From 2818edb9fc8bfe10aab231f8e0b971144652ff0e Mon Sep 17 00:00:00 2001 From: Zyy Date: Sat, 24 Oct 2026 00:05:38 +0800 Subject: [PATCH] perf(i18n): load language resources on demand Load and cache the selected language with synchronized UI messages. Keep the latest selection when loads overlap and render missing messages as empty text without language fallback. Co-authored-by: Codex --- docs/theme.md | 2 + frontend/src/components/AppLanguageMenu.vue | 21 +++++++-- frontend/src/composables/useLocale.ts | 8 +--- frontend/src/i18n/index.ts | 47 +++++++++++++++++-- frontend/src/i18n/locales/en/index.ts | 2 + frontend/src/i18n/locales/en/navigation.json | 1 + frontend/src/i18n/locales/zh-Hans/index.ts | 2 + .../src/i18n/locales/zh-Hans/navigation.json | 1 + frontend/src/i18n/locales/zh-Hant/index.ts | 2 + .../src/i18n/locales/zh-Hant/navigation.json | 1 + frontend/src/i18n/messages.ts | 23 +++++---- frontend/src/i18n/ui.ts | 6 +-- frontend/src/main.ts | 16 +++++-- 13 files changed, 103 insertions(+), 29 deletions(-) diff --git a/docs/theme.md b/docs/theme.md index ff2d058b5..1f7226883 100644 --- a/docs/theme.md +++ b/docs/theme.md @@ -71,6 +71,8 @@ 应用使用 Vue I18n Composition API,语言标识为 `en`、`zh-Hans`、`zh-Hant`。资源按领域存放在 `frontend/src/i18n/locales/`,使用 `领域.功能.用途` 的语义 key,例如 `accounts.connectionTest.description`,三种语言保持相同 key 和具名参数 +业务语言包及对应 UI 文案按语言加载,首次只加载当前语言,加载成功后缓存资源。缺失消息显示为空,不回退到其他语言或显示 key;切换时语言包加载失败则保留当前语言 + - 标签、帮助、ARIA、通知和图表显式取消息;列定义、选项和展示投影使用响应式计算,不把翻译结果存进业务数据 - 完整句子使用具名参数,含组件的消息使用 `I18nT`;数量使用 Vue I18n 复数规则,不拆词拼接 - 专业缩写在三种语言中保留原样及规范大小写,例如 `TTFT`、`RPM`、`TPM`、`API`、`HTTP`、`WS`、`RC`,直接显示时不翻译或展开,必要的全称和解释放入帮助提示 diff --git a/frontend/src/components/AppLanguageMenu.vue b/frontend/src/components/AppLanguageMenu.vue index aed4f46ca..1b1b1e30e 100644 --- a/frontend/src/components/AppLanguageMenu.vue +++ b/frontend/src/components/AppLanguageMenu.vue @@ -5,9 +5,10 @@ import { ZIconButton, ZMenuItem, ZPopover } from '@codex-proxy/ui' import { Check, Languages } from '@lucide/vue' import chineseFlag from 'circle-flags/flags/cn.svg?url&no-inline' import englishFlag from 'circle-flags/flags/us.svg?url&no-inline' -import { computed, useTemplateRef } from 'vue' +import { computed, shallowRef, useTemplateRef } from 'vue' import { useI18n } from 'vue-i18n' import { useLocale } from '@/composables/useLocale' +import { notification } from '@/utils/notification' withDefaults(defineProps<{ size?: IconButtonProps['size'], variant?: IconButtonProps['variant'] }>(), { size: 'small', @@ -16,6 +17,7 @@ withDefaults(defineProps<{ size?: IconButtonProps['size'], variant?: IconButtonP const { t } = useI18n({ useScope: 'global' }) const { locale, localeOptions, setLocale } = useLocale() +const pendingLocale = shallowRef() const localeFlags: Record = { 'zh-Hans': chineseFlag, 'zh-Hant': chineseFlag, @@ -30,9 +32,19 @@ function focusSelected() { choices.value?.querySelector('button[aria-pressed="true"]')?.focus() } -function selectLocale(value: Locale, close: () => void) { - setLocale(value) - close() +async function selectLocale(value: Locale, close: () => void) { + pendingLocale.value = value + try { + if (await setLocale(value)) + close() + } + catch { + notification.error({ message: t('navigation.languageLoadFailed') }) + } + finally { + if (pendingLocale.value === value) + pendingLocale.value = undefined + } } @@ -49,6 +61,7 @@ function selectLocale(value: Locale, close: () => void) { v-for="option in localeOptions" :key="option.value" :aria-pressed="option.value === locale" + :loading="pendingLocale === option.value" class="aria-pressed:bg-cp-control-item-bg-active aria-pressed:text-cp-primary-text aria-pressed:hover:bg-cp-control-item-bg-active" @click="selectLocale(option.value, close)" > diff --git a/frontend/src/composables/useLocale.ts b/frontend/src/composables/useLocale.ts index cd8a48cee..ab7455f26 100644 --- a/frontend/src/composables/useLocale.ts +++ b/frontend/src/composables/useLocale.ts @@ -1,13 +1,9 @@ import { useI18n } from 'vue-i18n' -import { isLocale, localeOptions } from '@/i18n/locale' +import { setLocale } from '@/i18n' +import { localeOptions } from '@/i18n/locale' export function useLocale() { const { locale } = useI18n({ useScope: 'global' }) - function setLocale(value: unknown) { - if (isLocale(value)) - locale.value = value - } - return { locale, localeOptions, setLocale } } diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts index 0a808085e..2c8b9e916 100644 --- a/frontend/src/i18n/index.ts +++ b/frontend/src/i18n/index.ts @@ -1,14 +1,55 @@ +import type { Locale } from './locale' import { createI18n } from 'vue-i18n' import { readInitialLocale } from './locale' -import messages from './messages' +import { localeLoaders, uiMessages } from './messages' export const i18n = createI18n({ legacy: false, locale: readInitialLocale(), - fallbackLocale: 'en', - messages, + fallbackLocale: false, + missing: () => '', + messages: {}, }) // 非组件入口与 Vue 组件共享同一 Composer,调用时读取当前语言 export const t: typeof i18n.global.t = i18n.global.t export const { locale } = i18n.global + +const pendingLoads = new Map>() +let selection = 0 + +function loadLocale(value: Locale): Promise { + if (uiMessages[value]) + return Promise.resolve() + + const pending = pendingLoads.get(value) + if (pending) + return pending + + const loading = localeLoaders[value]().then((messages) => { + i18n.global.setLocaleMessage(value, messages.default) + uiMessages[value] = messages.uiLocale + }).finally(() => { + pendingLoads.delete(value) + }) + pendingLoads.set(value, loading) + return loading +} + +export async function setLocale(value: Locale): Promise { + const currentSelection = ++selection + try { + await loadLocale(value) + } + catch (error) { + if (currentSelection !== selection) + return false + throw error + } + // 较早请求的语言包可以缓存,但不能覆盖用户之后的选择 + if (currentSelection !== selection) + return false + + locale.value = value + return true +} diff --git a/frontend/src/i18n/locales/en/index.ts b/frontend/src/i18n/locales/en/index.ts index 89e156c50..d08fb9e4a 100644 --- a/frontend/src/i18n/locales/en/index.ts +++ b/frontend/src/i18n/locales/en/index.ts @@ -12,6 +12,8 @@ import settings from './settings.json' import systemUpdate from './systemUpdate.json' import usage from './usage.json' +export { en as uiLocale } from '@codex-proxy/ui/locale' + export default { ...accounts, ...appearance, diff --git a/frontend/src/i18n/locales/en/navigation.json b/frontend/src/i18n/locales/en/navigation.json index 0ece3b082..c0450c0de 100644 --- a/frontend/src/i18n/locales/en/navigation.json +++ b/frontend/src/i18n/locales/en/navigation.json @@ -14,6 +14,7 @@ "open": "Open System update" }, "switchLanguage": "Switch language ({value})", + "languageLoadFailed": "Unable to load language, reload to try again", "mainLabel": "Main navigation", "pluginsLabel": "Plugin navigation", "pluginManagement": "Manage", diff --git a/frontend/src/i18n/locales/zh-Hans/index.ts b/frontend/src/i18n/locales/zh-Hans/index.ts index 89e156c50..694c9e9a9 100644 --- a/frontend/src/i18n/locales/zh-Hans/index.ts +++ b/frontend/src/i18n/locales/zh-Hans/index.ts @@ -12,6 +12,8 @@ import settings from './settings.json' import systemUpdate from './systemUpdate.json' import usage from './usage.json' +export { zhHans as uiLocale } from '@codex-proxy/ui/locale' + export default { ...accounts, ...appearance, diff --git a/frontend/src/i18n/locales/zh-Hans/navigation.json b/frontend/src/i18n/locales/zh-Hans/navigation.json index e83f6b468..29c3f49a8 100644 --- a/frontend/src/i18n/locales/zh-Hans/navigation.json +++ b/frontend/src/i18n/locales/zh-Hans/navigation.json @@ -14,6 +14,7 @@ "open": "打开系统更新" }, "switchLanguage": "切换语言({value})", + "languageLoadFailed": "语言加载失败,请刷新重试", "mainLabel": "主导航", "pluginsLabel": "插件导航", "pluginManagement": "插件管理", diff --git a/frontend/src/i18n/locales/zh-Hant/index.ts b/frontend/src/i18n/locales/zh-Hant/index.ts index 89e156c50..002c1c1ce 100644 --- a/frontend/src/i18n/locales/zh-Hant/index.ts +++ b/frontend/src/i18n/locales/zh-Hant/index.ts @@ -12,6 +12,8 @@ import settings from './settings.json' import systemUpdate from './systemUpdate.json' import usage from './usage.json' +export { zhHant as uiLocale } from '@codex-proxy/ui/locale' + export default { ...accounts, ...appearance, diff --git a/frontend/src/i18n/locales/zh-Hant/navigation.json b/frontend/src/i18n/locales/zh-Hant/navigation.json index fc87a4d7e..67a4e47bd 100644 --- a/frontend/src/i18n/locales/zh-Hant/navigation.json +++ b/frontend/src/i18n/locales/zh-Hant/navigation.json @@ -14,6 +14,7 @@ "open": "打開系統更新" }, "switchLanguage": "切換語言({value})", + "languageLoadFailed": "語言加載失敗,請刷新重試", "mainLabel": "主導航", "pluginsLabel": "插件導航", "pluginManagement": "插件管理", diff --git a/frontend/src/i18n/messages.ts b/frontend/src/i18n/messages.ts index 8ace74ec2..29c96df03 100644 --- a/frontend/src/i18n/messages.ts +++ b/frontend/src/i18n/messages.ts @@ -1,12 +1,19 @@ +import type { UiLocale } from '@codex-proxy/ui' import type { Locale } from './locale' -import en from './locales/en' -import zhHans from './locales/zh-Hans' -import zhHant from './locales/zh-Hant' +import type en from './locales/en' +import { shallowReactive } from 'vue' export type MessageSchema = typeof en -export default { - en, - 'zh-Hans': zhHans, - 'zh-Hant': zhHant, -} satisfies Record +interface LocaleMessages { + default: MessageSchema + uiLocale: UiLocale +} + +export const localeLoaders = { + 'en': () => import('./locales/en'), + 'zh-Hans': () => import('./locales/zh-Hans'), + 'zh-Hant': () => import('./locales/zh-Hant'), +} satisfies Record Promise> + +export const uiMessages = shallowReactive>>({}) diff --git a/frontend/src/i18n/ui.ts b/frontend/src/i18n/ui.ts index 2598ec30a..ed0290b26 100644 --- a/frontend/src/i18n/ui.ts +++ b/frontend/src/i18n/ui.ts @@ -1,8 +1,8 @@ import type { UiLocale } from '@codex-proxy/ui' import type { Locale } from './locale' -import { en, zhHans, zhHant } from '@codex-proxy/ui/locale' import { computed } from 'vue' import { locale } from './index' +import { uiMessages } from './messages' -const messages: Record = { en, 'zh-Hans': zhHans, 'zh-Hant': zhHant } -export const uiLocale = computed(() => messages[locale.value as Locale]) +// 挂载和切换语言均等待资源就绪,组件只读取已加载的语言 +export const uiLocale = computed(() => uiMessages[locale.value as Locale] as UiLocale) diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 1cd211fd1..22b51d026 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -1,6 +1,7 @@ import { createApp } from 'vue' import App from './App.vue' -import { i18n } from './i18n' +import { i18n, setLocale } from './i18n' +import { readInitialLocale } from './i18n/locale' import { authPlugin } from './plugins/auth' import { router } from './router' @@ -15,10 +16,15 @@ import './styles/index.css' const app = createApp(App) app.use(pinia) -app.use(i18n) useThemeStore(pinia).initializeTheme() -app.use(router) -app.use(authPlugin) -app.mount('#app') +async function mountApp() { + await setLocale(readInitialLocale()) + app.use(i18n) + app.use(router) + app.use(authPlugin) + app.mount('#app') +} + +void mountApp()