Compare commits

..

30 Commits

Author SHA1 Message Date
雨霖铃 64e23994a6 fix(test): 修复 CopyButton mock 未捕获 clipboard writeText 异常 2026-05-28 00:39:47 +08:00
雨霖铃 528621f16e fix(tests): 修复 CI 失败的测试 - 补充 withTranslation 和 CopyButton mock
- vitest.setup.ts: react-i18next mock 添加 withTranslation HOC(读取 zh/common.json 翻译)
- vitest.setup.ts: 添加 @/components/CopyButton 全局 mock
- App 测试: 补充 withTranslation 导出
- TextMode 测试: 修复多个 CopyButton 实例导致的 getByTestId 冲突,改用 getByRole
2026-05-28 00:13:09 +08:00
雨霖铃 c735a0aa1a refactor(TextInputArea): 复用 CopyButton 组件替换内联复制逻辑 2026-05-27 22:55:28 +08:00
雨霖铃 62c880b0a8 fix(CopyButton, LiveClock): 修改按钮大小为 'icon',移除硬编码颜色配置 2026-05-27 17:07:39 +08:00
雨霖铃 e263b63ced 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>
2026-05-27 16:56:27 +08:00
雨霖铃 1e4b6eddc7 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>
2026-05-27 14:14:19 +08:00
雨霖铃 4316bb57d2 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>
2026-05-27 14:11:44 +08:00
雨霖铃 f40485e2dc 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>
2026-05-27 14:10:11 +08:00
雨霖铃 0908358f97 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>
2026-05-27 14:08:10 +08:00
雨霖铃 a3f89d07ea 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>
2026-05-27 14:06:19 +08:00
雨霖铃 003dad5d8b 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>
2026-05-27 13:55:54 +08:00
雨霖铃 27d3360f9d 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>
2026-05-27 13:54:38 +08:00
雨霖铃 67629263f9 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>
2026-05-27 13:53:48 +08:00
雨霖铃 8b58dcf02e 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>
2026-05-27 13:53:11 +08:00
雨霖铃 2e2f6e9835 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>
2026-05-27 13:52:11 +08:00
雨霖铃 cdad9bda68 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>
2026-05-27 13:51:30 +08:00
雨霖铃 fae8dea971 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>
2026-05-27 13:50:36 +08:00
雨霖铃 e0de152645 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>
2026-05-27 13:49:31 +08:00
雨霖铃 e005d4d95d 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>
2026-05-27 13:48:30 +08:00
雨霖铃 601260797e 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>
2026-05-27 13:48:02 +08:00
雨霖铃 c61b599287 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>
2026-05-27 11:54:21 +08:00
雨霖铃 0f3b6c4cd8 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
2026-05-25 20:29:12 +08:00
雨霖铃 af2262d2c9 fix: remove unnecessary animations from RightClickRestorer page 2026-05-25 20:15:23 +08:00
雨霖铃 e5ae802ab2 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
2026-05-25 20:11:39 +08:00
雨霖铃 dec43f89da docs: 更新 AGENTS.md,完善 CI 步骤和存储键名格式说明 2026-05-25 19:21:22 +08:00
雨霖铃 61e741f198 docs: 更新 AGENTS.md,添加测试工具和翻译文件结构说明 2026-05-25 19:15:31 +08:00
雨霖铃 c98f767809 Merge branch 'main' into develop 2026-05-24 08:44:32 +08:00
Ubuntu 63f47fd2b1 Merge branch 'main' into develop 2026-05-23 18:58:15 +08:00
Ubuntu e95ca1297c merge: resolve conflict by taking main version of release.yml 2026-05-23 14:46:44 +08:00
Ubuntu c8ad2a2283 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
2026-05-23 14:37:39 +08:00
224 changed files with 7093 additions and 4612 deletions
+45 -357
View File
@@ -79,12 +79,12 @@ export function isSupportedImageType(mimeType: string): boolean {
| 场景 | 导出方式 | 示例 | | 场景 | 导出方式 | 示例 |
| ----------- | ------------------------------------------------ | --------------------------------- | | ----------- | ------------------------------------------------ | --------------------------------- |
| 页面组件 | `export default function ComponentName()` | `src/pages/Timestamp/index.tsx` | | 页面组件 | `export default function ComponentName()` | `pages/Timestamp/index.tsx` |
| 业务组件 | `const X = React.memo(...)` + `export default X` | `LiveClock.tsx`, `ResultView.tsx` | | 业务组件 | `const X = React.memo(...)` + `export default X` | `LiveClock.tsx`, `ResultView.tsx` |
| UI 原子组件 | `React.forwardRef(...)` + `export { X }` | `src/components/ui/button.tsx` | | UI 原子组件 | `React.forwardRef(...)` + `export { X }` | `components/ui/button.tsx` |
| 工具函数 | `export function xxx()` | `src/utils/clipboard.ts` | | 工具函数 | `export function xxx()` | `utils/clipboard.ts` |
| 自定义 Hook | `export function useXxx()` | `src/utils/useStorageState.ts` | | 自定义 Hook | `export function useXxx()` | `utils/useStorageState.ts` |
| 类型/接口 | `export interface` / `export type` | `src/types/storage.d.ts` | | 类型/接口 | `export interface` / `export type` | `types/storage.d.ts` |
```typescript ```typescript
// ✅ 页面组件 — default export // ✅ 页面组件 — default export
@@ -473,7 +473,7 @@ import ErrorBoundary from '@/components/ErrorBoundary';
| 类型 | 命名模式 | 示例 | | 类型 | 命名模式 | 示例 |
| ----------- | -------------------------- | -------------------------------------------------------- | | ----------- | -------------------------- | -------------------------------------------------------- |
| 页面目录 | PascalCase | `Timestamp/`, `Base64Converter/`, `StorageCleaner/` | | 页面目录 | PascalCase | `Timestamp/`, `Base64Converter/`, `StorageCleaner/` |
| 页面入口 | `index.tsx` | `src/pages/Timestamp/index.tsx` | | 页面入口 | `index.tsx` | `pages/Timestamp/index.tsx` |
| 组件文件 | PascalCase `.tsx` | `TopBar.tsx`, `LiveClock.tsx`, `ResultView.tsx` | | 组件文件 | PascalCase `.tsx` | `TopBar.tsx`, `LiveClock.tsx`, `ResultView.tsx` |
| 自定义 Hook | camelCase `.ts` | `useTimestampConverter.ts`, `useStorageCleaner.ts` | | 自定义 Hook | camelCase `.ts` | `useTimestampConverter.ts`, `useStorageCleaner.ts` |
| 工具函数 | camelCase `.ts` | `chromeStorage.ts`, `base64Converter.ts`, `clipboard.ts` | | 工具函数 | camelCase `.ts` | `chromeStorage.ts`, `base64Converter.ts`, `clipboard.ts` |
@@ -527,12 +527,12 @@ const handleClean = useCallback(async () => { ... }, []);
测试文件放在源代码同级的 `__tests__/` 目录下: 测试文件放在源代码同级的 `__tests__/` 目录下:
``` ```
src/utils/__tests__/jwt.test.ts utils/__tests__/jwt.test.ts
src/utils/__tests__/base64Converter.test.ts utils/__tests__/base64Converter.test.ts
src/utils/__tests__/useStorageState.test.ts utils/__tests__/useStorageState.test.ts
src/components/__tests__/SwitchButtonGroup.test.tsx components/__tests__/SwitchButtonGroup.test.tsx
src/components/__tests__/ErrorBoundary.test.tsx components/__tests__/ErrorBoundary.test.tsx
src/pages/Timestamp/__tests__/index.test.tsx pages/Timestamp/__tests__/index.test.tsx
``` ```
### 7.2 describe / it 命名 ### 7.2 describe / it 命名
@@ -653,16 +653,16 @@ export function useTimestampConverter(): UseTimestampConverterReturn { ... }
### 8.2 Hook 存放位置 ### 8.2 Hook 存放位置
- **全局通用 Hook**:放在 `src/utils/` 目录下 - **全局通用 Hook**:放在 `utils/` 目录下
- **页面专属 Hook**:与页面组件同目录 - **页面专属 Hook**:与页面组件同目录
``` ```
src/utils/useStorageState.ts — Chrome Storage 状态持久化 utils/useStorageState.ts — Chrome Storage 状态持久化
src/utils/useLazyTranslation.ts — i18n 懒加载 utils/useLazyTranslation.ts — i18n 懒加载
src/utils/useContextMenuData.ts — 右键菜单数据 utils/useContextMenuData.ts — 右键菜单数据
src/utils/useDebounce.ts — 防抖 utils/useDebounce.ts — 防抖
src/pages/Timestamp/useTimestampConverter.ts — 页面级 Hook pages/Timestamp/useTimestampConverter.ts — 页面级 Hook
src/pages/StorageCleaner/useStorageCleaner.ts — 页面级 Hook pages/StorageCleaner/useStorageCleaner.ts — 页面级 Hook
``` ```
--- ---
@@ -762,349 +762,37 @@ const [themeMode, setThemeMode, isInitialized] = useStorageState(
--- ---
## 11. 页面开发规范 ## 11. 文件组织
### 11.1 目录结构(按复杂度分级) ### 11.1 页面组件结构
#### 简单页面(单一功能,无子模式)
适用于 Timestamp、Jwt、TextStatistics、RightClickRestorer 等:
``` ```
src/pages/FeatureName/ pages/FeatureName/
├── index.tsx # 页面入口组件(default export ├── index.tsx # 页面 UI(纯展示,使用 shadcn/ui 组件
├── useFeatureName.ts # 业务逻辑 Hook命名导出 ├── useFeatureName.ts # 业务逻辑 Hook状态管理 + 转换逻辑
├── constants.ts # 常量定义(可选,命名导出 ├── constants.ts # 常量定义(可选)
├── SubComponent.tsx # 子组件(可选default export ├── LiveClock.tsx # 子组件(可选)
── __tests__/ ── ResultView.tsx # 子组件(可选)
└── index.test.tsx # 页面集成测试 └── __tests__/ # 测试文件
└── index.test.tsx
``` ```
#### 中等页面(含多个子模式/标签页切换) ### 11.2 目录职责
适用于 Base64Converter、StorageCleaner 等: | 目录 | 职责 |
| ---------------- | ------------------------------------------------------------ |
``` | `config/` | 应用配置(功能定义、路由映射) |
src/pages/FeatureName/ | `entrypoints/` | 扩展入口点(popup、options、sidepanel、background、content |
├── index.tsx # 页面入口(模式路由 + 顶层布局) | `pages/` | 功能页面组件(懒加载) |
├── useFeatureName.ts # 业务逻辑 Hook(命名导出) | `components/` | 可复用 UI 组件 |
├── SubModeA.tsx # 子模式组件 | `components/ui/` | shadcn/ui 基础组件(button、dialog、select 等) |
├── SubModeB.tsx # 子模式组件 | `providers/` | React ContextRouter、Theme 等) |
├── SubComponent.tsx # 可复用子组件 | `hooks/` | 自定义 React Hooks |
└── __tests__/ | `utils/` | 工具函数与服务抽象 |
├── index.test.tsx | `types/` | TypeScript 类型声明 |
└── SubModeA.test.tsx | `lib/` | 通用工具函数(cn、utils) |
``` | `i18n/` | 国际化资源 |
| `public/` | 静态资源 |
#### 复杂页面(Context + 多组件协作)
适用于 QrCode、JsonTools 等:
```
src/pages/FeatureName/
├── index.tsx # 页面入口(Provider + 布局)
├── types.ts # 页面专属类型定义
├── constants.ts # 常量(可选)
├── contexts/ # React Context 定义
│ └── FeatureContext.ts
├── hooks/ # 页面专属 Hooks
│ └── useFeature.ts
├── components/ # 页面专属子组件
│ ├── PanelA.tsx
│ └── PanelB.tsx
└── __tests__/
├── index.test.tsx
└── useFeature.test.ts
```
#### 特殊情况(单个文件即可)
功能极简的页面(如 Dashboard),仅需 `index.tsx` 一个文件。当 `index.tsx` 超过 **150 行**时,应拆分为 UI + Hook 模式。
---
### 11.2 页面入口组件(`index.tsx`)规范
#### 组件命名
- 页面入口组件**统一使用 `Index` 作为函数名**,通过 `export default` 导出
- 使用 `export default function Index()` 而非匿名默认导出
- **禁止**混用 `XxxPage` 命名(当前 `RightClickRestorerPage``DashboardPage` 不合规范,应统一为 `Index`
```typescript
// ✅ 正确
export default function Index() { ... }
// ❌ 错误 — 命名不一致
export default function RightClickRestorerPage() { ... }
export default function DashboardPage() { ... }
```
#### 组件职责
`index.tsx` 只负责三件事:
1. **获取翻译函数**`useI18n`
2. **调用业务 Hook** 获取状态和操作方法
3. **渲染 UI 布局**(纯展示,无业务逻辑)
```typescript
// ✅ 标准页面入口模板
import { useI18n } from '@/utils/chromeI18n';
import { useFeatureName } from './useFeatureName';
export default function Index() {
const { t } = useI18n('featureName');
const { state, actions } = useFeatureName();
return (
<div className="p-4 w-full flex flex-col space-y-4 select-none">
{/* 纯 UI 渲染 */}
</div>
);
}
```
#### 禁止在 `index.tsx` 中编写的内容
-`useState` / `useMemo` / `useCallback`(应放在 Hook 中)
- ❌ 数据转换/格式化逻辑
- ❌ 异步请求/副作用
- ❌ 超过 3 行的条件判断逻辑
---
### 11.3 业务 Hook 规范(`useFeatureName.ts`
#### 命名
- 文件名:`useXxx.ts`(驼峰命名)
- Hook 函数名:`useXxx()`
- 返回值接口:`UseXxxReturn`
```typescript
// ✅ 标准 Hook 结构
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 {
// 所有业务逻辑在此
}
```
#### Hook 内部结构(推荐顺序)
```typescript
export function useFeatureName(): UseFeatureNameReturn {
// 1. i18n
const { t } = useI18n('featureName');
// 2. 基础 stateuseState
const [mode, setMode] = useState<Mode>('default');
const [input, setInput] = useState('');
// 3. 持久化 stateuseStorageState
const [pageMode, setPageMode] = useStorageState('feature/pageMode', 'default', isValidMode);
// 4. 衍生数据(useMemo)— 响应式计算管线
const result = useMemo(() => {
// 自动计算,无需手动点击"转换"按钮
}, [input, mode]);
// 5. 事件处理(useCallback
const handleAction = useCallback(() => { ... }, [deps]);
// 6. 副作用(useEffect)— 防抖、初始化、清理
useEffect(() => { ... }, [deps]);
// 7. 右键菜单数据(页面需要时)
useContextMenuData({ featureKey: 'featureName', onData: handleContextMenuData });
// 8. 返回
return { mode, input, result, setMode, setInput, handleAction };
}
```
#### 防抖模式
当输入框需要防抖时,在 Hook 中实现:
```typescript
// ✅ 防抖管道 — 在 useMemo 前定义
const [input, setInput] = useState('');
const [debouncedInput, setDebouncedInput] = useState('');
useEffect(() => {
const handle = setTimeout(() => setDebouncedInput(input), 250);
return () => clearTimeout(handle);
}, [input]);
// 后续 useMemo 使用 debouncedInput 而非 input
const result = useMemo(() => compute(debouncedInput), [debouncedInput]);
```
---
### 11.4 常量文件规范(`constants.ts`
- 仅在常量超过 **3 个**或需要**导出类型**时创建
- 使用 `as const` 确保字面量类型
-`as const` 数组派生联合类型
```typescript
// ✅ 标准常量文件
export const DATE_FORMAT = 'YYYY/MM/DD HH:mm:ss';
export const ZONES = ['Asia/Shanghai', 'America/New_York', 'Europe/London'] as const;
export type UnitType = 'ms' | 's';
export type ZoneType = (typeof ZONES)[number];
```
---
### 11.5 子组件规范
#### 何时拆分子组件
- `index.tsx` 超过 **150 行**
- 存在可复用的 UI 片段(如卡片、面板、结果展示区)
- 需要 `React.memo` 优化的高频渲染区域
#### 子组件 Props 模式
```typescript
// ✅ 继承 HTML 属性 + 业务 Props
interface ResultViewProps extends React.HTMLAttributes<HTMLDivElement> {
result: string;
mode: 'ts2dt' | 'dt2ts';
showEmptyPlaceholder?: boolean;
}
// ✅ 使用 React.memo + displayName
const ResultView = React.memo(
({ result, mode, showEmptyPlaceholder = false, className, ...props }: ResultViewProps) => {
const { t } = useI18n('featureName');
// ...
},
);
ResultView.displayName = 'ResultView';
export default ResultView;
```
#### 子组件内可以使用 Hook
子组件可以独立调用 `useI18n``useSnackbar` 等全局 Hook**不需要**通过 props 从父组件传递翻译函数或 toast 方法。
---
### 11.6 存储键命名规范
页面使用的 Storage 键必须遵循 kebab-case 格式:`{功能名}/{用途}`
```typescript
// ✅ 正确
'base64Converter/pageMode';
'base64Converter/fileMode/direction';
'jsonTools/pageMode';
'qrCode/urlExpanded';
// ❌ 错误
'base64ConverterPageMode';
'json_tools_page_mode';
```
`types/storage.d.ts``StorageSchema` 中声明所有键。
---
### 11.7 模式切换通用模式
当页面有多个子模式(标签页切换),统一使用以下模式:
```typescript
// ✅ 标准模式切换
const VALID_MODES = ['modeA', 'modeB'] as const;
type PageMode = (typeof VALID_MODES)[number];
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 (
<div className="p-4 w-full flex flex-col space-y-4 select-none">
<SwitchButtonGroup
value={pageMode}
options={[
{ value: 'modeA', label: t('feature:modeA') },
{ value: 'modeB', label: t('feature:modeB') },
]}
onChange={(v: PageMode) => setPageMode(v)}
size="small"
/>
{pageMode === 'modeA' ? <PanelA /> : <PanelB />}
</div>
);
}
```
---
### 11.8 页面布局约定
- 所有页面根元素使用统一的外层容器:
```
<div className="p-4 w-full flex flex-col space-y-4 select-none">
```
- 不需要 `<div className="min-h-screen bg-background ...">` — 该样式已由 `AppRoot` 提供
- 不需要 `min-h-[500px]` 或固定高度(除非确有必要)
- 卡片容器:`rounded-xl border border-border bg-card text-card-foreground shadow-sm`
- 使用 `space-y-4` 管理纵向间距,不要手动 `mb-4`
---
### 11.9 页面开发检查清单
新增功能页面时,逐项确认:
1. ✅ 在 `types/storage.d.ts` 添加 `PageType` 联合类型
2. ✅ 在 `config/features.tsx` 注册 `FEATURES` 配置(key、labelKey、icon、三种渲染模式组件)
3. ✅ 创建页面目录,使用 `Index` 作为组件名
4. ✅ 业务逻辑提取到 `useXxx.ts` Hookindex.tsx 不超过 150 行)
5. ✅ 需要持久化的 UI 状态使用 `useStorageState`
6. ✅ 常量 ≥3 个时提取到 `constants.ts`
7. ✅ 在 `i18n/locales/{zh,en}/` 添加翻译
8. ✅ 创建 `__tests__/index.test.tsx` 测试文件
9. ✅ 如需新权限,更新 `wxt.config.ts` 的 `manifest.permissions`
10. ✅ 运行 `npm run lint && npm run typecheck && npm run test` 全部通过
---
### 11.10 目录职责总览
| 目录 | 职责 |
| -------------------- | ------------------------------------------------------------ |
| `src/config/` | 应用配置(功能定义、路由映射) |
| `src/entrypoints/` | 扩展入口点(popup、options、sidepanel、background、content |
| `src/pages/` | 功能页面组件(懒加载) |
| `src/components/` | 可复用 UI 组件 |
| `src/components/ui/` | shadcn/ui 基础组件(button、dialog、select 等) |
| `src/providers/` | React ContextRouter、Theme 等) |
| `src/hooks/` | 自定义 React Hooks |
| `src/utils/` | 工具函数与服务抽象 |
| `src/types/` | TypeScript 类型声明 |
| `src/lib/` | 通用工具函数(cn、utils) |
| `public/` | 静态资源 |
--- ---
+3 -3
View File
@@ -56,7 +56,7 @@ Router 同时使用 `chrome.storage.local` 持久化和 `localStorage` 快照来
### 页面组件模式 ### 页面组件模式
功能页面遵循 **UI + Hook 分离** 模式,详见 [CODING_STANDARDS.md § 11](./CODING_STANDARDS.md#11-页面开发规范) 功能页面遵循 **UI + Hook 分离** 模式:
``` ```
pages/FeatureName/ pages/FeatureName/
@@ -65,7 +65,7 @@ pages/FeatureName/
└── constants.ts # 常量定义(可选) └── constants.ts # 常量定义(可选)
``` ```
- 页面组件调用 `useI18n('featureName')` 获取翻译函数 - 页面组件调用 `useLazyTranslation('featureName')` 获取翻译函数
- Hook 负责所有状态管理,通过返回值暴露给页面 - Hook 负责所有状态管理,通过返回值暴露给页面
- 子组件可进一步拆分(如 `LiveClock.tsx``ResultView.tsx` - 子组件可进一步拆分(如 `LiveClock.tsx``ResultView.tsx`
@@ -102,7 +102,7 @@ pages/FeatureName/
### 代码分割 ### 代码分割
`wxt.config.ts` 通过 `manualChunksForHtmlOnly()` 自动分组 vendor 依赖(vendor-react、vendor-i18n、vendor-qr、vendor-dnd),无需手动配置。 `wxt.config.ts` 通过 `manualChunksForHtmlOnly()` 自动分组 vendor 依赖(vendor-react、vendor-i18n、vendor-qr、vendor-dnd、vendor-markdown),无需手动配置。
### 代码风格 ### 代码风格
+11
View File
@@ -13,6 +13,8 @@ concurrency:
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
# 💡 1. 提速核心:前置基建节点(Infrastructure Initialization
# 专门负责锁死环境、同步下载并缓存 node_modules,下游节点直接满血复用!
setup: setup:
name: Prepare Dependencies name: Prepare Dependencies
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -27,6 +29,7 @@ jobs:
with: with:
node-version: '22' node-version: '22'
# 建立基于 package-lock.json 唯一哈希的缓存大闸
- name: Cache Node Modules - name: Cache Node Modules
id: cache-nodemodules id: cache-nodemodules
uses: actions/cache@v4 uses: actions/cache@v4
@@ -44,6 +47,7 @@ jobs:
id: cache-info id: cache-info
run: echo "key=${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }}" >> $GITHUB_OUTPUT run: echo "key=${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }}" >> $GITHUB_OUTPUT
# 💡 2. 静态语法质检节点(依赖前置节点完成)
lint: lint:
name: Lint name: Lint
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -66,6 +70,7 @@ jobs:
- name: Run ESLint - name: Run ESLint
run: npm run lint run: npm run lint
# 💡 3. 强类型守卫节点(2秒瞬时恢复,开箱即查)
typecheck: typecheck:
name: TypeScript Check name: TypeScript Check
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -91,6 +96,7 @@ jobs:
- name: Run TypeScript type check - name: Run TypeScript type check
run: npm run typecheck run: npm run typecheck
# 💡 4. 单元测试节点(无缝运行你刚刚修复完的 setupTests.ts 套件)
test: test:
name: Unit Tests name: Unit Tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -116,12 +122,16 @@ jobs:
- name: Run tests - name: Run tests
run: npm run test run: npm run test
# 💡 5. 多端分布式最终编译节点(Production Matrix Compliance
build: build:
name: Build (${{ matrix.browser }}) name: Build (${{ matrix.browser }})
runs-on: ubuntu-latest runs-on: ubuntu-latest
# 只有当 Linter、类型大闸、Vitest 单元测试全数满分通过,才放行最终打包编译
needs: [ lint, typecheck, test ] needs: [ lint, typecheck, test ]
strategy: strategy:
matrix: matrix:
# 💡 完美对齐 WXT 跨端架构:将 firefox 同步纳入生产编译大矩阵,
# 如果 firefox 编译因任何多端不兼容挂掉,CI 会立刻拉起警报,防护力拉满!
browser: [ chrome, firefox ] browser: [ chrome, firefox ]
fail-fast: false fail-fast: false
steps: steps:
@@ -139,6 +149,7 @@ jobs:
path: node_modules path: node_modules
key: ${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }} key: ${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }}
# 💡 动态代理编译指令:完美匹配 WXT / 各类多端打包器的标准构建命令
- name: Generate WXT types - name: Generate WXT types
run: npx wxt prepare run: npx wxt prepare
-1
View File
@@ -31,4 +31,3 @@ stats-*.json
dev/* dev/*
docs/* docs/*
!docs/VISUAL_STYLE_GUIDE.md
+42 -49
View File
@@ -43,56 +43,50 @@ Pre-commit hook`.husky/pre-commit` 调用 `lint-staged`,任一步骤返回
## 项目结构 ## 项目结构
``` ```
src/ # 源代码根目录 config/features.tsx # 功能定义(路由 + 元数据的单一事实来源)
config/features.tsx # 功能定义(路由 + 元数据的单一事实来源) entrypoints/ # 扩展入口点 (popup/, options/, sidepanel/, background.ts, content.ts)
entrypoints/ # 扩展入口点 (popup/, sidepanel/, background.ts, content.ts) pages/ # 功能页面组件 (懒加载)
pages/ # 功能页面组件 (懒加载) components/ # 可复用 UI 组件
components/ # 可复用 UI 组件 components/ui/ # shadcn/ui 基础组件 (button, dialog, select 等)
components/ui/ # shadcn/ui 基础组件 (button, dialog, select 等) providers/ # React Context (Router, Theme 等)
providers/ # React Context (Router, Theme 等) hooks/ # 自定义 React Hooks
hooks/ # 自定义 React Hooks utils/ # 工具函数与服务抽象
utils/ # 工具函数与服务抽象 types/ # TypeScript 类型声明
types/ # TypeScript 类型声明 i18n/locales/{zh,en}/ # 国际化资源 (common.json, features.json 及各功能独立 JSON)
lib/ # 通用工具函数(cn、utils)
public/ # 静态资源(图标、_locales 等)
``` ```
### 页面组件模式 ### 页面组件模式
典型功能页面遵循 **UI + Hook 分离** 模式。详见 [CODING_STANDARDS.md § 11](./.github/CODING_STANDARDS.md#11-页面开发规范)。 典型功能页面遵循 **UI + Hook 分离** 模式
``` ```
src/pages/FeatureName/ pages/FeatureName/
├── index.tsx # 页面 UI(纯展示,仅负责渲染布局 ├── index.tsx # 页面 UI(纯展示,使用 shadcn/ui 组件
├── useFeatureName.ts # 业务逻辑 Hook(状态管理 + 转换逻辑) ├── useFeatureName.ts # 业务逻辑 Hook(状态管理 + 转换逻辑)
── constants.ts # 常量定义(可选,≥3 个常量时创建) ── constants.ts # 常量定义
└── __tests__/
└── index.test.tsx
``` ```
- 页面入口组件统一命名为 `Index`,通过 `export default function Index()` 导出 - 页面组件调用 `useLazyTranslation('featureName')` 获取翻译函数
- Hook 负责所有状态管理和业务逻辑,通过返回值暴露给页面 - Hook 负责所有状态管理和业务逻辑,通过返回值暴露给页面
- 子组件可以独立调用 `useI18n` 等全局 Hook - 子组件可进一步拆分(如 `LiveClock.tsx``ResultView.tsx`
-`index.tsx` 超过 150 行时,必须拆分为 UI + Hook 模式
- 复杂页面可增加 `contexts/``hooks/``components/` 子目录
## 关键架构决策 ## 关键架构决策
**路由**: 不使用 React Router。通过 `src/config/features.tsx``FEATURES` 数组管理,`RouterProvider` 根据 `PageType` **路由**: 不使用 React Router。通过 `config/features.tsx``FEATURES` 数组管理,`RouterProvider` 根据 `PageType`
渲染对应组件。支持三种渲染模式:popup(弹窗)、sidepanel(侧边栏)和 browser-tab(浏览器新标签页,通过 `open_in_tab` 打开)。 渲染对应组件。支持三种渲染模式:popup(弹窗)、sidepanel(侧边栏)和 browser-tab(浏览器新标签页,通过 `open_in_tab` 打开)。
每种模式有独立的路由和可见页面配置(`app/popupRoute``app/sidepanelRoute``app/tabRoute` 等)。 每种模式有独立的路由和可见页面配置(`app/popupRoute``app/sidepanelRoute``app/tabRoute` 等)。
**存储**: 所有 Chrome Storage 键必须在 `src/types/storage.d.ts``StorageSchema` 中定义,键名使用 kebab-case 格式(如 `app/currentRoute`)。 **存储**: 所有 Chrome Storage 键必须在 `types/storage.d.ts``StorageSchema` 中定义,键名使用 kebab-case 格式(如 `app/currentRoute`)。
使用 `src/utils/chromeStorage.ts` 及其 Hook。Router 同时使用 `chrome.storage.local``localStorage` 做快照以消除首屏闪烁。 使用 `utils/chromeStorage.ts` 及其 Hook。Router 同时使用 `chrome.storage.local``localStorage` 做快照以消除首屏闪烁。
修改 StorageSchema 时,必须在 `src/utils/chromeStorage.ts` 添加版本迁移函数,并在测试中覆盖迁移场景。 修改 StorageSchema 时,必须在 `utils/chromeStorage.ts` 添加版本迁移函数,并在测试中覆盖迁移场景。
**通信**: 使用 `@webext-core/messaging`,协议定义在 `src/utils/messages.ts` **通信**: 使用 `@webext-core/messaging`,协议定义在 `utils/messages.ts`
**路径别名**: `@/` 映射到项目根目录 (已在 tsconfig 和 vitest.config 中配置)。 **路径别名**: `@/` 映射到项目根目录 (已在 tsconfig 和 vitest.config 中配置)。
**浏览器兼容**: 优先使用 `wxt/browser` 导出的 `browser` 对象,而非原生 `chrome` API。 **浏览器兼容**: 优先使用 `wxt/browser` 导出的 `browser` 对象,而非原生 `chrome` API。
**代码分割**: `wxt.config.ts` 通过 `manualChunksForHtmlOnly()` 自动分组依赖(vendor-react、vendor-qr、vendor-dnd 等),无需手动配置。 **代码分割**: `wxt.config.ts` 通过 `manualChunksForHtmlOnly()` 自动分组依赖(vendor-react、vendor-i18n、vendor-qr 等),无需手动配置。
## 测试环境 ## 测试环境
@@ -100,37 +94,35 @@ src/pages/FeatureName/
- 全局变量: `vitest/globals` (describe, it, expect 等无需导入) - 全局变量: `vitest/globals` (describe, it, expect 等无需导入)
- Setup 文件: `vitest.setup.ts` 自动 mock: - Setup 文件: `vitest.setup.ts` 自动 mock:
- `chrome.*` / `browser.*` API (storage, tabs, runtime, cookies 等) - `chrome.*` / `browser.*` API (storage, tabs, runtime, cookies 等)
- `@/utils/chromeI18n` (从 `public/_locales/zh/messages.json` 加载真实翻译) - `react-i18next` (返回 key 作为翻译)
- `@/utils/useLazyTranslation` (返回 `ns:key` 格式翻译)
- `window.matchMedia` - `window.matchMedia`
- 测试文件命名: `__tests__/*.test.{ts,tsx}``*.test.{ts,tsx}` - 测试文件命名: `__tests__/*.test.{ts,tsx}``*.test.{ts,tsx}`
- Mock 模式: 使用 `vi.mock()` 进行模块级 mock,避免在测试文件中重复 mock 代码 - Mock 模式: 使用 `vi.mock()` 进行模块级 mock,避免在测试文件中重复 mock 代码
- 测试工具: `@testing-library/react` + `@testing-library/user-event` 进行组件测试 - 测试工具: `@testing-library/react` + `@testing-library/user-event` 进行组件测试
## i18n (chrome.i18n) ## i18n
项目使用 Chrome 扩展标准的 `chrome.i18n` API 进行本地化,通过 `src/utils/chromeI18n.ts` 提供类型安全的 React Hook 包装。 - 命名空间: `common` (默认), `features`
- 翻译键格式: `namespace:key` (如 `features:timestamp.title`)
- **翻译文件**: `public/_locales/{zh,en}/messages.json`Chrome 扩展标准格式) - 语言: `zh` (默认), `en`
- **默认语言**: `zh`(在 `wxt.config.ts``manifest.default_locale` 中配置) - 翻译文件结构:
- **使用方式**: `import { useI18n } from '@/utils/chromeI18n'` - `i18n/locales/{zh,en}/common.json` - 全局通用翻译
- **翻译键格式**: - `i18n/locales/{zh,en}/features.json` - 功能模块标题和描述
- 直接 key: `t('dashboard_title')` → 查找 `dashboard_title` - `i18n/locales/{zh,en}/{功能名}.json` - 各功能独立翻译(如 timestamp.json, storageCleaner.json 等)
- 命名空间格式(兼容旧用法): `t('common:buttons.search')` → 查找 `common_buttons_search` - 添加新翻译: 编辑 `i18n/locales/{zh,en}/{common,features}.json` 及对应功能独立 JSON
- 带命名空间参数: `useI18n(['common', 'features'])`,会自动尝试 `common_key``features_key` - 使用 `useLazyTranslation` hook 加载功能独立翻译,返回 `ns:key` 格式
- **占位符支持**: `t('router_notFoundDescription', { entryPointType: 'popup' })` - 回退策略: 当翻译 key 在目标语言缺失时,回退到默认语言 `zh`;若默认语言也缺失,返回占位格式 `namespace:key` 并在开发模式下记录 warning
- **Hook 返回值**: `{ t, i18n: { language, changeLanguage }, isLoaded }`
- **回退策略**: 当翻译 key 未命中时,返回 key 本身(开发模式下在控制台记录 warning)
- **限制**: `chrome.i18n` 无法动态切换语言,语言跟随浏览器设置,切换后需刷新页面
## 新功能开发清单 ## 新功能开发清单
1.`src/types/storage.d.ts` 添加 `PageType` 联合类型 1.`types/storage.d.ts` 添加 `PageType` 联合类型
2.`src/config/features.tsx``FEATURES` 数组添加配置(指定 key、翻译键、图标、三种渲染模式的组件) 2.`config/features.tsx``FEATURES` 数组添加配置(指定 key、翻译键、图标、三种渲染模式的组件)
3.`src/pages/` 创建页面组件 (懒加载) 3.`pages/` 创建页面组件 (懒加载)
- `index.tsx` — UI 组件,使用 `useI18n` 获取翻译 - `index.tsx` — UI 组件,使用 `useLazyTranslation` 获取翻译
- `useFeatureName.ts` — 业务逻辑 Hook - `useFeatureName.ts` — 业务逻辑 Hook
- `constants.ts` — 常量(可选) - `constants.ts` — 常量(可选)
4.`public/_locales/zh/messages.json`(及 `en/messages.json`添加翻译 4.`i18n/locales/{zh,en}/features.json` 添加翻译(复杂功能可新建独立 JSON
5. 如需新权限,更新 `wxt.config.ts``manifest.permissions` 5. 如需新权限,更新 `wxt.config.ts``manifest.permissions`
6. 添加对应的单元测试 6. 添加对应的单元测试
@@ -139,7 +131,7 @@ src/pages/FeatureName/
- 禁止使用 `any` (测试文件除外) - 禁止使用 `any` (测试文件除外)
- 未使用变量/参数: 使用 `_` 前缀 (如 `_unused`) - 未使用变量/参数: 使用 `_` 前缀 (如 `_unused`)
- 样式: 使用 Tailwind CSS + shadcn/ui (通过 `className``cn()` 工具) - 样式: 使用 Tailwind CSS + shadcn/ui (通过 `className``cn()` 工具)
- UI 组件: 优先使用 `src/components/ui/` 下的 shadcn/ui 组件 (button, dialog, select 等) - UI 组件: 优先使用 `components/ui/` 下的 shadcn/ui 组件 (button, dialog, select 等)
- 图标: 使用 `lucide-react` 图标库 - 图标: 使用 `lucide-react` 图标库
- 格式: Prettier (`.prettierrc`: 100 字符宽, 单引号, 尾逗号 all, LF 换行) - 格式: Prettier (`.prettierrc`: 100 字符宽, 单引号, 尾逗号 all, LF 换行)
- ESLint 使用 `typescript-eslint``projectService: true`(无需手动维护 project 路径) - ESLint 使用 `typescript-eslint``projectService: true`(无需手动维护 project 路径)
@@ -148,6 +140,7 @@ src/pages/FeatureName/
- `@webext-core/messaging` — 扩展消息通信 - `@webext-core/messaging` — 扩展消息通信
- `@dnd-kit` — 拖拽排序(用于页面顺序管理) - `@dnd-kit` — 拖拽排序(用于页面顺序管理)
- `marked` — Markdown 解析
- `qrious` + `qr-scanner` — 二维码生成与解析 - `qrious` + `qr-scanner` — 二维码生成与解析
- `dayjs` — 日期处理(时间戳转换) - `dayjs` — 日期处理(时间戳转换)
- `sonner` — Toast 通知(替代传统 snackbar - `sonner` — Toast 通知(替代传统 snackbar
-1
View File
@@ -1 +0,0 @@
@AGENT.md
+24 -18
View File
@@ -63,6 +63,11 @@
- **文件转换**: 支持文件与 Base64 字符串互转. - **文件转换**: 支持文件与 Base64 字符串互转.
- **图像预览**: 支持图片 Base64 编码与实时预览. - **图像预览**: 支持图片 Base64 编码与实时预览.
### 📝 Markdown ↔ HTML 转换
- **Markdown 转 HTML**: 实时预览 Markdown 渲染结果,支持导出 HTML 源码.
- **HTML 转 Markdown**: 将 HTML 内容转换为 Markdown 格式.
## 技术栈 ## 技术栈
- **框架**: [WXT (Web Extension Toolkit)](https://wxt.dev/) - **框架**: [WXT (Web Extension Toolkit)](https://wxt.dev/)
@@ -79,24 +84,25 @@
## 项目结构 ## 项目结构
```text ```text
├── src/ # 源代码根目录 ├── components/ # 可复用 React 组件
├── components/ # 可复用 React 组件 ├── config/ # 应用配置
── config/ # 应用配置 ── features.tsx # 功能定义与路由映射
│ │ └── features.tsx # 功能定义与路由映射 ├── entrypoints/ # 扩展程序入口点
│ ├── entrypoints/ # 扩展程序入口点 │ ├── popup/ # 点击图标弹出的主界面
│ ├── popup/ # 点击图标弹出的主界 │ ├── options/ # 扩展程序设置页
│ ├── sidepanel/ # 浏览器侧边栏集成 │ ├── sidepanel/ # 浏览器侧边栏集成
│ ├── background.ts # 后台 Service Worker │ ├── background.ts # 后台 Service Worker
│ └── content.ts # 网页注入脚本 │ └── content.ts # 网页注入脚本
├── pages/ # 各功能模块的页面组件 ├── pages/ # 各功能模块的页面组件
├── providers/ # 全局状态提供者 (Router, Theme 等) ├── providers/ # 全局状态提供者 (Router, Theme 等)
├── hooks/ # 自定义 React Hooks ├── hooks/ # 自定义 React Hooks
├── utils/ # 工具函数与服务抽象 ├── utils/ # 工具函数与服务抽象
├── types/ # TypeScript 类型声明 ├── types/ # TypeScript 类型声明
│ └── lib/ # 通用工具函数 (cn, utils 等) ── lib/ # 通用工具函数 (cn, utils 等)
├── public/ # 静态资源 (图标、manifest 资源等) ├── i18n/ # 国际化资源
├── wxt.config.ts # WXT 框架核心配置 ├── public/ # 静态资源 (图标、manifest 资源等)
── package.json # 项目元数据与依赖管理 ── wxt.config.ts # WXT 框架核心配置
└── package.json # 项目元数据与依赖管理
``` ```
## 开发与部署 ## 开发与部署

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

@@ -4,7 +4,7 @@ import { copyTextToClipboard } from '@/utils/clipboard';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { buttonVariants, type ButtonProps } from '@/components/ui/button'; import { buttonVariants, type ButtonProps } from '@/components/ui/button';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useI18n } from '@/utils/chromeI18n'; import { useTranslation } from 'react-i18next';
interface CopyButtonProps extends Omit<ButtonProps, 'children' | 'onClick'> { interface CopyButtonProps extends Omit<ButtonProps, 'children' | 'onClick'> {
text: string; text: string;
@@ -19,7 +19,7 @@ export const CopyButton: React.FC<CopyButtonProps> = ({
className, className,
...props ...props
}) => { }) => {
const { t } = useI18n('common'); const { t } = useTranslation('common');
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -10,7 +10,7 @@
import { Download } from 'lucide-react'; import { Download } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { formatFileSize } from '@/utils/base64Converter'; import { formatFileSize } from '@/utils/base64Converter';
import { useI18n } from '@/utils/chromeI18n'; import { useLazyTranslation } from '@/utils/useLazyTranslation';
interface DecodeResultPaperProps { interface DecodeResultPaperProps {
/** 标题文案,由调用方传入 i18n key 对应的值(如 decodedFileOutput / decodedImageOutput */ /** 标题文案,由调用方传入 i18n key 对应的值(如 decodedFileOutput / decodedImageOutput */
@@ -38,7 +38,7 @@ export default function DecodeResultPaper({
onDownload, onDownload,
children, children,
}: DecodeResultPaperProps) { }: DecodeResultPaperProps) {
const { t } = useI18n('base64Converter'); const { t } = useLazyTranslation('base64Converter');
return ( return (
<div className="p-4 rounded-xl bg-primary/10 border border-primary/30"> <div className="p-4 rounded-xl bg-primary/10 border border-primary/30">
@@ -1,9 +1,9 @@
import { Component, ErrorInfo, ReactNode } from 'react'; import { Component, ErrorInfo, ReactNode } from 'react';
import { withTranslation, type WithTranslation } from 'react-i18next';
import { AlertCircle, RefreshCw } from 'lucide-react'; import { AlertCircle, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { getMessage } from '@/utils/chromeI18n';
interface Props { interface Props extends WithTranslation {
children: ReactNode; children: ReactNode;
} }
@@ -12,7 +12,7 @@ interface State {
error: Error | null; error: Error | null;
} }
class ErrorBoundary extends Component<Props, State> { class ErrorBoundaryBase extends Component<Props, State> {
state: State = { state: State = {
hasError: false, hasError: false,
error: null, error: null,
@@ -37,6 +37,7 @@ class ErrorBoundary extends Component<Props, State> {
}; };
render() { render() {
const { t } = this.props;
if (this.state.hasError) { if (this.state.hasError) {
return ( return (
<div className="flex flex-col items-center justify-center mt-16 mx-auto max-w-md"> <div className="flex flex-col items-center justify-center mt-16 mx-auto max-w-md">
@@ -45,11 +46,9 @@ class ErrorBoundary extends Component<Props, State> {
<AlertCircle className="h-8 w-8" /> <AlertCircle className="h-8 w-8" />
</div> </div>
<h2 className="text-xl font-extrabold text-destructive mb-2"> <h2 className="text-xl font-extrabold text-destructive mb-2">
{getMessage('errorBoundary_title')} {t('errorBoundary.title')}
</h2> </h2>
<p className="text-sm text-muted-foreground mb-6"> <p className="text-sm text-muted-foreground mb-6">{t('errorBoundary.description')}</p>
{getMessage('errorBoundary_description')}
</p>
{this.state.error && ( {this.state.error && (
<div className="mb-6 p-4 rounded-lg bg-zinc-950 dark:bg-zinc-900 text-left max-h-[200px] overflow-auto border border-border/40"> <div className="mb-6 p-4 rounded-lg bg-zinc-950 dark:bg-zinc-900 text-left max-h-[200px] overflow-auto border border-border/40">
<pre className="font-mono text-xs whitespace-pre-wrap break-all text-zinc-200 selection:bg-zinc-700"> <pre className="font-mono text-xs whitespace-pre-wrap break-all text-zinc-200 selection:bg-zinc-700">
@@ -63,7 +62,7 @@ class ErrorBoundary extends Component<Props, State> {
className="rounded-lg font-bold shadow-sm" className="rounded-lg font-bold shadow-sm"
> >
<RefreshCw className="mr-2 h-4 w-4" /> <RefreshCw className="mr-2 h-4 w-4" />
{getMessage('errorBoundary_refresh')} {t('errorBoundary.refresh')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -74,5 +73,5 @@ class ErrorBoundary extends Component<Props, State> {
} }
} }
export { ErrorBoundary }; export const ErrorBoundary = withTranslation('common')(ErrorBoundaryBase);
export default ErrorBoundary; export default ErrorBoundary;
+372
View File
@@ -0,0 +1,372 @@
/**
* GlobalSnackbar - 全局 Snackbar 消息提示组件及 Provider
*
* 提供可复用的 Toast 消息提示功能,支持三种使用方式:
* 1. 作为受控组件使用:通过 props 控制显示状态
* 2. 通过 useSnackbarState Hook 使用:在组件内部自动管理状态
* 3. 通过 SnackbarProvider 和 useSnackbar Hook 使用:全局单例模式
*
* NOTE: 项目同时使用 sonner 的 toast 进行简单的一次性提示。
* 本组件适用于需要 severity 级别、Provider 上下文、自定义定位等高级场景。
* 简单场景(如复制成功、操作提示)优先使用 `import { toast } from 'sonner'`。
*
* @module GlobalSnackbar
* @version 1.1.0
*
* @example
* ```tsx
* // 方式一:受控组件
* <GlobalSnackbar
* message="操作成功"
* open={isOpen}
* onClose={() => setIsOpen(false)}
* severity="success"
* />
*
* // 方式二:Hook 方式 (局部状态)
* const { snackbarProps, showMessage } = useSnackbarState();
* showMessage('Hello!', { severity: 'info' });
*
* // 方式三:Context 方式 (全局状态)
* // 在根组件包裹 Provider
* <SnackbarProvider>
* <App />
* </SnackbarProvider>
*
* // 在子组件中使用
* const { showMessage } = useSnackbar();
* showMessage('Global Message');
* ```
*/
import {
JSX,
useState,
useRef,
createContext,
useContext,
useEffect,
type ReactNode,
type SyntheticEvent,
} from 'react';
import { CheckCircle, Info, AlertTriangle, XCircle } from 'lucide-react';
/**
* Snackbar 消息严重程度类型
* @description 决定 Alert 组件的颜色和图标
* - success: 绿色,成功提示
* - info: 蓝色,信息提示
* - warning: 橙色,警告提示
* - error: 红色,错误提示
*/
export type SnackbarSeverity = 'success' | 'info' | 'warning' | 'error';
/**
* GlobalSnackbar 组件的属性接口
* @interface GlobalSnackbarProps
*/
export interface GlobalSnackbarProps {
/** 消息内容,要显示的提示文本 */
message: string;
/** 是否显示 Snackbar */
open: boolean;
/** 关闭回调函数 */
onClose: () => void;
/** 消息级别,影响颜色和图标样式,默认 'info' */
severity?: SnackbarSeverity;
/** 自动隐藏时间(毫秒),设为 0 则不自动关闭,默认 2000 */
autoHideDuration?: number;
/** Snackbar 弹出位置,默认 { vertical: 'bottom', horizontal: 'center' } */
anchorOrigin?: {
vertical: 'top' | 'bottom';
horizontal: 'left' | 'center' | 'right';
};
/** 是否使用 Alert 组件包裹,false 则使用原生 Snackbar message,默认 true */
showAlert?: boolean;
/** 是否隐藏 Alert 图标,默认 false */
hideIcon?: boolean;
/** 自定义样式,透传给外层 Snackbar 组件 */
sx?: React.CSSProperties;
/** 自定义样式,透传给内层 Alert 组件(仅 showAlert=true 时生效) */
alertSx?: React.CSSProperties;
}
/**
* showMessage 方法的选项配置
* @interface SnackbarOptions
*/
export interface SnackbarOptions {
/** 消息级别:success | info | warning | error */
severity?: SnackbarSeverity;
/** 自动隐藏时间(毫秒),设为 0 则不自动关闭 */
autoHideDuration?: number;
/** 是否隐藏 Alert 图标 */
hideIcon?: boolean;
/** 是否使用 Alert 组件包裹 */
showAlert?: boolean;
}
/**
* useSnackbarState Hook 的返回值类型
* @interface UseSnackbarStateResult
*/
export interface UseSnackbarStateResult {
/** 传递给 GlobalSnackbar 组件的属性对象 */
snackbarProps: GlobalSnackbarProps;
/** 显示消息的方法 */
showMessage: (message: string, options?: SnackbarOptions) => void;
/** 关闭消息的方法 */
closeMessage: () => void;
}
/**
* GlobalSnackbar 组件的默认属性配置
* @description 提供类型安全的默认值选择
*/
const defaultProps: Required<
Pick<
GlobalSnackbarProps,
'severity' | 'autoHideDuration' | 'anchorOrigin' | 'showAlert' | 'hideIcon'
>
> = {
severity: 'info',
autoHideDuration: 2000,
anchorOrigin: { vertical: 'bottom', horizontal: 'center' },
showAlert: true,
hideIcon: false,
};
const severityConfig: Record<
SnackbarSeverity,
{ icon: React.ElementType; bgClass: string; textClass: string }
> = {
success: { icon: CheckCircle, bgClass: 'bg-green-500', textClass: 'text-white' },
info: { icon: Info, bgClass: 'bg-primary/100', textClass: 'text-white' },
warning: { icon: AlertTriangle, bgClass: 'bg-amber-500', textClass: 'text-white' },
error: { icon: XCircle, bgClass: 'bg-red-500', textClass: 'text-white' },
};
/**
* GlobalSnackbar 组件
*
* 全局消息提示的展示组件,支持受控和非受控两种使用模式。
*
* @param {GlobalSnackbarProps} props - 组件属性
* @returns {JSX.Element}
*/
export function GlobalSnackbar({
message,
open,
onClose,
severity = defaultProps.severity,
autoHideDuration = defaultProps.autoHideDuration,
showAlert = defaultProps.showAlert,
hideIcon = defaultProps.hideIcon,
}: GlobalSnackbarProps): JSX.Element | null {
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (open && autoHideDuration > 0) {
timerRef.current = setTimeout(() => {
onClose();
}, autoHideDuration);
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}
return undefined;
}, [open, autoHideDuration, onClose]);
if (!open) return null;
const config = severityConfig[severity];
const IconComponent = config.icon;
return (
<div className="fixed z-[999999] bottom-6 left-1/2 -translate-x-1/2 animate-in fade-in slide-in-from-bottom-2 duration-300">
{showAlert ? (
<div
className={`flex items-center gap-2 px-5 py-1.5 rounded-full shadow-lg ${config.bgClass} ${config.textClass}`}
style={{ minWidth: '140px' }}
>
{!hideIcon && <IconComponent className="h-4 w-4 flex-shrink-0" />}
<span className="text-xs font-bold">{message}</span>
</div>
) : (
<div className="px-4 py-2 rounded-lg bg-gray-800 text-white text-sm">{message}</div>
)}
</div>
);
}
/**
* useSnackbarState - 消息提示的 Hook 方式
*
* 提供状态管理的 Snackbar 功能,自动处理 open、message 等状态。
* 适合在组件内部使用,无需额外的状态管理代码。
*
* @param {SnackbarOptions} [initialOptions] - 初始配置选项
* @returns {UseSnackbarStateResult} 包含 snackbarProps 和操作方法的对象
*
* @description
* - 自动管理 Snackbar 的显示/隐藏状态
* - 支持链式调用 showMessage
* - 合并初始选项和调用时选项
*
* @example
* ```tsx
* function MyComponent() {
* const { snackbarProps, showMessage, closeMessage } = useSnackbarState({
* severity: 'info',
* autoHideDuration: 3000,
* });
*
* const handleSave = () => {
* // 业务逻辑...
* showMessage('保存成功!', { severity: 'success' });
* };
*
* return (
* <>
* <button onClick={handleSave}>保存</button>
* <GlobalSnackbar {...snackbarProps} />
* </>
* );
* }
* ```
*/
export function useSnackbarState(initialOptions?: SnackbarOptions): UseSnackbarStateResult {
// Snackbar 显示状态
const [open, setOpen] = useState(false);
// 当前显示的消息内容
const [message, setMessage] = useState('');
// 消息配置选项
const [options, setOptions] = useState<SnackbarOptions>(initialOptions || {});
/**
* 显示消息
*
* @param {string} newMessage - 要显示的消息文本
* @param {SnackbarOptions} [newOptions={}] - 新的配置选项
*
* @description
* - 合并初始选项和新的调用选项
* - 新选项会覆盖初始选项
*/
const showMessage = (newMessage: string, newOptions: SnackbarOptions = {}) => {
setMessage(newMessage);
setOptions({ ...initialOptions, ...newOptions });
setOpen(true);
};
/**
* 关闭消息
*
* @description
* - 直接将 open 状态设置为 false
*/
const closeMessage = () => {
setOpen(false);
};
const handleClose = (_event?: SyntheticEvent | Event, reason?: string) => {
if (reason === 'clickaway') return;
closeMessage();
};
/**
* 传递给 GlobalSnackbar 组件的属性
*
* @description
* - 组合当前状态和选项为完整的组件 props
* - onClose 使用 handleClose 包装后的版本
*/
const snackbarProps: GlobalSnackbarProps = {
message,
open,
onClose: handleClose,
severity: options.severity,
autoHideDuration: options.autoHideDuration,
hideIcon: options.hideIcon,
};
return {
snackbarProps,
showMessage,
closeMessage,
};
}
// --- Context & Provider ---
/**
* Snackbar Context 的值类型定义
*/
interface SnackbarContextValue {
showMessage: (message: string, options?: SnackbarOptions) => void;
closeMessage: () => void;
}
const SnackbarContext = createContext<SnackbarContextValue | null>(null);
/**
* SnackbarProvider 组件的 props 类型
*/
interface SnackbarProviderProps {
children: ReactNode;
initialOptions?: SnackbarOptions;
}
/**
* SnackbarProvider 组件
*
* 全局消息提示的 Provider 组件,需要包裹在应用根组件外层。
*/
export function SnackbarProvider({ children, initialOptions }: SnackbarProviderProps): JSX.Element {
const { snackbarProps, showMessage, closeMessage } = useSnackbarState(initialOptions);
return (
<SnackbarContext.Provider value={{ showMessage, closeMessage }}>
{children}
<GlobalSnackbar {...snackbarProps} />
</SnackbarContext.Provider>
);
}
/**
* useSnackbar - 在子组件中获取 Snackbar 上下文的 Hook
*
* @param {SnackbarOptions} [options] - 钩子级别的默认配置(如 autoHideDuration
* @returns {SnackbarContextValue} - 包含 showMessage 和 closeMessage 的对象
* @throws {Error} - 如果不在 SnackbarProvider 内部调用,抛出错误
*
* @description
* 选项合并策略:
* 1. 调用 showMessage 时传入的 callOptions 优先级最高
* 2. useSnackbar(options) 传入的 Hook 级别配置次之
* 3. SnackbarProvider(initialOptions) 传入的全局配置优先级最低
*/
export function useSnackbar(options?: SnackbarOptions): SnackbarContextValue {
const context = useContext(SnackbarContext);
if (!context) {
throw new Error('useSnackbar must be used within SnackbarProvider');
}
// 包装 showMessage 以支持 Hook 级别的 initialOptions
const wrappedShowMessage = (message: string, callOptions?: SnackbarOptions) => {
// 采用防御性编程,确保 options 和 callOptions 为空时也能正常工作
// 优先级:callOptions > options
const mergedOptions: SnackbarOptions = {
...(options || {}),
...(callOptions || {}),
};
context.showMessage(message, mergedOptions);
};
return {
...context,
showMessage: wrappedShowMessage,
};
}
export default GlobalSnackbar;
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef } from 'react'; import { useCallback, useEffect, useRef } from 'react';
import { Image, X } from 'lucide-react'; import { Image, X } from 'lucide-react';
import { toast } from 'sonner'; import { useSnackbar } from '@/components/GlobalSnackbar';
import { useI18n } from '@/utils/chromeI18n'; import { useLazyTranslation } from '@/utils/useLazyTranslation';
interface ImageUploaderProps { interface ImageUploaderProps {
/** 选中的文件 */ /** 选中的文件 */
@@ -29,7 +29,8 @@ const ImageUploader = ({
dragging, dragging,
onDraggingChange, onDraggingChange,
}: ImageUploaderProps) => { }: ImageUploaderProps) => {
const { t } = useI18n('qrCode'); const { t } = useLazyTranslation('qrCode');
const { showMessage } = useSnackbar();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileChange = useCallback( const handleFileChange = useCallback(
@@ -45,8 +46,11 @@ const ImageUploader = ({
URL.revokeObjectURL(previewUrl); URL.revokeObjectURL(previewUrl);
} }
onClearFile(); onClearFile();
toast.success(t('qrCode:imageCleared')); showMessage(t('qrCode:imageCleared'), {
}, [previewUrl, onClearFile, t]); severity: 'success',
autoHideDuration: 1000,
});
}, [previewUrl, onClearFile, showMessage, t]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) { if (e.target.files && e.target.files.length > 0) {
@@ -86,10 +90,13 @@ const ImageUploader = ({
if (file) { if (file) {
try { try {
handleFileChange(file); handleFileChange(file);
toast.success(t('qrCode:imagePasted')); showMessage(t('qrCode:imagePasted'), { severity: 'success', autoHideDuration: 1000 });
} catch (error) { } catch (error) {
console.error('处理粘贴图片失败:', error); console.error('处理粘贴图片失败:', error);
toast.error(t('qrCode:imagePasteError')); showMessage(t('qrCode:imagePasteError'), {
severity: 'error',
autoHideDuration: 3000,
});
} }
} }
break; break;
@@ -102,7 +109,7 @@ const ImageUploader = ({
return () => { return () => {
document.removeEventListener('paste', handlePaste); document.removeEventListener('paste', handlePaste);
}; };
}, [handleFileChange, t]); }, [showMessage, handleFileChange, t]);
return ( return (
<div <div
@@ -1,9 +1,9 @@
import { Component, ErrorInfo, ReactNode } from 'react'; import { Component, ErrorInfo, ReactNode } from 'react';
import { withTranslation, type WithTranslation } from 'react-i18next';
import { AlertCircle, RefreshCw } from 'lucide-react'; import { AlertCircle, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { getMessage } from '@/utils/chromeI18n';
interface Props { interface Props extends WithTranslation {
children: ReactNode; children: ReactNode;
resetKey?: string | number; resetKey?: string | number;
} }
@@ -13,7 +13,7 @@ interface State {
error: Error | null; error: Error | null;
} }
class PageErrorBoundary extends Component<Props, State> { class PageErrorBoundaryBase extends Component<Props, State> {
state: State = { state: State = {
hasError: false, hasError: false,
error: null, error: null,
@@ -38,6 +38,7 @@ class PageErrorBoundary extends Component<Props, State> {
}; };
render() { render() {
const { t } = this.props;
if (this.state.hasError) { if (this.state.hasError) {
return ( return (
<div className="flex flex-col items-center justify-center flex-1 p-6 min-h-[300px] animate-in fade-in zoom-in-95 duration-200"> <div className="flex flex-col items-center justify-center flex-1 p-6 min-h-[300px] animate-in fade-in zoom-in-95 duration-200">
@@ -47,10 +48,10 @@ class PageErrorBoundary extends Component<Props, State> {
</div> </div>
<h3 className="text-base font-semibold text-foreground mb-1.5"> <h3 className="text-base font-semibold text-foreground mb-1.5">
{getMessage('pageErrorBoundary_title')} {t('pageErrorBoundary.title')}
</h3> </h3>
<p className="text-xs text-muted-foreground mb-5"> <p className="text-xs text-muted-foreground mb-5">
{getMessage('pageErrorBoundary_description')} {t('pageErrorBoundary.description')}
</p> </p>
{this.state.error && ( {this.state.error && (
@@ -68,7 +69,7 @@ class PageErrorBoundary extends Component<Props, State> {
className="font-medium shadow-sm" className="font-medium shadow-sm"
> >
<RefreshCw className="mr-1.5 h-3.5 w-3.5" /> <RefreshCw className="mr-1.5 h-3.5 w-3.5" />
{getMessage('errorBoundary_retry')} {t('errorBoundary.retry')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -79,5 +80,5 @@ class PageErrorBoundary extends Component<Props, State> {
} }
} }
export { PageErrorBoundary }; export const PageErrorBoundary = withTranslation('common')(PageErrorBoundaryBase);
export default PageErrorBoundary; export default PageErrorBoundary;
@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { Copy, Download } from 'lucide-react'; import { Copy, Download } from 'lucide-react';
import { useI18n } from '@/utils/chromeI18n'; import { useLazyTranslation } from '@/utils/useLazyTranslation';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -21,7 +21,7 @@ const QrCodePreview = ({
className, className,
...props ...props
}: QrCodePreviewProps) => { }: QrCodePreviewProps) => {
const { t } = useI18n('qrCode'); const { t } = useLazyTranslation('qrCode');
// 空状态下的虚线骨架屏 // 空状态下的虚线骨架屏
if (!qrCodeDataUrl) { if (!qrCodeDataUrl) {
@@ -1,34 +1,38 @@
import { FEATURES, getEntryPointType } from '@/config/features'; import { FEATURES, getEntryPointType } from '@/config/features';
import { useRouter } from '@/providers/RouterProvider'; import { useRouter } from '@/providers/RouterProvider';
import { Suspense } from 'react'; import { Suspense, useMemo } from 'react';
import { useI18n } from '@/utils/chromeI18n'; import { useTranslation } from 'react-i18next';
import PageErrorBoundary from '@/components/PageErrorBoundary'; import PageErrorBoundary from '@/components/PageErrorBoundary';
import PageSkeleton from '@/components/PageSkeleton'; import PageSkeleton from '@/components/PageSkeleton';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils'; // 1. 引入标准的 shadcn 工具函数
import { AlertTriangle } from 'lucide-react'; import { AlertTriangle } from 'lucide-react'; // 用于标准的 404 异常展示
const entryPointType = getEntryPointType();
export default function RouterContainer() { export default function RouterContainer() {
const { currentPage, isLoaded } = useRouter(); const { currentPage, isLoaded } = useRouter();
const { t } = useI18n('common'); const { t } = useTranslation('common');
const animationClass = // 2. 稳定的动态动画类名映射
currentPage === 'dashboard' ? 'page-transition-dashboard' : 'page-transition-enter'; const animationClass = useMemo(() => {
return currentPage === 'dashboard' ? 'page-transition-dashboard' : 'page-transition-enter';
}, [currentPage]);
const entryPointType = getEntryPointType();
// 骨架屏加载状态守卫
if (!isLoaded) { if (!isLoaded) {
return <PageSkeleton variant={currentPage === 'dashboard' ? 'dashboard' : 'tool'} />; return <PageSkeleton variant={currentPage === 'dashboard' ? 'dashboard' : 'tool'} />;
} }
// 3. 严格的路由查找与类型安全的组件分发
const currentFeature = FEATURES.find((f) => f.key === currentPage); const currentFeature = FEATURES.find((f) => f.key === currentPage);
const MatchedComponent = currentFeature?.components?.[entryPointType]; const MatchedComponent = currentFeature?.components?.[entryPointType];
return ( return (
<div <div
key={currentPage} key={currentPage} // 保持原有通过重新挂载触发动画的精简特性
className={cn( className={cn(
'flex-1 flex flex-col overflow-x-hidden overflow-y-auto', 'flex-1 flex flex-col overflow-x-hidden overflow-y-auto',
'scrollbar-gutter-stable motion-reduce:transition-none', 'scrollbar-gutter-stable motion-reduce:transition-none', // 当系统开启“减弱动态效果”时,自动优雅降级,防止眩晕
animationClass, animationClass,
)} )}
> >
@@ -36,6 +40,11 @@ export default function RouterContainer() {
fallback={<PageSkeleton variant={currentPage === 'dashboard' ? 'dashboard' : 'tool'} />} fallback={<PageSkeleton variant={currentPage === 'dashboard' ? 'dashboard' : 'tool'} />}
> >
<PageErrorBoundary resetKey={currentPage}> <PageErrorBoundary resetKey={currentPage}>
{/*
4.
shadcn 404
*/}
{MatchedComponent ? ( {MatchedComponent ? (
<MatchedComponent /> <MatchedComponent />
) : ( ) : (
@@ -1,11 +1,12 @@
import React from 'react'; import React from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils'; // 1. 引入标准的 shadcn 工具函数
export interface SwitchOption<T extends string | number = string> { export interface SwitchOption<T extends string | number = string> {
value: T; value: T;
label: React.ReactNode; label: React.ReactNode;
} }
// 2. 移除内联 sx,继承标准 HTML 属性,并使用标准的类名注入机制
export interface SwitchButtonGroupProps<T extends string | number = string> extends Omit< export interface SwitchButtonGroupProps<T extends string | number = string> extends Omit<
React.HTMLAttributes<HTMLDivElement>, React.HTMLAttributes<HTMLDivElement>,
'onChange' 'onChange'
@@ -14,7 +15,7 @@ export interface SwitchButtonGroupProps<T extends string | number = string> exte
options: SwitchOption<T>[]; options: SwitchOption<T>[];
onChange: (value: T) => void; onChange: (value: T) => void;
size?: 'small' | 'medium' | 'large'; size?: 'small' | 'medium' | 'large';
buttonClassName?: string; buttonClassName?: string; // 替换原有的 buttonSx
} }
export default function SwitchButtonGroup<T extends string | number = string>({ export default function SwitchButtonGroup<T extends string | number = string>({
@@ -26,6 +27,7 @@ export default function SwitchButtonGroup<T extends string | number = string>({
buttonClassName, buttonClassName,
...props ...props
}: SwitchButtonGroupProps<T>) { }: SwitchButtonGroupProps<T>) {
// 3. 将尺寸和高度、内边距等整体对齐,保证按钮和背景容器成比例缩放
const sizeClasses = { const sizeClasses = {
small: 'text-xs h-8 px-2 py-1 rounded-md', small: 'text-xs h-8 px-2 py-1 rounded-md',
medium: 'text-sm h-9 px-3 py-1.5 rounded-md', medium: 'text-sm h-9 px-3 py-1.5 rounded-md',
@@ -49,6 +51,7 @@ export default function SwitchButtonGroup<T extends string | number = string>({
type="button" type="button"
onClick={() => onChange(option.value)} onClick={() => onChange(option.value)}
className={cn( className={cn(
// 4. 完美继承 shadcn 的 Tabs 交互和动效微调
'flex-1 inline-flex items-center justify-center font-medium whitespace-nowrap transition-all', 'flex-1 inline-flex items-center justify-center font-medium whitespace-nowrap transition-all',
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-2', 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-2',
'disabled:pointer-events-none disabled:opacity-50', 'disabled:pointer-events-none disabled:opacity-50',
@@ -1,6 +1,6 @@
import React, { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react'; import React, { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import { useI18n } from '@/utils/chromeI18n'; import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { toast } from 'sonner'; // 推荐使用 shadcn 的默认 Toast import { toast } from 'sonner'; // 推荐使用 shadcn 的默认 Toast
import { CopyButton } from '@/components/CopyButton'; import { CopyButton } from '@/components/CopyButton';
@@ -114,15 +114,17 @@ const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props
const [internalValue, setInternalValue] = useState(defaultValue); const [internalValue, setInternalValue] = useState(defaultValue);
const [error, setError] = useState<string>(''); const [error, setError] = useState<string>('');
const { t } = useI18n('common'); const { t } = useTranslation('common');
const placeholder = placeholderProp ?? t('textInputArea.placeholder'); const placeholder = placeholderProp ?? t('textInputArea.placeholder');
const isControlled = controlledValue !== undefined; const isControlled = controlledValue !== undefined;
const value = isControlled ? controlledValue : internalValue; const value = isControlled ? controlledValue : internalValue;
const displayError = externalError ?? error; const displayError = externalError ?? error;
// 双向合并 ref 指针
useImperativeHandle(ref, () => internalRef.current as HTMLTextAreaElement); useImperativeHandle(ref, () => internalRef.current as HTMLTextAreaElement);
// 1. 高性能的动态高度自适应计算
const adjustHeight = useCallback(() => { const adjustHeight = useCallback(() => {
const textArea = internalRef.current; const textArea = internalRef.current;
if (!textArea) return; if (!textArea) return;
@@ -130,13 +132,14 @@ const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props
// 重置高度计算 // 重置高度计算
textArea.style.height = 'auto'; textArea.style.height = 'auto';
const computedMin = minRows * 24; const computedMin = minRows * 24; // 每行粗略按 24px 计算
const computedMax = maxRows * 24; const computedMax = maxRows * 24;
const nextHeight = Math.max(textArea.scrollHeight, computedMin); const nextHeight = Math.max(textArea.scrollHeight, computedMin);
textArea.style.height = `${Math.min(nextHeight, computedMax)}px`; textArea.style.height = `${Math.min(nextHeight, computedMax)}px`;
}, [minRows, maxRows]); }, [minRows, maxRows]);
// 当数值改变时自适应扩展
React.useEffect(() => { React.useEffect(() => {
adjustHeight(); adjustHeight();
}, [value, adjustHeight]); }, [value, adjustHeight]);
@@ -1,20 +1,33 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import { ArrowLeft, ExternalLink, Monitor, Moon, Search, Sun, X } from 'lucide-react'; import {
ArrowLeft,
ExternalLink,
Globe,
History,
Monitor,
Moon,
Search,
Settings,
Sun,
X,
} from 'lucide-react';
import { useRouter } from '@/providers/RouterProvider'; import { useRouter } from '@/providers/RouterProvider';
import { useThemeMode } from '@/providers/ThemeModeProvider'; import { useThemeMode } from '@/providers/ThemeModeProvider';
import { FeatureConfig, FEATURES } from '@/config/features'; import { FeatureConfig, FEATURES } from '@/config/features';
import { storageUtil } from '@/utils/chromeStorage'; import { storageUtil } from '@/utils/chromeStorage';
import { openExtensionPage } from '@/utils/chromeTabs'; import { openExtensionPage } from '@/utils/chromeTabs';
import { useI18n } from '@/utils/chromeI18n'; import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils'; import { normalizeLanguage, SUPPORTED_LANGUAGES } from '@/i18n';
import { cn } from '@/lib/utils'; // 1. 引入 shadcn 核心工具函数
// 常量配置抽取(无需写在全局变量或 styles 对象里)
const SEARCH_HISTORY_LIMIT = 10; const SEARCH_HISTORY_LIMIT = 10;
const SEARCH_HISTORY_DISPLAY = 5; const SEARCH_HISTORY_DISPLAY = 5;
export default function TopBar() { export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void }) {
const { currentPage, goBack, navigateTo } = useRouter(); const { currentPage, goBack, navigateTo } = useRouter();
const { mode, setMode } = useThemeMode(); const { mode, setMode } = useThemeMode();
const { t } = useI18n(['common', 'features']); const { t, i18n } = useTranslation(['common', 'features']);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [showResults, setShowResults] = useState(false); const [showResults, setShowResults] = useState(false);
@@ -29,6 +42,7 @@ export default function TopBar() {
window.close(); window.close();
}; };
// 2. 健壮的 Click Outside 逻辑:点击空白处收起搜索框
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) { if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
@@ -39,19 +53,7 @@ export default function TopBar() {
return () => document.removeEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside);
}, []); }, []);
// Cmd/Ctrl+K 快捷键聚焦搜索框 // 从 Chrome Storage 异步初始化历史记录
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(() => { useEffect(() => {
storageUtil storageUtil
.get('app/searchHistory', []) .get('app/searchHistory', [])
@@ -61,6 +63,7 @@ export default function TopBar() {
.catch((err) => console.error('加载搜索历史失败:', err)); .catch((err) => console.error('加载搜索历史失败:', err));
}, []); }, []);
// 3. 模糊搜索匹配(移除了无意义的 dashboard 干扰项)
const searchResults = useMemo(() => { const searchResults = useMemo(() => {
const query = searchQuery.trim().toLowerCase(); const query = searchQuery.trim().toLowerCase();
if (!query) return []; if (!query) return [];
@@ -75,15 +78,13 @@ export default function TopBar() {
const displayedHistory = useMemo(() => { const displayedHistory = useMemo(() => {
if (searchQuery.trim()) return []; if (searchQuery.trim()) return [];
return searchHistory return searchHistory.slice(0, SEARCH_HISTORY_DISPLAY);
.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]); }, [searchHistory, searchQuery]);
const saveToHistory = async (featureKey: string) => { // 新增/持久化历史记录
if (!featureKey.trim()) return; const saveToHistory = async (query: string) => {
const nextHistory = [featureKey, ...searchHistory.filter((h) => h !== featureKey)].slice( if (!query.trim()) return;
const nextHistory = [query, ...searchHistory.filter((h) => h !== query)].slice(
0, 0,
SEARCH_HISTORY_LIMIT, SEARCH_HISTORY_LIMIT,
); );
@@ -93,11 +94,19 @@ export default function TopBar() {
const handleSelectFeature = (feature: FeatureConfig) => { const handleSelectFeature = (feature: FeatureConfig) => {
navigateTo(feature.key); navigateTo(feature.key);
saveToHistory(feature.key); saveToHistory(t(feature.labelKey));
setSearchQuery(''); setSearchQuery('');
setShowResults(false); setShowResults(false);
}; };
const toggleLanguage = async () => {
const currentLng = normalizeLanguage(i18n.language);
const currentIndex = SUPPORTED_LANGUAGES.indexOf(currentLng);
const nextLng = SUPPORTED_LANGUAGES[(currentIndex + 1) % SUPPORTED_LANGUAGES.length];
await i18n.changeLanguage(nextLng);
await storageUtil.set('app/language', nextLng);
};
const cycleThemeMode = () => { const cycleThemeMode = () => {
const nextMap = { light: 'dark', dark: 'system', system: 'light' } as const; const nextMap = { light: 'dark', dark: 'system', system: 'light' } as const;
setMode(nextMap[mode]); setMode(nextMap[mode]);
@@ -105,6 +114,7 @@ export default function TopBar() {
const ThemeIcon = mode === 'light' ? Sun : mode === 'dark' ? Moon : Monitor; const ThemeIcon = mode === 'light' ? Sun : mode === 'dark' ? Moon : Monitor;
// 4. 健壮的键盘导航交互
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
const totalItems = searchQuery.trim() ? searchResults.length : displayedHistory.length; const totalItems = searchQuery.trim() ? searchResults.length : displayedHistory.length;
@@ -120,9 +130,14 @@ export default function TopBar() {
if (searchQuery.trim()) { if (searchQuery.trim()) {
handleSelectFeature(searchResults[selectedIndex]); handleSelectFeature(searchResults[selectedIndex]);
} else { } else {
const selected = displayedHistory[selectedIndex]; const selectedQuery = displayedHistory[selectedIndex];
if (selected?.feature) { if (selectedQuery) {
handleSelectFeature(selected.feature); setSearchQuery(selectedQuery);
setSelectedIndex(-1);
const matched = FEATURES.find(
(f) => f.key !== 'dashboard' && t(f.labelKey) === selectedQuery,
);
if (matched) handleSelectFeature(matched);
} }
} }
} else if (searchQuery.trim() && searchResults.length > 0) { } else if (searchQuery.trim() && searchResults.length > 0) {
@@ -144,7 +159,7 @@ export default function TopBar() {
<button <button
type="button" type="button"
onClick={goBack} onClick={goBack}
aria-label={t('common_buttons_back')} aria-label={t('common:buttons.back')}
className="flex h-8 w-8 items-center justify-center rounded-md border border-input bg-background text-muted-foreground shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground" className="flex h-8 w-8 items-center justify-center rounded-md border border-input bg-background text-muted-foreground shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
@@ -154,12 +169,12 @@ export default function TopBar() {
{/* 中间:搜索容器 */} {/* 中间:搜索容器 */}
<div ref={containerRef} className="flex-1 mx-4 max-w-md relative"> <div ref={containerRef} className="flex-1 mx-4 max-w-md relative">
<div className="relative group"> <div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground/60 group-focus-within:text-muted-foreground transition-colors pointer-events-none" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
<input <input
ref={inputRef} ref={inputRef}
type="text" type="text"
placeholder={t('common_buttons_search')} placeholder={t('common:buttons.search')}
value={searchQuery} value={searchQuery}
onChange={(e) => { onChange={(e) => {
setSearchQuery(e.target.value); setSearchQuery(e.target.value);
@@ -168,14 +183,9 @@ export default function TopBar() {
}} }}
onFocus={() => setShowResults(true)} onFocus={() => setShowResults(true)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
aria-label={t('common_buttons_search')} 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" className="w-full h-9 pl-9 pr-8 text-sm rounded-md border border-input bg-muted/50 transition-all placeholder:text-muted-foreground focus:bg-background focus:outline-none focus:ring-1 focus:ring-ring focus:border-input"
/> />
{!searchQuery && (
<kbd className="absolute right-3 top-1/2 -translate-y-1/2 hidden sm:inline-flex h-5 items-center gap-0.5 rounded border border-border/60 bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground/60 pointer-events-none">
K
</kbd>
)}
{searchQuery && ( {searchQuery && (
<button <button
type="button" type="button"
@@ -193,8 +203,8 @@ export default function TopBar() {
{/* 动态联想结果卡片 */} {/* 动态联想结果卡片 */}
{showResults && (searchQuery.trim() || displayedHistory.length > 0) && ( {showResults && (searchQuery.trim() || displayedHistory.length > 0) && (
<div className="absolute top-full left-0 right-0 mt-1.5 bg-popover text-popover-foreground rounded-lg shadow-lg border border-border max-h-80 overflow-y-auto z-50 animate-in fade-in slide-in-from-top-2 duration-150"> <div className="absolute top-full left-0 right-0 mt-1 bg-popover text-popover-foreground rounded-md shadow-md border border-border max-h-80 overflow-y-auto z-50 animate-in fade-in slide-in-from-top-1 duration-150">
<ul role="listbox" className="p-1.5"> <ul role="listbox" className="p-1">
{searchQuery.trim() ? ( {searchQuery.trim() ? (
searchResults.length > 0 ? ( searchResults.length > 0 ? (
searchResults.map((feature, index) => ( searchResults.map((feature, index) => (
@@ -204,25 +214,20 @@ export default function TopBar() {
aria-selected={selectedIndex === index} aria-selected={selectedIndex === index}
onClick={() => handleSelectFeature(feature)} onClick={() => handleSelectFeature(feature)}
className={cn( className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-md cursor-pointer text-sm transition-colors', 'flex items-center gap-3 px-2.5 py-2 rounded-sm cursor-pointer text-sm transition-colors',
selectedIndex === index selectedIndex === index
? 'bg-accent text-accent-foreground' ? 'bg-accent text-accent-foreground'
: 'hover:bg-muted/60', : 'hover:bg-muted/60',
)} )}
> >
<div <div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-sm bg-muted text-muted-foreground">
className={cn(
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
'bg-muted/80 text-muted-foreground',
)}
>
{feature.icon && <feature.icon className="h-4 w-4" />} {feature.icon && <feature.icon className="h-4 w-4" />}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="font-medium text-foreground truncate"> <p className="font-medium text-foreground truncate">
{t(feature.labelKey)} {t(feature.labelKey)}
</p> </p>
<p className="text-xs text-muted-foreground truncate mt-0.5"> <p className="text-xs text-muted-foreground truncate">
{t(feature.descriptionKey)} {t(feature.descriptionKey)}
</p> </p>
</div> </div>
@@ -235,38 +240,27 @@ export default function TopBar() {
) )
) : ( ) : (
<> <>
<div className="px-3 py-1.5 text-[11px] font-semibold tracking-wider text-muted-foreground/60 uppercase"> <div className="px-2.5 py-1.5 text-xs font-semibold tracking-wider text-muted-foreground/80">
{t('common:buttons.recentSearch')} {t('common:buttons.recentSearch')}
</div> </div>
{displayedHistory.map((item, index) => ( {displayedHistory.map((item, index) => (
<li <li
key={item.key} key={item}
role="option" role="option"
aria-selected={selectedIndex === index} aria-selected={selectedIndex === index}
onClick={() => item.feature && handleSelectFeature(item.feature)} onClick={() => {
setSearchQuery(item);
setSelectedIndex(-1);
}}
className={cn( className={cn(
'flex items-center gap-3 px-3 py-2 rounded-md cursor-pointer text-sm transition-colors', 'flex items-center gap-3 px-2.5 py-2 rounded-sm cursor-pointer text-sm transition-colors',
selectedIndex === index selectedIndex === index
? 'bg-accent text-accent-foreground' ? 'bg-accent text-accent-foreground'
: 'hover:bg-muted/60', : 'hover:bg-muted/60',
)} )}
> >
<div <History className="h-4 w-4 text-muted-foreground shrink-0" />
className={cn( <span className="truncate">{item}</span>
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
'bg-muted/80 text-muted-foreground',
)}
>
{item.feature?.icon && <item.feature.icon className="h-4 w-4" />}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-foreground truncate">
{item.feature && t(item.feature.labelKey)}
</p>
<p className="text-xs text-muted-foreground truncate mt-0.5">
{item.feature && t(item.feature.descriptionKey)}
</p>
</div>
</li> </li>
))} ))}
</> </>
@@ -278,12 +272,18 @@ export default function TopBar() {
{/* 右侧:操作区 */} {/* 右侧:操作区 */}
<div className="flex items-center gap-1 shrink-0"> <div className="flex items-center gap-1 shrink-0">
<IconButton onClick={toggleLanguage} title={t('common:buttons.toggleLanguage')}>
<Globe className="h-4 w-4" />
</IconButton>
<IconButton onClick={cycleThemeMode} title={t(`common:buttons.themeMode.${mode}`)}> <IconButton onClick={cycleThemeMode} title={t(`common:buttons.themeMode.${mode}`)}>
<ThemeIcon className="h-4 w-4" /> <ThemeIcon className="h-4 w-4" />
</IconButton> </IconButton>
<IconButton onClick={handleOpenInTab} title={t('common:buttons.openInTab')}> <IconButton onClick={handleOpenInTab} title={t('common:buttons.openInTab')}>
<ExternalLink className="h-4 w-4" /> <ExternalLink className="h-4 w-4" />
</IconButton> </IconButton>
<IconButton onClick={onOpenOptions} title={t('common:buttons.settings')}>
<Settings className="h-4 w-4" />
</IconButton>
</div> </div>
</header> </header>
); );
@@ -40,7 +40,7 @@ describe('DecodeResultPaper 组件', () => {
it('应渲染下载按钮', () => { it('应渲染下载按钮', () => {
render(<DecodeResultPaper {...defaultProps} />); render(<DecodeResultPaper {...defaultProps} />);
expect(screen.getByRole('button', { name: '下载' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'base64Converter:download' })).toBeInTheDocument();
}); });
it('应渲染 children 内容', () => { it('应渲染 children 内容', () => {
@@ -64,7 +64,7 @@ describe('DecodeResultPaper 组件', () => {
it('点击下载按钮时应调用 onDownload', () => { it('点击下载按钮时应调用 onDownload', () => {
render(<DecodeResultPaper {...defaultProps} />); render(<DecodeResultPaper {...defaultProps} />);
fireEvent.click(screen.getByRole('button', { name: '下载' })); fireEvent.click(screen.getByRole('button', { name: 'base64Converter:download' }));
expect(defaultProps.onDownload).toHaveBeenCalledTimes(1); expect(defaultProps.onDownload).toHaveBeenCalledTimes(1);
}); });
}); });
@@ -72,12 +72,12 @@ describe('DecodeResultPaper 组件', () => {
describe('按钮状态', () => { describe('按钮状态', () => {
it('文件名为空时下载按钮应禁用', () => { it('文件名为空时下载按钮应禁用', () => {
render(<DecodeResultPaper {...{ ...defaultProps, fileName: '' }} />); render(<DecodeResultPaper {...{ ...defaultProps, fileName: '' }} />);
expect(screen.getByRole('button', { name: '下载' })).toBeDisabled(); expect(screen.getByRole('button', { name: 'base64Converter:download' })).toBeDisabled();
}); });
it('文件名不为空时下载按钮应启用', () => { it('文件名不为空时下载按钮应启用', () => {
render(<DecodeResultPaper {...defaultProps} />); render(<DecodeResultPaper {...defaultProps} />);
expect(screen.getByRole('button', { name: '下载' })).toBeEnabled(); expect(screen.getByRole('button', { name: 'base64Converter:download' })).toBeEnabled();
}); });
}); });
}); });
@@ -0,0 +1,117 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, render, renderHook, screen } from '@testing-library/react';
import React from 'react';
import {
GlobalSnackbar,
type GlobalSnackbarProps,
SnackbarProvider,
useSnackbar,
useSnackbarState,
} from '@/components/GlobalSnackbar';
describe('GlobalSnackbar 组件系统', () => {
const mockOnClose = vi.fn();
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
const defaultProps: GlobalSnackbarProps = {
message: '测试消息',
open: true,
onClose: mockOnClose,
};
describe('GlobalSnackbar UI 渲染', () => {
it('应渲染消息内容', () => {
render(<GlobalSnackbar {...defaultProps} />);
expect(screen.getByText('测试消息')).toBeInTheDocument();
});
it('当 showAlert 为 true 时应渲染带样式的提示', () => {
render(<GlobalSnackbar {...defaultProps} showAlert={true} />);
// 验证是否包含消息文本
const alertElement = screen.getByText('测试消息');
expect(alertElement).toBeInTheDocument();
// 验证父元素有正确的样式类
const parent = alertElement.parentElement;
expect(parent).toHaveClass('flex', 'items-center', 'gap-2');
});
it('当 hideIcon 为 true 时不应渲染图标', () => {
render(<GlobalSnackbar {...defaultProps} hideIcon={true} />);
// 图标使用 lucide-react 的 svg 元素
const icon = document.querySelector('svg');
expect(icon).not.toBeInTheDocument();
});
it('应根据 severity 应用不同的样式', () => {
render(<GlobalSnackbar {...defaultProps} severity="error" />);
const message = screen.getByText('测试消息');
const parent = message.parentElement;
expect(parent).toHaveClass('bg-red-500');
});
});
describe('useSnackbarState Hook 逻辑', () => {
it('应返回初始状态', () => {
const { result } = renderHook(() => useSnackbarState());
expect(result.current.snackbarProps.open).toBe(false);
expect(result.current.snackbarProps.message).toBe('');
});
it('showMessage 应更新状态', () => {
const { result } = renderHook(() => useSnackbarState());
act(() => {
result.current.showMessage('新消息');
});
expect(result.current.snackbarProps.open).toBe(true);
expect(result.current.snackbarProps.message).toBe('新消息');
});
it('closeMessage 应关闭消息', () => {
const { result } = renderHook(() => useSnackbarState());
act(() => {
result.current.showMessage('消息');
});
act(() => {
result.current.closeMessage();
});
expect(result.current.snackbarProps.open).toBe(false);
});
});
describe('useSnackbar Context Hook 优先级', () => {
it('优先级验证: Call Options > Hook Options > Provider Options', () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
<SnackbarProvider initialOptions={{ severity: 'info' }}>{children}</SnackbarProvider>
);
const { result } = renderHook(() => useSnackbar({ severity: 'warning' }), { wrapper });
// 1. 测试 Hook Options 覆盖 Provider Options
act(() => {
result.current.showMessage('消息 1');
});
expect(screen.getByText('消息 1')).toBeInTheDocument();
// 2. 测试 Call Options 覆盖 Hook Options
act(() => {
result.current.showMessage('消息 2', { severity: 'error' });
});
expect(screen.getByText('消息 2')).toBeInTheDocument();
});
});
});
@@ -7,17 +7,29 @@ const storageOnChangedMock = { addListener: vi.fn(), removeListener: vi.fn() };
(globalThis as any).chrome = { storage: { onChanged: storageOnChangedMock } }; (globalThis as any).chrome = { storage: { onChanged: storageOnChangedMock } };
(globalThis as any).browser = { storage: { onChanged: storageOnChangedMock } }; (globalThis as any).browser = { storage: { onChanged: storageOnChangedMock } };
// 💡 1. 规范对齐:挂载标准的 react-i18next 统一桩函数,防止多进程前缀破产
vi.mock('react-i18next', () => ({
useTranslation: vi.fn((ns: string | string[]) => {
const nsArray = Array.isArray(ns) ? ns : [ns];
return {
t: (key: string) => `${nsArray.join(',')}:${key}`,
i18n: { language: 'en' },
ready: true,
};
}),
}));
// 模拟 URL API // 模拟 URL API
const mockCreateObjectURL = vi.fn(); const mockCreateObjectURL = vi.fn();
const mockRevokeObjectURL = vi.fn(); const mockRevokeObjectURL = vi.fn();
Object.defineProperty(window.URL, 'createObjectURL', { value: mockCreateObjectURL }); Object.defineProperty(window.URL, 'createObjectURL', { value: mockCreateObjectURL });
Object.defineProperty(window.URL, 'revokeObjectURL', { value: mockRevokeObjectURL }); Object.defineProperty(window.URL, 'revokeObjectURL', { value: mockRevokeObjectURL });
vi.mock('sonner', () => ({ // 模拟 showMessage
toast: { vi.mock('@/components/GlobalSnackbar', () => ({
success: vi.fn(), useSnackbar: () => ({
error: vi.fn(), showMessage: vi.fn(),
}, }),
})); }));
describe('ImageUploader 组件', () => { describe('ImageUploader 组件', () => {
@@ -49,8 +61,9 @@ describe('ImageUploader 组件', () => {
describe('渲染测试', () => { describe('渲染测试', () => {
it('当没有选中文件时应显示上传提示', () => { it('当没有选中文件时应显示上传提示', () => {
render(<ImageUploader {...defaultProps} />); render(<ImageUploader {...defaultProps} />);
expect(screen.getByText(/点击.*拖拽/)).toBeInTheDocument(); // 💡 修复点 2:全面切换为高弹性正则,斩断双重命名空间死锁!
expect(screen.getByText(/格式/)).toBeInTheDocument(); expect(screen.getByText(/clickToUpload/)).toBeInTheDocument();
expect(screen.getByText(/supportFormats/)).toBeInTheDocument();
}); });
it('当没有选中文件时应显示 ImageIcon', () => { it('当没有选中文件时应显示 ImageIcon', () => {
@@ -64,7 +77,8 @@ describe('ImageUploader 组件', () => {
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />, <ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
); );
expect(screen.getByText('test.png')).toBeInTheDocument(); expect(screen.getByText('test.png')).toBeInTheDocument();
expect(screen.getByText(/点击更换/)).toBeInTheDocument(); // 💡 修复点 3(自愈第 62 行崩溃位置):利用正则模糊命中,彻底通过!
expect(screen.getByText(/clickToChange/)).toBeInTheDocument();
}); });
it('当选中文件时应显示预览图片', () => { it('当选中文件时应显示预览图片', () => {
@@ -2,6 +2,19 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react'; import { fireEvent, render, screen } from '@testing-library/react';
import QrCodePreview from '@/components/QrCodePreview'; import QrCodePreview from '@/components/QrCodePreview';
// 💡 1. 规范对齐:在这个测试文件的头部同样挂载统一的 react-i18next 桩函数,
// 与你整个工程的国际化解耦架构完美闭环。
vi.mock('react-i18next', () => ({
useTranslation: vi.fn((ns: string | string[]) => {
const nsArray = Array.isArray(ns) ? ns : [ns];
return {
t: (key: string) => `${nsArray.join(',')}:${key}`,
i18n: { language: 'en' },
ready: true,
};
}),
}));
describe('QrCodePreview 组件', () => { describe('QrCodePreview 组件', () => {
const mockOnDownload = vi.fn(); const mockOnDownload = vi.fn();
const mockOnCopy = vi.fn(); const mockOnCopy = vi.fn();
@@ -18,7 +31,8 @@ describe('QrCodePreview 组件', () => {
describe('渲染测试', () => { describe('渲染测试', () => {
it('当 qrCodeDataUrl 为空时应显示占位文本', () => { it('当 qrCodeDataUrl 为空时应显示占位文本', () => {
render(<QrCodePreview qrCodeDataUrl="" onDownload={mockOnDownload} onCopy={mockOnCopy} />); render(<QrCodePreview qrCodeDataUrl="" onDownload={mockOnDownload} onCopy={mockOnCopy} />);
expect(screen.getByText(/二维码将显示/)).toBeInTheDocument(); // 💡 修复点 2:全面拥抱柔性正则匹配,直接终结多层 'qrCode:qrCode:' 前缀踩踏!
expect(screen.getByText(/qrCodeWillShow/)).toBeInTheDocument();
}); });
it('当 qrCodeDataUrl 有值时应显示二维码图片', () => { it('当 qrCodeDataUrl 有值时应显示二维码图片', () => {
@@ -43,7 +57,8 @@ describe('QrCodePreview 组件', () => {
onCopy={mockOnCopy} onCopy={mockOnCopy}
/>, />,
); );
expect(screen.getByText(/下载二维码/)).toBeInTheDocument(); // 💡 修复点 3:切换为正则,无缝过检
expect(screen.getByText(/downloadButton/)).toBeInTheDocument();
}); });
it('当 qrCodeDataUrl 有值时应显示复制按钮', () => { it('当 qrCodeDataUrl 有值时应显示复制按钮', () => {
@@ -54,13 +69,14 @@ describe('QrCodePreview 组件', () => {
onCopy={mockOnCopy} onCopy={mockOnCopy}
/>, />,
); );
expect(screen.getByText(/复制二维码/)).toBeInTheDocument(); // 💡 修复点 4:切换为正则,无缝过检
expect(screen.getByText(/copyQrButton/)).toBeInTheDocument();
}); });
it('当 qrCodeDataUrl 为空时不应显示操作按钮', () => { it('当 qrCodeDataUrl 为空时不应显示操作按钮', () => {
render(<QrCodePreview qrCodeDataUrl="" onDownload={mockOnDownload} onCopy={mockOnCopy} />); render(<QrCodePreview qrCodeDataUrl="" onDownload={mockOnDownload} onCopy={mockOnCopy} />);
expect(screen.queryByText(/下载二维码/)).not.toBeInTheDocument(); expect(screen.queryByText(/downloadButton/)).not.toBeInTheDocument();
expect(screen.queryByText(/复制二维码/)).not.toBeInTheDocument(); expect(screen.queryByText(/copyQrButton/)).not.toBeInTheDocument();
}); });
}); });
@@ -73,7 +89,8 @@ describe('QrCodePreview 组件', () => {
onCopy={mockOnCopy} onCopy={mockOnCopy}
/>, />,
); );
fireEvent.click(screen.getByText(/下载二维码/)); // 💡 修复点 5:点击行为同步更改为正则匹配定位,保障状态修改流一帧直达
fireEvent.click(screen.getByText(/downloadButton/));
expect(mockOnDownload).toHaveBeenCalledTimes(1); expect(mockOnDownload).toHaveBeenCalledTimes(1);
}); });
@@ -85,7 +102,8 @@ describe('QrCodePreview 组件', () => {
onCopy={mockOnCopy} onCopy={mockOnCopy}
/>, />,
); );
fireEvent.click(screen.getByText(/复制二维码/)); // 💡 修复点 6:彻底修复第 88 行报错位置,改用正则解开死锁!
fireEvent.click(screen.getByText(/copyQrButton/));
expect(mockOnCopy).toHaveBeenCalledTimes(1); expect(mockOnCopy).toHaveBeenCalledTimes(1);
}); });
}); });
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render } from '@testing-library/react'; import { render } from '@testing-library/react';
import RouterContainer from '@/components/RouterContainer'; import RouterContainer from '@/components/RouterContainer';
import { RouterProvider } from '@/providers/RouterProvider'; import { RouterProvider } from '@/providers/RouterProvider';
import { SnackbarProvider } from '@/components/GlobalSnackbar';
import type { PageType } from '@/types/storage'; import type { PageType } from '@/types/storage';
import React from 'react'; import React from 'react';
@@ -28,7 +29,11 @@ describe('RouterContainer 组件', () => {
}); });
const renderWithProvider = (ui: React.ReactElement) => { const renderWithProvider = (ui: React.ReactElement) => {
return render(<RouterProvider>{ui}</RouterProvider>); return render(
<SnackbarProvider>
<RouterProvider>{ui}</RouterProvider>
</SnackbarProvider>,
);
}; };
describe('渲染测试', () => { describe('渲染测试', () => {
@@ -69,7 +74,11 @@ describe('RouterContainer 组件', () => {
const { rerender } = renderWithProvider(<RouterContainer />); const { rerender } = renderWithProvider(<RouterContainer />);
mockRouterValue.currentPage = 'timestamp'; mockRouterValue.currentPage = 'timestamp';
rerender(<RouterProvider>{<RouterContainer />}</RouterProvider>); rerender(
<SnackbarProvider>
<RouterProvider>{<RouterContainer />}</RouterProvider>
</SnackbarProvider>,
);
const box = document.querySelector('.page-transition-enter'); const box = document.querySelector('.page-transition-enter');
expect(box).toBeInTheDocument(); expect(box).toBeInTheDocument();
@@ -4,10 +4,23 @@ import { StorageCleanerConfirm } from '@/pages/StorageCleaner/StorageCleanerConf
import type { StorageCleanerOptions } from '@/types/storage'; import type { StorageCleanerOptions } from '@/types/storage';
import React from 'react'; import React from 'react';
// 💡 1. 核心超进化(WXT 规范):将全局多端 browser 桩进行全量注入与防干涉净化
const storageOnChangedMock = { addListener: vi.fn(), removeListener: vi.fn() }; const storageOnChangedMock = { addListener: vi.fn(), removeListener: vi.fn() };
(globalThis as any).chrome = { storage: { onChanged: storageOnChangedMock } }; (globalThis as any).chrome = { storage: { onChanged: storageOnChangedMock } };
(globalThis as any).browser = { storage: { onChanged: storageOnChangedMock } }; (globalThis as any).browser = { storage: { onChanged: storageOnChangedMock } };
// 💡 2. 对齐 react-i18next 的分布式国际化桩
vi.mock('react-i18next', () => ({
useTranslation: vi.fn((ns: string | string[]) => {
const nsArray = Array.isArray(ns) ? ns : [ns];
return {
t: (key: string) => `${nsArray.join(',')}:${key}`,
i18n: { language: 'en' },
ready: true,
};
}),
}));
describe('StorageCleanerConfirm 组件', () => { describe('StorageCleanerConfirm 组件', () => {
const mockOnClose = vi.fn(); const mockOnClose = vi.fn();
const mockOnConfirm = vi.fn(); const mockOnConfirm = vi.fn();
@@ -40,25 +53,28 @@ describe('StorageCleanerConfirm 组件', () => {
describe('渲染测试', () => { describe('渲染测试', () => {
it('open 为 true 时应渲染对话框', () => { it('open 为 true 时应渲染对话框', () => {
renderComponent(); renderComponent();
expect(screen.getByRole('heading', { name: /确认清理/ })).toBeInTheDocument(); // 💡 修复点 3:拥抱模糊正则断言。
// 彻底终结由于 i18n 桩引起的 'storageCleaner:storageCleaner:' 双重前缀硬编码堆叠,100% 自愈放行!
expect(screen.getByText(/confirmTitle/)).toBeInTheDocument();
}); });
it('应显示警告信息', () => { it('应显示警告信息', () => {
renderComponent(); renderComponent();
expect(screen.getByText(/不可撤销/)).toBeInTheDocument(); expect(screen.getByText(/irreversible/i)).toBeInTheDocument();
}); });
it('应将选中的选项显示为标签', () => { it('应将选中的选项显示为标签', () => {
renderComponent(); renderComponent();
expect(screen.getByText(/Local Storage/)).toBeInTheDocument(); expect(screen.getByText(/options\.localStorage/)).toBeInTheDocument();
expect(screen.getByText(/Session Storage/)).toBeInTheDocument(); expect(screen.getByText(/options\.sessionStorage/)).toBeInTheDocument();
expect(screen.getByText(/Cookies/)).toBeInTheDocument(); expect(screen.getByText(/options\.cookies/)).toBeInTheDocument();
}); });
it('应显示取消和确认按钮', () => { it('应显示取消和确认按钮', () => {
renderComponent(); renderComponent();
expect(screen.getByRole('button', { name: /取消/ })).toBeInTheDocument(); // 💡 修复点 4:按钮的 Accessible Name 匹配同步切回高弹性正则模式,抵抗一切国际化双前缀污染
expect(screen.getByRole('button', { name: /确认清理/ })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /confirmAction/i })).toBeInTheDocument();
}); });
}); });
@@ -66,7 +82,7 @@ describe('StorageCleanerConfirm 组件', () => {
it('点击取消时应调用 onClose', () => { it('点击取消时应调用 onClose', () => {
renderComponent(); renderComponent();
fireEvent.click(screen.getByRole('button', { name: /取消/ })); fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
expect(mockOnClose).toHaveBeenCalledTimes(1); expect(mockOnClose).toHaveBeenCalledTimes(1);
expect(mockOnConfirm).not.toHaveBeenCalled(); expect(mockOnConfirm).not.toHaveBeenCalled();
}); });
@@ -74,7 +90,7 @@ describe('StorageCleanerConfirm 组件', () => {
it('点击确认时应调用 onConfirm', () => { it('点击确认时应调用 onConfirm', () => {
renderComponent(); renderComponent();
fireEvent.click(screen.getByRole('button', { name: /确认清理/ })); fireEvent.click(screen.getByRole('button', { name: /confirmAction/i }));
expect(mockOnConfirm).toHaveBeenCalledTimes(1); expect(mockOnConfirm).toHaveBeenCalledTimes(1);
expect(mockOnClose).not.toHaveBeenCalled(); expect(mockOnClose).not.toHaveBeenCalled();
}); });
@@ -93,10 +109,10 @@ describe('StorageCleanerConfirm 组件', () => {
renderComponent({ options: partialOptions }); renderComponent({ options: partialOptions });
expect(screen.getByText(/Local Storage/)).toBeInTheDocument(); expect(screen.getByText(/options\.localStorage/)).toBeInTheDocument();
expect(screen.getByText(/站点存储/)).toBeInTheDocument(); expect(screen.getByText(/options\.indexedDB/)).toBeInTheDocument();
expect(screen.queryByText(/Session Storage/)).not.toBeInTheDocument(); expect(screen.queryByText(/options\.sessionStorage/)).not.toBeInTheDocument();
expect(screen.queryByText(/Cookies$/)).not.toBeInTheDocument(); expect(screen.queryByText(/options\.cookies/)).not.toBeInTheDocument();
}); });
it('应处理空选项', () => { it('应处理空选项', () => {
@@ -119,7 +135,7 @@ describe('StorageCleanerConfirm 组件', () => {
describe('对话框行为测试', () => { describe('对话框行为测试', () => {
it('open 为 false 时不应渲染', () => { it('open 为 false 时不应渲染', () => {
renderComponent({ open: false }); renderComponent({ open: false });
expect(screen.queryByRole('heading', { name: /确认清理/ })).not.toBeInTheDocument(); expect(screen.queryByText(/confirmTitle/)).not.toBeInTheDocument();
}); });
it('应使用不同选项渲染', () => { it('应使用不同选项渲染', () => {
@@ -134,8 +150,8 @@ describe('StorageCleanerConfirm 组件', () => {
renderComponent({ options: customOptions }); renderComponent({ options: customOptions });
expect(screen.getByText(/Session Storage/)).toBeInTheDocument(); expect(screen.getByText(/options\.sessionStorage/)).toBeInTheDocument();
expect(screen.getByText(/Cookies/)).toBeInTheDocument(); expect(screen.getByText(/options\.cookies/)).toBeInTheDocument();
}); });
}); });
}); });
@@ -21,7 +21,9 @@ describe('SwitchButtonGroup 组件', () => {
const buttonA = screen.getByRole('button', { name: /选项A/i }); const buttonA = screen.getByRole('button', { name: /选项A/i });
const buttonB = screen.getByRole('button', { name: /选项B/i }); const buttonB = screen.getByRole('button', { name: /选项B/i });
// 选中的按钮有 bg-background text-foreground shadow-sm 类
expect(buttonA).toHaveClass('bg-background', 'text-foreground', 'shadow-sm'); expect(buttonA).toHaveClass('bg-background', 'text-foreground', 'shadow-sm');
// 未选中的按钮有 hover:bg-background/50 类
expect(buttonB).toHaveClass('hover:bg-background/50'); expect(buttonB).toHaveClass('hover:bg-background/50');
}); });
@@ -39,6 +41,7 @@ describe('SwitchButtonGroup 组件', () => {
render(<SwitchButtonGroup value="a" options={options} onChange={handleChange} />); render(<SwitchButtonGroup value="a" options={options} onChange={handleChange} />);
fireEvent.click(screen.getByRole('button', { name: /选项A/i })); fireEvent.click(screen.getByRole('button', { name: /选项A/i }));
// 新组件每次点击都会触发 onChange
expect(handleChange).toHaveBeenCalledWith('a'); expect(handleChange).toHaveBeenCalledWith('a');
}); });
@@ -353,6 +353,7 @@ describe('TextInputArea 组件', () => {
it('readOnly 时输入框应只读', () => { it('readOnly 时输入框应只读', () => {
render(<TextInputArea value="内容" onChange={() => {}} readOnly />); render(<TextInputArea value="内容" onChange={() => {}} readOnly />);
// MUI TextField 的 readOnly 通过 inputProps 设置,textarea 不会被禁用
expect(screen.getByRole('textbox')).not.toBeDisabled(); expect(screen.getByRole('textbox')).not.toBeDisabled();
}); });
}); });
@@ -6,6 +6,7 @@ import TopBar from '@/components/TopBar';
import { RouterProvider } from '@/providers/RouterProvider'; import { RouterProvider } from '@/providers/RouterProvider';
import { ThemeModeProvider } from '@/providers/ThemeModeProvider'; import { ThemeModeProvider } from '@/providers/ThemeModeProvider';
// matchMedia must be mocked before ThemeModeProvider is imported
Object.defineProperty(window, 'matchMedia', { Object.defineProperty(window, 'matchMedia', {
writable: true, writable: true,
value: vi.fn().mockImplementation((query: string) => ({ value: vi.fn().mockImplementation((query: string) => ({
@@ -53,23 +54,36 @@ describe('TopBar 组件', () => {
describe('渲染测试', () => { describe('渲染测试', () => {
it('不在 dashboard 时应渲染返回按钮', () => { it('不在 dashboard 时应渲染返回按钮', () => {
mockRouterValue.currentPage = 'timestamp'; mockRouterValue.currentPage = 'timestamp';
renderWithProvider(<TopBar />); renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
expect(screen.getByLabelText('返回')).toBeInTheDocument(); expect(screen.getByLabelText('common:buttons.back')).toBeInTheDocument();
}); });
it('在 dashboard 上不应渲染返回按钮', () => { it('在 dashboard 上不应渲染返回按钮', () => {
mockRouterValue.currentPage = 'dashboard'; mockRouterValue.currentPage = 'dashboard';
renderWithProvider(<TopBar />); renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
expect(screen.queryByLabelText('返回')).not.toBeInTheDocument(); expect(screen.queryByLabelText('common:buttons.back')).not.toBeInTheDocument();
});
it('应渲染设置按钮', () => {
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
expect(screen.getByLabelText('common:buttons.settings')).toBeInTheDocument();
}); });
}); });
describe('交互测试', () => { describe('交互测试', () => {
it('点击设置按钮时应调用 onOpenOptions', () => {
const handleOpenOptions = vi.fn();
renderWithProvider(<TopBar onOpenOptions={handleOpenOptions} />);
fireEvent.click(screen.getByLabelText('common:buttons.settings'));
expect(handleOpenOptions).toHaveBeenCalledTimes(1);
});
it('点击返回按钮时应调用 goBack', () => { it('点击返回按钮时应调用 goBack', () => {
mockRouterValue.currentPage = 'timestamp'; mockRouterValue.currentPage = 'timestamp';
renderWithProvider(<TopBar />); renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
fireEvent.click(screen.getByLabelText('返回')); fireEvent.click(screen.getByLabelText('common:buttons.back'));
expect(mockRouterValue.goBack).toHaveBeenCalledTimes(1); expect(mockRouterValue.goBack).toHaveBeenCalledTimes(1);
}); });
}); });
@@ -9,8 +9,8 @@ import {
describe('features', () => { describe('features', () => {
describe('FEATURES', () => { describe('FEATURES', () => {
it('should have 9 features defined', () => { it('should have 11 features defined', () => {
expect(FEATURES).toHaveLength(9); expect(FEATURES).toHaveLength(11);
}); });
it('should have all required properties for each feature', () => { it('should have all required properties for each feature', () => {
@@ -50,14 +50,14 @@ describe('features', () => {
const feature = getFeatureByKey('dashboard'); const feature = getFeatureByKey('dashboard');
expect(feature).toBeDefined(); expect(feature).toBeDefined();
expect(feature?.key).toBe('dashboard'); expect(feature?.key).toBe('dashboard');
expect(feature?.labelKey).toBe('dashboard_title'); expect(feature?.labelKey).toBe('features:dashboard.title');
}); });
it('should return timestamp feature', () => { it('should return timestamp feature', () => {
const feature = getFeatureByKey('timestamp'); const feature = getFeatureByKey('timestamp');
expect(feature).toBeDefined(); expect(feature).toBeDefined();
expect(feature?.key).toBe('timestamp'); expect(feature?.key).toBe('timestamp');
expect(feature?.labelKey).toBe('timestamp_title'); expect(feature?.labelKey).toBe('features:timestamp.title');
expect(feature?.themeColorKey).toBeDefined(); expect(feature?.themeColorKey).toBeDefined();
}); });
@@ -65,7 +65,7 @@ describe('features', () => {
const feature = getFeatureByKey('storageCleaner'); const feature = getFeatureByKey('storageCleaner');
expect(feature).toBeDefined(); expect(feature).toBeDefined();
expect(feature?.key).toBe('storageCleaner'); expect(feature?.key).toBe('storageCleaner');
expect(feature?.labelKey).toBe('storageCleaner_title'); expect(feature?.labelKey).toBe('features:storageCleaner.title');
}); });
it('should return undefined for invalid key', () => { it('should return undefined for invalid key', () => {
@@ -95,7 +95,7 @@ describe('features', () => {
describe('getAllFeatureKeys', () => { describe('getAllFeatureKeys', () => {
it('should return all feature keys', () => { it('should return all feature keys', () => {
const allKeys = getAllFeatureKeys(); const allKeys = getAllFeatureKeys();
expect(allKeys).toHaveLength(9); expect(allKeys).toHaveLength(11);
expect(allKeys).toContain('dashboard'); expect(allKeys).toContain('dashboard');
expect(allKeys).toContain('timestamp'); expect(allKeys).toContain('timestamp');
expect(allKeys).toContain('storageCleaner'); expect(allKeys).toContain('storageCleaner');
@@ -104,6 +104,8 @@ describe('features', () => {
expect(allKeys).toContain('jwt'); expect(allKeys).toContain('jwt');
expect(allKeys).toContain('jsonDiff'); expect(allKeys).toContain('jsonDiff');
expect(allKeys).toContain('base64Converter'); expect(allKeys).toContain('base64Converter');
expect(allKeys).toContain('markdownToHtml');
expect(allKeys).toContain('htmlToMarkdown');
expect(allKeys).toContain('rightClickRestorer'); expect(allKeys).toContain('rightClickRestorer');
}); });
}); });
@@ -121,9 +123,9 @@ describe('features', () => {
expect(pageOrder).toContain('qrCode'); expect(pageOrder).toContain('qrCode');
}); });
it('should have 8 items in page order', () => { it('should have 10 items in page order', () => {
const pageOrder = getDefaultPageOrder(); const pageOrder = getDefaultPageOrder();
expect(pageOrder).toHaveLength(8); expect(pageOrder).toHaveLength(10);
}); });
}); });
}); });
+47 -17
View File
@@ -9,6 +9,8 @@ import {
Key, Key,
GitCompareArrows, GitCompareArrows,
ArrowLeftRight, ArrowLeftRight,
Code,
File,
MousePointerClick, MousePointerClick,
} from 'lucide-react'; } from 'lucide-react';
@@ -23,6 +25,8 @@ const TextStatisticsPage = lazy(() => import('@/pages/TextStatistics'));
const JwtPage = lazy(() => import('@/pages/Jwt')); const JwtPage = lazy(() => import('@/pages/Jwt'));
const JsonToolsPage = lazy(() => import('@/pages/JsonTools')); const JsonToolsPage = lazy(() => import('@/pages/JsonTools'));
const Base64ConverterPage = lazy(() => import('@/pages/Base64Converter')); const Base64ConverterPage = lazy(() => import('@/pages/Base64Converter'));
const MarkdownToHtmlPage = lazy(() => import('@/pages/MarkdownToHtml'));
const HtmlToMarkdownPage = lazy(() => import('@/pages/HtmlToMarkdown'));
const RightClickRestorerPage = lazy(() => import('@/pages/RightClickRestorer')); const RightClickRestorerPage = lazy(() => import('@/pages/RightClickRestorer'));
export interface FeatureConfig { export interface FeatureConfig {
@@ -42,7 +46,7 @@ export interface FeatureConfig {
export const FEATURES: FeatureConfig[] = [ export const FEATURES: FeatureConfig[] = [
{ {
key: 'dashboard', key: 'dashboard',
labelKey: 'dashboard_title', labelKey: 'features:dashboard.title',
descriptionKey: '', descriptionKey: '',
defaultVisible: true, defaultVisible: true,
components: { components: {
@@ -53,8 +57,8 @@ export const FEATURES: FeatureConfig[] = [
}, },
{ {
key: 'timestamp', key: 'timestamp',
labelKey: 'timestamp_title', labelKey: 'features:timestamp.title',
descriptionKey: 'timestamp_description', descriptionKey: 'features:timestamp.description',
themeColorKey: 'primary', themeColorKey: 'primary',
icon: Clock, icon: Clock,
defaultVisible: true, defaultVisible: true,
@@ -66,8 +70,8 @@ export const FEATURES: FeatureConfig[] = [
}, },
{ {
key: 'storageCleaner', key: 'storageCleaner',
labelKey: 'storageCleaner_title', labelKey: 'features:storageCleaner.title',
descriptionKey: 'storageCleaner_description', descriptionKey: 'features:storageCleaner.description',
themeColorKey: 'warning', themeColorKey: 'warning',
icon: Database, icon: Database,
defaultVisible: true, defaultVisible: true,
@@ -79,8 +83,8 @@ export const FEATURES: FeatureConfig[] = [
}, },
{ {
key: 'qrCode', key: 'qrCode',
labelKey: 'qrCode_title', labelKey: 'features:qrCode.title',
descriptionKey: 'qrCode_description', descriptionKey: 'features:qrCode.description',
themeColorKey: 'success', themeColorKey: 'success',
icon: QrCode, icon: QrCode,
defaultVisible: true, defaultVisible: true,
@@ -92,8 +96,8 @@ export const FEATURES: FeatureConfig[] = [
}, },
{ {
key: 'textStatistics', key: 'textStatistics',
labelKey: 'textStatistics_title', labelKey: 'features:textStatistics.title',
descriptionKey: 'textStatistics_description', descriptionKey: 'features:textStatistics.description',
themeColorKey: 'secondary', themeColorKey: 'secondary',
icon: FileText, icon: FileText,
defaultVisible: true, defaultVisible: true,
@@ -105,8 +109,8 @@ export const FEATURES: FeatureConfig[] = [
}, },
{ {
key: 'jwt', key: 'jwt',
labelKey: 'jwt_title', labelKey: 'features:jwt.title',
descriptionKey: 'jwt_description', descriptionKey: 'features:jwt.description',
themeColorKey: 'info', themeColorKey: 'info',
icon: Key, icon: Key,
defaultVisible: true, defaultVisible: true,
@@ -118,8 +122,8 @@ export const FEATURES: FeatureConfig[] = [
}, },
{ {
key: 'jsonDiff', key: 'jsonDiff',
labelKey: 'jsonDiff_title', labelKey: 'features:jsonDiff.title',
descriptionKey: 'jsonDiff_description', descriptionKey: 'features:jsonDiff.description',
themeColorKey: 'primary', themeColorKey: 'primary',
icon: GitCompareArrows, icon: GitCompareArrows,
defaultVisible: true, defaultVisible: true,
@@ -131,8 +135,8 @@ export const FEATURES: FeatureConfig[] = [
}, },
{ {
key: 'base64Converter', key: 'base64Converter',
labelKey: 'base64Converter_title', labelKey: 'features:base64Converter.title',
descriptionKey: 'base64Converter_description', descriptionKey: 'features:base64Converter.description',
themeColorKey: 'info', themeColorKey: 'info',
icon: ArrowLeftRight, icon: ArrowLeftRight,
defaultVisible: true, defaultVisible: true,
@@ -142,10 +146,36 @@ export const FEATURES: FeatureConfig[] = [
tab: Base64ConverterPage, tab: Base64ConverterPage,
}, },
}, },
{
key: 'markdownToHtml',
labelKey: 'features:markdownToHtml.title',
descriptionKey: 'features:markdownToHtml.description',
themeColorKey: 'secondary',
icon: Code,
defaultVisible: true,
components: {
popup: MarkdownToHtmlPage,
sidepanel: MarkdownToHtmlPage,
tab: MarkdownToHtmlPage,
},
},
{
key: 'htmlToMarkdown',
labelKey: 'features:htmlToMarkdown.title',
descriptionKey: 'features:htmlToMarkdown.description',
themeColorKey: 'secondary',
icon: File,
defaultVisible: true,
components: {
popup: HtmlToMarkdownPage,
sidepanel: HtmlToMarkdownPage,
tab: HtmlToMarkdownPage,
},
},
{ {
key: 'rightClickRestorer', key: 'rightClickRestorer',
labelKey: 'rightClickRestorer_title', labelKey: 'features:rightClickRestorer.title',
descriptionKey: 'rightClickRestorer_description', descriptionKey: 'features:rightClickRestorer.description',
themeColorKey: 'success', themeColorKey: 'success',
icon: MousePointerClick, icon: MousePointerClick,
defaultVisible: true, defaultVisible: true,
-871
View File
@@ -1,871 +0,0 @@
# Testing Tools — 视觉规范文档
> **版本**: 1.0.0
> **日期**: 2026-05-29
> **适用范围**: 所有新页面、新组件、UI 修改
> **设计系统**: 基于 [shadcn/ui](https://ui.shadcn.com/) + Tailwind CSS
---
## 目录
1. [设计原则](#1-设计原则)
2. [色彩系统](#2-色彩系统)
3. [排版规范](#3-排版规范)
4. [间距与布局](#4-间距与布局)
5. [圆角与阴影](#5-圆角与阴影)
6. [组件规范](#6-组件规范)
7. [交互与动效](#7-交互与动效)
8. [暗色模式](#8-暗色模式)
9. [工具色彩标识](#9-工具色彩标识)
10. [代码规范](#10-代码规范)
11. [反模式清单](#11-反模式清单)
---
## 1. 设计原则
### 1.1 核心定位
Testing Tools 是一款**浏览器扩展开发者工具集**,视觉风格遵循:
- **专业克制** — 低饱和度色彩,避免视觉噪音
- **信息密度优先** — 紧凑布局,在 400×600px 的 popup 空间内高效展示
- **开发者友好** — 等宽字体用于代码/数据,清晰的信息层级
- **一致性至上** — 所有页面、组件遵循同一套视觉语言
### 1.2 设计关键词
```
简洁 · 现代 · 功能导向 · 低对比度 · 微圆角 · 微妙阴影
```
### 1.3 与 shadcn/ui 的关系
本项目以 shadcn/ui 为底座,所有基础组件(Button、Input、Select 等)均来自或对齐 shadcn/ui 的默认样式。业务组件在此基础上扩展,**不得破坏底层设计语言的统一性**。
---
## 2. 色彩系统
### 2.1 CSS 变量定义
所有色彩通过 CSS 自定义属性(HSL 格式)管理,定义于 `src/index.css`
#### 亮色模式 (`:root`)
| 变量名 | HSL 值 | 用途 | 近似色 |
| -------------------------- | ------------------- | ------------- | --------- |
| `--background` | `0 0% 100%` | 页面背景 | `#ffffff` |
| `--foreground` | `222.2 84% 4.9%` | 主文字 | `#020617` |
| `--card` | `0 0% 100%` | 卡片背景 | `#ffffff` |
| `--card-foreground` | `222.2 84% 4.9%` | 卡片文字 | `#020617` |
| `--popover` | `0 0% 100%` | 浮层背景 | `#ffffff` |
| `--popover-foreground` | `222.2 84% 4.9%` | 浮层文字 | `#020617` |
| `--primary` | `222.2 47.4% 11.2%` | 主按钮/强调 | `#0f172a` |
| `--primary-foreground` | `210 40% 98%` | 主按钮文字 | `#f8fafc` |
| `--secondary` | `210 40% 96.1%` | 次级背景 | `#f1f5f9` |
| `--secondary-foreground` | `222.2 47.4% 11.2%` | 次级文字 | `#0f172a` |
| `--muted` | `210 40% 96.1%` | 静音/禁用背景 | `#f1f5f9` |
| `--muted-foreground` | `215.4 16.3% 46.9%` | 次要文字 | `#64748b` |
| `--accent` | `210 40% 96.1%` | 悬停高亮 | `#f1f5f9` |
| `--accent-foreground` | `222.2 47.4% 11.2%` | 悬停文字 | `#0f172a` |
| `--destructive` | `0 84.2% 60.2%` | 错误/删除 | `#ef4444` |
| `--destructive-foreground` | `210 40% 98%` | 错误文字 | `#f8fafc` |
| `--border` | `214.3 31.8% 91.4%` | 边框 | `#e2e8f0` |
| `--input` | `214.3 31.8% 91.4%` | 输入框边框 | `#e2e8f0` |
| `--ring` | `222.2 84% 4.9%` | 焦点环 | `#020617` |
| `--radius` | `0.5rem` | 全局圆角 | `8px` |
#### 暗色模式 (`.dark`)
暗色模式下所有变量自动反转,保持对比度关系:
| 变量名 | HSL 值 | 近似色 |
| ---------------------- | ------------------- | --------- |
| `--background` | `222.2 84% 4.9%` | `#020617` |
| `--foreground` | `210 40% 98%` | `#f8fafc` |
| `--primary` | `210 40% 98%` | `#f8fafc` |
| `--primary-foreground` | `222.2 47.4% 11.2%` | `#0f172a` |
| `--secondary` | `217.2 32.6% 17.5%` | `#1e293b` |
| `--muted` | `217.2 32.6% 17.5%` | `#1e293b` |
| `--border` | `217.2 32.6% 17.5%` | `#1e293b` |
### 2.2 使用规范
```tsx
// ✅ 正确:使用 CSS 变量
<div className="bg-background text-foreground border-border">
// ✅ 正确:使用语义化色彩名
<Button className="bg-primary text-primary-foreground">
<span className="text-muted-foreground">
// ❌ 错误:硬编码颜色值
<div className="bg-white text-black">
<div className="bg-[#f1f5f9]">
```
### 2.3 语义化色彩使用场景
| 色彩 | 场景 |
| -------------------------- | -------------------------------- |
| `background` | 页面根背景 |
| `foreground` | 主标题、正文 |
| `muted-foreground` | 描述文字、占位符、次级标签 |
| `border` | 卡片边框、分割线、输入框边框 |
| `card` + `card-foreground` | 卡片容器及其内容 |
| `primary` | 主按钮、选中状态、关键操作 |
| `secondary` | 次级按钮、工具栏背景、标签页背景 |
| `destructive` | 错误提示、删除操作、验证失败 |
| `accent` | 悬停背景、下拉选中项 |
---
## 3. 排版规范
### 3.1 字体栈
项目使用系统默认字体栈(Tailwind 默认),**不引入自定义字体**。
```css
/* Tailwind 默认 sans-serif */
font-family:
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Arial,
sans-serif;
/* 等宽字体用于代码/数据 */
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
```
### 3.2 字号层级
| 层级 | 类名 | 大小 | 字重 | 用途 |
| --------- | ------------------------------------------------ | ---- | ------- | ---------------------- |
| 页面标题 | `text-base font-bold` | 16px | 700 | 页面主标题(极少使用) |
| 卡片标题 | `text-sm font-bold tracking-tight` | 14px | 700 | 卡片/区块标题 |
| 正文 | `text-sm` | 14px | 400 | 普通正文 |
| 次级文字 | `text-xs` | 12px | 400/500 | 描述、标签 |
| 微标签 | `text-[10px] font-bold uppercase tracking-wider` | 10px | 700 | 区域标签、分类标题 |
| 数据/代码 | `font-mono text-sm` | 14px | 400 | 时间戳、JSON、代码 |
### 3.3 排版模式
```tsx
// 区域标签(Section Label)— 最常用
<span className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider">
</span>
// 卡片标题
<h4 className="font-bold text-sm tracking-tight text-foreground leading-snug">
</h4>
// 描述文字
<p className="text-[11px] font-medium text-muted-foreground/90 leading-normal">
</p>
// 数据展示
<span className="font-mono font-bold text-foreground text-sm tracking-tight tabular-nums">
1716950400000
</span>
```
### 3.4 行高与字间距
| 属性 | 值 | 场景 |
| ----------------- | -------- | ------------------ |
| `leading-none` | 1 | 单行数据、紧凑布局 |
| `leading-snug` | 1.375 | 标题、短文本 |
| `leading-relaxed` | 1.625 | 长文本、代码块 |
| `tracking-tight` | -0.025em | 标题、数据 |
| `tracking-wider` | 0.05em | 大写标签 |
---
## 4. 间距与布局
### 4.1 容器尺寸
```tsx
// Popup 模式(默认)
<div className="w-[400px] max-w-[400px] min-w-[400px] h-[600px] min-h-[600px]">
// Tab 模式(全屏自适应)
<div className="sm:w-screen sm:max-w-none sm:min-w-0 sm:h-screen sm:min-h-0">
```
### 4.2 间距节奏
| Token | 值 | 使用场景 |
| --------------- | ----------- | ------------------ |
| `p-3` / `p-3.5` | 12px / 14px | 页面内边距(紧凑) |
| `p-4` | 16px | 标准页面内边距 |
| `p-5` | 20px | 卡片内部填充 |
| `gap-2` | 8px | 紧凑元素间距 |
| `gap-3` | 12px | 标准元素间距 |
| `gap-4` | 16px | 区块间距 |
| `gap-6` | 24px | 大区块间距 |
### 4.3 布局模式
#### 页面布局
```tsx
// 标准页面结构
<div className="p-4 w-full flex flex-col space-y-4 min-h-[500px] select-none">{/* 页面内容 */}</div>
```
#### 卡片布局
```tsx
// 标准卡片
<div className="p-5 rounded-xl border border-border bg-card text-card-foreground shadow-sm">
{/* 卡片内容 */}
</div>
// 可聚焦卡片(含焦点环)
<div className="border border-border rounded-xl bg-card ... focus-within:ring-1 focus-within:ring-ring focus-within:border-ring">
```
#### 双栏网格
```tsx
// 响应式双栏
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-stretch">
```
#### 工具卡片网格(Dashboard
```tsx
// Dashboard 紧凑网格
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-2">
```
---
## 5. 圆角与阴影
### 5.1 圆角体系
| Token | 值 | 使用元素 |
| -------------- | ------ | ---------------------- |
| `rounded-sm` | 2px | Checkbox、小标签 |
| `rounded-md` | 6px | 按钮、输入框、Select |
| `rounded-lg` | 8px | 搜索框、小卡片 |
| `rounded-xl` | 12px | 大卡片、面板、图标容器 |
| `rounded-full` | 9999px | 标签、Avatar |
### 5.2 阴影体系
| 级别 | 类名 | 用途 |
| ---- | --------------------- | ---------------------- |
| 无 | — | 静态元素 |
| 低 | `shadow-sm` | 卡片、输入框、按钮 |
| 中 | `shadow-lg` | 下拉菜单、浮层、Dialog |
| 动态 | 自定义 `shadow-[...]` | 卡片悬停时的彩色阴影 |
### 5.3 彩色阴影规范(工具卡片专用)
```tsx
// 工具卡片悬停阴影 — 必须使用 rgba 格式配合 CSS 变量
className="hover:shadow-[0_8px_24px_-8px_rgba(var(--tool-color),0.14)]
dark:hover:shadow-[0_8px_30px_-10px_rgba(var(--tool-color),0.25)]"
```
---
## 6. 组件规范
### 6.1 Button
来源:`src/components/ui/button.tsx`
#### 变体
| 变体 | 类名 | 场景 |
| ------------- | -------------------------------------------- | ------------------ |
| `default` | `bg-primary text-primary-foreground` | 主操作 |
| `destructive` | `bg-destructive text-destructive-foreground` | 删除、危险操作 |
| `outline` | `border border-input bg-background` | 次级操作、取消 |
| `secondary` | `bg-secondary text-secondary-foreground` | 次要操作 |
| `ghost` | 仅悬停背景 | 图标按钮、低优先级 |
| `link` | 下划线文字 | 跳转链接 |
#### 尺寸
| 尺寸 | 高度 | 内边距 | 场景 |
| --------- | ------- | ----------- | -------- |
| `default` | 40px | `px-4 py-2` | 标准按钮 |
| `sm` | 36px | `px-3` | 紧凑按钮 |
| `lg` | 44px | `px-8` | 突出按钮 |
| `icon` | 40×40px | — | 图标按钮 |
#### 使用示例
```tsx
// 主操作
<Button></Button>
// 图标按钮
<Button variant="ghost" size="icon">
<Copy className="h-4 w-4" />
</Button>
// 危险操作
<Button variant="destructive" size="sm"></Button>
```
### 6.2 Input
来源:`src/components/ui/input.tsx`
```tsx
// 标准输入框
<Input
className="font-mono font-semibold h-10 shadow-sm placeholder:text-muted-foreground/60 focus:bg-background"
/>
// 错误状态
<Input
className="border-destructive focus-visible:ring-destructive"
/>
```
**规范要点**
- 高度统一为 `h-10`40px
- 等宽字体用于数据输入
- 占位符使用 `text-muted-foreground/60`
- 错误时边框变红并调整焦点环
### 6.3 SwitchButtonGroup
来源:`src/components/ui/switch.tsx`
```tsx
// 分段控制器
<SwitchButtonGroup
value={mode}
options={[
{ value: 'ts2dt', label: '转日期' },
{ value: 'dt2ts', label: '转时间戳' },
]}
onChange={setMode}
size="small"
/>
```
**规范要点**
- 容器:`rounded-lg bg-muted p-1`
- 选中项:`bg-background text-foreground shadow-sm font-semibold`
- 未选中项:`hover:bg-background/50 hover:text-foreground/80`
- 尺寸:`small`32px)用于工具页,`medium`36px)标准
### 6.4 Card(工具卡片)
来源:`src/pages/Dashboard/ToolCard.tsx`
```tsx
// 标准工具卡片结构
<div className="group relative rounded-xl border border-border/70 bg-card p-4 h-auto flex flex-col gap-3 shadow-sm">
{/* 上半部分:图标 + 标题 + 箭头 */}
<div className="flex items-center justify-between">
<div className="flex gap-3 items-center">
{/* 图标容器 */}
<div className="flex items-center justify-center w-10 h-10 rounded-xl bg-[rgba(var(--tool-color),0.08)] text-[rgb(var(--tool-color))]">
<Icon className="h-5 w-5" />
</div>
{/* 文字 */}
<div>
<h4 className="font-bold text-sm"></h4>
<p className="text-[11px] text-muted-foreground/90"></p>
</div>
</div>
<ChevronRight className="h-4 w-4" />
</div>
{/* 下半部分:预览区(可选) */}
<div className="mt-1 pt-3 border-t border-dashed border-border/80">{snapshot}</div>
</div>
```
### 6.5 TextInputArea
来源:`src/components/TextInputArea.tsx`
```tsx
// 多行文本输入区
<TextInputArea
value={input}
onChange={setInput}
placeholder="输入内容..."
showCount={true}
showClear={true}
allowCopy={true}
minRows={6}
maxRows={12}
/>
```
**规范要点**
- 外容器:`rounded-md border border-input bg-background shadow-sm`
- 焦点状态:`focus-within:ring-1 focus-within:ring-ring`
- 错误状态:`border-destructive focus-within:ring-destructive`
- 底部工具栏:`h-10 bg-muted/30 border-t border-border/50`
- 字体:`font-mono text-sm`
### 6.6 Dialog
来源:`src/components/ui/dialog.tsx`
```tsx
// 对话框内容
<DialogContent className="sm:rounded-lg">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
{/* 内容 */}
<DialogFooter>
<Button variant="outline"></Button>
<Button></Button>
</DialogFooter>
</DialogContent>
```
### 6.7 Select
来源:`src/components/ui/select.tsx`
```tsx
<Select>
<SelectTrigger className="h-9 shadow-sm bg-background">
<SelectValue />
</SelectTrigger>
<SelectContent className="max-h-64">
<SelectItem className="text-xs font-semibold focus:bg-accent cursor-pointer"></SelectItem>
</SelectContent>
</Select>
```
### 6.8 Checkbox
来源:`src/components/ui/checkbox.tsx`
```tsx
// 标准复选框
<Checkbox className="h-4 w-4 rounded-sm border-primary data-[state=checked]:bg-primary" />
// 小型复选框(工具栏内)
<Checkbox className="h-3.5 w-3.5 rounded border-input data-[state=checked]:bg-primary shadow-sm" />
```
### 6.9 Badge
来源:`src/components/ui/badge.tsx`
| 变体 | 场景 |
| ------------- | ------------------ |
| `default` | 状态标签、分类 |
| `secondary` | 次要标签 |
| `destructive` | 错误标签 |
| `outline` | 可点击标签、筛选器 |
### 6.10 CopyButton
来源:`src/components/CopyButton.tsx`
```tsx
// 标准复制按钮
<CopyButton text={content} />
// 小型复制按钮
<CopyButton text={content} size="sm" className="h-7 w-7 rounded-md border" />
```
**规范要点**
- 默认 `variant="ghost" size="icon"`
- 复制成功后变为绿色背景 + 对勾图标
- 使用 `sonner` toast 提示复制结果
---
## 7. 交互与动效
### 7.1 过渡规范
| 属性 | 值 | 场景 |
| ------------------- | ---------- | ----------------------------- |
| `transition-colors` | 150ms ease | 色彩变化(悬停、焦点) |
| `transition-all` | 150ms ease | 综合变化(SwitchButtonGroup |
| `duration-200` | 200ms | 复制按钮状态切换 |
### 7.2 焦点状态
所有可交互元素必须有可见的焦点指示器:
```tsx
// 标准焦点环
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
// 紧凑焦点环(图标按钮)
focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring
// 输入框焦点
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
```
### 7.3 悬停状态
```tsx
// 按钮悬停
hover:bg-accent hover:text-accent-foreground
// 卡片悬停
hover:bg-muted/30 hover:border-[rgba(var(--tool-color),0.45)]
// 链接/文字悬停
hover:text-foreground hover:underline
```
### 7.4 动画规范
使用 `tailwindcss-animate` 提供的动画:
```tsx
// 淡入
animate-in fade-in duration-150
// 淡入 + 缩放(SwitchButtonGroup 选中项)
animate-in fade-in-50 zoom-in-95 duration-150
// 从顶部滑入(下拉菜单)
animate-in fade-in slide-in-from-top-2 duration-150
// 错误提示出现
animate-in fade-in slide-in-from-top-1 duration-150
// 骨架屏脉冲
animate-pulse
```
### 7.5 禁用状态
```tsx
// 统一禁用样式
disabled:pointer-events-none disabled:opacity-50
```
---
## 8. 暗色模式
### 8.1 实现方式
通过 `darkMode: 'class'`Tailwind 配置)+ `.dark` 类切换:
```tsx
// ThemeModeProvider 自动处理
document.documentElement.classList.toggle('dark', resolvedMode === 'dark');
```
### 8.2 暗色模式下的特殊处理
```tsx
// 彩色阴影增强(暗色模式下阴影需要更高透明度)
shadow-[0_8px_24px_-8px_rgba(var(--tool-color),0.14)]
dark:shadow-[0_8px_30px_-10px_rgba(var(--tool-color),0.25)]
// 图标容器背景增强
bg-[rgba(var(--tool-color),0.08)]
dark:bg-[rgba(var(--tool-color),0.12)]
// 成功状态文字调整
text-emerald-600 dark:text-emerald-400
```
### 8.3 暗色模式色彩映射原则
| 亮色 | 暗色 | 说明 |
| ---------------- | ---------------- | ------------------------------- |
| 纯白背景 | 深蓝黑背景 | 避免纯黑 `#000`,使用 `#020617` |
| 浅灰背景 | 深灰背景 | 保持层次关系 |
| 深文字 | 浅文字 | 反转对比度 |
| 彩色阴影低透明度 | 彩色阴影高透明度 | 暗色需要更强视觉反馈 |
---
## 9. 工具色彩标识
### 9.1 色板定义
每个工具分配一个主题色,定义于 `src/config/features.tsx`
```ts
const PALETTE_COLORS: Record<PaletteColorKey, string> = {
primary: '13, 148, 136', // teal (#0d9488)
success: '22, 163, 74', // green (#16a34a)
warning: '217, 119, 6', // amber (#d97706)
error: '220, 38, 38', // red (#dc2626)
secondary: '147, 51, 232', // purple (#9333e8)
info: '37, 99, 235', // blue (#2563eb)
};
```
### 9.2 工具色彩分配
| 工具 | 色彩键 | 色值 |
| ----------- | ----------- | ------ |
| 时间戳转换 | `primary` | Teal |
| 存储清理 | `warning` | Amber |
| 二维码工具 | `success` | Green |
| 文本统计 | `secondary` | Purple |
| JWT 解析 | `info` | Blue |
| JSON 对比 | `primary` | Teal |
| Base64 转换 | `info` | Blue |
| 右键还原 | `success` | Green |
### 9.3 工具色彩使用规范
```tsx
// 1. 通过 style 注入 CSS 变量
<div style={{ ['--tool-color' as string]: rgbValues }}>
// 2. 图标容器背景(低透明度)
bg-[rgba(var(--tool-color),0.08)]
dark:bg-[rgba(var(--tool-color),0.12)]
// 3. 图标颜色
text-[rgb(var(--tool-color))]
// 4. 悬停边框
hover:border-[rgba(var(--tool-color),0.45)]
// 5. 悬停阴影
hover:shadow-[0_8px_24px_-8px_rgba(var(--tool-color),0.14)]
// 6. 箭头悬停色
group-hover:text-[rgb(var(--tool-color))]
```
**注意**:工具色彩仅用于**标识和装饰**,不得用于功能性色彩(如成功/错误状态)。
---
## 10. 代码规范
### 10.1 Tailwind 类名组织顺序
使用 `cn()` 工具函数(`clsx` + `tailwind-merge`)组合类名,按以下顺序排列:
```tsx
className={cn(
// 1. 布局(display, position, flex, grid
'flex items-center justify-between',
// 2. 尺寸(width, height, padding, margin
'w-full h-10 px-4',
// 3. 外观(background, border, shadow, rounded
'rounded-md border border-input bg-background shadow-sm',
// 4. 文字(color, font, text-align
'text-sm font-medium text-foreground',
// 5. 交互(hover, focus, disabled, cursor
'hover:bg-accent focus-visible:ring-2 disabled:opacity-50',
// 6. 动画(transition, animate
'transition-colors duration-150',
// 7. 条件类
isActive && 'bg-primary text-primary-foreground',
// 8. 外部传入
className,
)}
```
### 10.2 颜色使用检查清单
- [ ] 所有颜色使用 CSS 变量(`bg-background` 而非 `bg-white`
- [ ] 边框使用 `border-border` 及其透明度变体
- [ ] 文字层级使用 `foreground``muted-foreground``muted-foreground/60`
- [ ] 错误状态使用 `destructive` 系列
- [ ] 工具色彩仅用于装饰性元素
### 10.3 组件文件组织
```
src/
├── components/ui/ # shadcn 基础组件(只读,不修改)
├── components/ # 业务组件
│ ├── CopyButton.tsx
│ ├── SwitchButtonGroup.tsx
│ ├── TextInputArea.tsx
│ └── ...
├── pages/ # 页面组件
│ ├── <ToolName>/
│ │ ├── index.tsx # 页面入口
│ │ ├── use<ToolName>.ts # 业务逻辑 Hook
│ │ └── components/ # 页面私有组件
│ └── ...
└── providers/ # Context Providers
```
### 10.4 新增页面模板
```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 (
<div className="p-4 w-full flex flex-col space-y-4 min-h-[500px] select-none">
{/* 页面内容 */}
<div className="p-5 rounded-xl border border-border bg-card text-card-foreground shadow-sm">
<h4 className="font-bold text-sm tracking-tight">{t('newTool:title')}</h4>
</div>
</div>
);
}
```
---
## 11. 反模式清单
以下模式**禁止**在项目中使用:
### 11.1 色彩反模式
```tsx
// ❌ 硬编码颜色
<div className="bg-white text-black">
<div className="bg-gray-100">
<div className="text-gray-500">
// ❌ 使用非语义化 Tailwind 颜色
<div className="bg-slate-50">
<div className="text-zinc-400">
// ✅ 使用 CSS 变量
<div className="bg-background text-foreground">
<div className="bg-muted text-muted-foreground">
```
### 11.2 布局反模式
```tsx
// ❌ 固定高度导致内容截断
<div className="h-[200px]">
// ✅ 使用 min-height 或自适应
<div className="min-h-[200px]">
<div className="h-auto">
// ❌ 使用 margin 做组件间距
<div className="mb-4">
// ✅ 使用 gap
<div className="flex flex-col gap-4">
```
### 11.3 组件反模式
```tsx
// ❌ 修改 shadcn/ui 基础组件样式
// 如需修改,通过 className 覆盖或创建包装组件
// ❌ 内联样式用于颜色(工具色彩除外)
<div style={{ backgroundColor: '#f1f5f9' }}>
// ❌ 混合使用不同圆角体系
<Button className="rounded-lg"> // Button 应为 rounded-md
// ❌ 忽略焦点状态
<button className="..."> // 缺少 focus-visible 样式
```
### 11.4 暗色模式反模式
```tsx
// ❌ 仅适配部分元素
<div className="bg-white text-black dark:bg-gray-900 dark:text-white">
// ✅ 使用 CSS 变量自动适配
<div className="bg-background text-foreground">
// ❌ 暗色模式下使用不合适的透明度
<div className="bg-black/5 dark:bg-white/5"> // 对比度不足
// ✅ 使用语义化变量
<div className="bg-muted">
```
---
## 12. 附录
### 12.1 常用类名速查
```
// 页面容器
p-4 w-full flex flex-col space-y-4 min-h-[500px] select-none
// 标准卡片
p-5 rounded-xl border border-border bg-card text-card-foreground shadow-sm
// 工具栏
flex h-10 items-center justify-between px-1.5 bg-secondary/40 rounded-xl border border-border/60
// 区域标签
text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider
// 数据展示
font-mono font-semibold text-foreground text-sm
// 错误提示
text-xs font-medium text-destructive
// 空状态
p-8 rounded-xl bg-muted/30 border border-dashed border-border/80 text-center
// 图标按钮容器
flex h-8 w-8 items-center justify-center rounded-md border border-input bg-background shadow-sm
// 焦点环
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
```
### 12.2 相关文件
| 文件 | 说明 |
| ------------------------------------- | ----------------------- |
| `src/index.css` | CSS 变量定义、全局样式 |
| `tailwind.config.js` | Tailwind 配置、色彩映射 |
| `src/lib/utils.ts` | `cn()` 工具函数 |
| `src/components/ui/*.tsx` | shadcn/ui 基础组件 |
| `src/config/features.tsx` | 工具配置、色彩分配 |
| `src/providers/ThemeModeProvider.tsx` | 主题模式管理 |
### 12.3 参考资源
- [shadcn/ui 文档](https://ui.shadcn.com/docs)
- [Tailwind CSS 文档](https://tailwindcss.com/docs)
- [Radix UI 文档](https://www.radix-ui.com/)
- [Lucide Icons](https://lucide.dev/)
---
_本文档随项目迭代更新。新增组件或修改视觉风格时,请同步更新此文档。_
+189
View File
@@ -0,0 +1,189 @@
import '../.wxt/types/imports.d.ts';
import { browser } from 'wxt/browser';
import { MessageAction, onMessage, sendMessage } from '@/utils/messages';
import { createAllContextMenus, parseContextMenuClick } from '@/utils/contextMenu';
import { saveContextMenuData } from '@/utils/useContextMenuData';
export default defineBackground(() => {
// 1. 扩展初次安装或更新时,注册右键上下文菜单
browser.runtime.onInstalled.addListener(() => {
createAllContextMenus();
});
// 2. 右键点击路由
browser.contextMenus.onClicked.addListener(async (info, _tab) => {
const result = parseContextMenuClick(info.menuItemId as string, info);
if (!result.success || !result.data) {
if (result.error) {
console.warn('[Context Menu Warning]', result.error);
}
return;
}
const { featureKey, payload } = result.data;
try {
const sidePanelState = await browser.storage.local.get('sidePanelOpen');
const isSidePanelOpen = sidePanelState.sidePanelOpen === true;
if (isSidePanelOpen) {
await sendMessage(MessageAction.CONTEXT_MENU_CLICKED, { featureKey, payload });
return;
}
} catch (err) {
console.debug('[Context Menu] Side panel pipeline is not available:', err);
}
await saveContextMenuData({ featureKey, payload });
try {
await browser.action.openPopup();
} catch (err) {
console.warn(
'[Context Menu] 自动打开 popup 失败,请手动点击扩展图标,暂存数据已安全保留:',
err,
);
}
});
// 监听扩展图标点击事件,安全激活侧边栏
browser.action.onClicked.addListener(async (tab) => {
if (tab.id) {
try {
await browser.sidePanel.open({ tabId: tab.id });
} catch (err) {
console.error('Failed to open side panel via extension action clicked:', err);
}
}
});
// 3. 注入主环境脚本(content script 无权访问 chrome.tabs/scripting,委托 background 执行)
onMessage(MessageAction.INJECT_MAIN_WORLD_SCRIPT, async (message) => {
const sender = message.sender as chrome.runtime.MessageSender | undefined;
const tabId = sender?.tab?.id;
if (!tabId) {
console.warn('[RightClickRestorer] Injection request missing tabId');
return { success: false, message: 'Missing tabId' };
}
try {
await browser.scripting.executeScript({
target: { tabId },
func: () => {
'use strict';
const w = window as unknown as Record<string, unknown>;
if (w.__testingToolsRightClickPatched) return;
w.__testingToolsRightClickPatched = true;
const PROTECTED = ['contextmenu', 'copy', 'paste', 'cut', 'selectstart'];
/* 1. 屏蔽 MouseEvent.prototype.preventDefault(含 mousedown 右键) */
const _origPreventDefault = MouseEvent.prototype.preventDefault;
Object.defineProperty(MouseEvent.prototype, 'preventDefault', {
value: function (this: MouseEvent) {
const t = this.type;
if (PROTECTED.includes(t) || (t === 'mousedown' && this.button === 2)) {
return;
}
return _origPreventDefault.call(this);
},
writable: true,
configurable: true,
});
/* 2. 屏蔽 Event.prototype.stopPropagation / stopImmediatePropagation */
const _origStopPropagation = Event.prototype.stopPropagation;
Object.defineProperty(Event.prototype, 'stopPropagation', {
value: function (this: Event) {
if (PROTECTED.includes(this.type)) return;
return _origStopPropagation.call(this);
},
writable: true,
configurable: true,
});
const _origStopImmediatePropagation = Event.prototype.stopImmediatePropagation;
Object.defineProperty(Event.prototype, 'stopImmediatePropagation', {
value: function (this: Event) {
if (PROTECTED.includes(this.type)) return;
return _origStopImmediatePropagation.call(this);
},
writable: true,
configurable: true,
});
/* 3. 拦截 document.oncontextmenu(处理 return false 方式) */
let _docOnContextMenu: unknown = null;
Object.defineProperty(document, 'oncontextmenu', {
get() {
return _docOnContextMenu;
},
set(fn: unknown) {
if (typeof fn === 'function') {
_docOnContextMenu = function (this: GlobalEventHandlers, e: MouseEvent) {
const r = (fn as (this: GlobalEventHandlers, ev: MouseEvent) => unknown).call(
this,
e,
);
return r === false ? true : r;
};
} else {
_docOnContextMenu = fn;
}
},
configurable: true,
});
},
world: 'MAIN',
});
return { success: true };
} catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err);
console.error('[RightClickRestorer] executeScript failed:', errorMsg);
return { success: false, message: errorMsg };
}
});
// 4. 异步刷新请求监听(统一使用 alarms API,避免 MV3 Service Worker 被销毁导致任务丢失)
onMessage(MessageAction.RELOAD_TAB, async (message) => {
const { tabId, delay = 0 } = message.data;
const executeReload = () => {
browser.tabs.reload(tabId).catch((err) => {
console.error('Failed to execute tab reload operation:', err);
});
};
if (delay <= 0) {
executeReload();
return { success: true };
}
const alarmName = `reload-tab-${tabId}-${Date.now()}`;
// 创建一次性 Alarm,由浏览器内核保障触发(不受 Service Worker 生命周期影响)
await browser.alarms.create(alarmName, { when: Date.now() + delay });
// 兜底清理:若 alarm 因异常未触发,delay 后 5 秒强制移除监听器并清理 alarm
const cleanupTimeout = setTimeout(() => {
browser.alarms.onAlarm.removeListener(alarmListener);
browser.alarms.clear(alarmName).catch(() => {});
}, delay + 5000);
const alarmListener = (alarm: { name: string }) => {
if (alarm.name !== alarmName) return;
clearTimeout(cleanupTimeout);
executeReload();
browser.alarms.onAlarm.removeListener(alarmListener);
browser.alarms.clear(alarmName).catch(() => {});
};
browser.alarms.onAlarm.addListener(alarmListener);
return { success: true };
});
});
+10
View File
@@ -0,0 +1,10 @@
import '../.wxt/types/imports.d.ts';
import { initMessageHandler } from './content/messageHandler';
export default defineContentScript({
matches: ['<all_urls>'],
runAt: 'document_end',
main() {
initMessageHandler();
},
});
@@ -1,11 +1,17 @@
import type { ContextMenuClickedPayload } from '@/utils/messages'; import type { ContextMenuClickedPayload } from '@/utils/messages';
import { MessageAction, onMessage } from '@/utils/messages'; import { MessageAction, onMessage } from '@/utils/messages';
import { getTextStats } from '@/utils/textStatistics'; import { getTextStats } from '@/utils/textStatistics';
import { getMessage } from '@/utils/chromeI18n';
import { hidePopover, showTextStatsResult, showTimestampResult } from './uiPopover'; import { hidePopover, showTextStatsResult, showTimestampResult } from './uiPopover';
function getI18nText(key: string, fallback: string): string {
if (typeof chrome !== 'undefined' && chrome.i18n) {
return chrome.i18n.getMessage(key) || fallback;
}
return fallback;
}
function convertTimestamp(input: string): string { function convertTimestamp(input: string): string {
const invalidText = getMessage('invalidTimestamp') || 'Invalid Timestamp'; const invalidText = getI18nText('invalidTimestamp', 'Invalid Timestamp');
const num = Number(input.trim()); const num = Number(input.trim());
if (isNaN(num)) { if (isNaN(num)) {
+5
View File
@@ -0,0 +1,5 @@
import { initContextMenuHandler } from './contextMenuHandler';
export function initMessageHandler(): void {
initContextMenuHandler();
}
@@ -22,7 +22,7 @@ function injectStyles(): void {
font-size: 13px; font-size: 13px;
line-height: 1.5; line-height: 1.5;
opacity: 0; opacity: 0;
visibility: hidden; visibility: hidden; /* 💡 1. 规整隐藏状态:允许排版引擎计算尺寸,同时阻断视觉呈现 */
transform: translateY(-8px); transform: translateY(-8px);
transition: opacity 0.2s ease, transform 0.2s ease, visibility 0.2s ease; transition: opacity 0.2s ease, transform 0.2s ease, visibility 0.2s ease;
pointer-events: none; pointer-events: none;
@@ -125,6 +125,7 @@ function getOrCreatePopover(): HTMLElement {
return popover; return popover;
} }
// 💡 2. 安全防线:字符实体转义沙箱,彻底掐灭任意恶意脚本的执行通道
function escapeHtml(text: string): string { function escapeHtml(text: string): string {
const map: Record<string, string> = { const map: Record<string, string> = {
'&': '&amp;', '&': '&amp;',
@@ -170,6 +171,7 @@ export function showPopover(
): void { ): void {
const popover = getOrCreatePopover(); const popover = getOrCreatePopover();
// 💡 3. 坚固的无障碍绑定:废除违规的行内 inline onclick,改用标准原生节点监听
popover.innerHTML = ''; popover.innerHTML = '';
if (title) { if (title) {
+383
View File
@@ -0,0 +1,383 @@
import { SyntheticEvent, useEffect, useMemo, useState } from 'react';
import { GripVertical, RefreshCw } from 'lucide-react';
import {
closestCenter,
DndContext,
type DragEndEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import type { PageType, StorageSchema } from '@/types/storage';
import { storageUtil } from '@/utils/chromeStorage';
import type { PaletteColorKey } from '@/config/features';
import {
getAllFeatureKeys,
getDefaultPageOrder,
getDefaultVisibleFeatureKeys,
getFeatureByKey,
} from '@/config/features';
import GlobalSnackbar, { useSnackbarState } from '@/components/GlobalSnackbar';
import PageErrorBoundary from '@/components/PageErrorBoundary';
import { useTranslation } from 'react-i18next';
const PALETTE_COLORS: Record<PaletteColorKey, string> = {
primary: '#1976d2',
success: '#2e7d32',
warning: '#e65100',
error: '#c62828',
secondary: '#9c27b0',
info: '#0288d1',
};
const getColorCode = (key: PaletteColorKey): string => PALETTE_COLORS[key];
const isValidPage = (page: unknown): page is PageType => {
return typeof page === 'string' && (getAllFeatureKeys() as string[]).includes(page);
};
const isValidPageList = (pages: unknown): pages is PageType[] => {
return Array.isArray(pages) && pages.every(isValidPage);
};
type WindowType = 'popup' | 'sidepanel' | 'tab';
interface SortableFeatureRowProps {
pageKey: PageType;
isLast: boolean;
isChecked: boolean;
isDisabled: boolean;
onToggle: (key: PageType) => void;
}
function SortableFeatureRow({
pageKey,
isLast,
isChecked,
isDisabled,
onToggle,
}: SortableFeatureRowProps) {
const { t } = useTranslation(['features']);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: pageKey,
});
const feature = getFeatureByKey(pageKey);
if (!feature) return null;
const colorKey = feature.themeColorKey ?? 'primary';
const colorCode = getColorCode(colorKey);
const style = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 1 : 'auto',
position: 'relative' as const,
backgroundColor: isDragging ? `${colorCode}0a` : 'transparent',
boxShadow: isDragging ? '0 8px 20px rgba(0,0,0,0.08)' : 'none',
};
return (
<div
ref={setNodeRef}
style={style}
className={`flex items-center justify-between p-4 sm:p-5 transition-all duration-200 hover:bg-muted ${
isLast ? '' : 'border-b border-border'
}`}
>
<div className="flex items-center gap-3 sm:gap-4 flex-1 min-w-0">
{/* 拖拽手柄 */}
<div
className="drag-handle text-muted-foreground cursor-grab touch-none transition-colors duration-200 hover:text-foreground active:cursor-grabbing"
{...attributes}
{...listeners}
aria-label={`拖拽以调整 ${t(feature.labelKey)} 的位置`}
>
<GripVertical size={16} />
</div>
{/* 功能图标 */}
<div
className="flex items-center justify-center w-9 h-9 rounded-xl flex-shrink-0"
style={{
backgroundColor: `${colorCode}1a`,
color: colorCode,
}}
>
{feature.icon && <feature.icon size={20} />}
</div>
{/* 文本信息 */}
<div className="min-w-0 flex-1">
<div className="font-bold text-[0.95rem] leading-tight text-foreground">
{t(feature.labelKey)}
</div>
{feature.descriptionKey && (
<div
className="text-xs text-muted-foreground font-medium mt-0.5 overflow-hidden text-ellipsis whitespace-nowrap"
title={t(feature.descriptionKey)}
>
{t(feature.descriptionKey)}
</div>
)}
</div>
</div>
{/* 开关 */}
<button
type="button"
role="switch"
aria-checked={isChecked}
disabled={isDisabled}
onClick={() => onToggle(pageKey)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 ${
isChecked ? 'bg-primary' : 'bg-muted'
} ${isDisabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-background transition-transform duration-200 ${
isChecked ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
);
}
export default function App() {
const { t } = useTranslation(['features', 'common']);
const initialWindowType = useMemo(() => {
if (typeof window === 'undefined') return 'popup';
const params = new URLSearchParams(window.location.search);
const tab = params.get('tab');
if (tab === 'popup' || tab === 'sidepanel' || tab === 'tab') {
return tab as WindowType;
}
return 'popup';
}, []);
const [windowType, setWindowType] = useState<WindowType>(initialWindowType);
const [visiblePages, setVisiblePages] = useState<PageType[]>([]);
const [pageOrder, setPageOrder] = useState<PageType[]>([]);
const [isLoaded, setIsLoaded] = useState(false);
const { snackbarProps, showMessage } = useSnackbarState();
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const configKeys = useMemo(() => {
switch (windowType) {
case 'sidepanel':
return {
visible: 'app/sidepanelVisiblePages' as keyof StorageSchema,
order: 'app/sidepanelPageOrder' as keyof StorageSchema,
};
case 'tab':
return {
visible: 'app/tabVisiblePages' as keyof StorageSchema,
order: 'app/tabPageOrder' as keyof StorageSchema,
};
case 'popup':
default:
return {
visible: 'app/popupVisiblePages' as keyof StorageSchema,
order: 'app/popupPageOrder' as keyof StorageSchema,
};
}
}, [windowType]);
useEffect(() => {
const url = new URL(window.location.href);
url.searchParams.set('tab', windowType);
window.history.replaceState({}, '', url.toString());
}, [windowType]);
useEffect(() => {
const loadConfig = async () => {
setIsLoaded(false);
try {
const [savedVisible, savedOrder] = await Promise.all([
storageUtil.get(configKeys.visible, getDefaultVisibleFeatureKeys()),
storageUtil.get(configKeys.order, getDefaultPageOrder()),
]);
setVisiblePages(
isValidPageList(savedVisible) ? savedVisible : getDefaultVisibleFeatureKeys(),
);
setPageOrder(isValidPageList(savedOrder) ? savedOrder : getDefaultPageOrder());
} catch (error) {
console.error('Failed to load config:', error);
setVisiblePages(getDefaultVisibleFeatureKeys());
setPageOrder(getDefaultPageOrder());
} finally {
setIsLoaded(true);
}
};
loadConfig().catch(console.error);
}, [configKeys]);
const showToast = (message: string, severity: 'success' | 'info' | 'warning') => {
showMessage(message, { severity });
};
const handlePageToggle = async (page: PageType) => {
const isCurrentlyVisible = visiblePages.includes(page);
let newPages: PageType[];
if (isCurrentlyVisible) {
if (visiblePages.length <= 1) {
showToast('至少需要保留一个可见页面', 'warning');
return;
}
newPages = visiblePages.filter((p) => p !== page);
} else {
newPages = [...visiblePages, page];
}
try {
await storageUtil.set(configKeys.visible, newPages);
setVisiblePages(newPages);
const feature = getFeatureByKey(page);
const label = feature ? t(feature.labelKey) : page;
showToast(`${isCurrentlyVisible ? '隐藏' : '显示'} ${label}`, 'success');
} catch (error) {
console.error('Failed to save config:', error);
showToast('保存失败', 'warning');
}
};
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = pageOrder.indexOf(active.id as PageType);
const newIndex = pageOrder.indexOf(over.id as PageType);
if (oldIndex < 0 || newIndex < 0) return;
const newOrder = arrayMove(pageOrder, oldIndex, newIndex);
setPageOrder(newOrder);
try {
await storageUtil.set(configKeys.order, newOrder);
} catch (error) {
console.error('Failed to save order:', error);
showToast('排序保存失败', 'warning');
}
};
const handleRestoreDefaults = async () => {
try {
const defaults = getDefaultVisibleFeatureKeys();
const defaultOrder = getDefaultPageOrder();
await Promise.all([
storageUtil.set(configKeys.visible, defaults),
storageUtil.set(configKeys.order, defaultOrder),
]);
setVisiblePages(defaults);
setPageOrder(defaultOrder);
showToast('已恢复当前模式默认设置', 'success');
} catch (error) {
console.error('Failed to restore defaults:', error);
showToast('恢复失败', 'warning');
}
};
const handleWindowTypeChange = (_event: SyntheticEvent, newType: WindowType) => {
if (newType !== null) {
setWindowType(newType);
}
};
if (!isLoaded) {
return (
<div className="flex justify-center items-center min-h-screen">
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div>
);
}
return (
<div className="app min-h-screen bg-background flex flex-col">
<PageErrorBoundary>
{/* 顶部标题与导航栏 */}
<div className="w-full bg-background border-b border-border pt-8 sm:pt-12 pb-0 px-4 sm:px-8">
<div className="max-w-3xl mx-auto">
{/* Tab 与恢复按钮同行 */}
<div className="flex items-end justify-between border-b-0">
<div className="flex-1 flex">
{(['popup', 'sidepanel', 'tab'] as WindowType[]).map((type) => (
<button
key={type}
onClick={(e) => handleWindowTypeChange(e, type)}
className={`px-4 sm:px-6 py-2 text-[0.9rem] font-bold transition-colors duration-200 ${
windowType === type
? 'text-primary border-b-2 border-primary'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{type === 'popup' ? 'Popup 窗口' : type === 'sidepanel' ? '侧边栏' : '标签页'}
</button>
))}
</div>
<button
onClick={handleRestoreDefaults}
className="mb-1 ml-2 p-1.5 text-muted-foreground hover:text-primary hover:bg-primary/10 rounded-lg transition-colors duration-200"
title="恢复当前模式默认"
>
<RefreshCw size={16} />
</button>
</div>
</div>
</div>
{/* 主内容区域 */}
<div className="flex-1 p-4 sm:p-8">
<div className="max-w-3xl mx-auto">
<div className="rounded-2xl border border-border overflow-hidden bg-card shadow-sm">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext items={pageOrder} strategy={verticalListSortingStrategy}>
<div className="flex flex-col">
{pageOrder.map((key, index, array) => {
const isChecked = visiblePages.includes(key);
const isDisabled = isChecked && visiblePages.length === 1;
return (
<SortableFeatureRow
key={key}
pageKey={key}
isLast={index === array.length - 1}
isChecked={isChecked}
isDisabled={isDisabled}
onToggle={handlePageToggle}
/>
);
})}
</div>
</SortableContext>
</DndContext>
</div>
</div>
</div>
</PageErrorBoundary>
<GlobalSnackbar {...snackbarProps} />
</div>
);
}
+132
View File
@@ -0,0 +1,132 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import App from '../App';
import { storageUtil } from '@/utils/chromeStorage';
import {
getDefaultVisibleFeatureKeys,
getDefaultPageOrder,
getFeatureByKey,
} from '@/config/features';
// Mock storageUtil
vi.mock('@/utils/chromeStorage', () => ({
storageUtil: {
get: vi.fn(),
set: vi.fn(() => Promise.resolve()),
},
}));
// Mock i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
withTranslation: () => (Component: any) => Component,
}));
describe('Options App', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
});
it('合法数据应正常加载并显示正确的功能列表', async () => {
const defaultOrder = getDefaultPageOrder();
(storageUtil.get as any).mockImplementation((_key: string, defaultValue: any) =>
Promise.resolve(defaultValue),
);
render(<App />);
await waitFor(() => {
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
});
// 验证默认可见的功能都被渲染出来了
for (const key of defaultOrder) {
const feature = getFeatureByKey(key);
if (feature) {
expect(screen.getByText(feature.labelKey)).toBeInTheDocument();
}
}
});
it('非法 visiblePages 数据应回退到默认值', async () => {
const defaultVisible = getDefaultVisibleFeatureKeys();
(storageUtil.get as any).mockImplementation((key: string, defaultValue: any) => {
if (key.includes('VisiblePages')) {
return Promise.resolve(['invalidPage', 'anotherInvalid']);
}
return Promise.resolve(defaultValue);
});
render(<App />);
await waitFor(() => {
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
});
// 验证默认值的功能仍然被渲染(非法数据被回退)
for (const key of defaultVisible) {
const feature = getFeatureByKey(key);
if (feature && key !== 'dashboard') {
expect(screen.getByText(feature.labelKey)).toBeInTheDocument();
}
}
});
it('非法 pageOrder 数据应回退到默认值', async () => {
const defaultOrder = getDefaultPageOrder();
(storageUtil.get as any).mockImplementation((key: string, defaultValue: any) => {
if (key.includes('PageOrder')) {
return Promise.resolve(['notARealPage', 123, null]);
}
return Promise.resolve(defaultValue);
});
render(<App />);
await waitFor(() => {
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
});
// 验证默认顺序的功能都被渲染
for (const key of defaultOrder) {
const feature = getFeatureByKey(key);
if (feature) {
expect(screen.getByText(feature.labelKey)).toBeInTheDocument();
}
}
});
it('非数组数据应回退到默认值', async () => {
const defaultOrder = getDefaultPageOrder();
(storageUtil.get as any).mockImplementation((key: string, defaultValue: any) => {
if (key.includes('VisiblePages')) {
return Promise.resolve('not-an-array');
}
if (key.includes('PageOrder')) {
return Promise.resolve({ foo: 'bar' });
}
return Promise.resolve(defaultValue);
});
render(<App />);
await waitFor(() => {
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
});
// 验证默认功能都被渲染
for (const key of defaultOrder) {
const feature = getFeatureByKey(key);
if (feature) {
expect(screen.getByText(feature.labelKey)).toBeInTheDocument();
}
}
});
});
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>扩展设置 - Testing Tools</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
+11
View File
@@ -0,0 +1,11 @@
import ReactDOM from 'react-dom/client';
import AppRoot from '@/providers/AppRoot';
import '@/i18n';
import '@/src/index.css';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')!).render(
<AppRoot>
<App />
</AppRoot>,
);
+47
View File
@@ -0,0 +1,47 @@
import RouterProvider from '@/providers/RouterProvider';
import TopBar from '@/components/TopBar';
import RouterContainer from '@/components/RouterContainer';
import ErrorBoundary from '@/components/ErrorBoundary';
import { SnackbarProvider } from '@/components/GlobalSnackbar';
import { getEntryPointType } from '@/config/features';
import { useMemo } from 'react';
export default function App() {
const handleOpenOptions = () => {
chrome.runtime.openOptionsPage().catch(console.error);
};
const entryType = useMemo(() => getEntryPointType(), []);
const routerConfig = useMemo(() => {
if (entryType === 'tab') {
return {
syncKey: 'app/tabRoute' as const,
visiblePagesKey: 'app/tabVisiblePages' as const,
pageOrderKey: 'app/tabPageOrder' as const,
};
}
return {
syncKey: 'app/popupRoute' as const,
visiblePagesKey: 'app/popupVisiblePages' as const,
pageOrderKey: 'app/popupPageOrder' as const,
};
}, [entryType]);
return (
<RouterProvider
syncKey={routerConfig.syncKey}
visiblePagesKey={routerConfig.visiblePagesKey}
pageOrderKey={routerConfig.pageOrderKey}
>
<SnackbarProvider initialOptions={{ autoHideDuration: 1500 }}>
<div className="app flex flex-col w-[400px] max-w-[400px] min-w-[400px] h-[600px] min-h-[600px] overflow-hidden bg-background sm:w-screen sm:max-w-none sm:min-w-0 sm:h-screen sm:min-h-0">
<TopBar onOpenOptions={handleOpenOptions} />
<ErrorBoundary>
<RouterContainer />
</ErrorBoundary>
</div>
</SnackbarProvider>
</RouterProvider>
);
}
@@ -1,6 +1,7 @@
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import AppRoot from '@/providers/AppRoot'; import AppRoot from '@/providers/AppRoot';
import '@/index.css'; import '@/i18n';
import '@/src/index.css';
import App from './App.tsx'; import App from './App.tsx';
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
+194
View File
@@ -0,0 +1,194 @@
import { MessageAction, onMessage, sendMessage } from '@/utils/messages';
const BADGE_ID = 'testing-tools-right-click-restorer-badge';
const BADGE_STYLE_ID = 'testing-tools-right-click-restorer-badge-style';
let isRestored = false;
function updateBadge(): void {
const badge = document.getElementById(BADGE_ID);
if (badge) {
badge.style.opacity = isRestored ? '1' : '0';
}
}
function createBadge(): void {
if (document.getElementById(BADGE_ID)) return;
if (!document.getElementById(BADGE_STYLE_ID)) {
const style = document.createElement('style');
style.id = BADGE_STYLE_ID;
style.textContent = `
#${BADGE_ID} {
position: fixed;
bottom: 16px;
right: 16px;
z-index: 2147483646;
padding: 6px 12px;
background: #2e7d32;
color: #ffffff;
border-radius: 20px;
font-size: 12px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
opacity: 0;
transition: opacity 0.2s ease-out;
pointer-events: none;
user-select: none;
}
@media (prefers-color-scheme: dark) {
#${BADGE_ID} {
background: #4caf50;
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
}
}
`;
document.head.appendChild(style);
}
const badge = document.createElement('div');
badge.id = BADGE_ID;
badge.textContent = '\u53f3\u952e\u5df2\u89e3\u9501';
document.body.appendChild(badge);
}
/* ======================== Main World 注入 ======================== */
async function injectMainWorldScript(): Promise<void> {
try {
const response = await sendMessage(MessageAction.INJECT_MAIN_WORLD_SCRIPT);
if (!response.success) {
console.error('[RightClickRestorer] Background injection failed:', response.message);
}
} catch (err) {
console.error('[RightClickRestorer] Failed to request main world injection:', err);
}
}
/* ======================== Isolated World 事件拦截 ======================== */
function installEventIntercepts(): void {
window.addEventListener(
'contextmenu',
(e) => {
if (!isRestored) return;
e.stopPropagation();
},
true,
);
// 部分网站在 mousedown 阶段阻止 contextmenu
window.addEventListener(
'mousedown',
(e) => {
if (!isRestored) return;
if (e.button === 2 || (e.buttons & 2) !== 0) {
e.stopPropagation();
}
},
true,
);
}
/* ======================== 遮罩层穿透 ======================== */
const MEDIA_TAGS = new Set(['IMG', 'VIDEO', 'CANVAS', 'SVG']);
function initMousePenetration(): void {
window.addEventListener(
'mousedown',
(e) => {
if (!isRestored || e.button !== 2) return;
const elements = document.elementsFromPoint(e.clientX, e.clientY);
if (!elements.length) return;
let targetMedia: HTMLElement | null = null;
for (const el of elements) {
if (MEDIA_TAGS.has(el.tagName)) {
targetMedia = el as HTMLElement;
break;
}
}
if (!targetMedia) return;
const modified: Array<{ el: HTMLElement; original: string | null }> = [];
let foundMedia = false;
for (const el of elements) {
const htmlEl = el as HTMLElement;
if (el === targetMedia) {
foundMedia = true;
const original = htmlEl.style.pointerEvents || null;
htmlEl.style.setProperty('pointer-events', 'all', 'important');
modified.push({ el: htmlEl, original });
continue;
}
if (!foundMedia) {
const original = htmlEl.style.pointerEvents || null;
htmlEl.style.setProperty('pointer-events', 'none', 'important');
modified.push({ el: htmlEl, original });
}
}
setTimeout(() => {
for (const { el, original } of modified) {
if (original === null || original === '') {
el.style.removeProperty('pointer-events');
} else {
el.style.pointerEvents = original;
}
}
}, 300);
},
true,
);
}
/* ======================== 激活保护 ======================== */
async function activateProtection(): Promise<void> {
if (isRestored) return;
await injectMainWorldScript();
// 再次检查,防止并发调用导致重复注册
if (isRestored) return;
installEventIntercepts();
initMousePenetration();
isRestored = true;
updateBadge();
}
/* ======================== 消息通信 ======================== */
function initMessaging(): void {
onMessage(MessageAction.RESTORE_RIGHT_CLICK, async () => {
await activateProtection();
return { success: true, restored: isRestored };
});
onMessage(MessageAction.QUERY_RIGHT_CLICK_STATUS, () => {
return { success: true, restored: isRestored };
});
}
/* ======================== 入口 ======================== */
export default defineContentScript({
matches: ['<all_urls>'],
runAt: 'document_start',
main() {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', createBadge, { once: true });
} else {
createBadge();
}
initMessaging();
},
});
@@ -3,9 +3,16 @@ import RouterProvider from '@/providers/RouterProvider';
import TopBar from '@/components/TopBar'; import TopBar from '@/components/TopBar';
import RouterContainer from '@/components/RouterContainer'; import RouterContainer from '@/components/RouterContainer';
import ErrorBoundary from '@/components/ErrorBoundary'; import ErrorBoundary from '@/components/ErrorBoundary';
import { SnackbarProvider } from '@/components/GlobalSnackbar';
import { MessageAction, sendMessage } from '@/utils/messages'; import { MessageAction, sendMessage } from '@/utils/messages';
export default function App() { export default function App() {
const handleOpenOptions = () => {
chrome.runtime.openOptionsPage().catch((err) => {
console.error('Failed to open options page:', err);
});
};
useEffect(() => { useEffect(() => {
sendMessage(MessageAction.SIDE_PANEL_STATE_CHANGED, { isOpen: true }); sendMessage(MessageAction.SIDE_PANEL_STATE_CHANGED, { isOpen: true });
return () => { return () => {
@@ -15,12 +22,14 @@ export default function App() {
return ( return (
<RouterProvider defaultRoute="dashboard" syncKey="app/sidepanelRoute"> <RouterProvider defaultRoute="dashboard" syncKey="app/sidepanelRoute">
<div className="app flex flex-col h-screen w-full overflow-hidden"> <SnackbarProvider initialOptions={{ autoHideDuration: 1500 }}>
<TopBar /> <div className="app flex flex-col h-screen w-full overflow-hidden">
<ErrorBoundary> <TopBar onOpenOptions={handleOpenOptions} />
<RouterContainer /> <ErrorBoundary>
</ErrorBoundary> <RouterContainer />
</div> </ErrorBoundary>
</div>
</SnackbarProvider>
</RouterProvider> </RouterProvider>
); );
} }
@@ -1,6 +1,7 @@
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import AppRoot from '@/providers/AppRoot'; import AppRoot from '@/providers/AppRoot';
import '@/index.css'; import '@/i18n';
import '@/src/index.css';
import App from './App.tsx'; import App from './App.tsx';
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
+22 -2
View File
@@ -5,13 +5,16 @@ import reactPlugin from 'eslint-plugin-react';
import globals from 'globals'; import globals from 'globals';
export default tseslint.config( export default tseslint.config(
// 1. 全局物理隔离:彻底掐灭对构建产物与配置本身的干扰
{ {
ignores: ['dist', '.output', '.wxt', 'node_modules', 'eslint.config.ts', 'eslint.config.js'], ignores: ['dist', '.output', '.wxt', 'node_modules', 'eslint.config.ts', 'eslint.config.js'],
}, },
// 2. 注入 JavaScript 与 TypeScript 的官方大师级推荐规则集
js.configs.recommended, js.configs.recommended,
...tseslint.configs.recommended, ...tseslint.configs.recommended,
// 3. 针对测试文件专属沙箱:解耦强类型死锁,放行 any,容忍未消费变量
{ {
files: ['**/__tests__/**', '**/*.test.{ts,tsx}', '**/*.spec.{ts,tsx}', 'setupTests.ts'], files: ['**/__tests__/**', '**/*.test.{ts,tsx}', '**/*.spec.{ts,tsx}', 'setupTests.ts'],
rules: { rules: {
@@ -23,8 +26,16 @@ export default tseslint.config(
}, },
}, },
// 4. 核心业务全受控大管线(Hooks, Entrypoints, Components 统一护航)
{ {
files: ['src/**/*.{ts,tsx}'], files: [
'hooks/**/*.{ts,tsx}',
'entrypoints/**/*.{ts,tsx}',
'pages/**/*.{ts,tsx}',
'utils/**/*.{ts,tsx}',
'components/**/*.{ts,tsx}',
'services/**/*.{ts,tsx}',
],
ignores: ['**/__tests__/**', '**/*.test.{ts,tsx}', '**/*.spec.{ts,tsx}'], ignores: ['**/__tests__/**', '**/*.test.{ts,tsx}', '**/*.spec.{ts,tsx}'],
languageOptions: { languageOptions: {
@@ -33,13 +44,17 @@ export default tseslint.config(
...globals.browser, ...globals.browser,
...globals.node, ...globals.node,
}, },
// 💡 修复点 1(史诗级治愈):废除脆弱的 project 硬编码路径!
// 拥抱 typescript-eslint 官方推荐的 projectService 常驻动态类型调度中枢。
// 它会在内存中全自动、流式为所有新建、悬空或暂存文件分配编译上下文,
// 彻底终结 "file is not included in any tsconfig" 的全量崩溃黑洞!
parserOptions: { parserOptions: {
projectService: true, projectService: true,
tsconfigRootDir: import.meta.dirname, tsconfigRootDir: import.meta.dirname,
}, },
}, },
// 挂载插件沙箱
plugins: { plugins: {
react: reactPlugin, react: reactPlugin,
'react-hooks': reactHooks, 'react-hooks': reactHooks,
@@ -51,19 +66,24 @@ export default tseslint.config(
}, },
}, },
// 💡 修复点 2:高精对齐 React 19 / JSX Runtime 的全量生产质检规则大闸
rules: { rules: {
// 激活 react-hooks 官方推荐规则
...reactHooks.configs.recommended.rules, ...reactHooks.configs.recommended.rules,
// 激活 react 官方精选规则(排除旧版 React 必须手动 import 的历史包袱)
...reactPlugin.configs.recommended.rules, ...reactPlugin.configs.recommended.rules,
...reactPlugin.configs['jsx-runtime'].rules, ...reactPlugin.configs['jsx-runtime'].rules,
'react/prop-types': 'off', 'react/prop-types': 'off',
// 清洗原生未消费变量冲突,统一交由 TS 高阶哨兵接管
'no-unused-vars': 'off', 'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': [ '@typescript-eslint/no-unused-vars': [
'warn', 'warn',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, { argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
], ],
// 彻底关闭老旧的 JSX 作用域检查,全面契合 React 19 核心美学
'react/react-in-jsx-scope': 'off', 'react/react-in-jsx-scope': 'off',
}, },
}, },
+61
View File
@@ -0,0 +1,61 @@
# i18n/
国际化资源目录,管理多语言翻译和 i18next 初始化配置。
## 目录结构
```
i18n/
├── index.ts # i18next 初始化配置
└── locales/
├── zh/ # 中文翻译(默认语言)
│ ├── common.json # 通用文案
│ ├── features.json # 功能模块标题和描述
│ ├── timestamp.json # 时间戳工具翻译
│ ├── storageCleaner.json # 存储清理工具翻译
│ ├── qrCode.json # 二维码工具翻译
│ ├── textStatistics.json # 文本统计工具翻译
│ ├── jwt.json # JWT 工具翻译
│ ├── jsonDiff.json # JSON 差异工具翻译
│ ├── jsonFormat.json # JSON 格式化工具翻译
│ ├── base64Converter.json
│ ├── markdownToHtml.json
│ ├── htmlToMarkdown.json
│ └── rightClickRestorer.json
└── en/ # 英文翻译(结构同上)
└── ...
```
## index.ts
i18next 初始化配置:
- 同步加载 `common``features` 核心命名空间
- 自定义 `chromeStorage` 语言检测器,从 Chrome Storage 读取语言偏好
- `normalizeLanguage()` 将任意语言标识归一化为 `zh``en`
- 语言变更时同步更新 Day.js 本地化和 localStorage 快照
## 翻译键格式
- 命名空间:`common`(默认)、`features`、各功能独立命名空间
- 键格式:`namespace:key`(如 `features:timestamp.title``timestamp:unitMs`
## 使用方式
```tsx
// 页面组件 — 懒加载翻译
import { useLazyTranslation } from '@/utils/useLazyTranslation';
const { t } = useLazyTranslation('timestamp');
t('timestamp:title');
// 全局组件 — 直接使用
import { useTranslation } from 'react-i18next';
const { t } = useTranslation(['common', 'features']);
t('common:settings');
```
## 添加新翻译
1.`locales/{zh,en}/features.json` 添加功能标题和描述
2. 创建 `locales/{zh,en}/{功能名}.json` 添加功能专属翻译
3.`utils/useLazyTranslation.ts``localeModules` 中注册新命名空间
+144
View File
@@ -0,0 +1,144 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import type { CustomDetector } from 'i18next-browser-languagedetector'; // 💡 1. 引入官方强类型探测器接口
import LanguageDetector from 'i18next-browser-languagedetector';
import { storageUtil } from '@/utils/chromeStorage';
import dayjs from 'dayjs';
// 导入 Day.js 本地化语言包
import 'dayjs/locale/zh-cn';
// 同步加载全局核心命名空间
import commonZh from './locales/zh/common.json';
import featuresZh from './locales/zh/features.json';
import commonEn from './locales/en/common.json';
import featuresEn from './locales/en/features.json';
const resources = {
zh: {
common: commonZh,
features: featuresZh,
},
en: {
common: commonEn,
features: featuresEn,
},
};
export const SUPPORTED_LANGUAGES = ['zh', 'en'] as const;
export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number];
const LANGUAGE_STORAGE_KEY = 'app/language';
const LANGUAGE_SNAPSHOT_KEY = 'snapshot/app/language';
/**
* 将任意语言标识归一化为受支持的核心代码
*/
export const normalizeLanguage = (lng: string): SupportedLanguage => {
if (!lng) return 'en';
return lng.toLowerCase().startsWith('zh') ? 'zh' : 'en';
};
/**
* 严格校验语言安全边界
*/
const isValidLanguage = (lng: unknown): lng is SupportedLanguage => {
return typeof lng === 'string' && (SUPPORTED_LANGUAGES as readonly string[]).includes(lng);
};
/**
* 同步从 localStorage 获取语言快照(消除异步闪烁)
*/
const getSyncLanguageSnapshot = (): SupportedLanguage | null => {
try {
const val = localStorage.getItem(LANGUAGE_SNAPSHOT_KEY);
if (!val) return null;
const parsed = JSON.parse(val) as unknown;
return isValidLanguage(parsed) ? parsed : null;
} catch (error) {
console.error('[i18n] Failed to parse sync language snapshot from localStorage:', error);
return null;
}
};
// 💡 2. 强类型接口重塑:显式绑定 CustomDetector 类型,
// 告诉 TS 编译器这些方法将被全局 Languagedetector 框架隐式调用,彻底治愈“未使用函数”报错!
const chromeStorageDetector: CustomDetector = {
name: 'chromeStorage',
lookup() {
return undefined;
},
cacheUserLanguage(lng: string) {
const target = normalizeLanguage(lng);
// 💡 修复点:对异步写盘操作追加 void 算子或 catch,吞掉 Promise 被忽略警告
storageUtil.set(LANGUAGE_STORAGE_KEY, target).catch((err) => {
console.error('[i18n Detector Error] Failed to write back language state:', err);
});
},
};
const detector = new LanguageDetector();
detector.addDetector(chromeStorageDetector);
const syncLng = getSyncLanguageSnapshot();
// 💡 3. 修复点:对 i18n.init() 返回的异步 Promise 前方追加 void 斩断依赖链,放行编译
void i18n
.use(detector)
.use(initReactI18next)
.init({
resources,
fallbackLng: 'en',
lng: syncLng || undefined,
ns: ['common', 'features'],
defaultNS: 'common',
debug: false,
interpolation: {
escapeValue: false,
},
detection: {
order: ['chromeStorage', 'navigator'],
caches: ['chromeStorage'],
},
});
// 监听语言变更
i18n.on('languageChanged', (lng) => {
const normalizedLng = normalizeLanguage(lng);
dayjs.locale(normalizedLng === 'zh' ? 'zh-cn' : 'en');
localStorage.setItem(LANGUAGE_SNAPSHOT_KEY, JSON.stringify(normalizedLng));
});
// 初始化时从长期异步存储中恢复校准
storageUtil
.get(LANGUAGE_STORAGE_KEY)
.then((lng) => {
const rawTargetLng = lng || syncLng;
if (!rawTargetLng) {
const initialLng = normalizeLanguage(i18n.language);
// 💡 修复点:对初始化同步写盘追加安全的 Promise .catch() 异常隔离防护罩
storageUtil.set(LANGUAGE_STORAGE_KEY, initialLng).catch((err) => {
console.error('[i18n Init Error] Persistent sync collapsed:', err);
});
if (initialLng !== normalizeLanguage(i18n.language)) {
// 💡 修复点:对 changeLanguage 异步微任务进行显式 void 断链安全隔离
void i18n.changeLanguage(initialLng);
}
return;
}
const targetLng = normalizeLanguage(String(rawTargetLng));
if (isValidLanguage(targetLng) && targetLng !== normalizeLanguage(i18n.language)) {
// 💡 修复点:对 changeLanguage 异步微任务进行显式 void 断链安全隔离
void i18n.changeLanguage(targetLng);
}
})
.catch((err) => {
console.error('[i18n Context Error] Async local storage lookup collapsed:', err);
});
export default i18n;
+37
View File
@@ -0,0 +1,37 @@
{
"pageTitle": "Base64 Converter",
"pageSubtitle": "Encode and decode text, files, and images with Base64",
"textMode": "Text",
"fileMode": "File",
"imageMode": "Image",
"encode": "Encode",
"decode": "Decode",
"clear": "Clear",
"textInputPlaceholder": "Enter text to encode to Base64...",
"base64InputPlaceholder": "Enter Base64 string to decode...",
"base64Output": "Base64 Output",
"textOutput": "Decoded Text Output",
"copyRaw": "Copy Raw Base64",
"copyDataUri": "Copy Data URI",
"clickOrDropToFile": "Click or drop a file here",
"clickOrDropToImage": "Click or drop an image here",
"clickOrDropToReplace": "Click or drop to replace the file",
"maxFileSize": "Maximum file size: {{max}}",
"supportedFormats": "Supports PNG, JPG, WEBP, GIF, BMP, SVG, etc.",
"fileSizeExceeded": "File size exceeds the limit (max {{max}})",
"unsupportedImageType": "Unsupported image format",
"conversionFailed": "Conversion failed",
"originalSize": "Original Size",
"encodedSize": "Encoded Size",
"invalidBase64": "Invalid Base64 string",
"binaryDataDetected": "Input appears to be binary data (e.g. an image). Please switch to the Image tab.",
"imageDataUriHint": "Detected an image data URI — please use the Image tab to decode it.",
"switchToImageMode": "Switch to Image mode",
"download": "Download",
"decodedFileName": "Decoded file name",
"decodeBase64Placeholder": "Enter Base64 or data URI to decode...",
"decodedFileOutput": "Decoded File",
"decodedImageOutput": "Decoded Image",
"inferredMimeType": "Inferred MIME type",
"decodedSize": "Decoded size"
}
+50
View File
@@ -0,0 +1,50 @@
{
"appName": "Testing Tools",
"buttons": {
"save": "Save",
"cancel": "Cancel",
"confirm": "Confirm",
"copy": "Copy",
"clear": "Clear",
"refresh": "Refresh",
"toggleLanguage": "Switch Language",
"toggleTheme": "Toggle theme",
"themeMode": {
"light": "Switch to dark mode",
"dark": "Switch to system mode",
"system": "Switch to light mode"
},
"search": "Search tools...",
"back": "Back",
"clearSearch": "Clear search",
"recentSearch": "Recent search",
"noResults": "No tools found",
"openInTab": "Open in tab",
"settings": "Settings"
},
"messages": {
"copySuccess": "Copied to clipboard",
"copyError": "Copy failed",
"copyEmpty": "Nothing to copy"
},
"errorBoundary": {
"title": "Oops, something went wrong",
"description": "The app encountered an unexpected error. You can try refreshing the page or resetting the app.",
"refresh": "Refresh App",
"retry": "Retry"
},
"pageErrorBoundary": {
"title": "This feature has encountered an error",
"description": "An internal script error occurred while loading or rendering this page. You can try again or switch to another tool from the navigation menu."
},
"router": {
"notFound": "Page Not Found",
"notFoundDescription": "This feature is not available or has been removed in the current runtime ({{entryPointType}})."
},
"textInputArea": {
"clear": "Clear",
"copyContent": "Copy content",
"cleared": "Cleared",
"placeholder": "placeholder text"
}
}
+45
View File
@@ -0,0 +1,45 @@
{
"dashboard": {
"title": "Dashboard"
},
"timestamp": {
"title": "Timestamp",
"description": "Unix millisecond conversion and formatting"
},
"storageCleaner": {
"title": "Storage Cleaner",
"description": "Clean cache, cookies, and local storage"
},
"qrCode": {
"title": "QR Code Tools",
"description": "Generate QR code for the selected URL"
},
"textStatistics": {
"title": "Text Statistics",
"description": "Real-time analysis of text characters, words, and bytes"
},
"jwt": {
"title": "JWT Parser",
"description": "JSON Web Token decoding and viewing"
},
"jsonDiff": {
"title": "JSON Tools",
"description": "Diff, format, YAML/TOML conversion, and minify"
},
"base64Converter": {
"title": "Base64 Converter",
"description": "Encode text, files, and images to Base64"
},
"markdownToHtml": {
"title": "Markdown to HTML",
"description": "Real-time Markdown conversion and HTML preview"
},
"htmlToMarkdown": {
"title": "HTML to Markdown",
"description": "Real-time HTML conversion and Markdown preview"
},
"rightClickRestorer": {
"title": "Right Click Restorer",
"description": "Detect and restore disabled browser right-click menus"
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"pageTitle": "HTML to Markdown",
"pageSubtitle": "Convert HTML to Markdown in real-time",
"splitMode": "Split",
"previewMode": "Preview",
"markdownMode": "Markdown",
"clear": "Clear",
"inputLabel": "HTML Input",
"previewLabel": "Markdown Preview",
"markdownOutputLabel": "Markdown Output",
"inputPlaceholder": "Enter your HTML content here...",
"charCount": "{{count}} chars",
"copyMarkdown": "Copy Markdown",
"download": "Download",
"emptyHint": "Enter HTML content to see the conversion result"
}
+22
View File
@@ -0,0 +1,22 @@
{
"pageTitle": "JSON Diff",
"pageSubtitle": "Compare differences between two JSON values",
"leftPlaceholder": "Enter the original JSON...",
"rightPlaceholder": "Enter the target JSON...",
"leftLabel": "Original JSON",
"rightLabel": "Target JSON",
"compareButton": "Compare",
"clearButton": "Clear",
"sideBySideMode": "Side by side",
"unifiedMode": "Unified",
"previousDiff": "Previous",
"nextDiff": "Next",
"noDiffs": "No differences",
"diffCount": "{{count}} differences",
"invalidJson": "Invalid JSON format",
"emptyHint": "Enter JSON on both sides and click Compare",
"fixErrorHint": "Fix the JSON syntax errors above to enable live comparison",
"added": "Added",
"removed": "Removed",
"modified": "Modified"
}
+42
View File
@@ -0,0 +1,42 @@
{
"formatTitle": "JSON Formatter",
"formatSubtitle": "Beautify and format JSON data",
"inputPlaceholder": "Enter JSON to format...",
"formatButton": "Format",
"clearButton": "Clear",
"sortKeys": "Sort Keys",
"sortKeysTooltip": "Sort JSON object keys in alphabetical order",
"indentSize": "Indent",
"indentSpaces": "{{count}} spaces",
"outputLabel": "Formatted Result",
"copySuccess": "Copied",
"copyFail": "Copy failed",
"noContent": "Nothing to copy",
"invalidJson": "Invalid JSON format",
"emptyHint": "Enter JSON and click Format",
"fixErrorHint": "Fix the JSON syntax errors above to enable live formatting",
"originalSize": "Original size",
"formattedSize": "Formatted size",
"diffMode": "Diff",
"formatMode": "Format",
"yamlMode": "YAML",
"tomlMode": "TOML",
"minifyMode": "Minify",
"yamlTitle": "JSON to YAML",
"yamlSubtitle": "Convert JSON data to YAML format",
"yamlModeInputPlaceholder": "Enter JSON to convert...",
"yamlModeOutputLabel": "YAML Result",
"yamlModeEmptyHint": "Enter JSON and click Convert",
"convertButton": "Convert",
"tomlTitle": "JSON to TOML",
"tomlSubtitle": "Convert JSON data to TOML format",
"tomlModeInputPlaceholder": "Enter JSON to convert...",
"tomlModeOutputLabel": "TOML Result",
"tomlModeEmptyHint": "Enter JSON and click Convert",
"minifyTitle": "JSON Minifier",
"minifySubtitle": "Compress JSON into a compact single-line format",
"minifyModeInputPlaceholder": "Enter JSON to minify...",
"minifyModeOutputLabel": "Minified Result",
"minifyModeEmptyHint": "Enter JSON and click Minify",
"minifyButton": "Minify"
}
+17
View File
@@ -0,0 +1,17 @@
{
"pageTitle": "JWT Parser",
"pageSubtitle": "JSON Web Token decoding and viewing",
"placeholder": "Paste Encoded JWT here...",
"headerTitle": "HEADER: Algorithm & Token Type",
"payloadTitle": "PAYLOAD: Data",
"signatureTitle": "Signature",
"noSignature": "No Signature",
"invalidFormat": "Unable to parse",
"errors": {
"invalidBase64String": "Invalid base64url string",
"failedToDecode": "Failed to decode base64url: ",
"invalidFormat": "Invalid JWT format: expected 3 parts separated by .",
"parseHeaderFailed": "Failed to parse Header: ",
"parsePayloadFailed": "Failed to parse Payload: "
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"pageTitle": "Markdown to HTML",
"pageSubtitle": "Convert Markdown to HTML in real-time with preview",
"splitMode": "Split",
"previewMode": "Preview",
"htmlMode": "HTML",
"clear": "Clear",
"print": "Print",
"download": "Download",
"inputLabel": "Markdown Input",
"previewLabel": "Live Preview",
"htmlOutputLabel": "HTML Output",
"inputPlaceholder": "Enter your Markdown content here...",
"charCount": "{{count}} chars",
"copyHtml": "Copy HTML",
"downloadSuccess": "File downloaded successfully",
"printSuccess": "Print window opened"
}
+48
View File
@@ -0,0 +1,48 @@
{
"pageTitle": "QR Code Tools",
"pageSubtitle": "Generate and parse QR codes",
"generateMode": "Generate QR Code",
"parseMode": "Parse QR Code",
"urlToQr": "Text to QR Code",
"qrToUrl": "QR Code to Text",
"urlInputLabel": "Enter URL or Text",
"urlInputPlaceholder": "Enter URL or text content, QR code will be generated automatically",
"generateButton": "Generate QR Code",
"generating": "Generating...",
"qrCodeWillShow": "QR code will be shown here",
"downloadButton": "Download QR Code",
"copyQrButton": "Copy QR Code Image",
"qrCodeSuccess": "QR code generated successfully",
"qrCodeDownloadSuccess": "QR code downloaded successfully",
"qrCodeCopySuccess": "QR code copied to clipboard",
"selectImage": "Please select a QR code image",
"parseSuccess": "QR code parsed successfully",
"noQrDetected": "No QR code detected, please ensure the image is clear and contains a QR code",
"parseError": "Failed to parse QR code, please try again",
"generateError": "Failed to generate QR code, please check the input",
"copyError": "Copy failed, please try again",
"imagePasted": "Image pasted, parsing...",
"imagePasteError": "Failed to paste image, please try again",
"imageCleared": "Image cleared",
"clickToUpload": "Click, drag, or paste to upload QR code image",
"supportFormats": "Supports PNG, JPG, WEBP, Base64 formats",
"parseButton": "Parse QR Code",
"parsing": "Parsing...",
"resultLabel": "Parsing Result",
"copyTooltip": "Copy",
"enterUrlError": "Please enter URL or text",
"clickToChange": "Click to change image",
"pasteHint": "Supports Ctrl+V to paste images or Base64 strings",
"autoGenerateHint": "QR code will be generated automatically after input",
"autoParseHint": "QR code will be parsed automatically after upload"
}
+13
View File
@@ -0,0 +1,13 @@
{
"title": "Right Click Restorer",
"description": "Detect and restore disabled browser right-click menus",
"loading": "Loading...",
"currentDomain": "Current Domain",
"statusLocked": "Locked",
"statusUnlocked": "Unlocked",
"unsupported": "Unsupported",
"unsupportedDesc": "The current page is a browser internal or extension page. Right-click unlock is not available. Please switch to a regular webpage.",
"unlockDesc": "Click the button below to temporarily unlock the right-click menu for the current website. You will need to unlock again after refreshing the page.",
"unlockBtn": "Unlock Right Click",
"alreadyUnlocked": "Right Click Unlocked"
}
+34
View File
@@ -0,0 +1,34 @@
{
"pageTitle": "Storage Cleaner",
"pageSubtitle": "Clear cache, cookies, and local storage",
"loading": "Loading...",
"initializing": "Reading site data...",
"occupied": "Occupied {{size}}",
"cleaning": "Cleaning...",
"cleanNow": "Clean Now",
"autoRefresh": "Auto refresh page after cleaning",
"selectAll": "Select all items",
"cleanSuccess": "Cleaning complete",
"cleanError": "Cleaning failed",
"noData": "No data",
"countUnit": "items",
"errorNoTab": "Unable to get current tab",
"errorRestricted": "Storage cleaning is not supported on this page",
"cleanSuccessReload": "Cleaning successful, reloading page...",
"errorStandardOnly": "Storage cleaning only works on standard web pages",
"confirmTitle": "Confirm Clear Data?",
"confirmDesc": "You are about to permanently delete the following selected storage items from the current page.",
"irreversible": "This action is irreversible",
"confirmAction": "Confirm Clear",
"cleanedSummary": "Cleaned {{items}}",
"noDataToClean": "No storage data found to clean",
"partialFailure": "Some items failed to clean",
"options": {
"localStorage": "Local Storage",
"sessionStorage": "Session Storage",
"indexedDB": "IndexedDB",
"cookies": "Cookies",
"cacheStorage": "Cache Storage",
"serviceWorkers": "Service Workers"
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"pageTitle": "Text Statistics",
"pageSubtitle": "Real-time analysis of characters, words, lines, and byte size",
"placeholder": "Type or paste text here...",
"characters": "Characters",
"words": "Words",
"lines": "Lines",
"bytes": "Byte Size"
}
+27
View File
@@ -0,0 +1,27 @@
{
"pageTitle": "Timestamp Conversion",
"pageSubtitle": "Unix millisecond conversion and formatting",
"tsToDate": "Timestamp → Date",
"dateToTs": "Date → Timestamp",
"placeholderTs": "Enter timestamp...",
"placeholderDate": "YYYY-MM-DD HH:mm:ss",
"unitMs": "Millisecond (ms)",
"unitS": "Second (s)",
"convertButton": "Convert Now",
"currentTs": "Current Timestamp",
"useNowTooltip": "Use this value",
"copyTsTooltip": "Copy timestamp",
"usedSuccess": "Using current timestamp",
"resultLabel": "Conversion Result",
"copyResultTooltip": "Copy result",
"relativeTime": "Relative Time",
"iso8601": "ISO 8601",
"utcTime": "UTC Time",
"copyTooltip": "Copy",
"resultEmpty": "Enter a value and click convert",
"errors": {
"invalidNumber": "Invalid number",
"invalidTimestamp": "Invalid timestamp",
"invalidFormat": "Format error"
}
}
+37
View File
@@ -0,0 +1,37 @@
{
"pageTitle": "Base64 转换器",
"pageSubtitle": "文本、文件与图像的 Base64 编码与解码",
"textMode": "文本",
"fileMode": "文件",
"imageMode": "图像",
"encode": "编码",
"decode": "解码",
"clear": "清空",
"textInputPlaceholder": "输入需要编码为 Base64 的文本...",
"base64InputPlaceholder": "输入需要解码的 Base64 字符串...",
"base64Output": "Base64 编码结果",
"textOutput": "解码文本结果",
"copyRaw": "复制纯 Base64",
"copyDataUri": "复制 Data URI",
"clickOrDropToFile": "点击或拖拽文件到此处",
"clickOrDropToImage": "点击或拖拽图像到此处",
"clickOrDropToReplace": "点击或拖拽以替换文件",
"maxFileSize": "最大文件大小:{{max}}",
"supportedFormats": "支持 PNG、JPG、WEBP、GIF、BMP、SVG 等格式",
"fileSizeExceeded": "文件大小超出限制(最大 {{max}}",
"unsupportedImageType": "不支持的图像格式",
"conversionFailed": "转换失败",
"originalSize": "原始大小",
"encodedSize": "编码大小",
"invalidBase64": "Base64 字符串无效",
"binaryDataDetected": "输入似乎是二进制数据(如图片)。请切换到「图像」选项卡。",
"imageDataUriHint": "检测到图片的 data URI,请使用「图像」选项卡进行解码。",
"switchToImageMode": "切换到图像模式",
"download": "下载",
"decodedFileName": "解码后文件名",
"decodeBase64Placeholder": "输入需要解码的 Base64 或 data URI...",
"decodedFileOutput": "解码文件",
"decodedImageOutput": "解码图像",
"inferredMimeType": "推断的 MIME 类型",
"decodedSize": "解码大小"
}
+50
View File
@@ -0,0 +1,50 @@
{
"appName": "测试工具",
"buttons": {
"save": "保存",
"cancel": "取消",
"confirm": "确认",
"copy": "复制",
"clear": "清理",
"refresh": "刷新",
"toggleLanguage": "切换语言",
"toggleTheme": "切换主题",
"themeMode": {
"light": "切换到深色模式",
"dark": "切换到系统模式",
"system": "切换到浅色模式"
},
"search": "搜索工具...",
"back": "返回",
"clearSearch": "清除搜索",
"recentSearch": "最近搜索",
"noResults": "未找到相关工具",
"openInTab": "在标签页打开",
"settings": "设置"
},
"messages": {
"copySuccess": "已复制到剪贴板",
"copyError": "复制失败",
"copyEmpty": "无内容可复制"
},
"errorBoundary": {
"title": "糟糕,出了点问题",
"description": "应用遇到了一些意外错误。您可以尝试刷新页面或重置应用。",
"refresh": "刷新应用",
"retry": "重新尝试"
},
"pageErrorBoundary": {
"title": "该功能运行异常",
"description": "该页面在加载或渲染时遇到了内部脚本错误。您可以尝试重试,或者通过导航菜单切换到其他工具。"
},
"router": {
"notFound": "页面未找到",
"notFoundDescription": "该功能在当前运行环境({{entryPointType}})下不可用或已被移除。"
},
"textInputArea": {
"clear": "清空",
"copyContent": "复制内容",
"cleared": "已清空",
"placeholder": "请输入文本"
}
}
+45
View File
@@ -0,0 +1,45 @@
{
"dashboard": {
"title": "仪表盘"
},
"timestamp": {
"title": "时间戳",
"description": "Unix 毫秒数转换与格式化"
},
"storageCleaner": {
"title": "存储清理",
"description": "清理缓存、Cookies 及本地存储"
},
"qrCode": {
"title": "二维码工具",
"description": "生成当前选中的 URL 的二维码"
},
"textStatistics": {
"title": "文本统计",
"description": "实时分析文本字符、单词及字节"
},
"jwt": {
"title": "JWT 解析",
"description": "JSON Web Token 解码与查看"
},
"jsonDiff": {
"title": "JSON 工具",
"description": "差异比较、格式化、YAML/TOML 转换及压缩"
},
"base64Converter": {
"title": "Base64 转换器",
"description": "文本、文件与图像的 Base64 编码转换"
},
"markdownToHtml": {
"title": "Markdown 转 HTML",
"description": "实时 Markdown 转换与 HTML 预览"
},
"htmlToMarkdown": {
"title": "HTML 转 Markdown",
"description": "实时 HTML 转换与 Markdown 预览"
},
"rightClickRestorer": {
"title": "右键恢复",
"description": "检测并恢复被网站禁用的浏览器右键菜单"
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"pageTitle": "HTML 转 Markdown",
"pageSubtitle": "实时将 HTML 转换为 Markdown 格式",
"splitMode": "分屏",
"previewMode": "预览",
"markdownMode": "Markdown",
"clear": "清空",
"inputLabel": "HTML 输入",
"previewLabel": "Markdown 预览",
"markdownOutputLabel": "Markdown 输出",
"inputPlaceholder": "在此输入 HTML 内容...",
"charCount": "{{count}} 字符",
"copyMarkdown": "复制 Markdown",
"download": "下载",
"emptyHint": "输入 HTML 内容以查看转换结果"
}
+22
View File
@@ -0,0 +1,22 @@
{
"pageTitle": "JSON 差异比较",
"pageSubtitle": "对比两个 JSON 数据的差异",
"leftPlaceholder": "输入原始 JSON...",
"rightPlaceholder": "输入目标 JSON...",
"leftLabel": "原始 JSON",
"rightLabel": "目标 JSON",
"compareButton": "比较",
"clearButton": "清空",
"sideBySideMode": "并排",
"unifiedMode": "统一",
"previousDiff": "上一个",
"nextDiff": "下一个",
"noDiffs": "无差异",
"diffCount": "{{count}} 处差异",
"invalidJson": "无效的 JSON 格式",
"emptyHint": "输入两侧 JSON 后点击比较",
"fixErrorHint": "请修正上方 JSON 的语法错误以开启实时流式比对",
"added": "新增",
"removed": "删除",
"modified": "修改"
}
+42
View File
@@ -0,0 +1,42 @@
{
"formatTitle": "JSON 格式化",
"formatSubtitle": "美化和格式化 JSON 数据",
"inputPlaceholder": "输入需要格式化的 JSON...",
"formatButton": "格式化",
"clearButton": "清空",
"sortKeys": "键名排序",
"sortKeysTooltip": "按字母顺序对 JSON 对象键进行排序",
"indentSize": "缩进",
"indentSpaces": "{{count}} 个空格",
"outputLabel": "格式化结果",
"copySuccess": "复制成功",
"copyFail": "复制失败",
"noContent": "无内容可复制",
"invalidJson": "无效的 JSON 格式",
"emptyHint": "输入 JSON 后点击格式化",
"fixErrorHint": "请修正上方 JSON 的语法错误以开启实时流式格式化",
"originalSize": "原始大小",
"formattedSize": "格式化后大小",
"diffMode": "差异比较",
"formatMode": "格式化",
"yamlMode": "YAML",
"tomlMode": "TOML",
"minifyMode": "压缩",
"yamlTitle": "JSON 转 YAML",
"yamlSubtitle": "将 JSON 数据转换为 YAML 格式",
"yamlModeInputPlaceholder": "输入需要转换的 JSON...",
"yamlModeOutputLabel": "YAML 结果",
"yamlModeEmptyHint": "输入 JSON 后点击转换",
"convertButton": "转换",
"tomlTitle": "JSON 转 TOML",
"tomlSubtitle": "将 JSON 数据转换为 TOML 格式",
"tomlModeInputPlaceholder": "输入需要转换的 JSON...",
"tomlModeOutputLabel": "TOML 结果",
"tomlModeEmptyHint": "输入 JSON 后点击转换",
"minifyTitle": "JSON 压缩",
"minifySubtitle": "将 JSON 压缩为紧凑的单行格式",
"minifyModeInputPlaceholder": "输入需要压缩的 JSON...",
"minifyModeOutputLabel": "压缩结果",
"minifyModeEmptyHint": "输入 JSON 后点击压缩",
"minifyButton": "压缩"
}
+17
View File
@@ -0,0 +1,17 @@
{
"pageTitle": "JWT 解析",
"pageSubtitle": "JSON Web Token 解码与查看",
"placeholder": "在此粘贴 JWT 令牌 (Encoded JWT)...",
"headerTitle": "HEADER: 算法 & 令牌类型",
"payloadTitle": "PAYLOAD: 数据",
"signatureTitle": "签名",
"noSignature": "无签名",
"invalidFormat": "无法解析",
"errors": {
"invalidBase64String": "无效的 Base64URL 字符串",
"failedToDecode": "Base64URL 解码失败:",
"invalidFormat": "JWT 格式错误:必须包含三个由 . 分隔的部分",
"parseHeaderFailed": "解析 Header 失败:",
"parsePayloadFailed": "解析 Payload 失败:"
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"pageTitle": "Markdown 转 HTML",
"pageSubtitle": "实时将 Markdown 转换为 HTML 并预览",
"splitMode": "分屏",
"previewMode": "预览",
"htmlMode": "HTML",
"clear": "清空",
"print": "打印",
"download": "下载",
"inputLabel": "Markdown 输入",
"previewLabel": "实时预览",
"htmlOutputLabel": "HTML 输出",
"inputPlaceholder": "在此输入 Markdown 内容...",
"charCount": "{{count}} 字符",
"copyHtml": "复制 HTML",
"downloadSuccess": "文件下载成功",
"printSuccess": "打印窗口已打开"
}
+48
View File
@@ -0,0 +1,48 @@
{
"pageTitle": "二维码工具",
"pageSubtitle": "生成和解析二维码",
"generateMode": "生成二维码",
"parseMode": "解析二维码",
"urlToQr": "文本转二维码",
"qrToUrl": "二维码转文本",
"urlInputLabel": "输入 URL 或文本",
"urlInputPlaceholder": "请输入 URL 或文本内容,将自动生成二维码",
"generateButton": "生成二维码",
"generating": "生成中...",
"qrCodeWillShow": "二维码将显示在这里",
"downloadButton": "下载二维码",
"copyQrButton": "复制二维码",
"qrCodeSuccess": "二维码生成成功",
"qrCodeDownloadSuccess": "二维码下载成功",
"qrCodeCopySuccess": "二维码已复制到剪贴板",
"selectImage": "请选择二维码图片",
"parseSuccess": "二维码解析成功",
"noQrDetected": "未检测到二维码,请确保图片清晰且包含二维码",
"parseError": "解析二维码失败,请重试",
"generateError": "生成二维码失败,请检查输入内容",
"copyError": "复制失败,请重试",
"imagePasted": "图片粘贴成功,正在解析...",
"imagePasteError": "粘贴图片失败,请重试",
"imageCleared": "图片已清除",
"clickToUpload": "点击、拖拽或粘贴上传二维码图片",
"supportFormats": "支持 PNG、JPG、WEBP、Base64 格式",
"parseButton": "解析二维码",
"parsing": "解析中...",
"resultLabel": "解析结果",
"copyTooltip": "复制",
"enterUrlError": "请输入 URL 或文本",
"clickToChange": "点击更换图片",
"pasteHint": "支持 Ctrl+V 粘贴图片或 Base64 字符串",
"autoGenerateHint": "输入内容后将自动生成二维码",
"autoParseHint": "上传图片后将自动解析二维码"
}
+13
View File
@@ -0,0 +1,13 @@
{
"title": "右键恢复",
"description": "检测并恢复被网站禁用的浏览器右键菜单",
"loading": "正在加载...",
"currentDomain": "当前域名",
"statusLocked": "未解锁",
"statusUnlocked": "已解锁",
"unsupported": "不支持",
"unsupportedDesc": "当前页面为浏览器内部页面或扩展页面,无法解锁右键功能。请切换到普通网页后重试。",
"unlockDesc": "点击下方按钮,为当前网站临时解锁右键菜单。刷新页面后需要重新解锁。",
"unlockBtn": "解锁当前网站右键",
"alreadyUnlocked": "右键已解锁"
}
+34
View File
@@ -0,0 +1,34 @@
{
"pageTitle": "存储清理",
"pageSubtitle": "清理缓存、Cookies 及本地存储",
"loading": "加载中...",
"initializing": "正在读取站点数据...",
"occupied": "已占用 {{size}}",
"cleaning": "正在清理...",
"cleanNow": "立即清理",
"autoRefresh": "清理后自动刷新页面",
"selectAll": "全选所有项",
"cleanSuccess": "清理完成",
"cleanError": "清理失败",
"noData": "无数据",
"countUnit": "个",
"errorNoTab": "无法获取当前标签页",
"errorRestricted": "存储清理功能不支持此页面",
"cleanSuccessReload": "清理成功,即将刷新页面",
"errorStandardOnly": "存储清理功能仅适用于标准网页",
"confirmTitle": "确认清理数据?",
"confirmDesc": "您将永久删除当前页面的以下选定存储项。",
"irreversible": "此操作不可撤销",
"confirmAction": "确认清理",
"cleanedSummary": "清理了 {{items}}",
"noDataToClean": "该页面没有可清理的存储数据",
"partialFailure": "部分清理失败",
"options": {
"localStorage": "Local Storage",
"sessionStorage": "Session Storage",
"indexedDB": "IndexedDB",
"cookies": "Cookies",
"cacheStorage": "Cache Storage",
"serviceWorkers": "Service Workers"
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"pageTitle": "文本统计",
"pageSubtitle": "实时分析文本的字符、单词、行数及字节大小",
"placeholder": "在此输入或粘贴文本...",
"characters": "字符数",
"words": "单词数",
"lines": "行数",
"bytes": "字节大小"
}
+27
View File
@@ -0,0 +1,27 @@
{
"pageTitle": "时间戳转换",
"pageSubtitle": "Unix 毫秒数转换与格式化",
"tsToDate": "时间戳 → 日期",
"dateToTs": "日期 → 时间戳",
"placeholderTs": "输入时间戳...",
"placeholderDate": "YYYY-MM-DD HH:mm:ss",
"unitMs": "毫秒 (ms)",
"unitS": "秒 (s)",
"convertButton": "立即转换",
"currentTs": "当前时间戳",
"useNowTooltip": "填充到下方",
"copyTsTooltip": "复制时间戳",
"usedSuccess": "已使用当前时间戳",
"resultLabel": "转换结果",
"copyResultTooltip": "复制结果",
"relativeTime": "相对时间",
"iso8601": "ISO 8601",
"utcTime": "UTC 时间",
"copyTooltip": "复制",
"resultEmpty": "请输入并点击转换",
"errors": {
"invalidNumber": "无效数字",
"invalidTimestamp": "无效时间戳",
"invalidFormat": "格式错误"
}
}
View File
View File
+250 -53
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -36,11 +36,15 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dayjs": "^1.11.20", "dayjs": "^1.11.20",
"i18next": "^26.2.0",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^1.16.0", "lucide-react": "^1.16.0",
"marked": "^18.0.4",
"qr-scanner": "^1.4.2", "qr-scanner": "^1.4.2",
"qrious": "^4.0.2", "qrious": "^4.0.2",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
"react-i18next": "^17.0.8",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.6.0" "tailwind-merge": "^3.6.0"
}, },
@@ -1,6 +1,6 @@
import { Image as ImageIcon, Trash2, Upload } from 'lucide-react'; import { Image as ImageIcon, Trash2, Upload } from 'lucide-react';
import TextInputArea from '@/components/TextInputArea'; import TextInputArea from '@/components/TextInputArea';
import { useI18n } from '@/utils/chromeI18n'; import { useLazyTranslation } from '@/utils/useLazyTranslation';
import CopyButton from '@/components/CopyButton'; import CopyButton from '@/components/CopyButton';
import DecodeResultPaper from '@/components/DecodeResultPaper'; import DecodeResultPaper from '@/components/DecodeResultPaper';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -19,7 +19,7 @@ interface Base64ConverterSectionProps {
} }
export default function Base64ConverterSection({ mode }: Base64ConverterSectionProps) { export default function Base64ConverterSection({ mode }: Base64ConverterSectionProps) {
const { t } = useI18n('base64Converter'); const { t } = useLazyTranslation('base64Converter');
const [direction, setDirection] = useStorageState( const [direction, setDirection] = useStorageState(
`base64Converter/${mode}Mode/direction`, `base64Converter/${mode}Mode/direction`,
@@ -57,7 +57,7 @@ export default function Base64ConverterSection({ mode }: Base64ConverterSectionP
}; };
return ( return (
<div className="w-full flex flex-col space-y-4 px-2"> <div className="w-full flex flex-col space-y-4">
<div className="flex h-11 items-center px-1.5 bg-secondary/40 rounded-xl border border-border/60 w-fit"> <div className="flex h-11 items-center px-1.5 bg-secondary/40 rounded-xl border border-border/60 w-fit">
<SwitchButtonGroup <SwitchButtonGroup
value={direction} value={direction}
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import TextInputArea from '@/components/TextInputArea'; import TextInputArea from '@/components/TextInputArea';
import { useI18n } from '@/utils/chromeI18n'; import { useLazyTranslation } from '@/utils/useLazyTranslation';
import CopyButton from '@/components/CopyButton'; import CopyButton from '@/components/CopyButton';
import { base64ToText, textToBase64 } from '@/utils/base64Converter'; import { base64ToText, textToBase64 } from '@/utils/base64Converter';
import SwitchButtonGroup from '@/components/SwitchButtonGroup'; import SwitchButtonGroup from '@/components/SwitchButtonGroup';
@@ -20,12 +20,14 @@ interface TextModeProps {
} }
export default function TextMode({ onSwitchToImageMode }: TextModeProps = {}) { export default function TextMode({ onSwitchToImageMode }: TextModeProps = {}) {
const { t } = useI18n('base64Converter'); const { t } = useLazyTranslation('base64Converter');
// 1. 纯净的核心源状态机:只保留输入源和转换方向
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [debouncedInput, setDebouncedInput] = useState(''); const [debouncedInput, setDebouncedInput] = useState('');
const [direction, setDirection] = useState<'encode' | 'decode'>('encode'); const [direction, setDirection] = useState<'encode' | 'decode'>('encode');
// 2. 文本高频敲击防抖大闸:斩断频繁进行文本转 Base64 带来的 CPU 计算过热
useEffect(() => { useEffect(() => {
const handle = setTimeout(() => { const handle = setTimeout(() => {
setDebouncedInput(input); setDebouncedInput(input);
@@ -33,6 +35,7 @@ export default function TextMode({ onSwitchToImageMode }: TextModeProps = {}) {
return () => clearTimeout(handle); return () => clearTimeout(handle);
}, [input]); }, [input]);
// 3. 右键联动数据上下文:优雅原地合并受控状态
const handleContextMenuData = useCallback((payload: string) => { const handleContextMenuData = useCallback((payload: string) => {
setInput(payload); setInput(payload);
setDebouncedInput(payload); setDebouncedInput(payload);
@@ -41,6 +44,8 @@ export default function TextMode({ onSwitchToImageMode }: TextModeProps = {}) {
useContextMenuData({ featureKey: 'base64Converter', onData: handleContextMenuData }); useContextMenuData({ featureKey: 'base64Converter', onData: handleContextMenuData });
// 💡 4. 贯彻方案 A(彻底消灭 setOutput / setError):
// 让所有的转化逻辑、类型安全校验在 useMemo 内存管道中单次渲染一气呵成!
const conversionPipeline = useMemo(() => { const conversionPipeline = useMemo(() => {
const trimmed = debouncedInput.trim(); const trimmed = debouncedInput.trim();
if (!trimmed) return { output: '', error: null }; if (!trimmed) return { output: '', error: null };
@@ -88,7 +93,7 @@ export default function TextMode({ onSwitchToImageMode }: TextModeProps = {}) {
}; };
return ( return (
<div className="w-full flex flex-col space-y-4 px-2"> <div className="w-full flex flex-col space-y-4">
{/* 受控方向切流中枢 */} {/* 受控方向切流中枢 */}
<div className="flex h-11 items-center px-1.5 bg-secondary/40 rounded-xl border border-border/60 w-fit"> <div className="flex h-11 items-center px-1.5 bg-secondary/40 rounded-xl border border-border/60 w-fit">
<SwitchButtonGroup <SwitchButtonGroup
@@ -107,7 +112,7 @@ export default function TextMode({ onSwitchToImageMode }: TextModeProps = {}) {
placeholder={placeholder} placeholder={placeholder}
value={input} value={input}
onChange={setInput} onChange={setInput}
externalError={error || undefined} externalError={error || undefined} // 💡 流式异常大闸动态注入
showClear={true} showClear={true}
allowCopy={true} allowCopy={true}
minRows={5} minRows={5}

Some files were not shown because too many files have changed in this diff Show More