Files
web_template/web/dist/admin/index.html
2026-09-09 02:39:57 +08:00

1396 lines
64 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>管理后台 - Gotest Admin</title>
<style>
:root{--primary:#4f46e5;--primary-dark:#4338ca;--primary-light:#e0e7ff;--success:#10b981;--danger:#ef4444;--warning:#f59e0b;--info:#3b82f6;--bg:#f1f5f9;--card-bg:#fff;--text:#1e293b;--text-muted:#64748b;--border:#e2e8f0;--sidebar-bg:#1e293b;--sidebar-active:#4f46e5;--sidebar-text:#94a3b8;--sidebar-text-active:#fff;--radius:8px;--shadow:0 1px 3px rgba(0,0,0,.1);--shadow-lg:0 10px 30px rgba(0,0,0,.15)}
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;background:var(--bg);color:var(--text);font-size:14px}
/* Login */
.login-overlay{position:fixed;inset:0;background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);display:flex;align-items:center;justify-content:center;z-index:9999}
.login-card{background:#fff;border-radius:16px;padding:40px;width:380px;box-shadow:var(--shadow-lg)}
.login-card h1{font-size:24px;text-align:center;margin-bottom:8px;color:var(--text)}
.login-card .subtitle{text-align:center;color:var(--text-muted);margin-bottom:28px;font-size:13px}
.login-card .form-group{margin-bottom:16px}
.login-card label{display:block;margin-bottom:6px;font-weight:600;font-size:13px}
.login-card input{width:100%;padding:10px 14px;border:1px solid var(--border);border-radius:var(--radius);font-size:14px;transition:border .2s}
.login-card input:focus{outline:none;border-color:var(--primary);box-shadow:0 0 0 3px var(--primary-light)}
.login-btn{width:100%;padding:12px;background:var(--primary);color:#fff;border:none;border-radius:var(--radius);font-size:15px;font-weight:600;cursor:pointer;transition:background .2s}
.login-btn:hover{background:var(--primary-dark)}
.login-btn:disabled{opacity:.6;cursor:not-allowed}
.login-error{color:var(--danger);text-align:center;margin-top:12px;font-size:13px;min-height:18px}
.login-hint{text-align:center;margin-top:16px;padding:10px;background:#f0fdf4;border-radius:var(--radius);font-size:12px;color:#15803d}
/* Layout */
.app{display:flex;min-height:100vh}
.sidebar{width:240px;background:var(--sidebar-bg);color:var(--sidebar-text);position:fixed;top:0;bottom:0;left:0;z-index:100;overflow-y:auto;transition:transform .3s}
.sidebar-header{padding:20px 24px;border-bottom:1px solid #334155}
.sidebar-header h2{color:#fff;font-size:18px;display:flex;align-items:center;gap:8px}
.sidebar-header .logo{width:32px;height:32px;background:var(--primary);border-radius:8px;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:bold;font-size:16px}
.sidebar-nav{padding:12px 0}
.sidebar-section{padding:8px 24px;font-size:11px;text-transform:uppercase;letter-spacing:1px;color:#475569;margin-top:8px}
.sidebar-link{display:flex;align-items:center;gap:10px;padding:10px 24px;color:var(--sidebar-text);text-decoration:none;cursor:pointer;transition:all .2s;border-left:3px solid transparent}
.sidebar-link:hover{background:#334155;color:#cbd5e1}
.sidebar-link.active{background:var(--sidebar-active);color:var(--sidebar-text-active);border-left-color:#818cf8}
.sidebar-link .icon{width:20px;text-align:center;font-size:16px}
.sidebar-link .badge{margin-left:auto;background:var(--danger);color:#fff;font-size:11px;padding:1px 6px;border-radius:10px}
.main{flex:1;margin-left:240px;min-height:100vh}
.topbar{background:var(--card-bg);padding:12px 24px;display:flex;align-items:center;justify-content:space-between;box-shadow:var(--shadow);position:sticky;top:0;z-index:50}
.topbar-left{display:flex;align-items:center;gap:12px}
.topbar-left h3{font-size:18px;font-weight:600}
.topbar-right{display:flex;align-items:center;gap:16px}
.topbar-user{display:flex;align-items:center;gap:8px;cursor:pointer}
.topbar-avatar{width:36px;height:36px;border-radius:50%;background:var(--primary);color:#fff;display:flex;align-items:center;justify-content:center;font-weight:600}
.topbar-username{font-weight:600;font-size:13px}
.topbar-role{font-size:11px;color:var(--text-muted);background:var(--primary-light);padding:2px 8px;border-radius:10px;color:var(--primary)}
.content{padding:24px}
/* Cards & Stats */
.stats-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:16px;margin-bottom:24px}
.stat-card{background:var(--card-bg);border-radius:var(--radius);padding:20px;box-shadow:var(--shadow);transition:transform .2s,box-shadow .2s}
.stat-card:hover{transform:translateY(-2px);box-shadow:var(--shadow-lg)}
.stat-card .stat-icon{width:48px;height:48px;border-radius:12px;display:flex;align-items:center;justify-content:center;font-size:24px;margin-bottom:12px}
.stat-card .stat-value{font-size:28px;font-weight:700}
.stat-card .stat-label{color:var(--text-muted);font-size:13px;margin-top:4px}
.bg-indigo{background:#e0e7ff;color:#4f46e5}
.bg-green{background:#d1fae5;color:#10b981}
.bg-blue{background:#dbeafe;color:#3b82f6}
.bg-purple{background:#ede9fe;color:#8b5cf6}
.bg-orange{background:#fed7aa;color:#f97316}
.bg-pink{background:#fce7f3;color:#ec4899}
.bg-cyan{background:#cffafe;color:#06b6d4}
/* Section headers */
.section-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px}
.section-header h4{font-size:16px;font-weight:600}
/* Tables */
.table-card{background:var(--card-bg);border-radius:var(--radius);box-shadow:var(--shadow);overflow:hidden}
.table-toolbar{padding:16px;display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;border-bottom:1px solid var(--border)}
.search-box{display:flex;align-items:center;gap:8px}
.search-box input{padding:8px 12px;border:1px solid var(--border);border-radius:var(--radius);font-size:13px;width:240px}
.search-box input:focus{outline:none;border-color:var(--primary)}
.btn{padding:8px 16px;border:none;border-radius:var(--radius);font-size:13px;font-weight:600;cursor:pointer;transition:all .2s;display:inline-flex;align-items:center;gap:6px}
.btn-primary{background:var(--primary);color:#fff}
.btn-primary:hover{background:var(--primary-dark)}
.btn-success{background:var(--success);color:#fff}
.btn-danger{background:var(--danger);color:#fff}
.btn-danger:hover{background:#dc2626}
.btn-sm{padding:5px 10px;font-size:12px}
.btn-outline{background:transparent;border:1px solid var(--border);color:var(--text)}
.btn-outline:hover{background:#f8fafc}
table{width:100%;border-collapse:collapse}
thead{background:#f8fafc}
th{padding:12px 16px;text-align:left;font-size:12px;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:.5px;border-bottom:1px solid var(--border);white-space:nowrap}
td{padding:12px 16px;border-bottom:1px solid var(--border);font-size:13px}
tbody tr:hover{background:#f8fafc}
tbody tr:last-child td{border-bottom:none}
.table-wrap{overflow-x:auto}
.empty-row{text-align:center;padding:40px;color:var(--text-muted)}
/* Pagination */
.pagination{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-top:1px solid var(--border)}
.pagination-info{font-size:13px;color:var(--text-muted)}
.pagination-btns{display:flex;gap:4px}
.page-btn{padding:6px 12px;border:1px solid var(--border);border-radius:var(--radius);background:#fff;cursor:pointer;font-size:13px;transition:all .2s}
.page-btn:hover:not(:disabled){background:var(--primary-light);border-color:var(--primary)}
.page-btn.active{background:var(--primary);color:#fff;border-color:var(--primary)}
.page-btn:disabled{opacity:.5;cursor:not-allowed}
/* Status badges */
.badge-status{display:inline-block;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600}
.badge-active{background:#d1fae5;color:#065f46}
.badge-inactive{background:#fee2e2;color:#991b1b}
/* Modal */
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:1000;opacity:0;pointer-events:none;transition:opacity .2s}
.modal-overlay.show{opacity:1;pointer-events:auto}
.modal{background:#fff;border-radius:12px;width:600px;max-width:90vw;max-height:85vh;overflow-y:auto;box-shadow:var(--shadow-lg);transform:scale(.95);transition:transform .2s}
.modal-overlay.show .modal{transform:scale(1)}
.modal-header{padding:16px 24px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between}
.modal-header h3{font-size:18px;font-weight:600}
.modal-close{background:none;border:none;font-size:24px;cursor:pointer;color:var(--text-muted);line-height:1}
.modal-close:hover{color:var(--text)}
.modal-body{padding:24px}
.modal-footer{padding:16px 24px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;gap:8px}
/* Forms */
.form-row{display:grid;grid-template-columns:1fr 1fr;gap:16px}
.form-group{margin-bottom:16px}
.form-group label{display:block;margin-bottom:6px;font-weight:600;font-size:13px}
.form-group input,.form-group select,.form-group textarea{width:100%;padding:8px 12px;border:1px solid var(--border);border-radius:var(--radius);font-size:13px;font-family:inherit}
.form-group input:focus,.form-group select:focus,.form-group textarea:focus{outline:none;border-color:var(--primary);box-shadow:0 0 0 3px var(--primary-light)}
.form-group textarea{resize:vertical;min-height:80px}
/* Toast */
.toast-container{position:fixed;top:20px;right:20px;z-index:2000;display:flex;flex-direction:column;gap:8px}
.toast{padding:12px 20px;border-radius:var(--radius);color:#fff;font-size:14px;box-shadow:var(--shadow-lg);animation:slideIn .3s ease;display:flex;align-items:center;gap:8px;min-width:250px}
.toast-success{background:var(--success)}
.toast-error{background:var(--danger)}
.toast-info{background:var(--info)}
@keyframes slideIn{from{transform:translateX(100%);opacity:0}to{transform:translateX(0);opacity:1}}
/* Loading */
.loading{text-align:center;padding:40px;color:var(--text-muted)}
.spinner{display:inline-block;width:32px;height:32px;border:3px solid var(--border);border-top-color:var(--primary);border-radius:50%;animation:spin .8s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
/* Content layout: sidebar + main */
.content-layout{display:flex;gap:16px;align-items:flex-start}
.content-sidebar{width:220px;flex-shrink:0;background:var(--card-bg);border-radius:var(--radius);box-shadow:var(--shadow);overflow:hidden;position:sticky;top:80px}
.content-group{border-bottom:1px solid var(--border)}
.content-group:last-child{border-bottom:none}
.content-group-header{display:flex;align-items:center;gap:8px;padding:12px 16px;cursor:pointer;font-size:13px;font-weight:700;color:var(--text);transition:background .2s;user-select:none}
.content-group-header:hover{background:#f8fafc}
.content-group-header .arrow{margin-left:auto;transition:transform .25s;font-size:12px;color:var(--text-muted)}
.content-group.expanded .content-group-header .arrow{transform:rotate(90deg)}
.content-group-items{max-height:0;overflow:hidden;transition:max-height .3s ease}
.content-group.expanded .content-group-items{max-height:400px}
.content-item{padding:8px 16px 8px 40px;cursor:pointer;font-size:13px;color:var(--text-muted);transition:all .2s;border-left:3px solid transparent}
.content-item:hover{background:var(--primary-light);color:var(--primary)}
.content-item.active{background:var(--primary);color:#fff;border-left-color:#818cf8;font-weight:600}
.content-item .count{float:right;font-size:11px;opacity:.7}
.content-main{flex:1;min-width:0}
/* Upload page */
.upload-area{border:2px dashed var(--border);border-radius:12px;padding:40px;text-align:center;cursor:pointer;transition:all .2s;background:#fafbfc}
.upload-area:hover{border-color:var(--primary);background:var(--primary-light)}
.upload-area.dragover{border-color:var(--primary);background:var(--primary-light);transform:scale(1.01)}
.upload-area .upload-icon{font-size:48px;margin-bottom:12px}
.upload-area .upload-text{font-size:15px;color:var(--text-muted)}
.upload-area .upload-hint{font-size:12px;color:var(--text-muted);margin-top:8px}
.upload-formats{display:flex;gap:8px;justify-content:center;margin-top:12px}
.fmt-tag{display:inline-block;padding:3px 12px;border-radius:6px;font-size:12px;font-weight:700;letter-spacing:.5px}
.fmt-tag:nth-child(1){background:#fef3c7;color:#92400e}
.fmt-tag:nth-child(2){background:#dbeafe;color:#1e40af}
.fmt-tag:nth-child(3){background:#fce7f3;color:#9d174d}
.fmt-tag:nth-child(4){background:#d1fae5;color:#065f46}
.fmt-tag:nth-child(5){background:#ede9fe;color:#5b21b6}
.fmt-tag:nth-child(6){background:#ffedd5;color:#9a3412}
.fmt-tag:nth-child(7){background:#fee2e2;color:#991b1b}
.module-tabs{display:flex;gap:4px;margin-bottom:16px;flex-wrap:wrap}
.module-tab{padding:8px 16px;border-radius:var(--radius);background:#fff;border:1px solid var(--border);cursor:pointer;font-size:13px;font-weight:600;transition:all .2s;color:var(--text-muted)}
.module-tab:hover{background:#f8fafc}
.module-tab.active{background:var(--primary);color:#fff;border-color:var(--primary)}
.file-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:12px;margin-top:16px}
.file-card{background:var(--card-bg);border-radius:var(--radius);box-shadow:var(--shadow);overflow:hidden;transition:transform .2s,box-shadow .2s;position:relative}
.file-card:hover{transform:translateY(-2px);box-shadow:var(--shadow-lg)}
.file-card .file-preview{width:100%;height:120px;background:#f8fafc;display:flex;align-items:center;justify-content:center;overflow:hidden}
.file-card .file-preview img{width:100%;height:100%;object-fit:cover}
.file-card .file-preview .file-icon{font-size:36px}
.file-card .file-info{padding:8px 12px}
.file-card .file-name{font-size:12px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.file-card .file-meta{font-size:11px;color:var(--text-muted);margin-top:2px}
.file-card .file-actions{position:absolute;top:4px;right:4px;display:flex;gap:4px;opacity:0;transition:opacity .2s}
.file-card:hover .file-actions{opacity:1}
.file-card .file-actions button{background:rgba(255,255,255,.9);border:none;border-radius:4px;width:28px;height:28px;cursor:pointer;font-size:14px;display:flex;align-items:center;justify-content:center}
.file-card .file-actions button:hover{background:#fff}
.file-card .copy-url{position:absolute;bottom:4px;right:4px;opacity:0;transition:opacity .2s}
.file-card:hover .copy-url{opacity:1}
.upload-progress{margin-top:12px}
.upload-progress-bar{height:6px;background:var(--border);border-radius:3px;overflow:hidden}
.upload-progress-fill{height:100%;background:var(--primary);transition:width .3s}
/* Image cell */
.cell-img{width:40px;height:40px;border-radius:6px;object-fit:cover}
.cell-text-truncate{max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* Mobile */
.sidebar-toggle{display:none}
@media(max-width:768px){
.sidebar{transform:translateX(-100%)}
.sidebar.open{transform:translateX(0)}
.main{margin-left:0}
.sidebar-toggle{display:block;background:none;border:none;font-size:24px;cursor:pointer}
.form-row{grid-template-columns:1fr}
.search-box input{width:160px}
}
</style>
</head>
<body>
<!-- Login Overlay -->
<div class="login-overlay" id="loginOverlay">
<div class="login-card">
<h1 id="loginTitle">🔐 管理后台</h1>
<p class="subtitle" id="loginSubtitle">Gotest Admin Dashboard</p>
<!-- 登录表单 -->
<div id="loginForm">
<div class="form-group">
<label>用户名</label>
<input type="text" id="loginUsername" placeholder="请输入用户名">
</div>
<div class="form-group">
<label>密码</label>
<input type="password" id="loginPassword" placeholder="请输入密码">
</div>
<button class="login-btn" id="loginBtn" onclick="doLogin()">登 录</button>
<div class="login-error" id="loginError"></div>
</div>
<!-- 初始化表单(无管理员时显示) -->
<div id="initForm" style="display:none">
<div class="form-group">
<label>管理员用户名</label>
<input type="text" id="initUsername" placeholder="请设置管理员用户名">
</div>
<div class="form-group">
<label>管理员密码</label>
<input type="password" id="initPassword" placeholder="请设置密码(至少6位)">
</div>
<div class="form-group">
<label>确认密码</label>
<input type="password" id="initPasswordConfirm" placeholder="请再次输入密码">
</div>
<button class="login-btn" id="initBtn" onclick="doInitAdmin()">初始化管理员</button>
<div class="login-error" id="initError"></div>
</div>
</div>
</div>
<!-- Main App -->
<div class="app" id="app" style="display:none">
<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-header">
<h2><span class="logo">G</span> Gotest</h2>
</div>
<nav class="sidebar-nav">
<div class="sidebar-section">管理</div>
<a class="sidebar-link" onclick="navigate('users')" data-page="users">
<span class="icon">👥</span> 用户管理
</a>
<a class="sidebar-link" onclick="navigate('content')" data-page="content">
<span class="icon">📦</span> 内容管理
</a>
<div class="sidebar-section">系统</div>
<a class="sidebar-link" onclick="navigate('accesslog')" data-page="accesslog">
<span class="icon">📊</span> 访问日志
</a>
<a class="sidebar-link" onclick="navigate('webhook')" data-page="webhook">
<span class="icon">🔔</span> Webhook配置
</a>
<a class="sidebar-link" onclick="navigate('settings')" data-page="settings">
<span class="icon">⚙️</span> 系统设置
</a>
<a class="sidebar-link" onclick="navigate('sites')" data-page="sites">
<span class="icon">🌐</span> 站点导航
</a>
<a class="sidebar-link" onclick="doLogout()">
<span class="icon">🚪</span> 退出登录
</a>
</nav>
</aside>
<!-- Main Content -->
<div class="main">
<header class="topbar">
<div class="topbar-left">
<button class="sidebar-toggle" onclick="toggleSidebar()">☰</button>
<h3 id="pageTitle">内容管理</h3>
</div>
<div class="topbar-right">
<div class="topbar-user">
<div class="topbar-avatar" id="topbarAvatar">A</div>
<div>
<div class="topbar-username" id="topbarUsername">admin</div>
<div class="topbar-role">管理员</div>
</div>
</div>
</div>
</header>
<div class="content" id="content">
<div class="loading"><div class="spinner"></div></div>
</div>
</div>
</div>
<!-- Modal -->
<div class="modal-overlay" id="modalOverlay">
<div class="modal">
<div class="modal-header">
<h3 id="modalTitle">编辑</h3>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body" id="modalBody"></div>
<div class="modal-footer">
<button class="btn btn-outline" onclick="closeModal()">取消</button>
<button class="btn btn-primary" id="modalSaveBtn" onclick="saveModal()">保存</button>
</div>
</div>
</div>
<!-- Toast Container -->
<div class="toast-container" id="toastContainer"></div>
<script>
// ===== 全局状态 =====
const API_BASE = '/api/v1';
let token = localStorage.getItem('admin_token');
let currentUser = null;
let currentPage = 'dashboard';
let currentTable = null;
let currentListData = { list: [], total: 0, page: 1, page_size: 20 };
let editingId = null;
let editingTable = null;
// ===== API 工具 =====
async function api(path, options = {}) {
const url = API_BASE + path;
const headers = { 'Content-Type': 'application/json', ...options.headers };
if (token) headers['Authorization'] = 'Bearer ' + token;
try {
const resp = await fetch(url, { ...options, headers });
if (resp.status === 401) { doLogout(); return null; }
const data = await resp.json();
if (data.code !== 0) {
throw new Error(data.message || '请求失败');
}
return data;
} catch (e) {
if (e.message !== 'Failed to fetch') toast(e.message, 'error');
throw e;
}
}
// ===== Toast =====
function toast(msg, type = 'info') {
const container = document.getElementById('toastContainer');
const el = document.createElement('div');
el.className = 'toast toast-' + type;
const icon = type === 'success' ? '✅' : type === 'error' ? '❌' : 'ℹ️';
el.innerHTML = icon + ' ' + msg;
container.appendChild(el);
setTimeout(() => { el.style.opacity = '0'; el.style.transform = 'translateX(100%)'; setTimeout(() => el.remove(), 300); }, 3000);
}
// ===== 登录/登出 =====
async function checkAdminExists() {
try {
const resp = await fetch(API_BASE + '/admin/check');
const data = await resp.json();
return data.data && data.data.initialized;
} catch (e) {
return false;
}
}
async function doInitAdmin() {
const btn = document.getElementById('initBtn');
const username = document.getElementById('initUsername').value.trim();
const password = document.getElementById('initPassword').value;
const confirm = document.getElementById('initPasswordConfirm').value;
const errEl = document.getElementById('initError');
if (!username || !password) { errEl.textContent = '请输入用户名和密码'; return; }
if (password.length < 6) { errEl.textContent = '密码至少6位'; return; }
if (password !== confirm) { errEl.textContent = '两次密码不一致'; return; }
btn.disabled = true; btn.textContent = '初始化中...'; errEl.textContent = '';
try {
const resp = await fetch(API_BASE + '/admin/init', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const data = await resp.json();
if (data.code !== 0) { errEl.textContent = data.message || '初始化失败'; return; }
toast('初始化成功,请登录', 'success');
document.getElementById('initForm').style.display = 'none';
document.getElementById('loginForm').style.display = 'block';
document.getElementById('loginSubtitle').textContent = '请输入刚创建的管理员账号';
} catch (e) {
errEl.textContent = '网络错误,请重试';
} finally {
btn.disabled = false; btn.textContent = '初始化管理员';
}
}
async function doLogin() {
const btn = document.getElementById('loginBtn');
const username = document.getElementById('loginUsername').value.trim();
const password = document.getElementById('loginPassword').value;
const errEl = document.getElementById('loginError');
if (!username || !password) { errEl.textContent = '请输入用户名和密码'; return; }
btn.disabled = true; btn.textContent = '登录中...'; errEl.textContent = '';
try {
const resp = await fetch(API_BASE + '/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const data = await resp.json();
if (data.code !== 0) { errEl.textContent = data.message || '登录失败'; return; }
if (data.data.user.role !== 'admin') { errEl.textContent = '该账号无管理员权限'; return; }
token = data.data.token;
currentUser = { id: data.data.user.id, username: data.data.user.username, role: data.data.user.role };
localStorage.setItem('admin_token', token);
showApp();
} catch (e) {
errEl.textContent = '网络错误,请重试';
} finally {
btn.disabled = false; btn.textContent = '登 录';
}
}
function doLogout() {
token = null; currentUser = null;
localStorage.removeItem('admin_token');
document.getElementById('app').style.display = 'none';
document.getElementById('loginOverlay').style.display = 'flex';
}
function showApp() {
document.getElementById('loginOverlay').style.display = 'none';
document.getElementById('app').style.display = 'flex';
document.getElementById('topbarUsername').textContent = currentUser.username;
document.getElementById('topbarAvatar').textContent = currentUser.username[0].toUpperCase();
navigate('content');
}
// ===== 导航 =====
function navigate(page, params = {}) {
currentPage = page;
document.querySelectorAll('.sidebar-link').forEach(el => el.classList.remove('active'));
const link = document.querySelector(`[data-page="${page}"]`);
if (link) link.classList.add('active');
const titles = { users: '用户管理', content: '内容管理', settings: '系统设置', sites: '站点导航', accesslog: '访问日志', webhook: 'Webhook配置' };
document.getElementById('pageTitle').textContent = titles[page] || page;
if (page === 'users') renderUsers();
else if (page === 'content') renderContent(params.table);
else if (page === 'settings') renderSettings();
else if (page === 'sites') renderSites();
else if (page === 'accesslog') renderAccessLogs();
else if (page === 'webhook') renderWebhook();
if (window.innerWidth <= 768) document.getElementById('sidebar').classList.remove('open');
}
function toggleSidebar() { document.getElementById('sidebar').classList.toggle('open'); }
// ===== 用户管理 =====
let userPage = 1, userPageSize = 20, userKeyword = '';
async function renderUsers(page) {
if (page) userPage = page;
const el = document.getElementById('content');
el.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
try {
const params = new URLSearchParams({ page: userPage, page_size: userPageSize });
if (userKeyword) params.set('keyword', userKeyword);
const data = await api('/admin/users?' + params);
const d = data.data;
currentListData = d;
el.innerHTML = `
<div class="table-card">
<div class="table-toolbar">
<div class="search-box">
<input type="text" placeholder="搜索用户名..." value="${userKeyword}" onkeydown="if(event.key==='Enter'){userKeyword=this.value;renderUsers(1)}">
<button class="btn btn-outline btn-sm" onclick="userKeyword=document.querySelector('.search-box input').value;renderUsers(1)">搜索</button>
</div>
<button class="btn btn-primary btn-sm" onclick="openUserModal()">+ 新增用户</button>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>ID</th><th>用户名</th><th>手机号</th><th>角色</th><th>状态</th><th>注册时间</th><th>操作</th></tr></thead>
<tbody>
${d.list.length === 0 ? '<tr class="empty-row"><td colspan="7">暂无数据</td></tr>' : d.list.map(u => `
<tr>
<td>${u.id}</td>
<td>${u.username}</td>
<td>${u.phone || '-'}</td>
<td><span class="badge-status ${u.role==='admin'?'badge-active':'badge-inactive'}">${u.role==='admin'?'管理员':'普通用户'}</span></td>
<td><span class="badge-status ${u.status===1?'badge-active':'badge-inactive'}">${u.status===1?'正常':'禁用'}</span></td>
<td>${formatDate(u.created_at)}</td>
<td>
<button class="btn btn-outline btn-sm" onclick="openUserModal(${u.id})">编辑</button>
<button class="btn btn-danger btn-sm" onclick="deleteUser(${u.id})">删除</button>
</td>
</tr>`).join('')}
</tbody>
</table>
</div>
${renderPagination(d, 'renderUsers')}
</div>`;
} catch (e) { el.innerHTML = '<div class="loading">加载失败</div>'; }
}
function openUserModal(id) {
editingId = id;
const users = currentListData.list || [];
const user = id ? users.find(u => u.id === id) : null;
document.getElementById('modalTitle').textContent = id ? '编辑用户' : '新增用户';
document.getElementById('modalBody').innerHTML = `
<div class="form-row">
<div class="form-group">
<label>用户名 ${!id ? '<span style="color:var(--danger)">*</span>' : ''}</label>
<input type="text" id="m_username" value="${user?.username||''}" ${id?'disabled':''} placeholder="用户名">
</div>
<div class="form-group">
<label>密码 ${id ? '(留空不修改)' : '<span style="color:var(--danger)">*</span>'}</label>
<input type="password" id="m_password" placeholder="${id?'留空不修改':'请输入密码'}">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>手机号</label>
<input type="text" id="m_phone" value="${user?.phone||''}" placeholder="手机号">
</div>
<div class="form-group">
<label>角色</label>
<select id="m_role">
<option value="user" ${user?.role==='user'?'selected':''}>普通用户</option>
<option value="admin" ${user?.role==='admin'?'selected':''}>管理员</option>
</select>
</div>
</div>
${id ? `<div class="form-group">
<label>状态</label>
<select id="m_status">
<option value="1" ${user?.status===1?'selected':''}>正常</option>
<option value="0" ${user?.status===0?'selected':''}>禁用</option>
</select>
</div>` : ''}
`;
document.getElementById('modalSaveBtn').onclick = saveUser;
showModal();
}
async function saveUser() {
const username = document.getElementById('m_username').value.trim();
const password = document.getElementById('m_password').value;
const phone = document.getElementById('m_phone').value.trim();
const role = document.getElementById('m_role').value;
try {
if (editingId) {
const status = parseInt(document.getElementById('m_status').value);
await api('/admin/users/' + editingId, {
method: 'PUT',
body: JSON.stringify({ phone, role, status })
});
toast('更新成功', 'success');
} else {
if (!username || !password) { toast('用户名和密码不能为空', 'error'); return; }
await api('/admin/users', {
method: 'POST',
body: JSON.stringify({ username, password, phone, role })
});
toast('创建成功', 'success');
}
closeModal();
renderUsers();
} catch (e) {}
}
async function deleteUser(id) {
if (!confirm('确定删除该用户?')) return;
try {
await api('/admin/users/' + id, { method: 'DELETE' });
toast('删除成功', 'success');
renderUsers();
} catch (e) {}
}
// ===== 内容管理 =====
const contentModules = [
{ group: '企业官网', icon: '🏢', tables: [
{ key: 'enterprise_company', label: '企业信息' },
{ key: 'enterprise_news', label: '企业新闻' },
{ key: 'enterprise_services', label: '企业服务' },
{ key: 'enterprise_team', label: '团队成员' },
]},
{ group: '跨境电商', icon: '🛒', tables: [
{ key: 'shop_products', label: '商品' },
{ key: 'shop_categories', label: '商品分类' },
]},
{ group: '外卖平台', icon: '🍜', tables: [
{ key: 'food_restaurants', label: '餐厅' },
{ key: 'food_categories', label: '餐厅分类' },
{ key: 'food_menu_items', label: '菜品' },
{ key: 'food_menu_categories', label: '菜品分类' },
]},
{ group: '游戏平台', icon: '🎮', tables: [
{ key: 'game_games', label: '游戏' },
{ key: 'game_categories', label: '游戏分类' },
]},
{ group: '应用商店', icon: '📱', tables: [
{ key: 'app_apps', label: '应用' },
{ key: 'app_categories', label: '应用分类' },
]},
{ group: '音乐平台', icon: '🎵', tables: [
{ key: 'music_songs', label: '歌曲' },
{ key: 'music_categories', label: '音乐分类' },
]},
];
let contentPage = 1, contentPageSize = 20, contentKeyword = '';
function renderContent(table) {
if (table) currentTable = table;
if (!currentTable) currentTable = 'shop_products';
const el = document.getElementById('content');
// 构建左侧可下拉分组导航
let sidebarHtml = '';
for (const mod of contentModules) {
// 判断当前选中表是否属于该模块,属于则默认展开
const isActive = mod.tables.some(t => t.key === currentTable);
sidebarHtml += `<div class="content-group ${isActive?'expanded':''}">
<div class="content-group-header" onclick="this.parentElement.classList.toggle('expanded')">
<span>${mod.icon}</span> <span>${mod.group}</span>
<span class="arrow">▶</span>
</div>
<div class="content-group-items">`;
for (const t of mod.tables) {
sidebarHtml += `<div class="content-item ${t.key===currentTable?'active':''}" onclick="navigate('content',{table:'${t.key}'})">${t.label}</div>`;
}
sidebarHtml += `</div></div>`;
}
el.innerHTML = `<div class="content-layout">
<div class="content-sidebar">${sidebarHtml}</div>
<div class="content-main"><div id="contentTable"><div class="loading"><div class="spinner"></div></div></div></div>
</div>`;
loadContentTable();
}
async function loadContentTable(page) {
if (page) contentPage = page;
const el = document.getElementById('contentTable');
if (!el) return;
el.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
try {
const params = new URLSearchParams({ page: contentPage, page_size: contentPageSize });
if (contentKeyword) params.set('keyword', contentKeyword);
const data = await api('/admin/dashboard/' + currentTable + '?' + params);
const d = data.data;
currentListData = d;
// 动态生成表头
const sample = d.list[0] || {};
const allKeys = Object.keys(sample);
const displayKeys = allKeys.filter(k => !['updated_at'].includes(k));
const showKeys = displayKeys.slice(0, 8); // 最多显示8列
el.innerHTML = `
<div class="table-card">
<div class="table-toolbar">
<div class="search-box">
<input type="text" placeholder="搜索名称..." value="${contentKeyword}" onkeydown="if(event.key==='Enter'){contentKeyword=this.value;loadContentTable(1)}">
<button class="btn btn-outline btn-sm" onclick="contentKeyword=document.querySelector('.table-toolbar .search-box input').value;loadContentTable(1)">搜索</button>
</div>
<div style="display:flex;gap:8px">
<button class="btn btn-success btn-sm" onclick="openContentModal(null)">+ 新增</button>
</div>
</div>
<div class="table-wrap">
<table>
<thead><tr>
${showKeys.map(k => `<th>${formatKey(k)}</th>`).join('')}
<th>操作</th>
</tr></thead>
<tbody>
${d.list.length === 0 ? '<tr class="empty-row"><td colspan="' + (showKeys.length+1) + '">暂无数据</td></tr>' : d.list.map(row => `
<tr>
${showKeys.map(k => `<td>${formatCell(k, row[k])}</td>`).join('')}
<td>
<button class="btn btn-outline btn-sm" onclick="openContentModal(${row.id})">编辑</button>
<button class="btn btn-danger btn-sm" onclick="deleteContent(${row.id})">删除</button>
</td>
</tr>`).join('')}
</tbody>
</table>
</div>
${renderPagination(d, 'loadContentTable')}
</div>`;
} catch (e) { el.innerHTML = '<div class="loading">加载失败</div>'; }
}
function openContentModal(id) {
editingId = id;
editingTable = currentTable;
const items = currentListData.list || [];
const item = id ? items.find(r => r.id === id) : null;
const label = getTableLabel(currentTable);
document.getElementById('modalTitle').textContent = (id ? '编辑' : '新增') + ' - ' + label;
// 获取所有字段
const sample = item || (currentListData.list[0] || {});
const allKeys = sample ? Object.keys(sample).filter(k => !['created_at','updated_at','id'].includes(k)) : ['name'];
const fields = allKeys.length > 0 ? allKeys : ['name'];
let formHtml = '';
const imageFields = ['image','cover','logo','avatar','icon','images','screenshots'];
for (const key of fields) {
const val = item ? item[key] : '';
const isLongText = ['description','content','bio','vision','mission','notice','lyrics'].includes(key);
const isNumber = ['price','original_price','stock','sales','rating','sort','status','category_id','restaurant_id','plays','downloads','reviews','play_count','like_count','duration','delivery_time','min_order','delivery_fee','founded_year'].includes(key);
const isBool = ['is_hot','is_new','is_featured'].includes(key);
const isImage = imageFields.includes(key);
if (isBool) {
formHtml += `<div class="form-group"><label>${formatKey(key)}</label>
<select id="f_${key}"><option value="false" ${val!==true?'selected':''}>否</option><option value="true" ${val===true?'selected':''}>是</option></select></div>`;
} else if (isImage) {
const mod = currentTable.split('_')[0];
formHtml += `<div class="form-group"><label>${formatKey(key)}</label>
<div style="display:flex;gap:8px;align-items:center">
<input type="text" id="f_${key}" value="${val??''}" placeholder="输入URL或点击上传" style="flex:1">
<button type="button" class="btn btn-primary btn-sm" onclick="uploadToField('${key}','${mod}')" title="支持 JPG/PNG/GIF/WebP/SVG/MP4/PDF,最大 10MB">📤 上传</button>
</div>
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">支持格式: JPG · PNG · GIF · WebP · SVG · MP4 · PDF,最大 10MB</div>
${val ? `<img id="preview_${key}" src="${val}" style="margin-top:8px;max-width:120px;max-height:80px;border-radius:6px;border:1px solid var(--border)" onerror="this.style.display='none'">` : ''}</div>`;
} else if (isLongText) {
formHtml += `<div class="form-group"><label>${formatKey(key)}</label><textarea id="f_${key}">${val||''}</textarea></div>`;
} else if (isNumber) {
formHtml += `<div class="form-group"><label>${formatKey(key)}</label><input type="number" step="any" id="f_${key}" value="${val??''}"></div>`;
} else {
formHtml += `<div class="form-group"><label>${formatKey(key)}</label><input type="text" id="f_${key}" value="${val??''}"></div>`;
}
}
document.getElementById('modalBody').innerHTML = `<div class="form-row">${formHtml}</div>`;
document.getElementById('modalSaveBtn').onclick = saveContent;
showModal();
}
async function saveContent() {
const body = {};
const inputs = document.querySelectorAll('[id^="f_"]');
for (const input of inputs) {
const key = input.id.substring(2);
let val = input.value;
if (input.type === 'number') {
val = val === '' ? 0 : parseFloat(val);
} else if (input.tagName === 'SELECT' && (val === 'true' || val === 'false')) {
val = val === 'true';
}
body[key] = val;
}
try {
if (editingId) {
await api('/admin/dashboard/' + editingTable + '/' + editingId, {
method: 'PUT', body: JSON.stringify(body)
});
toast('更新成功', 'success');
} else {
await api('/admin/dashboard/' + editingTable, {
method: 'POST', body: JSON.stringify(body)
});
toast('创建成功', 'success');
}
closeModal();
loadContentTable();
} catch (e) {}
}
async function deleteContent(id) {
if (!confirm('确定删除?')) return;
try {
await api('/admin/dashboard/' + currentTable + '/' + id, { method: 'DELETE' });
toast('删除成功', 'success');
loadContentTable();
} catch (e) {}
}
// ===== 文件上传 =====
let uploadModule = 'common';
const moduleList = [
{ key: 'enterprise', label: '🏢 企业官网' },
{ key: 'shop', label: '🛒 跨境电商' },
{ key: 'food', label: '🍜 外卖平台' },
{ key: 'game', label: '🎮 游戏平台' },
{ key: 'app', label: '📱 应用商店' },
{ key: 'music', label: '🎵 音乐平台' },
{ key: 'common', label: '📂 公共资源' },
];
async function renderUploads() {
const el = document.getElementById('content');
let tabsHtml = '';
for (const m of moduleList) {
tabsHtml += `<div class="module-tab ${m.key===uploadModule?'active':''}" onclick="switchUploadModule('${m.key}')">${m.label}</div>`;
}
el.innerHTML = `
<div class="module-tabs">${tabsHtml}</div>
<div class="table-card">
<div style="padding:24px">
<div class="upload-area" id="uploadArea" onclick="document.getElementById('fileInput').click()">
<div class="upload-icon">📤</div>
<div class="upload-text">点击或拖拽文件到此处上传</div>
<div class="upload-formats">
<span class="fmt-tag">JPG</span>
<span class="fmt-tag">PNG</span>
<span class="fmt-tag">GIF</span>
<span class="fmt-tag">WebP</span>
<span class="fmt-tag">SVG</span>
<span class="fmt-tag">MP4</span>
<span class="fmt-tag">PDF</span>
</div>
<div class="upload-hint">单个文件最大 10MB · 可多选</div>
</div>
<input type="file" id="fileInput" multiple style="display:none" accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml,image/x-icon,image/bmp,video/mp4,video/webm,application/pdf">
<div class="upload-progress" id="uploadProgress" style="display:none">
<div class="upload-progress-bar"><div class="upload-progress-fill" id="progressFill" style="width:0%"></div></div>
<div style="text-align:center;margin-top:6px;font-size:12px;color:var(--text-muted)" id="progressText">上传中...</div>
</div>
</div>
</div>
<div class="section-header" style="margin-top:24px"><h4>📋 已上传文件</h4></div>
<div id="fileGrid"><div class="loading"><div class="spinner"></div></div></div>
`;
// 绑定文件选择和拖拽事件
const fileInput = document.getElementById('fileInput');
const uploadArea = document.getElementById('uploadArea');
fileInput.addEventListener('change', function() { handleFileUpload(this.files); });
uploadArea.addEventListener('dragover', function(e) { e.preventDefault(); this.classList.add('dragover'); });
uploadArea.addEventListener('dragleave', function(e) { e.preventDefault(); this.classList.remove('dragover'); });
uploadArea.addEventListener('drop', function(e) {
e.preventDefault();
this.classList.remove('dragover');
handleFileUpload(e.dataTransfer.files);
});
loadFileList();
}
function switchUploadModule(mod) {
uploadModule = mod;
renderUploads();
}
async function handleFileUpload(files) {
if (!files || files.length === 0) return;
const progressEl = document.getElementById('uploadProgress');
const fillEl = document.getElementById('progressFill');
const textEl = document.getElementById('progressText');
progressEl.style.display = 'block';
for (let i = 0; i < files.length; i++) {
const file = files[i];
textEl.textContent = `上传中 (${i+1}/${files.length}): ${file.name}`;
fillEl.style.width = ((i / files.length) * 100) + '%';
const formData = new FormData();
formData.append('file', file);
try {
const resp = await fetch(API_BASE + '/upload?module=' + uploadModule, {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token },
body: formData
});
const data = await resp.json();
if (data.code !== 0) {
toast('上传失败: ' + data.message, 'error');
} else {
toast('上传成功: ' + file.name, 'success');
}
} catch (e) {
toast('网络错误: ' + file.name, 'error');
}
}
fillEl.style.width = '100%';
textEl.textContent = '上传完成';
setTimeout(() => { progressEl.style.display = 'none'; fillEl.style.width = '0%'; }, 1500);
document.getElementById('fileInput').value = '';
loadFileList();
}
async function loadFileList() {
const el = document.getElementById('fileGrid');
if (!el) return;
el.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
try {
const data = await api('/upload/list?module=' + uploadModule);
const files = data.data.list || [];
if (files.length === 0) {
el.innerHTML = '<div class="table-card"><div class="empty-row" style="padding:40px">暂无文件,请上传</div></div>';
return;
}
let html = '<div class="file-grid">';
for (const f of files) {
const isImage = f.url.match(/\.(jpg|jpeg|png|gif|webp)$/i);
const isVideo = f.url.match(/\.(mp4|webm)$/i);
let preview = '';
if (isImage) {
preview = `<img src="${f.url}" onerror="this.parentElement.innerHTML='<div class=\\'file-icon\\'>📄</div>'">`;
} else if (isVideo) {
preview = `<div class="file-icon">🎬</div>`;
} else {
preview = `<div class="file-icon">📄</div>`;
}
html += `
<div class="file-card">
<div class="file-preview">${preview}</div>
<div class="file-actions">
<button title="复制链接" onclick="copyFileUrl('${f.url}')">🔗</button>
<button title="删除" onclick="deleteFile('${f.url}')">🗑</button>
</div>
<div class="file-info">
<div class="file-name" title="${f.name}">${f.name}</div>
<div class="file-meta">${formatFileSize(f.size)} · ${f.mod_time}</div>
</div>
</div>`;
}
html += '</div>';
el.innerHTML = html;
} catch (e) {
el.innerHTML = '<div class="loading">加载失败</div>';
}
}
function formatFileSize(bytes) {
if (!bytes) return '0 B';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / 1024 / 1024).toFixed(1) + ' MB';
}
function copyFileUrl(url) {
const fullUrl = window.location.origin + url;
navigator.clipboard.writeText(fullUrl).then(() => {
toast('链接已复制: ' + fullUrl, 'success');
}).catch(() => {
// 降级方案
const input = document.createElement('input');
input.value = fullUrl;
document.body.appendChild(input);
input.select();
document.execCommand('copy');
document.body.removeChild(input);
toast('链接已复制: ' + fullUrl, 'success');
});
}
async function deleteFile(fileUrl) {
if (!confirm('确定删除该文件?')) return;
// 从 URL 中提取模块相对路径
const path = fileUrl.replace('/uploads/', '');
try {
await api('/upload?module=' + uploadModule + '&filename=' + encodeURIComponent(path), { method: 'DELETE' });
toast('删除成功', 'success');
loadFileList();
} catch (e) {}
}
// 内容编辑弹窗中的字段上传
function uploadToField(fieldKey, mod) {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/jpeg,image/png,image/gif,image/webp,image/svg+xml,image/x-icon,image/bmp,video/mp4,video/webm,application/pdf';
input.onchange = async function() {
const file = this.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
try {
const resp = await fetch(API_BASE + '/upload?module=' + mod, {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token },
body: formData
});
const data = await resp.json();
if (data.code !== 0) {
toast('上传失败: ' + data.message, 'error');
return;
}
const urlInput = document.getElementById('f_' + fieldKey);
urlInput.value = data.data.url;
// 更新预览
let preview = document.getElementById('preview_' + fieldKey);
if (!preview) {
preview = document.createElement('img');
preview.id = 'preview_' + fieldKey;
preview.style.cssText = 'margin-top:8px;max-width:120px;max-height:80px;border-radius:6px;border:1px solid var(--border)';
urlInput.parentElement.parentElement.appendChild(preview);
}
preview.src = data.data.url;
preview.style.display = '';
toast('上传成功', 'success');
} catch (e) {
toast('网络错误', 'error');
}
};
input.click();
}
// ===== 系统设置 =====
async function renderSettings() {
const el = document.getElementById('content');
try {
const health = await fetch('/api/health').then(r => r.json());
el.innerHTML = `
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon bg-green">✅</div>
<div class="stat-value" style="font-size:18px">${health.data.status}</div>
<div class="stat-label">服务状态</div>
</div>
<div class="stat-card">
<div class="stat-icon bg-blue">🗄️</div>
<div class="stat-value" style="font-size:18px">${health.data.database}</div>
<div class="stat-label">数据库</div>
</div>
<div class="stat-card">
<div class="stat-icon bg-purple">🌍</div>
<div class="stat-value" style="font-size:18px">${health.data.env}</div>
<div class="stat-label">运行环境</div>
</div>
<div class="stat-card">
<div class="stat-icon bg-orange">🕐</div>
<div class="stat-value" style="font-size:14px">${formatDate(health.data.time)}</div>
<div class="stat-label">服务器时间</div>
</div>
</div>
<div class="table-card">
<div class="section-header" style="padding:16px"><h4>📋 系统信息</h4></div>
<div class="table-wrap">
<table>
<tbody>
<tr><td style="font-weight:600;width:200px">系统名称</td><td>Gotest 多站点管理系统</td></tr>
<tr><td style="font-weight:600">技术栈</td><td>Go + Gin + GORM + SQLite</td></tr>
<tr><td style="font-weight:600">前端模板</td><td>18 套(6 大业务线 × 3 风格)</td></tr>
<tr><td style="font-weight:600">API 版本</td><td>v1</td></tr>
<tr><td style="font-weight:600">认证方式</td><td>JWT Bearer Token</td></tr>
<tr><td style="font-weight:600">管理后台版本</td><td>v1.0</td></tr>
</tbody>
</table>
</div>
</div>`;
} catch (e) { el.innerHTML = '<div class="loading">加载失败</div>'; }
}
// ===== 站点导航 =====
function renderSites() {
const el = document.getElementById('content');
const sites = [
{ group: '企业官网', icon: '🏢', items: [
{ name: '科技风格', path: '/site1', color: '#3b82f6' },
{ name: '制造风格', path: '/site2', color: '#f97316' },
{ name: '咨询风格', path: '/site3', color: '#10b981' },
]},
{ group: '跨境电商', icon: '🛒', items: [
{ name: '红色风格', path: '/shop', color: '#ef4444' },
{ name: '暗黑风格', path: '/shop2', color: '#1e293b' },
{ name: '北欧风格', path: '/shop3', color: '#06b6d4' },
]},
{ group: '外卖平台', icon: '🍜', items: [
{ name: '橙黄风格', path: '/food1', color: '#f59e0b' },
{ name: '深色风格', path: '/food2', color: '#334155' },
{ name: '清新风格', path: '/food3', color: '#34d399' },
]},
{ group: '游戏平台', icon: '🎮', items: [
{ name: '赛博风格', path: '/game1', color: '#8b5cf6' },
{ name: '像素风格', path: '/game2', color: '#ec4899' },
{ name: '云游风格', path: '/game3', color: '#3b82f6' },
]},
{ group: '应用商店', icon: '📱', items: [
{ name: '极简风格', path: '/app1', color: '#64748b' },
{ name: '暗黑风格', path: '/app2', color: '#1e293b' },
{ name: '清新风格', path: '/app3', color: '#10b981' },
]},
{ group: '音乐平台', icon: '🎵', items: [
{ name: '暗黑风格', path: '/music1', color: '#18181b' },
{ name: '黑胶风格', path: '/music2', color: '#78350f' },
{ name: '渐变风格', path: '/music3', color: '#a855f7' },
]},
];
let html = '';
for (const mod of sites) {
let cards = '';
for (const s of mod.items) {
cards += `
<div class="stat-card" style="cursor:pointer" onclick="window.open('${s.path}','_blank')">
<div class="stat-icon" style="background:${s.color}20;color:${s.color}">${mod.icon}</div>
<div class="stat-value" style="font-size:16px">${s.name}</div>
<div class="stat-label">${s.path}</div>
</div>`;
}
html += `<div class="section-header"><h4>${mod.icon} ${mod.group}</h4></div><div class="stats-grid" style="margin-bottom:24px">${cards}</div>`;
}
el.innerHTML = html;
}
// ===== 访问日志 =====
let accessLogPage = 1, accessLogPageSize = 20;
async function renderAccessLogs(page) {
if (page) accessLogPage = page;
const el = document.getElementById('content');
el.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
try {
const [statsData, logsData] = await Promise.all([
api('/admin/access-logs/stats'),
api(`/admin/access-logs?page=${accessLogPage}&page_size=${accessLogPageSize}`)
]);
const stats = statsData.data;
const logs = logsData.data;
let hourlyChart = '';
if (stats.hourly && stats.hourly.length > 0) {
const maxCount = Math.max(...stats.hourly.map(h => h.count));
hourlyChart = stats.hourly.map(h => {
const pct = maxCount > 0 ? (h.count / maxCount * 100) : 0;
const hour = h.hour ? h.hour.split(' ')[1] || h.hour : '';
return `<div style="display:flex;align-items:end;gap:4px;flex-direction:column;align-items:center;flex:1">
<div style="font-size:11px;color:var(--text-muted)">${h.count}</div>
<div style="width:100%;max-width:32px;background:var(--primary);border-radius:4px 4px 0 0;height:${Math.max(4, pct)}%;min-height:4px;transition:height .3s"></div>
<div style="font-size:10px;color:var(--text-muted)">${hour}</div>
</div>`;
}).join('');
}
el.innerHTML = `
<div class="stats-grid" style="margin-bottom:24px">
<div class="stat-card">
<div class="stat-icon bg-blue">📈</div>
<div class="stat-value">${stats.total_visits || 0}</div>
<div class="stat-label">总访问量</div>
</div>
<div class="stat-card">
<div class="stat-icon bg-green">📅</div>
<div class="stat-value">${stats.today_visits || 0}</div>
<div class="stat-label">今日访问</div>
</div>
<div class="stat-card">
<div class="stat-icon bg-purple">🌐</div>
<div class="stat-value">${stats.unique_ips || 0}</div>
<div class="stat-label">独立IP</div>
</div>
</div>
${hourlyChart ? `<div class="table-card" style="margin-bottom:24px">
<div class="section-header" style="padding:16px"><h4>📊 最近24小时访问趋势</h4></div>
<div style="padding:16px;display:flex;align-items:end;gap:6px;height:180px">${hourlyChart}</div>
</div>` : ''}
<div class="table-card">
<div class="table-toolbar">
<h4 style="font-size:14px;font-weight:600">📋 访问记录</h4>
<button class="btn btn-danger btn-sm" onclick="clearAccessLogs()">清空日志</button>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>时间</th><th>IP</th><th>归属地</th><th>路径</th><th>方法</th><th>状态</th><th>耗时</th></tr></thead>
<tbody>
${logs.list.length === 0 ? '<tr class="empty-row"><td colspan="7">暂无访问记录</td></tr>' : logs.list.map(l => `
<tr>
<td>${formatDate(l.created_at)}</td>
<td><code style="background:#f1f5f9;padding:2px 6px;border-radius:4px;font-size:12px">${l.ip}</code></td>
<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${l.location||''}">${l.location || '-'}</td>
<td style="max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${l.path}">${l.path}</td>
<td><span class="badge-status ${l.method==='GET'?'badge-active':'badge-inactive'}">${l.method}</span></td>
<td><span class="badge-status ${l.status<400?'badge-active':'badge-inactive'}">${l.status}</span></td>
<td>${l.cost}ms</td>
</tr>`).join('')}
</tbody>
</table>
</div>
${renderPagination(logs, 'renderAccessLogs')}
</div>`;
} catch (e) { el.innerHTML = '<div class="loading">加载失败</div>'; }
}
async function clearAccessLogs() {
if (!confirm('确定清空所有访问日志?')) return;
try {
await api('/admin/access-logs', { method: 'DELETE' });
toast('清空成功', 'success');
renderAccessLogs(1);
} catch (e) {}
}
// ===== Webhook 配置 =====
async function renderWebhook() {
const el = document.getElementById('content');
el.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
try {
const data = await api('/admin/config/wechat_webhook_url');
const url = data.data ? data.data.value : '';
el.innerHTML = `
<div class="table-card">
<div class="section-header" style="padding:16px"><h4>🔔 企业微信 Webhook 配置</h4></div>
<div style="padding:24px;max-width:700px">
<div class="form-group">
<label>Webhook URL</label>
<input type="text" id="webhookUrl" value="${url || ''}" placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" style="width:100%">
<div style="font-size:12px;color:var(--text-muted);margin-top:6px">
企业微信群机器人的 Webhook 地址,用于接收客户咨询通知和系统告警
</div>
</div>
<div style="display:flex;gap:8px;margin-top:20px">
<button class="btn btn-primary" onclick="saveWebhook()">保存配置</button>
<button class="btn btn-outline" onclick="testWebhook()">测试发送</button>
</div>
<div id="webhookTestResult" style="margin-top:12px;display:none"></div>
</div>
</div>
<div class="table-card" style="margin-top:16px">
<div class="section-header" style="padding:16px"><h4>📖 使用说明</h4></div>
<div style="padding:0 24px 24px">
<ol style="line-height:2;color:var(--text-muted);font-size:13px">
<li>在企业微信群中添加一个<strong>群机器人</strong></li>
<li>点击机器人头像,复制 <strong>Webhook 地址</strong></li>
<li>将地址粘贴到上方输入框并保存</li>
<li>点击「测试发送」验证配置是否正确</li>
<li>配置成功后,客户咨询通知将自动推送到该群</li>
</ol>
</div>
</div>`;
} catch (e) { el.innerHTML = '<div class="loading">加载失败</div>'; }
}
async function saveWebhook() {
const url = document.getElementById('webhookUrl').value.trim();
try {
await api('/admin/config/wechat_webhook_url', {
method: 'PUT',
body: JSON.stringify({ value: url, remark: '企业微信群机器人Webhook地址' })
});
toast('保存成功', 'success');
} catch (e) {}
}
async function testWebhook() {
const resultEl = document.getElementById('webhookTestResult');
resultEl.style.display = 'block';
resultEl.innerHTML = '<div style="color:var(--text-muted)">发送中...</div>';
try {
await api('/admin/config/wechat_webhook_url', {
method: 'PUT',
body: JSON.stringify({ value: document.getElementById('webhookUrl').value.trim(), remark: '企业微信群机器人Webhook地址' })
});
// 通过客户咨询接口发送测试消息
const resp = await fetch(API_BASE + '/customer/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: '测试用户',
phone: '13800000000',
company: '测试公司',
email: 'test@example.com',
needs: '这是一条测试消息,用于验证 Webhook 配置是否正确'
})
});
const data = await resp.json();
if (data.code === 0) {
resultEl.innerHTML = '<div style="color:var(--success);font-weight:600">✅ 测试消息已发送,请检查企业微信群</div>';
} else {
resultEl.innerHTML = `<div style="color:var(--danger)">❌ 发送失败: ${data.message}</div>`;
}
} catch (e) {
resultEl.innerHTML = '<div style="color:var(--danger)">❌ 网络错误</div>';
}
}
// ===== 通用工具 =====
function formatDate(d) {
if (!d) return '-';
const date = new Date(d);
if (isNaN(date)) return d;
return date.getFullYear() + '-' + String(date.getMonth()+1).padStart(2,'0') + '-' + String(date.getDate()).padStart(2,'0') + ' ' + String(date.getHours()).padStart(2,'0') + ':' + String(date.getMinutes()).padStart(2,'0');
}
function formatKey(k) {
const map = {
id:'ID', name:'名称', title:'标题', description:'描述', price:'价格', original_price:'原价',
image:'图片', images:'图片组', category_id:'分类ID', category_name:'分类名', stock:'库存',
rating:'评分', sales:'销量', tags:'标签', sort:'排序', status:'状态', created_at:'创建时间',
summary:'摘要', content:'内容', author:'作者', category:'分类', icon:'图标',
logo:'Logo', slogan:'口号', vision:'愿景', mission:'使命', phone:'电话', email:'邮箱',
address:'地址', website:'网址', founded_year:'成立年份', employee_count:'员工数', stats:'统计',
cover:'封面', delivery_fee:'配送费', min_order:'起送价', delivery_time:'配送时间',
distance:'距离', notice:'公告', restaurant_id:'餐厅ID', developer:'开发商', publisher:'发行商',
plays:'游玩次数', downloads:'下载量', size:'大小', version:'版本', release_date:'发行日期',
platform:'平台', screenshots:'截图', features:'特性', is_hot:'热门', is_new:'最新',
is_featured:'精选', artist:'歌手', album:'专辑', duration:'时长', play_count:'播放量',
like_count:'收藏量', lyrics:'歌词', language:'语种', reviews:'评价数', update_date:'更新日期',
position:'职位', avatar:'头像', bio:'简介', username:'用户名', role:'角色'
};
return map[k] || k;
}
function formatCell(key, val) {
if (val === null || val === undefined || val === '') return '<span style="color:var(--text-muted)">-</span>';
if (key === 'status') return `<span class="badge-status ${val===1?'badge-active':'badge-inactive'}">${val===1?'正常':'禁用'}</span>`;
if (key === 'is_hot' || key === 'is_new' || key === 'is_featured') return val ? '✅' : '❌';
if (key === 'image' || key === 'cover' || key === 'logo' || key === 'avatar' || key === 'icon') {
if (typeof val === 'string' && val.startsWith('http')) return `<img class="cell-img" src="${val}" onerror="this.style.display='none'">`;
return String(val).substring(0, 30);
}
if (typeof val === 'string' && val.length > 100) return `<span class="cell-text-truncate" title="${val.replace(/"/g,'&quot;')}">${val.substring(0,100)}...</span>`;
if (key === 'created_at') return formatDate(val);
if (typeof val === 'object') return JSON.stringify(val).substring(0, 50);
return String(val);
}
function getTableLabel(key) {
for (const mod of contentModules) {
for (const t of mod.tables) {
if (t.key === key) return t.label;
}
}
return key;
}
function renderPagination(d, fnName) {
const totalPages = Math.ceil(d.total / d.page_size) || 1;
let btns = '';
btns += `<button class="page-btn" ${d.page<=1?'disabled':''} onclick="${fnName}(${d.page-1})">上一页</button>`;
const start = Math.max(1, d.page - 2);
const end = Math.min(totalPages, d.page + 2);
for (let i = start; i <= end; i++) {
btns += `<button class="page-btn ${i===d.page?'active':''}" onclick="${fnName}(${i})">${i}</button>`;
}
btns += `<button class="page-btn" ${d.page>=totalPages?'disabled':''} onclick="${fnName}(${d.page+1})">下一页</button>`;
return `<div class="pagination">
<div class="pagination-info">共 ${d.total} 条,第 ${d.page}/${totalPages} 页</div>
<div class="pagination-btns">${btns}</div>
</div>`;
}
function showModal() { document.getElementById('modalOverlay').classList.add('show'); }
function closeModal() { document.getElementById('modalOverlay').classList.remove('show'); editingId = null; }
// ===== 初始化 =====
document.getElementById('modalOverlay').addEventListener('click', (e) => {
if (e.target === e.currentTarget) closeModal();
});
document.getElementById('loginPassword').addEventListener('keydown', (e) => {
if (e.key === 'Enter') doLogin();
});
document.getElementById('initPasswordConfirm')?.addEventListener('keydown', (e) => {
if (e.key === 'Enter') doInitAdmin();
});
// 检查管理员是否存在,决定显示登录还是初始化
(async function() {
const hasAdmin = await checkAdminExists();
if (!hasAdmin) {
document.getElementById('loginForm').style.display = 'none';
document.getElementById('initForm').style.display = 'block';
document.getElementById('loginTitle').textContent = '🚀 初始化系统';
document.getElementById('loginSubtitle').textContent = '首次使用,请创建管理员账号';
}
})();
// 检查登录状态
if (token) {
// 验证 token 有效性
api('/user/profile').then((data) => {
const u = data.data;
if (u.role !== 'admin') { doLogout(); return; }
currentUser = { id: u.id, username: u.username, role: u.role };
showApp();
}).catch(() => {
doLogout();
});
} else {
document.getElementById('loginOverlay').style.display = 'flex';
}
</script>
</body>
</html>