/** * 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 ( ) } // ── 数据表格 ──────────────────────────────────────────────────── // 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 => ( ))} {data.map(row => ( {columns.map(col => ( ))} ))}
{col.label}
{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 (

{text}

{sub &&

{sub}

}
) } // ── 移动端卡片列表 (替代桌面端表格) ──────────────────────────── // 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, }: { kind: 'error' | 'ok' | 'info' | 'warning' title: string message?: string onClose?: () => void }) { 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 (

{message && (

{message}

)}
{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
}