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 { Component, ErrorInfo, ReactNode } from 'react';
|
||||||
import { AlertCircle, RefreshCw } from 'lucide-react';
|
import { ErrorFallback } from '@/components/ErrorFallback';
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -38,32 +37,14 @@ class ErrorBoundary extends Component<Props, State> {
|
|||||||
render() {
|
render() {
|
||||||
if (this.state.hasError) {
|
if (this.state.hasError) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center mt-16 mx-auto max-w-md">
|
<ErrorFallback
|
||||||
<div className="p-6 text-center rounded-xl border border-destructive/20 bg-destructive/5 shadow-sm">
|
variant="app"
|
||||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10 text-destructive mx-auto mb-4">
|
title="糟糕,出了点问题"
|
||||||
<AlertCircle className="h-8 w-8" />
|
description="应用遇到了一些意外错误。您可以尝试刷新页面或重置应用。"
|
||||||
</div>
|
error={this.state.error}
|
||||||
<h2 className="text-xl font-extrabold text-destructive mb-2">糟糕,出了点问题</h2>
|
actionLabel="刷新应用"
|
||||||
<p className="text-sm text-muted-foreground mb-6">
|
onAction={this.handleReset}
|
||||||
应用遇到了一些意外错误。您可以尝试刷新页面或重置应用。
|
/>
|
||||||
</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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 { Component, ErrorInfo, ReactNode } from 'react';
|
||||||
import { AlertCircle, RefreshCw } from 'lucide-react';
|
import { ErrorFallback } from '@/components/ErrorFallback';
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -39,36 +38,15 @@ class PageErrorBoundary extends Component<Props, State> {
|
|||||||
render() {
|
render() {
|
||||||
if (this.state.hasError) {
|
if (this.state.hasError) {
|
||||||
return (
|
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">
|
<ErrorFallback
|
||||||
<div className="p-6 text-center rounded-xl border border-destructive/20 bg-destructive/5 max-w-md w-full shadow-sm">
|
variant="page"
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10 text-destructive mx-auto mb-4">
|
title="该功能运行异常"
|
||||||
<AlertCircle className="h-6 w-6" />
|
description="该页面在加载或渲染时遇到了内部脚本错误。您可以尝试重试,或者通过导航菜单切换到其他工具。"
|
||||||
</div>
|
error={this.state.error}
|
||||||
|
actionLabel="重新尝试"
|
||||||
<h3 className="text-base font-semibold text-foreground mb-1.5">该功能运行异常</h3>
|
onAction={this.handleRetry}
|
||||||
<p className="text-xs text-muted-foreground mb-5">
|
showStack
|
||||||
该页面在加载或渲染时遇到了内部脚本错误。您可以尝试重试,或者通过导航菜单切换到其他工具。
|
/>
|
||||||
</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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
import type { GeneratorDefinition } from '@/types/testDataGenerator';
|
||||||
|
import { randomInt, randomPick } from './random';
|
||||||
/**
|
|
||||||
* 生成随机整数
|
|
||||||
*/
|
|
||||||
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)];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 随机整数生成器
|
* 随机整数生成器
|
||||||
|
|||||||
@@ -4,20 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { GeneratorDefinition } from '@/types/testDataGenerator';
|
import type { GeneratorDefinition } from '@/types/testDataGenerator';
|
||||||
|
import { randomInt, randomPick } from './random';
|
||||||
/**
|
|
||||||
* 生成随机整数
|
|
||||||
*/
|
|
||||||
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)];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 格式化日期
|
* 格式化日期
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { GeneratorDefinition } from '@/types/testDataGenerator';
|
import type { GeneratorDefinition } from '@/types/testDataGenerator';
|
||||||
|
import { randomInt, randomPick } from './random';
|
||||||
|
|
||||||
// 中文姓氏
|
// 中文姓氏
|
||||||
const SURNAMES = [
|
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';
|
import type { GeneratorDefinition } from '@/types/testDataGenerator';
|
||||||
|
import { randomInt, randomPick } from './random';
|
||||||
/**
|
|
||||||
* 生成随机整数
|
|
||||||
*/
|
|
||||||
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)];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* UUID 生成器
|
* UUID 生成器
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { formatBytes } from '@/utils/format';
|
|
||||||
import { CopyButton } from '@/components/CopyButton';
|
|
||||||
import EmptyPlaceholder from '@/components/EmptyPlaceholder';
|
import EmptyPlaceholder from '@/components/EmptyPlaceholder';
|
||||||
import TextInputArea from '@/components/TextInputArea';
|
import TextInputArea from '@/components/TextInputArea';
|
||||||
|
import JsonResultPanel from './JsonResultPanel';
|
||||||
import { validateJson } from '@/utils/jsonFormatter';
|
import { validateJson } from '@/utils/jsonFormatter';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { ConvertFunction, ConvertResult } from '../types';
|
import type { ConvertFunction, ConvertResult } from '../types';
|
||||||
@@ -98,40 +97,13 @@ export default function JsonConvertSection({
|
|||||||
|
|
||||||
{/* Result display */}
|
{/* Result display */}
|
||||||
{result && result.output ? (
|
{result && result.output ? (
|
||||||
<div className="relative rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden">
|
<JsonResultPanel
|
||||||
<div className="flex h-9 items-center justify-between px-4 border-b border-border bg-muted/50 select-none">
|
title={labels.outputLabel}
|
||||||
<div className="flex gap-4 items-center">
|
content={result.output}
|
||||||
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/90">
|
originalBytes={result.originalBytes}
|
||||||
{labels.outputLabel}
|
outputBytes={result.outputBytes}
|
||||||
</span>
|
maxHeight="380px"
|
||||||
|
|
||||||
<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>
|
|
||||||
) : (
|
) : (
|
||||||
<EmptyPlaceholder>
|
<EmptyPlaceholder>
|
||||||
{error ? '请修正上方 JSON 的语法错误以开启实时流式格式化' : labels.emptyHint}
|
{error ? '请修正上方 JSON 的语法错误以开启实时流式格式化' : labels.emptyHint}
|
||||||
|
|||||||
@@ -5,11 +5,10 @@ import {
|
|||||||
type JsonFormatResult,
|
type JsonFormatResult,
|
||||||
validateJson,
|
validateJson,
|
||||||
} from '@/utils/jsonFormatter';
|
} from '@/utils/jsonFormatter';
|
||||||
import { formatBytes } from '@/utils/format';
|
|
||||||
import { CopyButton } from '@/components/CopyButton';
|
|
||||||
import EmptyPlaceholder from '@/components/EmptyPlaceholder';
|
import EmptyPlaceholder from '@/components/EmptyPlaceholder';
|
||||||
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
||||||
import TextInputArea from '@/components/TextInputArea';
|
import TextInputArea from '@/components/TextInputArea';
|
||||||
|
import JsonResultPanel from './JsonResultPanel';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
|
||||||
@@ -113,41 +112,12 @@ export default function JsonFormatSection() {
|
|||||||
|
|
||||||
{/* 格式化结果流面板展示 */}
|
{/* 格式化结果流面板展示 */}
|
||||||
{result && result.formatted ? (
|
{result && result.formatted ? (
|
||||||
<div className="relative rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden">
|
<JsonResultPanel
|
||||||
{/* 结果栏头部 */}
|
title="格式化结果"
|
||||||
<div className="flex h-9 items-center justify-between px-4 border-b border-border bg-muted/50 select-none">
|
content={result.formatted}
|
||||||
<div className="flex gap-4 items-center">
|
originalBytes={result.originalBytes}
|
||||||
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/90">
|
outputBytes={result.formattedBytes}
|
||||||
{'格式化结果'}
|
|
||||||
</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>
|
|
||||||
) : (
|
) : (
|
||||||
<EmptyPlaceholder>
|
<EmptyPlaceholder>
|
||||||
{error ? '请修正上方 JSON 的语法错误以开启实时流式格式化' : '输入 JSON 后点击格式化'}
|
{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 { useEffect, useState } from 'react';
|
||||||
import { MessageAction, sendMessageToContent } from '@/utils/messages';
|
import { MessageAction, sendMessageToContent } from '@/utils/messages';
|
||||||
|
import { isUnsupportedPageUrl } from '@/utils/restrictedUrls';
|
||||||
import type { RestorerStatus } from './constants';
|
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 {
|
function deriveStatus(isUnsupported: boolean, isUnlocked: boolean): RestorerStatus {
|
||||||
if (isUnsupported) return 'unsupported';
|
if (isUnsupported) return 'unsupported';
|
||||||
return isUnlocked ? 'unlocked' : 'locked';
|
return isUnlocked ? 'unlocked' : 'locked';
|
||||||
@@ -46,7 +29,7 @@ export function useRightClickRestorer(): UseRightClickRestorerReturn {
|
|||||||
|
|
||||||
setDomain(url ? new URL(url).hostname : '');
|
setDomain(url ? new URL(url).hostname : '');
|
||||||
|
|
||||||
if (isUnsupportedPage(url)) {
|
if (isUnsupportedPageUrl(url)) {
|
||||||
setIsUnsupported(true);
|
setIsUnsupported(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,17 +2,9 @@ import React from 'react';
|
|||||||
import { formatBytes } from '@/utils/format';
|
import { formatBytes } from '@/utils/format';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { StorageSizeInfo } from '../useStorageCleaner';
|
import type { StorageSizeInfo } from '../useStorageCleaner';
|
||||||
|
import { OPTION_LABELS } from '../constants';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
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> {
|
interface OptionItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
labelKey: string;
|
labelKey: string;
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
@@ -30,7 +22,8 @@ export default function OptionItem({
|
|||||||
}: OptionItemProps) {
|
}: OptionItemProps) {
|
||||||
const sizeValue = sizeInfo?.value;
|
const sizeValue = sizeInfo?.value;
|
||||||
const isCount = sizeInfo?.displayType === 'count';
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { AlertTriangle } from 'lucide-react';
|
import { AlertTriangle } from 'lucide-react';
|
||||||
import type { StorageCleanerOptions } from '@/types/storage';
|
import type { StorageCleanerOptions } from '@/types/storage';
|
||||||
|
import { OPTION_LABELS } from '../constants';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
export interface StorageCleanerConfirmProps {
|
export interface StorageCleanerConfirmProps {
|
||||||
@@ -19,15 +20,6 @@ export interface StorageCleanerConfirmProps {
|
|||||||
options: StorageCleanerOptions;
|
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({
|
export function StorageCleanerConfirm({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -36,7 +28,9 @@ export function StorageCleanerConfirm({
|
|||||||
}: StorageCleanerConfirmProps) {
|
}: StorageCleanerConfirmProps) {
|
||||||
const selectedOptions = Object.entries(options)
|
const selectedOptions = Object.entries(options)
|
||||||
.filter(([_, value]) => value)
|
.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 (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
<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,
|
getDefaultVisibleFeatureKeys,
|
||||||
} from '@/config/features';
|
} from '@/config/features';
|
||||||
import { CONTEXT_MENU_DATA_EXPIRY_MS, saveContextMenuData } from '@/utils/useContextMenuData';
|
import { CONTEXT_MENU_DATA_EXPIRY_MS, saveContextMenuData } from '@/utils/useContextMenuData';
|
||||||
|
import { getSyncSnapshot } from '@/utils/syncSnapshot';
|
||||||
|
|
||||||
const MAX_RECENTLY_USED = 3;
|
const MAX_RECENTLY_USED = 3;
|
||||||
|
|
||||||
@@ -69,28 +70,6 @@ interface RouterProviderProps {
|
|||||||
pageOrderKey?: keyof StorageSchema;
|
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({
|
export function RouterProvider({
|
||||||
children,
|
children,
|
||||||
defaultRoute = 'dashboard',
|
defaultRoute = 'dashboard',
|
||||||
|
|||||||
@@ -77,15 +77,15 @@ describe('chromeStorage', () => {
|
|||||||
|
|
||||||
describe('get 类型签名', () => {
|
describe('get 类型签名', () => {
|
||||||
it('无默认值时应推断为可选返回类型', () => {
|
it('无默认值时应推断为可选返回类型', () => {
|
||||||
const getWithoutDefault = () => storageUtil.get('app/theme');
|
const _getWithoutDefault = () => storageUtil.get('app/theme');
|
||||||
expectTypeOf<ReturnType<typeof getWithoutDefault>>().toEqualTypeOf<
|
expectTypeOf<ReturnType<typeof _getWithoutDefault>>().toEqualTypeOf<
|
||||||
Promise<string | undefined>
|
Promise<string | undefined>
|
||||||
>();
|
>();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('有默认值时应推断为确定返回类型', () => {
|
it('有默认值时应推断为确定返回类型', () => {
|
||||||
const getWithDefault = () => storageUtil.get('app/theme', 'light');
|
const _getWithDefault = () => storageUtil.get('app/theme', 'light');
|
||||||
expectTypeOf<ReturnType<typeof getWithDefault>>().toEqualTypeOf<Promise<string>>();
|
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';
|
import type { CleaningResult, StorageCleanerOptions, StorageCleanResult } from '@/types/storage';
|
||||||
|
import { CLEAN_OPTION_KEYS, OPTION_LABELS } from '@/pages/StorageCleaner/constants';
|
||||||
const RESTRICTED_PROTOCOLS = [
|
export { isRestrictedUrl } from '@/utils/restrictedUrls';
|
||||||
'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',
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function getCurrentTab() {
|
export async function getCurrentTab() {
|
||||||
// For popup pages, we need to get the active tab from the browser window that triggered the popup.
|
// 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;
|
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> {
|
export async function getCookieSize(url: string): Promise<number> {
|
||||||
try {
|
try {
|
||||||
const cookies = await chrome.cookies.getAll({ url });
|
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 { useEffect, useRef, useState } from 'react';
|
||||||
import { storageUtil } from '@/utils/chromeStorage';
|
import { storageUtil } from '@/utils/chromeStorage';
|
||||||
|
import { getSyncSnapshot } from '@/utils/syncSnapshot';
|
||||||
import type { StorageSchema } from '@/types/storage';
|
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>(
|
export const useStorageState = <K extends keyof StorageSchema>(
|
||||||
key: K,
|
key: K,
|
||||||
defaultValue: StorageSchema[K],
|
defaultValue: StorageSchema[K],
|
||||||
|
|||||||
Reference in New Issue
Block a user