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
+5 -7
View File
@@ -4,7 +4,6 @@ import { copyTextToClipboard } from '@/utils/clipboard';
import { cn } from '@/lib/utils';
import { buttonVariants, type ButtonProps } from '@/components/ui/button';
import { toast } from 'sonner';
import { useI18n } from '@/utils/chromeI18n';
interface CopyButtonProps extends Omit<ButtonProps, 'children' | 'onClick'> {
text: string;
@@ -19,7 +18,6 @@ export const CopyButton: React.FC<CopyButtonProps> = ({
className,
...props
}) => {
const { t } = useI18n('common');
const [copied, setCopied] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -33,18 +31,18 @@ export const CopyButton: React.FC<CopyButtonProps> = ({
e.stopPropagation();
if (!text) {
toast.error(t('messages.copyEmpty'));
toast.error('无内容可复制');
return;
}
const success = await copyTextToClipboard(text);
if (success) {
toast.success(t('messages.copySuccess'));
toast.success('已复制到剪贴板');
setCopied(true);
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setCopied(false), 1500);
} else {
toast.error(t('messages.copyError'));
toast.error('复制失败');
}
};
@@ -52,8 +50,8 @@ export const CopyButton: React.FC<CopyButtonProps> = ({
<button
type="button"
onClick={handleCopy}
title={tooltip ?? t('buttons.copy')}
aria-label={tooltip ?? t('buttons.copy')}
title={tooltip ?? '复制'}
aria-label={tooltip ?? '复制'}
className={cn(
buttonVariants({ variant, size }),
copied &&
+3 -6
View File
@@ -1,7 +1,6 @@
import { Component, ErrorInfo, ReactNode } from 'react';
import { AlertCircle, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { getMessage } from '@/utils/chromeI18n';
interface Props {
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">
<AlertCircle className="h-8 w-8" />
</div>
<h2 className="text-xl font-extrabold text-destructive mb-2">
{getMessage('errorBoundary_title')}
</h2>
<h2 className="text-xl font-extrabold text-destructive mb-2"></h2>
<p className="text-sm text-muted-foreground mb-6">
{getMessage('errorBoundary_description')}
</p>
{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">
@@ -63,7 +60,7 @@ class ErrorBoundary extends Component<Props, State> {
className="rounded-lg font-bold shadow-sm"
>
<RefreshCw className="mr-2 h-4 w-4" />
{getMessage('errorBoundary_refresh')}
</Button>
</div>
</div>
+3 -6
View File
@@ -1,7 +1,6 @@
import { Component, ErrorInfo, ReactNode } from 'react';
import { AlertCircle, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { getMessage } from '@/utils/chromeI18n';
interface Props {
children: ReactNode;
@@ -46,11 +45,9 @@ class PageErrorBoundary extends Component<Props, State> {
<AlertCircle className="h-6 w-6" />
</div>
<h3 className="text-base font-semibold text-foreground mb-1.5">
{getMessage('pageErrorBoundary_title')}
</h3>
<h3 className="text-base font-semibold text-foreground mb-1.5"></h3>
<p className="text-xs text-muted-foreground mb-5">
{getMessage('pageErrorBoundary_description')}
</p>
{this.state.error && (
@@ -68,7 +65,7 @@ class PageErrorBoundary extends Component<Props, State> {
className="font-medium shadow-sm"
>
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
{getMessage('errorBoundary_retry')}
</Button>
</div>
</div>
+2 -4
View File
@@ -1,7 +1,6 @@
import { FEATURES, getEntryPointType } from '@/config/features';
import { useRouter } from '@/providers/RouterProvider';
import { Suspense } from 'react';
import { useI18n } from '@/utils/chromeI18n';
import PageErrorBoundary from '@/components/PageErrorBoundary';
import PageSkeleton from '@/components/PageSkeleton';
import { cn } from '@/lib/utils';
@@ -11,7 +10,6 @@ const entryPointType = getEntryPointType();
export default function RouterContainer() {
const { currentPage, isLoaded } = useRouter();
const { t } = useI18n('common');
const animationClass =
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">
<AlertTriangle className="h-6 w-6" />
</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]">
{t('router.notFoundDescription', { entryPointType })}
{`该功能在当前运行环境(${entryPointType})下不可用或已被移除。`}
</p>
</div>
)}
+6 -13
View File
@@ -1,6 +1,5 @@
import React, { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react';
import { X } from 'lucide-react';
import { useI18n } from '@/utils/chromeI18n';
import { cn } from '@/lib/utils';
import { toast } from 'sonner'; // 推荐使用 shadcn 的默认 Toast
import { CopyButton } from '@/components/CopyButton';
@@ -114,8 +113,7 @@ const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props
const [internalValue, setInternalValue] = useState(defaultValue);
const [error, setError] = useState<string>('');
const { t } = useI18n('common');
const placeholder = placeholderProp ?? t('textInputArea.placeholder');
const placeholder = placeholderProp ?? '请输入文本';
const isControlled = controlledValue !== undefined;
const value = isControlled ? controlledValue : internalValue;
@@ -159,7 +157,7 @@ const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newVal = e.target.value;
if (maxLength && newVal.length > maxLength) {
const msg = t('charCount', { count: maxLength });
const msg = `内容不能超过 ${maxLength} 个字符`;
setError(msg);
toast.warning(msg);
return;
@@ -181,9 +179,9 @@ const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props
onChange?.('');
setError('');
internalRef.current?.focus();
toast.success(t('textInputArea.cleared'));
toast.success('已清空');
onClear?.();
}, [isControlled, onChange, onClear, t]);
}, [isControlled, onChange, onClear]);
const handleAction = useCallback(
(action: ToolbarAction) => {
@@ -272,18 +270,13 @@ const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props
{/* 右侧系统按钮组 */}
<div className="flex items-center gap-1.5 ml-auto shrink-0">
{allowCopy && value && (
<CopyButton
text={value}
tooltip={t('textInputArea.copyContent')}
size="sm"
className="h-7 w-7 p-1"
/>
<CopyButton text={value} tooltip={'复制内容'} size="sm" className="h-7 w-7 p-1" />
)}
{showClear && value && !disabled && !readOnly && (
<button
type="button"
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"
>
<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 { storageUtil } from '@/utils/chromeStorage';
import { openExtensionPage } from '@/utils/chromeTabs';
import { useI18n } from '@/utils/chromeI18n';
import { cn } from '@/lib/utils';
const SEARCH_HISTORY_LIMIT = 10;
@@ -14,7 +13,6 @@ const SEARCH_HISTORY_DISPLAY = 5;
export default function TopBar() {
const { currentPage, goBack, navigateTo } = useRouter();
const { mode, setMode } = useThemeMode();
const { t } = useI18n(['common', 'features']);
const [searchQuery, setSearchQuery] = useState('');
const [showResults, setShowResults] = useState(false);
@@ -66,12 +64,9 @@ export default function TopBar() {
if (!query) return [];
return FEATURES.filter((f) => {
if (f.key === 'dashboard') return false;
return (
t(f.labelKey).toLowerCase().includes(query) ||
t(f.descriptionKey).toLowerCase().includes(query)
);
return f.label.toLowerCase().includes(query) || f.description.toLowerCase().includes(query);
});
}, [searchQuery, t]);
}, [searchQuery]);
const displayedHistory = useMemo(() => {
if (searchQuery.trim()) return [];
@@ -144,7 +139,7 @@ export default function TopBar() {
<button
type="button"
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"
>
<ArrowLeft className="h-4 w-4" />
@@ -159,7 +154,7 @@ export default function TopBar() {
<input
ref={inputRef}
type="text"
placeholder={t('common_buttons_search')}
placeholder={'搜索工具...'}
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
@@ -168,7 +163,7 @@ export default function TopBar() {
}}
onFocus={() => setShowResults(true)}
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"
/>
{!searchQuery && (
@@ -183,7 +178,7 @@ export default function TopBar() {
setSearchQuery('');
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"
>
<X className="h-3 w-3" />
@@ -219,24 +214,22 @@ export default function TopBar() {
{feature.icon && <feature.icon className="h-4 w-4" />}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-foreground truncate">
{t(feature.labelKey)}
</p>
<p className="font-medium text-foreground truncate">{feature.label}</p>
<p className="text-xs text-muted-foreground truncate mt-0.5">
{t(feature.descriptionKey)}
{feature.description}
</p>
</div>
</li>
))
) : (
<li className="px-4 py-6 text-center text-sm text-muted-foreground">
{t('common:buttons.noResults')}
{'未找到相关工具'}
</li>
)
) : (
<>
<div className="px-3 py-1.5 text-[11px] font-semibold tracking-wider text-muted-foreground/60 uppercase">
{t('common:buttons.recentSearch')}
{'最近搜索'}
</div>
{displayedHistory.map((item, index) => (
<li
@@ -261,10 +254,10 @@ export default function TopBar() {
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-foreground truncate">
{item.feature && t(item.feature.labelKey)}
{item.feature?.label}
</p>
<p className="text-xs text-muted-foreground truncate mt-0.5">
{item.feature && t(item.feature.descriptionKey)}
{item.feature?.description}
</p>
</div>
</li>
@@ -278,10 +271,19 @@ export default function TopBar() {
{/* 右侧:操作区 */}
<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" />
</IconButton>
<IconButton onClick={handleOpenInTab} title={t('common:buttons.openInTab')}>
<IconButton onClick={handleOpenInTab} title={'在标签页打开'}>
<ExternalLink className="h-4 w-4" />
</IconButton>
</div>
+12 -13
View File
@@ -18,12 +18,12 @@ describe('TextInputArea 组件', () => {
it('默认显示清空按钮', () => {
render(<TextInputArea value="有内容" onChange={() => {}} />);
expect(screen.getByRole('button', { name: 'textInputArea.clear' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '清空' })).toBeInTheDocument();
});
it('无内容时清空按钮应隐藏', () => {
render(<TextInputArea value="" onChange={() => {}} />);
expect(screen.queryByRole('button', { name: 'textInputArea.clear' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '清空' })).not.toBeInTheDocument();
});
it('disabled 时清空按钮应隐藏', () => {
@@ -57,7 +57,7 @@ describe('TextInputArea 组件', () => {
const handleChange = vi.fn();
render(<TextInputArea value="内容" onChange={handleChange} />);
fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' }));
fireEvent.click(screen.getByRole('button', { name: '清空' }));
expect(handleChange).toHaveBeenCalledWith('');
});
@@ -81,7 +81,7 @@ describe('TextInputArea 组件', () => {
it('清空按钮应清空内容', () => {
render(<TextInputArea defaultValue="内容" />);
fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' }));
fireEvent.click(screen.getByRole('button', { name: '清空' }));
expect(screen.getByRole('textbox')).toHaveValue('');
});
@@ -90,14 +90,12 @@ describe('TextInputArea 组件', () => {
describe('allowCopy 复制功能', () => {
it('allowCopy 且有内容时显示复制按钮', () => {
render(<TextInputArea value="可复制的内容" onChange={() => {}} allowCopy />);
expect(screen.getByRole('button', { name: 'textInputArea.copyContent' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '复制内容' })).toBeInTheDocument();
});
it('allowCopy 但无内容时隐藏复制按钮', () => {
render(<TextInputArea value="" onChange={() => {}} allowCopy />);
expect(
screen.queryByRole('button', { name: 'textInputArea.copyContent' }),
).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '复制内容' })).not.toBeInTheDocument();
});
it('allowCopy=false 时不显示复制按钮', () => {
@@ -113,7 +111,7 @@ describe('TextInputArea 组件', () => {
render(<TextInputArea value="测试" onChange={() => {}} allowCopy />);
await user.click(screen.getByRole('button', { name: 'textInputArea.copyContent' }));
await user.click(screen.getByRole('button', { name: '复制内容' }));
expect(writeTextSpy).toHaveBeenCalledWith('测试');
});
@@ -126,7 +124,7 @@ describe('TextInputArea 组件', () => {
render(<TextInputArea value="测试" onChange={() => {}} allowCopy />);
await user.click(screen.getByRole('button', { name: 'textInputArea.copyContent' }));
await user.click(screen.getByRole('button', { name: '复制内容' }));
expect(writeTextSpy).toHaveBeenCalledWith('测试');
});
@@ -158,6 +156,7 @@ describe('TextInputArea 组件', () => {
fireEvent.change(textarea, { target: { value: '123456' } });
expect(handleChange).not.toHaveBeenCalledWith('123456');
expect(screen.getByText('内容不能超过 5 个字符')).toBeInTheDocument();
});
it('未超出 maxLength 的输入应正常触发', () => {
@@ -370,7 +369,7 @@ describe('TextInputArea 组件', () => {
const writeTextSpy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined);
render(<TextInputArea value="测试" onChange={() => {}} allowCopy />);
await user.click(screen.getByRole('button', { name: 'textInputArea.copyContent' }));
await user.click(screen.getByRole('button', { name: '复制内容' }));
expect(writeTextSpy).toHaveBeenCalledWith('测试');
});
@@ -381,7 +380,7 @@ describe('TextInputArea 组件', () => {
const handleClear = vi.fn();
render(<TextInputArea value="内容" onChange={() => {}} onClear={handleClear} />);
fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' }));
fireEvent.click(screen.getByRole('button', { name: '清空' }));
expect(handleClear).toHaveBeenCalledOnce();
});
@@ -389,7 +388,7 @@ describe('TextInputArea 组件', () => {
it('不传 onClear 时清空按钮应正常工作', () => {
render(<TextInputArea defaultValue="内容" />);
fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' }));
fireEvent.click(screen.getByRole('button', { name: '清空' }));
expect(screen.getByRole('textbox')).toHaveValue('');
});