refactor(i18n): 移除 chrome.i18n 国际化,统一使用中文硬编码

移除 chromeI18n 工具、_locales 翻译文件和 manifest default_locale 配置,
将所有 UI 文案改为直接硬编码中文,并更新相关测试与文档。
This commit is contained in:
雨霖铃
2026-06-18 14:22:14 +00:00
parent 618f3c36f2
commit ec92afa294
68 changed files with 531 additions and 2148 deletions
-42
View File
@@ -1,42 +0,0 @@
# i18n 开发指南
## 概述
项目使用 Chrome 扩展标准的 `chrome.i18n` API 进行本地化,通过 `src/utils/chromeI18n.ts` 提供类型安全的 React Hook 包装。
## 配置
- **翻译文件**: `public/_locales/{zh_CN,en}/messages.json`Chrome 扩展标准格式)
- **默认语言**: `zh_CN`(在 `wxt.config.ts``manifest.default_locale` 中配置)
## 使用方式
```typescript
import { useI18n } from '@/utils/chromeI18n';
```
## 翻译键格式
- **直接 key**: `t('dashboard_title')` → 查找 `dashboard_title`
- **命名空间格式(兼容旧用法)**: `t('common:buttons.search')` → 查找 `common_buttons_search`
- **带命名空间参数**: `useI18n(['common', 'features'])`,会自动尝试 `common_key``features_key`
## 占位符支持
```typescript
t('router_notFoundDescription', { entryPointType: 'popup' });
```
## Hook 返回值
```typescript
{ t, i18n: { language, changeLanguage }, isLoaded }
```
## 回退策略
当翻译 key 未命中时,返回 key 本身(开发模式下在控制台记录 warning)
## 限制
`chrome.i18n` 无法动态切换语言,语言跟随浏览器设置,切换后需刷新页面
+4 -10
View File
@@ -55,7 +55,7 @@ src/pages/FeatureName/
- 页面入口组件统一命名为 `Index`,通过 `export default function Index()` 导出 - 页面入口组件统一命名为 `Index`,通过 `export default function Index()` 导出
- Hook 负责所有状态管理和业务逻辑,通过返回值暴露给页面 - Hook 负责所有状态管理和业务逻辑,通过返回值暴露给页面
- 子组件可以独立调用 `useI18n`全局 Hook - 子组件可以独立调用全局 Hook
-`index.tsx` 超过 150 行时,必须拆分为 UI + Hook 模式 -`index.tsx` 超过 150 行时,必须拆分为 UI + Hook 模式
- 复杂页面可增加 `contexts/``hooks/``components/` 子目录 - 复杂页面可增加 `contexts/``hooks/``components/` 子目录
@@ -82,27 +82,21 @@ src/pages/FeatureName/
- 全局变量: `vitest/globals` (describe, it, expect 等无需导入) - 全局变量: `vitest/globals` (describe, it, expect 等无需导入)
- Setup 文件: `vitest.setup.ts` 自动 mock: - Setup 文件: `vitest.setup.ts` 自动 mock:
- `chrome.*` / `browser.*` API (storage, tabs, runtime, cookies 等) - `chrome.*` / `browser.*` API (storage, tabs, runtime, cookies 等)
- `@/utils/chromeI18n` (从 `public/_locales/zh_CN/messages.json` 加载真实翻译)
- `window.matchMedia` - `window.matchMedia`
- 测试文件命名: `__tests__/*.test.{ts,tsx}``*.test.{ts,tsx}` - 测试文件命名: `__tests__/*.test.{ts,tsx}``*.test.{ts,tsx}`
- Mock 模式: 使用 `vi.mock()` 进行模块级 mock,避免在测试文件中重复 mock 代码 - Mock 模式: 使用 `vi.mock()` 进行模块级 mock,避免在测试文件中重复 mock 代码
- 测试工具: `@testing-library/react` + `@testing-library/user-event` 进行组件测试 - 测试工具: `@testing-library/react` + `@testing-library/user-event` 进行组件测试
## i18n (chrome.i18n)
详见 [i18n 开发指南](./.github/I18N.md)
## 新功能开发清单 ## 新功能开发清单
1.`src/types/storage.d.ts` 添加 `PageType` 联合类型 1.`src/types/storage.d.ts` 添加 `PageType` 联合类型
2.`src/config/features.tsx``FEATURES` 数组添加配置(指定 key、翻译键、图标、三种渲染模式的组件) 2.`src/config/features.tsx``FEATURES` 数组添加配置(指定 key、翻译键、图标、三种渲染模式的组件)
3.`src/pages/` 创建页面组件 (懒加载) 3.`src/pages/` 创建页面组件 (懒加载)
- `index.tsx` — UI 组件,使用 `useI18n` 获取翻译 - `index.tsx` — UI 组件
- `use{FeatureName}.ts` — 业务逻辑 Hook - `use{FeatureName}.ts` — 业务逻辑 Hook
- `constants.ts` — 常量(可选) - `constants.ts` — 常量(可选)
4. `public/_locales/zh/messages.json`(及 `en/messages.json`)添加翻译 4. 如需新权限,更新 `wxt.config.ts``manifest.permissions`;如有不使用的权限,需移除
5. 如需新权限,更新 `wxt.config.ts``manifest.permissions`;如有不使用的权限,需移除 5. 添加对应的单元测试
6. 添加对应的单元测试
## 代码规范 ## 代码规范
File diff suppressed because it is too large Load Diff
+5 -7
View File
@@ -4,7 +4,6 @@ import { copyTextToClipboard } from '@/utils/clipboard';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { buttonVariants, type ButtonProps } from '@/components/ui/button'; import { buttonVariants, type ButtonProps } from '@/components/ui/button';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useI18n } from '@/utils/chromeI18n';
interface CopyButtonProps extends Omit<ButtonProps, 'children' | 'onClick'> { interface CopyButtonProps extends Omit<ButtonProps, 'children' | 'onClick'> {
text: string; text: string;
@@ -19,7 +18,6 @@ export const CopyButton: React.FC<CopyButtonProps> = ({
className, className,
...props ...props
}) => { }) => {
const { t } = useI18n('common');
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -33,18 +31,18 @@ export const CopyButton: React.FC<CopyButtonProps> = ({
e.stopPropagation(); e.stopPropagation();
if (!text) { if (!text) {
toast.error(t('messages.copyEmpty')); toast.error('无内容可复制');
return; return;
} }
const success = await copyTextToClipboard(text); const success = await copyTextToClipboard(text);
if (success) { if (success) {
toast.success(t('messages.copySuccess')); toast.success('已复制到剪贴板');
setCopied(true); setCopied(true);
if (timerRef.current) clearTimeout(timerRef.current); if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setCopied(false), 1500); timerRef.current = setTimeout(() => setCopied(false), 1500);
} else { } else {
toast.error(t('messages.copyError')); toast.error('复制失败');
} }
}; };
@@ -52,8 +50,8 @@ export const CopyButton: React.FC<CopyButtonProps> = ({
<button <button
type="button" type="button"
onClick={handleCopy} onClick={handleCopy}
title={tooltip ?? t('buttons.copy')} title={tooltip ?? '复制'}
aria-label={tooltip ?? t('buttons.copy')} aria-label={tooltip ?? '复制'}
className={cn( className={cn(
buttonVariants({ variant, size }), buttonVariants({ variant, size }),
copied && copied &&
+3 -6
View File
@@ -1,7 +1,6 @@
import { Component, ErrorInfo, ReactNode } from 'react'; import { Component, ErrorInfo, ReactNode } from 'react';
import { AlertCircle, RefreshCw } from 'lucide-react'; import { AlertCircle, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { getMessage } from '@/utils/chromeI18n';
interface Props { interface Props {
children: ReactNode; children: ReactNode;
@@ -44,11 +43,9 @@ class ErrorBoundary extends Component<Props, State> {
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10 text-destructive mx-auto mb-4"> <div className="flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10 text-destructive mx-auto mb-4">
<AlertCircle className="h-8 w-8" /> <AlertCircle className="h-8 w-8" />
</div> </div>
<h2 className="text-xl font-extrabold text-destructive mb-2"> <h2 className="text-xl font-extrabold text-destructive mb-2"></h2>
{getMessage('errorBoundary_title')}
</h2>
<p className="text-sm text-muted-foreground mb-6"> <p className="text-sm text-muted-foreground mb-6">
{getMessage('errorBoundary_description')}
</p> </p>
{this.state.error && ( {this.state.error && (
<div className="mb-6 p-4 rounded-lg bg-zinc-950 dark:bg-zinc-900 text-left max-h-[200px] overflow-auto border border-border/40"> <div className="mb-6 p-4 rounded-lg bg-zinc-950 dark:bg-zinc-900 text-left max-h-[200px] overflow-auto border border-border/40">
@@ -63,7 +60,7 @@ class ErrorBoundary extends Component<Props, State> {
className="rounded-lg font-bold shadow-sm" className="rounded-lg font-bold shadow-sm"
> >
<RefreshCw className="mr-2 h-4 w-4" /> <RefreshCw className="mr-2 h-4 w-4" />
{getMessage('errorBoundary_refresh')}
</Button> </Button>
</div> </div>
</div> </div>
+3 -6
View File
@@ -1,7 +1,6 @@
import { Component, ErrorInfo, ReactNode } from 'react'; import { Component, ErrorInfo, ReactNode } from 'react';
import { AlertCircle, RefreshCw } from 'lucide-react'; import { AlertCircle, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { getMessage } from '@/utils/chromeI18n';
interface Props { interface Props {
children: ReactNode; children: ReactNode;
@@ -46,11 +45,9 @@ class PageErrorBoundary extends Component<Props, State> {
<AlertCircle className="h-6 w-6" /> <AlertCircle className="h-6 w-6" />
</div> </div>
<h3 className="text-base font-semibold text-foreground mb-1.5"> <h3 className="text-base font-semibold text-foreground mb-1.5"></h3>
{getMessage('pageErrorBoundary_title')}
</h3>
<p className="text-xs text-muted-foreground mb-5"> <p className="text-xs text-muted-foreground mb-5">
{getMessage('pageErrorBoundary_description')}
</p> </p>
{this.state.error && ( {this.state.error && (
@@ -68,7 +65,7 @@ class PageErrorBoundary extends Component<Props, State> {
className="font-medium shadow-sm" className="font-medium shadow-sm"
> >
<RefreshCw className="mr-1.5 h-3.5 w-3.5" /> <RefreshCw className="mr-1.5 h-3.5 w-3.5" />
{getMessage('errorBoundary_retry')}
</Button> </Button>
</div> </div>
</div> </div>
+2 -4
View File
@@ -1,7 +1,6 @@
import { FEATURES, getEntryPointType } from '@/config/features'; import { FEATURES, getEntryPointType } from '@/config/features';
import { useRouter } from '@/providers/RouterProvider'; import { useRouter } from '@/providers/RouterProvider';
import { Suspense } from 'react'; import { Suspense } from 'react';
import { useI18n } from '@/utils/chromeI18n';
import PageErrorBoundary from '@/components/PageErrorBoundary'; import PageErrorBoundary from '@/components/PageErrorBoundary';
import PageSkeleton from '@/components/PageSkeleton'; import PageSkeleton from '@/components/PageSkeleton';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -11,7 +10,6 @@ const entryPointType = getEntryPointType();
export default function RouterContainer() { export default function RouterContainer() {
const { currentPage, isLoaded } = useRouter(); const { currentPage, isLoaded } = useRouter();
const { t } = useI18n('common');
const animationClass = const animationClass =
currentPage === 'dashboard' ? 'page-transition-dashboard' : 'page-transition-enter'; currentPage === 'dashboard' ? 'page-transition-dashboard' : 'page-transition-enter';
@@ -43,9 +41,9 @@ export default function RouterContainer() {
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-destructive/10 text-destructive mb-4"> <div className="flex h-12 w-12 items-center justify-center rounded-xl bg-destructive/10 text-destructive mb-4">
<AlertTriangle className="h-6 w-6" /> <AlertTriangle className="h-6 w-6" />
</div> </div>
<h3 className="text-sm font-semibold text-foreground">{t('router.notFound')}</h3> <h3 className="text-sm font-semibold text-foreground">{'页面未找到'}</h3>
<p className="text-xs text-muted-foreground mt-1 max-w-[240px]"> <p className="text-xs text-muted-foreground mt-1 max-w-[240px]">
{t('router.notFoundDescription', { entryPointType })} {`该功能在当前运行环境(${entryPointType})下不可用或已被移除。`}
</p> </p>
</div> </div>
)} )}
+6 -13
View File
@@ -1,6 +1,5 @@
import React, { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react'; import React, { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import { useI18n } from '@/utils/chromeI18n';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { toast } from 'sonner'; // 推荐使用 shadcn 的默认 Toast import { toast } from 'sonner'; // 推荐使用 shadcn 的默认 Toast
import { CopyButton } from '@/components/CopyButton'; import { CopyButton } from '@/components/CopyButton';
@@ -114,8 +113,7 @@ const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props
const [internalValue, setInternalValue] = useState(defaultValue); const [internalValue, setInternalValue] = useState(defaultValue);
const [error, setError] = useState<string>(''); const [error, setError] = useState<string>('');
const { t } = useI18n('common'); const placeholder = placeholderProp ?? '请输入文本';
const placeholder = placeholderProp ?? t('textInputArea.placeholder');
const isControlled = controlledValue !== undefined; const isControlled = controlledValue !== undefined;
const value = isControlled ? controlledValue : internalValue; const value = isControlled ? controlledValue : internalValue;
@@ -159,7 +157,7 @@ const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newVal = e.target.value; const newVal = e.target.value;
if (maxLength && newVal.length > maxLength) { if (maxLength && newVal.length > maxLength) {
const msg = t('charCount', { count: maxLength }); const msg = `内容不能超过 ${maxLength} 个字符`;
setError(msg); setError(msg);
toast.warning(msg); toast.warning(msg);
return; return;
@@ -181,9 +179,9 @@ const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props
onChange?.(''); onChange?.('');
setError(''); setError('');
internalRef.current?.focus(); internalRef.current?.focus();
toast.success(t('textInputArea.cleared')); toast.success('已清空');
onClear?.(); onClear?.();
}, [isControlled, onChange, onClear, t]); }, [isControlled, onChange, onClear]);
const handleAction = useCallback( const handleAction = useCallback(
(action: ToolbarAction) => { (action: ToolbarAction) => {
@@ -272,18 +270,13 @@ const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props
{/* 右侧系统按钮组 */} {/* 右侧系统按钮组 */}
<div className="flex items-center gap-1.5 ml-auto shrink-0"> <div className="flex items-center gap-1.5 ml-auto shrink-0">
{allowCopy && value && ( {allowCopy && value && (
<CopyButton <CopyButton text={value} tooltip={'复制内容'} size="sm" className="h-7 w-7 p-1" />
text={value}
tooltip={t('textInputArea.copyContent')}
size="sm"
className="h-7 w-7 p-1"
/>
)} )}
{showClear && value && !disabled && !readOnly && ( {showClear && value && !disabled && !readOnly && (
<button <button
type="button" type="button"
onClick={handleClear} onClick={handleClear}
aria-label={t('textInputArea.clear')} aria-label={'清空'}
className="p-1 h-7 w-7 flex items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" className="p-1 h-7 w-7 flex items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
+23 -21
View File
@@ -5,7 +5,6 @@ import { useThemeMode } from '@/providers/ThemeModeProvider';
import { FeatureConfig, FEATURES } from '@/config/features'; import { FeatureConfig, FEATURES } from '@/config/features';
import { storageUtil } from '@/utils/chromeStorage'; import { storageUtil } from '@/utils/chromeStorage';
import { openExtensionPage } from '@/utils/chromeTabs'; import { openExtensionPage } from '@/utils/chromeTabs';
import { useI18n } from '@/utils/chromeI18n';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const SEARCH_HISTORY_LIMIT = 10; const SEARCH_HISTORY_LIMIT = 10;
@@ -14,7 +13,6 @@ const SEARCH_HISTORY_DISPLAY = 5;
export default function TopBar() { export default function TopBar() {
const { currentPage, goBack, navigateTo } = useRouter(); const { currentPage, goBack, navigateTo } = useRouter();
const { mode, setMode } = useThemeMode(); const { mode, setMode } = useThemeMode();
const { t } = useI18n(['common', 'features']);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [showResults, setShowResults] = useState(false); const [showResults, setShowResults] = useState(false);
@@ -66,12 +64,9 @@ export default function TopBar() {
if (!query) return []; if (!query) return [];
return FEATURES.filter((f) => { return FEATURES.filter((f) => {
if (f.key === 'dashboard') return false; if (f.key === 'dashboard') return false;
return ( return f.label.toLowerCase().includes(query) || f.description.toLowerCase().includes(query);
t(f.labelKey).toLowerCase().includes(query) ||
t(f.descriptionKey).toLowerCase().includes(query)
);
}); });
}, [searchQuery, t]); }, [searchQuery]);
const displayedHistory = useMemo(() => { const displayedHistory = useMemo(() => {
if (searchQuery.trim()) return []; if (searchQuery.trim()) return [];
@@ -144,7 +139,7 @@ export default function TopBar() {
<button <button
type="button" type="button"
onClick={goBack} onClick={goBack}
aria-label={t('common_buttons_back')} aria-label={'返回'}
className="flex h-8 w-8 items-center justify-center rounded-md border border-input bg-background text-muted-foreground shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground" className="flex h-8 w-8 items-center justify-center rounded-md border border-input bg-background text-muted-foreground shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
@@ -159,7 +154,7 @@ export default function TopBar() {
<input <input
ref={inputRef} ref={inputRef}
type="text" type="text"
placeholder={t('common_buttons_search')} placeholder={'搜索工具...'}
value={searchQuery} value={searchQuery}
onChange={(e) => { onChange={(e) => {
setSearchQuery(e.target.value); setSearchQuery(e.target.value);
@@ -168,7 +163,7 @@ export default function TopBar() {
}} }}
onFocus={() => setShowResults(true)} onFocus={() => setShowResults(true)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
aria-label={t('common_buttons_search')} aria-label={'搜索工具...'}
className="w-full h-9 pl-9 pr-16 text-sm rounded-lg border border-border/60 bg-muted/40 transition-all placeholder:text-muted-foreground/50 focus:bg-background focus:outline-none focus:ring-1 focus:ring-ring focus:border-input" className="w-full h-9 pl-9 pr-16 text-sm rounded-lg border border-border/60 bg-muted/40 transition-all placeholder:text-muted-foreground/50 focus:bg-background focus:outline-none focus:ring-1 focus:ring-ring focus:border-input"
/> />
{!searchQuery && ( {!searchQuery && (
@@ -183,7 +178,7 @@ export default function TopBar() {
setSearchQuery(''); setSearchQuery('');
setSelectedIndex(-1); setSelectedIndex(-1);
}} }}
aria-label={t('common:buttons.clearSearch')} aria-label={'清除搜索'}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-md text-muted-foreground hover:text-foreground transition-colors" className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-md text-muted-foreground hover:text-foreground transition-colors"
> >
<X className="h-3 w-3" /> <X className="h-3 w-3" />
@@ -219,24 +214,22 @@ export default function TopBar() {
{feature.icon && <feature.icon className="h-4 w-4" />} {feature.icon && <feature.icon className="h-4 w-4" />}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="font-medium text-foreground truncate"> <p className="font-medium text-foreground truncate">{feature.label}</p>
{t(feature.labelKey)}
</p>
<p className="text-xs text-muted-foreground truncate mt-0.5"> <p className="text-xs text-muted-foreground truncate mt-0.5">
{t(feature.descriptionKey)} {feature.description}
</p> </p>
</div> </div>
</li> </li>
)) ))
) : ( ) : (
<li className="px-4 py-6 text-center text-sm text-muted-foreground"> <li className="px-4 py-6 text-center text-sm text-muted-foreground">
{t('common:buttons.noResults')} {'未找到相关工具'}
</li> </li>
) )
) : ( ) : (
<> <>
<div className="px-3 py-1.5 text-[11px] font-semibold tracking-wider text-muted-foreground/60 uppercase"> <div className="px-3 py-1.5 text-[11px] font-semibold tracking-wider text-muted-foreground/60 uppercase">
{t('common:buttons.recentSearch')} {'最近搜索'}
</div> </div>
{displayedHistory.map((item, index) => ( {displayedHistory.map((item, index) => (
<li <li
@@ -261,10 +254,10 @@ export default function TopBar() {
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="font-medium text-foreground truncate"> <p className="font-medium text-foreground truncate">
{item.feature && t(item.feature.labelKey)} {item.feature?.label}
</p> </p>
<p className="text-xs text-muted-foreground truncate mt-0.5"> <p className="text-xs text-muted-foreground truncate mt-0.5">
{item.feature && t(item.feature.descriptionKey)} {item.feature?.description}
</p> </p>
</div> </div>
</li> </li>
@@ -278,10 +271,19 @@ export default function TopBar() {
{/* 右侧:操作区 */} {/* 右侧:操作区 */}
<div className="flex items-center gap-1 shrink-0"> <div className="flex items-center gap-1 shrink-0">
<IconButton onClick={cycleThemeMode} title={t(`common:buttons.themeMode.${mode}`)}> <IconButton
onClick={cycleThemeMode}
title={
mode === 'light'
? '切换到深色模式'
: mode === 'dark'
? '切换到系统模式'
: '切换到浅色模式'
}
>
<ThemeIcon className="h-4 w-4" /> <ThemeIcon className="h-4 w-4" />
</IconButton> </IconButton>
<IconButton onClick={handleOpenInTab} title={t('common:buttons.openInTab')}> <IconButton onClick={handleOpenInTab} title={'在标签页打开'}>
<ExternalLink className="h-4 w-4" /> <ExternalLink className="h-4 w-4" />
</IconButton> </IconButton>
</div> </div>
+12 -13
View File
@@ -18,12 +18,12 @@ describe('TextInputArea 组件', () => {
it('默认显示清空按钮', () => { it('默认显示清空按钮', () => {
render(<TextInputArea value="有内容" onChange={() => {}} />); render(<TextInputArea value="有内容" onChange={() => {}} />);
expect(screen.getByRole('button', { name: 'textInputArea.clear' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: '清空' })).toBeInTheDocument();
}); });
it('无内容时清空按钮应隐藏', () => { it('无内容时清空按钮应隐藏', () => {
render(<TextInputArea value="" onChange={() => {}} />); render(<TextInputArea value="" onChange={() => {}} />);
expect(screen.queryByRole('button', { name: 'textInputArea.clear' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: '清空' })).not.toBeInTheDocument();
}); });
it('disabled 时清空按钮应隐藏', () => { it('disabled 时清空按钮应隐藏', () => {
@@ -57,7 +57,7 @@ describe('TextInputArea 组件', () => {
const handleChange = vi.fn(); const handleChange = vi.fn();
render(<TextInputArea value="内容" onChange={handleChange} />); render(<TextInputArea value="内容" onChange={handleChange} />);
fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' })); fireEvent.click(screen.getByRole('button', { name: '清空' }));
expect(handleChange).toHaveBeenCalledWith(''); expect(handleChange).toHaveBeenCalledWith('');
}); });
@@ -81,7 +81,7 @@ describe('TextInputArea 组件', () => {
it('清空按钮应清空内容', () => { it('清空按钮应清空内容', () => {
render(<TextInputArea defaultValue="内容" />); render(<TextInputArea defaultValue="内容" />);
fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' })); fireEvent.click(screen.getByRole('button', { name: '清空' }));
expect(screen.getByRole('textbox')).toHaveValue(''); expect(screen.getByRole('textbox')).toHaveValue('');
}); });
@@ -90,14 +90,12 @@ describe('TextInputArea 组件', () => {
describe('allowCopy 复制功能', () => { describe('allowCopy 复制功能', () => {
it('allowCopy 且有内容时显示复制按钮', () => { it('allowCopy 且有内容时显示复制按钮', () => {
render(<TextInputArea value="可复制的内容" onChange={() => {}} allowCopy />); render(<TextInputArea value="可复制的内容" onChange={() => {}} allowCopy />);
expect(screen.getByRole('button', { name: 'textInputArea.copyContent' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: '复制内容' })).toBeInTheDocument();
}); });
it('allowCopy 但无内容时隐藏复制按钮', () => { it('allowCopy 但无内容时隐藏复制按钮', () => {
render(<TextInputArea value="" onChange={() => {}} allowCopy />); render(<TextInputArea value="" onChange={() => {}} allowCopy />);
expect( expect(screen.queryByRole('button', { name: '复制内容' })).not.toBeInTheDocument();
screen.queryByRole('button', { name: 'textInputArea.copyContent' }),
).not.toBeInTheDocument();
}); });
it('allowCopy=false 时不显示复制按钮', () => { it('allowCopy=false 时不显示复制按钮', () => {
@@ -113,7 +111,7 @@ describe('TextInputArea 组件', () => {
render(<TextInputArea value="测试" onChange={() => {}} allowCopy />); render(<TextInputArea value="测试" onChange={() => {}} allowCopy />);
await user.click(screen.getByRole('button', { name: 'textInputArea.copyContent' })); await user.click(screen.getByRole('button', { name: '复制内容' }));
expect(writeTextSpy).toHaveBeenCalledWith('测试'); expect(writeTextSpy).toHaveBeenCalledWith('测试');
}); });
@@ -126,7 +124,7 @@ describe('TextInputArea 组件', () => {
render(<TextInputArea value="测试" onChange={() => {}} allowCopy />); render(<TextInputArea value="测试" onChange={() => {}} allowCopy />);
await user.click(screen.getByRole('button', { name: 'textInputArea.copyContent' })); await user.click(screen.getByRole('button', { name: '复制内容' }));
expect(writeTextSpy).toHaveBeenCalledWith('测试'); expect(writeTextSpy).toHaveBeenCalledWith('测试');
}); });
@@ -158,6 +156,7 @@ describe('TextInputArea 组件', () => {
fireEvent.change(textarea, { target: { value: '123456' } }); fireEvent.change(textarea, { target: { value: '123456' } });
expect(handleChange).not.toHaveBeenCalledWith('123456'); expect(handleChange).not.toHaveBeenCalledWith('123456');
expect(screen.getByText('内容不能超过 5 个字符')).toBeInTheDocument();
}); });
it('未超出 maxLength 的输入应正常触发', () => { it('未超出 maxLength 的输入应正常触发', () => {
@@ -370,7 +369,7 @@ describe('TextInputArea 组件', () => {
const writeTextSpy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined); const writeTextSpy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined);
render(<TextInputArea value="测试" onChange={() => {}} allowCopy />); render(<TextInputArea value="测试" onChange={() => {}} allowCopy />);
await user.click(screen.getByRole('button', { name: 'textInputArea.copyContent' })); await user.click(screen.getByRole('button', { name: '复制内容' }));
expect(writeTextSpy).toHaveBeenCalledWith('测试'); expect(writeTextSpy).toHaveBeenCalledWith('测试');
}); });
@@ -381,7 +380,7 @@ describe('TextInputArea 组件', () => {
const handleClear = vi.fn(); const handleClear = vi.fn();
render(<TextInputArea value="内容" onChange={() => {}} onClear={handleClear} />); render(<TextInputArea value="内容" onChange={() => {}} onClear={handleClear} />);
fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' })); fireEvent.click(screen.getByRole('button', { name: '清空' }));
expect(handleClear).toHaveBeenCalledOnce(); expect(handleClear).toHaveBeenCalledOnce();
}); });
@@ -389,7 +388,7 @@ describe('TextInputArea 组件', () => {
it('不传 onClear 时清空按钮应正常工作', () => { it('不传 onClear 时清空按钮应正常工作', () => {
render(<TextInputArea defaultValue="内容" />); render(<TextInputArea defaultValue="内容" />);
fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' })); fireEvent.click(screen.getByRole('button', { name: '清空' }));
expect(screen.getByRole('textbox')).toHaveValue(''); expect(screen.getByRole('textbox')).toHaveValue('');
}); });
+7 -7
View File
@@ -16,13 +16,13 @@ describe('features', () => {
it('should have all required properties for each feature', () => { it('should have all required properties for each feature', () => {
FEATURES.forEach((feature) => { FEATURES.forEach((feature) => {
expect(feature).toHaveProperty('key'); expect(feature).toHaveProperty('key');
expect(feature).toHaveProperty('labelKey'); expect(feature).toHaveProperty('label');
expect(feature).toHaveProperty('descriptionKey'); expect(feature).toHaveProperty('description');
expect(feature).toHaveProperty('defaultVisible'); expect(feature).toHaveProperty('defaultVisible');
expect(feature).toHaveProperty('components'); expect(feature).toHaveProperty('components');
expect(typeof feature.key).toBe('string'); expect(typeof feature.key).toBe('string');
expect(typeof feature.labelKey).toBe('string'); expect(typeof feature.label).toBe('string');
expect(typeof feature.descriptionKey).toBe('string'); expect(typeof feature.description).toBe('string');
expect(typeof feature.defaultVisible).toBe('boolean'); expect(typeof feature.defaultVisible).toBe('boolean');
expect(typeof feature.components).toBe('object'); expect(typeof feature.components).toBe('object');
expect(feature.components).toHaveProperty('popup'); expect(feature.components).toHaveProperty('popup');
@@ -50,14 +50,14 @@ describe('features', () => {
const feature = getFeatureByKey('dashboard'); const feature = getFeatureByKey('dashboard');
expect(feature).toBeDefined(); expect(feature).toBeDefined();
expect(feature?.key).toBe('dashboard'); expect(feature?.key).toBe('dashboard');
expect(feature?.labelKey).toBe('dashboard_title'); expect(feature?.label).toBe('仪表盘');
}); });
it('should return timestamp feature', () => { it('should return timestamp feature', () => {
const feature = getFeatureByKey('timestamp'); const feature = getFeatureByKey('timestamp');
expect(feature).toBeDefined(); expect(feature).toBeDefined();
expect(feature?.key).toBe('timestamp'); expect(feature?.key).toBe('timestamp');
expect(feature?.labelKey).toBe('timestamp_title'); expect(feature?.label).toBe('时间戳');
expect(feature?.themeColorKey).toBeDefined(); expect(feature?.themeColorKey).toBeDefined();
}); });
@@ -65,7 +65,7 @@ describe('features', () => {
const feature = getFeatureByKey('storageCleaner'); const feature = getFeatureByKey('storageCleaner');
expect(feature).toBeDefined(); expect(feature).toBeDefined();
expect(feature?.key).toBe('storageCleaner'); expect(feature?.key).toBe('storageCleaner');
expect(feature?.labelKey).toBe('storageCleaner_title'); expect(feature?.label).toBe('存储清理');
}); });
it('should return undefined for invalid key', () => { it('should return undefined for invalid key', () => {
+22 -22
View File
@@ -29,8 +29,8 @@ const TestDataGeneratorPage = lazy(() => import('@/pages/TestDataGenerator'));
export interface FeatureConfig { export interface FeatureConfig {
key: PageType; key: PageType;
labelKey: string; label: string;
descriptionKey: string; description: string;
themeColorKey?: PaletteColorKey; themeColorKey?: PaletteColorKey;
icon?: ComponentType<LucideProps>; icon?: ComponentType<LucideProps>;
defaultVisible: boolean; defaultVisible: boolean;
@@ -44,8 +44,8 @@ export interface FeatureConfig {
export const FEATURES: FeatureConfig[] = [ export const FEATURES: FeatureConfig[] = [
{ {
key: 'dashboard', key: 'dashboard',
labelKey: 'dashboard_title', label: '仪表盘',
descriptionKey: '', description: '',
defaultVisible: true, defaultVisible: true,
components: { components: {
popup: DashboardPage, popup: DashboardPage,
@@ -55,8 +55,8 @@ export const FEATURES: FeatureConfig[] = [
}, },
{ {
key: 'timestamp', key: 'timestamp',
labelKey: 'timestamp_title', label: '时间戳',
descriptionKey: 'timestamp_description', description: 'Unix 毫秒数转换与格式化',
themeColorKey: 'primary', themeColorKey: 'primary',
icon: Clock, icon: Clock,
defaultVisible: true, defaultVisible: true,
@@ -68,8 +68,8 @@ export const FEATURES: FeatureConfig[] = [
}, },
{ {
key: 'storageCleaner', key: 'storageCleaner',
labelKey: 'storageCleaner_title', label: '存储清理',
descriptionKey: 'storageCleaner_description', description: '清理缓存、Cookies 及本地存储',
themeColorKey: 'warning', themeColorKey: 'warning',
icon: Database, icon: Database,
defaultVisible: true, defaultVisible: true,
@@ -81,8 +81,8 @@ export const FEATURES: FeatureConfig[] = [
}, },
{ {
key: 'qrCode', key: 'qrCode',
labelKey: 'qrCode_title', label: '二维码工具',
descriptionKey: 'qrCode_description', description: '生成当前选中的 URL 的二维码',
themeColorKey: 'success', themeColorKey: 'success',
icon: QrCode, icon: QrCode,
defaultVisible: true, defaultVisible: true,
@@ -94,8 +94,8 @@ export const FEATURES: FeatureConfig[] = [
}, },
{ {
key: 'textStatistics', key: 'textStatistics',
labelKey: 'textStatistics_title', label: '文本统计',
descriptionKey: 'textStatistics_description', description: '实时分析文本字符、单词及字节',
themeColorKey: 'secondary', themeColorKey: 'secondary',
icon: FileText, icon: FileText,
defaultVisible: true, defaultVisible: true,
@@ -107,8 +107,8 @@ export const FEATURES: FeatureConfig[] = [
}, },
{ {
key: 'jwt', key: 'jwt',
labelKey: 'jwt_title', label: 'JWT 解析',
descriptionKey: 'jwt_description', description: 'JSON Web Token 解码与查看',
themeColorKey: 'info', themeColorKey: 'info',
icon: Key, icon: Key,
defaultVisible: true, defaultVisible: true,
@@ -120,8 +120,8 @@ export const FEATURES: FeatureConfig[] = [
}, },
{ {
key: 'jsonDiff', key: 'jsonDiff',
labelKey: 'jsonDiff_title', label: 'JSON 工具',
descriptionKey: 'jsonDiff_description', description: '差异比较、格式化、YAML/TOML 转换及压缩',
themeColorKey: 'primary', themeColorKey: 'primary',
icon: GitCompareArrows, icon: GitCompareArrows,
defaultVisible: true, defaultVisible: true,
@@ -133,8 +133,8 @@ export const FEATURES: FeatureConfig[] = [
}, },
{ {
key: 'base64Converter', key: 'base64Converter',
labelKey: 'base64Converter_title', label: 'Base64 转换器',
descriptionKey: 'base64Converter_description', description: '文本、文件与图像的 Base64 编码转换',
themeColorKey: 'info', themeColorKey: 'info',
icon: ArrowLeftRight, icon: ArrowLeftRight,
defaultVisible: true, defaultVisible: true,
@@ -146,8 +146,8 @@ export const FEATURES: FeatureConfig[] = [
}, },
{ {
key: 'rightClickRestorer', key: 'rightClickRestorer',
labelKey: 'rightClickRestorer_title', label: '右键恢复',
descriptionKey: 'rightClickRestorer_description', description: '检测并恢复被网站禁用的浏览器右键菜单',
themeColorKey: 'success', themeColorKey: 'success',
icon: MousePointerClick, icon: MousePointerClick,
defaultVisible: true, defaultVisible: true,
@@ -159,8 +159,8 @@ export const FEATURES: FeatureConfig[] = [
}, },
{ {
key: 'testDataGenerator', key: 'testDataGenerator',
labelKey: 'testDataGenerator_title', label: '测试数据生成器',
descriptionKey: 'testDataGenerator_description', description: '自定义规则批量生成测试数据',
themeColorKey: 'warning', themeColorKey: 'warning',
icon: FileSpreadsheet, icon: FileSpreadsheet,
defaultVisible: true, defaultVisible: true,
@@ -1,11 +1,10 @@
import type { ContextMenuClickedPayload } from '@/utils/messages'; import type { ContextMenuClickedPayload } from '@/utils/messages';
import { MessageAction, onMessage } from '@/utils/messages'; import { MessageAction, onMessage } from '@/utils/messages';
import { getTextStats } from '@/utils/textStatistics'; import { getTextStats } from '@/utils/textStatistics';
import { getMessage } from '@/utils/chromeI18n';
import { hidePopover, showTextStatsResult, showTimestampResult } from './uiPopover'; import { hidePopover, showTextStatsResult, showTimestampResult } from './uiPopover';
function convertTimestamp(input: string): string { function convertTimestamp(input: string): string {
const invalidText = getMessage('invalidTimestamp') || 'Invalid Timestamp'; const invalidText = '无效时间戳';
const num = Number(input.trim()); const num = Number(input.trim());
if (isNaN(num)) { if (isNaN(num)) {
@@ -76,7 +76,7 @@ describe('TextMode', () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Base64 字符串无效')).toBeInTheDocument(); expect(screen.getByText('无效的 Base64 字符串')).toBeInTheDocument();
}); });
}); });
@@ -118,7 +118,7 @@ describe('TextMode', () => {
expect(screen.getByRole('button', { name: 'SGVsbG8=' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'SGVsbG8=' })).toBeInTheDocument();
}); });
fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' })); fireEvent.click(screen.getByRole('button', { name: '清空' }));
await waitFor(() => { await waitFor(() => {
expect(screen.queryByRole('button', { name: 'SGVsbG8=' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'SGVsbG8=' })).not.toBeInTheDocument();
@@ -182,9 +182,7 @@ describe('TextMode', () => {
}); });
await waitFor(() => { await waitFor(() => {
expect( expect(screen.getByText('检测到二进制数据(如图像),请使用图像选项卡')).toBeInTheDocument();
screen.getByText('输入似乎是二进制数据(如图片)。请切换到「图像」选项卡。'),
).toBeInTheDocument();
}); });
}); });
}); });
@@ -1,6 +1,5 @@
import { Image as ImageIcon, Trash2, Upload } from 'lucide-react'; import { Image as ImageIcon, Trash2, Upload } from 'lucide-react';
import TextInputArea from '@/components/TextInputArea'; import TextInputArea from '@/components/TextInputArea';
import { useI18n } from '@/utils/chromeI18n';
import { CopyButton } from '@/components/CopyButton'; import { CopyButton } from '@/components/CopyButton';
import DecodeResultPaper from './DecodeResultPaper'; import DecodeResultPaper from './DecodeResultPaper';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -20,8 +19,6 @@ interface Base64ConverterSectionProps {
} }
export default function Base64ConverterSection({ mode }: Base64ConverterSectionProps) { export default function Base64ConverterSection({ mode }: Base64ConverterSectionProps) {
const { t } = useI18n('base64Converter');
const [direction, setDirection] = useStorageState( const [direction, setDirection] = useStorageState(
`base64Converter/${mode}Mode/direction`, `base64Converter/${mode}Mode/direction`,
'encode', 'encode',
@@ -44,7 +41,6 @@ export default function Base64ConverterSection({ mode }: Base64ConverterSectionP
setCustomFileName, setCustomFileName,
resetAll, resetAll,
safeFileSelect, safeFileSelect,
maxFileSizeStr,
} = useBase64Converter({ mode }); } = useBase64Converter({ mode });
const handleDirectionChange = (next: Base64ConvertDirection) => { const handleDirectionChange = (next: Base64ConvertDirection) => {
@@ -63,8 +59,8 @@ export default function Base64ConverterSection({ mode }: Base64ConverterSectionP
<SwitchButtonGroup <SwitchButtonGroup
value={direction} value={direction}
options={[ options={[
{ value: 'encode', label: t('encode') }, { value: 'encode', label: '编码' },
{ value: 'decode', label: t('decode') }, { value: 'decode', label: '解码' },
]} ]}
onChange={handleDirectionChange} onChange={handleDirectionChange}
size="small" size="small"
@@ -126,7 +122,7 @@ export default function Base64ConverterSection({ mode }: Base64ConverterSectionP
{formatBytes(info.size)} · {info.type} {formatBytes(info.size)} · {info.type}
</span> </span>
<span className="text-[11px] font-medium text-primary/80 mt-1"> <span className="text-[11px] font-medium text-primary/80 mt-1">
{t('clickOrDropToReplace')} {'点击或拖拽以替换文件'}
</span> </span>
</div> </div>
) : ( ) : (
@@ -137,14 +133,14 @@ export default function Base64ConverterSection({ mode }: Base64ConverterSectionP
<Upload className="w-8 h-8 text-muted-foreground/60" /> <Upload className="w-8 h-8 text-muted-foreground/60" />
)} )}
<span className="text-xs font-bold text-foreground/80"> <span className="text-xs font-bold text-foreground/80">
{mode === 'image' ? t('clickOrDropToImage') : t('clickOrDropToFile')} {mode === 'image' ? '点击或拖拽图像到此处' : '点击或拖拽文件到此处'}
</span> </span>
<span className="text-[10px] font-medium text-muted-foreground/60"> <span className="text-[10px] font-medium text-muted-foreground/60">
{t('maxFileSize', { max: maxFileSizeStr })} {'最大文件大小:{{max}}'}
</span> </span>
{mode === 'image' && ( {mode === 'image' && (
<span className="text-[10px] font-medium text-muted-foreground/50"> <span className="text-[10px] font-medium text-muted-foreground/50">
{t('supportedFormats')} {'支持 PNG、JPG、WEBP、GIF、BMP、SVG 等格式'}
</span> </span>
)} )}
</div> </div>
@@ -161,17 +157,17 @@ export default function Base64ConverterSection({ mode }: Base64ConverterSectionP
<div className="p-4 rounded-2xl bg-card border border-border shadow-sm flex flex-col space-y-3"> <div className="p-4 rounded-2xl bg-card border border-border shadow-sm flex flex-col space-y-3">
<div className="flex justify-between items-center select-none"> <div className="flex justify-between items-center select-none">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/90"> <span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/90">
{t('base64Output')} {'Base64 编码结果'}
</span> </span>
<div className="flex gap-2"> <div className="flex gap-2">
<CopyButton <CopyButton
text={result.rawBase64} text={result.rawBase64}
tooltip={t('copyRaw')} tooltip={'复制纯 Base64'}
className="h-6 px-2 rounded-md border text-[10px] font-bold" className="h-6 px-2 rounded-md border text-[10px] font-bold"
/> />
<CopyButton <CopyButton
text={result.output} text={result.output}
tooltip={t('copyDataUri')} tooltip={'复制 Data URI'}
className="h-6 px-2 rounded-md border text-[10px] font-bold" className="h-6 px-2 rounded-md border text-[10px] font-bold"
/> />
</div> </div>
@@ -189,14 +185,14 @@ export default function Base64ConverterSection({ mode }: Base64ConverterSectionP
<div className="flex items-center justify-between font-mono text-[10px] text-muted-foreground/70 select-none pt-1"> <div className="flex items-center justify-between font-mono text-[10px] text-muted-foreground/70 select-none pt-1">
<div className="flex gap-4 items-center tabular-nums"> <div className="flex gap-4 items-center tabular-nums">
<span> <span>
{t('originalSize')}:{' '} {'原始大小'}:{' '}
<span className="font-semibold text-foreground/80"> <span className="font-semibold text-foreground/80">
{formatBytes(result.originalBytes)} {formatBytes(result.originalBytes)}
</span> </span>
</span> </span>
<span className="text-border/60">|</span> <span className="text-border/60">|</span>
<span> <span>
{t('encodedSize')}:{' '} {'编码大小'}:{' '}
<span className="font-semibold text-foreground/80"> <span className="font-semibold text-foreground/80">
{formatBytes(result.outputBytes)} {formatBytes(result.outputBytes)}
</span> </span>
@@ -209,7 +205,7 @@ export default function Base64ConverterSection({ mode }: Base64ConverterSectionP
className="h-7 rounded-md text-muted-foreground hover:text-destructive text-[11px] gap-1 px-2" className="h-7 rounded-md text-muted-foreground hover:text-destructive text-[11px] gap-1 px-2"
> >
<Trash2 className="w-3.5 h-3.5" /> <Trash2 className="w-3.5 h-3.5" />
{t('clear')} {'清空'}
</Button> </Button>
</div> </div>
</div> </div>
@@ -218,7 +214,7 @@ export default function Base64ConverterSection({ mode }: Base64ConverterSectionP
) : ( ) : (
<div className="flex flex-col space-y-4"> <div className="flex flex-col space-y-4">
<TextInputArea <TextInputArea
placeholder={t('decodeBase64Placeholder')} placeholder={'输入需要解码的 Base64 或 data URI...'}
value={decodeInput} value={decodeInput}
onChange={setDecodeInput} onChange={setDecodeInput}
externalError={decodeError || undefined} externalError={decodeError || undefined}
@@ -229,7 +225,7 @@ export default function Base64ConverterSection({ mode }: Base64ConverterSectionP
/> />
{decoded && ( {decoded && (
<DecodeResultPaper <DecodeResultPaper
title={mode === 'image' ? t('decodedImageOutput') : t('decodedFileOutput')} title={mode === 'image' ? '解码图像' : '解码文件'}
mimeType={decoded.mimeType} mimeType={decoded.mimeType}
blobSize={decoded.blob.size} blobSize={decoded.blob.size}
fileName={decodedFileName} fileName={decodedFileName}
@@ -12,7 +12,6 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { formatBytes } from '@/utils/format'; import { formatBytes } from '@/utils/format';
import { useI18n } from '@/utils/chromeI18n';
interface DecodeResultPaperProps { interface DecodeResultPaperProps {
/** 标题文案,由调用方传入 i18n key 对应的值(如 decodedFileOutput / decodedImageOutput */ /** 标题文案,由调用方传入 i18n key 对应的值(如 decodedFileOutput / decodedImageOutput */
@@ -40,8 +39,6 @@ export default function DecodeResultPaper({
onDownload, onDownload,
children, children,
}: DecodeResultPaperProps) { }: DecodeResultPaperProps) {
const { t } = useI18n('base64Converter');
return ( return (
<div className="p-4 rounded-xl bg-primary/10 border border-primary/30"> <div className="p-4 rounded-xl bg-primary/10 border border-primary/30">
{/* 标题 */} {/* 标题 */}
@@ -53,17 +50,17 @@ export default function DecodeResultPaper({
{/* 文件信息 */} {/* 文件信息 */}
<div className="flex gap-4 mb-3"> <div className="flex gap-4 mb-3">
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{t('inferredMimeType')}: {mimeType} {'推断的 MIME 类型'}: {mimeType}
</span> </span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{t('decodedSize')}: {formatBytes(blobSize)} {'解码大小'}: {formatBytes(blobSize)}
</span> </span>
</div> </div>
{/* 文件名输入 */} {/* 文件名输入 */}
<div className="mb-3"> <div className="mb-3">
<Label className="block text-xs font-medium text-muted-foreground mb-1"> <Label className="block text-xs font-medium text-muted-foreground mb-1">
{t('decodedFileName')} {'解码后文件名'}
</Label> </Label>
<Input value={fileName} onChange={(e) => onFileNameChange(e.target.value)} /> <Input value={fileName} onChange={(e) => onFileNameChange(e.target.value)} />
</div> </div>
@@ -76,7 +73,7 @@ export default function DecodeResultPaper({
className="w-full rounded-lg font-bold" className="w-full rounded-lg font-bold"
> >
<Download className="mr-2 h-4 w-4" /> <Download className="mr-2 h-4 w-4" />
{t('download')} {'下载'}
</Button> </Button>
</div> </div>
); );
@@ -20,7 +20,6 @@ export default function TextMode({ onSwitchToImageMode }: TextModeProps = {}) {
error, error,
showImageHint, showImageHint,
handleClear, handleClear,
t,
} = useTextMode(); } = useTextMode();
return ( return (
@@ -29,8 +28,8 @@ export default function TextMode({ onSwitchToImageMode }: TextModeProps = {}) {
<SwitchButtonGroup <SwitchButtonGroup
value={direction} value={direction}
options={[ options={[
{ value: 'encode', label: t('encode') }, { value: 'encode', label: '编码' },
{ value: 'decode', label: t('decode') }, { value: 'decode', label: '解码' },
]} ]}
onChange={handleDirectionChange} onChange={handleDirectionChange}
size="small" size="small"
@@ -52,7 +51,7 @@ export default function TextMode({ onSwitchToImageMode }: TextModeProps = {}) {
{showImageHint && ( {showImageHint && (
<div className="flex items-center justify-between p-3.5 rounded-xl bg-primary/10 border border-primary/20"> <div className="flex items-center justify-between p-3.5 rounded-xl bg-primary/10 border border-primary/20">
<span className="text-xs font-semibold text-primary tracking-tight"> <span className="text-xs font-semibold text-primary tracking-tight">
{t('imageDataUriHint')} data URI使
</span> </span>
<Button <Button
type="button" type="button"
@@ -61,7 +60,7 @@ export default function TextMode({ onSwitchToImageMode }: TextModeProps = {}) {
onClick={onSwitchToImageMode} onClick={onSwitchToImageMode}
className="h-7 rounded-md text-xs font-bold text-primary hover:text-primary hover:bg-primary/20 dark:hover:bg-primary/10 px-2.5" className="h-7 rounded-md text-xs font-bold text-primary hover:text-primary hover:bg-primary/20 dark:hover:bg-primary/10 px-2.5"
> >
{t('switchToImageMode')}
</Button> </Button>
</div> </div>
)} )}
+3 -5
View File
@@ -1,4 +1,3 @@
import { useI18n } from '@/utils/chromeI18n';
import { useStorageState } from '@/utils/useStorageState'; import { useStorageState } from '@/utils/useStorageState';
import type { Base64ConverterPageMode } from '@/types/storage'; import type { Base64ConverterPageMode } from '@/types/storage';
import TextMode from './components/TextMode'; import TextMode from './components/TextMode';
@@ -12,7 +11,6 @@ const isValidPageMode = (val: unknown): val is Base64ConverterPageMode =>
type PageMode = Base64ConverterPageMode; type PageMode = Base64ConverterPageMode;
export default function Index() { export default function Index() {
const { t } = useI18n('base64Converter');
const [pageMode, setPageMode] = useStorageState( const [pageMode, setPageMode] = useStorageState(
'base64Converter/pageMode', 'base64Converter/pageMode',
'text', 'text',
@@ -24,9 +22,9 @@ export default function Index() {
<SwitchButtonGroup <SwitchButtonGroup
value={pageMode} value={pageMode}
options={[ options={[
{ value: 'text', label: t('base64Converter:textMode') }, { value: 'text', label: '文本' },
{ value: 'file', label: t('base64Converter:fileMode') }, { value: 'file', label: '文件' },
{ value: 'image', label: t('base64Converter:imageMode') }, { value: 'image', label: '图像' },
]} ]}
onChange={(value: PageMode) => setPageMode(value)} onChange={(value: PageMode) => setPageMode(value)}
size="small" size="small"
@@ -1,5 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useI18n } from '@/utils/chromeI18n';
import type { FileToBase64Result } from '@/utils/base64Converter'; import type { FileToBase64Result } from '@/utils/base64Converter';
import { import {
base64ToBlob, base64ToBlob,
@@ -21,8 +20,6 @@ interface UseBase64ConverterProps {
} }
export function useBase64Converter({ mode }: UseBase64ConverterProps) { export function useBase64Converter({ mode }: UseBase64ConverterProps) {
const { t } = useI18n('base64Converter');
const [result, setResult] = useState<FileToBase64Result | null>(null); const [result, setResult] = useState<FileToBase64Result | null>(null);
const [info, setInfo] = useState<FileInfo | null>(null); const [info, setInfo] = useState<FileInfo | null>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -62,7 +59,7 @@ export function useBase64Converter({ mode }: UseBase64ConverterProps) {
setInfo(null); setInfo(null);
if (!isFileSizeValid(file.size)) { if (!isFileSizeValid(file.size)) {
setEncodeError(t('fileSizeExceeded', { max: `${MAX_FILE_SIZE / 1024 / 1024} MB` })); setEncodeError(`文件大小超出限制(最大 ${MAX_FILE_SIZE / 1024 / 1024} MB`);
return; return;
} }
@@ -71,7 +68,7 @@ export function useBase64Converter({ mode }: UseBase64ConverterProps) {
!isSupportedImageType(file.type) && !isSupportedImageType(file.type) &&
!isSupportedImageExtension(file.name) !isSupportedImageExtension(file.name)
) { ) {
setEncodeError(t('unsupportedImageType')); setEncodeError('不支持的图像格式');
return; return;
} }
@@ -87,13 +84,13 @@ export function useBase64Converter({ mode }: UseBase64ConverterProps) {
if (!cancelRef.current) setResult(res); if (!cancelRef.current) setResult(res);
} catch (e) { } catch (e) {
if (!cancelRef.current) { if (!cancelRef.current) {
setEncodeError(e instanceof Error ? e.message : t('conversionFailed')); setEncodeError(e instanceof Error ? e.message : '转换失败');
} }
} finally { } finally {
if (!cancelRef.current) setIsLoading(false); if (!cancelRef.current) setIsLoading(false);
} }
}, },
[mode, t], [mode],
); );
const safeFileSelect = useCallback( const safeFileSelect = useCallback(
@@ -116,10 +113,10 @@ export function useBase64Converter({ mode }: UseBase64ConverterProps) {
const message = e instanceof Error ? e.message : ''; const message = e instanceof Error ? e.message : '';
return { return {
decoded: null, decoded: null,
error: message === 'Invalid Base64 string' ? t('invalidBase64') : t('conversionFailed'), error: message === 'Invalid Base64 string' ? 'Base64 字符串无效' : '转换失败',
}; };
} }
}, [debouncedDecodeInput, t]); }, [debouncedDecodeInput]);
const decoded = decodePipeline.decoded; const decoded = decodePipeline.decoded;
const decodeError = decodePipeline.error; const decodeError = decodePipeline.error;
+6 -10
View File
@@ -1,19 +1,16 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useI18n } from '@/utils/chromeI18n';
import { base64ToText, textToBase64 } from '@/utils/base64Converter'; import { base64ToText, textToBase64 } from '@/utils/base64Converter';
import { useContextMenuData } from '@/utils/useContextMenuData'; import { useContextMenuData } from '@/utils/useContextMenuData';
const IMAGE_DATA_URI_PATTERN = /^\s*data:image\//i; const IMAGE_DATA_URI_PATTERN = /^\s*data:image\//i;
const ERROR_MESSAGE_TO_I18N: Record<string, string> = { const ERROR_MESSAGE_TO_I18N: Record<string, string> = {
'Invalid Base64 string': 'invalidBase64', 'Invalid Base64 string': '无效的 Base64 字符串',
'Input appears to be binary data (e.g. an image). Please use the Image tab instead.': 'Input appears to be binary data (e.g. an image). Please use the Image tab instead.':
'binaryDataDetected', '检测到二进制数据(如图像),请使用图像选项卡',
}; };
export function useTextMode() { export function useTextMode() {
const { t } = useI18n('base64Converter');
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [debouncedInput, setDebouncedInput] = useState(''); const [debouncedInput, setDebouncedInput] = useState('');
const [direction, setDirection] = useState<'encode' | 'decode'>('encode'); const [direction, setDirection] = useState<'encode' | 'decode'>('encode');
@@ -50,17 +47,17 @@ export function useTextMode() {
const i18nKey = ERROR_MESSAGE_TO_I18N[message]; const i18nKey = ERROR_MESSAGE_TO_I18N[message];
return { return {
output: '', output: '',
error: i18nKey ? t(i18nKey) : message || t('conversionFailed'), error: i18nKey || message || '转换失败',
}; };
} }
}, [debouncedInput, direction, t]); }, [debouncedInput, direction]);
const output = conversionPipeline.output; const output = conversionPipeline.output;
const error = conversionPipeline.error; const error = conversionPipeline.error;
const placeholder = const placeholder =
direction === 'encode' ? t('textInputPlaceholder') : t('base64InputPlaceholder'); direction === 'encode' ? '输入需要编码为 Base64 的文本...' : '输入需要解码的 Base64 字符串...';
const outputLabel = direction === 'encode' ? t('base64Output') : t('textOutput'); const outputLabel = direction === 'encode' ? 'Base64 编码结果' : '解码文本结果';
const showImageHint = useMemo( const showImageHint = useMemo(
() => direction === 'decode' && IMAGE_DATA_URI_PATTERN.test(input), () => direction === 'decode' && IMAGE_DATA_URI_PATTERN.test(input),
@@ -90,6 +87,5 @@ export function useTextMode() {
error, error,
showImageHint, showImageHint,
handleClear, handleClear,
t,
}; };
} }
+4 -6
View File
@@ -1,12 +1,10 @@
import { useRouter } from '@/providers/RouterProvider'; import { useRouter } from '@/providers/RouterProvider';
import { getFeatureByKey } from '@/config/features'; import { getFeatureByKey } from '@/config/features';
import type { PageType } from '@/types/storage'; import type { PageType } from '@/types/storage';
import { useI18n } from '@/utils/chromeI18n';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
export default function Index() { export default function Index() {
const { navigateTo, visiblePages, pageOrder, recentlyUsedTools } = useRouter(); const { navigateTo, visiblePages, pageOrder, recentlyUsedTools } = useRouter();
const { t } = useI18n(['features']);
const visibleSet = new Set<string>(visiblePages); const visibleSet = new Set<string>(visiblePages);
@@ -28,7 +26,7 @@ export default function Index() {
{showRecent && ( {showRecent && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<h3 className="text-xs font-semibold text-muted-foreground/80 uppercase tracking-wider"> <h3 className="text-xs font-semibold text-muted-foreground/80 uppercase tracking-wider">
{t('dashboard_recentlyUsed')} {'最近使用'}
</h3> </h3>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{recentFeatures.map(({ key, feature }) => { {recentFeatures.map(({ key, feature }) => {
@@ -46,7 +44,7 @@ export default function Index() {
)} )}
> >
<IconComponent className="h-3.5 w-3.5 text-muted-foreground/70" /> <IconComponent className="h-3.5 w-3.5 text-muted-foreground/70" />
{t(feature!.labelKey)} {feature!.label}
</button> </button>
); );
})} })}
@@ -57,7 +55,7 @@ export default function Index() {
{/* 全部工具 — 紧凑 Grid */} {/* 全部工具 — 紧凑 Grid */}
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<h3 className="text-xs font-semibold text-muted-foreground/80 uppercase tracking-wider"> <h3 className="text-xs font-semibold text-muted-foreground/80 uppercase tracking-wider">
{t('dashboard_allTools')} {'全部工具'}
</h3> </h3>
<div className={cn('grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-2')}> <div className={cn('grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-2')}>
{visibleFeatures.map(({ key, feature }) => { {visibleFeatures.map(({ key, feature }) => {
@@ -82,7 +80,7 @@ export default function Index() {
)} )}
/> />
<span className="text-[11px] font-medium text-muted-foreground/80 group-hover:text-foreground leading-tight text-center truncate w-full transition-colors"> <span className="text-[11px] font-medium text-muted-foreground/80 group-hover:text-foreground leading-tight text-center truncate w-full transition-colors">
{t(feature!.labelKey)} {feature!.label}
</span> </span>
</button> </button>
); );
@@ -1,6 +1,5 @@
import React from 'react'; import React from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react'; import { ChevronLeft, ChevronRight } from 'lucide-react';
import { useI18n } from '@/utils/chromeI18n';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
export interface DiffNavigatorProps extends React.HTMLAttributes<HTMLDivElement> { export interface DiffNavigatorProps extends React.HTMLAttributes<HTMLDivElement> {
@@ -19,8 +18,6 @@ export default function DiffNavigator({
className, className,
...props ...props
}: DiffNavigatorProps) { }: DiffNavigatorProps) {
const { t } = useI18n('jsonDiff');
const isFirst = currentIndex <= 0; const isFirst = currentIndex <= 0;
const isLast = currentIndex >= total - 1; const isLast = currentIndex >= total - 1;
@@ -33,9 +30,7 @@ export default function DiffNavigator({
)} )}
{...props} {...props}
> >
<span className="text-xs font-semibold text-muted-foreground/90"> <span className="text-xs font-semibold text-muted-foreground/90">{'无差异'}</span>
{t('jsonDiff:noDiffs')}
</span>
</div> </div>
); );
} }
@@ -52,7 +47,7 @@ export default function DiffNavigator({
<button <button
type="button" type="button"
disabled={isFirst} disabled={isFirst}
aria-label={t('jsonDiff:previousDiff')} aria-label={'上一个'}
onClick={onPrev} onClick={onPrev}
className={cn( className={cn(
'p-1 rounded-md text-muted-foreground hover:bg-accent hover:text-foreground', 'p-1 rounded-md text-muted-foreground hover:bg-accent hover:text-foreground',
@@ -72,7 +67,7 @@ export default function DiffNavigator({
<button <button
type="button" type="button"
disabled={isLast} disabled={isLast}
aria-label={t('jsonDiff:nextDiff')} aria-label={'下一个'}
onClick={onNext} onClick={onNext}
className={cn( className={cn(
'p-1 rounded-md text-muted-foreground hover:bg-accent hover:text-foreground', 'p-1 rounded-md text-muted-foreground hover:bg-accent hover:text-foreground',
@@ -1,5 +1,4 @@
import React from 'react'; import React from 'react';
import { useI18n } from '@/utils/chromeI18n';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import JsonTree from './JsonTree'; import JsonTree from './JsonTree';
import type { DiffNode, DiffResult as DiffResultType, DiffType, ViewMode } from '../types'; import type { DiffNode, DiffResult as DiffResultType, DiffType, ViewMode } from '../types';
@@ -17,8 +16,6 @@ export default function DiffResult({
className, className,
...props ...props
}: DiffResultProps) { }: DiffResultProps) {
const { t } = useI18n('jsonDiff');
if (viewMode === 'sideBySide') { if (viewMode === 'sideBySide') {
return ( return (
<div <div
@@ -26,11 +23,11 @@ export default function DiffResult({
{...props} {...props}
> >
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<SectionLabel text={t('jsonDiff:leftLabel')} /> <SectionLabel text={'原始 JSON'} />
<JsonTree node={result.root} side="left" activePath={activePath} /> <JsonTree node={result.root} side="left" activePath={activePath} />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<SectionLabel text={t('jsonDiff:rightLabel')} /> <SectionLabel text={'目标 JSON'} />
<JsonTree node={result.root} side="right" activePath={activePath} /> <JsonTree node={result.root} side="right" activePath={activePath} />
</div> </div>
</div> </div>
@@ -1,5 +1,4 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { useI18n } from '@/utils/chromeI18n';
import { formatBytes } from '@/utils/format'; import { formatBytes } from '@/utils/format';
import { CopyButton } from '@/components/CopyButton'; import { CopyButton } from '@/components/CopyButton';
import TextInputArea from '@/components/TextInputArea'; import TextInputArea from '@/components/TextInputArea';
@@ -7,6 +6,27 @@ import { validateJson } from '@/utils/jsonFormatter';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { ConvertFunction, ConvertResult } from '../types'; import type { ConvertFunction, ConvertResult } from '../types';
const CONVERT_LABELS: Record<
string,
{ inputPlaceholder: string; outputLabel: string; emptyHint: string }
> = {
yaml: {
inputPlaceholder: '输入需要转换的 JSON...',
outputLabel: 'YAML 结果',
emptyHint: '输入 JSON 后点击转换',
},
toml: {
inputPlaceholder: '输入需要转换的 JSON...',
outputLabel: 'TOML 结果',
emptyHint: '输入 JSON 后点击转换',
},
minify: {
inputPlaceholder: '输入需要压缩的 JSON...',
outputLabel: '压缩结果',
emptyHint: '输入 JSON 后点击压缩',
},
};
interface JsonConvertSectionProps extends React.HTMLAttributes<HTMLDivElement> { interface JsonConvertSectionProps extends React.HTMLAttributes<HTMLDivElement> {
translationPrefix: string; translationPrefix: string;
convertFunction: ConvertFunction; convertFunction: ConvertFunction;
@@ -18,12 +38,11 @@ export default function JsonConvertSection({
className, className,
...props ...props
}: JsonConvertSectionProps) { }: JsonConvertSectionProps) {
const { t } = useI18n('jsonFormat');
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [debouncedInput, setDebouncedInput] = useState(''); const [debouncedInput, setDebouncedInput] = useState('');
const pk = translationPrefix; const pk = translationPrefix;
const labels = CONVERT_LABELS[pk] || CONVERT_LABELS.yaml;
// Debounce input // Debounce input
useEffect(() => { useEffect(() => {
@@ -65,7 +84,7 @@ export default function JsonConvertSection({
<div className={cn('w-full flex flex-col gap-4', className)} {...props}> <div className={cn('w-full flex flex-col gap-4', className)} {...props}>
{/* 输入区 */} {/* 输入区 */}
<TextInputArea <TextInputArea
placeholder={t(`jsonFormat:${pk}InputPlaceholder`)} placeholder={labels.inputPlaceholder}
value={input} value={input}
onChange={setInput} onChange={setInput}
externalError={error || runtimeError || undefined} externalError={error || runtimeError || undefined}
@@ -82,19 +101,19 @@ export default function JsonConvertSection({
<div className="flex h-9 items-center justify-between px-4 border-b border-border bg-muted/50 select-none"> <div className="flex h-9 items-center justify-between px-4 border-b border-border bg-muted/50 select-none">
<div className="flex gap-4 items-center"> <div className="flex gap-4 items-center">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/90"> <span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/90">
{t(`jsonFormat:${pk}OutputLabel`)} {labels.outputLabel}
</span> </span>
<div className="hidden sm:flex gap-3 items-center font-mono text-[10px] text-muted-foreground/70 tabular-nums"> <div className="hidden sm:flex gap-3 items-center font-mono text-[10px] text-muted-foreground/70 tabular-nums">
<span> <span>
{t('jsonFormat:originalSize')}:{' '} {'原始大小'}:{' '}
<span className="font-semibold text-foreground/80"> <span className="font-semibold text-foreground/80">
{formatBytes(result.originalBytes)} {formatBytes(result.originalBytes)}
</span> </span>
</span> </span>
<span className="text-border/60">|</span> <span className="text-border/60">|</span>
<span> <span>
{t('jsonFormat:formattedSize')}:{' '} {'格式化后大小'}:{' '}
<span className="font-semibold text-foreground/80"> <span className="font-semibold text-foreground/80">
{formatBytes(result.outputBytes)} {formatBytes(result.outputBytes)}
</span> </span>
@@ -115,7 +134,7 @@ export default function JsonConvertSection({
) : ( ) : (
<div className="p-8 rounded-xl bg-muted/30 border border-dashed border-border/80 text-center flex flex-col items-center justify-center min-h-[120px] select-none"> <div className="p-8 rounded-xl bg-muted/30 border border-dashed border-border/80 text-center flex flex-col items-center justify-center min-h-[120px] select-none">
<p className="text-xs font-semibold text-muted-foreground/80 tracking-wide max-w-[240px] leading-relaxed"> <p className="text-xs font-semibold text-muted-foreground/80 tracking-wide max-w-[240px] leading-relaxed">
{error ? t('jsonFormat:fixErrorHint') : t(`jsonFormat:${pk}EmptyHint`)} {error ? '请修正上方 JSON 的语法错误以开启实时流式格式化' : labels.emptyHint}
</p> </p>
</div> </div>
)} )}
@@ -1,5 +1,4 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useI18n } from '@/utils/chromeI18n';
import { import {
formatJson, formatJson,
type JsonFormatOptions, type JsonFormatOptions,
@@ -14,8 +13,6 @@ import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
export default function JsonFormatSection() { export default function JsonFormatSection() {
const { t } = useI18n('jsonFormat');
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [debouncedInput, setDebouncedInput] = useState(''); const [debouncedInput, setDebouncedInput] = useState('');
const [indentSize, setIndentSize] = useState<number>(2); const [indentSize, setIndentSize] = useState<number>(2);
@@ -66,7 +63,7 @@ export default function JsonFormatSection() {
{/* 缩进配置区 */} {/* 缩进配置区 */}
<div className="flex gap-2 items-center shrink-0 select-none"> <div className="flex gap-2 items-center shrink-0 select-none">
<span className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider"> <span className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider">
{t('jsonFormat:indentSize')} {'缩进'}
</span> </span>
<SwitchButtonGroup <SwitchButtonGroup
value={indentSize} value={indentSize}
@@ -94,7 +91,7 @@ export default function JsonFormatSection() {
htmlFor="sort-keys-checkbox" htmlFor="sort-keys-checkbox"
className="text-xs font-bold text-foreground/80 cursor-pointer tracking-tight group-hover:text-foreground" className="text-xs font-bold text-foreground/80 cursor-pointer tracking-tight group-hover:text-foreground"
> >
{t('jsonFormat:sortKeys')} {'键名排序'}
</Label> </Label>
</div> </div>
</div> </div>
@@ -102,7 +99,7 @@ export default function JsonFormatSection() {
{/* 满血版输入终端 */} {/* 满血版输入终端 */}
<TextInputArea <TextInputArea
placeholder={t('jsonFormat:inputPlaceholder')} placeholder={'输入需要格式化的 JSON...'}
value={input} value={input}
onChange={setInput} onChange={setInput}
externalError={error || runtimeError || undefined} externalError={error || runtimeError || undefined}
@@ -120,19 +117,19 @@ export default function JsonFormatSection() {
<div className="flex h-9 items-center justify-between px-4 border-b border-border bg-muted/50 select-none"> <div className="flex h-9 items-center justify-between px-4 border-b border-border bg-muted/50 select-none">
<div className="flex gap-4 items-center"> <div className="flex gap-4 items-center">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/90"> <span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/90">
{t('jsonFormat:outputLabel')} {'格式化结果'}
</span> </span>
<div className="hidden sm:flex gap-3 items-center font-mono text-[10px] text-muted-foreground/70 tabular-nums"> <div className="hidden sm:flex gap-3 items-center font-mono text-[10px] text-muted-foreground/70 tabular-nums">
<span> <span>
{t('jsonFormat:originalSize')}:{' '} {'原始大小'}:{' '}
<span className="font-semibold text-foreground/80"> <span className="font-semibold text-foreground/80">
{formatBytes(result.originalBytes)} {formatBytes(result.originalBytes)}
</span> </span>
</span> </span>
<span className="text-border/60">|</span> <span className="text-border/60">|</span>
<span> <span>
{t('jsonFormat:formattedSize')}:{' '} {'格式化后大小'}:{' '}
<span className="font-semibold text-foreground/80"> <span className="font-semibold text-foreground/80">
{formatBytes(result.formattedBytes)} {formatBytes(result.formattedBytes)}
</span> </span>
@@ -154,7 +151,7 @@ export default function JsonFormatSection() {
/* 空状态指示引导区 */ /* 空状态指示引导区 */
<div className="p-8 rounded-xl bg-muted/30 border border-dashed border-border/80 text-center flex flex-col items-center justify-center min-h-[120px] select-none"> <div className="p-8 rounded-xl bg-muted/30 border border-dashed border-border/80 text-center flex flex-col items-center justify-center min-h-[120px] select-none">
<p className="text-xs font-semibold text-muted-foreground/80 tracking-wide max-w-[240px] leading-relaxed"> <p className="text-xs font-semibold text-muted-foreground/80 tracking-wide max-w-[240px] leading-relaxed">
{error ? t('jsonFormat:fixErrorHint') : t('jsonFormat:emptyHint')} {error ? '请修正上方 JSON 的语法错误以开启实时流式格式化' : '输入 JSON 后点击格式化'}
</p> </p>
</div> </div>
)} )}
+14 -14
View File
@@ -1,4 +1,3 @@
import { useI18n } from '@/utils/chromeI18n';
import JsonDiffInput from './components/JsonDiffInput'; import JsonDiffInput from './components/JsonDiffInput';
import DiffResult from './components/DiffResult'; import DiffResult from './components/DiffResult';
import DiffNavigator from './components/DiffNavigator'; import DiffNavigator from './components/DiffNavigator';
@@ -12,7 +11,6 @@ import type { ViewMode } from './types';
type PageMode = JsonToolsPageMode; type PageMode = JsonToolsPageMode;
export default function Index() { export default function Index() {
const { t } = useI18n(['jsonDiff', 'jsonFormat']);
const { const {
pageMode, pageMode,
setPageMode, setPageMode,
@@ -41,11 +39,11 @@ export default function Index() {
value={pageMode} value={pageMode}
onChange={(v: PageMode) => setPageMode(v)} onChange={(v: PageMode) => setPageMode(v)}
options={[ options={[
{ value: 'diff', label: t('jsonFormat:diffMode') }, { value: 'diff', label: '差异比较' },
{ value: 'format', label: t('jsonFormat:formatMode') }, { value: 'format', label: '格式化' },
{ value: 'yaml', label: t('jsonFormat:yamlMode') }, { value: 'yaml', label: 'YAML' },
{ value: 'toml', label: t('jsonFormat:tomlMode') }, { value: 'toml', label: 'TOML' },
{ value: 'minify', label: t('jsonFormat:minifyMode') }, { value: 'minify', label: '压缩' },
]} ]}
size="small" size="small"
className="w-full sm:w-auto" className="w-full sm:w-auto"
@@ -58,8 +56,8 @@ export default function Index() {
value={viewMode} value={viewMode}
onChange={(v: ViewMode) => setViewMode(v)} onChange={(v: ViewMode) => setViewMode(v)}
options={[ options={[
{ value: 'sideBySide', label: t('jsonDiff:sideBySideMode') }, { value: 'sideBySide', label: '并排' },
{ value: 'unified', label: t('jsonDiff:unifiedMode') }, { value: 'unified', label: '统一' },
]} ]}
size="small" size="small"
/> />
@@ -67,16 +65,16 @@ export default function Index() {
<div className="flex flex-col md:flex-row gap-4 w-full items-stretch"> <div className="flex flex-col md:flex-row gap-4 w-full items-stretch">
<JsonDiffInput <JsonDiffInput
label={t('jsonDiff:leftLabel')} label={'原始 JSON'}
placeholder={t('jsonDiff:leftPlaceholder')} placeholder={'输入原始 JSON...'}
value={leftInput} value={leftInput}
onChange={setLeftInput} onChange={setLeftInput}
error={leftError} error={leftError}
minRows={9} minRows={9}
/> />
<JsonDiffInput <JsonDiffInput
label={t('jsonDiff:rightLabel')} label={'目标 JSON'}
placeholder={t('jsonDiff:rightPlaceholder')} placeholder={'输入目标 JSON...'}
value={rightInput} value={rightInput}
onChange={setRightInput} onChange={setRightInput}
error={rightError} error={rightError}
@@ -99,7 +97,9 @@ export default function Index() {
) : ( ) : (
<div className="p-8 rounded-xl bg-muted/30 border border-dashed border-border/80 text-center flex flex-col items-center justify-center min-h-[140px]"> <div className="p-8 rounded-xl bg-muted/30 border border-dashed border-border/80 text-center flex flex-col items-center justify-center min-h-[140px]">
<p className="text-xs font-semibold text-muted-foreground/80 tracking-wide max-w-[260px] leading-relaxed"> <p className="text-xs font-semibold text-muted-foreground/80 tracking-wide max-w-[260px] leading-relaxed">
{leftError || rightError ? t('jsonDiff:fixErrorHint') : t('jsonDiff:emptyHint')} {leftError || rightError
? '请修正上方 JSON 的语法错误以开启实时流式比对'
: '输入两侧 JSON 后点击比较'}
</p> </p>
</div> </div>
)} )}
+2 -4
View File
@@ -1,5 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useI18n } from '@/utils/chromeI18n';
import { useStorageState } from '@/utils/useStorageState'; import { useStorageState } from '@/utils/useStorageState';
import { diffJson } from '@/utils/diffEngine'; import { diffJson } from '@/utils/diffEngine';
import { jsonToYaml } from '@/utils/jsonToYaml'; import { jsonToYaml } from '@/utils/jsonToYaml';
@@ -34,7 +33,6 @@ export interface UseJsonToolsReturn {
} }
export function useJsonTools(): UseJsonToolsReturn { export function useJsonTools(): UseJsonToolsReturn {
const { t } = useI18n(['jsonDiff', 'jsonFormat']);
const [pageMode, setPageMode] = useStorageState('jsonTools/pageMode', 'diff', isValidPageMode); const [pageMode, setPageMode] = useStorageState('jsonTools/pageMode', 'diff', isValidPageMode);
// Diff inputs // Diff inputs
@@ -54,12 +52,12 @@ export function useJsonTools(): UseJsonToolsReturn {
// Parse debounced inputs // Parse debounced inputs
const parseState = useMemo(() => { const parseState = useMemo(() => {
const invalidMsg = t('jsonDiff:invalidJson'); const invalidMsg = '无效的 JSON 格式';
return { return {
left: tryParse(debouncedLeft, invalidMsg), left: tryParse(debouncedLeft, invalidMsg),
right: tryParse(debouncedRight, invalidMsg), right: tryParse(debouncedRight, invalidMsg),
}; };
}, [debouncedLeft, debouncedRight, t]); }, [debouncedLeft, debouncedRight]);
const leftError = parseState.left.error; const leftError = parseState.left.error;
const rightError = parseState.right.error; const rightError = parseState.right.error;
+1 -3
View File
@@ -1,5 +1,4 @@
import { CopyButton } from '@/components/CopyButton'; import { CopyButton } from '@/components/CopyButton';
import { useI18n } from '@/utils/chromeI18n';
import { stringifyJson } from '@/utils/jwt'; import { stringifyJson } from '@/utils/jwt';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -18,7 +17,6 @@ export default function JwtSection({
bgClass, bgClass,
borderClass, borderClass,
}: JwtSectionProps) { }: JwtSectionProps) {
const { t } = useI18n('jwt');
return ( return (
<div className={cn('p-4 rounded-xl border border-solid', bgClass, borderClass)}> <div className={cn('p-4 rounded-xl border border-solid', bgClass, borderClass)}>
<div className="flex justify-between items-center mb-2 select-none"> <div className="flex justify-between items-center mb-2 select-none">
@@ -31,7 +29,7 @@ export default function JwtSection({
/> />
</div> </div>
<pre className="m-0 p-3 bg-muted/30 dark:bg-muted/10 rounded-lg text-xs font-mono overflow-x-auto whitespace-pre-wrap break-all border border-border/50 text-foreground/90 leading-relaxed select-text"> <pre className="m-0 p-3 bg-muted/30 dark:bg-muted/10 rounded-lg text-xs font-mono overflow-x-auto whitespace-pre-wrap break-all border border-border/50 text-foreground/90 leading-relaxed select-text">
{content ? stringifyJson(content) : t('jwt:invalidFormat')} {content ? stringifyJson(content) : '无法解析'}
</pre> </pre>
</div> </div>
); );
+6 -10
View File
@@ -2,10 +2,8 @@ import TextInputArea from '@/components/TextInputArea';
import { CopyButton } from '@/components/CopyButton'; import { CopyButton } from '@/components/CopyButton';
import JwtSection from './JwtSection'; import JwtSection from './JwtSection';
import { useJwt } from './useJwt'; import { useJwt } from './useJwt';
import { useI18n } from '@/utils/chromeI18n';
export default function Index() { export default function Index() {
const { t } = useI18n(['jwt', 'jsonFormat']);
const { jwtInput, result, handleChange, handleClear } = useJwt(); const { jwtInput, result, handleChange, handleClear } = useJwt();
return ( return (
@@ -14,7 +12,7 @@ export default function Index() {
<TextInputArea <TextInputArea
minRows={5} minRows={5}
maxRows={10} maxRows={10}
placeholder={t('jwt_placeholder')} placeholder={'在此粘贴 JWT 令牌 (Encoded JWT)...'}
value={jwtInput} value={jwtInput}
onChange={handleChange} onChange={handleChange}
allowCopy={true} allowCopy={true}
@@ -26,7 +24,7 @@ export default function Index() {
{result && !result.error && ( {result && !result.error && (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<JwtSection <JwtSection
title={t('jwt:headerTitle')} title={'HEADER: 算法 & 令牌类型'}
content={result.header} content={result.header}
colorClass="text-[#fb015b] dark:text-rose-400" colorClass="text-[#fb015b] dark:text-rose-400"
borderClass="border-[#fb015b]/20 dark:border-rose-500/20" borderClass="border-[#fb015b]/20 dark:border-rose-500/20"
@@ -34,7 +32,7 @@ export default function Index() {
/> />
<JwtSection <JwtSection
title={t('jwt:payloadTitle')} title={'PAYLOAD: 数据'}
content={result.payload} content={result.payload}
colorClass="text-[#a03aff] dark:text-purple-400" colorClass="text-[#a03aff] dark:text-purple-400"
borderClass="border-[#a03aff]/20 dark:border-purple-500/20" borderClass="border-[#a03aff]/20 dark:border-purple-500/20"
@@ -44,7 +42,7 @@ export default function Index() {
<div className="p-4 rounded-xl border border-border bg-secondary/40 shadow-sm"> <div className="p-4 rounded-xl border border-border bg-secondary/40 shadow-sm">
<div className="flex justify-between items-center mb-2"> <div className="flex justify-between items-center mb-2">
<span className="text-xs font-bold tracking-wider text-muted-foreground/90 uppercase"> <span className="text-xs font-bold tracking-wider text-muted-foreground/90 uppercase">
{t('jwt:signatureTitle')} {'签名'}
</span> </span>
<CopyButton <CopyButton
text={result.signature || ''} text={result.signature || ''}
@@ -52,7 +50,7 @@ export default function Index() {
/> />
</div> </div>
<span className="block text-xs font-mono break-all text-foreground/80 bg-muted/30 dark:bg-muted/10 p-3 rounded-lg border border-border/50 leading-relaxed select-text"> <span className="block text-xs font-mono break-all text-foreground/80 bg-muted/30 dark:bg-muted/10 p-3 rounded-lg border border-border/50 leading-relaxed select-text">
{result.signature || t('jwt:noSignature')} {result.signature || '无签名'}
</span> </span>
</div> </div>
</div> </div>
@@ -60,9 +58,7 @@ export default function Index() {
{result?.error && ( {result?.error && (
<div className="p-6 rounded-xl bg-muted/30 border border-dashed border-border text-center"> <div className="p-6 rounded-xl bg-muted/30 border border-dashed border-border text-center">
<p className="text-xs font-semibold text-muted-foreground/80"> <p className="text-xs font-semibold text-muted-foreground/80">{'无效的 JSON 格式'}</p>
{t('jsonFormat:invalidJson')}
</p>
</div> </div>
)} )}
</div> </div>
@@ -1,7 +1,6 @@
import { Loader2, Pencil, QrCode } from 'lucide-react'; import { Loader2, Pencil, QrCode } from 'lucide-react';
import TextInputArea from '@/components/TextInputArea'; import TextInputArea from '@/components/TextInputArea';
import QrCodePreview from './QrCodePreview'; import QrCodePreview from './QrCodePreview';
import { useI18n } from '@/utils/chromeI18n';
import { useQrCodeContext } from '../contexts/QrCodeContext'; import { useQrCodeContext } from '../contexts/QrCodeContext';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
@@ -11,7 +10,6 @@ import { cn } from '@/lib/utils';
const TEXT_PREVIEW_MAX_LENGTH = 80; const TEXT_PREVIEW_MAX_LENGTH = 80;
export default function GeneratePanel() { export default function GeneratePanel() {
const { t } = useI18n('qrCode');
const { const {
generatorState, generatorState,
setTextToEncode, setTextToEncode,
@@ -42,13 +40,13 @@ export default function GeneratePanel() {
> >
<div className="flex flex-col space-y-2.5"> <div className="flex flex-col space-y-2.5">
<Label className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider select-none pl-0.5"> <Label className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider select-none pl-0.5">
{t('qrCode:urlInputLabel')} {'输入 URL 或文本'}
</Label> </Label>
<TextInputArea <TextInputArea
value={generatorState.textToEncode} value={generatorState.textToEncode}
onChange={setTextToEncode} onChange={setTextToEncode}
placeholder={t('qrCode:urlInputPlaceholder')} placeholder={'请输入 URL 或文本内容,将自动生成二维码'}
showCount={true} showCount={true}
showClear={true} showClear={true}
allowCopy={false} allowCopy={false}
@@ -66,12 +64,12 @@ export default function GeneratePanel() {
{generatorState.generating ? ( {generatorState.generating ? (
<> <>
<Loader2 className="w-4 h-4 mr-2 animate-spin" /> <Loader2 className="w-4 h-4 mr-2 animate-spin" />
{t('qrCode:generating')} {'生成中...'}
</> </>
) : ( ) : (
<> <>
<QrCode className="w-4 h-4 mr-2" /> <QrCode className="w-4 h-4 mr-2" />
{t('qrCode:generateButton')} {'生成二维码'}
</> </>
)} )}
</Button> </Button>
@@ -88,11 +86,11 @@ export default function GeneratePanel() {
<div className="border border-border rounded-xl bg-card text-card-foreground shadow-sm p-3 mb-3"> <div className="border border-border rounded-xl bg-card text-card-foreground shadow-sm p-3 mb-3">
<div className="flex items-center justify-between mb-1.5"> <div className="flex items-center justify-between mb-1.5">
<Label className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider select-none pl-0.5"> <Label className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider select-none pl-0.5">
{t('qrCode:textPreviewLabel')} {'原始文本'}
</Label> </Label>
<Button variant="ghost" size="sm" onClick={backToEdit} className="h-6 px-2 text-xs"> <Button variant="ghost" size="sm" onClick={backToEdit} className="h-6 px-2 text-xs">
<Pencil className="w-3 h-3 mr-1" /> <Pencil className="w-3 h-3 mr-1" />
{t('qrCode:editButton')} {'编辑'}
</Button> </Button>
</div> </div>
<div <div
+8 -10
View File
@@ -3,7 +3,6 @@ import { Image, X } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { useI18n } from '@/utils/chromeI18n';
interface ImageUploaderProps { interface ImageUploaderProps {
/** 选中的文件 */ /** 选中的文件 */
@@ -31,7 +30,6 @@ const ImageUploader = ({
dragging, dragging,
onDraggingChange, onDraggingChange,
}: ImageUploaderProps) => { }: ImageUploaderProps) => {
const { t } = useI18n('qrCode');
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileChange = useCallback( const handleFileChange = useCallback(
@@ -47,8 +45,8 @@ const ImageUploader = ({
URL.revokeObjectURL(previewUrl); URL.revokeObjectURL(previewUrl);
} }
onClearFile(); onClearFile();
toast.success(t('qrCode:imageCleared')); toast.success('图片已清除');
}, [previewUrl, onClearFile, t]); }, [previewUrl, onClearFile]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) { if (e.target.files && e.target.files.length > 0) {
@@ -88,10 +86,10 @@ const ImageUploader = ({
if (file) { if (file) {
try { try {
handleFileChange(file); handleFileChange(file);
toast.success(t('qrCode:imagePasted')); toast.success('图片粘贴成功,正在解析...');
} catch (error) { } catch (error) {
console.error('处理粘贴图片失败:', error); console.error('处理粘贴图片失败:', error);
toast.error(t('qrCode:imagePasteError')); toast.error('粘贴图片失败,请重试');
} }
} }
break; break;
@@ -104,7 +102,7 @@ const ImageUploader = ({
return () => { return () => {
document.removeEventListener('paste', handlePaste); document.removeEventListener('paste', handlePaste);
}; };
}, [handleFileChange, t]); }, [handleFileChange]);
return ( return (
<div <div
@@ -151,7 +149,7 @@ const ImageUploader = ({
</Button> </Button>
</div> </div>
<span className="block text-sm text-muted-foreground mt-2">{selectedFile.name}</span> <span className="block text-sm text-muted-foreground mt-2">{selectedFile.name}</span>
<span className="block text-xs text-muted-foreground">{t('qrCode:clickToChange')}</span> <span className="block text-xs text-muted-foreground">{'点击更换图片'}</span>
</div> </div>
) : ( ) : (
<> <>
@@ -160,10 +158,10 @@ const ImageUploader = ({
className="w-12 h-12 text-muted-foreground mx-auto mb-2" className="w-12 h-12 text-muted-foreground mx-auto mb-2"
/> />
<span className="block text-sm text-muted-foreground mb-1"> <span className="block text-sm text-muted-foreground mb-1">
{t('qrCode:clickToUpload')} {'点击、拖拽或粘贴上传二维码图片'}
</span> </span>
<span className="block text-xs text-muted-foreground"> <span className="block text-xs text-muted-foreground">
{t('qrCode:supportFormats')} {'支持 PNG、JPG、WEBP、Base64 格式'}
</span> </span>
</> </>
)} )}
+9 -11
View File
@@ -3,14 +3,12 @@ import { RefreshCw } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import TextInputArea from '@/components/TextInputArea'; import TextInputArea from '@/components/TextInputArea';
import ImageUploader from './ImageUploader'; import ImageUploader from './ImageUploader';
import { useI18n } from '@/utils/chromeI18n';
import { useQrCodeContext } from '../contexts/QrCodeContext'; import { useQrCodeContext } from '../contexts/QrCodeContext';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
export default function ParsePanel() { export default function ParsePanel() {
const { t } = useI18n('qrCode');
const { parserState, setParserState, handleFileChange, handleClearFile } = useQrCodeContext(); const { parserState, setParserState, handleFileChange, handleClearFile } = useQrCodeContext();
const hasFile = parserState.selectedFile !== null; const hasFile = parserState.selectedFile !== null;
@@ -27,7 +25,7 @@ export default function ParsePanel() {
const file = items[i].getAsFile(); const file = items[i].getAsFile();
if (file) { if (file) {
handleFileChange(file); handleFileChange(file);
toast.success(t('qrCode:imagePasted')); toast.success('图片粘贴成功,正在解析...');
} }
return; return;
} }
@@ -41,14 +39,14 @@ export default function ParsePanel() {
const blob = await response.blob(); const blob = await response.blob();
const file = new File([blob], 'pasted-image.png', { type: blob.type }); const file = new File([blob], 'pasted-image.png', { type: blob.type });
handleFileChange(file); handleFileChange(file);
toast.success(t('qrCode:imagePasted')); toast.success('图片粘贴成功,正在解析...');
} catch (error) { } catch (error) {
console.error('处理 Base64 图片失败:', error); console.error('处理 Base64 图片失败:', error);
toast.error(t('qrCode:imagePasteError')); toast.error('粘贴图片失败,请重试');
} }
} }
}, },
[handleFileChange, t], [handleFileChange],
); );
useEffect(() => { useEffect(() => {
@@ -82,11 +80,11 @@ export default function ParsePanel() {
<div className="border border-border rounded-xl bg-card text-card-foreground shadow-sm p-3 mb-3"> <div className="border border-border rounded-xl bg-card text-card-foreground shadow-sm p-3 mb-3">
<div className="flex items-center justify-between mb-1.5"> <div className="flex items-center justify-between mb-1.5">
<Label className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider select-none pl-0.5"> <Label className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider select-none pl-0.5">
{t('qrCode:uploadedImage')} {'已上传图片'}
</Label> </Label>
<Button variant="ghost" size="sm" onClick={handleClearFile} className="h-6 px-2 text-xs"> <Button variant="ghost" size="sm" onClick={handleClearFile} className="h-6 px-2 text-xs">
<RefreshCw className="w-3 h-3 mr-1" /> <RefreshCw className="w-3 h-3 mr-1" />
{t('qrCode:reuploadButton')} {'重新上传'}
</Button> </Button>
</div> </div>
<div className="flex items-center gap-3 bg-muted/50 rounded-md p-2"> <div className="flex items-center gap-3 bg-muted/50 rounded-md p-2">
@@ -98,7 +96,7 @@ export default function ParsePanel() {
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm text-foreground truncate">{parserState.selectedFile?.name}</p> <p className="text-sm text-foreground truncate">{parserState.selectedFile?.name}</p>
{parserState.parsing && ( {parserState.parsing && (
<p className="text-xs text-primary animate-pulse mt-1">{t('qrCode:parsing')}</p> <p className="text-xs text-primary animate-pulse mt-1">{'解析中...'}</p>
)} )}
</div> </div>
</div> </div>
@@ -113,7 +111,7 @@ export default function ParsePanel() {
> >
<div className="flex flex-col space-y-2.5"> <div className="flex flex-col space-y-2.5">
<Label className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider pl-0.5"> <Label className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider pl-0.5">
{t('qrCode:resultLabel')} {'解析结果'}
</Label> </Label>
<TextInputArea <TextInputArea
@@ -121,7 +119,7 @@ export default function ParsePanel() {
readOnly={true} readOnly={true}
showClear={false} showClear={false}
allowCopy={true} allowCopy={true}
placeholder={parserState.parsing ? '' : t('qrCode:resultPlaceholder')} placeholder={parserState.parsing ? '' : '解析结果将显示在此处'}
minRows={4} minRows={4}
maxRows={8} maxRows={8}
externalError={parserState.parseError || undefined} externalError={parserState.parseError || undefined}
@@ -1,6 +1,5 @@
import React from 'react'; import React from 'react';
import { Copy, Download } from 'lucide-react'; import { Copy, Download } from 'lucide-react';
import { useI18n } from '@/utils/chromeI18n';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -24,8 +23,6 @@ const QrCodePreview = ({
className, className,
...props ...props
}: QrCodePreviewProps) => { }: QrCodePreviewProps) => {
const { t } = useI18n('qrCode');
// 空状态下的虚线骨架屏 // 空状态下的虚线骨架屏
if (!qrCodeDataUrl) { if (!qrCodeDataUrl) {
return ( return (
@@ -37,7 +34,7 @@ const QrCodePreview = ({
{...props} {...props}
> >
<p className="text-sm text-muted-foreground text-center"> <p className="text-sm text-muted-foreground text-center">
{placeholderText || t('qrCode:qrCodeWillShow')} {placeholderText || '二维码将显示在这里'}
</p> </p>
</div> </div>
); );
@@ -68,12 +65,12 @@ const QrCodePreview = ({
<div className="flex w-full gap-2 mt-3"> <div className="flex w-full gap-2 mt-3">
<Button variant="outline" size="sm" onClick={onDownload} className="flex-1 h-8"> <Button variant="outline" size="sm" onClick={onDownload} className="flex-1 h-8">
<Download className="w-3.5 h-3.5 text-muted-foreground" /> <Download className="w-3.5 h-3.5 text-muted-foreground" />
<span className="truncate text-xs">{t('qrCode:downloadButton')}</span> <span className="truncate text-xs">{'下载二维码'}</span>
</Button> </Button>
<Button variant="default" size="sm" onClick={onCopy} className="flex-1 h-8"> <Button variant="default" size="sm" onClick={onCopy} className="flex-1 h-8">
<Copy className="w-3.5 h-3.5" /> <Copy className="w-3.5 h-3.5" />
<span className="truncate text-xs">{t('qrCode:copyQrButton')}</span> <span className="truncate text-xs">{'复制二维码'}</span>
</Button> </Button>
</div> </div>
</div> </div>
+23 -35
View File
@@ -3,7 +3,6 @@ import QRious from 'qrious';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { parseQrCodeFromFile } from '@/utils/qrCodeParser'; import { parseQrCodeFromFile } from '@/utils/qrCodeParser';
import { useContextMenuData } from '@/utils/useContextMenuData'; import { useContextMenuData } from '@/utils/useContextMenuData';
import { useI18n } from '@/utils/chromeI18n';
import type { QrCodeContextValue } from '../contexts/QrCodeContext'; import type { QrCodeContextValue } from '../contexts/QrCodeContext';
import type { QrCodeGeneratorState, QrCodeMode, QrCodeParserState } from '../types'; import type { QrCodeGeneratorState, QrCodeMode, QrCodeParserState } from '../types';
@@ -78,8 +77,6 @@ function generateQrCodeDataUrl(text: string): string {
} }
export function useQrCode(): QrCodeContextValue { export function useQrCode(): QrCodeContextValue {
const { t } = useI18n('qrCode');
const [mode, setMode] = useState<QrCodeMode>('generate'); const [mode, setMode] = useState<QrCodeMode>('generate');
const [generatorState, setGeneratorState] = useState<QrCodeGeneratorState>({ const [generatorState, setGeneratorState] = useState<QrCodeGeneratorState>({
@@ -112,8 +109,7 @@ export function useQrCode(): QrCodeContextValue {
}, []); }, []);
/** 自动检测URL并生成二维码 */ /** 自动检测URL并生成二维码 */
const autoGenerateIfUrl = useCallback( const autoGenerateIfUrl = useCallback((text: string) => {
(text: string) => {
if (debounceTimerRef.current) { if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current); clearTimeout(debounceTimerRef.current);
} }
@@ -139,13 +135,11 @@ export function useQrCode(): QrCodeContextValue {
setGeneratorState((prev) => ({ setGeneratorState((prev) => ({
...prev, ...prev,
generating: false, generating: false,
inputError: t('qrCode:generateError'), inputError: '生成二维码失败,请重试',
})); }));
} }
}, DEBOUNCE_DELAY); }, DEBOUNCE_DELAY);
}, }, []);
[t],
);
const setTextToEncode = useCallback( const setTextToEncode = useCallback(
(text: string) => { (text: string) => {
@@ -160,8 +154,8 @@ export function useQrCode(): QrCodeContextValue {
const text = generatorState.textToEncode.trim(); const text = generatorState.textToEncode.trim();
if (!text) { if (!text) {
setGeneratorState((prev) => ({ ...prev, inputError: t('qrCode:inputRequired') })); setGeneratorState((prev) => ({ ...prev, inputError: '请输入内容' }));
toast.error(t('qrCode:inputRequired')); toast.error('请输入内容');
return; return;
} }
@@ -176,9 +170,9 @@ export function useQrCode(): QrCodeContextValue {
setGeneratorState((prev) => ({ setGeneratorState((prev) => ({
...prev, ...prev,
generating: false, generating: false,
inputError: t('qrCode:generateError'), inputError: '生成二维码失败,请重试',
})); }));
toast.error(t('qrCode:generateError')); toast.error('生成二维码失败,请重试');
return; return;
} }
@@ -190,7 +184,7 @@ export function useQrCode(): QrCodeContextValue {
generating: false, generating: false,
})); }));
}, 0); }, 0);
}, [generatorState.textToEncode, t]); }, [generatorState.textToEncode]);
/** 返回编辑态,保留上次输入内容 */ /** 返回编辑态,保留上次输入内容 */
const backToEdit = useCallback(() => { const backToEdit = useCallback(() => {
@@ -207,8 +201,7 @@ export function useQrCode(): QrCodeContextValue {
}, []); }, []);
/** 从图片URL解析二维码 */ /** 从图片URL解析二维码 */
const parseQrCodeFromUrl = useCallback( const parseQrCodeFromUrl = useCallback(async (imageUrl: string) => {
async (imageUrl: string) => {
try { try {
setParserState((prev) => ({ setParserState((prev) => ({
...prev, ...prev,
@@ -230,23 +223,21 @@ export function useQrCode(): QrCodeContextValue {
if (result.success && result.data) { if (result.success && result.data) {
setParserState((prev) => ({ ...prev, decodedResult: result.data! })); setParserState((prev) => ({ ...prev, decodedResult: result.data! }));
toast.success(t('qrCode:parseSuccess')); toast.success('二维码解析成功');
} else { } else {
const errorMsg = result.error || t('qrCode:noQrDetected'); const errorMsg = result.error || '未检测到二维码,请确保图片清晰且包含二维码';
setParserState((prev) => ({ ...prev, parseError: errorMsg })); setParserState((prev) => ({ ...prev, parseError: errorMsg }));
toast.error(errorMsg); toast.error(errorMsg);
} }
} catch (error) { } catch (error) {
console.error('解析图片二维码失败:', error); console.error('解析图片二维码失败:', error);
const errorMsg = error instanceof Error ? error.message : t('qrCode:parseError'); const errorMsg = error instanceof Error ? error.message : '解析二维码失败,请重试';
setParserState((prev) => ({ ...prev, parseError: errorMsg })); setParserState((prev) => ({ ...prev, parseError: errorMsg }));
toast.error(errorMsg); toast.error(errorMsg);
} finally { } finally {
setParserState((prev) => ({ ...prev, parsing: false })); setParserState((prev) => ({ ...prev, parsing: false }));
} }
}, }, []);
[t],
);
/** 右键菜单传入URL时,自动生成二维码或解析图片 */ /** 右键菜单传入URL时,自动生成二维码或解析图片 */
const handleContextMenuData = useCallback( const handleContextMenuData = useCallback(
@@ -294,8 +285,7 @@ export function useQrCode(): QrCodeContextValue {
useContextMenuData({ featureKey: 'qrCode', onData: handleContextMenuData }); useContextMenuData({ featureKey: 'qrCode', onData: handleContextMenuData });
// 反向活态解析二维码算法 // 反向活态解析二维码算法
const parseQrCode = useCallback( const parseQrCode = useCallback(async (file: File) => {
async (file: File) => {
try { try {
setParserState((prev) => ({ ...prev, parsing: true, parseError: '', decodedResult: '' })); setParserState((prev) => ({ ...prev, parsing: true, parseError: '', decodedResult: '' }));
@@ -303,23 +293,21 @@ export function useQrCode(): QrCodeContextValue {
if (result.success && result.data) { if (result.success && result.data) {
setParserState((prev) => ({ ...prev, decodedResult: result.data! })); setParserState((prev) => ({ ...prev, decodedResult: result.data! }));
toast.success(t('qrCode:parseSuccess')); toast.success('二维码解析成功');
} else { } else {
const errorMsg = result.error || t('qrCode:noQrDetected'); const errorMsg = result.error || '未检测到二维码,请确保图片清晰且包含二维码';
setParserState((prev) => ({ ...prev, parseError: errorMsg })); setParserState((prev) => ({ ...prev, parseError: errorMsg }));
toast.error(errorMsg); toast.error(errorMsg);
} }
} catch (error) { } catch (error) {
console.error('解析二维码失败:', error); console.error('解析二维码失败:', error);
const errorMsg = error instanceof Error ? error.message : t('qrCode:parseError'); const errorMsg = error instanceof Error ? error.message : '解析二维码失败,请重试';
setParserState((prev) => ({ ...prev, parseError: errorMsg })); setParserState((prev) => ({ ...prev, parseError: errorMsg }));
toast.error(errorMsg); toast.error(errorMsg);
} finally { } finally {
setParserState((prev) => ({ ...prev, parsing: false })); setParserState((prev) => ({ ...prev, parsing: false }));
} }
}, }, []);
[t],
);
const downloadQrCode = useCallback(() => { const downloadQrCode = useCallback(() => {
if (!generatorState.qrCodeDataUrl) return; if (!generatorState.qrCodeDataUrl) return;
@@ -328,8 +316,8 @@ export function useQrCode(): QrCodeContextValue {
link.href = generatorState.qrCodeDataUrl; link.href = generatorState.qrCodeDataUrl;
link.download = 'qrcode.png'; link.download = 'qrcode.png';
link.click(); link.click();
toast.success(t('qrCode:qrCodeDownloadSuccess')); toast.success('二维码下载成功');
}, [generatorState.qrCodeDataUrl, t]); }, [generatorState.qrCodeDataUrl]);
const copyQrCode = useCallback(async () => { const copyQrCode = useCallback(async () => {
if (!generatorState.qrCodeDataUrl) return; if (!generatorState.qrCodeDataUrl) return;
@@ -344,12 +332,12 @@ export function useQrCode(): QrCodeContextValue {
}), }),
]); ]);
toast.success(t('qrCode:qrCodeCopySuccess')); toast.success('二维码已复制到剪贴板');
} catch (error) { } catch (error) {
console.error('复制二维码失败:', error); console.error('复制二维码失败:', error);
toast.error(t('qrCode:copyError')); toast.error('复制失败,请重试');
} }
}, [generatorState.qrCodeDataUrl, t]); }, [generatorState.qrCodeDataUrl]);
const handleFileChange = useCallback( const handleFileChange = useCallback(
(file: File) => { (file: File) => {
+2 -4
View File
@@ -1,6 +1,5 @@
import SwitchButtonGroup from '@/components/SwitchButtonGroup'; import SwitchButtonGroup from '@/components/SwitchButtonGroup';
import { useI18n } from '@/utils/chromeI18n';
import { QrCodeContext } from './contexts/QrCodeContext'; import { QrCodeContext } from './contexts/QrCodeContext';
import { useQrCode } from './hooks/useQrCode'; import { useQrCode } from './hooks/useQrCode';
import GeneratePanel from './components/GeneratePanel'; import GeneratePanel from './components/GeneratePanel';
@@ -8,13 +7,12 @@ import ParsePanel from './components/ParsePanel';
import type { QrCodeMode } from './types'; import type { QrCodeMode } from './types';
export default function Index() { export default function Index() {
const { t } = useI18n('qrCode');
const qrCode = useQrCode(); const qrCode = useQrCode();
// 模式选项驱动骨架 // 模式选项驱动骨架
const modeOptions = [ const modeOptions = [
{ value: 'generate' as QrCodeMode, label: t('qrCode:urlToQr') }, { value: 'generate' as QrCodeMode, label: '文本转二维码' },
{ value: 'parse' as QrCodeMode, label: t('qrCode:qrToUrl') }, { value: 'parse' as QrCodeMode, label: '二维码转文本' },
]; ];
return ( return (
+11 -13
View File
@@ -3,17 +3,15 @@ import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Shield, ShieldCheck, MousePointerClick, AlertTriangle } from 'lucide-react'; import { Shield, ShieldCheck, MousePointerClick, AlertTriangle } from 'lucide-react';
import { useRightClickRestorer } from './useRightClickRestorer'; import { useRightClickRestorer } from './useRightClickRestorer';
import { useI18n } from '@/utils/chromeI18n';
export default function Index() { export default function Index() {
const { t } = useI18n('rightClickRestorer');
const { domain, isLoading, isUnlocked, isUnsupported, unlock } = useRightClickRestorer(); const { domain, isLoading, isUnlocked, isUnsupported, unlock } = useRightClickRestorer();
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex flex-col items-center justify-center py-12 min-h-[280px] w-full"> <div className="flex flex-col items-center justify-center py-12 min-h-[280px] w-full">
<span className="text-xs text-muted-foreground mt-2 font-medium tracking-wide"> <span className="text-xs text-muted-foreground mt-2 font-medium tracking-wide">
{t('rightClickRestorer:loading')} {'正在加载...'}
</span> </span>
</div> </div>
); );
@@ -24,7 +22,7 @@ export default function Index() {
{/* Current Domain */} {/* Current Domain */}
<div className="w-full rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden"> <div className="w-full rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden">
<div className="p-4"> <div className="p-4">
<Label className="text-sm font-medium">{t('rightClickRestorer:currentDomain')}</Label> <Label className="text-sm font-medium">{'当前域名'}</Label>
<div className="mt-2 flex items-center justify-between gap-2"> <div className="mt-2 flex items-center justify-between gap-2">
<code className="text-sm bg-muted px-2 py-1 rounded truncate min-w-0 flex-1"> <code className="text-sm bg-muted px-2 py-1 rounded truncate min-w-0 flex-1">
{domain || '—'} {domain || '—'}
@@ -32,17 +30,17 @@ export default function Index() {
{isUnsupported ? ( {isUnsupported ? (
<Badge variant="destructive" className="gap-1 shrink-0"> <Badge variant="destructive" className="gap-1 shrink-0">
<AlertTriangle className="h-3 w-3" /> <AlertTriangle className="h-3 w-3" />
{t('rightClickRestorer:unsupported')} {'不支持'}
</Badge> </Badge>
) : isUnlocked ? ( ) : isUnlocked ? (
<Badge variant="default" className="gap-1 bg-green-600 hover:bg-green-700 shrink-0"> <Badge variant="default" className="gap-1 bg-green-600 hover:bg-green-700 shrink-0">
<ShieldCheck className="h-3 w-3" /> <ShieldCheck className="h-3 w-3" />
{t('rightClickRestorer:statusUnlocked')} {'已解锁'}
</Badge> </Badge>
) : ( ) : (
<Badge variant="secondary" className="gap-1 shrink-0"> <Badge variant="secondary" className="gap-1 shrink-0">
<Shield className="h-3 w-3" /> <Shield className="h-3 w-3" />
{t('rightClickRestorer:statusLocked')} {'未解锁'}
</Badge> </Badge>
)} )}
</div> </div>
@@ -55,16 +53,18 @@ export default function Index() {
{isUnsupported ? ( {isUnsupported ? (
<> <>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t('rightClickRestorer:unsupportedDesc')} {'当前页面为浏览器内部页面或扩展页面,无法解锁右键功能。请切换到普通网页后重试。'}
</p> </p>
<Button className="w-full gap-2" disabled variant="secondary"> <Button className="w-full gap-2" disabled variant="secondary">
<AlertTriangle className="h-4 w-4" /> <AlertTriangle className="h-4 w-4" />
{t('rightClickRestorer:unsupported')} {'不支持'}
</Button> </Button>
</> </>
) : ( ) : (
<> <>
<p className="text-xs text-muted-foreground">{t('rightClickRestorer:unlockDesc')}</p> <p className="text-xs text-muted-foreground">
{'点击下方按钮,为当前网站临时解锁右键菜单。刷新页面后需要重新解锁。'}
</p>
<Button <Button
className="w-full gap-2" className="w-full gap-2"
onClick={() => void unlock()} onClick={() => void unlock()}
@@ -72,9 +72,7 @@ export default function Index() {
variant={isUnlocked ? 'secondary' : 'default'} variant={isUnlocked ? 'secondary' : 'default'}
> >
<MousePointerClick className="h-4 w-4" /> <MousePointerClick className="h-4 w-4" />
{isUnlocked {isUnlocked ? '右键已解锁' : '解锁当前网站右键'}
? t('rightClickRestorer:alreadyUnlocked')
: t('rightClickRestorer:unlockBtn')}
</Button> </Button>
</> </>
)} )}
@@ -14,7 +14,6 @@
*/ */
import React from 'react'; import React from 'react';
import { useI18n } from '@/utils/chromeI18n';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
@@ -43,8 +42,6 @@ export default function AutoRefreshToggle({
className, className,
...props ...props
}: AutoRefreshToggleProps) { }: AutoRefreshToggleProps) {
const { t } = useI18n('storageCleaner');
return ( return (
<div <div
className={cn( className={cn(
@@ -57,7 +54,7 @@ export default function AutoRefreshToggle({
htmlFor="auto-refresh-switch" htmlFor="auto-refresh-switch"
className="text-xs font-bold text-muted-foreground/90 cursor-pointer select-none tracking-wide uppercase" className="text-xs font-bold text-muted-foreground/90 cursor-pointer select-none tracking-wide uppercase"
> >
{t('storageCleaner:autoRefresh')} {'清理后自动刷新页面'}
</Label> </Label>
<Switch id="auto-refresh-switch" checked={reloadAfterClean} onCheckedChange={onChange} /> <Switch id="auto-refresh-switch" checked={reloadAfterClean} onCheckedChange={onChange} />
@@ -2,7 +2,6 @@ import React from 'react';
import { CheckCircle, XCircle } from 'lucide-react'; import { CheckCircle, XCircle } from 'lucide-react';
import type { CleaningResult as CleaningResultType } from '@/types/storage'; import type { CleaningResult as CleaningResultType } from '@/types/storage';
import { formatCleaningResult } from '@/utils/storageCleaner'; import { formatCleaningResult } from '@/utils/storageCleaner';
import { useI18n } from '@/utils/chromeI18n';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
interface CleaningResultProps extends React.HTMLAttributes<HTMLDivElement> { interface CleaningResultProps extends React.HTMLAttributes<HTMLDivElement> {
@@ -10,8 +9,6 @@ interface CleaningResultProps extends React.HTMLAttributes<HTMLDivElement> {
} }
export default function CleaningResult({ result, className, ...props }: CleaningResultProps) { export default function CleaningResult({ result, className, ...props }: CleaningResultProps) {
const { t } = useI18n('storageCleaner');
if (!result) return null; if (!result) return null;
const isSuccess = result.overallSuccess; const isSuccess = result.overallSuccess;
@@ -33,9 +30,7 @@ export default function CleaningResult({ result, className, ...props }: Cleaning
)} )}
<span className="text-xs sm:text-sm font-semibold leading-relaxed break-all"> <span className="text-xs sm:text-sm font-semibold leading-relaxed break-all">
{isSuccess {isSuccess ? formatCleaningResult(result) : result.error || '部分清理失败'}
? formatCleaningResult(result, t)
: result.error || t('storageCleaner:partialFailure')}
</span> </span>
</div> </div>
</div> </div>
@@ -1,5 +1,4 @@
import { AlertCircle } from 'lucide-react'; import { AlertCircle } from 'lucide-react';
import { useI18n } from '@/utils/chromeI18n';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
interface ErrorDisplayProps extends React.HTMLAttributes<HTMLDivElement> { interface ErrorDisplayProps extends React.HTMLAttributes<HTMLDivElement> {
@@ -7,8 +6,6 @@ interface ErrorDisplayProps extends React.HTMLAttributes<HTMLDivElement> {
} }
export default function ErrorDisplay({ error, className, ...props }: ErrorDisplayProps) { export default function ErrorDisplay({ error, className, ...props }: ErrorDisplayProps) {
const { t } = useI18n('storageCleaner');
return ( return (
<div <div
className={cn( className={cn(
@@ -27,7 +24,7 @@ export default function ErrorDisplay({ error, className, ...props }: ErrorDispla
</p> </p>
<p className="text-xs font-medium leading-relaxed text-muted-foreground/90 px-2"> <p className="text-xs font-medium leading-relaxed text-muted-foreground/90 px-2">
{t('storageCleaner:errorStandardOnly')} {'存储清理功能仅适用于标准网页'}
</p> </p>
</div> </div>
</div> </div>
@@ -1,10 +1,18 @@
import React from 'react'; import React from 'react';
import { formatBytes } from '@/utils/format'; import { formatBytes } from '@/utils/format';
import { useI18n } from '@/utils/chromeI18n';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { StorageSizeInfo } from '../useStorageCleaner'; import type { StorageSizeInfo } from '../useStorageCleaner';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
const OPTION_LABELS: Record<string, string> = {
localStorage: 'Local Storage',
sessionStorage: 'Session Storage',
indexedDB: '站点存储',
cookies: 'Cookies',
cacheStorage: 'Cache Storage',
serviceWorkers: 'Service Workers',
};
interface OptionItemProps extends React.HTMLAttributes<HTMLDivElement> { interface OptionItemProps extends React.HTMLAttributes<HTMLDivElement> {
labelKey: string; labelKey: string;
checked: boolean; checked: boolean;
@@ -20,10 +28,9 @@ export default function OptionItem({
className, className,
...props ...props
}: OptionItemProps) { }: OptionItemProps) {
const { t } = useI18n('storageCleaner');
const sizeValue = sizeInfo?.value; const sizeValue = sizeInfo?.value;
const isCount = sizeInfo?.displayType === 'count'; const isCount = sizeInfo?.displayType === 'count';
const label = OPTION_LABELS[labelKey] || labelKey;
return ( return (
<div <div
@@ -44,7 +51,7 @@ export default function OptionItem({
checked ? 'text-foreground' : 'text-foreground/75', checked ? 'text-foreground' : 'text-foreground/75',
)} )}
> >
{t(labelKey)} {label}
</span> </span>
{sizeValue !== undefined && sizeValue > 0 ? ( {sizeValue !== undefined && sizeValue > 0 ? (
@@ -54,11 +61,11 @@ export default function OptionItem({
checked ? 'text-primary/70' : 'text-muted-foreground/70', checked ? 'text-primary/70' : 'text-muted-foreground/70',
)} )}
> >
{isCount ? `${sizeValue} ${t('storageCleaner:countUnit')}` : formatBytes(sizeValue)} {isCount ? `${sizeValue} ` : formatBytes(sizeValue)}
</span> </span>
) : ( ) : (
<span className="block text-[10px] font-medium text-muted-foreground/50 mt-0.5 italic"> <span className="block text-[10px] font-medium text-muted-foreground/50 mt-0.5 italic">
{t('storageCleaner:noData')} {'无数据'}
</span> </span>
)} )}
</div> </div>
@@ -10,7 +10,6 @@ import {
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { AlertTriangle } from 'lucide-react'; import { AlertTriangle } from 'lucide-react';
import type { StorageCleanerOptions } from '@/types/storage'; import type { StorageCleanerOptions } from '@/types/storage';
import { useI18n } from '@/utils/chromeI18n';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
export interface StorageCleanerConfirmProps { export interface StorageCleanerConfirmProps {
@@ -20,17 +19,24 @@ export interface StorageCleanerConfirmProps {
options: StorageCleanerOptions; options: StorageCleanerOptions;
} }
const OPTION_LABELS: Record<string, string> = {
localStorage: 'Local Storage',
sessionStorage: 'Session Storage',
indexedDB: '站点存储',
cookies: 'Cookies',
cacheStorage: 'Cache Storage',
serviceWorkers: 'Service Workers',
};
export function StorageCleanerConfirm({ export function StorageCleanerConfirm({
open, open,
onClose, onClose,
onConfirm, onConfirm,
options, options,
}: StorageCleanerConfirmProps) { }: StorageCleanerConfirmProps) {
const { t } = useI18n('storageCleaner');
const selectedOptions = Object.entries(options) const selectedOptions = Object.entries(options)
.filter(([_, value]) => value) .filter(([_, value]) => value)
.map(([key, _]) => t(`storageCleaner:options.${key as keyof StorageCleanerOptions}`)); .map(([key, _]) => OPTION_LABELS[key] || key);
return ( return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}> <Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
@@ -42,14 +48,14 @@ export function StorageCleanerConfirm({
{/* 头部标题区域 */} {/* 头部标题区域 */}
<DialogHeader className="pt-1"> <DialogHeader className="pt-1">
<DialogTitle className="text-center text-lg font-bold tracking-tight text-foreground"> <DialogTitle className="text-center text-lg font-bold tracking-tight text-foreground">
{t('storageCleaner:confirmTitle')} {'确认清理数据?'}
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
{/* 内容主体:限制最大宽度,防止内部元素在大分辨率下被横向拉得太松散 */} {/* 内容主体:限制最大宽度,防止内部元素在大分辨率下被横向拉得太松散 */}
<div className="text-center py-4 flex flex-col items-center w-full max-w-[280px] mx-auto"> <div className="text-center py-4 flex flex-col items-center w-full max-w-[280px] mx-auto">
<DialogDescription className="mb-4 text-xs font-medium text-muted-foreground/90 leading-relaxed"> <DialogDescription className="mb-4 text-xs font-medium text-muted-foreground/90 leading-relaxed">
{t('storageCleaner:confirmDesc')} {'您将永久删除当前页面的以下选定存储项。'}
</DialogDescription> </DialogDescription>
{/* 待清理项目徽章群 */} {/* 待清理项目徽章群 */}
@@ -69,7 +75,7 @@ export function StorageCleanerConfirm({
<div className="inline-flex items-center justify-center gap-1.5 px-3.5 py-2 rounded-lg bg-destructive/5 border border-dashed border-destructive/20 w-full max-w-[240px]"> <div className="inline-flex items-center justify-center gap-1.5 px-3.5 py-2 rounded-lg bg-destructive/5 border border-dashed border-destructive/20 w-full max-w-[240px]">
<AlertTriangle className="h-3.5 w-3.5 text-destructive shrink-0" /> <AlertTriangle className="h-3.5 w-3.5 text-destructive shrink-0" />
<span className="text-[11px] font-bold text-destructive leading-none tracking-tight"> <span className="text-[11px] font-bold text-destructive leading-none tracking-tight">
{t('storageCleaner:irreversible')} {'此操作不可撤销'}
</span> </span>
</div> </div>
</div> </div>
@@ -81,7 +87,7 @@ export function StorageCleanerConfirm({
onClick={onConfirm} onClick={onConfirm}
className="w-full text-xs font-bold shadow-sm h-9" className="w-full text-xs font-bold shadow-sm h-9"
> >
{t('storageCleaner:confirmAction')} {'确认清理'}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
@@ -89,7 +95,7 @@ export function StorageCleanerConfirm({
onClick={onClose} onClick={onClose}
className="w-full text-xs font-semibold shadow-sm h-9 text-muted-foreground hover:text-foreground" className="w-full text-xs font-semibold shadow-sm h-9 text-muted-foreground hover:text-foreground"
> >
{t('common_buttons_cancel')} {'取消'}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -2,7 +2,6 @@ import React from 'react';
import type { StorageCleanerOptions } from '@/types/storage'; import type { StorageCleanerOptions } from '@/types/storage';
import type { StorageSizeInfo } from '../useStorageCleaner'; import type { StorageSizeInfo } from '../useStorageCleaner';
import OptionItem from './OptionItem'; import OptionItem from './OptionItem';
import { useI18n } from '@/utils/chromeI18n';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
@@ -26,8 +25,6 @@ export default function StorageOptionsGrid({
className, className,
...props ...props
}: StorageOptionsGridProps) { }: StorageOptionsGridProps) {
const { t } = useI18n('storageCleaner');
const optionKeys: (keyof StorageCleanerOptions)[] = [ const optionKeys: (keyof StorageCleanerOptions)[] = [
'localStorage', 'localStorage',
'sessionStorage', 'sessionStorage',
@@ -48,7 +45,7 @@ export default function StorageOptionsGrid({
{optionKeys.map((key) => ( {optionKeys.map((key) => (
<OptionItem <OptionItem
key={key} key={key}
labelKey={`storageCleaner:options.${key}`} labelKey={key}
checked={options[key]} checked={options[key]}
sizeInfo={sizes[key]} sizeInfo={sizes[key]}
onChange={() => onOptionChange(key)} onChange={() => onOptionChange(key)}
@@ -62,7 +59,7 @@ export default function StorageOptionsGrid({
className="border-t border-border flex justify-between items-center pl-3.5 pr-7 py-2.5 bg-muted/20 hover:bg-muted/40 cursor-pointer select-none transition-colors" className="border-t border-border flex justify-between items-center pl-3.5 pr-7 py-2.5 bg-muted/20 hover:bg-muted/40 cursor-pointer select-none transition-colors"
> >
<Label className="text-xs font-bold text-muted-foreground/90 cursor-pointer tracking-wide uppercase"> <Label className="text-xs font-bold text-muted-foreground/90 cursor-pointer tracking-wide uppercase">
{t('storageCleaner:selectAll')} {'全选所有项'}
</Label> </Label>
<Checkbox <Checkbox
+3 -6
View File
@@ -6,11 +6,8 @@ import StorageOptionsGrid from './components/StorageOptionsGrid';
import AutoRefreshToggle from './components/AutoRefreshToggle'; import AutoRefreshToggle from './components/AutoRefreshToggle';
import ErrorDisplay from './components/ErrorDisplay'; import ErrorDisplay from './components/ErrorDisplay';
import CleaningResult from './components/CleaningResult'; import CleaningResult from './components/CleaningResult';
import { useI18n } from '@/utils/chromeI18n';
export default function Index() { export default function Index() {
const { t } = useI18n('storageCleaner');
const { const {
error, error,
isInitializing, isInitializing,
@@ -36,7 +33,7 @@ export default function Index() {
<div className="flex flex-col items-center justify-center py-12 min-h-[280px] w-full"> <div className="flex flex-col items-center justify-center py-12 min-h-[280px] w-full">
<Loader2 className="h-6 w-6 text-muted-foreground/80" /> <Loader2 className="h-6 w-6 text-muted-foreground/80" />
<span className="text-xs text-muted-foreground mt-2 font-medium tracking-wide"> <span className="text-xs text-muted-foreground mt-2 font-medium tracking-wide">
{t('storageCleaner:initializing')} {'正在读取站点数据...'}
</span> </span>
</div> </div>
); );
@@ -75,10 +72,10 @@ export default function Index() {
{loading ? ( {loading ? (
<> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('storageCleaner:cleaning')} {'正在清理...'}
</> </>
) : ( ) : (
t('storageCleaner:cleanNow') '立即清理'
)} )}
</Button> </Button>
</div> </div>
+8 -13
View File
@@ -16,7 +16,6 @@ import {
getSessionStorageSize, getSessionStorageSize,
isRestrictedUrl, isRestrictedUrl,
} from '@/utils/storageCleaner'; } from '@/utils/storageCleaner';
import { useI18n } from '@/utils/chromeI18n';
import { toast } from 'sonner'; import { toast } from 'sonner';
const DEFAULT_OPTIONS: StorageCleanerOptions = { const DEFAULT_OPTIONS: StorageCleanerOptions = {
@@ -59,7 +58,6 @@ export interface UseStorageCleanerReturn {
} }
export function useStorageCleaner(): UseStorageCleanerReturn { export function useStorageCleaner(): UseStorageCleanerReturn {
const { t } = useI18n(['storageCleaner', 'common']);
const [error, setError] = useState<string>(''); const [error, setError] = useState<string>('');
const [isInitializing, setIsInitializing] = useState<boolean>(true); const [isInitializing, setIsInitializing] = useState<boolean>(true);
const [options, setOptions] = useState<StorageCleanerOptions>(DEFAULT_OPTIONS); const [options, setOptions] = useState<StorageCleanerOptions>(DEFAULT_OPTIONS);
@@ -93,11 +91,11 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
if (currentRequestId !== requestIdRef.current) return; if (currentRequestId !== requestIdRef.current) return;
if (!tab || !tab.url) { if (!tab || !tab.url) {
setError(t('storageCleaner:errorNoTab')); setError('无法获取当前标签页');
return; return;
} }
if (isRestrictedUrl(tab.url)) { if (isRestrictedUrl(tab.url)) {
setError(t('storageCleaner:errorRestricted')); setError('存储清理功能不支持此页面');
return; return;
} }
@@ -135,7 +133,7 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
setIsInitializing(false); setIsInitializing(false);
} }
} }
}, [t]); }, []);
const loadInfoRef = useRef(loadInfo); const loadInfoRef = useRef(loadInfo);
useEffect(() => { useEffect(() => {
@@ -177,10 +175,7 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
if (storageTimerRef.current) clearTimeout(storageTimerRef.current); if (storageTimerRef.current) clearTimeout(storageTimerRef.current);
storageTimerRef.current = setTimeout(async () => { storageTimerRef.current = setTimeout(async () => {
await storageUtil await storageUtil
.set('storageCleaner/preferences', { .set('storageCleaner/preferences', { reloadAfterClean, selectedTypes: options })
reloadAfterClean,
selectedTypes: options,
})
.catch(console.error); .catch(console.error);
}, 500); }, 500);
}, [options, reloadAfterClean, isInitializing]); }, [options, reloadAfterClean, isInitializing]);
@@ -209,7 +204,7 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
const tab = await getCurrentTab(); const tab = await getCurrentTab();
if (!tab || !tab.id || !tab.url) { if (!tab || !tab.id || !tab.url) {
toast.warning(t('storageCleaner:errorNoTab')); toast.warning('无法获取当前标签页');
return; return;
} }
@@ -219,18 +214,18 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
setResult(cleaningResult); setResult(cleaningResult);
if (reloadAfterClean && cleaningResult.overallSuccess) { if (reloadAfterClean && cleaningResult.overallSuccess) {
toast.success(t('storageCleaner:cleanSuccessReload')); toast.success('清理成功,即将刷新页面');
await chrome.tabs.reload(tab.id); await chrome.tabs.reload(tab.id);
} else { } else {
await loadInfo(); await loadInfo();
} }
} catch (err) { } catch (err) {
toast.error(`${t('storageCleaner:cleanError')}: ${String(err)}`); toast.error(`清理失败: ${String(err)}`);
} finally { } finally {
setLoading(false); setLoading(false);
setShowConfirm(false); setShowConfirm(false);
} }
}, [options, reloadAfterClean, loadInfo, t]); }, [options, reloadAfterClean, loadInfo]);
const totalBytes = useMemo(() => { const totalBytes = useMemo(() => {
return Object.values(sizes).reduce((acc, s) => { return Object.values(sizes).reduce((acc, s) => {
@@ -5,7 +5,6 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { FileJson, FileText } from 'lucide-react'; import { FileJson, FileText } from 'lucide-react';
import { useI18n } from '@/utils/chromeI18n';
import { getGeneratorById } from '@/lib/generators'; import { getGeneratorById } from '@/lib/generators';
import type { FieldConfig } from '@/types/testDataGenerator'; import type { FieldConfig } from '@/types/testDataGenerator';
@@ -56,8 +55,6 @@ function getValueColor(value: string): string {
} }
export default function DataPreview({ fields }: DataPreviewProps) { export default function DataPreview({ fields }: DataPreviewProps) {
const { t } = useI18n('testDataGenerator');
// 生成一条示例数据 // 生成一条示例数据
const sampleData = useMemo(() => { const sampleData = useMemo(() => {
if (fields.length === 0) return null; if (fields.length === 0) return null;
@@ -83,7 +80,7 @@ export default function DataPreview({ fields }: DataPreviewProps) {
return ( return (
<div className="flex flex-col items-center justify-center h-full text-center"> <div className="flex flex-col items-center justify-center h-full text-center">
<FileJson className="h-8 w-8 text-muted-foreground/30 mb-2" /> <FileJson className="h-8 w-8 text-muted-foreground/30 mb-2" />
<p className="text-xs text-muted-foreground/50">{t('testDataGenerator_noDataHint')}</p> <p className="text-xs text-muted-foreground/50">{'配置字段后点击「生成数据」按钮'}</p>
</div> </div>
); );
} }
@@ -96,12 +93,10 @@ export default function DataPreview({ fields }: DataPreviewProps) {
<div className="h-5 w-5 rounded bg-primary/10 flex items-center justify-center"> <div className="h-5 w-5 rounded bg-primary/10 flex items-center justify-center">
<FileText className="h-3 w-3 text-primary" /> <FileText className="h-3 w-3 text-primary" />
</div> </div>
<span className="text-xs font-medium text-muted-foreground"> <span className="text-xs font-medium text-muted-foreground">{'示例数据'}</span>
{t('testDataGenerator_sampleData')}
</span>
</div> </div>
<span className="text-[10px] text-muted-foreground/50 bg-muted px-1.5 py-0.5 rounded"> <span className="text-[10px] text-muted-foreground/50 bg-muted px-1.5 py-0.5 rounded">
{Object.keys(sampleData).length} {t('testDataGenerator_fields')} {Object.keys(sampleData).length} {'字段'}
</span> </span>
</div> </div>
@@ -6,7 +6,6 @@
import { Copy, Download } from 'lucide-react'; import { Copy, Download } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useI18n } from '@/utils/chromeI18n';
import { DataExporter } from '@/utils/dataExporter'; import { DataExporter } from '@/utils/dataExporter';
import type { GenerateResult } from '@/types/testDataGenerator'; import type { GenerateResult } from '@/types/testDataGenerator';
@@ -15,8 +14,6 @@ interface ExportPanelProps {
} }
export default function ExportPanel({ result }: ExportPanelProps) { export default function ExportPanel({ result }: ExportPanelProps) {
const { t } = useI18n('testDataGenerator');
if (!result?.data || result.data.length === 0) { if (!result?.data || result.data.length === 0) {
return null; return null;
} }
@@ -25,9 +22,9 @@ export default function ExportPanel({ result }: ExportPanelProps) {
const content = DataExporter.toJSON(result.data!); const content = DataExporter.toJSON(result.data!);
const success = await DataExporter.copyToClipboard(content); const success = await DataExporter.copyToClipboard(content);
if (success) { if (success) {
toast.success(t('testDataGenerator_copySuccess')); toast.success('已复制到剪贴板');
} else { } else {
toast.error(t('testDataGenerator_copyFailed')); toast.error('复制失败');
} }
}; };
@@ -35,9 +32,9 @@ export default function ExportPanel({ result }: ExportPanelProps) {
const content = DataExporter.toCSV(result.data!); const content = DataExporter.toCSV(result.data!);
const success = await DataExporter.copyToClipboard(content); const success = await DataExporter.copyToClipboard(content);
if (success) { if (success) {
toast.success(t('testDataGenerator_copySuccess')); toast.success('已复制到剪贴板');
} else { } else {
toast.error(t('testDataGenerator_copyFailed')); toast.error('复制失败');
} }
}; };
@@ -53,27 +50,27 @@ export default function ExportPanel({ result }: ExportPanelProps) {
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<h4 className="text-sm font-medium text-foreground">{t('testDataGenerator_export')}</h4> <h4 className="text-sm font-medium text-foreground">{'导出数据'}</h4>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<Button variant="outline" size="sm" onClick={handleCopyJSON} className="h-9 gap-1.5"> <Button variant="outline" size="sm" onClick={handleCopyJSON} className="h-9 gap-1.5">
<Copy className="h-4 w-4" /> <Copy className="h-4 w-4" />
{t('testDataGenerator_copyJSON')} {'复制 JSON'}
</Button> </Button>
<Button variant="outline" size="sm" onClick={handleCopyCSV} className="h-9 gap-1.5"> <Button variant="outline" size="sm" onClick={handleCopyCSV} className="h-9 gap-1.5">
<Copy className="h-4 w-4" /> <Copy className="h-4 w-4" />
{t('testDataGenerator_copyCSV')} {'复制 CSV'}
</Button> </Button>
<Button variant="outline" size="sm" onClick={handleDownloadJSON} className="h-9 gap-1.5"> <Button variant="outline" size="sm" onClick={handleDownloadJSON} className="h-9 gap-1.5">
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
{t('testDataGenerator_downloadJSON')} {'下载 JSON'}
</Button> </Button>
<Button variant="outline" size="sm" onClick={handleDownloadCSV} className="h-9 gap-1.5"> <Button variant="outline" size="sm" onClick={handleDownloadCSV} className="h-9 gap-1.5">
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
{t('testDataGenerator_downloadCSV')} {'下载 CSV'}
</Button> </Button>
</div> </div>
</div> </div>
@@ -4,7 +4,6 @@
*/ */
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { useI18n } from '@/utils/chromeI18n';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
@@ -22,7 +21,6 @@ interface FieldEditorProps {
} }
export default function FieldEditor({ field, onChange, allFieldNames = [] }: FieldEditorProps) { export default function FieldEditor({ field, onChange, allFieldNames = [] }: FieldEditorProps) {
const { t } = useI18n('testDataGenerator');
const generator = getGeneratorById(field.generatorId); const generator = getGeneratorById(field.generatorId);
const [nameError, setNameError] = useState<string | null>(null); const [nameError, setNameError] = useState<string | null>(null);
@@ -30,20 +28,20 @@ export default function FieldEditor({ field, onChange, allFieldNames = [] }: Fie
(name: string): string | null => { (name: string): string | null => {
const trimmed = name.trim(); const trimmed = name.trim();
if (!trimmed) { if (!trimmed) {
return t('testDataGenerator_fieldNameEmpty'); return '字段名称不能为空';
} }
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(trimmed)) { if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(trimmed)) {
return t('testDataGenerator_fieldNameInvalid'); return '字段名称只能包含字母、数字和下划线';
} }
const isDuplicate = allFieldNames.some( const isDuplicate = allFieldNames.some(
(n, i) => n === trimmed && i !== allFieldNames.indexOf(field.name), (n, i) => n === trimmed && i !== allFieldNames.indexOf(field.name),
); );
if (isDuplicate) { if (isDuplicate) {
return t('testDataGenerator_fieldNameDuplicate'); return '字段名称已存在';
} }
return null; return null;
}, },
[allFieldNames, field.name, t], [allFieldNames, field.name],
); );
const handleNameChange = (name: string) => { const handleNameChange = (name: string) => {
@@ -111,15 +109,13 @@ export default function FieldEditor({ field, onChange, allFieldNames = [] }: Fie
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label className="text-sm font-medium text-foreground"> <Label className="text-sm font-medium text-foreground">{'字段名称'}</Label>
{t('testDataGenerator_fieldName')}
</Label>
<span className="text-xs text-muted-foreground">{field.name.length}/20</span> <span className="text-xs text-muted-foreground">{field.name.length}/20</span>
</div> </div>
<Input <Input
value={field.name} value={field.name}
onChange={(e) => handleNameChange(e.target.value)} onChange={(e) => handleNameChange(e.target.value)}
placeholder={t('testDataGenerator_fieldNamePlaceholder')} placeholder={'请输入字段名称'}
maxLength={20} maxLength={20}
className={`h-9 ${nameError ? 'border-destructive' : ''}`} className={`h-9 ${nameError ? 'border-destructive' : ''}`}
/> />
@@ -128,9 +124,7 @@ export default function FieldEditor({ field, onChange, allFieldNames = [] }: Fie
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label className="text-sm font-medium text-foreground"> <Label className="text-sm font-medium text-foreground">{'字段描述'}</Label>
{t('testDataGenerator_fieldDescription')}
</Label>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{(field.description || '').length}/50 {(field.description || '').length}/50
</span> </span>
@@ -138,7 +132,7 @@ export default function FieldEditor({ field, onChange, allFieldNames = [] }: Fie
<Input <Input
value={field.description || ''} value={field.description || ''}
onChange={(e) => handleDescriptionChange(e.target.value)} onChange={(e) => handleDescriptionChange(e.target.value)}
placeholder={t('testDataGenerator_fieldDescriptionPlaceholder')} placeholder={'可选,添加字段说明'}
maxLength={50} maxLength={50}
className="h-9" className="h-9"
/> />
@@ -148,18 +142,14 @@ export default function FieldEditor({ field, onChange, allFieldNames = [] }: Fie
{/* 必填/选填配置 */} {/* 必填/选填配置 */}
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label className="text-sm font-medium text-foreground"> <Label className="text-sm font-medium text-foreground">{'必填'}</Label>
{t('testDataGenerator_required')}
</Label>
<Switch checked={field.required} onCheckedChange={handleRequiredChange} /> <Switch checked={field.required} onCheckedChange={handleRequiredChange} />
</div> </div>
{!field.required && ( {!field.required && (
<div className="space-y-2 pl-1"> <div className="space-y-2 pl-1">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">{'空值率'}</span>
{t('testDataGenerator_nullRate')}
</span>
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
{field.nullRate}% {field.nullRate}%
</Badge> </Badge>
@@ -199,26 +189,20 @@ export default function FieldEditor({ field, onChange, allFieldNames = [] }: Fie
{/* 唯一性约束 */} {/* 唯一性约束 */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label className="text-sm font-medium text-foreground"> <Label className="text-sm font-medium text-foreground">{'唯一性约束'}</Label>
{t('testDataGenerator_uniqueConstraint')}
</Label>
<Switch checked={field.unique} onCheckedChange={handleUniqueChange} /> <Switch checked={field.unique} onCheckedChange={handleUniqueChange} />
</div> </div>
{/* 生成器选择 */} {/* 生成器选择 */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm font-medium text-foreground"> <Label className="text-sm font-medium text-foreground">{'数据生成器'}</Label>
{t('testDataGenerator_generator')}
</Label>
<GeneratorSelector selectedId={field.generatorId} onChange={handleGeneratorChange} /> <GeneratorSelector selectedId={field.generatorId} onChange={handleGeneratorChange} />
</div> </div>
{/* 生成器参数配置 */} {/* 生成器参数配置 */}
{generator && ( {generator && (
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm font-medium text-foreground"> <Label className="text-sm font-medium text-foreground">{'生成器参数'}</Label>
{t('testDataGenerator_generatorParams')}
</Label>
<GeneratorConfig <GeneratorConfig
generator={generator} generator={generator}
params={field.params} params={field.params}
@@ -3,7 +3,6 @@
* 展示单个字段的基本信息,适配固定高度卡片 * 展示单个字段的基本信息,适配固定高度卡片
*/ */
import { useI18n } from '@/utils/chromeI18n';
import { getGeneratorById } from '@/lib/generators'; import { getGeneratorById } from '@/lib/generators';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import type { FieldConfig } from '@/types/testDataGenerator'; import type { FieldConfig } from '@/types/testDataGenerator';
@@ -14,7 +13,6 @@ interface FieldItemProps {
} }
export default function FieldItem({ field, onClick }: FieldItemProps) { export default function FieldItem({ field, onClick }: FieldItemProps) {
const { t } = useI18n('testDataGenerator');
const generator = getGeneratorById(field.generatorId); const generator = getGeneratorById(field.generatorId);
return ( return (
@@ -43,7 +41,7 @@ export default function FieldItem({ field, onClick }: FieldItemProps) {
)} )}
{field.unique && ( {field.unique && (
<Badge variant="outline" className="text-[10px] shrink-0 px-1 py-0 text-blue-500"> <Badge variant="outline" className="text-[10px] shrink-0 px-1 py-0 text-blue-500">
{t('testDataGenerator_unique')} {'唯一'}
</Badge> </Badge>
)} )}
</div> </div>
@@ -10,7 +10,6 @@ import { toast } from 'sonner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Dialog, DialogContent } from '@/components/ui/dialog'; import { Dialog, DialogContent } from '@/components/ui/dialog';
import { useI18n } from '@/utils/chromeI18n';
import * as ruleStorage from '@/utils/ruleStorage'; import * as ruleStorage from '@/utils/ruleStorage';
import { import {
DndContext, DndContext,
@@ -123,7 +122,6 @@ export default function FieldList({
editingRule, editingRule,
onRuleSaved, onRuleSaved,
}: FieldListProps) { }: FieldListProps) {
const { t } = useI18n('testDataGenerator');
const [scrollTop, setScrollTop] = useState(0); const [scrollTop, setScrollTop] = useState(0);
const [activeId, setActiveId] = useState<string | null>(null); const [activeId, setActiveId] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
@@ -226,10 +224,10 @@ export default function FieldList({
}); });
if (updated) { if (updated) {
toast.success(t('testDataGenerator_ruleUpdated')); toast.success('规则已更新');
onRuleSaved?.(); onRuleSaved?.();
} }
}, [editingRule, fields, t, onRuleSaved]); }, [editingRule, fields, onRuleSaved]);
// 新建规则或另存为 // 新建规则或另存为
const handleSave = useCallback( const handleSave = useCallback(
@@ -258,18 +256,18 @@ export default function FieldList({
setShowConfirmOverwrite(false); setShowConfirmOverwrite(false);
setRuleName(''); setRuleName('');
setRuleDescription(''); setRuleDescription('');
toast.success(t('testDataGenerator_ruleSaved')); toast.success('规则已保存');
onRuleSaved?.(); onRuleSaved?.();
} }
}, },
[ruleName, ruleDescription, fields, t, onRuleSaved], [ruleName, ruleDescription, fields, onRuleSaved],
); );
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-foreground"> <h3 className="text-sm font-medium text-foreground">
{t('testDataGenerator_fields')} ({fields.length}/{MAX_FIELDS}) {'字段'} ({fields.length}/{MAX_FIELDS})
</h3> </h3>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{editingRule ? ( {editingRule ? (
@@ -280,10 +278,10 @@ export default function FieldList({
onClick={handleUpdateRule} onClick={handleUpdateRule}
disabled={fields.length === 0} disabled={fields.length === 0}
className="h-8 gap-1.5 px-2.5" className="h-8 gap-1.5 px-2.5"
title={`${t('testDataGenerator_editing')}: ${editingRule.name}`} title={`${'编辑中'}: ${editingRule.name}`}
> >
<Save className="h-3.5 w-3.5" /> <Save className="h-3.5 w-3.5" />
{t('testDataGenerator_updateRule')} {'更新规则'}
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
@@ -292,7 +290,7 @@ export default function FieldList({
disabled={fields.length === 0} disabled={fields.length === 0}
className="h-8 px-2" className="h-8 px-2"
> >
{t('testDataGenerator_saveAs')} {'另存为'}
</Button> </Button>
</> </>
) : ( ) : (
@@ -304,7 +302,7 @@ export default function FieldList({
className="h-8 gap-1.5 px-2.5" className="h-8 gap-1.5 px-2.5"
> >
<Save className="h-3.5 w-3.5" /> <Save className="h-3.5 w-3.5" />
{t('testDataGenerator_saveRule')} {'保存规则'}
</Button> </Button>
)} )}
<Button <Button
@@ -315,7 +313,7 @@ export default function FieldList({
className="h-8 gap-1.5 px-2.5" className="h-8 gap-1.5 px-2.5"
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
{t('testDataGenerator_addField')} {'添加字段'}
</Button> </Button>
</div> </div>
</div> </div>
@@ -324,10 +322,8 @@ export default function FieldList({
{fields.length === 0 ? ( {fields.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center"> <div className="flex flex-col items-center justify-center py-8 text-center">
<GripVertical className="h-10 w-10 text-muted-foreground/40 mb-3" /> <GripVertical className="h-10 w-10 text-muted-foreground/40 mb-3" />
<p className="text-sm text-muted-foreground">{t('testDataGenerator_noFields')}</p> <p className="text-sm text-muted-foreground">{'暂无字段'}</p>
<p className="text-xs text-muted-foreground/70 mt-1"> <p className="text-xs text-muted-foreground/70 mt-1">{'点击上方按钮添加第一个字段'}</p>
{t('testDataGenerator_addFieldHint')}
</p>
</div> </div>
) : ( ) : (
<DndContext <DndContext
@@ -419,22 +415,22 @@ export default function FieldList({
<Input <Input
value={ruleName} value={ruleName}
onChange={(e) => setRuleName(e.target.value)} onChange={(e) => setRuleName(e.target.value)}
placeholder={t('testDataGenerator_ruleNamePlaceholder')} placeholder={'规则名称'}
className="h-9" className="h-9"
/> />
<Input <Input
value={ruleDescription} value={ruleDescription}
onChange={(e) => setRuleDescription(e.target.value)} onChange={(e) => setRuleDescription(e.target.value)}
placeholder={t('testDataGenerator_ruleDescPlaceholder')} placeholder={'规则描述(可选)'}
className="h-9" className="h-9"
/> />
</div> </div>
<div className="flex justify-end gap-2 px-6 py-2 border-t shrink-0"> <div className="flex justify-end gap-2 px-6 py-2 border-t shrink-0">
<Button variant="ghost" size="sm" onClick={() => setShowSaveDialog(false)}> <Button variant="ghost" size="sm" onClick={() => setShowSaveDialog(false)}>
{t('testDataGenerator_cancel')} {'取消'}
</Button> </Button>
<Button size="sm" onClick={() => handleSave()} disabled={!ruleName.trim()}> <Button size="sm" onClick={() => handleSave()} disabled={!ruleName.trim()}>
{t('testDataGenerator_confirm')} {'确认'}
</Button> </Button>
</div> </div>
</DialogContent> </DialogContent>
@@ -447,16 +443,14 @@ export default function FieldList({
className="w-[calc(100vw-4rem)] max-w-[420px] p-0 pt-6 flex flex-col" className="w-[calc(100vw-4rem)] max-w-[420px] p-0 pt-6 flex flex-col"
> >
<div className="flex-1 overflow-y-auto px-6 pb-4"> <div className="flex-1 overflow-y-auto px-6 pb-4">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">{'已存在同名规则,是否覆盖保存?'}</p>
{t('testDataGenerator_ruleNameDuplicate')}
</p>
</div> </div>
<div className="flex justify-end gap-2 px-6 py-2 border-t shrink-0"> <div className="flex justify-end gap-2 px-6 py-2 border-t shrink-0">
<Button variant="ghost" size="sm" onClick={() => setShowConfirmOverwrite(false)}> <Button variant="ghost" size="sm" onClick={() => setShowConfirmOverwrite(false)}>
{t('testDataGenerator_cancel')} {'取消'}
</Button> </Button>
<Button size="sm" onClick={() => handleSave(true)}> <Button size="sm" onClick={() => handleSave(true)}>
{t('testDataGenerator_overwrite')} {'覆盖'}
</Button> </Button>
</div> </div>
</DialogContent> </DialogContent>
@@ -5,7 +5,6 @@
import { Play, Square } from 'lucide-react'; import { Play, Square } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useI18n } from '@/utils/chromeI18n';
import type { GenerateProgress } from '@/types/testDataGenerator'; import type { GenerateProgress } from '@/types/testDataGenerator';
interface GenerateButtonProps { interface GenerateButtonProps {
@@ -23,27 +22,20 @@ export default function GenerateButton({
progress, progress,
disabled, disabled,
}: GenerateButtonProps) { }: GenerateButtonProps) {
const { t } = useI18n('testDataGenerator');
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{isGenerating ? ( {isGenerating ? (
<> <>
<Button variant="destructive" onClick={onCancel} className="w-full h-11 gap-2"> <Button variant="destructive" onClick={onCancel} className="w-full h-11 gap-2">
<Square className="h-5 w-5" /> <Square className="h-5 w-5" />
{t('testDataGenerator_cancel')} {'取消'}
</Button> </Button>
{/* 进度条 */} {/* 进度条 */}
{progress && ( {progress && (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between text-xs text-muted-foreground"> <div className="flex items-center justify-between text-xs text-muted-foreground">
<span> <span>{'已生成 {{current}} / {{total}} 条'}</span>
{t('testDataGenerator_progress', {
current: progress.generated.toLocaleString(),
total: progress.total.toLocaleString(),
})}
</span>
<span>{progress.progress}%</span> <span>{progress.progress}%</span>
</div> </div>
<div className="h-2 bg-muted rounded-full overflow-hidden"> <div className="h-2 bg-muted rounded-full overflow-hidden">
@@ -54,9 +46,7 @@ export default function GenerateButton({
</div> </div>
{progress.estimatedTimeLeft !== undefined && ( {progress.estimatedTimeLeft !== undefined && (
<p className="text-xs text-muted-foreground text-center"> <p className="text-xs text-muted-foreground text-center">
{t('testDataGenerator_estimatedTime', { {'预计剩余 {{time}} 秒'}
time: Math.ceil(progress.estimatedTimeLeft / 1000),
})}
</p> </p>
)} )}
</div> </div>
@@ -65,7 +55,7 @@ export default function GenerateButton({
) : ( ) : (
<Button onClick={onClick} disabled={disabled} className="w-full h-11 gap-2"> <Button onClick={onClick} disabled={disabled} className="w-full h-11 gap-2">
<Play className="h-5 w-5" /> <Play className="h-5 w-5" />
{t('testDataGenerator_generate')} {'生成数据'}
</Button> </Button>
)} )}
</div> </div>
@@ -3,7 +3,6 @@
* 配置生成数量、数据格式等选项 * 配置生成数量、数据格式等选项
*/ */
import { useI18n } from '@/utils/chromeI18n';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
@@ -27,15 +26,11 @@ export default function GenerateOptions({
format, format,
onFormatChange, onFormatChange,
}: GenerateOptionsProps) { }: GenerateOptionsProps) {
const { t } = useI18n('testDataGenerator');
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* 生成数量 */} {/* 生成数量 */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm font-medium text-foreground"> <Label className="text-sm font-medium text-foreground">{'生成数量'}</Label>
{t('testDataGenerator_count')}
</Label>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{COUNT_PRESETS.map((preset) => ( {COUNT_PRESETS.map((preset) => (
<button <button
@@ -68,9 +63,7 @@ export default function GenerateOptions({
{/* 数据格式 */} {/* 数据格式 */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm font-medium text-foreground"> <Label className="text-sm font-medium text-foreground">{'数据格式'}</Label>
{t('testDataGenerator_format')}
</Label>
<div className="flex gap-2"> <div className="flex gap-2">
{FORMAT_OPTIONS.map((option) => ( {FORMAT_OPTIONS.map((option) => (
<button <button
@@ -13,7 +13,6 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { useI18n } from '@/utils/chromeI18n';
import type { GeneratorDefinition } from '@/types/testDataGenerator'; import type { GeneratorDefinition } from '@/types/testDataGenerator';
interface GeneratorConfigProps { interface GeneratorConfigProps {
@@ -23,17 +22,12 @@ interface GeneratorConfigProps {
} }
export default function GeneratorConfig({ generator, params, onChange }: GeneratorConfigProps) { export default function GeneratorConfig({ generator, params, onChange }: GeneratorConfigProps) {
const { t } = useI18n('testDataGenerator');
const handleParamChange = (key: string, value: unknown) => { const handleParamChange = (key: string, value: unknown) => {
onChange({ ...params, [key]: value }); onChange({ ...params, [key]: value });
}; };
if (generator.params.length === 0) { if (generator.params.length === 0) {
return ( return <p className="text-sm text-muted-foreground py-2">{'此生成器无可配置参数'}</p>;
<p className="text-sm text-muted-foreground py-2">
{t('testDataGenerator_noGeneratorParams')}
</p>
);
} }
return ( return (
@@ -71,9 +65,7 @@ export default function GeneratorConfig({ generator, params, onChange }: Generat
{param.type === 'boolean' && ( {param.type === 'boolean' && (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{params[param.key] !== false {params[param.key] !== false ? '启用' : '禁用'}
? t('testDataGenerator_enabled')
: t('testDataGenerator_disabled')}
</span> </span>
<Switch <Switch
checked={params[param.key] !== false} checked={params[param.key] !== false}
@@ -116,7 +108,7 @@ export default function GeneratorConfig({ generator, params, onChange }: Generat
.filter(Boolean), .filter(Boolean),
) )
} }
placeholder={t('testDataGenerator_commaSeparated')} placeholder={'用逗号分隔多个值'}
className="h-9" className="h-9"
/> />
)} )}
@@ -6,7 +6,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { Search, User, Briefcase, Code, Hash } from 'lucide-react'; import { Search, User, Briefcase, Code, Hash } from 'lucide-react';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { useI18n } from '@/utils/chromeI18n';
import { generatorCategories, getGeneratorsByCategory, searchGenerators } from '@/lib/generators'; import { generatorCategories, getGeneratorsByCategory, searchGenerators } from '@/lib/generators';
interface GeneratorSelectorProps { interface GeneratorSelectorProps {
@@ -22,7 +21,6 @@ const categoryIcons: Record<string, React.ComponentType<{ className?: string }>>
}; };
export default function GeneratorSelector({ selectedId, onChange }: GeneratorSelectorProps) { export default function GeneratorSelector({ selectedId, onChange }: GeneratorSelectorProps) {
const { t } = useI18n('testDataGenerator');
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [activeCategory, setActiveCategory] = useState<string>(generatorCategories[0]?.id || ''); const [activeCategory, setActiveCategory] = useState<string>(generatorCategories[0]?.id || '');
@@ -38,7 +36,7 @@ export default function GeneratorSelector({ selectedId, onChange }: GeneratorSel
<Input <Input
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('testDataGenerator_searchGenerator')} placeholder={'搜索生成器...'}
className="pl-9 h-9" className="pl-9 h-9"
/> />
</div> </div>
@@ -4,7 +4,6 @@
*/ */
import { CheckCircle, AlertTriangle, XCircle, Clock, Database } from 'lucide-react'; import { CheckCircle, AlertTriangle, XCircle, Clock, Database } from 'lucide-react';
import { useI18n } from '@/utils/chromeI18n';
import type { GenerateResult } from '@/types/testDataGenerator'; import type { GenerateResult } from '@/types/testDataGenerator';
interface ResultPanelProps { interface ResultPanelProps {
@@ -12,8 +11,6 @@ interface ResultPanelProps {
} }
export default function ResultPanel({ result }: ResultPanelProps) { export default function ResultPanel({ result }: ResultPanelProps) {
const { t } = useI18n('testDataGenerator');
if (!result) return null; if (!result) return null;
const getStatusIcon = () => { const getStatusIcon = () => {
@@ -28,12 +25,12 @@ export default function ResultPanel({ result }: ResultPanelProps) {
const getStatusText = () => { const getStatusText = () => {
if (result.success && (!result.warnings || result.warnings.length === 0)) { if (result.success && (!result.warnings || result.warnings.length === 0)) {
return t('testDataGenerator_success'); return '生成成功';
} }
if (result.success && result.warnings && result.warnings.length > 0) { if (result.success && result.warnings && result.warnings.length > 0) {
return t('testDataGenerator_partialSuccess'); return '部分成功';
} }
return t('testDataGenerator_failed'); return '生成失败';
}; };
return ( return (
@@ -52,9 +49,7 @@ export default function ResultPanel({ result }: ResultPanelProps) {
<span className="text-lg font-semibold text-foreground"> <span className="text-lg font-semibold text-foreground">
{result.stats.total.toLocaleString()} {result.stats.total.toLocaleString()}
</span> </span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">{'总条数'}</span>
{t('testDataGenerator_totalCount')}
</span>
</div> </div>
<div className="flex flex-col items-center p-2 rounded-lg bg-muted/30"> <div className="flex flex-col items-center p-2 rounded-lg bg-muted/30">
@@ -62,9 +57,7 @@ export default function ResultPanel({ result }: ResultPanelProps) {
<span className="text-lg font-semibold text-green-500"> <span className="text-lg font-semibold text-green-500">
{result.stats.success.toLocaleString()} {result.stats.success.toLocaleString()}
</span> </span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">{'成功'}</span>
{t('testDataGenerator_successCount')}
</span>
</div> </div>
<div className="flex flex-col items-center p-2 rounded-lg bg-muted/30"> <div className="flex flex-col items-center p-2 rounded-lg bg-muted/30">
@@ -72,7 +65,7 @@ export default function ResultPanel({ result }: ResultPanelProps) {
<span className="text-lg font-semibold text-foreground"> <span className="text-lg font-semibold text-foreground">
{(result.stats.duration / 1000).toFixed(2)}s {(result.stats.duration / 1000).toFixed(2)}s
</span> </span>
<span className="text-xs text-muted-foreground">{t('testDataGenerator_duration')}</span> <span className="text-xs text-muted-foreground">{'耗时'}</span>
</div> </div>
</div> </div>
)} )}
@@ -82,9 +75,7 @@ export default function ResultPanel({ result }: ResultPanelProps) {
<div className="p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/20"> <div className="p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/20">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<AlertTriangle className="h-4 w-4 text-yellow-500" /> <AlertTriangle className="h-4 w-4 text-yellow-500" />
<span className="text-sm font-medium text-yellow-500"> <span className="text-sm font-medium text-yellow-500">{'警告'}</span>
{t('testDataGenerator_warnings')}
</span>
</div> </div>
<ul className="list-disc list-inside space-y-1"> <ul className="list-disc list-inside space-y-1">
{result.warnings.slice(0, 5).map((warning, index) => ( {result.warnings.slice(0, 5).map((warning, index) => (
@@ -93,9 +84,7 @@ export default function ResultPanel({ result }: ResultPanelProps) {
</li> </li>
))} ))}
{result.warnings.length > 5 && ( {result.warnings.length > 5 && (
<li className="text-xs text-yellow-500/80"> <li className="text-xs text-yellow-500/80">... {'还有 {{count}} 条警告'}</li>
... {t('testDataGenerator_moreWarnings', { count: result.warnings.length - 5 })}
</li>
)} )}
</ul> </ul>
</div> </div>
@@ -20,7 +20,6 @@ import { toast } from 'sonner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Dialog, DialogContent } from '@/components/ui/dialog'; import { Dialog, DialogContent } from '@/components/ui/dialog';
import { useI18n } from '@/utils/chromeI18n';
import * as ruleStorage from '@/utils/ruleStorage'; import * as ruleStorage from '@/utils/ruleStorage';
import type { DataRule, FieldConfig } from '@/types/testDataGenerator'; import type { DataRule, FieldConfig } from '@/types/testDataGenerator';
@@ -31,7 +30,6 @@ interface RuleManagerProps {
} }
export default function RuleManager({ onLoad, onEdit, onRulesChanged }: RuleManagerProps) { export default function RuleManager({ onLoad, onEdit, onRulesChanged }: RuleManagerProps) {
const { t, i18n } = useI18n('testDataGenerator');
const [rules, setRules] = useState<DataRule[]>(() => ruleStorage.getAll()); const [rules, setRules] = useState<DataRule[]>(() => ruleStorage.getAll());
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(''); const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('');
@@ -64,9 +62,9 @@ export default function RuleManager({ onLoad, onEdit, onRulesChanged }: RuleMana
onLoad(rule.fields); onLoad(rule.fields);
ruleStorage.recordUse(rule.id); ruleStorage.recordUse(rule.id);
loadRules(); loadRules();
toast.success(t('testDataGenerator_ruleLoaded', { name: rule.name })); toast.success(`已加载规则「${rule.name}`);
}, },
[onLoad, loadRules, t], [onLoad, loadRules],
); );
const handleDelete = useCallback(() => { const handleDelete = useCallback(() => {
@@ -74,20 +72,20 @@ export default function RuleManager({ onLoad, onEdit, onRulesChanged }: RuleMana
ruleStorage.deleteRule(ruleToDelete.id); ruleStorage.deleteRule(ruleToDelete.id);
loadRules(); loadRules();
setRuleToDelete(null); setRuleToDelete(null);
toast.success(t('testDataGenerator_ruleDeleted')); toast.success('规则已删除');
onRulesChanged?.(); onRulesChanged?.();
}, [ruleToDelete, loadRules, t, onRulesChanged]); }, [ruleToDelete, loadRules, onRulesChanged]);
const handleDuplicate = useCallback( const handleDuplicate = useCallback(
(id: string) => { (id: string) => {
const result = ruleStorage.duplicate(id, t('testDataGenerator_ruleCopySuffix')); const result = ruleStorage.duplicate(id, '(副本)');
if (result) { if (result) {
loadRules(); loadRules();
toast.success(t('testDataGenerator_ruleDuplicated')); toast.success('规则已复制');
onRulesChanged?.(); onRulesChanged?.();
} }
}, },
[loadRules, t, onRulesChanged], [loadRules, onRulesChanged],
); );
const handleEdit = useCallback( const handleEdit = useCallback(
@@ -113,14 +111,14 @@ export default function RuleManager({ onLoad, onEdit, onRulesChanged }: RuleMana
} finally { } finally {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
toast.success(t('testDataGenerator_exportSuccess')); toast.success('规则已导出');
} catch (error) { } catch (error) {
console.error('[RuleManager] 导出失败:', error); console.error('[RuleManager] 导出失败:', error);
toast.error(t('testDataGenerator_exportFailed')); toast.error('导出失败');
} finally { } finally {
setIsExporting(false); setIsExporting(false);
} }
}, [t]); }, []);
const handleImport = useCallback(() => { const handleImport = useCallback(() => {
const input = document.createElement('input'); const input = document.createElement('input');
@@ -136,11 +134,11 @@ export default function RuleManager({ onLoad, onEdit, onRulesChanged }: RuleMana
const result = ruleStorage.importRules(text); const result = ruleStorage.importRules(text);
if (result.success > 0) { if (result.success > 0) {
toast.success(t('testDataGenerator_importSuccess', { count: result.success })); toast.success(`成功导入 ${result.success} 条规则`);
} }
if (result.failed > 0) { if (result.failed > 0) {
toast.error(t('testDataGenerator_importFailed', { count: result.failed })); toast.error(`${result.failed} 条规则导入失败`);
console.warn('[RuleManager] 导入警告:', result.errors); console.warn('[RuleManager] 导入警告:', result.errors);
} }
@@ -148,25 +146,22 @@ export default function RuleManager({ onLoad, onEdit, onRulesChanged }: RuleMana
onRulesChanged?.(); onRulesChanged?.();
} catch (error) { } catch (error) {
console.error('[RuleManager] 导入失败:', error); console.error('[RuleManager] 导入失败:', error);
toast.error(t('testDataGenerator_importFailed')); toast.error('规则导入失败');
} finally { } finally {
setIsImporting(false); setIsImporting(false);
} }
}; };
input.click(); input.click();
}, [loadRules, t, onRulesChanged]); }, [loadRules, onRulesChanged]);
const formatDate = useCallback( const formatDate = useCallback((timestamp: number) => {
(timestamp: number) => { return new Date(timestamp).toLocaleDateString('zh-CN', {
return new Date(timestamp).toLocaleDateString(i18n.language || 'zh-CN', {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
}); });
}, }, []);
[i18n.language],
);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -178,15 +173,15 @@ export default function RuleManager({ onLoad, onEdit, onRulesChanged }: RuleMana
> >
<div className="flex-1 overflow-y-auto px-6 pb-4"> <div className="flex-1 overflow-y-auto px-6 pb-4">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t('testDataGenerator_confirmDeleteDescription', { name: ruleToDelete?.name ?? '' })} {ruleToDelete && `确定要删除规则「${ruleToDelete.name}」吗?此操作不可撤销。`}
</p> </p>
</div> </div>
<div className="flex justify-end gap-2 px-6 py-2 border-t shrink-0"> <div className="flex justify-end gap-2 px-6 py-2 border-t shrink-0">
<Button variant="ghost" size="sm" onClick={() => setRuleToDelete(null)}> <Button variant="ghost" size="sm" onClick={() => setRuleToDelete(null)}>
{t('testDataGenerator_cancel')} {'取消'}
</Button> </Button>
<Button variant="destructive" size="sm" onClick={handleDelete}> <Button variant="destructive" size="sm" onClick={handleDelete}>
{t('testDataGenerator_confirm')} {'确认'}
</Button> </Button>
</div> </div>
</DialogContent> </DialogContent>
@@ -206,7 +201,7 @@ export default function RuleManager({ onLoad, onEdit, onRulesChanged }: RuleMana
) : ( ) : (
<Upload className="h-4 w-4" /> <Upload className="h-4 w-4" />
)} )}
{t('testDataGenerator_import')} {'导入'}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
@@ -220,7 +215,7 @@ export default function RuleManager({ onLoad, onEdit, onRulesChanged }: RuleMana
) : ( ) : (
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
)} )}
{t('testDataGenerator_export')} {'导出数据'}
</Button> </Button>
</div> </div>
@@ -230,12 +225,12 @@ export default function RuleManager({ onLoad, onEdit, onRulesChanged }: RuleMana
<Input <Input
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value.slice(0, 20))} onChange={(e) => setSearchQuery(e.target.value.slice(0, 20))}
placeholder={t('testDataGenerator_searchRules')} placeholder={'搜索规则...'}
className="pl-9 pr-24 h-9" className="pl-9 pr-24 h-9"
maxLength={20} maxLength={20}
/> />
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-muted-foreground pointer-events-none"> <span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-muted-foreground pointer-events-none tabular-nums">
{t('testDataGenerator_ruleCount', { count: rules.length, max: 20 })} {`已保存 ${rules.length}/${ruleStorage.MAX_RULES}`}
</span> </span>
</div> </div>
@@ -245,9 +240,7 @@ export default function RuleManager({ onLoad, onEdit, onRulesChanged }: RuleMana
<div className="text-center py-6"> <div className="text-center py-6">
<Tag className="h-8 w-8 text-muted-foreground/40 mx-auto mb-2" /> <Tag className="h-8 w-8 text-muted-foreground/40 mx-auto mb-2" />
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{debouncedSearchQuery {debouncedSearchQuery ? '未找到匹配的规则' : '暂无保存的规则'}
? t('testDataGenerator_noSearchResults')
: t('testDataGenerator_noRules')}
</p> </p>
</div> </div>
) : ( ) : (
@@ -260,7 +253,7 @@ export default function RuleManager({ onLoad, onEdit, onRulesChanged }: RuleMana
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-medium text-sm text-foreground truncate">{rule.name}</span> <span className="font-medium text-sm text-foreground truncate">{rule.name}</span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{rule.fields.length} {t('testDataGenerator_fields')} {rule.fields.length} {'字段'}
</span> </span>
</div> </div>
{rule.description && ( {rule.description && (
@@ -273,7 +266,7 @@ export default function RuleManager({ onLoad, onEdit, onRulesChanged }: RuleMana
<Clock className="h-3 w-3" /> <Clock className="h-3 w-3" />
{formatDate(rule.updatedAt)} {formatDate(rule.updatedAt)}
</span> </span>
<span>{t('testDataGenerator_usedTimes', { count: rule.useCount })}</span> <span>{`使用 ${rule.useCount}`}</span>
</div> </div>
</div> </div>
@@ -283,7 +276,7 @@ export default function RuleManager({ onLoad, onEdit, onRulesChanged }: RuleMana
size="icon" size="icon"
className="h-7 w-7" className="h-7 w-7"
onClick={() => handleLoad(rule)} onClick={() => handleLoad(rule)}
title={t('testDataGenerator_load')} title={'加载'}
> >
<FolderOpen className="h-3.5 w-3.5" /> <FolderOpen className="h-3.5 w-3.5" />
</Button> </Button>
@@ -292,7 +285,7 @@ export default function RuleManager({ onLoad, onEdit, onRulesChanged }: RuleMana
size="icon" size="icon"
className="h-7 w-7" className="h-7 w-7"
onClick={() => handleEdit(rule)} onClick={() => handleEdit(rule)}
title={t('testDataGenerator_edit')} title={'编辑'}
> >
<Edit className="h-3.5 w-3.5" /> <Edit className="h-3.5 w-3.5" />
</Button> </Button>
@@ -301,7 +294,7 @@ export default function RuleManager({ onLoad, onEdit, onRulesChanged }: RuleMana
size="icon" size="icon"
className="h-7 w-7" className="h-7 w-7"
onClick={() => handleDuplicate(rule.id)} onClick={() => handleDuplicate(rule.id)}
title={t('testDataGenerator_duplicate')} title={'复制'}
> >
<Copy className="h-3.5 w-3.5" /> <Copy className="h-3.5 w-3.5" />
</Button> </Button>
@@ -310,7 +303,7 @@ export default function RuleManager({ onLoad, onEdit, onRulesChanged }: RuleMana
size="icon" size="icon"
className="h-7 w-7 text-destructive hover:text-destructive" className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => setRuleToDelete(rule)} onClick={() => setRuleToDelete(rule)}
title={t('testDataGenerator_delete')} title={'删除'}
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
</Button> </Button>
@@ -4,6 +4,7 @@ import userEvent from '@testing-library/user-event';
// Mock dependencies // Mock dependencies
vi.mock('@/utils/ruleStorage', () => ({ vi.mock('@/utils/ruleStorage', () => ({
MAX_RULES: 20,
getAll: vi.fn(() => []), getAll: vi.fn(() => []),
save: vi.fn(), save: vi.fn(),
deleteRule: vi.fn(), deleteRule: vi.fn(),
@@ -87,8 +88,7 @@ describe('RuleManager', () => {
expect(defaultProps.onLoad).toHaveBeenCalledWith(mockFields); expect(defaultProps.onLoad).toHaveBeenCalledWith(mockFields);
expect(mockedRuleStorage.recordUse).toHaveBeenCalledWith('rule-1'); expect(mockedRuleStorage.recordUse).toHaveBeenCalledWith('rule-1');
// Note: t() mock doesn't handle placeholders, so we just check it was called expect(mockedToast.success).toHaveBeenCalledWith('已加载规则「Test Rule」');
expect(mockedToast.success).toHaveBeenCalled();
}); });
it('should show delete confirmation dialog', async () => { it('should show delete confirmation dialog', async () => {
@@ -100,7 +100,7 @@ describe('RuleManager', () => {
const deleteButton = screen.getByTitle('删除'); const deleteButton = screen.getByTitle('删除');
await user.click(deleteButton); await user.click(deleteButton);
expect(screen.getByText(/确定要删除规则/)).toBeInTheDocument(); expect(screen.getByText('确定要删除规则「Test Rule」吗?此操作不可撤销。')).toBeInTheDocument();
}); });
it('should delete rule after confirmation', async () => { it('should delete rule after confirmation', async () => {
@@ -178,6 +178,14 @@ describe('RuleManager', () => {
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
it('should show saved rule count', () => {
mockedRuleStorage.getAll.mockReturnValue([mockRule]);
render(<RuleManager {...defaultProps} />);
expect(screen.getByText('已保存 1/20 条')).toBeInTheDocument();
});
it('should show field count for each rule', () => { it('should show field count for each rule', () => {
mockedRuleStorage.getAll.mockReturnValue([mockRule]); mockedRuleStorage.getAll.mockReturnValue([mockRule]);
+10 -12
View File
@@ -4,7 +4,6 @@
import { useState, useCallback, useRef, useEffect } from 'react'; import { useState, useCallback, useRef, useEffect } from 'react';
import { Settings, Database, Tag } from 'lucide-react'; import { Settings, Database, Tag } from 'lucide-react';
import { useI18n } from '@/utils/chromeI18n';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useGenerator } from './hooks/useGenerator'; import { useGenerator } from './hooks/useGenerator';
import type { FieldConfig, GenerateResult, DataRule } from '@/types/testDataGenerator'; import type { FieldConfig, GenerateResult, DataRule } from '@/types/testDataGenerator';
@@ -29,7 +28,6 @@ import RuleManager from './components/RuleManager';
type TabType = 'fields' | 'rules'; type TabType = 'fields' | 'rules';
export default function TestDataGeneratorPage() { export default function TestDataGeneratorPage() {
const { t } = useI18n('testDataGenerator');
const { isGenerating, progress, result, error, generate, cancel, clearResult } = useGenerator(); const { isGenerating, progress, result, error, generate, cancel, clearResult } = useGenerator();
// 字段配置 // 字段配置
@@ -52,11 +50,11 @@ export default function TestDataGeneratorPage() {
useEffect(() => { useEffect(() => {
if (result?.success && result.stats && result !== lastToastResultRef.current) { if (result?.success && result.stats && result !== lastToastResultRef.current) {
lastToastResultRef.current = result; lastToastResultRef.current = result;
toast.success(t('testDataGenerator_generateSuccess'), { toast.success('生成完成', {
description: `${result.stats.total} ${t('testDataGenerator_records')}`, description: `${result.stats.total} ${'条数据'}`,
}); });
} }
}, [result, t]); }, [result]);
// 添加新字段 // 添加新字段
const handleAddField = useCallback(() => { const handleAddField = useCallback(() => {
@@ -132,9 +130,9 @@ export default function TestDataGeneratorPage() {
setEditingRule(rule); setEditingRule(rule);
setActiveTab('fields'); setActiveTab('fields');
clearResult(); clearResult();
toast.success(t('testDataGenerator_editingRule', { name: rule.name })); toast.success('正在编辑规则「{{name}}」');
}, },
[clearResult, t], [clearResult],
); );
// 保存规则成功后清除编辑状态 // 保存规则成功后清除编辑状态
@@ -176,7 +174,7 @@ export default function TestDataGeneratorPage() {
)} )}
> >
<Settings className="h-4 w-4" /> <Settings className="h-4 w-4" />
{t('testDataGenerator_fieldConfig')} {'字段配置'}
</button> </button>
<button <button
onClick={() => setActiveTab('rules')} onClick={() => setActiveTab('rules')}
@@ -188,7 +186,7 @@ export default function TestDataGeneratorPage() {
)} )}
> >
<Tag className="h-4 w-4" /> <Tag className="h-4 w-4" />
{t('testDataGenerator_ruleManagement')} {'规则管理'}
</button> </button>
</div> </div>
@@ -259,7 +257,7 @@ export default function TestDataGeneratorPage() {
<div className="p-4 rounded-xl border border-border bg-card shadow-sm"> <div className="p-4 rounded-xl border border-border bg-card shadow-sm">
<h3 className="text-sm font-medium text-foreground mb-3 flex items-center gap-2"> <h3 className="text-sm font-medium text-foreground mb-3 flex items-center gap-2">
<Database className="h-4 w-4" /> <Database className="h-4 w-4" />
{t('testDataGenerator_dataPreview')} {'数据预览'}
</h3> </h3>
<div className="h-[280px]"> <div className="h-[280px]">
<DataPreview fields={fields} /> <DataPreview fields={fields} />
@@ -293,10 +291,10 @@ export default function TestDataGeneratorPage() {
</div> </div>
<div className="flex justify-end gap-2 px-6 py-2 border-t shrink-0"> <div className="flex justify-end gap-2 px-6 py-2 border-t shrink-0">
<Button variant="ghost" size="sm" onClick={() => setIsEditorOpen(false)}> <Button variant="ghost" size="sm" onClick={() => setIsEditorOpen(false)}>
{t('testDataGenerator_cancel')} {'取消'}
</Button> </Button>
<Button size="sm" onClick={() => setIsEditorOpen(false)}> <Button size="sm" onClick={() => setIsEditorOpen(false)}>
{t('testDataGenerator_done')} {'完成'}
</Button> </Button>
</div> </div>
</DialogContent> </DialogContent>
+5 -7
View File
@@ -1,18 +1,16 @@
import TextInputArea from '@/components/TextInputArea'; import TextInputArea from '@/components/TextInputArea';
import { formatBytes } from '@/utils/format'; import { formatBytes } from '@/utils/format';
import { useI18n } from '@/utils/chromeI18n';
import { useTextStatistics } from './useTextStatistics'; import { useTextStatistics } from './useTextStatistics';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
export default function Index() { export default function Index() {
const { t } = useI18n('textStatistics');
const { text, stats, setText } = useTextStatistics(); const { text, stats, setText } = useTextStatistics();
const statItems = [ const statItems = [
{ label: t('textStatistics:characters'), value: stats.characters }, { label: '字符数', value: stats.characters },
{ label: t('textStatistics:words'), value: stats.words }, { label: '单词数', value: stats.words },
{ label: t('textStatistics:lines'), value: stats.lines }, { label: '行数', value: stats.lines },
{ label: t('textStatistics:bytes'), value: formatBytes(stats.bytes) }, { label: '字节大小', value: formatBytes(stats.bytes) },
]; ];
return ( return (
@@ -20,7 +18,7 @@ export default function Index() {
<TextInputArea <TextInputArea
value={text} value={text}
onChange={setText} onChange={setText}
placeholder={t('textStatistics:placeholder')} placeholder={'在此输入或粘贴文本...'}
minRows={10} minRows={10}
maxRows={18} maxRows={18}
showClear={true} showClear={true}
+4 -11
View File
@@ -4,7 +4,6 @@ import { toast } from 'sonner';
import { CopyButton } from '@/components/CopyButton'; import { CopyButton } from '@/components/CopyButton';
import type { UnitType } from '../constants'; import type { UnitType } from '../constants';
import { msToUnit } from '../constants'; import { msToUnit } from '../constants';
import { useI18n } from '@/utils/chromeI18n';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
interface LiveClockProps extends React.HTMLAttributes<HTMLDivElement> { interface LiveClockProps extends React.HTMLAttributes<HTMLDivElement> {
@@ -13,8 +12,6 @@ interface LiveClockProps extends React.HTMLAttributes<HTMLDivElement> {
} }
export default function LiveClock({ unit, onUseNow, className, ...props }: LiveClockProps) { export default function LiveClock({ unit, onUseNow, className, ...props }: LiveClockProps) {
const { t } = useI18n('timestamp');
const [rawTime, setRawTime] = useState(() => Date.now()); const [rawTime, setRawTime] = useState(() => Date.now());
useEffect(() => { useEffect(() => {
@@ -36,7 +33,7 @@ export default function LiveClock({ unit, onUseNow, className, ...props }: LiveC
{...props} {...props}
> >
<span className="text-muted-foreground font-bold text-[10px] uppercase tracking-wider whitespace-nowrap shrink-0 selection:bg-transparent select-none"> <span className="text-muted-foreground font-bold text-[10px] uppercase tracking-wider whitespace-nowrap shrink-0 selection:bg-transparent select-none">
{t('timestamp:currentTs')} {'当前时间戳'}
</span> </span>
<span className="flex-1 font-mono font-bold text-foreground text-sm tracking-tight leading-none truncate tabular-nums"> <span className="flex-1 font-mono font-bold text-foreground text-sm tracking-tight leading-none truncate tabular-nums">
@@ -47,19 +44,15 @@ export default function LiveClock({ unit, onUseNow, className, ...props }: LiveC
type="button" type="button"
onClick={() => { onClick={() => {
onUseNow(rawTime); onUseNow(rawTime);
toast.success(t('timestamp:usedSuccess')); toast.success('已使用当前时间戳');
}} }}
title={t('timestamp:useNowTooltip')} title={'填充到下方'}
className="flex h-7 w-7 items-center justify-center rounded-md border border-input bg-background text-muted-foreground shadow-sm hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" className="flex h-7 w-7 items-center justify-center rounded-md border border-input bg-background text-muted-foreground shadow-sm hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
> >
<Clock className="w-3.5 h-3.5" /> <Clock className="w-3.5 h-3.5" />
</button> </button>
<CopyButton <CopyButton text={text} tooltip={'复制时间戳'} className="h-7 w-7 rounded-md border" />
text={text}
tooltip={t('timestamp:copyTsTooltip')}
className="h-7 w-7 rounded-md border"
/>
</div> </div>
); );
} }
@@ -1,6 +1,5 @@
import React from 'react'; import React from 'react';
import { CopyButton } from '@/components/CopyButton'; import { CopyButton } from '@/components/CopyButton';
import { useI18n } from '@/utils/chromeI18n';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
interface ResultViewProps extends React.HTMLAttributes<HTMLDivElement> { interface ResultViewProps extends React.HTMLAttributes<HTMLDivElement> {
@@ -14,8 +13,6 @@ export default function ResultView({
className, className,
...props ...props
}: ResultViewProps) { }: ResultViewProps) {
const { t } = useI18n('timestamp');
if (!result) { if (!result) {
if (!showEmptyPlaceholder) return null; if (!showEmptyPlaceholder) return null;
return ( return (
@@ -26,7 +23,7 @@ export default function ResultView({
)} )}
{...props} {...props}
> >
{t('timestamp:resultEmpty')} {'请输入并点击转换'}
</div> </div>
); );
} }
@@ -34,7 +31,7 @@ export default function ResultView({
return ( return (
<div className={cn('flex flex-col w-full', className)} {...props}> <div className={cn('flex flex-col w-full', className)} {...props}>
<span className="block text-muted-foreground/90 mb-2.5 text-xs font-semibold tracking-wider uppercase"> <span className="block text-muted-foreground/90 mb-2.5 text-xs font-semibold tracking-wider uppercase">
{t('timestamp:resultLabel')} {'转换结果'}
</span> </span>
<div className="bg-card text-card-foreground border border-border p-4 sm:p-5 rounded-xl relative shadow-sm flex justify-between items-center gap-4 focus-within:ring-1 focus-within:ring-ring"> <div className="bg-card text-card-foreground border border-border p-4 sm:p-5 rounded-xl relative shadow-sm flex justify-between items-center gap-4 focus-within:ring-1 focus-within:ring-ring">
@@ -43,7 +40,7 @@ export default function ResultView({
</span> </span>
<CopyButton <CopyButton
text={result} text={result}
tooltip={t('timestamp:copyResultTooltip')} tooltip={'复制结果'}
className="h-8 w-8 rounded-md shrink-0 border" className="h-8 w-8 rounded-md shrink-0 border"
/> />
</div> </div>
+7 -12
View File
@@ -4,7 +4,6 @@ import type { ModeType, UnitType, ZoneType } from './constants';
import LiveClock from './components/LiveClock'; import LiveClock from './components/LiveClock';
import ResultView from './components/ResultView'; import ResultView from './components/ResultView';
import { useTimestampConverter } from './useTimestampConverter'; import { useTimestampConverter } from './useTimestampConverter';
import { useI18n } from '@/utils/chromeI18n';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -17,18 +16,16 @@ import {
} from '@/components/ui/select'; } from '@/components/ui/select';
const MODE_OPTIONS: { value: ModeType; label: string }[] = [ const MODE_OPTIONS: { value: ModeType; label: string }[] = [
{ value: 'ts2dt', label: 'timestamp:tsToDate' }, { value: 'ts2dt', label: '时间戳转日期' },
{ value: 'dt2ts', label: 'timestamp:dateToTs' }, { value: 'dt2ts', label: '日期转时间戳' },
]; ];
const UNIT_OPTIONS: { value: UnitType; label: string }[] = [ const UNIT_OPTIONS: { value: UnitType; label: string }[] = [
{ value: 'ms', label: 'timestamp:unitMs' }, { value: 'ms', label: '毫秒' },
{ value: 's', label: 'timestamp:unitS' }, { value: 's', label: '' },
]; ];
export default function Index() { export default function Index() {
const { t } = useI18n('timestamp');
const { const {
mode, mode,
input, input,
@@ -53,7 +50,7 @@ export default function Index() {
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<SwitchButtonGroup <SwitchButtonGroup
value={mode} value={mode}
options={MODE_OPTIONS.map((o) => ({ ...o, label: t(o.label) }))} options={MODE_OPTIONS}
onChange={setMode} onChange={setMode}
size="small" size="small"
/> />
@@ -61,9 +58,7 @@ export default function Index() {
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Input <Input
type="text" type="text"
placeholder={ placeholder={mode === 'ts2dt' ? '输入时间戳...' : 'YYYY-MM-DD HH:mm:ss'}
mode === 'ts2dt' ? t('timestamp:placeholderTs') : t('timestamp:placeholderDate')
}
value={input} value={input}
onChange={(e) => setInput(e.target.value)} onChange={(e) => setInput(e.target.value)}
className={cn( className={cn(
@@ -78,7 +73,7 @@ export default function Index() {
<div className="flex flex-col sm:flex-row items-stretch gap-3 w-full"> <div className="flex flex-col sm:flex-row items-stretch gap-3 w-full">
<SwitchButtonGroup <SwitchButtonGroup
value={unit} value={unit}
options={UNIT_OPTIONS.map((o) => ({ ...o, label: t(o.label) }))} options={UNIT_OPTIONS}
onChange={setUnit} onChange={setUnit}
size="small" size="small"
className="sm:w-auto shrink-0" className="sm:w-auto shrink-0"
+4 -6
View File
@@ -2,7 +2,6 @@ import { useMemo, useState } from 'react';
import dayjs from '@/utils/dayjs'; import dayjs from '@/utils/dayjs';
import type { UnitType, ZoneType, ModeType } from './constants'; import type { UnitType, ZoneType, ModeType } from './constants';
import { DATE_FORMAT, msToUnit, dayjsFromTimestamp } from './constants'; import { DATE_FORMAT, msToUnit, dayjsFromTimestamp } from './constants';
import { useI18n } from '@/utils/chromeI18n';
import { useContextMenuData } from '@/utils/useContextMenuData'; import { useContextMenuData } from '@/utils/useContextMenuData';
export interface UseTimestampConverterReturn { export interface UseTimestampConverterReturn {
@@ -29,7 +28,6 @@ function isTimestampLike(input: string): boolean {
} }
export function useTimestampConverter(): UseTimestampConverterReturn { export function useTimestampConverter(): UseTimestampConverterReturn {
const { t } = useI18n('timestamp');
const [mode, setMode] = useState<ModeType>('ts2dt'); const [mode, setMode] = useState<ModeType>('ts2dt');
const [unit, setUnit] = useState<UnitType>('ms'); const [unit, setUnit] = useState<UnitType>('ms');
const [zone, setZone] = useState<ZoneType>('Asia/Shanghai'); const [zone, setZone] = useState<ZoneType>('Asia/Shanghai');
@@ -43,22 +41,22 @@ export function useTimestampConverter(): UseTimestampConverterReturn {
if (mode === 'ts2dt') { if (mode === 'ts2dt') {
const num = Number(rawInput); const num = Number(rawInput);
if (isNaN(num)) { if (isNaN(num)) {
return { result: '', error: t('timestamp:errors.invalidNumber') }; return { result: '', error: '请输入有效数字' };
} }
const d = dayjsFromTimestamp(num, unit); const d = dayjsFromTimestamp(num, unit);
if (!d.isValid()) { if (!d.isValid()) {
return { result: '', error: t('timestamp:errors.invalidTimestamp') }; return { result: '', error: '无效时间戳' };
} }
return { result: d.tz(zone).format(DATE_FORMAT), error: '' }; return { result: d.tz(zone).format(DATE_FORMAT), error: '' };
} else { } else {
const d = dayjs.tz(rawInput, DATE_FORMAT, zone); const d = dayjs.tz(rawInput, DATE_FORMAT, zone);
if (!d.isValid()) { if (!d.isValid()) {
return { result: '', error: t('timestamp:errors.invalidFormat') }; return { result: '', error: '无效的日期格式' };
} }
const ms = d.valueOf(); const ms = d.valueOf();
return { result: String(msToUnit(ms, unit)), error: '' }; return { result: String(msToUnit(ms, unit)), error: '' };
} }
}, [input, mode, unit, zone, t]); }, [input, mode, unit, zone]);
const handleContextMenuData = (payload: string) => { const handleContextMenuData = (payload: string) => {
const trimmed = payload.trim(); const trimmed = payload.trim();
-68
View File
@@ -1,68 +0,0 @@
/**
* chrome.i18n 类型安全 wrapper
* 提供与 react-i18next 兼容的接口
*/
/**
* 获取翻译文本
* @param msgId 翻译 key(如 'timestamp_pageTitle'
* @param substitutions 占位符替换值(可选)
* @returns 翻译后的文本
*/
export function getMessage(msgId: string, substitutions?: string[]): string {
try {
return chrome.i18n.getMessage(msgId, substitutions);
} catch (error) {
console.warn(`[chrome.i18n] 无法获取翻译: ${msgId}`, error);
return msgId; // 回退到 key 本身
}
}
/**
* react-i18next 兼容的 Hook
* 返回 t 函数和相关信息
*/
export function useI18n(namespace?: string | string[]) {
const namespaces = Array.isArray(namespace) ? namespace : namespace ? [namespace] : [];
const t = (key: string, options?: Record<string, unknown>): string => {
// 统一将分隔符转换为下划线,兼容 'namespace:key.path' 和 'key.path' 两种写法
const msgId = key.replace(':', '_').replace(/\./g, '_');
// 先尝试直接查找 key
let message = getMessage(msgId);
// 如果直接查找未命中(空字符串或返回 key 本身),尝试命名空间前缀(使用转换后的 msgId)
if ((!message || message === msgId) && namespaces.length > 0) {
for (const ns of namespaces) {
const candidate = `${ns}_${msgId}`;
const result = getMessage(candidate);
if (result !== candidate) {
message = result;
break;
}
}
}
if (options) {
for (const [placeholder, value] of Object.entries(options)) {
message = message.replace(`{{${placeholder}}}`, String(value));
}
}
return message;
};
return {
t,
i18n: {
language: 'zh',
changeLanguage: (_lng?: string) => {
// chrome.i18n 无法动态切换语言,需要刷新页面
console.warn('[chrome.i18n] 无法动态切换语言,需要刷新页面');
return Promise.resolve();
},
},
isLoaded: true,
};
}
+7 -12
View File
@@ -2,8 +2,6 @@
* JWT 解析工具 * JWT 解析工具
*/ */
import { getMessage } from '@/utils/chromeI18n';
interface JwtHeader { interface JwtHeader {
alg: string; alg: string;
typ?: string; typ?: string;
@@ -43,7 +41,7 @@ export function decodeBase64Url(str: string): string {
const pad = base64.length % 4; const pad = base64.length % 4;
if (pad) { if (pad) {
if (pad === 1) { if (pad === 1) {
throw new Error(getMessage('jwt_errors_invalidBase64String')); throw new Error('无效的 Base64URL 字符串');
} }
base64 += new Array(5 - pad).join('='); base64 += new Array(5 - pad).join('=');
} }
@@ -58,10 +56,9 @@ export function decodeBase64Url(str: string): string {
const decoder = new TextDecoder('utf-8'); const decoder = new TextDecoder('utf-8');
return decoder.decode(bytes); return decoder.decode(bytes);
} catch (e) { } catch (e) {
throw new Error( throw new Error('Base64 解码失败: ' + (e instanceof Error ? e.message : String(e)), {
getMessage('jwt_errors_failedToDecode') + (e instanceof Error ? e.message : String(e)), cause: e,
{ cause: e }, });
);
} }
} }
@@ -78,7 +75,7 @@ export function parseJwt(token: string): JwtResult {
payload: null, payload: null,
signature: '', signature: '',
raw: { header: '', payload: '', signature: '' }, raw: { header: '', payload: '', signature: '' },
error: getMessage('jwt_errors_invalidFormat'), error: 'JWT 格式无效:应包含 3 个部分(header.payload.signature',
}; };
} }
@@ -98,8 +95,7 @@ export function parseJwt(token: string): JwtResult {
const headerJson = decodeBase64Url(headerB64); const headerJson = decodeBase64Url(headerB64);
result.header = JSON.parse(headerJson); result.header = JSON.parse(headerJson);
} catch (e) { } catch (e) {
result.error = result.error = 'JWT Header 解析失败: ' + (e instanceof Error ? e.message : String(e));
getMessage('jwt_errors_parseHeaderFailed') + (e instanceof Error ? e.message : String(e));
return result; return result;
} }
@@ -107,8 +103,7 @@ export function parseJwt(token: string): JwtResult {
const payloadJson = decodeBase64Url(payloadB64); const payloadJson = decodeBase64Url(payloadB64);
result.payload = JSON.parse(payloadJson); result.payload = JSON.parse(payloadJson);
} catch (e) { } catch (e) {
result.error = result.error = 'JWT Payload 解析失败: ' + (e instanceof Error ? e.message : String(e));
getMessage('jwt_errors_parsePayloadFailed') + (e instanceof Error ? e.message : String(e));
return result; return result;
} }
+1 -1
View File
@@ -9,7 +9,7 @@ import type { DataRule, FieldConfig } from '@/types/testDataGenerator';
const STORAGE_KEY = 'testDataGenerator_rules'; const STORAGE_KEY = 'testDataGenerator_rules';
/** 最大规则数量 */ /** 最大规则数量 */
const MAX_RULES = 20; export const MAX_RULES = 20;
/** /**
* 获取所有规则 * 获取所有规则
+13 -7
View File
@@ -20,6 +20,15 @@ const CLEAN_OPTION_KEYS: (keyof StorageCleanerOptions)[] = [
'serviceWorkers', 'serviceWorkers',
]; ];
const OPTION_LABELS: Record<string, string> = {
localStorage: 'Local Storage',
sessionStorage: 'Session Storage',
indexedDB: '站点存储',
cookies: 'Cookies',
cacheStorage: 'Cache Storage',
serviceWorkers: 'Service Workers',
};
export async function getCurrentTab() { export async function getCurrentTab() {
// For popup pages, we need to get the active tab from the browser window that triggered the popup. // For popup pages, we need to get the active tab from the browser window that triggered the popup.
// We should ONLY care about the currently active tab in the last focused window. // We should ONLY care about the currently active tab in the last focused window.
@@ -362,22 +371,19 @@ export async function clearStorage(
return result; return result;
} }
export function formatCleaningResult( export function formatCleaningResult(result: CleaningResult): string {
result: CleaningResult,
t: (key: string, options?: Record<string, unknown>) => string,
): string {
const parts: string[] = []; const parts: string[] = [];
for (const key of CLEAN_OPTION_KEYS) { for (const key of CLEAN_OPTION_KEYS) {
const r = result[key]; const r = result[key];
if (r?.success && r.count > 0) { if (r?.success && r.count > 0) {
parts.push(`${r.count} ${t(`storageCleaner:options.${key}`)}`); parts.push(`${r.count} ${OPTION_LABELS[key] || key}`);
} }
} }
if (parts.length === 0) { if (parts.length === 0) {
return t('storageCleaner:noDataToClean'); return '该页面没有可清理的存储数据';
} }
return t('storageCleaner:cleanedSummary', { items: parts.join(', ') }); return `清理了 ${parts.join(', ')}`;
} }
-35
View File
@@ -1,41 +1,6 @@
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
import { afterEach, beforeEach, vi } from 'vitest'; import { afterEach, beforeEach, vi } from 'vitest';
import React from 'react'; import React from 'react';
import zhMessages from './public/_locales/zh_CN/messages.json';
// Type assertion to allow string indexing
const zhMessagesMap = zhMessages as Record<string, { message: string }>;
vi.mock('@/utils/chromeI18n', () => ({
useI18n: (ns?: string | string[]) => ({
t: (key: string) => {
let msgId = key;
// Handle namespace:key format
if (key.includes(':')) {
msgId = key.replace(':', '_').replace(/\./g, '_');
}
// Try direct key first
if (zhMessagesMap[msgId]) return zhMessagesMap[msgId].message;
// Try namespace prefix (using converted msgId)
if (ns) {
const namespaces = Array.isArray(ns) ? ns : [ns];
for (const n of namespaces) {
const candidate = `${n}_${msgId}`;
if (zhMessagesMap[candidate]) return zhMessagesMap[candidate].message;
}
}
return msgId;
},
i18n: {
changeLanguage: vi.fn().mockResolvedValue(undefined),
language: 'zh',
},
isLoaded: true,
}),
getMessage: (msgId: string) => zhMessagesMap[msgId]?.message ?? msgId,
getLanguage: () => 'zh',
preloadNamespaces: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('@/components/CopyButton', () => ({ vi.mock('@/components/CopyButton', () => ({
CopyButton: ({ CopyButton: ({
+1 -2
View File
@@ -45,7 +45,6 @@ export default defineConfig({
name: 'Testing Tool', name: 'Testing Tool',
description: 'A tool for testing web applications.', description: 'A tool for testing web applications.',
version_name: undefined, version_name: undefined,
default_locale: 'zh_CN',
permissions: [ permissions: [
'storage', 'storage',
'clipboardWrite', 'clipboardWrite',
@@ -64,7 +63,7 @@ export default defineConfig({
'128': 'icon/128.png', '128': 'icon/128.png',
}, },
action: { action: {
default_title: '__MSG_appName__', default_title: 'Testing Tool',
default_icon: { default_icon: {
'16': 'icon/16.png', '16': 'icon/16.png',
'32': 'icon/32.png', '32': 'icon/32.png',