refactor(代码重复): 抽取共享工具函数与 UI 组件,消除多处重复实现
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
* 随机整数生成器
|
||||
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
* 格式化日期
|
||||
|
||||
@@ -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)];
|
||||
}
|
||||
|
||||
/**
|
||||
* 中文姓名生成器
|
||||
*/
|
||||
|
||||
@@ -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)];
|
||||
}
|
||||
@@ -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()}>
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
@@ -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:']),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,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],
|
||||
|
||||
Reference in New Issue
Block a user