- 新增 `model-usage-stats` 插件,提供模型级别的 token 用量统计和 API 接口 - 增强 API Potluck 插件,记录并展示 prompt、completion 和 total tokens 用量 - 更新插件管理器以支持禁用插件的路由拦截和静态文件访问控制 - 在前端页面中展示 token 用量统计数据 - 升级版本号至 2.13.3
763 lines
29 KiB
HTML
763 lines
29 KiB
HTML
<!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 大锅饭 - 我的用量</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: 1000px;
|
|
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;
|
|
}
|
|
.navbar-user {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
}
|
|
.navbar-user .welcome {
|
|
color: var(--text-secondary);
|
|
font-size: 14px;
|
|
}
|
|
.navbar-user .welcome strong { color: var(--text-primary); }
|
|
|
|
/* 主题切换按钮 */
|
|
.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; }
|
|
|
|
/* 主容器 */
|
|
.main-container {
|
|
max-width: 1000px;
|
|
margin: 0 auto;
|
|
padding: 30px 24px;
|
|
}
|
|
|
|
/* 区块标题 */
|
|
.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;
|
|
}
|
|
|
|
/* 统计卡片 */
|
|
.stats-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 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.cyan::before { background: linear-gradient(90deg, var(--info-color), #22d3ee); }
|
|
.stat-card.orange::before { background: linear-gradient(90deg, #f59e0b, #fbbf24); }
|
|
.stat-card .label {
|
|
font-size: 12px;
|
|
color: var(--text-secondary);
|
|
margin-bottom: 10px;
|
|
}
|
|
.stat-card .value {
|
|
font-size: 2rem;
|
|
font-weight: 700;
|
|
color: var(--text-primary);
|
|
}
|
|
.stat-card .value .dim {
|
|
font-size: 1.25rem;
|
|
color: var(--text-secondary);
|
|
font-weight: 400;
|
|
}
|
|
.stat-card.purple .value > span:first-child { color: var(--indigo-500); }
|
|
.stat-card.green .value > span:first-child { color: var(--success-color); }
|
|
.stat-card.cyan .value { color: var(--info-color); }
|
|
.stat-card.orange .value { color: #f59e0b; font-size: 1.25rem; margin-top: 0.5rem; }
|
|
|
|
/* 分布统计样式 */
|
|
.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);
|
|
}
|
|
|
|
/* 登录页面 */
|
|
|
|
.login-container {
|
|
min-height: calc(100vh - 64px);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 24px;
|
|
}
|
|
.login-box {
|
|
background: var(--bg-primary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: var(--radius-xl);
|
|
padding: 40px;
|
|
width: 100%;
|
|
max-width: 400px;
|
|
box-shadow: var(--shadow-lg);
|
|
}
|
|
.login-box h2 {
|
|
font-size: 24px;
|
|
font-weight: 700;
|
|
color: var(--text-primary);
|
|
text-align: center;
|
|
margin-bottom: 8px;
|
|
}
|
|
.login-box .subtitle {
|
|
text-align: center;
|
|
color: var(--text-secondary);
|
|
font-size: 14px;
|
|
margin-bottom: 30px;
|
|
}
|
|
.form-group { margin-bottom: 20px; }
|
|
.form-group label {
|
|
display: block;
|
|
font-size: 13px;
|
|
color: var(--text-secondary);
|
|
font-weight: 500;
|
|
margin-bottom: 8px;
|
|
}
|
|
.form-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;
|
|
font-family: 'JetBrains Mono', monospace;
|
|
transition: var(--transition);
|
|
}
|
|
.form-input:focus {
|
|
outline: none;
|
|
border-color: var(--primary-color);
|
|
box-shadow: 0 0 0 4px var(--primary-10);
|
|
}
|
|
.form-input::placeholder { color: var(--text-tertiary); }
|
|
.form-checkbox {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
font-size: 13px;
|
|
color: var(--text-secondary);
|
|
cursor: pointer;
|
|
}
|
|
.form-checkbox input {
|
|
width: 16px;
|
|
height: 16px;
|
|
accent-color: var(--primary-color);
|
|
}
|
|
|
|
/* API Key 显示区域 */
|
|
.apikey-display {
|
|
background: var(--bg-primary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: var(--radius-xl);
|
|
padding: 24px;
|
|
margin-bottom: 24px;
|
|
box-shadow: var(--shadow-sm);
|
|
}
|
|
.apikey-display input {
|
|
width: 100%;
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: var(--radius-lg);
|
|
padding: 14px 16px;
|
|
color: var(--success-color);
|
|
font-size: 14px;
|
|
font-family: 'JetBrains Mono', monospace;
|
|
text-align: center;
|
|
margin-bottom: 16px;
|
|
transition: var(--transition);
|
|
}
|
|
.apikey-display input:focus {
|
|
outline: none;
|
|
border-color: var(--primary-color);
|
|
}
|
|
|
|
/* 提示信息 */
|
|
.info-box {
|
|
background: var(--info-bg-lighter);
|
|
border: 1px solid var(--info-border);
|
|
border-radius: var(--radius-lg);
|
|
padding: 14px 16px;
|
|
margin-top: 20px;
|
|
}
|
|
.info-box p {
|
|
font-size: 13px;
|
|
color: var(--info-text);
|
|
margin: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
.btn {
|
|
height: 2.5rem;
|
|
}
|
|
|
|
/* 响应式 */
|
|
@media (max-width: 768px) {
|
|
.navbar-inner { padding: 0 16px; }
|
|
.navbar-user .welcome { display: none; }
|
|
.main-container { padding: 20px 16px; }
|
|
.stats-grid { grid-template-columns: 1fr; }
|
|
.login-box { padding: 24px; }
|
|
}
|
|
@media (max-width: 480px) {
|
|
.stat-card .value { font-size: 1.5rem; }
|
|
.stat-card .value .dim { font-size: 1rem; }
|
|
.navbar-brand .badge { display: none; }
|
|
}
|
|
|
|
/* 按钮块级样式 */
|
|
.btn-block {
|
|
width: 100%;
|
|
justify-content: center;
|
|
}
|
|
</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-user" id="navbarUser" style="display: none;">
|
|
<span class="welcome">欢迎,<strong id="navUserName">-</strong></span>
|
|
<button class="theme-toggle" id="themeToggle" title="切换主题">
|
|
<i class="fas fa-sun"></i>
|
|
<i class="fas fa-moon"></i>
|
|
</button>
|
|
<button class="btn btn-secondary btn-sm" onclick="logout()">
|
|
<i class="fas fa-sign-out-alt"></i> <span class="btn-text">退出</span>
|
|
</button>
|
|
</div>
|
|
<!-- 未登录时显示主题切换 -->
|
|
<button class="theme-toggle" id="themeToggleLogin" style="display: none;" title="切换主题">
|
|
<i class="fas fa-sun"></i>
|
|
<i class="fas fa-moon"></i>
|
|
</button>
|
|
</div>
|
|
</nav>
|
|
|
|
<!-- 登录页面 -->
|
|
<div class="login-container" id="loginContainer">
|
|
<div class="login-box">
|
|
<h2><i class="fas fa-key"></i> 登录</h2>
|
|
<p class="subtitle">使用您的 API Key 登录查看用量</p>
|
|
<div class="form-group">
|
|
<label>API Key</label>
|
|
<input type="text" class="form-input" id="apiKeyInput" placeholder="maki_xxxxxxxx..." autocomplete="off">
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-checkbox">
|
|
<input type="checkbox" id="rememberKey" checked>
|
|
<span>记住我的 Key</span>
|
|
</label>
|
|
</div>
|
|
<button class="btn btn-primary btn-block" id="loginBtn" onclick="login()">
|
|
<i class="fas fa-sign-in-alt"></i> 登录
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 主内容区 -->
|
|
<div class="main-container" id="mainContainer" style="display: none;">
|
|
<h3 class="section-title"><i class="fas fa-chart-bar"></i> 个人使用统计</h3>
|
|
|
|
<!-- 统计卡片 -->
|
|
<div class="stats-grid">
|
|
<div class="stat-card orange">
|
|
<div class="label">最后使用</div>
|
|
<div class="value" id="statLastUsed">从未</div>
|
|
</div>
|
|
<div class="stat-card purple">
|
|
<div class="label">每日用量</div>
|
|
<div class="value">
|
|
<span id="statToday">0</span>
|
|
<span class="dim"> / <span id="statLimit">0</span></span>
|
|
</div>
|
|
</div>
|
|
<div class="stat-card green">
|
|
<div class="label">剩余额度</div>
|
|
<div class="value">
|
|
<span id="statRemaining">0</span>
|
|
</div>
|
|
</div>
|
|
<div class="stat-card cyan">
|
|
<div class="label">累计调用</div>
|
|
<div class="value" id="statTotal">0</div>
|
|
</div>
|
|
<div class="stat-card green">
|
|
<div class="label">今日 Tokens</div>
|
|
<div class="value" id="statTodayTokens">0</div>
|
|
</div>
|
|
<div class="stat-card purple">
|
|
<div class="label">累计 Tokens</div>
|
|
<div class="value" id="statTotalTokens">0</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 我的使用分布统计区域 -->
|
|
<div id="usageStatsSection" style="margin-bottom: 2.5rem; display: none;">
|
|
<h3 class="section-title"><i class="fas fa-chart-pie"></i> 我的使用分布 (最近 7 天)</h3>
|
|
<div class="stats-grid" style="grid-template-columns: repeat(auto-fit, minmax(300px, 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>常用提供商</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>常用模型</span>
|
|
<span id="modelTotalCount" style="color: var(--primary-color);">0 次</span>
|
|
</div>
|
|
<div id="modelDistribution" class="distribution-list">
|
|
<!-- 动态填充 -->
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- API 密钥区域 -->
|
|
|
|
<h3 class="section-title"><i class="fas fa-key"></i> API 密钥</h3>
|
|
|
|
<div class="apikey-display">
|
|
<input type="text" id="displayApiKeyFull" readonly
|
|
style="font-family: 'JetBrains Mono', monospace; font-size: 14px; text-align: center; background: var(--bg-secondary);">
|
|
|
|
<button class="btn btn-primary btn-block" id="copyKeyBtn" onclick="copyApiKey()">
|
|
<i class="fas fa-copy" id="copyKeyIcon"></i> <span id="copyKeyText">复制 Key</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="info-box">
|
|
<p>
|
|
<i class="fas fa-info-circle"></i>
|
|
API Key 用于访问 API 服务。请妥善保管,不要泄露给他人。
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<script type="module">
|
|
import { getCurrentTheme, setTheme, toggleTheme, initThemeSwitcher } from './app/theme-switcher.js';
|
|
|
|
const API_BASE = '/api/potluckuser';
|
|
let currentApiKey = '';
|
|
let isLoggedIn = false;
|
|
const formatNumber = (num) => new Intl.NumberFormat('zh-CN').format(Number(num || 0));
|
|
const usageCount = (entry) => typeof entry === 'number' ? entry : Number(entry?.requestCount || 0);
|
|
const usageTokens = (entry) => typeof entry === 'number' ? 0 : Number(entry?.totalTokens || 0);
|
|
|
|
// 初始化主题
|
|
function setupTheme() {
|
|
const savedTheme = getCurrentTheme();
|
|
setTheme(savedTheme);
|
|
|
|
// 绑定两个主题切换按钮
|
|
document.getElementById('themeToggle')?.addEventListener('click', toggleTheme);
|
|
document.getElementById('themeToggleLogin')?.addEventListener('click', toggleTheme);
|
|
}
|
|
|
|
// 初始化
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
setupTheme();
|
|
|
|
const savedKey = localStorage.getItem('potluck_user_key');
|
|
if (savedKey) {
|
|
document.getElementById('apiKeyInput').value = savedKey;
|
|
login();
|
|
}
|
|
|
|
document.getElementById('apiKeyInput').addEventListener('keypress', (e) => {
|
|
if (e.key === 'Enter') login();
|
|
});
|
|
});
|
|
|
|
// 登录
|
|
async function login() {
|
|
const apiKey = document.getElementById('apiKeyInput').value.trim();
|
|
if (!apiKey) {
|
|
showToast('请输入 API Key', 'error');
|
|
return;
|
|
}
|
|
if (!apiKey.startsWith('maki_')) {
|
|
showToast('API Key 格式不正确', 'error');
|
|
return;
|
|
}
|
|
|
|
currentApiKey = apiKey;
|
|
document.getElementById('loginBtn').disabled = true;
|
|
|
|
try {
|
|
const response = await fetch(`${API_BASE}/usage`, {
|
|
method: 'GET',
|
|
headers: { 'Authorization': `Bearer ${apiKey}` }
|
|
});
|
|
const data = await response.json();
|
|
|
|
if (!response.ok || !data.success) {
|
|
throw new Error(data.error?.message || '登录失败');
|
|
}
|
|
|
|
if (document.getElementById('rememberKey').checked) {
|
|
localStorage.setItem('potluck_user_key', apiKey);
|
|
}
|
|
|
|
isLoggedIn = true;
|
|
displayUserInfo(data.data);
|
|
document.getElementById('loginContainer').style.display = 'none';
|
|
document.getElementById('mainContainer').style.display = 'block';
|
|
document.getElementById('navbarUser').style.display = 'flex';
|
|
document.getElementById('themeToggleLogin').style.display = 'none';
|
|
|
|
showToast('登录成功', 'success');
|
|
|
|
} catch (error) {
|
|
showToast(error.message, 'error');
|
|
} finally {
|
|
document.getElementById('loginBtn').disabled = false;
|
|
}
|
|
}
|
|
|
|
// 退出登录
|
|
function logout() {
|
|
currentApiKey = '';
|
|
isLoggedIn = false;
|
|
localStorage.removeItem('potluck_user_key');
|
|
document.getElementById('loginContainer').style.display = 'flex';
|
|
document.getElementById('mainContainer').style.display = 'none';
|
|
document.getElementById('navbarUser').style.display = 'none';
|
|
document.getElementById('themeToggleLogin').style.display = 'inline-flex';
|
|
document.getElementById('apiKeyInput').value = '';
|
|
showToast('已退出登录', 'success');
|
|
}
|
|
|
|
// 显示用户信息
|
|
function displayUserInfo(data) {
|
|
document.getElementById('navUserName').textContent = data.name || '用户';
|
|
|
|
// 每日用量:已用/限额
|
|
document.getElementById('statToday').textContent = data.usage.today;
|
|
document.getElementById('statLimit').textContent = data.usage.limit;
|
|
|
|
// 剩余额度
|
|
document.getElementById('statRemaining').textContent = data.usage.remaining;
|
|
|
|
// 累计调用
|
|
document.getElementById('statTotal').textContent = data.total || 0;
|
|
|
|
// Token 用量
|
|
document.getElementById('statTodayTokens').textContent = formatNumber(data.usage?.totalTokens || 0);
|
|
document.getElementById('statTotalTokens').textContent = formatNumber(data.tokens?.total || 0);
|
|
|
|
// 最后使用时间
|
|
if (data.lastUsedAt) {
|
|
const date = new Date(data.lastUsedAt);
|
|
document.getElementById('statLastUsed').textContent = formatDate(date);
|
|
} else {
|
|
document.getElementById('statLastUsed').textContent = '从未';
|
|
}
|
|
|
|
// 显示完整 API Key
|
|
document.getElementById('displayApiKeyFull').value = currentApiKey;
|
|
|
|
// 渲染个人使用分布
|
|
renderUsageHistory(data.usageHistory);
|
|
}
|
|
|
|
/**
|
|
* 渲染个人使用分布
|
|
*/
|
|
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;
|
|
let totalTokens = 0;
|
|
|
|
// 汇总最近 7 天的数据
|
|
Object.values(usageHistory).forEach(day => {
|
|
if (day.providers) {
|
|
Object.entries(day.providers).forEach(([p, usage]) => {
|
|
aggregatedProviders[p] = (aggregatedProviders[p] || 0) + usageCount(usage);
|
|
totalCalls += usageCount(usage);
|
|
totalTokens += usageTokens(usage);
|
|
});
|
|
}
|
|
if (day.models) {
|
|
Object.entries(day.models).forEach(([m, usage]) => {
|
|
aggregatedModels[m] = (aggregatedModels[m] || 0) + usageCount(usage);
|
|
});
|
|
}
|
|
});
|
|
|
|
if (totalCalls === 0) {
|
|
document.getElementById('usageStatsSection').style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
// 渲染提供商分布
|
|
renderDistribution('providerDistribution', aggregatedProviders, totalCalls);
|
|
document.getElementById('providerTotalCount').textContent = `${formatNumber(totalCalls)} 次 / ${formatNumber(totalTokens)} Tokens`;
|
|
|
|
// 渲染模型分布
|
|
renderDistribution('modelDistribution', aggregatedModels, totalCalls);
|
|
document.getElementById('modelTotalCount').textContent = `${formatNumber(totalCalls)} 次`;
|
|
}
|
|
|
|
function renderDistribution(elementId, data, total) {
|
|
const container = document.getElementById(elementId);
|
|
const sorted = Object.entries(data).sort((a, b) => b[1] - a[1]);
|
|
|
|
// 最多显示前 5 个
|
|
const topData = sorted.slice(0, 5);
|
|
|
|
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">${formatNumber(count)} 次 (${percent}%)</span>
|
|
</div>
|
|
<div class="dist-bar">
|
|
<div class="dist-fill" style="width: ${percent}%"></div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
if (sorted.length > 5) {
|
|
const otherCount = sorted.slice(5).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;">其他</span>
|
|
<span class="dist-count">${formatNumber(otherCount)} 次 (${otherPercent}%)</span>
|
|
</div>
|
|
<div class="dist-bar">
|
|
<div class="dist-fill" style="width: ${otherPercent}%; background: #94a3b8;"></div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text || '';
|
|
return div.innerHTML;
|
|
}
|
|
|
|
// 格式化日期
|
|
function formatDate(date) {
|
|
const y = date.getFullYear();
|
|
const m = String(date.getMonth() + 1).padStart(2, '0');
|
|
const d = String(date.getDate()).padStart(2, '0');
|
|
const h = String(date.getHours()).padStart(2, '0');
|
|
const min = String(date.getMinutes()).padStart(2, '0');
|
|
const s = String(date.getSeconds()).padStart(2, '0');
|
|
return `${y}-${m}-${d} ${h}:${min}:${s}`;
|
|
}
|
|
|
|
// 复制 API Key
|
|
function copyApiKey() {
|
|
const text = currentApiKey;
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
navigator.clipboard.writeText(text).then(() => {
|
|
document.getElementById('copyKeyIcon').className = 'fas fa-check';
|
|
document.getElementById('copyKeyText').textContent = '已复制';
|
|
showToast('已复制到剪贴板', 'success');
|
|
setTimeout(() => {
|
|
document.getElementById('copyKeyIcon').className = 'fas fa-copy';
|
|
document.getElementById('copyKeyText').textContent = '复制 Key';
|
|
}, 2000);
|
|
}).catch(() => copyToClipboardFallback(text));
|
|
} else {
|
|
copyToClipboardFallback(text);
|
|
}
|
|
}
|
|
|
|
// 复制到剪贴板(兼容方案)
|
|
function copyToClipboardFallback(text, successMsg = '已复制到剪贴板') {
|
|
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(successMsg, 'success');
|
|
} catch (err) {
|
|
showToast('复制失败,请手动复制', 'error');
|
|
}
|
|
document.body.removeChild(textArea);
|
|
}
|
|
|
|
// Toast
|
|
function showToast(message, type = 'success') {
|
|
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;
|
|
}
|
|
|
|
// 导出全局函数
|
|
window.login = login;
|
|
window.logout = logout;
|
|
window.copyApiKey = copyApiKey;
|
|
window.showToast = showToast;
|
|
</script>
|
|
</body>
|
|
</html>
|