402 lines
14 KiB
TypeScript
402 lines
14 KiB
TypeScript
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<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>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 组件上的加载进度条:放在卡片(`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>
|
||
)
|
||
}
|
||
}
|
||
|
||
/** 数据源设置:默认连同源的后端;填了地址就连别的后端(跨域用 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)
|
||
}
|