Files
MoShiro/web/shared/ui/index.tsx
T

402 lines
14 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 { 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)
}