feat(api-potluck): 添加 API 大锅饭插件功能(彩蛋)

1. 新增 api-potluck 模块,支持 API 密钥认证和用量记录
2. 在 request-handler.js 中集成大锅饭路由和认证中间件
3. 在 common.js 中添加用量记录调用逻辑
4. 新增 potluck.html 静态页面和配置文件 api-potluck-keys.json
This commit is contained in:
leonai 2026-01-08 20:15:59 +08:00
parent deb261d068
commit dbc98dae74
8 changed files with 1068 additions and 4 deletions

3
.gitignore vendored
View file

@ -9,4 +9,5 @@ input_system_prompt.txt
token-store.json
usage-cache.json
*_oauth_creds.json
*-auth-token.json
*-auth-token.json
api-potluck-keys.json

View file

@ -0,0 +1,277 @@
/**
* API 大锅饭 - 管理 API 路由
* 提供 Key 管理的 RESTful API
*/
import {
createKey,
listKeys,
getKey,
deleteKey,
updateKeyLimit,
resetKeyUsage,
toggleKey,
updateKeyName,
getStats
} from './key-manager.js';
/**
* 解析请求体
* @param {http.IncomingMessage} req
* @returns {Promise<Object>}
*/
function parseRequestBody(req) {
return new Promise((resolve, reject) => {
let body = '';
req.on('data', chunk => {
body += chunk.toString();
});
req.on('end', () => {
try {
resolve(body ? JSON.parse(body) : {});
} catch (error) {
reject(new Error('Invalid JSON format'));
}
});
req.on('error', reject);
});
}
/**
* 发送 JSON 响应
* @param {http.ServerResponse} res
* @param {number} statusCode
* @param {Object} data
*/
function sendJson(res, statusCode, data) {
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(data));
}
/**
* 验证管理员 Token
* @param {http.IncomingMessage} req
* @returns {Promise<boolean>}
*/
async function checkAdminAuth(req) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return false;
}
// 动态导入 ui-manager 中的 token 验证逻辑
try {
const { existsSync, readFileSync } = await import('fs');
const { promises: fs } = await import('fs');
const path = await import('path');
const TOKEN_STORE_FILE = path.join(process.cwd(), 'configs', 'token-store.json');
if (!existsSync(TOKEN_STORE_FILE)) {
return false;
}
const content = readFileSync(TOKEN_STORE_FILE, 'utf8');
const tokenStore = JSON.parse(content);
const token = authHeader.substring(7);
const tokenInfo = tokenStore.tokens[token];
if (!tokenInfo) {
return false;
}
// 检查是否过期
if (Date.now() > tokenInfo.expiryTime) {
return false;
}
return true;
} catch (error) {
console.error('[API Potluck] Auth check error:', error.message);
return false;
}
}
/**
* 处理 Potluck 管理 API 请求
* @param {string} method - HTTP 方法
* @param {string} path - 请求路径
* @param {http.IncomingMessage} req - HTTP 请求对象
* @param {http.ServerResponse} res - HTTP 响应对象
* @returns {Promise<boolean>} - 是否处理了请求
*/
export async function handlePotluckApiRoutes(method, path, req, res) {
// 只处理 /api/potluck 开头的请求
if (!path.startsWith('/api/potluck')) {
return false;
}
// 验证管理员权限
const isAuthed = await checkAdminAuth(req);
if (!isAuthed) {
sendJson(res, 401, {
success: false,
error: { message: 'Unauthorized: Please login first', code: 'UNAUTHORIZED' }
});
return true;
}
try {
// GET /api/potluck/stats - 获取统计信息
if (method === 'GET' && path === '/api/potluck/stats') {
const stats = await getStats();
sendJson(res, 200, { success: true, data: stats });
return true;
}
// GET /api/potluck/keys - 获取所有 Key 列表
if (method === 'GET' && path === '/api/potluck/keys') {
const keys = await listKeys();
const stats = await getStats();
sendJson(res, 200, {
success: true,
data: {
keys,
stats
}
});
return true;
}
// POST /api/potluck/keys - 创建新 Key
if (method === 'POST' && path === '/api/potluck/keys') {
const body = await parseRequestBody(req);
const { name, dailyLimit } = body;
const keyData = await createKey(name, dailyLimit);
sendJson(res, 201, {
success: true,
message: 'API Key created successfully',
data: keyData
});
return true;
}
// 处理带 keyId 的路由
const keyIdMatch = path.match(/^\/api\/potluck\/keys\/([^\/]+)(\/.*)?$/);
if (keyIdMatch) {
const keyId = decodeURIComponent(keyIdMatch[1]);
const subPath = keyIdMatch[2] || '';
// GET /api/potluck/keys/:keyId - 获取单个 Key 详情
if (method === 'GET' && !subPath) {
const keyData = await getKey(keyId);
if (!keyData) {
sendJson(res, 404, { success: false, error: { message: 'Key not found' } });
return true;
}
sendJson(res, 200, { success: true, data: keyData });
return true;
}
// DELETE /api/potluck/keys/:keyId - 删除 Key
if (method === 'DELETE' && !subPath) {
const deleted = await deleteKey(keyId);
if (!deleted) {
sendJson(res, 404, { success: false, error: { message: 'Key not found' } });
return true;
}
sendJson(res, 200, { success: true, message: 'Key deleted successfully' });
return true;
}
// PUT /api/potluck/keys/:keyId/limit - 更新每日限额
if (method === 'PUT' && subPath === '/limit') {
const body = await parseRequestBody(req);
const { dailyLimit } = body;
if (typeof dailyLimit !== 'number' || dailyLimit < 0) {
sendJson(res, 400, {
success: false,
error: { message: 'Invalid dailyLimit value' }
});
return true;
}
const keyData = await updateKeyLimit(keyId, dailyLimit);
if (!keyData) {
sendJson(res, 404, { success: false, error: { message: 'Key not found' } });
return true;
}
sendJson(res, 200, {
success: true,
message: 'Daily limit updated successfully',
data: keyData
});
return true;
}
// POST /api/potluck/keys/:keyId/reset - 重置当天调用次数
if (method === 'POST' && subPath === '/reset') {
const keyData = await resetKeyUsage(keyId);
if (!keyData) {
sendJson(res, 404, { success: false, error: { message: 'Key not found' } });
return true;
}
sendJson(res, 200, {
success: true,
message: 'Usage reset successfully',
data: keyData
});
return true;
}
// POST /api/potluck/keys/:keyId/toggle - 切换启用/禁用状态
if (method === 'POST' && subPath === '/toggle') {
const keyData = await toggleKey(keyId);
if (!keyData) {
sendJson(res, 404, { success: false, error: { message: 'Key not found' } });
return true;
}
sendJson(res, 200, {
success: true,
message: `Key ${keyData.enabled ? 'enabled' : 'disabled'} successfully`,
data: keyData
});
return true;
}
// PUT /api/potluck/keys/:keyId/name - 更新 Key 名称
if (method === 'PUT' && subPath === '/name') {
const body = await parseRequestBody(req);
const { name } = body;
if (!name || typeof name !== 'string') {
sendJson(res, 400, {
success: false,
error: { message: 'Invalid name value' }
});
return true;
}
const keyData = await updateKeyName(keyId, name);
if (!keyData) {
sendJson(res, 404, { success: false, error: { message: 'Key not found' } });
return true;
}
sendJson(res, 200, {
success: true,
message: 'Name updated successfully',
data: keyData
});
return true;
}
}
// 未匹配的 potluck 路由
sendJson(res, 404, { success: false, error: { message: 'Potluck API endpoint not found' } });
return true;
} catch (error) {
console.error('[API Potluck] API error:', error);
sendJson(res, 500, {
success: false,
error: { message: error.message || 'Internal server error' }
});
return true;
}
}

33
src/api-potluck/index.js Normal file
View file

@ -0,0 +1,33 @@
/**
* API 大锅饭 - 模块入口
* 导出所有功能供外部使用
*/
// Key 管理
export {
createKey,
listKeys,
getKey,
deleteKey,
updateKeyLimit,
resetKeyUsage,
toggleKey,
updateKeyName,
validateKey,
incrementUsage,
getStats,
KEY_PREFIX,
DEFAULT_DAILY_LIMIT
} from './key-manager.js';
// 中间件
export {
extractPotluckKey,
isPotluckRequest,
potluckAuthMiddleware,
recordPotluckUsage,
sendPotluckError
} from './middleware.js';
// API 路由
export { handlePotluckApiRoutes } from './api-routes.js';

View file

@ -0,0 +1,297 @@
/**
* API 大锅饭 - Key 管理模块
* 使用内存缓存 + 写锁 + 定期持久化解决并发安全问题
*/
import { promises as fs } from 'fs';
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秒持久化一次
// 内存缓存
let keyStore = null;
let isDirty = false;
let isWriting = false;
let persistTimer = null;
/**
* 初始化从文件加载数据到内存
*/
function ensureLoaded() {
if (keyStore !== null) return;
try {
if (existsSync(KEYS_STORE_FILE)) {
const content = readFileSync(KEYS_STORE_FILE, 'utf8');
keyStore = JSON.parse(content);
} else {
keyStore = { keys: {} };
syncWriteToFile();
}
} catch (error) {
console.error('[API Potluck] Failed to load key store:', error.message);
keyStore = { keys: {} };
}
// 启动定期持久化
if (!persistTimer) {
persistTimer = setInterval(persistIfDirty, PERSIST_INTERVAL);
// 进程退出时保存
process.on('beforeExit', () => persistIfDirty());
process.on('SIGINT', () => { persistIfDirty(); process.exit(0); });
process.on('SIGTERM', () => { persistIfDirty(); process.exit(0); });
}
}
/**
* 同步写入文件仅初始化时使用
*/
function syncWriteToFile() {
try {
const dir = path.dirname(KEYS_STORE_FILE);
if (!existsSync(dir)) {
require('fs').mkdirSync(dir, { recursive: true });
}
writeFileSync(KEYS_STORE_FILE, JSON.stringify(keyStore, null, 2), 'utf8');
} catch (error) {
console.error('[API Potluck] Sync write failed:', error.message);
}
}
/**
* 异步持久化带写锁
*/
async function persistIfDirty() {
if (!isDirty || isWriting || keyStore === null) return;
isWriting = true;
try {
const dir = path.dirname(KEYS_STORE_FILE);
if (!existsSync(dir)) {
await fs.mkdir(dir, { recursive: true });
}
// 写入临时文件再重命名,防止写入中断导致文件损坏
const tempFile = KEYS_STORE_FILE + '.tmp';
await fs.writeFile(tempFile, JSON.stringify(keyStore, null, 2), 'utf8');
await fs.rename(tempFile, KEYS_STORE_FILE);
isDirty = false;
} catch (error) {
console.error('[API Potluck] Persist failed:', error.message);
} finally {
isWriting = false;
}
}
/**
* 标记数据已修改
*/
function markDirty() {
isDirty = true;
}
/**
* 生成随机 API Key
*/
function generateApiKey() {
return `${KEY_PREFIX}${crypto.randomBytes(16).toString('hex')}`;
}
/**
* 获取今天的日期字符串 (YYYY-MM-DD)
*/
function getTodayDateString() {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
}
/**
* 检查并重置过期的每日计数
*/
function checkAndResetDailyCount(keyData) {
const today = getTodayDateString();
if (keyData.lastResetDate !== today) {
keyData.todayUsage = 0;
keyData.lastResetDate = today;
}
return keyData;
}
/**
* 创建新的 API Key
*/
export async function createKey(name = '', dailyLimit = DEFAULT_DAILY_LIMIT) {
ensureLoaded();
const apiKey = generateApiKey();
const now = new Date().toISOString();
const today = getTodayDateString();
const keyData = {
id: apiKey,
name: name || `Key-${Object.keys(keyStore.keys).length + 1}`,
createdAt: now,
dailyLimit,
todayUsage: 0,
totalUsage: 0,
lastResetDate: today,
lastUsedAt: null,
enabled: true
};
keyStore.keys[apiKey] = keyData;
markDirty();
await persistIfDirty(); // 创建操作立即持久化
console.log(`[API Potluck] Created key: ${apiKey.substring(0, 12)}...`);
return keyData;
}
/**
* 获取所有 Key 列表
*/
export async function listKeys() {
ensureLoaded();
const keys = [];
for (const [keyId, keyData] of Object.entries(keyStore.keys)) {
const updated = checkAndResetDailyCount({ ...keyData });
keys.push({
...updated,
maskedKey: `${keyId.substring(0, 12)}...${keyId.substring(keyId.length - 4)}`
});
}
return keys;
}
/**
* 获取单个 Key 详情
*/
export async function getKey(keyId) {
ensureLoaded();
const keyData = keyStore.keys[keyId];
if (!keyData) return null;
return checkAndResetDailyCount({ ...keyData });
}
/**
* 删除 Key
*/
export async function deleteKey(keyId) {
ensureLoaded();
if (!keyStore.keys[keyId]) return false;
delete keyStore.keys[keyId];
markDirty();
await persistIfDirty(); // 删除操作立即持久化
console.log(`[API Potluck] Deleted key: ${keyId.substring(0, 12)}...`);
return true;
}
/**
* 更新 Key 的每日限额
*/
export async function updateKeyLimit(keyId, newLimit) {
ensureLoaded();
if (!keyStore.keys[keyId]) return null;
keyStore.keys[keyId].dailyLimit = newLimit;
markDirty();
return keyStore.keys[keyId];
}
/**
* 重置 Key 的当天调用次数
*/
export async function resetKeyUsage(keyId) {
ensureLoaded();
if (!keyStore.keys[keyId]) return null;
keyStore.keys[keyId].todayUsage = 0;
keyStore.keys[keyId].lastResetDate = getTodayDateString();
markDirty();
return keyStore.keys[keyId];
}
/**
* 切换 Key 的启用/禁用状态
*/
export async function toggleKey(keyId) {
ensureLoaded();
if (!keyStore.keys[keyId]) return null;
keyStore.keys[keyId].enabled = !keyStore.keys[keyId].enabled;
markDirty();
return keyStore.keys[keyId];
}
/**
* 更新 Key 名称
*/
export async function updateKeyName(keyId, newName) {
ensureLoaded();
if (!keyStore.keys[keyId]) return null;
keyStore.keys[keyId].name = newName;
markDirty();
return keyStore.keys[keyId];
}
/**
* 验证 API Key 是否有效且有配额
*/
export async function validateKey(apiKey) {
ensureLoaded();
if (!apiKey || !apiKey.startsWith(KEY_PREFIX)) {
return { valid: false, reason: 'invalid_format' };
}
const keyData = keyStore.keys[apiKey];
if (!keyData) return { valid: false, reason: 'not_found' };
if (!keyData.enabled) return { valid: false, reason: 'disabled' };
// 直接在内存中检查和重置
checkAndResetDailyCount(keyData);
if (keyData.todayUsage >= keyData.dailyLimit) {
return { valid: false, reason: 'quota_exceeded', keyData };
}
return { valid: true, keyData };
}
/**
* 增加 Key 的使用次数原子操作直接修改内存
*/
export async function incrementUsage(apiKey) {
ensureLoaded();
const keyData = keyStore.keys[apiKey];
if (!keyData) return null;
checkAndResetDailyCount(keyData);
keyData.todayUsage += 1;
keyData.totalUsage += 1;
keyData.lastUsedAt = new Date().toISOString();
markDirty();
// 不立即持久化,由定时器批量写入
return keyData;
}
/**
* 获取统计信息
*/
export async function getStats() {
ensureLoaded();
const keys = Object.values(keyStore.keys);
let enabledKeys = 0, todayTotalUsage = 0, totalUsage = 0;
for (const key of keys) {
checkAndResetDailyCount(key);
if (key.enabled) enabledKeys++;
todayTotalUsage += key.todayUsage;
totalUsage += key.totalUsage;
}
return {
totalKeys: keys.length,
enabledKeys,
disabledKeys: keys.length - enabledKeys,
todayTotalUsage,
totalUsage
};
}
// 导出常量
export { KEY_PREFIX, DEFAULT_DAILY_LIMIT };

View file

@ -0,0 +1,152 @@
/**
* API 大锅饭 - 中间件模块
* 负责请求拦截和配额检查
*/
import { validateKey, incrementUsage, KEY_PREFIX } from './key-manager.js';
/**
* 从请求中提取 Potluck API Key
* 支持多种认证方式
* 1. Authorization: Bearer maki_xxx
* 2. x-api-key: maki_xxx
* 3. x-goog-api-key: maki_xxx
* 4. URL query: ?key=maki_xxx
*
* @param {http.IncomingMessage} req - HTTP 请求对象
* @param {URL} requestUrl - 解析后的 URL 对象
* @returns {string|null} 提取到的 API Key如果不是 potluck key 则返回 null
*/
export function extractPotluckKey(req, requestUrl) {
// 1. 检查 Authorization header
const authHeader = req.headers['authorization'];
if (authHeader && authHeader.startsWith('Bearer ')) {
const token = authHeader.substring(7);
if (token.startsWith(KEY_PREFIX)) {
return token;
}
}
// 2. 检查 x-api-key header (Claude style)
const xApiKey = req.headers['x-api-key'];
if (xApiKey && xApiKey.startsWith(KEY_PREFIX)) {
return xApiKey;
}
// 3. 检查 x-goog-api-key header (Gemini style)
const googApiKey = req.headers['x-goog-api-key'];
if (googApiKey && googApiKey.startsWith(KEY_PREFIX)) {
return googApiKey;
}
// 4. 检查 URL query parameter
const queryKey = requestUrl.searchParams.get('key');
if (queryKey && queryKey.startsWith(KEY_PREFIX)) {
return queryKey;
}
return null;
}
/**
* 检查请求是否使用 Potluck Key
* @param {http.IncomingMessage} req - HTTP 请求对象
* @param {URL} requestUrl - 解析后的 URL 对象
* @returns {boolean}
*/
export function isPotluckRequest(req, requestUrl) {
return extractPotluckKey(req, requestUrl) !== null;
}
/**
* Potluck 认证中间件
* 验证 Potluck API Key 并检查配额
*
* @param {http.IncomingMessage} req - HTTP 请求对象
* @param {URL} requestUrl - 解析后的 URL 对象
* @returns {Promise<{authorized: boolean, error?: Object, keyData?: Object, apiKey?: string}>}
*/
export async function potluckAuthMiddleware(req, requestUrl) {
const apiKey = extractPotluckKey(req, requestUrl);
if (!apiKey) {
// 不是 potluck 请求,返回 null 让原有逻辑处理
return { authorized: null };
}
// 验证 Key
const validation = await validateKey(apiKey);
if (!validation.valid) {
const errorMessages = {
'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'
};
const statusCodes = {
'invalid_format': 401,
'not_found': 401,
'disabled': 403,
'quota_exceeded': 429
};
return {
authorized: false,
error: {
statusCode: statusCodes[validation.reason] || 401,
message: errorMessages[validation.reason] || 'Authentication failed',
code: validation.reason,
keyData: validation.keyData
}
};
}
return {
authorized: true,
keyData: validation.keyData,
apiKey: apiKey
};
}
/**
* 记录 Potluck 请求使用
* 在请求成功处理后调用
*
* @param {string} apiKey - API Key
* @returns {Promise<Object|null>}
*/
export async function recordPotluckUsage(apiKey) {
if (!apiKey || !apiKey.startsWith(KEY_PREFIX)) {
return null;
}
return incrementUsage(apiKey);
}
/**
* 创建 Potluck 错误响应
* @param {http.ServerResponse} res - HTTP 响应对象
* @param {Object} error - 错误信息
*/
export function sendPotluckError(res, error) {
const response = {
error: {
message: error.message,
code: error.code,
type: 'potluck_error'
}
};
// 如果是配额超限,添加额外信息
if (error.code === 'quota_exceeded' && error.keyData) {
response.error.quota = {
used: error.keyData.todayUsage,
limit: error.keyData.dailyLimit,
resetDate: error.keyData.lastResetDate
};
}
res.writeHead(error.statusCode, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(response));
}

View file

@ -490,6 +490,15 @@ export async function handleContentGenerationRequest(req, res, service, endpoint
} else {
await handleUnaryRequest(res, service, model, processedRequestBody, fromProvider, toProvider, CONFIG.PROMPT_LOG_MODE, PROMPT_LOG_FILENAME, providerPoolManager, actualUuid, actualCustomName);
}
// ============== API 大锅饭插件 - 开始 ==============
if (CONFIG.potluckApiKey) {
try {
const { recordPotluckUsage } = await import('./api-potluck/index.js');
await recordPotluckUsage(CONFIG.potluckApiKey);
} catch (e) { /* 静默失败,不影响主流程 */ }
}
// ============== API 大锅饭插件 - 结束 ==============
}
/**

View file

@ -8,6 +8,10 @@ import { MODEL_PROVIDER } from './common.js';
import { PROMPT_LOG_FILENAME } from './config-manager.js';
import { handleOllamaRequest, handleOllamaShow } from './ollama-handler.js';
// ============== API 大锅饭插件 - 开始 ==============
import { handlePotluckApiRoutes, potluckAuthMiddleware, sendPotluckError } from './api-potluck/index.js';
// ============== API 大锅饭插件 - 结束 ==============
/**
* Parse request body as JSON
*/
@ -54,7 +58,8 @@ export function createRequestHandler(config, providerPoolManager) {
}
// Serve static files for UI (除了登录页面需要认证)
if (path.startsWith('/static/') || path === '/' || path === '/favicon.ico' || path === '/index.html' || path.startsWith('/app/') || path === '/login.html') {
// ============== API 大锅饭插件: 添加 /potluck.html ==============
if (path.startsWith('/static/') || path === '/' || path === '/favicon.ico' || path === '/index.html' || path.startsWith('/app/') || path === '/login.html' || path === '/potluck.html') {
const served = await serveStaticFiles(path, res);
if (served) return;
}
@ -62,6 +67,11 @@ export function createRequestHandler(config, providerPoolManager) {
const uiHandled = await handleUIApiRequests(method, path, req, res, currentConfig, providerPoolManager);
if (uiHandled) return;
// ============== API 大锅饭插件 - 开始 ==============
const potluckRouteHandled = await handlePotluckApiRoutes(method, path, req, res);
if (potluckRouteHandled) return;
// ============== API 大锅饭插件 - 结束 ==============
// Ollama show endpoint with model name
if (method === 'POST' && path === '/ollama/api/show') {
await handleOllamaShow(req, res);
@ -144,7 +154,16 @@ export function createRequestHandler(config, providerPoolManager) {
}
// Check authentication for API requests (before Ollama handling to allow unauthenticated Ollama endpoints)
if (!isAuthorized(req, requestUrl, currentConfig.REQUIRED_API_KEY)) {
// ============== API 大锅饭插件 - 开始 ==============
const potluckAuth = await potluckAuthMiddleware(req, requestUrl);
if (potluckAuth.authorized === false) {
sendPotluckError(res, potluckAuth.error);
return;
} else if (potluckAuth.authorized === true) {
currentConfig.potluckApiKey = potluckAuth.apiKey;
console.log(`[API Potluck] Authorized with key: ${potluckAuth.apiKey.substring(0, 12)}...`);
} else if (!isAuthorized(req, requestUrl, currentConfig.REQUIRED_API_KEY)) {
// ============== API 大锅饭插件 - 结束 ==============
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: { message: 'Unauthorized: API key is invalid or missing.' } }));
return;
@ -217,4 +236,4 @@ export function createRequestHandler(config, providerPoolManager) {
handleError(res, error, currentConfig.MODEL_PROVIDER);
}
};
}
}

276
static/potluck.html Normal file
View file

@ -0,0 +1,276 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>API 大锅饭 - Key 管理</title>
<style>
* { 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%); min-height: 100vh; color: #e0e0e0; }
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
header { display: flex; justify-content: space-between; align-items: center; padding: 20px 0; border-bottom: 1px solid #333; flex-wrap: wrap; gap: 10px; }
h1 { font-size: 24px; color: #fff; display: flex; align-items: center; gap: 10px; }
h1 span { font-size: 28px; }
.stats-bar { display: flex; gap: 20px; margin: 20px 0; flex-wrap: wrap; }
.stat-card { background: rgba(255,255,255,0.05); border-radius: 12px; padding: 15px 25px; min-width: 150px; }
.stat-card .label { font-size: 12px; color: #888; margin-bottom: 5px; }
.stat-card .value { font-size: 24px; font-weight: bold; color: #4ade80; }
.btn { padding: 10px 20px; border: none; border-radius: 8px; cursor: pointer; font-size: 14px; transition: all 0.2s; }
.btn-primary { background: #4ade80; color: #000; font-weight: 600; }
.btn-primary:hover { background: #22c55e; }
.btn-secondary { background: rgba(255,255,255,0.1); color: #fff; }
.btn-secondary:hover { background: rgba(255,255,255,0.2); }
.btn-danger { background: #ef4444; color: #fff; }
.btn-danger:hover { background: #dc2626; }
.btn-sm { padding: 6px 12px; font-size: 12px; }
.keys-section { margin-top: 30px; }
.keys-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; flex-wrap: wrap; gap: 10px; }
.keys-header h2 { font-size: 18px; color: #fff; }
.toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.search-box { padding: 8px 12px; border: 1px solid #333; border-radius: 8px; background: rgba(255,255,255,0.05); color: #fff; font-size: 14px; width: 200px; }
.search-box:focus { outline: none; border-color: #4ade80; }
.sort-select { padding: 8px 12px; border: 1px solid #333; border-radius: 8px; background: rgba(255,255,255,0.05); color: #fff; font-size: 14px; cursor: pointer; }
.sort-select:focus { outline: none; border-color: #4ade80; }
.keys-list { display: flex; flex-direction: column; gap: 12px; }
.key-card { background: rgba(255,255,255,0.05); border-radius: 12px; padding: 20px; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 15px; }
.key-card.disabled { opacity: 0.5; }
.key-info { flex: 1; min-width: 200px; }
.key-name { font-size: 16px; font-weight: 600; color: #fff; margin-bottom: 5px; }
.key-id { font-family: monospace; font-size: 13px; color: #888; display: flex; align-items: center; gap: 8px; }
.btn-copy { background: none; border: none; cursor: pointer; font-size: 14px; padding: 2px 6px; border-radius: 4px; transition: background 0.2s; }
.btn-copy:hover { background: rgba(255,255,255,0.1); }
.key-stats { display: flex; gap: 20px; flex-wrap: wrap; }
.key-stat { text-align: center; min-width: 80px; }
.key-stat .label { font-size: 11px; color: #666; }
.key-stat .value { font-size: 14px; font-weight: 600; }
.key-stat .value.warning { color: #fbbf24; }
.key-stat .value.danger { color: #ef4444; }
.key-stat .value.muted { color: #666; font-size: 12px; }
.key-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.progress-bar { width: 80px; height: 6px; background: rgba(255,255,255,0.1); border-radius: 3px; overflow: hidden; margin-top: 5px; }
.progress-bar .fill { height: 100%; background: #4ade80; transition: width 0.3s; }
.progress-bar .fill.warning { background: #fbbf24; }
.progress-bar .fill.danger { background: #ef4444; }
.modal { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.7); justify-content: center; align-items: center; z-index: 1000; }
.modal.active { display: flex; }
.modal-content { background: #1e293b; border-radius: 16px; padding: 30px; max-width: 500px; width: 90%; }
.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.modal-header h3 { color: #fff; font-size: 18px; }
.modal-close { background: none; border: none; color: #888; font-size: 24px; cursor: pointer; }
.form-group { margin-bottom: 15px; }
.form-group label { display: block; margin-bottom: 5px; color: #888; font-size: 13px; }
.form-group input { width: 100%; padding: 12px; border: 1px solid #333; border-radius: 8px; background: rgba(255,255,255,0.05); color: #fff; font-size: 14px; }
.form-group input:focus { outline: none; border-color: #4ade80; }
.key-display { background: #0f172a; padding: 15px; border-radius: 8px; font-family: monospace; font-size: 14px; color: #4ade80; word-break: break-all; margin: 15px 0; }
.empty-state { text-align: center; padding: 60px 20px; color: #666; }
.empty-state p { margin-bottom: 20px; }
.toast { position: fixed; bottom: 20px; right: 20px; padding: 15px 25px; background: #1e293b; border-radius: 8px; color: #fff; box-shadow: 0 4px 20px rgba(0,0,0,0.3); transform: translateY(100px); opacity: 0; transition: all 0.3s; z-index: 2000; }
.toast.show { transform: translateY(0); opacity: 1; }
.toast.success { border-left: 4px solid #4ade80; }
.toast.error { border-left: 4px solid #ef4444; }
.no-results { text-align: center; padding: 40px; color: #666; }
</style>
</head>
<body>
<div class="container">
<header>
<h1><span>🍲</span> API 大锅饭</h1>
<button id="createKeyBtn" class="btn btn-primary">+ 生成新 Key</button>
</header>
<div class="stats-bar">
<div class="stat-card"><div class="label">总 Key 数</div><div class="value" id="totalKeys">0</div></div>
<div class="stat-card"><div class="label">已启用</div><div class="value" id="enabledKeys">0</div></div>
<div class="stat-card"><div class="label">今日总调用</div><div class="value" id="todayUsage">0</div></div>
<div class="stat-card"><div class="label">累计调用</div><div class="value" id="totalUsage">0</div></div>
</div>
<div class="keys-section">
<div class="keys-header">
<h2>Key 列表</h2>
<div class="toolbar">
<input type="text" id="searchBox" class="search-box" placeholder="搜索名称或 Key...">
<select id="sortSelect" class="sort-select">
<option value="name-asc">名称 A-Z</option>
<option value="name-desc">名称 Z-A</option>
<option value="usage-desc">今日用量 ↓</option>
<option value="usage-asc">今日用量 ↑</option>
<option value="total-desc">累计用量 ↓</option>
<option value="lastUsed-desc">最近使用 ↓</option>
<option value="created-desc">创建时间 ↓</option>
</select>
</div>
</div>
<div id="keysList" class="keys-list"></div>
</div>
</div>
<div id="createModal" class="modal">
<div class="modal-content">
<div class="modal-header"><h3>生成新 API Key</h3><button class="modal-close" onclick="closeModal('createModal')">&times;</button></div>
<div class="form-group"><label>Key 名称 (可选)</label><input type="text" id="keyName" placeholder="例如测试用户1"></div>
<div class="form-group"><label>每日调用限额</label><input type="number" id="keyLimit" value="1000" min="1"></div>
<button class="btn btn-primary" style="width:100%" onclick="createKey()">生成 Key</button>
</div>
</div>
<div id="showKeyModal" class="modal">
<div class="modal-content">
<div class="modal-header"><h3>🎉 Key 已生成</h3><button class="modal-close" onclick="closeModal('showKeyModal')">&times;</button></div>
<p style="color:#888;margin-bottom:10px">请妥善保存此 Key关闭后将无法再次查看完整内容</p>
<div class="key-display" id="newKeyDisplay"></div>
<button class="btn btn-secondary" style="width:100%" onclick="copyKey()">📋 复制 Key</button>
</div>
</div>
<div id="editLimitModal" class="modal">
<div class="modal-content">
<div class="modal-header"><h3>修改每日限额</h3><button class="modal-close" onclick="closeModal('editLimitModal')">&times;</button></div>
<div class="form-group"><label>新的每日限额</label><input type="number" id="newLimit" min="1"></div>
<input type="hidden" id="editKeyId">
<button class="btn btn-primary" style="width:100%" onclick="updateLimit()">保存</button>
</div>
</div>
<div id="toast" class="toast"></div>
<script>
let currentNewKey = '';
let allKeys = [];
const API_BASE = '/api/potluck';
function getToken() { return localStorage.getItem('authToken'); }
async function apiRequest(url, options = {}) {
const token = getToken();
const headers = { 'Content-Type': 'application/json', ...options.headers };
if (token) headers['Authorization'] = `Bearer ${token}`;
const response = await fetch(url, { ...options, headers });
const data = await response.json();
if (response.status === 401) { window.location.href = '/login.html'; return null; }
return data;
}
async function loadData() {
const result = await apiRequest(`${API_BASE}/keys`);
if (!result || !result.success) { showToast('加载失败', 'error'); return; }
const { keys, stats } = result.data;
document.getElementById('totalKeys').textContent = stats.totalKeys;
document.getElementById('enabledKeys').textContent = stats.enabledKeys;
document.getElementById('todayUsage').textContent = stats.todayTotalUsage;
document.getElementById('totalUsage').textContent = stats.totalUsage;
allKeys = keys;
applyFilterAndSort();
}
function applyFilterAndSort() {
const searchTerm = document.getElementById('searchBox').value.toLowerCase().trim();
const sortValue = document.getElementById('sortSelect').value;
let filtered = allKeys;
if (searchTerm) {
filtered = allKeys.filter(k => k.name.toLowerCase().includes(searchTerm) || k.id.toLowerCase().includes(searchTerm));
}
const [field, order] = sortValue.split('-');
filtered.sort((a, b) => {
let va, vb;
if (field === 'name') { va = a.name.toLowerCase(); vb = b.name.toLowerCase(); }
else if (field === 'usage') { va = a.todayUsage; vb = b.todayUsage; }
else if (field === 'total') { va = a.totalUsage; vb = b.totalUsage; }
else if (field === 'lastUsed') { va = a.lastUsedAt || ''; vb = b.lastUsedAt || ''; }
else if (field === 'created') { va = a.createdAt || ''; vb = b.createdAt || ''; }
if (va < vb) return order === 'asc' ? -1 : 1;
if (va > vb) return order === 'asc' ? 1 : -1;
return 0;
});
renderKeys(filtered);
}
function formatTime(isoStr) {
if (!isoStr) return '从未';
const d = new Date(isoStr);
const now = new Date();
const diff = now - d;
if (diff < 60000) return '刚刚';
if (diff < 3600000) return Math.floor(diff / 60000) + '分钟前';
if (diff < 86400000) return Math.floor(diff / 3600000) + '小时前';
if (diff < 604800000) return Math.floor(diff / 86400000) + '天前';
return d.toLocaleDateString();
}
function renderKeys(keys) {
const container = document.getElementById('keysList');
if (allKeys.length === 0) {
container.innerHTML = '<div class="empty-state"><p>还没有任何 API Key</p><button class="btn btn-primary" onclick="openModal(\'createModal\')">生成第一个 Key</button></div>';
return;
}
if (keys.length === 0) {
container.innerHTML = '<div class="no-results">没有匹配的结果</div>';
return;
}
container.innerHTML = keys.map(key => {
const usagePercent = key.dailyLimit > 0 ? (key.todayUsage / key.dailyLimit * 100) : 0;
const progressClass = usagePercent >= 90 ? 'danger' : usagePercent >= 70 ? 'warning' : '';
const valueClass = usagePercent >= 90 ? 'danger' : usagePercent >= 70 ? 'warning' : '';
return `<div class="key-card ${key.enabled ? '' : 'disabled'}">
<div class="key-info"><div class="key-name">${escapeHtml(key.name)}</div><div class="key-id">${key.maskedKey} <button class="btn-copy" onclick="copyToClipboard('${key.id}')" title="复制完整 Key">📋</button></div></div>
<div class="key-stats">
<div class="key-stat"><div class="label">今日/限额</div><div class="value ${valueClass}">${key.todayUsage}/${key.dailyLimit}</div><div class="progress-bar"><div class="fill ${progressClass}" style="width:${Math.min(usagePercent, 100)}%"></div></div></div>
<div class="key-stat"><div class="label">累计</div><div class="value">${key.totalUsage}</div></div>
<div class="key-stat"><div class="label">最后调用</div><div class="value muted">${formatTime(key.lastUsedAt)}</div></div>
<div class="key-stat"><div class="label">状态</div><div class="value" style="color:${key.enabled ? '#4ade80' : '#ef4444'}">${key.enabled ? '启用' : '禁用'}</div></div>
</div>
<div class="key-actions">
<button class="btn btn-secondary btn-sm" onclick="resetUsage('${key.id}')">重置</button>
<button class="btn btn-secondary btn-sm" onclick="openEditLimit('${key.id}', ${key.dailyLimit})">限额</button>
<button class="btn btn-secondary btn-sm" onclick="toggleKey('${key.id}')">${key.enabled ? '禁用' : '启用'}</button>
<button class="btn btn-danger btn-sm" onclick="deleteKey('${key.id}')">删除</button>
</div>
</div>`;
}).join('');
}
async function createKey() {
const name = document.getElementById('keyName').value;
const dailyLimit = parseInt(document.getElementById('keyLimit').value) || 1000;
const result = await apiRequest(`${API_BASE}/keys`, { method: 'POST', body: JSON.stringify({ name, dailyLimit }) });
if (result && result.success) {
currentNewKey = result.data.id;
document.getElementById('newKeyDisplay').textContent = currentNewKey;
closeModal('createModal'); openModal('showKeyModal'); loadData();
document.getElementById('keyName').value = ''; document.getElementById('keyLimit').value = '1000';
} else { showToast(result?.error?.message || '创建失败', 'error'); }
}
function copyKey() { navigator.clipboard.writeText(currentNewKey).then(() => showToast('已复制到剪贴板', 'success')); }
function copyToClipboard(text) { navigator.clipboard.writeText(text).then(() => showToast('Key 已复制', 'success')); }
async function resetUsage(keyId) {
if (!confirm('确定要重置该 Key 的今日调用次数吗?')) return;
const result = await apiRequest(`${API_BASE}/keys/${encodeURIComponent(keyId)}/reset`, { method: 'POST' });
if (result && result.success) { showToast('已重置', 'success'); loadData(); }
else { showToast(result?.error?.message || '操作失败', 'error'); }
}
function openEditLimit(keyId, currentLimit) {
document.getElementById('editKeyId').value = keyId;
document.getElementById('newLimit').value = currentLimit;
openModal('editLimitModal');
}
async function updateLimit() {
const keyId = document.getElementById('editKeyId').value;
const dailyLimit = parseInt(document.getElementById('newLimit').value);
if (!dailyLimit || dailyLimit < 1) { showToast('请输入有效的限额', 'error'); return; }
const result = await apiRequest(`${API_BASE}/keys/${encodeURIComponent(keyId)}/limit`, { method: 'PUT', body: JSON.stringify({ dailyLimit }) });
if (result && result.success) { showToast('限额已更新', 'success'); closeModal('editLimitModal'); loadData(); }
else { showToast(result?.error?.message || '操作失败', 'error'); }
}
async function toggleKey(keyId) {
const result = await apiRequest(`${API_BASE}/keys/${encodeURIComponent(keyId)}/toggle`, { method: 'POST' });
if (result && result.success) { showToast(result.message, 'success'); loadData(); }
else { showToast(result?.error?.message || '操作失败', 'error'); }
}
async function deleteKey(keyId) {
if (!confirm('确定要删除该 Key 吗?此操作不可恢复。')) return;
const result = await apiRequest(`${API_BASE}/keys/${encodeURIComponent(keyId)}`, { method: 'DELETE' });
if (result && result.success) { showToast('已删除', 'success'); loadData(); }
else { showToast(result?.error?.message || '删除失败', 'error'); }
}
function openModal(id) { document.getElementById(id).classList.add('active'); }
function closeModal(id) { document.getElementById(id).classList.remove('active'); }
function showToast(message, type = 'success') {
const toast = document.getElementById('toast');
toast.textContent = message; toast.className = `toast ${type} show`;
setTimeout(() => toast.classList.remove('show'), 3000);
}
function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text || ''; return div.innerHTML; }
document.getElementById('createKeyBtn').addEventListener('click', () => openModal('createModal'));
document.getElementById('searchBox').addEventListener('input', applyFilterAndSort);
document.getElementById('sortSelect').addEventListener('change', applyFilterAndSort);
if (!getToken()) { window.location.href = '/login.html'; } else { loadData(); }
</script>
</body>
</html>