1396 lines
64 KiB
HTML
1396 lines
64 KiB
HTML
<!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()">×</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,'"')}">${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>
|