|
|
<!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> |
|
|
|