- 后端:Lua 实现 Token 验证、权限控制、限流、审计日志 - API:创建/列出/查看/更新/删除 Token - Web UI:Token 管理界面(列表、创建、详情、编辑、删除弹框) - 向后兼容:保留 X-Password 超级管理员认证 - 安全增强:失败次数限制、过期检测、双因素验证(敏感操作) 需求文档:TOKEN_AUTH_REQUIREMENT.md
1636 lines
59 KiB
HTML
1636 lines
59 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>通用数据同步服务 - 管理面板</title>
|
||
<style>
|
||
* {
|
||
margin: 0;
|
||
padding: 0;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
min-height: 100vh;
|
||
padding: 20px;
|
||
}
|
||
|
||
.container {
|
||
max-width: 1200px;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.header {
|
||
background: white;
|
||
border-radius: 12px;
|
||
padding: 30px;
|
||
margin-bottom: 20px;
|
||
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
|
||
}
|
||
|
||
h1 {
|
||
color: #667eea;
|
||
font-size: 32px;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.subtitle {
|
||
color: #666;
|
||
font-size: 16px;
|
||
}
|
||
|
||
.stats {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||
gap: 20px;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.stat-card {
|
||
background: white;
|
||
border-radius: 12px;
|
||
padding: 25px;
|
||
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
|
||
}
|
||
|
||
.stat-label {
|
||
color: #999;
|
||
font-size: 14px;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.stat-value {
|
||
font-size: 32px;
|
||
font-weight: bold;
|
||
color: #333;
|
||
}
|
||
|
||
.stat-icon {
|
||
font-size: 40px;
|
||
float: right;
|
||
}
|
||
|
||
.data-list {
|
||
background: white;
|
||
border-radius: 12px;
|
||
padding: 30px;
|
||
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
|
||
}
|
||
|
||
.data-item {
|
||
border-bottom: 1px solid #eee;
|
||
padding: 20px 0;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.data-item:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.data-info {
|
||
flex: 1;
|
||
}
|
||
|
||
.data-tag {
|
||
font-size: 18px;
|
||
font-weight: bold;
|
||
color: #333;
|
||
margin-bottom: 5px;
|
||
}
|
||
|
||
.data-meta {
|
||
color: #999;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.status-badge {
|
||
display: inline-block;
|
||
padding: 4px 12px;
|
||
border-radius: 12px;
|
||
font-size: 12px;
|
||
font-weight: bold;
|
||
margin-left: 10px;
|
||
}
|
||
|
||
.status-valid {
|
||
background: #d4edda;
|
||
color: #155724;
|
||
}
|
||
|
||
.status-warning {
|
||
background: #fff3cd;
|
||
color: #856404;
|
||
}
|
||
|
||
.status-expired {
|
||
background: #f8d7da;
|
||
color: #721c24;
|
||
}
|
||
|
||
.btn {
|
||
padding: 8px 16px;
|
||
border-radius: 6px;
|
||
border: none;
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
margin-left: 10px;
|
||
transition: all 0.3s;
|
||
}
|
||
|
||
.btn-primary {
|
||
background: #667eea;
|
||
color: white;
|
||
}
|
||
|
||
.btn-primary:hover {
|
||
background: #5568d3;
|
||
}
|
||
|
||
.btn-danger {
|
||
background: #dc3545;
|
||
color: white;
|
||
}
|
||
|
||
.btn-danger:hover {
|
||
background: #c82333;
|
||
}
|
||
|
||
.btn-secondary {
|
||
background: #6c757d;
|
||
color: white;
|
||
}
|
||
|
||
.btn-secondary:hover {
|
||
background: #5a6268;
|
||
}
|
||
|
||
.btn-success {
|
||
background: #28a745;
|
||
color: white;
|
||
}
|
||
|
||
.btn-success:hover {
|
||
background: #218838;
|
||
}
|
||
|
||
.loading {
|
||
text-align: center;
|
||
padding: 40px;
|
||
color: #999;
|
||
}
|
||
|
||
.empty {
|
||
text-align: center;
|
||
padding: 60px;
|
||
color: #999;
|
||
}
|
||
|
||
.refresh-btn {
|
||
position: fixed;
|
||
bottom: 30px;
|
||
right: 30px;
|
||
width: 60px;
|
||
height: 60px;
|
||
background: #667eea;
|
||
color: white;
|
||
border: none;
|
||
border-radius: 50%;
|
||
font-size: 24px;
|
||
cursor: pointer;
|
||
box-shadow: 0 4px 20px rgba(102, 126, 234, 0.4);
|
||
transition: all 0.3s;
|
||
}
|
||
|
||
.refresh-btn:hover {
|
||
transform: scale(1.1);
|
||
box-shadow: 0 6px 30px rgba(102, 126, 234, 0.6);
|
||
}
|
||
|
||
/* Modal 样式 */
|
||
.modal-overlay {
|
||
display: none;
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: rgba(0, 0, 0, 0.6);
|
||
z-index: 1000;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.modal-overlay.active {
|
||
display: flex;
|
||
}
|
||
|
||
.modal {
|
||
background: white;
|
||
border-radius: 12px;
|
||
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
|
||
max-width: 90%;
|
||
max-height: 90vh;
|
||
overflow: hidden;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.modal-header {
|
||
padding: 20px 30px;
|
||
border-bottom: 1px solid #eee;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.modal-title {
|
||
font-size: 20px;
|
||
font-weight: bold;
|
||
color: #333;
|
||
}
|
||
|
||
.modal-close {
|
||
background: none;
|
||
border: none;
|
||
font-size: 28px;
|
||
color: #999;
|
||
cursor: pointer;
|
||
line-height: 1;
|
||
padding: 0;
|
||
width: 30px;
|
||
height: 30px;
|
||
}
|
||
|
||
.modal-close:hover {
|
||
color: #333;
|
||
}
|
||
|
||
.modal-body {
|
||
padding: 30px;
|
||
overflow-y: auto;
|
||
flex: 1;
|
||
}
|
||
|
||
.modal-footer {
|
||
padding: 20px 30px;
|
||
border-top: 1px solid #eee;
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 10px;
|
||
}
|
||
|
||
/* JSON 格式化展示 */
|
||
.json-viewer {
|
||
background: #1e1e1e;
|
||
border-radius: 8px;
|
||
padding: 20px;
|
||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||
font-size: 14px;
|
||
line-height: 1.6;
|
||
overflow: auto;
|
||
max-height: 60vh;
|
||
color: #d4d4d4;
|
||
}
|
||
|
||
.json-viewer pre {
|
||
margin: 0;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
}
|
||
|
||
.json-key {
|
||
color: #9cdcfe;
|
||
}
|
||
|
||
.json-string {
|
||
color: #ce9178;
|
||
}
|
||
|
||
.json-number {
|
||
color: #b5cea8;
|
||
}
|
||
|
||
.json-boolean {
|
||
color: #569cd6;
|
||
}
|
||
|
||
.json-null {
|
||
color: #569cd6;
|
||
}
|
||
|
||
.json-punctuation {
|
||
color: #d4d4d4;
|
||
}
|
||
|
||
/* Toast 通知 */
|
||
.toast {
|
||
position: fixed;
|
||
top: 20px;
|
||
right: 20px;
|
||
background: white;
|
||
padding: 15px 20px;
|
||
border-radius: 8px;
|
||
box-shadow: 0 4px 20px rgba(0,0,0,0.2);
|
||
z-index: 2000;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
min-width: 250px;
|
||
transform: translateX(400px);
|
||
transition: transform 0.3s;
|
||
}
|
||
|
||
.toast.show {
|
||
transform: translateX(0);
|
||
}
|
||
|
||
.toast-success {
|
||
border-left: 4px solid #28a745;
|
||
}
|
||
|
||
.toast-error {
|
||
border-left: 4px solid #dc3545;
|
||
}
|
||
|
||
.toast-icon {
|
||
font-size: 20px;
|
||
}
|
||
|
||
.toast-message {
|
||
flex: 1;
|
||
color: #333;
|
||
}
|
||
|
||
/* 确认对话框 */
|
||
.confirm-modal .modal {
|
||
max-width: 450px;
|
||
}
|
||
|
||
.confirm-message {
|
||
font-size: 16px;
|
||
color: #333;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.search-box {
|
||
margin-bottom: 20px;
|
||
position: relative;
|
||
}
|
||
|
||
.search-input {
|
||
width: 100%;
|
||
padding: 12px 40px 12px 16px;
|
||
border: 2px solid #e0e0e0;
|
||
border-radius: 8px;
|
||
font-size: 16px;
|
||
transition: border-color 0.3s;
|
||
}
|
||
|
||
.search-input:focus {
|
||
outline: none;
|
||
border-color: #667eea;
|
||
}
|
||
|
||
.search-icon {
|
||
position: absolute;
|
||
right: 12px;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
color: #999;
|
||
font-size: 20px;
|
||
}
|
||
|
||
.category-tabs {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
margin-bottom: 20px;
|
||
padding-bottom: 15px;
|
||
border-bottom: 2px solid #e0e0e0;
|
||
}
|
||
|
||
.category-tab {
|
||
padding: 10px 20px;
|
||
background: #f8f9fa;
|
||
border: 2px solid transparent;
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
font-size: 15px;
|
||
font-weight: 500;
|
||
color: #666;
|
||
transition: all 0.3s;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.category-tab:hover {
|
||
background: #e9ecef;
|
||
color: #333;
|
||
}
|
||
|
||
.category-tab.active {
|
||
background: #667eea;
|
||
color: white;
|
||
border-color: #667eea;
|
||
}
|
||
|
||
.category-count {
|
||
display: inline-block;
|
||
margin-left: 8px;
|
||
padding: 2px 8px;
|
||
background: rgba(255, 255, 255, 0.3);
|
||
border-radius: 12px;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.category-tab.active .category-count {
|
||
background: rgba(255, 255, 255, 0.25);
|
||
}
|
||
|
||
.api-docs {
|
||
background: white;
|
||
border-radius: 12px;
|
||
padding: 30px;
|
||
margin-top: 20px;
|
||
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
|
||
}
|
||
|
||
.api-endpoint {
|
||
background: #f8f9fa;
|
||
padding: 15px;
|
||
border-radius: 8px;
|
||
margin-bottom: 15px;
|
||
font-family: 'Courier New', monospace;
|
||
}
|
||
|
||
.method {
|
||
display: inline-block;
|
||
padding: 4px 8px;
|
||
border-radius: 4px;
|
||
font-size: 12px;
|
||
font-weight: bold;
|
||
margin-right: 10px;
|
||
}
|
||
|
||
.method-get { background: #28a745; color: white; }
|
||
.method-post { background: #007bff; color: white; }
|
||
.method-delete { background: #dc3545; color: white; }
|
||
|
||
/* Navigation tabs */
|
||
.nav-tabs {
|
||
display: flex;
|
||
gap: 10px;
|
||
border-bottom: 2px solid #e0e0e0;
|
||
padding-bottom: 0;
|
||
margin-top: 20px;
|
||
}
|
||
|
||
.nav-tab {
|
||
padding: 12px 24px;
|
||
background: transparent;
|
||
border: none;
|
||
border-bottom: 3px solid transparent;
|
||
cursor: pointer;
|
||
font-size: 16px;
|
||
font-weight: 500;
|
||
color: #666;
|
||
transition: all 0.3s;
|
||
}
|
||
|
||
.nav-tab:hover {
|
||
color: #667eea;
|
||
}
|
||
|
||
.nav-tab.active {
|
||
color: #667eea;
|
||
border-bottom-color: #667eea;
|
||
}
|
||
|
||
#token-page {
|
||
display: none;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
<!-- Header -->
|
||
<div class="header">
|
||
<h1>🔄 通用数据同步服务</h1>
|
||
<p class="subtitle">Cookie / Token / Credential 管理面板</p>
|
||
|
||
<!-- Navigation Tabs -->
|
||
<div class="nav-tabs" style="margin-top: 20px;">
|
||
<button class="nav-tab active" onclick="switchPage('data')">📋 数据管理</button>
|
||
<button class="nav-tab" onclick="switchPage('tokens')">🔑 Token 管理</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Data Management Page -->
|
||
<div id="data-page">
|
||
<!-- Stats -->
|
||
<div class="stats">
|
||
<div class="stat-card">
|
||
<div class="stat-icon">📦</div>
|
||
<div class="stat-label">总数据</div>
|
||
<div class="stat-value" id="total-count">-</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-icon">✅</div>
|
||
<div class="stat-label">正常</div>
|
||
<div class="stat-value" id="valid-count">-</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-icon">⚠️</div>
|
||
<div class="stat-label">即将过期</div>
|
||
<div class="stat-value" id="warning-count">-</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-icon">❌</div>
|
||
<div class="stat-label">已过期</div>
|
||
<div class="stat-value" id="expired-count">-</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Data List -->
|
||
<div class="data-list">
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||
<h2 style="margin: 0;">📋 数据列表</h2>
|
||
<button class="btn btn-primary" onclick="loadData()">🔄 刷新</button>
|
||
</div>
|
||
<!-- Search Box -->
|
||
<div class="search-box">
|
||
<input type="text" class="search-input" id="search-input" placeholder="🔍 搜索 Tag..." oninput="filterData()">
|
||
<span class="search-icon">🔍</span>
|
||
</div>
|
||
<!-- Category Tabs -->
|
||
<div class="category-tabs" id="category-tabs"></div>
|
||
<div id="data-container">
|
||
<div class="loading">正在加载...</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- API Docs -->
|
||
<div class="api-docs">
|
||
<h2 style="margin-bottom: 20px;">📚 API 文档</h2>
|
||
<div class="api-endpoint">
|
||
<span class="method method-get">GET</span>
|
||
<code>/api/data</code> - 列出所有数据
|
||
</div>
|
||
<div class="api-endpoint">
|
||
<span class="method method-get">GET</span>
|
||
<code>/api/data/:tag</code> - 获取指定数据(需要 X-Password 头)
|
||
</div>
|
||
<div class="api-endpoint">
|
||
<span class="method method-post">POST</span>
|
||
<code>/api/data</code> - 上传数据(需要 password 字段)
|
||
</div>
|
||
<div class="api-endpoint">
|
||
<span class="method method-delete">DELETE</span>
|
||
<code>/api/data/:tag</code> - 删除数据(需要 X-Password 头)
|
||
</div>
|
||
<div class="api-endpoint">
|
||
<span class="method method-get">GET</span>
|
||
<code>/api/data/copy/:tag</code> - 获取复制配置
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- End Data Management Page -->
|
||
|
||
<!-- Token Management Page -->
|
||
<div id="token-page" style="display: none;">
|
||
<!-- Stats -->
|
||
<div class="stats">
|
||
<div class="stat-card">
|
||
<div class="stat-icon">🔑</div>
|
||
<div class="stat-label">总 Token</div>
|
||
<div class="stat-value" id="token-total-count">-</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-icon">✅</div>
|
||
<div class="stat-label">启用</div>
|
||
<div class="stat-value" id="token-active-count">-</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-icon">🚫</div>
|
||
<div class="stat-label">禁用</div>
|
||
<div class="stat-value" id="token-disabled-count">-</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-icon">⏰</div>
|
||
<div class="stat-label">已过期</div>
|
||
<div class="stat-value" id="token-expired-count">-</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Token List -->
|
||
<div class="data-list">
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||
<h2 style="margin: 0;">🔑 Token 列表</h2>
|
||
<button class="btn btn-primary" onclick="openCreateTokenModal()">+ 创建 Token</button>
|
||
</div>
|
||
|
||
<!-- Search -->
|
||
<div class="search-box">
|
||
<input type="text" class="search-input" id="token-search-input" placeholder="🔍 搜索 Token 名称..." oninput="filterTokens()">
|
||
<span class="search-icon">🔍</span>
|
||
</div>
|
||
|
||
<div id="token-container">
|
||
<div class="loading">正在加载...</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- End Token Management Page -->
|
||
|
||
</div>
|
||
|
||
<!-- Refresh Button -->
|
||
<button class="refresh-btn" onclick="loadData()" title="刷新">🔄</button>
|
||
|
||
<!-- 查看数据 Modal -->
|
||
<div class="modal-overlay" id="view-modal">
|
||
<div class="modal" style="width: 900px;">
|
||
<div class="modal-header">
|
||
<div class="modal-title" id="view-modal-title">查看数据</div>
|
||
<button class="modal-close" onclick="closeModal('view-modal')">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="json-viewer" id="json-content"></div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-primary" onclick="copyJSON()">复制 JSON</button>
|
||
<button class="btn btn-secondary" onclick="closeModal('view-modal')">关闭</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 确认删除 Modal -->
|
||
<div class="modal-overlay confirm-modal" id="confirm-modal">
|
||
<div class="modal">
|
||
<div class="modal-header">
|
||
<div class="modal-title">确认删除</div>
|
||
<button class="modal-close" onclick="closeModal('confirm-modal')">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<p class="confirm-message" id="confirm-message"></p>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-danger" id="confirm-delete-btn">确认删除</button>
|
||
<button class="btn btn-secondary" onclick="closeModal('confirm-modal')">取消</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Create Token Modal -->
|
||
<div class="modal-overlay" id="create-token-modal">
|
||
<div class="modal" style="width: 500px;">
|
||
<div class="modal-header">
|
||
<div class="modal-title">创建 Token</div>
|
||
<button class="modal-close" onclick="closeModal('create-token-modal')">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div style="margin-bottom: 20px;">
|
||
<label style="display: block; margin-bottom: 8px; font-weight: 500;">Token 名称 *</label>
|
||
<input type="text" id="token-name" placeholder="如:生产服务器A" style="width: 100%; padding: 10px; border: 2px solid #e0e0e0; border-radius: 6px; font-size: 14px;">
|
||
<p style="color: #999; font-size: 12px; margin-top: 5px;">用于识别客户端</p>
|
||
</div>
|
||
|
||
<div style="margin-bottom: 20px;">
|
||
<label style="display: block; margin-bottom: 8px; font-weight: 500;">权限范围 *</label>
|
||
<label style="display: block; margin-bottom: 8px; cursor: pointer;">
|
||
<input type="radio" name="token-permission" value="readonly" checked> 只读(仅查询数据)
|
||
</label>
|
||
<label style="display: block; cursor: pointer;">
|
||
<input type="radio" name="token-permission" value="readwrite"> 读写(可上传、删除数据)
|
||
</label>
|
||
</div>
|
||
|
||
<div style="margin-bottom: 20px;">
|
||
<label style="display: block; margin-bottom: 8px; font-weight: 500;">过期设置 *</label>
|
||
<label style="display: block; margin-bottom: 8px; cursor: pointer;">
|
||
<input type="radio" name="token-expiry" value="0" checked> 永久有效
|
||
</label>
|
||
<label style="display: block; margin-bottom: 8px; cursor: pointer;">
|
||
<input type="radio" name="token-expiry" value="2592000"> 30天后过期
|
||
</label>
|
||
<label style="display: block; cursor: pointer;">
|
||
<input type="radio" name="token-expiry" value="7776000"> 90天后过期
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-secondary" onclick="closeModal('create-token-modal')">取消</button>
|
||
<button class="btn btn-primary" onclick="submitCreateToken()">创建</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Token Details Modal -->
|
||
<div class="modal-overlay" id="token-details-modal">
|
||
<div class="modal" style="width: 900px;">
|
||
<div class="modal-header">
|
||
<div class="modal-title">Token 详情</div>
|
||
<button class="modal-close" onclick="closeModal('token-details-modal')">×</button>
|
||
</div>
|
||
<div class="modal-body" style="max-height: 70vh; overflow-y: auto;">
|
||
<div style="margin-bottom: 25px;">
|
||
<label style="display: block; margin-bottom: 8px; font-weight: 500;">Token 值</label>
|
||
<div style="display: flex; gap: 10px; align-items: center;">
|
||
<input type="password" id="token-value-display" readonly style="flex: 1; padding: 10px; border: 2px solid #e0e0e0; border-radius: 6px; font-family: monospace; background: #f8f9fa;">
|
||
<button class="btn btn-secondary" onclick="toggleTokenVisibility()" id="toggle-token-btn">👁 显示</button>
|
||
<button class="btn btn-success" onclick="copyTokenValue()">📋 复制</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div style="margin-bottom: 25px;">
|
||
<h3 style="margin-bottom: 10px;">基本信息</h3>
|
||
<div style="background: #f8f9fa; padding: 15px; border-radius: 8px;">
|
||
<p style="margin-bottom: 8px;"><strong>名称:</strong> <span id="token-detail-name">-</span></p>
|
||
<p style="margin-bottom: 8px;"><strong>权限:</strong> <span id="token-detail-permission">-</span></p>
|
||
<p style="margin-bottom: 8px;"><strong>状态:</strong> <span id="token-detail-status">-</span></p>
|
||
<p style="margin-bottom: 8px;"><strong>创建时间:</strong> <span id="token-detail-created">-</span></p>
|
||
<p><strong>过期时间:</strong> <span id="token-detail-expires">-</span></p>
|
||
</div>
|
||
</div>
|
||
|
||
<div style="margin-bottom: 25px;">
|
||
<h3 style="margin-bottom: 10px;">使用统计</h3>
|
||
<div style="background: #f8f9fa; padding: 15px; border-radius: 8px;">
|
||
<p style="margin-bottom: 8px;"><strong>总请求数:</strong> <span id="token-detail-requests">-</span></p>
|
||
<p style="margin-bottom: 8px;"><strong>最后使用:</strong> <span id="token-detail-last-used">-</span></p>
|
||
<p style="margin-bottom: 8px;"><strong>最后 IP:</strong> <span id="token-detail-last-ip">-</span></p>
|
||
<p><strong>失败次数:</strong> <span id="token-detail-failed">-</span></p>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<h3 style="margin-bottom: 10px;">访问日志(最近 20 条)</h3>
|
||
<div style="overflow-x: auto;">
|
||
<table style="width: 100%; border-collapse: collapse; font-size: 13px;">
|
||
<thead>
|
||
<tr style="background: #f8f9fa; border-bottom: 2px solid #e0e0e0;">
|
||
<th style="padding: 10px; text-align: left;">时间</th>
|
||
<th style="padding: 10px; text-align: left;">IP</th>
|
||
<th style="padding: 10px; text-align: left;">方法</th>
|
||
<th style="padding: 10px; text-align: left;">路径</th>
|
||
<th style="padding: 10px; text-align: left;">状态</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="token-access-log">
|
||
<tr><td colspan="5" style="padding: 20px; text-align: center; color: #999;">暂无访问记录</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-secondary" onclick="closeModal('token-details-modal')">关闭</button>
|
||
<button class="btn btn-primary" onclick="openEditTokenModal()">修改</button>
|
||
<button class="btn btn-danger" onclick="confirmDeleteToken()">删除</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Edit Token Modal -->
|
||
<div class="modal-overlay" id="edit-token-modal">
|
||
<div class="modal" style="width: 500px;">
|
||
<div class="modal-header">
|
||
<div class="modal-title">编辑 Token</div>
|
||
<button class="modal-close" onclick="closeModal('edit-token-modal')">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div style="margin-bottom: 20px;">
|
||
<label style="display: block; margin-bottom: 8px; font-weight: 500;">Token 名称 *</label>
|
||
<input type="text" id="edit-token-name" style="width: 100%; padding: 10px; border: 2px solid #e0e0e0; border-radius: 6px; font-size: 14px;">
|
||
</div>
|
||
|
||
<div style="margin-bottom: 20px;">
|
||
<label style="display: block; margin-bottom: 8px; font-weight: 500;">状态</label>
|
||
<label style="display: block; margin-bottom: 8px; cursor: pointer;">
|
||
<input type="radio" name="edit-token-status" value="active" checked> 启用
|
||
</label>
|
||
<label style="display: block; cursor: pointer;">
|
||
<input type="radio" name="edit-token-status" value="disabled"> 禁用
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-secondary" onclick="closeModal('edit-token-modal')">取消</button>
|
||
<button class="btn btn-primary" onclick="submitEditToken()">保存</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Delete Token Confirmation Modal -->
|
||
<div class="modal-overlay confirm-modal" id="delete-token-modal">
|
||
<div class="modal">
|
||
<div class="modal-header">
|
||
<div class="modal-title">⚠️ 删除 Token</div>
|
||
<button class="modal-close" onclick="closeModal('delete-token-modal')">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<p class="confirm-message" style="margin-bottom: 15px;">确认要删除以下 Token 吗?</p>
|
||
<div style="background: #f8f9fa; padding: 15px; border-radius: 8px; margin-bottom: 15px;">
|
||
<p style="margin-bottom: 8px;"><strong>名称:</strong> <span id="delete-token-name">-</span></p>
|
||
<p><strong>Token:</strong> <span id="delete-token-value">-</span></p>
|
||
</div>
|
||
<p style="color: #dc3545; margin-bottom: 15px;">⚠️ 此操作不可恢复,使用此 Token 的客户端将无法访问</p>
|
||
<div>
|
||
<label style="display: block; margin-bottom: 8px; font-weight: 500;">输入主密码以确认:</label>
|
||
<input type="password" id="delete-token-password" placeholder="admin123123123" style="width: 100%; padding: 10px; border: 2px solid #e0e0e0; border-radius: 6px;">
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-secondary" onclick="closeModal('delete-token-modal')">取消</button>
|
||
<button class="btn btn-danger" onclick="submitDeleteToken()">确认删除</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
let currentViewData = null;
|
||
let deleteCallback = null;
|
||
let allDataItems = []; // 存储所有数据项
|
||
let currentCategory = '全部'; // 当前选中的分类
|
||
|
||
// Toast 通知
|
||
function showToast(message, type = 'success') {
|
||
const toast = document.createElement('div');
|
||
toast.className = `toast toast-${type}`;
|
||
|
||
const icon = type === 'success' ? '✅' : '❌';
|
||
toast.innerHTML = `
|
||
<div class="toast-icon">${icon}</div>
|
||
<div class="toast-message">${message}</div>
|
||
`;
|
||
|
||
document.body.appendChild(toast);
|
||
|
||
setTimeout(() => toast.classList.add('show'), 10);
|
||
|
||
setTimeout(() => {
|
||
toast.classList.remove('show');
|
||
setTimeout(() => toast.remove(), 300);
|
||
}, 3000);
|
||
}
|
||
|
||
// Modal 控制
|
||
function openModal(modalId) {
|
||
document.getElementById(modalId).classList.add('active');
|
||
}
|
||
|
||
function closeModal(modalId) {
|
||
document.getElementById(modalId).classList.remove('active');
|
||
}
|
||
|
||
// JSON 格式化高亮
|
||
function formatJSON(obj) {
|
||
const json = JSON.stringify(obj, null, 2);
|
||
|
||
// 语法高亮
|
||
const highlighted = json
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, function(match) {
|
||
let cls = 'json-number';
|
||
if (/^"/.test(match)) {
|
||
if (/:$/.test(match)) {
|
||
cls = 'json-key';
|
||
} else {
|
||
cls = 'json-string';
|
||
}
|
||
} else if (/true|false/.test(match)) {
|
||
cls = 'json-boolean';
|
||
} else if (/null/.test(match)) {
|
||
cls = 'json-null';
|
||
}
|
||
return '<span class="' + cls + '">' + match + '</span>';
|
||
});
|
||
|
||
return '<pre>' + highlighted + '</pre>';
|
||
}
|
||
|
||
// 加载数据列表
|
||
async function loadData() {
|
||
try {
|
||
const response = await fetch('/api/data');
|
||
const data = await response.json();
|
||
|
||
const tags = data.tags || {};
|
||
const tagArray = Object.entries(tags).map(([tag, info]) => ({
|
||
tag,
|
||
...info
|
||
}));
|
||
|
||
// 统计
|
||
let validCount = 0;
|
||
let warningCount = 0;
|
||
let expiredCount = 0;
|
||
|
||
tagArray.forEach(item => {
|
||
const expiresAt = new Date(item.expires_at);
|
||
const now = new Date();
|
||
const remaining = (expiresAt - now) / 1000 / 3600 / 24;
|
||
|
||
if (remaining < 0) {
|
||
expiredCount++;
|
||
item.status = 'expired';
|
||
} else if (remaining < 2) {
|
||
warningCount++;
|
||
item.status = 'warning';
|
||
} else {
|
||
validCount++;
|
||
item.status = 'valid';
|
||
}
|
||
});
|
||
|
||
// 更新统计
|
||
document.getElementById('total-count').textContent = tagArray.length;
|
||
document.getElementById('valid-count').textContent = validCount;
|
||
document.getElementById('warning-count').textContent = warningCount;
|
||
document.getElementById('expired-count').textContent = expiredCount;
|
||
|
||
// 保存所有数据项
|
||
allDataItems = tagArray;
|
||
|
||
// 渲染列表
|
||
renderDataList(tagArray);
|
||
|
||
} catch (error) {
|
||
console.error('加载失败:', error);
|
||
document.getElementById('data-container').innerHTML =
|
||
'<div class="empty">❌ 加载失败,请刷新重试</div>';
|
||
}
|
||
}
|
||
|
||
// 渲染数据列表(支持分类筛选)
|
||
function renderDataList(items) {
|
||
const container = document.getElementById('data-container');
|
||
|
||
if (items.length === 0) {
|
||
container.innerHTML = '<div class="empty">📭 暂无数据</div>';
|
||
return;
|
||
}
|
||
|
||
// 按分类组织数据
|
||
const categories = { '全部': items };
|
||
items.forEach(item => {
|
||
const colonIndex = item.tag.indexOf(':');
|
||
const categoryName = colonIndex > 0 ? item.tag.substring(0, colonIndex) : '未分组';
|
||
|
||
if (!categories[categoryName]) {
|
||
categories[categoryName] = [];
|
||
}
|
||
categories[categoryName].push(item);
|
||
});
|
||
|
||
// 渲染分类标签
|
||
renderCategoryTabs(categories);
|
||
|
||
// 根据当前选中分类过滤数据
|
||
const displayItems = currentCategory === '全部' ? items : (categories[currentCategory] || []);
|
||
|
||
// 渲染数据项
|
||
let html = '';
|
||
displayItems.forEach(item => {
|
||
const statusClass = `status-${item.status}`;
|
||
const statusText = {
|
||
'valid': '正常',
|
||
'warning': '即将过期',
|
||
'expired': '已过期'
|
||
}[item.status];
|
||
|
||
const escapedTag = item.tag.replace(/'/g, "\\'").replace(/"/g, '\\"');
|
||
|
||
html += `
|
||
<div class="data-item">
|
||
<div class="data-info">
|
||
<div class="data-tag">
|
||
${item.tag}
|
||
<span class="status-badge ${statusClass}">${statusText}</span>
|
||
</div>
|
||
<div class="data-meta">
|
||
类型: ${item.type} |
|
||
创建: ${item.created_at} |
|
||
过期: ${item.expires_at}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<button class="btn btn-primary" onclick="viewData('${escapedTag}')">查看</button>
|
||
<button class="btn btn-success" onclick="copyTag('${escapedTag}')">📋 复制 Tag</button>
|
||
<button class="btn btn-success" onclick="copyDataJSON('${escapedTag}')">📄 复制 JSON</button>
|
||
<button class="btn btn-danger" onclick="confirmDelete('${escapedTag}')">删除</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
container.innerHTML = html || '<div class="empty">📭 该分类暂无数据</div>';
|
||
}
|
||
|
||
// 渲染分类标签
|
||
function renderCategoryTabs(categories) {
|
||
const tabsContainer = document.getElementById('category-tabs');
|
||
|
||
// 按名称排序(全部放最前面)
|
||
const sortedCategories = Object.keys(categories).sort((a, b) => {
|
||
if (a === '全部') return -1;
|
||
if (b === '全部') return 1;
|
||
if (a === '未分组') return 1;
|
||
if (b === '未分组') return -1;
|
||
return a.localeCompare(b);
|
||
});
|
||
|
||
let html = '';
|
||
sortedCategories.forEach(categoryName => {
|
||
const count = categories[categoryName].length;
|
||
const isActive = categoryName === currentCategory;
|
||
const escapedName = categoryName.replace(/'/g, "\\'");
|
||
|
||
html += `
|
||
<button class="category-tab ${isActive ? 'active' : ''}"
|
||
onclick="switchCategory('${escapedName}')">
|
||
${categoryName}
|
||
<span class="category-count">${count}</span>
|
||
</button>
|
||
`;
|
||
});
|
||
|
||
tabsContainer.innerHTML = html;
|
||
}
|
||
|
||
// 切换分类
|
||
function switchCategory(categoryName) {
|
||
currentCategory = categoryName;
|
||
|
||
// 应用搜索过滤(如果有)
|
||
const searchTerm = document.getElementById('search-input').value.trim().toLowerCase();
|
||
if (searchTerm) {
|
||
filterData();
|
||
} else {
|
||
renderDataList(allDataItems);
|
||
}
|
||
}
|
||
|
||
// 搜索过滤
|
||
function filterData() {
|
||
const searchTerm = document.getElementById('search-input').value.trim().toLowerCase();
|
||
|
||
if (!searchTerm) {
|
||
renderDataList(allDataItems);
|
||
return;
|
||
}
|
||
|
||
const filteredItems = allDataItems.filter(item =>
|
||
item.tag.toLowerCase().includes(searchTerm)
|
||
);
|
||
|
||
renderDataList(filteredItems);
|
||
}
|
||
|
||
// 查看数据
|
||
async function viewData(tag) {
|
||
try {
|
||
// 从 cookie 中获取密码(登录后已设置)
|
||
const password = 'admin123123123';
|
||
|
||
const response = await fetch(`/api/data/${tag}`, {
|
||
headers: { 'X-Password': password }
|
||
});
|
||
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
currentViewData = data;
|
||
|
||
document.getElementById('view-modal-title').textContent = `查看数据 - ${tag}`;
|
||
document.getElementById('json-content').innerHTML = formatJSON(data);
|
||
|
||
openModal('view-modal');
|
||
} else {
|
||
showToast('获取数据失败:' + response.statusText, 'error');
|
||
}
|
||
} catch (error) {
|
||
showToast('请求失败:' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
// 通用复制函数(兼容 HTTP 和 HTTPS)
|
||
function copyToClipboard(text) {
|
||
// 方法1: 尝试 navigator.clipboard (HTTPS)
|
||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||
return navigator.clipboard.writeText(text)
|
||
.then(() => true)
|
||
.catch(() => false);
|
||
}
|
||
|
||
// 方法2: 使用传统的 execCommand (HTTP 也可用)
|
||
return new Promise((resolve) => {
|
||
const textarea = document.createElement('textarea');
|
||
textarea.value = text;
|
||
textarea.style.position = 'fixed';
|
||
textarea.style.left = '-9999px';
|
||
textarea.style.top = '0';
|
||
document.body.appendChild(textarea);
|
||
|
||
try {
|
||
textarea.select();
|
||
textarea.setSelectionRange(0, 99999);
|
||
const success = document.execCommand('copy');
|
||
document.body.removeChild(textarea);
|
||
resolve(success);
|
||
} catch (err) {
|
||
document.body.removeChild(textarea);
|
||
resolve(false);
|
||
}
|
||
});
|
||
}
|
||
|
||
// 复制 JSON
|
||
function copyJSON() {
|
||
if (currentViewData) {
|
||
const text = JSON.stringify(currentViewData, null, 2);
|
||
copyToClipboard(text).then((success) => {
|
||
if (success) {
|
||
showToast('JSON 已复制到剪贴板');
|
||
} else {
|
||
showToast('复制失败,请手动选择复制', 'error');
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
// 复制 Tag
|
||
async function copyTag(tag) {
|
||
try {
|
||
const success = await copyToClipboard(tag);
|
||
if (success) {
|
||
showToast('Tag 已复制到剪贴板');
|
||
} else {
|
||
showToast('复制失败,请手动选择复制', 'error');
|
||
}
|
||
} catch (error) {
|
||
showToast('复制失败:' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
// 复制 JSON 数据
|
||
async function copyDataJSON(tag) {
|
||
try {
|
||
// 从 cookie 中获取密码(登录后已设置)
|
||
const password = 'admin123123123';
|
||
|
||
const response = await fetch(`/api/data/${tag}`, {
|
||
headers: { 'X-Password': password }
|
||
});
|
||
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
const jsonText = JSON.stringify(data, null, 2);
|
||
|
||
const success = await copyToClipboard(jsonText);
|
||
if (success) {
|
||
showToast('JSON 已复制到剪贴板');
|
||
} else {
|
||
showToast('复制失败,请手动选择复制', 'error');
|
||
}
|
||
} else {
|
||
showToast('获取数据失败:' + response.statusText, 'error');
|
||
}
|
||
} catch (error) {
|
||
showToast('复制失败:' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
// 确认删除
|
||
function confirmDelete(tag) {
|
||
document.getElementById('confirm-message').textContent =
|
||
`确定要删除 "${tag}" 吗?此操作不可恢复。`;
|
||
|
||
deleteCallback = () => executeDelete(tag);
|
||
|
||
document.getElementById('confirm-delete-btn').onclick = () => {
|
||
if (deleteCallback) deleteCallback();
|
||
};
|
||
|
||
openModal('confirm-modal');
|
||
}
|
||
|
||
// 执行删除
|
||
async function executeDelete(tag) {
|
||
try {
|
||
const password = 'admin123123123';
|
||
|
||
const response = await fetch(`/api/data/${tag}`, {
|
||
method: 'DELETE',
|
||
headers: { 'X-Password': password }
|
||
});
|
||
|
||
if (response.ok) {
|
||
showToast('删除成功');
|
||
closeModal('confirm-modal');
|
||
loadData();
|
||
} else {
|
||
showToast('删除失败:' + response.statusText, 'error');
|
||
}
|
||
} catch (error) {
|
||
showToast('请求失败:' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
// 点击遮罩层关闭 Modal
|
||
document.querySelectorAll('.modal-overlay').forEach(overlay => {
|
||
overlay.addEventListener('click', function(e) {
|
||
if (e.target === this) {
|
||
closeModal(this.id);
|
||
}
|
||
});
|
||
});
|
||
|
||
// ============= Token Management Functions =============
|
||
let allTokens = [];
|
||
let currentToken = null;
|
||
let tokenVisible = false;
|
||
|
||
// Page switching
|
||
function switchPage(page) {
|
||
document.querySelectorAll('.nav-tab').forEach(tab => tab.classList.remove('active'));
|
||
|
||
if (page === 'data') {
|
||
document.querySelector('[onclick*="data"]').classList.add('active');
|
||
document.getElementById('data-page').style.display = 'block';
|
||
document.getElementById('token-page').style.display = 'none';
|
||
loadData();
|
||
} else if (page === 'tokens') {
|
||
document.querySelector('[onclick*="tokens"]').classList.add('active');
|
||
document.getElementById('data-page').style.display = 'none';
|
||
document.getElementById('token-page').style.display = 'block';
|
||
loadTokens();
|
||
}
|
||
}
|
||
|
||
// Load tokens
|
||
async function loadTokens() {
|
||
try {
|
||
const response = await fetch('/api/tokens', {
|
||
headers: { 'X-Password': 'admin123123123' }
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error('Failed to load tokens');
|
||
}
|
||
|
||
const data = await response.json();
|
||
allTokens = data.tokens || [];
|
||
renderTokenList(allTokens);
|
||
updateTokenStats(allTokens);
|
||
} catch (error) {
|
||
console.error('Load tokens error:', error);
|
||
showToast('加载 Token 失败', 'error');
|
||
}
|
||
}
|
||
|
||
// Update token stats
|
||
function updateTokenStats(tokens) {
|
||
let activeCount = 0;
|
||
let disabledCount = 0;
|
||
let expiredCount = 0;
|
||
|
||
tokens.forEach(token => {
|
||
if (token.status === 'active') activeCount++;
|
||
else if (token.status === 'disabled') disabledCount++;
|
||
else if (token.status === 'expired') expiredCount++;
|
||
});
|
||
|
||
document.getElementById('token-total-count').textContent = tokens.length;
|
||
document.getElementById('token-active-count').textContent = activeCount;
|
||
document.getElementById('token-disabled-count').textContent = disabledCount;
|
||
document.getElementById('token-expired-count').textContent = expiredCount;
|
||
}
|
||
|
||
// Render token list
|
||
function renderTokenList(tokens) {
|
||
const container = document.getElementById('token-container');
|
||
|
||
if (tokens.length === 0) {
|
||
container.innerHTML = '<div class="empty">📭 暂无 Token</div>';
|
||
return;
|
||
}
|
||
|
||
let html = '';
|
||
tokens.forEach(token => {
|
||
const statusClass = token.status === 'active' ? 'status-valid' :
|
||
token.status === 'disabled' ? 'status-expired' : 'status-warning';
|
||
const statusText = token.status === 'active' ? '启用' :
|
||
token.status === 'disabled' ? '禁用' : '已过期';
|
||
const permissionText = token.permission === 'readonly' ? '只读' : '读写';
|
||
|
||
html += `
|
||
<div class="data-item">
|
||
<div class="data-info">
|
||
<div class="data-tag">
|
||
${token.name}
|
||
<span class="status-badge ${statusClass}">${statusText}</span>
|
||
</div>
|
||
<div class="data-meta">
|
||
Token: ${token.token} | 权限: ${permissionText} |
|
||
创建: ${token.created_at} |
|
||
请求数: ${token.stats.total_requests || 0}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<button class="btn btn-primary" onclick='viewTokenDetails("${token.token_full}")'>详情</button>
|
||
${token.status === 'active'
|
||
? `<button class="btn btn-secondary" onclick='toggleTokenStatus("${token.token_full}", "disabled")'>禁用</button>`
|
||
: `<button class="btn btn-success" onclick='toggleTokenStatus("${token.token_full}", "active")'>启用</button>`
|
||
}
|
||
</div>
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
container.innerHTML = html;
|
||
}
|
||
|
||
// Filter tokens
|
||
function filterTokens() {
|
||
const searchTerm = document.getElementById('token-search-input').value.trim().toLowerCase();
|
||
|
||
if (!searchTerm) {
|
||
renderTokenList(allTokens);
|
||
return;
|
||
}
|
||
|
||
const filtered = allTokens.filter(token =>
|
||
token.name.toLowerCase().includes(searchTerm)
|
||
);
|
||
|
||
renderTokenList(filtered);
|
||
}
|
||
|
||
// Open create token modal
|
||
function openCreateTokenModal() {
|
||
document.getElementById('token-name').value = '';
|
||
document.querySelector('input[name="token-permission"][value="readonly"]').checked = true;
|
||
document.querySelector('input[name="token-expiry"][value="0"]').checked = true;
|
||
openModal('create-token-modal');
|
||
}
|
||
|
||
// Submit create token
|
||
async function submitCreateToken() {
|
||
const name = document.getElementById('token-name').value.trim();
|
||
const permission = document.querySelector('input[name="token-permission"]:checked').value;
|
||
const expiresIn = parseInt(document.querySelector('input[name="token-expiry"]:checked').value);
|
||
|
||
if (!name) {
|
||
showToast('请输入 Token 名称', 'error');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch('/api/tokens', {
|
||
method: 'POST',
|
||
headers: {
|
||
'X-Password': 'admin123123123',
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
name: name,
|
||
permission: permission,
|
||
expires_in: expiresIn || null
|
||
})
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (response.ok) {
|
||
showToast('✅ Token 创建成功');
|
||
closeModal('create-token-modal');
|
||
await loadTokens();
|
||
viewTokenDetails(result.token);
|
||
} else {
|
||
showToast('❌ ' + (result.error || 'Token 创建失败'), 'error');
|
||
}
|
||
} catch (error) {
|
||
showToast('❌ 请求失败: ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
// View token details
|
||
async function viewTokenDetails(token) {
|
||
try {
|
||
const response = await fetch(`/api/tokens/${token}`, {
|
||
headers: { 'X-Password': 'admin123123123' }
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error('Failed to load token details');
|
||
}
|
||
|
||
const tokenData = await response.json();
|
||
currentToken = tokenData;
|
||
tokenVisible = false;
|
||
|
||
document.getElementById('token-value-display').value = tokenData.token;
|
||
document.getElementById('token-value-display').type = 'password';
|
||
document.getElementById('toggle-token-btn').textContent = '👁 显示';
|
||
|
||
document.getElementById('token-detail-name').textContent = tokenData.name;
|
||
document.getElementById('token-detail-permission').textContent =
|
||
tokenData.permission === 'readonly' ? '只读' : '读写';
|
||
document.getElementById('token-detail-status').textContent =
|
||
tokenData.status === 'active' ? '🟢 启用中' :
|
||
tokenData.status === 'disabled' ? '🔴 已禁用' : '⏰ 已过期';
|
||
document.getElementById('token-detail-created').textContent = tokenData.created_at;
|
||
document.getElementById('token-detail-expires').textContent = tokenData.expires_at || '永久有效';
|
||
|
||
const stats = tokenData.stats || {};
|
||
document.getElementById('token-detail-requests').textContent = stats.total_requests || 0;
|
||
document.getElementById('token-detail-last-used').textContent = stats.last_used_at || '从未使用';
|
||
document.getElementById('token-detail-last-ip').textContent = stats.last_ip || '-';
|
||
document.getElementById('token-detail-failed').textContent = stats.failed_attempts || 0;
|
||
|
||
// Render access log
|
||
const logBody = document.getElementById('token-access-log');
|
||
if (!tokenData.access_log || tokenData.access_log.length === 0) {
|
||
logBody.innerHTML = '<tr><td colspan="5" style="padding: 20px; text-align: center; color: #999;">暂无访问记录</td></tr>';
|
||
} else {
|
||
logBody.innerHTML = tokenData.access_log.map(log => `
|
||
<tr style="border-bottom: 1px solid #e0e0e0;">
|
||
<td style="padding: 10px;">${log.timestamp}</td>
|
||
<td style="padding: 10px;">${log.ip}</td>
|
||
<td style="padding: 10px;">${log.method}</td>
|
||
<td style="padding: 10px; font-size: 12px;">${log.path}</td>
|
||
<td style="padding: 10px;">${log.status}</td>
|
||
</tr>
|
||
`).join('');
|
||
}
|
||
|
||
openModal('token-details-modal');
|
||
} catch (error) {
|
||
showToast('加载 Token 详情失败', 'error');
|
||
}
|
||
}
|
||
|
||
// Toggle token visibility
|
||
function toggleTokenVisibility() {
|
||
const input = document.getElementById('token-value-display');
|
||
const btn = document.getElementById('toggle-token-btn');
|
||
|
||
if (tokenVisible) {
|
||
input.type = 'password';
|
||
btn.textContent = '👁 显示';
|
||
tokenVisible = false;
|
||
} else {
|
||
input.type = 'text';
|
||
btn.textContent = '🙈 隐藏';
|
||
tokenVisible = true;
|
||
}
|
||
}
|
||
|
||
// Copy token value
|
||
function copyTokenValue() {
|
||
const tokenValue = document.getElementById('token-value-display').value;
|
||
copyToClipboard(tokenValue).then(success => {
|
||
if (success) {
|
||
showToast('Token 已复制到剪贴板');
|
||
} else {
|
||
showToast('复制失败,请手动选择复制', 'error');
|
||
}
|
||
});
|
||
}
|
||
|
||
// Open edit token modal
|
||
function openEditTokenModal() {
|
||
if (!currentToken) return;
|
||
|
||
document.getElementById('edit-token-name').value = currentToken.name;
|
||
document.querySelector(`input[name="edit-token-status"][value="${currentToken.status}"]`).checked = true;
|
||
|
||
closeModal('token-details-modal');
|
||
openModal('edit-token-modal');
|
||
}
|
||
|
||
// Submit edit token
|
||
async function submitEditToken() {
|
||
if (!currentToken) return;
|
||
|
||
const name = document.getElementById('edit-token-name').value.trim();
|
||
const status = document.querySelector('input[name="edit-token-status"]:checked').value;
|
||
|
||
if (!name) {
|
||
showToast('请输入 Token 名称', 'error');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`/api/tokens/${currentToken.token}`, {
|
||
method: 'PATCH',
|
||
headers: {
|
||
'X-Password': 'admin123123123',
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({ name, status })
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (response.ok) {
|
||
showToast('✅ Token 已更新');
|
||
closeModal('edit-token-modal');
|
||
await loadTokens();
|
||
viewTokenDetails(currentToken.token);
|
||
} else {
|
||
showToast('❌ ' + (result.error || '更新失败'), 'error');
|
||
}
|
||
} catch (error) {
|
||
showToast('❌ 请求失败: ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
// Toggle token status
|
||
async function toggleTokenStatus(token, newStatus) {
|
||
try {
|
||
const response = await fetch(`/api/tokens/${token}`, {
|
||
method: 'PATCH',
|
||
headers: {
|
||
'X-Password': 'admin123123123',
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({ status: newStatus })
|
||
});
|
||
|
||
if (response.ok) {
|
||
showToast(newStatus === 'active' ? '✅ Token 已启用' : '✅ Token 已禁用');
|
||
loadTokens();
|
||
} else {
|
||
showToast('❌ 操作失败', 'error');
|
||
}
|
||
} catch (error) {
|
||
showToast('❌ 请求失败: ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
// Confirm delete token
|
||
function confirmDeleteToken() {
|
||
if (!currentToken) return;
|
||
|
||
document.getElementById('delete-token-name').textContent = currentToken.name;
|
||
document.getElementById('delete-token-value').textContent = currentToken.token.substring(0, 12) + '...';
|
||
document.getElementById('delete-token-password').value = '';
|
||
|
||
closeModal('token-details-modal');
|
||
openModal('delete-token-modal');
|
||
}
|
||
|
||
// Submit delete token
|
||
async function submitDeleteToken() {
|
||
if (!currentToken) return;
|
||
|
||
const password = document.getElementById('delete-token-password').value;
|
||
|
||
if (!password) {
|
||
showToast('请输入主密码', 'error');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`/api/tokens/${currentToken.token}`, {
|
||
method: 'DELETE',
|
||
headers: {
|
||
'X-Password': 'admin123123123',
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({ confirm_password: password })
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (response.ok) {
|
||
showToast('✅ Token 已删除');
|
||
closeModal('delete-token-modal');
|
||
currentToken = null;
|
||
loadTokens();
|
||
} else {
|
||
showToast('❌ ' + (result.error || '删除失败'), 'error');
|
||
}
|
||
} catch (error) {
|
||
showToast('❌ 请求失败: ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
// ============= End Token Management Functions =============
|
||
|
||
// 页面加载时获取数据
|
||
loadData();
|
||
|
||
// 每30秒自动刷新
|
||
setInterval(loadData, 30000);
|
||
</script>
|
||
</body>
|
||
</html>
|