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