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 @@
![Logo]()
-
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;