From 112f86966d49882361f08cdf2309aa4751e408b2 Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Sat, 6 Dec 2025 00:15:44 +0800 Subject: [PATCH] feat: add version check functionality with UI integration, status updates, and internationalization support --- app.js | 167 +++++++++++++++++++++++++++++++++++++++ i18n.js | 26 ++++++ index.html | 33 +++++++- src/core/connection.js | 171 ++++++++++++++++++++++++++++++++++++++++ src/modules/language.js | 1 + src/modules/login.js | 2 + styles.css | 124 ++++++++++++++++++++++++++++- 7 files changed, 522 insertions(+), 2 deletions(-) diff --git a/app.js b/app.js index 215d321..04d8713 100644 --- a/app.js +++ b/app.js @@ -56,6 +56,9 @@ class CLIProxyManager { this.uiVersion = null; this.serverVersion = null; this.serverBuildDate = null; + this.latestVersion = null; + this.versionCheckStatus = 'muted'; + this.versionCheckMessage = i18n.t('system_info.version_check_idle'); // 配置缓存 - 改为分段缓存(交由 ConfigService 管理) this.cacheExpiry = CACHE_EXPIRY_MS; @@ -113,6 +116,20 @@ class CLIProxyManager { result: null }; + // 顶栏标题动画状态 + this.brandCollapseTimer = null; + this.brandCollapseDelayMs = 5000; + this.brandIsCollapsed = false; + this.brandAnimationReady = false; + this.brandElements = { + toggle: null, + wrapper: null, + fullText: null, + shortText: null + }; + this.brandResizeHandler = null; + this.brandToggleHandler = null; + // 主题管理 this.currentTheme = 'light'; @@ -280,6 +297,7 @@ class CLIProxyManager { const connectionStatus = document.getElementById('connection-status'); const refreshAll = document.getElementById('refresh-all'); const availableModelsRefresh = document.getElementById('available-models-refresh'); + const versionCheckBtn = document.getElementById('version-check-btn'); if (connectionStatus) { connectionStatus.addEventListener('click', () => this.checkConnectionStatus()); @@ -290,6 +308,9 @@ class CLIProxyManager { if (availableModelsRefresh) { availableModelsRefresh.addEventListener('click', () => this.loadAvailableModels({ forceRefresh: true })); } + if (versionCheckBtn) { + versionCheckBtn.addEventListener('click', () => this.checkLatestVersion()); + } // 基础设置 const debugToggle = document.getElementById('debug-toggle'); @@ -659,6 +680,152 @@ class CLIProxyManager { }); } + // 顶栏标题动画与状态 + setupBrandTitleAnimation() { + const mainPage = document.getElementById('main-page'); + if (mainPage && mainPage.style.display === 'none') { + return; + } + + const toggle = document.getElementById('brand-name-toggle'); + const wrapper = document.getElementById('brand-texts'); + const fullText = document.querySelector('.brand-text-full'); + const shortText = document.querySelector('.brand-text-short'); + + if (!toggle || !wrapper || !fullText || !shortText) { + return; + } + + this.brandElements = { toggle, wrapper, fullText, shortText }; + + if (!this.brandToggleHandler) { + this.brandToggleHandler = () => this.handleBrandToggle(); + toggle.addEventListener('click', this.brandToggleHandler); + } + if (!this.brandResizeHandler) { + this.brandResizeHandler = () => this.updateBrandTextWidths({ immediate: true }); + window.addEventListener('resize', this.brandResizeHandler); + } + + this.brandAnimationReady = true; + } + + getBrandTextWidth(element) { + if (!element) { + return 0; + } + const width = element.scrollWidth || element.getBoundingClientRect().width || 0; + return Number.isFinite(width) ? Math.ceil(width) : 0; + } + + applyBrandWidth(targetWidth, { animate = true } = {}) { + const wrapper = this.brandElements?.wrapper; + if (!wrapper || !Number.isFinite(targetWidth)) { + return; + } + + if (!animate) { + const previousTransition = wrapper.style.transition; + wrapper.style.transition = 'none'; + wrapper.style.width = `${targetWidth}px`; + wrapper.getBoundingClientRect(); // 强制重绘以应用无动画的宽度 + wrapper.style.transition = previousTransition; + return; + } + + wrapper.style.width = `${targetWidth}px`; + } + + updateBrandTextWidths(options = {}) { + const { wrapper, fullText, shortText } = this.brandElements || {}; + if (!wrapper || !fullText || !shortText) { + return; + } + + const targetSpan = this.brandIsCollapsed ? shortText : fullText; + const targetWidth = this.getBrandTextWidth(targetSpan); + this.applyBrandWidth(targetWidth, { animate: !options.immediate }); + } + + setBrandCollapsed(collapsed, options = {}) { + const { toggle, fullText, shortText } = this.brandElements || {}; + if (!toggle || !fullText || !shortText) { + return; + } + + this.brandIsCollapsed = collapsed; + const targetSpan = collapsed ? shortText : fullText; + const targetWidth = this.getBrandTextWidth(targetSpan); + + this.applyBrandWidth(targetWidth, { animate: options.animate !== false }); + toggle.classList.toggle('collapsed', collapsed); + toggle.classList.toggle('expanded', !collapsed); + } + + scheduleBrandCollapse(delayMs = this.brandCollapseDelayMs) { + this.clearBrandCollapseTimer(); + this.brandCollapseTimer = window.setTimeout(() => { + this.setBrandCollapsed(true); + this.brandCollapseTimer = null; + }, delayMs); + } + + clearBrandCollapseTimer() { + if (this.brandCollapseTimer) { + clearTimeout(this.brandCollapseTimer); + this.brandCollapseTimer = null; + } + } + + startBrandCollapseCycle() { + this.setupBrandTitleAnimation(); + if (!this.brandAnimationReady) { + return; + } + + this.clearBrandCollapseTimer(); + this.brandIsCollapsed = false; + this.setBrandCollapsed(false, { animate: false }); + this.scheduleBrandCollapse(this.brandCollapseDelayMs); + } + + resetBrandTitleState() { + this.clearBrandCollapseTimer(); + const mainPage = document.getElementById('main-page'); + if (!this.brandAnimationReady || (mainPage && mainPage.style.display === 'none')) { + this.brandIsCollapsed = false; + return; + } + + this.brandIsCollapsed = false; + this.setBrandCollapsed(false, { animate: false }); + } + + refreshBrandTitleAfterTextChange() { + if (!this.brandAnimationReady) { + return; + } + this.updateBrandTextWidths({ immediate: true }); + if (!this.brandIsCollapsed) { + this.scheduleBrandCollapse(this.brandCollapseDelayMs); + } + } + + handleBrandToggle() { + if (!this.brandAnimationReady) { + return; + } + + const nextCollapsed = !this.brandIsCollapsed; + this.setBrandCollapsed(nextCollapsed); + this.clearBrandCollapseTimer(); + + if (!nextCollapsed) { + // 展开后给用户留出一点时间阅读再收起 + this.scheduleBrandCollapse(this.brandCollapseDelayMs + 1500); + } + } + // 显示通知 showNotification(message, type = 'info') { diff --git a/i18n.js b/i18n.js index 3a62dc1..d8bc1c1 100644 --- a/i18n.js +++ b/i18n.js @@ -52,6 +52,7 @@ const i18n = { // 页面标题 'title.main': 'CLI Proxy API Management Center', 'title.login': 'CLI Proxy API Management Center', + 'title.abbr': 'CPAMC', // 自动登录 'auto_login.title': '正在自动登录...', @@ -572,6 +573,18 @@ const i18n = { 'system_info.models_empty': '未从 /v1/models 获取到模型数据', 'system_info.models_error': '获取模型列表失败', 'system_info.models_count': '可用模型 {count} 个', + 'system_info.version_check_title': '版本检查', + 'system_info.version_check_desc': '调用 /latest-version 接口比对服务器版本,提示是否有可用更新。', + 'system_info.version_current_label': '当前版本', + 'system_info.version_latest_label': '最新版本', + 'system_info.version_check_button': '检查更新', + 'system_info.version_check_idle': '点击检查更新', + 'system_info.version_checking': '正在检查最新版本...', + 'system_info.version_update_available': '有新版本可用:{version}', + 'system_info.version_is_latest': '当前已是最新版本', + 'system_info.version_check_error': '检查更新失败', + 'system_info.version_current_missing': '未获取到服务器版本号,暂无法比对', + 'system_info.version_unknown': '未知', // 通知消息 'notification.debug_updated': '调试设置已更新', @@ -694,6 +707,7 @@ const i18n = { // Page titles 'title.main': 'CLI Proxy API Management Center', 'title.login': 'CLI Proxy API Management Center', + 'title.abbr': 'CPAMC', // Auto login 'auto_login.title': 'Auto Login in Progress...', @@ -1213,6 +1227,18 @@ const i18n = { 'system_info.models_empty': 'No models returned by /v1/models', 'system_info.models_error': 'Failed to load model list', 'system_info.models_count': '{count} available models', + 'system_info.version_check_title': 'Update Check', + 'system_info.version_check_desc': 'Call the /latest-version endpoint to compare with the server version and see if an update is available.', + 'system_info.version_current_label': 'Current version', + 'system_info.version_latest_label': 'Latest version', + 'system_info.version_check_button': 'Check for updates', + 'system_info.version_check_idle': 'Click to check for updates', + 'system_info.version_checking': 'Checking for the latest version...', + 'system_info.version_update_available': 'An update is available: {version}', + 'system_info.version_is_latest': 'You are on the latest version', + 'system_info.version_check_error': 'Update check failed', + 'system_info.version_current_missing': 'Server version is unavailable; cannot compare', + 'system_info.version_unknown': 'Unknown', // Notification messages 'notification.debug_updated': 'Debug settings updated', diff --git a/index.html b/index.html index cb6d3c2..7d86688 100644 --- a/index.html +++ b/index.html @@ -126,7 +126,12 @@
- CLI Proxy API Management Center +
@@ -875,6 +880,7 @@
+ @@ -1309,6 +1315,31 @@ + +
+
+

版本检查

+
+
+

调用 /latest-version 接口比对服务器版本,提示是否有可用更新。

+
+
+ 当前版本 + - +
+
+ 最新版本 + - +
+
+
+ + 点击检查更新 +
+
+
diff --git a/src/core/connection.js b/src/core/connection.js index e35c31b..97b4cea 100644 --- a/src/core/connection.js +++ b/src/core/connection.js @@ -129,6 +129,56 @@ export const connectionModule = { } }, + renderVersionCheckStatus({ + currentVersion, + latestVersion, + message, + status + } = {}) { + const resolvedCurrent = (typeof currentVersion === 'undefined' || currentVersion === null) + ? this.serverVersion + : currentVersion; + const resolvedLatest = (typeof latestVersion === 'undefined' || latestVersion === null) + ? this.latestVersion + : latestVersion; + const resolvedMessage = (typeof message === 'undefined' || message === null) + ? (this.versionCheckMessage || i18n.t('system_info.version_check_idle')) + : message; + const resolvedStatus = status || this.versionCheckStatus || 'muted'; + + this.latestVersion = resolvedLatest || null; + this.versionCheckMessage = resolvedMessage; + this.versionCheckStatus = resolvedStatus; + + const currentEl = document.getElementById('version-check-current'); + if (currentEl) { + currentEl.textContent = resolvedCurrent || i18n.t('system_info.version_unknown'); + } + + const latestEl = document.getElementById('version-check-latest'); + if (latestEl) { + latestEl.textContent = resolvedLatest || '-'; + } + + const resultEl = document.getElementById('version-check-result'); + if (resultEl) { + resultEl.textContent = resolvedMessage; + resultEl.className = `version-check-result ${resolvedStatus}`.trim(); + } + }, + + resetVersionCheckStatus() { + this.latestVersion = null; + this.versionCheckMessage = i18n.t('system_info.version_check_idle'); + this.versionCheckStatus = 'muted'; + this.renderVersionCheckStatus({ + currentVersion: this.serverVersion, + latestVersion: this.latestVersion, + message: this.versionCheckMessage, + status: this.versionCheckStatus + }); + }, + // 渲染底栏的版本与构建时间 renderVersionInfo() { const versionEl = document.getElementById('api-version'); @@ -149,12 +199,20 @@ export const connectionModule = { const domVersion = this.readUiVersionFromDom(); uiVersionEl.textContent = this.uiVersion || domVersion || 'v0.0.0-dev'; } + + this.renderVersionCheckStatus({ + currentVersion: this.serverVersion, + latestVersion: this.latestVersion, + message: this.versionCheckMessage, + status: this.versionCheckStatus + }); }, // 清空版本信息(例如登出时) resetVersionInfo() { this.serverVersion = null; this.serverBuildDate = null; + this.resetVersionCheckStatus(); this.renderVersionInfo(); }, @@ -171,6 +229,119 @@ export const connectionModule = { return buildDate; }, + parseVersionSegments(version) { + if (!version || typeof version !== 'string') return null; + const cleaned = version.trim().replace(/^v/i, ''); + if (!cleaned) return null; + const parts = cleaned.split(/[^0-9]+/).filter(Boolean).map(segment => { + const parsed = parseInt(segment, 10); + return Number.isFinite(parsed) ? parsed : 0; + }); + return parts.length ? parts : null; + }, + + compareVersions(latestVersion, currentVersion) { + const latestParts = this.parseVersionSegments(latestVersion); + const currentParts = this.parseVersionSegments(currentVersion); + if (!latestParts || !currentParts) { + return null; + } + + const length = Math.max(latestParts.length, currentParts.length); + for (let i = 0; i < length; i++) { + const latest = latestParts[i] || 0; + const current = currentParts[i] || 0; + if (latest > current) return 1; + if (latest < current) return -1; + } + + return 0; + }, + + async checkLatestVersion() { + if (!this.isConnected) { + const message = i18n.t('notification.connection_required'); + this.renderVersionCheckStatus({ + currentVersion: this.serverVersion, + latestVersion: this.latestVersion, + message, + status: 'warning' + }); + this.showNotification(message, 'error'); + return; + } + + const button = document.getElementById('version-check-btn'); + const originalLabel = button ? button.innerHTML : ''; + + if (button) { + button.disabled = true; + button.innerHTML = `
${i18n.t('system_info.version_checking')}`; + } + + this.renderVersionCheckStatus({ + currentVersion: this.serverVersion, + latestVersion: this.latestVersion, + message: i18n.t('system_info.version_checking'), + status: 'info' + }); + + try { + const data = await this.makeRequest('/latest-version'); + const latestVersion = data?.['latest-version'] || data?.latest_version || ''; + const latestParts = this.parseVersionSegments(latestVersion); + const currentParts = this.parseVersionSegments(this.serverVersion); + const comparison = (latestParts && currentParts) + ? this.compareVersions(latestVersion, this.serverVersion) + : null; + let messageKey = 'system_info.version_check_error'; + let statusClass = 'error'; + + if (!latestParts) { + messageKey = 'system_info.version_check_error'; + } else if (!currentParts) { + messageKey = 'system_info.version_current_missing'; + statusClass = 'warning'; + } else if (comparison > 0) { + messageKey = 'system_info.version_update_available'; + statusClass = 'warning'; + } else { + messageKey = 'system_info.version_is_latest'; + statusClass = 'success'; + } + + const message = i18n.t(messageKey, latestVersion ? { version: latestVersion } : undefined); + this.renderVersionCheckStatus({ + currentVersion: this.serverVersion, + latestVersion, + message, + status: statusClass + }); + + if (latestVersion && comparison !== null) { + const notifyKey = comparison > 0 + ? 'system_info.version_update_available' + : 'system_info.version_is_latest'; + const notifyType = comparison > 0 ? 'warning' : 'success'; + this.showNotification(i18n.t(notifyKey, { version: latestVersion }), notifyType); + } + } catch (error) { + const message = `${i18n.t('system_info.version_check_error')}: ${error.message}`; + this.renderVersionCheckStatus({ + currentVersion: this.serverVersion, + latestVersion: this.latestVersion, + message, + status: 'error' + }); + this.showNotification(message, 'error'); + } finally { + if (button) { + button.disabled = false; + button.innerHTML = originalLabel; + } + } + }, + // API 请求方法 async makeRequest(endpoint, options = {}) { try { diff --git a/src/modules/language.js b/src/modules/language.js index 553623c..e2d41fd 100644 --- a/src/modules/language.js +++ b/src/modules/language.js @@ -21,6 +21,7 @@ export const languageModule = { const newLang = currentLang === 'zh-CN' ? 'en-US' : 'zh-CN'; i18n.setLanguage(newLang); + this.refreshBrandTitleAfterTextChange(); this.updateThemeButtons(); this.updateConnectionStatus(); diff --git a/src/modules/login.js b/src/modules/login.js index 701d97d..b2c0722 100644 --- a/src/modules/login.js +++ b/src/modules/login.js @@ -66,6 +66,7 @@ export const loginModule = { document.getElementById('login-page').style.display = 'flex'; document.getElementById('main-page').style.display = 'none'; this.isLoggedIn = false; + this.resetBrandTitleState(); this.updateLoginConnectionInfo(); }, @@ -74,6 +75,7 @@ export const loginModule = { document.getElementById('main-page').style.display = 'block'; this.isLoggedIn = true; this.updateConnectionInfo(); + this.startBrandCollapseCycle(); }, async login(apiBase, managementKey) { diff --git a/styles.css b/styles.css index 9fcf17a..afa61d5 100644 --- a/styles.css +++ b/styles.css @@ -836,6 +836,7 @@ body { align-items: center; gap: 12px; max-width: max-content; + flex-shrink: 0; } .top-navbar-brand-logo { @@ -849,8 +850,70 @@ body { font-size: 18px; font-weight: 600; color: var(--text-primary); + overflow: visible; + text-overflow: initial; + white-space: nowrap; + line-height: 1; +} + +.top-navbar-brand-toggle { + display: inline-flex; + align-items: center; + background: transparent; + border: none; + padding: 0; + margin: 0; + cursor: pointer; + font: inherit; + color: var(--text-primary); + height: 32px; +} + +.top-navbar-brand-toggle:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 4px; + border-radius: 8px; +} + +.brand-texts { + position: relative; + display: inline-flex; + align-items: center; overflow: hidden; - text-overflow: ellipsis; + white-space: nowrap; + transition: width 0.45s ease; +} + +.brand-text { + display: block; + white-space: nowrap; + transition: opacity 0.35s ease; + line-height: 1.2; +} + +.brand-text-short { + position: absolute; + left: 0; + top: 0; + opacity: 0; +} + +.top-navbar-brand-toggle.expanded .brand-text-full { + opacity: 1; +} + +.top-navbar-brand-toggle.expanded .brand-text-short { + opacity: 0; +} + +.top-navbar-brand-toggle.collapsed .brand-text-full { + opacity: 0; + position: absolute; +} + +.top-navbar-brand-toggle.collapsed .brand-text-short { + opacity: 1; + position: relative; } .top-navbar-actions { @@ -2368,6 +2431,65 @@ input:checked+.slider:before { color: var(--text-tertiary); } +.version-check { + display: flex; + flex-direction: column; + gap: 12px; +} + +.version-check-rows { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 12px; +} + +.version-check-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 14px; + border-radius: 8px; + background: var(--bg-quaternary); + border: 1px solid var(--border-primary); +} + +.version-check-value { + font-weight: 600; + color: var(--text-tertiary); +} + +.version-check-actions { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.version-check-result { + font-weight: 600; + color: var(--text-secondary); +} + +.version-check-result.success { + color: #10b981; +} + +.version-check-result.warning { + color: #f59e0b; +} + +.version-check-result.error { + color: #ef4444; +} + +.version-check-result.info { + color: var(--text-secondary); +} + +.version-check-result.muted { + color: var(--text-quaternary); +} + /* JSON模态框 */ .json-modal { position: fixed;