diff --git a/dashboard/src/i18n/composables.ts b/dashboard/src/i18n/composables.ts index c67d749034..9d972c8b79 100644 --- a/dashboard/src/i18n/composables.ts +++ b/dashboard/src/i18n/composables.ts @@ -1,5 +1,5 @@ import { ref, computed } from 'vue'; -import { translations as staticTranslations } from './translations'; +import { SUPPORTED_LOCALES, isLocaleSupported, loadLocaleTranslations } from './translations'; import type { Locale } from './types'; // 全局状态 @@ -10,35 +10,35 @@ const translations = ref>({}); * 初始化i18n系统 */ export async function initI18n(locale: Locale = 'zh-CN') { - currentLocale.value = locale; - - // 加载静态翻译数据 - loadTranslations(locale); + // 加载翻译数据并获取实际生效语言(可能回退到zh-CN) + const effectiveLocale = await loadTranslations(locale); + currentLocale.value = effectiveLocale; } /** - * 加载翻译数据(现在从静态导入获取) + * 加载翻译数据并返回实际生效语言 */ -function loadTranslations(locale: Locale) { +async function loadTranslations(locale: Locale): Promise { try { - const data = staticTranslations[locale]; - if (data) { - translations.value = data; - } else { - console.warn(`Translations not found for locale: ${locale}`); - // 回退到中文 - if (locale !== 'zh-CN') { - console.log('Falling back to zh-CN'); - translations.value = staticTranslations['zh-CN']; - } - } + const data = await loadLocaleTranslations(locale); + translations.value = data; + return locale; } catch (error) { console.error(`Failed to load translations for ${locale}:`, error); + // 回退到中文 if (locale !== 'zh-CN') { - console.log('Falling back to zh-CN'); - translations.value = staticTranslations['zh-CN']; + try { + console.log('Falling back to zh-CN'); + translations.value = await loadLocaleTranslations('zh-CN'); + return 'zh-CN'; + } catch (fallbackError) { + console.error('Failed to load fallback translations for zh-CN:', fallbackError); + } } + + // 保持原有语言状态不变,避免状态与已加载翻译不一致 + return currentLocale.value; } } @@ -84,17 +84,22 @@ export function useI18n() { // 切换语言 const setLocale = async (newLocale: Locale) => { if (newLocale !== currentLocale.value) { - currentLocale.value = newLocale; - loadTranslations(newLocale); + const previousLocale = currentLocale.value; + const effectiveLocale = await loadTranslations(newLocale); + currentLocale.value = effectiveLocale; + + if (effectiveLocale === previousLocale) { + return; + } // 保存到localStorage - localStorage.setItem('astrbot-locale', newLocale); + localStorage.setItem('astrbot-locale', effectiveLocale); // 触发自定义事件,通知相关页面重新加载配置数据 // 这是因为插件适配器的 i18n 数据是通过后端 API 注入的, // 需要根据 Accept-Language 头重新获取 window.dispatchEvent(new CustomEvent('astrbot-locale-changed', { - detail: { locale: newLocale } + detail: { locale: effectiveLocale } })); } }; @@ -103,7 +108,7 @@ export function useI18n() { const locale = computed(() => currentLocale.value); // 获取可用语言列表 - const availableLocales: Locale[] = ['zh-CN', 'en-US', 'ru-RU']; + const availableLocales: Locale[] = [...SUPPORTED_LOCALES]; // 检查是否已加载 const isLoaded = computed(() => Object.keys(translations.value).length > 0); @@ -221,7 +226,7 @@ function deepMerge(target: Record, source: Record) { export async function setupI18n() { // 从localStorage获取保存的语言设置 const savedLocale = localStorage.getItem('astrbot-locale') as Locale; - const initialLocale = savedLocale && ['zh-CN', 'en-US', 'ru-RU'].includes(savedLocale) + const initialLocale = savedLocale && isLocaleSupported(savedLocale) ? savedLocale : 'zh-CN'; diff --git a/dashboard/src/i18n/translations.ts b/dashboard/src/i18n/translations.ts index fa15e619ff..4f1484f48f 100644 --- a/dashboard/src/i18n/translations.ts +++ b/dashboard/src/i18n/translations.ts @@ -1,7 +1,3 @@ -// 静态导入所有翻译文件 -// 这种方式确保构建时所有翻译都会被正确打包 - -// 中文翻译 import zhCNCommon from './locales/zh-CN/core/common.json'; import zhCNActions from './locales/zh-CN/core/actions.json'; import zhCNStatus from './locales/zh-CN/core/status.json'; @@ -42,237 +38,235 @@ import zhCNErrors from './locales/zh-CN/messages/errors.json'; import zhCNSuccess from './locales/zh-CN/messages/success.json'; import zhCNValidation from './locales/zh-CN/messages/validation.json'; -// English translation -import enUSCommon from './locales/en-US/core/common.json'; -import enUSActions from './locales/en-US/core/actions.json'; -import enUSStatus from './locales/en-US/core/status.json'; -import enUSNavigation from './locales/en-US/core/navigation.json'; -import enUSHeader from './locales/en-US/core/header.json'; -import enUSShared from './locales/en-US/core/shared.json'; - -import enUSChat from './locales/en-US/features/chat.json'; -import enUSExtension from './locales/en-US/features/extension.json'; -import enUSConversation from './locales/en-US/features/conversation.json'; -import enUSSessionManagement from './locales/en-US/features/session-management.json'; -import enUSToolUse from './locales/en-US/features/tool-use.json'; -import enUSProvider from './locales/en-US/features/provider.json'; -import enUSPlatform from './locales/en-US/features/platform.json'; -import enUSConfig from './locales/en-US/features/config.json'; -import enUSConfigMetadata from './locales/en-US/features/config-metadata.json'; -import enUSConsole from './locales/en-US/features/console.json'; -import enUSTrace from './locales/en-US/features/trace.json'; -import enUSAbout from './locales/en-US/features/about.json'; -import enUSSettings from './locales/en-US/features/settings.json'; -import enUSAuth from './locales/en-US/features/auth.json'; -import enUSChart from './locales/en-US/features/chart.json'; -import enUSDashboard from './locales/en-US/features/dashboard.json'; -import enUSCron from './locales/en-US/features/cron.json'; -import enUSAlkaidIndex from './locales/en-US/features/alkaid/index.json'; -import enUSAlkaidKnowledgeBase from './locales/en-US/features/alkaid/knowledge-base.json'; -import enUSAlkaidMemory from './locales/en-US/features/alkaid/memory.json'; -import enUSKnowledgeBaseIndex from './locales/en-US/features/knowledge-base/index.json'; -import enUSKnowledgeBaseDetail from './locales/en-US/features/knowledge-base/detail.json'; -import enUSKnowledgeBaseDocument from './locales/en-US/features/knowledge-base/document.json'; -import enUSPersona from './locales/en-US/features/persona.json'; -import enUSMigration from './locales/en-US/features/migration.json'; -import enUSCommand from './locales/en-US/features/command.json'; -import enUSSubagent from './locales/en-US/features/subagent.json'; -import enUSWelcome from './locales/en-US/features/welcome.json'; - -import enUSErrors from './locales/en-US/messages/errors.json'; -import enUSSuccess from './locales/en-US/messages/success.json'; -import enUSValidation from './locales/en-US/messages/validation.json'; - -// Russian translation -import ruRUCommon from './locales/ru-RU/core/common.json'; -import ruRUActions from './locales/ru-RU/core/actions.json'; -import ruRUStatus from './locales/ru-RU/core/status.json'; -import ruRUNavigation from './locales/ru-RU/core/navigation.json'; -import ruRUHeader from './locales/ru-RU/core/header.json'; -import ruRUShared from './locales/ru-RU/core/shared.json'; - -import ruRUChat from './locales/ru-RU/features/chat.json'; -import ruRUExtension from './locales/ru-RU/features/extension.json'; -import ruRUConversation from './locales/ru-RU/features/conversation.json'; -import ruRUSessionManagement from './locales/ru-RU/features/session-management.json'; -import ruRUToolUse from './locales/ru-RU/features/tool-use.json'; -import ruRUProvider from './locales/ru-RU/features/provider.json'; -import ruRUPlatform from './locales/ru-RU/features/platform.json'; -import ruRUConfig from './locales/ru-RU/features/config.json'; -import ruRUConfigMetadata from './locales/ru-RU/features/config-metadata.json'; -import ruRUConsole from './locales/ru-RU/features/console.json'; -import ruRUTrace from './locales/ru-RU/features/trace.json'; -import ruRUAbout from './locales/ru-RU/features/about.json'; -import ruRUSettings from './locales/ru-RU/features/settings.json'; -import ruRUAuth from './locales/ru-RU/features/auth.json'; -import ruRUChart from './locales/ru-RU/features/chart.json'; -import ruRUDashboard from './locales/ru-RU/features/dashboard.json'; -import ruRUCron from './locales/ru-RU/features/cron.json'; -import ruRUAlkaidIndex from './locales/ru-RU/features/alkaid/index.json'; -import ruRUAlkaidKnowledgeBase from './locales/ru-RU/features/alkaid/knowledge-base.json'; -import ruRUAlkaidMemory from './locales/ru-RU/features/alkaid/memory.json'; -import ruRUKnowledgeBaseIndex from './locales/ru-RU/features/knowledge-base/index.json'; -import ruRUKnowledgeBaseDetail from './locales/ru-RU/features/knowledge-base/detail.json'; -import ruRUKnowledgeBaseDocument from './locales/ru-RU/features/knowledge-base/document.json'; -import ruRUPersona from './locales/ru-RU/features/persona.json'; -import ruRUMigration from './locales/ru-RU/features/migration.json'; -import ruRUCommand from './locales/ru-RU/features/command.json'; -import ruRUSubagent from './locales/ru-RU/features/subagent.json'; -import ruRUWelcome from './locales/ru-RU/features/welcome.json'; - -import ruRUErrors from './locales/ru-RU/messages/errors.json'; -import ruRUSuccess from './locales/ru-RU/messages/success.json'; -import ruRUValidation from './locales/ru-RU/messages/validation.json'; - -// 组装翻译对象 -export const translations = { - 'zh-CN': { - core: { - common: zhCNCommon, - actions: zhCNActions, - status: zhCNStatus, - navigation: zhCNNavigation, - header: zhCNHeader, - shared: zhCNShared - }, - features: { - chat: zhCNChat, - extension: zhCNExtension, - conversation: zhCNConversation, - 'session-management': zhCNSessionManagement, - tooluse: zhCNToolUse, - provider: zhCNProvider, - platform: zhCNPlatform, - config: zhCNConfig, - 'config-metadata': zhCNConfigMetadata, - console: zhCNConsole, - trace: zhCNTrace, - about: zhCNAbout, - settings: zhCNSettings, - auth: zhCNAuth, - chart: zhCNChart, - dashboard: zhCNDashboard, - cron: zhCNCron, - alkaid: { - index: zhCNAlkaidIndex, - 'knowledge-base': zhCNAlkaidKnowledgeBase, - memory: zhCNAlkaidMemory - }, - 'knowledge-base': { - index: zhCNKnowledgeBaseIndex, - detail: zhCNKnowledgeBaseDetail, - document: zhCNKnowledgeBaseDocument - }, - persona: zhCNPersona, - migration: zhCNMigration, - command: zhCNCommand, - subagent: zhCNSubagent, - welcome: zhCNWelcome - }, - messages: { - errors: zhCNErrors, - success: zhCNSuccess, - validation: zhCNValidation - } +export const SUPPORTED_LOCALES = ['zh-CN', 'en-US', 'ru-RU'] as const; +export type SupportedLocale = (typeof SUPPORTED_LOCALES)[number]; + +// 保留一份静态 schema 作为类型来源 +export const translationSchema = { + core: { + common: zhCNCommon, + actions: zhCNActions, + status: zhCNStatus, + navigation: zhCNNavigation, + header: zhCNHeader, + shared: zhCNShared }, - 'en-US': { - core: { - common: enUSCommon, - actions: enUSActions, - status: enUSStatus, - navigation: enUSNavigation, - header: enUSHeader, - shared: enUSShared + features: { + chat: zhCNChat, + extension: zhCNExtension, + conversation: zhCNConversation, + 'session-management': zhCNSessionManagement, + tooluse: zhCNToolUse, + provider: zhCNProvider, + platform: zhCNPlatform, + config: zhCNConfig, + 'config-metadata': zhCNConfigMetadata, + console: zhCNConsole, + trace: zhCNTrace, + about: zhCNAbout, + settings: zhCNSettings, + auth: zhCNAuth, + chart: zhCNChart, + dashboard: zhCNDashboard, + cron: zhCNCron, + alkaid: { + index: zhCNAlkaidIndex, + 'knowledge-base': zhCNAlkaidKnowledgeBase, + memory: zhCNAlkaidMemory }, - features: { - chat: enUSChat, - extension: enUSExtension, - conversation: enUSConversation, - 'session-management': enUSSessionManagement, - tooluse: enUSToolUse, - provider: enUSProvider, - platform: enUSPlatform, - config: enUSConfig, - 'config-metadata': enUSConfigMetadata, - console: enUSConsole, - trace: enUSTrace, - about: enUSAbout, - settings: enUSSettings, - auth: enUSAuth, - chart: enUSChart, - dashboard: enUSDashboard, - cron: enUSCron, - alkaid: { - index: enUSAlkaidIndex, - 'knowledge-base': enUSAlkaidKnowledgeBase, - memory: enUSAlkaidMemory - }, - 'knowledge-base': { - index: enUSKnowledgeBaseIndex, - detail: enUSKnowledgeBaseDetail, - document: enUSKnowledgeBaseDocument - }, - persona: enUSPersona, - migration: enUSMigration, - command: enUSCommand, - subagent: enUSSubagent, - welcome: enUSWelcome + 'knowledge-base': { + index: zhCNKnowledgeBaseIndex, + detail: zhCNKnowledgeBaseDetail, + document: zhCNKnowledgeBaseDocument }, - messages: { - errors: enUSErrors, - success: enUSSuccess, - validation: enUSValidation - } + persona: zhCNPersona, + migration: zhCNMigration, + command: zhCNCommand, + subagent: zhCNSubagent, + welcome: zhCNWelcome }, - 'ru-RU': { - core: { - common: ruRUCommon, - actions: ruRUActions, - status: ruRUStatus, - navigation: ruRUNavigation, - header: ruRUHeader, - shared: ruRUShared - }, - features: { - chat: ruRUChat, - extension: ruRUExtension, - conversation: ruRUConversation, - 'session-management': ruRUSessionManagement, - tooluse: ruRUToolUse, - provider: ruRUProvider, - platform: ruRUPlatform, - config: ruRUConfig, - 'config-metadata': ruRUConfigMetadata, - console: ruRUConsole, - trace: ruRUTrace, - about: ruRUAbout, - settings: ruRUSettings, - auth: ruRUAuth, - chart: ruRUChart, - dashboard: ruRUDashboard, - cron: ruRUCron, - alkaid: { - index: ruRUAlkaidIndex, - 'knowledge-base': ruRUAlkaidKnowledgeBase, - memory: ruRUAlkaidMemory - }, - 'knowledge-base': { - index: ruRUKnowledgeBaseIndex, - detail: ruRUKnowledgeBaseDetail, - document: ruRUKnowledgeBaseDocument - }, - persona: ruRUPersona, - migration: ruRUMigration, - command: ruRUCommand, - subagent: ruRUSubagent, - welcome: ruRUWelcome - }, - messages: { - errors: ruRUErrors, - success: ruRUSuccess, - validation: ruRUValidation - } + messages: { + errors: zhCNErrors, + success: zhCNSuccess, + validation: zhCNValidation } +} as const; + +export type TranslationData = typeof translationSchema; + +type TranslationModule = { + default: Record; +}; + +// NOTE: +// `zh-CN` is statically imported above to build `translationSchema` (used at runtime and for type inference). +// If the same JSON files are also included in the lazy-loading glob, Vite will warn that those modules cannot +// be moved into a separate chunk. Excluding `zh-CN` here keeps the default locale bundled while allowing +// other locales to be truly lazy-loaded. +const localeModuleLoaders = import.meta.glob([ + './locales/*/**/*.json', + '!./locales/zh-CN/**/*.json' +]); +const localeCache = new Map(); +const loadingPromises = new Map>(); + +export function isLocaleSupported(locale: string): locale is SupportedLocale { + return (SUPPORTED_LOCALES as readonly string[]).includes(locale); +} + +const PATH_SEGMENT_ALIASES: Record = { + 'tool-use': 'tooluse' }; -export type TranslationData = typeof translations; +function getSchemaNode(pathSegments: string[]): any { + let node: any = translationSchema; + for (const segment of pathSegments) { + if (!node || typeof node !== 'object') { + return null; + } + node = node[segment]; + } + return node; +} + +function normalizePathSegment(segment: string, normalizedParentPath: string[]): string { + // Prefer schema-driven normalization: only map when the target key exists under the same parent. + const parentNode = getSchemaNode(normalizedParentPath); + if (parentNode && typeof parentNode === 'object') { + if (segment in parentNode) { + return segment; + } + + const alias = PATH_SEGMENT_ALIASES[segment]; + if (alias && alias in parentNode) { + return alias; + } + + // Heuristic: if file uses kebab-case but schema key is collapsed (e.g. tool-use -> tooluse) + const collapsed = segment.replace(/-/g, ''); + if (collapsed !== segment && collapsed in parentNode) { + return collapsed; + } + } + + // Fallback to explicit aliases to keep backward compatibility. + return PATH_SEGMENT_ALIASES[segment] ?? segment; +} + +function normalizePathSegments(segments: string[]): string[] { + const normalized: string[] = []; + for (const segment of segments) { + normalized.push(normalizePathSegment(segment, normalized)); + } + return normalized; +} + +function setNestedValue(target: Record, pathSegments: string[], value: Record): void { + let current = target; + + for (let index = 0; index < pathSegments.length - 1; index++) { + const segment = pathSegments[index]; + if (!(segment in current) || typeof current[segment] !== 'object') { + current[segment] = {}; + } + current = current[segment]; + } + + const finalSegment = pathSegments[pathSegments.length - 1]; + if (!(finalSegment in current) || typeof current[finalSegment] !== 'object') { + current[finalSegment] = {}; + } + + current[finalSegment] = { + ...current[finalSegment], + ...value + }; +} + +function extractLocaleAndPath(modulePath: string): { locale: string; pathSegments: string[] } | null { + const match = modulePath.match(/^\.\/locales\/([^/]+)\/(.+)\.json$/); + if (!match) { + return null; + } + + const [, locale, relativePath] = match; + const pathSegments = normalizePathSegments(relativePath.split('/')); + + return { + locale, + pathSegments + }; +} + +export async function loadLocaleTranslations(locale: SupportedLocale): Promise { + if (locale === 'zh-CN') { + localeCache.set(locale, translationSchema); + return translationSchema; + } + + const cached = localeCache.get(locale); + if (cached) { + return cached; + } + + const inFlight = loadingPromises.get(locale); + if (inFlight) { + return inFlight; + } + + const loadingPromise = (async () => { + const localeData: Record = {}; + + const entries = Object.entries(localeModuleLoaders).filter(([modulePath]) => + modulePath.startsWith(`./locales/${locale}/`) + ); + + if (entries.length === 0) { + throw new Error(`No translation modules found for locale: ${locale}`); + } + + let loadedModuleCount = 0; + + await Promise.all( + entries.map(async ([modulePath, loadModule]) => { + const parsed = extractLocaleAndPath(modulePath); + if (!parsed || !isLocaleSupported(parsed.locale)) { + return; + } + + const loadedModule = await loadModule(); + const moduleData = loadedModule.default || loadedModule; + setNestedValue(localeData, parsed.pathSegments, moduleData); + loadedModuleCount += 1; + }) + ); + + if (loadedModuleCount === 0 || Object.keys(localeData).length === 0) { + throw new Error(`Loaded empty translations for locale: ${locale}`); + } + + const typedLocaleData = localeData as TranslationData; + localeCache.set(locale, typedLocaleData); + + return typedLocaleData; + })(); + + loadingPromises.set(locale, loadingPromise); + + try { + return await loadingPromise; + } finally { + loadingPromises.delete(locale); + } +} + +export function clearLocaleTranslationsCache(locale?: SupportedLocale): void { + if (locale) { + localeCache.delete(locale); + loadingPromises.delete(locale); + return; + } + + localeCache.clear(); + loadingPromises.clear(); +} diff --git a/dashboard/src/i18n/types.ts b/dashboard/src/i18n/types.ts index 4cd5537113..0ff3aa103f 100644 --- a/dashboard/src/i18n/types.ts +++ b/dashboard/src/i18n/types.ts @@ -4,13 +4,13 @@ */ // 直接导入已经组织好的翻译数据 -import { translations } from './translations'; +import { SUPPORTED_LOCALES, translationSchema } from './translations'; // 导出翻译数据常量,供类型推断使用 -export const translationData = translations; +export const translationData = translationSchema; // 从实际的翻译数据推断完整的翻译结构类型 -export type TranslationSchema = typeof translations[keyof typeof translations]; +export type TranslationSchema = typeof translationSchema; // TypeScript 助手:递归提取嵌套键路径 type NestedKeyOf = T extends object @@ -25,7 +25,7 @@ type NestedKeyOf = T extends object export type TranslationKey = NestedKeyOf; // 语言环境类型 - 从实际的翻译数据键推断 -export type Locale = keyof typeof translations; +export type Locale = (typeof SUPPORTED_LOCALES)[number]; // 翻译函数类型 export type TranslationFunction = {