feat(usage): 添加用量查询功能及界面

实现完整的用量查询功能,包括:
1. 新增用量查询页面和导航入口
2. 为各API服务适配器添加用量查询接口
3. 实现用量数据缓存机制
4. 添加响应式用量数据展示界面
5. 支持自动刷新和手动刷新用量数据
6. 显示模型配额、剩余用量和重置时间等信息
This commit is contained in:
hex2077 2025-12-16 15:29:15 +08:00
parent 7c976bdf0a
commit 3887243528
12 changed files with 2483 additions and 22 deletions

View file

@ -95,6 +95,18 @@ export class GeminiApiServiceAdapter extends ApiServiceAdapter {
}
return Promise.resolve();
}
/**
* 获取用量限制信息
* @returns {Promise<Object>} 用量限制信息
*/
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<Object>} 用量限制信息
*/
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<Object>} 用量限制信息
*/
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 服务适配器

View file

@ -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<Object>} 用量限制信息
*/
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;
}
}
}

View file

@ -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<Object>} 模型配额信息
*/
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<Object>} 模型配额信息
*/
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;
}
}
}

View file

@ -592,4 +592,104 @@ export class GeminiApiService {
return false;
}
}
/**
* 获取模型配额信息
* @returns {Promise<Object>} 模型配额信息
*/
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<Object>} 模型配额信息
*/
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;
}
}
}

View file

@ -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': [

View file

@ -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<Object|null>} 缓存的用量数据如果不存在或读取失败则返回 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<Object|null>} 缓存的用量数据
*/
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<Object>} 所有提供商的用量信息
*/
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<Object>} 提供商用量信息
*/
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<Object>} 用量信息
*/
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 || '未命名';
}

526
src/usage-service.js Normal file
View file

@ -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<Object>} 用量信息
*/
async getUsage(providerType, uuid = null) {
const handler = this.providerHandlers[providerType];
if (!handler) {
throw new Error(`不支持的提供商类型: ${providerType}`);
}
return handler(uuid);
}
/**
* 获取所有提供商的用量信息
* @returns {Promise<Object>} 所有提供商的用量信息
*/
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<Object>} 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<Object>} 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<Object>} 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<string>} 支持的提供商类型列表
*/
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;
}

View file

@ -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);

View file

@ -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('认证模块已加载');

View file

@ -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;
}
}

535
static/app/usage-manager.js Normal file
View file

@ -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 = `
<div class="usage-empty">
<i class="fas fa-chart-bar"></i>
<p>暂无用量数据</p>
</div>
`;
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 = `
<div class="usage-empty">
<i class="fas fa-chart-bar"></i>
<p>暂无已初始化的服务实例</p>
</div>
`;
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 = `
<div class="usage-group-title">
<i class="fas fa-chevron-right toggle-icon"></i>
<i class="${providerIcon} provider-icon"></i>
<span class="provider-name">${providerDisplayName}</span>
<span class="instance-count">${instanceCount} 个实例</span>
<span class="success-count ${successCount === instanceCount ? 'all-success' : ''}">${successCount}/${instanceCount} 成功</span>
</div>
`;
// 点击头部切换折叠状态
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
? '<i class="fas fa-check-circle status-success"></i>'
: '<i class="fas fa-times-circle status-error"></i>';
const healthBadge = instance.isDisabled
? '<span class="badge badge-disabled">已禁用</span>'
: (instance.isHealthy
? '<span class="badge badge-healthy">健康</span>'
: '<span class="badge badge-unhealthy">异常</span>');
// 获取用户邮箱和订阅信息
const userEmail = instance.usage?.user?.email || '';
const subscriptionTitle = instance.usage?.subscription?.title || '';
// 用户信息行
const userInfoHTML = userEmail ? `
<div class="instance-user-info">
<span class="user-email" title="${userEmail}"><i class="fas fa-envelope"></i> ${userEmail}</span>
${subscriptionTitle ? `<span class="user-subscription">${subscriptionTitle}</span>` : ''}
</div>
` : '';
header.innerHTML = `
<div class="instance-header-top">
<div class="instance-provider-type">
<i class="${providerIcon}"></i>
<span>${providerDisplayName}</span>
</div>
<div class="instance-status-badges">
${statusIcon}
${healthBadge}
</div>
</div>
<div class="instance-name">
<span class="instance-name-text" title="${instance.name || instance.uuid}">${instance.name || instance.uuid}</span>
</div>
${userInfoHTML}
`;
card.appendChild(header);
// 实例内容 - 只显示用量和到期时间
const content = document.createElement('div');
content.className = 'usage-instance-content';
if (instance.error) {
content.innerHTML = `
<div class="usage-error-message">
<i class="fas fa-exclamation-triangle"></i>
<span>${instance.error}</span>
</div>
`;
} 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 = `
<div class="total-usage-header">
<span class="total-label"><i class="fas fa-chart-pie"></i> </span>
<span class="total-value">${formatNumber(totalUsage.used)} / ${formatNumber(totalUsage.limit)}</span>
</div>
<div class="progress-bar ${progressClass}">
<div class="progress-fill" style="width: ${totalUsage.percent}%"></div>
</div>
<div class="total-percent">${totalUsage.percent.toFixed(2)}%</div>
`;
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 = `
<div class="breakdown-item-compact">
<div class="breakdown-header-compact">
<span class="breakdown-name">${breakdown.displayName || breakdown.resourceType}</span>
<span class="breakdown-usage">${formatNumber(breakdown.currentUsage)} / ${formatNumber(breakdown.usageLimit)}</span>
</div>
<div class="progress-bar-small ${progressClass}">
<div class="progress-fill" style="width: ${usagePercent}%"></div>
</div>
`;
// 免费试用信息
if (breakdown.freeTrial && breakdown.freeTrial.status === 'ACTIVE') {
html += `
<div class="extra-usage-info free-trial">
<span class="extra-label"><i class="fas fa-gift"></i> </span>
<span class="extra-value">${formatNumber(breakdown.freeTrial.currentUsage)} / ${formatNumber(breakdown.freeTrial.usageLimit)}</span>
<span class="extra-expires">到期: ${formatDate(breakdown.freeTrial.expiresAt)}</span>
</div>
`;
}
// 奖励信息
if (breakdown.bonuses && breakdown.bonuses.length > 0) {
for (const bonus of breakdown.bonuses) {
if (bonus.status === 'ACTIVE') {
html += `
<div class="extra-usage-info bonus">
<span class="extra-label"><i class="fas fa-star"></i> ${bonus.displayName || bonus.code}</span>
<span class="extra-value">${formatNumber(bonus.currentUsage)} / ${formatNumber(bonus.usageLimit)}</span>
<span class="extra-expires">到期: ${formatDate(bonus.expiresAt)}</span>
</div>
`;
}
}
}
html += '</div>';
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;
}
}

View file

@ -48,6 +48,9 @@
<a href="#upload-config" class="nav-item" data-section="upload-config" aria-label="上传配置管理">
<i class="fas fa-upload" aria-hidden="true"></i> <span>上传配置管理</span>
</a>
<a href="#usage" class="nav-item" data-section="usage" aria-label="用量查询">
<i class="fas fa-chart-bar" aria-hidden="true"></i> <span>用量查询</span>
</a>
<a href="#logs" class="nav-item" data-section="logs" aria-label="实时日志">
<i class="fas fa-file-alt" aria-hidden="true"></i> <span>实时日志</span>
</a>
@ -838,6 +841,36 @@
</div>
</section>
<!-- Usage Section -->
<section id="usage" class="section" aria-labelledby="usage-title">
<h2 id="usage-title">用量查询</h2>
<div class="usage-panel">
<div class="usage-controls">
<button class="btn btn-primary" id="refreshUsageBtn" aria-label="刷新用量数据">
<i class="fas fa-sync-alt"></i> 刷新用量
</button>
<span class="usage-last-update" id="usageLastUpdate">上次更新: --</span>
</div>
<div class="usage-loading" id="usageLoading" style="display: none;">
<i class="fas fa-spinner fa-spin"></i> 正在加载用量数据...
</div>
<div class="usage-error" id="usageError" style="display: none;">
<i class="fas fa-exclamation-triangle"></i>
<span id="usageErrorMessage"></span>
</div>
<div class="usage-content" id="usageContent">
<!-- 用量数据将在这里动态生成 -->
<div class="usage-empty" id="usageEmpty">
<i class="fas fa-chart-bar"></i>
<p>点击"刷新用量"按钮获取授权文件用量信息</p>
</div>
</div>
</div>
</section>
<!-- Logs Section -->
<section id="logs" class="section" aria-labelledby="logs-title">
<h2 id="logs-title">实时日志</h2>