280 lines
9.7 KiB
TypeScript
280 lines
9.7 KiB
TypeScript
import { Check, Copy, Moon, Plug, Sun } from 'lucide-react'
|
||
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react'
|
||
import { ApiError } from '../api/client'
|
||
import { getBase, getToken, normalizeBase, setSource } from '../api/source'
|
||
import { 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])
|
||
return (
|
||
<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>
|
||
)
|
||
}
|
||
|
||
/** 错误提示:把常见的错误码、字段名和校验信息翻成中文;没有收录的信息原样显示。 */
|
||
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>
|
||
)
|
||
}
|
||
|
||
/** 数据源设置:默认连同源的后端;填了地址就连别的后端(跨域用 API 令牌,后端要放行这个前端的来源)。 */
|
||
export function SourceButton({ tokenLabel = '只读令牌(站点私有时才需要)' }: { tokenLabel?: string }) {
|
||
const [open, setOpen] = useState(false)
|
||
const [base, setBase] = useState('')
|
||
const [token, setToken] = useState('')
|
||
const remote = getBase() !== ''
|
||
const show = () => {
|
||
setBase(getBase())
|
||
setToken(getToken())
|
||
setOpen(true)
|
||
}
|
||
const apply = (nextBase: string, nextToken: string) => {
|
||
setSource(nextBase, nextToken)
|
||
location.reload()
|
||
}
|
||
return (
|
||
<>
|
||
<button type="button" className={`btn icon${remote ? ' primary' : ''}`} onClick={show} aria-label="数据源" title={remote ? `数据源:${getBase()}` : '数据源:同源'}>
|
||
<Plug size={18} />
|
||
</button>
|
||
{open ? (
|
||
<Dialog
|
||
title="数据源"
|
||
onClose={() => setOpen(false)}
|
||
actions={
|
||
<>
|
||
<button type="button" className="btn" onClick={() => apply('', '')}>
|
||
恢复同源
|
||
</button>
|
||
<button type="button" className="btn primary" onClick={() => apply(base, token)}>
|
||
保存并刷新
|
||
</button>
|
||
</>
|
||
}
|
||
>
|
||
<div className="stack">
|
||
<p className="muted">默认连这个页面所在的后端。填了地址就连另一个后端(比如本机跑前端、连线上的);此时不用 Cookie,改用 API 令牌,后端需要放行这个页面的来源(<code>Monitor__CorsOrigins</code>)。</p>
|
||
<Field label="后端地址" hint={base && normalizeBase(base) !== base.trim() ? `将使用 ${normalizeBase(base)}` : '例如 https://mo.oeo.one;留空 = 同源'}>
|
||
<input className="input" value={base} onChange={(e) => setBase(e.target.value)} placeholder="https://mo.oeo.one" autoFocus />
|
||
</Field>
|
||
<Field label={tokenLabel}>
|
||
<input className="input" type="password" value={token} onChange={(e) => setToken(e.target.value)} autoComplete="off" />
|
||
</Field>
|
||
</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)
|
||
}
|