Files
web_template/web/dist/admin/index.html
2026-07-10 10:06:55 +00:00

919 lines
42 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>管理后台 - Gotest Admin</title>
<style>
:root{--primary:#4f46e5;--primary-dark:#4338ca;--primary-light:#e0e7ff;--success:#10b981;--danger:#ef4444;--warning:#f59e0b;--info:#3b82f6;--bg:#f1f5f9;--card-bg:#fff;--text:#1e293b;--text-muted:#64748b;--border:#e2e8f0;--sidebar-bg:#1e293b;--sidebar-active:#4f46e5;--sidebar-text:#94a3b8;--sidebar-text-active:#fff;--radius:8px;--shadow:0 1px 3px rgba(0,0,0,.1);--shadow-lg:0 10px 30px rgba(0,0,0,.15)}
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;background:var(--bg);color:var(--text);font-size:14px}
/* Login */
.login-overlay{position:fixed;inset:0;background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);display:flex;align-items:center;justify-content:center;z-index:9999}
.login-card{background:#fff;border-radius:16px;padding:40px;width:380px;box-shadow:var(--shadow-lg)}
.login-card h1{font-size:24px;text-align:center;margin-bottom:8px;color:var(--text)}
.login-card .subtitle{text-align:center;color:var(--text-muted);margin-bottom:28px;font-size:13px}
.login-card .form-group{margin-bottom:16px}
.login-card label{display:block;margin-bottom:6px;font-weight:600;font-size:13px}
.login-card input{width:100%;padding:10px 14px;border:1px solid var(--border);border-radius:var(--radius);font-size:14px;transition:border .2s}
.login-card input:focus{outline:none;border-color:var(--primary);box-shadow:0 0 0 3px var(--primary-light)}
.login-btn{width:100%;padding:12px;background:var(--primary);color:#fff;border:none;border-radius:var(--radius);font-size:15px;font-weight:600;cursor:pointer;transition:background .2s}
.login-btn:hover{background:var(--primary-dark)}
.login-btn:disabled{opacity:.6;cursor:not-allowed}
.login-error{color:var(--danger);text-align:center;margin-top:12px;font-size:13px;min-height:18px}
.login-hint{text-align:center;margin-top:16px;padding:10px;background:#f0fdf4;border-radius:var(--radius);font-size:12px;color:#15803d}
/* Layout */
.app{display:flex;min-height:100vh}
.sidebar{width:240px;background:var(--sidebar-bg);color:var(--sidebar-text);position:fixed;top:0;bottom:0;left:0;z-index:100;overflow-y:auto;transition:transform .3s}
.sidebar-header{padding:20px 24px;border-bottom:1px solid #334155}
.sidebar-header h2{color:#fff;font-size:18px;display:flex;align-items:center;gap:8px}
.sidebar-header .logo{width:32px;height:32px;background:var(--primary);border-radius:8px;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:bold;font-size:16px}
.sidebar-nav{padding:12px 0}
.sidebar-section{padding:8px 24px;font-size:11px;text-transform:uppercase;letter-spacing:1px;color:#475569;margin-top:8px}
.sidebar-link{display:flex;align-items:center;gap:10px;padding:10px 24px;color:var(--sidebar-text);text-decoration:none;cursor:pointer;transition:all .2s;border-left:3px solid transparent}
.sidebar-link:hover{background:#334155;color:#cbd5e1}
.sidebar-link.active{background:var(--sidebar-active);color:var(--sidebar-text-active);border-left-color:#818cf8}
.sidebar-link .icon{width:20px;text-align:center;font-size:16px}
.sidebar-link .badge{margin-left:auto;background:var(--danger);color:#fff;font-size:11px;padding:1px 6px;border-radius:10px}
.main{flex:1;margin-left:240px;min-height:100vh}
.topbar{background:var(--card-bg);padding:12px 24px;display:flex;align-items:center;justify-content:space-between;box-shadow:var(--shadow);position:sticky;top:0;z-index:50}
.topbar-left{display:flex;align-items:center;gap:12px}
.topbar-left h3{font-size:18px;font-weight:600}
.topbar-right{display:flex;align-items:center;gap:16px}
.topbar-user{display:flex;align-items:center;gap:8px;cursor:pointer}
.topbar-avatar{width:36px;height:36px;border-radius:50%;background:var(--primary);color:#fff;display:flex;align-items:center;justify-content:center;font-weight:600}
.topbar-username{font-weight:600;font-size:13px}
.topbar-role{font-size:11px;color:var(--text-muted);background:var(--primary-light);padding:2px 8px;border-radius:10px;color:var(--primary)}
.content{padding:24px}
/* Cards & Stats */
.stats-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:16px;margin-bottom:24px}
.stat-card{background:var(--card-bg);border-radius:var(--radius);padding:20px;box-shadow:var(--shadow);transition:transform .2s,box-shadow .2s}
.stat-card:hover{transform:translateY(-2px);box-shadow:var(--shadow-lg)}
.stat-card .stat-icon{width:48px;height:48px;border-radius:12px;display:flex;align-items:center;justify-content:center;font-size:24px;margin-bottom:12px}
.stat-card .stat-value{font-size:28px;font-weight:700}
.stat-card .stat-label{color:var(--text-muted);font-size:13px;margin-top:4px}
.bg-indigo{background:#e0e7ff;color:#4f46e5}
.bg-green{background:#d1fae5;color:#10b981}
.bg-blue{background:#dbeafe;color:#3b82f6}
.bg-purple{background:#ede9fe;color:#8b5cf6}
.bg-orange{background:#fed7aa;color:#f97316}
.bg-pink{background:#fce7f3;color:#ec4899}
.bg-cyan{background:#cffafe;color:#06b6d4}
/* Section headers */
.section-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px}
.section-header h4{font-size:16px;font-weight:600}
/* Tables */
.table-card{background:var(--card-bg);border-radius:var(--radius);box-shadow:var(--shadow);overflow:hidden}
.table-toolbar{padding:16px;display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;border-bottom:1px solid var(--border)}
.search-box{display:flex;align-items:center;gap:8px}
.search-box input{padding:8px 12px;border:1px solid var(--border);border-radius:var(--radius);font-size:13px;width:240px}
.search-box input:focus{outline:none;border-color:var(--primary)}
.btn{padding:8px 16px;border:none;border-radius:var(--radius);font-size:13px;font-weight:600;cursor:pointer;transition:all .2s;display:inline-flex;align-items:center;gap:6px}
.btn-primary{background:var(--primary);color:#fff}
.btn-primary:hover{background:var(--primary-dark)}
.btn-success{background:var(--success);color:#fff}
.btn-danger{background:var(--danger);color:#fff}
.btn-danger:hover{background:#dc2626}
.btn-sm{padding:5px 10px;font-size:12px}
.btn-outline{background:transparent;border:1px solid var(--border);color:var(--text)}
.btn-outline:hover{background:#f8fafc}
table{width:100%;border-collapse:collapse}
thead{background:#f8fafc}
th{padding:12px 16px;text-align:left;font-size:12px;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:.5px;border-bottom:1px solid var(--border);white-space:nowrap}
td{padding:12px 16px;border-bottom:1px solid var(--border);font-size:13px}
tbody tr:hover{background:#f8fafc}
tbody tr:last-child td{border-bottom:none}
.table-wrap{overflow-x:auto}
.empty-row{text-align:center;padding:40px;color:var(--text-muted)}
/* Pagination */
.pagination{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-top:1px solid var(--border)}
.pagination-info{font-size:13px;color:var(--text-muted)}
.pagination-btns{display:flex;gap:4px}
.page-btn{padding:6px 12px;border:1px solid var(--border);border-radius:var(--radius);background:#fff;cursor:pointer;font-size:13px;transition:all .2s}
.page-btn:hover:not(:disabled){background:var(--primary-light);border-color:var(--primary)}
.page-btn.active{background:var(--primary);color:#fff;border-color:var(--primary)}
.page-btn:disabled{opacity:.5;cursor:not-allowed}
/* Status badges */
.badge-status{display:inline-block;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600}
.badge-active{background:#d1fae5;color:#065f46}
.badge-inactive{background:#fee2e2;color:#991b1b}
/* Modal */
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:1000;opacity:0;pointer-events:none;transition:opacity .2s}
.modal-overlay.show{opacity:1;pointer-events:auto}
.modal{background:#fff;border-radius:12px;width:600px;max-width:90vw;max-height:85vh;overflow-y:auto;box-shadow:var(--shadow-lg);transform:scale(.95);transition:transform .2s}
.modal-overlay.show .modal{transform:scale(1)}
.modal-header{padding:16px 24px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between}
.modal-header h3{font-size:18px;font-weight:600}
.modal-close{background:none;border:none;font-size:24px;cursor:pointer;color:var(--text-muted);line-height:1}
.modal-close:hover{color:var(--text)}
.modal-body{padding:24px}
.modal-footer{padding:16px 24px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;gap:8px}
/* Forms */
.form-row{display:grid;grid-template-columns:1fr 1fr;gap:16px}
.form-group{margin-bottom:16px}
.form-group label{display:block;margin-bottom:6px;font-weight:600;font-size:13px}
.form-group input,.form-group select,.form-group textarea{width:100%;padding:8px 12px;border:1px solid var(--border);border-radius:var(--radius);font-size:13px;font-family:inherit}
.form-group input:focus,.form-group select:focus,.form-group textarea:focus{outline:none;border-color:var(--primary);box-shadow:0 0 0 3px var(--primary-light)}
.form-group textarea{resize:vertical;min-height:80px}
/* Toast */
.toast-container{position:fixed;top:20px;right:20px;z-index:2000;display:flex;flex-direction:column;gap:8px}
.toast{padding:12px 20px;border-radius:var(--radius);color:#fff;font-size:14px;box-shadow:var(--shadow-lg);animation:slideIn .3s ease;display:flex;align-items:center;gap:8px;min-width:250px}
.toast-success{background:var(--success)}
.toast-error{background:var(--danger)}
.toast-info{background:var(--info)}
@keyframes slideIn{from{transform:translateX(100%);opacity:0}to{transform:translateX(0);opacity:1}}
/* Loading */
.loading{text-align:center;padding:40px;color:var(--text-muted)}
.spinner{display:inline-block;width:32px;height:32px;border:3px solid var(--border);border-top-color:var(--primary);border-radius:50%;animation:spin .8s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
/* Content 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>