mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
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:
@@ -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`,直接显示时不翻译或展开,必要的全称和解释放入帮助提示
|
||||
|
||||
@@ -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)"
|
||||
>
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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": "插件管理",
|
||||
|
||||
@@ -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>>>({})
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user