| <!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> |
|
|
| |
| <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')">×</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')">×</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() { |
| |
| 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()} 格式的日志文件...`); |
| } |
| |
| |
| 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')); |
| |
| |
| const tabs = document.querySelectorAll('.nav-tab'); |
| tabs.forEach(tab => tab.classList.remove('active')); |
| |
| |
| document.getElementById(tabName + '-tab').classList.add('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); |
| } |
| } |
| |
| |
| 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); |
| }); |
| |
| |
| updateKeysTable(); |
| |
| } catch (error) { |
| console.error('加载认证Keys失败:', error); |
| } |
| } |
| |
| |
| 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> |
| `; |
| } |
| } |
| |
| |
| function refreshKeys() { |
| loadAuthKeys(); |
| } |
| |
| |
| 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; |
| } |
| } |
| |
| |
| 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); |
| } |
| } |
| |
| |
| 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); |
| } |
| } |
| |
| |
| 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); |
| } |
| } |
| |
| |
| 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' |
| } |
| } |
| } |
| }); |
| |
| |
| 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> |
|
|