Compare commits
91 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0624b609be | |||
| f551428142 | |||
| 1ae057ccfa | |||
| 400a1afb0c | |||
| c6d0914d33 | |||
| 7617e317f2 | |||
| 396b3d7013 | |||
| 92fb853329 | |||
| e4e5464be0 | |||
| f4b6ae89b5 | |||
| 37bdeb5f3a | |||
| e919af6857 | |||
| 5be15301b5 | |||
| 306600a35c | |||
| a0345b384d | |||
| 9458d62a99 | |||
| 5b26287cff | |||
| 6dca673a5a | |||
| afd57bb5b2 | |||
| 3f447708b7 | |||
| f9821553b3 | |||
| 355ab0cf86 | |||
| 13113f0ea2 | |||
| 0d69695dbb | |||
| 093bf278da | |||
| f9fbc79612 | |||
| 1b9f6d044d | |||
| b41cfb2a02 | |||
| 93019f56cf | |||
| 2727e60025 | |||
| c2e54b34cb | |||
| dbdec710ef | |||
| 7bd52bfc2b | |||
| c9fe27a26b | |||
| e97f4cae9a | |||
| 21a6430f0d | |||
| dff2705ef8 | |||
| f8c48a3047 | |||
| 488e2e8e49 | |||
| 98abe59731 | |||
| 77168e2652 | |||
| 4f7e402dba | |||
| e33761381d | |||
| 6bddc4fcfc | |||
| 3df45f491c | |||
| 0054e97a0a | |||
| 97159cb799 | |||
| 5175677b24 | |||
| 3400ffc9dc | |||
| 4955f617ee | |||
| 2cb5b0c1e7 | |||
| c6cadda95b | |||
| 067a20fb0a | |||
| ca4bfc33fd | |||
| e140af7698 | |||
| b79fe7dd49 | |||
| 9903483f42 | |||
| 08911d4cba | |||
| 1daf049737 | |||
| 8b6bac9f2c | |||
| aa23a263fe | |||
| 64e23994a6 | |||
| 528621f16e | |||
| 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 | |||
| 63f47fd2b1 | |||
| e95ca1297c | |||
| c8ad2a2283 |
+323
-10
@@ -762,22 +762,335 @@ const [themeMode, setThemeMode, isInitialized] = useStorageState(
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11. 文件组织
|
## 11. 页面开发规范
|
||||||
|
|
||||||
### 11.1 页面组件结构
|
### 11.1 目录结构(按复杂度分级)
|
||||||
|
|
||||||
|
#### 简单页面(单一功能,无子模式)
|
||||||
|
|
||||||
|
适用于 Timestamp、Jwt、TextStatistics、RightClickRestorer 等:
|
||||||
|
|
||||||
```
|
```
|
||||||
src/pages/FeatureName/
|
src/pages/FeatureName/
|
||||||
├── index.tsx # 页面 UI(纯展示,使用 shadcn/ui 组件)
|
├── index.tsx # 页面入口组件(default export)
|
||||||
├── useFeatureName.ts # 业务逻辑 Hook(状态管理 + 转换逻辑)
|
├── useFeatureName.ts # 业务逻辑 Hook(命名导出)
|
||||||
├── constants.ts # 常量定义(可选)
|
├── constants.ts # 常量定义(可选,命名导出)
|
||||||
├── LiveClock.tsx # 子组件(可选)
|
├── SubComponent.tsx # 子组件(可选,default export)
|
||||||
├── ResultView.tsx # 子组件(可选)
|
└── __tests__/
|
||||||
└── __tests__/ # 测试文件
|
└── index.test.tsx # 页面集成测试
|
||||||
└── index.test.tsx
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 11.2 目录职责
|
#### 中等页面(含多个子模式/标签页切换)
|
||||||
|
|
||||||
|
适用于 Base64Converter、StorageCleaner 等:
|
||||||
|
|
||||||
|
```
|
||||||
|
src/pages/FeatureName/
|
||||||
|
├── index.tsx # 页面入口(模式路由 + 顶层布局)
|
||||||
|
├── useFeatureName.ts # 业务逻辑 Hook(命名导出)
|
||||||
|
├── SubModeA.tsx # 子模式组件
|
||||||
|
├── SubModeB.tsx # 子模式组件
|
||||||
|
├── SubComponent.tsx # 可复用子组件
|
||||||
|
└── __tests__/
|
||||||
|
├── index.test.tsx
|
||||||
|
└── SubModeA.test.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 复杂页面(Context + 多组件协作)
|
||||||
|
|
||||||
|
适用于 QrCode、JsonTools 等:
|
||||||
|
|
||||||
|
```
|
||||||
|
src/pages/FeatureName/
|
||||||
|
├── index.tsx # 页面入口(Provider + 布局)
|
||||||
|
├── types.ts # 页面专属类型定义
|
||||||
|
├── constants.ts # 常量(可选)
|
||||||
|
├── contexts/ # React Context 定义
|
||||||
|
│ └── FeatureContext.ts
|
||||||
|
├── hooks/ # 页面专属 Hooks
|
||||||
|
│ └── useFeature.ts
|
||||||
|
├── components/ # 页面专属子组件
|
||||||
|
│ ├── PanelA.tsx
|
||||||
|
│ └── PanelB.tsx
|
||||||
|
└── __tests__/
|
||||||
|
├── index.test.tsx
|
||||||
|
└── useFeature.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 特殊情况(单个文件即可)
|
||||||
|
|
||||||
|
功能极简的页面(如 Dashboard),仅需 `index.tsx` 一个文件。当 `index.tsx` 超过 **150 行**时,应拆分为 UI + Hook 模式。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 11.2 页面入口组件(`index.tsx`)规范
|
||||||
|
|
||||||
|
#### 组件命名
|
||||||
|
|
||||||
|
- 页面入口组件**统一使用 `Index` 作为函数名**,通过 `export default` 导出
|
||||||
|
- 使用 `export default function Index()` 而非匿名默认导出
|
||||||
|
- **禁止**混用 `XxxPage` 命名(当前 `RightClickRestorerPage`、`DashboardPage` 不合规范,应统一为 `Index`)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ 正确
|
||||||
|
export default function Index() { ... }
|
||||||
|
|
||||||
|
// ❌ 错误 — 命名不一致
|
||||||
|
export default function RightClickRestorerPage() { ... }
|
||||||
|
export default function DashboardPage() { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 组件职责
|
||||||
|
|
||||||
|
`index.tsx` 只负责三件事:
|
||||||
|
|
||||||
|
1. **获取翻译函数**(`useI18n`)
|
||||||
|
2. **调用业务 Hook** 获取状态和操作方法
|
||||||
|
3. **渲染 UI 布局**(纯展示,无业务逻辑)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ 标准页面入口模板
|
||||||
|
import { useI18n } from '@/utils/chromeI18n';
|
||||||
|
import { useFeatureName } from './useFeatureName';
|
||||||
|
|
||||||
|
export default function Index() {
|
||||||
|
const { t } = useI18n('featureName');
|
||||||
|
const { state, actions } = useFeatureName();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 w-full flex flex-col space-y-4 select-none">
|
||||||
|
{/* 纯 UI 渲染 */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 禁止在 `index.tsx` 中编写的内容
|
||||||
|
|
||||||
|
- ❌ `useState` / `useMemo` / `useCallback`(应放在 Hook 中)
|
||||||
|
- ❌ 数据转换/格式化逻辑
|
||||||
|
- ❌ 异步请求/副作用
|
||||||
|
- ❌ 超过 3 行的条件判断逻辑
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 11.3 业务 Hook 规范(`useFeatureName.ts`)
|
||||||
|
|
||||||
|
#### 命名
|
||||||
|
|
||||||
|
- 文件名:`useXxx.ts`(驼峰命名)
|
||||||
|
- Hook 函数名:`useXxx()`
|
||||||
|
- 返回值接口:`UseXxxReturn`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ 标准 Hook 结构
|
||||||
|
export interface UseTimestampConverterReturn {
|
||||||
|
mode: 'ts2dt' | 'dt2ts';
|
||||||
|
input: string;
|
||||||
|
result: string;
|
||||||
|
error: string;
|
||||||
|
setMode: (mode: 'ts2dt' | 'dt2ts') => void;
|
||||||
|
setInput: (value: string) => void;
|
||||||
|
handleUseNow: (now: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTimestampConverter(): UseTimestampConverterReturn {
|
||||||
|
// 所有业务逻辑在此
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Hook 内部结构(推荐顺序)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function useFeatureName(): UseFeatureNameReturn {
|
||||||
|
// 1. i18n
|
||||||
|
const { t } = useI18n('featureName');
|
||||||
|
|
||||||
|
// 2. 基础 state(useState)
|
||||||
|
const [mode, setMode] = useState<Mode>('default');
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
|
||||||
|
// 3. 持久化 state(useStorageState)
|
||||||
|
const [pageMode, setPageMode] = useStorageState('feature/pageMode', 'default', isValidMode);
|
||||||
|
|
||||||
|
// 4. 衍生数据(useMemo)— 响应式计算管线
|
||||||
|
const result = useMemo(() => {
|
||||||
|
// 自动计算,无需手动点击"转换"按钮
|
||||||
|
}, [input, mode]);
|
||||||
|
|
||||||
|
// 5. 事件处理(useCallback)
|
||||||
|
const handleAction = useCallback(() => { ... }, [deps]);
|
||||||
|
|
||||||
|
// 6. 副作用(useEffect)— 防抖、初始化、清理
|
||||||
|
useEffect(() => { ... }, [deps]);
|
||||||
|
|
||||||
|
// 7. 右键菜单数据(页面需要时)
|
||||||
|
useContextMenuData({ featureKey: 'featureName', onData: handleContextMenuData });
|
||||||
|
|
||||||
|
// 8. 返回
|
||||||
|
return { mode, input, result, setMode, setInput, handleAction };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 防抖模式
|
||||||
|
|
||||||
|
当输入框需要防抖时,在 Hook 中实现:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ 防抖管道 — 在 useMemo 前定义
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
const [debouncedInput, setDebouncedInput] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handle = setTimeout(() => setDebouncedInput(input), 250);
|
||||||
|
return () => clearTimeout(handle);
|
||||||
|
}, [input]);
|
||||||
|
|
||||||
|
// 后续 useMemo 使用 debouncedInput 而非 input
|
||||||
|
const result = useMemo(() => compute(debouncedInput), [debouncedInput]);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 11.4 常量文件规范(`constants.ts`)
|
||||||
|
|
||||||
|
- 仅在常量超过 **3 个**或需要**导出类型**时创建
|
||||||
|
- 使用 `as const` 确保字面量类型
|
||||||
|
- 从 `as const` 数组派生联合类型
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ 标准常量文件
|
||||||
|
export const DATE_FORMAT = 'YYYY/MM/DD HH:mm:ss';
|
||||||
|
|
||||||
|
export const ZONES = ['Asia/Shanghai', 'America/New_York', 'Europe/London'] as const;
|
||||||
|
|
||||||
|
export type UnitType = 'ms' | 's';
|
||||||
|
export type ZoneType = (typeof ZONES)[number];
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 11.5 子组件规范
|
||||||
|
|
||||||
|
#### 何时拆分子组件
|
||||||
|
|
||||||
|
- `index.tsx` 超过 **150 行**
|
||||||
|
- 存在可复用的 UI 片段(如卡片、面板、结果展示区)
|
||||||
|
- 需要 `React.memo` 优化的高频渲染区域
|
||||||
|
|
||||||
|
#### 子组件 Props 模式
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ 继承 HTML 属性 + 业务 Props
|
||||||
|
interface ResultViewProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
result: string;
|
||||||
|
mode: 'ts2dt' | 'dt2ts';
|
||||||
|
showEmptyPlaceholder?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ 使用 React.memo + displayName
|
||||||
|
const ResultView = React.memo(
|
||||||
|
({ result, mode, showEmptyPlaceholder = false, className, ...props }: ResultViewProps) => {
|
||||||
|
const { t } = useI18n('featureName');
|
||||||
|
// ...
|
||||||
|
},
|
||||||
|
);
|
||||||
|
ResultView.displayName = 'ResultView';
|
||||||
|
export default ResultView;
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 子组件内可以使用 Hook
|
||||||
|
|
||||||
|
子组件可以独立调用 `useI18n`、`useSnackbar` 等全局 Hook,**不需要**通过 props 从父组件传递翻译函数或 toast 方法。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 11.6 存储键命名规范
|
||||||
|
|
||||||
|
页面使用的 Storage 键必须遵循 kebab-case 格式:`{功能名}/{用途}`。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ 正确
|
||||||
|
'base64Converter/pageMode';
|
||||||
|
'base64Converter/fileMode/direction';
|
||||||
|
'jsonTools/pageMode';
|
||||||
|
'qrCode/urlExpanded';
|
||||||
|
|
||||||
|
// ❌ 错误
|
||||||
|
'base64ConverterPageMode';
|
||||||
|
'json_tools_page_mode';
|
||||||
|
```
|
||||||
|
|
||||||
|
在 `types/storage.d.ts` 的 `StorageSchema` 中声明所有键。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 11.7 模式切换通用模式
|
||||||
|
|
||||||
|
当页面有多个子模式(标签页切换),统一使用以下模式:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ 标准模式切换
|
||||||
|
const VALID_MODES = ['modeA', 'modeB'] as const;
|
||||||
|
type PageMode = (typeof VALID_MODES)[number];
|
||||||
|
|
||||||
|
const isValidMode = (val: unknown): val is PageMode =>
|
||||||
|
typeof val === 'string' && (VALID_MODES as readonly string[]).includes(val);
|
||||||
|
|
||||||
|
export default function Index() {
|
||||||
|
const { t } = useI18n('featureName');
|
||||||
|
const [pageMode, setPageMode] = useStorageState('feature/pageMode', 'modeA', isValidMode);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 w-full flex flex-col space-y-4 select-none">
|
||||||
|
<SwitchButtonGroup
|
||||||
|
value={pageMode}
|
||||||
|
options={[
|
||||||
|
{ value: 'modeA', label: t('feature:modeA') },
|
||||||
|
{ value: 'modeB', label: t('feature:modeB') },
|
||||||
|
]}
|
||||||
|
onChange={(v: PageMode) => setPageMode(v)}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
{pageMode === 'modeA' ? <PanelA /> : <PanelB />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 11.8 页面布局约定
|
||||||
|
|
||||||
|
- 所有页面根元素使用统一的外层容器:
|
||||||
|
```
|
||||||
|
<div className="p-4 w-full flex flex-col space-y-4 select-none">
|
||||||
|
```
|
||||||
|
- 不需要 `<div className="min-h-screen bg-background ...">` — 该样式已由 `AppRoot` 提供
|
||||||
|
- 不需要 `min-h-[500px]` 或固定高度(除非确有必要)
|
||||||
|
- 卡片容器:`rounded-xl border border-border bg-card text-card-foreground shadow-sm`
|
||||||
|
- 使用 `space-y-4` 管理纵向间距,不要手动 `mb-4`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 11.9 页面开发检查清单
|
||||||
|
|
||||||
|
新增功能页面时,逐项确认:
|
||||||
|
|
||||||
|
1. ✅ 在 `types/storage.d.ts` 添加 `PageType` 联合类型
|
||||||
|
2. ✅ 在 `config/features.tsx` 注册 `FEATURES` 配置(key、labelKey、icon、三种渲染模式组件)
|
||||||
|
3. ✅ 创建页面目录,使用 `Index` 作为组件名
|
||||||
|
4. ✅ 业务逻辑提取到 `useXxx.ts` Hook(index.tsx 不超过 150 行)
|
||||||
|
5. ✅ 需要持久化的 UI 状态使用 `useStorageState`
|
||||||
|
6. ✅ 常量 ≥3 个时提取到 `constants.ts`
|
||||||
|
7. ✅ 在 `i18n/locales/{zh,en}/` 添加翻译
|
||||||
|
8. ✅ 创建 `__tests__/index.test.tsx` 测试文件
|
||||||
|
9. ✅ 如需新权限,更新 `wxt.config.ts` 的 `manifest.permissions`
|
||||||
|
10. ✅ 运行 `npm run lint && npm run typecheck && npm run test` 全部通过
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 11.10 目录职责总览
|
||||||
|
|
||||||
| 目录 | 职责 |
|
| 目录 | 职责 |
|
||||||
| -------------------- | ------------------------------------------------------------ |
|
| -------------------- | ------------------------------------------------------------ |
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ Router 同时使用 `chrome.storage.local` 持久化和 `localStorage` 快照来
|
|||||||
|
|
||||||
### 页面组件模式
|
### 页面组件模式
|
||||||
|
|
||||||
功能页面遵循 **UI + Hook 分离** 模式:
|
功能页面遵循 **UI + Hook 分离** 模式,详见 [CODING_STANDARDS.md § 11](./CODING_STANDARDS.md#11-页面开发规范):
|
||||||
|
|
||||||
```
|
```
|
||||||
pages/FeatureName/
|
pages/FeatureName/
|
||||||
@@ -65,7 +65,7 @@ pages/FeatureName/
|
|||||||
└── constants.ts # 常量定义(可选)
|
└── constants.ts # 常量定义(可选)
|
||||||
```
|
```
|
||||||
|
|
||||||
- 页面组件调用 `useLazyTranslation('featureName')` 获取翻译函数
|
- 页面组件调用 `useI18n('featureName')` 获取翻译函数
|
||||||
- Hook 负责所有状态管理,通过返回值暴露给页面
|
- Hook 负责所有状态管理,通过返回值暴露给页面
|
||||||
- 子组件可进一步拆分(如 `LiveClock.tsx`、`ResultView.tsx`)
|
- 子组件可进一步拆分(如 `LiveClock.tsx`、`ResultView.tsx`)
|
||||||
|
|
||||||
|
|||||||
@@ -31,3 +31,4 @@ stats-*.json
|
|||||||
dev/*
|
dev/*
|
||||||
|
|
||||||
docs/*
|
docs/*
|
||||||
|
!docs/VISUAL_STYLE_GUIDE.md
|
||||||
|
|||||||
@@ -1 +1,5 @@
|
|||||||
|
# 后台运行类型检查,结果输出到 stderr 但不阻塞提交
|
||||||
|
npx tsc --noEmit &
|
||||||
|
|
||||||
|
# 前台运行 lint-staged(自动修复 + 格式化)
|
||||||
npx lint-staged
|
npx lint-staged
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# pre-push: 严格检查,阻塞有问题的代码推送到远程
|
||||||
|
# 类型检查
|
||||||
|
npx tsc --noEmit
|
||||||
|
|
||||||
|
# ESLint 严格检查(不允许 warning)
|
||||||
|
npx eslint . --max-warnings=0
|
||||||
@@ -59,18 +59,22 @@ public/ # 静态资源(图标、_locales 等)
|
|||||||
|
|
||||||
### 页面组件模式
|
### 页面组件模式
|
||||||
|
|
||||||
典型功能页面遵循 **UI + Hook 分离** 模式:
|
典型功能页面遵循 **UI + Hook 分离** 模式。详见 [CODING_STANDARDS.md § 11](./.github/CODING_STANDARDS.md#11-页面开发规范)。
|
||||||
|
|
||||||
```
|
```
|
||||||
src/pages/FeatureName/
|
src/pages/FeatureName/
|
||||||
├── index.tsx # 页面 UI(纯展示,使用 shadcn/ui 组件)
|
├── index.tsx # 页面 UI(纯展示,仅负责渲染布局)
|
||||||
├── useFeatureName.ts # 业务逻辑 Hook(状态管理 + 转换逻辑)
|
├── useFeatureName.ts # 业务逻辑 Hook(状态管理 + 转换逻辑)
|
||||||
└── constants.ts # 常量定义
|
├── constants.ts # 常量定义(可选,≥3 个常量时创建)
|
||||||
|
└── __tests__/
|
||||||
|
└── index.test.tsx
|
||||||
```
|
```
|
||||||
|
|
||||||
- 页面组件调用 `useLazyTranslation('featureName')` 获取翻译函数
|
- 页面入口组件统一命名为 `Index`,通过 `export default function Index()` 导出
|
||||||
- Hook 负责所有状态管理和业务逻辑,通过返回值暴露给页面
|
- Hook 负责所有状态管理和业务逻辑,通过返回值暴露给页面
|
||||||
- 子组件可进一步拆分(如 `LiveClock.tsx`、`ResultView.tsx`)
|
- 子组件可以独立调用 `useI18n` 等全局 Hook
|
||||||
|
- 当 `index.tsx` 超过 150 行时,必须拆分为 UI + Hook 模式
|
||||||
|
- 复杂页面可增加 `contexts/`、`hooks/`、`components/` 子目录
|
||||||
|
|
||||||
## 关键架构决策
|
## 关键架构决策
|
||||||
|
|
||||||
@@ -88,7 +92,7 @@ src/pages/FeatureName/
|
|||||||
|
|
||||||
**浏览器兼容**: 优先使用 `wxt/browser` 导出的 `browser` 对象,而非原生 `chrome` API。
|
**浏览器兼容**: 优先使用 `wxt/browser` 导出的 `browser` 对象,而非原生 `chrome` API。
|
||||||
|
|
||||||
**代码分割**: `wxt.config.ts` 通过 `manualChunksForHtmlOnly()` 自动分组依赖(vendor-react、vendor-i18n、vendor-qr 等),无需手动配置。
|
**代码分割**: `wxt.config.ts` 通过 `manualChunksForHtmlOnly()` 自动分组依赖(vendor-react、vendor-qr、vendor-dnd 等),无需手动配置。
|
||||||
|
|
||||||
## 测试环境
|
## 测试环境
|
||||||
|
|
||||||
@@ -96,35 +100,37 @@ src/pages/FeatureName/
|
|||||||
- 全局变量: `vitest/globals` (describe, it, expect 等无需导入)
|
- 全局变量: `vitest/globals` (describe, it, expect 等无需导入)
|
||||||
- Setup 文件: `vitest.setup.ts` 自动 mock:
|
- Setup 文件: `vitest.setup.ts` 自动 mock:
|
||||||
- `chrome.*` / `browser.*` API (storage, tabs, runtime, cookies 等)
|
- `chrome.*` / `browser.*` API (storage, tabs, runtime, cookies 等)
|
||||||
- `react-i18next` (返回 key 作为翻译)
|
- `@/utils/chromeI18n` (从 `public/_locales/zh/messages.json` 加载真实翻译)
|
||||||
- `@/utils/useLazyTranslation` (返回 `ns:key` 格式翻译)
|
|
||||||
- `window.matchMedia`
|
- `window.matchMedia`
|
||||||
- 测试文件命名: `__tests__/*.test.{ts,tsx}` 或 `*.test.{ts,tsx}`
|
- 测试文件命名: `__tests__/*.test.{ts,tsx}` 或 `*.test.{ts,tsx}`
|
||||||
- Mock 模式: 使用 `vi.mock()` 进行模块级 mock,避免在测试文件中重复 mock 代码
|
- Mock 模式: 使用 `vi.mock()` 进行模块级 mock,避免在测试文件中重复 mock 代码
|
||||||
- 测试工具: `@testing-library/react` + `@testing-library/user-event` 进行组件测试
|
- 测试工具: `@testing-library/react` + `@testing-library/user-event` 进行组件测试
|
||||||
|
|
||||||
## i18n
|
## i18n (chrome.i18n)
|
||||||
|
|
||||||
- 命名空间: `common` (默认), `features`
|
项目使用 Chrome 扩展标准的 `chrome.i18n` API 进行本地化,通过 `src/utils/chromeI18n.ts` 提供类型安全的 React Hook 包装。
|
||||||
- 翻译键格式: `namespace:key` (如 `features:timestamp.title`)
|
|
||||||
- 语言: `zh` (默认), `en`
|
- **翻译文件**: `public/_locales/{zh,en}/messages.json`(Chrome 扩展标准格式)
|
||||||
- 翻译文件结构:
|
- **默认语言**: `zh`(在 `wxt.config.ts` 的 `manifest.default_locale` 中配置)
|
||||||
- `i18n/locales/{zh,en}/common.json` - 全局通用翻译
|
- **使用方式**: `import { useI18n } from '@/utils/chromeI18n'`
|
||||||
- `i18n/locales/{zh,en}/features.json` - 功能模块标题和描述
|
- **翻译键格式**:
|
||||||
- `i18n/locales/{zh,en}/{功能名}.json` - 各功能独立翻译(如 timestamp.json, storageCleaner.json 等)
|
- 直接 key: `t('dashboard_title')` → 查找 `dashboard_title`
|
||||||
- 添加新翻译: 编辑 `i18n/locales/{zh,en}/{common,features}.json` 及对应功能独立 JSON
|
- 命名空间格式(兼容旧用法): `t('common:buttons.search')` → 查找 `common_buttons_search`
|
||||||
- 使用 `useLazyTranslation` hook 加载功能独立翻译,返回 `ns:key` 格式
|
- 带命名空间参数: `useI18n(['common', 'features'])`,会自动尝试 `common_key`、`features_key`
|
||||||
- 回退策略: 当翻译 key 在目标语言缺失时,回退到默认语言 `zh`;若默认语言也缺失,返回占位格式 `namespace:key` 并在开发模式下记录 warning
|
- **占位符支持**: `t('router_notFoundDescription', { entryPointType: 'popup' })`
|
||||||
|
- **Hook 返回值**: `{ t, i18n: { language, changeLanguage }, isLoaded }`
|
||||||
|
- **回退策略**: 当翻译 key 未命中时,返回 key 本身(开发模式下在控制台记录 warning)
|
||||||
|
- **限制**: `chrome.i18n` 无法动态切换语言,语言跟随浏览器设置,切换后需刷新页面
|
||||||
|
|
||||||
## 新功能开发清单
|
## 新功能开发清单
|
||||||
|
|
||||||
1. 在 `src/types/storage.d.ts` 添加 `PageType` 联合类型
|
1. 在 `src/types/storage.d.ts` 添加 `PageType` 联合类型
|
||||||
2. 在 `src/config/features.tsx` 的 `FEATURES` 数组添加配置(指定 key、翻译键、图标、三种渲染模式的组件)
|
2. 在 `src/config/features.tsx` 的 `FEATURES` 数组添加配置(指定 key、翻译键、图标、三种渲染模式的组件)
|
||||||
3. 在 `src/pages/` 创建页面组件 (懒加载):
|
3. 在 `src/pages/` 创建页面组件 (懒加载):
|
||||||
- `index.tsx` — UI 组件,使用 `useLazyTranslation` 获取翻译
|
- `index.tsx` — UI 组件,使用 `useI18n` 获取翻译
|
||||||
- `useFeatureName.ts` — 业务逻辑 Hook
|
- `useFeatureName.ts` — 业务逻辑 Hook
|
||||||
- `constants.ts` — 常量(可选)
|
- `constants.ts` — 常量(可选)
|
||||||
4. 在 `i18n/locales/{zh,en}/features.json` 添加翻译(复杂功能可新建独立 JSON)
|
4. 在 `public/_locales/zh/messages.json`(及 `en/messages.json`)添加翻译
|
||||||
5. 如需新权限,更新 `wxt.config.ts` 的 `manifest.permissions`
|
5. 如需新权限,更新 `wxt.config.ts` 的 `manifest.permissions`
|
||||||
6. 添加对应的单元测试
|
6. 添加对应的单元测试
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,871 @@
|
|||||||
|
# Testing Tools — 视觉规范文档
|
||||||
|
|
||||||
|
> **版本**: 1.0.0
|
||||||
|
> **日期**: 2026-05-29
|
||||||
|
> **适用范围**: 所有新页面、新组件、UI 修改
|
||||||
|
> **设计系统**: 基于 [shadcn/ui](https://ui.shadcn.com/) + Tailwind CSS
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
1. [设计原则](#1-设计原则)
|
||||||
|
2. [色彩系统](#2-色彩系统)
|
||||||
|
3. [排版规范](#3-排版规范)
|
||||||
|
4. [间距与布局](#4-间距与布局)
|
||||||
|
5. [圆角与阴影](#5-圆角与阴影)
|
||||||
|
6. [组件规范](#6-组件规范)
|
||||||
|
7. [交互与动效](#7-交互与动效)
|
||||||
|
8. [暗色模式](#8-暗色模式)
|
||||||
|
9. [工具色彩标识](#9-工具色彩标识)
|
||||||
|
10. [代码规范](#10-代码规范)
|
||||||
|
11. [反模式清单](#11-反模式清单)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 设计原则
|
||||||
|
|
||||||
|
### 1.1 核心定位
|
||||||
|
|
||||||
|
Testing Tools 是一款**浏览器扩展开发者工具集**,视觉风格遵循:
|
||||||
|
|
||||||
|
- **专业克制** — 低饱和度色彩,避免视觉噪音
|
||||||
|
- **信息密度优先** — 紧凑布局,在 400×600px 的 popup 空间内高效展示
|
||||||
|
- **开发者友好** — 等宽字体用于代码/数据,清晰的信息层级
|
||||||
|
- **一致性至上** — 所有页面、组件遵循同一套视觉语言
|
||||||
|
|
||||||
|
### 1.2 设计关键词
|
||||||
|
|
||||||
|
```
|
||||||
|
简洁 · 现代 · 功能导向 · 低对比度 · 微圆角 · 微妙阴影
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 与 shadcn/ui 的关系
|
||||||
|
|
||||||
|
本项目以 shadcn/ui 为底座,所有基础组件(Button、Input、Select 等)均来自或对齐 shadcn/ui 的默认样式。业务组件在此基础上扩展,**不得破坏底层设计语言的统一性**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 色彩系统
|
||||||
|
|
||||||
|
### 2.1 CSS 变量定义
|
||||||
|
|
||||||
|
所有色彩通过 CSS 自定义属性(HSL 格式)管理,定义于 `src/index.css`:
|
||||||
|
|
||||||
|
#### 亮色模式 (`:root`)
|
||||||
|
|
||||||
|
| 变量名 | HSL 值 | 用途 | 近似色 |
|
||||||
|
| -------------------------- | ------------------- | ------------- | --------- |
|
||||||
|
| `--background` | `0 0% 100%` | 页面背景 | `#ffffff` |
|
||||||
|
| `--foreground` | `222.2 84% 4.9%` | 主文字 | `#020617` |
|
||||||
|
| `--card` | `0 0% 100%` | 卡片背景 | `#ffffff` |
|
||||||
|
| `--card-foreground` | `222.2 84% 4.9%` | 卡片文字 | `#020617` |
|
||||||
|
| `--popover` | `0 0% 100%` | 浮层背景 | `#ffffff` |
|
||||||
|
| `--popover-foreground` | `222.2 84% 4.9%` | 浮层文字 | `#020617` |
|
||||||
|
| `--primary` | `222.2 47.4% 11.2%` | 主按钮/强调 | `#0f172a` |
|
||||||
|
| `--primary-foreground` | `210 40% 98%` | 主按钮文字 | `#f8fafc` |
|
||||||
|
| `--secondary` | `210 40% 96.1%` | 次级背景 | `#f1f5f9` |
|
||||||
|
| `--secondary-foreground` | `222.2 47.4% 11.2%` | 次级文字 | `#0f172a` |
|
||||||
|
| `--muted` | `210 40% 96.1%` | 静音/禁用背景 | `#f1f5f9` |
|
||||||
|
| `--muted-foreground` | `215.4 16.3% 46.9%` | 次要文字 | `#64748b` |
|
||||||
|
| `--accent` | `210 40% 96.1%` | 悬停高亮 | `#f1f5f9` |
|
||||||
|
| `--accent-foreground` | `222.2 47.4% 11.2%` | 悬停文字 | `#0f172a` |
|
||||||
|
| `--destructive` | `0 84.2% 60.2%` | 错误/删除 | `#ef4444` |
|
||||||
|
| `--destructive-foreground` | `210 40% 98%` | 错误文字 | `#f8fafc` |
|
||||||
|
| `--border` | `214.3 31.8% 91.4%` | 边框 | `#e2e8f0` |
|
||||||
|
| `--input` | `214.3 31.8% 91.4%` | 输入框边框 | `#e2e8f0` |
|
||||||
|
| `--ring` | `222.2 84% 4.9%` | 焦点环 | `#020617` |
|
||||||
|
| `--radius` | `0.5rem` | 全局圆角 | `8px` |
|
||||||
|
|
||||||
|
#### 暗色模式 (`.dark`)
|
||||||
|
|
||||||
|
暗色模式下所有变量自动反转,保持对比度关系:
|
||||||
|
|
||||||
|
| 变量名 | HSL 值 | 近似色 |
|
||||||
|
| ---------------------- | ------------------- | --------- |
|
||||||
|
| `--background` | `222.2 84% 4.9%` | `#020617` |
|
||||||
|
| `--foreground` | `210 40% 98%` | `#f8fafc` |
|
||||||
|
| `--primary` | `210 40% 98%` | `#f8fafc` |
|
||||||
|
| `--primary-foreground` | `222.2 47.4% 11.2%` | `#0f172a` |
|
||||||
|
| `--secondary` | `217.2 32.6% 17.5%` | `#1e293b` |
|
||||||
|
| `--muted` | `217.2 32.6% 17.5%` | `#1e293b` |
|
||||||
|
| `--border` | `217.2 32.6% 17.5%` | `#1e293b` |
|
||||||
|
|
||||||
|
### 2.2 使用规范
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ✅ 正确:使用 CSS 变量
|
||||||
|
<div className="bg-background text-foreground border-border">
|
||||||
|
|
||||||
|
// ✅ 正确:使用语义化色彩名
|
||||||
|
<Button className="bg-primary text-primary-foreground">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
|
||||||
|
// ❌ 错误:硬编码颜色值
|
||||||
|
<div className="bg-white text-black">
|
||||||
|
<div className="bg-[#f1f5f9]">
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 语义化色彩使用场景
|
||||||
|
|
||||||
|
| 色彩 | 场景 |
|
||||||
|
| -------------------------- | -------------------------------- |
|
||||||
|
| `background` | 页面根背景 |
|
||||||
|
| `foreground` | 主标题、正文 |
|
||||||
|
| `muted-foreground` | 描述文字、占位符、次级标签 |
|
||||||
|
| `border` | 卡片边框、分割线、输入框边框 |
|
||||||
|
| `card` + `card-foreground` | 卡片容器及其内容 |
|
||||||
|
| `primary` | 主按钮、选中状态、关键操作 |
|
||||||
|
| `secondary` | 次级按钮、工具栏背景、标签页背景 |
|
||||||
|
| `destructive` | 错误提示、删除操作、验证失败 |
|
||||||
|
| `accent` | 悬停背景、下拉选中项 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 排版规范
|
||||||
|
|
||||||
|
### 3.1 字体栈
|
||||||
|
|
||||||
|
项目使用系统默认字体栈(Tailwind 默认),**不引入自定义字体**。
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Tailwind 默认 sans-serif */
|
||||||
|
font-family:
|
||||||
|
ui-sans-serif,
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
BlinkMacSystemFont,
|
||||||
|
'Segoe UI',
|
||||||
|
Roboto,
|
||||||
|
'Helvetica Neue',
|
||||||
|
Arial,
|
||||||
|
sans-serif;
|
||||||
|
|
||||||
|
/* 等宽字体用于代码/数据 */
|
||||||
|
font-family:
|
||||||
|
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 字号层级
|
||||||
|
|
||||||
|
| 层级 | 类名 | 大小 | 字重 | 用途 |
|
||||||
|
| --------- | ------------------------------------------------ | ---- | ------- | ---------------------- |
|
||||||
|
| 页面标题 | `text-base font-bold` | 16px | 700 | 页面主标题(极少使用) |
|
||||||
|
| 卡片标题 | `text-sm font-bold tracking-tight` | 14px | 700 | 卡片/区块标题 |
|
||||||
|
| 正文 | `text-sm` | 14px | 400 | 普通正文 |
|
||||||
|
| 次级文字 | `text-xs` | 12px | 400/500 | 描述、标签 |
|
||||||
|
| 微标签 | `text-[10px] font-bold uppercase tracking-wider` | 10px | 700 | 区域标签、分类标题 |
|
||||||
|
| 数据/代码 | `font-mono text-sm` | 14px | 400 | 时间戳、JSON、代码 |
|
||||||
|
|
||||||
|
### 3.3 排版模式
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 区域标签(Section Label)— 最常用
|
||||||
|
<span className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider">
|
||||||
|
输出结果
|
||||||
|
</span>
|
||||||
|
|
||||||
|
// 卡片标题
|
||||||
|
<h4 className="font-bold text-sm tracking-tight text-foreground leading-snug">
|
||||||
|
标题文字
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
// 描述文字
|
||||||
|
<p className="text-[11px] font-medium text-muted-foreground/90 leading-normal">
|
||||||
|
描述内容
|
||||||
|
</p>
|
||||||
|
|
||||||
|
// 数据展示
|
||||||
|
<span className="font-mono font-bold text-foreground text-sm tracking-tight tabular-nums">
|
||||||
|
1716950400000
|
||||||
|
</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 行高与字间距
|
||||||
|
|
||||||
|
| 属性 | 值 | 场景 |
|
||||||
|
| ----------------- | -------- | ------------------ |
|
||||||
|
| `leading-none` | 1 | 单行数据、紧凑布局 |
|
||||||
|
| `leading-snug` | 1.375 | 标题、短文本 |
|
||||||
|
| `leading-relaxed` | 1.625 | 长文本、代码块 |
|
||||||
|
| `tracking-tight` | -0.025em | 标题、数据 |
|
||||||
|
| `tracking-wider` | 0.05em | 大写标签 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 间距与布局
|
||||||
|
|
||||||
|
### 4.1 容器尺寸
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Popup 模式(默认)
|
||||||
|
<div className="w-[400px] max-w-[400px] min-w-[400px] h-[600px] min-h-[600px]">
|
||||||
|
|
||||||
|
// Tab 模式(全屏自适应)
|
||||||
|
<div className="sm:w-screen sm:max-w-none sm:min-w-0 sm:h-screen sm:min-h-0">
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 间距节奏
|
||||||
|
|
||||||
|
| Token | 值 | 使用场景 |
|
||||||
|
| --------------- | ----------- | ------------------ |
|
||||||
|
| `p-3` / `p-3.5` | 12px / 14px | 页面内边距(紧凑) |
|
||||||
|
| `p-4` | 16px | 标准页面内边距 |
|
||||||
|
| `p-5` | 20px | 卡片内部填充 |
|
||||||
|
| `gap-2` | 8px | 紧凑元素间距 |
|
||||||
|
| `gap-3` | 12px | 标准元素间距 |
|
||||||
|
| `gap-4` | 16px | 区块间距 |
|
||||||
|
| `gap-6` | 24px | 大区块间距 |
|
||||||
|
|
||||||
|
### 4.3 布局模式
|
||||||
|
|
||||||
|
#### 页面布局
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 标准页面结构
|
||||||
|
<div className="p-4 w-full flex flex-col space-y-4 min-h-[500px] select-none">{/* 页面内容 */}</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 卡片布局
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 标准卡片
|
||||||
|
<div className="p-5 rounded-xl border border-border bg-card text-card-foreground shadow-sm">
|
||||||
|
{/* 卡片内容 */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// 可聚焦卡片(含焦点环)
|
||||||
|
<div className="border border-border rounded-xl bg-card ... focus-within:ring-1 focus-within:ring-ring focus-within:border-ring">
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 双栏网格
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 响应式双栏
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-stretch">
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 工具卡片网格(Dashboard)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Dashboard 紧凑网格
|
||||||
|
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-2">
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 圆角与阴影
|
||||||
|
|
||||||
|
### 5.1 圆角体系
|
||||||
|
|
||||||
|
| Token | 值 | 使用元素 |
|
||||||
|
| -------------- | ------ | ---------------------- |
|
||||||
|
| `rounded-sm` | 2px | Checkbox、小标签 |
|
||||||
|
| `rounded-md` | 6px | 按钮、输入框、Select |
|
||||||
|
| `rounded-lg` | 8px | 搜索框、小卡片 |
|
||||||
|
| `rounded-xl` | 12px | 大卡片、面板、图标容器 |
|
||||||
|
| `rounded-full` | 9999px | 标签、Avatar |
|
||||||
|
|
||||||
|
### 5.2 阴影体系
|
||||||
|
|
||||||
|
| 级别 | 类名 | 用途 |
|
||||||
|
| ---- | --------------------- | ---------------------- |
|
||||||
|
| 无 | — | 静态元素 |
|
||||||
|
| 低 | `shadow-sm` | 卡片、输入框、按钮 |
|
||||||
|
| 中 | `shadow-lg` | 下拉菜单、浮层、Dialog |
|
||||||
|
| 动态 | 自定义 `shadow-[...]` | 卡片悬停时的彩色阴影 |
|
||||||
|
|
||||||
|
### 5.3 彩色阴影规范(工具卡片专用)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 工具卡片悬停阴影 — 必须使用 rgba 格式配合 CSS 变量
|
||||||
|
className="hover:shadow-[0_8px_24px_-8px_rgba(var(--tool-color),0.14)]
|
||||||
|
dark:hover:shadow-[0_8px_30px_-10px_rgba(var(--tool-color),0.25)]"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 组件规范
|
||||||
|
|
||||||
|
### 6.1 Button
|
||||||
|
|
||||||
|
来源:`src/components/ui/button.tsx`
|
||||||
|
|
||||||
|
#### 变体
|
||||||
|
|
||||||
|
| 变体 | 类名 | 场景 |
|
||||||
|
| ------------- | -------------------------------------------- | ------------------ |
|
||||||
|
| `default` | `bg-primary text-primary-foreground` | 主操作 |
|
||||||
|
| `destructive` | `bg-destructive text-destructive-foreground` | 删除、危险操作 |
|
||||||
|
| `outline` | `border border-input bg-background` | 次级操作、取消 |
|
||||||
|
| `secondary` | `bg-secondary text-secondary-foreground` | 次要操作 |
|
||||||
|
| `ghost` | 仅悬停背景 | 图标按钮、低优先级 |
|
||||||
|
| `link` | 下划线文字 | 跳转链接 |
|
||||||
|
|
||||||
|
#### 尺寸
|
||||||
|
|
||||||
|
| 尺寸 | 高度 | 内边距 | 场景 |
|
||||||
|
| --------- | ------- | ----------- | -------- |
|
||||||
|
| `default` | 40px | `px-4 py-2` | 标准按钮 |
|
||||||
|
| `sm` | 36px | `px-3` | 紧凑按钮 |
|
||||||
|
| `lg` | 44px | `px-8` | 突出按钮 |
|
||||||
|
| `icon` | 40×40px | — | 图标按钮 |
|
||||||
|
|
||||||
|
#### 使用示例
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 主操作
|
||||||
|
<Button>确认</Button>
|
||||||
|
|
||||||
|
// 图标按钮
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
// 危险操作
|
||||||
|
<Button variant="destructive" size="sm">删除</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 Input
|
||||||
|
|
||||||
|
来源:`src/components/ui/input.tsx`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 标准输入框
|
||||||
|
<Input
|
||||||
|
className="font-mono font-semibold h-10 shadow-sm placeholder:text-muted-foreground/60 focus:bg-background"
|
||||||
|
/>
|
||||||
|
|
||||||
|
// 错误状态
|
||||||
|
<Input
|
||||||
|
className="border-destructive focus-visible:ring-destructive"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
**规范要点**:
|
||||||
|
|
||||||
|
- 高度统一为 `h-10`(40px)
|
||||||
|
- 等宽字体用于数据输入
|
||||||
|
- 占位符使用 `text-muted-foreground/60`
|
||||||
|
- 错误时边框变红并调整焦点环
|
||||||
|
|
||||||
|
### 6.3 SwitchButtonGroup
|
||||||
|
|
||||||
|
来源:`src/components/ui/switch.tsx`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 分段控制器
|
||||||
|
<SwitchButtonGroup
|
||||||
|
value={mode}
|
||||||
|
options={[
|
||||||
|
{ value: 'ts2dt', label: '转日期' },
|
||||||
|
{ value: 'dt2ts', label: '转时间戳' },
|
||||||
|
]}
|
||||||
|
onChange={setMode}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
**规范要点**:
|
||||||
|
|
||||||
|
- 容器:`rounded-lg bg-muted p-1`
|
||||||
|
- 选中项:`bg-background text-foreground shadow-sm font-semibold`
|
||||||
|
- 未选中项:`hover:bg-background/50 hover:text-foreground/80`
|
||||||
|
- 尺寸:`small`(32px)用于工具页,`medium`(36px)标准
|
||||||
|
|
||||||
|
### 6.4 Card(工具卡片)
|
||||||
|
|
||||||
|
来源:`src/pages/Dashboard/ToolCard.tsx`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 标准工具卡片结构
|
||||||
|
<div className="group relative rounded-xl border border-border/70 bg-card p-4 h-auto flex flex-col gap-3 shadow-sm">
|
||||||
|
{/* 上半部分:图标 + 标题 + 箭头 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex gap-3 items-center">
|
||||||
|
{/* 图标容器 */}
|
||||||
|
<div className="flex items-center justify-center w-10 h-10 rounded-xl bg-[rgba(var(--tool-color),0.08)] text-[rgb(var(--tool-color))]">
|
||||||
|
<Icon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
{/* 文字 */}
|
||||||
|
<div>
|
||||||
|
<h4 className="font-bold text-sm">标题</h4>
|
||||||
|
<p className="text-[11px] text-muted-foreground/90">描述</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
{/* 下半部分:预览区(可选) */}
|
||||||
|
<div className="mt-1 pt-3 border-t border-dashed border-border/80">{snapshot}</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.5 TextInputArea
|
||||||
|
|
||||||
|
来源:`src/components/TextInputArea.tsx`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 多行文本输入区
|
||||||
|
<TextInputArea
|
||||||
|
value={input}
|
||||||
|
onChange={setInput}
|
||||||
|
placeholder="输入内容..."
|
||||||
|
showCount={true}
|
||||||
|
showClear={true}
|
||||||
|
allowCopy={true}
|
||||||
|
minRows={6}
|
||||||
|
maxRows={12}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
**规范要点**:
|
||||||
|
|
||||||
|
- 外容器:`rounded-md border border-input bg-background shadow-sm`
|
||||||
|
- 焦点状态:`focus-within:ring-1 focus-within:ring-ring`
|
||||||
|
- 错误状态:`border-destructive focus-within:ring-destructive`
|
||||||
|
- 底部工具栏:`h-10 bg-muted/30 border-t border-border/50`
|
||||||
|
- 字体:`font-mono text-sm`
|
||||||
|
|
||||||
|
### 6.6 Dialog
|
||||||
|
|
||||||
|
来源:`src/components/ui/dialog.tsx`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 对话框内容
|
||||||
|
<DialogContent className="sm:rounded-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>标题</DialogTitle>
|
||||||
|
<DialogDescription>描述文字</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{/* 内容 */}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline">取消</Button>
|
||||||
|
<Button>确认</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.7 Select
|
||||||
|
|
||||||
|
来源:`src/components/ui/select.tsx`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Select>
|
||||||
|
<SelectTrigger className="h-9 shadow-sm bg-background">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="max-h-64">
|
||||||
|
<SelectItem className="text-xs font-semibold focus:bg-accent cursor-pointer">选项</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.8 Checkbox
|
||||||
|
|
||||||
|
来源:`src/components/ui/checkbox.tsx`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 标准复选框
|
||||||
|
<Checkbox className="h-4 w-4 rounded-sm border-primary data-[state=checked]:bg-primary" />
|
||||||
|
|
||||||
|
// 小型复选框(工具栏内)
|
||||||
|
<Checkbox className="h-3.5 w-3.5 rounded border-input data-[state=checked]:bg-primary shadow-sm" />
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.9 Badge
|
||||||
|
|
||||||
|
来源:`src/components/ui/badge.tsx`
|
||||||
|
|
||||||
|
| 变体 | 场景 |
|
||||||
|
| ------------- | ------------------ |
|
||||||
|
| `default` | 状态标签、分类 |
|
||||||
|
| `secondary` | 次要标签 |
|
||||||
|
| `destructive` | 错误标签 |
|
||||||
|
| `outline` | 可点击标签、筛选器 |
|
||||||
|
|
||||||
|
### 6.10 CopyButton
|
||||||
|
|
||||||
|
来源:`src/components/CopyButton.tsx`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 标准复制按钮
|
||||||
|
<CopyButton text={content} />
|
||||||
|
|
||||||
|
// 小型复制按钮
|
||||||
|
<CopyButton text={content} size="sm" className="h-7 w-7 rounded-md border" />
|
||||||
|
```
|
||||||
|
|
||||||
|
**规范要点**:
|
||||||
|
|
||||||
|
- 默认 `variant="ghost" size="icon"`
|
||||||
|
- 复制成功后变为绿色背景 + 对勾图标
|
||||||
|
- 使用 `sonner` toast 提示复制结果
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 交互与动效
|
||||||
|
|
||||||
|
### 7.1 过渡规范
|
||||||
|
|
||||||
|
| 属性 | 值 | 场景 |
|
||||||
|
| ------------------- | ---------- | ----------------------------- |
|
||||||
|
| `transition-colors` | 150ms ease | 色彩变化(悬停、焦点) |
|
||||||
|
| `transition-all` | 150ms ease | 综合变化(SwitchButtonGroup) |
|
||||||
|
| `duration-200` | 200ms | 复制按钮状态切换 |
|
||||||
|
|
||||||
|
### 7.2 焦点状态
|
||||||
|
|
||||||
|
所有可交互元素必须有可见的焦点指示器:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 标准焦点环
|
||||||
|
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
|
||||||
|
|
||||||
|
// 紧凑焦点环(图标按钮)
|
||||||
|
focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring
|
||||||
|
|
||||||
|
// 输入框焦点
|
||||||
|
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 悬停状态
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 按钮悬停
|
||||||
|
hover:bg-accent hover:text-accent-foreground
|
||||||
|
|
||||||
|
// 卡片悬停
|
||||||
|
hover:bg-muted/30 hover:border-[rgba(var(--tool-color),0.45)]
|
||||||
|
|
||||||
|
// 链接/文字悬停
|
||||||
|
hover:text-foreground hover:underline
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.4 动画规范
|
||||||
|
|
||||||
|
使用 `tailwindcss-animate` 提供的动画:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 淡入
|
||||||
|
animate-in fade-in duration-150
|
||||||
|
|
||||||
|
// 淡入 + 缩放(SwitchButtonGroup 选中项)
|
||||||
|
animate-in fade-in-50 zoom-in-95 duration-150
|
||||||
|
|
||||||
|
// 从顶部滑入(下拉菜单)
|
||||||
|
animate-in fade-in slide-in-from-top-2 duration-150
|
||||||
|
|
||||||
|
// 错误提示出现
|
||||||
|
animate-in fade-in slide-in-from-top-1 duration-150
|
||||||
|
|
||||||
|
// 骨架屏脉冲
|
||||||
|
animate-pulse
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.5 禁用状态
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 统一禁用样式
|
||||||
|
disabled:pointer-events-none disabled:opacity-50
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 暗色模式
|
||||||
|
|
||||||
|
### 8.1 实现方式
|
||||||
|
|
||||||
|
通过 `darkMode: 'class'`(Tailwind 配置)+ `.dark` 类切换:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ThemeModeProvider 自动处理
|
||||||
|
document.documentElement.classList.toggle('dark', resolvedMode === 'dark');
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 暗色模式下的特殊处理
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 彩色阴影增强(暗色模式下阴影需要更高透明度)
|
||||||
|
shadow-[0_8px_24px_-8px_rgba(var(--tool-color),0.14)]
|
||||||
|
dark:shadow-[0_8px_30px_-10px_rgba(var(--tool-color),0.25)]
|
||||||
|
|
||||||
|
// 图标容器背景增强
|
||||||
|
bg-[rgba(var(--tool-color),0.08)]
|
||||||
|
dark:bg-[rgba(var(--tool-color),0.12)]
|
||||||
|
|
||||||
|
// 成功状态文字调整
|
||||||
|
text-emerald-600 dark:text-emerald-400
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 暗色模式色彩映射原则
|
||||||
|
|
||||||
|
| 亮色 | 暗色 | 说明 |
|
||||||
|
| ---------------- | ---------------- | ------------------------------- |
|
||||||
|
| 纯白背景 | 深蓝黑背景 | 避免纯黑 `#000`,使用 `#020617` |
|
||||||
|
| 浅灰背景 | 深灰背景 | 保持层次关系 |
|
||||||
|
| 深文字 | 浅文字 | 反转对比度 |
|
||||||
|
| 彩色阴影低透明度 | 彩色阴影高透明度 | 暗色需要更强视觉反馈 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 工具色彩标识
|
||||||
|
|
||||||
|
### 9.1 色板定义
|
||||||
|
|
||||||
|
每个工具分配一个主题色,定义于 `src/config/features.tsx`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const PALETTE_COLORS: Record<PaletteColorKey, string> = {
|
||||||
|
primary: '13, 148, 136', // teal (#0d9488)
|
||||||
|
success: '22, 163, 74', // green (#16a34a)
|
||||||
|
warning: '217, 119, 6', // amber (#d97706)
|
||||||
|
error: '220, 38, 38', // red (#dc2626)
|
||||||
|
secondary: '147, 51, 232', // purple (#9333e8)
|
||||||
|
info: '37, 99, 235', // blue (#2563eb)
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.2 工具色彩分配
|
||||||
|
|
||||||
|
| 工具 | 色彩键 | 色值 |
|
||||||
|
| ----------- | ----------- | ------ |
|
||||||
|
| 时间戳转换 | `primary` | Teal |
|
||||||
|
| 存储清理 | `warning` | Amber |
|
||||||
|
| 二维码工具 | `success` | Green |
|
||||||
|
| 文本统计 | `secondary` | Purple |
|
||||||
|
| JWT 解析 | `info` | Blue |
|
||||||
|
| JSON 对比 | `primary` | Teal |
|
||||||
|
| Base64 转换 | `info` | Blue |
|
||||||
|
| 右键还原 | `success` | Green |
|
||||||
|
|
||||||
|
### 9.3 工具色彩使用规范
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 1. 通过 style 注入 CSS 变量
|
||||||
|
<div style={{ ['--tool-color' as string]: rgbValues }}>
|
||||||
|
|
||||||
|
// 2. 图标容器背景(低透明度)
|
||||||
|
bg-[rgba(var(--tool-color),0.08)]
|
||||||
|
dark:bg-[rgba(var(--tool-color),0.12)]
|
||||||
|
|
||||||
|
// 3. 图标颜色
|
||||||
|
text-[rgb(var(--tool-color))]
|
||||||
|
|
||||||
|
// 4. 悬停边框
|
||||||
|
hover:border-[rgba(var(--tool-color),0.45)]
|
||||||
|
|
||||||
|
// 5. 悬停阴影
|
||||||
|
hover:shadow-[0_8px_24px_-8px_rgba(var(--tool-color),0.14)]
|
||||||
|
|
||||||
|
// 6. 箭头悬停色
|
||||||
|
group-hover:text-[rgb(var(--tool-color))]
|
||||||
|
```
|
||||||
|
|
||||||
|
**注意**:工具色彩仅用于**标识和装饰**,不得用于功能性色彩(如成功/错误状态)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 代码规范
|
||||||
|
|
||||||
|
### 10.1 Tailwind 类名组织顺序
|
||||||
|
|
||||||
|
使用 `cn()` 工具函数(`clsx` + `tailwind-merge`)组合类名,按以下顺序排列:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
className={cn(
|
||||||
|
// 1. 布局(display, position, flex, grid)
|
||||||
|
'flex items-center justify-between',
|
||||||
|
// 2. 尺寸(width, height, padding, margin)
|
||||||
|
'w-full h-10 px-4',
|
||||||
|
// 3. 外观(background, border, shadow, rounded)
|
||||||
|
'rounded-md border border-input bg-background shadow-sm',
|
||||||
|
// 4. 文字(color, font, text-align)
|
||||||
|
'text-sm font-medium text-foreground',
|
||||||
|
// 5. 交互(hover, focus, disabled, cursor)
|
||||||
|
'hover:bg-accent focus-visible:ring-2 disabled:opacity-50',
|
||||||
|
// 6. 动画(transition, animate)
|
||||||
|
'transition-colors duration-150',
|
||||||
|
// 7. 条件类
|
||||||
|
isActive && 'bg-primary text-primary-foreground',
|
||||||
|
// 8. 外部传入
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.2 颜色使用检查清单
|
||||||
|
|
||||||
|
- [ ] 所有颜色使用 CSS 变量(`bg-background` 而非 `bg-white`)
|
||||||
|
- [ ] 边框使用 `border-border` 及其透明度变体
|
||||||
|
- [ ] 文字层级使用 `foreground` → `muted-foreground` → `muted-foreground/60`
|
||||||
|
- [ ] 错误状态使用 `destructive` 系列
|
||||||
|
- [ ] 工具色彩仅用于装饰性元素
|
||||||
|
|
||||||
|
### 10.3 组件文件组织
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── components/ui/ # shadcn 基础组件(只读,不修改)
|
||||||
|
├── components/ # 业务组件
|
||||||
|
│ ├── CopyButton.tsx
|
||||||
|
│ ├── SwitchButtonGroup.tsx
|
||||||
|
│ ├── TextInputArea.tsx
|
||||||
|
│ └── ...
|
||||||
|
├── pages/ # 页面组件
|
||||||
|
│ ├── <ToolName>/
|
||||||
|
│ │ ├── index.tsx # 页面入口
|
||||||
|
│ │ ├── use<ToolName>.ts # 业务逻辑 Hook
|
||||||
|
│ │ └── components/ # 页面私有组件
|
||||||
|
│ └── ...
|
||||||
|
└── providers/ # Context Providers
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.4 新增页面模板
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// src/pages/NewTool/index.tsx
|
||||||
|
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
||||||
|
import { useI18n } from '@/utils/chromeI18n';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export default function Index() {
|
||||||
|
const { t } = useI18n('newTool');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 w-full flex flex-col space-y-4 min-h-[500px] select-none">
|
||||||
|
{/* 页面内容 */}
|
||||||
|
<div className="p-5 rounded-xl border border-border bg-card text-card-foreground shadow-sm">
|
||||||
|
<h4 className="font-bold text-sm tracking-tight">{t('newTool:title')}</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 反模式清单
|
||||||
|
|
||||||
|
以下模式**禁止**在项目中使用:
|
||||||
|
|
||||||
|
### 11.1 色彩反模式
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ❌ 硬编码颜色
|
||||||
|
<div className="bg-white text-black">
|
||||||
|
<div className="bg-gray-100">
|
||||||
|
<div className="text-gray-500">
|
||||||
|
|
||||||
|
// ❌ 使用非语义化 Tailwind 颜色
|
||||||
|
<div className="bg-slate-50">
|
||||||
|
<div className="text-zinc-400">
|
||||||
|
|
||||||
|
// ✅ 使用 CSS 变量
|
||||||
|
<div className="bg-background text-foreground">
|
||||||
|
<div className="bg-muted text-muted-foreground">
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.2 布局反模式
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ❌ 固定高度导致内容截断
|
||||||
|
<div className="h-[200px]">
|
||||||
|
|
||||||
|
// ✅ 使用 min-height 或自适应
|
||||||
|
<div className="min-h-[200px]">
|
||||||
|
<div className="h-auto">
|
||||||
|
|
||||||
|
// ❌ 使用 margin 做组件间距
|
||||||
|
<div className="mb-4">
|
||||||
|
|
||||||
|
// ✅ 使用 gap
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.3 组件反模式
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ❌ 修改 shadcn/ui 基础组件样式
|
||||||
|
// 如需修改,通过 className 覆盖或创建包装组件
|
||||||
|
|
||||||
|
// ❌ 内联样式用于颜色(工具色彩除外)
|
||||||
|
<div style={{ backgroundColor: '#f1f5f9' }}>
|
||||||
|
|
||||||
|
// ❌ 混合使用不同圆角体系
|
||||||
|
<Button className="rounded-lg"> // Button 应为 rounded-md
|
||||||
|
|
||||||
|
// ❌ 忽略焦点状态
|
||||||
|
<button className="..."> // 缺少 focus-visible 样式
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.4 暗色模式反模式
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ❌ 仅适配部分元素
|
||||||
|
<div className="bg-white text-black dark:bg-gray-900 dark:text-white">
|
||||||
|
|
||||||
|
// ✅ 使用 CSS 变量自动适配
|
||||||
|
<div className="bg-background text-foreground">
|
||||||
|
|
||||||
|
// ❌ 暗色模式下使用不合适的透明度
|
||||||
|
<div className="bg-black/5 dark:bg-white/5"> // 对比度不足
|
||||||
|
|
||||||
|
// ✅ 使用语义化变量
|
||||||
|
<div className="bg-muted">
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 附录
|
||||||
|
|
||||||
|
### 12.1 常用类名速查
|
||||||
|
|
||||||
|
```
|
||||||
|
// 页面容器
|
||||||
|
p-4 w-full flex flex-col space-y-4 min-h-[500px] select-none
|
||||||
|
|
||||||
|
// 标准卡片
|
||||||
|
p-5 rounded-xl border border-border bg-card text-card-foreground shadow-sm
|
||||||
|
|
||||||
|
// 工具栏
|
||||||
|
flex h-10 items-center justify-between px-1.5 bg-secondary/40 rounded-xl border border-border/60
|
||||||
|
|
||||||
|
// 区域标签
|
||||||
|
text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider
|
||||||
|
|
||||||
|
// 数据展示
|
||||||
|
font-mono font-semibold text-foreground text-sm
|
||||||
|
|
||||||
|
// 错误提示
|
||||||
|
text-xs font-medium text-destructive
|
||||||
|
|
||||||
|
// 空状态
|
||||||
|
p-8 rounded-xl bg-muted/30 border border-dashed border-border/80 text-center
|
||||||
|
|
||||||
|
// 图标按钮容器
|
||||||
|
flex h-8 w-8 items-center justify-center rounded-md border border-input bg-background shadow-sm
|
||||||
|
|
||||||
|
// 焦点环
|
||||||
|
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
|
||||||
|
```
|
||||||
|
|
||||||
|
### 12.2 相关文件
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
| ------------------------------------- | ----------------------- |
|
||||||
|
| `src/index.css` | CSS 变量定义、全局样式 |
|
||||||
|
| `tailwind.config.js` | Tailwind 配置、色彩映射 |
|
||||||
|
| `src/lib/utils.ts` | `cn()` 工具函数 |
|
||||||
|
| `src/components/ui/*.tsx` | shadcn/ui 基础组件 |
|
||||||
|
| `src/config/features.tsx` | 工具配置、色彩分配 |
|
||||||
|
| `src/providers/ThemeModeProvider.tsx` | 主题模式管理 |
|
||||||
|
|
||||||
|
### 12.3 参考资源
|
||||||
|
|
||||||
|
- [shadcn/ui 文档](https://ui.shadcn.com/docs)
|
||||||
|
- [Tailwind CSS 文档](https://tailwindcss.com/docs)
|
||||||
|
- [Radix UI 文档](https://www.radix-ui.com/)
|
||||||
|
- [Lucide Icons](https://lucide.dev/)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_本文档随项目迭代更新。新增组件或修改视觉风格时,请同步更新此文档。_
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
# 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` 中注册新命名空间
|
|
||||||
@@ -1,4 +1,12 @@
|
|||||||
|
/* global process */
|
||||||
|
|
||||||
|
const isCI = process.env.CI === 'true';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
'*.{ts,tsx,js,jsx,mjs}': ['eslint --fix --max-warnings=0 --no-warn-ignored', 'prettier --write'],
|
'*.{ts,tsx,js,jsx,mjs}': [
|
||||||
|
'eslint --fix --no-warn-ignored',
|
||||||
|
...(isCI ? ['eslint --max-warnings=0'] : []),
|
||||||
|
'prettier --write',
|
||||||
|
],
|
||||||
'*.{json,css,scss,md}': ['prettier --write'],
|
'*.{json,css,scss,md}': ['prettier --write'],
|
||||||
};
|
};
|
||||||
|
|||||||
Generated
+53
-143
File diff suppressed because it is too large
Load Diff
@@ -576,6 +576,50 @@
|
|||||||
"message": "点击更换图片",
|
"message": "点击更换图片",
|
||||||
"description": "Translation key: qrCode_clickToChange"
|
"description": "Translation key: qrCode_clickToChange"
|
||||||
},
|
},
|
||||||
|
"qrCode_generateButton": {
|
||||||
|
"message": "生成二维码",
|
||||||
|
"description": "Translation key: qrCode_generateButton"
|
||||||
|
},
|
||||||
|
"qrCode_editButton": {
|
||||||
|
"message": "编辑",
|
||||||
|
"description": "Translation key: qrCode_editButton"
|
||||||
|
},
|
||||||
|
"qrCode_textPreviewLabel": {
|
||||||
|
"message": "原始文本",
|
||||||
|
"description": "Translation key: qrCode_textPreviewLabel"
|
||||||
|
},
|
||||||
|
"qrCode_generateFirstHint": {
|
||||||
|
"message": "输入文本后点击「生成二维码」按钮",
|
||||||
|
"description": "Translation key: qrCode_generateFirstHint"
|
||||||
|
},
|
||||||
|
"qrCode_inputRequired": {
|
||||||
|
"message": "请输入内容",
|
||||||
|
"description": "Translation key: qrCode_inputRequired"
|
||||||
|
},
|
||||||
|
"qrCode_generateError": {
|
||||||
|
"message": "生成二维码失败,请重试",
|
||||||
|
"description": "Translation key: qrCode_generateError"
|
||||||
|
},
|
||||||
|
"qrCode_uploadedImage": {
|
||||||
|
"message": "已上传图片",
|
||||||
|
"description": "Translation key: qrCode_uploadedImage"
|
||||||
|
},
|
||||||
|
"qrCode_reuploadButton": {
|
||||||
|
"message": "重新上传",
|
||||||
|
"description": "Translation key: qrCode_reuploadButton"
|
||||||
|
},
|
||||||
|
"qrCode_parsing": {
|
||||||
|
"message": "解析中...",
|
||||||
|
"description": "Translation key: qrCode_parsing"
|
||||||
|
},
|
||||||
|
"qrCode_resultPlaceholder": {
|
||||||
|
"message": "解析结果将显示在此处",
|
||||||
|
"description": "Translation key: qrCode_resultPlaceholder"
|
||||||
|
},
|
||||||
|
"qrCode_imageToQr": {
|
||||||
|
"message": "解析图片二维码",
|
||||||
|
"description": "Translation key: qrCode_imageToQr"
|
||||||
|
},
|
||||||
"rightClickRestorer_loading": {
|
"rightClickRestorer_loading": {
|
||||||
"message": "正在加载...",
|
"message": "正在加载...",
|
||||||
"description": "Translation key: rightClickRestorer_loading"
|
"description": "Translation key: rightClickRestorer_loading"
|
||||||
@@ -697,7 +741,7 @@
|
|||||||
"description": "Translation key: storageCleaner_options_sessionStorage"
|
"description": "Translation key: storageCleaner_options_sessionStorage"
|
||||||
},
|
},
|
||||||
"storageCleaner_options_indexedDB": {
|
"storageCleaner_options_indexedDB": {
|
||||||
"message": "IndexedDB",
|
"message": "站点存储",
|
||||||
"description": "Translation key: storageCleaner_options_indexedDB"
|
"description": "Translation key: storageCleaner_options_indexedDB"
|
||||||
},
|
},
|
||||||
"storageCleaner_options_cookies": {
|
"storageCleaner_options_cookies": {
|
||||||
@@ -760,6 +804,10 @@
|
|||||||
"message": "毫秒 (ms)",
|
"message": "毫秒 (ms)",
|
||||||
"description": "Translation key: timestamp_unitMs"
|
"description": "Translation key: timestamp_unitMs"
|
||||||
},
|
},
|
||||||
|
"timestamp_unitS": {
|
||||||
|
"message": "秒 (s)",
|
||||||
|
"description": "Translation key: timestamp_unitS"
|
||||||
|
},
|
||||||
"timestamp_currentTs": {
|
"timestamp_currentTs": {
|
||||||
"message": "当前时间戳",
|
"message": "当前时间戳",
|
||||||
"description": "Translation key: timestamp_currentTs"
|
"description": "Translation key: timestamp_currentTs"
|
||||||
@@ -1,372 +0,0 @@
|
|||||||
/**
|
|
||||||
* GlobalSnackbar - 全局 Snackbar 消息提示组件及 Provider
|
|
||||||
*
|
|
||||||
* 提供可复用的 Toast 消息提示功能,支持三种使用方式:
|
|
||||||
* 1. 作为受控组件使用:通过 props 控制显示状态
|
|
||||||
* 2. 通过 useSnackbarState Hook 使用:在组件内部自动管理状态
|
|
||||||
* 3. 通过 SnackbarProvider 和 useSnackbar Hook 使用:全局单例模式
|
|
||||||
*
|
|
||||||
* NOTE: 项目同时使用 sonner 的 toast 进行简单的一次性提示。
|
|
||||||
* 本组件适用于需要 severity 级别、Provider 上下文、自定义定位等高级场景。
|
|
||||||
* 简单场景(如复制成功、操作提示)优先使用 `import { toast } from 'sonner'`。
|
|
||||||
*
|
|
||||||
* @module GlobalSnackbar
|
|
||||||
* @version 1.1.0
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* // 方式一:受控组件
|
|
||||||
* <GlobalSnackbar
|
|
||||||
* message="操作成功"
|
|
||||||
* open={isOpen}
|
|
||||||
* onClose={() => setIsOpen(false)}
|
|
||||||
* severity="success"
|
|
||||||
* />
|
|
||||||
*
|
|
||||||
* // 方式二:Hook 方式 (局部状态)
|
|
||||||
* const { snackbarProps, showMessage } = useSnackbarState();
|
|
||||||
* showMessage('Hello!', { severity: 'info' });
|
|
||||||
*
|
|
||||||
* // 方式三:Context 方式 (全局状态)
|
|
||||||
* // 在根组件包裹 Provider
|
|
||||||
* <SnackbarProvider>
|
|
||||||
* <App />
|
|
||||||
* </SnackbarProvider>
|
|
||||||
*
|
|
||||||
* // 在子组件中使用
|
|
||||||
* const { showMessage } = useSnackbar();
|
|
||||||
* showMessage('Global Message');
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
JSX,
|
|
||||||
useState,
|
|
||||||
useRef,
|
|
||||||
createContext,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
type ReactNode,
|
|
||||||
type SyntheticEvent,
|
|
||||||
} from 'react';
|
|
||||||
import { CheckCircle, Info, AlertTriangle, XCircle } from 'lucide-react';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Snackbar 消息严重程度类型
|
|
||||||
* @description 决定 Alert 组件的颜色和图标
|
|
||||||
* - success: 绿色,成功提示
|
|
||||||
* - info: 蓝色,信息提示
|
|
||||||
* - warning: 橙色,警告提示
|
|
||||||
* - error: 红色,错误提示
|
|
||||||
*/
|
|
||||||
export type SnackbarSeverity = 'success' | 'info' | 'warning' | 'error';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GlobalSnackbar 组件的属性接口
|
|
||||||
* @interface GlobalSnackbarProps
|
|
||||||
*/
|
|
||||||
export interface GlobalSnackbarProps {
|
|
||||||
/** 消息内容,要显示的提示文本 */
|
|
||||||
message: string;
|
|
||||||
/** 是否显示 Snackbar */
|
|
||||||
open: boolean;
|
|
||||||
/** 关闭回调函数 */
|
|
||||||
onClose: () => void;
|
|
||||||
/** 消息级别,影响颜色和图标样式,默认 'info' */
|
|
||||||
severity?: SnackbarSeverity;
|
|
||||||
/** 自动隐藏时间(毫秒),设为 0 则不自动关闭,默认 2000 */
|
|
||||||
autoHideDuration?: number;
|
|
||||||
/** Snackbar 弹出位置,默认 { vertical: 'bottom', horizontal: 'center' } */
|
|
||||||
anchorOrigin?: {
|
|
||||||
vertical: 'top' | 'bottom';
|
|
||||||
horizontal: 'left' | 'center' | 'right';
|
|
||||||
};
|
|
||||||
/** 是否使用 Alert 组件包裹,false 则使用原生 Snackbar message,默认 true */
|
|
||||||
showAlert?: boolean;
|
|
||||||
/** 是否隐藏 Alert 图标,默认 false */
|
|
||||||
hideIcon?: boolean;
|
|
||||||
/** 自定义样式,透传给外层 Snackbar 组件 */
|
|
||||||
sx?: React.CSSProperties;
|
|
||||||
/** 自定义样式,透传给内层 Alert 组件(仅 showAlert=true 时生效) */
|
|
||||||
alertSx?: React.CSSProperties;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* showMessage 方法的选项配置
|
|
||||||
* @interface SnackbarOptions
|
|
||||||
*/
|
|
||||||
export interface SnackbarOptions {
|
|
||||||
/** 消息级别:success | info | warning | error */
|
|
||||||
severity?: SnackbarSeverity;
|
|
||||||
/** 自动隐藏时间(毫秒),设为 0 则不自动关闭 */
|
|
||||||
autoHideDuration?: number;
|
|
||||||
/** 是否隐藏 Alert 图标 */
|
|
||||||
hideIcon?: boolean;
|
|
||||||
/** 是否使用 Alert 组件包裹 */
|
|
||||||
showAlert?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* useSnackbarState Hook 的返回值类型
|
|
||||||
* @interface UseSnackbarStateResult
|
|
||||||
*/
|
|
||||||
export interface UseSnackbarStateResult {
|
|
||||||
/** 传递给 GlobalSnackbar 组件的属性对象 */
|
|
||||||
snackbarProps: GlobalSnackbarProps;
|
|
||||||
/** 显示消息的方法 */
|
|
||||||
showMessage: (message: string, options?: SnackbarOptions) => void;
|
|
||||||
/** 关闭消息的方法 */
|
|
||||||
closeMessage: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GlobalSnackbar 组件的默认属性配置
|
|
||||||
* @description 提供类型安全的默认值选择
|
|
||||||
*/
|
|
||||||
const defaultProps: Required<
|
|
||||||
Pick<
|
|
||||||
GlobalSnackbarProps,
|
|
||||||
'severity' | 'autoHideDuration' | 'anchorOrigin' | 'showAlert' | 'hideIcon'
|
|
||||||
>
|
|
||||||
> = {
|
|
||||||
severity: 'info',
|
|
||||||
autoHideDuration: 2000,
|
|
||||||
anchorOrigin: { vertical: 'bottom', horizontal: 'center' },
|
|
||||||
showAlert: true,
|
|
||||||
hideIcon: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const severityConfig: Record<
|
|
||||||
SnackbarSeverity,
|
|
||||||
{ icon: React.ElementType; bgClass: string; textClass: string }
|
|
||||||
> = {
|
|
||||||
success: { icon: CheckCircle, bgClass: 'bg-green-500', textClass: 'text-white' },
|
|
||||||
info: { icon: Info, bgClass: 'bg-primary/100', textClass: 'text-white' },
|
|
||||||
warning: { icon: AlertTriangle, bgClass: 'bg-amber-500', textClass: 'text-white' },
|
|
||||||
error: { icon: XCircle, bgClass: 'bg-red-500', textClass: 'text-white' },
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GlobalSnackbar 组件
|
|
||||||
*
|
|
||||||
* 全局消息提示的展示组件,支持受控和非受控两种使用模式。
|
|
||||||
*
|
|
||||||
* @param {GlobalSnackbarProps} props - 组件属性
|
|
||||||
* @returns {JSX.Element}
|
|
||||||
*/
|
|
||||||
export function GlobalSnackbar({
|
|
||||||
message,
|
|
||||||
open,
|
|
||||||
onClose,
|
|
||||||
severity = defaultProps.severity,
|
|
||||||
autoHideDuration = defaultProps.autoHideDuration,
|
|
||||||
showAlert = defaultProps.showAlert,
|
|
||||||
hideIcon = defaultProps.hideIcon,
|
|
||||||
}: GlobalSnackbarProps): JSX.Element | null {
|
|
||||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open && autoHideDuration > 0) {
|
|
||||||
timerRef.current = setTimeout(() => {
|
|
||||||
onClose();
|
|
||||||
}, autoHideDuration);
|
|
||||||
return () => {
|
|
||||||
if (timerRef.current) clearTimeout(timerRef.current);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}, [open, autoHideDuration, onClose]);
|
|
||||||
|
|
||||||
if (!open) return null;
|
|
||||||
|
|
||||||
const config = severityConfig[severity];
|
|
||||||
const IconComponent = config.icon;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed z-[999999] bottom-6 left-1/2 -translate-x-1/2 animate-in fade-in slide-in-from-bottom-2 duration-300">
|
|
||||||
{showAlert ? (
|
|
||||||
<div
|
|
||||||
className={`flex items-center gap-2 px-5 py-1.5 rounded-full shadow-lg ${config.bgClass} ${config.textClass}`}
|
|
||||||
style={{ minWidth: '140px' }}
|
|
||||||
>
|
|
||||||
{!hideIcon && <IconComponent className="h-4 w-4 flex-shrink-0" />}
|
|
||||||
<span className="text-xs font-bold">{message}</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="px-4 py-2 rounded-lg bg-gray-800 text-white text-sm">{message}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* useSnackbarState - 消息提示的 Hook 方式
|
|
||||||
*
|
|
||||||
* 提供状态管理的 Snackbar 功能,自动处理 open、message 等状态。
|
|
||||||
* 适合在组件内部使用,无需额外的状态管理代码。
|
|
||||||
*
|
|
||||||
* @param {SnackbarOptions} [initialOptions] - 初始配置选项
|
|
||||||
* @returns {UseSnackbarStateResult} 包含 snackbarProps 和操作方法的对象
|
|
||||||
*
|
|
||||||
* @description
|
|
||||||
* - 自动管理 Snackbar 的显示/隐藏状态
|
|
||||||
* - 支持链式调用 showMessage
|
|
||||||
* - 合并初始选项和调用时选项
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* function MyComponent() {
|
|
||||||
* const { snackbarProps, showMessage, closeMessage } = useSnackbarState({
|
|
||||||
* severity: 'info',
|
|
||||||
* autoHideDuration: 3000,
|
|
||||||
* });
|
|
||||||
*
|
|
||||||
* const handleSave = () => {
|
|
||||||
* // 业务逻辑...
|
|
||||||
* showMessage('保存成功!', { severity: 'success' });
|
|
||||||
* };
|
|
||||||
*
|
|
||||||
* return (
|
|
||||||
* <>
|
|
||||||
* <button onClick={handleSave}>保存</button>
|
|
||||||
* <GlobalSnackbar {...snackbarProps} />
|
|
||||||
* </>
|
|
||||||
* );
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function useSnackbarState(initialOptions?: SnackbarOptions): UseSnackbarStateResult {
|
|
||||||
// Snackbar 显示状态
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
// 当前显示的消息内容
|
|
||||||
const [message, setMessage] = useState('');
|
|
||||||
// 消息配置选项
|
|
||||||
const [options, setOptions] = useState<SnackbarOptions>(initialOptions || {});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 显示消息
|
|
||||||
*
|
|
||||||
* @param {string} newMessage - 要显示的消息文本
|
|
||||||
* @param {SnackbarOptions} [newOptions={}] - 新的配置选项
|
|
||||||
*
|
|
||||||
* @description
|
|
||||||
* - 合并初始选项和新的调用选项
|
|
||||||
* - 新选项会覆盖初始选项
|
|
||||||
*/
|
|
||||||
const showMessage = (newMessage: string, newOptions: SnackbarOptions = {}) => {
|
|
||||||
setMessage(newMessage);
|
|
||||||
setOptions({ ...initialOptions, ...newOptions });
|
|
||||||
setOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 关闭消息
|
|
||||||
*
|
|
||||||
* @description
|
|
||||||
* - 直接将 open 状态设置为 false
|
|
||||||
*/
|
|
||||||
const closeMessage = () => {
|
|
||||||
setOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = (_event?: SyntheticEvent | Event, reason?: string) => {
|
|
||||||
if (reason === 'clickaway') return;
|
|
||||||
closeMessage();
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 传递给 GlobalSnackbar 组件的属性
|
|
||||||
*
|
|
||||||
* @description
|
|
||||||
* - 组合当前状态和选项为完整的组件 props
|
|
||||||
* - onClose 使用 handleClose 包装后的版本
|
|
||||||
*/
|
|
||||||
const snackbarProps: GlobalSnackbarProps = {
|
|
||||||
message,
|
|
||||||
open,
|
|
||||||
onClose: handleClose,
|
|
||||||
severity: options.severity,
|
|
||||||
autoHideDuration: options.autoHideDuration,
|
|
||||||
hideIcon: options.hideIcon,
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
snackbarProps,
|
|
||||||
showMessage,
|
|
||||||
closeMessage,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Context & Provider ---
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Snackbar Context 的值类型定义
|
|
||||||
*/
|
|
||||||
interface SnackbarContextValue {
|
|
||||||
showMessage: (message: string, options?: SnackbarOptions) => void;
|
|
||||||
closeMessage: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SnackbarContext = createContext<SnackbarContextValue | null>(null);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SnackbarProvider 组件的 props 类型
|
|
||||||
*/
|
|
||||||
interface SnackbarProviderProps {
|
|
||||||
children: ReactNode;
|
|
||||||
initialOptions?: SnackbarOptions;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SnackbarProvider 组件
|
|
||||||
*
|
|
||||||
* 全局消息提示的 Provider 组件,需要包裹在应用根组件外层。
|
|
||||||
*/
|
|
||||||
export function SnackbarProvider({ children, initialOptions }: SnackbarProviderProps): JSX.Element {
|
|
||||||
const { snackbarProps, showMessage, closeMessage } = useSnackbarState(initialOptions);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SnackbarContext.Provider value={{ showMessage, closeMessage }}>
|
|
||||||
{children}
|
|
||||||
<GlobalSnackbar {...snackbarProps} />
|
|
||||||
</SnackbarContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* useSnackbar - 在子组件中获取 Snackbar 上下文的 Hook
|
|
||||||
*
|
|
||||||
* @param {SnackbarOptions} [options] - 钩子级别的默认配置(如 autoHideDuration)
|
|
||||||
* @returns {SnackbarContextValue} - 包含 showMessage 和 closeMessage 的对象
|
|
||||||
* @throws {Error} - 如果不在 SnackbarProvider 内部调用,抛出错误
|
|
||||||
*
|
|
||||||
* @description
|
|
||||||
* 选项合并策略:
|
|
||||||
* 1. 调用 showMessage 时传入的 callOptions 优先级最高
|
|
||||||
* 2. useSnackbar(options) 传入的 Hook 级别配置次之
|
|
||||||
* 3. SnackbarProvider(initialOptions) 传入的全局配置优先级最低
|
|
||||||
*/
|
|
||||||
export function useSnackbar(options?: SnackbarOptions): SnackbarContextValue {
|
|
||||||
const context = useContext(SnackbarContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error('useSnackbar must be used within SnackbarProvider');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 包装 showMessage 以支持 Hook 级别的 initialOptions
|
|
||||||
const wrappedShowMessage = (message: string, callOptions?: SnackbarOptions) => {
|
|
||||||
// 采用防御性编程,确保 options 和 callOptions 为空时也能正常工作
|
|
||||||
// 优先级:callOptions > options
|
|
||||||
const mergedOptions: SnackbarOptions = {
|
|
||||||
...(options || {}),
|
|
||||||
...(callOptions || {}),
|
|
||||||
};
|
|
||||||
context.showMessage(message, mergedOptions);
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
...context,
|
|
||||||
showMessage: wrappedShowMessage,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default GlobalSnackbar;
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useRef } from 'react';
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
import { Image, X } from 'lucide-react';
|
import { Image, X } from 'lucide-react';
|
||||||
import { useSnackbar } from '@/components/GlobalSnackbar';
|
import { toast } from 'sonner';
|
||||||
import { useI18n } from '@/utils/chromeI18n';
|
import { useI18n } from '@/utils/chromeI18n';
|
||||||
|
|
||||||
interface ImageUploaderProps {
|
interface ImageUploaderProps {
|
||||||
@@ -30,7 +30,6 @@ const ImageUploader = ({
|
|||||||
onDraggingChange,
|
onDraggingChange,
|
||||||
}: ImageUploaderProps) => {
|
}: ImageUploaderProps) => {
|
||||||
const { t } = useI18n('qrCode');
|
const { t } = useI18n('qrCode');
|
||||||
const { showMessage } = useSnackbar();
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const handleFileChange = useCallback(
|
const handleFileChange = useCallback(
|
||||||
@@ -46,11 +45,8 @@ const ImageUploader = ({
|
|||||||
URL.revokeObjectURL(previewUrl);
|
URL.revokeObjectURL(previewUrl);
|
||||||
}
|
}
|
||||||
onClearFile();
|
onClearFile();
|
||||||
showMessage(t('qrCode:imageCleared'), {
|
toast.success(t('qrCode:imageCleared'));
|
||||||
severity: 'success',
|
}, [previewUrl, onClearFile, t]);
|
||||||
autoHideDuration: 1000,
|
|
||||||
});
|
|
||||||
}, [previewUrl, onClearFile, showMessage, t]);
|
|
||||||
|
|
||||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (e.target.files && e.target.files.length > 0) {
|
if (e.target.files && e.target.files.length > 0) {
|
||||||
@@ -90,13 +86,10 @@ const ImageUploader = ({
|
|||||||
if (file) {
|
if (file) {
|
||||||
try {
|
try {
|
||||||
handleFileChange(file);
|
handleFileChange(file);
|
||||||
showMessage(t('qrCode:imagePasted'), { severity: 'success', autoHideDuration: 1000 });
|
toast.success(t('qrCode:imagePasted'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('处理粘贴图片失败:', error);
|
console.error('处理粘贴图片失败:', error);
|
||||||
showMessage(t('qrCode:imagePasteError'), {
|
toast.error(t('qrCode:imagePasteError'));
|
||||||
severity: 'error',
|
|
||||||
autoHideDuration: 3000,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -109,7 +102,7 @@ const ImageUploader = ({
|
|||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('paste', handlePaste);
|
document.removeEventListener('paste', handlePaste);
|
||||||
};
|
};
|
||||||
}, [showMessage, handleFileChange, t]);
|
}, [handleFileChange, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -12,12 +12,15 @@ interface QrCodePreviewProps extends React.HTMLAttributes<HTMLDivElement> {
|
|||||||
onDownload: () => void;
|
onDownload: () => void;
|
||||||
/** 复制回调 */
|
/** 复制回调 */
|
||||||
onCopy: () => void;
|
onCopy: () => void;
|
||||||
|
/** 自定义占位文本,用于空状态提示 */
|
||||||
|
placeholderText?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const QrCodePreview = ({
|
const QrCodePreview = ({
|
||||||
qrCodeDataUrl,
|
qrCodeDataUrl,
|
||||||
onDownload,
|
onDownload,
|
||||||
onCopy,
|
onCopy,
|
||||||
|
placeholderText,
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: QrCodePreviewProps) => {
|
}: QrCodePreviewProps) => {
|
||||||
@@ -33,7 +36,9 @@ const QrCodePreview = ({
|
|||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<p className="text-sm text-muted-foreground text-center">{t('qrCode:qrCodeWillShow')}</p>
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
|
{placeholderText || t('qrCode:qrCodeWillShow')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -41,34 +46,34 @@ const QrCodePreview = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex flex-col justify-center items-center min-h-[200px] border border-input rounded-xl p-6 bg-muted/40',
|
'flex flex-col justify-center items-center border border-input rounded-xl p-4 bg-muted/40',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-center w-full max-w-xs">
|
<div className="flex flex-col items-center w-full max-w-xs">
|
||||||
{/*
|
{/*
|
||||||
2. 二维码容器适配:
|
二维码容器适配:
|
||||||
在暗黑模式下,纯黑白的二维码如果直接暴露在暗色背景下,会导致手机摄像头极难识别。
|
在暗黑模式下,纯黑白的二维码如果直接暴露在暗色背景下,会导致手机摄像头极难识别。
|
||||||
通过裹一层 bg-white 和 p-3,确保黑白对比度绝对安全,同时加入 shadow 增强卡片感。
|
通过裹一层 bg-white 和 p-2,确保黑白对比度绝对安全,同时加入 shadow 增强卡片感。
|
||||||
*/}
|
*/}
|
||||||
<div className="p-3 bg-white rounded-lg shadow-sm border border-border/40">
|
<div className="p-2 bg-white rounded-lg shadow-sm border border-border/40">
|
||||||
<img
|
<img
|
||||||
src={qrCodeDataUrl}
|
src={qrCodeDataUrl}
|
||||||
alt="QR Code Preview"
|
alt="QR Code Preview"
|
||||||
className="w-56 h-56 max-w-full object-contain block animate-in fade-in duration-300"
|
className="w-48 h-48 max-w-full object-contain block animate-in fade-in duration-300"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex w-full gap-2 mt-5">
|
<div className="flex w-full gap-2 mt-3">
|
||||||
<Button variant="outline" size="sm" onClick={onDownload} className="flex-1">
|
<Button variant="outline" size="sm" onClick={onDownload} className="flex-1 h-8">
|
||||||
<Download className="w-4 h-4 text-muted-foreground" />
|
<Download className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
<span className="truncate">{t('qrCode:downloadButton')}</span>
|
<span className="truncate text-xs">{t('qrCode:downloadButton')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button variant="default" size="sm" onClick={onCopy} className="flex-1">
|
<Button variant="default" size="sm" onClick={onCopy} className="flex-1 h-8">
|
||||||
<Copy className="w-4 h-4" />
|
<Copy className="w-3.5 h-3.5" />
|
||||||
<span className="truncate">{t('qrCode:copyQrButton')}</span>
|
<span className="truncate text-xs">{t('qrCode:copyQrButton')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,21 +1,20 @@
|
|||||||
import { FEATURES, getEntryPointType } from '@/config/features';
|
import { FEATURES, getEntryPointType } from '@/config/features';
|
||||||
import { useRouter } from '@/providers/RouterProvider';
|
import { useRouter } from '@/providers/RouterProvider';
|
||||||
import { Suspense, useMemo } from 'react';
|
import { Suspense } from 'react';
|
||||||
import { useI18n } from '@/utils/chromeI18n';
|
import { useI18n } from '@/utils/chromeI18n';
|
||||||
import PageErrorBoundary from '@/components/PageErrorBoundary';
|
import PageErrorBoundary from '@/components/PageErrorBoundary';
|
||||||
import PageSkeleton from '@/components/PageSkeleton';
|
import PageSkeleton from '@/components/PageSkeleton';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { AlertTriangle } from 'lucide-react';
|
import { AlertTriangle } from 'lucide-react';
|
||||||
|
|
||||||
|
const entryPointType = getEntryPointType();
|
||||||
|
|
||||||
export default function RouterContainer() {
|
export default function RouterContainer() {
|
||||||
const { currentPage, isLoaded } = useRouter();
|
const { currentPage, isLoaded } = useRouter();
|
||||||
const { t } = useI18n('common');
|
const { t } = useI18n('common');
|
||||||
|
|
||||||
const animationClass = useMemo(() => {
|
const animationClass =
|
||||||
return currentPage === 'dashboard' ? 'page-transition-dashboard' : 'page-transition-enter';
|
currentPage === 'dashboard' ? 'page-transition-dashboard' : 'page-transition-enter';
|
||||||
}, [currentPage]);
|
|
||||||
|
|
||||||
const entryPointType = getEntryPointType();
|
|
||||||
|
|
||||||
if (!isLoaded) {
|
if (!isLoaded) {
|
||||||
return <PageSkeleton variant={currentPage === 'dashboard' ? 'dashboard' : 'tool'} />;
|
return <PageSkeleton variant={currentPage === 'dashboard' ? 'dashboard' : 'tool'} />;
|
||||||
|
|||||||
@@ -1,117 +0,0 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { act, render, renderHook, screen } from '@testing-library/react';
|
|
||||||
import React from 'react';
|
|
||||||
import {
|
|
||||||
GlobalSnackbar,
|
|
||||||
type GlobalSnackbarProps,
|
|
||||||
SnackbarProvider,
|
|
||||||
useSnackbar,
|
|
||||||
useSnackbarState,
|
|
||||||
} from '@/components/GlobalSnackbar';
|
|
||||||
|
|
||||||
describe('GlobalSnackbar 组件系统', () => {
|
|
||||||
const mockOnClose = vi.fn();
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
const defaultProps: GlobalSnackbarProps = {
|
|
||||||
message: '测试消息',
|
|
||||||
open: true,
|
|
||||||
onClose: mockOnClose,
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('GlobalSnackbar UI 渲染', () => {
|
|
||||||
it('应渲染消息内容', () => {
|
|
||||||
render(<GlobalSnackbar {...defaultProps} />);
|
|
||||||
expect(screen.getByText('测试消息')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('当 showAlert 为 true 时应渲染带样式的提示', () => {
|
|
||||||
render(<GlobalSnackbar {...defaultProps} showAlert={true} />);
|
|
||||||
// 验证是否包含消息文本
|
|
||||||
const alertElement = screen.getByText('测试消息');
|
|
||||||
expect(alertElement).toBeInTheDocument();
|
|
||||||
// 验证父元素有正确的样式类
|
|
||||||
const parent = alertElement.parentElement;
|
|
||||||
expect(parent).toHaveClass('flex', 'items-center', 'gap-2');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('当 hideIcon 为 true 时不应渲染图标', () => {
|
|
||||||
render(<GlobalSnackbar {...defaultProps} hideIcon={true} />);
|
|
||||||
// 图标使用 lucide-react 的 svg 元素
|
|
||||||
const icon = document.querySelector('svg');
|
|
||||||
expect(icon).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应根据 severity 应用不同的样式', () => {
|
|
||||||
render(<GlobalSnackbar {...defaultProps} severity="error" />);
|
|
||||||
const message = screen.getByText('测试消息');
|
|
||||||
const parent = message.parentElement;
|
|
||||||
expect(parent).toHaveClass('bg-red-500');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('useSnackbarState Hook 逻辑', () => {
|
|
||||||
it('应返回初始状态', () => {
|
|
||||||
const { result } = renderHook(() => useSnackbarState());
|
|
||||||
expect(result.current.snackbarProps.open).toBe(false);
|
|
||||||
expect(result.current.snackbarProps.message).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('showMessage 应更新状态', () => {
|
|
||||||
const { result } = renderHook(() => useSnackbarState());
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.showMessage('新消息');
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.snackbarProps.open).toBe(true);
|
|
||||||
expect(result.current.snackbarProps.message).toBe('新消息');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('closeMessage 应关闭消息', () => {
|
|
||||||
const { result } = renderHook(() => useSnackbarState());
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.showMessage('消息');
|
|
||||||
});
|
|
||||||
act(() => {
|
|
||||||
result.current.closeMessage();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.snackbarProps.open).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('useSnackbar Context Hook 优先级', () => {
|
|
||||||
it('优先级验证: Call Options > Hook Options > Provider Options', () => {
|
|
||||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
|
||||||
<SnackbarProvider initialOptions={{ severity: 'info' }}>{children}</SnackbarProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useSnackbar({ severity: 'warning' }), { wrapper });
|
|
||||||
|
|
||||||
// 1. 测试 Hook Options 覆盖 Provider Options
|
|
||||||
act(() => {
|
|
||||||
result.current.showMessage('消息 1');
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByText('消息 1')).toBeInTheDocument();
|
|
||||||
|
|
||||||
// 2. 测试 Call Options 覆盖 Hook Options
|
|
||||||
act(() => {
|
|
||||||
result.current.showMessage('消息 2', { severity: 'error' });
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByText('消息 2')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -13,10 +13,11 @@ const mockRevokeObjectURL = vi.fn();
|
|||||||
Object.defineProperty(window.URL, 'createObjectURL', { value: mockCreateObjectURL });
|
Object.defineProperty(window.URL, 'createObjectURL', { value: mockCreateObjectURL });
|
||||||
Object.defineProperty(window.URL, 'revokeObjectURL', { value: mockRevokeObjectURL });
|
Object.defineProperty(window.URL, 'revokeObjectURL', { value: mockRevokeObjectURL });
|
||||||
|
|
||||||
vi.mock('@/components/GlobalSnackbar', () => ({
|
vi.mock('sonner', () => ({
|
||||||
useSnackbar: () => ({
|
toast: {
|
||||||
showMessage: vi.fn(),
|
success: vi.fn(),
|
||||||
}),
|
error: vi.fn(),
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('ImageUploader 组件', () => {
|
describe('ImageUploader 组件', () => {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|||||||
import { render } from '@testing-library/react';
|
import { render } from '@testing-library/react';
|
||||||
import RouterContainer from '@/components/RouterContainer';
|
import RouterContainer from '@/components/RouterContainer';
|
||||||
import { RouterProvider } from '@/providers/RouterProvider';
|
import { RouterProvider } from '@/providers/RouterProvider';
|
||||||
import { SnackbarProvider } from '@/components/GlobalSnackbar';
|
|
||||||
import type { PageType } from '@/types/storage';
|
import type { PageType } from '@/types/storage';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
@@ -29,11 +28,7 @@ describe('RouterContainer 组件', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const renderWithProvider = (ui: React.ReactElement) => {
|
const renderWithProvider = (ui: React.ReactElement) => {
|
||||||
return render(
|
return render(<RouterProvider>{ui}</RouterProvider>);
|
||||||
<SnackbarProvider>
|
|
||||||
<RouterProvider>{ui}</RouterProvider>
|
|
||||||
</SnackbarProvider>,
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('渲染测试', () => {
|
describe('渲染测试', () => {
|
||||||
@@ -74,11 +69,7 @@ describe('RouterContainer 组件', () => {
|
|||||||
const { rerender } = renderWithProvider(<RouterContainer />);
|
const { rerender } = renderWithProvider(<RouterContainer />);
|
||||||
|
|
||||||
mockRouterValue.currentPage = 'timestamp';
|
mockRouterValue.currentPage = 'timestamp';
|
||||||
rerender(
|
rerender(<RouterProvider>{<RouterContainer />}</RouterProvider>);
|
||||||
<SnackbarProvider>
|
|
||||||
<RouterProvider>{<RouterContainer />}</RouterProvider>
|
|
||||||
</SnackbarProvider>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const box = document.querySelector('.page-transition-enter');
|
const box = document.querySelector('.page-transition-enter');
|
||||||
expect(box).toBeInTheDocument();
|
expect(box).toBeInTheDocument();
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ describe('StorageCleanerConfirm 组件', () => {
|
|||||||
renderComponent({ options: partialOptions });
|
renderComponent({ options: partialOptions });
|
||||||
|
|
||||||
expect(screen.getByText(/Local Storage/)).toBeInTheDocument();
|
expect(screen.getByText(/Local Storage/)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/IndexedDB/)).toBeInTheDocument();
|
expect(screen.getByText(/站点存储/)).toBeInTheDocument();
|
||||||
expect(screen.queryByText(/Session Storage/)).not.toBeInTheDocument();
|
expect(screen.queryByText(/Session Storage/)).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText(/Cookies$/)).not.toBeInTheDocument();
|
expect(screen.queryByText(/Cookies$/)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { browser } from 'wxt/browser';
|
|||||||
import { MessageAction, onMessage, sendMessage } from '@/utils/messages';
|
import { MessageAction, onMessage, sendMessage } from '@/utils/messages';
|
||||||
import { createAllContextMenus, parseContextMenuClick } from '@/utils/contextMenu';
|
import { createAllContextMenus, parseContextMenuClick } from '@/utils/contextMenu';
|
||||||
import { saveContextMenuData } from '@/utils/useContextMenuData';
|
import { saveContextMenuData } from '@/utils/useContextMenuData';
|
||||||
|
import { mainWorldInjectionScript } from '@/utils/rightClickInjection';
|
||||||
|
|
||||||
export default defineBackground(() => {
|
export default defineBackground(() => {
|
||||||
// 1. 扩展初次安装或更新时,注册右键上下文菜单
|
// 1. 扩展初次安装或更新时,注册右键上下文菜单
|
||||||
@@ -71,68 +72,7 @@ export default defineBackground(() => {
|
|||||||
try {
|
try {
|
||||||
await browser.scripting.executeScript({
|
await browser.scripting.executeScript({
|
||||||
target: { tabId },
|
target: { tabId },
|
||||||
func: () => {
|
func: mainWorldInjectionScript,
|
||||||
'use strict';
|
|
||||||
const w = window as unknown as Record<string, unknown>;
|
|
||||||
if (w.__testingToolsRightClickPatched) return;
|
|
||||||
w.__testingToolsRightClickPatched = true;
|
|
||||||
|
|
||||||
const PROTECTED = ['contextmenu', 'copy', 'paste', 'cut', 'selectstart'];
|
|
||||||
|
|
||||||
const _origPreventDefault = MouseEvent.prototype.preventDefault;
|
|
||||||
Object.defineProperty(MouseEvent.prototype, 'preventDefault', {
|
|
||||||
value: function (this: MouseEvent) {
|
|
||||||
const t = this.type;
|
|
||||||
if (PROTECTED.includes(t) || (t === 'mousedown' && this.button === 2)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return _origPreventDefault.call(this);
|
|
||||||
},
|
|
||||||
writable: true,
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const _origStopPropagation = Event.prototype.stopPropagation;
|
|
||||||
Object.defineProperty(Event.prototype, 'stopPropagation', {
|
|
||||||
value: function (this: Event) {
|
|
||||||
if (PROTECTED.includes(this.type)) return;
|
|
||||||
return _origStopPropagation.call(this);
|
|
||||||
},
|
|
||||||
writable: true,
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const _origStopImmediatePropagation = Event.prototype.stopImmediatePropagation;
|
|
||||||
Object.defineProperty(Event.prototype, 'stopImmediatePropagation', {
|
|
||||||
value: function (this: Event) {
|
|
||||||
if (PROTECTED.includes(this.type)) return;
|
|
||||||
return _origStopImmediatePropagation.call(this);
|
|
||||||
},
|
|
||||||
writable: true,
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
let _docOnContextMenu: unknown = null;
|
|
||||||
Object.defineProperty(document, 'oncontextmenu', {
|
|
||||||
get() {
|
|
||||||
return _docOnContextMenu;
|
|
||||||
},
|
|
||||||
set(fn: unknown) {
|
|
||||||
if (typeof fn === 'function') {
|
|
||||||
_docOnContextMenu = function (this: GlobalEventHandlers, e: MouseEvent) {
|
|
||||||
const r = (fn as (this: GlobalEventHandlers, ev: MouseEvent) => unknown).call(
|
|
||||||
this,
|
|
||||||
e,
|
|
||||||
);
|
|
||||||
return r === false ? true : r;
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
_docOnContextMenu = fn;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
world: 'MAIN',
|
world: 'MAIN',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -143,41 +83,4 @@ export default defineBackground(() => {
|
|||||||
return { success: false, message: errorMsg };
|
return { success: false, message: errorMsg };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onMessage(MessageAction.RELOAD_TAB, async (message) => {
|
|
||||||
const { tabId, delay = 0 } = message.data;
|
|
||||||
|
|
||||||
const executeReload = () => {
|
|
||||||
browser.tabs.reload(tabId).catch((err) => {
|
|
||||||
console.error('Failed to execute tab reload operation:', err);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (delay <= 0) {
|
|
||||||
executeReload();
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
const alarmName = `reload-tab-${tabId}-${Date.now()}`;
|
|
||||||
|
|
||||||
await browser.alarms.create(alarmName, { when: Date.now() + delay });
|
|
||||||
|
|
||||||
const cleanupTimeout = setTimeout(() => {
|
|
||||||
browser.alarms.onAlarm.removeListener(alarmListener);
|
|
||||||
browser.alarms.clear(alarmName).catch(() => {});
|
|
||||||
}, delay + 5000);
|
|
||||||
|
|
||||||
const alarmListener = (alarm: { name: string }) => {
|
|
||||||
if (alarm.name !== alarmName) return;
|
|
||||||
|
|
||||||
clearTimeout(cleanupTimeout);
|
|
||||||
executeReload();
|
|
||||||
browser.alarms.onAlarm.removeListener(alarmListener);
|
|
||||||
browser.alarms.clear(alarmName).catch(() => {});
|
|
||||||
};
|
|
||||||
|
|
||||||
browser.alarms.onAlarm.addListener(alarmListener);
|
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import '../../.wxt/types/imports.d.ts';
|
import '../../.wxt/types/imports.d.ts';
|
||||||
import { initMessageHandler } from './content/messageHandler';
|
import { initContextMenuHandler } from './content/contextMenuHandler';
|
||||||
|
|
||||||
export default defineContentScript({
|
export default defineContentScript({
|
||||||
matches: ['<all_urls>'],
|
matches: ['<all_urls>'],
|
||||||
runAt: 'document_end',
|
runAt: 'document_end',
|
||||||
main() {
|
main() {
|
||||||
initMessageHandler();
|
initContextMenuHandler();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,17 +1,11 @@
|
|||||||
import type { ContextMenuClickedPayload } from '@/utils/messages';
|
import type { ContextMenuClickedPayload } from '@/utils/messages';
|
||||||
import { MessageAction, onMessage } from '@/utils/messages';
|
import { MessageAction, onMessage } from '@/utils/messages';
|
||||||
import { getTextStats } from '@/utils/textStatistics';
|
import { getTextStats } from '@/utils/textStatistics';
|
||||||
|
import { getMessage } from '@/utils/chromeI18n';
|
||||||
import { hidePopover, showTextStatsResult, showTimestampResult } from './uiPopover';
|
import { hidePopover, showTextStatsResult, showTimestampResult } from './uiPopover';
|
||||||
|
|
||||||
function getI18nText(key: string, fallback: string): string {
|
|
||||||
if (typeof chrome !== 'undefined' && chrome.i18n) {
|
|
||||||
return chrome.i18n.getMessage(key) || fallback;
|
|
||||||
}
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function convertTimestamp(input: string): string {
|
function convertTimestamp(input: string): string {
|
||||||
const invalidText = getI18nText('invalidTimestamp', 'Invalid Timestamp');
|
const invalidText = getMessage('invalidTimestamp') || 'Invalid Timestamp';
|
||||||
const num = Number(input.trim());
|
const num = Number(input.trim());
|
||||||
|
|
||||||
if (isNaN(num)) {
|
if (isNaN(num)) {
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { initContextMenuHandler } from './contextMenuHandler';
|
|
||||||
|
|
||||||
export function initMessageHandler(): void {
|
|
||||||
initContextMenuHandler();
|
|
||||||
}
|
|
||||||
@@ -2,27 +2,22 @@ import RouterProvider from '@/providers/RouterProvider';
|
|||||||
import TopBar from '@/components/TopBar';
|
import TopBar from '@/components/TopBar';
|
||||||
import RouterContainer from '@/components/RouterContainer';
|
import RouterContainer from '@/components/RouterContainer';
|
||||||
import ErrorBoundary from '@/components/ErrorBoundary';
|
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||||
import { SnackbarProvider } from '@/components/GlobalSnackbar';
|
|
||||||
import { getEntryPointType } from '@/config/features';
|
import { getEntryPointType } from '@/config/features';
|
||||||
import { useMemo } from 'react';
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const entryType = useMemo(() => getEntryPointType(), []);
|
const entryType = getEntryPointType();
|
||||||
|
|
||||||
const routerConfig = useMemo(() => {
|
const routerConfig =
|
||||||
if (entryType === 'tab') {
|
entryType === 'tab'
|
||||||
return {
|
? {
|
||||||
syncKey: 'app/tabRoute' as const,
|
syncKey: 'app/tabRoute' as const,
|
||||||
visiblePagesKey: 'app/tabVisiblePages' as const,
|
visiblePagesKey: 'app/tabVisiblePages' as const,
|
||||||
pageOrderKey: 'app/tabPageOrder' as const,
|
pageOrderKey: 'app/tabPageOrder' as const,
|
||||||
};
|
}
|
||||||
}
|
: {
|
||||||
return {
|
syncKey: 'app/popupRoute' as const,
|
||||||
syncKey: 'app/popupRoute' as const,
|
visiblePagesKey: 'app/popupVisiblePages' as const,
|
||||||
visiblePagesKey: 'app/popupVisiblePages' as const,
|
pageOrderKey: 'app/popupPageOrder' as const,
|
||||||
pageOrderKey: 'app/popupPageOrder' as const,
|
};
|
||||||
};
|
|
||||||
}, [entryType]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RouterProvider
|
<RouterProvider
|
||||||
@@ -30,14 +25,12 @@ export default function App() {
|
|||||||
visiblePagesKey={routerConfig.visiblePagesKey}
|
visiblePagesKey={routerConfig.visiblePagesKey}
|
||||||
pageOrderKey={routerConfig.pageOrderKey}
|
pageOrderKey={routerConfig.pageOrderKey}
|
||||||
>
|
>
|
||||||
<SnackbarProvider initialOptions={{ autoHideDuration: 1500 }}>
|
<div className="app flex flex-col w-[400px] max-w-[400px] min-w-[400px] h-[600px] min-h-[600px] overflow-hidden bg-background sm:w-screen sm:max-w-none sm:min-w-0 sm:h-screen sm:min-h-0">
|
||||||
<div className="app flex flex-col w-[400px] max-w-[400px] min-w-[400px] h-[600px] min-h-[600px] overflow-hidden bg-background sm:w-screen sm:max-w-none sm:min-w-0 sm:h-screen sm:min-h-0">
|
<TopBar />
|
||||||
<TopBar />
|
<ErrorBoundary>
|
||||||
<ErrorBoundary>
|
<RouterContainer />
|
||||||
<RouterContainer />
|
</ErrorBoundary>
|
||||||
</ErrorBoundary>
|
</div>
|
||||||
</div>
|
|
||||||
</SnackbarProvider>
|
|
||||||
</RouterProvider>
|
</RouterProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,57 +1,7 @@
|
|||||||
import { MessageAction, onMessage, sendMessage } from '@/utils/messages';
|
import { MessageAction, onMessage, sendMessage } from '@/utils/messages';
|
||||||
|
|
||||||
const BADGE_ID = 'testing-tools-right-click-restorer-badge';
|
|
||||||
const BADGE_STYLE_ID = 'testing-tools-right-click-restorer-badge-style';
|
|
||||||
|
|
||||||
let isRestored = false;
|
let isRestored = false;
|
||||||
|
|
||||||
function updateBadge(): void {
|
|
||||||
const badge = document.getElementById(BADGE_ID);
|
|
||||||
if (badge) {
|
|
||||||
badge.style.opacity = isRestored ? '1' : '0';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createBadge(): void {
|
|
||||||
if (document.getElementById(BADGE_ID)) return;
|
|
||||||
|
|
||||||
if (!document.getElementById(BADGE_STYLE_ID)) {
|
|
||||||
const style = document.createElement('style');
|
|
||||||
style.id = BADGE_STYLE_ID;
|
|
||||||
style.textContent = `
|
|
||||||
#${BADGE_ID} {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 16px;
|
|
||||||
right: 16px;
|
|
||||||
z-index: 2147483646;
|
|
||||||
padding: 6px 12px;
|
|
||||||
background: #2e7d32;
|
|
||||||
color: #ffffff;
|
|
||||||
border-radius: 20px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.2s ease-out;
|
|
||||||
pointer-events: none;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
#${BADGE_ID} {
|
|
||||||
background: #4caf50;
|
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
document.head.appendChild(style);
|
|
||||||
}
|
|
||||||
|
|
||||||
const badge = document.createElement('div');
|
|
||||||
badge.id = BADGE_ID;
|
|
||||||
badge.textContent = '\u53f3\u952e\u5df2\u89e3\u9501';
|
|
||||||
document.body.appendChild(badge);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ======================== Main World 注入 ======================== */
|
/* ======================== Main World 注入 ======================== */
|
||||||
|
|
||||||
async function injectMainWorldScript(): Promise<void> {
|
async function injectMainWorldScript(): Promise<void> {
|
||||||
@@ -90,64 +40,6 @@ function installEventIntercepts(): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ======================== 遮罩层穿透 ======================== */
|
|
||||||
|
|
||||||
const MEDIA_TAGS = new Set(['IMG', 'VIDEO', 'CANVAS', 'SVG']);
|
|
||||||
|
|
||||||
function initMousePenetration(): void {
|
|
||||||
window.addEventListener(
|
|
||||||
'mousedown',
|
|
||||||
(e) => {
|
|
||||||
if (!isRestored || e.button !== 2) return;
|
|
||||||
|
|
||||||
const elements = document.elementsFromPoint(e.clientX, e.clientY);
|
|
||||||
if (!elements.length) return;
|
|
||||||
|
|
||||||
let targetMedia: HTMLElement | null = null;
|
|
||||||
for (const el of elements) {
|
|
||||||
if (MEDIA_TAGS.has(el.tagName)) {
|
|
||||||
targetMedia = el as HTMLElement;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!targetMedia) return;
|
|
||||||
|
|
||||||
const modified: Array<{ el: HTMLElement; original: string | null }> = [];
|
|
||||||
let foundMedia = false;
|
|
||||||
|
|
||||||
for (const el of elements) {
|
|
||||||
const htmlEl = el as HTMLElement;
|
|
||||||
|
|
||||||
if (el === targetMedia) {
|
|
||||||
foundMedia = true;
|
|
||||||
const original = htmlEl.style.pointerEvents || null;
|
|
||||||
htmlEl.style.setProperty('pointer-events', 'all', 'important');
|
|
||||||
modified.push({ el: htmlEl, original });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!foundMedia) {
|
|
||||||
const original = htmlEl.style.pointerEvents || null;
|
|
||||||
htmlEl.style.setProperty('pointer-events', 'none', 'important');
|
|
||||||
modified.push({ el: htmlEl, original });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
for (const { el, original } of modified) {
|
|
||||||
if (original === null || original === '') {
|
|
||||||
el.style.removeProperty('pointer-events');
|
|
||||||
} else {
|
|
||||||
el.style.pointerEvents = original;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ======================== 激活保护 ======================== */
|
/* ======================== 激活保护 ======================== */
|
||||||
|
|
||||||
async function activateProtection(): Promise<void> {
|
async function activateProtection(): Promise<void> {
|
||||||
@@ -159,10 +51,8 @@ async function activateProtection(): Promise<void> {
|
|||||||
if (isRestored) return;
|
if (isRestored) return;
|
||||||
|
|
||||||
installEventIntercepts();
|
installEventIntercepts();
|
||||||
initMousePenetration();
|
|
||||||
|
|
||||||
isRestored = true;
|
isRestored = true;
|
||||||
updateBadge();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ======================== 消息通信 ======================== */
|
/* ======================== 消息通信 ======================== */
|
||||||
@@ -184,11 +74,6 @@ export default defineContentScript({
|
|||||||
matches: ['<all_urls>'],
|
matches: ['<all_urls>'],
|
||||||
runAt: 'document_start',
|
runAt: 'document_start',
|
||||||
main() {
|
main() {
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', createBadge, { once: true });
|
|
||||||
} else {
|
|
||||||
createBadge();
|
|
||||||
}
|
|
||||||
initMessaging();
|
initMessaging();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import RouterProvider from '@/providers/RouterProvider';
|
|||||||
import TopBar from '@/components/TopBar';
|
import TopBar from '@/components/TopBar';
|
||||||
import RouterContainer from '@/components/RouterContainer';
|
import RouterContainer from '@/components/RouterContainer';
|
||||||
import ErrorBoundary from '@/components/ErrorBoundary';
|
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||||
import { SnackbarProvider } from '@/components/GlobalSnackbar';
|
|
||||||
import { MessageAction, sendMessage } from '@/utils/messages';
|
import { MessageAction, sendMessage } from '@/utils/messages';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -16,14 +15,12 @@ export default function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<RouterProvider defaultRoute="dashboard" syncKey="app/sidepanelRoute">
|
<RouterProvider defaultRoute="dashboard" syncKey="app/sidepanelRoute">
|
||||||
<SnackbarProvider initialOptions={{ autoHideDuration: 1500 }}>
|
<div className="app flex flex-col h-screen w-full overflow-hidden">
|
||||||
<div className="app flex flex-col h-screen w-full overflow-hidden">
|
<TopBar />
|
||||||
<TopBar />
|
<ErrorBoundary>
|
||||||
<ErrorBoundary>
|
<RouterContainer />
|
||||||
<RouterContainer />
|
</ErrorBoundary>
|
||||||
</ErrorBoundary>
|
</div>
|
||||||
</div>
|
|
||||||
</SnackbarProvider>
|
|
||||||
</RouterProvider>
|
</RouterProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export default function Base64ConverterSection({ mode }: Base64ConverterSectionP
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full flex flex-col space-y-4">
|
<div className="w-full flex flex-col space-y-4 px-2">
|
||||||
<div className="flex h-11 items-center px-1.5 bg-secondary/40 rounded-xl border border-border/60 w-fit">
|
<div className="flex h-11 items-center px-1.5 bg-secondary/40 rounded-xl border border-border/60 w-fit">
|
||||||
<SwitchButtonGroup
|
<SwitchButtonGroup
|
||||||
value={direction}
|
value={direction}
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export default function TextMode({ onSwitchToImageMode }: TextModeProps = {}) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full flex flex-col space-y-4">
|
<div className="w-full flex flex-col space-y-4 px-2">
|
||||||
{/* 受控方向切流中枢 */}
|
{/* 受控方向切流中枢 */}
|
||||||
<div className="flex h-11 items-center px-1.5 bg-secondary/40 rounded-xl border border-border/60 w-fit">
|
<div className="flex h-11 items-center px-1.5 bg-secondary/40 rounded-xl border border-border/60 w-fit">
|
||||||
<SwitchButtonGroup
|
<SwitchButtonGroup
|
||||||
|
|||||||
@@ -1,29 +1,24 @@
|
|||||||
import { useRouter } from '@/providers/RouterProvider';
|
import { useRouter } from '@/providers/RouterProvider';
|
||||||
import { getFeatureByKey } from '@/config/features';
|
import { getFeatureByKey } from '@/config/features';
|
||||||
import type { PageType } from '@/types/storage';
|
import type { PageType } from '@/types/storage';
|
||||||
import { useMemo } from 'react';
|
|
||||||
import { useI18n } from '@/utils/chromeI18n';
|
import { useI18n } from '@/utils/chromeI18n';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function Index() {
|
||||||
const { navigateTo, visiblePages, pageOrder, recentlyUsedTools } = useRouter();
|
const { navigateTo, visiblePages, pageOrder, recentlyUsedTools } = useRouter();
|
||||||
const { t } = useI18n(['features']);
|
const { t } = useI18n(['features']);
|
||||||
|
|
||||||
const visibleSet = useMemo(() => new Set<string>(visiblePages), [visiblePages]);
|
const visibleSet = new Set<string>(visiblePages);
|
||||||
|
|
||||||
const visibleFeatures = useMemo(() => {
|
const visibleFeatures = pageOrder
|
||||||
return pageOrder
|
.filter((key) => visibleSet.has(key))
|
||||||
.filter((key) => visibleSet.has(key))
|
.map((key) => ({ key, feature: getFeatureByKey(key) }))
|
||||||
.map((key) => ({ key, feature: getFeatureByKey(key) }))
|
.filter((item) => item.feature?.themeColorKey && item.feature.icon != null);
|
||||||
.filter((item) => item.feature?.themeColorKey && item.feature.icon != null);
|
|
||||||
}, [pageOrder, visibleSet]);
|
|
||||||
|
|
||||||
const recentFeatures = useMemo(() => {
|
const recentFeatures = recentlyUsedTools
|
||||||
return recentlyUsedTools
|
.filter((key) => visibleSet.has(key))
|
||||||
.filter((key) => visibleSet.has(key))
|
.map((key) => ({ key, feature: getFeatureByKey(key) }))
|
||||||
.map((key) => ({ key, feature: getFeatureByKey(key) }))
|
.filter((item) => item.feature?.themeColorKey && item.feature.icon != null);
|
||||||
.filter((item) => item.feature?.themeColorKey && item.feature.icon != null);
|
|
||||||
}, [recentlyUsedTools, visibleSet]);
|
|
||||||
|
|
||||||
const showRecent = recentFeatures.length > 0;
|
const showRecent = recentFeatures.length > 0;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { useI18n } from '@/utils/chromeI18n';
|
import { useI18n } from '@/utils/chromeI18n';
|
||||||
import { formatByteSize } from '@/utils/textStatistics';
|
import { formatBytes } from '@/utils/format';
|
||||||
import CopyButton from '@/components/CopyButton';
|
import CopyButton from '@/components/CopyButton';
|
||||||
import TextInputArea from '@/components/TextInputArea';
|
import TextInputArea from '@/components/TextInputArea';
|
||||||
import { validateJson } from '@/utils/jsonFormatter';
|
import { validateJson } from '@/utils/jsonFormatter';
|
||||||
@@ -96,14 +96,14 @@ export default function JsonConvertSection({
|
|||||||
<span>
|
<span>
|
||||||
{t('jsonFormat:originalSize')}:{' '}
|
{t('jsonFormat:originalSize')}:{' '}
|
||||||
<span className="font-semibold text-foreground/80">
|
<span className="font-semibold text-foreground/80">
|
||||||
{formatByteSize(result.originalBytes)}
|
{formatBytes(result.originalBytes)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="text-border/60">|</span>
|
<span className="text-border/60">|</span>
|
||||||
<span>
|
<span>
|
||||||
{t('jsonFormat:formattedSize')}:{' '}
|
{t('jsonFormat:formattedSize')}:{' '}
|
||||||
<span className="font-semibold text-foreground/80">
|
<span className="font-semibold text-foreground/80">
|
||||||
{formatByteSize(result.outputBytes)}
|
{formatBytes(result.outputBytes)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
type JsonFormatResult,
|
type JsonFormatResult,
|
||||||
validateJson,
|
validateJson,
|
||||||
} from '@/utils/jsonFormatter';
|
} from '@/utils/jsonFormatter';
|
||||||
import { formatByteSize } from '@/utils/textStatistics';
|
import { formatBytes } from '@/utils/format';
|
||||||
import CopyButton from '@/components/CopyButton';
|
import CopyButton from '@/components/CopyButton';
|
||||||
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
||||||
import TextInputArea from '@/components/TextInputArea';
|
import TextInputArea from '@/components/TextInputArea';
|
||||||
@@ -127,14 +127,14 @@ export default function JsonFormatSection() {
|
|||||||
<span>
|
<span>
|
||||||
{t('jsonFormat:originalSize')}:{' '}
|
{t('jsonFormat:originalSize')}:{' '}
|
||||||
<span className="font-semibold text-foreground/80">
|
<span className="font-semibold text-foreground/80">
|
||||||
{formatByteSize(result.originalBytes)}
|
{formatBytes(result.originalBytes)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="text-border/60">|</span>
|
<span className="text-border/60">|</span>
|
||||||
<span>
|
<span>
|
||||||
{t('jsonFormat:formattedSize')}:{' '}
|
{t('jsonFormat:formattedSize')}:{' '}
|
||||||
<span className="font-semibold text-foreground/80">
|
<span className="font-semibold text-foreground/80">
|
||||||
{formatByteSize(result.formattedBytes)}
|
{formatBytes(result.formattedBytes)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { JsonToolsPageMode } from '@/types/storage';
|
||||||
|
|
||||||
|
export const VALID_PAGE_MODES: readonly JsonToolsPageMode[] = [
|
||||||
|
'diff',
|
||||||
|
'format',
|
||||||
|
'yaml',
|
||||||
|
'toml',
|
||||||
|
'minify',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const isValidPageMode = (val: unknown): val is JsonToolsPageMode =>
|
||||||
|
typeof val === 'string' && (VALID_PAGE_MODES as readonly string[]).includes(val);
|
||||||
|
|
||||||
|
export interface ParseState {
|
||||||
|
value: unknown;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const tryParse = (raw: string, invalidMsg: string): ParseState => {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) return { value: undefined, error: null };
|
||||||
|
try {
|
||||||
|
return { value: JSON.parse(trimmed), error: null };
|
||||||
|
} catch {
|
||||||
|
return { value: undefined, error: invalidMsg };
|
||||||
|
}
|
||||||
|
};
|
||||||
+25
-105
@@ -1,113 +1,39 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
||||||
import { useI18n } from '@/utils/chromeI18n';
|
import { useI18n } from '@/utils/chromeI18n';
|
||||||
import JsonDiffInput from './JsonDiffInput';
|
import JsonDiffInput from './JsonDiffInput';
|
||||||
import DiffResult from './DiffResult';
|
import DiffResult from './DiffResult';
|
||||||
import DiffNavigator from './DiffNavigator';
|
import DiffNavigator from './DiffNavigator';
|
||||||
import JsonFormatSection from './JsonFormatSection';
|
import JsonFormatSection from './JsonFormatSection';
|
||||||
import type { ConvertFunction } from './JsonConvertSection';
|
|
||||||
import JsonConvertSection from './JsonConvertSection';
|
import JsonConvertSection from './JsonConvertSection';
|
||||||
import { diffJson } from './diffEngine';
|
|
||||||
import { jsonToYaml } from '@/utils/jsonToYaml';
|
|
||||||
import { jsonToToml } from '@/utils/jsonToToml';
|
|
||||||
import { minifyJson } from '@/utils/jsonFormatter';
|
|
||||||
import { useStorageState } from '@/utils/useStorageState';
|
|
||||||
import type { JsonToolsPageMode } from '@/types/storage';
|
|
||||||
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
||||||
|
import { useJsonTools } from './useJsonTools';
|
||||||
|
import type { JsonToolsPageMode } from '@/types/storage';
|
||||||
import type { ViewMode } from './types';
|
import type { ViewMode } from './types';
|
||||||
|
|
||||||
interface ParseState {
|
|
||||||
value: unknown;
|
|
||||||
error: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tryParse = (raw: string, invalidMsg: string): ParseState => {
|
|
||||||
const trimmed = raw.trim();
|
|
||||||
if (!trimmed) return { value: undefined, error: null };
|
|
||||||
try {
|
|
||||||
return { value: JSON.parse(trimmed), error: null };
|
|
||||||
} catch {
|
|
||||||
return { value: undefined, error: invalidMsg };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const VALID_PAGE_MODES: readonly JsonToolsPageMode[] = ['diff', 'format', 'yaml', 'toml', 'minify'];
|
|
||||||
const isValidPageMode = (val: unknown): val is JsonToolsPageMode =>
|
|
||||||
typeof val === 'string' && (VALID_PAGE_MODES as readonly string[]).includes(val);
|
|
||||||
|
|
||||||
type PageMode = JsonToolsPageMode;
|
type PageMode = JsonToolsPageMode;
|
||||||
|
|
||||||
export default function Index() {
|
export default function Index() {
|
||||||
const { t } = useI18n(['jsonDiff', 'jsonFormat']);
|
const { t } = useI18n(['jsonDiff', 'jsonFormat']);
|
||||||
const [pageMode, setPageMode] = useStorageState('jsonTools/pageMode', 'diff', isValidPageMode);
|
const {
|
||||||
|
pageMode,
|
||||||
// Debounce input
|
setPageMode,
|
||||||
const [leftInput, setLeftInput] = useState('');
|
leftInput,
|
||||||
const [rightInput, setRightInput] = useState('');
|
rightInput,
|
||||||
|
setLeftInput,
|
||||||
// Debounced values
|
setRightInput,
|
||||||
const [debouncedLeft, setDebouncedLeft] = useState('');
|
leftError,
|
||||||
const [debouncedRight, setDebouncedRight] = useState('');
|
rightError,
|
||||||
|
viewMode,
|
||||||
useEffect(() => {
|
setViewMode,
|
||||||
const handle = setTimeout(() => {
|
diffResult,
|
||||||
setDebouncedLeft(leftInput);
|
total,
|
||||||
setDebouncedRight(rightInput);
|
currentDiffIndex,
|
||||||
}, 250);
|
handlePrev,
|
||||||
return () => clearTimeout(handle);
|
handleNext,
|
||||||
}, [leftInput, rightInput]);
|
activePath,
|
||||||
|
yamlConvert,
|
||||||
// Parse debounced inputs
|
tomlConvert,
|
||||||
const parseState = useMemo(() => {
|
minifyConvert,
|
||||||
const invalidMsg = t('jsonDiff:invalidJson');
|
} = useJsonTools();
|
||||||
return {
|
|
||||||
left: tryParse(debouncedLeft, invalidMsg),
|
|
||||||
right: tryParse(debouncedRight, invalidMsg),
|
|
||||||
};
|
|
||||||
}, [debouncedLeft, debouncedRight, t]);
|
|
||||||
|
|
||||||
const leftError = parseState.left.error;
|
|
||||||
const rightError = parseState.right.error;
|
|
||||||
|
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('sideBySide');
|
|
||||||
const [currentDiffIndex, setCurrentDiffIndex] = useState(0);
|
|
||||||
|
|
||||||
// Real-time diff computation
|
|
||||||
const diffResult = useMemo(() => {
|
|
||||||
const { left, right } = parseState;
|
|
||||||
if (left.error || right.error || debouncedLeft.trim() === '' || debouncedRight.trim() === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return diffJson(left.value, right.value);
|
|
||||||
}, [parseState, debouncedLeft, debouncedRight]);
|
|
||||||
|
|
||||||
const total = diffResult?.diffPaths.length ?? 0;
|
|
||||||
|
|
||||||
const handlePrev = useCallback(() => {
|
|
||||||
if (total === 0) return;
|
|
||||||
setCurrentDiffIndex((idx) => (idx - 1 + total) % total);
|
|
||||||
}, [total]);
|
|
||||||
|
|
||||||
const handleNext = useCallback(() => {
|
|
||||||
if (total === 0) return;
|
|
||||||
setCurrentDiffIndex((idx) => (idx + 1) % total);
|
|
||||||
}, [total]);
|
|
||||||
|
|
||||||
const activePath = diffResult && total > 0 ? diffResult.diffPaths[currentDiffIndex] : undefined;
|
|
||||||
|
|
||||||
const yamlConvert: ConvertFunction = useCallback((text: string) => {
|
|
||||||
const r = jsonToYaml(text);
|
|
||||||
return { output: r.output, originalBytes: r.originalBytes, outputBytes: r.outputBytes };
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const tomlConvert: ConvertFunction = useCallback((text: string) => {
|
|
||||||
const r = jsonToToml(text);
|
|
||||||
return { output: r.output, originalBytes: r.originalBytes, outputBytes: r.outputBytes };
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const minifyConvert: ConvertFunction = useCallback((text: string) => {
|
|
||||||
const r = minifyJson(text);
|
|
||||||
return { output: r.minified, originalBytes: r.originalBytes, outputBytes: r.minifiedBytes };
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 w-full flex flex-col space-y-4 min-h-[500px] select-none">
|
<div className="p-4 w-full flex flex-col space-y-4 min-h-[500px] select-none">
|
||||||
@@ -144,10 +70,7 @@ export default function Index() {
|
|||||||
label={t('jsonDiff:leftLabel')}
|
label={t('jsonDiff:leftLabel')}
|
||||||
placeholder={t('jsonDiff:leftPlaceholder')}
|
placeholder={t('jsonDiff:leftPlaceholder')}
|
||||||
value={leftInput}
|
value={leftInput}
|
||||||
onChange={(val) => {
|
onChange={setLeftInput}
|
||||||
setLeftInput(val);
|
|
||||||
setCurrentDiffIndex(0);
|
|
||||||
}}
|
|
||||||
error={leftError}
|
error={leftError}
|
||||||
minRows={9}
|
minRows={9}
|
||||||
/>
|
/>
|
||||||
@@ -155,10 +78,7 @@ export default function Index() {
|
|||||||
label={t('jsonDiff:rightLabel')}
|
label={t('jsonDiff:rightLabel')}
|
||||||
placeholder={t('jsonDiff:rightPlaceholder')}
|
placeholder={t('jsonDiff:rightPlaceholder')}
|
||||||
value={rightInput}
|
value={rightInput}
|
||||||
onChange={(val) => {
|
onChange={setRightInput}
|
||||||
setRightInput(val);
|
|
||||||
setCurrentDiffIndex(0);
|
|
||||||
}}
|
|
||||||
error={rightError}
|
error={rightError}
|
||||||
minRows={9}
|
minRows={9}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useI18n } from '@/utils/chromeI18n';
|
||||||
|
import { useStorageState } from '@/utils/useStorageState';
|
||||||
|
import { diffJson } from './diffEngine';
|
||||||
|
import { jsonToYaml } from '@/utils/jsonToYaml';
|
||||||
|
import { jsonToToml } from '@/utils/jsonToToml';
|
||||||
|
import { minifyJson } from '@/utils/jsonFormatter';
|
||||||
|
import { isValidPageMode, tryParse } from './constants';
|
||||||
|
import type { JsonToolsPageMode } from '@/types/storage';
|
||||||
|
import type { ConvertFunction } from './JsonConvertSection';
|
||||||
|
import type { ViewMode } from './types';
|
||||||
|
|
||||||
|
export interface UseJsonToolsReturn {
|
||||||
|
pageMode: JsonToolsPageMode;
|
||||||
|
setPageMode: (mode: JsonToolsPageMode) => void;
|
||||||
|
// Diff mode state
|
||||||
|
leftInput: string;
|
||||||
|
rightInput: string;
|
||||||
|
setLeftInput: (val: string) => void;
|
||||||
|
setRightInput: (val: string) => void;
|
||||||
|
leftError: string | null;
|
||||||
|
rightError: string | null;
|
||||||
|
viewMode: ViewMode;
|
||||||
|
setViewMode: (mode: ViewMode) => void;
|
||||||
|
diffResult: ReturnType<typeof diffJson> | null;
|
||||||
|
total: number;
|
||||||
|
currentDiffIndex: number;
|
||||||
|
handlePrev: () => void;
|
||||||
|
handleNext: () => void;
|
||||||
|
activePath: string | undefined;
|
||||||
|
// Convert functions
|
||||||
|
yamlConvert: ConvertFunction;
|
||||||
|
tomlConvert: ConvertFunction;
|
||||||
|
minifyConvert: ConvertFunction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useJsonTools(): UseJsonToolsReturn {
|
||||||
|
const { t } = useI18n(['jsonDiff', 'jsonFormat']);
|
||||||
|
const [pageMode, setPageMode] = useStorageState('jsonTools/pageMode', 'diff', isValidPageMode);
|
||||||
|
|
||||||
|
// Diff inputs
|
||||||
|
const [leftInput, setLeftInput] = useState('');
|
||||||
|
const [rightInput, setRightInput] = useState('');
|
||||||
|
const [debouncedLeft, setDebouncedLeft] = useState('');
|
||||||
|
const [debouncedRight, setDebouncedRight] = useState('');
|
||||||
|
|
||||||
|
// Debounce
|
||||||
|
useEffect(() => {
|
||||||
|
const handle = setTimeout(() => {
|
||||||
|
setDebouncedLeft(leftInput);
|
||||||
|
setDebouncedRight(rightInput);
|
||||||
|
}, 250);
|
||||||
|
return () => clearTimeout(handle);
|
||||||
|
}, [leftInput, rightInput]);
|
||||||
|
|
||||||
|
// Parse debounced inputs
|
||||||
|
const parseState = useMemo(() => {
|
||||||
|
const invalidMsg = t('jsonDiff:invalidJson');
|
||||||
|
return {
|
||||||
|
left: tryParse(debouncedLeft, invalidMsg),
|
||||||
|
right: tryParse(debouncedRight, invalidMsg),
|
||||||
|
};
|
||||||
|
}, [debouncedLeft, debouncedRight, t]);
|
||||||
|
|
||||||
|
const leftError = parseState.left.error;
|
||||||
|
const rightError = parseState.right.error;
|
||||||
|
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>('sideBySide');
|
||||||
|
const [currentDiffIndex, setCurrentDiffIndex] = useState(0);
|
||||||
|
|
||||||
|
// Real-time diff computation
|
||||||
|
const diffResult = useMemo(() => {
|
||||||
|
const { left, right } = parseState;
|
||||||
|
if (left.error || right.error || debouncedLeft.trim() === '' || debouncedRight.trim() === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return diffJson(left.value, right.value);
|
||||||
|
}, [parseState, debouncedLeft, debouncedRight]);
|
||||||
|
|
||||||
|
const total = diffResult?.diffPaths.length ?? 0;
|
||||||
|
|
||||||
|
const handlePrev = useCallback(() => {
|
||||||
|
if (total === 0) return;
|
||||||
|
setCurrentDiffIndex((idx) => (idx - 1 + total) % total);
|
||||||
|
}, [total]);
|
||||||
|
|
||||||
|
const handleNext = useCallback(() => {
|
||||||
|
if (total === 0) return;
|
||||||
|
setCurrentDiffIndex((idx) => (idx + 1) % total);
|
||||||
|
}, [total]);
|
||||||
|
|
||||||
|
const activePath = diffResult && total > 0 ? diffResult.diffPaths[currentDiffIndex] : undefined;
|
||||||
|
|
||||||
|
// Convert functions
|
||||||
|
const yamlConvert: ConvertFunction = useCallback((text: string) => {
|
||||||
|
const r = jsonToYaml(text);
|
||||||
|
return { output: r.output, originalBytes: r.originalBytes, outputBytes: r.outputBytes };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const tomlConvert: ConvertFunction = useCallback((text: string) => {
|
||||||
|
const r = jsonToToml(text);
|
||||||
|
return { output: r.output, originalBytes: r.originalBytes, outputBytes: r.outputBytes };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const minifyConvert: ConvertFunction = useCallback((text: string) => {
|
||||||
|
const r = minifyJson(text);
|
||||||
|
return { output: r.minified, originalBytes: r.originalBytes, outputBytes: r.minifiedBytes };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
pageMode,
|
||||||
|
setPageMode,
|
||||||
|
leftInput,
|
||||||
|
rightInput,
|
||||||
|
setLeftInput: (val: string) => {
|
||||||
|
setLeftInput(val);
|
||||||
|
setCurrentDiffIndex(0);
|
||||||
|
},
|
||||||
|
setRightInput: (val: string) => {
|
||||||
|
setRightInput(val);
|
||||||
|
setCurrentDiffIndex(0);
|
||||||
|
},
|
||||||
|
leftError,
|
||||||
|
rightError,
|
||||||
|
viewMode,
|
||||||
|
setViewMode,
|
||||||
|
diffResult,
|
||||||
|
total,
|
||||||
|
currentDiffIndex,
|
||||||
|
handlePrev,
|
||||||
|
handleNext,
|
||||||
|
activePath,
|
||||||
|
yamlConvert,
|
||||||
|
tomlConvert,
|
||||||
|
minifyConvert,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import CopyButton from '@/components/CopyButton';
|
||||||
|
import { useI18n } from '@/utils/chromeI18n';
|
||||||
|
import { stringifyJson } from '@/utils/jwt';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface JwtSectionProps {
|
||||||
|
title: string;
|
||||||
|
content: unknown;
|
||||||
|
colorClass: string;
|
||||||
|
bgClass: string;
|
||||||
|
borderClass: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function JwtSection({
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
colorClass,
|
||||||
|
bgClass,
|
||||||
|
borderClass,
|
||||||
|
}: JwtSectionProps) {
|
||||||
|
const { t } = useI18n('jwt');
|
||||||
|
return (
|
||||||
|
<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}
|
||||||
|
</span>
|
||||||
|
<CopyButton
|
||||||
|
text={JSON.stringify(content)}
|
||||||
|
className="h-6 w-6 rounded-md border text-muted-foreground"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<pre className="m-0 p-3 bg-muted/30 dark:bg-muted/10 rounded-lg text-xs font-mono overflow-x-auto whitespace-pre-wrap break-all border border-border/50 text-foreground/90 leading-relaxed select-text">
|
||||||
|
{content ? stringifyJson(content) : t('jwt:invalidFormat')}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+9
-72
@@ -1,91 +1,31 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
||||||
import { parseJwt, stringifyJson } from '@/utils/jwt';
|
|
||||||
import CopyButton from '@/components/CopyButton';
|
|
||||||
import TextInputArea from '@/components/TextInputArea';
|
import TextInputArea from '@/components/TextInputArea';
|
||||||
|
import CopyButton from '@/components/CopyButton';
|
||||||
|
import JwtSection from './JwtSection';
|
||||||
|
import { useJwt } from './useJwt';
|
||||||
import { useI18n } from '@/utils/chromeI18n';
|
import { useI18n } from '@/utils/chromeI18n';
|
||||||
import { useContextMenuData } from '@/utils/useContextMenuData';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
interface SectionProps {
|
|
||||||
title: string;
|
|
||||||
content: unknown;
|
|
||||||
colorClass: string;
|
|
||||||
bgClass: string;
|
|
||||||
borderClass: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Section = ({ title, content, colorClass, bgClass, borderClass }: SectionProps) => {
|
|
||||||
const { t } = useI18n('jwt');
|
|
||||||
return (
|
|
||||||
<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}
|
|
||||||
</span>
|
|
||||||
<CopyButton
|
|
||||||
text={JSON.stringify(content)}
|
|
||||||
className="h-6 w-6 rounded-md border text-muted-foreground"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<pre className="m-0 p-3 bg-muted/30 dark:bg-muted/10 rounded-lg text-xs font-mono overflow-x-auto whitespace-pre-wrap break-all border border-border/50 text-foreground/90 leading-relaxed select-text">
|
|
||||||
{content ? stringifyJson(content) : t('jwt:invalidFormat')}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Index() {
|
export default function Index() {
|
||||||
const { t } = useI18n(['jwt', 'jsonFormat']);
|
const { t } = useI18n(['jwt', 'jsonFormat']);
|
||||||
const [jwtInput, setJwtInput] = useState('');
|
const { jwtInput, result, handleChange, handleClear } = useJwt();
|
||||||
|
|
||||||
// 2. 防抖中转管道:切断高频键盘敲击时的红色语法闪烁
|
|
||||||
const [debouncedInput, setDebouncedInput] = useState('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handle = setTimeout(() => {
|
|
||||||
setDebouncedInput(jwtInput);
|
|
||||||
}, 200);
|
|
||||||
return () => clearTimeout(handle);
|
|
||||||
}, [jwtInput]);
|
|
||||||
|
|
||||||
const handleContextMenuData = useCallback((payload: string) => {
|
|
||||||
const cleaned = payload.replace(/^Bearer\s*/i, '').trim();
|
|
||||||
setJwtInput(cleaned);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useContextMenuData({ featureKey: 'jwt', onData: handleContextMenuData });
|
|
||||||
|
|
||||||
const result = useMemo(() => {
|
|
||||||
if (!debouncedInput.trim()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return parseJwt(debouncedInput);
|
|
||||||
}, [debouncedInput]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 w-full flex flex-col space-y-4 select-none">
|
<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 gap-4">
|
||||||
{/* 输入终端 */}
|
|
||||||
<TextInputArea
|
<TextInputArea
|
||||||
minRows={5}
|
minRows={5}
|
||||||
maxRows={10}
|
maxRows={10}
|
||||||
placeholder={t('jwt_placeholder')}
|
placeholder={t('jwt_placeholder')}
|
||||||
value={jwtInput}
|
value={jwtInput}
|
||||||
onChange={(val) => {
|
onChange={handleChange}
|
||||||
const cleaned = val.replace(/^Bearer\s*/i, '').trim();
|
|
||||||
setJwtInput(cleaned);
|
|
||||||
}}
|
|
||||||
allowCopy={true}
|
allowCopy={true}
|
||||||
showClear={true}
|
showClear={true}
|
||||||
externalError={result?.error || undefined}
|
externalError={result?.error || undefined}
|
||||||
onClear={() => setJwtInput('')}
|
onClear={handleClear}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 解码看板结果展现 */}
|
|
||||||
{result && !result.error && (
|
{result && !result.error && (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{/* Header 分区:完美致敬 JWT.io 的鲜艳色彩,同时实现黑夜暗化自适应 */}
|
<JwtSection
|
||||||
<Section
|
|
||||||
title={t('jwt:headerTitle')}
|
title={t('jwt:headerTitle')}
|
||||||
content={result.header}
|
content={result.header}
|
||||||
colorClass="text-[#fb015b] dark:text-rose-400"
|
colorClass="text-[#fb015b] dark:text-rose-400"
|
||||||
@@ -93,16 +33,14 @@ export default function Index() {
|
|||||||
bgClass="bg-[#fb015b]/5 dark:bg-rose-500/5"
|
bgClass="bg-[#fb015b]/5 dark:bg-rose-500/5"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Payload 分区 */}
|
<JwtSection
|
||||||
<Section
|
|
||||||
title={t('jwt:payloadTitle')}
|
title={t('jwt:payloadTitle')}
|
||||||
content={result.payload}
|
content={result.payload}
|
||||||
colorClass="text-[#a03aff] dark:text-purple-400" // 针对暗黑模式略微调高对比度
|
colorClass="text-[#a03aff] dark:text-purple-400"
|
||||||
borderClass="border-[#a03aff]/20 dark:border-purple-500/20"
|
borderClass="border-[#a03aff]/20 dark:border-purple-500/20"
|
||||||
bgClass="bg-[#a03aff]/5 dark:bg-purple-500/5"
|
bgClass="bg-[#a03aff]/5 dark:bg-purple-500/5"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Signature 签名区:完全对齐标准的 shadcn 骨架阶度 */}
|
|
||||||
<div className="p-4 rounded-xl border border-border bg-secondary/40 shadow-sm">
|
<div className="p-4 rounded-xl border border-border bg-secondary/40 shadow-sm">
|
||||||
<div className="flex justify-between items-center mb-2">
|
<div className="flex justify-between items-center mb-2">
|
||||||
<span className="text-xs font-bold tracking-wider text-muted-foreground/90 uppercase">
|
<span className="text-xs font-bold tracking-wider text-muted-foreground/90 uppercase">
|
||||||
@@ -120,7 +58,6 @@ export default function Index() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 当解析错误时的干净中性引导拦截 */}
|
|
||||||
{result?.error && (
|
{result?.error && (
|
||||||
<div className="p-6 rounded-xl bg-muted/30 border border-dashed border-border text-center">
|
<div className="p-6 rounded-xl bg-muted/30 border border-dashed border-border text-center">
|
||||||
<p className="text-xs font-semibold text-muted-foreground/80">
|
<p className="text-xs font-semibold text-muted-foreground/80">
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useContextMenuData } from '@/utils/useContextMenuData';
|
||||||
|
import { parseJwt } from '@/utils/jwt';
|
||||||
|
import type { JwtResult } from '@/utils/jwt';
|
||||||
|
|
||||||
|
export interface UseJwtReturn {
|
||||||
|
jwtInput: string;
|
||||||
|
result: JwtResult | null;
|
||||||
|
handleChange: (val: string) => void;
|
||||||
|
handleClear: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useJwt(): UseJwtReturn {
|
||||||
|
const [jwtInput, setJwtInput] = useState('');
|
||||||
|
const [debouncedInput, setDebouncedInput] = useState('');
|
||||||
|
|
||||||
|
// 防抖管道
|
||||||
|
useEffect(() => {
|
||||||
|
const handle = setTimeout(() => setDebouncedInput(jwtInput), 200);
|
||||||
|
return () => clearTimeout(handle);
|
||||||
|
}, [jwtInput]);
|
||||||
|
|
||||||
|
// 右键菜单数据
|
||||||
|
const handleContextMenuData = useCallback((payload: string) => {
|
||||||
|
setJwtInput(payload.replace(/^Bearer\s*/i, '').trim());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useContextMenuData({ featureKey: 'jwt', onData: handleContextMenuData });
|
||||||
|
|
||||||
|
// 响应式解析
|
||||||
|
const result = useMemo(() => {
|
||||||
|
if (!debouncedInput.trim()) return null;
|
||||||
|
return parseJwt(debouncedInput);
|
||||||
|
}, [debouncedInput]);
|
||||||
|
|
||||||
|
const handleChange = useCallback((val: string) => {
|
||||||
|
setJwtInput(val.replace(/^Bearer\s*/i, '').trim());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleClear = useCallback(() => setJwtInput(''), []);
|
||||||
|
|
||||||
|
return { jwtInput, result, handleChange, handleClear };
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ vi.mock('lucide-react', async (importOriginal) => {
|
|||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
QrCode: () => <div data-testid="mock-lucide-qrcode">Icon</div>,
|
QrCode: () => <div data-testid="mock-lucide-qrcode">Icon</div>,
|
||||||
|
Pencil: () => <div data-testid="mock-lucide-pencil">Icon</div>,
|
||||||
|
Loader2: () => <div data-testid="mock-lucide-loader">Icon</div>,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -32,20 +34,29 @@ vi.mock('@/components/ImageUploader', () => ({
|
|||||||
default: () => <div data-testid="image-uploader">ImageUploader</div>,
|
default: () => <div data-testid="image-uploader">ImageUploader</div>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('qrious', () => ({
|
vi.mock('qrious', () => {
|
||||||
default: vi.fn().mockImplementation(() => ({
|
return {
|
||||||
toDataURL: () => 'data:image/png;base64,mock',
|
default: class QRious {
|
||||||
})),
|
constructor() {
|
||||||
}));
|
return {
|
||||||
|
toDataURL: () => 'data:image/png;base64,mock',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
describe('QrCodePage', () => {
|
describe('QrCodePage', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应该默认渲染生成模式', () => {
|
it('应该默认渲染生成模式的输入态', () => {
|
||||||
render(<QrCodePage />);
|
render(<QrCodePage />);
|
||||||
expect(screen.getByTestId('qr-code-preview')).toBeInTheDocument();
|
expect(screen.getByText('输入 URL 或文本')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('生成二维码')).toBeInTheDocument();
|
||||||
|
// 输入态应该隐藏二维码预览
|
||||||
|
expect(screen.queryByTestId('qr-code-preview')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应该渲染模式切换按钮', () => {
|
it('应该渲染模式切换按钮', () => {
|
||||||
@@ -58,27 +69,123 @@ describe('QrCodePage', () => {
|
|||||||
render(<QrCodePage />);
|
render(<QrCodePage />);
|
||||||
fireEvent.click(screen.getByText('二维码转文本'));
|
fireEvent.click(screen.getByText('二维码转文本'));
|
||||||
expect(screen.getByTestId('image-uploader')).toBeInTheDocument();
|
expect(screen.getByTestId('image-uploader')).toBeInTheDocument();
|
||||||
expect(screen.queryByTestId('qr-code-preview')).not.toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('切换回生成模式应该渲染 QrCodePreview', () => {
|
it('切换回生成模式应该渲染输入态', () => {
|
||||||
render(<QrCodePage />);
|
render(<QrCodePage />);
|
||||||
// 先切换到解析模式
|
// 先切换到解析模式
|
||||||
fireEvent.click(screen.getByText('二维码转文本'));
|
fireEvent.click(screen.getByText('二维码转文本'));
|
||||||
expect(screen.getByTestId('image-uploader')).toBeInTheDocument();
|
|
||||||
// 再切换回生成模式
|
// 再切换回生成模式
|
||||||
fireEvent.click(screen.getByText('文本转二维码'));
|
fireEvent.click(screen.getByText('文本转二维码'));
|
||||||
expect(screen.getByTestId('qr-code-preview')).toBeInTheDocument();
|
expect(screen.getByText('输入 URL 或文本')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('生成二维码')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应该渲染输入区域的系统标签(对齐新版 Label 机制)', () => {
|
it('应该渲染输入区域的系统标签', () => {
|
||||||
render(<QrCodePage />);
|
render(<QrCodePage />);
|
||||||
expect(screen.getByText('输入 URL 或文本')).toBeInTheDocument();
|
expect(screen.getByText('输入 URL 或文本')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应该渲染双翼响应式卡片网格布局', () => {
|
it('输入态应该显示生成按钮', () => {
|
||||||
const { container } = render(<QrCodePage />);
|
render(<QrCodePage />);
|
||||||
const gridContainer = container.querySelector('.grid');
|
const generateButton = screen.getByText('生成二维码');
|
||||||
expect(gridContainer).toBeInTheDocument();
|
expect(generateButton).toBeInTheDocument();
|
||||||
|
expect(generateButton).toBeDisabled(); // 空输入时按钮应该禁用
|
||||||
|
});
|
||||||
|
|
||||||
|
it('有输入内容时生成按钮应该可用', () => {
|
||||||
|
render(<QrCodePage />);
|
||||||
|
const textarea = screen.getByPlaceholderText('请输入 URL 或文本内容,将自动生成二维码');
|
||||||
|
fireEvent.change(textarea, { target: { value: 'https://example.com' } });
|
||||||
|
|
||||||
|
const generateButton = screen.getByText('生成二维码');
|
||||||
|
expect(generateButton).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('点击生成按钮应该切换到预览态', () => {
|
||||||
|
render(<QrCodePage />);
|
||||||
|
|
||||||
|
// 输入文本
|
||||||
|
const textarea = screen.getByPlaceholderText('请输入 URL 或文本内容,将自动生成二维码');
|
||||||
|
fireEvent.change(textarea, { target: { value: 'https://example.com' } });
|
||||||
|
|
||||||
|
// 点击生成按钮
|
||||||
|
const generateButton = screen.getByText('生成二维码');
|
||||||
|
fireEvent.click(generateButton);
|
||||||
|
|
||||||
|
// 验证切换到预览态
|
||||||
|
expect(screen.getByText('原始文本')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('编辑')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('生成二维码')).not.toBeInTheDocument();
|
||||||
|
// 预览态应该显示二维码
|
||||||
|
expect(screen.getByTestId('qr-code-preview')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('预览态应该显示截断的文本', () => {
|
||||||
|
render(<QrCodePage />);
|
||||||
|
|
||||||
|
// 输入长文本
|
||||||
|
const longText =
|
||||||
|
'https://example.com/very/long/path/that/should/be/truncated/in/the/preview/because/it/exceeds/the/maximum/length';
|
||||||
|
const textarea = screen.getByPlaceholderText('请输入 URL 或文本内容,将自动生成二维码');
|
||||||
|
fireEvent.change(textarea, { target: { value: longText } });
|
||||||
|
|
||||||
|
// 点击生成按钮
|
||||||
|
fireEvent.click(screen.getByText('生成二维码'));
|
||||||
|
|
||||||
|
// 验证显示截断的文本(80字符 + "...")
|
||||||
|
const truncatedText = longText.slice(0, 80) + '...';
|
||||||
|
expect(screen.getByText(truncatedText)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('预览态应该显示完整的短文本', () => {
|
||||||
|
render(<QrCodePage />);
|
||||||
|
|
||||||
|
// 输入短文本
|
||||||
|
const shortText = 'https://example.com';
|
||||||
|
const textarea = screen.getByPlaceholderText('请输入 URL 或文本内容,将自动生成二维码');
|
||||||
|
fireEvent.change(textarea, { target: { value: shortText } });
|
||||||
|
|
||||||
|
// 点击生成按钮
|
||||||
|
fireEvent.click(screen.getByText('生成二维码'));
|
||||||
|
|
||||||
|
// 验证显示完整文本
|
||||||
|
expect(screen.getByText(shortText)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('点击编辑按钮应该返回输入态', () => {
|
||||||
|
render(<QrCodePage />);
|
||||||
|
|
||||||
|
// 输入文本并生成
|
||||||
|
const textarea = screen.getByPlaceholderText('请输入 URL 或文本内容,将自动生成二维码');
|
||||||
|
fireEvent.change(textarea, { target: { value: 'https://example.com' } });
|
||||||
|
fireEvent.click(screen.getByText('生成二维码'));
|
||||||
|
|
||||||
|
// 点击编辑按钮
|
||||||
|
fireEvent.click(screen.getByText('编辑'));
|
||||||
|
|
||||||
|
// 验证返回输入态
|
||||||
|
expect(screen.getByText('输入 URL 或文本')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('生成二维码')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('编辑')).not.toBeInTheDocument();
|
||||||
|
// 输入态应该隐藏二维码预览
|
||||||
|
expect(screen.queryByTestId('qr-code-preview')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('返回编辑态应该保留上次输入的内容', () => {
|
||||||
|
render(<QrCodePage />);
|
||||||
|
|
||||||
|
// 输入文本并生成
|
||||||
|
const textarea = screen.getByPlaceholderText('请输入 URL 或文本内容,将自动生成二维码');
|
||||||
|
fireEvent.change(textarea, { target: { value: 'https://example.com' } });
|
||||||
|
fireEvent.click(screen.getByText('生成二维码'));
|
||||||
|
|
||||||
|
// 点击编辑按钮
|
||||||
|
fireEvent.click(screen.getByText('编辑'));
|
||||||
|
|
||||||
|
// 验证输入框保留了上次的内容
|
||||||
|
const textareaAfterEdit =
|
||||||
|
screen.getByPlaceholderText('请输入 URL 或文本内容,将自动生成二维码');
|
||||||
|
expect(textareaAfterEdit).toHaveValue('https://example.com');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,51 +1,114 @@
|
|||||||
|
import { Loader2, Pencil, QrCode } from 'lucide-react';
|
||||||
import TextInputArea from '@/components/TextInputArea';
|
import TextInputArea from '@/components/TextInputArea';
|
||||||
import QrCodePreview from '@/components/QrCodePreview';
|
import QrCodePreview from '@/components/QrCodePreview';
|
||||||
import { useI18n } from '@/utils/chromeI18n';
|
import { useI18n } from '@/utils/chromeI18n';
|
||||||
import { useQrCodeContext } from '../contexts/QrCodeContext';
|
import { useQrCodeContext } from '../contexts/QrCodeContext';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
/** 文本预览的最大显示字符数 */
|
||||||
|
const TEXT_PREVIEW_MAX_LENGTH = 80;
|
||||||
|
|
||||||
export default function GeneratePanel() {
|
export default function GeneratePanel() {
|
||||||
const { t } = useI18n('qrCode');
|
const { t } = useI18n('qrCode');
|
||||||
const { generatorState, setTextToEncode, downloadQrCode, copyQrCode } = useQrCodeContext();
|
const {
|
||||||
|
generatorState,
|
||||||
|
setTextToEncode,
|
||||||
|
confirmGenerate,
|
||||||
|
backToEdit,
|
||||||
|
downloadQrCode,
|
||||||
|
copyQrCode,
|
||||||
|
} = useQrCodeContext();
|
||||||
|
|
||||||
return (
|
const isInputStep = generatorState.step === 'input';
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 w-full items-stretch select-none p-0.5">
|
const hasText = generatorState.textToEncode.trim().length > 0;
|
||||||
<div
|
|
||||||
className={cn(
|
/** 截断文本用于预览显示 */
|
||||||
'border border-border rounded-xl bg-card text-card-foreground shadow-sm flex flex-col p-4',
|
const getTruncatedText = (text: string) => {
|
||||||
'focus-within:ring-1 focus-within:ring-ring focus-within:border-ring',
|
if (text.length <= TEXT_PREVIEW_MAX_LENGTH) return text;
|
||||||
)}
|
return text.slice(0, TEXT_PREVIEW_MAX_LENGTH) + '...';
|
||||||
>
|
};
|
||||||
<div className="flex flex-col space-y-2.5 h-full">
|
|
||||||
<Label className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider select-none pl-0.5">
|
// 输入态:只显示输入框和生成按钮
|
||||||
{t('qrCode:urlInputLabel')}
|
if (isInputStep) {
|
||||||
</Label>
|
return (
|
||||||
|
<div className="w-full 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',
|
||||||
|
'focus-within:ring-1 focus-within:ring-ring focus-within:border-ring',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col space-y-2.5">
|
||||||
|
<Label className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider select-none pl-0.5">
|
||||||
|
{t('qrCode:urlInputLabel')}
|
||||||
|
</Label>
|
||||||
|
|
||||||
<div className="flex-1 min-h-0">
|
|
||||||
<TextInputArea
|
<TextInputArea
|
||||||
value={generatorState.textToEncode}
|
value={generatorState.textToEncode}
|
||||||
onChange={setTextToEncode}
|
onChange={setTextToEncode}
|
||||||
placeholder={t('qrCode:urlInputPlaceholder')}
|
placeholder={t('qrCode:urlInputPlaceholder')}
|
||||||
showCount={true}
|
showCount={true}
|
||||||
showClear={true}
|
showClear={true}
|
||||||
allowCopy={true}
|
allowCopy={false}
|
||||||
minRows={6}
|
minRows={6}
|
||||||
maxRows={12}
|
maxRows={12}
|
||||||
externalError={generatorState.inputError || undefined}
|
externalError={generatorState.inputError || undefined}
|
||||||
onClear={() => setTextToEncode('')}
|
onClear={() => setTextToEncode('')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={confirmGenerate}
|
||||||
|
disabled={!hasText || generatorState.generating}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{generatorState.generating ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
{t('qrCode:generating')}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<QrCode className="w-4 h-4 mr-2" />
|
||||||
|
{t('qrCode:generateButton')}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
<div className="flex flex-col h-full">
|
// 预览态:显示文本预览 + 二维码(紧凑布局,适配popup窗口)
|
||||||
<QrCodePreview
|
return (
|
||||||
qrCodeDataUrl={generatorState.qrCodeDataUrl}
|
<div className="w-full select-none p-0.5 flex flex-col">
|
||||||
onDownload={downloadQrCode}
|
{/* 文本预览区域(固定高度,单行显示) */}
|
||||||
onCopy={copyQrCode}
|
<div className="border border-border rounded-xl bg-card text-card-foreground shadow-sm p-3 mb-3">
|
||||||
/>
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<Label className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider select-none pl-0.5">
|
||||||
|
{t('qrCode:textPreviewLabel')}
|
||||||
|
</Label>
|
||||||
|
<Button variant="ghost" size="sm" onClick={backToEdit} className="h-6 px-2 text-xs">
|
||||||
|
<Pencil className="w-3 h-3 mr-1" />
|
||||||
|
{t('qrCode:editButton')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="text-sm text-foreground bg-muted/50 rounded-md px-3 py-2 cursor-default line-clamp-1"
|
||||||
|
title={generatorState.savedText}
|
||||||
|
>
|
||||||
|
{getTruncatedText(generatorState.savedText)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 二维码预览区域(自适应剩余空间) */}
|
||||||
|
<QrCodePreview
|
||||||
|
qrCodeDataUrl={generatorState.qrCodeDataUrl}
|
||||||
|
onDownload={downloadQrCode}
|
||||||
|
onCopy={copyQrCode}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
import { useCallback, useEffect } from 'react';
|
import { useCallback, useEffect } from 'react';
|
||||||
|
import { RefreshCw } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import TextInputArea from '@/components/TextInputArea';
|
import TextInputArea from '@/components/TextInputArea';
|
||||||
import ImageUploader from '@/components/ImageUploader';
|
import ImageUploader from '@/components/ImageUploader';
|
||||||
import { useSnackbar } from '@/components/GlobalSnackbar';
|
|
||||||
import { useI18n } from '@/utils/chromeI18n';
|
import { useI18n } from '@/utils/chromeI18n';
|
||||||
import { useQrCodeContext } from '../contexts/QrCodeContext';
|
import { useQrCodeContext } from '../contexts/QrCodeContext';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
export default function ParsePanel() {
|
export default function ParsePanel() {
|
||||||
const { t } = useI18n('qrCode');
|
const { t } = useI18n('qrCode');
|
||||||
const { showMessage } = useSnackbar();
|
|
||||||
const { parserState, setParserState, handleFileChange, handleClearFile } = useQrCodeContext();
|
const { parserState, setParserState, handleFileChange, handleClearFile } = useQrCodeContext();
|
||||||
|
|
||||||
|
const hasFile = parserState.selectedFile !== null;
|
||||||
|
|
||||||
// 全局粘贴事件监听
|
// 全局粘贴事件监听
|
||||||
const handlePaste = useCallback(
|
const handlePaste = useCallback(
|
||||||
async (e: ClipboardEvent) => {
|
async (e: ClipboardEvent) => {
|
||||||
@@ -24,7 +27,7 @@ export default function ParsePanel() {
|
|||||||
const file = items[i].getAsFile();
|
const file = items[i].getAsFile();
|
||||||
if (file) {
|
if (file) {
|
||||||
handleFileChange(file);
|
handleFileChange(file);
|
||||||
showMessage(t('qrCode:imagePasted'), { severity: 'success', autoHideDuration: 1000 });
|
toast.success(t('qrCode:imagePasted'));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -38,14 +41,14 @@ export default function ParsePanel() {
|
|||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
const file = new File([blob], 'pasted-image.png', { type: blob.type });
|
const file = new File([blob], 'pasted-image.png', { type: blob.type });
|
||||||
handleFileChange(file);
|
handleFileChange(file);
|
||||||
showMessage(t('qrCode:imagePasted'), { severity: 'success', autoHideDuration: 1000 });
|
toast.success(t('qrCode:imagePasted'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('处理 Base64 图片失败:', error);
|
console.error('处理 Base64 图片失败:', error);
|
||||||
showMessage(t('qrCode:imagePasteError'), { severity: 'error', autoHideDuration: 3000 });
|
toast.error(t('qrCode:imagePasteError'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[handleFileChange, showMessage, t],
|
[handleFileChange, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -55,9 +58,10 @@ export default function ParsePanel() {
|
|||||||
};
|
};
|
||||||
}, [handlePaste]);
|
}, [handlePaste]);
|
||||||
|
|
||||||
return (
|
// 未上传图片:只显示上传区域
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 w-full items-stretch select-none p-0.5">
|
if (!hasFile) {
|
||||||
<div className="flex flex-col h-full">
|
return (
|
||||||
|
<div className="w-full select-none p-0.5">
|
||||||
<ImageUploader
|
<ImageUploader
|
||||||
selectedFile={parserState.selectedFile}
|
selectedFile={parserState.selectedFile}
|
||||||
onFileChange={handleFileChange}
|
onFileChange={handleFileChange}
|
||||||
@@ -68,30 +72,60 @@ export default function ParsePanel() {
|
|||||||
onDraggingChange={(dragging) => setParserState((prev) => ({ ...prev, dragging }))}
|
onDraggingChange={(dragging) => setParserState((prev) => ({ ...prev, dragging }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已上传图片:显示图片预览 + 解析结果
|
||||||
|
return (
|
||||||
|
<div className="w-full select-none p-0.5 flex flex-col">
|
||||||
|
{/* 图片预览区域(小图预览) */}
|
||||||
|
<div className="border border-border rounded-xl bg-card text-card-foreground shadow-sm p-3 mb-3">
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<Label className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider select-none pl-0.5">
|
||||||
|
{t('qrCode:uploadedImage')}
|
||||||
|
</Label>
|
||||||
|
<Button variant="ghost" size="sm" onClick={handleClearFile} className="h-6 px-2 text-xs">
|
||||||
|
<RefreshCw className="w-3 h-3 mr-1" />
|
||||||
|
{t('qrCode:reuploadButton')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 bg-muted/50 rounded-md p-2">
|
||||||
|
<img
|
||||||
|
src={parserState.previewUrl}
|
||||||
|
alt="Uploaded QR Code"
|
||||||
|
className="w-16 h-16 object-contain rounded"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm text-foreground truncate">{parserState.selectedFile?.name}</p>
|
||||||
|
{parserState.parsing && (
|
||||||
|
<p className="text-xs text-primary animate-pulse mt-1">{t('qrCode:parsing')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 解析结果区域 */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'border border-border rounded-xl bg-card text-card-foreground shadow-sm flex flex-col p-4',
|
'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',
|
'focus-within:ring-1 focus-within:ring-ring focus-within:border-ring',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col space-y-2.5 h-full">
|
<div className="flex flex-col space-y-2.5">
|
||||||
<Label className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider pl-0.5">
|
<Label className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider pl-0.5">
|
||||||
{t('qrCode:resultLabel')}
|
{t('qrCode:resultLabel')}
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
<div className="flex-1 min-h-0">
|
<TextInputArea
|
||||||
<TextInputArea
|
value={parserState.decodedResult}
|
||||||
value={parserState.decodedResult}
|
readOnly={true}
|
||||||
readOnly={true}
|
showClear={false}
|
||||||
showClear={false}
|
allowCopy={true}
|
||||||
allowCopy={true}
|
placeholder={parserState.parsing ? '' : t('qrCode:resultPlaceholder')}
|
||||||
placeholder=""
|
minRows={4}
|
||||||
minRows={6}
|
maxRows={8}
|
||||||
maxRows={12}
|
externalError={parserState.parseError || undefined}
|
||||||
externalError={parserState.parseError || undefined}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ export interface QrCodeContextValue {
|
|||||||
|
|
||||||
generatorState: QrCodeGeneratorState;
|
generatorState: QrCodeGeneratorState;
|
||||||
setTextToEncode: (text: string) => void;
|
setTextToEncode: (text: string) => void;
|
||||||
|
/** 显式触发生成二维码并切换到预览态 */
|
||||||
|
confirmGenerate: () => void;
|
||||||
|
/** 返回编辑态,保留上次输入内容 */
|
||||||
|
backToEdit: () => void;
|
||||||
|
|
||||||
downloadQrCode: () => void;
|
downloadQrCode: () => void;
|
||||||
copyQrCode: () => Promise<void>;
|
copyQrCode: () => Promise<void>;
|
||||||
|
|||||||
@@ -1,23 +1,93 @@
|
|||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import QRious from 'qrious';
|
import QRious from 'qrious';
|
||||||
import { useSnackbar } from '@/components/GlobalSnackbar';
|
import { toast } from 'sonner';
|
||||||
import { parseQrCodeFromFile } from '@/utils/qrCodeParser';
|
import { parseQrCodeFromFile } from '@/utils/qrCodeParser';
|
||||||
import { useContextMenuData } from '@/utils/useContextMenuData';
|
import { useContextMenuData } from '@/utils/useContextMenuData';
|
||||||
import { useI18n } from '@/utils/chromeI18n';
|
import { useI18n } from '@/utils/chromeI18n';
|
||||||
import { useDebounce } from '@/utils/useDebounce';
|
|
||||||
import type { QrCodeContextValue } from '../contexts/QrCodeContext';
|
import type { QrCodeContextValue } from '../contexts/QrCodeContext';
|
||||||
import type { QrCodeGeneratorState, QrCodeMode, QrCodeParserState } from '../types';
|
import type { QrCodeGeneratorState, QrCodeMode, QrCodeParserState } from '../types';
|
||||||
|
|
||||||
|
/** 防抖延迟时间(毫秒) */
|
||||||
|
const DEBOUNCE_DELAY = 500;
|
||||||
|
|
||||||
|
/** 检测文本是否为URL格式 */
|
||||||
|
function isUrl(text: string): boolean {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed) return false;
|
||||||
|
|
||||||
|
// 检查是否以 http:// 或 https:// 开头
|
||||||
|
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否为域名格式(包含.且不以特殊字符开头)
|
||||||
|
const domainPattern = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z]{2,})+([/:].*)?$/;
|
||||||
|
return domainPattern.test(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 检测URL是否为图片格式 */
|
||||||
|
function isImageUrl(url: string): boolean {
|
||||||
|
const trimmedUrl = url.trim().toLowerCase();
|
||||||
|
|
||||||
|
// 检查是否为 data:image 格式
|
||||||
|
if (trimmedUrl.startsWith('data:image/')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否包含图片扩展名
|
||||||
|
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg', '.ico'];
|
||||||
|
if (imageExtensions.some((ext) => trimmedUrl.includes(ext))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查URL路径中是否包含图片相关关键词
|
||||||
|
const imageKeywords = ['/image/', '/img/', '/photo/', '/pic/', '/upload/'];
|
||||||
|
if (imageKeywords.some((keyword) => trimmedUrl.includes(keyword))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成二维码的核心逻辑 */
|
||||||
|
function generateQrCodeDataUrl(text: string): string {
|
||||||
|
const trimmedText = text.trim();
|
||||||
|
if (!trimmedText) return '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
let url = trimmedText;
|
||||||
|
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||||
|
url = 'https://' + url;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDark = document.documentElement.classList.contains('dark');
|
||||||
|
|
||||||
|
const qr = new QRious({
|
||||||
|
value: url,
|
||||||
|
size: 260,
|
||||||
|
level: 'H',
|
||||||
|
foreground: isDark ? '#f3f4f6' : '#0f172a',
|
||||||
|
background: isDark ? 'transparent' : '#ffffff',
|
||||||
|
});
|
||||||
|
|
||||||
|
return qr.toDataURL();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('QR code generation failed:', error);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function useQrCode(): QrCodeContextValue {
|
export function useQrCode(): QrCodeContextValue {
|
||||||
const { t } = useI18n('qrCode');
|
const { t } = useI18n('qrCode');
|
||||||
const { showMessage } = useSnackbar();
|
|
||||||
|
|
||||||
const [mode, setMode] = useState<QrCodeMode>('generate');
|
const [mode, setMode] = useState<QrCodeMode>('generate');
|
||||||
|
|
||||||
const [generatorState, setGeneratorState] = useState<
|
const [generatorState, setGeneratorState] = useState<QrCodeGeneratorState>({
|
||||||
Omit<QrCodeGeneratorState, 'generating' | 'qrCodeDataUrl'>
|
step: 'input',
|
||||||
>({
|
|
||||||
textToEncode: '',
|
textToEncode: '',
|
||||||
|
savedText: '',
|
||||||
|
qrCodeDataUrl: '',
|
||||||
|
generating: false,
|
||||||
inputError: '',
|
inputError: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -30,52 +100,192 @@ export function useQrCode(): QrCodeContextValue {
|
|||||||
dragging: false,
|
dragging: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const debouncedTextToEncode = useDebounce(generatorState.textToEncode, 200);
|
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const qrCodeDataUrl = useMemo(() => {
|
// 清除防抖定时器
|
||||||
const text = debouncedTextToEncode.trim();
|
useEffect(() => {
|
||||||
if (!text) return '';
|
return () => {
|
||||||
|
if (debounceTimerRef.current) {
|
||||||
|
clearTimeout(debounceTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
try {
|
/** 自动检测URL并生成二维码 */
|
||||||
let url = text;
|
const autoGenerateIfUrl = useCallback(
|
||||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
(text: string) => {
|
||||||
url = 'https://' + url;
|
if (debounceTimerRef.current) {
|
||||||
|
clearTimeout(debounceTimerRef.current);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isDark = document.documentElement.classList.contains('dark');
|
if (!isUrl(text)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const qr = new QRious({
|
debounceTimerRef.current = setTimeout(() => {
|
||||||
value: url,
|
setGeneratorState((prev) => ({ ...prev, generating: true, inputError: '' }));
|
||||||
size: 260,
|
|
||||||
level: 'H',
|
|
||||||
foreground: isDark ? '#f3f4f6' : '#0f172a',
|
|
||||||
background: isDark ? 'transparent' : '#ffffff',
|
|
||||||
});
|
|
||||||
|
|
||||||
return qr.toDataURL();
|
const qrCodeDataUrl = generateQrCodeDataUrl(text);
|
||||||
} catch (error) {
|
|
||||||
console.error('QR code generation sync task failed:', error);
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}, [debouncedTextToEncode]);
|
|
||||||
|
|
||||||
const fullGeneratorState = useMemo<QrCodeGeneratorState>(
|
if (qrCodeDataUrl) {
|
||||||
() => ({
|
setGeneratorState((prev) => ({
|
||||||
...generatorState,
|
...prev,
|
||||||
qrCodeDataUrl,
|
step: 'preview',
|
||||||
generating: false,
|
savedText: text.trim(),
|
||||||
}),
|
qrCodeDataUrl,
|
||||||
[generatorState, qrCodeDataUrl],
|
generating: false,
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
setGeneratorState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
generating: false,
|
||||||
|
inputError: t('qrCode:generateError'),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, DEBOUNCE_DELAY);
|
||||||
|
},
|
||||||
|
[t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setTextToEncode = useCallback((text: string) => {
|
const setTextToEncode = useCallback(
|
||||||
setGeneratorState((prev) => ({ ...prev, textToEncode: text, inputError: '' }));
|
(text: string) => {
|
||||||
|
setGeneratorState((prev) => ({ ...prev, textToEncode: text, inputError: '' }));
|
||||||
|
autoGenerateIfUrl(text);
|
||||||
|
},
|
||||||
|
[autoGenerateIfUrl],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 手动触发生成二维码(用于非URL文本) */
|
||||||
|
const confirmGenerate = useCallback(() => {
|
||||||
|
const text = generatorState.textToEncode.trim();
|
||||||
|
|
||||||
|
if (!text) {
|
||||||
|
setGeneratorState((prev) => ({ ...prev, inputError: t('qrCode:inputRequired') }));
|
||||||
|
toast.error(t('qrCode:inputRequired'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setGeneratorState((prev) => ({ ...prev, generating: true, inputError: '' }));
|
||||||
|
|
||||||
|
const qrCodeDataUrl = generateQrCodeDataUrl(text);
|
||||||
|
|
||||||
|
if (!qrCodeDataUrl) {
|
||||||
|
setGeneratorState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
generating: false,
|
||||||
|
inputError: t('qrCode:generateError'),
|
||||||
|
}));
|
||||||
|
toast.error(t('qrCode:generateError'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setGeneratorState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
step: 'preview',
|
||||||
|
savedText: text,
|
||||||
|
qrCodeDataUrl,
|
||||||
|
generating: false,
|
||||||
|
}));
|
||||||
|
}, [generatorState.textToEncode, t]);
|
||||||
|
|
||||||
|
/** 返回编辑态,保留上次输入内容 */
|
||||||
|
const backToEdit = useCallback(() => {
|
||||||
|
if (debounceTimerRef.current) {
|
||||||
|
clearTimeout(debounceTimerRef.current);
|
||||||
|
}
|
||||||
|
setGeneratorState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
step: 'input',
|
||||||
|
textToEncode: prev.savedText,
|
||||||
|
qrCodeDataUrl: '',
|
||||||
|
inputError: '',
|
||||||
|
}));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleContextMenuData = useCallback((payload: string) => {
|
/** 从图片URL解析二维码 */
|
||||||
setMode('generate');
|
const parseQrCodeFromUrl = useCallback(
|
||||||
setGeneratorState((prev) => ({ ...prev, textToEncode: payload, inputError: '' }));
|
async (imageUrl: string) => {
|
||||||
}, []);
|
try {
|
||||||
|
setParserState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
parsing: true,
|
||||||
|
parseError: '',
|
||||||
|
decodedResult: '',
|
||||||
|
previewUrl: imageUrl,
|
||||||
|
selectedFile: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 从URL获取图片并转换为File对象
|
||||||
|
const response = await fetch(imageUrl);
|
||||||
|
const blob = await response.blob();
|
||||||
|
const file = new File([blob], 'qrcode-image.png', { type: blob.type });
|
||||||
|
|
||||||
|
setParserState((prev) => ({ ...prev, selectedFile: file }));
|
||||||
|
|
||||||
|
const result = await parseQrCodeFromFile(file);
|
||||||
|
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setParserState((prev) => ({ ...prev, decodedResult: result.data! }));
|
||||||
|
toast.success(t('qrCode:parseSuccess'));
|
||||||
|
} else {
|
||||||
|
const errorMsg = result.error || t('qrCode:noQrDetected');
|
||||||
|
setParserState((prev) => ({ ...prev, parseError: errorMsg }));
|
||||||
|
toast.error(errorMsg);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('解析图片二维码失败:', error);
|
||||||
|
const errorMsg = error instanceof Error ? error.message : t('qrCode:parseError');
|
||||||
|
setParserState((prev) => ({ ...prev, parseError: errorMsg }));
|
||||||
|
toast.error(errorMsg);
|
||||||
|
} finally {
|
||||||
|
setParserState((prev) => ({ ...prev, parsing: false }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 右键菜单传入URL时,自动生成二维码或解析图片 */
|
||||||
|
const handleContextMenuData = useCallback(
|
||||||
|
(payload: string) => {
|
||||||
|
// 检测是否为图片URL,如果是则切换到解析模式
|
||||||
|
if (isImageUrl(payload)) {
|
||||||
|
setMode('parse');
|
||||||
|
void parseQrCodeFromUrl(payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 非图片URL,生成二维码
|
||||||
|
setMode('generate');
|
||||||
|
|
||||||
|
// 直接生成二维码,无需等待
|
||||||
|
const qrCodeDataUrl = generateQrCodeDataUrl(payload);
|
||||||
|
|
||||||
|
if (qrCodeDataUrl) {
|
||||||
|
// 生成成功,直接跳转到预览态
|
||||||
|
setGeneratorState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
step: 'preview',
|
||||||
|
textToEncode: payload,
|
||||||
|
savedText: payload.trim(),
|
||||||
|
qrCodeDataUrl,
|
||||||
|
generating: false,
|
||||||
|
inputError: '',
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
// 生成失败,停留在输入态,显示文本供用户编辑
|
||||||
|
setGeneratorState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
step: 'input',
|
||||||
|
textToEncode: payload,
|
||||||
|
savedText: '',
|
||||||
|
qrCodeDataUrl: '',
|
||||||
|
generating: false,
|
||||||
|
inputError: '',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[parseQrCodeFromUrl],
|
||||||
|
);
|
||||||
|
|
||||||
useContextMenuData({ featureKey: 'qrCode', onData: handleContextMenuData });
|
useContextMenuData({ featureKey: 'qrCode', onData: handleContextMenuData });
|
||||||
|
|
||||||
@@ -89,39 +299,39 @@ export function useQrCode(): QrCodeContextValue {
|
|||||||
|
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
setParserState((prev) => ({ ...prev, decodedResult: result.data! }));
|
setParserState((prev) => ({ ...prev, decodedResult: result.data! }));
|
||||||
showMessage(t('qrCode:parseSuccess'), { severity: 'success', autoHideDuration: 1000 });
|
toast.success(t('qrCode:parseSuccess'));
|
||||||
} else {
|
} else {
|
||||||
const errorMsg = result.error || t('qrCode:noQrDetected');
|
const errorMsg = result.error || t('qrCode:noQrDetected');
|
||||||
setParserState((prev) => ({ ...prev, parseError: errorMsg }));
|
setParserState((prev) => ({ ...prev, parseError: errorMsg }));
|
||||||
showMessage(errorMsg, { severity: 'error', autoHideDuration: 3000 });
|
toast.error(errorMsg);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('解析二维码失败:', error);
|
console.error('解析二维码失败:', error);
|
||||||
const errorMsg = error instanceof Error ? error.message : t('qrCode:parseError');
|
const errorMsg = error instanceof Error ? error.message : t('qrCode:parseError');
|
||||||
setParserState((prev) => ({ ...prev, parseError: errorMsg }));
|
setParserState((prev) => ({ ...prev, parseError: errorMsg }));
|
||||||
showMessage(errorMsg, { severity: 'error', autoHideDuration: 3000 });
|
toast.error(errorMsg);
|
||||||
} finally {
|
} finally {
|
||||||
setParserState((prev) => ({ ...prev, parsing: false }));
|
setParserState((prev) => ({ ...prev, parsing: false }));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[t, showMessage],
|
[t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const downloadQrCode = useCallback(() => {
|
const downloadQrCode = useCallback(() => {
|
||||||
if (!qrCodeDataUrl) return;
|
if (!generatorState.qrCodeDataUrl) return;
|
||||||
|
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = qrCodeDataUrl;
|
link.href = generatorState.qrCodeDataUrl;
|
||||||
link.download = 'qrcode.png';
|
link.download = 'qrcode.png';
|
||||||
link.click();
|
link.click();
|
||||||
showMessage(t('qrCode:qrCodeDownloadSuccess'), { severity: 'success', autoHideDuration: 1000 });
|
toast.success(t('qrCode:qrCodeDownloadSuccess'));
|
||||||
}, [qrCodeDataUrl, showMessage, t]);
|
}, [generatorState.qrCodeDataUrl, t]);
|
||||||
|
|
||||||
const copyQrCode = useCallback(async () => {
|
const copyQrCode = useCallback(async () => {
|
||||||
if (!qrCodeDataUrl) return;
|
if (!generatorState.qrCodeDataUrl) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(qrCodeDataUrl);
|
const response = await fetch(generatorState.qrCodeDataUrl);
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
|
|
||||||
await navigator.clipboard.write([
|
await navigator.clipboard.write([
|
||||||
@@ -130,12 +340,12 @@ export function useQrCode(): QrCodeContextValue {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
showMessage(t('qrCode:qrCodeCopySuccess'), { severity: 'success', autoHideDuration: 1000 });
|
toast.success(t('qrCode:qrCodeCopySuccess'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('复制二维码失败:', error);
|
console.error('复制二维码失败:', error);
|
||||||
showMessage(t('qrCode:copyError'), { severity: 'error', autoHideDuration: 3000 });
|
toast.error(t('qrCode:copyError'));
|
||||||
}
|
}
|
||||||
}, [qrCodeDataUrl, showMessage, t]);
|
}, [generatorState.qrCodeDataUrl, t]);
|
||||||
|
|
||||||
const handleFileChange = useCallback(
|
const handleFileChange = useCallback(
|
||||||
(file: File) => {
|
(file: File) => {
|
||||||
@@ -179,8 +389,10 @@ export function useQrCode(): QrCodeContextValue {
|
|||||||
return {
|
return {
|
||||||
mode,
|
mode,
|
||||||
setMode,
|
setMode,
|
||||||
generatorState: fullGeneratorState,
|
generatorState,
|
||||||
setTextToEncode,
|
setTextToEncode,
|
||||||
|
confirmGenerate,
|
||||||
|
backToEdit,
|
||||||
parseQrCode,
|
parseQrCode,
|
||||||
downloadQrCode,
|
downloadQrCode,
|
||||||
copyQrCode,
|
copyQrCode,
|
||||||
|
|||||||
@@ -5,16 +5,23 @@
|
|||||||
/** 二维码功能核心主路由模式 */
|
/** 二维码功能核心主路由模式 */
|
||||||
export type QrCodeMode = 'generate' | 'parse';
|
export type QrCodeMode = 'generate' | 'parse';
|
||||||
|
|
||||||
|
/** 生成器的操作步骤 */
|
||||||
|
export type GeneratorStep = 'input' | 'preview';
|
||||||
|
|
||||||
/** * 二维码生成器的状态
|
/** * 二维码生成器的状态
|
||||||
* 💡 架构优化:保留与全局 Context 骨架契合的形态,
|
* 💡 架构优化:采用两步操作模式(输入态 → 预览态),
|
||||||
* 外部依然可以流畅读取这些状态,但在新架构下运行效率和稳定性大幅提升!
|
* 用户显式点击生成按钮后才生成二维码,体验更清晰!
|
||||||
*/
|
*/
|
||||||
export interface QrCodeGeneratorState {
|
export interface QrCodeGeneratorState {
|
||||||
|
/** 当前操作步骤:输入态或预览态 */
|
||||||
|
step: GeneratorStep;
|
||||||
/** 受控的输入源文本(支持 URL 或任意文本快照) */
|
/** 受控的输入源文本(支持 URL 或任意文本快照) */
|
||||||
textToEncode: string;
|
textToEncode: string;
|
||||||
/** 由防抖源文本流在单次渲染内存中同步派生出的二维码 Base64 Data URL */
|
/** 保存的文本快照,用于预览态展示和编辑态回填 */
|
||||||
|
savedText: string;
|
||||||
|
/** 由用户显式触发生成的二维码 Base64 Data URL */
|
||||||
qrCodeDataUrl: string;
|
qrCodeDataUrl: string;
|
||||||
/** 是否正在生成(流式架构下已默认为恒定 false 的非阻塞快照,保留作为 UI 骨架兼容) */
|
/** 是否正在生成 */
|
||||||
generating: boolean;
|
generating: boolean;
|
||||||
/** 输入文本校验或底层画布崩溃的错误提示信息 */
|
/** 输入文本校验或底层画布崩溃的错误提示信息 */
|
||||||
inputError: string;
|
inputError: string;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Shield, ShieldCheck, MousePointerClick, AlertTriangle } from 'lucide-re
|
|||||||
import { useRightClickRestorer } from './useRightClickRestorer';
|
import { useRightClickRestorer } from './useRightClickRestorer';
|
||||||
import { useI18n } from '@/utils/chromeI18n';
|
import { useI18n } from '@/utils/chromeI18n';
|
||||||
|
|
||||||
export default function RightClickRestorerPage() {
|
export default function Index() {
|
||||||
const { t } = useI18n('rightClickRestorer');
|
const { t } = useI18n('rightClickRestorer');
|
||||||
const { domain, isLoading, isUnlocked, isUnsupported, unlock } = useRightClickRestorer();
|
const { domain, isLoading, isUnlocked, isUnsupported, unlock } = useRightClickRestorer();
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { MessageAction, sendMessageToContent } from '@/utils/messages';
|
import { MessageAction, sendMessageToContent } from '@/utils/messages';
|
||||||
|
|
||||||
const UNSUPPORTED_PROTOCOLS = new Set([
|
const UNSUPPORTED_PROTOCOLS = new Set([
|
||||||
@@ -39,17 +39,10 @@ export function useRightClickRestorer(): UseRightClickRestorerReturn {
|
|||||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||||
const url = tab?.url;
|
const url = tab?.url;
|
||||||
|
|
||||||
if (url) {
|
setDomain(url ? new URL(url).hostname : '');
|
||||||
try {
|
|
||||||
setDomain(new URL(url).hostname);
|
|
||||||
} catch {
|
|
||||||
setDomain('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isUnsupportedPage(url)) {
|
if (isUnsupportedPage(url)) {
|
||||||
setIsUnsupported(true);
|
setIsUnsupported(true);
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +60,7 @@ export function useRightClickRestorer(): UseRightClickRestorerReturn {
|
|||||||
void load();
|
void load();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const unlock = useCallback(async () => {
|
async function unlock() {
|
||||||
if (isUnsupported) return;
|
if (isUnsupported) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -78,7 +71,7 @@ export function useRightClickRestorer(): UseRightClickRestorerReturn {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[RightClickRestorer] Failed to unlock:', err);
|
console.error('[RightClickRestorer] Failed to unlock:', err);
|
||||||
}
|
}
|
||||||
}, [isUnsupported]);
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
domain,
|
domain,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* 自动刷新开关组件
|
* 自动刷新开关行
|
||||||
*
|
*
|
||||||
* 用于 StorageCleaner 页面,控制是否自动刷新存储数据列表。
|
* 用于 StorageCleaner 页面的操作区域内(非独立卡片),
|
||||||
* 以卡片形式展示,左侧为标签文本,右侧为 shadcn/ui Switch 开关。
|
* 控制清理后是否自动刷新页面。左侧为标签文本,右侧为 shadcn/ui Switch 开关。
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```tsx
|
* ```tsx
|
||||||
@@ -48,15 +48,14 @@ export default function AutoRefreshToggle({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full p-4 rounded-xl border border-border bg-card text-card-foreground shadow-sm flex justify-between items-center',
|
'w-full px-3.5 py-3 border-t border-border flex justify-between items-center',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{/* Label 与 Switch 通过 htmlFor + id 关联,支持点击文字触发开关 */}
|
|
||||||
<Label
|
<Label
|
||||||
htmlFor="auto-refresh-switch"
|
htmlFor="auto-refresh-switch"
|
||||||
className="text-sm font-bold text-foreground cursor-pointer select-none tracking-tight"
|
className="text-xs font-bold text-muted-foreground/90 cursor-pointer select-none tracking-wide uppercase"
|
||||||
>
|
>
|
||||||
{t('storageCleaner:autoRefresh')}
|
{t('storageCleaner:autoRefresh')}
|
||||||
</Label>
|
</Label>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export default function CleaningResult({ result, className, ...props }: Cleaning
|
|||||||
|
|
||||||
if (!result) return null;
|
if (!result) return null;
|
||||||
|
|
||||||
const isSuccess = result.success;
|
const isSuccess = result.overallSuccess;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('w-full', className)} {...props}>
|
<div className={cn('w-full', className)} {...props}>
|
||||||
|
|||||||
@@ -1,59 +1,63 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { formatSize } from '@/utils/storageCleaner';
|
import { formatBytes } from '@/utils/format';
|
||||||
import { useI18n } from '@/utils/chromeI18n';
|
import { useI18n } from '@/utils/chromeI18n';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
// 引入官方的 Checkbox 原子组件
|
import type { StorageSizeInfo } from './useStorageCleaner';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
|
||||||
interface OptionItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
interface OptionItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
labelKey: string;
|
labelKey: string;
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
size?: number;
|
sizeInfo?: StorageSizeInfo;
|
||||||
isCount?: boolean;
|
|
||||||
onChange: () => void;
|
onChange: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function OptionItem({
|
export default function OptionItem({
|
||||||
labelKey,
|
labelKey,
|
||||||
checked,
|
checked,
|
||||||
size,
|
sizeInfo,
|
||||||
isCount = false,
|
|
||||||
onChange,
|
onChange,
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: OptionItemProps) {
|
}: OptionItemProps) {
|
||||||
const { t } = useI18n('storageCleaner');
|
const { t } = useI18n('storageCleaner');
|
||||||
|
|
||||||
|
const sizeValue = sizeInfo?.value;
|
||||||
|
const isCount = sizeInfo?.displayType === 'count';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={onChange}
|
onClick={onChange}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex justify-between items-center py-2.5 px-3.5 rounded-xl border cursor-pointer select-none',
|
'flex justify-between items-center py-2.5 px-3.5 rounded-lg border cursor-pointer select-none transition-colors',
|
||||||
checked
|
checked
|
||||||
? 'bg-primary/5 border-primary/30 shadow-sm'
|
? 'bg-primary/5 border-primary/30 shadow-sm'
|
||||||
: 'bg-transparent border-transparent hover:bg-muted/70',
|
: 'bg-transparent border-transparent hover:bg-muted/50',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{/* 左侧数据区域 */}
|
<div className="flex-1 min-w-0 mr-3">
|
||||||
<div className="flex-1 min-w-0 mr-4">
|
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'block text-xs font-semibold leading-tight truncate',
|
'block text-xs font-semibold leading-tight truncate transition-colors',
|
||||||
checked ? 'text-foreground font-bold' : 'text-foreground/80',
|
checked ? 'text-foreground' : 'text-foreground/75',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{t(labelKey)}
|
{t(labelKey)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* 底部容量大小或计数标识 */}
|
{sizeValue !== undefined && sizeValue > 0 ? (
|
||||||
{size !== undefined && size > 0 ? (
|
<span
|
||||||
<span className="block text-[10px] font-mono font-medium text-muted-foreground/80 mt-0.5 tabular-nums">
|
className={cn(
|
||||||
{isCount ? `${size} ${t('storageCleaner:countUnit')}` : formatSize(size)}
|
'block text-[10px] font-mono font-medium mt-0.5 tabular-nums transition-colors',
|
||||||
|
checked ? 'text-primary/70' : 'text-muted-foreground/70',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isCount ? `${sizeValue} ${t('storageCleaner:countUnit')}` : formatBytes(sizeValue)}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="block text-[10px] font-medium text-muted-foreground/60 mt-0.5 italic">
|
<span className="block text-[10px] font-medium text-muted-foreground/50 mt-0.5 italic">
|
||||||
{t('storageCleaner:noData')}
|
{t('storageCleaner:noData')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { StorageCleanerOptions } from '@/types/storage';
|
import type { StorageCleanerOptions } from '@/types/storage';
|
||||||
|
import type { StorageSizeInfo } from './useStorageCleaner';
|
||||||
import OptionItem from './OptionItem';
|
import OptionItem from './OptionItem';
|
||||||
import { useI18n } from '@/utils/chromeI18n';
|
import { useI18n } from '@/utils/chromeI18n';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -8,7 +9,7 @@ import { Label } from '@/components/ui/label';
|
|||||||
|
|
||||||
interface StorageOptionsGridProps extends React.HTMLAttributes<HTMLDivElement> {
|
interface StorageOptionsGridProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
options: StorageCleanerOptions;
|
options: StorageCleanerOptions;
|
||||||
sizes: Record<string, number>;
|
sizes: Record<string, StorageSizeInfo>;
|
||||||
allSelected: boolean;
|
allSelected: boolean;
|
||||||
someSelected: boolean;
|
someSelected: boolean;
|
||||||
onOptionChange: (key: keyof StorageCleanerOptions) => void;
|
onOptionChange: (key: keyof StorageCleanerOptions) => void;
|
||||||
@@ -27,13 +28,13 @@ export default function StorageOptionsGrid({
|
|||||||
}: StorageOptionsGridProps) {
|
}: StorageOptionsGridProps) {
|
||||||
const { t } = useI18n('storageCleaner');
|
const { t } = useI18n('storageCleaner');
|
||||||
|
|
||||||
const optionKeys: { key: keyof StorageCleanerOptions; isCount?: boolean }[] = [
|
const optionKeys: (keyof StorageCleanerOptions)[] = [
|
||||||
{ key: 'localStorage' },
|
'localStorage',
|
||||||
{ key: 'sessionStorage' },
|
'sessionStorage',
|
||||||
{ key: 'indexedDB' },
|
'indexedDB',
|
||||||
{ key: 'cookies' },
|
'cookies',
|
||||||
{ key: 'cacheStorage', isCount: true },
|
'cacheStorage',
|
||||||
{ key: 'serviceWorkers', isCount: true },
|
'serviceWorkers',
|
||||||
];
|
];
|
||||||
|
|
||||||
const handleToggleAll = () => {
|
const handleToggleAll = () => {
|
||||||
@@ -41,22 +42,15 @@ export default function StorageOptionsGrid({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={cn('w-full overflow-hidden', className)} {...props}>
|
||||||
className={cn(
|
<div className="px-3.5 pt-3.5 pb-2">
|
||||||
'w-full rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden',
|
<div className="grid grid-cols-2 gap-2 items-stretch">
|
||||||
className,
|
{optionKeys.map((key) => (
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<div className="p-3">
|
|
||||||
<div className="grid grid-cols-2 gap-2.5 items-stretch">
|
|
||||||
{optionKeys.map(({ key, isCount }) => (
|
|
||||||
<OptionItem
|
<OptionItem
|
||||||
key={key}
|
key={key}
|
||||||
labelKey={`storageCleaner:options.${key}`}
|
labelKey={`storageCleaner:options.${key}`}
|
||||||
checked={options[key]}
|
checked={options[key]}
|
||||||
size={sizes[key]}
|
sizeInfo={sizes[key]}
|
||||||
isCount={isCount}
|
|
||||||
onChange={() => onOptionChange(key)}
|
onChange={() => onOptionChange(key)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -65,9 +59,9 @@ export default function StorageOptionsGrid({
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
onClick={handleToggleAll}
|
onClick={handleToggleAll}
|
||||||
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"
|
className="border-t border-border flex justify-between items-center pl-3.5 pr-7 py-2.5 bg-muted/20 hover:bg-muted/40 cursor-pointer select-none transition-colors"
|
||||||
>
|
>
|
||||||
<Label className="text-xs font-bold text-muted-foreground/90 cursor-pointer">
|
<Label className="text-xs font-bold text-muted-foreground/90 cursor-pointer tracking-wide uppercase">
|
||||||
{t('storageCleaner:selectAll')}
|
{t('storageCleaner:selectAll')}
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
@@ -75,7 +69,7 @@ export default function StorageOptionsGrid({
|
|||||||
checked={allSelected ? true : someSelected ? 'indeterminate' : false}
|
checked={allSelected ? true : someSelected ? 'indeterminate' : false}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onCheckedChange={(checked) => onSelectAll(checked === true)}
|
onCheckedChange={(checked) => onSelectAll(checked === true)}
|
||||||
className="h-4 w-4 shrink-0 rounded border-input data-[state=checked]:bg-primary data-[state=indeterminate]:bg-primary"
|
className="h-4 w-4 shrink-0 rounded border-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=indeterminate]:bg-muted-foreground/40"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import StorageCleanerConfirm from '@/pages/StorageCleaner/StorageCleanerConfirm';
|
import StorageCleanerConfirm from './StorageCleanerConfirm';
|
||||||
import { useStorageCleaner } from './useStorageCleaner';
|
import { useStorageCleaner } from './useStorageCleaner';
|
||||||
import StorageOptionsGrid from './StorageOptionsGrid';
|
import StorageOptionsGrid from './StorageOptionsGrid';
|
||||||
import AutoRefreshToggle from './AutoRefreshToggle';
|
import AutoRefreshToggle from './AutoRefreshToggle';
|
||||||
@@ -47,37 +47,42 @@ export default function Index() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 w-full flex flex-col space-y-3.5">
|
<div className="p-4 w-full flex flex-col space-y-4 select-none">
|
||||||
<StorageOptionsGrid
|
{/* 操作区域卡片 */}
|
||||||
options={options}
|
<div className="w-full rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden">
|
||||||
sizes={sizes}
|
<StorageOptionsGrid
|
||||||
allSelected={allSelected}
|
options={options}
|
||||||
someSelected={someSelected}
|
sizes={sizes}
|
||||||
onOptionChange={handleOptionChange}
|
allSelected={allSelected}
|
||||||
onSelectAll={handleSelectAll}
|
someSelected={someSelected}
|
||||||
/>
|
onOptionChange={handleOptionChange}
|
||||||
|
onSelectAll={handleSelectAll}
|
||||||
|
/>
|
||||||
|
|
||||||
<AutoRefreshToggle
|
<AutoRefreshToggle
|
||||||
reloadAfterClean={reloadAfterClean}
|
reloadAfterClean={reloadAfterClean}
|
||||||
onChange={handleReloadAfterCleanChange}
|
onChange={handleReloadAfterCleanChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button
|
<div className="px-3.5 pb-3.5 pt-1">
|
||||||
variant="destructive"
|
<Button
|
||||||
size="default"
|
variant="destructive"
|
||||||
onClick={() => setShowConfirm(true)}
|
size="default"
|
||||||
disabled={isButtonDisabled}
|
onClick={() => setShowConfirm(true)}
|
||||||
className="w-full h-10 font-bold shadow-sm text-sm tracking-wide"
|
disabled={isButtonDisabled}
|
||||||
>
|
className="w-full h-10 font-bold shadow-sm text-sm tracking-wide"
|
||||||
{loading ? (
|
>
|
||||||
<>
|
{loading ? (
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<>
|
||||||
{t('storageCleaner:cleaning')}
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
</>
|
{t('storageCleaner:cleaning')}
|
||||||
) : (
|
</>
|
||||||
t('storageCleaner:cleanNow')
|
) : (
|
||||||
)}
|
t('storageCleaner:cleanNow')
|
||||||
</Button>
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<CleaningResult result={result} />
|
<CleaningResult result={result} />
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
getSessionStorageSize,
|
getSessionStorageSize,
|
||||||
isRestrictedUrl,
|
isRestrictedUrl,
|
||||||
} from '@/utils/storageCleaner';
|
} from '@/utils/storageCleaner';
|
||||||
import { MessageAction, sendMessage } from '@/utils/messages';
|
|
||||||
import { useI18n } from '@/utils/chromeI18n';
|
import { useI18n } from '@/utils/chromeI18n';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
@@ -34,18 +33,22 @@ const DEFAULT_PREFERENCES: StorageCleanerPreferences = {
|
|||||||
selectedTypes: DEFAULT_OPTIONS,
|
selectedTypes: DEFAULT_OPTIONS,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface StorageSizeInfo {
|
||||||
|
value: number;
|
||||||
|
displayType: 'bytes' | 'count';
|
||||||
|
}
|
||||||
|
|
||||||
export interface UseStorageCleanerReturn {
|
export interface UseStorageCleanerReturn {
|
||||||
domain: string;
|
|
||||||
error: string;
|
error: string;
|
||||||
isInitializing: boolean;
|
isInitializing: boolean;
|
||||||
options: StorageCleanerOptions;
|
options: StorageCleanerOptions;
|
||||||
sizes: Record<string, number>;
|
sizes: Record<string, StorageSizeInfo>;
|
||||||
reloadAfterClean: boolean;
|
reloadAfterClean: boolean;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
result: CleaningResult | null;
|
result: CleaningResult | null;
|
||||||
showConfirm: boolean;
|
showConfirm: boolean;
|
||||||
setShowConfirm: (show: boolean) => void;
|
setShowConfirm: (show: boolean) => void;
|
||||||
totalSize: number;
|
totalBytes: number;
|
||||||
allSelected: boolean;
|
allSelected: boolean;
|
||||||
someSelected: boolean;
|
someSelected: boolean;
|
||||||
|
|
||||||
@@ -57,11 +60,10 @@ export interface UseStorageCleanerReturn {
|
|||||||
|
|
||||||
export function useStorageCleaner(): UseStorageCleanerReturn {
|
export function useStorageCleaner(): UseStorageCleanerReturn {
|
||||||
const { t } = useI18n(['storageCleaner', 'common']);
|
const { t } = useI18n(['storageCleaner', 'common']);
|
||||||
const [domain, setDomain] = useState<string>('');
|
|
||||||
const [error, setError] = useState<string>('');
|
const [error, setError] = useState<string>('');
|
||||||
const [isInitializing, setIsInitializing] = useState<boolean>(true);
|
const [isInitializing, setIsInitializing] = useState<boolean>(true);
|
||||||
const [options, setOptions] = useState<StorageCleanerOptions>(DEFAULT_OPTIONS);
|
const [options, setOptions] = useState<StorageCleanerOptions>(DEFAULT_OPTIONS);
|
||||||
const [sizes, setSizes] = useState<Record<string, number>>({});
|
const [sizes, setSizes] = useState<Record<string, StorageSizeInfo>>({});
|
||||||
const [reloadAfterClean, setReloadAfterClean] = useState<boolean>(true);
|
const [reloadAfterClean, setReloadAfterClean] = useState<boolean>(true);
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
const [result, setResult] = useState<CleaningResult | null>(null);
|
const [result, setResult] = useState<CleaningResult | null>(null);
|
||||||
@@ -102,7 +104,6 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
|
|||||||
setError('');
|
setError('');
|
||||||
const url = tab.url;
|
const url = tab.url;
|
||||||
const tabId = tab.id!;
|
const tabId = tab.id!;
|
||||||
setDomain(new URL(url).hostname);
|
|
||||||
|
|
||||||
const [savedPrefs, cSize, lsSize, ssSize, idbSize, cacheCount, swCount] = await Promise.all([
|
const [savedPrefs, cSize, lsSize, ssSize, idbSize, cacheCount, swCount] = await Promise.all([
|
||||||
storageUtil.get('storageCleaner/preferences', DEFAULT_PREFERENCES),
|
storageUtil.get('storageCleaner/preferences', DEFAULT_PREFERENCES),
|
||||||
@@ -122,12 +123,12 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setSizes({
|
setSizes({
|
||||||
cookies: cSize,
|
cookies: { value: cSize, displayType: 'bytes' },
|
||||||
localStorage: lsSize,
|
localStorage: { value: lsSize, displayType: 'bytes' },
|
||||||
sessionStorage: ssSize,
|
sessionStorage: { value: ssSize, displayType: 'bytes' },
|
||||||
indexedDB: idbSize,
|
indexedDB: { value: idbSize, displayType: 'bytes' },
|
||||||
cacheStorage: cacheCount,
|
cacheStorage: { value: cacheCount, displayType: 'count' },
|
||||||
serviceWorkers: swCount,
|
serviceWorkers: { value: swCount, displayType: 'count' },
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
if (currentRequestId === requestIdRef.current) {
|
if (currentRequestId === requestIdRef.current) {
|
||||||
@@ -217,9 +218,9 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
|
|||||||
const cleaningResult = await clearStorage(tab.id, tab.url, options);
|
const cleaningResult = await clearStorage(tab.id, tab.url, options);
|
||||||
setResult(cleaningResult);
|
setResult(cleaningResult);
|
||||||
|
|
||||||
if (reloadAfterClean && cleaningResult.success) {
|
if (reloadAfterClean && cleaningResult.overallSuccess) {
|
||||||
toast.success(t('storageCleaner:cleanSuccessReload'));
|
toast.success(t('storageCleaner:cleanSuccessReload'));
|
||||||
await sendMessage(MessageAction.RELOAD_TAB, { tabId: tab.id, delay: 1000 });
|
await chrome.tabs.reload(tab.id);
|
||||||
} else {
|
} else {
|
||||||
await loadInfo();
|
await loadInfo();
|
||||||
}
|
}
|
||||||
@@ -231,13 +232,10 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
|
|||||||
}
|
}
|
||||||
}, [options, reloadAfterClean, loadInfo, t]);
|
}, [options, reloadAfterClean, loadInfo, t]);
|
||||||
|
|
||||||
const totalSize = useMemo(() => {
|
const totalBytes = useMemo(() => {
|
||||||
return (
|
return Object.values(sizes).reduce((acc, s) => {
|
||||||
(sizes.cookies || 0) +
|
return s.displayType === 'bytes' ? acc + (s.value || 0) : acc;
|
||||||
(sizes.localStorage || 0) +
|
}, 0);
|
||||||
(sizes.sessionStorage || 0) +
|
|
||||||
(sizes.indexedDB || 0)
|
|
||||||
);
|
|
||||||
}, [sizes]);
|
}, [sizes]);
|
||||||
|
|
||||||
const selectionMetrics = useMemo(() => {
|
const selectionMetrics = useMemo(() => {
|
||||||
@@ -248,7 +246,6 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
|
|||||||
}, [options]);
|
}, [options]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
domain,
|
|
||||||
error,
|
error,
|
||||||
isInitializing,
|
isInitializing,
|
||||||
options,
|
options,
|
||||||
@@ -258,7 +255,7 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
|
|||||||
result,
|
result,
|
||||||
showConfirm,
|
showConfirm,
|
||||||
setShowConfirm,
|
setShowConfirm,
|
||||||
totalSize,
|
totalBytes,
|
||||||
allSelected: selectionMetrics.all,
|
allSelected: selectionMetrics.all,
|
||||||
someSelected: selectionMetrics.some,
|
someSelected: selectionMetrics.some,
|
||||||
handleReloadAfterCleanChange,
|
handleReloadAfterCleanChange,
|
||||||
|
|||||||
@@ -1,32 +1,22 @@
|
|||||||
import { useCallback, useMemo, useState } from 'react';
|
|
||||||
import TextInputArea from '@/components/TextInputArea';
|
import TextInputArea from '@/components/TextInputArea';
|
||||||
import { formatByteSize, getTextStats } from '@/utils/textStatistics';
|
import { formatBytes } from '@/utils/format';
|
||||||
import { useI18n } from '@/utils/chromeI18n';
|
import { useI18n } from '@/utils/chromeI18n';
|
||||||
import { useContextMenuData } from '@/utils/useContextMenuData';
|
import { useTextStatistics } from './useTextStatistics';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
export default function Index() {
|
export default function Index() {
|
||||||
const { t } = useI18n('textStatistics');
|
const { t } = useI18n('textStatistics');
|
||||||
const [text, setText] = useState('');
|
const { text, stats, setText } = useTextStatistics();
|
||||||
|
|
||||||
const handleContextMenuData = useCallback((payload: string) => {
|
|
||||||
setText(payload);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useContextMenuData({ featureKey: 'textStatistics', onData: handleContextMenuData });
|
|
||||||
|
|
||||||
const stats = useMemo(() => getTextStats(text), [text]);
|
|
||||||
|
|
||||||
const statItems = [
|
const statItems = [
|
||||||
{ label: t('textStatistics:characters'), value: stats.characters },
|
{ label: t('textStatistics:characters'), value: stats.characters },
|
||||||
{ label: t('textStatistics:words'), value: stats.words },
|
{ label: t('textStatistics:words'), value: stats.words },
|
||||||
{ label: t('textStatistics:lines'), value: stats.lines },
|
{ label: t('textStatistics:lines'), value: stats.lines },
|
||||||
{ label: t('textStatistics:bytes'), value: formatByteSize(stats.bytes) },
|
{ label: t('textStatistics:bytes'), value: formatBytes(stats.bytes) },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 w-full space-y-4">
|
<div className="p-4 w-full space-y-4">
|
||||||
{/* 文本输入区域 */}
|
|
||||||
<TextInputArea
|
<TextInputArea
|
||||||
value={text}
|
value={text}
|
||||||
onChange={setText}
|
onChange={setText}
|
||||||
@@ -37,7 +27,6 @@ export default function Index() {
|
|||||||
allowCopy={true}
|
allowCopy={true}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 统计结果展示区域 */}
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
{statItems.map((item) => (
|
{statItems.map((item) => (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import { getTextStats, type TextStats } from '@/utils/textStatistics';
|
||||||
|
import { useContextMenuData } from '@/utils/useContextMenuData';
|
||||||
|
|
||||||
|
export interface UseTextStatisticsReturn {
|
||||||
|
text: string;
|
||||||
|
stats: TextStats;
|
||||||
|
setText: (text: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTextStatistics(): UseTextStatisticsReturn {
|
||||||
|
const [text, setText] = useState('');
|
||||||
|
|
||||||
|
const handleContextMenuData = useCallback((payload: string) => {
|
||||||
|
setText(payload);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useContextMenuData({ featureKey: 'textStatistics', onData: handleContextMenuData });
|
||||||
|
|
||||||
|
const stats = getTextStats(text);
|
||||||
|
|
||||||
|
return { text, stats, setText };
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Clock } from 'lucide-react';
|
import { Clock } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import CopyButton from '@/components/CopyButton';
|
import CopyButton from '@/components/CopyButton';
|
||||||
import { useSnackbar } from '@/components/GlobalSnackbar';
|
|
||||||
import type { UnitType } from './constants';
|
import type { UnitType } from './constants';
|
||||||
|
import { msToUnit } from './constants';
|
||||||
import { useI18n } from '@/utils/chromeI18n';
|
import { useI18n } from '@/utils/chromeI18n';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -11,42 +12,20 @@ interface LiveClockProps extends React.HTMLAttributes<HTMLDivElement> {
|
|||||||
onUseNow: (val: number) => void;
|
onUseNow: (val: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LiveClock = React.memo(({ unit, onUseNow, className, ...props }: LiveClockProps) => {
|
export default function LiveClock({ unit, onUseNow, className, ...props }: LiveClockProps) {
|
||||||
const { t } = useI18n('timestamp');
|
const { t } = useI18n('timestamp');
|
||||||
const { showMessage } = useSnackbar();
|
|
||||||
const onUseNowRef = useRef(onUseNow);
|
|
||||||
|
|
||||||
const [currentDisplay, setCurrentDisplay] = useState(() => {
|
const [rawTime, setRawTime] = useState(() => Date.now());
|
||||||
const initNow = Date.now();
|
|
||||||
return {
|
|
||||||
rawTime: initNow,
|
|
||||||
text: String(Math.floor(initNow / (unit === 'ms' ? 1 : 1000))),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onUseNowRef.current = onUseNow;
|
|
||||||
}, [onUseNow]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
const rightNow = Date.now();
|
setRawTime(Date.now());
|
||||||
const nextText = String(Math.floor(rightNow / (unit === 'ms' ? 1 : 1000)));
|
|
||||||
|
|
||||||
setCurrentDisplay((prev) => {
|
|
||||||
if (prev.text === nextText) return prev;
|
|
||||||
return { rawTime: rightNow, text: nextText };
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const tickId = setInterval(tick, 200);
|
const tickId = setInterval(tick, 200);
|
||||||
return () => clearInterval(tickId);
|
return () => clearInterval(tickId);
|
||||||
}, [unit]);
|
}, [unit]);
|
||||||
|
|
||||||
const handleUseNow = useCallback(() => {
|
const text = String(msToUnit(rawTime, unit));
|
||||||
onUseNowRef.current(currentDisplay.rawTime);
|
|
||||||
showMessage?.(t('timestamp:usedSuccess'), { severity: 'success' });
|
|
||||||
}, [currentDisplay.rawTime, showMessage, t]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -61,12 +40,15 @@ const LiveClock = React.memo(({ unit, onUseNow, className, ...props }: LiveClock
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span className="flex-1 font-mono font-bold text-foreground text-sm tracking-tight leading-none truncate tabular-nums">
|
<span className="flex-1 font-mono font-bold text-foreground text-sm tracking-tight leading-none truncate tabular-nums">
|
||||||
{currentDisplay.text}
|
{text}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleUseNow}
|
onClick={() => {
|
||||||
|
onUseNow(rawTime);
|
||||||
|
toast.success(t('timestamp:usedSuccess'));
|
||||||
|
}}
|
||||||
title={t('timestamp:useNowTooltip')}
|
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 hover:bg-accent hover:text-foreground 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"
|
||||||
>
|
>
|
||||||
@@ -74,14 +56,10 @@ const LiveClock = React.memo(({ unit, onUseNow, className, ...props }: LiveClock
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<CopyButton
|
<CopyButton
|
||||||
text={currentDisplay.text}
|
text={text}
|
||||||
tooltip={t('timestamp:copyTsTooltip')}
|
tooltip={t('timestamp:copyTsTooltip')}
|
||||||
className="h-7 w-7 rounded-md border"
|
className="h-7 w-7 rounded-md border"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
|
||||||
LiveClock.displayName = 'LiveClock';
|
|
||||||
|
|
||||||
export default LiveClock;
|
|
||||||
|
|||||||
@@ -1,119 +1,52 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React from 'react';
|
||||||
import dayjs from '@/utils/dayjs';
|
|
||||||
import CopyButton from '@/components/CopyButton';
|
import CopyButton from '@/components/CopyButton';
|
||||||
import type { UnitType } from './constants';
|
|
||||||
import { DATE_FORMAT } from './constants';
|
|
||||||
import { useI18n } from '@/utils/chromeI18n';
|
import { useI18n } from '@/utils/chromeI18n';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface ResultViewProps extends React.HTMLAttributes<HTMLDivElement> {
|
interface ResultViewProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
result: string;
|
result: string;
|
||||||
mode: 'ts2dt' | 'dt2ts';
|
|
||||||
unit: UnitType;
|
|
||||||
zone: string;
|
|
||||||
|
|
||||||
showEmptyPlaceholder?: boolean;
|
showEmptyPlaceholder?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ResultView = React.memo(
|
export default function ResultView({
|
||||||
({
|
result,
|
||||||
result,
|
showEmptyPlaceholder = false,
|
||||||
mode,
|
className,
|
||||||
unit,
|
...props
|
||||||
zone,
|
}: ResultViewProps) {
|
||||||
showEmptyPlaceholder = false,
|
const { t } = useI18n('timestamp');
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: ResultViewProps) => {
|
|
||||||
const { t } = useI18n('timestamp');
|
|
||||||
|
|
||||||
const extraInfo = useMemo(() => {
|
|
||||||
if (!result) return null;
|
|
||||||
const d =
|
|
||||||
mode === 'ts2dt'
|
|
||||||
? dayjs(result, DATE_FORMAT).tz(zone)
|
|
||||||
: unit === 'ms'
|
|
||||||
? dayjs(Number(result))
|
|
||||||
: dayjs.unix(Number(result));
|
|
||||||
|
|
||||||
return {
|
|
||||||
relative: d.fromNow(),
|
|
||||||
iso: d.toISOString(),
|
|
||||||
utc: d.utc().format(DATE_FORMAT) + ' UTC',
|
|
||||||
};
|
|
||||||
}, [result, mode, zone, unit]);
|
|
||||||
|
|
||||||
if (!result) {
|
|
||||||
if (!showEmptyPlaceholder) return null;
|
|
||||||
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]',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{t('timestamp:resultEmpty')}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
if (!showEmptyPlaceholder) return null;
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex flex-col w-full', className)} {...props}>
|
<div
|
||||||
{/* 顶部小标签 */}
|
className={cn(
|
||||||
<span className="block text-muted-foreground/90 mb-2.5 text-xs font-semibold tracking-wider uppercase">
|
'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]',
|
||||||
{t('timestamp:resultLabel')}
|
className,
|
||||||
</span>
|
)}
|
||||||
|
{...props}
|
||||||
<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">
|
{t('timestamp:resultEmpty')}
|
||||||
{result}
|
|
||||||
</span>
|
|
||||||
<CopyButton
|
|
||||||
text={result}
|
|
||||||
tooltip={t('timestamp:copyResultTooltip')}
|
|
||||||
className="h-8 w-8 rounded-md shrink-0 border"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-muted/40 p-4 rounded-xl border border-border/50 flex flex-col gap-3">
|
|
||||||
{[
|
|
||||||
{ label: t('timestamp:relativeTime'), value: extraInfo?.relative },
|
|
||||||
{ label: t('timestamp:iso8601'), value: extraInfo?.iso, isMono: true },
|
|
||||||
{ label: t('timestamp:utcTime'), value: extraInfo?.utc, isMono: true },
|
|
||||||
].map((item) => (
|
|
||||||
<div
|
|
||||||
key={item.label}
|
|
||||||
className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-1.5 py-0.5 border-b border-border/30 last:border-0 pb-2 sm:pb-0 last:pb-0"
|
|
||||||
>
|
|
||||||
<span className="text-muted-foreground font-semibold text-xs shrink-0 select-none">
|
|
||||||
{item.label}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center justify-between sm:justify-end gap-2 min-w-0 w-full sm:w-auto">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'text-xs text-foreground/90 font-medium break-all text-left sm:text-right tabular-nums',
|
|
||||||
item.isMono && 'font-mono text-[11px]',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.value}
|
|
||||||
</span>
|
|
||||||
{item.value && (
|
|
||||||
<CopyButton
|
|
||||||
text={item.value}
|
|
||||||
tooltip={t('timestamp:copyTooltip')}
|
|
||||||
className="h-6 w-6 rounded-md border shrink-0 text-muted-foreground"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
);
|
|
||||||
|
|
||||||
ResultView.displayName = 'ResultView';
|
return (
|
||||||
|
<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')}
|
||||||
|
</span>
|
||||||
|
|
||||||
export default ResultView;
|
<div className="bg-card text-card-foreground border border-border p-4 sm:p-5 rounded-xl relative 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>
|
||||||
|
<CopyButton
|
||||||
|
text={result}
|
||||||
|
tooltip={t('timestamp:copyResultTooltip')}
|
||||||
|
className="h-8 w-8 rounded-md shrink-0 border"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,22 @@
|
|||||||
|
import type { Dayjs } from 'dayjs';
|
||||||
|
import dayjs from '@/utils/dayjs';
|
||||||
|
|
||||||
export const DATE_FORMAT = 'YYYY/MM/DD HH:mm:ss';
|
export const DATE_FORMAT = 'YYYY/MM/DD HH:mm:ss';
|
||||||
|
|
||||||
export const ZONES = ['Asia/Shanghai', 'America/New_York', 'Europe/London'] as const;
|
export const ZONES = ['Asia/Shanghai', 'America/New_York', 'Europe/London'] as const;
|
||||||
|
|
||||||
|
export type ModeType = 'ts2dt' | 'dt2ts';
|
||||||
export type UnitType = 'ms' | 's';
|
export type UnitType = 'ms' | 's';
|
||||||
export type ZoneType = (typeof ZONES)[number];
|
export type ZoneType = (typeof ZONES)[number];
|
||||||
|
|
||||||
|
const DIVISORS: Record<UnitType, number> = { ms: 1, s: 1000 };
|
||||||
|
|
||||||
|
/** Convert milliseconds to display value based on unit. */
|
||||||
|
export function msToUnit(ms: number, unit: UnitType): number {
|
||||||
|
return Math.floor(ms / DIVISORS[unit]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a dayjs object from a numeric timestamp according to unit. */
|
||||||
|
export function dayjsFromTimestamp(ts: number, unit: UnitType): Dayjs {
|
||||||
|
return unit === 'ms' ? dayjs(ts) : dayjs.unix(ts);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
||||||
import { ZONES } from './constants';
|
import { ZONES } from './constants';
|
||||||
|
import type { ModeType, UnitType, ZoneType } from './constants';
|
||||||
import LiveClock from './LiveClock';
|
import LiveClock from './LiveClock';
|
||||||
import ResultView from './ResultView';
|
import ResultView from './ResultView';
|
||||||
import { useTimestampConverter } from './useTimestampConverter';
|
import { useTimestampConverter } from './useTimestampConverter';
|
||||||
@@ -15,6 +16,16 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
|
|
||||||
|
const MODE_OPTIONS: { value: ModeType; label: string }[] = [
|
||||||
|
{ value: 'ts2dt', label: 'timestamp:tsToDate' },
|
||||||
|
{ value: 'dt2ts', label: 'timestamp:dateToTs' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const UNIT_OPTIONS: { value: UnitType; label: string }[] = [
|
||||||
|
{ value: 'ms', label: 'timestamp:unitMs' },
|
||||||
|
{ value: 's', label: 'timestamp:unitS' },
|
||||||
|
];
|
||||||
|
|
||||||
export default function Index() {
|
export default function Index() {
|
||||||
const { t } = useI18n('timestamp');
|
const { t } = useI18n('timestamp');
|
||||||
|
|
||||||
@@ -42,11 +53,8 @@ export default function Index() {
|
|||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<SwitchButtonGroup
|
<SwitchButtonGroup
|
||||||
value={mode}
|
value={mode}
|
||||||
options={[
|
options={MODE_OPTIONS.map((o) => ({ ...o, label: t(o.label) }))}
|
||||||
{ value: 'ts2dt', label: t('timestamp:tsToDate') },
|
onChange={setMode}
|
||||||
{ value: 'dt2ts', label: t('timestamp:dateToTs') },
|
|
||||||
]}
|
|
||||||
onChange={(newMode) => setMode(newMode as 'ts2dt' | 'dt2ts')}
|
|
||||||
size="small"
|
size="small"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -57,7 +65,7 @@ export default function Index() {
|
|||||||
mode === 'ts2dt' ? t('timestamp:placeholderTs') : t('timestamp:placeholderDate')
|
mode === 'ts2dt' ? t('timestamp:placeholderTs') : t('timestamp:placeholderDate')
|
||||||
}
|
}
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e: { target: { value: string } }) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'font-mono font-semibold h-10 shadow-sm placeholder:text-muted-foreground/60 focus:bg-background',
|
'font-mono font-semibold h-10 shadow-sm placeholder:text-muted-foreground/60 focus:bg-background',
|
||||||
error && 'border-destructive focus-visible:ring-destructive',
|
error && 'border-destructive focus-visible:ring-destructive',
|
||||||
@@ -70,16 +78,13 @@ export default function Index() {
|
|||||||
<div className="flex flex-col sm:flex-row items-stretch gap-3 w-full">
|
<div className="flex flex-col sm:flex-row items-stretch gap-3 w-full">
|
||||||
<SwitchButtonGroup
|
<SwitchButtonGroup
|
||||||
value={unit}
|
value={unit}
|
||||||
options={[
|
options={UNIT_OPTIONS.map((o) => ({ ...o, label: t(o.label) }))}
|
||||||
{ value: 'ms', label: t('timestamp:unitMs') },
|
onChange={setUnit}
|
||||||
{ value: 's', label: t('timestamp:unitS') },
|
|
||||||
]}
|
|
||||||
onChange={(v) => setUnit(v as 'ms' | 's')}
|
|
||||||
size="small"
|
size="small"
|
||||||
className="sm:w-auto shrink-0"
|
className="sm:w-auto shrink-0"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Select value={zone} onValueChange={(v: string) => setZone(v as typeof zone)}>
|
<Select value={zone} onValueChange={(v: string) => setZone(v as ZoneType)}>
|
||||||
<SelectTrigger className="flex-1 font-mono font-semibold h-9 shadow-sm bg-background">
|
<SelectTrigger className="flex-1 font-mono font-semibold h-9 shadow-sm bg-background">
|
||||||
<SelectValue placeholder="选择时区" />
|
<SelectValue placeholder="选择时区" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -100,7 +105,7 @@ export default function Index() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-5 rounded-xl border border-border bg-card text-card-foreground shadow-sm h-full flex flex-col">
|
<div className="p-5 rounded-xl border border-border bg-card text-card-foreground shadow-sm h-full flex flex-col">
|
||||||
<ResultView result={result} mode={mode} unit={unit} zone={zone} showEmptyPlaceholder />
|
<ResultView result={result} showEmptyPlaceholder />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,39 +1,42 @@
|
|||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import dayjs from '@/utils/dayjs';
|
import dayjs from '@/utils/dayjs';
|
||||||
import type { UnitType, ZoneType } from './constants';
|
import type { UnitType, ZoneType, ModeType } from './constants';
|
||||||
import { DATE_FORMAT } from './constants';
|
import { DATE_FORMAT, msToUnit, dayjsFromTimestamp } from './constants';
|
||||||
import { useI18n } from '@/utils/chromeI18n';
|
import { useI18n } from '@/utils/chromeI18n';
|
||||||
import { useContextMenuData } from '@/utils/useContextMenuData';
|
import { useContextMenuData } from '@/utils/useContextMenuData';
|
||||||
|
|
||||||
export interface UseTimestampConverterReturn {
|
export interface UseTimestampConverterReturn {
|
||||||
mode: 'ts2dt' | 'dt2ts';
|
mode: ModeType;
|
||||||
input: string;
|
input: string;
|
||||||
unit: UnitType;
|
unit: UnitType;
|
||||||
zone: ZoneType;
|
zone: ZoneType;
|
||||||
result: string;
|
result: string;
|
||||||
error: string;
|
error: string;
|
||||||
|
|
||||||
setMode: (mode: 'ts2dt' | 'dt2ts') => void;
|
setMode: (mode: ModeType) => void;
|
||||||
setInput: (value: string) => void;
|
setInput: (value: string) => void;
|
||||||
setUnit: (unit: UnitType) => void;
|
setUnit: (unit: UnitType) => void;
|
||||||
setZone: (zone: ZoneType) => void;
|
setZone: (zone: ZoneType) => void;
|
||||||
handleUseNow: (now: number) => void;
|
handleUseNow: (now: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TIMESTAMP_REGEX = /^\d+$/;
|
||||||
|
const MS_TIMESTAMP_MIN_LENGTH = 13;
|
||||||
|
|
||||||
function isTimestampLike(input: string): boolean {
|
function isTimestampLike(input: string): boolean {
|
||||||
const trimmed = input.trim();
|
const trimmed = input.trim();
|
||||||
return /^\d+$/.test(trimmed) && trimmed.length >= 10;
|
return TIMESTAMP_REGEX.test(trimmed) && trimmed.length >= 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTimestampConverter(): UseTimestampConverterReturn {
|
export function useTimestampConverter(): UseTimestampConverterReturn {
|
||||||
const { t } = useI18n('timestamp');
|
const { t } = useI18n('timestamp');
|
||||||
const [mode, setMode] = useState<'ts2dt' | 'dt2ts'>('ts2dt');
|
const [mode, setMode] = useState<ModeType>('ts2dt');
|
||||||
const [unit, setUnit] = useState<UnitType>('ms');
|
const [unit, setUnit] = useState<UnitType>('ms');
|
||||||
const [zone, setZone] = useState<ZoneType>('Asia/Shanghai');
|
const [zone, setZone] = useState<ZoneType>('Asia/Shanghai');
|
||||||
|
|
||||||
const [input, setInput] = useState(() => String(Date.now()));
|
const [input, setInput] = useState(() => String(Date.now()));
|
||||||
|
|
||||||
const conversionPipeline = useMemo(() => {
|
const { result, error } = useMemo(() => {
|
||||||
const rawInput = input.trim();
|
const rawInput = input.trim();
|
||||||
if (!rawInput) return { result: '', error: '' };
|
if (!rawInput) return { result: '', error: '' };
|
||||||
|
|
||||||
@@ -42,7 +45,7 @@ export function useTimestampConverter(): UseTimestampConverterReturn {
|
|||||||
if (isNaN(num)) {
|
if (isNaN(num)) {
|
||||||
return { result: '', error: t('timestamp:errors.invalidNumber') };
|
return { result: '', error: t('timestamp:errors.invalidNumber') };
|
||||||
}
|
}
|
||||||
const d = unit === 'ms' ? dayjs(num) : dayjs.unix(num);
|
const d = dayjsFromTimestamp(num, unit);
|
||||||
if (!d.isValid()) {
|
if (!d.isValid()) {
|
||||||
return { result: '', error: t('timestamp:errors.invalidTimestamp') };
|
return { result: '', error: t('timestamp:errors.invalidTimestamp') };
|
||||||
}
|
}
|
||||||
@@ -53,19 +56,15 @@ export function useTimestampConverter(): UseTimestampConverterReturn {
|
|||||||
return { result: '', error: t('timestamp:errors.invalidFormat') };
|
return { result: '', error: t('timestamp:errors.invalidFormat') };
|
||||||
}
|
}
|
||||||
const ms = d.valueOf();
|
const ms = d.valueOf();
|
||||||
const outputTs = unit === 'ms' ? String(ms) : String(Math.floor(ms / 1000));
|
return { result: String(msToUnit(ms, unit)), error: '' };
|
||||||
return { result: outputTs, error: '' };
|
|
||||||
}
|
}
|
||||||
}, [input, mode, unit, zone, t]);
|
}, [input, mode, unit, zone, t]);
|
||||||
|
|
||||||
const { result, error } = conversionPipeline;
|
const handleContextMenuData = (payload: string) => {
|
||||||
|
|
||||||
const handleContextMenuData = useCallback((payload: string) => {
|
|
||||||
const trimmed = payload.trim();
|
const trimmed = payload.trim();
|
||||||
if (isTimestampLike(trimmed)) {
|
if (isTimestampLike(trimmed)) {
|
||||||
setMode('ts2dt');
|
setMode('ts2dt');
|
||||||
const detectedUnit: UnitType = trimmed.length >= 13 ? 'ms' : 's';
|
setUnit(trimmed.length >= MS_TIMESTAMP_MIN_LENGTH ? 'ms' : 's');
|
||||||
setUnit(detectedUnit);
|
|
||||||
setInput(trimmed);
|
setInput(trimmed);
|
||||||
} else {
|
} else {
|
||||||
const d = dayjs(trimmed);
|
const d = dayjs(trimmed);
|
||||||
@@ -77,30 +76,24 @@ export function useTimestampConverter(): UseTimestampConverterReturn {
|
|||||||
setInput(trimmed);
|
setInput(trimmed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
useContextMenuData({ featureKey: 'timestamp', onData: handleContextMenuData });
|
useContextMenuData({ featureKey: 'timestamp', onData: handleContextMenuData });
|
||||||
|
|
||||||
const handleUseNow = useCallback(
|
const handleUseNow = (now: number) => {
|
||||||
(now: number) => {
|
if (mode === 'ts2dt') {
|
||||||
if (mode === 'ts2dt') {
|
setInput(String(msToUnit(now, unit)));
|
||||||
setInput(String(unit === 'ms' ? now : Math.floor(now / 1000)));
|
} else {
|
||||||
} else {
|
setInput(dayjs(now).tz(zone).format(DATE_FORMAT));
|
||||||
setInput(dayjs(now).tz(zone).format(DATE_FORMAT));
|
}
|
||||||
}
|
};
|
||||||
},
|
|
||||||
[mode, unit, zone],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSetMode = useCallback(
|
const handleSetMode = (newMode: ModeType) => {
|
||||||
(newMode: 'ts2dt' | 'dt2ts') => {
|
setMode(newMode);
|
||||||
setMode(newMode);
|
if (result && !error) {
|
||||||
if (result && !error) {
|
setInput(result);
|
||||||
setInput(result);
|
}
|
||||||
}
|
};
|
||||||
},
|
|
||||||
[result, error],
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mode,
|
mode,
|
||||||
|
|||||||
@@ -1,12 +1,4 @@
|
|||||||
import {
|
import { createContext, ReactNode, useCallback, useContext, useEffect, useState } from 'react';
|
||||||
createContext,
|
|
||||||
ReactNode,
|
|
||||||
useCallback,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
|
||||||
import { browser } from 'wxt/browser';
|
import { browser } from 'wxt/browser';
|
||||||
import { storageUtil } from '@/utils/chromeStorage';
|
import { storageUtil } from '@/utils/chromeStorage';
|
||||||
|
|
||||||
@@ -141,12 +133,11 @@ export function ThemeModeProvider({ children }: ThemeModeProviderProps) {
|
|||||||
}
|
}
|
||||||
}, [resolvedMode]);
|
}, [resolvedMode]);
|
||||||
|
|
||||||
const contextValue = useMemo(
|
return (
|
||||||
() => ({ mode, resolvedMode, setMode }),
|
<ThemeModeContext.Provider value={{ mode, resolvedMode, setMode }}>
|
||||||
[mode, resolvedMode, setMode],
|
{children}
|
||||||
|
</ThemeModeContext.Provider>
|
||||||
);
|
);
|
||||||
|
|
||||||
return <ThemeModeContext.Provider value={contextValue}>{children}</ThemeModeContext.Provider>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useThemeMode(): ThemeModeContextType {
|
export function useThemeMode(): ThemeModeContextType {
|
||||||
|
|||||||
Vendored
+1
-1
@@ -189,7 +189,7 @@ export type StorageCleanResult =
|
|||||||
*/
|
*/
|
||||||
export interface CleaningResult {
|
export interface CleaningResult {
|
||||||
/** 整体操作是否成功 */
|
/** 整体操作是否成功 */
|
||||||
success: boolean;
|
overallSuccess: boolean;
|
||||||
/** 整体错误信息(如果有) */
|
/** 整体错误信息(如果有) */
|
||||||
error?: string;
|
error?: string;
|
||||||
/** 各项清理的具体结果 */
|
/** 各项清理的具体结果 */
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ describe('contextMenu', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('CONTEXT_MENU_CONFIGS', () => {
|
describe('CONTEXT_MENU_CONFIGS', () => {
|
||||||
it('应该包含 7 个菜单项配置', () => {
|
it('应该包含 8 个菜单项配置', () => {
|
||||||
expect(CONTEXT_MENU_CONFIGS).toHaveLength(7);
|
expect(CONTEXT_MENU_CONFIGS).toHaveLength(8);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应该有一个父级菜单项 Testing Tools', () => {
|
it('应该有一个父级菜单项 Testing Tools', () => {
|
||||||
@@ -43,13 +43,22 @@ describe('contextMenu', () => {
|
|||||||
expect(pageMenus).toHaveLength(2);
|
expect(pageMenus).toHaveLength(2);
|
||||||
expect(pageMenus.map((m) => m.id)).toEqual(['storageCleaner', 'qrCode-page']);
|
expect(pageMenus.map((m) => m.id)).toEqual(['storageCleaner', 'qrCode-page']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('应该有 1 个 image 上下文的子菜单', () => {
|
||||||
|
const imageMenus = CONTEXT_MENU_CONFIGS.filter(
|
||||||
|
(c) => c.contexts[0] === 'image' && c.parentId === 'testing-tools-parent',
|
||||||
|
);
|
||||||
|
expect(imageMenus).toHaveLength(1);
|
||||||
|
expect(imageMenus[0].id).toBe('qrCode-image');
|
||||||
|
expect(imageMenus[0].title).toBe('🖼️ 解析图片二维码');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('createAllContextMenus', () => {
|
describe('createAllContextMenus', () => {
|
||||||
it('应该为每个配置调用 chrome.contextMenus.create', () => {
|
it('应该为每个配置调用 chrome.contextMenus.create', () => {
|
||||||
createAllContextMenus();
|
createAllContextMenus();
|
||||||
|
|
||||||
expect(chrome.contextMenus.create).toHaveBeenCalledTimes(7);
|
expect(chrome.contextMenus.create).toHaveBeenCalledTimes(8);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应该使用正确的参数创建菜单项', () => {
|
it('应该使用正确的参数创建菜单项', () => {
|
||||||
@@ -185,5 +194,33 @@ describe('contextMenu', () => {
|
|||||||
data: { featureKey: 'textStatistics', payload: 'short text' },
|
data: { featureKey: 'textStatistics', payload: 'short text' },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('当点击 qrCode-image 菜单时应返回 qrCode 功能和图片URL', () => {
|
||||||
|
const info = createMockOnClickData({
|
||||||
|
srcUrl: 'https://example.com/image.png',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = parseContextMenuClick('qrCode-image', info);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
success: true,
|
||||||
|
data: { featureKey: 'qrCode', payload: 'https://example.com/image.png' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('srcUrl 优先于 selectionText 和 pageUrl', () => {
|
||||||
|
const info = createMockOnClickData({
|
||||||
|
srcUrl: 'https://example.com/image.png',
|
||||||
|
selectionText: 'selected text',
|
||||||
|
pageUrl: 'https://example.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = parseContextMenuClick('qrCode-image', info);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
success: true,
|
||||||
|
data: { featureKey: 'qrCode', payload: 'https://example.com/image.png' },
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,10 +34,14 @@ describe('messages', () => {
|
|||||||
const mockTab = { id: 123, url: 'https://example.com' };
|
const mockTab = { id: 123, url: 'https://example.com' };
|
||||||
(chrome.tabs.query as any).mockResolvedValue([mockTab]);
|
(chrome.tabs.query as any).mockResolvedValue([mockTab]);
|
||||||
|
|
||||||
const result = await sendMessageToContent(MessageAction.RELOAD_TAB, { tabId: 123 });
|
const result = await sendMessageToContent(MessageAction.RESTORE_RIGHT_CLICK);
|
||||||
|
|
||||||
expect(result).toEqual(mockResponse);
|
expect(result).toEqual(mockResponse);
|
||||||
expect(mockSendMessage).toHaveBeenCalledWith(MessageAction.RELOAD_TAB, { tabId: 123 }, 123);
|
expect(mockSendMessage).toHaveBeenCalledWith(
|
||||||
|
MessageAction.RESTORE_RIGHT_CLICK,
|
||||||
|
undefined,
|
||||||
|
123,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应该支持不带数据的消息发送', async () => {
|
it('应该支持不带数据的消息发送', async () => {
|
||||||
@@ -60,11 +64,11 @@ describe('messages', () => {
|
|||||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
(chrome.tabs.query as any).mockResolvedValue([]);
|
(chrome.tabs.query as any).mockResolvedValue([]);
|
||||||
|
|
||||||
const result = await sendMessageToContent(MessageAction.RELOAD_TAB, { tabId: 123 });
|
const result = await sendMessageToContent(MessageAction.RESTORE_RIGHT_CLICK);
|
||||||
|
|
||||||
expect(result).toEqual({ success: false, message: '无法获取当前标签页' });
|
expect(result).toEqual({ success: false, message: '无法获取当前标签页' });
|
||||||
expect(consoleSpy).toHaveBeenCalledWith(
|
expect(consoleSpy).toHaveBeenCalledWith(
|
||||||
'[Messaging] 无法获取当前标签页,无法发送动作: reloadTab',
|
'[Messaging] 无法获取当前标签页,无法发送动作: restoreRightClick',
|
||||||
);
|
);
|
||||||
consoleSpy.mockRestore();
|
consoleSpy.mockRestore();
|
||||||
});
|
});
|
||||||
@@ -73,7 +77,7 @@ describe('messages', () => {
|
|||||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
(chrome.tabs.query as any).mockResolvedValue([{ url: 'https://example.com' }]);
|
(chrome.tabs.query as any).mockResolvedValue([{ url: 'https://example.com' }]);
|
||||||
|
|
||||||
const result = await sendMessageToContent(MessageAction.RELOAD_TAB, { tabId: 123 });
|
const result = await sendMessageToContent(MessageAction.RESTORE_RIGHT_CLICK);
|
||||||
|
|
||||||
expect(result).toEqual({ success: false, message: '无法获取当前标签页' });
|
expect(result).toEqual({ success: false, message: '无法获取当前标签页' });
|
||||||
consoleSpy.mockRestore();
|
consoleSpy.mockRestore();
|
||||||
@@ -87,7 +91,7 @@ describe('messages', () => {
|
|||||||
const mockTab = { id: 123, url: 'https://example.com' };
|
const mockTab = { id: 123, url: 'https://example.com' };
|
||||||
(chrome.tabs.query as any).mockResolvedValue([mockTab]);
|
(chrome.tabs.query as any).mockResolvedValue([mockTab]);
|
||||||
|
|
||||||
const result = await sendMessageToContent(MessageAction.RELOAD_TAB, { tabId: 123 });
|
const result = await sendMessageToContent(MessageAction.RESTORE_RIGHT_CLICK);
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -104,7 +108,7 @@ describe('messages', () => {
|
|||||||
const mockTab = { id: 123, url: 'https://example.com' };
|
const mockTab = { id: 123, url: 'https://example.com' };
|
||||||
(chrome.tabs.query as any).mockResolvedValue([mockTab]);
|
(chrome.tabs.query as any).mockResolvedValue([mockTab]);
|
||||||
|
|
||||||
const result = await sendMessageToContent(MessageAction.RELOAD_TAB, { tabId: 123 });
|
const result = await sendMessageToContent(MessageAction.RESTORE_RIGHT_CLICK);
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -121,7 +125,7 @@ describe('messages', () => {
|
|||||||
const mockTab = { id: 123, url: 'https://example.com' };
|
const mockTab = { id: 123, url: 'https://example.com' };
|
||||||
(chrome.tabs.query as any).mockResolvedValue([mockTab]);
|
(chrome.tabs.query as any).mockResolvedValue([mockTab]);
|
||||||
|
|
||||||
const result = await sendMessageToContent(MessageAction.RELOAD_TAB, { tabId: 123 });
|
const result = await sendMessageToContent(MessageAction.RESTORE_RIGHT_CLICK);
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -138,7 +142,7 @@ describe('messages', () => {
|
|||||||
const mockTab = { id: 123, url: 'https://example.com' };
|
const mockTab = { id: 123, url: 'https://example.com' };
|
||||||
(chrome.tabs.query as any).mockResolvedValue([mockTab]);
|
(chrome.tabs.query as any).mockResolvedValue([mockTab]);
|
||||||
|
|
||||||
const result = await sendMessageToContent(MessageAction.RELOAD_TAB, { tabId: 123 });
|
const result = await sendMessageToContent(MessageAction.RESTORE_RIGHT_CLICK);
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -151,7 +155,7 @@ describe('messages', () => {
|
|||||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
(chrome.tabs.query as any).mockRejectedValue(new Error('Query failed'));
|
(chrome.tabs.query as any).mockRejectedValue(new Error('Query failed'));
|
||||||
|
|
||||||
const result = await sendMessageToContent(MessageAction.RELOAD_TAB, { tabId: 123 });
|
const result = await sendMessageToContent(MessageAction.RESTORE_RIGHT_CLICK);
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
success: false,
|
success: false,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { clearCookies, formatSize, isRestrictedUrl } from '@/utils/storageCleaner';
|
import { clearCookies, isRestrictedUrl } from '@/utils/storageCleaner';
|
||||||
|
import { formatBytes } from '@/utils/format';
|
||||||
|
|
||||||
describe('storageCleaner utils', () => {
|
describe('storageCleaner utils', () => {
|
||||||
describe('isRestrictedUrl', () => {
|
describe('isRestrictedUrl', () => {
|
||||||
@@ -48,36 +49,36 @@ describe('storageCleaner utils', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('formatSize', () => {
|
describe('formatBytes', () => {
|
||||||
it('should return "0 B" for 0 bytes', () => {
|
it('should return "0 B" for 0 bytes', () => {
|
||||||
expect(formatSize(0)).toBe('0 B');
|
expect(formatBytes(0)).toBe('0 B');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should format bytes correctly', () => {
|
it('should format bytes correctly', () => {
|
||||||
expect(formatSize(500)).toBe('500 B');
|
expect(formatBytes(500)).toBe('500 B');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should format kilobytes correctly', () => {
|
it('should format kilobytes correctly', () => {
|
||||||
expect(formatSize(1024)).toBe('1.0 KB');
|
expect(formatBytes(1024)).toBe('1.0 KB');
|
||||||
expect(formatSize(1536)).toBe('1.5 KB');
|
expect(formatBytes(1536)).toBe('1.5 KB');
|
||||||
expect(formatSize(2048)).toBe('2.0 KB');
|
expect(formatBytes(2048)).toBe('2.0 KB');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should format megabytes correctly', () => {
|
it('should format megabytes correctly', () => {
|
||||||
expect(formatSize(1048576)).toBe('1.00 MB');
|
expect(formatBytes(1048576)).toBe('1.00 MB');
|
||||||
expect(formatSize(1572864)).toBe('1.50 MB');
|
expect(formatBytes(1572864)).toBe('1.50 MB');
|
||||||
expect(formatSize(5242880)).toBe('5.00 MB');
|
expect(formatBytes(5242880)).toBe('5.00 MB');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should format gigabytes correctly', () => {
|
it('should format gigabytes correctly', () => {
|
||||||
expect(formatSize(1073741824)).toBe('1.00 GB');
|
expect(formatBytes(1073741824)).toBe('1.00 GB');
|
||||||
expect(formatSize(2147483648)).toBe('2.00 GB');
|
expect(formatBytes(2147483648)).toBe('2.00 GB');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle edge cases', () => {
|
it('should handle edge cases', () => {
|
||||||
expect(formatSize(1)).toBe('1 B');
|
expect(formatBytes(1)).toBe('1 B');
|
||||||
expect(formatSize(1023)).toBe('1023 B');
|
expect(formatBytes(1023)).toBe('1023 B');
|
||||||
expect(formatSize(1025)).toBe('1.0 KB');
|
expect(formatBytes(1025)).toBe('1.0 KB');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { formatByteSize, getTextStats } from '@/utils/textStatistics';
|
import { formatBytes } from '@/utils/format';
|
||||||
|
import { getTextStats } from '@/utils/textStatistics';
|
||||||
|
|
||||||
describe('textStatistics utils', () => {
|
describe('textStatistics utils', () => {
|
||||||
describe('getTextStats', () => {
|
describe('getTextStats', () => {
|
||||||
@@ -49,12 +50,12 @@ describe('textStatistics utils', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('formatByteSize', () => {
|
describe('formatBytes', () => {
|
||||||
it('should format bytes correctly', () => {
|
it('should format bytes correctly', () => {
|
||||||
expect(formatByteSize(100)).toBe('100 B');
|
expect(formatBytes(100)).toBe('100 B');
|
||||||
expect(formatByteSize(0)).toBe('0 B');
|
expect(formatBytes(0)).toBe('0 B');
|
||||||
expect(formatByteSize(1024)).toBe('1.0 KB');
|
expect(formatBytes(1024)).toBe('1.0 KB');
|
||||||
expect(formatByteSize(1024 * 1024)).toBe('1.00 MB');
|
expect(formatBytes(1024 * 1024)).toBe('1.00 MB');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -213,8 +213,9 @@ export function extractMimeTypeFromDataUri(dataUri: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 格式化文件大小显示(兼容旧接口,内部委托给 formatBytes)
|
* 格式化文件大小显示
|
||||||
*
|
*
|
||||||
|
* @deprecated 直接使用 {@link formatBytes} 代替
|
||||||
* @param bytes 字节数
|
* @param bytes 字节数
|
||||||
* @returns 格式化后的字符串
|
* @returns 格式化后的字符串
|
||||||
*/
|
*/
|
||||||
@@ -270,12 +271,7 @@ const MAGIC_BYTE_SIGNATURES: ReadonlyArray<{
|
|||||||
* @returns 字节序列
|
* @returns 字节序列
|
||||||
*/
|
*/
|
||||||
export function base64ToBytes(b64: string): Uint8Array {
|
export function base64ToBytes(b64: string): Uint8Array {
|
||||||
const binary = atob(b64);
|
return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
||||||
const bytes = new Uint8Array(binary.length);
|
|
||||||
for (let i = 0; i < binary.length; i += 1) {
|
|
||||||
bytes[i] = binary.charCodeAt(i);
|
|
||||||
}
|
|
||||||
return bytes;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -287,25 +283,11 @@ export function base64ToBytes(b64: string): Uint8Array {
|
|||||||
export function sniffMimeFromBytes(bytes: Uint8Array): { mime: string; ext: string } | null {
|
export function sniffMimeFromBytes(bytes: Uint8Array): { mime: string; ext: string } | null {
|
||||||
for (const sig of MAGIC_BYTE_SIGNATURES) {
|
for (const sig of MAGIC_BYTE_SIGNATURES) {
|
||||||
if (bytes.length < sig.bytes.length) continue;
|
if (bytes.length < sig.bytes.length) continue;
|
||||||
let matched = true;
|
if (!sig.bytes.every((b, i) => bytes[i] === b)) continue;
|
||||||
for (let i = 0; i < sig.bytes.length; i += 1) {
|
|
||||||
if (bytes[i] !== sig.bytes[i]) {
|
|
||||||
matched = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!matched) continue;
|
|
||||||
if (sig.tail) {
|
if (sig.tail) {
|
||||||
const { offset, bytes: tailBytes } = sig.tail;
|
const { offset, bytes: tailBytes } = sig.tail;
|
||||||
if (bytes.length < offset + tailBytes.length) continue;
|
if (bytes.length < offset + tailBytes.length) continue;
|
||||||
let tailMatched = true;
|
if (!tailBytes.every((b, i) => bytes[offset + i] === b)) continue;
|
||||||
for (let i = 0; i < tailBytes.length; i += 1) {
|
|
||||||
if (bytes[offset + i] !== tailBytes[i]) {
|
|
||||||
tailMatched = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!tailMatched) continue;
|
|
||||||
}
|
}
|
||||||
return { mime: sig.mime, ext: sig.ext };
|
return { mime: sig.mime, ext: sig.ext };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export const MAX_PAYLOAD_LENGTH = 10000;
|
|||||||
/** 菜单项 ID 到 PageType 的映射(仅处理非常规映射) */
|
/** 菜单项 ID 到 PageType 的映射(仅处理非常规映射) */
|
||||||
const MENU_ID_TO_PAGE_TYPE: Record<string, PageType> = {
|
const MENU_ID_TO_PAGE_TYPE: Record<string, PageType> = {
|
||||||
'qrCode-page': 'qrCode',
|
'qrCode-page': 'qrCode',
|
||||||
|
'qrCode-image': 'qrCode',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -77,6 +78,12 @@ export const CONTEXT_MENU_CONFIGS: ContextMenuItemConfig[] = [
|
|||||||
contexts: [chrome.contextMenus.ContextType.PAGE],
|
contexts: [chrome.contextMenus.ContextType.PAGE],
|
||||||
parentId: PARENT_MENU_ID,
|
parentId: PARENT_MENU_ID,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'qrCode-image',
|
||||||
|
title: '🖼️ 解析图片二维码',
|
||||||
|
contexts: [chrome.contextMenus.ContextType.IMAGE],
|
||||||
|
parentId: PARENT_MENU_ID,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function createAllContextMenus(): void {
|
export function createAllContextMenus(): void {
|
||||||
@@ -96,6 +103,14 @@ export function parseContextMenuClick(
|
|||||||
): ParseResult {
|
): ParseResult {
|
||||||
const featureKey = getMenuPageType(menuItemId);
|
const featureKey = getMenuPageType(menuItemId);
|
||||||
|
|
||||||
|
// 处理图片 URL(右键点击图片时)
|
||||||
|
if (info.srcUrl) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { featureKey, payload: info.srcUrl },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (info.selectionText) {
|
if (info.selectionText) {
|
||||||
const text = info.selectionText;
|
const text = info.selectionText;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { defineExtensionMessaging } from '@webext-core/messaging';
|
import { defineExtensionMessaging } from '@webext-core/messaging';
|
||||||
|
|
||||||
export enum MessageAction {
|
export enum MessageAction {
|
||||||
RELOAD_TAB = 'reloadTab',
|
|
||||||
SIDE_PANEL_STATE_CHANGED = 'sidePanelStateChanged',
|
SIDE_PANEL_STATE_CHANGED = 'sidePanelStateChanged',
|
||||||
CONTEXT_MENU_CLICKED = 'contextMenuClicked',
|
CONTEXT_MENU_CLICKED = 'contextMenuClicked',
|
||||||
RESTORE_RIGHT_CLICK = 'restoreRightClick',
|
RESTORE_RIGHT_CLICK = 'restoreRightClick',
|
||||||
@@ -21,7 +20,6 @@ export interface ContextMenuClickedPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ProtocolMap {
|
export interface ProtocolMap {
|
||||||
[MessageAction.RELOAD_TAB](data: { tabId: number; delay?: number }): MessageResponse;
|
|
||||||
[MessageAction.SIDE_PANEL_STATE_CHANGED](data: { isOpen: boolean }): void;
|
[MessageAction.SIDE_PANEL_STATE_CHANGED](data: { isOpen: boolean }): void;
|
||||||
[MessageAction.CONTEXT_MENU_CLICKED](data: ContextMenuClickedPayload): void;
|
[MessageAction.CONTEXT_MENU_CLICKED](data: ContextMenuClickedPayload): void;
|
||||||
[MessageAction.RESTORE_RIGHT_CLICK](data: undefined): MessageResponse & { restored: boolean };
|
[MessageAction.RESTORE_RIGHT_CLICK](data: undefined): MessageResponse & { restored: boolean };
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* 右键解锁功能的主环境注入脚本
|
||||||
|
*
|
||||||
|
* 由于 content script 运行在 Isolated World,无法直接修改网页主环境的
|
||||||
|
* Event.prototype.preventDefault 等原生方法。需要通过 background
|
||||||
|
* 的 scripting.executeScript({ world: 'MAIN' }) 注入此脚本。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在网页主环境中执行的注入函数
|
||||||
|
* 注意:此函数会被序列化后通过 executeScript 注入,不能引用外部变量
|
||||||
|
*/
|
||||||
|
export function mainWorldInjectionScript(): void {
|
||||||
|
'use strict';
|
||||||
|
const w = window as unknown as Record<string, unknown>;
|
||||||
|
if (w.__testingToolsRightClickPatched) return;
|
||||||
|
w.__testingToolsRightClickPatched = true;
|
||||||
|
|
||||||
|
const PROTECTED = ['contextmenu', 'copy', 'paste', 'cut', 'selectstart'];
|
||||||
|
|
||||||
|
const _origPreventDefault = MouseEvent.prototype.preventDefault;
|
||||||
|
Object.defineProperty(MouseEvent.prototype, 'preventDefault', {
|
||||||
|
value: function (this: MouseEvent) {
|
||||||
|
const t = this.type;
|
||||||
|
if (PROTECTED.includes(t) || (t === 'mousedown' && this.button === 2)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return _origPreventDefault.call(this);
|
||||||
|
},
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const _origStopPropagation = Event.prototype.stopPropagation;
|
||||||
|
Object.defineProperty(Event.prototype, 'stopPropagation', {
|
||||||
|
value: function (this: Event) {
|
||||||
|
if (PROTECTED.includes(this.type)) return;
|
||||||
|
return _origStopPropagation.call(this);
|
||||||
|
},
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const _origStopImmediatePropagation = Event.prototype.stopImmediatePropagation;
|
||||||
|
Object.defineProperty(Event.prototype, 'stopImmediatePropagation', {
|
||||||
|
value: function (this: Event) {
|
||||||
|
if (PROTECTED.includes(this.type)) return;
|
||||||
|
return _origStopImmediatePropagation.call(this);
|
||||||
|
},
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
let _docOnContextMenu: unknown = null;
|
||||||
|
Object.defineProperty(document, 'oncontextmenu', {
|
||||||
|
get() {
|
||||||
|
return _docOnContextMenu;
|
||||||
|
},
|
||||||
|
set(fn: unknown) {
|
||||||
|
if (typeof fn === 'function') {
|
||||||
|
_docOnContextMenu = function (this: GlobalEventHandlers, e: MouseEvent) {
|
||||||
|
const r = (fn as (this: GlobalEventHandlers, ev: MouseEvent) => unknown).call(this, e);
|
||||||
|
return r === false ? true : r;
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
_docOnContextMenu = fn;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
+213
-250
@@ -1,5 +1,4 @@
|
|||||||
import type { CleaningResult, StorageCleanerOptions, StorageCleanResult } from '@/types/storage';
|
import type { CleaningResult, StorageCleanerOptions, StorageCleanResult } from '@/types/storage';
|
||||||
import { formatBytes } from './format';
|
|
||||||
|
|
||||||
const RESTRICTED_PROTOCOLS = [
|
const RESTRICTED_PROTOCOLS = [
|
||||||
'chrome:',
|
'chrome:',
|
||||||
@@ -11,6 +10,16 @@ const RESTRICTED_PROTOCOLS = [
|
|||||||
'data:',
|
'data:',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
/** 清理选项的 key 列表(用于遍历结果) */
|
||||||
|
const CLEAN_OPTION_KEYS: (keyof StorageCleanerOptions)[] = [
|
||||||
|
'localStorage',
|
||||||
|
'sessionStorage',
|
||||||
|
'indexedDB',
|
||||||
|
'cookies',
|
||||||
|
'cacheStorage',
|
||||||
|
'serviceWorkers',
|
||||||
|
];
|
||||||
|
|
||||||
export async function getCurrentTab() {
|
export async function getCurrentTab() {
|
||||||
// For popup pages, we need to get the active tab from the browser window that triggered the popup.
|
// For popup pages, we need to get the active tab from the browser window that triggered the popup.
|
||||||
// We should ONLY care about the currently active tab in the last focused window.
|
// We should ONLY care about the currently active tab in the last focused window.
|
||||||
@@ -59,129 +68,122 @@ export async function getCookieSize(url: string): Promise<number> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getLocalStorageSize(tabId: number): Promise<number> {
|
/**
|
||||||
|
* 通用辅助:在指定标签页中执行脚本并返回结果
|
||||||
|
*
|
||||||
|
* @param tabId 标签页 ID
|
||||||
|
* @param func 在页面上下文中执行的函数
|
||||||
|
* @param errorLabel 错误日志前缀
|
||||||
|
* @param fallback 执行失败时的回退值
|
||||||
|
*/
|
||||||
|
async function runScript<T>(
|
||||||
|
tabId: number,
|
||||||
|
func: () => T | Promise<T>,
|
||||||
|
errorLabel: string,
|
||||||
|
fallback: T,
|
||||||
|
): Promise<T> {
|
||||||
try {
|
try {
|
||||||
const [result] = await chrome.scripting.executeScript({
|
const [result] = await chrome.scripting.executeScript({ target: { tabId }, func });
|
||||||
target: { tabId },
|
return (result?.result as T) ?? fallback;
|
||||||
func: () => {
|
|
||||||
try {
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
return Object.entries(localStorage).reduce(
|
|
||||||
(acc, [k, v]) => acc + encoder.encode(k).length + encoder.encode(v).length,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return (result?.result as number) || 0;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to get LocalStorage size:', error);
|
console.error(`Failed to ${errorLabel}:`, error);
|
||||||
return 0;
|
return fallback;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getLocalStorageSize(tabId: number): Promise<number> {
|
||||||
|
return runScript(
|
||||||
|
tabId,
|
||||||
|
() => {
|
||||||
|
try {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
return Object.entries(localStorage).reduce(
|
||||||
|
(acc, [k, v]) => acc + encoder.encode(k).length + encoder.encode(v).length,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'get LocalStorage size',
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function getSessionStorageSize(tabId: number): Promise<number> {
|
export async function getSessionStorageSize(tabId: number): Promise<number> {
|
||||||
try {
|
return runScript(
|
||||||
const [result] = await chrome.scripting.executeScript({
|
tabId,
|
||||||
target: { tabId },
|
() => {
|
||||||
func: () => {
|
try {
|
||||||
try {
|
const encoder = new TextEncoder();
|
||||||
const encoder = new TextEncoder();
|
return Object.entries(sessionStorage).reduce(
|
||||||
return Object.entries(sessionStorage).reduce(
|
(acc, [k, v]) => acc + encoder.encode(k).length + encoder.encode(v).length,
|
||||||
(acc, [k, v]) => acc + encoder.encode(k).length + encoder.encode(v).length,
|
0,
|
||||||
0,
|
);
|
||||||
);
|
} catch {
|
||||||
} catch {
|
return 0;
|
||||||
return 0;
|
}
|
||||||
}
|
},
|
||||||
},
|
'get SessionStorage size',
|
||||||
});
|
0,
|
||||||
return (result?.result as number) || 0;
|
);
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to get SessionStorage size:', error);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getOriginStorageEstimate(tabId: number): Promise<number> {
|
export async function getOriginStorageEstimate(tabId: number): Promise<number> {
|
||||||
try {
|
return runScript(
|
||||||
const [result] = await chrome.scripting.executeScript({
|
tabId,
|
||||||
target: { tabId },
|
async () => {
|
||||||
func: async () => {
|
try {
|
||||||
try {
|
if (navigator.storage && navigator.storage.estimate) {
|
||||||
if (navigator.storage && navigator.storage.estimate) {
|
const estimate = await navigator.storage.estimate();
|
||||||
const estimate = await navigator.storage.estimate();
|
return estimate.usage || 0;
|
||||||
return estimate.usage || 0;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
} catch {
|
|
||||||
return 0;
|
|
||||||
}
|
}
|
||||||
},
|
return 0;
|
||||||
});
|
} catch {
|
||||||
return (result?.result as number) || 0;
|
return 0;
|
||||||
} catch (error) {
|
}
|
||||||
console.error('Failed to get origin storage estimate:', error);
|
},
|
||||||
return 0;
|
'get origin storage estimate',
|
||||||
}
|
0,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCacheStorageSize(tabId: number): Promise<number> {
|
export async function getCacheStorageSize(tabId: number): Promise<number> {
|
||||||
try {
|
return runScript(
|
||||||
const [result] = await chrome.scripting.executeScript({
|
tabId,
|
||||||
target: { tabId },
|
async () => {
|
||||||
func: async () => {
|
try {
|
||||||
try {
|
if ('caches' in window) {
|
||||||
if ('caches' in window) {
|
const keys = await caches.keys();
|
||||||
const keys = await caches.keys();
|
return keys.length;
|
||||||
return keys.length;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
} catch {
|
|
||||||
return 0;
|
|
||||||
}
|
}
|
||||||
},
|
return 0;
|
||||||
});
|
} catch {
|
||||||
return (result?.result as number) || 0;
|
return 0;
|
||||||
} catch (error) {
|
}
|
||||||
console.error('Failed to get CacheStorage size:', error);
|
},
|
||||||
return 0;
|
'get CacheStorage size',
|
||||||
}
|
0,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getServiceWorkerCount(tabId: number): Promise<number> {
|
export async function getServiceWorkerCount(tabId: number): Promise<number> {
|
||||||
try {
|
return runScript(
|
||||||
const [result] = await chrome.scripting.executeScript({
|
tabId,
|
||||||
target: { tabId },
|
async () => {
|
||||||
func: async () => {
|
try {
|
||||||
try {
|
if ('serviceWorker' in navigator) {
|
||||||
if ('serviceWorker' in navigator) {
|
const regs = await navigator.serviceWorker.getRegistrations();
|
||||||
const regs = await navigator.serviceWorker.getRegistrations();
|
return regs.length;
|
||||||
return regs.length;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
} catch {
|
|
||||||
return 0;
|
|
||||||
}
|
}
|
||||||
},
|
return 0;
|
||||||
});
|
} catch {
|
||||||
return (result?.result as number) || 0;
|
return 0;
|
||||||
} catch (error) {
|
}
|
||||||
console.error('Failed to get ServiceWorker count:', error);
|
},
|
||||||
return 0;
|
'get ServiceWorker count',
|
||||||
}
|
0,
|
||||||
}
|
);
|
||||||
|
|
||||||
/**
|
|
||||||
* 格式化字节大小显示(兼容旧接口,内部委托给 formatBytes)
|
|
||||||
*
|
|
||||||
* @param bytes 字节数
|
|
||||||
* @returns 格式化后的字符串
|
|
||||||
*/
|
|
||||||
export function formatSize(bytes: number): string {
|
|
||||||
return formatBytes(bytes);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearCookies(url: string): Promise<StorageCleanResult> {
|
export async function clearCookies(url: string): Promise<StorageCleanResult> {
|
||||||
@@ -203,148 +205,125 @@ export async function clearCookies(url: string): Promise<StorageCleanResult> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function injectClearLocalStorage(tabId: number): Promise<StorageCleanResult> {
|
/**
|
||||||
try {
|
* 通用辅助:执行清理脚本并解析结果
|
||||||
const [result] = await chrome.scripting.executeScript({
|
*
|
||||||
target: { tabId },
|
* @param tabId 标签页 ID
|
||||||
func: () => {
|
* @param func 在页面上下文中执行的清理函数
|
||||||
const count = localStorage.length;
|
* @param errorLabel 错误日志前缀
|
||||||
localStorage.clear();
|
*/
|
||||||
return { count };
|
async function runCleanScript(
|
||||||
},
|
tabId: number,
|
||||||
});
|
func: () => { count: number } | Promise<{ count: number }>,
|
||||||
if (result?.result && typeof result.result === 'object' && 'count' in result.result) {
|
errorLabel: string,
|
||||||
return { success: true, count: result.result.count };
|
): Promise<StorageCleanResult> {
|
||||||
}
|
const raw = await runScript(tabId, func, errorLabel, { count: 0 });
|
||||||
return { success: false, error: 'No result returned' };
|
if (raw && typeof raw === 'object' && 'count' in raw) {
|
||||||
} catch (error) {
|
return { success: true, count: raw.count };
|
||||||
return { success: false, error: String(error) };
|
|
||||||
}
|
}
|
||||||
|
return { success: false, error: 'No result returned' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function injectClearLocalStorage(tabId: number): Promise<StorageCleanResult> {
|
||||||
|
return runCleanScript(
|
||||||
|
tabId,
|
||||||
|
() => {
|
||||||
|
const count = localStorage.length;
|
||||||
|
localStorage.clear();
|
||||||
|
return { count };
|
||||||
|
},
|
||||||
|
'clear LocalStorage',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function injectClearSessionStorage(tabId: number): Promise<StorageCleanResult> {
|
export async function injectClearSessionStorage(tabId: number): Promise<StorageCleanResult> {
|
||||||
try {
|
return runCleanScript(
|
||||||
const [result] = await chrome.scripting.executeScript({
|
tabId,
|
||||||
target: { tabId },
|
() => {
|
||||||
func: () => {
|
const count = sessionStorage.length;
|
||||||
const count = sessionStorage.length;
|
sessionStorage.clear();
|
||||||
sessionStorage.clear();
|
return { count };
|
||||||
return { count };
|
},
|
||||||
},
|
'clear SessionStorage',
|
||||||
});
|
);
|
||||||
if (result?.result && typeof result.result === 'object' && 'count' in result.result) {
|
|
||||||
return { success: true, count: result.result.count };
|
|
||||||
}
|
|
||||||
return { success: false, error: 'No result returned' };
|
|
||||||
} catch (error) {
|
|
||||||
return { success: false, error: String(error) };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function injectClearIndexedDB(tabId: number): Promise<StorageCleanResult> {
|
export async function injectClearIndexedDB(tabId: number): Promise<StorageCleanResult> {
|
||||||
try {
|
return runCleanScript(
|
||||||
const [result] = await chrome.scripting.executeScript({
|
tabId,
|
||||||
target: { tabId },
|
async () => {
|
||||||
func: async () => {
|
if (typeof indexedDB.databases !== 'function') {
|
||||||
if (typeof indexedDB.databases === 'function') {
|
return { count: 0 };
|
||||||
const databases = await indexedDB.databases();
|
}
|
||||||
let count = 0;
|
const databases = await indexedDB.databases();
|
||||||
for (const db of databases) {
|
let count = 0;
|
||||||
if (db.name) {
|
for (const db of databases) {
|
||||||
const dbName = db.name as string;
|
if (!db.name) continue;
|
||||||
try {
|
const dbName = db.name;
|
||||||
await new Promise<void>((resolve, reject) => {
|
try {
|
||||||
const deleteReq = indexedDB.deleteDatabase(dbName);
|
await new Promise<void>((resolve, reject) => {
|
||||||
const timeout = setTimeout(() => {
|
const deleteReq = indexedDB.deleteDatabase(dbName);
|
||||||
console.warn('IndexedDB delete timeout:', dbName);
|
const timeout = setTimeout(() => {
|
||||||
resolve(); // Timeout, move to next
|
console.warn('IndexedDB delete timeout:', dbName);
|
||||||
}, 5000);
|
resolve();
|
||||||
|
}, 5000);
|
||||||
deleteReq.onblocked = () => {
|
deleteReq.onblocked = () => {
|
||||||
console.warn('IndexedDB delete blocked:', dbName);
|
console.warn('IndexedDB delete blocked:', dbName);
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
resolve(); // Blocked, move to next
|
resolve();
|
||||||
};
|
};
|
||||||
deleteReq.onsuccess = () => {
|
deleteReq.onsuccess = () => {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
resolve();
|
resolve();
|
||||||
};
|
};
|
||||||
deleteReq.onerror = () => {
|
deleteReq.onerror = () => {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
reject(new Error(`Failed to delete ${dbName}`));
|
reject(new Error(`Failed to delete ${dbName}`));
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
count++;
|
count++;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Delete DB error:', e);
|
console.error('Delete DB error:', e);
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { count };
|
|
||||||
}
|
}
|
||||||
return { error: 'databases_api_unavailable' };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (result?.result && typeof result.result === 'object') {
|
|
||||||
if ('error' in result.result) {
|
|
||||||
return { success: false, error: String(result.result.error) };
|
|
||||||
}
|
}
|
||||||
if ('count' in result.result) {
|
return { count };
|
||||||
return { success: true, count: result.result.count };
|
},
|
||||||
}
|
'clear IndexedDB',
|
||||||
}
|
);
|
||||||
return { success: false, error: 'No result returned' };
|
|
||||||
} catch (error) {
|
|
||||||
return { success: false, error: String(error) };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function injectClearCacheStorage(tabId: number): Promise<StorageCleanResult> {
|
export async function injectClearCacheStorage(tabId: number): Promise<StorageCleanResult> {
|
||||||
try {
|
return runCleanScript(
|
||||||
const [result] = await chrome.scripting.executeScript({
|
tabId,
|
||||||
target: { tabId },
|
async () => {
|
||||||
func: async () => {
|
if ('caches' in window) {
|
||||||
if ('caches' in window) {
|
const cacheNames = await caches.keys();
|
||||||
const cacheNames = await caches.keys();
|
for (const name of cacheNames) {
|
||||||
for (const name of cacheNames) {
|
await caches.delete(name);
|
||||||
await caches.delete(name);
|
|
||||||
}
|
|
||||||
return { count: cacheNames.length };
|
|
||||||
}
|
}
|
||||||
return { count: 0 };
|
return { count: cacheNames.length };
|
||||||
},
|
}
|
||||||
});
|
return { count: 0 };
|
||||||
if (result?.result && typeof result.result === 'object' && 'count' in result.result) {
|
},
|
||||||
return { success: true, count: result.result.count };
|
'clear CacheStorage',
|
||||||
}
|
);
|
||||||
return { success: false, error: 'No result returned' };
|
|
||||||
} catch (error) {
|
|
||||||
return { success: false, error: String(error) };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function injectUnregisterServiceWorkers(tabId: number): Promise<StorageCleanResult> {
|
export async function injectUnregisterServiceWorkers(tabId: number): Promise<StorageCleanResult> {
|
||||||
try {
|
return runCleanScript(
|
||||||
const [result] = await chrome.scripting.executeScript({
|
tabId,
|
||||||
target: { tabId },
|
async () => {
|
||||||
func: async () => {
|
if ('serviceWorker' in navigator) {
|
||||||
if ('serviceWorker' in navigator) {
|
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
for (const registration of registrations) {
|
||||||
for (const registration of registrations) {
|
await registration.unregister();
|
||||||
await registration.unregister();
|
|
||||||
}
|
|
||||||
return { count: registrations.length };
|
|
||||||
}
|
}
|
||||||
return { count: 0 };
|
return { count: registrations.length };
|
||||||
},
|
}
|
||||||
});
|
return { count: 0 };
|
||||||
if (result?.result && typeof result.result === 'object' && 'count' in result.result) {
|
},
|
||||||
return { success: true, count: result.result.count };
|
'unregister ServiceWorkers',
|
||||||
}
|
);
|
||||||
return { success: false, error: 'No result returned' };
|
|
||||||
} catch (error) {
|
|
||||||
return { success: false, error: String(error) };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearStorage(
|
export async function clearStorage(
|
||||||
@@ -352,39 +331,32 @@ export async function clearStorage(
|
|||||||
url: string,
|
url: string,
|
||||||
options: StorageCleanerOptions,
|
options: StorageCleanerOptions,
|
||||||
): Promise<CleaningResult> {
|
): Promise<CleaningResult> {
|
||||||
const result: CleaningResult = { success: true };
|
const result: CleaningResult = { overallSuccess: true };
|
||||||
|
|
||||||
if (options.localStorage) {
|
if (options.localStorage) {
|
||||||
result.localStorage = await injectClearLocalStorage(tabId);
|
result.localStorage = await injectClearLocalStorage(tabId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.sessionStorage) {
|
if (options.sessionStorage) {
|
||||||
result.sessionStorage = await injectClearSessionStorage(tabId);
|
result.sessionStorage = await injectClearSessionStorage(tabId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.indexedDB) {
|
if (options.indexedDB) {
|
||||||
result.indexedDB = await injectClearIndexedDB(tabId);
|
result.indexedDB = await injectClearIndexedDB(tabId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.cookies) {
|
if (options.cookies) {
|
||||||
result.cookies = await clearCookies(url);
|
result.cookies = await clearCookies(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.cacheStorage) {
|
if (options.cacheStorage) {
|
||||||
result.cacheStorage = await injectClearCacheStorage(tabId);
|
result.cacheStorage = await injectClearCacheStorage(tabId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.serviceWorkers) {
|
if (options.serviceWorkers) {
|
||||||
result.serviceWorkers = await injectUnregisterServiceWorkers(tabId);
|
result.serviceWorkers = await injectUnregisterServiceWorkers(tabId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if any operation failed
|
|
||||||
const failures = Object.values(result).filter(
|
const failures = Object.values(result).filter(
|
||||||
(r): r is StorageCleanResult => r?.success === false,
|
(r): r is StorageCleanResult => r?.success === false,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (failures.length > 0) {
|
if (failures.length > 0) {
|
||||||
result.success = false;
|
result.overallSuccess = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
@@ -396,16 +368,7 @@ export function formatCleaningResult(
|
|||||||
): string {
|
): string {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
|
|
||||||
const optionKeys: (keyof StorageCleanerOptions)[] = [
|
for (const key of CLEAN_OPTION_KEYS) {
|
||||||
'localStorage',
|
|
||||||
'sessionStorage',
|
|
||||||
'indexedDB',
|
|
||||||
'cookies',
|
|
||||||
'cacheStorage',
|
|
||||||
'serviceWorkers',
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const key of optionKeys) {
|
|
||||||
const r = result[key];
|
const r = result[key];
|
||||||
if (r?.success && r.count > 0) {
|
if (r?.success && r.count > 0) {
|
||||||
parts.push(`${r.count} ${t(`storageCleaner:options.${key}`)}`);
|
parts.push(`${r.count} ${t(`storageCleaner:options.${key}`)}`);
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { formatBytes } from './format';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 文本统计信息接口
|
* 文本统计信息接口
|
||||||
*/
|
*/
|
||||||
@@ -35,7 +33,7 @@ export function getTextStats(text: string): TextStats {
|
|||||||
const segmenter = new Intl.Segmenter(undefined, { granularity: 'word' });
|
const segmenter = new Intl.Segmenter(undefined, { granularity: 'word' });
|
||||||
const segments = segmenter.segment(text);
|
const segments = segmenter.segment(text);
|
||||||
for (const segment of segments) {
|
for (const segment of segments) {
|
||||||
// isWordLike 为 true 表示该片段是“类词”的(非空格、非标点)
|
// isWordLike 为 true 表示该片段是"类词"的(非空格、非标点)
|
||||||
if (segment.isWordLike) {
|
if (segment.isWordLike) {
|
||||||
words++;
|
words++;
|
||||||
}
|
}
|
||||||
@@ -44,7 +42,7 @@ export function getTextStats(text: string): TextStats {
|
|||||||
// 降级方案:如果不支持 Intl.Segmenter,使用正则匹配英文单词
|
// 降级方案:如果不支持 Intl.Segmenter,使用正则匹配英文单词
|
||||||
// 但对中文支持较差
|
// 但对中文支持较差
|
||||||
const englishWords = text.match(/\b\w+\b/g) || [];
|
const englishWords = text.match(/\b\w+\b/g) || [];
|
||||||
const chineseChars = text.match(/[\u4e00-\u9fa5]/g) || [];
|
const chineseChars = text.match(/[一-龥]/g) || [];
|
||||||
words = englishWords.length + chineseChars.length;
|
words = englishWords.length + chineseChars.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,13 +55,3 @@ export function getTextStats(text: string): TextStats {
|
|||||||
|
|
||||||
return { characters, words, lines, bytes };
|
return { characters, words, lines, bytes };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 格式化字节大小显示(兼容旧接口,内部委托给 formatBytes)
|
|
||||||
*
|
|
||||||
* @param bytes 字节数
|
|
||||||
* @returns 格式化后的字符串
|
|
||||||
*/
|
|
||||||
export function formatByteSize(bytes: number): string {
|
|
||||||
return formatBytes(bytes);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { storageUtil } from '@/utils/chromeStorage';
|
import { storageUtil } from '@/utils/chromeStorage';
|
||||||
import type { ContextMenuPendingData, PageType } from '@/types/storage';
|
import type { ContextMenuPendingData, PageType } from '@/types/storage';
|
||||||
|
|
||||||
@@ -23,44 +23,58 @@ export interface UseContextMenuDataOptions {
|
|||||||
* 3. Hook 会自动从 storage 中读取并消费匹配的数据
|
* 3. Hook 会自动从 storage 中读取并消费匹配的数据
|
||||||
*/
|
*/
|
||||||
export function useContextMenuData({ featureKey, onData }: UseContextMenuDataOptions): void {
|
export function useContextMenuData({ featureKey, onData }: UseContextMenuDataOptions): void {
|
||||||
const checkAndConsumeData = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const data = await storageUtil.get(STORAGE_KEY, undefined);
|
|
||||||
|
|
||||||
if (!data) return;
|
|
||||||
|
|
||||||
if (data.featureKey !== featureKey) return;
|
|
||||||
|
|
||||||
if (Date.now() - data.timestamp > CONTEXT_MENU_DATA_EXPIRY_MS) {
|
|
||||||
await storageUtil.remove(STORAGE_KEY);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await storageUtil.remove(STORAGE_KEY);
|
|
||||||
|
|
||||||
onData(data.payload);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[useContextMenuData] 处理右键菜单数据失败:', error);
|
|
||||||
}
|
|
||||||
}, [featureKey, onData]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
checkAndConsumeData();
|
const checkAndConsumeData = async () => {
|
||||||
}, [checkAndConsumeData]);
|
try {
|
||||||
|
const data = await storageUtil.get(STORAGE_KEY, undefined);
|
||||||
|
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
if (data.featureKey !== featureKey) return;
|
||||||
|
|
||||||
|
if (Date.now() - data.timestamp > CONTEXT_MENU_DATA_EXPIRY_MS) {
|
||||||
|
await storageUtil.remove(STORAGE_KEY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await storageUtil.remove(STORAGE_KEY);
|
||||||
|
|
||||||
|
onData(data.payload);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[useContextMenuData] 处理右键菜单数据失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void checkAndConsumeData();
|
||||||
|
}, [featureKey, onData]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleStorageChange = (changes: { [key: string]: chrome.storage.StorageChange }) => {
|
const handleStorageChange = (changes: { [key: string]: chrome.storage.StorageChange }) => {
|
||||||
if (changes[STORAGE_KEY]) {
|
if (changes[STORAGE_KEY]) {
|
||||||
const newData = changes[STORAGE_KEY].newValue as ContextMenuPendingData | null;
|
const newData = changes[STORAGE_KEY].newValue as ContextMenuPendingData | null;
|
||||||
if (newData && newData.featureKey === featureKey) {
|
if (newData && newData.featureKey === featureKey) {
|
||||||
checkAndConsumeData();
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const data = await storageUtil.get(STORAGE_KEY, undefined);
|
||||||
|
if (!data) return;
|
||||||
|
if (data.featureKey !== featureKey) return;
|
||||||
|
if (Date.now() - data.timestamp > CONTEXT_MENU_DATA_EXPIRY_MS) {
|
||||||
|
await storageUtil.remove(STORAGE_KEY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await storageUtil.remove(STORAGE_KEY);
|
||||||
|
onData(data.payload);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[useContextMenuData] 处理右键菜单数据失败:', error);
|
||||||
|
}
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
chrome.storage.onChanged.addListener(handleStorageChange);
|
chrome.storage.onChanged.addListener(handleStorageChange);
|
||||||
return () => chrome.storage.onChanged.removeListener(handleStorageChange);
|
return () => chrome.storage.onChanged.removeListener(handleStorageChange);
|
||||||
}, [featureKey, checkAndConsumeData]);
|
}, [featureKey, onData]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+7
-16
@@ -1,11 +1,10 @@
|
|||||||
import '@testing-library/jest-dom';
|
import '@testing-library/jest-dom';
|
||||||
import { afterEach, beforeEach, vi } from 'vitest';
|
import { afterEach, beforeEach, vi } from 'vitest';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import zhMessages from './public/_locales/zh_CN/messages.json';
|
||||||
|
|
||||||
// Load actual zh translations for getMessage mock
|
// Type assertion to allow string indexing
|
||||||
const zhMessages: Record<string, { message: string }> =
|
const zhMessagesMap = zhMessages as Record<string, { message: string }>;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
||||||
require('./public/_locales/zh/messages.json');
|
|
||||||
|
|
||||||
vi.mock('@/utils/chromeI18n', () => ({
|
vi.mock('@/utils/chromeI18n', () => ({
|
||||||
useI18n: (ns?: string | string[]) => ({
|
useI18n: (ns?: string | string[]) => ({
|
||||||
@@ -16,13 +15,13 @@ vi.mock('@/utils/chromeI18n', () => ({
|
|||||||
msgId = key.replace(':', '_').replace(/\./g, '_');
|
msgId = key.replace(':', '_').replace(/\./g, '_');
|
||||||
}
|
}
|
||||||
// Try direct key first
|
// Try direct key first
|
||||||
if (zhMessages[msgId]) return zhMessages[msgId].message;
|
if (zhMessagesMap[msgId]) return zhMessagesMap[msgId].message;
|
||||||
// Try namespace prefix (using converted msgId)
|
// Try namespace prefix (using converted msgId)
|
||||||
if (ns) {
|
if (ns) {
|
||||||
const namespaces = Array.isArray(ns) ? ns : [ns];
|
const namespaces = Array.isArray(ns) ? ns : [ns];
|
||||||
for (const n of namespaces) {
|
for (const n of namespaces) {
|
||||||
const candidate = `${n}_${msgId}`;
|
const candidate = `${n}_${msgId}`;
|
||||||
if (zhMessages[candidate]) return zhMessages[candidate].message;
|
if (zhMessagesMap[candidate]) return zhMessagesMap[candidate].message;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return msgId;
|
return msgId;
|
||||||
@@ -33,7 +32,7 @@ vi.mock('@/utils/chromeI18n', () => ({
|
|||||||
},
|
},
|
||||||
isLoaded: true,
|
isLoaded: true,
|
||||||
}),
|
}),
|
||||||
getMessage: (msgId: string) => zhMessages[msgId]?.message ?? msgId,
|
getMessage: (msgId: string) => zhMessagesMap[msgId]?.message ?? msgId,
|
||||||
getLanguage: () => 'zh',
|
getLanguage: () => 'zh',
|
||||||
preloadNamespaces: vi.fn().mockResolvedValue(undefined),
|
preloadNamespaces: vi.fn().mockResolvedValue(undefined),
|
||||||
}));
|
}));
|
||||||
@@ -125,7 +124,7 @@ const webExtensionMock = {
|
|||||||
get: vi.fn().mockResolvedValue({}),
|
get: vi.fn().mockResolvedValue({}),
|
||||||
sendMessage: vi.fn().mockResolvedValue(undefined),
|
sendMessage: vi.fn().mockResolvedValue(undefined),
|
||||||
create: vi.fn().mockResolvedValue({}),
|
create: vi.fn().mockResolvedValue({}),
|
||||||
reload: vi.fn().mockResolvedValue(undefined), // ✅ 承接 MessageAction.RELOAD_TAB 刷新单元测试
|
reload: vi.fn().mockResolvedValue(undefined),
|
||||||
},
|
},
|
||||||
runtime: {
|
runtime: {
|
||||||
id: 'test-extension-id',
|
id: 'test-extension-id',
|
||||||
@@ -153,13 +152,6 @@ const webExtensionMock = {
|
|||||||
getAll: vi.fn().mockResolvedValue([]),
|
getAll: vi.fn().mockResolvedValue([]),
|
||||||
remove: vi.fn().mockResolvedValue(undefined),
|
remove: vi.fn().mockResolvedValue(undefined),
|
||||||
},
|
},
|
||||||
alarms: {
|
|
||||||
create: vi.fn().mockResolvedValue(undefined),
|
|
||||||
onAlarm: {
|
|
||||||
addListener: vi.fn(),
|
|
||||||
removeListener: vi.fn(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
contextMenus: {
|
contextMenus: {
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
onClicked: {
|
onClicked: {
|
||||||
@@ -202,7 +194,6 @@ afterEach(() => {
|
|||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
// 全局 matchMedia 极客级环境模拟(ThemeModeProvider 依赖)
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
Object.defineProperty(window, 'matchMedia', {
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
writable: true,
|
writable: true,
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import { defineWebExtConfig } from 'wxt';
|
import { defineWebExtConfig } from 'wxt';
|
||||||
|
|
||||||
export default defineWebExtConfig({
|
export default defineWebExtConfig({
|
||||||
startUrls: ['https://www.baidu.com', 'chrome://extensions/'],
|
startUrls: ['https://www.bing.com', 'chrome://extensions/'],
|
||||||
chromiumArgs: ['chrome://extensions/'],
|
chromiumArgs: ['chrome://extensions/'],
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-2
@@ -45,7 +45,7 @@ export default defineConfig({
|
|||||||
name: 'Testing Tool',
|
name: 'Testing Tool',
|
||||||
description: 'A tool for testing web applications.',
|
description: 'A tool for testing web applications.',
|
||||||
version_name: undefined,
|
version_name: undefined,
|
||||||
default_locale: 'zh',
|
default_locale: 'zh_CN',
|
||||||
permissions: [
|
permissions: [
|
||||||
'storage',
|
'storage',
|
||||||
'unlimitedStorage',
|
'unlimitedStorage',
|
||||||
@@ -56,7 +56,6 @@ export default defineConfig({
|
|||||||
'cookies',
|
'cookies',
|
||||||
'sidePanel',
|
'sidePanel',
|
||||||
'contextMenus',
|
'contextMenus',
|
||||||
'alarms',
|
|
||||||
],
|
],
|
||||||
host_permissions: ['<all_urls>'],
|
host_permissions: ['<all_urls>'],
|
||||||
action: {
|
action: {
|
||||||
|
|||||||
Reference in New Issue
Block a user