Add web/dist/admin/index.html

This commit is contained in:
ata
2026-07-10 10:06:55 +00:00
committed by Gitee
parent 9c929cf9c1
commit 1ad1fa5b3b

918
web/dist/admin/index.html vendored Normal file
View File

@@ -0,0 +1,918 @@
<!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 nav tabs */
.content-tabs{display:flex;gap:4px;margin-bottom:16px;flex-wrap:wrap}
.content-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)}
.content-tab:hover{background:#f8fafc}
.content-tab.active{background:var(--primary);color:#fff;border-color:var(--primary)}
/* 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>🔐 管理后台</h1>
<p class="subtitle">Gotest Admin Dashboard</p>
<div class="form-group">
<label>用户名</label>
<input type="text" id="loginUsername" placeholder="请输入用户名" value="admin">
</div>
<div class="form-group">
<label>密码</label>
<input type="password" id="loginPassword" placeholder="请输入密码" value="admin123">
</div>
<button class="login-btn" id="loginBtn" onclick="doLogin()">登 录</button>
<div class="login-error" id="loginError"></div>
<div class="login-hint">默认账号: admin / admin123</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('dashboard')" data-page="dashboard">
<span class="icon">📊</span> 仪表盘
</a>
<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('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 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('dashboard');
}
// ===== 导航 =====
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 = { dashboard: '仪表盘', users: '用户管理', content: '内容管理', settings: '系统设置', sites: '站点导航' };
document.getElementById('pageTitle').textContent = titles[page] || page;
if (page === 'dashboard') renderDashboard();
else if (page === 'users') renderUsers();
else if (page === 'content') renderContent(params.table);
else if (page === 'settings') renderSettings();
else if (page === 'sites') renderSites();
if (window.innerWidth <= 768) document.getElementById('sidebar').classList.remove('open');
}
function toggleSidebar() { document.getElementById('sidebar').classList.toggle('open'); }
// ===== 仪表盘 =====
async function renderDashboard() {
const el = document.getElementById('content');
el.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
try {
const data = await api('/admin/dashboard/stats');
const g = data.data.grouped;
const iconMap = { enterprise: '🏢', shop: '🛒', food: '🍜', game: '🎮', app: '📱', music: '🎵' };
const colorMap = { enterprise: 'bg-indigo', shop: 'bg-green', food: 'bg-orange', game: 'bg-purple', app: 'bg-blue', music: 'bg-pink' };
let statsHtml = `
<div class="stat-card">
<div class="stat-icon bg-cyan">👥</div>
<div class="stat-value">${data.data.total_counts.users || 0}</div>
<div class="stat-label">总用户数</div>
</div>`;
let groupedHtml = '';
for (const [key, val] of Object.entries(g)) {
const items = val.items;
let total = 0;
for (const v of Object.values(items)) total += (v || 0);
statsHtml += `
<div class="stat-card" onclick="navigate('content',{table:'${Object.keys(items)[0]}'})" style="cursor:pointer">
<div class="stat-icon ${colorMap[key]}">${iconMap[key]}</div>
<div class="stat-value">${total}</div>
<div class="stat-label">${val.label}</div>
</div>`;
groupedHtml += `
<div class="table-card" style="margin-bottom:16px">
<div class="section-header" style="padding:16px">
<h4>${iconMap[key]} ${val.label}</h4>
</div>
<div class="table-wrap">
<table>
<tbody>`;
const itemLabels = {
company: '企业信息', news: '新闻', services: '服务', team: '团队',
products: '商品', categories: '分类', restaurants: '餐厅',
menu_items: '菜品', menu_categories: '菜品分类',
games: '游戏', apps: '应用', songs: '歌曲'
};
for (const [itemKey, itemVal] of Object.entries(items)) {
const tableKey = key + '_' + itemKey;
groupedHtml += `
<tr style="cursor:pointer" onclick="navigate('content',{table:'${tableKey}'})">
<td style="font-weight:600">${itemLabels[itemKey] || itemKey}</td>
<td style="text-align:right"><span class="badge-status badge-active">${itemVal || 0} 条</span></td>
<td style="text-align:right;width:60px">→</td>
</tr>`;
}
groupedHtml += `</tbody></table></div></div>`;
}
el.innerHTML = `
<div class="stats-grid">${statsHtml}</div>
<div class="section-header"><h4>📈 各模块数据概览</h4></div>
${groupedHtml}
`;
} catch (e) { el.innerHTML = '<div class="loading">加载失败,请刷新重试</div>'; }
}
// ===== 用户管理 =====
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');
// 渲染 tabs
let tabsHtml = '';
for (const mod of contentModules) {
for (const t of mod.tables) {
tabsHtml += `<div class="content-tab ${t.key===currentTable?'active':''}" onclick="navigate('content',{table:'${t.key}'})">${mod.icon} ${t.label}</div>`;
}
}
el.innerHTML = `<div class="content-tabs">${tabsHtml}</div><div id="contentTable"><div class="loading"><div class="spinner"></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 = '';
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);
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 (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) {}
}
// ===== 系统设置 =====
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;
}
// ===== 通用工具 =====
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();
});
// 检查登录状态
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>