Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 945780def8 | |||
| d4c29a1bf4 | |||
| 42d3fedbaa | |||
| 92d524d832 | |||
| 6abdf4add4 | |||
| a0d6e76cad | |||
| cc6d414d10 | |||
| 5daa151b5e | |||
| e93b5c817f | |||
| d678f14549 | |||
| c95e028482 | |||
| 8ec36ac3b3 | |||
| 262c5e9255 | |||
| e4993cb0ce | |||
| 0d60961a82 | |||
| df3eee8749 | |||
| cf05df7022 | |||
| e0888c7fb3 | |||
| 8b2bca9eef | |||
| 6bac88fbc4 | |||
| 4c08e17b19 | |||
| fa0f1bdcad | |||
| 2d7bb8fc2c | |||
| a0e6871b16 | |||
| 3adfe95234 | |||
| efbed7cf53 | |||
| 364672918d | |||
| 5e23b29a25 | |||
| cc9162f601 | |||
| 67bafe53d5 |
@@ -0,0 +1,820 @@
|
|||||||
|
# 代码编写规范
|
||||||
|
|
||||||
|
本文档定义了 Testing Tools 浏览器扩展项目的编码规范和最佳实践。所有代码贡献者应遵循这些规范以保持代码库的一致性和可维护性。
|
||||||
|
|
||||||
|
## 1. TypeScript 规范
|
||||||
|
|
||||||
|
### 1.1 类型定义:`interface` vs `type`
|
||||||
|
|
||||||
|
- **`interface`**:用于组件 Props、对象结构、Context 类型等可扩展结构
|
||||||
|
- **`type`**:用于联合类型、工具类型、不可扩展的类型别名
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ interface — 组件 Props / 对象结构
|
||||||
|
export interface GlobalSnackbarProps {
|
||||||
|
message: string;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
severity?: SnackbarSeverity;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ interface — 继承 HTML 属性
|
||||||
|
interface LiveClockProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
unit: UnitType;
|
||||||
|
onUseNow: (val: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ type — 联合类型
|
||||||
|
export type ThemeMode = 'light' | 'dark' | 'system';
|
||||||
|
export type PageType = 'dashboard' | 'timestamp' | 'storageCleaner' | ...;
|
||||||
|
|
||||||
|
// ✅ type — 工具类型
|
||||||
|
export type ResolvedThemeMode = 'light' | 'dark';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 泛型使用
|
||||||
|
|
||||||
|
广泛使用泛型约束,结合 `extends` 进行类型守卫:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ 泛型 + extends 约束
|
||||||
|
export interface SwitchOption<T extends string | number = string> {
|
||||||
|
value: T;
|
||||||
|
label: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ 泛型 + StorageSchema 键约束
|
||||||
|
async get<K extends keyof StorageSchema>(
|
||||||
|
key: K,
|
||||||
|
defaultValue?: StorageSchema[K],
|
||||||
|
): Promise<StorageSchema[K] | undefined> { ... }
|
||||||
|
|
||||||
|
// ✅ 泛型 Hook
|
||||||
|
export const useStorageState = <K extends keyof StorageSchema>(
|
||||||
|
key: K,
|
||||||
|
defaultValue: StorageSchema[K],
|
||||||
|
validator?: (val: unknown) => val is StorageSchema[K],
|
||||||
|
) => { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 类型守卫
|
||||||
|
|
||||||
|
优先使用类型守卫函数(`val is Type` 谓词),避免 `as` 强转:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ 类型守卫谓词函数
|
||||||
|
const isValidMode = (v: unknown): v is ThemeMode => VALID_MODES.includes(v as ThemeMode);
|
||||||
|
|
||||||
|
const isValidPage = (page: unknown): page is PageType => {
|
||||||
|
return typeof page === 'string' && (getAllFeatureKeys() as string[]).includes(page);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ✅ 安全的 as 断言,仅在类型守卫验证后使用
|
||||||
|
export function isSupportedImageType(mimeType: string): boolean {
|
||||||
|
return (SUPPORTED_IMAGE_TYPES as readonly string[]).includes(mimeType);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.4 导出模式
|
||||||
|
|
||||||
|
| 场景 | 导出方式 | 示例 |
|
||||||
|
| ----------- | ------------------------------------------------ | --------------------------------- |
|
||||||
|
| 页面组件 | `export default function ComponentName()` | `src/pages/Timestamp/index.tsx` |
|
||||||
|
| 业务组件 | `const X = React.memo(...)` + `export default X` | `LiveClock.tsx`, `ResultView.tsx` |
|
||||||
|
| UI 原子组件 | `React.forwardRef(...)` + `export { X }` | `src/components/ui/button.tsx` |
|
||||||
|
| 工具函数 | `export function xxx()` | `src/utils/clipboard.ts` |
|
||||||
|
| 自定义 Hook | `export function useXxx()` | `src/utils/useStorageState.ts` |
|
||||||
|
| 类型/接口 | `export interface` / `export type` | `src/types/storage.d.ts` |
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ 页面组件 — default export
|
||||||
|
export default function Index() { ... }
|
||||||
|
|
||||||
|
// ✅ 需要 memo 的组件 — 箭头函数 + React.memo + displayName
|
||||||
|
const LiveClock = React.memo(({ ... }: LiveClockProps) => { ... });
|
||||||
|
LiveClock.displayName = 'LiveClock';
|
||||||
|
export default LiveClock;
|
||||||
|
|
||||||
|
// ✅ UI 组件 — forwardRef + 命名导出
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(...);
|
||||||
|
Button.displayName = 'Button';
|
||||||
|
export { Button, buttonVariants };
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. React 组件规范
|
||||||
|
|
||||||
|
### 2.1 组件定义方式
|
||||||
|
|
||||||
|
- **标准组件**:使用 `function` 声明
|
||||||
|
- **需要 memo 的组件**:使用箭头函数 + `React.memo`
|
||||||
|
- **需要 ref 的组件**:使用 `React.forwardRef`
|
||||||
|
- **错误边界**:使用 Class 组件(React 要求)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ 标准页面组件
|
||||||
|
export default function Index() { ... }
|
||||||
|
|
||||||
|
// ✅ 需要 memo 的组件
|
||||||
|
const LiveClock = React.memo(({ unit, onUseNow, className, ...props }: LiveClockProps) => {
|
||||||
|
...
|
||||||
|
});
|
||||||
|
LiveClock.displayName = 'LiveClock';
|
||||||
|
export default LiveClock;
|
||||||
|
|
||||||
|
// ✅ 需要 ref 的组件
|
||||||
|
const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props, ref) => {
|
||||||
|
...
|
||||||
|
});
|
||||||
|
TextInputArea.displayName = 'TextInputArea';
|
||||||
|
export default TextInputArea;
|
||||||
|
|
||||||
|
// ✅ Class 组件(仅用于 ErrorBoundary)
|
||||||
|
export class ErrorBoundary extends Component<Props, State> { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 Props 模式
|
||||||
|
|
||||||
|
- 使用 `interface` 定义 Props
|
||||||
|
- 继承 `React.HTMLAttributes` 以支持原生属性透传
|
||||||
|
- 使用 `Omit` 排除冲突属性
|
||||||
|
- 解构 `className` 和 `...rest props`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ 继承 HTML 属性 + className 透传
|
||||||
|
interface ResultViewProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
result: string;
|
||||||
|
mode: 'ts2dt' | 'dt2ts';
|
||||||
|
unit: UnitType;
|
||||||
|
zone: string;
|
||||||
|
showEmptyPlaceholder?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用时解构 className 和 rest props
|
||||||
|
const ResultView = React.memo(({
|
||||||
|
result, mode, unit, zone,
|
||||||
|
showEmptyPlaceholder = false,
|
||||||
|
className, ...props
|
||||||
|
}: ResultViewProps) => {
|
||||||
|
return <div className={cn('flex flex-col w-full', className)} {...props}>...</div>;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ✅ Omit 排除冲突属性
|
||||||
|
export interface TextInputAreaProps extends Omit<
|
||||||
|
React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'onChange'
|
||||||
|
> { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 状态管理
|
||||||
|
|
||||||
|
- **本地状态**:`useState` + 惰性初始化
|
||||||
|
- **衍生状态**:`useMemo` 响应式计算管线
|
||||||
|
- **持久化状态**:Chrome Storage + `localStorage` 快照
|
||||||
|
- **全局状态**:React Context
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ useState + 惰性初始化
|
||||||
|
const [input, setInput] = useState(() => String(Date.now()));
|
||||||
|
|
||||||
|
// ✅ useMemo 响应式计算管线(零延迟,无需手动 convert 按钮)
|
||||||
|
const conversionPipeline = useMemo(() => {
|
||||||
|
const rawInput = input.trim();
|
||||||
|
if (!rawInput) return { result: '', error: '' };
|
||||||
|
// ... 自动计算结果
|
||||||
|
}, [input, mode, unit, zone, t]);
|
||||||
|
|
||||||
|
// ✅ Chrome Storage 持久化状态
|
||||||
|
export const useStorageState = <K extends keyof StorageSchema>(
|
||||||
|
key: K, defaultValue: StorageSchema[K], validator?: ...
|
||||||
|
) => { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 副作用模式
|
||||||
|
|
||||||
|
- **取消标志**:防止异步竞态
|
||||||
|
- **ref 回调指针**:保持回调最新避免依赖膨胀
|
||||||
|
- **事件监听 cleanup**:始终在 cleanup 中移除监听器
|
||||||
|
- **定时器 cleanup**:始终在 cleanup 中清除定时器
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ 取消标志模式 — 防止异步竞态
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
storageUtil.get(THEME_MODE_KEY, 'system').then((saved) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (isValidMode(saved)) { ... }
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [updateResolved]);
|
||||||
|
|
||||||
|
// ✅ ref 回调指针 — 保持回调最新避免依赖膨胀
|
||||||
|
const onUseNowRef = useRef(onUseNow);
|
||||||
|
useEffect(() => { onUseNowRef.current = onUseNow; }, [onUseNow]);
|
||||||
|
|
||||||
|
// ✅ setInterval + cleanup
|
||||||
|
useEffect(() => {
|
||||||
|
const tickId = setInterval(tick, 200);
|
||||||
|
return () => clearInterval(tickId);
|
||||||
|
}, [unit]);
|
||||||
|
|
||||||
|
// ✅ 事件监听 cleanup
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => { ... };
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.5 `memo` / `useCallback` / `useMemo` 使用
|
||||||
|
|
||||||
|
| 场景 | 使用方式 |
|
||||||
|
| ---------------------------------- | ------------- |
|
||||||
|
| 高频渲染组件(列表子项、实时时钟) | `React.memo` |
|
||||||
|
| 事件处理函数、回调引用 | `useCallback` |
|
||||||
|
| 响应式计算管线、衍生数据 | `useMemo` |
|
||||||
|
| 避免重复创建对象/集合 | `useMemo` |
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ React.memo — 高频更新组件
|
||||||
|
const LiveClock = React.memo(({ ... }) => { ... });
|
||||||
|
const ResultView = React.memo(({ ... }) => { ... });
|
||||||
|
|
||||||
|
// ✅ useCallback — 事件处理
|
||||||
|
const handleUseNow = useCallback((now: number) => {
|
||||||
|
if (mode === 'ts2dt') {
|
||||||
|
setInput(String(unit === 'ms' ? now : Math.floor(now / 1000)));
|
||||||
|
} else {
|
||||||
|
setInput(dayjs(now).tz(zone).format(DATE_FORMAT));
|
||||||
|
}
|
||||||
|
}, [mode, unit, zone]);
|
||||||
|
|
||||||
|
// ✅ useMemo — 避免重复创建集合
|
||||||
|
const visibleSet = useMemo(() => new Set<string>(visiblePages), [visiblePages]);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 导入规范
|
||||||
|
|
||||||
|
### 3.1 导入顺序
|
||||||
|
|
||||||
|
按来源分组,顺序如下:
|
||||||
|
|
||||||
|
1. React 核心
|
||||||
|
2. 第三方库(图标、UI 库等)
|
||||||
|
3. 业务 Provider / Context
|
||||||
|
4. 配置 / 存储
|
||||||
|
5. i18n
|
||||||
|
6. 本地页面组件
|
||||||
|
7. UI 组件
|
||||||
|
8. 工具函数 / Hook
|
||||||
|
9. 类型
|
||||||
|
10. 常量
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 1. React 核心
|
||||||
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
// 2. 第三方库
|
||||||
|
import { ArrowLeft, ExternalLink, Globe } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
// 3. 业务 Provider
|
||||||
|
import { useRouter } from '@/providers/RouterProvider';
|
||||||
|
import { useThemeMode } from '@/providers/ThemeModeProvider';
|
||||||
|
// 4. 配置 / 存储
|
||||||
|
import { FeatureConfig, FEATURES } from '@/config/features';
|
||||||
|
import { storageUtil } from '@/utils/chromeStorage';
|
||||||
|
// 5. i18n
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { normalizeLanguage, SUPPORTED_LANGUAGES } from '@/i18n';
|
||||||
|
// 6. 本地组件
|
||||||
|
import TextMode from './TextMode';
|
||||||
|
import { ZONES } from './constants';
|
||||||
|
// 7. UI 组件
|
||||||
|
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
// 8. 工具函数 / Hook
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||||
|
// 9. 类型
|
||||||
|
import type { PageType, StorageSchema } from '@/types/storage';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 路径别名
|
||||||
|
|
||||||
|
- `@/` 映射到项目根目录
|
||||||
|
- **跨目录导入**:使用 `@/` 绝对别名
|
||||||
|
- **同目录导入**:使用相对路径 `./`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ 绝对别名导入 — 跨目录
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { storageUtil } from '@/utils/chromeStorage';
|
||||||
|
import type { StorageSchema } from '@/types/storage';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
// ✅ 相对导入 — 仅限同目录
|
||||||
|
import TextMode from './TextMode';
|
||||||
|
import { ZONES } from './constants';
|
||||||
|
import { useTimestampConverter } from './useTimestampConverter';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 样式规范
|
||||||
|
|
||||||
|
### 4.1 `cn()` 工具函数
|
||||||
|
|
||||||
|
统一使用 `cn()` 合并 Tailwind 类名(来自 `clsx` + `tailwind-merge`),导入自 `@/lib/utils`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// ✅ 条件类名 + 合并外部 className
|
||||||
|
<div className={cn(
|
||||||
|
'flex items-center gap-3 px-3 h-10 rounded-lg border border-border/80 bg-secondary/50',
|
||||||
|
className, // 外部传入的覆盖
|
||||||
|
)} {...props}>
|
||||||
|
|
||||||
|
// ✅ 错误状态变体
|
||||||
|
<Input className={cn(
|
||||||
|
'font-mono font-semibold h-10 shadow-sm placeholder:text-muted-foreground/60',
|
||||||
|
error && 'border-destructive focus-visible:ring-destructive',
|
||||||
|
)} />
|
||||||
|
|
||||||
|
// ✅ 选中/未选中状态
|
||||||
|
className={cn(
|
||||||
|
'flex-1 inline-flex items-center justify-center font-medium whitespace-nowrap transition-all',
|
||||||
|
sizeClasses[size],
|
||||||
|
isSelected
|
||||||
|
? 'bg-background text-foreground shadow-sm font-semibold'
|
||||||
|
: 'hover:bg-background/50 hover:text-foreground/80',
|
||||||
|
buttonClassName,
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 主题 / 暗色模式
|
||||||
|
|
||||||
|
使用 shadcn/ui 的 CSS 变量语义化类名,**禁止硬编码颜色值**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ 语义化颜色 token — 亮/暗模式自适应
|
||||||
|
<div className="min-h-screen bg-background text-foreground antialiased selection:bg-primary/20">
|
||||||
|
<div className="p-5 rounded-xl border border-border bg-card text-card-foreground shadow-sm">
|
||||||
|
|
||||||
|
// ✅ 暗色模式特殊处理
|
||||||
|
'fixed ... bg-white dark:bg-gray-900 p-6 ...'
|
||||||
|
|
||||||
|
// ✅ 需要固定颜色的特殊场景(如二维码白色背景保护)
|
||||||
|
<div className="p-3 bg-white rounded-lg shadow-sm border border-border/40">
|
||||||
|
```
|
||||||
|
|
||||||
|
**常用语义化 token:**
|
||||||
|
|
||||||
|
| 用途 | 类名 |
|
||||||
|
| ---- | ------------------------------------------------------------------ |
|
||||||
|
| 背景 | `bg-background`, `bg-card`, `bg-muted`, `bg-secondary` |
|
||||||
|
| 文字 | `text-foreground`, `text-card-foreground`, `text-muted-foreground` |
|
||||||
|
| 边框 | `border-border`, `border-border/80` |
|
||||||
|
| 主色 | `text-primary`, `bg-primary`, `border-primary` |
|
||||||
|
| 危险 | `text-destructive`, `bg-destructive`, `border-destructive` |
|
||||||
|
|
||||||
|
### 4.3 响应式设计
|
||||||
|
|
||||||
|
移动优先,使用 `sm:` / `md:` / `lg:` 断点:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Grid 自适应布局
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-stretch">
|
||||||
|
|
||||||
|
// ✅ Dashboard 自动填充网格
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-[repeat(auto-fill,minmax(290px,1fr))] auto-rows-auto gap-3.5 p-3.5 w-full h-auto">
|
||||||
|
|
||||||
|
// ✅ 弹性方向切换
|
||||||
|
<div className="flex flex-col sm:flex-row items-stretch gap-3 w-full">
|
||||||
|
|
||||||
|
// ✅ 内边距响应式
|
||||||
|
<div className="p-4 sm:p-6 space-y-4">
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 错误处理
|
||||||
|
|
||||||
|
### 5.1 工具函数:结果对象模式
|
||||||
|
|
||||||
|
工具函数**不抛异常**,返回包含 `hasError` 和 `error` 字段的结果对象:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ 结果对象模式
|
||||||
|
export function markdownToHtml(markdown: string): MarkdownToHtmlResult {
|
||||||
|
try {
|
||||||
|
...
|
||||||
|
return { html, originalLength, htmlLength, hasError: false };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
html: '', ...
|
||||||
|
hasError: true,
|
||||||
|
error: error instanceof Error ? error.message : 'Markdown 解析失败',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 UI 层:try-catch + Toast
|
||||||
|
|
||||||
|
UI 层异步操作使用 try-catch,通过 `sonner` 的 `toast` 显示错误:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
const handleCopy = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
toast.success('复制成功');
|
||||||
|
} catch {
|
||||||
|
toast.error('复制失败');
|
||||||
|
}
|
||||||
|
}, [value]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Promise 异常隔离
|
||||||
|
|
||||||
|
对不关心返回值的异步操作,使用 `void` + `.catch()` 隔离异常:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ void + .catch 模式
|
||||||
|
void storageUtil.set(THEME_MODE_KEY, next).catch((err) => {
|
||||||
|
console.error('[Theme Storage Error] Failed to persistent theme state:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ✅ async 函数调用 + .catch
|
||||||
|
loadConfig().catch(console.error);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 ErrorBoundary
|
||||||
|
|
||||||
|
在应用顶层使用 `ErrorBoundary` 组件捕获子组件树异常:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||||
|
|
||||||
|
<ErrorBoundary>
|
||||||
|
<RouterContainer />
|
||||||
|
</ErrorBoundary>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 命名规范
|
||||||
|
|
||||||
|
### 6.1 文件命名
|
||||||
|
|
||||||
|
| 类型 | 命名模式 | 示例 |
|
||||||
|
| ----------- | -------------------------- | -------------------------------------------------------- |
|
||||||
|
| 页面目录 | PascalCase | `Timestamp/`, `Base64Converter/`, `StorageCleaner/` |
|
||||||
|
| 页面入口 | `index.tsx` | `src/pages/Timestamp/index.tsx` |
|
||||||
|
| 组件文件 | PascalCase `.tsx` | `TopBar.tsx`, `LiveClock.tsx`, `ResultView.tsx` |
|
||||||
|
| 自定义 Hook | camelCase `.ts` | `useTimestampConverter.ts`, `useStorageCleaner.ts` |
|
||||||
|
| 工具函数 | camelCase `.ts` | `chromeStorage.ts`, `base64Converter.ts`, `clipboard.ts` |
|
||||||
|
| 测试文件 | 与源文件同名 `.test.ts(x)` | `jwt.test.ts`, `SwitchButtonGroup.test.tsx` |
|
||||||
|
| 类型文件 | camelCase `.d.ts` | `storage.d.ts` |
|
||||||
|
| 常量文件 | camelCase `.ts` | `constants.ts` |
|
||||||
|
|
||||||
|
### 6.2 变量 / 函数命名
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ camelCase — 变量、函数、Hook
|
||||||
|
const conversionPipeline = useMemo(...);
|
||||||
|
const handleUseNow = useCallback(...);
|
||||||
|
export function useTimestampConverter(): UseTimestampConverterReturn { ... }
|
||||||
|
export function textToBase64(text: string): TextToBase64Result { ... }
|
||||||
|
|
||||||
|
// ✅ PascalCase — 组件、类型、接口
|
||||||
|
const LiveClock = React.memo(...);
|
||||||
|
export interface GlobalSnackbarProps { ... }
|
||||||
|
export type ThemeMode = 'light' | 'dark' | 'system';
|
||||||
|
|
||||||
|
// ✅ SCREAMING_SNAKE_CASE — 常量
|
||||||
|
const SEARCH_HISTORY_LIMIT = 10;
|
||||||
|
const THEME_MODE_KEY = 'app/themeMode' as const;
|
||||||
|
export const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
||||||
|
export const SUPPORTED_IMAGE_TYPES = [...] as const;
|
||||||
|
|
||||||
|
// ✅ 布尔值 — is/has/should 前缀
|
||||||
|
const isControlled = controlledValue !== undefined;
|
||||||
|
const isDashboard = currentPage === 'dashboard';
|
||||||
|
const hasError = true;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 事件处理函数
|
||||||
|
|
||||||
|
使用 `handle` 前缀命名组件内事件处理函数:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const handleUseNow = useCallback((now: number) => { ... }, []);
|
||||||
|
const handleSelectFeature = (feature: FeatureConfig) => { ... };
|
||||||
|
const handleFileChange = useCallback((file: File) => { ... }, []);
|
||||||
|
const handleClean = useCallback(async () => { ... }, []);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 测试规范
|
||||||
|
|
||||||
|
### 7.1 文件组织
|
||||||
|
|
||||||
|
测试文件放在源代码同级的 `__tests__/` 目录下:
|
||||||
|
|
||||||
|
```
|
||||||
|
src/utils/__tests__/jwt.test.ts
|
||||||
|
src/utils/__tests__/base64Converter.test.ts
|
||||||
|
src/utils/__tests__/useStorageState.test.ts
|
||||||
|
src/components/__tests__/SwitchButtonGroup.test.tsx
|
||||||
|
src/components/__tests__/ErrorBoundary.test.tsx
|
||||||
|
src/pages/Timestamp/__tests__/index.test.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 describe / it 命名
|
||||||
|
|
||||||
|
`describe` 使用模块/函数名,`it` 使用中文描述行为("应该..."):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ 中文 describe + 中文 it
|
||||||
|
describe('textToBase64', () => {
|
||||||
|
it('应该编码 ASCII 文本', () => { ... });
|
||||||
|
it('应该编码中文文本', () => { ... });
|
||||||
|
it('应该编码空字符串', () => { ... });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ✅ 中文 describe + 中文 it(组件测试)
|
||||||
|
describe('SwitchButtonGroup 组件', () => {
|
||||||
|
it('应渲染所有选项按钮', () => { ... });
|
||||||
|
it('应高亮当前选中的按钮', () => { ... });
|
||||||
|
it('点击未选中按钮时应触发 onChange 并传入选中值', () => { ... });
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 Mock 模式
|
||||||
|
|
||||||
|
- 使用 `vi.mock()` 进行模块级 Mock
|
||||||
|
- 使用 `vi.fn()` 进行函数级 Mock
|
||||||
|
- 使用 `vi.useFakeTimers()` 控制时间
|
||||||
|
- **避免重复 mock `vitest.setup.ts` 中已有的内容**(chrome API、i18n、matchMedia 等)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ 模块级 Mock
|
||||||
|
vi.mock('@/utils/chromeStorage', () => ({
|
||||||
|
storageUtil: {
|
||||||
|
get: vi.fn(),
|
||||||
|
set: vi.fn(() => Promise.resolve()),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ✅ 函数级 Mock + 断言
|
||||||
|
const handleChange = vi.fn();
|
||||||
|
render(<SwitchButtonGroup value="a" options={options} onChange={handleChange} />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /选项B/i }));
|
||||||
|
expect(handleChange).toHaveBeenCalledTimes(1);
|
||||||
|
expect(handleChange).toHaveBeenCalledWith('b');
|
||||||
|
|
||||||
|
// ✅ 定时器 Mock
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.4 断言模式
|
||||||
|
|
||||||
|
使用 Testing Library 的 DOM 查询 + Vitest 匹配器:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ 语义化查询
|
||||||
|
expect(screen.getByRole('button', { name: /选项A/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('normal-content')).toHaveTextContent('正常内容');
|
||||||
|
expect(screen.queryByText('糟糕,出了点问题')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
// ✅ CSS 类断言
|
||||||
|
expect(button).toHaveClass('bg-background', 'text-foreground', 'shadow-sm');
|
||||||
|
|
||||||
|
// ✅ 异步断言
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current[0]).toBe(false);
|
||||||
|
expect(result.current[2]).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ✅ renderHook 测试自定义 Hook
|
||||||
|
const { result } = renderHook(() => useStorageState('qrCode/urlExpanded', true));
|
||||||
|
expect(result.current[0]).toBe(true);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 自定义 Hook 规范
|
||||||
|
|
||||||
|
### 8.1 命名和结构
|
||||||
|
|
||||||
|
- 使用 `use` 前缀命名
|
||||||
|
- 定义返回值接口类型
|
||||||
|
- 添加 JSDoc 注释
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ 完整的 Hook 结构
|
||||||
|
/**
|
||||||
|
* 自定义 Hook:处理右键菜单传递的数据
|
||||||
|
*
|
||||||
|
* @param options - 配置选项
|
||||||
|
* @example
|
||||||
|
* useContextMenuData({ featureKey: 'jwt', onData: handlePayload });
|
||||||
|
*/
|
||||||
|
export function useContextMenuData({ featureKey, onData }: UseContextMenuDataOptions): void {
|
||||||
|
const checkAndConsumeData = useCallback(async () => { ... }, [featureKey, onData]);
|
||||||
|
useEffect(() => { checkAndConsumeData(); }, [checkAndConsumeData]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ 返回值接口定义
|
||||||
|
export interface UseTimestampConverterReturn {
|
||||||
|
mode: 'ts2dt' | 'dt2ts';
|
||||||
|
input: string;
|
||||||
|
result: string;
|
||||||
|
error: string;
|
||||||
|
setMode: (mode: 'ts2dt' | 'dt2ts') => void;
|
||||||
|
setInput: (value: string) => void;
|
||||||
|
handleUseNow: (now: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTimestampConverter(): UseTimestampConverterReturn { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 Hook 存放位置
|
||||||
|
|
||||||
|
- **全局通用 Hook**:放在 `src/utils/` 目录下
|
||||||
|
- **页面专属 Hook**:与页面组件同目录
|
||||||
|
|
||||||
|
```
|
||||||
|
src/utils/useStorageState.ts — Chrome Storage 状态持久化
|
||||||
|
src/utils/useLazyTranslation.ts — i18n 懒加载
|
||||||
|
src/utils/useContextMenuData.ts — 右键菜单数据
|
||||||
|
src/utils/useDebounce.ts — 防抖
|
||||||
|
src/pages/Timestamp/useTimestampConverter.ts — 页面级 Hook
|
||||||
|
src/pages/StorageCleaner/useStorageCleaner.ts — 页面级 Hook
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 国际化规范
|
||||||
|
|
||||||
|
### 9.1 翻译键格式
|
||||||
|
|
||||||
|
- 命名空间:`common`(默认)、`features`
|
||||||
|
- 翻译键格式:`namespace:key`(如 `features:timestamp.title`)
|
||||||
|
- 语言:`zh`(默认)、`en`
|
||||||
|
|
||||||
|
### 9.2 翻译文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
i18n/locales/{zh,en}/common.json — 全局通用翻译
|
||||||
|
i18n/locales/{zh,en}/features.json — 功能模块标题和描述
|
||||||
|
i18n/locales/{zh,en}/{功能名}.json — 各功能独立翻译
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.3 使用方式
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ 页面组件 — 使用 useLazyTranslation
|
||||||
|
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||||
|
|
||||||
|
export default function Index() {
|
||||||
|
const { t } = useLazyTranslation('timestamp');
|
||||||
|
return <h1>{t('timestamp:title')}</h1>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ 全局组件 — 使用 useTranslation
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
export function TopBar() {
|
||||||
|
const { t } = useTranslation(['common', 'features']);
|
||||||
|
return <span>{t('common:settings')}</span>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.4 添加新翻译
|
||||||
|
|
||||||
|
1. 在 `i18n/locales/{zh,en}/features.json` 添加功能标题和描述
|
||||||
|
2. 创建 `i18n/locales/{zh,en}/{功能名}.json` 添加功能专属翻译
|
||||||
|
3. 在 `utils/useLazyTranslation.ts` 的 `localeModules` 中注册新命名空间
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 存储规范
|
||||||
|
|
||||||
|
### 10.1 StorageSchema
|
||||||
|
|
||||||
|
所有 Chrome Storage 键必须在 `types/storage.d.ts` 的 `StorageSchema` 中声明:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface StorageSchema {
|
||||||
|
'app/currentRoute': PageType;
|
||||||
|
'app/popupRoute': PageType;
|
||||||
|
'app/theme': string;
|
||||||
|
'app/themeMode': 'light' | 'dark' | 'system';
|
||||||
|
'storageCleaner/preferences': StorageCleanerPreferences;
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.2 存储操作
|
||||||
|
|
||||||
|
使用 `utils/chromeStorage.ts` 的类型安全封装:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { storageUtil } from '@/utils/chromeStorage';
|
||||||
|
import type { StorageSchema } from '@/types/storage';
|
||||||
|
|
||||||
|
// ✅ 读取
|
||||||
|
const theme = await storageUtil.get('app/theme', 'default');
|
||||||
|
|
||||||
|
// ✅ 写入
|
||||||
|
await storageUtil.set('app/theme', 'dark');
|
||||||
|
|
||||||
|
// ✅ 删除
|
||||||
|
await storageUtil.remove('app/theme');
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.3 持久化状态 Hook
|
||||||
|
|
||||||
|
使用 `useStorageState` 自动同步 Chrome Storage:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useStorageState } from '@/utils/useStorageState';
|
||||||
|
|
||||||
|
const [themeMode, setThemeMode, isInitialized] = useStorageState(
|
||||||
|
'app/themeMode',
|
||||||
|
'system',
|
||||||
|
isValidMode, // 可选的类型守卫
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 文件组织
|
||||||
|
|
||||||
|
### 11.1 页面组件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
src/pages/FeatureName/
|
||||||
|
├── index.tsx # 页面 UI(纯展示,使用 shadcn/ui 组件)
|
||||||
|
├── useFeatureName.ts # 业务逻辑 Hook(状态管理 + 转换逻辑)
|
||||||
|
├── constants.ts # 常量定义(可选)
|
||||||
|
├── LiveClock.tsx # 子组件(可选)
|
||||||
|
├── ResultView.tsx # 子组件(可选)
|
||||||
|
└── __tests__/ # 测试文件
|
||||||
|
└── index.test.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.2 目录职责
|
||||||
|
|
||||||
|
| 目录 | 职责 |
|
||||||
|
| -------------------- | ------------------------------------------------------------ |
|
||||||
|
| `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 Context(Router、Theme 等) |
|
||||||
|
| `src/hooks/` | 自定义 React Hooks |
|
||||||
|
| `src/utils/` | 工具函数与服务抽象 |
|
||||||
|
| `src/types/` | TypeScript 类型声明 |
|
||||||
|
| `src/lib/` | 通用工具函数(cn、utils) |
|
||||||
|
| `public/` | 静态资源 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 代码风格
|
||||||
|
|
||||||
|
### 12.1 Prettier 配置
|
||||||
|
|
||||||
|
- 行宽:100 字符
|
||||||
|
- 引号:单引号
|
||||||
|
- 尾逗号:all
|
||||||
|
- 换行符:LF
|
||||||
|
- 分号:是
|
||||||
|
|
||||||
|
### 12.2 ESLint 规则
|
||||||
|
|
||||||
|
- 禁止使用 `any`(测试文件除外)
|
||||||
|
- 未使用变量/参数:使用 `_` 前缀(如 `_unused`)
|
||||||
|
- React 19 JSX Runtime:无需手动导入 React
|
||||||
|
- 使用 `typescript-eslint` 的 `projectService: true`
|
||||||
|
|
||||||
|
### 12.3 注释规范
|
||||||
|
|
||||||
|
- **文件级注释**:使用 JSDoc `@module` 格式(如 `GlobalSnackbar.tsx`)
|
||||||
|
- **函数注释**:使用 JSDoc,包含 `@param`、`@returns`、`@example`
|
||||||
|
- **行内注释**:仅在需要澄清复杂逻辑时使用
|
||||||
|
- **禁止注释显而易见的代码**
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# Copilot 指令
|
||||||
|
|
||||||
|
基于 WXT 框架的浏览器扩展项目(React 19 + TypeScript),为开发者和测试人员提供效率工具:时间戳转换、存储清理、JWT 解析、JSON 工具、二维码、Base64、Markdown 等。
|
||||||
|
|
||||||
|
## 核心命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # Chrome 开发模式(支持 HMR)
|
||||||
|
npm run dev:firefox # Firefox 开发模式
|
||||||
|
npm run build # Chrome 生产构建
|
||||||
|
npm run build:firefox # Firefox 生产构建
|
||||||
|
npm run zip # 打包 Chrome 扩展(.output/*.zip)
|
||||||
|
npm run zip:firefox # 打包 Firefox 扩展
|
||||||
|
npm run lint # ESLint 检查(--max-warnings=0)
|
||||||
|
npm run typecheck # TypeScript 类型检查(tsc --noEmit)
|
||||||
|
npm run test # 运行全部单元测试(vitest run)
|
||||||
|
npm run test:watch # Vitest 监视模式
|
||||||
|
npm run test:coverage # 带覆盖率的测试
|
||||||
|
```
|
||||||
|
|
||||||
|
运行单个测试文件:`npx vitest run path/to/file.test.ts`
|
||||||
|
|
||||||
|
修改 `package.json` 后需运行 `npm install`(会自动触发 `postinstall` → `wxt prepare` 重新生成 `.wxt/` 类型声明)。
|
||||||
|
|
||||||
|
## CI 流水线(GitHub Actions)
|
||||||
|
|
||||||
|
严格顺序门控,任一步骤失败则终止:
|
||||||
|
|
||||||
|
1. **setup** — 安装依赖,缓存 `node_modules`
|
||||||
|
2. **lint**、**typecheck**、**test** — 三者并行运行,全部通过才继续
|
||||||
|
3. **build** — Chrome + Firefox 矩阵构建(仅当步骤 2 全部通过时执行)
|
||||||
|
|
||||||
|
Pre-commit 钩子(`.husky/pre-commit` → `lint-staged`):
|
||||||
|
|
||||||
|
1. 代码文件(`*.{ts,tsx,js,jsx,mjs}`):运行 `eslint --fix --max-warnings=0`
|
||||||
|
2. 同一代码文件:运行 `prettier --write`
|
||||||
|
3. 其他文件(`*.{json,css,scss,md}`):运行 `prettier --write`
|
||||||
|
|
||||||
|
## 项目架构
|
||||||
|
|
||||||
|
### 路由(不使用 React Router)
|
||||||
|
|
||||||
|
路由完全通过 `config/features.tsx` 中的 `FEATURES` 数组管理。每个功能定义一个 `key`(类型为 `types/storage.d.ts` 中的 `PageType`)和三个懒加载组件,分别对应 `popup`、`sidepanel`、`tab` 三种渲染模式。`providers/RouterProvider.tsx` 中的 `RouterProvider` 根据存储状态渲染当前页面。
|
||||||
|
|
||||||
|
存在三套独立的路由作用域:`app/popupRoute`、`app/sidepanelRoute`、`app/tabRoute`,各自维护独立的可见页面列表和页面排序。
|
||||||
|
|
||||||
|
### 存储
|
||||||
|
|
||||||
|
所有 Chrome Storage 键必须在 `types/storage.d.ts` 的 `StorageSchema` 中声明,键名使用 kebab-case 格式(如 `app/currentRoute`)。使用 `utils/chromeStorage.ts` 中的类型安全封装(`storageUtil.get/set/remove`)。
|
||||||
|
|
||||||
|
Router 同时使用 `chrome.storage.local` 持久化和 `localStorage` 快照来消除首屏闪烁。
|
||||||
|
|
||||||
|
### 扩展通信
|
||||||
|
|
||||||
|
使用 `@webext-core/messaging`。通信协议在 `utils/messages.ts` 中通过 `ProtocolMap` 定义。使用该模块导出的 `sendMessage` / `onMessage`,不要直接使用原生 `chrome.runtime.sendMessage`。
|
||||||
|
|
||||||
|
### 页面组件模式
|
||||||
|
|
||||||
|
功能页面遵循 **UI + Hook 分离** 模式:
|
||||||
|
|
||||||
|
```
|
||||||
|
pages/FeatureName/
|
||||||
|
├── index.tsx # UI 组件(纯展示,使用 shadcn/ui 组件)
|
||||||
|
├── useFeatureName.ts # 业务逻辑 Hook(状态管理 + 转换逻辑)
|
||||||
|
└── constants.ts # 常量定义(可选)
|
||||||
|
```
|
||||||
|
|
||||||
|
- 页面组件调用 `useLazyTranslation('featureName')` 获取翻译函数
|
||||||
|
- Hook 负责所有状态管理,通过返回值暴露给页面
|
||||||
|
- 子组件可进一步拆分(如 `LiveClock.tsx`、`ResultView.tsx`)
|
||||||
|
|
||||||
|
### 新功能开发清单
|
||||||
|
|
||||||
|
1. 在 `types/storage.d.ts` 的 `PageType` 联合类型中添加新成员
|
||||||
|
2. 在 `config/features.tsx` 的 `FEATURES` 数组中添加配置(key、翻译键、图标、三种渲染模式组件)
|
||||||
|
3. 在 `pages/` 目录创建页面组件(懒加载):
|
||||||
|
- `index.tsx` — 使用 `useLazyTranslation` 的 UI 组件
|
||||||
|
- `useFeatureName.ts` — 业务逻辑 Hook
|
||||||
|
- `constants.ts` — 常量(可选)
|
||||||
|
4. 在 `i18n/locales/{zh,en}/features.json` 添加翻译(复杂功能可新建独立 JSON 文件)
|
||||||
|
5. 如需新权限,更新 `wxt.config.ts` 的 `manifest.permissions`
|
||||||
|
6. 添加对应的单元测试
|
||||||
|
|
||||||
|
## 关键规范
|
||||||
|
|
||||||
|
> 完整的代码编写规范详见 [CODING_STANDARDS.md](./CODING_STANDARDS.md)。
|
||||||
|
|
||||||
|
### 浏览器 API
|
||||||
|
|
||||||
|
始终使用 `wxt/browser` 导出的 `browser` 对象,而非原生 `chrome` API,以确保跨浏览器兼容性。
|
||||||
|
|
||||||
|
### 路径别名
|
||||||
|
|
||||||
|
`@/` 映射到项目根目录(已在 tsconfig 和 vitest.config 中配置)。跨目录导入使用 `@/` 绝对别名,同目录导入使用 `./` 相对路径。
|
||||||
|
|
||||||
|
### UI 组件
|
||||||
|
|
||||||
|
- 使用 `components/ui/` 下的 shadcn/ui 组件(button、dialog、select、input 等)
|
||||||
|
- 图标:`lucide-react`
|
||||||
|
- 样式:Tailwind CSS + `@/lib/utils` 中的 `cn()` 工具函数(clsx + tailwind-merge)
|
||||||
|
- 主题:使用 shadcn/ui 语义化 token(`bg-background`、`text-foreground`、`border-border` 等),禁止硬编码颜色
|
||||||
|
|
||||||
|
### 代码分割
|
||||||
|
|
||||||
|
`wxt.config.ts` 通过 `manualChunksForHtmlOnly()` 自动分组 vendor 依赖(vendor-react、vendor-i18n、vendor-qr、vendor-dnd),无需手动配置。
|
||||||
|
|
||||||
|
### 代码风格
|
||||||
|
|
||||||
|
- 禁止使用 `any`(测试文件除外)
|
||||||
|
- 未使用的变量/参数:使用 `_` 前缀(如 `_unused`)
|
||||||
|
- Prettier:100 字符宽、单引号、尾逗号 all、LF 换行
|
||||||
|
- ESLint 使用 `typescript-eslint` 的 `projectService: true`
|
||||||
|
- 导出模式:页面组件 default export,工具函数/Hook 命名导出,UI 组件 forwardRef + 命名导出
|
||||||
|
|
||||||
|
### 测试
|
||||||
|
|
||||||
|
- 环境:jsdom
|
||||||
|
- 全局变量:`vitest/globals`(describe、it、expect 等无需导入)
|
||||||
|
- Setup 文件:`vitest.setup.ts` 自动 mock 以下内容:
|
||||||
|
- `chrome.*` / `browser.*` API(storage、tabs、runtime、cookies 等)
|
||||||
|
- `react-i18next`(返回 key 作为翻译)
|
||||||
|
- `@/utils/useLazyTranslation`(返回 `ns:key` 格式)
|
||||||
|
- `window.matchMedia`
|
||||||
|
- 测试文件命名:`__tests__/*.test.{ts,tsx}` 或 `*.test.{ts,tsx}`
|
||||||
|
- 使用 `vi.mock()` 进行模块级 mock;避免重复 mock `vitest.setup.ts` 中已有的内容
|
||||||
|
- 测试工具:`@testing-library/react` + `@testing-library/user-event`
|
||||||
|
|
||||||
|
### 国际化(i18n)
|
||||||
|
|
||||||
|
- 命名空间:`common`(默认)、`features`
|
||||||
|
- 翻译键格式:`namespace:key`(如 `features:timestamp.title`)
|
||||||
|
- 语言:`zh`(默认)、`en`
|
||||||
|
- 翻译文件:`i18n/locales/{zh,en}/{common,features}.json` + 各功能独立 JSON 文件
|
||||||
|
- 使用 `useLazyTranslation` Hook 加载功能专属翻译
|
||||||
|
- 回退策略:缺失的翻译键回退到 `zh`,若仍缺失则返回占位格式 `namespace:key`
|
||||||
|
|
||||||
|
### WXT 生成文件
|
||||||
|
|
||||||
|
- `.wxt/` 目录由 `postinstall`(`wxt prepare`)自动生成,包含 TypeScript 类型声明和扩展 tsconfig
|
||||||
|
- 生产构建输出到 `.output/` 目录
|
||||||
|
- `tsconfig.json` 继承自 `./.wxt/tsconfig.json`
|
||||||
@@ -13,8 +13,6 @@ 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
|
||||||
@@ -29,7 +27,6 @@ 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
|
||||||
@@ -47,7 +44,6 @@ 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
|
||||||
@@ -70,7 +66,6 @@ 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
|
||||||
@@ -90,10 +85,12 @@ 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') }}
|
||||||
|
|
||||||
|
- name: Generate WXT types
|
||||||
|
run: npx wxt prepare
|
||||||
|
|
||||||
- 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
|
||||||
@@ -113,19 +110,18 @@ 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') }}
|
||||||
|
|
||||||
|
- name: Generate WXT types
|
||||||
|
run: npx wxt prepare
|
||||||
|
|
||||||
- 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:
|
||||||
@@ -143,7 +139,9 @@ 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
|
||||||
|
run: npx wxt prepare
|
||||||
|
|
||||||
- name: Build Extension (${{ matrix.browser }})
|
- name: Build Extension (${{ matrix.browser }})
|
||||||
run: |
|
run: |
|
||||||
if npm run | grep -q "build:${{ matrix.browser }}"; then
|
if npm run | grep -q "build:${{ matrix.browser }}"; then
|
||||||
|
|||||||
@@ -9,145 +9,147 @@ permissions:
|
|||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ── Phase 1: 依赖统一前置基础架构(Infrastructure Stage) ───────────────────
|
# ── Phase 1: 依赖准备 ───────────────────
|
||||||
setup:
|
setup:
|
||||||
name: Prepare Dependencies
|
name: Prepare Dependencies
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- uses: actions/checkout@v4
|
||||||
uses: actions/checkout@v4
|
- uses: actions/setup-node@v4
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
|
- uses: actions/cache@v4
|
||||||
- name: Cache Node Modules
|
|
||||||
id: cache-nodemodules
|
id: cache-nodemodules
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
with:
|
||||||
path: node_modules
|
path: node_modules
|
||||||
key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }}
|
key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-release-v22-
|
${{ runner.os }}-release-v22-
|
||||||
|
- if: steps.cache-nodemodules.outputs.cache-hit != 'true'
|
||||||
- name: Install dependencies
|
|
||||||
if: steps.cache-nodemodules.outputs.cache-hit != 'true'
|
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
# ── Phase 2: 全量生产级断言检查(秒级瞬时恢复缓存,安全闭环) ──────────────────
|
# ── Phase 2: 质量检查 ──────────────────
|
||||||
lint:
|
lint:
|
||||||
name: Lint
|
name: Lint
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: setup
|
needs: setup
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- uses: actions/checkout@v4
|
||||||
uses: actions/checkout@v4
|
- uses: actions/setup-node@v4
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
- name: Restore Node Modules
|
- uses: actions/cache@v4
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
with:
|
||||||
path: node_modules
|
path: node_modules
|
||||||
key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }}
|
key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }}
|
||||||
- name: Run ESLint
|
- run: npm run lint
|
||||||
run: npm run lint
|
|
||||||
|
|
||||||
typecheck:
|
typecheck:
|
||||||
name: TypeScript Check
|
name: TypeScript Check
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: setup
|
needs: setup
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- uses: actions/checkout@v4
|
||||||
uses: actions/checkout@v4
|
- uses: actions/setup-node@v4
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
- name: Restore Node Modules
|
- uses: actions/cache@v4
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
with:
|
||||||
path: node_modules
|
path: node_modules
|
||||||
key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }}
|
key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }}
|
||||||
- name: Run TypeScript type check
|
- run: npx wxt prepare
|
||||||
run: npm run compile
|
- run: npm run typecheck
|
||||||
|
|
||||||
test:
|
test:
|
||||||
name: Unit Tests
|
name: Unit Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: setup
|
needs: setup
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- uses: actions/checkout@v4
|
||||||
uses: actions/checkout@v4
|
- uses: actions/setup-node@v4
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
- name: Restore Node Modules
|
- uses: actions/cache@v4
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
with:
|
||||||
path: node_modules
|
path: node_modules
|
||||||
key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }}
|
key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }}
|
||||||
- name: Run tests
|
- run: npx wxt prepare
|
||||||
run: npm run test
|
- run: npm run test
|
||||||
|
|
||||||
# ── Phase 3: 多端分布式高精打包(Compile & Upload Artifacts) ───────────────
|
# ── Phase 3: 打包 ───────────────────────
|
||||||
build-extension:
|
build-extension:
|
||||||
name: Package Extension
|
name: Package Extension
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [ lint, typecheck, test ]
|
needs: [lint, typecheck, test]
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- uses: actions/checkout@v4
|
||||||
uses: actions/checkout@v4
|
- uses: actions/setup-node@v4
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
- name: Restore Node Modules
|
- uses: actions/cache@v4
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
with:
|
||||||
path: node_modules
|
path: node_modules
|
||||||
key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }}
|
key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
|
||||||
# 执行 WXT 高阶打包压缩指令
|
- name: Sync version from tag
|
||||||
|
run: |
|
||||||
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
|
echo "Setting version to $VERSION"
|
||||||
|
npm version "$VERSION" --no-git-tag-version
|
||||||
|
|
||||||
- name: Build and Zip Extension
|
- name: Build and Zip Extension
|
||||||
run: |
|
run: |
|
||||||
npm run zip
|
npm run zip
|
||||||
npm run zip:firefox
|
npm run zip:firefox
|
||||||
|
|
||||||
# 💡 核心自愈补丁:显式将 .output 下打包出的真实生产绝对路径文件,
|
# 🔍 调试:确认 zip 文件位置
|
||||||
# 稳固地上存至 GitHub 的常驻产物箱中进行安全物理隔离,防范后期发布网络崩溃导致产物蒸发!
|
- name: Verify output files
|
||||||
|
run: |
|
||||||
|
echo "=== .output/ contents ==="
|
||||||
|
ls -la .output/ || echo "Not found"
|
||||||
|
echo "=== All zip files ==="
|
||||||
|
find . -name "*.zip" -type f
|
||||||
|
|
||||||
|
# 💡 关键修复:将 zip 文件复制到固定目录,避免 glob 问题
|
||||||
|
- name: Prepare release artifacts
|
||||||
|
run: |
|
||||||
|
mkdir -p release-archives
|
||||||
|
cp .output/*.zip release-archives/ 2>/dev/null || true
|
||||||
|
echo "Files in release-archives:"
|
||||||
|
ls -la release-archives/
|
||||||
|
|
||||||
- name: Upload Extension Artifacts
|
- name: Upload Extension Artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: extension-zips
|
name: extension-zips
|
||||||
path: |
|
path: release-archives/
|
||||||
.output/*.zip
|
if-no-files-found: error
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
# ── Phase 4: 独立中央签发发布(Atomic Release Publisher) ───────────────────
|
# ── Phase 4: 发布 ───────────────────────
|
||||||
release:
|
release:
|
||||||
name: Create GitHub Release
|
name: Create GitHub Release
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: build-extension
|
needs: build-extension
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- uses: actions/checkout@v4
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
# 💡 独立下载打包完好的绝对产物包
|
|
||||||
- name: Download Extension Artifacts
|
- name: Download Extension Artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: extension-zips
|
name: extension-zips
|
||||||
path: release-artifacts
|
path: release-artifacts
|
||||||
|
|
||||||
|
# 🔍 调试:确认下载成功
|
||||||
|
- name: Verify downloaded artifacts
|
||||||
|
run: |
|
||||||
|
echo "=== release-artifacts/ ==="
|
||||||
|
ls -la release-artifacts/
|
||||||
|
|
||||||
- name: Extract version from tag
|
- name: Extract version from tag
|
||||||
id: version
|
id: version
|
||||||
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
|
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
# 💡 最终无风险原子级发布大礼包
|
|
||||||
- name: Create GitHub Release
|
- name: Create GitHub Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
@@ -156,6 +158,5 @@ jobs:
|
|||||||
draft: false
|
draft: false
|
||||||
prerelease: ${{ contains(github.ref_name, '-') }}
|
prerelease: ${{ contains(github.ref_name, '-') }}
|
||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
# 百分之百精准指向被下载下来的、毫无路径污染风险的 Zip 包实体
|
|
||||||
files: |
|
files: |
|
||||||
release-artifacts/*.zip
|
release-artifacts/*.zip
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# AGENTS.md
|
# AGENTS.md
|
||||||
|
|
||||||
WXT 浏览器扩展项目 (React 19 + TypeScript + MUI v7)。
|
WXT 浏览器扩展项目 (React 19 + TypeScript)。提供时间戳转换、存储清理、JWT 解析、JSON 工具、二维码、Base64、Markdown 等测试效率工具。
|
||||||
|
|
||||||
## 核心命令
|
## 核心命令
|
||||||
|
|
||||||
@@ -9,6 +9,8 @@ npm run dev # Chrome 开发模式 (HMR)
|
|||||||
npm run dev:firefox # Firefox 开发模式
|
npm run dev:firefox # Firefox 开发模式
|
||||||
npm run build # Chrome 生产构建
|
npm run build # Chrome 生产构建
|
||||||
npm run build:firefox # Firefox 生产构建
|
npm run build:firefox # Firefox 生产构建
|
||||||
|
npm run zip # 打包 Chrome 扩展 (.output/*.zip)
|
||||||
|
npm run zip:firefox # 打包 Firefox 扩展
|
||||||
npm run lint # ESLint (--max-warnings=0)
|
npm run lint # ESLint (--max-warnings=0)
|
||||||
npm run typecheck # tsc --noEmit
|
npm run typecheck # tsc --noEmit
|
||||||
npm run test # vitest run (单次执行)
|
npm run test # vitest run (单次执行)
|
||||||
@@ -20,64 +22,109 @@ npm run test:coverage # 带覆盖率的测试
|
|||||||
|
|
||||||
## 验证流程
|
## 验证流程
|
||||||
|
|
||||||
CI 执行顺序: `lint → typecheck → test → build` (build 依赖前三者)。
|
CI 步骤(严格顺序,任一步骤失败则停止并标记 CI 失败):
|
||||||
|
|
||||||
Pre-commit hook (lint-staged) 顺序:
|
1. `setup`(安装依赖、`wxt prepare`)
|
||||||
|
2. 并行运行 `lint`、`typecheck`、`test`(三者全部通过才继续)
|
||||||
|
3. `build`(仅当步骤 2 全部成功时执行)
|
||||||
|
|
||||||
1. `prettier --write`
|
Pre-commit hook(`.husky/pre-commit` 调用 `lint-staged`,任一步骤返回非零则终止提交):
|
||||||
2. `eslint --fix --max-warnings=0`
|
|
||||||
3. `tsc --noEmit`
|
|
||||||
|
|
||||||
提交前确保三者通过。
|
1. 代码文件 (`*.{ts,tsx,js,jsx,mjs}`):运行 `eslint --fix --max-warnings=0 --no-warn-ignored`;若失败则终止并报告错误
|
||||||
|
2. 同一代码文件:运行 `prettier --write`
|
||||||
|
3. 其他文件 (`*.{json,css,scss,md}`):运行 `prettier --write`
|
||||||
|
|
||||||
|
## WXT 生成文件
|
||||||
|
|
||||||
|
- `.wxt/` 目录由 `postinstall` 自动执行 `wxt prepare` 生成,包含 TypeScript 类型声明和扩展的 tsconfig。
|
||||||
|
- 生产构建输出到 `.output/` 目录。
|
||||||
|
- `tsconfig.json` 继承自 `./.wxt/tsconfig.json`。
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
```
|
```
|
||||||
config/features.tsx # 功能定义(路由 + 元数据的单一事实来源)
|
src/ # 源代码根目录
|
||||||
config/pageTheme.ts # 页面级主题常量
|
config/features.tsx # 功能定义(路由 + 元数据的单一事实来源)
|
||||||
config/theme.ts # MUI 全局主题
|
entrypoints/ # 扩展入口点 (popup/, sidepanel/, background.ts, content.ts)
|
||||||
entrypoints/ # 扩展入口点 (popup/, options/, sidepanel/, background.ts, content.ts)
|
pages/ # 功能页面组件 (懒加载)
|
||||||
pages/ # 功能页面组件 (懒加载)
|
components/ # 可复用 UI 组件
|
||||||
components/ # 可复用 UI 组件
|
components/ui/ # shadcn/ui 基础组件 (button, dialog, select 等)
|
||||||
providers/ # React Context (Router, Theme 等)
|
providers/ # React Context (Router, Theme 等)
|
||||||
utils/ # 工具函数与服务抽象
|
hooks/ # 自定义 React Hooks
|
||||||
types/ # TypeScript 类型声明
|
utils/ # 工具函数与服务抽象
|
||||||
i18n/locales/{zh,en}/ # 国际化资源 (common.json, features.json)
|
types/ # TypeScript 类型声明
|
||||||
|
lib/ # 通用工具函数(cn、utils)
|
||||||
|
public/ # 静态资源(图标、_locales 等)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 页面组件模式
|
||||||
|
|
||||||
|
典型功能页面遵循 **UI + Hook 分离** 模式:
|
||||||
|
|
||||||
|
```
|
||||||
|
src/pages/FeatureName/
|
||||||
|
├── index.tsx # 页面 UI(纯展示,使用 shadcn/ui 组件)
|
||||||
|
├── useFeatureName.ts # 业务逻辑 Hook(状态管理 + 转换逻辑)
|
||||||
|
└── constants.ts # 常量定义
|
||||||
|
```
|
||||||
|
|
||||||
|
- 页面组件调用 `useLazyTranslation('featureName')` 获取翻译函数
|
||||||
|
- Hook 负责所有状态管理和业务逻辑,通过返回值暴露给页面
|
||||||
|
- 子组件可进一步拆分(如 `LiveClock.tsx`、`ResultView.tsx`)
|
||||||
|
|
||||||
## 关键架构决策
|
## 关键架构决策
|
||||||
|
|
||||||
**路由**: 不使用 React Router。通过 `config/features.tsx` 的 `FEATURES` 数组管理,`RouterProvider` 根据 `PageType` 渲染对应组件。
|
**路由**: 不使用 React Router。通过 `src/config/features.tsx` 的 `FEATURES` 数组管理,`RouterProvider` 根据 `PageType`
|
||||||
|
渲染对应组件。支持三种渲染模式:popup(弹窗)、sidepanel(侧边栏)和 browser-tab(浏览器新标签页,通过 `open_in_tab` 打开)。
|
||||||
|
每种模式有独立的路由和可见页面配置(`app/popupRoute`、`app/sidepanelRoute`、`app/tabRoute` 等)。
|
||||||
|
|
||||||
**存储**: 所有 Chrome Storage 键必须在 `types/storage.d.ts` 的 `StorageSchema` 中定义。使用 `utils/chromeStorage.ts` 及其 Hook。
|
**存储**: 所有 Chrome Storage 键必须在 `src/types/storage.d.ts` 的 `StorageSchema` 中定义,键名使用 kebab-case 格式(如 `app/currentRoute`)。
|
||||||
|
使用 `src/utils/chromeStorage.ts` 及其 Hook。Router 同时使用 `chrome.storage.local` 和 `localStorage` 做快照以消除首屏闪烁。
|
||||||
|
修改 StorageSchema 时,必须在 `src/utils/chromeStorage.ts` 添加版本迁移函数,并在测试中覆盖迁移场景。
|
||||||
|
|
||||||
**通信**: 使用 `@webext-core/messaging`,协议定义在 `utils/messages.ts`。
|
**通信**: 使用 `@webext-core/messaging`,协议定义在 `src/utils/messages.ts`。
|
||||||
|
|
||||||
**路径别名**: `@/` 映射到项目根目录 (已在 tsconfig 和 vitest.config 中配置)。
|
**路径别名**: `@/` 映射到项目根目录 (已在 tsconfig 和 vitest.config 中配置)。
|
||||||
|
|
||||||
|
**浏览器兼容**: 优先使用 `wxt/browser` 导出的 `browser` 对象,而非原生 `chrome` API。
|
||||||
|
|
||||||
|
**代码分割**: `wxt.config.ts` 通过 `manualChunksForHtmlOnly()` 自动分组依赖(vendor-react、vendor-i18n、vendor-qr 等),无需手动配置。
|
||||||
|
|
||||||
## 测试环境
|
## 测试环境
|
||||||
|
|
||||||
- 环境: jsdom
|
- 环境: jsdom
|
||||||
- 全局变量: `vitest/globals` (describe, it, expect 等无需导入)
|
- 全局变量: `vitest/globals` (describe, it, expect 等无需导入)
|
||||||
- Setup 文件: `vitest.setup.ts` 自动 mock:
|
- Setup 文件: `vitest.setup.ts` 自动 mock:
|
||||||
- `chrome.*` API (storage, tabs, runtime, cookies 等)
|
- `chrome.*` / `browser.*` API (storage, tabs, runtime, cookies 等)
|
||||||
- `react-i18next` (返回 key 作为翻译)
|
- `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 代码
|
||||||
|
- 测试工具: `@testing-library/react` + `@testing-library/user-event` 进行组件测试
|
||||||
|
|
||||||
## i18n
|
## i18n
|
||||||
|
|
||||||
- 命名空间: `common` (默认), `features`
|
- 命名空间: `common` (默认), `features`
|
||||||
- 翻译键格式: `namespace:key` (如 `features:timestamp.title`)
|
- 翻译键格式: `namespace:key` (如 `features:timestamp.title`)
|
||||||
- 语言: `zh` (默认), `en`
|
- 语言: `zh` (默认), `en`
|
||||||
- 添加新翻译: 编辑 `i18n/locales/{zh,en}/{common,features}.json`
|
- 翻译文件结构:
|
||||||
|
- `i18n/locales/{zh,en}/common.json` - 全局通用翻译
|
||||||
|
- `i18n/locales/{zh,en}/features.json` - 功能模块标题和描述
|
||||||
|
- `i18n/locales/{zh,en}/{功能名}.json` - 各功能独立翻译(如 timestamp.json, storageCleaner.json 等)
|
||||||
|
- 添加新翻译: 编辑 `i18n/locales/{zh,en}/{common,features}.json` 及对应功能独立 JSON
|
||||||
|
- 使用 `useLazyTranslation` hook 加载功能独立翻译,返回 `ns:key` 格式
|
||||||
|
- 回退策略: 当翻译 key 在目标语言缺失时,回退到默认语言 `zh`;若默认语言也缺失,返回占位格式 `namespace:key` 并在开发模式下记录 warning
|
||||||
|
|
||||||
## 新功能开发清单
|
## 新功能开发清单
|
||||||
|
|
||||||
1. 在 `types/storage.d.ts` 添加 `PageType` 联合类型
|
1. 在 `src/types/storage.d.ts` 添加 `PageType` 联合类型
|
||||||
2. 在 `config/features.tsx` 的 `FEATURES` 数组添加配置
|
2. 在 `src/config/features.tsx` 的 `FEATURES` 数组添加配置(指定 key、翻译键、图标、三种渲染模式的组件)
|
||||||
3. 在 `pages/` 创建页面组件 (懒加载)
|
3. 在 `src/pages/` 创建页面组件 (懒加载):
|
||||||
4. 在 `i18n/locales/{zh,en}/features.json` 添加翻译
|
- `index.tsx` — UI 组件,使用 `useLazyTranslation` 获取翻译
|
||||||
|
- `useFeatureName.ts` — 业务逻辑 Hook
|
||||||
|
- `constants.ts` — 常量(可选)
|
||||||
|
4. 在 `i18n/locales/{zh,en}/features.json` 添加翻译(复杂功能可新建独立 JSON)
|
||||||
5. 如需新权限,更新 `wxt.config.ts` 的 `manifest.permissions`
|
5. 如需新权限,更新 `wxt.config.ts` 的 `manifest.permissions`
|
||||||
6. 添加对应的单元测试
|
6. 添加对应的单元测试
|
||||||
|
|
||||||
@@ -85,14 +132,16 @@ i18n/locales/{zh,en}/ # 国际化资源 (common.json, features.json)
|
|||||||
|
|
||||||
- 禁止使用 `any` (测试文件除外)
|
- 禁止使用 `any` (测试文件除外)
|
||||||
- 未使用变量/参数: 使用 `_` 前缀 (如 `_unused`)
|
- 未使用变量/参数: 使用 `_` 前缀 (如 `_unused`)
|
||||||
- 样式: 复杂页面样式放 `config/pageTheme.ts`,简单样式用 MUI `sx` prop
|
- 样式: 使用 Tailwind CSS + shadcn/ui (通过 `className` 和 `cn()` 工具)
|
||||||
- 格式: Prettier (100 字符宽, 单引号, 尾逗号)
|
- UI 组件: 优先使用 `src/components/ui/` 下的 shadcn/ui 组件 (button, dialog, select 等)
|
||||||
|
- 图标: 使用 `lucide-react` 图标库
|
||||||
|
- 格式: Prettier (`.prettierrc`: 100 字符宽, 单引号, 尾逗号 all, LF 换行)
|
||||||
|
- ESLint 使用 `typescript-eslint` 的 `projectService: true`(无需手动维护 project 路径)
|
||||||
|
|
||||||
## 技术栈版本
|
## 关键外部库(非显而易见的)
|
||||||
|
|
||||||
- WXT: ^0.20.26
|
- `@webext-core/messaging` — 扩展消息通信
|
||||||
- React: ^19.2.6
|
- `@dnd-kit` — 拖拽排序(用于页面顺序管理)
|
||||||
- MUI: ^7.3.8
|
- `qrious` + `qr-scanner` — 二维码生成与解析
|
||||||
- TypeScript: ^5.9.3
|
- `dayjs` — 日期处理(时间戳转换)
|
||||||
- Vitest: ^4.1.7
|
- `sonner` — Toast 通知(替代传统 snackbar)
|
||||||
- i18next: ^26.2.0
|
|
||||||
|
|||||||
@@ -41,18 +41,36 @@
|
|||||||
- **安全检查**: 自动去除 `Bearer` 前缀,处理异常输入并提供友好提示.
|
- **安全检查**: 自动去除 `Bearer` 前缀,处理异常输入并提供友好提示.
|
||||||
- **签名查看**: 展示 JWT 签名部分,辅助验证令牌完整性.
|
- **签名查看**: 展示 JWT 签名部分,辅助验证令牌完整性.
|
||||||
|
|
||||||
|
### 🖱️ 右键恢复工具
|
||||||
|
|
||||||
|
- **当前页面检测**: 自动识别当前活动标签页的域名.
|
||||||
|
- **一键恢复**: 解除网站对右键菜单的限制,恢复复制、粘贴等基础操作.
|
||||||
|
- **状态可视化**: 通过 Badge 组件直观展示当前页面的锁定/解锁状态.
|
||||||
|
|
||||||
### 🖼️ 二维码工具
|
### 🖼️ 二维码工具
|
||||||
|
|
||||||
- **生成器**: 将当前 URL 或自定义文本快速转换为二维码,支持下载.
|
- **生成器**: 将当前 URL 或自定义文本快速转换为二维码,支持下载.
|
||||||
- **解析器**: 支持通过上传图片或粘贴图片来解析二维码内容.
|
- **解析器**: 支持通过上传图片或粘贴图片来解析二维码内容.
|
||||||
|
|
||||||
|
### 🔄 JSON 工具
|
||||||
|
|
||||||
|
- **差异比较**: 对比两段 JSON 数据,高亮展示差异.
|
||||||
|
- **格式化**: 支持 JSON 美化、压缩、转 YAML / TOML.
|
||||||
|
|
||||||
|
### 🧰 Base64 转换器
|
||||||
|
|
||||||
|
- **文本编解码**: 支持文本内容的 Base64 编码与解码.
|
||||||
|
- **文件转换**: 支持文件与 Base64 字符串互转.
|
||||||
|
- **图像预览**: 支持图片 Base64 编码与实时预览.
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
- **框架**: [WXT (Web Extension Toolkit)](https://wxt.dev/)
|
- **框架**: [WXT (Web Extension Toolkit)](https://wxt.dev/)
|
||||||
- **前端**: React 19 + TypeScript
|
- **前端**: React 19 + TypeScript
|
||||||
- **UI 组件**: Material UI (MUI) @7.x
|
- **UI 组件**: shadcn/ui (基于 Radix UI 的无头组件库)
|
||||||
- **样式**: Emotion (Styled Components)
|
- **样式**: Tailwind CSS + class-variance-authority + cn() 工具函数
|
||||||
- **日期处理**: dayjs (集成 UTC 和 Timezone 插件)
|
- **日期处理**: dayjs (集成 UTC 和 Timezone 插件)
|
||||||
|
- **国际化**: i18next + react-i18next
|
||||||
- **通信**: @webext-core/messaging
|
- **通信**: @webext-core/messaging
|
||||||
- **存储**: Chrome Storage API (类型安全封装)
|
- **存储**: Chrome Storage API (类型安全封装)
|
||||||
- **解析引擎**: qr-scanner (二维码解析), qrious (二维码生成)
|
- **解析引擎**: qr-scanner (二维码解析), qrious (二维码生成)
|
||||||
@@ -61,21 +79,22 @@
|
|||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
```text
|
```text
|
||||||
├── components/ # 可复用 React 组件
|
├── src/ # 源代码根目录
|
||||||
├── config/ # 应用配置(路由、功能元数据、主题)
|
│ ├── components/ # 可复用 React 组件
|
||||||
│ ├── features.tsx # 功能定义与路由映射
|
│ ├── config/ # 应用配置
|
||||||
│ └── pageTheme.ts # 各功能页面的视觉风格配置
|
│ │ └── 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, Snackbar 等)
|
│ ├── hooks/ # 自定义 React Hooks
|
||||||
├── types/ # TypeScript 类型声明
|
│ ├── utils/ # 工具函数与服务抽象
|
||||||
├── utils/ # 工具函数与服务抽象
|
│ ├── types/ # TypeScript 类型声明
|
||||||
├── public/ # 静态资源 (图标、 manifest 资源等)
|
│ └── lib/ # 通用工具函数 (cn, utils 等)
|
||||||
|
├── public/ # 静态资源 (图标、manifest 资源等)
|
||||||
├── wxt.config.ts # WXT 框架核心配置
|
├── wxt.config.ts # WXT 框架核心配置
|
||||||
└── package.json # 项目元数据与依赖管理
|
└── package.json # 项目元数据与依赖管理
|
||||||
```
|
```
|
||||||
@@ -90,15 +109,20 @@
|
|||||||
### 常用命令
|
### 常用命令
|
||||||
|
|
||||||
| 命令 | 说明 |
|
| 命令 | 说明 |
|
||||||
| ----------------------- | -------------------------------- |
|
| ----------------------- | --------------------------------- |
|
||||||
| `npm run dev` | 启动 Chrome 开发模式(支持 HMR) |
|
| `npm run dev` | 启动 Chrome 开发模式(支持 HMR) |
|
||||||
| `npm run dev:firefox` | 启动 Firefox 开发模式 |
|
| `npm run dev:firefox` | 启动 Firefox 开发模式 |
|
||||||
| `npm run build` | 构建 Chrome 生产版本 |
|
| `npm run build` | 构建 Chrome 生产版本 |
|
||||||
| `npm run compile` | 执行 TypeScript 类型检查 |
|
| `npm run build:firefox` | 构建 Firefox 生产版本 |
|
||||||
|
| `npm run zip` | 打包 Chrome 扩展 (.output/\*.zip) |
|
||||||
|
| `npm run zip:firefox` | 打包 Firefox 扩展 |
|
||||||
|
| `npm run typecheck` | 执行 TypeScript 类型检查 |
|
||||||
| `npm run lint` | 执行 ESLint 代码规范检查 |
|
| `npm run lint` | 执行 ESLint 代码规范检查 |
|
||||||
| `npm run test` | 运行单元测试 |
|
| `npm run test` | 运行单元测试 |
|
||||||
| `npm run test:coverage` | 生成测试覆盖率报告 |
|
| `npm run test:coverage` | 生成测试覆盖率报告 |
|
||||||
|
|
||||||
|
运行单个测试: `npx vitest run path/to/file.test.ts`
|
||||||
|
|
||||||
### 自动化流程
|
### 自动化流程
|
||||||
|
|
||||||
项目通过 GitHub Actions 实现了完善的 CI/CD 流程:
|
项目通过 GitHub Actions 实现了完善的 CI/CD 流程:
|
||||||
@@ -110,12 +134,14 @@
|
|||||||
|
|
||||||
本扩展根据功能需要申请了以下权限:
|
本扩展根据功能需要申请了以下权限:
|
||||||
|
|
||||||
- `storage`: 存储用户设置和工具配置.
|
- `storage` & `unlimitedStorage`: 存储用户设置、工具配置及大量数据.
|
||||||
- `activeTab` & `tabs`: 获取当前页面 URL 及其元数据.
|
- `activeTab` & `tabs`: 获取当前页面 URL 及其元数据.
|
||||||
- `scripting`: 在网页中执行清理脚本.
|
- `scripting`: 在网页中执行清理和右键恢复脚本.
|
||||||
- `cookies`: 管理和清理网站 Cookie.
|
- `cookies`: 管理和清理网站 Cookie.
|
||||||
- `sidePanel`: 支持在浏览器侧边栏中运行.
|
- `sidePanel`: 支持在浏览器侧边栏中运行.
|
||||||
- `clipboardWrite`: 提供一键复制功能.
|
- `clipboardWrite`: 提供一键复制功能.
|
||||||
|
- `contextMenus`: 注册右键菜单,支持快捷操作.
|
||||||
|
- `alarms`: 管理后台定时任务(如清理后自动刷新).
|
||||||
|
|
||||||
## 浏览器支持
|
## 浏览器支持
|
||||||
|
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
|
||||||
import { Check, Copy } from 'lucide-react';
|
|
||||||
import { copyTextToClipboard } from '@/utils/clipboard';
|
|
||||||
import { cn } from '@/lib/utils'; // 1. 必须使用 cn 工具函数
|
|
||||||
import { toast } from 'sonner'; // 2. 推荐使用 shadcn 默认的全局 toast
|
|
||||||
|
|
||||||
// 3. 继承原生按钮属性,允许外部自由扩展 className、variant 等
|
|
||||||
interface CopyButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
||||||
text: string;
|
|
||||||
tooltip?: string;
|
|
||||||
size?: 'small' | 'medium' | 'large';
|
|
||||||
// 移除复杂的自定义颜色变体,交由 Tailwind 类名或 shadcn 的 variant 解决
|
|
||||||
variant?: 'default' | 'secondary' | 'ghost' | 'outline';
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CopyButton: React.FC<CopyButtonProps> = ({
|
|
||||||
text,
|
|
||||||
tooltip = '复制',
|
|
||||||
size = 'small',
|
|
||||||
variant = 'ghost',
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}) => {
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (timerRef.current) clearTimeout(timerRef.current);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleCopy = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
|
||||||
e.stopPropagation(); // 基础组件防冒泡,避免触发父级点击事件
|
|
||||||
|
|
||||||
if (!text) {
|
|
||||||
toast.error('无内容可复制');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const success = await copyTextToClipboard(text);
|
|
||||||
if (success) {
|
|
||||||
toast.success('复制成功');
|
|
||||||
setCopied(true);
|
|
||||||
if (timerRef.current) clearTimeout(timerRef.current);
|
|
||||||
timerRef.current = setTimeout(() => setCopied(false), 1500);
|
|
||||||
} else {
|
|
||||||
toast.error('复制失败');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 4. 将控制尺寸的类名标准化
|
|
||||||
const sizeClasses = {
|
|
||||||
small: 'h-8 w-8 text-xs',
|
|
||||||
medium: 'h-10 w-10 text-sm',
|
|
||||||
large: 'h-12 w-12 text-base',
|
|
||||||
};
|
|
||||||
|
|
||||||
// 5. 映射 shadcn 的底层通用 Variant 类名
|
|
||||||
const variantClasses = {
|
|
||||||
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
|
||||||
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
|
||||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
|
||||||
outline:
|
|
||||||
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleCopy}
|
|
||||||
title={tooltip}
|
|
||||||
// 6. 使用 cn() 合并类名,并完美支持暗黑模式的语义化变量 (destructive/muted等)
|
|
||||||
className={cn(
|
|
||||||
'inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
|
|
||||||
sizeClasses[size],
|
|
||||||
copied
|
|
||||||
? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400' // 兼顾暗黑模式的成功色
|
|
||||||
: variantClasses[variant],
|
|
||||||
className, // 允许外部直接传入 text-red-500 等覆盖样式
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{copied ? (
|
|
||||||
<Check className="h-[1.2em] w-[1.2em] animate-in fade-in zoom-in-75 duration-200" />
|
|
||||||
) : (
|
|
||||||
<Copy className="h-[1.2em] w-[1.2em]" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CopyButton;
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
import { ReactNode, useMemo } from 'react';
|
|
||||||
import { getEntryPointType } from '@/config/features';
|
|
||||||
import { cn } from '@/lib/utils'; // shadcn 核心类名合并工具
|
|
||||||
|
|
||||||
export interface PageHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
||||||
/** 要显示的图标组件 */
|
|
||||||
icon: ReactNode;
|
|
||||||
/**
|
|
||||||
* 图标的颜色,支持:
|
|
||||||
* 1. Tailwind 颜色类名 (如 'text-blue-500', 'text-primary') -> 推荐
|
|
||||||
* 2. 原生颜色值 (如 '#3b82f6')
|
|
||||||
*/
|
|
||||||
iconColor?: string;
|
|
||||||
/** 主标题文本 */
|
|
||||||
title: string;
|
|
||||||
/** 副标题文本(可选) */
|
|
||||||
subtitle?: string;
|
|
||||||
/** 在标题右侧显示的徽章/标签组件(可选) */
|
|
||||||
badge?: ReactNode;
|
|
||||||
/** 覆盖图标容器的类名 */
|
|
||||||
iconClassName?: string;
|
|
||||||
/** 覆盖主标题的类名 */
|
|
||||||
titleClassName?: string;
|
|
||||||
/** 覆盖副标题的类名 */
|
|
||||||
subtitleClassName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PageHeader({
|
|
||||||
icon,
|
|
||||||
iconColor = 'text-blue-500', // 默认改用类名,若需保持 Hex 可写 "#3b82f6"
|
|
||||||
title,
|
|
||||||
subtitle,
|
|
||||||
badge,
|
|
||||||
iconClassName,
|
|
||||||
titleClassName,
|
|
||||||
subtitleClassName,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: PageHeaderProps) {
|
|
||||||
const entryPointType = useMemo(() => getEntryPointType(), []);
|
|
||||||
|
|
||||||
// 扩展环境判断:如果是 popup 形式则不渲染头部
|
|
||||||
if (entryPointType === 'popup') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 判断传入的是否是 Hex/RGB 等原生颜色值
|
|
||||||
const isRawColor =
|
|
||||||
iconColor.startsWith('#') || iconColor.startsWith('rgb') || iconColor.startsWith('hsl');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn('flex items-center gap-3 mb-6', className)} {...props}>
|
|
||||||
{/* 图标容器 */}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'p-2 rounded-lg flex items-center justify-center shrink-0',
|
|
||||||
// 如果不是原生颜色,直接当作 Tailwind 类名注入
|
|
||||||
!isRawColor && iconColor,
|
|
||||||
iconClassName,
|
|
||||||
)}
|
|
||||||
style={
|
|
||||||
isRawColor
|
|
||||||
? {
|
|
||||||
color: iconColor,
|
|
||||||
// 使用 CSS inline 变量或 color-mix 安全处理透明度,不再暴力拼接 "15"
|
|
||||||
backgroundColor: `color-mix(in srgb, ${iconColor} 8%, transparent)`,
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{/* 确保图标大小可控,通过子元素选择器约束 SVG 宽高 */}
|
|
||||||
<div className="[&>svg]:h-5 [&>svg]:w-5">{icon}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 标题区域 */}
|
|
||||||
<div className="flex-1 min-w-0 flex flex-col gap-0.5">
|
|
||||||
{/* 标题行(含徽章) */}
|
|
||||||
<div className="flex justify-between items-center gap-2">
|
|
||||||
<h1
|
|
||||||
className={cn(
|
|
||||||
'text-base font-extrabold tracking-tight leading-tight text-foreground truncate',
|
|
||||||
titleClassName,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</h1>
|
|
||||||
{badge && <div className="shrink-0">{badge}</div>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 副标题 - 使用 p 标签(block)保证换行 */}
|
|
||||||
{subtitle && (
|
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
'text-xs font-semibold text-muted-foreground truncate',
|
|
||||||
subtitleClassName,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{subtitle}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import PageHeader, { type PageHeaderProps } from '@/components/PageHeader';
|
|
||||||
|
|
||||||
vi.mock('@/config/features', () => ({
|
|
||||||
getEntryPointType: vi.fn(() => 'sidepanel'),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('PageHeader 组件系统', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
vi.resetAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
const defaultProps: PageHeaderProps = {
|
|
||||||
icon: <span data-testid="test-icon">⏰</span>,
|
|
||||||
title: '时间戳转换',
|
|
||||||
subtitle: 'Unix 毫秒数转换与格式化',
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('PageHeader UI 渲染', () => {
|
|
||||||
it('应渲染页面标题栏&副标题', () => {
|
|
||||||
render(<PageHeader {...defaultProps} />);
|
|
||||||
expect(screen.getByText('时间戳转换')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Unix 毫秒数转换与格式化')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应渲染图标', () => {
|
|
||||||
render(<PageHeader {...defaultProps} />);
|
|
||||||
expect(screen.getByTestId('test-icon')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应渲染自定义图标&图标颜色', () => {
|
|
||||||
render(
|
|
||||||
<PageHeader
|
|
||||||
{...defaultProps}
|
|
||||||
icon={<span data-testid="custom-icon">X</span>}
|
|
||||||
iconColor="#FF0000"
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
expect(screen.getByTestId('custom-icon')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应默认使用蓝色作为 primary 色', () => {
|
|
||||||
render(<PageHeader {...defaultProps} />);
|
|
||||||
const iconContainer = screen.getByTestId('test-icon').parentElement?.parentElement;
|
|
||||||
expect(iconContainer).toHaveClass('text-blue-500');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应渲染 badge 组件', () => {
|
|
||||||
const badge = <span data-testid="test-badge">New</span>;
|
|
||||||
render(<PageHeader {...defaultProps} badge={badge} />);
|
|
||||||
expect(screen.getByTestId('test-badge')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应支持自定义 iconClassName', () => {
|
|
||||||
render(<PageHeader {...defaultProps} iconClassName="custom-icon-class" />);
|
|
||||||
const iconContainer = screen.getByTestId('test-icon').parentElement?.parentElement;
|
|
||||||
expect(iconContainer).toHaveClass('custom-icon-class');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应支持自定义 titleClassName', () => {
|
|
||||||
render(<PageHeader {...defaultProps} titleClassName="custom-title-class" />);
|
|
||||||
const title = screen.getByText('时间戳转换');
|
|
||||||
expect(title).toHaveClass('custom-title-class');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应支持自定义 subtitleClassName', () => {
|
|
||||||
render(<PageHeader {...defaultProps} subtitleClassName="custom-subtitle-class" />);
|
|
||||||
const subtitle = screen.getByText('Unix 毫秒数转换与格式化');
|
|
||||||
expect(subtitle).toHaveClass('custom-subtitle-class');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应支持自定义 className', () => {
|
|
||||||
const { container } = render(<PageHeader {...defaultProps} className="custom-page-header" />);
|
|
||||||
const outerElement = container.firstChild;
|
|
||||||
expect(outerElement).toHaveClass('custom-page-header');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('无副标题时不渲染副标题区域', () => {
|
|
||||||
const { container } = render(<PageHeader icon={defaultProps.icon} title="仅标题" />);
|
|
||||||
const subtitles = container.querySelectorAll('.text-muted-foreground');
|
|
||||||
expect(subtitles.length).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('PageHeader 入口点隐藏', () => {
|
|
||||||
it('popup 模式下应返回 null', async () => {
|
|
||||||
const { getEntryPointType } = await import('@/config/features');
|
|
||||||
vi.mocked(getEntryPointType).mockReturnValue('popup');
|
|
||||||
|
|
||||||
const { container } = render(<PageHeader {...defaultProps} />);
|
|
||||||
expect(container.innerHTML).toBe('');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
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];
|
|
||||||
|
|
||||||
export const THEME_COLORS = {
|
|
||||||
primary: '#1976d2',
|
|
||||||
primaryDark: '#1565c0',
|
|
||||||
primaryLight: '#42a5f5',
|
|
||||||
success: '#2e7d32',
|
|
||||||
successDark: '#1b5e20',
|
|
||||||
successLight: '#4caf50',
|
|
||||||
warning: '#e65100',
|
|
||||||
warningDark: '#bf360c',
|
|
||||||
warningLight: '#ff9800',
|
|
||||||
error: '#c62828',
|
|
||||||
errorDark: '#b71c1c',
|
|
||||||
errorLight: '#f44336',
|
|
||||||
purple: '#6a1b9a',
|
|
||||||
purpleDark: '#4a148c',
|
|
||||||
purpleLight: '#9c27b0',
|
|
||||||
indigo: '#303f9f',
|
|
||||||
indigoDark: '#1a237e',
|
|
||||||
indigoLight: '#7986cb',
|
|
||||||
white: '#FFFFFF',
|
|
||||||
black: '#000000',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const STATUS_COLORS = {
|
|
||||||
success: THEME_COLORS.success,
|
|
||||||
warning: THEME_COLORS.warning,
|
|
||||||
error: THEME_COLORS.error,
|
|
||||||
info: THEME_COLORS.primary,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const timestampPageStyles = {
|
|
||||||
primaryColor: THEME_COLORS.primary,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const storageCleanerPageStyles = {
|
|
||||||
warningColor: THEME_COLORS.warning,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const qrCodePageStyles = {
|
|
||||||
primaryColor: THEME_COLORS.success,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const textStatisticsPageStyles = {
|
|
||||||
primaryColor: THEME_COLORS.purple,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const base64ConverterPageStyles = {
|
|
||||||
primaryColor: THEME_COLORS.indigo,
|
|
||||||
};
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
# 为懒加载页面组件添加独立 ErrorBoundary 保护 — 设计文档
|
|
||||||
|
|
||||||
- 日期:2026-05-13
|
|
||||||
- 范围:popup / sidepanel / tab / options 入口下的页面错误隔离
|
|
||||||
|
|
||||||
## 背景与目标
|
|
||||||
|
|
||||||
当前应用通过 `config/features.tsx` 中的 `React.lazy()` 懒加载所有页面组件。`RouterContainer` 使用 `Suspense` 包裹动态组件,而错误边界仅在外层 `entrypoints/popup/App.tsx`、`entrypoints/sidepanel/App.tsx` 中包裹 `RouterContainer`、以及 `entrypoints/options/App.tsx` 顶层。
|
|
||||||
|
|
||||||
问题:单个懒加载页面在加载或渲染时一旦抛错,错误会冒泡到全局 `ErrorBoundary`,触发全屏错误 UI,整个路由容器和 TopBar 一起被替换。用户必须刷新页面才能继续使用其他工具,体验受损。
|
|
||||||
|
|
||||||
目标:将错误影响范围限制在当前页面区域;其他页面、TopBar、导航、Snackbar 不受影响;用户可在错误状态下切换到其他工具或重试当前页。
|
|
||||||
|
|
||||||
## 设计概要
|
|
||||||
|
|
||||||
新增 `PageErrorBoundary` 组件,专门用于页面级错误隔离,在 `RouterContainer` 的 `Suspense` 内层使用;`options/App.tsx` 也替换为同款页面级边界。现有全局 `ErrorBoundary` 保留作为兜底,覆盖 TopBar / Snackbar / RouterProvider 等同级组件。
|
|
||||||
|
|
||||||
## 组件设计
|
|
||||||
|
|
||||||
### `components/PageErrorBoundary.tsx`(新增)
|
|
||||||
|
|
||||||
复用现有 `ErrorBoundary` 的错误捕获机制(`getDerivedStateFromError` + `componentDidCatch`),但具备以下差异:
|
|
||||||
|
|
||||||
- **轻量内嵌 UI**:使用 `Paper` + 居中文本,去除全屏 `Container` + `mt:8` 布局,适配 popup 400×600 与 sidepanel 等窄屏环境。结构:
|
|
||||||
- 图标 (`ErrorOutlineIcon`)
|
|
||||||
- 标题:"该页面加载失败"
|
|
||||||
- 副标题:"页面在加载或渲染时遇到错误,您可以重试或切换到其他工具。"
|
|
||||||
- 折叠错误信息块(沿用现有错误展示样式,使用 monospace、可滚动)
|
|
||||||
- 主操作按钮:"重试"(`RefreshIcon`)—— 重置内部 state,让子树重新挂载
|
|
||||||
- **`resetKey` prop**:可选;当 `resetKey` 在 `componentDidUpdate` 中变化时,自动重置 `hasError` / `error`,无需用户手动操作
|
|
||||||
- **`componentDidCatch`**:仍使用 `console.error('Uncaught error in page:', error, errorInfo)` 输出,不引入额外上报
|
|
||||||
|
|
||||||
接口:
|
|
||||||
```ts
|
|
||||||
interface PageErrorBoundaryProps {
|
|
||||||
children: ReactNode;
|
|
||||||
resetKey?: string | number; // 变化时自动重置
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `components/ErrorBoundary.tsx`(不变)
|
|
||||||
|
|
||||||
保留作为全局兜底。负责捕获 TopBar、SnackbarProvider、RouterProvider 等同级组件中可能出现的错误,沿用全屏 `Container` 样式与"刷新应用"操作。
|
|
||||||
|
|
||||||
## 集成点
|
|
||||||
|
|
||||||
### `components/RouterContainer.tsx`(修改)
|
|
||||||
|
|
||||||
在 `Suspense` 内层插入 `PageErrorBoundary`,传入 `resetKey={currentPage}`:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
<Suspense fallback={<Spinner />}>
|
|
||||||
<PageErrorBoundary resetKey={currentPage}>
|
|
||||||
{Component && <Component />}
|
|
||||||
</PageErrorBoundary>
|
|
||||||
</Suspense>
|
|
||||||
```
|
|
||||||
|
|
||||||
说明:
|
|
||||||
- `PageErrorBoundary` 置于 `Suspense` 内部,可同时捕获懒加载 chunk 加载失败(异步异常)与页面渲染期同步错误
|
|
||||||
- `resetKey={currentPage}` 使页面切换时自动清除错误状态,无需用户干预
|
|
||||||
- 保留外层 `Box key={currentPage}` 与动画 className,不改变页面切换语义
|
|
||||||
|
|
||||||
### `entrypoints/options/App.tsx`(修改)
|
|
||||||
|
|
||||||
将顶层 `<ErrorBoundary>` 替换为 `<PageErrorBoundary>`。options 是单页应用,统一使用页面级错误卡片即可。
|
|
||||||
|
|
||||||
### `entrypoints/popup/App.tsx`、`entrypoints/sidepanel/App.tsx`(不变)
|
|
||||||
|
|
||||||
保留外层 `<ErrorBoundary>` 包裹 `<RouterContainer />`,作为同级组件(TopBar 等)的兜底。`PageErrorBoundary` 与全局 `ErrorBoundary` 各司其职:
|
|
||||||
|
|
||||||
- 页面级(`PageErrorBoundary`):捕获懒加载页面内部错误,隔离影响范围,仅替换页面区域
|
|
||||||
- 全局(`ErrorBoundary`):捕获 RouterContainer 自身、TopBar、Snackbar 等组件错误,作为最后兜底
|
|
||||||
|
|
||||||
## 数据流 / 错误处理
|
|
||||||
|
|
||||||
### 捕获路径
|
|
||||||
- 懒加载 chunk 加载失败(网络 / CSP / chunk 缺失) → `Suspense` 内部 promise reject → `PageErrorBoundary` 捕获
|
|
||||||
- 页面渲染期同步错误(组件抛错、null 引用等) → `PageErrorBoundary` 捕获
|
|
||||||
- 事件回调或 Promise 中的异步错误 → React 错误边界不捕获(固有行为,本次不处理)
|
|
||||||
|
|
||||||
### 恢复路径
|
|
||||||
- **页面切换自动重置**:用户从错误页切换到其他页面 → `currentPage` 变化 → `resetKey` 变化 → `PageErrorBoundary.componentDidUpdate` 重置 → 新页面正常渲染
|
|
||||||
- **当前页重试**:用户点击"重试" → 内部 state 重置 → 子树重新挂载 → React 重新触发 `lazy()` 加载(懒加载失败时也会重新发起 `import()`)
|
|
||||||
- **持续错误**:若 `lazy()` chunk 始终无法加载(例如永久 404),重试会再次显示错误卡片;用户可切换到其他页面继续使用其他工具
|
|
||||||
|
|
||||||
### 日志
|
|
||||||
沿用 `console.error`;不引入 Sentry / 外部上报。
|
|
||||||
|
|
||||||
## 测试策略
|
|
||||||
|
|
||||||
### 新增 `components/__tests__/PageErrorBoundary.test.tsx`
|
|
||||||
|
|
||||||
1. 正常渲染:子组件正常渲染时,输出原始 children
|
|
||||||
2. 同步错误捕获:子组件抛错时,显示错误卡片(标题"该页面加载失败"、错误信息)
|
|
||||||
3. 重试按钮恢复:错误状态下,将 children 替换为正常组件,点击"重试"按钮,重置状态并显示正常内容
|
|
||||||
4. `resetKey` 变化自动重置:错误状态下 `resetKey` 变化时,自动清空错误并渲染新 children
|
|
||||||
5. `resetKey` 不变保持错误:children 变化但 `resetKey` 未变化时,保持错误状态(避免误重置)
|
|
||||||
6. 错误信息显示:错误的 `toString()` 内容能在 UI 中可见
|
|
||||||
|
|
||||||
### 新增 `components/__tests__/RouterContainer.test.tsx`
|
|
||||||
|
|
||||||
- 通过 mock `useRouter` 与 `FEATURES`,注入一个会抛错的懒加载组件,验证 `PageErrorBoundary` 捕获错误且 TopBar / 父容器 DOM 仍存在
|
|
||||||
- 切换 `currentPage`(重新触发 hook 返回值)后验证错误自动清除、新页面正常渲染
|
|
||||||
|
|
||||||
### 不变
|
|
||||||
- `components/__tests__/ErrorBoundary.test.tsx` 不需改动(全局边界行为未变)
|
|
||||||
|
|
||||||
### 风格
|
|
||||||
遵循现有 `ErrorBoundary.test.tsx` 模式:`vi.spyOn(console, 'error')` 抑制噪声 + `@testing-library/react` 的 `render` + `screen.getByText` 断言。
|
|
||||||
|
|
||||||
## 文件清单
|
|
||||||
|
|
||||||
**新增:**
|
|
||||||
- `components/PageErrorBoundary.tsx`
|
|
||||||
- `components/__tests__/PageErrorBoundary.test.tsx`
|
|
||||||
- `components/__tests__/RouterContainer.test.tsx`
|
|
||||||
|
|
||||||
**修改:**
|
|
||||||
- `components/RouterContainer.tsx` — 在 `Suspense` 内层包裹 `PageErrorBoundary resetKey={currentPage}`
|
|
||||||
- `entrypoints/options/App.tsx` — 将 `<ErrorBoundary>` 替换为 `<PageErrorBoundary>`
|
|
||||||
|
|
||||||
**不变:**
|
|
||||||
- `components/ErrorBoundary.tsx`
|
|
||||||
- `components/__tests__/ErrorBoundary.test.tsx`
|
|
||||||
- `entrypoints/popup/App.tsx`、`entrypoints/sidepanel/App.tsx`
|
|
||||||
- `config/features.tsx`
|
|
||||||
|
|
||||||
## 验收标准
|
|
||||||
|
|
||||||
1. 单页面在懒加载或渲染时抛错,仅当前页面区域显示错误卡片,TopBar 与导航仍可点击
|
|
||||||
2. 在错误状态下切换到其他工具,新页面能正常加载与显示
|
|
||||||
3. 点击错误卡片中的"重试"按钮,子树重新挂载并重新触发懒加载
|
|
||||||
4. 全局 `ErrorBoundary` 仍能捕获 TopBar / Snackbar 等同级组件的错误
|
|
||||||
5. 所有新增测试通过;现有 `ErrorBoundary` 测试不受影响;`npm run compile`、`npm run lint`、`npm run test` 均通过
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
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 {
|
|
||||||
// 检查侧边栏(Side Panel)的挂载激活状态
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 💡 核心自愈机制:保存数据到共享沙箱 Storage,Popup 打开后(无论是自动还是手动)都会读取
|
|
||||||
await saveContextMenuData({ featureKey, payload });
|
|
||||||
|
|
||||||
// 打开 popup 弹窗
|
|
||||||
try {
|
|
||||||
await browser.action.openPopup();
|
|
||||||
} catch (err) {
|
|
||||||
// 💡 修复点:自动打开 Popup 失败时,绝对不能将 pendingData 撕毁!
|
|
||||||
// 保持数据留在 storage 内部,由于 Service Worker 的持久化,用户之后不管什么时候手动点开图标,
|
|
||||||
// 数据依旧完好如初,完美契合了你的设计注释!
|
|
||||||
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. 异步刷新请求监听
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 如果小于 1000ms(短抖动缓冲),可以使用极轻量级 setTimeout 防御
|
|
||||||
// 如果是秒级以上的延时,为防止 Service Worker 闲置被内核销毁,应当使用 Alarms 沙箱驱动
|
|
||||||
if (delay > 0 && delay < 1000) {
|
|
||||||
setTimeout(executeReload, delay);
|
|
||||||
} else if (delay >= 1000) {
|
|
||||||
const alarmName = `reload-tab-${tabId}-${Date.now()}`;
|
|
||||||
|
|
||||||
// 创建一个临时的一次性 Alarm 闹钟
|
|
||||||
await browser.alarms.create(alarmName, { when: Date.now() + delay });
|
|
||||||
|
|
||||||
// 动态注册一个一次性的生命周期续航守卫
|
|
||||||
const alarmListener = (alarm: { name: string }) => {
|
|
||||||
if (alarm.name === alarmName) {
|
|
||||||
executeReload();
|
|
||||||
browser.alarms.onAlarm.removeListener(alarmListener);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
browser.alarms.onAlarm.addListener(alarmListener);
|
|
||||||
} else {
|
|
||||||
executeReload();
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true, message: '刷新请求已通过常驻 Service Worker 安全隔离区' };
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,390 +0,0 @@
|
|||||||
import { SyntheticEvent, useEffect, useMemo, useState } from 'react';
|
|
||||||
import { GripVertical, RefreshCw, Settings } 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 PageHeader from '@/components/PageHeader';
|
|
||||||
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">
|
|
||||||
<PageHeader
|
|
||||||
icon={<Settings size={20} />}
|
|
||||||
iconColor="#1976d2"
|
|
||||||
title="应用设置"
|
|
||||||
subtitle="针对不同窗口类型独立配置 Dashboard 中显示的功能及其排序"
|
|
||||||
/>
|
|
||||||
{/* 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
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,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
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>,
|
|
||||||
);
|
|
||||||
+10
-22
@@ -5,16 +5,13 @@ 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: {
|
||||||
@@ -26,16 +23,8 @@ export default tseslint.config(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// 4. 核心业务全受控大管线(Hooks, Entrypoints, Components 统一护航)
|
|
||||||
{
|
{
|
||||||
files: [
|
files: ['src/**/*.{ts,tsx}'],
|
||||||
'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: {
|
||||||
@@ -44,38 +33,37 @@ 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,
|
||||||
},
|
},
|
||||||
|
|
||||||
// 💡 修复点 2:高精对齐 React 19 / JSX Runtime 的全量生产质检规则大闸
|
settings: {
|
||||||
|
react: {
|
||||||
|
version: 'detect',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
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,
|
||||||
|
|
||||||
// 清洗原生未消费变量冲突,统一交由 TS 高阶哨兵接管
|
'react/prop-types': 'off',
|
||||||
|
|
||||||
'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',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
@@ -1,144 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
},
|
|
||||||
"textInputArea": {
|
|
||||||
"clear": "Clear",
|
|
||||||
"copyContent": "Copy content",
|
|
||||||
"cleared": "Cleared",
|
|
||||||
"placeholder": "placeholder text"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"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",
|
|
||||||
"added": "Added",
|
|
||||||
"removed": "Removed",
|
|
||||||
"modified": "Modified"
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
{
|
|
||||||
"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",
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"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: "
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
{
|
|
||||||
"pageTitle": "Storage Cleaner",
|
|
||||||
"pageSubtitle": "Clear cache, cookies, and local storage",
|
|
||||||
"loading": "Loading...",
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
{
|
|
||||||
"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": "解码大小"
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
{
|
|
||||||
"appName": "测试工具",
|
|
||||||
"buttons": {
|
|
||||||
"save": "保存",
|
|
||||||
"cancel": "取消",
|
|
||||||
"confirm": "确认",
|
|
||||||
"copy": "复制",
|
|
||||||
"clear": "清理",
|
|
||||||
"refresh": "刷新",
|
|
||||||
"toggleLanguage": "切换语言",
|
|
||||||
"toggleTheme": "切换主题",
|
|
||||||
"themeMode": {
|
|
||||||
"light": "切换到深色模式",
|
|
||||||
"dark": "切换到系统模式",
|
|
||||||
"system": "切换到浅色模式"
|
|
||||||
},
|
|
||||||
"search": "搜索工具...",
|
|
||||||
"back": "返回",
|
|
||||||
"clearSearch": "清除搜索",
|
|
||||||
"recentSearch": "最近搜索",
|
|
||||||
"noResults": "未找到相关工具",
|
|
||||||
"openInTab": "在标签页打开",
|
|
||||||
"settings": "设置"
|
|
||||||
},
|
|
||||||
"messages": {
|
|
||||||
"copySuccess": "已复制到剪贴板",
|
|
||||||
"copyError": "复制失败"
|
|
||||||
},
|
|
||||||
"textInputArea": {
|
|
||||||
"clear": "清空",
|
|
||||||
"copyContent": "复制内容",
|
|
||||||
"cleared": "已清空",
|
|
||||||
"placeholder": "请输入文本"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
{
|
|
||||||
"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 预览"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"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 内容以查看转换结果"
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"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 后点击比较",
|
|
||||||
"added": "新增",
|
|
||||||
"removed": "删除",
|
|
||||||
"modified": "修改"
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
{
|
|
||||||
"formatTitle": "JSON 格式化",
|
|
||||||
"formatSubtitle": "美化和格式化 JSON 数据",
|
|
||||||
"inputPlaceholder": "输入需要格式化的 JSON...",
|
|
||||||
"formatButton": "格式化",
|
|
||||||
"clearButton": "清空",
|
|
||||||
"sortKeys": "键名排序",
|
|
||||||
"sortKeysTooltip": "按字母顺序对 JSON 对象键进行排序",
|
|
||||||
"indentSize": "缩进",
|
|
||||||
"indentSpaces": "{{count}} 个空格",
|
|
||||||
"outputLabel": "格式化结果",
|
|
||||||
"copySuccess": "复制成功",
|
|
||||||
"copyFail": "复制失败",
|
|
||||||
"noContent": "无内容可复制",
|
|
||||||
"invalidJson": "无效的 JSON 格式",
|
|
||||||
"emptyHint": "输入 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": "压缩"
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"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 失败:"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"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": "打印窗口已打开"
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
{
|
|
||||||
"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": "上传图片后将自动解析二维码"
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
{
|
|
||||||
"pageTitle": "存储清理",
|
|
||||||
"pageSubtitle": "清理缓存、Cookies 及本地存储",
|
|
||||||
"loading": "加载中...",
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"pageTitle": "文本统计",
|
|
||||||
"pageSubtitle": "实时分析文本的字符、单词、行数及字节大小",
|
|
||||||
"placeholder": "在此输入或粘贴文本...",
|
|
||||||
"characters": "字符数",
|
|
||||||
"words": "单词数",
|
|
||||||
"lines": "行数",
|
|
||||||
"bytes": "字节大小"
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
{
|
|
||||||
"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": "格式错误"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Generated
+144
-161
File diff suppressed because it is too large
Load Diff
@@ -36,15 +36,11 @@
|
|||||||
"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,220 +0,0 @@
|
|||||||
import { Trash2, Upload } from 'lucide-react';
|
|
||||||
import TextInputArea from '@/components/TextInputArea';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import CopyButton from '@/components/CopyButton';
|
|
||||||
import DecodeResultPaper from '@/components/DecodeResultPaper';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { downloadBlob, formatFileSize, MAX_FILE_SIZE } from '@/utils/base64Converter';
|
|
||||||
import { useStorageState } from '@/utils/useStorageState';
|
|
||||||
import type { Base64ConvertDirection } from '@/types/storage';
|
|
||||||
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
|
||||||
import { useBase64Converter } from './useBase64Converter'; // 💡 斩断重复代码
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
const isValidDirection = (val: unknown): val is Base64ConvertDirection =>
|
|
||||||
val === 'encode' || val === 'decode';
|
|
||||||
|
|
||||||
export default function FileMode() {
|
|
||||||
const { t } = useTranslation('base64Converter');
|
|
||||||
const [direction, setDirection] = useStorageState(
|
|
||||||
'base64Converter/fileMode/direction',
|
|
||||||
'encode',
|
|
||||||
isValidDirection,
|
|
||||||
);
|
|
||||||
|
|
||||||
const {
|
|
||||||
result,
|
|
||||||
info,
|
|
||||||
isLoading,
|
|
||||||
isDragging,
|
|
||||||
setIsDragging,
|
|
||||||
fileInputRef,
|
|
||||||
encodeError,
|
|
||||||
decodeInput,
|
|
||||||
setDecodeInput,
|
|
||||||
decoded,
|
|
||||||
decodeError,
|
|
||||||
decodedFileName,
|
|
||||||
setCustomFileName,
|
|
||||||
resetAll,
|
|
||||||
safeFileSelect,
|
|
||||||
} = useBase64Converter({ mode: 'file' });
|
|
||||||
|
|
||||||
const handleDownload = () => {
|
|
||||||
if (decoded) downloadBlob(decoded.blob, decodedFileName);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full flex flex-col space-y-4 animate-in fade-in duration-300">
|
|
||||||
<div className="flex h-11 items-center px-1.5 bg-secondary/40 rounded-xl border border-border/60 w-fit">
|
|
||||||
<SwitchButtonGroup
|
|
||||||
value={direction}
|
|
||||||
options={[
|
|
||||||
{ value: 'encode', label: t('encode') },
|
|
||||||
{ value: 'decode', label: t('decode') },
|
|
||||||
]}
|
|
||||||
onChange={(next) => {
|
|
||||||
if (next && next !== direction) {
|
|
||||||
resetAll();
|
|
||||||
setDirection(next);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{direction === 'encode' ? (
|
|
||||||
<div className="flex flex-col space-y-4">
|
|
||||||
<div
|
|
||||||
onDragOver={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsDragging(true);
|
|
||||||
}}
|
|
||||||
onDragLeave={() => setIsDragging(false)}
|
|
||||||
onDrop={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsDragging(false);
|
|
||||||
const file = e.dataTransfer.files[0];
|
|
||||||
if (file) safeFileSelect(file);
|
|
||||||
}}
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
className={cn(
|
|
||||||
'flex flex-col items-center justify-center min-h-[190px] border-2 border-dashed rounded-2xl p-8 cursor-pointer transition-all duration-300',
|
|
||||||
isDragging
|
|
||||||
? 'border-primary bg-primary/10'
|
|
||||||
: info
|
|
||||||
? 'border-primary/60 bg-primary/5'
|
|
||||||
: 'border-border bg-muted/40 hover:border-primary/80 hover:bg-muted/70',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
hidden
|
|
||||||
onChange={(e) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (file) safeFileSelect(file);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="w-9 h-9 border-3 border-primary/20 border-t-primary rounded-full animate-spin" />
|
|
||||||
) : info ? (
|
|
||||||
<div className="flex flex-col items-center gap-1.5 text-center">
|
|
||||||
<Upload className="w-8 h-8 text-primary animate-bounce" />
|
|
||||||
<span className="text-sm font-bold text-foreground/90 max-w-[280px] truncate">
|
|
||||||
{info.name}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-muted-foreground/80 font-mono tabular-nums">
|
|
||||||
{formatFileSize(info.size)} · {info.type}
|
|
||||||
</span>
|
|
||||||
<span className="text-[11px] font-medium text-primary/80 mt-1">
|
|
||||||
{t('clickOrDropToReplace')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col items-center gap-1.5 text-center">
|
|
||||||
<Upload className="w-8 h-8 text-muted-foreground/60" />
|
|
||||||
<span className="text-xs font-bold text-foreground/80">
|
|
||||||
{t('clickOrDropToFile')}
|
|
||||||
</span>
|
|
||||||
<span className="text-[10px] font-medium text-muted-foreground/60">
|
|
||||||
{t('maxFileSize', { max: `${MAX_FILE_SIZE / 1024 / 1024} MB` })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{encodeError && (
|
|
||||||
<div
|
|
||||||
role="alert"
|
|
||||||
className="p-3.5 bg-destructive/10 border border-destructive/20 rounded-xl text-xs font-semibold text-destructive"
|
|
||||||
>
|
|
||||||
{encodeError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{result && (
|
|
||||||
<div className="p-4 rounded-2xl bg-card border border-border shadow-sm flex flex-col space-y-3">
|
|
||||||
<div className="flex justify-between items-center select-none">
|
|
||||||
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/90">
|
|
||||||
{t('base64Output')}
|
|
||||||
</span>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<CopyButton
|
|
||||||
text={result.rawBase64}
|
|
||||||
tooltip={t('copyRaw')}
|
|
||||||
className="h-6 px-2 rounded-md border text-[10px] font-bold"
|
|
||||||
/>
|
|
||||||
<CopyButton
|
|
||||||
text={result.output}
|
|
||||||
tooltip={t('copyDataUri')}
|
|
||||||
className="h-6 px-2 rounded-md border text-[10px] font-bold"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<TextInputArea
|
|
||||||
readOnly
|
|
||||||
value={
|
|
||||||
result.output.length > 2000
|
|
||||||
? `${result.output.substring(0, 2000)}...`
|
|
||||||
: result.output
|
|
||||||
}
|
|
||||||
showClear={false}
|
|
||||||
minRows={4}
|
|
||||||
/>
|
|
||||||
<div className="flex items-center justify-between font-mono text-[10px] text-muted-foreground/70 select-none pt-1">
|
|
||||||
<div className="flex gap-4 items-center tabular-nums">
|
|
||||||
<span>
|
|
||||||
{t('originalSize')}:{' '}
|
|
||||||
<span className="font-semibold text-foreground/80">
|
|
||||||
{formatFileSize(result.originalBytes)}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span className="text-border/60">|</span>
|
|
||||||
<span>
|
|
||||||
{t('encodedSize')}:{' '}
|
|
||||||
<span className="font-semibold text-foreground/80">
|
|
||||||
{formatFileSize(result.outputBytes)}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={resetAll}
|
|
||||||
className="h-7 rounded-md text-muted-foreground hover:text-destructive text-[11px] gap-1 px-2"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
|
||||||
{t('clear')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col space-y-4">
|
|
||||||
<TextInputArea
|
|
||||||
placeholder={t('decodeBase64Placeholder')}
|
|
||||||
value={decodeInput}
|
|
||||||
onChange={setDecodeInput}
|
|
||||||
externalError={decodeError || undefined}
|
|
||||||
showClear={true}
|
|
||||||
allowCopy={true}
|
|
||||||
minRows={6}
|
|
||||||
onClear={resetAll}
|
|
||||||
/>
|
|
||||||
{decoded && (
|
|
||||||
<DecodeResultPaper
|
|
||||||
title={t('decodedFileOutput')}
|
|
||||||
mimeType={decoded.mimeType}
|
|
||||||
blobSize={decoded.blob.size}
|
|
||||||
fileName={decodedFileName}
|
|
||||||
onFileNameChange={setCustomFileName}
|
|
||||||
onDownload={handleDownload}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,259 +0,0 @@
|
|||||||
import { Image as ImageIcon, Trash2 } from 'lucide-react';
|
|
||||||
import TextInputArea from '@/components/TextInputArea';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import CopyButton from '@/components/CopyButton';
|
|
||||||
import DecodeResultPaper from '@/components/DecodeResultPaper';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { downloadBlob, formatFileSize } from '@/utils/base64Converter';
|
|
||||||
import { useStorageState } from '@/utils/useStorageState';
|
|
||||||
import type { Base64ConvertDirection } from '@/types/storage';
|
|
||||||
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
|
||||||
import { useBase64Converter } from './useBase64Converter'; // 💡 引入共享核心
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
const isValidDirection = (val: unknown): val is Base64ConvertDirection =>
|
|
||||||
val === 'encode' || val === 'decode';
|
|
||||||
|
|
||||||
export default function ImageMode() {
|
|
||||||
const { t } = useTranslation('base64Converter');
|
|
||||||
const [direction, setDirection] = useStorageState(
|
|
||||||
'base64Converter/imageMode/direction',
|
|
||||||
'encode',
|
|
||||||
isValidDirection,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 消费完全托管的核心 Hook,消灭本地多余状态机
|
|
||||||
const {
|
|
||||||
result,
|
|
||||||
info,
|
|
||||||
isLoading,
|
|
||||||
isDragging,
|
|
||||||
setIsDragging,
|
|
||||||
fileInputRef,
|
|
||||||
encodeError,
|
|
||||||
decodeInput,
|
|
||||||
setDecodeInput,
|
|
||||||
decoded,
|
|
||||||
decodeError,
|
|
||||||
decodedFileName,
|
|
||||||
setCustomFileName,
|
|
||||||
resetAll,
|
|
||||||
safeFileSelect,
|
|
||||||
} = useBase64Converter({ mode: 'image' });
|
|
||||||
|
|
||||||
const handleDirectionChange = (next: Base64ConvertDirection) => {
|
|
||||||
if (!next || next === direction) return;
|
|
||||||
resetAll();
|
|
||||||
setDirection(next);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDownload = () => {
|
|
||||||
if (decoded) downloadBlob(decoded.blob, decodedFileName);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full flex flex-col space-y-4 animate-in fade-in duration-300">
|
|
||||||
<div className="flex h-11 items-center px-1.5 bg-secondary/40 rounded-xl border border-border/60 w-fit">
|
|
||||||
<SwitchButtonGroup
|
|
||||||
value={direction}
|
|
||||||
options={[
|
|
||||||
{ value: 'encode', label: t('encode') },
|
|
||||||
{ value: 'decode', label: t('decode') },
|
|
||||||
]}
|
|
||||||
onChange={handleDirectionChange}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{direction === 'encode' ? (
|
|
||||||
<div className="flex flex-col space-y-4">
|
|
||||||
{/* 图片拖拽投递箱终端 */}
|
|
||||||
<div
|
|
||||||
onDragOver={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsDragging(true);
|
|
||||||
}}
|
|
||||||
onDragLeave={() => setIsDragging(false)}
|
|
||||||
onDrop={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsDragging(false);
|
|
||||||
const file = e.dataTransfer.files[0];
|
|
||||||
if (file) safeFileSelect(file);
|
|
||||||
}}
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
className={cn(
|
|
||||||
'flex flex-col items-center justify-center min-h-[190px] border-2 border-dashed rounded-2xl p-8 cursor-pointer transition-all duration-300',
|
|
||||||
isDragging
|
|
||||||
? 'border-primary bg-primary/10'
|
|
||||||
: info
|
|
||||||
? 'border-primary/60 bg-primary/5'
|
|
||||||
: 'border-border bg-muted/40 hover:border-primary/80 hover:bg-muted/70',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
hidden
|
|
||||||
onChange={(e) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (file) safeFileSelect(file);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="w-9 h-9 border-3 border-primary/20 border-t-primary rounded-full animate-spin" />
|
|
||||||
) : info ? (
|
|
||||||
<div className="flex flex-col items-center gap-1.5 text-center animate-in fade-in duration-200 w-full">
|
|
||||||
{result && (
|
|
||||||
<div className="relative p-1 border border-border bg-background rounded-lg shadow-sm mb-1 max-w-[180px] overflow-hidden">
|
|
||||||
<img
|
|
||||||
src={result.output}
|
|
||||||
alt="preview"
|
|
||||||
className="max-h-32 w-full object-contain rounded"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<span className="text-sm font-bold text-foreground/90 max-w-[280px] truncate">
|
|
||||||
{info.name}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-muted-foreground/80 font-mono tabular-nums">
|
|
||||||
{formatFileSize(info.size)} · {info.type}
|
|
||||||
</span>
|
|
||||||
<span className="text-[11px] font-medium text-primary/80 mt-1">
|
|
||||||
{t('clickOrDropToReplace')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col items-center gap-1.5 text-center">
|
|
||||||
<ImageIcon className="w-8 h-8 text-muted-foreground/60" />
|
|
||||||
<span className="text-xs font-bold text-foreground/80">
|
|
||||||
{t('clickOrDropToImage')}
|
|
||||||
</span>
|
|
||||||
<span className="text-[10px] font-medium text-muted-foreground/60">
|
|
||||||
{t('supportedFormats')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{encodeError && (
|
|
||||||
<div
|
|
||||||
role="alert"
|
|
||||||
className="p-3.5 bg-destructive/10 border border-destructive/20 rounded-xl text-xs font-semibold text-destructive tracking-wide"
|
|
||||||
>
|
|
||||||
{encodeError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{result && (
|
|
||||||
<div className="p-4 rounded-2xl bg-card border border-border shadow-sm flex flex-col space-y-3">
|
|
||||||
<div className="flex justify-between items-center select-none">
|
|
||||||
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/90">
|
|
||||||
{t('base64Output')}
|
|
||||||
</span>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<CopyButton
|
|
||||||
text={result.rawBase64}
|
|
||||||
tooltip={t('copyRaw')}
|
|
||||||
className="h-6 px-2 rounded-md border text-[10px] font-bold"
|
|
||||||
/>
|
|
||||||
<CopyButton
|
|
||||||
text={result.output}
|
|
||||||
tooltip={t('copyDataUri')}
|
|
||||||
className="h-6 px-2 rounded-md border text-[10px] font-bold"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TextInputArea
|
|
||||||
readOnly
|
|
||||||
value={
|
|
||||||
result.output.length > 2000
|
|
||||||
? `${result.output.substring(0, 2000)}...`
|
|
||||||
: result.output
|
|
||||||
}
|
|
||||||
showClear={false}
|
|
||||||
minRows={4}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between font-mono text-[10px] text-muted-foreground/70 select-none pt-1">
|
|
||||||
<div className="flex gap-4 items-center tabular-nums">
|
|
||||||
<span>
|
|
||||||
{t('originalSize')}:{' '}
|
|
||||||
<span className="font-semibold text-foreground/80">
|
|
||||||
{formatFileSize(result.originalBytes)}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span className="text-border/60">|</span>
|
|
||||||
<span>
|
|
||||||
{t('encodedSize')}:{' '}
|
|
||||||
<span className="font-semibold text-foreground/80">
|
|
||||||
{formatFileSize(result.outputBytes)}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={resetAll}
|
|
||||||
className="h-7 rounded-md text-muted-foreground hover:text-destructive text-[11px] gap-1 px-2"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
|
||||||
{t('clear')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{info && !result && (
|
|
||||||
<div className="flex justify-end select-none">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={resetAll}
|
|
||||||
className="h-8 rounded-md text-xs gap-1.5 text-destructive hover:text-destructive hover:bg-destructive/5 dark:hover:bg-destructive/10"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
|
||||||
{t('clear')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col space-y-4">
|
|
||||||
<TextInputArea
|
|
||||||
placeholder={t('decodeBase64Placeholder')}
|
|
||||||
value={decodeInput}
|
|
||||||
onChange={setDecodeInput}
|
|
||||||
externalError={decodeError || undefined}
|
|
||||||
showClear={true}
|
|
||||||
allowCopy={true}
|
|
||||||
minRows={6}
|
|
||||||
onClear={resetAll}
|
|
||||||
/>
|
|
||||||
{decoded && (
|
|
||||||
<div className="animate-in slide-in-from-bottom-2 duration-300">
|
|
||||||
<DecodeResultPaper
|
|
||||||
title={t('decodedImageOutput')}
|
|
||||||
mimeType={decoded.mimeType}
|
|
||||||
blobSize={decoded.blob.size}
|
|
||||||
fileName={decodedFileName}
|
|
||||||
onFileNameChange={setCustomFileName}
|
|
||||||
onDownload={handleDownload}
|
|
||||||
>
|
|
||||||
<div className="relative p-1.5 border border-border bg-background dark:bg-muted/10 rounded-xl max-w-[220px] mb-3 overflow-hidden shadow-sm">
|
|
||||||
<img
|
|
||||||
src={`data:${decoded.mimeType};base64,${decoded.rawBase64}`}
|
|
||||||
alt="decoded preview"
|
|
||||||
className="max-h-40 w-full rounded-lg object-contain bg-[linear-gradient(45deg,#ccc_25%,transparent_25%),linear-gradient(-45deg,#ccc_25%,transparent_25%),linear-gradient(45deg,transparent_75%,#ccc_75%),linear-gradient(-45deg,transparent_75%,#ccc_75%)] bg-[size:10px_10px] bg-[position:0_0,0_5px,5px_-5px,-5px_0] dark:bg-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</DecodeResultPaper>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
||||||
import FileMode from '../FileMode';
|
|
||||||
|
|
||||||
// Mock CopyButton
|
|
||||||
vi.mock('@/components/CopyButton', () => ({
|
|
||||||
default: ({ text, tooltip }: { text: string; tooltip?: string }) => (
|
|
||||||
<button data-testid="copy-button" data-tooltip={tooltip}>
|
|
||||||
{text.slice(0, 20)}
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
localStorage.clear();
|
|
||||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
// useStorageState's async loadState may overwrite user toggle if we click before the
|
|
||||||
// initial chrome.storage read settles. Flush pending microtasks first.
|
|
||||||
const waitForStorageReady = () => act(() => Promise.resolve());
|
|
||||||
|
|
||||||
describe('FileMode', () => {
|
|
||||||
it('应该渲染文件上传区域', async () => {
|
|
||||||
render(<FileMode />);
|
|
||||||
await waitForStorageReady();
|
|
||||||
expect(screen.getByText('clickOrDropToFile')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('maxFileSize')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应该处理有效的文件选择', async () => {
|
|
||||||
render(<FileMode />);
|
|
||||||
await waitForStorageReady();
|
|
||||||
|
|
||||||
const file = new File(['test content'], 'test.txt', { type: 'text/plain' });
|
|
||||||
|
|
||||||
// 文件输入是隐藏的,直接触发 change 事件
|
|
||||||
const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
|
||||||
fireEvent.change(hiddenInput, { target: { files: [file] } });
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('test.txt')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('base64Output')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应该拒绝超出大小限制的文件', async () => {
|
|
||||||
render(<FileMode />);
|
|
||||||
await waitForStorageReady();
|
|
||||||
|
|
||||||
// 创建一个超过 10MB 的文件
|
|
||||||
const largeContent = new Uint8Array(11 * 1024 * 1024);
|
|
||||||
const file = new File([largeContent], 'large.bin', { type: 'application/octet-stream' });
|
|
||||||
|
|
||||||
const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
|
||||||
fireEvent.change(hiddenInput, { target: { files: [file] } });
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByRole('alert')).toHaveTextContent('fileSizeExceeded');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('点击清除按钮应该清空文件状态', async () => {
|
|
||||||
render(<FileMode />);
|
|
||||||
await waitForStorageReady();
|
|
||||||
|
|
||||||
const file = new File(['test'], 'test.txt', { type: 'text/plain' });
|
|
||||||
const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
|
||||||
fireEvent.change(hiddenInput, { target: { files: [file] } });
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('test.txt')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByText('clear'));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.queryByText('test.txt')).not.toBeInTheDocument();
|
|
||||||
expect(screen.getByText('clickOrDropToFile')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应该显示文件大小和类型信息', async () => {
|
|
||||||
render(<FileMode />);
|
|
||||||
await waitForStorageReady();
|
|
||||||
|
|
||||||
const file = new File(['test content'], 'test.txt', { type: 'text/plain' });
|
|
||||||
const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
|
||||||
fireEvent.change(hiddenInput, { target: { files: [file] } });
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('test.txt')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
// 文件类型显示在 caption 中,格式为 "size · type"
|
|
||||||
expect(screen.getByText(/test\.txt/)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应该显示原始大小和编码大小', async () => {
|
|
||||||
render(<FileMode />);
|
|
||||||
await waitForStorageReady();
|
|
||||||
|
|
||||||
const file = new File(['test content'], 'test.txt', { type: 'text/plain' });
|
|
||||||
const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
|
||||||
fireEvent.change(hiddenInput, { target: { files: [file] } });
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText(/originalSize/)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/encodedSize/)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应该提供复制按钮', async () => {
|
|
||||||
render(<FileMode />);
|
|
||||||
await waitForStorageReady();
|
|
||||||
|
|
||||||
const file = new File(['test'], 'test.txt', { type: 'text/plain' });
|
|
||||||
const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
|
||||||
fireEvent.change(hiddenInput, { target: { files: [file] } });
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
const copyButtons = screen.getAllByTestId('copy-button');
|
|
||||||
expect(copyButtons.length).toBeGreaterThanOrEqual(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应该渲染 encode/decode 切换按钮', () => {
|
|
||||||
render(<FileMode />);
|
|
||||||
expect(screen.getByText('encode')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('decode')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('切到 decode 应该显示 Base64 输入框', async () => {
|
|
||||||
render(<FileMode />);
|
|
||||||
await waitForStorageReady();
|
|
||||||
fireEvent.click(screen.getByText('decode'));
|
|
||||||
expect(await screen.findByPlaceholderText('decodeBase64Placeholder')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('解码 PDF Base64 后应该显示 application/pdf 与默认文件名 decoded.pdf', async () => {
|
|
||||||
render(<FileMode />);
|
|
||||||
await waitForStorageReady();
|
|
||||||
fireEvent.click(screen.getByText('decode'));
|
|
||||||
|
|
||||||
const input = await screen.findByPlaceholderText('decodeBase64Placeholder');
|
|
||||||
fireEvent.change(input, { target: { value: 'JVBERi0K' } });
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
vi.advanceTimersByTime(250);
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('decodedFileOutput')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/application\/pdf/)).toBeInTheDocument();
|
|
||||||
expect(screen.getByDisplayValue('decoded.pdf')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('解码后的文件名应该可编辑', async () => {
|
|
||||||
render(<FileMode />);
|
|
||||||
await waitForStorageReady();
|
|
||||||
fireEvent.click(screen.getByText('decode'));
|
|
||||||
|
|
||||||
const input = await screen.findByPlaceholderText('decodeBase64Placeholder');
|
|
||||||
fireEvent.change(input, { target: { value: 'JVBERi0K' } });
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
vi.advanceTimersByTime(250);
|
|
||||||
});
|
|
||||||
|
|
||||||
const filenameInput = (await screen.findByDisplayValue('decoded.pdf')) as HTMLInputElement;
|
|
||||||
fireEvent.change(filenameInput, { target: { value: 'my-report.pdf' } });
|
|
||||||
expect(filenameInput.value).toBe('my-report.pdf');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('解码后应该显示下载按钮', async () => {
|
|
||||||
render(<FileMode />);
|
|
||||||
await waitForStorageReady();
|
|
||||||
fireEvent.click(screen.getByText('decode'));
|
|
||||||
|
|
||||||
const input = await screen.findByPlaceholderText('decodeBase64Placeholder');
|
|
||||||
fireEvent.change(input, { target: { value: 'JVBERi0K' } });
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
vi.advanceTimersByTime(250);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(await screen.findByText('download')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('解码非法 Base64 应该显示 invalidBase64 错误', async () => {
|
|
||||||
render(<FileMode />);
|
|
||||||
await waitForStorageReady();
|
|
||||||
fireEvent.click(screen.getByText('decode'));
|
|
||||||
|
|
||||||
const input = await screen.findByPlaceholderText('decodeBase64Placeholder');
|
|
||||||
fireEvent.change(input, { target: { value: '!!!not base64' } });
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
vi.advanceTimersByTime(250);
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('invalidBase64')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('切换方向时应该清空解码状态', async () => {
|
|
||||||
render(<FileMode />);
|
|
||||||
await waitForStorageReady();
|
|
||||||
fireEvent.click(screen.getByText('decode'));
|
|
||||||
|
|
||||||
const input = await screen.findByPlaceholderText('decodeBase64Placeholder');
|
|
||||||
fireEvent.change(input, { target: { value: 'JVBERi0K' } });
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
vi.advanceTimersByTime(250);
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('decodedFileOutput')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByText('encode'));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.queryByText('decodedFileOutput')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
||||||
import ImageMode from '../ImageMode';
|
|
||||||
|
|
||||||
// Mock CopyButton
|
|
||||||
vi.mock('@/components/CopyButton', () => ({
|
|
||||||
default: ({ text, tooltip }: { text: string; tooltip?: string }) => (
|
|
||||||
<button data-testid="copy-button" data-tooltip={tooltip}>
|
|
||||||
{text.slice(0, 20)}
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
localStorage.clear();
|
|
||||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
const waitForStorageReady = () => act(() => Promise.resolve());
|
|
||||||
|
|
||||||
describe('ImageMode', () => {
|
|
||||||
it('应该渲染图像上传区域', async () => {
|
|
||||||
render(<ImageMode />);
|
|
||||||
await waitForStorageReady();
|
|
||||||
expect(screen.getByText('clickOrDropToImage')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('supportedFormats')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应该接受有效的图像文件', async () => {
|
|
||||||
render(<ImageMode />);
|
|
||||||
await waitForStorageReady();
|
|
||||||
|
|
||||||
const file = new File(['fake-image-data'], 'test.png', { type: 'image/png' });
|
|
||||||
const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
|
||||||
fireEvent.change(hiddenInput, { target: { files: [file] } });
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('test.png')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('base64Output')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应该拒绝非图像文件', async () => {
|
|
||||||
render(<ImageMode />);
|
|
||||||
await waitForStorageReady();
|
|
||||||
|
|
||||||
const file = new File(['not an image'], 'test.txt', { type: 'text/plain' });
|
|
||||||
const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
|
||||||
fireEvent.change(hiddenInput, { target: { files: [file] } });
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByRole('alert')).toHaveTextContent('unsupportedImageType');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应该拒绝超出大小限制的图像', async () => {
|
|
||||||
render(<ImageMode />);
|
|
||||||
await waitForStorageReady();
|
|
||||||
|
|
||||||
const largeContent = new Uint8Array(11 * 1024 * 1024);
|
|
||||||
const file = new File([largeContent], 'large.png', { type: 'image/png' });
|
|
||||||
|
|
||||||
const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
|
||||||
fireEvent.change(hiddenInput, { target: { files: [file] } });
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByRole('alert')).toHaveTextContent('fileSizeExceeded');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应该通过扩展名识别图像', async () => {
|
|
||||||
render(<ImageMode />);
|
|
||||||
await waitForStorageReady();
|
|
||||||
|
|
||||||
// 没有 MIME 类型但有正确扩展名
|
|
||||||
const file = new File(['fake'], 'test.jpg');
|
|
||||||
const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
|
||||||
fireEvent.change(hiddenInput, { target: { files: [file] } });
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('test.jpg')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('点击清除按钮应该清空图像状态', async () => {
|
|
||||||
render(<ImageMode />);
|
|
||||||
await waitForStorageReady();
|
|
||||||
|
|
||||||
const file = new File(['fake'], 'test.png', { type: 'image/png' });
|
|
||||||
const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
|
||||||
fireEvent.change(hiddenInput, { target: { files: [file] } });
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('test.png')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByText('clear'));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.queryByText('test.png')).not.toBeInTheDocument();
|
|
||||||
expect(screen.getByText('clickOrDropToImage')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应该显示图像预览', async () => {
|
|
||||||
render(<ImageMode />);
|
|
||||||
await waitForStorageReady();
|
|
||||||
|
|
||||||
const file = new File(['fake-image'], 'test.png', { type: 'image/png' });
|
|
||||||
const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
|
||||||
fireEvent.change(hiddenInput, { target: { files: [file] } });
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
const img = screen.getByAltText('preview');
|
|
||||||
expect(img).toBeInTheDocument();
|
|
||||||
expect(img.tagName.toLowerCase()).toBe('img');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应该渲染 encode/decode 切换按钮', async () => {
|
|
||||||
render(<ImageMode />);
|
|
||||||
await waitForStorageReady();
|
|
||||||
expect(screen.getByText('encode')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('decode')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('解码 PNG Base64 后应该显示图像预览', async () => {
|
|
||||||
render(<ImageMode />);
|
|
||||||
await waitForStorageReady();
|
|
||||||
fireEvent.click(screen.getByText('decode'));
|
|
||||||
|
|
||||||
const input = await screen.findByPlaceholderText('decodeBase64Placeholder');
|
|
||||||
fireEvent.change(input, { target: { value: 'iVBORw0KGgo=' } });
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
vi.advanceTimersByTime(250);
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('decodedImageOutput')).toBeInTheDocument();
|
|
||||||
const img = screen.getByAltText('decoded preview');
|
|
||||||
expect(img).toBeInTheDocument();
|
|
||||||
expect(img.tagName.toLowerCase()).toBe('img');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('解码后默认文件名应该为 decoded.png', async () => {
|
|
||||||
render(<ImageMode />);
|
|
||||||
await waitForStorageReady();
|
|
||||||
fireEvent.click(screen.getByText('decode'));
|
|
||||||
|
|
||||||
const input = await screen.findByPlaceholderText('decodeBase64Placeholder');
|
|
||||||
fireEvent.change(input, { target: { value: 'iVBORw0KGgo=' } });
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
vi.advanceTimersByTime(250);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(await screen.findByDisplayValue('decoded.png')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('解码非法 Base64 应该显示 invalidBase64 错误', async () => {
|
|
||||||
render(<ImageMode />);
|
|
||||||
await waitForStorageReady();
|
|
||||||
fireEvent.click(screen.getByText('decode'));
|
|
||||||
|
|
||||||
const input = await screen.findByPlaceholderText('decodeBase64Placeholder');
|
|
||||||
fireEvent.change(input, { target: { value: '!!!not base64' } });
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
vi.advanceTimersByTime(250);
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('invalidBase64')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import { useRouter } from '@/providers/RouterProvider';
|
|
||||||
import ToolCard from '@/pages/Dashboard/ToolCard';
|
|
||||||
import { getFeatureByKey } from '@/config/features';
|
|
||||||
import type { PageType } from '@/types/storage';
|
|
||||||
import { useMemo } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
export default function DashboardPage() {
|
|
||||||
const { navigateTo, visiblePages, pageOrder } = useRouter();
|
|
||||||
const { t } = useTranslation(['features']);
|
|
||||||
|
|
||||||
const visibleSet = useMemo(() => new Set<string>(visiblePages), [visiblePages]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'grid grid-cols-1 sm:grid-cols-[repeat(auto-fill,minmax(290px,1fr))] auto-rows-auto gap-3.5 p-3.5 w-full h-auto',
|
|
||||||
'animate-in fade-in duration-300 select-none',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{pageOrder.map((key) => {
|
|
||||||
if (!visibleSet.has(key)) return null;
|
|
||||||
|
|
||||||
const feature = getFeatureByKey(key);
|
|
||||||
if (!feature?.themeColorKey || feature.icon == null) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ToolCard
|
|
||||||
key={key}
|
|
||||||
title={t(feature.labelKey)}
|
|
||||||
description={t(feature.descriptionKey)}
|
|
||||||
colorKey={feature.themeColorKey}
|
|
||||||
icon={feature.icon}
|
|
||||||
onNavigate={() => navigateTo(key as PageType)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
import { useCallback, useMemo, useState } from 'react';
|
|
||||||
import { Code, Download, Trash2 } from 'lucide-react';
|
|
||||||
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
|
||||||
import PageHeader from '@/components/PageHeader';
|
|
||||||
import CopyButton from '@/components/CopyButton';
|
|
||||||
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
|
||||||
import { Button } from '@/components/ui/button'; // 💡 1. 全面回归规范:引入原生的 shadcn 原子 Button
|
|
||||||
import { useStorageState } from '@/utils/useStorageState';
|
|
||||||
import type { HtmlToMarkdownPreviewMode } from '@/types/storage';
|
|
||||||
import { downloadMarkdownFile, htmlToMarkdown, SAMPLE_HTML } from '@/utils/htmlToMarkdown';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
const isValidPreviewMode = (val: unknown): val is HtmlToMarkdownPreviewMode =>
|
|
||||||
typeof val === 'string' && ['split', 'preview', 'markdown'].includes(val);
|
|
||||||
|
|
||||||
export default function HtmlToMarkdownPage() {
|
|
||||||
const { t } = useLazyTranslation('htmlToMarkdown');
|
|
||||||
const [previewMode, setPreviewMode] = useStorageState(
|
|
||||||
'htmlToMarkdown/previewMode',
|
|
||||||
'split' as HtmlToMarkdownPreviewMode,
|
|
||||||
isValidPreviewMode,
|
|
||||||
);
|
|
||||||
const [html, setHtml] = useState(SAMPLE_HTML);
|
|
||||||
|
|
||||||
const result = useMemo(() => htmlToMarkdown(html), [html]);
|
|
||||||
const error = result.hasError ? (result.error ?? null) : null;
|
|
||||||
|
|
||||||
const handleModeChange = useCallback(
|
|
||||||
(newMode: HtmlToMarkdownPreviewMode) => {
|
|
||||||
setPreviewMode(newMode);
|
|
||||||
},
|
|
||||||
[setPreviewMode],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleClear = useCallback(() => {
|
|
||||||
setHtml('');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleDownload = useCallback(() => {
|
|
||||||
if (result.markdown) {
|
|
||||||
downloadMarkdownFile(result.markdown, 'converted.md');
|
|
||||||
}
|
|
||||||
}, [result.markdown]);
|
|
||||||
|
|
||||||
const showInput = previewMode !== 'preview';
|
|
||||||
const showOutput = previewMode !== 'markdown';
|
|
||||||
|
|
||||||
return (
|
|
||||||
/* 💡 统一间距尺寸:
|
|
||||||
- 彻底清除多余的 container max-w-7xl 这种网页大边距,
|
|
||||||
- 统一收拢为我们先前在 Dashboard 页、JSON 工具箱制定的 p-4 space-y-4 标准极客桌面规格。
|
|
||||||
*/
|
|
||||||
<div className="p-4 w-full flex flex-col space-y-4 select-none animate-in fade-in duration-300">
|
|
||||||
<PageHeader
|
|
||||||
title={t('pageTitle')}
|
|
||||||
subtitle={t('pageSubtitle')}
|
|
||||||
icon={<Code className="h-4 w-4" />}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
{/* 工具栏集成区 */}
|
|
||||||
<div className="flex flex-col sm:flex-row gap-3 justify-between items-stretch sm:items-center bg-secondary/40 rounded-xl border border-border/60 px-1.5 py-1.5 sm:h-12">
|
|
||||||
<SwitchButtonGroup
|
|
||||||
value={previewMode}
|
|
||||||
options={[
|
|
||||||
{ value: 'split', label: t('splitMode') },
|
|
||||||
{ value: 'preview', label: t('previewMode') },
|
|
||||||
{ value: 'markdown', label: t('markdownMode') },
|
|
||||||
]}
|
|
||||||
onChange={handleModeChange}
|
|
||||||
size="small"
|
|
||||||
className="w-full sm:w-auto"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex gap-2 shrink-0">
|
|
||||||
{/* 2. 重塑下载按钮:接入受控 Button,追加 active 物理微缩放动效 */}
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleDownload}
|
|
||||||
disabled={!result.markdown}
|
|
||||||
className="h-8 rounded-md font-medium text-xs gap-1.5 shadow-sm active:scale-95 transition-all"
|
|
||||||
>
|
|
||||||
<Download className="h-3.5 w-3.5" />
|
|
||||||
{t('download')}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* 重塑清空按钮 */}
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleClear}
|
|
||||||
className="h-8 rounded-md font-medium text-xs gap-1.5 shadow-sm active:scale-95 text-destructive hover:text-destructive hover:bg-destructive/5 dark:hover:bg-destructive/10 border-input/60 transition-all"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
{t('clear')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 错误提示:
|
|
||||||
- 💡 核心修复点:将硬编码的 bg-red-50 实色,完美超进化为系统的全自适应透明色变体
|
|
||||||
*/}
|
|
||||||
{error && (
|
|
||||||
<div className="p-3.5 bg-destructive/10 border border-destructive/20 rounded-xl text-destructive text-xs font-semibold tracking-wide animate-in shake duration-300">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 双翼/单栏联动面板展示区 */}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'grid gap-4 min-h-[460px] w-full',
|
|
||||||
showInput && showOutput ? 'grid-cols-1 md:grid-cols-2' : 'grid-cols-1',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* HTML 输入端卡片面板 */}
|
|
||||||
{showInput && (
|
|
||||||
/* 3. 智能聚焦框联动(Focus Ring Clamping):
|
|
||||||
- 外层容器追加 focus-within 变量追踪大闸。
|
|
||||||
- 只要用户用鼠标点击了内部的 textarea,外层整块精巧的圆角大边框会一帧内亮起 primary 系统的深色呼吸发光环,
|
|
||||||
- 这种“全外包裹层框聚焦”的体验极大模仿了本地原生 IDE 的硬核专业体验!
|
|
||||||
*/
|
|
||||||
<div className="border border-border rounded-xl bg-card text-card-foreground shadow-sm overflow-hidden flex flex-col transition-all duration-200 focus-within:ring-1 focus-within:ring-ring focus-within:border-ring">
|
|
||||||
{/* 卡片头部:改用标准的灰色 bg-muted/50 */}
|
|
||||||
<div className="flex h-9 items-center justify-between px-4 bg-muted/50 border-b border-border select-none">
|
|
||||||
<span className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider">
|
|
||||||
{t('inputLabel')}
|
|
||||||
</span>
|
|
||||||
<span className="text-[10px] font-mono text-muted-foreground/60 tabular-nums">
|
|
||||||
{t('charCount', { count: html.length })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<textarea
|
|
||||||
value={html}
|
|
||||||
onChange={(e) => setHtml(e.target.value)}
|
|
||||||
placeholder={t('inputPlaceholder')}
|
|
||||||
className="flex-1 min-h-[380px] p-4 bg-transparent font-mono text-xs leading-relaxed resize-none focus:outline-none text-foreground/90 select-text"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Markdown 输出端卡片面板 */}
|
|
||||||
{showOutput && (
|
|
||||||
<div className="border border-border rounded-xl bg-card text-card-foreground shadow-sm overflow-hidden flex flex-col">
|
|
||||||
<div className="flex h-9 items-center justify-between px-4 bg-muted/50 border-b border-border select-none">
|
|
||||||
<span className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider">
|
|
||||||
{(previewMode as string) === 'markdown'
|
|
||||||
? t('markdownOutputLabel')
|
|
||||||
: t('previewLabel')}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-[10px] font-mono text-muted-foreground/60 tabular-nums">
|
|
||||||
{t('charCount', { count: result.markdownLength })}
|
|
||||||
</span>
|
|
||||||
<CopyButton
|
|
||||||
text={result.markdown}
|
|
||||||
className="h-6 w-6 rounded-md border text-muted-foreground"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(previewMode as string) === 'markdown' ? (
|
|
||||||
<textarea
|
|
||||||
value={result.markdown}
|
|
||||||
readOnly
|
|
||||||
className="flex-1 min-h-[380px] p-4 font-mono text-xs leading-relaxed resize-none focus:outline-none bg-muted/30 dark:bg-muted/10 text-foreground/80 select-text"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex-1 p-4 min-h-[380px] overflow-auto bg-transparent font-mono text-xs leading-relaxed whitespace-pre-wrap break-all text-foreground/90 select-text">
|
|
||||||
{result.markdown || (
|
|
||||||
<span className="text-muted-foreground/70 italic text-[11px] font-sans">
|
|
||||||
{t('emptyHint')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,311 +0,0 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
||||||
import { Code, Download, Printer, Trash2 } from 'lucide-react';
|
|
||||||
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
|
||||||
import PageHeader from '@/components/PageHeader';
|
|
||||||
import CopyButton from '@/components/CopyButton';
|
|
||||||
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { useStorageState } from '@/utils/useStorageState';
|
|
||||||
import type { MarkdownToHtmlPreviewMode } from '@/types/storage';
|
|
||||||
import {
|
|
||||||
downloadHtmlFile,
|
|
||||||
markdownToHtml,
|
|
||||||
printHtml,
|
|
||||||
SAMPLE_MARKDOWN,
|
|
||||||
wrapHtmlDocument,
|
|
||||||
} from '@/utils/markdownToHtml';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
const isValidPreviewMode = (val: unknown): val is MarkdownToHtmlPreviewMode =>
|
|
||||||
typeof val === 'string' && ['split', 'preview', 'html'].includes(val);
|
|
||||||
|
|
||||||
// 💡 规范回归:保持最纯净的通用选择器集合,内部变量全部交由全局 :root 驱动
|
|
||||||
const PREVIEW_STYLES = `
|
|
||||||
.markdown-body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
line-height: 1.6;
|
|
||||||
color: var(--md-foreground);
|
|
||||||
background-color: transparent;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
.markdown-body h1, .markdown-body h2, .markdown-body h3 {
|
|
||||||
margin-top: 24px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 1.25;
|
|
||||||
color: var(--md-foreground);
|
|
||||||
}
|
|
||||||
.markdown-body h1 { border-bottom: 1px solid var(--md-border); padding-bottom: 0.3em; font-size: 1.6em; }
|
|
||||||
.markdown-body h2 { border-bottom: 1px solid var(--md-border); padding-bottom: 0.3em; font-size: 1.35em; }
|
|
||||||
.markdown-body p { margin-top: 0; margin-bottom: 16px; }
|
|
||||||
.markdown-body a { color: #3b82f6; text-decoration: none; }
|
|
||||||
.markdown-body a:hover { text-decoration: underline; }
|
|
||||||
.markdown-body code {
|
|
||||||
background-color: var(--md-code-bg);
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 85%;
|
|
||||||
padding: 0.2em 0.4em;
|
|
||||||
font-family: Menlo, Consolas, monospace;
|
|
||||||
}
|
|
||||||
.markdown-body pre {
|
|
||||||
background-color: var(--md-pre-bg);
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 85%;
|
|
||||||
line-height: 1.45;
|
|
||||||
overflow: auto;
|
|
||||||
padding: 16px;
|
|
||||||
margin: 0 0 16px;
|
|
||||||
border: 1px solid var(--md-border);
|
|
||||||
}
|
|
||||||
.markdown-body pre code {
|
|
||||||
background-color: transparent;
|
|
||||||
border: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
.markdown-body blockquote {
|
|
||||||
border-left: 0.25em solid var(--md-quote-line);
|
|
||||||
color: var(--md-muted);
|
|
||||||
margin: 0 0 16px;
|
|
||||||
padding: 0 1em;
|
|
||||||
}
|
|
||||||
.markdown-body table {
|
|
||||||
border-collapse: collapse;
|
|
||||||
width: 100%;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
.markdown-body table th, .markdown-body table td {
|
|
||||||
border: 1px solid var(--md-border);
|
|
||||||
padding: 6px 13px;
|
|
||||||
}
|
|
||||||
.markdown-body table tr:nth-child(2n) { background-color: var(--md-code-bg); }
|
|
||||||
.markdown-body table th { font-weight: 600; background-color: var(--md-code-bg); }
|
|
||||||
`;
|
|
||||||
|
|
||||||
export default function MarkdownToHtmlPage() {
|
|
||||||
const { t } = useLazyTranslation('markdownToHtml');
|
|
||||||
const [previewMode, setPreviewMode] = useStorageState(
|
|
||||||
'markdownToHtml/previewMode',
|
|
||||||
'split' as MarkdownToHtmlPreviewMode,
|
|
||||||
isValidPreviewMode,
|
|
||||||
);
|
|
||||||
const [markdown, setMarkdown] = useState(SAMPLE_MARKDOWN);
|
|
||||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
||||||
|
|
||||||
const result = useMemo(() => markdownToHtml(markdown), [markdown]);
|
|
||||||
const error = result.hasError ? (result.error ?? null) : null;
|
|
||||||
|
|
||||||
// 💡 自适应大总管:以极高严谨度拼装明暗大闸,用标准换行切断粘连风险
|
|
||||||
useEffect(() => {
|
|
||||||
const iframe = iframeRef.current;
|
|
||||||
if (!iframe) return;
|
|
||||||
|
|
||||||
const isDarkMode = document.documentElement.classList.contains('dark');
|
|
||||||
|
|
||||||
const themeVariables = isDarkMode
|
|
||||||
? `:root {
|
|
||||||
--md-bg: #090d16;
|
|
||||||
--md-foreground: #e6edf3;
|
|
||||||
--md-border: rgba(255,255,255,0.15);
|
|
||||||
--md-code-bg: rgba(255,255,255,0.12);
|
|
||||||
--md-pre-bg: rgba(255,255,255,0.04);
|
|
||||||
--md-muted: #8b949e;
|
|
||||||
--md-quote-line: rgba(255,255,255,0.25);
|
|
||||||
}`
|
|
||||||
: `:root {
|
|
||||||
--md-bg: #ffffff;
|
|
||||||
--md-foreground: #1f2328;
|
|
||||||
--md-border: rgba(128,128,128,0.2);
|
|
||||||
--md-code-bg: rgba(128,128,128,0.08);
|
|
||||||
--md-pre-bg: rgba(128,128,128,0.03);
|
|
||||||
--md-muted: #4b5563;
|
|
||||||
--md-quote-line: rgba(128,128,128,0.3);
|
|
||||||
}`;
|
|
||||||
|
|
||||||
// 💡 3. 核心大清洗:将全局基础树(html, body)与派生样式完全独立硬编码,杜绝任何语法踩踏
|
|
||||||
const baseGlobalStyles = `
|
|
||||||
html, body {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background-color: var(--md-bg);
|
|
||||||
color: var(--md-foreground);
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
padding: 16px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
iframe.srcdoc = `<!DOCTYPE html>
|
|
||||||
<html lang="zh" style="background-color: ${isDarkMode ? '#090d16' : '#ffffff'};">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<style>
|
|
||||||
${themeVariables}
|
|
||||||
${PREVIEW_STYLES}
|
|
||||||
${baseGlobalStyles}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body class="markdown-body">${result.html}</body>
|
|
||||||
</html>`;
|
|
||||||
}, [result.html, previewMode]);
|
|
||||||
|
|
||||||
const handleModeChange = useCallback(
|
|
||||||
(newMode: MarkdownToHtmlPreviewMode) => {
|
|
||||||
setPreviewMode(newMode);
|
|
||||||
},
|
|
||||||
[setPreviewMode],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleClear = useCallback(() => {
|
|
||||||
setMarkdown('');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handlePrint = useCallback(() => {
|
|
||||||
printHtml(result.html, t('pageTitle'));
|
|
||||||
}, [result.html, t]);
|
|
||||||
|
|
||||||
const handleDownload = useCallback(() => {
|
|
||||||
const doc = wrapHtmlDocument(result.html, t('pageTitle'));
|
|
||||||
downloadHtmlFile(doc, 'markdown-export.html');
|
|
||||||
}, [result.html, t]);
|
|
||||||
|
|
||||||
const showInput = previewMode !== 'preview';
|
|
||||||
const showPreview = previewMode !== 'html';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-4 w-full flex flex-col space-y-4 select-none animate-in fade-in duration-300">
|
|
||||||
<PageHeader
|
|
||||||
title={t('pageTitle')}
|
|
||||||
subtitle={t('pageSubtitle')}
|
|
||||||
icon={<Code className="h-4 w-4" />}
|
|
||||||
className="pb-1"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex flex-col space-y-4">
|
|
||||||
{/* 工具集成控制中枢 */}
|
|
||||||
<div className="flex flex-col sm:flex-row gap-3 justify-between items-stretch sm:items-center bg-secondary/40 rounded-xl border border-border/60 px-1.5 py-1.5 sm:h-12">
|
|
||||||
<SwitchButtonGroup
|
|
||||||
value={previewMode}
|
|
||||||
options={[
|
|
||||||
{ value: 'split', label: t('splitMode') },
|
|
||||||
{ value: 'preview', label: t('previewMode') },
|
|
||||||
{ value: 'html', label: t('htmlMode') },
|
|
||||||
]}
|
|
||||||
onChange={handleModeChange}
|
|
||||||
size="small"
|
|
||||||
className="w-full sm:w-auto"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex gap-2 shrink-0">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleClear}
|
|
||||||
className="h-8 rounded-md font-medium text-xs gap-1.5 shadow-sm active:scale-95 text-destructive hover:text-destructive hover:bg-destructive/5 dark:hover:bg-destructive/10 border-input/60 transition-all"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
{t('clear')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handlePrint}
|
|
||||||
disabled={!result.html}
|
|
||||||
className="h-8 rounded-md font-medium text-xs gap-1.5 shadow-sm active:scale-95 transition-all"
|
|
||||||
>
|
|
||||||
<Printer className="h-3.5 w-3.5" />
|
|
||||||
{t('print')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleDownload}
|
|
||||||
disabled={!result.html}
|
|
||||||
className="h-8 rounded-md font-medium text-xs gap-1.5 shadow-sm active:scale-95 transition-all"
|
|
||||||
>
|
|
||||||
<Download className="h-3.5 w-3.5" />
|
|
||||||
{t('download')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 错误拦截提示框 */}
|
|
||||||
{error && (
|
|
||||||
<div
|
|
||||||
role="alert"
|
|
||||||
className="p-3.5 bg-destructive/10 border border-destructive/20 rounded-xl text-destructive text-xs font-semibold tracking-wide animate-in shake duration-300"
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 主框架多栏联动排版轴 */}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'grid gap-4 min-h-[480px] w-full',
|
|
||||||
showInput && showPreview ? 'grid-cols-1 md:grid-cols-2' : 'grid-cols-1',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Markdown 输入翼终端 */}
|
|
||||||
{showInput && (
|
|
||||||
<div className="border border-border rounded-xl bg-card text-card-foreground shadow-sm overflow-hidden flex flex-col transition-all duration-200 focus-within:ring-1 focus-within:ring-ring focus-within:border-ring animate-in fade-in">
|
|
||||||
<div className="flex h-9 items-center justify-between px-4 bg-muted/50 border-b border-border select-none">
|
|
||||||
<span className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider">
|
|
||||||
{t('inputLabel')}
|
|
||||||
</span>
|
|
||||||
<span className="text-[10px] font-mono text-muted-foreground/60 tabular-nums">
|
|
||||||
{t('charCount', { count: markdown.length })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<textarea
|
|
||||||
value={markdown}
|
|
||||||
onChange={(e) => setMarkdown(e.target.value)}
|
|
||||||
placeholder={t('inputPlaceholder')}
|
|
||||||
className="flex-1 min-h-[390px] p-4 bg-transparent font-mono text-xs leading-relaxed resize-none focus:outline-none text-foreground/90 select-text"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 实时 HTML/Iframe 预览翼终端 */}
|
|
||||||
{showPreview && (
|
|
||||||
<div className="border border-border rounded-xl bg-card text-card-foreground shadow-sm overflow-hidden flex flex-col animate-in fade-in">
|
|
||||||
<div className="flex h-9 items-center justify-between px-4 bg-muted/50 border-b border-border select-none">
|
|
||||||
<span className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider">
|
|
||||||
{(previewMode as string) === 'html' ? t('htmlOutputLabel') : t('previewLabel')}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-[10px] font-mono text-muted-foreground/60 tabular-nums">
|
|
||||||
{t('charCount', { count: result.htmlLength })}
|
|
||||||
</span>
|
|
||||||
<CopyButton
|
|
||||||
text={result.html}
|
|
||||||
className="h-6 w-6 rounded-md border text-muted-foreground"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(previewMode as string) === 'html' ? (
|
|
||||||
<textarea
|
|
||||||
value={result.html}
|
|
||||||
readOnly
|
|
||||||
className="flex-1 min-h-[390px] p-4 font-mono text-xs leading-relaxed resize-none focus:outline-none bg-muted/30 dark:bg-muted/10 text-foreground/80 select-text"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex-1 min-h-[390px] overflow-hidden bg-transparent">
|
|
||||||
<iframe
|
|
||||||
ref={iframeRef}
|
|
||||||
title="markdown-preview"
|
|
||||||
className="w-full h-full min-h-[360px] border-none bg-transparent"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import { QrCode as QrCodeIcon } from 'lucide-react'; // 💡 别名规整,防止与页面组件发生重名误判
|
|
||||||
import PageHeader from '@/components/PageHeader';
|
|
||||||
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
|
||||||
import { qrCodePageStyles } from '@/config/pageTheme';
|
|
||||||
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
|
||||||
import { QrCodeContext } from './contexts/QrCodeContext';
|
|
||||||
import { useQrCode } from './hooks/useQrCode';
|
|
||||||
import GeneratePanel from './components/GeneratePanel';
|
|
||||||
import ParsePanel from './components/ParsePanel';
|
|
||||||
import type { QrCodeMode } from './types';
|
|
||||||
|
|
||||||
export default function Index() {
|
|
||||||
const { t } = useLazyTranslation('qrCode');
|
|
||||||
const qrCode = useQrCode();
|
|
||||||
|
|
||||||
// 模式选项驱动骨架
|
|
||||||
const modeOptions = [
|
|
||||||
{ value: 'generate' as QrCodeMode, label: t('qrCode:urlToQr') },
|
|
||||||
{ value: 'parse' as QrCodeMode, label: t('qrCode:qrToUrl') },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<QrCodeContext.Provider value={qrCode}>
|
|
||||||
{/* 💡 统一视觉规范大超进化:
|
|
||||||
- 彻底剥离破坏流式宽度的 max-w-[400px] 枷锁,开启标准的 w-full 全自适应包裹。
|
|
||||||
- 替换为标准的 p-4 呼吸内边距配合 flex flex-col space-y-4,接管系统级重排!
|
|
||||||
*/}
|
|
||||||
<div className="p-4 w-full flex flex-col space-y-4 min-h-[500px] select-none animate-in fade-in duration-300">
|
|
||||||
{/* 标题控制栏:追加微调 py-0.5,防范文字边缘截断 */}
|
|
||||||
<PageHeader
|
|
||||||
title={t('qrCode:pageTitle')}
|
|
||||||
subtitle={t('qrCode:pageSubtitle')}
|
|
||||||
icon={<QrCodeIcon className="h-4 w-4" />} // 💡 规范对齐:强制锁死 Icon 宽高,抹杀闪烁
|
|
||||||
iconColor={qrCodePageStyles.primaryColor}
|
|
||||||
className="pb-1"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 流式中央控制切流卡:注入 sm 断点防御,防范单栏状态下发生变形 */}
|
|
||||||
<div className="w-full sm:w-fit pt-0.5">
|
|
||||||
<SwitchButtonGroup
|
|
||||||
value={qrCode.mode}
|
|
||||||
options={modeOptions}
|
|
||||||
onChange={qrCode.setMode}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 💡 面板渲染沙箱:
|
|
||||||
- 在切流渲染时,利用独立的 mt-2 增加纵深边界线。
|
|
||||||
- 配合内部自带的双翼 Flex 聚焦大边框,形成坚固如铁的架构闭环!
|
|
||||||
*/}
|
|
||||||
<div className="w-full pt-1.5">
|
|
||||||
{qrCode.mode === 'generate' ? (
|
|
||||||
<div className="animate-in fade-in duration-200">
|
|
||||||
<GeneratePanel />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="animate-in fade-in duration-200">
|
|
||||||
<ParsePanel />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</QrCodeContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
// 引入官方的 Switch 原子组件
|
|
||||||
import { Switch } from '@/components/ui/switch';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
|
|
||||||
interface AutoRefreshToggleProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> {
|
|
||||||
autoRefresh: boolean;
|
|
||||||
onChange: (checked: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AutoRefreshToggle({
|
|
||||||
autoRefresh,
|
|
||||||
onChange,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: AutoRefreshToggleProps) {
|
|
||||||
const { t } = useLazyTranslation('storageCleaner');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
// 外层包裹维持优雅的 shadcn 风格中性卡片,去除硬编码 mb-3 扩展灵活性
|
|
||||||
'w-full p-4 rounded-xl border border-border bg-card text-card-foreground shadow-sm transition-all focus-within:ring-1 focus-within:ring-ring flex justify-between items-center',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{/* 3. 使用标准的 shadcn/ui Label 组件:
|
|
||||||
绑定 htmlFor 建立安全的表单无障碍桥梁,使得用户点击文字也能触发开关联动
|
|
||||||
*/}
|
|
||||||
<Label
|
|
||||||
htmlFor="auto-refresh-switch"
|
|
||||||
className="text-sm font-bold text-foreground cursor-pointer select-none tracking-tight"
|
|
||||||
>
|
|
||||||
{t('storageCleaner:autoRefresh')}
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
{/* 4. 超进化:彻底废除 200 个字符的原生 checkbox 拼接!
|
|
||||||
完美调用 shadcn 的 Switch 组件。它会自动应用全站统一的主色(Primary)、
|
|
||||||
带阻尼的滑块硬件加速动效、以及教科书级别的 WAI-ARIA 无障碍键盘焦点提示。
|
|
||||||
*/}
|
|
||||||
<Switch
|
|
||||||
id="auto-refresh-switch"
|
|
||||||
checked={autoRefresh}
|
|
||||||
onCheckedChange={onChange}
|
|
||||||
className="data-[state=checked]:bg-primary" // 如果依然需要特定的琥珀色可写 data-[state=checked]:bg-amber-500
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# public/
|
||||||
|
|
||||||
|
静态资源目录,存放无需构建处理的文件,会被直接复制到输出目录。
|
||||||
|
|
||||||
|
## 文件说明
|
||||||
|
|
||||||
|
| 文件/目录 | 用途 |
|
||||||
|
| -------------- | -------------------------------- |
|
||||||
|
| `icon/` | 扩展图标,提供多种尺寸 |
|
||||||
|
| `icon/16.png` | 16×16 图标(工具栏) |
|
||||||
|
| `icon/32.png` | 32×32 图标 |
|
||||||
|
| `icon/48.png` | 48×48 图标(扩展管理页) |
|
||||||
|
| `icon/96.png` | 96×96 图标 |
|
||||||
|
| `icon/128.png` | 128×128 图标(Chrome Web Store) |
|
||||||
|
| `wxt.svg` | WXT 框架标志 SVG 图标 |
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- 修改图标后需同步更新 `wxt.config.ts` 中的 manifest 配置
|
||||||
|
- 图标格式推荐使用 PNG,确保透明背景
|
||||||
@@ -0,0 +1,876 @@
|
|||||||
|
{
|
||||||
|
"buttons_copy": {
|
||||||
|
"message": "复制",
|
||||||
|
"description": "Translation key: buttons_copy"
|
||||||
|
},
|
||||||
|
"buttons_clear": {
|
||||||
|
"message": "清理",
|
||||||
|
"description": "Translation key: buttons_clear"
|
||||||
|
},
|
||||||
|
|
||||||
|
"messages_copySuccess": {
|
||||||
|
"message": "已复制到剪贴板",
|
||||||
|
"description": "Translation key: messages_copySuccess"
|
||||||
|
},
|
||||||
|
"messages_copyError": {
|
||||||
|
"message": "复制失败",
|
||||||
|
"description": "Translation key: messages_copyError"
|
||||||
|
},
|
||||||
|
"messages_copyEmpty": {
|
||||||
|
"message": "无内容可复制",
|
||||||
|
"description": "Translation key: messages_copyEmpty"
|
||||||
|
},
|
||||||
|
"errorBoundary_title": {
|
||||||
|
"message": "糟糕,出了点问题",
|
||||||
|
"description": "Translation key: errorBoundary_title"
|
||||||
|
},
|
||||||
|
"errorBoundary_description": {
|
||||||
|
"message": "应用遇到了一些意外错误。您可以尝试刷新页面或重置应用。",
|
||||||
|
"description": "Translation key: errorBoundary_description"
|
||||||
|
},
|
||||||
|
"errorBoundary_refresh": {
|
||||||
|
"message": "刷新应用",
|
||||||
|
"description": "Translation key: errorBoundary_refresh"
|
||||||
|
},
|
||||||
|
"errorBoundary_retry": {
|
||||||
|
"message": "重新尝试",
|
||||||
|
"description": "Translation key: errorBoundary_retry"
|
||||||
|
},
|
||||||
|
"pageErrorBoundary_title": {
|
||||||
|
"message": "该功能运行异常",
|
||||||
|
"description": "Translation key: pageErrorBoundary_title"
|
||||||
|
},
|
||||||
|
"pageErrorBoundary_description": {
|
||||||
|
"message": "该页面在加载或渲染时遇到了内部脚本错误。您可以尝试重试,或者通过导航菜单切换到其他工具。",
|
||||||
|
"description": "Translation key: pageErrorBoundary_description"
|
||||||
|
},
|
||||||
|
"router_notFound": {
|
||||||
|
"message": "页面未找到",
|
||||||
|
"description": "Translation key: router_notFound"
|
||||||
|
},
|
||||||
|
"router_notFoundDescription": {
|
||||||
|
"message": "该功能在当前运行环境({{entryPointType}})下不可用或已被移除。",
|
||||||
|
"description": "Translation key: router_notFoundDescription"
|
||||||
|
},
|
||||||
|
"textInputArea_clear": {
|
||||||
|
"message": "清空",
|
||||||
|
"description": "Translation key: textInputArea_clear"
|
||||||
|
},
|
||||||
|
"textInputArea_copyContent": {
|
||||||
|
"message": "复制内容",
|
||||||
|
"description": "Translation key: textInputArea_copyContent"
|
||||||
|
},
|
||||||
|
"textInputArea_cleared": {
|
||||||
|
"message": "已清空",
|
||||||
|
"description": "Translation key: textInputArea_cleared"
|
||||||
|
},
|
||||||
|
"textInputArea_placeholder": {
|
||||||
|
"message": "请输入文本",
|
||||||
|
"description": "Translation key: textInputArea_placeholder"
|
||||||
|
},
|
||||||
|
"dashboard_title": {
|
||||||
|
"message": "仪表盘",
|
||||||
|
"description": "Translation key: dashboard_title"
|
||||||
|
},
|
||||||
|
"dashboard_searchPlaceholder": {
|
||||||
|
"message": "搜索工具...",
|
||||||
|
"description": "Translation key: dashboard_searchPlaceholder"
|
||||||
|
},
|
||||||
|
"dashboard_recentlyUsed": {
|
||||||
|
"message": "最近使用",
|
||||||
|
"description": "Translation key: dashboard_recentlyUsed"
|
||||||
|
},
|
||||||
|
"dashboard_allTools": {
|
||||||
|
"message": "全部工具",
|
||||||
|
"description": "Translation key: dashboard_allTools"
|
||||||
|
},
|
||||||
|
"timestamp_title": {
|
||||||
|
"message": "时间戳",
|
||||||
|
"description": "Translation key: timestamp_title"
|
||||||
|
},
|
||||||
|
"timestamp_description": {
|
||||||
|
"message": "Unix 毫秒数转换与格式化",
|
||||||
|
"description": "Translation key: timestamp_description"
|
||||||
|
},
|
||||||
|
"storageCleaner_title": {
|
||||||
|
"message": "存储清理",
|
||||||
|
"description": "Translation key: storageCleaner_title"
|
||||||
|
},
|
||||||
|
"storageCleaner_description": {
|
||||||
|
"message": "清理缓存、Cookies 及本地存储",
|
||||||
|
"description": "Translation key: storageCleaner_description"
|
||||||
|
},
|
||||||
|
"qrCode_title": {
|
||||||
|
"message": "二维码工具",
|
||||||
|
"description": "Translation key: qrCode_title"
|
||||||
|
},
|
||||||
|
"qrCode_description": {
|
||||||
|
"message": "生成当前选中的 URL 的二维码",
|
||||||
|
"description": "Translation key: qrCode_description"
|
||||||
|
},
|
||||||
|
"textStatistics_title": {
|
||||||
|
"message": "文本统计",
|
||||||
|
"description": "Translation key: textStatistics_title"
|
||||||
|
},
|
||||||
|
"textStatistics_description": {
|
||||||
|
"message": "实时分析文本字符、单词及字节",
|
||||||
|
"description": "Translation key: textStatistics_description"
|
||||||
|
},
|
||||||
|
"jwt_title": {
|
||||||
|
"message": "JWT 解析",
|
||||||
|
"description": "Translation key: jwt_title"
|
||||||
|
},
|
||||||
|
"jwt_description": {
|
||||||
|
"message": "JSON Web Token 解码与查看",
|
||||||
|
"description": "Translation key: jwt_description"
|
||||||
|
},
|
||||||
|
"jsonDiff_title": {
|
||||||
|
"message": "JSON 工具",
|
||||||
|
"description": "Translation key: jsonDiff_title"
|
||||||
|
},
|
||||||
|
"jsonDiff_description": {
|
||||||
|
"message": "差异比较、格式化、YAML/TOML 转换及压缩",
|
||||||
|
"description": "Translation key: jsonDiff_description"
|
||||||
|
},
|
||||||
|
"base64Converter_title": {
|
||||||
|
"message": "Base64 转换器",
|
||||||
|
"description": "Translation key: base64Converter_title"
|
||||||
|
},
|
||||||
|
"base64Converter_description": {
|
||||||
|
"message": "文本、文件与图像的 Base64 编码转换",
|
||||||
|
"description": "Translation key: base64Converter_description"
|
||||||
|
},
|
||||||
|
"rightClickRestorer_title": {
|
||||||
|
"message": "右键恢复",
|
||||||
|
"description": "Translation key: rightClickRestorer_title"
|
||||||
|
},
|
||||||
|
"rightClickRestorer_description": {
|
||||||
|
"message": "检测并恢复被网站禁用的浏览器右键菜单",
|
||||||
|
"description": "Translation key: rightClickRestorer_description"
|
||||||
|
},
|
||||||
|
"base64Converter_pageTitle": {
|
||||||
|
"message": "Base64 转换器",
|
||||||
|
"description": "Translation key: base64Converter_pageTitle"
|
||||||
|
},
|
||||||
|
"base64Converter_textMode": {
|
||||||
|
"message": "文本",
|
||||||
|
"description": "Translation key: base64Converter_textMode"
|
||||||
|
},
|
||||||
|
"base64Converter_fileMode": {
|
||||||
|
"message": "文件",
|
||||||
|
"description": "Translation key: base64Converter_fileMode"
|
||||||
|
},
|
||||||
|
"base64Converter_imageMode": {
|
||||||
|
"message": "图像",
|
||||||
|
"description": "Translation key: base64Converter_imageMode"
|
||||||
|
},
|
||||||
|
"base64Converter_encode": {
|
||||||
|
"message": "编码",
|
||||||
|
"description": "Translation key: base64Converter_encode"
|
||||||
|
},
|
||||||
|
"base64Converter_decode": {
|
||||||
|
"message": "解码",
|
||||||
|
"description": "Translation key: base64Converter_decode"
|
||||||
|
},
|
||||||
|
"base64Converter_clear": {
|
||||||
|
"message": "清空",
|
||||||
|
"description": "Translation key: base64Converter_clear"
|
||||||
|
},
|
||||||
|
"base64Converter_textInputPlaceholder": {
|
||||||
|
"message": "输入需要编码为 Base64 的文本...",
|
||||||
|
"description": "Translation key: base64Converter_textInputPlaceholder"
|
||||||
|
},
|
||||||
|
"base64Converter_base64InputPlaceholder": {
|
||||||
|
"message": "输入需要解码的 Base64 字符串...",
|
||||||
|
"description": "Translation key: base64Converter_base64InputPlaceholder"
|
||||||
|
},
|
||||||
|
"base64Converter_base64Output": {
|
||||||
|
"message": "Base64 编码结果",
|
||||||
|
"description": "Translation key: base64Converter_base64Output"
|
||||||
|
},
|
||||||
|
"base64Converter_textOutput": {
|
||||||
|
"message": "解码文本结果",
|
||||||
|
"description": "Translation key: base64Converter_textOutput"
|
||||||
|
},
|
||||||
|
"base64Converter_copyRaw": {
|
||||||
|
"message": "复制纯 Base64",
|
||||||
|
"description": "Translation key: base64Converter_copyRaw"
|
||||||
|
},
|
||||||
|
"base64Converter_copyDataUri": {
|
||||||
|
"message": "复制 Data URI",
|
||||||
|
"description": "Translation key: base64Converter_copyDataUri"
|
||||||
|
},
|
||||||
|
"base64Converter_clickOrDropToFile": {
|
||||||
|
"message": "点击或拖拽文件到此处",
|
||||||
|
"description": "Translation key: base64Converter_clickOrDropToFile"
|
||||||
|
},
|
||||||
|
"base64Converter_clickOrDropToImage": {
|
||||||
|
"message": "点击或拖拽图像到此处",
|
||||||
|
"description": "Translation key: base64Converter_clickOrDropToImage"
|
||||||
|
},
|
||||||
|
"base64Converter_clickOrDropToReplace": {
|
||||||
|
"message": "点击或拖拽以替换文件",
|
||||||
|
"description": "Translation key: base64Converter_clickOrDropToReplace"
|
||||||
|
},
|
||||||
|
"base64Converter_maxFileSize": {
|
||||||
|
"message": "最大文件大小:{{max}}",
|
||||||
|
"description": "Translation key: base64Converter_maxFileSize"
|
||||||
|
},
|
||||||
|
"base64Converter_supportedFormats": {
|
||||||
|
"message": "支持 PNG、JPG、WEBP、GIF、BMP、SVG 等格式",
|
||||||
|
"description": "Translation key: base64Converter_supportedFormats"
|
||||||
|
},
|
||||||
|
"base64Converter_fileSizeExceeded": {
|
||||||
|
"message": "文件大小超出限制(最大 {{max}})",
|
||||||
|
"description": "Translation key: base64Converter_fileSizeExceeded"
|
||||||
|
},
|
||||||
|
"base64Converter_unsupportedImageType": {
|
||||||
|
"message": "不支持的图像格式",
|
||||||
|
"description": "Translation key: base64Converter_unsupportedImageType"
|
||||||
|
},
|
||||||
|
"base64Converter_conversionFailed": {
|
||||||
|
"message": "转换失败",
|
||||||
|
"description": "Translation key: base64Converter_conversionFailed"
|
||||||
|
},
|
||||||
|
"base64Converter_originalSize": {
|
||||||
|
"message": "原始大小",
|
||||||
|
"description": "Translation key: base64Converter_originalSize"
|
||||||
|
},
|
||||||
|
"base64Converter_encodedSize": {
|
||||||
|
"message": "编码大小",
|
||||||
|
"description": "Translation key: base64Converter_encodedSize"
|
||||||
|
},
|
||||||
|
"base64Converter_invalidBase64": {
|
||||||
|
"message": "Base64 字符串无效",
|
||||||
|
"description": "Translation key: base64Converter_invalidBase64"
|
||||||
|
},
|
||||||
|
"base64Converter_binaryDataDetected": {
|
||||||
|
"message": "输入似乎是二进制数据(如图片)。请切换到「图像」选项卡。",
|
||||||
|
"description": "Translation key: base64Converter_binaryDataDetected"
|
||||||
|
},
|
||||||
|
"base64Converter_imageDataUriHint": {
|
||||||
|
"message": "检测到图片的 data URI,请使用「图像」选项卡进行解码。",
|
||||||
|
"description": "Translation key: base64Converter_imageDataUriHint"
|
||||||
|
},
|
||||||
|
"base64Converter_switchToImageMode": {
|
||||||
|
"message": "切换到图像模式",
|
||||||
|
"description": "Translation key: base64Converter_switchToImageMode"
|
||||||
|
},
|
||||||
|
"base64Converter_download": {
|
||||||
|
"message": "下载",
|
||||||
|
"description": "Translation key: base64Converter_download"
|
||||||
|
},
|
||||||
|
"base64Converter_decodedFileName": {
|
||||||
|
"message": "解码后文件名",
|
||||||
|
"description": "Translation key: base64Converter_decodedFileName"
|
||||||
|
},
|
||||||
|
"base64Converter_decodeBase64Placeholder": {
|
||||||
|
"message": "输入需要解码的 Base64 或 data URI...",
|
||||||
|
"description": "Translation key: base64Converter_decodeBase64Placeholder"
|
||||||
|
},
|
||||||
|
"base64Converter_decodedFileOutput": {
|
||||||
|
"message": "解码文件",
|
||||||
|
"description": "Translation key: base64Converter_decodedFileOutput"
|
||||||
|
},
|
||||||
|
"base64Converter_decodedImageOutput": {
|
||||||
|
"message": "解码图像",
|
||||||
|
"description": "Translation key: base64Converter_decodedImageOutput"
|
||||||
|
},
|
||||||
|
"base64Converter_inferredMimeType": {
|
||||||
|
"message": "推断的 MIME 类型",
|
||||||
|
"description": "Translation key: base64Converter_inferredMimeType"
|
||||||
|
},
|
||||||
|
"base64Converter_decodedSize": {
|
||||||
|
"message": "解码大小",
|
||||||
|
"description": "Translation key: base64Converter_decodedSize"
|
||||||
|
},
|
||||||
|
"jsonDiff_pageTitle": {
|
||||||
|
"message": "JSON 差异比较",
|
||||||
|
"description": "Translation key: jsonDiff_pageTitle"
|
||||||
|
},
|
||||||
|
"jsonDiff_leftPlaceholder": {
|
||||||
|
"message": "输入原始 JSON...",
|
||||||
|
"description": "Translation key: jsonDiff_leftPlaceholder"
|
||||||
|
},
|
||||||
|
"jsonDiff_rightPlaceholder": {
|
||||||
|
"message": "输入目标 JSON...",
|
||||||
|
"description": "Translation key: jsonDiff_rightPlaceholder"
|
||||||
|
},
|
||||||
|
"jsonDiff_leftLabel": {
|
||||||
|
"message": "原始 JSON",
|
||||||
|
"description": "Translation key: jsonDiff_leftLabel"
|
||||||
|
},
|
||||||
|
"jsonDiff_rightLabel": {
|
||||||
|
"message": "目标 JSON",
|
||||||
|
"description": "Translation key: jsonDiff_rightLabel"
|
||||||
|
},
|
||||||
|
"jsonDiff_sideBySideMode": {
|
||||||
|
"message": "并排",
|
||||||
|
"description": "Translation key: jsonDiff_sideBySideMode"
|
||||||
|
},
|
||||||
|
"jsonDiff_unifiedMode": {
|
||||||
|
"message": "统一",
|
||||||
|
"description": "Translation key: jsonDiff_unifiedMode"
|
||||||
|
},
|
||||||
|
"jsonDiff_previousDiff": {
|
||||||
|
"message": "上一个",
|
||||||
|
"description": "Translation key: jsonDiff_previousDiff"
|
||||||
|
},
|
||||||
|
"jsonDiff_nextDiff": {
|
||||||
|
"message": "下一个",
|
||||||
|
"description": "Translation key: jsonDiff_nextDiff"
|
||||||
|
},
|
||||||
|
"jsonDiff_noDiffs": {
|
||||||
|
"message": "无差异",
|
||||||
|
"description": "Translation key: jsonDiff_noDiffs"
|
||||||
|
},
|
||||||
|
"jsonDiff_invalidJson": {
|
||||||
|
"message": "无效的 JSON 格式",
|
||||||
|
"description": "Translation key: jsonDiff_invalidJson"
|
||||||
|
},
|
||||||
|
"jsonDiff_emptyHint": {
|
||||||
|
"message": "输入两侧 JSON 后点击比较",
|
||||||
|
"description": "Translation key: jsonDiff_emptyHint"
|
||||||
|
},
|
||||||
|
"jsonDiff_fixErrorHint": {
|
||||||
|
"message": "请修正上方 JSON 的语法错误以开启实时流式比对",
|
||||||
|
"description": "Translation key: jsonDiff_fixErrorHint"
|
||||||
|
},
|
||||||
|
"jsonDiff_added": {
|
||||||
|
"message": "新增",
|
||||||
|
"description": "Translation key: jsonDiff_added"
|
||||||
|
},
|
||||||
|
"jsonDiff_removed": {
|
||||||
|
"message": "删除",
|
||||||
|
"description": "Translation key: jsonDiff_removed"
|
||||||
|
},
|
||||||
|
"jsonDiff_modified": {
|
||||||
|
"message": "修改",
|
||||||
|
"description": "Translation key: jsonDiff_modified"
|
||||||
|
},
|
||||||
|
"jsonFormat_inputPlaceholder": {
|
||||||
|
"message": "输入需要格式化的 JSON...",
|
||||||
|
"description": "Translation key: jsonFormat_inputPlaceholder"
|
||||||
|
},
|
||||||
|
"jsonFormat_sortKeys": {
|
||||||
|
"message": "键名排序",
|
||||||
|
"description": "Translation key: jsonFormat_sortKeys"
|
||||||
|
},
|
||||||
|
"jsonFormat_indentSize": {
|
||||||
|
"message": "缩进",
|
||||||
|
"description": "Translation key: jsonFormat_indentSize"
|
||||||
|
},
|
||||||
|
"jsonFormat_outputLabel": {
|
||||||
|
"message": "格式化结果",
|
||||||
|
"description": "Translation key: jsonFormat_outputLabel"
|
||||||
|
},
|
||||||
|
"jsonFormat_invalidJson": {
|
||||||
|
"message": "无效的 JSON 格式",
|
||||||
|
"description": "Translation key: jsonFormat_invalidJson"
|
||||||
|
},
|
||||||
|
"jsonFormat_emptyHint": {
|
||||||
|
"message": "输入 JSON 后点击格式化",
|
||||||
|
"description": "Translation key: jsonFormat_emptyHint"
|
||||||
|
},
|
||||||
|
"jsonFormat_fixErrorHint": {
|
||||||
|
"message": "请修正上方 JSON 的语法错误以开启实时流式格式化",
|
||||||
|
"description": "Translation key: jsonFormat_fixErrorHint"
|
||||||
|
},
|
||||||
|
"jsonFormat_originalSize": {
|
||||||
|
"message": "原始大小",
|
||||||
|
"description": "Translation key: jsonFormat_originalSize"
|
||||||
|
},
|
||||||
|
"jsonFormat_formattedSize": {
|
||||||
|
"message": "格式化后大小",
|
||||||
|
"description": "Translation key: jsonFormat_formattedSize"
|
||||||
|
},
|
||||||
|
"jsonFormat_diffMode": {
|
||||||
|
"message": "差异比较",
|
||||||
|
"description": "Translation key: jsonFormat_diffMode"
|
||||||
|
},
|
||||||
|
"jsonFormat_formatMode": {
|
||||||
|
"message": "格式化",
|
||||||
|
"description": "Translation key: jsonFormat_formatMode"
|
||||||
|
},
|
||||||
|
"jsonFormat_yamlMode": {
|
||||||
|
"message": "YAML",
|
||||||
|
"description": "Translation key: jsonFormat_yamlMode"
|
||||||
|
},
|
||||||
|
"jsonFormat_tomlMode": {
|
||||||
|
"message": "TOML",
|
||||||
|
"description": "Translation key: jsonFormat_tomlMode"
|
||||||
|
},
|
||||||
|
"jsonFormat_minifyMode": {
|
||||||
|
"message": "压缩",
|
||||||
|
"description": "Translation key: jsonFormat_minifyMode"
|
||||||
|
},
|
||||||
|
"jsonFormat_yamlModeInputPlaceholder": {
|
||||||
|
"message": "输入需要转换的 JSON...",
|
||||||
|
"description": "Translation key: jsonFormat_yamlModeInputPlaceholder"
|
||||||
|
},
|
||||||
|
"jsonFormat_yamlModeOutputLabel": {
|
||||||
|
"message": "YAML 结果",
|
||||||
|
"description": "Translation key: jsonFormat_yamlModeOutputLabel"
|
||||||
|
},
|
||||||
|
"jsonFormat_yamlModeEmptyHint": {
|
||||||
|
"message": "输入 JSON 后点击转换",
|
||||||
|
"description": "Translation key: jsonFormat_yamlModeEmptyHint"
|
||||||
|
},
|
||||||
|
"jsonFormat_tomlModeInputPlaceholder": {
|
||||||
|
"message": "输入需要转换的 JSON...",
|
||||||
|
"description": "Translation key: jsonFormat_tomlModeInputPlaceholder"
|
||||||
|
},
|
||||||
|
"jsonFormat_tomlModeOutputLabel": {
|
||||||
|
"message": "TOML 结果",
|
||||||
|
"description": "Translation key: jsonFormat_tomlModeOutputLabel"
|
||||||
|
},
|
||||||
|
"jsonFormat_tomlModeEmptyHint": {
|
||||||
|
"message": "输入 JSON 后点击转换",
|
||||||
|
"description": "Translation key: jsonFormat_tomlModeEmptyHint"
|
||||||
|
},
|
||||||
|
"jsonFormat_minifyModeInputPlaceholder": {
|
||||||
|
"message": "输入需要压缩的 JSON...",
|
||||||
|
"description": "Translation key: jsonFormat_minifyModeInputPlaceholder"
|
||||||
|
},
|
||||||
|
"jsonFormat_minifyModeOutputLabel": {
|
||||||
|
"message": "压缩结果",
|
||||||
|
"description": "Translation key: jsonFormat_minifyModeOutputLabel"
|
||||||
|
},
|
||||||
|
"jsonFormat_minifyModeEmptyHint": {
|
||||||
|
"message": "输入 JSON 后点击压缩",
|
||||||
|
"description": "Translation key: jsonFormat_minifyModeEmptyHint"
|
||||||
|
},
|
||||||
|
"jwt_pageTitle": {
|
||||||
|
"message": "JWT 解析",
|
||||||
|
"description": "Translation key: jwt_pageTitle"
|
||||||
|
},
|
||||||
|
"jwt_placeholder": {
|
||||||
|
"message": "在此粘贴 JWT 令牌 (Encoded JWT)...",
|
||||||
|
"description": "Translation key: jwt_placeholder"
|
||||||
|
},
|
||||||
|
"jwt_headerTitle": {
|
||||||
|
"message": "HEADER: 算法 & 令牌类型",
|
||||||
|
"description": "Translation key: jwt_headerTitle"
|
||||||
|
},
|
||||||
|
"jwt_payloadTitle": {
|
||||||
|
"message": "PAYLOAD: 数据",
|
||||||
|
"description": "Translation key: jwt_payloadTitle"
|
||||||
|
},
|
||||||
|
"jwt_signatureTitle": {
|
||||||
|
"message": "签名",
|
||||||
|
"description": "Translation key: jwt_signatureTitle"
|
||||||
|
},
|
||||||
|
"jwt_noSignature": {
|
||||||
|
"message": "无签名",
|
||||||
|
"description": "Translation key: jwt_noSignature"
|
||||||
|
},
|
||||||
|
"jwt_invalidFormat": {
|
||||||
|
"message": "无法解析",
|
||||||
|
"description": "Translation key: jwt_invalidFormat"
|
||||||
|
},
|
||||||
|
"jwt_errors_invalidBase64String": {
|
||||||
|
"message": "无效的 Base64URL 字符串",
|
||||||
|
"description": "Translation key: jwt_errors_invalidBase64String"
|
||||||
|
},
|
||||||
|
"jwt_errors_failedToDecode": {
|
||||||
|
"message": "Base64URL 解码失败:",
|
||||||
|
"description": "Translation key: jwt_errors_failedToDecode"
|
||||||
|
},
|
||||||
|
"jwt_errors_invalidFormat": {
|
||||||
|
"message": "JWT 格式错误:必须包含三个由 . 分隔的部分",
|
||||||
|
"description": "Translation key: jwt_errors_invalidFormat"
|
||||||
|
},
|
||||||
|
"jwt_errors_parseHeaderFailed": {
|
||||||
|
"message": "解析 Header 失败:",
|
||||||
|
"description": "Translation key: jwt_errors_parseHeaderFailed"
|
||||||
|
},
|
||||||
|
"jwt_errors_parsePayloadFailed": {
|
||||||
|
"message": "解析 Payload 失败:",
|
||||||
|
"description": "Translation key: jwt_errors_parsePayloadFailed"
|
||||||
|
},
|
||||||
|
"qrCode_pageTitle": {
|
||||||
|
"message": "二维码工具",
|
||||||
|
"description": "Translation key: qrCode_pageTitle"
|
||||||
|
},
|
||||||
|
"qrCode_urlToQr": {
|
||||||
|
"message": "文本转二维码",
|
||||||
|
"description": "Translation key: qrCode_urlToQr"
|
||||||
|
},
|
||||||
|
"qrCode_qrToUrl": {
|
||||||
|
"message": "二维码转文本",
|
||||||
|
"description": "Translation key: qrCode_qrToUrl"
|
||||||
|
},
|
||||||
|
"qrCode_urlInputLabel": {
|
||||||
|
"message": "输入 URL 或文本",
|
||||||
|
"description": "Translation key: qrCode_urlInputLabel"
|
||||||
|
},
|
||||||
|
"qrCode_urlInputPlaceholder": {
|
||||||
|
"message": "请输入 URL 或文本内容,将自动生成二维码",
|
||||||
|
"description": "Translation key: qrCode_urlInputPlaceholder"
|
||||||
|
},
|
||||||
|
"qrCode_generating": {
|
||||||
|
"message": "生成中...",
|
||||||
|
"description": "Translation key: qrCode_generating"
|
||||||
|
},
|
||||||
|
"qrCode_qrCodeWillShow": {
|
||||||
|
"message": "二维码将显示在这里",
|
||||||
|
"description": "Translation key: qrCode_qrCodeWillShow"
|
||||||
|
},
|
||||||
|
"qrCode_downloadButton": {
|
||||||
|
"message": "下载二维码",
|
||||||
|
"description": "Translation key: qrCode_downloadButton"
|
||||||
|
},
|
||||||
|
"qrCode_copyQrButton": {
|
||||||
|
"message": "复制二维码",
|
||||||
|
"description": "Translation key: qrCode_copyQrButton"
|
||||||
|
},
|
||||||
|
"qrCode_qrCodeDownloadSuccess": {
|
||||||
|
"message": "二维码下载成功",
|
||||||
|
"description": "Translation key: qrCode_qrCodeDownloadSuccess"
|
||||||
|
},
|
||||||
|
"qrCode_qrCodeCopySuccess": {
|
||||||
|
"message": "二维码已复制到剪贴板",
|
||||||
|
"description": "Translation key: qrCode_qrCodeCopySuccess"
|
||||||
|
},
|
||||||
|
"qrCode_parseSuccess": {
|
||||||
|
"message": "二维码解析成功",
|
||||||
|
"description": "Translation key: qrCode_parseSuccess"
|
||||||
|
},
|
||||||
|
"qrCode_noQrDetected": {
|
||||||
|
"message": "未检测到二维码,请确保图片清晰且包含二维码",
|
||||||
|
"description": "Translation key: qrCode_noQrDetected"
|
||||||
|
},
|
||||||
|
"qrCode_parseError": {
|
||||||
|
"message": "解析二维码失败,请重试",
|
||||||
|
"description": "Translation key: qrCode_parseError"
|
||||||
|
},
|
||||||
|
"qrCode_copyError": {
|
||||||
|
"message": "复制失败,请重试",
|
||||||
|
"description": "Translation key: qrCode_copyError"
|
||||||
|
},
|
||||||
|
"qrCode_imagePasted": {
|
||||||
|
"message": "图片粘贴成功,正在解析...",
|
||||||
|
"description": "Translation key: qrCode_imagePasted"
|
||||||
|
},
|
||||||
|
"qrCode_imagePasteError": {
|
||||||
|
"message": "粘贴图片失败,请重试",
|
||||||
|
"description": "Translation key: qrCode_imagePasteError"
|
||||||
|
},
|
||||||
|
"qrCode_imageCleared": {
|
||||||
|
"message": "图片已清除",
|
||||||
|
"description": "Translation key: qrCode_imageCleared"
|
||||||
|
},
|
||||||
|
"qrCode_clickToUpload": {
|
||||||
|
"message": "点击、拖拽或粘贴上传二维码图片",
|
||||||
|
"description": "Translation key: qrCode_clickToUpload"
|
||||||
|
},
|
||||||
|
"qrCode_supportFormats": {
|
||||||
|
"message": "支持 PNG、JPG、WEBP、Base64 格式",
|
||||||
|
"description": "Translation key: qrCode_supportFormats"
|
||||||
|
},
|
||||||
|
"qrCode_resultLabel": {
|
||||||
|
"message": "解析结果",
|
||||||
|
"description": "Translation key: qrCode_resultLabel"
|
||||||
|
},
|
||||||
|
"qrCode_clickToChange": {
|
||||||
|
"message": "点击更换图片",
|
||||||
|
"description": "Translation key: qrCode_clickToChange"
|
||||||
|
},
|
||||||
|
"rightClickRestorer_loading": {
|
||||||
|
"message": "正在加载...",
|
||||||
|
"description": "Translation key: rightClickRestorer_loading"
|
||||||
|
},
|
||||||
|
"rightClickRestorer_currentDomain": {
|
||||||
|
"message": "当前域名",
|
||||||
|
"description": "Translation key: rightClickRestorer_currentDomain"
|
||||||
|
},
|
||||||
|
"rightClickRestorer_statusLocked": {
|
||||||
|
"message": "未解锁",
|
||||||
|
"description": "Translation key: rightClickRestorer_statusLocked"
|
||||||
|
},
|
||||||
|
"rightClickRestorer_statusUnlocked": {
|
||||||
|
"message": "已解锁",
|
||||||
|
"description": "Translation key: rightClickRestorer_statusUnlocked"
|
||||||
|
},
|
||||||
|
"rightClickRestorer_unsupported": {
|
||||||
|
"message": "不支持",
|
||||||
|
"description": "Translation key: rightClickRestorer_unsupported"
|
||||||
|
},
|
||||||
|
"rightClickRestorer_unsupportedDesc": {
|
||||||
|
"message": "当前页面为浏览器内部页面或扩展页面,无法解锁右键功能。请切换到普通网页后重试。",
|
||||||
|
"description": "Translation key: rightClickRestorer_unsupportedDesc"
|
||||||
|
},
|
||||||
|
"rightClickRestorer_unlockDesc": {
|
||||||
|
"message": "点击下方按钮,为当前网站临时解锁右键菜单。刷新页面后需要重新解锁。",
|
||||||
|
"description": "Translation key: rightClickRestorer_unlockDesc"
|
||||||
|
},
|
||||||
|
"rightClickRestorer_unlockBtn": {
|
||||||
|
"message": "解锁当前网站右键",
|
||||||
|
"description": "Translation key: rightClickRestorer_unlockBtn"
|
||||||
|
},
|
||||||
|
"rightClickRestorer_alreadyUnlocked": {
|
||||||
|
"message": "右键已解锁",
|
||||||
|
"description": "Translation key: rightClickRestorer_alreadyUnlocked"
|
||||||
|
},
|
||||||
|
"storageCleaner_pageTitle": {
|
||||||
|
"message": "存储清理",
|
||||||
|
"description": "Translation key: storageCleaner_pageTitle"
|
||||||
|
},
|
||||||
|
"storageCleaner_loading": {
|
||||||
|
"message": "加载中...",
|
||||||
|
"description": "Translation key: storageCleaner_loading"
|
||||||
|
},
|
||||||
|
"storageCleaner_initializing": {
|
||||||
|
"message": "正在读取站点数据...",
|
||||||
|
"description": "Translation key: storageCleaner_initializing"
|
||||||
|
},
|
||||||
|
"storageCleaner_cleaning": {
|
||||||
|
"message": "正在清理...",
|
||||||
|
"description": "Translation key: storageCleaner_cleaning"
|
||||||
|
},
|
||||||
|
"storageCleaner_cleanNow": {
|
||||||
|
"message": "立即清理",
|
||||||
|
"description": "Translation key: storageCleaner_cleanNow"
|
||||||
|
},
|
||||||
|
"storageCleaner_autoRefresh": {
|
||||||
|
"message": "清理后自动刷新页面",
|
||||||
|
"description": "Translation key: storageCleaner_autoRefresh"
|
||||||
|
},
|
||||||
|
"storageCleaner_selectAll": {
|
||||||
|
"message": "全选所有项",
|
||||||
|
"description": "Translation key: storageCleaner_selectAll"
|
||||||
|
},
|
||||||
|
"storageCleaner_noData": {
|
||||||
|
"message": "无数据",
|
||||||
|
"description": "Translation key: storageCleaner_noData"
|
||||||
|
},
|
||||||
|
"storageCleaner_errorNoTab": {
|
||||||
|
"message": "无法获取当前标签页",
|
||||||
|
"description": "Translation key: storageCleaner_errorNoTab"
|
||||||
|
},
|
||||||
|
"storageCleaner_errorRestricted": {
|
||||||
|
"message": "存储清理功能不支持此页面",
|
||||||
|
"description": "Translation key: storageCleaner_errorRestricted"
|
||||||
|
},
|
||||||
|
"storageCleaner_cleanSuccessReload": {
|
||||||
|
"message": "清理成功,即将刷新页面",
|
||||||
|
"description": "Translation key: storageCleaner_cleanSuccessReload"
|
||||||
|
},
|
||||||
|
"storageCleaner_errorStandardOnly": {
|
||||||
|
"message": "存储清理功能仅适用于标准网页",
|
||||||
|
"description": "Translation key: storageCleaner_errorStandardOnly"
|
||||||
|
},
|
||||||
|
"storageCleaner_confirmTitle": {
|
||||||
|
"message": "确认清理数据?",
|
||||||
|
"description": "Translation key: storageCleaner_confirmTitle"
|
||||||
|
},
|
||||||
|
"storageCleaner_confirmDesc": {
|
||||||
|
"message": "您将永久删除当前页面的以下选定存储项。",
|
||||||
|
"description": "Translation key: storageCleaner_confirmDesc"
|
||||||
|
},
|
||||||
|
"storageCleaner_irreversible": {
|
||||||
|
"message": "此操作不可撤销",
|
||||||
|
"description": "Translation key: storageCleaner_irreversible"
|
||||||
|
},
|
||||||
|
"storageCleaner_confirmAction": {
|
||||||
|
"message": "确认清理",
|
||||||
|
"description": "Translation key: storageCleaner_confirmAction"
|
||||||
|
},
|
||||||
|
"storageCleaner_cleanedSummary": {
|
||||||
|
"message": "清理了 {{items}}",
|
||||||
|
"description": "Translation key: storageCleaner_cleanedSummary"
|
||||||
|
},
|
||||||
|
"storageCleaner_noDataToClean": {
|
||||||
|
"message": "该页面没有可清理的存储数据",
|
||||||
|
"description": "Translation key: storageCleaner_noDataToClean"
|
||||||
|
},
|
||||||
|
"storageCleaner_partialFailure": {
|
||||||
|
"message": "部分清理失败",
|
||||||
|
"description": "Translation key: storageCleaner_partialFailure"
|
||||||
|
},
|
||||||
|
"storageCleaner_options_localStorage": {
|
||||||
|
"message": "Local Storage",
|
||||||
|
"description": "Translation key: storageCleaner_options_localStorage"
|
||||||
|
},
|
||||||
|
"storageCleaner_options_sessionStorage": {
|
||||||
|
"message": "Session Storage",
|
||||||
|
"description": "Translation key: storageCleaner_options_sessionStorage"
|
||||||
|
},
|
||||||
|
"storageCleaner_options_indexedDB": {
|
||||||
|
"message": "IndexedDB",
|
||||||
|
"description": "Translation key: storageCleaner_options_indexedDB"
|
||||||
|
},
|
||||||
|
"storageCleaner_options_cookies": {
|
||||||
|
"message": "Cookies",
|
||||||
|
"description": "Translation key: storageCleaner_options_cookies"
|
||||||
|
},
|
||||||
|
"storageCleaner_options_cacheStorage": {
|
||||||
|
"message": "Cache Storage",
|
||||||
|
"description": "Translation key: storageCleaner_options_cacheStorage"
|
||||||
|
},
|
||||||
|
"storageCleaner_options_serviceWorkers": {
|
||||||
|
"message": "Service Workers",
|
||||||
|
"description": "Translation key: storageCleaner_options_serviceWorkers"
|
||||||
|
},
|
||||||
|
"textStatistics_pageTitle": {
|
||||||
|
"message": "文本统计",
|
||||||
|
"description": "Translation key: textStatistics_pageTitle"
|
||||||
|
},
|
||||||
|
"textStatistics_placeholder": {
|
||||||
|
"message": "在此输入或粘贴文本...",
|
||||||
|
"description": "Translation key: textStatistics_placeholder"
|
||||||
|
},
|
||||||
|
"textStatistics_characters": {
|
||||||
|
"message": "字符数",
|
||||||
|
"description": "Translation key: textStatistics_characters"
|
||||||
|
},
|
||||||
|
"textStatistics_words": {
|
||||||
|
"message": "单词数",
|
||||||
|
"description": "Translation key: textStatistics_words"
|
||||||
|
},
|
||||||
|
"textStatistics_lines": {
|
||||||
|
"message": "行数",
|
||||||
|
"description": "Translation key: textStatistics_lines"
|
||||||
|
},
|
||||||
|
"textStatistics_bytes": {
|
||||||
|
"message": "字节大小",
|
||||||
|
"description": "Translation key: textStatistics_bytes"
|
||||||
|
},
|
||||||
|
"timestamp_pageTitle": {
|
||||||
|
"message": "时间戳转换",
|
||||||
|
"description": "Translation key: timestamp_pageTitle"
|
||||||
|
},
|
||||||
|
"timestamp_tsToDate": {
|
||||||
|
"message": "时间戳 → 日期",
|
||||||
|
"description": "Translation key: timestamp_tsToDate"
|
||||||
|
},
|
||||||
|
"timestamp_dateToTs": {
|
||||||
|
"message": "日期 → 时间戳",
|
||||||
|
"description": "Translation key: timestamp_dateToTs"
|
||||||
|
},
|
||||||
|
"timestamp_placeholderTs": {
|
||||||
|
"message": "输入时间戳...",
|
||||||
|
"description": "Translation key: timestamp_placeholderTs"
|
||||||
|
},
|
||||||
|
"timestamp_placeholderDate": {
|
||||||
|
"message": "YYYY-MM-DD HH:mm:ss",
|
||||||
|
"description": "Translation key: timestamp_placeholderDate"
|
||||||
|
},
|
||||||
|
"timestamp_unitMs": {
|
||||||
|
"message": "毫秒 (ms)",
|
||||||
|
"description": "Translation key: timestamp_unitMs"
|
||||||
|
},
|
||||||
|
"timestamp_currentTs": {
|
||||||
|
"message": "当前时间戳",
|
||||||
|
"description": "Translation key: timestamp_currentTs"
|
||||||
|
},
|
||||||
|
"timestamp_useNowTooltip": {
|
||||||
|
"message": "填充到下方",
|
||||||
|
"description": "Translation key: timestamp_useNowTooltip"
|
||||||
|
},
|
||||||
|
"timestamp_copyTsTooltip": {
|
||||||
|
"message": "复制时间戳",
|
||||||
|
"description": "Translation key: timestamp_copyTsTooltip"
|
||||||
|
},
|
||||||
|
"timestamp_usedSuccess": {
|
||||||
|
"message": "已使用当前时间戳",
|
||||||
|
"description": "Translation key: timestamp_usedSuccess"
|
||||||
|
},
|
||||||
|
"timestamp_resultLabel": {
|
||||||
|
"message": "转换结果",
|
||||||
|
"description": "Translation key: timestamp_resultLabel"
|
||||||
|
},
|
||||||
|
"timestamp_copyResultTooltip": {
|
||||||
|
"message": "复制结果",
|
||||||
|
"description": "Translation key: timestamp_copyResultTooltip"
|
||||||
|
},
|
||||||
|
"timestamp_relativeTime": {
|
||||||
|
"message": "相对时间",
|
||||||
|
"description": "Translation key: timestamp_relativeTime"
|
||||||
|
},
|
||||||
|
"timestamp_iso8601": {
|
||||||
|
"message": "ISO 8601",
|
||||||
|
"description": "Translation key: timestamp_iso8601"
|
||||||
|
},
|
||||||
|
"timestamp_utcTime": {
|
||||||
|
"message": "UTC 时间",
|
||||||
|
"description": "Translation key: timestamp_utcTime"
|
||||||
|
},
|
||||||
|
"timestamp_copyTooltip": {
|
||||||
|
"message": "复制",
|
||||||
|
"description": "Translation key: timestamp_copyTooltip"
|
||||||
|
},
|
||||||
|
"timestamp_resultEmpty": {
|
||||||
|
"message": "请输入并点击转换",
|
||||||
|
"description": "Translation key: timestamp_resultEmpty"
|
||||||
|
},
|
||||||
|
"common_buttons_cancel": {
|
||||||
|
"message": "取消",
|
||||||
|
"description": "Translation key: buttons_cancel"
|
||||||
|
},
|
||||||
|
"common_buttons_copy": {
|
||||||
|
"message": "复制",
|
||||||
|
"description": "Translation key: buttons_copy"
|
||||||
|
},
|
||||||
|
"common_buttons_themeMode_light": {
|
||||||
|
"message": "切换到深色模式",
|
||||||
|
"description": "Translation key: buttons_themeMode_light"
|
||||||
|
},
|
||||||
|
"common_buttons_themeMode_dark": {
|
||||||
|
"message": "切换到系统模式",
|
||||||
|
"description": "Translation key: buttons_themeMode_dark"
|
||||||
|
},
|
||||||
|
"common_buttons_themeMode_system": {
|
||||||
|
"message": "切换到浅色模式",
|
||||||
|
"description": "Translation key: buttons_themeMode_system"
|
||||||
|
},
|
||||||
|
"common_buttons_search": {
|
||||||
|
"message": "搜索工具...",
|
||||||
|
"description": "Translation key: buttons_search"
|
||||||
|
},
|
||||||
|
"common_buttons_back": {
|
||||||
|
"message": "返回",
|
||||||
|
"description": "Translation key: buttons_back"
|
||||||
|
},
|
||||||
|
"common_buttons_clearSearch": {
|
||||||
|
"message": "清除搜索",
|
||||||
|
"description": "Translation key: buttons_clearSearch"
|
||||||
|
},
|
||||||
|
"common_buttons_recentSearch": {
|
||||||
|
"message": "最近搜索",
|
||||||
|
"description": "Translation key: buttons_recentSearch"
|
||||||
|
},
|
||||||
|
"common_buttons_noResults": {
|
||||||
|
"message": "未找到相关工具",
|
||||||
|
"description": "Translation key: buttons_noResults"
|
||||||
|
},
|
||||||
|
"common_buttons_openInTab": {
|
||||||
|
"message": "在标签页打开",
|
||||||
|
"description": "Translation key: buttons_openInTab"
|
||||||
|
},
|
||||||
|
|
||||||
|
"common_errorBoundary_title": {
|
||||||
|
"message": "糟糕,出了点问题",
|
||||||
|
"description": "Translation key: errorBoundary_title"
|
||||||
|
},
|
||||||
|
"common_errorBoundary_description": {
|
||||||
|
"message": "应用遇到了一些意外错误。您可以尝试刷新页面或重置应用。",
|
||||||
|
"description": "Translation key: errorBoundary_description"
|
||||||
|
},
|
||||||
|
"common_errorBoundary_refresh": {
|
||||||
|
"message": "刷新应用",
|
||||||
|
"description": "Translation key: errorBoundary_refresh"
|
||||||
|
},
|
||||||
|
"common_errorBoundary_retry": {
|
||||||
|
"message": "重新尝试",
|
||||||
|
"description": "Translation key: errorBoundary_retry"
|
||||||
|
},
|
||||||
|
"common_pageErrorBoundary_title": {
|
||||||
|
"message": "该功能运行异常",
|
||||||
|
"description": "Translation key: pageErrorBoundary_title"
|
||||||
|
},
|
||||||
|
"common_pageErrorBoundary_description": {
|
||||||
|
"message": "该页面在加载或渲染时遇到了内部脚本错误。您可以尝试重试,或者通过导航菜单切换到其他工具。",
|
||||||
|
"description": "Translation key: pageErrorBoundary_description"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# src/
|
||||||
|
|
||||||
|
源码目录,存放全局样式定义。
|
||||||
|
|
||||||
|
## 文件说明
|
||||||
|
|
||||||
|
| 文件 | 用途 |
|
||||||
|
| ----------- | ----------------- |
|
||||||
|
| `index.css` | 全局 CSS 入口文件 |
|
||||||
|
|
||||||
|
## index.css
|
||||||
|
|
||||||
|
全局样式入口,包含:
|
||||||
|
|
||||||
|
- **Tailwind 指令**:`@tailwind base/components/utilities`
|
||||||
|
- **shadcn/ui CSS 变量**:定义 `--background`、`--primary`、`--destructive`、`--card`、`--muted`、`--accent`、`--border`、`--ring` 等语义化颜色变量
|
||||||
|
- **主题色值**:`:root`(亮色)和 `.dark`(暗色)两套完整的颜色定义
|
||||||
|
- **圆角变量**:`--radius` 定义全局圆角大小
|
||||||
|
|
||||||
|
## 修改注意事项
|
||||||
|
|
||||||
|
- 修改 CSS 变量会影响所有使用 shadcn/ui 语义化 token 的组件
|
||||||
|
- 新增颜色变量需同时在 `:root` 和 `.dark` 中定义
|
||||||
|
- 避免在组件中硬编码颜色值,应使用 CSS 变量或 Tailwind 的语义化类名
|
||||||
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,73 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Check, Copy } from 'lucide-react';
|
||||||
|
import { copyTextToClipboard } from '@/utils/clipboard';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { buttonVariants, type ButtonProps } from '@/components/ui/button';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useI18n } from '@/utils/chromeI18n';
|
||||||
|
|
||||||
|
interface CopyButtonProps extends Omit<ButtonProps, 'children' | 'onClick'> {
|
||||||
|
text: string;
|
||||||
|
tooltip?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CopyButton: React.FC<CopyButtonProps> = ({
|
||||||
|
text,
|
||||||
|
tooltip,
|
||||||
|
variant = 'ghost',
|
||||||
|
size = 'icon',
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const { t } = useI18n('common');
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCopy = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (!text) {
|
||||||
|
toast.error(t('messages.copyEmpty'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = await copyTextToClipboard(text);
|
||||||
|
if (success) {
|
||||||
|
toast.success(t('messages.copySuccess'));
|
||||||
|
setCopied(true);
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = setTimeout(() => setCopied(false), 1500);
|
||||||
|
} else {
|
||||||
|
toast.error(t('messages.copyError'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopy}
|
||||||
|
title={tooltip ?? t('buttons.copy')}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({ variant, size }),
|
||||||
|
copied &&
|
||||||
|
'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-500/10',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<Check className="h-[1.2em] w-[1.2em] animate-in fade-in zoom-in-75 duration-200" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-[1.2em] w-[1.2em]" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CopyButton;
|
||||||
@@ -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 { useTranslation } from 'react-i18next';
|
import { useI18n } from '@/utils/chromeI18n';
|
||||||
|
|
||||||
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 } = useTranslation('base64Converter');
|
const { t } = useI18n('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,6 +1,7 @@
|
|||||||
import { Component, ErrorInfo, ReactNode } from 'react';
|
import { Component, ErrorInfo, ReactNode } from 'react';
|
||||||
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 {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -11,10 +12,7 @@ interface State {
|
|||||||
error: Error | null;
|
error: Error | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
class ErrorBoundary extends Component<Props, State> {
|
||||||
* 错误边界组件:捕获子组件树中的 JavaScript 错误
|
|
||||||
*/
|
|
||||||
export class ErrorBoundary extends Component<Props, State> {
|
|
||||||
state: State = {
|
state: State = {
|
||||||
hasError: false,
|
hasError: false,
|
||||||
error: null,
|
error: null,
|
||||||
@@ -41,27 +39,31 @@ export class ErrorBoundary extends Component<Props, State> {
|
|||||||
render() {
|
render() {
|
||||||
if (this.state.hasError) {
|
if (this.state.hasError) {
|
||||||
return (
|
return (
|
||||||
<div className="mt-16 mx-auto max-w-md">
|
<div className="flex flex-col items-center justify-center mt-16 mx-auto max-w-md">
|
||||||
<div className="p-6 text-center rounded-xl border border-red-200 bg-red-50">
|
<div className="p-6 text-center rounded-xl border border-destructive/20 bg-destructive/5 shadow-sm">
|
||||||
<AlertCircle className="h-16 w-16 text-red-500 mx-auto mb-4" />
|
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10 text-destructive mx-auto mb-4">
|
||||||
<h2 className="text-xl font-extrabold text-red-600 mb-2">糟糕,出了点问题</h2>
|
<AlertCircle className="h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-extrabold text-destructive mb-2">
|
||||||
|
{getMessage('errorBoundary_title')}
|
||||||
|
</h2>
|
||||||
<p className="text-sm text-muted-foreground mb-6">
|
<p className="text-sm text-muted-foreground mb-6">
|
||||||
应用遇到了一些意外错误。您可以尝试刷新页面或重置应用。
|
{getMessage('errorBoundary_description')}
|
||||||
</p>
|
</p>
|
||||||
{this.state.error && (
|
{this.state.error && (
|
||||||
<div className="mb-6 p-4 bg-muted rounded-lg text-left max-h-[200px] overflow-auto">
|
<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-red-700">
|
<pre className="font-mono text-xs whitespace-pre-wrap break-all text-zinc-200 selection:bg-zinc-700">
|
||||||
{this.state.error.toString()}
|
{this.state.error.toString()}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="destructive"
|
||||||
onClick={this.handleReset}
|
onClick={this.handleReset}
|
||||||
className="rounded-lg font-bold bg-red-600 hover:bg-red-700 text-white"
|
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')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -72,4 +74,5 @@ export class ErrorBoundary extends Component<Props, State> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { ErrorBoundary };
|
||||||
export default ErrorBoundary;
|
export default ErrorBoundary;
|
||||||
@@ -6,6 +6,10 @@
|
|||||||
* 2. 通过 useSnackbarState Hook 使用:在组件内部自动管理状态
|
* 2. 通过 useSnackbarState Hook 使用:在组件内部自动管理状态
|
||||||
* 3. 通过 SnackbarProvider 和 useSnackbar Hook 使用:全局单例模式
|
* 3. 通过 SnackbarProvider 和 useSnackbar Hook 使用:全局单例模式
|
||||||
*
|
*
|
||||||
|
* NOTE: 项目同时使用 sonner 的 toast 进行简单的一次性提示。
|
||||||
|
* 本组件适用于需要 severity 级别、Provider 上下文、自定义定位等高级场景。
|
||||||
|
* 简单场景(如复制成功、操作提示)优先使用 `import { toast } from 'sonner'`。
|
||||||
|
*
|
||||||
* @module GlobalSnackbar
|
* @module GlobalSnackbar
|
||||||
* @version 1.1.0
|
* @version 1.1.0
|
||||||
*
|
*
|
||||||
@@ -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 { useSnackbar } from '@/components/GlobalSnackbar';
|
import { useSnackbar } from '@/components/GlobalSnackbar';
|
||||||
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
import { useI18n } from '@/utils/chromeI18n';
|
||||||
|
|
||||||
interface ImageUploaderProps {
|
interface ImageUploaderProps {
|
||||||
/** 选中的文件 */
|
/** 选中的文件 */
|
||||||
@@ -29,7 +29,7 @@ const ImageUploader = ({
|
|||||||
dragging,
|
dragging,
|
||||||
onDraggingChange,
|
onDraggingChange,
|
||||||
}: ImageUploaderProps) => {
|
}: ImageUploaderProps) => {
|
||||||
const { t } = useLazyTranslation('qrCode');
|
const { t } = useI18n('qrCode');
|
||||||
const { showMessage } = useSnackbar();
|
const { showMessage } = useSnackbar();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ const ImageUploader = ({
|
|||||||
[onFileChange, onPreviewUrlChange],
|
[onFileChange, onPreviewUrlChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleClearFile = () => {
|
const handleClearFile = useCallback(() => {
|
||||||
if (previewUrl) {
|
if (previewUrl) {
|
||||||
URL.revokeObjectURL(previewUrl);
|
URL.revokeObjectURL(previewUrl);
|
||||||
}
|
}
|
||||||
@@ -50,7 +50,7 @@ const ImageUploader = ({
|
|||||||
severity: 'success',
|
severity: 'success',
|
||||||
autoHideDuration: 1000,
|
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) {
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Component, ErrorInfo, ReactNode } from 'react';
|
import { Component, ErrorInfo, ReactNode } from 'react';
|
||||||
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 {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -12,11 +13,7 @@ interface State {
|
|||||||
error: Error | null;
|
error: Error | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
class PageErrorBoundary extends Component<Props, State> {
|
||||||
* 页面级错误边界组件:捕获子组件树中的 JavaScript 错误
|
|
||||||
* 完美适配 shadcn/ui 语义化主题与暗黑模式
|
|
||||||
*/
|
|
||||||
export class PageErrorBoundary extends Component<Props, State> {
|
|
||||||
state: State = {
|
state: State = {
|
||||||
hasError: false,
|
hasError: false,
|
||||||
error: null,
|
error: null,
|
||||||
@@ -44,23 +41,18 @@ export class PageErrorBoundary extends Component<Props, State> {
|
|||||||
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">
|
||||||
{/*
|
|
||||||
1. 适配暗黑模式的容器设计:
|
|
||||||
不再使用 border-red-200 / bg-red-50,改用标准的 border-destructive/20 和 bg-destructive/5,
|
|
||||||
并在黑夜模式下会自动转为深红底色,绝不刺眼。
|
|
||||||
*/}
|
|
||||||
<div className="p-6 text-center rounded-xl border border-destructive/20 bg-destructive/5 max-w-md w-full shadow-sm">
|
<div className="p-6 text-center rounded-xl border border-destructive/20 bg-destructive/5 max-w-md w-full shadow-sm">
|
||||||
{/* 2. 状态符号改用标准的 text-destructive 语义色 */}
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10 text-destructive mx-auto mb-4">
|
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10 text-destructive mx-auto mb-4">
|
||||||
<AlertCircle className="h-6 w-6" />
|
<AlertCircle className="h-6 w-6" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 className="text-base font-semibold text-foreground mb-1.5">该功能运行异常</h3>
|
<h3 className="text-base font-semibold text-foreground mb-1.5">
|
||||||
|
{getMessage('pageErrorBoundary_title')}
|
||||||
|
</h3>
|
||||||
<p className="text-xs text-muted-foreground mb-5">
|
<p className="text-xs text-muted-foreground mb-5">
|
||||||
该页面在加载或渲染时遇到了内部脚本错误。您可以尝试重试,或者通过导航菜单切换到其他工具。
|
{getMessage('pageErrorBoundary_description')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* 3. 错误日志展示:使用与 shadcn 贴合的深色代码块包裹 */}
|
|
||||||
{this.state.error && (
|
{this.state.error && (
|
||||||
<div className="mb-5 p-3 rounded-lg bg-zinc-950 dark:bg-zinc-900 text-left max-h-40 overflow-y-auto border border-border/40">
|
<div className="mb-5 p-3 rounded-lg bg-zinc-950 dark:bg-zinc-900 text-left max-h-40 overflow-y-auto border border-border/40">
|
||||||
<pre className="font-mono text-[11px] leading-relaxed whitespace-pre-wrap break-all text-zinc-200 selection:bg-zinc-700">
|
<pre className="font-mono text-[11px] leading-relaxed whitespace-pre-wrap break-all text-zinc-200 selection:bg-zinc-700">
|
||||||
@@ -69,11 +61,6 @@ export class PageErrorBoundary extends Component<Props, State> {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/*
|
|
||||||
4. 严谨调用 shadcn 原子 Button:
|
|
||||||
去掉全部手动指定的红底白字类名,直接启用 variant="destructive"。
|
|
||||||
它会自动处理 hover 颜色变化、暗黑模式切换以及无障碍高亮边框。
|
|
||||||
*/}
|
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -81,7 +68,7 @@ export 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')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -92,4 +79,5 @@ export class PageErrorBoundary extends Component<Props, State> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { PageErrorBoundary };
|
||||||
export default PageErrorBoundary;
|
export default PageErrorBoundary;
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Copy, Download } from 'lucide-react';
|
import { Copy, Download } from 'lucide-react';
|
||||||
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
import { useI18n } from '@/utils/chromeI18n';
|
||||||
import { cn } from '@/lib/utils'; // 1. 引入标准的 shadcn 工具函数
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
// 继承原生 HTML Div 属性,方便外部无缝扩充类名或监听事件
|
// 继承原生 HTML Div 属性,方便外部无缝扩充类名或监听事件
|
||||||
interface QrCodePreviewProps extends React.HTMLAttributes<HTMLDivElement> {
|
interface QrCodePreviewProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
@@ -20,7 +21,7 @@ const QrCodePreview = ({
|
|||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: QrCodePreviewProps) => {
|
}: QrCodePreviewProps) => {
|
||||||
const { t } = useLazyTranslation('qrCode');
|
const { t } = useI18n('qrCode');
|
||||||
|
|
||||||
// 空状态下的虚线骨架屏
|
// 空状态下的虚线骨架屏
|
||||||
if (!qrCodeDataUrl) {
|
if (!qrCodeDataUrl) {
|
||||||
@@ -59,27 +60,16 @@ const QrCodePreview = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 3. 按钮群全面向 shadcn 官方 Button 视觉规范对齐 */}
|
|
||||||
<div className="flex w-full gap-2 mt-5">
|
<div className="flex w-full gap-2 mt-5">
|
||||||
{/* 下载按钮:使用标准的次要按钮风格 (Outline) */}
|
<Button variant="outline" size="sm" onClick={onDownload} className="flex-1">
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onDownload}
|
|
||||||
className="flex-1 inline-flex h-9 items-center justify-center gap-2 px-3 text-sm font-medium rounded-md border border-input bg-background shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
||||||
>
|
|
||||||
<Download className="w-4 h-4 text-muted-foreground" />
|
<Download className="w-4 h-4 text-muted-foreground" />
|
||||||
<span className="truncate">{t('qrCode:downloadButton')}</span>
|
<span className="truncate">{t('qrCode:downloadButton')}</span>
|
||||||
</button>
|
</Button>
|
||||||
|
|
||||||
{/* 复制按钮:使用标准的主要行动按钮风格 (Default) */}
|
<Button variant="default" size="sm" onClick={onCopy} className="flex-1">
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onCopy}
|
|
||||||
className="flex-1 inline-flex h-9 items-center justify-center gap-2 px-3 text-sm font-medium rounded-md bg-primary text-primary-foreground shadow transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
||||||
>
|
|
||||||
<Copy className="w-4 h-4" />
|
<Copy className="w-4 h-4" />
|
||||||
<span className="truncate">{t('qrCode:copyQrButton')}</span>
|
<span className="truncate">{t('qrCode:copyQrButton')}</span>
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# components/
|
||||||
|
|
||||||
|
通用业务组件目录,存放跨页面复用的 UI 组件,与具体工具页面解耦。
|
||||||
|
|
||||||
|
## 组件列表
|
||||||
|
|
||||||
|
| 组件 | 用途 |
|
||||||
|
| ----------------------- | ------------------------------------------------------------------------------ |
|
||||||
|
| `TopBar.tsx` | 顶部导航栏,集成搜索(含历史记录)、主题切换、语言切换、返回导航 |
|
||||||
|
| `RouterContainer.tsx` | 路由容器,根据当前路由动态渲染对应页面组件,集成错误边界和骨架屏 |
|
||||||
|
| `SwitchButtonGroup.tsx` | 通用切换按钮组,支持 `small/medium/large` 三种尺寸,用于页面子模式切换 |
|
||||||
|
| `TextInputArea.tsx` | 增强文本输入区域,支持校验规则、工具栏操作、字符计数、清空 |
|
||||||
|
| `CopyButton.tsx` | 一键复制按钮,支持复制成功状态动画,封装 `copyTextToClipboard` 和 `toast` 反馈 |
|
||||||
|
| `ImageUploader.tsx` | 图片上传组件,支持拖拽上传、文件选择和预览 |
|
||||||
|
| `QrCodePreview.tsx` | 二维码预览组件,展示生成的二维码图片,提供复制和下载操作 |
|
||||||
|
| `DecodeResultPaper.tsx` | Base64 解码结果展示面板,显示 MIME 类型、文件大小、文件名输入和下载按钮 |
|
||||||
|
| `GlobalSnackbar.tsx` | 全局消息提示组件 + Context Provider,支持受控/Hook/全局单例三种使用方式 |
|
||||||
|
| `ErrorBoundary.tsx` | 全局错误边界(类组件),捕获子组件树 JS 错误并展示友好错误页面 |
|
||||||
|
| `PageErrorBoundary.tsx` | 页面级错误边界,适配 shadcn 暗黑模式,支持 `resetKey` 自动恢复 |
|
||||||
|
| `PageSkeleton.tsx` | 页面骨架屏,提供 `dashboard` 和 `tool` 两种变体,用于 Suspense fallback |
|
||||||
|
|
||||||
|
## 使用约定
|
||||||
|
|
||||||
|
- 优先使用 `components/ui/` 下的 shadcn/ui 基础组件
|
||||||
|
- 组件使用 `cn()` 合并 Tailwind 类名,支持 `className` 透传
|
||||||
|
- 需要 memo 优化的组件使用 `React.memo` + `displayName`
|
||||||
|
- 需要 ref 转发的组件使用 `React.forwardRef` + `displayName`
|
||||||
@@ -1,38 +1,35 @@
|
|||||||
import { FEATURES, getEntryPointType } from '@/config/features';
|
import { FEATURES, getEntryPointType } from '@/config/features';
|
||||||
import { useRouter } from '@/providers/RouterProvider';
|
import { useRouter } from '@/providers/RouterProvider';
|
||||||
import { Suspense, useMemo } from 'react';
|
import { Suspense, useMemo } from 'react';
|
||||||
|
import { useI18n } from '@/utils/chromeI18n';
|
||||||
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'; // 1. 引入标准的 shadcn 工具函数
|
import { cn } from '@/lib/utils';
|
||||||
import { AlertTriangle } from 'lucide-react'; // 用于标准的 404 异常展示
|
import { AlertTriangle } from 'lucide-react';
|
||||||
|
|
||||||
export default function RouterContainer() {
|
export default function RouterContainer() {
|
||||||
const { currentPage, isLoaded } = useRouter();
|
const { currentPage, isLoaded } = useRouter();
|
||||||
|
const { t } = useI18n('common');
|
||||||
|
|
||||||
// 2. 稳定的动态动画类名映射
|
|
||||||
const animationClass = useMemo(() => {
|
const animationClass = useMemo(() => {
|
||||||
return currentPage === 'dashboard' ? 'page-transition-dashboard' : 'page-transition-enter';
|
return currentPage === 'dashboard' ? 'page-transition-dashboard' : 'page-transition-enter';
|
||||||
}, [currentPage]);
|
}, [currentPage]);
|
||||||
|
|
||||||
const entryPointType = useMemo(() => {
|
const entryPointType = getEntryPointType();
|
||||||
return 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,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -40,11 +37,6 @@ 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 />
|
||||||
) : (
|
) : (
|
||||||
@@ -52,9 +44,9 @@ export default function RouterContainer() {
|
|||||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-destructive/10 text-destructive mb-4">
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-destructive/10 text-destructive mb-4">
|
||||||
<AlertTriangle className="h-6 w-6" />
|
<AlertTriangle className="h-6 w-6" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-sm font-semibold text-foreground">页面未找到</h3>
|
<h3 className="text-sm font-semibold text-foreground">{t('router.notFound')}</h3>
|
||||||
<p className="text-xs text-muted-foreground mt-1 max-w-[240px]">
|
<p className="text-xs text-muted-foreground mt-1 max-w-[240px]">
|
||||||
该功能在当前运行环境({entryPointType})下不可用或已被移除。
|
{t('router.notFoundDescription', { entryPointType })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { cn } from '@/lib/utils'; // 1. 引入标准的 shadcn 工具函数
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
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'
|
||||||
@@ -15,7 +14,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; // 替换原有的 buttonSx
|
buttonClassName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SwitchButtonGroup<T extends string | number = string>({
|
export default function SwitchButtonGroup<T extends string | number = string>({
|
||||||
@@ -27,21 +26,16 @@ 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',
|
||||||
large: 'text-base h-11 px-4 py-2 rounded-lg',
|
large: 'text-base h-11 px-4 py-2 rounded-lg',
|
||||||
};
|
};
|
||||||
|
|
||||||
const containerPadding = size === 'large' ? 'p-1' : 'p-1';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
// 将默认布局设计得更为通用(去掉一刀切的 mb-4,由外部控制布局空间)
|
'inline-flex w-full items-center justify-center rounded-lg bg-muted text-muted-foreground p-1',
|
||||||
'inline-flex w-full items-center justify-center rounded-lg bg-muted text-muted-foreground',
|
|
||||||
containerPadding,
|
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -55,7 +49,6 @@ 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,8 +1,9 @@
|
|||||||
import React, { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react';
|
import React, { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react';
|
||||||
import { Copy, X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useI18n } from '@/utils/chromeI18n';
|
||||||
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';
|
||||||
|
|
||||||
export type ValidateRule = {
|
export type ValidateRule = {
|
||||||
validator: (value: string) => boolean;
|
validator: (value: string) => boolean;
|
||||||
@@ -113,17 +114,15 @@ 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 } = useTranslation('common');
|
const { t } = useI18n('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;
|
||||||
@@ -131,14 +130,13 @@ const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props
|
|||||||
// 重置高度计算
|
// 重置高度计算
|
||||||
textArea.style.height = 'auto';
|
textArea.style.height = 'auto';
|
||||||
|
|
||||||
const computedMin = minRows * 24; // 每行粗略按 24px 计算
|
const computedMin = minRows * 24;
|
||||||
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]);
|
||||||
@@ -183,19 +181,9 @@ const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props
|
|||||||
onChange?.('');
|
onChange?.('');
|
||||||
setError('');
|
setError('');
|
||||||
internalRef.current?.focus();
|
internalRef.current?.focus();
|
||||||
toast.success('已清空内容');
|
toast.success(t('textInputArea.cleared'));
|
||||||
onClear?.();
|
onClear?.();
|
||||||
}, [isControlled, onChange, onClear]);
|
}, [isControlled, onChange, onClear, t]);
|
||||||
|
|
||||||
const handleCopy = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(value);
|
|
||||||
toast.success('复制成功');
|
|
||||||
} catch {
|
|
||||||
setError('复制失败');
|
|
||||||
toast.error('复制失败');
|
|
||||||
}
|
|
||||||
}, [value]);
|
|
||||||
|
|
||||||
const handleAction = useCallback(
|
const handleAction = useCallback(
|
||||||
(action: ToolbarAction) => {
|
(action: ToolbarAction) => {
|
||||||
@@ -284,14 +272,12 @@ const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props
|
|||||||
{/* 右侧系统按钮组 */}
|
{/* 右侧系统按钮组 */}
|
||||||
<div className="flex items-center gap-1.5 ml-auto shrink-0">
|
<div className="flex items-center gap-1.5 ml-auto shrink-0">
|
||||||
{allowCopy && value && (
|
{allowCopy && value && (
|
||||||
<button
|
<CopyButton
|
||||||
type="button"
|
text={value}
|
||||||
onClick={handleCopy}
|
tooltip={t('textInputArea.copyContent')}
|
||||||
aria-label={t('textInputArea.copyContent')}
|
size="sm"
|
||||||
className="p-1 h-7 w-7 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-background/80 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
className="h-7 w-7 p-1"
|
||||||
>
|
/>
|
||||||
<Copy className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
{showClear && value && !disabled && !readOnly && (
|
{showClear && value && !disabled && !readOnly && (
|
||||||
<button
|
<button
|
||||||
@@ -1,32 +1,20 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import { ArrowLeft, ExternalLink, Monitor, Moon, Search, Sun, X } from 'lucide-react';
|
||||||
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 { useTranslation } from 'react-i18next';
|
import { openExtensionPage } from '@/utils/chromeTabs';
|
||||||
import { normalizeLanguage, SUPPORTED_LANGUAGES } from '@/i18n';
|
import { useI18n } from '@/utils/chromeI18n';
|
||||||
import { cn } from '@/lib/utils'; // 1. 引入 shadcn 核心工具函数
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
// 常量配置抽取(无需写在全局变量或 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({ onOpenOptions }: { onOpenOptions: () => void }) {
|
export default function TopBar() {
|
||||||
const { currentPage, goBack, navigateTo } = useRouter();
|
const { currentPage, goBack, navigateTo } = useRouter();
|
||||||
const { mode, setMode } = useThemeMode();
|
const { mode, setMode } = useThemeMode();
|
||||||
const { t, i18n } = useTranslation(['common', 'features']);
|
const { t } = useI18n(['common', 'features']);
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [showResults, setShowResults] = useState(false);
|
const [showResults, setShowResults] = useState(false);
|
||||||
@@ -41,7 +29,6 @@ export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void })
|
|||||||
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)) {
|
||||||
@@ -52,7 +39,19 @@ export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void })
|
|||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 从 Chrome Storage 异步初始化历史记录
|
// Cmd/Ctrl+K 快捷键聚焦搜索框
|
||||||
|
useEffect(() => {
|
||||||
|
const handleGlobalKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||||
|
e.preventDefault();
|
||||||
|
inputRef.current?.focus();
|
||||||
|
setShowResults(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', handleGlobalKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', handleGlobalKeyDown);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
storageUtil
|
storageUtil
|
||||||
.get('app/searchHistory', [])
|
.get('app/searchHistory', [])
|
||||||
@@ -62,7 +61,6 @@ export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void })
|
|||||||
.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 [];
|
||||||
@@ -77,13 +75,15 @@ export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void })
|
|||||||
|
|
||||||
const displayedHistory = useMemo(() => {
|
const displayedHistory = useMemo(() => {
|
||||||
if (searchQuery.trim()) return [];
|
if (searchQuery.trim()) return [];
|
||||||
return searchHistory.slice(0, SEARCH_HISTORY_DISPLAY);
|
return searchHistory
|
||||||
|
.slice(0, SEARCH_HISTORY_DISPLAY)
|
||||||
|
.map((key) => ({ key, feature: FEATURES.find((f) => f.key === key) }))
|
||||||
|
.filter((item) => item.feature && item.feature.key !== 'dashboard');
|
||||||
}, [searchHistory, searchQuery]);
|
}, [searchHistory, searchQuery]);
|
||||||
|
|
||||||
// 新增/持久化历史记录
|
const saveToHistory = async (featureKey: string) => {
|
||||||
const saveToHistory = async (query: string) => {
|
if (!featureKey.trim()) return;
|
||||||
if (!query.trim()) return;
|
const nextHistory = [featureKey, ...searchHistory.filter((h) => h !== featureKey)].slice(
|
||||||
const nextHistory = [query, ...searchHistory.filter((h) => h !== query)].slice(
|
|
||||||
0,
|
0,
|
||||||
SEARCH_HISTORY_LIMIT,
|
SEARCH_HISTORY_LIMIT,
|
||||||
);
|
);
|
||||||
@@ -93,19 +93,11 @@ export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void })
|
|||||||
|
|
||||||
const handleSelectFeature = (feature: FeatureConfig) => {
|
const handleSelectFeature = (feature: FeatureConfig) => {
|
||||||
navigateTo(feature.key);
|
navigateTo(feature.key);
|
||||||
saveToHistory(t(feature.labelKey));
|
saveToHistory(feature.key);
|
||||||
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]);
|
||||||
@@ -113,7 +105,6 @@ export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void })
|
|||||||
|
|
||||||
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;
|
||||||
|
|
||||||
@@ -125,17 +116,14 @@ export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void })
|
|||||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev));
|
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev));
|
||||||
} else if (e.key === 'Enter') {
|
} else if (e.key === 'Enter') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (selectedIndex >= 0) {
|
if (selectedIndex >= 0 && selectedIndex < totalItems) {
|
||||||
if (searchQuery.trim()) {
|
if (searchQuery.trim()) {
|
||||||
handleSelectFeature(searchResults[selectedIndex]);
|
handleSelectFeature(searchResults[selectedIndex]);
|
||||||
} else {
|
} else {
|
||||||
const selectedQuery = displayedHistory[selectedIndex];
|
const selected = displayedHistory[selectedIndex];
|
||||||
setSearchQuery(selectedQuery);
|
if (selected?.feature) {
|
||||||
setSelectedIndex(-1);
|
handleSelectFeature(selected.feature);
|
||||||
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) {
|
||||||
handleSelectFeature(searchResults[0]);
|
handleSelectFeature(searchResults[0]);
|
||||||
@@ -156,7 +144,7 @@ export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void })
|
|||||||
<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" />
|
||||||
@@ -166,12 +154,12 @@ export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void })
|
|||||||
|
|
||||||
{/* 中间:搜索容器 */}
|
{/* 中间:搜索容器 */}
|
||||||
<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">
|
<div className="relative group">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
<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" />
|
||||||
<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);
|
||||||
@@ -180,9 +168,14 @@ export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void })
|
|||||||
}}
|
}}
|
||||||
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-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"
|
className="w-full h-9 pl-9 pr-16 text-sm rounded-lg border border-border/60 bg-muted/40 transition-all placeholder:text-muted-foreground/50 focus:bg-background focus:outline-none focus:ring-1 focus:ring-ring focus:border-input"
|
||||||
/>
|
/>
|
||||||
|
{!searchQuery && (
|
||||||
|
<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"
|
||||||
@@ -200,8 +193,8 @@ export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void })
|
|||||||
|
|
||||||
{/* 动态联想结果卡片 */}
|
{/* 动态联想结果卡片 */}
|
||||||
{showResults && (searchQuery.trim() || displayedHistory.length > 0) && (
|
{showResults && (searchQuery.trim() || displayedHistory.length > 0) && (
|
||||||
<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">
|
<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">
|
||||||
<ul role="listbox" className="p-1">
|
<ul role="listbox" className="p-1.5">
|
||||||
{searchQuery.trim() ? (
|
{searchQuery.trim() ? (
|
||||||
searchResults.length > 0 ? (
|
searchResults.length > 0 ? (
|
||||||
searchResults.map((feature, index) => (
|
searchResults.map((feature, index) => (
|
||||||
@@ -211,20 +204,25 @@ export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void })
|
|||||||
aria-selected={selectedIndex === index}
|
aria-selected={selectedIndex === index}
|
||||||
onClick={() => handleSelectFeature(feature)}
|
onClick={() => handleSelectFeature(feature)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-3 px-2.5 py-2 rounded-sm cursor-pointer text-sm transition-colors',
|
'flex items-center gap-3 px-3 py-2.5 rounded-md 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 className="flex h-7 w-7 shrink-0 items-center justify-center rounded-sm bg-muted text-muted-foreground">
|
<div
|
||||||
|
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">
|
<p className="text-xs text-muted-foreground truncate mt-0.5">
|
||||||
{t(feature.descriptionKey)}
|
{t(feature.descriptionKey)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -237,27 +235,38 @@ export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void })
|
|||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="px-2.5 py-1.5 text-xs font-semibold tracking-wider text-muted-foreground/80">
|
<div className="px-3 py-1.5 text-[11px] font-semibold tracking-wider text-muted-foreground/60 uppercase">
|
||||||
{t('common:buttons.recentSearch')}
|
{t('common:buttons.recentSearch')}
|
||||||
</div>
|
</div>
|
||||||
{displayedHistory.map((item, index) => (
|
{displayedHistory.map((item, index) => (
|
||||||
<li
|
<li
|
||||||
key={item}
|
key={item.key}
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={selectedIndex === index}
|
aria-selected={selectedIndex === index}
|
||||||
onClick={() => {
|
onClick={() => item.feature && handleSelectFeature(item.feature)}
|
||||||
setSearchQuery(item);
|
|
||||||
setSelectedIndex(-1);
|
|
||||||
}}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-3 px-2.5 py-2 rounded-sm cursor-pointer text-sm transition-colors',
|
'flex items-center gap-3 px-3 py-2 rounded-md 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',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<History className="h-4 w-4 text-muted-foreground shrink-0" />
|
<div
|
||||||
<span className="truncate">{item}</span>
|
className={cn(
|
||||||
|
'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>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
@@ -269,18 +278,12 @@ export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void })
|
|||||||
|
|
||||||
{/* 右侧:操作区 */}
|
{/* 右侧:操作区 */}
|
||||||
<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>
|
||||||
);
|
);
|
||||||
+4
-4
@@ -40,7 +40,7 @@ describe('DecodeResultPaper 组件', () => {
|
|||||||
|
|
||||||
it('应渲染下载按钮', () => {
|
it('应渲染下载按钮', () => {
|
||||||
render(<DecodeResultPaper {...defaultProps} />);
|
render(<DecodeResultPaper {...defaultProps} />);
|
||||||
expect(screen.getByRole('button', { name: 'download' })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: '下载' })).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: 'download' }));
|
fireEvent.click(screen.getByRole('button', { name: '下载' }));
|
||||||
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: 'download' })).toBeDisabled();
|
expect(screen.getByRole('button', { name: '下载' })).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('文件名不为空时下载按钮应启用', () => {
|
it('文件名不为空时下载按钮应启用', () => {
|
||||||
render(<DecodeResultPaper {...defaultProps} />);
|
render(<DecodeResultPaper {...defaultProps} />);
|
||||||
expect(screen.getByRole('button', { name: 'download' })).toBeEnabled();
|
expect(screen.getByRole('button', { name: '下载' })).toBeEnabled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+9
-17
@@ -1,14 +1,18 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { render, screen, fireEvent, act } from '@testing-library/react';
|
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||||
import ImageUploader from '@/components/ImageUploader';
|
import ImageUploader from '@/components/ImageUploader';
|
||||||
|
|
||||||
|
// 配置多端一致性常驻桩(WXT 规范)
|
||||||
|
const storageOnChangedMock = { addListener: vi.fn(), removeListener: vi.fn() };
|
||||||
|
(globalThis as any).chrome = { storage: { onChanged: storageOnChangedMock } };
|
||||||
|
(globalThis as any).browser = { storage: { onChanged: storageOnChangedMock } };
|
||||||
|
|
||||||
// 模拟 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 });
|
||||||
|
|
||||||
// 模拟 showMessage
|
|
||||||
vi.mock('@/components/GlobalSnackbar', () => ({
|
vi.mock('@/components/GlobalSnackbar', () => ({
|
||||||
useSnackbar: () => ({
|
useSnackbar: () => ({
|
||||||
showMessage: vi.fn(),
|
showMessage: vi.fn(),
|
||||||
@@ -44,8 +48,8 @@ describe('ImageUploader 组件', () => {
|
|||||||
describe('渲染测试', () => {
|
describe('渲染测试', () => {
|
||||||
it('当没有选中文件时应显示上传提示', () => {
|
it('当没有选中文件时应显示上传提示', () => {
|
||||||
render(<ImageUploader {...defaultProps} />);
|
render(<ImageUploader {...defaultProps} />);
|
||||||
expect(screen.getByText('qrCode:clickToUpload')).toBeInTheDocument();
|
expect(screen.getByText(/点击.*拖拽/)).toBeInTheDocument();
|
||||||
expect(screen.getByText('qrCode:supportFormats')).toBeInTheDocument();
|
expect(screen.getByText(/格式/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('当没有选中文件时应显示 ImageIcon', () => {
|
it('当没有选中文件时应显示 ImageIcon', () => {
|
||||||
@@ -59,7 +63,7 @@ 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('qrCode:clickToChange')).toBeInTheDocument();
|
expect(screen.getByText(/点击更换/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('当选中文件时应显示预览图片', () => {
|
it('当选中文件时应显示预览图片', () => {
|
||||||
@@ -157,18 +161,6 @@ describe('ImageUploader 组件', () => {
|
|||||||
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:test-url');
|
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:test-url');
|
||||||
expect(mockOnClearFile).toHaveBeenCalledTimes(1);
|
expect(mockOnClearFile).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('清除文件时应撤销预览 URL', () => {
|
|
||||||
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
|
||||||
render(
|
|
||||||
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
|
|
||||||
);
|
|
||||||
|
|
||||||
const clearButton = screen.getByTestId('ClearIcon').closest('button')!;
|
|
||||||
fireEvent.click(clearButton);
|
|
||||||
|
|
||||||
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:test-url');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('粘贴功能', () => {
|
describe('粘贴功能', () => {
|
||||||
+7
-7
@@ -18,7 +18,7 @@ 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('qrCode:qrCodeWillShow')).toBeInTheDocument();
|
expect(screen.getByText(/二维码将显示/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('当 qrCodeDataUrl 有值时应显示二维码图片', () => {
|
it('当 qrCodeDataUrl 有值时应显示二维码图片', () => {
|
||||||
@@ -43,7 +43,7 @@ describe('QrCodePreview 组件', () => {
|
|||||||
onCopy={mockOnCopy}
|
onCopy={mockOnCopy}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
expect(screen.getByText('qrCode:downloadButton')).toBeInTheDocument();
|
expect(screen.getByText(/下载二维码/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('当 qrCodeDataUrl 有值时应显示复制按钮', () => {
|
it('当 qrCodeDataUrl 有值时应显示复制按钮', () => {
|
||||||
@@ -54,13 +54,13 @@ describe('QrCodePreview 组件', () => {
|
|||||||
onCopy={mockOnCopy}
|
onCopy={mockOnCopy}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
expect(screen.getByText('qrCode:copyQrButton')).toBeInTheDocument();
|
expect(screen.getByText(/复制二维码/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('当 qrCodeDataUrl 为空时不应显示操作按钮', () => {
|
it('当 qrCodeDataUrl 为空时不应显示操作按钮', () => {
|
||||||
render(<QrCodePreview qrCodeDataUrl="" onDownload={mockOnDownload} onCopy={mockOnCopy} />);
|
render(<QrCodePreview qrCodeDataUrl="" onDownload={mockOnDownload} onCopy={mockOnCopy} />);
|
||||||
expect(screen.queryByText('qrCode:downloadButton')).not.toBeInTheDocument();
|
expect(screen.queryByText(/下载二维码/)).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText('qrCode:copyQrButton')).not.toBeInTheDocument();
|
expect(screen.queryByText(/复制二维码/)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -73,7 +73,7 @@ describe('QrCodePreview 组件', () => {
|
|||||||
onCopy={mockOnCopy}
|
onCopy={mockOnCopy}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
fireEvent.click(screen.getByText('qrCode:downloadButton'));
|
fireEvent.click(screen.getByText(/下载二维码/));
|
||||||
expect(mockOnDownload).toHaveBeenCalledTimes(1);
|
expect(mockOnDownload).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ describe('QrCodePreview 组件', () => {
|
|||||||
onCopy={mockOnCopy}
|
onCopy={mockOnCopy}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
fireEvent.click(screen.getByText('qrCode:copyQrButton'));
|
fireEvent.click(screen.getByText(/复制二维码/));
|
||||||
expect(mockOnCopy).toHaveBeenCalledTimes(1);
|
expect(mockOnCopy).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+20
-18
@@ -4,6 +4,10 @@ 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';
|
||||||
|
|
||||||
|
const storageOnChangedMock = { addListener: vi.fn(), removeListener: vi.fn() };
|
||||||
|
(globalThis as any).chrome = { storage: { onChanged: storageOnChangedMock } };
|
||||||
|
(globalThis as any).browser = { storage: { onChanged: storageOnChangedMock } };
|
||||||
|
|
||||||
describe('StorageCleanerConfirm 组件', () => {
|
describe('StorageCleanerConfirm 组件', () => {
|
||||||
const mockOnClose = vi.fn();
|
const mockOnClose = vi.fn();
|
||||||
const mockOnConfirm = vi.fn();
|
const mockOnConfirm = vi.fn();
|
||||||
@@ -36,27 +40,25 @@ describe('StorageCleanerConfirm 组件', () => {
|
|||||||
describe('渲染测试', () => {
|
describe('渲染测试', () => {
|
||||||
it('open 为 true 时应渲染对话框', () => {
|
it('open 为 true 时应渲染对话框', () => {
|
||||||
renderComponent();
|
renderComponent();
|
||||||
expect(screen.getByText('storageCleaner:confirmTitle')).toBeInTheDocument();
|
expect(screen.getByRole('heading', { name: /确认清理/ })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应显示警告信息', () => {
|
it('应显示警告信息', () => {
|
||||||
renderComponent();
|
renderComponent();
|
||||||
expect(screen.getByText(/storageCleaner:irreversible/i)).toBeInTheDocument();
|
expect(screen.getByText(/不可撤销/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应将选中的选项显示为标签', () => {
|
it('应将选中的选项显示为标签', () => {
|
||||||
renderComponent();
|
renderComponent();
|
||||||
expect(screen.getByText('storageCleaner:options.localStorage')).toBeInTheDocument();
|
expect(screen.getByText(/Local Storage/)).toBeInTheDocument();
|
||||||
expect(screen.getByText('storageCleaner:options.sessionStorage')).toBeInTheDocument();
|
expect(screen.getByText(/Session Storage/)).toBeInTheDocument();
|
||||||
expect(screen.getByText('storageCleaner:options.cookies')).toBeInTheDocument();
|
expect(screen.getByText(/Cookies/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应显示取消和确认按钮', () => {
|
it('应显示取消和确认按钮', () => {
|
||||||
renderComponent();
|
renderComponent();
|
||||||
expect(screen.getByRole('button', { name: /common:buttons.cancel/i })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: /取消/ })).toBeInTheDocument();
|
||||||
expect(
|
expect(screen.getByRole('button', { name: /确认清理/ })).toBeInTheDocument();
|
||||||
screen.getByRole('button', { name: /storageCleaner:confirmAction/i }),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -64,7 +66,7 @@ describe('StorageCleanerConfirm 组件', () => {
|
|||||||
it('点击取消时应调用 onClose', () => {
|
it('点击取消时应调用 onClose', () => {
|
||||||
renderComponent();
|
renderComponent();
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /common:buttons.cancel/i }));
|
fireEvent.click(screen.getByRole('button', { name: /取消/ }));
|
||||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||||
expect(mockOnConfirm).not.toHaveBeenCalled();
|
expect(mockOnConfirm).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -72,7 +74,7 @@ describe('StorageCleanerConfirm 组件', () => {
|
|||||||
it('点击确认时应调用 onConfirm', () => {
|
it('点击确认时应调用 onConfirm', () => {
|
||||||
renderComponent();
|
renderComponent();
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /storageCleaner:confirmAction/i }));
|
fireEvent.click(screen.getByRole('button', { name: /确认清理/ }));
|
||||||
expect(mockOnConfirm).toHaveBeenCalledTimes(1);
|
expect(mockOnConfirm).toHaveBeenCalledTimes(1);
|
||||||
expect(mockOnClose).not.toHaveBeenCalled();
|
expect(mockOnClose).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -91,10 +93,10 @@ describe('StorageCleanerConfirm 组件', () => {
|
|||||||
|
|
||||||
renderComponent({ options: partialOptions });
|
renderComponent({ options: partialOptions });
|
||||||
|
|
||||||
expect(screen.getByText('storageCleaner:options.localStorage')).toBeInTheDocument();
|
expect(screen.getByText(/Local Storage/)).toBeInTheDocument();
|
||||||
expect(screen.getByText('storageCleaner:options.indexedDB')).toBeInTheDocument();
|
expect(screen.getByText(/IndexedDB/)).toBeInTheDocument();
|
||||||
expect(screen.queryByText('storageCleaner:options.sessionStorage')).not.toBeInTheDocument();
|
expect(screen.queryByText(/Session Storage/)).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText('storageCleaner:options.cookies')).not.toBeInTheDocument();
|
expect(screen.queryByText(/Cookies$/)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应处理空选项', () => {
|
it('应处理空选项', () => {
|
||||||
@@ -117,7 +119,7 @@ describe('StorageCleanerConfirm 组件', () => {
|
|||||||
describe('对话框行为测试', () => {
|
describe('对话框行为测试', () => {
|
||||||
it('open 为 false 时不应渲染', () => {
|
it('open 为 false 时不应渲染', () => {
|
||||||
renderComponent({ open: false });
|
renderComponent({ open: false });
|
||||||
expect(screen.queryByText('storageCleaner:confirmTitle')).not.toBeInTheDocument();
|
expect(screen.queryByRole('heading', { name: /确认清理/ })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应使用不同选项渲染', () => {
|
it('应使用不同选项渲染', () => {
|
||||||
@@ -132,8 +134,8 @@ describe('StorageCleanerConfirm 组件', () => {
|
|||||||
|
|
||||||
renderComponent({ options: customOptions });
|
renderComponent({ options: customOptions });
|
||||||
|
|
||||||
expect(screen.getByText('storageCleaner:options.sessionStorage')).toBeInTheDocument();
|
expect(screen.getByText(/Session Storage/)).toBeInTheDocument();
|
||||||
expect(screen.getByText('storageCleaner:options.cookies')).toBeInTheDocument();
|
expect(screen.getByText(/Cookies/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
-3
@@ -21,9 +21,7 @@ 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');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -41,7 +39,6 @@ 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');
|
||||||
});
|
});
|
||||||
|
|
||||||
-1
@@ -353,7 +353,6 @@ 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,7 +6,6 @@ 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) => ({
|
||||||
@@ -54,36 +53,23 @@ describe('TopBar 组件', () => {
|
|||||||
describe('渲染测试', () => {
|
describe('渲染测试', () => {
|
||||||
it('不在 dashboard 时应渲染返回按钮', () => {
|
it('不在 dashboard 时应渲染返回按钮', () => {
|
||||||
mockRouterValue.currentPage = 'timestamp';
|
mockRouterValue.currentPage = 'timestamp';
|
||||||
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
|
renderWithProvider(<TopBar />);
|
||||||
expect(screen.getByLabelText('common:buttons.back')).toBeInTheDocument();
|
expect(screen.getByLabelText('返回')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('在 dashboard 上不应渲染返回按钮', () => {
|
it('在 dashboard 上不应渲染返回按钮', () => {
|
||||||
mockRouterValue.currentPage = 'dashboard';
|
mockRouterValue.currentPage = 'dashboard';
|
||||||
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
|
renderWithProvider(<TopBar />);
|
||||||
expect(screen.queryByLabelText('common:buttons.back')).not.toBeInTheDocument();
|
expect(screen.queryByLabelText('返回')).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 onOpenOptions={vi.fn()} />);
|
renderWithProvider(<TopBar />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByLabelText('common:buttons.back'));
|
fireEvent.click(screen.getByLabelText('返回'));
|
||||||
expect(mockRouterValue.goBack).toHaveBeenCalledTimes(1);
|
expect(mockRouterValue.goBack).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# components/ui/
|
||||||
|
|
||||||
|
shadcn/ui 基础原子组件目录,基于 Radix UI 原语 + Tailwind CSS 实现。
|
||||||
|
|
||||||
|
## 组件列表
|
||||||
|
|
||||||
|
| 组件 | 用途 |
|
||||||
|
| -------------- | -------------------------------------------------------------------------------------------------- |
|
||||||
|
| `button.tsx` | 按钮组件,支持 `default/destructive/outline/secondary/ghost/link` 变体和 `default/sm/lg/icon` 尺寸 |
|
||||||
|
| `input.tsx` | 标准输入框,统一的 ring/focus 样式 |
|
||||||
|
| `select.tsx` | 下拉选择组件,包含 Trigger、Content、Item 等子组件 |
|
||||||
|
| `dialog.tsx` | 对话框组件,包含 Overlay、Content、Header、Footer、Title、Description |
|
||||||
|
| `checkbox.tsx` | 复选框组件 |
|
||||||
|
| `label.tsx` | 标签组件 |
|
||||||
|
| `switch.tsx` | 开关组件 |
|
||||||
|
| `badge.tsx` | 徽章组件,支持 `default/secondary/destructive/outline` 变体 |
|
||||||
|
|
||||||
|
## 使用方式
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
```
|
||||||
|
|
||||||
|
## 添加新组件
|
||||||
|
|
||||||
|
使用 shadcn/ui CLI 添加新组件:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn-ui@latest add <component-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
组件配置在项目根目录的 `components.json` 中定义。
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# config/
|
||||||
|
|
||||||
|
应用级配置目录,存放功能特性的注册中心。
|
||||||
|
|
||||||
|
## 文件说明
|
||||||
|
|
||||||
|
| 文件 | 用途 |
|
||||||
|
| -------------- | ---------------------------------------- |
|
||||||
|
| `features.tsx` | 核心配置文件,定义所有工具功能的注册信息 |
|
||||||
|
|
||||||
|
## features.tsx
|
||||||
|
|
||||||
|
`FEATURES` 数组是路由和功能元数据的**单一事实来源**,每个功能定义包含:
|
||||||
|
|
||||||
|
- `key`:页面类型标识(`PageType`)
|
||||||
|
- `labelKey` / `descriptionKey`:i18n 翻译键
|
||||||
|
- `themeColorKey`:主题色(`primary/success/warning/error/secondary/info`)
|
||||||
|
- `icon`:lucide-react 图标组件
|
||||||
|
- `defaultVisible`:默认是否可见
|
||||||
|
- `components`:三种渲染模式的懒加载组件(`popup`、`sidepanel`、`tab`)
|
||||||
|
|
||||||
|
## 已注册功能(11 个)
|
||||||
|
|
||||||
|
| key | 图标 | 说明 |
|
||||||
|
| -------------------- | ----------------- | ----------------- |
|
||||||
|
| `dashboard` | — | 仪表盘首页 |
|
||||||
|
| `timestamp` | Clock | 时间戳转换工具 |
|
||||||
|
| `storageCleaner` | Database | 存储清理工具 |
|
||||||
|
| `qrCode` | QrCode | 二维码工具 |
|
||||||
|
| `textStatistics` | FileText | 文本统计工具 |
|
||||||
|
| `jwt` | Key | JWT 解析工具 |
|
||||||
|
| `jsonDiff` | GitCompareArrows | JSON 差异比较工具 |
|
||||||
|
| `base64Converter` | ArrowLeftRight | Base64 转换器 |
|
||||||
|
| `markdownToHtml` | Code | Markdown 转 HTML |
|
||||||
|
| `htmlToMarkdown` | File | HTML 转 Markdown |
|
||||||
|
| `rightClickRestorer` | MousePointerClick | 右键菜单恢复工具 |
|
||||||
|
|
||||||
|
## 导出函数
|
||||||
|
|
||||||
|
- `getFeatureByKey(key)` — 根据 key 获取功能配置
|
||||||
|
- `getDefaultVisibleFeatureKeys()` — 获取默认可见的功能 key 列表
|
||||||
|
- `getAllFeatureKeys()` — 获取所有功能 key 列表
|
||||||
|
- `getDefaultPageOrder()` — 获取默认页面排序(不含 dashboard)
|
||||||
|
- `getEntryPointType()` — 判断当前入口类型(popup/sidepanel/tab)
|
||||||
@@ -9,8 +9,8 @@ import {
|
|||||||
|
|
||||||
describe('features', () => {
|
describe('features', () => {
|
||||||
describe('FEATURES', () => {
|
describe('FEATURES', () => {
|
||||||
it('should have 10 features defined', () => {
|
it('should have 9 features defined', () => {
|
||||||
expect(FEATURES).toHaveLength(10);
|
expect(FEATURES).toHaveLength(9);
|
||||||
});
|
});
|
||||||
|
|
||||||
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('features:dashboard.title');
|
expect(feature?.labelKey).toBe('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('features:timestamp.title');
|
expect(feature?.labelKey).toBe('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('features:storageCleaner.title');
|
expect(feature?.labelKey).toBe('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(10);
|
expect(allKeys).toHaveLength(9);
|
||||||
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,8 +104,7 @@ 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('rightClickRestorer');
|
||||||
expect(allKeys).toContain('htmlToMarkdown');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -122,9 +121,9 @@ describe('features', () => {
|
|||||||
expect(pageOrder).toContain('qrCode');
|
expect(pageOrder).toContain('qrCode');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have 9 items in page order', () => {
|
it('should have 8 items in page order', () => {
|
||||||
const pageOrder = getDefaultPageOrder();
|
const pageOrder = getDefaultPageOrder();
|
||||||
expect(pageOrder).toHaveLength(9);
|
expect(pageOrder).toHaveLength(8);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -9,8 +9,7 @@ import {
|
|||||||
Key,
|
Key,
|
||||||
GitCompareArrows,
|
GitCompareArrows,
|
||||||
ArrowLeftRight,
|
ArrowLeftRight,
|
||||||
Code,
|
MousePointerClick,
|
||||||
File,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
export type PaletteColorKey = 'primary' | 'success' | 'warning' | 'error' | 'secondary' | 'info';
|
export type PaletteColorKey = 'primary' | 'success' | 'warning' | 'error' | 'secondary' | 'info';
|
||||||
@@ -24,8 +23,7 @@ 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 RightClickRestorerPage = lazy(() => import('@/pages/RightClickRestorer'));
|
||||||
const HtmlToMarkdownPage = lazy(() => import('@/pages/HtmlToMarkdown'));
|
|
||||||
|
|
||||||
export interface FeatureConfig {
|
export interface FeatureConfig {
|
||||||
key: PageType;
|
key: PageType;
|
||||||
@@ -44,7 +42,7 @@ export interface FeatureConfig {
|
|||||||
export const FEATURES: FeatureConfig[] = [
|
export const FEATURES: FeatureConfig[] = [
|
||||||
{
|
{
|
||||||
key: 'dashboard',
|
key: 'dashboard',
|
||||||
labelKey: 'features:dashboard.title',
|
labelKey: 'dashboard_title',
|
||||||
descriptionKey: '',
|
descriptionKey: '',
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
components: {
|
components: {
|
||||||
@@ -55,8 +53,8 @@ export const FEATURES: FeatureConfig[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'timestamp',
|
key: 'timestamp',
|
||||||
labelKey: 'features:timestamp.title',
|
labelKey: 'timestamp_title',
|
||||||
descriptionKey: 'features:timestamp.description',
|
descriptionKey: 'timestamp_description',
|
||||||
themeColorKey: 'primary',
|
themeColorKey: 'primary',
|
||||||
icon: Clock,
|
icon: Clock,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
@@ -68,8 +66,8 @@ export const FEATURES: FeatureConfig[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'storageCleaner',
|
key: 'storageCleaner',
|
||||||
labelKey: 'features:storageCleaner.title',
|
labelKey: 'storageCleaner_title',
|
||||||
descriptionKey: 'features:storageCleaner.description',
|
descriptionKey: 'storageCleaner_description',
|
||||||
themeColorKey: 'warning',
|
themeColorKey: 'warning',
|
||||||
icon: Database,
|
icon: Database,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
@@ -81,8 +79,8 @@ export const FEATURES: FeatureConfig[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'qrCode',
|
key: 'qrCode',
|
||||||
labelKey: 'features:qrCode.title',
|
labelKey: 'qrCode_title',
|
||||||
descriptionKey: 'features:qrCode.description',
|
descriptionKey: 'qrCode_description',
|
||||||
themeColorKey: 'success',
|
themeColorKey: 'success',
|
||||||
icon: QrCode,
|
icon: QrCode,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
@@ -94,8 +92,8 @@ export const FEATURES: FeatureConfig[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'textStatistics',
|
key: 'textStatistics',
|
||||||
labelKey: 'features:textStatistics.title',
|
labelKey: 'textStatistics_title',
|
||||||
descriptionKey: 'features:textStatistics.description',
|
descriptionKey: 'textStatistics_description',
|
||||||
themeColorKey: 'secondary',
|
themeColorKey: 'secondary',
|
||||||
icon: FileText,
|
icon: FileText,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
@@ -107,8 +105,8 @@ export const FEATURES: FeatureConfig[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'jwt',
|
key: 'jwt',
|
||||||
labelKey: 'features:jwt.title',
|
labelKey: 'jwt_title',
|
||||||
descriptionKey: 'features:jwt.description',
|
descriptionKey: 'jwt_description',
|
||||||
themeColorKey: 'info',
|
themeColorKey: 'info',
|
||||||
icon: Key,
|
icon: Key,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
@@ -120,8 +118,8 @@ export const FEATURES: FeatureConfig[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'jsonDiff',
|
key: 'jsonDiff',
|
||||||
labelKey: 'features:jsonDiff.title',
|
labelKey: 'jsonDiff_title',
|
||||||
descriptionKey: 'features:jsonDiff.description',
|
descriptionKey: 'jsonDiff_description',
|
||||||
themeColorKey: 'primary',
|
themeColorKey: 'primary',
|
||||||
icon: GitCompareArrows,
|
icon: GitCompareArrows,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
@@ -133,8 +131,8 @@ export const FEATURES: FeatureConfig[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'base64Converter',
|
key: 'base64Converter',
|
||||||
labelKey: 'features:base64Converter.title',
|
labelKey: 'base64Converter_title',
|
||||||
descriptionKey: 'features:base64Converter.description',
|
descriptionKey: 'base64Converter_description',
|
||||||
themeColorKey: 'info',
|
themeColorKey: 'info',
|
||||||
icon: ArrowLeftRight,
|
icon: ArrowLeftRight,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
@@ -145,29 +143,16 @@ export const FEATURES: FeatureConfig[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'markdownToHtml',
|
key: 'rightClickRestorer',
|
||||||
labelKey: 'features:markdownToHtml.title',
|
labelKey: 'rightClickRestorer_title',
|
||||||
descriptionKey: 'features:markdownToHtml.description',
|
descriptionKey: 'rightClickRestorer_description',
|
||||||
themeColorKey: 'secondary',
|
themeColorKey: 'success',
|
||||||
icon: Code,
|
icon: MousePointerClick,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
components: {
|
components: {
|
||||||
popup: MarkdownToHtmlPage,
|
popup: RightClickRestorerPage,
|
||||||
sidepanel: MarkdownToHtmlPage,
|
sidepanel: RightClickRestorerPage,
|
||||||
tab: MarkdownToHtmlPage,
|
tab: RightClickRestorerPage,
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'htmlToMarkdown',
|
|
||||||
labelKey: 'features:htmlToMarkdown.title',
|
|
||||||
descriptionKey: 'features:htmlToMarkdown.description',
|
|
||||||
themeColorKey: 'secondary',
|
|
||||||
icon: File,
|
|
||||||
defaultVisible: true,
|
|
||||||
components: {
|
|
||||||
popup: HtmlToMarkdownPage,
|
|
||||||
sidepanel: HtmlToMarkdownPage,
|
|
||||||
tab: HtmlToMarkdownPage,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# entrypoints/
|
||||||
|
|
||||||
|
WXT 框架要求的扩展生命周期入口点,对应 Chrome Extension 的各个上下文。
|
||||||
|
|
||||||
|
## 入口文件
|
||||||
|
|
||||||
|
| 文件 | 用途 |
|
||||||
|
| ------------------------------- | --------------------------------------------------------------------------------------------- |
|
||||||
|
| `background.ts` | Service Worker 入口:注册右键菜单、监听菜单点击、处理消息通信、管理侧边栏状态、注入主环境脚本 |
|
||||||
|
| `content.ts` | Content Script 入口:注入所有页面(`<all_urls>`),在 `document_end` 时初始化消息处理器 |
|
||||||
|
| `rightClickRestorer.content.ts` | 专用 Content Script:处理右键菜单恢复功能,注入浮动状态徽章 |
|
||||||
|
|
||||||
|
## 子目录
|
||||||
|
|
||||||
|
### popup/
|
||||||
|
|
||||||
|
Popup 弹窗页面(点击扩展图标弹出)。
|
||||||
|
|
||||||
|
| 文件 | 用途 |
|
||||||
|
| ------------ | ------------------------------------------------------------------------------ |
|
||||||
|
| `index.html` | HTML 入口 |
|
||||||
|
| `main.tsx` | React 挂载点 |
|
||||||
|
| `App.tsx` | 根组件,组装 `RouterProvider` + `TopBar` + `ErrorBoundary` + `RouterContainer` |
|
||||||
|
|
||||||
|
### sidepanel/
|
||||||
|
|
||||||
|
侧边栏页面,结构与 popup 类似,额外通知 background 侧边栏开启/关闭状态。
|
||||||
|
|
||||||
|
### options/
|
||||||
|
|
||||||
|
设置页面,支持:
|
||||||
|
|
||||||
|
- 拖拽排序功能顺序(`@dnd-kit`)
|
||||||
|
- 功能可见性管理(显示/隐藏)
|
||||||
|
- Popup/Sidepanel/Tab 三种模式独立配置
|
||||||
|
|
||||||
|
### content/
|
||||||
|
|
||||||
|
Content Script 内部分模块:
|
||||||
|
|
||||||
|
| 文件 | 用途 |
|
||||||
|
| ----------------------- | ------------------------------------------------------------------- |
|
||||||
|
| `messageHandler.ts` | 消息处理器初始化入口 |
|
||||||
|
| `contextMenuHandler.ts` | 右键菜单点击事件处理,执行时间戳转换/文本统计并通过 UI Popover 展示 |
|
||||||
|
| `uiPopover.ts` | 在页面中注入浮层 Popover UI,展示右键菜单操作结果 |
|
||||||
|
|
||||||
|
## 架构说明
|
||||||
|
|
||||||
|
- `background.ts` 是扩展的核心协调者,处理跨上下文通信
|
||||||
|
- `content.ts` 注入到所有页面,负责接收和处理来自 background 的消息
|
||||||
|
- `popup/`、`sidepanel/`、`options/` 共享同一套页面组件(来自 `pages/`),通过 `RouterProvider` 的不同配置实现独立路由
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
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'];
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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()}`;
|
||||||
|
|
||||||
|
await browser.alarms.create(alarmName, { when: Date.now() + delay });
|
||||||
|
|
||||||
|
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 };
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import '../.wxt/types/imports.d.ts';
|
import '../../.wxt/types/imports.d.ts';
|
||||||
import { initMessageHandler } from './content/messageHandler';
|
import { initMessageHandler } from './content/messageHandler';
|
||||||
|
|
||||||
export default defineContentScript({
|
export default defineContentScript({
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user