feat(usage): 添加用量查询功能及界面
实现完整的用量查询功能,包括: 1. 新增用量查询页面和导航入口 2. 为各API服务适配器添加用量查询接口 3. 实现用量数据缓存机制 4. 添加响应式用量数据展示界面 5. 支持自动刷新和手动刷新用量数据 6. 显示模型配额、剩余用量和重置时间等信息
This commit is contained in:
parent
7c976bdf0a
commit
3887243528
12 changed files with 2483 additions and 22 deletions
|
|
@ -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 服务适配器
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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': [
|
||||
|
|
|
|||
|
|
@ -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
526
src/usage-service.js
Normal 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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('认证模块已加载');
|
||||
|
|
@ -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
535
static/app/usage-manager.js
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue