Implement internationalization (i18n) system for LoRA Manager

- Added i18n support with automatic language detection based on browser settings.
- Implemented translations for English (en) and Simplified Chinese (zh-CN).
- Created utility functions for text replacement in HTML templates and JavaScript.
- Developed a comprehensive translation key structure for various application components.
- Added formatting functions for numbers, dates, and file sizes according to locale.
- Included RTL language support and dynamic updates for DOM elements.
- Created tests to verify the functionality of the i18n system.
This commit is contained in:
Will Miao
2025-08-28 22:22:26 +08:00
parent 4246908f2e
commit f82908221c
18 changed files with 1786 additions and 121 deletions

View File

@@ -4,6 +4,7 @@ import { createPageControls } from './components/controls/index.js';
import { CheckpointContextMenu } from './components/ContextMenu/index.js';
import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js';
import { MODEL_TYPES } from './api/apiConfig.js';
import { initializePageI18n } from './utils/i18nHelpers.js';
// Initialize the Checkpoints page
class CheckpointsPageManager {
@@ -36,6 +37,9 @@ class CheckpointsPageManager {
// Initialize common page features
appCore.initializePageFeatures();
// Initialize i18n for the page
initializePageI18n();
console.log('Checkpoints Manager initialized');
}
}

View File

@@ -3,6 +3,7 @@ import { toggleTheme } from '../utils/uiHelpers.js';
import { SearchManager } from '../managers/SearchManager.js';
import { FilterManager } from '../managers/FilterManager.js';
import { initPageState } from '../state/index.js';
import { updateSearchPlaceholder } from '../utils/i18nHelpers.js';
/**
* Header.js - Manages the application header behavior across different pages
@@ -51,17 +52,14 @@ export class HeaderManager {
const currentTheme = localStorage.getItem('lm_theme') || 'auto';
themeToggle.classList.add(`theme-${currentTheme}`);
// Set initial tooltip text
this.updateThemeTooltip(themeToggle, currentTheme);
themeToggle.addEventListener('click', () => {
if (typeof toggleTheme === 'function') {
const newTheme = toggleTheme();
// Update tooltip based on next toggle action
if (newTheme === 'light') {
themeToggle.title = "Switch to dark theme";
} else if (newTheme === 'dark') {
themeToggle.title = "Switch to auto theme";
} else {
themeToggle.title = "Switch to light theme";
}
this.updateThemeTooltip(themeToggle, newTheme);
}
});
}
@@ -136,7 +134,7 @@ export class HeaderManager {
const searchButtons = headerSearch.querySelectorAll('button');
if (searchInput) {
searchInput.disabled = true;
searchInput.placeholder = 'Search not available on statistics page';
searchInput.placeholder = window.i18n?.t('header.search.notAvailable') || 'Search not available on statistics page';
}
searchButtons.forEach(btn => btn.disabled = true);
} else if (headerSearch) {
@@ -146,8 +144,22 @@ export class HeaderManager {
const searchButtons = headerSearch.querySelectorAll('button');
if (searchInput) {
searchInput.disabled = false;
// Update placeholder based on current page
updateSearchPlaceholder(window.location.pathname);
}
searchButtons.forEach(btn => btn.disabled = false);
}
}
updateThemeTooltip(themeToggle, currentTheme) {
if (!window.i18n) return;
if (currentTheme === 'light') {
themeToggle.title = window.i18n.t('header.theme.switchToDark');
} else if (currentTheme === 'dark') {
themeToggle.title = window.i18n.t('header.theme.switchToAuto');
} else {
themeToggle.title = window.i18n.t('header.theme.switchToLight');
}
}
}

View File

@@ -13,6 +13,7 @@ import { bannerService } from './managers/BannerService.js';
import { showToast, initTheme, initBackToTop } from './utils/uiHelpers.js';
import { initializeInfiniteScroll } from './utils/infiniteScroll.js';
import { migrateStorageItems } from './utils/storageHelpers.js';
import { i18n } from './i18n/index.js';
// Core application class
export class AppCore {
@@ -26,6 +27,10 @@ export class AppCore {
console.log('AppCore: Initializing...');
// Initialize i18n first
window.i18n = i18n;
console.log(`AppCore: Language detected: ${i18n.getCurrentLocale()}`);
// Initialize managers
state.loadingManager = new LoadingManager();
modalManager.initialize();

View File

@@ -4,6 +4,7 @@ import { createPageControls } from './components/controls/index.js';
import { EmbeddingContextMenu } from './components/ContextMenu/index.js';
import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js';
import { MODEL_TYPES } from './api/apiConfig.js';
import { initializePageI18n } from './utils/i18nHelpers.js';
// Initialize the Embeddings page
class EmbeddingsPageManager {
@@ -36,6 +37,9 @@ class EmbeddingsPageManager {
// Initialize common page features
appCore.initializePageFeatures();
// Initialize i18n for the page
initializePageI18n();
console.log('Embeddings Manager initialized');
}
}

157
static/js/i18n/index.js Normal file
View File

@@ -0,0 +1,157 @@
/**
* Internationalization (i18n) system for LoRA Manager
* Automatically detects browser language and provides fallback to English
*/
import { en } from './locales/en.js';
import { zhCN } from './locales/zh-CN.js';
class I18nManager {
constructor() {
this.locales = {
'en': en,
'zh-CN': zhCN,
'zh': zhCN, // Fallback for 'zh' to 'zh-CN'
};
this.currentLocale = this.detectLanguage();
this.translations = this.locales[this.currentLocale] || this.locales['en'];
}
/**
* Detect browser language with fallback to English
* @returns {string} Language code
*/
detectLanguage() {
// Get browser language
const browserLang = navigator.language || navigator.languages[0] || 'en';
// Check if we have exact match
if (this.locales[browserLang]) {
return browserLang;
}
// Check for language without region (e.g., 'zh' from 'zh-CN')
const langCode = browserLang.split('-')[0];
if (this.locales[langCode]) {
return langCode;
}
// Fallback to English
return 'en';
}
/**
* Get translation for a key with optional parameters
* @param {string} key - Translation key (supports dot notation)
* @param {Object} params - Parameters for string interpolation
* @returns {string} Translated text
*/
t(key, params = {}) {
const keys = key.split('.');
let value = this.translations;
// Navigate through nested object
for (const k of keys) {
if (value && typeof value === 'object' && k in value) {
value = value[k];
} else {
// Fallback to English if key not found in current locale
value = this.locales['en'];
for (const fallbackKey of keys) {
if (value && typeof value === 'object' && fallbackKey in value) {
value = value[fallbackKey];
} else {
console.warn(`Translation key not found: ${key}`);
return key; // Return key as fallback
}
}
break;
}
}
if (typeof value !== 'string') {
console.warn(`Translation key is not a string: ${key}`);
return key;
}
// Replace parameters in the string
return this.interpolate(value, params);
}
/**
* Interpolate parameters into a string
* Supports both {{param}} and {param} syntax
* @param {string} str - String with placeholders
* @param {Object} params - Parameters to interpolate
* @returns {string} Interpolated string
*/
interpolate(str, params) {
return str.replace(/\{\{?(\w+)\}?\}/g, (match, key) => {
return params[key] !== undefined ? params[key] : match;
});
}
/**
* Get current locale
* @returns {string} Current locale code
*/
getCurrentLocale() {
return this.currentLocale;
}
/**
* Check if current locale is RTL (Right-to-Left)
* @returns {boolean} True if RTL
*/
isRTL() {
const rtlLocales = ['ar', 'he', 'fa', 'ur'];
return rtlLocales.includes(this.currentLocale.split('-')[0]);
}
/**
* Format number according to current locale
* @param {number} number - Number to format
* @param {Object} options - Intl.NumberFormat options
* @returns {string} Formatted number
*/
formatNumber(number, options = {}) {
return new Intl.NumberFormat(this.currentLocale, options).format(number);
}
/**
* Format date according to current locale
* @param {Date|string|number} date - Date to format
* @param {Object} options - Intl.DateTimeFormat options
* @returns {string} Formatted date
*/
formatDate(date, options = {}) {
const dateObj = date instanceof Date ? date : new Date(date);
return new Intl.DateTimeFormat(this.currentLocale, options).format(dateObj);
}
/**
* Format file size with locale-specific formatting
* @param {number} bytes - Size in bytes
* @param {number} decimals - Number of decimal places
* @returns {string} Formatted size
*/
formatFileSize(bytes, decimals = 2) {
if (bytes === 0) return this.t('common.fileSize.zero');
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));
const size = parseFloat((bytes / Math.pow(k, i)).toFixed(dm));
return `${this.formatNumber(size)} ${this.t(`common.fileSize.${sizes[i]}`)}`;
}
}
// Create singleton instance
export const i18n = new I18nManager();
// Export for global access (will be attached to window)
export default i18n;

View File

@@ -0,0 +1,379 @@
/**
* English (en) translations for LoRA Manager
*/
export const en = {
// Common terms used throughout the application
common: {
// File operations
file: 'File',
folder: 'Folder',
name: 'Name',
size: 'Size',
date: 'Date',
type: 'Type',
path: 'Path',
// File sizes
fileSize: {
zero: '0 Bytes',
bytes: 'Bytes',
kb: 'KB',
mb: 'MB',
gb: 'GB',
tb: 'TB'
},
// Actions
actions: {
save: 'Save',
cancel: 'Cancel',
delete: 'Delete',
edit: 'Edit',
copy: 'Copy',
move: 'Move',
refresh: 'Refresh',
download: 'Download',
upload: 'Upload',
search: 'Search',
filter: 'Filter',
sort: 'Sort',
select: 'Select',
selectAll: 'Select All',
deselectAll: 'Deselect All',
confirm: 'Confirm',
close: 'Close',
back: 'Back',
next: 'Next',
previous: 'Previous',
view: 'View',
preview: 'Preview',
details: 'Details',
settings: 'Settings',
help: 'Help',
about: 'About'
},
// Status messages
status: {
loading: 'Loading...',
saving: 'Saving...',
saved: 'Saved',
error: 'Error',
success: 'Success',
warning: 'Warning',
info: 'Information',
processing: 'Processing...',
completed: 'Completed',
failed: 'Failed',
cancelled: 'Cancelled',
pending: 'Pending',
ready: 'Ready'
}
},
// Header and navigation
header: {
appTitle: 'LoRA Manager',
navigation: {
loras: 'LoRAs',
recipes: 'Recipes',
checkpoints: 'Checkpoints',
embeddings: 'Embeddings',
statistics: 'Stats'
},
search: {
placeholder: 'Search...',
placeholders: {
loras: 'Search LoRAs...',
recipes: 'Search recipes...',
checkpoints: 'Search checkpoints...',
embeddings: 'Search embeddings...'
},
options: 'Search Options',
searchIn: 'Search In:',
notAvailable: 'Search not available on statistics page',
filters: {
filename: 'Filename',
modelname: 'Model Name',
tags: 'Tags',
creator: 'Creator',
title: 'Recipe Title',
loraName: 'LoRA Filename',
loraModel: 'LoRA Model Name'
}
},
filter: {
title: 'Filter Models',
baseModel: 'Base Model',
modelTags: 'Tags (Top 20)',
clearAll: 'Clear All Filters'
},
theme: {
toggle: 'Toggle theme',
switchToLight: 'Switch to light theme',
switchToDark: 'Switch to dark theme',
switchToAuto: 'Switch to auto theme'
}
},
// LoRAs page
loras: {
title: 'LoRA Models',
controls: {
sort: {
title: 'Sort models by...',
name: 'Name',
nameAsc: 'A - Z',
nameDesc: 'Z - A',
date: 'Date Added',
dateDesc: 'Newest',
dateAsc: 'Oldest',
size: 'File Size',
sizeDesc: 'Largest',
sizeAsc: 'Smallest'
},
refresh: {
title: 'Refresh model list',
quick: 'Quick Refresh (incremental)',
full: 'Full Rebuild (complete)'
},
fetch: 'Fetch from Civitai',
download: 'Download from URL',
bulk: 'Bulk Operations',
duplicates: 'Find Duplicates',
favorites: 'Show Favorites Only'
},
bulkOperations: {
title: 'Bulk Operations',
selected: '{count} selected',
sendToWorkflow: 'Send all selected LoRAs to workflow',
copyAll: 'Copy all selected LoRAs syntax',
refreshAll: 'Refresh CivitAI metadata for selected models',
moveAll: 'Move selected models to folder',
deleteAll: 'Delete selected models',
clear: 'Clear selection'
},
contextMenu: {
refreshMetadata: 'Refresh Civitai Data',
relinkCivitai: 'Re-link to Civitai',
copySyntax: 'Copy LoRA Syntax',
sendToWorkflowAppend: 'Send to Workflow (Append)',
sendToWorkflowReplace: 'Send to Workflow (Replace)',
openExamples: 'Open Examples Folder',
downloadExamples: 'Download Example Images',
replacePreview: 'Replace Preview',
setContentRating: 'Set Content Rating',
moveToFolder: 'Move to Folder',
excludeModel: 'Exclude Model',
deleteModel: 'Delete Model'
},
modal: {
title: 'LoRA Details',
tabs: {
examples: 'Examples',
description: 'Model Description',
recipes: 'Recipes'
},
info: {
filename: 'Filename',
modelName: 'Model Name',
baseModel: 'Base Model',
fileSize: 'File Size',
dateAdded: 'Date Added',
triggerWords: 'Trigger Words',
description: 'Description',
tags: 'Tags',
rating: 'Rating',
downloads: 'Downloads',
likes: 'Likes',
version: 'Version'
},
actions: {
copyTriggerWords: 'Copy trigger words',
copyLoraName: 'Copy LoRA name',
sendToWorkflow: 'Send to Workflow',
viewOnCivitai: 'View on Civitai',
downloadExamples: 'Download example images'
}
}
},
// Recipes page
recipes: {
title: 'LoRA Recipes',
controls: {
import: 'Import Recipe',
create: 'Create Recipe',
export: 'Export Selected',
downloadMissing: 'Download Missing LoRAs'
},
card: {
author: 'Author',
loras: '{count} LoRAs',
tags: 'Tags',
actions: {
sendToWorkflow: 'Send to Workflow',
edit: 'Edit Recipe',
duplicate: 'Duplicate Recipe',
export: 'Export Recipe',
delete: 'Delete Recipe'
}
}
},
// Checkpoints page
checkpoints: {
title: 'Checkpoint Models',
info: {
filename: 'Filename',
modelName: 'Model Name',
baseModel: 'Base Model',
fileSize: 'File Size',
dateAdded: 'Date Added'
}
},
// Embeddings page
embeddings: {
title: 'Embedding Models',
info: {
filename: 'Filename',
modelName: 'Model Name',
triggerWords: 'Trigger Words',
fileSize: 'File Size',
dateAdded: 'Date Added'
}
},
// Statistics page
statistics: {
title: 'Statistics',
overview: {
title: 'Overview',
totalLoras: 'Total LoRAs',
totalCheckpoints: 'Total Checkpoints',
totalEmbeddings: 'Total Embeddings',
totalSize: 'Total Size',
favoriteModels: 'Favorite Models'
},
charts: {
modelsByType: 'Models by Type',
modelsByBaseModel: 'Models by Base Model',
modelsBySize: 'Models by File Size',
modelsAddedOverTime: 'Models Added Over Time'
}
},
// Modals and dialogs
modals: {
delete: {
title: 'Confirm Deletion',
message: 'Are you sure you want to delete this model?',
warningMessage: 'This action cannot be undone.',
confirm: 'Delete',
cancel: 'Cancel'
},
exclude: {
title: 'Exclude Model',
message: 'Are you sure you want to exclude this model from the library?',
confirm: 'Exclude',
cancel: 'Cancel'
},
download: {
title: 'Download Model',
url: 'Model URL',
placeholder: 'Enter Civitai model URL...',
download: 'Download',
cancel: 'Cancel'
},
move: {
title: 'Move Models',
selectFolder: 'Select destination folder',
createFolder: 'Create new folder',
folderName: 'Folder name',
move: 'Move',
cancel: 'Cancel'
},
contentRating: {
title: 'Set Content Rating',
current: 'Current',
levels: {
pg: 'PG',
pg13: 'PG13',
r: 'R',
x: 'X',
xxx: 'XXX'
}
}
},
// Error messages
errors: {
general: 'An error occurred',
networkError: 'Network error. Please check your connection.',
serverError: 'Server error. Please try again later.',
fileNotFound: 'File not found',
invalidFile: 'Invalid file format',
uploadFailed: 'Upload failed',
downloadFailed: 'Download failed',
saveFailed: 'Save failed',
loadFailed: 'Load failed',
deleteFailed: 'Delete failed',
moveFailed: 'Move failed',
copyFailed: 'Copy failed',
fetchFailed: 'Failed to fetch data from Civitai',
invalidUrl: 'Invalid URL format',
missingPermissions: 'Insufficient permissions'
},
// Success messages
success: {
saved: 'Successfully saved',
deleted: 'Successfully deleted',
moved: 'Successfully moved',
copied: 'Successfully copied',
downloaded: 'Successfully downloaded',
uploaded: 'Successfully uploaded',
refreshed: 'Successfully refreshed',
exported: 'Successfully exported',
imported: 'Successfully imported'
},
// Keyboard shortcuts
keyboard: {
navigation: 'Keyboard Navigation:',
shortcuts: {
pageUp: 'Scroll up one page',
pageDown: 'Scroll down one page',
home: 'Jump to top',
end: 'Jump to bottom',
bulkMode: 'Toggle bulk mode',
search: 'Focus search',
escape: 'Close modal/panel'
}
},
// Initialization
initialization: {
title: 'Initializing LoRA Manager',
message: 'Scanning and building LoRA cache. This may take a few minutes...',
steps: {
scanning: 'Scanning model files...',
processing: 'Processing metadata...',
building: 'Building cache...',
finalizing: 'Finalizing...'
}
},
// Tooltips and help text
tooltips: {
refresh: 'Refresh the model list',
bulkOperations: 'Select multiple models for batch operations',
favorites: 'Show only favorite models',
duplicates: 'Find and manage duplicate models',
search: 'Search models by name, tags, or other criteria',
filter: 'Filter models by various criteria',
sort: 'Sort models by different attributes',
backToTop: 'Scroll back to top of page'
}
};

View File

@@ -0,0 +1,379 @@
/**
* Simplified Chinese (zh-CN) translations for LoRA Manager
*/
export const zhCN = {
// 应用中使用的通用术语
common: {
// 文件操作
file: '文件',
folder: '文件夹',
name: '名称',
size: '大小',
date: '日期',
type: '类型',
path: '路径',
// 文件大小
fileSize: {
zero: '0 字节',
bytes: '字节',
kb: 'KB',
mb: 'MB',
gb: 'GB',
tb: 'TB'
},
// 操作
actions: {
save: '保存',
cancel: '取消',
delete: '删除',
edit: '编辑',
copy: '复制',
move: '移动',
refresh: '刷新',
download: '下载',
upload: '上传',
search: '搜索',
filter: '筛选',
sort: '排序',
select: '选择',
selectAll: '全选',
deselectAll: '取消全选',
confirm: '确认',
close: '关闭',
back: '返回',
next: '下一步',
previous: '上一步',
view: '查看',
preview: '预览',
details: '详情',
settings: '设置',
help: '帮助',
about: '关于'
},
// 状态信息
status: {
loading: '加载中...',
saving: '保存中...',
saved: '已保存',
error: '错误',
success: '成功',
warning: '警告',
info: '信息',
processing: '处理中...',
completed: '已完成',
failed: '失败',
cancelled: '已取消',
pending: '等待中',
ready: '就绪'
}
},
// 头部和导航
header: {
appTitle: 'LoRA 管理器',
navigation: {
loras: 'LoRA 模型',
recipes: '配方',
checkpoints: '检查点',
embeddings: '嵌入模型',
statistics: '统计'
},
search: {
placeholder: '搜索...',
placeholders: {
loras: '搜索 LoRA...',
recipes: '搜索配方...',
checkpoints: '搜索检查点...',
embeddings: '搜索嵌入模型...'
},
options: '搜索选项',
searchIn: '搜索范围:',
notAvailable: '统计页面不支持搜索',
filters: {
filename: '文件名',
modelname: '模型名称',
tags: '标签',
creator: '创作者',
title: '配方标题',
loraName: 'LoRA 文件名',
loraModel: 'LoRA 模型名称'
}
},
filter: {
title: '筛选模型',
baseModel: '基础模型',
modelTags: '标签前20个',
clearAll: '清除所有筛选'
},
theme: {
toggle: '切换主题',
switchToLight: '切换到浅色主题',
switchToDark: '切换到深色主题',
switchToAuto: '切换到自动主题'
}
},
// LoRA 页面
loras: {
title: 'LoRA 模型',
controls: {
sort: {
title: '排序方式...',
name: '名称',
nameAsc: 'A - Z',
nameDesc: 'Z - A',
date: '添加日期',
dateDesc: '最新',
dateAsc: '最旧',
size: '文件大小',
sizeDesc: '最大',
sizeAsc: '最小'
},
refresh: {
title: '刷新模型列表',
quick: '快速刷新(增量)',
full: '完全重建(完整)'
},
fetch: '从 Civitai 获取',
download: '从 URL 下载',
bulk: '批量操作',
duplicates: '查找重复项',
favorites: '仅显示收藏'
},
bulkOperations: {
title: '批量操作',
selected: '已选择 {count} 项',
sendToWorkflow: '将所有选中的 LoRA 发送到工作流',
copyAll: '复制所有选中 LoRA 的语法',
refreshAll: '刷新选中模型的 CivitAI 元数据',
moveAll: '将选中模型移动到文件夹',
deleteAll: '删除选中的模型',
clear: '清除选择'
},
contextMenu: {
refreshMetadata: '刷新 Civitai 数据',
relinkCivitai: '重新链接到 Civitai',
copySyntax: '复制 LoRA 语法',
sendToWorkflowAppend: '发送到工作流(追加)',
sendToWorkflowReplace: '发送到工作流(替换)',
openExamples: '打开示例文件夹',
downloadExamples: '下载示例图片',
replacePreview: '替换预览图',
setContentRating: '设置内容评级',
moveToFolder: '移动到文件夹',
excludeModel: '排除模型',
deleteModel: '删除模型'
},
modal: {
title: 'LoRA 详情',
tabs: {
examples: '示例',
description: '模型描述',
recipes: '配方'
},
info: {
filename: '文件名',
modelName: '模型名称',
baseModel: '基础模型',
fileSize: '文件大小',
dateAdded: '添加日期',
triggerWords: '触发词',
description: '描述',
tags: '标签',
rating: '评分',
downloads: '下载量',
likes: '点赞数',
version: '版本'
},
actions: {
copyTriggerWords: '复制触发词',
copyLoraName: '复制 LoRA 名称',
sendToWorkflow: '发送到工作流',
viewOnCivitai: '在 Civitai 上查看',
downloadExamples: '下载示例图片'
}
}
},
// 配方页面
recipes: {
title: 'LoRA 配方',
controls: {
import: '导入配方',
create: '创建配方',
export: '导出选中',
downloadMissing: '下载缺失的 LoRA'
},
card: {
author: '作者',
loras: '{count} 个 LoRA',
tags: '标签',
actions: {
sendToWorkflow: '发送到工作流',
edit: '编辑配方',
duplicate: '复制配方',
export: '导出配方',
delete: '删除配方'
}
}
},
// 检查点页面
checkpoints: {
title: '检查点模型',
info: {
filename: '文件名',
modelName: '模型名称',
baseModel: '基础模型',
fileSize: '文件大小',
dateAdded: '添加日期'
}
},
// 嵌入模型页面
embeddings: {
title: '嵌入模型',
info: {
filename: '文件名',
modelName: '模型名称',
triggerWords: '触发词',
fileSize: '文件大小',
dateAdded: '添加日期'
}
},
// 统计页面
statistics: {
title: '统计信息',
overview: {
title: '概览',
totalLoras: 'LoRA 总数',
totalCheckpoints: '检查点总数',
totalEmbeddings: '嵌入模型总数',
totalSize: '总大小',
favoriteModels: '收藏模型'
},
charts: {
modelsByType: '按类型统计模型',
modelsByBaseModel: '按基础模型统计',
modelsBySize: '按文件大小统计',
modelsAddedOverTime: '模型添加时间分布'
}
},
// 模态框和对话框
modals: {
delete: {
title: '确认删除',
message: '确定要删除这个模型吗?',
warningMessage: '此操作无法撤销。',
confirm: '删除',
cancel: '取消'
},
exclude: {
title: '排除模型',
message: '确定要从库中排除这个模型吗?',
confirm: '排除',
cancel: '取消'
},
download: {
title: '下载模型',
url: '模型 URL',
placeholder: '输入 Civitai 模型 URL...',
download: '下载',
cancel: '取消'
},
move: {
title: '移动模型',
selectFolder: '选择目标文件夹',
createFolder: '创建新文件夹',
folderName: '文件夹名称',
move: '移动',
cancel: '取消'
},
contentRating: {
title: '设置内容评级',
current: '当前',
levels: {
pg: '普通级',
pg13: '辅导级',
r: '限制级',
x: '成人级',
xxx: '重口级'
}
}
},
// 错误信息
errors: {
general: '发生错误',
networkError: '网络错误,请检查您的连接。',
serverError: '服务器错误,请稍后重试。',
fileNotFound: '文件未找到',
invalidFile: '无效的文件格式',
uploadFailed: '上传失败',
downloadFailed: '下载失败',
saveFailed: '保存失败',
loadFailed: '加载失败',
deleteFailed: '删除失败',
moveFailed: '移动失败',
copyFailed: '复制失败',
fetchFailed: '从 Civitai 获取数据失败',
invalidUrl: '无效的 URL 格式',
missingPermissions: '权限不足'
},
// 成功信息
success: {
saved: '保存成功',
deleted: '删除成功',
moved: '移动成功',
copied: '复制成功',
downloaded: '下载成功',
uploaded: '上传成功',
refreshed: '刷新成功',
exported: '导出成功',
imported: '导入成功'
},
// 键盘快捷键
keyboard: {
navigation: '键盘导航:',
shortcuts: {
pageUp: '向上滚动一页',
pageDown: '向下滚动一页',
home: '跳转到顶部',
end: '跳转到底部',
bulkMode: '切换批量模式',
search: '聚焦搜索框',
escape: '关闭模态框/面板'
}
},
// 初始化
initialization: {
title: '初始化 LoRA 管理器',
message: '正在扫描并构建 LoRA 缓存,这可能需要几分钟时间...',
steps: {
scanning: '扫描模型文件...',
processing: '处理元数据...',
building: '构建缓存...',
finalizing: '完成中...'
}
},
// 工具提示和帮助文本
tooltips: {
refresh: '刷新模型列表',
bulkOperations: '选择多个模型进行批量操作',
favorites: '仅显示收藏的模型',
duplicates: '查找和管理重复的模型',
search: '按名称、标签或其他条件搜索模型',
filter: '按各种条件筛选模型',
sort: '按不同属性排序模型',
backToTop: '滚动回页面顶部'
}
};

View File

@@ -5,6 +5,7 @@ import { LoraContextMenu } from './components/ContextMenu/index.js';
import { createPageControls } from './components/controls/index.js';
import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js';
import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js';
import { initializePageI18n } from './utils/i18nHelpers.js';
// Initialize the LoRA page
class LoraPageManager {
@@ -45,6 +46,9 @@ class LoraPageManager {
// Initialize common page features (virtual scroll)
appCore.initializePageFeatures();
// Initialize i18n for the page
initializePageI18n();
}
}

View File

@@ -5,6 +5,7 @@ import { modalManager } from './ModalManager.js';
import { moveManager } from './MoveManager.js';
import { getModelApiClient } from '../api/modelApiFactory.js';
import { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.js';
import { updateBulkSelectionCount } from '../utils/i18nHelpers.js';
export class BulkManager {
constructor() {
@@ -182,11 +183,10 @@ export class BulkManager {
updateSelectedCount() {
const countElement = document.getElementById('selectedCount');
const currentConfig = MODEL_CONFIG[state.currentPageType];
const displayName = currentConfig?.displayName || 'Models';
if (countElement) {
countElement.textContent = `${state.selectedModels.size} ${displayName.toLowerCase()}(s) selected `;
// Use i18n helper to update the count text
updateBulkSelectionCount(state.selectedModels.size);
const existingCaret = countElement.querySelector('.dropdown-caret');
if (existingCaret) {

View File

@@ -8,6 +8,7 @@ import { RecipeContextMenu } from './components/ContextMenu/index.js';
import { DuplicatesManager } from './components/DuplicatesManager.js';
import { refreshVirtualScroll } from './utils/infiniteScroll.js';
import { refreshRecipes } from './api/recipeApi.js';
import { initializePageI18n } from './utils/i18nHelpers.js';
class RecipeManager {
constructor() {
@@ -54,6 +55,9 @@ class RecipeManager {
// Initialize common page features
appCore.initializePageFeatures();
// Initialize i18n for the page
initializePageI18n();
}
_initSearchOptions() {

View File

@@ -1,6 +1,7 @@
// Statistics page functionality
import { appCore } from './core.js';
import { showToast } from './utils/uiHelpers.js';
import { initializePageI18n } from './utils/i18nHelpers.js';
// Chart.js import (assuming it's available globally or via CDN)
// If Chart.js isn't available, we'll need to add it to the project
@@ -26,6 +27,9 @@ class StatisticsManager {
// Initialize charts and visualizations
this.initializeVisualizations();
// Initialize i18n for the page
initializePageI18n();
this.initialized = true;
}

123
static/js/test/i18nTest.js Normal file
View File

@@ -0,0 +1,123 @@
/**
* i18n System Test
* Simple test to verify internationalization functionality
*/
import { i18n } from '../i18n/index.js';
import { initializePageI18n, t, formatFileSize, formatDate, formatNumber } from '../utils/i18nHelpers.js';
// Mock DOM elements for testing
function createMockDOM() {
// Create a test container
const container = document.createElement('div');
container.innerHTML = `
<div data-i18n="header.appTitle">LoRA Manager</div>
<input data-i18n="header.search.placeholder" data-i18n-target="placeholder" placeholder="Search..." />
<button data-i18n="common.actions.save">Save</button>
<span data-i18n="loras.bulkOperations.selected" data-i18n-params='{"count": 5}'>5 selected</span>
`;
document.body.appendChild(container);
return container;
}
// Test basic translation functionality
function testBasicTranslation() {
console.log('=== Testing Basic Translation ===');
// Test simple translation
const saveText = t('common.actions.save');
console.log(`Save button text: ${saveText}`);
// Test translation with parameters
const selectedText = t('loras.bulkOperations.selected', { count: 3 });
console.log(`Selection text: ${selectedText}`);
// Test non-existent key (should return the key itself)
const missingKey = t('non.existent.key');
console.log(`Missing key: ${missingKey}`);
}
// Test DOM translation
function testDOMTranslation() {
console.log('=== Testing DOM Translation ===');
const container = createMockDOM();
// Apply translations
initializePageI18n();
// Check if translations were applied
const titleElement = container.querySelector('[data-i18n="header.appTitle"]');
const inputElement = container.querySelector('input[data-i18n="header.search.placeholder"]');
const buttonElement = container.querySelector('[data-i18n="common.actions.save"]');
console.log(`Title: ${titleElement.textContent}`);
console.log(`Input placeholder: ${inputElement.placeholder}`);
console.log(`Button: ${buttonElement.textContent}`);
// Clean up
document.body.removeChild(container);
}
// Test formatting functions
function testFormatting() {
console.log('=== Testing Formatting Functions ===');
// Test file size formatting
const sizes = [0, 1024, 1048576, 1073741824];
sizes.forEach(size => {
const formatted = formatFileSize(size);
console.log(`${size} bytes = ${formatted}`);
});
// Test date formatting
const date = new Date('2024-01-15T10:30:00');
const formattedDate = formatDate(date, {
year: 'numeric',
month: 'long',
day: 'numeric'
});
console.log(`Date: ${formattedDate}`);
// Test number formatting
const number = 1234.567;
const formattedNumber = formatNumber(number, {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
console.log(`Number: ${formattedNumber}`);
}
// Test language detection
function testLanguageDetection() {
console.log('=== Testing Language Detection ===');
console.log(`Detected language: ${i18n.getCurrentLocale()}`);
console.log(`Is RTL: ${i18n.isRTL()}`);
console.log(`Browser language: ${navigator.language}`);
}
// Run all tests
function runTests() {
console.log('Starting i18n System Tests...');
console.log('=====================================');
testLanguageDetection();
testBasicTranslation();
testFormatting();
// Only test DOM if we're in a browser environment
if (typeof document !== 'undefined') {
testDOMTranslation();
}
console.log('=====================================');
console.log('i18n System Tests Completed!');
}
// Export for manual testing
export { runTests };
// Auto-run tests if this module is loaded directly
if (typeof window !== 'undefined' && window.location.search.includes('test=i18n')) {
document.addEventListener('DOMContentLoaded', runTests);
}

View File

@@ -0,0 +1,197 @@
/**
* DOM utilities for i18n text replacement
*/
import { i18n } from '../i18n/index.js';
/**
* Replace text content in DOM elements with translations
* Uses data-i18n attribute to specify translation keys
*/
export function translateDOM() {
// Find all elements with data-i18n attribute
const elements = document.querySelectorAll('[data-i18n]');
elements.forEach(element => {
const key = element.getAttribute('data-i18n');
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 = i18n.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;
}
});
}
/**
* Update placeholder text based on current page
* @param {string} currentPath - Current page path
*/
export function updateSearchPlaceholder(currentPath) {
const searchInput = document.getElementById('searchInput');
if (!searchInput) return;
let placeholderKey = 'header.search.placeholder';
if (currentPath === '/loras') {
placeholderKey = 'header.search.placeholders.loras';
} else if (currentPath === '/loras/recipes') {
placeholderKey = 'header.search.placeholders.recipes';
} else if (currentPath === '/checkpoints') {
placeholderKey = 'header.search.placeholders.checkpoints';
} else if (currentPath === '/embeddings') {
placeholderKey = 'header.search.placeholders.embeddings';
}
searchInput.placeholder = i18n.t(placeholderKey);
}
/**
* Set text content for an element using i18n
* @param {Element|string} element - DOM element or selector
* @param {string} key - Translation key
* @param {Object} params - Translation parameters
*/
export function setTranslatedText(element, key, params = {}) {
const el = typeof element === 'string' ? document.querySelector(element) : element;
if (el) {
el.textContent = i18n.t(key, params);
}
}
/**
* Set attribute value for an element using i18n
* @param {Element|string} element - DOM element or selector
* @param {string} attribute - Attribute name
* @param {string} key - Translation key
* @param {Object} params - Translation parameters
*/
export function setTranslatedAttribute(element, attribute, key, params = {}) {
const el = typeof element === 'string' ? document.querySelector(element) : element;
if (el) {
el.setAttribute(attribute, i18n.t(key, params));
}
}
/**
* Create a translated element
* @param {string} tagName - HTML tag name
* @param {string} key - Translation key
* @param {Object} params - Translation parameters
* @param {Object} attributes - Additional attributes
* @returns {Element} Created element
*/
export function createTranslatedElement(tagName, key, params = {}, attributes = {}) {
const element = document.createElement(tagName);
element.textContent = i18n.t(key, params);
Object.entries(attributes).forEach(([attr, value]) => {
element.setAttribute(attr, value);
});
return element;
}
/**
* Update bulk selection count text
* @param {number} count - Number of selected items
*/
export function updateBulkSelectionCount(count) {
const selectedCountElement = document.getElementById('selectedCount');
if (selectedCountElement) {
const textNode = selectedCountElement.firstChild;
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
textNode.textContent = i18n.t('loras.bulkOperations.selected', { count });
}
}
}
/**
* Format file size with localized units
* @param {number} bytes - Size in bytes
* @param {number} decimals - Number of decimal places
* @returns {string} Formatted size string
*/
export function formatFileSize(bytes, decimals = 2) {
return i18n.formatFileSize(bytes, decimals);
}
/**
* Format date with current locale
* @param {Date|string|number} date - Date to format
* @param {Object} options - Intl.DateTimeFormat options
* @returns {string} Formatted date string
*/
export function formatDate(date, options = {}) {
return i18n.formatDate(date, options);
}
/**
* Format number with current locale
* @param {number} number - Number to format
* @param {Object} options - Intl.NumberFormat options
* @returns {string} Formatted number string
*/
export function formatNumber(number, options = {}) {
return i18n.formatNumber(number, options);
}
/**
* Initialize i18n for the page
* This should be called after DOM content is loaded
*/
export function initializePageI18n() {
// Translate all elements with data-i18n attributes
translateDOM();
// Update search placeholder based on current page
const currentPath = window.location.pathname;
updateSearchPlaceholder(currentPath);
// Set document direction for RTL languages
if (i18n.isRTL()) {
document.documentElement.setAttribute('dir', 'rtl');
document.body.classList.add('rtl');
} else {
document.documentElement.setAttribute('dir', 'ltr');
document.body.classList.remove('rtl');
}
}
/**
* Helper function to get translation directly
* @param {string} key - Translation key
* @param {Object} params - Parameters for interpolation
* @returns {string} Translated text
*/
export function t(key, params = {}) {
return i18n.t(key, params);
}