)}
- {/* 当解析错误时的干净中性引导拦截 */}
{result?.error && (
diff --git a/src/pages/Jwt/useJwt.ts b/src/pages/Jwt/useJwt.ts
new file mode 100644
index 0000000..5f65e3f
--- /dev/null
+++ b/src/pages/Jwt/useJwt.ts
@@ -0,0 +1,43 @@
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useContextMenuData } from '@/utils/useContextMenuData';
+import { parseJwt } from '@/utils/jwt';
+import type { JwtResult } from '@/utils/jwt';
+
+export interface UseJwtReturn {
+ jwtInput: string;
+ result: JwtResult | null;
+ handleChange: (val: string) => void;
+ handleClear: () => void;
+}
+
+export function useJwt(): UseJwtReturn {
+ const [jwtInput, setJwtInput] = useState('');
+ const [debouncedInput, setDebouncedInput] = useState('');
+
+ // 防抖管道
+ useEffect(() => {
+ const handle = setTimeout(() => setDebouncedInput(jwtInput), 200);
+ return () => clearTimeout(handle);
+ }, [jwtInput]);
+
+ // 右键菜单数据
+ const handleContextMenuData = useCallback((payload: string) => {
+ setJwtInput(payload.replace(/^Bearer\s*/i, '').trim());
+ }, []);
+
+ useContextMenuData({ featureKey: 'jwt', onData: handleContextMenuData });
+
+ // 响应式解析
+ const result = useMemo(() => {
+ if (!debouncedInput.trim()) return null;
+ return parseJwt(debouncedInput);
+ }, [debouncedInput]);
+
+ const handleChange = useCallback((val: string) => {
+ setJwtInput(val.replace(/^Bearer\s*/i, '').trim());
+ }, []);
+
+ const handleClear = useCallback(() => setJwtInput(''), []);
+
+ return { jwtInput, result, handleChange, handleClear };
+}
diff --git a/src/pages/QrCode/components/ParsePanel.tsx b/src/pages/QrCode/components/ParsePanel.tsx
index bd84c56..a0a2aa6 100644
--- a/src/pages/QrCode/components/ParsePanel.tsx
+++ b/src/pages/QrCode/components/ParsePanel.tsx
@@ -1,7 +1,7 @@
import { useCallback, useEffect } from 'react';
+import { toast } from 'sonner';
import TextInputArea from '@/components/TextInputArea';
import ImageUploader from '@/components/ImageUploader';
-import { useSnackbar } from '@/components/GlobalSnackbar';
import { useI18n } from '@/utils/chromeI18n';
import { useQrCodeContext } from '../contexts/QrCodeContext';
import { Label } from '@/components/ui/label';
@@ -9,7 +9,6 @@ import { cn } from '@/lib/utils';
export default function ParsePanel() {
const { t } = useI18n('qrCode');
- const { showMessage } = useSnackbar();
const { parserState, setParserState, handleFileChange, handleClearFile } = useQrCodeContext();
// 全局粘贴事件监听
@@ -24,7 +23,7 @@ export default function ParsePanel() {
const file = items[i].getAsFile();
if (file) {
handleFileChange(file);
- showMessage(t('qrCode:imagePasted'), { severity: 'success', autoHideDuration: 1000 });
+ toast.success(t('qrCode:imagePasted'));
}
return;
}
@@ -38,14 +37,14 @@ export default function ParsePanel() {
const blob = await response.blob();
const file = new File([blob], 'pasted-image.png', { type: blob.type });
handleFileChange(file);
- showMessage(t('qrCode:imagePasted'), { severity: 'success', autoHideDuration: 1000 });
+ toast.success(t('qrCode:imagePasted'));
} catch (error) {
console.error('处理 Base64 图片失败:', error);
- showMessage(t('qrCode:imagePasteError'), { severity: 'error', autoHideDuration: 3000 });
+ toast.error(t('qrCode:imagePasteError'));
}
}
},
- [handleFileChange, showMessage, t],
+ [handleFileChange, t],
);
useEffect(() => {
diff --git a/src/pages/QrCode/hooks/useQrCode.ts b/src/pages/QrCode/hooks/useQrCode.ts
index 706cefd..bedf5c3 100644
--- a/src/pages/QrCode/hooks/useQrCode.ts
+++ b/src/pages/QrCode/hooks/useQrCode.ts
@@ -1,6 +1,6 @@
import { useCallback, useMemo, useState } from 'react';
import QRious from 'qrious';
-import { useSnackbar } from '@/components/GlobalSnackbar';
+import { toast } from 'sonner';
import { parseQrCodeFromFile } from '@/utils/qrCodeParser';
import { useContextMenuData } from '@/utils/useContextMenuData';
import { useI18n } from '@/utils/chromeI18n';
@@ -10,7 +10,6 @@ import type { QrCodeGeneratorState, QrCodeMode, QrCodeParserState } from '../typ
export function useQrCode(): QrCodeContextValue {
const { t } = useI18n('qrCode');
- const { showMessage } = useSnackbar();
const [mode, setMode] = useState('generate');
@@ -89,22 +88,22 @@ export function useQrCode(): QrCodeContextValue {
if (result.success && result.data) {
setParserState((prev) => ({ ...prev, decodedResult: result.data! }));
- showMessage(t('qrCode:parseSuccess'), { severity: 'success', autoHideDuration: 1000 });
+ toast.success(t('qrCode:parseSuccess'));
} else {
const errorMsg = result.error || t('qrCode:noQrDetected');
setParserState((prev) => ({ ...prev, parseError: errorMsg }));
- showMessage(errorMsg, { severity: 'error', autoHideDuration: 3000 });
+ toast.error(errorMsg);
}
} catch (error) {
console.error('解析二维码失败:', error);
const errorMsg = error instanceof Error ? error.message : t('qrCode:parseError');
setParserState((prev) => ({ ...prev, parseError: errorMsg }));
- showMessage(errorMsg, { severity: 'error', autoHideDuration: 3000 });
+ toast.error(errorMsg);
} finally {
setParserState((prev) => ({ ...prev, parsing: false }));
}
},
- [t, showMessage],
+ [t],
);
const downloadQrCode = useCallback(() => {
@@ -114,8 +113,8 @@ export function useQrCode(): QrCodeContextValue {
link.href = qrCodeDataUrl;
link.download = 'qrcode.png';
link.click();
- showMessage(t('qrCode:qrCodeDownloadSuccess'), { severity: 'success', autoHideDuration: 1000 });
- }, [qrCodeDataUrl, showMessage, t]);
+ toast.success(t('qrCode:qrCodeDownloadSuccess'));
+ }, [qrCodeDataUrl, t]);
const copyQrCode = useCallback(async () => {
if (!qrCodeDataUrl) return;
@@ -130,12 +129,12 @@ export function useQrCode(): QrCodeContextValue {
}),
]);
- showMessage(t('qrCode:qrCodeCopySuccess'), { severity: 'success', autoHideDuration: 1000 });
+ toast.success(t('qrCode:qrCodeCopySuccess'));
} catch (error) {
console.error('复制二维码失败:', error);
- showMessage(t('qrCode:copyError'), { severity: 'error', autoHideDuration: 3000 });
+ toast.error(t('qrCode:copyError'));
}
- }, [qrCodeDataUrl, showMessage, t]);
+ }, [qrCodeDataUrl, t]);
const handleFileChange = useCallback(
(file: File) => {
diff --git a/src/pages/RightClickRestorer/index.tsx b/src/pages/RightClickRestorer/index.tsx
index 3b14f78..35dec5f 100644
--- a/src/pages/RightClickRestorer/index.tsx
+++ b/src/pages/RightClickRestorer/index.tsx
@@ -5,7 +5,7 @@ import { Shield, ShieldCheck, MousePointerClick, AlertTriangle } from 'lucide-re
import { useRightClickRestorer } from './useRightClickRestorer';
import { useI18n } from '@/utils/chromeI18n';
-export default function RightClickRestorerPage() {
+export default function Index() {
const { t } = useI18n('rightClickRestorer');
const { domain, isLoading, isUnlocked, isUnsupported, unlock } = useRightClickRestorer();
diff --git a/src/pages/RightClickRestorer/useRightClickRestorer.ts b/src/pages/RightClickRestorer/useRightClickRestorer.ts
index f7abeb4..b215743 100644
--- a/src/pages/RightClickRestorer/useRightClickRestorer.ts
+++ b/src/pages/RightClickRestorer/useRightClickRestorer.ts
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useState } from 'react';
+import { useEffect, useState } from 'react';
import { MessageAction, sendMessageToContent } from '@/utils/messages';
const UNSUPPORTED_PROTOCOLS = new Set([
@@ -39,17 +39,10 @@ export function useRightClickRestorer(): UseRightClickRestorerReturn {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
const url = tab?.url;
- if (url) {
- try {
- setDomain(new URL(url).hostname);
- } catch {
- setDomain('');
- }
- }
+ setDomain(url ? new URL(url).hostname : '');
if (isUnsupportedPage(url)) {
setIsUnsupported(true);
- setIsLoading(false);
return;
}
@@ -67,7 +60,7 @@ export function useRightClickRestorer(): UseRightClickRestorerReturn {
void load();
}, []);
- const unlock = useCallback(async () => {
+ async function unlock() {
if (isUnsupported) return;
try {
@@ -78,7 +71,7 @@ export function useRightClickRestorer(): UseRightClickRestorerReturn {
} catch (err) {
console.error('[RightClickRestorer] Failed to unlock:', err);
}
- }, [isUnsupported]);
+ }
return {
domain,
diff --git a/src/pages/StorageCleaner/AutoRefreshToggle.tsx b/src/pages/StorageCleaner/AutoRefreshToggle.tsx
index cc21293..6b353fb 100644
--- a/src/pages/StorageCleaner/AutoRefreshToggle.tsx
+++ b/src/pages/StorageCleaner/AutoRefreshToggle.tsx
@@ -1,8 +1,8 @@
/**
- * 自动刷新开关组件
+ * 自动刷新开关行
*
- * 用于 StorageCleaner 页面,控制是否自动刷新存储数据列表。
- * 以卡片形式展示,左侧为标签文本,右侧为 shadcn/ui Switch 开关。
+ * 用于 StorageCleaner 页面的操作区域内(非独立卡片),
+ * 控制清理后是否自动刷新页面。左侧为标签文本,右侧为 shadcn/ui Switch 开关。
*
* @example
* ```tsx
@@ -48,15 +48,14 @@ export default function AutoRefreshToggle({
return (
- {/* Label 与 Switch 通过 htmlFor + id 关联,支持点击文字触发开关 */}
diff --git a/src/pages/StorageCleaner/CleaningResult.tsx b/src/pages/StorageCleaner/CleaningResult.tsx
index d546d09..7025838 100644
--- a/src/pages/StorageCleaner/CleaningResult.tsx
+++ b/src/pages/StorageCleaner/CleaningResult.tsx
@@ -14,7 +14,7 @@ export default function CleaningResult({ result, className, ...props }: Cleaning
if (!result) return null;
- const isSuccess = result.success;
+ const isSuccess = result.overallSuccess;
return (
diff --git a/src/pages/StorageCleaner/OptionItem.tsx b/src/pages/StorageCleaner/OptionItem.tsx
index 18c7fe6..a6c96ee 100644
--- a/src/pages/StorageCleaner/OptionItem.tsx
+++ b/src/pages/StorageCleaner/OptionItem.tsx
@@ -1,59 +1,63 @@
import React from 'react';
-import { formatSize } from '@/utils/storageCleaner';
+import { formatBytes } from '@/utils/format';
import { useI18n } from '@/utils/chromeI18n';
import { cn } from '@/lib/utils';
-// 引入官方的 Checkbox 原子组件
+import type { StorageSizeInfo } from './useStorageCleaner';
import { Checkbox } from '@/components/ui/checkbox';
interface OptionItemProps extends React.HTMLAttributes
{
labelKey: string;
checked: boolean;
- size?: number;
- isCount?: boolean;
+ sizeInfo?: StorageSizeInfo;
onChange: () => void;
}
export default function OptionItem({
labelKey,
checked,
- size,
- isCount = false,
+ sizeInfo,
onChange,
className,
...props
}: OptionItemProps) {
const { t } = useI18n('storageCleaner');
+ const sizeValue = sizeInfo?.value;
+ const isCount = sizeInfo?.displayType === 'count';
+
return (
- {/* 左侧数据区域 */}
-
+
{t(labelKey)}
- {/* 底部容量大小或计数标识 */}
- {size !== undefined && size > 0 ? (
-
- {isCount ? `${size} ${t('storageCleaner:countUnit')}` : formatSize(size)}
+ {sizeValue !== undefined && sizeValue > 0 ? (
+
+ {isCount ? `${sizeValue} ${t('storageCleaner:countUnit')}` : formatBytes(sizeValue)}
) : (
-
+
{t('storageCleaner:noData')}
)}
diff --git a/src/pages/StorageCleaner/StorageOptionsGrid.tsx b/src/pages/StorageCleaner/StorageOptionsGrid.tsx
index 07a4334..f19e36a 100644
--- a/src/pages/StorageCleaner/StorageOptionsGrid.tsx
+++ b/src/pages/StorageCleaner/StorageOptionsGrid.tsx
@@ -1,5 +1,6 @@
import React from 'react';
import type { StorageCleanerOptions } from '@/types/storage';
+import type { StorageSizeInfo } from './useStorageCleaner';
import OptionItem from './OptionItem';
import { useI18n } from '@/utils/chromeI18n';
import { cn } from '@/lib/utils';
@@ -8,7 +9,7 @@ import { Label } from '@/components/ui/label';
interface StorageOptionsGridProps extends React.HTMLAttributes {
options: StorageCleanerOptions;
- sizes: Record;
+ sizes: Record;
allSelected: boolean;
someSelected: boolean;
onOptionChange: (key: keyof StorageCleanerOptions) => void;
@@ -27,13 +28,13 @@ export default function StorageOptionsGrid({
}: StorageOptionsGridProps) {
const { t } = useI18n('storageCleaner');
- const optionKeys: { key: keyof StorageCleanerOptions; isCount?: boolean }[] = [
- { key: 'localStorage' },
- { key: 'sessionStorage' },
- { key: 'indexedDB' },
- { key: 'cookies' },
- { key: 'cacheStorage', isCount: true },
- { key: 'serviceWorkers', isCount: true },
+ const optionKeys: (keyof StorageCleanerOptions)[] = [
+ 'localStorage',
+ 'sessionStorage',
+ 'indexedDB',
+ 'cookies',
+ 'cacheStorage',
+ 'serviceWorkers',
];
const handleToggleAll = () => {
@@ -41,22 +42,15 @@ export default function StorageOptionsGrid({
};
return (
-
-
-
- {optionKeys.map(({ key, isCount }) => (
+
+
+
+ {optionKeys.map((key) => (
onOptionChange(key)}
/>
))}
@@ -65,9 +59,9 @@ export default function StorageOptionsGrid({
-
diff --git a/src/pages/StorageCleaner/index.tsx b/src/pages/StorageCleaner/index.tsx
index b582639..63b1f28 100644
--- a/src/pages/StorageCleaner/index.tsx
+++ b/src/pages/StorageCleaner/index.tsx
@@ -1,6 +1,6 @@
import { Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
-import StorageCleanerConfirm from '@/pages/StorageCleaner/StorageCleanerConfirm';
+import StorageCleanerConfirm from './StorageCleanerConfirm';
import { useStorageCleaner } from './useStorageCleaner';
import StorageOptionsGrid from './StorageOptionsGrid';
import AutoRefreshToggle from './AutoRefreshToggle';
@@ -47,37 +47,42 @@ export default function Index() {
}
return (
-
-
+
+ {/* 操作区域卡片 */}
+
+
-
+
-
+
+
+
+
diff --git a/src/pages/StorageCleaner/useStorageCleaner.ts b/src/pages/StorageCleaner/useStorageCleaner.ts
index 5910df4..9b46579 100644
--- a/src/pages/StorageCleaner/useStorageCleaner.ts
+++ b/src/pages/StorageCleaner/useStorageCleaner.ts
@@ -16,7 +16,6 @@ import {
getSessionStorageSize,
isRestrictedUrl,
} from '@/utils/storageCleaner';
-import { MessageAction, sendMessage } from '@/utils/messages';
import { useI18n } from '@/utils/chromeI18n';
import { toast } from 'sonner';
@@ -34,18 +33,22 @@ const DEFAULT_PREFERENCES: StorageCleanerPreferences = {
selectedTypes: DEFAULT_OPTIONS,
};
+export interface StorageSizeInfo {
+ value: number;
+ displayType: 'bytes' | 'count';
+}
+
export interface UseStorageCleanerReturn {
- domain: string;
error: string;
isInitializing: boolean;
options: StorageCleanerOptions;
- sizes: Record
;
+ sizes: Record;
reloadAfterClean: boolean;
loading: boolean;
result: CleaningResult | null;
showConfirm: boolean;
setShowConfirm: (show: boolean) => void;
- totalSize: number;
+ totalBytes: number;
allSelected: boolean;
someSelected: boolean;
@@ -57,11 +60,10 @@ export interface UseStorageCleanerReturn {
export function useStorageCleaner(): UseStorageCleanerReturn {
const { t } = useI18n(['storageCleaner', 'common']);
- const [domain, setDomain] = useState('');
const [error, setError] = useState('');
const [isInitializing, setIsInitializing] = useState(true);
const [options, setOptions] = useState(DEFAULT_OPTIONS);
- const [sizes, setSizes] = useState>({});
+ const [sizes, setSizes] = useState>({});
const [reloadAfterClean, setReloadAfterClean] = useState(true);
const [loading, setLoading] = useState(false);
const [result, setResult] = useState(null);
@@ -102,7 +104,6 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
setError('');
const url = tab.url;
const tabId = tab.id!;
- setDomain(new URL(url).hostname);
const [savedPrefs, cSize, lsSize, ssSize, idbSize, cacheCount, swCount] = await Promise.all([
storageUtil.get('storageCleaner/preferences', DEFAULT_PREFERENCES),
@@ -122,12 +123,12 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
}
setSizes({
- cookies: cSize,
- localStorage: lsSize,
- sessionStorage: ssSize,
- indexedDB: idbSize,
- cacheStorage: cacheCount,
- serviceWorkers: swCount,
+ cookies: { value: cSize, displayType: 'bytes' },
+ localStorage: { value: lsSize, displayType: 'bytes' },
+ sessionStorage: { value: ssSize, displayType: 'bytes' },
+ indexedDB: { value: idbSize, displayType: 'bytes' },
+ cacheStorage: { value: cacheCount, displayType: 'count' },
+ serviceWorkers: { value: swCount, displayType: 'count' },
});
} finally {
if (currentRequestId === requestIdRef.current) {
@@ -217,9 +218,9 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
const cleaningResult = await clearStorage(tab.id, tab.url, options);
setResult(cleaningResult);
- if (reloadAfterClean && cleaningResult.success) {
+ if (reloadAfterClean && cleaningResult.overallSuccess) {
toast.success(t('storageCleaner:cleanSuccessReload'));
- await sendMessage(MessageAction.RELOAD_TAB, { tabId: tab.id, delay: 1000 });
+ await chrome.tabs.reload(tab.id);
} else {
await loadInfo();
}
@@ -231,13 +232,10 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
}
}, [options, reloadAfterClean, loadInfo, t]);
- const totalSize = useMemo(() => {
- return (
- (sizes.cookies || 0) +
- (sizes.localStorage || 0) +
- (sizes.sessionStorage || 0) +
- (sizes.indexedDB || 0)
- );
+ const totalBytes = useMemo(() => {
+ return Object.values(sizes).reduce((acc, s) => {
+ return s.displayType === 'bytes' ? acc + (s.value || 0) : acc;
+ }, 0);
}, [sizes]);
const selectionMetrics = useMemo(() => {
@@ -248,7 +246,6 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
}, [options]);
return {
- domain,
error,
isInitializing,
options,
@@ -258,7 +255,7 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
result,
showConfirm,
setShowConfirm,
- totalSize,
+ totalBytes,
allSelected: selectionMetrics.all,
someSelected: selectionMetrics.some,
handleReloadAfterCleanChange,
diff --git a/src/pages/TextStatistics/index.tsx b/src/pages/TextStatistics/index.tsx
index ef96961..3661285 100644
--- a/src/pages/TextStatistics/index.tsx
+++ b/src/pages/TextStatistics/index.tsx
@@ -1,32 +1,22 @@
-import { useCallback, useMemo, useState } from 'react';
import TextInputArea from '@/components/TextInputArea';
-import { formatByteSize, getTextStats } from '@/utils/textStatistics';
+import { formatBytes } from '@/utils/format';
import { useI18n } from '@/utils/chromeI18n';
-import { useContextMenuData } from '@/utils/useContextMenuData';
+import { useTextStatistics } from './useTextStatistics';
import { cn } from '@/lib/utils';
export default function Index() {
const { t } = useI18n('textStatistics');
- const [text, setText] = useState('');
-
- const handleContextMenuData = useCallback((payload: string) => {
- setText(payload);
- }, []);
-
- useContextMenuData({ featureKey: 'textStatistics', onData: handleContextMenuData });
-
- const stats = useMemo(() => getTextStats(text), [text]);
+ const { text, stats, setText } = useTextStatistics();
const statItems = [
{ label: t('textStatistics:characters'), value: stats.characters },
{ label: t('textStatistics:words'), value: stats.words },
{ label: t('textStatistics:lines'), value: stats.lines },
- { label: t('textStatistics:bytes'), value: formatByteSize(stats.bytes) },
+ { label: t('textStatistics:bytes'), value: formatBytes(stats.bytes) },
];
return (
- {/* 文本输入区域 */}
- {/* 统计结果展示区域 */}
{statItems.map((item) => (
void;
+}
+
+export function useTextStatistics(): UseTextStatisticsReturn {
+ const [text, setText] = useState('');
+
+ const handleContextMenuData = useCallback((payload: string) => {
+ setText(payload);
+ }, []);
+
+ useContextMenuData({ featureKey: 'textStatistics', onData: handleContextMenuData });
+
+ const stats = getTextStats(text);
+
+ return { text, stats, setText };
+}
diff --git a/src/pages/Timestamp/LiveClock.tsx b/src/pages/Timestamp/LiveClock.tsx
index a314758..a75b352 100644
--- a/src/pages/Timestamp/LiveClock.tsx
+++ b/src/pages/Timestamp/LiveClock.tsx
@@ -1,8 +1,9 @@
-import React, { useCallback, useEffect, useRef, useState } from 'react';
+import React, { useEffect, useState } from 'react';
import { Clock } from 'lucide-react';
+import { toast } from 'sonner';
import CopyButton from '@/components/CopyButton';
-import { useSnackbar } from '@/components/GlobalSnackbar';
import type { UnitType } from './constants';
+import { msToUnit } from './constants';
import { useI18n } from '@/utils/chromeI18n';
import { cn } from '@/lib/utils';
@@ -11,42 +12,20 @@ interface LiveClockProps extends React.HTMLAttributes
{
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 { showMessage } = useSnackbar();
- const onUseNowRef = useRef(onUseNow);
- 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]);
+ const [rawTime, setRawTime] = useState(() => Date.now());
useEffect(() => {
const tick = () => {
- const rightNow = 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 };
- });
+ setRawTime(Date.now());
};
-
const tickId = setInterval(tick, 200);
return () => clearInterval(tickId);
}, [unit]);
- const handleUseNow = useCallback(() => {
- onUseNowRef.current(currentDisplay.rawTime);
- showMessage?.(t('timestamp:usedSuccess'), { severity: 'success' });
- }, [currentDisplay.rawTime, showMessage, t]);
+ const text = String(msToUnit(rawTime, unit));
return (
- {currentDisplay.text}
+ {text}
);
-});
-
-LiveClock.displayName = 'LiveClock';
-
-export default LiveClock;
+}
diff --git a/src/pages/Timestamp/ResultView.tsx b/src/pages/Timestamp/ResultView.tsx
index 71087a7..076d01e 100644
--- a/src/pages/Timestamp/ResultView.tsx
+++ b/src/pages/Timestamp/ResultView.tsx
@@ -1,119 +1,52 @@
-import React, { useMemo } from 'react';
-import dayjs from '@/utils/dayjs';
+import React from 'react';
import CopyButton from '@/components/CopyButton';
-import type { UnitType } from './constants';
-import { DATE_FORMAT } from './constants';
import { useI18n } from '@/utils/chromeI18n';
import { cn } from '@/lib/utils';
interface ResultViewProps extends React.HTMLAttributes {
result: string;
- mode: 'ts2dt' | 'dt2ts';
- unit: UnitType;
- zone: string;
-
showEmptyPlaceholder?: boolean;
}
-const ResultView = React.memo(
- ({
- result,
- mode,
- unit,
- zone,
- showEmptyPlaceholder = false,
- className,
- ...props
- }: ResultViewProps) => {
- 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 (!showEmptyPlaceholder) return null;
- return (
-
- {t('timestamp:resultEmpty')}
-
- );
- }
+export default function ResultView({
+ result,
+ showEmptyPlaceholder = false,
+ className,
+ ...props
+}: ResultViewProps) {
+ const { t } = useI18n('timestamp');
+ if (!result) {
+ if (!showEmptyPlaceholder) return null;
return (
-
- {/* 顶部小标签 */}
-
- {t('timestamp:resultLabel')}
-
-
-
-
- {result}
-
-
-
-
-
- {[
- { 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) => (
-
-
- {item.label}
-
-
-
- {item.value}
-
- {item.value && (
-
- )}
-
-
- ))}
-
+
+ {t('timestamp:resultEmpty')}
);
- },
-);
+ }
-ResultView.displayName = 'ResultView';
+ return (
+
+
+ {t('timestamp:resultLabel')}
+
-export default ResultView;
+
+
+ {result}
+
+
+
+
+ );
+}
diff --git a/src/pages/Timestamp/constants.ts b/src/pages/Timestamp/constants.ts
index ae08b00..6a84613 100644
--- a/src/pages/Timestamp/constants.ts
+++ b/src/pages/Timestamp/constants.ts
@@ -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 ZONES = ['Asia/Shanghai', 'America/New_York', 'Europe/London'] as const;
+export type ModeType = 'ts2dt' | 'dt2ts';
export type UnitType = 'ms' | 's';
export type ZoneType = (typeof ZONES)[number];
+
+const DIVISORS: Record
= { 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);
+}
diff --git a/src/pages/Timestamp/index.tsx b/src/pages/Timestamp/index.tsx
index e554860..2120708 100644
--- a/src/pages/Timestamp/index.tsx
+++ b/src/pages/Timestamp/index.tsx
@@ -1,5 +1,6 @@
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
import { ZONES } from './constants';
+import type { ModeType, UnitType, ZoneType } from './constants';
import LiveClock from './LiveClock';
import ResultView from './ResultView';
import { useTimestampConverter } from './useTimestampConverter';
@@ -15,6 +16,16 @@ import {
SelectValue,
} 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() {
const { t } = useI18n('timestamp');
@@ -42,11 +53,8 @@ export default function Index() {
setMode(newMode as 'ts2dt' | 'dt2ts')}
+ options={MODE_OPTIONS.map((o) => ({ ...o, label: t(o.label) }))}
+ onChange={setMode}
size="small"
/>
@@ -57,7 +65,7 @@ export default function Index() {
mode === 'ts2dt' ? t('timestamp:placeholderTs') : t('timestamp:placeholderDate')
}
value={input}
- onChange={(e: { target: { value: string } }) => setInput(e.target.value)}
+ onChange={(e) => setInput(e.target.value)}
className={cn(
'font-mono font-semibold h-10 shadow-sm placeholder:text-muted-foreground/60 focus:bg-background',
error && 'border-destructive focus-visible:ring-destructive',
@@ -70,16 +78,13 @@ export default function Index() {
setUnit(v as 'ms' | 's')}
+ options={UNIT_OPTIONS.map((o) => ({ ...o, label: t(o.label) }))}
+ onChange={setUnit}
size="small"
className="sm:w-auto shrink-0"
/>
-
-
+
diff --git a/src/pages/Timestamp/useTimestampConverter.ts b/src/pages/Timestamp/useTimestampConverter.ts
index 31d8be8..7562c85 100644
--- a/src/pages/Timestamp/useTimestampConverter.ts
+++ b/src/pages/Timestamp/useTimestampConverter.ts
@@ -1,39 +1,42 @@
-import { useCallback, useMemo, useState } from 'react';
+import { useMemo, useState } from 'react';
import dayjs from '@/utils/dayjs';
-import type { UnitType, ZoneType } from './constants';
-import { DATE_FORMAT } from './constants';
+import type { UnitType, ZoneType, ModeType } from './constants';
+import { DATE_FORMAT, msToUnit, dayjsFromTimestamp } from './constants';
import { useI18n } from '@/utils/chromeI18n';
import { useContextMenuData } from '@/utils/useContextMenuData';
export interface UseTimestampConverterReturn {
- mode: 'ts2dt' | 'dt2ts';
+ mode: ModeType;
input: string;
unit: UnitType;
zone: ZoneType;
result: string;
error: string;
- setMode: (mode: 'ts2dt' | 'dt2ts') => void;
+ setMode: (mode: ModeType) => void;
setInput: (value: string) => void;
setUnit: (unit: UnitType) => void;
setZone: (zone: ZoneType) => void;
handleUseNow: (now: number) => void;
}
+const TIMESTAMP_REGEX = /^\d+$/;
+const MS_TIMESTAMP_MIN_LENGTH = 13;
+
function isTimestampLike(input: string): boolean {
const trimmed = input.trim();
- return /^\d+$/.test(trimmed) && trimmed.length >= 10;
+ return TIMESTAMP_REGEX.test(trimmed) && trimmed.length >= 10;
}
export function useTimestampConverter(): UseTimestampConverterReturn {
const { t } = useI18n('timestamp');
- const [mode, setMode] = useState<'ts2dt' | 'dt2ts'>('ts2dt');
+ const [mode, setMode] = useState('ts2dt');
const [unit, setUnit] = useState('ms');
const [zone, setZone] = useState('Asia/Shanghai');
const [input, setInput] = useState(() => String(Date.now()));
- const conversionPipeline = useMemo(() => {
+ const { result, error } = useMemo(() => {
const rawInput = input.trim();
if (!rawInput) return { result: '', error: '' };
@@ -42,7 +45,7 @@ export function useTimestampConverter(): UseTimestampConverterReturn {
if (isNaN(num)) {
return { result: '', error: t('timestamp:errors.invalidNumber') };
}
- const d = unit === 'ms' ? dayjs(num) : dayjs.unix(num);
+ const d = dayjsFromTimestamp(num, unit);
if (!d.isValid()) {
return { result: '', error: t('timestamp:errors.invalidTimestamp') };
}
@@ -53,19 +56,15 @@ export function useTimestampConverter(): UseTimestampConverterReturn {
return { result: '', error: t('timestamp:errors.invalidFormat') };
}
const ms = d.valueOf();
- const outputTs = unit === 'ms' ? String(ms) : String(Math.floor(ms / 1000));
- return { result: outputTs, error: '' };
+ return { result: String(msToUnit(ms, unit)), error: '' };
}
}, [input, mode, unit, zone, t]);
- const { result, error } = conversionPipeline;
-
- const handleContextMenuData = useCallback((payload: string) => {
+ const handleContextMenuData = (payload: string) => {
const trimmed = payload.trim();
if (isTimestampLike(trimmed)) {
setMode('ts2dt');
- const detectedUnit: UnitType = trimmed.length >= 13 ? 'ms' : 's';
- setUnit(detectedUnit);
+ setUnit(trimmed.length >= MS_TIMESTAMP_MIN_LENGTH ? 'ms' : 's');
setInput(trimmed);
} else {
const d = dayjs(trimmed);
@@ -77,30 +76,24 @@ export function useTimestampConverter(): UseTimestampConverterReturn {
setInput(trimmed);
}
}
- }, []);
+ };
useContextMenuData({ featureKey: 'timestamp', onData: handleContextMenuData });
- const handleUseNow = useCallback(
- (now: number) => {
- if (mode === 'ts2dt') {
- setInput(String(unit === 'ms' ? now : Math.floor(now / 1000)));
- } else {
- setInput(dayjs(now).tz(zone).format(DATE_FORMAT));
- }
- },
- [mode, unit, zone],
- );
+ const handleUseNow = (now: number) => {
+ if (mode === 'ts2dt') {
+ setInput(String(msToUnit(now, unit)));
+ } else {
+ setInput(dayjs(now).tz(zone).format(DATE_FORMAT));
+ }
+ };
- const handleSetMode = useCallback(
- (newMode: 'ts2dt' | 'dt2ts') => {
- setMode(newMode);
- if (result && !error) {
- setInput(result);
- }
- },
- [result, error],
- );
+ const handleSetMode = (newMode: ModeType) => {
+ setMode(newMode);
+ if (result && !error) {
+ setInput(result);
+ }
+ };
return {
mode,
diff --git a/src/providers/ThemeModeProvider.tsx b/src/providers/ThemeModeProvider.tsx
index 7a7b75c..02b7997 100644
--- a/src/providers/ThemeModeProvider.tsx
+++ b/src/providers/ThemeModeProvider.tsx
@@ -1,12 +1,4 @@
-import {
- createContext,
- ReactNode,
- useCallback,
- useContext,
- useEffect,
- useMemo,
- useState,
-} from 'react';
+import { createContext, ReactNode, useCallback, useContext, useEffect, useState } from 'react';
import { browser } from 'wxt/browser';
import { storageUtil } from '@/utils/chromeStorage';
@@ -141,12 +133,11 @@ export function ThemeModeProvider({ children }: ThemeModeProviderProps) {
}
}, [resolvedMode]);
- const contextValue = useMemo(
- () => ({ mode, resolvedMode, setMode }),
- [mode, resolvedMode, setMode],
+ return (
+
+ {children}
+
);
-
- return {children};
}
export function useThemeMode(): ThemeModeContextType {
diff --git a/src/types/storage.d.ts b/src/types/storage.d.ts
index 64fe134..f5ab210 100644
--- a/src/types/storage.d.ts
+++ b/src/types/storage.d.ts
@@ -189,7 +189,7 @@ export type StorageCleanResult =
*/
export interface CleaningResult {
/** 整体操作是否成功 */
- success: boolean;
+ overallSuccess: boolean;
/** 整体错误信息(如果有) */
error?: string;
/** 各项清理的具体结果 */
diff --git a/src/utils/__tests__/messages.test.ts b/src/utils/__tests__/messages.test.ts
index 7468edb..434361c 100644
--- a/src/utils/__tests__/messages.test.ts
+++ b/src/utils/__tests__/messages.test.ts
@@ -34,10 +34,14 @@ describe('messages', () => {
const mockTab = { id: 123, url: 'https://example.com' };
(chrome.tabs.query as any).mockResolvedValue([mockTab]);
- const result = await sendMessageToContent(MessageAction.RELOAD_TAB, { tabId: 123 });
+ const result = await sendMessageToContent(MessageAction.RESTORE_RIGHT_CLICK);
expect(result).toEqual(mockResponse);
- expect(mockSendMessage).toHaveBeenCalledWith(MessageAction.RELOAD_TAB, { tabId: 123 }, 123);
+ expect(mockSendMessage).toHaveBeenCalledWith(
+ MessageAction.RESTORE_RIGHT_CLICK,
+ undefined,
+ 123,
+ );
});
it('应该支持不带数据的消息发送', async () => {
@@ -60,11 +64,11 @@ describe('messages', () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
(chrome.tabs.query as any).mockResolvedValue([]);
- const result = await sendMessageToContent(MessageAction.RELOAD_TAB, { tabId: 123 });
+ const result = await sendMessageToContent(MessageAction.RESTORE_RIGHT_CLICK);
expect(result).toEqual({ success: false, message: '无法获取当前标签页' });
expect(consoleSpy).toHaveBeenCalledWith(
- '[Messaging] 无法获取当前标签页,无法发送动作: reloadTab',
+ '[Messaging] 无法获取当前标签页,无法发送动作: restoreRightClick',
);
consoleSpy.mockRestore();
});
@@ -73,7 +77,7 @@ describe('messages', () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
(chrome.tabs.query as any).mockResolvedValue([{ url: 'https://example.com' }]);
- const result = await sendMessageToContent(MessageAction.RELOAD_TAB, { tabId: 123 });
+ const result = await sendMessageToContent(MessageAction.RESTORE_RIGHT_CLICK);
expect(result).toEqual({ success: false, message: '无法获取当前标签页' });
consoleSpy.mockRestore();
@@ -87,7 +91,7 @@ describe('messages', () => {
const mockTab = { id: 123, url: 'https://example.com' };
(chrome.tabs.query as any).mockResolvedValue([mockTab]);
- const result = await sendMessageToContent(MessageAction.RELOAD_TAB, { tabId: 123 });
+ const result = await sendMessageToContent(MessageAction.RESTORE_RIGHT_CLICK);
expect(result).toEqual({
success: false,
@@ -104,7 +108,7 @@ describe('messages', () => {
const mockTab = { id: 123, url: 'https://example.com' };
(chrome.tabs.query as any).mockResolvedValue([mockTab]);
- const result = await sendMessageToContent(MessageAction.RELOAD_TAB, { tabId: 123 });
+ const result = await sendMessageToContent(MessageAction.RESTORE_RIGHT_CLICK);
expect(result).toEqual({
success: false,
@@ -121,7 +125,7 @@ describe('messages', () => {
const mockTab = { id: 123, url: 'https://example.com' };
(chrome.tabs.query as any).mockResolvedValue([mockTab]);
- const result = await sendMessageToContent(MessageAction.RELOAD_TAB, { tabId: 123 });
+ const result = await sendMessageToContent(MessageAction.RESTORE_RIGHT_CLICK);
expect(result).toEqual({
success: false,
@@ -138,7 +142,7 @@ describe('messages', () => {
const mockTab = { id: 123, url: 'https://example.com' };
(chrome.tabs.query as any).mockResolvedValue([mockTab]);
- const result = await sendMessageToContent(MessageAction.RELOAD_TAB, { tabId: 123 });
+ const result = await sendMessageToContent(MessageAction.RESTORE_RIGHT_CLICK);
expect(result).toEqual({
success: false,
@@ -151,7 +155,7 @@ describe('messages', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
(chrome.tabs.query as any).mockRejectedValue(new Error('Query failed'));
- const result = await sendMessageToContent(MessageAction.RELOAD_TAB, { tabId: 123 });
+ const result = await sendMessageToContent(MessageAction.RESTORE_RIGHT_CLICK);
expect(result).toEqual({
success: false,
diff --git a/src/utils/__tests__/storageCleaner.test.ts b/src/utils/__tests__/storageCleaner.test.ts
index 4b2d6c2..9137a38 100644
--- a/src/utils/__tests__/storageCleaner.test.ts
+++ b/src/utils/__tests__/storageCleaner.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
-import { clearCookies, formatSize, isRestrictedUrl } from '@/utils/storageCleaner';
+import { clearCookies, isRestrictedUrl } from '@/utils/storageCleaner';
+import { formatBytes } from '@/utils/format';
describe('storageCleaner utils', () => {
describe('isRestrictedUrl', () => {
@@ -48,36 +49,36 @@ describe('storageCleaner utils', () => {
});
});
- describe('formatSize', () => {
+ describe('formatBytes', () => {
it('should return "0 B" for 0 bytes', () => {
- expect(formatSize(0)).toBe('0 B');
+ expect(formatBytes(0)).toBe('0 B');
});
it('should format bytes correctly', () => {
- expect(formatSize(500)).toBe('500 B');
+ expect(formatBytes(500)).toBe('500 B');
});
it('should format kilobytes correctly', () => {
- expect(formatSize(1024)).toBe('1.0 KB');
- expect(formatSize(1536)).toBe('1.5 KB');
- expect(formatSize(2048)).toBe('2.0 KB');
+ expect(formatBytes(1024)).toBe('1.0 KB');
+ expect(formatBytes(1536)).toBe('1.5 KB');
+ expect(formatBytes(2048)).toBe('2.0 KB');
});
it('should format megabytes correctly', () => {
- expect(formatSize(1048576)).toBe('1.00 MB');
- expect(formatSize(1572864)).toBe('1.50 MB');
- expect(formatSize(5242880)).toBe('5.00 MB');
+ expect(formatBytes(1048576)).toBe('1.00 MB');
+ expect(formatBytes(1572864)).toBe('1.50 MB');
+ expect(formatBytes(5242880)).toBe('5.00 MB');
});
it('should format gigabytes correctly', () => {
- expect(formatSize(1073741824)).toBe('1.00 GB');
- expect(formatSize(2147483648)).toBe('2.00 GB');
+ expect(formatBytes(1073741824)).toBe('1.00 GB');
+ expect(formatBytes(2147483648)).toBe('2.00 GB');
});
it('should handle edge cases', () => {
- expect(formatSize(1)).toBe('1 B');
- expect(formatSize(1023)).toBe('1023 B');
- expect(formatSize(1025)).toBe('1.0 KB');
+ expect(formatBytes(1)).toBe('1 B');
+ expect(formatBytes(1023)).toBe('1023 B');
+ expect(formatBytes(1025)).toBe('1.0 KB');
});
});
diff --git a/src/utils/__tests__/textStatistics.test.ts b/src/utils/__tests__/textStatistics.test.ts
index 308f348..81d4103 100644
--- a/src/utils/__tests__/textStatistics.test.ts
+++ b/src/utils/__tests__/textStatistics.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
-import { formatByteSize, getTextStats } from '@/utils/textStatistics';
+import { formatBytes } from '@/utils/format';
+import { getTextStats } from '@/utils/textStatistics';
describe('textStatistics utils', () => {
describe('getTextStats', () => {
@@ -49,12 +50,12 @@ describe('textStatistics utils', () => {
});
});
- describe('formatByteSize', () => {
+ describe('formatBytes', () => {
it('should format bytes correctly', () => {
- expect(formatByteSize(100)).toBe('100 B');
- expect(formatByteSize(0)).toBe('0 B');
- expect(formatByteSize(1024)).toBe('1.0 KB');
- expect(formatByteSize(1024 * 1024)).toBe('1.00 MB');
+ expect(formatBytes(100)).toBe('100 B');
+ expect(formatBytes(0)).toBe('0 B');
+ expect(formatBytes(1024)).toBe('1.0 KB');
+ expect(formatBytes(1024 * 1024)).toBe('1.00 MB');
});
});
});
diff --git a/src/utils/base64Converter.ts b/src/utils/base64Converter.ts
index 7d1828e..54ee1f4 100644
--- a/src/utils/base64Converter.ts
+++ b/src/utils/base64Converter.ts
@@ -213,8 +213,9 @@ export function extractMimeTypeFromDataUri(dataUri: string): string {
}
/**
- * 格式化文件大小显示(兼容旧接口,内部委托给 formatBytes)
+ * 格式化文件大小显示
*
+ * @deprecated 直接使用 {@link formatBytes} 代替
* @param bytes 字节数
* @returns 格式化后的字符串
*/
@@ -270,12 +271,7 @@ const MAGIC_BYTE_SIGNATURES: ReadonlyArray<{
* @returns 字节序列
*/
export function base64ToBytes(b64: string): Uint8Array {
- const binary = atob(b64);
- const bytes = new Uint8Array(binary.length);
- for (let i = 0; i < binary.length; i += 1) {
- bytes[i] = binary.charCodeAt(i);
- }
- return bytes;
+ return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
}
/**
@@ -287,25 +283,11 @@ export function base64ToBytes(b64: string): Uint8Array {
export function sniffMimeFromBytes(bytes: Uint8Array): { mime: string; ext: string } | null {
for (const sig of MAGIC_BYTE_SIGNATURES) {
if (bytes.length < sig.bytes.length) continue;
- let matched = true;
- for (let i = 0; i < sig.bytes.length; i += 1) {
- if (bytes[i] !== sig.bytes[i]) {
- matched = false;
- break;
- }
- }
- if (!matched) continue;
+ if (!sig.bytes.every((b, i) => bytes[i] === b)) continue;
if (sig.tail) {
const { offset, bytes: tailBytes } = sig.tail;
if (bytes.length < offset + tailBytes.length) continue;
- let tailMatched = true;
- for (let i = 0; i < tailBytes.length; i += 1) {
- if (bytes[offset + i] !== tailBytes[i]) {
- tailMatched = false;
- break;
- }
- }
- if (!tailMatched) continue;
+ if (!tailBytes.every((b, i) => bytes[offset + i] === b)) continue;
}
return { mime: sig.mime, ext: sig.ext };
}
diff --git a/src/utils/messages.ts b/src/utils/messages.ts
index 476c4bd..b1f9bcb 100644
--- a/src/utils/messages.ts
+++ b/src/utils/messages.ts
@@ -1,7 +1,6 @@
import { defineExtensionMessaging } from '@webext-core/messaging';
export enum MessageAction {
- RELOAD_TAB = 'reloadTab',
SIDE_PANEL_STATE_CHANGED = 'sidePanelStateChanged',
CONTEXT_MENU_CLICKED = 'contextMenuClicked',
RESTORE_RIGHT_CLICK = 'restoreRightClick',
@@ -21,7 +20,6 @@ export interface ContextMenuClickedPayload {
}
export interface ProtocolMap {
- [MessageAction.RELOAD_TAB](data: { tabId: number; delay?: number }): MessageResponse;
[MessageAction.SIDE_PANEL_STATE_CHANGED](data: { isOpen: boolean }): void;
[MessageAction.CONTEXT_MENU_CLICKED](data: ContextMenuClickedPayload): void;
[MessageAction.RESTORE_RIGHT_CLICK](data: undefined): MessageResponse & { restored: boolean };
diff --git a/src/utils/rightClickInjection.ts b/src/utils/rightClickInjection.ts
new file mode 100644
index 0000000..6645fa1
--- /dev/null
+++ b/src/utils/rightClickInjection.ts
@@ -0,0 +1,71 @@
+/**
+ * 右键解锁功能的主环境注入脚本
+ *
+ * 由于 content script 运行在 Isolated World,无法直接修改网页主环境的
+ * Event.prototype.preventDefault 等原生方法。需要通过 background
+ * 的 scripting.executeScript({ world: 'MAIN' }) 注入此脚本。
+ */
+
+/**
+ * 在网页主环境中执行的注入函数
+ * 注意:此函数会被序列化后通过 executeScript 注入,不能引用外部变量
+ */
+export function mainWorldInjectionScript(): void {
+ 'use strict';
+ const w = window as unknown as Record;
+ if (w.__testingToolsRightClickPatched) return;
+ w.__testingToolsRightClickPatched = true;
+
+ const PROTECTED = ['contextmenu', 'copy', 'paste', 'cut', 'selectstart'];
+
+ const _origPreventDefault = MouseEvent.prototype.preventDefault;
+ Object.defineProperty(MouseEvent.prototype, 'preventDefault', {
+ value: function (this: MouseEvent) {
+ const t = this.type;
+ if (PROTECTED.includes(t) || (t === 'mousedown' && this.button === 2)) {
+ return;
+ }
+ return _origPreventDefault.call(this);
+ },
+ writable: true,
+ configurable: true,
+ });
+
+ const _origStopPropagation = Event.prototype.stopPropagation;
+ Object.defineProperty(Event.prototype, 'stopPropagation', {
+ value: function (this: Event) {
+ if (PROTECTED.includes(this.type)) return;
+ return _origStopPropagation.call(this);
+ },
+ writable: true,
+ configurable: true,
+ });
+
+ const _origStopImmediatePropagation = Event.prototype.stopImmediatePropagation;
+ Object.defineProperty(Event.prototype, 'stopImmediatePropagation', {
+ value: function (this: Event) {
+ if (PROTECTED.includes(this.type)) return;
+ return _origStopImmediatePropagation.call(this);
+ },
+ writable: true,
+ configurable: true,
+ });
+
+ let _docOnContextMenu: unknown = null;
+ Object.defineProperty(document, 'oncontextmenu', {
+ get() {
+ return _docOnContextMenu;
+ },
+ set(fn: unknown) {
+ if (typeof fn === 'function') {
+ _docOnContextMenu = function (this: GlobalEventHandlers, e: MouseEvent) {
+ const r = (fn as (this: GlobalEventHandlers, ev: MouseEvent) => unknown).call(this, e);
+ return r === false ? true : r;
+ };
+ } else {
+ _docOnContextMenu = fn;
+ }
+ },
+ configurable: true,
+ });
+}
diff --git a/src/utils/storageCleaner.ts b/src/utils/storageCleaner.ts
index bf2591b..1238e07 100644
--- a/src/utils/storageCleaner.ts
+++ b/src/utils/storageCleaner.ts
@@ -1,5 +1,4 @@
import type { CleaningResult, StorageCleanerOptions, StorageCleanResult } from '@/types/storage';
-import { formatBytes } from './format';
const RESTRICTED_PROTOCOLS = [
'chrome:',
@@ -11,6 +10,16 @@ const RESTRICTED_PROTOCOLS = [
'data:',
] as const;
+/** 清理选项的 key 列表(用于遍历结果) */
+const CLEAN_OPTION_KEYS: (keyof StorageCleanerOptions)[] = [
+ 'localStorage',
+ 'sessionStorage',
+ 'indexedDB',
+ 'cookies',
+ 'cacheStorage',
+ 'serviceWorkers',
+];
+
export async function getCurrentTab() {
// For popup pages, we need to get the active tab from the browser window that triggered the popup.
// We should ONLY care about the currently active tab in the last focused window.
@@ -59,129 +68,122 @@ export async function getCookieSize(url: string): Promise {
}
}
-export async function getLocalStorageSize(tabId: number): Promise {
+/**
+ * 通用辅助:在指定标签页中执行脚本并返回结果
+ *
+ * @param tabId 标签页 ID
+ * @param func 在页面上下文中执行的函数
+ * @param errorLabel 错误日志前缀
+ * @param fallback 执行失败时的回退值
+ */
+async function runScript(
+ tabId: number,
+ func: () => T | Promise,
+ errorLabel: string,
+ fallback: T,
+): Promise {
try {
- const [result] = await chrome.scripting.executeScript({
- target: { tabId },
- func: () => {
- try {
- const encoder = new TextEncoder();
- return Object.entries(localStorage).reduce(
- (acc, [k, v]) => acc + encoder.encode(k).length + encoder.encode(v).length,
- 0,
- );
- } catch {
- return 0;
- }
- },
- });
- return (result?.result as number) || 0;
+ const [result] = await chrome.scripting.executeScript({ target: { tabId }, func });
+ return (result?.result as T) ?? fallback;
} catch (error) {
- console.error('Failed to get LocalStorage size:', error);
- return 0;
+ console.error(`Failed to ${errorLabel}:`, error);
+ return fallback;
}
}
+export async function getLocalStorageSize(tabId: number): Promise {
+ return runScript(
+ tabId,
+ () => {
+ try {
+ const encoder = new TextEncoder();
+ return Object.entries(localStorage).reduce(
+ (acc, [k, v]) => acc + encoder.encode(k).length + encoder.encode(v).length,
+ 0,
+ );
+ } catch {
+ return 0;
+ }
+ },
+ 'get LocalStorage size',
+ 0,
+ );
+}
+
export async function getSessionStorageSize(tabId: number): Promise {
- try {
- const [result] = await chrome.scripting.executeScript({
- target: { tabId },
- func: () => {
- try {
- const encoder = new TextEncoder();
- return Object.entries(sessionStorage).reduce(
- (acc, [k, v]) => acc + encoder.encode(k).length + encoder.encode(v).length,
- 0,
- );
- } catch {
- return 0;
- }
- },
- });
- return (result?.result as number) || 0;
- } catch (error) {
- console.error('Failed to get SessionStorage size:', error);
- return 0;
- }
+ return runScript(
+ tabId,
+ () => {
+ try {
+ const encoder = new TextEncoder();
+ return Object.entries(sessionStorage).reduce(
+ (acc, [k, v]) => acc + encoder.encode(k).length + encoder.encode(v).length,
+ 0,
+ );
+ } catch {
+ return 0;
+ }
+ },
+ 'get SessionStorage size',
+ 0,
+ );
}
export async function getOriginStorageEstimate(tabId: number): Promise {
- try {
- const [result] = await chrome.scripting.executeScript({
- target: { tabId },
- func: async () => {
- try {
- if (navigator.storage && navigator.storage.estimate) {
- const estimate = await navigator.storage.estimate();
- return estimate.usage || 0;
- }
- return 0;
- } catch {
- return 0;
+ return runScript(
+ tabId,
+ async () => {
+ try {
+ if (navigator.storage && navigator.storage.estimate) {
+ const estimate = await navigator.storage.estimate();
+ return estimate.usage || 0;
}
- },
- });
- return (result?.result as number) || 0;
- } catch (error) {
- console.error('Failed to get origin storage estimate:', error);
- return 0;
- }
+ return 0;
+ } catch {
+ return 0;
+ }
+ },
+ 'get origin storage estimate',
+ 0,
+ );
}
export async function getCacheStorageSize(tabId: number): Promise {
- try {
- const [result] = await chrome.scripting.executeScript({
- target: { tabId },
- func: async () => {
- try {
- if ('caches' in window) {
- const keys = await caches.keys();
- return keys.length;
- }
- return 0;
- } catch {
- return 0;
+ return runScript(
+ tabId,
+ async () => {
+ try {
+ if ('caches' in window) {
+ const keys = await caches.keys();
+ return keys.length;
}
- },
- });
- return (result?.result as number) || 0;
- } catch (error) {
- console.error('Failed to get CacheStorage size:', error);
- return 0;
- }
+ return 0;
+ } catch {
+ return 0;
+ }
+ },
+ 'get CacheStorage size',
+ 0,
+ );
}
export async function getServiceWorkerCount(tabId: number): Promise {
- try {
- const [result] = await chrome.scripting.executeScript({
- target: { tabId },
- func: async () => {
- try {
- if ('serviceWorker' in navigator) {
- const regs = await navigator.serviceWorker.getRegistrations();
- return regs.length;
- }
- return 0;
- } catch {
- return 0;
+ return runScript(
+ tabId,
+ async () => {
+ try {
+ if ('serviceWorker' in navigator) {
+ const regs = await navigator.serviceWorker.getRegistrations();
+ return regs.length;
}
- },
- });
- return (result?.result as number) || 0;
- } catch (error) {
- console.error('Failed to get ServiceWorker count:', error);
- return 0;
- }
-}
-
-/**
- * 格式化字节大小显示(兼容旧接口,内部委托给 formatBytes)
- *
- * @param bytes 字节数
- * @returns 格式化后的字符串
- */
-export function formatSize(bytes: number): string {
- return formatBytes(bytes);
+ return 0;
+ } catch {
+ return 0;
+ }
+ },
+ 'get ServiceWorker count',
+ 0,
+ );
}
export async function clearCookies(url: string): Promise {
@@ -203,148 +205,125 @@ export async function clearCookies(url: string): Promise {
}
}
-export async function injectClearLocalStorage(tabId: number): Promise {
- try {
- const [result] = await chrome.scripting.executeScript({
- target: { tabId },
- func: () => {
- const count = localStorage.length;
- localStorage.clear();
- return { count };
- },
- });
- if (result?.result && typeof result.result === 'object' && 'count' in result.result) {
- return { success: true, count: result.result.count };
- }
- return { success: false, error: 'No result returned' };
- } catch (error) {
- return { success: false, error: String(error) };
+/**
+ * 通用辅助:执行清理脚本并解析结果
+ *
+ * @param tabId 标签页 ID
+ * @param func 在页面上下文中执行的清理函数
+ * @param errorLabel 错误日志前缀
+ */
+async function runCleanScript(
+ tabId: number,
+ func: () => { count: number } | Promise<{ count: number }>,
+ errorLabel: string,
+): Promise {
+ const raw = await runScript(tabId, func, errorLabel, { count: 0 });
+ if (raw && typeof raw === 'object' && 'count' in raw) {
+ return { success: true, count: raw.count };
}
+ return { success: false, error: 'No result returned' };
+}
+
+export async function injectClearLocalStorage(tabId: number): Promise {
+ return runCleanScript(
+ tabId,
+ () => {
+ const count = localStorage.length;
+ localStorage.clear();
+ return { count };
+ },
+ 'clear LocalStorage',
+ );
}
export async function injectClearSessionStorage(tabId: number): Promise {
- try {
- const [result] = await chrome.scripting.executeScript({
- target: { tabId },
- func: () => {
- const count = sessionStorage.length;
- sessionStorage.clear();
- return { count };
- },
- });
- if (result?.result && typeof result.result === 'object' && 'count' in result.result) {
- return { success: true, count: result.result.count };
- }
- return { success: false, error: 'No result returned' };
- } catch (error) {
- return { success: false, error: String(error) };
- }
+ return runCleanScript(
+ tabId,
+ () => {
+ const count = sessionStorage.length;
+ sessionStorage.clear();
+ return { count };
+ },
+ 'clear SessionStorage',
+ );
}
export async function injectClearIndexedDB(tabId: number): Promise {
- try {
- const [result] = await chrome.scripting.executeScript({
- target: { tabId },
- func: async () => {
- if (typeof indexedDB.databases === 'function') {
- const databases = await indexedDB.databases();
- let count = 0;
- for (const db of databases) {
- if (db.name) {
- const dbName = db.name as string;
- try {
- await new Promise((resolve, reject) => {
- const deleteReq = indexedDB.deleteDatabase(dbName);
- const timeout = setTimeout(() => {
- console.warn('IndexedDB delete timeout:', dbName);
- resolve(); // Timeout, move to next
- }, 5000);
-
- deleteReq.onblocked = () => {
- console.warn('IndexedDB delete blocked:', dbName);
- clearTimeout(timeout);
- resolve(); // Blocked, move to next
- };
- deleteReq.onsuccess = () => {
- clearTimeout(timeout);
- resolve();
- };
- deleteReq.onerror = () => {
- clearTimeout(timeout);
- reject(new Error(`Failed to delete ${dbName}`));
- };
- });
- count++;
- } catch (e) {
- console.error('Delete DB error:', e);
- }
- }
- }
- return { count };
+ return runCleanScript(
+ tabId,
+ async () => {
+ if (typeof indexedDB.databases !== 'function') {
+ return { count: 0 };
+ }
+ const databases = await indexedDB.databases();
+ let count = 0;
+ for (const db of databases) {
+ if (!db.name) continue;
+ const dbName = db.name;
+ try {
+ await new Promise((resolve, reject) => {
+ const deleteReq = indexedDB.deleteDatabase(dbName);
+ const timeout = setTimeout(() => {
+ console.warn('IndexedDB delete timeout:', dbName);
+ resolve();
+ }, 5000);
+ deleteReq.onblocked = () => {
+ console.warn('IndexedDB delete blocked:', dbName);
+ clearTimeout(timeout);
+ resolve();
+ };
+ deleteReq.onsuccess = () => {
+ clearTimeout(timeout);
+ resolve();
+ };
+ deleteReq.onerror = () => {
+ clearTimeout(timeout);
+ reject(new Error(`Failed to delete ${dbName}`));
+ };
+ });
+ count++;
+ } catch (e) {
+ console.error('Delete DB error:', e);
}
- return { error: 'databases_api_unavailable' };
- },
- });
- if (result?.result && typeof result.result === 'object') {
- if ('error' in result.result) {
- return { success: false, error: String(result.result.error) };
}
- if ('count' in result.result) {
- return { success: true, count: result.result.count };
- }
- }
- return { success: false, error: 'No result returned' };
- } catch (error) {
- return { success: false, error: String(error) };
- }
+ return { count };
+ },
+ 'clear IndexedDB',
+ );
}
export async function injectClearCacheStorage(tabId: number): Promise {
- try {
- const [result] = await chrome.scripting.executeScript({
- target: { tabId },
- func: async () => {
- if ('caches' in window) {
- const cacheNames = await caches.keys();
- for (const name of cacheNames) {
- await caches.delete(name);
- }
- return { count: cacheNames.length };
+ return runCleanScript(
+ tabId,
+ async () => {
+ if ('caches' in window) {
+ const cacheNames = await caches.keys();
+ for (const name of cacheNames) {
+ await caches.delete(name);
}
- return { count: 0 };
- },
- });
- if (result?.result && typeof result.result === 'object' && 'count' in result.result) {
- return { success: true, count: result.result.count };
- }
- return { success: false, error: 'No result returned' };
- } catch (error) {
- return { success: false, error: String(error) };
- }
+ return { count: cacheNames.length };
+ }
+ return { count: 0 };
+ },
+ 'clear CacheStorage',
+ );
}
export async function injectUnregisterServiceWorkers(tabId: number): Promise {
- try {
- const [result] = await chrome.scripting.executeScript({
- target: { tabId },
- func: async () => {
- if ('serviceWorker' in navigator) {
- const registrations = await navigator.serviceWorker.getRegistrations();
- for (const registration of registrations) {
- await registration.unregister();
- }
- return { count: registrations.length };
+ return runCleanScript(
+ tabId,
+ async () => {
+ if ('serviceWorker' in navigator) {
+ const registrations = await navigator.serviceWorker.getRegistrations();
+ for (const registration of registrations) {
+ await registration.unregister();
}
- return { count: 0 };
- },
- });
- if (result?.result && typeof result.result === 'object' && 'count' in result.result) {
- return { success: true, count: result.result.count };
- }
- return { success: false, error: 'No result returned' };
- } catch (error) {
- return { success: false, error: String(error) };
- }
+ return { count: registrations.length };
+ }
+ return { count: 0 };
+ },
+ 'unregister ServiceWorkers',
+ );
}
export async function clearStorage(
@@ -352,39 +331,32 @@ export async function clearStorage(
url: string,
options: StorageCleanerOptions,
): Promise {
- const result: CleaningResult = { success: true };
+ const result: CleaningResult = { overallSuccess: true };
if (options.localStorage) {
result.localStorage = await injectClearLocalStorage(tabId);
}
-
if (options.sessionStorage) {
result.sessionStorage = await injectClearSessionStorage(tabId);
}
-
if (options.indexedDB) {
result.indexedDB = await injectClearIndexedDB(tabId);
}
-
if (options.cookies) {
result.cookies = await clearCookies(url);
}
-
if (options.cacheStorage) {
result.cacheStorage = await injectClearCacheStorage(tabId);
}
-
if (options.serviceWorkers) {
result.serviceWorkers = await injectUnregisterServiceWorkers(tabId);
}
- // Check if any operation failed
const failures = Object.values(result).filter(
(r): r is StorageCleanResult => r?.success === false,
);
-
if (failures.length > 0) {
- result.success = false;
+ result.overallSuccess = false;
}
return result;
@@ -396,16 +368,7 @@ export function formatCleaningResult(
): string {
const parts: string[] = [];
- const optionKeys: (keyof StorageCleanerOptions)[] = [
- 'localStorage',
- 'sessionStorage',
- 'indexedDB',
- 'cookies',
- 'cacheStorage',
- 'serviceWorkers',
- ];
-
- for (const key of optionKeys) {
+ for (const key of CLEAN_OPTION_KEYS) {
const r = result[key];
if (r?.success && r.count > 0) {
parts.push(`${r.count} ${t(`storageCleaner:options.${key}`)}`);
diff --git a/src/utils/textStatistics.ts b/src/utils/textStatistics.ts
index 4cb6f17..ece617b 100644
--- a/src/utils/textStatistics.ts
+++ b/src/utils/textStatistics.ts
@@ -1,5 +1,3 @@
-import { formatBytes } from './format';
-
/**
* 文本统计信息接口
*/
@@ -35,7 +33,7 @@ export function getTextStats(text: string): TextStats {
const segmenter = new Intl.Segmenter(undefined, { granularity: 'word' });
const segments = segmenter.segment(text);
for (const segment of segments) {
- // isWordLike 为 true 表示该片段是“类词”的(非空格、非标点)
+ // isWordLike 为 true 表示该片段是"类词"的(非空格、非标点)
if (segment.isWordLike) {
words++;
}
@@ -44,7 +42,7 @@ export function getTextStats(text: string): TextStats {
// 降级方案:如果不支持 Intl.Segmenter,使用正则匹配英文单词
// 但对中文支持较差
const englishWords = text.match(/\b\w+\b/g) || [];
- const chineseChars = text.match(/[\u4e00-\u9fa5]/g) || [];
+ const chineseChars = text.match(/[一-龥]/g) || [];
words = englishWords.length + chineseChars.length;
}
@@ -57,13 +55,3 @@ export function getTextStats(text: string): TextStats {
return { characters, words, lines, bytes };
}
-
-/**
- * 格式化字节大小显示(兼容旧接口,内部委托给 formatBytes)
- *
- * @param bytes 字节数
- * @returns 格式化后的字符串
- */
-export function formatByteSize(bytes: number): string {
- return formatBytes(bytes);
-}
diff --git a/src/utils/useContextMenuData.ts b/src/utils/useContextMenuData.ts
index 71d353e..8c2b1c1 100644
--- a/src/utils/useContextMenuData.ts
+++ b/src/utils/useContextMenuData.ts
@@ -1,4 +1,4 @@
-import { useCallback, useEffect } from 'react';
+import { useEffect } from 'react';
import { storageUtil } from '@/utils/chromeStorage';
import type { ContextMenuPendingData, PageType } from '@/types/storage';
@@ -23,44 +23,58 @@ export interface UseContextMenuDataOptions {
* 3. Hook 会自动从 storage 中读取并消费匹配的数据
*/
export function useContextMenuData({ featureKey, onData }: UseContextMenuDataOptions): void {
- const checkAndConsumeData = useCallback(async () => {
- try {
- const data = await storageUtil.get(STORAGE_KEY, undefined);
-
- if (!data) return;
-
- if (data.featureKey !== featureKey) return;
-
- if (Date.now() - data.timestamp > CONTEXT_MENU_DATA_EXPIRY_MS) {
- await storageUtil.remove(STORAGE_KEY);
- return;
- }
-
- await storageUtil.remove(STORAGE_KEY);
-
- onData(data.payload);
- } catch (error) {
- console.error('[useContextMenuData] 处理右键菜单数据失败:', error);
- }
- }, [featureKey, onData]);
-
useEffect(() => {
- checkAndConsumeData();
- }, [checkAndConsumeData]);
+ const checkAndConsumeData = async () => {
+ try {
+ const data = await storageUtil.get(STORAGE_KEY, undefined);
+
+ if (!data) return;
+
+ if (data.featureKey !== featureKey) return;
+
+ if (Date.now() - data.timestamp > CONTEXT_MENU_DATA_EXPIRY_MS) {
+ await storageUtil.remove(STORAGE_KEY);
+ return;
+ }
+
+ await storageUtil.remove(STORAGE_KEY);
+
+ onData(data.payload);
+ } catch (error) {
+ console.error('[useContextMenuData] 处理右键菜单数据失败:', error);
+ }
+ };
+
+ void checkAndConsumeData();
+ }, [featureKey, onData]);
useEffect(() => {
const handleStorageChange = (changes: { [key: string]: chrome.storage.StorageChange }) => {
if (changes[STORAGE_KEY]) {
const newData = changes[STORAGE_KEY].newValue as ContextMenuPendingData | null;
if (newData && newData.featureKey === featureKey) {
- checkAndConsumeData();
+ void (async () => {
+ try {
+ const data = await storageUtil.get(STORAGE_KEY, undefined);
+ if (!data) return;
+ if (data.featureKey !== featureKey) return;
+ if (Date.now() - data.timestamp > CONTEXT_MENU_DATA_EXPIRY_MS) {
+ await storageUtil.remove(STORAGE_KEY);
+ return;
+ }
+ await storageUtil.remove(STORAGE_KEY);
+ onData(data.payload);
+ } catch (error) {
+ console.error('[useContextMenuData] 处理右键菜单数据失败:', error);
+ }
+ })();
}
}
};
chrome.storage.onChanged.addListener(handleStorageChange);
return () => chrome.storage.onChanged.removeListener(handleStorageChange);
- }, [featureKey, checkAndConsumeData]);
+ }, [featureKey, onData]);
}
/**
diff --git a/vitest.setup.ts b/vitest.setup.ts
index 97bef60..a2bae5d 100644
--- a/vitest.setup.ts
+++ b/vitest.setup.ts
@@ -1,11 +1,10 @@
import '@testing-library/jest-dom';
import { afterEach, beforeEach, vi } from 'vitest';
import React from 'react';
+import zhMessages from './public/_locales/zh/messages.json';
-// Load actual zh translations for getMessage mock
-const zhMessages: Record =
- // eslint-disable-next-line @typescript-eslint/no-require-imports
- require('./public/_locales/zh/messages.json');
+// Type assertion to allow string indexing
+const zhMessagesMap = zhMessages as Record;
vi.mock('@/utils/chromeI18n', () => ({
useI18n: (ns?: string | string[]) => ({
@@ -16,13 +15,13 @@ vi.mock('@/utils/chromeI18n', () => ({
msgId = key.replace(':', '_').replace(/\./g, '_');
}
// Try direct key first
- if (zhMessages[msgId]) return zhMessages[msgId].message;
+ if (zhMessagesMap[msgId]) return zhMessagesMap[msgId].message;
// Try namespace prefix (using converted msgId)
if (ns) {
const namespaces = Array.isArray(ns) ? ns : [ns];
for (const n of namespaces) {
const candidate = `${n}_${msgId}`;
- if (zhMessages[candidate]) return zhMessages[candidate].message;
+ if (zhMessagesMap[candidate]) return zhMessagesMap[candidate].message;
}
}
return msgId;
@@ -33,7 +32,7 @@ vi.mock('@/utils/chromeI18n', () => ({
},
isLoaded: true,
}),
- getMessage: (msgId: string) => zhMessages[msgId]?.message ?? msgId,
+ getMessage: (msgId: string) => zhMessagesMap[msgId]?.message ?? msgId,
getLanguage: () => 'zh',
preloadNamespaces: vi.fn().mockResolvedValue(undefined),
}));
@@ -125,7 +124,7 @@ const webExtensionMock = {
get: vi.fn().mockResolvedValue({}),
sendMessage: vi.fn().mockResolvedValue(undefined),
create: vi.fn().mockResolvedValue({}),
- reload: vi.fn().mockResolvedValue(undefined), // ✅ 承接 MessageAction.RELOAD_TAB 刷新单元测试
+ reload: vi.fn().mockResolvedValue(undefined),
},
runtime: {
id: 'test-extension-id',
@@ -202,7 +201,6 @@ afterEach(() => {
vi.restoreAllMocks();
});
-// 全局 matchMedia 极客级环境模拟(ThemeModeProvider 依赖)
beforeEach(() => {
Object.defineProperty(window, 'matchMedia', {
writable: true,