From 0f3060dcf3fd844e9d613f238a07c4b25007e0b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=A8=E9=9C=96=E9=93=83?= Date: Fri, 19 Jun 2026 20:55:39 +0800 Subject: [PATCH] =?UTF-8?q?refactor(=E4=BB=A3=E7=A0=81=E9=87=8D=E5=A4=8D):?= =?UTF-8?q?=20=E6=8A=BD=E5=8F=96=E5=85=B1=E4=BA=AB=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E5=87=BD=E6=95=B0=E4=B8=8E=20UI=20=E7=BB=84=E4=BB=B6=EF=BC=8C?= =?UTF-8?q?=E6=B6=88=E9=99=A4=E5=A4=9A=E5=A4=84=E9=87=8D=E5=A4=8D=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- src/components/ErrorBoundary.tsx | 37 ++------ src/components/ErrorFallback.tsx | 94 +++++++++++++++++++ src/components/PageErrorBoundary.tsx | 42 ++------- src/lib/generators/__tests__/random.test.ts | 22 +++++ src/lib/generators/basic.ts | 15 +-- src/lib/generators/business.ts | 15 +-- src/lib/generators/personal.ts | 15 +-- src/lib/generators/random.ts | 7 ++ src/lib/generators/technical.ts | 15 +-- .../components/JsonConvertSection.tsx | 44 ++------- .../components/JsonFormatSection.tsx | 44 ++------- .../JsonTools/components/JsonResultPanel.tsx | 53 +++++++++++ .../useRightClickRestorer.ts | 21 +---- .../StorageCleaner/components/OptionItem.tsx | 13 +-- .../components/StorageCleanerConfirm.tsx | 14 +-- src/pages/StorageCleaner/constants.ts | 19 ++++ src/providers/RouterProvider.tsx | 23 +---- src/utils/__tests__/chromeStorage.test.ts | 8 +- src/utils/__tests__/restrictedUrls.test.ts | 49 ++++++++++ src/utils/__tests__/syncSnapshot.test.ts | 33 +++++++ src/utils/restrictedUrls.ts | 33 +++++++ src/utils/storageCleaner.ts | 36 +------ src/utils/syncSnapshot.ts | 21 +++++ src/utils/useStorageState.ts | 22 +---- 24 files changed, 386 insertions(+), 309 deletions(-) create mode 100644 src/components/ErrorFallback.tsx create mode 100644 src/lib/generators/__tests__/random.test.ts create mode 100644 src/lib/generators/random.ts create mode 100644 src/pages/JsonTools/components/JsonResultPanel.tsx create mode 100644 src/pages/StorageCleaner/constants.ts create mode 100644 src/utils/__tests__/restrictedUrls.test.ts create mode 100644 src/utils/__tests__/syncSnapshot.test.ts create mode 100644 src/utils/restrictedUrls.ts create mode 100644 src/utils/syncSnapshot.ts diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx index a3e84dc..81288ce 100644 --- a/src/components/ErrorBoundary.tsx +++ b/src/components/ErrorBoundary.tsx @@ -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 { render() { if (this.state.hasError) { return ( -
-
-
- -
-

糟糕,出了点问题

-

- 应用遇到了一些意外错误。您可以尝试刷新页面或重置应用。 -

- {this.state.error && ( -
-
-                  {this.state.error.toString()}
-                
-
- )} - -
-
+ ); } diff --git a/src/components/ErrorFallback.tsx b/src/components/ErrorFallback.tsx new file mode 100644 index 0000000..637dc81 --- /dev/null +++ b/src/components/ErrorFallback.tsx @@ -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 ( +
+
+
+ +
+ + {isApp ? ( +

{title}

+ ) : ( +

{title}

+ )} + +

+ {description} +

+ + {errorText && ( +
+
+              {errorText}
+            
+
+ )} + + +
+
+ ); +} diff --git a/src/components/PageErrorBoundary.tsx b/src/components/PageErrorBoundary.tsx index 56a835b..2c7f78a 100644 --- a/src/components/PageErrorBoundary.tsx +++ b/src/components/PageErrorBoundary.tsx @@ -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 { render() { if (this.state.hasError) { return ( -
-
-
- -
- -

该功能运行异常

-

- 该页面在加载或渲染时遇到了内部脚本错误。您可以尝试重试,或者通过导航菜单切换到其他工具。 -

- - {this.state.error && ( -
-
-                  {this.state.error.stack || this.state.error.toString()}
-                
-
- )} - - -
-
+ ); } diff --git a/src/lib/generators/__tests__/random.test.ts b/src/lib/generators/__tests__/random.test.ts new file mode 100644 index 0000000..655cf00 --- /dev/null +++ b/src/lib/generators/__tests__/random.test.ts @@ -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'); + }); +}); diff --git a/src/lib/generators/basic.ts b/src/lib/generators/basic.ts index eb41cc1..c7118ca 100644 --- a/src/lib/generators/basic.ts +++ b/src/lib/generators/basic.ts @@ -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(arr: T[]): T { - return arr[Math.floor(Math.random() * arr.length)]; -} +import { randomInt, randomPick } from './random'; /** * 随机整数生成器 diff --git a/src/lib/generators/business.ts b/src/lib/generators/business.ts index 95ff110..cc366ae 100644 --- a/src/lib/generators/business.ts +++ b/src/lib/generators/business.ts @@ -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(arr: T[]): T { - return arr[Math.floor(Math.random() * arr.length)]; -} +import { randomInt, randomPick } from './random'; /** * 格式化日期 diff --git a/src/lib/generators/personal.ts b/src/lib/generators/personal.ts index 5d11ed6..d7d2235 100644 --- a/src/lib/generators/personal.ts +++ b/src/lib/generators/personal.ts @@ -4,6 +4,7 @@ */ import type { GeneratorDefinition } from '@/types/testDataGenerator'; +import { randomInt, randomPick } from './random'; // 中文姓氏 const SURNAMES = [ @@ -162,20 +163,6 @@ const CITIES: Record = { 四川省: ['成都市', '绵阳市', '德阳市', '宜宾市'], }; -/** - * 生成随机整数 - */ -function randomInt(min: number, max: number): number { - return Math.floor(Math.random() * (max - min + 1)) + min; -} - -/** - * 从数组中随机选择 - */ -function randomPick(arr: T[]): T { - return arr[Math.floor(Math.random() * arr.length)]; -} - /** * 中文姓名生成器 */ diff --git a/src/lib/generators/random.ts b/src/lib/generators/random.ts new file mode 100644 index 0000000..2edcb00 --- /dev/null +++ b/src/lib/generators/random.ts @@ -0,0 +1,7 @@ +export function randomInt(min: number, max: number): number { + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +export function randomPick(arr: readonly T[]): T { + return arr[Math.floor(Math.random() * arr.length)]; +} diff --git a/src/lib/generators/technical.ts b/src/lib/generators/technical.ts index 3c9c3bc..e7da0ac 100644 --- a/src/lib/generators/technical.ts +++ b/src/lib/generators/technical.ts @@ -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(arr: T[]): T { - return arr[Math.floor(Math.random() * arr.length)]; -} +import { randomInt, randomPick } from './random'; /** * UUID 生成器 diff --git a/src/pages/JsonTools/components/JsonConvertSection.tsx b/src/pages/JsonTools/components/JsonConvertSection.tsx index 5e7c162..6f6fcc2 100644 --- a/src/pages/JsonTools/components/JsonConvertSection.tsx +++ b/src/pages/JsonTools/components/JsonConvertSection.tsx @@ -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 ? ( -
-
-
- - {labels.outputLabel} - - -
- - {'原始大小'}:{' '} - - {formatBytes(result.originalBytes)} - - - | - - {'格式化后大小'}:{' '} - - {formatBytes(result.outputBytes)} - - -
-
- - -
- -
- {result.output} -
-
+ ) : ( {error ? '请修正上方 JSON 的语法错误以开启实时流式格式化' : labels.emptyHint} diff --git a/src/pages/JsonTools/components/JsonFormatSection.tsx b/src/pages/JsonTools/components/JsonFormatSection.tsx index 0eef1ca..27d85af 100644 --- a/src/pages/JsonTools/components/JsonFormatSection.tsx +++ b/src/pages/JsonTools/components/JsonFormatSection.tsx @@ -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 ? ( -
- {/* 结果栏头部 */} -
-
- - {'格式化结果'} - - -
- - {'原始大小'}:{' '} - - {formatBytes(result.originalBytes)} - - - | - - {'格式化后大小'}:{' '} - - {formatBytes(result.formattedBytes)} - - -
-
- - -
- -
- {result.formatted} -
-
+ ) : ( {error ? '请修正上方 JSON 的语法错误以开启实时流式格式化' : '输入 JSON 后点击格式化'} diff --git a/src/pages/JsonTools/components/JsonResultPanel.tsx b/src/pages/JsonTools/components/JsonResultPanel.tsx new file mode 100644 index 0000000..a40a0a2 --- /dev/null +++ b/src/pages/JsonTools/components/JsonResultPanel.tsx @@ -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 ( +
+
+
+ + {title} + + +
+ + {'原始大小'}:{' '} + {formatBytes(originalBytes)} + + | + + {outputSizeLabel}:{' '} + {formatBytes(outputBytes)} + +
+
+ + +
+ +
+ {content} +
+
+ ); +} diff --git a/src/pages/RightClickRestorer/useRightClickRestorer.ts b/src/pages/RightClickRestorer/useRightClickRestorer.ts index 5e56912..b209126 100644 --- a/src/pages/RightClickRestorer/useRightClickRestorer.ts +++ b/src/pages/RightClickRestorer/useRightClickRestorer.ts @@ -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; } diff --git a/src/pages/StorageCleaner/components/OptionItem.tsx b/src/pages/StorageCleaner/components/OptionItem.tsx index dd4d6f3..745c506 100644 --- a/src/pages/StorageCleaner/components/OptionItem.tsx +++ b/src/pages/StorageCleaner/components/OptionItem.tsx @@ -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 = { - localStorage: 'Local Storage', - sessionStorage: 'Session Storage', - indexedDB: '站点存储', - cookies: 'Cookies', - cacheStorage: 'Cache Storage', - serviceWorkers: 'Service Workers', -}; - interface OptionItemProps extends React.HTMLAttributes { 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 (
= { - 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 ( !isOpen && onClose()}> diff --git a/src/pages/StorageCleaner/constants.ts b/src/pages/StorageCleaner/constants.ts new file mode 100644 index 0000000..98bdb28 --- /dev/null +++ b/src/pages/StorageCleaner/constants.ts @@ -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', +}; diff --git a/src/providers/RouterProvider.tsx b/src/providers/RouterProvider.tsx index e70ce13..2470ce3 100644 --- a/src/providers/RouterProvider.tsx +++ b/src/providers/RouterProvider.tsx @@ -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 = ( - 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', diff --git a/src/utils/__tests__/chromeStorage.test.ts b/src/utils/__tests__/chromeStorage.test.ts index cb4f0d5..1ada37e 100644 --- a/src/utils/__tests__/chromeStorage.test.ts +++ b/src/utils/__tests__/chromeStorage.test.ts @@ -77,15 +77,15 @@ describe('chromeStorage', () => { describe('get 类型签名', () => { it('无默认值时应推断为可选返回类型', () => { - const getWithoutDefault = () => storageUtil.get('app/theme'); - expectTypeOf>().toEqualTypeOf< + const _getWithoutDefault = () => storageUtil.get('app/theme'); + expectTypeOf>().toEqualTypeOf< Promise >(); }); it('有默认值时应推断为确定返回类型', () => { - const getWithDefault = () => storageUtil.get('app/theme', 'light'); - expectTypeOf>().toEqualTypeOf>(); + const _getWithDefault = () => storageUtil.get('app/theme', 'light'); + expectTypeOf>().toEqualTypeOf>(); }); }); diff --git a/src/utils/__tests__/restrictedUrls.test.ts b/src/utils/__tests__/restrictedUrls.test.ts new file mode 100644 index 0000000..73a115b --- /dev/null +++ b/src/utils/__tests__/restrictedUrls.test.ts @@ -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,

Hello

')).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:']), + ); + }); +}); diff --git a/src/utils/__tests__/syncSnapshot.test.ts b/src/utils/__tests__/syncSnapshot.test.ts new file mode 100644 index 0000000..b5dab66 --- /dev/null +++ b/src/utils/__tests__/syncSnapshot.test.ts @@ -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(); + }); +}); diff --git a/src/utils/restrictedUrls.ts b/src/utils/restrictedUrls.ts new file mode 100644 index 0000000..898f83d --- /dev/null +++ b/src/utils/restrictedUrls.ts @@ -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)); +} diff --git a/src/utils/storageCleaner.ts b/src/utils/storageCleaner.ts index 39d1f59..fecaf39 100644 --- a/src/utils/storageCleaner.ts +++ b/src/utils/storageCleaner.ts @@ -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 = { - 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 { try { const cookies = await chrome.cookies.getAll({ url }); diff --git a/src/utils/syncSnapshot.ts b/src/utils/syncSnapshot.ts new file mode 100644 index 0000000..62ce828 --- /dev/null +++ b/src/utils/syncSnapshot.ts @@ -0,0 +1,21 @@ +/** + * 从 localStorage 获取同步快照(用于消除异步加载产生的首屏闪烁) + */ +export function getSyncSnapshot( + 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; + } +} diff --git a/src/utils/useStorageState.ts b/src/utils/useStorageState.ts index 8713861..71d0241 100644 --- a/src/utils/useStorageState.ts +++ b/src/utils/useStorageState.ts @@ -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 = ( - 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 = ( key: K, defaultValue: StorageSchema[K],