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:
2026-09-22 23:44:46 +08:00
parent a7e8b75503
commit 2cfc9ccc0f
18 changed files with 1226 additions and 569 deletions
+22 -433
View File
@@ -1,438 +1,27 @@
/** /**
* Admin 后台 - 通用 UI 组件集合(报刊风) * 【兼容壳】组件已合并到 `src/components/ui/`。
* *
* 与前台共用同一套设计语言: * 历史:本文件曾是后台专属的组件库,与 `pages/matches/ui.tsx` 平行存在。
* - 纸色底(paper)、墨色字(ink)、印报红唯一强调(press) * 二者分裂导致 Spinner 出现三份逐字节相同的副本,且前台页面要跨目录
* - 方正边框、细线分隔、宋体标题、无圆角、无彩色药丸标签 * `import '../admin/components'` 才能拿到通用组件。
*
* 现已全部归入 `src/components/ui/`,本文件保留为再导出壳,
* 使既有 import 路径继续可用。**新代码请直接从 `components/ui` 导入。**
*
* 本文件不含任何组件实现。
*/ */
import { ReactNode } from 'react' export { Card, CardHeader, CardBody } from '../components/ui/Card'
export { Badge } from '../components/ui/Badge'
export { StatCard, ProgressBar, AgentWeightsBar } from '../components/ui/Stat'
export { SectionHeader } from '../components/ui/SectionHeader'
export { Alert, ErrorBanner, describeError, EmptyState, EmptyText } from '../components/ui/Feedback'
export { DataTable, MobileCardList, ResponsiveTable } from '../components/ui/DataTable'
export { Spinner } from '../components/ui'
import { ApiError } from './api' /**
* 骨架占位块。历史上的本地命名,内部已统一到基元 `Skeleton`。
// ── 卡片 ──────────────────────────────────────────────────────── * 刻意只导出 `SkeletonBlock` 一个名字(不再顺带导出 `Skeleton`),
* 以保持本壳的导出面 == 迁移前的导出面,避免新增符号带来歧义。
export function Card({ */
children, export { Skeleton as SkeletonBlock } from '../components/ui'
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>
}
// ── 统计卡片 ────────────────────────────────────────────────────
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>
)
}
// ── 状态标记 ────────────────────────────────────────────────────
// 报刊不用彩色药丸:小方块 + 文字,红=异常/失败,墨=正常,灰=中性
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>
)
}
// ── 数据表格 ────────────────────────────────────────────────────
// 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>
)
}
// ── 进度条:同前台置信度细线 ────────────────────────────────────
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>
)
}
// ── 空状态:同前台「本版暂无赛程」 ──────────────────────────────
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>
)
}
// ── 移动端卡片列表 (替代桌面端表格) ────────────────────────────
// 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} />
</>
)
}
// ── 小节标题:同前台 section-head ───────────────────────────────
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>
)
}
// ── 提示条:错误红框(同前台) / 正常墨框 ────────────────────────
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 (
<div 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 Spinner({ className = '' }: { className?: string }) {
return (
<svg
viewBox="0 0 20 20"
className={`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>
)
}
// ── 骨架占位 ────────────────────────────────────────────────────
export function SkeletonBlock({ className = '' }: { className?: string }) {
return <div className={`skeleton ${className}`} />
}
/** 空状态文本 */
export function EmptyText({ text }: { text: string }) {
return (
<div className="py-10 text-center text-sm text-ink-400">
{text}
</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>
)
}
Binary file not shown.
+6 -8
View File
@@ -2,17 +2,15 @@
* 回到顶部浮动按钮(前台两页共用,此前 Matches/Standings 各复制一份)。 * 回到顶部浮动按钮(前台两页共用,此前 Matches/Standings 各复制一份)。
* 方角纸片风:去掉早期版本的 rounded-full + shadow-lg,与全站方角 * 方角纸片风:去掉早期版本的 rounded-full + shadow-lg,与全站方角
* 无阴影语言对齐;出现/隐藏仅动画 transform 与 opacity。 * 无阴影语言对齐;出现/隐藏仅动画 transform 与 opacity。
*
* 滚动监听已抽到 `lib/useScroll` 的 useWindowScrollY —— 全站三处
* 滚动监听此前各自实现一遍生命周期,现统一为单一来源。
*/ */
import { useEffect, useState } from 'react' import { useWindowScrollY } from '../lib/useScroll'
export default function BackTop({ threshold = 300 }: { threshold?: number }) { export default function BackTop({ threshold = 300 }: { threshold?: number }) {
const [show, setShow] = useState(false) const scrollY = useWindowScrollY()
const show = scrollY > threshold
useEffect(() => {
const handleScroll = () => setShow(window.scrollY > threshold)
window.addEventListener('scroll', handleScroll, { passive: true })
return () => window.removeEventListener('scroll', handleScroll)
}, [threshold])
return ( return (
<button <button
+39
View File
@@ -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>
)
}
+49
View File
@@ -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>
}
+118
View File
@@ -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} />
</>
)
}
+151
View File
@@ -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>
))}
</>
)
}
+67
View File
@@ -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>
)
}
+31
View File
@@ -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>
)
}
+85
View File
@@ -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>
)
})
+327
View File
@@ -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>
)
}
+72 -5
View File
@@ -2,11 +2,57 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
/* 毛体草书(刘建毛草) - 国内 CDN,离线回退粗楷体 */ /*
设计令牌(颜色)的 CSS 侧来源。
─────────────────────────────────────────────────────────────
此前 index.css 里三处直接写死了十六进制字面量:
· :focus-visible 的 outline → #9e1b1b
· .masthead-rule 的 border-top → #17140f
· .font-brush 的 color → #9E1B1B(还是大写写法)
它们与 tailwind.config.js 的 press / ink 令牌是同一批颜色,却没有
共享来源。后果:调整品牌红时改令牌不会影响这三处 —— 焦点轮廓、
报头粗线、毛笔字会保持旧色,出现「两个印报红,其中一个没有名字」。
现在把颜色定义在 :root,由 tailwind.config.js 消费同一份变量,
使 CSS 与工具类共享单一来源。改色只需改这里一处。
⚠️ 值必须写成 RGB 三元组(如 23 20 15)而非 #17140F。
原因:Tailwind 的透明度修饰符(bg-press/60)需要把颜色拆成
rgb(R G B / <alpha-value>) 才能注入 alpha。若变量本身是完整
十六进制,`bg-press-wash/60` 这类写法会**静默不生成任何 CSS** ——
编译不报错,但样式消失(实测踩到过)。三元组写法同时兼容
不需要 alpha 的场景。
*/
:root {
/* 纸白:微暖底色,像新闻纸而不是纯白画布 */
--paper-50: 253 252 248;
--paper-100: 247 244 236;
--paper-200: 237 233 222;
--paper-300: 221 215 199;
/* 墨色:暖黑灰阶 */
--ink-900: 23 20 15;
--ink-700: 59 54 46;
--ink-500: 110 103 91;
--ink-400: 156 149 135;
--ink-300: 201 196 184;
--ink-200: 226 223 215;
/* 印报红:全站唯一强调色 */
--press: 158 27 27;
--press-dark: 124 20 20;
--press-wash: 247 233 228;
}
/* 毛体草书(刘建毛草) - 本地自托管子集(仅「先知」两字,约 1KB)
原先直连 jsDelivr 的 @main 分支(未锁版本、国内加载不稳、5MB 全字库)。
现仅保留报头用字,构建期随包发布,消除外部单点依赖。 */
@font-face { @font-face {
font-family: 'Liu Jian Mao Cao'; font-family: 'Liu Jian Mao Cao';
src: url('https://cdn.jsdelivr.net/gh/google/fonts@main/ofl/liujianmaocao/LiuJianMaoCao-Regular.ttf') format('truetype'); src: url('./assets/fonts/LiuJianMaoCao-subset.woff2') format('woff2');
font-display: swap; font-display: swap;
font-weight: 400;
unicode-range: U+5148, U+77E5; /* 先 U+5148 / 知 U+77E5 */
} }
/* 粗毛笔字效果:笔触加粗 + 微描边 */ /* 粗毛笔字效果:笔触加粗 + 微描边 */
@@ -48,7 +94,7 @@
/* 统一焦点环:印报红细线,键盘可达 */ /* 统一焦点环:印报红细线,键盘可达 */
:focus-visible { :focus-visible {
outline: 2px solid #9e1b1b; outline: 2px solid var(--press);
outline-offset: 2px; outline-offset: 2px;
} }
@@ -82,7 +128,7 @@
@layer components { @layer components {
/* ── 报头双线:粗线在上、细线在下 ── */ /* ── 报头双线:粗线在上、细线在下 ── */
.masthead-rule { .masthead-rule {
border-top: 3px solid #17140f; border-top: 3px solid var(--ink-900);
} }
/* ── 按钮:方正边框式,悬停反白 ── */ /* ── 按钮:方正边框式,悬停反白 ── */
@@ -109,6 +155,15 @@
@apply border-ink-900 bg-transparent text-ink-900 @apply border-ink-900 bg-transparent text-ink-900
hover:border-press hover:bg-press-wash hover:text-press-dark; hover:border-press hover:bg-press-wash hover:text-press-dark;
} }
/* 幽灵按钮:无边、无底色,仅 hover 时浮出浅纸底。
用于「重置」「取消」等最轻量的次要动作。
(EvalPage 曾使用但从未定义,与 btn-outline 同型问题;
现由 src/components/ui 的 Button variant 类型约束兜底,不再可能静默失效) */
.btn-ghost {
@apply border-transparent bg-transparent text-ink-500
hover:bg-paper-200 hover:text-ink-900
disabled:hover:bg-transparent disabled:hover:text-ink-500;
}
/* ── 弹窗入场:遮罩淡入 + 面板上浮(仅 opacity/transform,GPU 友好) ── */ /* ── 弹窗入场:遮罩淡入 + 面板上浮(仅 opacity/transform,GPU 友好) ── */
.modal-overlay-enter { .modal-overlay-enter {
@@ -126,6 +181,18 @@
to { opacity: 1; transform: translateY(0); } to { opacity: 1; transform: translateY(0); }
} }
/* ── 不确定态进度条(indeterminate) ──
用于「耗时未知」的等待场景(如多专家预测)。与确定性进度条的区别
是它不表达百分比,只表达「仍在进行」—— 因此不存在「卡在 95%」
这种误导。transform-only,GPU 友好。 */
.predict-scan {
animation: predict-scan 1.4s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes predict-scan {
0% { transform: translateX(-100%); }
100% { transform: translateX(300%); }
}
/* ── 统一空态 ── */ /* ── 统一空态 ── */
.empty-state { .empty-state {
@apply border-y border-ink-200 py-12 text-center; @apply border-y border-ink-200 py-12 text-center;
@@ -192,7 +259,7 @@
.nav-icon-wrap:hover .nav-icon, .nav-icon-wrap:hover .nav-icon,
.nav-icon-wrap.active .nav-icon { .nav-icon-wrap.active .nav-icon {
opacity: 1; opacity: 1;
color: #9E1B1B; color: var(--press);
stroke-width: 2; stroke-width: 2;
} }
} }
+63
View File
@@ -0,0 +1,63 @@
/**
* 日期工具(纯函数,零依赖)。
*
* 从 `pages/matches/ui.tsx` 抽出 —— 它们原本与 UI 组件混在一个文件里,
* 但并不是组件,而是可独立测试的纯函数。
*/
/** 日期 key 辅助:YYYY-MM-DD(本地时区) */
function dateKey(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
/** 未来第 n 天的日期 key */
function addDays(d: Date, n: number): string {
const x = new Date(d)
x.setFullYear(x.getFullYear(), x.getMonth(), x.getDate() + n)
return dateKey(x)
}
/** UTC ISO → 本地日期 YYYY-MM-DD(用于分组) */
export function toLocalDateKey(iso: string): string {
const d = new Date(iso)
return dateKey(d)
}
/** 日期分组头显示:今日/明日/周几 · 年月日 */
export function formatDateHeader(dateKeyStr: string): string {
if (!dateKeyStr) return '未开赛'
const d = new Date(dateKeyStr + 'T00:00:00')
if (isNaN(d.getTime())) return dateKeyStr
const today = new Date()
const todayKey = dateKey(today)
const tmr = new Date(today)
tmr.setDate(tmr.getDate() + 1)
const tmrKey = dateKey(tmr)
const weekday = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][d.getDay()]
if (dateKeyStr === todayKey) return `今日 ${weekday}`
if (dateKeyStr === tmrKey) return `明日 ${weekday}`
return `${d.getMonth() + 1}${d.getDate()}${weekday}`
}
/**
* 按本地日期分组(非 UTC),保持时间序。
*
* 泛型约束 `{ match_date: string }` 而非具体业务类型 —— 使本工具
* 与 Matches 页面解耦,任何带日期字段的列表都能复用。
*/
export function groupByDate<T extends { match_date: string }>(list: T[]): Array<[string, T[]]> {
const map = new Map<string, T[]>()
for (const m of list) {
const key = toLocalDateKey(m.match_date)
const arr = map.get(key)
if (arr) arr.push(m)
else map.set(key, [m])
}
return [...map.entries()]
}
/** 比赛是否在未来 3 天内(用于默认视图过滤):今天 00:00 → 第 3 天 00:00 */
export function withinNext3Days(matchDate: string): boolean {
const key = toLocalDateKey(matchDate)
return key >= dateKey(new Date()) && key < addDays(new Date(), 3)
}
+86
View File
@@ -0,0 +1,86 @@
/**
* 滚动相关 hooks —— 统一全站的滚动监听。
*
* 背景:此前滚动监听散落三处,各写一遍 addEventListener /
* removeEventListener / passive / 初始调用:
* · components/BackTop.tsx —— 监听 window,判断是否显示回到顶部
* · pages/Matches.tsx —— 监听联赛 tab 容器,判断能否右滚
* · admin/AdminLayout.tsx —— 关闭移动端抽屉时锁滚动
*
* 三份重复的生命周期管理,任何一处漏了 cleanup 就是内存泄漏;
* 且 `{ passive: true }` 这个纯收益的优化只有两处记得加。
*
* 这里抽成两个 hook,把「监听 → 清理」的模式收敛为单一实现。
*/
import { useEffect, useState } from 'react'
import type { RefObject } from 'react'
/**
* 监听 window 纵向滚动位置。
*
* @returns 当前 scrollY(节流到动画帧,避免滚动过程中高频 setState)
*/
export function useWindowScrollY(): number {
const [y, setY] = useState(() =>
typeof window === 'undefined' ? 0 : window.scrollY,
)
useEffect(() => {
let ticking = false
const onScroll = () => {
// rAF 节流:滚动事件可能每帧触发多次,直接 setState 会造成
// 大量无意义的重渲染
if (ticking) return
ticking = true
requestAnimationFrame(() => {
setY(window.scrollY)
ticking = false
})
}
window.addEventListener('scroll', onScroll, { passive: true })
onScroll() // 初始同步:刷新后可能已在页面中部
return () => window.removeEventListener('scroll', onScroll)
}, [])
return y
}
/**
* 监听某个可滚动元素是否还能继续向右滚动。
*
* 用于横向溢出的导航条:能右滚时在右缘显示渐隐提示,提示用户
* 「右边还有内容」。同时监听 window resize —— 视口变宽后可能
* 就不再溢出了,不重新计算会留下错误的提示。
*
* @param ref 目标滚动容器
* @param tolerance 容差(px),避免亚像素误差导致提示闪烁
* @param deps 触发重新测量的依赖(如列表内容变化时)
*/
export function useCanScrollRight(
ref: RefObject<HTMLElement | null>,
tolerance = 8,
deps: unknown[] = [],
): boolean {
const [canScroll, setCanScroll] = useState(false)
useEffect(() => {
const el = ref.current
if (!el) return
const update = () => {
setCanScroll(el.scrollWidth - el.scrollLeft - el.clientWidth > tolerance)
}
update() // 初次测量
el.addEventListener('scroll', update, { passive: true })
window.addEventListener('resize', update)
return () => {
el.removeEventListener('scroll', update)
window.removeEventListener('resize', update)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ref, tolerance, ...deps])
return canScroll
}
+11 -110
View File
@@ -1,114 +1,15 @@
/** /**
* Matches 页面族共享的原子 UI 小件(无业务状态) * 【兼容壳】组件与工具已迁出本文件
* *
* D3: 从 Matches.tsx 内联定义上移到模块级 —— Switch 原先定义在组件函数 * 历史:本文件混装了两类内容 ——
* 体内(每次渲染重建组件对象),它没有内部 state,提升后渲染结果一致。 * - 通用 UI 组件(Switch / SkeletonRows) → 已上移到 `components/ui`
* - 日期纯函数(formatDateHeader 等) → 已抽到 `lib/date.ts`
*
* 本文件保留为再导出壳,使既有 import 路径继续可用。
* **新代码请直接从 `components/ui` 或 `lib/date` 导入。**
*
* 本文件不含任何实现。
*/ */
import type { Match } from './types'
export function Spinner({ className = '' }: { className?: string }) { export { Spinner, SkeletonRows, Switch } from '../../components/ui'
return ( export { formatDateHeader, toLocalDateKey, groupByDate, withinNext3Days } from '../../lib/date'
<svg
viewBox="0 0 20 20"
className={`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>
)
}
/** 骨架占位行:低调脉动灰块 */
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>
))}
</>
)
}
/** 状态/模式一组的文字切换 */
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>
)
}
/** 日期分组头显示:今日/明天/周几 · 年月日 */
export function formatDateHeader(dateKey: string): string {
if (!dateKey) return '未开赛'
const d = new Date(dateKey + 'T00:00:00')
if (isNaN(d.getTime())) return dateKey
const today = new Date()
const todayKey = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
const tmr = new Date(today)
tmr.setDate(tmr.getDate() + 1)
const tmrKey = `${tmr.getFullYear()}-${String(tmr.getMonth() + 1).padStart(2, '0')}-${String(tmr.getDate()).padStart(2, '0')}`
const weekday = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][d.getDay()]
if (dateKey === todayKey) return `今日 ${weekday}`
if (dateKey === tmrKey) return `明日 ${weekday}`
return `${d.getMonth() + 1}${d.getDate()}${weekday}`
}
/** UTC ISO → 本地日期 YYYY-MM-DD(用于分组) */
export function toLocalDateKey(iso: string): string {
const d = new Date(iso)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
/** 按本地日期分组(非 UTC),保持时间序 */
export function groupByDate(list: Match[]): Array<[string, Match[]]> {
const map = new Map<string, Match[]>()
for (const m of list) {
const key = toLocalDateKey(m.match_date)
const arr = map.get(key)
if (arr) arr.push(m)
else map.set(key, [m])
}
return [...map.entries()]
}
/** 日期 key 辅助:YYYY-MM-DD(本地时区) */
function dateKey(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
/** 未来 3 天窗口:今天 00:00 → 第 3 天 00:00(即今天/明天/后天) */
function addDays(d: Date, n: number): string {
const x = new Date(d)
x.setFullYear(x.getFullYear(), x.getMonth(), x.getDate() + n)
return dateKey(x)
}
/** 比赛是否在未来 3 天内(用于默认视图过滤) */
export function withinNext3Days(matchDate: string): boolean {
const key = toLocalDateKey(matchDate)
return key >= dateKey(new Date()) && key < addDays(new Date(), 3)
}
+52 -13
View File
@@ -4,31 +4,46 @@ export default {
theme: { theme: {
extend: { extend: {
colors: { colors: {
/*
颜色来源说明:
纸白/墨色/印报红三组令牌的**权威定义在 src/index.css 的 :root**
(CSS 自定义属性,值为 RGB 三元组)。这里引用同一份变量,使
工具类(bg-press)与手写 CSS(outline: var(--press))不可能漂移。
此前两处各自硬编码十六进制,改色时必须记得改两边 ——
index.css 里就有 3 处漏网(焦点环/报头粗线/毛笔字),保留了旧色。
<alpha-value> 占位符不可省略:它让 Tailwind 的透明度修饰符
(bg-press-wash/60)能正确注入 alpha。缺了它,带 /60 的类名会
静默不生成任何 CSS。
*/
// 纸白:微暖底色,像新闻纸而不是纯白画布 // 纸白:微暖底色,像新闻纸而不是纯白画布
paper: { paper: {
50: '#FDFCF8', 50: 'rgb(var(--paper-50) / <alpha-value>)',
100: '#F7F4EC', 100: 'rgb(var(--paper-100) / <alpha-value>)',
200: '#EDE9DE', 200: 'rgb(var(--paper-200) / <alpha-value>)',
300: '#DDD7C7', 300: 'rgb(var(--paper-300) / <alpha-value>)',
}, },
// 墨色:暖黑灰阶,替代冷调 slate // 墨色:暖黑灰阶,替代冷调 slate
// 注:50/100/600/800 尚未提升为 CSS 变量(仅 CSS 侧未用到),
// 保持字面量直至有第二个消费方,避免过早抽象。
ink: { ink: {
50: '#FAF9F7', 50: '#FAF9F7',
100: '#F0EEE9', 100: '#F0EEE9',
200: '#E2DFD7', 200: 'rgb(var(--ink-200) / <alpha-value>)',
300: '#C9C4B8', 300: 'rgb(var(--ink-300) / <alpha-value>)',
400: '#9C9587', 400: 'rgb(var(--ink-400) / <alpha-value>)',
500: '#6E675B', 500: 'rgb(var(--ink-500) / <alpha-value>)',
600: '#524C42', 600: '#524C42',
700: '#3B362E', 700: 'rgb(var(--ink-700) / <alpha-value>)',
800: '#282420', 800: '#282420',
900: '#17140F', 900: 'rgb(var(--ink-900) / <alpha-value>)',
}, },
// 印报红:全站唯一强调色,克制使用 // 印报红:全站唯一强调色,克制使用
press: { press: {
DEFAULT: '#9E1B1B', DEFAULT: 'rgb(var(--press) / <alpha-value>)',
dark: '#7C1414', dark: 'rgb(var(--press-dark) / <alpha-value>)',
wash: '#F7E9E4', wash: 'rgb(var(--press-wash) / <alpha-value>)',
}, },
// ── 语义状态色:成功/警告/负面 ── // ── 语义状态色:成功/警告/负面 ──
// 设计约束:纸底 #FDFCF8 上文字级(600/700)对比度 ≥ 4.5:1(WCAG AA), // 设计约束:纸底 #FDFCF8 上文字级(600/700)对比度 ≥ 4.5:1(WCAG AA),
@@ -57,6 +72,26 @@ export default {
600: '#A83B3B', 600: '#A83B3B',
700: '#8F3030', 700: '#8F3030',
}, },
// ── 赛制分区色:欧战 / 附加赛 ──
// 与 ok/warn/bad 同一套降饱和逻辑,替代此前的 sky/orange 原生色
// (原生色冷调高饱和,与纸色暖灰底冲突)。
// 对比度:#FDFCF8 纸底上 700 级 ≥ 4.5:1,100 级仅作底色不与文字对比。
euro: {
50: '#EAF0F2',
100: '#DCE6EA',
300: '#A9C0C9',
500: '#5B7C8A',
600: '#476571',
700: '#3A5460',
},
playoff: {
50: '#F8EEDF',
100: '#F2E0C6',
300: '#DDBE8A',
500: '#B87A2E',
600: '#966124',
700: '#7C511E',
},
}, },
fontFamily: { fontFamily: {
// 毛体草书(国内 CDN)+ 粗楷体回退 // 毛体草书(国内 CDN)+ 粗楷体回退
@@ -84,6 +119,10 @@ export default {
fontSize: { fontSize: {
'2xs': ['11px', { lineHeight: '16px' }], '2xs': ['11px', { lineHeight: '16px' }],
}, },
// 报纸硬阴影:实心偏移、无模糊,替代此前内联的一次性写法
boxShadow: {
print: '4px 4px 0 0 rgb(0 0 0 / 0.06)',
},
}, },
}, },
plugins: [], plugins: [],