test-g / logs.html
kuefr's picture
Create logs.html
c58b8ed verified
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>API日志管理中心</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #e3f2fd 0%, #f0f8ff 100%);
min-height: 100vh;
color: #333;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
.header {
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(10px);
border-radius: 20px;
padding: 30px;
margin-bottom: 30px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
text-align: center;
}
.header h1 {
color: #1976d2;
font-size: 2.5em;
margin-bottom: 10px;
font-weight: 600;
}
.header p {
color: #666;
font-size: 1.1em;
}
.login-form {
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(10px);
border-radius: 20px;
padding: 40px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
max-width: 400px;
margin: 50px auto;
}
.login-form h2 {
color: #1976d2;
margin-bottom: 30px;
text-align: center;
font-size: 1.8em;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #555;
}
.form-control {
width: 100%;
padding: 12px 16px;
border: 2px solid #e3f2fd;
border-radius: 12px;
font-size: 14px;
transition: all 0.3s ease;
background: rgba(255, 255, 255, 0.8);
}
.form-control:focus {
outline: none;
border-color: #1976d2;
box-shadow: 0 0 0 3px rgba(25, 118, 210, 0.1);
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 12px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
text-decoration: none;
display: inline-block;
text-align: center;
}
.btn-primary {
background: linear-gradient(45deg, #1976d2, #42a5f5);
color: white;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(25, 118, 210, 0.3);
}
.btn-success {
background: linear-gradient(45deg, #4caf50, #66bb6a);
color: white;
}
.btn-warning {
background: linear-gradient(45deg, #ff9800, #ffb74d);
color: white;
}
.btn-danger {
background: linear-gradient(45deg, #f44336, #ef5350);
color: white;
}
.btn-secondary {
background: linear-gradient(45deg, #6c757d, #868e96);
color: white;
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2);
}
.dashboard {
display: none;
}
.nav-tabs {
display: flex;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(10px);
border-radius: 15px;
padding: 8px;
margin-bottom: 30px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
}
.nav-tab {
flex: 1;
padding: 12px 20px;
text-align: center;
background: transparent;
border: none;
border-radius: 10px;
cursor: pointer;
font-weight: 500;
transition: all 0.3s ease;
color: #666;
}
.nav-tab.active {
background: linear-gradient(45deg, #1976d2, #42a5f5);
color: white;
box-shadow: 0 4px 12px rgba(25, 118, 210, 0.3);
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
.card {
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(10px);
border-radius: 20px;
padding: 30px;
margin-bottom: 30px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.card-title {
color: #1976d2;
font-size: 1.5em;
margin-bottom: 20px;
font-weight: 600;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(10px);
border-radius: 15px;
padding: 25px;
text-align: center;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
transition: transform 0.3s ease;
}
.stat-card:hover {
transform: translateY(-5px);
}
.stat-value {
font-size: 2.5em;
font-weight: 700;
color: #1976d2;
margin-bottom: 10px;
}
.stat-label {
color: #666;
font-size: 1em;
font-weight: 500;
}
/* 日志筛选区域样式 */
.log-filters {
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(10px);
border-radius: 15px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
}
.filter-row {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 15px;
margin-bottom: 15px;
}
.filter-row:last-child {
grid-template-columns: 1fr 1fr auto auto auto auto auto;
align-items: end;
}
.filter-group {
display: flex;
flex-direction: column;
}
.filter-group label {
color: #666;
font-size: 12px;
margin-bottom: 5px;
}
.filter-input {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 14px;
background: white;
}
.filter-input:focus {
outline: none;
border-color: #1976d2;
box-shadow: 0 0 0 2px rgba(25, 118, 210, 0.1);
}
.date-range {
display: flex;
align-items: center;
gap: 10px;
}
.date-separator {
color: #666;
font-weight: bold;
}
/* 表格样式 */
.table-container {
overflow-x: auto;
border-radius: 15px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
background: white;
}
.logs-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.logs-table thead {
background: linear-gradient(45deg, #1976d2, #42a5f5);
}
.logs-table th {
padding: 12px 8px;
text-align: left;
font-weight: 500;
color: white;
font-size: 13px;
white-space: nowrap;
}
.logs-table td {
padding: 10px 8px;
border-bottom: 1px solid #f0f0f0;
font-size: 13px;
vertical-align: middle;
}
.logs-table tbody tr:hover {
background-color: #f8f9fa;
}
.logs-table tbody tr:nth-child(even) {
background-color: #fafafa;
}
/* 日志表格特殊列样式 */
.log-time {
color: #333;
white-space: nowrap;
min-width: 140px;
}
.log-user {
display: flex;
align-items: center;
gap: 8px;
min-width: 100px;
}
.user-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
background: #42a5f5;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: bold;
}
.log-type-badge {
padding: 4px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: 500;
text-align: center;
min-width: 60px;
}
.type-normal {
background: #e8f5e8;
color: #2e7d32;
}
.type-stream {
background: #e3f2fd;
color: #1976d2;
}
.type-fake-stream {
background: #fff3e0;
color: #f57c00;
}
.log-model {
background: #f3e5f5;
color: #7b1fa2;
padding: 4px 8px;
border-radius: 12px;
font-size: 11px;
text-align: center;
font-weight: 500;
}
.log-timing {
color: #4caf50;
font-size: 12px;
}
.log-status {
text-align: center;
}
.status-success {
color: #4caf50;
}
.status-error {
color: #f44336;
}
.log-tokens {
color: #666;
font-size: 12px;
text-align: right;
}
.log-detail {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
color: #666;
}
/* 分页样式 */
.pagination-container {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 20px;
padding: 15px 0;
}
.pagination-info {
color: #666;
font-size: 14px;
}
.pagination-controls {
display: flex;
align-items: center;
gap: 10px;
}
.pagination-btn {
padding: 8px 16px;
border: 1px solid #ddd;
border-radius: 8px;
background: white;
cursor: pointer;
font-size: 14px;
transition: all 0.3s ease;
}
.pagination-btn:hover:not(:disabled) {
background: #1976d2;
color: white;
border-color: #1976d2;
}
.pagination-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pagination-input {
width: 60px;
padding: 6px;
border: 1px solid #ddd;
border-radius: 4px;
text-align: center;
}
.page-size-select {
padding: 6px;
border: 1px solid #ddd;
border-radius: 4px;
}
/* 其他原有样式保持不变 */
.table {
width: 100%;
border-collapse: collapse;
background: white;
border-radius: 15px;
overflow: hidden;
}
.table th {
background: linear-gradient(45deg, #1976d2, #42a5f5);
color: white;
padding: 15px;
text-align: left;
font-weight: 500;
}
.table td {
padding: 15px;
border-bottom: 1px solid #f0f0f0;
transition: background-color 0.3s ease;
}
.table tr:hover td {
background-color: #f8f9fa;
}
.table tr:nth-child(even) {
background-color: #fafafa;
}
.charts-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 30px;
margin-bottom: 30px;
}
.chart-card {
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(10px);
border-radius: 20px;
padding: 30px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.chart-title {
color: #1976d2;
font-size: 1.3em;
margin-bottom: 20px;
font-weight: 600;
text-align: center;
}
.status-badge {
padding: 4px 12px;
border-radius: 20px;
font-size: 0.85em;
font-weight: 500;
}
.status-enabled {
background: linear-gradient(45deg, #4caf50, #66bb6a);
color: white;
}
.status-disabled {
background: linear-gradient(45deg, #ff9800, #ffb74d);
color: white;
}
.alert {
padding: 15px 20px;
border-radius: 12px;
margin-bottom: 20px;
border-left: 4px solid;
}
.alert-success {
background: rgba(76, 175, 80, 0.1);
border-color: #4caf50;
color: #2e7d32;
}
.alert-error {
background: rgba(244, 67, 54, 0.1);
border-color: #f44336;
color: #c62828;
}
.alert-info {
background: rgba(25, 118, 210, 0.1);
border-color: #1976d2;
color: #1565c0;
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(5px);
z-index: 1000;
}
.modal-content {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: white;
border-radius: 20px;
padding: 30px;
max-width: 500px;
width: 90%;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 2px solid #f0f0f0;
}
.modal-title {
color: #1976d2;
font-size: 1.5em;
font-weight: 600;
}
.close {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #999;
transition: color 0.3s ease;
}
.close:hover {
color: #f44336;
}
.form-row {
display: flex;
gap: 15px;
margin-bottom: 20px;
}
.form-row .form-group {
flex: 1;
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 15px;
}
.toolbar-left {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.export-area {
background: #f8f9fa;
border-radius: 12px;
padding: 20px;
margin-top: 20px;
}
.export-content {
background: white;
border-radius: 8px;
padding: 15px;
font-family: 'Courier New', monospace;
font-size: 14px;
border: 2px solid #e3f2fd;
word-break: break-all;
}
.loading {
display: none;
text-align: center;
padding: 40px;
color: #666;
}
.loading.show {
display: block;
}
.spinner {
border: 3px solid #f3f3f3;
border-top: 3px solid #1976d2;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 0 auto 20px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: #666;
}
.empty-state h3 {
color: #999;
margin-bottom: 10px;
}
/* 编辑别名相关样式 */
.alias-edit {
display: flex;
align-items: center;
gap: 8px;
}
.alias-display {
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
transition: background-color 0.3s ease;
}
.alias-display:hover {
background-color: #f0f0f0;
}
.alias-edit-input {
padding: 4px 8px;
border: 1px solid #1976d2;
border-radius: 4px;
font-size: 14px;
min-width: 120px;
}
.alias-edit-buttons {
display: flex;
gap: 4px;
}
.btn-icon {
padding: 4px 8px;
font-size: 12px;
min-width: auto;
}
/* 文件日志控制区域 */
.file-logging-control {
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(10px);
border-radius: 15px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 15px;
}
.file-logging-info {
display: flex;
align-items: center;
gap: 15px;
}
.file-logging-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.switch {
position: relative;
display: inline-block;
width: 60px;
height: 34px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: .4s;
border-radius: 34px;
}
.slider:before {
position: absolute;
content: "";
height: 26px;
width: 26px;
left: 4px;
bottom: 4px;
background-color: white;
transition: .4s;
border-radius: 50%;
}
input:checked + .slider {
background-color: #1976d2;
}
input:checked + .slider:before {
transform: translateX(26px);
}
@media (max-width: 768px) {
.container {
padding: 10px;
}
.nav-tabs {
flex-direction: column;
gap: 5px;
}
.charts-container {
grid-template-columns: 1fr;
}
.stats-grid {
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
.filter-row {
grid-template-columns: 1fr;
}
.filter-row:last-child {
grid-template-columns: 1fr;
}
.date-range {
flex-direction: column;
align-items: stretch;
}
.logs-table {
font-size: 12px;
}
.logs-table th,
.logs-table td {
padding: 8px 4px;
}
.file-logging-control {
flex-direction: column;
align-items: stretch;
}
.file-logging-info {
justify-content: center;
}
.file-logging-actions {
justify-content: center;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🚀 API日志管理中心</h1>
<p>OpenAI to Gemini Proxy 日志监控与管理系统</p>
</div>
<!-- 登录表单 -->
<div id="loginForm" class="login-form">
<h2>🔐 管理员登录</h2>
<div class="form-group">
<label for="password">管理员密码</label>
<input type="password" id="password" class="form-control" placeholder="请输入管理员密码">
</div>
<button onclick="login()" class="btn btn-primary" style="width: 100%;">登录</button>
<div id="loginError" class="alert alert-error" style="display: none; margin-top: 15px;"></div>
</div>
<!-- 管理面板 -->
<div id="dashboard" class="dashboard">
<!-- 导航标签 -->
<div class="nav-tabs">
<button class="nav-tab active" onclick="showTab('overview')">📊 概览统计</button>
<button class="nav-tab" onclick="showTab('logs')">📋 请求日志</button>
<button class="nav-tab" onclick="showTab('keys')">🔑 认证管理</button>
<button class="nav-tab" onclick="showTab('charts')">📈 统计图表</button>
</div>
<!-- 概览统计 -->
<div id="overview-tab" class="tab-content active">
<div class="stats-grid" id="statsGrid">
<!-- 统计卡片将动态生成 -->
</div>
<div class="card">
<h3 class="card-title">📋 最近请求</h3>
<div id="recentLogs">
<div class="loading show">
<div class="spinner"></div>
<p>加载中...</p>
</div>
</div>
</div>
</div>
<!-- 请求日志 -->
<div id="logs-tab" class="tab-content">
<!-- 文件日志控制 -->
<div class="file-logging-control">
<div class="file-logging-info">
<span>📄 文件日志:</span>
<label class="switch">
<input type="checkbox" id="fileLoggingSwitch" onchange="toggleFileLogging()">
<span class="slider"></span>
</label>
<span id="fileLoggingStatus">加载中...</span>
</div>
<div class="file-logging-actions">
<button onclick="downloadLogs('json')" class="btn btn-primary">📥 下载JSON</button>
<button onclick="downloadLogs('csv')" class="btn btn-secondary">📥 下载CSV</button>
</div>
</div>
<!-- 日志筛选器 -->
<div class="log-filters">
<div class="filter-row">
<div class="filter-group">
<label>开始时间 ~ 结束时间</label>
<div class="date-range">
<input type="datetime-local" id="startTime" class="filter-input">
<span class="date-separator">~</span>
<input type="datetime-local" id="endTime" class="filter-input">
</div>
</div>
<div class="filter-group">
<label>令牌名称</label>
<input type="text" id="tokenFilter" class="filter-input" placeholder="搜索...">
</div>
<div class="filter-group">
<label>模型名称</label>
<input type="text" id="modelFilter" class="filter-input" placeholder="搜索...">
</div>
</div>
<div class="filter-row">
<div class="filter-group">
<label>分组</label>
<select id="groupFilter" class="filter-input">
<option value="">全部</option>
</select>
</div>
<div class="filter-group">
<button onclick="applyFilters()" class="btn btn-primary">查询</button>
</div>
<div class="filter-group">
<button onclick="resetFilters()" class="btn btn-secondary">重置</button>
</div>
<div class="filter-group">
<button onclick="refreshLogs()" class="btn btn-warning">刷新</button>
</div>
<div class="filter-group">
<button onclick="clearAllLogs()" class="btn btn-danger">清除所有</button>
</div>
</div>
</div>
<!-- 日志表格 -->
<div class="card">
<div id="logsContainer">
<div class="loading show">
<div class="spinner"></div>
<p>加载日志中...</p>
</div>
</div>
<!-- 分页控制 -->
<div id="logsPagination" class="pagination-container" style="display: none;">
<div class="pagination-info" id="paginationInfo"></div>
<div class="pagination-controls">
<button id="firstPageBtn" class="pagination-btn" onclick="goToPage(1)">首页</button>
<button id="prevPageBtn" class="pagination-btn" onclick="goToPage(currentPage - 1)">上一页</button>
<input type="number" id="pageInput" class="pagination-input" min="1" onchange="goToInputPage()">
<button id="nextPageBtn" class="pagination-btn" onclick="goToPage(currentPage + 1)">下一页</button>
<select id="pageSizeSelect" class="page-size-select" onchange="changePageSize()">
<option value="50">每页50条</option>
<option value="100" selected>每页100条</option>
<option value="200">每页200条</option>
</select>
</div>
</div>
</div>
</div>
<!-- 认证管理 -->
<div id="keys-tab" class="tab-content">
<div class="card">
<div class="toolbar">
<h3 class="card-title">🔑 认证Key管理</h3>
<div>
<button onclick="showAddKeyModal()" class="btn btn-success">➕ 添加Key</button>
<button onclick="exportKeys()" class="btn btn-secondary">📤 导出配置</button>
<button onclick="refreshKeys()" class="btn btn-primary">🔄 刷新</button>
</div>
</div>
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>别名</th>
<th>Token</th>
<th>状态</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="keysTableBody">
<tr>
<td colspan="5" style="text-align: center; padding: 40px;">
<div class="loading show">
<div class="spinner"></div>
<p>加载中...</p>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div id="exportArea" class="export-area" style="display: none;">
<h4>环境变量配置</h4>
<p>复制以下内容到您的 .env 文件中:</p>
<div id="exportContent" class="export-content"></div>
</div>
</div>
</div>
<!-- 统计图表 -->
<div id="charts-tab" class="tab-content">
<div class="charts-container">
<div class="chart-card">
<h3 class="chart-title">📊 请求类型分布</h3>
<canvas id="requestTypeChart"></canvas>
</div>
<div class="chart-card">
<h3 class="chart-title">🎯 模型使用统计</h3>
<canvas id="modelUsageChart"></canvas>
</div>
<div class="chart-card">
<h3 class="chart-title">✅ 请求状态分布</h3>
<canvas id="statusChart"></canvas>
</div>
<div class="chart-card">
<h3 class="chart-title">🔑 Key使用对比</h3>
<canvas id="keyUsageChart"></canvas>
</div>
</div>
</div>
</div>
</div>
<!-- 添加Key模态框 -->
<div id="addKeyModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">➕ 添加新的认证Key</h3>
<button class="close" onclick="closeModal('addKeyModal')">&times;</button>
</div>
<form onsubmit="addKey(event)">
<div class="form-group">
<label for="keyAlias">别名</label>
<input type="text" id="keyAlias" class="form-control" placeholder="为这个Key设置一个别名" required>
</div>
<div class="form-group">
<label for="keyToken">Token</label>
<input type="text" id="keyToken" class="form-control" placeholder="输入认证Token" required>
</div>
<div style="display: flex; gap: 10px; justify-content: flex-end;">
<button type="button" onclick="closeModal('addKeyModal')" class="btn btn-secondary">取消</button>
<button type="submit" class="btn btn-success">添加</button>
</div>
</form>
</div>
</div>
<!-- 编辑别名模态框 -->
<div id="editAliasModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">✏️ 编辑别名</h3>
<button class="close" onclick="closeModal('editAliasModal')">&times;</button>
</div>
<form onsubmit="saveAlias(event)">
<div class="form-group">
<label for="editAlias">别名</label>
<input type="text" id="editAlias" class="form-control" required>
</div>
<div style="display: flex; gap: 10px; justify-content: flex-end;">
<button type="button" onclick="closeModal('editAliasModal')" class="btn btn-secondary">取消</button>
<button type="submit" class="btn btn-success">保存</button>
</div>
</form>
</div>
</div>
<script>
// 全局变量
let authToken = null;
let currentPage = 1;
let pageSize = 100;
let totalPages = 1;
let chartInstances = {};
let allLogs = [];
let filteredLogs = [];
let authKeys = [];
let currentEditToken = null;
// 日志筛选参数
let filters = {
startTime: '',
endTime: '',
tokenFilter: '',
modelFilter: '',
groupFilter: ''
};
// 登录功能
async function login() {
const password = document.getElementById('password').value;
if (!password) {
showError('loginError', '请输入密码');
return;
}
try {
const response = await fetch('/admin/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
const result = await response.json();
if (result.success) {
authToken = result.token;
document.getElementById('loginForm').style.display = 'none';
document.getElementById('dashboard').style.display = 'block';
await initDashboard();
} else {
showError('loginError', '密码错误,请重试');
}
} catch (error) {
showError('loginError', '登录失败,请检查网络连接');
}
}
// 初始化仪表板
async function initDashboard() {
// 设置默认时间范围(最近7天)
const now = new Date();
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
document.getElementById('startTime').value = formatDateForInput(weekAgo);
document.getElementById('endTime').value = formatDateForInput(now);
await Promise.all([
loadStatistics(),
loadAuthKeys(),
loadAllLogs(),
loadRecentLogs(),
loadFileLoggingStatus()
]);
}
// 加载文件日志状态
async function loadFileLoggingStatus() {
try {
const response = await fetch('/admin/file-logging-status');
const result = await response.json();
document.getElementById('fileLoggingSwitch').checked = result.enabled;
document.getElementById('fileLoggingStatus').textContent = result.enabled ? '已启用' : '已禁用(仅内存)';
} catch (error) {
console.error('获取文件日志状态失败:', error);
document.getElementById('fileLoggingStatus').textContent = '状态未知';
}
}
// 切换文件日志状态
async function toggleFileLogging() {
const enabled = document.getElementById('fileLoggingSwitch').checked;
try {
const response = await fetch('/admin/file-logging', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled })
});
const result = await response.json();
if (result.success) {
document.getElementById('fileLoggingStatus').textContent = enabled ? '已启用' : '已禁用(仅内存)';
showSuccess(result.message);
} else {
// 恢复开关状态
document.getElementById('fileLoggingSwitch').checked = !enabled;
alert('设置失败: ' + result.message);
}
} catch (error) {
// 恢复开关状态
document.getElementById('fileLoggingSwitch').checked = !enabled;
alert('设置失败: ' + error.message);
}
}
// 下载日志文件
function downloadLogs(format) {
const timestamp = new Date().toISOString().slice(0, 19).replace(/[:.]/g, '-');
const filename = `api-logs-${timestamp}.${format}`;
const link = document.createElement('a');
link.href = `/admin/download-logs?format=${format}`;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
showSuccess(`正在下载 ${format.toUpperCase()} 格式的日志文件...`);
}
// 格式化日期为input[datetime-local]格式
function formatDateForInput(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
// 显示错误信息
function showError(elementId, message) {
const element = document.getElementById(elementId);
element.textContent = message;
element.style.display = 'block';
setTimeout(() => {
element.style.display = 'none';
}, 5000);
}
// 显示成功信息
function showSuccess(message) {
const alertDiv = document.createElement('div');
alertDiv.className = 'alert alert-success';
alertDiv.textContent = message;
alertDiv.style.position = 'fixed';
alertDiv.style.top = '20px';
alertDiv.style.right = '20px';
alertDiv.style.zIndex = '9999';
alertDiv.style.minWidth = '300px';
document.body.appendChild(alertDiv);
setTimeout(() => {
alertDiv.remove();
}, 3000);
}
// 标签切换
function showTab(tabName) {
// 隐藏所有标签内容
const contents = document.querySelectorAll('.tab-content');
contents.forEach(content => content.classList.remove('active'));
// 移除所有标签的active状态
const tabs = document.querySelectorAll('.nav-tab');
tabs.forEach(tab => tab.classList.remove('active'));
// 显示选中的标签内容
document.getElementById(tabName + '-tab').classList.add('active');
// 设置对应按钮为active
event.target.classList.add('active');
// 特殊处理
if (tabName === 'charts') {
setTimeout(() => loadCharts(), 100);
} else if (tabName === 'logs') {
applyFilters();
}
}
// 加载统计信息
async function loadStatistics() {
try {
const response = await fetch('/admin/statistics');
const stats = await response.json();
const statsGrid = document.getElementById('statsGrid');
let totalRequests = 0;
let totalStreamRequests = 0;
let totalNormalRequests = 0;
let totalFakeStreamRequests = 0;
let totalErrorRequests = 0;
// 计算总数
Object.values(stats).forEach(keyStats => {
totalRequests += keyStats.totalRequests;
totalStreamRequests += keyStats.streamRequests;
totalNormalRequests += keyStats.normalRequests;
totalFakeStreamRequests += keyStats.fakeStreamRequests || 0;
totalErrorRequests += keyStats.errorRequests;
});
statsGrid.innerHTML = `
<div class="stat-card">
<div class="stat-value">${totalRequests}</div>
<div class="stat-label">总请求数</div>
</div>
<div class="stat-card">
<div class="stat-value">${totalStreamRequests}</div>
<div class="stat-label">流式请求</div>
</div>
<div class="stat-card">
<div class="stat-value">${totalNormalRequests}</div>
<div class="stat-label">普通请求</div>
</div>
<div class="stat-card">
<div class="stat-value">${totalFakeStreamRequests}</div>
<div class="stat-label">假流式请求</div>
</div>
<div class="stat-card">
<div class="stat-value">${totalErrorRequests}</div>
<div class="stat-label">错误请求</div>
</div>
<div class="stat-card">
<div class="stat-value">${Object.keys(stats).length}</div>
<div class="stat-label">活跃Key数</div>
</div>
<div class="stat-card">
<div class="stat-value">${totalRequests > 0 ? ((totalRequests - totalErrorRequests) / totalRequests * 100).toFixed(1) : 0}%</div>
<div class="stat-label">成功率</div>
</div>
`;
} catch (error) {
console.error('加载统计信息失败:', error);
}
}
// 加载认证Keys
async function loadAuthKeys() {
try {
const response = await fetch('/admin/keys');
const keys = await response.json();
authKeys = keys;
// 更新分组选择器
const groupFilter = document.getElementById('groupFilter');
groupFilter.innerHTML = '<option value="">全部</option>';
keys.forEach(key => {
const option = document.createElement('option');
option.value = key.token;
option.textContent = key.alias;
groupFilter.appendChild(option);
});
// 更新Keys管理表格
updateKeysTable();
} catch (error) {
console.error('加载认证Keys失败:', error);
}
}
// 更新Keys管理表格
function updateKeysTable() {
const tbody = document.getElementById('keysTableBody');
if (authKeys.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="5" class="empty-state">
<h3>暂无认证Key</h3>
<p>点击"添加Key"按钮创建第一个认证Key</p>
</td>
</tr>
`;
} else {
tbody.innerHTML = authKeys.map(key => `
<tr>
<td>
<div class="alias-edit" id="alias-${key.token}">
<span class="alias-display" onclick="startEditAlias('${key.token}', '${key.alias}')" title="点击编辑别名">
<strong>${key.alias}</strong> ✏️
</span>
</div>
</td>
<td><code>${key.token.substring(0, 15)}...${key.token.substring(key.token.length - 5)}</code></td>
<td>
<span class="status-badge ${key.enabled ? 'status-enabled' : 'status-disabled'}">
${key.enabled ? '✅ 启用' : '❌ 禁用'}
</span>
</td>
<td>${new Date(key.createdAt).toLocaleString()}</td>
<td>
<button onclick="toggleKeyStatus('${key.token}', ${!key.enabled})"
class="btn ${key.enabled ? 'btn-warning' : 'btn-success'}"
style="margin-right: 5px;">
${key.enabled ? '禁用' : '启用'}
</button>
<button onclick="deleteKey('${key.token}')" class="btn btn-danger">删除</button>
</td>
</tr>
`).join('');
}
}
// 开始编辑别名
function startEditAlias(token, currentAlias) {
currentEditToken = token;
document.getElementById('editAlias').value = currentAlias;
document.getElementById('editAliasModal').style.display = 'block';
}
// 保存别名
async function saveAlias(event) {
event.preventDefault();
const newAlias = document.getElementById('editAlias').value.trim();
if (!newAlias) {
alert('别名不能为空');
return;
}
try {
const response = await fetch(`/admin/keys/${encodeURIComponent(currentEditToken)}/alias`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ alias: newAlias })
});
const result = await response.json();
if (result.success) {
showSuccess('别名更新成功');
closeModal('editAliasModal');
await loadAuthKeys();
// 如果当前在日志页面,也需要重新加载日志以更新显示
if (document.getElementById('logs-tab').classList.contains('active')) {
await loadAllLogs();
applyFilters();
}
} else {
alert('更新失败: ' + (result.message || '未知错误'));
}
} catch (error) {
alert('更新失败: ' + error.message);
}
}
// 加载所有日志
async function loadAllLogs() {
try {
const response = await fetch('/admin/all-logs');
allLogs = await response.json();
console.log(`加载了 ${allLogs.length} 条日志`);
} catch (error) {
console.error('加载所有日志失败:', error);
}
}
// 应用筛选器
function applyFilters() {
// 获取筛选条件
filters.startTime = document.getElementById('startTime').value;
filters.endTime = document.getElementById('endTime').value;
filters.tokenFilter = document.getElementById('tokenFilter').value.toLowerCase();
filters.modelFilter = document.getElementById('modelFilter').value.toLowerCase();
filters.groupFilter = document.getElementById('groupFilter').value;
// 筛选日志
filteredLogs = allLogs.filter(log => {
// 时间筛选
if (filters.startTime) {
const logTime = new Date(log.timestamp || log.requestTime);
const startTime = new Date(filters.startTime);
if (logTime < startTime) return false;
}
if (filters.endTime) {
const logTime = new Date(log.timestamp || log.requestTime);
const endTime = new Date(filters.endTime);
if (logTime > endTime) return false;
}
// 令牌筛选
if (filters.tokenFilter && !(log.keyAlias || '').toLowerCase().includes(filters.tokenFilter)) {
return false;
}
// 模型筛选
if (filters.modelFilter && !(log.model || '').toLowerCase().includes(filters.modelFilter)) {
return false;
}
// 分组筛选
if (filters.groupFilter && log.authKey !== filters.groupFilter) {
return false;
}
return true;
});
// 重置到第一页
currentPage = 1;
// 渲染筛选后的日志
renderLogs();
}
// 重置筛选器
function resetFilters() {
document.getElementById('startTime').value = '';
document.getElementById('endTime').value = '';
document.getElementById('tokenFilter').value = '';
document.getElementById('modelFilter').value = '';
document.getElementById('groupFilter').value = '';
// 重新设置默认时间范围
const now = new Date();
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
document.getElementById('startTime').value = formatDateForInput(weekAgo);
document.getElementById('endTime').value = formatDateForInput(now);
applyFilters();
}
// 渲染日志
function renderLogs() {
const container = document.getElementById('logsContainer');
const pagination = document.getElementById('logsPagination');
if (filteredLogs.length === 0) {
container.innerHTML = `
<div class="empty-state">
<h3>暂无符合条件的日志</h3>
<p>尝试调整筛选条件或重新加载数据</p>
</div>
`;
pagination.style.display = 'none';
return;
}
// 计算分页
totalPages = Math.ceil(filteredLogs.length / pageSize);
const startIndex = (currentPage - 1) * pageSize;
const endIndex = Math.min(startIndex + pageSize, filteredLogs.length);
const currentLogs = filteredLogs.slice(startIndex, endIndex);
// 渲染日志表格
container.innerHTML = `
<div class="table-container">
<table class="logs-table">
<thead>
<tr>
<th>时间</th>
<th>用户</th>
<th>类型</th>
<th>模型</th>
<th>用时</th>
<th>提示</th>
<th>补全</th>
<th>详情</th>
</tr>
</thead>
<tbody>
${currentLogs.map((log, index) => `
<tr>
<td class="log-time">${formatLogTime(log.timestamp || log.requestTime)}</td>
<td class="log-user">
<div class="user-avatar">${log.keyAlias ? log.keyAlias.charAt(0).toUpperCase() : 'U'}</div>
${log.keyAlias || 'Unknown'}
</td>
<td>
<span class="log-type-badge ${getTypeClass(log.requestType)}">
${getTypeText(log.requestType)}
</span>
</td>
<td>
<span class="log-model">${log.model || 'unknown'}</span>
</td>
<td class="log-timing">
${log.duration ? log.duration + ' ms' : '-'}
</td>
<td class="log-status ${log.status === 'success' ? 'status-success' : 'status-error'}">
${log.status === 'success' ? '✓' : '✗'}
</td>
<td class="log-tokens">
${log.responseTokens || 0}
</td>
<td class="log-detail" title="${log.responseContent || log.error || '无内容'}">
${getDetailText(log)}
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
// 更新分页信息
updatePagination();
pagination.style.display = 'flex';
}
// 格式化日志时间
function formatLogTime(timeString) {
const date = new Date(timeString);
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `2025-${month}-${day} ${hours}:${minutes}:${seconds}`;
}
// 获取类型样式类
function getTypeClass(requestType) {
if (requestType === 'stream') return 'type-stream';
if (requestType === 'fake-stream') return 'type-fake-stream';
return 'type-normal';
}
// 获取类型文本
function getTypeText(requestType) {
if (requestType === 'stream') return '流';
if (requestType === 'fake-stream') return '假流';
return '普通';
}
// 获取详情文本
function getDetailText(log) {
if (log.status === 'error' && log.error) {
return `错误: ${log.error}`;
}
if (log.responseContent) {
return log.responseContent;
}
return '无内容';
}
// 更新分页信息
function updatePagination() {
const info = document.getElementById('paginationInfo');
const startRecord = (currentPage - 1) * pageSize + 1;
const endRecord = Math.min(currentPage * pageSize, filteredLogs.length);
info.textContent = `显示第 ${startRecord} 条 - 第 ${endRecord} 条,共 ${filteredLogs.length} 条`;
// 更新按钮状态
document.getElementById('firstPageBtn').disabled = currentPage <= 1;
document.getElementById('prevPageBtn').disabled = currentPage <= 1;
document.getElementById('nextPageBtn').disabled = currentPage >= totalPages;
// 更新页码输入框
document.getElementById('pageInput').value = currentPage;
document.getElementById('pageInput').max = totalPages;
}
// 跳转到指定页
function goToPage(page) {
if (page < 1 || page > totalPages) return;
currentPage = page;
renderLogs();
}
// 跳转到输入的页码
function goToInputPage() {
const inputPage = parseInt(document.getElementById('pageInput').value);
goToPage(inputPage);
}
// 改变每页大小
function changePageSize() {
pageSize = parseInt(document.getElementById('pageSizeSelect').value);
currentPage = 1;
renderLogs();
}
// 刷新日志
async function refreshLogs() {
document.getElementById('logsContainer').innerHTML = `
<div class="loading show">
<div class="spinner"></div>
<p>重新加载日志中...</p>
</div>
`;
await loadAllLogs();
applyFilters();
}
// 清除所有日志
async function clearAllLogs() {
if (!confirm('确定要清除所有Key的日志吗?此操作不可撤销!')) {
return;
}
try {
const deletePromises = authKeys.map(key =>
fetch(`/admin/logs/${key.token}`, { method: 'DELETE' })
);
await Promise.all(deletePromises);
showSuccess('所有日志已清除');
await loadAllLogs();
applyFilters();
await loadStatistics();
await loadRecentLogs();
} catch (error) {
alert('清除日志失败: ' + error.message);
}
}
// 加载最近日志
async function loadRecentLogs() {
try {
const recentLogs = allLogs.slice(0, 10);
const recentLogsContainer = document.getElementById('recentLogs');
if (recentLogs.length === 0) {
recentLogsContainer.innerHTML = `
<div class="empty-state">
<h3>暂无请求日志</h3>
<p>当有API请求时,日志将在这里显示</p>
</div>
`;
} else {
recentLogsContainer.innerHTML = `
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>Key别名</th>
<th>时间</th>
<th>类型</th>
<th>模型</th>
<th>状态</th>
<th>Token数</th>
<th>响应内容</th>
</tr>
</thead>
<tbody>
${recentLogs.map(log => `
<tr>
<td><code>${log.keyAlias || 'Unknown'}</code></td>
<td>${new Date(log.timestamp || log.requestTime).toLocaleString()}</td>
<td>
<span class="status-badge ${log.requestType === 'stream' ? 'status-success' : 'status-secondary'}">
${log.requestType === 'stream' ? '🌊 流式' : log.requestType === 'fake-stream' ? '🎭 假流式' : '📄 普通'}
</span>
</td>
<td>${log.model || '未知'}</td>
<td>
<span class="status-badge ${log.status === 'success' ? 'status-success' : 'status-error'}">
${log.status === 'success' ? '✅ 成功' : '❌ 失败'}
</span>
</td>
<td>${log.responseTokens || 0}</td>
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
${log.responseContent || (log.error ? '错误: ' + log.error : '无内容')}
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
}
} catch (error) {
console.error('加载最近日志失败:', error);
document.getElementById('recentLogs').innerHTML = `
<div class="alert alert-error">加载日志失败,请稍后重试</div>
`;
}
}
// 刷新Keys
function refreshKeys() {
loadAuthKeys();
}
// 显示添加Key模态框
function showAddKeyModal() {
document.getElementById('addKeyModal').style.display = 'block';
}
// 关闭模态框
function closeModal(modalId) {
document.getElementById(modalId).style.display = 'none';
// 清空表单
if (modalId === 'addKeyModal') {
document.getElementById('keyAlias').value = '';
document.getElementById('keyToken').value = '';
} else if (modalId === 'editAliasModal') {
document.getElementById('editAlias').value = '';
currentEditToken = null;
}
}
// 添加Key
async function addKey(event) {
event.preventDefault();
const alias = document.getElementById('keyAlias').value;
const token = document.getElementById('keyToken').value;
try {
const response = await fetch('/admin/keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ alias, token })
});
const result = await response.json();
if (result.success) {
showSuccess('认证Key添加成功');
closeModal('addKeyModal');
await loadAuthKeys();
} else {
alert('添加失败: ' + (result.message || '未知错误'));
}
} catch (error) {
alert('添加失败: ' + error.message);
}
}
// 切换Key状态
async function toggleKeyStatus(token, enabled) {
try {
const response = await fetch(`/admin/keys/${encodeURIComponent(token)}/status`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled })
});
const result = await response.json();
if (result.success) {
showSuccess(result.message);
await loadAuthKeys();
} else {
alert('操作失败: ' + (result.message || '未知错误'));
}
} catch (error) {
alert('操作失败: ' + error.message);
}
}
// 删除Key
async function deleteKey(token) {
if (!confirm('确定要删除此认证Key吗?此操作不可撤销!')) {
return;
}
try {
const response = await fetch(`/admin/keys/${encodeURIComponent(token)}`, {
method: 'DELETE'
});
const result = await response.json();
if (result.success) {
showSuccess('认证Key删除成功');
await loadAuthKeys();
} else {
alert('删除失败: ' + (result.message || '未知错误'));
}
} catch (error) {
alert('删除失败: ' + error.message);
}
}
// 导出Keys配置
async function exportKeys() {
try {
const response = await fetch('/admin/export');
const data = await response.json();
document.getElementById('exportContent').textContent = data.envString;
document.getElementById('exportArea').style.display = 'block';
// 滚动到导出区域
document.getElementById('exportArea').scrollIntoView({ behavior: 'smooth' });
} catch (error) {
alert('导出失败: ' + error.message);
}
}
// 加载图表
async function loadCharts() {
try {
const response = await fetch('/admin/statistics');
const stats = await response.json();
// 清除旧图表
Object.values(chartInstances).forEach(chart => chart.destroy());
chartInstances = {};
// 准备数据
const keyLabels = [];
const requestTypeCounts = { stream: 0, normal: 0, 'fake-stream': 0 };
const modelCounts = {};
const statusCounts = { success: 0, error: 0 };
const keyRequestCounts = [];
Object.entries(stats).forEach(([key, keyStats]) => {
const alias = authKeys.find(k => k.token === key)?.alias || key.substring(0, 10) + '...';
keyLabels.push(alias);
keyRequestCounts.push(keyStats.totalRequests);
requestTypeCounts.stream += keyStats.streamRequests;
requestTypeCounts.normal += keyStats.normalRequests;
requestTypeCounts['fake-stream'] += keyStats.fakeStreamRequests || 0;
statusCounts.success += (keyStats.totalRequests - keyStats.errorRequests);
statusCounts.error += keyStats.errorRequests;
Object.entries(keyStats.modelUsage || {}).forEach(([model, count]) => {
modelCounts[model] = (modelCounts[model] || 0) + count;
});
});
// 请求类型分布图
chartInstances.requestType = new Chart(document.getElementById('requestTypeChart'), {
type: 'doughnut',
data: {
labels: ['流式请求', '普通请求', '假流式请求'],
datasets: [{
data: [requestTypeCounts.stream, requestTypeCounts.normal, requestTypeCounts['fake-stream']],
backgroundColor: ['#42a5f5', '#66bb6a', '#ffb74d'],
borderWidth: 2,
borderColor: '#fff'
}]
},
options: {
responsive: true,
plugins: {
legend: {
position: 'bottom'
}
}
}
});
// 模型使用统计
const modelLabels = Object.keys(modelCounts).slice(0, 8);
const modelData = modelLabels.map(model => modelCounts[model]);
chartInstances.modelUsage = new Chart(document.getElementById('modelUsageChart'), {
type: 'bar',
data: {
labels: modelLabels,
datasets: [{
label: '请求次数',
data: modelData,
backgroundColor: '#42a5f5',
borderColor: '#1976d2',
borderWidth: 1
}]
},
options: {
responsive: true,
scales: {
y: {
beginAtZero: true
}
}
}
});
// 请求状态分布
chartInstances.status = new Chart(document.getElementById('statusChart'), {
type: 'pie',
data: {
labels: ['成功', '失败'],
datasets: [{
data: [statusCounts.success, statusCounts.error],
backgroundColor: ['#4caf50', '#f44336'],
borderWidth: 2,
borderColor: '#fff'
}]
},
options: {
responsive: true,
plugins: {
legend: {
position: 'bottom'
}
}
}
});
// Key使用对比
chartInstances.keyUsage = new Chart(document.getElementById('keyUsageChart'), {
type: 'bar',
data: {
labels: keyLabels,
datasets: [{
label: '请求次数',
data: keyRequestCounts,
backgroundColor: '#66bb6a',
borderColor: '#4caf50',
borderWidth: 1
}]
},
options: {
responsive: true,
scales: {
y: {
beginAtZero: true
}
}
}
});
} catch (error) {
console.error('加载图表失败:', error);
}
}
// 页面点击事件 - 关闭模态框
window.addEventListener('click', function(event) {
const modals = document.querySelectorAll('.modal');
modals.forEach(modal => {
if (event.target === modal) {
modal.style.display = 'none';
}
});
});
// 回车登录
document.getElementById('password').addEventListener('keypress', function(event) {
if (event.key === 'Enter') {
login();
}
});
// 页面加载完成后初始化
document.addEventListener('DOMContentLoaded', function() {
// 页面加载时显示正确的日志标签
setTimeout(() => {
if (document.querySelector('.nav-tab[onclick*="logs"]')) {
applyFilters();
}
}, 1000);
});
</script>
</body>
</html>