refactor(代码重复): 抽取共享工具函数与 UI 组件,消除多处重复实现

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
雨霖铃
2026-06-19 20:55:39 +08:00
parent 47d74f999e
commit 0f3060dcf3
24 changed files with 386 additions and 309 deletions
+9 -28
View File
@@ -1,6 +1,5 @@
import { Component, ErrorInfo, ReactNode } from 'react';
import { AlertCircle, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { ErrorFallback } from '@/components/ErrorFallback';
interface Props {
children: ReactNode;
@@ -38,32 +37,14 @@ class ErrorBoundary extends Component<Props, State> {
render() {
if (this.state.hasError) {
return (
<div className="flex flex-col items-center justify-center mt-16 mx-auto max-w-md">
<div className="p-6 text-center rounded-xl border border-destructive/20 bg-destructive/5 shadow-sm">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10 text-destructive mx-auto mb-4">
<AlertCircle className="h-8 w-8" />
</div>
<h2 className="text-xl font-extrabold text-destructive mb-2"></h2>
<p className="text-sm text-muted-foreground mb-6">
</p>
{this.state.error && (
<div className="mb-6 p-4 rounded-lg bg-zinc-950 dark:bg-zinc-900 text-left max-h-[200px] overflow-auto border border-border/40">
<pre className="font-mono text-xs whitespace-pre-wrap break-all text-zinc-200 selection:bg-zinc-700">
{this.state.error.toString()}
</pre>
</div>
)}
<Button
variant="destructive"
onClick={this.handleReset}
className="rounded-lg font-bold shadow-sm"
>
<RefreshCw className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
<ErrorFallback
variant="app"
title="糟糕,出了点问题"
description="应用遇到了一些意外错误。您可以尝试刷新页面或重置应用。"
error={this.state.error}
actionLabel="刷新应用"
onAction={this.handleReset}
/>
);
}
+94
View File
@@ -0,0 +1,94 @@
import { AlertCircle, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
export interface ErrorFallbackProps {
title: string;
description: string;
error: Error | null;
actionLabel: string;
onAction: () => void;
variant?: 'app' | 'page';
showStack?: boolean;
className?: string;
}
export function ErrorFallback({
title,
description,
error,
actionLabel,
onAction,
variant = 'page',
showStack = false,
className,
}: ErrorFallbackProps) {
const isApp = variant === 'app';
const errorText = error ? (showStack ? error.stack || error.toString() : error.toString()) : null;
return (
<div
className={cn(
'flex flex-col items-center justify-center',
isApp
? 'mt-16 mx-auto max-w-md'
: 'flex-1 p-6 min-h-[300px] animate-in fade-in zoom-in-95 duration-200',
className,
)}
>
<div
className={cn(
'p-6 text-center rounded-xl border border-destructive/20 bg-destructive/5 shadow-sm',
!isApp && 'max-w-md w-full',
)}
>
<div
className={cn(
'flex items-center justify-center rounded-full bg-destructive/10 text-destructive mx-auto mb-4',
isApp ? 'h-16 w-16' : 'h-12 w-12',
)}
>
<AlertCircle className={isApp ? 'h-8 w-8' : 'h-6 w-6'} />
</div>
{isApp ? (
<h2 className="text-xl font-extrabold text-destructive mb-2">{title}</h2>
) : (
<h3 className="text-base font-semibold text-foreground mb-1.5">{title}</h3>
)}
<p className={cn('text-muted-foreground', isApp ? 'text-sm mb-6' : 'text-xs mb-5')}>
{description}
</p>
{errorText && (
<div
className={cn(
'rounded-lg bg-zinc-950 dark:bg-zinc-900 text-left border border-border/40',
isApp ? 'mb-6 p-4 max-h-[200px] overflow-auto' : 'mb-5 p-3 max-h-40 overflow-y-auto',
)}
>
<pre
className={cn(
'font-mono whitespace-pre-wrap break-all text-zinc-200 selection:bg-zinc-700',
isApp ? 'text-xs' : 'text-[11px] leading-relaxed',
)}
>
{errorText}
</pre>
</div>
)}
<Button
variant="destructive"
size={isApp ? 'default' : 'sm'}
onClick={onAction}
className={cn(isApp ? 'rounded-lg font-bold shadow-sm' : 'font-medium shadow-sm')}
>
<RefreshCw className={isApp ? 'mr-2 h-4 w-4' : 'mr-1.5 h-3.5 w-3.5'} />
{actionLabel}
</Button>
</div>
</div>
);
}
+10 -32
View File
@@ -1,6 +1,5 @@
import { Component, ErrorInfo, ReactNode } from 'react';
import { AlertCircle, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { ErrorFallback } from '@/components/ErrorFallback';
interface Props {
children: ReactNode;
@@ -39,36 +38,15 @@ class PageErrorBoundary extends Component<Props, State> {
render() {
if (this.state.hasError) {
return (
<div className="flex flex-col items-center justify-center flex-1 p-6 min-h-[300px] animate-in fade-in zoom-in-95 duration-200">
<div className="p-6 text-center rounded-xl border border-destructive/20 bg-destructive/5 max-w-md w-full shadow-sm">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10 text-destructive mx-auto mb-4">
<AlertCircle className="h-6 w-6" />
</div>
<h3 className="text-base font-semibold text-foreground mb-1.5"></h3>
<p className="text-xs text-muted-foreground mb-5">
</p>
{this.state.error && (
<div className="mb-5 p-3 rounded-lg bg-zinc-950 dark:bg-zinc-900 text-left max-h-40 overflow-y-auto border border-border/40">
<pre className="font-mono text-[11px] leading-relaxed whitespace-pre-wrap break-all text-zinc-200 selection:bg-zinc-700">
{this.state.error.stack || this.state.error.toString()}
</pre>
</div>
)}
<Button
variant="destructive"
size="sm"
onClick={this.handleRetry}
className="font-medium shadow-sm"
>
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
</Button>
</div>
</div>
<ErrorFallback
variant="page"
title="该功能运行异常"
description="该页面在加载或渲染时遇到了内部脚本错误。您可以尝试重试,或者通过导航菜单切换到其他工具。"
error={this.state.error}
actionLabel="重新尝试"
onAction={this.handleRetry}
showStack
/>
);
}
@@ -0,0 +1,22 @@
import { randomInt, randomPick } from '@/lib/generators/random';
describe('generators/random', () => {
it('randomInt 应返回闭区间内的整数', () => {
vi.spyOn(Math, 'random').mockReturnValue(0.5);
expect(randomInt(1, 10)).toBe(6);
expect(randomInt(5, 5)).toBe(5);
});
it('randomPick 应对单元素数组返回该元素', () => {
vi.spyOn(Math, 'random').mockReturnValue(0);
expect(randomPick(['only'])).toBe('only');
});
it('randomPick 应返回数组中的元素', () => {
vi.spyOn(Math, 'random').mockReturnValue(0.99);
expect(randomPick(['a', 'b', 'c'])).toBe('c');
});
});
+1 -14
View File
@@ -4,20 +4,7 @@
*/
import type { GeneratorDefinition } from '@/types/testDataGenerator';
/**
* 生成随机整数
*/
function randomInt(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
/**
* 从数组中随机选择
*/
function randomPick<T>(arr: T[]): T {
return arr[Math.floor(Math.random() * arr.length)];
}
import { randomInt, randomPick } from './random';
/**
* 随机整数生成器
+1 -14
View File
@@ -4,20 +4,7 @@
*/
import type { GeneratorDefinition } from '@/types/testDataGenerator';
/**
* 生成随机整数
*/
function randomInt(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
/**
* 从数组中随机选择
*/
function randomPick<T>(arr: T[]): T {
return arr[Math.floor(Math.random() * arr.length)];
}
import { randomInt, randomPick } from './random';
/**
* 格式化日期
+1 -14
View File
@@ -4,6 +4,7 @@
*/
import type { GeneratorDefinition } from '@/types/testDataGenerator';
import { randomInt, randomPick } from './random';
// 中文姓氏
const SURNAMES = [
@@ -162,20 +163,6 @@ const CITIES: Record<string, string[]> = {
: ['成都市', '绵阳市', '德阳市', '宜宾市'],
};
/**
* 生成随机整数
*/
function randomInt(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
/**
* 从数组中随机选择
*/
function randomPick<T>(arr: T[]): T {
return arr[Math.floor(Math.random() * arr.length)];
}
/**
* 中文姓名生成器
*/
+7
View File
@@ -0,0 +1,7 @@
export function randomInt(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
export function randomPick<T>(arr: readonly T[]): T {
return arr[Math.floor(Math.random() * arr.length)];
}
+1 -14
View File
@@ -4,20 +4,7 @@
*/
import type { GeneratorDefinition } from '@/types/testDataGenerator';
/**
* 生成随机整数
*/
function randomInt(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
/**
* 从数组中随机选择
*/
function randomPick<T>(arr: T[]): T {
return arr[Math.floor(Math.random() * arr.length)];
}
import { randomInt, randomPick } from './random';
/**
* UUID 生成器
@@ -1,8 +1,7 @@
import React, { useEffect, useMemo, useState } from 'react';
import { formatBytes } from '@/utils/format';
import { CopyButton } from '@/components/CopyButton';
import EmptyPlaceholder from '@/components/EmptyPlaceholder';
import TextInputArea from '@/components/TextInputArea';
import JsonResultPanel from './JsonResultPanel';
import { validateJson } from '@/utils/jsonFormatter';
import { cn } from '@/lib/utils';
import type { ConvertFunction, ConvertResult } from '../types';
@@ -98,40 +97,13 @@ export default function JsonConvertSection({
{/* Result display */}
{result && result.output ? (
<div className="relative rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden">
<div className="flex h-9 items-center justify-between px-4 border-b border-border bg-muted/50 select-none">
<div className="flex gap-4 items-center">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/90">
{labels.outputLabel}
</span>
<div className="hidden sm:flex gap-3 items-center font-mono text-[10px] text-muted-foreground/70 tabular-nums">
<span>
{'原始大小'}:{' '}
<span className="font-semibold text-foreground/80">
{formatBytes(result.originalBytes)}
</span>
</span>
<span className="text-border/60">|</span>
<span>
{'格式化后大小'}:{' '}
<span className="font-semibold text-foreground/80">
{formatBytes(result.outputBytes)}
</span>
</span>
</div>
</div>
<CopyButton
text={result.output}
className="h-6 w-6 rounded-md border text-muted-foreground"
/>
</div>
<div className="p-4 font-mono text-xs text-foreground/90 whitespace-pre-wrap break-all max-h-[380px] overflow-y-auto leading-relaxed select-text">
{result.output}
</div>
</div>
<JsonResultPanel
title={labels.outputLabel}
content={result.output}
originalBytes={result.originalBytes}
outputBytes={result.outputBytes}
maxHeight="380px"
/>
) : (
<EmptyPlaceholder>
{error ? '请修正上方 JSON 的语法错误以开启实时流式格式化' : labels.emptyHint}
@@ -5,11 +5,10 @@ import {
type JsonFormatResult,
validateJson,
} from '@/utils/jsonFormatter';
import { formatBytes } from '@/utils/format';
import { CopyButton } from '@/components/CopyButton';
import EmptyPlaceholder from '@/components/EmptyPlaceholder';
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
import TextInputArea from '@/components/TextInputArea';
import JsonResultPanel from './JsonResultPanel';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
@@ -113,41 +112,12 @@ export default function JsonFormatSection() {
{/* 格式化结果流面板展示 */}
{result && result.formatted ? (
<div className="relative rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden">
{/* 结果栏头部 */}
<div className="flex h-9 items-center justify-between px-4 border-b border-border bg-muted/50 select-none">
<div className="flex gap-4 items-center">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/90">
{'格式化结果'}
</span>
<div className="hidden sm:flex gap-3 items-center font-mono text-[10px] text-muted-foreground/70 tabular-nums">
<span>
{'原始大小'}:{' '}
<span className="font-semibold text-foreground/80">
{formatBytes(result.originalBytes)}
</span>
</span>
<span className="text-border/60">|</span>
<span>
{'格式化后大小'}:{' '}
<span className="font-semibold text-foreground/80">
{formatBytes(result.formattedBytes)}
</span>
</span>
</div>
</div>
<CopyButton
text={result.formatted}
className="h-6 w-6 rounded-md border text-muted-foreground"
/>
</div>
<div className="p-4 font-mono text-xs text-foreground/90 whitespace-pre-wrap break-all max-h-[420px] overflow-y-auto leading-relaxed select-text">
{result.formatted}
</div>
</div>
<JsonResultPanel
title="格式化结果"
content={result.formatted}
originalBytes={result.originalBytes}
outputBytes={result.formattedBytes}
/>
) : (
<EmptyPlaceholder>
{error ? '请修正上方 JSON 的语法错误以开启实时流式格式化' : '输入 JSON 后点击格式化'}
@@ -0,0 +1,53 @@
import { formatBytes } from '@/utils/format';
import { CopyButton } from '@/components/CopyButton';
export interface JsonResultPanelProps {
title: string;
content: string;
originalBytes: number;
outputBytes: number;
outputSizeLabel?: string;
maxHeight?: string;
}
export default function JsonResultPanel({
title,
content,
originalBytes,
outputBytes,
outputSizeLabel = '格式化后大小',
maxHeight = '420px',
}: JsonResultPanelProps) {
return (
<div className="relative rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden">
<div className="flex h-9 items-center justify-between px-4 border-b border-border bg-muted/50 select-none">
<div className="flex gap-4 items-center">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/90">
{title}
</span>
<div className="hidden sm:flex gap-3 items-center font-mono text-[10px] text-muted-foreground/70 tabular-nums">
<span>
{'原始大小'}:{' '}
<span className="font-semibold text-foreground/80">{formatBytes(originalBytes)}</span>
</span>
<span className="text-border/60">|</span>
<span>
{outputSizeLabel}:{' '}
<span className="font-semibold text-foreground/80">{formatBytes(outputBytes)}</span>
</span>
</div>
</div>
<CopyButton text={content} className="h-6 w-6 rounded-md border text-muted-foreground" />
</div>
<div
className="p-4 font-mono text-xs text-foreground/90 whitespace-pre-wrap break-all overflow-y-auto leading-relaxed select-text"
style={{ maxHeight }}
>
{content}
</div>
</div>
);
}
@@ -1,25 +1,8 @@
import { useEffect, useState } from 'react';
import { MessageAction, sendMessageToContent } from '@/utils/messages';
import { isUnsupportedPageUrl } from '@/utils/restrictedUrls';
import type { RestorerStatus } from './constants';
const UNSUPPORTED_PROTOCOLS = new Set([
'chrome:',
'chrome-extension:',
'about:',
'edge:',
'brave:',
]);
function isUnsupportedPage(url: string | undefined): boolean {
if (!url) return true;
try {
const protocol = new URL(url).protocol;
return UNSUPPORTED_PROTOCOLS.has(protocol);
} catch {
return true;
}
}
function deriveStatus(isUnsupported: boolean, isUnlocked: boolean): RestorerStatus {
if (isUnsupported) return 'unsupported';
return isUnlocked ? 'unlocked' : 'locked';
@@ -46,7 +29,7 @@ export function useRightClickRestorer(): UseRightClickRestorerReturn {
setDomain(url ? new URL(url).hostname : '');
if (isUnsupportedPage(url)) {
if (isUnsupportedPageUrl(url)) {
setIsUnsupported(true);
return;
}
@@ -2,17 +2,9 @@ import React from 'react';
import { formatBytes } from '@/utils/format';
import { cn } from '@/lib/utils';
import type { StorageSizeInfo } from '../useStorageCleaner';
import { OPTION_LABELS } from '../constants';
import { Checkbox } from '@/components/ui/checkbox';
const OPTION_LABELS: Record<string, string> = {
localStorage: 'Local Storage',
sessionStorage: 'Session Storage',
indexedDB: '站点存储',
cookies: 'Cookies',
cacheStorage: 'Cache Storage',
serviceWorkers: 'Service Workers',
};
interface OptionItemProps extends React.HTMLAttributes<HTMLDivElement> {
labelKey: string;
checked: boolean;
@@ -30,7 +22,8 @@ export default function OptionItem({
}: OptionItemProps) {
const sizeValue = sizeInfo?.value;
const isCount = sizeInfo?.displayType === 'count';
const label = OPTION_LABELS[labelKey] || labelKey;
const label =
labelKey in OPTION_LABELS ? OPTION_LABELS[labelKey as keyof typeof OPTION_LABELS] : labelKey;
return (
<div
@@ -10,6 +10,7 @@ import {
import { Badge } from '@/components/ui/badge';
import { AlertTriangle } from 'lucide-react';
import type { StorageCleanerOptions } from '@/types/storage';
import { OPTION_LABELS } from '../constants';
import { cn } from '@/lib/utils';
export interface StorageCleanerConfirmProps {
@@ -19,15 +20,6 @@ export interface StorageCleanerConfirmProps {
options: StorageCleanerOptions;
}
const OPTION_LABELS: Record<string, string> = {
localStorage: 'Local Storage',
sessionStorage: 'Session Storage',
indexedDB: '站点存储',
cookies: 'Cookies',
cacheStorage: 'Cache Storage',
serviceWorkers: 'Service Workers',
};
export function StorageCleanerConfirm({
open,
onClose,
@@ -36,7 +28,9 @@ export function StorageCleanerConfirm({
}: StorageCleanerConfirmProps) {
const selectedOptions = Object.entries(options)
.filter(([_, value]) => value)
.map(([key, _]) => OPTION_LABELS[key] || key);
.map(([key]) =>
key in OPTION_LABELS ? OPTION_LABELS[key as keyof typeof OPTION_LABELS] : key,
);
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
+19
View File
@@ -0,0 +1,19 @@
import type { StorageCleanerOptions } from '@/types/storage';
export const CLEAN_OPTION_KEYS = [
'localStorage',
'sessionStorage',
'indexedDB',
'cookies',
'cacheStorage',
'serviceWorkers',
] as const satisfies readonly (keyof StorageCleanerOptions)[];
export const OPTION_LABELS: Record<(typeof CLEAN_OPTION_KEYS)[number], string> = {
localStorage: 'Local Storage',
sessionStorage: 'Session Storage',
indexedDB: '站点存储',
cookies: 'Cookies',
cacheStorage: 'Cache Storage',
serviceWorkers: 'Service Workers',
};
+1 -22
View File
@@ -16,6 +16,7 @@ import {
getDefaultVisibleFeatureKeys,
} from '@/config/features';
import { CONTEXT_MENU_DATA_EXPIRY_MS, saveContextMenuData } from '@/utils/useContextMenuData';
import { getSyncSnapshot } from '@/utils/syncSnapshot';
const MAX_RECENTLY_USED = 3;
@@ -69,28 +70,6 @@ interface RouterProviderProps {
pageOrderKey?: keyof StorageSchema;
}
/**
* 同步从 localStorage 获取存储快照(首屏 0 闪烁核心防线)
*/
const getSyncSnapshot = <T,>(
key: string,
defaultValue: T,
validator?: (val: unknown) => val is T,
): T => {
try {
const val = localStorage.getItem(`snapshot/${key}`);
if (!val) return defaultValue;
const parsed = JSON.parse(val) as unknown;
if (validator) {
return validator(parsed) ? parsed : defaultValue;
}
return (parsed as T) ?? defaultValue;
} catch (error) {
console.error('[Router Snapshot Error] Failed to read sync cache:', error);
return defaultValue;
}
};
export function RouterProvider({
children,
defaultRoute = 'dashboard',
+4 -4
View File
@@ -77,15 +77,15 @@ describe('chromeStorage', () => {
describe('get 类型签名', () => {
it('无默认值时应推断为可选返回类型', () => {
const getWithoutDefault = () => storageUtil.get('app/theme');
expectTypeOf<ReturnType<typeof getWithoutDefault>>().toEqualTypeOf<
const _getWithoutDefault = () => storageUtil.get('app/theme');
expectTypeOf<ReturnType<typeof _getWithoutDefault>>().toEqualTypeOf<
Promise<string | undefined>
>();
});
it('有默认值时应推断为确定返回类型', () => {
const getWithDefault = () => storageUtil.get('app/theme', 'light');
expectTypeOf<ReturnType<typeof getWithDefault>>().toEqualTypeOf<Promise<string>>();
const _getWithDefault = () => storageUtil.get('app/theme', 'light');
expectTypeOf<ReturnType<typeof _getWithDefault>>().toEqualTypeOf<Promise<string>>();
});
});
@@ -0,0 +1,49 @@
import {
isRestrictedUrl,
isUnsupportedPageUrl,
RESTRICTED_PROTOCOLS,
} from '@/utils/restrictedUrls';
describe('restrictedUrls', () => {
describe('isRestrictedUrl', () => {
it('应识别受限协议页面', () => {
expect(isRestrictedUrl('chrome://settings')).toBe(true);
expect(isRestrictedUrl('chrome-extension://abc123/background.html')).toBe(true);
expect(isRestrictedUrl('about:blank')).toBe(true);
expect(isRestrictedUrl('edge://settings')).toBe(true);
expect(isRestrictedUrl('brave://settings')).toBe(true);
expect(isRestrictedUrl('view-source:https://example.com')).toBe(true);
expect(isRestrictedUrl('file:///path/to/file')).toBe(true);
expect(isRestrictedUrl('data:text/html,<h1>Hello</h1>')).toBe(true);
});
it('应允许普通 http/https 页面', () => {
expect(isRestrictedUrl('http://example.com')).toBe(false);
expect(isRestrictedUrl('https://example.com')).toBe(false);
});
it('空 URL 应视为受限', () => {
expect(isRestrictedUrl(undefined)).toBe(true);
expect(isRestrictedUrl('')).toBe(true);
});
});
describe('isUnsupportedPageUrl', () => {
it('应通过 protocol 精确匹配识别受限页面', () => {
expect(isUnsupportedPageUrl('chrome://newtab/')).toBe(true);
expect(isUnsupportedPageUrl('brave://settings/')).toBe(true);
expect(isUnsupportedPageUrl('https://example.com')).toBe(false);
});
it('无效 URL 应视为不支持', () => {
expect(isUnsupportedPageUrl(undefined)).toBe(true);
expect(isUnsupportedPageUrl('not-a-url')).toBe(true);
});
});
it('RESTRICTED_PROTOCOLS 应包含 storage cleaner 与右键恢复所需协议', () => {
expect(RESTRICTED_PROTOCOLS).toEqual(
expect.arrayContaining(['brave:', 'view-source:', 'file:', 'data:']),
);
});
});
+33
View File
@@ -0,0 +1,33 @@
import { getSyncSnapshot } from '@/utils/syncSnapshot';
describe('getSyncSnapshot', () => {
beforeEach(() => {
localStorage.clear();
vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it('无快照时应返回默认值', () => {
expect(getSyncSnapshot('app/test-key', 'default')).toBe('default');
});
it('应读取并解析合法 JSON 快照', () => {
localStorage.setItem('snapshot/app/test-key', JSON.stringify('saved'));
expect(getSyncSnapshot('app/test-key', 'default')).toBe('saved');
});
it('validator 失败时应回退到默认值', () => {
localStorage.setItem('snapshot/app/test-key', JSON.stringify('invalid'));
const isNumber = (val: unknown): val is number => typeof val === 'number';
expect(getSyncSnapshot('app/test-key', 0, isNumber)).toBe(0);
});
it('非法 JSON 时应回退到默认值并记录错误', () => {
localStorage.setItem('snapshot/app/test-key', '{invalid');
expect(getSyncSnapshot('app/test-key', 'fallback')).toBe('fallback');
expect(console.error).toHaveBeenCalled();
});
});
+33
View File
@@ -0,0 +1,33 @@
/** 浏览器内部/受限协议(含尾部冒号,用于 protocol 匹配) */
export const RESTRICTED_PROTOCOLS = [
'chrome:',
'chrome-extension:',
'about:',
'edge:',
'brave:',
'view-source:',
'file:',
'data:',
] as const;
export function getUrlProtocol(url: string): string | null {
try {
return new URL(url).protocol;
} catch {
return null;
}
}
/** 用于 content script / 右键恢复等(protocol 精确匹配) */
export function isUnsupportedPageUrl(url: string | undefined): boolean {
if (!url) return true;
const protocol = getUrlProtocol(url);
if (!protocol) return true;
return (RESTRICTED_PROTOCOLS as readonly string[]).includes(protocol);
}
/** 用于 storage cleaner tab 检测(前缀匹配,兼容无 protocol 的场景) */
export function isRestrictedUrl(url?: string): boolean {
if (!url) return true;
return RESTRICTED_PROTOCOLS.some((p) => url.startsWith(p));
}
+2 -34
View File
@@ -1,33 +1,6 @@
import type { CleaningResult, StorageCleanerOptions, StorageCleanResult } from '@/types/storage';
const RESTRICTED_PROTOCOLS = [
'chrome:',
'chrome-extension:',
'about:',
'edge:',
'view-source:',
'file:',
'data:',
] as const;
/** 清理选项的 key 列表(用于遍历结果) */
const CLEAN_OPTION_KEYS: (keyof StorageCleanerOptions)[] = [
'localStorage',
'sessionStorage',
'indexedDB',
'cookies',
'cacheStorage',
'serviceWorkers',
];
const OPTION_LABELS: Record<string, string> = {
localStorage: 'Local Storage',
sessionStorage: 'Session Storage',
indexedDB: '站点存储',
cookies: 'Cookies',
cacheStorage: 'Cache Storage',
serviceWorkers: 'Service Workers',
};
import { CLEAN_OPTION_KEYS, OPTION_LABELS } from '@/pages/StorageCleaner/constants';
export { isRestrictedUrl } from '@/utils/restrictedUrls';
export async function getCurrentTab() {
// For popup pages, we need to get the active tab from the browser window that triggered the popup.
@@ -52,11 +25,6 @@ export async function getCurrentTab() {
return fallbackTab;
}
export function isRestrictedUrl(url?: string): boolean {
if (!url) return true;
return RESTRICTED_PROTOCOLS.some((p) => url.startsWith(p));
}
export async function getCookieSize(url: string): Promise<number> {
try {
const cookies = await chrome.cookies.getAll({ url });
+21
View File
@@ -0,0 +1,21 @@
/**
* 从 localStorage 获取同步快照(用于消除异步加载产生的首屏闪烁)
*/
export function getSyncSnapshot<T>(
key: string,
defaultValue: T,
validator?: (val: unknown) => val is T,
): T {
try {
const val = localStorage.getItem(`snapshot/${key}`);
if (!val) return defaultValue;
const parsed = JSON.parse(val) as unknown;
if (validator) {
return validator(parsed) ? parsed : defaultValue;
}
return (parsed as T) ?? defaultValue;
} catch (error) {
console.error(`[SyncSnapshot] Failed to read snapshot/${key}:`, error);
return defaultValue;
}
}
+1 -21
View File
@@ -1,28 +1,8 @@
import { useEffect, useRef, useState } from 'react';
import { storageUtil } from '@/utils/chromeStorage';
import { getSyncSnapshot } from '@/utils/syncSnapshot';
import type { StorageSchema } from '@/types/storage';
/**
* 从 localStorage 获取同步快照(用于消除异步加载产生的首屏闪烁)
*/
const getSyncSnapshot = <T>(
key: string,
defaultValue: T,
validator?: (val: unknown) => val is T,
): T => {
try {
const val = localStorage.getItem(`snapshot/${key}`);
if (!val) return defaultValue;
const parsed = JSON.parse(val) as unknown;
if (validator) {
return validator(parsed) ? parsed : defaultValue;
}
return (parsed as T) ?? defaultValue;
} catch {
return defaultValue;
}
};
export const useStorageState = <K extends keyof StorageSchema>(
key: K,
defaultValue: StorageSchema[K],