From 7f6bf6f06b7c05d06cbbe06217277debe5959923 Mon Sep 17 00:00:00 2001 From: leonai <731962175@qq.com> Date: Fri, 9 Jan 2026 21:47:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(api-potluck):=20=E6=8F=92=E4=BB=B6=20-=20A?= =?UTF-8?q?PI=20=E5=A4=A7=E9=94=85=E9=A5=AD=20-=20=E5=8D=87=E7=BA=A7=20V1.?= =?UTF-8?q?0.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 新增用户凭证数据管理模块(user-data-manager.js),支持凭证关联、资源包计算和配置热更新 2. 实现资源包机制:每个健康凭证提供额外调用次数,支持有效期管理和自动过期清理 3. 新增系统配置API:支持动态调整默认限额、资源包次数和有效期 4. 新增批量操作API:批量应用限额和同步资源包状态到所有Key 5. 实现凭证健康检查:从主服务ProviderPoolManager同步凭证状态 6. 新增用户端API Key重置功能,支持数据自动迁移 7. 重构前端界面:采用GitHub风格深色主题,优化移动端响应式布局 8. 新增定时健康检查调度器,自动同步所有用户凭证状态 --- .gitignore | 3 +- configs/api-potluck-data.json.example | 29 + configs/api-potluck-keys.json.example | 16 + src/plugins/api-potluck/api-routes.js | 618 +++- src/plugins/api-potluck/index.js | 27 +- src/plugins/api-potluck/key-manager.js | 247 +- src/plugins/api-potluck/middleware.js | 2 +- src/plugins/api-potluck/user-data-manager.js | 721 +++++ static/potluck-user.html | 2708 ++++++++++++------ static/potluck.html | 881 +++++- 10 files changed, 4185 insertions(+), 1067 deletions(-) create mode 100644 configs/api-potluck-data.json.example create mode 100644 configs/api-potluck-keys.json.example create mode 100644 src/plugins/api-potluck/user-data-manager.js diff --git a/.gitignore b/.gitignore index 45981ee..80a7842 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,5 @@ token-store.json usage-cache.json *_oauth_creds.json *-auth-token.json -api-potluck-keys.json \ No newline at end of file +api-potluck-keys.json +api-potluck-data.json \ No newline at end of file diff --git a/configs/api-potluck-data.json.example b/configs/api-potluck-data.json.example new file mode 100644 index 0000000..ed1dc8d --- /dev/null +++ b/configs/api-potluck-data.json.example @@ -0,0 +1,29 @@ +{ + "config": { + "defaultDailyLimit": 500, + "bonusPerCredential": 300, + "bonusValidityDays": 30, + "persistInterval": 5000 + }, + "users": { + "maki_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx": { + "credentials": [ + { + "id": "cred_0000000000000_xxxxxx", + "path": "configs/kiro/example_kiro-auth-token/example_kiro-auth-token.json", + "provider": "claude-kiro-oauth", + "authMethod": "refresh-token", + "addedAt": "2026-01-01T00:00:00.000Z" + } + ], + "credentialBonuses": [ + { + "credentialId": "cred_0000000000000_xxxxxx", + "grantedAt": "2026-01-01T00:00:00.000Z", + "usedCount": 0 + } + ], + "createdAt": "2026-01-01T00:00:00.000Z" + } + } +} diff --git a/configs/api-potluck-keys.json.example b/configs/api-potluck-keys.json.example new file mode 100644 index 0000000..8b77965 --- /dev/null +++ b/configs/api-potluck-keys.json.example @@ -0,0 +1,16 @@ +{ + "keys": { + "maki_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx": { + "id": "maki_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "name": "示例用户", + "createdAt": "2026-01-01T00:00:00.000Z", + "dailyLimit": 500, + "todayUsage": 0, + "totalUsage": 0, + "lastResetDate": "2026-01-01", + "lastUsedAt": null, + "enabled": true, + "bonusRemaining": 0 + } + } +} diff --git a/src/plugins/api-potluck/api-routes.js b/src/plugins/api-potluck/api-routes.js index 02aef7b..18b37cd 100644 --- a/src/plugins/api-potluck/api-routes.js +++ b/src/plugins/api-potluck/api-routes.js @@ -12,16 +12,32 @@ import { resetKeyUsage, toggleKey, updateKeyName, + regenerateKey, getStats, validateKey, - KEY_PREFIX + KEY_PREFIX, + setConfigGetter, + updateBonusRemaining, + applyDailyLimitToAllKeys, + getAllKeyIds } from './key-manager.js'; +import { + getUserCredentials, + addUserCredential, + migrateUserCredentials, + getAllUsersCredentials, + syncCredentialBonuses, + getBonusDetails, + getConfig, + updateConfig, + getAllUserApiKeys +} from './user-data-manager.js'; import path from 'path'; +import { existsSync } from 'fs'; import { promises as fs } from 'fs'; import multer from 'multer'; import { batchImportKiroRefreshTokensStream, importAwsCredentials } from '../../oauth-handlers.js'; -import { handleUploadOAuthCredentials } from '../../ui-manager.js'; -import { autoLinkProviderConfigs } from '../../service-manager.js'; +import { autoLinkProviderConfigs, getProviderPoolManager } from '../../service-manager.js'; import { CONFIG } from '../../config-manager.js'; /** @@ -138,16 +154,116 @@ export async function handlePotluckApiRoutes(method, path, req, res) { if (method === 'GET' && path === '/api/potluck/keys') { const keys = await listKeys(); const stats = await getStats(); + const config = getConfig(); sendJson(res, 200, { success: true, data: { keys, - stats + stats, + config } }); return true; } + // GET /api/potluck/config - 获取配置 + if (method === 'GET' && path === '/api/potluck/config') { + const config = getConfig(); + sendJson(res, 200, { + success: true, + data: config + }); + return true; + } + + // PUT /api/potluck/config - 更新配置 + if (method === 'PUT' && path === '/api/potluck/config') { + const body = await parseRequestBody(req); + const { defaultDailyLimit, bonusPerCredential, bonusValidityDays, persistInterval } = body; + + // 验证参数 + if (defaultDailyLimit !== undefined && (typeof defaultDailyLimit !== 'number' || defaultDailyLimit < 1)) { + sendJson(res, 400, { success: false, error: { message: 'defaultDailyLimit must be a positive number' } }); + return true; + } + if (bonusPerCredential !== undefined && (typeof bonusPerCredential !== 'number' || bonusPerCredential < 0)) { + sendJson(res, 400, { success: false, error: { message: 'bonusPerCredential must be a non-negative number' } }); + return true; + } + if (bonusValidityDays !== undefined && (typeof bonusValidityDays !== 'number' || bonusValidityDays < 1)) { + sendJson(res, 400, { success: false, error: { message: 'bonusValidityDays must be a positive number' } }); + return true; + } + if (persistInterval !== undefined && (typeof persistInterval !== 'number' || persistInterval < 1000)) { + sendJson(res, 400, { success: false, error: { message: 'persistInterval must be at least 1000ms' } }); + return true; + } + + const newConfig = await updateConfig({ defaultDailyLimit, bonusPerCredential, bonusValidityDays, persistInterval }); + sendJson(res, 200, { + success: true, + message: 'Config updated successfully', + data: newConfig + }); + return true; + } + + // POST /api/potluck/keys/apply-limit - 批量应用每日限额到所有 Key + if (method === 'POST' && path === '/api/potluck/keys/apply-limit') { + const config = getConfig(); + const result = await applyDailyLimitToAllKeys(config.defaultDailyLimit); + sendJson(res, 200, { + success: true, + message: `已将每日限额 ${config.defaultDailyLimit} 应用到 ${result.updated}/${result.total} 个 Key`, + data: result + }); + return true; + } + + // POST /api/potluck/keys/apply-bonus - 批量同步所有用户的资源包 + if (method === 'POST' && path === '/api/potluck/keys/apply-bonus') { + const allKeyIds = getAllKeyIds(); + let totalSynced = 0; + let totalBonusUpdated = 0; + + for (const apiKey of allKeyIds) { + try { + // 获取用户凭据并检查健康状态 + const credentials = getUserCredentials(apiKey); + if (credentials.length === 0) continue; + + // 构建带健康状态的凭证列表(从主服务同步) + const credentialsWithHealth = []; + for (const cred of credentials) { + const healthResult = await syncCredentialHealthFromPool(apiKey, cred); + credentialsWithHealth.push({ + id: cred.id, + isHealthy: healthResult.isHealthy, + addedAt: cred.addedAt + }); + } + + // 同步资源包 + const bonusSync = await syncCredentialBonuses(apiKey, credentialsWithHealth); + await updateBonusRemaining(apiKey, bonusSync.bonusRemaining); + + totalSynced++; + if (bonusSync.added > 0 || bonusSync.removed > 0) { + totalBonusUpdated++; + } + } catch (error) { + console.warn(`[API Potluck] Failed to sync bonus for ${apiKey.substring(0, 12)}...:`, error.message); + } + } + + sendJson(res, 200, { + success: true, + message: `已同步 ${totalSynced} 个用户的资源包,${totalBonusUpdated} 个有变更`, + data: { totalKeys: allKeyIds.length, synced: totalSynced, updated: totalBonusUpdated } + }); + return true; + } + // POST /api/potluck/keys - 创建新 Key if (method === 'POST' && path === '/api/potluck/keys') { const body = await parseRequestBody(req); @@ -270,6 +386,25 @@ export async function handlePotluckApiRoutes(method, path, req, res) { }); return true; } + + // POST /api/potluck/keys/:keyId/regenerate - 重新生成 Key + if (method === 'POST' && subPath === '/regenerate') { + const result = await regenerateKey(keyId); + if (!result) { + sendJson(res, 404, { success: false, error: { message: 'Key not found' } }); + return true; + } + sendJson(res, 200, { + success: true, + message: 'Key regenerated successfully', + data: { + oldKey: result.oldKey, + newKey: result.newKey, + keyData: result.keyData + } + }); + return true; + } } // 未匹配的 potluck 路由 @@ -377,6 +512,11 @@ export async function handlePotluckUserApiRoutes(method, path, req, res) { ? Math.round((keyData.todayUsage / keyData.dailyLimit) * 100) : 0; + // 获取资源包详情 + const bonusDetails = getBonusDetails(apiKey); + const bonusTotal = bonusDetails.bonuses.length * bonusDetails.bonusPerCredential; + const bonusUsed = bonusDetails.bonuses.reduce((sum, b) => sum + b.usedCount, 0); + // 返回用户友好的使用量信息(隐藏敏感信息) sendJson(res, 200, { success: true, @@ -390,6 +530,9 @@ export async function handlePotluckUserApiRoutes(method, path, req, res) { percent: usagePercent, resetDate: keyData.lastResetDate }, + bonusRemaining: keyData.bonusRemaining || 0, + bonusTotal: bonusTotal, + bonusUsed: bonusUsed, total: keyData.totalUsage, lastUsedAt: keyData.lastUsedAt, createdAt: keyData.createdAt, @@ -405,6 +548,31 @@ export async function handlePotluckUserApiRoutes(method, path, req, res) { return await handleUserUpload(req, res, apiKey); } + // POST /api/potluckuser/regenerate-key - 用户重置自己的 API Key + if (method === 'POST' && path === '/api/potluckuser/regenerate-key') { + const result = await regenerateKey(apiKey); + if (!result) { + sendJson(res, 404, { + success: false, + error: { message: 'Key not found' } + }); + return true; + } + + // 同时迁移用户的凭据数据到新 Key + await migrateUserCredentials(apiKey, result.newKey); + + sendJson(res, 200, { + success: true, + message: 'API Key regenerated successfully', + data: { + newKey: result.newKey, + maskedNewKey: `${result.newKey.substring(0, 12)}...${result.newKey.substring(result.newKey.length - 4)}` + } + }); + return true; + } + // POST /api/potluckuser/kiro/batch-import-tokens - 批量导入 Kiro refresh token if (method === 'POST' && path === '/api/potluckuser/kiro/batch-import-tokens') { return await handleKiroBatchImportTokens(req, res, apiKey); @@ -415,6 +583,77 @@ export async function handlePotluckUserApiRoutes(method, path, req, res) { return await handleKiroImportAwsCredentials(req, res, apiKey); } + // GET /api/potluckuser/credentials - 获取用户的凭据列表 + if (method === 'GET' && path === '/api/potluckuser/credentials') { + const credentials = getUserCredentials(apiKey); + const bonusDetails = getBonusDetails(apiKey); + + // 将资源包信息附加到对应凭证 + const credentialsWithBonus = credentials.map(cred => { + const bonus = bonusDetails.bonuses.find(b => b.credentialId === cred.id); + return { + ...cred, + bonus: bonus ? { + usedCount: bonus.usedCount, + remaining: bonus.remaining, + total: bonusDetails.bonusPerCredential, + expiresAt: bonus.expiresAt + } : null + }; + }); + + sendJson(res, 200, { + success: true, + data: credentialsWithBonus + }); + return true; + } + + // POST /api/potluckuser/credentials/check-all - 批量检查所有凭据健康状态 + if (method === 'POST' && path === '/api/potluckuser/credentials/check-all') { + const results = await checkUserCredentialsHealth(apiKey); + const credentials = getUserCredentials(apiKey); + const bonusDetails = getBonusDetails(apiKey); + + // 将资源包信息附加到对应凭证 + const credentialsWithBonus = credentials.map(cred => { + const healthResult = results.find(r => r.id === cred.id); + const bonus = bonusDetails.bonuses.find(b => b.credentialId === cred.id); + return { + ...cred, + isHealthy: healthResult?.isHealthy, + healthMessage: healthResult?.message, + bonus: bonus ? { + usedCount: bonus.usedCount, + remaining: bonus.remaining, + total: bonusDetails.bonusPerCredential, + expiresAt: bonus.expiresAt + } : null + }; + }); + + sendJson(res, 200, { + success: true, + data: { + results, + credentials: credentialsWithBonus + } + }); + return true; + } + + // 处理凭据相关的路由 + const credentialMatch = path.match(/^\/api\/potluckuser\/credentials\/([^\/]+)(\/.*)?$/); + if (credentialMatch) { + const credentialId = decodeURIComponent(credentialMatch[1]); + const subPath = credentialMatch[2] || ''; + + // POST /api/potluckuser/credentials/:id/health - 检查凭据健康状态 + if (method === 'POST' && subPath === '/health') { + return await handleCredentialHealthCheck(req, res, apiKey, credentialId); + } + } + // 未匹配的用户端路由 sendJson(res, 404, { success: false, @@ -483,56 +722,88 @@ const userUpload = multer({ }); /** - * 处理用户上传授权文件(带自动绑定功能) + * 处理用户上传授权文件(带自动绑定和凭据关联功能) * @param {http.IncomingMessage} req * @param {http.ServerResponse} res * @param {string} apiKey - 用户的 API Key * @returns {Promise} */ async function handleUserUpload(req, res, apiKey) { - // 创建一个包装的响应对象来捕获上传结果 - let uploadResult = null; - const originalEnd = res.end.bind(res); - const originalWriteHead = res.writeHead.bind(res); - let statusCode = 200; - - // 拦截响应以获取上传结果 - res.writeHead = function(code, headers) { - statusCode = code; - return originalWriteHead(code, headers); - }; - - res.end = function(data) { - if (statusCode === 200 && data) { - try { - uploadResult = JSON.parse(data); - } catch (e) { - // 忽略解析错误 + return new Promise((resolve) => { + userUpload.single('file')(req, res, async (err) => { + if (err) { + console.error('[API Potluck User] File upload error:', err.message); + sendJson(res, 400, { success: false, error: err.message }); + resolve(true); + return; } - } - return originalEnd(data); - }; - - // 执行文件上传 - const handled = await handleUploadOAuthCredentials(req, res, { - providerMap: providerMap, - logPrefix: '[API Potluck User]', - userInfo: `user: ${apiKey.substring(0, 12)}...`, - customUpload: userUpload + + if (!req.file) { + sendJson(res, 400, { success: false, error: 'No file uploaded' }); + resolve(true); + return; + } + + try { + const providerType = req.body?.provider || 'common'; + const provider = providerMap[providerType] || providerType; + const tempFilePath = req.file.path; + + // 根据 provider 确定目标目录 + let targetDir = path.join(process.cwd(), 'configs', provider); + + // kiro 类型需要子文件夹 + if (provider === 'kiro') { + const timestamp = Date.now(); + const originalNameWithoutExt = path.parse(req.file.originalname).name; + const subFolder = `${timestamp}_${originalNameWithoutExt}`; + targetDir = path.join(targetDir, subFolder); + } + + await fs.mkdir(targetDir, { recursive: true }); + + const targetFilePath = path.join(targetDir, req.file.filename); + await fs.rename(tempFilePath, targetFilePath); + + const relativePath = path.relative(process.cwd(), targetFilePath).replace(/\\/g, '/'); + + // 将凭据关联到用户 + const credentialInfo = { + path: relativePath, + provider: providerType, + authMethod: 'file-upload' + }; + const credential = await addUserCredential(apiKey, credentialInfo); + + // 自动从主服务同步健康状态 + const healthResult = await syncCredentialHealthFromPool(apiKey, credential); + + // 触发自动绑定 + try { + await autoLinkProviderConfigs(CONFIG); + } catch (linkError) { + console.warn('[API Potluck User] Auto-link failed:', linkError.message); + } + + console.log(`[API Potluck User] File uploaded, linked and health checked: ${relativePath} (provider: ${providerType}, health: ${healthResult.message})`); + + sendJson(res, 200, { + success: true, + message: 'File uploaded successfully', + filePath: relativePath, + originalName: req.file.originalname, + provider: provider, + health: healthResult + }); + resolve(true); + + } catch (error) { + console.error('[API Potluck User] File processing error:', error); + sendJson(res, 500, { success: false, error: error.message }); + resolve(true); + } + }); }); - - // 如果上传成功,调用自动绑定功能扫描并绑定新上传的配置文件 - if (uploadResult && uploadResult.success && uploadResult.filePath) { - try { - console.log(`[API Potluck User] Triggering auto-link for uploaded file: ${uploadResult.filePath}`); - await autoLinkProviderConfigs(CONFIG); - } catch (linkError) { - // 自动绑定失败不影响上传结果,只记录日志 - console.warn(`[API Potluck User] Auto-link failed:`, linkError.message); - } - } - - return handled; } /** @@ -577,9 +848,27 @@ async function handleKiroBatchImportTokens(req, res, apiKey) { const result = await batchImportKiroRefreshTokensStream( refreshTokens, region || 'us-east-1', - (progress) => { + async (progress) => { // 每处理完一个 token 发送进度更新 sendSSE('progress', progress); + + // 成功的凭据关联到用户并执行健康检查 + if (progress.current && progress.current.success && progress.current.path) { + try { + const credentialInfo = { + path: progress.current.path.replace(/\\/g, '/'), + provider: 'claude-kiro-oauth', + authMethod: 'refresh-token' + }; + const credential = await addUserCredential(apiKey, credentialInfo); + + // 自动从主服务同步健康状态 + await syncCredentialHealthFromPool(apiKey, credential); + console.log(`[API Potluck User] Credential linked and health synced: ${credentialInfo.path}`); + } catch (linkError) { + console.warn('[API Potluck User] Failed to link/check credential:', linkError.message); + } + } } ); @@ -653,10 +942,24 @@ async function handleKiroImportAwsCredentials(req, res, apiKey) { if (result.success) { console.log(`[API Potluck User] Successfully imported credentials to: ${result.path}`); + + // 将凭据路径关联到用户 + const credentialInfo = { + path: result.path, + provider: 'claude-kiro-oauth', + authMethod: credentials.authMethod || 'builder-id' + }; + const credential = await addUserCredential(apiKey, credentialInfo); + + // 自动从主服务同步健康状态 + const healthResult = await syncCredentialHealthFromPool(apiKey, credential); + console.log(`[API Potluck User] Health sync result: ${healthResult.message}`); + sendJson(res, 200, { success: true, path: result.path, - message: 'AWS credentials imported successfully' + message: 'AWS credentials imported successfully', + health: healthResult }); } else { const statusCode = result.error === 'duplicate' ? 409 : 500; @@ -677,3 +980,222 @@ async function handleKiroImportAwsCredentials(req, res, apiKey) { return true; } } + +/** + * 从主服务同步凭据健康状态(不触发实际检查,不存储到本地) + * @param {string} apiKey - 用户的 API Key(保留参数以兼容调用) + * @param {Object} credential - 凭据对象 + * @returns {Promise<{isHealthy: boolean|null, message: string}>} + */ +async function syncCredentialHealthFromPool(apiKey, credential) { + const fullPath = path.join(process.cwd(), credential.path); + + // 检查文件是否存在 + if (!existsSync(fullPath)) { + return { isHealthy: false, message: '凭据文件不存在' }; + } + + // 从 ProviderPoolManager 获取该凭据对应的 provider 状态 + const poolManager = getProviderPoolManager(); + if (poolManager && credential.provider) { + // 在 providerStatus 中查找匹配的配置 + const providerPool = poolManager.providerStatus[credential.provider]; + if (providerPool && providerPool.length > 0) { + // 通过凭据路径匹配 provider 配置 + const normalizedCredPath = credential.path.replace(/\\/g, '/'); + const matchedProvider = providerPool.find(p => { + const configPath = p.config.kiroOAuthCredsFile || p.config.oauthCredsFile || ''; + const normalizedConfigPath = configPath.replace(/\\/g, '/'); + return normalizedConfigPath === normalizedCredPath || + normalizedConfigPath.endsWith(normalizedCredPath) || + normalizedCredPath.endsWith(normalizedConfigPath); + }); + + if (matchedProvider) { + const config = matchedProvider.config; + const isHealthy = config.isHealthy && !config.isDisabled; + let message = '健康检查:正常'; + + if (config.isDisabled) { + message = '已禁用'; + } else if (!config.isHealthy) { + message = config.lastErrorMessage || '健康检查:异常'; + } + + return { isHealthy, message }; + } + } + } + + // 未在主服务中找到匹配的配置,检查文件有效性 + try { + const content = await fs.readFile(fullPath, 'utf8'); + const credData = JSON.parse(content); + + // 检查 expiresAt 字段 + if (credData.expiresAt) { + const expiresAt = new Date(credData.expiresAt); + const now = new Date(); + + if (expiresAt < now) { + return { isHealthy: false, message: '凭据已过期' }; + } + } + + // 文件存在且未过期,但未在主服务中注册 + return { isHealthy: null, message: '未注册到服务' }; + + } catch (parseError) { + return { isHealthy: false, message: '凭据文件格式错误' }; + } +} + +/** + * 处理凭据健康检查 + * @param {http.IncomingMessage} req + * @param {http.ServerResponse} res + * @param {string} apiKey - 用户的 API Key + * @param {string} credentialId - 凭据 ID + */ +async function handleCredentialHealthCheck(req, res, apiKey, credentialId) { + try { + const credentials = getUserCredentials(apiKey); + const credential = credentials.find(c => c.id === credentialId); + + if (!credential) { + sendJson(res, 404, { + success: false, + error: { message: 'Credential not found' } + }); + return true; + } + + console.log(`[API Potluck User] Syncing health for credential: ${credential.path}`); + + const result = await syncCredentialHealthFromPool(apiKey, credential); + + sendJson(res, 200, { + success: true, + data: result + }); + return true; + + } catch (error) { + console.error('[API Potluck User] Health check error:', error); + sendJson(res, 500, { + success: false, + error: error.message + }); + return true; + } +} + + +// ============ 定时健康检查 ============ + +const HEALTH_CHECK_INTERVAL = 5 * 60 * 1000; // 5 分钟 +let healthCheckTimer = null; + +/** + * 批量同步所有用户的凭据健康状态(从主服务同步) + * @returns {Promise<{total: number, checked: number, healthy: number, unhealthy: number}>} + */ +async function checkAllCredentialsHealth() { + const allUsers = getAllUsersCredentials(); + let total = 0, checked = 0, healthy = 0, unhealthy = 0; + + for (const { apiKey, credentials } of allUsers) { + for (const credential of credentials) { + total++; + try { + const result = await syncCredentialHealthFromPool(apiKey, credential); + checked++; + if (result.isHealthy) { + healthy++; + } else if (result.isHealthy === false) { + unhealthy++; + } + // isHealthy === null 表示未注册到服务,不计入健康/不健康 + } catch (error) { + console.warn(`[API Potluck] Health sync failed for ${credential.path}:`, error.message); + } + } + } + + return { total, checked, healthy, unhealthy }; +} + +/** + * 同步单个用户的所有凭据健康状态(从主服务同步) + * 同时更新资源包状态和 Key 的 bonusRemaining + * @param {string} apiKey - 用户的 API Key + * @returns {Promise>} + */ +async function checkUserCredentialsHealth(apiKey) { + const credentials = getUserCredentials(apiKey); + const results = []; + + for (const credential of credentials) { + try { + const result = await syncCredentialHealthFromPool(apiKey, credential); + results.push({ + id: credential.id, + isHealthy: result.isHealthy, + message: result.message, + addedAt: credential.addedAt // 传递 addedAt 用于资源包初始化 + }); + } catch (error) { + results.push({ + id: credential.id, + isHealthy: null, + message: '同步失败: ' + error.message, + addedAt: credential.addedAt + }); + } + } + + // 同步资源包状态并更新 Key 的 bonusRemaining + const bonusSync = await syncCredentialBonuses(apiKey, results); + await updateBonusRemaining(apiKey, bonusSync.bonusRemaining); + + return results; +} + +/** + * 启动定时健康检查 + */ +export function startHealthCheckScheduler() { + if (healthCheckTimer) { + clearInterval(healthCheckTimer); + } + + // 启动后延迟 30 秒执行第一次同步 + setTimeout(async () => { + console.log('[API Potluck] Running initial health sync from pool...'); + const result = await checkAllCredentialsHealth(); + console.log(`[API Potluck] Health sync complete: ${result.healthy}/${result.total} healthy`); + }, 30000); + + // 定时同步 + healthCheckTimer = setInterval(async () => { + console.log('[API Potluck] Running scheduled health sync from pool...'); + const result = await checkAllCredentialsHealth(); + console.log(`[API Potluck] Health sync complete: ${result.healthy}/${result.total} healthy`); + }, HEALTH_CHECK_INTERVAL); + + console.log(`[API Potluck] Health sync scheduler started (interval: ${HEALTH_CHECK_INTERVAL / 1000}s)`); +} + +/** + * 停止定时健康检查 + */ +export function stopHealthCheckScheduler() { + if (healthCheckTimer) { + clearInterval(healthCheckTimer); + healthCheckTimer = null; + console.log('[API Potluck] Health sync scheduler stopped'); + } +} + +// 导出批量检查函数供 API 使用 +export { checkUserCredentialsHealth }; diff --git a/src/plugins/api-potluck/index.js b/src/plugins/api-potluck/index.js index b248ca8..4d85ae7 100644 --- a/src/plugins/api-potluck/index.js +++ b/src/plugins/api-potluck/index.js @@ -21,7 +21,7 @@ import { incrementUsage, getStats, KEY_PREFIX, - DEFAULT_DAILY_LIMIT + setConfigGetter } from './key-manager.js'; import { @@ -30,14 +30,16 @@ import { sendPotluckError } from './middleware.js'; -import { handlePotluckApiRoutes, handlePotluckUserApiRoutes } from './api-routes.js'; +import { consumeBonus, getConfig } from './user-data-manager.js'; + +import { handlePotluckApiRoutes, handlePotluckUserApiRoutes, startHealthCheckScheduler, stopHealthCheckScheduler } from './api-routes.js'; /** * 插件定义 */ const apiPotluckPlugin = { name: 'api-potluck', - version: '1.0.0', + version: '1.0.1', description: 'API 大锅饭 - Key 管理和用量统计插件', // 插件类型:认证插件 @@ -52,7 +54,10 @@ const apiPotluckPlugin = { */ async init(config) { console.log('[API Potluck Plugin] Initializing...'); - // 插件初始化逻辑(如果需要) + // 注入配置获取函数 + setConfigGetter(getConfig); + // 启动定时健康检查 + startHealthCheckScheduler(); }, /** @@ -60,7 +65,8 @@ const apiPotluckPlugin = { */ async destroy() { console.log('[API Potluck Plugin] Destroying...'); - // 清理逻辑(如果需要) + // 停止定时健康检查 + stopHealthCheckScheduler(); }, /** @@ -108,7 +114,7 @@ const apiPotluckPlugin = { 'invalid_format': 'Invalid API key format', 'not_found': 'API key not found', 'disabled': 'API key has been disabled', - 'quota_exceeded': 'Daily quota exceeded for this API key' + 'quota_exceeded': 'Quota exceeded for this API key' }; const statusCodes = { @@ -153,7 +159,10 @@ const apiPotluckPlugin = { async onContentGenerated(config) { if (config.potluckApiKey) { try { - await incrementUsage(config.potluckApiKey); + // 传入资源包消耗回调 + await incrementUsage(config.potluckApiKey, async (apiKey) => { + await consumeBonus(apiKey); + }); } catch (e) { // 静默失败,不影响主流程 console.error('[API Potluck Plugin] Failed to record usage:', e.message); @@ -176,7 +185,7 @@ const apiPotluckPlugin = { incrementUsage, getStats, KEY_PREFIX, - DEFAULT_DAILY_LIMIT, + getConfig, extractPotluckKey, isPotluckRequest } @@ -198,7 +207,7 @@ export { incrementUsage, getStats, KEY_PREFIX, - DEFAULT_DAILY_LIMIT, + getConfig, extractPotluckKey, isPotluckRequest }; \ No newline at end of file diff --git a/src/plugins/api-potluck/key-manager.js b/src/plugins/api-potluck/key-manager.js index c122f39..c72af50 100644 --- a/src/plugins/api-potluck/key-manager.js +++ b/src/plugins/api-potluck/key-manager.js @@ -8,17 +8,43 @@ import { existsSync, readFileSync, writeFileSync } from 'fs'; import path from 'path'; import crypto from 'crypto'; -// 配置 +// 配置常量 const KEYS_STORE_FILE = path.join(process.cwd(), 'configs', 'api-potluck-keys.json'); const KEY_PREFIX = 'maki_'; -const DEFAULT_DAILY_LIMIT = 1000; -const PERSIST_INTERVAL = 5000; // 5秒持久化一次 + +// 默认配置(会被 user-data-manager 的配置覆盖) +const DEFAULT_CONFIG = { + defaultDailyLimit: 500, + persistInterval: 5000 +}; + +// 配置获取函数(由外部注入) +let configGetter = null; + +/** + * 设置配置获取函数 + * @param {Function} getter - 返回配置对象的函数 + */ +export function setConfigGetter(getter) { + configGetter = getter; +} + +/** + * 获取当前配置 + */ +function getConfig() { + if (configGetter) { + return configGetter(); + } + return DEFAULT_CONFIG; +} // 内存缓存 let keyStore = null; let isDirty = false; let isWriting = false; let persistTimer = null; +let currentPersistInterval = DEFAULT_CONFIG.persistInterval; /** * 初始化:从文件加载数据到内存 @@ -29,6 +55,18 @@ function ensureLoaded() { if (existsSync(KEYS_STORE_FILE)) { const content = readFileSync(KEYS_STORE_FILE, 'utf8'); keyStore = JSON.parse(content); + // 兼容历史数据:为旧 Key 添加 bonusRemaining 字段 + let needsMigration = false; + for (const keyData of Object.values(keyStore.keys)) { + if (keyData.bonusRemaining === undefined) { + keyData.bonusRemaining = 0; + needsMigration = true; + } + } + if (needsMigration) { + console.log('[API Potluck] Migrated legacy keys: added bonusRemaining field'); + markDirty(); + } } else { keyStore = { keys: {} }; syncWriteToFile(); @@ -37,9 +75,14 @@ function ensureLoaded() { console.error('[API Potluck] Failed to load key store:', error.message); keyStore = { keys: {} }; } + + // 获取配置的持久化间隔 + const config = getConfig(); + currentPersistInterval = config.persistInterval || DEFAULT_CONFIG.persistInterval; + // 启动定期持久化 if (!persistTimer) { - persistTimer = setInterval(persistIfDirty, PERSIST_INTERVAL); + persistTimer = setInterval(persistIfDirty, currentPersistInterval); // 进程退出时保存 process.on('beforeExit', () => persistIfDirty()); process.on('SIGINT', () => { persistIfDirty(); process.exit(0); }); @@ -93,10 +136,23 @@ function markDirty() { } /** - * 生成随机 API Key + * 生成随机 API Key(确保不重复) */ function generateApiKey() { - return `${KEY_PREFIX}${crypto.randomBytes(16).toString('hex')}`; + ensureLoaded(); + let apiKey; + let attempts = 0; + const maxAttempts = 10; + + do { + apiKey = `${KEY_PREFIX}${crypto.randomBytes(16).toString('hex')}`; + attempts++; + if (attempts >= maxAttempts) { + throw new Error('Failed to generate unique API key after multiple attempts'); + } + } while (keyStore.keys[apiKey]); + + return apiKey; } /** @@ -121,9 +177,14 @@ function checkAndResetDailyCount(keyData) { /** * 创建新的 API Key + * @param {string} name - Key 名称 + * @param {number} [dailyLimit] - 每日限额,不传则使用配置的默认值 */ -export async function createKey(name = '', dailyLimit = DEFAULT_DAILY_LIMIT) { +export async function createKey(name = '', dailyLimit = null) { ensureLoaded(); + const config = getConfig(); + const actualDailyLimit = dailyLimit ?? config.defaultDailyLimit ?? DEFAULT_CONFIG.defaultDailyLimit; + const apiKey = generateApiKey(); const now = new Date().toISOString(); const today = getTodayDateString(); @@ -132,12 +193,13 @@ export async function createKey(name = '', dailyLimit = DEFAULT_DAILY_LIMIT) { id: apiKey, name: name || `Key-${Object.keys(keyStore.keys).length + 1}`, createdAt: now, - dailyLimit, + dailyLimit: actualDailyLimit, todayUsage: 0, totalUsage: 0, lastResetDate: today, lastUsedAt: null, - enabled: true + enabled: true, + bonusRemaining: 0 // 剩余资源包总次数(由同步检查更新) }; keyStore.keys[apiKey] = keyData; @@ -233,7 +295,44 @@ export async function updateKeyName(keyId, newName) { } /** - * 验证 API Key 是否有效且有配额 + * 重新生成 API Key(保留原有数据,更换 Key ID) + * @param {string} oldKeyId - 原 Key ID + * @returns {Promise<{oldKey: string, newKey: string, keyData: Object}|null>} + */ +export async function regenerateKey(oldKeyId) { + ensureLoaded(); + const oldKeyData = keyStore.keys[oldKeyId]; + if (!oldKeyData) return null; + + // 生成新的唯一 Key + const newKeyId = generateApiKey(); + + // 复制数据到新 Key + const newKeyData = { + ...oldKeyData, + id: newKeyId, + regeneratedAt: new Date().toISOString(), + regeneratedFrom: oldKeyId.substring(0, 12) + '...' + }; + + // 删除旧 Key,添加新 Key + delete keyStore.keys[oldKeyId]; + keyStore.keys[newKeyId] = newKeyData; + + markDirty(); + await persistIfDirty(); // 立即持久化 + + console.log(`[API Potluck] Regenerated key: ${oldKeyId.substring(0, 12)}... -> ${newKeyId.substring(0, 12)}...`); + + return { + oldKey: oldKeyId, + newKey: newKeyId, + keyData: newKeyData + }; +} + +/** + * 验证 API Key 是否有效且有配额(每日限额 + 资源包) */ export async function validateKey(apiKey) { ensureLoaded(); @@ -246,27 +345,65 @@ export async function validateKey(apiKey) { // 直接在内存中检查和重置 checkAndResetDailyCount(keyData); - if (keyData.todayUsage >= keyData.dailyLimit) { - return { valid: false, reason: 'quota_exceeded', keyData }; + + // 检查每日限额 + if (keyData.todayUsage < keyData.dailyLimit) { + return { valid: true, keyData, useBonus: false }; } - return { valid: true, keyData }; + + // 每日限额用尽,检查资源包 + const bonusRemaining = keyData.bonusRemaining || 0; + if (bonusRemaining > 0) { + return { valid: true, keyData, useBonus: true, bonusRemaining }; + } + + return { valid: false, reason: 'quota_exceeded', keyData }; } /** * 增加 Key 的使用次数(原子操作,直接修改内存) + * 优先消耗每日限额,用尽后消耗资源包 + * @param {string} apiKey - API Key + * @param {Function} [onBonusUsed] - 资源包消耗回调,用于更新 data 中的 usedCount */ -export async function incrementUsage(apiKey) { +export async function incrementUsage(apiKey, onBonusUsed = null) { ensureLoaded(); const keyData = keyStore.keys[apiKey]; if (!keyData) return null; checkAndResetDailyCount(keyData); - keyData.todayUsage += 1; + + let usedBonus = false; + + // 优先消耗每日限额 + if (keyData.todayUsage < keyData.dailyLimit) { + keyData.todayUsage += 1; + } else { + // 每日限额用尽,消耗资源包 + const bonusRemaining = keyData.bonusRemaining || 0; + + if (bonusRemaining > 0) { + keyData.bonusRemaining = bonusRemaining - 1; + usedBonus = true; + + // 触发回调更新 data 中的 usedCount + if (onBonusUsed) { + await onBonusUsed(apiKey); + } + } else { + // 无可用配额 + return null; + } + } + keyData.totalUsage += 1; keyData.lastUsedAt = new Date().toISOString(); markDirty(); - // 不立即持久化,由定时器批量写入 - return keyData; + + return { + ...keyData, + usedBonus + }; } /** @@ -293,5 +430,79 @@ export async function getStats() { }; } +// ============ 凭证资源包管理 ============ + +/** + * 更新 Key 的剩余资源包次数(由同步检查调用) + * @param {string} keyId - Key ID + * @param {number} bonusRemaining - 剩余资源包总次数 + * @returns {Promise} + */ +export async function updateBonusRemaining(keyId, bonusRemaining) { + ensureLoaded(); + const keyData = keyStore.keys[keyId]; + if (!keyData) return false; + + keyData.bonusRemaining = Math.max(0, bonusRemaining); + markDirty(); + return true; +} + +/** + * 获取 Key 的资源包信息 + * @param {string} keyId - Key ID + * @param {Function} getConfigFn - 获取配置的函数(从 user-data-manager 传入) + * @returns {Promise} + */ +export async function getBonusInfo(keyId, getConfigFn = null) { + ensureLoaded(); + const keyData = keyStore.keys[keyId]; + if (!keyData) return null; + + // 从 user-data-manager 获取配置 + const config = getConfigFn ? getConfigFn() : { bonusPerCredential: 300, bonusValidityDays: 30 }; + + return { + bonusRemaining: keyData.bonusRemaining || 0, + bonusPerCredential: config.bonusPerCredential, + validityDays: config.bonusValidityDays + }; +} + +/** + * 批量更新所有 Key 的每日限额 + * @param {number} newLimit - 新的每日限额 + * @returns {Promise<{total: number, updated: number}>} + */ +export async function applyDailyLimitToAllKeys(newLimit) { + ensureLoaded(); + const keys = Object.values(keyStore.keys); + let updated = 0; + + for (const keyData of keys) { + if (keyData.dailyLimit !== newLimit) { + keyData.dailyLimit = newLimit; + updated++; + } + } + + if (updated > 0) { + markDirty(); + await persistIfDirty(); + } + + console.log(`[API Potluck] Applied daily limit ${newLimit} to ${updated}/${keys.length} keys`); + return { total: keys.length, updated }; +} + +/** + * 获取所有 Key ID 列表 + * @returns {string[]} + */ +export function getAllKeyIds() { + ensureLoaded(); + return Object.keys(keyStore.keys); +} + // 导出常量 -export { KEY_PREFIX, DEFAULT_DAILY_LIMIT }; +export { KEY_PREFIX }; diff --git a/src/plugins/api-potluck/middleware.js b/src/plugins/api-potluck/middleware.js index 089b247..0101c5d 100644 --- a/src/plugins/api-potluck/middleware.js +++ b/src/plugins/api-potluck/middleware.js @@ -82,7 +82,7 @@ export async function potluckAuthMiddleware(req, requestUrl) { 'invalid_format': 'Invalid API key format', 'not_found': 'API key not found', 'disabled': 'API key has been disabled', - 'quota_exceeded': 'Daily quota exceeded for this API key' + 'quota_exceeded': 'Quota exceeded for this API key' }; const statusCodes = { diff --git a/src/plugins/api-potluck/user-data-manager.js b/src/plugins/api-potluck/user-data-manager.js new file mode 100644 index 0000000..4d330ac --- /dev/null +++ b/src/plugins/api-potluck/user-data-manager.js @@ -0,0 +1,721 @@ +/** + * API 大锅饭 - 用户数据管理模块 + * 管理用户关联的凭据文件路径和资源包 + * 使用 Mutex 解决并发问题 + */ + +import { promises as fs } from 'fs'; +import { existsSync, readFileSync, writeFileSync, watch } from 'fs'; +import path from 'path'; + +// 配置文件路径 +const USER_DATA_FILE = path.join(process.cwd(), 'configs', 'api-potluck-data.json'); + +// 默认配置值 +const DEFAULT_CONFIG = { + defaultDailyLimit: 500, + bonusPerCredential: 300, + bonusValidityDays: 30, + persistInterval: 5000 +}; + +// 内存缓存 +let userDataStore = null; +let isDirty = false; +let isWriting = false; +let persistTimer = null; +let fileWatcher = null; +let currentPersistInterval = DEFAULT_CONFIG.persistInterval; + +// ============ 简易 Mutex 实现 ============ +class SimpleMutex { + constructor() { + this._locked = false; + this._waiting = []; + } + + async acquire() { + return new Promise((resolve) => { + if (!this._locked) { + this._locked = true; + resolve(); + } else { + this._waiting.push(resolve); + } + }); + } + + release() { + if (this._waiting.length > 0) { + const next = this._waiting.shift(); + next(); + } else { + this._locked = false; + } + } + + async runExclusive(fn) { + await this.acquire(); + try { + return await fn(); + } finally { + this.release(); + } + } +} + +// 全局锁:用于资源包消耗操作 +const bonusMutex = new SimpleMutex(); + +// ============ 配置管理 ============ + +/** + * 获取完整配置(支持热更新) + */ +function getFullConfig() { + ensureLoaded(); + const config = userDataStore.config || {}; + return { + defaultDailyLimit: config.defaultDailyLimit ?? DEFAULT_CONFIG.defaultDailyLimit, + bonusPerCredential: config.bonusPerCredential ?? DEFAULT_CONFIG.bonusPerCredential, + bonusValidityDays: config.bonusValidityDays ?? DEFAULT_CONFIG.bonusValidityDays, + persistInterval: config.persistInterval ?? DEFAULT_CONFIG.persistInterval + }; +} + +/** + * 获取资源包配置(兼容旧接口) + */ +function getBonusConfig() { + const config = getFullConfig(); + return { + bonusPerCredential: config.bonusPerCredential, + bonusValidityDays: config.bonusValidityDays + }; +} + +/** + * 更新配置 + * @param {Object} newConfig - 新配置 + * @returns {Object} 更新后的完整配置 + */ +export async function updateConfig(newConfig) { + ensureLoaded(); + + if (!userDataStore.config) { + userDataStore.config = {}; + } + + // 验证并更新各配置项 + if (typeof newConfig.defaultDailyLimit === 'number' && newConfig.defaultDailyLimit > 0) { + userDataStore.config.defaultDailyLimit = newConfig.defaultDailyLimit; + } + if (typeof newConfig.bonusPerCredential === 'number' && newConfig.bonusPerCredential >= 0) { + userDataStore.config.bonusPerCredential = newConfig.bonusPerCredential; + } + if (typeof newConfig.bonusValidityDays === 'number' && newConfig.bonusValidityDays > 0) { + userDataStore.config.bonusValidityDays = newConfig.bonusValidityDays; + } + if (typeof newConfig.persistInterval === 'number' && newConfig.persistInterval >= 1000) { + userDataStore.config.persistInterval = newConfig.persistInterval; + // 更新持久化定时器 + updatePersistTimer(newConfig.persistInterval); + } + + markDirty(); + await persistIfDirty(); + + const updatedConfig = getFullConfig(); + console.log(`[API Potluck UserData] Config updated:`, updatedConfig); + return updatedConfig; +} + +/** + * 更新持久化定时器间隔 + */ +function updatePersistTimer(newInterval) { + if (newInterval === currentPersistInterval) return; + + currentPersistInterval = newInterval; + if (persistTimer) { + clearInterval(persistTimer); + persistTimer = setInterval(persistIfDirty, currentPersistInterval); + console.log(`[API Potluck UserData] Persist interval updated to ${currentPersistInterval}ms`); + } +} + +/** + * 获取当前配置(对外暴露) + */ +export function getConfig() { + return getFullConfig(); +} + +/** + * 兼容旧接口:更新资源包配置 + */ +export async function updateBonusConfig(newConfig) { + return updateConfig(newConfig); +} + +/** + * 初始化:从文件加载数据到内存 + */ +function ensureLoaded() { + if (userDataStore !== null) return; + try { + if (existsSync(USER_DATA_FILE)) { + const content = readFileSync(USER_DATA_FILE, 'utf8'); + userDataStore = JSON.parse(content); + // 兼容旧数据:确保 config 和 users 存在 + if (!userDataStore.config) { + userDataStore.config = {}; + } + if (!userDataStore.users) { + userDataStore.users = {}; + } + } else { + userDataStore = { config: {}, users: {} }; + syncWriteToFile(); + } + } catch (error) { + console.error('[API Potluck UserData] Failed to load user data:', error.message); + userDataStore = { config: {}, users: {} }; + } + + // 获取配置的持久化间隔 + const config = userDataStore.config || {}; + currentPersistInterval = config.persistInterval ?? DEFAULT_CONFIG.persistInterval; + + // 启动定期持久化 + if (!persistTimer) { + persistTimer = setInterval(persistIfDirty, currentPersistInterval); + } + // 启动文件监听(热更新) + startFileWatcher(); +} + +/** + * 同步写入文件(仅初始化时使用) + */ +function syncWriteToFile() { + try { + const dir = path.dirname(USER_DATA_FILE); + if (!existsSync(dir)) { + require('fs').mkdirSync(dir, { recursive: true }); + } + writeFileSync(USER_DATA_FILE, JSON.stringify(userDataStore, null, 2), 'utf8'); + } catch (error) { + console.error('[API Potluck UserData] Sync write failed:', error.message); + } +} + +/** + * 异步持久化(带写锁) + */ +async function persistIfDirty() { + if (!isDirty || isWriting || userDataStore === null) return; + isWriting = true; + try { + const dir = path.dirname(USER_DATA_FILE); + if (!existsSync(dir)) { + await fs.mkdir(dir, { recursive: true }); + } + const tempFile = USER_DATA_FILE + '.tmp'; + await fs.writeFile(tempFile, JSON.stringify(userDataStore, null, 2), 'utf8'); + await fs.rename(tempFile, USER_DATA_FILE); + isDirty = false; + } catch (error) { + console.error('[API Potluck UserData] Persist failed:', error.message); + } finally { + isWriting = false; + } +} + +/** + * 标记数据已修改 + */ +function markDirty() { + isDirty = true; +} + +/** + * 启动文件监听(热更新配置) + */ +let lastReloadTime = 0; +function startFileWatcher() { + if (fileWatcher) return; + + try { + fileWatcher = watch(USER_DATA_FILE, { persistent: false }, (eventType) => { + if (eventType !== 'change') return; + + // 防抖:忽略自己写入触发的事件 + const now = Date.now(); + if (now - lastReloadTime < 1000 || isWriting) return; + lastReloadTime = now; + + // 重新加载配置部分 + try { + const content = readFileSync(USER_DATA_FILE, 'utf8'); + const newData = JSON.parse(content); + + // 只热更新 config 部分,不覆盖内存中的 users 数据 + if (newData.config) { + const oldConfig = userDataStore.config || {}; + const newConfig = newData.config; + + // 检查配置是否有变化 + if (JSON.stringify(oldConfig) !== JSON.stringify(newConfig)) { + userDataStore.config = newConfig; + console.log('[API Potluck UserData] Config hot-reloaded:', getBonusConfig()); + } + } + } catch (error) { + console.error('[API Potluck UserData] Hot-reload failed:', error.message); + } + }); + + console.log('[API Potluck UserData] File watcher started for config hot-reload'); + } catch (error) { + console.error('[API Potluck UserData] Failed to start file watcher:', error.message); + } +} + +/** + * 停止文件监听 + */ +export function stopFileWatcher() { + if (fileWatcher) { + fileWatcher.close(); + fileWatcher = null; + } +} + +/** + * 获取用户数据 + * @param {string} apiKey - 用户的 API Key + * @returns {Object|null} + */ +export function getUserData(apiKey) { + ensureLoaded(); + return userDataStore.users[apiKey] || null; +} + +/** + * 初始化用户数据(如果不存在) + * @param {string} apiKey - 用户的 API Key + * @returns {Object} + */ +export function ensureUserData(apiKey) { + ensureLoaded(); + if (!userDataStore.users[apiKey]) { + userDataStore.users[apiKey] = { + credentials: [], + credentialBonuses: [], + createdAt: new Date().toISOString() + }; + markDirty(); + } + // 兼容旧数据:添加 credentialBonuses 数组 + if (!userDataStore.users[apiKey].credentialBonuses) { + userDataStore.users[apiKey].credentialBonuses = []; + markDirty(); + } + return userDataStore.users[apiKey]; +} + +/** + * 添加凭据路径到用户 + * @param {string} apiKey - 用户的 API Key + * @param {Object} credentialInfo - 凭据信息 + * @param {string} credentialInfo.path - 凭据文件路径 + * @param {string} credentialInfo.provider - 提供商类型 (如 'claude-kiro-oauth') + * @param {string} [credentialInfo.authMethod] - 认证方式 (如 'builder-id', 'google', 'github') + * @returns {Object} 添加的凭据信息 + */ +export async function addUserCredential(apiKey, credentialInfo) { + ensureLoaded(); + const userData = ensureUserData(apiKey); + + // 检查是否已存在相同路径 + const existingIndex = userData.credentials.findIndex(c => c.path === credentialInfo.path); + + // 只保留核心字段,健康状态从主服务实时获取 + const credential = { + id: `cred_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`, + path: credentialInfo.path, + provider: credentialInfo.provider || 'claude-kiro-oauth', + authMethod: credentialInfo.authMethod || 'unknown', + addedAt: new Date().toISOString() + }; + + if (existingIndex >= 0) { + // 更新已存在的凭据,保留原有 id 和 addedAt + credential.id = userData.credentials[existingIndex].id; + credential.addedAt = userData.credentials[existingIndex].addedAt; + userData.credentials[existingIndex] = credential; + } else { + userData.credentials.push(credential); + } + + markDirty(); + await persistIfDirty(); + + return credential; +} + +/** + * 移除用户凭据 + * @param {string} apiKey - 用户的 API Key + * @param {string} credentialId - 凭据 ID + * @returns {boolean} + */ +export async function removeUserCredential(apiKey, credentialId) { + ensureLoaded(); + const userData = userDataStore.users[apiKey]; + if (!userData) return false; + + const index = userData.credentials.findIndex(c => c.id === credentialId); + if (index === -1) return false; + + userData.credentials.splice(index, 1); + markDirty(); + await persistIfDirty(); + + return true; +} + +/** + * 获取用户的所有凭据 + * @param {string} apiKey - 用户的 API Key + * @returns {Array} + */ +export function getUserCredentials(apiKey) { + ensureLoaded(); + const userData = userDataStore.users[apiKey]; + return userData ? userData.credentials : []; +} + +/** + * 通过路径查找凭据 + * @param {string} apiKey - 用户的 API Key + * @param {string} credPath - 凭据文件路径 + * @returns {Object|null} + */ +export function findCredentialByPath(apiKey, credPath) { + ensureLoaded(); + const userData = userDataStore.users[apiKey]; + if (!userData) return null; + + return userData.credentials.find(c => c.path === credPath) || null; +} + +/** + * 检查凭据路径是否已被任何用户使用 + * @param {string} credPath - 凭据文件路径 + * @returns {{exists: boolean, apiKey?: string}} + */ +export function isCredentialPathUsed(credPath) { + ensureLoaded(); + for (const [apiKey, userData] of Object.entries(userDataStore.users)) { + const found = userData.credentials.find(c => c.path === credPath); + if (found) { + return { exists: true, apiKey }; + } + } + return { exists: false }; +} + +/** + * 迁移用户凭据到新 Key(用于 Key 重置时) + * @param {string} oldApiKey - 旧 API Key + * @param {string} newApiKey - 新 API Key + * @returns {Promise} + */ +export async function migrateUserCredentials(oldApiKey, newApiKey) { + ensureLoaded(); + const oldUserData = userDataStore.users[oldApiKey]; + if (!oldUserData) return false; + + // 将旧用户数据迁移到新 Key + userDataStore.users[newApiKey] = { + ...oldUserData, + migratedFrom: oldApiKey.substring(0, 12) + '...', + migratedAt: new Date().toISOString() + }; + + // 删除旧用户数据 + delete userDataStore.users[oldApiKey]; + + markDirty(); + await persistIfDirty(); + + console.log(`[API Potluck UserData] Migrated credentials from ${oldApiKey.substring(0, 12)}... to ${newApiKey.substring(0, 12)}...`); + return true; +} + +/** + * 获取所有用户及其凭据(用于批量健康检查) + * @returns {Array<{apiKey: string, credentials: Array}>} + */ +export function getAllUsersCredentials() { + ensureLoaded(); + const result = []; + for (const [apiKey, userData] of Object.entries(userDataStore.users)) { + if (userData.credentials && userData.credentials.length > 0) { + result.push({ + apiKey, + credentials: userData.credentials + }); + } + } + return result; +} + +// ============ 凭证资源包管理 ============ + +/** + * 计算资源包过期时间(使用动态配置) + * @param {string} grantedAt - 授予时间 + * @returns {Date} + */ +function calculateExpiresAt(grantedAt) { + const { bonusValidityDays } = getBonusConfig(); + const granted = new Date(grantedAt); + return new Date(granted.getTime() + bonusValidityDays * 24 * 60 * 60 * 1000); +} + +/** + * 检查资源包是否过期 + * @param {Object} bonus - 资源包对象 + * @returns {boolean} + */ +function isBonusExpired(bonus) { + const expiresAt = calculateExpiresAt(bonus.grantedAt); + return new Date() > expiresAt; +} + +/** + * 为凭证添加资源包(凭证健康时调用) + * @param {string} apiKey - 用户的 API Key + * @param {string} credentialId - 凭证 ID + * @returns {Object|null} 添加的资源包信息 + */ +export async function addCredentialBonus(apiKey, credentialId) { + ensureLoaded(); + const userData = ensureUserData(apiKey); + + // 检查是否已存在 + const existing = userData.credentialBonuses.find(b => b.credentialId === credentialId); + if (existing) { + return existing; + } + + const bonus = { + credentialId, + grantedAt: new Date().toISOString(), + usedCount: 0 + }; + + userData.credentialBonuses.push(bonus); + markDirty(); + + console.log(`[API Potluck UserData] Added bonus for credential: ${credentialId}`); + return bonus; +} + +/** + * 移除凭证资源包(凭证失效时调用) + * @param {string} apiKey - 用户的 API Key + * @param {string} credentialId - 凭证 ID + * @returns {boolean} + */ +export async function removeCredentialBonus(apiKey, credentialId) { + ensureLoaded(); + const userData = userDataStore.users[apiKey]; + if (!userData || !userData.credentialBonuses) return false; + + const index = userData.credentialBonuses.findIndex(b => b.credentialId === credentialId); + if (index === -1) return false; + + userData.credentialBonuses.splice(index, 1); + markDirty(); + + console.log(`[API Potluck UserData] Removed bonus for credential: ${credentialId}`); + return true; +} + +/** + * 消耗资源包次数(FIFO 顺序,使用 Mutex 保证并发安全) + * @param {string} apiKey - 用户的 API Key + * @returns {boolean} 是否成功消耗 + */ +export async function consumeBonus(apiKey) { + // 使用 Mutex 保证并发安全 + return bonusMutex.runExclusive(async () => { + ensureLoaded(); + const userData = userDataStore.users[apiKey]; + if (!userData || !userData.credentialBonuses) return false; + + const { bonusPerCredential } = getBonusConfig(); + + // 按 grantedAt 排序(FIFO) + const sortedBonuses = userData.credentialBonuses + .filter(b => !isBonusExpired(b)) + .sort((a, b) => new Date(a.grantedAt) - new Date(b.grantedAt)); + + // 找到第一个有剩余次数的资源包 + for (const bonus of sortedBonuses) { + const remaining = bonusPerCredential - bonus.usedCount; + if (remaining > 0) { + bonus.usedCount += 1; + markDirty(); + return true; + } + } + + return false; + }); +} + +/** + * 计算用户的剩余资源包总次数 + * @param {string} apiKey - 用户的 API Key + * @param {Set} [healthyCredentialIds] - 健康凭证 ID 集合(可选,用于过滤) + * @returns {number} + */ +export function calculateBonusRemaining(apiKey, healthyCredentialIds = null) { + ensureLoaded(); + const userData = userDataStore.users[apiKey]; + if (!userData || !userData.credentialBonuses) return 0; + + const { bonusPerCredential } = getBonusConfig(); + + let total = 0; + for (const bonus of userData.credentialBonuses) { + // 检查是否过期 + if (isBonusExpired(bonus)) continue; + + // 如果提供了健康凭证集合,检查凭证是否健康 + if (healthyCredentialIds && !healthyCredentialIds.has(bonus.credentialId)) continue; + + const remaining = bonusPerCredential - bonus.usedCount; + if (remaining > 0) { + total += remaining; + } + } + + return total; +} + +/** + * 同步资源包状态(根据健康凭证列表) + * 兼容历史数据:为已有健康凭证创建资源包,使用凭证的 addedAt 作为 grantedAt + * @param {string} apiKey - 用户的 API Key + * @param {Array<{id: string, isHealthy: boolean, addedAt?: string}>} credentialsWithHealth - 带健康状态的凭证列表 + * @returns {{added: number, removed: number, bonusRemaining: number}} + */ +export async function syncCredentialBonuses(apiKey, credentialsWithHealth) { + ensureLoaded(); + const userData = ensureUserData(apiKey); + + let added = 0, removed = 0; + + // 获取健康凭证 ID 集合 + const healthyIds = new Set( + credentialsWithHealth + .filter(c => c.isHealthy === true) + .map(c => c.id) + ); + + // 为新的健康凭证添加资源包 + for (const cred of credentialsWithHealth) { + if (cred.isHealthy !== true) continue; + + const exists = userData.credentialBonuses.some(b => b.credentialId === cred.id); + if (!exists) { + // 使用凭证的 addedAt 作为资源包授予时间(兼容历史数据) + const grantedAt = cred.addedAt || new Date().toISOString(); + userData.credentialBonuses.push({ + credentialId: cred.id, + grantedAt: grantedAt, + usedCount: 0 + }); + added++; + console.log(`[API Potluck UserData] Created bonus for credential ${cred.id}, grantedAt: ${grantedAt}`); + } + } + + // 移除失效凭证的资源包 + const toRemove = userData.credentialBonuses.filter(b => !healthyIds.has(b.credentialId)); + for (const bonus of toRemove) { + const idx = userData.credentialBonuses.indexOf(bonus); + if (idx !== -1) { + userData.credentialBonuses.splice(idx, 1); + removed++; + } + } + + // 清理过期资源包 + const expiredCount = userData.credentialBonuses.filter(b => isBonusExpired(b)).length; + userData.credentialBonuses = userData.credentialBonuses.filter(b => !isBonusExpired(b)); + + if (added > 0 || removed > 0 || expiredCount > 0) { + markDirty(); + } + + // 计算剩余资源包次数 + const bonusRemaining = calculateBonusRemaining(apiKey, healthyIds); + + return { added, removed, bonusRemaining }; +} + +/** + * 获取用户的资源包详情 + * @param {string} apiKey - 用户的 API Key + * @returns {Object} + */ +export function getBonusDetails(apiKey) { + ensureLoaded(); + const { bonusPerCredential, bonusValidityDays } = getBonusConfig(); + const userData = userDataStore.users[apiKey]; + if (!userData) { + return { + bonuses: [], + totalRemaining: 0, + bonusPerCredential, + validityDays: bonusValidityDays + }; + } + + const bonuses = (userData.credentialBonuses || []) + .filter(b => !isBonusExpired(b)) + .map(b => ({ + credentialId: b.credentialId, + grantedAt: b.grantedAt, + expiresAt: calculateExpiresAt(b.grantedAt).toISOString(), + usedCount: b.usedCount, + remaining: bonusPerCredential - b.usedCount + })); + + const totalRemaining = bonuses.reduce((sum, b) => sum + Math.max(0, b.remaining), 0); + + return { + bonuses, + totalRemaining, + bonusPerCredential, + validityDays: bonusValidityDays + }; +} + +/** + * 获取所有用户的 API Key 列表 + * @returns {string[]} + */ +export function getAllUserApiKeys() { + ensureLoaded(); + return Object.keys(userDataStore.users); +} + +export { USER_DATA_FILE }; diff --git a/static/potluck-user.html b/static/potluck-user.html index 0eea253..1bc8c6f 100644 --- a/static/potluck-user.html +++ b/static/potluck-user.html @@ -8,958 +8,1366 @@ * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); + background: #0d1117; min-height: 100vh; - color: #e0e0e0; + color: #e6edf3; + } + + /* 顶部导航 */ + .navbar { + background: #161b22; + border-bottom: 1px solid #30363d; + position: sticky; + top: 0; + z-index: 100; + } + .navbar-inner { + max-width: 1000px; + margin: 0 auto; + padding: 0 24px; + height: 60px; display: flex; - flex-direction: column; align-items: center; - padding: 20px; + justify-content: space-between; } - .container { - max-width: 600px; - width: 100%; - margin: 0 auto; + .navbar-brand { + display: flex; + align-items: center; + gap: 12px; + font-size: 20px; + font-weight: 700; + color: #fff; } - header { - text-align: center; - padding: 40px 0 30px; + .navbar-brand .icon { font-size: 28px; } + .navbar-brand .badge { + background: linear-gradient(135deg, #f472b6, #ec4899); + color: #fff; + font-size: 11px; + padding: 2px 8px; + border-radius: 10px; + font-weight: 500; } - h1 { - font-size: 28px; - color: #fff; - display: flex; - align-items: center; - justify-content: center; - gap: 12px; + .navbar-user { + display: flex; + align-items: center; + gap: 16px; } - h1 span { font-size: 36px; } - .subtitle { - color: #888; - margin-top: 10px; + .navbar-user .welcome { + color: #8b949e; font-size: 14px; } + .navbar-user .welcome strong { color: #e6edf3; } + .btn-logout { + background: linear-gradient(135deg, #f472b6, #ec4899); + color: #fff; + border: none; + padding: 8px 16px; + border-radius: 8px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + gap: 6px; + transition: all 0.2s; + } + .btn-logout:hover { opacity: 0.9; transform: translateY(-1px); } - /* 登录区域 */ - .login-section { - background: rgba(255,255,255,0.05); - border-radius: 16px; - padding: 30px; + /* 主容器 */ + .main-container { + max-width: 1000px; + margin: 0 auto; + padding: 30px 24px; + } + + /* Tab 导航 */ + .tab-nav { + display: flex; + gap: 8px; + border-bottom: 1px solid #30363d; + margin-bottom: 30px; + justify-content: center; + } + .tab-btn { + background: none; + border: none; + color: #8b949e; + padding: 12px 20px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + border-bottom: 2px solid transparent; + transition: all 0.2s; + } + .tab-btn:hover { color: #e6edf3; } + .tab-btn.active { + color: #e6edf3; + border-bottom-color: #f472b6; + } + .tab-content { display: none; } + .tab-content.active { display: block; } + + /* 区块标题 */ + .section-title { + font-size: 18px; + font-weight: 600; + color: #e6edf3; margin-bottom: 20px; } - .login-section h2 { + + /* 统计卡片网格 */ + .stats-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 16px; + margin-bottom: 24px; + } + + /* 第一行:3列带边框卡片 */ + .stats-row-3 { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; + margin-bottom: 12px; + } + .stat-card-bordered { + background: #161b22; + border: 1px solid #30363d; + border-radius: 12px; + padding: 20px; + text-align: center; + position: relative; + } + .stat-card-bordered::before { + content: ''; + position: absolute; + top: -1px; + left: -1px; + right: -1px; + height: 3px; + border-radius: 12px 12px 0 0; + } + .stat-card-bordered.purple::before { background: linear-gradient(90deg, #a855f7, #7c3aed); } + .stat-card-bordered.pink::before { background: linear-gradient(90deg, #ec4899, #f472b6); } + .stat-card-bordered.green::before { background: linear-gradient(90deg, #10b981, #34d399); } + .stat-card-bordered.cyan::before { background: linear-gradient(90deg, #06b6d4, #22d3ee); } + .stat-card-bordered.orange::before { background: linear-gradient(90deg, #f59e0b, #fbbf24); } + .stat-card-bordered .label { + font-size: 12px; + color: #8b949e; + margin-bottom: 10px; + } + .stat-card-bordered .value { + font-size: 28px; + font-weight: 700; + color: #e6edf3; + } + .stat-card-bordered .value .dim { font-size: 18px; - color: #fff; - margin-bottom: 20px; + color: #8b949e; + font-weight: 400; + } + .stat-card-bordered.purple .value > span:first-child { color: #a855f7; } + .stat-card-bordered.green .value > span:first-child { color: #10b981; } + .stat-card-bordered.cyan .value { color: #22d3ee; } + + /* 第二行:2列大卡片 */ + .stats-row-2 { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; + margin-bottom: 24px; + } + .stat-card-large { + background: #161b22; + border: 1px solid #30363d; + border-radius: 12px; + padding: 28px 24px; text-align: center; } - .login-section.hidden { - display: none; + .stat-card-large .value { + font-size: 36px; + font-weight: 700; + margin-bottom: 8px; } - .input-group { - display: flex; - flex-direction: column; - gap: 15px; + .stat-card-large .value .highlight { color: #22d3ee; } + .stat-card-large .value .highlight.green { color: #10b981; } + .stat-card-large .value .dim { font-size: 24px; color: #8b949e; font-weight: 400; } + .stat-card-large .label { font-size: 13px; color: #8b949e; } + + .stat-card { + background: #161b22; + border: 1px solid #30363d; + border-radius: 12px; + padding: 20px; + text-align: center; + position: relative; + overflow: hidden; } - .input-group input { - width: 100%; - padding: 14px 18px; - border: 1px solid #333; - border-radius: 10px; - background: rgba(255,255,255,0.05); - color: #fff; - font-size: 15px; - font-family: monospace; + .stat-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; } - .input-group input:focus { - outline: none; - border-color: #4ade80; + .stat-card.purple::before { background: linear-gradient(90deg, #a855f7, #7c3aed); } + .stat-card.pink::before { background: linear-gradient(90deg, #ec4899, #f472b6); } + .stat-card.cyan::before { background: linear-gradient(90deg, #06b6d4, #22d3ee); } + .stat-card.green::before { background: linear-gradient(90deg, #10b981, #34d399); } + .stat-card.orange::before { background: linear-gradient(90deg, #f59e0b, #fbbf24); } + .stat-card .label { + font-size: 12px; + color: #8b949e; + margin-bottom: 8px; } - .input-group input::placeholder { - color: #666; + .stat-card .value { + font-size: 32px; + font-weight: 700; } - .btn { - padding: 14px 28px; - border: none; - border-radius: 10px; - cursor: pointer; - font-size: 15px; - font-weight: 600; - transition: all 0.2s; - width: 100%; + .stat-card.purple .value { color: #a855f7; } + .stat-card.pink .value { color: #ec4899; } + .stat-card.cyan .value { color: #22d3ee; } + .stat-card.green .value { color: #10b981; } + .stat-card.orange .value { color: #f59e0b; } + .stat-card .sub { font-size: 14px; color: #8b949e; } + + /* 大统计卡片 */ + .stats-large { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 16px; + margin-bottom: 24px; } - .btn-primary { - background: #4ade80; - color: #000; + .stat-large { + background: #161b22; + border: 1px solid #30363d; + border-radius: 12px; + padding: 24px; + text-align: center; } - .btn-primary:hover { - background: #22c55e; + .stat-large .value { + font-size: 42px; + font-weight: 700; + margin-bottom: 8px; } - .btn-primary:disabled { - background: #333; - color: #666; - cursor: not-allowed; - } - .remember-key { + .stat-large .value .highlight { color: #22d3ee; } + .stat-large .value .dim { color: #8b949e; } + .stat-large .label { font-size: 13px; color: #8b949e; } + + /* 操作卡片 */ + .action-card { + background: linear-gradient(135deg, #1e1b4b 0%, #312e81 100%); + border: 1px solid #4c1d95; + border-radius: 12px; + padding: 20px 24px; display: flex; align-items: center; - gap: 8px; - font-size: 13px; - color: #888; - } - .remember-key input[type="checkbox"] { - width: 16px; - height: 16px; - accent-color: #4ade80; - } - - /* 已登录区域 */ - .dashboard-section { - display: none; - } - .dashboard-section.active { - display: block; - } - - /* 用户信息卡片 */ - .user-card { - background: rgba(255,255,255,0.05); - border-radius: 16px; - padding: 25px; - margin-bottom: 20px; - } - .user-header { - display: flex; justify-content: space-between; - align-items: center; - margin-bottom: 20px; - flex-wrap: wrap; - gap: 10px; + margin-bottom: 24px; } - .user-info { + .action-card-content { display: flex; align-items: center; - gap: 15px; + gap: 16px; } - .user-avatar { - width: 50px; - height: 50px; - background: linear-gradient(135deg, #4ade80, #22c55e); - border-radius: 50%; + .action-card-icon { + width: 48px; + height: 48px; + background: rgba(168, 85, 247, 0.2); + border-radius: 12px; display: flex; align-items: center; justify-content: center; font-size: 24px; } - .user-details .user-name { - font-size: 18px; + .action-card-text h4 { + font-size: 15px; font-weight: 600; - color: #fff; - } - .user-details .user-key { - font-family: monospace; - font-size: 12px; - color: #666; - margin-top: 4px; - } - .user-status { - padding: 6px 14px; - border-radius: 20px; - font-size: 13px; - font-weight: 500; - } - .user-status.enabled { - background: rgba(74, 222, 128, 0.2); - color: #4ade80; - } - .user-status.disabled { - background: rgba(239, 68, 68, 0.2); - color: #ef4444; - } - .user-actions { - display: flex; - gap: 10px; - margin-top: 15px; - } - .action-btn { - background: rgba(255,255,255,0.1); - border: none; - padding: 8px 16px; - border-radius: 8px; - color: #888; - font-size: 13px; - cursor: pointer; - transition: all 0.2s; - display: flex; - align-items: center; - gap: 6px; - } - .action-btn:hover { - background: rgba(255,255,255,0.15); - color: #fff; - } - .action-btn.logout { - color: #ef4444; - } - .action-btn.logout:hover { - background: rgba(239, 68, 68, 0.2); - } - - /* 使用量统计 */ - .usage-stats { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); - gap: 15px; - margin-top: 20px; - } - .stat-card { - background: rgba(255,255,255,0.03); - border-radius: 12px; - padding: 18px; - text-align: center; - } - .stat-card .label { - font-size: 12px; - color: #888; - margin-bottom: 8px; - } - .stat-card .value { - font-size: 28px; - font-weight: bold; - color: #4ade80; - } - .stat-card .value.warning { color: #fbbf24; } - .stat-card .value.danger { color: #ef4444; } - - /* 进度条 */ - .usage-progress { - margin-top: 25px; - } - .progress-header { - display: flex; - justify-content: space-between; - margin-bottom: 10px; - font-size: 14px; - } - .progress-header .label { color: #888; } - .progress-header .value { color: #fff; font-weight: 600; } - .progress-bar { - height: 12px; - background: rgba(255,255,255,0.1); - border-radius: 6px; - overflow: hidden; - } - .progress-bar .fill { - height: 100%; - background: linear-gradient(90deg, #4ade80, #22c55e); - border-radius: 6px; - transition: width 0.5s ease; - } - .progress-bar .fill.warning { background: linear-gradient(90deg, #fbbf24, #f59e0b); } - .progress-bar .fill.danger { background: linear-gradient(90deg, #ef4444, #dc2626); } - .progress-info { - display: flex; - justify-content: space-between; - margin-top: 8px; - font-size: 12px; - color: #666; - } - - /* 上传授权文件区域 */ - .upload-section { - background: rgba(255,255,255,0.05); - border-radius: 16px; - padding: 25px; - margin-bottom: 20px; - } - .upload-section h3 { - font-size: 16px; - color: #fff; - margin-bottom: 8px; - } - .upload-desc { - font-size: 13px; - color: #888; - margin-bottom: 20px; - } - .upload-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); - gap: 15px; - } - .upload-card { - background: rgba(255,255,255,0.03); - border: 1px solid rgba(255,255,255,0.1); - border-radius: 12px; - padding: 18px; - text-align: center; - transition: all 0.2s; - } - .upload-card:hover { - border-color: rgba(74, 222, 128, 0.3); - background: rgba(255,255,255,0.05); - } - .upload-icon { - font-size: 28px; - margin-bottom: 10px; - } - .upload-name { - font-size: 14px; - font-weight: 600; - color: #fff; + color: #e6edf3; margin-bottom: 4px; } - .upload-provider { - font-size: 11px; - color: #666; - font-family: monospace; - margin-bottom: 12px; - } - .upload-btn { - width: 100%; - padding: 10px 16px; - border: none; - border-radius: 8px; - background: rgba(74, 222, 128, 0.2); - color: #4ade80; + .action-card-text p { font-size: 13px; - font-weight: 500; + color: #8b949e; + } + .btn-action { + background: linear-gradient(135deg, #7c3aed, #a855f7); + color: #fff; + border: none; + padding: 10px 20px; + border-radius: 8px; + font-size: 13px; + font-weight: 600; cursor: pointer; - transition: all 0.2s; - } - .upload-btn:hover { - background: rgba(74, 222, 128, 0.3); - } - .upload-btn:disabled { - background: rgba(255,255,255,0.1); - color: #666; - cursor: not-allowed; - } - .upload-btn .btn-loading { - display: inline-flex; + display: flex; align-items: center; gap: 6px; + transition: all 0.2s; + white-space: nowrap; } - .upload-status { - margin-top: 10px; - font-size: 12px; - min-height: 18px; - word-break: break-all; - overflow: hidden; - text-overflow: ellipsis; - max-width: 100%; - } - .upload-status.success { - color: #4ade80; - } - .upload-status.error { - color: #ef4444; - } + .btn-action:hover { opacity: 0.9; transform: translateY(-1px); } - /* 详细信息 */ - .detail-section { - background: rgba(255,255,255,0.05); - border-radius: 16px; - padding: 25px; - } - .detail-section h3 { - font-size: 16px; - color: #fff; - margin-bottom: 15px; - } - .detail-list { + /* 凭证列表 */ + .credentials-list { display: flex; flex-direction: column; gap: 12px; } - .detail-item { - display: flex; - justify-content: space-between; - padding: 10px 0; - border-bottom: 1px solid rgba(255,255,255,0.05); - } - .detail-item:last-child { border-bottom: none; } - .detail-item .label { color: #888; font-size: 14px; } - .detail-item .value { color: #fff; font-size: 14px; font-weight: 500; } - - /* 错误提示 */ - .error-message { - background: rgba(239, 68, 68, 0.1); - border: 1px solid rgba(239, 68, 68, 0.3); + .credential-item { + background: #161b22; + border: 1px solid #30363d; border-radius: 12px; - padding: 20px; - text-align: center; - color: #ef4444; - display: none; - margin-bottom: 20px; + padding: 16px 20px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + transition: all 0.2s; } - .error-message.active { display: block; } - .error-message .icon { font-size: 40px; margin-bottom: 10px; } - .error-message .title { font-size: 16px; font-weight: 600; margin-bottom: 5px; } - .error-message .desc { font-size: 14px; color: #888; } - - /* 加载状态 */ - .loading { - text-align: center; - padding: 40px; - color: #888; - display: none; + .credential-item:hover { + border-color: #8b949e; } - .loading.active { display: block; } - .loading .spinner { + .credential-info { + display: flex; + align-items: center; + gap: 14px; + flex: 1; + min-width: 0; + } + .credential-icon { width: 40px; height: 40px; - border: 3px solid rgba(255,255,255,0.1); - border-top-color: #4ade80; - border-radius: 50%; - animation: spin 1s linear infinite; - margin: 0 auto 15px; + background: #21262d; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + flex-shrink: 0; + } + .credential-details { flex: 1; min-width: 0; } + .credential-name { + font-size: 14px; + font-weight: 600; + color: #e6edf3; + margin-bottom: 4px; + } + .credential-path { + font-size: 12px; + color: #8b949e; + font-family: monospace; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .credential-status { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + padding: 4px 10px; + border-radius: 20px; + flex-shrink: 0; + } + .credential-status.healthy { + background: rgba(16, 185, 129, 0.15); + color: #10b981; + } + .credential-status.unhealthy { + background: rgba(239, 68, 68, 0.15); + color: #ef4444; + } + .credential-status.unknown { + background: rgba(139, 148, 158, 0.15); + color: #8b949e; + } + .credential-status.checking { + background: rgba(251, 191, 36, 0.15); + color: #fbbf24; + } + .credential-actions { + display: flex; + gap: 8px; + flex-shrink: 0; + } + .btn-icon { + background: #21262d; + border: 1px solid #30363d; + color: #8b949e; + width: 36px; + height: 36px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s; + } + .btn-icon:hover { + background: #30363d; + color: #e6edf3; + } + .btn-icon.danger:hover { + background: rgba(239, 68, 68, 0.15); + border-color: #ef4444; + color: #ef4444; } - @keyframes spin { to { transform: rotate(360deg); } } - /* 页脚 */ - footer { - margin-top: 40px; + /* 空状态 */ + .empty-state { text-align: center; - color: #666; + padding: 60px 20px; + color: #8b949e; + } + .empty-state .icon { font-size: 48px; margin-bottom: 16px; } + .empty-state p { font-size: 14px; } + + /* 登录页面 */ + .login-container { + min-height: calc(100vh - 60px); + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + } + .login-box { + background: #161b22; + border: 1px solid #30363d; + border-radius: 16px; + padding: 40px; + width: 100%; + max-width: 400px; + } + .login-box h2 { + font-size: 24px; + font-weight: 700; + color: #e6edf3; + text-align: center; + margin-bottom: 8px; + } + .login-box .subtitle { + text-align: center; + color: #8b949e; + font-size: 14px; + margin-bottom: 30px; + } + .form-group { margin-bottom: 20px; } + .form-group label { + display: block; + font-size: 13px; + color: #8b949e; + margin-bottom: 8px; + } + .form-input { + width: 100%; + background: #0d1117; + border: 1px solid #30363d; + border-radius: 8px; + padding: 12px 16px; + color: #e6edf3; + font-size: 14px; + font-family: monospace; + transition: all 0.2s; + } + .form-input:focus { + outline: none; + border-color: #a855f7; + box-shadow: 0 0 0 3px rgba(168, 85, 247, 0.15); + } + .form-input::placeholder { color: #484f58; } + .form-checkbox { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: #8b949e; + cursor: pointer; + } + .form-checkbox input { + width: 16px; + height: 16px; + accent-color: #a855f7; + } + .btn-login { + width: 100%; + background: linear-gradient(135deg, #7c3aed, #a855f7); + color: #fff; + border: none; + padding: 14px; + border-radius: 8px; + font-size: 15px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + } + .btn-login:hover { opacity: 0.9; } + .btn-login:disabled { + background: #30363d; + color: #484f58; + cursor: not-allowed; + } + + /* 模态框 */ + .modal-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.7); + display: none; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 20px; + } + .modal-overlay.active { display: flex; } + .modal { + background: #161b22; + border: 1px solid #30363d; + border-radius: 16px; + width: 100%; + max-width: 600px; + max-height: 90vh; + overflow: hidden; + display: flex; + flex-direction: column; + } + .modal-header { + padding: 20px 24px; + border-bottom: 1px solid #30363d; + display: flex; + align-items: center; + justify-content: space-between; + } + .modal-header h3 { + font-size: 18px; + font-weight: 600; + color: #e6edf3; + display: flex; + align-items: center; + gap: 10px; + } + .modal-close { + background: none; + border: none; + color: #8b949e; + font-size: 24px; + cursor: pointer; + padding: 4px; + line-height: 1; + } + .modal-close:hover { color: #e6edf3; } + .modal-body { + padding: 24px; + overflow-y: auto; + flex: 1; + } + .modal-footer { + padding: 16px 24px; + border-top: 1px solid #30363d; + display: flex; + justify-content: flex-end; + gap: 12px; + } + .btn-secondary { + background: #21262d; + border: 1px solid #30363d; + color: #e6edf3; + padding: 10px 20px; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + } + .btn-secondary:hover { background: #30363d; } + .btn-primary { + background: linear-gradient(135deg, #7c3aed, #a855f7); + border: none; + color: #fff; + padding: 10px 20px; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + } + .btn-primary:hover { opacity: 0.9; } + .btn-primary:disabled { + background: #30363d; + color: #484f58; + cursor: not-allowed; + } + + /* 模式切换 */ + .mode-toggle { + display: flex; + gap: 8px; + margin-bottom: 20px; + } + .mode-btn { + flex: 1; + background: #21262d; + border: 2px solid #30363d; + color: #8b949e; + padding: 12px; + border-radius: 8px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + } + .mode-btn:hover { border-color: #8b949e; } + .mode-btn.active { + background: rgba(168, 85, 247, 0.15); + border-color: #a855f7; + color: #a855f7; + } + + /* 文件上传区域 */ + .upload-zone { + border: 2px dashed #30363d; + border-radius: 12px; + padding: 40px 20px; + text-align: center; + cursor: pointer; + transition: all 0.2s; + margin-bottom: 16px; + } + .upload-zone:hover { + border-color: #a855f7; + background: rgba(168, 85, 247, 0.05); + } + .upload-zone .icon { font-size: 40px; margin-bottom: 12px; } + .upload-zone p { color: #8b949e; font-size: 14px; } + .upload-zone .hint { font-size: 12px; color: #484f58; margin-top: 8px; } + + /* 文件列表 */ + .file-list { + background: #0d1117; + border-radius: 8px; + padding: 12px; + margin-bottom: 16px; + } + .file-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: #161b22; + border-radius: 6px; + margin-bottom: 6px; + } + .file-item:last-child { margin-bottom: 0; } + .file-item-info { + display: flex; + align-items: center; + gap: 10px; + flex: 1; + min-width: 0; + } + .file-item-name { + font-size: 13px; + color: #e6edf3; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .file-item-fields { + font-size: 11px; + color: #8b949e; + } + .file-item-remove { + background: none; + border: none; + color: #8b949e; + cursor: pointer; + padding: 4px; + } + .file-item-remove:hover { color: #ef4444; } + + /* 验证结果 */ + .validation-result { + padding: 16px; + border-radius: 8px; + margin-bottom: 16px; + } + .validation-result.success { + background: rgba(16, 185, 129, 0.1); + border: 1px solid rgba(16, 185, 129, 0.3); + } + .validation-result.error { + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.3); + } + .validation-result h4 { + font-size: 14px; + font-weight: 600; + margin-bottom: 10px; + display: flex; + align-items: center; + gap: 8px; + } + .validation-result.success h4 { color: #10b981; } + .validation-result.error h4 { color: #ef4444; } + .validation-result ul { + list-style: none; font-size: 13px; } - footer a { color: #4ade80; text-decoration: none; } - footer a:hover { text-decoration: underline; } + .validation-result li { + padding: 4px 0; + display: flex; + align-items: center; + gap: 8px; + } + .validation-result .found { color: #10b981; } + .validation-result .missing { color: #ef4444; } - /* Toast 提示 */ + /* JSON 预览 */ + .json-preview { + background: #0d1117; + border-radius: 8px; + padding: 16px; + margin-bottom: 16px; + } + .json-preview-header { + font-size: 13px; + font-weight: 600; + color: #8b949e; + margin-bottom: 12px; + display: flex; + align-items: center; + gap: 8px; + } + .json-preview pre { + font-family: monospace; + font-size: 12px; + color: #7ee787; + white-space: pre-wrap; + word-break: break-all; + max-height: 150px; + overflow: auto; + } + + /* 提示信息 */ + .info-box { + background: rgba(168, 85, 247, 0.1); + border: 1px solid rgba(168, 85, 247, 0.3); + border-radius: 8px; + padding: 14px 16px; + margin-bottom: 20px; + } + .info-box p { + font-size: 13px; + color: #c4b5fd; + } + .info-box code { + background: rgba(0, 0, 0, 0.3); + padding: 2px 6px; + border-radius: 4px; + font-family: monospace; + font-size: 12px; + } + + /* Toast */ .toast { position: fixed; - bottom: 20px; - right: 20px; - padding: 15px 25px; - background: #1e293b; + bottom: 24px; + right: 24px; + background: #161b22; + border: 1px solid #30363d; border-radius: 8px; - color: #fff; - box-shadow: 0 4px 20px rgba(0,0,0,0.3); + padding: 14px 20px; + color: #e6edf3; + font-size: 14px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); transform: translateY(100px); opacity: 0; transition: all 0.3s; z-index: 2000; + display: flex; + align-items: center; + gap: 10px; } .toast.show { transform: translateY(0); opacity: 1; } - .toast.success { border-left: 4px solid #4ade80; } + .toast.success { border-left: 4px solid #10b981; } .toast.error { border-left: 4px solid #ef4444; } /* 响应式 */ - @media (max-width: 480px) { - .user-info { flex-direction: column; text-align: center; } - .user-header { flex-direction: column; } - .user-actions { justify-content: center; } - .usage-stats { grid-template-columns: repeat(2, 1fr); } + @media (max-width: 768px) { + .navbar-inner { padding: 0 16px; } + .navbar-user .welcome { display: none; } + .btn-logout span:first-child { display: none; } + .main-container { padding: 20px 16px; } + .stats-grid { grid-template-columns: repeat(2, 1fr); } + .stats-row-3 { grid-template-columns: repeat(3, 1fr); gap: 8px; } + .stats-row-2 { grid-template-columns: 1fr; gap: 8px; } + .stat-card-bordered .value { font-size: 22px; } + .stat-card-bordered .value .dim { font-size: 14px; } + .stat-card-large .value { font-size: 28px; } + .stat-card-large .value .dim { font-size: 18px; } + .stats-large { grid-template-columns: 1fr; } + .action-card { flex-direction: column; gap: 16px; text-align: center; } + .action-card-content { flex-direction: column; } + .credential-item { flex-direction: column; align-items: flex-start; } + .credential-actions { width: 100%; justify-content: flex-end; } + .tab-nav { overflow-x: auto; } + .tab-btn { white-space: nowrap; padding: 12px 16px; } } - + @media (max-width: 480px) { + .stats-grid { grid-template-columns: repeat(2, 1fr); } + .stats-row-3 { grid-template-columns: 1fr; } + .stat-card-bordered { padding: 16px; } + .stat-card-bordered .value { font-size: 24px; } + .stat-card .value { font-size: 28px; } + .stat-large .value { font-size: 32px; } + .upload-providers-grid { grid-template-columns: 1fr; } + } + + /* 提供商上传卡片 */ + .upload-providers-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 16px; + } + .upload-provider-card { + background: #161b22; + border: 1px solid #30363d; + border-radius: 12px; + padding: 20px; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + transition: all 0.2s; + } + .upload-provider-card:hover { + border-color: #8b949e; + } + .upload-provider-icon { + font-size: 32px; + margin-bottom: 12px; + } + .upload-provider-info { + margin-bottom: 16px; + } + .upload-provider-name { + font-size: 15px; + font-weight: 600; + color: #e6edf3; + margin-bottom: 4px; + } + .upload-provider-type { + font-size: 11px; + color: #8b949e; + font-family: monospace; + } + .btn-upload { + width: 100%; + background: #21262d; + border: 1px solid #30363d; + color: #e6edf3; + padding: 10px 16px; + border-radius: 8px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + } + .btn-upload:hover { + background: #30363d; + border-color: #8b949e; + } + .btn-upload:disabled { + opacity: 0.6; + cursor: not-allowed; + } + .upload-provider-status { + margin-top: 10px; + font-size: 12px; + min-height: 18px; + word-break: break-all; + } + .upload-provider-status.success { color: #10b981; } + .upload-provider-status.error { color: #ef4444; } + -
-
-

🍲 API 大锅饭

-

使用 API Key 登录查看用量

-
- - -