From 99d2e9d8311831b92129a6837cbc360b1daa32b4 Mon Sep 17 00:00:00 2001 From: baiqing Date: Thu, 30 Apr 2026 11:36:16 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(i18n):=20=E5=BC=95=E5=85=A5=E8=AF=AD?= =?UTF-8?q?=E8=A8=80=E5=8C=85=E7=B3=BB=E7=BB=9F=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?=20zh-CN=20/=20en=20=E5=88=87=E6=8D=A2=20+=20=E8=B7=9F=E9=9A=8F?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 接入 react-i18next + i18next-browser-languagedetector,资源以 TS 模块静态打包 - 资源 src/i18n/{zh-CN,en}.ts;en.ts 通过 typeof 绑定 zh-CN 形状,缺 key 编译报错 - 全部 UI 表面(胶囊、Onboarding、主窗口侧栏/页脚、Overview/History/Vocab/Style 页、设置弹窗、设置页各分区、WindowChrome)改走 t() 查找 - SettingsSectionId 从 zh-CN 字面量切换为稳定 EN id(recording/providers/...);ModalSectionId 同改 - 设置 → 语言 分区新增「跟随系统 / 简体中文 / English」切换器,localStorage 'ol.locale' 持久化 - 首启检测顺序 localStorage → navigator.language;fallback 永远 zh-CN - lib/hotkey.ts 改用 i18n.t 直接渲染触发键 / 适配器名称 Closes #40 --- openless-all/app/package-lock.json | 111 +++++- openless-all/app/package.json | 5 +- openless-all/app/src/components/Capsule.tsx | 10 +- .../app/src/components/FloatingShell.tsx | 49 +-- .../app/src/components/Onboarding.tsx | 34 +- .../app/src/components/SettingsModal.tsx | 70 ++-- .../app/src/components/WindowChrome.tsx | 8 +- openless-all/app/src/i18n/en.ts | 323 ++++++++++++++++++ openless-all/app/src/i18n/index.ts | 68 ++++ openless-all/app/src/i18n/zh-CN.ts | 321 +++++++++++++++++ openless-all/app/src/lib/hotkey.ts | 23 +- openless-all/app/src/main.tsx | 1 + openless-all/app/src/pages/History.tsx | 78 +++-- openless-all/app/src/pages/Overview.tsx | 78 +++-- openless-all/app/src/pages/Settings.tsx | 226 +++++++----- openless-all/app/src/pages/Style.tsx | 53 +-- openless-all/app/src/pages/Vocab.tsx | 27 +- 17 files changed, 1193 insertions(+), 292 deletions(-) create mode 100644 openless-all/app/src/i18n/en.ts create mode 100644 openless-all/app/src/i18n/index.ts create mode 100644 openless-all/app/src/i18n/zh-CN.ts diff --git a/openless-all/app/package-lock.json b/openless-all/app/package-lock.json index ff024763..42b8fac6 100644 --- a/openless-all/app/package-lock.json +++ b/openless-all/app/package-lock.json @@ -1,17 +1,20 @@ { "name": "openless-app", - "version": "1.1.3", + "version": "1.1.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openless-app", - "version": "1.1.3", + "version": "1.1.4", "dependencies": { "@tauri-apps/api": "^2.1.1", "@tauri-apps/plugin-shell": "^2.0.1", + "i18next": "^26.0.8", + "i18next-browser-languagedetector": "^8.2.1", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "react-i18next": "^17.0.6" }, "devDependencies": { "@tauri-apps/cli": "^2.1.0", @@ -256,6 +259,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -1620,6 +1632,52 @@ "node": ">=6.9.0" } }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, + "node_modules/i18next": { + "version": "26.0.8", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.0.8.tgz", + "integrity": "sha512-BRzLom0mhDhV9v0QhgUUHWQJuwFmnr1194xEcNLYD6ym8y8s542n4jXUvRLnhNTbh9PmpU6kGZamyuGHQMsGjw==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "peerDependencies": { + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz", + "integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1768,6 +1826,33 @@ "react": "^18.3.1" } }, + "node_modules/react-i18next": { + "version": "17.0.6", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.6.tgz", + "integrity": "sha512-WzJ6SMKF+GTD7JZZqxSR1AKKmXjaSu39sClUrNlwxS4Tl7a99O+ltFy6yhPMO+wgZuxpQjJ2PZkfrQKmAqrLhw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 26.0.1", + "react": ">= 16.8.0", + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -1856,7 +1941,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -1897,6 +1982,15 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", @@ -1957,6 +2051,15 @@ } } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/openless-all/app/package.json b/openless-all/app/package.json index 4d107ce3..a478009a 100644 --- a/openless-all/app/package.json +++ b/openless-all/app/package.json @@ -12,8 +12,11 @@ "dependencies": { "@tauri-apps/api": "^2.1.1", "@tauri-apps/plugin-shell": "^2.0.1", + "i18next": "^26.0.8", + "i18next-browser-languagedetector": "^8.2.1", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "react-i18next": "^17.0.6" }, "devDependencies": { "@tauri-apps/cli": "^2.1.0", diff --git a/openless-all/app/src/components/Capsule.tsx b/openless-all/app/src/components/Capsule.tsx index 071014d5..24fbdc84 100644 --- a/openless-all/app/src/components/Capsule.tsx +++ b/openless-all/app/src/components/Capsule.tsx @@ -21,6 +21,7 @@ // 控件可用性:仅 listening 时 cancel/confirm 才能点(与 Swift `isControlEnabled` 一致)。 import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { invokeOrMock, isTauri } from '../lib/ipc'; import type { CapsulePayload, CapsuleState } from '../lib/types'; @@ -168,6 +169,7 @@ interface PillProps { } function Pill({ state, level, insertedChars, message, onCancel, onConfirm }: PillProps) { + const { t } = useTranslation(); // 与 Swift `isControlEnabled` 同语义:只有 listening 时 cancel/confirm 才可点。 const enabled = state === 'recording'; @@ -182,19 +184,19 @@ function Pill({ state, level, insertedChars, message, onCancel, onConfirm }: Pil
- 正在思考中 + {t('capsule.thinking')}
); break; case 'done': - center = ; + center = ; break; case 'cancelled': - center = ; + center = ; break; case 'error': - center = ; + center = ; break; default: center = ; diff --git a/openless-all/app/src/components/FloatingShell.tsx b/openless-all/app/src/components/FloatingShell.tsx index 875e4afc..a653fb9e 100644 --- a/openless-all/app/src/components/FloatingShell.tsx +++ b/openless-all/app/src/components/FloatingShell.tsx @@ -4,7 +4,8 @@ // // Ported verbatim from design_handoff_openless/variants.jsx::FloatingShell. -import { useEffect, useState, type ComponentType } from 'react'; +import { useEffect, useMemo, useState, type ComponentType } from 'react'; +import { useTranslation } from 'react-i18next'; import { Icon } from './Icon'; import { WindowChrome, detectOS, type OS } from './WindowChrome'; import { SettingsModal } from './SettingsModal'; @@ -31,11 +32,11 @@ interface NavItem { cmp: ComponentType; } -const NAV: NavItem[] = [ - { id: 'overview', name: '概览', icon: 'overview', cmp: Overview }, - { id: 'history', name: '历史', icon: 'history', cmp: History }, - { id: 'vocab', name: '词汇表', icon: 'vocab', cmp: Vocab }, - { id: 'style', name: '风格', icon: 'style', cmp: Style }, +const NAV_BASE: Array> = [ + { id: 'overview', icon: 'overview', cmp: Overview }, + { id: 'history', icon: 'history', cmp: History }, + { id: 'vocab', icon: 'vocab', cmp: Vocab }, + { id: 'style', icon: 'style', cmp: Style }, ]; interface FloatingShellProps { @@ -54,10 +55,15 @@ export function FloatingShell({ os: osProp, initialTab = 'overview', initialSett } function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initialTab: AppTab; initialSettings: boolean }) { + const { t } = useTranslation(); const { currentTab, setCurrentTab, settingsOpen, setSettingsOpen } = useAppState(initialTab, initialSettings); const [settingsInitialSection, setSettingsInitialSection] = useState(); const [providerPromptOpen, setProviderPromptOpen] = useState(false); const { hotkey } = useHotkeySettings(); + const NAV = useMemo( + () => NAV_BASE.map(b => ({ ...b, name: t(`nav.${b.id}`) })), + [t], + ); const Page = (NAV.find((n) => n.id === currentTab) ?? NAV[0]).cmp; useEffect(() => { @@ -86,7 +92,7 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia const openProviderSettings = () => { rememberProviderPrompt(); - openSettings('提供商'); + openSettings('providers'); }; return ( @@ -171,7 +177,7 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia marginTop: 8, }}> -
录音快捷键
+
{t('shell.shortcutLabel')}
{getHotkeyTriggerLabel(hotkey?.trigger)} - 开始 / 停止 + {t('shell.shortcutHint')}
@@ -194,8 +200,8 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia border: '0.5px solid rgba(37,99,235,0.15)', }}> -
BETA
-
所有数据都只保存在本机。
+
{t('shell.betaTag')}
+
{t('shell.betaNote')}
@@ -238,14 +244,14 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia zIndex: 2, }}> - openSettings('提供商')} /> - openExternal('https://github.com/appergb/openless/issues')} /> - openSettings()} /> - openExternal('https://github.com/appergb/openless#readme')} /> + openSettings('providers')} /> + openExternal('https://github.com/appergb/openless/issues')} /> + openSettings()} /> + openExternal('https://github.com/appergb/openless#readme')} />
- 版本 {APP_VERSION_LABEL} + {t('shell.footer.version', { version: APP_VERSION_LABEL })}
@@ -286,6 +292,7 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia } function ProviderSetupPrompt({ onLater, onOpenSettings }: { onLater: () => void; onOpenSettings: () => void }) { + const { t } = useTranslation(); return (
void; >
-
设置语音提供商
+
{t('shell.providerPrompt.title')}
- 还没有配置 ASR 或 LLM 提供商,语音输入和润色暂时无法正常工作。 + {t('shell.providerPrompt.body')}
diff --git a/openless-all/app/src/components/Onboarding.tsx b/openless-all/app/src/components/Onboarding.tsx index 9186e0c6..81deaeca 100644 --- a/openless-all/app/src/components/Onboarding.tsx +++ b/openless-all/app/src/components/Onboarding.tsx @@ -4,6 +4,7 @@ // 与 Swift `Sources/OpenLessApp/Onboarding/` 同语义,但简化为单页三步。 import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { checkAccessibilityPermission, checkMicrophonePermission, @@ -20,6 +21,7 @@ interface OnboardingProps { } export function Onboarding({ onComplete }: OnboardingProps) { + const { t } = useTranslation(); const [accessibility, setAccessibility] = useState('notDetermined'); const [microphone, setMicrophone] = useState('notDetermined'); const [busy, setBusy] = useState(false); @@ -116,45 +118,45 @@ export function Onboarding({ onComplete }: OnboardingProps) { OL
-
欢迎使用 OpenLess
+
{t('onboarding.welcome')}
- 本地说出,本地落字。开始前需要两个系统权限。 + {t('onboarding.intro')}
- 授权全部完成后此引导自动关闭。如果一直不消失,从菜单栏 OpenLess → 退出,重新打开 App。 + {t('onboarding.footerHint')} diff --git a/openless-all/app/src/components/SettingsModal.tsx b/openless-all/app/src/components/SettingsModal.tsx index ea7090ed..17a8cbea 100644 --- a/openless-all/app/src/components/SettingsModal.tsx +++ b/openless-all/app/src/components/SettingsModal.tsx @@ -3,6 +3,7 @@ // (plus its AccountSection / PersonalizeSection / AboutMini siblings). import { useEffect, useState, type CSSProperties } from 'react'; +import { useTranslation } from 'react-i18next'; import { Icon } from './Icon'; import { APP_VERSION_LABEL } from '../lib/appVersion'; import { Settings as SettingsContent, type SettingsSectionId } from '../pages/Settings'; @@ -18,7 +19,8 @@ interface SettingsModalProps { initialSettingsSection?: SettingsSectionId; } -type ModalSectionId = '账户' | '设置' | '个性化' | '关于'; +// 稳定 ID(与 i18n key 一致,方便 modal.sections.* 渲染)。 +type ModalSectionId = 'account' | 'settings' | 'personalize' | 'about'; interface ModalNavItem { id: string; @@ -31,10 +33,11 @@ interface ModalGroup { } export function SettingsModal({ os: _os, onClose, initialSettingsSection }: SettingsModalProps) { - const [section, setSection] = useState('设置'); + const { t } = useTranslation(); + const [section, setSection] = useState('settings'); const groups: ModalGroup[] = [ - { items: [{ id: '账户', icon: 'user' }, { id: '设置', icon: 'settings' }, { id: '个性化', icon: 'sparkle' }, { id: '关于', icon: 'info' }] }, - { items: [{ id: '帮助中心', icon: 'help', external: true }, { id: '版本说明', icon: 'doc', external: true }] }, + { items: [{ id: 'account', icon: 'user' }, { id: 'settings', icon: 'settings' }, { id: 'personalize', icon: 'sparkle' }, { id: 'about', icon: 'info' }] }, + { items: [{ id: 'helpCenter', icon: 'help', external: true }, { id: 'releaseNotes', icon: 'doc', external: true }] }, ]; return ( @@ -94,7 +97,7 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett }}> - {it.id} + {t(`modal.sections.${it.id}`)} {it.external && } ); @@ -114,17 +117,17 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett display: 'inline-flex', alignItems: 'center', justifyContent: 'center', cursor: 'default', }} - title="关闭"> + title={t('common.close')}> -

{section}

+

{t(`modal.sections.${section}`)}

- {section === '设置' && } - {section === '账户' && } - {section === '个性化' && } - {section === '关于' && } + {section === 'settings' && } + {section === 'account' && } + {section === 'personalize' && } + {section === 'about' && } @@ -140,6 +143,7 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett } function AccountSection() { + const { t } = useTranslation(); return (
L
-
本地用户
-
未登录 · 所有数据保存在本机
+
{t('modal.account.localUser')}
+
{t('modal.account.localUserDesc')}
+ }}>{t('modal.account.loginSync')}

- OpenLess 默认完全本地运行。登录后可在多设备间同步词汇表与风格预设,识别仍在本机或你配置的 Provider 上完成。 + {t('modal.account.footer')}

); } function PersonalizeSection() { + const { t } = useTranslation(); // 玻璃强度持久化到 localStorage,并实时写入 CSS var --ol-glass-blur。 // 这是 CSS-only 的层(影响 backdrop-filter 的内层强度);macOS NSVisualEffectView // 是另一层,由 Tauri 在窗口创建时一次性配置,运行时改动需要重启 App。 @@ -186,13 +191,16 @@ function PersonalizeSection() { return (
- - + + - - + + - +
- - + + - +
@@ -218,20 +229,21 @@ function PersonalizeSection() { } function AboutMini() { + const { t } = useTranslation(); return (
OpenLess
-
自然说话,完美书写 · {APP_VERSION_LABEL}
+
{t('modal.about.tagline')} · {APP_VERSION_LABEL}
- - - - - 本地优先 + + + + + {t('modal.about.localFirst')}
); diff --git a/openless-all/app/src/components/WindowChrome.tsx b/openless-all/app/src/components/WindowChrome.tsx index 53cd8128..26effa96 100644 --- a/openless-all/app/src/components/WindowChrome.tsx +++ b/openless-all/app/src/components/WindowChrome.tsx @@ -12,6 +12,7 @@ // └───────────────────────────────────────────────┘ import { type CSSProperties, type ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; export type OS = 'mac' | 'win'; @@ -81,6 +82,7 @@ interface WinTitleBarProps { } function WinTitleBar({ title }: WinTitleBarProps) { + const { t } = useTranslation(); return (
{title}
- - -
diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts new file mode 100644 index 00000000..7d9061bf --- /dev/null +++ b/openless-all/app/src/i18n/en.ts @@ -0,0 +1,323 @@ +// English resources — translated from zh-CN.ts. Keep keys in sync. + +import type { zhCN } from './zh-CN'; + +// Type-level guarantee that en mirrors the zh-CN shape. +export const en: typeof zhCN = { + app: { + name: 'OpenLess', + tagline: 'Speak naturally, write perfectly', + }, + common: { + loading: 'Loading…', + refresh: 'Refresh', + clear: 'Clear', + copy: 'Copy', + delete: 'Delete', + later: 'Later', + close: 'Close', + show: 'Show', + hide: 'Hide', + saved: 'Saved', + copied: 'Copied', + add: 'Add', + }, + capsule: { + thinking: 'Thinking…', + cancelled: 'Cancelled', + error: 'Something went wrong', + inserted: 'Inserted {{count}}', + }, + nav: { + overview: 'Overview', + history: 'History', + vocab: 'Vocabulary', + style: 'Style', + }, + shell: { + shortcutLabel: 'Recording shortcut', + shortcutHint: 'Start / Stop', + betaTag: 'BETA', + betaNote: 'All data stays on this device.', + footer: { + account: 'Account', + feedback: 'Feedback', + settings: 'Settings', + help: 'Help', + version: 'Version {{version}}', + checkUpdates: 'Check for updates', + }, + providerPrompt: { + title: 'Set up speech providers', + body: 'No ASR or LLM provider is configured yet. Voice input and polishing will not work until you add credentials.', + later: 'Later', + openSettings: 'Open Settings', + }, + }, + onboarding: { + welcome: 'Welcome to OpenLess', + intro: 'Speak locally, type locally. Two system permissions are needed before you start.', + accessibilityTitle: 'Accessibility', + hotkeyTitle: 'Global hotkey', + accessibilityDesc: 'Used to listen to the global hotkey (default {{trigger}}) and write transcripts at the cursor.', + hotkeyDesc: 'Used to confirm that the global hotkey listener is available.', + micTitle: 'Microphone', + micDesc: 'Used to capture your voice input.', + actionNotApplicable: 'Not required', + actionGranted: 'Granted', + actionOpenSystem: 'Open System Settings', + actionGrant: 'Grant', + actionRequestMic: 'Request access', + accessibilityHint: 'After granting, you must **fully quit OpenLess** and reopen it (a macOS TCC requirement).', + footerHint: 'This onboarding closes automatically once both permissions are granted. If it persists, quit OpenLess from the menu bar and relaunch.', + }, + overview: { + kicker: 'DASHBOARD', + title: "Today's overview", + desc: 'Speak locally, type locally. Your dictation rhythm and system status for today.', + pressPrefix: 'Press', + pressSuffix: 'to start', + asrKind: 'ASR', + llmKind: 'LLM', + asrName: 'Volcengine', + asrSubname: 'bigmodel', + llmName: 'OpenAI-compatible', + llmConfigured: 'Active LLM configured', + llmNotConfigured: 'Not configured', + statusConfigured: 'Configured', + statusNotConfigured: 'Not configured', + metricChars: 'Characters today', + metricSegments: '{{count}} segments', + metricDuration: 'Total duration today', + metricAvg: 'Avg per segment', + metricAvgTrend: "Today's average", + metricNoData: 'No data', + metricTotal: 'Total records', + metricTotalTrend: 'Local archive (max 200)', + weekTitle: 'Last 7 days', + weekUnit: 'count / day', + recentTitle: 'Recent transcripts', + recentAll: 'View all →', + recentEmpty: 'No records yet. Press {{trigger}} to start your first recording.', + weekDays: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'], + }, + history: { + kicker: 'HISTORY', + title: 'History', + desc: 'Recent transcripts are stored only on this device. Timeline on the left, raw vs polished on the right.', + filterAll: 'All', + summary: '{{total}} total · showing {{shown}}', + empty: 'No history yet. Press {{trigger}} to record one.', + rawLabel: 'Raw', + rawEmpty: '(empty)', + selectHint: 'Select an entry on the left to see details.', + insertedTo: 'Inserted into', + chars: '{{count}} chars', + vocabHits: '{{count}} vocab hits', + inserted: 'Inserted', + copiedFallback: 'Copied (use {{shortcut}})', + insertFailed: 'Insert failed', + confirmClear: 'Delete all {{count}} history entries? This cannot be undone.', + }, + vocab: { + kicker: 'VOCABULARY', + title: 'Vocabulary', + desc: 'Tell the model about words to expect — new terms, names, jargon. They are passed both as ASR hot words and as model context.', + placeholder: 'Type a word, press Enter or click Add…', + tip: 'Mixed Chinese/English supported · numeric prefixes are matched literally · hits counted automatically', + loadFailed: 'Load failed: {{err}}', + empty: 'No entries yet. Add a new term or piece of jargon above so the model can prioritize it.', + tipDisabled: 'Click to disable this entry', + tipEnabled: 'Click to enable this entry', + removeAria: 'Remove', + }, + style: { + kicker: 'STYLE', + title: 'Output style', + desc: 'Pick a default style for global recording. Each card can be toggled individually; disabled styles are hidden from the history "re-polish" switcher.', + masterToggle: 'Master switch', + currentDefault: 'Current default', + ariaSetDefault: 'Set as default', + modes: { + raw: { name: 'Raw', desc: 'Only adds punctuation and natural breaks — no rewriting or expansion.', sample: "Keeps spoken cadence; fillers like 'um' or 'you know' get dropped, but sentences stay intact." }, + light: { name: 'Light polish', desc: 'Drops fillers, adds punctuation, and produces sendable natural prose.', sample: "Makes the transcript flow well without sounding scripted — your tone and habits remain." }, + structured: { name: 'Structured', desc: 'Auto-organizes into a numbered outline when you cover several topics or steps.', sample: '1. Topic one\n 1) Point a\n 2) Point b\n2. Topic two\n 1) Point c' }, + formal: { name: 'Formal', desc: 'Email and workplace tone — more complete, more professional.', sample: 'Detects greetings/sign-offs in email contexts; avoids empty pleasantries.' }, + }, + }, + settings: { + kicker: 'SETTINGS', + title: 'Settings', + desc: 'Recording method, model and ASR providers, hotkeys, permissions, and About — everything is here.', + sections: { + recording: 'Recording', + providers: 'Providers', + shortcuts: 'Shortcuts', + permissions: 'Permissions', + language: 'Language', + about: 'About', + }, + recording: { + title: 'Recording', + desc: 'Define the global recording hotkey and how it triggers.', + hotkeyLabel: 'Recording hotkey', + hotkeyDescAcc: 'Pressing it captures voice globally. Requires Accessibility permission.', + hotkeyDescNoAcc: 'Pressing it captures voice globally. No Accessibility permission required.', + modeLabel: 'Trigger mode', + modeDesc: 'Toggle = press once to start, again to stop. Push-to-talk = hold to record, release to stop.', + modeToggle: 'Toggle', + modeHold: 'Push-to-talk', + capsuleLabel: 'Recording capsule', + capsuleDesc: 'Show a translucent capsule at the bottom of the screen while recording / transcribing.', + }, + providers: { + llmTitle: 'LLM (polishing)', + llmDesc: 'OpenAI-compatible protocol. Multiple vendors supported.', + providerLabel: 'Provider', + llmProviderDesc: 'Selecting a preset auto-fills the default Base URL.', + asrProviderDesc: 'Switching providers automatically loads the matching credentials.', + asrTitle: 'ASR (transcription)', + asrDesc: 'Used to turn speech into text in real time.', + presets: { + ark: 'ARK (Volcengine Ark)', + deepseek: 'DeepSeek', + siliconflow: 'SiliconFlow', + openai: 'OpenAI', + custom: 'Custom', + asrVolcengine: 'Volcengine bigasr', + asrSiliconflow: 'SiliconFlow SenseVoice', + asrWhisper: 'OpenAI Whisper (compatible)', + }, + fillDefault: 'Fill default value', + }, + shortcuts: { + title: 'Shortcut reference', + descAcc: 'All shortcuts apply globally. Accessibility permission must be granted in Permissions.', + descNoAcc: 'All shortcuts apply globally. If unresponsive, check the global hotkey status in Permissions.', + startStop: 'Start / Stop recording', + cancel: 'Cancel current recording', + confirm: 'Confirm capsule insertion', + switchStyle: 'Switch to previous style', + openApp: 'Open OpenLess', + confirmHint: 'Click ✓ on the capsule', + notSupported: 'Not yet supported', + }, + permissions: { + title: 'Permissions', + descAcc: 'OpenLess needs the following system permissions to work. After granting, fully quit and relaunch the app for changes to take effect.', + descNoAcc: 'OpenLess needs microphone access and uses the global hotkey listener state to verify the native hook is running.', + micLabel: 'Microphone', + micDesc: 'Used to capture your voice input.', + accLabel: 'Accessibility', + accDesc: 'Used to listen to the global hotkey and write transcripts at the cursor.', + hotkeyLabel: 'Global hotkey', + hotkeyDescWithAdapter: 'Active adapter: {{adapter}}. Used to confirm the hotkey listener is installed.', + hotkeyDescPlain: 'Used to confirm the hotkey listener is installed.', + networkLabel: 'Network', + networkDesc: 'Required for cloud ASR / LLM calls. Disable for local-only mode.', + networkOk: 'Available', + checking: 'Checking…', + granted: 'Granted', + notApplicable: 'Not required', + denied: 'Not granted', + indeterminate: 'Undetermined', + openSystem: 'Open System Settings', + grant: 'Grant', + hotkeyInstalled: 'Installed', + hotkeyStarting: 'Installing…', + hotkeyFailed: 'Listener failed', + }, + language: { + title: 'Interface language', + desc: 'Switch the UI language. Applies to the current session immediately and persists across launches.', + label: 'Language', + labelDesc: 'Choose "Follow system" to match the OS language at launch.', + followSystem: 'Follow system', + zh: '简体中文', + en: 'English', + restartHint: 'Some native menus (system tray, etc.) may require an app restart to fully switch.', + }, + about: { + tagline: 'Speak naturally, write perfectly', + checkUpdate: 'Check for updates', + openReleases: 'Open Releases', + source: 'Source', + docs: 'Docs', + feedback: 'Feedback', + qq: 'QQ community group', + qqDesc: 'Search the group number in QQ to join, or scan the QR code.', + copyQq: 'Copy group number', + privacy: 'Privacy', + privacyDesc: 'All transcripts stay on this device. Cloud APIs are only called for real-time transcription/polish; no recordings are retained.', + localFirst: 'Local-first', + }, + }, + modal: { + sections: { + account: 'Account', + settings: 'Settings', + personalize: 'Personalize', + about: 'About', + helpCenter: 'Help center', + releaseNotes: 'Release notes', + }, + account: { + localUser: 'Local user', + localUserDesc: 'Not signed in · all data stays local', + loginSync: 'Sign in / Sync', + footer: 'OpenLess runs fully locally by default. Signing in syncs vocabulary and style presets across devices; recognition still happens on this machine or your configured provider.', + }, + personalize: { + appearance: 'Appearance', + appearanceDesc: 'Follow system / Light / Dark', + appearanceSystem: 'Follow system', + appearanceLight: 'Light', + appearanceDark: 'Dark', + language: 'Interface language', + blur: 'Glass blur intensity', + blurDesc: 'Affects the inner backdrop-filter strength (the macOS system frosted layer can not be tuned at runtime).', + startupOpen: 'On launch', + startupOverview: 'Overview', + startupLast: 'Last position', + startupAtBoot: 'Launch at login', + }, + about: { + tagline: 'Speak naturally, write perfectly', + checkUpdate: 'Check for updates', + checkUpdateBtn: 'Check', + docs: 'Docs', + docsBtn: 'openless.app/docs ↗', + feedback: 'Feedback channel', + feedbackBtn: 'GitHub Issues ↗', + privacy: 'Privacy', + privacyDesc: 'All transcripts stay on this device. Cloud APIs are used only for real-time calls.', + localFirst: 'Local-first', + }, + }, + windowChrome: { + minimize: 'Minimize', + maximize: 'Maximize', + close: 'Close', + }, + hotkey: { + triggers: { + rightOption: 'Right Option', + leftOption: 'Left Option', + rightControl: 'Right Control', + leftControl: 'Left Control', + rightCommand: 'Right Command', + fn: 'Fn (Globe key)', + rightAlt: 'Right Alt', + }, + fallback: 'Global hotkey', + modeHoldSuffix: ' (push-to-talk)', + modeToggleSuffix: ' (start / stop)', + usageHold: 'Hold {{trigger}} to talk, release to stop.', + usageToggle: 'Press {{trigger}} to start, press again to stop.', + adapter: { + macEventTap: 'macOS Event Tap', + windowsLowLevel: 'Windows low-level keyboard hook', + rdev: 'rdev listener', + }, + }, +}; diff --git a/openless-all/app/src/i18n/index.ts b/openless-all/app/src/i18n/index.ts new file mode 100644 index 00000000..08fb87b3 --- /dev/null +++ b/openless-all/app/src/i18n/index.ts @@ -0,0 +1,68 @@ +// i18n 入口 — 必须在任意 UI 组件 import 之前完成 init。 +// +// 设计说明: +// - 资源在打包时静态注入(zh-CN.ts / en.ts)。无需后端推送,无网络请求。 +// - LocalStorage key `ol.locale` 持久化用户选择;首次启动按 navigator.language 推断。 +// - fallback 永远是 zh-CN:已知的产品权威文案,且 zh-CN.ts 是 source of truth。 + +import i18n from 'i18next'; +import LanguageDetector from 'i18next-browser-languagedetector'; +import { initReactI18next } from 'react-i18next'; +import { en } from './en'; +import { zhCN } from './zh-CN'; + +export const SUPPORTED_LOCALES = ['zh-CN', 'en'] as const; +export type SupportedLocale = (typeof SUPPORTED_LOCALES)[number]; + +export const LOCALE_STORAGE_KEY = 'ol.locale'; +const FOLLOW_SYSTEM_VALUE = 'system'; + +void i18n + .use(LanguageDetector) + .use(initReactI18next) + .init({ + resources: { + 'zh-CN': { translation: zhCN }, + en: { translation: en }, + }, + fallbackLng: 'zh-CN', + supportedLngs: SUPPORTED_LOCALES as unknown as string[], + nonExplicitSupportedLngs: true, // 'zh' / 'zh-Hans' 等都收敛到 'zh-CN' + load: 'currentOnly', + interpolation: { escapeValue: false }, + detection: { + order: ['localStorage', 'navigator'], + lookupLocalStorage: LOCALE_STORAGE_KEY, + caches: ['localStorage'], + }, + }); + +export default i18n; + +/** + * 当前持久化偏好。'system' 表示跟随系统;具体语言 tag 表示用户已显式选择。 + * 与 i18n.language 不同:i18n.language 永远是已 resolve 的具体语言。 + */ +export function getLocalePreference(): SupportedLocale | typeof FOLLOW_SYSTEM_VALUE { + if (typeof window === 'undefined') return FOLLOW_SYSTEM_VALUE; + const raw = window.localStorage.getItem(LOCALE_STORAGE_KEY); + if (raw === 'zh-CN' || raw === 'en') return raw; + return FOLLOW_SYSTEM_VALUE; +} + +/** + * 写入用户偏好并立即切换 i18n 语言。 + * pref === 'system' 时清除存储项,让下次启动重新走 navigator 检测。 + */ +export async function setLocalePreference(pref: SupportedLocale | typeof FOLLOW_SYSTEM_VALUE): Promise { + if (pref === FOLLOW_SYSTEM_VALUE) { + window.localStorage.removeItem(LOCALE_STORAGE_KEY); + const detected = (i18n.services.languageDetector?.detect?.() as string | string[] | undefined) ?? 'zh-CN'; + const target = Array.isArray(detected) ? detected[0] : detected; + await i18n.changeLanguage(target); + return; + } + await i18n.changeLanguage(pref); +} + +export const FOLLOW_SYSTEM = FOLLOW_SYSTEM_VALUE; diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts new file mode 100644 index 00000000..eee47765 --- /dev/null +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -0,0 +1,321 @@ +// 简体中文资源 — 与产品当前文案保持一致。 +// 添加新 key 时,必须同步更新 en.ts,否则首次切换到 English 会回落到中文残留。 + +export const zhCN = { + app: { + name: 'OpenLess', + tagline: '自然说话,完美书写', + }, + common: { + loading: '加载中…', + refresh: '刷新', + clear: '清空', + copy: '复制', + delete: '删除', + later: '稍后', + close: '关闭', + show: '显示', + hide: '隐藏', + saved: '已保存', + copied: '已复制', + add: '添加', + }, + capsule: { + thinking: '正在思考中', + cancelled: '已取消', + error: '出错了', + inserted: '已插入 {{count}}', + }, + nav: { + overview: '概览', + history: '历史', + vocab: '词汇表', + style: '风格', + }, + shell: { + shortcutLabel: '录音快捷键', + shortcutHint: '开始 / 停止', + betaTag: 'BETA', + betaNote: '所有数据都只保存在本机。', + footer: { + account: '账户', + feedback: '反馈', + settings: '设置', + help: '帮助', + version: '版本 {{version}}', + checkUpdates: '检查更新', + }, + providerPrompt: { + title: '设置语音提供商', + body: '还没有配置 ASR 或 LLM 提供商,语音输入和润色暂时无法正常工作。', + later: '稍后', + openSettings: '去设置', + }, + }, + onboarding: { + welcome: '欢迎使用 OpenLess', + intro: '本地说出,本地落字。开始前需要两个系统权限。', + accessibilityTitle: '辅助功能', + hotkeyTitle: '全局快捷键', + accessibilityDesc: '用于监听全局快捷键(默认 {{trigger}})并把识别结果写入光标位置。', + hotkeyDesc: '用于确认全局快捷键监听可用。', + micTitle: '麦克风', + micDesc: '用于捕获你的语音输入。', + actionNotApplicable: '无需授权', + actionGranted: '已授权', + actionOpenSystem: '打开系统设置', + actionGrant: '授权', + actionRequestMic: '弹出授权', + accessibilityHint: '授权后必须**完全退出 OpenLess** 再重新打开(macOS TCC 规则)。', + footerHint: '授权全部完成后此引导自动关闭。如果一直不消失,从菜单栏 OpenLess → 退出,重新打开 App。', + }, + overview: { + kicker: 'DASHBOARD', + title: '今日概览', + desc: '本地说出,本地落字。下面是你今日的口述节奏与系统状态。', + pressPrefix: '按', + pressSuffix: '开始录音', + asrKind: 'ASR 语音', + llmKind: 'LLM 模型', + asrName: '火山引擎', + asrSubname: 'bigmodel', + llmName: 'OpenAI 兼容', + llmConfigured: '已配置 active LLM', + llmNotConfigured: '未配置', + statusConfigured: '已配置', + statusNotConfigured: '未配置', + metricChars: '今日字数', + metricSegments: '{{count}} 段', + metricDuration: '今日总时长', + metricAvg: '平均段落', + metricAvgTrend: '今日均值', + metricNoData: '暂无数据', + metricTotal: '累计记录', + metricTotalTrend: '本机存档 (上限 200)', + weekTitle: '近 7 天', + weekUnit: '条数 / 天', + recentTitle: '最近识别', + recentAll: '全部记录 →', + recentEmpty: '还没有记录。按 {{trigger}} 开始第一次录音。', + weekDays: ['日', '一', '二', '三', '四', '五', '六'], + }, + history: { + kicker: 'HISTORY', + title: '历史记录', + desc: '最近的识别结果只保存在本机。左侧为时间线,右侧为原文与润色对比。', + filterAll: '全部', + summary: '共 {{total}} 条 · 显示 {{shown}}', + empty: '还没有历史记录。按 {{trigger}} 录一段试试。', + rawLabel: '原文', + rawEmpty: '(空)', + selectHint: '左侧选一条查看详情。', + insertedTo: '插入到', + chars: '{{count}} 字', + vocabHits: '{{count}} 个热词', + inserted: '已插入', + copiedFallback: '已复制(需 {{shortcut}})', + insertFailed: '插入失败', + confirmClear: '确定清空全部 {{count}} 条记录?此操作不可恢复。', + }, + vocab: { + kicker: 'VOCABULARY', + title: '词汇表', + desc: '告诉模型识别前可能出现的词——生词、新词或专业词汇。同时进入 ASR 热词与后期模型上下文。', + placeholder: '输入词语,按 Enter 或点添加…', + tip: '支持中英混合 · 数字开头按字面识别 · 命中次数自动计数', + loadFailed: '加载失败:{{err}}', + empty: '还没有词条。在上面输入一个生词或专业术语,让模型在听写时优先匹配。', + tipDisabled: '点击禁用此词条', + tipEnabled: '点击启用此词条', + removeAria: '删除', + }, + style: { + kicker: 'STYLE', + title: '输出风格', + desc: '选择默认风格用于全局录音。每张卡可单独启停;启停的风格不会出现在历史记录的「重新润色」切换中。', + masterToggle: '整体启用', + currentDefault: '当前默认', + ariaSetDefault: '设为默认', + modes: { + raw: { name: '原文', desc: '只补标点和必要分句,不改写不扩写。', sample: '保留原始口语;嗯、那个等口癖会被去除,但不会重组语句。' }, + light: { name: '轻度润色', desc: '去口癖、补标点,整理为可发送的自然文字。', sample: '让转写听起来不像念稿——保留语气和表达习惯,但行文流畅。' }, + structured: { name: '清晰结构', desc: '多个主题或步骤时,自动组织为分点列表。', sample: '1. 主题一\n 1) 要点 a\n 2) 要点 b\n2. 主题二\n 1) 要点 c' }, + formal: { name: '正式表达', desc: '工作沟通和邮件场景,更专业更完整。', sample: '邮件场景自动识别问候 / 落款;不引入空泛客套。' }, + }, + }, + settings: { + kicker: 'SETTINGS', + title: '设置', + desc: '录音方式、模型与语音提供商、快捷键、权限与关于信息——全部在这里。', + sections: { + recording: '录音', + providers: '提供商', + shortcuts: '快捷键', + permissions: '权限', + language: '语言', + about: '关于', + }, + recording: { + title: '录音', + desc: '定义全局录音的快捷键与触发方式。', + hotkeyLabel: '录音快捷键', + hotkeyDescAcc: '按下即开始捕获语音,全局生效。需要授予辅助功能权限。', + hotkeyDescNoAcc: '按下即开始捕获语音,全局生效。无需额外辅助功能授权。', + modeLabel: '录音方式', + modeDesc: '切换式 = 按一次开始、再按一次结束;按住说话 = 按住开始、松开结束。', + modeToggle: '切换式', + modeHold: '按住说话', + capsuleLabel: '录音胶囊', + capsuleDesc: '录音 / 转写时在屏幕底部显示半透明胶囊。', + }, + providers: { + llmTitle: 'LLM 模型(润色)', + llmDesc: 'OpenAI 兼容协议,支持多家供应商切换。', + providerLabel: '供应商', + llmProviderDesc: '选择后将自动填入 Base URL 默认值。', + asrProviderDesc: '切换后将自动选用对应凭据。', + asrTitle: 'ASR 语音(转写)', + asrDesc: '用于将口述实时转写为文本。', + presets: { + ark: 'ARK(火山方舟)', + deepseek: 'DeepSeek', + siliconflow: '硅基流动', + openai: 'OpenAI', + custom: '自定义', + asrVolcengine: '火山引擎 bigasr', + asrSiliconflow: '硅基流动 SenseVoice', + asrWhisper: 'OpenAI Whisper(兼容)', + }, + fillDefault: '填入默认值', + }, + shortcuts: { + title: '快捷键速查', + descAcc: '所有快捷键全局生效,需要在权限设置中开启辅助功能。', + descNoAcc: '所有快捷键全局生效。若无响应,请在权限页查看全局快捷键监听状态。', + startStop: '开始 / 停止录音', + cancel: '取消本次录音', + confirm: '胶囊确认插入', + switchStyle: '切换上一次风格', + openApp: '打开 OpenLess', + confirmHint: '点击右侧 ✓', + notSupported: '暂未支持', + }, + permissions: { + title: '权限', + descAcc: 'OpenLess 需要以下系统权限才能正常工作。授权后通常需要完全退出 App 重启一次才生效。', + descNoAcc: 'OpenLess 需要麦克风可用,并依赖全局快捷键监听状态判断 native hook 是否正常工作。', + micLabel: '麦克风', + micDesc: '用于捕获你的语音输入。', + accLabel: '辅助功能', + accDesc: '用于监听全局快捷键并将识别结果写入光标位置。', + hotkeyLabel: '全局快捷键', + hotkeyDescWithAdapter: '当前适配器:{{adapter}}。用于判断快捷键监听是否已经安装。', + hotkeyDescPlain: '用于判断快捷键监听是否已经安装。', + networkLabel: '网络', + networkDesc: '云端 ASR / LLM 调用所必需。本地模式可关闭。', + networkOk: '可用', + checking: '检查中…', + granted: '已授权', + notApplicable: '无需授权', + denied: '未授权', + indeterminate: '未确定', + openSystem: '打开系统设置', + grant: '授权', + hotkeyInstalled: '已安装', + hotkeyStarting: '安装中…', + hotkeyFailed: '监听失败', + }, + language: { + title: '界面语言', + desc: '切换 UI 显示语言。当前会话即时生效,下次启动自动沿用。', + label: '语言', + labelDesc: '选择「跟随系统」时按操作系统当前语言显示。', + followSystem: '跟随系统', + zh: '简体中文', + en: 'English', + restartHint: '部分原生菜单(系统托盘等)可能需要重启 App 才会切换。', + }, + about: { + tagline: '自然说话,完美书写', + checkUpdate: '检查更新', + openReleases: '打开 Releases', + source: '源码', + docs: '文档', + feedback: '反馈', + qq: '社区 QQ 群', + qqDesc: '使用 QQ 搜索群号加入,或扫码进群。', + copyQq: '复制群号', + privacy: '隐私', + privacyDesc: '所有识别结果仅保存在本机。云端 API 仅用于实时转写与润色,不会保留你的录音。', + localFirst: '本地优先', + }, + }, + modal: { + sections: { + account: '账户', + settings: '设置', + personalize: '个性化', + about: '关于', + helpCenter: '帮助中心', + releaseNotes: '版本说明', + }, + account: { + localUser: '本地用户', + localUserDesc: '未登录 · 所有数据保存在本机', + loginSync: '登录 / 同步', + footer: 'OpenLess 默认完全本地运行。登录后可在多设备间同步词汇表与风格预设,识别仍在本机或你配置的 Provider 上完成。', + }, + personalize: { + appearance: '外观', + appearanceDesc: '跟随系统 / 浅色 / 深色', + appearanceSystem: '跟随系统', + appearanceLight: '浅色', + appearanceDark: '深色', + language: '界面语言', + blur: '毛玻璃强度', + blurDesc: '影响窗口内层 backdrop-filter 强度(macOS 系统磨砂层无法运行时调)。', + startupOpen: '启动时打开', + startupOverview: '概览', + startupLast: '上次位置', + startupAtBoot: '开机自启', + }, + about: { + tagline: '自然说话,完美书写', + checkUpdate: '检查更新', + checkUpdateBtn: '检查', + docs: '文档', + docsBtn: 'openless.app/docs ↗', + feedback: '反馈渠道', + feedbackBtn: 'GitHub Issues ↗', + privacy: '隐私', + privacyDesc: '所有识别结果只保存在本机,云端 API 仅用于实时调用。', + localFirst: '本地优先', + }, + }, + windowChrome: { + minimize: '最小化', + maximize: '最大化', + close: '关闭', + }, + hotkey: { + triggers: { + rightOption: '右 Option', + leftOption: '左 Option', + rightControl: '右 Control', + leftControl: '左 Control', + rightCommand: '右 Command', + fn: 'Fn (地球键)', + rightAlt: '右 Alt', + }, + fallback: '全局快捷键', + modeHoldSuffix: '(按住说话)', + modeToggleSuffix: '(开始 / 停止)', + usageHold: '按住 {{trigger}} 说话,松开结束。', + usageToggle: '按 {{trigger}} 开始录音,再按一次结束。', + adapter: { + macEventTap: 'macOS Event Tap', + windowsLowLevel: 'Windows 低层键盘 hook', + rdev: 'rdev 监听器', + }, + }, +}; diff --git a/openless-all/app/src/lib/hotkey.ts b/openless-all/app/src/lib/hotkey.ts index 06674754..4819d8b1 100644 --- a/openless-all/app/src/lib/hotkey.ts +++ b/openless-all/app/src/lib/hotkey.ts @@ -1,25 +1,22 @@ +import i18n from '../i18n'; import type { HotkeyBinding, HotkeyTrigger } from './types'; -export const HOTKEY_TRIGGER_LABEL: Record = { - rightOption: '右 Option', - leftOption: '左 Option', - rightControl: '右 Control', - leftControl: '左 Control', - rightCommand: '右 Command', - fn: 'Fn (地球键)', - rightAlt: '右 Alt', -}; - export function getHotkeyTriggerLabel(trigger: HotkeyTrigger | null | undefined): string { - return trigger ? HOTKEY_TRIGGER_LABEL[trigger] : '全局快捷键'; + if (!trigger) return i18n.t('hotkey.fallback'); + return i18n.t(`hotkey.triggers.${trigger}`); } export function getHotkeyStartStopLabel(binding: HotkeyBinding | null | undefined): string { const trigger = getHotkeyTriggerLabel(binding?.trigger); - return binding?.mode === 'hold' ? `${trigger}(按住说话)` : `${trigger}(开始 / 停止)`; + const suffix = binding?.mode === 'hold' + ? i18n.t('hotkey.modeHoldSuffix') + : i18n.t('hotkey.modeToggleSuffix'); + return `${trigger}${suffix}`; } export function getHotkeyUsageHint(binding: HotkeyBinding | null | undefined): string { const trigger = getHotkeyTriggerLabel(binding?.trigger); - return binding?.mode === 'hold' ? `按住 ${trigger} 说话,松开结束。` : `按 ${trigger} 开始录音,再按一次结束。`; + return binding?.mode === 'hold' + ? i18n.t('hotkey.usageHold', { trigger }) + : i18n.t('hotkey.usageToggle', { trigger }); } diff --git a/openless-all/app/src/main.tsx b/openless-all/app/src/main.tsx index 2751bbee..192d07ca 100644 --- a/openless-all/app/src/main.tsx +++ b/openless-all/app/src/main.tsx @@ -1,6 +1,7 @@ import React from "react"; import ReactDOM from "react-dom/client"; import { App } from "./App"; +import "./i18n"; // 必须在任何 UI 组件之前完成 i18n init import "./styles/tokens.css"; import "./styles/global.css"; diff --git a/openless-all/app/src/pages/History.tsx b/openless-all/app/src/pages/History.tsx index 69980049..28fcf088 100644 --- a/openless-all/app/src/pages/History.tsx +++ b/openless-all/app/src/pages/History.tsx @@ -2,6 +2,7 @@ // 真实数据来自 ~/Library/Application Support/OpenLess/history.json。 import { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { Icon } from '../components/Icon'; import { detectOS } from '../components/WindowChrome'; import { getHotkeyTriggerLabel } from '../lib/hotkey'; @@ -10,22 +11,31 @@ import type { DictationSession, PolishMode } from '../lib/types'; import { useHotkeySettings } from '../state/HotkeySettingsContext'; import { Btn, Card, PageHeader, Pill } from './_atoms'; -const FILTERS: Array<{ id: 'all' | PolishMode; label: string }> = [ - { id: 'all', label: '全部' }, - { id: 'raw', label: '原文' }, - { id: 'light', label: '轻度润色' }, - { id: 'structured', label: '清晰结构' }, - { id: 'formal', label: '正式表达' }, -]; +function useFilters(): Array<{ id: 'all' | PolishMode; label: string }> { + const { t } = useTranslation(); + return [ + { id: 'all', label: t('history.filterAll') }, + { id: 'raw', label: t('style.modes.raw.name') }, + { id: 'light', label: t('style.modes.light.name') }, + { id: 'structured', label: t('style.modes.structured.name') }, + { id: 'formal', label: t('style.modes.formal.name') }, + ]; +} -const MODE_LABEL: Record = { - raw: '原文', - light: '轻度润色', - structured: '清晰结构', - formal: '正式表达', -}; +function useModeLabel(): Record { + const { t } = useTranslation(); + return { + raw: t('style.modes.raw.name'), + light: t('style.modes.light.name'), + structured: t('style.modes.structured.name'), + formal: t('style.modes.formal.name'), + }; +} export function History() { + const { t } = useTranslation(); + const FILTERS = useFilters(); + const MODE_LABEL = useModeLabel(); const [filter, setFilter] = useState<'all' | PolishMode>('all'); const [items, setItems] = useState([]); const [selectedId, setSelectedId] = useState(null); @@ -56,7 +66,7 @@ export function History() { const onClear = async () => { if (items.length === 0) return; - if (!confirm(`确定清空全部 ${items.length} 条记录?此操作不可恢复。`)) return; + if (!confirm(t('history.confirmClear', { count: items.length }))) return; await clearHistory(); setItems([]); setSelectedId(null); @@ -76,13 +86,13 @@ export function History() { return (
- 刷新 - 清空 + {t('common.refresh')} + {t('common.clear')}
} /> @@ -96,7 +106,7 @@ export function History() { background: 'var(--ol-surface-2)', color: 'var(--ol-ink-3)', }}> - 共 {items.length} 条 · 显示 {filtered.length} + {t('history.summary', { total: items.length, shown: filtered.length })}
{FILTERS.map(f => ( @@ -115,10 +125,10 @@ export function History() {
- {loading &&
加载中…
} + {loading &&
{t('common.loading')}
} {!loading && filtered.length === 0 && (
- 还没有历史记录。按 {getHotkeyTriggerLabel(hotkey?.trigger)} 录一段试试。 + {t('history.empty', { trigger: getHotkeyTriggerLabel(hotkey?.trigger) })}
)} {filtered.map(s => ( @@ -161,15 +171,15 @@ export function History() { {formatDuration(item.durationMs)}
- 复制 - 删除 + {t('common.copy')} + {t('common.delete')}
- 原文 + {t('history.rawLabel')}

- {item.rawTranscript || '(空)'} + {item.rawTranscript || t('history.rawEmpty')}

@@ -180,17 +190,23 @@ export function History() {
- {item.appName && 插入到 {item.appName}} - {item.finalText.length} 字 + {item.appName && {t('history.insertedTo')} {item.appName}} + {t('history.chars', { count: item.finalText.length })} {item.dictionaryEntryCount != null && item.dictionaryEntryCount > 0 && ( - {item.dictionaryEntryCount} 个热词 + {t('history.vocabHits', { count: item.dictionaryEntryCount })} )} - {item.insertStatus === 'inserted' ? '已插入' : item.insertStatus === 'copiedFallback' ? `已复制(需 ${detectOS() === 'win' ? 'Ctrl+V' : '⌘V'})` : '插入失败'} + { + item.insertStatus === 'inserted' + ? t('history.inserted') + : item.insertStatus === 'copiedFallback' + ? t('history.copiedFallback', { shortcut: detectOS() === 'win' ? 'Ctrl+V' : '⌘V' }) + : t('history.insertFailed') + }
) : (
- {loading ? '加载中…' : '左侧选一条查看详情。'} + {loading ? t('common.loading') : t('history.selectHint')}
)} diff --git a/openless-all/app/src/pages/Overview.tsx b/openless-all/app/src/pages/Overview.tsx index df47355f..0368640e 100644 --- a/openless-all/app/src/pages/Overview.tsx +++ b/openless-all/app/src/pages/Overview.tsx @@ -1,6 +1,7 @@ // Overview.tsx — 真实指标,从 listHistory + getCredentials 派生。 import { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { Icon } from '../components/Icon'; import { getHotkeyTriggerLabel } from '../lib/hotkey'; import { getCredentials, listHistory } from '../lib/ipc'; @@ -8,18 +9,23 @@ import type { CredentialsStatus, DictationSession, PolishMode } from '../lib/typ import { useHotkeySettings } from '../state/HotkeySettingsContext'; import { Btn, Card, PageHeader, Pill } from './_atoms'; -const MODE_LABEL: Record = { - raw: '原文', - light: '轻度润色', - structured: '清晰结构', - formal: '正式表达', -}; +function useModeLabels(): Record { + const { t } = useTranslation(); + return { + raw: t('style.modes.raw.name'), + light: t('style.modes.light.name'), + structured: t('style.modes.structured.name'), + formal: t('style.modes.formal.name'), + }; +} interface OverviewProps { onOpenHistory?: () => void; } export function Overview({ onOpenHistory }: OverviewProps) { + const { t } = useTranslation(); + const modeLabel = useModeLabels(); const [history, setHistory] = useState([]); const [creds, setCreds] = useState({ volcengineConfigured: false, @@ -61,9 +67,9 @@ export function Overview({ onOpenHistory }: OverviewProps) { return ( <> - 按 + {t('overview.pressPrefix')} {getHotkeyTriggerLabel(hotkey?.trigger)} - 开始录音 + {t('overview.pressSuffix')} } />
- - - 0 ? '今日均值' : '暂无数据'} /> - + + + 0 ? t('overview.metricAvgTrend') : t('overview.metricNoData')} /> +
- 近 7 天 - 条数 / 天 + {t('overview.weekTitle')} + {t('overview.weekUnit')}
- {weekDayLabels().map((d, i) => {d})} + {weekDayLabels(t('overview.weekDays', { returnObjects: true }) as string[]).map((d, i) => {d})}
- 最近识别 - 全部记录 → + {t('overview.recentTitle')} + {t('overview.recentAll')}
{history.length === 0 && (
- 还没有记录。按 {getHotkeyTriggerLabel(hotkey?.trigger)} 开始第一次录音。 + {t('overview.recentEmpty', { trigger: getHotkeyTriggerLabel(hotkey?.trigger) })}
)} {history.slice(0, 5).map(s => ( - + ))}
@@ -152,6 +158,9 @@ interface ProviderCardProps { } function ProviderCard({ kind, name, subname, configured }: ProviderCardProps) { + const { t } = useTranslation(); + // ASR 卡用 mic 图标,其他用 sparkle —— 通过比较译文判断会随语言改变,故改用本地化无关的字面量比较。 + const isAsr = kind === t('overview.asrKind'); return (
- +
@@ -170,10 +179,10 @@ function ProviderCard({ kind, name, subname, configured }: ProviderCardProps) { {configured ? ( - 已配置 + {t('overview.statusConfigured')} ) : ( - 未配置 + {t('overview.statusNotConfigured')} )}
{name}
@@ -230,14 +239,14 @@ function WeekChart({ data }: { data: number[] }) { ); } -function RecentRow({ session }: { session: DictationSession }) { +function RecentRow({ session, modeLabel }: { session: DictationSession; modeLabel: Record }) { return (
{formatTime(session.createdAt)} - {MODE_LABEL[session.mode]} + {modeLabel[session.mode]}
{session.finalText.split('\n')[0]} @@ -266,8 +275,7 @@ function formatDuration(ms: number): string { return `${Math.floor(sec / 60)}:${String(Math.floor(sec % 60)).padStart(2, '0')}`; } -function weekDayLabels(): string[] { - const names = ['日', '一', '二', '三', '四', '五', '六']; +function weekDayLabels(names: string[]): string[] { const today = new Date().getDay(); const out: string[] = []; for (let i = 6; i >= 0; i--) { diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index 899fa63a..5a9aa2f9 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -1,8 +1,9 @@ // Settings.tsx — ported verbatim from design_handoff_openless/pages.jsx::Settings. -// Internal sub-sections (Recording / Providers / Shortcuts / Permissions / About) +// Internal sub-sections (Recording / Providers / Shortcuts / Permissions / Language / About) // keep their inline-style literals 1:1 with the source JSX. import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; import { Icon } from '../components/Icon'; import { APP_VERSION_LABEL } from '../lib/appVersion'; import { getHotkeyStartStopLabel, getHotkeyTriggerLabel } from '../lib/hotkey'; @@ -27,6 +28,12 @@ import type { PermissionStatus, } from '../lib/types'; import { useHotkeySettings } from '../state/HotkeySettingsContext'; +import i18n, { + FOLLOW_SYSTEM, + getLocalePreference, + setLocalePreference, + type SupportedLocale, +} from '../i18n'; import { Btn, Card, PageHeader, Pill } from './_atoms'; interface SettingsProps { @@ -34,11 +41,13 @@ interface SettingsProps { initialSection?: SettingsSectionId; } -export type SettingsSectionId = '录音' | '提供商' | '快捷键' | '权限' | '关于'; +export type SettingsSectionId = 'recording' | 'providers' | 'shortcuts' | 'permissions' | 'language' | 'about'; -export function Settings({ embedded = false, initialSection = '录音' }: SettingsProps) { +const SECTION_ORDER: SettingsSectionId[] = ['recording', 'providers', 'shortcuts', 'permissions', 'language', 'about']; + +export function Settings({ embedded = false, initialSection = 'recording' }: SettingsProps) { + const { t } = useTranslation(); const [section, setSection] = useState(initialSection); - const sections: SettingsSectionId[] = ['录音', '提供商', '快捷键', '权限', '关于']; useEffect(() => { setSection(initialSection); @@ -48,14 +57,14 @@ export function Settings({ embedded = false, initialSection = '录音' }: Settin <> {!embedded && ( )}
- {sections.map(s => ( + {SECTION_ORDER.map(s => ( ))}
- {section === '录音' && } - {section === '提供商' && } - {section === '快捷键' && } - {section === '权限' && } - {section === '关于' && } + {section === 'recording' && } + {section === 'providers' && } + {section === 'shortcuts' && } + {section === 'permissions' && } + {section === 'language' && } + {section === 'about' && }
@@ -102,12 +112,13 @@ function SettingRow({ label, desc, children }: SettingRowProps) { } function RecordingSection() { + const { t } = useTranslation(); const { prefs, capability, updatePrefs: savePrefs } = useHotkeySettings(); if (!prefs || !capability) { return ( -
加载中…
+
{t('common.loading')}
); } @@ -120,18 +131,18 @@ function RecordingSection() { savePrefs({ ...prefs, showCapsule }); const choices: Array<[HotkeyMode, string]> = [ - ['toggle', '切换式'], - ['hold', '按住说话'], + ['toggle', t('settings.recording.modeToggle')], + ['hold', t('settings.recording.modeHold')], ]; const hotkeyDesc = capability.requiresAccessibilityPermission - ? '按下即开始捕获语音,全局生效。需要授予辅助功能权限。' - : '按下即开始捕获语音,全局生效。无需额外辅助功能授权。'; + ? t('settings.recording.hotkeyDescAcc') + : t('settings.recording.hotkeyDescNoAcc'); return ( -
录音
-
定义全局录音的快捷键与触发方式。
- +
{t('settings.recording.title')}
+
{t('settings.recording.desc')}
+ - +
{choices.map(([v, l]) => ( )} {mask && ( {saved && ( - 已保存 + {t('common.saved')} )}
@@ -440,29 +453,31 @@ const iconBtnStyle: CSSProperties = { }; function ShortcutsSection() { + const { t } = useTranslation(); const { hotkey, capability } = useHotkeySettings(); if (!hotkey || !capability) { return ( -
加载中…
+
{t('common.loading')}
); } const desc = capability.requiresAccessibilityPermission - ? '所有快捷键全局生效,需要在权限设置中开启辅助功能。' - : '所有快捷键全局生效。若无响应,请在权限页查看全局快捷键监听状态。'; + ? t('settings.shortcuts.descAcc') + : t('settings.shortcuts.descNoAcc'); + const notSupported = t('settings.shortcuts.notSupported'); const rows: Array<[string, string]> = [ - ['开始 / 停止录音', getHotkeyStartStopLabel(hotkey)], - ['取消本次录音', 'Esc'], - ['胶囊确认插入', '点击右侧 ✓'], - ['切换上一次风格', capability.requiresAccessibilityPermission ? '⌘ ⇧ S' : '暂未支持'], - ['打开 OpenLess', capability.requiresAccessibilityPermission ? '⌘ ⇧ O' : '暂未支持'], + [t('settings.shortcuts.startStop'), getHotkeyStartStopLabel(hotkey)], + [t('settings.shortcuts.cancel'), 'Esc'], + [t('settings.shortcuts.confirm'), t('settings.shortcuts.confirmHint')], + [t('settings.shortcuts.switchStyle'), capability.requiresAccessibilityPermission ? '⌘ ⇧ S' : notSupported], + [t('settings.shortcuts.openApp'), capability.requiresAccessibilityPermission ? '⌘ ⇧ O' : notSupported], ]; return ( -
快捷键速查
+
{t('settings.shortcuts.title')}
{desc}
{rows.map(([k, v]) => ( @@ -481,6 +496,7 @@ function ShortcutsSection() { } function PermissionsSection() { + const { t } = useTranslation(); const [accessibility, setAccessibility] = useState('loading'); const [microphone, setMicrophone] = useState('loading'); const [hotkey, setHotkey] = useState(null); @@ -528,40 +544,40 @@ function PermissionsSection() { }; const desc = capability?.requiresAccessibilityPermission - ? 'OpenLess 需要以下系统权限才能正常工作。授权后通常需要完全退出 App 重启一次才生效。' - : 'OpenLess 需要麦克风可用,并依赖全局快捷键监听状态判断 native hook 是否正常工作。'; + ? t('settings.permissions.descAcc') + : t('settings.permissions.descNoAcc'); return ( -
权限
+
{t('settings.permissions.title')}
{desc}
- +
{microphone !== 'granted' && microphone !== 'notApplicable' && microphone !== 'loading' && ( - {microphone === 'denied' || microphone === 'restricted' ? '打开系统设置' : '授权'} + {microphone === 'denied' || microphone === 'restricted' ? t('settings.permissions.openSystem') : t('settings.permissions.grant')} )}
{capability?.requiresAccessibilityPermission && ( - +
{accessibility !== 'granted' && accessibility !== 'notApplicable' && ( - 授权 + {t('settings.permissions.grant')} )}
)}
@@ -572,30 +588,63 @@ function PermissionsSection() { )}
- - 可用 + + {t('settings.permissions.networkOk')}
); } function PermissionPill({ status }: { status: PermissionStatus | 'loading' }) { + const { t } = useTranslation(); if (status === 'loading') { - return 检查中…; + return {t('settings.permissions.checking')}; } if (status === 'granted') { - return 已授权; + return {t('settings.permissions.granted')}; } if (status === 'notApplicable') { - return 无需授权; + return {t('settings.permissions.notApplicable')}; } if (status === 'denied' || status === 'restricted') { - return 未授权; + return {t('settings.permissions.denied')}; } - return 未确定; + return {t('settings.permissions.indeterminate')}; +} + +function LanguageSection() { + const { t } = useTranslation(); + const [pref, setPref] = useState(getLocalePreference()); + + const apply = async (next: SupportedLocale | typeof FOLLOW_SYSTEM) => { + setPref(next); + await setLocalePreference(next); + }; + + return ( + +
{t('settings.language.title')}
+
{t('settings.language.desc')}
+ + + +
+ {t('settings.language.restartHint')} +
+
+ ); } function AboutSection() { + const { t } = useTranslation(); const [qqCopied, setQqCopied] = useState(false); const copyQq = () => { @@ -617,14 +666,14 @@ function AboutSection() { >OL
OpenLess
-
自然说话,完美书写 · {APP_VERSION_LABEL}
+
{t('settings.about.tagline')} · {APP_VERSION_LABEL}
- openExternal('https://github.com/appergb/openless/releases')}>打开 Releases - openExternal('https://github.com/appergb/openless')}>GitHub - openExternal('https://github.com/appergb/openless#readme')}>README - openExternal('https://github.com/appergb/openless/issues')}>GitHub Issues - + openExternal('https://github.com/appergb/openless/releases')}>{t('settings.about.openReleases')} + openExternal('https://github.com/appergb/openless')}>GitHub + openExternal('https://github.com/appergb/openless#readme')}>README + openExternal('https://github.com/appergb/openless/issues')}>GitHub Issues +
1078960553 - - {qqCopied && 已复制} + {qqCopied && {t('common.copied')}}
- - 本地优先 + + {t('settings.about.localFirst')} ); } function HotkeyStatusPill({ status }: { status: HotkeyStatus | null }) { + const { t } = useTranslation(); if (!status) { - return 检查中…; + return {t('settings.permissions.checking')}; } if (status.state === 'installed') { - return 已安装; + return {t('settings.permissions.hotkeyInstalled')}; } if (status.state === 'starting') { - return 安装中…; + return {t('settings.permissions.hotkeyStarting')}; } - return 监听失败; + return {t('settings.permissions.hotkeyFailed')}; } function adapterDisplayName(adapter: HotkeyCapability['adapter'] | HotkeyStatus['adapter']) { - if (adapter === 'macEventTap') return 'macOS Event Tap'; - if (adapter === 'windowsLowLevel') return 'Windows 低层键盘 hook'; - return 'rdev 监听器'; + if (adapter === 'macEventTap') return i18n.t('hotkey.adapter.macEventTap'); + if (adapter === 'windowsLowLevel') return i18n.t('hotkey.adapter.windowsLowLevel'); + return i18n.t('hotkey.adapter.rdev'); } diff --git a/openless-all/app/src/pages/Style.tsx b/openless-all/app/src/pages/Style.tsx index 743280cb..df5c5b56 100644 --- a/openless-all/app/src/pages/Style.tsx +++ b/openless-all/app/src/pages/Style.tsx @@ -2,6 +2,7 @@ // defaultMode 来自 prefs.defaultMode,启停从 prefs.enabledModes 反推。 import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { getSettings, setDefaultPolishMode, setStyleEnabled, setSettings } from '../lib/ipc'; import type { PolishMode, UserPreferences } from '../lib/types'; import { PageHeader, Pill } from './_atoms'; @@ -13,34 +14,16 @@ interface StyleDef { sample: string; } -const STYLES: StyleDef[] = [ - { - id: 'raw', - name: '原文', - desc: '只补标点和必要分句,不改写不扩写。', - sample: '保留原始口语;嗯、那个等口癖会被去除,但不会重组语句。', - }, - { - id: 'light', - name: '轻度润色', - desc: '去口癖、补标点,整理为可发送的自然文字。', - sample: '让转写听起来不像念稿——保留语气和表达习惯,但行文流畅。', - }, - { - id: 'structured', - name: '清晰结构', - desc: '多个主题或步骤时,自动组织为分点列表。', - sample: '1. 主题一\n 1) 要点 a\n 2) 要点 b\n2. 主题二\n 1) 要点 c', - }, - { - id: 'formal', - name: '正式表达', - desc: '工作沟通和邮件场景,更专业更完整。', - sample: '邮件场景自动识别问候 / 落款;不引入空泛客套。', - }, -]; +const STYLE_IDS: PolishMode[] = ['raw', 'light', 'structured', 'formal']; export function Style() { + const { t } = useTranslation(); + const STYLES: StyleDef[] = STYLE_IDS.map(id => ({ + id, + name: t(`style.modes.${id}.name`), + desc: t(`style.modes.${id}.desc`), + sample: t(`style.modes.${id}.sample`), + })); const [prefs, setPrefs] = useState(null); useEffect(() => { @@ -66,9 +49,9 @@ export function Style() { if (!prefs) { return ( ); } @@ -92,12 +75,12 @@ export function Style() { return ( <> - 整体启用 + {t('style.masterToggle')} - {isDefault && 当前默认} + {isDefault && {t('style.currentDefault')}} {!isDefault && (
} /> @@ -73,7 +75,7 @@ export function Vocab() {
- 添加 + {t('common.add')}
- 支持中英混合 · 数字开头按字面识别 · 命中次数自动计数 + {t('vocab.tip')}
- {loading &&
加载中…
} + {loading &&
{t('common.loading')}
} {!loading && error && (
- 加载失败:{error} + {t('vocab.loadFailed', { err: error })}
)} {!loading && !error && entries.length === 0 && (
- 还没有词条。在上面输入一个生词或专业术语,让模型在听写时优先匹配。 + {t('vocab.empty')}
)} {!error && entries.map(e => ( @@ -117,6 +119,7 @@ interface VocabChipProps { } function VocabChip({ entry, onRemove, onToggle }: VocabChipProps) { + const { t } = useTranslation(); const enabled = entry.enabled; return (