From 77fac0e28f0da856e783c0256f94382a22ff4754 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=88=98=E8=88=AA=E5=AE=87?= <3364451258@qq.com>
Date: Fri, 19 Jun 2026 21:23:10 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20i18n=20=E4=B8=AD=E8=8B=B1=E5=8F=8C?=
=?UTF-8?q?=E8=AF=AD=E7=95=8C=E9=9D=A2=20=E2=80=94=20i18next=20+=20react-i?=
=?UTF-8?q?18next?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 安装 i18next / react-i18next / i18next-browser-languagedetector
- 新建 src/i18n.ts 配置(fallback zh)
- 中/英翻译文件各 ~50 条目
- App.tsx 新增 EN/中 语言切换按钮
- ExportPanel + QrPreview + ModePanel + HistoryList + ErrorBoundary
- 全部 7 种模式组件均支持双语
- 12 前端测试通过,tsc 零错误
---
gui/src-frontend/package.json | 5 +-
gui/src-frontend/pnpm-lock.yaml | 77 ++++++++++++++++++
.../public/locales/en/translation.json | 80 +++++++++++++++++++
.../public/locales/zh/translation.json | 80 +++++++++++++++++++
gui/src-frontend/src/App.tsx | 18 ++++-
.../src/components/ErrorBoundary.tsx | 17 ++--
.../src/components/ExportPanel.tsx | 40 +++++-----
.../src/components/HistoryList.tsx | 22 +++--
gui/src-frontend/src/components/ModePanel.tsx | 18 ++++-
gui/src-frontend/src/components/QrPreview.tsx | 16 ++--
gui/src-frontend/src/i18n.ts | 17 ++++
gui/src-frontend/src/main.tsx | 1 +
gui/src-frontend/src/modes/EmailMode.tsx | 8 +-
gui/src-frontend/src/modes/PhoneMode.tsx | 4 +-
gui/src-frontend/src/modes/SmsMode.tsx | 6 +-
gui/src-frontend/src/modes/TextMode.tsx | 4 +-
gui/src-frontend/src/modes/UrlMode.tsx | 2 +
gui/src-frontend/src/modes/VCardMode.tsx | 14 ++--
gui/src-frontend/src/modes/WifiMode.tsx | 8 +-
19 files changed, 381 insertions(+), 56 deletions(-)
create mode 100644 gui/src-frontend/public/locales/en/translation.json
create mode 100644 gui/src-frontend/public/locales/zh/translation.json
create mode 100644 gui/src-frontend/src/i18n.ts
diff --git a/gui/src-frontend/package.json b/gui/src-frontend/package.json
index 561f350..4732d70 100644
--- a/gui/src-frontend/package.json
+++ b/gui/src-frontend/package.json
@@ -32,8 +32,11 @@
"@tauri-apps/plugin-dialog": "^2",
"@tauri-apps/plugin-fs": "^2",
"@tauri-apps/plugin-store": "^2",
+ "i18next": "^26.3.1",
+ "i18next-browser-languagedetector": "^8.2.1",
"react": "^18.3.1",
- "react-dom": "^18.3.1"
+ "react-dom": "^18.3.1",
+ "react-i18next": "^17.0.8"
},
"devDependencies": {
"@commitlint/cli": "^19",
diff --git a/gui/src-frontend/pnpm-lock.yaml b/gui/src-frontend/pnpm-lock.yaml
index 8569773..700e9f4 100644
--- a/gui/src-frontend/pnpm-lock.yaml
+++ b/gui/src-frontend/pnpm-lock.yaml
@@ -23,12 +23,21 @@ importers:
'@tauri-apps/plugin-store':
specifier: ^2
version: 2.4.3
+ i18next:
+ specifier: ^26.3.1
+ version: 26.3.1(typescript@5.9.3)
+ i18next-browser-languagedetector:
+ specifier: ^8.2.1
+ version: 8.2.1
react:
specifier: ^18.3.1
version: 18.3.1
react-dom:
specifier: ^18.3.1
version: 18.3.1(react@18.3.1)
+ react-i18next:
+ specifier: ^17.0.8
+ version: 17.0.8(i18next@26.3.1(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)
devDependencies:
'@commitlint/cli':
specifier: ^19
@@ -1571,6 +1580,9 @@ packages:
resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==}
engines: {node: '>=18'}
+ html-parse-stringify@3.0.1:
+ resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
+
http-proxy-agent@7.0.2:
resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
engines: {node: '>= 14'}
@@ -1588,6 +1600,17 @@ packages:
engines: {node: '>=18'}
hasBin: true
+ i18next-browser-languagedetector@8.2.1:
+ resolution: {integrity: sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==}
+
+ i18next@26.3.1:
+ resolution: {integrity: sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==}
+ peerDependencies:
+ typescript: ^5 || ^6
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
iconv-lite@0.6.3:
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
engines: {node: '>=0.10.0'}
@@ -2141,6 +2164,22 @@ packages:
peerDependencies:
react: ^18.3.1
+ react-i18next@17.0.8:
+ resolution: {integrity: sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==}
+ peerDependencies:
+ i18next: '>= 26.2.0'
+ react: '>= 16.8.0'
+ react-dom: '*'
+ react-native: '*'
+ typescript: ^5 || ^6
+ peerDependenciesMeta:
+ react-dom:
+ optional: true
+ react-native:
+ optional: true
+ typescript:
+ optional: true
+
react-is@17.0.1:
resolution: {integrity: sha512-NAnt2iGDXohE5LI7uBnLnqvLQMtzhkiAOLXTmv+qnF9Ky7xAPcX8Up/xWIhxvLVGJvuLiNc4xQLtuqDRzb4fSA==}
@@ -2482,6 +2521,11 @@ packages:
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
+ use-sync-external-store@1.6.0:
+ resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
@@ -2561,6 +2605,10 @@ packages:
jsdom:
optional: true
+ void-elements@3.1.0:
+ resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
+ engines: {node: '>=0.10.0'}
+
w3c-xmlserializer@5.0.0:
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
engines: {node: '>=18'}
@@ -4115,6 +4163,10 @@ snapshots:
dependencies:
whatwg-encoding: 3.1.1
+ html-parse-stringify@3.0.1:
+ dependencies:
+ void-elements: 3.1.0
+
http-proxy-agent@7.0.2:
dependencies:
agent-base: 7.1.0
@@ -4133,6 +4185,14 @@ snapshots:
husky@9.1.7: {}
+ i18next-browser-languagedetector@8.2.1:
+ dependencies:
+ '@babel/runtime': 7.29.7
+
+ i18next@26.3.1(typescript@5.9.3):
+ optionalDependencies:
+ typescript: 5.9.3
+
iconv-lite@0.6.3:
dependencies:
safer-buffer: 2.1.2
@@ -4617,6 +4677,17 @@ snapshots:
react: 18.3.1
scheduler: 0.23.2
+ react-i18next@17.0.8(i18next@26.3.1(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3):
+ dependencies:
+ '@babel/runtime': 7.29.7
+ html-parse-stringify: 3.0.1
+ i18next: 26.3.1(typescript@5.9.3)
+ react: 18.3.1
+ use-sync-external-store: 1.6.0(react@18.3.1)
+ optionalDependencies:
+ react-dom: 18.3.1(react@18.3.1)
+ typescript: 5.9.3
+
react-is@17.0.1: {}
react-refresh@0.17.0: {}
@@ -4968,6 +5039,10 @@ snapshots:
dependencies:
punycode: 2.3.1
+ use-sync-external-store@1.6.0(react@18.3.1):
+ dependencies:
+ react: 18.3.1
+
util-deprecate@1.0.2: {}
validate-npm-package-license@3.0.4:
@@ -5051,6 +5126,8 @@ snapshots:
- tsx
- yaml
+ void-elements@3.1.0: {}
+
w3c-xmlserializer@5.0.0:
dependencies:
xml-name-validator: 5.0.0
diff --git a/gui/src-frontend/public/locales/en/translation.json b/gui/src-frontend/public/locales/en/translation.json
new file mode 100644
index 0000000..608a52b
--- /dev/null
+++ b/gui/src-frontend/public/locales/en/translation.json
@@ -0,0 +1,80 @@
+{
+ "app": {
+ "title": "QRGen",
+ "encodingModes": "Encoding Modes",
+ "exportOptions": "Export Options",
+ "decode": "Decode"
+ },
+ "mode": {
+ "text": "Text",
+ "url": "URL",
+ "wifi": "WiFi",
+ "vcard": "vCard",
+ "email": "Email",
+ "phone": "Phone",
+ "sms": "SMS"
+ },
+ "export": {
+ "eccLevel": "ECC Level",
+ "moduleSize": "Module Size",
+ "margin": "Margin",
+ "copySvg": "Copy SVG",
+ "exportPng": "Export PNG",
+ "exportSvg": "Export SVG",
+ "exporting": "Exporting...",
+ "selectImage": "Select Image to Decode",
+ "decoding": "Decoding..."
+ },
+ "preview": {
+ "loading": "Generating...",
+ "empty": "Enter content to generate QR code",
+ "version": "Version",
+ "mask": "Mask"
+ },
+ "history": {
+ "title": "History",
+ "clear": "Clear",
+ "empty": "No records"
+ },
+ "wifi": {
+ "ssid": "SSID",
+ "password": "Password",
+ "none": "None",
+ "hidden": "Hidden"
+ },
+ "vcard": {
+ "name": "Name",
+ "phone": "Phone",
+ "email": "Email",
+ "company": "Company",
+ "address": "Address"
+ },
+ "email": {
+ "to": "To",
+ "subject": "Subject",
+ "body": "Body"
+ },
+ "phone": {
+ "placeholder": "Enter phone number"
+ },
+ "sms": {
+ "number": "Phone Number",
+ "message": "Message"
+ },
+ "text": {
+ "placeholder": "Enter text..."
+ },
+ "error": {
+ "appError": "Application Error",
+ "reload": "Reload",
+ "decodeFailed": "Decode Failed",
+ "exportPngFailed": "PNG Export Failed",
+ "exportSvgFailed": "SVG Export Failed",
+ "copyFailed": "Copy Failed"
+ },
+ "dialog": {
+ "imageFiles": "Image Files",
+ "pngImage": "PNG Image",
+ "svgImage": "SVG Image"
+ }
+}
diff --git a/gui/src-frontend/public/locales/zh/translation.json b/gui/src-frontend/public/locales/zh/translation.json
new file mode 100644
index 0000000..04e7e07
--- /dev/null
+++ b/gui/src-frontend/public/locales/zh/translation.json
@@ -0,0 +1,80 @@
+{
+ "app": {
+ "title": "QRGen",
+ "encodingModes": "编码模式",
+ "exportOptions": "导出选项",
+ "decode": "解码"
+ },
+ "mode": {
+ "text": "文本",
+ "url": "URL",
+ "wifi": "WiFi",
+ "vcard": "vCard",
+ "email": "Email",
+ "phone": "电话",
+ "sms": "SMS"
+ },
+ "export": {
+ "eccLevel": "纠错级别",
+ "moduleSize": "模块大小",
+ "margin": "边距",
+ "copySvg": "复制 SVG",
+ "exportPng": "导出 PNG",
+ "exportSvg": "导出 SVG",
+ "exporting": "导出中...",
+ "selectImage": "选择图片解码",
+ "decoding": "解码中..."
+ },
+ "preview": {
+ "loading": "生成中...",
+ "empty": "输入内容生成 QR 码",
+ "version": "版本",
+ "mask": "掩码"
+ },
+ "history": {
+ "title": "历史记录",
+ "clear": "清空",
+ "empty": "暂无记录"
+ },
+ "wifi": {
+ "ssid": "SSID",
+ "password": "密码",
+ "none": "无密码",
+ "hidden": "隐藏"
+ },
+ "vcard": {
+ "name": "姓名",
+ "phone": "电话",
+ "email": "邮箱",
+ "company": "公司",
+ "address": "地址"
+ },
+ "email": {
+ "to": "收件人",
+ "subject": "主题",
+ "body": "正文"
+ },
+ "phone": {
+ "placeholder": "输入电话号码"
+ },
+ "sms": {
+ "number": "电话号码",
+ "message": "短信内容"
+ },
+ "text": {
+ "placeholder": "输入任意文本..."
+ },
+ "error": {
+ "appError": "应用发生错误",
+ "reload": "重新加载",
+ "decodeFailed": "解码失败",
+ "exportPngFailed": "导出 PNG 失败",
+ "exportSvgFailed": "导出 SVG 失败",
+ "copyFailed": "复制失败"
+ },
+ "dialog": {
+ "imageFiles": "图片文件",
+ "pngImage": "PNG 图片",
+ "svgImage": "SVG 图片"
+ }
+}
diff --git a/gui/src-frontend/src/App.tsx b/gui/src-frontend/src/App.tsx
index 12a6b60..0e8033b 100644
--- a/gui/src-frontend/src/App.tsx
+++ b/gui/src-frontend/src/App.tsx
@@ -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 (
{/* 顶部标题栏 */}
-
🀫 QRGen
+
+ 🄲 {t('app.title')}
+
+
+
{/* 三栏主体 */}
diff --git a/gui/src-frontend/src/components/ErrorBoundary.tsx b/gui/src-frontend/src/components/ErrorBoundary.tsx
index 98d46e5..eb77071 100644
--- a/gui/src-frontend/src/components/ErrorBoundary.tsx
+++ b/gui/src-frontend/src/components/ErrorBoundary.tsx
@@ -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
{
+class ErrorBoundaryInner extends Component {
state: State = { hasError: false, error: null };
static getDerivedStateFromError(error: Error) {
@@ -16,23 +17,22 @@ export default class ErrorBoundary extends Component {
}
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 (
⚠
-
应用发生错误
+
{t('error.appError')}
{this.state.error?.message}
);
@@ -40,3 +40,6 @@ export default class ErrorBoundary extends Component {
return this.props.children;
}
}
+
+const ErrorBoundary = withTranslation()(ErrorBoundaryInner);
+export default ErrorBoundary;
diff --git a/gui/src-frontend/src/components/ExportPanel.tsx b/gui/src-frontend/src/components/ExportPanel.tsx
index 3ad3172..937da9a 100644
--- a/gui/src-frontend/src/components/ExportPanel.tsx
+++ b/gui/src-frontend/src/components/ExportPanel.tsx
@@ -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(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 (
-
导出选项
+
+ {t('app.exportOptions')}
+
{errorMsg && (
@@ -98,7 +103,7 @@ export default function ExportPanel() {
)}