import { Check, Copy, Moon, Plug, Sun } from 'lucide-react' import { Component, createContext, useCallback, useContext, useEffect, useRef, useState, type ErrorInfo, 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({ options, value, onChange }: { options: { value: T; label: ReactNode; disabled?: boolean; title?: string }[]; value: T; onChange: (v: T) => void }) { return (
{options.map((o) => ( ))}
) } export function Chip({ tone = '', children }: { tone?: 'ok' | 'warn' | 'bad' | 'accent' | ''; children: ReactNode }) { return {children} } export function Dot({ tone = '' }: { tone?: 'ok' | 'warn' | 'bad' | '' }) { return } export function Spinner() { return } export function Empty({ children }: { children: ReactNode }) { return
{children}
} export function Stat({ label, value, unit, tone = '' }: { label: string; value: ReactNode; unit?: string; tone?: 'ok' | 'warn' | 'bad' | '' }) { return (
{label} {value} {unit ? {unit} : null}
) } export function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) { return (
{label} {children} {hint ? {hint} : null}
) } 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 (
e.target === e.currentTarget && onClose()}>

{title}

{children} {actions ?
{actions}
: null}
) } /** 错误提示:把常见的错误码、字段名和校验信息翻成中文;没有收录的信息原样显示。 */ 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
{describeError(error)}
} /* ── 提示 ── */ 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([]) 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 ( {children}
{items.map((t) => (
{t.text}
))}
) } export const useToast = () => useContext(ToastContext) /** 执行一个操作,成功提示、失败显示错误。 */ export function useAction() { const toast = useToast() const [busy, setBusy] = useState(false) const run = useCallback( async (action: () => Promise, 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; onClose: () => void }) { const [busy, setBusy] = useState(false) return ( } >
{children}
) } export function CopyButton({ text, label = '复制' }: { text: string; label?: string }) { const [done, setDone] = useState(false) return ( ) } export function ThemeToggle() { const [theme, toggle] = useTheme() return ( ) } /** * 组件上的加载进度条:放在卡片(`position: relative`)里,贴着上边缘。 * 数据在加载时流动,不加载时不占地方。 */ export function LoadingBar({ active }: { active: boolean }) { return active ? (
) : 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 (
{tabs.map((t) => ( ))}
{tab.command}
) } /** * 页面出错时显示提示,而不是整页白屏:说明出了什么错,可以重试(重新渲染这一块)或刷新整个页面。 * 常见原因是前端和后端版本对不上(后端还没升级到新接口)。 */ 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 (

页面出错了

这一页没能显示出来。如果刚升级过,多半是前端和后端的版本对不上:先把后端升级到对应的版本,再刷新。

{error.message}
) } } /** 数据源设置:默认连同源的后端;填了地址就连别的后端(跨域用 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 ( <> {open ? ( setOpen(false)} actions={ <> } >

默认连这个页面所在的后端。填了地址就连另一个后端(比如本机跑前端、连线上的);此时不用 Cookie,改用 API 令牌,后端需要放行这个页面的来源(Monitor__CorsOrigins)。

setBase(e.target.value)} placeholder="https://mo.oeo.one" autoFocus /> setToken(e.target.value)} autoComplete="off" />
) : 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) }