diff --git a/src/adapter.js b/src/adapter.js index 368964f..8bff212 100644 --- a/src/adapter.js +++ b/src/adapter.js @@ -95,6 +95,18 @@ export class GeminiApiServiceAdapter extends ApiServiceAdapter { } return Promise.resolve(); } + + /** + * 获取用量限制信息 + * @returns {Promise} 用量限制信息 + */ + async getUsageLimits() { + if (!this.geminiApiService.isInitialized) { + console.warn("geminiApiService not initialized, attempting to re-initialize..."); + await this.geminiApiService.initialize(); + } + return this.geminiApiService.getUsageLimits(); + } } // Antigravity API 服务适配器 @@ -135,6 +147,18 @@ export class AntigravityApiServiceAdapter extends ApiServiceAdapter { } return Promise.resolve(); } + + /** + * 获取用量限制信息 + * @returns {Promise} 用量限制信息 + */ + async getUsageLimits() { + if (!this.antigravityApiService.isInitialized) { + console.warn("antigravityApiService not initialized, attempting to re-initialize..."); + await this.antigravityApiService.initialize(); + } + return this.antigravityApiService.getUsageLimits(); + } } // OpenAI API 服务适配器 @@ -270,6 +294,18 @@ export class KiroApiServiceAdapter extends ApiServiceAdapter { } return Promise.resolve(); } + + /** + * 获取用量限制信息 + * @returns {Promise} 用量限制信息 + */ + async getUsageLimits() { + if (!this.kiroApiService.isInitialized) { + console.warn("kiroApiService not initialized, attempting to re-initialize..."); + await this.kiroApiService.initialize(); + } + return this.kiroApiService.getUsageLimits(); + } } // Qwen API 服务适配器 diff --git a/src/claude/claude-kiro.js b/src/claude/claude-kiro.js index 5843c6a..070ee26 100644 --- a/src/claude/claude-kiro.js +++ b/src/claude/claude-kiro.js @@ -8,15 +8,18 @@ import * as http from 'http'; import * as https from 'https'; import { getProviderModels } from '../provider-models.js'; import { countTokens } from '@anthropic-ai/tokenizer'; +import { json } from 'stream/consumers'; const KIRO_CONSTANTS = { REFRESH_URL: 'https://prod.{{region}}.auth.desktop.kiro.dev/refreshToken', REFRESH_IDC_URL: 'https://oidc.{{region}}.amazonaws.com/token', BASE_URL: 'https://codewhisperer.{{region}}.amazonaws.com/generateAssistantResponse', AMAZON_Q_URL: 'https://codewhisperer.{{region}}.amazonaws.com/SendMessageStreaming', + USAGE_LIMITS_URL: 'https://q.{{region}}.amazonaws.com/getUsageLimits', DEFAULT_MODEL_NAME: 'claude-opus-4-5', AXIOS_TIMEOUT: 120000, // 2 minutes timeout USER_AGENT: 'KiroIDE', + KIRO_VERSION: '0.7.5', CONTENT_TYPE_JSON: 'application/json', ACCEPT_JSON: 'application/json', AUTH_METHOD_SOCIAL: 'social', @@ -303,6 +306,7 @@ export class KiroApiService { console.log('[Kiro] Initializing Kiro API Service...'); await this.initializeAuth(); const macSha256 = await getMacAddressSha256(); + const kiroVersion = KIRO_CONSTANTS.KIRO_VERSION; // 配置 HTTP/HTTPS agent 限制连接池大小,避免资源泄漏 const httpAgent = new http.Agent({ keepAlive: true, @@ -323,11 +327,11 @@ export class KiroApiService { httpsAgent, headers: { 'Content-Type': KIRO_CONSTANTS.CONTENT_TYPE_JSON, - 'x-amz-user-agent': `aws-sdk-js/1.0.7 KiroIDE-0.1.25-${macSha256}`, - 'user-agent': `aws-sdk-js/1.0.7 ua/2.1 os/win32#10.0.26100 lang/js md/nodejs#20.16.0 api/codewhispererstreaming#1.0.7 m/E KiroIDE-0.1.25-${macSha256}`, + 'Accept': KIRO_CONSTANTS.ACCEPT_JSON, 'amz-sdk-request': 'attempt=1; max=1', 'x-amzn-kiro-agent-mode': 'vibe', - 'Accept': KIRO_CONSTANTS.ACCEPT_JSON, + 'x-amz-user-agent': `aws-sdk-js/1.0.0 KiroIDE-${kiroVersion}-${macSha256}`, + 'user-agent': `aws-sdk-js/1.0.0 ua/2.1 os/win32#10.0.26100 lang/js md/nodejs#22.21.1 api/codewhispererruntime#1.0.0 m/N,E KiroIDE-${kiroVersion}-${macSha256}` }, }; @@ -1739,4 +1743,64 @@ async initializeAuth(forceRefresh = false) { return false; // Treat as expired if parsing fails } } + + /** + * 获取用量限制信息 + * @returns {Promise} 用量限制信息 + */ + async getUsageLimits() { + if (!this.isInitialized) await this.initialize(); + + // 检查 token 是否即将过期,如果是则先刷新 + if (this.isExpiryDateNear()) { + console.log('[Kiro] Token is near expiry, refreshing before getUsageLimits request...'); + await this.initializeAuth(true); + } + + // 内部固定的资源类型 + const resourceType = 'AGENTIC_REQUEST'; + + // 构建请求 URL + const usageLimitsUrl = KIRO_CONSTANTS.USAGE_LIMITS_URL.replace('{{region}}', this.region); + const params = new URLSearchParams({ + isEmailRequired: 'true', + origin: KIRO_CONSTANTS.ORIGIN_AI_EDITOR, + resourceType: resourceType + }); + if (this.authMethod === KIRO_CONSTANTS.AUTH_METHOD_SOCIAL) { + params.append('profileArn', this.profileArn); + } + const fullUrl = `${usageLimitsUrl}?${params.toString()}`; + + // 构建请求头 + const headers = { + 'amz-sdk-invocation-id': uuidv4(), + 'Authorization': `Bearer ${this.accessToken}`, + }; + + try { + const response = await this.axiosInstance.get(fullUrl, { headers }); + console.log('[Kiro] Usage limits fetched successfully'); + return response.data; + } catch (error) { + // 如果是 403 错误,尝试刷新 token 后重试 + if (error.response?.status === 403) { + console.log('[Kiro] Received 403 on getUsageLimits. Attempting token refresh and retrying...'); + try { + await this.initializeAuth(true); + // 更新 Authorization header + headers['Authorization'] = `Bearer ${this.accessToken}`; + headers['amz-sdk-invocation-id'] = uuidv4(); + const retryResponse = await this.axiosInstance.get(fullUrl, { headers }); + console.log('[Kiro] Usage limits fetched successfully after token refresh'); + return retryResponse.data; + } catch (refreshError) { + console.error('[Kiro] Token refresh failed during getUsageLimits retry:', refreshError.message); + throw refreshError; + } + } + console.error('[Kiro] Failed to fetch usage limits:', error.message); + throw error; + } + } } diff --git a/src/gemini/antigravity-core.js b/src/gemini/antigravity-core.js index 0c10915..1ed2804 100644 --- a/src/gemini/antigravity-core.js +++ b/src/gemini/antigravity-core.js @@ -7,7 +7,7 @@ import * as os from 'os'; import * as readline from 'readline'; import { v4 as uuidv4 } from 'uuid'; import open from 'open'; -import { API_ACTIONS, formatExpiryTime } from '../common.js'; +import { formatExpiryTime } from '../common.js'; import { getProviderModels } from '../provider-models.js'; // 配置 HTTP/HTTPS agent 限制连接池大小,避免资源泄漏 @@ -45,7 +45,8 @@ const MODEL_ALIAS_MAP = { 'gemini-3-pro-image-preview': 'gemini-3-pro-image', 'gemini-3-pro-preview': 'gemini-3-pro-high', 'gemini-claude-sonnet-4-5': 'claude-sonnet-4-5', - 'gemini-claude-sonnet-4-5-thinking': 'claude-sonnet-4-5-thinking' + 'gemini-claude-sonnet-4-5-thinking': 'claude-sonnet-4-5-thinking', + 'gemini-claude-opus-4-5-thinking': 'claude-opus-4-5-thinking' }; const MODEL_NAME_MAP = { @@ -53,27 +54,22 @@ const MODEL_NAME_MAP = { 'gemini-3-pro-image': 'gemini-3-pro-image-preview', 'gemini-3-pro-high': 'gemini-3-pro-preview', 'claude-sonnet-4-5': 'gemini-claude-sonnet-4-5', - 'claude-sonnet-4-5-thinking': 'gemini-claude-sonnet-4-5-thinking' + 'claude-sonnet-4-5-thinking': 'gemini-claude-sonnet-4-5-thinking', + 'claude-opus-4-5-thinking': 'gemini-claude-opus-4-5-thinking' }; -// 不支持的模型列表 -const UNSUPPORTED_MODELS = ['chat_20706', 'chat_23310', 'gemini-2.5-flash-thinking', 'gemini-3-pro-low', 'gemini-2.5-pro']; - /** * 将别名转换为真实模型名 */ function alias2ModelName(modelName) { - return MODEL_ALIAS_MAP[modelName] || modelName; + return MODEL_ALIAS_MAP[modelName]; } /** * 将真实模型名转换为别名 */ function modelName2Alias(modelName) { - if (UNSUPPORTED_MODELS.includes(modelName)) { - return ''; - } - return MODEL_NAME_MAP[modelName] || modelName; + return MODEL_NAME_MAP[modelName]; } /** @@ -152,8 +148,8 @@ function geminiToAntigravity(modelName, payload, projectId) { } } - // 处理 Claude Sonnet 模型的工具声明 - if (modelName.startsWith('claude-sonnet-')) { + // 处理 Claude 模型的工具声明 (包括 sonnet 和 opus) + if (modelName.startsWith('claude-sonnet-') || modelName.startsWith('claude-opus-')) { if (template.request.tools && Array.isArray(template.request.tools)) { template.request.tools.forEach(tool => { if (tool.functionDeclarations && Array.isArray(tool.functionDeclarations)) { @@ -499,7 +495,7 @@ export class AntigravityApiService { const models = Object.keys(res.data.models); this.availableModels = models .map(modelName2Alias) - .filter(alias => alias !== ''); + .filter(alias => alias !== undefined && alias !== '' && alias !== null); console.log(`[Antigravity] Available models: [${this.availableModels.join(', ')}]`); return; @@ -777,4 +773,102 @@ export class AntigravityApiService { return false; } } + + /** + * 获取模型配额信息 + * @returns {Promise} 模型配额信息 + */ + async getUsageLimits() { + if (!this.isInitialized) await this.initialize(); + + // 检查 token 是否即将过期,如果是则先刷新 + if (this.isExpiryDateNear()) { + console.log('[Antigravity] Token is near expiry, refreshing before getUsageLimits request...'); + await this.initializeAuth(true); + } + + try { + const modelsWithQuotas = await this.getModelsWithQuotas(); + return modelsWithQuotas; + } catch (error) { + console.error('[Antigravity] Failed to get usage limits:', error.message); + throw error; + } + } + + /** + * 获取带配额信息的模型列表 + * @returns {Promise} 模型配额信息 + */ + async getModelsWithQuotas() { + try { + // 解析模型配额信息 + const result = { + lastUpdated: Date.now(), + models: {} + }; + + // 调用 fetchAvailableModels 接口获取模型和配额信息 + for (const baseURL of this.baseURLs) { + try { + const modelsURL = `${baseURL}/${ANTIGRAVITY_API_VERSION}:fetchAvailableModels`; + const requestOptions = { + url: modelsURL, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': this.userAgent + }, + responseType: 'json', + body: JSON.stringify({}) + }; + + const res = await this.authClient.request(requestOptions); + console.log(`[Antigravity] fetchAvailableModels success`); + if (res.data && res.data.models) { + const modelsData = res.data.models; + + // 遍历模型数据,提取配额信息 + for (const [modelId, modelData] of Object.entries(modelsData)) { + const aliasName = modelName2Alias(modelId); + if (aliasName == null ||aliasName === '') continue; // 跳过不支持的模型 + + const modelInfo = { + remaining: 0, + resetTime: null, + resetTimeRaw: null + }; + + // 从 quotaInfo 中提取配额信息 + if (modelData.quotaInfo) { + modelInfo.remaining = modelData.quotaInfo.remainingFraction || modelData.quotaInfo.remaining || 0; + modelInfo.resetTime = modelData.quotaInfo.resetTime || null; + modelInfo.resetTimeRaw = modelData.quotaInfo.resetTime; + } + + result.models[aliasName] = modelInfo; + } + + // 对模型按名称排序 + const sortedModels = {}; + Object.keys(result.models).sort().forEach(key => { + sortedModels[key] = result.models[key]; + }); + result.models = sortedModels; + // console.log(`[Antigravity] Sorted Models:`, sortedModels); + console.log(`[Antigravity] Successfully fetched quotas for ${Object.keys(result.models).length} models`); + break; // 成功获取后退出循环 + } + } catch (error) { + console.error(`[Antigravity] Failed to fetch models with quotas from ${baseURL}:`, error.message); + } + } + + return result; + } catch (error) { + console.error('[Antigravity] Failed to get models with quotas:', error.message); + throw error; + } + } + } \ No newline at end of file diff --git a/src/gemini/gemini-core.js b/src/gemini/gemini-core.js index fa0adf0..2b15ee9 100644 --- a/src/gemini/gemini-core.js +++ b/src/gemini/gemini-core.js @@ -592,4 +592,104 @@ export class GeminiApiService { return false; } } + + /** + * 获取模型配额信息 + * @returns {Promise} 模型配额信息 + */ + async getUsageLimits() { + if (!this.isInitialized) await this.initialize(); + + // 检查 token 是否即将过期,如果是则先刷新 + if (this.isExpiryDateNear()) { + console.log('[Gemini] Token is near expiry, refreshing before getUsageLimits request...'); + await this.initializeAuth(true); + } + + try { + const modelsWithQuotas = await this.getModelsWithQuotas(); + return modelsWithQuotas; + } catch (error) { + console.error('[Gemini] Failed to get usage limits:', error.message); + throw error; + } + } + + /** + * 获取带配额信息的模型列表 + * @returns {Promise} 模型配额信息 + */ + async getModelsWithQuotas() { + try { + // 解析模型配额信息 + const result = { + lastUpdated: Date.now(), + models: {} + }; + + // 调用 retrieveUserQuota 接口获取用户配额信息 + try { + const quotaURL = `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:retrieveUserQuota`; + const requestBody = { + project: `projects/${this.projectId}` + }; + const requestOptions = { + url: quotaURL, + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + responseType: 'json', + body: JSON.stringify(requestBody) + }; + + const res = await this.authClient.request(requestOptions); + console.log(`[Gemini] retrieveUserQuota success`); + if (res.data && res.data.buckets) { + const buckets = res.data.buckets; + + // 遍历 buckets 数组,提取配额信息 + for (const bucket of buckets) { + const modelId = bucket.modelId; + + // 检查模型是否在支持的模型列表中 + if (!GEMINI_MODELS.includes(modelId)) continue; + + const modelInfo = { + remaining: bucket.remainingFraction || 0, + resetTime: bucket.resetTime || null, + resetTimeRaw: bucket.resetTime + }; + + result.models[modelId] = modelInfo; + } + + // 对模型按名称排序 + const sortedModels = {}; + Object.keys(result.models).sort().forEach(key => { + sortedModels[key] = result.models[key]; + }); + result.models = sortedModels; + // console.log(`[Gemini] Sorted Models:`, sortedModels); + console.log(`[Gemini] Successfully fetched quotas for ${Object.keys(result.models).length} models`); + } + } catch (fetchError) { + console.error(`[Gemini] Failed to fetch user quota:`, fetchError.message); + + // 如果 retrieveUserQuota 失败,回退到使用固定模型列表 + for (const modelId of GEMINI_MODELS) { + result.models[modelId] = { + remaining: 0, + resetTime: null, + resetTimeRaw: null + }; + } + } + + return result; + } catch (error) { + console.error('[Gemini] Failed to get models with quotas:', error.message); + throw error; + } + } } diff --git a/src/provider-models.js b/src/provider-models.js index 4f3ed77..d046b7d 100644 --- a/src/provider-models.js +++ b/src/provider-models.js @@ -18,7 +18,7 @@ export const PROVIDER_MODELS = { 'gemini-3-pro-preview', 'gemini-claude-sonnet-4-5', 'gemini-claude-sonnet-4-5-thinking', - 'gemini-2.5-flash' + 'gemini-claude-opus-4-5-thinking' ], 'claude-custom': [], 'claude-kiro-oauth': [ diff --git a/src/ui-manager.js b/src/ui-manager.js index 2f7276a..50fcd33 100644 --- a/src/ui-manager.js +++ b/src/ui-manager.js @@ -6,7 +6,7 @@ import crypto from 'crypto'; import { getRequestBody } from './common.js'; import { getAllProviderModels, getProviderModels } from './provider-models.js'; import { CONFIG } from './config-manager.js'; -import { serviceInstances } from './adapter.js'; +import { serviceInstances, getServiceAdapter } from './adapter.js'; import { initApiService } from './service-manager.js'; import { handleGeminiCliOAuth, handleGeminiAntigravityOAuth, handleQwenOAuth } from './oauth-handlers.js'; import { @@ -21,10 +21,79 @@ import { addToUsedPaths, formatSystemPath } from './provider-utils.js'; +import { formatKiroUsage, formatGeminiUsage, formatAntigravityUsage } from './usage-service.js'; // Token存储到本地文件中 const TOKEN_STORE_FILE = 'token-store.json'; +// 用量缓存文件路径 +const USAGE_CACHE_FILE = 'usage-cache.json'; + +/** + * 读取用量缓存文件 + * @returns {Promise} 缓存的用量数据,如果不存在或读取失败则返回 null + */ +async function readUsageCache() { + try { + if (existsSync(USAGE_CACHE_FILE)) { + const content = await fs.readFile(USAGE_CACHE_FILE, 'utf8'); + return JSON.parse(content); + } + return null; + } catch (error) { + console.warn('[Usage Cache] Failed to read usage cache:', error.message); + return null; + } +} + +/** + * 写入用量缓存文件 + * @param {Object} usageData - 用量数据 + */ +async function writeUsageCache(usageData) { + try { + await fs.writeFile(USAGE_CACHE_FILE, JSON.stringify(usageData, null, 2), 'utf8'); + console.log('[Usage Cache] Usage data cached to', USAGE_CACHE_FILE); + } catch (error) { + console.error('[Usage Cache] Failed to write usage cache:', error.message); + } +} + +/** + * 读取特定提供商类型的用量缓存 + * @param {string} providerType - 提供商类型 + * @returns {Promise} 缓存的用量数据 + */ +async function readProviderUsageCache(providerType) { + const cache = await readUsageCache(); + if (cache && cache.providers && cache.providers[providerType]) { + return { + ...cache.providers[providerType], + cachedAt: cache.timestamp, + fromCache: true + }; + } + return null; +} + +/** + * 更新特定提供商类型的用量缓存 + * @param {string} providerType - 提供商类型 + * @param {Object} usageData - 用量数据 + */ +async function updateProviderUsageCache(providerType, usageData) { + let cache = await readUsageCache(); + if (!cache) { + cache = { + timestamp: new Date().toISOString(), + providers: {} + }; + } + cache.providers[providerType] = usageData; + cache.timestamp = new Date().toISOString(); + await writeUsageCache(cache); +} + /** * 读取token存储文件 */ @@ -1588,6 +1657,90 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo } } + // Get usage limits for all providers + if (method === 'GET' && pathParam === '/api/usage') { + try { + // 解析查询参数,检查是否需要强制刷新 + const url = new URL(req.url, `http://${req.headers.host}`); + const refresh = url.searchParams.get('refresh') === 'true'; + + let usageResults; + + if (!refresh) { + // 优先读取缓存 + const cachedData = await readUsageCache(); + if (cachedData) { + console.log('[Usage API] Returning cached usage data'); + usageResults = { ...cachedData, fromCache: true }; + } + } + + if (!usageResults) { + // 缓存不存在或需要刷新,重新查询 + console.log('[Usage API] Fetching fresh usage data'); + usageResults = await getAllProvidersUsage(currentConfig, providerPoolManager); + // 写入缓存 + await writeUsageCache(usageResults); + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(usageResults)); + return true; + } catch (error) { + console.error('[UI API] Failed to get usage:', error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: '获取用量信息失败: ' + error.message + } + })); + return true; + } + } + + // Get usage limits for a specific provider type + const usageProviderMatch = pathParam.match(/^\/api\/usage\/([^\/]+)$/); + if (method === 'GET' && usageProviderMatch) { + const providerType = decodeURIComponent(usageProviderMatch[1]); + try { + // 解析查询参数,检查是否需要强制刷新 + const url = new URL(req.url, `http://${req.headers.host}`); + const refresh = url.searchParams.get('refresh') === 'true'; + + let usageResults; + + if (!refresh) { + // 优先读取缓存 + const cachedData = await readProviderUsageCache(providerType); + if (cachedData) { + console.log(`[Usage API] Returning cached usage data for ${providerType}`); + usageResults = cachedData; + } + } + + if (!usageResults) { + // 缓存不存在或需要刷新,重新查询 + console.log(`[Usage API] Fetching fresh usage data for ${providerType}`); + usageResults = await getProviderTypeUsage(providerType, currentConfig, providerPoolManager); + // 更新缓存 + await updateProviderUsageCache(providerType, usageResults); + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(usageResults)); + return true; + } catch (error) { + console.error(`[UI API] Failed to get usage for ${providerType}:`, error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: `获取 ${providerType} 用量信息失败: ` + error.message + } + })); + return true; + } + } + // Reload configuration files if (method === 'POST' && pathParam === '/api/reload-config') { try { @@ -2006,3 +2159,196 @@ async function scanOAuthDirectory(dirPath, usedPaths, currentConfig) { // 注意:normalizePath, getFileName, pathsEqual, isPathUsed, detectProviderFromPath // 已移至 provider-utils.js 公共模块 + +/** + * 获取所有支持用量查询的提供商的用量信息 + * @param {Object} currentConfig - 当前配置 + * @param {Object} providerPoolManager - 提供商池管理器 + * @returns {Promise} 所有提供商的用量信息 + */ +async function getAllProvidersUsage(currentConfig, providerPoolManager) { + const results = { + timestamp: new Date().toISOString(), + providers: {} + }; + + // 支持用量查询的提供商列表 + const supportedProviders = ['claude-kiro-oauth', 'gemini-cli-oauth', 'gemini-antigravity']; + + // 并发获取所有提供商的用量数据 + const usagePromises = supportedProviders.map(async (providerType) => { + try { + const providerUsage = await getProviderTypeUsage(providerType, currentConfig, providerPoolManager); + return { providerType, data: providerUsage, success: true }; + } catch (error) { + return { + providerType, + data: { + error: error.message, + instances: [] + }, + success: false + }; + } + }); + + // 等待所有并发请求完成 + const usageResults = await Promise.all(usagePromises); + + // 将结果整合到 results.providers 中 + for (const result of usageResults) { + results.providers[result.providerType] = result.data; + } + + return results; +} + +/** + * 获取指定提供商类型的用量信息 + * @param {string} providerType - 提供商类型 + * @param {Object} currentConfig - 当前配置 + * @param {Object} providerPoolManager - 提供商池管理器 + * @returns {Promise} 提供商用量信息 + */ +async function getProviderTypeUsage(providerType, currentConfig, providerPoolManager) { + const result = { + providerType, + instances: [], + totalCount: 0, + successCount: 0, + errorCount: 0 + }; + + // 获取提供商池中的所有实例 + let providers = []; + if (providerPoolManager && providerPoolManager.providerPools && providerPoolManager.providerPools[providerType]) { + providers = providerPoolManager.providerPools[providerType]; + } else if (currentConfig.providerPools && currentConfig.providerPools[providerType]) { + providers = currentConfig.providerPools[providerType]; + } + + result.totalCount = providers.length; + + // 遍历所有提供商实例获取用量 + for (const provider of providers) { + const providerKey = providerType + (provider.uuid || ''); + let adapter = serviceInstances[providerKey]; + + const instanceResult = { + uuid: provider.uuid || 'unknown', + name: getProviderDisplayName(provider, providerType), + isHealthy: provider.isHealthy !== false, + isDisabled: provider.isDisabled === true, + success: false, + usage: null, + error: null + }; + + // 首先检查是否已禁用,已禁用的提供商跳过初始化 + if (provider.isDisabled) { + instanceResult.error = '提供商已禁用'; + result.errorCount++; + } else if (!adapter) { + // 服务实例未初始化,尝试自动初始化 + try { + console.log(`[Usage API] Auto-initializing service adapter for ${providerType}: ${provider.uuid}`); + // 构建配置对象 + const serviceConfig = { + ...CONFIG, + ...provider, + MODEL_PROVIDER: providerType + }; + adapter = getServiceAdapter(serviceConfig); + } catch (initError) { + console.error(`[Usage API] Failed to initialize adapter for ${providerType}: ${provider.uuid}:`, initError.message); + instanceResult.error = `服务实例初始化失败: ${initError.message}`; + result.errorCount++; + } + } + + // 如果适配器存在(包括刚初始化的),且没有错误,尝试获取用量 + if (adapter && !instanceResult.error) { + try { + const usage = await getAdapterUsage(adapter, providerType); + instanceResult.success = true; + instanceResult.usage = usage; + result.successCount++; + } catch (error) { + instanceResult.error = error.message; + result.errorCount++; + } + } + + result.instances.push(instanceResult); + } + + return result; +} + +/** + * 从适配器获取用量信息 + * @param {Object} adapter - 服务适配器 + * @param {string} providerType - 提供商类型 + * @returns {Promise} 用量信息 + */ +async function getAdapterUsage(adapter, providerType) { + if (providerType === 'claude-kiro-oauth') { + if (typeof adapter.getUsageLimits === 'function') { + const rawUsage = await adapter.getUsageLimits(); + return formatKiroUsage(rawUsage); + } else if (adapter.kiroApiService && typeof adapter.kiroApiService.getUsageLimits === 'function') { + const rawUsage = await adapter.kiroApiService.getUsageLimits(); + return formatKiroUsage(rawUsage); + } + throw new Error('该适配器不支持用量查询'); + } + + if (providerType === 'gemini-cli-oauth') { + if (typeof adapter.getUsageLimits === 'function') { + const rawUsage = await adapter.getUsageLimits(); + return formatGeminiUsage(rawUsage); + } else if (adapter.geminiApiService && typeof adapter.geminiApiService.getUsageLimits === 'function') { + const rawUsage = await adapter.geminiApiService.getUsageLimits(); + return formatGeminiUsage(rawUsage); + } + throw new Error('该适配器不支持用量查询'); + } + + if (providerType === 'gemini-antigravity') { + if (typeof adapter.getUsageLimits === 'function') { + const rawUsage = await adapter.getUsageLimits(); + return formatAntigravityUsage(rawUsage); + } else if (adapter.antigravityApiService && typeof adapter.antigravityApiService.getUsageLimits === 'function') { + const rawUsage = await adapter.antigravityApiService.getUsageLimits(); + return formatAntigravityUsage(rawUsage); + } + throw new Error('该适配器不支持用量查询'); + } + + throw new Error(`不支持的提供商类型: ${providerType}`); +} + +/** + * 获取提供商显示名称 + * @param {Object} provider - 提供商配置 + * @param {string} providerType - 提供商类型 + * @returns {string} 显示名称 + */ +function getProviderDisplayName(provider, providerType) { + // 尝试从凭据文件路径提取名称 + const credPathKey = { + 'claude-kiro-oauth': 'KIRO_OAUTH_CREDS_FILE_PATH', + 'gemini-cli-oauth': 'GEMINI_OAUTH_CREDS_FILE_PATH', + 'gemini-antigravity': 'ANTIGRAVITY_OAUTH_CREDS_FILE_PATH', + 'openai-qwen-oauth': 'QWEN_OAUTH_CREDS_FILE_PATH' + }[providerType]; + + if (credPathKey && provider[credPathKey]) { + const filePath = provider[credPathKey]; + const fileName = path.basename(filePath); + const dirName = path.basename(path.dirname(filePath)); + return `${dirName}/${fileName}`; + } + + return provider.uuid || '未命名'; +} diff --git a/src/usage-service.js b/src/usage-service.js new file mode 100644 index 0000000..13dee90 --- /dev/null +++ b/src/usage-service.js @@ -0,0 +1,526 @@ +/** + * 用量查询服务 + * 用于处理各个提供商的授权文件用量查询 + */ + +import { getProviderPoolManager } from './service-manager.js'; +import { serviceInstances } from './adapter.js'; +import { MODEL_PROVIDER } from './common.js'; + +/** + * 用量查询服务类 + * 提供统一的接口来查询各提供商的用量信息 + */ +export class UsageService { + constructor() { + this.providerHandlers = { + [MODEL_PROVIDER.KIRO_API]: this.getKiroUsage.bind(this), + [MODEL_PROVIDER.GEMINI_CLI]: this.getGeminiUsage.bind(this), + [MODEL_PROVIDER.ANTIGRAVITY]: this.getAntigravityUsage.bind(this), + }; + } + + /** + * 获取指定提供商的用量信息 + * @param {string} providerType - 提供商类型 + * @param {string} [uuid] - 可选的提供商实例 UUID + * @returns {Promise} 用量信息 + */ + async getUsage(providerType, uuid = null) { + const handler = this.providerHandlers[providerType]; + if (!handler) { + throw new Error(`不支持的提供商类型: ${providerType}`); + } + return handler(uuid); + } + + /** + * 获取所有提供商的用量信息 + * @returns {Promise} 所有提供商的用量信息 + */ + async getAllUsage() { + const results = {}; + const poolManager = getProviderPoolManager(); + + for (const [providerType, handler] of Object.entries(this.providerHandlers)) { + try { + // 检查是否有号池配置 + if (poolManager) { + const pools = poolManager.getProviderPools(providerType); + if (pools && pools.length > 0) { + results[providerType] = []; + for (const pool of pools) { + try { + const usage = await handler(pool.uuid); + results[providerType].push({ + uuid: pool.uuid, + usage + }); + } catch (error) { + results[providerType].push({ + uuid: pool.uuid, + error: error.message + }); + } + } + } + } + + // 如果没有号池配置,尝试获取单个实例的用量 + if (!results[providerType] || results[providerType].length === 0) { + const usage = await handler(null); + results[providerType] = [{ uuid: 'default', usage }]; + } + } catch (error) { + results[providerType] = [{ uuid: 'default', error: error.message }]; + } + } + + return results; + } + + /** + * 获取 Kiro 提供商的用量信息 + * @param {string} [uuid] - 可选的提供商实例 UUID + * @returns {Promise} Kiro 用量信息 + */ + async getKiroUsage(uuid = null) { + const providerKey = uuid ? MODEL_PROVIDER.KIRO_API + uuid : MODEL_PROVIDER.KIRO_API; + const adapter = serviceInstances[providerKey]; + + if (!adapter) { + throw new Error(`Kiro 服务实例未找到: ${providerKey}`); + } + + // 使用适配器的 getUsageLimits 方法 + if (typeof adapter.getUsageLimits === 'function') { + return adapter.getUsageLimits(); + } + + // 兼容直接访问 kiroApiService 的情况 + if (adapter.kiroApiService && typeof adapter.kiroApiService.getUsageLimits === 'function') { + return adapter.kiroApiService.getUsageLimits(); + } + + throw new Error(`Kiro 服务实例不支持用量查询: ${providerKey}`); + } + + /** + * 获取 Gemini CLI 提供商的用量信息 + * @param {string} [uuid] - 可选的提供商实例 UUID + * @returns {Promise} Gemini 用量信息 + */ + async getGeminiUsage(uuid = null) { + const providerKey = uuid ? MODEL_PROVIDER.GEMINI_CLI + uuid : MODEL_PROVIDER.GEMINI_CLI; + const adapter = serviceInstances[providerKey]; + + if (!adapter) { + throw new Error(`Gemini CLI 服务实例未找到: ${providerKey}`); + } + + // 使用适配器的 getUsageLimits 方法 + if (typeof adapter.getUsageLimits === 'function') { + return adapter.getUsageLimits(); + } + + // 兼容直接访问 geminiApiService 的情况 + if (adapter.geminiApiService && typeof adapter.geminiApiService.getUsageLimits === 'function') { + return adapter.geminiApiService.getUsageLimits(); + } + + throw new Error(`Gemini CLI 服务实例不支持用量查询: ${providerKey}`); + } + + /** + * 获取 Antigravity 提供商的用量信息 + * @param {string} [uuid] - 可选的提供商实例 UUID + * @returns {Promise} Antigravity 用量信息 + */ + async getAntigravityUsage(uuid = null) { + const providerKey = uuid ? MODEL_PROVIDER.ANTIGRAVITY + uuid : MODEL_PROVIDER.ANTIGRAVITY; + const adapter = serviceInstances[providerKey]; + + if (!adapter) { + throw new Error(`Antigravity 服务实例未找到: ${providerKey}`); + } + + // 使用适配器的 getUsageLimits 方法 + if (typeof adapter.getUsageLimits === 'function') { + return adapter.getUsageLimits(); + } + + // 兼容直接访问 antigravityApiService 的情况 + if (adapter.antigravityApiService && typeof adapter.antigravityApiService.getUsageLimits === 'function') { + return adapter.antigravityApiService.getUsageLimits(); + } + + throw new Error(`Antigravity 服务实例不支持用量查询: ${providerKey}`); + } + + /** + * 获取支持用量查询的提供商列表 + * @returns {Array} 支持的提供商类型列表 + */ + getSupportedProviders() { + return Object.keys(this.providerHandlers); + } +} + +// 导出单例实例 +export const usageService = new UsageService(); + +/** + * 格式化 Kiro 用量信息为易读格式 + * @param {Object} usageData - 原始用量数据 + * @returns {Object} 格式化后的用量信息 + */ +export function formatKiroUsage(usageData) { + if (!usageData) { + return null; + } + + const result = { + // 基本信息 + daysUntilReset: usageData.daysUntilReset, + nextDateReset: usageData.nextDateReset ? new Date(usageData.nextDateReset * 1000).toISOString() : null, + + // 订阅信息 + subscription: null, + + // 用户信息 + user: null, + + // 用量明细 + usageBreakdown: [] + }; + + // 解析订阅信息 + if (usageData.subscriptionInfo) { + result.subscription = { + title: usageData.subscriptionInfo.subscriptionTitle, + type: usageData.subscriptionInfo.type, + upgradeCapability: usageData.subscriptionInfo.upgradeCapability, + overageCapability: usageData.subscriptionInfo.overageCapability + }; + } + + // 解析用户信息 + if (usageData.userInfo) { + result.user = { + email: usageData.userInfo.email, + userId: usageData.userInfo.userId + }; + } + + // 解析用量明细 + if (usageData.usageBreakdownList && Array.isArray(usageData.usageBreakdownList)) { + for (const breakdown of usageData.usageBreakdownList) { + const item = { + resourceType: breakdown.resourceType, + displayName: breakdown.displayName, + displayNamePlural: breakdown.displayNamePlural, + unit: breakdown.unit, + currency: breakdown.currency, + + // 当前用量 + currentUsage: breakdown.currentUsageWithPrecision ?? breakdown.currentUsage, + usageLimit: breakdown.usageLimitWithPrecision ?? breakdown.usageLimit, + + // 超额信息 + currentOverages: breakdown.currentOveragesWithPrecision ?? breakdown.currentOverages, + overageCap: breakdown.overageCapWithPrecision ?? breakdown.overageCap, + overageRate: breakdown.overageRate, + overageCharges: breakdown.overageCharges, + + // 下次重置时间 + nextDateReset: breakdown.nextDateReset ? new Date(breakdown.nextDateReset * 1000).toISOString() : null, + + // 免费试用信息 + freeTrial: null, + + // 奖励信息 + bonuses: [] + }; + + // 解析免费试用信息 + if (breakdown.freeTrialInfo) { + item.freeTrial = { + status: breakdown.freeTrialInfo.freeTrialStatus, + currentUsage: breakdown.freeTrialInfo.currentUsageWithPrecision ?? breakdown.freeTrialInfo.currentUsage, + usageLimit: breakdown.freeTrialInfo.usageLimitWithPrecision ?? breakdown.freeTrialInfo.usageLimit, + expiresAt: breakdown.freeTrialInfo.freeTrialExpiry + ? new Date(breakdown.freeTrialInfo.freeTrialExpiry * 1000).toISOString() + : null + }; + } + + // 解析奖励信息 + if (breakdown.bonuses && Array.isArray(breakdown.bonuses)) { + for (const bonus of breakdown.bonuses) { + item.bonuses.push({ + code: bonus.bonusCode, + displayName: bonus.displayName, + description: bonus.description, + status: bonus.status, + currentUsage: bonus.currentUsage, + usageLimit: bonus.usageLimit, + redeemedAt: bonus.redeemedAt ? new Date(bonus.redeemedAt * 1000).toISOString() : null, + expiresAt: bonus.expiresAt ? new Date(bonus.expiresAt * 1000).toISOString() : null + }); + } + } + + result.usageBreakdown.push(item); + } + } + + return result; +} + +/** + * 格式化 Gemini 用量信息为易读格式(映射到 Kiro 数据结构) + * @param {Object} usageData - 原始用量数据 + * @returns {Object} 格式化后的用量信息 + */ +export function formatGeminiUsage(usageData) { + if (!usageData) { + return null; + } + + const TZ_OFFSET = 8 * 60 * 60 * 1000; // Beijing timezone offset + + /** + * 将 UTC 时间转换为北京时间 + * @param {string} utcString - UTC 时间字符串 + * @returns {string} 北京时间字符串 + */ + function utcToBeijing(utcString) { + try { + if (!utcString) return '--'; + const utcDate = new Date(utcString); + const beijingTime = new Date(utcDate.getTime() + TZ_OFFSET); + return beijingTime + .toLocaleString('zh-CN', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }) + .replace(/\//g, '-'); + } catch (e) { + return '--'; + } + } + + const result = { + // 基本信息 - 映射到 Kiro 结构 + daysUntilReset: null, + nextDateReset: null, + + // 订阅信息 + subscription: { + title: 'Gemini CLI OAuth', + type: 'gemini-cli-oauth', + upgradeCapability: null, + overageCapability: null + }, + + // 用户信息 + user: { + email: null, + userId: null + }, + + // 用量明细 + usageBreakdown: [] + }; + + // 解析配额信息 + if (usageData.quotaInfo) { + result.subscription.title = usageData.quotaInfo.currentTier || 'Gemini CLI OAuth'; + if (usageData.quotaInfo.quotaResetTime) { + result.nextDateReset = usageData.quotaInfo.quotaResetTime; + // 计算距离重置的天数 + const resetDate = new Date(usageData.quotaInfo.quotaResetTime); + const now = new Date(); + const diffTime = resetDate.getTime() - now.getTime(); + result.daysUntilReset = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + } + } + + // 解析模型配额信息 + if (usageData.models && typeof usageData.models === 'object') { + for (const [modelName, modelInfo] of Object.entries(usageData.models)) { + // Gemini 返回的数据结构:{ remaining, resetTime, resetTimeRaw } + // remaining 是 0-1 之间的比例值,表示剩余配额百分比 + const remainingPercent = typeof modelInfo.remaining === 'number' ? modelInfo.remaining : 1; + const usedPercent = 1 - remainingPercent; + + const item = { + resourceType: 'MODEL_USAGE', + displayName: modelInfo.displayName || modelName, + displayNamePlural: modelInfo.displayName || modelName, + unit: 'quota', + currency: null, + + // 当前用量 - Gemini 返回的是剩余比例,转换为已用比例(百分比形式) + currentUsage: Math.round(usedPercent * 100), + usageLimit: 100, // 以百分比表示,总量为 100% + + // 超额信息 + currentOverages: 0, + overageCap: 0, + overageRate: null, + overageCharges: 0, + + // 下次重置时间 + nextDateReset: modelInfo.resetTimeRaw ? new Date(modelInfo.resetTimeRaw).toISOString() : + (modelInfo.resetTime ? new Date(modelInfo.resetTime).toISOString() : null), + + // 免费试用信息 + freeTrial: null, + + // 奖励信息 + bonuses: [], + + // 额外的 Gemini 特有信息 + modelName: modelName, + inputTokenLimit: modelInfo.inputTokenLimit || 0, + outputTokenLimit: modelInfo.outputTokenLimit || 0, + remaining: remainingPercent, + remainingPercent: Math.round(remainingPercent * 100), // 剩余百分比 + resetTime: (modelInfo.resetTimeRaw || modelInfo.resetTime) ? + utcToBeijing(modelInfo.resetTimeRaw || modelInfo.resetTime) : '--', + resetTimeRaw: modelInfo.resetTimeRaw || modelInfo.resetTime || null + }; + + result.usageBreakdown.push(item); + } + } + + return result; +} + +/** + * 格式化 Antigravity 用量信息为易读格式(映射到 Kiro 数据结构) + * @param {Object} usageData - 原始用量数据 + * @returns {Object} 格式化后的用量信息 + */ +export function formatAntigravityUsage(usageData) { + if (!usageData) { + return null; + } + + const TZ_OFFSET = 8 * 60 * 60 * 1000; // Beijing timezone offset + + /** + * 将 UTC 时间转换为北京时间 + * @param {string} utcString - UTC 时间字符串 + * @returns {string} 北京时间字符串 + */ + function utcToBeijing(utcString) { + try { + if (!utcString) return '--'; + const utcDate = new Date(utcString); + const beijingTime = new Date(utcDate.getTime() + TZ_OFFSET); + return beijingTime + .toLocaleString('zh-CN', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }) + .replace(/\//g, '-'); + } catch (e) { + return '--'; + } + } + + const result = { + // 基本信息 - 映射到 Kiro 结构 + daysUntilReset: null, + nextDateReset: null, + + // 订阅信息 + subscription: { + title: 'Gemini Antigravity', + type: 'gemini-antigravity', + upgradeCapability: null, + overageCapability: null + }, + + // 用户信息 + user: { + email: null, + userId: null + }, + + // 用量明细 + usageBreakdown: [] + }; + + // 解析配额信息 + if (usageData.quotaInfo) { + result.subscription.title = usageData.quotaInfo.currentTier || 'Gemini Antigravity'; + if (usageData.quotaInfo.quotaResetTime) { + result.nextDateReset = usageData.quotaInfo.quotaResetTime; + // 计算距离重置的天数 + const resetDate = new Date(usageData.quotaInfo.quotaResetTime); + const now = new Date(); + const diffTime = resetDate.getTime() - now.getTime(); + result.daysUntilReset = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + } + } + + // 解析模型配额信息 + if (usageData.models && typeof usageData.models === 'object') { + for (const [modelName, modelInfo] of Object.entries(usageData.models)) { + // Antigravity 返回的数据结构:{ remaining, resetTime, resetTimeRaw } + // remaining 是 0-1 之间的比例值,表示剩余配额百分比 + const remainingPercent = typeof modelInfo.remaining === 'number' ? modelInfo.remaining : 1; + const usedPercent = 1 - remainingPercent; + + const item = { + resourceType: 'MODEL_USAGE', + displayName: modelInfo.displayName || modelName, + displayNamePlural: modelInfo.displayName || modelName, + unit: 'quota', + currency: null, + + // 当前用量 - Antigravity 返回的是剩余比例,转换为已用比例(百分比形式) + currentUsage: Math.round(usedPercent * 100), + usageLimit: 100, // 以百分比表示,总量为 100% + + // 超额信息 + currentOverages: 0, + overageCap: 0, + overageRate: null, + overageCharges: 0, + + // 下次重置时间 + nextDateReset: modelInfo.resetTimeRaw ? new Date(modelInfo.resetTimeRaw).toISOString() : + (modelInfo.resetTime ? new Date(modelInfo.resetTime).toISOString() : null), + + // 免费试用信息 + freeTrial: null, + + // 奖励信息 + bonuses: [], + + // 额外的 Antigravity 特有信息 + modelName: modelName, + inputTokenLimit: modelInfo.inputTokenLimit || 0, + outputTokenLimit: modelInfo.outputTokenLimit || 0, + remaining: remainingPercent, + remainingPercent: Math.round(remainingPercent * 100), // 剩余百分比 + resetTime: (modelInfo.resetTimeRaw || modelInfo.resetTime) ? + utcToBeijing(modelInfo.resetTimeRaw || modelInfo.resetTime) : '--', + resetTimeRaw: modelInfo.resetTimeRaw || modelInfo.resetTime || null + }; + + result.usageBreakdown.push(item); + } + } + + return result; +} \ No newline at end of file diff --git a/static/app/app.js b/static/app/app.js index 102f5ce..1342336 100644 --- a/static/app/app.js +++ b/static/app/app.js @@ -63,6 +63,11 @@ import { reloadConfig } from './upload-config-manager.js'; +import { + initUsageManager, + refreshUsage +} from './usage-manager.js'; + /** * 加载初始数据 */ @@ -96,6 +101,7 @@ function initApp() { initFileUpload(); // 初始化文件上传功能 initRoutingExamples(); // 初始化路径路由示例功能 initUploadConfigManager(); // 初始化上传配置管理功能 + initUsageManager(); // 初始化用量管理功能 loadInitialData(); // 显示欢迎消息 @@ -144,6 +150,9 @@ window.closeConfigModal = closeConfigModal; window.copyConfigContent = copyConfigContent; window.reloadConfig = reloadConfig; +// 用量管理相关全局函数 +window.refreshUsage = refreshUsage; + // 导出调试函数 window.getProviderStats = () => getProviderStats(providerStats); diff --git a/static/app/auth.js b/static/app/auth.js index 00d733a..c6a5083 100644 --- a/static/app/auth.js +++ b/static/app/auth.js @@ -291,9 +291,21 @@ async function login(password, rememberMe = false) { } } -// 导出实例 -window.authManager = new AuthManager(); -window.apiClient = new ApiClient(); +// 创建单例实例 +const authManager = new AuthManager(); +const apiClient = new ApiClient(); + +/** + * 获取带认证的请求头(便捷函数) + * @returns {Object} 包含认证信息的请求头 + */ +function getAuthHeaders() { + return apiClient.getAuthHeaders(); +} + +// 导出实例到 window(兼容旧代码) +window.authManager = authManager; +window.apiClient = apiClient; window.initAuth = initAuth; window.logout = logout; window.login = login; @@ -302,4 +314,16 @@ window.login = login; window.AuthManager = AuthManager; window.ApiClient = ApiClient; +// ES6 模块导出 +export { + AuthManager, + ApiClient, + authManager, + apiClient, + initAuth, + logout, + login, + getAuthHeaders +}; + console.log('认证模块已加载'); \ No newline at end of file diff --git a/static/app/styles.css b/static/app/styles.css index 0663a2e..cecb67c 100644 --- a/static/app/styles.css +++ b/static/app/styles.css @@ -3661,3 +3661,697 @@ input:checked + .toggle-slider:before { justify-content: center; } } + +/* ==================== 用量查询页面样式 ==================== */ +.usage-panel { + background: var(--bg-primary); + border-radius: 0.5rem; + box-shadow: var(--shadow-md); + padding: 1.5rem; +} + +.usage-controls { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--border-color); +} + +.usage-last-update { + font-size: 0.875rem; + color: var(--text-secondary); +} + +.usage-loading, +.usage-error, +.usage-empty { + text-align: center; + padding: 3rem; + color: var(--text-secondary); +} + +.usage-error { + color: var(--danger-color); + background: #fef2f2; + border-radius: 0.5rem; + border: 1px solid #fee2e2; +} + +.usage-empty i { + font-size: 3rem; + margin-bottom: 1rem; + color: var(--text-secondary); + opacity: 0.5; +} + +/* 提供商分组样式 */ +.usage-provider-group { + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 0.5rem; + margin-bottom: 1rem; + overflow: hidden; +} + +.usage-group-header { + display: flex; + align-items: center; + padding: 0.75rem 1rem; + background: var(--bg-secondary); + cursor: pointer; + user-select: none; + transition: var(--transition); +} + +.usage-group-header:hover { + background: var(--bg-tertiary); +} + +.usage-group-title { + display: flex; + align-items: center; + gap: 0.75rem; + flex: 1; +} + +.usage-group-title .toggle-icon { + font-size: 0.75rem; + color: var(--text-secondary); + transition: transform 0.3s ease; + width: 1rem; +} + +.usage-provider-group:not(.collapsed) .toggle-icon { + transform: rotate(90deg); +} + +.usage-group-title .provider-icon { + font-size: 1.25rem; + color: var(--primary-color); +} + +.usage-group-title .provider-name { + font-weight: 600; + font-size: 1rem; + color: var(--text-primary); +} + +.usage-group-title .instance-count { + font-size: 0.875rem; + color: var(--text-secondary); + background: var(--bg-tertiary); + padding: 0.25rem 0.5rem; + border-radius: 9999px; +} + +.usage-group-title .success-count { + font-size: 0.75rem; + color: var(--text-secondary); + margin-left: auto; +} + +.usage-group-title .success-count.all-success { + color: var(--success-color); +} + +.usage-group-content { + padding: 1rem; + display: block; +} + +.usage-provider-group.collapsed .usage-group-content { + display: none; +} + +/* 用量卡片网格 - 一排四个 */ +.usage-cards-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 1rem; +} + +@media (max-width: 1400px) { + .usage-cards-grid { + grid-template-columns: repeat(3, 1fr); + } +} + +@media (max-width: 1024px) { + .usage-cards-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 640px) { + .usage-cards-grid { + grid-template-columns: 1fr; + } +} + +.stat-success { color: var(--success-color); } +.stat-error { color: var(--danger-color); } +.stat-total { color: var(--text-secondary); } + +/* 实例卡片 */ +.usage-instance-card { + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 0.5rem; + overflow: hidden; + transition: var(--transition); + display: flex; + flex-direction: column; +} + +.usage-instance-card:hover { + box-shadow: var(--shadow-md); + transform: translateY(-2px); +} + +.usage-instance-card.error { + border-color: #fca5a5; +} + +.usage-instance-card.success { + border-left: 3px solid var(--success-color); +} + +.usage-instance-header { + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--border-color); + background: linear-gradient(to right, var(--bg-tertiary), var(--bg-secondary)); +} + +.instance-header-top { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.375rem; +} + +.instance-provider-type { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: 0.7rem; + color: var(--primary-color); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.instance-provider-type i { + font-size: 0.75rem; +} + +.instance-status-badges { + display: flex; + align-items: center; + gap: 0.375rem; +} + +.instance-name { + margin-bottom: 0.375rem; +} + +.instance-name-text { + font-size: 0.875rem; + font-weight: 600; + color: var(--text-primary); + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* 用户信息整合到卡片头部 */ +.instance-user-info { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + padding-top: 0.375rem; + border-top: 1px dashed var(--border-color); + margin-top: 0.375rem; +} + +.user-email { + font-size: 0.7rem; + color: var(--text-secondary); + display: flex; + align-items: center; + gap: 0.25rem; + max-width: 180px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.user-email i { + font-size: 0.65rem; + color: var(--primary-color); +} + +.user-subscription { + font-size: 0.65rem; + padding: 0.125rem 0.375rem; + background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); + color: white; + border-radius: 9999px; + font-weight: 500; +} + +.status-success { color: var(--success-color); } +.status-error { color: var(--danger-color); } + +.badge { + font-size: 0.75rem; + padding: 0.125rem 0.5rem; + border-radius: 9999px; + font-weight: 500; +} + +.badge-healthy { + background: #d1fae5; + color: #065f46; +} + +.badge-unhealthy { + background: #fee2e2; + color: #991b1b; +} + +.badge-disabled { + background: #f3f4f6; + color: #6b7280; +} + +.usage-instance-content { + padding: 0.75rem; + flex: 1; +} + +.usage-error-message { + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--danger-color); + padding: 0.75rem; + background: #fef2f2; + border-radius: 0.375rem; + font-size: 0.75rem; +} + +/* 用量详情 - 紧凑布局 */ +.usage-details { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.usage-section { + background: var(--bg-secondary); + padding: 0.75rem; + border-radius: 0.375rem; + border: 1px solid var(--border-color); +} + +.usage-section h4 { + font-size: 0.75rem; + font-weight: 600; + color: var(--text-secondary); + margin-bottom: 0.5rem; + display: flex; + align-items: center; + gap: 0.375rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.usage-section h4 i { + color: var(--primary-color); + font-size: 0.75rem; +} + +/* 信息网格 - 紧凑 */ +.usage-section .info-grid { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.usage-section .info-item { + display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: 0.375rem; + border-bottom: 1px solid var(--border-color); +} + +.usage-section .info-item:last-child { + border-bottom: none; + padding-bottom: 0; +} + +.usage-section .label { + font-size: 0.75rem; + color: var(--text-secondary); +} + +.usage-section .value { + font-size: 0.75rem; + font-weight: 600; + color: var(--text-primary); + text-align: right; + max-width: 60%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.subscription-title { + color: var(--primary-color); +} + +/* 总用量样式 */ +.total-usage { + background: linear-gradient(135deg, var(--bg-primary) 0%, var(--bg-secondary) 100%); + border: 1px solid var(--primary-color); + border-left: 3px solid var(--primary-color); +} + +.total-usage-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; +} + +.total-label { + font-size: 0.8rem; + font-weight: 600; + color: var(--text-primary); + display: flex; + align-items: center; + gap: 0.375rem; +} + +.total-label i { + color: var(--primary-color); +} + +.total-value { + font-size: 0.75rem; + font-weight: 600; + color: var(--text-secondary); + font-family: monospace; +} + +.total-percent { + text-align: right; + font-size: 0.7rem; + font-weight: 600; + color: var(--text-secondary); + margin-top: 0.25rem; +} + +/* 重置时间紧凑样式 */ +.reset-info-compact { + background: var(--bg-secondary); + padding: 0.5rem 0.75rem; + border-radius: 0.375rem; + border: 1px solid var(--border-color); +} + +.reset-info-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.25rem 0; +} + +.reset-info-row:first-child { + border-bottom: 1px solid var(--border-color); + padding-bottom: 0.375rem; + margin-bottom: 0.25rem; +} + +.reset-label { + font-size: 0.7rem; + color: var(--text-secondary); + display: flex; + align-items: center; + gap: 0.25rem; +} + +.reset-label i { + font-size: 0.65rem; + color: var(--primary-color); +} + +.reset-value { + font-size: 0.7rem; + font-weight: 600; + color: var(--text-primary); +} + +.reset-days { + color: var(--primary-color); + font-weight: 700; +} + +/* 过期时间行样式 */ +.expiration-row { + border-top: 1px dashed var(--border-color); + padding-top: 0.375rem; + margin-top: 0.25rem; +} + +.expiring-soon { + color: var(--danger-color) !important; + font-weight: 700; +} + +/* 用量明细 - 紧凑版 */ +.usage-breakdown-compact { + background: var(--bg-secondary); + padding: 0.5rem; + border-radius: 0.375rem; + border: 1px solid var(--border-color); +} + +.breakdown-item-compact { + padding: 0.5rem; + background: var(--bg-primary); + border-radius: 0.25rem; + margin-bottom: 0.5rem; + border: 1px solid var(--border-color); +} + +.breakdown-item-compact:last-child { + margin-bottom: 0; +} + +.breakdown-header-compact { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.375rem; + font-size: 0.7rem; +} + +.breakdown-header-compact .breakdown-name { + font-weight: 600; + color: var(--text-primary); +} + +.breakdown-header-compact .breakdown-usage { + color: var(--text-secondary); + font-family: monospace; +} + +.progress-bar-small { + height: 0.25rem; + background: var(--bg-tertiary); + border-radius: 9999px; + overflow: hidden; + margin-bottom: 0.375rem; +} + +.progress-bar-small .progress-fill { + height: 100%; + border-radius: 9999px; + transition: width 0.3s ease; +} + +.progress-bar-small.normal .progress-fill { background: var(--success-color); } +.progress-bar-small.warning .progress-fill { background: var(--warning-color); } +.progress-bar-small.danger .progress-fill { background: var(--danger-color); } + +/* 额外用量信息(免费试用、奖励) */ +.extra-usage-info { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.375rem; + font-size: 0.6rem; + padding: 0.375rem; + border-radius: 0.25rem; + margin-top: 0.375rem; +} + +.extra-usage-info.free-trial { + background: #eff6ff; + color: #1e40af; + border: 1px solid #dbeafe; +} + +.extra-usage-info.bonus { + background: #fffbeb; + color: #92400e; + border: 1px solid #fef3c7; +} + +.extra-usage-info .extra-label { + font-weight: 600; + display: flex; + align-items: center; + gap: 0.25rem; +} + +.extra-usage-info .extra-label i { + font-size: 0.55rem; +} + +.extra-usage-info .extra-value { + font-family: monospace; + font-weight: 600; +} + +.extra-usage-info .extra-expires { + margin-left: auto; + opacity: 0.8; + font-size: 0.55rem; +} + +.breakdown-item { + margin-bottom: 0.75rem; + background: var(--bg-primary); + padding: 0.75rem; + border-radius: 0.375rem; + border: 1px solid var(--border-color); +} + +.breakdown-item:last-child { + margin-bottom: 0; +} + +.breakdown-header { + display: flex; + justify-content: space-between; + margin-bottom: 0.375rem; + font-size: 0.75rem; + font-weight: 500; + flex-wrap: wrap; + gap: 0.25rem; +} + +.breakdown-name { + color: var(--text-primary); +} + +.breakdown-usage { + color: var(--text-secondary); + font-family: monospace; + font-size: 0.7rem; +} + +.progress-bar { + height: 0.375rem; + background: var(--bg-tertiary); + border-radius: 9999px; + overflow: hidden; + margin-bottom: 0.5rem; +} + +.progress-fill { + height: 100%; + border-radius: 9999px; + transition: width 0.3s ease; +} + +.progress-bar.normal .progress-fill { background: var(--success-color); } +.progress-bar.warning .progress-fill { background: var(--warning-color); } +.progress-bar.danger .progress-fill { background: var(--danger-color); } + +/* 免费试用和奖励信息 - 紧凑 */ +.free-trial-info, +.bonus-item { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.375rem; + font-size: 0.65rem; + padding: 0.375rem; + background: var(--bg-tertiary); + border-radius: 0.25rem; + margin-top: 0.375rem; +} + +.free-trial-info { + background: #eff6ff; + color: #1e40af; + border: 1px solid #dbeafe; +} + +.bonus-item { + background: #fffbeb; + color: #92400e; + border: 1px solid #fef3c7; +} + +.free-trial-info .label, +.bonus-item .bonus-name { + font-weight: 600; + display: flex; + align-items: center; + gap: 0.25rem; +} + +.free-trial-info .value, +.bonus-item .bonus-usage { + font-family: monospace; + font-weight: 600; +} + +.free-trial-info .expires, +.bonus-item .bonus-expires { + margin-left: auto; + color: currentColor; + opacity: 0.8; + font-size: 0.6rem; +} + +.bonuses-info { + margin-top: 0.5rem; + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +/* 用量卡片响应式调整 */ +@media (max-width: 768px) { + .usage-instance-header { + padding: 0.5rem 0.75rem; + } + + .instance-name-text { + max-width: 120px; + } + + .usage-instance-content { + padding: 0.5rem; + } +} diff --git a/static/app/usage-manager.js b/static/app/usage-manager.js new file mode 100644 index 0000000..f7d4783 --- /dev/null +++ b/static/app/usage-manager.js @@ -0,0 +1,535 @@ +// 用量管理模块 + +import { showToast } from './utils.js'; +import { getAuthHeaders } from './auth.js'; + +/** + * 初始化用量管理功能 + */ +export function initUsageManager() { + const refreshBtn = document.getElementById('refreshUsageBtn'); + if (refreshBtn) { + refreshBtn.addEventListener('click', refreshUsage); + } + + // 初始化时自动加载缓存数据 + loadUsage(); +} + +/** + * 加载用量数据(优先从缓存读取) + */ +export async function loadUsage() { + const loadingEl = document.getElementById('usageLoading'); + const errorEl = document.getElementById('usageError'); + const contentEl = document.getElementById('usageContent'); + const emptyEl = document.getElementById('usageEmpty'); + const lastUpdateEl = document.getElementById('usageLastUpdate'); + + // 显示加载状态 + if (loadingEl) loadingEl.style.display = 'block'; + if (errorEl) errorEl.style.display = 'none'; + if (emptyEl) emptyEl.style.display = 'none'; + + try { + // 不带 refresh 参数,优先读取缓存 + const response = await fetch('/api/usage', { + method: 'GET', + headers: getAuthHeaders() + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + + // 隐藏加载状态 + if (loadingEl) loadingEl.style.display = 'none'; + + // 渲染用量数据 + renderUsageData(data, contentEl); + + // 更新最后更新时间 + if (lastUpdateEl) { + if (data.fromCache && data.timestamp) { + lastUpdateEl.textContent = `缓存时间: ${new Date(data.timestamp).toLocaleString()}`; + } else { + lastUpdateEl.textContent = `上次更新: ${new Date().toLocaleString()}`; + } + } + } catch (error) { + console.error('获取用量数据失败:', error); + + if (loadingEl) loadingEl.style.display = 'none'; + if (errorEl) { + errorEl.style.display = 'block'; + const errorMsgEl = document.getElementById('usageErrorMessage'); + if (errorMsgEl) { + errorMsgEl.textContent = error.message || '获取用量数据失败'; + } + } + } +} + +/** + * 刷新用量数据(强制从服务器获取最新数据) + */ +export async function refreshUsage() { + const loadingEl = document.getElementById('usageLoading'); + const errorEl = document.getElementById('usageError'); + const contentEl = document.getElementById('usageContent'); + const emptyEl = document.getElementById('usageEmpty'); + const lastUpdateEl = document.getElementById('usageLastUpdate'); + const refreshBtn = document.getElementById('refreshUsageBtn'); + + // 显示加载状态 + if (loadingEl) loadingEl.style.display = 'block'; + if (errorEl) errorEl.style.display = 'none'; + if (emptyEl) emptyEl.style.display = 'none'; + if (refreshBtn) refreshBtn.disabled = true; + + try { + // 带 refresh=true 参数,强制刷新 + const response = await fetch('/api/usage?refresh=true', { + method: 'GET', + headers: getAuthHeaders() + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + + // 隐藏加载状态 + if (loadingEl) loadingEl.style.display = 'none'; + + // 渲染用量数据 + renderUsageData(data, contentEl); + + // 更新最后更新时间 + if (lastUpdateEl) { + lastUpdateEl.textContent = `上次更新: ${new Date().toLocaleString()}`; + } + + showToast('用量数据已刷新', 'success'); + } catch (error) { + console.error('获取用量数据失败:', error); + + if (loadingEl) loadingEl.style.display = 'none'; + if (errorEl) { + errorEl.style.display = 'block'; + const errorMsgEl = document.getElementById('usageErrorMessage'); + if (errorMsgEl) { + errorMsgEl.textContent = error.message || '获取用量数据失败'; + } + } + + showToast('获取用量数据失败: ' + error.message, 'error'); + } finally { + if (refreshBtn) refreshBtn.disabled = false; + } +} + +/** + * 渲染用量数据 + * @param {Object} data - 用量数据 + * @param {HTMLElement} container - 容器元素 + */ +function renderUsageData(data, container) { + if (!container) return; + + // 清空容器 + container.innerHTML = ''; + + if (!data || !data.providers || Object.keys(data.providers).length === 0) { + container.innerHTML = ` +
+ +

暂无用量数据

+
+ `; + return; + } + + // 按提供商分组收集已初始化且未禁用的实例 + const groupedInstances = {}; + + for (const [providerType, providerData] of Object.entries(data.providers)) { + if (providerData.instances && providerData.instances.length > 0) { + const validInstances = []; + for (const instance of providerData.instances) { + // 过滤掉服务实例未初始化的 + if (instance.error === '服务实例未初始化') { + continue; + } + // 过滤掉已禁用的提供商 + if (instance.isDisabled) { + continue; + } + validInstances.push(instance); + } + if (validInstances.length > 0) { + groupedInstances[providerType] = validInstances; + } + } + } + + if (Object.keys(groupedInstances).length === 0) { + container.innerHTML = ` +
+ +

暂无已初始化的服务实例

+
+ `; + return; + } + + // 按提供商分组渲染 + for (const [providerType, instances] of Object.entries(groupedInstances)) { + const groupContainer = createProviderGroup(providerType, instances); + container.appendChild(groupContainer); + } +} + +/** + * 创建提供商分组容器 + * @param {string} providerType - 提供商类型 + * @param {Array} instances - 实例数组 + * @returns {HTMLElement} 分组容器元素 + */ +function createProviderGroup(providerType, instances) { + const groupContainer = document.createElement('div'); + groupContainer.className = 'usage-provider-group collapsed'; + + const providerDisplayName = getProviderDisplayName(providerType); + const providerIcon = getProviderIcon(providerType); + const instanceCount = instances.length; + const successCount = instances.filter(i => i.success).length; + + // 分组头部(可点击折叠) + const header = document.createElement('div'); + header.className = 'usage-group-header'; + header.innerHTML = ` +
+ + + ${providerDisplayName} + ${instanceCount} 个实例 + ${successCount}/${instanceCount} 成功 +
+ `; + + // 点击头部切换折叠状态 + header.addEventListener('click', () => { + groupContainer.classList.toggle('collapsed'); + }); + + groupContainer.appendChild(header); + + // 分组内容(卡片网格) + const content = document.createElement('div'); + content.className = 'usage-group-content'; + + const gridContainer = document.createElement('div'); + gridContainer.className = 'usage-cards-grid'; + + for (const instance of instances) { + const instanceCard = createInstanceUsageCard(instance, providerType); + gridContainer.appendChild(instanceCard); + } + + content.appendChild(gridContainer); + groupContainer.appendChild(content); + + return groupContainer; +} + +/** + * 创建实例用量卡片 + * @param {Object} instance - 实例数据 + * @param {string} providerType - 提供商类型 + * @returns {HTMLElement} 卡片元素 + */ +function createInstanceUsageCard(instance, providerType) { + const card = document.createElement('div'); + card.className = `usage-instance-card ${instance.success ? 'success' : 'error'}`; + + const providerDisplayName = getProviderDisplayName(providerType); + const providerIcon = getProviderIcon(providerType); + + // 实例头部 - 整合用户信息 + const header = document.createElement('div'); + header.className = 'usage-instance-header'; + + const statusIcon = instance.success + ? '' + : ''; + + const healthBadge = instance.isDisabled + ? '已禁用' + : (instance.isHealthy + ? '健康' + : '异常'); + + // 获取用户邮箱和订阅信息 + const userEmail = instance.usage?.user?.email || ''; + const subscriptionTitle = instance.usage?.subscription?.title || ''; + + // 用户信息行 + const userInfoHTML = userEmail ? ` + + ` : ''; + + header.innerHTML = ` +
+
+ + ${providerDisplayName} +
+
+ ${statusIcon} + ${healthBadge} +
+
+
+ ${instance.name || instance.uuid} +
+ ${userInfoHTML} + `; + card.appendChild(header); + + // 实例内容 - 只显示用量和到期时间 + const content = document.createElement('div'); + content.className = 'usage-instance-content'; + + if (instance.error) { + content.innerHTML = ` +
+ + ${instance.error} +
+ `; + } else if (instance.usage) { + content.appendChild(renderUsageDetails(instance.usage)); + } + + card.appendChild(content); + return card; +} + +/** + * 渲染用量详情 - 显示总用量、用量明细和到期时间 + * @param {Object} usage - 用量数据 + * @returns {HTMLElement} 详情元素 + */ +function renderUsageDetails(usage) { + const container = document.createElement('div'); + container.className = 'usage-details'; + + // 计算总用量 + const totalUsage = calculateTotalUsage(usage.usageBreakdown); + + // 总用量进度条 + if (totalUsage.hasData) { + const totalSection = document.createElement('div'); + totalSection.className = 'usage-section total-usage'; + + const progressClass = totalUsage.percent >= 90 ? 'danger' : (totalUsage.percent >= 70 ? 'warning' : 'normal'); + + totalSection.innerHTML = ` +
+ 总用量 + ${formatNumber(totalUsage.used)} / ${formatNumber(totalUsage.limit)} +
+
+
+
+
${totalUsage.percent.toFixed(2)}%
+ `; + container.appendChild(totalSection); + } + + // 用量明细(包含免费试用和奖励信息) + if (usage.usageBreakdown && usage.usageBreakdown.length > 0) { + const breakdownSection = document.createElement('div'); + breakdownSection.className = 'usage-section usage-breakdown-compact'; + + let breakdownHTML = ''; + + for (const breakdown of usage.usageBreakdown) { + breakdownHTML += createUsageBreakdownHTML(breakdown); + } + + breakdownSection.innerHTML = breakdownHTML; + container.appendChild(breakdownSection); + } + + return container; +} + +/** + * 创建用量明细 HTML(紧凑版) + * @param {Object} breakdown - 用量明细数据 + * @returns {string} HTML 字符串 + */ +function createUsageBreakdownHTML(breakdown) { + const usagePercent = breakdown.usageLimit > 0 + ? Math.min(100, (breakdown.currentUsage / breakdown.usageLimit) * 100) + : 0; + + const progressClass = usagePercent >= 90 ? 'danger' : (usagePercent >= 70 ? 'warning' : 'normal'); + + let html = ` +
+
+ ${breakdown.displayName || breakdown.resourceType} + ${formatNumber(breakdown.currentUsage)} / ${formatNumber(breakdown.usageLimit)} +
+
+
+
+ `; + + // 免费试用信息 + if (breakdown.freeTrial && breakdown.freeTrial.status === 'ACTIVE') { + html += ` +
+ 免费试用 + ${formatNumber(breakdown.freeTrial.currentUsage)} / ${formatNumber(breakdown.freeTrial.usageLimit)} + 到期: ${formatDate(breakdown.freeTrial.expiresAt)} +
+ `; + } + + // 奖励信息 + if (breakdown.bonuses && breakdown.bonuses.length > 0) { + for (const bonus of breakdown.bonuses) { + if (bonus.status === 'ACTIVE') { + html += ` +
+ ${bonus.displayName || bonus.code} + ${formatNumber(bonus.currentUsage)} / ${formatNumber(bonus.usageLimit)} + 到期: ${formatDate(bonus.expiresAt)} +
+ `; + } + } + } + + html += '
'; + return html; +} + +/** + * 计算总用量(包含基础用量、免费试用和奖励) + * @param {Array} usageBreakdown - 用量明细数组 + * @returns {Object} 总用量信息 + */ +function calculateTotalUsage(usageBreakdown) { + if (!usageBreakdown || usageBreakdown.length === 0) { + return { hasData: false, used: 0, limit: 0, percent: 0 }; + } + + let totalUsed = 0; + let totalLimit = 0; + + for (const breakdown of usageBreakdown) { + // 基础用量 + totalUsed += breakdown.currentUsage || 0; + totalLimit += breakdown.usageLimit || 0; + + // 免费试用用量 + if (breakdown.freeTrial && breakdown.freeTrial.status === 'ACTIVE') { + totalUsed += breakdown.freeTrial.currentUsage || 0; + totalLimit += breakdown.freeTrial.usageLimit || 0; + } + + // 奖励用量 + if (breakdown.bonuses && breakdown.bonuses.length > 0) { + for (const bonus of breakdown.bonuses) { + if (bonus.status === 'ACTIVE') { + totalUsed += bonus.currentUsage || 0; + totalLimit += bonus.usageLimit || 0; + } + } + } + } + + const percent = totalLimit > 0 ? Math.min(100, (totalUsed / totalLimit) * 100) : 0; + + return { + hasData: true, + used: totalUsed, + limit: totalLimit, + percent: percent + }; +} + +/** + * 获取提供商显示名称 + * @param {string} providerType - 提供商类型 + * @returns {string} 显示名称 + */ +function getProviderDisplayName(providerType) { + const names = { + 'claude-kiro-oauth': 'Claude Kiro OAuth', + 'gemini-cli-oauth': 'Gemini CLI OAuth', + 'gemini-antigravity': 'Gemini Antigravity', + 'openai-qwen-oauth': 'Qwen OAuth' + }; + return names[providerType] || providerType; +} + +/** + * 获取提供商图标 + * @param {string} providerType - 提供商类型 + * @returns {string} 图标类名 + */ +function getProviderIcon(providerType) { + const icons = { + 'claude-kiro-oauth': 'fas fa-robot', + 'gemini-cli-oauth': 'fas fa-gem', + 'gemini-antigravity': 'fas fa-rocket', + 'openai-qwen-oauth': 'fas fa-code' + }; + return icons[providerType] || 'fas fa-server'; +} + + +/** + * 格式化数字(向上取整保留两位小数) + * @param {number} num - 数字 + * @returns {string} 格式化后的数字 + */ +function formatNumber(num) { + if (num === null || num === undefined) return '0.00'; + // 向上取整到两位小数 + const rounded = Math.ceil(num * 100) / 100; + return rounded.toFixed(2); +} + +/** + * 格式化日期 + * @param {string} dateStr - ISO 日期字符串 + * @returns {string} 格式化后的日期 + */ +function formatDate(dateStr) { + if (!dateStr) return '--'; + try { + const date = new Date(dateStr); + return date.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }); + } catch (e) { + return dateStr; + } +} \ No newline at end of file diff --git a/static/index.html b/static/index.html index ce62cbf..71760d0 100644 --- a/static/index.html +++ b/static/index.html @@ -48,6 +48,9 @@ 上传配置管理 + + 用量查询 + 实时日志 @@ -838,6 +841,36 @@ + +
+

用量查询

+
+
+ + 上次更新: -- +
+ + + + + +
+ +
+ +

点击"刷新用量"按钮获取授权文件用量信息

+
+
+
+
+

实时日志