Files
K-hermes 2cf807b59c feat: 实现 Token 认证系统
- 后端:Lua 实现 Token 验证、权限控制、限流、审计日志
- API:创建/列出/查看/更新/删除 Token
- Web UI:Token 管理界面(列表、创建、详情、编辑、删除弹框)
- 向后兼容:保留 X-Password 超级管理员认证
- 安全增强:失败次数限制、过期检测、双因素验证(敏感操作)

需求文档:TOKEN_AUTH_REQUIREMENT.md
2026-09-13 23:40:42 +08:00

1636 lines
59 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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')">&times;</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')">&times;</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')">&times;</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')">&times;</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')">&times;</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')">&times;</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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.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>