feat: i18n 中英双语界面 — i18next + react-i18next

- 安装 i18next / react-i18next / i18next-browser-languagedetector
- 新建 src/i18n.ts 配置(fallback zh)
- 中/英翻译文件各 ~50 条目
- App.tsx 新增 EN/中 语言切换按钮
- ExportPanel + QrPreview + ModePanel + HistoryList + ErrorBoundary
- 全部 7 种模式组件均支持双语
- 12 前端测试通过,tsc 零错误
This commit is contained in:
2026-06-19 21:23:10 +08:00
parent 8e9e7e1b4c
commit 77fac0e28f
19 changed files with 381 additions and 56 deletions
@@ -1,8 +1,20 @@
import { useTranslation } from 'react-i18next';
import { useQrState } from '../store/qrContext';
import { MODE_LABELS, type HistoryEntry } from '../types';
import { type HistoryEntry } from '../types';
import { persistHistory } from '../hooks/useQrEncode';
const MODE_I18N: Record<string, string> = {
text: 'mode.text',
url: 'mode.url',
wifi: 'mode.wifi',
vcard: 'mode.vcard',
email: 'mode.email',
phone: 'mode.phone',
sms: 'mode.sms',
};
export default function HistoryList() {
const { t } = useTranslation();
const { state, dispatch } = useQrState();
const handleClick = (entry: HistoryEntry) => {
@@ -36,21 +48,21 @@ export default function HistoryList() {
<div className="flex flex-col h-full">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wider">
📋
📋 {t('history.title')}
</span>
{state.history.length > 0 && (
<button
onClick={handleClear}
className="text-xs text-red-400 hover:text-red-600 transition-colors"
>
{t('history.clear')}
</button>
)}
</div>
<div className="flex-1 overflow-y-auto space-y-1">
{state.history.length === 0 && (
<p className="text-xs text-gray-400 text-center py-4"></p>
<p className="text-xs text-gray-400 text-center py-4">{t('history.empty')}</p>
)}
{state.history.map((entry) => (
<div
@@ -61,7 +73,7 @@ export default function HistoryList() {
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="px-1 py-0.5 rounded text-[10px] font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400">
{MODE_LABELS[entry.mode] || entry.mode}
{t(MODE_I18N[entry.mode] || entry.mode)}
</span>
<span className="text-gray-400">{formatTime(entry.timestamp)}</span>
</div>