From a4814b8c348e9a2ec78f2e033f6258064cb46409 Mon Sep 17 00:00:00 2001 From: Wha1eChai Date: Fri, 9 Jan 2026 00:39:25 +0800 Subject: [PATCH] feat(webui): refactor settings architecture and achieve full i18n coverage --- public/css/style.css | 74 ++- public/index.html | 2 + public/js/components/dashboard.js | 9 +- public/js/components/model-manager.js | 43 ++ public/js/components/server-config.js | 229 ++++++++++ public/js/store.js | 80 ++++ public/js/utils.js | 8 +- public/views/accounts.html | 23 +- public/views/dashboard.html | 38 +- public/views/settings.html | 628 ++++++++++---------------- 10 files changed, 711 insertions(+), 423 deletions(-) create mode 100644 public/js/components/model-manager.js create mode 100644 public/js/components/server-config.js diff --git a/public/css/style.css b/public/css/style.css index 7e18137..781c4a1 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -156,4 +156,76 @@ } .standard-table tbody tr { @apply hover:bg-white/5 transition-colors border-b border-space-border/30 last:border-0; -} \ No newline at end of file +} + +/* Custom Range Slider */ +.custom-range { + -webkit-appearance: none; + appearance: none; + width: 100%; + height: 6px; + background: var(--color-space-800); + border-radius: 999px; + outline: none; + cursor: pointer; + position: relative; + background-image: linear-gradient(to right, var(--range-color) 0%, var(--range-color) 100%); + background-repeat: no-repeat; + background-size: 0% 100%; +} + +.custom-range::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 18px; + height: 18px; + border-radius: 50%; + background: #ffffff; + box-shadow: 0 0 10px var(--range-color-glow); + cursor: pointer; + margin-top: -6px; + transition: transform 0.1s ease, box-shadow 0.2s ease; +} + +.custom-range::-webkit-slider-thumb:hover { + transform: scale(1.1); + box-shadow: 0 0 15px var(--range-color-glow); +} + +.custom-range::-moz-range-thumb { + width: 18px; + height: 18px; + border: none; + border-radius: 50%; + background: #ffffff; + box-shadow: 0 0 10px var(--range-color-glow); + cursor: pointer; + transition: transform 0.1s ease, box-shadow 0.2s ease; +} + +.custom-range::-moz-range-thumb:hover { + transform: scale(1.1); + box-shadow: 0 0 15px var(--range-color-glow); +} + +/* Color Variants */ +.custom-range-purple { + --range-color: var(--color-neon-purple); + --range-color-glow: rgba(168, 85, 247, 0.5); +} +.custom-range-green { + --range-color: var(--color-neon-green); + --range-color-glow: rgba(34, 197, 94, 0.5); +} +.custom-range-cyan { + --range-color: var(--color-neon-cyan); + --range-color-glow: rgba(6, 182, 212, 0.5); +} +.custom-range-yellow { + --range-color: var(--color-neon-yellow); + --range-color-glow: rgba(234, 179, 8, 0.5); +} +.custom-range-accent { + --range-color: var(--color-neon-cyan); /* Default accent to cyan if needed, or match DaisyUI */ + --range-color-glow: rgba(6, 182, 212, 0.5); +} diff --git a/public/index.html b/public/index.html index abe47d9..e6c65c1 100644 --- a/public/index.html +++ b/public/index.html @@ -298,6 +298,8 @@ + + diff --git a/public/js/components/dashboard.js b/public/js/components/dashboard.js index 83c42fb..6f71e5d 100644 --- a/public/js/components/dashboard.js +++ b/public/js/components/dashboard.js @@ -573,18 +573,19 @@ window.Components.dashboard = () => ({ const familyColor = familyColors[family] || familyColors['unknown']; - // Get translation keys if available, otherwise capitalize - const familyName = family.charAt(0).toUpperCase() + family.slice(1); + // Get translation keys const store = Alpine.store('global'); + const familyKey = 'family' + family.charAt(0).toUpperCase() + family.slice(1); + const familyName = store.t(familyKey); // Labels using translations if possible const activeLabel = family === 'claude' ? store.t('claudeActive') : family === 'gemini' ? store.t('geminiActive') : - `${familyName} Active`; + `${familyName} ${store.t('activeSuffix')}`; const depletedLabel = family === 'claude' ? store.t('claudeEmpty') : family === 'gemini' ? store.t('geminiEmpty') : - `${familyName} Depleted`; + `${familyName} ${store.t('depleted')}`; // Active segment data.push(activeVal); diff --git a/public/js/components/model-manager.js b/public/js/components/model-manager.js new file mode 100644 index 0000000..69554f4 --- /dev/null +++ b/public/js/components/model-manager.js @@ -0,0 +1,43 @@ +/** + * Model Manager Component + * Handles model configuration (pinning, hiding, aliasing, mapping) + * Registers itself to window.Components for Alpine.js to consume + */ +window.Components = window.Components || {}; + +window.Components.modelManager = () => ({ + init() { + // Component is ready + }, + + /** + * Update model configuration with authentication + * @param {string} modelId - The model ID to update + * @param {object} configUpdates - Configuration updates (pinned, hidden, alias, mapping) + */ + async updateModelConfig(modelId, configUpdates) { + const store = Alpine.store('global'); + try { + const { response, newPassword } = await window.utils.request('/api/models/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ modelId, config: configUpdates }) + }, store.webuiPassword); + + if (newPassword) store.webuiPassword = newPassword; + + if (!response.ok) { + throw new Error('Failed to update model config'); + } + + // Optimistic update + Alpine.store('data').modelConfig[modelId] = { + ...Alpine.store('data').modelConfig[modelId], + ...configUpdates + }; + Alpine.store('data').computeQuotaRows(); + } catch (e) { + store.showToast('Failed to update model config: ' + e.message, 'error'); + } + } +}); diff --git a/public/js/components/server-config.js b/public/js/components/server-config.js new file mode 100644 index 0000000..135f1cf --- /dev/null +++ b/public/js/components/server-config.js @@ -0,0 +1,229 @@ +/** + * Server Config Component + * Registers itself to window.Components for Alpine.js to consume + */ +window.Components = window.Components || {}; + +window.Components.serverConfig = () => ({ + serverConfig: {}, + loading: false, + advancedExpanded: false, + debounceTimers: {}, // Store debounce timers for each config field + + init() { + // Initial fetch if this is the active sub-tab + if (this.activeTab === 'server') { + this.fetchServerConfig(); + } + + // Watch local activeTab (from parent settings scope) + this.$watch('activeTab', (tab) => { + if (tab === 'server') { + this.fetchServerConfig(); + } + }); + }, + + async fetchServerConfig() { + const password = Alpine.store('global').webuiPassword; + try { + const { response, newPassword } = await window.utils.request('/api/config', {}, password); + if (newPassword) Alpine.store('global').webuiPassword = newPassword; + + if (!response.ok) throw new Error('Failed to fetch config'); + const data = await response.json(); + this.serverConfig = data.config || {}; + } catch (e) { + console.error('Failed to fetch server config:', e); + } + }, + + + + // Password management + passwordDialog: { + show: false, + oldPassword: '', + newPassword: '', + confirmPassword: '' + }, + + showPasswordDialog() { + this.passwordDialog = { + show: true, + oldPassword: '', + newPassword: '', + confirmPassword: '' + }; + }, + + hidePasswordDialog() { + this.passwordDialog = { + show: false, + oldPassword: '', + newPassword: '', + confirmPassword: '' + }; + }, + + async changePassword() { + const store = Alpine.store('global'); + const { oldPassword, newPassword, confirmPassword } = this.passwordDialog; + + if (newPassword !== confirmPassword) { + store.showToast(store.t('passwordsNotMatch'), 'error'); + return; + } + if (newPassword.length < 6) { + store.showToast(store.t('passwordTooShort'), 'error'); + return; + } + + try { + const { response } = await window.utils.request('/api/config/password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ oldPassword, newPassword }) + }, store.webuiPassword); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || 'Failed to change password'); + } + + // Update stored password + store.webuiPassword = newPassword; + store.showToast('Password changed successfully', 'success'); + this.hidePasswordDialog(); + } catch (e) { + store.showToast('Failed to change password: ' + e.message, 'error'); + } + }, + + // Toggle Debug Mode with instant save + async toggleDebug(enabled) { + const store = Alpine.store('global'); + + // Optimistic update + const previousValue = this.serverConfig.debug; + this.serverConfig.debug = enabled; + + try { + const { response, newPassword } = await window.utils.request('/api/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ debug: enabled }) + }, store.webuiPassword); + + if (newPassword) store.webuiPassword = newPassword; + + const data = await response.json(); + if (data.status === 'ok') { + const status = enabled ? 'enabled' : 'disabled'; + store.showToast(`Debug mode ${status}`, 'success'); + await this.fetchServerConfig(); // Confirm server state + } else { + throw new Error(data.error || 'Failed to update debug mode'); + } + } catch (e) { + // Rollback on error + this.serverConfig.debug = previousValue; + store.showToast('Failed to update debug mode: ' + e.message, 'error'); + } + }, + + // Toggle Token Cache with instant save + async toggleTokenCache(enabled) { + const store = Alpine.store('global'); + + // Optimistic update + const previousValue = this.serverConfig.persistTokenCache; + this.serverConfig.persistTokenCache = enabled; + + try { + const { response, newPassword } = await window.utils.request('/api/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ persistTokenCache: enabled }) + }, store.webuiPassword); + + if (newPassword) store.webuiPassword = newPassword; + + const data = await response.json(); + if (data.status === 'ok') { + const status = enabled ? 'enabled' : 'disabled'; + store.showToast(`Token cache ${status}`, 'success'); + await this.fetchServerConfig(); // Confirm server state + } else { + throw new Error(data.error || 'Failed to update token cache'); + } + } catch (e) { + // Rollback on error + this.serverConfig.persistTokenCache = previousValue; + store.showToast('Failed to update token cache: ' + e.message, 'error'); + } + }, + + // Generic debounced save method for numeric configs + async saveConfigField(fieldName, value, displayName) { + const store = Alpine.store('global'); + + // Clear existing timer for this field + if (this.debounceTimers[fieldName]) { + clearTimeout(this.debounceTimers[fieldName]); + } + + // Optimistic update + const previousValue = this.serverConfig[fieldName]; + this.serverConfig[fieldName] = parseInt(value); + + // Set new timer + this.debounceTimers[fieldName] = setTimeout(async () => { + try { + const payload = {}; + payload[fieldName] = parseInt(value); + + const { response, newPassword } = await window.utils.request('/api/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }, store.webuiPassword); + + if (newPassword) store.webuiPassword = newPassword; + + const data = await response.json(); + if (data.status === 'ok') { + store.showToast(`${displayName} updated to ${value}`, 'success'); + await this.fetchServerConfig(); // Confirm server state + } else { + throw new Error(data.error || `Failed to update ${displayName}`); + } + } catch (e) { + // Rollback on error + this.serverConfig[fieldName] = previousValue; + store.showToast(`Failed to update ${displayName}: ` + e.message, 'error'); + } + }, 500); // 500ms debounce + }, + + // Individual toggle methods for each Advanced Tuning field + toggleMaxRetries(value) { + this.saveConfigField('maxRetries', value, 'Max Retries'); + }, + + toggleRetryBaseMs(value) { + this.saveConfigField('retryBaseMs', value, 'Retry Base Delay'); + }, + + toggleRetryMaxMs(value) { + this.saveConfigField('retryMaxMs', value, 'Retry Max Delay'); + }, + + toggleDefaultCooldownMs(value) { + this.saveConfigField('defaultCooldownMs', value, 'Default Cooldown'); + }, + + toggleMaxWaitBeforeErrorMs(value) { + this.saveConfigField('maxWaitBeforeErrorMs', value, 'Max Wait Threshold'); + } +}); diff --git a/public/js/store.js b/public/js/store.js index 4539586..81f008c 100644 --- a/public/js/store.js +++ b/public/js/store.js @@ -167,6 +167,9 @@ document.addEventListener('alpine:init', () => { configSaved: "Configuration Saved", enterPassword: "Enter Web UI Password:", ready: "READY", + depleted: "Depleted", + timeH: "H", + timeM: "M", familyClaude: "Claude", familyGemini: "Gemini", familyOther: "Other", @@ -176,6 +179,43 @@ document.addEventListener('alpine:init', () => { logLevelSuccess: "SUCCESS", logLevelWarn: "WARN", logLevelError: "ERR", + totalColon: "Total:", + todayColon: "Today:", + hour1Colon: "1H:", + smart: "Smart", + smartTitle: "Select Top 5 most used models (24h)", + activeCount: "{count} Active", + allCaps: "ALL", + claudeCaps: "CLAUDE", + geminiCaps: "GEMINI", + modelMapping: "Mapping (Target Model ID)", + systemInfo: "System Information", + refresh: "Refresh", + runtimeConfig: "Runtime Configuration", + debugDesc: "Enable detailed logging (See Logs tab)", + networkRetry: "Network Retry Settings", + maxRetries: "Max Retries", + retryBaseDelay: "Retry Base Delay (ms)", + retryMaxDelay: "Retry Max Delay (ms)", + persistTokenCache: "Persist Token Cache", + persistTokenDesc: "Save OAuth tokens to disk for faster restarts", + rateLimiting: "Account Rate Limiting & Timeouts", + defaultCooldown: "Default Cooldown Time", + maxWaitThreshold: "Max Wait Threshold (Sticky)", + maxWaitDesc: "Maximum time to wait for a sticky account to reset before failing or switching.", + saveConfigServer: "Save Configuration", + serverRestartAlert: "Some changes may require server restart. Config is saved to {path}", + changePassword: "Change WebUI Password", + changePasswordDesc: "Update the password for accessing this dashboard", + currentPassword: "Current Password", + newPassword: "New Password", + confirmNewPassword: "Confirm New Password", + passwordEmptyDesc: "Leave empty if no password set", + passwordLengthDesc: "At least 6 characters", + passwordConfirmDesc: "Re-enter new password", + cancel: "Cancel", + passwordsNotMatch: "Passwords do not match", + passwordTooShort: "Password must be at least 6 characters", }, zh: { dashboard: "仪表盘", @@ -332,6 +372,9 @@ document.addEventListener('alpine:init', () => { configSaved: "配置已保存", enterPassword: "请输入 Web UI 密码:", ready: "就绪", + depleted: "已耗尽", + timeH: "时", + timeM: "分", familyClaude: "Claude 系列", familyGemini: "Gemini 系列", familyOther: "其他系列", @@ -341,6 +384,43 @@ document.addEventListener('alpine:init', () => { logLevelSuccess: "成功", logLevelWarn: "警告", logLevelError: "错误", + totalColon: "总计:", + todayColon: "今日:", + hour1Colon: "1小时:", + smart: "智能选择", + smartTitle: "自动选择过去 24 小时最常用的 5 个模型", + activeCount: "{count} 活跃", + allCaps: "全部", + claudeCaps: "CLAUDE", + geminiCaps: "GEMINI", + modelMapping: "映射 (目标模型 ID)", + systemInfo: "系统信息", + refresh: "刷新", + runtimeConfig: "运行时配置", + debugDesc: "启用详细日志记录 (见运行日志)", + networkRetry: "网络重试设置", + maxRetries: "最大重试次数", + retryBaseDelay: "重试基础延迟 (毫秒)", + retryMaxDelay: "重试最大延迟 (毫秒)", + persistTokenCache: "持久化令牌缓存", + persistTokenDesc: "将 OAuth 令牌保存到磁盘以实现快速重启", + rateLimiting: "账号限流与超时", + defaultCooldown: "默认冷却时间", + maxWaitThreshold: "最大等待阈值 (粘性会话)", + maxWaitDesc: "粘性账号在失败或切换前等待重置的最长时间。", + saveConfigServer: "保存配置", + serverRestartAlert: "部分更改可能需要重启服务器。配置已保存至 {path}", + changePassword: "修改 WebUI 密码", + changePasswordDesc: "更新访问此仪表盘的密码", + currentPassword: "当前密码", + newPassword: "新密码", + confirmNewPassword: "确认新密码", + passwordEmptyDesc: "如果未设置密码请留空", + passwordLengthDesc: "至少 6 个字符", + passwordConfirmDesc: "请再次输入新密码", + cancel: "取消", + passwordsNotMatch: "密码不匹配", + passwordTooShort: "密码至少需要 6 个字符", } }, diff --git a/public/js/utils.js b/public/js/utils.js index 2926d0b..34cae36 100644 --- a/public/js/utils.js +++ b/public/js/utils.js @@ -37,8 +37,12 @@ window.utils = { if (diff <= 0) return store ? store.t('ready') : 'READY'; const mins = Math.floor(diff / 60000); const hrs = Math.floor(mins / 60); - if (hrs > 0) return `${hrs}H ${mins % 60}M`; - return `${mins}M`; + + const hSuffix = store ? store.t('timeH') : 'H'; + const mSuffix = store ? store.t('timeM') : 'M'; + + if (hrs > 0) return `${hrs}${hSuffix} ${mins % 60}${mSuffix}`; + return `${mins}${mSuffix}`; }, getThemeColor(name) { diff --git a/public/views/accounts.html b/public/views/accounts.html index c37bd37..f8ac8e1 100644 --- a/public/views/accounts.html +++ b/public/views/accounts.html @@ -9,13 +9,22 @@ Manage OAuth tokens and session states

- +
+ + +
diff --git a/public/views/dashboard.html b/public/views/dashboard.html index 8ca7c6b..24601ff 100644 --- a/public/views/dashboard.html +++ b/public/views/dashboard.html @@ -74,14 +74,14 @@
- Claude + Claude
- Gemini + Gemini
@@ -111,15 +111,15 @@
- Total: + Total:
- Today: + Today:
- 1H: + 1H:
@@ -148,7 +148,7 @@ - Filter () + Filter (0/0) @@ -170,12 +170,7 @@ x-text="displayMode === 'family' ? $store.global.t('selectFamilies') : $store.global.t('selectModels')">
| @@ -199,8 +194,8 @@ @change="toggleFamily(family)" class="checkbox checkbox-xs checkbox-primary">
- + @@ -210,7 +205,7 @@ x-show="displayMode === 'model'">
- +
@@ -254,7 +249,7 @@ @@ -319,15 +314,15 @@ + @click="$store.data.filters.family = 'all'; $store.data.computeQuotaRows()" x-text="$store.global.t('allCaps')">ALL + @click="$store.data.filters.family = 'claude'; $store.data.computeQuotaRows()" x-text="$store.global.t('claudeCaps')">CLAUDE + @click="$store.data.filters.family = 'gemini'; $store.data.computeQuotaRows()" x-text="$store.global.t('geminiCaps')">GEMINI @@ -370,7 +365,8 @@
-
+
@@ -392,7 +388,7 @@
- - - + + NO MODELS DETECTED @@ -495,334 +461,220 @@
- -
- - -
-
System Information
- -
-
-
-
Port
-
-
-
-
UI Version
-
-
-
- -
Runtime Configuration
- - -
- -
-