refactor(ui): 合并双组件库为 components/ui 基元层,设计令牌变量化,报头字体子集自托管
- 新建 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 限定)
This commit is contained in:
@@ -2,17 +2,15 @@
|
||||
* 回到顶部浮动按钮(前台两页共用,此前 Matches/Standings 各复制一份)。
|
||||
* 方角纸片风:去掉早期版本的 rounded-full + shadow-lg,与全站方角
|
||||
* 无阴影语言对齐;出现/隐藏仅动画 transform 与 opacity。
|
||||
*
|
||||
* 滚动监听已抽到 `lib/useScroll` 的 useWindowScrollY —— 全站三处
|
||||
* 滚动监听此前各自实现一遍生命周期,现统一为单一来源。
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useWindowScrollY } from '../lib/useScroll'
|
||||
|
||||
export default function BackTop({ threshold = 300 }: { threshold?: number }) {
|
||||
const [show, setShow] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => setShow(window.scrollY > threshold)
|
||||
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||
return () => window.removeEventListener('scroll', handleScroll)
|
||||
}, [threshold])
|
||||
const scrollY = useWindowScrollY()
|
||||
const show = scrollY > threshold
|
||||
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* 状态标记 Badge。
|
||||
*
|
||||
* 报刊不用彩色药丸:小方块 + 文字,红=异常/失败,墨=正常,灰=中性。
|
||||
* 原位于 admin/components.tsx。
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 卡片族:Card / CardHeader / CardBody。
|
||||
*
|
||||
* 报刊风:方角、墨线描边、无阴影。原位于 admin/components.tsx,
|
||||
* 合并到 components/ui 作为全站共享组件。
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
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>
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* 数据表格族:DataTable / MobileCardList / ResponsiveTable。
|
||||
* 原位于 admin/components.tsx。
|
||||
*
|
||||
* ResponsiveTable 按断点二选一渲染:桌面表格 / 移动卡片。
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import { EmptyState } from './Feedback'
|
||||
|
||||
// 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>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 移动端卡片列表 (替代桌面端表格) ────────────────────────────
|
||||
|
||||
// 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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* 反馈类组件: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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* 小节标题 SectionHeader:同前台 section-head 语言。
|
||||
* 原位于 admin/components.tsx。
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* SkeletonRows:骨架占位行(低调脉动灰块)。
|
||||
*
|
||||
* 原位于 pages/matches/ui.tsx —— 它是通用骨架,不属于 Matches 页面,
|
||||
* 上移到 UI 层。单块骨架用 <Skeleton>,整行占位用本组件。
|
||||
*/
|
||||
|
||||
export function SkeletonRows({ n = 4 }: { n?: number }) {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: n }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4 border-b border-ink-200 px-1 py-3.5">
|
||||
<div className="skeleton h-3 w-16" />
|
||||
<div className="skeleton h-3 flex-1" />
|
||||
<div className="skeleton h-3 w-10" />
|
||||
<div className="skeleton h-3 flex-1" />
|
||||
<div className="skeleton h-3 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* 统计与进度可视化:StatCard / ProgressBar / AgentWeightsBar。
|
||||
* 原位于 admin/components.tsx。
|
||||
*/
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
/** 进度条:同前台置信度细线 */
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Switch:一组互斥的文字切换(状态 / 模式)。
|
||||
*
|
||||
* 原位于 pages/matches/ui.tsx,上移到 UI 层 —— 它用的是全站统一的
|
||||
* `.tab` / `.tab-on` 语言(方角、印报红下划线),属通用组件。
|
||||
*
|
||||
* 注:内部的 <button> 不在 `.btn` 体系内(用 `.tab`),属有意保留的自定义样式。
|
||||
*/
|
||||
|
||||
export function Switch({ value, onChange, items }: {
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
items: { v: string; label: string; title?: string }[]
|
||||
}) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-2.5">
|
||||
{items.map((it, i) => (
|
||||
<span key={it.v} className="inline-flex items-center gap-2.5">
|
||||
{i > 0 && <span className="text-ink-300" aria-hidden="true">/</span>}
|
||||
<button
|
||||
onClick={() => onChange(it.v)}
|
||||
title={it.title}
|
||||
className={`relative tab ${value === it.v ? 'tab-on' : ''} text-xs`}
|
||||
>
|
||||
{it.label}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Tabs —— 报刊风版面切换基元。
|
||||
*
|
||||
* 背景:`Matches.tsx` 与 `Standings.tsx` 各写了一份高度雷同的联赛切换
|
||||
* `<button>` + `.tab` / `.tab-on` 类名。两份实现已经在细节上漂移
|
||||
* (一个有 `disabled` 无数据态与虚线下划线,一个没有),这正是缺少
|
||||
* 基元的典型症状 —— 复制粘贴会在无人察觉处产生第二个「事实标准」。
|
||||
*
|
||||
* 收口后,选择态、`aria-current`、无数据降级三件事只在一处定义。
|
||||
*/
|
||||
|
||||
import { forwardRef } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { cx } from './index'
|
||||
|
||||
export interface TabItem {
|
||||
/** 稳定 key,同时作为标识 */
|
||||
value: string
|
||||
/** 显示文本 */
|
||||
label: ReactNode
|
||||
/**
|
||||
* 无可用数据。不阻断点击(用户仍可切过去看空状态),
|
||||
* 但用虚线下划线弱化提示 —— 而非降字色,浅灰在纸底上对比度不足且形似禁用。
|
||||
*/
|
||||
empty?: boolean
|
||||
/** 原生 title 提示,鼠标悬停时解释 empty 的原因 */
|
||||
title?: string
|
||||
}
|
||||
|
||||
export interface TabsProps {
|
||||
items: TabItem[]
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
/** 整体禁用(如切换请求进行中) */
|
||||
disabled?: boolean
|
||||
/** 无障碍标签,描述这组页签是什么(如「联赛」) */
|
||||
ariaLabel: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 页签组基元。
|
||||
*
|
||||
* 视觉完全复用既有 `.tab` / `.tab-on`,不引入新的设计语言。
|
||||
*
|
||||
* 转发 ref 到内部 `<nav>`:调用方需要它来测量横向溢出
|
||||
* (如「右侧还有更多联赛」的渐变遮罩依赖 `useCanScrollRight`)。
|
||||
*/
|
||||
export const Tabs = forwardRef<HTMLElement, TabsProps>(function Tabs(
|
||||
{ items, value, onChange, disabled = false, ariaLabel, className },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<nav
|
||||
ref={ref}
|
||||
className={cx('flex items-center overflow-x-auto', className)}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{items.map(item => {
|
||||
const on = value === item.value
|
||||
return (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
onClick={() => onChange(item.value)}
|
||||
disabled={disabled}
|
||||
title={item.title}
|
||||
// aria-current 而非 aria-selected:这是「当前所在版面」的导航语义,
|
||||
// 不是 tablist/tab 的复合控件模式(那需要 role=tabpanel 配套)。
|
||||
aria-current={on ? 'true' : undefined}
|
||||
className={cx('relative tab font-serif', on && 'tab-on', disabled && 'disabled:opacity-50')}
|
||||
>
|
||||
{item.label}
|
||||
{item.empty && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-x-0 bottom-0 border-b border-dotted border-ink-300"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* UI 基元层 —— 全站唯一的基础交互组件来源。
|
||||
*
|
||||
* 背景:此前只有业务级组件(Card/DataTable 等),基础交互控件全靠各页面
|
||||
* 手写 className + `.btn` / `.field` 等 CSS 类维持一致。结果是:
|
||||
* - `btn-ghost` 被使用但从未定义,样式静默失效(与早年的 btn-outline 同型)
|
||||
* - Spinner 在前台/后台各复制一份
|
||||
* - 弹窗焦点陷阱只在一个弹窗里有,另一个缺
|
||||
* 组件化的意义在于「让错误在编译期暴露」,而不是靠人工守纪律。
|
||||
*
|
||||
* 设计约束:视觉输出与迁移前逐字一致(使用同一批 CSS 类),
|
||||
* 本层只做「收口 + 类型约束」,不改设计语言。
|
||||
*/
|
||||
|
||||
import { forwardRef, useEffect, useRef } from 'react'
|
||||
import type {
|
||||
ButtonHTMLAttributes,
|
||||
InputHTMLAttributes,
|
||||
SelectHTMLAttributes,
|
||||
ReactNode,
|
||||
MouseEventHandler,
|
||||
} from 'react'
|
||||
|
||||
// ── 工具:CSS 类名拼接 ────────────────────────────────────────────
|
||||
|
||||
export function cx(...parts: Array<string | false | null | undefined>): string {
|
||||
return parts.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
// ── Button ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 按钮变体。用联合类型约束,写错变体名会在编译期报错
|
||||
* —— 这是根治 `btn-ghost` 那类「幻影变体」的关键。
|
||||
*/
|
||||
export type ButtonVariant = 'default' | 'solid' | 'outline' | 'danger' | 'ghost'
|
||||
export type ButtonSize = 'default' | 'sm'
|
||||
|
||||
const VARIANT_CLASS: Record<ButtonVariant, string> = {
|
||||
default: '',
|
||||
solid: 'btn-solid',
|
||||
outline: 'btn-outline',
|
||||
danger: 'btn-danger',
|
||||
ghost: 'btn-ghost',
|
||||
}
|
||||
|
||||
const SIZE_CLASS: Record<ButtonSize, string> = {
|
||||
default: '',
|
||||
sm: 'btn-sm',
|
||||
}
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant
|
||||
size?: ButtonSize
|
||||
/** 显示加载态:自动禁用并前置 Spinner */
|
||||
loading?: boolean
|
||||
/** 占满父容器宽度 */
|
||||
block?: boolean
|
||||
/**
|
||||
* 传入时渲染为 `<a>` 而非 `<button>`(链接样式的按钮)。
|
||||
* 此时 disabled/loading 无效 —— 链接没有这两个语义。
|
||||
*/
|
||||
href?: string
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* 按钮基元。默认输出 `.btn` 的方角纸片样式。
|
||||
*
|
||||
* 传 `href` 时渲染为 `<a>`(链接样式的按钮,如卡片里的「定位 →」跳转),
|
||||
* 复用同一套变体类名。按钮语义(点击执行动作)与链接语义(导航到别处)
|
||||
* 在 HTML 层不可互换,因此不硬造一个 `<button>` 假装链接。
|
||||
*
|
||||
* 注:`min-h-[44px]` 等尺寸细节仍由各页面按场景通过 className 覆盖
|
||||
* (如弹窗按钮需更大触控目标),基元只保证变体与尺寸语义统一。
|
||||
*/
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
||||
{ variant = 'default', size = 'default', loading = false, block = false, href, className, children, disabled, ...rest },
|
||||
ref,
|
||||
) {
|
||||
const cls = cx(
|
||||
'btn',
|
||||
VARIANT_CLASS[variant],
|
||||
SIZE_CLASS[size],
|
||||
block && 'w-full',
|
||||
className,
|
||||
)
|
||||
|
||||
if (href != null) {
|
||||
// 锚点分支:只透传与 <a> 兼容且实际会用到的属性。
|
||||
// 显式收窄而不是解构 rest —— button 属性(如 formAction)对锚点无意义,
|
||||
// 且两类元素的 onClick 处理器参数类型不同,直接展开会让 TS 反变失配。
|
||||
const a = rest as unknown as {
|
||||
onClick?: MouseEventHandler<HTMLAnchorElement>
|
||||
title?: string
|
||||
'aria-label'?: string
|
||||
target?: string
|
||||
}
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
onClick={a.onClick}
|
||||
title={a.title}
|
||||
aria-label={a['aria-label']}
|
||||
target={a.target}
|
||||
className={cls}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
disabled={disabled || loading}
|
||||
aria-busy={loading || undefined}
|
||||
className={cls}
|
||||
{...rest}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Spinner /> {children}
|
||||
</>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})
|
||||
|
||||
// ── Input / Select ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 输入框基元:统一 `.field` 方角样式。
|
||||
*
|
||||
* 无障碍提醒:请为输入框提供可访问名称 —— 要么传 `id` 并在外部配
|
||||
* `<label htmlFor={id}>`,要么传 `label`(内部转 aria-label)。
|
||||
*
|
||||
* 背景:全站 20 个 `<input>` 里多数只有视觉上相邻的 `<label>`,
|
||||
* 没有 `htmlFor`/`id` 关联。屏幕阅读器读出来只有「编辑框」,
|
||||
* 在密码框场景下用户无法分辨「当前密码」与「确认新密码」。
|
||||
*
|
||||
* 注:多数页面目前仍直接写 `<input className="field">` 而非用本基元,
|
||||
* 那些调用点需各自补 id/htmlFor(已在 admin 各页处理)。
|
||||
*/
|
||||
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
/** 无可见标签时的可访问名称(内部转 aria-label) */
|
||||
label?: string
|
||||
}
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
{ className, label, 'aria-label': ariaLabel, ...rest },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
aria-label={ariaLabel ?? label}
|
||||
className={cx('field', className)}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
// 用 type 别名而非空 interface:空 interface 与父类型完全等价,
|
||||
// 留着只会让「这里以后可能要加字段」的意图变成噪声。
|
||||
export type SelectProps = SelectHTMLAttributes<HTMLSelectElement>
|
||||
|
||||
/** 下拉框基元:与 Input 同一 `.field` 语言。 */
|
||||
export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select(
|
||||
{ className, children, ...rest },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<select ref={ref} className={cx('field', className)} {...rest}>
|
||||
{children}
|
||||
</select>
|
||||
)
|
||||
})
|
||||
|
||||
// ── Spinner ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 加载指示。全站唯一来源(此前存在三份逐字节相同的副本:
|
||||
* admin/components.tsx / pages/matches/ui.tsx / MatchPredictPanel.tsx)。
|
||||
*/
|
||||
export function Spinner({ className = '' }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 20 20"
|
||||
className={cx('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>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Skeleton ─────────────────────────────────────────────────────
|
||||
|
||||
/** 骨架占位块 */
|
||||
export function Skeleton({ className = '' }: { className?: string }) {
|
||||
return <div className={cx('skeleton', className)} />
|
||||
}
|
||||
|
||||
// ── 业务级组件:统一从本目录再导出,使 `components/ui` 成为唯一入口 ──
|
||||
|
||||
export { Card, CardHeader, CardBody } from './Card'
|
||||
export { Badge } from './Badge'
|
||||
export { StatCard, ProgressBar, AgentWeightsBar } from './Stat'
|
||||
export { SectionHeader } from './SectionHeader'
|
||||
export { Alert, ErrorBanner, describeError, EmptyState, EmptyText } from './Feedback'
|
||||
export { DataTable, MobileCardList, ResponsiveTable } from './DataTable'
|
||||
export { SkeletonRows } from './SkeletonRows'
|
||||
export { Switch } from './Switch'
|
||||
export { Tabs } from './Tabs'
|
||||
export type { TabItem, TabsProps } from './Tabs'
|
||||
|
||||
// ── Modal ────────────────────────────────────────────────────────
|
||||
|
||||
export interface ModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
/** 无障碍名称,会写入 aria-label */
|
||||
label: string
|
||||
children: ReactNode
|
||||
/** 面板附加类名(控制最大宽度、对齐方式等) */
|
||||
panelClassName?: string
|
||||
/** 遮罩层附加类名 */
|
||||
overlayClassName?: string
|
||||
/** 点击遮罩是否关闭(默认 true) */
|
||||
closeOnOverlay?: boolean
|
||||
/** 显示入场动画(默认 true) */
|
||||
animate?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 弹窗基元:一次性内置全部无障碍行为,使各弹窗表现天然一致。
|
||||
*
|
||||
* 内置能力(此前仅 PredictModal 完整具备,CommandPalette 缺前 3 项):
|
||||
* - 焦点陷阱:Tab / Shift+Tab 循环限制在弹窗内
|
||||
* - ESC 关闭
|
||||
* - 背景滚动锁定(避免内层滚到底后带动底层页面)
|
||||
* - 关闭后焦点归还给触发元素
|
||||
* - 初始聚焦面板,键盘用户可直接 Tab 进入
|
||||
*/
|
||||
export function Modal({
|
||||
open,
|
||||
onClose,
|
||||
label,
|
||||
children,
|
||||
panelClassName = '',
|
||||
overlayClassName = '',
|
||||
closeOnOverlay = true,
|
||||
animate = true,
|
||||
}: ModalProps) {
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const previouslyFocused = useRef<HTMLElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
previouslyFocused.current = document.activeElement as HTMLElement | null
|
||||
panelRef.current?.focus()
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
if (e.key === 'Tab') {
|
||||
const focusables = panelRef.current?.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
)
|
||||
if (!focusables || focusables.length === 0) return
|
||||
const first = focusables[0]
|
||||
const last = focusables[focusables.length - 1]
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault()
|
||||
last.focus()
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
const prevOverflow = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
document.body.style.overflow = prevOverflow
|
||||
previouslyFocused.current?.focus()
|
||||
}
|
||||
}, [open, onClose])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'fixed inset-0 flex items-start justify-center bg-ink-900/50 p-4',
|
||||
animate && 'modal-overlay-enter',
|
||||
overlayClassName,
|
||||
)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={label}
|
||||
onClick={e => {
|
||||
if (closeOnOverlay && e.target === e.currentTarget) onClose()
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
tabIndex={-1}
|
||||
className={cx('relative outline-none', animate && 'modal-panel-enter', panelClassName)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user