Namespaces, assemblies, projects and directories, the solution, binaries, images, systemd units, config paths, environment variables, protocol method names, the media type and the raw chunk magic all move to MoShiro/moshiro. Existing data and deployments are not compatible and must be recreated. Also fix the architecture tests after the solution layout change and make the update tests independent of their order.
511 lines
19 KiB
TypeScript
511 lines
19 KiB
TypeScript
import { Check, Copy, Moon, Plug, Plus, Sun, X } from 'lucide-react'
|
||
import { createPortal } from 'react-dom'
|
||
import { Component, createContext, useCallback, useContext, useEffect, useRef, useState, type ErrorInfo, type ReactNode } from 'react'
|
||
import { ApiError, loginTo } from '../api/client'
|
||
import { activateSource, getBase, listSources, normalizeBase, pingBackend, removeSource, setSource } from '../api/source'
|
||
import { useQuery, useTheme } from '../hooks'
|
||
import { translateCode, translateField, translateMessage } from '../i18n'
|
||
|
||
/** 连接式分组(单选):组内小圆角、外角大圆角,选中段变胶囊。 */
|
||
export function Segmented<T extends string>({ options, value, onChange }: { options: { value: T; label: ReactNode; disabled?: boolean; title?: string }[]; value: T; onChange: (v: T) => void }) {
|
||
return (
|
||
<div className="group" role="tablist">
|
||
{options.map((o) => (
|
||
<button key={o.value} type="button" role="tab" aria-selected={o.value === value} className={o.value === value ? 'on' : ''} disabled={o.disabled} title={o.title} onClick={() => onChange(o.value)}>
|
||
{o.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export function Chip({ tone = '', children }: { tone?: 'ok' | 'warn' | 'bad' | 'accent' | ''; children: ReactNode }) {
|
||
return <span className={`chip ${tone}`}>{children}</span>
|
||
}
|
||
|
||
export function Dot({ tone = '' }: { tone?: 'ok' | 'warn' | 'bad' | '' }) {
|
||
return <span className={`dot ${tone}`} />
|
||
}
|
||
|
||
export function Spinner() {
|
||
return <span className="spinner" role="status" aria-label="加载中" />
|
||
}
|
||
|
||
export function Empty({ children }: { children: ReactNode }) {
|
||
return <div className="empty">{children}</div>
|
||
}
|
||
|
||
export function Stat({ label, value, unit, tone = '' }: { label: string; value: ReactNode; unit?: string; tone?: 'ok' | 'warn' | 'bad' | '' }) {
|
||
return (
|
||
<div className="stat">
|
||
<span className="k">{label}</span>
|
||
<span className={`v ${tone}`}>
|
||
{value}
|
||
{unit ? <small>{unit}</small> : null}
|
||
</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
|
||
return (
|
||
<div className="field">
|
||
<span className="label">{label}</span>
|
||
{children}
|
||
{hint ? <span className="hint">{hint}</span> : null}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export function Dialog({ title, onClose, children, actions }: { title: string; onClose: () => void; children: ReactNode; actions?: ReactNode }) {
|
||
useEffect(() => {
|
||
const onKey = (e: KeyboardEvent) => {
|
||
if (e.key === 'Escape') onClose()
|
||
}
|
||
window.addEventListener('keydown', onKey)
|
||
return () => window.removeEventListener('keydown', onKey)
|
||
}, [onClose])
|
||
// 渲染到 body 上:对话框是 position: fixed 的,如果放在带 backdrop-filter / transform 的祖先里(比如展示端的顶栏),
|
||
// 它会以那个祖先为定位基准,被挤到顶栏里、一半在屏幕外
|
||
return createPortal(
|
||
<div className="scrim" onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
|
||
<div className="dialog" role="dialog" aria-modal="true" aria-label={title}>
|
||
<h2>{title}</h2>
|
||
{children}
|
||
{actions ? <div className="actions">{actions}</div> : null}
|
||
</div>
|
||
</div>,
|
||
document.body,
|
||
)
|
||
}
|
||
|
||
/** 错误提示:把常见的错误码、字段名和校验信息翻成中文;没有收录的信息原样显示。 */
|
||
export function describeError(e: unknown): string {
|
||
if (e instanceof ApiError) {
|
||
const fields = Object.entries(e.errors).flatMap(([field, messages]) => messages.map((m) => `${translateField(field)}:${translateMessage(m)}`))
|
||
if (fields.length > 0) return fields.join(';')
|
||
const byCode = translateCode(e.code)
|
||
// 有具体说明的(例如“要覆盖的模板不存在”)优先给说明,翻不出来就用错误码对应的话
|
||
const detail = e.detail ? translateMessage(e.detail) : ''
|
||
if (detail && detail !== e.detail) return detail
|
||
return byCode ?? (detail || e.code)
|
||
}
|
||
return e instanceof Error ? e.message : String(e)
|
||
}
|
||
|
||
export function ErrorNotice({ error }: { error: unknown }) {
|
||
if (!error) return null
|
||
const privateSite = error instanceof ApiError && error.code === 'site_private'
|
||
return <div className={`notice ${privateSite ? '' : 'bad'}`}>{describeError(error)}</div>
|
||
}
|
||
|
||
/* ── 提示 ── */
|
||
interface ToastItem {
|
||
id: number
|
||
text: string
|
||
bad: boolean
|
||
}
|
||
|
||
const ToastContext = createContext<(text: string, bad?: boolean) => void>(() => undefined)
|
||
|
||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||
const [items, setItems] = useState<ToastItem[]>([])
|
||
const nextId = useRef(1)
|
||
const push = useCallback((text: string, bad = false) => {
|
||
const id = nextId.current++
|
||
setItems((list) => [...list, { id, text, bad }])
|
||
window.setTimeout(() => setItems((list) => list.filter((t) => t.id !== id)), bad ? 6000 : 3000)
|
||
}, [])
|
||
return (
|
||
<ToastContext.Provider value={push}>
|
||
{children}
|
||
<div className="toasts" aria-live="polite">
|
||
{items.map((t) => (
|
||
<div key={t.id} className={`toast ${t.bad ? 'bad' : ''}`}>
|
||
{t.text}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</ToastContext.Provider>
|
||
)
|
||
}
|
||
|
||
export const useToast = () => useContext(ToastContext)
|
||
|
||
/** 执行一个操作,成功提示、失败显示错误。 */
|
||
export function useAction() {
|
||
const toast = useToast()
|
||
const [busy, setBusy] = useState(false)
|
||
const run = useCallback(
|
||
async (action: () => Promise<unknown>, done?: string) => {
|
||
setBusy(true)
|
||
try {
|
||
await action()
|
||
if (done) toast(done)
|
||
return true
|
||
} catch (e) {
|
||
toast(describeError(e), true)
|
||
return false
|
||
} finally {
|
||
setBusy(false)
|
||
}
|
||
},
|
||
[toast],
|
||
)
|
||
return { busy, run }
|
||
}
|
||
|
||
/** 确认对话框。 */
|
||
export function Confirm({ title, children, confirmText = '确定', danger, onConfirm, onClose }: { title: string; children: ReactNode; confirmText?: string; danger?: boolean; onConfirm: () => void | Promise<void>; onClose: () => void }) {
|
||
const [busy, setBusy] = useState(false)
|
||
return (
|
||
<Dialog
|
||
title={title}
|
||
onClose={onClose}
|
||
actions={
|
||
<>
|
||
<button type="button" className="btn text" onClick={onClose}>
|
||
取消
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`btn ${danger ? 'danger' : 'primary'}`}
|
||
disabled={busy}
|
||
onClick={async () => {
|
||
setBusy(true)
|
||
try {
|
||
await onConfirm()
|
||
} finally {
|
||
setBusy(false)
|
||
onClose()
|
||
}
|
||
}}
|
||
>
|
||
{confirmText}
|
||
</button>
|
||
</>
|
||
}
|
||
>
|
||
<div className="muted">{children}</div>
|
||
</Dialog>
|
||
)
|
||
}
|
||
|
||
export function CopyButton({ text, label = '复制' }: { text: string; label?: string }) {
|
||
const [done, setDone] = useState(false)
|
||
return (
|
||
<button
|
||
type="button"
|
||
className="btn small"
|
||
onClick={async () => {
|
||
try {
|
||
await navigator.clipboard.writeText(text)
|
||
setDone(true)
|
||
window.setTimeout(() => setDone(false), 1500)
|
||
} catch {
|
||
// 没有剪贴板权限时用户可以手动选中
|
||
}
|
||
}}
|
||
>
|
||
{done ? <Check size={14} /> : <Copy size={14} />}
|
||
{done ? '已复制' : label}
|
||
</button>
|
||
)
|
||
}
|
||
|
||
export function ThemeToggle() {
|
||
const [theme, toggle] = useTheme()
|
||
return (
|
||
<button type="button" className="btn icon" onClick={toggle} aria-label="切换明暗主题" title="切换明暗主题">
|
||
{theme === 'dark' ? <Sun size={18} /> : <Moon size={18} />}
|
||
</button>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 组件上的加载进度条:放在卡片(`position: relative`)里,贴着上边缘。
|
||
* 数据在加载时流动,不加载时不占地方。
|
||
*/
|
||
export function LoadingBar({ active }: { active: boolean }) {
|
||
return active ? (
|
||
<div className="loading-bar" role="progressbar" aria-label="加载中">
|
||
<i />
|
||
</div>
|
||
) : null
|
||
}
|
||
|
||
export interface CommandTab {
|
||
id: string
|
||
label: string
|
||
command: string
|
||
}
|
||
|
||
/** 按浏览器所在的系统挑一个默认的标签(Windows / macOS / 其他当 Linux)。 */
|
||
export function guessPlatform(): 'windows' | 'macos' | 'linux' {
|
||
const text = `${navigator.platform} ${navigator.userAgent}`.toLowerCase()
|
||
return text.includes('win') ? 'windows' : text.includes('mac') ? 'macos' : 'linux'
|
||
}
|
||
|
||
/**
|
||
* 带标签切换的命令框(curl / npm / bun 那样):上面一排下划线标签,下面是等宽的命令,右侧一个复制图标。
|
||
* 记住上次选的标签(只是浏览器里的小方便)。
|
||
*/
|
||
export function CommandTabs({ tabs, initial, storageKey }: { tabs: CommandTab[]; initial?: string; storageKey?: string }) {
|
||
const [active, setActive] = useState(() => {
|
||
let saved: string | null = null
|
||
try {
|
||
saved = storageKey ? localStorage.getItem(storageKey) : null
|
||
} catch {
|
||
// 浏览器禁用存储时只是不记住
|
||
}
|
||
return [saved, initial].find((id) => id && tabs.some((t) => t.id === id)) ?? tabs[0]?.id ?? ''
|
||
})
|
||
const [done, setDone] = useState(false)
|
||
const tab = tabs.find((t) => t.id === active) ?? tabs[0]
|
||
if (!tab) return null
|
||
const pick = (id: string) => {
|
||
setActive(id)
|
||
try {
|
||
if (storageKey) localStorage.setItem(storageKey, id)
|
||
} catch {
|
||
// 同上
|
||
}
|
||
}
|
||
return (
|
||
<div className="cmd-tabs">
|
||
<div className="cmd-tablist" role="tablist">
|
||
{tabs.map((t) => (
|
||
<button key={t.id} type="button" role="tab" aria-selected={t.id === tab.id} className={t.id === tab.id ? 'on' : ''} onClick={() => pick(t.id)}>
|
||
{t.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="cmd-body">
|
||
<code>{tab.command}</code>
|
||
<button
|
||
type="button"
|
||
className="cmd-copy"
|
||
aria-label="复制命令"
|
||
title="复制命令"
|
||
onClick={async () => {
|
||
try {
|
||
await navigator.clipboard.writeText(tab.command)
|
||
setDone(true)
|
||
window.setTimeout(() => setDone(false), 1500)
|
||
} catch {
|
||
// 没有剪贴板权限时用户可以手动选中
|
||
}
|
||
}}
|
||
>
|
||
{done ? <Check size={16} /> : <Copy size={16} />}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 页面出错时显示提示,而不是整页白屏:说明出了什么错,可以重试(重新渲染这一块)或刷新整个页面。
|
||
* 常见原因是前端和后端版本对不上(后端还没升级到新接口)。
|
||
*/
|
||
export class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> {
|
||
state: { error: Error | null } = { error: null }
|
||
|
||
static getDerivedStateFromError(error: Error) {
|
||
return { error }
|
||
}
|
||
|
||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||
console.error(error, info.componentStack)
|
||
}
|
||
|
||
render() {
|
||
const { error } = this.state
|
||
if (!error) return this.props.children
|
||
return (
|
||
<div className="login">
|
||
<div className="card stack" style={{ maxWidth: 520 }}>
|
||
<div>
|
||
<h1>页面出错了</h1>
|
||
<p className="muted">这一页没能显示出来。如果刚升级过,多半是前端和后端的版本对不上:先把后端升级到对应的版本,再刷新。</p>
|
||
</div>
|
||
<pre className="code">{error.message}</pre>
|
||
<div className="row" style={{ gap: 8 }}>
|
||
<button type="button" className="btn primary" onClick={() => this.setState({ error: null })}>
|
||
重试
|
||
</button>
|
||
<button type="button" className="btn" onClick={() => location.reload()}>
|
||
刷新页面
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
}
|
||
|
||
/** 地址的简短写法:去掉协议,留 host(和端口)。 */
|
||
const shortHost = (base: string) => base.replace(/^https?:\/\//i, '')
|
||
|
||
/**
|
||
* 数据源:默认连这个页面所在的后端(同源);也可以保存几个别的后端(比如本机跑前端、连线上的),在列表里点一下就切换。
|
||
* 跨域时不用 Cookie,改用 API 令牌,所以每个后端各存一个令牌(只在浏览器里),后端需要放行这个页面的来源(`MoShiro__CorsOrigins`)。
|
||
*/
|
||
export function SourceButton({ variant = 'icon' }: { variant?: 'icon' | 'status' }) {
|
||
const [open, setOpen] = useState(false)
|
||
const [adding, setAdding] = useState(listSources().length === 0)
|
||
const [base, setBase] = useState('')
|
||
const [username, setUsername] = useState('')
|
||
const [password, setPassword] = useState('')
|
||
const [error, setError] = useState('')
|
||
const [busy, setBusy] = useState(false)
|
||
const [, refresh] = useState(0)
|
||
const active = getBase()
|
||
const saved = listSources()
|
||
const switchTo = (next: string) => {
|
||
if (next !== active) {
|
||
activateSource(next)
|
||
location.reload()
|
||
}
|
||
}
|
||
// 状态条用:每 15 秒问一下当前后端还连不连得上
|
||
const health = useQuery(() => (variant === 'status' ? pingBackend() : Promise.resolve(undefined)), [variant, active], variant === 'status' ? 15_000 : undefined)
|
||
const state = health.data === false ? 'bad' : health.data === undefined ? 'wait' : 'ok'
|
||
const rows = saved.map((x) => ({ base: x.base, label: shortHost(x.base), detail: x.token ? '已登录' : '未登录' }))
|
||
// 连接中的排在最前面
|
||
rows.sort((a, b) => Number(b.base === active) - Number(a.base === active))
|
||
return (
|
||
<>
|
||
{variant === 'status' ? (
|
||
<button type="button" className={`src-status ${state}`} onClick={() => setOpen(true)} aria-label="数据源" title={`后端:${active || location.origin}(点击切换)`}>
|
||
<span className="st-dot" />
|
||
<span className="st-host">{shortHost(active || location.origin)}</span>
|
||
<span className="st">{state === 'ok' ? '已连接' : state === 'bad' ? '无法连接' : '连接中…'}</span>
|
||
</button>
|
||
) : (
|
||
<button type="button" className={`btn icon${active ? ' primary' : ''}`} onClick={() => setOpen(true)} aria-label="数据源" title={active ? `后端:${active}` : `后端:${location.origin}`}>
|
||
<Plug size={18} />
|
||
</button>
|
||
)}
|
||
{open ? (
|
||
<Dialog
|
||
title="数据源"
|
||
onClose={() => setOpen(false)}
|
||
actions={
|
||
<button type="button" className="btn" onClick={() => setOpen(false)}>
|
||
关闭
|
||
</button>
|
||
}
|
||
>
|
||
<div className="stack">
|
||
{rows.length > 0 ? (
|
||
<div className="src-list" role="listbox" aria-label="已保存的后端">
|
||
{rows.map((row) => {
|
||
const on = row.base === active
|
||
return (
|
||
<div key={row.base || 'same-origin'} className={`src-row${on ? ' on' : ''}`}>
|
||
<button type="button" role="option" aria-selected={on} className="src-pick" onClick={() => switchTo(row.base)} title={on ? '正在连接' : '点击切换到这个后端'}>
|
||
<span className="src-dot" />
|
||
<span className="src-text">
|
||
<b>{row.label}</b>
|
||
<small>{row.detail}</small>
|
||
</span>
|
||
{on ? <span className="chip accent">连接中</span> : null}
|
||
</button>
|
||
{row.base ? (
|
||
<button
|
||
type="button"
|
||
className="btn icon small"
|
||
aria-label="忘掉这个后端"
|
||
title={on ? '断开并忘掉这个后端' : '忘掉这个后端'}
|
||
onClick={() => {
|
||
removeSource(row.base)
|
||
if (on) location.reload()
|
||
else refresh((n) => n + 1)
|
||
}}
|
||
>
|
||
<X size={14} />
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
) : (
|
||
<p className="muted">还没有保存后端。填上后端地址和用户名、密码,就可以连上去;不填则使用 {location.origin} 的 API。</p>
|
||
)}
|
||
{adding ? (
|
||
<div className="stack">
|
||
<Field label="后端地址" hint={base && normalizeBase(base) !== base.trim() ? `将使用 ${normalizeBase(base)}` : '例如 https://mo.oeo.one'}>
|
||
<input className="input" name="backend-url" type="url" autoComplete="url" value={base} onChange={(e) => setBase(e.target.value)} placeholder="https://mo.oeo.one" autoFocus />
|
||
</Field>
|
||
<Field label="用户名" hint="站点公开、只想看数据的话可以留空">
|
||
<input className="input" name="backend-username" autoComplete="off" value={username} onChange={(e) => setUsername(e.target.value)} />
|
||
</Field>
|
||
<Field label="密码">
|
||
<input className="input" name="backend-password" type="password" autoComplete="new-password" value={password} onChange={(e) => setPassword(e.target.value)} />
|
||
</Field>
|
||
{error ? <div className="notice bad">{error}</div> : null}
|
||
<div className="row" style={{ gap: 8 }}>
|
||
<button
|
||
type="button"
|
||
className="btn primary"
|
||
disabled={!normalizeBase(base) || busy}
|
||
onClick={async () => {
|
||
setBusy(true)
|
||
setError('')
|
||
try {
|
||
// 填了用户名就先登录(拿回会话存起来),登录成功才保存这个后端
|
||
setSource(base, username ? await loginTo(normalizeBase(base), username, password) : '')
|
||
location.reload()
|
||
} catch (err) {
|
||
setError(describeError(err))
|
||
} finally {
|
||
setBusy(false)
|
||
}
|
||
}}
|
||
>
|
||
保存并连接
|
||
</button>
|
||
{rows.length > 0 ? (
|
||
<button type="button" className="btn" onClick={() => setAdding(false)}>
|
||
取消
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div>
|
||
<button
|
||
type="button"
|
||
className="btn small"
|
||
onClick={() => {
|
||
setBase('')
|
||
setUsername('')
|
||
setPassword('')
|
||
setError('')
|
||
setAdding(true)
|
||
}}
|
||
>
|
||
<Plus size={14} />
|
||
添加后端
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Dialog>
|
||
) : null}
|
||
</>
|
||
)
|
||
}
|
||
|
||
/** 下载文本文件(probe.json 只给一次)。 */
|
||
export function downloadText(filename: string, text: string) {
|
||
const url = URL.createObjectURL(new Blob([text], { type: 'application/json' }))
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
a.download = filename
|
||
a.click()
|
||
URL.revokeObjectURL(url)
|
||
}
|