/**
* Admin 后台 - 通用 UI 组件集合(报刊风)
*
* 与前台共用同一套设计语言:
* - 纸色底(paper)、墨色字(ink)、印报红唯一强调(press)
* - 方正边框、细线分隔、宋体标题、无圆角、无彩色药丸标签
*/
import { ReactNode } from 'react'
import { ApiError } from './api'
// ── 卡片 ────────────────────────────────────────────────────────
export function Card({
children,
className = '',
}: {
children: ReactNode
className?: string
}) {
return (
{children}
)
}
export function CardHeader({
title,
description,
action,
}: {
title: string
description?: string
action?: ReactNode
}) {
return (
{title}
{action}
{description &&
{description}
}
)
}
export function CardBody({
children,
className = '',
}: {
children: ReactNode
className?: string
}) {
return {children}
}
// ── 统计卡片 ────────────────────────────────────────────────────
export function StatCard({
label,
value,
hint,
}: {
label: string
value: string | number
hint?: string
}) {
return (
{label}
{value}
{hint &&
{hint}
}
)
}
// ── 状态标记 ────────────────────────────────────────────────────
// 报刊不用彩色药丸:小方块 + 文字,红=异常/失败,墨=正常,灰=中性
const MARK_STYLES: Record = {
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 (
{children}
)
}
// ── 数据表格 ────────────────────────────────────────────────────
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function DataTable({
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
}
return (
{columns.map(col => (
|
{col.label}
|
))}
{data.map(row => (
{columns.map(col => (
|
{col.render
? col.render(row)
: row != null && typeof row === 'object' && col.key in row
? String((row as Record)[col.key] ?? '—')
: '—'}
|
))}
))}
)
}
// ── 进度条:同前台置信度细线 ────────────────────────────────────
export function ProgressBar({ value, className = '' }: { value: number; className?: string }) {
const clamped = Math.max(0, Math.min(100, value))
return (
)
}
// ── 空状态:同前台「本版暂无赛程」 ──────────────────────────────
export function EmptyState({ text = '暂无数据', sub }: { text?: string; sub?: string }) {
return (
)
}
// ── 移动端卡片列表 (替代桌面端表格) ────────────────────────────
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function MobileCardList({
data,
renderCard,
emptyText = '暂无数据',
}: {
data: T[]
renderCard: (row: T, index: number) => ReactNode
emptyText?: string
}) {
if (data.length === 0) {
return
}
return (
{data.map((row, idx) => (
{renderCard(row, idx)}
))}
)
}
// ── 响应式表格容器 ──────────────────────────────────────────────
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function ResponsiveTable({
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
}
return (
<>
{/* 桌面端表格 */}
{/* 移动端卡片 */}
>
)
}
// ── 小节标题:同前台 section-head ───────────────────────────────
export function SectionHeader({
title,
description,
action,
}: {
title: string
description?: string
action?: ReactNode
}) {
return (
{title}
{description &&
{description}
}
{action}
)
}
// ── 提示条:错误红框(同前台) / 正常墨框 ────────────────────────
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 (
{title}
{message && (
{message}
)}
{action}
{onClose && (
)}
)
}
// ── 错误横幅:标准化错误标题/文案/建议动作 ──────────────────────
/** 根据错误对象生成标准化的错误标题、文案与建议动作。 */
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
}
export function Spinner({ className = '' }: { className?: string }) {
return (
)
}
// ── 骨架占位 ────────────────────────────────────────────────────
export function SkeletonBlock({ className = '' }: { className?: string }) {
return
}
/** 空状态文本 */
export function EmptyText({ text }: { text: string }) {
return (
{text}
)
}
/** Agent 权重条形图 */
export function AgentWeightsBar({ weights, okCount }: { weights: Record; 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 (
终裁专家权重
{entries.map(([k, w], i) => (
{Math.round((w / total) * 100)}%
{k}
))}
有效专家:{okCount}/{entries.length}
)
}