Files
web_template/web/dist/admin/index.html

1147 lines
53 KiB
HTML
Raw Blame History

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