Develop (#74)
* feat: 生成成功改为 toast 提示 * refactor: 数据预览改为显示示例数据,简化组件逻辑 * fix: 非必填字段预览显示 null * fix: 修复空值率与必填状态的逻辑 * fix: 切换为非必填时默认空值率 100% * feat: 字段编辑弹窗添加「完成」按钮 去除关闭按钮,添加取消按钮 * refactor: 删除未使用的 defaultNullRate 功能 * feat: 优化规则管理功能 * fix: 更新 features 测试断言以匹配新增的 testDataGenerator * refactor: 将 formatFileSize 重命名为 formatBytes 并移至通用工具模块 * refactor: 使用 Label 组件替换原生 label 元素,并在 DecodeResultPaper 中引入 Input 组件 * refactor: 规范化页面组件目录结构 * refactor(JsonTools): 规范化页面组件目录结构 * refactor(StorageCleaner): 规范化页面组件目录结构 * refactor(Timestamp): 规范化页面组件目录结构 * refactor(i18n): 移除 chrome.i18n 国际化,统一使用中文硬编码 * refactor(AppRoot): 移除 RouterProvider 组件,直接渲染子组件 * fix(RouterProvider): 将 goBack 重命名为 goHome,修复跨窗同步 merge 不一致 * fix(ThemeModeProvider): 修复主题快照同步与首屏闪烁问题 * refactor: 将 UI 文案改为中文并优化相关逻辑 * refactor: 清理代码注释并优化组件结构 * feat(Dashboard): 重构仪表板功能组件和逻辑 * refactor: 更新功能键名称并移除 README 文件 * refactor: 重构 TopBar 组件并更新项目结构 * feat(TopBar): 添加搜索功能组件并优化结构 * refactor: 统一使用 Button 组件替换原有 button 元素 * feat: 添加 EmptyPlaceholder 组件并更新相关页面 * feat(TextStatistics): 引入 StatCard 组件以优化统计信息展示 * feat(Timestamp): 重构时间戳转换功能,新增 ConverterForm 组件 * feat(Jwt): 重构 JWT 组件,新增常量和结果视图 * refactor(RightClickRestorer): 用状态配置表简化页面 UI 结构 * fix(business): 修复 skewedRandom 函数中的数学计算错误 * refactor(ImageUploader): 移除预览 URL 相关逻辑并简化粘贴事件处理 * feat(TestDataGenerator): 更新规则编辑提示和警告显示逻辑 * refactor(CopyButton.test): 移除冗余测试用例 * refactor(RouterProvider, chromeStorage, useContextMenuData): 更新存储获取逻辑以移除默认值参数 * refactor(代码重复): 抽取共享工具函数与 UI 组件,消除多处重复实现 * fix(RouterProvider): 修复初始加载状态逻辑,确保在加载前不覆盖 chrome.storage * feat(TopBar): 添加搜索框快捷键提示功能及相关测试 * refactor(StorageCleaner): 移动 isRestrictedUrl 函数到独立模块并更新相关引用 * fix(StorageCleaner): 修复 IndexedDB 清理竞态、部分成功计数与刷新后状态同步 * fix: 修复路由初始化竞态、存储状态静默覆盖与存储清理误删 * refactor: 统一中文文案并简化组件结构,提升代码可读性 * refactor: 更新 Worker 消息类型以支持生成任务 ID * fix: 修复 ruleStorage 写入失败处理与 CopyButton 成功态样式
This commit is contained in:
+14
-2
@@ -7,6 +7,9 @@
|
||||
| 文件 | 用途 |
|
||||
| ------------------------ | --------------------------------------------------------------------------------------------------- |
|
||||
| `chromeStorage.ts` | Chrome Storage API 封装:类型安全的 `StorageUtils` 类,提供 `get/set/remove` 方法 |
|
||||
| `syncSnapshot.ts` | 通用 `localStorage` 快照读取(`snapshot/{key}`),用于 Router 与 `useStorageState` 首屏防闪烁 |
|
||||
| `themeSnapshot.ts` | 主题专用快照读写与 `document.documentElement` class 切换 |
|
||||
| `restrictedUrls.ts` | 受限 URL 检测(`chrome://`、`about:` 等),供 Storage Cleaner 等模块复用 |
|
||||
| `chromeTabs.ts` | Chrome Tabs API 封装:获取活动标签页、获取域名、在新标签页打开扩展页面 |
|
||||
| `clipboard.ts` | 剪贴板操作:`copyTextToClipboard`(文本)、`copyImageToClipboard`(图片) |
|
||||
| `messages.ts` | 扩展消息通信:基于 `@webext-core/messaging`,定义 `MessageAction` 枚举和 `ProtocolMap` 类型安全映射 |
|
||||
@@ -17,11 +20,10 @@
|
||||
| `jsonToYaml.ts` | JSON→YAML 转换 |
|
||||
| `jsonToToml.ts` | JSON→TOML 转换 |
|
||||
| `qrCodeParser.ts` | 二维码解析:基于 `qr-scanner` 库从文件中解析二维码 |
|
||||
| `storageCleaner.ts` | 存储清理:获取当前标签页、检测受限 URL、计算 Cookie/Storage 大小、清理操作 |
|
||||
| `storageCleaner.ts` | 存储清理:获取当前标签页、计算 Cookie/Storage 大小、清理操作 |
|
||||
| `textStatistics.ts` | 文本统计:使用 `Intl.Segmenter` 计算字符数/单词数/行数/字节大小 |
|
||||
| `format.ts` | 通用格式化:`formatBytes` 将字节转为可读字符串(B/KB/MB/GB/TB) |
|
||||
| `dayjs.ts` | Day.js 初始化:扩展 UTC、Timezone、RelativeTime 插件,加载中文本地化 |
|
||||
| `chromeI18n.ts` | Chrome `chrome.i18n` 包装:提供 `getMessage` 和兼容 React 使用的 `useI18n` Hook |
|
||||
| `ruleStorage.ts` | 测试数据生成器规则存储:基于 `localStorage` 的 CRUD、搜索、导入/导出和数量限制 |
|
||||
| `dataExporter.ts` | 测试数据导出:JSON/CSV 转换、文件下载和复制到剪贴板 |
|
||||
| `rightClickInjection.ts` | 右键恢复注入脚本:在页面上下文恢复 contextmenu/copy/paste 等事件默认行为 |
|
||||
@@ -34,6 +36,15 @@
|
||||
| `useContextMenuData.ts` | 右键菜单数据 Hook:从 storage 读取待处理数据,匹配 featureKey 后消费并触发回调 |
|
||||
| `useDebounce.ts` | 防抖 Hook:对值进行延迟更新,避免频繁触发 |
|
||||
|
||||
### useStorageState 初始化防覆盖
|
||||
|
||||
`useStorageState` 在挂载时从 storage 异步加载。写入 storage 需满足以下任一条件:
|
||||
|
||||
- **`loadSucceededRef`**:storage 读取成功
|
||||
- **`userModifiedRef`**:用户通过 setter 主动修改过值
|
||||
|
||||
若 storage 读取失败且用户未修改,不会将默认值写回 storage,避免静默覆盖已有数据。
|
||||
|
||||
## 使用约定
|
||||
|
||||
- 工具函数使用**命名导出**(`export function xxx()`)
|
||||
@@ -41,3 +52,4 @@
|
||||
- Hook 使用 `use` 前缀命名,定义返回值接口类型
|
||||
- 存储操作使用 `chromeStorage.ts` 的 `storageUtil` 封装,不要直接调用 `chrome.storage`
|
||||
- 消息通信使用 `messages.ts` 的 `sendMessage`/`onMessage`,不要使用原生 `chrome.runtime.sendMessage`
|
||||
- 需要首屏快照的新持久化状态,优先复用 `syncSnapshot.ts` 或参考 `themeSnapshot.ts` 模式
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
isSupportedImageType,
|
||||
isSupportedImageExtension,
|
||||
extractMimeTypeFromDataUri,
|
||||
formatFileSize,
|
||||
base64ToBytes,
|
||||
sniffMimeFromBytes,
|
||||
base64ToBlob,
|
||||
@@ -185,26 +184,6 @@ describe('extractMimeTypeFromDataUri', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatFileSize', () => {
|
||||
it('应该格式化字节', () => {
|
||||
expect(formatFileSize(0)).toBe('0 B');
|
||||
expect(formatFileSize(512)).toBe('512 B');
|
||||
});
|
||||
|
||||
it('应该格式化 KB', () => {
|
||||
expect(formatFileSize(1024)).toBe('1.0 KB');
|
||||
expect(formatFileSize(1536)).toBe('1.5 KB');
|
||||
});
|
||||
|
||||
it('应该格式化 MB', () => {
|
||||
expect(formatFileSize(1048576)).toBe('1.00 MB');
|
||||
});
|
||||
|
||||
it('应该格式化 GB', () => {
|
||||
expect(formatFileSize(1073741824)).toBe('1.00 GB');
|
||||
});
|
||||
});
|
||||
|
||||
describe('base64ToBytes', () => {
|
||||
it('应该解码标准 ASCII Base64 为字节序列', () => {
|
||||
const bytes = base64ToBytes('aGVsbG8=');
|
||||
@@ -337,7 +316,7 @@ describe('base64ToBlob', () => {
|
||||
});
|
||||
|
||||
it('应该对非法 Base64 抛出 Invalid Base64 string', () => {
|
||||
expect(() => base64ToBlob('这不是 base64!')).toThrow('Invalid Base64 string');
|
||||
expect(() => base64ToBlob('这不是 base64!')).toThrow('无效的 Base64 字符串');
|
||||
});
|
||||
|
||||
it('应该返回原始 Base64(已去除 data URI 前缀)', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, expectTypeOf } from 'vitest';
|
||||
import { storageUtil } from '@/utils/chromeStorage';
|
||||
|
||||
describe('chromeStorage', () => {
|
||||
@@ -75,6 +75,20 @@ describe('chromeStorage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('get 类型签名', () => {
|
||||
it('无默认值时应推断为可选返回类型', () => {
|
||||
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>>();
|
||||
});
|
||||
});
|
||||
|
||||
describe('set', () => {
|
||||
it('应该成功设置字符串值', async () => {
|
||||
(chrome.storage.local.set as any).mockResolvedValue(undefined);
|
||||
|
||||
@@ -50,7 +50,7 @@ describe('contextMenu', () => {
|
||||
);
|
||||
expect(imageMenus).toHaveLength(1);
|
||||
expect(imageMenus[0].id).toBe('qrCode-image');
|
||||
expect(imageMenus[0].title).toBe('🖼️ 解析图片二维码');
|
||||
expect(imageMenus[0].title).toBe('解析图片二维码');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -73,7 +73,7 @@ describe('contextMenu', () => {
|
||||
|
||||
expect(chrome.contextMenus.create).toHaveBeenCalledWith({
|
||||
id: 'jwt',
|
||||
title: '🔑 解析 JWT',
|
||||
title: '解析 JWT',
|
||||
contexts: ['selection'],
|
||||
parentId: 'testing-tools-parent',
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { diffJson } from '../../pages/JsonTools/diffEngine';
|
||||
import { diffJson } from '../diffEngine';
|
||||
|
||||
describe('diffJson', () => {
|
||||
it('should detect added property', () => {
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
clearAllIndexedDBs,
|
||||
INDEXED_DB_CLEAR_STORE_TIMEOUT_MS,
|
||||
INDEXED_DB_DELETE_FALLBACK_DELAY_MS,
|
||||
INDEXED_DB_DELETE_TIMEOUT_MS,
|
||||
} from '@/utils/indexedDbCleaner';
|
||||
|
||||
type DeleteDatabaseBehavior = 'success' | 'blocked' | 'timeout' | 'error';
|
||||
type ClearStoreBehavior = 'success' | 'hang';
|
||||
|
||||
function createDeleteDatabaseMock(behavior: DeleteDatabaseBehavior) {
|
||||
return vi.fn(() => {
|
||||
const request = {} as IDBOpenDBRequest;
|
||||
if (behavior === 'timeout') {
|
||||
return request;
|
||||
}
|
||||
queueMicrotask(() => {
|
||||
if (behavior === 'blocked') {
|
||||
request.onblocked?.({} as IDBVersionChangeEvent);
|
||||
} else if (behavior === 'success') {
|
||||
request.onsuccess?.({} as Event);
|
||||
} else if (behavior === 'error') {
|
||||
request.onerror?.({} as Event);
|
||||
}
|
||||
});
|
||||
return request;
|
||||
});
|
||||
}
|
||||
|
||||
function createDeleteDatabaseMockWithLateSuccess(lateAfterMs: number) {
|
||||
return vi.fn(() => {
|
||||
const request = {} as IDBOpenDBRequest;
|
||||
setTimeout(() => {
|
||||
request.onsuccess?.({} as Event);
|
||||
}, lateAfterMs);
|
||||
return request;
|
||||
});
|
||||
}
|
||||
|
||||
function createOpenMock(options: {
|
||||
storeNames: string[];
|
||||
onClearStore?: () => void;
|
||||
onTransactionComplete?: () => void;
|
||||
onDbClose?: () => void;
|
||||
clearStoreBehavior?: ClearStoreBehavior;
|
||||
deferTransactionComplete?: boolean;
|
||||
syncTransactionComplete?: boolean;
|
||||
hangOpen?: boolean;
|
||||
}) {
|
||||
const {
|
||||
storeNames,
|
||||
onClearStore,
|
||||
onTransactionComplete,
|
||||
onDbClose,
|
||||
clearStoreBehavior = 'success',
|
||||
deferTransactionComplete = false,
|
||||
syncTransactionComplete = false,
|
||||
hangOpen = false,
|
||||
} = options;
|
||||
|
||||
return vi.fn(() => {
|
||||
const request = {} as IDBOpenDBRequest;
|
||||
if (hangOpen) {
|
||||
return request;
|
||||
}
|
||||
const db = {
|
||||
objectStoreNames: storeNames,
|
||||
transaction: vi.fn(() => {
|
||||
const tx = {
|
||||
oncomplete: null as ((event: Event) => void) | null,
|
||||
onerror: null as ((event: Event) => void) | null,
|
||||
onabort: null as ((event: Event) => void) | null,
|
||||
abort: vi.fn(),
|
||||
objectStore: vi.fn(() => ({
|
||||
clear: () => {
|
||||
const clearRequest = {} as IDBRequest<void>;
|
||||
onClearStore?.();
|
||||
if (clearStoreBehavior === 'success') {
|
||||
queueMicrotask(() => {
|
||||
clearRequest.onsuccess?.({} as Event);
|
||||
if (syncTransactionComplete) {
|
||||
onTransactionComplete?.();
|
||||
tx.oncomplete?.({} as Event);
|
||||
return;
|
||||
}
|
||||
if (deferTransactionComplete) {
|
||||
return;
|
||||
}
|
||||
setTimeout(() => {
|
||||
onTransactionComplete?.();
|
||||
tx.oncomplete?.({} as Event);
|
||||
}, 0);
|
||||
});
|
||||
}
|
||||
return clearRequest;
|
||||
},
|
||||
})),
|
||||
};
|
||||
return tx;
|
||||
}),
|
||||
close: vi.fn(() => {
|
||||
onDbClose?.();
|
||||
}),
|
||||
};
|
||||
queueMicrotask(() => {
|
||||
Object.defineProperty(request, 'result', { value: db });
|
||||
request.onsuccess?.({} as Event);
|
||||
});
|
||||
return request;
|
||||
});
|
||||
}
|
||||
|
||||
type DeleteBehaviorConfig = DeleteDatabaseBehavior | Record<string, DeleteDatabaseBehavior>;
|
||||
|
||||
function createIndexedDBMock(options: {
|
||||
databases: Array<{ name: string }>;
|
||||
deleteBehavior: DeleteBehaviorConfig;
|
||||
open?: ReturnType<typeof createOpenMock>;
|
||||
}) {
|
||||
const resolveDeleteBehavior = (dbName: string): DeleteDatabaseBehavior => {
|
||||
if (typeof options.deleteBehavior === 'string') {
|
||||
return options.deleteBehavior;
|
||||
}
|
||||
return options.deleteBehavior[dbName] ?? 'success';
|
||||
};
|
||||
|
||||
return {
|
||||
databases: vi.fn().mockResolvedValue(options.databases),
|
||||
deleteDatabase: vi.fn((dbName: string) =>
|
||||
createDeleteDatabaseMock(resolveDeleteBehavior(dbName))(),
|
||||
),
|
||||
...(options.open ? { open: options.open } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function withIndexedDBMock<T>(indexedDBMock: object, run: () => Promise<T>): Promise<T> {
|
||||
const originalIndexedDB = globalThis.indexedDB;
|
||||
Object.defineProperty(globalThis, 'indexedDB', { configurable: true, value: indexedDBMock });
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'indexedDB', {
|
||||
configurable: true,
|
||||
value: originalIndexedDB,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe('indexedDbCleaner', () => {
|
||||
const runClear = () =>
|
||||
clearAllIndexedDBs(
|
||||
INDEXED_DB_DELETE_TIMEOUT_MS,
|
||||
INDEXED_DB_CLEAR_STORE_TIMEOUT_MS,
|
||||
INDEXED_DB_DELETE_FALLBACK_DELAY_MS,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should delete IndexedDB successfully when deleteDatabase completes', async () => {
|
||||
const indexedDBMock = createIndexedDBMock({
|
||||
databases: [{ name: 'app-db' }],
|
||||
deleteBehavior: 'success',
|
||||
});
|
||||
|
||||
const result = await withIndexedDBMock(indexedDBMock, () =>
|
||||
clearAllIndexedDBs(
|
||||
INDEXED_DB_DELETE_TIMEOUT_MS,
|
||||
INDEXED_DB_CLEAR_STORE_TIMEOUT_MS,
|
||||
INDEXED_DB_DELETE_FALLBACK_DELAY_MS,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ count: 1, errors: [] });
|
||||
expect(indexedDBMock.deleteDatabase).toHaveBeenCalledWith('app-db');
|
||||
});
|
||||
|
||||
it('should report IndexedDB blocked deletions with a user-facing hint', async () => {
|
||||
const indexedDBMock = createIndexedDBMock({
|
||||
databases: [{ name: 'blocked-db' }],
|
||||
deleteBehavior: 'blocked',
|
||||
});
|
||||
|
||||
const result = await withIndexedDBMock(indexedDBMock, () =>
|
||||
clearAllIndexedDBs(
|
||||
INDEXED_DB_DELETE_TIMEOUT_MS,
|
||||
INDEXED_DB_CLEAR_STORE_TIMEOUT_MS,
|
||||
INDEXED_DB_DELETE_FALLBACK_DELAY_MS,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
count: 0,
|
||||
errors: ['页面仍占用 IndexedDB(blocked-db),请刷新后重试或关闭占用该页面的连接'],
|
||||
});
|
||||
expect(indexedDBMock.deleteDatabase).toHaveBeenCalledWith('blocked-db');
|
||||
});
|
||||
|
||||
it('should clear object stores when IndexedDB deletion is blocked', async () => {
|
||||
const clearStore = vi.fn();
|
||||
const indexedDBMock = createIndexedDBMock({
|
||||
databases: [{ name: 'ImageCacheDB' }],
|
||||
deleteBehavior: 'blocked',
|
||||
open: createOpenMock({ storeNames: ['images'], onClearStore: clearStore }),
|
||||
});
|
||||
|
||||
const result = await withIndexedDBMock(indexedDBMock, runClear);
|
||||
|
||||
expect(result).toEqual({ count: 1, errors: [] });
|
||||
expect(clearStore).toHaveBeenCalledTimes(1);
|
||||
expect(indexedDBMock.deleteDatabase).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should clear object stores when IndexedDB deletion times out', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
const clearStore = vi.fn();
|
||||
const openMock = createOpenMock({ storeNames: ['images'], onClearStore: clearStore });
|
||||
const hangOpenRequest = {} as IDBOpenDBRequest;
|
||||
openMock.mockImplementationOnce(() => hangOpenRequest);
|
||||
const indexedDBMock = createIndexedDBMock({
|
||||
databases: [{ name: 'ImageCacheDB' }],
|
||||
deleteBehavior: 'timeout',
|
||||
open: openMock,
|
||||
});
|
||||
|
||||
const result = await withIndexedDBMock(indexedDBMock, async () => {
|
||||
const resultPromise = runClear();
|
||||
await vi.advanceTimersByTimeAsync(
|
||||
INDEXED_DB_CLEAR_STORE_TIMEOUT_MS +
|
||||
INDEXED_DB_DELETE_TIMEOUT_MS +
|
||||
INDEXED_DB_DELETE_FALLBACK_DELAY_MS,
|
||||
);
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
return resultPromise;
|
||||
});
|
||||
|
||||
expect(result).toEqual({ count: 1, errors: [] });
|
||||
expect(clearStore).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should wait for transaction complete before closing db', async () => {
|
||||
const events: string[] = [];
|
||||
const indexedDBMock = createIndexedDBMock({
|
||||
databases: [{ name: 'ImageCacheDB' }],
|
||||
deleteBehavior: 'blocked',
|
||||
open: createOpenMock({
|
||||
storeNames: ['images'],
|
||||
onTransactionComplete: () => events.push('transaction-complete'),
|
||||
onDbClose: () => events.push('db-close'),
|
||||
}),
|
||||
});
|
||||
|
||||
await withIndexedDBMock(indexedDBMock, runClear);
|
||||
|
||||
expect(events).toEqual(['transaction-complete', 'db-close']);
|
||||
});
|
||||
|
||||
it('should not hang when transaction completes before clear promises settle', async () => {
|
||||
const indexedDBMock = createIndexedDBMock({
|
||||
databases: [{ name: 'ImageCacheDB' }],
|
||||
deleteBehavior: 'blocked',
|
||||
open: createOpenMock({
|
||||
storeNames: ['images'],
|
||||
syncTransactionComplete: true,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await withIndexedDBMock(indexedDBMock, runClear);
|
||||
|
||||
expect(result).toEqual({ count: 1, errors: [] });
|
||||
});
|
||||
|
||||
it('should timeout when opening IndexedDB for fallback never completes', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
const indexedDBMock = createIndexedDBMock({
|
||||
databases: [{ name: 'ImageCacheDB' }],
|
||||
deleteBehavior: 'blocked',
|
||||
open: createOpenMock({ storeNames: ['images'], hangOpen: true }),
|
||||
});
|
||||
|
||||
const resultPromise = withIndexedDBMock(indexedDBMock, runClear);
|
||||
await vi.advanceTimersByTimeAsync(
|
||||
INDEXED_DB_CLEAR_STORE_TIMEOUT_MS +
|
||||
INDEXED_DB_DELETE_FALLBACK_DELAY_MS +
|
||||
INDEXED_DB_CLEAR_STORE_TIMEOUT_MS,
|
||||
);
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result).toEqual({
|
||||
count: 0,
|
||||
errors: ['无法打开 IndexedDB(ImageCacheDB)进行清空,请刷新后重试'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should ignore late onsuccess after delete timeout', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
const clearStore = vi.fn();
|
||||
const openMock = createOpenMock({ storeNames: ['images'], onClearStore: clearStore });
|
||||
const hangOpenRequest = {} as IDBOpenDBRequest;
|
||||
openMock.mockImplementationOnce(() => hangOpenRequest);
|
||||
const indexedDBMock = {
|
||||
databases: vi.fn().mockResolvedValue([{ name: 'ImageCacheDB' }]),
|
||||
deleteDatabase: createDeleteDatabaseMockWithLateSuccess(INDEXED_DB_DELETE_TIMEOUT_MS + 1000),
|
||||
open: openMock,
|
||||
};
|
||||
|
||||
const result = await withIndexedDBMock(indexedDBMock, async () => {
|
||||
const resultPromise = runClear();
|
||||
await vi.advanceTimersByTimeAsync(
|
||||
INDEXED_DB_CLEAR_STORE_TIMEOUT_MS +
|
||||
INDEXED_DB_DELETE_TIMEOUT_MS +
|
||||
INDEXED_DB_DELETE_FALLBACK_DELAY_MS,
|
||||
);
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
return resultPromise;
|
||||
});
|
||||
|
||||
expect(result).toEqual({ count: 1, errors: [] });
|
||||
expect(clearStore).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(result).toEqual({ count: 1, errors: [] });
|
||||
});
|
||||
|
||||
it('should report the store name when fallback clearing times out', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
const openMock = createOpenMock({ storeNames: ['images'], clearStoreBehavior: 'hang' });
|
||||
const hangOpenRequest = {} as IDBOpenDBRequest;
|
||||
openMock.mockImplementationOnce(() => hangOpenRequest);
|
||||
const indexedDBMock = createIndexedDBMock({
|
||||
databases: [{ name: 'ImageCacheDB' }],
|
||||
deleteBehavior: 'blocked',
|
||||
open: openMock,
|
||||
});
|
||||
|
||||
const result = await withIndexedDBMock(indexedDBMock, async () => {
|
||||
const resultPromise = runClear();
|
||||
await vi.advanceTimersByTimeAsync(
|
||||
INDEXED_DB_CLEAR_STORE_TIMEOUT_MS +
|
||||
INDEXED_DB_DELETE_FALLBACK_DELAY_MS +
|
||||
INDEXED_DB_CLEAR_STORE_TIMEOUT_MS,
|
||||
);
|
||||
return resultPromise;
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
count: 0,
|
||||
errors: ['清空 IndexedDB 超时(ImageCacheDB/images),请刷新后重试'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should delete all IndexedDB databases when every delete succeeds', async () => {
|
||||
const indexedDBMock = createIndexedDBMock({
|
||||
databases: [{ name: 'db-a' }, { name: 'db-b' }, { name: 'db-c' }],
|
||||
deleteBehavior: 'success',
|
||||
});
|
||||
|
||||
const result = await withIndexedDBMock(indexedDBMock, () =>
|
||||
clearAllIndexedDBs(
|
||||
INDEXED_DB_DELETE_TIMEOUT_MS,
|
||||
INDEXED_DB_CLEAR_STORE_TIMEOUT_MS,
|
||||
INDEXED_DB_DELETE_FALLBACK_DELAY_MS,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ count: 3, errors: [] });
|
||||
expect(indexedDBMock.deleteDatabase).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should succeed when some deletions fallback to clear object stores', async () => {
|
||||
const clearStore = vi.fn();
|
||||
const indexedDBMock = createIndexedDBMock({
|
||||
databases: [{ name: 'db-a' }, { name: 'db-b' }, { name: 'db-c' }],
|
||||
deleteBehavior: { 'db-a': 'success', 'db-b': 'success', 'db-c': 'blocked' },
|
||||
open: createOpenMock({ storeNames: ['data'], onClearStore: clearStore }),
|
||||
});
|
||||
|
||||
const result = await withIndexedDBMock(indexedDBMock, () =>
|
||||
clearAllIndexedDBs(
|
||||
INDEXED_DB_DELETE_TIMEOUT_MS,
|
||||
INDEXED_DB_CLEAR_STORE_TIMEOUT_MS,
|
||||
INDEXED_DB_DELETE_FALLBACK_DELAY_MS,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ count: 3, errors: [] });
|
||||
expect(clearStore).toHaveBeenCalledTimes(3);
|
||||
expect(indexedDBMock.deleteDatabase).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should preserve partial count when some IndexedDB databases fail', async () => {
|
||||
const indexedDBMock = createIndexedDBMock({
|
||||
databases: [{ name: 'db-a' }, { name: 'db-b' }, { name: 'db-c' }],
|
||||
deleteBehavior: { 'db-a': 'success', 'db-b': 'success', 'db-c': 'error' },
|
||||
});
|
||||
|
||||
const result = await withIndexedDBMock(indexedDBMock, () =>
|
||||
clearAllIndexedDBs(
|
||||
INDEXED_DB_DELETE_TIMEOUT_MS,
|
||||
INDEXED_DB_CLEAR_STORE_TIMEOUT_MS,
|
||||
INDEXED_DB_DELETE_FALLBACK_DELAY_MS,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
count: 2,
|
||||
errors: ['删除 IndexedDB 失败(db-c),请刷新后重试'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve partial count when blocked fallback fails for one database', async () => {
|
||||
const indexedDBMock = createIndexedDBMock({
|
||||
databases: [{ name: 'db-a' }, { name: 'db-b' }, { name: 'blocked-db' }],
|
||||
deleteBehavior: { 'db-a': 'success', 'db-b': 'success', 'blocked-db': 'blocked' },
|
||||
});
|
||||
|
||||
const result = await withIndexedDBMock(indexedDBMock, () =>
|
||||
clearAllIndexedDBs(
|
||||
INDEXED_DB_DELETE_TIMEOUT_MS,
|
||||
INDEXED_DB_CLEAR_STORE_TIMEOUT_MS,
|
||||
INDEXED_DB_DELETE_FALLBACK_DELAY_MS,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
count: 2,
|
||||
errors: ['页面仍占用 IndexedDB(blocked-db),请刷新后重试或关闭占用该页面的连接'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should clear ImageCacheDB on consecutive attempts without calling delete first', async () => {
|
||||
const clearStore = vi.fn();
|
||||
const openMock = createOpenMock({ storeNames: ['images'], onClearStore: clearStore });
|
||||
const indexedDBMock = createIndexedDBMock({
|
||||
databases: [{ name: 'ImageCacheDB' }],
|
||||
deleteBehavior: 'blocked',
|
||||
open: openMock,
|
||||
});
|
||||
|
||||
await withIndexedDBMock(indexedDBMock, runClear);
|
||||
const secondResult = await withIndexedDBMock(indexedDBMock, runClear);
|
||||
|
||||
expect(secondResult).toEqual({ count: 1, errors: [] });
|
||||
expect(clearStore).toHaveBeenCalledTimes(2);
|
||||
expect(indexedDBMock.deleteDatabase).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should run when deserialized into page context with explicit timeout args', async () => {
|
||||
const indexedDBMock = createIndexedDBMock({
|
||||
databases: [],
|
||||
deleteBehavior: 'success',
|
||||
});
|
||||
|
||||
const result = await withIndexedDBMock(indexedDBMock, async () => {
|
||||
const injected = (0, eval)(`(${clearAllIndexedDBs.toString()})`) as typeof clearAllIndexedDBs;
|
||||
return injected(
|
||||
INDEXED_DB_DELETE_TIMEOUT_MS,
|
||||
INDEXED_DB_CLEAR_STORE_TIMEOUT_MS,
|
||||
INDEXED_DB_DELETE_FALLBACK_DELAY_MS,
|
||||
);
|
||||
});
|
||||
|
||||
expect(result).toEqual({ count: 0, errors: [] });
|
||||
});
|
||||
|
||||
it('should return count 0 when no IndexedDB databases exist', async () => {
|
||||
const indexedDBMock = createIndexedDBMock({
|
||||
databases: [],
|
||||
deleteBehavior: 'success',
|
||||
});
|
||||
|
||||
const result = await withIndexedDBMock(indexedDBMock, () =>
|
||||
clearAllIndexedDBs(
|
||||
INDEXED_DB_DELETE_TIMEOUT_MS,
|
||||
INDEXED_DB_CLEAR_STORE_TIMEOUT_MS,
|
||||
INDEXED_DB_DELETE_FALLBACK_DELAY_MS,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ count: 0, errors: [] });
|
||||
expect(indexedDBMock.deleteDatabase).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -76,14 +76,6 @@ describe('jsonToToml', () => {
|
||||
expect(() => jsonToToml('{invalid}')).toThrow(SyntaxError);
|
||||
});
|
||||
|
||||
it('should throw Error for non-object top-level value', () => {
|
||||
expect(() => jsonToToml('"hello"')).toThrow(
|
||||
'TOML requires the top-level value to be an object',
|
||||
);
|
||||
expect(() => jsonToToml('[1,2]')).toThrow('TOML requires the top-level value to be an object');
|
||||
expect(() => jsonToToml('null')).toThrow('TOML requires the top-level value to be an object');
|
||||
});
|
||||
|
||||
it('should escape special characters in strings', () => {
|
||||
const result = jsonToToml('{"path":"C:\\\\Users\\\\test"}');
|
||||
expect(result.output).toContain('"C:\\\\Users\\\\test"');
|
||||
|
||||
@@ -86,11 +86,6 @@ describe('jsonToYaml', () => {
|
||||
expect(() => jsonToYaml('{invalid}')).toThrow(SyntaxError);
|
||||
});
|
||||
|
||||
it('should handle keys with special characters', () => {
|
||||
const result = jsonToYaml('{"key with spaces":"value"}');
|
||||
expect(result.output).toContain('"key with spaces"');
|
||||
});
|
||||
|
||||
it('should handle nested arrays in objects', () => {
|
||||
const result = jsonToYaml('{"items":[1,2,3]}');
|
||||
expect(result.output).toContain('items:');
|
||||
|
||||
@@ -2,7 +2,6 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
import { parseQrCodeFromFile } from '@/utils/qrCodeParser';
|
||||
import QrScanner from 'qr-scanner';
|
||||
|
||||
// Mock qr-scanner
|
||||
vi.mock('qr-scanner', () => ({
|
||||
default: {
|
||||
scanImage: vi.fn(),
|
||||
|
||||
@@ -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:']),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,85 +1,61 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { clearCookies, isRestrictedUrl } from '@/utils/storageCleaner';
|
||||
import { formatBytes } from '@/utils/format';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
clearAllIndexedDBs,
|
||||
INDEXED_DB_CLEAR_STORE_TIMEOUT_MS,
|
||||
INDEXED_DB_DELETE_FALLBACK_DELAY_MS,
|
||||
INDEXED_DB_DELETE_TIMEOUT_MS,
|
||||
} from '@/utils/indexedDbCleaner';
|
||||
import { clearCookies, clearStorage } from '@/utils/storageCleaner';
|
||||
import type { StorageCleanerOptions } from '@/types/storage';
|
||||
|
||||
const indexedDBClearOptions: StorageCleanerOptions = {
|
||||
localStorage: false,
|
||||
sessionStorage: false,
|
||||
indexedDB: true,
|
||||
cookies: false,
|
||||
cacheStorage: false,
|
||||
serviceWorkers: false,
|
||||
};
|
||||
|
||||
function mockExecuteScriptEval() {
|
||||
(chrome.scripting.executeScript as any).mockImplementationOnce(
|
||||
async ({ func, args }: { func: (...a: unknown[]) => unknown; args?: unknown[] }) => {
|
||||
if (func === clearAllIndexedDBs) {
|
||||
const deleteTimeoutMs = (args?.[0] as number | undefined) ?? INDEXED_DB_DELETE_TIMEOUT_MS;
|
||||
const clearStoreTimeoutMs =
|
||||
(args?.[1] as number | undefined) ?? INDEXED_DB_CLEAR_STORE_TIMEOUT_MS;
|
||||
const fallbackDelayMs =
|
||||
(args?.[2] as number | undefined) ?? INDEXED_DB_DELETE_FALLBACK_DELAY_MS;
|
||||
return [
|
||||
{
|
||||
result: await clearAllIndexedDBs(deleteTimeoutMs, clearStoreTimeoutMs, fallbackDelayMs),
|
||||
},
|
||||
];
|
||||
}
|
||||
const isolatedFunc = (0, eval)(`(${func.toString()})`) as (
|
||||
...a: unknown[]
|
||||
) => Promise<unknown>;
|
||||
return [{ result: await isolatedFunc(...(args ?? [])) }];
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function mockIndexedDBForEmptyDatabases() {
|
||||
Object.defineProperty(globalThis, 'indexedDB', {
|
||||
configurable: true,
|
||||
value: {
|
||||
databases: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('storageCleaner utils', () => {
|
||||
describe('isRestrictedUrl', () => {
|
||||
it('should return true for chrome:// URLs', () => {
|
||||
expect(isRestrictedUrl('chrome://settings')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for chrome-extension:// URLs', () => {
|
||||
expect(isRestrictedUrl('chrome-extension://abc123/background.html')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for about:// URLs', () => {
|
||||
expect(isRestrictedUrl('about:blank')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for edge:// URLs', () => {
|
||||
expect(isRestrictedUrl('edge://settings')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for view-source:// URLs', () => {
|
||||
expect(isRestrictedUrl('view-source:https://example.com')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for file:// URLs', () => {
|
||||
expect(isRestrictedUrl('file:///path/to/file')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for data:// URLs', () => {
|
||||
expect(isRestrictedUrl('data:text/html,<h1>Hello</h1>')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for http:// URLs', () => {
|
||||
expect(isRestrictedUrl('http://example.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for https:// URLs', () => {
|
||||
expect(isRestrictedUrl('https://example.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for undefined URL', () => {
|
||||
expect(isRestrictedUrl(undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for empty string', () => {
|
||||
expect(isRestrictedUrl('')).toBe(true);
|
||||
});
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('formatBytes', () => {
|
||||
it('should return "0 B" for 0 bytes', () => {
|
||||
expect(formatBytes(0)).toBe('0 B');
|
||||
});
|
||||
|
||||
it('should format bytes correctly', () => {
|
||||
expect(formatBytes(500)).toBe('500 B');
|
||||
});
|
||||
|
||||
it('should format kilobytes correctly', () => {
|
||||
expect(formatBytes(1024)).toBe('1.0 KB');
|
||||
expect(formatBytes(1536)).toBe('1.5 KB');
|
||||
expect(formatBytes(2048)).toBe('2.0 KB');
|
||||
});
|
||||
|
||||
it('should format megabytes correctly', () => {
|
||||
expect(formatBytes(1048576)).toBe('1.00 MB');
|
||||
expect(formatBytes(1572864)).toBe('1.50 MB');
|
||||
expect(formatBytes(5242880)).toBe('5.00 MB');
|
||||
});
|
||||
|
||||
it('should format gigabytes correctly', () => {
|
||||
expect(formatBytes(1073741824)).toBe('1.00 GB');
|
||||
expect(formatBytes(2147483648)).toBe('2.00 GB');
|
||||
});
|
||||
|
||||
it('should handle edge cases', () => {
|
||||
expect(formatBytes(1)).toBe('1 B');
|
||||
expect(formatBytes(1023)).toBe('1023 B');
|
||||
expect(formatBytes(1025)).toBe('1.0 KB');
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('clearCookies', () => {
|
||||
@@ -131,4 +107,107 @@ describe('storageCleaner utils', () => {
|
||||
expect(result).toEqual({ success: false, error: 'Error: Permission denied' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearStorage', () => {
|
||||
it('should report script injection failures instead of treating fallback values as success', async () => {
|
||||
(chrome.scripting.executeScript as any).mockRejectedValueOnce(
|
||||
new Error('Cannot access this page'),
|
||||
);
|
||||
|
||||
const result = await clearStorage(1, 'https://example.com', {
|
||||
localStorage: true,
|
||||
sessionStorage: false,
|
||||
indexedDB: false,
|
||||
cookies: false,
|
||||
cacheStorage: false,
|
||||
serviceWorkers: false,
|
||||
});
|
||||
|
||||
expect(result.overallSuccess).toBe(false);
|
||||
expect(result.localStorage).toEqual({
|
||||
success: false,
|
||||
error: 'Error: Cannot access this page',
|
||||
});
|
||||
});
|
||||
|
||||
it('should wire IndexedDB cleanup through executeScript with clearAllIndexedDBs', async () => {
|
||||
mockIndexedDBForEmptyDatabases();
|
||||
mockExecuteScriptEval();
|
||||
|
||||
const result = await clearStorage(1, 'https://example.com', indexedDBClearOptions);
|
||||
|
||||
expect(chrome.scripting.executeScript).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
func: clearAllIndexedDBs,
|
||||
args: [
|
||||
INDEXED_DB_DELETE_TIMEOUT_MS,
|
||||
INDEXED_DB_CLEAR_STORE_TIMEOUT_MS,
|
||||
INDEXED_DB_DELETE_FALLBACK_DELAY_MS,
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(result.overallSuccess).toBe(true);
|
||||
expect(result.indexedDB).toEqual({ success: true, count: 0 });
|
||||
});
|
||||
|
||||
it('should aggregate selected storage failures into overallSuccess', async () => {
|
||||
(chrome.scripting.executeScript as any).mockResolvedValueOnce([{ result: { count: 1 } }]);
|
||||
(chrome.cookies.getAll as any).mockRejectedValueOnce(new Error('Cookie denied'));
|
||||
|
||||
const result = await clearStorage(1, 'https://example.com', {
|
||||
localStorage: true,
|
||||
sessionStorage: false,
|
||||
indexedDB: false,
|
||||
cookies: true,
|
||||
cacheStorage: false,
|
||||
serviceWorkers: false,
|
||||
});
|
||||
|
||||
expect(result.overallSuccess).toBe(false);
|
||||
expect(result.localStorage).toEqual({ success: true, count: 1 });
|
||||
expect(result.cookies).toEqual({ success: false, error: 'Error: Cookie denied' });
|
||||
expect(result.error).toBe('Cookies: Error: Cookie denied');
|
||||
});
|
||||
|
||||
it('should join multiple storage failure messages in result.error', async () => {
|
||||
(chrome.scripting.executeScript as any).mockRejectedValueOnce(new Error('Script denied'));
|
||||
(chrome.cookies.getAll as any).mockRejectedValueOnce(new Error('Cookie denied'));
|
||||
|
||||
const result = await clearStorage(1, 'https://example.com', {
|
||||
localStorage: true,
|
||||
sessionStorage: false,
|
||||
indexedDB: false,
|
||||
cookies: true,
|
||||
cacheStorage: false,
|
||||
serviceWorkers: false,
|
||||
});
|
||||
|
||||
expect(result.overallSuccess).toBe(false);
|
||||
expect(result.localStorage).toEqual({ success: false, error: 'Error: Script denied' });
|
||||
expect(result.cookies).toEqual({ success: false, error: 'Error: Cookie denied' });
|
||||
expect(result.error).toBe(
|
||||
'Local Storage: Error: Script denied\nCookies: Error: Cookie denied',
|
||||
);
|
||||
});
|
||||
|
||||
it('should preserve partial IndexedDB count when runCleanScript receives errors', async () => {
|
||||
(chrome.scripting.executeScript as any).mockImplementationOnce(async () => [
|
||||
{
|
||||
result: {
|
||||
count: 2,
|
||||
errors: ['删除 IndexedDB 失败(db-c),请刷新后重试'],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await clearStorage(1, 'https://example.com', indexedDBClearOptions);
|
||||
|
||||
expect(result.overallSuccess).toBe(false);
|
||||
expect(result.indexedDB).toEqual({
|
||||
success: false,
|
||||
count: 2,
|
||||
error: '(已成功清理 2 个数据库,但部分失败)\n删除 IndexedDB 失败(db-c),请刷新后重试',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,41 @@
|
||||
import {
|
||||
applyResolvedThemeClass,
|
||||
applyThemeFromSnapshot,
|
||||
getThemeSyncSnapshot,
|
||||
persistThemeModeSnapshot,
|
||||
resolveThemeMode,
|
||||
THEME_MODE_SNAPSHOT_KEY,
|
||||
} from '@/utils/themeSnapshot';
|
||||
|
||||
describe('themeSnapshot', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
document.documentElement.classList.remove('dark');
|
||||
});
|
||||
|
||||
it('应从未知快照值回退到 system', () => {
|
||||
localStorage.setItem(THEME_MODE_SNAPSHOT_KEY, JSON.stringify('invalid'));
|
||||
expect(getThemeSyncSnapshot()).toBe('system');
|
||||
});
|
||||
|
||||
it('resolveThemeMode 应解析 system 为当前系统主题', () => {
|
||||
vi.spyOn(window, 'matchMedia').mockReturnValue({
|
||||
matches: true,
|
||||
} as MediaQueryList);
|
||||
|
||||
expect(resolveThemeMode('dark')).toBe('dark');
|
||||
expect(resolveThemeMode('system')).toBe('dark');
|
||||
});
|
||||
|
||||
it('applyThemeFromSnapshot 应根据快照同步 dark 类', () => {
|
||||
persistThemeModeSnapshot('dark');
|
||||
applyThemeFromSnapshot();
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true);
|
||||
});
|
||||
|
||||
it('applyResolvedThemeClass 移除 dark 时应清理 class', () => {
|
||||
document.documentElement.classList.add('dark');
|
||||
applyResolvedThemeClass('light');
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -166,4 +166,18 @@ describe('useStorageState', () => {
|
||||
|
||||
expect(result.current[2]).toBe(false);
|
||||
});
|
||||
|
||||
it('加载失败时不应把快照默认值写回 Chrome Storage', async () => {
|
||||
localStorage.setItem('snapshot/app/searchHistory', JSON.stringify([]));
|
||||
|
||||
(storageUtil.get as any).mockRejectedValue(new Error('Storage read failed'));
|
||||
|
||||
renderHook(() =>
|
||||
useStorageState('app/searchHistory', [], (val): val is string[] => Array.isArray(val)),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(storageUtil.set).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
* Base64 转换器工具函数
|
||||
*/
|
||||
|
||||
import { formatBytes } from './format';
|
||||
|
||||
/** 最大文件大小限制(10 MB) */
|
||||
export const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
||||
|
||||
@@ -167,12 +165,12 @@ export function isSupportedImageExtension(fileName: string): boolean {
|
||||
export function fileToBase64(file: File): Promise<FileToBase64Result> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!file) {
|
||||
reject(new Error('No file provided'));
|
||||
reject(new Error('未提供文件'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isFileSizeValid(file.size)) {
|
||||
reject(new Error(`File size exceeds the limit (${MAX_FILE_SIZE / 1024 / 1024} MB)`));
|
||||
reject(new Error(`文件大小超出限制(最大 ${MAX_FILE_SIZE / 1024 / 1024} MB)`));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -194,7 +192,7 @@ export function fileToBase64(file: File): Promise<FileToBase64Result> {
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
reject(new Error('Failed to read file'));
|
||||
reject(new Error('读取文件失败'));
|
||||
};
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
@@ -212,17 +210,6 @@ export function extractMimeTypeFromDataUri(dataUri: string): string {
|
||||
return match ? match[1] : 'application/octet-stream';
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化文件大小显示
|
||||
*
|
||||
* @deprecated 直接使用 {@link formatBytes} 代替
|
||||
* @param bytes 字节数
|
||||
* @returns 格式化后的字符串
|
||||
*/
|
||||
export function formatFileSize(bytes: number): string {
|
||||
return formatBytes(bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64 解码为二进制后的产物
|
||||
*/
|
||||
@@ -309,7 +296,7 @@ export function base64ToBlob(input: string): Base64ToBlobResult {
|
||||
const cleaned = prefixMatch ? trimmed.slice(prefixMatch[0].length) : trimmed;
|
||||
|
||||
if (!isValidBase64(cleaned)) {
|
||||
throw new Error('Invalid Base64 string');
|
||||
throw new Error('无效的 Base64 字符串');
|
||||
}
|
||||
|
||||
const bytes = base64ToBytes(cleaned);
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
/**
|
||||
* chrome.i18n 类型安全 wrapper
|
||||
* 提供与 react-i18next 兼容的接口
|
||||
*/
|
||||
|
||||
/**
|
||||
* 获取翻译文本
|
||||
* @param msgId 翻译 key(如 'timestamp_pageTitle')
|
||||
* @param substitutions 占位符替换值(可选)
|
||||
* @returns 翻译后的文本
|
||||
*/
|
||||
export function getMessage(msgId: string, substitutions?: string[]): string {
|
||||
try {
|
||||
return chrome.i18n.getMessage(msgId, substitutions);
|
||||
} catch (error) {
|
||||
console.warn(`[chrome.i18n] 无法获取翻译: ${msgId}`, error);
|
||||
return msgId; // 回退到 key 本身
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* react-i18next 兼容的 Hook
|
||||
* 返回 t 函数和相关信息
|
||||
*/
|
||||
export function useI18n(namespace?: string | string[]) {
|
||||
const namespaces = Array.isArray(namespace) ? namespace : namespace ? [namespace] : [];
|
||||
|
||||
const t = (key: string, options?: Record<string, unknown>): string => {
|
||||
// 统一将分隔符转换为下划线,兼容 'namespace:key.path' 和 'key.path' 两种写法
|
||||
const msgId = key.replace(':', '_').replace(/\./g, '_');
|
||||
|
||||
// 先尝试直接查找 key
|
||||
let message = getMessage(msgId);
|
||||
|
||||
// 如果直接查找未命中(空字符串或返回 key 本身),尝试命名空间前缀(使用转换后的 msgId)
|
||||
if ((!message || message === msgId) && namespaces.length > 0) {
|
||||
for (const ns of namespaces) {
|
||||
const candidate = `${ns}_${msgId}`;
|
||||
const result = getMessage(candidate);
|
||||
if (result !== candidate) {
|
||||
message = result;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (options) {
|
||||
for (const [placeholder, value] of Object.entries(options)) {
|
||||
message = message.replace(`{{${placeholder}}}`, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
return message;
|
||||
};
|
||||
|
||||
return {
|
||||
t,
|
||||
i18n: {
|
||||
language: 'zh',
|
||||
changeLanguage: (_lng?: string) => {
|
||||
// chrome.i18n 无法动态切换语言,需要刷新页面
|
||||
console.warn('[chrome.i18n] 无法动态切换语言,需要刷新页面');
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
isLoaded: true,
|
||||
};
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { StorageSchema } from '@/types/storage';
|
||||
|
||||
class StorageUtils {
|
||||
async get<K extends keyof StorageSchema>(key: K): Promise<StorageSchema[K]>;
|
||||
|
||||
async get<K extends keyof StorageSchema>(
|
||||
key: K,
|
||||
defaultValue?: StorageSchema[K],
|
||||
): Promise<StorageSchema[K] | undefined>;
|
||||
defaultValue: StorageSchema[K],
|
||||
): Promise<StorageSchema[K]>;
|
||||
|
||||
async get<K extends keyof StorageSchema>(key: K): Promise<StorageSchema[K] | undefined>;
|
||||
|
||||
/**
|
||||
* 获取值
|
||||
@@ -19,7 +19,10 @@ class StorageUtils {
|
||||
defaultValue?: StorageSchema[K],
|
||||
): Promise<StorageSchema[K] | undefined> {
|
||||
const result = await chrome.storage.local.get([key]);
|
||||
return (result[key] ?? defaultValue) as StorageSchema[K] | undefined;
|
||||
if (defaultValue !== undefined) {
|
||||
return (result[key] ?? defaultValue) as StorageSchema[K];
|
||||
}
|
||||
return result[key] as StorageSchema[K] | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -44,43 +44,43 @@ export const CONTEXT_MENU_CONFIGS: ContextMenuItemConfig[] = [
|
||||
},
|
||||
{
|
||||
id: 'jwt',
|
||||
title: '🔑 解析 JWT',
|
||||
title: '解析 JWT',
|
||||
contexts: [chrome.contextMenus.ContextType.SELECTION],
|
||||
parentId: PARENT_MENU_ID,
|
||||
},
|
||||
{
|
||||
id: 'base64Converter',
|
||||
title: '🔄 Base64 解码',
|
||||
title: 'Base64 解码',
|
||||
contexts: [chrome.contextMenus.ContextType.SELECTION],
|
||||
parentId: PARENT_MENU_ID,
|
||||
},
|
||||
{
|
||||
id: 'textStatistics',
|
||||
title: '📊 统计选中文本',
|
||||
title: '统计选中文本',
|
||||
contexts: [chrome.contextMenus.ContextType.SELECTION],
|
||||
parentId: PARENT_MENU_ID,
|
||||
},
|
||||
{
|
||||
id: 'timestamp',
|
||||
title: '⏰ 转换时间戳',
|
||||
title: '转换时间戳',
|
||||
contexts: [chrome.contextMenus.ContextType.SELECTION],
|
||||
parentId: PARENT_MENU_ID,
|
||||
},
|
||||
{
|
||||
id: 'storageCleaner',
|
||||
title: '🧹 清理当前网站存储',
|
||||
title: '清理当前网站存储',
|
||||
contexts: [chrome.contextMenus.ContextType.PAGE],
|
||||
parentId: PARENT_MENU_ID,
|
||||
},
|
||||
{
|
||||
id: 'qrCode-page',
|
||||
title: '🔗 网页链接转二维码',
|
||||
title: '网页链接转二维码',
|
||||
contexts: [chrome.contextMenus.ContextType.PAGE],
|
||||
parentId: PARENT_MENU_ID,
|
||||
},
|
||||
{
|
||||
id: 'qrCode-image',
|
||||
title: '🖼️ 解析图片二维码',
|
||||
title: '解析图片二维码',
|
||||
contexts: [chrome.contextMenus.ContextType.IMAGE],
|
||||
parentId: PARENT_MENU_ID,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import type { DiffNode, DiffResult } from '@/pages/JsonTools/types';
|
||||
|
||||
const ROOT_PATH = '$';
|
||||
const SENTINEL = Symbol('missing');
|
||||
|
||||
type MaybeMissing = unknown | typeof SENTINEL;
|
||||
|
||||
const isObject = (v: unknown): v is Record<string, unknown> =>
|
||||
typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
|
||||
const isArray = (v: unknown): v is unknown[] => Array.isArray(v);
|
||||
|
||||
/**
|
||||
* JSONPath 生成器
|
||||
*/
|
||||
const buildPath = (parent: string, key: string, isArrayChild: boolean): string => {
|
||||
if (isArrayChild) {
|
||||
return `${parent}[${key}]`;
|
||||
}
|
||||
const needsEscaping = key.includes('.') || key.includes('[') || key.includes(' ');
|
||||
const formattedKey = needsEscaping ? `["${key}"]` : `.${key}`;
|
||||
|
||||
return parent === ROOT_PATH ? `${ROOT_PATH}${formattedKey}` : `${parent}${formattedKey}`;
|
||||
};
|
||||
|
||||
const primitiveEqual = (a: unknown, b: unknown): boolean => {
|
||||
if (typeof a === 'number' && typeof b === 'number') {
|
||||
return Object.is(a, b);
|
||||
}
|
||||
return a === b;
|
||||
};
|
||||
|
||||
const diffNode = (
|
||||
left: MaybeMissing,
|
||||
right: MaybeMissing,
|
||||
key: string,
|
||||
path: string,
|
||||
diffPaths: string[],
|
||||
): DiffNode => {
|
||||
if (left === SENTINEL && right !== SENTINEL) {
|
||||
diffPaths.push(path);
|
||||
return {
|
||||
key,
|
||||
type: 'added',
|
||||
oldValue: undefined,
|
||||
newValue: right,
|
||||
path,
|
||||
isLeaf: !isObject(right) && !isArray(right),
|
||||
hasDiffInChildren: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (right === SENTINEL && left !== SENTINEL) {
|
||||
diffPaths.push(path);
|
||||
return {
|
||||
key,
|
||||
type: 'removed',
|
||||
oldValue: left,
|
||||
newValue: undefined,
|
||||
path,
|
||||
isLeaf: !isObject(left) && !isArray(left),
|
||||
hasDiffInChildren: false,
|
||||
};
|
||||
}
|
||||
|
||||
const leftObj = isObject(left);
|
||||
const rightObj = isObject(right);
|
||||
const leftArr = isArray(left);
|
||||
const rightArr = isArray(right);
|
||||
|
||||
if (leftObj && rightObj) {
|
||||
const keySet = new Set<string>();
|
||||
const leftKeys = Object.keys(left);
|
||||
const rightKeys = Object.keys(right);
|
||||
|
||||
for (let i = 0; i < leftKeys.length; i++) keySet.add(leftKeys[i]);
|
||||
for (let i = 0; i < rightKeys.length; i++) keySet.add(rightKeys[i]);
|
||||
|
||||
const children: DiffNode[] = [];
|
||||
keySet.forEach((k) => {
|
||||
const childPath = buildPath(path, k, false);
|
||||
const l: MaybeMissing = k in left ? left[k] : SENTINEL;
|
||||
const r: MaybeMissing = k in right ? right[k] : SENTINEL;
|
||||
children.push(diffNode(l, r, k, childPath, diffPaths));
|
||||
});
|
||||
|
||||
const hasDiffInChildren = children.some((c) => c.type !== 'unchanged' || c.hasDiffInChildren);
|
||||
|
||||
return {
|
||||
key,
|
||||
type: hasDiffInChildren ? 'modified' : 'unchanged',
|
||||
oldValue: left,
|
||||
newValue: right,
|
||||
children,
|
||||
path,
|
||||
isLeaf: false,
|
||||
hasDiffInChildren,
|
||||
};
|
||||
}
|
||||
|
||||
if (leftArr && rightArr) {
|
||||
const len = Math.max(left.length, right.length);
|
||||
const children: DiffNode[] = new Array(len);
|
||||
|
||||
for (let i = 0; i < len; i++) {
|
||||
const k = String(i);
|
||||
const childPath = buildPath(path, k, true);
|
||||
const l: MaybeMissing = i < left.length ? left[i] : SENTINEL;
|
||||
const r: MaybeMissing = i < right.length ? right[i] : SENTINEL;
|
||||
children[i] = diffNode(l, r, k, childPath, diffPaths);
|
||||
}
|
||||
|
||||
const hasDiffInChildren = children.some((c) => c.type !== 'unchanged' || c.hasDiffInChildren);
|
||||
|
||||
return {
|
||||
key,
|
||||
type: hasDiffInChildren ? 'modified' : 'unchanged',
|
||||
oldValue: left,
|
||||
newValue: right,
|
||||
children,
|
||||
path,
|
||||
isLeaf: false,
|
||||
hasDiffInChildren,
|
||||
};
|
||||
}
|
||||
|
||||
const leftIsContainer = leftObj || leftArr;
|
||||
const rightIsContainer = rightObj || rightArr;
|
||||
|
||||
if (!leftIsContainer && !rightIsContainer) {
|
||||
if (left === null || right === null) {
|
||||
if (left === null && right === null) {
|
||||
return {
|
||||
key,
|
||||
type: 'unchanged',
|
||||
oldValue: left,
|
||||
newValue: right,
|
||||
path,
|
||||
isLeaf: true,
|
||||
hasDiffInChildren: false,
|
||||
};
|
||||
}
|
||||
} else if (typeof left === typeof right) {
|
||||
if (primitiveEqual(left, right)) {
|
||||
return {
|
||||
key,
|
||||
type: 'unchanged',
|
||||
oldValue: left,
|
||||
newValue: right,
|
||||
path,
|
||||
isLeaf: true,
|
||||
hasDiffInChildren: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
diffPaths.push(path);
|
||||
return {
|
||||
key,
|
||||
type: 'modified',
|
||||
oldValue: left,
|
||||
newValue: right,
|
||||
path,
|
||||
isLeaf: !leftIsContainer && !rightIsContainer,
|
||||
hasDiffInChildren: false,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 比较两个 JSON 值的差异
|
||||
*/
|
||||
export const diffJson = (left: unknown, right: unknown): DiffResult => {
|
||||
const diffPaths: string[] = [];
|
||||
const root = diffNode(left, right, '', ROOT_PATH, diffPaths);
|
||||
return {
|
||||
root,
|
||||
diffPaths,
|
||||
diffCount: diffPaths.length,
|
||||
};
|
||||
};
|
||||
+1
-2
@@ -2,7 +2,7 @@
|
||||
* 格式化字节大小为易读字符串
|
||||
*
|
||||
* @param bytes 字节数
|
||||
* @returns 格式化后的字符串,例如 "1.5 KB" 或 "100 B"
|
||||
* @returns 格式化后的字符串
|
||||
*/
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
@@ -17,7 +17,6 @@ export function formatBytes(bytes: number): string {
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
// KB uses 1 decimal, MB/GB/TB use 2 decimals
|
||||
const decimals = unitIndex === 0 ? 1 : 2;
|
||||
|
||||
return `${size.toFixed(decimals)} ${units[unitIndex]}`;
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
export const INDEXED_DB_DELETE_TIMEOUT_MS = 5000;
|
||||
export const INDEXED_DB_CLEAR_STORE_TIMEOUT_MS = 5000;
|
||||
export const INDEXED_DB_DELETE_FALLBACK_DELAY_MS = 200;
|
||||
|
||||
export interface IndexedDBCleanResult {
|
||||
count: number;
|
||||
errors?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 在页面上下文中清理当前 origin 的全部 IndexedDB。
|
||||
* 设计为可传入 executeScript({ func }) 的自包含函数。
|
||||
*
|
||||
* 参数必须由调用方显式传入(不可使用模块常量作默认参数),
|
||||
* 否则函数序列化到页面后缺省参数求值会 ReferenceError。
|
||||
*/
|
||||
export async function clearAllIndexedDBs(
|
||||
deleteTimeoutMs: number,
|
||||
clearStoreTimeoutMs: number,
|
||||
fallbackDelayMs: number,
|
||||
): Promise<IndexedDBCleanResult> {
|
||||
const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const waitForTransaction = (tx: IDBTransaction, timeoutMs: number): Promise<void> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
reject(new Error('Transaction timeout'));
|
||||
}, timeoutMs);
|
||||
|
||||
tx.oncomplete = () => {
|
||||
clearTimeout(timeoutId);
|
||||
resolve();
|
||||
};
|
||||
tx.onerror = () => {
|
||||
clearTimeout(timeoutId);
|
||||
reject(tx.error ?? new Error('Transaction failed'));
|
||||
};
|
||||
tx.onabort = () => {
|
||||
clearTimeout(timeoutId);
|
||||
reject(tx.error ?? new Error('Transaction aborted'));
|
||||
};
|
||||
});
|
||||
|
||||
const openDatabase = (dbName: string, timeoutMs: number): Promise<IDBDatabase> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
reject(new Error('Open timeout'));
|
||||
}, timeoutMs);
|
||||
|
||||
const openReq = indexedDB.open(dbName);
|
||||
openReq.onerror = () => {
|
||||
clearTimeout(timeoutId);
|
||||
reject(openReq.error ?? new Error('Open failed'));
|
||||
};
|
||||
openReq.onsuccess = () => {
|
||||
clearTimeout(timeoutId);
|
||||
resolve(openReq.result);
|
||||
};
|
||||
});
|
||||
|
||||
const waitForDeleteDatabase = (dbName: string, timeoutMs: number) =>
|
||||
new Promise<'deleted' | 'blocked' | 'timeout' | 'error'>((resolve) => {
|
||||
let settled = false;
|
||||
const settle = (status: 'deleted' | 'blocked' | 'timeout' | 'error') => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeoutId);
|
||||
resolve(status);
|
||||
};
|
||||
|
||||
const deleteReq = indexedDB.deleteDatabase(dbName);
|
||||
const timeoutId = setTimeout(() => {
|
||||
console.warn(`IndexedDB 删除超时: ${dbName}`);
|
||||
settle('timeout');
|
||||
}, timeoutMs);
|
||||
|
||||
deleteReq.onblocked = () => {
|
||||
console.warn(`IndexedDB 删除被阻塞: ${dbName}`);
|
||||
settle('blocked');
|
||||
};
|
||||
deleteReq.onsuccess = () => settle('deleted');
|
||||
deleteReq.onerror = () => settle('error');
|
||||
});
|
||||
|
||||
const formatDeleteError = (dbName: string, status: 'blocked' | 'timeout' | 'error'): string => {
|
||||
if (status === 'blocked') {
|
||||
return `页面仍占用 IndexedDB(${dbName}),请刷新后重试或关闭占用该页面的连接`;
|
||||
}
|
||||
|
||||
if (status === 'timeout') {
|
||||
return `删除 IndexedDB 超时(${dbName}),请刷新后重试`;
|
||||
}
|
||||
|
||||
return `删除 IndexedDB 失败(${dbName}),请刷新后重试`;
|
||||
};
|
||||
|
||||
const clearObjectStores = async (
|
||||
dbName: string,
|
||||
): Promise<{ success: boolean; errors: string[] }> => {
|
||||
if (typeof indexedDB.open !== 'function') {
|
||||
return { success: false, errors: [] };
|
||||
}
|
||||
|
||||
const clearStore = (store: IDBObjectStore, storeName: string): Promise<string | null> =>
|
||||
new Promise((resolve) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
resolve(`清空 IndexedDB 超时(${dbName}/${storeName}),请刷新后重试`);
|
||||
}, clearStoreTimeoutMs);
|
||||
|
||||
const clearReq = store.clear();
|
||||
clearReq.onsuccess = () => {
|
||||
clearTimeout(timeoutId);
|
||||
resolve(null);
|
||||
};
|
||||
clearReq.onerror = () => {
|
||||
clearTimeout(timeoutId);
|
||||
resolve(`清空 IndexedDB 失败(${dbName}/${storeName}),请刷新后重试`);
|
||||
};
|
||||
});
|
||||
|
||||
let db: IDBDatabase | undefined;
|
||||
try {
|
||||
db = await openDatabase(dbName, clearStoreTimeoutMs);
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
errors: [`无法打开 IndexedDB(${dbName})进行清空,请刷新后重试`],
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const storeNames = Array.from(db.objectStoreNames);
|
||||
if (storeNames.length === 0) {
|
||||
return { success: true, errors: [] };
|
||||
}
|
||||
|
||||
const transaction = db.transaction(storeNames, 'readwrite');
|
||||
// 必须在 clear 请求完成前注册 oncomplete,否则事务可能已结束导致永久挂起
|
||||
const transactionDone = waitForTransaction(transaction, clearStoreTimeoutMs);
|
||||
void transactionDone.catch(() => {});
|
||||
const errors = (
|
||||
await Promise.all(
|
||||
storeNames.map((storeName) => clearStore(transaction.objectStore(storeName), storeName)),
|
||||
)
|
||||
).filter((error): error is string => Boolean(error));
|
||||
|
||||
if (errors.length > 0) {
|
||||
transaction.abort();
|
||||
return { success: false, errors };
|
||||
}
|
||||
|
||||
await transactionDone;
|
||||
return { success: true, errors: [] };
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
errors: [`清空 IndexedDB 失败(${dbName}),请刷新后重试`],
|
||||
};
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
if (typeof indexedDB.databases !== 'function') {
|
||||
return { count: 0 };
|
||||
}
|
||||
|
||||
const databases = await indexedDB.databases();
|
||||
let count = 0;
|
||||
const errors: string[] = [];
|
||||
for (const db of databases) {
|
||||
if (!db.name) continue;
|
||||
const dbName = db.name;
|
||||
|
||||
// 先清空 object store,再尝试 delete。若先 delete 且 blocked,
|
||||
// delete 请求仍 pending 时再 open 同库容易超时(如 Bing ImageCacheDB 连续清理)。
|
||||
const clearResult = await clearObjectStores(dbName);
|
||||
if (clearResult.success) {
|
||||
count++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const status = await waitForDeleteDatabase(dbName, deleteTimeoutMs);
|
||||
if (status === 'deleted') {
|
||||
count++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (status === 'blocked' || status === 'timeout') {
|
||||
await delay(fallbackDelayMs);
|
||||
const retryClear = await clearObjectStores(dbName);
|
||||
if (retryClear.success) {
|
||||
count++;
|
||||
} else if (retryClear.errors.length > 0) {
|
||||
errors.push(...retryClear.errors);
|
||||
} else {
|
||||
errors.push(formatDeleteError(dbName, status));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (clearResult.errors.length > 0) {
|
||||
errors.push(...clearResult.errors);
|
||||
} else {
|
||||
errors.push(formatDeleteError(dbName, status));
|
||||
}
|
||||
}
|
||||
|
||||
return { count, errors };
|
||||
} catch (error) {
|
||||
return { count: 0, errors: [`读取或清理 IndexedDB 失败: ${String(error)}`] };
|
||||
}
|
||||
}
|
||||
@@ -204,7 +204,7 @@ export function jsonToToml(text: string): JsonToTomlResult {
|
||||
|
||||
// TOML 要求顶层是表
|
||||
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error('TOML requires the top-level value to be an object');
|
||||
throw new Error('TOML 要求顶层值必须是一个对象');
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
|
||||
+7
-12
@@ -2,8 +2,6 @@
|
||||
* JWT 解析工具
|
||||
*/
|
||||
|
||||
import { getMessage } from '@/utils/chromeI18n';
|
||||
|
||||
interface JwtHeader {
|
||||
alg: string;
|
||||
typ?: string;
|
||||
@@ -43,7 +41,7 @@ export function decodeBase64Url(str: string): string {
|
||||
const pad = base64.length % 4;
|
||||
if (pad) {
|
||||
if (pad === 1) {
|
||||
throw new Error(getMessage('jwt_errors_invalidBase64String'));
|
||||
throw new Error('无效的 Base64URL 字符串');
|
||||
}
|
||||
base64 += new Array(5 - pad).join('=');
|
||||
}
|
||||
@@ -58,10 +56,9 @@ export function decodeBase64Url(str: string): string {
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
return decoder.decode(bytes);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
getMessage('jwt_errors_failedToDecode') + (e instanceof Error ? e.message : String(e)),
|
||||
{ cause: e },
|
||||
);
|
||||
throw new Error('Base64 解码失败: ' + (e instanceof Error ? e.message : String(e)), {
|
||||
cause: e,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +75,7 @@ export function parseJwt(token: string): JwtResult {
|
||||
payload: null,
|
||||
signature: '',
|
||||
raw: { header: '', payload: '', signature: '' },
|
||||
error: getMessage('jwt_errors_invalidFormat'),
|
||||
error: 'JWT 格式无效:应包含 3 个部分(header.payload.signature)',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -98,8 +95,7 @@ export function parseJwt(token: string): JwtResult {
|
||||
const headerJson = decodeBase64Url(headerB64);
|
||||
result.header = JSON.parse(headerJson);
|
||||
} catch (e) {
|
||||
result.error =
|
||||
getMessage('jwt_errors_parseHeaderFailed') + (e instanceof Error ? e.message : String(e));
|
||||
result.error = 'JWT Header 解析失败: ' + (e instanceof Error ? e.message : String(e));
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -107,8 +103,7 @@ export function parseJwt(token: string): JwtResult {
|
||||
const payloadJson = decodeBase64Url(payloadB64);
|
||||
result.payload = JSON.parse(payloadJson);
|
||||
} catch (e) {
|
||||
result.error =
|
||||
getMessage('jwt_errors_parsePayloadFailed') + (e instanceof Error ? e.message : String(e));
|
||||
result.error = 'JWT Payload 解析失败: ' + (e instanceof Error ? e.message : String(e));
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ interface QrCodeParseResult {
|
||||
|
||||
/**
|
||||
* 从文件中解析二维码
|
||||
* 使用 qr-scanner 替代 jsqr 以减小体积并提高性能
|
||||
*/
|
||||
export async function parseQrCodeFromFile(file: File): Promise<QrCodeParseResult> {
|
||||
try {
|
||||
|
||||
@@ -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.some((p) => p === protocol);
|
||||
}
|
||||
|
||||
/** 用于 storage cleaner tab 检测(前缀匹配,兼容无 protocol 的场景) */
|
||||
export function isRestrictedUrl(url?: string): boolean {
|
||||
if (!url) return true;
|
||||
return RESTRICTED_PROTOCOLS.some((p) => url.startsWith(p));
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import type { DataRule, FieldConfig } from '@/types/testDataGenerator';
|
||||
const STORAGE_KEY = 'testDataGenerator_rules';
|
||||
|
||||
/** 最大规则数量 */
|
||||
const MAX_RULES = 20;
|
||||
export const MAX_RULES = 20;
|
||||
|
||||
/**
|
||||
* 获取所有规则
|
||||
@@ -78,9 +78,7 @@ export function save(
|
||||
useCount: 0,
|
||||
};
|
||||
rules.unshift(newRule);
|
||||
if (!setAll(rules)) {
|
||||
return null;
|
||||
}
|
||||
if (!setAll(rules)) return null;
|
||||
return newRule;
|
||||
}
|
||||
|
||||
@@ -97,9 +95,7 @@ export function save(
|
||||
updatedAt: now,
|
||||
};
|
||||
rules[index] = updatedRule;
|
||||
if (!setAll(rules)) {
|
||||
return null;
|
||||
}
|
||||
if (!setAll(rules)) return null;
|
||||
return updatedRule;
|
||||
}
|
||||
|
||||
@@ -121,9 +117,7 @@ export function update(id: string, updates: Partial<DataRule>): DataRule | null
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
rules[index] = updatedRule;
|
||||
if (!setAll(rules)) {
|
||||
return null;
|
||||
}
|
||||
if (!setAll(rules)) return null;
|
||||
return updatedRule;
|
||||
}
|
||||
|
||||
@@ -138,7 +132,8 @@ export function deleteRule(id: string): boolean {
|
||||
return false;
|
||||
}
|
||||
rules.splice(index, 1);
|
||||
return setAll(rules);
|
||||
if (!setAll(rules)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -170,9 +165,7 @@ export function duplicate(id: string, copySuffix = '(副本)'): DataRule | n
|
||||
|
||||
const rules = getAll();
|
||||
rules.unshift(newRule);
|
||||
if (!setAll(rules)) {
|
||||
return null;
|
||||
}
|
||||
setAll(rules);
|
||||
return newRule;
|
||||
}
|
||||
|
||||
|
||||
+86
-99
@@ -1,31 +1,20 @@
|
||||
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',
|
||||
];
|
||||
import { CLEAN_OPTION_KEYS, OPTION_LABELS } from '@/pages/StorageCleaner/constants';
|
||||
import {
|
||||
clearAllIndexedDBs,
|
||||
INDEXED_DB_CLEAR_STORE_TIMEOUT_MS,
|
||||
INDEXED_DB_DELETE_FALLBACK_DELAY_MS,
|
||||
INDEXED_DB_DELETE_TIMEOUT_MS,
|
||||
type IndexedDBCleanResult,
|
||||
} from '@/utils/indexedDbCleaner';
|
||||
import { browser } from 'wxt/browser';
|
||||
|
||||
export async function getCurrentTab() {
|
||||
// For popup pages, we need to get the active tab from the browser window that triggered the popup.
|
||||
// We should ONLY care about the currently active tab in the last focused window.
|
||||
// If it's a restricted URL, we return it anyway and let the caller handle the error display.
|
||||
|
||||
const [tab] = await chrome.tabs.query({
|
||||
const [tab] = await browser.tabs.query({
|
||||
active: true,
|
||||
lastFocusedWindow: true,
|
||||
});
|
||||
@@ -35,7 +24,7 @@ export async function getCurrentTab() {
|
||||
}
|
||||
|
||||
// Fallback for cases where lastFocusedWindow might not work as expected (e.g. certain sidepanel scenarios)
|
||||
const [fallbackTab] = await chrome.tabs.query({
|
||||
const [fallbackTab] = await browser.tabs.query({
|
||||
active: true,
|
||||
currentWindow: true,
|
||||
});
|
||||
@@ -43,14 +32,9 @@ 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 });
|
||||
const cookies = await browser.cookies.getAll({ url });
|
||||
const encoder = new TextEncoder();
|
||||
// 估算:名称 + 值 + 域名 + 路径 的 UTF-8 字节数
|
||||
return cookies.reduce(
|
||||
@@ -68,29 +52,49 @@ export async function getCookieSize(url: string): Promise<number> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用辅助:在指定标签页中执行脚本并返回结果
|
||||
*
|
||||
* @param tabId 标签页 ID
|
||||
* @param func 在页面上下文中执行的函数
|
||||
* @param errorLabel 错误日志前缀
|
||||
* @param fallback 执行失败时的回退值
|
||||
*/
|
||||
async function runScript<T>(
|
||||
type ExecuteInTabOptions<T> =
|
||||
| { errorLabel: string; mode: 'fallback'; fallback: T }
|
||||
| { errorLabel: string; mode: 'throw' };
|
||||
|
||||
async function executeInTab<T, A extends unknown[] = []>(
|
||||
tabId: number,
|
||||
func: () => T | Promise<T>,
|
||||
errorLabel: string,
|
||||
fallback: T,
|
||||
func: (...args: A) => T | Promise<T>,
|
||||
options: ExecuteInTabOptions<T>,
|
||||
args?: A,
|
||||
): Promise<T> {
|
||||
try {
|
||||
const [result] = await chrome.scripting.executeScript({ target: { tabId }, func });
|
||||
return (result?.result as T) ?? fallback;
|
||||
const [result] = await browser.scripting.executeScript({
|
||||
target: { tabId },
|
||||
func,
|
||||
...(args ? { args } : {}),
|
||||
});
|
||||
const value = result?.result as T | undefined;
|
||||
if (value !== undefined && value !== null) {
|
||||
return value;
|
||||
}
|
||||
if (options.mode === 'fallback') {
|
||||
return options.fallback;
|
||||
}
|
||||
return undefined as T;
|
||||
} catch (error) {
|
||||
console.error(`Failed to ${errorLabel}:`, error);
|
||||
return fallback;
|
||||
console.error(`Failed to ${options.errorLabel}:`, error);
|
||||
if (options.mode === 'throw') {
|
||||
throw error;
|
||||
}
|
||||
return options.fallback;
|
||||
}
|
||||
}
|
||||
|
||||
async function runScript<T, A extends unknown[] = []>(
|
||||
tabId: number,
|
||||
func: (...args: A) => T | Promise<T>,
|
||||
errorLabel: string,
|
||||
fallback: T,
|
||||
args?: A,
|
||||
): Promise<T> {
|
||||
return executeInTab(tabId, func, { errorLabel, mode: 'fallback', fallback }, args);
|
||||
}
|
||||
|
||||
export async function getLocalStorageSize(tabId: number): Promise<number> {
|
||||
return runScript(
|
||||
tabId,
|
||||
@@ -188,12 +192,12 @@ export async function getServiceWorkerCount(tabId: number): Promise<number> {
|
||||
|
||||
export async function clearCookies(url: string): Promise<StorageCleanResult> {
|
||||
try {
|
||||
const cookies = await chrome.cookies.getAll({ url });
|
||||
const cookies = await browser.cookies.getAll({ url });
|
||||
for (const cookie of cookies) {
|
||||
const protocol = cookie.secure ? 'https:' : 'http:';
|
||||
const domain = cookie.domain.startsWith('.') ? cookie.domain.slice(1) : cookie.domain;
|
||||
const cookieUrl = `${protocol}//${domain}${cookie.path}`;
|
||||
await chrome.cookies.remove({
|
||||
await browser.cookies.remove({
|
||||
url: cookieUrl,
|
||||
name: cookie.name,
|
||||
storeId: cookie.storeId,
|
||||
@@ -212,16 +216,33 @@ export async function clearCookies(url: string): Promise<StorageCleanResult> {
|
||||
* @param func 在页面上下文中执行的清理函数
|
||||
* @param errorLabel 错误日志前缀
|
||||
*/
|
||||
async function runCleanScript(
|
||||
async function runCleanScript<A extends unknown[] = []>(
|
||||
tabId: number,
|
||||
func: () => { count: number } | Promise<{ count: number }>,
|
||||
func: (...args: A) => IndexedDBCleanResult | Promise<IndexedDBCleanResult>,
|
||||
errorLabel: string,
|
||||
args?: A,
|
||||
): Promise<StorageCleanResult> {
|
||||
const raw = await runScript(tabId, func, errorLabel, { count: 0 });
|
||||
if (raw && typeof raw === 'object' && 'count' in raw) {
|
||||
try {
|
||||
const raw = await executeInTab(tabId, func, { errorLabel, mode: 'throw' }, args);
|
||||
if (!raw || typeof raw !== 'object' || !('count' in raw)) {
|
||||
return { success: false, error: 'No result returned' };
|
||||
}
|
||||
|
||||
if (raw.errors?.length) {
|
||||
const errorMsg = raw.errors.join('\n');
|
||||
const partialHint = raw.count > 0 ? `(已成功清理 ${raw.count} 个数据库,但部分失败)\n` : '';
|
||||
return {
|
||||
success: false,
|
||||
error: partialHint + errorMsg,
|
||||
...(raw.count > 0 ? { count: raw.count } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true, count: raw.count };
|
||||
} catch (error) {
|
||||
console.error(`Failed to ${errorLabel}:`, error);
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
return { success: false, error: 'No result returned' };
|
||||
}
|
||||
|
||||
async function injectClearLocalStorage(tabId: number): Promise<StorageCleanResult> {
|
||||
@@ -249,47 +270,11 @@ async function injectClearSessionStorage(tabId: number): Promise<StorageCleanRes
|
||||
}
|
||||
|
||||
async function injectClearIndexedDB(tabId: number): Promise<StorageCleanResult> {
|
||||
return runCleanScript(
|
||||
tabId,
|
||||
async () => {
|
||||
if (typeof indexedDB.databases !== 'function') {
|
||||
return { count: 0 };
|
||||
}
|
||||
const databases = await indexedDB.databases();
|
||||
let count = 0;
|
||||
for (const db of databases) {
|
||||
if (!db.name) continue;
|
||||
const dbName = db.name;
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const deleteReq = indexedDB.deleteDatabase(dbName);
|
||||
const timeout = setTimeout(() => {
|
||||
console.warn('IndexedDB delete timeout:', dbName);
|
||||
resolve();
|
||||
}, 5000);
|
||||
deleteReq.onblocked = () => {
|
||||
console.warn('IndexedDB delete blocked:', dbName);
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
};
|
||||
deleteReq.onsuccess = () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
};
|
||||
deleteReq.onerror = () => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error(`Failed to delete ${dbName}`));
|
||||
};
|
||||
});
|
||||
count++;
|
||||
} catch (e) {
|
||||
console.error('Delete DB error:', e);
|
||||
}
|
||||
}
|
||||
return { count };
|
||||
},
|
||||
'clear IndexedDB',
|
||||
);
|
||||
return runCleanScript(tabId, clearAllIndexedDBs, 'clear IndexedDB', [
|
||||
INDEXED_DB_DELETE_TIMEOUT_MS,
|
||||
INDEXED_DB_CLEAR_STORE_TIMEOUT_MS,
|
||||
INDEXED_DB_DELETE_FALLBACK_DELAY_MS,
|
||||
]);
|
||||
}
|
||||
|
||||
async function injectClearCacheStorage(tabId: number): Promise<StorageCleanResult> {
|
||||
@@ -357,27 +342,29 @@ export async function clearStorage(
|
||||
);
|
||||
if (failures.length > 0) {
|
||||
result.overallSuccess = false;
|
||||
result.error = CLEAN_OPTION_KEYS.flatMap((key) => {
|
||||
const itemResult = result[key];
|
||||
if (!itemResult || itemResult.success) return [];
|
||||
return `${OPTION_LABELS[key]}: ${itemResult.error}`;
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function formatCleaningResult(
|
||||
result: CleaningResult,
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
): string {
|
||||
export function formatCleaningResult(result: CleaningResult): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
for (const key of CLEAN_OPTION_KEYS) {
|
||||
const r = result[key];
|
||||
if (r?.success && r.count > 0) {
|
||||
parts.push(`${r.count} ${t(`storageCleaner:options.${key}`)}`);
|
||||
parts.push(`${r.count} ${OPTION_LABELS[key] || key}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (parts.length === 0) {
|
||||
return t('storageCleaner:noDataToClean');
|
||||
return '该页面没有可清理的存储数据';
|
||||
}
|
||||
|
||||
return t('storageCleaner:cleanedSummary', { items: parts.join(', ') });
|
||||
return `清理了 ${parts.join(', ')}`;
|
||||
}
|
||||
|
||||
@@ -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(`读取快照失败 (${key}):`, error);
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
export type ThemeMode = 'light' | 'dark' | 'system';
|
||||
export type ResolvedThemeMode = 'light' | 'dark';
|
||||
|
||||
export const THEME_MODE_STORAGE_KEY = 'app/themeMode' as const;
|
||||
export const THEME_MODE_SNAPSHOT_KEY = 'snapshot/app/themeMode';
|
||||
|
||||
const VALID_MODES: ThemeMode[] = ['light', 'dark', 'system'];
|
||||
|
||||
export const isValidThemeMode = (value: unknown): value is ThemeMode =>
|
||||
VALID_MODES.includes(value as ThemeMode);
|
||||
|
||||
/**
|
||||
* 同步读取 localStorage 快照(首屏防闪烁)
|
||||
*/
|
||||
export const getThemeSyncSnapshot = (): ThemeMode => {
|
||||
try {
|
||||
const raw = localStorage.getItem(THEME_MODE_SNAPSHOT_KEY);
|
||||
if (!raw) return 'system';
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
return isValidThemeMode(parsed) ? parsed : 'system';
|
||||
} catch {
|
||||
return 'system';
|
||||
}
|
||||
};
|
||||
|
||||
export const getSystemThemeMode = (): ResolvedThemeMode => {
|
||||
if (typeof window === 'undefined') return 'light';
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
};
|
||||
|
||||
export const resolveThemeMode = (mode: ThemeMode): ResolvedThemeMode =>
|
||||
mode === 'system' ? getSystemThemeMode() : mode;
|
||||
|
||||
export const persistThemeModeSnapshot = (mode: ThemeMode): void => {
|
||||
try {
|
||||
localStorage.setItem(THEME_MODE_SNAPSHOT_KEY, JSON.stringify(mode));
|
||||
} catch (error) {
|
||||
console.error('保存主题快照失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
export const applyResolvedThemeClass = (resolved: ResolvedThemeMode): void => {
|
||||
if (typeof document === 'undefined') return;
|
||||
document.documentElement.classList.toggle('dark', resolved === 'dark');
|
||||
};
|
||||
|
||||
/** 在 React 挂载前同步应用快照主题,避免首帧闪烁 */
|
||||
export const applyThemeFromSnapshot = (): void => {
|
||||
applyResolvedThemeClass(resolveThemeMode(getThemeSyncSnapshot()));
|
||||
};
|
||||
@@ -26,7 +26,7 @@ export function useContextMenuData({ featureKey, onData }: UseContextMenuDataOpt
|
||||
useEffect(() => {
|
||||
const checkAndConsumeData = async () => {
|
||||
try {
|
||||
const data = await storageUtil.get(STORAGE_KEY, undefined);
|
||||
const data = await storageUtil.get(STORAGE_KEY);
|
||||
|
||||
if (!data) return;
|
||||
|
||||
@@ -55,7 +55,7 @@ export function useContextMenuData({ featureKey, onData }: UseContextMenuDataOpt
|
||||
if (newData && newData.featureKey === featureKey) {
|
||||
void (async () => {
|
||||
try {
|
||||
const data = await storageUtil.get(STORAGE_KEY, undefined);
|
||||
const data = await storageUtil.get(STORAGE_KEY);
|
||||
if (!data) return;
|
||||
if (data.featureKey !== featureKey) return;
|
||||
if (Date.now() - data.timestamp > CONTEXT_MENU_DATA_EXPIRY_MS) {
|
||||
|
||||
@@ -1,40 +1,33 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type Dispatch,
|
||||
type SetStateAction,
|
||||
} 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],
|
||||
validator?: (val: unknown) => val is StorageSchema[K],
|
||||
) => {
|
||||
const [value, setValue] = useState<StorageSchema[K]>(() =>
|
||||
const [value, setValueInternal] = useState<StorageSchema[K]>(() =>
|
||||
getSyncSnapshot(key as string, defaultValue, validator),
|
||||
);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const hasLoadedFromStorage = useRef(false);
|
||||
const loadSucceededRef = useRef(false);
|
||||
const userModifiedRef = useRef(false);
|
||||
|
||||
const setValue = useCallback<Dispatch<SetStateAction<StorageSchema[K]>>>((next) => {
|
||||
userModifiedRef.current = true;
|
||||
setValueInternal(next);
|
||||
}, []);
|
||||
|
||||
// Only load from storage once on mount
|
||||
useEffect(() => {
|
||||
if (hasLoadedFromStorage.current) return;
|
||||
|
||||
@@ -44,15 +37,17 @@ export const useStorageState = <K extends keyof StorageSchema>(
|
||||
try {
|
||||
const savedValue = await storageUtil.get(key, defaultValue);
|
||||
if (cancelled) return;
|
||||
loadSucceededRef.current = true;
|
||||
if (savedValue !== undefined) {
|
||||
if (validator) {
|
||||
setValue(validator(savedValue) ? savedValue : defaultValue);
|
||||
setValueInternal(validator(savedValue) ? savedValue : defaultValue);
|
||||
} else {
|
||||
setValue(savedValue);
|
||||
setValueInternal(savedValue);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`加载状态失败 (${key}):`, error);
|
||||
loadSucceededRef.current = false;
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsInitialized(true);
|
||||
@@ -68,9 +63,9 @@ export const useStorageState = <K extends keyof StorageSchema>(
|
||||
};
|
||||
}, [defaultValue, key, validator]);
|
||||
|
||||
// Save to storage and localStorage snapshot when value changes (after initial load)
|
||||
useEffect(() => {
|
||||
if (!isInitialized) return;
|
||||
if (!loadSucceededRef.current && !userModifiedRef.current) return;
|
||||
|
||||
const saveState = async () => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user