From d4c29a1bf4cf5b778559db0c299d9488c8f84b01 Mon Sep 17 00:00:00 2001 From: LingandRX <56020800+LingandRX@users.noreply.github.com> Date: Thu, 28 May 2026 00:40:54 +0800 Subject: [PATCH] Develop (#54) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ci): fix release workflow artifact upload issue - Split artifact upload into 3 separate steps (Chrome, Firefox, Source) - Update download steps to match new artifact names - Add if-no-files-found: error for better error handling - Fix Node.js 20 compatibility issue with upload-artifact@v4 glob pattern * docs: 更新 AGENTS.md,添加测试工具和翻译文件结构说明 * docs: 更新 AGENTS.md,完善 CI 步骤和存储键名格式说明 * fix: remove unnecessary animations from StorageCleaner page - Remove entrance animations (animate-in, fade-in, zoom-in) from all components - Remove scale effect (active:scale-[0.99]) from clean button - Remove transition effects (transition-all, transition-colors) from OptionItem, StorageOptionsGrid, AutoRefreshToggle - Remove bouncing animation from error icon - Remove pulsing animation from warning banner - Keep animate-spin on button loading spinner as functional indicator * fix: remove unnecessary animations from RightClickRestorer page * fix: remove all unnecessary animations from all pages - Remove dead code animations (animate-in, fade-in, slide-in, zoom-in, shake) from tailwindcss-animate plugin (not installed) - Remove decorative transition effects (transition-all, transition-colors) from all page components - Remove scale effects (active:scale-95) from buttons - Remove bounce animations (animate-bounce) from icons - Keep functional animate-spin on loading spinners as they provide essential loading feedback Affected pages: Dashboard, Timestamp, Jwt, JsonTools, Base64Converter, HtmlToMarkdown, MarkdownToHtml, QrCode, TextStatistics * docs: 添加项目文档(copilot-instructions、编码规范、各目录 README) - 新增 .github/copilot-instructions.md:Copilot 指令文件,涵盖构建命令、CI 流水线、项目架构、关键规范 - 新增 .github/CODING_STANDARDS.md:完整的代码编写规范文档(TypeScript、React、样式、错误处理、命名、测试、Hook、i18n、存储等 12 个章节) - 新增 11 个目录的 README.md:components、components/ui、config、entrypoints、i18n、lib、pages、providers、public、src、types、utils - 更新 AGENTS.md:补充页面组件模式说明和 components/ui 目录 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(CopyButton): use shadcn buttonVariants instead of duplicated variant classes Remove hand-written variantClasses/sizeClasses that duplicated shadcn's buttonVariants. Now extends ButtonProps and imports buttonVariants from components/ui/button for consistent styling. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(ErrorBoundary): use shadcn destructive tokens instead of hardcoded red colors Replace hardcoded red-200/red-50/red-600 with border-destructive, bg-destructive/5, text-destructive. Use variant='destructive' on Button. Align error log styling with PageErrorBoundary. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(TextInputArea): replace hardcoded Chinese strings with i18n t() calls Use existing translation keys for clear success, copy success, and copy error messages instead of hardcoded Chinese text. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(QrCodePreview): use shadcn Button instead of manual button styles Replace hand-written button class strings with shadcn Button component using outline and default variants. Removes ~15 lines of duplicated Tailwind classes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(GlobalSnackbar): clarify relationship with sonner toast Add note explaining when to use GlobalSnackbar (provider context, severity levels, custom positioning) vs sonner toast (simple one-off messages). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(ImageUploader): wrap handleClearFile with useCallback Prevents unnecessary re-renders by memoizing the callback, consistent with handleFileChange which already uses useCallback. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(SwitchButtonGroup): remove redundant containerPadding conditional Both branches of the ternary returned 'p-1'. Inline the constant directly into the className string. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(TopBar): add missing openExtensionPage import The function was called in handleOpenInTab but never imported from utils/chromeTabs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(TopBar): add selectedIndex boundary protection in keyboard nav Guard against out-of-bounds access when search results change during keyboard navigation. Check selectedIndex < totalItems before accessing arrays. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(RouterContainer): remove unnecessary empty-dep useMemo getEntryPointType() is a simple config getter. Replace useMemo(() => ..., []) with a direct call — the empty dep array made the memo pointless. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(i18n): add missing translation keys for error boundaries and copy messages Add keys for errorBoundary, pageErrorBoundary, router, and messages.copyEmpty in both zh and en locales. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(CopyButton): replace hardcoded Chinese strings with i18n t() calls Use useTranslation('common') for tooltip, copy empty/success/error toast messages. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(ErrorBoundary): replace hardcoded Chinese with i18n via withTranslation HOC Use react-i18next withTranslation HOC for class component to translate title, description, and refresh button text. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(PageErrorBoundary): replace hardcoded Chinese with i18n via withTranslation HOC Use react-i18next withTranslation HOC for class component to translate title, description, and retry button text. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(RouterContainer): replace hardcoded Chinese with i18n t() calls Use useTranslation('common') for 404 page title and description with entryPointType interpolation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(pages): 修复 TypeScript 错误、硬编码字符串,移除死代码 - 修复 LiveClock.tsx CopyButton size 属性类型错误 (small → sm) - 修复 ToolCard.tsx 紫色 RGB 值拼写错误 (147,51,2 purple → 147,51,232) - 替换 4 处硬编码中文为 i18n 调用 (StorageCleaner, JsonTools) - MarkdownToHtml 预览链接色改为 CSS 变量以支持暗黑模式 - 移除 Base64Converter 中已被 Base64ConverterSection 替代的死代码 (FileMode.tsx, ImageMode.tsx 及其测试文件) - 更新 README.md 移除对已删除文件的引用 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(CopyButton, LiveClock): 修改按钮大小为 'icon',移除硬编码颜色配置 * refactor(TextInputArea): 复用 CopyButton 组件替换内联复制逻辑 * fix(test): 修复 CopyButton mock 未捕获 clipboard writeText 异常 --------- Co-authored-by: Ubuntu Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/CODING_STANDARDS.md | 821 ++++++++++++++++++ .github/copilot-instructions.md | 141 +++ AGENTS.md | 45 +- components/CopyButton.tsx | 54 +- components/ErrorBoundary.tsx | 36 +- components/GlobalSnackbar.tsx | 4 + components/ImageUploader.tsx | 4 +- components/PageErrorBoundary.tsx | 31 +- components/QrCodePreview.tsx | 22 +- components/README.md | 27 + components/RouterContainer.tsx | 10 +- components/SwitchButtonGroup.tsx | 6 +- components/TextInputArea.tsx | 31 +- components/TopBar.tsx | 17 +- components/ui/README.md | 40 + config/README.md | 44 + entrypoints/README.md | 51 ++ entrypoints/options/__tests__/App.test.tsx | 1 + i18n/README.md | 61 ++ i18n/locales/en/common.json | 17 +- i18n/locales/en/jsonDiff.json | 1 + i18n/locales/en/jsonFormat.json | 1 + i18n/locales/en/storageCleaner.json | 1 + i18n/locales/zh/common.json | 17 +- i18n/locales/zh/jsonDiff.json | 1 + i18n/locales/zh/jsonFormat.json | 1 + i18n/locales/zh/storageCleaner.json | 1 + lib/README.md | 26 + package-lock.json | 198 +++-- pages/Base64Converter/FileMode.tsx | 220 ----- pages/Base64Converter/ImageMode.tsx | 259 ------ .../__tests__/FileMode.test.tsx | 235 ----- .../__tests__/ImageMode.test.tsx | 182 ---- .../__tests__/TextMode.test.tsx | 13 +- pages/Dashboard/ToolCard.tsx | 2 +- pages/JsonTools/JsonConvertSection.tsx | 2 +- pages/JsonTools/JsonFormatSection.tsx | 2 +- pages/JsonTools/index.tsx | 4 +- pages/MarkdownToHtml/index.tsx | 4 +- pages/README.md | 140 +++ pages/StorageCleaner/index.tsx | 2 +- pages/Timestamp/LiveClock.tsx | 3 +- providers/README.md | 63 ++ public/README.md | 20 + src/README.md | 24 + types/README.md | 42 + utils/README.md | 42 + vitest.setup.ts | 66 ++ 48 files changed, 1922 insertions(+), 1113 deletions(-) create mode 100644 .github/CODING_STANDARDS.md create mode 100644 .github/copilot-instructions.md create mode 100644 components/README.md create mode 100644 components/ui/README.md create mode 100644 config/README.md create mode 100644 entrypoints/README.md create mode 100644 i18n/README.md create mode 100644 lib/README.md delete mode 100644 pages/Base64Converter/FileMode.tsx delete mode 100644 pages/Base64Converter/ImageMode.tsx delete mode 100644 pages/Base64Converter/__tests__/FileMode.test.tsx delete mode 100644 pages/Base64Converter/__tests__/ImageMode.test.tsx create mode 100644 pages/README.md create mode 100644 providers/README.md create mode 100644 public/README.md create mode 100644 src/README.md create mode 100644 types/README.md create mode 100644 utils/README.md diff --git a/.github/CODING_STANDARDS.md b/.github/CODING_STANDARDS.md new file mode 100644 index 0000000..8e36816 --- /dev/null +++ b/.github/CODING_STANDARDS.md @@ -0,0 +1,821 @@ +# 代码编写规范 + +本文档定义了 Testing Tools 浏览器扩展项目的编码规范和最佳实践。所有代码贡献者应遵循这些规范以保持代码库的一致性和可维护性。 + +## 1. TypeScript 规范 + +### 1.1 类型定义:`interface` vs `type` + +- **`interface`**:用于组件 Props、对象结构、Context 类型等可扩展结构 +- **`type`**:用于联合类型、工具类型、不可扩展的类型别名 + +```typescript +// ✅ interface — 组件 Props / 对象结构 +export interface GlobalSnackbarProps { + message: string; + open: boolean; + onClose: () => void; + severity?: SnackbarSeverity; +} + +// ✅ interface — 继承 HTML 属性 +interface LiveClockProps extends React.HTMLAttributes { + unit: UnitType; + onUseNow: (val: number) => void; +} + +// ✅ type — 联合类型 +export type ThemeMode = 'light' | 'dark' | 'system'; +export type PageType = 'dashboard' | 'timestamp' | 'storageCleaner' | ...; + +// ✅ type — 工具类型 +export type ResolvedThemeMode = 'light' | 'dark'; +``` + +### 1.2 泛型使用 + +广泛使用泛型约束,结合 `extends` 进行类型守卫: + +```typescript +// ✅ 泛型 + extends 约束 +export interface SwitchOption { + value: T; + label: React.ReactNode; +} + +// ✅ 泛型 + StorageSchema 键约束 +async get( + key: K, + defaultValue?: StorageSchema[K], +): Promise { ... } + +// ✅ 泛型 Hook +export const useStorageState = ( + key: K, + defaultValue: StorageSchema[K], + validator?: (val: unknown) => val is StorageSchema[K], +) => { ... } +``` + +### 1.3 类型守卫 + +优先使用类型守卫函数(`val is Type` 谓词),避免 `as` 强转: + +```typescript +// ✅ 类型守卫谓词函数 +const isValidMode = (v: unknown): v is ThemeMode => VALID_MODES.includes(v as ThemeMode); + +const isValidPage = (page: unknown): page is PageType => { + return typeof page === 'string' && (getAllFeatureKeys() as string[]).includes(page); +}; + +// ✅ 安全的 as 断言,仅在类型守卫验证后使用 +export function isSupportedImageType(mimeType: string): boolean { + return (SUPPORTED_IMAGE_TYPES as readonly string[]).includes(mimeType); +} +``` + +### 1.4 导出模式 + +| 场景 | 导出方式 | 示例 | +| ----------- | ------------------------------------------------ | --------------------------------- | +| 页面组件 | `export default function ComponentName()` | `pages/Timestamp/index.tsx` | +| 业务组件 | `const X = React.memo(...)` + `export default X` | `LiveClock.tsx`, `ResultView.tsx` | +| UI 原子组件 | `React.forwardRef(...)` + `export { X }` | `components/ui/button.tsx` | +| 工具函数 | `export function xxx()` | `utils/clipboard.ts` | +| 自定义 Hook | `export function useXxx()` | `utils/useStorageState.ts` | +| 类型/接口 | `export interface` / `export type` | `types/storage.d.ts` | + +```typescript +// ✅ 页面组件 — default export +export default function Index() { ... } + +// ✅ 需要 memo 的组件 — 箭头函数 + React.memo + displayName +const LiveClock = React.memo(({ ... }: LiveClockProps) => { ... }); +LiveClock.displayName = 'LiveClock'; +export default LiveClock; + +// ✅ UI 组件 — forwardRef + 命名导出 +const Button = React.forwardRef(...); +Button.displayName = 'Button'; +export { Button, buttonVariants }; +``` + +--- + +## 2. React 组件规范 + +### 2.1 组件定义方式 + +- **标准组件**:使用 `function` 声明 +- **需要 memo 的组件**:使用箭头函数 + `React.memo` +- **需要 ref 的组件**:使用 `React.forwardRef` +- **错误边界**:使用 Class 组件(React 要求) + +```typescript +// ✅ 标准页面组件 +export default function Index() { ... } + +// ✅ 需要 memo 的组件 +const LiveClock = React.memo(({ unit, onUseNow, className, ...props }: LiveClockProps) => { + ... +}); +LiveClock.displayName = 'LiveClock'; +export default LiveClock; + +// ✅ 需要 ref 的组件 +const TextInputArea = forwardRef((props, ref) => { + ... +}); +TextInputArea.displayName = 'TextInputArea'; +export default TextInputArea; + +// ✅ Class 组件(仅用于 ErrorBoundary) +export class ErrorBoundary extends Component { ... } +``` + +### 2.2 Props 模式 + +- 使用 `interface` 定义 Props +- 继承 `React.HTMLAttributes` 以支持原生属性透传 +- 使用 `Omit` 排除冲突属性 +- 解构 `className` 和 `...rest props` + +```typescript +// ✅ 继承 HTML 属性 + className 透传 +interface ResultViewProps extends React.HTMLAttributes { + result: string; + mode: 'ts2dt' | 'dt2ts'; + unit: UnitType; + zone: string; + showEmptyPlaceholder?: boolean; +} + +// 使用时解构 className 和 rest props +const ResultView = React.memo(({ + result, mode, unit, zone, + showEmptyPlaceholder = false, + className, ...props +}: ResultViewProps) => { + return
...
; +}); + +// ✅ Omit 排除冲突属性 +export interface TextInputAreaProps extends Omit< + React.TextareaHTMLAttributes, 'onChange' +> { ... } +``` + +### 2.3 状态管理 + +- **本地状态**:`useState` + 惰性初始化 +- **衍生状态**:`useMemo` 响应式计算管线 +- **持久化状态**:Chrome Storage + `localStorage` 快照 +- **全局状态**:React Context + +```typescript +// ✅ useState + 惰性初始化 +const [input, setInput] = useState(() => String(Date.now())); + +// ✅ useMemo 响应式计算管线(零延迟,无需手动 convert 按钮) +const conversionPipeline = useMemo(() => { + const rawInput = input.trim(); + if (!rawInput) return { result: '', error: '' }; + // ... 自动计算结果 +}, [input, mode, unit, zone, t]); + +// ✅ Chrome Storage 持久化状态 +export const useStorageState = ( + key: K, defaultValue: StorageSchema[K], validator?: ... +) => { ... } +``` + +### 2.4 副作用模式 + +- **取消标志**:防止异步竞态 +- **ref 回调指针**:保持回调最新避免依赖膨胀 +- **事件监听 cleanup**:始终在 cleanup 中移除监听器 +- **定时器 cleanup**:始终在 cleanup 中清除定时器 + +```typescript +// ✅ 取消标志模式 — 防止异步竞态 +useEffect(() => { + let cancelled = false; + storageUtil.get(THEME_MODE_KEY, 'system').then((saved) => { + if (cancelled) return; + if (isValidMode(saved)) { ... } + }); + return () => { cancelled = true; }; +}, [updateResolved]); + +// ✅ ref 回调指针 — 保持回调最新避免依赖膨胀 +const onUseNowRef = useRef(onUseNow); +useEffect(() => { onUseNowRef.current = onUseNow; }, [onUseNow]); + +// ✅ setInterval + cleanup +useEffect(() => { + const tickId = setInterval(tick, 200); + return () => clearInterval(tickId); +}, [unit]); + +// ✅ 事件监听 cleanup +useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { ... }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); +}, []); +``` + +### 2.5 `memo` / `useCallback` / `useMemo` 使用 + +| 场景 | 使用方式 | +| ---------------------------------- | ------------- | +| 高频渲染组件(列表子项、实时时钟) | `React.memo` | +| 事件处理函数、回调引用 | `useCallback` | +| 响应式计算管线、衍生数据 | `useMemo` | +| 避免重复创建对象/集合 | `useMemo` | + +```typescript +// ✅ React.memo — 高频更新组件 +const LiveClock = React.memo(({ ... }) => { ... }); +const ResultView = React.memo(({ ... }) => { ... }); + +// ✅ useCallback — 事件处理 +const handleUseNow = useCallback((now: number) => { + if (mode === 'ts2dt') { + setInput(String(unit === 'ms' ? now : Math.floor(now / 1000))); + } else { + setInput(dayjs(now).tz(zone).format(DATE_FORMAT)); + } +}, [mode, unit, zone]); + +// ✅ useMemo — 避免重复创建集合 +const visibleSet = useMemo(() => new Set(visiblePages), [visiblePages]); +``` + +--- + +## 3. 导入规范 + +### 3.1 导入顺序 + +按来源分组,顺序如下: + +1. React 核心 +2. 第三方库(图标、UI 库等) +3. 业务 Provider / Context +4. 配置 / 存储 +5. i18n +6. 本地页面组件 +7. UI 组件 +8. 工具函数 / Hook +9. 类型 +10. 常量 + +```typescript +// 1. React 核心 +import React, { useEffect, useMemo, useRef, useState } from 'react'; +// 2. 第三方库 +import { ArrowLeft, ExternalLink, Globe } from 'lucide-react'; +import { toast } from 'sonner'; +// 3. 业务 Provider +import { useRouter } from '@/providers/RouterProvider'; +import { useThemeMode } from '@/providers/ThemeModeProvider'; +// 4. 配置 / 存储 +import { FeatureConfig, FEATURES } from '@/config/features'; +import { storageUtil } from '@/utils/chromeStorage'; +// 5. i18n +import { useTranslation } from 'react-i18next'; +import { normalizeLanguage, SUPPORTED_LANGUAGES } from '@/i18n'; +// 6. 本地组件 +import TextMode from './TextMode'; +import { ZONES } from './constants'; +// 7. UI 组件 +import SwitchButtonGroup from '@/components/SwitchButtonGroup'; +import { Button } from '@/components/ui/button'; +// 8. 工具函数 / Hook +import { cn } from '@/lib/utils'; +import { useLazyTranslation } from '@/utils/useLazyTranslation'; +// 9. 类型 +import type { PageType, StorageSchema } from '@/types/storage'; +``` + +### 3.2 路径别名 + +- `@/` 映射到项目根目录 +- **跨目录导入**:使用 `@/` 绝对别名 +- **同目录导入**:使用相对路径 `./` + +```typescript +// ✅ 绝对别名导入 — 跨目录 +import { cn } from '@/lib/utils'; +import { storageUtil } from '@/utils/chromeStorage'; +import type { StorageSchema } from '@/types/storage'; +import { Button } from '@/components/ui/button'; + +// ✅ 相对导入 — 仅限同目录 +import TextMode from './TextMode'; +import { ZONES } from './constants'; +import { useTimestampConverter } from './useTimestampConverter'; +``` + +--- + +## 4. 样式规范 + +### 4.1 `cn()` 工具函数 + +统一使用 `cn()` 合并 Tailwind 类名(来自 `clsx` + `tailwind-merge`),导入自 `@/lib/utils`: + +```typescript +import { cn } from '@/lib/utils'; + +// ✅ 条件类名 + 合并外部 className +
+ +// ✅ 错误状态变体 + + +// ✅ 选中/未选中状态 +className={cn( + 'flex-1 inline-flex items-center justify-center font-medium whitespace-nowrap transition-all', + sizeClasses[size], + isSelected + ? 'bg-background text-foreground shadow-sm font-semibold' + : 'hover:bg-background/50 hover:text-foreground/80', + buttonClassName, +)} +``` + +### 4.2 主题 / 暗色模式 + +使用 shadcn/ui 的 CSS 变量语义化类名,**禁止硬编码颜色值**: + +```typescript +// ✅ 语义化颜色 token — 亮/暗模式自适应 +
+
+ +// ✅ 暗色模式特殊处理 +'fixed ... bg-white dark:bg-gray-900 p-6 ...' + +// ✅ 需要固定颜色的特殊场景(如二维码白色背景保护) +
+``` + +**常用语义化 token:** + +| 用途 | 类名 | +| ---- | ------------------------------------------------------------------ | +| 背景 | `bg-background`, `bg-card`, `bg-muted`, `bg-secondary` | +| 文字 | `text-foreground`, `text-card-foreground`, `text-muted-foreground` | +| 边框 | `border-border`, `border-border/80` | +| 主色 | `text-primary`, `bg-primary`, `border-primary` | +| 危险 | `text-destructive`, `bg-destructive`, `border-destructive` | + +### 4.3 响应式设计 + +移动优先,使用 `sm:` / `md:` / `lg:` 断点: + +```typescript +// ✅ Grid 自适应布局 +
+ +// ✅ Dashboard 自动填充网格 +
+ +// ✅ 弹性方向切换 +
+ +// ✅ 内边距响应式 +
+``` + +--- + +## 5. 错误处理 + +### 5.1 工具函数:结果对象模式 + +工具函数**不抛异常**,返回包含 `hasError` 和 `error` 字段的结果对象: + +```typescript +// ✅ 结果对象模式 +export function markdownToHtml(markdown: string): MarkdownToHtmlResult { + try { + ... + return { html, originalLength, htmlLength, hasError: false }; + } catch (error) { + return { + html: '', ... + hasError: true, + error: error instanceof Error ? error.message : 'Markdown 解析失败', + }; + } +} +``` + +### 5.2 UI 层:try-catch + Toast + +UI 层异步操作使用 try-catch,通过 `sonner` 的 `toast` 显示错误: + +```typescript +import { toast } from 'sonner'; + +const handleCopy = useCallback(async () => { + try { + await navigator.clipboard.writeText(value); + toast.success('复制成功'); + } catch { + toast.error('复制失败'); + } +}, [value]); +``` + +### 5.3 Promise 异常隔离 + +对不关心返回值的异步操作,使用 `void` + `.catch()` 隔离异常: + +```typescript +// ✅ void + .catch 模式 +void storageUtil.set(THEME_MODE_KEY, next).catch((err) => { + console.error('[Theme Storage Error] Failed to persistent theme state:', err); +}); + +// ✅ async 函数调用 + .catch +loadConfig().catch(console.error); +``` + +### 5.4 ErrorBoundary + +在应用顶层使用 `ErrorBoundary` 组件捕获子组件树异常: + +```typescript +import ErrorBoundary from '@/components/ErrorBoundary'; + + + + +``` + +--- + +## 6. 命名规范 + +### 6.1 文件命名 + +| 类型 | 命名模式 | 示例 | +| ----------- | -------------------------- | -------------------------------------------------------- | +| 页面目录 | PascalCase | `Timestamp/`, `Base64Converter/`, `StorageCleaner/` | +| 页面入口 | `index.tsx` | `pages/Timestamp/index.tsx` | +| 组件文件 | PascalCase `.tsx` | `TopBar.tsx`, `LiveClock.tsx`, `ResultView.tsx` | +| 自定义 Hook | camelCase `.ts` | `useTimestampConverter.ts`, `useStorageCleaner.ts` | +| 工具函数 | camelCase `.ts` | `chromeStorage.ts`, `base64Converter.ts`, `clipboard.ts` | +| 测试文件 | 与源文件同名 `.test.ts(x)` | `jwt.test.ts`, `SwitchButtonGroup.test.tsx` | +| 类型文件 | camelCase `.d.ts` | `storage.d.ts` | +| 常量文件 | camelCase `.ts` | `constants.ts` | + +### 6.2 变量 / 函数命名 + +```typescript +// ✅ camelCase — 变量、函数、Hook +const conversionPipeline = useMemo(...); +const handleUseNow = useCallback(...); +export function useTimestampConverter(): UseTimestampConverterReturn { ... } +export function textToBase64(text: string): TextToBase64Result { ... } + +// ✅ PascalCase — 组件、类型、接口 +const LiveClock = React.memo(...); +export interface GlobalSnackbarProps { ... } +export type ThemeMode = 'light' | 'dark' | 'system'; + +// ✅ SCREAMING_SNAKE_CASE — 常量 +const SEARCH_HISTORY_LIMIT = 10; +const THEME_MODE_KEY = 'app/themeMode' as const; +export const MAX_FILE_SIZE = 10 * 1024 * 1024; +export const SUPPORTED_IMAGE_TYPES = [...] as const; + +// ✅ 布尔值 — is/has/should 前缀 +const isControlled = controlledValue !== undefined; +const isDashboard = currentPage === 'dashboard'; +const hasError = true; +``` + +### 6.3 事件处理函数 + +使用 `handle` 前缀命名组件内事件处理函数: + +```typescript +const handleUseNow = useCallback((now: number) => { ... }, []); +const handleSelectFeature = (feature: FeatureConfig) => { ... }; +const handleFileChange = useCallback((file: File) => { ... }, []); +const handleClean = useCallback(async () => { ... }, []); +``` + +--- + +## 7. 测试规范 + +### 7.1 文件组织 + +测试文件放在源代码同级的 `__tests__/` 目录下: + +``` +utils/__tests__/jwt.test.ts +utils/__tests__/base64Converter.test.ts +utils/__tests__/useStorageState.test.ts +components/__tests__/SwitchButtonGroup.test.tsx +components/__tests__/ErrorBoundary.test.tsx +pages/Timestamp/__tests__/index.test.tsx +``` + +### 7.2 describe / it 命名 + +`describe` 使用模块/函数名,`it` 使用中文描述行为("应该..."): + +```typescript +// ✅ 中文 describe + 中文 it +describe('textToBase64', () => { + it('应该编码 ASCII 文本', () => { ... }); + it('应该编码中文文本', () => { ... }); + it('应该编码空字符串', () => { ... }); +}); + +// ✅ 中文 describe + 中文 it(组件测试) +describe('SwitchButtonGroup 组件', () => { + it('应渲染所有选项按钮', () => { ... }); + it('应高亮当前选中的按钮', () => { ... }); + it('点击未选中按钮时应触发 onChange 并传入选中值', () => { ... }); +}); +``` + +### 7.3 Mock 模式 + +- 使用 `vi.mock()` 进行模块级 Mock +- 使用 `vi.fn()` 进行函数级 Mock +- 使用 `vi.useFakeTimers()` 控制时间 +- **避免重复 mock `vitest.setup.ts` 中已有的内容**(chrome API、i18n、matchMedia 等) + +```typescript +// ✅ 模块级 Mock +vi.mock('@/utils/chromeStorage', () => ({ + storageUtil: { + get: vi.fn(), + set: vi.fn(() => Promise.resolve()), + }, +})); + +// ✅ 函数级 Mock + 断言 +const handleChange = vi.fn(); +render(); +fireEvent.click(screen.getByRole('button', { name: /选项B/i })); +expect(handleChange).toHaveBeenCalledTimes(1); +expect(handleChange).toHaveBeenCalledWith('b'); + +// ✅ 定时器 Mock +beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); +}); +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); +}); +``` + +### 7.4 断言模式 + +使用 Testing Library 的 DOM 查询 + Vitest 匹配器: + +```typescript +// ✅ 语义化查询 +expect(screen.getByRole('button', { name: /选项A/i })).toBeInTheDocument(); +expect(screen.getByTestId('normal-content')).toHaveTextContent('正常内容'); +expect(screen.queryByText('糟糕,出了点问题')).not.toBeInTheDocument(); + +// ✅ CSS 类断言 +expect(button).toHaveClass('bg-background', 'text-foreground', 'shadow-sm'); + +// ✅ 异步断言 +await waitFor(() => { + expect(result.current[0]).toBe(false); + expect(result.current[2]).toBe(true); +}); + +// ✅ renderHook 测试自定义 Hook +const { result } = renderHook(() => useStorageState('qrCode/urlExpanded', true)); +expect(result.current[0]).toBe(true); +``` + +--- + +## 8. 自定义 Hook 规范 + +### 8.1 命名和结构 + +- 使用 `use` 前缀命名 +- 定义返回值接口类型 +- 添加 JSDoc 注释 + +```typescript +// ✅ 完整的 Hook 结构 +/** + * 自定义 Hook:处理右键菜单传递的数据 + * + * @param options - 配置选项 + * @example + * useContextMenuData({ featureKey: 'jwt', onData: handlePayload }); + */ +export function useContextMenuData({ featureKey, onData }: UseContextMenuDataOptions): void { + const checkAndConsumeData = useCallback(async () => { ... }, [featureKey, onData]); + useEffect(() => { checkAndConsumeData(); }, [checkAndConsumeData]); +} + +// ✅ 返回值接口定义 +export interface UseTimestampConverterReturn { + mode: 'ts2dt' | 'dt2ts'; + input: string; + result: string; + error: string; + setMode: (mode: 'ts2dt' | 'dt2ts') => void; + setInput: (value: string) => void; + handleUseNow: (now: number) => void; +} + +export function useTimestampConverter(): UseTimestampConverterReturn { ... } +``` + +### 8.2 Hook 存放位置 + +- **全局通用 Hook**:放在 `utils/` 目录下 +- **页面专属 Hook**:与页面组件同目录 + +``` +utils/useStorageState.ts — Chrome Storage 状态持久化 +utils/useLazyTranslation.ts — i18n 懒加载 +utils/useContextMenuData.ts — 右键菜单数据 +utils/useDebounce.ts — 防抖 +pages/Timestamp/useTimestampConverter.ts — 页面级 Hook +pages/StorageCleaner/useStorageCleaner.ts — 页面级 Hook +``` + +--- + +## 9. 国际化规范 + +### 9.1 翻译键格式 + +- 命名空间:`common`(默认)、`features` +- 翻译键格式:`namespace:key`(如 `features:timestamp.title`) +- 语言:`zh`(默认)、`en` + +### 9.2 翻译文件结构 + +``` +i18n/locales/{zh,en}/common.json — 全局通用翻译 +i18n/locales/{zh,en}/features.json — 功能模块标题和描述 +i18n/locales/{zh,en}/{功能名}.json — 各功能独立翻译 +``` + +### 9.3 使用方式 + +```typescript +// ✅ 页面组件 — 使用 useLazyTranslation +import { useLazyTranslation } from '@/utils/useLazyTranslation'; + +export default function Index() { + const { t } = useLazyTranslation('timestamp'); + return

{t('timestamp:title')}

; +} + +// ✅ 全局组件 — 使用 useTranslation +import { useTranslation } from 'react-i18next'; + +export function TopBar() { + const { t } = useTranslation(['common', 'features']); + return {t('common:settings')}; +} +``` + +### 9.4 添加新翻译 + +1. 在 `i18n/locales/{zh,en}/features.json` 添加功能标题和描述 +2. 创建 `i18n/locales/{zh,en}/{功能名}.json` 添加功能专属翻译 +3. 在 `utils/useLazyTranslation.ts` 的 `localeModules` 中注册新命名空间 + +--- + +## 10. 存储规范 + +### 10.1 StorageSchema + +所有 Chrome Storage 键必须在 `types/storage.d.ts` 的 `StorageSchema` 中声明: + +```typescript +export interface StorageSchema { + 'app/currentRoute': PageType; + 'app/popupRoute': PageType; + 'app/theme': string; + 'app/themeMode': 'light' | 'dark' | 'system'; + 'storageCleaner/preferences': StorageCleanerPreferences; + // ... +} +``` + +### 10.2 存储操作 + +使用 `utils/chromeStorage.ts` 的类型安全封装: + +```typescript +import { storageUtil } from '@/utils/chromeStorage'; +import type { StorageSchema } from '@/types/storage'; + +// ✅ 读取 +const theme = await storageUtil.get('app/theme', 'default'); + +// ✅ 写入 +await storageUtil.set('app/theme', 'dark'); + +// ✅ 删除 +await storageUtil.remove('app/theme'); +``` + +### 10.3 持久化状态 Hook + +使用 `useStorageState` 自动同步 Chrome Storage: + +```typescript +import { useStorageState } from '@/utils/useStorageState'; + +const [themeMode, setThemeMode, isInitialized] = useStorageState( + 'app/themeMode', + 'system', + isValidMode, // 可选的类型守卫 +); +``` + +--- + +## 11. 文件组织 + +### 11.1 页面组件结构 + +``` +pages/FeatureName/ +├── index.tsx # 页面 UI(纯展示,使用 shadcn/ui 组件) +├── useFeatureName.ts # 业务逻辑 Hook(状态管理 + 转换逻辑) +├── constants.ts # 常量定义(可选) +├── LiveClock.tsx # 子组件(可选) +├── ResultView.tsx # 子组件(可选) +└── __tests__/ # 测试文件 + └── index.test.tsx +``` + +### 11.2 目录职责 + +| 目录 | 职责 | +| ---------------- | ------------------------------------------------------------ | +| `config/` | 应用配置(功能定义、路由映射) | +| `entrypoints/` | 扩展入口点(popup、options、sidepanel、background、content) | +| `pages/` | 功能页面组件(懒加载) | +| `components/` | 可复用 UI 组件 | +| `components/ui/` | shadcn/ui 基础组件(button、dialog、select 等) | +| `providers/` | React Context(Router、Theme 等) | +| `hooks/` | 自定义 React Hooks | +| `utils/` | 工具函数与服务抽象 | +| `types/` | TypeScript 类型声明 | +| `lib/` | 通用工具函数(cn、utils) | +| `i18n/` | 国际化资源 | +| `public/` | 静态资源 | + +--- + +## 12. 代码风格 + +### 12.1 Prettier 配置 + +- 行宽:100 字符 +- 引号:单引号 +- 尾逗号:all +- 换行符:LF +- 分号:是 + +### 12.2 ESLint 规则 + +- 禁止使用 `any`(测试文件除外) +- 未使用变量/参数:使用 `_` 前缀(如 `_unused`) +- React 19 JSX Runtime:无需手动导入 React +- 使用 `typescript-eslint` 的 `projectService: true` + +### 12.3 注释规范 + +- **文件级注释**:使用 JSDoc `@module` 格式(如 `GlobalSnackbar.tsx`) +- **函数注释**:使用 JSDoc,包含 `@param`、`@returns`、`@example` +- **行内注释**:仅在需要澄清复杂逻辑时使用 +- **禁止注释显而易见的代码** diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..ff35225 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,141 @@ +# Copilot 指令 + +基于 WXT 框架的浏览器扩展项目(React 19 + TypeScript),为开发者和测试人员提供效率工具:时间戳转换、存储清理、JWT 解析、JSON 工具、二维码、Base64、Markdown 等。 + +## 核心命令 + +```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 分离** 模式: + +``` +pages/FeatureName/ +├── index.tsx # UI 组件(纯展示,使用 shadcn/ui 组件) +├── useFeatureName.ts # 业务逻辑 Hook(状态管理 + 转换逻辑) +└── constants.ts # 常量定义(可选) +``` + +- 页面组件调用 `useLazyTranslation('featureName')` 获取翻译函数 +- Hook 负责所有状态管理,通过返回值暴露给页面 +- 子组件可进一步拆分(如 `LiveClock.tsx`、`ResultView.tsx`) + +### 新功能开发清单 + +1. 在 `types/storage.d.ts` 的 `PageType` 联合类型中添加新成员 +2. 在 `config/features.tsx` 的 `FEATURES` 数组中添加配置(key、翻译键、图标、三种渲染模式组件) +3. 在 `pages/` 目录创建页面组件(懒加载): + - `index.tsx` — 使用 `useLazyTranslation` 的 UI 组件 + - `useFeatureName.ts` — 业务逻辑 Hook + - `constants.ts` — 常量(可选) +4. 在 `i18n/locales/{zh,en}/features.json` 添加翻译(复杂功能可新建独立 JSON 文件) +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-i18n、vendor-qr、vendor-dnd、vendor-markdown),无需手动配置。 + +### 代码风格 + +- 禁止使用 `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 等) + - `react-i18next`(返回 key 作为翻译) + - `@/utils/useLazyTranslation`(返回 `ns:key` 格式) + - `window.matchMedia` +- 测试文件命名:`__tests__/*.test.{ts,tsx}` 或 `*.test.{ts,tsx}` +- 使用 `vi.mock()` 进行模块级 mock;避免重复 mock `vitest.setup.ts` 中已有的内容 +- 测试工具:`@testing-library/react` + `@testing-library/user-event` + +### 国际化(i18n) + +- 命名空间:`common`(默认)、`features` +- 翻译键格式:`namespace:key`(如 `features:timestamp.title`) +- 语言:`zh`(默认)、`en` +- 翻译文件:`i18n/locales/{zh,en}/{common,features}.json` + 各功能独立 JSON 文件 +- 使用 `useLazyTranslation` Hook 加载功能专属翻译 +- 回退策略:缺失的翻译键回退到 `zh`,若仍缺失则返回占位格式 `namespace:key` + +### WXT 生成文件 + +- `.wxt/` 目录由 `postinstall`(`wxt prepare`)自动生成,包含 TypeScript 类型声明和扩展 tsconfig +- 生产构建输出到 `.output/` 目录 +- `tsconfig.json` 继承自 `./.wxt/tsconfig.json` diff --git a/AGENTS.md b/AGENTS.md index f28e8bd..bdf1672 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,6 +47,7 @@ config/features.tsx # 功能定义(路由 + 元数据的单一事实来源 entrypoints/ # 扩展入口点 (popup/, options/, sidepanel/, background.ts, content.ts) pages/ # 功能页面组件 (懒加载) components/ # 可复用 UI 组件 +components/ui/ # shadcn/ui 基础组件 (button, dialog, select 等) providers/ # React Context (Router, Theme 等) hooks/ # 自定义 React Hooks utils/ # 工具函数与服务抽象 @@ -54,11 +55,26 @@ types/ # TypeScript 类型声明 i18n/locales/{zh,en}/ # 国际化资源 (common.json, features.json 及各功能独立 JSON) ``` +### 页面组件模式 + +典型功能页面遵循 **UI + Hook 分离** 模式: + +``` +pages/FeatureName/ +├── index.tsx # 页面 UI(纯展示,使用 shadcn/ui 组件) +├── useFeatureName.ts # 业务逻辑 Hook(状态管理 + 转换逻辑) +└── constants.ts # 常量定义 +``` + +- 页面组件调用 `useLazyTranslation('featureName')` 获取翻译函数 +- Hook 负责所有状态管理和业务逻辑,通过返回值暴露给页面 +- 子组件可进一步拆分(如 `LiveClock.tsx`、`ResultView.tsx`) + ## 关键架构决策 **路由**: 不使用 React Router。通过 `config/features.tsx` 的 `FEATURES` 数组管理,`RouterProvider` 根据 `PageType` 渲染对应组件。支持三种渲染模式:popup(弹窗)、sidepanel(侧边栏)和 browser-tab(浏览器新标签页,通过 `open_in_tab` 打开)。 -每种模式有独立的路由和可见页面配置。 +每种模式有独立的路由和可见页面配置(`app/popupRoute`、`app/sidepanelRoute`、`app/tabRoute` 等)。 **存储**: 所有 Chrome Storage 键必须在 `types/storage.d.ts` 的 `StorageSchema` 中定义,键名使用 kebab-case 格式(如 `app/currentRoute`)。 使用 `utils/chromeStorage.ts` 及其 Hook。Router 同时使用 `chrome.storage.local` 和 `localStorage` 做快照以消除首屏闪烁。 @@ -70,6 +86,8 @@ i18n/locales/{zh,en}/ # 国际化资源 (common.json, features.json 及各功 **浏览器兼容**: 优先使用 `wxt/browser` 导出的 `browser` 对象,而非原生 `chrome` API。 +**代码分割**: `wxt.config.ts` 通过 `manualChunksForHtmlOnly()` 自动分组依赖(vendor-react、vendor-i18n、vendor-qr 等),无需手动配置。 + ## 测试环境 - 环境: jsdom @@ -99,8 +117,11 @@ i18n/locales/{zh,en}/ # 国际化资源 (common.json, features.json 及各功 ## 新功能开发清单 1. 在 `types/storage.d.ts` 添加 `PageType` 联合类型 -2. 在 `config/features.tsx` 的 `FEATURES` 数组添加配置 -3. 在 `pages/` 创建页面组件 (懒加载) +2. 在 `config/features.tsx` 的 `FEATURES` 数组添加配置(指定 key、翻译键、图标、三种渲染模式的组件) +3. 在 `pages/` 创建页面组件 (懒加载): + - `index.tsx` — UI 组件,使用 `useLazyTranslation` 获取翻译 + - `useFeatureName.ts` — 业务逻辑 Hook + - `constants.ts` — 常量(可选) 4. 在 `i18n/locales/{zh,en}/features.json` 添加翻译(复杂功能可新建独立 JSON) 5. 如需新权限,更新 `wxt.config.ts` 的 `manifest.permissions` 6. 添加对应的单元测试 @@ -115,15 +136,11 @@ i18n/locales/{zh,en}/ # 国际化资源 (common.json, features.json 及各功 - 格式: Prettier (`.prettierrc`: 100 字符宽, 单引号, 尾逗号 all, LF 换行) - ESLint 使用 `typescript-eslint` 的 `projectService: true`(无需手动维护 project 路径) -## 技术栈版本 +## 关键外部库(非显而易见的) -- WXT: ^0.20.26 -- React: ^19.2.6 -- Tailwind CSS: ^3.4.19 -- shadcn/ui (基于 Radix UI + class-variance-authority) -- TypeScript: ^5.9.3 -- Vitest: ^4.1.7 -- i18next: ^26.2.0 -- @dnd-kit (拖拽排序) -- marked (Markdown 解析) -- qrious + qr-scanner (二维码生成与解析) +- `@webext-core/messaging` — 扩展消息通信 +- `@dnd-kit` — 拖拽排序(用于页面顺序管理) +- `marked` — Markdown 解析 +- `qrious` + `qr-scanner` — 二维码生成与解析 +- `dayjs` — 日期处理(时间戳转换) +- `sonner` — Toast 通知(替代传统 snackbar) diff --git a/components/CopyButton.tsx b/components/CopyButton.tsx index 77c89b9..608edf7 100644 --- a/components/CopyButton.tsx +++ b/components/CopyButton.tsx @@ -1,26 +1,25 @@ import React, { useEffect, useRef, useState } from 'react'; import { Check, Copy } from 'lucide-react'; import { copyTextToClipboard } from '@/utils/clipboard'; -import { cn } from '@/lib/utils'; // 1. 必须使用 cn 工具函数 -import { toast } from 'sonner'; // 2. 推荐使用 shadcn 默认的全局 toast +import { cn } from '@/lib/utils'; +import { buttonVariants, type ButtonProps } from '@/components/ui/button'; +import { toast } from 'sonner'; +import { useTranslation } from 'react-i18next'; -// 3. 继承原生按钮属性,允许外部自由扩展 className、variant 等 -interface CopyButtonProps extends React.ButtonHTMLAttributes { +interface CopyButtonProps extends Omit { text: string; tooltip?: string; - size?: 'small' | 'medium' | 'large'; - // 移除复杂的自定义颜色变体,交由 Tailwind 类名或 shadcn 的 variant 解决 - variant?: 'default' | 'secondary' | 'ghost' | 'outline'; } export const CopyButton: React.FC = ({ text, - tooltip = '复制', - size = 'small', + tooltip, variant = 'ghost', + size = 'icon', className, ...props }) => { + const { t } = useTranslation('common'); const [copied, setCopied] = useState(false); const timerRef = useRef | null>(null); @@ -31,53 +30,34 @@ export const CopyButton: React.FC = ({ }, []); const handleCopy = async (e: React.MouseEvent) => { - e.stopPropagation(); // 基础组件防冒泡,避免触发父级点击事件 + e.stopPropagation(); if (!text) { - toast.error('无内容可复制'); + toast.error(t('messages.copyEmpty')); return; } const success = await copyTextToClipboard(text); if (success) { - toast.success('复制成功'); + toast.success(t('messages.copySuccess')); setCopied(true); if (timerRef.current) clearTimeout(timerRef.current); timerRef.current = setTimeout(() => setCopied(false), 1500); } else { - toast.error('复制失败'); + toast.error(t('messages.copyError')); } }; - // 4. 将控制尺寸的类名标准化 - const sizeClasses = { - small: 'h-8 w-8 text-xs', - medium: 'h-10 w-10 text-sm', - large: 'h-12 w-12 text-base', - }; - - // 5. 映射 shadcn 的底层通用 Variant 类名 - const variantClasses = { - default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90', - secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80', - ghost: 'hover:bg-accent hover:text-accent-foreground', - outline: - 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground', - }; - return (
@@ -72,4 +73,5 @@ export class ErrorBoundary extends Component { } } +export const ErrorBoundary = withTranslation('common')(ErrorBoundaryBase); export default ErrorBoundary; diff --git a/components/GlobalSnackbar.tsx b/components/GlobalSnackbar.tsx index f663106..096dee8 100644 --- a/components/GlobalSnackbar.tsx +++ b/components/GlobalSnackbar.tsx @@ -6,6 +6,10 @@ * 2. 通过 useSnackbarState Hook 使用:在组件内部自动管理状态 * 3. 通过 SnackbarProvider 和 useSnackbar Hook 使用:全局单例模式 * + * NOTE: 项目同时使用 sonner 的 toast 进行简单的一次性提示。 + * 本组件适用于需要 severity 级别、Provider 上下文、自定义定位等高级场景。 + * 简单场景(如复制成功、操作提示)优先使用 `import { toast } from 'sonner'`。 + * * @module GlobalSnackbar * @version 1.1.0 * diff --git a/components/ImageUploader.tsx b/components/ImageUploader.tsx index 6aba860..4744764 100644 --- a/components/ImageUploader.tsx +++ b/components/ImageUploader.tsx @@ -41,7 +41,7 @@ const ImageUploader = ({ [onFileChange, onPreviewUrlChange], ); - const handleClearFile = () => { + const handleClearFile = useCallback(() => { if (previewUrl) { URL.revokeObjectURL(previewUrl); } @@ -50,7 +50,7 @@ const ImageUploader = ({ severity: 'success', autoHideDuration: 1000, }); - }; + }, [previewUrl, onClearFile, showMessage, t]); const handleInputChange = (e: React.ChangeEvent) => { if (e.target.files && e.target.files.length > 0) { diff --git a/components/PageErrorBoundary.tsx b/components/PageErrorBoundary.tsx index 6bb511e..bb1cc12 100644 --- a/components/PageErrorBoundary.tsx +++ b/components/PageErrorBoundary.tsx @@ -1,8 +1,9 @@ import { Component, ErrorInfo, ReactNode } from 'react'; +import { withTranslation, type WithTranslation } from 'react-i18next'; import { AlertCircle, RefreshCw } from 'lucide-react'; import { Button } from '@/components/ui/button'; -interface Props { +interface Props extends WithTranslation { children: ReactNode; resetKey?: string | number; } @@ -12,11 +13,7 @@ interface State { error: Error | null; } -/** - * 页面级错误边界组件:捕获子组件树中的 JavaScript 错误 - * 完美适配 shadcn/ui 语义化主题与暗黑模式 - */ -export class PageErrorBoundary extends Component { +class PageErrorBoundaryBase extends Component { state: State = { hasError: false, error: null, @@ -41,26 +38,22 @@ export class PageErrorBoundary extends Component { }; render() { + const { t } = this.props; if (this.state.hasError) { return (
- {/* - 1. 适配暗黑模式的容器设计: - 不再使用 border-red-200 / bg-red-50,改用标准的 border-destructive/20 和 bg-destructive/5, - 并在黑夜模式下会自动转为深红底色,绝不刺眼。 - */}
- {/* 2. 状态符号改用标准的 text-destructive 语义色 */}
-

该功能运行异常

+

+ {t('pageErrorBoundary.title')} +

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

- {/* 3. 错误日志展示:使用与 shadcn 贴合的深色代码块包裹 */} {this.state.error && (
@@ -69,11 +62,6 @@ export class PageErrorBoundary extends Component {
               
)} - {/* - 4. 严谨调用 shadcn 原子 Button: - 去掉全部手动指定的红底白字类名,直接启用 variant="destructive"。 - 它会自动处理 hover 颜色变化、暗黑模式切换以及无障碍高亮边框。 - */}
@@ -92,4 +80,5 @@ export class PageErrorBoundary extends Component { } } +export const PageErrorBoundary = withTranslation('common')(PageErrorBoundaryBase); export default PageErrorBoundary; diff --git a/components/QrCodePreview.tsx b/components/QrCodePreview.tsx index 5640f03..82864f1 100644 --- a/components/QrCodePreview.tsx +++ b/components/QrCodePreview.tsx @@ -1,7 +1,8 @@ import React from 'react'; import { Copy, Download } from 'lucide-react'; import { useLazyTranslation } from '@/utils/useLazyTranslation'; -import { cn } from '@/lib/utils'; // 1. 引入标准的 shadcn 工具函数 +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; // 继承原生 HTML Div 属性,方便外部无缝扩充类名或监听事件 interface QrCodePreviewProps extends React.HTMLAttributes { @@ -59,27 +60,16 @@ const QrCodePreview = ({ />
- {/* 3. 按钮群全面向 shadcn 官方 Button 视觉规范对齐 */}
- {/* 下载按钮:使用标准的次要按钮风格 (Outline) */} - + - {/* 复制按钮:使用标准的主要行动按钮风格 (Default) */} - +
diff --git a/components/README.md b/components/README.md new file mode 100644 index 0000000..abd1286 --- /dev/null +++ b/components/README.md @@ -0,0 +1,27 @@ +# components/ + +通用业务组件目录,存放跨页面复用的 UI 组件,与具体工具页面解耦。 + +## 组件列表 + +| 组件 | 用途 | +| ----------------------- | ------------------------------------------------------------------------------ | +| `TopBar.tsx` | 顶部导航栏,集成搜索(含历史记录)、主题切换、语言切换、返回导航 | +| `RouterContainer.tsx` | 路由容器,根据当前路由动态渲染对应页面组件,集成错误边界和骨架屏 | +| `SwitchButtonGroup.tsx` | 通用切换按钮组,支持 `small/medium/large` 三种尺寸,用于页面子模式切换 | +| `TextInputArea.tsx` | 增强文本输入区域,支持校验规则、工具栏操作、字符计数、清空 | +| `CopyButton.tsx` | 一键复制按钮,支持复制成功状态动画,封装 `copyTextToClipboard` 和 `toast` 反馈 | +| `ImageUploader.tsx` | 图片上传组件,支持拖拽上传、文件选择和预览 | +| `QrCodePreview.tsx` | 二维码预览组件,展示生成的二维码图片,提供复制和下载操作 | +| `DecodeResultPaper.tsx` | Base64 解码结果展示面板,显示 MIME 类型、文件大小、文件名输入和下载按钮 | +| `GlobalSnackbar.tsx` | 全局消息提示组件 + Context Provider,支持受控/Hook/全局单例三种使用方式 | +| `ErrorBoundary.tsx` | 全局错误边界(类组件),捕获子组件树 JS 错误并展示友好错误页面 | +| `PageErrorBoundary.tsx` | 页面级错误边界,适配 shadcn 暗黑模式,支持 `resetKey` 自动恢复 | +| `PageSkeleton.tsx` | 页面骨架屏,提供 `dashboard` 和 `tool` 两种变体,用于 Suspense fallback | + +## 使用约定 + +- 优先使用 `components/ui/` 下的 shadcn/ui 基础组件 +- 组件使用 `cn()` 合并 Tailwind 类名,支持 `className` 透传 +- 需要 memo 优化的组件使用 `React.memo` + `displayName` +- 需要 ref 转发的组件使用 `React.forwardRef` + `displayName` diff --git a/components/RouterContainer.tsx b/components/RouterContainer.tsx index fbcb3ce..2689f41 100644 --- a/components/RouterContainer.tsx +++ b/components/RouterContainer.tsx @@ -1,6 +1,7 @@ import { FEATURES, getEntryPointType } from '@/config/features'; import { useRouter } from '@/providers/RouterProvider'; import { Suspense, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; import PageErrorBoundary from '@/components/PageErrorBoundary'; import PageSkeleton from '@/components/PageSkeleton'; import { cn } from '@/lib/utils'; // 1. 引入标准的 shadcn 工具函数 @@ -8,15 +9,14 @@ import { AlertTriangle } from 'lucide-react'; // 用于标准的 404 异常展 export default function RouterContainer() { const { currentPage, isLoaded } = useRouter(); + const { t } = useTranslation('common'); // 2. 稳定的动态动画类名映射 const animationClass = useMemo(() => { return currentPage === 'dashboard' ? 'page-transition-dashboard' : 'page-transition-enter'; }, [currentPage]); - const entryPointType = useMemo(() => { - return getEntryPointType(); - }, []); + const entryPointType = getEntryPointType(); // 骨架屏加载状态守卫 if (!isLoaded) { @@ -52,9 +52,9 @@ export default function RouterContainer() {
-

页面未找到

+

{t('router.notFound')}

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

)} diff --git a/components/SwitchButtonGroup.tsx b/components/SwitchButtonGroup.tsx index cf4c8ee..831ad7a 100644 --- a/components/SwitchButtonGroup.tsx +++ b/components/SwitchButtonGroup.tsx @@ -34,14 +34,10 @@ export default function SwitchButtonGroup({ large: 'text-base h-11 px-4 py-2 rounded-lg', }; - const containerPadding = size === 'large' ? 'p-1' : 'p-1'; - return (
boolean; @@ -183,19 +184,9 @@ const TextInputArea = forwardRef((props onChange?.(''); setError(''); internalRef.current?.focus(); - toast.success('已清空内容'); + toast.success(t('textInputArea.cleared')); onClear?.(); - }, [isControlled, onChange, onClear]); - - const handleCopy = useCallback(async () => { - try { - await navigator.clipboard.writeText(value); - toast.success('复制成功'); - } catch { - setError('复制失败'); - toast.error('复制失败'); - } - }, [value]); + }, [isControlled, onChange, onClear, t]); const handleAction = useCallback( (action: ToolbarAction) => { @@ -284,14 +275,12 @@ const TextInputArea = forwardRef((props {/* 右侧系统按钮组 */}
{allowCopy && value && ( - + )} {showClear && value && !disabled && !readOnly && ( -
-
- )} -
- ) : ( -
- - {decoded && ( - - )} -
- )} -
- ); -} diff --git a/pages/Base64Converter/ImageMode.tsx b/pages/Base64Converter/ImageMode.tsx deleted file mode 100644 index eafb0c3..0000000 --- a/pages/Base64Converter/ImageMode.tsx +++ /dev/null @@ -1,259 +0,0 @@ -import { Image as ImageIcon, Trash2 } from 'lucide-react'; -import TextInputArea from '@/components/TextInputArea'; -import { useLazyTranslation } from '@/utils/useLazyTranslation'; -import CopyButton from '@/components/CopyButton'; -import DecodeResultPaper from '@/components/DecodeResultPaper'; -import { Button } from '@/components/ui/button'; -import { downloadBlob, formatFileSize } from '@/utils/base64Converter'; -import { useStorageState } from '@/utils/useStorageState'; -import type { Base64ConvertDirection } from '@/types/storage'; -import SwitchButtonGroup from '@/components/SwitchButtonGroup'; -import { useBase64Converter } from './useBase64Converter'; // 💡 引入共享核心 -import { cn } from '@/lib/utils'; - -const isValidDirection = (val: unknown): val is Base64ConvertDirection => - val === 'encode' || val === 'decode'; - -export default function ImageMode() { - const { t } = useLazyTranslation('base64Converter'); - const [direction, setDirection] = useStorageState( - 'base64Converter/imageMode/direction', - 'encode', - isValidDirection, - ); - - // 消费完全托管的核心 Hook,消灭本地多余状态机 - const { - result, - info, - isLoading, - isDragging, - setIsDragging, - fileInputRef, - encodeError, - decodeInput, - setDecodeInput, - decoded, - decodeError, - decodedFileName, - setCustomFileName, - resetAll, - safeFileSelect, - } = useBase64Converter({ mode: 'image' }); - - const handleDirectionChange = (next: Base64ConvertDirection) => { - if (!next || next === direction) return; - resetAll(); - setDirection(next); - }; - - const handleDownload = () => { - if (decoded) downloadBlob(decoded.blob, decodedFileName); - }; - - return ( -
-
- -
- - {direction === 'encode' ? ( -
- {/* 图片拖拽投递箱终端 */} -
{ - e.preventDefault(); - setIsDragging(true); - }} - onDragLeave={() => setIsDragging(false)} - onDrop={(e) => { - e.preventDefault(); - setIsDragging(false); - const file = e.dataTransfer.files[0]; - if (file) safeFileSelect(file); - }} - onClick={() => fileInputRef.current?.click()} - className={cn( - 'flex flex-col items-center justify-center min-h-[190px] border-2 border-dashed rounded-2xl p-8 cursor-pointer', - isDragging - ? 'border-primary bg-primary/10' - : info - ? 'border-primary/60 bg-primary/5' - : 'border-border bg-muted/40 hover:border-primary/80 hover:bg-muted/70', - )} - > - { - const file = e.target.files?.[0]; - if (file) safeFileSelect(file); - }} - /> - {isLoading ? ( -
- ) : info ? ( -
- {result && ( -
- preview -
- )} - - {info.name} - - - {formatFileSize(info.size)} · {info.type} - - - {t('clickOrDropToReplace')} - -
- ) : ( -
- - - {t('clickOrDropToImage')} - - - {t('supportedFormats')} - -
- )} -
- - {encodeError && ( -
- {encodeError} -
- )} - - {result && ( -
-
- - {t('base64Output')} - -
- - -
-
- - 2000 - ? `${result.output.substring(0, 2000)}...` - : result.output - } - showClear={false} - minRows={4} - /> - -
-
- - {t('originalSize')}:{' '} - - {formatFileSize(result.originalBytes)} - - - | - - {t('encodedSize')}:{' '} - - {formatFileSize(result.outputBytes)} - - -
- - -
-
- )} - - {info && !result && ( -
- -
- )} -
- ) : ( -
- - {decoded && ( -
- -
- decoded preview -
-
-
- )} -
- )} -
- ); -} diff --git a/pages/Base64Converter/__tests__/FileMode.test.tsx b/pages/Base64Converter/__tests__/FileMode.test.tsx deleted file mode 100644 index 4af2760..0000000 --- a/pages/Base64Converter/__tests__/FileMode.test.tsx +++ /dev/null @@ -1,235 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; -import FileMode from '../FileMode'; - -// Mock CopyButton -vi.mock('@/components/CopyButton', () => ({ - default: ({ text, tooltip }: { text: string; tooltip?: string }) => ( - - ), -})); - -beforeEach(() => { - localStorage.clear(); - vi.useFakeTimers({ shouldAdvanceTime: true }); -}); - -afterEach(() => { - vi.useRealTimers(); -}); - -// useStorageState's async loadState may overwrite user toggle if we click before the -// initial chrome.storage read settles. Flush pending microtasks first. -const waitForStorageReady = () => act(() => Promise.resolve()); - -describe('FileMode', () => { - it('应该渲染文件上传区域', async () => { - render(); - await waitForStorageReady(); - expect(screen.getByText('base64Converter:clickOrDropToFile')).toBeInTheDocument(); - expect(screen.getByText('base64Converter:maxFileSize')).toBeInTheDocument(); - }); - - it('应该处理有效的文件选择', async () => { - render(); - await waitForStorageReady(); - - const file = new File(['test content'], 'test.txt', { type: 'text/plain' }); - - // 文件输入是隐藏的,直接触发 change 事件 - const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement; - fireEvent.change(hiddenInput, { target: { files: [file] } }); - - await waitFor(() => { - expect(screen.getByText('test.txt')).toBeInTheDocument(); - expect(screen.getByText('base64Converter:base64Output')).toBeInTheDocument(); - }); - }); - - it('应该拒绝超出大小限制的文件', async () => { - render(); - await waitForStorageReady(); - - // 创建一个超过 10MB 的文件 - const largeContent = new Uint8Array(11 * 1024 * 1024); - const file = new File([largeContent], 'large.bin', { type: 'application/octet-stream' }); - - const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement; - fireEvent.change(hiddenInput, { target: { files: [file] } }); - - await waitFor(() => { - expect(screen.getByRole('alert')).toHaveTextContent('base64Converter:fileSizeExceeded'); - }); - }); - - it('点击清除按钮应该清空文件状态', async () => { - render(); - await waitForStorageReady(); - - const file = new File(['test'], 'test.txt', { type: 'text/plain' }); - const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement; - fireEvent.change(hiddenInput, { target: { files: [file] } }); - - await waitFor(() => { - expect(screen.getByText('test.txt')).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByText('base64Converter:clear')); - - await waitFor(() => { - expect(screen.queryByText('test.txt')).not.toBeInTheDocument(); - expect(screen.getByText('base64Converter:clickOrDropToFile')).toBeInTheDocument(); - }); - }); - - it('应该显示文件大小和类型信息', async () => { - render(); - await waitForStorageReady(); - - const file = new File(['test content'], 'test.txt', { type: 'text/plain' }); - const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement; - fireEvent.change(hiddenInput, { target: { files: [file] } }); - - await waitFor(() => { - expect(screen.getByText('test.txt')).toBeInTheDocument(); - }); - // 文件类型显示在 caption 中,格式为 "size · type" - expect(screen.getByText(/test\.txt/)).toBeInTheDocument(); - }); - - it('应该显示原始大小和编码大小', async () => { - render(); - await waitForStorageReady(); - - const file = new File(['test content'], 'test.txt', { type: 'text/plain' }); - const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement; - fireEvent.change(hiddenInput, { target: { files: [file] } }); - - await waitFor(() => { - expect(screen.getByText(/base64Converter:originalSize/)).toBeInTheDocument(); - expect(screen.getByText(/base64Converter:encodedSize/)).toBeInTheDocument(); - }); - }); - - it('应该提供复制按钮', async () => { - render(); - await waitForStorageReady(); - - const file = new File(['test'], 'test.txt', { type: 'text/plain' }); - const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement; - fireEvent.change(hiddenInput, { target: { files: [file] } }); - - await waitFor(() => { - const copyButtons = screen.getAllByTestId('copy-button'); - expect(copyButtons.length).toBeGreaterThanOrEqual(2); - }); - }); - - it('应该渲染 encode/decode 切换按钮', () => { - render(); - expect(screen.getByText('base64Converter:encode')).toBeInTheDocument(); - expect(screen.getByText('base64Converter:decode')).toBeInTheDocument(); - }); - - it('切到 decode 应该显示 Base64 输入框', async () => { - render(); - await waitForStorageReady(); - fireEvent.click(screen.getByText('base64Converter:decode')); - expect( - await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder'), - ).toBeInTheDocument(); - }); - - it('解码 PDF Base64 后应该显示 application/pdf 与默认文件名 decoded.pdf', async () => { - render(); - await waitForStorageReady(); - fireEvent.click(screen.getByText('base64Converter:decode')); - - const input = await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder'); - fireEvent.change(input, { target: { value: 'JVBERi0K' } }); - - act(() => { - vi.advanceTimersByTime(250); - }); - - await waitFor(() => { - expect(screen.getByText('base64Converter:decodedFileOutput')).toBeInTheDocument(); - expect(screen.getByText(/application\/pdf/)).toBeInTheDocument(); - expect(screen.getByDisplayValue('decoded.pdf')).toBeInTheDocument(); - }); - }); - - it('解码后的文件名应该可编辑', async () => { - render(); - await waitForStorageReady(); - fireEvent.click(screen.getByText('base64Converter:decode')); - - const input = await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder'); - fireEvent.change(input, { target: { value: 'JVBERi0K' } }); - - act(() => { - vi.advanceTimersByTime(250); - }); - - const filenameInput = (await screen.findByDisplayValue('decoded.pdf')) as HTMLInputElement; - fireEvent.change(filenameInput, { target: { value: 'my-report.pdf' } }); - expect(filenameInput.value).toBe('my-report.pdf'); - }); - - it('解码后应该显示下载按钮', async () => { - render(); - await waitForStorageReady(); - fireEvent.click(screen.getByText('base64Converter:decode')); - - const input = await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder'); - fireEvent.change(input, { target: { value: 'JVBERi0K' } }); - - act(() => { - vi.advanceTimersByTime(250); - }); - - expect(await screen.findByText('base64Converter:download')).toBeInTheDocument(); - }); - - it('解码非法 Base64 应该显示 invalidBase64 错误', async () => { - render(); - await waitForStorageReady(); - fireEvent.click(screen.getByText('base64Converter:decode')); - - const input = await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder'); - fireEvent.change(input, { target: { value: '!!!not base64' } }); - - act(() => { - vi.advanceTimersByTime(250); - }); - - await waitFor(() => { - expect(screen.getByText('base64Converter:invalidBase64')).toBeInTheDocument(); - }); - }); - - it('切换方向时应该清空解码状态', async () => { - render(); - await waitForStorageReady(); - fireEvent.click(screen.getByText('base64Converter:decode')); - - const input = await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder'); - fireEvent.change(input, { target: { value: 'JVBERi0K' } }); - - act(() => { - vi.advanceTimersByTime(250); - }); - - await waitFor(() => { - expect(screen.getByText('base64Converter:decodedFileOutput')).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByText('base64Converter:encode')); - - await waitFor(() => { - expect(screen.queryByText('decodedFileOutput')).not.toBeInTheDocument(); - }); - }); -}); diff --git a/pages/Base64Converter/__tests__/ImageMode.test.tsx b/pages/Base64Converter/__tests__/ImageMode.test.tsx deleted file mode 100644 index 440dbe7..0000000 --- a/pages/Base64Converter/__tests__/ImageMode.test.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; -import ImageMode from '../ImageMode'; - -// Mock CopyButton -vi.mock('@/components/CopyButton', () => ({ - default: ({ text, tooltip }: { text: string; tooltip?: string }) => ( - - ), -})); - -beforeEach(() => { - localStorage.clear(); - vi.useFakeTimers({ shouldAdvanceTime: true }); -}); - -afterEach(() => { - vi.useRealTimers(); -}); - -const waitForStorageReady = () => act(() => Promise.resolve()); - -describe('ImageMode', () => { - it('应该渲染图像上传区域', async () => { - render(); - await waitForStorageReady(); - expect(screen.getByText('base64Converter:clickOrDropToImage')).toBeInTheDocument(); - expect(screen.getByText('base64Converter:supportedFormats')).toBeInTheDocument(); - }); - - it('应该接受有效的图像文件', async () => { - render(); - await waitForStorageReady(); - - const file = new File(['fake-image-data'], 'test.png', { type: 'image/png' }); - const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement; - fireEvent.change(hiddenInput, { target: { files: [file] } }); - - await waitFor(() => { - expect(screen.getByText('test.png')).toBeInTheDocument(); - expect(screen.getByText('base64Converter:base64Output')).toBeInTheDocument(); - }); - }); - - it('应该拒绝非图像文件', async () => { - render(); - await waitForStorageReady(); - - const file = new File(['not an image'], 'test.txt', { type: 'text/plain' }); - const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement; - fireEvent.change(hiddenInput, { target: { files: [file] } }); - - await waitFor(() => { - expect(screen.getByRole('alert')).toHaveTextContent('base64Converter:unsupportedImageType'); - }); - }); - - it('应该拒绝超出大小限制的图像', async () => { - render(); - await waitForStorageReady(); - - const largeContent = new Uint8Array(11 * 1024 * 1024); - const file = new File([largeContent], 'large.png', { type: 'image/png' }); - - const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement; - fireEvent.change(hiddenInput, { target: { files: [file] } }); - - await waitFor(() => { - expect(screen.getByRole('alert')).toHaveTextContent('base64Converter:fileSizeExceeded'); - }); - }); - - it('应该通过扩展名识别图像', async () => { - render(); - await waitForStorageReady(); - - // 没有 MIME 类型但有正确扩展名 - const file = new File(['fake'], 'test.jpg'); - const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement; - fireEvent.change(hiddenInput, { target: { files: [file] } }); - - await waitFor(() => { - expect(screen.getByText('test.jpg')).toBeInTheDocument(); - }); - }); - - it('点击清除按钮应该清空图像状态', async () => { - render(); - await waitForStorageReady(); - - const file = new File(['fake'], 'test.png', { type: 'image/png' }); - const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement; - fireEvent.change(hiddenInput, { target: { files: [file] } }); - - await waitFor(() => { - expect(screen.getByText('test.png')).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByText('base64Converter:clear')); - - await waitFor(() => { - expect(screen.queryByText('test.png')).not.toBeInTheDocument(); - expect(screen.getByText('base64Converter:clickOrDropToImage')).toBeInTheDocument(); - }); - }); - - it('应该显示图像预览', async () => { - render(); - await waitForStorageReady(); - - const file = new File(['fake-image'], 'test.png', { type: 'image/png' }); - const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement; - fireEvent.change(hiddenInput, { target: { files: [file] } }); - - await waitFor(() => { - const img = screen.getByAltText('preview'); - expect(img).toBeInTheDocument(); - expect(img.tagName.toLowerCase()).toBe('img'); - }); - }); - - it('应该渲染 encode/decode 切换按钮', async () => { - render(); - await waitForStorageReady(); - expect(screen.getByText('base64Converter:encode')).toBeInTheDocument(); - expect(screen.getByText('base64Converter:decode')).toBeInTheDocument(); - }); - - it('解码 PNG Base64 后应该显示图像预览', async () => { - render(); - await waitForStorageReady(); - fireEvent.click(screen.getByText('base64Converter:decode')); - - const input = await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder'); - fireEvent.change(input, { target: { value: 'iVBORw0KGgo=' } }); - - act(() => { - vi.advanceTimersByTime(250); - }); - - await waitFor(() => { - expect(screen.getByText('base64Converter:decodedImageOutput')).toBeInTheDocument(); - const img = screen.getByAltText('decoded preview'); - expect(img).toBeInTheDocument(); - expect(img.tagName.toLowerCase()).toBe('img'); - }); - }); - - it('解码后默认文件名应该为 decoded.png', async () => { - render(); - await waitForStorageReady(); - fireEvent.click(screen.getByText('base64Converter:decode')); - - const input = await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder'); - fireEvent.change(input, { target: { value: 'iVBORw0KGgo=' } }); - - act(() => { - vi.advanceTimersByTime(250); - }); - - expect(await screen.findByDisplayValue('decoded.png')).toBeInTheDocument(); - }); - - it('解码非法 Base64 应该显示 invalidBase64 错误', async () => { - render(); - await waitForStorageReady(); - fireEvent.click(screen.getByText('base64Converter:decode')); - - const input = await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder'); - fireEvent.change(input, { target: { value: '!!!not base64' } }); - - act(() => { - vi.advanceTimersByTime(250); - }); - - await waitFor(() => { - expect(screen.getByText('base64Converter:invalidBase64')).toBeInTheDocument(); - }); - }); -}); diff --git a/pages/Base64Converter/__tests__/TextMode.test.tsx b/pages/Base64Converter/__tests__/TextMode.test.tsx index c5d86d3..7dc94bd 100644 --- a/pages/Base64Converter/__tests__/TextMode.test.tsx +++ b/pages/Base64Converter/__tests__/TextMode.test.tsx @@ -4,6 +4,7 @@ import TextMode from '../TextMode'; // Mock CopyButton vi.mock('@/components/CopyButton', () => ({ + CopyButton: ({ text }: { text: string }) => , default: ({ text }: { text: string }) => , })); @@ -39,7 +40,7 @@ describe('TextMode', () => { await waitFor(() => { expect(screen.getByText('base64Converter:base64Output')).toBeInTheDocument(); }); - expect(screen.getByTestId('copy-button')).toHaveTextContent('SGVsbG8='); + expect(screen.getByRole('button', { name: 'SGVsbG8=' })).toBeInTheDocument(); }); it('应该解码 Base64 文本', async () => { @@ -58,7 +59,7 @@ describe('TextMode', () => { await waitFor(() => { expect(screen.getByText('base64Converter:textOutput')).toBeInTheDocument(); }); - expect(screen.getByTestId('copy-button')).toHaveTextContent('Hello'); + expect(screen.getByRole('button', { name: 'Hello' })).toBeInTheDocument(); }); it('应该对无效 Base64 显示错误', async () => { @@ -91,7 +92,7 @@ describe('TextMode', () => { }); await waitFor(() => { - expect(screen.getByTestId('copy-button')).toHaveTextContent('SGVsbG8='); + expect(screen.getByRole('button', { name: 'SGVsbG8=' })).toBeInTheDocument(); }); // 切换方向 @@ -99,7 +100,7 @@ describe('TextMode', () => { // 输出应该被清除 await waitFor(() => { - expect(screen.queryByTestId('copy-button')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'SGVsbG8=' })).not.toBeInTheDocument(); }); }); @@ -114,13 +115,13 @@ describe('TextMode', () => { }); await waitFor(() => { - expect(screen.getByTestId('copy-button')).toHaveTextContent('SGVsbG8='); + expect(screen.getByRole('button', { name: 'SGVsbG8=' })).toBeInTheDocument(); }); fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' })); await waitFor(() => { - expect(screen.queryByTestId('copy-button')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'SGVsbG8=' })).not.toBeInTheDocument(); expect(input).toHaveValue(''); }); }); diff --git a/pages/Dashboard/ToolCard.tsx b/pages/Dashboard/ToolCard.tsx index 08b4819..e8f6379 100644 --- a/pages/Dashboard/ToolCard.tsx +++ b/pages/Dashboard/ToolCard.tsx @@ -10,7 +10,7 @@ const PALETTE_COLORS: Record = { success: '22, 163, 74', // green warning: '217, 119, 6', // amber (存储清理的橙色轴) error: '220, 38, 38', // red - secondary: '147, 51, 2 purple', + secondary: '147, 51, 232', info: '37, 99, 235', // blue }; diff --git a/pages/JsonTools/JsonConvertSection.tsx b/pages/JsonTools/JsonConvertSection.tsx index 8abd951..46eca77 100644 --- a/pages/JsonTools/JsonConvertSection.tsx +++ b/pages/JsonTools/JsonConvertSection.tsx @@ -133,7 +133,7 @@ export default function JsonConvertSection({ /* 5. 空状态提示容器:完美的中性虚线引导,不喧宾夺主 */

- {error ? '请修正上方 JSON 的语法错误以激活流式转换' : t(`jsonFormat:${pk}EmptyHint`)} + {error ? t('jsonFormat:fixErrorHint') : t(`jsonFormat:${pk}EmptyHint`)}

)} diff --git a/pages/JsonTools/JsonFormatSection.tsx b/pages/JsonTools/JsonFormatSection.tsx index bc4b091..bcd8076 100644 --- a/pages/JsonTools/JsonFormatSection.tsx +++ b/pages/JsonTools/JsonFormatSection.tsx @@ -160,7 +160,7 @@ export default function JsonFormatSection() { /* 空状态指示引导区 */

- {error ? '请修正上方 JSON 语法错误以开启实时流式格式化' : t('jsonFormat:emptyHint')} + {error ? t('jsonFormat:fixErrorHint') : t('jsonFormat:emptyHint')}

)} diff --git a/pages/JsonTools/index.tsx b/pages/JsonTools/index.tsx index c5f7079..0b58ff0 100644 --- a/pages/JsonTools/index.tsx +++ b/pages/JsonTools/index.tsx @@ -182,9 +182,7 @@ export default function Index() { ) : (

- {leftError || rightError - ? '请修正上方 JSON 的语法错误以开启实时流式比对' - : t('jsonDiff:emptyHint')} + {leftError || rightError ? t('jsonDiff:fixErrorHint') : t('jsonDiff:emptyHint')}

)} diff --git a/pages/MarkdownToHtml/index.tsx b/pages/MarkdownToHtml/index.tsx index a43eda3..cf9e6e5 100644 --- a/pages/MarkdownToHtml/index.tsx +++ b/pages/MarkdownToHtml/index.tsx @@ -37,7 +37,7 @@ const PREVIEW_STYLES = ` .markdown-body h1 { border-bottom: 1px solid var(--md-border); padding-bottom: 0.3em; font-size: 1.6em; } .markdown-body h2 { border-bottom: 1px solid var(--md-border); padding-bottom: 0.3em; font-size: 1.35em; } .markdown-body p { margin-top: 0; margin-bottom: 16px; } - .markdown-body a { color: #3b82f6; text-decoration: none; } + .markdown-body a { color: var(--md-link-color); text-decoration: none; } .markdown-body a:hover { text-decoration: underline; } .markdown-body code { background-color: var(--md-code-bg); @@ -110,6 +110,7 @@ export default function MarkdownToHtmlPage() { --md-pre-bg: rgba(255,255,255,0.04); --md-muted: #8b949e; --md-quote-line: rgba(255,255,255,0.25); + --md-link-color: #58a6ff; }` : `:root { --md-bg: #ffffff; @@ -119,6 +120,7 @@ export default function MarkdownToHtmlPage() { --md-pre-bg: rgba(128,128,128,0.03); --md-muted: #4b5563; --md-quote-line: rgba(128,128,128,0.3); + --md-link-color: #3b82f6; }`; // 💡 3. 核心大清洗:将全局基础树(html, body)与派生样式完全独立硬编码,杜绝任何语法踩踏 diff --git a/pages/README.md b/pages/README.md new file mode 100644 index 0000000..c3a15a2 --- /dev/null +++ b/pages/README.md @@ -0,0 +1,140 @@ +# pages/ + +功能页面组件目录,每个子目录对应一个工具页面。 + +## 目录结构约定 + +典型页面遵循 **UI + Hook 分离** 模式: + +``` +pages/FeatureName/ +├── index.tsx # 页面 UI(纯展示,使用 shadcn/ui 组件) +├── useFeatureName.ts # 业务逻辑 Hook(状态管理 + 转换逻辑) +├── constants.ts # 常量定义(可选) +├── SubComponent.tsx # 子组件(可选) +└── __tests__/ # 测试文件 + └── index.test.tsx +``` + +## 页面列表 + +### Dashboard/ + +仪表盘首页,以卡片网格展示所有可见工具,支持点击导航。 + +| 文件 | 用途 | +| -------------- | -------------------------------------------- | +| `index.tsx` | 页面组件,渲染工具卡片网格 | +| `ToolCard.tsx` | 工具卡片组件,展示图标、标题、描述和实时数据 | + +### Timestamp/ + +时间戳转换工具,支持秒/毫秒级互转、多时区选择、实时时钟。 + +| 文件 | 用途 | +| -------------------------- | ----------------------------------------------------------------- | +| `index.tsx` | 页面 UI | +| `useTimestampConverter.ts` | 业务逻辑 Hook,包含转换模式、输入、单位、时区状态和响应式计算管线 | +| `LiveClock.tsx` | 实时时钟子组件,`React.memo` 优化 | +| `ResultView.tsx` | 转换结果展示子组件 | +| `constants.ts` | 时区列表等常量 | + +### StorageCleaner/ + +浏览器存储清理工具,支持 Cookie/LocalStorage/SessionStorage/IndexedDB/Cache/SW 清理。 + +| 文件 | 用途 | +| --------------------------- | -------------- | +| `index.tsx` | 页面 UI | +| `useStorageCleaner.ts` | 业务逻辑 Hook | +| `StorageOptionsGrid.tsx` | 清理选项网格 | +| `StorageCleanerConfirm.tsx` | 清理确认对话框 | +| `AutoRefreshToggle.tsx` | 自动刷新开关 | +| `ErrorDisplay.tsx` | 错误展示组件 | +| `CleaningResult.tsx` | 清理结果展示 | +| `OptionItem.tsx` | 单个选项组件 | + +### QrCode/ + +二维码工具,支持生成(URL→QR)和解析(QR→URL)。 + +| 文件/目录 | 用途 | +| ------------- | ---------------- | +| `index.tsx` | 页面 UI | +| `types.ts` | 类型定义 | +| `contexts/` | Context Provider | +| `hooks/` | 业务逻辑 Hooks | +| `components/` | 子组件 | + +### TextStatistics/ + +文本统计工具,实时计算字符数、单词数、行数、字节大小。 + +| 文件 | 用途 | +| ----------- | --------------------------------------------- | +| `index.tsx` | 页面组件,集成 `TextInputArea` 和统计结果展示 | + +### Jwt/ + +JWT 解析工具,解码 Header/Payload/Signature。 + +| 文件/目录 | 用途 | +| ------------- | ---------------- | +| `index.tsx` | 页面 UI | +| `types.ts` | 类型定义 | +| `contexts/` | Context Provider | +| `hooks/` | 业务逻辑 Hooks | +| `components/` | 子组件 | + +### JsonTools/ + +JSON 工具集:差异比较、格式化、YAML/TOML/Minify 转换。 + +| 文件 | 用途 | +| ------------------------ | ---------------------------- | +| `index.tsx` | 页面入口,子模式切换 | +| `types.ts` | 类型定义 | +| `diffEngine.ts` | 差异比较引擎 | +| `JsonDiffInput.tsx` | JSON 输入组件 | +| `DiffResult.tsx` | 差异结果展示 | +| `DiffNavigator.tsx` | 差异导航器 | +| `JsonFormatSection.tsx` | 格式化区域 | +| `JsonConvertSection.tsx` | 转换区域(YAML/TOML/Minify) | +| `JsonTree.tsx` | JSON 树形展示 | + +### Base64Converter/ + +Base64 编解码工具,支持文本/文件/图片三种模式。 + +| 文件 | 用途 | +| ---------------------------- | --------------------- | +| `index.tsx` | 页面入口,子模式切换 | +| `useBase64Converter.ts` | 业务逻辑 Hook | +| `TextMode.tsx` | 文本模式 | +| `Base64ConverterSection.tsx` | 文件/图片通用转换区域 | + +### MarkdownToHtml/ + +Markdown 转 HTML 工具,支持分栏/预览/源码三种视图模式。 + +### HtmlToMarkdown/ + +HTML 转 Markdown 工具,支持分栏/预览/Markdown 三种视图模式。 + +### RightClickRestorer/ + +右键菜单恢复工具,解除网站对右键的限制。 + +| 文件 | 用途 | +| -------------------------- | ------------- | +| `index.tsx` | 页面 UI | +| `useRightClickRestorer.ts` | 业务逻辑 Hook | + +## 新增页面 + +1. 在 `types/storage.d.ts` 添加 `PageType` 联合类型成员 +2. 在 `config/features.tsx` 的 `FEATURES` 数组添加配置 +3. 在 `pages/` 创建页面目录(遵循上述结构) +4. 在 `i18n/locales/{zh,en}/features.json` 添加翻译 +5. 如需新权限,更新 `wxt.config.ts` +6. 添加对应的单元测试 diff --git a/pages/StorageCleaner/index.tsx b/pages/StorageCleaner/index.tsx index 8cf82af..02ddd2d 100644 --- a/pages/StorageCleaner/index.tsx +++ b/pages/StorageCleaner/index.tsx @@ -36,7 +36,7 @@ export default function Index() {
- 正在读取站点数据... + {t('storageCleaner:initializing')}
); diff --git a/pages/Timestamp/LiveClock.tsx b/pages/Timestamp/LiveClock.tsx index a809183..a503e87 100644 --- a/pages/Timestamp/LiveClock.tsx +++ b/pages/Timestamp/LiveClock.tsx @@ -88,8 +88,7 @@ const LiveClock = React.memo(({ unit, onUseNow, className, ...props }: LiveClock
); diff --git a/providers/README.md b/providers/README.md new file mode 100644 index 0000000..22d2209 --- /dev/null +++ b/providers/README.md @@ -0,0 +1,63 @@ +# providers/ + +React Context Provider 目录,为整个应用提供全局共享状态。 + +## 文件说明 + +| 文件 | 用途 | +| ----------------------- | ---------------------- | +| `AppRoot.tsx` | 应用根 Provider 组合器 | +| `RouterProvider.tsx` | 路由 Context Provider | +| `ThemeModeProvider.tsx` | 主题模式 Provider | + +## AppRoot.tsx + +应用根 Provider 组合器,按顺序包裹: + +``` +React.StrictMode + └── ThemeModeProvider + └── RouterProvider + └── children +``` + +## RouterProvider.tsx + +路由 Context Provider,管理: + +- **当前页面**:`currentPage`(`PageType`) +- **可见页面列表**:`visiblePages` +- **页面排序**:`pageOrder` +- **加载状态**:`isLoaded` + +核心特性: + +- 通过 `chrome.storage` 持久化路由状态 +- 使用 `localStorage` 快照实现首屏 0 闪烁 +- 支持 popup/sidepanel/tab 三种入口的独立路由同步(通过 `syncKey`、`visiblePagesKey`、`pageOrderKey` 配置) +- 处理右键菜单待处理数据的路由跳转 +- 监听 `chrome.storage.onChanged` 实现跨端同步 + +导出: + +- `RouterProvider` 组件 +- `useRouter()` Hook — 获取 `currentPage`、`visiblePages`、`pageOrder`、`navigateTo`、`goBack` 等 + +## ThemeModeProvider.tsx + +主题模式 Provider,管理: + +- **主题模式**:`light` / `dark` / `system` +- **解析后的主题**:`resolvedTheme`(`light` / `dark`) + +核心特性: + +- 使用 `localStorage` 快照实现首屏 0 闪烁 +- 监听系统级暗色模式变化(`matchMedia`) +- 通过 `chrome.storage` 跨端同步主题偏好 +- 自动在 `document.documentElement` 上切换 `dark` class + +导出: + +- `ThemeModeProvider` 组件 +- `useThemeMode()` Hook — 获取 `themeMode`、`resolvedTheme`、`setThemeMode` diff --git a/public/README.md b/public/README.md new file mode 100644 index 0000000..b7b3f2f --- /dev/null +++ b/public/README.md @@ -0,0 +1,20 @@ +# public/ + +静态资源目录,存放无需构建处理的文件,会被直接复制到输出目录。 + +## 文件说明 + +| 文件/目录 | 用途 | +| -------------- | -------------------------------- | +| `icon/` | 扩展图标,提供多种尺寸 | +| `icon/16.png` | 16×16 图标(工具栏) | +| `icon/32.png` | 32×32 图标 | +| `icon/48.png` | 48×48 图标(扩展管理页) | +| `icon/96.png` | 96×96 图标 | +| `icon/128.png` | 128×128 图标(Chrome Web Store) | +| `wxt.svg` | WXT 框架标志 SVG 图标 | + +## 注意事项 + +- 修改图标后需同步更新 `wxt.config.ts` 中的 manifest 配置 +- 图标格式推荐使用 PNG,确保透明背景 diff --git a/src/README.md b/src/README.md new file mode 100644 index 0000000..5bb0402 --- /dev/null +++ b/src/README.md @@ -0,0 +1,24 @@ +# src/ + +源码目录,存放全局样式定义。 + +## 文件说明 + +| 文件 | 用途 | +| ----------- | ----------------- | +| `index.css` | 全局 CSS 入口文件 | + +## index.css + +全局样式入口,包含: + +- **Tailwind 指令**:`@tailwind base/components/utilities` +- **shadcn/ui CSS 变量**:定义 `--background`、`--primary`、`--destructive`、`--card`、`--muted`、`--accent`、`--border`、`--ring` 等语义化颜色变量 +- **主题色值**:`:root`(亮色)和 `.dark`(暗色)两套完整的颜色定义 +- **圆角变量**:`--radius` 定义全局圆角大小 + +## 修改注意事项 + +- 修改 CSS 变量会影响所有使用 shadcn/ui 语义化 token 的组件 +- 新增颜色变量需同时在 `:root` 和 `.dark` 中定义 +- 避免在组件中硬编码颜色值,应使用 CSS 变量或 Tailwind 的语义化类名 diff --git a/types/README.md b/types/README.md new file mode 100644 index 0000000..67fbff4 --- /dev/null +++ b/types/README.md @@ -0,0 +1,42 @@ +# types/ + +全局共享的 TypeScript 类型声明文件目录。 + +## 文件说明 + +### storage.d.ts + +核心类型定义文件,包含: + +**页面类型:** + +- `PageType` — 所有页面类型的联合类型(`dashboard` | `timestamp` | `storageCleaner` | ...) +- `JsonToolsPageMode` — JSON 工具子模式(`diff` | `format` | `yaml` | `toml` | `minify`) +- `Base64ConverterPageMode` — Base64 子模式(`text` | `file` | `image`) +- `Base64ConvertDirection` — 编解码方向(`encode` | `decode`) +- `MarkdownToHtmlPreviewMode` — Markdown 预览模式(`split` | `preview` | `html`) +- `HtmlToMarkdownPreviewMode` — HTML 预览模式(`split` | `preview` | `markdown`) + +**存储 Schema:** + +- `StorageSchema` — Chrome Storage 完整数据结构定义,所有存储键必须在此声明 + - 键名使用 kebab-case 格式(如 `app/currentRoute`) + - 包含路由、主题、工具偏好、搜索历史等所有持久化数据 + +**其他类型:** + +- `FormMapEntry` — 表单映射条目定义 +- `ContextMenuPendingData` — 右键菜单待处理数据 +- `StorageCleanerPreferences` / `StorageCleanerOptions` — 存储清理偏好 +- `CleaningResult` / `StorageCleanResult` — 清理结果类型 + +### qrious.d.ts + +`qrious` 库的类型声明,定义 QR 码生成选项和 `QRious` 类。 + +## 修改 StorageSchema 的注意事项 + +修改 `StorageSchema` 时,必须: + +1. 在 `utils/chromeStorage.ts` 添加版本迁移函数 +2. 在测试中覆盖迁移场景 diff --git a/utils/README.md b/utils/README.md new file mode 100644 index 0000000..a3f8d04 --- /dev/null +++ b/utils/README.md @@ -0,0 +1,42 @@ +# utils/ + +通用工具函数和 React 自定义 Hooks 目录,与具体页面解耦。 + +## 工具函数 + +| 文件 | 用途 | +| -------------------- | --------------------------------------------------------------------------------------------------- | +| `chromeStorage.ts` | Chrome Storage API 封装:类型安全的 `StorageUtils` 类,提供 `get/set/remove` 方法 | +| `chromeTabs.ts` | Chrome Tabs API 封装:获取活动标签页、获取域名、在新标签页打开扩展页面 | +| `clipboard.ts` | 剪贴板操作:`copyTextToClipboard`(文本)、`copyImageToClipboard`(图片) | +| `messages.ts` | 扩展消息通信:基于 `@webext-core/messaging`,定义 `MessageAction` 枚举和 `ProtocolMap` 类型安全映射 | +| `contextMenu.ts` | 右键菜单配置与操作:定义菜单项、创建菜单、解析点击事件、ID→PageType 映射 | +| `base64Converter.ts` | Base64 编解码:文本↔Base64、文件↔Base64、图片预览,定义文件大小限制和图像 MIME 类型 | +| `jwt.ts` | JWT 解析:Base64URL 解码、解析 Header/Payload/Signature、JSON 格式化输出 | +| `jsonFormatter.ts` | JSON 格式化/压缩:支持缩进、按键排序、minify | +| `jsonToYaml.ts` | JSON→YAML 转换 | +| `jsonToToml.ts` | JSON→TOML 转换 | +| `markdownToHtml.ts` | Markdown→HTML 转换:基于 `marked` 库,支持 GFM 和换行转换 | +| `htmlToMarkdown.ts` | HTML→Markdown 转换:基于 DOMParser 解析 | +| `qrCodeParser.ts` | 二维码解析:基于 `qr-scanner` 库从文件中解析二维码 | +| `storageCleaner.ts` | 存储清理:获取当前标签页、检测受限 URL、计算 Cookie/Storage 大小、清理操作 | +| `textStatistics.ts` | 文本统计:使用 `Intl.Segmenter` 计算字符数/单词数/行数/字节大小 | +| `format.ts` | 通用格式化:`formatBytes` 将字节转为可读字符串(B/KB/MB/GB/TB) | +| `dayjs.ts` | Day.js 初始化:扩展 UTC、Timezone、RelativeTime 插件,加载中文本地化 | + +## 自定义 Hooks + +| 文件 | 用途 | +| ----------------------- | -------------------------------------------------------------------------------------------------------------- | +| `useStorageState.ts` | Chrome Storage 状态 Hook:类似 `useState`,值自动同步到 `chrome.storage`,使用 `localStorage` 快照消除首屏闪烁 | +| `useLazyTranslation.ts` | 懒加载翻译 Hook:按需动态导入 i18n 命名空间,支持预加载和缓存 | +| `useContextMenuData.ts` | 右键菜单数据 Hook:从 storage 读取待处理数据,匹配 featureKey 后消费并触发回调 | +| `useDebounce.ts` | 防抖 Hook:对值进行延迟更新,避免频繁触发 | + +## 使用约定 + +- 工具函数使用**命名导出**(`export function xxx()`) +- 工具函数**不抛异常**,返回包含 `hasError` 和 `error` 字段的结果对象 +- Hook 使用 `use` 前缀命名,定义返回值接口类型 +- 存储操作使用 `chromeStorage.ts` 的 `storageUtil` 封装,不要直接调用 `chrome.storage` +- 消息通信使用 `messages.ts` 的 `sendMessage`/`onMessage`,不要使用原生 `chrome.runtime.sendMessage` diff --git a/vitest.setup.ts b/vitest.setup.ts index 5e13962..6f4aaab 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -1,5 +1,26 @@ import '@testing-library/jest-dom'; import { afterEach, beforeEach, vi } from 'vitest'; +import { readFileSync } from 'fs'; +import { resolve } from 'path'; +import React from 'react'; + +// 读取中文翻译文件用于 withTranslation mock +const zhCommon = JSON.parse( + readFileSync(resolve(__dirname, 'i18n/locales/zh/common.json'), 'utf-8'), +); + +// 支持嵌套 key 查找,如 "errorBoundary.title" → zhCommon.errorBoundary.title +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function nestedLookup(obj: Record, key: string): string | undefined { + const parts = key.split('.'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let current: any = obj; + for (const part of parts) { + if (current == null || typeof current !== 'object') return undefined; + current = current[part]; + } + return typeof current === 'string' ? current : undefined; +} vi.mock('@/utils/useLazyTranslation', () => ({ useLazyTranslation: (ns?: string) => ({ @@ -21,12 +42,57 @@ vi.mock('react-i18next', () => ({ language: 'zh-CN', }, }), + withTranslation: (ns?: string) => { + const translations = ns === 'common' ? zhCommon : {}; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (Component: React.ComponentType) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const Wrapped = (props: any) => + React.createElement(Component, { + ...props, + t: (key: string) => nestedLookup(translations, key) || key, + i18n: { language: 'zh-CN', changeLanguage: vi.fn() }, + }); + Wrapped.displayName = `withTranslation(${Component.displayName || Component.name || 'Component'})`; + return Wrapped; + }; + }, initReactI18next: { type: '3rdParty', init: vi.fn().mockResolvedValue(undefined), }, })); +vi.mock('@/components/CopyButton', () => ({ + CopyButton: ({ + text, + tooltip, + onClick, + }: { + text: string; + tooltip?: string; + onClick?: (e: React.MouseEvent) => void; + }) => + React.createElement( + 'button', + { + 'aria-label': tooltip || 'copy', + type: 'button', + onClick: async (e: React.MouseEvent) => { + if (text) { + try { + await navigator.clipboard.writeText(text); + } catch { + // 与真实 CopyButton 行为一致:失败时静默处理 + } + } + onClick?.(e); + }, + }, + 'Copy', + ), +})); + const storageMock = { local: { get: vi.fn().mockImplementation(() => Promise.resolve({})),