Files
ToNav/templates/admin/dashboard.html
OpenClaw Agent c0cdd146b1 feat: upgrade to V1.2 - Tags, Click Stats, and Robust WebDAV
- add Tagging system (backend and frontend)
- add Click count statistics and redirection logic
- add config.example.py
- fix WebDAV MKCOL 405 error and response handling
- fix redirection loop during force password change
- audit SQL queries for security
2026-02-13 07:58:11 +08:00

342 lines
17 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.
{% extends "base.html" %}
{% block title %}ToNav 管理后台{% endblock %}
{% block content %}
<div class="container admin-layout">
<!-- 顶部导航 -->
<div class="admin-header">
<div class="header-left">
<h1>🧭 ToNav 管理后台</h1>
<span class="username" id="username">加载中...</span>
</div>
<div class="header-actions">
<button class="btn btn-outline" onclick="window.open('/', '_blank')">查看前台 ↗</button>
<button class="btn btn-primary" onclick="location.href='/admin/logout'">退出登录</button>
</div>
</div>
<!-- 主内容区 -->
<div class="main-content">
<!-- 强制改密提示 -->
{% if session.get('must_change') %}
<div class="alert alert-danger" style="margin-bottom: 20px; padding: 15px; background: #fff2f0; border: 1px solid #ffccc7; color: #ff4d4f; border-radius: 10px; font-weight: bold;">
⚠️ 为了账户安全,首次登录请先修改默认密码。在修改完成前,其他管理功能将被禁用。
</div>
{% endif %}
<!-- 统计卡片 -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon">📡</div>
<div class="stat-info">
<div class="stat-value" id="totalServices">0</div>
<div class="stat-label">总服务数</div>
</div>
</div>
{% if not session.get('must_change') %}
<div class="stat-card">
<div class="stat-icon"></div>
<div class="stat-info">
<div class="stat-value" id="enabledServices">0</div>
<div class="stat-label">已启用</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">📂</div>
<div class="stat-info">
<div class="stat-value" id="totalCategories">0</div>
<div class="stat-label">分类数</div>
</div>
</div>
{% endif %}
</div>
<!-- 快捷操作 -->
{% if not session.get('must_change') %}
<div class="quick-actions">
<button class="action-btn" onclick="location.href='/admin/services'">
<span class="action-icon">📡</span>
<span class="action-label">服务管理</span>
</button>
<button class="action-btn" onclick="location.href='/admin/categories'">
<span class="action-icon">📂</span>
<span class="action-label">分类管理</span>
</button>
<button class="action-btn" onclick="runHealthCheck()">
<span class="action-icon">🔍</span>
<span class="action-label">健康检测</span>
</button>
<!-- 备份操作 -->
<button class="action-btn" onclick="showBackupModal()">
<span class="action-icon">☁️</span>
<span class="action-label">备份设置</span>
</button>
<button class="action-btn" onclick="backupLocal()">
<span class="action-icon">💾</span>
<span class="action-label">本地备份</span>
</button>
</div>
{% endif %}
</div>
<!-- 修改密码 -->
<div class="settings-card">
<h2>修改密码</h2>
<form id="changePasswordForm">
<div class="form-row">
<input type="password" id="oldPassword" class="input" placeholder="旧密码" required>
<input type="password" id="newPassword" class="input" placeholder="新密码至少6位" required minlength="6">
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">修改密码</button>
</div>
</form>
</div>
<!-- 备份设置弹窗 -->
<div class="modal" id="backupModal">
<div class="modal-content">
<div class="modal-header" style="background: #1a1a1a; color: #fff; padding: 20px; border-radius: 20px 20px 0 0; display: flex; justify-content: space-between; align-items: center;">
<h2>☁️ 云端备份设置 (WebDAV)</h2>
<button class="close-btn" onclick="closeBackupModal()" style="background:none; border:none; color:#fff; font-size:24px; cursor:pointer;">×</button>
</div>
<div class="modal-body" style="padding: 25px;">
<div class="form-group" style="margin-bottom: 15px;">
<label style="display:block; margin-bottom:5px; font-size:14px;">WebDAV URL *</label>
<input type="url" id="webdavUrl" class="input" placeholder="https://example.com/webdav/tonav/" required>
</div>
<div class="form-group" style="margin-bottom: 15px;">
<label style="display:block; margin-bottom:5px; font-size:14px;">用户名</label>
<input type="text" id="webdavUser" class="input" placeholder="username">
</div>
<div class="form-group" style="margin-bottom: 20px;">
<label style="display:block; margin-bottom:5px; font-size:14px;">密码 / Token</label>
<input type="password" id="webdavPass" class="input" placeholder="password">
</div>
<div class="form-actions" style="display: flex; gap: 10px; border-top: 1px solid #eee; pt: 15px; margin-bottom: 20px;">
<button class="btn" onclick="testWebDAV()" id="btnTest">连通性测试</button>
<button class="btn btn-primary" onclick="saveBackupSettings()">保存配置</button>
<button class="btn" style="background: #52c41a; color: #fff;" onclick="runCloudBackup()">立即备份</button>
</div>
<div class="cloud-backups">
<h3 style="font-size: 14px; margin-bottom: 10px; color: #666;">📜 云端历史备份</h3>
<div id="backupFileList" style="max-height: 200px; overflow-y: auto; border: 1px solid #f0f0f0; border-radius: 8px;">
<div style="padding: 15px; text-align: center; color: #999; font-size: 13px;">正在获取列表...</div>
</div>
</div>
</div>
</div>
</div>
</div>
<style>
.admin-layout { max-width: 900px; }
.admin-header { background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%); color: #fff; padding: 25px 30px; border-radius: 20px 20px 0 0; display: flex; justify-content: space-between; align-items: center; }
.header-left h1 { font-size: 22px; font-weight: 700; margin-bottom: 5px; }
.username { font-size: 13px; color: #8c8c8c; }
.main-content { padding: 30px; background: #fff; }
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; margin-bottom: 20px; }
.stat-card { display: flex; align-items: center; gap: 15px; padding: 20px; background: #fafafa; border-radius: 15px; transition: all 0.3s; height: 100px; }
.stat-card:hover { background: #f0f0f0; transform: translateY(-2px); }
.stat-icon { font-size: 36px; }
.stat-value { font-size: 32px; font-weight: 800; color: var(--main-red); line-height: 1; }
.stat-label { font-size: 13px; color: #8c8c8c; margin-top: 5px; }
.quick-actions { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 15px; }
.action-btn { background: #fff; border: 2px solid #f0f0f0; border-radius: 15px; padding: 20px; cursor: pointer; transition: all 0.3s; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; height: 120px; }
.action-btn:hover { border-color: var(--main-red); background: #fff2f0; transform: translateY(-3px); box-shadow: 0 4px 15px rgba(255, 77, 79, 0.2); }
.action-icon { font-size: 32px; }
.action-label { font-size: 14px; font-weight: 600; color: #262626; }
.settings-card { background: #fff; padding: 30px; border-radius: 0 0 20px 20px; }
.settings-card h2 { font-size: 18px; font-weight: 600; margin-bottom: 20px; color: #262626; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-bottom: 15px; }
.input { width: 100%; padding: 12px 15px; border: 1px solid #d9d9d9; border-radius: 10px; font-size: 14px; transition: all 0.3s; }
.input:focus { outline: none; border-color: var(--main-red); box-shadow: 0 0 0 3px rgba(255, 77, 79, 0.1); }
.header-actions { display: flex; gap: 10px; align-items: center; }
.btn-outline { background: transparent; border: 1px solid rgba(255,255,255,0.3); color: #fff; }
.btn-outline:hover { background: rgba(255,255,255,0.1); border-color: #fff; }
.btn { padding: 12px 24px; border: none; border-radius: 10px; font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.3s; }
.btn-primary { background: var(--main-red); color: #fff; box-shadow: 0 4px 15px rgba(255, 77, 79, 0.4); }
/* Modal */
.modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.6); z-index: 1000; align-items: center; justify-content: center; }
.modal.active { display: flex; }
.modal-content { background: #fff; border-radius: 20px; width: 90%; max-width: 500px; box-shadow: 0 10px 40px rgba(0,0,0,0.2); }
@media (max-width: 768px) { .stats-grid, .quick-actions, .form-row { grid-template-columns: 1fr; } .admin-header { flex-direction: column; align-items: flex-start; gap: 15px; } .stat-card, .action-btn { height: auto; } }
</style>
{% endblock %}
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
loadUsername();
});
async function loadUsername() {
try {
const response = await fetch('/api/admin/login/status?t=' + new Date().getTime());
const data = await response.json();
if (data.logged_in) {
document.getElementById('username').textContent = data.username;
if (!data.must_change) loadStats();
} else { window.location.href = '/admin/login'; }
} catch (err) { window.location.href = '/admin/login'; }
}
async function loadStats() {
try {
const [servicesResp, categoriesResp] = await Promise.all([
fetch('/api/admin/services?t=' + new Date().getTime()),
fetch('/api/admin/categories?t=' + new Date().getTime())
]);
const services = await servicesResp.json();
const categories = await categoriesResp.json();
document.getElementById('totalServices').textContent = services.length;
if (document.getElementById('enabledServices')) {
document.getElementById('enabledServices').textContent = services.filter(s => s.is_enabled).length;
document.getElementById('totalCategories').textContent = categories.length;
}
} catch (err) {}
}
async function runHealthCheck() {
try {
const response = await fetch('/api/admin/health-check', { method: 'POST' });
const data = await response.json();
if (data.results) {
const online = data.results.filter(r => r.status === 'online').length;
const offline = data.results.filter(r => r.status === 'offline').length;
alert(`检测完成:\n在线: ${online}\n离线: ${offline}`);
}
} catch (err) { alert('检测失败: ' + err.message); }
}
// 备份相关逻辑
function backupLocal() { window.location.href = '/api/admin/backup/local'; }
async function showBackupModal() {
try {
const response = await fetch('/api/admin/settings');
const data = await response.json();
document.getElementById('webdavUrl').value = data.webdav_url || '';
document.getElementById('webdavUser').value = data.webdav_user || '';
document.getElementById('webdavPass').value = data.webdav_password || '';
document.getElementById('backupModal').classList.add('active');
loadBackupList();
} catch (err) { alert('加载配置失败'); }
}
async function loadBackupList() {
const listDiv = document.getElementById('backupFileList');
try {
const response = await fetch('/api/admin/backup/list');
const data = await response.json();
if (data.files && data.files.length > 0) {
let html = '';
data.files.forEach(file => {
html += `
<div style="padding: 10px 15px; border-bottom: 1px solid #f9f9f9; display: flex; justify-content: space-between; align-items: center;">
<span style="font-size: 13px; font-family: monospace;">${file}</span>
<button onclick="restoreFromCloud('${file}')" style="background: #faad14; color: #fff; border: none; border-radius: 4px; padding: 4px 10px; font-size: 12px; cursor: pointer;">恢复</button>
</div>
`;
});
listDiv.innerHTML = html;
} else {
listDiv.innerHTML = '<div style="padding: 15px; text-align: center; color: #999; font-size: 13px;">云端暂无备份包</div>';
}
} catch (err) {
listDiv.innerHTML = '<div style="padding: 15px; text-align: center; color: #ff4d4f; font-size: 13px;">获取列表失败</div>';
}
}
async function restoreFromCloud(filename) {
if (!confirm(`⚠️ 警告:确定要从备份 ${filename} 恢复吗?当前所有数据将被覆盖!系统将自动备份当前版本为 tonav.db.bak`)) return;
try {
const response = await fetch('/api/admin/backup/restore', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({filename: filename})
});
const data = await response.json();
if (response.ok) { alert(data.message); location.reload(); }
else { alert('恢复失败: ' + data.error); }
} catch (err) { alert('请求异常'); }
}
function closeBackupModal() { document.getElementById('backupModal').classList.remove('active'); }
async function saveBackupSettings() {
const data = {
webdav_url: document.getElementById('webdavUrl').value,
webdav_user: document.getElementById('webdavUser').value,
webdav_password: document.getElementById('webdavPass').value
};
try {
const response = await fetch('/api/admin/settings', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
});
if (response.ok) alert('配置保存成功');
else alert('保存失败');
} catch (err) { alert('请求错误'); }
}
async function testWebDAV() {
const data = {
webdav_url: document.getElementById('webdavUrl').value,
webdav_user: document.getElementById('webdavUser').value,
webdav_password: document.getElementById('webdavPass').value
};
const btn = document.getElementById('btnTest');
btn.disabled = true; btn.textContent = '测试中...';
try {
const response = await fetch('/api/admin/backup/test', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
});
const res = await response.json();
if (response.ok) alert(res.message);
else alert(res.error);
} catch (err) { alert('测试异常'); }
finally { btn.disabled = false; btn.textContent = '连通性测试'; }
}
async function runCloudBackup() {
try {
const response = await fetch('/api/admin/backup/webdav', { method: 'POST' });
const data = await response.json();
if (response.ok) {
alert(data.message || '云端备份成功!');
loadBackupList();
} else {
alert('备份失败: ' + (data.error || '未知错误'));
}
} catch (err) {
alert('请求失败: ' + err.message);
}
}
document.getElementById('changePasswordForm').addEventListener('submit', async function(e) {
e.preventDefault();
const oldPassword = document.getElementById('oldPassword').value;
const newPassword = document.getElementById('newPassword').value;
try {
const response = await fetch('/api/admin/change-password', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({old_password: oldPassword, new_password: newPassword})
});
const data = await response.json();
if (response.ok) { alert('密码修改成功'); location.reload(); }
else { alert(data.error || '修改失败'); }
} catch (err) { alert('请求失败'); }
});
</script>
{% endblock %}