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({ 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]) // 渲染到 body 上:对话框是 position: fixed 的,如果放在带 backdrop-filter / transform 的祖先里(比如展示端的顶栏), // 它会以那个祖先为定位基准,被挤到顶栏里、一半在屏幕外 return createPortal(
e.target === e.currentTarget && onClose()}>

{title}

{children} {actions ?
{actions}
: null}
, 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
{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}
) } } /** 地址的简短写法:去掉协议,留 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' ? ( ) : ( )} {open ? ( setOpen(false)} actions={ } >
{rows.length > 0 ? (
{rows.map((row) => { const on = row.base === active return (
{row.base ? ( ) : null}
) })}
) : (

还没有保存后端。填上后端地址和用户名、密码,就可以连上去;不填则使用 {location.origin} 的 API。

)} {adding ? (
setBase(e.target.value)} placeholder="https://mo.oeo.one" autoFocus /> setUsername(e.target.value)} /> setPassword(e.target.value)} /> {error ?
{error}
: null}
{rows.length > 0 ? ( ) : null}
) : (
)}
) : 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) }