Files
data-sync-service/web/index.html
T
K-hermes 6701ddad55 fix: 修复 HTTP 环境下复制功能失败的问题
问题:
- navigator.clipboard 只在 HTTPS 下可用
- HTTP 环境(IP 访问)会报错 Cannot read properties of undefined

解决方案:
- 创建兼容的 copyToClipboard 函数
- 优先尝试 navigator.clipboard (HTTPS)
- 降级到 document.execCommand (HTTP 也可用)
- 统一处理 copyJSON 和 copyConfig 两处复制功能
- 失败时提示用户手动复制
2026-08-15 13:17:26 +08:00

794 lines
24 KiB
HTML
Raw 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;
}
.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">
<h2 style="margin-bottom: 20px;">📋 数据列表</h2>
<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-primary" onclick="copyConfig('${escapedTag}')">复制配置</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');
}
});
}
}
// 复制配置
async function copyConfig(tag) {
try {
const response = await fetch(`/api/data/copy/${tag}`);
const data = await response.json();
const success = await copyToClipboard(data.copy_text);
if (success) {
showToast('配置已复制到剪贴板');
} else {
showToast('复制失败,请手动选择复制', '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>