- types.ts: 添加 agent_weights / status 字段 - components.tsx: 添加 AgentWeightsBar 纯 CSS 权重分布条 + EmptyText 组件 - Predictions.tsx: 摘要行 degraded/failed 状态徽章、展开区 AgentWeightsBar、 预测中防连点、中文错误映射 - EvalPage.tsx: 修复 EmptyText 导入 构建验证:tsc && vite build 通过
439 lines
15 KiB
TypeScript
439 lines
15 KiB
TypeScript
/**
|
|
* Admin 后台 - 通用 UI 组件集合(报刊风)
|
|
*
|
|
* 与前台共用同一套设计语言:
|
|
* - 纸色底(paper)、墨色字(ink)、印报红唯一强调(press)
|
|
* - 方正边框、细线分隔、宋体标题、无圆角、无彩色药丸标签
|
|
*/
|
|
|
|
import { ReactNode } from 'react'
|
|
|
|
import { ApiError } from './api'
|
|
|
|
// ── 卡片 ────────────────────────────────────────────────────────
|
|
|
|
export function Card({
|
|
children,
|
|
className = '',
|
|
}: {
|
|
children: ReactNode
|
|
className?: string
|
|
}) {
|
|
return (
|
|
<div className={`border border-ink-900 bg-paper-50 ${className}`}>{children}</div>
|
|
)
|
|
}
|
|
|
|
export function CardHeader({
|
|
title,
|
|
description,
|
|
action,
|
|
}: {
|
|
title: string
|
|
description?: string
|
|
action?: ReactNode
|
|
}) {
|
|
return (
|
|
<div className="border-b border-ink-900 bg-paper-100 px-4 py-2.5 sm:px-5">
|
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
<h3 className="font-serif text-sm font-bold text-ink-900">{title}</h3>
|
|
{action}
|
|
</div>
|
|
{description && <p className="mt-1 text-2xs text-ink-500">{description}</p>}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function CardBody({
|
|
children,
|
|
className = '',
|
|
}: {
|
|
children: ReactNode
|
|
className?: string
|
|
}) {
|
|
return <div className={`px-4 py-4 sm:px-5 ${className}`}>{children}</div>
|
|
}
|
|
|
|
// ── 统计卡片 ────────────────────────────────────────────────────
|
|
|
|
export function StatCard({
|
|
label,
|
|
value,
|
|
hint,
|
|
}: {
|
|
label: string
|
|
value: string | number
|
|
hint?: string
|
|
}) {
|
|
return (
|
|
<div className="border border-ink-900 bg-paper-50 px-4 py-3.5">
|
|
<span className="text-2xs tracking-[0.2em] text-ink-400">{label}</span>
|
|
<div className="mt-1.5 font-serif text-3xl font-bold tabular-nums leading-none text-ink-900">
|
|
{value}
|
|
</div>
|
|
{hint && <div className="mt-1.5 text-2xs text-ink-400">{hint}</div>}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── 状态标记 ────────────────────────────────────────────────────
|
|
// 报刊不用彩色药丸:小方块 + 文字,红=异常/失败,墨=正常,灰=中性
|
|
|
|
const MARK_STYLES: Record<string, { text: string; mark: string }> = {
|
|
success: { text: 'text-ink-800', mark: 'bg-ink-900' },
|
|
completed: { text: 'text-ink-800', mark: 'bg-ink-900' },
|
|
win: { text: 'text-ink-800', mark: 'bg-ink-900' },
|
|
ok: { text: 'text-ink-800', mark: 'bg-ink-900' },
|
|
running: { text: 'text-ink-600', mark: 'bg-ink-400' },
|
|
info: { text: 'text-ink-600', mark: 'bg-ink-400' },
|
|
queued: { text: 'text-ink-500', mark: 'border border-ink-400' },
|
|
pending: { text: 'text-ink-400', mark: 'bg-ink-300' },
|
|
push: { text: 'text-ink-400', mark: 'bg-ink-300' },
|
|
warning: { text: 'text-press', mark: 'border border-press' },
|
|
failed: { text: 'text-press font-medium', mark: 'bg-press' },
|
|
error: { text: 'text-press font-medium', mark: 'bg-press' },
|
|
loss: { text: 'text-press font-medium', mark: 'bg-press' },
|
|
}
|
|
|
|
export function Badge({
|
|
status,
|
|
children,
|
|
}: {
|
|
status: string
|
|
children: ReactNode
|
|
}) {
|
|
const s = MARK_STYLES[status] ?? MARK_STYLES.pending
|
|
return (
|
|
<span className={`inline-flex items-center gap-1.5 whitespace-nowrap text-2xs ${s.text}`}>
|
|
<span className={`inline-block h-1.5 w-1.5 ${s.mark}`} aria-hidden="true" />
|
|
{children}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
// ── 数据表格 ────────────────────────────────────────────────────
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
export function DataTable<T = any>({
|
|
columns,
|
|
data,
|
|
rowKey,
|
|
emptyText = '暂无数据',
|
|
}: {
|
|
columns: { key: string; label: string; render?: (row: T) => ReactNode; width?: string }[]
|
|
data: T[]
|
|
rowKey: (row: T) => string | number
|
|
emptyText?: string
|
|
}) {
|
|
if (data.length === 0) {
|
|
return <EmptyState text={emptyText} />
|
|
}
|
|
|
|
return (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-left text-sm">
|
|
<thead>
|
|
<tr className="border-b border-ink-900 text-2xs tracking-wider text-ink-500">
|
|
{columns.map(col => (
|
|
<th key={col.key} className="px-3 py-2 font-medium" style={{ width: col.width }}>
|
|
{col.label}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{data.map(row => (
|
|
<tr
|
|
key={rowKey(row)}
|
|
className="border-b border-ink-200 transition-colors hover:bg-paper-100"
|
|
>
|
|
{columns.map(col => (
|
|
<td key={col.key} className="px-3 py-2.5 text-ink-800">
|
|
{col.render
|
|
? col.render(row)
|
|
: row != null && typeof row === 'object' && col.key in row
|
|
? String((row as Record<string, unknown>)[col.key] ?? '—')
|
|
: '—'}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── 进度条:同前台置信度细线 ────────────────────────────────────
|
|
|
|
export function ProgressBar({ value, className = '' }: { value: number; className?: string }) {
|
|
const clamped = Math.max(0, Math.min(100, value))
|
|
return (
|
|
<div className={`h-2 w-full overflow-hidden rounded-full bg-ink-200 ${className}`} role="progressbar" aria-valuenow={clamped}>
|
|
<div
|
|
className="h-full rounded-full bg-press transition-[width] duration-500"
|
|
style={{ width: `${clamped}%` }}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── 空状态:同前台「本版暂无赛程」 ──────────────────────────────
|
|
|
|
export function EmptyState({ text = '暂无数据', sub }: { text?: string; sub?: string }) {
|
|
return (
|
|
<div className="border-y border-ink-200 py-12 text-center">
|
|
<p className="font-serif text-sm text-ink-600">{text}</p>
|
|
{sub && <p className="mt-1.5 text-xs text-ink-400">{sub}</p>}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── 移动端卡片列表 (替代桌面端表格) ────────────────────────────
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
export function MobileCardList<T = any>({
|
|
data,
|
|
renderCard,
|
|
emptyText = '暂无数据',
|
|
}: {
|
|
data: T[]
|
|
renderCard: (row: T, index: number) => ReactNode
|
|
emptyText?: string
|
|
}) {
|
|
if (data.length === 0) {
|
|
return <EmptyState text={emptyText} />
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-3 lg:hidden">
|
|
{data.map((row, idx) => (
|
|
<div key={idx} className="border border-ink-900 bg-paper-50 p-4">
|
|
{renderCard(row, idx)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── 响应式表格容器 ──────────────────────────────────────────────
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
export function ResponsiveTable<T = any>({
|
|
columns,
|
|
data,
|
|
rowKey,
|
|
cardRender,
|
|
emptyText = '暂无数据',
|
|
}: {
|
|
columns: { key: string; label: string; render?: (row: T) => ReactNode; width?: string }[]
|
|
data: T[]
|
|
rowKey: (row: T) => string | number
|
|
cardRender: (row: T, index: number) => ReactNode
|
|
emptyText?: string
|
|
}) {
|
|
if (data.length === 0) {
|
|
return <EmptyState text={emptyText} />
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{/* 桌面端表格 */}
|
|
<div className="hidden overflow-x-auto lg:block">
|
|
<DataTable columns={columns} data={data} rowKey={rowKey} emptyText={emptyText} />
|
|
</div>
|
|
{/* 移动端卡片 */}
|
|
<MobileCardList data={data} renderCard={cardRender} emptyText={emptyText} />
|
|
</>
|
|
)
|
|
}
|
|
|
|
// ── 小节标题:同前台 section-head ───────────────────────────────
|
|
|
|
export function SectionHeader({
|
|
title,
|
|
description,
|
|
action,
|
|
}: {
|
|
title: string
|
|
description?: string
|
|
action?: ReactNode
|
|
}) {
|
|
return (
|
|
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
|
|
<div>
|
|
<h2 className="section-head text-base">{title}</h2>
|
|
{description && <p className="mt-1.5 text-xs text-ink-500">{description}</p>}
|
|
</div>
|
|
{action}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── 提示条:错误红框(同前台) / 正常墨框 ────────────────────────
|
|
|
|
export function Alert({
|
|
kind,
|
|
title,
|
|
message,
|
|
onClose,
|
|
action,
|
|
}: {
|
|
kind: 'error' | 'ok' | 'info' | 'warning'
|
|
title: string
|
|
message?: string
|
|
onClose?: () => void
|
|
/** 右侧操作按钮(如「去修复」) */
|
|
action?: ReactNode
|
|
}) {
|
|
const style =
|
|
kind === 'error'
|
|
? 'border-press bg-press-wash'
|
|
: kind === 'warning'
|
|
? 'border-press bg-press-wash/60'
|
|
: kind === 'ok'
|
|
? 'border-ink-900 bg-paper-100'
|
|
: 'border-ink-300 bg-paper-50'
|
|
const titleCls = kind === 'error' || kind === 'warning' ? 'text-press' : 'text-ink-900'
|
|
|
|
return (
|
|
<div className={`flex items-start justify-between gap-3 border px-4 py-3 ${style}`}>
|
|
<div>
|
|
<p className={`flex items-center gap-1.5 text-sm font-medium ${titleCls}`}>
|
|
<span
|
|
className={`inline-block h-1.5 w-1.5 ${kind === 'error' || kind === 'warning' ? 'bg-press' : 'bg-ink-900'}`}
|
|
aria-hidden="true"
|
|
/>
|
|
{title}
|
|
</p>
|
|
{message && (
|
|
<p className="mt-0.5 whitespace-pre-wrap text-xs leading-relaxed text-ink-600">
|
|
{message}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{action}
|
|
{onClose && (
|
|
<button
|
|
onClick={onClose}
|
|
className="text-ink-400 transition-colors hover:text-ink-900"
|
|
aria-label="关闭"
|
|
>
|
|
<svg viewBox="0 0 20 20" className="h-4 w-4" fill="currentColor" aria-hidden="true">
|
|
<path d="M6.3 5.3a1 1 0 011.4 0L10 7.6l2.3-2.3a1 1 0 111.4 1.4L11.4 9l2.3 2.3a1 1 0 01-1.4 1.4L10 10.4l-2.3 2.3a1 1 0 01-1.4-1.4L8.6 9 6.3 6.7a1 1 0 010-1.4z" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── 错误横幅:标准化错误标题/文案/建议动作 ──────────────────────
|
|
|
|
/** 根据错误对象生成标准化的错误标题、文案与建议动作。 */
|
|
export function describeError(err: unknown): { title: string; detail: string; kind: 'error' | 'warning' } {
|
|
if (err instanceof ApiError) {
|
|
const status = err.status
|
|
const apiDetail = typeof err.data === 'object' && err.data && 'detail' in (err.data as object)
|
|
? String((err.data as { detail: unknown }).detail)
|
|
: ''
|
|
const msg = apiDetail || err.message
|
|
switch (status) {
|
|
case 401:
|
|
return { title: '登录已过期', detail: '请重新登录后继续操作。', kind: 'warning' }
|
|
case 403:
|
|
return { title: '无权访问', detail: msg || '当前账号没有执行该操作的权限。', kind: 'error' }
|
|
case 429:
|
|
return { title: '请求过于频繁', detail: msg || '每分钟最多 10 次预测,请稍后再试。', kind: 'warning' }
|
|
case 502:
|
|
return { title: '上游 LLM 不可用', detail: msg || 'LLM 服务暂时不可用,请稍后重试或切换到更便宜的模型。', kind: 'error' }
|
|
case 503:
|
|
return { title: '服务未就绪', detail: msg || '服务器鉴权未配置,请联系管理员。', kind: 'error' }
|
|
case 0:
|
|
return { title: '网络错误或请求超时', detail: '请检查网络连接后重试。', kind: 'warning' }
|
|
}
|
|
if (status >= 500) {
|
|
return { title: '服务器错误', detail: msg || `HTTP ${status},请稍后重试。`, kind: 'error' }
|
|
}
|
|
return { title: '请求失败', detail: msg || `HTTP ${status}`, kind: 'error' }
|
|
}
|
|
if (err instanceof Error) {
|
|
return { title: '操作失败', detail: err.message, kind: 'error' }
|
|
}
|
|
return { title: '未知错误', detail: String(err), kind: 'error' }
|
|
}
|
|
|
|
/** 统一错误横幅:用于页面级错误展示。 */
|
|
export function ErrorBanner({
|
|
err,
|
|
onClose,
|
|
}: {
|
|
err: unknown
|
|
onClose?: () => void
|
|
}) {
|
|
const { title, detail, kind } = describeError(err)
|
|
return <Alert kind={kind} title={title} message={detail} onClose={onClose} />
|
|
}
|
|
|
|
export function Spinner({ className = '' }: { className?: string }) {
|
|
return (
|
|
<svg
|
|
viewBox="0 0 20 20"
|
|
className={`h-3.5 w-3.5 animate-spin ${className}`}
|
|
fill="none"
|
|
aria-hidden="true"
|
|
>
|
|
<circle cx="10" cy="10" r="7.5" stroke="currentColor" strokeWidth="1.5" strokeOpacity="0.25" />
|
|
<path d="M17.5 10A7.5 7.5 0 0010 2.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
// ── 骨架占位 ────────────────────────────────────────────────────
|
|
|
|
export function SkeletonBlock({ className = '' }: { className?: string }) {
|
|
return <div className={`skeleton ${className}`} />
|
|
}
|
|
|
|
|
|
/** 空状态文本 */
|
|
export function EmptyText({ text }: { text: string }) {
|
|
return (
|
|
<div className="py-10 text-center text-sm text-ink-400">
|
|
{text}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** Agent 权重条形图 */
|
|
export function AgentWeightsBar({ weights, okCount }: { weights: Record<string, number>; okCount: number }) {
|
|
const entries = Object.entries(weights).filter(([, w]) => w > 0)
|
|
if (entries.length === 0) return null
|
|
const total = entries.reduce((s, [, w]) => s + w, 0) || 1
|
|
const colors = ['bg-ink-900', 'bg-ink-700', 'bg-ink-500', 'bg-press', 'bg-ink-300']
|
|
return (
|
|
<div className="mt-2 border-t border-ink-200 pt-2">
|
|
<div className="mb-1 text-2xs text-ink-400">终裁专家权重</div>
|
|
<div className="space-y-1">
|
|
{entries.map(([k, w], i) => (
|
|
<div key={k} className="flex items-center gap-2 text-2xs">
|
|
<div className="h-3.5 flex-1 overflow-hidden rounded-sm bg-ink-200/60">
|
|
<div
|
|
className={`h-full ${colors[i % colors.length]} transition-all duration-500`}
|
|
style={{ width: `${Math.max(3, Math.round((w / total) * 100))}%` }}
|
|
/>
|
|
</div>
|
|
<span className="w-12 text-right tabular-nums text-ink-500">
|
|
{Math.round((w / total) * 100)}%
|
|
</span>
|
|
<span className="w-24 truncate text-ink-400" title={k}>{k}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="mt-1 text-2xs text-ink-400">有效专家:{okCount}/{entries.length}</div>
|
|
</div>
|
|
)
|
|
}
|