Files
K-hermes 8a57945cd2 refactor: 将复制配置改为复制Tag功能
- 按钮文字: 📋 复制配置 → 📋 复制 Tag
- 函数名称: copyConfig() → copyTag()
- 功能逻辑: 直接复制tag字符串,移除API调用
- 提示文案: 配置已复制到剪贴板 → Tag已复制到剪贴板
2026-08-15 23:50:01 +08:00

832 lines
26 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;
}
.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; }
</style>
</head>
<body>
<div class="container">
<!-- Header -->
<div class="header">
<h1>🔄 通用数据同步服务</h1>
<p class="subtitle">Cookie / Token / Credential 管理面板</p>
</div>
<!-- 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="loadDataList()">🔄 刷新</button>
</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>
<!-- 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>
<script>
let currentViewData = null;
let deleteCallback = null;
// 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;
// 渲染列表
const container = document.getElementById('data-container');
if (tagArray.length === 0) {
container.innerHTML = '<div class="empty">📭 暂无数据</div>';
return;
}
container.innerHTML = tagArray.map((item) => {
const statusClass = `status-${item.status}`;
const statusText = {
'valid': '正常',
'warning': '即将过期',
'expired': '已过期'
}[item.status];
const escapedTag = item.tag.replace(/'/g, "\\'").replace(/"/g, '\\"');
return `
<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>
`;
}).join('');
} catch (error) {
console.error('加载失败:', error);
document.getElementById('data-container').innerHTML =
'<div class="empty">❌ 加载失败,请刷新重试</div>';
}
}
// 查看数据
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);
}
});
});
// 页面加载时获取数据
loadData();
// 每30秒自动刷新
setInterval(loadData, 30000);
</script>
</body>
</html>