Files
MoShiro/web/shared/ui/index.tsx
T
justme d1be70cda2 feat(web): 线路详情页的时间轴、EWMA 平滑和加载进度
- 先选日期,再在连续 48 小时(或 24 小时)的丢包缩略图上拖选区;选区可以跨零点,
  点轴或刻度时离得近的端点贴过去,悬停预览会动的端点;没有数据的时段画成斜纹
- 刻度下方的时长快捷档:30 分钟、1 小时、3 小时、6 小时、12 小时、整天
- 指标卡片平铺,丢包等标明 UDP/TCP;图表按 UDP、TCP、评分分组
- 图表数据刷新时原地更新(不再整张重建),毫秒到 1 秒以上显示成秒
- EWMA 平滑开关(关/轻/中/重),原始数据以淡线垫底
- 数据加载时卡片上显示进度条和占位;useQuery 区分定时刷新和用户触发的加载
- 前端可以在页面里指定后端地址和令牌(配合后端 Monitor__CorsOrigins)
2026-10-06 14:48:58 +08:00

292 lines
10 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>
)
}
/**
* 组件上的加载进度条:放在卡片(`position: relative`)里,贴着上边缘。
* 数据在加载时流动,不加载时不占地方。
*/
export function LoadingBar({ active }: { active: boolean }) {
return active ? (
<div className="loading-bar" role="progressbar" aria-label="加载中">
<i />
</div>
) : null
}
/** 数据源设置:默认连同源的后端;填了地址就连别的后端(跨域用 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)
}