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
+17 -1
View File
@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next';
import { QrProvider, useQrState } from './store/qrContext';
import ErrorBoundary from './components/ErrorBoundary';
import ModePanel from './components/ModePanel';
@@ -13,11 +14,26 @@ import PhoneMode from './modes/PhoneMode';
import SmsMode from './modes/SmsMode';
function AppLayout() {
const { t, i18n } = useTranslation();
const toggleLang = () => {
i18n.changeLanguage(i18n.language.startsWith('en') ? 'zh' : 'en');
};
return (
<div className="h-screen flex flex-col bg-gray-50 dark:bg-gray-950">
{/* 顶部标题栏 */}
<div className="h-10 flex items-center px-4 bg-white/80 dark:bg-gray-900/80 backdrop-blur-xl border-b border-gray-200 dark:border-gray-800">
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">🀫 QRGen</span>
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">
&#x1f132; {t('app.title')}
</span>
<div className="flex-1" />
<button
onClick={toggleLang}
className="text-xs px-2 py-0.5 rounded bg-gray-100 dark:bg-gray-800 text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
>
{i18n.language.startsWith('en') ? '中' : 'EN'}
</button>
</div>
{/* 三栏主体 */}
@@ -1,6 +1,7 @@
import React, { Component, type ReactNode } from 'react';
import { withTranslation, type WithTranslation } from 'react-i18next';
interface Props {
interface Props extends WithTranslation {
children: ReactNode;
}
interface State {
@@ -8,7 +9,7 @@ interface State {
error: Error | null;
}
export default class ErrorBoundary extends Component<Props, State> {
class ErrorBoundaryInner extends Component<Props, State> {
state: State = { hasError: false, error: null };
static getDerivedStateFromError(error: Error) {
@@ -16,23 +17,22 @@ export default class ErrorBoundary extends Component<Props, State> {
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
// 生产环境错误日志记录入口
// TODO: 集成遥测服务后将错误上报
console.error('QRGen ErrorBoundary 捕获错误:', error.message, info.componentStack);
console.error('QRGen ErrorBoundary:', error.message, info.componentStack);
}
render() {
const { t } = this.props;
if (this.state.hasError) {
return (
<div className="h-screen flex flex-col items-center justify-center gap-3 bg-gray-50 dark:bg-gray-950 text-gray-600 dark:text-gray-400">
<span className="text-4xl"></span>
<h2 className="text-lg font-semibold"></h2>
<h2 className="text-lg font-semibold">{t('error.appError')}</h2>
<p className="text-sm max-w-md text-center">{this.state.error?.message}</p>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 rounded-lg bg-blue-500 text-white text-sm hover:bg-blue-600 transition-all"
>
{t('error.reload')}
</button>
</div>
);
@@ -40,3 +40,6 @@ export default class ErrorBoundary extends Component<Props, State> {
return this.props.children;
}
}
const ErrorBoundary = withTranslation()(ErrorBoundaryInner);
export default ErrorBoundary;
+22 -18
View File
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQrState } from '../store/qrContext';
import { invoke } from '@tauri-apps/api/core';
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
@@ -8,6 +9,7 @@ import type { QrConfig } from '../types';
import { buildEncodedText } from '../utils/qrText';
export default function ExportPanel() {
const { t } = useTranslation();
const { state, dispatch } = useQrState();
const [exporting, setExporting] = useState(false);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
@@ -20,7 +22,9 @@ export default function ExportPanel() {
setDecodedText(null);
try {
const filePath = await open({
filters: [{ name: '图片文件', extensions: ['png', 'jpg', 'jpeg', 'webp', 'bmp'] }],
filters: [
{ name: t('dialog.imageFiles'), extensions: ['png', 'jpg', 'jpeg', 'webp', 'bmp'] },
],
multiple: false,
});
if (!filePath) {
@@ -31,7 +35,7 @@ export default function ExportPanel() {
const text: string = await invoke('decode_qr', { imageBytes: Array.from(bytes) });
setDecodedText(text);
} catch (e) {
setErrorMsg(`解码失败: ${e}`);
setErrorMsg(`${t('error.decodeFailed')}: ${e}`);
}
setDecoding(false);
};
@@ -41,7 +45,7 @@ export default function ExportPanel() {
try {
await writeText(state.preview.svg);
} catch (e) {
setErrorMsg(`复制失败: ${e}`);
setErrorMsg(`${t('error.copyFailed')}: ${e}`);
}
};
@@ -51,14 +55,13 @@ export default function ExportPanel() {
setErrorMsg(null);
try {
const filePath = await save({
filters: [{ name: 'PNG 图片', extensions: ['png'] }],
filters: [{ name: t('dialog.pngImage'), extensions: ['png'] }],
defaultPath: 'qrcode.png',
});
if (!filePath) {
setExporting(false);
return;
}
const bytes: number[] = await invoke('export_png', {
text: buildEncodedText(state.mode, state.formData),
level: state.config.level,
@@ -67,7 +70,7 @@ export default function ExportPanel() {
});
await writeFile(filePath, new Uint8Array(bytes));
} catch (e) {
setErrorMsg(`导出 PNG 失败: ${e}`);
setErrorMsg(`${t('error.exportPngFailed')}: ${e}`);
}
setExporting(false);
};
@@ -77,19 +80,21 @@ export default function ExportPanel() {
setErrorMsg(null);
try {
const filePath = await save({
filters: [{ name: 'SVG 图片', extensions: ['svg'] }],
filters: [{ name: t('dialog.svgImage'), extensions: ['svg'] }],
defaultPath: 'qrcode.svg',
});
if (!filePath) return;
await writeFile(filePath, new TextEncoder().encode(state.preview.svg));
} catch (e) {
setErrorMsg(`导出 SVG 失败: ${e}`);
setErrorMsg(`${t('error.exportSvgFailed')}: ${e}`);
}
};
return (
<div className="flex flex-col gap-2">
<div className="text-xs font-semibold text-gray-400 uppercase tracking-wider"></div>
<div className="text-xs font-semibold text-gray-400 uppercase tracking-wider">
{t('app.exportOptions')}
</div>
{errorMsg && (
<div className="text-xs text-red-500 bg-red-50 dark:bg-red-900/20 rounded-lg px-2 py-1.5 break-all">
@@ -98,7 +103,7 @@ export default function ExportPanel() {
)}
<label className="text-xs text-gray-600 dark:text-gray-400">
{t('export.eccLevel')}
<select
value={state.config.level}
onChange={(e) =>
@@ -117,7 +122,7 @@ export default function ExportPanel() {
</label>
<label className="text-xs text-gray-600 dark:text-gray-400">
: {state.config.moduleSize}px
{t('export.moduleSize')}: {state.config.moduleSize}px
<input
type="range"
min={2}
@@ -131,7 +136,7 @@ export default function ExportPanel() {
</label>
<label className="text-xs text-gray-600 dark:text-gray-400">
: {state.config.margin}
{t('export.margin')}: {state.config.margin}
<input
type="range"
min={1}
@@ -147,34 +152,33 @@ export default function ExportPanel() {
disabled={!state.preview}
className="w-full py-2 rounded-lg bg-blue-500 text-white text-sm font-medium hover:bg-blue-600 disabled:opacity-40 transition-all"
>
SVG
{t('export.copySvg')}
</button>
<button
onClick={handleExportPng}
disabled={!state.preview || exporting}
className="w-full py-2 rounded-lg bg-green-500 text-white text-sm font-medium hover:bg-green-600 disabled:opacity-40 transition-all"
>
{exporting ? '导出中...' : '导出 PNG'}
{exporting ? t('export.exporting') : t('export.exportPng')}
</button>
<button
onClick={handleExportSvg}
disabled={!state.preview}
className="w-full py-2 rounded-lg bg-purple-500 text-white text-sm font-medium hover:bg-purple-600 disabled:opacity-40 transition-all"
>
SVG
{t('export.exportSvg')}
</button>
{/* 解码区 */}
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
<div className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">
{t('app.decode')}
</div>
<button
onClick={handleDecode}
disabled={decoding}
className="w-full py-2 rounded-lg bg-amber-500 text-white text-sm font-medium hover:bg-amber-600 disabled:opacity-40 transition-all"
>
{decoding ? '解码中...' : '选择图片解码'}
{decoding ? t('export.decoding') : t('export.selectImage')}
</button>
{decodedText && (
<div className="mt-2 p-2 bg-green-50 dark:bg-green-900/20 rounded-lg text-xs text-green-700 dark:text-green-300 break-all max-h-24 overflow-auto">
@@ -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>
+15 -3
View File
@@ -1,13 +1,25 @@
import { useTranslation } from 'react-i18next';
import { useQrState } from '../store/qrContext';
import { MODES, MODE_LABELS } from '../types';
import { MODES } from '../types';
const MODE_LABELS: 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 ModePanel() {
const { t } = useTranslation();
const { state, dispatch } = useQrState();
return (
<div className="w-48 border-r border-gray-200 dark:border-gray-800 p-3 flex flex-col gap-1 bg-white/60 dark:bg-gray-900/60 backdrop-blur-sm">
<div className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2 px-2">
{t('app.encodingModes')}
</div>
{MODES.map((mode) => (
<button
@@ -19,7 +31,7 @@ export default function ModePanel() {
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
>
{MODE_LABELS[mode]}
{t(MODE_LABELS[mode])}
</button>
))}
</div>
+11 -5
View File
@@ -1,4 +1,5 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useQrState } from '../store/qrContext';
/** SVG 字符串 → data URL(安全渲染,img 上下文阻止脚本执行) */
@@ -10,6 +11,7 @@ function svgToDataUrl(svg: string): string {
}
export default function QrPreview() {
const { t } = useTranslation();
const { state } = useQrState();
const svgDataUrl = useMemo(
@@ -24,9 +26,9 @@ export default function QrPreview() {
<div className="flex flex-col items-center justify-center gap-3 text-gray-400">
<div className={`${containerCls} border border-gray-200`}>
{state.loading ? (
<span className="text-sm animate-pulse">...</span>
<span className="text-sm animate-pulse">{t('preview.loading')}</span>
) : (
<span className="text-sm"> QR </span>
<span className="text-sm">{t('preview.empty')}</span>
)}
</div>
</div>
@@ -37,14 +39,18 @@ export default function QrPreview() {
<div className="flex flex-col items-center gap-3">
{/* 纯白背景 + 微阴影,无边框/圆角干扰扫描 */}
<div className={containerCls}>
<img src={svgDataUrl} alt="QR " className="w-60 h-60" />
<img src={svgDataUrl} alt="QR Code" className="w-60 h-60" />
</div>
<div className="flex gap-3 text-xs text-gray-400">
<span> {state.preview!.version}</span>
<span>
{t('preview.version')} {state.preview!.version}
</span>
<span>
{state.preview!.size}×{state.preview!.size}
</span>
<span> {state.preview!.mask}</span>
<span>
{t('preview.mask')} {state.preview!.mask}
</span>
</div>
</div>
);
+17
View File
@@ -0,0 +1,17 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import zh from '../public/locales/zh/translation.json';
import en from '../public/locales/en/translation.json';
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: { zh: { translation: zh }, en: { translation: en } },
fallbackLng: 'zh',
interpolation: { escapeValue: false },
});
export default i18n;
+1
View File
@@ -1,6 +1,7 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './i18n';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
+5 -3
View File
@@ -1,8 +1,10 @@
import { useTranslation } from 'react-i18next';
import { useQrState } from '../store/qrContext';
import { useQrEncode } from '../hooks/useQrEncode';
import { buildEmailText } from '../utils/qrText';
export default function EmailMode() {
const { t } = useTranslation();
const { state, dispatch } = useQrState();
const { encode } = useQrEncode();
@@ -15,19 +17,19 @@ export default function EmailMode() {
return (
<div className="flex gap-2 items-center h-full px-4">
<input
placeholder="收件人"
placeholder={t('email.to')}
value={state.formData.to || ''}
onChange={(e) => update('to', e.target.value)}
className="flex-1 px-3 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 text-sm bg-transparent outline-none focus:ring-2 focus:ring-blue-500/30"
/>
<input
placeholder="主题"
placeholder={t('email.subject')}
value={state.formData.subject || ''}
onChange={(e) => update('subject', e.target.value)}
className="flex-1 px-3 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 text-sm bg-transparent outline-none focus:ring-2 focus:ring-blue-500/30"
/>
<input
placeholder="正文"
placeholder={t('email.body')}
value={state.formData.body || ''}
onChange={(e) => update('body', e.target.value)}
className="flex-[2] px-3 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 text-sm bg-transparent outline-none focus:ring-2 focus:ring-blue-500/30"
+3 -1
View File
@@ -1,8 +1,10 @@
import { useTranslation } from 'react-i18next';
import { useQrState } from '../store/qrContext';
import { useQrEncode } from '../hooks/useQrEncode';
import { buildPhoneText } from '../utils/qrText';
export default function PhoneMode() {
const { t } = useTranslation();
const { state, dispatch } = useQrState();
const { encode } = useQrEncode();
@@ -13,7 +15,7 @@ export default function PhoneMode() {
return (
<input
placeholder="输入电话号码"
placeholder={t('phone.placeholder')}
type="tel"
value={state.formData.number || ''}
onChange={(e) => update(e.target.value)}
+4 -2
View File
@@ -1,8 +1,10 @@
import { useTranslation } from 'react-i18next';
import { useQrState } from '../store/qrContext';
import { useQrEncode } from '../hooks/useQrEncode';
import { buildSmsText } from '../utils/qrText';
export default function SmsMode() {
const { t } = useTranslation();
const { state, dispatch } = useQrState();
const { encode } = useQrEncode();
@@ -15,14 +17,14 @@ export default function SmsMode() {
return (
<div className="flex gap-2 items-center h-full px-4">
<input
placeholder="电话号码"
placeholder={t('sms.number')}
type="tel"
value={state.formData.number || ''}
onChange={(e) => update('number', e.target.value)}
className="flex-1 px-3 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 text-sm bg-transparent outline-none focus:ring-2 focus:ring-blue-500/30"
/>
<input
placeholder="短信内容"
placeholder={t('sms.message')}
value={state.formData.message || ''}
onChange={(e) => update('message', e.target.value)}
className="flex-[2] px-3 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 text-sm bg-transparent outline-none focus:ring-2 focus:ring-blue-500/30"
+3 -1
View File
@@ -1,7 +1,9 @@
import { useTranslation } from 'react-i18next';
import { useQrState } from '../store/qrContext';
import { useQrEncode } from '../hooks/useQrEncode';
export default function TextMode() {
const { t } = useTranslation();
const { state, dispatch } = useQrState();
const { encode } = useQrEncode();
@@ -12,7 +14,7 @@ export default function TextMode() {
return (
<textarea
placeholder="输入任意文本..."
placeholder={t('text.placeholder')}
value={state.formData.text || ''}
onChange={(e) => handleChange(e.target.value)}
rows={3}
+2
View File
@@ -1,7 +1,9 @@
import { useTranslation } from 'react-i18next';
import { useQrState } from '../store/qrContext';
import { useQrEncode } from '../hooks/useQrEncode';
export default function UrlMode() {
const { t } = useTranslation();
const { state, dispatch } = useQrState();
const { encode } = useQrEncode();
+8 -6
View File
@@ -1,16 +1,18 @@
import { useTranslation } from 'react-i18next';
import { useQrState } from '../store/qrContext';
import { useQrEncode } from '../hooks/useQrEncode';
import { buildVCardText } from '../utils/qrText';
const FIELDS = [
{ key: 'name', placeholder: '姓名' },
{ key: 'phone', placeholder: '电话' },
{ key: 'email', placeholder: '邮箱' },
{ key: 'company', placeholder: '公司' },
{ key: 'address', placeholder: '地址' },
{ key: 'name', i18n: 'vcard.name' },
{ key: 'phone', i18n: 'vcard.phone' },
{ key: 'email', i18n: 'vcard.email' },
{ key: 'company', i18n: 'vcard.company' },
{ key: 'address', i18n: 'vcard.address' },
];
export default function VCardMode() {
const { t } = useTranslation();
const { state, dispatch } = useQrState();
const { encode } = useQrEncode();
@@ -25,7 +27,7 @@ export default function VCardMode() {
{FIELDS.map((f) => (
<input
key={f.key}
placeholder={f.placeholder}
placeholder={t(f.i18n)}
value={state.formData[f.key] || ''}
onChange={(e) => update(f.key, e.target.value)}
className="flex-1 px-3 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 text-sm bg-transparent outline-none focus:ring-2 focus:ring-blue-500/30"
+5 -3
View File
@@ -1,8 +1,10 @@
import { useTranslation } from 'react-i18next';
import { useQrState } from '../store/qrContext';
import { useQrEncode } from '../hooks/useQrEncode';
import { buildWifiText } from '../utils/qrText';
export default function WifiMode() {
const { t } = useTranslation();
const { state, dispatch } = useQrState();
const { encode } = useQrEncode();
@@ -22,7 +24,7 @@ export default function WifiMode() {
className="flex-1 px-3 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 text-sm bg-transparent outline-none focus:ring-2 focus:ring-blue-500/30"
/>
<input
placeholder="密码"
placeholder={t('wifi.password')}
type="password"
value={state.formData.password || ''}
onChange={(e) => update('password', e.target.value)}
@@ -35,7 +37,7 @@ export default function WifiMode() {
>
<option value="WPA">WPA/WPA2</option>
<option value="WEP">WEP</option>
<option value="nopass"></option>
<option value="nopass">{t('wifi.password')}</option>
</select>
<label className="flex items-center gap-1 text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
<input
@@ -43,7 +45,7 @@ export default function WifiMode() {
checked={state.formData.hidden === 'true'}
onChange={(e) => update('hidden', e.target.checked ? 'true' : 'false')}
/>
{t('wifi.hidden')}
</label>
</div>
);