refactor: simplify Timestamp and RightClickRestorer
Timestamp: - Extract shared utilities (msToUnit, dayjsFromTimestamp, ModeType) - Remove unnecessary useCallback/useMemo/React.memo - Remove extra info section (relative time, ISO 8601, UTC) - Fix missing translation key timestamp_unitS RightClickRestorer: - Remove website badge (右键已解锁 overlay) - Remove mouse penetration logic for media elements - Remove unnecessary useCallback - Remove redundant setIsLoading call Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -760,6 +760,10 @@
|
|||||||
"message": "毫秒 (ms)",
|
"message": "毫秒 (ms)",
|
||||||
"description": "Translation key: timestamp_unitMs"
|
"description": "Translation key: timestamp_unitMs"
|
||||||
},
|
},
|
||||||
|
"timestamp_unitS": {
|
||||||
|
"message": "秒 (s)",
|
||||||
|
"description": "Translation key: timestamp_unitS"
|
||||||
|
},
|
||||||
"timestamp_currentTs": {
|
"timestamp_currentTs": {
|
||||||
"message": "当前时间戳",
|
"message": "当前时间戳",
|
||||||
"description": "Translation key: timestamp_currentTs"
|
"description": "Translation key: timestamp_currentTs"
|
||||||
|
|||||||
@@ -1,57 +1,7 @@
|
|||||||
import { MessageAction, onMessage, sendMessage } from '@/utils/messages';
|
import { MessageAction, onMessage, sendMessage } from '@/utils/messages';
|
||||||
|
|
||||||
const BADGE_ID = 'testing-tools-right-click-restorer-badge';
|
|
||||||
const BADGE_STYLE_ID = 'testing-tools-right-click-restorer-badge-style';
|
|
||||||
|
|
||||||
let isRestored = false;
|
let isRestored = false;
|
||||||
|
|
||||||
function updateBadge(): void {
|
|
||||||
const badge = document.getElementById(BADGE_ID);
|
|
||||||
if (badge) {
|
|
||||||
badge.style.opacity = isRestored ? '1' : '0';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createBadge(): void {
|
|
||||||
if (document.getElementById(BADGE_ID)) return;
|
|
||||||
|
|
||||||
if (!document.getElementById(BADGE_STYLE_ID)) {
|
|
||||||
const style = document.createElement('style');
|
|
||||||
style.id = BADGE_STYLE_ID;
|
|
||||||
style.textContent = `
|
|
||||||
#${BADGE_ID} {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 16px;
|
|
||||||
right: 16px;
|
|
||||||
z-index: 2147483646;
|
|
||||||
padding: 6px 12px;
|
|
||||||
background: #2e7d32;
|
|
||||||
color: #ffffff;
|
|
||||||
border-radius: 20px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.2s ease-out;
|
|
||||||
pointer-events: none;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
#${BADGE_ID} {
|
|
||||||
background: #4caf50;
|
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
document.head.appendChild(style);
|
|
||||||
}
|
|
||||||
|
|
||||||
const badge = document.createElement('div');
|
|
||||||
badge.id = BADGE_ID;
|
|
||||||
badge.textContent = '\u53f3\u952e\u5df2\u89e3\u9501';
|
|
||||||
document.body.appendChild(badge);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ======================== Main World 注入 ======================== */
|
/* ======================== Main World 注入 ======================== */
|
||||||
|
|
||||||
async function injectMainWorldScript(): Promise<void> {
|
async function injectMainWorldScript(): Promise<void> {
|
||||||
@@ -90,64 +40,6 @@ function installEventIntercepts(): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ======================== 遮罩层穿透 ======================== */
|
|
||||||
|
|
||||||
const MEDIA_TAGS = new Set(['IMG', 'VIDEO', 'CANVAS', 'SVG']);
|
|
||||||
|
|
||||||
function initMousePenetration(): void {
|
|
||||||
window.addEventListener(
|
|
||||||
'mousedown',
|
|
||||||
(e) => {
|
|
||||||
if (!isRestored || e.button !== 2) return;
|
|
||||||
|
|
||||||
const elements = document.elementsFromPoint(e.clientX, e.clientY);
|
|
||||||
if (!elements.length) return;
|
|
||||||
|
|
||||||
let targetMedia: HTMLElement | null = null;
|
|
||||||
for (const el of elements) {
|
|
||||||
if (MEDIA_TAGS.has(el.tagName)) {
|
|
||||||
targetMedia = el as HTMLElement;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!targetMedia) return;
|
|
||||||
|
|
||||||
const modified: Array<{ el: HTMLElement; original: string | null }> = [];
|
|
||||||
let foundMedia = false;
|
|
||||||
|
|
||||||
for (const el of elements) {
|
|
||||||
const htmlEl = el as HTMLElement;
|
|
||||||
|
|
||||||
if (el === targetMedia) {
|
|
||||||
foundMedia = true;
|
|
||||||
const original = htmlEl.style.pointerEvents || null;
|
|
||||||
htmlEl.style.setProperty('pointer-events', 'all', 'important');
|
|
||||||
modified.push({ el: htmlEl, original });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!foundMedia) {
|
|
||||||
const original = htmlEl.style.pointerEvents || null;
|
|
||||||
htmlEl.style.setProperty('pointer-events', 'none', 'important');
|
|
||||||
modified.push({ el: htmlEl, original });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
for (const { el, original } of modified) {
|
|
||||||
if (original === null || original === '') {
|
|
||||||
el.style.removeProperty('pointer-events');
|
|
||||||
} else {
|
|
||||||
el.style.pointerEvents = original;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ======================== 激活保护 ======================== */
|
/* ======================== 激活保护 ======================== */
|
||||||
|
|
||||||
async function activateProtection(): Promise<void> {
|
async function activateProtection(): Promise<void> {
|
||||||
@@ -159,10 +51,8 @@ async function activateProtection(): Promise<void> {
|
|||||||
if (isRestored) return;
|
if (isRestored) return;
|
||||||
|
|
||||||
installEventIntercepts();
|
installEventIntercepts();
|
||||||
initMousePenetration();
|
|
||||||
|
|
||||||
isRestored = true;
|
isRestored = true;
|
||||||
updateBadge();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ======================== 消息通信 ======================== */
|
/* ======================== 消息通信 ======================== */
|
||||||
@@ -184,11 +74,6 @@ export default defineContentScript({
|
|||||||
matches: ['<all_urls>'],
|
matches: ['<all_urls>'],
|
||||||
runAt: 'document_start',
|
runAt: 'document_start',
|
||||||
main() {
|
main() {
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', createBadge, { once: true });
|
|
||||||
} else {
|
|
||||||
createBadge();
|
|
||||||
}
|
|
||||||
initMessaging();
|
initMessaging();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { MessageAction, sendMessageToContent } from '@/utils/messages';
|
import { MessageAction, sendMessageToContent } from '@/utils/messages';
|
||||||
|
|
||||||
const UNSUPPORTED_PROTOCOLS = new Set([
|
const UNSUPPORTED_PROTOCOLS = new Set([
|
||||||
@@ -43,7 +43,6 @@ export function useRightClickRestorer(): UseRightClickRestorerReturn {
|
|||||||
|
|
||||||
if (isUnsupportedPage(url)) {
|
if (isUnsupportedPage(url)) {
|
||||||
setIsUnsupported(true);
|
setIsUnsupported(true);
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +60,7 @@ export function useRightClickRestorer(): UseRightClickRestorerReturn {
|
|||||||
void load();
|
void load();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const unlock = useCallback(async () => {
|
async function unlock() {
|
||||||
if (isUnsupported) return;
|
if (isUnsupported) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -72,7 +71,7 @@ export function useRightClickRestorer(): UseRightClickRestorerReturn {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[RightClickRestorer] Failed to unlock:', err);
|
console.error('[RightClickRestorer] Failed to unlock:', err);
|
||||||
}
|
}
|
||||||
}, [isUnsupported]);
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
domain,
|
domain,
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Clock } from 'lucide-react';
|
import { Clock } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import CopyButton from '@/components/CopyButton';
|
import CopyButton from '@/components/CopyButton';
|
||||||
import type { UnitType } from './constants';
|
import type { UnitType } from './constants';
|
||||||
|
import { msToUnit } from './constants';
|
||||||
import { useI18n } from '@/utils/chromeI18n';
|
import { useI18n } from '@/utils/chromeI18n';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -11,41 +12,20 @@ interface LiveClockProps extends React.HTMLAttributes<HTMLDivElement> {
|
|||||||
onUseNow: (val: number) => void;
|
onUseNow: (val: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LiveClock = React.memo(({ unit, onUseNow, className, ...props }: LiveClockProps) => {
|
export default function LiveClock({ unit, onUseNow, className, ...props }: LiveClockProps) {
|
||||||
const { t } = useI18n('timestamp');
|
const { t } = useI18n('timestamp');
|
||||||
const onUseNowRef = useRef(onUseNow);
|
|
||||||
|
|
||||||
const [currentDisplay, setCurrentDisplay] = useState(() => {
|
const [rawTime, setRawTime] = useState(() => Date.now());
|
||||||
const initNow = Date.now();
|
|
||||||
return {
|
|
||||||
rawTime: initNow,
|
|
||||||
text: String(Math.floor(initNow / (unit === 'ms' ? 1 : 1000))),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onUseNowRef.current = onUseNow;
|
|
||||||
}, [onUseNow]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
const rightNow = Date.now();
|
setRawTime(Date.now());
|
||||||
const nextText = String(Math.floor(rightNow / (unit === 'ms' ? 1 : 1000)));
|
|
||||||
|
|
||||||
setCurrentDisplay((prev) => {
|
|
||||||
if (prev.text === nextText) return prev;
|
|
||||||
return { rawTime: rightNow, text: nextText };
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const tickId = setInterval(tick, 200);
|
const tickId = setInterval(tick, 200);
|
||||||
return () => clearInterval(tickId);
|
return () => clearInterval(tickId);
|
||||||
}, [unit]);
|
}, [unit]);
|
||||||
|
|
||||||
const handleUseNow = useCallback(() => {
|
const text = String(msToUnit(rawTime, unit));
|
||||||
onUseNowRef.current(currentDisplay.rawTime);
|
|
||||||
toast.success(t('timestamp:usedSuccess'));
|
|
||||||
}, [currentDisplay.rawTime, t]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -60,12 +40,15 @@ const LiveClock = React.memo(({ unit, onUseNow, className, ...props }: LiveClock
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span className="flex-1 font-mono font-bold text-foreground text-sm tracking-tight leading-none truncate tabular-nums">
|
<span className="flex-1 font-mono font-bold text-foreground text-sm tracking-tight leading-none truncate tabular-nums">
|
||||||
{currentDisplay.text}
|
{text}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleUseNow}
|
onClick={() => {
|
||||||
|
onUseNow(rawTime);
|
||||||
|
toast.success(t('timestamp:usedSuccess'));
|
||||||
|
}}
|
||||||
title={t('timestamp:useNowTooltip')}
|
title={t('timestamp:useNowTooltip')}
|
||||||
className="flex h-7 w-7 items-center justify-center rounded-md border border-input bg-background text-muted-foreground shadow-sm hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
className="flex h-7 w-7 items-center justify-center rounded-md border border-input bg-background text-muted-foreground shadow-sm hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
>
|
>
|
||||||
@@ -73,14 +56,10 @@ const LiveClock = React.memo(({ unit, onUseNow, className, ...props }: LiveClock
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<CopyButton
|
<CopyButton
|
||||||
text={currentDisplay.text}
|
text={text}
|
||||||
tooltip={t('timestamp:copyTsTooltip')}
|
tooltip={t('timestamp:copyTsTooltip')}
|
||||||
className="h-7 w-7 rounded-md border"
|
className="h-7 w-7 rounded-md border"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
|
||||||
LiveClock.displayName = 'LiveClock';
|
|
||||||
|
|
||||||
export default LiveClock;
|
|
||||||
|
|||||||
@@ -1,48 +1,21 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React from 'react';
|
||||||
import dayjs from '@/utils/dayjs';
|
|
||||||
import CopyButton from '@/components/CopyButton';
|
import CopyButton from '@/components/CopyButton';
|
||||||
import type { UnitType } from './constants';
|
|
||||||
import { DATE_FORMAT } from './constants';
|
|
||||||
import { useI18n } from '@/utils/chromeI18n';
|
import { useI18n } from '@/utils/chromeI18n';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface ResultViewProps extends React.HTMLAttributes<HTMLDivElement> {
|
interface ResultViewProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
result: string;
|
result: string;
|
||||||
mode: 'ts2dt' | 'dt2ts';
|
|
||||||
unit: UnitType;
|
|
||||||
zone: string;
|
|
||||||
|
|
||||||
showEmptyPlaceholder?: boolean;
|
showEmptyPlaceholder?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ResultView = React.memo(
|
export default function ResultView({
|
||||||
({
|
|
||||||
result,
|
result,
|
||||||
mode,
|
|
||||||
unit,
|
|
||||||
zone,
|
|
||||||
showEmptyPlaceholder = false,
|
showEmptyPlaceholder = false,
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: ResultViewProps) => {
|
}: ResultViewProps) {
|
||||||
const { t } = useI18n('timestamp');
|
const { t } = useI18n('timestamp');
|
||||||
|
|
||||||
const extraInfo = useMemo(() => {
|
|
||||||
if (!result) return null;
|
|
||||||
const d =
|
|
||||||
mode === 'ts2dt'
|
|
||||||
? dayjs(result, DATE_FORMAT).tz(zone)
|
|
||||||
: unit === 'ms'
|
|
||||||
? dayjs(Number(result))
|
|
||||||
: dayjs.unix(Number(result));
|
|
||||||
|
|
||||||
return {
|
|
||||||
relative: d.fromNow(),
|
|
||||||
iso: d.toISOString(),
|
|
||||||
utc: d.utc().format(DATE_FORMAT) + ' UTC',
|
|
||||||
};
|
|
||||||
}, [result, mode, zone, unit]);
|
|
||||||
|
|
||||||
if (!result) {
|
if (!result) {
|
||||||
if (!showEmptyPlaceholder) return null;
|
if (!showEmptyPlaceholder) return null;
|
||||||
return (
|
return (
|
||||||
@@ -60,12 +33,11 @@ const ResultView = React.memo(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex flex-col w-full', className)} {...props}>
|
<div className={cn('flex flex-col w-full', className)} {...props}>
|
||||||
{/* 顶部小标签 */}
|
|
||||||
<span className="block text-muted-foreground/90 mb-2.5 text-xs font-semibold tracking-wider uppercase">
|
<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-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">
|
<div className="bg-card text-card-foreground border border-border p-4 sm:p-5 rounded-xl relative shadow-sm flex justify-between items-center gap-4 focus-within:ring-1 focus-within:ring-ring">
|
||||||
<span className="font-mono font-extrabold text-foreground break-all text-xl sm:text-2xl tracking-tight leading-tight select-all tabular-nums">
|
<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>
|
||||||
@@ -75,45 +47,6 @@ const ResultView = React.memo(
|
|||||||
className="h-8 w-8 rounded-md shrink-0 border"
|
className="h-8 w-8 rounded-md shrink-0 border"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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:iso8601'), value: extraInfo?.iso, isMono: true },
|
|
||||||
{ label: t('timestamp:utcTime'), value: extraInfo?.utc, isMono: true },
|
|
||||||
].map((item) => (
|
|
||||||
<div
|
|
||||||
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}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center justify-between sm:justify-end gap-2 min-w-0 w-full sm:w-auto">
|
|
||||||
<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]',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.value}
|
|
||||||
</span>
|
|
||||||
{item.value && (
|
|
||||||
<CopyButton
|
|
||||||
text={item.value}
|
|
||||||
tooltip={t('timestamp:copyTooltip')}
|
|
||||||
className="h-6 w-6 rounded-md border shrink-0 text-muted-foreground"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
);
|
|
||||||
|
|
||||||
ResultView.displayName = 'ResultView';
|
|
||||||
|
|
||||||
export default ResultView;
|
|
||||||
|
|||||||
@@ -1,6 +1,22 @@
|
|||||||
|
import type { Dayjs } from 'dayjs';
|
||||||
|
import dayjs from '@/utils/dayjs';
|
||||||
|
|
||||||
export const DATE_FORMAT = 'YYYY/MM/DD HH:mm:ss';
|
export const DATE_FORMAT = 'YYYY/MM/DD HH:mm:ss';
|
||||||
|
|
||||||
export const ZONES = ['Asia/Shanghai', 'America/New_York', 'Europe/London'] as const;
|
export const ZONES = ['Asia/Shanghai', 'America/New_York', 'Europe/London'] as const;
|
||||||
|
|
||||||
|
export type ModeType = 'ts2dt' | 'dt2ts';
|
||||||
export type UnitType = 'ms' | 's';
|
export type UnitType = 'ms' | 's';
|
||||||
export type ZoneType = (typeof ZONES)[number];
|
export type ZoneType = (typeof ZONES)[number];
|
||||||
|
|
||||||
|
const DIVISORS: Record<UnitType, number> = { ms: 1, s: 1000 };
|
||||||
|
|
||||||
|
/** Convert milliseconds to display value based on unit. */
|
||||||
|
export function msToUnit(ms: number, unit: UnitType): number {
|
||||||
|
return Math.floor(ms / DIVISORS[unit]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a dayjs object from a numeric timestamp according to unit. */
|
||||||
|
export function dayjsFromTimestamp(ts: number, unit: UnitType): Dayjs {
|
||||||
|
return unit === 'ms' ? dayjs(ts) : dayjs.unix(ts);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
||||||
import { ZONES } from './constants';
|
import { ZONES } from './constants';
|
||||||
|
import type { ModeType, UnitType, ZoneType } from './constants';
|
||||||
import LiveClock from './LiveClock';
|
import LiveClock from './LiveClock';
|
||||||
import ResultView from './ResultView';
|
import ResultView from './ResultView';
|
||||||
import { useTimestampConverter } from './useTimestampConverter';
|
import { useTimestampConverter } from './useTimestampConverter';
|
||||||
@@ -15,6 +16,16 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
|
|
||||||
|
const MODE_OPTIONS: { value: ModeType; label: string }[] = [
|
||||||
|
{ value: 'ts2dt', label: 'timestamp:tsToDate' },
|
||||||
|
{ value: 'dt2ts', label: 'timestamp:dateToTs' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const UNIT_OPTIONS: { value: UnitType; label: string }[] = [
|
||||||
|
{ value: 'ms', label: 'timestamp:unitMs' },
|
||||||
|
{ value: 's', label: 'timestamp:unitS' },
|
||||||
|
];
|
||||||
|
|
||||||
export default function Index() {
|
export default function Index() {
|
||||||
const { t } = useI18n('timestamp');
|
const { t } = useI18n('timestamp');
|
||||||
|
|
||||||
@@ -42,11 +53,8 @@ export default function Index() {
|
|||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<SwitchButtonGroup
|
<SwitchButtonGroup
|
||||||
value={mode}
|
value={mode}
|
||||||
options={[
|
options={MODE_OPTIONS.map((o) => ({ ...o, label: t(o.label) }))}
|
||||||
{ value: 'ts2dt', label: t('timestamp:tsToDate') },
|
onChange={setMode}
|
||||||
{ value: 'dt2ts', label: t('timestamp:dateToTs') },
|
|
||||||
]}
|
|
||||||
onChange={(newMode) => setMode(newMode as 'ts2dt' | 'dt2ts')}
|
|
||||||
size="small"
|
size="small"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -57,7 +65,7 @@ export default function Index() {
|
|||||||
mode === 'ts2dt' ? t('timestamp:placeholderTs') : t('timestamp:placeholderDate')
|
mode === 'ts2dt' ? t('timestamp:placeholderTs') : t('timestamp:placeholderDate')
|
||||||
}
|
}
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e: { target: { value: string } }) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'font-mono font-semibold h-10 shadow-sm placeholder:text-muted-foreground/60 focus:bg-background',
|
'font-mono font-semibold h-10 shadow-sm placeholder:text-muted-foreground/60 focus:bg-background',
|
||||||
error && 'border-destructive focus-visible:ring-destructive',
|
error && 'border-destructive focus-visible:ring-destructive',
|
||||||
@@ -70,16 +78,13 @@ export default function Index() {
|
|||||||
<div className="flex flex-col sm:flex-row items-stretch gap-3 w-full">
|
<div className="flex flex-col sm:flex-row items-stretch gap-3 w-full">
|
||||||
<SwitchButtonGroup
|
<SwitchButtonGroup
|
||||||
value={unit}
|
value={unit}
|
||||||
options={[
|
options={UNIT_OPTIONS.map((o) => ({ ...o, label: t(o.label) }))}
|
||||||
{ value: 'ms', label: t('timestamp:unitMs') },
|
onChange={setUnit}
|
||||||
{ value: 's', label: t('timestamp:unitS') },
|
|
||||||
]}
|
|
||||||
onChange={(v) => setUnit(v as 'ms' | 's')}
|
|
||||||
size="small"
|
size="small"
|
||||||
className="sm:w-auto shrink-0"
|
className="sm:w-auto shrink-0"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Select value={zone} onValueChange={(v: string) => setZone(v as typeof zone)}>
|
<Select value={zone} onValueChange={(v: string) => setZone(v as ZoneType)}>
|
||||||
<SelectTrigger className="flex-1 font-mono font-semibold h-9 shadow-sm bg-background">
|
<SelectTrigger className="flex-1 font-mono font-semibold h-9 shadow-sm bg-background">
|
||||||
<SelectValue placeholder="选择时区" />
|
<SelectValue placeholder="选择时区" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -100,7 +105,7 @@ export default function Index() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-5 rounded-xl border border-border bg-card text-card-foreground 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} showEmptyPlaceholder />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,39 +1,42 @@
|
|||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import dayjs from '@/utils/dayjs';
|
import dayjs from '@/utils/dayjs';
|
||||||
import type { UnitType, ZoneType } from './constants';
|
import type { UnitType, ZoneType, ModeType } from './constants';
|
||||||
import { DATE_FORMAT } from './constants';
|
import { DATE_FORMAT, msToUnit, dayjsFromTimestamp } from './constants';
|
||||||
import { useI18n } from '@/utils/chromeI18n';
|
import { useI18n } from '@/utils/chromeI18n';
|
||||||
import { useContextMenuData } from '@/utils/useContextMenuData';
|
import { useContextMenuData } from '@/utils/useContextMenuData';
|
||||||
|
|
||||||
export interface UseTimestampConverterReturn {
|
export interface UseTimestampConverterReturn {
|
||||||
mode: 'ts2dt' | 'dt2ts';
|
mode: ModeType;
|
||||||
input: string;
|
input: string;
|
||||||
unit: UnitType;
|
unit: UnitType;
|
||||||
zone: ZoneType;
|
zone: ZoneType;
|
||||||
result: string;
|
result: string;
|
||||||
error: string;
|
error: string;
|
||||||
|
|
||||||
setMode: (mode: 'ts2dt' | 'dt2ts') => void;
|
setMode: (mode: ModeType) => 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TIMESTAMP_REGEX = /^\d+$/;
|
||||||
|
const MS_TIMESTAMP_MIN_LENGTH = 13;
|
||||||
|
|
||||||
function isTimestampLike(input: string): boolean {
|
function isTimestampLike(input: string): boolean {
|
||||||
const trimmed = input.trim();
|
const trimmed = input.trim();
|
||||||
return /^\d+$/.test(trimmed) && trimmed.length >= 10;
|
return TIMESTAMP_REGEX.test(trimmed) && trimmed.length >= 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTimestampConverter(): UseTimestampConverterReturn {
|
export function useTimestampConverter(): UseTimestampConverterReturn {
|
||||||
const { t } = useI18n('timestamp');
|
const { t } = useI18n('timestamp');
|
||||||
const [mode, setMode] = useState<'ts2dt' | 'dt2ts'>('ts2dt');
|
const [mode, setMode] = useState<ModeType>('ts2dt');
|
||||||
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 [input, setInput] = useState(() => String(Date.now()));
|
const [input, setInput] = useState(() => String(Date.now()));
|
||||||
|
|
||||||
const conversionPipeline = useMemo(() => {
|
const { result, error } = useMemo(() => {
|
||||||
const rawInput = input.trim();
|
const rawInput = input.trim();
|
||||||
if (!rawInput) return { result: '', error: '' };
|
if (!rawInput) return { result: '', error: '' };
|
||||||
|
|
||||||
@@ -42,7 +45,7 @@ export function useTimestampConverter(): UseTimestampConverterReturn {
|
|||||||
if (isNaN(num)) {
|
if (isNaN(num)) {
|
||||||
return { result: '', error: t('timestamp:errors.invalidNumber') };
|
return { result: '', error: t('timestamp:errors.invalidNumber') };
|
||||||
}
|
}
|
||||||
const d = unit === 'ms' ? dayjs(num) : dayjs.unix(num);
|
const d = dayjsFromTimestamp(num, unit);
|
||||||
if (!d.isValid()) {
|
if (!d.isValid()) {
|
||||||
return { result: '', error: t('timestamp:errors.invalidTimestamp') };
|
return { result: '', error: t('timestamp:errors.invalidTimestamp') };
|
||||||
}
|
}
|
||||||
@@ -53,19 +56,15 @@ export function useTimestampConverter(): UseTimestampConverterReturn {
|
|||||||
return { result: '', error: t('timestamp:errors.invalidFormat') };
|
return { result: '', error: t('timestamp:errors.invalidFormat') };
|
||||||
}
|
}
|
||||||
const ms = d.valueOf();
|
const ms = d.valueOf();
|
||||||
const outputTs = unit === 'ms' ? String(ms) : String(Math.floor(ms / 1000));
|
return { result: String(msToUnit(ms, unit)), error: '' };
|
||||||
return { result: outputTs, error: '' };
|
|
||||||
}
|
}
|
||||||
}, [input, mode, unit, zone, t]);
|
}, [input, mode, unit, zone, t]);
|
||||||
|
|
||||||
const { result, error } = conversionPipeline;
|
const handleContextMenuData = (payload: string) => {
|
||||||
|
|
||||||
const handleContextMenuData = useCallback((payload: string) => {
|
|
||||||
const trimmed = payload.trim();
|
const trimmed = payload.trim();
|
||||||
if (isTimestampLike(trimmed)) {
|
if (isTimestampLike(trimmed)) {
|
||||||
setMode('ts2dt');
|
setMode('ts2dt');
|
||||||
const detectedUnit: UnitType = trimmed.length >= 13 ? 'ms' : 's';
|
setUnit(trimmed.length >= MS_TIMESTAMP_MIN_LENGTH ? 'ms' : 's');
|
||||||
setUnit(detectedUnit);
|
|
||||||
setInput(trimmed);
|
setInput(trimmed);
|
||||||
} else {
|
} else {
|
||||||
const d = dayjs(trimmed);
|
const d = dayjs(trimmed);
|
||||||
@@ -77,30 +76,24 @@ export function useTimestampConverter(): UseTimestampConverterReturn {
|
|||||||
setInput(trimmed);
|
setInput(trimmed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
useContextMenuData({ featureKey: 'timestamp', onData: handleContextMenuData });
|
useContextMenuData({ featureKey: 'timestamp', onData: handleContextMenuData });
|
||||||
|
|
||||||
const handleUseNow = useCallback(
|
const handleUseNow = (now: number) => {
|
||||||
(now: number) => {
|
|
||||||
if (mode === 'ts2dt') {
|
if (mode === 'ts2dt') {
|
||||||
setInput(String(unit === 'ms' ? now : Math.floor(now / 1000)));
|
setInput(String(msToUnit(now, unit)));
|
||||||
} else {
|
} else {
|
||||||
setInput(dayjs(now).tz(zone).format(DATE_FORMAT));
|
setInput(dayjs(now).tz(zone).format(DATE_FORMAT));
|
||||||
}
|
}
|
||||||
},
|
};
|
||||||
[mode, unit, zone],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSetMode = useCallback(
|
const handleSetMode = (newMode: ModeType) => {
|
||||||
(newMode: 'ts2dt' | 'dt2ts') => {
|
|
||||||
setMode(newMode);
|
setMode(newMode);
|
||||||
if (result && !error) {
|
if (result && !error) {
|
||||||
setInput(result);
|
setInput(result);
|
||||||
}
|
}
|
||||||
},
|
};
|
||||||
[result, error],
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mode,
|
mode,
|
||||||
|
|||||||
Reference in New Issue
Block a user