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
+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 }) {
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 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)
}
export { Spinner, SkeletonRows, Switch } from '../../components/ui'
export { formatDateHeader, toLocalDateKey, groupByDate, withinNext3Days } from '../../lib/date'