diff --git a/src/routes/StatusRoutes.js b/src/routes/StatusRoutes.js index b619759..6ac6d37 100644 --- a/src/routes/StatusRoutes.js +++ b/src/routes/StatusRoutes.js @@ -193,6 +193,24 @@ class StatusRoutes { res.status(400).json({ error: "Invalid count", message: "settingFailed" }); } }); + + app.put("/api/settings/session-error-threshold", isAuthenticated, (req, res) => { + const { threshold } = req.body; + const newThreshold = parseInt(threshold, 10); + + if (Number.isFinite(newThreshold) && newThreshold >= 0) { + this.config.sessionErrorThreshold = newThreshold; + this.serverSystem.sessionRegistry.sessionErrorThreshold = newThreshold; + this.logger.info(`[WebUI] Session error threshold updated to: ${newThreshold}`); + res.status(200).json({ + message: "settingUpdateSuccess", + setting: "sessionErrorThreshold", + value: newThreshold, + }); + } else { + res.status(400).json({ error: "Invalid threshold", message: "settingFailed" }); + } + }); } _getSystemSummary() { diff --git a/ui/app/components/MetricCard.vue b/ui/app/components/MetricCard.vue new file mode 100644 index 0000000..c6afa2a --- /dev/null +++ b/ui/app/components/MetricCard.vue @@ -0,0 +1,358 @@ + + + + + + + diff --git a/ui/app/components/SideNavBar.vue b/ui/app/components/SideNavBar.vue new file mode 100644 index 0000000..54cdc55 --- /dev/null +++ b/ui/app/components/SideNavBar.vue @@ -0,0 +1,151 @@ + + + + + + + diff --git a/ui/app/components/TopAppBar.vue b/ui/app/components/TopAppBar.vue new file mode 100644 index 0000000..f87619b --- /dev/null +++ b/ui/app/components/TopAppBar.vue @@ -0,0 +1,171 @@ + + + + + + + diff --git a/ui/app/composables/useI18nHelper.js b/ui/app/composables/useI18nHelper.js new file mode 100644 index 0000000..becd51a --- /dev/null +++ b/ui/app/composables/useI18nHelper.js @@ -0,0 +1,42 @@ +/** + * File: ui/app/composables/useI18nHelper.js + * Description: Composable for internationalization helper functions + * + * Author: iBUHUB + */ + +import { ref } from 'vue'; +import I18n from '../utils/i18n'; + +export function useI18nHelper() { + const langVersion = ref(I18n.state.version); + + // i18n helper with reactivity trigger + const t = (key, options) => { + langVersion.value; // trigger reactivity + return I18n.t(key, options); + }; + + // Get current language + const getCurrentLang = () => I18n.getLang(); + + // Toggle language + const toggleLanguage = () => { + I18n.toggleLang(); + langVersion.value++; + }; + + // Handle language change from select + const handleLanguageChange = lang => { + I18n.setLang(lang); + langVersion.value++; + }; + + return { + getCurrentLang, + handleLanguageChange, + langVersion, + t, + toggleLanguage, + }; +} diff --git a/ui/app/composables/useLogs.js b/ui/app/composables/useLogs.js new file mode 100644 index 0000000..e4e75a8 --- /dev/null +++ b/ui/app/composables/useLogs.js @@ -0,0 +1,62 @@ +/** + * File: ui/app/composables/useLogs.js + * Description: Composable for managing logs display and operations + * + * Author: iBUHUB + */ + +import { ElMessage } from 'element-plus'; +import I18n from '../utils/i18n'; +import escapeHtml from '../utils/escapeHtml'; + +export function useLogs() { + // i18n helper + const t = (key, options) => I18n.t(key, options); + + // Helper function to highlight log levels + const highlightLogLevel = (content, level, color) => + content.replace( + new RegExp(`(^|\\r?\\n)(\\[${level}\\])(?=\\s)`, 'g'), + `$1$2` + ); + + // Computed for formatted logs (receives raw logs string) + const getFormattedLogs = logs => { + let safeLogs = escapeHtml(logs || t('loading')); + + safeLogs = highlightLogLevel(safeLogs, 'DEBUG', '#3498db'); + safeLogs = highlightLogLevel(safeLogs, 'WARN', '#f39c12'); + safeLogs = highlightLogLevel(safeLogs, 'ERROR', '#e74c3c'); + + return safeLogs; + }; + + // Clear logs view (updates state directly) + const clearLogsView = state => { + state.logs = ''; + }; + + // Download logs + const downloadLogs = logs => { + if (!logs) { + ElMessage.warning(t('noLogsAvailable')); + return; + } + + const blob = new Blob([logs], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `canvastoapi-logs-${new Date().toISOString().slice(0, 10)}.txt`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + + return { + clearLogsView, + downloadLogs, + getFormattedLogs, + }; +} diff --git a/ui/app/composables/useSessions.js b/ui/app/composables/useSessions.js new file mode 100644 index 0000000..a27eaeb --- /dev/null +++ b/ui/app/composables/useSessions.js @@ -0,0 +1,86 @@ +/** + * File: ui/app/composables/useSessions.js + * Description: Composable for managing browser sessions state and operations + * + * Author: iBUHUB + */ + +import { computed, ref } from 'vue'; +import { ElMessage, ElMessageBox } from 'element-plus'; +import I18n from '../utils/i18n'; + +export function useSessions() { + const sessions = ref([]); + + // i18n helper + const t = (key, options) => I18n.t(key, options); + + // Computed + const activeSessionCount = computed(() => sessions.value.filter(s => !s.disabledAt).length); + const disabledSessionCount = computed(() => sessions.value.filter(s => s.disabledAt).length); + + // Helper functions + function formatTime(timestamp) { + if (!timestamp) return '-'; + const date = new Date(timestamp); + return date.toLocaleString(); + } + + function getSessionStatusClass(session) { + if (session.disabledAt) { + return 'status-disabled'; + } + return 'status-online'; + } + + function getSessionStatusText(session) { + if (session.disabledAt) { + return t('disabledLabel'); + } + return t('onlineLabel'); + } + + // Reset session health + const handleResetHealth = async (session, onSuccess) => { + try { + await ElMessageBox.confirm(t('sessionResetConfirm', { session: session.connectionId }), t('warningTitle'), { + cancelButtonText: t('cancel'), + confirmButtonText: t('ok'), + type: 'warning', + }); + + const response = await fetch(`/api/sessions/${session.sessionId}/reset-health`, { + headers: { + 'Content-Type': 'application/json', + }, + method: 'PUT', + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || 'Reset failed'); + } + + ElMessage.success(t('sessionResetSuccess', { session: session.connectionId })); + + // Call onSuccess callback to refresh session list + if (onSuccess) { + await onSuccess(); + } + } catch (error) { + if (error !== 'cancel') { + ElMessage.error(error.message || t('unknownError')); + } + } + }; + + return { + activeSessionCount, + disabledSessionCount, + formatTime, + getSessionStatusClass, + getSessionStatusText, + handleResetHealth, + sessions, + }; +} diff --git a/ui/app/composables/useSettings.js b/ui/app/composables/useSettings.js new file mode 100644 index 0000000..0fdaa05 --- /dev/null +++ b/ui/app/composables/useSettings.js @@ -0,0 +1,267 @@ +/** + * File: ui/app/composables/useSettings.js + * Description: Composable for managing application settings state and API updates + * + * Author: iBUHUB + */ + +import { reactive } from 'vue'; +import { ElMessage, ElMessageBox } from 'element-plus'; +import I18n from '../utils/i18n'; + +export function useSettings() { + const state = reactive({ + debugMode: false, + forceThinking: false, + forceUrlContext: false, + forceWebSearch: false, + logMaxCount: 100, + selectionStrategy: 'round', + sessionErrorThreshold: 3, + streamingMode: 'fake', + }); + + const loading = reactive({ + debugMode: false, + forceThinking: false, + forceUrlContext: false, + forceWebSearch: false, + logMaxCount: false, + selectionStrategy: false, + sessionErrorThreshold: false, + streamingMode: false, + }); + + // i18n helper + const t = (key, options) => I18n.t(key, options); + + // Settings update methods + const handleDebugModeChange = async value => { + loading.debugMode = true; + try { + const response = await fetch('/api/settings/debug-mode', { + headers: { 'Content-Type': 'application/json' }, + method: 'PUT', + }); + + const data = await response.json(); + if (response.ok) { + ElMessage.success(t('settingUpdateSuccess', { setting: t('logLevel'), value: data.value })); + } else { + ElMessage.error(t('settingFailed', { message: data.message || 'Unknown error' })); + state.debugMode = !value; + } + } catch (error) { + ElMessage.error(t('settingFailed', { message: error.message })); + state.debugMode = !value; + } finally { + loading.debugMode = false; + } + }; + + const updateLogMaxCount = async value => { + loading.logMaxCount = true; + try { + const response = await fetch('/api/settings/log-max-count', { + body: JSON.stringify({ count: value }), + headers: { 'Content-Type': 'application/json' }, + method: 'PUT', + }); + + const data = await response.json(); + if (response.ok) { + ElMessage.success(t('settingUpdateSuccess', { setting: t('logMaxCount'), value })); + } else { + ElMessage.error(t('settingFailed', { message: data.message || 'Unknown error' })); + } + } catch (error) { + ElMessage.error(t('settingFailed', { message: error.message })); + } finally { + loading.logMaxCount = false; + } + }; + + const updateStreamingMode = async value => { + loading.streamingMode = true; + try { + // Show confirmation for "real" mode + if (value === 'real') { + try { + await ElMessageBox.confirm(t('streamingModeEnableConfirm'), t('warningTitle'), { + cancelButtonText: t('cancel'), + confirmButtonText: t('ok'), + type: 'warning', + }); + } catch { + // User cancelled, revert to "fake" + state.streamingMode = 'fake'; + loading.streamingMode = false; + return; + } + } + + const response = await fetch('/api/settings/streaming-mode', { + body: JSON.stringify({ mode: value }), + headers: { 'Content-Type': 'application/json' }, + method: 'PUT', + }); + + const data = await response.json(); + if (response.ok) { + ElMessage.success(t('settingUpdateSuccess', { setting: t('streamingMode'), value })); + } else { + ElMessage.error(t('settingFailed', { message: data.message || 'Unknown error' })); + state.streamingMode = value === 'real' ? 'fake' : 'real'; + } + } catch (error) { + ElMessage.error(t('settingFailed', { message: error.message })); + state.streamingMode = value === 'real' ? 'fake' : 'real'; + } finally { + loading.streamingMode = false; + } + }; + + const updateSelectionStrategy = async value => { + loading.selectionStrategy = true; + try { + const response = await fetch('/api/settings/selection-strategy', { + body: JSON.stringify({ strategy: value }), + headers: { 'Content-Type': 'application/json' }, + method: 'PUT', + }); + + const data = await response.json(); + if (response.ok) { + ElMessage.success(t('settingUpdateSuccess', { setting: t('selectionStrategyLabel'), value })); + } else { + ElMessage.error(t('settingFailed', { message: data.message || 'Unknown error' })); + state.selectionStrategy = value === 'round' ? 'random' : 'round'; + } + } catch (error) { + ElMessage.error(t('settingFailed', { message: error.message })); + state.selectionStrategy = value === 'round' ? 'random' : 'round'; + } finally { + loading.selectionStrategy = false; + } + }; + + const updateSessionErrorThreshold = async value => { + loading.sessionErrorThreshold = true; + try { + const response = await fetch('/api/settings/session-error-threshold', { + body: JSON.stringify({ threshold: value }), + headers: { 'Content-Type': 'application/json' }, + method: 'PUT', + }); + + const data = await response.json(); + if (response.ok) { + ElMessage.success(t('settingUpdateSuccess', { setting: t('sessionErrorThreshold'), value })); + } else { + ElMessage.error(t('settingFailed', { message: data.message || 'Unknown error' })); + } + } catch (error) { + ElMessage.error(t('settingFailed', { message: error.message })); + } finally { + loading.sessionErrorThreshold = false; + } + }; + + const updateForceThinking = async value => { + loading.forceThinking = true; + try { + const response = await fetch('/api/settings/force-thinking', { + headers: { 'Content-Type': 'application/json' }, + method: 'PUT', + }); + + const data = await response.json(); + if (response.ok) { + ElMessage.success(t('settingUpdateSuccess', { setting: t('forceThinking'), value: data.value })); + } else { + ElMessage.error(t('settingFailed', { message: data.message || 'Unknown error' })); + state.forceThinking = !value; + } + } catch (error) { + ElMessage.error(t('settingFailed', { message: error.message })); + state.forceThinking = !value; + } finally { + loading.forceThinking = false; + } + }; + + const updateForceWebSearch = async value => { + loading.forceWebSearch = true; + try { + // Show confirmation when enabling + if (value) { + try { + await ElMessageBox.confirm(t('forceWebSearchEnableConfirm'), t('warningTitle'), { + cancelButtonText: t('cancel'), + confirmButtonText: t('ok'), + type: 'warning', + }); + } catch { + // User cancelled, revert + state.forceWebSearch = false; + loading.forceWebSearch = false; + return; + } + } + + const response = await fetch('/api/settings/force-web-search', { + headers: { 'Content-Type': 'application/json' }, + method: 'PUT', + }); + + const data = await response.json(); + if (response.ok) { + ElMessage.success(t('settingUpdateSuccess', { setting: t('forceWebSearch'), value: data.value })); + } else { + ElMessage.error(t('settingFailed', { message: data.message || 'Unknown error' })); + state.forceWebSearch = !value; + } + } catch (error) { + ElMessage.error(t('settingFailed', { message: error.message })); + state.forceWebSearch = !value; + } finally { + loading.forceWebSearch = false; + } + }; + + const updateForceUrlContext = async value => { + loading.forceUrlContext = true; + try { + const response = await fetch('/api/settings/force-url-context', { + headers: { 'Content-Type': 'application/json' }, + method: 'PUT', + }); + + const data = await response.json(); + if (response.ok) { + ElMessage.success(t('settingUpdateSuccess', { setting: t('forceUrlContext'), value: data.value })); + } else { + ElMessage.error(t('settingFailed', { message: data.message || 'Unknown error' })); + state.forceUrlContext = !value; + } + } catch (error) { + ElMessage.error(t('settingFailed', { message: error.message })); + state.forceUrlContext = !value; + } finally { + loading.forceUrlContext = false; + } + }; + + return { + handleDebugModeChange, + loading, + state, + updateForceThinking, + updateForceUrlContext, + updateForceWebSearch, + updateLogMaxCount, + updateSelectionStrategy, + updateSessionErrorThreshold, + updateStreamingMode, + }; +} diff --git a/ui/app/composables/useStatusPolling.js b/ui/app/composables/useStatusPolling.js new file mode 100644 index 0000000..c76f534 --- /dev/null +++ b/ui/app/composables/useStatusPolling.js @@ -0,0 +1,117 @@ +/** + * File: ui/app/composables/useStatusPolling.js + * Description: Composable for managing status polling and data synchronization + * + * Author: iBUHUB + */ + +import { computed, nextTick, reactive, ref } from 'vue'; + +export function useStatusPolling(settingsState, versionState, sessionsRef, getActiveTab) { + const updateTimer = ref(null); + + const state = reactive({ + browserWsPath: '/ws', + logCount: 0, + logs: '', + logScrollTop: 0, + serviceConnected: false, + sharePageUrl: '', + }); + + // Computed for WebSocket endpoint text + const browserWsEndpointText = computed(() => { + if (typeof window === 'undefined') { + return state.browserWsPath || '/ws'; + } + + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + return `${protocol}//${window.location.host}${state.browserWsPath || '/ws'}`; + }); + + // Apply status payload to all states + const applyStatusPayload = payload => { + const status = payload?.status || {}; + + // Update settings state + settingsState.sessionErrorThreshold = Number.isFinite(Number(status.sessionErrorThreshold)) + ? Number(status.sessionErrorThreshold) + : 3; + settingsState.debugMode = Boolean(status.debugMode); + settingsState.forceThinking = Boolean(status.forceThinking); + settingsState.forceUrlContext = Boolean(status.forceUrlContext); + settingsState.forceWebSearch = Boolean(status.forceWebSearch); + settingsState.logMaxCount = Number(status.logMaxCount || 100); + settingsState.selectionStrategy = status.selectionStrategy || 'round'; + settingsState.streamingMode = status.streamingMode || 'fake'; + + // Update local state + state.logCount = Number(payload?.logCount || 0); + state.logs = payload?.logs || ''; + state.serviceConnected = Boolean(status.serviceConnected); + state.browserWsPath = status.browserWsPath || '/ws'; + state.sharePageUrl = status.sharePageUrl || ''; + + // Update sessions + sessionsRef.value = Array.isArray(status.browserSessions) ? status.browserSessions : []; + }; + + // Fetch status data + const fetchStatus = async () => { + try { + const logContainer = getActiveTab() === 'logs' ? document.getElementById('log-container') : null; + if (logContainer) { + state.logScrollTop = logContainer.scrollTop; + } + + const response = await fetch('/api/status'); + if (response.redirected) { + window.location.href = response.url; + return; + } + if (response.status === 401) { + window.location.href = '/login'; + return; + } + if (!response.ok) throw new Error(`status ${response.status}`); + + const data = await response.json(); + applyStatusPayload(data); + + if (getActiveTab() === 'logs') { + nextTick(() => { + const updatedLogContainer = document.getElementById('log-container'); + if (updatedLogContainer) { + updatedLogContainer.scrollTop = state.logScrollTop || 0; + } + }); + } + } catch (error) { + state.serviceConnected = false; + } + }; + + // Start polling + const startPolling = (interval = 5000) => { + fetchStatus(); + updateTimer.value = setInterval(fetchStatus, interval); + }; + + // Stop polling + const stopPolling = () => { + if (updateTimer.value) { + clearInterval(updateTimer.value); + updateTimer.value = null; + } + }; + + return { + applyStatusPayload, + browserWsEndpointText, + fetchStatus, + startPolling, + state, + stopPolling, + updateTimer, + }; +} diff --git a/ui/app/composables/useVersionInfo.js b/ui/app/composables/useVersionInfo.js new file mode 100644 index 0000000..90b9076 --- /dev/null +++ b/ui/app/composables/useVersionInfo.js @@ -0,0 +1,71 @@ +/** + * File: ui/app/composables/useVersionInfo.js + * Description: Composable for managing version information and update checking + * + * Author: iBUHUB + */ + +import { computed, reactive } from 'vue'; + +export function useVersionInfo() { + const state = reactive({ + currentVersion: '', + hasUpdate: false, + latestVersion: '', + releaseUrl: '', + }); + + // Computed + const appVersion = computed(() => { + // eslint-disable-next-line no-undef + const version = state.currentVersion || (typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'dev'); + if (/^\d/.test(version)) { + return `v${version}`; + } + if (version.startsWith('preview')) { + return version.charAt(0).toUpperCase() + version.slice(1); + } + return version; + }); + + const latestVersionFormatted = computed(() => { + /* eslint-disable no-undef */ + const version = + state.latestVersion || + state.currentVersion || + (typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'dev'); + /* eslint-enable no-undef */ + if (/^\d/.test(version)) { + return `v${version}`; + } + if (version.startsWith('preview')) { + return version.charAt(0).toUpperCase() + version.slice(1); + } + return version; + }); + + // Fetch version info + const fetchVersionInfo = async () => { + try { + const response = await fetch('/api/version/check'); + if (!response.ok) { + return; + } + + const data = await response.json(); + state.currentVersion = data.current || ''; + state.hasUpdate = Boolean(data.hasUpdate); + state.latestVersion = data.latest || ''; + state.releaseUrl = data.releaseUrl || ''; + } catch { + state.hasUpdate = false; + } + }; + + return { + appVersion, + fetchVersionInfo, + latestVersionFormatted, + state, + }; +} diff --git a/ui/app/index.html b/ui/app/index.html index a7eeaf4..19617f7 100644 --- a/ui/app/index.html +++ b/ui/app/index.html @@ -9,8 +9,20 @@ - Gemini Canvas Proxy + CanvasToAPI - Technical Dashboard + + + + + + diff --git a/ui/app/pages/StatusPage.vue b/ui/app/pages/StatusPage.vue index 2910764..ef83f68 100644 --- a/ui/app/pages/StatusPage.vue +++ b/ui/app/pages/StatusPage.vue @@ -1,1530 +1,478 @@ diff --git a/ui/app/router/index.js b/ui/app/router/index.js index 6c444ee..4313931 100644 --- a/ui/app/router/index.js +++ b/ui/app/router/index.js @@ -1,6 +1,6 @@ /** * File: ui/app/router/index.js - * Description: Vue Router configuration for the application, defining all routes and navigation behavior + * Description: Vue Router configuration for the application * * Author: iBUHUB */ @@ -13,7 +13,7 @@ import NotFound from '../pages/NotFound.vue'; const routes = [ { component: StatusPage, - name: 'status', + name: 'dashboard', path: '/', }, { diff --git a/ui/app/styles/global.less b/ui/app/styles/global.less index 412c4d8..75947ef 100644 --- a/ui/app/styles/global.less +++ b/ui/app/styles/global.less @@ -26,6 +26,14 @@ body, overflow: visible; } +// Body styling +body { + font-size: @font-size-base; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + line-height: 1.5; +} + // Vue cloak directive [v-cloak] { display: none; @@ -36,9 +44,109 @@ body, display: contents; } -// Smooth scrolling、 +// Smooth scrolling @media (prefers-reduced-motion: no-preference) { html { scroll-behavior: smooth; } } + +// Material Symbols configuration +.material-symbols-outlined { + direction: ltr; + display: inline-block; + -webkit-font-smoothing: antialiased; + font-style: normal; + font-variation-settings: + "FILL" 0, + "wght" 400, + "GRAD" 0, + "opsz" 24; + font-weight: normal; + letter-spacing: normal; + line-height: 1; + text-transform: none; + white-space: nowrap; + word-wrap: normal; +} + +// Focus visible for accessibility +:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} + +// Selection styling +::selection { + background-color: rgba(0, 74, 198, 0.2); +} + +// Scrollbar styling (optional) +::-webkit-scrollbar { + height: 8px; + width: 8px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: @outline-variant; + border-radius: @border-radius-full; + + &:hover { + background: @outline; + } +} + +// ========== Element Plus Theme Overrides ========== +// Override Element Plus CSS variables to match Material Design 3 theme +:root { + // Text colors for inputs and selects + --el-text-color-primary: var(--color-on-surface); + --el-text-color-regular: var(--color-on-surface-variant); + --el-input-text-color: var(--color-on-surface); + --el-select-input-color: var(--color-on-surface); + + // Input placeholder color + --el-input-placeholder-color: var(--color-on-surface-variant); + + // Border colors to match Material Design 3 + --el-border-color: var(--color-outline-variant); + --el-border-color-light: var(--color-outline-variant); + --el-border-color-hover: var(--color-outline); + + // Background colors + --el-fill-color-blank: var(--color-surface-container-lowest); + --el-bg-color-overlay: var(--color-surface-container-lowest); +} + +// Dark mode scrollbar and Element Plus overrides +[data-theme="dark"] { + ::-webkit-scrollbar-thumb { + background: var(--color-outline-variant); + + &:hover { + background: var(--color-outline); + } + } + + // Text colors for inputs and selects in dark mode + --el-text-color-primary: var(--color-on-surface); + --el-text-color-regular: var(--color-on-surface-variant); + --el-input-text-color: var(--color-on-surface); + --el-select-input-color: var(--color-on-surface); + + // Input placeholder color in dark mode + --el-input-placeholder-color: var(--color-on-surface-variant); + + // Border colors in dark mode + --el-border-color: var(--color-outline-variant); + --el-border-color-light: var(--color-outline-variant); + --el-border-color-hover: var(--color-outline); + + // Background colors in dark mode + --el-fill-color-blank: var(--color-surface-container-lowest); + --el-bg-color-overlay: var(--color-surface-container-lowest); +} diff --git a/ui/app/styles/variables.less b/ui/app/styles/variables.less index cf9e5fd..270da33 100644 --- a/ui/app/styles/variables.less +++ b/ui/app/styles/variables.less @@ -5,57 +5,215 @@ * Author: iBUHUB */ -// ========== CSS Variables System ========== +// ========== CSS Variables System (Material Design 3 Inspired) ========== :root { - // Light theme (Default) - --color-primary: #007bff; - --color-primary-rgb: 0, 123, 255; - --color-primary-hover: #0069d9; - --color-primary-active: #0056b3; - --color-success: #2ecc71; - --color-success-rgb: 46, 204, 113; - --color-warning: #f39c12; - --color-warning-rgb: 243, 156, 18; - --color-error: #e74c3c; - --color-error-rgb: 231, 76, 60; - --bg-body: #f0f2f5; - --bg-card: #fff; - --bg-list-item-hover: #e1e6eb; // darken #f0f2f5 by ~3% - --text-primary: #333; - --text-secondary: #666; - --border-color: #ccc; - --border-light: #eee; - - // Affix - --affix-bg: rgba(255, 255, 255, 0.95); + // ===== Primary Palette ===== + --color-primary: #004ac6; + --color-primary-rgb: 0, 74, 198; + --color-primary-hover: #003ea8; + --color-primary-active: #003090; + --color-primary-container: #2563eb; + --color-primary-fixed: #dbe1ff; + --color-primary-fixed-dim: #b4c5ff; + --color-on-primary: #fff; + --color-on-primary-container: #eeefff; + --color-on-primary-fixed: #00174b; + --color-on-primary-fixed-variant: #003ea8; + --color-inverse-primary: #b4c5ff; + + // ===== Secondary Palette ===== + --color-secondary: #515f74; + --color-secondary-rgb: 81, 95, 116; + --color-secondary-container: #d5e3fc; + --color-secondary-fixed: #d5e3fc; + --color-secondary-fixed-dim: #b9c7df; + --color-on-secondary: #fff; + --color-on-secondary-container: #57657a; + --color-on-secondary-fixed: #0d1c2e; + --color-on-secondary-fixed-variant: #3a485b; + + // ===== Tertiary Palette ===== + --color-tertiary: #943700; + --color-tertiary-rgb: 148, 55, 0; + --color-tertiary-container: #bc4800; + --color-tertiary-fixed: #ffdbcd; + --color-tertiary-fixed-dim: #ffb596; + --color-on-tertiary: #fff; + --color-on-tertiary-container: #ffede6; + --color-on-tertiary-fixed: #360f00; + --color-on-tertiary-fixed-variant: #7d2d00; + + // ===== Error Palette ===== + --color-error: #ba1a1a; + --color-error-rgb: 186, 26, 26; + --color-error-container: #ffdad6; + --color-on-error: #fff; + --color-on-error-container: #93000a; + + // ===== Success Palette (Emerald) ===== + --color-success: #059669; + --color-success-rgb: 5, 150, 105; + --color-success-container: #d1fae5; + --color-on-success: #fff; + --color-on-success-container: #064e3b; + + // ===== Warning Palette (Amber) ===== + --color-warning: #d97706; + --color-warning-rgb: 217, 119, 6; + --color-warning-container: #fef3c7; + --color-on-warning: #fff; + --color-on-warning-container: #78350f; + + // ===== Surface & Background ===== + --color-background: #faf8ff; + --color-surface: #faf8ff; + --color-surface-dim: #d9d9e5; + --color-surface-bright: #faf8ff; + --color-surface-container-lowest: #fff; + --color-surface-container-low: #f3f3fe; + --color-surface-container: #ededf9; + --color-surface-container-high: #e7e7f3; + --color-surface-container-highest: #e1e2ed; + --color-surface-variant: #e1e2ed; + --color-surface-tint: #0053db; + + // ===== Text Colors ===== + --color-on-background: #191b23; + --color-on-surface: #191b23; + --color-on-surface-variant: #434655; + + // ===== Outline ===== + --color-outline: #737686; + --color-outline-variant: #c3c6d7; + + // ===== Inverse Colors ===== + --color-inverse-surface: #2e3039; + --color-inverse-on-surface: #f0f0fb; + + // ===== Border & Shadow ===== + --border-color: #c3c6d7; + --border-light: #e1e2ed; + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.07); + --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1); + --shadow-focus: 0 0 0 3px rgba(0, 74, 198, 0.25); + + // ===== Affix ===== + --affix-bg: rgba(255, 255, 255, 0.7); --affix-border: rgba(0, 0, 0, 0.1); --affix-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); - // Text on primary - --text-on-primary: #fff; + // ===== Legacy Compatibility ===== + --bg-body: #faf8ff; + --bg-card: #fff; + --bg-list-item-hover: #e7e7f3; + --text-primary: #191b23; + --text-secondary: #434655; } +// ========== Dark Theme ========== [data-theme="dark"] { - // Dark theme overrides - --bg-body: #1a1a1a; - --bg-card: #242424; - --bg-list-item-hover: #2a2a2a; // slightly lighter than #1a1a1a - --text-primary: #f0f0f0; - --text-secondary: #aaa; - --border-color: #444; - --border-light: #333; - - // Adjust brand colors for dark mode if needed - --color-primary: #339aff; - --color-primary-rgb: 51, 154, 255; - - // Ensure sufficient contrast on potentially lighter primary in dark mode - --text-on-primary: #fff; // Can be changed to #000 if primary becomes too light - - // Affix - --affix-bg: rgba(60, 60, 60, 0.95); - --affix-border: rgba(255, 255, 255, 0.15); + // ===== Primary Palette ===== + --color-primary: #b4c5ff; + --color-primary-rgb: 180, 197, 255; + --color-primary-hover: #94a8ff; + --color-primary-active: #7488ff; + --color-primary-container: #004ac6; + --color-primary-fixed: #dbe1ff; + --color-primary-fixed-dim: #b4c5ff; + --color-on-primary: #003090; + --color-on-primary-container: #dbe1ff; + --color-on-primary-fixed: #00174b; + --color-on-primary-fixed-variant: #003ea8; + --color-inverse-primary: #004ac6; + + // ===== Secondary Palette ===== + --color-secondary: #b9c7df; + --color-secondary-rgb: 185, 199, 223; + --color-secondary-container: #3a485b; + --color-secondary-fixed: #d5e3fc; + --color-secondary-fixed-dim: #b9c7df; + --color-on-secondary: #1a2a3e; + --color-on-secondary-container: #d5e3fc; + --color-on-secondary-fixed: #0d1c2e; + --color-on-secondary-fixed-variant: #3a485b; + + // ===== Tertiary Palette ===== + --color-tertiary: #ffb596; + --color-tertiary-rgb: 255, 181, 150; + --color-tertiary-container: #5a2000; + --color-tertiary-fixed: #ffdbcd; + --color-tertiary-fixed-dim: #ffb596; + --color-on-tertiary: #5a2000; + --color-on-tertiary-container: #ffdbcd; + --color-on-tertiary-fixed: #360f00; + --color-on-tertiary-fixed-variant: #7d2d00; + + // ===== Error Palette ===== + --color-error: #ffb4ab; + --color-error-rgb: 255, 180, 171; + --color-error-container: #93000a; + --color-on-error: #690005; + --color-on-error-container: #ffdad6; + + // ===== Success Palette ===== + --color-success: #34d399; + --color-success-rgb: 52, 211, 153; + --color-success-container: #064e3b; + --color-on-success: #022c22; + --color-on-success-container: #a7f3d0; + + // ===== Warning Palette ===== + --color-warning: #fbbf24; + --color-warning-rgb: 251, 191, 36; + --color-warning-container: #78350f; + --color-on-warning: #451a03; + --color-on-warning-container: #fef3c7; + + // ===== Surface & Background ===== + --color-background: #121318; + --color-surface: #121318; + --color-surface-dim: #0c0c10; + --color-surface-bright: #38393f; + --color-surface-container-lowest: #070709; + --color-surface-container-low: #1a1b21; + --color-surface-container: #1e1f25; + --color-surface-container-high: #28292f; + --color-surface-container-highest: #33343a; + --color-surface-variant: #43454e; + --color-surface-tint: #b4c5ff; + + // ===== Text Colors ===== + --color-on-background: #e3e2e8; + --color-on-surface: #e3e2e8; + --color-on-surface-variant: #c3c6d7; + + // ===== Outline ===== + --color-outline: #8d90a1; + --color-outline-variant: #43454e; + + // ===== Inverse Colors ===== + --color-inverse-surface: #e3e2e8; + --color-inverse-on-surface: #1a1b21; + + // ===== Border & Shadow ===== + --border-color: #43454e; + --border-light: #33343a; + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4); + --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.5); + + // ===== Affix ===== + --affix-bg: rgba(30, 31, 37, 0.95); + --affix-border: rgba(255, 255, 255, 0.1); --affix-shadow: 0 4px 16px rgba(0, 0, 0, 0.6); + + // ===== Legacy Compatibility ===== + --bg-body: #121318; + --bg-card: #1e1f25; + --bg-list-item-hover: #28292f; + --text-primary: #e3e2e8; + --text-secondary: #c3c6d7; } // ========== Color Variables (Mapped to CSS Vars) ========== @@ -63,64 +221,124 @@ @primary-color: var(--color-primary); @primary-hover-color: var(--color-primary-hover); @primary-active-color: var(--color-primary-active); -@text-on-primary: var(--text-on-primary); +@primary-container: var(--color-primary-container); +@on-primary: var(--color-on-primary); +@on-primary-container: var(--color-on-primary-container); +@inverse-primary: var(--color-inverse-primary); +@text-on-primary: var(--color-on-primary); + +// Secondary colors +@secondary-color: var(--color-secondary); +@secondary-container: var(--color-secondary-container); +@on-secondary: var(--color-on-secondary); +@on-secondary-container: var(--color-on-secondary-container); + +// Tertiary colors +@tertiary-color: var(--color-tertiary); +@tertiary-container: var(--color-tertiary-container); +@on-tertiary: var(--color-on-tertiary); +@on-tertiary-container: var(--color-on-tertiary-container); // Status colors @success-color: var(--color-success); +@success-container: var(--color-success-container); +@on-success: var(--color-on-success); @warning-color: var(--color-warning); +@warning-container: var(--color-warning-container); +@on-warning: var(--color-on-warning); @error-color: var(--color-error); +@error-container: var(--color-error-container); +@on-error: var(--color-on-error); -// Neutral colors +// Surface colors @background-light: var(--bg-body); @background-white: var(--bg-card); +@surface: var(--color-surface); +@surface-container-lowest: var(--color-surface-container-lowest); +@surface-container: var(--color-surface-container); +@surface-container-low: var(--color-surface-container-low); +@surface-container-high: var(--color-surface-container-high); +@surface-container-highest: var(--color-surface-container-highest); +@surface-variant: var(--color-surface-variant); + +// Text colors @text-primary: var(--text-primary); @text-secondary: var(--text-secondary); +@on-surface: var(--color-on-surface); +@on-surface-variant: var(--color-on-surface-variant); + +// Outline +@outline: var(--color-outline); +@outline-variant: var(--color-outline-variant); + +// Inverse +@inverse-surface: var(--color-inverse-surface); +@inverse-on-surface: var(--color-inverse-on-surface); + +// Border & Shadow @border-color: var(--border-color); @border-light: var(--border-light); - -// Deprecated or mapped to new system -@dark-background: #2d2d2d; // Keeping for legacy if used elsewhere, but preferable to use vars -@dark-text: #f0f0f0; +@shadow-sm: var(--shadow-sm); +@shadow-md: var(--shadow-md); +@shadow-lg: var(--shadow-lg); +@shadow-focus: var(--shadow-focus); // ========== Typography ========== -@font-family-sans: sans-serif; +@font-family-sans: "Inter", sans-serif; +@font-family-headline: "Manrope", sans-serif; @font-family-mono: "SF Mono", Consolas, Menlo, monospace; -@font-size-base: 1em; -@font-size-small: 0.9em; -@font-size-large: 1.1em; +@font-size-base: 14px; +@font-size-xs: 10px; +@font-size-sm: 12px; +@font-size-md: 14px; +@font-size-lg: 16px; +@font-size-xl: 18px; +@font-size-2xl: 20px; +@font-size-3xl: 24px; // ========== Spacing ========== -@spacing-xs: 8px; -@spacing-sm: 10px; -@spacing-md: 15px; -@spacing-lg: 20px; -@spacing-xl: 40px; +@spacing-xs: 4px; +@spacing-sm: 8px; +@spacing-md: 12px; +@spacing-lg: 16px; +@spacing-xl: 24px; +@spacing-2xl: 32px; +@spacing-3xl: 48px; // ========== Border Radius ========== -@border-radius-sm: 5px; +@border-radius-sm: 4px; @border-radius-md: 8px; -@border-radius-lg: 10px; -@border-radius-xl: 12px; +@border-radius-lg: 12px; +@border-radius-xl: 16px; +@border-radius-2xl: 20px; +@border-radius-full: 24px; @border-radius-circle: 50%; // ========== Shadows ========== -@shadow-light: 0 4px 6px rgba(0, 0, 0, 0.1); -@shadow-medium: 0 4px 8px rgba(0, 0, 0, 0.1); -@shadow-focus: 0 0 0 3px rgba(0, 123, 255, 0.25); +@shadow-light: @shadow-md; +@shadow-medium: @shadow-lg; // ========== Transitions ========== -@transition-fast: 0.2s ease; -@transition-normal: 0.3s ease; +@transition-fast: 0.15s ease; +@transition-normal: 0.2s ease; +@transition-slow: 0.3s ease; // ========== Dimensions ========== -@button-min-height: 44px; -@container-max-width: 800px; +@sidebar-width: 256px; +@topbar-height: 64px; +@button-min-height: 36px; +@container-max-width: 1200px; @log-container-max-height: 400px; // ========== Z-Index ========== @z-index-base: 1; @z-index-dropdown: 10; +@z-index-sticky: 40; +@z-index-fixed: 50; +@z-index-modal-backdrop: 80; @z-index-modal: 100; +@z-index-popover: 200; +@z-index-tooltip: 300; @z-index-affix: 999; // ========== Affix Floating Buttons ========== @@ -132,3 +350,12 @@ @affix-button-border: var(--affix-border); @affix-button-shadow: var(--affix-shadow); @affix-button-hover-shadow: 0 6px 20px rgba(0, 0, 0, 0.3); + +// ========== Material Symbols ========== +.material-symbols-outlined { + font-variation-settings: + "FILL" 0, + "wght" 400, + "GRAD" 0, + "opsz" 24; +} diff --git a/ui/locales/en.json b/ui/locales/en.json index ca66251..81a01cc 100644 --- a/ui/locales/en.json +++ b/ui/locales/en.json @@ -17,6 +17,7 @@ "accountSwitchSuccessNext": "Switch successful! Switched to account #{newIndex}.", "actionsPanel": "Settings", "activeContexts": "Concurrent Logins", + "activeSessions": "Active Sessions", "activeSessionsLabel": "Active Sessions", "alreadyCurrentAccount": "This is already the current active account.", "apiKey": "API Key", @@ -60,6 +61,7 @@ "authVncContainerMissing": "VNC container is missing.", "authVncContainerMissingDetail": "Please refresh the page and try again.", "authVncNotConnected": "VNC session is not connected yet.", + "avgLatency": "Avg Latency", "batchDelete": "Batch Delete", "batchDeleteFailed": "Batch delete failed: {error}", "batchDeletePartial": "Partial success: deleted {successCount}, failed {failedCount}", @@ -77,12 +79,14 @@ "btnSwitchAccount": "Switch Account", "cancel": "Cancel", "clearViewLabel": "Clear View", + "clickToCopy": "Click to copy", "collapse": "Collapse", "confirmBatchDelete": "Are you sure you want to delete {count} selected accounts?", "confirmDelete": "Are you sure you want to delete account", "confirmSwitch": "Are you sure you want to switch to account", "connected": "Connected", "connectedAtLabel": "Connected At", + "connectedViaWs": "{count} connected via WebSocket", "connecting": "Connecting", "consecutiveFailures": "Consecutive Failures", "copy": "Copy", @@ -92,6 +96,7 @@ "currentVersion": "Current Version", "custom": "Custom", "dark": "Dark", + "dashboardOverview": "Dashboard Overview", "debug": "Debug", "dedupedAvailable": "Available Accounts (Deduped)", "default": "Default", @@ -114,6 +119,8 @@ "errorsLabel": "Consecutive Errors", "errorThresholdLabel": "Error Threshold", "expand": "Expand", + "exportReport": "Export Report", + "exportReportStarted": "Export started...", "fake": "Fake", "false": "Disabled", "fastSwitch": "Fast Switch Account", @@ -130,6 +137,7 @@ "forceWebSearch": "Force Web Search", "forceWebSearchEnableConfirm": "Based on current testing, enabling web search may cause request errors. Continue enabling it?", "formatErrors": "Format Errors (Ignored)", + "globalRequestVolume": "Global request volume (per minute)", "immediateSwitchCodes": "Immediate Switch (Codes)", "invalidJson": "Invalid JSON", "jsonFormatError": "N/A (JSON format error)", @@ -138,6 +146,7 @@ "latestEntries": "Latest", "latestVersion": "Latest Version", "light": "Light", + "loadDistribution": "Load Distribution", "loading": "Loading...", "log": "Logs", "loginBtn": "Login", @@ -155,43 +164,57 @@ "logoutFailed": "Logout failed.", "logoutSuccess": "Logout successful.", "maxRetries": "Error Retries", + "method": "Method", "networkError": "Network connection failed. Please check your network settings.", + "newDeployment": "New Deployment", "newSessionLinkLabel": "Click here to create a new session", "newVersionAvailable": "New version available", "noAccountSelected": "No account selected.", "noActiveAccount": "No active account", "noBrowserSessions": "No browser sessions are currently connected.", + "noLogsAvailable": "No logs available to download", "normal": "Normal", "noSupportedFiles": "No supported files selected (only .json or .zip).", "notImplemented": "Not implemented yet", "ok": "OK", + "online": "Online", "onlineLabel": "Online", "onlyAppliesWhenFakeStreaming": "only applies to fake/non-streaming", "onlyAppliesWhenStreamingEnabled": "only applies when streaming is enabled", "operationInProgress": "Operation in progress...", "passwordPlaceholder": "Password", + "path": "Path", "proxySettings": "Proxy", "proxySettingsStatus": "Proxy Status", "real": "Real", + "realTimeInfrastructure": "Real-time infrastructure health and API throughput.", "realtimeLogs": "Real-time Logs", + "realtimeTraffic": "Real-time Traffic", + "recentApiRequests": "Recent API Requests", + "refreshed": "Refreshed", "refreshLabel": "Refresh", "repo": "Repo", + "requestsPerAccount": "Requests per account unit", "running": "Running", "selectAll": "Select All", "selectedCount": "{count} selected", "selectionStrategyLabel": "Selection Strategy", "selectionStrategyRandom": "Random", "selectionStrategyRound": "Round Robin", + "serverStatus": "Server Status", "serviceConfig": "Service Configuration", "serviceConnection": "Service Connection", "serviceStatus": "Service Status", + "sessionErrorThreshold": "Session Error Threshold", "sessionPoolHeading": "Session Pool", "sessionResetActionHint": "Click to mark this session healthy again and clear errors.", "sessionResetConfirm": "Mark session {session} healthy again and clear its error state?", "sessionResetSuccess": "Session {session} health reset successfully.", "settingFailed": "Setting failed: {message}", "settings": "Settings", + "settingsConfiguration": "Settings Configuration", "settingUpdateSuccess": "{setting} updated to: {value}", + "status": "Status", "statusFetchFailed": "Failed to fetch status", "statusHeading": "Console Panel", "statusTitle": "Gemini Canvas Proxy - Service Status", @@ -200,9 +223,12 @@ "switchingAccountNotice": "Switching account, please do not refresh the page.", "switchLanguage": "Switch Language", "systemBusySwitchingOrRecoveringAccounts": "The system is switching or recovering accounts. Please try again later.", + "systemSettingsLogs": "System settings and runtime logs", "tagCurrent": "Current", "tagExpired": "Expired", "theme": "Theme", + "timestamp": "Timestamp", + "todayRequests": "Today Requests", "toggleLabel": "Toggle", "total": "Total", "totalScanned": "Total Scanned Accounts", @@ -211,9 +237,11 @@ "unknownError": "Unknown error", "unnamedAccount": "N/A (Unnamed)", "uploadFile": "Upload Auth", + "uptime24h": "Uptime last 24h", "usageCount": "Usage Count", "usernamePlaceholder": "Username", "versionInfo": "Version Info", + "viewDetailedLogs": "View Detailed Balancer Logs", "viewRelease": "View Release", "warningDeleteCurrentAccount": "You are about to delete the currently active account. After deletion, there will be no active account and you will need to manually switch to another account. Continue?", "warningTitle": "Warning", diff --git a/ui/locales/zh.json b/ui/locales/zh.json index d86f843..7843df6 100644 --- a/ui/locales/zh.json +++ b/ui/locales/zh.json @@ -17,6 +17,7 @@ "accountSwitchSuccessNext": "切换成功!已切换到账号 {newIndex}。", "actionsPanel": "设置", "activeContexts": "同时登录的账号", + "activeSessions": "活跃会话", "activeSessionsLabel": "活跃会话", "alreadyCurrentAccount": "当前已是该账号,无需切换。", "apiKey": "API 密钥", @@ -60,6 +61,7 @@ "authVncContainerMissing": "VNC 容器丢失。", "authVncContainerMissingDetail": "请刷新页面并重试。", "authVncNotConnected": "VNC 会话尚未连接。", + "avgLatency": "平均延迟", "batchDelete": "批量删除", "batchDeleteFailed": "批量删除失败:{error}", "batchDeletePartial": "部分删除成功:已删除 {successCount} 个,失败 {failedCount} 个", @@ -77,12 +79,14 @@ "btnSwitchAccount": "切换账号", "cancel": "取消", "clearViewLabel": "清空视图", + "clickToCopy": "点击复制", "collapse": "收起", "confirmBatchDelete": "确定要删除选中的 {count} 个账号吗?", "confirmDelete": "确定要删除账号", "confirmSwitch": "确定要切换到账号", "connected": "已连接", "connectedAtLabel": "连接时间", + "connectedViaWs": "{count} 个通过 WebSocket 连接", "connecting": "连接中", "consecutiveFailures": "连续失败次数", "copy": "复制", @@ -92,6 +96,7 @@ "currentVersion": "当前版本", "custom": "自定义", "dark": "暗色", + "dashboardOverview": "仪表盘概览", "debug": "调试", "dedupedAvailable": "去重后可用账号", "default": "默认", @@ -114,6 +119,8 @@ "errorsLabel": "连续错误", "errorThresholdLabel": "错误阈值", "expand": "展开", + "exportReport": "导出报告", + "exportReportStarted": "导出已开始...", "fake": "假", "false": "已禁用", "fastSwitch": "快速切换账号", @@ -130,6 +137,7 @@ "forceWebSearch": "强制联网", "forceWebSearchEnableConfirm": "根据目前的测试,启用联网搜索可能导致请求报错。是否继续开启?", "formatErrors": "格式错误账号(已忽略)", + "globalRequestVolume": "全局请求量(每分钟)", "immediateSwitchCodes": "立即切换(状态码)", "invalidJson": "无效的 JSON 格式", "jsonFormatError": "N/A (JSON 格式错误)", @@ -138,6 +146,7 @@ "latestEntries": "最新", "latestVersion": "最新版本", "light": "亮色", + "loadDistribution": "负载分布", "loading": "加载中...", "log": "日志", "loginBtn": "登录", @@ -155,43 +164,57 @@ "logoutFailed": "登出失败。", "logoutSuccess": "登出成功。", "maxRetries": "错误重试次数", + "method": "方法", "networkError": "网络连接失败,请检查您的网络设置。", + "newDeployment": "新建部署", "newSessionLinkLabel": "新建会话点击此处", "newVersionAvailable": "有新版本可用", "noAccountSelected": "未选择任何账号。", "noActiveAccount": "无活动账号", "noBrowserSessions": "当前没有已连接的浏览器会话。", + "noLogsAvailable": "没有可下载的日志", "normal": "标准", "noSupportedFiles": "未选择支持的文件(仅支持 .json 或 .zip)。", "notImplemented": "尚未实现", "ok": "确定", + "online": "在线", "onlineLabel": "在线", "onlyAppliesWhenFakeStreaming": "仅对假流式和非流式生效", "onlyAppliesWhenStreamingEnabled": "仅在启用流式传输时适用", "operationInProgress": "操作进行中...", "passwordPlaceholder": "密码", + "path": "路径", "proxySettings": "代理", "proxySettingsStatus": "代理状态", "real": "真", + "realTimeInfrastructure": "实时基础设施健康状态和 API 吞吐量", "realtimeLogs": "实时日志", + "realtimeTraffic": "实时流量", + "recentApiRequests": "最近 API 请求", + "refreshed": "已刷新", "refreshLabel": "刷新", "repo": "仓库", + "requestsPerAccount": "每个账号的请求数", "running": "运行中", "selectAll": "全选", "selectedCount": "已选择 {count} 个", "selectionStrategyLabel": "选择策略", "selectionStrategyRandom": "随机", "selectionStrategyRound": "轮询", + "serverStatus": "服务器状态", "serviceConfig": "服务配置", "serviceConnection": "服务连接", "serviceStatus": "服务状态", + "sessionErrorThreshold": "会话错误阈值", "sessionPoolHeading": "会话池", "sessionResetActionHint": "点击后将这个 session 恢复为正常并清空错误状态。", "sessionResetConfirm": "是否将 session {session} 恢复为正常并清空错误状态?", "sessionResetSuccess": "会话 {session} 状态已恢复。", "settingFailed": "设置失败:{message}", "settings": "设置", + "settingsConfiguration": "设置配置", "settingUpdateSuccess": "{setting} 已更新为:{value}", + "status": "状态", "statusFetchFailed": "获取状态失败", "statusHeading": "控制台面板", "statusTitle": "Gemini Canvas 代理 - 服务状态", @@ -200,9 +223,12 @@ "switchingAccountNotice": "正在切换账号,请勿刷新页面。", "switchLanguage": "切换语言", "systemBusySwitchingOrRecoveringAccounts": "系统正在切换或恢复账号,请稍后重试。", + "systemSettingsLogs": "系统设置与运行日志", "tagCurrent": "当前", "tagExpired": "过期", "theme": "主题", + "timestamp": "时间戳", + "todayRequests": "今日请求", "toggleLabel": "切换", "total": "总计", "totalScanned": "已扫描账号总数", @@ -211,9 +237,11 @@ "unknownError": "未知错误", "unnamedAccount": "N/A (未命名)", "uploadFile": "上传 Auth", + "uptime24h": "过去 24 小时在线率", "usageCount": "使用次数", "usernamePlaceholder": "用户名", "versionInfo": "版本信息", + "viewDetailedLogs": "查看详细负载均衡日志", "viewRelease": "查看版本发布", "warningDeleteCurrentAccount": "您即将删除当前正在使用的账号。删除后将进入无活动账号状态,需要手动切换到其他账号。是否继续?", "warningTitle": "警告",