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() { )}