feat(i18n): 统一国际化系统架构,支持插件独立翻译 (#301)

* feat(i18n): 统一国际化系统架构,支持插件独立翻译

## 主要改动

### 核心架构
- 增强 LocaleService,支持插件命名空间翻译扩展
- 新增 editor-runtime/i18n 模块,提供 createPluginLocale/createPluginTranslator
- 新增 editor-core/tokens.ts,定义 LocaleServiceToken 等服务令牌
- 改进 PluginAPI 类型安全,使用 ServiceToken<T> 替代 any

### 编辑器本地化
- 扩展 en.ts/zh.ts 翻译文件,覆盖所有 UI 组件
- 新增 es.ts 西班牙语支持
- 重构 40+ 组件使用 useLocale() hook

### 插件本地化系统
- behavior-tree-editor: 新增 locales/ 和 useBTLocale hook
- material-editor: 新增 locales/ 和 useMaterialLocale hook
- particle-editor: 新增 locales/ 和 useParticleLocale hook
- tilemap-editor: 新增 locales/ 和 useTilemapLocale hook
- ui-editor: 新增 locales/ 和 useUILocale hook

### 类型安全改进
- 修复 Debug 工具使用公共接口替代 as any
- 修复 ChunkStreamingSystem 添加 forEachChunk 公共方法
- 修复 blueprint-editor 移除不必要的向后兼容代码

* fix(behavior-tree-editor): 使用 ServiceToken 模式修复服务解析

- 创建 BehaviorTreeServiceToken 遵循"谁定义接口,谁导出Token"原则
- 使用 ServiceToken.id (symbol) 注册服务到 ServiceContainer
- 更新 PluginSDKRegistry.resolveService 支持 ServiceToken 检测
- BehaviorTreeEditorPanel 现在使用类型安全的 PluginAPI.resolve

* fix(behavior-tree-editor): 使用 ServiceContainer.resolve 获取类注册的服务

* fix: 修复多个包的依赖和类型问题

- core: EntityDataCollector.getEntityDetails 使用 HierarchySystem 获取父实体
- ui-editor: 添加 @esengine/editor-runtime 依赖
- tilemap-editor: 添加 @esengine/editor-runtime 依赖
- particle-editor: 添加 @esengine/editor-runtime 依赖
This commit is contained in:
YHH
2025-12-09 18:04:03 +08:00
committed by GitHub
parent 995fa2d514
commit 1b0d38edce
103 changed files with 8015 additions and 1633 deletions

View File

@@ -4,16 +4,90 @@ import { createLogger } from '@esengine/ecs-framework';
const logger = createLogger('LocaleService');
export type Locale = 'en' | 'zh';
/**
* 支持的语言类型
* Supported locale types
*
* - en: English
* - zh: 简体中文 (Simplified Chinese)
* - es: Español (Spanish)
*/
export type Locale = 'en' | 'zh' | 'es';
/**
* 语言显示信息
* Locale display information
*/
export interface LocaleInfo {
code: Locale;
name: string;
nativeName: string;
}
/**
* 支持的语言列表
* List of supported locales
*/
export const SUPPORTED_LOCALES: readonly LocaleInfo[] = [
{ code: 'en', name: 'English', nativeName: 'English' },
{ code: 'zh', name: 'Chinese', nativeName: '简体中文' },
{ code: 'es', name: 'Spanish', nativeName: 'Español' }
] as const;
/**
* 翻译值类型
* Translation value type
*/
export interface Translations {
[key: string]: string | Translations;
}
/**
* 国际化服务
* 插件翻译包
* Plugin translation bundle
*
* 管理编辑器的多语言支持
* 用于插件注册自己的翻译
* Used for plugins to register their own translations
*/
export interface PluginTranslations {
en: Translations;
zh: Translations;
es?: Translations;
}
/**
* 翻译参数类型
* Translation parameters type
*/
export type TranslationParams = Record<string, string | number>;
/**
* 国际化服务
* Internationalization service
*
* 管理编辑器的多语言支持,提供翻译、语言切换和事件通知功能。
* Manages editor's multi-language support, provides translation, locale switching and event notification.
*
* @example
* ```typescript
* // 获取服务 | Get service
* const localeService = Core.services.resolve(LocaleService);
*
* // 翻译文本 | Translate text
* localeService.t('common.save'); // "Save" or "保存"
*
* // 带参数的翻译 | Translation with parameters
* localeService.t('scene.savedSuccess', { name: 'MyScene' }); // "Scene saved: MyScene"
*
* // 切换语言 | Switch locale
* localeService.setLocale('zh');
*
* // 插件注册翻译 | Plugin register translations
* localeService.extendTranslations('behaviorTree', {
* en: { title: 'Behavior Tree Editor', ... },
* zh: { title: '行为树编辑器', ... }
* });
* ```
*/
@Injectable()
export class LocaleService implements IService {
@@ -29,22 +103,125 @@ export class LocaleService implements IService {
}
/**
* 注册语言包
* 注册核心语言包(覆盖式)
* Register core translations (overwrites existing)
*
* 用于编辑器核心初始化时注册基础翻译
* Used for editor core to register base translations during initialization
*
* @param locale - 语言代码 | Locale code
* @param translations - 翻译对象 | Translation object
*/
public registerTranslations(locale: Locale, translations: Translations): void {
this.translations.set(locale, translations);
logger.info(`Registered translations for locale: ${locale}`);
}
/**
* 扩展语言包(合并式)
* Extend translations (merges with existing)
*
* 用于插件注册自己的翻译,会合并到现有翻译中
* Used for plugins to register their translations, merges with existing
*
* @param namespace - 命名空间,如 'behaviorTree' | Namespace, e.g. 'behaviorTree'
* @param pluginTranslations - 插件翻译包 | Plugin translation bundle
*
* @example
* ```typescript
* // 在插件的 editorModule.install() 中调用
* // Call in plugin's editorModule.install()
* localeService.extendTranslations('behaviorTree', {
* en: {
* title: 'Behavior Tree Editor',
* nodePalette: 'Node Palette',
* // ...
* },
* zh: {
* title: '行为树编辑器',
* nodePalette: '节点面板',
* // ...
* }
* });
*
* // 然后在组件中使用
* // Then use in components
* t('behaviorTree.title') // "Behavior Tree Editor" or "行为树编辑器"
* ```
*/
public extendTranslations(namespace: string, pluginTranslations: PluginTranslations): void {
const locales: Locale[] = ['en', 'zh', 'es'];
for (const locale of locales) {
const existing = this.translations.get(locale) || {};
const pluginTrans = pluginTranslations[locale];
if (pluginTrans) {
// 深度合并到命名空间下 | Deep merge under namespace
const merged = {
...existing,
[namespace]: this.deepMerge(
(existing[namespace] as Translations) || {},
pluginTrans
)
};
this.translations.set(locale, merged);
}
}
logger.info(`Extended translations for namespace: ${namespace}`);
}
/**
* 深度合并两个翻译对象
* Deep merge two translation objects
*/
private deepMerge(target: Translations, source: Translations): Translations {
const result: Translations = { ...target };
for (const key of Object.keys(source)) {
const sourceValue = source[key];
const targetValue = target[key];
if (
typeof sourceValue === 'object' &&
sourceValue !== null &&
typeof targetValue === 'object' &&
targetValue !== null
) {
result[key] = this.deepMerge(
targetValue as Translations,
sourceValue as Translations
);
} else {
result[key] = sourceValue;
}
}
return result;
}
/**
* 获取当前语言
* Get current locale
*/
public getCurrentLocale(): Locale {
return this.currentLocale;
}
/**
* 获取支持的语言列表
* Get list of supported locales
*/
public getSupportedLocales(): readonly LocaleInfo[] {
return SUPPORTED_LOCALES;
}
/**
* 设置当前语言
* Set current locale
*
* @param locale - 目标语言代码 | Target locale code
*/
public setLocale(locale: Locale): void {
if (!this.translations.has(locale)) {
@@ -62,11 +239,28 @@ export class LocaleService implements IService {
/**
* 翻译文本
* Translate text
*
* @param key - 翻译键,支持点分隔的路径如 "menu.file.save"
* @param fallback - 如果找不到翻译时的回退文本
* @param key - 翻译键,支持点分隔的路径 | Translation key, supports dot-separated paths
* @param params - 可选的参数对象,用于替换模板中的占位符 {{key}} | Optional params for placeholder substitution
* @param fallback - 如果找不到翻译时的回退文本 | Fallback text if translation not found
*
* @example
* ```typescript
* // 简单翻译 | Simple translation
* t('common.save') // "Save"
*
* // 带参数替换 | With parameter substitution
* t('scene.savedSuccess', { name: 'MyScene' }) // "Scene saved: MyScene"
*
* // 插件翻译 | Plugin translation
* t('behaviorTree.title') // "Behavior Tree Editor"
*
* // 带回退文本 | With fallback
* t('unknown.key', undefined, 'Default Text') // "Default Text"
* ```
*/
public t(key: string, fallback?: string): string {
public t(key: string, params?: TranslationParams, fallback?: string): string {
const translations = this.translations.get(this.currentLocale);
if (!translations) {
return fallback || key;
@@ -74,6 +268,12 @@ export class LocaleService implements IService {
const value = this.getNestedValue(translations, key);
if (typeof value === 'string') {
// 支持参数替换 {{key}} | Support parameter substitution {{key}}
if (params) {
return value.replace(/\{\{(\w+)\}\}/g, (_, paramKey) => {
return String(params[paramKey] ?? `{{${paramKey}}}`);
});
}
return value;
}
@@ -82,6 +282,10 @@ export class LocaleService implements IService {
/**
* 监听语言变化
* Listen to locale changes
*
* @param listener - 回调函数 | Callback function
* @returns 取消订阅函数 | Unsubscribe function
*/
public onChange(listener: (locale: Locale) => void): () => void {
this.changeListeners.add(listener);
@@ -91,12 +295,30 @@ export class LocaleService implements IService {
};
}
/**
* 检查翻译键是否存在
* Check if a translation key exists
*
* @param key - 翻译键 | Translation key
* @param locale - 可选的语言代码,默认使用当前语言 | Optional locale, defaults to current
*/
public hasKey(key: string, locale?: Locale): boolean {
const targetLocale = locale || this.currentLocale;
const translations = this.translations.get(targetLocale);
if (!translations) {
return false;
}
const value = this.getNestedValue(translations, key);
return typeof value === 'string';
}
/**
* 获取嵌套对象的值
* Get nested object value
*/
private getNestedValue(obj: Translations, path: string): string | Translations | undefined {
const keys = path.split('.');
let current: any = obj;
let current: string | Translations | undefined = obj;
for (const key of keys) {
if (current && typeof current === 'object' && key in current) {
@@ -111,11 +333,12 @@ export class LocaleService implements IService {
/**
* 从 localStorage 加载保存的语言设置
* Load saved locale from localStorage
*/
private loadSavedLocale(): Locale | null {
try {
const saved = localStorage.getItem('editor-locale');
if (saved === 'en' || saved === 'zh') {
if (saved === 'en' || saved === 'zh' || saved === 'es') {
return saved;
}
} catch (error) {
@@ -126,6 +349,7 @@ export class LocaleService implements IService {
/**
* 保存语言设置到 localStorage
* Save locale to localStorage
*/
private saveLocale(locale: Locale): void {
try {

View File

@@ -2,44 +2,83 @@ import { Injectable, IService } from '@esengine/ecs-framework';
export type SettingType = 'string' | 'number' | 'boolean' | 'select' | 'color' | 'range' | 'pluginList' | 'collisionMatrix' | 'moduleList';
/**
* Localizable text - can be a plain string or a translation key (prefixed with '$')
* 可本地化文本 - 可以是普通字符串或翻译键(以 '$' 为前缀)
*
* @example
* // Plain text (not recommended for user-facing strings)
* title: 'Appearance'
*
* // Translation key (recommended)
* title: '$pluginSettings.appearance.title'
*/
export type LocalizableText = string;
/**
* Check if text is a translation key (starts with '$')
* 检查文本是否为翻译键(以 '$' 开头)
*/
export function isTranslationKey(text: string): boolean {
return text.startsWith('$');
}
/**
* Get the actual translation key (without '$' prefix)
* 获取实际的翻译键(去掉 '$' 前缀)
*/
export function getTranslationKey(text: string): string {
return text.startsWith('$') ? text.slice(1) : text;
}
export interface SettingOption {
label: string;
label: LocalizableText;
value: any;
}
export interface SettingValidator {
validate: (value: any) => boolean;
errorMessage: string;
errorMessage: LocalizableText;
}
export interface SettingDescriptor {
key: string;
label: string;
/** Label text or translation key (prefixed with '$') | 标签文本或翻译键(以 '$' 为前缀) */
label: LocalizableText;
type: SettingType;
defaultValue: any;
description?: string;
placeholder?: string;
/** Description text or translation key (prefixed with '$') | 描述文本或翻译键(以 '$' 为前缀) */
description?: LocalizableText;
/** Placeholder text or translation key (prefixed with '$') | 占位符文本或翻译键(以 '$' 为前缀) */
placeholder?: LocalizableText;
options?: SettingOption[];
validator?: SettingValidator;
min?: number;
max?: number;
step?: number;
/** 自定义渲染器组件(用于 collisionMatrix 等复杂类型) */
/**
* Custom renderer component (for complex types like collisionMatrix)
* 自定义渲染器组件(用于 collisionMatrix 等复杂类型)
*/
customRenderer?: React.ComponentType<any>;
}
export interface SettingSection {
id: string;
title: string;
description?: string;
/** Title text or translation key (prefixed with '$') | 标题文本或翻译键(以 '$' 为前缀) */
title: LocalizableText;
/** Description text or translation key (prefixed with '$') | 描述文本或翻译键(以 '$' 为前缀) */
description?: LocalizableText;
icon?: string;
settings: SettingDescriptor[];
}
export interface SettingCategory {
id: string;
title: string;
description?: string;
/** Title text or translation key (prefixed with '$') | 标题文本或翻译键(以 '$' 为前缀) */
title: LocalizableText;
/** Description text or translation key (prefixed with '$') | 描述文本或翻译键(以 '$' 为前缀) */
description?: LocalizableText;
sections: SettingSection[];
}