/** * 反馈类组件:Alert / ErrorBanner / describeError / EmptyState / EmptyText。 * 原位于 admin/components.tsx。 * * 依赖说明:`ApiError` 直接从 `lib/http` 导入(而非 `admin/api` 的转发), * 使本组件不依赖 admin 目录 —— 这正是合并两套组件库要解决的问题。 */ import type { ReactNode } from 'react' import { ApiError } from '../../lib/http' // ── 提示条:错误红框(同前台) / 正常墨框 ──────────────────────── 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' /* 无障碍:提示条是异步出现的(请求失败、操作完成),视觉用户 看到了,屏幕阅读器用户此前什么都收不到 —— 因为它只是一个 普通的
,出现时不会触发任何朗读。 这里按语义分级: error / warning → role="alert" (assertive,立即打断朗读) ok / info → role="status" (polite,等当前朗读结束) 这也是 WAI-ARIA 对 live region 的推荐用法。 */ const isUrgent = kind === 'error' || kind === 'warning' return (

{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 EmptyState({ text = '暂无数据', sub }: { text?: string; sub?: string }) { return (

{text}

{sub &&

{sub}

}
) } /** 空状态文本(极简版) */ export function EmptyText({ text }: { text: string }) { return (
{text}
) }