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:
@@ -32,8 +32,11 @@
|
|||||||
"@tauri-apps/plugin-dialog": "^2",
|
"@tauri-apps/plugin-dialog": "^2",
|
||||||
"@tauri-apps/plugin-fs": "^2",
|
"@tauri-apps/plugin-fs": "^2",
|
||||||
"@tauri-apps/plugin-store": "^2",
|
"@tauri-apps/plugin-store": "^2",
|
||||||
|
"i18next": "^26.3.1",
|
||||||
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1",
|
||||||
|
"react-i18next": "^17.0.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@commitlint/cli": "^19",
|
"@commitlint/cli": "^19",
|
||||||
|
|||||||
Generated
+77
@@ -23,12 +23,21 @@ importers:
|
|||||||
'@tauri-apps/plugin-store':
|
'@tauri-apps/plugin-store':
|
||||||
specifier: ^2
|
specifier: ^2
|
||||||
version: 2.4.3
|
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:
|
react:
|
||||||
specifier: ^18.3.1
|
specifier: ^18.3.1
|
||||||
version: 18.3.1
|
version: 18.3.1
|
||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^18.3.1
|
specifier: ^18.3.1
|
||||||
version: 18.3.1(react@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:
|
devDependencies:
|
||||||
'@commitlint/cli':
|
'@commitlint/cli':
|
||||||
specifier: ^19
|
specifier: ^19
|
||||||
@@ -1571,6 +1580,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==}
|
resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
html-parse-stringify@3.0.1:
|
||||||
|
resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
|
||||||
|
|
||||||
http-proxy-agent@7.0.2:
|
http-proxy-agent@7.0.2:
|
||||||
resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
|
resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
|
||||||
engines: {node: '>= 14'}
|
engines: {node: '>= 14'}
|
||||||
@@ -1588,6 +1600,17 @@ packages:
|
|||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
hasBin: true
|
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:
|
iconv-lite@0.6.3:
|
||||||
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -2141,6 +2164,22 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^18.3.1
|
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:
|
react-is@17.0.1:
|
||||||
resolution: {integrity: sha512-NAnt2iGDXohE5LI7uBnLnqvLQMtzhkiAOLXTmv+qnF9Ky7xAPcX8Up/xWIhxvLVGJvuLiNc4xQLtuqDRzb4fSA==}
|
resolution: {integrity: sha512-NAnt2iGDXohE5LI7uBnLnqvLQMtzhkiAOLXTmv+qnF9Ky7xAPcX8Up/xWIhxvLVGJvuLiNc4xQLtuqDRzb4fSA==}
|
||||||
|
|
||||||
@@ -2482,6 +2521,11 @@ packages:
|
|||||||
uri-js@4.4.1:
|
uri-js@4.4.1:
|
||||||
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
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:
|
util-deprecate@1.0.2:
|
||||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||||
|
|
||||||
@@ -2561,6 +2605,10 @@ packages:
|
|||||||
jsdom:
|
jsdom:
|
||||||
optional: true
|
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:
|
w3c-xmlserializer@5.0.0:
|
||||||
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
|
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -4115,6 +4163,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
whatwg-encoding: 3.1.1
|
whatwg-encoding: 3.1.1
|
||||||
|
|
||||||
|
html-parse-stringify@3.0.1:
|
||||||
|
dependencies:
|
||||||
|
void-elements: 3.1.0
|
||||||
|
|
||||||
http-proxy-agent@7.0.2:
|
http-proxy-agent@7.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
agent-base: 7.1.0
|
agent-base: 7.1.0
|
||||||
@@ -4133,6 +4185,14 @@ snapshots:
|
|||||||
|
|
||||||
husky@9.1.7: {}
|
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:
|
iconv-lite@0.6.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
safer-buffer: 2.1.2
|
safer-buffer: 2.1.2
|
||||||
@@ -4617,6 +4677,17 @@ snapshots:
|
|||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
scheduler: 0.23.2
|
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-is@17.0.1: {}
|
||||||
|
|
||||||
react-refresh@0.17.0: {}
|
react-refresh@0.17.0: {}
|
||||||
@@ -4968,6 +5039,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
punycode: 2.3.1
|
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: {}
|
util-deprecate@1.0.2: {}
|
||||||
|
|
||||||
validate-npm-package-license@3.0.4:
|
validate-npm-package-license@3.0.4:
|
||||||
@@ -5051,6 +5126,8 @@ snapshots:
|
|||||||
- tsx
|
- tsx
|
||||||
- yaml
|
- yaml
|
||||||
|
|
||||||
|
void-elements@3.1.0: {}
|
||||||
|
|
||||||
w3c-xmlserializer@5.0.0:
|
w3c-xmlserializer@5.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
xml-name-validator: 5.0.0
|
xml-name-validator: 5.0.0
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 图片"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { QrProvider, useQrState } from './store/qrContext';
|
import { QrProvider, useQrState } from './store/qrContext';
|
||||||
import ErrorBoundary from './components/ErrorBoundary';
|
import ErrorBoundary from './components/ErrorBoundary';
|
||||||
import ModePanel from './components/ModePanel';
|
import ModePanel from './components/ModePanel';
|
||||||
@@ -13,11 +14,26 @@ import PhoneMode from './modes/PhoneMode';
|
|||||||
import SmsMode from './modes/SmsMode';
|
import SmsMode from './modes/SmsMode';
|
||||||
|
|
||||||
function AppLayout() {
|
function AppLayout() {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
|
||||||
|
const toggleLang = () => {
|
||||||
|
i18n.changeLanguage(i18n.language.startsWith('en') ? 'zh' : 'en');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex flex-col bg-gray-50 dark:bg-gray-950">
|
<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">
|
<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">
|
||||||
|
🄲 {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>
|
</div>
|
||||||
|
|
||||||
{/* 三栏主体 */}
|
{/* 三栏主体 */}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { Component, type ReactNode } from 'react';
|
import React, { Component, type ReactNode } from 'react';
|
||||||
|
import { withTranslation, type WithTranslation } from 'react-i18next';
|
||||||
|
|
||||||
interface Props {
|
interface Props extends WithTranslation {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
interface State {
|
interface State {
|
||||||
@@ -8,7 +9,7 @@ interface State {
|
|||||||
error: Error | null;
|
error: Error | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class ErrorBoundary extends Component<Props, State> {
|
class ErrorBoundaryInner extends Component<Props, State> {
|
||||||
state: State = { hasError: false, error: null };
|
state: State = { hasError: false, error: null };
|
||||||
|
|
||||||
static getDerivedStateFromError(error: Error) {
|
static getDerivedStateFromError(error: Error) {
|
||||||
@@ -16,23 +17,22 @@ export default class ErrorBoundary extends Component<Props, State> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
||||||
// 生产环境错误日志记录入口
|
console.error('QRGen ErrorBoundary:', error.message, info.componentStack);
|
||||||
// TODO: 集成遥测服务后将错误上报
|
|
||||||
console.error('QRGen ErrorBoundary 捕获错误:', error.message, info.componentStack);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
const { t } = this.props;
|
||||||
if (this.state.hasError) {
|
if (this.state.hasError) {
|
||||||
return (
|
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">
|
<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>
|
<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>
|
<p className="text-sm max-w-md text-center">{this.state.error?.message}</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => window.location.reload()}
|
onClick={() => window.location.reload()}
|
||||||
className="px-4 py-2 rounded-lg bg-blue-500 text-white text-sm hover:bg-blue-600 transition-all"
|
className="px-4 py-2 rounded-lg bg-blue-500 text-white text-sm hover:bg-blue-600 transition-all"
|
||||||
>
|
>
|
||||||
重新加载
|
{t('error.reload')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -40,3 +40,6 @@ export default class ErrorBoundary extends Component<Props, State> {
|
|||||||
return this.props.children;
|
return this.props.children;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ErrorBoundary = withTranslation()(ErrorBoundaryInner);
|
||||||
|
export default ErrorBoundary;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useQrState } from '../store/qrContext';
|
import { useQrState } from '../store/qrContext';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
|
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
|
||||||
@@ -8,6 +9,7 @@ import type { QrConfig } from '../types';
|
|||||||
import { buildEncodedText } from '../utils/qrText';
|
import { buildEncodedText } from '../utils/qrText';
|
||||||
|
|
||||||
export default function ExportPanel() {
|
export default function ExportPanel() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { state, dispatch } = useQrState();
|
const { state, dispatch } = useQrState();
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||||
@@ -20,7 +22,9 @@ export default function ExportPanel() {
|
|||||||
setDecodedText(null);
|
setDecodedText(null);
|
||||||
try {
|
try {
|
||||||
const filePath = await open({
|
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,
|
multiple: false,
|
||||||
});
|
});
|
||||||
if (!filePath) {
|
if (!filePath) {
|
||||||
@@ -31,7 +35,7 @@ export default function ExportPanel() {
|
|||||||
const text: string = await invoke('decode_qr', { imageBytes: Array.from(bytes) });
|
const text: string = await invoke('decode_qr', { imageBytes: Array.from(bytes) });
|
||||||
setDecodedText(text);
|
setDecodedText(text);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErrorMsg(`解码失败: ${e}`);
|
setErrorMsg(`${t('error.decodeFailed')}: ${e}`);
|
||||||
}
|
}
|
||||||
setDecoding(false);
|
setDecoding(false);
|
||||||
};
|
};
|
||||||
@@ -41,7 +45,7 @@ export default function ExportPanel() {
|
|||||||
try {
|
try {
|
||||||
await writeText(state.preview.svg);
|
await writeText(state.preview.svg);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErrorMsg(`复制失败: ${e}`);
|
setErrorMsg(`${t('error.copyFailed')}: ${e}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -51,14 +55,13 @@ export default function ExportPanel() {
|
|||||||
setErrorMsg(null);
|
setErrorMsg(null);
|
||||||
try {
|
try {
|
||||||
const filePath = await save({
|
const filePath = await save({
|
||||||
filters: [{ name: 'PNG 图片', extensions: ['png'] }],
|
filters: [{ name: t('dialog.pngImage'), extensions: ['png'] }],
|
||||||
defaultPath: 'qrcode.png',
|
defaultPath: 'qrcode.png',
|
||||||
});
|
});
|
||||||
if (!filePath) {
|
if (!filePath) {
|
||||||
setExporting(false);
|
setExporting(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bytes: number[] = await invoke('export_png', {
|
const bytes: number[] = await invoke('export_png', {
|
||||||
text: buildEncodedText(state.mode, state.formData),
|
text: buildEncodedText(state.mode, state.formData),
|
||||||
level: state.config.level,
|
level: state.config.level,
|
||||||
@@ -67,7 +70,7 @@ export default function ExportPanel() {
|
|||||||
});
|
});
|
||||||
await writeFile(filePath, new Uint8Array(bytes));
|
await writeFile(filePath, new Uint8Array(bytes));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErrorMsg(`导出 PNG 失败: ${e}`);
|
setErrorMsg(`${t('error.exportPngFailed')}: ${e}`);
|
||||||
}
|
}
|
||||||
setExporting(false);
|
setExporting(false);
|
||||||
};
|
};
|
||||||
@@ -77,19 +80,21 @@ export default function ExportPanel() {
|
|||||||
setErrorMsg(null);
|
setErrorMsg(null);
|
||||||
try {
|
try {
|
||||||
const filePath = await save({
|
const filePath = await save({
|
||||||
filters: [{ name: 'SVG 图片', extensions: ['svg'] }],
|
filters: [{ name: t('dialog.svgImage'), extensions: ['svg'] }],
|
||||||
defaultPath: 'qrcode.svg',
|
defaultPath: 'qrcode.svg',
|
||||||
});
|
});
|
||||||
if (!filePath) return;
|
if (!filePath) return;
|
||||||
await writeFile(filePath, new TextEncoder().encode(state.preview.svg));
|
await writeFile(filePath, new TextEncoder().encode(state.preview.svg));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErrorMsg(`导出 SVG 失败: ${e}`);
|
setErrorMsg(`${t('error.exportSvgFailed')}: ${e}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<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 && (
|
{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">
|
<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">
|
<label className="text-xs text-gray-600 dark:text-gray-400">
|
||||||
纠错级别
|
{t('export.eccLevel')}
|
||||||
<select
|
<select
|
||||||
value={state.config.level}
|
value={state.config.level}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
@@ -117,7 +122,7 @@ export default function ExportPanel() {
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="text-xs text-gray-600 dark:text-gray-400">
|
<label className="text-xs text-gray-600 dark:text-gray-400">
|
||||||
模块大小: {state.config.moduleSize}px
|
{t('export.moduleSize')}: {state.config.moduleSize}px
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={2}
|
min={2}
|
||||||
@@ -131,7 +136,7 @@ export default function ExportPanel() {
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="text-xs text-gray-600 dark:text-gray-400">
|
<label className="text-xs text-gray-600 dark:text-gray-400">
|
||||||
边距: {state.config.margin}
|
{t('export.margin')}: {state.config.margin}
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={1}
|
min={1}
|
||||||
@@ -147,34 +152,33 @@ export default function ExportPanel() {
|
|||||||
disabled={!state.preview}
|
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"
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={handleExportPng}
|
onClick={handleExportPng}
|
||||||
disabled={!state.preview || exporting}
|
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"
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={handleExportSvg}
|
onClick={handleExportSvg}
|
||||||
disabled={!state.preview}
|
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"
|
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>
|
</button>
|
||||||
|
|
||||||
{/* 解码区 */}
|
|
||||||
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
|
<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">
|
<div className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">
|
||||||
解码
|
{t('app.decode')}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleDecode}
|
onClick={handleDecode}
|
||||||
disabled={decoding}
|
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"
|
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>
|
</button>
|
||||||
{decodedText && (
|
{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">
|
<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 { useQrState } from '../store/qrContext';
|
||||||
import { MODE_LABELS, type HistoryEntry } from '../types';
|
import { type HistoryEntry } from '../types';
|
||||||
import { persistHistory } from '../hooks/useQrEncode';
|
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() {
|
export default function HistoryList() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { state, dispatch } = useQrState();
|
const { state, dispatch } = useQrState();
|
||||||
|
|
||||||
const handleClick = (entry: HistoryEntry) => {
|
const handleClick = (entry: HistoryEntry) => {
|
||||||
@@ -36,21 +48,21 @@ export default function HistoryList() {
|
|||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
||||||
📋 历史记录
|
📋 {t('history.title')}
|
||||||
</span>
|
</span>
|
||||||
{state.history.length > 0 && (
|
{state.history.length > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={handleClear}
|
onClick={handleClear}
|
||||||
className="text-xs text-red-400 hover:text-red-600 transition-colors"
|
className="text-xs text-red-400 hover:text-red-600 transition-colors"
|
||||||
>
|
>
|
||||||
清空
|
{t('history.clear')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto space-y-1">
|
<div className="flex-1 overflow-y-auto space-y-1">
|
||||||
{state.history.length === 0 && (
|
{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) => (
|
{state.history.map((entry) => (
|
||||||
<div
|
<div
|
||||||
@@ -61,7 +73,7 @@ export default function HistoryList() {
|
|||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-1.5">
|
<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">
|
<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>
|
||||||
<span className="text-gray-400">{formatTime(entry.timestamp)}</span>
|
<span className="text-gray-400">{formatTime(entry.timestamp)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,25 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useQrState } from '../store/qrContext';
|
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() {
|
export default function ModePanel() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { state, dispatch } = useQrState();
|
const { state, dispatch } = useQrState();
|
||||||
|
|
||||||
return (
|
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="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">
|
<div className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2 px-2">
|
||||||
编码模式
|
{t('app.encodingModes')}
|
||||||
</div>
|
</div>
|
||||||
{MODES.map((mode) => (
|
{MODES.map((mode) => (
|
||||||
<button
|
<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'
|
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{MODE_LABELS[mode]}
|
{t(MODE_LABELS[mode])}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useQrState } from '../store/qrContext';
|
import { useQrState } from '../store/qrContext';
|
||||||
|
|
||||||
/** SVG 字符串 → data URL(安全渲染,img 上下文阻止脚本执行) */
|
/** SVG 字符串 → data URL(安全渲染,img 上下文阻止脚本执行) */
|
||||||
@@ -10,6 +11,7 @@ function svgToDataUrl(svg: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function QrPreview() {
|
export default function QrPreview() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { state } = useQrState();
|
const { state } = useQrState();
|
||||||
|
|
||||||
const svgDataUrl = useMemo(
|
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="flex flex-col items-center justify-center gap-3 text-gray-400">
|
||||||
<div className={`${containerCls} border border-gray-200`}>
|
<div className={`${containerCls} border border-gray-200`}>
|
||||||
{state.loading ? (
|
{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>
|
||||||
</div>
|
</div>
|
||||||
@@ -37,14 +39,18 @@ export default function QrPreview() {
|
|||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
{/* 纯白背景 + 微阴影,无边框/圆角干扰扫描 */}
|
{/* 纯白背景 + 微阴影,无边框/圆角干扰扫描 */}
|
||||||
<div className={containerCls}>
|
<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>
|
||||||
<div className="flex gap-3 text-xs text-gray-400">
|
<div className="flex gap-3 text-xs text-gray-400">
|
||||||
<span>版本 {state.preview!.version}</span>
|
<span>
|
||||||
|
{t('preview.version')} {state.preview!.version}
|
||||||
|
</span>
|
||||||
<span>
|
<span>
|
||||||
{state.preview!.size}×{state.preview!.size}
|
{state.preview!.size}×{state.preview!.size}
|
||||||
</span>
|
</span>
|
||||||
<span>掩码 {state.preview!.mask}</span>
|
<span>
|
||||||
|
{t('preview.mask')} {state.preview!.mask}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
import './i18n';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useQrState } from '../store/qrContext';
|
import { useQrState } from '../store/qrContext';
|
||||||
import { useQrEncode } from '../hooks/useQrEncode';
|
import { useQrEncode } from '../hooks/useQrEncode';
|
||||||
import { buildEmailText } from '../utils/qrText';
|
import { buildEmailText } from '../utils/qrText';
|
||||||
|
|
||||||
export default function EmailMode() {
|
export default function EmailMode() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { state, dispatch } = useQrState();
|
const { state, dispatch } = useQrState();
|
||||||
const { encode } = useQrEncode();
|
const { encode } = useQrEncode();
|
||||||
|
|
||||||
@@ -15,19 +17,19 @@ export default function EmailMode() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex gap-2 items-center h-full px-4">
|
<div className="flex gap-2 items-center h-full px-4">
|
||||||
<input
|
<input
|
||||||
placeholder="收件人"
|
placeholder={t('email.to')}
|
||||||
value={state.formData.to || ''}
|
value={state.formData.to || ''}
|
||||||
onChange={(e) => update('to', e.target.value)}
|
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"
|
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
|
<input
|
||||||
placeholder="主题"
|
placeholder={t('email.subject')}
|
||||||
value={state.formData.subject || ''}
|
value={state.formData.subject || ''}
|
||||||
onChange={(e) => update('subject', e.target.value)}
|
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"
|
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
|
<input
|
||||||
placeholder="正文"
|
placeholder={t('email.body')}
|
||||||
value={state.formData.body || ''}
|
value={state.formData.body || ''}
|
||||||
onChange={(e) => update('body', e.target.value)}
|
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"
|
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"
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useQrState } from '../store/qrContext';
|
import { useQrState } from '../store/qrContext';
|
||||||
import { useQrEncode } from '../hooks/useQrEncode';
|
import { useQrEncode } from '../hooks/useQrEncode';
|
||||||
import { buildPhoneText } from '../utils/qrText';
|
import { buildPhoneText } from '../utils/qrText';
|
||||||
|
|
||||||
export default function PhoneMode() {
|
export default function PhoneMode() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { state, dispatch } = useQrState();
|
const { state, dispatch } = useQrState();
|
||||||
const { encode } = useQrEncode();
|
const { encode } = useQrEncode();
|
||||||
|
|
||||||
@@ -13,7 +15,7 @@ export default function PhoneMode() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
placeholder="输入电话号码"
|
placeholder={t('phone.placeholder')}
|
||||||
type="tel"
|
type="tel"
|
||||||
value={state.formData.number || ''}
|
value={state.formData.number || ''}
|
||||||
onChange={(e) => update(e.target.value)}
|
onChange={(e) => update(e.target.value)}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useQrState } from '../store/qrContext';
|
import { useQrState } from '../store/qrContext';
|
||||||
import { useQrEncode } from '../hooks/useQrEncode';
|
import { useQrEncode } from '../hooks/useQrEncode';
|
||||||
import { buildSmsText } from '../utils/qrText';
|
import { buildSmsText } from '../utils/qrText';
|
||||||
|
|
||||||
export default function SmsMode() {
|
export default function SmsMode() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { state, dispatch } = useQrState();
|
const { state, dispatch } = useQrState();
|
||||||
const { encode } = useQrEncode();
|
const { encode } = useQrEncode();
|
||||||
|
|
||||||
@@ -15,14 +17,14 @@ export default function SmsMode() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex gap-2 items-center h-full px-4">
|
<div className="flex gap-2 items-center h-full px-4">
|
||||||
<input
|
<input
|
||||||
placeholder="电话号码"
|
placeholder={t('sms.number')}
|
||||||
type="tel"
|
type="tel"
|
||||||
value={state.formData.number || ''}
|
value={state.formData.number || ''}
|
||||||
onChange={(e) => update('number', e.target.value)}
|
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"
|
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
|
<input
|
||||||
placeholder="短信内容"
|
placeholder={t('sms.message')}
|
||||||
value={state.formData.message || ''}
|
value={state.formData.message || ''}
|
||||||
onChange={(e) => update('message', e.target.value)}
|
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"
|
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"
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useQrState } from '../store/qrContext';
|
import { useQrState } from '../store/qrContext';
|
||||||
import { useQrEncode } from '../hooks/useQrEncode';
|
import { useQrEncode } from '../hooks/useQrEncode';
|
||||||
|
|
||||||
export default function TextMode() {
|
export default function TextMode() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { state, dispatch } = useQrState();
|
const { state, dispatch } = useQrState();
|
||||||
const { encode } = useQrEncode();
|
const { encode } = useQrEncode();
|
||||||
|
|
||||||
@@ -12,7 +14,7 @@ export default function TextMode() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<textarea
|
<textarea
|
||||||
placeholder="输入任意文本..."
|
placeholder={t('text.placeholder')}
|
||||||
value={state.formData.text || ''}
|
value={state.formData.text || ''}
|
||||||
onChange={(e) => handleChange(e.target.value)}
|
onChange={(e) => handleChange(e.target.value)}
|
||||||
rows={3}
|
rows={3}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useQrState } from '../store/qrContext';
|
import { useQrState } from '../store/qrContext';
|
||||||
import { useQrEncode } from '../hooks/useQrEncode';
|
import { useQrEncode } from '../hooks/useQrEncode';
|
||||||
|
|
||||||
export default function UrlMode() {
|
export default function UrlMode() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { state, dispatch } = useQrState();
|
const { state, dispatch } = useQrState();
|
||||||
const { encode } = useQrEncode();
|
const { encode } = useQrEncode();
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useQrState } from '../store/qrContext';
|
import { useQrState } from '../store/qrContext';
|
||||||
import { useQrEncode } from '../hooks/useQrEncode';
|
import { useQrEncode } from '../hooks/useQrEncode';
|
||||||
import { buildVCardText } from '../utils/qrText';
|
import { buildVCardText } from '../utils/qrText';
|
||||||
|
|
||||||
const FIELDS = [
|
const FIELDS = [
|
||||||
{ key: 'name', placeholder: '姓名' },
|
{ key: 'name', i18n: 'vcard.name' },
|
||||||
{ key: 'phone', placeholder: '电话' },
|
{ key: 'phone', i18n: 'vcard.phone' },
|
||||||
{ key: 'email', placeholder: '邮箱' },
|
{ key: 'email', i18n: 'vcard.email' },
|
||||||
{ key: 'company', placeholder: '公司' },
|
{ key: 'company', i18n: 'vcard.company' },
|
||||||
{ key: 'address', placeholder: '地址' },
|
{ key: 'address', i18n: 'vcard.address' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function VCardMode() {
|
export default function VCardMode() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { state, dispatch } = useQrState();
|
const { state, dispatch } = useQrState();
|
||||||
const { encode } = useQrEncode();
|
const { encode } = useQrEncode();
|
||||||
|
|
||||||
@@ -25,7 +27,7 @@ export default function VCardMode() {
|
|||||||
{FIELDS.map((f) => (
|
{FIELDS.map((f) => (
|
||||||
<input
|
<input
|
||||||
key={f.key}
|
key={f.key}
|
||||||
placeholder={f.placeholder}
|
placeholder={t(f.i18n)}
|
||||||
value={state.formData[f.key] || ''}
|
value={state.formData[f.key] || ''}
|
||||||
onChange={(e) => update(f.key, e.target.value)}
|
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"
|
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"
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useQrState } from '../store/qrContext';
|
import { useQrState } from '../store/qrContext';
|
||||||
import { useQrEncode } from '../hooks/useQrEncode';
|
import { useQrEncode } from '../hooks/useQrEncode';
|
||||||
import { buildWifiText } from '../utils/qrText';
|
import { buildWifiText } from '../utils/qrText';
|
||||||
|
|
||||||
export default function WifiMode() {
|
export default function WifiMode() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { state, dispatch } = useQrState();
|
const { state, dispatch } = useQrState();
|
||||||
const { encode } = useQrEncode();
|
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"
|
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
|
<input
|
||||||
placeholder="密码"
|
placeholder={t('wifi.password')}
|
||||||
type="password"
|
type="password"
|
||||||
value={state.formData.password || ''}
|
value={state.formData.password || ''}
|
||||||
onChange={(e) => update('password', e.target.value)}
|
onChange={(e) => update('password', e.target.value)}
|
||||||
@@ -35,7 +37,7 @@ export default function WifiMode() {
|
|||||||
>
|
>
|
||||||
<option value="WPA">WPA/WPA2</option>
|
<option value="WPA">WPA/WPA2</option>
|
||||||
<option value="WEP">WEP</option>
|
<option value="WEP">WEP</option>
|
||||||
<option value="nopass">无密码</option>
|
<option value="nopass">无{t('wifi.password')}</option>
|
||||||
</select>
|
</select>
|
||||||
<label className="flex items-center gap-1 text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
<label className="flex items-center gap-1 text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||||
<input
|
<input
|
||||||
@@ -43,7 +45,7 @@ export default function WifiMode() {
|
|||||||
checked={state.formData.hidden === 'true'}
|
checked={state.formData.hidden === 'true'}
|
||||||
onChange={(e) => update('hidden', e.target.checked ? 'true' : 'false')}
|
onChange={(e) => update('hidden', e.target.checked ? 'true' : 'false')}
|
||||||
/>
|
/>
|
||||||
隐藏
|
{t('wifi.hidden')}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user