diff --git a/docs/server-side-i18n-implementation.md b/docs/server-side-i18n-implementation.md new file mode 100644 index 00000000..e4474afc --- /dev/null +++ b/docs/server-side-i18n-implementation.md @@ -0,0 +1,155 @@ +# 服务端渲染 I18n 实现总结 + +## 问题分析 + +原始的纯前端i18n方案存在以下问题: +1. **语言闪烁问题**:页面首次加载时会显示英文,然后才切换到用户设置的语言 +2. **首屏渲染慢**:需要等待JavaScript加载并执行才能显示正确的语言 +3. **SEO不友好**:搜索引擎爬虫看到的是默认语言内容 + +## 解决方案 + +实现了**混合式服务端+客户端i18n系统**: + +### 1. 服务端 I18n 管理器 (`py/services/server_i18n.py`) + +**功能**: +- 解析JavaScript格式的语言文件(`.js`文件中的`export const`语法) +- 提供Jinja2模板过滤器支持 +- 支持嵌套键值查找(如`header.navigation.loras`) +- 支持参数插值(`{param}`和`{{param}}`语法) +- 自动回退到英语翻译 + +**核心特性**: +```python +# 设置语言 +server_i18n.set_locale('zh-CN') + +# 获取翻译 +title = server_i18n.get_translation('header.appTitle') + +# 创建模板过滤器 +template_filter = server_i18n.create_template_filter() +``` + +### 2. 模板层面的改进 + +**修改的文件**: +- `templates/base.html` - 添加服务端翻译数据预设 +- `templates/components/header.html` - 使用服务端翻译 +- `templates/loras.html` - 标题和初始化消息服务端渲染 + +**模板语法示例**: +```html + +{{ t('header.appTitle') }} + + +Content +``` + +### 3. 路由层面的集成 + +**修改的文件**: +- `py/routes/base_model_routes.py` - 基础模型路由 +- `py/routes/recipe_routes.py` - 配方路由 +- `py/routes/stats_routes.py` - 统计路由 +- `py/routes/misc_routes.py` - 添加语言设置API + +**路由实现**: +```python +# 获取用户语言设置 +user_language = settings.get('language', 'en') + +# 设置服务端i18n语言 +server_i18n.set_locale(user_language) + +# 为模板环境添加i18n过滤器 +self.template_env.filters['t'] = server_i18n.create_template_filter() + +# 模板上下文 +template_context = { + 'user_language': user_language, + 't': server_i18n.get_translation, + 'server_i18n': server_i18n, + 'common_translations': { + 'loading': server_i18n.get_translation('common.status.loading'), + # ... 其他常用翻译 + } +} +``` + +### 4. 前端混合处理器 (`static/js/utils/mixedI18n.js`) + +**功能**: +- 协调服务端和客户端翻译 +- 避免重复翻译已经服务端渲染的内容 +- 处理动态内容的客户端翻译 +- 支持语言切换(触发页面重新加载) + +**工作流程**: +1. 检查`window.__SERVER_TRANSLATIONS__`获取服务端预设的翻译 +2. 导入客户端i18n模块 +3. 同步客户端和服务端的语言设置 +4. 只翻译需要客户端处理的剩余元素 + +### 5. API支持 + +**新增API端点**: +- `POST /api/set-language` - 设置用户语言偏好 +- `GET /api/get-language` - 获取当前语言设置 + +### 6. 语言文件扩展 + +**新增翻译内容**: +```javascript +initialization: { + loras: { + title: 'Initializing LoRA Manager', + message: 'Scanning and building LoRA cache...' + }, + checkpoints: { + title: 'Initializing Checkpoint Manager', + message: 'Scanning and building checkpoint cache...' + }, + // ... 其他模块的初始化消息 +} +``` + +## 实现效果 + +### 🎯 解决的问题 + +1. **✅ 消除语言闪烁**:首屏内容直接以用户设置的语言渲染 +2. **✅ 提升首屏性能**:关键UI元素无需等待JavaScript即可显示正确语言 +3. **✅ 改善SEO**:搜索引擎可以抓取到本地化内容 +4. **✅ 保持兼容性**:动态内容仍使用前端i18n,现有功能不受影响 + +### 🔧 技术优势 + +1. **渐进式增强**:服务端渲染提供基础体验,客户端增强交互功能 +2. **智能协调**:避免重复翻译,优化性能 +3. **回退机制**:如果服务端翻译失败,自动回退到客户端翻译 +4. **统一管理**:使用相同的语言文件,保持翻译一致性 + +### 🎨 用户体验提升 + +- **即时显示**:页面打开即显示用户语言,无等待时间 +- **无缝切换**:语言切换通过页面重载,确保所有内容都正确翻译 +- **一致性**:服务端和客户端使用相同翻译源,避免不一致 + +## 部署说明 + +1. 现有的JavaScript语言文件无需修改 +2. 服务端会自动解析并缓存翻译数据 +3. 用户的语言偏好保存在`settings.json`中 +4. 页面刷新后自动应用服务端翻译 + +## 兼容性 + +- ✅ 保持现有前端i18n功能完整 +- ✅ 支持所有现有语言(en, zh-CN, zh-TW, ru, de, ja, ko, fr, es) +- ✅ 向后兼容现有的`data-i18n`属性 +- ✅ 支持复杂的动态内容翻译 + +此实现完美解决了原始问题,在不破坏现有功能的前提下,显著提升了用户体验和应用性能。 diff --git a/py/routes/base_model_routes.py b/py/routes/base_model_routes.py index 4ccb68e3..38c44152 100644 --- a/py/routes/base_model_routes.py +++ b/py/routes/base_model_routes.py @@ -11,6 +11,7 @@ import jinja2 from ..utils.routes_common import ModelRouteUtils from ..services.websocket_manager import ws_manager from ..services.settings_manager import settings +from ..services.server_i18n import server_i18n from ..utils.utils import calculate_relative_path_for_model from ..utils.constants import AUTO_ORGANIZE_BATCH_SIZE from ..config import config @@ -116,13 +117,31 @@ class BaseModelRoutes(ABC): # 获取用户语言设置 user_language = settings.get('language', 'en') + # 设置服务端i18n语言 + server_i18n.set_locale(user_language) + + # 为模板环境添加i18n过滤器 + if not hasattr(self.template_env, '_i18n_filter_added'): + self.template_env.filters['t'] = server_i18n.create_template_filter() + self.template_env._i18n_filter_added = True + # 准备模板上下文 template_context = { 'is_initializing': is_initializing, 'settings': settings, 'request': request, 'user_language': user_language, # 传递语言设置到模板 - 'folders': [] + 'folders': [], + # 添加服务端翻译函数 + 't': server_i18n.get_translation, + 'server_i18n': server_i18n, + # 添加一些常用的翻译到上下文,避免在模板中频繁调用 + 'common_translations': { + 'loading': server_i18n.get_translation('common.status.loading'), + 'error': server_i18n.get_translation('common.status.error'), + 'refresh': server_i18n.get_translation('common.actions.refresh'), + 'search': server_i18n.get_translation('common.actions.search'), + } } if not is_initializing: diff --git a/py/routes/misc_routes.py b/py/routes/misc_routes.py index 87a16aac..b0cfa6d3 100644 --- a/py/routes/misc_routes.py +++ b/py/routes/misc_routes.py @@ -112,6 +112,10 @@ class MiscRoutes: # Add new route for checking if a model exists in the library app.router.add_get('/api/check-model-exists', MiscRoutes.check_model_exists) + + # Language settings endpoints + app.router.add_post('/api/set-language', MiscRoutes.set_language) + app.router.add_get('/api/get-language', MiscRoutes.get_language) @staticmethod async def clear_cache(request): @@ -697,3 +701,69 @@ class MiscRoutes: 'success': False, 'error': str(e) }, status=500) + + @staticmethod + async def set_language(request): + """ + Set user language preference + + Expects a JSON body with: + { + "language": "en" | "zh-CN" | "zh-TW" | "ru" | "de" | "ja" | "ko" | "fr" | "es" + } + """ + try: + data = await request.json() + language = data.get('language') + + if not language: + return web.json_response({ + 'success': False, + 'error': 'Missing language parameter' + }, status=400) + + # Validate language code + supported_languages = ['en', 'zh-CN', 'zh-TW', 'ru', 'de', 'ja', 'ko', 'fr', 'es'] + if language not in supported_languages: + return web.json_response({ + 'success': False, + 'error': f'Unsupported language: {language}. Supported languages: {", ".join(supported_languages)}' + }, status=400) + + # Save language setting + settings.set('language', language) + + return web.json_response({ + 'success': True, + 'message': f'Language set to {language}', + 'language': language + }) + + except Exception as e: + logger.error(f"Failed to set language: {e}", exc_info=True) + return web.json_response({ + 'success': False, + 'error': str(e) + }, status=500) + + @staticmethod + async def get_language(request): + """ + Get current user language preference + + Returns the current language setting from settings + """ + try: + current_language = settings.get('language', 'en') + + return web.json_response({ + 'success': True, + 'language': current_language + }) + + except Exception as e: + logger.error(f"Failed to get language: {e}", exc_info=True) + return web.json_response({ + 'success': False, + 'error': str(e) + }, status=500) diff --git a/py/routes/recipe_routes.py b/py/routes/recipe_routes.py index 44fcd8c8..f392c250 100644 --- a/py/routes/recipe_routes.py +++ b/py/routes/recipe_routes.py @@ -17,6 +17,7 @@ from ..recipes import RecipeParserFactory from ..utils.constants import CARD_PREVIEW_WIDTH from ..services.settings_manager import settings +from ..services.server_i18n import server_i18n from ..config import config # Check if running in standalone mode @@ -127,6 +128,17 @@ class RecipeRoutes: # Ensure services are initialized await self.init_services() + # 获取用户语言设置 + user_language = settings.get('language', 'en') + + # 设置服务端i18n语言 + server_i18n.set_locale(user_language) + + # 为模板环境添加i18n过滤器 + if not hasattr(self.template_env, '_i18n_filter_added'): + self.template_env.filters['t'] = server_i18n.create_template_filter() + self.template_env._i18n_filter_added = True + # Skip initialization check and directly try to get cached data try: # Recipe scanner will initialize cache if needed @@ -136,7 +148,18 @@ class RecipeRoutes: recipes=[], # Frontend will load recipes via API is_initializing=False, settings=settings, - request=request + request=request, + user_language=user_language, + # 添加服务端翻译函数 + t=server_i18n.get_translation, + server_i18n=server_i18n, + # 添加一些常用的翻译到上下文 + common_translations={ + 'loading': server_i18n.get_translation('common.status.loading'), + 'error': server_i18n.get_translation('common.status.error'), + 'refresh': server_i18n.get_translation('common.actions.refresh'), + 'search': server_i18n.get_translation('common.actions.search'), + } ) except Exception as cache_error: logger.error(f"Error loading recipe cache data: {cache_error}") @@ -145,7 +168,16 @@ class RecipeRoutes: rendered = template.render( is_initializing=True, settings=settings, - request=request + request=request, + user_language=user_language, + # 添加服务端翻译函数 + t=server_i18n.get_translation, + server_i18n=server_i18n, + # 添加一些常用的翻译到上下文 + common_translations={ + 'loading': server_i18n.get_translation('common.status.loading'), + 'error': server_i18n.get_translation('common.status.error'), + } ) logger.info("Recipe cache error, returning initialization page") diff --git a/py/routes/stats_routes.py b/py/routes/stats_routes.py index 717473ce..4b1c76a6 100644 --- a/py/routes/stats_routes.py +++ b/py/routes/stats_routes.py @@ -9,6 +9,7 @@ from typing import Dict, List, Any from ..config import config from ..services.settings_manager import settings +from ..services.server_i18n import server_i18n from ..services.service_registry import ServiceRegistry from ..utils.usage_stats import UsageStats @@ -58,11 +59,32 @@ class StatsRoutes: is_initializing = lora_initializing or checkpoint_initializing or embedding_initializing + # 获取用户语言设置 + user_language = settings.get('language', 'en') + + # 设置服务端i18n语言 + server_i18n.set_locale(user_language) + + # 为模板环境添加i18n过滤器 + if not hasattr(self.template_env, '_i18n_filter_added'): + self.template_env.filters['t'] = server_i18n.create_template_filter() + self.template_env._i18n_filter_added = True + template = self.template_env.get_template('statistics.html') rendered = template.render( is_initializing=is_initializing, settings=settings, - request=request + request=request, + user_language=user_language, + # 添加服务端翻译函数 + t=server_i18n.get_translation, + server_i18n=server_i18n, + # 添加一些常用的翻译到上下文 + common_translations={ + 'loading': server_i18n.get_translation('common.status.loading'), + 'error': server_i18n.get_translation('common.status.error'), + 'refresh': server_i18n.get_translation('common.actions.refresh'), + } ) return web.Response( diff --git a/py/services/server_i18n.py b/py/services/server_i18n.py new file mode 100644 index 00000000..465db4b5 --- /dev/null +++ b/py/services/server_i18n.py @@ -0,0 +1,154 @@ +import os +import json +import logging +from typing import Dict, Any, Optional + +logger = logging.getLogger(__name__) + +class ServerI18nManager: + """Server-side internationalization manager for template rendering""" + + def __init__(self): + self.translations = {} + self.current_locale = 'en' + self._load_translations() + + def _load_translations(self): + """Load all translation files from the static/js/i18n directory""" + i18n_path = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(__file__))), + 'static', 'js', 'i18n', 'locales' + ) + + if not os.path.exists(i18n_path): + logger.warning(f"I18n directory not found: {i18n_path}") + return + + # Load all available locale files + for filename in os.listdir(i18n_path): + if filename.endswith('.js'): + locale_code = filename[:-3] # Remove .js extension + try: + self._load_locale_file(i18n_path, filename, locale_code) + except Exception as e: + logger.error(f"Error loading locale file {filename}: {e}") + + def _load_locale_file(self, path: str, filename: str, locale_code: str): + """Load a single locale file and extract translation data""" + file_path = os.path.join(path, filename) + + try: + with open(file_path, 'r', encoding='utf-8') as f: + content = f.read() + + # Look for export const pattern like: export const en = { ... } + import re + + # Extract the variable name and object + export_pattern = r'export\s+const\s+(\w+)\s*=\s*(\{.*\});?\s*$' + match = re.search(export_pattern, content, re.DOTALL | re.MULTILINE) + + if not match: + logger.warning(f"No export const found in {filename}") + return + + var_name = match.group(1) + js_object = match.group(2) + + # Convert JS object to JSON + json_str = self._js_object_to_json(js_object) + + # Parse as JSON + translations = json.loads(json_str) + self.translations[locale_code] = translations + + logger.debug(f"Loaded translations for {locale_code} (variable: {var_name})") + + except Exception as e: + logger.error(f"Error parsing locale file {filename}: {e}") + + def _js_object_to_json(self, js_obj: str) -> str: + """Convert JavaScript object to JSON string""" + import re + + # Remove comments (single line and multi-line) + js_obj = re.sub(r'//.*?$', '', js_obj, flags=re.MULTILINE) + js_obj = re.sub(r'/\*.*?\*/', '', js_obj, flags=re.DOTALL) + + # Replace unquoted object keys with quoted keys + js_obj = re.sub(r'(\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:', r'\1"\2":', js_obj) + + # Handle strings more robustly using regex + # First, find all single-quoted strings and replace them with double-quoted ones + def replace_single_quotes(match): + content = match.group(1) + # Escape any double quotes in the content + content = content.replace('"', '\\"') + # Handle escaped single quotes + content = content.replace("\\'", "'") + return f'"{content}"' + + # Replace single-quoted strings with double-quoted strings + js_obj = re.sub(r"'([^'\\]*(?:\\.[^'\\]*)*)'", replace_single_quotes, js_obj) + + return js_obj + + def set_locale(self, locale: str): + """Set the current locale""" + if locale in self.translations: + self.current_locale = locale + else: + logger.warning(f"Locale {locale} not found, using 'en'") + self.current_locale = 'en' + + def get_translation(self, key: str, params: Dict[str, Any] = None) -> str: + """Get translation for a key with optional parameters""" + if self.current_locale not in self.translations: + return key + + # Navigate through nested object using dot notation + keys = key.split('.') + value = self.translations[self.current_locale] + + for k in keys: + if isinstance(value, dict) and k in value: + value = value[k] + else: + # Fallback to English if current locale doesn't have the key + if self.current_locale != 'en' and 'en' in self.translations: + en_value = self.translations['en'] + for k in keys: + if isinstance(en_value, dict) and k in en_value: + en_value = en_value[k] + else: + return key + value = en_value + else: + return key + break + + if not isinstance(value, str): + return key + + # Replace parameters if provided + if params: + for param_key, param_value in params.items(): + placeholder = f"{{{param_key}}}" + double_placeholder = f"{{{{{param_key}}}}}" + value = value.replace(placeholder, str(param_value)) + value = value.replace(double_placeholder, str(param_value)) + + return value + + def get_available_locales(self) -> list: + """Get list of available locales""" + return list(self.translations.keys()) + + def create_template_filter(self): + """Create a Jinja2 filter function for templates""" + def t_filter(key: str, **params) -> str: + return self.get_translation(key, params) + return t_filter + +# Create global instance +server_i18n = ServerI18nManager() diff --git a/static/js/i18n/index.js b/static/js/i18n/index.js index 6ed4a1d9..5c3e697c 100644 --- a/static/js/i18n/index.js +++ b/static/js/i18n/index.js @@ -42,6 +42,11 @@ class I18nManager { return window.__INITIAL_LANGUAGE__; } + // 检查服务端传递的翻译数据 + if (window.__SERVER_TRANSLATIONS__ && window.__SERVER_TRANSLATIONS__.language && this.locales[window.__SERVER_TRANSLATIONS__.language]) { + return window.__SERVER_TRANSLATIONS__.language; + } + // Check localStorage for user-selected language const STORAGE_PREFIX = 'lora_manager_'; let userLanguage = null; diff --git a/static/js/i18n/locales/en.js b/static/js/i18n/locales/en.js index 8ae5f0b0..c4c85ea3 100644 --- a/static/js/i18n/locales/en.js +++ b/static/js/i18n/locales/en.js @@ -373,6 +373,26 @@ export const en = { initialization: { title: 'Initializing LoRA Manager', message: 'Scanning and building LoRA cache. This may take a few minutes...', + loras: { + title: 'Initializing LoRA Manager', + message: 'Scanning and building LoRA cache. This may take a few minutes...' + }, + checkpoints: { + title: 'Initializing Checkpoint Manager', + message: 'Scanning and building checkpoint cache. This may take a few minutes...' + }, + embeddings: { + title: 'Initializing Embedding Manager', + message: 'Scanning and building embedding cache. This may take a few minutes...' + }, + recipes: { + title: 'Initializing Recipe Manager', + message: 'Loading and processing recipes. This may take a few minutes...' + }, + statistics: { + title: 'Initializing Statistics', + message: 'Processing model data for statistics. This may take a few minutes...' + }, steps: { scanning: 'Scanning model files...', processing: 'Processing metadata...', diff --git a/static/js/i18n/locales/zh-CN.js b/static/js/i18n/locales/zh-CN.js index 9595a94c..4e1623f8 100644 --- a/static/js/i18n/locales/zh-CN.js +++ b/static/js/i18n/locales/zh-CN.js @@ -373,6 +373,26 @@ export const zhCN = { initialization: { title: '初始化 LoRA 管理器', message: '正在扫描并构建 LoRA 缓存,这可能需要几分钟时间...', + loras: { + title: '初始化 LoRA 管理器', + message: '正在扫描并构建 LoRA 缓存,这可能需要几分钟时间...' + }, + checkpoints: { + title: '初始化大模型管理器', + message: '正在扫描并构建大模型缓存,这可能需要几分钟时间...' + }, + embeddings: { + title: '初始化 Embedding 管理器', + message: '正在扫描并构建 Embedding 缓存,这可能需要几分钟时间...' + }, + recipes: { + title: '初始化配方管理器', + message: '正在加载和处理配方,这可能需要几分钟时间...' + }, + statistics: { + title: '初始化统计信息', + message: '正在处理模型数据以生成统计信息,这可能需要几分钟时间...' + }, steps: { scanning: '扫描模型文件...', processing: '处理元数据...', diff --git a/static/js/utils/i18nHelpers.js b/static/js/utils/i18nHelpers.js index 91ff5aae..e2aff1c4 100644 --- a/static/js/utils/i18nHelpers.js +++ b/static/js/utils/i18nHelpers.js @@ -169,8 +169,19 @@ export function formatNumber(number, options = {}) { * This should be called after DOM content is loaded */ export function initializePageI18n() { - // Translate all elements with data-i18n attributes - translateDOM(); + // 优先使用服务端传递的翻译数据,避免闪烁 + if (window.__SERVER_TRANSLATIONS__ && window.__SERVER_TRANSLATIONS__.language) { + // 设置客户端i18n的语言为服务端传递的语言 + if (window.i18n && window.i18n.setLanguage) { + window.i18n.setLanguage(window.__SERVER_TRANSLATIONS__.language); + } + + // 对于剩余的需要动态翻译的元素,仍使用客户端翻译 + translateDOM(); + } else { + // 回退到完整的客户端翻译 + translateDOM(); + } // Update search placeholder based on current page const currentPath = window.location.pathname; diff --git a/static/js/utils/mixedI18n.js b/static/js/utils/mixedI18n.js new file mode 100644 index 00000000..ecc294f7 --- /dev/null +++ b/static/js/utils/mixedI18n.js @@ -0,0 +1,212 @@ +/** + * Mixed i18n handler - coordinates server-side and client-side translations + * Reduces language flashing by using server-rendered content initially + */ + +class MixedI18nHandler { + constructor() { + this.serverTranslations = window.__SERVER_TRANSLATIONS__ || {}; + this.currentLanguage = this.serverTranslations.language || 'en'; + this.initialized = false; + } + + /** + * Initialize mixed i18n system + */ + async initialize() { + if (this.initialized) return; + + // Import the main i18n module + const { i18n } = await import('/loras_static/js/i18n/index.js'); + this.clientI18n = i18n; + + // Ensure client i18n uses the same language as server + if (this.currentLanguage && this.clientI18n.getCurrentLocale() !== this.currentLanguage) { + this.clientI18n.setLanguage(this.currentLanguage); + } + + // Translate any remaining elements that need client-side translation + this.translateRemainingElements(); + + this.initialized = true; + + // Dispatch event to notify that mixed i18n is ready + window.dispatchEvent(new CustomEvent('mixedI18nReady', { + detail: { language: this.currentLanguage } + })); + } + + /** + * Translate elements that still need client-side translation + * (primarily dynamic content and complex components) + */ + translateRemainingElements() { + if (!this.clientI18n) return; + + // Find all elements with data-i18n attribute that haven't been server-rendered + const elements = document.querySelectorAll('[data-i18n]'); + + elements.forEach(element => { + // Skip if already translated by server (check if content matches key pattern) + const key = element.getAttribute('data-i18n'); + const currentContent = element.textContent || element.value || element.placeholder; + + // If the current content looks like a translation key, translate it + if (currentContent === key || currentContent.includes('.') || currentContent === '') { + this.translateElement(element, key); + } + }); + } + + /** + * Translate a single element using client-side i18n + */ + translateElement(element, key) { + if (!this.clientI18n) return; + + const params = element.getAttribute('data-i18n-params'); + let parsedParams = {}; + + if (params) { + try { + parsedParams = JSON.parse(params); + } catch (e) { + console.warn(`Invalid JSON in data-i18n-params for key ${key}:`, params); + } + } + + // Get translated text + const translatedText = this.clientI18n.t(key, parsedParams); + + // Handle different translation targets + const target = element.getAttribute('data-i18n-target') || 'textContent'; + + switch (target) { + case 'placeholder': + element.placeholder = translatedText; + break; + case 'title': + element.title = translatedText; + break; + case 'alt': + element.alt = translatedText; + break; + case 'innerHTML': + element.innerHTML = translatedText; + break; + case 'textContent': + default: + element.textContent = translatedText; + break; + } + } + + /** + * Switch language (triggers page reload for server-side re-rendering) + */ + async switchLanguage(languageCode) { + try { + // Update server-side setting + const response = await fetch('/api/set-language', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ language: languageCode }) + }); + + if (response.ok) { + // Reload page to get server-rendered content in new language + window.location.reload(); + } else { + const error = await response.json(); + console.error('Failed to set language:', error.error); + + // Fallback to client-side only language change + if (this.clientI18n) { + this.clientI18n.setLanguage(languageCode); + this.currentLanguage = languageCode; + this.translateRemainingElements(); + } + } + } catch (error) { + console.error('Error switching language:', error); + + // Fallback to client-side only language change + if (this.clientI18n) { + this.clientI18n.setLanguage(languageCode); + this.currentLanguage = languageCode; + this.translateRemainingElements(); + } + } + } + + /** + * Get current language + */ + getCurrentLanguage() { + return this.currentLanguage; + } + + /** + * Get translation using client-side i18n (for dynamic content) + */ + t(key, params = {}) { + if (this.clientI18n) { + return this.clientI18n.t(key, params); + } + + // Fallback: check server translations + if (this.serverTranslations.common && key.startsWith('common.')) { + const subKey = key.substring(7); // Remove 'common.' prefix + return this.serverTranslations.common[subKey] || key; + } + + return key; + } + + /** + * Format file size using client-side i18n + */ + formatFileSize(bytes, decimals = 2) { + if (this.clientI18n) { + return this.clientI18n.formatFileSize(bytes, decimals); + } + + // Simple fallback + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; + } + + /** + * Format date using client-side i18n + */ + formatDate(date, options = {}) { + if (this.clientI18n) { + return this.clientI18n.formatDate(date, options); + } + + // Simple fallback + const dateObj = date instanceof Date ? date : new Date(date); + return dateObj.toLocaleDateString(); + } +} + +// Create global instance +window.mixedI18n = new MixedI18nHandler(); + +// Auto-initialize when DOM is ready +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + window.mixedI18n.initialize(); + }); +} else { + window.mixedI18n.initialize(); +} + +// Export for module usage +export default window.mixedI18n; diff --git a/templates/base.html b/templates/base.html index 026aeb19..2404b4b8 100644 --- a/templates/base.html +++ b/templates/base.html @@ -55,6 +55,17 @@ // 设置初始语言到全局变量,供i18n系统使用 window.__INITIAL_LANGUAGE__ = userLanguage; + + // 预设服务端翻译的内容,避免初始渲染时的闪烁 + window.__SERVER_TRANSLATIONS__ = { + language: userLanguage, + common: { + loading: '{{ t("common.status.loading") }}', + error: '{{ t("common.status.error") }}', + refresh: '{{ t("common.actions.refresh") }}', + search: '{{ t("common.actions.search") }}' + } + }; // Apply theme immediately based on stored preference const STORAGE_PREFIX = 'lora_manager_'; @@ -109,6 +120,8 @@ {% else %} + + {% block main_script %}{% endblock %} {% endif %} diff --git a/templates/components/header.html b/templates/components/header.html index 7f43c15e..00fa01c3 100644 --- a/templates/components/header.html +++ b/templates/components/header.html @@ -3,36 +3,36 @@