Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c735a0aa1a | |||
| 62c880b0a8 | |||
| e263b63ced | |||
| 1e4b6eddc7 | |||
| 4316bb57d2 | |||
| f40485e2dc | |||
| 0908358f97 | |||
| a3f89d07ea | |||
| 003dad5d8b | |||
| 27d3360f9d | |||
| 67629263f9 | |||
| 8b58dcf02e | |||
| 2e2f6e9835 | |||
| cdad9bda68 | |||
| fae8dea971 | |||
| e0de152645 | |||
| e005d4d95d | |||
| 601260797e | |||
| c61b599287 | |||
| 0f3b6c4cd8 | |||
| af2262d2c9 | |||
| e5ae802ab2 | |||
| dec43f89da | |||
| 61e741f198 | |||
| c98f767809 | |||
| 92d524d832 | |||
| 63f47fd2b1 | |||
| e95ca1297c | |||
| c8ad2a2283 |
@@ -0,0 +1,821 @@
|
||||
# 代码编写规范
|
||||
|
||||
本文档定义了 Testing Tools 浏览器扩展项目的编码规范和最佳实践。所有代码贡献者应遵循这些规范以保持代码库的一致性和可维护性。
|
||||
|
||||
## 1. TypeScript 规范
|
||||
|
||||
### 1.1 类型定义:`interface` vs `type`
|
||||
|
||||
- **`interface`**:用于组件 Props、对象结构、Context 类型等可扩展结构
|
||||
- **`type`**:用于联合类型、工具类型、不可扩展的类型别名
|
||||
|
||||
```typescript
|
||||
// ✅ interface — 组件 Props / 对象结构
|
||||
export interface GlobalSnackbarProps {
|
||||
message: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
severity?: SnackbarSeverity;
|
||||
}
|
||||
|
||||
// ✅ interface — 继承 HTML 属性
|
||||
interface LiveClockProps extends React.HTMLAttributes<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()` | `pages/Timestamp/index.tsx` |
|
||||
| 业务组件 | `const X = React.memo(...)` + `export default X` | `LiveClock.tsx`, `ResultView.tsx` |
|
||||
| UI 原子组件 | `React.forwardRef(...)` + `export { X }` | `components/ui/button.tsx` |
|
||||
| 工具函数 | `export function xxx()` | `utils/clipboard.ts` |
|
||||
| 自定义 Hook | `export function useXxx()` | `utils/useStorageState.ts` |
|
||||
| 类型/接口 | `export interface` / `export type` | `types/storage.d.ts` |
|
||||
|
||||
```typescript
|
||||
// ✅ 页面组件 — default export
|
||||
export default function Index() { ... }
|
||||
|
||||
// ✅ 需要 memo 的组件 — 箭头函数 + React.memo + displayName
|
||||
const LiveClock = React.memo(({ ... }: LiveClockProps) => { ... });
|
||||
LiveClock.displayName = 'LiveClock';
|
||||
export default LiveClock;
|
||||
|
||||
// ✅ UI 组件 — forwardRef + 命名导出
|
||||
const Button = React.forwardRef<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` | `pages/Timestamp/index.tsx` |
|
||||
| 组件文件 | PascalCase `.tsx` | `TopBar.tsx`, `LiveClock.tsx`, `ResultView.tsx` |
|
||||
| 自定义 Hook | camelCase `.ts` | `useTimestampConverter.ts`, `useStorageCleaner.ts` |
|
||||
| 工具函数 | camelCase `.ts` | `chromeStorage.ts`, `base64Converter.ts`, `clipboard.ts` |
|
||||
| 测试文件 | 与源文件同名 `.test.ts(x)` | `jwt.test.ts`, `SwitchButtonGroup.test.tsx` |
|
||||
| 类型文件 | camelCase `.d.ts` | `storage.d.ts` |
|
||||
| 常量文件 | camelCase `.ts` | `constants.ts` |
|
||||
|
||||
### 6.2 变量 / 函数命名
|
||||
|
||||
```typescript
|
||||
// ✅ camelCase — 变量、函数、Hook
|
||||
const conversionPipeline = useMemo(...);
|
||||
const handleUseNow = useCallback(...);
|
||||
export function useTimestampConverter(): UseTimestampConverterReturn { ... }
|
||||
export function textToBase64(text: string): TextToBase64Result { ... }
|
||||
|
||||
// ✅ PascalCase — 组件、类型、接口
|
||||
const LiveClock = React.memo(...);
|
||||
export interface GlobalSnackbarProps { ... }
|
||||
export type ThemeMode = 'light' | 'dark' | 'system';
|
||||
|
||||
// ✅ SCREAMING_SNAKE_CASE — 常量
|
||||
const SEARCH_HISTORY_LIMIT = 10;
|
||||
const THEME_MODE_KEY = 'app/themeMode' as const;
|
||||
export const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
||||
export const SUPPORTED_IMAGE_TYPES = [...] as const;
|
||||
|
||||
// ✅ 布尔值 — is/has/should 前缀
|
||||
const isControlled = controlledValue !== undefined;
|
||||
const isDashboard = currentPage === 'dashboard';
|
||||
const hasError = true;
|
||||
```
|
||||
|
||||
### 6.3 事件处理函数
|
||||
|
||||
使用 `handle` 前缀命名组件内事件处理函数:
|
||||
|
||||
```typescript
|
||||
const handleUseNow = useCallback((now: number) => { ... }, []);
|
||||
const handleSelectFeature = (feature: FeatureConfig) => { ... };
|
||||
const handleFileChange = useCallback((file: File) => { ... }, []);
|
||||
const handleClean = useCallback(async () => { ... }, []);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 测试规范
|
||||
|
||||
### 7.1 文件组织
|
||||
|
||||
测试文件放在源代码同级的 `__tests__/` 目录下:
|
||||
|
||||
```
|
||||
utils/__tests__/jwt.test.ts
|
||||
utils/__tests__/base64Converter.test.ts
|
||||
utils/__tests__/useStorageState.test.ts
|
||||
components/__tests__/SwitchButtonGroup.test.tsx
|
||||
components/__tests__/ErrorBoundary.test.tsx
|
||||
pages/Timestamp/__tests__/index.test.tsx
|
||||
```
|
||||
|
||||
### 7.2 describe / it 命名
|
||||
|
||||
`describe` 使用模块/函数名,`it` 使用中文描述行为("应该..."):
|
||||
|
||||
```typescript
|
||||
// ✅ 中文 describe + 中文 it
|
||||
describe('textToBase64', () => {
|
||||
it('应该编码 ASCII 文本', () => { ... });
|
||||
it('应该编码中文文本', () => { ... });
|
||||
it('应该编码空字符串', () => { ... });
|
||||
});
|
||||
|
||||
// ✅ 中文 describe + 中文 it(组件测试)
|
||||
describe('SwitchButtonGroup 组件', () => {
|
||||
it('应渲染所有选项按钮', () => { ... });
|
||||
it('应高亮当前选中的按钮', () => { ... });
|
||||
it('点击未选中按钮时应触发 onChange 并传入选中值', () => { ... });
|
||||
});
|
||||
```
|
||||
|
||||
### 7.3 Mock 模式
|
||||
|
||||
- 使用 `vi.mock()` 进行模块级 Mock
|
||||
- 使用 `vi.fn()` 进行函数级 Mock
|
||||
- 使用 `vi.useFakeTimers()` 控制时间
|
||||
- **避免重复 mock `vitest.setup.ts` 中已有的内容**(chrome API、i18n、matchMedia 等)
|
||||
|
||||
```typescript
|
||||
// ✅ 模块级 Mock
|
||||
vi.mock('@/utils/chromeStorage', () => ({
|
||||
storageUtil: {
|
||||
get: vi.fn(),
|
||||
set: vi.fn(() => Promise.resolve()),
|
||||
},
|
||||
}));
|
||||
|
||||
// ✅ 函数级 Mock + 断言
|
||||
const handleChange = vi.fn();
|
||||
render(<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**:放在 `utils/` 目录下
|
||||
- **页面专属 Hook**:与页面组件同目录
|
||||
|
||||
```
|
||||
utils/useStorageState.ts — Chrome Storage 状态持久化
|
||||
utils/useLazyTranslation.ts — i18n 懒加载
|
||||
utils/useContextMenuData.ts — 右键菜单数据
|
||||
utils/useDebounce.ts — 防抖
|
||||
pages/Timestamp/useTimestampConverter.ts — 页面级 Hook
|
||||
pages/StorageCleaner/useStorageCleaner.ts — 页面级 Hook
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 国际化规范
|
||||
|
||||
### 9.1 翻译键格式
|
||||
|
||||
- 命名空间:`common`(默认)、`features`
|
||||
- 翻译键格式:`namespace:key`(如 `features:timestamp.title`)
|
||||
- 语言:`zh`(默认)、`en`
|
||||
|
||||
### 9.2 翻译文件结构
|
||||
|
||||
```
|
||||
i18n/locales/{zh,en}/common.json — 全局通用翻译
|
||||
i18n/locales/{zh,en}/features.json — 功能模块标题和描述
|
||||
i18n/locales/{zh,en}/{功能名}.json — 各功能独立翻译
|
||||
```
|
||||
|
||||
### 9.3 使用方式
|
||||
|
||||
```typescript
|
||||
// ✅ 页面组件 — 使用 useLazyTranslation
|
||||
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||
|
||||
export default function Index() {
|
||||
const { t } = useLazyTranslation('timestamp');
|
||||
return <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 页面组件结构
|
||||
|
||||
```
|
||||
pages/FeatureName/
|
||||
├── index.tsx # 页面 UI(纯展示,使用 shadcn/ui 组件)
|
||||
├── useFeatureName.ts # 业务逻辑 Hook(状态管理 + 转换逻辑)
|
||||
├── constants.ts # 常量定义(可选)
|
||||
├── LiveClock.tsx # 子组件(可选)
|
||||
├── ResultView.tsx # 子组件(可选)
|
||||
└── __tests__/ # 测试文件
|
||||
└── index.test.tsx
|
||||
```
|
||||
|
||||
### 11.2 目录职责
|
||||
|
||||
| 目录 | 职责 |
|
||||
| ---------------- | ------------------------------------------------------------ |
|
||||
| `config/` | 应用配置(功能定义、路由映射) |
|
||||
| `entrypoints/` | 扩展入口点(popup、options、sidepanel、background、content) |
|
||||
| `pages/` | 功能页面组件(懒加载) |
|
||||
| `components/` | 可复用 UI 组件 |
|
||||
| `components/ui/` | shadcn/ui 基础组件(button、dialog、select 等) |
|
||||
| `providers/` | React Context(Router、Theme 等) |
|
||||
| `hooks/` | 自定义 React Hooks |
|
||||
| `utils/` | 工具函数与服务抽象 |
|
||||
| `types/` | TypeScript 类型声明 |
|
||||
| `lib/` | 通用工具函数(cn、utils) |
|
||||
| `i18n/` | 国际化资源 |
|
||||
| `public/` | 静态资源 |
|
||||
|
||||
---
|
||||
|
||||
## 12. 代码风格
|
||||
|
||||
### 12.1 Prettier 配置
|
||||
|
||||
- 行宽:100 字符
|
||||
- 引号:单引号
|
||||
- 尾逗号:all
|
||||
- 换行符:LF
|
||||
- 分号:是
|
||||
|
||||
### 12.2 ESLint 规则
|
||||
|
||||
- 禁止使用 `any`(测试文件除外)
|
||||
- 未使用变量/参数:使用 `_` 前缀(如 `_unused`)
|
||||
- React 19 JSX Runtime:无需手动导入 React
|
||||
- 使用 `typescript-eslint` 的 `projectService: true`
|
||||
|
||||
### 12.3 注释规范
|
||||
|
||||
- **文件级注释**:使用 JSDoc `@module` 格式(如 `GlobalSnackbar.tsx`)
|
||||
- **函数注释**:使用 JSDoc,包含 `@param`、`@returns`、`@example`
|
||||
- **行内注释**:仅在需要澄清复杂逻辑时使用
|
||||
- **禁止注释显而易见的代码**
|
||||
@@ -0,0 +1,141 @@
|
||||
# Copilot 指令
|
||||
|
||||
基于 WXT 框架的浏览器扩展项目(React 19 + TypeScript),为开发者和测试人员提供效率工具:时间戳转换、存储清理、JWT 解析、JSON 工具、二维码、Base64、Markdown 等。
|
||||
|
||||
## 核心命令
|
||||
|
||||
```bash
|
||||
npm run dev # Chrome 开发模式(支持 HMR)
|
||||
npm run dev:firefox # Firefox 开发模式
|
||||
npm run build # Chrome 生产构建
|
||||
npm run build:firefox # Firefox 生产构建
|
||||
npm run zip # 打包 Chrome 扩展(.output/*.zip)
|
||||
npm run zip:firefox # 打包 Firefox 扩展
|
||||
npm run lint # ESLint 检查(--max-warnings=0)
|
||||
npm run typecheck # TypeScript 类型检查(tsc --noEmit)
|
||||
npm run test # 运行全部单元测试(vitest run)
|
||||
npm run test:watch # Vitest 监视模式
|
||||
npm run test:coverage # 带覆盖率的测试
|
||||
```
|
||||
|
||||
运行单个测试文件:`npx vitest run path/to/file.test.ts`
|
||||
|
||||
修改 `package.json` 后需运行 `npm install`(会自动触发 `postinstall` → `wxt prepare` 重新生成 `.wxt/` 类型声明)。
|
||||
|
||||
## CI 流水线(GitHub Actions)
|
||||
|
||||
严格顺序门控,任一步骤失败则终止:
|
||||
|
||||
1. **setup** — 安装依赖,缓存 `node_modules`
|
||||
2. **lint**、**typecheck**、**test** — 三者并行运行,全部通过才继续
|
||||
3. **build** — Chrome + Firefox 矩阵构建(仅当步骤 2 全部通过时执行)
|
||||
|
||||
Pre-commit 钩子(`.husky/pre-commit` → `lint-staged`):
|
||||
|
||||
1. 代码文件(`*.{ts,tsx,js,jsx,mjs}`):运行 `eslint --fix --max-warnings=0`
|
||||
2. 同一代码文件:运行 `prettier --write`
|
||||
3. 其他文件(`*.{json,css,scss,md}`):运行 `prettier --write`
|
||||
|
||||
## 项目架构
|
||||
|
||||
### 路由(不使用 React Router)
|
||||
|
||||
路由完全通过 `config/features.tsx` 中的 `FEATURES` 数组管理。每个功能定义一个 `key`(类型为 `types/storage.d.ts` 中的 `PageType`)和三个懒加载组件,分别对应 `popup`、`sidepanel`、`tab` 三种渲染模式。`providers/RouterProvider.tsx` 中的 `RouterProvider` 根据存储状态渲染当前页面。
|
||||
|
||||
存在三套独立的路由作用域:`app/popupRoute`、`app/sidepanelRoute`、`app/tabRoute`,各自维护独立的可见页面列表和页面排序。
|
||||
|
||||
### 存储
|
||||
|
||||
所有 Chrome Storage 键必须在 `types/storage.d.ts` 的 `StorageSchema` 中声明,键名使用 kebab-case 格式(如 `app/currentRoute`)。使用 `utils/chromeStorage.ts` 中的类型安全封装(`storageUtil.get/set/remove`)。
|
||||
|
||||
Router 同时使用 `chrome.storage.local` 持久化和 `localStorage` 快照来消除首屏闪烁。
|
||||
|
||||
### 扩展通信
|
||||
|
||||
使用 `@webext-core/messaging`。通信协议在 `utils/messages.ts` 中通过 `ProtocolMap` 定义。使用该模块导出的 `sendMessage` / `onMessage`,不要直接使用原生 `chrome.runtime.sendMessage`。
|
||||
|
||||
### 页面组件模式
|
||||
|
||||
功能页面遵循 **UI + Hook 分离** 模式:
|
||||
|
||||
```
|
||||
pages/FeatureName/
|
||||
├── index.tsx # UI 组件(纯展示,使用 shadcn/ui 组件)
|
||||
├── useFeatureName.ts # 业务逻辑 Hook(状态管理 + 转换逻辑)
|
||||
└── constants.ts # 常量定义(可选)
|
||||
```
|
||||
|
||||
- 页面组件调用 `useLazyTranslation('featureName')` 获取翻译函数
|
||||
- Hook 负责所有状态管理,通过返回值暴露给页面
|
||||
- 子组件可进一步拆分(如 `LiveClock.tsx`、`ResultView.tsx`)
|
||||
|
||||
### 新功能开发清单
|
||||
|
||||
1. 在 `types/storage.d.ts` 的 `PageType` 联合类型中添加新成员
|
||||
2. 在 `config/features.tsx` 的 `FEATURES` 数组中添加配置(key、翻译键、图标、三种渲染模式组件)
|
||||
3. 在 `pages/` 目录创建页面组件(懒加载):
|
||||
- `index.tsx` — 使用 `useLazyTranslation` 的 UI 组件
|
||||
- `useFeatureName.ts` — 业务逻辑 Hook
|
||||
- `constants.ts` — 常量(可选)
|
||||
4. 在 `i18n/locales/{zh,en}/features.json` 添加翻译(复杂功能可新建独立 JSON 文件)
|
||||
5. 如需新权限,更新 `wxt.config.ts` 的 `manifest.permissions`
|
||||
6. 添加对应的单元测试
|
||||
|
||||
## 关键规范
|
||||
|
||||
> 完整的代码编写规范详见 [CODING_STANDARDS.md](./CODING_STANDARDS.md)。
|
||||
|
||||
### 浏览器 API
|
||||
|
||||
始终使用 `wxt/browser` 导出的 `browser` 对象,而非原生 `chrome` API,以确保跨浏览器兼容性。
|
||||
|
||||
### 路径别名
|
||||
|
||||
`@/` 映射到项目根目录(已在 tsconfig 和 vitest.config 中配置)。跨目录导入使用 `@/` 绝对别名,同目录导入使用 `./` 相对路径。
|
||||
|
||||
### UI 组件
|
||||
|
||||
- 使用 `components/ui/` 下的 shadcn/ui 组件(button、dialog、select、input 等)
|
||||
- 图标:`lucide-react`
|
||||
- 样式:Tailwind CSS + `@/lib/utils` 中的 `cn()` 工具函数(clsx + tailwind-merge)
|
||||
- 主题:使用 shadcn/ui 语义化 token(`bg-background`、`text-foreground`、`border-border` 等),禁止硬编码颜色
|
||||
|
||||
### 代码分割
|
||||
|
||||
`wxt.config.ts` 通过 `manualChunksForHtmlOnly()` 自动分组 vendor 依赖(vendor-react、vendor-i18n、vendor-qr、vendor-dnd、vendor-markdown),无需手动配置。
|
||||
|
||||
### 代码风格
|
||||
|
||||
- 禁止使用 `any`(测试文件除外)
|
||||
- 未使用的变量/参数:使用 `_` 前缀(如 `_unused`)
|
||||
- Prettier:100 字符宽、单引号、尾逗号 all、LF 换行
|
||||
- ESLint 使用 `typescript-eslint` 的 `projectService: true`
|
||||
- 导出模式:页面组件 default export,工具函数/Hook 命名导出,UI 组件 forwardRef + 命名导出
|
||||
|
||||
### 测试
|
||||
|
||||
- 环境:jsdom
|
||||
- 全局变量:`vitest/globals`(describe、it、expect 等无需导入)
|
||||
- Setup 文件:`vitest.setup.ts` 自动 mock 以下内容:
|
||||
- `chrome.*` / `browser.*` API(storage、tabs、runtime、cookies 等)
|
||||
- `react-i18next`(返回 key 作为翻译)
|
||||
- `@/utils/useLazyTranslation`(返回 `ns:key` 格式)
|
||||
- `window.matchMedia`
|
||||
- 测试文件命名:`__tests__/*.test.{ts,tsx}` 或 `*.test.{ts,tsx}`
|
||||
- 使用 `vi.mock()` 进行模块级 mock;避免重复 mock `vitest.setup.ts` 中已有的内容
|
||||
- 测试工具:`@testing-library/react` + `@testing-library/user-event`
|
||||
|
||||
### 国际化(i18n)
|
||||
|
||||
- 命名空间:`common`(默认)、`features`
|
||||
- 翻译键格式:`namespace:key`(如 `features:timestamp.title`)
|
||||
- 语言:`zh`(默认)、`en`
|
||||
- 翻译文件:`i18n/locales/{zh,en}/{common,features}.json` + 各功能独立 JSON 文件
|
||||
- 使用 `useLazyTranslation` Hook 加载功能专属翻译
|
||||
- 回退策略:缺失的翻译键回退到 `zh`,若仍缺失则返回占位格式 `namespace:key`
|
||||
|
||||
### WXT 生成文件
|
||||
|
||||
- `.wxt/` 目录由 `postinstall`(`wxt prepare`)自动生成,包含 TypeScript 类型声明和扩展 tsconfig
|
||||
- 生产构建输出到 `.output/` 目录
|
||||
- `tsconfig.json` 继承自 `./.wxt/tsconfig.json`
|
||||
@@ -1,6 +1,6 @@
|
||||
# AGENTS.md
|
||||
|
||||
WXT 浏览器扩展项目 (React 19 + TypeScript)。
|
||||
WXT 浏览器扩展项目 (React 19 + TypeScript)。提供时间戳转换、存储清理、JWT 解析、JSON 工具、二维码、Base64、Markdown 等测试效率工具。
|
||||
|
||||
## 核心命令
|
||||
|
||||
@@ -22,14 +22,17 @@ npm run test:coverage # 带覆盖率的测试
|
||||
|
||||
## 验证流程
|
||||
|
||||
CI 执行顺序: `setup → lint/typecheck/test(并行) → build` (build 依赖前三者)。
|
||||
CI 步骤(严格顺序,任一步骤失败则停止并标记 CI 失败):
|
||||
|
||||
Pre-commit hook (`.husky/pre-commit` 调用 `lint-staged`):
|
||||
1. `setup`(安装依赖、`wxt prepare`)
|
||||
2. 并行运行 `lint`、`typecheck`、`test`(三者全部通过才继续)
|
||||
3. `build`(仅当步骤 2 全部成功时执行)
|
||||
|
||||
- 代码文件 (`*.{ts,tsx,js,jsx,mjs}`): `eslint --fix --max-warnings=0 --no-warn-ignored` → `prettier --write`
|
||||
- 其他文件 (`*.{json,css,scss,md}`): `prettier --write`
|
||||
Pre-commit hook(`.husky/pre-commit` 调用 `lint-staged`,任一步骤返回非零则终止提交):
|
||||
|
||||
提交前确保 `lint` 和 `typecheck` 通过。
|
||||
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 生成文件
|
||||
|
||||
@@ -44,6 +47,7 @@ config/features.tsx # 功能定义(路由 + 元数据的单一事实来源
|
||||
entrypoints/ # 扩展入口点 (popup/, options/, sidepanel/, background.ts, content.ts)
|
||||
pages/ # 功能页面组件 (懒加载)
|
||||
components/ # 可复用 UI 组件
|
||||
components/ui/ # shadcn/ui 基础组件 (button, dialog, select 等)
|
||||
providers/ # React Context (Router, Theme 等)
|
||||
hooks/ # 自定义 React Hooks
|
||||
utils/ # 工具函数与服务抽象
|
||||
@@ -51,13 +55,30 @@ types/ # TypeScript 类型声明
|
||||
i18n/locales/{zh,en}/ # 国际化资源 (common.json, features.json 及各功能独立 JSON)
|
||||
```
|
||||
|
||||
### 页面组件模式
|
||||
|
||||
典型功能页面遵循 **UI + Hook 分离** 模式:
|
||||
|
||||
```
|
||||
pages/FeatureName/
|
||||
├── index.tsx # 页面 UI(纯展示,使用 shadcn/ui 组件)
|
||||
├── useFeatureName.ts # 业务逻辑 Hook(状态管理 + 转换逻辑)
|
||||
└── constants.ts # 常量定义
|
||||
```
|
||||
|
||||
- 页面组件调用 `useLazyTranslation('featureName')` 获取翻译函数
|
||||
- Hook 负责所有状态管理和业务逻辑,通过返回值暴露给页面
|
||||
- 子组件可进一步拆分(如 `LiveClock.tsx`、`ResultView.tsx`)
|
||||
|
||||
## 关键架构决策
|
||||
|
||||
**路由**: 不使用 React Router。通过 `config/features.tsx` 的 `FEATURES` 数组管理,`RouterProvider` 根据 `PageType`
|
||||
渲染对应组件。支持三种渲染模式: popup / sidepanel / tab。
|
||||
渲染对应组件。支持三种渲染模式:popup(弹窗)、sidepanel(侧边栏)和 browser-tab(浏览器新标签页,通过 `open_in_tab` 打开)。
|
||||
每种模式有独立的路由和可见页面配置(`app/popupRoute`、`app/sidepanelRoute`、`app/tabRoute` 等)。
|
||||
|
||||
**存储**: 所有 Chrome Storage 键必须在 `types/storage.d.ts` 的 `StorageSchema` 中定义。使用 `utils/chromeStorage.ts` 及其
|
||||
Hook。Router 同时使用 `chrome.storage.local` 和 `localStorage` 做快照以消除首屏闪烁。
|
||||
**存储**: 所有 Chrome Storage 键必须在 `types/storage.d.ts` 的 `StorageSchema` 中定义,键名使用 kebab-case 格式(如 `app/currentRoute`)。
|
||||
使用 `utils/chromeStorage.ts` 及其 Hook。Router 同时使用 `chrome.storage.local` 和 `localStorage` 做快照以消除首屏闪烁。
|
||||
修改 StorageSchema 时,必须在 `utils/chromeStorage.ts` 添加版本迁移函数,并在测试中覆盖迁移场景。
|
||||
|
||||
**通信**: 使用 `@webext-core/messaging`,协议定义在 `utils/messages.ts`。
|
||||
|
||||
@@ -65,6 +86,8 @@ Hook。Router 同时使用 `chrome.storage.local` 和 `localStorage` 做快照
|
||||
|
||||
**浏览器兼容**: 优先使用 `wxt/browser` 导出的 `browser` 对象,而非原生 `chrome` API。
|
||||
|
||||
**代码分割**: `wxt.config.ts` 通过 `manualChunksForHtmlOnly()` 自动分组依赖(vendor-react、vendor-i18n、vendor-qr 等),无需手动配置。
|
||||
|
||||
## 测试环境
|
||||
|
||||
- 环境: jsdom
|
||||
@@ -75,19 +98,30 @@ Hook。Router 同时使用 `chrome.storage.local` 和 `localStorage` 做快照
|
||||
- `@/utils/useLazyTranslation` (返回 `ns:key` 格式翻译)
|
||||
- `window.matchMedia`
|
||||
- 测试文件命名: `__tests__/*.test.{ts,tsx}` 或 `*.test.{ts,tsx}`
|
||||
- Mock 模式: 使用 `vi.mock()` 进行模块级 mock,避免在测试文件中重复 mock 代码
|
||||
- 测试工具: `@testing-library/react` + `@testing-library/user-event` 进行组件测试
|
||||
|
||||
## i18n
|
||||
|
||||
- 命名空间: `common` (默认), `features`
|
||||
- 翻译键格式: `namespace:key` (如 `features:timestamp.title`)
|
||||
- 语言: `zh` (默认), `en`
|
||||
- 翻译文件结构:
|
||||
- `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` 联合类型
|
||||
2. 在 `config/features.tsx` 的 `FEATURES` 数组添加配置
|
||||
3. 在 `pages/` 创建页面组件 (懒加载)
|
||||
2. 在 `config/features.tsx` 的 `FEATURES` 数组添加配置(指定 key、翻译键、图标、三种渲染模式的组件)
|
||||
3. 在 `pages/` 创建页面组件 (懒加载):
|
||||
- `index.tsx` — UI 组件,使用 `useLazyTranslation` 获取翻译
|
||||
- `useFeatureName.ts` — 业务逻辑 Hook
|
||||
- `constants.ts` — 常量(可选)
|
||||
4. 在 `i18n/locales/{zh,en}/features.json` 添加翻译(复杂功能可新建独立 JSON)
|
||||
5. 如需新权限,更新 `wxt.config.ts` 的 `manifest.permissions`
|
||||
6. 添加对应的单元测试
|
||||
@@ -97,15 +131,16 @@ Hook。Router 同时使用 `chrome.storage.local` 和 `localStorage` 做快照
|
||||
- 禁止使用 `any` (测试文件除外)
|
||||
- 未使用变量/参数: 使用 `_` 前缀 (如 `_unused`)
|
||||
- 样式: 使用 Tailwind CSS + shadcn/ui (通过 `className` 和 `cn()` 工具)
|
||||
- UI 组件: 优先使用 `components/ui/` 下的 shadcn/ui 组件 (button, dialog, select 等)
|
||||
- 图标: 使用 `lucide-react` 图标库
|
||||
- 格式: Prettier (`.prettierrc`: 100 字符宽, 单引号, 尾逗号 all, LF 换行)
|
||||
- ESLint 使用 `typescript-eslint` 的 `projectService: true`(无需手动维护 project 路径)
|
||||
|
||||
## 技术栈版本
|
||||
## 关键外部库(非显而易见的)
|
||||
|
||||
- WXT: ^0.20.26
|
||||
- React: ^19.2.6
|
||||
- Tailwind CSS: ^3.4.19
|
||||
- shadcn/ui (基于 Radix UI + class-variance-authority)
|
||||
- TypeScript: ^5.9.3
|
||||
- Vitest: ^4.1.7
|
||||
- i18next: ^26.2.0
|
||||
- `@webext-core/messaging` — 扩展消息通信
|
||||
- `@dnd-kit` — 拖拽排序(用于页面顺序管理)
|
||||
- `marked` — Markdown 解析
|
||||
- `qrious` + `qr-scanner` — 二维码生成与解析
|
||||
- `dayjs` — 日期处理(时间戳转换)
|
||||
- `sonner` — Toast 通知(替代传统 snackbar)
|
||||
|
||||
+17
-37
@@ -1,26 +1,25 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Check, Copy } from 'lucide-react';
|
||||
import { copyTextToClipboard } from '@/utils/clipboard';
|
||||
import { cn } from '@/lib/utils'; // 1. 必须使用 cn 工具函数
|
||||
import { toast } from 'sonner'; // 2. 推荐使用 shadcn 默认的全局 toast
|
||||
import { cn } from '@/lib/utils';
|
||||
import { buttonVariants, type ButtonProps } from '@/components/ui/button';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// 3. 继承原生按钮属性,允许外部自由扩展 className、variant 等
|
||||
interface CopyButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
interface CopyButtonProps extends Omit<ButtonProps, 'children' | 'onClick'> {
|
||||
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',
|
||||
tooltip,
|
||||
variant = 'ghost',
|
||||
size = 'icon',
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
const { t } = useTranslation('common');
|
||||
const [copied, setCopied] = useState(false);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
@@ -31,53 +30,34 @@ export const CopyButton: React.FC<CopyButtonProps> = ({
|
||||
}, []);
|
||||
|
||||
const handleCopy = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation(); // 基础组件防冒泡,避免触发父级点击事件
|
||||
e.stopPropagation();
|
||||
|
||||
if (!text) {
|
||||
toast.error('无内容可复制');
|
||||
toast.error(t('messages.copyEmpty'));
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await copyTextToClipboard(text);
|
||||
if (success) {
|
||||
toast.success('复制成功');
|
||||
toast.success(t('messages.copySuccess'));
|
||||
setCopied(true);
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => setCopied(false), 1500);
|
||||
} else {
|
||||
toast.error('复制失败');
|
||||
toast.error(t('messages.copyError'));
|
||||
}
|
||||
};
|
||||
|
||||
// 4. 将控制尺寸的类名标准化
|
||||
const sizeClasses = {
|
||||
small: 'h-8 w-8 text-xs',
|
||||
medium: 'h-10 w-10 text-sm',
|
||||
large: 'h-12 w-12 text-base',
|
||||
};
|
||||
|
||||
// 5. 映射 shadcn 的底层通用 Variant 类名
|
||||
const variantClasses = {
|
||||
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
||||
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
outline:
|
||||
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
title={tooltip}
|
||||
// 6. 使用 cn() 合并类名,并完美支持暗黑模式的语义化变量 (destructive/muted等)
|
||||
title={tooltip ?? t('buttons.copy')}
|
||||
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 等覆盖样式
|
||||
buttonVariants({ variant, size }),
|
||||
copied &&
|
||||
'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-500/10',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Component, ErrorInfo, ReactNode } from 'react';
|
||||
import { withTranslation, type WithTranslation } from 'react-i18next';
|
||||
import { AlertCircle, RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface Props {
|
||||
interface Props extends WithTranslation {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
@@ -11,10 +12,7 @@ interface State {
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误边界组件:捕获子组件树中的 JavaScript 错误
|
||||
*/
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
class ErrorBoundaryBase extends Component<Props, State> {
|
||||
state: State = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
@@ -39,29 +37,32 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { t } = this.props;
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="mt-16 mx-auto max-w-md">
|
||||
<div className="p-6 text-center rounded-xl border border-red-200 bg-red-50">
|
||||
<AlertCircle className="h-16 w-16 text-red-500 mx-auto mb-4" />
|
||||
<h2 className="text-xl font-extrabold text-red-600 mb-2">糟糕,出了点问题</h2>
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
应用遇到了一些意外错误。您可以尝试刷新页面或重置应用。
|
||||
</p>
|
||||
<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-destructive/20 bg-destructive/5 shadow-sm">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10 text-destructive mx-auto mb-4">
|
||||
<AlertCircle className="h-8 w-8" />
|
||||
</div>
|
||||
<h2 className="text-xl font-extrabold text-destructive mb-2">
|
||||
{t('errorBoundary.title')}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-6">{t('errorBoundary.description')}</p>
|
||||
{this.state.error && (
|
||||
<div className="mb-6 p-4 bg-muted rounded-lg text-left max-h-[200px] overflow-auto">
|
||||
<pre className="font-mono text-xs whitespace-pre-wrap break-all text-red-700">
|
||||
<div className="mb-6 p-4 rounded-lg bg-zinc-950 dark:bg-zinc-900 text-left max-h-[200px] overflow-auto border border-border/40">
|
||||
<pre className="font-mono text-xs whitespace-pre-wrap break-all text-zinc-200 selection:bg-zinc-700">
|
||||
{this.state.error.toString()}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="default"
|
||||
variant="destructive"
|
||||
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" />
|
||||
刷新应用
|
||||
{t('errorBoundary.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -72,4 +73,5 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
}
|
||||
}
|
||||
|
||||
export const ErrorBoundary = withTranslation('common')(ErrorBoundaryBase);
|
||||
export default ErrorBoundary;
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
* 2. 通过 useSnackbarState Hook 使用:在组件内部自动管理状态
|
||||
* 3. 通过 SnackbarProvider 和 useSnackbar Hook 使用:全局单例模式
|
||||
*
|
||||
* NOTE: 项目同时使用 sonner 的 toast 进行简单的一次性提示。
|
||||
* 本组件适用于需要 severity 级别、Provider 上下文、自定义定位等高级场景。
|
||||
* 简单场景(如复制成功、操作提示)优先使用 `import { toast } from 'sonner'`。
|
||||
*
|
||||
* @module GlobalSnackbar
|
||||
* @version 1.1.0
|
||||
*
|
||||
|
||||
@@ -41,7 +41,7 @@ const ImageUploader = ({
|
||||
[onFileChange, onPreviewUrlChange],
|
||||
);
|
||||
|
||||
const handleClearFile = () => {
|
||||
const handleClearFile = useCallback(() => {
|
||||
if (previewUrl) {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
}
|
||||
@@ -50,7 +50,7 @@ const ImageUploader = ({
|
||||
severity: 'success',
|
||||
autoHideDuration: 1000,
|
||||
});
|
||||
};
|
||||
}, [previewUrl, onClearFile, showMessage, t]);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Component, ErrorInfo, ReactNode } from 'react';
|
||||
import { withTranslation, type WithTranslation } from 'react-i18next';
|
||||
import { AlertCircle, RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface Props {
|
||||
interface Props extends WithTranslation {
|
||||
children: ReactNode;
|
||||
resetKey?: string | number;
|
||||
}
|
||||
@@ -12,11 +13,7 @@ interface State {
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面级错误边界组件:捕获子组件树中的 JavaScript 错误
|
||||
* 完美适配 shadcn/ui 语义化主题与暗黑模式
|
||||
*/
|
||||
export class PageErrorBoundary extends Component<Props, State> {
|
||||
class PageErrorBoundaryBase extends Component<Props, State> {
|
||||
state: State = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
@@ -41,26 +38,22 @@ export class PageErrorBoundary extends Component<Props, State> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { t } = this.props;
|
||||
if (this.state.hasError) {
|
||||
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">
|
||||
{/*
|
||||
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">
|
||||
{/* 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">
|
||||
<AlertCircle className="h-6 w-6" />
|
||||
</div>
|
||||
|
||||
<h3 className="text-base font-semibold text-foreground mb-1.5">该功能运行异常</h3>
|
||||
<h3 className="text-base font-semibold text-foreground mb-1.5">
|
||||
{t('pageErrorBoundary.title')}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mb-5">
|
||||
该页面在加载或渲染时遇到了内部脚本错误。您可以尝试重试,或者通过导航菜单切换到其他工具。
|
||||
{t('pageErrorBoundary.description')}
|
||||
</p>
|
||||
|
||||
{/* 3. 错误日志展示:使用与 shadcn 贴合的深色代码块包裹 */}
|
||||
{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">
|
||||
<pre className="font-mono text-[11px] leading-relaxed whitespace-pre-wrap break-all text-zinc-200 selection:bg-zinc-700">
|
||||
@@ -69,11 +62,6 @@ export class PageErrorBoundary extends Component<Props, State> {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/*
|
||||
4. 严谨调用 shadcn 原子 Button:
|
||||
去掉全部手动指定的红底白字类名,直接启用 variant="destructive"。
|
||||
它会自动处理 hover 颜色变化、暗黑模式切换以及无障碍高亮边框。
|
||||
*/}
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
@@ -81,7 +69,7 @@ export class PageErrorBoundary extends Component<Props, State> {
|
||||
className="font-medium shadow-sm"
|
||||
>
|
||||
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
|
||||
重新尝试
|
||||
{t('errorBoundary.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -92,4 +80,5 @@ export class PageErrorBoundary extends Component<Props, State> {
|
||||
}
|
||||
}
|
||||
|
||||
export const PageErrorBoundary = withTranslation('common')(PageErrorBoundaryBase);
|
||||
export default PageErrorBoundary;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from 'react';
|
||||
import { Copy, Download } from 'lucide-react';
|
||||
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||
import { cn } from '@/lib/utils'; // 1. 引入标准的 shadcn 工具函数
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
// 继承原生 HTML Div 属性,方便外部无缝扩充类名或监听事件
|
||||
interface QrCodePreviewProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
@@ -59,27 +60,16 @@ const QrCodePreview = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 3. 按钮群全面向 shadcn 官方 Button 视觉规范对齐 */}
|
||||
<div className="flex w-full gap-2 mt-5">
|
||||
{/* 下载按钮:使用标准的次要按钮风格 (Outline) */}
|
||||
<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"
|
||||
>
|
||||
<Button variant="outline" size="sm" onClick={onDownload} className="flex-1">
|
||||
<Download className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="truncate">{t('qrCode:downloadButton')}</span>
|
||||
</button>
|
||||
</Button>
|
||||
|
||||
{/* 复制按钮:使用标准的主要行动按钮风格 (Default) */}
|
||||
<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"
|
||||
>
|
||||
<Button variant="default" size="sm" onClick={onCopy} className="flex-1">
|
||||
<Copy className="w-4 h-4" />
|
||||
<span className="truncate">{t('qrCode:copyQrButton')}</span>
|
||||
</button>
|
||||
</Button>
|
||||
</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,6 +1,7 @@
|
||||
import { FEATURES, getEntryPointType } from '@/config/features';
|
||||
import { useRouter } from '@/providers/RouterProvider';
|
||||
import { Suspense, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import PageErrorBoundary from '@/components/PageErrorBoundary';
|
||||
import PageSkeleton from '@/components/PageSkeleton';
|
||||
import { cn } from '@/lib/utils'; // 1. 引入标准的 shadcn 工具函数
|
||||
@@ -8,15 +9,14 @@ import { AlertTriangle } from 'lucide-react'; // 用于标准的 404 异常展
|
||||
|
||||
export default function RouterContainer() {
|
||||
const { currentPage, isLoaded } = useRouter();
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
// 2. 稳定的动态动画类名映射
|
||||
const animationClass = useMemo(() => {
|
||||
return currentPage === 'dashboard' ? 'page-transition-dashboard' : 'page-transition-enter';
|
||||
}, [currentPage]);
|
||||
|
||||
const entryPointType = useMemo(() => {
|
||||
return getEntryPointType();
|
||||
}, []);
|
||||
const entryPointType = getEntryPointType();
|
||||
|
||||
// 骨架屏加载状态守卫
|
||||
if (!isLoaded) {
|
||||
@@ -52,9 +52,9 @@ export default function RouterContainer() {
|
||||
<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" />
|
||||
</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]">
|
||||
该功能在当前运行环境({entryPointType})下不可用或已被移除。
|
||||
{t('router.notFoundDescription', { entryPointType })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -34,14 +34,10 @@ export default function SwitchButtonGroup<T extends string | number = string>({
|
||||
large: 'text-base h-11 px-4 py-2 rounded-lg',
|
||||
};
|
||||
|
||||
const containerPadding = size === 'large' ? 'p-1' : 'p-1';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
// 将默认布局设计得更为通用(去掉一刀切的 mb-4,由外部控制布局空间)
|
||||
'inline-flex w-full items-center justify-center rounded-lg bg-muted text-muted-foreground',
|
||||
containerPadding,
|
||||
'inline-flex w-full items-center justify-center rounded-lg bg-muted text-muted-foreground p-1',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
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 { cn } from '@/lib/utils';
|
||||
import { toast } from 'sonner'; // 推荐使用 shadcn 的默认 Toast
|
||||
import { CopyButton } from '@/components/CopyButton';
|
||||
|
||||
export type ValidateRule = {
|
||||
validator: (value: string) => boolean;
|
||||
@@ -183,19 +184,9 @@ const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props
|
||||
onChange?.('');
|
||||
setError('');
|
||||
internalRef.current?.focus();
|
||||
toast.success('已清空内容');
|
||||
toast.success(t('textInputArea.cleared'));
|
||||
onClear?.();
|
||||
}, [isControlled, onChange, onClear]);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
toast.success('复制成功');
|
||||
} catch {
|
||||
setError('复制失败');
|
||||
toast.error('复制失败');
|
||||
}
|
||||
}, [value]);
|
||||
}, [isControlled, onChange, onClear, t]);
|
||||
|
||||
const handleAction = useCallback(
|
||||
(action: ToolbarAction) => {
|
||||
@@ -284,14 +275,12 @@ const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props
|
||||
{/* 右侧系统按钮组 */}
|
||||
<div className="flex items-center gap-1.5 ml-auto shrink-0">
|
||||
{allowCopy && value && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
aria-label={t('textInputArea.copyContent')}
|
||||
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"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</button>
|
||||
<CopyButton
|
||||
text={value}
|
||||
tooltip={t('textInputArea.copyContent')}
|
||||
size="sm"
|
||||
className="h-7 w-7 p-1"
|
||||
/>
|
||||
)}
|
||||
{showClear && value && !disabled && !readOnly && (
|
||||
<button
|
||||
|
||||
+10
-7
@@ -15,6 +15,7 @@ import { useRouter } from '@/providers/RouterProvider';
|
||||
import { useThemeMode } from '@/providers/ThemeModeProvider';
|
||||
import { FeatureConfig, FEATURES } from '@/config/features';
|
||||
import { storageUtil } from '@/utils/chromeStorage';
|
||||
import { openExtensionPage } from '@/utils/chromeTabs';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { normalizeLanguage, SUPPORTED_LANGUAGES } from '@/i18n';
|
||||
import { cn } from '@/lib/utils'; // 1. 引入 shadcn 核心工具函数
|
||||
@@ -125,17 +126,19 @@ export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void })
|
||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev));
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (selectedIndex >= 0) {
|
||||
if (selectedIndex >= 0 && selectedIndex < totalItems) {
|
||||
if (searchQuery.trim()) {
|
||||
handleSelectFeature(searchResults[selectedIndex]);
|
||||
} else {
|
||||
const selectedQuery = displayedHistory[selectedIndex];
|
||||
setSearchQuery(selectedQuery);
|
||||
setSelectedIndex(-1);
|
||||
const matched = FEATURES.find(
|
||||
(f) => f.key !== 'dashboard' && t(f.labelKey) === selectedQuery,
|
||||
);
|
||||
if (matched) handleSelectFeature(matched);
|
||||
if (selectedQuery) {
|
||||
setSearchQuery(selectedQuery);
|
||||
setSelectedIndex(-1);
|
||||
const matched = FEATURES.find(
|
||||
(f) => f.key !== 'dashboard' && t(f.labelKey) === selectedQuery,
|
||||
);
|
||||
if (matched) handleSelectFeature(matched);
|
||||
}
|
||||
}
|
||||
} else if (searchQuery.trim() && searchResults.length > 0) {
|
||||
handleSelectFeature(searchResults[0]);
|
||||
|
||||
@@ -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)
|
||||
@@ -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,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` 中注册新命名空间
|
||||
@@ -24,7 +24,22 @@
|
||||
},
|
||||
"messages": {
|
||||
"copySuccess": "Copied to clipboard",
|
||||
"copyError": "Copy failed"
|
||||
"copyError": "Copy failed",
|
||||
"copyEmpty": "Nothing to copy"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Oops, something went wrong",
|
||||
"description": "The app encountered an unexpected error. You can try refreshing the page or resetting the app.",
|
||||
"refresh": "Refresh App",
|
||||
"retry": "Retry"
|
||||
},
|
||||
"pageErrorBoundary": {
|
||||
"title": "This feature has encountered an error",
|
||||
"description": "An internal script error occurred while loading or rendering this page. You can try again or switch to another tool from the navigation menu."
|
||||
},
|
||||
"router": {
|
||||
"notFound": "Page Not Found",
|
||||
"notFoundDescription": "This feature is not available or has been removed in the current runtime ({{entryPointType}})."
|
||||
},
|
||||
"textInputArea": {
|
||||
"clear": "Clear",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"diffCount": "{{count}} differences",
|
||||
"invalidJson": "Invalid JSON format",
|
||||
"emptyHint": "Enter JSON on both sides and click Compare",
|
||||
"fixErrorHint": "Fix the JSON syntax errors above to enable live comparison",
|
||||
"added": "Added",
|
||||
"removed": "Removed",
|
||||
"modified": "Modified"
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"noContent": "Nothing to copy",
|
||||
"invalidJson": "Invalid JSON format",
|
||||
"emptyHint": "Enter JSON and click Format",
|
||||
"fixErrorHint": "Fix the JSON syntax errors above to enable live formatting",
|
||||
"originalSize": "Original size",
|
||||
"formattedSize": "Formatted size",
|
||||
"diffMode": "Diff",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"pageTitle": "Storage Cleaner",
|
||||
"pageSubtitle": "Clear cache, cookies, and local storage",
|
||||
"loading": "Loading...",
|
||||
"initializing": "Reading site data...",
|
||||
"occupied": "Occupied {{size}}",
|
||||
"cleaning": "Cleaning...",
|
||||
"cleanNow": "Clean Now",
|
||||
|
||||
@@ -24,7 +24,22 @@
|
||||
},
|
||||
"messages": {
|
||||
"copySuccess": "已复制到剪贴板",
|
||||
"copyError": "复制失败"
|
||||
"copyError": "复制失败",
|
||||
"copyEmpty": "无内容可复制"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "糟糕,出了点问题",
|
||||
"description": "应用遇到了一些意外错误。您可以尝试刷新页面或重置应用。",
|
||||
"refresh": "刷新应用",
|
||||
"retry": "重新尝试"
|
||||
},
|
||||
"pageErrorBoundary": {
|
||||
"title": "该功能运行异常",
|
||||
"description": "该页面在加载或渲染时遇到了内部脚本错误。您可以尝试重试,或者通过导航菜单切换到其他工具。"
|
||||
},
|
||||
"router": {
|
||||
"notFound": "页面未找到",
|
||||
"notFoundDescription": "该功能在当前运行环境({{entryPointType}})下不可用或已被移除。"
|
||||
},
|
||||
"textInputArea": {
|
||||
"clear": "清空",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"diffCount": "{{count}} 处差异",
|
||||
"invalidJson": "无效的 JSON 格式",
|
||||
"emptyHint": "输入两侧 JSON 后点击比较",
|
||||
"fixErrorHint": "请修正上方 JSON 的语法错误以开启实时流式比对",
|
||||
"added": "新增",
|
||||
"removed": "删除",
|
||||
"modified": "修改"
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"noContent": "无内容可复制",
|
||||
"invalidJson": "无效的 JSON 格式",
|
||||
"emptyHint": "输入 JSON 后点击格式化",
|
||||
"fixErrorHint": "请修正上方 JSON 的语法错误以开启实时流式格式化",
|
||||
"originalSize": "原始大小",
|
||||
"formattedSize": "格式化后大小",
|
||||
"diffMode": "差异比较",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"pageTitle": "存储清理",
|
||||
"pageSubtitle": "清理缓存、Cookies 及本地存储",
|
||||
"loading": "加载中...",
|
||||
"initializing": "正在读取站点数据...",
|
||||
"occupied": "已占用 {{size}}",
|
||||
"cleaning": "正在清理...",
|
||||
"cleanNow": "立即清理",
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# lib/
|
||||
|
||||
通用库工具目录,存放与业务无关的底层工具函数。
|
||||
|
||||
## 文件说明
|
||||
|
||||
| 文件 | 用途 |
|
||||
| ---------- | ------------------------------------ |
|
||||
| `utils.ts` | `cn()` 函数 — shadcn/ui 标准工具函数 |
|
||||
|
||||
## cn()
|
||||
|
||||
组合 `clsx` + `tailwind-merge`,用于合并和去重 Tailwind CSS 类名:
|
||||
|
||||
```typescript
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// 条件类名 + 合并外部 className
|
||||
<div className={cn(
|
||||
'flex items-center gap-3 px-3',
|
||||
isActive && 'bg-primary text-primary-foreground',
|
||||
className,
|
||||
)}>
|
||||
```
|
||||
|
||||
所有需要动态合并 Tailwind 类名的场景都应使用 `cn()`,而非手动拼接字符串。
|
||||
Generated
+144
-54
File diff suppressed because it is too large
Load Diff
@@ -57,7 +57,7 @@ export default function Base64ConverterSection({ mode }: Base64ConverterSectionP
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col space-y-4 animate-in fade-in duration-300">
|
||||
<div className="w-full flex flex-col space-y-4">
|
||||
<div className="flex h-11 items-center px-1.5 bg-secondary/40 rounded-xl border border-border/60 w-fit">
|
||||
<SwitchButtonGroup
|
||||
value={direction}
|
||||
@@ -86,7 +86,7 @@ export default function Base64ConverterSection({ mode }: Base64ConverterSectionP
|
||||
}}
|
||||
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',
|
||||
'flex flex-col items-center justify-center min-h-[190px] border-2 border-dashed rounded-2xl p-8 cursor-pointer',
|
||||
isDragging
|
||||
? 'border-primary bg-primary/10'
|
||||
: info
|
||||
@@ -107,7 +107,7 @@ export default function Base64ConverterSection({ mode }: Base64ConverterSectionP
|
||||
{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 w-full animate-in fade-in duration-200">
|
||||
<div className="flex flex-col items-center gap-1.5 text-center w-full">
|
||||
{mode === 'image' && result && (
|
||||
<div className="relative p-1 border border-border bg-background rounded-lg shadow-sm mb-1 max-w-[180px] overflow-hidden">
|
||||
<img
|
||||
@@ -117,9 +117,7 @@ export default function Base64ConverterSection({ mode }: Base64ConverterSectionP
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Upload
|
||||
className={cn('w-8 h-8 text-primary', mode === 'file' && 'animate-bounce')}
|
||||
/>
|
||||
<Upload className={cn('w-8 h-8 text-primary')} />
|
||||
<span className="text-sm font-bold text-foreground/90 max-w-[280px] truncate">
|
||||
{info.name}
|
||||
</span>
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
import { Trash2, Upload } from 'lucide-react';
|
||||
import TextInputArea from '@/components/TextInputArea';
|
||||
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||
import CopyButton from '@/components/CopyButton';
|
||||
import DecodeResultPaper from '@/components/DecodeResultPaper';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { downloadBlob, formatFileSize, 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 } = useLazyTranslation('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 { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||
import CopyButton from '@/components/CopyButton';
|
||||
import DecodeResultPaper from '@/components/DecodeResultPaper';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { downloadBlob, formatFileSize } from '@/utils/base64Converter';
|
||||
import { useStorageState } from '@/utils/useStorageState';
|
||||
import type { Base64ConvertDirection } from '@/types/storage';
|
||||
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
||||
import { useBase64Converter } from './useBase64Converter'; // 💡 引入共享核心
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const isValidDirection = (val: unknown): val is Base64ConvertDirection =>
|
||||
val === 'encode' || val === 'decode';
|
||||
|
||||
export default function ImageMode() {
|
||||
const { t } = useLazyTranslation('base64Converter');
|
||||
const [direction, setDirection] = useStorageState(
|
||||
'base64Converter/imageMode/direction',
|
||||
'encode',
|
||||
isValidDirection,
|
||||
);
|
||||
|
||||
// 消费完全托管的核心 Hook,消灭本地多余状态机
|
||||
const {
|
||||
result,
|
||||
info,
|
||||
isLoading,
|
||||
isDragging,
|
||||
setIsDragging,
|
||||
fileInputRef,
|
||||
encodeError,
|
||||
decodeInput,
|
||||
setDecodeInput,
|
||||
decoded,
|
||||
decodeError,
|
||||
decodedFileName,
|
||||
setCustomFileName,
|
||||
resetAll,
|
||||
safeFileSelect,
|
||||
} = useBase64Converter({ mode: 'image' });
|
||||
|
||||
const handleDirectionChange = (next: Base64ConvertDirection) => {
|
||||
if (!next || next === direction) return;
|
||||
resetAll();
|
||||
setDirection(next);
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
if (decoded) downloadBlob(decoded.blob, decodedFileName);
|
||||
};
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -93,7 +93,7 @@ export default function TextMode({ onSwitchToImageMode }: TextModeProps = {}) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col space-y-4 animate-in fade-in duration-300">
|
||||
<div className="w-full flex flex-col space-y-4">
|
||||
{/* 受控方向切流中枢 */}
|
||||
<div className="flex h-11 items-center px-1.5 bg-secondary/40 rounded-xl border border-border/60 w-fit">
|
||||
<SwitchButtonGroup
|
||||
@@ -125,7 +125,7 @@ export default function TextMode({ onSwitchToImageMode }: TextModeProps = {}) {
|
||||
- 完美向全站 shadcn 暗黑生态看齐,采用标准的 bg-primary/10 混合变体。
|
||||
*/}
|
||||
{showImageHint && (
|
||||
<div className="flex items-center justify-between p-3.5 rounded-xl bg-primary/10 border border-primary/20 animate-in slide-in-from-top-1 duration-200">
|
||||
<div className="flex items-center justify-between p-3.5 rounded-xl bg-primary/10 border border-primary/20">
|
||||
<span className="text-xs font-semibold text-primary tracking-tight">
|
||||
{t('imageDataUriHint')}
|
||||
</span>
|
||||
@@ -134,7 +134,7 @@ export default function TextMode({ onSwitchToImageMode }: TextModeProps = {}) {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onSwitchToImageMode}
|
||||
className="h-7 rounded-md text-xs font-bold text-primary hover:text-primary hover:bg-primary/20 dark:hover:bg-primary/10 transition-colors px-2.5"
|
||||
className="h-7 rounded-md text-xs font-bold text-primary hover:text-primary hover:bg-primary/20 dark:hover:bg-primary/10 px-2.5"
|
||||
>
|
||||
{t('switchToImageMode')}
|
||||
</Button>
|
||||
@@ -143,7 +143,7 @@ export default function TextMode({ onSwitchToImageMode }: TextModeProps = {}) {
|
||||
|
||||
{/* 5. 编码/解码核心数据承载流卡片 */}
|
||||
{output && (
|
||||
<div className="p-4 rounded-2xl bg-card border border-border shadow-sm flex flex-col space-y-3 animate-in slide-in-from-bottom-2 duration-300">
|
||||
<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">
|
||||
{outputLabel}
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import FileMode from '../FileMode';
|
||||
|
||||
// Mock CopyButton
|
||||
vi.mock('@/components/CopyButton', () => ({
|
||||
default: ({ text, tooltip }: { text: string; tooltip?: string }) => (
|
||||
<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('base64Converter:clickOrDropToFile')).toBeInTheDocument();
|
||||
expect(screen.getByText('base64Converter: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('base64Converter: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('base64Converter: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('base64Converter:clear'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('test.txt')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('base64Converter: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(/base64Converter:originalSize/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/base64Converter: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('base64Converter:encode')).toBeInTheDocument();
|
||||
expect(screen.getByText('base64Converter:decode')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('切到 decode 应该显示 Base64 输入框', async () => {
|
||||
render(<FileMode />);
|
||||
await waitForStorageReady();
|
||||
fireEvent.click(screen.getByText('base64Converter:decode'));
|
||||
expect(
|
||||
await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('解码 PDF Base64 后应该显示 application/pdf 与默认文件名 decoded.pdf', async () => {
|
||||
render(<FileMode />);
|
||||
await waitForStorageReady();
|
||||
fireEvent.click(screen.getByText('base64Converter:decode'));
|
||||
|
||||
const input = await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder');
|
||||
fireEvent.change(input, { target: { value: 'JVBERi0K' } });
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(250);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('base64Converter:decodedFileOutput')).toBeInTheDocument();
|
||||
expect(screen.getByText(/application\/pdf/)).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('decoded.pdf')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('解码后的文件名应该可编辑', async () => {
|
||||
render(<FileMode />);
|
||||
await waitForStorageReady();
|
||||
fireEvent.click(screen.getByText('base64Converter:decode'));
|
||||
|
||||
const input = await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder');
|
||||
fireEvent.change(input, { target: { value: 'JVBERi0K' } });
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(250);
|
||||
});
|
||||
|
||||
const filenameInput = (await screen.findByDisplayValue('decoded.pdf')) as HTMLInputElement;
|
||||
fireEvent.change(filenameInput, { target: { value: 'my-report.pdf' } });
|
||||
expect(filenameInput.value).toBe('my-report.pdf');
|
||||
});
|
||||
|
||||
it('解码后应该显示下载按钮', async () => {
|
||||
render(<FileMode />);
|
||||
await waitForStorageReady();
|
||||
fireEvent.click(screen.getByText('base64Converter:decode'));
|
||||
|
||||
const input = await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder');
|
||||
fireEvent.change(input, { target: { value: 'JVBERi0K' } });
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(250);
|
||||
});
|
||||
|
||||
expect(await screen.findByText('base64Converter:download')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('解码非法 Base64 应该显示 invalidBase64 错误', async () => {
|
||||
render(<FileMode />);
|
||||
await waitForStorageReady();
|
||||
fireEvent.click(screen.getByText('base64Converter:decode'));
|
||||
|
||||
const input = await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder');
|
||||
fireEvent.change(input, { target: { value: '!!!not base64' } });
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(250);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('base64Converter:invalidBase64')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('切换方向时应该清空解码状态', async () => {
|
||||
render(<FileMode />);
|
||||
await waitForStorageReady();
|
||||
fireEvent.click(screen.getByText('base64Converter:decode'));
|
||||
|
||||
const input = await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder');
|
||||
fireEvent.change(input, { target: { value: 'JVBERi0K' } });
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(250);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('base64Converter:decodedFileOutput')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('base64Converter:encode'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('decodedFileOutput')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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('base64Converter:clickOrDropToImage')).toBeInTheDocument();
|
||||
expect(screen.getByText('base64Converter: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('base64Converter: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('base64Converter: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('base64Converter: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('base64Converter:clear'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('test.png')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('base64Converter: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('base64Converter:encode')).toBeInTheDocument();
|
||||
expect(screen.getByText('base64Converter:decode')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('解码 PNG Base64 后应该显示图像预览', async () => {
|
||||
render(<ImageMode />);
|
||||
await waitForStorageReady();
|
||||
fireEvent.click(screen.getByText('base64Converter:decode'));
|
||||
|
||||
const input = await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder');
|
||||
fireEvent.change(input, { target: { value: 'iVBORw0KGgo=' } });
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(250);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('base64Converter:decodedImageOutput')).toBeInTheDocument();
|
||||
const img = screen.getByAltText('decoded preview');
|
||||
expect(img).toBeInTheDocument();
|
||||
expect(img.tagName.toLowerCase()).toBe('img');
|
||||
});
|
||||
});
|
||||
|
||||
it('解码后默认文件名应该为 decoded.png', async () => {
|
||||
render(<ImageMode />);
|
||||
await waitForStorageReady();
|
||||
fireEvent.click(screen.getByText('base64Converter:decode'));
|
||||
|
||||
const input = await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder');
|
||||
fireEvent.change(input, { target: { value: 'iVBORw0KGgo=' } });
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(250);
|
||||
});
|
||||
|
||||
expect(await screen.findByDisplayValue('decoded.png')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('解码非法 Base64 应该显示 invalidBase64 错误', async () => {
|
||||
render(<ImageMode />);
|
||||
await waitForStorageReady();
|
||||
fireEvent.click(screen.getByText('base64Converter:decode'));
|
||||
|
||||
const input = await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder');
|
||||
fireEvent.change(input, { target: { value: '!!!not base64' } });
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(250);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('base64Converter:invalidBase64')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,7 @@ export default function Index() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-4 w-full flex flex-col space-y-4 min-h-[520px] select-none animate-in fade-in duration-300">
|
||||
<div className="p-4 w-full flex flex-col space-y-4 min-h-[520px] select-none">
|
||||
<SwitchButtonGroup
|
||||
value={pageMode}
|
||||
options={[
|
||||
|
||||
@@ -10,7 +10,7 @@ const PALETTE_COLORS: Record<PaletteColorKey, string> = {
|
||||
success: '22, 163, 74', // green
|
||||
warning: '217, 119, 6', // amber (存储清理的橙色轴)
|
||||
error: '220, 38, 38', // red
|
||||
secondary: '147, 51, 2 purple',
|
||||
secondary: '147, 51, 232',
|
||||
info: '37, 99, 235', // blue
|
||||
};
|
||||
|
||||
@@ -46,7 +46,6 @@ export default function ToolCard({
|
||||
*/
|
||||
className={cn(
|
||||
'group relative rounded-xl border border-border/70 bg-card text-card-foreground p-4 h-auto flex flex-col items-stretch justify-start gap-3 shadow-sm select-none box-border',
|
||||
'transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]',
|
||||
'hover:bg-muted/30',
|
||||
'hover:border-[rgba(var(--tool-color),0.45)]',
|
||||
'hover:shadow-[0_8px_24px_-8px_rgba(var(--tool-color),0.14)] dark:hover:shadow-[0_8px_30px_-10px_rgba(var(--tool-color),0.25)]',
|
||||
@@ -60,7 +59,7 @@ export default function ToolCard({
|
||||
{/* 左侧圆形图标容器 */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center w-10 h-10 rounded-xl shrink-0 transition-colors duration-300',
|
||||
'flex items-center justify-center w-10 h-10 rounded-xl shrink-0',
|
||||
'bg-[rgba(var(--tool-color),0.08)] dark:bg-[rgba(var(--tool-color),0.12)]',
|
||||
'text-[rgb(var(--tool-color))]',
|
||||
)}
|
||||
@@ -82,7 +81,7 @@ export default function ToolCard({
|
||||
</div>
|
||||
|
||||
{/* 右侧指示小箭头 */}
|
||||
<div className="text-muted-foreground/40 group-hover:text-[rgb(var(--tool-color))] p-1 shrink-0 transition-all duration-300 ease-in-out group-hover:translate-x-0.5">
|
||||
<div className="text-muted-foreground/40 group-hover:text-[rgb(var(--tool-color))] p-1 shrink-0 group-hover:translate-x-0.5">
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ export default function DashboardPage() {
|
||||
<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',
|
||||
'select-none',
|
||||
)}
|
||||
>
|
||||
{pageOrder.map((key) => {
|
||||
|
||||
@@ -49,7 +49,7 @@ export default function HtmlToMarkdownPage() {
|
||||
- 彻底清除多余的 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">
|
||||
<div className="p-4 w-full flex flex-col space-y-4 select-none">
|
||||
<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">
|
||||
@@ -72,7 +72,7 @@ export default function HtmlToMarkdownPage() {
|
||||
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"
|
||||
className="h-8 rounded-md font-medium text-xs gap-1.5 shadow-sm"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
{t('download')}
|
||||
@@ -83,7 +83,7 @@ export default function HtmlToMarkdownPage() {
|
||||
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"
|
||||
className="h-8 rounded-md font-medium text-xs gap-1.5 shadow-sm text-destructive hover:text-destructive hover:bg-destructive/5 dark:hover:bg-destructive/10 border-input/60"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{t('clear')}
|
||||
@@ -95,7 +95,7 @@ export default function HtmlToMarkdownPage() {
|
||||
- 💡 核心修复点:将硬编码的 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">
|
||||
<div className="p-3.5 bg-destructive/10 border border-destructive/20 rounded-xl text-destructive text-xs font-semibold tracking-wide">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
@@ -114,7 +114,7 @@ export default function HtmlToMarkdownPage() {
|
||||
- 只要用户用鼠标点击了内部的 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">
|
||||
<div className="border border-border rounded-xl bg-card text-card-foreground shadow-sm overflow-hidden flex flex-col 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">
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function DiffNavigator({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-3 px-4 py-2 rounded-lg border border-border bg-muted/30 select-none animate-in fade-in duration-200',
|
||||
'flex items-center justify-center gap-3 px-4 py-2 rounded-lg border border-border bg-muted/30 select-none',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -60,9 +60,9 @@ export default function DiffNavigator({
|
||||
aria-label={t('jsonDiff:previousDiff')}
|
||||
onClick={onPrev}
|
||||
className={cn(
|
||||
'p-1 rounded-md text-muted-foreground transition-all hover:bg-accent hover:text-foreground',
|
||||
'p-1 rounded-md text-muted-foreground hover:bg-accent hover:text-foreground',
|
||||
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
|
||||
'disabled:pointer-events-none disabled:opacity-30 active:scale-95', // 4. 边界拦截:触顶时优雅淡化并锁死点击
|
||||
'disabled:pointer-events-none disabled:opacity-30', // 4. 边界拦截:触顶时优雅淡化并锁死点击
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
@@ -81,9 +81,9 @@ export default function DiffNavigator({
|
||||
aria-label={t('jsonDiff:nextDiff')}
|
||||
onClick={onNext}
|
||||
className={cn(
|
||||
'p-1 rounded-md text-muted-foreground transition-all hover:bg-accent hover:text-foreground',
|
||||
'p-1 rounded-md text-muted-foreground hover:bg-accent hover:text-foreground',
|
||||
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
|
||||
'disabled:pointer-events-none disabled:opacity-30 active:scale-95', // 4. 边界拦截:触底时优雅淡化并锁死点击
|
||||
'disabled:pointer-events-none disabled:opacity-30', // 4. 边界拦截:触底时优雅淡化并锁死点击
|
||||
)}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
|
||||
@@ -197,7 +197,7 @@ const UnifiedRow = ({ depth, type, text, active, multiline }: UnifiedRowProps) =
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-start w-full font-mono py-0.5 select-text group transition-colors',
|
||||
'flex items-start w-full font-mono py-0.5 select-text group',
|
||||
currentTheme.bg,
|
||||
currentTheme.text,
|
||||
// 4. 高亮定位条:不再使用生硬的蓝圆环,改为现代编辑器的“侧边左高亮带”设计,质感直接拉满
|
||||
|
||||
@@ -74,10 +74,7 @@ export default function JsonConvertSection({
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('w-full flex flex-col gap-4 animate-in fade-in duration-300', className)}
|
||||
{...props}
|
||||
>
|
||||
<div className={cn('w-full flex flex-col gap-4', className)} {...props}>
|
||||
{/* 输入区 */}
|
||||
<TextInputArea
|
||||
placeholder={t(`jsonFormat:${pk}InputPlaceholder`)}
|
||||
@@ -93,7 +90,7 @@ export default function JsonConvertSection({
|
||||
|
||||
{/* 4. 结果展示或状态引导卡片区 */}
|
||||
{result && result.output ? (
|
||||
<div className="relative rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden animate-in slide-in-from-bottom-2 duration-300">
|
||||
<div className="relative rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden">
|
||||
{/* 结果栏精致头部 */}
|
||||
<div className="flex h-9 items-center justify-between px-4 border-b border-border bg-muted/50 select-none">
|
||||
<div className="flex gap-4 items-center">
|
||||
@@ -136,7 +133,7 @@ export default function JsonConvertSection({
|
||||
/* 5. 空状态提示容器:完美的中性虚线引导,不喧宾夺主 */
|
||||
<div className="p-8 rounded-xl bg-muted/30 border border-dashed border-border/80 text-center flex flex-col items-center justify-center min-h-[120px] select-none">
|
||||
<p className="text-xs font-semibold text-muted-foreground/80 tracking-wide max-w-[240px] leading-relaxed">
|
||||
{error ? '请修正上方 JSON 的语法错误以激活流式转换' : t(`jsonFormat:${pk}EmptyHint`)}
|
||||
{error ? t('jsonFormat:fixErrorHint') : t(`jsonFormat:${pk}EmptyHint`)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -62,7 +62,7 @@ export default function JsonFormatSection() {
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-4 animate-in fade-in duration-300">
|
||||
<div className="w-full flex flex-col gap-4">
|
||||
{/* 工具控制栏 */}
|
||||
<div className="flex h-10 items-center justify-between px-1.5 bg-secondary/40 rounded-xl border border-border/60">
|
||||
<div className="flex gap-4 items-center w-full">
|
||||
@@ -95,7 +95,7 @@ export default function JsonFormatSection() {
|
||||
/>
|
||||
<Label
|
||||
htmlFor="sort-keys-checkbox"
|
||||
className="text-xs font-bold text-foreground/80 cursor-pointer tracking-tight group-hover:text-foreground transition-colors"
|
||||
className="text-xs font-bold text-foreground/80 cursor-pointer tracking-tight group-hover:text-foreground"
|
||||
>
|
||||
{t('jsonFormat:sortKeys')}
|
||||
</Label>
|
||||
@@ -118,7 +118,7 @@ export default function JsonFormatSection() {
|
||||
|
||||
{/* 格式化结果流面板展示 */}
|
||||
{result && result.formatted ? (
|
||||
<div className="relative rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden animate-in slide-in-from-bottom-2 duration-300">
|
||||
<div className="relative rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden">
|
||||
{/* 结果栏头部 */}
|
||||
<div className="flex h-9 items-center justify-between px-4 border-b border-border bg-muted/50 select-none">
|
||||
<div className="flex gap-4 items-center">
|
||||
@@ -160,7 +160,7 @@ export default function JsonFormatSection() {
|
||||
/* 空状态指示引导区 */
|
||||
<div className="p-8 rounded-xl bg-muted/30 border border-dashed border-border/80 text-center flex flex-col items-center justify-center min-h-[120px] select-none">
|
||||
<p className="text-xs font-semibold text-muted-foreground/80 tracking-wide max-w-[240px] leading-relaxed">
|
||||
{error ? '请修正上方 JSON 语法错误以开启实时流式格式化' : t('jsonFormat:emptyHint')}
|
||||
{error ? t('jsonFormat:fixErrorHint') : t('jsonFormat:emptyHint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -135,7 +135,7 @@ const NodeRow = React.memo(
|
||||
<div
|
||||
onClick={() => setOverride(expanded ? 'closed' : 'open')}
|
||||
className={cn(
|
||||
'group flex items-center gap-1 py-0.5 pr-2 text-xs font-mono select-none cursor-pointer rounded-sm transition-colors w-full h-[22px] leading-relaxed',
|
||||
'group flex items-center gap-1 py-0.5 pr-2 text-xs font-mono select-none cursor-pointer rounded-sm w-full h-[22px] leading-relaxed',
|
||||
theme.bg,
|
||||
isActive &&
|
||||
'bg-primary/10 relative before:absolute before:left-0 before:top-0 before:bottom-0 before:w-0.5 before:bg-blue-500 rounded-none ring-0',
|
||||
@@ -207,7 +207,7 @@ const NodeRow = React.memo(
|
||||
<div
|
||||
ref={rowRef}
|
||||
className={cn(
|
||||
'flex items-center gap-1 py-0.5 pr-2 font-mono text-xs w-full h-[22px] leading-relaxed rounded-sm transition-colors',
|
||||
'flex items-center gap-1 py-0.5 pr-2 font-mono text-xs w-full h-[22px] leading-relaxed rounded-sm',
|
||||
theme.bg,
|
||||
isActive &&
|
||||
'bg-primary/10 relative before:absolute before:left-0 before:top-0 before:bottom-0 before:w-0.5 before:bg-blue-500 rounded-none ring-0',
|
||||
|
||||
@@ -113,7 +113,7 @@ export default function Index() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="p-4 w-full flex flex-col space-y-4 min-h-[500px] select-none animate-in fade-in duration-300">
|
||||
<div className="p-4 w-full flex flex-col space-y-4 min-h-[500px] select-none">
|
||||
<SwitchButtonGroup
|
||||
value={pageMode}
|
||||
onChange={(v: PageMode) => setPageMode(v)}
|
||||
@@ -129,7 +129,7 @@ export default function Index() {
|
||||
/>
|
||||
|
||||
{pageMode === 'diff' ? (
|
||||
<div className="flex flex-col space-y-4 animate-in fade-in duration-200">
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="flex h-10 items-center justify-between px-1.5 bg-secondary/40 rounded-xl border border-border/60">
|
||||
<SwitchButtonGroup
|
||||
value={viewMode}
|
||||
@@ -182,9 +182,7 @@ export default function Index() {
|
||||
) : (
|
||||
<div className="p-8 rounded-xl bg-muted/30 border border-dashed border-border/80 text-center flex flex-col items-center justify-center min-h-[140px]">
|
||||
<p className="text-xs font-semibold text-muted-foreground/80 tracking-wide max-w-[260px] leading-relaxed">
|
||||
{leftError || rightError
|
||||
? '请修正上方 JSON 的语法错误以开启实时流式比对'
|
||||
: t('jsonDiff:emptyHint')}
|
||||
{leftError || rightError ? t('jsonDiff:fixErrorHint') : t('jsonDiff:emptyHint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
+4
-6
@@ -17,9 +17,7 @@ interface SectionProps {
|
||||
const Section = ({ title, content, colorClass, bgClass, borderClass }: SectionProps) => {
|
||||
const { t } = useLazyTranslation('jwt');
|
||||
return (
|
||||
<div
|
||||
className={cn('p-4 rounded-xl border border-solid transition-colors', bgClass, borderClass)}
|
||||
>
|
||||
<div className={cn('p-4 rounded-xl border border-solid', bgClass, borderClass)}>
|
||||
<div className="flex justify-between items-center mb-2 select-none">
|
||||
<span className={cn('text-xs font-bold tracking-wider uppercase', colorClass)}>
|
||||
{title}
|
||||
@@ -70,7 +68,7 @@ export default function Index() {
|
||||
}, [debouncedInput]);
|
||||
|
||||
return (
|
||||
<div className="p-4 w-full flex flex-col space-y-4 animate-in fade-in duration-300 select-none">
|
||||
<div className="p-4 w-full flex flex-col space-y-4 select-none">
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* 输入终端 */}
|
||||
<TextInputArea
|
||||
@@ -90,7 +88,7 @@ export default function Index() {
|
||||
|
||||
{/* 解码看板结果展现 */}
|
||||
{result && !result.error && (
|
||||
<div className="flex flex-col gap-4 animate-in slide-in-from-bottom-2 duration-300">
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Header 分区:完美致敬 JWT.io 的鲜艳色彩,同时实现黑夜暗化自适应 */}
|
||||
<Section
|
||||
title={t('jwt:headerTitle')}
|
||||
@@ -110,7 +108,7 @@ export default function Index() {
|
||||
/>
|
||||
|
||||
{/* Signature 签名区:完全对齐标准的 shadcn 骨架阶度 */}
|
||||
<div className="p-4 rounded-xl border border-border bg-secondary/40 shadow-sm transition-colors">
|
||||
<div className="p-4 rounded-xl border border-border bg-secondary/40 shadow-sm">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-xs font-bold tracking-wider text-muted-foreground/90 uppercase">
|
||||
{t('jwt:signatureTitle')}
|
||||
|
||||
@@ -37,7 +37,7 @@ const PREVIEW_STYLES = `
|
||||
.markdown-body h1 { border-bottom: 1px solid var(--md-border); padding-bottom: 0.3em; font-size: 1.6em; }
|
||||
.markdown-body h2 { border-bottom: 1px solid var(--md-border); padding-bottom: 0.3em; font-size: 1.35em; }
|
||||
.markdown-body p { margin-top: 0; margin-bottom: 16px; }
|
||||
.markdown-body a { color: #3b82f6; text-decoration: none; }
|
||||
.markdown-body a { color: var(--md-link-color); text-decoration: none; }
|
||||
.markdown-body a:hover { text-decoration: underline; }
|
||||
.markdown-body code {
|
||||
background-color: var(--md-code-bg);
|
||||
@@ -110,6 +110,7 @@ export default function MarkdownToHtmlPage() {
|
||||
--md-pre-bg: rgba(255,255,255,0.04);
|
||||
--md-muted: #8b949e;
|
||||
--md-quote-line: rgba(255,255,255,0.25);
|
||||
--md-link-color: #58a6ff;
|
||||
}`
|
||||
: `:root {
|
||||
--md-bg: #ffffff;
|
||||
@@ -119,6 +120,7 @@ export default function MarkdownToHtmlPage() {
|
||||
--md-pre-bg: rgba(128,128,128,0.03);
|
||||
--md-muted: #4b5563;
|
||||
--md-quote-line: rgba(128,128,128,0.3);
|
||||
--md-link-color: #3b82f6;
|
||||
}`;
|
||||
|
||||
// 💡 3. 核心大清洗:将全局基础树(html, body)与派生样式完全独立硬编码,杜绝任何语法踩踏
|
||||
@@ -175,7 +177,7 @@ export default function MarkdownToHtmlPage() {
|
||||
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">
|
||||
<div className="p-4 w-full flex flex-col space-y-4 select-none">
|
||||
<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">
|
||||
@@ -196,7 +198,7 @@ export default function MarkdownToHtmlPage() {
|
||||
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"
|
||||
className="h-8 rounded-md font-medium text-xs gap-1.5 shadow-sm text-destructive hover:text-destructive hover:bg-destructive/5 dark:hover:bg-destructive/10 border-input/60"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{t('clear')}
|
||||
@@ -206,7 +208,7 @@ export default function MarkdownToHtmlPage() {
|
||||
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"
|
||||
className="h-8 rounded-md font-medium text-xs gap-1.5 shadow-sm"
|
||||
>
|
||||
<Printer className="h-3.5 w-3.5" />
|
||||
{t('print')}
|
||||
@@ -216,7 +218,7 @@ export default function MarkdownToHtmlPage() {
|
||||
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"
|
||||
className="h-8 rounded-md font-medium text-xs gap-1.5 shadow-sm"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
{t('download')}
|
||||
@@ -228,7 +230,7 @@ export default function MarkdownToHtmlPage() {
|
||||
{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"
|
||||
className="p-3.5 bg-destructive/10 border border-destructive/20 rounded-xl text-destructive text-xs font-semibold tracking-wide"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
@@ -243,7 +245,7 @@ export default function MarkdownToHtmlPage() {
|
||||
>
|
||||
{/* 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="border border-border rounded-xl bg-card text-card-foreground shadow-sm overflow-hidden flex flex-col focus-within:ring-1 focus-within:ring-ring focus-within:border-ring">
|
||||
<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')}
|
||||
@@ -263,7 +265,7 @@ export default function MarkdownToHtmlPage() {
|
||||
|
||||
{/* 实时 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="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) === 'html' ? t('htmlOutputLabel') : t('previewLabel')}
|
||||
|
||||
@@ -10,11 +10,11 @@ export default function GeneratePanel() {
|
||||
const { generatorState, setTextToEncode, downloadQrCode, copyQrCode } = useQrCodeContext();
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 w-full items-stretch select-none p-0.5 animate-in fade-in duration-300">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 w-full items-stretch select-none p-0.5">
|
||||
{/* 左翼:高性能受控输入翼终端 */}
|
||||
<div
|
||||
className={cn(
|
||||
'border border-border rounded-xl bg-card text-card-foreground shadow-sm flex flex-col p-4 transition-all duration-200',
|
||||
'border border-border rounded-xl bg-card text-card-foreground shadow-sm flex flex-col p-4',
|
||||
'focus-within:ring-1 focus-within:ring-ring focus-within:border-ring',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -61,7 +61,7 @@ export default function ParsePanel() {
|
||||
/* 💡 统一大视觉轴:
|
||||
- 追加 p-0.5 微隔离,配合 gap-6 建立与生成面板(GeneratePanel)绝对像素对齐的网格天平。
|
||||
*/
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 w-full items-stretch select-none p-0.5 animate-in fade-in duration-300">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 w-full items-stretch select-none p-0.5">
|
||||
{/* 左翼:图片接收/拖拽/剪贴板上传终端 */}
|
||||
<div className="flex flex-col h-full">
|
||||
<ImageUploader
|
||||
@@ -78,7 +78,7 @@ export default function ParsePanel() {
|
||||
{/* 右翼:高阶解析出码只读终端 */}
|
||||
<div
|
||||
className={cn(
|
||||
'border border-border rounded-xl bg-card text-card-foreground shadow-sm flex flex-col p-4 transition-all duration-200',
|
||||
'border border-border rounded-xl bg-card text-card-foreground shadow-sm flex flex-col p-4',
|
||||
// 💡 视觉对称增强:加入相同的聚焦变量环联动,使双翼权重达成完美绝对平衡
|
||||
'focus-within:ring-1 focus-within:ring-ring focus-within:border-ring',
|
||||
)}
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function Index() {
|
||||
- 彻底剥离破坏流式宽度的 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">
|
||||
<div className="p-4 w-full flex flex-col space-y-4 min-h-[500px] select-none">
|
||||
{/* 流式中央控制切流卡:注入 sm 断点防御,防范单栏状态下发生变形 */}
|
||||
<div className="w-full sm:w-fit pt-0.5">
|
||||
<SwitchButtonGroup
|
||||
@@ -40,11 +40,11 @@ export default function Index() {
|
||||
*/}
|
||||
<div className="w-full pt-1.5">
|
||||
{qrCode.mode === 'generate' ? (
|
||||
<div className="animate-in fade-in duration-200">
|
||||
<div>
|
||||
<GeneratePanel />
|
||||
</div>
|
||||
) : (
|
||||
<div className="animate-in fade-in duration-200">
|
||||
<div>
|
||||
<ParsePanel />
|
||||
</div>
|
||||
)}
|
||||
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
# pages/
|
||||
|
||||
功能页面组件目录,每个子目录对应一个工具页面。
|
||||
|
||||
## 目录结构约定
|
||||
|
||||
典型页面遵循 **UI + Hook 分离** 模式:
|
||||
|
||||
```
|
||||
pages/FeatureName/
|
||||
├── index.tsx # 页面 UI(纯展示,使用 shadcn/ui 组件)
|
||||
├── useFeatureName.ts # 业务逻辑 Hook(状态管理 + 转换逻辑)
|
||||
├── constants.ts # 常量定义(可选)
|
||||
├── SubComponent.tsx # 子组件(可选)
|
||||
└── __tests__/ # 测试文件
|
||||
└── index.test.tsx
|
||||
```
|
||||
|
||||
## 页面列表
|
||||
|
||||
### Dashboard/
|
||||
|
||||
仪表盘首页,以卡片网格展示所有可见工具,支持点击导航。
|
||||
|
||||
| 文件 | 用途 |
|
||||
| -------------- | -------------------------------------------- |
|
||||
| `index.tsx` | 页面组件,渲染工具卡片网格 |
|
||||
| `ToolCard.tsx` | 工具卡片组件,展示图标、标题、描述和实时数据 |
|
||||
|
||||
### Timestamp/
|
||||
|
||||
时间戳转换工具,支持秒/毫秒级互转、多时区选择、实时时钟。
|
||||
|
||||
| 文件 | 用途 |
|
||||
| -------------------------- | ----------------------------------------------------------------- |
|
||||
| `index.tsx` | 页面 UI |
|
||||
| `useTimestampConverter.ts` | 业务逻辑 Hook,包含转换模式、输入、单位、时区状态和响应式计算管线 |
|
||||
| `LiveClock.tsx` | 实时时钟子组件,`React.memo` 优化 |
|
||||
| `ResultView.tsx` | 转换结果展示子组件 |
|
||||
| `constants.ts` | 时区列表等常量 |
|
||||
|
||||
### StorageCleaner/
|
||||
|
||||
浏览器存储清理工具,支持 Cookie/LocalStorage/SessionStorage/IndexedDB/Cache/SW 清理。
|
||||
|
||||
| 文件 | 用途 |
|
||||
| --------------------------- | -------------- |
|
||||
| `index.tsx` | 页面 UI |
|
||||
| `useStorageCleaner.ts` | 业务逻辑 Hook |
|
||||
| `StorageOptionsGrid.tsx` | 清理选项网格 |
|
||||
| `StorageCleanerConfirm.tsx` | 清理确认对话框 |
|
||||
| `AutoRefreshToggle.tsx` | 自动刷新开关 |
|
||||
| `ErrorDisplay.tsx` | 错误展示组件 |
|
||||
| `CleaningResult.tsx` | 清理结果展示 |
|
||||
| `OptionItem.tsx` | 单个选项组件 |
|
||||
|
||||
### QrCode/
|
||||
|
||||
二维码工具,支持生成(URL→QR)和解析(QR→URL)。
|
||||
|
||||
| 文件/目录 | 用途 |
|
||||
| ------------- | ---------------- |
|
||||
| `index.tsx` | 页面 UI |
|
||||
| `types.ts` | 类型定义 |
|
||||
| `contexts/` | Context Provider |
|
||||
| `hooks/` | 业务逻辑 Hooks |
|
||||
| `components/` | 子组件 |
|
||||
|
||||
### TextStatistics/
|
||||
|
||||
文本统计工具,实时计算字符数、单词数、行数、字节大小。
|
||||
|
||||
| 文件 | 用途 |
|
||||
| ----------- | --------------------------------------------- |
|
||||
| `index.tsx` | 页面组件,集成 `TextInputArea` 和统计结果展示 |
|
||||
|
||||
### Jwt/
|
||||
|
||||
JWT 解析工具,解码 Header/Payload/Signature。
|
||||
|
||||
| 文件/目录 | 用途 |
|
||||
| ------------- | ---------------- |
|
||||
| `index.tsx` | 页面 UI |
|
||||
| `types.ts` | 类型定义 |
|
||||
| `contexts/` | Context Provider |
|
||||
| `hooks/` | 业务逻辑 Hooks |
|
||||
| `components/` | 子组件 |
|
||||
|
||||
### JsonTools/
|
||||
|
||||
JSON 工具集:差异比较、格式化、YAML/TOML/Minify 转换。
|
||||
|
||||
| 文件 | 用途 |
|
||||
| ------------------------ | ---------------------------- |
|
||||
| `index.tsx` | 页面入口,子模式切换 |
|
||||
| `types.ts` | 类型定义 |
|
||||
| `diffEngine.ts` | 差异比较引擎 |
|
||||
| `JsonDiffInput.tsx` | JSON 输入组件 |
|
||||
| `DiffResult.tsx` | 差异结果展示 |
|
||||
| `DiffNavigator.tsx` | 差异导航器 |
|
||||
| `JsonFormatSection.tsx` | 格式化区域 |
|
||||
| `JsonConvertSection.tsx` | 转换区域(YAML/TOML/Minify) |
|
||||
| `JsonTree.tsx` | JSON 树形展示 |
|
||||
|
||||
### Base64Converter/
|
||||
|
||||
Base64 编解码工具,支持文本/文件/图片三种模式。
|
||||
|
||||
| 文件 | 用途 |
|
||||
| ---------------------------- | --------------------- |
|
||||
| `index.tsx` | 页面入口,子模式切换 |
|
||||
| `useBase64Converter.ts` | 业务逻辑 Hook |
|
||||
| `TextMode.tsx` | 文本模式 |
|
||||
| `Base64ConverterSection.tsx` | 文件/图片通用转换区域 |
|
||||
|
||||
### MarkdownToHtml/
|
||||
|
||||
Markdown 转 HTML 工具,支持分栏/预览/源码三种视图模式。
|
||||
|
||||
### HtmlToMarkdown/
|
||||
|
||||
HTML 转 Markdown 工具,支持分栏/预览/Markdown 三种视图模式。
|
||||
|
||||
### RightClickRestorer/
|
||||
|
||||
右键菜单恢复工具,解除网站对右键的限制。
|
||||
|
||||
| 文件 | 用途 |
|
||||
| -------------------------- | ------------- |
|
||||
| `index.tsx` | 页面 UI |
|
||||
| `useRightClickRestorer.ts` | 业务逻辑 Hook |
|
||||
|
||||
## 新增页面
|
||||
|
||||
1. 在 `types/storage.d.ts` 添加 `PageType` 联合类型成员
|
||||
2. 在 `config/features.tsx` 的 `FEATURES` 数组添加配置
|
||||
3. 在 `pages/` 创建页面目录(遵循上述结构)
|
||||
4. 在 `i18n/locales/{zh,en}/features.json` 添加翻译
|
||||
5. 如需新权限,更新 `wxt.config.ts`
|
||||
6. 添加对应的单元测试
|
||||
@@ -11,7 +11,7 @@ export default function RightClickRestorerPage() {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 min-h-[280px] w-full animate-in fade-in duration-200">
|
||||
<div className="flex flex-col items-center justify-center py-12 min-h-[280px] w-full">
|
||||
<span className="text-xs text-muted-foreground mt-2 font-medium tracking-wide">
|
||||
{t('rightClickRestorer:loading')}
|
||||
</span>
|
||||
@@ -20,9 +20,9 @@ export default function RightClickRestorerPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 w-full flex flex-col space-y-4 animate-in fade-in duration-300">
|
||||
<div className="p-4 w-full flex flex-col space-y-4">
|
||||
{/* Current Domain */}
|
||||
<div className="w-full rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden transition-all">
|
||||
<div className="w-full rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden">
|
||||
<div className="p-4">
|
||||
<Label className="text-sm font-medium">{t('rightClickRestorer:currentDomain')}</Label>
|
||||
<div className="mt-2 flex items-center justify-between gap-2">
|
||||
@@ -50,7 +50,7 @@ export default function RightClickRestorerPage() {
|
||||
</div>
|
||||
|
||||
{/* Unlock Action */}
|
||||
<div className="w-full rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden transition-all">
|
||||
<div className="w-full rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden">
|
||||
<div className="p-4 space-y-3">
|
||||
{isUnsupported ? (
|
||||
<>
|
||||
|
||||
@@ -48,7 +48,7 @@ export default function AutoRefreshToggle({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'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',
|
||||
'w-full p-4 rounded-xl border border-border bg-card text-card-foreground shadow-sm flex justify-between items-center',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -17,10 +17,7 @@ export default function CleaningResult({ result, className, ...props }: Cleaning
|
||||
const isSuccess = result.success;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('animate-in fade-in slide-in-from-top-1 duration-200 w-full', className)}
|
||||
{...props}
|
||||
>
|
||||
<div className={cn('w-full', className)} {...props}>
|
||||
{/* 2. 彻底重构容器类名结构:
|
||||
- 成功状态:采用 Tailwind 官方推荐的 emerald 体系,利用 /10 (10% 透明度) 和 /20 (边框)。
|
||||
- 失败状态:完全放权给标准的 border-destructive/20 和 bg-destructive/5。
|
||||
|
||||
@@ -13,7 +13,7 @@ export default function ErrorDisplay({ error, className, ...props }: ErrorDispla
|
||||
// 1. 精简层级:单层外壳直接搞定居中、响应式高度与外部类名扩展
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center py-8 min-h-[240px] sm:min-h-[360px] p-4 text-center animate-in fade-in zoom-in-95 duration-200',
|
||||
'flex flex-col items-center justify-center py-8 min-h-[240px] sm:min-h-[360px] p-4 text-center',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -25,7 +25,7 @@ export default function ErrorDisplay({ error, className, ...props }: ErrorDispla
|
||||
*/}
|
||||
<div className="w-full max-w-xs flex flex-col items-center justify-center rounded-xl p-5 border border-destructive/20 bg-destructive/5 shadow-sm">
|
||||
{/* 3. 图标与主要错误信息全面对接 text-destructive 语义色 */}
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-destructive/10 text-destructive mb-3.5 shrink-0 animate-bounce [animation-duration:2s]">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-destructive/10 text-destructive mb-3.5 shrink-0">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ export default function OptionItem({
|
||||
// 3. 跨越级交互升级:将外部容器升级为一个高度敏感的可点击 Tab 热区
|
||||
onClick={onChange}
|
||||
className={cn(
|
||||
'flex justify-between items-center py-2.5 px-3.5 rounded-xl border cursor-pointer select-none transition-all duration-200',
|
||||
'flex justify-between items-center py-2.5 px-3.5 rounded-xl border cursor-pointer select-none',
|
||||
// 4. 彻底抛弃硬编码黄底:
|
||||
// - 选中时:使用 bg-primary/5 (系统主色超淡叠加) 配合标准 border-primary/30。
|
||||
// - 未选中时:保持透明 border-transparent,悬停呈现 bg-muted。
|
||||
@@ -45,7 +45,7 @@ export default function OptionItem({
|
||||
<div className="flex-1 min-w-0 mr-4">
|
||||
<span
|
||||
className={cn(
|
||||
'block text-xs font-semibold leading-tight truncate transition-colors',
|
||||
'block text-xs font-semibold leading-tight truncate',
|
||||
checked ? 'text-foreground font-bold' : 'text-foreground/80',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -42,7 +42,6 @@ export function StorageCleanerConfirm({
|
||||
<DialogContent
|
||||
className={cn(
|
||||
'w-[90%] max-w-[340px] p-6 gap-0 rounded-2xl overflow-hidden shadow-xl border border-border bg-card text-card-foreground',
|
||||
'animate-in fade-in-50 zoom-in-95 duration-200',
|
||||
)}
|
||||
>
|
||||
{/* 头部标题区域 */}
|
||||
@@ -72,7 +71,7 @@ export function StorageCleanerConfirm({
|
||||
</div>
|
||||
|
||||
{/* 风险警告横幅 */}
|
||||
<div className="inline-flex items-center justify-center gap-1.5 px-3.5 py-2 rounded-lg bg-destructive/5 border border-dashed border-destructive/20 w-full max-w-[240px] animate-pulse [animation-duration:3s]">
|
||||
<div className="inline-flex items-center justify-center gap-1.5 px-3.5 py-2 rounded-lg bg-destructive/5 border border-dashed border-destructive/20 w-full max-w-[240px]">
|
||||
<AlertTriangle className="h-3.5 w-3.5 text-destructive shrink-0" />
|
||||
<span className="text-[11px] font-bold text-destructive leading-none tracking-tight">
|
||||
{t('storageCleaner:irreversible')}
|
||||
|
||||
@@ -46,7 +46,7 @@ export default function StorageOptionsGrid({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'w-full rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden transition-all',
|
||||
'w-full rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -75,7 +75,7 @@ export default function StorageOptionsGrid({
|
||||
*/}
|
||||
<div
|
||||
onClick={handleToggleAll}
|
||||
className="border-t border-border flex justify-between items-center px-4 py-2.5 bg-muted/20 hover:bg-muted/50 transition-colors cursor-pointer select-none"
|
||||
className="border-t border-border flex justify-between items-center px-4 py-2.5 bg-muted/20 hover:bg-muted/50 cursor-pointer select-none"
|
||||
>
|
||||
<Label className="text-xs font-bold text-muted-foreground/90 cursor-pointer">
|
||||
{t('storageCleaner:selectAll')}
|
||||
|
||||
@@ -33,10 +33,10 @@ export default function Index() {
|
||||
|
||||
if (isInitializing) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 min-h-[280px] w-full animate-in fade-in duration-200">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground/80" />
|
||||
<div className="flex flex-col items-center justify-center py-12 min-h-[280px] w-full">
|
||||
<Loader2 className="h-6 w-6 text-muted-foreground/80" />
|
||||
<span className="text-xs text-muted-foreground mt-2 font-medium tracking-wide">
|
||||
正在读取站点数据...
|
||||
{t('storageCleaner:initializing')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -47,7 +47,7 @@ export default function Index() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 w-full flex flex-col space-y-3.5 animate-in fade-in duration-300">
|
||||
<div className="p-4 w-full flex flex-col space-y-3.5">
|
||||
<StorageOptionsGrid
|
||||
options={options}
|
||||
sizes={sizes}
|
||||
@@ -67,7 +67,7 @@ export default function Index() {
|
||||
size="default"
|
||||
onClick={() => setShowConfirm(true)}
|
||||
disabled={isButtonDisabled}
|
||||
className="w-full h-10 font-bold shadow-sm text-sm tracking-wide transition-all active:scale-[0.99]"
|
||||
className="w-full h-10 font-bold shadow-sm text-sm tracking-wide"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function Index() {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-4 w-full space-y-4 animate-in fade-in duration-300">
|
||||
<div className="p-4 w-full space-y-4">
|
||||
{/* 文本输入区域 */}
|
||||
<TextInputArea
|
||||
value={text}
|
||||
@@ -45,7 +45,6 @@ export default function Index() {
|
||||
key={item.label}
|
||||
className={cn(
|
||||
'flex flex-col justify-center items-center p-4 text-center rounded-xl border border-border bg-card shadow-sm text-card-foreground',
|
||||
'transition-all duration-200 ease-out',
|
||||
'hover:-translate-y-0.5 hover:shadow-md hover:border-primary/50 focus-within:ring-1 focus-within:ring-ring',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -80,7 +80,7 @@ const LiveClock = React.memo(({ unit, onUseNow, className, ...props }: LiveClock
|
||||
type="button"
|
||||
onClick={handleUseNow}
|
||||
title={t('timestamp:useNowTooltip')}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md border border-input bg-background text-muted-foreground shadow-sm transition-all hover:bg-accent hover:text-foreground active:scale-95 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md border border-input bg-background text-muted-foreground shadow-sm hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
@@ -88,8 +88,7 @@ const LiveClock = React.memo(({ unit, onUseNow, className, ...props }: LiveClock
|
||||
<CopyButton
|
||||
text={currentDisplay.text}
|
||||
tooltip={t('timestamp:copyTsTooltip')}
|
||||
size="small"
|
||||
className="h-7 w-7 rounded-md border" // 移除了硬编码的颜色配置表,完全交由组件的内置 Class 渲染
|
||||
className="h-7 w-7 rounded-md border"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -50,7 +50,7 @@ const ResultView = React.memo(
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex-1 flex items-center justify-center text-sm font-medium border border-dashed border-border/60 rounded-xl py-12 px-4 text-center text-muted-foreground bg-muted/20 min-h-[320px] animate-in fade-in duration-200',
|
||||
'flex-1 flex items-center justify-center text-sm font-medium border border-dashed border-border/60 rounded-xl py-12 px-4 text-center text-muted-foreground bg-muted/20 min-h-[320px]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -61,13 +61,7 @@ const ResultView = React.memo(
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'animate-in fade-in slide-in-from-bottom-2 duration-300 flex flex-col w-full',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className={cn('flex flex-col w-full', className)} {...props}>
|
||||
{/* 顶部小标签 */}
|
||||
<span className="block text-muted-foreground/90 mb-2.5 text-xs font-semibold tracking-wider uppercase">
|
||||
{t('timestamp:resultLabel')}
|
||||
@@ -78,7 +72,7 @@ const ResultView = React.memo(
|
||||
对齐 shadcn 官方卡片风格,使用 bg-card、border-border 构筑多层级阴影。
|
||||
核心数值直接拉粗为 text-foreground (在黑夜模式下会自动转为大气的纯白,完美避开刺眼强光)
|
||||
*/}
|
||||
<div className="bg-card text-card-foreground border border-border p-4 sm:p-5 rounded-xl relative mb-3.5 shadow-sm flex justify-between items-center gap-4 focus-within:ring-1 focus-within:ring-ring transition-all">
|
||||
<div className="bg-card text-card-foreground border border-border p-4 sm:p-5 rounded-xl relative mb-3.5 shadow-sm flex justify-between items-center gap-4 focus-within:ring-1 focus-within:ring-ring">
|
||||
<span className="font-mono font-extrabold text-foreground break-all text-xl sm:text-2xl tracking-tight leading-tight select-all tabular-nums">
|
||||
{result}
|
||||
</span>
|
||||
|
||||
@@ -70,17 +70,13 @@ export default function Index() {
|
||||
value={input}
|
||||
onChange={(e: { target: { value: string } }) => setInput(e.target.value)}
|
||||
className={cn(
|
||||
'font-mono font-semibold h-10 shadow-sm placeholder:text-muted-foreground/60 focus:bg-background transition-all',
|
||||
'font-mono font-semibold h-10 shadow-sm placeholder:text-muted-foreground/60 focus:bg-background',
|
||||
error && 'border-destructive focus-visible:ring-destructive',
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* 错误自愈提示 */}
|
||||
{error && (
|
||||
<p className="text-destructive text-xs font-medium px-0.5 animate-in fade-in slide-in-from-top-1 duration-150">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{error && <p className="text-destructive text-xs font-medium px-0.5">{error}</p>}
|
||||
</div>
|
||||
|
||||
{/* 核心配置群:单位切换 + 时区选择紧凑横排 */}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# providers/
|
||||
|
||||
React Context Provider 目录,为整个应用提供全局共享状态。
|
||||
|
||||
## 文件说明
|
||||
|
||||
| 文件 | 用途 |
|
||||
| ----------------------- | ---------------------- |
|
||||
| `AppRoot.tsx` | 应用根 Provider 组合器 |
|
||||
| `RouterProvider.tsx` | 路由 Context Provider |
|
||||
| `ThemeModeProvider.tsx` | 主题模式 Provider |
|
||||
|
||||
## AppRoot.tsx
|
||||
|
||||
应用根 Provider 组合器,按顺序包裹:
|
||||
|
||||
```
|
||||
React.StrictMode
|
||||
└── ThemeModeProvider
|
||||
└── RouterProvider
|
||||
└── children
|
||||
```
|
||||
|
||||
## RouterProvider.tsx
|
||||
|
||||
路由 Context Provider,管理:
|
||||
|
||||
- **当前页面**:`currentPage`(`PageType`)
|
||||
- **可见页面列表**:`visiblePages`
|
||||
- **页面排序**:`pageOrder`
|
||||
- **加载状态**:`isLoaded`
|
||||
|
||||
核心特性:
|
||||
|
||||
- 通过 `chrome.storage` 持久化路由状态
|
||||
- 使用 `localStorage` 快照实现首屏 0 闪烁
|
||||
- 支持 popup/sidepanel/tab 三种入口的独立路由同步(通过 `syncKey`、`visiblePagesKey`、`pageOrderKey` 配置)
|
||||
- 处理右键菜单待处理数据的路由跳转
|
||||
- 监听 `chrome.storage.onChanged` 实现跨端同步
|
||||
|
||||
导出:
|
||||
|
||||
- `RouterProvider` 组件
|
||||
- `useRouter()` Hook — 获取 `currentPage`、`visiblePages`、`pageOrder`、`navigateTo`、`goBack` 等
|
||||
|
||||
## ThemeModeProvider.tsx
|
||||
|
||||
主题模式 Provider,管理:
|
||||
|
||||
- **主题模式**:`light` / `dark` / `system`
|
||||
- **解析后的主题**:`resolvedTheme`(`light` / `dark`)
|
||||
|
||||
核心特性:
|
||||
|
||||
- 使用 `localStorage` 快照实现首屏 0 闪烁
|
||||
- 监听系统级暗色模式变化(`matchMedia`)
|
||||
- 通过 `chrome.storage` 跨端同步主题偏好
|
||||
- 自动在 `document.documentElement` 上切换 `dark` class
|
||||
|
||||
导出:
|
||||
|
||||
- `ThemeModeProvider` 组件
|
||||
- `useThemeMode()` Hook — 获取 `themeMode`、`resolvedTheme`、`setThemeMode`
|
||||
@@ -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,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 的语义化类名
|
||||
+1
-5
@@ -26,11 +26,7 @@
|
||||
"target": "ESNext",
|
||||
|
||||
"types": ["chrome", "webextension-polyfill", "@testing-library/jest-dom", "vitest/globals"],
|
||||
"noImplicitAny": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
"noImplicitAny": true
|
||||
},
|
||||
// 确保包含你的源代码目录
|
||||
"include": [
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# types/
|
||||
|
||||
全局共享的 TypeScript 类型声明文件目录。
|
||||
|
||||
## 文件说明
|
||||
|
||||
### storage.d.ts
|
||||
|
||||
核心类型定义文件,包含:
|
||||
|
||||
**页面类型:**
|
||||
|
||||
- `PageType` — 所有页面类型的联合类型(`dashboard` | `timestamp` | `storageCleaner` | ...)
|
||||
- `JsonToolsPageMode` — JSON 工具子模式(`diff` | `format` | `yaml` | `toml` | `minify`)
|
||||
- `Base64ConverterPageMode` — Base64 子模式(`text` | `file` | `image`)
|
||||
- `Base64ConvertDirection` — 编解码方向(`encode` | `decode`)
|
||||
- `MarkdownToHtmlPreviewMode` — Markdown 预览模式(`split` | `preview` | `html`)
|
||||
- `HtmlToMarkdownPreviewMode` — HTML 预览模式(`split` | `preview` | `markdown`)
|
||||
|
||||
**存储 Schema:**
|
||||
|
||||
- `StorageSchema` — Chrome Storage 完整数据结构定义,所有存储键必须在此声明
|
||||
- 键名使用 kebab-case 格式(如 `app/currentRoute`)
|
||||
- 包含路由、主题、工具偏好、搜索历史等所有持久化数据
|
||||
|
||||
**其他类型:**
|
||||
|
||||
- `FormMapEntry` — 表单映射条目定义
|
||||
- `ContextMenuPendingData` — 右键菜单待处理数据
|
||||
- `StorageCleanerPreferences` / `StorageCleanerOptions` — 存储清理偏好
|
||||
- `CleaningResult` / `StorageCleanResult` — 清理结果类型
|
||||
|
||||
### qrious.d.ts
|
||||
|
||||
`qrious` 库的类型声明,定义 QR 码生成选项和 `QRious` 类。
|
||||
|
||||
## 修改 StorageSchema 的注意事项
|
||||
|
||||
修改 `StorageSchema` 时,必须:
|
||||
|
||||
1. 在 `utils/chromeStorage.ts` 添加版本迁移函数
|
||||
2. 在测试中覆盖迁移场景
|
||||
@@ -0,0 +1,42 @@
|
||||
# utils/
|
||||
|
||||
通用工具函数和 React 自定义 Hooks 目录,与具体页面解耦。
|
||||
|
||||
## 工具函数
|
||||
|
||||
| 文件 | 用途 |
|
||||
| -------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| `chromeStorage.ts` | Chrome Storage API 封装:类型安全的 `StorageUtils` 类,提供 `get/set/remove` 方法 |
|
||||
| `chromeTabs.ts` | Chrome Tabs API 封装:获取活动标签页、获取域名、在新标签页打开扩展页面 |
|
||||
| `clipboard.ts` | 剪贴板操作:`copyTextToClipboard`(文本)、`copyImageToClipboard`(图片) |
|
||||
| `messages.ts` | 扩展消息通信:基于 `@webext-core/messaging`,定义 `MessageAction` 枚举和 `ProtocolMap` 类型安全映射 |
|
||||
| `contextMenu.ts` | 右键菜单配置与操作:定义菜单项、创建菜单、解析点击事件、ID→PageType 映射 |
|
||||
| `base64Converter.ts` | Base64 编解码:文本↔Base64、文件↔Base64、图片预览,定义文件大小限制和图像 MIME 类型 |
|
||||
| `jwt.ts` | JWT 解析:Base64URL 解码、解析 Header/Payload/Signature、JSON 格式化输出 |
|
||||
| `jsonFormatter.ts` | JSON 格式化/压缩:支持缩进、按键排序、minify |
|
||||
| `jsonToYaml.ts` | JSON→YAML 转换 |
|
||||
| `jsonToToml.ts` | JSON→TOML 转换 |
|
||||
| `markdownToHtml.ts` | Markdown→HTML 转换:基于 `marked` 库,支持 GFM 和换行转换 |
|
||||
| `htmlToMarkdown.ts` | HTML→Markdown 转换:基于 DOMParser 解析 |
|
||||
| `qrCodeParser.ts` | 二维码解析:基于 `qr-scanner` 库从文件中解析二维码 |
|
||||
| `storageCleaner.ts` | 存储清理:获取当前标签页、检测受限 URL、计算 Cookie/Storage 大小、清理操作 |
|
||||
| `textStatistics.ts` | 文本统计:使用 `Intl.Segmenter` 计算字符数/单词数/行数/字节大小 |
|
||||
| `format.ts` | 通用格式化:`formatBytes` 将字节转为可读字符串(B/KB/MB/GB/TB) |
|
||||
| `dayjs.ts` | Day.js 初始化:扩展 UTC、Timezone、RelativeTime 插件,加载中文本地化 |
|
||||
|
||||
## 自定义 Hooks
|
||||
|
||||
| 文件 | 用途 |
|
||||
| ----------------------- | -------------------------------------------------------------------------------------------------------------- |
|
||||
| `useStorageState.ts` | Chrome Storage 状态 Hook:类似 `useState`,值自动同步到 `chrome.storage`,使用 `localStorage` 快照消除首屏闪烁 |
|
||||
| `useLazyTranslation.ts` | 懒加载翻译 Hook:按需动态导入 i18n 命名空间,支持预加载和缓存 |
|
||||
| `useContextMenuData.ts` | 右键菜单数据 Hook:从 storage 读取待处理数据,匹配 featureKey 后消费并触发回调 |
|
||||
| `useDebounce.ts` | 防抖 Hook:对值进行延迟更新,避免频繁触发 |
|
||||
|
||||
## 使用约定
|
||||
|
||||
- 工具函数使用**命名导出**(`export function xxx()`)
|
||||
- 工具函数**不抛异常**,返回包含 `hasError` 和 `error` 字段的结果对象
|
||||
- Hook 使用 `use` 前缀命名,定义返回值接口类型
|
||||
- 存储操作使用 `chromeStorage.ts` 的 `storageUtil` 封装,不要直接调用 `chrome.storage`
|
||||
- 消息通信使用 `messages.ts` 的 `sendMessage`/`onMessage`,不要使用原生 `chrome.runtime.sendMessage`
|
||||
Reference in New Issue
Block a user