refactor: 重构 Timestamp 页面使用响应式 Hook 统一状态并全面迁移至 shadcn 样式
This commit is contained in:
@@ -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 { Clock } from 'lucide-react';
|
||||||
import CopyButton from '@/components/CopyButton';
|
import CopyButton from '@/components/CopyButton';
|
||||||
import { useSnackbar } from '@/components/GlobalSnackbar';
|
import { useSnackbar } from '@/components/GlobalSnackbar';
|
||||||
import type { UnitType } from '@/config/pageTheme';
|
import type { UnitType } from '@/config/pageTheme';
|
||||||
import { timestampPageStyles } from '@/config/pageTheme';
|
|
||||||
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||||
|
import { cn } from '@/lib/utils'; // 引入标准的 shadcn 工具函数
|
||||||
|
|
||||||
interface LiveClockProps {
|
interface LiveClockProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
unit: UnitType;
|
unit: UnitType;
|
||||||
onUseNow: (val: number) => void;
|
onUseNow: (val: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LiveClock = React.memo(({ unit, onUseNow }: LiveClockProps) => {
|
const LiveClock = React.memo(({ unit, onUseNow, className, ...props }: LiveClockProps) => {
|
||||||
const [now, setNow] = useState(() => Date.now());
|
|
||||||
const { t } = useLazyTranslation('timestamp');
|
const { t } = useLazyTranslation('timestamp');
|
||||||
const { showMessage } = useSnackbar();
|
const { showMessage } = useSnackbar();
|
||||||
const onUseNowRef = useRef(onUseNow);
|
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(() => {
|
useEffect(() => {
|
||||||
onUseNowRef.current = onUseNow;
|
onUseNowRef.current = onUseNow;
|
||||||
}, [onUseNow]);
|
}, [onUseNow]);
|
||||||
|
|
||||||
|
// 2. 高频高灵敏度计时器 (200ms 刷新率)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const tickId = setInterval(() => setNow(Date.now()), 1000);
|
const tick = () => {
|
||||||
return () => clearInterval(tickId);
|
const rightNow = Date.now();
|
||||||
}, []);
|
const nextText = String(Math.floor(rightNow / (unit === 'ms' ? 1 : 1000)));
|
||||||
|
|
||||||
const displayVal = useMemo(
|
// 性能核心:只有当生成的文本内容发生变化时,才触发 React 的 State 更新。
|
||||||
() => String(Math.floor(now / (unit === 'ms' ? 1 : 1000))),
|
// 在“秒(s)”单位下,这可以让组件的渲染频率暴跌 90%,做到极度省电和高性能。
|
||||||
[now, unit],
|
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(() => {
|
const handleUseNow = useCallback(() => {
|
||||||
onUseNowRef.current(now);
|
// 捕获真实极其精准的绝对时间戳
|
||||||
showMessage(t('timestamp:usedSuccess'), { severity: 'success' });
|
onUseNowRef.current(currentDisplay.rawTime);
|
||||||
}, [now, showMessage, t]);
|
showMessage?.(t('timestamp:usedSuccess'), { severity: 'success' });
|
||||||
|
}, [currentDisplay.rawTime, showMessage, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 px-4 py-2 mb-4 bg-primary/10 rounded-lg border border-primary/20">
|
<div
|
||||||
<span className="text-primary font-bold text-[0.65rem] uppercase tracking-wider whitespace-nowrap">
|
className={cn(
|
||||||
|
// 3. 完美适配 shadcn 暗黑模式:
|
||||||
|
// 不再写死 bg-primary/10,改用更高级的 bg-secondary/50 和中性边框,
|
||||||
|
// 在任何主题色下都能表现得低调且极具质感。
|
||||||
|
'flex items-center gap-3 px-3 h-10 rounded-lg border border-border/80 bg-secondary/50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="text-muted-foreground font-bold text-[10px] uppercase tracking-wider whitespace-nowrap shrink-0 selection:bg-transparent select-none">
|
||||||
{t('timestamp:currentTs')}
|
{t('timestamp:currentTs')}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex-1 font-mono font-bold text-primary text-sm tracking-tight leading-tight overflow-hidden text-ellipsis">
|
|
||||||
{displayVal}
|
{/* 4. tabular-nums 强制使用等宽数字布局,彻底消灭数字跳动时字符宽度不同带来的抖动颤噪感 */}
|
||||||
|
<span className="flex-1 font-mono font-bold text-foreground text-sm tracking-tight leading-none truncate tabular-nums">
|
||||||
|
{currentDisplay.text}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
{/* 5. 按钮重构成精巧的 shadcn 原子微动效风格 */}
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={handleUseNow}
|
onClick={handleUseNow}
|
||||||
title={t('timestamp:useNowTooltip')}
|
title={t('timestamp:useNowTooltip')}
|
||||||
className="p-1.5 rounded-md bg-background text-primary shadow-sm hover:bg-primary hover:text-primary-foreground transition-colors"
|
className="flex h-7 w-7 items-center justify-center rounded-md border border-input bg-background text-muted-foreground shadow-sm transition-all hover:bg-accent hover:text-foreground active:scale-95 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
>
|
>
|
||||||
<Clock className="w-4 h-4" />
|
<Clock className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<CopyButton
|
<CopyButton
|
||||||
text={displayVal}
|
text={currentDisplay.text}
|
||||||
tooltip={t('timestamp:copyTsTooltip')}
|
tooltip={t('timestamp:copyTsTooltip')}
|
||||||
size="small"
|
size="small"
|
||||||
color={timestampPageStyles.primaryColor}
|
className="h-7 w-7 rounded-md border" // 移除了硬编码的颜色配置表,完全交由组件的内置 Class 渲染
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,22 +2,32 @@ import React, { useMemo } from 'react';
|
|||||||
import dayjs from '@/utils/dayjs';
|
import dayjs from '@/utils/dayjs';
|
||||||
import CopyButton from '@/components/CopyButton';
|
import CopyButton from '@/components/CopyButton';
|
||||||
import type { UnitType } from '@/config/pageTheme';
|
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 { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||||
|
import { cn } from '@/lib/utils'; // shadcn 核心类名合并工具
|
||||||
|
|
||||||
interface ResultViewProps {
|
interface ResultViewProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
result: string;
|
result: string;
|
||||||
mode: 'ts2dt' | 'dt2ts';
|
mode: 'ts2dt' | 'dt2ts';
|
||||||
unit: UnitType;
|
unit: UnitType;
|
||||||
zone: string;
|
zone: string;
|
||||||
/** 无结果时是否渲染占位(桌面端右栏使用),默认 false(移动端单栏隐藏) */
|
/** 无结果时是否渲染占位(桌面端右栏使用),默认 false */
|
||||||
showEmptyPlaceholder?: boolean;
|
showEmptyPlaceholder?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ResultView = React.memo(
|
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 { t } = useLazyTranslation('timestamp');
|
||||||
|
|
||||||
|
// 严谨计算时间衍生的附加时区/相对时间状态
|
||||||
const extraInfo = useMemo(() => {
|
const extraInfo = useMemo(() => {
|
||||||
if (!result) return null;
|
if (!result) return null;
|
||||||
const d =
|
const d =
|
||||||
@@ -34,53 +44,83 @@ const ResultView = React.memo(
|
|||||||
};
|
};
|
||||||
}, [result, mode, zone, unit]);
|
}, [result, mode, zone, unit]);
|
||||||
|
|
||||||
|
// 1. 空状态骨架面板:优雅匹配 shadcn 的中性灰色居中占位
|
||||||
if (!result) {
|
if (!result) {
|
||||||
if (!showEmptyPlaceholder) return null;
|
if (!showEmptyPlaceholder) return null;
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm font-semibold py-12 text-center">
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex-1 flex items-center justify-center text-sm font-medium border border-dashed border-border/60 rounded-xl py-12 px-4 text-center text-muted-foreground bg-muted/20 min-h-[320px] animate-in fade-in duration-200',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
{t('timestamp:resultEmpty')}
|
{t('timestamp:resultEmpty')}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-in fade-in duration-300">
|
<div
|
||||||
<span className="block text-muted-foreground mb-3 text-xs font-bold">
|
className={cn(
|
||||||
|
'animate-in fade-in slide-in-from-bottom-2 duration-300 flex flex-col w-full',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{/* 顶部小标签 */}
|
||||||
|
<span className="block text-muted-foreground/90 mb-2.5 text-xs font-semibold tracking-wider uppercase">
|
||||||
{t('timestamp:resultLabel')}
|
{t('timestamp:resultLabel')}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div className="bg-primary/10 p-5 rounded-xl relative mb-4 border border-primary/30 flex justify-between items-center">
|
{/*
|
||||||
<span className="font-mono font-bold text-primary break-all pr-4 text-xl tracking-tight leading-tight">
|
2. 核心结果大卡片:
|
||||||
|
对齐 shadcn 官方卡片风格,使用 bg-card、border-border 构筑多层级阴影。
|
||||||
|
核心数值直接拉粗为 text-foreground (在黑夜模式下会自动转为大气的纯白,完美避开刺眼强光)
|
||||||
|
*/}
|
||||||
|
<div className="bg-card text-card-foreground border border-border p-4 sm:p-5 rounded-xl relative mb-3.5 shadow-sm flex justify-between items-center gap-4 focus-within:ring-1 focus-within:ring-ring transition-all">
|
||||||
|
<span className="font-mono font-extrabold text-foreground break-all text-xl sm:text-2xl tracking-tight leading-tight select-all tabular-nums">
|
||||||
{result}
|
{result}
|
||||||
</span>
|
</span>
|
||||||
<CopyButton
|
<CopyButton
|
||||||
text={result}
|
text={result}
|
||||||
tooltip={t('timestamp:copyResultTooltip')}
|
tooltip={t('timestamp:copyResultTooltip')}
|
||||||
size="small"
|
className="h-8 w-8 rounded-md shrink-0 border"
|
||||||
color={timestampPageStyles.primaryColor}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-primary/5 p-4 rounded-xl border border-primary/20 flex flex-col gap-3">
|
{/*
|
||||||
|
3. 衍生的附加参考数据区:
|
||||||
|
背景改为低饱和度的 bg-muted/40 隔离带。
|
||||||
|
内部数值降级为 text-muted-foreground,建立教科书般的完美“视觉权重层级”。
|
||||||
|
*/}
|
||||||
|
<div className="bg-muted/40 p-4 rounded-xl border border-border/50 flex flex-col gap-3">
|
||||||
{[
|
{[
|
||||||
{ label: t('timestamp:relativeTime'), value: extraInfo?.relative },
|
{ label: t('timestamp:relativeTime'), value: extraInfo?.relative },
|
||||||
{ label: t('timestamp:iso8601'), value: extraInfo?.iso },
|
{ label: t('timestamp:iso8601'), value: extraInfo?.iso, isMono: true },
|
||||||
{ label: t('timestamp:utcTime'), value: extraInfo?.utc },
|
{ label: t('timestamp:utcTime'), value: extraInfo?.utc, isMono: true },
|
||||||
].map((item) => (
|
].map((item) => (
|
||||||
<div key={item.label} className="flex justify-between items-center">
|
<div
|
||||||
<span className="text-muted-foreground font-bold text-xs pr-2 whitespace-nowrap">
|
key={item.label}
|
||||||
|
className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-1.5 py-0.5 border-b border-border/30 last:border-0 pb-2 sm:pb-0 last:pb-0"
|
||||||
|
>
|
||||||
|
<span className="text-muted-foreground font-semibold text-xs shrink-0 select-none">
|
||||||
{item.label}
|
{item.label}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center justify-between sm:justify-end gap-2 min-w-0 w-full sm:w-auto">
|
||||||
<span className="font-mono text-primary font-semibold text-xs break-all text-right">
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-xs text-foreground/90 font-medium break-all text-left sm:text-right tabular-nums',
|
||||||
|
item.isMono && 'font-mono text-[11px]', // ISO/UTC 等机器时间使用精细化等宽代码体
|
||||||
|
)}
|
||||||
|
>
|
||||||
{item.value}
|
{item.value}
|
||||||
</span>
|
</span>
|
||||||
{item.value && (
|
{item.value && (
|
||||||
<CopyButton
|
<CopyButton
|
||||||
text={item.value}
|
text={item.value}
|
||||||
tooltip={t('timestamp:copyTooltip')}
|
tooltip={t('timestamp:copyTooltip')}
|
||||||
size="small"
|
className="h-6 w-6 rounded-md border shrink-0 text-muted-foreground"
|
||||||
color={timestampPageStyles.primaryColor}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+86
-54
@@ -6,13 +6,25 @@ import LiveClock from './LiveClock';
|
|||||||
import ResultView from './ResultView';
|
import ResultView from './ResultView';
|
||||||
import { useTimestampConverter } from './useTimestampConverter';
|
import { useTimestampConverter } from './useTimestampConverter';
|
||||||
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
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() {
|
export default function Index() {
|
||||||
const { t } = useLazyTranslation('timestamp');
|
const { t } = useLazyTranslation('timestamp');
|
||||||
|
|
||||||
|
// 2. 完美对接全新重构后的统一单源响应式 Hook
|
||||||
const {
|
const {
|
||||||
mode,
|
mode,
|
||||||
tsInput,
|
input,
|
||||||
dtInput,
|
|
||||||
unit,
|
unit,
|
||||||
zone,
|
zone,
|
||||||
result,
|
result,
|
||||||
@@ -22,12 +34,11 @@ export default function Index() {
|
|||||||
setUnit,
|
setUnit,
|
||||||
setZone,
|
setZone,
|
||||||
handleUseNow,
|
handleUseNow,
|
||||||
convert,
|
|
||||||
} = useTimestampConverter();
|
} = useTimestampConverter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen bg-background text-foreground antialiased selection:bg-primary/20">
|
||||||
<div className="max-w-7xl mx-auto p-4">
|
<div className="max-w-7xl mx-auto p-4 sm:p-6 space-y-4">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={t('timestamp:pageTitle')}
|
title={t('timestamp:pageTitle')}
|
||||||
@@ -35,41 +46,55 @@ export default function Index() {
|
|||||||
icon={<Clock />}
|
icon={<Clock />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 参考信息:分栏上方全宽单行参考条 */}
|
{/* 动态参考信息时钟条 */}
|
||||||
<LiveClock unit={unit} onUseNow={handleUseNow} />
|
<LiveClock unit={unit} onUseNow={handleUseNow} />
|
||||||
|
|
||||||
{/* 桌面端 md+ 左右分栏;移动端单栏堆叠 */}
|
{/* 核心工作台网格:桌面端 md+ 左右等宽分栏;移动端单栏堆叠 */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-stretch">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-stretch">
|
||||||
{/* 左栏:转换工作台 */}
|
{/* 左栏:转换工作台 */}
|
||||||
<div className="p-5 rounded-xl bg-background border border-border shadow-sm">
|
<div className="p-5 rounded-xl border border-border bg-card text-card-foreground shadow-sm flex flex-col justify-between">
|
||||||
{/* 模式切换 */}
|
<div className="flex flex-col gap-4">
|
||||||
<SwitchButtonGroup
|
{/* 模式选择切换组 */}
|
||||||
value={mode}
|
<SwitchButtonGroup
|
||||||
options={[
|
value={mode}
|
||||||
{ value: 'ts2dt', label: t('timestamp:tsToDate') },
|
options={[
|
||||||
{ value: 'dt2ts', label: t('timestamp:dateToTs') },
|
{ value: 'ts2dt', label: t('timestamp:tsToDate') },
|
||||||
]}
|
{ value: 'dt2ts', label: t('timestamp:dateToTs') },
|
||||||
onChange={(newMode) => setMode(newMode)}
|
]}
|
||||||
size="small"
|
onChange={(newMode) => setMode(newMode as 'ts2dt' | 'dt2ts')}
|
||||||
/>
|
size="small"
|
||||||
|
|
||||||
{/* 输入区 */}
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder={
|
|
||||||
mode === 'ts2dt' ? t('timestamp:placeholderTs') : t('timestamp:placeholderDate')
|
|
||||||
}
|
|
||||||
value={mode === 'ts2dt' ? tsInput : dtInput}
|
|
||||||
onChange={(e) => 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`}
|
|
||||||
/>
|
/>
|
||||||
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
|
|
||||||
|
|
||||||
{/* 单位+时区紧凑横排 */}
|
{/* 输入交互区 */}
|
||||||
<div className="flex flex-wrap gap-3 w-full">
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{/*
|
||||||
|
3. 替换为标准的 shadcn <Input /> 组件:
|
||||||
|
享受原生高水准的 focus-visible 环形动画响应。
|
||||||
|
*/}
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder={
|
||||||
|
mode === 'ts2dt' ? t('timestamp:placeholderTs') : t('timestamp:placeholderDate')
|
||||||
|
}
|
||||||
|
value={input}
|
||||||
|
onChange={(e: { target: { value: string } }) => 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 && (
|
||||||
|
<p className="text-destructive text-xs font-medium px-0.5 animate-in fade-in slide-in-from-top-1 duration-150">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 核心配置群:单位切换 + 时区选择紧凑横排 */}
|
||||||
|
<div className="flex flex-col sm:flex-row items-stretch gap-3 w-full">
|
||||||
|
{/* 时间精度单位选择 */}
|
||||||
<SwitchButtonGroup
|
<SwitchButtonGroup
|
||||||
value={unit}
|
value={unit}
|
||||||
options={[
|
options={[
|
||||||
@@ -78,33 +103,40 @@ export default function Index() {
|
|||||||
]}
|
]}
|
||||||
onChange={(v) => setUnit(v as 'ms' | 's')}
|
onChange={(v) => setUnit(v as 'ms' | 's')}
|
||||||
size="small"
|
size="small"
|
||||||
|
className="sm:w-auto shrink-0" // 窄屏下全宽,宽屏下自适应收缩
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<select
|
{/*
|
||||||
value={zone}
|
4. 降维打击:将原生 <select> 强行超进化为标准的 shadcn <Select>:
|
||||||
onChange={(e) => setZone(e.target.value as typeof zone)}
|
全操作系统的样式绝对一致,完美融合暗黑模式,悬浮弹窗自带微距磨砂玻璃阻尼动效。
|
||||||
className="flex-1 min-w-0 px-4 py-2.5 rounded-lg border border-input bg-background text-sm font-mono font-semibold focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all"
|
*/}
|
||||||
>
|
<Select value={zone} onValueChange={(v: string) => setZone(v as typeof zone)}>
|
||||||
{ZONES.map((z) => (
|
<SelectTrigger className="flex-1 font-mono font-semibold h-9 shadow-sm bg-background">
|
||||||
<option key={z} value={z} className="text-sm font-semibold">
|
<SelectValue placeholder="选择时区" />
|
||||||
{z}
|
</SelectTrigger>
|
||||||
</option>
|
<SelectContent className="max-h-64 font-mono">
|
||||||
))}
|
{ZONES.map((z) => (
|
||||||
</select>
|
<SelectItem
|
||||||
|
key={z}
|
||||||
|
value={z}
|
||||||
|
className="text-xs font-semibold focus:bg-accent cursor-pointer"
|
||||||
|
>
|
||||||
|
{z}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 立即转换 */}
|
{/*
|
||||||
<button
|
5. 彻底移除了原先丑陋的手动 convert 按钮!
|
||||||
onClick={convert}
|
在响应式 Hook 的加持下,此处留白或由排版自然撑开,界面视觉极其干净。
|
||||||
className="block mx-auto mt-4 mb-1 max-w-[240px] w-full py-2.5 text-sm font-semibold rounded-lg bg-primary text-primary-foreground shadow-md hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 transition-all"
|
*/}
|
||||||
>
|
|
||||||
{t('timestamp:convertButton')}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 右栏:结果展示卡片 */}
|
{/* 右栏:结果实时流展示卡片 */}
|
||||||
<div className="p-5 rounded-xl bg-background border border-border shadow-sm h-full flex flex-col">
|
<div className="p-5 rounded-xl border border-border bg-card text-card-foreground shadow-sm h-full flex flex-col">
|
||||||
<ResultView result={result} mode={mode} unit={unit} zone={zone} showEmptyPlaceholder />
|
<ResultView result={result} mode={mode} unit={unit} zone={zone} showEmptyPlaceholder />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import dayjs from '@/utils/dayjs';
|
import dayjs from '@/utils/dayjs';
|
||||||
import type { UnitType, ZoneType } from '@/config/pageTheme';
|
import type { UnitType, ZoneType } from '@/config/pageTheme';
|
||||||
import { DATE_FORMAT } from '@/config/pageTheme';
|
import { DATE_FORMAT } from '@/config/pageTheme';
|
||||||
@@ -6,148 +6,111 @@ import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
|||||||
import { useContextMenuData } from '@/utils/useContextMenuData';
|
import { useContextMenuData } from '@/utils/useContextMenuData';
|
||||||
|
|
||||||
export interface UseTimestampConverterReturn {
|
export interface UseTimestampConverterReturn {
|
||||||
// State
|
|
||||||
mode: 'ts2dt' | 'dt2ts';
|
mode: 'ts2dt' | 'dt2ts';
|
||||||
tsInput: string;
|
input: string; // 统一为单一受控输入源
|
||||||
dtInput: string;
|
|
||||||
unit: UnitType;
|
unit: UnitType;
|
||||||
zone: ZoneType;
|
zone: ZoneType;
|
||||||
result: string;
|
result: string;
|
||||||
error: string;
|
error: string;
|
||||||
|
|
||||||
// Actions
|
|
||||||
setMode: (mode: 'ts2dt' | 'dt2ts') => void;
|
setMode: (mode: 'ts2dt' | 'dt2ts') => void;
|
||||||
setInput: (value: string) => void;
|
setInput: (value: string) => void;
|
||||||
setUnit: (unit: UnitType) => void;
|
setUnit: (unit: UnitType) => void;
|
||||||
setZone: (zone: ZoneType) => void;
|
setZone: (zone: ZoneType) => void;
|
||||||
handleUseNow: (now: number) => void;
|
handleUseNow: (now: number) => void;
|
||||||
convert: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 判断输入是否为时间戳(纯数字或长度 >= 10 的数字字符串)
|
|
||||||
*/
|
|
||||||
function isTimestampLike(input: string): boolean {
|
function isTimestampLike(input: string): boolean {
|
||||||
const trimmed = input.trim();
|
const trimmed = input.trim();
|
||||||
if (!/^\d+$/.test(trimmed)) return false;
|
return /^\d+$/.test(trimmed) && trimmed.length >= 10;
|
||||||
return trimmed.length >= 10;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTimestampConverter(): UseTimestampConverterReturn {
|
export function useTimestampConverter(): UseTimestampConverterReturn {
|
||||||
const { t } = useLazyTranslation('timestamp');
|
const { t } = useLazyTranslation('timestamp');
|
||||||
const [mode, setMode] = useState<'ts2dt' | 'dt2ts'>('ts2dt');
|
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<UnitType>('ms');
|
const [unit, setUnit] = useState<UnitType>('ms');
|
||||||
const [zone, setZone] = useState<ZoneType>('Asia/Shanghai');
|
const [zone, setZone] = useState<ZoneType>('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') {
|
if (mode === 'ts2dt') {
|
||||||
const rawInput = tsInput.trim();
|
|
||||||
if (!rawInput) return;
|
|
||||||
const num = Number(rawInput);
|
const num = Number(rawInput);
|
||||||
if (isNaN(num)) {
|
if (isNaN(num)) {
|
||||||
setError(t('timestamp:errors.invalidNumber'));
|
return { result: '', error: t('timestamp:errors.invalidNumber') };
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
const d = unit === 'ms' ? dayjs(num) : dayjs.unix(num);
|
const d = unit === 'ms' ? dayjs(num) : dayjs.unix(num);
|
||||||
if (!d.isValid()) {
|
if (!d.isValid()) {
|
||||||
setError(t('timestamp:errors.invalidTimestamp'));
|
return { result: '', error: t('timestamp:errors.invalidTimestamp') };
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
setError('');
|
return { result: d.tz(zone).format(DATE_FORMAT), error: '' };
|
||||||
setResult(d.tz(zone).format(DATE_FORMAT));
|
|
||||||
} else {
|
} else {
|
||||||
const rawInput = dtInput.trim();
|
|
||||||
if (!rawInput) return;
|
|
||||||
const d = dayjs.tz(rawInput, DATE_FORMAT, zone);
|
const d = dayjs.tz(rawInput, DATE_FORMAT, zone);
|
||||||
if (!d.isValid()) {
|
if (!d.isValid()) {
|
||||||
setError(t('timestamp:errors.invalidFormat'));
|
return { result: '', error: t('timestamp:errors.invalidFormat') };
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
setError('');
|
|
||||||
const ms = d.valueOf();
|
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 { result, error } = conversionPipeline;
|
||||||
const handleContextMenuData = useCallback(
|
|
||||||
(payload: string) => {
|
// 3. 处理右键菜单联动(直接修改唯一的 input,衍生转换自动触发)
|
||||||
const trimmed = payload.trim();
|
const handleContextMenuData = useCallback((payload: string) => {
|
||||||
if (isTimestampLike(trimmed)) {
|
const trimmed = payload.trim();
|
||||||
// 看起来是时间戳,切换到 ts2dt 模式
|
if (isTimestampLike(trimmed)) {
|
||||||
setMode('ts2dt');
|
setMode('ts2dt');
|
||||||
setTsInput(trimmed);
|
const detectedUnit: UnitType = trimmed.length >= 13 ? 'ms' : 's';
|
||||||
// 如果是 13 位毫秒级时间戳,自动选择 ms 单位
|
setUnit(detectedUnit);
|
||||||
const detectedUnit: UnitType = trimmed.length >= 13 ? 'ms' : 's';
|
setInput(trimmed);
|
||||||
setUnit(detectedUnit);
|
} else {
|
||||||
// 直接执行转换
|
const d = dayjs(trimmed);
|
||||||
const num = Number(trimmed);
|
if (d.isValid()) {
|
||||||
if (!isNaN(num)) {
|
setMode('dt2ts');
|
||||||
const d = detectedUnit === 'ms' ? dayjs(num) : dayjs.unix(num);
|
setInput(d.format(DATE_FORMAT));
|
||||||
if (d.isValid()) {
|
|
||||||
setError('');
|
|
||||||
setResult(d.tz(zone).format(DATE_FORMAT));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// 尝试作为日期时间解析
|
setMode('ts2dt');
|
||||||
const d = dayjs(trimmed);
|
setInput(trimmed);
|
||||||
if (d.isValid()) {
|
|
||||||
setMode('dt2ts');
|
|
||||||
setDtInput(d.format(DATE_FORMAT));
|
|
||||||
// 直接执行转换
|
|
||||||
const ms = d.valueOf();
|
|
||||||
setError('');
|
|
||||||
setResult(String(ms));
|
|
||||||
} else {
|
|
||||||
// 无法识别,作为时间戳处理
|
|
||||||
setMode('ts2dt');
|
|
||||||
setTsInput(trimmed);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
[zone],
|
}, []);
|
||||||
);
|
|
||||||
|
|
||||||
useContextMenuData({ featureKey: 'timestamp', onData: handleContextMenuData });
|
useContextMenuData({ featureKey: 'timestamp', onData: handleContextMenuData });
|
||||||
|
|
||||||
const handleUseNow = useCallback(
|
const handleUseNow = useCallback(
|
||||||
(now: number) => {
|
(now: number) => {
|
||||||
if (mode === 'ts2dt') {
|
if (mode === 'ts2dt') {
|
||||||
setTsInput(String(unit === 'ms' ? now : Math.floor(now / 1000)));
|
setInput(String(unit === 'ms' ? now : Math.floor(now / 1000)));
|
||||||
} else {
|
} else {
|
||||||
setDtInput(dayjs(now).tz(zone).format(DATE_FORMAT));
|
setInput(dayjs(now).tz(zone).format(DATE_FORMAT));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[mode, unit, zone],
|
[mode, unit, zone],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSetMode = useCallback((newMode: 'ts2dt' | 'dt2ts') => {
|
// 4. 尊享级连贯交互:切换 Mode 时,自动把上一个模式计算出的结果喂进输入框
|
||||||
setMode(newMode);
|
// 比如:输入时间戳 -> 得到日期 -> 切换模式 -> 日期直接进入输入框,方便用户进行微调反向转换
|
||||||
setError('');
|
const handleSetMode = useCallback(
|
||||||
setResult('');
|
(newMode: 'ts2dt' | 'dt2ts') => {
|
||||||
}, []);
|
setMode(newMode);
|
||||||
|
if (result && !error) {
|
||||||
const setInput = useCallback(
|
setInput(result);
|
||||||
(value: string) => {
|
|
||||||
if (mode === 'ts2dt') {
|
|
||||||
setTsInput(value);
|
|
||||||
} else {
|
|
||||||
setDtInput(value);
|
|
||||||
}
|
}
|
||||||
setError('');
|
|
||||||
},
|
},
|
||||||
[mode],
|
[result, error],
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mode,
|
mode,
|
||||||
tsInput,
|
input,
|
||||||
dtInput,
|
|
||||||
unit,
|
unit,
|
||||||
zone,
|
zone,
|
||||||
result,
|
result,
|
||||||
@@ -157,6 +120,5 @@ export function useTimestampConverter(): UseTimestampConverterReturn {
|
|||||||
setUnit,
|
setUnit,
|
||||||
setZone,
|
setZone,
|
||||||
handleUseNow,
|
handleUseNow,
|
||||||
convert,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user