feat(web): streamline Web WASM locale startup

This commit is contained in:
digua committed 2026-07-25 01:14:04 +08:00
1 parent 088d3ee54d
commit 14657847fe
12 files changed
+127 -35

No files matched your search

+1 -1
View File
@@ -1,5 +1,5 @@
<!doctype html>
<html lang="zh-CN">
<html lang="en-US">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
@@ -0,0 +1,12 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { resolveWebWasmInitialLocale } from './locale-bootstrap'
test('uses the saved Web WASM locale before browser language detection', () => {
assert.equal(resolveWebWasmInitialLocale('ja-JP', ['zh-CN']), 'ja-JP')
})
test('detects the browser language for a first visit or invalid saved locale', () => {
assert.equal(resolveWebWasmInitialLocale('', ['zh-Hant-TW']), 'zh-TW')
assert.equal(resolveWebWasmInitialLocale('unsupported', ['en-GB']), 'en-US')
})
+22
View File
@@ -0,0 +1,22 @@
import { detectSystemLocale, isValidLocale, setLocale, type LocaleType } from '@/i18n'
import { WEB_WASM_LOCALE_STORAGE_KEY } from '@/services/preferences/browser'
export function resolveWebWasmInitialLocale(
savedLocale: string | null | undefined,
browserLanguages?: readonly string[]
): LocaleType {
return savedLocale && isValidLocale(savedLocale) ? savedLocale : detectSystemLocale(browserLanguages)
}
export function initializeWebWasmLocale(): LocaleType {
let savedLocale: string | null = null
try {
savedLocale = globalThis.localStorage?.getItem(WEB_WASM_LOCALE_STORAGE_KEY) ?? null
} catch {
// Storage may be unavailable in hardened browser contexts; browser language remains a safe default.
}
const locale = resolveWebWasmInitialLocale(savedLocale)
setLocale(locale)
return locale
}
+3
View File
@@ -16,8 +16,11 @@ import { registerWebWasmAdapters } from '@/services/browser-runtime/register'
import { handleWebWasmPageHide } from './runtime-lifecycle'
import { WebWasmSessionSync } from './session-sync'
import { useSessionStore } from '@/stores/session'
import { initializeWebWasmLocale } from './locale-bootstrap'
import '@/assets/styles/main.css'
initializeWebWasmLocale()
async function start(): Promise<void> {
reportRuntimeLog({ level: 'info', scope: 'web-wasm-bootstrap', message: 'Initializing Web WASM services' })
installGlobalErrorReporting()
+1 -14
View File
@@ -5,10 +5,8 @@ import { useI18n } from 'vue-i18n'
import { storeToRefs } from 'pinia'
import logoSvg from '@/assets/images/logo.svg'
import AgreementModal from '@/components/home/AgreementModal.vue'
import ChangelogModal from '@/components/home/ChangelogModal.vue'
import HomeFooter from '@/components/home/HomeFooter.vue'
import ImportArea from '@/components/import/ImportArea.vue'
import LanguageSelectModal from '@/components/home/LanguageSelectModal.vue'
import { availableLocales, type LocaleType } from '@/i18n'
import { useSettingsStore } from '@/stores/settings'
import { useSessionStore } from '@/stores/session'
@@ -20,7 +18,6 @@ const sessionStore = useSessionStore()
const { sessions } = storeToRefs(sessionStore)
const isMounted = ref(false)
const languageMenuOpen = ref(false)
const changelogModalRef = ref<InstanceType<typeof ChangelogModal> | null>(null)
const agreementModalRef = ref<InstanceType<typeof AgreementModal> | null>(null)
const isDark = useDark()
const toggleDark = useToggle(isDark)
@@ -170,20 +167,10 @@ onMounted(() => {
</section>
</main>
<HomeFooter
:remote-config-enabled="false"
@open-changelog="changelogModalRef?.open()"
@open-terms="agreementModalRef?.open()"
/>
<HomeFooter :remote-config-enabled="false" :show-changelog="false" @open-terms="agreementModalRef?.open()" />
</div>
<!--
Web WASM intentionally does not gate its fully local import flow on agreement acceptance.
Keep the agreement available from HomeFooter only; do not open it from LanguageSelectModal's done event.
-->
<LanguageSelectModal />
<AgreementModal ref="agreementModalRef" />
<ChangelogModal ref="changelogModalRef" />
</div>
</template>
+12 -5
View File
@@ -4,11 +4,17 @@ import { useI18n } from 'vue-i18n'
import { CHATLAB_SITE_BASE, getChatlabSiteLocalePath, getChatlabSiteLangQuery } from '@/utils/chatlabSiteLocale'
import { IS_ELECTRON } from '@/utils/platform'
import { usePlatformService } from '@/services'
import { resolveHomeFooterConfigSource } from './home-footer-config'
import { filterHomeFooterLinks, resolveHomeFooterConfigSource } from './home-footer-config'
const props = defineProps<{
remoteConfigEnabled: boolean
}>()
const props = withDefaults(
defineProps<{
remoteConfigEnabled: boolean
showChangelog?: boolean
}>(),
{
showChangelog: true,
}
)
const emit = defineEmits<{
openChangelog: []
@@ -69,6 +75,7 @@ function getDefaultLinks(): FooterLink[] {
}
const footerLinks = ref<FooterLink[]>(getDefaultLinks())
const visibleFooterLinks = computed(() => filterHomeFooterLinks(footerLinks.value, props.showChangelog))
// 社交链接配置(由远程配置 socialData 直接提供)
interface SocialData {
@@ -210,7 +217,7 @@ watch(locale, () => {
<template>
<div class="absolute bottom-4 left-0 right-0">
<div class="flex items-center justify-center">
<template v-for="(link, index) in footerLinks" :key="link.id">
<template v-for="(link, index) in visibleFooterLinks" :key="link.id">
<!-- 分隔点 -->
<span v-if="index > 0" class="mx-2 text-gray-300 dark:text-gray-600">·</span>
<!-- 链接按钮 -->
+12 -1
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { resolveHomeFooterConfigSource } from './home-footer-config'
import { filterHomeFooterLinks, resolveHomeFooterConfigSource } from './home-footer-config'
test('keeps Web WASM footer configuration cache-only', () => {
assert.equal(resolveHomeFooterConfigSource({ remoteConfigEnabled: false, isElectron: false }), 'cache-only')
@@ -13,3 +13,14 @@ test('uses the platform adapter for Electron footer configuration', () => {
test('uses direct network fetch for CLI Web footer configuration', () => {
assert.equal(resolveHomeFooterConfigSource({ remoteConfigEnabled: true, isElectron: false }), 'network')
})
test('hides bundled and cached changelog links when the application disables changelogs', () => {
const links = [
{ id: 'website', url: 'https://chatlab.fun' },
{ id: 'changelog', action: 'changelog' },
{ id: 'legacy-release-notes', action: 'changelog' },
]
assert.deepEqual(filterHomeFooterLinks(links, false), [links[0]])
assert.deepEqual(filterHomeFooterLinks(links, true), links)
})
@@ -9,3 +9,11 @@ export function resolveHomeFooterConfigSource(options: HomeFooterConfigSourceOpt
if (!options.remoteConfigEnabled) return 'cache-only'
return options.isElectron ? 'platform' : 'network'
}
export function filterHomeFooterLinks<T extends { id: string; action?: string }>(
links: readonly T[],
showChangelog: boolean
): T[] {
if (showChangelog) return [...links]
return links.filter((link) => link.id !== 'changelog' && link.action !== 'changelog')
}
+9 -1
View File
@@ -25,12 +25,19 @@ function getInitialLocale(): LocaleType {
return detectSystemLocale()
}
function setDocumentLocale(locale: LocaleType): void {
if (typeof document !== 'undefined') document.documentElement.lang = locale
}
const initialLocale = getInitialLocale()
setDocumentLocale(initialLocale)
/**
* 创建 i18n 实例
*/
export const i18n = createI18n({
legacy: false,
locale: getInitialLocale(),
locale: initialLocale,
fallbackLocale: 'en-US',
messages: {
'zh-CN': zhCN,
@@ -45,6 +52,7 @@ export const i18n = createI18n({
*/
export function setLocale(locale: LocaleType) {
i18n.global.locale.value = locale
setDocumentLocale(locale)
}
/**
+17
View File
@@ -0,0 +1,17 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { detectSystemLocale } from './types'
test('detects supported locales from the browser language preference order', () => {
assert.equal(detectSystemLocale(['zh-Hant-HK', 'en-US']), 'zh-TW')
assert.equal(detectSystemLocale(['zh-HK']), 'zh-TW')
assert.equal(detectSystemLocale(['zh-CN']), 'zh-CN')
assert.equal(detectSystemLocale(['ja-JP']), 'ja-JP')
assert.equal(detectSystemLocale(['en-GB']), 'en-US')
})
test('uses the first supported browser language and falls back to English', () => {
assert.equal(detectSystemLocale(['fr-FR', 'ja-JP', 'en-US']), 'ja-JP')
assert.equal(detectSystemLocale(['fr-FR']), 'en-US')
assert.equal(detectSystemLocale([]), 'en-US')
})
+27 -10
View File
@@ -42,20 +42,37 @@ export const defaultLocale: LocaleType = 'zh-CN'
/**
* 检测系统语言
*/
export function detectSystemLocale(): LocaleType {
const systemLocale = navigator.language
if (systemLocale === 'zh-TW' || systemLocale === 'zh-Hant') {
return 'zh-TW'
}
if (systemLocale.startsWith('zh')) {
return 'zh-CN'
}
if (systemLocale.startsWith('ja')) {
return 'ja-JP'
export function detectSystemLocale(languages: readonly string[] = getBrowserLanguages()): LocaleType {
for (const language of languages) {
const locale = matchSupportedLocale(language)
if (locale) return locale
}
return 'en-US'
}
function getBrowserLanguages(): readonly string[] {
if (typeof navigator === 'undefined') return []
return navigator.languages.length > 0 ? navigator.languages : [navigator.language]
}
function matchSupportedLocale(language: string): LocaleType | null {
const normalized = language.trim().replaceAll('_', '-').toLowerCase()
if (!normalized) return null
if (
normalized === 'zh-tw' ||
normalized.startsWith('zh-hant') ||
normalized.startsWith('zh-hk') ||
normalized.startsWith('zh-mo') ||
normalized.startsWith('zh-cht')
) {
return 'zh-TW'
}
if (normalized.startsWith('zh')) return 'zh-CN'
if (normalized.startsWith('ja')) return 'ja-JP'
if (normalized.startsWith('en')) return 'en-US'
return null
}
// ========== Locale 辅助函数 ==========
/**
+3 -3
View File
@@ -2,7 +2,7 @@ import type { Preferences, PreferencesAdapter, UiConfig } from './types'
const PREFERENCES_KEY = 'chatlab:web-wasm:preferences'
const UI_CONFIG_KEY = 'chatlab:web-wasm:ui-config'
const LOCALE_KEY = 'chatlab:web-wasm:locale'
export const WEB_WASM_LOCALE_STORAGE_KEY = 'chatlab:web-wasm:locale'
type StoredUiConfig = Omit<UiConfig, 'default_session_tab'> & {
default_session_tab: UiConfig['default_session_tab'] | 'overview'
@@ -42,12 +42,12 @@ export class BrowserPreferencesAdapter implements PreferencesAdapter {
}
async getLocale(): Promise<string> {
return this.storage.getItem(LOCALE_KEY) ?? ''
return this.storage.getItem(WEB_WASM_LOCALE_STORAGE_KEY) ?? ''
}
async saveLocale(lang: string): Promise<{ success: boolean; error?: string }> {
try {
this.storage.setItem(LOCALE_KEY, lang)
this.storage.setItem(WEB_WASM_LOCALE_STORAGE_KEY, lang)
return { success: true }
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }