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 <noreply@openai.com>
This commit is contained in:
Zyy
2026-10-24 00:05:38 +08:00
co-authored by Codex
parent 1d1f34ba7e
commit 2818edb9fc
13 changed files with 103 additions and 29 deletions
+2
View File
@@ -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`,直接显示时不翻译或展开,必要的全称和解释放入帮助提示
+17 -4
View File
@@ -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<Locale>()
const localeFlags: Record<Locale, string> = {
'zh-Hans': chineseFlag,
'zh-Hant': chineseFlag,
@@ -30,9 +32,19 @@ function focusSelected() {
choices.value?.querySelector<HTMLButtonElement>('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
}
}
</script>
@@ -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)"
>
+2 -6
View File
@@ -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 }
}
+44 -3
View File
@@ -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<Locale, Promise<void>>()
let selection = 0
function loadLocale(value: Locale): Promise<void> {
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<boolean> {
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
}
+2
View File
@@ -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,
@@ -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",
@@ -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,
@@ -14,6 +14,7 @@
"open": "打开系统更新"
},
"switchLanguage": "切换语言({value})",
"languageLoadFailed": "语言加载失败,请刷新重试",
"mainLabel": "主导航",
"pluginsLabel": "插件导航",
"pluginManagement": "插件管理",
@@ -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,
@@ -14,6 +14,7 @@
"open": "打開系統更新"
},
"switchLanguage": "切換語言({value})",
"languageLoadFailed": "語言加載失敗,請刷新重試",
"mainLabel": "主導航",
"pluginsLabel": "插件導航",
"pluginManagement": "插件管理",
+15 -8
View File
@@ -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<Locale, MessageSchema>
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<Locale, () => Promise<LocaleMessages>>
export const uiMessages = shallowReactive<Partial<Record<Locale, UiLocale>>>({})
+3 -3
View File
@@ -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<Locale, UiLocale> = { 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)
+11 -5
View File
@@ -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()