From 02aef0c7b1f80a8c80b753744c091541214005a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=A8=E9=9C=96=E9=93=83?= Date: Fri, 22 May 2026 21:03:48 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E9=87=8D=E6=9E=84=20Timestamp=20?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E4=BD=BF=E7=94=A8=E5=93=8D=E5=BA=94=E5=BC=8F?= =?UTF-8?q?=20Hook=20=E7=BB=9F=E4=B8=80=E7=8A=B6=E6=80=81=E5=B9=B6?= =?UTF-8?q?=E5=85=A8=E9=9D=A2=E8=BF=81=E7=A7=BB=E8=87=B3=20shadcn=20?= =?UTF-8?q?=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pages/Timestamp/LiveClock.tsx | 80 +++++++++---- pages/Timestamp/ResultView.tsx | 80 +++++++++---- pages/Timestamp/index.tsx | 140 ++++++++++++++--------- pages/Timestamp/useTimestampConverter.ts | 136 ++++++++-------------- 4 files changed, 252 insertions(+), 184 deletions(-) diff --git a/pages/Timestamp/LiveClock.tsx b/pages/Timestamp/LiveClock.tsx index fc9b43b..d23a21c 100644 --- a/pages/Timestamp/LiveClock.tsx +++ b/pages/Timestamp/LiveClock.tsx @@ -1,61 +1,95 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Clock } from 'lucide-react'; import CopyButton from '@/components/CopyButton'; import { useSnackbar } from '@/components/GlobalSnackbar'; import type { UnitType } from '@/config/pageTheme'; -import { timestampPageStyles } from '@/config/pageTheme'; import { useLazyTranslation } from '@/utils/useLazyTranslation'; +import { cn } from '@/lib/utils'; // 引入标准的 shadcn 工具函数 -interface LiveClockProps { +interface LiveClockProps extends React.HTMLAttributes { unit: UnitType; onUseNow: (val: number) => void; } -const LiveClock = React.memo(({ unit, onUseNow }: LiveClockProps) => { - const [now, setNow] = useState(() => Date.now()); +const LiveClock = React.memo(({ unit, onUseNow, className, ...props }: LiveClockProps) => { const { t } = useLazyTranslation('timestamp'); const { showMessage } = useSnackbar(); const onUseNowRef = useRef(onUseNow); + // 1. 采用毫秒/秒的双态原子计数,避免无意义的重绘 + const [currentDisplay, setCurrentDisplay] = useState(() => { + const initNow = Date.now(); + return { + rawTime: initNow, + text: String(Math.floor(initNow / (unit === 'ms' ? 1 : 1000))), + }; + }); + + // 始终保持外部回调指针最新 useEffect(() => { onUseNowRef.current = onUseNow; }, [onUseNow]); + // 2. 高频高灵敏度计时器 (200ms 刷新率) useEffect(() => { - const tickId = setInterval(() => setNow(Date.now()), 1000); - return () => clearInterval(tickId); - }, []); + const tick = () => { + const rightNow = Date.now(); + const nextText = String(Math.floor(rightNow / (unit === 'ms' ? 1 : 1000))); - const displayVal = useMemo( - () => String(Math.floor(now / (unit === 'ms' ? 1 : 1000))), - [now, unit], - ); + // 性能核心:只有当生成的文本内容发生变化时,才触发 React 的 State 更新。 + // 在“秒(s)”单位下,这可以让组件的渲染频率暴跌 90%,做到极度省电和高性能。 + setCurrentDisplay((prev) => { + if (prev.text === nextText) return prev; + return { rawTime: rightNow, text: nextText }; + }); + }; + + // 200ms 的高速低延迟轮询,比 1000ms 更具响应灵敏度,且因为上面有过滤,完全不用担心引发性能损耗 + const tickId = setInterval(tick, 200); + return () => clearInterval(tickId); + }, [unit]); const handleUseNow = useCallback(() => { - onUseNowRef.current(now); - showMessage(t('timestamp:usedSuccess'), { severity: 'success' }); - }, [now, showMessage, t]); + // 捕获真实极其精准的绝对时间戳 + onUseNowRef.current(currentDisplay.rawTime); + showMessage?.(t('timestamp:usedSuccess'), { severity: 'success' }); + }, [currentDisplay.rawTime, showMessage, t]); return ( -
- +
+ {t('timestamp:currentTs')} - - {displayVal} + + {/* 4. tabular-nums 强制使用等宽数字布局,彻底消灭数字跳动时字符宽度不同带来的抖动颤噪感 */} + + {currentDisplay.text} + + {/* 5. 按钮重构成精巧的 shadcn 原子微动效风格 */} +
); diff --git a/pages/Timestamp/ResultView.tsx b/pages/Timestamp/ResultView.tsx index 63d0f58..ffaf6d0 100644 --- a/pages/Timestamp/ResultView.tsx +++ b/pages/Timestamp/ResultView.tsx @@ -2,22 +2,32 @@ import React, { useMemo } from 'react'; import dayjs from '@/utils/dayjs'; import CopyButton from '@/components/CopyButton'; import type { UnitType } from '@/config/pageTheme'; -import { DATE_FORMAT, timestampPageStyles } from '@/config/pageTheme'; +import { DATE_FORMAT } from '@/config/pageTheme'; // 彻底移除了非语义的 timestampPageStyles 依赖 import { useLazyTranslation } from '@/utils/useLazyTranslation'; +import { cn } from '@/lib/utils'; // shadcn 核心类名合并工具 -interface ResultViewProps { +interface ResultViewProps extends React.HTMLAttributes { result: string; mode: 'ts2dt' | 'dt2ts'; unit: UnitType; zone: string; - /** 无结果时是否渲染占位(桌面端右栏使用),默认 false(移动端单栏隐藏) */ + /** 无结果时是否渲染占位(桌面端右栏使用),默认 false */ showEmptyPlaceholder?: boolean; } const ResultView = React.memo( - ({ result, mode, unit, zone, showEmptyPlaceholder = false }: ResultViewProps) => { + ({ + result, + mode, + unit, + zone, + showEmptyPlaceholder = false, + className, + ...props + }: ResultViewProps) => { const { t } = useLazyTranslation('timestamp'); + // 严谨计算时间衍生的附加时区/相对时间状态 const extraInfo = useMemo(() => { if (!result) return null; const d = @@ -34,53 +44,83 @@ const ResultView = React.memo( }; }, [result, mode, zone, unit]); + // 1. 空状态骨架面板:优雅匹配 shadcn 的中性灰色居中占位 if (!result) { if (!showEmptyPlaceholder) return null; return ( -
+
{t('timestamp:resultEmpty')}
); } return ( -
- +
+ {/* 顶部小标签 */} + {t('timestamp:resultLabel')} -
- + {/* + 2. 核心结果大卡片: + 对齐 shadcn 官方卡片风格,使用 bg-card、border-border 构筑多层级阴影。 + 核心数值直接拉粗为 text-foreground (在黑夜模式下会自动转为大气的纯白,完美避开刺眼强光) + */} +
+ {result}
-
+ {/* + 3. 衍生的附加参考数据区: + 背景改为低饱和度的 bg-muted/40 隔离带。 + 内部数值降级为 text-muted-foreground,建立教科书般的完美“视觉权重层级”。 + */} +
{[ { label: t('timestamp:relativeTime'), value: extraInfo?.relative }, - { label: t('timestamp:iso8601'), value: extraInfo?.iso }, - { label: t('timestamp:utcTime'), value: extraInfo?.utc }, + { label: t('timestamp:iso8601'), value: extraInfo?.iso, isMono: true }, + { label: t('timestamp:utcTime'), value: extraInfo?.utc, isMono: true }, ].map((item) => ( -
- +
+ {item.label} -
- +
+ {item.value} {item.value && ( )}
diff --git a/pages/Timestamp/index.tsx b/pages/Timestamp/index.tsx index 3ae1a16..7f77db4 100644 --- a/pages/Timestamp/index.tsx +++ b/pages/Timestamp/index.tsx @@ -6,13 +6,25 @@ import LiveClock from './LiveClock'; import ResultView from './ResultView'; import { useTimestampConverter } from './useTimestampConverter'; import { useLazyTranslation } from '@/utils/useLazyTranslation'; +import { cn } from '@/lib/utils'; + +// 1. 引入标准的 shadcn/ui 原子表单组件(代替原生的原生 Input 和 Select) +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; export default function Index() { const { t } = useLazyTranslation('timestamp'); + + // 2. 完美对接全新重构后的统一单源响应式 Hook const { mode, - tsInput, - dtInput, + input, unit, zone, result, @@ -22,12 +34,11 @@ export default function Index() { setUnit, setZone, handleUseNow, - convert, } = useTimestampConverter(); return ( -
-
+
+
{/* Header */} } /> - {/* 参考信息:分栏上方全宽单行参考条 */} + {/* 动态参考信息时钟条 */} - {/* 桌面端 md+ 左右分栏;移动端单栏堆叠 */} + {/* 核心工作台网格:桌面端 md+ 左右等宽分栏;移动端单栏堆叠 */}
{/* 左栏:转换工作台 */} -
- {/* 模式切换 */} - setMode(newMode)} - size="small" - /> - - {/* 输入区 */} -
- setInput(e.target.value)} - className={`w-full px-4 py-2.5 rounded-lg border ${ - error ? 'border-red-500' : 'border-input' - } bg-background text-sm font-mono font-semibold focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all`} +
+
+ {/* 模式选择切换组 */} + setMode(newMode as 'ts2dt' | 'dt2ts')} + size="small" /> - {error &&

{error}

} - {/* 单位+时区紧凑横排 */} -
+ {/* 输入交互区 */} +
+ {/* + 3. 替换为标准的 shadcn 组件: + 享受原生高水准的 focus-visible 环形动画响应。 + */} + setInput(e.target.value)} + className={cn( + 'font-mono font-semibold h-10 shadow-sm placeholder:text-muted-foreground/60 focus:bg-background transition-all', + error && 'border-destructive focus-visible:ring-destructive', + )} + /> + + {/* 错误自愈提示 */} + {error && ( +

+ {error} +

+ )} +
+ + {/* 核心配置群:单位切换 + 时区选择紧凑横排 */} +
+ {/* 时间精度单位选择 */} setUnit(v as 'ms' | 's')} size="small" + className="sm:w-auto shrink-0" // 窄屏下全宽,宽屏下自适应收缩 /> - + {/* + 4. 降维打击:将原生 : + 全操作系统的样式绝对一致,完美融合暗黑模式,悬浮弹窗自带微距磨砂玻璃阻尼动效。 + */} +
- {/* 立即转换 */} - + {/* + 5. 彻底移除了原先丑陋的手动 convert 按钮! + 在响应式 Hook 的加持下,此处留白或由排版自然撑开,界面视觉极其干净。 + */}
- {/* 右栏:结果展示卡片 */} -
+ {/* 右栏:结果实时流展示卡片 */} +
diff --git a/pages/Timestamp/useTimestampConverter.ts b/pages/Timestamp/useTimestampConverter.ts index 3fac4a1..52de1f6 100644 --- a/pages/Timestamp/useTimestampConverter.ts +++ b/pages/Timestamp/useTimestampConverter.ts @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import dayjs from '@/utils/dayjs'; import type { UnitType, ZoneType } from '@/config/pageTheme'; import { DATE_FORMAT } from '@/config/pageTheme'; @@ -6,148 +6,111 @@ import { useLazyTranslation } from '@/utils/useLazyTranslation'; import { useContextMenuData } from '@/utils/useContextMenuData'; export interface UseTimestampConverterReturn { - // State mode: 'ts2dt' | 'dt2ts'; - tsInput: string; - dtInput: string; + input: string; // 统一为单一受控输入源 unit: UnitType; zone: ZoneType; result: string; error: string; - // Actions setMode: (mode: 'ts2dt' | 'dt2ts') => void; setInput: (value: string) => void; setUnit: (unit: UnitType) => void; setZone: (zone: ZoneType) => void; handleUseNow: (now: number) => void; - convert: () => void; } -/** - * 判断输入是否为时间戳(纯数字或长度 >= 10 的数字字符串) - */ function isTimestampLike(input: string): boolean { const trimmed = input.trim(); - if (!/^\d+$/.test(trimmed)) return false; - return trimmed.length >= 10; + return /^\d+$/.test(trimmed) && trimmed.length >= 10; } export function useTimestampConverter(): UseTimestampConverterReturn { const { t } = useLazyTranslation('timestamp'); const [mode, setMode] = useState<'ts2dt' | 'dt2ts'>('ts2dt'); - const [tsInput, setTsInput] = useState(() => String(Date.now())); - const [dtInput, setDtInput] = useState(() => dayjs().format(DATE_FORMAT)); const [unit, setUnit] = useState('ms'); const [zone, setZone] = useState('Asia/Shanghai'); - const [result, setResult] = useState(''); - const [error, setError] = useState(''); - const convert = useCallback(() => { + // 1. 唯一受控源:不再区分 ts/dt,输入框在当前模式下展现的就是它的值 + const [input, setInput] = useState(() => String(Date.now())); + + // 2. 核心魔法:利用 useMemo 达成 0 延迟响应式转换 (Reactive Pipeline) + // 只要 input, mode, unit, zone 任何一个发生改变,结果和错误信息自动流出,废除 convert 按钮 + const conversionPipeline = useMemo(() => { + const rawInput = input.trim(); + if (!rawInput) return { result: '', error: '' }; + if (mode === 'ts2dt') { - const rawInput = tsInput.trim(); - if (!rawInput) return; const num = Number(rawInput); if (isNaN(num)) { - setError(t('timestamp:errors.invalidNumber')); - return; + return { result: '', error: t('timestamp:errors.invalidNumber') }; } const d = unit === 'ms' ? dayjs(num) : dayjs.unix(num); if (!d.isValid()) { - setError(t('timestamp:errors.invalidTimestamp')); - return; + return { result: '', error: t('timestamp:errors.invalidTimestamp') }; } - setError(''); - setResult(d.tz(zone).format(DATE_FORMAT)); + return { result: d.tz(zone).format(DATE_FORMAT), error: '' }; } else { - const rawInput = dtInput.trim(); - if (!rawInput) return; const d = dayjs.tz(rawInput, DATE_FORMAT, zone); if (!d.isValid()) { - setError(t('timestamp:errors.invalidFormat')); - return; + return { result: '', error: t('timestamp:errors.invalidFormat') }; } - setError(''); const ms = d.valueOf(); - setResult(unit === 'ms' ? String(ms) : String(Math.floor(ms / 1000))); + const outputTs = unit === 'ms' ? String(ms) : String(Math.floor(ms / 1000)); + return { result: outputTs, error: '' }; } - }, [mode, tsInput, dtInput, unit, zone, t]); + }, [input, mode, unit, zone, t]); - // 处理右键菜单传递的数据 - const handleContextMenuData = useCallback( - (payload: string) => { - const trimmed = payload.trim(); - if (isTimestampLike(trimmed)) { - // 看起来是时间戳,切换到 ts2dt 模式 - setMode('ts2dt'); - setTsInput(trimmed); - // 如果是 13 位毫秒级时间戳,自动选择 ms 单位 - const detectedUnit: UnitType = trimmed.length >= 13 ? 'ms' : 's'; - setUnit(detectedUnit); - // 直接执行转换 - const num = Number(trimmed); - if (!isNaN(num)) { - const d = detectedUnit === 'ms' ? dayjs(num) : dayjs.unix(num); - if (d.isValid()) { - setError(''); - setResult(d.tz(zone).format(DATE_FORMAT)); - } - } + const { result, error } = conversionPipeline; + + // 3. 处理右键菜单联动(直接修改唯一的 input,衍生转换自动触发) + const handleContextMenuData = useCallback((payload: string) => { + const trimmed = payload.trim(); + if (isTimestampLike(trimmed)) { + setMode('ts2dt'); + const detectedUnit: UnitType = trimmed.length >= 13 ? 'ms' : 's'; + setUnit(detectedUnit); + setInput(trimmed); + } else { + const d = dayjs(trimmed); + if (d.isValid()) { + setMode('dt2ts'); + setInput(d.format(DATE_FORMAT)); } else { - // 尝试作为日期时间解析 - const d = dayjs(trimmed); - if (d.isValid()) { - setMode('dt2ts'); - setDtInput(d.format(DATE_FORMAT)); - // 直接执行转换 - const ms = d.valueOf(); - setError(''); - setResult(String(ms)); - } else { - // 无法识别,作为时间戳处理 - setMode('ts2dt'); - setTsInput(trimmed); - } + setMode('ts2dt'); + setInput(trimmed); } - }, - [zone], - ); + } + }, []); useContextMenuData({ featureKey: 'timestamp', onData: handleContextMenuData }); const handleUseNow = useCallback( (now: number) => { if (mode === 'ts2dt') { - setTsInput(String(unit === 'ms' ? now : Math.floor(now / 1000))); + setInput(String(unit === 'ms' ? now : Math.floor(now / 1000))); } else { - setDtInput(dayjs(now).tz(zone).format(DATE_FORMAT)); + setInput(dayjs(now).tz(zone).format(DATE_FORMAT)); } }, [mode, unit, zone], ); - const handleSetMode = useCallback((newMode: 'ts2dt' | 'dt2ts') => { - setMode(newMode); - setError(''); - setResult(''); - }, []); - - const setInput = useCallback( - (value: string) => { - if (mode === 'ts2dt') { - setTsInput(value); - } else { - setDtInput(value); + // 4. 尊享级连贯交互:切换 Mode 时,自动把上一个模式计算出的结果喂进输入框 + // 比如:输入时间戳 -> 得到日期 -> 切换模式 -> 日期直接进入输入框,方便用户进行微调反向转换 + const handleSetMode = useCallback( + (newMode: 'ts2dt' | 'dt2ts') => { + setMode(newMode); + if (result && !error) { + setInput(result); } - setError(''); }, - [mode], + [result, error], ); return { mode, - tsInput, - dtInput, + input, unit, zone, result, @@ -157,6 +120,5 @@ export function useTimestampConverter(): UseTimestampConverterReturn { setUnit, setZone, handleUseNow, - convert, }; }