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:
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user