From 2cfc9ccc0fdae8d07805bdfade5ff47d65af31f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=9A=E6=96=B9=E5=89=91?= Date: Tue, 22 Sep 2026 23:44:46 +0800 Subject: [PATCH] =?UTF-8?q?refactor(ui):=20=E5=90=88=E5=B9=B6=E5=8F=8C?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=E5=BA=93=E4=B8=BA=20components/ui=20?= =?UTF-8?q?=E5=9F=BA=E5=85=83=E5=B1=82,=E8=AE=BE=E8=AE=A1=E4=BB=A4?= =?UTF-8?q?=E7=89=8C=E5=8F=98=E9=87=8F=E5=8C=96,=E6=8A=A5=E5=A4=B4?= =?UTF-8?q?=E5=AD=97=E4=BD=93=E5=AD=90=E9=9B=86=E8=87=AA=E6=89=98=E7=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新建 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 接 (修复透明度修饰符静默失效) - 刘建毛草 5MB 全字库(jsDelivr 未锁版) → 1KB 自托管子集(仅「先知」二字,unicode-range 限定) --- frontend/src/admin/components.tsx | 455 +----------------- .../assets/fonts/LiuJianMaoCao-subset.woff2 | Bin 0 -> 1072 bytes frontend/src/components/BackTop.tsx | 14 +- frontend/src/components/ui/Badge.tsx | 39 ++ frontend/src/components/ui/Card.tsx | 49 ++ frontend/src/components/ui/DataTable.tsx | 118 +++++ frontend/src/components/ui/Feedback.tsx | 151 ++++++ frontend/src/components/ui/SectionHeader.tsx | 25 + frontend/src/components/ui/SkeletonRows.tsx | 22 + frontend/src/components/ui/Stat.tsx | 67 +++ frontend/src/components/ui/Switch.tsx | 31 ++ frontend/src/components/ui/Tabs.tsx | 85 ++++ frontend/src/components/ui/index.tsx | 327 +++++++++++++ frontend/src/index.css | 77 ++- frontend/src/lib/date.ts | 63 +++ frontend/src/lib/useScroll.ts | 86 ++++ frontend/src/pages/matches/ui.tsx | 121 +---- frontend/tailwind.config.js | 65 ++- 18 files changed, 1226 insertions(+), 569 deletions(-) create mode 100644 frontend/src/assets/fonts/LiuJianMaoCao-subset.woff2 create mode 100644 frontend/src/components/ui/Badge.tsx create mode 100644 frontend/src/components/ui/Card.tsx create mode 100644 frontend/src/components/ui/DataTable.tsx create mode 100644 frontend/src/components/ui/Feedback.tsx create mode 100644 frontend/src/components/ui/SectionHeader.tsx create mode 100644 frontend/src/components/ui/SkeletonRows.tsx create mode 100644 frontend/src/components/ui/Stat.tsx create mode 100644 frontend/src/components/ui/Switch.tsx create mode 100644 frontend/src/components/ui/Tabs.tsx create mode 100644 frontend/src/components/ui/index.tsx create mode 100644 frontend/src/lib/date.ts create mode 100644 frontend/src/lib/useScroll.ts 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 0000000000000000000000000000000000000000..c317a7a633abce4a00553713766067c7903ada04 GIT binary patch literal 1072 zcmV-01kd|-Pew8T0RR9100b}q3;+NC00)c!00ZFw0RY4R00000000000000000000 z0000S3I<>RJP`;AhFFCq0X7080}2ZW00bZfga`*98~z0&k}%4B0Dp17lcB}~YPET_ z##`a7sQCI)5$O5&HJ(T~yi{idG-~HHbUrm~D^Hi}5oBvP`}ajqRaJyS5ebK?!lCeG z;aFE_LSZX`glzLa+P%3#S#$Yvp~=J65uCkt1M^xZt7=Z-WCp;gETxlTDV9Efd@p(M zOAa6x!If_C;KJArTb9v-%O1yqLvXQ8^g4MO-I9nmOdrusU2cnI`=6UVZ(1*bPbnrk zmxfusa7~bmwgpmnL*VRy`G604cb}>ITtm$>3PZqoo?;xJM6#{8dd&G(v25s%5>A!7 zYjip}@8VRp`Ig#>F@(}*Tq^?NOy@ybAwxoQ5@^nKP%0NP{Gy=y=O<%6J4A@0P=|Sx zu0qj^#MCXs9X}!4caC(6tj$HlREEK;YLdvZ4T+e|u&`D21hdsDQj@Ol6D;s;;i^J% zgK+IImeB=YY5Xw#G=IwfLp9I*%9Ja7OItMOD(BZ@I9d53Uqx%_RAYfxi`VP6&%knn zj^3qnx;EtjkNL%7lO6SuBIC%^!cw+2qFt=oY7;Rsk;XGOC>YrDOas*oM=`aFCJ_rV z6@I$oLGtS4Ng`pFtEMtxv~K0 z+D(B><9Fj+^~QPmTiani_00Ppt!`RJqiOooJr$V-&ob3%`p^RW=rDmgus~C7foU!I zR8t{r*na1gD{)>Ek*1s^0hCyekIn$EEE6UakI{T8=_Ca%&;g=kHpW+pmX0eS(NXr4 z7a-=WBg=ls9Dyc`t$E3fxHon-R_~)R2$18XvP)-YtN@u7BIaDlJ4;ZtF9F2EpIw6FuEDn*Jm8~UH)WX**W@O8vFwG8 literal 0 HcmV?d00001 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` 并在外部配 + * `