feat(i18n): Implement server-side internationalization support

- Added ServerI18nManager to handle translations and locale settings on the server.
- Integrated server-side translations into templates, reducing language flashing on initial load.
- Created API endpoints for setting and getting user language preferences.
- Enhanced client-side i18n handling to work seamlessly with server-rendered content.
- Updated various templates to utilize the new translation system.
- Added mixed i18n handler to coordinate server and client translations, improving user experience.
- Expanded translation files to include initialization messages for various components.
This commit is contained in:
Will Miao
2025-08-30 16:56:56 +08:00
parent 3c9e402bc0
commit 29160bd6e5
14 changed files with 775 additions and 42 deletions

View File

@@ -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;

View File

@@ -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;