Files
MoShiro/web/shared/ui/index.tsx
T
2026-10-04 21:46:39 +08:00

280 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}