- 新建 components/ui/(Button/Input/Select/Modal/Tabs/Spinner/Skeleton 等),变体用联合类型约束,根治幻影变体 - admin/components.tsx 与 matches/ui.tsx 改为再导出壳,消除 Spinner 三份重复 - 日期工具收口到 lib/date.ts,滚动监听收口到 lib/useScroll.ts - index.css 引入 :root RGB 三元组令牌,tailwind.config 接 <alpha-value>(修复透明度修饰符静默失效) - 刘建毛草 5MB 全字库(jsDelivr 未锁版) → 1KB 自托管子集(仅「先知」二字,unicode-range 限定)
152 lines
5.6 KiB
TypeScript
152 lines
5.6 KiB
TypeScript
/**
|
|
* 反馈类组件: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'
|
|
|
|
/*
|
|
无障碍:提示条是异步出现的(请求失败、操作完成),视觉用户
|
|
看到了,屏幕阅读器用户此前什么都收不到 —— 因为它只是一个
|
|
普通的 <div>,出现时不会触发任何朗读。
|
|
|
|
这里按语义分级:
|
|
error / warning → role="alert" (assertive,立即打断朗读)
|
|
ok / info → role="status" (polite,等当前朗读结束)
|
|
这也是 WAI-ARIA 对 live region 的推荐用法。
|
|
*/
|
|
const isUrgent = kind === 'error' || kind === 'warning'
|
|
|
|
return (
|
|
<div
|
|
role={isUrgent ? 'alert' : 'status'}
|
|
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 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>
|
|
)
|
|
}
|
|
|
|
/** 空状态文本(极简版) */
|
|
export function EmptyText({ text }: { text: string }) {
|
|
return (
|
|
<div className="py-10 text-center text-sm text-ink-400">
|
|
{text}
|
|
</div>
|
|
)
|
|
}
|