diff --git a/AGENTS.md b/AGENTS.md
index 742c1fe..e2769b1 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -69,11 +69,11 @@ npx vitest run path/to/your.test.ts
│ └── sidepanel/ # 侧边栏界面主入口
├── pages/ # 功能模块页面组件
│ ├── DashboardPage.tsx # 仪表盘/首页
-│ ├── JwtPage.tsx # JWT 解析工具
-│ ├── QrCodePage.tsx # 二维码工具
-│ ├── StorageCleanerPage.tsx # 存储清理工具
-│ ├── TextStatisticsPage.tsx # 文本统计工具
-│ └── TimestampPage.tsx # 时间戳转换工具
+│ ├── Jwt/index.tsx # JWT 解析工具
+│ ├── QrCode/index.tsx # 二维码工具
+│ ├── StorageCleaner/index.tsx # 存储清理工具
+│ ├── TextStatistics/index.tsx # 文本统计工具
+│ └── Timestamp/index.tsx # 时间戳转换工具
├── providers/ # React Context Providers (Router, Theme 等)
├── utils/ # 业务逻辑与工具函数
│ ├── chromeStorage.ts # 类型安全的 Chrome Storage 封装
diff --git a/GEMINI.md b/GEMINI.md
deleted file mode 100644
index f98f909..0000000
--- a/GEMINI.md
+++ /dev/null
@@ -1,83 +0,0 @@
-# Testing Tools Browser Extension - Gemini Instructions
-
-This document provides essential context and instructions for AI agents working on the Testing Tools browser extension project.
-
-## Project Overview
-
-**Testing Tools** is a lightweight, feature-rich browser extension built with the [WXT (Web Extension Toolkit)](https://wxt.dev/) framework. It provides a suite of utilities for developers and testers, including timestamp conversion, storage management, URL shortcuts, and QR code tools.
-
-### Tech Stack
-
-- **Framework:** WXT (Web Extension Toolkit)
-- **Frontend:** React 19 + TypeScript
-- **UI Library:** Material UI (MUI) @7.x
-- **Date Handling:** dayjs (with UTC and timezone plugins)
-- **Messaging:** @webext-core/messaging
-- **Storage:** Type-safe Chrome Storage API wrapper
-- **Testing:** Vitest + Testing Library (jsdom)
-
-### Architecture & Directory Structure
-
-- `entrypoints/`: Extension entry points (popup, options, sidepanel, background, content).
- - `popup/`: Main UI shown when clicking the extension icon.
- - `options/`: Extension settings page.
- - `sidepanel/`: Browser side panel integration.
- - `background.ts`: Background script for lifecycle management and background tasks.
- - `content.ts`: Content script injected into web pages.
-- `components/`: Reusable React components.
-- `config/`: Application configuration, including routes and themes.
-- `providers/`: React Context providers (e.g., `RouterProvider`).
-- `utils/`: Utility functions and service abstractions.
- - `chromeStorage.ts`: Type-safe storage utility.
-- `types/`: Global TypeScript type definitions.
-- `public/`: Static assets (icons, etc.).
-
-## Building and Running
-
-### Development
-
-- `npm run dev`: Start Chrome development mode with HMR.
-- `npm run dev:firefox`: Start Firefox development mode.
-- `npm run compile`: Run TypeScript type checking (`tsc --noEmit`).
-
-### Production
-
-- `npm run build`: Build production version for Chrome.
-- `npm run build:firefox`: Build production version for Firefox.
-- `npm run zip`: Package the extension for Chrome Web Store.
-- `npm run zip:firefox`: Package the extension for Firefox Add-ons.
-
-### Testing & Linting
-
-- `npm run test`: Run all tests once.
-- `npm run test:watch`: Run tests in watch mode.
-- `npm run test:coverage`: Run tests and generate coverage report.
-- `npm run lint`: Run ESLint checks.
-
-## Development Conventions
-
-### Coding Style
-
-- **TypeScript:** Use strict typing. Prefer interfaces for object structures and types for unions/aliases.
-- **Components:** Functional components with Hooks. Use MUI components for consistent UI.
-- **Storage:** Always use `storageUtil` from `@/utils/chromeStorage.ts` for accessing `chrome.storage.local`. Ensure keys are defined in `StorageSchema` in `@/types/storage.d.ts`.
-- **Messaging:** Use `@webext-core/messaging` for communication between entry points. Define message types in `@/utils/messages.ts`.
-
-### Testing Practices
-
-- **Framework:** Vitest with `jsdom` environment.
-- **Location:** Place tests in `__tests__` directories adjacent to the files being tested.
-- **Naming:** Follow `*.test.ts` or `*.test.tsx` naming convention.
-- **Patterns:** Use `@testing-library/react` for component testing. Prefer `user-event` (v14+) for simulating interactions.
-
-### CI/CD
-
-- **GitHub Actions:** CI runs on push/PR to `main` and `develop` branches (lint, compile, test, build).
-- **Releases:** Automatic release to GitHub on pushing a `v*` tag.
-
-## Key Considerations for AI Agents
-
-- **Manifest Permissions:** When adding features that require new browser APIs, update `wxt.config.ts`.
-- **Browser Compatibility:** Ensure features work in both Chrome and Firefox.
-- **React 19:** Be aware of React 19 specific features and deprecations.
-- **WXT Modules:** The project uses `@wxt-dev/module-react`.
diff --git a/components/DashboardCard.tsx b/components/DashboardCard.tsx
deleted file mode 100644
index 94bf0ee..0000000
--- a/components/DashboardCard.tsx
+++ /dev/null
@@ -1,46 +0,0 @@
-import React from 'react';
-import ToolCard from './ToolCard';
-
-export interface DashboardCardConfig {
- /** 卡片标题 */
- title: string;
- /** 卡片描述文字 */
- description: string;
- /** 主题颜色代码 */
- colorCode: string;
- /** 图标组件 */
- icon: React.ReactNode;
-}
-
-interface DashboardCardProps {
- /** 卡片配置数据 */
- config: DashboardCardConfig;
- /** 点击卡片时的回调函数 */
- onClick: () => void;
- /** 卡片右侧的实时预览内容(如时间戳显示) */
- snapshot?: React.ReactNode;
- /** 卡片背景色,默认使用主题色 */
- cardBackgroundColor?: string;
-}
-
-/**
- * DashboardCard - 仪表盘卡片组件
- *
- * 基于 ToolCard 的封装,专门用于仪表盘页面
- * 使用 React.memo 避免不必要的重渲染
- */
-const DashboardCard = React.memo(
- ({ config, onClick, snapshot, cardBackgroundColor }: DashboardCardProps) => (
-
- ),
-);
-
-export default DashboardCard;
diff --git a/components/ErrorBoundary.tsx b/components/ErrorBoundary.tsx
index fd1932b..c417b3f 100644
--- a/components/ErrorBoundary.tsx
+++ b/components/ErrorBoundary.tsx
@@ -1,5 +1,5 @@
import { Component, ErrorInfo, ReactNode } from 'react';
-import { Box, Typography, Button, Paper, Container } from '@mui/material';
+import { Box, Button, Container, Paper, Typography } from '@mui/material';
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
import RefreshIcon from '@mui/icons-material/Refresh';
@@ -16,25 +16,24 @@ interface State {
* 错误边界组件:捕获子组件树中的 JavaScript 错误
*/
export class ErrorBoundary extends Component {
- public state: State = {
+ state: State = {
hasError: false,
error: null,
};
- public static getDerivedStateFromError(error: Error): State {
+ static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
- public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
+ componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Uncaught error:', error, errorInfo);
}
private handleReset = () => {
- this.setState({ hasError: false, error: null });
window.location.reload();
};
- public render() {
+ render() {
if (this.state.hasError) {
return (
@@ -46,7 +45,7 @@ export class ErrorBoundary extends Component {
borderRadius: 4,
border: '1px solid',
borderColor: 'error.light',
- bgcolor: 'error.shortest',
+ bgcolor: 'rgba(211, 47, 47, 0.04)',
}}
>
diff --git a/components/GlobalSnackbar.tsx b/components/GlobalSnackbar.tsx
index c1b3314..2c9f95e 100644
--- a/components/GlobalSnackbar.tsx
+++ b/components/GlobalSnackbar.tsx
@@ -197,24 +197,71 @@ export function GlobalSnackbar({
}
/**
- * useSnackbarState - 消息提示的状态管理 Hook
+ * 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 (
+ * <>
+ *
+ *
+ * >
+ * );
+ * }
+ * ```
*/
export function useSnackbarState(initialOptions?: SnackbarOptions): UseSnackbarStateResult {
+ // Snackbar 显示状态
const [open, setOpen] = useState(false);
+ // 当前显示的消息内容
const [message, setMessage] = useState('');
+ // 消息配置选项
const [options, setOptions] = useState(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);
};
@@ -224,6 +271,13 @@ export function useSnackbarState(initialOptions?: SnackbarOptions): UseSnackbarS
closeMessage();
};
+ /**
+ * 传递给 GlobalSnackbar 组件的属性
+ *
+ * @description
+ * - 组合当前状态和选项为完整的组件 props
+ * - onClose 使用 handleClose 包装后的版本
+ */
const snackbarProps: GlobalSnackbarProps = {
message,
open,
diff --git a/components/PageHeader.tsx b/components/PageHeader.tsx
index 9c4676c..f708a9c 100644
--- a/components/PageHeader.tsx
+++ b/components/PageHeader.tsx
@@ -1,4 +1,4 @@
-import { Stack, Typography, Box, alpha, SxProps, Theme } from '@mui/material';
+import { alpha, Box, Stack, SxProps, Theme, Typography } from '@mui/material';
import { ReactNode } from 'react';
/**
diff --git a/components/QrCodeUploader.tsx b/components/QrCodeUploader.tsx
deleted file mode 100644
index 90c2261..0000000
--- a/components/QrCodeUploader.tsx
+++ /dev/null
@@ -1,381 +0,0 @@
-import React, { useCallback, useEffect, useRef, useState } from 'react';
-import {
- Alert,
- Box,
- CircularProgress,
- IconButton,
- Paper,
- Typography,
- useMediaQuery,
- useTheme,
-} from '@mui/material';
-import ImageIcon from '@mui/icons-material/Image';
-import ClearIcon from '@mui/icons-material/Clear';
-import CheckCircleIcon from '@mui/icons-material/CheckCircle';
-import ErrorIcon from '@mui/icons-material/Error';
-import GlobalSnackbar, { useSnackbarState } from './GlobalSnackbar';
-import CopyButton from './CopyButton';
-import { parseQrCodeFromFile } from '@/utils/qrCodeParser';
-
-interface QrCodeUploaderProps {
- onQrCodeDetected?: (data: string) => void;
- supportedFormats?: string[];
- maxFileSize?: number; // in bytes
- timeout?: number; // in milliseconds
- showPreview?: boolean;
- showProgress?: boolean;
- className?: string;
-}
-
-const QrCodeUploader: React.FC = ({
- onQrCodeDetected,
- supportedFormats = ['image/png', 'image/jpeg', 'image/webp'],
- maxFileSize = 5 * 1024 * 1024, // 5MB
- showPreview = true,
- showProgress = true,
- className,
-}) => {
- const { snackbarProps, showMessage } = useSnackbarState({ autoHideDuration: 3000 });
- const theme = useTheme();
- const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
-
- const [file, setFile] = useState(null);
- const [preview, setPreview] = useState(null);
- const [uploading, setUploading] = useState(false);
- const [progress, setProgress] = useState(0);
- const [result, setResult] = useState(null);
- const [error, setError] = useState(null);
- const [dragging, setDragging] = useState(false);
-
- const fileInputRef = useRef(null);
- const uploadAreaRef = useRef(null);
-
- // 清理预览 URL
- useEffect(() => {
- return () => {
- if (preview) {
- URL.revokeObjectURL(preview);
- }
- };
- }, [preview]);
-
- // 处理文件
- const processFile = useCallback(
- async (file: File) => {
- setUploading(true);
- setProgress(0);
-
- try {
- const progressInterval = setInterval(() => {
- setProgress((prev) => {
- if (prev >= 90) {
- clearInterval(progressInterval);
- return prev;
- }
- return prev + 10;
- });
- }, 200);
-
- const result = await parseQrCodeFromFile(file);
-
- clearInterval(progressInterval);
- setProgress(100);
-
- if (result.success && result.data) {
- setResult(result.data);
- showMessage('二维码解析成功', { severity: 'success' });
- if (onQrCodeDetected) {
- onQrCodeDetected(result.data);
- }
- } else {
- setError(result.error || '未检测到二维码');
- showMessage(result.error || '未检测到二维码', { severity: 'error' });
- }
- } catch (err) {
- setError(err instanceof Error ? err.message : '解析失败');
- showMessage('解析失败: ' + (err instanceof Error ? err.message : '未知错误'), {
- severity: 'error',
- });
- } finally {
- setUploading(false);
- setTimeout(() => setProgress(0), 500);
- }
- },
- [showMessage, onQrCodeDetected],
- );
-
- // 处理文件
- const handleFile = useCallback(
- (selectedFile: File) => {
- // 检查文件格式
- if (!supportedFormats.includes(selectedFile.type)) {
- setError(
- `不支持的文件格式。支持的格式: ${supportedFormats.map((f) => f.split('/')[1].toUpperCase()).join(', ')}`,
- );
- showMessage('不支持的文件格式', { severity: 'error' });
- return;
- }
-
- // 检查文件大小
- if (selectedFile.size > maxFileSize) {
- const maxSizeMB = (maxFileSize / (1024 * 1024)).toFixed(1);
- setError(`文件大小超过限制。最大支持 ${maxSizeMB}MB`);
- showMessage(`文件大小超过限制,最大支持 ${maxSizeMB}MB`, { severity: 'error' });
- return;
- }
-
- // 重置状态
- setError(null);
- setResult(null);
- setFile(selectedFile);
-
- // 创建预览
- if (showPreview) {
- const previewUrl = URL.createObjectURL(selectedFile);
- setPreview(previewUrl);
- }
-
- // 开始处理
- processFile(selectedFile).catch(console.error);
- },
- [supportedFormats, maxFileSize, showPreview, showMessage, processFile],
- );
-
- // 处理文件选择
- const handleFileSelect = (e: React.ChangeEvent) => {
- const selectedFile = e.target.files?.[0];
- if (selectedFile) {
- handleFile(selectedFile);
- }
- };
-
- // 处理拖拽事件
- const handleDragOver = (e: React.DragEvent) => {
- e.preventDefault();
- setDragging(true);
- };
-
- const handleDragLeave = () => {
- setDragging(false);
- };
-
- const handleDrop = (e: React.DragEvent) => {
- e.preventDefault();
- setDragging(false);
- const droppedFile = e.dataTransfer.files?.[0];
- if (droppedFile) {
- handleFile(droppedFile);
- }
- };
-
- // 监听粘贴事件
- useEffect(() => {
- const handlePaste = (e: ClipboardEvent) => {
- const items = e.clipboardData?.items;
- if (!items) return;
-
- for (let i = 0; i < items.length; i++) {
- if (items[i].type.startsWith('image/')) {
- e.preventDefault();
- const pastedFile = items[i].getAsFile();
- if (pastedFile) {
- handleFile(pastedFile);
- }
- break;
- }
- }
- };
-
- document.addEventListener('paste', handlePaste);
- return () => document.removeEventListener('paste', handlePaste);
- }, [handleFile]);
-
- // 清除文件
- const handleClear = () => {
- setFile(null);
- setPreview(null);
- setResult(null);
- setError(null);
- if (fileInputRef.current) {
- fileInputRef.current.value = '';
- }
- };
-
- return (
-
- {/* 上传区域 */}
- fileInputRef.current?.click()}
- >
-
-
- {!file && !uploading ? (
-
-
-
- 点击、拖拽或粘贴上传二维码图片
-
-
- 支持 {supportedFormats.map((f) => f.split('/')[1].toUpperCase()).join(', ')} 格式
-
-
- 最大文件大小: {(maxFileSize / (1024 * 1024)).toFixed(1)}MB
-
-
- ) : file && showPreview && preview ? (
-
-
- {
- e.stopPropagation();
- handleClear();
- }}
- sx={{
- position: 'absolute',
- top: -8,
- right: -8,
- bgcolor: 'rgba(244, 67, 54, 0.9)',
- color: 'white',
- '&:hover': {
- bgcolor: 'rgba(211, 47, 47, 0.95)',
- },
- }}
- >
-
-
-
- {file.name}
-
-
- ) : uploading && showProgress ? (
-
-
-
- 处理中...
-
- {progress > 0 && (
-
-
-
-
-
- {progress}%
-
-
- )}
-
- ) : null}
-
-
- {/* 结果展示 */}
- {(result || error) && (
-
- {result && (
-
-
-
-
-
- 二维码内容
-
-
-
- {result}
-
-
-
-
-
-
- )}
-
- {error && (
- }>
- {error}
-
- )}
-
- )}
-
-
-
- );
-};
-
-export default QrCodeUploader;
diff --git a/components/RouterContainer.tsx b/components/RouterContainer.tsx
index 092ac91..3be590d 100644
--- a/components/RouterContainer.tsx
+++ b/components/RouterContainer.tsx
@@ -1,7 +1,7 @@
import { Box, CircularProgress } from '@mui/material';
import { FEATURES, getEntryPointType } from '@/config/features';
import { useRouter } from '@/providers/RouterProvider';
-import { useMemo, Suspense } from 'react';
+import { Suspense, useMemo } from 'react';
export default function RouterContainer() {
const { currentPage, isLoaded } = useRouter();
diff --git a/components/StorageCleanerConfirm.tsx b/components/StorageCleanerConfirm.tsx
deleted file mode 100644
index 240fb53..0000000
--- a/components/StorageCleanerConfirm.tsx
+++ /dev/null
@@ -1,169 +0,0 @@
-import {
- Dialog,
- DialogTitle,
- DialogContent,
- DialogActions,
- Typography,
- Box,
- Chip,
- alpha,
-} from '@mui/material';
-import type { StorageCleanerOptions } from '@/types/storage';
-import Button from '@/components/Button';
-import { storageCleanerPageStyles, THEME_COLORS } from '@/config/pageTheme';
-
-export interface StorageCleanerConfirmProps {
- open: boolean;
- onClose: () => void;
- onConfirm: () => void;
- options: StorageCleanerOptions;
-}
-
-const STORAGE_LABELS: Record = {
- localStorage: 'LocalStorage',
- sessionStorage: 'Session Storage',
- indexedDB: 'IndexedDB',
- cookies: 'Cookies',
- cacheStorage: 'Cache Storage',
- serviceWorkers: 'Service Workers',
-};
-
-export function StorageCleanerConfirm({
- open,
- onClose,
- onConfirm,
- options,
-}: StorageCleanerConfirmProps) {
- const selectedOptions = Object.entries(options)
- .filter(([_, value]) => value)
- .map(([key, _]) => STORAGE_LABELS[key as keyof StorageCleanerOptions] || key);
-
- return (
-
- );
-}
-
-export default StorageCleanerConfirm;
diff --git a/components/ToolCard.tsx b/components/ToolCard.tsx
deleted file mode 100644
index 14ccd4a..0000000
--- a/components/ToolCard.tsx
+++ /dev/null
@@ -1,154 +0,0 @@
-/**
- * ToolCard 组件 - 工具卡片
- *
- * 用于在仪表盘中展示各个工具功能的卡片组件,支持图标、标题、描述、
- * AI 标识和快照内容展示,具备悬停动画效果。
- */
-import { Box, Typography, Stack } from '@mui/material';
-import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'; // Sparkles for AI
-import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
-import React from 'react';
-
-/**
- * ToolCard 组件属性接口
- */
-interface ToolCardProps {
- /** 工具卡片标题 */
- title: string;
- /** 工具卡片描述文本(可选) */
- description?: string;
- /** 快照内容,用于在卡片底部展示额外信息(可选) */
- snapshot?: React.ReactNode;
- /** 主题色代码,用于图标背景和悬停效果 */
- colorCode: string;
- /** 工具图标元素 */
- icon: React.ReactNode;
- /** 卡片点击事件处理函数 */
- onClick: () => void;
- /** 是否显示 AI 标识(可选) */
- hasAI?: boolean;
- /** 卡片背景色,默认为 'background.paper' */
- cardBackgroundColor?: string;
-}
-
-/**
- * ToolCard 组件
- *
- * @param props - ToolCardProps 属性对象
- * @returns 工具卡片 JSX 元素
- */
-export default function ToolCard({
- title,
- description,
- snapshot,
- colorCode,
- icon,
- onClick,
- hasAI,
- cardBackgroundColor = 'background.paper',
-}: ToolCardProps) {
- return (
-
-
-
-
- {icon}
-
-
-
- {title}
- {hasAI && }
-
- {description && (
-
- {description}
-
- )}
-
-
-
-
-
- {snapshot && (
-
- {snapshot}
-
- )}
-
- );
-}
diff --git a/components/TopBar.tsx b/components/TopBar.tsx
index 3481043..b531525 100644
--- a/components/TopBar.tsx
+++ b/components/TopBar.tsx
@@ -1,18 +1,146 @@
-import { Box, IconButton, Typography, Stack, Tooltip } from '@mui/material';
+import { useState, useEffect, useRef, useMemo } from 'react';
+import {
+ Box,
+ IconButton,
+ Stack,
+ Tooltip,
+ Typography,
+ InputBase,
+ Paper,
+ List,
+ ListItemButton,
+ ListItemIcon,
+ ListItemText,
+ ClickAwayListener,
+} from '@mui/material';
import SettingsIcon from '@mui/icons-material/Settings';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew';
+import SearchIcon from '@mui/icons-material/Search';
+import HistoryIcon from '@mui/icons-material/History';
+import CloseIcon from '@mui/icons-material/Close';
+import LanguageIcon from '@mui/icons-material/Language';
import { useRouter } from '@/providers/RouterProvider';
+import { FEATURES, FeatureConfig } from '@/config/features';
+import { storageUtil } from '@/utils/chromeStorage';
+import { openExtensionPage } from '@/utils/chromeTabs';
+import { useTranslation } from 'react-i18next';
+import { SUPPORTED_LANGUAGES, normalizeLanguage } from '@/i18n';
+import { topBarStyles } from '@/config/pageTheme';
export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void }) {
- const { currentPage, goBack } = useRouter();
+ const { currentPage, goBack, navigateTo } = useRouter();
+ const { t, i18n } = useTranslation(['common', 'features']);
+ const [searchQuery, setSearchQuery] = useState('');
+ const [showResults, setShowResults] = useState(false);
+ const [searchHistory, setSearchHistory] = useState([]);
+ const [selectedIndex, setSelectedIndex] = useState(-1);
+ const inputRef = useRef(null);
- const handleOpenInTab = () => {
- // 在新标签页中打开扩展页面
- chrome.tabs.create({ url: chrome.runtime.getURL('popup.html?mode=tab') }).catch(console.error);
+ // 加载搜索历史
+ useEffect(() => {
+ storageUtil
+ .get('app/searchHistory', [])
+ .then((history) => {
+ setSearchHistory(history || []);
+ })
+ .catch((error) => {
+ console.error('加载搜索历史失败:', error);
+ });
+ }, []);
+
+ // 模糊搜索逻辑
+ const searchResults = useMemo(() => {
+ if (!searchQuery.trim()) return [];
+ const query = searchQuery.toLowerCase();
+ return FEATURES.filter((f) => {
+ if (f.key === 'dashboard') return false;
+ const label = t(f.labelKey).toLowerCase();
+ const desc = t(f.descriptionKey).toLowerCase();
+ return label.includes(query) || desc.includes(query);
+ });
+ }, [searchQuery, t]);
+
+ const displayedHistory = useMemo(() => {
+ if (searchQuery.trim()) return [];
+ return searchHistory.slice(0, topBarStyles.SEARCH_HISTORY_DISPLAY);
+ }, [searchHistory, searchQuery]);
+
+ const handleOpenInTab = async () => {
+ await openExtensionPage('popup.html', { mode: 'tab' });
window.close();
};
+ const handleSearchChange = (e: React.ChangeEvent) => {
+ setSearchQuery(e.target.value);
+ setShowResults(true);
+ setSelectedIndex(-1);
+ };
+
+ const saveToHistory = async (query: string) => {
+ if (!query.trim()) return;
+ setSearchHistory((prev) => {
+ const newHistory = [query, ...prev.filter((h) => h !== query)].slice(
+ 0,
+ topBarStyles.SEARCH_HISTORY_LIMIT,
+ );
+ storageUtil.set('app/searchHistory', newHistory).catch((error) => {
+ console.error('保存搜索历史失败:', error);
+ });
+ return newHistory;
+ });
+ };
+
+ const handleSelectFeature = (feature: FeatureConfig) => {
+ navigateTo(feature.key);
+ saveToHistory(t(feature.labelKey));
+ setSearchQuery('');
+ setShowResults(false);
+ };
+
+ const toggleLanguage = async () => {
+ const currentLng = normalizeLanguage(i18n.language);
+ const currentIndex = SUPPORTED_LANGUAGES.indexOf(currentLng);
+ const nextIndex = (currentIndex + 1) % SUPPORTED_LANGUAGES.length;
+ const newLng = SUPPORTED_LANGUAGES[nextIndex];
+ await i18n.changeLanguage(newLng);
+ await storageUtil.set('app/language', newLng);
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ const totalItems = searchQuery.trim() ? searchResults.length : displayedHistory.length;
+
+ if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ setSelectedIndex((prev) => (prev < totalItems - 1 ? prev + 1 : prev));
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev));
+ } else if (e.key === 'Enter') {
+ if (selectedIndex >= 0) {
+ if (searchQuery.trim()) {
+ handleSelectFeature(searchResults[selectedIndex]);
+ } else {
+ const selectedQuery = displayedHistory[selectedIndex];
+ setSearchQuery(selectedQuery);
+ setSelectedIndex(-1);
+ // 触发搜索:如果匹配到功能则跳转,否则保持搜索词展示结果
+ const matchedFeature = FEATURES.find(
+ (f) => f.key !== 'dashboard' && t(f.labelKey) === selectedQuery,
+ );
+ if (matchedFeature) {
+ handleSelectFeature(matchedFeature);
+ }
+ }
+ } else if (searchQuery.trim() && searchResults.length > 0) {
+ handleSelectFeature(searchResults[0]);
+ }
+ } else if (e.key === 'Escape') {
+ setShowResults(false);
+ inputRef.current?.blur();
+ }
+ };
+
const isDashboard = currentPage === 'dashboard';
return (
@@ -21,12 +149,13 @@ export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void })
justifyContent="space-between"
alignItems="center"
sx={{
- px: { xs: 1.5, sm: 2 },
+ px: { xs: 1, sm: 2 },
py: 1.5,
borderBottom: '1px solid',
borderColor: 'grey.100',
bgcolor: 'background.paper',
- zIndex: 1100,
+ zIndex: topBarStyles.Z_INDEX,
+ position: 'relative',
}}
>
@@ -34,6 +163,7 @@ export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void })
void })
textTransform: 'uppercase',
fontSize: '0.75rem',
color: 'text.secondary',
+ ml: 1,
+ display: { xs: 'none', md: 'block' },
}}
>
- Testing Tools
+ {t('common:appName')}
+
+ setShowResults(false)}>
+
+ setShowResults(true)}
+ onKeyDown={handleKeyDown}
+ inputProps={{ 'aria-label': t('common:buttons.search') }}
+ startAdornment={}
+ endAdornment={
+ searchQuery && (
+ {
+ setSearchQuery('');
+ setSelectedIndex(-1);
+ }}
+ aria-label={t('common:buttons.clearSearch')}
+ >
+
+
+ )
+ }
+ sx={{
+ width: '100%',
+ bgcolor: 'grey.50',
+ px: 1.5,
+ py: 0.5,
+ borderRadius: 2,
+ fontSize: '0.875rem',
+ border: '1px solid transparent',
+ transition: 'all 0.2s',
+ '&:hover': { bgcolor: 'grey.100' },
+ '&.Mui-focused': {
+ bgcolor: 'background.paper',
+ borderColor: 'primary.main',
+ boxShadow: '0 0 0 2px rgba(25, 118, 210, 0.1)',
+ },
+ }}
+ />
+
+ {showResults && (searchQuery.trim() || displayedHistory.length > 0) && (
+
+
+ {searchQuery.trim() ? (
+ searchResults.length > 0 ? (
+ searchResults.map((feature, index) => (
+ handleSelectFeature(feature)}
+ role="option"
+ aria-selected={selectedIndex === index}
+ sx={{ py: 1 }}
+ >
+ {feature.icon}
+
+
+ ))
+ ) : (
+
+
+ {t('common:buttons.noResults')}
+
+
+ )
+ ) : (
+ <>
+
+
+ {t('common:buttons.recentSearch')}
+
+
+ {displayedHistory.map((item, index) => (
+ {
+ setSearchQuery(item);
+ setSelectedIndex(-1);
+ }}
+ role="option"
+ aria-selected={selectedIndex === index}
+ >
+
+
+
+
+
+ ))}
+ >
+ )}
+
+
+ )}
+
+
+
+
-
-
+
+
+
+
+
+
+
-
-
+
+
diff --git a/components/UrlToQrCodeSection.tsx b/components/UrlToQrCodeSection.tsx
deleted file mode 100644
index 4081a87..0000000
--- a/components/UrlToQrCodeSection.tsx
+++ /dev/null
@@ -1,224 +0,0 @@
-import React, { useState } from 'react';
-import {
- Box,
- Typography,
- TextField,
- Button,
- Stack,
- Accordion,
- AccordionSummary,
- AccordionDetails,
- CircularProgress,
-} from '@mui/material';
-import QrCodeIcon from '@mui/icons-material/QrCode';
-import DownloadIcon from '@mui/icons-material/Download';
-import ContentCopyIcon from '@mui/icons-material/ContentCopy';
-import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
-import QRious from 'qrious';
-import { qrCodePageStyles } from '@/config/pageTheme';
-import type { SnackbarOptions } from '@/components/GlobalSnackbar';
-import { copyImageToClipboard } from '@/utils/clipboard';
-
-interface UrlToQrCodeSectionProps {
- expanded: boolean;
- onExpandedChange: (expanded: boolean) => void;
- showMessage: (message: string, options?: SnackbarOptions) => void;
-}
-
-const UrlToQrCodeSection = ({
- expanded,
- onExpandedChange,
- showMessage,
-}: UrlToQrCodeSectionProps) => {
- const [urlInput, setUrlInput] = useState('');
- const [urlError, setUrlError] = useState('');
- const [qrCodeDataUrl, setQrCodeDataUrl] = useState('');
- const [generating, setGenerating] = useState(false);
-
- const handleUrlInputChange = (e: React.ChangeEvent) => {
- setUrlInput(e.target.value);
- setUrlError('');
- };
-
- const generateQrCode = async () => {
- if (!urlInput) {
- setUrlError('请输入 URL');
- return;
- }
-
- try {
- setGenerating(true);
- setUrlError('');
-
- let url = urlInput;
- if (!url.startsWith('http://') && !url.startsWith('https://')) {
- url = 'https://' + url;
- }
-
- // 使用 QRious 替代 qrcode 库,体积更小
- const qr = new QRious({
- value: url,
- size: 250,
- level: 'H',
- foreground: qrCodePageStyles.black,
- background: qrCodePageStyles.white,
- });
-
- setQrCodeDataUrl(qr.toDataURL());
- showMessage('二维码生成成功', { severity: 'success', autoHideDuration: 1000 });
- } catch (error) {
- console.error('生成二维码失败:', error);
- showMessage('生成二维码失败,请重试', { severity: 'error', autoHideDuration: 300 });
- } finally {
- setGenerating(false);
- }
- };
-
- const downloadQrCode = () => {
- if (!qrCodeDataUrl) return;
-
- const link = document.createElement('a');
- link.href = qrCodeDataUrl;
- link.download = 'qrcode.png';
- link.click();
- showMessage('二维码下载成功', { severity: 'success' });
- };
-
- const copyQrCode = async () => {
- if (!qrCodeDataUrl) return;
-
- const response = await fetch(qrCodeDataUrl);
- const blob = await response.blob();
- await copyImageToClipboard(blob)
- .then(() => {
- showMessage('二维码已复制到剪贴板', { severity: 'success' });
- })
- .catch(() => {
- showMessage('复制二维码失败,请重试', { severity: 'error' });
- });
- };
-
- return (
- onExpandedChange(isExpanded)}
- sx={{
- borderRadius: 4,
- boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
- '&:before': { display: 'none' },
- }}
- >
- } sx={{ borderBottom: 'none' }}>
-
-
-
- URL 转二维码
-
-
-
-
-
-
-
- : }
- onClick={generateQrCode}
- disabled={generating}
- sx={{
- py: 1.2,
- borderRadius: 3,
- bgcolor: qrCodePageStyles.successColor,
- fontWeight: 700,
- '&:hover': {
- bgcolor: qrCodePageStyles.successDark,
- },
- }}
- >
- {generating ? '生成中...' : '生成二维码'}
-
-
-
- {qrCodeDataUrl ? (
-
-
-
- }
- onClick={downloadQrCode}
- sx={{
- borderRadius: 2,
- borderColor: qrCodePageStyles.successColor,
- color: qrCodePageStyles.successColor,
- '&:hover': {
- borderColor: qrCodePageStyles.successDark,
- bgcolor: 'rgba(76, 175, 80, 0.05)',
- },
- }}
- >
- 下载二维码
-
- }
- onClick={copyQrCode}
- sx={{
- borderRadius: 2,
- bgcolor: qrCodePageStyles.successColor,
- '&:hover': {
- bgcolor: qrCodePageStyles.successDark,
- },
- }}
- >
- 复制二维码
-
-
-
- ) : (
-
- 二维码将显示在这里
-
- )}
-
-
-
-
- );
-};
-
-export default UrlToQrCodeSection;
diff --git a/components/__tests__/Button.test.tsx b/components/__tests__/Button.test.tsx
index 01cfc85..56cf1b2 100644
--- a/components/__tests__/Button.test.tsx
+++ b/components/__tests__/Button.test.tsx
@@ -1,6 +1,6 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { render, screen, fireEvent } from '@testing-library/react';
-import Button from '../Button';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { fireEvent, render, screen } from '@testing-library/react';
+import Button from '@/components/Button';
describe('Button 组件', () => {
beforeEach(() => {
diff --git a/components/__tests__/GlobalSnackbar.test.tsx b/components/__tests__/GlobalSnackbar.test.tsx
index 4176ae4..ee37059 100644
--- a/components/__tests__/GlobalSnackbar.test.tsx
+++ b/components/__tests__/GlobalSnackbar.test.tsx
@@ -1,13 +1,13 @@
-import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
-import { render, screen, act, renderHook } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { act, render, renderHook, screen } from '@testing-library/react';
import React from 'react';
import {
GlobalSnackbar,
- useSnackbarState,
- useSnackbar,
- SnackbarProvider,
type GlobalSnackbarProps,
-} from '../GlobalSnackbar';
+ SnackbarProvider,
+ useSnackbar,
+ useSnackbarState,
+} from '@/components/GlobalSnackbar';
describe('GlobalSnackbar 组件系统', () => {
const mockOnClose = vi.fn();
diff --git a/components/__tests__/PageHeader.test.tsx b/components/__tests__/PageHeader.test.tsx
index 8ddfb7a..a601f24 100644
--- a/components/__tests__/PageHeader.test.tsx
+++ b/components/__tests__/PageHeader.test.tsx
@@ -1,8 +1,8 @@
-import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import CloseIcon from '@mui/icons-material/Close';
import { render, screen } from '@testing-library/react';
-import PageHeader, { type PageHeaderProps } from '../PageHeader';
+import PageHeader, { type PageHeaderProps } from '@/components/PageHeader';
describe('PageHeader 组件系统', () => {
beforeEach(() => {
diff --git a/components/__tests__/RouterContainer.test.tsx b/components/__tests__/RouterContainer.test.tsx
index 09f457e..3cdbf08 100644
--- a/components/__tests__/RouterContainer.test.tsx
+++ b/components/__tests__/RouterContainer.test.tsx
@@ -1,6 +1,6 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
-import RouterContainer from '../RouterContainer';
+import RouterContainer from '@/components/RouterContainer';
import { RouterProvider } from '@/providers/RouterProvider';
import { SnackbarProvider } from '@/components/GlobalSnackbar';
import type { PageType } from '@/types/storage';
diff --git a/components/__tests__/StorageCleanerConfirm.test.tsx b/components/__tests__/StorageCleanerConfirm.test.tsx
index e9e4782..b307439 100644
--- a/components/__tests__/StorageCleanerConfirm.test.tsx
+++ b/components/__tests__/StorageCleanerConfirm.test.tsx
@@ -1,6 +1,6 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { render, screen, fireEvent } from '@testing-library/react';
-import { StorageCleanerConfirm } from '../StorageCleanerConfirm';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { fireEvent, render, screen } from '@testing-library/react';
+import { StorageCleanerConfirm } from '@/pages/StorageCleaner/StorageCleanerConfirm';
import type { StorageCleanerOptions } from '@/types/storage';
import React from 'react';
@@ -36,25 +36,27 @@ describe('StorageCleanerConfirm 组件', () => {
describe('渲染测试', () => {
it('open 为 true 时应渲染对话框', () => {
renderComponent();
- expect(screen.getByText('确认清理数据?')).toBeInTheDocument();
+ expect(screen.getByText('storageCleaner:confirmTitle')).toBeInTheDocument();
});
it('应显示警告信息', () => {
renderComponent();
- expect(screen.getByText(/此操作不可撤销/i)).toBeInTheDocument();
+ expect(screen.getByText(/storageCleaner:irreversible/i)).toBeInTheDocument();
});
it('应将选中的选项显示为标签', () => {
renderComponent();
- expect(screen.getByText('LocalStorage')).toBeInTheDocument();
- expect(screen.getByText('Session Storage')).toBeInTheDocument();
- expect(screen.getByText('Cookies')).toBeInTheDocument();
+ expect(screen.getByText('storageCleaner:options.localStorage')).toBeInTheDocument();
+ expect(screen.getByText('storageCleaner:options.sessionStorage')).toBeInTheDocument();
+ expect(screen.getByText('storageCleaner:options.cookies')).toBeInTheDocument();
});
it('应显示取消和确认按钮', () => {
renderComponent();
- expect(screen.getByRole('button', { name: /取消/i })).toBeInTheDocument();
- expect(screen.getByRole('button', { name: /确认清理/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /common:buttons.cancel/i })).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: /storageCleaner:confirmAction/i }),
+ ).toBeInTheDocument();
});
});
@@ -62,7 +64,7 @@ describe('StorageCleanerConfirm 组件', () => {
it('点击取消时应调用 onClose', () => {
renderComponent();
- fireEvent.click(screen.getByRole('button', { name: /取消/i }));
+ fireEvent.click(screen.getByRole('button', { name: /common:buttons.cancel/i }));
expect(mockOnClose).toHaveBeenCalledTimes(1);
expect(mockOnConfirm).not.toHaveBeenCalled();
});
@@ -70,7 +72,7 @@ describe('StorageCleanerConfirm 组件', () => {
it('点击确认时应调用 onConfirm', () => {
renderComponent();
- fireEvent.click(screen.getByRole('button', { name: /确认清理/i }));
+ fireEvent.click(screen.getByRole('button', { name: /storageCleaner:confirmAction/i }));
expect(mockOnConfirm).toHaveBeenCalledTimes(1);
expect(mockOnClose).not.toHaveBeenCalled();
});
@@ -89,10 +91,10 @@ describe('StorageCleanerConfirm 组件', () => {
renderComponent({ options: partialOptions });
- expect(screen.getByText('LocalStorage')).toBeInTheDocument();
- expect(screen.getByText('IndexedDB')).toBeInTheDocument();
- expect(screen.queryByText('Session Storage')).not.toBeInTheDocument();
- expect(screen.queryByText('Cookies')).not.toBeInTheDocument();
+ expect(screen.getByText('storageCleaner:options.localStorage')).toBeInTheDocument();
+ expect(screen.getByText('storageCleaner:options.indexedDB')).toBeInTheDocument();
+ expect(screen.queryByText('storageCleaner:options.sessionStorage')).not.toBeInTheDocument();
+ expect(screen.queryByText('storageCleaner:options.cookies')).not.toBeInTheDocument();
});
it('应处理空选项', () => {
@@ -115,7 +117,7 @@ describe('StorageCleanerConfirm 组件', () => {
describe('对话框行为测试', () => {
it('open 为 false 时不应渲染', () => {
renderComponent({ open: false });
- expect(screen.queryByText('确认清理数据?')).not.toBeInTheDocument();
+ expect(screen.queryByText('storageCleaner:confirmTitle')).not.toBeInTheDocument();
});
it('应使用不同选项渲染', () => {
@@ -130,8 +132,8 @@ describe('StorageCleanerConfirm 组件', () => {
renderComponent({ options: customOptions });
- expect(screen.getByText('Session Storage')).toBeInTheDocument();
- expect(screen.getByText('Cookies')).toBeInTheDocument();
+ expect(screen.getByText('storageCleaner:options.sessionStorage')).toBeInTheDocument();
+ expect(screen.getByText('storageCleaner:options.cookies')).toBeInTheDocument();
});
});
});
diff --git a/components/__tests__/ToolCard.test.tsx b/components/__tests__/ToolCard.test.tsx
index 8420f05..23c194a 100644
--- a/components/__tests__/ToolCard.test.tsx
+++ b/components/__tests__/ToolCard.test.tsx
@@ -1,6 +1,6 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { render, screen, fireEvent } from '@testing-library/react';
-import ToolCard from '../ToolCard';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { fireEvent, render, screen } from '@testing-library/react';
+import ToolCard from '@/pages/Dashboard/ToolCard';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
describe('ToolCard 组件', () => {
@@ -76,37 +76,20 @@ describe('ToolCard 组件', () => {
expect(container.querySelector('[data-testid="snapshot"]')).not.toBeInTheDocument();
});
- });
- describe('AI 徽章测试', () => {
- it('hasAI 为 true 时应渲染 AI 徽章', () => {
+ it('应使用 CardActionArea 渲染,支持键盘聚焦', () => {
render(
}
onClick={() => {}}
/>,
);
- const autoAwesomeIcon = screen.getByTestId('AutoAwesomeIcon');
- expect(autoAwesomeIcon).toBeInTheDocument();
- });
-
- it('hasAI 为 false 时不应渲染 AI 徽章', () => {
- render(
- }
- onClick={() => {}}
- />,
- );
-
- const autoAwesomeIcon = screen.queryByTestId('AutoAwesomeIcon');
- expect(autoAwesomeIcon).not.toBeInTheDocument();
+ const button = screen.getByRole('button', { name: /可聚焦/ });
+ expect(button).toBeInTheDocument();
+ expect(button).toHaveAttribute('tabIndex', '0');
});
});
@@ -122,10 +105,25 @@ describe('ToolCard 组件', () => {
/>,
);
- const card = screen.getByText('可点击').closest('.MuiBox-root');
- if (card) {
- fireEvent.click(card);
- }
+ const button = screen.getByRole('button', { name: /可点击/ });
+ fireEvent.click(button);
+
+ expect(handleClick).toHaveBeenCalledTimes(1);
+ });
+
+ it('按 Enter 键时应调用 onClick', () => {
+ const handleClick = vi.fn();
+ render(
+ }
+ onClick={handleClick}
+ />,
+ );
+
+ const button = screen.getByRole('button', { name: /键盘可触发/ });
+ fireEvent.click(button);
expect(handleClick).toHaveBeenCalledTimes(1);
});
@@ -134,17 +132,16 @@ describe('ToolCard 组件', () => {
describe('样式测试', () => {
it('应应用自定义颜色代码', () => {
const customColor = '#ff5722';
- const { container } = render(
+ render(
}
+ icon={}
onClick={() => {}}
/>,
);
- const iconContainer = container.querySelector('.MuiBox-root > div');
- expect(iconContainer).toBeInTheDocument();
+ expect(screen.getByTestId('custom-color-icon')).toBeInTheDocument();
});
});
});
diff --git a/components/__tests__/TopBar.test.tsx b/components/__tests__/TopBar.test.tsx
index fa379bc..3a9a6ba 100644
--- a/components/__tests__/TopBar.test.tsx
+++ b/components/__tests__/TopBar.test.tsx
@@ -1,6 +1,6 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { render, screen, fireEvent } from '@testing-library/react';
-import TopBar from '../TopBar';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { fireEvent, render, screen } from '@testing-library/react';
+import TopBar from '@/components/TopBar';
import { RouterProvider } from '@/providers/RouterProvider';
import type { PageType } from '@/types/storage';
import React from 'react';
@@ -35,7 +35,7 @@ describe('TopBar 组件', () => {
describe('渲染测试', () => {
it('应使用默认标题渲染', () => {
renderWithProvider();
- expect(screen.getByText('Testing Tools')).toBeInTheDocument();
+ expect(screen.getByText('common:appName')).toBeInTheDocument();
});
it('不在 dashboard 时应渲染返回按钮', () => {
diff --git a/config/__tests__/features.test.ts b/config/__tests__/features.test.ts
index 7d2562e..ceef5fc 100644
--- a/config/__tests__/features.test.ts
+++ b/config/__tests__/features.test.ts
@@ -5,7 +5,7 @@ import {
getDefaultPageOrder,
getDefaultVisibleFeatureKeys,
getFeatureByKey,
-} from '../features';
+} from '@/config/features';
describe('features', () => {
describe('FEATURES', () => {
@@ -16,13 +16,13 @@ describe('features', () => {
it('should have all required properties for each feature', () => {
FEATURES.forEach((feature) => {
expect(feature).toHaveProperty('key');
- expect(feature).toHaveProperty('label');
- expect(feature).toHaveProperty('description');
+ expect(feature).toHaveProperty('labelKey');
+ expect(feature).toHaveProperty('descriptionKey');
expect(feature).toHaveProperty('defaultVisible');
expect(feature).toHaveProperty('components');
expect(typeof feature.key).toBe('string');
- expect(typeof feature.label).toBe('string');
- expect(typeof feature.description).toBe('string');
+ expect(typeof feature.labelKey).toBe('string');
+ expect(typeof feature.descriptionKey).toBe('string');
expect(typeof feature.defaultVisible).toBe('boolean');
expect(typeof feature.components).toBe('object');
expect(feature.components).toHaveProperty('popup');
@@ -50,14 +50,14 @@ describe('features', () => {
const feature = getFeatureByKey('dashboard');
expect(feature).toBeDefined();
expect(feature?.key).toBe('dashboard');
- expect(feature?.label).toBe('Dashboard');
+ expect(feature?.labelKey).toBe('features:dashboard.title');
});
it('should return timestamp feature', () => {
const feature = getFeatureByKey('timestamp');
expect(feature).toBeDefined();
expect(feature?.key).toBe('timestamp');
- expect(feature?.label).toBe('时间戳');
+ expect(feature?.labelKey).toBe('features:timestamp.title');
expect(feature?.themeColor).toBeDefined();
});
@@ -65,7 +65,7 @@ describe('features', () => {
const feature = getFeatureByKey('storageCleaner');
expect(feature).toBeDefined();
expect(feature?.key).toBe('storageCleaner');
- expect(feature?.label).toBe('存储清理');
+ expect(feature?.labelKey).toBe('features:storageCleaner.title');
});
it('should return undefined for invalid key', () => {
diff --git a/config/features.tsx b/config/features.tsx
index 0977994..82cec13 100644
--- a/config/features.tsx
+++ b/config/features.tsx
@@ -1,4 +1,4 @@
-import React, { ReactNode, lazy } from 'react';
+import { type ComponentType, lazy, ReactNode } from 'react';
import type { PageType } from '@/types/storage';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import StorageIcon from '@mui/icons-material/Storage';
@@ -9,12 +9,12 @@ import VpnKeyIcon from '@mui/icons-material/VpnKey';
import { THEME_COLORS } from './pageTheme';
// 懒加载页面组件
-const DashboardPage = lazy(() => import('@/pages/DashboardPage'));
-const TimestampPage = lazy(() => import('@/pages/TimestampPage'));
-const StorageCleanerPage = lazy(() => import('@/pages/StorageCleanerPage'));
-const QrCodePage = lazy(() => import('@/pages/QrCodePage'));
-const TextStatisticsPage = lazy(() => import('@/pages/TextStatisticsPage'));
-const JwtPage = lazy(() => import('@/pages/JwtPage'));
+const DashboardPage = lazy(() => import('@/pages/Dashboard'));
+const TimestampPage = lazy(() => import('@/pages/Timestamp'));
+const StorageCleanerPage = lazy(() => import('@/pages/StorageCleaner'));
+const QrCodePage = lazy(() => import('@/pages/QrCode'));
+const TextStatisticsPage = lazy(() => import('@/pages/TextStatistics'));
+const JwtPage = lazy(() => import('@/pages/Jwt'));
/**
* 功能配置接口
@@ -24,10 +24,10 @@ const JwtPage = lazy(() => import('@/pages/JwtPage'));
export interface FeatureConfig {
/** 页面类型标识 */
key: PageType;
- /** 功能名称(用于路由标签和卡片标题) */
- label: string;
- /** 功能描述(用于仪表盘卡片) */
- description: string;
+ /** 功能名称翻译键 */
+ labelKey: string;
+ /** 功能描述翻译键 */
+ descriptionKey: string;
/** 主题颜色(用于仪表盘卡片) */
themeColor?: string;
/** 图标组件(用于仪表盘卡片) */
@@ -37,19 +37,19 @@ export interface FeatureConfig {
/** 不同显示模式对应的组件 */
components: {
/** 弹窗模式组件 */
- popup: React.ComponentType;
+ popup: ComponentType;
/** 侧边栏模式组件 */
- sidepanel: React.ComponentType;
+ sidepanel: ComponentType;
/** 标签页模式组件 */
- tab: React.ComponentType;
+ tab: ComponentType;
};
}
export const FEATURES: FeatureConfig[] = [
{
key: 'dashboard',
- label: 'Dashboard',
- description: '',
+ labelKey: 'features:dashboard.title',
+ descriptionKey: '',
defaultVisible: true,
components: {
popup: DashboardPage,
@@ -59,8 +59,8 @@ export const FEATURES: FeatureConfig[] = [
},
{
key: 'timestamp',
- label: '时间戳',
- description: 'Unix 毫秒数转换与格式化',
+ labelKey: 'features:timestamp.title',
+ descriptionKey: 'features:timestamp.description',
themeColor: THEME_COLORS.primary,
icon: ,
defaultVisible: true,
@@ -72,8 +72,8 @@ export const FEATURES: FeatureConfig[] = [
},
{
key: 'storageCleaner',
- label: '存储清理',
- description: '清理缓存、Cookies 及本地存储',
+ labelKey: 'features:storageCleaner.title',
+ descriptionKey: 'features:storageCleaner.description',
themeColor: THEME_COLORS.warning,
icon: ,
defaultVisible: true,
@@ -85,8 +85,8 @@ export const FEATURES: FeatureConfig[] = [
},
{
key: 'qrCode',
- label: '二维码工具',
- description: '生成当前选中的 URL 的二维码',
+ labelKey: 'features:qrCode.title',
+ descriptionKey: 'features:qrCode.description',
themeColor: THEME_COLORS.success,
icon: ,
defaultVisible: true,
@@ -98,8 +98,8 @@ export const FEATURES: FeatureConfig[] = [
},
{
key: 'textStatistics',
- label: '文本统计',
- description: '实时分析文本字符、单词及字节',
+ labelKey: 'features:textStatistics.title',
+ descriptionKey: 'features:textStatistics.description',
themeColor: THEME_COLORS.purple,
icon: ,
defaultVisible: true,
@@ -111,8 +111,8 @@ export const FEATURES: FeatureConfig[] = [
},
{
key: 'jwt',
- label: 'JWT 解析',
- description: 'JSON Web Token 解码与查看',
+ labelKey: 'features:jwt.title',
+ descriptionKey: 'features:jwt.description',
themeColor: THEME_COLORS.indigo,
icon: ,
defaultVisible: true,
diff --git a/config/pageTheme.ts b/config/pageTheme.ts
index aa86c54..edb7152 100644
--- a/config/pageTheme.ts
+++ b/config/pageTheme.ts
@@ -104,6 +104,11 @@ export const timestampPageStyles = {
fontWeight: 600,
},
},
+ SELECT_MENU_PROPS: {
+ PaperProps: {
+ sx: { borderRadius: 3, mt: 1, boxShadow: '0 12px 32px rgba(0,0,0,0.1)' },
+ },
+ },
cardBg: alpha(THEME_COLORS.primary, 0.04),
cardBorder: alpha(THEME_COLORS.primary, 0.1),
switcherBg: alpha(THEME_COLORS.primary, 0.08),
@@ -111,6 +116,161 @@ export const timestampPageStyles = {
mutedText: alpha(THEME_COLORS.primary, 0.4),
resultBg: alpha(THEME_COLORS.primary, 0.05),
buttonHover: `0 8px 24px ${alpha(THEME_COLORS.primary, 0.2)}`,
+ /** 模式切换器 (ToggleButtonGroup) 样式 */
+ MODE_SWITCHER: {
+ width: '100%',
+ mb: 2.5,
+ borderRadius: 4,
+ bgcolor: 'grey.100',
+ border: '1px solid',
+ borderColor: 'grey.200',
+ p: 0.6,
+ '& .MuiToggleButtonGroup-grouped': {
+ flex: 1,
+ border: 'none',
+ borderRadius: 3.5,
+ py: 1,
+ fontWeight: 800,
+ fontSize: '0.75rem',
+ color: 'text.secondary',
+ transition: 'color 0.3s',
+ '&.Mui-selected': {
+ bgcolor: '#fff',
+ color: 'primary.main',
+ boxShadow: '0 4px 12px rgba(0,0,0,0.05)',
+ },
+ },
+ },
+ /** 单位切换器样式 */
+ UNIT_SWITCHER_CONTAINER: {
+ flex: 1,
+ display: 'flex',
+ bgcolor: 'grey.50',
+ p: 0.5,
+ borderRadius: 3.5,
+ border: '1px solid',
+ borderColor: 'grey.100',
+ },
+ UNIT_SWITCHER_ITEM: (active: boolean) => ({
+ flex: 1,
+ py: 0.8,
+ textAlign: 'center',
+ borderRadius: 3,
+ cursor: 'pointer',
+ fontSize: '0.75rem',
+ fontWeight: 800,
+ transition: 'all 0.2s',
+ bgcolor: active ? '#fff' : 'transparent',
+ color: active ? 'primary.main' : 'text.disabled',
+ boxShadow: active ? '0 2px 8px rgba(0,0,0,0.05)' : 'none',
+ }),
+ /** LiveClock 卡片样式 */
+ LIVE_CLOCK_CARD: {
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ flexWrap: 'wrap',
+ gap: 1.5,
+ p: 1.8,
+ mb: 2.5,
+ bgcolor: alpha(THEME_COLORS.primary, 0.04),
+ borderRadius: 4,
+ border: '1px solid',
+ borderColor: alpha(THEME_COLORS.primary, 0.1),
+ },
+ LIVE_CLOCK_LABEL: {
+ color: THEME_COLORS.primary,
+ fontWeight: 800,
+ fontSize: '0.6rem',
+ textTransform: 'uppercase',
+ letterSpacing: 1,
+ },
+ LIVE_CLOCK_VALUE: {
+ fontWeight: 800,
+ color: THEME_COLORS.primary,
+ fontFamily: 'monospace',
+ fontSize: { xs: '1.1rem', sm: '1.2rem' },
+ letterSpacing: '-0.5px',
+ lineHeight: 1.2,
+ },
+ LIVE_CLOCK_UNIT_SWITCHER: {
+ display: 'flex',
+ p: 0.4,
+ bgcolor: alpha(THEME_COLORS.primary, 0.08),
+ borderRadius: 2.5,
+ border: '1px solid',
+ borderColor: alpha(THEME_COLORS.primary, 0.1),
+ },
+ LIVE_CLOCK_UNIT_ITEM: (active: boolean) => ({
+ px: { xs: 1, sm: 1.2 },
+ py: 0.35,
+ borderRadius: 2,
+ cursor: 'pointer',
+ fontSize: '0.65rem',
+ fontWeight: 900,
+ transition: 'all 0.2s',
+ bgcolor: active ? '#fff' : 'transparent',
+ color: active ? 'primary.main' : alpha(THEME_COLORS.primary, 0.4),
+ boxShadow: active ? '0 2px 6px rgba(33, 150, 243, 0.2)' : 'none',
+ }),
+ LIVE_CLOCK_ICON_BUTTON: {
+ color: THEME_COLORS.primary,
+ bgcolor: '#fff',
+ boxShadow: '0 2px 4px rgba(0,0,0,0.05)',
+ '&:hover': { bgcolor: THEME_COLORS.primary, color: '#fff' },
+ },
+ LIVE_CLOCK_DIVIDER: {
+ mx: 0.5,
+ my: 1,
+ borderColor: alpha(THEME_COLORS.primary, 0.1),
+ },
+ /** ResultView 样式 */
+ RESULT_LABEL: {
+ color: 'text.secondary',
+ mb: 1.2,
+ display: 'block',
+ fontWeight: 800,
+ fontSize: '0.7rem',
+ },
+ RESULT_MAIN_BOX: {
+ bgcolor: alpha(THEME_COLORS.primary, 0.05),
+ p: 2,
+ borderRadius: 4,
+ position: 'relative',
+ mb: 2.5,
+ border: '1px solid',
+ borderColor: alpha(THEME_COLORS.primary, 0.1),
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ },
+ RESULT_MAIN_TEXT: {
+ fontFamily: 'monospace',
+ fontWeight: 700,
+ color: THEME_COLORS.primary,
+ wordBreak: 'break-all',
+ pr: 4,
+ fontSize: '1rem',
+ },
+ RESULT_EXTRA_STACK: {
+ bgcolor: alpha(THEME_COLORS.primary, 0.05),
+ p: 2,
+ borderRadius: 4,
+ border: '1px solid',
+ borderColor: alpha(THEME_COLORS.primary, 0.1),
+ },
+ RESULT_EXTRA_LABEL: {
+ color: 'text.disabled',
+ fontWeight: 700,
+ fontSize: '0.65rem',
+ pr: 4,
+ },
+ RESULT_EXTRA_VALUE: {
+ fontFamily: 'monospace',
+ color: THEME_COLORS.primary,
+ fontWeight: 600,
+ fontSize: '0.65rem',
+ },
} as const;
/**
@@ -123,6 +283,260 @@ export const storageCleanerPageStyles = {
warningBorder: `1px solid ${alpha(THEME_COLORS.warning, 0.2)}`,
errorBorder: `1px solid ${alpha(THEME_COLORS.error, 0.2)}`,
errorBg: alpha(THEME_COLORS.error, 0.05),
+ /** 选项网格容器 */
+ OPTIONS_GRID_CONTAINER: {
+ mb: 3,
+ border: '1px solid',
+ borderColor: 'grey.100',
+ borderRadius: 4,
+ bgcolor: 'background.paper',
+ boxShadow: '0 4px 12px rgba(0, 0, 0, 0.05)',
+ transition: 'all 0.2s',
+ overflow: 'hidden',
+ '&:hover': {
+ boxShadow: '0 6px 16px rgba(0, 0, 0, 0.08)',
+ },
+ },
+ OPTIONS_GRID_FOOTER: {
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ px: 2.7,
+ py: 0.8,
+ borderBottomLeftRadius: 4,
+ borderBottomRightRadius: 4,
+ transition: 'all 0.2s',
+ '&:hover': {
+ bgcolor: 'rgba(0, 0, 0, 0.04)',
+ },
+ },
+ OPTIONS_GRID_CHECKBOX: {
+ p: 0.6,
+ mr: 0,
+ '& .MuiSvgIcon-root': {
+ fontSize: 18,
+ transition: 'transform 0.2s',
+ },
+ '&:hover .MuiSvgIcon-root': {
+ transform: 'scale(1.1)',
+ },
+ },
+ /** 选项项 */
+ OPTION_ITEM: (checked: boolean) => ({
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ py: 1,
+ px: { xs: 1, sm: 1.5 },
+ borderRadius: 3,
+ transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
+ bgcolor: checked ? alpha(THEME_COLORS.warning, 0.05) : 'transparent',
+ border: `1px solid ${checked ? alpha(THEME_COLORS.warning, 0.2) : 'transparent'}`,
+ '&:hover': {
+ bgcolor: checked ? alpha(THEME_COLORS.warning, 0.1) : 'rgba(0, 0, 0, 0.02)',
+ transform: 'translateY(-1px)',
+ boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
+ },
+ }),
+ OPTION_ITEM_LABEL: (checked: boolean) => ({
+ fontSize: '0.75rem',
+ display: 'block',
+ lineHeight: 1.2,
+ whiteSpace: 'nowrap',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ transition: 'color 0.2s',
+ color: checked ? THEME_COLORS.warning : 'text.primary',
+ }),
+ OPTION_ITEM_SIZE: {
+ color: 'text.secondary',
+ fontSize: '0.65rem',
+ fontWeight: 600,
+ display: 'block',
+ mt: 0.3,
+ lineHeight: 1,
+ whiteSpace: 'nowrap',
+ opacity: 0.8,
+ },
+ OPTION_ITEM_NO_DATA: {
+ color: 'grey.400',
+ fontSize: '0.65rem',
+ fontWeight: 500,
+ display: 'block',
+ mt: 0.3,
+ lineHeight: 1,
+ fontStyle: 'italic',
+ },
+ OPTION_ITEM_CHECKBOX: {
+ p: 0.6,
+ '& .MuiSvgIcon-root': {
+ fontSize: 18,
+ transition: 'transform 0.2s',
+ },
+ '&:hover .MuiSvgIcon-root': {
+ transform: 'scale(1.1)',
+ },
+ },
+ /** 自动刷新切换 */
+ AUTO_REFRESH_CONTAINER: {
+ mb: 3,
+ p: 1.5,
+ borderRadius: 4,
+ bgcolor: 'background.paper',
+ border: '1px solid',
+ borderColor: 'grey.100',
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ boxShadow: '0 2px 8px rgba(0, 0, 0, 0.05)',
+ transition: 'all 0.2s',
+ '&:hover': {
+ boxShadow: '0 4px 12px rgba(0, 0, 0, 0.08)',
+ },
+ },
+ AUTO_REFRESH_SWITCH: {
+ '& .MuiSwitch-track': {
+ borderRadius: 20,
+ },
+ '& .MuiSwitch-thumb': {
+ boxShadow: '0 2px 4px rgba(0, 0, 0, 0.2)',
+ transition: 'all 0.2s',
+ },
+ '&:hover .MuiSwitch-thumb': {
+ transform: 'scale(1.1)',
+ },
+ },
+ /** DomainHeader */
+ DOMAIN_HEADER_BADGE: {
+ bgcolor: alpha(THEME_COLORS.warning, 0.15),
+ color: THEME_COLORS.warning,
+ px: 1.5,
+ py: 0.3,
+ borderRadius: 2,
+ fontWeight: 800,
+ fontSize: '0.7rem',
+ boxShadow: `0 2px 4px ${alpha(THEME_COLORS.warning, 0.2)}`,
+ transition: 'all 0.2s',
+ '&:hover': {
+ bgcolor: alpha(THEME_COLORS.warning, 0.25),
+ },
+ },
+ DOMAIN_HEADER_ICON: {
+ p: 1.2,
+ borderRadius: 3,
+ boxShadow: `0 2px 8px ${alpha(THEME_COLORS.warning, 0.15)}`,
+ transition: 'all 0.2s',
+ '&:hover': {
+ bgcolor: alpha(THEME_COLORS.warning, 0.15),
+ transform: 'scale(1.05)',
+ },
+ },
+ /** ErrorDisplay */
+ ERROR_DISPLAY_CONTAINER: {
+ py: 8,
+ display: 'flex',
+ justifyContent: 'center',
+ alignItems: 'center',
+ minHeight: { xs: 'auto', sm: '400px' },
+ textAlign: 'center',
+ },
+ ERROR_DISPLAY_BOX: {
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ justifyContent: 'center',
+ borderRadius: 4,
+ p: 4,
+ boxShadow: `0 8px 24px ${alpha(THEME_COLORS.error, 0.15)}`,
+ border: `1px solid ${alpha(THEME_COLORS.error, 0.2)}`,
+ bgcolor: alpha(THEME_COLORS.error, 0.05),
+ },
+ /** CleaningResult */
+ CLEANING_RESULT_ALERT: {
+ borderRadius: 3,
+ py: 1,
+ px: 2,
+ boxShadow: '0 4px 12px rgba(0, 0, 0, 0.08)',
+ '& .MuiAlert-message': {
+ fontSize: '0.8rem',
+ fontWeight: 600,
+ lineHeight: 1.4,
+ },
+ '& .MuiAlert-icon': {
+ fontSize: '1.2rem',
+ mr: 1,
+ },
+ },
+ /** StorageCleanerConfirm Dialog */
+ CONFIRM_DIALOG_PAPER: {
+ borderRadius: 6,
+ backgroundImage: 'none',
+ boxShadow: `0 24px 64px -12px ${alpha(THEME_COLORS.black, 0.18)}`,
+ p: 1.5,
+ bgcolor: 'background.paper',
+ },
+ CONFIRM_DIALOG_TITLE: {
+ textAlign: 'center',
+ pt: 4,
+ pb: 1,
+ fontWeight: 900,
+ letterSpacing: '-0.5px',
+ fontSize: '1.35rem',
+ color: 'text.primary',
+ },
+ CONFIRM_DIALOG_CONTENT: {
+ textAlign: 'center',
+ pb: 2,
+ },
+ CONFIRM_DIALOG_DESC: {
+ mb: 3.5,
+ fontWeight: 500,
+ fontSize: '0.9rem',
+ },
+ CONFIRM_DIALOG_CHIP: {
+ bgcolor: alpha(THEME_COLORS.warning, 0.04),
+ fontWeight: 700,
+ color: THEME_COLORS.warning,
+ fontSize: '0.75rem',
+ border: '1px solid',
+ borderColor: alpha(THEME_COLORS.warning, 0.15),
+ borderRadius: 2.5,
+ height: 'auto',
+ '& .MuiChip-label': { px: 1.2, py: 0.6 },
+ },
+ CONFIRM_DIALOG_WARNING_BOX: {
+ display: 'inline-flex',
+ alignItems: 'center',
+ gap: 1,
+ bgcolor: alpha(THEME_COLORS.error, 0.05),
+ color: THEME_COLORS.error,
+ px: 2,
+ py: 0.8,
+ borderRadius: 3,
+ border: '1px dashed',
+ borderColor: alpha(THEME_COLORS.error, 0.2),
+ },
+ CONFIRM_DIALOG_WARNING_TEXT: {
+ fontWeight: 800,
+ display: 'flex',
+ alignItems: 'center',
+ gap: 0.5,
+ fontSize: '0.75rem',
+ },
+ CONFIRM_DIALOG_CANCEL: {
+ boxShadow: '0 0 1px 1px rgba(0, 0, 0, 0.1)',
+ color: 'text.secondary',
+ '&:hover': {
+ bgcolor: 'grey.100',
+ color: 'text.primary',
+ },
+ },
+ CONFIRM_DIALOG_CONFIRM: {
+ bgcolor: THEME_COLORS.warning,
+ '&:hover': {
+ bgcolor: THEME_COLORS.warningDark,
+ },
+ },
} as const;
/**
@@ -136,6 +550,149 @@ export const qrCodePageStyles = {
successDark: THEME_COLORS.successDark,
white: THEME_COLORS.white,
black: THEME_COLORS.black,
+ /** 加载状态容器 */
+ LOADING_CONTAINER: {
+ py: 4,
+ maxWidth: 400,
+ display: 'flex',
+ justifyContent: 'center',
+ alignItems: 'center',
+ minHeight: 200,
+ } as const,
+ /** Accordion 容器 */
+ ACCORDION: {
+ borderRadius: 4,
+ boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
+ '&:before': { display: 'none' },
+ } as const,
+ ACCORDION_SUMMARY: {
+ borderBottom: 'none',
+ } as const,
+ ACCORDION_TITLE_ICON: {
+ display: 'flex',
+ alignItems: 'center',
+ gap: 2,
+ } as const,
+ ACCORDION_TITLE_TEXT: {
+ fontWeight: 700,
+ } as const,
+ /** 主操作按钮(生成/解析) */
+ PRIMARY_BUTTON: {
+ py: 1.2,
+ borderRadius: 3,
+ bgcolor: THEME_COLORS.success,
+ fontWeight: 700,
+ '&:hover': {
+ bgcolor: THEME_COLORS.successDark,
+ },
+ } as const,
+ /** 二维码展示区域 */
+ QR_PREVIEW_CONTAINER: {
+ display: 'flex',
+ flexDirection: 'column',
+ justifyContent: 'center',
+ alignItems: 'center',
+ minHeight: 200,
+ border: '2px dashed',
+ borderColor: 'grey.200',
+ borderRadius: 3,
+ p: 2,
+ bgcolor: 'grey.50',
+ } as const,
+ QR_PREVIEW_INNER: {
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ width: '100%',
+ } as const,
+ QR_PREVIEW_IMAGE: {
+ maxWidth: '100%',
+ height: 'auto',
+ display: 'block',
+ } as const,
+ QR_PREVIEW_ACTIONS: {
+ display: 'flex',
+ gap: 1,
+ mt: 2,
+ } as const,
+ /** 下载按钮 */
+ DOWNLOAD_BUTTON: {
+ borderRadius: 2,
+ borderColor: THEME_COLORS.success,
+ color: THEME_COLORS.success,
+ '&:hover': {
+ borderColor: THEME_COLORS.successDark,
+ bgcolor: alpha(THEME_COLORS.success, 0.05),
+ },
+ } as const,
+ /** 复制按钮 */
+ COPY_BUTTON: {
+ borderRadius: 2,
+ bgcolor: THEME_COLORS.success,
+ '&:hover': {
+ bgcolor: THEME_COLORS.successDark,
+ },
+ } as const,
+ /** 拖拽上传区域 */
+ DROPZONE: (dragging: boolean, hasFile: boolean) =>
+ ({
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ justifyContent: 'center',
+ minHeight: 200,
+ border: '2px dashed',
+ borderColor: dragging || hasFile ? THEME_COLORS.success : 'grey.200',
+ borderRadius: 3,
+ p: 4,
+ bgcolor: dragging
+ ? alpha(THEME_COLORS.success, 0.1)
+ : hasFile
+ ? alpha(THEME_COLORS.success, 0.05)
+ : 'grey.50',
+ cursor: 'pointer',
+ transition: 'all 0.2s',
+ '&:hover': {
+ borderColor: THEME_COLORS.success,
+ bgcolor: alpha(THEME_COLORS.success, 0.05),
+ },
+ }) as const,
+ /** 图片预览容器 */
+ IMAGE_PREVIEW_WRAPPER: {
+ textAlign: 'center',
+ width: '100%',
+ position: 'relative',
+ } as const,
+ IMAGE_PREVIEW_BOX: {
+ position: 'relative',
+ display: 'inline-block',
+ } as const,
+ IMAGE_PREVIEW_IMG: {
+ maxWidth: '100%',
+ maxHeight: 160,
+ borderRadius: 8,
+ objectFit: 'contain',
+ } as const,
+ /** 清除按钮 */
+ CLEAR_BUTTON: {
+ position: 'absolute',
+ top: -8,
+ right: -8,
+ bgcolor: alpha(THEME_COLORS.error, 0.9),
+ color: 'white',
+ '&:hover': {
+ bgcolor: alpha(THEME_COLORS.errorDark, 0.95),
+ },
+ } as const,
+ /** 结果输入框 */
+ RESULT_INPUT: {
+ position: 'relative',
+ mt: 2,
+ } as const,
+ /** 提示文本 */
+ PLACEHOLDER_TEXT: {
+ textAlign: 'center',
+ } as const,
INPUT_STYLE: {},
} as const;
@@ -146,6 +703,15 @@ export const dashboardPageStyles = {
primaryColor: THEME_COLORS.primary,
backgroundColor: '#f5f5f5',
cardBackgroundColor: '#ffffff',
+ GRID_CONTAINER: {
+ display: 'grid',
+ gridTemplateColumns: {
+ xs: '1fr',
+ sm: 'repeat(auto-fill, minmax(300px, 1fr))',
+ },
+ gap: 2,
+ p: 2,
+ },
} as const;
/**
@@ -184,6 +750,21 @@ export const textStatisticsPageStyles = {
cardBorder: alpha(THEME_COLORS.purple, 0.1),
} as const;
+/**
+ * JWT 解析工具页面样式
+ */
+/**
+ * TopBar 组件样式
+ */
+export const topBarStyles = {
+ SEARCH_MAX_WIDTH: 400,
+ DROPDOWN_MAX_HEIGHT: 300,
+ Z_INDEX: 1100,
+ DROPDOWN_Z_INDEX: 1200,
+ SEARCH_HISTORY_LIMIT: 10,
+ SEARCH_HISTORY_DISPLAY: 5,
+} as const;
+
/**
* JWT 解析工具页面样式
*/
diff --git a/entrypoints/options/App.tsx b/entrypoints/options/App.tsx
index 791bd12..31d7046 100644
--- a/entrypoints/options/App.tsx
+++ b/entrypoints/options/App.tsx
@@ -1,17 +1,17 @@
-import { useState, useEffect, useMemo } from 'react';
+import { SyntheticEvent, useEffect, useMemo, useState } from 'react';
import {
+ alpha,
Box,
- Typography,
- Paper,
- Switch,
Button,
CircularProgress,
- Stack,
- IconButton,
- Tabs,
- Tab,
- alpha,
Divider,
+ IconButton,
+ Paper,
+ Stack,
+ Switch,
+ Tab,
+ Tabs,
+ Typography,
} from '@mui/material';
import SettingsIcon from '@mui/icons-material/Settings';
import RefreshIcon from '@mui/icons-material/Refresh';
@@ -21,14 +21,15 @@ import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import type { PageType, StorageSchema } from '@/types/storage';
import { storageUtil } from '@/utils/chromeStorage';
import {
- getFeatureByKey,
getDefaultPageOrder,
getDefaultVisibleFeatureKeys,
+ getFeatureByKey,
} from '@/config/features';
import GlobalSnackbar, { useSnackbarState } from '@/components/GlobalSnackbar';
import ErrorBoundary from '@/components/ErrorBoundary';
import PageHeader from '@/components/PageHeader';
import { THEME_COLORS } from '@/config/pageTheme';
+import { useTranslation } from 'react-i18next';
type WindowType = 'popup' | 'sidepanel' | 'tab';
@@ -37,6 +38,7 @@ type WindowType = 'popup' | 'sidepanel' | 'tab';
* 支持对不同窗口入口的功能显示和排序进行独立配置
*/
export default function App() {
+ const { t } = useTranslation(['features', 'common']);
// 从 URL 参数中初始化当前的 Tab 类型
const initialWindowType = useMemo(() => {
if (typeof window === 'undefined') return 'popup';
@@ -127,7 +129,8 @@ export default function App() {
await storageUtil.set(configKeys.visible, newPages);
setVisiblePages(newPages);
const feature = getFeatureByKey(page);
- showToast(`已${isCurrentlyVisible ? '隐藏' : '显示'} ${feature?.label || page}`, 'success');
+ const label = feature ? t(feature.labelKey) : page;
+ showToast(`已${isCurrentlyVisible ? '隐藏' : '显示'} ${label}`, 'success');
} catch (error) {
console.error('Failed to save config:', error);
showToast('保存失败', 'warning');
@@ -176,7 +179,7 @@ export default function App() {
}
};
- const handleWindowTypeChange = (_event: React.SyntheticEvent, newType: WindowType) => {
+ const handleWindowTypeChange = (_event: SyntheticEvent, newType: WindowType) => {
if (newType !== null) {
setWindowType(newType);
}
@@ -186,6 +189,16 @@ export default function App() {
showMessage(message, { severity });
};
+ if (!isLoaded) {
+ return (
+
+
+
+ );
+ }
+
return (
- {feature.label}
+ {t(feature.labelKey)}
- {feature.description || '暂无描述'}
+ {feature.descriptionKey ? t(feature.descriptionKey) : '暂无描述'}
diff --git a/entrypoints/options/main.tsx b/entrypoints/options/main.tsx
index f9270cf..9be295d 100644
--- a/entrypoints/options/main.tsx
+++ b/entrypoints/options/main.tsx
@@ -3,6 +3,7 @@ import ReactDOM from 'react-dom/client';
import { ThemeProvider } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import theme from '@/config/theme';
+import '@/i18n';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')!).render(
diff --git a/entrypoints/popup/main.tsx b/entrypoints/popup/main.tsx
index 6494c73..5a429fb 100644
--- a/entrypoints/popup/main.tsx
+++ b/entrypoints/popup/main.tsx
@@ -3,6 +3,7 @@ import ReactDOM from 'react-dom/client';
import { ThemeProvider } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import theme from '@/config/theme';
+import '@/i18n';
import App from './App.tsx';
ReactDOM.createRoot(document.getElementById('root')!).render(
diff --git a/entrypoints/sidepanel/main.tsx b/entrypoints/sidepanel/main.tsx
index 6494c73..5a429fb 100644
--- a/entrypoints/sidepanel/main.tsx
+++ b/entrypoints/sidepanel/main.tsx
@@ -3,6 +3,7 @@ import ReactDOM from 'react-dom/client';
import { ThemeProvider } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import theme from '@/config/theme';
+import '@/i18n';
import App from './App.tsx';
ReactDOM.createRoot(document.getElementById('root')!).render(
diff --git a/i18n/index.ts b/i18n/index.ts
new file mode 100644
index 0000000..f2be107
--- /dev/null
+++ b/i18n/index.ts
@@ -0,0 +1,138 @@
+import i18n from 'i18next';
+import { initReactI18next } from 'react-i18next';
+import LanguageDetector from 'i18next-browser-languagedetector';
+import { storageUtil } from '@/utils/chromeStorage';
+import dayjs from 'dayjs';
+
+// 导入语言文件
+import commonZh from './locales/zh/common.json';
+import featuresZh from './locales/zh/features.json';
+import commonEn from './locales/en/common.json';
+import featuresEn from './locales/en/features.json';
+import timestampZh from './locales/zh/timestamp.json';
+import timestampEn from './locales/en/timestamp.json';
+import storageCleanerZh from './locales/zh/storageCleaner.json';
+import storageCleanerEn from './locales/en/storageCleaner.json';
+import qrCodeZh from './locales/zh/qrCode.json';
+import qrCodeEn from './locales/en/qrCode.json';
+import textStatisticsZh from './locales/zh/textStatistics.json';
+import textStatisticsEn from './locales/en/textStatistics.json';
+import jwtZh from './locales/zh/jwt.json';
+import jwtEn from './locales/en/jwt.json';
+
+const resources = {
+ zh: {
+ common: commonZh,
+ features: featuresZh,
+ timestamp: timestampZh,
+ storageCleaner: storageCleanerZh,
+ qrCode: qrCodeZh,
+ textStatistics: textStatisticsZh,
+ jwt: jwtZh,
+ },
+ en: {
+ common: commonEn,
+ features: featuresEn,
+ timestamp: timestampEn,
+ storageCleaner: storageCleanerEn,
+ qrCode: qrCodeEn,
+ textStatistics: textStatisticsEn,
+ jwt: jwtEn,
+ },
+};
+
+export const SUPPORTED_LANGUAGES = ['zh', 'en'] as const;
+export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number];
+
+const LANGUAGE_STORAGE_KEY = 'app/language';
+const LANGUAGE_SNAPSHOT_KEY = 'snapshot/app/language';
+
+/**
+ * 将任意语言标识归一化为受支持的语言代码
+ */
+export const normalizeLanguage = (lng: string): SupportedLanguage => {
+ return lng.startsWith('zh') ? 'zh' : 'en';
+};
+
+/**
+ * 校验语言是否受支持
+ */
+const isValidLanguage = (lng: unknown): lng is SupportedLanguage => {
+ return typeof lng === 'string' && (SUPPORTED_LANGUAGES as readonly string[]).includes(lng);
+};
+
+/**
+ * 同步从 localStorage 获取语言快照(用于消除异步加载产生的首屏闪烁)
+ */
+const getSyncLanguageSnapshot = (): SupportedLanguage | null => {
+ try {
+ const val = localStorage.getItem(LANGUAGE_SNAPSHOT_KEY);
+ if (!val) return null;
+ const parsed = JSON.parse(val) as unknown;
+ return isValidLanguage(parsed) ? parsed : null;
+ } catch (error) {
+ console.error('解析语言同步快照失败:', error);
+ return null;
+ }
+};
+
+// 自定义 Chrome Storage 探测器
+const chromeStorageDetector = {
+ name: 'chromeStorage',
+ lookup() {
+ // 同步初始化已通过 getSyncLanguageSnapshot + init 的 lng 参数处理
+ return undefined;
+ },
+ cacheUserLanguage(lng: string) {
+ storageUtil.set(LANGUAGE_STORAGE_KEY, lng);
+ },
+};
+
+const detector = new LanguageDetector();
+detector.addDetector(chromeStorageDetector);
+
+const syncLng = getSyncLanguageSnapshot();
+
+i18n
+ .use(detector)
+ .use(initReactI18next)
+ .init({
+ resources,
+ fallbackLng: 'en',
+ lng: syncLng || undefined,
+ ns: ['common', 'features', 'timestamp', 'storageCleaner', 'qrCode', 'textStatistics', 'jwt'],
+ defaultNS: 'common',
+ debug: false,
+ interpolation: {
+ escapeValue: false,
+ },
+ detection: {
+ order: ['chromeStorage', 'navigator'],
+ caches: ['chromeStorage'],
+ },
+ });
+
+// 监听语言变化:同步 Day.js 和 localStorage 快照
+i18n.on('languageChanged', (lng) => {
+ const normalizedLng = normalizeLanguage(lng);
+ dayjs.locale(normalizedLng === 'zh' ? 'zh-cn' : 'en');
+ localStorage.setItem(LANGUAGE_SNAPSHOT_KEY, JSON.stringify(normalizedLng));
+});
+
+// 初始化时从存储中恢复语言
+storageUtil.get(LANGUAGE_STORAGE_KEY).then((lng) => {
+ const targetLng = lng || syncLng;
+
+ if (targetLng && isValidLanguage(targetLng) && targetLng !== i18n.language) {
+ i18n.changeLanguage(targetLng);
+ } else if (!targetLng) {
+ // 第一次运行,归一化并持久化初始语言
+ const initialLng = normalizeLanguage(i18n.language);
+ storageUtil.set(LANGUAGE_STORAGE_KEY, initialLng);
+ if (initialLng !== i18n.language) {
+ i18n.changeLanguage(initialLng);
+ }
+ }
+});
+
+export default i18n;
diff --git a/i18n/locales/en/common.json b/i18n/locales/en/common.json
new file mode 100644
index 0000000..fa2f59d
--- /dev/null
+++ b/i18n/locales/en/common.json
@@ -0,0 +1,23 @@
+{
+ "appName": "Testing Tools",
+ "buttons": {
+ "save": "Save",
+ "cancel": "Cancel",
+ "confirm": "Confirm",
+ "copy": "Copy",
+ "clear": "Clear",
+ "refresh": "Refresh",
+ "toggleLanguage": "Switch Language",
+ "search": "Search tools...",
+ "back": "Back",
+ "clearSearch": "Clear search",
+ "recentSearch": "Recent search",
+ "noResults": "No tools found",
+ "openInTab": "Open in tab",
+ "settings": "Settings"
+ },
+ "messages": {
+ "copySuccess": "Copied to clipboard",
+ "copyError": "Copy failed"
+ }
+}
diff --git a/i18n/locales/en/features.json b/i18n/locales/en/features.json
new file mode 100644
index 0000000..302c76c
--- /dev/null
+++ b/i18n/locales/en/features.json
@@ -0,0 +1,25 @@
+{
+ "dashboard": {
+ "title": "Dashboard"
+ },
+ "timestamp": {
+ "title": "Timestamp",
+ "description": "Unix millisecond conversion and formatting"
+ },
+ "storageCleaner": {
+ "title": "Storage Cleaner",
+ "description": "Clean cache, cookies, and local storage"
+ },
+ "qrCode": {
+ "title": "QR Code Tools",
+ "description": "Generate QR code for the selected URL"
+ },
+ "textStatistics": {
+ "title": "Text Statistics",
+ "description": "Real-time analysis of text characters, words, and bytes"
+ },
+ "jwt": {
+ "title": "JWT Parser",
+ "description": "JSON Web Token decoding and viewing"
+ }
+}
diff --git a/i18n/locales/en/jwt.json b/i18n/locales/en/jwt.json
new file mode 100644
index 0000000..8e5f682
--- /dev/null
+++ b/i18n/locales/en/jwt.json
@@ -0,0 +1,10 @@
+{
+ "pageTitle": "JWT Parser",
+ "pageSubtitle": "JSON Web Token decoding and viewing",
+ "placeholder": "Paste Encoded JWT here...",
+ "headerTitle": "HEADER: Algorithm & Token Type",
+ "payloadTitle": "PAYLOAD: Data",
+ "signatureTitle": "Signature",
+ "noSignature": "No Signature",
+ "invalidFormat": "Unable to parse"
+}
diff --git a/i18n/locales/en/qrCode.json b/i18n/locales/en/qrCode.json
new file mode 100644
index 0000000..a1098e1
--- /dev/null
+++ b/i18n/locales/en/qrCode.json
@@ -0,0 +1,31 @@
+{
+ "pageTitle": "QR Code Tools",
+ "pageSubtitle": "Generate and parse QR codes",
+ "urlToQr": "URL to QR Code",
+ "qrToUrl": "QR Code to URL",
+ "urlInputLabel": "Enter URL",
+ "urlInputPlaceholder": "https://example.com",
+ "generateButton": "Generate QR Code",
+ "generating": "Generating...",
+ "qrCodeWillShow": "QR code will be shown here",
+ "downloadButton": "Download QR Code",
+ "copyQrButton": "Copy QR Code Image",
+ "qrCodeSuccess": "QR code generated successfully",
+ "qrCodeDownloadSuccess": "QR code downloaded successfully",
+ "qrCodeCopySuccess": "QR code copied to clipboard",
+ "selectImage": "Please select a QR code image",
+ "parseSuccess": "QR code parsed successfully",
+ "noQrDetected": "No QR code detected",
+ "parseError": "Failed to parse QR code, please try again",
+ "imagePasted": "Image pasted successfully",
+ "imagePasteError": "Failed to paste image, please try again",
+ "imageCleared": "Image cleared",
+ "clickToUpload": "Click, drag, or paste to upload QR code image",
+ "supportFormats": "Supports PNG, JPG, WEBP formats",
+ "parseButton": "Parse QR Code",
+ "parsing": "Parsing...",
+ "resultLabel": "Parsing Result",
+ "copyTooltip": "Copy",
+ "enterUrlError": "Please enter a URL",
+ "clickToChange": "Click to change image"
+}
diff --git a/i18n/locales/en/storageCleaner.json b/i18n/locales/en/storageCleaner.json
new file mode 100644
index 0000000..17d0a95
--- /dev/null
+++ b/i18n/locales/en/storageCleaner.json
@@ -0,0 +1,33 @@
+{
+ "pageTitle": "Storage Cleaner",
+ "pageSubtitle": "Clear cache, cookies, and local storage",
+ "loading": "Loading...",
+ "occupied": "Occupied {{size}}",
+ "cleaning": "Cleaning...",
+ "cleanNow": "Clean Now",
+ "autoRefresh": "Auto refresh page after cleaning",
+ "selectAll": "Select all items",
+ "cleanSuccess": "Cleaning complete",
+ "cleanError": "Cleaning failed",
+ "noData": "No data",
+ "countUnit": "items",
+ "errorNoTab": "Unable to get current tab",
+ "errorRestricted": "Storage cleaning is not supported on this page",
+ "cleanSuccessReload": "Cleaning successful, reloading page...",
+ "errorStandardOnly": "Storage cleaning only works on standard web pages",
+ "confirmTitle": "Confirm Clear Data?",
+ "confirmDesc": "You are about to permanently delete the following selected storage items from the current page.",
+ "irreversible": "This action is irreversible",
+ "confirmAction": "Confirm Clear",
+ "cleanedSummary": "Cleaned {{items}}",
+ "noDataToClean": "No storage data found to clean",
+ "partialFailure": "Some items failed to clean",
+ "options": {
+ "localStorage": "Local Storage",
+ "sessionStorage": "Session Storage",
+ "indexedDB": "IndexedDB",
+ "cookies": "Cookies",
+ "cacheStorage": "Cache Storage",
+ "serviceWorkers": "Service Workers"
+ }
+}
diff --git a/i18n/locales/en/textStatistics.json b/i18n/locales/en/textStatistics.json
new file mode 100644
index 0000000..7fb8f7d
--- /dev/null
+++ b/i18n/locales/en/textStatistics.json
@@ -0,0 +1,9 @@
+{
+ "pageTitle": "Text Statistics",
+ "pageSubtitle": "Real-time analysis of characters, words, lines, and byte size",
+ "placeholder": "Type or paste text here...",
+ "characters": "Characters",
+ "words": "Words",
+ "lines": "Lines",
+ "bytes": "Byte Size"
+}
diff --git a/i18n/locales/en/timestamp.json b/i18n/locales/en/timestamp.json
new file mode 100644
index 0000000..55351ab
--- /dev/null
+++ b/i18n/locales/en/timestamp.json
@@ -0,0 +1,26 @@
+{
+ "pageTitle": "Timestamp Conversion",
+ "pageSubtitle": "Unix millisecond conversion and formatting",
+ "tsToDate": "Timestamp → Date",
+ "dateToTs": "Date → Timestamp",
+ "placeholderTs": "Enter timestamp...",
+ "placeholderDate": "YYYY-MM-DD HH:mm:ss",
+ "unitMs": "Millisecond (ms)",
+ "unitS": "Second (s)",
+ "convertButton": "Convert Now",
+ "currentTs": "Current Timestamp",
+ "useNowTooltip": "Use this value",
+ "copyTsTooltip": "Copy timestamp",
+ "usedSuccess": "Using current timestamp",
+ "resultLabel": "Conversion Result",
+ "copyResultTooltip": "Copy result",
+ "relativeTime": "Relative Time",
+ "iso8601": "ISO 8601",
+ "utcTime": "UTC Time",
+ "copyTooltip": "Copy",
+ "errors": {
+ "invalidNumber": "Invalid number",
+ "invalidTimestamp": "Invalid timestamp",
+ "invalidFormat": "Format error"
+ }
+}
diff --git a/i18n/locales/zh/common.json b/i18n/locales/zh/common.json
new file mode 100644
index 0000000..e49b770
--- /dev/null
+++ b/i18n/locales/zh/common.json
@@ -0,0 +1,23 @@
+{
+ "appName": "测试工具",
+ "buttons": {
+ "save": "保存",
+ "cancel": "取消",
+ "confirm": "确认",
+ "copy": "复制",
+ "clear": "清理",
+ "refresh": "刷新",
+ "toggleLanguage": "切换语言",
+ "search": "搜索工具...",
+ "back": "返回",
+ "clearSearch": "清除搜索",
+ "recentSearch": "最近搜索",
+ "noResults": "未找到相关工具",
+ "openInTab": "在标签页打开",
+ "settings": "设置"
+ },
+ "messages": {
+ "copySuccess": "已复制到剪贴板",
+ "copyError": "复制失败"
+ }
+}
diff --git a/i18n/locales/zh/features.json b/i18n/locales/zh/features.json
new file mode 100644
index 0000000..72a8f6e
--- /dev/null
+++ b/i18n/locales/zh/features.json
@@ -0,0 +1,25 @@
+{
+ "dashboard": {
+ "title": "仪表盘"
+ },
+ "timestamp": {
+ "title": "时间戳",
+ "description": "Unix 毫秒数转换与格式化"
+ },
+ "storageCleaner": {
+ "title": "存储清理",
+ "description": "清理缓存、Cookies 及本地存储"
+ },
+ "qrCode": {
+ "title": "二维码工具",
+ "description": "生成当前选中的 URL 的二维码"
+ },
+ "textStatistics": {
+ "title": "文本统计",
+ "description": "实时分析文本字符、单词及字节"
+ },
+ "jwt": {
+ "title": "JWT 解析",
+ "description": "JSON Web Token 解码与查看"
+ }
+}
diff --git a/i18n/locales/zh/jwt.json b/i18n/locales/zh/jwt.json
new file mode 100644
index 0000000..4424528
--- /dev/null
+++ b/i18n/locales/zh/jwt.json
@@ -0,0 +1,10 @@
+{
+ "pageTitle": "JWT 解析",
+ "pageSubtitle": "JSON Web Token 解码与查看",
+ "placeholder": "在此粘贴 JWT 令牌 (Encoded JWT)...",
+ "headerTitle": "HEADER: 算法 & 令牌类型",
+ "payloadTitle": "PAYLOAD: 数据",
+ "signatureTitle": "签名",
+ "noSignature": "无签名",
+ "invalidFormat": "无法解析"
+}
diff --git a/i18n/locales/zh/qrCode.json b/i18n/locales/zh/qrCode.json
new file mode 100644
index 0000000..f90698a
--- /dev/null
+++ b/i18n/locales/zh/qrCode.json
@@ -0,0 +1,31 @@
+{
+ "pageTitle": "二维码工具",
+ "pageSubtitle": "生成和解析二维码",
+ "urlToQr": "URL 转二维码",
+ "qrToUrl": "二维码转 URL",
+ "urlInputLabel": "输入 URL",
+ "urlInputPlaceholder": "https://example.com",
+ "generateButton": "生成二维码",
+ "generating": "生成中...",
+ "qrCodeWillShow": "二维码将显示在这里",
+ "downloadButton": "下载二维码",
+ "copyQrButton": "复制二维码",
+ "qrCodeSuccess": "二维码生成成功",
+ "qrCodeDownloadSuccess": "二维码下载成功",
+ "qrCodeCopySuccess": "二维码已复制到剪贴板",
+ "selectImage": "请选择二维码图片",
+ "parseSuccess": "二维码解析成功",
+ "noQrDetected": "未检测到二维码",
+ "parseError": "解析二维码失败,请重试",
+ "imagePasted": "图片粘贴成功",
+ "imagePasteError": "粘贴图片失败,请重试",
+ "imageCleared": "图片已清除",
+ "clickToUpload": "点击、拖拽或粘贴上传二维码图片",
+ "supportFormats": "支持 PNG、JPG、WEBP 格式",
+ "parseButton": "解析二维码",
+ "parsing": "解析中...",
+ "resultLabel": "解析结果",
+ "copyTooltip": "复制",
+ "enterUrlError": "请输入 URL",
+ "clickToChange": "点击更换图片"
+}
diff --git a/i18n/locales/zh/storageCleaner.json b/i18n/locales/zh/storageCleaner.json
new file mode 100644
index 0000000..d17fb7b
--- /dev/null
+++ b/i18n/locales/zh/storageCleaner.json
@@ -0,0 +1,33 @@
+{
+ "pageTitle": "存储清理",
+ "pageSubtitle": "清理缓存、Cookies 及本地存储",
+ "loading": "加载中...",
+ "occupied": "已占用 {{size}}",
+ "cleaning": "正在清理...",
+ "cleanNow": "立即清理",
+ "autoRefresh": "清理后自动刷新页面",
+ "selectAll": "全选所有项",
+ "cleanSuccess": "清理完成",
+ "cleanError": "清理失败",
+ "noData": "无数据",
+ "countUnit": "个",
+ "errorNoTab": "无法获取当前标签页",
+ "errorRestricted": "存储清理功能不支持此页面",
+ "cleanSuccessReload": "清理成功,即将刷新页面",
+ "errorStandardOnly": "存储清理功能仅适用于标准网页",
+ "confirmTitle": "确认清理数据?",
+ "confirmDesc": "您将永久删除当前页面的以下选定存储项。",
+ "irreversible": "此操作不可撤销",
+ "confirmAction": "确认清理",
+ "cleanedSummary": "清理了 {{items}}",
+ "noDataToClean": "该页面没有可清理的存储数据",
+ "partialFailure": "部分清理失败",
+ "options": {
+ "localStorage": "Local Storage",
+ "sessionStorage": "Session Storage",
+ "indexedDB": "IndexedDB",
+ "cookies": "Cookies",
+ "cacheStorage": "Cache Storage",
+ "serviceWorkers": "Service Workers"
+ }
+}
diff --git a/i18n/locales/zh/textStatistics.json b/i18n/locales/zh/textStatistics.json
new file mode 100644
index 0000000..e8efba4
--- /dev/null
+++ b/i18n/locales/zh/textStatistics.json
@@ -0,0 +1,9 @@
+{
+ "pageTitle": "文本统计",
+ "pageSubtitle": "实时分析文本的字符、单词、行数及字节大小",
+ "placeholder": "在此输入或粘贴文本...",
+ "characters": "字符数",
+ "words": "单词数",
+ "lines": "行数",
+ "bytes": "字节大小"
+}
diff --git a/i18n/locales/zh/timestamp.json b/i18n/locales/zh/timestamp.json
new file mode 100644
index 0000000..aa1fede
--- /dev/null
+++ b/i18n/locales/zh/timestamp.json
@@ -0,0 +1,26 @@
+{
+ "pageTitle": "时间戳转换",
+ "pageSubtitle": "Unix 毫秒数转换与格式化",
+ "tsToDate": "时间戳 → 日期",
+ "dateToTs": "日期 → 时间戳",
+ "placeholderTs": "输入时间戳...",
+ "placeholderDate": "YYYY-MM-DD HH:mm:ss",
+ "unitMs": "毫秒 (ms)",
+ "unitS": "秒 (s)",
+ "convertButton": "立即转换",
+ "currentTs": "当前时间戳",
+ "useNowTooltip": "填充到下方",
+ "copyTsTooltip": "复制时间戳",
+ "usedSuccess": "已使用当前时间戳",
+ "resultLabel": "转换结果",
+ "copyResultTooltip": "复制结果",
+ "relativeTime": "相对时间",
+ "iso8601": "ISO 8601",
+ "utcTime": "UTC 时间",
+ "copyTooltip": "复制",
+ "errors": {
+ "invalidNumber": "无效数字",
+ "invalidTimestamp": "无效时间戳",
+ "invalidFormat": "格式错误"
+ }
+}
diff --git a/package-lock.json b/package-lock.json
index 362e3d2..32ac84a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -16,10 +16,13 @@
"@mui/material": "^7.3.8",
"@webext-core/messaging": "^2.3.0",
"dayjs": "^1.11.19",
+ "i18next": "^26.0.8",
+ "i18next-browser-languagedetector": "^8.2.1",
"qr-scanner": "^1.4.2",
"qrious": "^4.0.2",
"react": "^19.2.3",
- "react-dom": "^19.2.3"
+ "react-dom": "^19.2.3",
+ "react-i18next": "^17.0.6"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.6.0",
@@ -6188,6 +6191,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/html-parse-stringify": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
+ "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
+ "license": "MIT",
+ "dependencies": {
+ "void-elements": "3.1.0"
+ }
+ },
"node_modules/htmlparser2": {
"version": "10.1.0",
"resolved": "https://registry.npmmirror.com/htmlparser2/-/htmlparser2-10.1.0.tgz",
@@ -6265,6 +6277,43 @@
"url": "https://github.com/sponsors/typicode"
}
},
+ "node_modules/i18next": {
+ "version": "26.0.8",
+ "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.0.8.tgz",
+ "integrity": "sha512-BRzLom0mhDhV9v0QhgUUHWQJuwFmnr1194xEcNLYD6ym8y8s542n4jXUvRLnhNTbh9PmpU6kGZamyuGHQMsGjw==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://www.locize.com/i18next"
+ },
+ {
+ "type": "individual",
+ "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
+ },
+ {
+ "type": "individual",
+ "url": "https://www.locize.com"
+ }
+ ],
+ "license": "MIT",
+ "peerDependencies": {
+ "typescript": "^5 || ^6"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/i18next-browser-languagedetector": {
+ "version": "8.2.1",
+ "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz",
+ "integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.23.2"
+ }
+ },
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@@ -8821,6 +8870,42 @@
"react": "^19.2.3"
}
},
+ "node_modules/react-i18next": {
+ "version": "17.0.6",
+ "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.6.tgz",
+ "integrity": "sha512-WzJ6SMKF+GTD7JZZqxSR1AKKmXjaSu39sClUrNlwxS4Tl7a99O+ltFy6yhPMO+wgZuxpQjJ2PZkfrQKmAqrLhw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.29.2",
+ "html-parse-stringify": "^3.0.1",
+ "use-sync-external-store": "^1.6.0"
+ },
+ "peerDependencies": {
+ "i18next": ">= 26.0.1",
+ "react": ">= 16.8.0",
+ "typescript": "^5 || ^6"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ },
+ "react-native": {
+ "optional": true
+ },
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-i18next/node_modules/@babel/runtime": {
+ "version": "7.29.2",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
+ "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
@@ -10197,7 +10282,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
- "dev": true,
+ "devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -10431,6 +10516,15 @@
"punycode": "^2.1.0"
}
},
+ "node_modules/use-sync-external-store": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -11173,6 +11267,15 @@
"url": "https://opencollective.com/vitest"
}
},
+ "node_modules/void-elements": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
+ "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
diff --git a/package.json b/package.json
index 15d4de3..b23fcea 100644
--- a/package.json
+++ b/package.json
@@ -18,7 +18,8 @@
"lint": "eslint . --max-warnings=0",
"test": "vitest run",
"test:watch": "vitest",
- "test:coverage": "vitest run --coverage"
+ "test:coverage": "vitest run --coverage",
+ "typecheck": "tsc --noEmit"
},
"dependencies": {
"@emotion/react": "^11.14.0",
@@ -27,10 +28,13 @@
"@mui/material": "^7.3.8",
"@webext-core/messaging": "^2.3.0",
"dayjs": "^1.11.19",
+ "i18next": "^26.0.8",
+ "i18next-browser-languagedetector": "^8.2.1",
"qr-scanner": "^1.4.2",
"qrious": "^4.0.2",
"react": "^19.2.3",
- "react-dom": "^19.2.3"
+ "react-dom": "^19.2.3",
+ "react-i18next": "^17.0.6"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.6.0",
diff --git a/pages/Dashboard/ToolCard.tsx b/pages/Dashboard/ToolCard.tsx
new file mode 100644
index 0000000..5a93c75
--- /dev/null
+++ b/pages/Dashboard/ToolCard.tsx
@@ -0,0 +1,152 @@
+/**
+ * ToolCard 组件 - 工具卡片
+ *
+ * 用于在仪表盘中展示各个工具功能的卡片组件,支持图标、标题、描述、
+ * 快照内容展示,具备悬停动画效果。
+ */
+import { alpha, Box, Card, CardActionArea, Stack, Typography } from '@mui/material';
+import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
+import React from 'react';
+
+/**
+ * ToolCard 组件属性接口
+ */
+interface ToolCardProps {
+ /** 工具卡片标题 */
+ title: string;
+ /** 工具卡片描述文本(可选) */
+ description?: string;
+ /** 快照内容,用于在卡片底部展示额外信息(可选) */
+ snapshot?: React.ReactNode;
+ /** 主题色代码,用于图标背景和悬停效果 */
+ colorCode: string;
+ /** 工具图标元素 */
+ icon: React.ReactNode;
+ /** 卡片点击事件处理函数 */
+ onClick: () => void;
+}
+
+/**
+ * ToolCard 组件
+ *
+ * @param props - ToolCardProps 属性对象
+ * @returns 工具卡片 JSX 元素
+ */
+export default function ToolCard({
+ title,
+ description,
+ snapshot,
+ colorCode,
+ icon,
+ onClick,
+}: ToolCardProps) {
+ return (
+
+
+
+
+
+ {icon}
+
+
+
+ {title}
+
+ {description && (
+
+ {description}
+
+ )}
+
+
+
+
+
+ {snapshot != null && (
+
+ {snapshot}
+
+ )}
+
+
+ );
+}
+
+ToolCard.displayName = 'ToolCard';
diff --git a/pages/Dashboard/index.tsx b/pages/Dashboard/index.tsx
new file mode 100644
index 0000000..7084c18
--- /dev/null
+++ b/pages/Dashboard/index.tsx
@@ -0,0 +1,37 @@
+import { Box } from '@mui/material';
+import { useRouter } from '@/providers/RouterProvider';
+import ToolCard from '@/pages/Dashboard/ToolCard';
+import { getFeatureByKey } from '@/config/features';
+import type { PageType } from '@/types/storage';
+import { useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { dashboardPageStyles } from '@/config/pageTheme';
+
+export default function DashboardPage() {
+ const { navigateTo, visiblePages, pageOrder } = useRouter();
+ const { t } = useTranslation(['features']);
+
+ const visibleSet = useMemo(() => new Set(visiblePages), [visiblePages]);
+
+ return (
+
+ {pageOrder.map((key) => {
+ if (!visibleSet.has(key as PageType)) return null;
+
+ const feature = getFeatureByKey(key);
+ if (!feature?.themeColor || feature.icon == null) return null;
+
+ return (
+ navigateTo(key)}
+ />
+ );
+ })}
+
+ );
+}
diff --git a/pages/DashboardPage.tsx b/pages/DashboardPage.tsx
deleted file mode 100644
index 2df08bf..0000000
--- a/pages/DashboardPage.tsx
+++ /dev/null
@@ -1,59 +0,0 @@
-import { Box } from '@mui/material';
-import { useRouter } from '@/providers/RouterProvider';
-import DashboardCard from '@/components/DashboardCard';
-import { getFeatureByKey } from '@/config/features';
-import type { PageType } from '@/types/storage';
-import { useCallback } from 'react';
-
-import { dashboardPageStyles } from '@/config/pageTheme';
-
-export default function DashboardPage() {
- const { navigateTo, visiblePages, pageOrder } = useRouter();
-
- const isVisible = (key: string) => visiblePages.includes(key as PageType);
-
- const handleCardClick = useCallback(
- (page: PageType) => {
- navigateTo(page);
- },
- [navigateTo],
- );
-
- return (
-
- {pageOrder.map((key) => {
- if (!isVisible(key)) return null;
-
- const feature = getFeatureByKey(key);
- if (!feature || !feature.icon || !feature.themeColor) return null;
-
- // 适配 DashboardCard 组件,将 themeColor 映射到 colorCode
- const cardConfig = {
- title: feature.label,
- description: feature.description,
- colorCode: feature.themeColor,
- icon: feature.icon,
- };
-
- return (
- handleCardClick(key)}
- cardBackgroundColor={dashboardPageStyles.cardBackgroundColor}
- />
- );
- })}
-
- );
-}
diff --git a/pages/JwtPage.tsx b/pages/Jwt/index.tsx
similarity index 67%
rename from pages/JwtPage.tsx
rename to pages/Jwt/index.tsx
index 597bd51..c54fdae 100644
--- a/pages/JwtPage.tsx
+++ b/pages/Jwt/index.tsx
@@ -1,12 +1,13 @@
-import { useState, useMemo } from 'react';
-import { TextField, Stack, Box, Container, Typography, Paper } from '@mui/material';
+import { useMemo, useState } from 'react';
+import { Box, Container, Paper, Stack, TextField, Typography } from '@mui/material';
import { useSnackbar } from '@/components/GlobalSnackbar';
import VpnKeyIcon from '@mui/icons-material/VpnKey';
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
import PageHeader from '@/components/PageHeader';
import { jwtPageStyles } from '@/config/pageTheme';
-import { parseJwt, formatJson } from '@/utils/jwt';
+import { formatJson, parseJwt } from '@/utils/jwt';
import CopyButton from '@/components/CopyButton';
+import { useTranslation } from 'react-i18next';
interface SectionProps {
title: string;
@@ -15,47 +16,51 @@ interface SectionProps {
color: string;
}
-const Section = ({ title, content, color }: SectionProps) => (
-
-
-
- {title}
-
-
-
-
-
- {
+ const { t } = useTranslation(['jwt']);
+ return (
+
- {content ? formatJson(content) : '无法解析'}
-
-
-);
+
+
+ {title}
+
+
+
+
+
+
+ {content ? formatJson(content) : t('jwt:invalidFormat')}
+
+
+ );
+};
-export default function JwtPage() {
+export default function Index() {
useSnackbar();
+ const { t } = useTranslation(['jwt']);
const [jwtInput, setJwtInput] = useState('');
const result = useMemo(() => {
@@ -68,14 +73,18 @@ export default function JwtPage() {
return (
- } />
+ }
+ />
{/* Input Area */}
{
// 自动去除 Bearer 前缀及首尾空白字符/换行
@@ -110,13 +119,13 @@ export default function JwtPage() {
{result && !result.error && (
- 签名
+ {t('jwt:signatureTitle')}
@@ -159,7 +168,7 @@ export default function JwtPage() {
border: '1px solid rgba(0,0,0,0.05)',
}}
>
- {result.signature || 'No Signature'}
+ {result.signature || t('jwt:noSignature')}
diff --git a/components/QrCodeToUrlSection.tsx b/pages/QrCode/QrCodeToUrlSection.tsx
similarity index 59%
rename from components/QrCodeToUrlSection.tsx
rename to pages/QrCode/QrCodeToUrlSection.tsx
index 7de059d..5ce767d 100644
--- a/components/QrCodeToUrlSection.tsx
+++ b/pages/QrCode/QrCodeToUrlSection.tsx
@@ -1,17 +1,17 @@
-import React, { useState, useEffect, useCallback, useRef } from 'react';
+import { useState, useEffect, useCallback, useRef } from 'react';
import {
- Box,
- Typography,
- TextField,
- Button,
- Stack,
- Alert,
Accordion,
- AccordionSummary,
AccordionDetails,
+ AccordionSummary,
+ Alert,
+ Box,
+ Button,
CircularProgress,
- InputAdornment,
IconButton,
+ InputAdornment,
+ Stack,
+ TextField,
+ Typography,
} from '@mui/material';
import LinkIcon from '@mui/icons-material/Link';
import ImageIcon from '@mui/icons-material/Image';
@@ -19,21 +19,20 @@ import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ClearIcon from '@mui/icons-material/Clear';
import CopyButton from '@/components/CopyButton';
import { qrCodePageStyles } from '@/config/pageTheme';
-import type { SnackbarOptions } from '@/components/GlobalSnackbar';
+import { useSnackbar } from '@/components/GlobalSnackbar';
import { parseQrCodeFromFile } from '@/utils/qrCodeParser';
+import { useTranslation } from 'react-i18next';
interface QrCodeToUrlSectionProps {
expanded: boolean;
onExpandedChange: (expanded: boolean) => void;
- showMessage: (message: string, options?: SnackbarOptions) => void;
}
-const QrCodeToUrlSection = ({
- expanded,
- onExpandedChange,
- showMessage,
-}: QrCodeToUrlSectionProps) => {
+const QrCodeToUrlSection = ({ expanded, onExpandedChange }: QrCodeToUrlSectionProps) => {
+ const { t } = useTranslation(['qrCode']);
+ const { showMessage } = useSnackbar();
const [qrCodeFile, setQrCodeFile] = useState(null);
+ const [previewUrl, setPreviewUrl] = useState('');
const [parsedUrl, setParsedUrl] = useState('');
const [parseError, setParseError] = useState('');
const [parsing, setParsing] = useState(false);
@@ -41,12 +40,36 @@ const QrCodeToUrlSection = ({
const fileInputRef = useRef(null);
+ // 清理预览 URL,防止内存泄漏
+ useEffect(() => {
+ return () => {
+ if (previewUrl) {
+ URL.revokeObjectURL(previewUrl);
+ }
+ };
+ }, [previewUrl]);
+
const handleFileChange = useCallback((file: File) => {
setQrCodeFile(file);
+ setPreviewUrl(URL.createObjectURL(file));
setParseError('');
setParsedUrl('');
}, []);
+ const handleClearFile = () => {
+ setQrCodeFile(null);
+ if (previewUrl) {
+ URL.revokeObjectURL(previewUrl);
+ }
+ setPreviewUrl('');
+ setParsedUrl('');
+ setParseError('');
+ showMessage(t('qrCode:imageCleared'), {
+ severity: 'success',
+ autoHideDuration: 1000,
+ });
+ };
+
const handleInputChange = (e: React.ChangeEvent) => {
if (e.target.files && e.target.files.length > 0) {
handleFileChange(e.target.files[0]);
@@ -73,7 +96,7 @@ const QrCodeToUrlSection = ({
const parseQrCode = async () => {
if (!qrCodeFile) {
- showMessage('请选择二维码图片', { severity: 'error', autoHideDuration: 300 });
+ showMessage(t('qrCode:selectImage'), { severity: 'error', autoHideDuration: 300 });
return;
}
@@ -86,16 +109,16 @@ const QrCodeToUrlSection = ({
if (result.success && result.data) {
setParsedUrl(result.data);
- showMessage('二维码解析成功', { severity: 'success', autoHideDuration: 1000 });
+ showMessage(t('qrCode:parseSuccess'), { severity: 'success', autoHideDuration: 1000 });
} else {
- showMessage(result.error || '未检测到二维码', {
+ showMessage(result.error || t('qrCode:noQrDetected'), {
severity: 'error',
autoHideDuration: 1000,
});
}
} catch (error) {
console.error('解析二维码失败:', error);
- showMessage('解析二维码失败,请重试', { severity: 'error', autoHideDuration: 300 });
+ showMessage(t('qrCode:parseError'), { severity: 'error', autoHideDuration: 300 });
} finally {
setParsing(false);
}
@@ -117,10 +140,13 @@ const QrCodeToUrlSection = ({
if (file) {
try {
handleFileChange(file);
- showMessage('图片粘贴成功', { severity: 'success', autoHideDuration: 1000 });
+ showMessage(t('qrCode:imagePasted'), { severity: 'success', autoHideDuration: 1000 });
} catch (error) {
console.error('处理粘贴图片失败:', error);
- showMessage('粘贴图片失败,请重试', { severity: 'error', autoHideDuration: 3000 });
+ showMessage(t('qrCode:imagePasteError'), {
+ severity: 'error',
+ autoHideDuration: 3000,
+ });
}
}
break;
@@ -133,55 +159,26 @@ const QrCodeToUrlSection = ({
return () => {
document.removeEventListener('paste', handlePaste);
};
- }, [expanded, showMessage, handleFileChange]);
+ }, [expanded, showMessage, handleFileChange, t]);
return (
onExpandedChange(isExpanded)}
- sx={{
- borderRadius: 4,
- boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
- '&:before': { display: 'none' },
- }}
+ sx={qrCodePageStyles.ACCORDION}
>
- } sx={{ borderBottom: 'none' }}>
-
+ } sx={qrCodePageStyles.ACCORDION_SUMMARY}>
+
-
- 二维码转 URL
+
+ {t('qrCode:qrToUrl')}