diff --git a/.github/CI.md b/.github/CI.md new file mode 100644 index 0000000..6ab7abd --- /dev/null +++ b/.github/CI.md @@ -0,0 +1,17 @@ +# CI/CD 配置 + +## CI 步骤 + +严格顺序,任一步骤失败则停止并标记 CI 失败: + +1. `setup`(安装依赖、`wxt prepare`) +2. 并行运行 `lint`、`typecheck`、`test`(三者全部通过才继续) +3. `build`(仅当步骤 2 全部成功时执行) + +## Pre-commit Hook + +`.husky/pre-commit` 调用 `lint-staged`,任一步骤返回非零则终止提交: + +1. 代码文件 (`*.{ts,tsx,js,jsx,mjs}`):运行 `eslint --fix --max-warnings=0 --no-warn-ignored`;若失败则终止并报告错误 +2. 同一代码文件:运行 `prettier --write` +3. 其他文件 (`*.{json,css,scss,md}`):运行 `prettier --write` diff --git a/.github/CODING_STANDARDS.md b/.github/CODING_STANDARDS.md index 3035c0f..b19d0a5 100644 --- a/.github/CODING_STANDARDS.md +++ b/.github/CODING_STANDARDS.md @@ -265,12 +265,11 @@ const visibleSet = useMemo(() => new Set(visiblePages), [visiblePages]); 2. 第三方库(图标、UI 库等) 3. 业务 Provider / Context 4. 配置 / 存储 -5. i18n -6. 本地页面组件 -7. UI 组件 -8. 工具函数 / Hook -9. 类型 -10. 常量 +5. 本地页面组件 +6. UI 组件 +7. 工具函数 / Hook +8. 类型 +9. 常量 ```typescript // 1. React 核心 @@ -284,18 +283,16 @@ import { useThemeMode } from '@/providers/ThemeModeProvider'; // 4. 配置 / 存储 import { FeatureConfig, FEATURES } from '@/config/features'; import { storageUtil } from '@/utils/chromeStorage'; -// 5. i18n -import { useI18n } from '@/utils/chromeI18n'; -// 6. 本地组件 +// 5. 本地组件 import TextMode from './TextMode'; import { ZONES } from './constants'; -// 7. UI 组件 +// 6. UI 组件 import SwitchButtonGroup from '@/components/SwitchButtonGroup'; import { Button } from '@/components/ui/button'; -// 8. 工具函数 / Hook +// 7. 工具函数 / Hook import { cn } from '@/lib/utils'; import { useStorageState } from '@/utils/useStorageState'; -// 9. 类型 +// 8. 类型 import type { PageType, StorageSchema } from '@/types/storage'; ``` @@ -559,7 +556,7 @@ describe('SwitchButtonGroup 组件', () => { - 使用 `vi.mock()` 进行模块级 Mock - 使用 `vi.fn()` 进行函数级 Mock - 使用 `vi.useFakeTimers()` 控制时间 -- **避免重复 mock `vitest.setup.ts` 中已有的内容**(chrome API、i18n、matchMedia 等) +- **避免重复 mock `vitest.setup.ts` 中已有的内容**(chrome API、matchMedia 等) ```typescript // ✅ 模块级 Mock @@ -657,7 +654,7 @@ export function useTimestampConverter(): UseTimestampConverterReturn { ... } ``` src/utils/useStorageState.ts — Chrome Storage 状态持久化 -src/utils/chromeI18n.ts — chrome.i18n wrapper 与 useI18n +src/utils/syncSnapshot.ts — localStorage 快照读取 src/utils/useContextMenuData.ts — 右键菜单数据 src/utils/useDebounce.ts — 防抖 src/pages/Timestamp/useTimestampConverter.ts — 页面级 Hook @@ -666,52 +663,50 @@ src/pages/StorageCleaner/useStorageCleaner.ts — 页面级 Hook --- -## 9. 国际化规范 +## 9. UI 文案规范 -### 9.1 翻译键格式 +项目已移除 `chrome.i18n`,所有 UI 文案直接在代码中使用中文。 -- 使用 Chrome 扩展标准的 `chrome.i18n`,通过 `src/utils/chromeI18n.ts` 暴露 `useI18n` -- 翻译 key 存放在 `public/_locales/zh_CN/messages.json` -- 直接 key:`t('dashboard_title')` → 查找 `dashboard_title` -- 命名空间兼容写法:`t('common:buttons.search')` → 查找 `common_buttons_search` -- 命名空间参数:`useI18n(['common', 'features'])` 会尝试 `common_key`、`features_key` +### 9.1 功能元数据 -### 9.2 翻译文件结构 - -``` -public/_locales/zh_CN/messages.json — Chrome 扩展默认语言包 -wxt.config.ts — manifest.default_locale = 'zh_CN' -``` - -### 9.3 使用方式 +功能名称与描述在 `config/features.tsx` 的 `FEATURES` 数组中定义: ```typescript -// ✅ 页面组件 / 子组件 — 使用 useI18n -import { useI18n } from '@/utils/chromeI18n'; - -export default function Index() { - const { t } = useI18n('timestamp'); - return

{t('timestamp_title')}

; -} - -// ✅ 带占位符 -export function NotFoundMessage() { - const { t } = useI18n('router'); - return

{t('router_notFoundDescription', { entryPointType: 'popup' })}

; +{ + key: 'timestamp', + label: '时间戳转换', + description: '日期与时间戳互转', + defaultVisible: true, + components: { popup: TimestampPage, sidepanel: TimestampPage, tab: TimestampPage }, } ``` -### 9.4 添加新翻译 +Dashboard 卡片、TopBar 搜索等功能从此处读取 `label` / `description`。 -1. 在 `public/_locales/zh_CN/messages.json` 添加 Chrome 扩展格式的消息: - ```json - { - "feature_title": { "message": "功能标题" }, - "feature_description": { "message": "功能描述" } - } - ``` -2. key 使用下划线分隔,避免点号;`useI18n` 会把 `namespace:key.path` 兼容转换为下划线 -3. `chrome.i18n` 不支持运行时动态切换语言,浏览器语言变化后需要刷新扩展页面 +### 9.2 页面与组件文案 + +- 页面标题、按钮、提示信息等直接在 JSX 或 `constants.ts` 中写中文 +- 错误消息可在 Hook 中定义,或使用常量映射 +- Toast 通知使用 `sonner` 的 `toast()`,文案写在调用处或常量中 + +```typescript +// ✅ 页面组件 — 直接写中文 +export default function Index() { + return

时间戳转换

; +} + +// ✅ 常量文件 — 可复用文案 +export const ERROR_MESSAGES = { + invalidInput: '输入格式无效', + conversionFailed: '转换失败', +} as const; +``` + +### 9.3 添加新功能文案 + +1. 在 `config/features.tsx` 填写 `label` 和 `description` +2. 在页面组件、`constants.ts` 或 Hook 中编写 UI 文案 +3. 扩展名称与描述在 `wxt.config.ts` 的 `manifest` 中维护 --- @@ -764,6 +759,18 @@ const [themeMode, setThemeMode, isInitialized] = useStorageState( ); ``` +### 10.4 首屏快照与初始化防覆盖 + +Chrome Storage 读取是异步的。项目通过 `localStorage` 快照(键名 `snapshot/{storageKey}`)提供同步初始值,消除首屏闪烁。 + +| 模块 | 快照工具 | 防覆盖机制 | +| ---- | -------- | ---------- | +| `RouterProvider` | `syncSnapshot.ts` | `canPersistRef`(加载成功后才写入)、`hasUserNavigatedRef`(用户导航后不被 storage 覆盖) | +| `useStorageState` | `syncSnapshot.ts` | `loadSucceededRef` 或 `userModifiedRef` 为 true 时才写入 | +| `ThemeModeProvider` | `themeSnapshot.ts` | `hasUserSetMode`(用户切换主题后不被 storage 覆盖) | + +新增持久化状态时,应遵循相同模式:同步快照作初始 state → 异步加载 storage → 加载成功或用户修改后才允许写入。 + --- ## 11. 页面开发规范 @@ -846,24 +853,21 @@ export default function DashboardPage() { ... } #### 组件职责 -`index.tsx` 只负责三件事: +`index.tsx` 只负责两件事: -1. **获取翻译函数**(`useI18n`) -2. **调用业务 Hook** 获取状态和操作方法 -3. **渲染 UI 布局**(纯展示,无业务逻辑) +1. **调用业务 Hook** 获取状态和操作方法 +2. **渲染 UI 布局**(纯展示,无业务逻辑) ```typescript // ✅ 标准页面入口模板 -import { useI18n } from '@/utils/chromeI18n'; import { useFeatureName } from './useFeatureName'; export default function Index() { - const { t } = useI18n('featureName'); const { state, actions } = useFeatureName(); return (
- {/* 纯 UI 渲染 */} + {/* 纯 UI 渲染,文案直接写中文 */}
); } @@ -907,25 +911,22 @@ export function useTimestampConverter(): UseTimestampConverterReturn { ```typescript export function useFeatureName(): UseFeatureNameReturn { - // 1. i18n - const { t } = useI18n('featureName'); - - // 2. 基础 state(useState) + // 1. 基础 state(useState) const [mode, setMode] = useState('default'); const [input, setInput] = useState(''); - // 3. 持久化 state(useStorageState) + // 2. 持久化 state(useStorageState) const [pageMode, setPageMode] = useStorageState('feature/pageMode', 'default', isValidMode); - // 4. 衍生数据(useMemo)— 响应式计算管线 + // 3. 衍生数据(useMemo)— 响应式计算管线 const result = useMemo(() => { // 自动计算,无需手动点击"转换"按钮 }, [input, mode]); - // 5. 事件处理(useCallback) + // 4. 事件处理(useCallback) const handleAction = useCallback(() => { ... }, [deps]); - // 6. 副作用(useEffect)— 防抖、初始化、清理 + // 5. 副作用(useEffect)— 防抖、初始化、清理 useEffect(() => { ... }, [deps]); // 7. 右键菜单数据(页面需要时) @@ -995,7 +996,7 @@ interface ResultViewProps extends React.HTMLAttributes { // ✅ 使用 React.memo + displayName const ResultView = React.memo( ({ result, mode, showEmptyPlaceholder = false, className, ...props }: ResultViewProps) => { - const { t } = useI18n('featureName'); + // 文案直接写中文或使用 constants // ... }, ); @@ -1005,7 +1006,7 @@ export default ResultView; #### 子组件内可以使用 Hook -子组件可以独立调用 `useI18n`、`useSnackbar` 等全局 Hook,**不需要**通过 props 从父组件传递翻译函数或 toast 方法。 +子组件可以独立调用 `useRouter`、`useThemeMode`、`toast` 等全局 Hook/API,**不需要**通过 props 从父组件传递。 --- @@ -1042,7 +1043,6 @@ const isValidMode = (val: unknown): val is PageMode => typeof val === 'string' && (VALID_MODES as readonly string[]).includes(val); export default function Index() { - const { t } = useI18n('featureName'); const [pageMode, setPageMode] = useStorageState('feature/pageMode', 'modeA', isValidMode); return ( @@ -1050,8 +1050,8 @@ export default function Index() { setPageMode(v)} size="small" @@ -1082,12 +1082,12 @@ export default function Index() { 新增功能页面时,逐项确认: 1. ✅ 在 `types/storage.d.ts` 添加 `PageType` 联合类型 -2. ✅ 在 `config/features.tsx` 注册 `FEATURES` 配置(key、labelKey、icon、三种渲染模式组件) +2. ✅ 在 `config/features.tsx` 注册 `FEATURES` 配置(key、label、description、icon、三种渲染模式组件) 3. ✅ 创建页面目录,使用 `Index` 作为组件名 4. ✅ 业务逻辑提取到 `useXxx.ts` Hook(index.tsx 不超过 150 行) 5. ✅ 需要持久化的 UI 状态使用 `useStorageState` 6. ✅ 常量 ≥3 个时提取到 `constants.ts` -7. ✅ 在 `public/_locales/zh_CN/messages.json` 添加翻译 +7. ✅ 在页面组件或 `constants.ts` 中编写 UI 文案 8. ✅ 创建 `__tests__/index.test.tsx` 测试文件 9. ✅ 如需新权限,更新 `wxt.config.ts` 的 `manifest.permissions` 10. ✅ 运行 `npm run lint && npm run typecheck && npm run test` 全部通过 @@ -1108,7 +1108,7 @@ export default function Index() { | `src/utils/` | 工具函数与服务抽象 | | `src/types/` | TypeScript 类型声明 | | `src/lib/` | 通用工具函数与生成器库(cn、utils、generators) | -| `public/` | 静态资源与 Chrome `_locales` 语言包 | +| `public/` | 静态资源(图标等) | --- diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index 5824d6a..0000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -1,140 +0,0 @@ -# Copilot 指令 - -基于 WXT 框架的浏览器扩展项目(React 19 + TypeScript),为开发者和测试人员提供效率工具:时间戳转换、存储清理、JWT 解析、JSON 工具、二维码、Base64、测试数据生成器等。 - -## 核心命令 - -```bash -npm run dev # Chrome 开发模式(支持 HMR) -npm run dev:firefox # Firefox 开发模式 -npm run build # Chrome 生产构建 -npm run build:firefox # Firefox 生产构建 -npm run zip # 打包 Chrome 扩展(.output/*.zip) -npm run zip:firefox # 打包 Firefox 扩展 -npm run lint # ESLint 检查(--max-warnings=0) -npm run typecheck # TypeScript 类型检查(tsc --noEmit) -npm run test # 运行全部单元测试(vitest run) -npm run test:watch # Vitest 监视模式 -npm run test:coverage # 带覆盖率的测试 -``` - -运行单个测试文件:`npx vitest run path/to/file.test.ts` - -修改 `package.json` 后需运行 `npm install`(会自动触发 `postinstall` → `wxt prepare` 重新生成 `.wxt/` 类型声明)。 - -## CI 流水线(GitHub Actions) - -严格顺序门控,任一步骤失败则终止: - -1. **setup** — 安装依赖,缓存 `node_modules` -2. **lint**、**typecheck**、**test** — 三者并行运行,全部通过才继续 -3. **build** — Chrome + Firefox 矩阵构建(仅当步骤 2 全部通过时执行) - -Pre-commit 钩子(`.husky/pre-commit` → `lint-staged`): - -1. 代码文件(`*.{ts,tsx,js,jsx,mjs}`):运行 `eslint --fix --max-warnings=0` -2. 同一代码文件:运行 `prettier --write` -3. 其他文件(`*.{json,css,scss,md}`):运行 `prettier --write` - -## 项目架构 - -### 路由(不使用 React Router) - -路由完全通过 `config/features.tsx` 中的 `FEATURES` 数组管理。每个功能定义一个 `key`(类型为 `types/storage.d.ts` 中的 `PageType`)和三个懒加载组件,分别对应 `popup`、`sidepanel`、`tab` 三种渲染模式。`providers/RouterProvider.tsx` 中的 `RouterProvider` 根据存储状态渲染当前页面。 - -存在三套独立的路由作用域:`app/popupRoute`、`app/sidepanelRoute`、`app/tabRoute`,各自维护独立的可见页面列表和页面排序。 - -### 存储 - -所有 Chrome Storage 键必须在 `types/storage.d.ts` 的 `StorageSchema` 中声明,键名使用 kebab-case 格式(如 `app/currentRoute`)。使用 `utils/chromeStorage.ts` 中的类型安全封装(`storageUtil.get/set/remove`)。 - -Router 同时使用 `chrome.storage.local` 持久化和 `localStorage` 快照来消除首屏闪烁。 - -### 扩展通信 - -使用 `@webext-core/messaging`。通信协议在 `utils/messages.ts` 中通过 `ProtocolMap` 定义。使用该模块导出的 `sendMessage` / `onMessage`,不要直接使用原生 `chrome.runtime.sendMessage`。 - -### 页面组件模式 - -功能页面遵循 **UI + Hook 分离** 模式,详见 [CODING_STANDARDS.md § 11](./CODING_STANDARDS.md#11-页面开发规范): - -``` -pages/FeatureName/ -├── index.tsx # UI 组件(纯展示,使用 shadcn/ui 组件) -├── useFeatureName.ts # 业务逻辑 Hook(状态管理 + 转换逻辑) -└── constants.ts # 常量定义(可选) -``` - -- 页面组件调用 `useI18n('featureName')` 获取翻译函数 -- Hook 负责所有状态管理,通过返回值暴露给页面 -- 子组件可进一步拆分(如 `LiveClock.tsx`、`ResultView.tsx`) - -### 新功能开发清单 - -1. 在 `types/storage.d.ts` 的 `PageType` 联合类型中添加新成员 -2. 在 `config/features.tsx` 的 `FEATURES` 数组中添加配置(key、翻译键、图标、三种渲染模式组件) -3. 在 `pages/` 目录创建页面组件(懒加载): - - `index.tsx` — 使用 `useI18n` 的 UI 组件 - - `useFeatureName.ts` — 业务逻辑 Hook - - `constants.ts` — 常量(可选) -4. 在 `public/_locales/zh_CN/messages.json` 添加 Chrome i18n 翻译 -5. 如需新权限,更新 `wxt.config.ts` 的 `manifest.permissions` -6. 添加对应的单元测试 - -## 关键规范 - -> 完整的代码编写规范详见 [CODING_STANDARDS.md](./CODING_STANDARDS.md)。 - -### 浏览器 API - -始终使用 `wxt/browser` 导出的 `browser` 对象,而非原生 `chrome` API,以确保跨浏览器兼容性。 - -### 路径别名 - -`@/` 映射到项目根目录(已在 tsconfig 和 vitest.config 中配置)。跨目录导入使用 `@/` 绝对别名,同目录导入使用 `./` 相对路径。 - -### UI 组件 - -- 使用 `components/ui/` 下的 shadcn/ui 组件(button、dialog、select、input 等) -- 图标:`lucide-react` -- 样式:Tailwind CSS + `@/lib/utils` 中的 `cn()` 工具函数(clsx + tailwind-merge) -- 主题:使用 shadcn/ui 语义化 token(`bg-background`、`text-foreground`、`border-border` 等),禁止硬编码颜色 - -### 代码分割 - -`wxt.config.ts` 通过 `manualChunksForHtmlOnly()` 自动分组 vendor 依赖(vendor-react、vendor-qr、vendor-dnd),无需手动配置。 - -### 代码风格 - -- 禁止使用 `any`(测试文件除外) -- 未使用的变量/参数:使用 `_` 前缀(如 `_unused`) -- Prettier:100 字符宽、单引号、尾逗号 all、LF 换行 -- ESLint 使用 `typescript-eslint` 的 `projectService: true` -- 导出模式:页面组件 default export,工具函数/Hook 命名导出,UI 组件 forwardRef + 命名导出 - -### 测试 - -- 环境:jsdom -- 全局变量:`vitest/globals`(describe、it、expect 等无需导入) -- Setup 文件:`vitest.setup.ts` 自动 mock 以下内容: - - `chrome.*` / `browser.*` API(storage、tabs、runtime、cookies 等) - - `@/utils/chromeI18n`(从 `public/_locales/zh_CN/messages.json` 加载真实翻译) - - `window.matchMedia` -- 测试文件命名:`__tests__/*.test.{ts,tsx}` 或 `*.test.{ts,tsx}` -- 使用 `vi.mock()` 进行模块级 mock;避免重复 mock `vitest.setup.ts` 中已有的内容 -- 测试工具:`@testing-library/react` + `@testing-library/user-event` - -### 国际化(i18n) - -- 使用 Chrome 扩展标准 `chrome.i18n` -- 默认语言目录:`public/_locales/zh_CN/messages.json` -- 使用方式:`import { useI18n } from '@/utils/chromeI18n'` -- 翻译键格式:直接 key(如 `timestamp_title`);兼容 `namespace:key.path` 并转换为下划线 -- 回退策略:缺失翻译返回 key 本身,并在开发模式下记录 warning -- 限制:`chrome.i18n` 跟随浏览器语言,不能在运行时动态切换语言 - -### WXT 生成文件 - -- `.wxt/` 目录由 `postinstall`(`wxt prepare`)自动生成,包含 TypeScript 类型声明和扩展 tsconfig -- 生产构建输出到 `.output/` 目录 -- `tsconfig.json` 继承自 `./.wxt/tsconfig.json` diff --git a/AGENTS.md b/AGENTS.md index d7a608d..6af87e1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,48 +16,64 @@ npm run typecheck # tsc --noEmit npm run test # vitest run (单次执行) npm run test:watch # vitest 监视模式 npm run test:coverage # 带覆盖率的测试 +npx vitest run path/to/file.test.ts # 运行单个测试文件 +npx wxt prepare # 重新生成 .wxt/ 类型声明(npm install 时 postinstall 会自动执行) ``` -运行单个测试: `npx vitest run path/to/file.test.ts` +修改 `package.json` 或首次克隆仓库后需执行 `npm install`,会自动触发 `postinstall` → `wxt prepare`。 ## 验证流程 -CI 步骤(严格顺序,任一步骤失败则停止并标记 CI 失败): +详见 [CI 配置](./.github/CI.md)。本地与 CI 的检查层次如下。 -1. `setup`(安装依赖、`wxt prepare`) -2. 并行运行 `lint`、`typecheck`、`test`(三者全部通过才继续) -3. `build`(仅当步骤 2 全部成功时执行) +### Pre-commit(`.husky/pre-commit`) -Pre-commit hook(`.husky/pre-commit` 调用 `lint-staged`,任一步骤返回非零则终止提交): +1. 后台运行 `tsc --noEmit`(不阻塞提交,结果输出到 stderr) +2. 前台运行 `lint-staged`: + - 代码文件 (`*.{ts,tsx,js,jsx,mjs}`):`eslint --fix --max-warnings=0`,再 `prettier --write` + - 其他文件 (`*.{json,css,scss,md}`):`prettier --write` -1. 代码文件 (`*.{ts,tsx,js,jsx,mjs}`):运行 `eslint --fix --max-warnings=0 --no-warn-ignored`;若失败则终止并报告错误 -2. 同一代码文件:运行 `prettier --write` -3. 其他文件 (`*.{json,css,scss,md}`):运行 `prettier --write` +### Pre-push(`.husky/pre-push`) -## WXT 生成文件 +1. 全项目 `tsc --noEmit`(阻塞推送) +2. 对本次推送相对 upstream(或 `origin/main`)变更的 `*.{ts,tsx,js,jsx,mjs}` 文件运行 `eslint --max-warnings=0` -- `.wxt/` 目录由 `postinstall` 自动执行 `wxt prepare` 生成,包含 TypeScript 类型声明和扩展的 tsconfig。 -- 生产构建输出到 `.output/` 目录。 -- `tsconfig.json` 继承自 `./.wxt/tsconfig.json`。 +### CI(GitHub Actions) + +1. `setup` — 安装依赖 +2. 并行 `lint`、`typecheck`(含 `wxt prepare`)、`test` +3. `build` — Chrome + Firefox 矩阵构建(仅当步骤 2 全部通过) ## 项目结构 ``` -src/ # 源代码根目录 - config/features.tsx # 功能定义(路由 + 元数据的单一事实来源) - entrypoints/ # 扩展入口点 (popup/, sidepanel/, background.ts, content.ts) - pages/ # 功能页面组件 (懒加载) - components/ # 可复用 UI 组件 - components/ui/ # shadcn/ui 基础组件 (button, dialog, select 等) - providers/ # React Context (Router, Theme 等) - hooks/ # 自定义 React Hooks - utils/ # 工具函数与服务抽象 - types/ # TypeScript 类型声明 - lib/ # 通用工具函数(cn、utils) - workers/ # Web Worker(数据生成等耗时任务) -public/ # 静态资源(图标、_locales 等) +src/ # 源代码根目录 + config/features.tsx # 功能定义(路由 + 元数据的单一事实来源) + entrypoints/ # 扩展入口点 (popup/, sidepanel/, background.ts, content.ts) + layout/ # 应用壳层布局(TopBar 导航、搜索、主题切换) + pages/ # 功能页面组件 (懒加载) + components/ # 可复用 UI 组件 + components/ui/ # shadcn/ui 基础组件 (button, dialog, select 等) + providers/ # React Context (Router, Theme 等) + hooks/ # 自定义 React Hooks + utils/ # 工具函数与服务抽象 + types/ # TypeScript 类型声明 + lib/ # 通用工具函数(cn、utils)及数据生成器定义 + workers/ # Web Worker(数据生成等耗时任务) +spec/ # 功能规格、修复方案与验收标准(见 spec/README.md) +public/ # 静态资源(图标等) +.wxt/ # wxt prepare 自动生成,含类型声明与扩展 tsconfig(勿手动编辑) +.output/ # 生产构建输出目录 ``` +### layout/ + +应用壳层,与 popup / sidepanel / tab 入口绑定。当前含 `TopBar/`(搜索、主题切换、返回导航、「在标签页打开」),遵循与 `pages/` 相同的 UI + Hook 模式。详见 [layout/README.md](./src/layout/README.md)。 + +### spec/ + +功能规格与验收标准文档,重大改动前优先查阅。索引见 [spec/README.md](./spec/README.md)。 + ### 页面组件模式 典型功能页面遵循 **UI + Hook 分离** 模式。详见 [CODING_STANDARDS.md § 11](./.github/CODING_STANDARDS.md#11-页面开发规范)。 @@ -73,7 +89,7 @@ src/pages/FeatureName/ - 页面入口组件统一命名为 `Index`,通过 `export default function Index()` 导出 - Hook 负责所有状态管理和业务逻辑,通过返回值暴露给页面 -- 子组件可以独立调用 `useI18n` 等全局 Hook +- 子组件可以独立调用全局 Hook - 当 `index.tsx` 超过 150 行时,必须拆分为 UI + Hook 模式 - 复杂页面可增加 `contexts/`、`hooks/`、`components/` 子目录 @@ -112,60 +128,49 @@ src/types/ ## 关键架构决策 **路由**: 不使用 React Router。通过 `src/config/features.tsx` 的 `FEATURES` 数组管理,`RouterProvider` 根据 `PageType` -渲染对应组件。支持三种渲染模式:popup(弹窗)、sidepanel(侧边栏)和 browser-tab(浏览器新标签页,通过 `open_in_tab` 打开)。 +渲染对应组件。支持三种渲染模式:popup(弹窗)、sidepanel(侧边栏)和 tab(浏览器新标签页,TopBar「在标签页打开」调用 `openExtensionPage('popup.html', { mode: 'tab' })`,由 `getEntryPointType()` 根据 URL 参数 `mode=tab` 识别)。 每种模式有独立的路由和可见页面配置(`app/popupRoute`、`app/sidepanelRoute`、`app/tabRoute` 等)。 **存储**: 所有 Chrome Storage 键必须在 `src/types/storage.d.ts` 的 `StorageSchema` 中定义,键名使用 kebab-case 格式(如 `app/currentRoute`)。 -使用 `src/utils/chromeStorage.ts` 及其 Hook。Router 同时使用 `chrome.storage.local` 和 `localStorage` 做快照以消除首屏闪烁。 -修改 StorageSchema 时,必须在 `src/utils/chromeStorage.ts` 添加版本迁移函数,并在测试中覆盖迁移场景。 +使用 `src/utils/chromeStorage.ts` 及其 Hook。Router 同时使用 `chrome.storage.local` 和 `localStorage` 快照(`snapshot/{key}`)消除首屏闪烁。 +异步加载完成前禁止写入 storage(`RouterProvider` 的 `canPersistRef`、`useStorageState` 的 `loadSucceededRef`),避免默认值覆盖已有数据。 **通信**: 使用 `@webext-core/messaging`,协议定义在 `src/utils/messages.ts`。 -**路径别名**: `@/` 映射到项目根目录 (已在 tsconfig 和 vitest.config 中配置)。 +**路径别名**: `@/` 映射到 `src/` 目录(已在 `.wxt/tsconfig.json` 和 `vitest.config.ts` 中配置)。项目根目录使用 `@@/`。 **浏览器兼容**: 优先使用 `wxt/browser` 导出的 `browser` 对象,而非原生 `chrome` API。 -**代码分割**: `wxt.config.ts` 通过 `manualChunksForHtmlOnly()` 自动分组依赖(vendor-react、vendor-qr、vendor-dnd 等),无需手动配置。 - ## 测试环境 - 环境: jsdom - 全局变量: `vitest/globals` (describe, it, expect 等无需导入) - Setup 文件: `vitest.setup.ts` 自动 mock: - `chrome.*` / `browser.*` API (storage, tabs, runtime, cookies 等) - - `@/utils/chromeI18n` (从 `public/_locales/zh_CN/messages.json` 加载真实翻译) - `window.matchMedia` - 测试文件命名: `__tests__/*.test.{ts,tsx}` 或 `*.test.{ts,tsx}` - Mock 模式: 使用 `vi.mock()` 进行模块级 mock,避免在测试文件中重复 mock 代码 - 测试工具: `@testing-library/react` + `@testing-library/user-event` 进行组件测试 -## i18n (chrome.i18n) +## UI 文案 -项目使用 Chrome 扩展标准的 `chrome.i18n` API 进行本地化,通过 `src/utils/chromeI18n.ts` 提供类型安全的 React Hook 包装。 +项目已移除 `chrome.i18n`,UI 文案直接在代码中使用中文。 -- **翻译文件**: `public/_locales/zh_CN/messages.json`(Chrome 扩展标准格式) -- **默认语言**: `zh_CN`(在 `wxt.config.ts` 的 `manifest.default_locale` 中配置) -- **使用方式**: `import { useI18n } from '@/utils/chromeI18n'` -- **翻译键格式**: - - 直接 key: `t('dashboard_title')` → 查找 `dashboard_title` - - 命名空间格式(兼容旧用法): `t('common:buttons.search')` → 查找 `common_buttons_search` - - 带命名空间参数: `useI18n(['common', 'features'])`,会自动尝试 `common_key`、`features_key` -- **占位符支持**: `t('router_notFoundDescription', { entryPointType: 'popup' })` -- **Hook 返回值**: `{ t, i18n: { language, changeLanguage }, isLoaded }` -- **回退策略**: 当翻译 key 未命中时,返回 key 本身(开发模式下在控制台记录 warning) -- **限制**: `chrome.i18n` 无法动态切换语言,语言跟随浏览器设置,切换后需刷新页面 +- **功能元数据**: `src/config/features.tsx` 的 `FEATURES` 数组定义 `label`、`description`(用于 Dashboard 卡片与搜索) +- **页面文案**: 在组件 JSX、`constants.ts` 或 Hook 中直接写中文 +- **Manifest 文案**: 扩展名称与描述在 `wxt.config.ts` 的 `manifest` 中维护 +- **Toast / 错误提示**: 在 Hook 或 `constants.ts` 中定义,使用 `sonner` 的 `toast()` 展示 ## 新功能开发清单 1. 在 `src/types/storage.d.ts` 添加 `PageType` 联合类型 -2. 在 `src/config/features.tsx` 的 `FEATURES` 数组添加配置(指定 key、翻译键、图标、三种渲染模式的组件) +2. 在 `src/config/features.tsx` 的 `FEATURES` 数组添加配置(指定 key、label、description、图标、三种渲染模式的组件) 3. 在 `src/pages/` 创建页面组件 (懒加载): - - `index.tsx` — UI 组件,使用 `useI18n` 获取翻译 + - `index.tsx` — UI 组件(纯展示) - `useFeatureName.ts` — 业务逻辑 Hook - - `constants.ts` — 常量(可选) -4. 在 `public/_locales/zh_CN/messages.json` 添加翻译 -5. 如需新权限,更新 `wxt.config.ts` 的 `manifest.permissions` -6. 添加对应的单元测试 + - `constants.ts` — 常量定义(可选,≥3 个常量时创建) +4. 如需新权限,更新 `wxt.config.ts` 的 `manifest.permissions` +5. 添加对应的单元测试 ## 代码规范 @@ -174,9 +179,9 @@ src/types/ - 样式: 使用 Tailwind CSS + shadcn/ui (通过 `className` 和 `cn()` 工具) - UI 组件: 优先使用 `src/components/ui/` 下的 shadcn/ui 组件 (button, dialog, select 等) - 图标: 使用 `lucide-react` 图标库 -- 格式: Prettier (`.prettierrc`: 100 字符宽, 单引号, 尾逗号 all, LF 换行) -- ESLint 使用 `typescript-eslint` 的 `projectService: true`(无需手动维护 project 路径) -- **Git Commit**: 必须使用中文描述,遵循 Conventional Commits 规范(如 `fix(组件名): 描述`、`feat(功能名): 描述`) +- 格式: Prettier [配置](./.prettierrc) +- ESLint 使用 `typescript-eslint` 的 `projectService: true` +- Git Commit: 使用中文描述,遵循 [Conventional Commits 规范](https://www.conventionalcommits.org/zh-hans/v1.0.0/) ## 关键外部库(非显而易见的) @@ -184,5 +189,4 @@ src/types/ - `@dnd-kit` — 拖拽排序(用于页面顺序管理和字段列表排序) - `qrious` + `qr-scanner` — 二维码生成与解析 - `dayjs` — 日期处理(时间戳转换) -- `sonner` — Toast 通知(替代传统 snackbar) -- Web Worker — 批量数据生成(`src/workers/generator.worker.ts`),避免阻塞 UI 线程 +- `sonner` — Toast 通知 diff --git a/CLAUDE.md b/CLAUDE.md index 39c76db..43c994c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1 @@ -@AGENT.md +@AGENTS.md diff --git a/README.md b/README.md index 43f7c39..8df269e 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ - **UI 组件**: shadcn/ui (基于 Radix UI 的无头组件库) - **样式**: Tailwind CSS + class-variance-authority + cn() 工具函数 - **日期处理**: dayjs (集成 UTC 和 Timezone 插件) -- **国际化**: Chrome `chrome.i18n` + `public/_locales/zh_CN/messages.json` +- **UI 文案**: 组件内直接使用中文字符串;功能名称与描述定义在 `src/config/features.tsx` - **通信**: @webext-core/messaging - **存储**: Chrome Storage API (类型安全封装) - **解析引擎**: qr-scanner (二维码解析), qrious (二维码生成) @@ -108,7 +108,7 @@ │ ├── utils/ # 工具函数与服务抽象 │ ├── types/ # TypeScript 类型声明 │ └── lib/ # 通用工具函数与生成器库 (cn, utils, generators 等) -├── public/ # 静态资源 (图标、_locales 本地化资源等) +├── public/ # 静态资源 (图标等) ├── wxt.config.ts # WXT 框架核心配置 └── package.json # 项目元数据与依赖管理 ``` diff --git a/docs/VISUAL_STYLE_GUIDE.md b/docs/VISUAL_STYLE_GUIDE.md index ceb8169..dc59cdd 100644 --- a/docs/VISUAL_STYLE_GUIDE.md +++ b/docs/VISUAL_STYLE_GUIDE.md @@ -724,17 +724,14 @@ src/ ```tsx // src/pages/NewTool/index.tsx import SwitchButtonGroup from '@/components/SwitchButtonGroup'; -import { useI18n } from '@/utils/chromeI18n'; import { cn } from '@/lib/utils'; export default function Index() { - const { t } = useI18n('newTool'); - return ( -
+
{/* 页面内容 */}
-

{t('newTool:title')}

+

新工具

); diff --git a/public/README.md b/public/README.md index 9be021a..e535947 100644 --- a/public/README.md +++ b/public/README.md @@ -12,24 +12,9 @@ | `icon/48.png` | 48×48 图标(扩展管理页) | | `icon/96.png` | 96×96 图标 | | `icon/128.png` | 128×128 图标(Chrome Web Store) | -| `_locales/` | Chrome 扩展本地化资源 | - -## 本地化资源 - -项目使用 Chrome 扩展标准 `chrome.i18n`,默认语言在 `wxt.config.ts` 中配置为 `zh_CN`。 - -| 文件/目录 | 用途 | -| ------------------------------ | -------------------------------------------------- | -| `_locales/zh_CN/messages.json` | 默认中文语言包,包含功能名、描述、按钮、提示等翻译 | - -添加或修改文案时: - -- 使用 Chrome 扩展消息格式:`"key": { "message": "文本" }` -- key 使用下划线分隔,例如 `testDataGenerator_title` -- 代码中通过 `useI18n` 或 `getMessage` 读取,不要新增 `i18n/locales` 目录 ## 注意事项 - 修改图标后需同步更新 `wxt.config.ts` 中的 manifest 配置 - 图标格式推荐使用 PNG,确保透明背景 -- 修改 `_locales` 后需确认 `manifest.default_locale` 与语言目录名一致 +- UI 文案不在此目录维护,见 `src/config/features.tsx` 与各页面组件 diff --git a/public/_locales/zh_CN/messages.json b/public/_locales/zh_CN/messages.json deleted file mode 100644 index 9717b5d..0000000 --- a/public/_locales/zh_CN/messages.json +++ /dev/null @@ -1,1304 +0,0 @@ -{ - "buttons_copy": { - "message": "复制", - "description": "Translation key: buttons_copy" - }, - "buttons_clear": { - "message": "清理", - "description": "Translation key: buttons_clear" - }, - "messages_copySuccess": { - "message": "已复制到剪贴板", - "description": "Translation key: messages_copySuccess" - }, - "messages_copyError": { - "message": "复制失败", - "description": "Translation key: messages_copyError" - }, - "messages_copyEmpty": { - "message": "无内容可复制", - "description": "Translation key: messages_copyEmpty" - }, - "errorBoundary_title": { - "message": "糟糕,出了点问题", - "description": "Translation key: errorBoundary_title" - }, - "errorBoundary_description": { - "message": "应用遇到了一些意外错误。您可以尝试刷新页面或重置应用。", - "description": "Translation key: errorBoundary_description" - }, - "errorBoundary_refresh": { - "message": "刷新应用", - "description": "Translation key: errorBoundary_refresh" - }, - "errorBoundary_retry": { - "message": "重新尝试", - "description": "Translation key: errorBoundary_retry" - }, - "pageErrorBoundary_title": { - "message": "该功能运行异常", - "description": "Translation key: pageErrorBoundary_title" - }, - "pageErrorBoundary_description": { - "message": "该页面在加载或渲染时遇到了内部脚本错误。您可以尝试重试,或者通过导航菜单切换到其他工具。", - "description": "Translation key: pageErrorBoundary_description" - }, - "router_notFound": { - "message": "页面未找到", - "description": "Translation key: router_notFound" - }, - "router_notFoundDescription": { - "message": "该功能在当前运行环境({{entryPointType}})下不可用或已被移除。", - "description": "Translation key: router_notFoundDescription" - }, - "textInputArea_clear": { - "message": "清空", - "description": "Translation key: textInputArea_clear" - }, - "textInputArea_copyContent": { - "message": "复制内容", - "description": "Translation key: textInputArea_copyContent" - }, - "textInputArea_cleared": { - "message": "已清空", - "description": "Translation key: textInputArea_cleared" - }, - "textInputArea_placeholder": { - "message": "请输入文本", - "description": "Translation key: textInputArea_placeholder" - }, - "dashboard_title": { - "message": "仪表盘", - "description": "Translation key: dashboard_title" - }, - "dashboard_searchPlaceholder": { - "message": "搜索工具...", - "description": "Translation key: dashboard_searchPlaceholder" - }, - "dashboard_recentlyUsed": { - "message": "最近使用", - "description": "Translation key: dashboard_recentlyUsed" - }, - "dashboard_allTools": { - "message": "全部工具", - "description": "Translation key: dashboard_allTools" - }, - "timestamp_title": { - "message": "时间戳", - "description": "Translation key: timestamp_title" - }, - "timestamp_description": { - "message": "Unix 毫秒数转换与格式化", - "description": "Translation key: timestamp_description" - }, - "storageCleaner_title": { - "message": "存储清理", - "description": "Translation key: storageCleaner_title" - }, - "storageCleaner_description": { - "message": "清理缓存、Cookies 及本地存储", - "description": "Translation key: storageCleaner_description" - }, - "qrCode_title": { - "message": "二维码工具", - "description": "Translation key: qrCode_title" - }, - "qrCode_description": { - "message": "生成当前选中的 URL 的二维码", - "description": "Translation key: qrCode_description" - }, - "textStatistics_title": { - "message": "文本统计", - "description": "Translation key: textStatistics_title" - }, - "textStatistics_description": { - "message": "实时分析文本字符、单词及字节", - "description": "Translation key: textStatistics_description" - }, - "jwt_title": { - "message": "JWT 解析", - "description": "Translation key: jwt_title" - }, - "jwt_description": { - "message": "JSON Web Token 解码与查看", - "description": "Translation key: jwt_description" - }, - "jsonDiff_title": { - "message": "JSON 工具", - "description": "Translation key: jsonDiff_title" - }, - "jsonDiff_description": { - "message": "差异比较、格式化、YAML/TOML 转换及压缩", - "description": "Translation key: jsonDiff_description" - }, - "base64Converter_title": { - "message": "Base64 转换器", - "description": "Translation key: base64Converter_title" - }, - "base64Converter_description": { - "message": "文本、文件与图像的 Base64 编码转换", - "description": "Translation key: base64Converter_description" - }, - "rightClickRestorer_title": { - "message": "右键恢复", - "description": "Translation key: rightClickRestorer_title" - }, - "rightClickRestorer_description": { - "message": "检测并恢复被网站禁用的浏览器右键菜单", - "description": "Translation key: rightClickRestorer_description" - }, - "base64Converter_pageTitle": { - "message": "Base64 转换器", - "description": "Translation key: base64Converter_pageTitle" - }, - "base64Converter_textMode": { - "message": "文本", - "description": "Translation key: base64Converter_textMode" - }, - "base64Converter_fileMode": { - "message": "文件", - "description": "Translation key: base64Converter_fileMode" - }, - "base64Converter_imageMode": { - "message": "图像", - "description": "Translation key: base64Converter_imageMode" - }, - "base64Converter_encode": { - "message": "编码", - "description": "Translation key: base64Converter_encode" - }, - "base64Converter_decode": { - "message": "解码", - "description": "Translation key: base64Converter_decode" - }, - "base64Converter_clear": { - "message": "清空", - "description": "Translation key: base64Converter_clear" - }, - "base64Converter_textInputPlaceholder": { - "message": "输入需要编码为 Base64 的文本...", - "description": "Translation key: base64Converter_textInputPlaceholder" - }, - "base64Converter_base64InputPlaceholder": { - "message": "输入需要解码的 Base64 字符串...", - "description": "Translation key: base64Converter_base64InputPlaceholder" - }, - "base64Converter_base64Output": { - "message": "Base64 编码结果", - "description": "Translation key: base64Converter_base64Output" - }, - "base64Converter_textOutput": { - "message": "解码文本结果", - "description": "Translation key: base64Converter_textOutput" - }, - "base64Converter_copyRaw": { - "message": "复制纯 Base64", - "description": "Translation key: base64Converter_copyRaw" - }, - "base64Converter_copyDataUri": { - "message": "复制 Data URI", - "description": "Translation key: base64Converter_copyDataUri" - }, - "base64Converter_clickOrDropToFile": { - "message": "点击或拖拽文件到此处", - "description": "Translation key: base64Converter_clickOrDropToFile" - }, - "base64Converter_clickOrDropToImage": { - "message": "点击或拖拽图像到此处", - "description": "Translation key: base64Converter_clickOrDropToImage" - }, - "base64Converter_clickOrDropToReplace": { - "message": "点击或拖拽以替换文件", - "description": "Translation key: base64Converter_clickOrDropToReplace" - }, - "base64Converter_maxFileSize": { - "message": "最大文件大小:{{max}}", - "description": "Translation key: base64Converter_maxFileSize" - }, - "base64Converter_supportedFormats": { - "message": "支持 PNG、JPG、WEBP、GIF、BMP、SVG 等格式", - "description": "Translation key: base64Converter_supportedFormats" - }, - "base64Converter_fileSizeExceeded": { - "message": "文件大小超出限制(最大 {{max}})", - "description": "Translation key: base64Converter_fileSizeExceeded" - }, - "base64Converter_unsupportedImageType": { - "message": "不支持的图像格式", - "description": "Translation key: base64Converter_unsupportedImageType" - }, - "base64Converter_conversionFailed": { - "message": "转换失败", - "description": "Translation key: base64Converter_conversionFailed" - }, - "base64Converter_originalSize": { - "message": "原始大小", - "description": "Translation key: base64Converter_originalSize" - }, - "base64Converter_encodedSize": { - "message": "编码大小", - "description": "Translation key: base64Converter_encodedSize" - }, - "base64Converter_invalidBase64": { - "message": "Base64 字符串无效", - "description": "Translation key: base64Converter_invalidBase64" - }, - "base64Converter_binaryDataDetected": { - "message": "输入似乎是二进制数据(如图片)。请切换到「图像」选项卡。", - "description": "Translation key: base64Converter_binaryDataDetected" - }, - "base64Converter_imageDataUriHint": { - "message": "检测到图片的 data URI,请使用「图像」选项卡进行解码。", - "description": "Translation key: base64Converter_imageDataUriHint" - }, - "base64Converter_switchToImageMode": { - "message": "切换到图像模式", - "description": "Translation key: base64Converter_switchToImageMode" - }, - "base64Converter_download": { - "message": "下载", - "description": "Translation key: base64Converter_download" - }, - "base64Converter_decodedFileName": { - "message": "解码后文件名", - "description": "Translation key: base64Converter_decodedFileName" - }, - "base64Converter_decodeBase64Placeholder": { - "message": "输入需要解码的 Base64 或 data URI...", - "description": "Translation key: base64Converter_decodeBase64Placeholder" - }, - "base64Converter_decodedFileOutput": { - "message": "解码文件", - "description": "Translation key: base64Converter_decodedFileOutput" - }, - "base64Converter_decodedImageOutput": { - "message": "解码图像", - "description": "Translation key: base64Converter_decodedImageOutput" - }, - "base64Converter_inferredMimeType": { - "message": "推断的 MIME 类型", - "description": "Translation key: base64Converter_inferredMimeType" - }, - "base64Converter_decodedSize": { - "message": "解码大小", - "description": "Translation key: base64Converter_decodedSize" - }, - "jsonDiff_pageTitle": { - "message": "JSON 差异比较", - "description": "Translation key: jsonDiff_pageTitle" - }, - "jsonDiff_leftPlaceholder": { - "message": "输入原始 JSON...", - "description": "Translation key: jsonDiff_leftPlaceholder" - }, - "jsonDiff_rightPlaceholder": { - "message": "输入目标 JSON...", - "description": "Translation key: jsonDiff_rightPlaceholder" - }, - "jsonDiff_leftLabel": { - "message": "原始 JSON", - "description": "Translation key: jsonDiff_leftLabel" - }, - "jsonDiff_rightLabel": { - "message": "目标 JSON", - "description": "Translation key: jsonDiff_rightLabel" - }, - "jsonDiff_sideBySideMode": { - "message": "并排", - "description": "Translation key: jsonDiff_sideBySideMode" - }, - "jsonDiff_unifiedMode": { - "message": "统一", - "description": "Translation key: jsonDiff_unifiedMode" - }, - "jsonDiff_previousDiff": { - "message": "上一个", - "description": "Translation key: jsonDiff_previousDiff" - }, - "jsonDiff_nextDiff": { - "message": "下一个", - "description": "Translation key: jsonDiff_nextDiff" - }, - "jsonDiff_noDiffs": { - "message": "无差异", - "description": "Translation key: jsonDiff_noDiffs" - }, - "jsonDiff_invalidJson": { - "message": "无效的 JSON 格式", - "description": "Translation key: jsonDiff_invalidJson" - }, - "jsonDiff_emptyHint": { - "message": "输入两侧 JSON 后点击比较", - "description": "Translation key: jsonDiff_emptyHint" - }, - "jsonDiff_fixErrorHint": { - "message": "请修正上方 JSON 的语法错误以开启实时流式比对", - "description": "Translation key: jsonDiff_fixErrorHint" - }, - "jsonDiff_added": { - "message": "新增", - "description": "Translation key: jsonDiff_added" - }, - "jsonDiff_removed": { - "message": "删除", - "description": "Translation key: jsonDiff_removed" - }, - "jsonDiff_modified": { - "message": "修改", - "description": "Translation key: jsonDiff_modified" - }, - "jsonFormat_inputPlaceholder": { - "message": "输入需要格式化的 JSON...", - "description": "Translation key: jsonFormat_inputPlaceholder" - }, - "jsonFormat_sortKeys": { - "message": "键名排序", - "description": "Translation key: jsonFormat_sortKeys" - }, - "jsonFormat_indentSize": { - "message": "缩进", - "description": "Translation key: jsonFormat_indentSize" - }, - "jsonFormat_outputLabel": { - "message": "格式化结果", - "description": "Translation key: jsonFormat_outputLabel" - }, - "jsonFormat_invalidJson": { - "message": "无效的 JSON 格式", - "description": "Translation key: jsonFormat_invalidJson" - }, - "jsonFormat_emptyHint": { - "message": "输入 JSON 后点击格式化", - "description": "Translation key: jsonFormat_emptyHint" - }, - "jsonFormat_fixErrorHint": { - "message": "请修正上方 JSON 的语法错误以开启实时流式格式化", - "description": "Translation key: jsonFormat_fixErrorHint" - }, - "jsonFormat_originalSize": { - "message": "原始大小", - "description": "Translation key: jsonFormat_originalSize" - }, - "jsonFormat_formattedSize": { - "message": "格式化后大小", - "description": "Translation key: jsonFormat_formattedSize" - }, - "jsonFormat_diffMode": { - "message": "差异比较", - "description": "Translation key: jsonFormat_diffMode" - }, - "jsonFormat_formatMode": { - "message": "格式化", - "description": "Translation key: jsonFormat_formatMode" - }, - "jsonFormat_yamlMode": { - "message": "YAML", - "description": "Translation key: jsonFormat_yamlMode" - }, - "jsonFormat_tomlMode": { - "message": "TOML", - "description": "Translation key: jsonFormat_tomlMode" - }, - "jsonFormat_minifyMode": { - "message": "压缩", - "description": "Translation key: jsonFormat_minifyMode" - }, - "jsonFormat_yamlModeInputPlaceholder": { - "message": "输入需要转换的 JSON...", - "description": "Translation key: jsonFormat_yamlModeInputPlaceholder" - }, - "jsonFormat_yamlModeOutputLabel": { - "message": "YAML 结果", - "description": "Translation key: jsonFormat_yamlModeOutputLabel" - }, - "jsonFormat_yamlModeEmptyHint": { - "message": "输入 JSON 后点击转换", - "description": "Translation key: jsonFormat_yamlModeEmptyHint" - }, - "jsonFormat_tomlModeInputPlaceholder": { - "message": "输入需要转换的 JSON...", - "description": "Translation key: jsonFormat_tomlModeInputPlaceholder" - }, - "jsonFormat_tomlModeOutputLabel": { - "message": "TOML 结果", - "description": "Translation key: jsonFormat_tomlModeOutputLabel" - }, - "jsonFormat_tomlModeEmptyHint": { - "message": "输入 JSON 后点击转换", - "description": "Translation key: jsonFormat_tomlModeEmptyHint" - }, - "jsonFormat_minifyModeInputPlaceholder": { - "message": "输入需要压缩的 JSON...", - "description": "Translation key: jsonFormat_minifyModeInputPlaceholder" - }, - "jsonFormat_minifyModeOutputLabel": { - "message": "压缩结果", - "description": "Translation key: jsonFormat_minifyModeOutputLabel" - }, - "jsonFormat_minifyModeEmptyHint": { - "message": "输入 JSON 后点击压缩", - "description": "Translation key: jsonFormat_minifyModeEmptyHint" - }, - "jwt_pageTitle": { - "message": "JWT 解析", - "description": "Translation key: jwt_pageTitle" - }, - "jwt_placeholder": { - "message": "在此粘贴 JWT 令牌 (Encoded JWT)...", - "description": "Translation key: jwt_placeholder" - }, - "jwt_headerTitle": { - "message": "HEADER: 算法 & 令牌类型", - "description": "Translation key: jwt_headerTitle" - }, - "jwt_payloadTitle": { - "message": "PAYLOAD: 数据", - "description": "Translation key: jwt_payloadTitle" - }, - "jwt_signatureTitle": { - "message": "签名", - "description": "Translation key: jwt_signatureTitle" - }, - "jwt_noSignature": { - "message": "无签名", - "description": "Translation key: jwt_noSignature" - }, - "jwt_invalidFormat": { - "message": "无法解析", - "description": "Translation key: jwt_invalidFormat" - }, - "jwt_errors_invalidBase64String": { - "message": "无效的 Base64URL 字符串", - "description": "Translation key: jwt_errors_invalidBase64String" - }, - "jwt_errors_failedToDecode": { - "message": "Base64URL 解码失败:", - "description": "Translation key: jwt_errors_failedToDecode" - }, - "jwt_errors_invalidFormat": { - "message": "JWT 格式错误:必须包含三个由 . 分隔的部分", - "description": "Translation key: jwt_errors_invalidFormat" - }, - "jwt_errors_parseHeaderFailed": { - "message": "解析 Header 失败:", - "description": "Translation key: jwt_errors_parseHeaderFailed" - }, - "jwt_errors_parsePayloadFailed": { - "message": "解析 Payload 失败:", - "description": "Translation key: jwt_errors_parsePayloadFailed" - }, - "qrCode_pageTitle": { - "message": "二维码工具", - "description": "Translation key: qrCode_pageTitle" - }, - "qrCode_urlToQr": { - "message": "文本转二维码", - "description": "Translation key: qrCode_urlToQr" - }, - "qrCode_qrToUrl": { - "message": "二维码转文本", - "description": "Translation key: qrCode_qrToUrl" - }, - "qrCode_urlInputLabel": { - "message": "输入 URL 或文本", - "description": "Translation key: qrCode_urlInputLabel" - }, - "qrCode_urlInputPlaceholder": { - "message": "请输入 URL 或文本内容,将自动生成二维码", - "description": "Translation key: qrCode_urlInputPlaceholder" - }, - "qrCode_generating": { - "message": "生成中...", - "description": "Translation key: qrCode_generating" - }, - "qrCode_qrCodeWillShow": { - "message": "二维码将显示在这里", - "description": "Translation key: qrCode_qrCodeWillShow" - }, - "qrCode_downloadButton": { - "message": "下载二维码", - "description": "Translation key: qrCode_downloadButton" - }, - "qrCode_copyQrButton": { - "message": "复制二维码", - "description": "Translation key: qrCode_copyQrButton" - }, - "qrCode_qrCodeDownloadSuccess": { - "message": "二维码下载成功", - "description": "Translation key: qrCode_qrCodeDownloadSuccess" - }, - "qrCode_qrCodeCopySuccess": { - "message": "二维码已复制到剪贴板", - "description": "Translation key: qrCode_qrCodeCopySuccess" - }, - "qrCode_parseSuccess": { - "message": "二维码解析成功", - "description": "Translation key: qrCode_parseSuccess" - }, - "qrCode_noQrDetected": { - "message": "未检测到二维码,请确保图片清晰且包含二维码", - "description": "Translation key: qrCode_noQrDetected" - }, - "qrCode_parseError": { - "message": "解析二维码失败,请重试", - "description": "Translation key: qrCode_parseError" - }, - "qrCode_copyError": { - "message": "复制失败,请重试", - "description": "Translation key: qrCode_copyError" - }, - "qrCode_imagePasted": { - "message": "图片粘贴成功,正在解析...", - "description": "Translation key: qrCode_imagePasted" - }, - "qrCode_imagePasteError": { - "message": "粘贴图片失败,请重试", - "description": "Translation key: qrCode_imagePasteError" - }, - "qrCode_imageCleared": { - "message": "图片已清除", - "description": "Translation key: qrCode_imageCleared" - }, - "qrCode_clickToUpload": { - "message": "点击、拖拽或粘贴上传二维码图片", - "description": "Translation key: qrCode_clickToUpload" - }, - "qrCode_supportFormats": { - "message": "支持 PNG、JPG、WEBP、Base64 格式", - "description": "Translation key: qrCode_supportFormats" - }, - "qrCode_resultLabel": { - "message": "解析结果", - "description": "Translation key: qrCode_resultLabel" - }, - "qrCode_clickToChange": { - "message": "点击更换图片", - "description": "Translation key: qrCode_clickToChange" - }, - "qrCode_generateButton": { - "message": "生成二维码", - "description": "Translation key: qrCode_generateButton" - }, - "qrCode_editButton": { - "message": "编辑", - "description": "Translation key: qrCode_editButton" - }, - "qrCode_textPreviewLabel": { - "message": "原始文本", - "description": "Translation key: qrCode_textPreviewLabel" - }, - "qrCode_generateFirstHint": { - "message": "输入文本后点击「生成二维码」按钮", - "description": "Translation key: qrCode_generateFirstHint" - }, - "qrCode_inputRequired": { - "message": "请输入内容", - "description": "Translation key: qrCode_inputRequired" - }, - "qrCode_generateError": { - "message": "生成二维码失败,请重试", - "description": "Translation key: qrCode_generateError" - }, - "qrCode_uploadedImage": { - "message": "已上传图片", - "description": "Translation key: qrCode_uploadedImage" - }, - "qrCode_reuploadButton": { - "message": "重新上传", - "description": "Translation key: qrCode_reuploadButton" - }, - "qrCode_parsing": { - "message": "解析中...", - "description": "Translation key: qrCode_parsing" - }, - "qrCode_resultPlaceholder": { - "message": "解析结果将显示在此处", - "description": "Translation key: qrCode_resultPlaceholder" - }, - "qrCode_imageToQr": { - "message": "解析图片二维码", - "description": "Translation key: qrCode_imageToQr" - }, - "rightClickRestorer_loading": { - "message": "正在加载...", - "description": "Translation key: rightClickRestorer_loading" - }, - "rightClickRestorer_currentDomain": { - "message": "当前域名", - "description": "Translation key: rightClickRestorer_currentDomain" - }, - "rightClickRestorer_statusLocked": { - "message": "未解锁", - "description": "Translation key: rightClickRestorer_statusLocked" - }, - "rightClickRestorer_statusUnlocked": { - "message": "已解锁", - "description": "Translation key: rightClickRestorer_statusUnlocked" - }, - "rightClickRestorer_unsupported": { - "message": "不支持", - "description": "Translation key: rightClickRestorer_unsupported" - }, - "rightClickRestorer_unsupportedDesc": { - "message": "当前页面为浏览器内部页面或扩展页面,无法解锁右键功能。请切换到普通网页后重试。", - "description": "Translation key: rightClickRestorer_unsupportedDesc" - }, - "rightClickRestorer_unlockDesc": { - "message": "点击下方按钮,为当前网站临时解锁右键菜单。刷新页面后需要重新解锁。", - "description": "Translation key: rightClickRestorer_unlockDesc" - }, - "rightClickRestorer_unlockBtn": { - "message": "解锁当前网站右键", - "description": "Translation key: rightClickRestorer_unlockBtn" - }, - "rightClickRestorer_alreadyUnlocked": { - "message": "右键已解锁", - "description": "Translation key: rightClickRestorer_alreadyUnlocked" - }, - "storageCleaner_pageTitle": { - "message": "存储清理", - "description": "Translation key: storageCleaner_pageTitle" - }, - "storageCleaner_loading": { - "message": "加载中...", - "description": "Translation key: storageCleaner_loading" - }, - "storageCleaner_initializing": { - "message": "正在读取站点数据...", - "description": "Translation key: storageCleaner_initializing" - }, - "storageCleaner_cleaning": { - "message": "正在清理...", - "description": "Translation key: storageCleaner_cleaning" - }, - "storageCleaner_cleanNow": { - "message": "立即清理", - "description": "Translation key: storageCleaner_cleanNow" - }, - "storageCleaner_autoRefresh": { - "message": "清理后自动刷新页面", - "description": "Translation key: storageCleaner_autoRefresh" - }, - "storageCleaner_selectAll": { - "message": "全选所有项", - "description": "Translation key: storageCleaner_selectAll" - }, - "storageCleaner_noData": { - "message": "无数据", - "description": "Translation key: storageCleaner_noData" - }, - "storageCleaner_errorNoTab": { - "message": "无法获取当前标签页", - "description": "Translation key: storageCleaner_errorNoTab" - }, - "storageCleaner_errorRestricted": { - "message": "存储清理功能不支持此页面", - "description": "Translation key: storageCleaner_errorRestricted" - }, - "storageCleaner_cleanSuccessReload": { - "message": "清理成功,即将刷新页面", - "description": "Translation key: storageCleaner_cleanSuccessReload" - }, - "storageCleaner_errorStandardOnly": { - "message": "存储清理功能仅适用于标准网页", - "description": "Translation key: storageCleaner_errorStandardOnly" - }, - "storageCleaner_confirmTitle": { - "message": "确认清理数据?", - "description": "Translation key: storageCleaner_confirmTitle" - }, - "storageCleaner_confirmDesc": { - "message": "您将永久删除当前页面的以下选定存储项。", - "description": "Translation key: storageCleaner_confirmDesc" - }, - "storageCleaner_irreversible": { - "message": "此操作不可撤销", - "description": "Translation key: storageCleaner_irreversible" - }, - "storageCleaner_confirmAction": { - "message": "确认清理", - "description": "Translation key: storageCleaner_confirmAction" - }, - "storageCleaner_cleanedSummary": { - "message": "清理了 {{items}}", - "description": "Translation key: storageCleaner_cleanedSummary" - }, - "storageCleaner_noDataToClean": { - "message": "该页面没有可清理的存储数据", - "description": "Translation key: storageCleaner_noDataToClean" - }, - "storageCleaner_partialFailure": { - "message": "部分清理失败", - "description": "Translation key: storageCleaner_partialFailure" - }, - "storageCleaner_options_localStorage": { - "message": "Local Storage", - "description": "Translation key: storageCleaner_options_localStorage" - }, - "storageCleaner_options_sessionStorage": { - "message": "Session Storage", - "description": "Translation key: storageCleaner_options_sessionStorage" - }, - "storageCleaner_options_indexedDB": { - "message": "站点存储", - "description": "Translation key: storageCleaner_options_indexedDB" - }, - "storageCleaner_options_cookies": { - "message": "Cookies", - "description": "Translation key: storageCleaner_options_cookies" - }, - "storageCleaner_options_cacheStorage": { - "message": "Cache Storage", - "description": "Translation key: storageCleaner_options_cacheStorage" - }, - "storageCleaner_options_serviceWorkers": { - "message": "Service Workers", - "description": "Translation key: storageCleaner_options_serviceWorkers" - }, - "textStatistics_pageTitle": { - "message": "文本统计", - "description": "Translation key: textStatistics_pageTitle" - }, - "textStatistics_placeholder": { - "message": "在此输入或粘贴文本...", - "description": "Translation key: textStatistics_placeholder" - }, - "textStatistics_characters": { - "message": "字符数", - "description": "Translation key: textStatistics_characters" - }, - "textStatistics_words": { - "message": "单词数", - "description": "Translation key: textStatistics_words" - }, - "textStatistics_lines": { - "message": "行数", - "description": "Translation key: textStatistics_lines" - }, - "textStatistics_bytes": { - "message": "字节大小", - "description": "Translation key: textStatistics_bytes" - }, - "timestamp_pageTitle": { - "message": "时间戳转换", - "description": "Translation key: timestamp_pageTitle" - }, - "timestamp_tsToDate": { - "message": "时间戳 → 日期", - "description": "Translation key: timestamp_tsToDate" - }, - "timestamp_dateToTs": { - "message": "日期 → 时间戳", - "description": "Translation key: timestamp_dateToTs" - }, - "timestamp_placeholderTs": { - "message": "输入时间戳...", - "description": "Translation key: timestamp_placeholderTs" - }, - "timestamp_placeholderDate": { - "message": "YYYY-MM-DD HH:mm:ss", - "description": "Translation key: timestamp_placeholderDate" - }, - "timestamp_unitMs": { - "message": "毫秒 (ms)", - "description": "Translation key: timestamp_unitMs" - }, - "timestamp_unitS": { - "message": "秒 (s)", - "description": "Translation key: timestamp_unitS" - }, - "timestamp_currentTs": { - "message": "当前时间戳", - "description": "Translation key: timestamp_currentTs" - }, - "timestamp_useNowTooltip": { - "message": "填充到下方", - "description": "Translation key: timestamp_useNowTooltip" - }, - "timestamp_copyTsTooltip": { - "message": "复制时间戳", - "description": "Translation key: timestamp_copyTsTooltip" - }, - "timestamp_usedSuccess": { - "message": "已使用当前时间戳", - "description": "Translation key: timestamp_usedSuccess" - }, - "timestamp_resultLabel": { - "message": "转换结果", - "description": "Translation key: timestamp_resultLabel" - }, - "timestamp_copyResultTooltip": { - "message": "复制结果", - "description": "Translation key: timestamp_copyResultTooltip" - }, - "timestamp_relativeTime": { - "message": "相对时间", - "description": "Translation key: timestamp_relativeTime" - }, - "timestamp_iso8601": { - "message": "ISO 8601", - "description": "Translation key: timestamp_iso8601" - }, - "timestamp_utcTime": { - "message": "UTC 时间", - "description": "Translation key: timestamp_utcTime" - }, - "timestamp_copyTooltip": { - "message": "复制", - "description": "Translation key: timestamp_copyTooltip" - }, - "timestamp_resultEmpty": { - "message": "请输入并点击转换", - "description": "Translation key: timestamp_resultEmpty" - }, - "common_buttons_cancel": { - "message": "取消", - "description": "Translation key: buttons_cancel" - }, - "common_buttons_copy": { - "message": "复制", - "description": "Translation key: buttons_copy" - }, - "common_buttons_themeMode_light": { - "message": "切换到深色模式", - "description": "Translation key: buttons_themeMode_light" - }, - "common_buttons_themeMode_dark": { - "message": "切换到系统模式", - "description": "Translation key: buttons_themeMode_dark" - }, - "common_buttons_themeMode_system": { - "message": "切换到浅色模式", - "description": "Translation key: buttons_themeMode_system" - }, - "common_buttons_search": { - "message": "搜索工具...", - "description": "Translation key: buttons_search" - }, - "common_buttons_back": { - "message": "返回", - "description": "Translation key: buttons_back" - }, - "common_buttons_clearSearch": { - "message": "清除搜索", - "description": "Translation key: buttons_clearSearch" - }, - "common_buttons_recentSearch": { - "message": "最近搜索", - "description": "Translation key: buttons_recentSearch" - }, - "common_buttons_noResults": { - "message": "未找到相关工具", - "description": "Translation key: buttons_noResults" - }, - "common_buttons_openInTab": { - "message": "在标签页打开", - "description": "Translation key: buttons_openInTab" - }, - "common_errorBoundary_title": { - "message": "糟糕,出了点问题", - "description": "Translation key: errorBoundary_title" - }, - "common_errorBoundary_description": { - "message": "应用遇到了一些意外错误。您可以尝试刷新页面或重置应用。", - "description": "Translation key: errorBoundary_description" - }, - "common_errorBoundary_refresh": { - "message": "刷新应用", - "description": "Translation key: errorBoundary_refresh" - }, - "common_errorBoundary_retry": { - "message": "重新尝试", - "description": "Translation key: errorBoundary_retry" - }, - "common_pageErrorBoundary_title": { - "message": "该功能运行异常", - "description": "Translation key: pageErrorBoundary_title" - }, - "common_pageErrorBoundary_description": { - "message": "该页面在加载或渲染时遇到了内部脚本错误。您可以尝试重试,或者通过导航菜单切换到其他工具。", - "description": "Translation key: pageErrorBoundary_description" - }, - "testDataGenerator_title": { - "message": "测试数据生成器", - "description": "Translation key: testDataGenerator_title" - }, - "testDataGenerator_description": { - "message": "自定义字段配置,批量生成测试数据", - "description": "Translation key: testDataGenerator_description" - }, - "testDataGenerator_fields": { - "message": "字段", - "description": "Translation key: testDataGenerator_fields" - }, - "testDataGenerator_addField": { - "message": "添加字段", - "description": "Translation key: testDataGenerator_addField" - }, - "testDataGenerator_noFields": { - "message": "暂无字段", - "description": "Translation key: testDataGenerator_noFields" - }, - "testDataGenerator_addFieldHint": { - "message": "点击上方按钮添加第一个字段", - "description": "Translation key: testDataGenerator_addFieldHint" - }, - "testDataGenerator_unique": { - "message": "唯一", - "description": "Translation key: testDataGenerator_unique" - }, - "testDataGenerator_fieldName": { - "message": "字段名称", - "description": "Translation key: testDataGenerator_fieldName" - }, - "testDataGenerator_fieldNamePlaceholder": { - "message": "请输入字段名称", - "description": "Translation key: testDataGenerator_fieldNamePlaceholder" - }, - "testDataGenerator_fieldDescription": { - "message": "字段描述", - "description": "Translation key: testDataGenerator_fieldDescription" - }, - "testDataGenerator_fieldDescriptionPlaceholder": { - "message": "可选,添加字段说明", - "description": "Translation key: testDataGenerator_fieldDescriptionPlaceholder" - }, - "testDataGenerator_required": { - "message": "必填", - "description": "Translation key: testDataGenerator_required" - }, - "testDataGenerator_nullRate": { - "message": "空值率", - "description": "Translation key: testDataGenerator_nullRate" - }, - "testDataGenerator_uniqueConstraint": { - "message": "唯一性约束", - "description": "Translation key: testDataGenerator_uniqueConstraint" - }, - "testDataGenerator_generator": { - "message": "数据生成器", - "description": "Translation key: testDataGenerator_generator" - }, - "testDataGenerator_generatorParams": { - "message": "生成器参数", - "description": "Translation key: testDataGenerator_generatorParams" - }, - "testDataGenerator_searchGenerator": { - "message": "搜索生成器...", - "description": "Translation key: testDataGenerator_searchGenerator" - }, - "testDataGenerator_searchResults": { - "message": "搜索结果", - "description": "Translation key: testDataGenerator_searchResults" - }, - "testDataGenerator_fieldConfig": { - "message": "字段配置", - "description": "Translation key: testDataGenerator_fieldConfig" - }, - "testDataGenerator_ruleManagement": { - "message": "规则管理", - "description": "Translation key: testDataGenerator_ruleManagement" - }, - "testDataGenerator_selectFieldToEdit": { - "message": "选择左侧字段进行编辑", - "description": "Translation key: testDataGenerator_selectFieldToEdit" - }, - "testDataGenerator_count": { - "message": "生成数量", - "description": "Translation key: testDataGenerator_count" - }, - "testDataGenerator_format": { - "message": "数据格式", - "description": "Translation key: testDataGenerator_format" - }, - "testDataGenerator_generate": { - "message": "生成数据", - "description": "Translation key: testDataGenerator_generate" - }, - "testDataGenerator_cancel": { - "message": "取消", - "description": "Translation key: testDataGenerator_cancel" - }, - "testDataGenerator_progress": { - "message": "已生成 {{current}} / {{total}} 条", - "description": "Translation key: testDataGenerator_progress" - }, - "testDataGenerator_estimatedTime": { - "message": "预计剩余 {{time}} 秒", - "description": "Translation key: testDataGenerator_estimatedTime" - }, - "testDataGenerator_dataPreview": { - "message": "数据预览", - "description": "Translation key: testDataGenerator_dataPreview" - }, - "testDataGenerator_noData": { - "message": "暂无数据", - "description": "Translation key: testDataGenerator_noData" - }, - "testDataGenerator_noDataHint": { - "message": "配置字段后点击「生成数据」按钮", - "description": "Translation key: testDataGenerator_noDataHint" - }, - "testDataGenerator_sampleData": { - "message": "示例数据", - "description": "Translation key: testDataGenerator_sampleData" - }, - "testDataGenerator_copied": { - "message": "已复制", - "description": "Translation key: testDataGenerator_copied" - }, - "testDataGenerator_copy": { - "message": "复制", - "description": "Translation key: testDataGenerator_copy" - }, - "testDataGenerator_pageInfo": { - "message": "第 {{current}} / {{total}} 页", - "description": "Translation key: testDataGenerator_pageInfo" - }, - "testDataGenerator_prevPage": { - "message": "上一页", - "description": "Translation key: testDataGenerator_prevPage" - }, - "testDataGenerator_nextPage": { - "message": "下一页", - "description": "Translation key: testDataGenerator_nextPage" - }, - "testDataGenerator_success": { - "message": "生成成功", - "description": "Translation key: testDataGenerator_success" - }, - "testDataGenerator_generateSuccess": { - "message": "生成完成", - "description": "Translation key: testDataGenerator_generateSuccess" - }, - "testDataGenerator_records": { - "message": "条数据", - "description": "Translation key: testDataGenerator_records" - }, - "testDataGenerator_partialSuccess": { - "message": "部分成功", - "description": "Translation key: testDataGenerator_partialSuccess" - }, - "testDataGenerator_failed": { - "message": "生成失败", - "description": "Translation key: testDataGenerator_failed" - }, - "testDataGenerator_totalCount": { - "message": "总条数", - "description": "Translation key: testDataGenerator_totalCount" - }, - "testDataGenerator_successCount": { - "message": "成功", - "description": "Translation key: testDataGenerator_successCount" - }, - "testDataGenerator_duration": { - "message": "耗时", - "description": "Translation key: testDataGenerator_duration" - }, - "testDataGenerator_warnings": { - "message": "警告", - "description": "Translation key: testDataGenerator_warnings" - }, - "testDataGenerator_moreWarnings": { - "message": "还有 {{count}} 条警告", - "description": "Translation key: testDataGenerator_moreWarnings" - }, - "testDataGenerator_export": { - "message": "导出数据", - "description": "Translation key: testDataGenerator_export" - }, - "testDataGenerator_copyJSON": { - "message": "复制 JSON", - "description": "Translation key: testDataGenerator_copyJSON" - }, - "testDataGenerator_copyCSV": { - "message": "复制 CSV", - "description": "Translation key: testDataGenerator_copyCSV" - }, - "testDataGenerator_downloadJSON": { - "message": "下载 JSON", - "description": "Translation key: testDataGenerator_downloadJSON" - }, - "testDataGenerator_downloadCSV": { - "message": "下载 CSV", - "description": "Translation key: testDataGenerator_downloadCSV" - }, - "testDataGenerator_saveRule": { - "message": "保存规则", - "description": "Translation key: testDataGenerator_saveRule" - }, - "testDataGenerator_import": { - "message": "导入", - "description": "Translation key: testDataGenerator_import" - }, - "testDataGenerator_exportRules": { - "message": "导出规则", - "description": "Translation key: testDataGenerator_exportRules" - }, - "testDataGenerator_ruleCount": { - "message": "已保存 {{count}}/{{max}} 条", - "description": "Translation key: testDataGenerator_ruleCount" - }, - "testDataGenerator_searchRules": { - "message": "搜索规则...", - "description": "Translation key: testDataGenerator_searchRules" - }, - "testDataGenerator_saveNewRule": { - "message": "保存新规则", - "description": "Translation key: testDataGenerator_saveNewRule" - }, - "testDataGenerator_ruleNamePlaceholder": { - "message": "规则名称", - "description": "Translation key: testDataGenerator_ruleNamePlaceholder" - }, - "testDataGenerator_ruleDescPlaceholder": { - "message": "规则描述(可选)", - "description": "Translation key: testDataGenerator_ruleDescPlaceholder" - }, - "testDataGenerator_confirm": { - "message": "确认", - "description": "Translation key: testDataGenerator_confirm" - }, - "testDataGenerator_done": { - "message": "完成", - "description": "Translation key: testDataGenerator_done" - }, - "testDataGenerator_noSearchResults": { - "message": "未找到匹配的规则", - "description": "Translation key: testDataGenerator_noSearchResults" - }, - "testDataGenerator_noRules": { - "message": "暂无保存的规则", - "description": "Translation key: testDataGenerator_noRules" - }, - "testDataGenerator_usedTimes": { - "message": "使用 {{count}} 次", - "description": "Translation key: testDataGenerator_usedTimes" - }, - "testDataGenerator_load": { - "message": "加载", - "description": "Translation key: testDataGenerator_load" - }, - "testDataGenerator_duplicate": { - "message": "复制", - "description": "Translation key: testDataGenerator_duplicate" - }, - "testDataGenerator_delete": { - "message": "删除", - "description": "Translation key: testDataGenerator_delete" - }, - "testDataGenerator_virtualMode": { - "message": "虚拟列表模式(共 $ 行)" - }, - "testDataGenerator_totalRows": { - "message": "共 $ 条数据" - }, - "testDataGenerator_ruleSaved": { - "message": "规则已保存", - "description": "Translation key: testDataGenerator_ruleSaved" - }, - "testDataGenerator_ruleLoaded": { - "message": "已加载规则「{{name}}」", - "description": "Translation key: testDataGenerator_ruleLoaded" - }, - "testDataGenerator_ruleDeleted": { - "message": "规则已删除", - "description": "Translation key: testDataGenerator_ruleDeleted" - }, - "testDataGenerator_ruleDuplicated": { - "message": "规则已复制", - "description": "Translation key: testDataGenerator_ruleDuplicated" - }, - "testDataGenerator_exportSuccess": { - "message": "规则已导出", - "description": "Translation key: testDataGenerator_exportSuccess" - }, - "testDataGenerator_importSuccess": { - "message": "成功导入 {{count}} 条规则", - "description": "Translation key: testDataGenerator_importSuccess" - }, - "testDataGenerator_importFailed": { - "message": "{{count}} 条规则导入失败", - "description": "Translation key: testDataGenerator_importFailed" - }, - "testDataGenerator_confirmDeleteTitle": { - "message": "确认删除规则", - "description": "Translation key: testDataGenerator_confirmDeleteTitle" - }, - "testDataGenerator_confirmDeleteDescription": { - "message": "确定要删除规则「{{name}}」吗?此操作不可撤销。", - "description": "Translation key: testDataGenerator_confirmDeleteDescription" - }, - "testDataGenerator_ruleNameDuplicate": { - "message": "已存在同名规则,是否覆盖保存?", - "description": "Translation key: testDataGenerator_ruleNameDuplicate" - }, - "testDataGenerator_overwrite": { - "message": "覆盖", - "description": "Translation key: testDataGenerator_overwrite" - }, - "testDataGenerator_edit": { - "message": "编辑", - "description": "Translation key: testDataGenerator_edit" - }, - "testDataGenerator_ruleUpdated": { - "message": "规则已更新", - "description": "Translation key: testDataGenerator_ruleUpdated" - }, - "testDataGenerator_editing": { - "message": "编辑中", - "description": "Translation key: testDataGenerator_editing" - }, - "testDataGenerator_editingRule": { - "message": "正在编辑规则「{{name}}」", - "description": "Translation key: testDataGenerator_editingRule" - }, - "testDataGenerator_updateRule": { - "message": "更新规则", - "description": "Translation key: testDataGenerator_updateRule" - }, - "testDataGenerator_saveAs": { - "message": "另存为", - "description": "Translation key: testDataGenerator_saveAs" - }, - "testDataGenerator_noGeneratorParams": { - "message": "此生成器无可配置参数", - "description": "Translation key: testDataGenerator_noGeneratorParams" - }, - "testDataGenerator_enabled": { - "message": "启用", - "description": "Translation key: testDataGenerator_enabled" - }, - "testDataGenerator_disabled": { - "message": "禁用", - "description": "Translation key: testDataGenerator_disabled" - }, - "testDataGenerator_commaSeparated": { - "message": "用逗号分隔多个值", - "description": "Translation key: testDataGenerator_commaSeparated" - }, - "testDataGenerator_copySuccess": { - "message": "已复制到剪贴板", - "description": "Translation key: testDataGenerator_copySuccess" - }, - "testDataGenerator_copyFailed": { - "message": "复制失败", - "description": "Translation key: testDataGenerator_copyFailed" - }, - "testDataGenerator_fieldNameEmpty": { - "message": "字段名称不能为空", - "description": "Translation key: testDataGenerator_fieldNameEmpty" - }, - "testDataGenerator_fieldNameDuplicate": { - "message": "字段名称已存在", - "description": "Translation key: testDataGenerator_fieldNameDuplicate" - }, - "testDataGenerator_fieldNameInvalid": { - "message": "字段名称只能包含字母、数字和下划线", - "description": "Translation key: testDataGenerator_fieldNameInvalid" - }, - "testDataGenerator_ruleCopySuffix": { - "message": "(副本)", - "description": "Translation key: testDataGenerator_ruleCopySuffix" - } -} diff --git a/spec/README.md b/spec/README.md new file mode 100644 index 0000000..2a417f6 --- /dev/null +++ b/spec/README.md @@ -0,0 +1,7 @@ +# Spec 目录 + +功能规格、修复方案与验收标准文档。 + +| 文档 | 状态 | 说明 | +| -------------------------------------------------------------------------------- | ----------------- | ---------------------------------------------------- | +| [storage-cleaner/indexeddb-fix-plan.md](./storage-cleaner/indexeddb-fix-plan.md) | ✅ Phase 3 已完成 | Storage Cleaner IndexedDB 清理逻辑修复方案与验收标准 | diff --git a/spec/storage-cleaner/indexeddb-fix-plan.md b/spec/storage-cleaner/indexeddb-fix-plan.md new file mode 100644 index 0000000..384d3e6 --- /dev/null +++ b/spec/storage-cleaner/indexeddb-fix-plan.md @@ -0,0 +1,435 @@ +# Storage Cleaner — IndexedDB 修复方案与验收标准 + +> 创建时间: 2026-06-25 +> 状态: ✅ Phase 3 已完成(2026-06-25) +> 关联模块: `src/utils/storageCleaner.ts` +> 前置审查: Code Review(`storageCleaner.ts` 修改版) + +## 背景与目标 + +本次修复针对 `storageCleaner.ts` 中 IndexedDB 清理逻辑及错误处理链路的审查结论,按优先级分三阶段实施。 + +| 问题域 | 现状 | 目标 | +| ------------------- | ---------------------------------------------- | --------------------------------- | +| IndexedDB fallback | `store.clear()` 完成后立即 `db.close()` | 等 transaction commit 后再关闭 | +| deleteDatabase 超时 | 超时后仍可能触发 `onsuccess`,与 fallback 并发 | 单次删除生命周期内只 resolve 一次 | +| 部分成功 | 多 DB 部分失败时 `count` 丢失 | 失败结果保留已清理数量 | +| 代码结构 | `runScript` / `runCleanScript` 重复 | 统一 executeScript 入口 | +| 测试 | 缺多 DB 混合场景 | 补单元测试覆盖 | + +--- + +## Phase 1 — 合并前必做(P1) + +### 1.1 等待 IndexedDB transaction 完成后再关闭连接 + +#### 问题 + +`clearObjectStores` 在 `Promise.all(clearStore...)` 结束后立刻 `db.close()`。单个 `clearReq.onsuccess` 只表示 request 完成,transaction 可能尚未 commit,存在清空被回滚的风险。 + +#### 根因 + +IndexedDB 规范中,transaction 的持久化以 `transaction.oncomplete` 为准,而非单个 request 的 `onsuccess`。 + +#### 修复方案 + +在注入脚本内的 `clearObjectStores` 中,增加 `waitForTransaction` 辅助函数: + +```typescript +const waitForTransaction = (tx: IDBTransaction): Promise => + new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error ?? new Error('Transaction failed')); + tx.onabort = () => reject(tx.error ?? new Error('Transaction aborted')); + }); +``` + +修改 `openReq.onsuccess` 分支: + +```typescript +const transaction = db.transaction(storeNames, 'readwrite'); +const errors = ( + await Promise.all( + storeNames.map((storeName) => clearStore(transaction.objectStore(storeName), storeName)), + ) +).filter((error): error is string => Boolean(error)); + +try { + await waitForTransaction(transaction); +} catch { + db.close(); + resolve({ + success: false, + errors: [`清空 IndexedDB 失败(${dbName}),请刷新后重试`], + }); + return; +} + +db.close(); +resolve({ success: errors.length === 0, errors }); +``` + +#### 涉及文件 + +- `src/utils/storageCleaner.ts` — `injectClearIndexedDB` 内 `clearObjectStores` + +#### 新增测试 + +```typescript +it('should wait for transaction complete before closing db', async () => { + // mock: clear onsuccess 先于 transaction.oncomplete 触发 + // 断言 db.close 在 transaction.oncomplete 之后调用 +}); +``` + +--- + +### 1.2 消除 deleteDatabase 超时竞态 + +#### 问题 + +超时 `resolve('timeout')` 后,`deleteReq.onsuccess` 仍可能触发;此时 fallback 的 `indexedDB.open` 与进行中的 `deleteDatabase` 可能并发,行为未定义。 + +#### 修复方案 + +为每个 DB 删除引入 **单次 settle** 状态: + +```typescript +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(timeout); + resolve(status); + }; + + const deleteReq = indexedDB.deleteDatabase(dbName); + const timeout = setTimeout(() => { + console.warn('IndexedDB delete timeout:', dbName); + settle('timeout'); + }, timeoutMs); + + deleteReq.onblocked = () => { + console.warn('IndexedDB delete blocked:', dbName); + settle('blocked'); + }; + deleteReq.onsuccess = () => settle('deleted'); + deleteReq.onerror = () => settle('error'); + }); +``` + +#### timeout / blocked 后的 fallback 策略 + +| 状态 | 行为 | +| --------- | ------------------------------------------------------------------ | +| `blocked` | 立即 fallback `clearObjectStores`(页面仍占用连接,open 通常可行) | +| `timeout` | 先 `await delay(100~200ms)` 再 fallback,降低与 delete 并发概率 | +| `error` | 不 fallback,直接报错 | + +#### 涉及文件 + +- `src/utils/storageCleaner.ts` — 替换现有 `new Promise` 删除逻辑 + +#### 新增测试 + +```typescript +it('should ignore late onsuccess after delete timeout', async () => { + // deleteBehavior: timeout,5000ms 后 resolve timeout + // 6000ms 后再触发 onsuccess + // 断言:只走 fallback 一次,count 不因 late onsuccess 重复 +1 +}); +``` + +--- + +## Phase 2 — 建议同 PR 或紧接 follow-up(P2) + +### 2.1 IndexedDB 部分成功时保留 count + +#### 问题 + +多 DB 场景返回 `{ count: 2, errors: ['...'] }` 时,`runCleanScript` 只返回 `{ success: false, error }`,用户看不到已清理 2 个库。 + +#### 修复方案(推荐) + +扩展失败分支类型,可选 `count`: + +```typescript +// src/types/storage.d.ts +export type StorageCleanResult = + | { success: true; count: number } + | { success: false; error: string; count?: number }; // 部分成功时的已清理数 +``` + +修改 `runCleanScript`: + +```typescript +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 } : {}), + }; +} +``` + +#### UI 层(可选增强) + +`CleaningResult.tsx` 失败时若 `result.indexedDB?.count` 存在,可展示部分成功提示(非必须,error 字符串已含 hint 即可)。 + +#### 涉及文件 + +- `src/types/storage.d.ts` +- `src/utils/storageCleaner.ts` — `runCleanScript` +- `src/utils/__tests__/storageCleaner.test.ts` +- (可选)`src/pages/StorageCleaner/components/CleaningResult.tsx` + +#### 新增测试 + +```typescript +it('should preserve partial count when some IndexedDB databases fail', async () => { + // 3 个 DB:2 成功删除,1 blocked 且 fallback 失败 + // expect: success false, count 2, error 含「已成功清理 2 个」 +}); +``` + +--- + +### 2.2 统一 executeScript 调用入口 + +#### 问题 + +`runScript` 与 `runCleanScript` 各自调用 `browser.scripting.executeScript`,行为不一致(吞错 vs 抛错)。 + +#### 修复方案 + +抽取底层函数: + +```typescript +type ExecuteScriptMode = 'fallback' | 'throw'; + +async function executeInTab( + tabId: number, + func: () => T | Promise, + options: { errorLabel: string; mode: 'fallback'; fallback: T }, +): Promise; +async function executeInTab( + tabId: number, + func: () => T | Promise, + options: { errorLabel: string; mode: 'throw' }, +): Promise; +async function executeInTab(...) { + try { + const [result] = await browser.scripting.executeScript({ target: { tabId }, func }); + return (result?.result as T) ?? (options.mode === 'fallback' ? options.fallback : undefined as T); + } catch (error) { + console.error(`Failed to ${options.errorLabel}:`, error); + if (options.mode === 'throw') throw error; + return options.fallback; + } +} +``` + +- `runScript` → `executeInTab(..., { mode: 'fallback', fallback })` +- `runCleanScript` → `executeInTab(..., { mode: 'throw' })` + 结果解析 + +#### 涉及文件 + +- `src/utils/storageCleaner.ts` + +#### 验收 + +现有 11 个测试全部通过,无行为回归。 + +--- + +### 2.3 错误信息分隔符统一 + +#### 问题 + +`runCleanScript` 用 `'; '` 拼接,`clearStorage` 的 `result.error` 用 `'\n'`,UI 用 `break-all` 展示,多错误时可读性不一致。 + +#### 修复方案 + +IndexedDB 内部多错误统一改为 `'\n'`: + +```typescript +error: raw.errors.join('\n'); +``` + +#### 涉及文件 + +- `src/utils/storageCleaner.ts` +- 相关测试断言(若有 `'; '` 期望) + +--- + +### 2.4 补充多 DB 混合场景测试 + +| 用例 | 输入 | 期望 | +| ------------------ | ------------------------------ | -------------------------------------------- | +| 全部成功 | 3 DB,均 delete success | `success: true, count: 3` | +| 部分 fallback 成功 | 2 success + 1 blocked→clear OK | `success: true, count: 3` | +| 部分失败 | 2 success + 1 error | `success: false, count: 2, error 含失败库名` | +| 空库列表 | `databases()` 返回 `[]` | `success: true, count: 0` | + +#### 涉及文件 + +- `src/utils/__tests__/storageCleaner.test.ts` +- 扩展 `createIndexedDBMock` 支持 per-db 不同 `deleteBehavior` + +--- + +## Phase 3 — 可选优化(P3) + +### 3.1 超时常量提升到模块级 + +```typescript +// src/utils/storageCleaner.ts 或 src/pages/StorageCleaner/constants.ts +const INDEXED_DB_DELETE_TIMEOUT_MS = 5000; +const INDEXED_DB_CLEAR_STORE_TIMEOUT_MS = 5000; +``` + +注入脚本通过闭包引用(executeScript 会序列化 func,常量需在 func 外部定义并 capture,或仍写在 func 内但从模块常量赋值)。 + +### 3.2 IndexedDB 逻辑拆分(长期) + +将 `clearObjectStores`、`waitForDeleteDatabase` 等抽到 `src/utils/indexedDbCleaner.ts` 的纯函数,注入层只做: + +```typescript +async () => clearAllIndexedDBs(INDEXED_DB_DELETE_TIMEOUT_MS); +``` + +便于单测,不依赖 `mockExecuteScriptEval` 间接执行注入函数。工作量大,建议单独 PR。 + +--- + +## 实施顺序 + +```mermaid +flowchart TD + A[1.1 transaction.oncomplete] --> B[1.2 delete settle 防竞态] + B --> C[2.4 补多 DB 测试] + C --> D[2.1 部分成功 count] + D --> E[2.2 统一 executeInTab] + E --> F[2.3 错误分隔符] + F --> G[3.x 可选重构] +``` + +| 阶段 | 预估工作量 | 风险 | +| ------- | ---------- | ---------------- | +| Phase 1 | 0.5~1 天 | 低,逻辑局部 | +| Phase 2 | 0.5~1 天 | 中,涉及类型扩展 | +| Phase 3 | 1~2 天 | 低,可延后 | + +--- + +## 验收标准 + +### A. 自动化(CI 必须通过) + +```bash +npm run test -- src/utils/__tests__/storageCleaner.test.ts +npm run typecheck +npm run lint +``` + +| 编号 | 标准 | +| ---- | ------------------------------------------------------------------------------ | +| A-1 | 全部单元测试通过,新增测试 ≥ 3(transaction 顺序、late onsuccess、多 DB 混合) | +| A-2 | `tsc --noEmit` 无错误;若扩展 `StorageCleanResult`,所有引用处类型正确 | +| A-3 | ESLint `--max-warnings=0` 通过 | + +--- + +### B. 功能行为 + +| 编号 | 场景 | 期望结果 | +| ---- | ---------------------------------------------------- | ----------------------------------------------------------------------------- | +| B-1 | 单 DB,`deleteDatabase` 成功 | `indexedDB: { success: true, count: 1 }`,`overallSuccess: true` | +| B-2 | 单 DB,`deleteDatabase` blocked,fallback clear 成功 | `success: true, count: 1`;transaction 在 `oncomplete` 后 `db.close` | +| B-3 | 单 DB,delete 超时 5s,fallback clear 成功 | 5s 内进入 fallback;不因 late `onsuccess` 重复计数 | +| B-4 | fallback 中某 store clear hang 5s | `success: false`,error 含 `dbName/storeName` | +| B-5 | 3 DB:2 成功 + 1 失败 | `success: false`,`count: 2`(Phase 2.1 后),error 含失败库名与部分成功提示 | +| B-6 | `executeScript` 注入失败 | `success: false`,**不得** `{ success: true, count: 0 }` | +| B-7 | localStorage 成功 + cookies 失败 | `overallSuccess: false`,`result.error` 为 `Cookies: ...`(换行分隔多项失败) | + +--- + +### C. 回归与 UI + +| 编号 | 标准 | +| ---- | --------------------------------------------------------------------------------------------------------- | +| C-1 | `formatCleaningResult` 成功路径不变 | +| C-2 | `CleaningResult` 失败时展示 `result.error`;含 `\n` 时多行可读(现有 `leading-relaxed break-all` 可接受) | +| C-3 | `reloadAfterClean=true` 且 `overallSuccess=false` 时不刷新页面(`useStorageCleaner` 现有逻辑) | +| C-4 | Cookie 清理:domain 前导 `.` 剥离逻辑不变 | + +--- + +### D. 手动验收(扩展环境) + +在 Chrome 加载 unpacked extension,选普通 HTTPS 页面: + +| 编号 | 步骤 | 期望 | +| ---- | ------------------------------------------------- | ----------------------------------------------------------------------- | +| D-1 | 页面写入 localStorage + IndexedDB,仅清 IndexedDB | 成功提示或明确错误;DevTools → Application → IndexedDB 数据为空或库已删 | +| D-2 | 打开 DevTools 保持 IndexedDB 面板,执行清理 | 若 blocked,显示中文提示;fallback 成功后数据不可见 | +| D-3 | 勾选「清理后刷新」且全部成功 | Toast「清理成功,即将刷新页面」,页面刷新 | +| D-4 | 部分失败 | 不刷新;结果区红色展示错误详情 | + +--- + +### E. 代码质量 + +| 编号 | 标准 | +| ---- | ------------------------------------------------------------------ | +| E-1 | 注入脚本内无重复 `settled` / timeout 逻辑(删除与 clear 各自封装) | +| E-2 | 错误文案仍为中文,含库名/store 名 | +| E-3 | 无 `any`(测试文件除外) | +| E-4 | Phase 1 合并后,P1 项在 PR 描述中标注「已修复」并附测试名 | + +--- + +## PR 检查清单 + +```markdown +## 修复内容 + +- [ ] P1: transaction.oncomplete 后再 db.close +- [ ] P1: deleteDatabase settle 防竞态 +- [ ] P2: 部分成功保留 count(可选) +- [ ] P2: 多 DB 混合测试 +- [ ] P2: 错误信息 `\n` 分隔(可选) + +## 验收 + +- [ ] npm run test / typecheck / lint 通过 +- [ ] 新增测试覆盖 B-2、B-3、B-5 +- [ ] 手动 D-1 ~ D-4 至少测 D-1、D-3 +``` + +--- + +## 风险与边界说明 + +1. **fallback 清空 ≠ 删除库**:blocked 时只清 object store,库结构仍在;成功 `count` 表示「有效清理动作完成」,需在 UI/文档中说明(可选文案:「数据已清空,数据库结构可能仍存在」)。 +2. **timeout 延迟 fallback**:100~200ms 为经验值,无法完全消除竞态,只能降低概率;完全消除需浏览器不支持 abort delete 的前提下接受 best-effort。 +3. **类型扩展**:`StorageCleanResult` 加可选 `count` 为向后兼容;消费方用 `'count' in result && result.count` 判断即可。 + +--- + +## 相关文件索引 + +| 文件 | 说明 | +| -------------------------------------------------------- | -------------------------------------------- | +| `src/utils/storageCleaner.ts` | 核心清理逻辑 | +| `src/utils/__tests__/storageCleaner.test.ts` | 单元测试 | +| `src/types/storage.d.ts` | `StorageCleanResult` / `CleaningResult` 类型 | +| `src/pages/StorageCleaner/constants.ts` | 选项标签与键名 | +| `src/pages/StorageCleaner/useStorageCleaner.ts` | 清理流程编排 | +| `src/pages/StorageCleaner/components/CleaningResult.tsx` | 结果展示 UI | diff --git a/src/assets/react.svg b/src/assets/react.svg deleted file mode 100644 index 8e0e0f1..0000000 --- a/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/components/CopyButton.tsx b/src/components/CopyButton.tsx index 6adf5c0..971e6c2 100644 --- a/src/components/CopyButton.tsx +++ b/src/components/CopyButton.tsx @@ -1,10 +1,9 @@ import React, { useEffect, useRef, useState } from 'react'; import { Check, Copy } from 'lucide-react'; import { copyTextToClipboard } from '@/utils/clipboard'; +import { Button, type ButtonProps } from '@/components/ui/button'; import { cn } from '@/lib/utils'; -import { buttonVariants, type ButtonProps } from '@/components/ui/button'; import { toast } from 'sonner'; -import { useI18n } from '@/utils/chromeI18n'; interface CopyButtonProps extends Omit { text: string; @@ -19,7 +18,6 @@ export const CopyButton: React.FC = ({ className, ...props }) => { - const { t } = useI18n('common'); const [copied, setCopied] = useState(false); const timerRef = useRef | null>(null); @@ -33,33 +31,30 @@ export const CopyButton: React.FC = ({ e.stopPropagation(); if (!text) { - toast.error(t('messages.copyEmpty')); + toast.error('无内容可复制'); return; } const success = await copyTextToClipboard(text); if (success) { - toast.success(t('messages.copySuccess')); + toast.success('已复制到剪贴板'); setCopied(true); if (timerRef.current) clearTimeout(timerRef.current); timerRef.current = setTimeout(() => setCopied(false), 1500); } else { - toast.error(t('messages.copyError')); + toast.error('复制失败'); } }; return ( - + ); }; diff --git a/src/components/DecodeResultPaper.tsx b/src/components/DecodeResultPaper.tsx deleted file mode 100644 index 3984b0f..0000000 --- a/src/components/DecodeResultPaper.tsx +++ /dev/null @@ -1,86 +0,0 @@ -/** - * DecodeResultPaper - * - * FileMode 与 ImageMode 通用的 decode 结果展示组件。 - * 提取了二者 decode 输出区完全一致的结构: - * 标题 → 可选预览(children)→ 文件信息 → 文件名输入 → 下载按钮 - * - * FileMode 直接使用,ImageMode 通过 children 传入图片预览。 - */ -import { Download } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { formatFileSize } from '@/utils/base64Converter'; -import { useI18n } from '@/utils/chromeI18n'; - -interface DecodeResultPaperProps { - /** 标题文案,由调用方传入 i18n key 对应的值(如 decodedFileOutput / decodedImageOutput) */ - title: string; - /** 解码后推断的 MIME 类型 */ - mimeType: string; - /** 解码后 Blob 的大小(字节) */ - blobSize: number; - /** 当前文件名 */ - fileName: string; - /** 文件名变更回调 */ - onFileNameChange: (name: string) => void; - /** 下载按钮点击回调 */ - onDownload: () => void; - /** 可选的预览内容,ImageMode 用于渲染图片预览 */ - children?: React.ReactNode; -} - -export default function DecodeResultPaper({ - title, - mimeType, - blobSize, - fileName, - onFileNameChange, - onDownload, - children, -}: DecodeResultPaperProps) { - const { t } = useI18n('base64Converter'); - - return ( -
- {/* 标题 */} - {title} - - {/* 可选预览内容(ImageMode 的图片) */} - {children} - - {/* 文件信息 */} -
- - {t('inferredMimeType')}: {mimeType} - - - {t('decodedSize')}: {formatFileSize(blobSize)} - -
- - {/* 文件名输入 */} -
- - onFileNameChange(e.target.value)} - className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary" - /> -
- - {/* 下载按钮 */} - -
- ); -} diff --git a/src/components/EmptyPlaceholder.tsx b/src/components/EmptyPlaceholder.tsx new file mode 100644 index 0000000..5ffacc1 --- /dev/null +++ b/src/components/EmptyPlaceholder.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { cn } from '@/lib/utils'; + +export interface EmptyPlaceholderProps extends React.HTMLAttributes { + children: React.ReactNode; + messageClassName?: string; +} + +const CONTAINER_CLASSES = + 'rounded-xl bg-muted/30 border border-dashed border-border/80 text-center flex flex-col items-center justify-center select-none p-8 min-h-[120px]'; + +const MESSAGE_CLASSES = + 'text-xs font-semibold text-muted-foreground/80 tracking-wide max-w-[240px] leading-relaxed'; + +export default function EmptyPlaceholder({ + children, + className, + messageClassName, + ...props +}: EmptyPlaceholderProps) { + return ( +
+ {typeof children === 'string' || typeof children === 'number' ? ( +

{children}

+ ) : ( + children + )} +
+ ); +} diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx index 1c62b15..81288ce 100644 --- a/src/components/ErrorBoundary.tsx +++ b/src/components/ErrorBoundary.tsx @@ -1,7 +1,5 @@ import { Component, ErrorInfo, ReactNode } from 'react'; -import { AlertCircle, RefreshCw } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { getMessage } from '@/utils/chromeI18n'; +import { ErrorFallback } from '@/components/ErrorFallback'; interface Props { children: ReactNode; @@ -39,34 +37,14 @@ class ErrorBoundary extends Component { render() { if (this.state.hasError) { return ( -
-
-
- -
-

- {getMessage('errorBoundary_title')} -

-

- {getMessage('errorBoundary_description')} -

- {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 b483612..2c7f78a 100644 --- a/src/components/PageErrorBoundary.tsx +++ b/src/components/PageErrorBoundary.tsx @@ -1,7 +1,5 @@ import { Component, ErrorInfo, ReactNode } from 'react'; -import { AlertCircle, RefreshCw } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { getMessage } from '@/utils/chromeI18n'; +import { ErrorFallback } from '@/components/ErrorFallback'; interface Props { children: ReactNode; @@ -40,38 +38,15 @@ class PageErrorBoundary extends Component { render() { if (this.state.hasError) { return ( -
-
-
- -
- -

- {getMessage('pageErrorBoundary_title')} -

-

- {getMessage('pageErrorBoundary_description')} -

- - {this.state.error && ( -
-
-                  {this.state.error.stack || this.state.error.toString()}
-                
-
- )} - - -
-
+ ); } diff --git a/src/components/README.md b/src/components/README.md index abd1286..c8759a0 100644 --- a/src/components/README.md +++ b/src/components/README.md @@ -6,9 +6,9 @@ | 组件 | 用途 | | ----------------------- | ------------------------------------------------------------------------------ | -| `TopBar.tsx` | 顶部导航栏,集成搜索(含历史记录)、主题切换、语言切换、返回导航 | | `RouterContainer.tsx` | 路由容器,根据当前路由动态渲染对应页面组件,集成错误边界和骨架屏 | | `SwitchButtonGroup.tsx` | 通用切换按钮组,支持 `small/medium/large` 三种尺寸,用于页面子模式切换 | +| `EmptyPlaceholder.tsx` | 虚线边框空状态占位,统一工具页「暂无结果」提示样式 | | `TextInputArea.tsx` | 增强文本输入区域,支持校验规则、工具栏操作、字符计数、清空 | | `CopyButton.tsx` | 一键复制按钮,支持复制成功状态动画,封装 `copyTextToClipboard` 和 `toast` 反馈 | | `ImageUploader.tsx` | 图片上传组件,支持拖拽上传、文件选择和预览 | diff --git a/src/components/RouterContainer.tsx b/src/components/RouterContainer.tsx index a8de5ce..1dbb08a 100644 --- a/src/components/RouterContainer.tsx +++ b/src/components/RouterContainer.tsx @@ -1,7 +1,6 @@ import { FEATURES, getEntryPointType } from '@/config/features'; import { useRouter } from '@/providers/RouterProvider'; import { Suspense } from 'react'; -import { useI18n } from '@/utils/chromeI18n'; import PageErrorBoundary from '@/components/PageErrorBoundary'; import PageSkeleton from '@/components/PageSkeleton'; import { cn } from '@/lib/utils'; @@ -11,7 +10,6 @@ const entryPointType = getEntryPointType(); export default function RouterContainer() { const { currentPage, isLoaded } = useRouter(); - const { t } = useI18n('common'); const animationClass = currentPage === 'dashboard' ? 'page-transition-dashboard' : 'page-transition-enter'; @@ -43,9 +41,9 @@ export default function RouterContainer() {
-

{t('router.notFound')}

+

页面未找到

- {t('router.notFoundDescription', { entryPointType })} + {`该功能在当前运行环境(${entryPointType})下不可用或已被移除。`}

)} diff --git a/src/components/SwitchButtonGroup.tsx b/src/components/SwitchButtonGroup.tsx index 77368d1..b0e425d 100644 --- a/src/components/SwitchButtonGroup.tsx +++ b/src/components/SwitchButtonGroup.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; export interface SwitchOption { @@ -17,6 +18,16 @@ export interface SwitchButtonGroupProps exte buttonClassName?: string; } +const SIZE_CLASSES = { + small: 'text-xs h-8 px-2 py-1 rounded-md', + medium: 'text-sm h-9 px-3 py-1.5 rounded-md', + large: 'text-base h-11 px-4 py-2 rounded-lg', +} as const; + +const SELECTED_CLASSES = + 'bg-background text-foreground shadow-sm font-semibold animate-in fade-in-50 zoom-in-95 duration-150'; +const UNSELECTED_CLASSES = 'hover:bg-background/50 hover:text-foreground/80'; + export default function SwitchButtonGroup({ value, options, @@ -26,12 +37,6 @@ export default function SwitchButtonGroup({ buttonClassName, ...props }: SwitchButtonGroupProps) { - const sizeClasses = { - small: 'text-xs h-8 px-2 py-1 rounded-md', - medium: 'text-sm h-9 px-3 py-1.5 rounded-md', - large: 'text-base h-11 px-4 py-2 rounded-lg', - }; - return (
({ )} {...props} > - {options.map((option) => { - const isSelected = value === option.value; - - return ( - - ); - })} + {options.map((option) => ( + + ))}
); } diff --git a/src/components/TextInputArea.tsx b/src/components/TextInputArea.tsx index fa79fab..f1fcaff 100644 --- a/src/components/TextInputArea.tsx +++ b/src/components/TextInputArea.tsx @@ -1,9 +1,9 @@ import React, { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react'; import { X } from 'lucide-react'; -import { useI18n } from '@/utils/chromeI18n'; import { cn } from '@/lib/utils'; -import { toast } from 'sonner'; // 推荐使用 shadcn 的默认 Toast +import { toast } from 'sonner'; import { CopyButton } from '@/components/CopyButton'; +import { Button } from './ui/button'; export type ValidateRule = { validator: (value: string) => boolean; @@ -44,7 +44,6 @@ export interface TextInputAreaProps extends Omit< onClear?: () => void; } -// 提炼基础的 ActionButton,全面向 shadcn 核心 Button 样式对齐 function ActionButton({ action, value, @@ -114,8 +113,7 @@ const TextInputArea = forwardRef((props const [internalValue, setInternalValue] = useState(defaultValue); const [error, setError] = useState(''); - const { t } = useI18n('common'); - const placeholder = placeholderProp ?? t('textInputArea.placeholder'); + const placeholder = placeholderProp ?? '请输入文本'; const isControlled = controlledValue !== undefined; const value = isControlled ? controlledValue : internalValue; @@ -127,7 +125,6 @@ const TextInputArea = forwardRef((props const textArea = internalRef.current; if (!textArea) return; - // 重置高度计算 textArea.style.height = 'auto'; const computedMin = minRows * 24; @@ -159,7 +156,7 @@ const TextInputArea = forwardRef((props const handleChange = (e: React.ChangeEvent) => { const newVal = e.target.value; if (maxLength && newVal.length > maxLength) { - const msg = t('charCount', { count: maxLength }); + const msg = `内容不能超过 ${maxLength} 个字符`; setError(msg); toast.warning(msg); return; @@ -181,9 +178,9 @@ const TextInputArea = forwardRef((props onChange?.(''); setError(''); internalRef.current?.focus(); - toast.success(t('textInputArea.cleared')); + toast.success('已清空'); onClear?.(); - }, [isControlled, onChange, onClear, t]); + }, [isControlled, onChange, onClear]); const handleAction = useCallback( (action: ToolbarAction) => { @@ -256,7 +253,6 @@ const TextInputArea = forwardRef((props {hasBottomBar && (
- {/* 左侧自定义动作 */}
{bottomActions.map((action) => ( ((props ))}
- {/* 右侧系统按钮组 */}
{allowCopy && value && ( - + )} {showClear && value && !disabled && !readOnly && ( - + )}
)} - {/* 错误提示 */} {displayError && (

{displayError} diff --git a/src/components/TopBar.tsx b/src/components/TopBar.tsx deleted file mode 100644 index 919c1a7..0000000 --- a/src/components/TopBar.tsx +++ /dev/null @@ -1,313 +0,0 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { ArrowLeft, ExternalLink, Monitor, Moon, Search, Sun, X } from 'lucide-react'; -import { useRouter } from '@/providers/RouterProvider'; -import { useThemeMode } from '@/providers/ThemeModeProvider'; -import { FeatureConfig, FEATURES } from '@/config/features'; -import { storageUtil } from '@/utils/chromeStorage'; -import { openExtensionPage } from '@/utils/chromeTabs'; -import { useI18n } from '@/utils/chromeI18n'; -import { cn } from '@/lib/utils'; - -const SEARCH_HISTORY_LIMIT = 10; -const SEARCH_HISTORY_DISPLAY = 5; - -export default function TopBar() { - const { currentPage, goBack, navigateTo } = useRouter(); - const { mode, setMode } = useThemeMode(); - const { t } = useI18n(['common', 'features']); - - const [searchQuery, setSearchQuery] = useState(''); - const [showResults, setShowResults] = useState(false); - const [searchHistory, setSearchHistory] = useState([]); - const [selectedIndex, setSelectedIndex] = useState(-1); - - const containerRef = useRef(null); - const inputRef = useRef(null); - - const handleOpenInTab = async () => { - await openExtensionPage('popup.html', { mode: 'tab' }); - window.close(); - }; - - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (containerRef.current && !containerRef.current.contains(event.target as Node)) { - setShowResults(false); - } - }; - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - }, []); - - // Cmd/Ctrl+K 快捷键聚焦搜索框 - useEffect(() => { - const handleGlobalKeyDown = (e: KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key === 'k') { - e.preventDefault(); - inputRef.current?.focus(); - setShowResults(true); - } - }; - document.addEventListener('keydown', handleGlobalKeyDown); - return () => document.removeEventListener('keydown', handleGlobalKeyDown); - }, []); - - useEffect(() => { - storageUtil - .get('app/searchHistory', []) - .then((history) => { - if (history) setSearchHistory(history); - }) - .catch((err) => console.error('加载搜索历史失败:', err)); - }, []); - - const searchResults = useMemo(() => { - const query = searchQuery.trim().toLowerCase(); - if (!query) return []; - return FEATURES.filter((f) => { - if (f.key === 'dashboard') return false; - return ( - t(f.labelKey).toLowerCase().includes(query) || - t(f.descriptionKey).toLowerCase().includes(query) - ); - }); - }, [searchQuery, t]); - - const displayedHistory = useMemo(() => { - if (searchQuery.trim()) return []; - return searchHistory - .slice(0, SEARCH_HISTORY_DISPLAY) - .map((key) => ({ key, feature: FEATURES.find((f) => f.key === key) })) - .filter((item) => item.feature && item.feature.key !== 'dashboard'); - }, [searchHistory, searchQuery]); - - const saveToHistory = async (featureKey: string) => { - if (!featureKey.trim()) return; - const nextHistory = [featureKey, ...searchHistory.filter((h) => h !== featureKey)].slice( - 0, - SEARCH_HISTORY_LIMIT, - ); - setSearchHistory(nextHistory); - await storageUtil.set('app/searchHistory', nextHistory).catch((err) => console.error(err)); - }; - - const handleSelectFeature = (feature: FeatureConfig) => { - navigateTo(feature.key); - saveToHistory(feature.key); - setSearchQuery(''); - setShowResults(false); - }; - - const cycleThemeMode = () => { - const nextMap = { light: 'dark', dark: 'system', system: 'light' } as const; - setMode(nextMap[mode]); - }; - - const ThemeIcon = mode === 'light' ? Sun : mode === 'dark' ? Moon : Monitor; - - const handleKeyDown = (e: React.KeyboardEvent) => { - const totalItems = searchQuery.trim() ? searchResults.length : displayedHistory.length; - - if (e.key === 'ArrowDown') { - e.preventDefault(); - setSelectedIndex((prev) => (prev < totalItems - 1 ? prev + 1 : prev)); - } else if (e.key === 'ArrowUp') { - e.preventDefault(); - setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev)); - } else if (e.key === 'Enter') { - e.preventDefault(); - if (selectedIndex >= 0 && selectedIndex < totalItems) { - if (searchQuery.trim()) { - handleSelectFeature(searchResults[selectedIndex]); - } else { - const selected = displayedHistory[selectedIndex]; - if (selected?.feature) { - handleSelectFeature(selected.feature); - } - } - } else if (searchQuery.trim() && searchResults.length > 0) { - handleSelectFeature(searchResults[0]); - } - } else if (e.key === 'Escape') { - setShowResults(false); - inputRef.current?.blur(); - } - }; - - const isDashboard = currentPage === 'dashboard'; - - return ( -

- {/* 左侧:返回按钮区 */} -
- {!isDashboard && ( - - )} -
- - {/* 中间:搜索容器 */} -
-
- - { - setSearchQuery(e.target.value); - setShowResults(true); - setSelectedIndex(-1); - }} - onFocus={() => setShowResults(true)} - onKeyDown={handleKeyDown} - aria-label={t('common_buttons_search')} - className="w-full h-9 pl-9 pr-16 text-sm rounded-lg border border-border/60 bg-muted/40 transition-all placeholder:text-muted-foreground/50 focus:bg-background focus:outline-none focus:ring-1 focus:ring-ring focus:border-input" - /> - {!searchQuery && ( - - ⌘K - - )} - {searchQuery && ( - - )} -
- - {/* 动态联想结果卡片 */} - {showResults && (searchQuery.trim() || displayedHistory.length > 0) && ( -
-
    - {searchQuery.trim() ? ( - searchResults.length > 0 ? ( - searchResults.map((feature, index) => ( -
  • handleSelectFeature(feature)} - className={cn( - 'flex items-center gap-3 px-3 py-2.5 rounded-md cursor-pointer text-sm transition-colors', - selectedIndex === index - ? 'bg-accent text-accent-foreground' - : 'hover:bg-muted/60', - )} - > -
    - {feature.icon && } -
    -
    -

    - {t(feature.labelKey)} -

    -

    - {t(feature.descriptionKey)} -

    -
    -
  • - )) - ) : ( -
  • - {t('common:buttons.noResults')} -
  • - ) - ) : ( - <> -
    - {t('common:buttons.recentSearch')} -
    - {displayedHistory.map((item, index) => ( -
  • item.feature && handleSelectFeature(item.feature)} - className={cn( - 'flex items-center gap-3 px-3 py-2 rounded-md cursor-pointer text-sm transition-colors', - selectedIndex === index - ? 'bg-accent text-accent-foreground' - : 'hover:bg-muted/60', - )} - > -
    - {item.feature?.icon && } -
    -
    -

    - {item.feature && t(item.feature.labelKey)} -

    -

    - {item.feature && t(item.feature.descriptionKey)} -

    -
    -
  • - ))} - - )} -
-
- )} -
- - {/* 右侧:操作区 */} -
- - - - - - -
-
- ); -} - -// 5. 提炼出高度复用的原子按钮,大幅精简 Tailwind 冗余,符合 shadcn 的灵巧风格 -function IconButton({ - children, - onClick, - title, -}: { - children: React.ReactNode; - onClick: () => void; - title: string; -}) { - return ( - - ); -} diff --git a/src/components/__tests__/CopyButton.test.tsx b/src/components/__tests__/CopyButton.test.tsx index 64af285..6d1c8ac 100644 --- a/src/components/__tests__/CopyButton.test.tsx +++ b/src/components/__tests__/CopyButton.test.tsx @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { act, render, screen, waitFor } from '@testing-library/react'; +import { act, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; // unmock the globally-mocked component so we test the real implementation @@ -45,29 +45,6 @@ describe('CopyButton', () => { expect(mockedToast.success).toHaveBeenCalled(); }); - it('复制成功后图标切换为 Check,1.5 秒后恢复', async () => { - mockedCopy.mockResolvedValue(true); - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); - - render(); - - // 点击后复制成功,按钮获得 emerald 样式(说明切到了 Check 状态) - await user.click(screen.getByRole('button')); - - await waitFor(() => { - expect(screen.getByRole('button').className).toContain('text-emerald'); - }); - - // 1.5 秒后样式恢复 - act(() => { - vi.advanceTimersByTime(1500); - }); - - await waitFor(() => { - expect(screen.getByRole('button').className).not.toContain('text-emerald'); - }); - }); - it('复制空文本时弹出 error toast', async () => { const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); @@ -91,15 +68,10 @@ describe('CopyButton', () => { expect(mockedToast.error).toHaveBeenCalled(); }); - // ==================== 新增测试 ==================== - - it('初始渲染时显示 Copy 图标且无 emerald 样式', () => { + it('初始渲染时带有默认 aria-label', () => { render(); - const button = screen.getByRole('button'); - expect(button.className).not.toContain('text-emerald'); - // 通过 aria-label 确认按钮存在,图标由 lucide 渲染为 svg - expect(button).toHaveAttribute('aria-label'); + expect(screen.getByRole('button')).toHaveAttribute('aria-label', '复制'); }); it('自定义 tooltip 会覆盖默认 title 和 aria-label', () => { @@ -142,15 +114,12 @@ describe('CopyButton', () => { await user.click(screen.getByRole('button')); - // 在 1.5 秒超时到期前卸载组件 act(() => { vi.advanceTimersByTime(500); }); - // 卸载不应抛出 "Can't perform a React state update on an unmounted component" 警告 expect(() => unmount()).not.toThrow(); - // 前进剩余时间,确认没有异常 act(() => { vi.advanceTimersByTime(2000); }); @@ -164,23 +133,15 @@ describe('CopyButton', () => { const button = screen.getByRole('button'); - // 快速点击 3 次 await user.click(button); await user.click(button); await user.click(button); - // copyTextToClipboard 应该被调用 3 次(每次点击都执行) expect(mockedCopy).toHaveBeenCalledTimes(3); - // 但 setTimeout 相关的 clearTimeout + setTimeout 组合应正常工作 - // advance 1.5 秒后,copied 状态应恢复为 false act(() => { vi.advanceTimersByTime(1500); }); - - await waitFor(() => { - expect(button.className).not.toContain('text-emerald'); - }); }); it('其他 button props 通过 ...props 透传', () => { diff --git a/src/components/__tests__/EmptyPlaceholder.test.tsx b/src/components/__tests__/EmptyPlaceholder.test.tsx new file mode 100644 index 0000000..d5e6ce7 --- /dev/null +++ b/src/components/__tests__/EmptyPlaceholder.test.tsx @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import EmptyPlaceholder from '@/components/EmptyPlaceholder'; + +describe('EmptyPlaceholder 组件', () => { + it('应渲染字符串提示文本', () => { + render(请输入内容); + + expect(screen.getByText('请输入内容')).toBeInTheDocument(); + }); + + it('应应用规范空状态容器样式', () => { + const { container } = render(提示); + + const placeholder = container.firstChild; + expect(placeholder).toHaveClass( + 'rounded-xl', + 'bg-muted/30', + 'border-dashed', + 'border-border/80', + 'min-h-[120px]', + ); + }); + + it('应支持 className 自定义容器样式', () => { + const { container } = render( + 提示, + ); + + expect(container.firstChild).toHaveClass('flex-1', 'min-h-[320px]'); + }); + + it('应支持 messageClassName 自定义文本样式', () => { + render(提示); + + expect(screen.getByText('提示')).toHaveClass('text-sm', 'max-w-none'); + }); + + it('应支持 ReactNode 类型的 children', () => { + render( + + 自定义内容 + , + ); + + expect(screen.getByTestId('custom-content')).toBeInTheDocument(); + }); +}); diff --git a/src/components/__tests__/RouterContainer.test.tsx b/src/components/__tests__/RouterContainer.test.tsx index 4ab1e39..a913afd 100644 --- a/src/components/__tests__/RouterContainer.test.tsx +++ b/src/components/__tests__/RouterContainer.test.tsx @@ -12,7 +12,7 @@ const mockRouterValue = { isLoaded: true, navigateTo: vi.fn(), syncNavigation: vi.fn(), - goBack: vi.fn(), + goHome: vi.fn(), setVisiblePages: vi.fn(), setPageOrder: vi.fn(), }; diff --git a/src/components/__tests__/StorageCleanerConfirm.test.tsx b/src/components/__tests__/StorageCleanerConfirm.test.tsx index 490ffa4..6cd2fbe 100644 --- a/src/components/__tests__/StorageCleanerConfirm.test.tsx +++ b/src/components/__tests__/StorageCleanerConfirm.test.tsx @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { fireEvent, render, screen } from '@testing-library/react'; -import { StorageCleanerConfirm } from '@/pages/StorageCleaner/StorageCleanerConfirm'; +import { StorageCleanerConfirm } from '@/pages/StorageCleaner/components/StorageCleanerConfirm'; import type { StorageCleanerOptions } from '@/types/storage'; import React from 'react'; @@ -94,7 +94,7 @@ describe('StorageCleanerConfirm 组件', () => { renderComponent({ options: partialOptions }); expect(screen.getByText(/Local Storage/)).toBeInTheDocument(); - expect(screen.getByText(/站点存储/)).toBeInTheDocument(); + expect(screen.getByText(/IndexedDB/)).toBeInTheDocument(); expect(screen.queryByText(/Session Storage/)).not.toBeInTheDocument(); expect(screen.queryByText(/Cookies$/)).not.toBeInTheDocument(); }); diff --git a/src/components/__tests__/TextInputArea.test.tsx b/src/components/__tests__/TextInputArea.test.tsx index 1e10a8e..ceb5b41 100644 --- a/src/components/__tests__/TextInputArea.test.tsx +++ b/src/components/__tests__/TextInputArea.test.tsx @@ -18,12 +18,12 @@ describe('TextInputArea 组件', () => { it('默认显示清空按钮', () => { render( {}} />); - expect(screen.getByRole('button', { name: 'textInputArea.clear' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '清空' })).toBeInTheDocument(); }); it('无内容时清空按钮应隐藏', () => { render( {}} />); - expect(screen.queryByRole('button', { name: 'textInputArea.clear' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: '清空' })).not.toBeInTheDocument(); }); it('disabled 时清空按钮应隐藏', () => { @@ -57,7 +57,7 @@ describe('TextInputArea 组件', () => { const handleChange = vi.fn(); render(); - fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' })); + fireEvent.click(screen.getByRole('button', { name: '清空' })); expect(handleChange).toHaveBeenCalledWith(''); }); @@ -81,7 +81,7 @@ describe('TextInputArea 组件', () => { it('清空按钮应清空内容', () => { render(); - fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' })); + fireEvent.click(screen.getByRole('button', { name: '清空' })); expect(screen.getByRole('textbox')).toHaveValue(''); }); @@ -90,14 +90,12 @@ describe('TextInputArea 组件', () => { describe('allowCopy 复制功能', () => { it('allowCopy 且有内容时显示复制按钮', () => { render( {}} allowCopy />); - expect(screen.getByRole('button', { name: 'textInputArea.copyContent' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '复制内容' })).toBeInTheDocument(); }); it('allowCopy 但无内容时隐藏复制按钮', () => { render( {}} allowCopy />); - expect( - screen.queryByRole('button', { name: 'textInputArea.copyContent' }), - ).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: '复制内容' })).not.toBeInTheDocument(); }); it('allowCopy=false 时不显示复制按钮', () => { @@ -113,7 +111,7 @@ describe('TextInputArea 组件', () => { render( {}} allowCopy />); - await user.click(screen.getByRole('button', { name: 'textInputArea.copyContent' })); + await user.click(screen.getByRole('button', { name: '复制内容' })); expect(writeTextSpy).toHaveBeenCalledWith('测试'); }); @@ -126,7 +124,7 @@ describe('TextInputArea 组件', () => { render( {}} allowCopy />); - await user.click(screen.getByRole('button', { name: 'textInputArea.copyContent' })); + await user.click(screen.getByRole('button', { name: '复制内容' })); expect(writeTextSpy).toHaveBeenCalledWith('测试'); }); @@ -158,6 +156,7 @@ describe('TextInputArea 组件', () => { fireEvent.change(textarea, { target: { value: '123456' } }); expect(handleChange).not.toHaveBeenCalledWith('123456'); + expect(screen.getByText('内容不能超过 5 个字符')).toBeInTheDocument(); }); it('未超出 maxLength 的输入应正常触发', () => { @@ -370,7 +369,7 @@ describe('TextInputArea 组件', () => { const writeTextSpy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined); render( {}} allowCopy />); - await user.click(screen.getByRole('button', { name: 'textInputArea.copyContent' })); + await user.click(screen.getByRole('button', { name: '复制内容' })); expect(writeTextSpy).toHaveBeenCalledWith('测试'); }); @@ -381,7 +380,7 @@ describe('TextInputArea 组件', () => { const handleClear = vi.fn(); render( {}} onClear={handleClear} />); - fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' })); + fireEvent.click(screen.getByRole('button', { name: '清空' })); expect(handleClear).toHaveBeenCalledOnce(); }); @@ -389,7 +388,7 @@ describe('TextInputArea 组件', () => { it('不传 onClear 时清空按钮应正常工作', () => { render(); - fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' })); + fireEvent.click(screen.getByRole('button', { name: '清空' })); expect(screen.getByRole('textbox')).toHaveValue(''); }); diff --git a/src/config/README.md b/src/config/README.md deleted file mode 100644 index 5a252cf..0000000 --- a/src/config/README.md +++ /dev/null @@ -1,43 +0,0 @@ -# config/ - -应用级配置目录,存放功能特性的注册中心。 - -## 文件说明 - -| 文件 | 用途 | -| -------------- | ---------------------------------------- | -| `features.tsx` | 核心配置文件,定义所有工具功能的注册信息 | - -## features.tsx - -`FEATURES` 数组是路由和功能元数据的**单一事实来源**,每个功能定义包含: - -- `key`:页面类型标识(`PageType`) -- `labelKey` / `descriptionKey`:i18n 翻译键 -- `themeColorKey`:主题色(`primary/success/warning/error/secondary/info`) -- `icon`:lucide-react 图标组件 -- `defaultVisible`:默认是否可见 -- `components`:三种渲染模式的懒加载组件(`popup`、`sidepanel`、`tab`) - -## 已注册功能(10 个) - -| key | 图标 | 说明 | -| -------------------- | ----------------- | ----------------- | -| `dashboard` | — | 仪表盘首页 | -| `timestamp` | Clock | 时间戳转换工具 | -| `storageCleaner` | Database | 存储清理工具 | -| `qrCode` | QrCode | 二维码工具 | -| `textStatistics` | FileText | 文本统计工具 | -| `jwt` | Key | JWT 解析工具 | -| `jsonDiff` | GitCompareArrows | JSON 差异比较工具 | -| `base64Converter` | ArrowLeftRight | Base64 转换器 | -| `rightClickRestorer` | MousePointerClick | 右键菜单恢复工具 | -| `testDataGenerator` | FileSpreadsheet | 测试数据生成器 | - -## 导出函数 - -- `getFeatureByKey(key)` — 根据 key 获取功能配置 -- `getDefaultVisibleFeatureKeys()` — 获取默认可见的功能 key 列表 -- `getAllFeatureKeys()` — 获取所有功能 key 列表 -- `getDefaultPageOrder()` — 获取默认页面排序(不含 dashboard) -- `getEntryPointType()` — 判断当前入口类型(popup/sidepanel/tab) diff --git a/src/config/__tests__/features.lazy.test.ts b/src/config/__tests__/features.lazy.test.ts new file mode 100644 index 0000000..6aba224 --- /dev/null +++ b/src/config/__tests__/features.lazy.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const pageLoadTracker = vi.hoisted(() => ({ + loaded: [] as string[], +})); + +vi.mock('@/pages/Dashboard', () => { + pageLoadTracker.loaded.push('Dashboard'); + return { default: () => null }; +}); +vi.mock('@/pages/Timestamp', () => { + pageLoadTracker.loaded.push('Timestamp'); + return { default: () => null }; +}); +vi.mock('@/pages/StorageCleaner', () => { + pageLoadTracker.loaded.push('StorageCleaner'); + return { default: () => null }; +}); +vi.mock('@/pages/QrCode', () => { + pageLoadTracker.loaded.push('QrCode'); + return { default: () => null }; +}); +vi.mock('@/pages/TextStatistics', () => { + pageLoadTracker.loaded.push('TextStatistics'); + return { default: () => null }; +}); +vi.mock('@/pages/Jwt', () => { + pageLoadTracker.loaded.push('Jwt'); + return { default: () => null }; +}); +vi.mock('@/pages/JsonTools', () => { + pageLoadTracker.loaded.push('JsonTools'); + return { default: () => null }; +}); +vi.mock('@/pages/Base64Converter', () => { + pageLoadTracker.loaded.push('Base64Converter'); + return { default: () => null }; +}); +vi.mock('@/pages/RightClickRestorer', () => { + pageLoadTracker.loaded.push('RightClickRestorer'); + return { default: () => null }; +}); +vi.mock('@/pages/TestDataGenerator', () => { + pageLoadTracker.loaded.push('TestDataGenerator'); + return { default: () => null }; +}); + +describe('features 懒加载', () => { + beforeEach(() => { + pageLoadTracker.loaded.length = 0; + vi.resetModules(); + }); + + it('仅导入工具函数时不应加载任何页面模块', async () => { + const { getAllFeatureKeys, getDefaultPageOrder } = await import('@/config/features'); + + getAllFeatureKeys(); + getDefaultPageOrder(); + + expect(pageLoadTracker.loaded).toEqual([]); + }); + + it('访问 FEATURES 元数据时不应加载任何页面模块', async () => { + const { FEATURES } = await import('@/config/features'); + + expect(FEATURES).toHaveLength(10); + expect(pageLoadTracker.loaded).toEqual([]); + }); + + it('渲染 lazy 组件时才应加载对应页面模块', async () => { + const React = await import('react'); + const { render, waitFor } = await import('@testing-library/react'); + const { FEATURES } = await import('@/config/features'); + + const DashboardPage = FEATURES.find((f) => f.key === 'dashboard')!.components.popup; + + render( + React.createElement(React.Suspense, { fallback: null }, React.createElement(DashboardPage)), + ); + + await waitFor(() => { + expect(pageLoadTracker.loaded).toEqual(['Dashboard']); + }); + }); +}); diff --git a/src/config/__tests__/features.test.ts b/src/config/__tests__/features.test.ts index 6ac2402..07cdecb 100644 --- a/src/config/__tests__/features.test.ts +++ b/src/config/__tests__/features.test.ts @@ -9,27 +9,26 @@ import { describe('features', () => { describe('FEATURES', () => { - it('should have 10 features defined', () => { + it('应该有10个功能定义', () => { expect(FEATURES).toHaveLength(10); }); - it('should have all required properties for each feature', () => { + it('应该有每个功能的所有必需属性', () => { FEATURES.forEach((feature) => { expect(feature).toHaveProperty('key'); - expect(feature).toHaveProperty('labelKey'); - expect(feature).toHaveProperty('descriptionKey'); + expect(feature).toHaveProperty('label'); + expect(feature).toHaveProperty('description'); expect(feature).toHaveProperty('defaultVisible'); expect(feature).toHaveProperty('components'); expect(typeof feature.key).toBe('string'); - expect(typeof feature.labelKey).toBe('string'); - expect(typeof feature.descriptionKey).toBe('string'); + expect(typeof feature.label).toBe('string'); + expect(typeof feature.description).toBe('string'); expect(typeof feature.defaultVisible).toBe('boolean'); expect(typeof feature.components).toBe('object'); expect(feature.components).toHaveProperty('popup'); expect(feature.components).toHaveProperty('sidepanel'); expect(feature.components).toHaveProperty('tab'); - // Optional UI properties for non-hidden features if (feature.key !== 'dashboard') { expect(feature).toHaveProperty('icon'); expect(feature).toHaveProperty('themeColorKey'); @@ -38,7 +37,7 @@ describe('features', () => { }); }); - it('should have unique keys for each feature', () => { + it('应该有每个功能的唯一key', () => { const keys = FEATURES.map((f) => f.key); const uniqueKeys = new Set(keys); expect(uniqueKeys.size).toBe(keys.length); @@ -46,36 +45,36 @@ describe('features', () => { }); describe('getFeatureByKey', () => { - it('should return dashboard feature', () => { + it('应该返回dashboard功能', () => { const feature = getFeatureByKey('dashboard'); expect(feature).toBeDefined(); expect(feature?.key).toBe('dashboard'); - expect(feature?.labelKey).toBe('dashboard_title'); + expect(feature?.label).toBe('仪表盘'); }); - it('should return timestamp feature', () => { + it('应该返回时间戳功能', () => { const feature = getFeatureByKey('timestamp'); expect(feature).toBeDefined(); expect(feature?.key).toBe('timestamp'); - expect(feature?.labelKey).toBe('timestamp_title'); + expect(feature?.label).toBe('时间戳'); expect(feature?.themeColorKey).toBeDefined(); }); - it('should return storageCleaner feature', () => { + it('应该返回存储清理功能', () => { const feature = getFeatureByKey('storageCleaner'); expect(feature).toBeDefined(); expect(feature?.key).toBe('storageCleaner'); - expect(feature?.labelKey).toBe('storageCleaner_title'); + expect(feature?.label).toBe('存储清理'); }); - it('should return undefined for invalid key', () => { + it('应该返回undefined用于无效的key', () => { const feature = getFeatureByKey('invalid' as any); expect(feature).toBeUndefined(); }); }); describe('getDefaultVisibleFeatureKeys', () => { - it('should return only visible features', () => { + it('应该返回仅可见的功能', () => { const visibleKeys = getDefaultVisibleFeatureKeys(); visibleKeys.forEach((key) => { const feature = getFeatureByKey(key); @@ -83,7 +82,7 @@ describe('features', () => { }); }); - it('should include dashboard, timestamp, storageCleaner, qrCode', () => { + it('应该包含仪表盘、时间戳、存储清理、二维码', () => { const visibleKeys = getDefaultVisibleFeatureKeys(); expect(visibleKeys).toContain('dashboard'); expect(visibleKeys).toContain('timestamp'); @@ -93,7 +92,7 @@ describe('features', () => { }); describe('getAllFeatureKeys', () => { - it('should return all feature keys', () => { + it('应该返回所有功能key', () => { const allKeys = getAllFeatureKeys(); expect(allKeys).toHaveLength(10); expect(allKeys).toContain('dashboard'); @@ -102,7 +101,7 @@ describe('features', () => { expect(allKeys).toContain('qrCode'); expect(allKeys).toContain('textStatistics'); expect(allKeys).toContain('jwt'); - expect(allKeys).toContain('jsonDiff'); + expect(allKeys).toContain('jsonTools'); expect(allKeys).toContain('base64Converter'); expect(allKeys).toContain('rightClickRestorer'); expect(allKeys).toContain('testDataGenerator'); @@ -110,19 +109,19 @@ describe('features', () => { }); describe('getDefaultPageOrder', () => { - it('should exclude dashboard from page order', () => { + it('应该排除仪表盘从页面顺序', () => { const pageOrder = getDefaultPageOrder(); expect(pageOrder).not.toContain('dashboard'); }); - it('should include timestamp, storageCleaner, qrCode in page order', () => { + it('应该包含时间戳、存储清理、二维码在页面顺序', () => { const pageOrder = getDefaultPageOrder(); expect(pageOrder).toContain('timestamp'); expect(pageOrder).toContain('storageCleaner'); expect(pageOrder).toContain('qrCode'); }); - it('should have 9 items in page order', () => { + it('应该有9个项目在页面顺序', () => { const pageOrder = getDefaultPageOrder(); expect(pageOrder).toHaveLength(9); }); diff --git a/src/config/features.tsx b/src/config/features.tsx index 7ef8689..d98d397 100644 --- a/src/config/features.tsx +++ b/src/config/features.tsx @@ -29,8 +29,8 @@ const TestDataGeneratorPage = lazy(() => import('@/pages/TestDataGenerator')); export interface FeatureConfig { key: PageType; - labelKey: string; - descriptionKey: string; + label: string; + description: string; themeColorKey?: PaletteColorKey; icon?: ComponentType; defaultVisible: boolean; @@ -44,8 +44,8 @@ export interface FeatureConfig { export const FEATURES: FeatureConfig[] = [ { key: 'dashboard', - labelKey: 'dashboard_title', - descriptionKey: '', + label: '仪表盘', + description: '', defaultVisible: true, components: { popup: DashboardPage, @@ -55,8 +55,8 @@ export const FEATURES: FeatureConfig[] = [ }, { key: 'timestamp', - labelKey: 'timestamp_title', - descriptionKey: 'timestamp_description', + label: '时间戳', + description: 'Unix 毫秒数转换与格式化', themeColorKey: 'primary', icon: Clock, defaultVisible: true, @@ -68,8 +68,8 @@ export const FEATURES: FeatureConfig[] = [ }, { key: 'storageCleaner', - labelKey: 'storageCleaner_title', - descriptionKey: 'storageCleaner_description', + label: '存储清理', + description: '清理缓存、Cookies 及本地存储', themeColorKey: 'warning', icon: Database, defaultVisible: true, @@ -81,8 +81,8 @@ export const FEATURES: FeatureConfig[] = [ }, { key: 'qrCode', - labelKey: 'qrCode_title', - descriptionKey: 'qrCode_description', + label: '二维码工具', + description: '生成当前选中的 URL 的二维码', themeColorKey: 'success', icon: QrCode, defaultVisible: true, @@ -94,8 +94,8 @@ export const FEATURES: FeatureConfig[] = [ }, { key: 'textStatistics', - labelKey: 'textStatistics_title', - descriptionKey: 'textStatistics_description', + label: '文本统计', + description: '实时分析文本字符、单词及字节', themeColorKey: 'secondary', icon: FileText, defaultVisible: true, @@ -107,8 +107,8 @@ export const FEATURES: FeatureConfig[] = [ }, { key: 'jwt', - labelKey: 'jwt_title', - descriptionKey: 'jwt_description', + label: 'JWT 解析', + description: 'JSON Web Token 解码与查看', themeColorKey: 'info', icon: Key, defaultVisible: true, @@ -119,9 +119,9 @@ export const FEATURES: FeatureConfig[] = [ }, }, { - key: 'jsonDiff', - labelKey: 'jsonDiff_title', - descriptionKey: 'jsonDiff_description', + key: 'jsonTools', + label: 'JSON 工具', + description: '差异比较、格式化、YAML/TOML 转换及压缩', themeColorKey: 'primary', icon: GitCompareArrows, defaultVisible: true, @@ -133,8 +133,8 @@ export const FEATURES: FeatureConfig[] = [ }, { key: 'base64Converter', - labelKey: 'base64Converter_title', - descriptionKey: 'base64Converter_description', + label: 'Base64 转换器', + description: '文本、文件与图像的 Base64 编码转换', themeColorKey: 'info', icon: ArrowLeftRight, defaultVisible: true, @@ -146,8 +146,8 @@ export const FEATURES: FeatureConfig[] = [ }, { key: 'rightClickRestorer', - labelKey: 'rightClickRestorer_title', - descriptionKey: 'rightClickRestorer_description', + label: '右键恢复', + description: '检测并恢复被网站禁用的浏览器右键菜单', themeColorKey: 'success', icon: MousePointerClick, defaultVisible: true, @@ -159,8 +159,8 @@ export const FEATURES: FeatureConfig[] = [ }, { key: 'testDataGenerator', - labelKey: 'testDataGenerator_title', - descriptionKey: 'testDataGenerator_description', + label: '测试数据生成器', + description: '自定义规则批量生成测试数据', themeColorKey: 'warning', icon: FileSpreadsheet, defaultVisible: true, diff --git a/src/entrypoints/README.md b/src/entrypoints/README.md deleted file mode 100644 index 47982f0..0000000 --- a/src/entrypoints/README.md +++ /dev/null @@ -1,51 +0,0 @@ -# entrypoints/ - -WXT 框架要求的扩展生命周期入口点,对应 Chrome Extension 的各个上下文。 - -## 入口文件 - -| 文件 | 用途 | -| ------------------------------- | --------------------------------------------------------------------------------------------- | -| `background.ts` | Service Worker 入口:注册右键菜单、监听菜单点击、处理消息通信、管理侧边栏状态、注入主环境脚本 | -| `content.ts` | Content Script 入口:注入所有页面(``),在 `document_end` 时初始化消息处理器 | -| `rightClickRestorer.content.ts` | 专用 Content Script:处理右键菜单恢复功能,注入浮动状态徽章 | - -## 子目录 - -### popup/ - -Popup 弹窗页面(点击扩展图标弹出)。 - -| 文件 | 用途 | -| ------------ | ------------------------------------------------------------------------------ | -| `index.html` | HTML 入口 | -| `main.tsx` | React 挂载点 | -| `App.tsx` | 根组件,组装 `RouterProvider` + `TopBar` + `ErrorBoundary` + `RouterContainer` | - -### sidepanel/ - -侧边栏页面,结构与 popup 类似,额外通知 background 侧边栏开启/关闭状态。 - -### options/ - -设置页面,支持: - -- 拖拽排序功能顺序(`@dnd-kit`) -- 功能可见性管理(显示/隐藏) -- Popup/Sidepanel/Tab 三种模式独立配置 - -### content/ - -Content Script 内部分模块: - -| 文件 | 用途 | -| ----------------------- | ------------------------------------------------------------------- | -| `messageHandler.ts` | 消息处理器初始化入口 | -| `contextMenuHandler.ts` | 右键菜单点击事件处理,执行时间戳转换/文本统计并通过 UI Popover 展示 | -| `uiPopover.ts` | 在页面中注入浮层 Popover UI,展示右键菜单操作结果 | - -## 架构说明 - -- `background.ts` 是扩展的核心协调者,处理跨上下文通信 -- `content.ts` 注入到所有页面,负责接收和处理来自 background 的消息 -- `popup/`、`sidepanel/`、`options/` 共享同一套页面组件(来自 `pages/`),通过 `RouterProvider` 的不同配置实现独立路由 diff --git a/src/entrypoints/__tests__/background.test.ts b/src/entrypoints/__tests__/background.test.ts index 21d5d32..7029ba1 100644 --- a/src/entrypoints/__tests__/background.test.ts +++ b/src/entrypoints/__tests__/background.test.ts @@ -36,7 +36,7 @@ describe('background 菜单注册与分流', () => { expect(chrome.contextMenus.create).toHaveBeenCalledWith( expect.objectContaining({ id: 'jwt', - title: '🔑 解析 JWT', + title: '解析 JWT', parentId: 'testing-tools-parent', }), ); @@ -48,7 +48,7 @@ describe('background 菜单注册与分流', () => { expect(chrome.contextMenus.create).toHaveBeenCalledWith( expect.objectContaining({ id: 'qrCode-page', - title: '🔗 网页链接转二维码', + title: '网页链接转二维码', contexts: ['page'], }), ); diff --git a/src/entrypoints/background.ts b/src/entrypoints/background.ts index d1b177b..7c56c95 100644 --- a/src/entrypoints/background.ts +++ b/src/entrypoints/background.ts @@ -41,14 +41,11 @@ export default defineBackground(() => { try { await browser.action.openPopup(); } catch (err) { - console.warn( - '[Context Menu] 自动打开 popup 失败,请手动点击扩展图标,暂存数据已安全保留:', - err, - ); + console.warn('[Context Menu] 自动打开 popup 失败,请手动点击扩展图标,数据已暂存:', err); } }); - // 监听扩展图标点击事件,安全激活侧边栏 + // 扩展图标点击时打开侧边栏 browser.action.onClicked.addListener(async (tab) => { if (tab.id) { try { @@ -65,8 +62,8 @@ export default defineBackground(() => { const tabId = sender?.tab?.id; if (!tabId) { - console.warn('[RightClickRestorer] Injection request missing tabId'); - return { success: false, message: 'Missing tabId' }; + console.warn('[RightClickRestorer] 注入请求缺少 tabId'); + return { success: false, message: '缺少 tabId' }; } try { @@ -79,7 +76,7 @@ export default defineBackground(() => { return { success: true }; } catch (err) { const errorMsg = err instanceof Error ? err.message : String(err); - console.error('[RightClickRestorer] executeScript failed:', errorMsg); + console.error('[RightClickRestorer] 执行脚本失败:', errorMsg); return { success: false, message: errorMsg }; } }); diff --git a/src/entrypoints/content/contextMenuHandler.ts b/src/entrypoints/content/contextMenuHandler.ts index cc9b940..c5671e2 100644 --- a/src/entrypoints/content/contextMenuHandler.ts +++ b/src/entrypoints/content/contextMenuHandler.ts @@ -1,22 +1,20 @@ import type { ContextMenuClickedPayload } from '@/utils/messages'; import { MessageAction, onMessage } from '@/utils/messages'; import { getTextStats } from '@/utils/textStatistics'; -import { getMessage } from '@/utils/chromeI18n'; import { hidePopover, showTextStatsResult, showTimestampResult } from './uiPopover'; function convertTimestamp(input: string): string { - const invalidText = getMessage('invalidTimestamp') || 'Invalid Timestamp'; const num = Number(input.trim()); if (isNaN(num)) { - return invalidText; + return '无效时间戳'; } - // 1e12 判定毫秒级/秒级时间戳兼容 + // 1e12 区分毫秒/秒级时间戳 const d = num > 1e12 ? new Date(num) : new Date(num * 1000); if (isNaN(d.getTime())) { - return invalidText; + return '无效时间戳'; } const year = d.getFullYear(); diff --git a/src/entrypoints/content/uiPopover.ts b/src/entrypoints/content/uiPopover.ts index faf985e..e9a514c 100644 --- a/src/entrypoints/content/uiPopover.ts +++ b/src/entrypoints/content/uiPopover.ts @@ -137,12 +137,11 @@ function escapeHtml(text: string): string { } function positionPopover(popover: HTMLElement, x: number, y: number): void { - // 此时借助 visibility: hidden,元素在隐藏状态下拥有真实的布局高宽 const rect = popover.getBoundingClientRect(); const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; - let left = x + 8; // 微微追加水平偏置,防范直接遮挡用户的鼠标落点 + let left = x + 8; let top = y + 8; if (left + rect.width > viewportWidth - 16) { @@ -178,7 +177,7 @@ function showPopover( const titleSpan = document.createElement('span'); titleSpan.className = 'popover-title'; - titleSpan.textContent = title; // ✅ 强安全性护航 + titleSpan.textContent = title; const closeBtn = document.createElement('button'); closeBtn.className = 'popover-close'; @@ -194,10 +193,9 @@ function showPopover( const contentContainer = document.createElement('div'); contentContainer.className = 'popover-content'; - contentContainer.innerHTML = contentHtml; // 内部拼装的方法已提前完成全消毒转义 + contentContainer.innerHTML = contentHtml; popover.appendChild(contentContainer); - // 提前移除激活类名,使 visibility: hidden 起效以供测量 popover.classList.remove('visible'); requestAnimationFrame(() => { @@ -226,7 +224,6 @@ export function hidePopover(): void { } export function showTimestampResult(x: number, y: number, timestamp: string, result: string): void { - // 对外部传来的参数先全数塞入 escapeHtml 大闸进行纯氧化清洗 const cleanTimestamp = escapeHtml(timestamp); const cleanResult = escapeHtml(result); @@ -236,7 +233,7 @@ export function showTimestampResult(x: number, y: number, timestamp: string, res
转换结果
${cleanResult}
`; - showPopover(x, y, content, '⏰ 时间戳转换'); + showPopover(x, y, content, '转换结果'); } export function showTextStatsResult( @@ -246,7 +243,6 @@ export function showTextStatsResult( stats: { characters: number; words: number; lines: number; bytes: number }, ): void { const truncatedText = text.length > 50 ? text.substring(0, 50) + '...' : text; - // 对选中的脏文本先进行严格转义 const cleanText = escapeHtml(truncatedText); const content = ` @@ -271,5 +267,5 @@ export function showTextStatsResult( `; - showPopover(x, y, content, '📊 文本统计'); + showPopover(x, y, content, '统计结果'); } diff --git a/src/entrypoints/popup/App.tsx b/src/entrypoints/popup/App.tsx index 413568a..9bda4d4 100644 --- a/src/entrypoints/popup/App.tsx +++ b/src/entrypoints/popup/App.tsx @@ -1,5 +1,5 @@ import RouterProvider from '@/providers/RouterProvider'; -import TopBar from '@/components/TopBar'; +import TopBar from '@/layout/TopBar'; import RouterContainer from '@/components/RouterContainer'; import ErrorBoundary from '@/components/ErrorBoundary'; import { getEntryPointType } from '@/config/features'; diff --git a/src/entrypoints/popup/index.html b/src/entrypoints/popup/index.html index f2ec7be..8a48568 100644 --- a/src/entrypoints/popup/index.html +++ b/src/entrypoints/popup/index.html @@ -5,6 +5,19 @@ Testing Tools - 标签页 +