AIClient-2-API/static/potluck.html
hex2077 fa19bae517 refactor(potluck): 简化 API 大锅饭系统并增强安全性和 UI
- 移除凭证管理和资源包系统,简化为基于每日限额的 Key 管理
- 新增登录安全防护(频率限制、账户锁定、IP 追踪)
- 重构日志系统使用 AsyncLocalStorage 替代全局状态
- 全面升级 UI 界面(主题切换、使用分布统计、响应式设计)
- 优化安装脚本(PowerShell 支持、手动安装指引)

BREAKING CHANGE: API Potluck 插件不再支持凭证资源包功能,所有 Key 仅基于每日限额进行配额管理。user-data-manager 模块已禁用,相关 API 端点已移除。
2026-03-05 17:21:47 +08:00

1162 lines
46 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#059669">
<title>API 大锅饭 - Key 管理</title>
<link rel="stylesheet" href="app/base.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
body {
background: var(--bg-secondary);
min-height: 100vh;
}
/* 顶部导航 */
.navbar {
background: var(--bg-primary);
border-bottom: 1px solid var(--border-color);
position: sticky;
top: 0;
z-index: 100;
box-shadow: var(--shadow-sm);
}
.navbar-inner {
max-width: 1400px;
margin: 0 auto;
padding: 0 24px;
height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
}
.navbar-brand {
display: flex;
align-items: center;
gap: 12px;
font-size: 20px;
font-weight: 700;
color: var(--text-primary);
}
.navbar-brand .icon {
font-size: 28px;
color: var(--primary-color);
}
.navbar-brand .badge {
background: linear-gradient(135deg, var(--primary-color), var(--primary-light));
color: #ffffff;
font-size: 11px;
padding: 2px 8px;
border-radius: var(--radius-full);
font-weight: 600;
}
/* 主容器 */
.main-container {
max-width: 1400px;
margin: 0 auto;
padding: 30px 24px;
}
/* 统计卡片网格 */
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius-xl);
padding: 1.5rem;
text-align: center;
position: relative;
overflow: hidden;
box-shadow: var(--shadow-sm);
transition: var(--transition);
}
.stat-card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-lg);
border-color: var(--primary-30);
}
.stat-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
}
.stat-card.purple::before { background: linear-gradient(90deg, var(--indigo-500), var(--indigo-600)); }
.stat-card.green::before { background: linear-gradient(90deg, var(--success-color), var(--primary-light)); }
.stat-card.pink::before { background: linear-gradient(90deg, #ec4899, #f472b6); }
.stat-card.cyan::before { background: linear-gradient(90deg, var(--info-color), #22d3ee); }
.stat-card .label {
font-size: 12px;
color: var(--text-secondary);
margin-bottom: 8px;
}
.stat-card .value {
font-size: 2rem;
font-weight: 700;
color: var(--text-primary);
}
.stat-card.purple .value { color: var(--indigo-500); }
.stat-card.green .value { color: var(--success-color); }
.stat-card.pink .value { color: #ec4899; }
.stat-card.cyan .value { color: var(--info-color); }
/* 分布统计样式 */
.distribution-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.distribution-item {
width: 100%;
}
.dist-info {
display: flex;
justify-content: space-between;
font-size: 13px;
margin-bottom: 4px;
}
.dist-name {
color: var(--text-primary);
font-weight: 500;
}
.dist-count {
color: var(--text-secondary);
}
.dist-bar {
height: 8px;
background: var(--bg-tertiary);
border-radius: var(--radius-full);
overflow: hidden;
}
.dist-fill {
height: 100%;
background: linear-gradient(90deg, var(--primary-color), var(--primary-light));
border-radius: var(--radius-full);
}
/* 区块标题 */
.section-title {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 1.5rem;
display: flex;
align-items: center;
gap: 0.75rem;
}
/* 工具栏 */
.toolbar {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.search-box {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 10px 14px;
color: var(--text-primary);
font-size: 14px;
width: 220px;
transition: var(--transition);
flex: 1;
min-width: 180px;
}
.sort-select {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 10px 14px;
color: var(--text-primary);
font-size: 14px;
cursor: pointer;
transition: var(--transition);
flex-shrink: 0;
}
.search-box:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 4px var(--primary-10);
}
.search-box::placeholder { color: var(--text-tertiary); }
/* Key 列表 */
.keys-section { margin-top: 10px; }
.keys-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 16px;
}
.keys-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.key-card {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius-xl);
padding: 20px;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 16px;
transition: var(--transition);
box-shadow: var(--shadow-sm);
}
.key-card:hover {
border-color: var(--primary-color);
box-shadow: var(--shadow-md);
}
.key-card.disabled { opacity: 0.6; }
.key-info { flex: 1; min-width: 200px; }
.key-name {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 6px;
}
.key-id {
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 8px;
}
.btn-copy {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
cursor: pointer;
font-size: 12px;
padding: 4px 8px;
border-radius: var(--radius-md);
transition: var(--transition);
}
.btn-copy:hover {
background: var(--bg-tertiary);
color: var(--primary-color);
border-color: var(--primary-color);
}
/* Key 统计 */
.key-stats {
display: flex;
gap: 24px;
flex-wrap: wrap;
}
.key-stat {
text-align: center;
min-width: 70px;
}
.key-stat .label {
font-size: 11px;
color: var(--text-secondary);
margin-bottom: 4px;
}
.key-stat .value {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.key-stat .value.warning { color: var(--warning-color); }
.key-stat .value.danger { color: var(--danger-color); }
.key-stat .value.muted { color: var(--text-tertiary); font-size: 12px; }
/* Key 详情中的分布 */
.key-dist-mini {
margin-top: 10px;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.dist-badge {
font-size: 10px;
padding: 2px 6px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
color: var(--text-secondary);
display: inline-flex;
align-items: center;
gap: 4px;
}
.dist-badge b { color: var(--primary-color); }
.progress-bar {
height: 6px;
background: var(--bg-tertiary);
border-radius: var(--radius-full);
overflow: hidden;
margin-top: 6px;
}
.progress-bar .fill {
height: 100%;
background: linear-gradient(90deg, var(--success-color), var(--primary-light));
transition: width 0.3s;
}
.progress-bar .fill.warning { background: linear-gradient(90deg, var(--warning-color), #fbbf24); }
.progress-bar .fill.danger { background: linear-gradient(90deg, var(--danger-color), #f87171); }
/* Key 操作 */
.key-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
/* 模态框 */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: var(--overlay-bg);
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
backdrop-filter: blur(4px);
}
.modal-overlay.active { display: flex; }
.modal {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius-xl);
padding: 0;
max-width: 500px;
width: 100%;
overflow: hidden;
box-shadow: var(--shadow-xl);
animation: slideIn 0.3s ease;
}
.modal-header {
padding: 20px 24px;
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
}
.modal-header h3 {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
.modal-close {
background: none;
border: none;
color: var(--text-secondary);
font-size: 24px;
cursor: pointer;
padding: 4px;
line-height: 1;
transition: var(--transition);
}
.modal-close:hover { color: var(--text-primary); }
.modal-body { padding: 24px; }
.modal-footer {
padding: 16px 24px;
border-top: 1px solid var(--border-color);
display: flex;
justify-content: flex-end;
gap: 12px;
}
/* 表单 */
.form-group { margin-bottom: 20px; }
.form-group label {
display: block;
font-size: 13px;
color: var(--text-secondary);
font-weight: 500;
margin-bottom: 8px;
}
.form-group input {
width: 100%;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 12px 16px;
color: var(--text-primary);
font-size: 14px;
transition: var(--transition);
}
.form-group input:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 4px var(--primary-10);
}
.form-group input::placeholder { color: var(--text-tertiary); }
/* Key 显示 */
.key-display {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 16px;
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
color: var(--success-color);
word-break: break-all;
margin: 16px 0;
}
/* 空状态 */
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-secondary);
}
.empty-state .icon { font-size: 48px; margin-bottom: 16px; opacity: 0.5; }
.empty-state p { font-size: 14px; margin-bottom: 20px; }
.no-results {
text-align: center;
padding: 40px;
color: var(--text-secondary);
}
.btn {
height: 2.5rem;
}
/* 响应式 */
@media (max-width: 1024px) {
.key-card { gap: 12px; }
.key-stats { gap: 16px; }
.key-stat { min-width: 60px; }
.stats-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 768px) {
.navbar-inner { padding: 0 16px; }
.main-container { padding: 20px 16px; }
.stats-grid { grid-template-columns: 1fr; }
.key-card {
flex-direction: column;
align-items: stretch;
gap: 16px;
}
.key-info { margin-bottom: 4px; }
.key-stats {
width: 100%;
justify-content: flex-start;
gap: 20px;
flex-wrap: wrap;
}
.key-stat { min-width: 70px; text-align: left; }
.key-actions {
width: 100%;
justify-content: flex-start;
padding-top: 12px;
border-top: 1px solid var(--border-color);
}
}
@media (max-width: 480px) {
.stat-card .value { font-size: 1.5rem; }
.keys-header { flex-direction: column; align-items: stretch; gap: 12px; }
.toolbar {
flex-direction: column;
align-items: stretch;
}
.toolbar .search-wrapper {
display: flex;
gap: 8px;
width: 100%;
}
.search-box {
flex: 1;
min-width: 0;
}
.sort-select {
width: auto;
flex-shrink: 0;
}
.toolbar .btn-limit-wrapper {
width: 100%;
}
.toolbar .btn-limit-wrapper .btn {
width: 100%;
justify-content: center;
}
.key-stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.key-stat {
min-width: unset;
text-align: center;
}
.key-actions {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.navbar-brand .badge { display: none; }
.navbar-actions { gap: 8px; }
#createKeyBtn .btn-text { display: none; }
.theme-toggle { width: 36px; height: 36px; font-size: 1rem; }
}
@keyframes slideIn {
from { transform: scale(0.95); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
/* 按钮扩展样式 */
.btn-block {
width: 100%;
justify-content: center;
}
.btn-lg {
padding: 0.75rem 1.5rem;
font-size: 1rem;
}
/* 移动端 Key 操作按钮样式 */
@media (max-width: 480px) {
.key-actions .btn-sm {
padding: 10px 12px;
font-size: 13px;
height: 44px;
}
}
/* 主题切换按钮 */
.theme-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
padding: 0;
background: var(--bg-tertiary);
color: var(--text-secondary);
border: 1px solid var(--border-color);
border-radius: 50%;
cursor: pointer;
font-size: 1.125rem;
transition: var(--transition);
}
.theme-toggle:hover {
background: var(--bg-secondary);
border-color: var(--primary-color);
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.theme-toggle .fa-sun { display: none; }
.theme-toggle .fa-moon { display: inline-block; }
[data-theme="dark"] .theme-toggle .fa-sun { display: inline-block; }
[data-theme="dark"] .theme-toggle .fa-moon { display: none; }
</style>
</head>
<body>
<!-- 顶部导航 -->
<nav class="navbar">
<div class="navbar-inner">
<div class="navbar-brand">
<span class="icon">🍲</span>
<span>API 大锅饭</span>
<span class="badge">管理端</span>
</div>
<div class="navbar-actions" style="display: flex; align-items: center; gap: 12px;">
<button class="theme-toggle" id="themeToggle" title="切换主题">
<i class="fas fa-sun"></i>
<i class="fas fa-moon"></i>
</button>
<button id="createKeyBtn" class="btn btn-primary">
<i class="fas fa-plus"></i> <span class="btn-text">生成新 Key</span>
</button>
</div>
</div>
</nav>
<!-- 主内容区 -->
<div class="main-container">
<!-- 统计卡片 -->
<div class="stats-grid">
<div class="stat-card purple">
<div class="label">总 Key 数</div>
<div class="value" id="totalKeys">0</div>
</div>
<div class="stat-card green">
<div class="label">已启用</div>
<div class="value" id="enabledKeys">0</div>
</div>
<div class="stat-card pink">
<div class="label">今日总调用</div>
<div class="value" id="todayUsage">0</div>
</div>
<div class="stat-card cyan">
<div class="label">累计调用</div>
<div class="value" id="totalUsage">0</div>
</div>
</div>
<!-- 使用分布统计区域 -->
<div id="usageStatsSection" style="margin-bottom: 2.5rem; display: none;">
<div class="section-title">
<i class="fas fa-chart-pie"></i> 最近 7 天使用分布统计
</div>
<div class="stats-grid" style="grid-template-columns: repeat(2, 1fr); gap: 1.5rem;">
<!-- 提供商分布 -->
<div class="stat-card" style="text-align: left; padding: 20px;">
<div class="label" style="margin-bottom: 15px; font-weight: 600; display: flex; justify-content: space-between;">
<span>提供商占比 (Providers)</span>
<span id="providerTotalCount" style="color: var(--primary-color);">0 次</span>
</div>
<div id="providerDistribution" class="distribution-list">
<!-- 动态填充 -->
</div>
</div>
<!-- 模型分布 -->
<div class="stat-card" style="text-align: left; padding: 20px;">
<div class="label" style="margin-bottom: 15px; font-weight: 600; display: flex; justify-content: space-between;">
<span>模型活跃度 (Models)</span>
<span id="modelTotalCount" style="color: var(--primary-color);">0 次</span>
</div>
<div id="modelDistribution" class="distribution-list">
<!-- 动态填充 -->
</div>
</div>
</div>
</div>
<!-- Key 列表区域 -->
<div class="keys-section">
<div class="keys-header">
<h2 class="section-title" style="margin-bottom: 0;"><i class="fas fa-key"></i> Key 列表</h2>
<div class="toolbar">
<div class="search-wrapper">
<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 class="btn-limit-wrapper">
<button class="btn btn-secondary btn-sm" onclick="showApplyLimitModal()">
<i class="fas fa-cog"></i> <span class="btn-limit-text">批量应用限额</span>
</button>
</div>
</div>
</div>
<div id="keysList" class="keys-list"></div>
</div>
</div>
<!-- 创建 Key 模态框 -->
<div id="createModal" class="modal-overlay">
<div class="modal">
<div class="modal-header">
<h3><i class="fas fa-plus-circle"></i> 生成新 API Key</h3>
<button class="modal-close" onclick="closeModal('createModal')">&times;</button>
</div>
<div class="modal-body">
<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" placeholder="500" min="1">
</div>
<button class="btn btn-primary btn-block" onclick="createKey()">
<i class="fas fa-check"></i> 生成 Key
</button>
</div>
</div>
</div>
<!-- 显示新 Key 模态框 -->
<div id="showKeyModal" class="modal-overlay">
<div class="modal">
<div class="modal-header">
<h3><i class="fas fa-key"></i> Key 已生成</h3>
<button class="modal-close" onclick="closeModal('showKeyModal')">&times;</button>
</div>
<div class="modal-body">
<p style="color:var(--text-secondary);margin-bottom:10px">请妥善保存此 Key关闭后将无法再次查看完整内容</p>
<div class="key-display" id="newKeyDisplay"></div>
<button class="btn btn-secondary btn-block" onclick="copyKey()">
<i class="fas fa-copy"></i> 复制 Key
</button>
</div>
</div>
</div>
<!-- 修改限额模态框 -->
<div id="editLimitModal" class="modal-overlay">
<div class="modal">
<div class="modal-header">
<h3><i class="fas fa-sliders-h"></i> 修改每日限额</h3>
<button class="modal-close" onclick="closeModal('editLimitModal')">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>新的每日限额</label>
<input type="number" id="newLimit" min="1">
</div>
<input type="hidden" id="editKeyId">
<button class="btn btn-primary btn-block" onclick="updateLimit()">
<i class="fas fa-save"></i> 保存
</button>
</div>
</div>
</div>
<!-- 批量应用限额模态框 -->
<div id="applyLimitModal" class="modal-overlay">
<div class="modal">
<div class="modal-header">
<h3><i class="fas fa-cog"></i> 批量应用每日限额</h3>
<button class="modal-close" onclick="closeModal('applyLimitModal')">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>每日调用限额</label>
<input type="number" id="applyLimitValue" placeholder="500" min="1">
</div>
<div style="background: var(--danger-bg); border: 1px solid var(--danger-border); border-radius: var(--radius-lg); padding: 12px; margin-top: 12px;">
<p style="color: var(--danger-text); font-size: 13px; margin: 0;"><i class="fas fa-exclamation-triangle"></i> 风险提示:</p>
<ul style="color: var(--danger-text); font-size: 12px; margin: 8px 0 0 16px; padding: 0;">
<li>所有 Key 的每日限额将被覆盖</li>
<li>已单独设置限额的 Key 也会被修改</li>
<li>此操作不可撤销</li>
</ul>
</div>
<button class="btn btn-primary btn-block" style="margin-top: 16px;" onclick="applyDailyLimitToAll()">
<i class="fas fa-check"></i> 应用限额
</button>
</div>
</div>
</div>
<script>
let currentNewKey = '';
let allKeys = [];
let systemConfig = { defaultDailyLimit: 500 };
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;
// 渲染使用历史分布
renderUsageHistory(stats.usageHistory);
allKeys = keys;
applyFilterAndSort();
}
/**
* 渲染最近 7 天使用历史分布
*/
function renderUsageHistory(usageHistory) {
if (!usageHistory || Object.keys(usageHistory).length === 0) {
document.getElementById('usageStatsSection').style.display = 'none';
return;
}
document.getElementById('usageStatsSection').style.display = 'block';
const aggregatedProviders = {};
const aggregatedModels = {};
let totalCalls = 0;
// 汇总最近 7 天的数据
Object.values(usageHistory).forEach(day => {
if (day.providers) {
Object.entries(day.providers).forEach(([p, count]) => {
aggregatedProviders[p] = (aggregatedProviders[p] || 0) + count;
totalCalls += count;
});
}
if (day.models) {
Object.entries(day.models).forEach(([m, count]) => {
aggregatedModels[m] = (aggregatedModels[m] || 0) + count;
});
}
});
if (totalCalls === 0) {
document.getElementById('usageStatsSection').style.display = 'none';
return;
}
// 渲染提供商分布
renderDistribution('providerDistribution', aggregatedProviders, totalCalls);
document.getElementById('providerTotalCount').textContent = `${totalCalls}`;
// 渲染模型分布 (模型总数与提供商总数一致)
renderDistribution('modelDistribution', aggregatedModels, totalCalls);
document.getElementById('modelTotalCount').textContent = `${totalCalls}`;
}
function renderDistribution(elementId, data, total) {
const container = document.getElementById(elementId);
const sorted = Object.entries(data).sort((a, b) => b[1] - a[1]);
// 最多显示前 6 个
const topData = sorted.slice(0, 6);
container.innerHTML = topData.map(([name, count]) => {
const percent = Math.round((count / total) * 100);
return `
<div class="distribution-item">
<div class="dist-info">
<span class="dist-name">${escapeHtml(name)}</span>
<span class="dist-count">${count} 次 (${percent}%)</span>
</div>
<div class="dist-bar">
<div class="dist-fill" style="width: ${percent}%"></div>
</div>
</div>
`;
}).join('');
if (sorted.length > 6) {
const otherCount = sorted.slice(6).reduce((sum, item) => sum + item[1], 0);
const otherPercent = Math.round((otherCount / total) * 100);
container.innerHTML += `
<div class="distribution-item">
<div class="dist-info">
<span class="dist-name" style="font-style: italic;">其他 (${sorted.length - 6} 个)</span>
<span class="dist-count">${otherCount} 次 (${otherPercent}%)</span>
</div>
<div class="dist-bar">
<div class="dist-fill" style="width: ${otherPercent}%; background: #94a3b8;"></div>
</div>
</div>
`;
}
}
// 显示批量应用限额模态框
function showApplyLimitModal() {
document.getElementById('applyLimitValue').value = systemConfig.defaultDailyLimit || 500;
openModal('applyLimitModal');
}
// 批量应用每日限额
async function applyDailyLimitToAll() {
const dailyLimit = parseInt(document.getElementById('applyLimitValue').value);
if (!dailyLimit || dailyLimit < 1) {
showToast('请输入有效的限额', 'error');
return;
}
showToast('正在应用限额...', 'success');
const result = await apiRequest(`${API_BASE}/keys/apply-limit`, {
method: 'POST',
body: JSON.stringify({ dailyLimit })
});
if (result && result.success) {
showToast(result.message, 'success');
closeModal('applyLimitModal');
loadData();
} else {
showToast(result?.error?.message || '操作失败', 'error');
}
}
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.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}
function renderKeys(keys) {
const container = document.getElementById('keysList');
if (allKeys.length === 0) {
container.innerHTML = '<div class="empty-state"><div class="icon"><i class="fas fa-inbox"></i></div><p>还没有任何 API Key</p><button class="btn btn-primary btn-lg" onclick="openModal(\'createModal\')"><i class="fas fa-plus"></i> 生成第一个 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' : '';
// 汇总该 Key 的提供商分布 (最近 7 天)
const providers = {};
if (key.usageHistory) {
Object.values(key.usageHistory).forEach(day => {
if (day.providers) {
Object.entries(day.providers).forEach(([p, count]) => {
providers[p] = (providers[p] || 0) + count;
});
}
});
}
const topProviders = Object.entries(providers)
.sort((a, b) => b[1] - a[1])
.slice(0, 3);
const providerBadges = topProviders.map(([name, count]) =>
`<span class="dist-badge">${escapeHtml(name)}: <b>${count}</b></span>`
).join('');
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"><i class="fas fa-copy"></i></button></div>
${providerBadges ? `<div class="key-dist-mini">${providerBadges}</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 ? 'var(--success-color)' : 'var(--danger-color)'}">${key.enabled ? '启用' : '禁用'}</div>
</div>
</div>
<div class="key-actions">
<button class="btn btn-secondary btn-sm" onclick="resetUsage('${key.id}')" title="重置今日用量"><i class="fas fa-redo"></i> <span class="btn-text">重置</span></button>
<button class="btn btn-secondary btn-sm" onclick="openEditLimit('${key.id}', ${key.dailyLimit})" title="修改限额"><i class="fas fa-sliders-h"></i> <span class="btn-text">限额</span></button>
<button class="btn btn-secondary btn-sm" onclick="toggleKey('${key.id}')" title="${key.enabled ? '禁用' : '启用'}">${key.enabled ? '<i class="fas fa-toggle-on"></i>' : '<i class="fas fa-toggle-off"></i>'} <span class="btn-text">${key.enabled ? '禁用' : '启用'}</span></button>
<button class="btn btn-danger btn-sm" onclick="deleteKey('${key.id}')" title="删除"><i class="fas fa-trash"></i> <span class="btn-text">删除</span></button>
</div>
</div>`;
}).join('');
}
async function createKey() {
const name = document.getElementById('keyName').value;
const dailyLimit = parseInt(document.getElementById('keyLimit').value) || systemConfig.defaultDailyLimit;
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 = '';
} else {
showToast(result?.error?.message || '创建失败', 'error');
}
}
function copyToClipboardFallback(text) {
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-9999px';
textArea.style.top = '-9999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand('copy');
showToast('已复制到剪贴板', 'success');
} catch (err) {
showToast('复制失败,请手动复制', 'error');
}
document.body.removeChild(textArea);
}
function copyKey() {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(currentNewKey).then(() => showToast('已复制到剪贴板', 'success')).catch(() => copyToClipboardFallback(currentNewKey));
} else {
copyToClipboardFallback(currentNewKey);
}
}
function copyToClipboard(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(() => showToast('Key 已复制', 'success')).catch(() => copyToClipboardFallback(text));
} else {
copyToClipboardFallback(text);
}
}
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') {
// 使用主页面的 toast 系统
const toastContainer = document.querySelector('.toast-container') || createToastContainer();
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = message;
toastContainer.appendChild(toast);
setTimeout(() => {
toast.remove();
}, 3000);
}
function createToastContainer() {
const container = document.createElement('div');
container.className = 'toast-container';
document.body.appendChild(container);
return container;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text || '';
return div.innerHTML;
}
// 主题切换
function setupTheme() {
const savedTheme = localStorage.getItem('theme') || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
if (savedTheme === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
} else {
document.documentElement.removeAttribute('data-theme');
}
}
// 事件绑定
document.getElementById('createKeyBtn').addEventListener('click', () => openModal('createModal'));
document.getElementById('searchBox').addEventListener('input', applyFilterAndSort);
document.getElementById('sortSelect').addEventListener('change', applyFilterAndSort);
document.getElementById('themeToggle')?.addEventListener('click', () => {
const currentTheme = document.documentElement.getAttribute('data-theme');
if (currentTheme === 'dark') {
document.documentElement.removeAttribute('data-theme');
localStorage.setItem('theme', 'light');
} else {
document.documentElement.setAttribute('data-theme', 'dark');
localStorage.setItem('theme', 'dark');
}
});
// 初始化
setupTheme();
if (!getToken()) { window.location.href = '/login.html'; } else { loadData(); }
</script>
</body>
</html>