feat(ui): 增强提供商刷新状态显示和版本选择功能

- 在提供商管理界面添加刷新状态徽章,显示“刷新中”状态
- 为更新功能添加版本选择下拉框,支持选择特定版本进行更新
- 在提供商状态中新增 needsRefresh 字段用于跟踪刷新状态
- 修复冷启动时刷新状态重置逻辑,避免持久化状态影响新会话
- 为刷新操作添加超时保护机制,防止适配器调用无限挂起
- 完善国际化翻译,添加相关状态和版本标签
This commit is contained in:
hex2077 2026-04-05 21:46:05 +08:00
parent 85d7b50cb1
commit 1ee4ca37d1
9 changed files with 201 additions and 74 deletions

View file

@ -72,6 +72,7 @@ export class ProviderPoolManager {
this.refreshBufferQueues = {}; // 按 providerType 分组的缓冲队列 this.refreshBufferQueues = {}; // 按 providerType 分组的缓冲队列
this.refreshBufferTimers = {}; // 按 providerType 分组的定时器 this.refreshBufferTimers = {}; // 按 providerType 分组的定时器
this.bufferDelay = options.globalConfig?.REFRESH_BUFFER_DELAY ?? 5000; // 默认5秒缓冲延迟 this.bufferDelay = options.globalConfig?.REFRESH_BUFFER_DELAY ?? 5000; // 默认5秒缓冲延迟
this.refreshTaskTimeoutMs = options.globalConfig?.REFRESH_TASK_TIMEOUT_MS ?? 60000; // 默认60秒刷新超时
// 用于并发选点时的原子排序辅助(自增序列) // 用于并发选点时的原子排序辅助(自增序列)
this._selectionSequence = 0; this._selectionSequence = 0;
@ -184,6 +185,12 @@ export class ProviderPoolManager {
_enqueueRefresh(providerType, providerStatus, force = false) { _enqueueRefresh(providerType, providerStatus, force = false) {
const uuid = providerStatus.uuid; const uuid = providerStatus.uuid;
// 如果节点被禁用,不进行刷新
if (providerStatus.config.isDisabled) {
this._log('debug', `Skipping refresh for disabled node ${uuid}`);
return;
}
// 如果已经在刷新中,直接返回 // 如果已经在刷新中,直接返回
if (this.refreshingUuids.has(uuid)) { if (this.refreshingUuids.has(uuid)) {
this._log('debug', `Node ${uuid} is already in refresh queue.`); this._log('debug', `Node ${uuid} is already in refresh queue.`);
@ -405,16 +412,18 @@ export class ProviderPoolManager {
// 调用适配器的 refreshToken 方法(内部封装了具体的刷新逻辑) // 调用适配器的 refreshToken 方法(内部封装了具体的刷新逻辑)
if (typeof serviceAdapter.refreshToken === 'function') { if (typeof serviceAdapter.refreshToken === 'function') {
const startTime = Date.now(); const startTime = Date.now();
let refreshOperation;
if (force) { if (force) {
if (typeof serviceAdapter.forceRefreshToken === 'function') { if (typeof serviceAdapter.forceRefreshToken === 'function') {
await serviceAdapter.forceRefreshToken(); refreshOperation = serviceAdapter.forceRefreshToken();
} else { } else {
this._log('warn', `forceRefreshToken not implemented for ${providerType}, falling back to refreshToken`); this._log('warn', `forceRefreshToken not implemented for ${providerType}, falling back to refreshToken`);
await serviceAdapter.refreshToken(); refreshOperation = serviceAdapter.refreshToken();
} }
} else { } else {
await serviceAdapter.refreshToken(); refreshOperation = serviceAdapter.refreshToken();
} }
await this._awaitRefreshWithTimeout(refreshOperation, providerType, providerStatus.uuid);
const duration = Date.now() - startTime; const duration = Date.now() - startTime;
this._log('info', `Token refresh successful for node ${providerStatus.uuid} (Duration: ${duration}ms)`); this._log('info', `Token refresh successful for node ${providerStatus.uuid} (Duration: ${duration}ms)`);
@ -422,6 +431,8 @@ export class ProviderPoolManager {
config.needsRefresh = false; config.needsRefresh = false;
config.refreshCount = 0; config.refreshCount = 0;
config.lastRefreshTime = Date.now(); // 记录最后刷新成功时间 config.lastRefreshTime = Date.now(); // 记录最后刷新成功时间
this._debouncedSave(providerType);
} else { } else {
throw new Error(`refreshToken method not implemented for ${providerType}`); throw new Error(`refreshToken method not implemented for ${providerType}`);
} }
@ -433,6 +444,31 @@ export class ProviderPoolManager {
} }
} }
/**
* 为刷新任务附加超时保护避免单个适配器调用无限挂起
* @private
*/
async _awaitRefreshWithTimeout(refreshOperation, providerType, uuid) {
if (this.refreshTaskTimeoutMs <= 0) {
return await refreshOperation;
}
let timeoutId = null;
const timeoutPromise = new Promise((_, reject) => {
timeoutId = setTimeout(() => {
reject(new Error(`Refresh timeout after ${this.refreshTaskTimeoutMs}ms for node ${uuid} (${providerType})`));
}, this.refreshTaskTimeoutMs);
});
try {
return await Promise.race([Promise.resolve(refreshOperation), timeoutPromise]);
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
}
}
/** /**
* 计算节点的权重/评分用于排序 * 计算节点的权重/评分用于排序
* 分数越低优先级越高 * 分数越低优先级越高
@ -627,6 +663,7 @@ export class ProviderPoolManager {
*/ */
initializeProviderStatus() { initializeProviderStatus() {
const oldFullStatus = this.providerStatus || {}; const oldFullStatus = this.providerStatus || {};
const isColdStart = Object.keys(oldFullStatus).length === 0;
this.providerStatus = {}; // Tracks health and usage for each provider instance this.providerStatus = {}; // Tracks health and usage for each provider instance
for (const providerType in this.providerPools) { for (const providerType in this.providerPools) {
const oldStatus = oldFullStatus[providerType] || []; const oldStatus = oldFullStatus[providerType] || [];
@ -652,8 +689,13 @@ export class ProviderPoolManager {
providerConfig.errorCount = providerConfig.errorCount !== undefined ? providerConfig.errorCount : 0; providerConfig.errorCount = providerConfig.errorCount !== undefined ? providerConfig.errorCount : 0;
// --- V2: 刷新监控字段 --- // --- V2: 刷新监控字段 ---
providerConfig.needsRefresh = providerConfig.needsRefresh !== undefined ? providerConfig.needsRefresh : false; const persistedNeedsRefresh = providerConfig.needsRefresh !== undefined ? providerConfig.needsRefresh : false;
providerConfig.refreshCount = providerConfig.refreshCount !== undefined ? providerConfig.refreshCount : 0; const persistedRefreshCount = providerConfig.refreshCount !== undefined ? providerConfig.refreshCount : 0;
if (isColdStart && (persistedNeedsRefresh || persistedRefreshCount > 0)) {
this._log('info', `Resetting stale refresh state for provider ${providerConfig.uuid} (${providerType}) on startup.`);
}
providerConfig.needsRefresh = isColdStart ? false : persistedNeedsRefresh;
providerConfig.refreshCount = isColdStart ? 0 : persistedRefreshCount;
// 优化2: 简化 lastErrorTime 处理逻辑 // 优化2: 简化 lastErrorTime 处理逻辑
providerConfig.lastErrorTime = providerConfig.lastErrorTime instanceof Date providerConfig.lastErrorTime = providerConfig.lastErrorTime instanceof Date
@ -680,6 +722,9 @@ export class ProviderPoolManager {
logger.error(`[ProviderPoolManager] Error initializing node for ${providerType}: ${nodeError.message}`); logger.error(`[ProviderPoolManager] Error initializing node for ${providerType}: ${nodeError.message}`);
} }
}); });
// 确保初始化时的默认值补全也能写盘
this._debouncedSave(providerType);
} }
this._log('info', `Initialized provider statuses: ok (maxErrorCount: ${this.maxErrorCount})`); this._log('info', `Initialized provider statuses: ok (maxErrorCount: ${this.maxErrorCount})`);
} }

View file

@ -563,7 +563,8 @@ export async function getProviderStatus(config, options = {}) {
'customName', 'customName',
'isHealthy', 'isHealthy',
'lastErrorTime', 'lastErrorTime',
'lastErrorMessage' 'lastErrorMessage',
'needsRefresh'
]; ];
// identify 字段映射表 // identify 字段映射表
const identifyFieldMap = { const identifyFieldMap = {

View file

@ -18,7 +18,7 @@ function sanitizeProviderData(provider, maskSensitive = false) {
if (maskSensitive) { if (maskSensitive) {
for (const key in sanitized) { for (const key in sanitized) {
// 排除已知非敏感字段 // 排除已知非敏感字段
if (key === 'uuid' || key === 'customName' || key === 'isHealthy' || key === 'isDisabled') continue; if (key === 'uuid' || key === 'customName' || key === 'isHealthy' || key === 'isDisabled' || key === 'needsRefresh') continue;
const val = sanitized[key]; const val = sanitized[key];
if (typeof val !== 'string' || !val) continue; if (typeof val !== 'string' || !val) continue;
@ -138,8 +138,18 @@ export async function handleGetProviders(req, res, currentConfig, providerPoolMa
const poolsData = JSON.parse(readFileSync(filePath, 'utf-8')); const poolsData = JSON.parse(readFileSync(filePath, 'utf-8'));
poolTypes = Object.keys(poolsData); poolTypes = Object.keys(poolsData);
poolTypes.forEach(type => { poolTypes.forEach(type => {
if (!providerStatus[type]) { // 如果管理器中没有该组,或者该组是空的,则从文件中补全
providerStatus[type] = []; if (!providerStatus[type] || providerStatus[type].length === 0) {
const fileProviders = poolsData[type] || [];
if (fileProviders.length > 0) {
providerStatus[type] = fileProviders.map(p => ({
...p,
activeRequests: 0,
waitingRequests: 0
}));
} else if (!providerStatus[type]) {
providerStatus[type] = [];
}
} }
}); });
} }

View file

@ -6,6 +6,7 @@ import { exec } from 'child_process';
import { promisify } from 'util'; import { promisify } from 'util';
import { CONFIG } from '../core/config-manager.js'; import { CONFIG } from '../core/config-manager.js';
import { parseProxyUrl } from '../utils/proxy-utils.js'; import { parseProxyUrl } from '../utils/proxy-utils.js';
import { getRequestBody } from '../utils/common.js';
const execAsync = promisify(exec); const execAsync = promisify(exec);
const GITHUB_REPO = 'justlovemaki/AIClient-2-API'; const GITHUB_REPO = 'justlovemaki/AIClient-2-API';
@ -149,16 +150,16 @@ function compareVersions(v1, v2) {
} }
/** /**
* 通过 GitHub API 获取最新版本 * 通过 GitHub API 获取最近的版本列表
* @returns {Promise<string|null>} 最新版本号或 null * @param {number} limit - 限制返回的版本数量
* @returns {Promise<string[]>} 版本列表
*/ */
async function getLatestVersionFromGitHub() { async function getVersionsFromGitHub(limit = 10) {
const candidates = buildGitHubApiCandidates(GITHUB_REPO); const candidates = buildGitHubApiCandidates(GITHUB_REPO);
for (const candidate of candidates) { for (const candidate of candidates) {
try { try {
logger.info(`[Update] Fetching latest version from GitHub API via ${candidate.name}...`); logger.info(`[Update] Fetching versions from GitHub API via ${candidate.name}...`);
logger.info(`[Update] Request URL: ${candidate.url}`);
const response = await fetchWithProxy(candidate.url, { const response = await fetchWithProxy(candidate.url, {
headers: { headers: {
'Accept': 'application/vnd.github.v3+json', 'Accept': 'application/vnd.github.v3+json',
@ -189,15 +190,23 @@ async function getLatestVersionFromGitHub() {
} }
versions.sort((a, b) => compareVersions(b, a)); versions.sort((a, b) => compareVersions(b, a));
logger.info(`[Update] Latest version fetched successfully via ${candidate.name}: ${versions[0]}`); return versions.slice(0, limit);
return versions[0];
} catch (error) { } catch (error) {
logger.warn(`[Update] Failed to fetch latest version via ${candidate.name}: ${error.message}`); logger.warn(`[Update] Failed to fetch versions via ${candidate.name}: ${error.message}`);
} }
} }
logger.warn('[Update] All GitHub API proxy attempts failed'); logger.warn('[Update] All GitHub API proxy attempts failed');
return null; return [];
}
/**
* 通过 GitHub API 获取最新版本
* @returns {Promise<string|null>} 最新版本号或 null
*/
async function getLatestVersionFromGitHub() {
const versions = await getVersionsFromGitHub(1);
return versions.length > 0 ? versions[0] : null;
} }
/** /**
@ -231,10 +240,11 @@ export async function checkForUpdates() {
} }
let latestTag = null; let latestTag = null;
let availableVersions = [];
let updateMethod = 'unknown'; let updateMethod = 'unknown';
if (isGitRepo) { if (isGitRepo) {
// Git 仓库模式:使用 git 命令 // Git 仓库模式:使用 git命令
updateMethod = 'git'; updateMethod = 'git';
// 获取远程 tags // 获取远程 tags
@ -244,45 +254,33 @@ export async function checkForUpdates() {
} catch (error) { } catch (error) {
logger.warn('[Update] Failed to fetch tags via git, falling back to GitHub API:', error.message); logger.warn('[Update] Failed to fetch tags via git, falling back to GitHub API:', error.message);
// 如果 git fetch 失败,回退到 GitHub API // 如果 git fetch 失败,回退到 GitHub API
latestTag = await getLatestVersionFromGitHub(); availableVersions = await getVersionsFromGitHub(10);
latestTag = availableVersions.length > 0 ? availableVersions[0] : null;
updateMethod = 'github_api'; updateMethod = 'github_api';
} }
// 如果 git fetch 成功,获取最新的 tag // 如果 git fetch 成功,获取最新的 tag 和可用的 tags
if (!latestTag && updateMethod === 'git') { if (!latestTag && updateMethod === 'git') {
const isWindows = process.platform === 'win32';
try { try {
if (isWindows) { // 获取最近的 10 个 tag
// Windows: 使用 git for-each-ref这是跨平台兼容的方式 const { stdout } = await execAsync('git tag --sort=-v:refname');
const { stdout } = await execAsync('git for-each-ref --sort=-v:refname --format="%(refname:short)" refs/tags --count=1'); const tags = stdout.trim().split('\n').filter(t => t);
latestTag = stdout.trim(); if (tags.length > 0) {
} else { availableVersions = tags.slice(0, 10);
// Linux/macOS: 使用 head 命令,更高效 latestTag = availableVersions[0];
const { stdout } = await execAsync('git tag --sort=-v:refname | head -n 1');
latestTag = stdout.trim();
} }
} catch (error) { } catch (error) {
// 备用方案:获取所有 tags 并在 JavaScript 中排序 logger.warn('[Update] Failed to get tags via git, falling back to GitHub API:', error.message);
try { availableVersions = await getVersionsFromGitHub(10);
const { stdout } = await execAsync('git tag'); latestTag = availableVersions.length > 0 ? availableVersions[0] : null;
const tags = stdout.trim().split('\n').filter(t => t); updateMethod = 'github_api';
if (tags.length > 0) {
// 按版本号排序(降序)
tags.sort((a, b) => compareVersions(b, a));
latestTag = tags[0];
}
} catch (e) {
logger.warn('[Update] Failed to get latest tag via git, falling back to GitHub API:', e.message);
latestTag = await getLatestVersionFromGitHub();
updateMethod = 'github_api';
}
} }
} }
} else { } else {
// 非 Git 仓库模式(如 Docker 容器):使用 GitHub API // 非 Git 仓库模式(如 Docker 容器):使用 GitHub API
updateMethod = 'github_api'; updateMethod = 'github_api';
latestTag = await getLatestVersionFromGitHub(); availableVersions = await getVersionsFromGitHub(10);
latestTag = availableVersions.length > 0 ? availableVersions[0] : null;
} }
if (!latestTag) { if (!latestTag) {
@ -290,6 +288,7 @@ export async function checkForUpdates() {
hasUpdate: false, hasUpdate: false,
localVersion, localVersion,
latestVersion: null, latestVersion: null,
availableVersions: [],
updateMethod, updateMethod,
error: 'Unable to get latest version information' error: 'Unable to get latest version information'
}; };
@ -305,6 +304,7 @@ export async function checkForUpdates() {
hasUpdate, hasUpdate,
localVersion, localVersion,
latestVersion: latestTag, latestVersion: latestTag,
availableVersions,
updateMethod, updateMethod,
error: null error: null
}; };
@ -312,9 +312,10 @@ export async function checkForUpdates() {
/** /**
* 执行更新操作 * 执行更新操作
* @param {string} targetTag - 目标版本 tag如果未提供则更新到最新版本
* @returns {Promise<Object>} 更新结果 * @returns {Promise<Object>} 更新结果
*/ */
export async function performUpdate() { export async function performUpdate(targetTag = null) {
// 首先检查是否有更新 // 首先检查是否有更新
const updateInfo = await checkForUpdates(); const updateInfo = await checkForUpdates();
@ -322,7 +323,12 @@ export async function performUpdate() {
throw new Error(updateInfo.error); throw new Error(updateInfo.error);
} }
if (!updateInfo.hasUpdate) { // 如果未提供 targetTag使用最新版本
const latestTag = updateInfo.latestVersion;
const finalTag = targetTag || latestTag;
// 如果是更新到最新版本,且当前已是最新版本
if (!targetTag && !updateInfo.hasUpdate) {
return { return {
success: true, success: true,
message: 'Already at the latest version', message: 'Already at the latest version',
@ -332,16 +338,25 @@ export async function performUpdate() {
}; };
} }
const latestTag = updateInfo.latestVersion; // 如果指定了 tag但与本地版本相同
if (targetTag && (targetTag === updateInfo.localVersion || targetTag === `v${updateInfo.localVersion}`)) {
return {
success: true,
message: `Already at version ${targetTag}`,
localVersion: updateInfo.localVersion,
latestVersion: updateInfo.latestVersion,
updated: false
};
}
// 检查更新方式 - 如果是通过 GitHub API 获取的版本信息,说明不在 Git 仓库中 // 检查更新方式 - 如果是通过 GitHub API 获取的版本信息,说明不在 Git 仓库中
if (updateInfo.updateMethod === 'github_api') { if (updateInfo.updateMethod === 'github_api') {
// Docker/非 Git 环境,通过下载 tarball 更新 // Docker/非 Git 环境,通过下载 tarball 更新
logger.info('[Update] Running in Docker/non-Git environment, will download and extract tarball'); logger.info(`[Update] Running in Docker/non-Git environment, will download and extract tarball for ${finalTag}`);
return await performTarballUpdate(updateInfo.localVersion, latestTag); return await performTarballUpdate(updateInfo.localVersion, finalTag);
} }
logger.info(`[Update] Starting update to ${latestTag}...`); logger.info(`[Update] Starting update to ${finalTag}...`);
// 检查是否有未提交的更改 // 检查是否有未提交的更改
try { try {
@ -355,19 +370,19 @@ export async function performUpdate() {
logger.warn('[Update] Failed to check git status:', error.message); logger.warn('[Update] Failed to check git status:', error.message);
} }
// 执行 checkout 到最新 tag // 执行 checkout 到目标 tag
try { try {
logger.info(`[Update] Checking out to ${latestTag}...`); logger.info(`[Update] Checking out to ${finalTag}...`);
await execAsync(`git checkout ${latestTag}`); await execAsync(`git checkout ${finalTag}`);
} catch (error) { } catch (error) {
logger.error('[Update] Failed to checkout:', error.message); logger.error('[Update] Failed to checkout:', error.message);
throw new Error('Failed to switch to new version: ' + error.message); throw new Error(`Failed to switch to version ${finalTag}: ` + error.message);
} }
// 更新 VERSION 文件(如果 tag 和 VERSION 文件不同步) // 更新 VERSION 文件(如果 tag 和 VERSION 文件不同步)
const versionFilePath = path.join(process.cwd(), 'VERSION'); const versionFilePath = path.join(process.cwd(), 'VERSION');
try { try {
const newVersion = latestTag.replace(/^v/, ''); const newVersion = finalTag.replace(/^v/, '');
writeFileSync(versionFilePath, newVersion, 'utf-8'); writeFileSync(versionFilePath, newVersion, 'utf-8');
logger.info(`[Update] VERSION file updated to ${newVersion}`); logger.info(`[Update] VERSION file updated to ${newVersion}`);
} catch (error) { } catch (error) {
@ -379,7 +394,7 @@ export async function performUpdate() {
try { try {
// 确保本地版本号有 v 前缀,以匹配 git tag 格式 // 确保本地版本号有 v 前缀,以匹配 git tag 格式
const localVersionTag = updateInfo.localVersion.startsWith('v') ? updateInfo.localVersion : `v${updateInfo.localVersion}`; const localVersionTag = updateInfo.localVersion.startsWith('v') ? updateInfo.localVersion : `v${updateInfo.localVersion}`;
const { stdout: diffOutput } = await execAsync(`git diff ${localVersionTag}..${latestTag} --name-only`); const { stdout: diffOutput } = await execAsync(`git diff ${localVersionTag}..${finalTag} --name-only`);
if (diffOutput.includes('package.json') || diffOutput.includes('package-lock.json')) { if (diffOutput.includes('package.json') || diffOutput.includes('package-lock.json')) {
logger.info('[Update] package.json changed, running npm install...'); logger.info('[Update] package.json changed, running npm install...');
await execAsync('npm install'); await execAsync('npm install');
@ -389,13 +404,14 @@ export async function performUpdate() {
logger.warn('[Update] Failed to check package changes:', error.message); logger.warn('[Update] Failed to check package changes:', error.message);
} }
logger.info(`[Update] Update completed successfully to ${latestTag}`); logger.info(`[Update] Update completed successfully to ${finalTag}`);
return { return {
success: true, success: true,
message: `Successfully updated to version ${latestTag}`, message: `Successfully updated to version ${finalTag}`,
localVersion: updateInfo.localVersion, localVersion: updateInfo.localVersion,
latestVersion: latestTag, latestVersion: latestTag,
targetVersion: finalTag,
updated: true, updated: true,
updateMethod: 'git', updateMethod: 'git',
needsRestart: needsRestart, needsRestart: needsRestart,
@ -626,7 +642,10 @@ export async function handleCheckUpdate(req, res) {
*/ */
export async function handlePerformUpdate(req, res) { export async function handlePerformUpdate(req, res) {
try { try {
const updateResult = await performUpdate(); const body = await getRequestBody(req);
const version = body?.version || null;
const updateResult = await performUpdate(version);
res.writeHead(200, { 'Content-Type': 'application/json' }); res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(updateResult)); res.end(JSON.stringify(updateResult));
return true; return true;
@ -640,4 +659,4 @@ export async function handlePerformUpdate(req, res) {
})); }));
return true; return true;
} }
} }

View file

@ -47,6 +47,8 @@ const translations = {
'dashboard.update.performTitle': '更新到最新版本', 'dashboard.update.performTitle': '更新到最新版本',
'dashboard.update.checking': '正在检查...', 'dashboard.update.checking': '正在检查...',
'dashboard.update.upToDate': '已是最新', 'dashboard.update.upToDate': '已是最新',
'dashboard.update.latest': '最新',
'dashboard.update.current': '当前',
'dashboard.update.hasUpdate': '发现新版本: {version}', 'dashboard.update.hasUpdate': '发现新版本: {version}',
'dashboard.update.updating': '正在更新...', 'dashboard.update.updating': '正在更新...',
'dashboard.update.success': '更新成功', 'dashboard.update.success': '更新成功',
@ -464,6 +466,7 @@ const translations = {
'providers.healthyProviders': '健康提供商', 'providers.healthyProviders': '健康提供商',
'providers.status.healthy': '{healthy}/{total} 可用', 'providers.status.healthy': '{healthy}/{total} 可用',
'providers.status.empty': '0/0 节点', 'providers.status.empty': '0/0 节点',
'providers.status.needsRefresh': '刷新中',
'providers.stat.totalAccounts': '总账户', 'providers.stat.totalAccounts': '总账户',
'providers.stat.healthyAccounts': '健康账户', 'providers.stat.healthyAccounts': '健康账户',
'providers.stat.usageCount': '使用次数', 'providers.stat.usageCount': '使用次数',
@ -914,6 +917,8 @@ const translations = {
'dashboard.update.performTitle': 'Update to latest version', 'dashboard.update.performTitle': 'Update to latest version',
'dashboard.update.checking': 'Checking...', 'dashboard.update.checking': 'Checking...',
'dashboard.update.upToDate': 'Up to date', 'dashboard.update.upToDate': 'Up to date',
'dashboard.update.latest': 'Latest',
'dashboard.update.current': 'Current',
'dashboard.update.hasUpdate': 'New version available: {version}', 'dashboard.update.hasUpdate': 'New version available: {version}',
'dashboard.update.updating': 'Updating...', 'dashboard.update.updating': 'Updating...',
'dashboard.update.success': 'Update successful', 'dashboard.update.success': 'Update successful',
@ -1332,6 +1337,7 @@ const translations = {
'providers.healthyProviders': 'Healthy Providers', 'providers.healthyProviders': 'Healthy Providers',
'providers.status.healthy': '{healthy}/{total} Available', 'providers.status.healthy': '{healthy}/{total} Available',
'providers.status.empty': '0/0 Nodes', 'providers.status.empty': '0/0 Nodes',
'providers.status.needsRefresh': 'Refreshing',
'providers.stat.totalAccounts': 'Total Accounts', 'providers.stat.totalAccounts': 'Total Accounts',
'providers.stat.healthyAccounts': 'Healthy Accounts', 'providers.stat.healthyAccounts': 'Healthy Accounts',
'providers.stat.usageCount': 'Usage Count', 'providers.stat.usageCount': 'Usage Count',

View file

@ -370,6 +370,7 @@ function renderProviderList(providers) {
const toggleButtonText = isDisabled ? t('modal.provider.enabled') : t('modal.provider.disabled'); const toggleButtonText = isDisabled ? t('modal.provider.enabled') : t('modal.provider.disabled');
const toggleButtonIcon = isDisabled ? 'fas fa-play' : 'fas fa-ban'; const toggleButtonIcon = isDisabled ? 'fas fa-play' : 'fas fa-ban';
const toggleButtonClass = isDisabled ? 'btn-success' : 'btn-warning'; const toggleButtonClass = isDisabled ? 'btn-success' : 'btn-warning';
const needsRefresh = !!provider.needsRefresh;
// 构建错误信息显示 // 构建错误信息显示
let errorInfoHtml = ''; let errorInfoHtml = '';
@ -388,7 +389,10 @@ function renderProviderList(providers) {
<div class="provider-item-detail ${healthClass} ${disabledClass}" data-uuid="${provider.uuid}"> <div class="provider-item-detail ${healthClass} ${disabledClass}" data-uuid="${provider.uuid}">
<div class="provider-item-header" onclick="window.toggleProviderDetails('${provider.uuid}')"> <div class="provider-item-header" onclick="window.toggleProviderDetails('${provider.uuid}')">
<div class="provider-info"> <div class="provider-info">
<div class="provider-name">${provider.customName || provider.uuid}</div> <div class="provider-name">
${provider.customName || provider.uuid}
${needsRefresh ? `<span class="badge badge-warning" style="font-size: 10px; margin-left: 8px; vertical-align: middle;"><i class="fas fa-sync-alt fa-spin"></i> <span data-i18n="providers.status.needsRefresh">${t('providers.status.needsRefresh')}</span></span>` : ''}
</div>
<div class="provider-meta"> <div class="provider-meta">
<span class="health-status"> <span class="health-status">
<i class="${healthIcon}"></i> <i class="${healthIcon}"></i>

View file

@ -3108,6 +3108,8 @@ async function checkUpdate(silent = false) {
const updateBtn = document.getElementById('performUpdateBtn'); const updateBtn = document.getElementById('performUpdateBtn');
const updateBadge = document.getElementById('updateBadge'); const updateBadge = document.getElementById('updateBadge');
const latestVersionText = document.getElementById('latestVersionText'); const latestVersionText = document.getElementById('latestVersionText');
const versionSelectWrapper = document.getElementById('versionSelectWrapper');
const versionSelect = document.getElementById('versionSelect');
const checkBtnIcon = checkBtn?.querySelector('i'); const checkBtnIcon = checkBtn?.querySelector('i');
const checkBtnText = checkBtn?.querySelector('span'); const checkBtnText = checkBtn?.querySelector('span');
@ -3120,16 +3122,46 @@ async function checkUpdate(silent = false) {
const data = await window.apiClient.get('/check-update'); const data = await window.apiClient.get('/check-update');
// 处理版本列表
if (versionSelect && data.availableVersions && data.availableVersions.length > 0) {
versionSelect.innerHTML = '';
data.availableVersions.forEach(version => {
const option = document.createElement('option');
option.value = version;
option.textContent = version;
// 如果是最新版本,增加标识
if (version === data.latestVersion) {
option.textContent += ` (${t('dashboard.update.latest') || 'Latest'})`;
}
// 如果是当前版本,增加标识
if (version === data.localVersion || version === `v${data.localVersion}`) {
option.textContent += ` (${t('dashboard.update.current') || 'Current'})`;
option.selected = true;
}
versionSelect.appendChild(option);
});
if (versionSelectWrapper) versionSelectWrapper.style.display = 'block';
if (updateBtn) {
updateBtn.style.display = 'inline-flex';
// 如果是回退,修改按钮文字
updateBtn.querySelector('span').textContent = t('dashboard.update.perform');
}
}
if (data.hasUpdate) { if (data.hasUpdate) {
if (updateBtn) updateBtn.style.display = 'inline-flex';
if (updateBadge) updateBadge.style.display = 'inline-flex'; if (updateBadge) updateBadge.style.display = 'inline-flex';
if (latestVersionText) latestVersionText.textContent = data.latestVersion; if (latestVersionText) latestVersionText.textContent = data.latestVersion;
// 如果有新版本且未选择特定版本,默认选中最新
if (versionSelect && data.latestVersion) {
versionSelect.value = data.latestVersion;
}
if (!silent) { if (!silent) {
showToast(t('common.info'), t('dashboard.update.hasUpdate', { version: data.latestVersion }), 'info'); showToast(t('common.info'), t('dashboard.update.hasUpdate', { version: data.latestVersion }), 'info');
} }
} else { } else {
if (updateBtn) updateBtn.style.display = 'none';
if (updateBadge) updateBadge.style.display = 'none'; if (updateBadge) updateBadge.style.display = 'none';
if (!silent) { if (!silent) {
showToast(t('common.info'), t('dashboard.update.upToDate'), 'success'); showToast(t('common.info'), t('dashboard.update.upToDate'), 'success');
@ -3154,10 +3186,10 @@ async function checkUpdate(silent = false) {
*/ */
async function performUpdate() { async function performUpdate() {
const updateBtn = document.getElementById('performUpdateBtn'); const updateBtn = document.getElementById('performUpdateBtn');
const latestVersionText = document.getElementById('latestVersionText'); const versionSelect = document.getElementById('versionSelect');
const version = latestVersionText?.textContent || ''; const selectedVersion = versionSelect?.value || '';
if (!confirm(t('dashboard.update.confirmMsg', { version }))) { if (!confirm(t('dashboard.update.confirmMsg', { version: selectedVersion }))) {
return; return;
} }
@ -3173,7 +3205,7 @@ async function performUpdate() {
showToast(t('common.info'), t('dashboard.update.updating'), 'info'); showToast(t('common.info'), t('dashboard.update.updating'), 'info');
const data = await window.apiClient.post('/update'); const data = await window.apiClient.post('/update', { version: selectedVersion });
if (data.success) { if (data.success) {
if (data.updated) { if (data.updated) {
@ -3183,8 +3215,8 @@ async function performUpdate() {
// 自动重启服务 // 自动重启服务
await restartServiceAfterUpdate(); await restartServiceAfterUpdate();
} else { } else {
// 已是最新版本 // 已是目标版本
showToast(t('common.info'), t('dashboard.update.upToDate'), 'info'); showToast(t('common.info'), data.message || t('dashboard.update.upToDate'), 'info');
} }
} }
} catch (error) { } catch (error) {

View file

@ -41,6 +41,11 @@
<div class="system-info-header"> <div class="system-info-header">
<h3 data-i18n="dashboard.systemInfo">系统信息</h3> <h3 data-i18n="dashboard.systemInfo">系统信息</h3>
<div class="update-controls"> <div class="update-controls">
<div id="versionSelectWrapper" style="display: none; margin-right: 8px;">
<select id="versionSelect" class="form-control select-sm" style="height: 32px; padding: 0 8px; font-size: 13px; min-width: 120px;">
<!-- Versions will be loaded here -->
</select>
</div>
<button id="checkUpdateBtn" class="btn btn-outline btn-sm" data-i18n-title="dashboard.update.checkTitle" title="检查更新"> <button id="checkUpdateBtn" class="btn btn-outline btn-sm" data-i18n-title="dashboard.update.checkTitle" title="检查更新">
<i class="fas fa-sync-alt"></i> <span data-i18n="dashboard.update.check">检查更新</span> <i class="fas fa-sync-alt"></i> <span data-i18n="dashboard.update.check">检查更新</span>
</button> </button>

View file

@ -198,9 +198,14 @@
letter-spacing: 0.05em; letter-spacing: 0.05em;
} }
.provider-badge.official { background: var(--info-bg); color: var(--info-text); } .badge-warning {
.provider-badge.oauth { background: var(--success-bg); color: var(--success-text); } background: var(--warning-bg);
.provider-badge.responses { background: var(--warning-bg); color: var(--warning-text); } color: var(--warning-text);
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
}
.routing-card-content { .routing-card-content {
padding: 1.5rem; padding: 1.5rem;