diff --git a/frontend/src/admin/components.tsx b/frontend/src/admin/components.tsx index 2be2f87..b2f6fdc 100644 --- a/frontend/src/admin/components.tsx +++ b/frontend/src/admin/components.tsx @@ -1,438 +1,27 @@ /** - * Admin 后台 - 通用 UI 组件集合(报刊风) + * 【兼容壳】组件已合并到 `src/components/ui/`。 * - * 与前台共用同一套设计语言: - * - 纸色底(paper)、墨色字(ink)、印报红唯一强调(press) - * - 方正边框、细线分隔、宋体标题、无圆角、无彩色药丸标签 + * 历史:本文件曾是后台专属的组件库,与 `pages/matches/ui.tsx` 平行存在。 + * 二者分裂导致 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' - -// ── 卡片 ──────────────────────────────────────────────────────── - -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, - 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 ( -
-
-

-

- {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 Spinner({ className = '' }: { className?: string }) { - return ( - - ) -} - -// ── 骨架占位 ──────────────────────────────────────────────────── - -export function SkeletonBlock({ className = '' }: { className?: string }) { - return
-} - - -/** 空状态文本 */ -export function EmptyText({ text }: { text: string }) { - return ( -
- {text} -
- ) -} - -/** Agent 权重条形图 */ -export function AgentWeightsBar({ weights, okCount }: { weights: Record; 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 ( -
-
终裁专家权重
-
- {entries.map(([k, w], i) => ( -
-
-
-
- - {Math.round((w / total) * 100)}% - - {k} -
- ))} -
-
有效专家:{okCount}/{entries.length}
-
- ) -} +/** + * 骨架占位块。历史上的本地命名,内部已统一到基元 `Skeleton`。 + * 刻意只导出 `SkeletonBlock` 一个名字(不再顺带导出 `Skeleton`), + * 以保持本壳的导出面 == 迁移前的导出面,避免新增符号带来歧义。 + */ +export { Skeleton as SkeletonBlock } from '../components/ui' diff --git a/frontend/src/assets/fonts/LiuJianMaoCao-subset.woff2 b/frontend/src/assets/fonts/LiuJianMaoCao-subset.woff2 new file mode 100644 index 0000000..c317a7a Binary files /dev/null and b/frontend/src/assets/fonts/LiuJianMaoCao-subset.woff2 differ diff --git a/frontend/src/components/BackTop.tsx b/frontend/src/components/BackTop.tsx index 45eb8f4..1095fa5 100644 --- a/frontend/src/components/BackTop.tsx +++ b/frontend/src/components/BackTop.tsx @@ -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 ( + )} +
+
+ ) +} + +// ── 错误横幅:标准化错误标题/文案/建议动作 ────────────────────── + +/** 根据错误对象生成标准化的错误标题、文案与建议动作。 */ +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} +
+ ) +} diff --git a/frontend/src/components/ui/SectionHeader.tsx b/frontend/src/components/ui/SectionHeader.tsx new file mode 100644 index 0000000..36c6f33 --- /dev/null +++ b/frontend/src/components/ui/SectionHeader.tsx @@ -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 ( +
+
+

{title}

+ {description &&

{description}

} +
+ {action} +
+ ) +} diff --git a/frontend/src/components/ui/SkeletonRows.tsx b/frontend/src/components/ui/SkeletonRows.tsx new file mode 100644 index 0000000..22d771c --- /dev/null +++ b/frontend/src/components/ui/SkeletonRows.tsx @@ -0,0 +1,22 @@ +/** + * SkeletonRows:骨架占位行(低调脉动灰块)。 + * + * 原位于 pages/matches/ui.tsx —— 它是通用骨架,不属于 Matches 页面, + * 上移到 UI 层。单块骨架用 ,整行占位用本组件。 + */ + +export function SkeletonRows({ n = 4 }: { n?: number }) { + return ( + <> + {Array.from({ length: n }).map((_, i) => ( +
+
+
+
+
+
+
+ ))} + + ) +} diff --git a/frontend/src/components/ui/Stat.tsx b/frontend/src/components/ui/Stat.tsx new file mode 100644 index 0000000..c381cf4 --- /dev/null +++ b/frontend/src/components/ui/Stat.tsx @@ -0,0 +1,67 @@ +/** + * 统计与进度可视化:StatCard / ProgressBar / AgentWeightsBar。 + * 原位于 admin/components.tsx。 + */ + +export function StatCard({ + label, + value, + hint, +}: { + label: string + value: string | number + hint?: string +}) { + return ( +
+ {label} +
+ {value} +
+ {hint &&
{hint}
} +
+ ) +} + +/** 进度条:同前台置信度细线 */ +export function ProgressBar({ value, className = '' }: { value: number; className?: string }) { + const clamped = Math.max(0, Math.min(100, value)) + return ( +
+
+
+ ) +} + +/** Agent 权重条形图 */ +export function AgentWeightsBar({ weights, okCount }: { weights: Record; 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 ( +
+
终裁专家权重
+
+ {entries.map(([k, w], i) => ( +
+
+
+
+ + {Math.round((w / total) * 100)}% + + {k} +
+ ))} +
+
有效专家:{okCount}/{entries.length}
+
+ ) +} diff --git a/frontend/src/components/ui/Switch.tsx b/frontend/src/components/ui/Switch.tsx new file mode 100644 index 0000000..092b85b --- /dev/null +++ b/frontend/src/components/ui/Switch.tsx @@ -0,0 +1,31 @@ +/** + * Switch:一组互斥的文字切换(状态 / 模式)。 + * + * 原位于 pages/matches/ui.tsx,上移到 UI 层 —— 它用的是全站统一的 + * `.tab` / `.tab-on` 语言(方角、印报红下划线),属通用组件。 + * + * 注:内部的 + + ))} + + ) +} diff --git a/frontend/src/components/ui/Tabs.tsx b/frontend/src/components/ui/Tabs.tsx new file mode 100644 index 0000000..5b95596 --- /dev/null +++ b/frontend/src/components/ui/Tabs.tsx @@ -0,0 +1,85 @@ +/** + * Tabs —— 报刊风版面切换基元。 + * + * 背景:`Matches.tsx` 与 `Standings.tsx` 各写了一份高度雷同的联赛切换 + * ` + ) + })} + + ) +}) diff --git a/frontend/src/components/ui/index.tsx b/frontend/src/components/ui/index.tsx new file mode 100644 index 0000000..aa31cdf --- /dev/null +++ b/frontend/src/components/ui/index.tsx @@ -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 { + 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 = { + default: '', + solid: 'btn-solid', + outline: 'btn-outline', + danger: 'btn-danger', + ghost: 'btn-ghost', +} + +const SIZE_CLASS: Record = { + default: '', + sm: 'btn-sm', +} + +export interface ButtonProps extends ButtonHTMLAttributes { + variant?: ButtonVariant + size?: ButtonSize + /** 显示加载态:自动禁用并前置 Spinner */ + loading?: boolean + /** 占满父容器宽度 */ + block?: boolean + /** + * 传入时渲染为 `` 而非 ` + ) +}) + +// ── Input / Select ─────────────────────────────────────────────── + +/** + * 输入框基元:统一 `.field` 方角样式。 + * + * 无障碍提醒:请为输入框提供可访问名称 —— 要么传 `id` 并在外部配 + * `