feat: align with upstream 1.5.9.9
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import * as zhCN from './zh-CN'
|
||||
|
||||
export const supportedLocales = [
|
||||
{ code: 'zh-CN', label: '简体中文' },
|
||||
{ code: 'en-US', label: 'English' }
|
||||
] as const
|
||||
|
||||
export type Locale = (typeof supportedLocales)[number]['code']
|
||||
type LocaleValue<T> = T extends (...args: infer Args) => infer Return
|
||||
? (...args: Args) => Return
|
||||
: T extends readonly (infer Item)[]
|
||||
? readonly LocaleValue<Item>[]
|
||||
: T extends string
|
||||
? string
|
||||
: T extends object
|
||||
? { [Key in keyof T]: LocaleValue<T[Key]> }
|
||||
: T
|
||||
|
||||
export type LocaleMessages = LocaleValue<typeof zhCN>
|
||||
type LocaleSectionKey = keyof LocaleMessages
|
||||
type LegacyNestedSectionKey = 'auth' | 'ui' | 'songs'
|
||||
|
||||
const LOCALE_STORAGE_KEY = 'voicehub.locale'
|
||||
export const LOCALE_COOKIE_KEY = 'voicehub.locale'
|
||||
// 语言偏好来源:'manual' = 用户手动选择(长期记住),'system' = 自动跟随系统(每次进入重新解析)
|
||||
export const LOCALE_PREFERENCE_KEY = 'voicehub.locale.pref'
|
||||
export type LocalePreference = 'manual' | 'system'
|
||||
export const FALLBACK_LOCALE: Locale = 'zh-CN'
|
||||
|
||||
// 语言 cookie 共用配置(长期有效,跟随手动/系统模式一起写入)
|
||||
const localeCookieOptions = {
|
||||
maxAge: 60 * 60 * 24 * 365,
|
||||
sameSite: 'lax' as const,
|
||||
path: '/'
|
||||
}
|
||||
|
||||
export const isSupportedLocale = (locale: string | null | undefined): locale is Locale =>
|
||||
supportedLocales.some((item) => item.code === locale)
|
||||
|
||||
// 词典按需加载:中文作为兜底与合并基底静态内置,其余语言在被激活时才动态加载,
|
||||
// 避免默认语言用户下载不需要的语言包。
|
||||
const zhMessages = zhCN as unknown as LocaleMessages
|
||||
|
||||
const localeLoaders: Record<Locale, () => Promise<LocaleMessages>> = {
|
||||
'zh-CN': () => Promise.resolve(zhMessages),
|
||||
'en-US': () => import('./en-US').then((module) => module as unknown as LocaleMessages)
|
||||
}
|
||||
|
||||
// 已加载词典缓存。词典内容不可变,可在服务端跨请求安全共享(只做追加)。
|
||||
const loadedMessages = ref<Partial<Record<Locale, LocaleMessages>>>({
|
||||
'zh-CN': zhMessages
|
||||
})
|
||||
const loadingPromises: Partial<Record<Locale, Promise<LocaleMessages>>> = {}
|
||||
|
||||
export function isLocaleMessagesLoaded(locale: Locale): boolean {
|
||||
return Boolean(loadedMessages.value[locale])
|
||||
}
|
||||
|
||||
export async function loadLocaleMessages(locale: Locale): Promise<void> {
|
||||
if (!isSupportedLocale(locale) || loadedMessages.value[locale]) return
|
||||
|
||||
if (!loadingPromises[locale]) {
|
||||
loadingPromises[locale] = localeLoaders[locale]().then((module) => {
|
||||
loadedMessages.value = { ...loadedMessages.value, [locale]: module }
|
||||
return module
|
||||
})
|
||||
}
|
||||
|
||||
await loadingPromises[locale]
|
||||
}
|
||||
|
||||
// 客户端语言解析:用户手动偏好(localStorage)→ 浏览器/系统语言 → 兜底。
|
||||
// 自动跟随系统的解析结果不写入 localStorage,系统语言变化后才能重新跟随。
|
||||
export function resolveClientInitialLocale(): Locale {
|
||||
if (!import.meta.client) return FALLBACK_LOCALE
|
||||
|
||||
const savedLocale = window.localStorage.getItem(LOCALE_STORAGE_KEY)
|
||||
const preference = window.localStorage.getItem(LOCALE_PREFERENCE_KEY)
|
||||
if (preference === 'manual' && isSupportedLocale(savedLocale)) return savedLocale
|
||||
|
||||
// navigator.languages 为浏览器首选语言列表(通常跟随系统语言),逐项匹配更准确
|
||||
const browserLanguages =
|
||||
window.navigator.languages?.length > 0 ? window.navigator.languages : [window.navigator.language]
|
||||
for (const language of browserLanguages) {
|
||||
if (!language) continue
|
||||
if (isSupportedLocale(language)) return language
|
||||
const normalized = language.toLowerCase()
|
||||
if (normalized.startsWith('zh')) return 'zh-CN'
|
||||
if (normalized.startsWith('en')) return 'en-US'
|
||||
}
|
||||
|
||||
return FALLBACK_LOCALE
|
||||
}
|
||||
|
||||
// 服务端语言解析:根据 Accept-Language 头选择最合适的受支持语言。
|
||||
export function resolveLocaleFromAcceptLanguage(header?: string | null): Locale {
|
||||
if (!header) return FALLBACK_LOCALE
|
||||
|
||||
// 解析 (语言, q 权重) 对,缺省 q=1,按 q 稳定降序后再匹配;
|
||||
// 否则会忽略权重,对 `zh;q=0.1,en;q=0.9` 这类头选错语言。
|
||||
const languages = header
|
||||
.split(',')
|
||||
.map((part) => {
|
||||
const [lang, ...params] = part.trim().split(';')
|
||||
const qParam = params.find((p) => p.trim().startsWith('q='))
|
||||
const q = qParam ? Number(qParam.split('=')[1]) : 1
|
||||
return { lang: lang?.trim().toLowerCase() ?? '', q: Number.isFinite(q) ? q : 0 }
|
||||
})
|
||||
.filter((item) => item.lang)
|
||||
.sort((a, b) => b.q - a.q)
|
||||
|
||||
for (const { lang } of languages) {
|
||||
if (lang.startsWith('zh')) return 'zh-CN'
|
||||
if (lang.startsWith('en')) return 'en-US'
|
||||
}
|
||||
|
||||
return FALLBACK_LOCALE
|
||||
}
|
||||
|
||||
// 当前语言状态:
|
||||
// - 在 Nuxt 上下文内用 useState:SSR 下每个请求相互隔离,客户端首屏自动水合,避免模块级 ref 造成的跨请求串扰;
|
||||
// - 在 Nuxt 上下文外(如个别 composable 在模块加载期即被实例化)回退到模块级 ref,避免 useState 脱离 Nuxt 实例而报错。
|
||||
// 注意:服务端的模块级 ref 为所有请求共享,但它仅在「无 Nuxt 上下文」的模块初始化期被读取(此时尚无请求态语言),
|
||||
// 且 setLocale 在服务端不会写它(见下),因此只会返回兜底默认值,不存在跨请求语言串扰。
|
||||
const fallbackLocaleRef = ref<Locale>(FALLBACK_LOCALE)
|
||||
const getCurrentLocale = () =>
|
||||
tryUseNuxtApp()
|
||||
? useState<Locale>('voicehub-locale', () => fallbackLocaleRef.value)
|
||||
: fallbackLocaleRef
|
||||
|
||||
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
||||
Object.prototype.toString.call(value) === '[object Object]'
|
||||
|
||||
const mergeLocaleFallback = <T>(fallbackValue: T, currentValue: unknown): T => {
|
||||
if (Array.isArray(fallbackValue) || typeof fallbackValue === 'function') {
|
||||
return (currentValue ?? fallbackValue) as T
|
||||
}
|
||||
|
||||
if (!isPlainObject(fallbackValue)) {
|
||||
return (currentValue ?? fallbackValue) as T
|
||||
}
|
||||
|
||||
const currentObject = isPlainObject(currentValue) ? currentValue : {}
|
||||
const merged = { ...fallbackValue } as Record<string, unknown>
|
||||
|
||||
for (const key of Object.keys(currentObject)) {
|
||||
merged[key] = currentObject[key]
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(fallbackValue)) {
|
||||
merged[key] = mergeLocaleFallback(value, currentObject[key])
|
||||
}
|
||||
|
||||
return merged as T
|
||||
}
|
||||
|
||||
const getLocaleSection = <Key extends LocaleSectionKey>(
|
||||
localeMessages: LocaleMessages,
|
||||
key: Key
|
||||
) => {
|
||||
const section = localeMessages[key]
|
||||
|
||||
// 兼容早期迁移时 auth/ui/songs 被临时挂在 pages 下的结构,避免 SSR 首屏读取空对象崩溃。
|
||||
if (
|
||||
section === undefined &&
|
||||
(key === 'auth' || key === 'ui' || key === 'songs') &&
|
||||
isPlainObject(localeMessages.pages)
|
||||
) {
|
||||
return localeMessages.pages[key as LegacyNestedSectionKey] as LocaleMessages[Key]
|
||||
}
|
||||
|
||||
return section
|
||||
}
|
||||
|
||||
// 合并结果缓存:词典内容不可变,(语言, 段) 的深合并结果只需计算一次。
|
||||
// 仅在目标语言词典「已真正加载」后才缓存,避免把「加载完成前的临时回退结果」错误固化。
|
||||
const mergedSectionCache = new Map<string, unknown>()
|
||||
|
||||
const getMergedSection = <Key extends LocaleSectionKey>(
|
||||
locale: Locale,
|
||||
key: Key
|
||||
): LocaleMessages[Key] => {
|
||||
const loaded = loadedMessages.value[locale]
|
||||
const currentMessages = loaded ?? zhMessages
|
||||
const computeMerged = () =>
|
||||
mergeLocaleFallback(getLocaleSection(zhMessages, key), getLocaleSection(currentMessages, key))
|
||||
|
||||
// 目标语言尚未加载完成时,实时合并且不缓存;待加载完成后再固化正确结果。
|
||||
if (!loaded) return computeMerged()
|
||||
|
||||
const cacheKey = `${locale}:${String(key)}`
|
||||
const cached = mergedSectionCache.get(cacheKey)
|
||||
if (cached !== undefined) return cached as LocaleMessages[Key]
|
||||
|
||||
const result = computeMerged()
|
||||
mergedSectionCache.set(cacheKey, result)
|
||||
return result
|
||||
}
|
||||
|
||||
export function setLocale(locale: Locale, manual = false) {
|
||||
if (!isSupportedLocale(locale)) return
|
||||
|
||||
// 与读取路径解耦:存在 Nuxt 上下文时更新 useState(请求隔离),
|
||||
// 客户端另同步模块级回退 ref,避免两者出现 split-brain 导致部分消费者收不到切换。
|
||||
if (tryUseNuxtApp()) {
|
||||
useState<Locale>('voicehub-locale', () => fallbackLocaleRef.value).value = locale
|
||||
// 语言与偏好来源写入 cookie:manual 长期记住;system 仅本次会话,下次进入重新跟随系统语言
|
||||
const localeCookie = useCookie<Locale | undefined>(LOCALE_COOKIE_KEY, localeCookieOptions)
|
||||
const preferenceCookie = useCookie<LocalePreference | undefined>(
|
||||
LOCALE_PREFERENCE_KEY,
|
||||
localeCookieOptions
|
||||
)
|
||||
localeCookie.value = locale
|
||||
preferenceCookie.value = manual ? 'manual' : 'system'
|
||||
}
|
||||
// 触发目标语言词典的按需加载;加载完成后相关 computed 会自动更新。
|
||||
void loadLocaleMessages(locale)
|
||||
|
||||
if (import.meta.client) {
|
||||
// 客户端同步模块级回退 ref,使在模块加载期实例化的单例(如共享歌词实例)也能响应语言切换。
|
||||
fallbackLocaleRef.value = locale
|
||||
if (manual) {
|
||||
try {
|
||||
window.localStorage.setItem(LOCALE_STORAGE_KEY, locale)
|
||||
window.localStorage.setItem(LOCALE_PREFERENCE_KEY, 'manual')
|
||||
} catch {
|
||||
// 忽略隐私模式等场景下的持久化失败
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 切换到跟随系统模式:清除手动偏好并立即按系统语言切换。
|
||||
export function followSystemLocale(): Locale {
|
||||
if (import.meta.client) {
|
||||
try {
|
||||
window.localStorage.removeItem(LOCALE_STORAGE_KEY)
|
||||
window.localStorage.removeItem(LOCALE_PREFERENCE_KEY)
|
||||
} catch {
|
||||
// 忽略隐私模式等场景下的清除失败
|
||||
}
|
||||
}
|
||||
// 清除偏好后再解析,确保取到的是系统语言而非历史手动选择
|
||||
const resolved = import.meta.client ? resolveClientInitialLocale() : FALLBACK_LOCALE
|
||||
setLocale(resolved, false)
|
||||
return resolved
|
||||
}
|
||||
|
||||
export function useLocale() {
|
||||
const currentLocale = getCurrentLocale()
|
||||
// 偏好来源标志(manual=用户手动选择 / system=跟随系统);无 Nuxt 上下文(模块加载期)时视为跟随系统
|
||||
const preferenceCookie = tryUseNuxtApp()
|
||||
? useCookie<LocalePreference | undefined>(LOCALE_PREFERENCE_KEY, localeCookieOptions)
|
||||
: null
|
||||
// 当前是否为「跟随系统」模式(未手动选择过语言)
|
||||
const isFollowingSystem = computed(() => preferenceCookie?.value !== 'manual')
|
||||
const withFallback = <Key extends keyof LocaleMessages>(key: Key) =>
|
||||
computed(() => getMergedSection(currentLocale.value, key))
|
||||
|
||||
return {
|
||||
currentLocale,
|
||||
isFollowingSystem,
|
||||
followSystemLocale,
|
||||
supportedLocales,
|
||||
setLocale,
|
||||
loadLocaleMessages,
|
||||
siteConfig: withFallback('siteConfig'),
|
||||
changePassword: withFallback('changePassword'),
|
||||
common: withFallback('common'),
|
||||
pages: withFallback('pages'),
|
||||
auth: withFallback('auth'),
|
||||
ui: withFallback('ui'),
|
||||
audioPlayer: withFallback('audioPlayer'),
|
||||
composableErrors: withFallback('composableErrors'),
|
||||
songs: withFallback('songs'),
|
||||
admin: withFallback('admin'),
|
||||
yearReview: withFallback('yearReview'),
|
||||
serverErrors: withFallback('serverErrors'),
|
||||
theme: withFallback('theme') as unknown as typeof import('./zh-CN').theme,
|
||||
importantNotification: withFallback('importantNotification')
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user