- 导航页(index.html)添加完整i18n引擎,支持zh-CN/zh-TW/en - 导航页添加语言切换按钮(EN/中/繁) - 所有20个前端模板默认语言从zh-CN改为en - 导航页SEO meta标签改为英文
1325 lines
117 KiB
HTML
1325 lines
117 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title data-i18n="page_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-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}
|
||
.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}
|
||
.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-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px}
|
||
.section-header h4{font-size:16px;font-weight:600}
|
||
.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{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}
|
||
.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-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}
|
||
.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-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{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{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-main{flex:1;min-width:0}
|
||
.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}
|
||
.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}
|
||
.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}
|
||
.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}}
|
||
.lang-switcher{position:relative}
|
||
.lang-btn{display:flex;align-items:center;gap:6px;padding:6px 12px;border-radius:var(--radius);font-size:13px;font-weight:600;color:var(--text-muted);cursor:pointer;background:var(--bg);border:1px solid var(--border);transition:all .2s}
|
||
.lang-btn:hover{color:var(--text);background:var(--primary-light);border-color:var(--primary)}
|
||
.lang-dropdown{position:absolute;top:calc(100% + 6px);right:0;min-width:140px;background:#fff;border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow-lg);overflow:hidden;opacity:0;transform:translateY(-8px);pointer-events:none;transition:all .2s;z-index:100}
|
||
.lang-dropdown.open{opacity:1;transform:none;pointer-events:auto}
|
||
.lang-option{display:flex;align-items:center;gap:10px;padding:10px 14px;font-size:13px;color:var(--text-muted);cursor:pointer;transition:all .2s}
|
||
.lang-option:hover{background:var(--primary-light);color:var(--primary)}
|
||
.lang-option.active{color:var(--primary);font-weight:600}
|
||
.lang-option .check{width:16px;height:16px;opacity:0;transition:opacity .2s}
|
||
.lang-option.active .check{opacity:1}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="login-overlay" id="loginOverlay">
|
||
<div class="login-card">
|
||
<h1 id="loginTitle">🔐 <span data-i18n="login_title">管理后台</span></h1>
|
||
<p class="subtitle" id="loginSubtitle" data-i18n="login_subtitle">Gotest Admin Dashboard</p>
|
||
<div id="loginForm">
|
||
<div class="form-group"><label data-i18n="login_username">用户名</label><input type="text" id="loginUsername" data-i18n-placeholder="login_username_placeholder" placeholder="请输入用户名"></div>
|
||
<div class="form-group"><label data-i18n="login_password">密码</label><input type="password" id="loginPassword" data-i18n-placeholder="login_password_placeholder" placeholder="请输入密码"></div>
|
||
<button class="login-btn" id="loginBtn" onclick="doLogin()" data-i18n="login_btn">登 录</button>
|
||
<div class="login-error" id="loginError"></div>
|
||
</div>
|
||
<div id="initForm" style="display:none">
|
||
<div class="form-group"><label data-i18n="init_admin_username">管理员用户名</label><input type="text" id="initUsername" data-i18n-placeholder="init_admin_username_placeholder" placeholder="请设置管理员用户名"></div>
|
||
<div class="form-group"><label data-i18n="init_admin_password">管理员密码</label><input type="password" id="initPassword" data-i18n-placeholder="init_admin_password_placeholder" placeholder="请设置密码(至少6位)"></div>
|
||
<div class="form-group"><label data-i18n="init_confirm_password">确认密码</label><input type="password" id="initPasswordConfirm" data-i18n-placeholder="init_confirm_placeholder" placeholder="请再次输入密码"></div>
|
||
<button class="login-btn" id="initBtn" onclick="doInitAdmin()" data-i18n="init_btn">初始化管理员</button>
|
||
<div class="login-error" id="initError"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="app" id="app" style="display:none">
|
||
<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" data-i18n="sidebar_management">管理</div>
|
||
<a class="sidebar-link" onclick="navigate('users')" data-page="users"><span class="icon">👥</span> <span data-i18n="sidebar_users">用户管理</span></a>
|
||
<a class="sidebar-link" onclick="navigate('content')" data-page="content"><span class="icon">📦</span> <span data-i18n="sidebar_content">内容管理</span></a>
|
||
<div class="sidebar-section" data-i18n="sidebar_system">系统</div>
|
||
<a class="sidebar-link" onclick="navigate('accesslog')" data-page="accesslog"><span class="icon">📊</span> <span data-i18n="sidebar_accesslog">访问日志</span></a>
|
||
<a class="sidebar-link" onclick="navigate('webhook')" data-page="webhook"><span class="icon">🔔</span> <span data-i18n="sidebar_webhook">Webhook配置</span></a>
|
||
<a class="sidebar-link" onclick="navigate('navsettings')" data-page="navsettings"><span class="icon">📝</span> <span data-i18n="sidebar_navsettings">导航页设置</span></a>
|
||
<a class="sidebar-link" onclick="navigate('appshowcase')" data-page="appshowcase"><span class="icon">📱</span> <span data-i18n="sidebar_appshowcase">App候选码</span></a>
|
||
<a class="sidebar-link" onclick="navigate('bizsystems')" data-page="bizsystems"><span class="icon">💼</span> <span data-i18n="sidebar_bizsystems">业务系统</span></a>
|
||
<a class="sidebar-link" onclick="navigate('settings')" data-page="settings"><span class="icon">⚙️</span> <span data-i18n="sidebar_settings">系统设置</span></a>
|
||
<a class="sidebar-link" onclick="navigate('sites')" data-page="sites"><span class="icon">🌐</span> <span data-i18n="sidebar_sites">站点导航</span></a>
|
||
<a class="sidebar-link" onclick="doLogout()"><span class="icon">🚪</span> <span data-i18n="sidebar_logout">退出登录</span></a>
|
||
</nav>
|
||
</aside>
|
||
<div class="main">
|
||
<header class="topbar">
|
||
<div class="topbar-left">
|
||
<button class="sidebar-toggle" onclick="toggleSidebar()">☰</button>
|
||
<h3 id="pageTitle" data-i18n="sidebar_content">内容管理</h3>
|
||
</div>
|
||
<div class="topbar-right">
|
||
<div class="lang-switcher" id="langSwitcher">
|
||
<button class="lang-btn" id="langBtn" onclick="toggleLangDropdown()">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:16px;height:16px"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15 15 0 0 1 0 20 15 15 0 0 1 0-20z"/></svg>
|
||
<span id="langLabel">中文</span>
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:12px;height:12px"><polyline points="6 9 12 15 18 9"/></svg>
|
||
</button>
|
||
<div class="lang-dropdown" id="langDropdown">
|
||
<div class="lang-option" data-lang="zh-CN" onclick="switchLang('zh-CN')"><svg class="check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg><span>简体中文</span></div>
|
||
<div class="lang-option" data-lang="zh-TW" onclick="switchLang('zh-TW')"><svg class="check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg><span>繁體中文</span></div>
|
||
<div class="lang-option" data-lang="en" onclick="switchLang('en')"><svg class="check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg><span>English</span></div>
|
||
</div>
|
||
</div>
|
||
<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" data-i18n="role_admin">管理员</div></div>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
<div class="content" id="content"><div class="loading"><div class="spinner"></div></div></div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-overlay" id="modalOverlay">
|
||
<div class="modal">
|
||
<div class="modal-header"><h3 id="modalTitle" data-i18n="modal_edit">编辑</h3><button class="modal-close" onclick="closeModal()">×</button></div>
|
||
<div class="modal-body" id="modalBody"></div>
|
||
<div class="modal-footer"><button class="btn btn-outline" onclick="closeModal()" data-i18n="cancel">取消</button><button class="btn btn-primary" id="modalSaveBtn" onclick="saveModal()" data-i18n="save">保存</button></div>
|
||
</div>
|
||
</div>
|
||
<div class="toast-container" id="toastContainer"></div>
|
||
|
||
<script>
|
||
/* =========================================================
|
||
i18n 国际化引擎
|
||
========================================================= */
|
||
const I18N_LOCALES = {
|
||
'zh-CN': {
|
||
page_title:'管理后台 - Gotest Admin',login_title:'管理后台',login_subtitle:'Gotest Admin Dashboard',
|
||
login_username:'用户名',login_username_placeholder:'请输入用户名',login_password:'密码',login_password_placeholder:'请输入密码',
|
||
login_btn:'登 录',login_logging:'登录中...',login_empty_error:'请输入用户名和密码',login_failed:'登录失败',
|
||
login_no_permission:'该账号无管理员权限',login_network_error:'网络错误,请重试',
|
||
init_admin_username:'管理员用户名',init_admin_username_placeholder:'请设置管理员用户名',
|
||
init_admin_password:'管理员密码',init_admin_password_placeholder:'请设置密码(至少6位)',
|
||
init_confirm_password:'确认密码',init_confirm_placeholder:'请再次输入密码',
|
||
init_btn:'初始化管理员',init_initializing:'初始化中...',init_empty_error:'请输入用户名和密码',
|
||
init_password_short:'密码至少6位',init_password_mismatch:'两次密码不一致',
|
||
init_success:'初始化成功,请登录',init_subtitle_after:'请输入刚创建的管理员账号',init_failed:'初始化失败',
|
||
init_title:'🚀 初始化系统',init_subtitle:'首次使用,请创建管理员账号',role_admin:'管理员',
|
||
sidebar_management:'管理',sidebar_users:'用户管理',sidebar_content:'内容管理',sidebar_system:'系统',
|
||
sidebar_accesslog:'访问日志',sidebar_webhook:'Webhook配置',sidebar_navsettings:'导航页设置',
|
||
sidebar_appshowcase:'App候选码',sidebar_bizsystems:'业务系统',sidebar_settings:'系统设置',
|
||
sidebar_sites:'站点导航',sidebar_logout:'退出登录',
|
||
modal_edit:'编辑',modal_add:'新增',cancel:'取消',save:'保存',search:'搜索',
|
||
search_placeholder_users:'搜索用户名...',search_placeholder_content:'搜索名称...',
|
||
new_user:'+ 新增用户',edit_user:'编辑用户',add_user:'新增用户',
|
||
table_id:'ID',table_username:'用户名',table_phone:'手机号',table_role:'角色',
|
||
table_status:'状态',table_created:'注册时间',table_actions:'操作',
|
||
edit:'编辑',delete:'删除',no_data:'暂无数据',
|
||
admin_role:'管理员',normal_user:'普通用户',status_active:'正常',status_inactive:'禁用',
|
||
user_modal_username:'用户名',user_modal_password:'密码',user_modal_password_hint:'(留空不修改)',
|
||
user_modal_password_placeholder:'留空不修改',user_modal_password_placeholder_new:'请输入密码',
|
||
user_modal_phone:'手机号',user_modal_role:'角色',user_modal_status:'状态',
|
||
update_success:'更新成功',create_success:'创建成功',delete_success:'删除成功',
|
||
user_empty_error:'用户名和密码不能为空',confirm_delete_user:'确定删除该用户?',confirm_delete:'确定删除?',
|
||
loading_error:'加载失败',
|
||
content_enterprise:'企业官网',content_shop:'跨境电商',content_food:'外卖平台',
|
||
content_game:'游戏平台',content_app:'应用商店',content_music:'音乐平台',
|
||
content_enterprise_company:'企业信息',content_enterprise_news:'企业新闻',
|
||
content_enterprise_services:'企业服务',content_enterprise_team:'团队成员',
|
||
content_shop_products:'商品',content_shop_categories:'商品分类',
|
||
content_food_restaurants:'餐厅',content_food_categories:'餐厅分类',
|
||
content_food_menu_items:'菜品',content_food_menu_categories:'菜品分类',
|
||
content_game_games:'游戏',content_game_categories:'游戏分类',
|
||
content_app_apps:'应用',content_app_categories:'应用分类',
|
||
content_music_songs:'歌曲',content_music_categories:'音乐分类',
|
||
field_name:'名称',field_title:'标题',field_description:'描述',field_price:'价格',
|
||
field_original_price:'原价',field_image:'图片',field_images:'图片组',
|
||
field_category_id:'分类ID',field_category_name:'分类名',field_stock:'库存',
|
||
field_rating:'评分',field_sales:'销量',field_tags:'标签',field_sort:'排序',
|
||
field_created_at:'创建时间',field_summary:'摘要',field_content:'内容',field_author:'作者',
|
||
field_category:'分类',field_icon:'图标',field_logo:'Logo',field_slogan:'口号',
|
||
field_vision:'愿景',field_mission:'使命',field_email:'邮箱',field_address:'地址',
|
||
field_website:'网址',field_founded_year:'成立年份',field_employee_count:'员工数',
|
||
field_stats:'统计',field_cover:'封面',field_delivery_fee:'配送费',field_min_order:'起送价',
|
||
field_delivery_time:'配送时间',field_distance:'距离',field_notice:'公告',
|
||
field_restaurant_id:'餐厅ID',field_developer:'开发商',field_publisher:'发行商',
|
||
field_plays:'游玩次数',field_downloads:'下载量',field_size:'大小',field_version:'版本',
|
||
field_release_date:'发行日期',field_platform:'平台',field_screenshots:'截图',
|
||
field_features:'特性',field_is_hot:'热门',field_is_new:'最新',field_is_featured:'精选',
|
||
field_artist:'歌手',field_album:'专辑',field_duration:'时长',field_play_count:'播放量',
|
||
field_like_count:'收藏量',field_lyrics:'歌词',field_language:'语种',field_reviews:'评价数',
|
||
field_update_date:'更新日期',field_position:'职位',field_avatar:'头像',field_bio:'简介',
|
||
bool_true:'是',bool_false:'否',
|
||
upload_placeholder:'输入URL或点击上传',upload_btn:'📤 上传',
|
||
upload_format_hint:'支持格式: JPG · PNG · GIF · WebP · SVG · MP4 · PDF,最大 10MB',
|
||
upload_success:'上传成功',upload_failed:'上传失败',upload_network_error:'网络错误',
|
||
upload_click_or_drag:'点击或拖拽文件到此处上传',upload_max_size:'单个文件最大 10MB · 可多选',
|
||
upload_uploaded_files:'📋 已上传文件',upload_no_files:'暂无文件,请上传',
|
||
upload_progress:'上传中...',upload_complete:'上传完成',upload_progress_text:'上传中',
|
||
copy_link:'复制链接',link_copied:'链接已复制',confirm_delete_file:'确定删除该文件?',
|
||
settings_service_status:'服务状态',settings_database:'数据库',settings_env:'运行环境',
|
||
settings_server_time:'服务器时间',settings_system_info:'📋 系统信息',
|
||
settings_system_name:'系统名称',settings_system_name_val:'Gotest 多站点管理系统',
|
||
settings_tech_stack:'技术栈',settings_tech_stack_val:'Go + Gin + GORM + SQLite',
|
||
settings_templates:'前端模板',settings_templates_val:'18 套(6 大业务线 × 3 风格)',
|
||
settings_api_version:'API 版本',settings_api_version_val:'v1',
|
||
settings_auth_method:'认证方式',settings_auth_method_val:'JWT Bearer Token',
|
||
settings_admin_version:'管理后台版本',settings_admin_version_val:'v1.0',
|
||
sites_enterprise:'企业官网',sites_shop:'跨境电商',sites_food:'外卖平台',
|
||
sites_game:'游戏平台',sites_app:'应用商店',sites_music:'音乐平台',
|
||
accesslog_total:'总访问量',accesslog_today:'今日访问',accesslog_unique_ip:'独立IP',
|
||
accesslog_hourly_trend:'📊 最近24小时访问趋势',accesslog_records:'📋 访问记录',
|
||
accesslog_clear:'清空日志',accesslog_time:'时间',accesslog_ip:'IP',
|
||
accesslog_location:'归属地',accesslog_path:'路径',accesslog_method:'方法',
|
||
accesslog_status:'状态',accesslog_cost:'耗时',accesslog_no_records:'暂无访问记录',
|
||
confirm_clear_logs:'确定清空所有访问日志?',clear_success:'清空成功',
|
||
webhook_title:'🔔 企业微信 Webhook 配置',webhook_url_label:'Webhook URL',
|
||
webhook_url_placeholder:'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=...',
|
||
webhook_url_hint:'企业微信群机器人的 Webhook 地址,用于接收客户咨询通知和系统告警',
|
||
webhook_save:'保存配置',webhook_test:'测试发送',webhook_usage_title:'📖 使用说明',
|
||
webhook_step1:'在企业微信群中添加一个<strong>群机器人</strong>',
|
||
webhook_step2:'点击机器人头像,复制 <strong>Webhook 地址</strong>',
|
||
webhook_step3:'将地址粘贴到上方输入框并保存',
|
||
webhook_step4:'点击「测试发送」验证配置是否正确',
|
||
webhook_step5:'配置成功后,客户咨询通知将自动推送到该群',
|
||
webhook_test_sending:'发送中...',webhook_test_success:'✅ 测试消息已发送,请检查企业微信群',
|
||
webhook_test_failed:'❌ 测试发送失败',webhook_remark:'企业微信群机器人Webhook地址',
|
||
navsettings_title:'📝 导航页文案设置',navsettings_page_title:'页面标题 (左侧)',
|
||
navsettings_page_highlight:'页面标题 (右侧渐变)',navsettings_subtitle:'副标题描述',
|
||
navsettings_pill_templates:'标签 - 模板数',navsettings_pill_lines:'标签 - 行业数',
|
||
navsettings_pill_per_industry:'标签 - 每行业模板数',navsettings_pill_responsive:'标签 - 第四项',
|
||
navsettings_category_title:'📂 分类标题',
|
||
navsettings_enterprise_title:'企业官网 - 标题',navsettings_enterprise_sub:'企业官网 - 副标题',
|
||
navsettings_shop_title:'跨境电商 - 标题',navsettings_shop_sub:'跨境电商 - 副标题',
|
||
navsettings_food_title:'外卖平台 - 标题',navsettings_food_sub:'外卖平台 - 副标题',
|
||
navsettings_game_title:'游戏平台 - 标题',navsettings_game_sub:'游戏平台 - 副标题',
|
||
navsettings_app_title:'应用商店 - 标题',navsettings_app_sub:'应用商店 - 副标题',
|
||
navsettings_music_title:'音乐平台 - 标题',navsettings_music_sub:'音乐平台 - 副标题',
|
||
navsettings_appshow_title:'App候选码 - 标题',navsettings_appshow_sub:'App候选码 - 副标题',
|
||
navsettings_bizsys_title:'业务系统 - 标题',navsettings_bizsys_sub:'业务系统 - 副标题',
|
||
navsettings_consult_title_label:'咨询区 - 标题',navsettings_consult_desc_label:'咨询区 - 描述',
|
||
navsettings_consult_success_label:'提交成功文案',navsettings_footer_label:'页脚文案',
|
||
navsettings_company_label:'工作室/公司名称',navsettings_save:'保存所有设置',
|
||
navsettings_save_success:'保存成功!刷新导航页即可看到效果',navsettings_save_failed:'保存失败',
|
||
appshow_title:'📱 App 候选码管理',appshow_add:'+ 新增App',
|
||
appshow_category:'分类',appshow_platform:'平台',appshow_version:'版本',
|
||
appshow_rating:'评分',appshow_status:'状态',appshow_listed:'上架',appshow_unlisted:'下架',
|
||
appshow_edit:'编辑App',appshow_add_title:'新增App',appshow_name_label:'App名称 *',
|
||
appshow_category_placeholder:'工具/社交/教育/生活/娱乐',appshow_platform_placeholder:'Android/iOS/跨平台',
|
||
appshow_desc_label:'简介',appshow_icon_label:'图标URL',appshow_download_label:'下载链接',
|
||
appshow_video_label:'录屏视频URL',appshow_size_label:'包大小',appshow_rating_label:'评分',
|
||
appshow_sort_label:'排序',appshow_status_label:'状态',
|
||
bizsys_title:'💼 业务系统管理',bizsys_add:'+ 新增系统',
|
||
bizsys_category:'分类',bizsys_price_type:'价格类型',bizsys_price:'价格',
|
||
bizsys_online:'上线',bizsys_offline:'下线',bizsys_edit:'编辑系统',bizsys_add_title:'新增系统',
|
||
bizsys_name_label:'系统名称 *',bizsys_category_placeholder:'进销存/OA/财务/工单/ERP/CRM',
|
||
bizsys_intro_label:'一句话介绍',bizsys_detail_label:'详细说明',
|
||
bizsys_price_type_yearly:'按年',bizsys_price_type_monthly:'按月',
|
||
bizsys_price_type_onetime:'一次性',bizsys_price_type_custom:'定制开发',
|
||
bizsys_price_desc_label:'价格描述',bizsys_price_placeholder:'如 3000元/年',
|
||
bizsys_demo_label:'演示链接',bizsys_sort_label:'排序',bizsys_status_label:'状态',
|
||
confirm_delete_app:'确定删除?',confirm_delete_bizsys:'确定删除?',
|
||
pagination_prev:'上一页',pagination_next:'下一页',consult_section_title:'💬 咨询区 & 页脚'
|
||
},
|
||
'en': {
|
||
page_title:'Admin Dashboard - Gotest Admin',login_title:'Admin Dashboard',login_subtitle:'Gotest Admin Dashboard',
|
||
login_username:'Username',login_username_placeholder:'Enter username',login_password:'Password',login_password_placeholder:'Enter password',
|
||
login_btn:'Sign In',login_logging:'Signing in...',login_empty_error:'Please enter username and password',login_failed:'Login failed',
|
||
login_no_permission:'No admin permission',login_network_error:'Network error, please try again',
|
||
init_admin_username:'Admin Username',init_admin_username_placeholder:'Set admin username',
|
||
init_admin_password:'Admin Password',init_admin_password_placeholder:'Set password (min 6 chars)',
|
||
init_confirm_password:'Confirm Password',init_confirm_placeholder:'Enter password again',
|
||
init_btn:'Initialize Admin',init_initializing:'Initializing...',init_empty_error:'Please enter username and password',
|
||
init_password_short:'Password must be at least 6 characters',init_password_mismatch:'Passwords do not match',
|
||
init_success:'Initialized successfully, please login',init_subtitle_after:'Please login with the admin account you just created',init_failed:'Initialization failed',
|
||
init_title:'🚀 Initialize System',init_subtitle:'First use, please create admin account',role_admin:'Admin',
|
||
sidebar_management:'MANAGEMENT',sidebar_users:'Users',sidebar_content:'Content',sidebar_system:'SYSTEM',
|
||
sidebar_accesslog:'Access Logs',sidebar_webhook:'Webhook',sidebar_navsettings:'Nav Settings',
|
||
sidebar_appshowcase:'App Showcase',sidebar_bizsystems:'Biz Systems',sidebar_settings:'Settings',
|
||
sidebar_sites:'Site Nav',sidebar_logout:'Logout',
|
||
modal_edit:'Edit',modal_add:'Add',cancel:'Cancel',save:'Save',search:'Search',
|
||
search_placeholder_users:'Search username...',search_placeholder_content:'Search name...',
|
||
new_user:'+ New User',edit_user:'Edit User',add_user:'Add User',
|
||
table_id:'ID',table_username:'Username',table_phone:'Phone',table_role:'Role',
|
||
table_status:'Status',table_created:'Created',table_actions:'Actions',
|
||
edit:'Edit',delete:'Delete',no_data:'No data',
|
||
admin_role:'Admin',normal_user:'User',status_active:'Active',status_inactive:'Disabled',
|
||
user_modal_username:'Username',user_modal_password:'Password',user_modal_password_hint:'(leave blank to keep)',
|
||
user_modal_password_placeholder:'Leave blank to keep',user_modal_password_placeholder_new:'Enter password',
|
||
user_modal_phone:'Phone',user_modal_role:'Role',user_modal_status:'Status',
|
||
update_success:'Updated successfully',create_success:'Created successfully',delete_success:'Deleted successfully',
|
||
user_empty_error:'Username and password cannot be empty',confirm_delete_user:'Delete this user?',confirm_delete:'Delete this item?',
|
||
loading_error:'Failed to load',
|
||
content_enterprise:'Enterprise',content_shop:'E-Commerce',content_food:'Food Delivery',
|
||
content_game:'Gaming',content_app:'App Store',content_music:'Music',
|
||
content_enterprise_company:'Company Info',content_enterprise_news:'Company News',
|
||
content_enterprise_services:'Services',content_enterprise_team:'Team',
|
||
content_shop_products:'Products',content_shop_categories:'Categories',
|
||
content_food_restaurants:'Restaurants',content_food_categories:'Categories',
|
||
content_food_menu_items:'Menu Items',content_food_menu_categories:'Menu Categories',
|
||
content_game_games:'Games',content_game_categories:'Categories',
|
||
content_app_apps:'Apps',content_app_categories:'Categories',
|
||
content_music_songs:'Songs',content_music_categories:'Categories',
|
||
field_name:'Name',field_title:'Title',field_description:'Description',field_price:'Price',
|
||
field_original_price:'Original Price',field_image:'Image',field_images:'Images',
|
||
field_category_id:'Category ID',field_category_name:'Category Name',field_stock:'Stock',
|
||
field_rating:'Rating',field_sales:'Sales',field_tags:'Tags',field_sort:'Sort',
|
||
field_created_at:'Created At',field_summary:'Summary',field_content:'Content',field_author:'Author',
|
||
field_category:'Category',field_icon:'Icon',field_logo:'Logo',field_slogan:'Slogan',
|
||
field_vision:'Vision',field_mission:'Mission',field_email:'Email',field_address:'Address',
|
||
field_website:'Website',field_founded_year:'Founded Year',field_employee_count:'Employees',
|
||
field_stats:'Stats',field_cover:'Cover',field_delivery_fee:'Delivery Fee',field_min_order:'Min Order',
|
||
field_delivery_time:'Delivery Time',field_distance:'Distance',field_notice:'Notice',
|
||
field_restaurant_id:'Restaurant ID',field_developer:'Developer',field_publisher:'Publisher',
|
||
field_plays:'Plays',field_downloads:'Downloads',field_size:'Size',field_version:'Version',
|
||
field_release_date:'Release Date',field_platform:'Platform',field_screenshots:'Screenshots',
|
||
field_features:'Features',field_is_hot:'Hot',field_is_new:'New',field_is_featured:'Featured',
|
||
field_artist:'Artist',field_album:'Album',field_duration:'Duration',field_play_count:'Play Count',
|
||
field_like_count:'Like Count',field_lyrics:'Lyrics',field_language:'Language',field_reviews:'Reviews',
|
||
field_update_date:'Update Date',field_position:'Position',field_avatar:'Avatar',field_bio:'Bio',
|
||
bool_true:'Yes',bool_false:'No',
|
||
upload_placeholder:'Enter URL or click to upload',upload_btn:'📤 Upload',
|
||
upload_format_hint:'Formats: JPG · PNG · GIF · WebP · SVG · MP4 · PDF, max 10MB',
|
||
upload_success:'Upload success',upload_failed:'Upload failed',upload_network_error:'Network error',
|
||
upload_click_or_drag:'Click or drag files here to upload',upload_max_size:'Max 10MB per file · Multiple files allowed',
|
||
upload_uploaded_files:'📋 Uploaded Files',upload_no_files:'No files uploaded yet',
|
||
upload_progress:'Uploading...',upload_complete:'Upload complete',upload_progress_text:'Uploading',
|
||
copy_link:'Copy link',link_copied:'Link copied',confirm_delete_file:'Delete this file?',
|
||
settings_service_status:'Service Status',settings_database:'Database',settings_env:'Environment',
|
||
settings_server_time:'Server Time',settings_system_info:'📋 System Info',
|
||
settings_system_name:'System Name',settings_system_name_val:'Gotest Multi-Site Management System',
|
||
settings_tech_stack:'Tech Stack',settings_tech_stack_val:'Go + Gin + GORM + SQLite',
|
||
settings_templates:'Templates',settings_templates_val:'18 (6 industries × 3 styles)',
|
||
settings_api_version:'API Version',settings_api_version_val:'v1',
|
||
settings_auth_method:'Auth Method',settings_auth_method_val:'JWT Bearer Token',
|
||
settings_admin_version:'Admin Version',settings_admin_version_val:'v1.0',
|
||
sites_enterprise:'Enterprise',sites_shop:'E-Commerce',sites_food:'Food Delivery',
|
||
sites_game:'Gaming',sites_app:'App Store',sites_music:'Music',
|
||
accesslog_total:'Total Visits',accesslog_today:'Today',accesslog_unique_ip:'Unique IPs',
|
||
accesslog_hourly_trend:'📊 Last 24h Visit Trend',accesslog_records:'📋 Access Records',
|
||
accesslog_clear:'Clear Logs',accesslog_time:'Time',accesslog_ip:'IP',
|
||
accesslog_location:'Location',accesslog_path:'Path',accesslog_method:'Method',
|
||
accesslog_status:'Status',accesslog_cost:'Duration',accesslog_no_records:'No access records',
|
||
confirm_clear_logs:'Clear all access logs?',clear_success:'Cleared successfully',
|
||
webhook_title:'🔔 WeChat Work Webhook Config',webhook_url_label:'Webhook URL',
|
||
webhook_url_placeholder:'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=...',
|
||
webhook_url_hint:'WeChat Work group bot webhook URL for receiving notifications and alerts',
|
||
webhook_save:'Save Config',webhook_test:'Test Send',webhook_usage_title:'📖 Instructions',
|
||
webhook_step1:'Add a <strong>group bot</strong> in your WeChat Work group',
|
||
webhook_step2:'Click bot avatar, copy <strong>Webhook URL</strong>',
|
||
webhook_step3:'Paste the URL into the input field above and save',
|
||
webhook_step4:'Click "Test Send" to verify configuration',
|
||
webhook_step5:'After setup, notifications will be pushed to the group',
|
||
webhook_test_sending:'Sending...',webhook_test_success:'✅ Test message sent, check WeChat Work group',
|
||
webhook_test_failed:'❌ Test send failed',webhook_remark:'WeChat Work group bot webhook URL',
|
||
navsettings_title:'📝 Nav Page Settings',navsettings_page_title:'Page Title (Left)',
|
||
navsettings_page_highlight:'Page Title (Right Gradient)',navsettings_subtitle:'Subtitle Description',
|
||
navsettings_pill_templates:'Pill - Templates',navsettings_pill_lines:'Pill - Industries',
|
||
navsettings_pill_per_industry:'Pill - Per Industry',navsettings_pill_responsive:'Pill - Fourth Item',
|
||
navsettings_category_title:'📂 Category Titles',
|
||
navsettings_enterprise_title:'Enterprise - Title',navsettings_enterprise_sub:'Enterprise - Subtitle',
|
||
navsettings_shop_title:'E-Commerce - Title',navsettings_shop_sub:'E-Commerce - Subtitle',
|
||
navsettings_food_title:'Food Delivery - Title',navsettings_food_sub:'Food Delivery - Subtitle',
|
||
navsettings_game_title:'Gaming - Title',navsettings_game_sub:'Gaming - Subtitle',
|
||
navsettings_app_title:'App Store - Title',navsettings_app_sub:'App Store - Subtitle',
|
||
navsettings_music_title:'Music - Title',navsettings_music_sub:'Music - Subtitle',
|
||
navsettings_appshow_title:'App Showcase - Title',navsettings_appshow_sub:'App Showcase - Subtitle',
|
||
navsettings_bizsys_title:'Biz Systems - Title',navsettings_bizsys_sub:'Biz Systems - Subtitle',
|
||
navsettings_consult_title_label:'Consult - Title',navsettings_consult_desc_label:'Consult - Description',
|
||
navsettings_consult_success_label:'Submit Success Text',navsettings_footer_label:'Footer Text',
|
||
navsettings_company_label:'Company Name',navsettings_save:'Save All Settings',
|
||
navsettings_save_success:'Saved! Refresh nav page to see changes',navsettings_save_failed:'Save failed',
|
||
appshow_title:'📱 App Showcase Management',appshow_add:'+ New App',
|
||
appshow_category:'Category',appshow_platform:'Platform',appshow_version:'Version',
|
||
appshow_rating:'Rating',appshow_status:'Status',appshow_listed:'Listed',appshow_unlisted:'Unlisted',
|
||
appshow_edit:'Edit App',appshow_add_title:'New App',appshow_name_label:'App Name *',
|
||
appshow_category_placeholder:'Tool/Social/Education/Lifestyle/Entertainment',appshow_platform_placeholder:'Android/iOS/Cross-platform',
|
||
appshow_desc_label:'Description',appshow_icon_label:'Icon URL',appshow_download_label:'Download URL',
|
||
appshow_video_label:'Video URL',appshow_size_label:'Package Size',appshow_rating_label:'Rating',
|
||
appshow_sort_label:'Sort',appshow_status_label:'Status',
|
||
bizsys_title:'💼 Biz System Management',bizsys_add:'+ New System',
|
||
bizsys_category:'Category',bizsys_price_type:'Price Type',bizsys_price:'Price',
|
||
bizsys_online:'Online',bizsys_offline:'Offline',bizsys_edit:'Edit System',bizsys_add_title:'New System',
|
||
bizsys_name_label:'System Name *',bizsys_category_placeholder:'Inventory/OA/Finance/Ticket/ERP/CRM',
|
||
bizsys_intro_label:'One-line Intro',bizsys_detail_label:'Details',
|
||
bizsys_price_type_yearly:'Yearly',bizsys_price_type_monthly:'Monthly',
|
||
bizsys_price_type_onetime:'One-time',bizsys_price_type_custom:'Custom',
|
||
bizsys_price_desc_label:'Price Description',bizsys_price_placeholder:'e.g. 3000/yr',
|
||
bizsys_demo_label:'Demo URL',bizsys_sort_label:'Sort',bizsys_status_label:'Status',
|
||
confirm_delete_app:'Delete this app?',confirm_delete_bizsys:'Delete this system?',
|
||
pagination_prev:'Prev',pagination_next:'Next',consult_section_title:'💬 Consult & Footer'
|
||
},
|
||
'zh-TW': {
|
||
page_title:'管理後台 - Gotest Admin',login_title:'管理後台',login_subtitle:'Gotest Admin Dashboard',
|
||
login_username:'使用者名稱',login_username_placeholder:'請輸入使用者名稱',login_password:'密碼',login_password_placeholder:'請輸入密碼',
|
||
login_btn:'登 入',login_logging:'登入中...',login_empty_error:'請輸入使用者名稱和密碼',login_failed:'登入失敗',
|
||
login_no_permission:'該帳號無管理員權限',login_network_error:'網路錯誤,請重試',
|
||
init_admin_username:'管理員使用者名稱',init_admin_username_placeholder:'請設定管理員使用者名稱',
|
||
init_admin_password:'管理員密碼',init_admin_password_placeholder:'請設定密碼(至少6位)',
|
||
init_confirm_password:'確認密碼',init_confirm_placeholder:'請再次輸入密碼',
|
||
init_btn:'初始化管理員',init_initializing:'初始化中...',init_empty_error:'請輸入使用者名稱和密碼',
|
||
init_password_short:'密碼至少6位',init_password_mismatch:'兩次密碼不一致',
|
||
init_success:'初始化成功,請登入',init_subtitle_after:'請輸入剛建立的管理員帳號',init_failed:'初始化失敗',
|
||
init_title:'🚀 初始化系統',init_subtitle:'首次使用,請建立管理員帳號',role_admin:'管理員',
|
||
sidebar_management:'管理',sidebar_users:'使用者管理',sidebar_content:'內容管理',sidebar_system:'系統',
|
||
sidebar_accesslog:'存取日誌',sidebar_webhook:'Webhook配置',sidebar_navsettings:'導航頁設定',
|
||
sidebar_appshowcase:'App候選碼',sidebar_bizsystems:'業務系統',sidebar_settings:'系統設定',
|
||
sidebar_sites:'站點導航',sidebar_logout:'登出',
|
||
modal_edit:'編輯',modal_add:'新增',cancel:'取消',save:'儲存',search:'搜尋',
|
||
search_placeholder_users:'搜尋使用者名稱...',search_placeholder_content:'搜尋名稱...',
|
||
new_user:'+ 新增使用者',edit_user:'編輯使用者',add_user:'新增使用者',
|
||
table_id:'ID',table_username:'使用者名稱',table_phone:'手機號碼',table_role:'角色',
|
||
table_status:'狀態',table_created:'註冊時間',table_actions:'操作',
|
||
edit:'編輯',delete:'刪除',no_data:'暫無資料',
|
||
admin_role:'管理員',normal_user:'一般使用者',status_active:'正常',status_inactive:'停用',
|
||
user_modal_username:'使用者名稱',user_modal_password:'密碼',user_modal_password_hint:'(留空不修改)',
|
||
user_modal_password_placeholder:'留空不修改',user_modal_password_placeholder_new:'請輸入密碼',
|
||
user_modal_phone:'手機號碼',user_modal_role:'角色',user_modal_status:'狀態',
|
||
update_success:'更新成功',create_success:'建立成功',delete_success:'刪除成功',
|
||
user_empty_error:'使用者名稱和密碼不能為空',confirm_delete_user:'確定刪除該使用者?',confirm_delete:'確定刪除?',
|
||
loading_error:'載入失敗',
|
||
content_enterprise:'企業官網',content_shop:'跨境電商',content_food:'外送平台',
|
||
content_game:'遊戲平台',content_app:'應用商店',content_music:'音樂平台',
|
||
content_enterprise_company:'企業資訊',content_enterprise_news:'企業新聞',
|
||
content_enterprise_services:'企業服務',content_enterprise_team:'團隊成員',
|
||
content_shop_products:'商品',content_shop_categories:'商品分類',
|
||
content_food_restaurants:'餐廳',content_food_categories:'餐廳分類',
|
||
content_food_menu_items:'菜單項目',content_food_menu_categories:'菜單分類',
|
||
content_game_games:'遊戲',content_game_categories:'遊戲分類',
|
||
content_app_apps:'應用',content_app_categories:'應用分類',
|
||
content_music_songs:'歌曲',content_music_categories:'音樂分類',
|
||
field_name:'名稱',field_title:'標題',field_description:'描述',field_price:'價格',
|
||
field_original_price:'原價',field_image:'圖片',field_images:'圖片組',
|
||
field_category_id:'分類ID',field_category_name:'分類名',field_stock:'庫存',
|
||
field_rating:'評分',field_sales:'銷量',field_tags:'標籤',field_sort:'排序',
|
||
field_created_at:'建立時間',field_summary:'摘要',field_content:'內容',field_author:'作者',
|
||
field_category:'分類',field_icon:'圖示',field_logo:'Logo',field_slogan:'口號',
|
||
field_vision:'願景',field_mission:'使命',field_email:'電子郵件',field_address:'地址',
|
||
field_website:'網址',field_founded_year:'成立年份',field_employee_count:'員工人數',
|
||
field_stats:'統計',field_cover:'封面',field_delivery_fee:'外送費',field_min_order:'起送價',
|
||
field_delivery_time:'外送時間',field_distance:'距離',field_notice:'公告',
|
||
field_restaurant_id:'餐廳ID',field_developer:'開發商',field_publisher:'發行商',
|
||
field_plays:'遊玩次數',field_downloads:'下載量',field_size:'大小',field_version:'版本',
|
||
field_release_date:'發行日期',field_platform:'平台',field_screenshots:'截圖',
|
||
field_features:'特性',field_is_hot:'熱門',field_is_new:'最新',field_is_featured:'精選',
|
||
field_artist:'歌手',field_album:'專輯',field_duration:'時長',field_play_count:'播放量',
|
||
field_like_count:'收藏量',field_lyrics:'歌詞',field_language:'語種',field_reviews:'評價數',
|
||
field_update_date:'更新日期',field_position:'職位',field_avatar:'頭像',field_bio:'簡介',
|
||
bool_true:'是',bool_false:'否',
|
||
upload_placeholder:'輸入網址或點擊上傳',upload_btn:'📤 上傳',
|
||
upload_format_hint:'支援格式: JPG · PNG · GIF · WebP · SVG · MP4 · PDF,最大 10MB',
|
||
upload_success:'上傳成功',upload_failed:'上傳失敗',upload_network_error:'網路錯誤',
|
||
upload_click_or_drag:'點擊或拖曳檔案到此處上傳',upload_max_size:'單檔最大 10MB · 可多選',
|
||
upload_uploaded_files:'📋 已上傳檔案',upload_no_files:'暫無檔案,請上傳',
|
||
upload_progress:'上傳中...',upload_complete:'上傳完成',upload_progress_text:'上傳中',
|
||
copy_link:'複製連結',link_copied:'連結已複製',confirm_delete_file:'確定刪除該檔案?',
|
||
settings_service_status:'服務狀態',settings_database:'資料庫',settings_env:'執行環境',
|
||
settings_server_time:'伺服器時間',settings_system_info:'📋 系統資訊',
|
||
settings_system_name:'系統名稱',settings_system_name_val:'Gotest 多站點管理系統',
|
||
settings_tech_stack:'技術棧',settings_tech_stack_val:'Go + Gin + GORM + SQLite',
|
||
settings_templates:'前端模板',settings_templates_val:'18 套(6 大業務線 × 3 風格)',
|
||
settings_api_version:'API 版本',settings_api_version_val:'v1',
|
||
settings_auth_method:'認證方式',settings_auth_method_val:'JWT Bearer Token',
|
||
settings_admin_version:'管理後台版本',settings_admin_version_val:'v1.0',
|
||
sites_enterprise:'企業官網',sites_shop:'跨境電商',sites_food:'外送平台',
|
||
sites_game:'遊戲平台',sites_app:'應用商店',sites_music:'音樂平台',
|
||
accesslog_total:'總存取量',accesslog_today:'今日存取',accesslog_unique_ip:'獨立IP',
|
||
accesslog_hourly_trend:'📊 最近24小時存取趨勢',accesslog_records:'📋 存取記錄',
|
||
accesslog_clear:'清空日誌',accesslog_time:'時間',accesslog_ip:'IP',
|
||
accesslog_location:'歸屬地',accesslog_path:'路徑',accesslog_method:'方法',
|
||
accesslog_status:'狀態',accesslog_cost:'耗時',accesslog_no_records:'暫無存取記錄',
|
||
confirm_clear_logs:'確定清空所有存取日誌?',clear_success:'清空成功',
|
||
webhook_title:'🔔 企業微信 Webhook 配置',webhook_url_label:'Webhook URL',
|
||
webhook_url_placeholder:'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=...',
|
||
webhook_url_hint:'企業微信群機器人的 Webhook 位址,用於接收客戶諮詢通知和系統告警',
|
||
webhook_save:'儲存設定',webhook_test:'測試發送',webhook_usage_title:'📖 使用說明',
|
||
webhook_step1:'在企業微信群中新增一個<strong>群機器人</strong>',
|
||
webhook_step2:'點擊機器人頭像,複製 <strong>Webhook 位址</strong>',
|
||
webhook_step3:'將位址貼到上方輸入框並儲存',
|
||
webhook_step4:'點擊「測試發送」驗證設定是否正確',
|
||
webhook_step5:'設定成功後,客戶諮詢通知將自動推送到該群',
|
||
webhook_test_sending:'發送中...',webhook_test_success:'✅ 測試訊息已發送,請檢查企業微信群',
|
||
webhook_test_failed:'❌ 測試發送失敗',webhook_remark:'企業微信群機器人Webhook位址',
|
||
navsettings_title:'📝 導航頁文案設定',navsettings_page_title:'頁面標題(左側)',
|
||
navsettings_page_highlight:'頁面標題(右側漸變)',navsettings_subtitle:'副標題描述',
|
||
navsettings_pill_templates:'標籤 - 模板數',navsettings_pill_lines:'標籤 - 業界數',
|
||
navsettings_pill_per_industry:'標籤 - 每業界模板數',navsettings_pill_responsive:'標籤 - 第四項',
|
||
navsettings_category_title:'📂 分類標題',
|
||
navsettings_enterprise_title:'企業官網 - 標題',navsettings_enterprise_sub:'企業官網 - 副標題',
|
||
navsettings_shop_title:'跨境電商 - 標題',navsettings_shop_sub:'跨境電商 - 副標題',
|
||
navsettings_food_title:'外送平台 - 標題',navsettings_food_sub:'外送平台 - 副標題',
|
||
navsettings_game_title:'遊戲平台 - 標題',navsettings_game_sub:'遊戲平台 - 副標題',
|
||
navsettings_app_title:'應用商店 - 標題',navsettings_app_sub:'應用商店 - 副標題',
|
||
navsettings_music_title:'音樂平台 - 標題',navsettings_music_sub:'音樂平台 - 副標題',
|
||
navsettings_appshow_title:'App候選碼 - 標題',navsettings_appshow_sub:'App候選碼 - 副標題',
|
||
navsettings_bizsys_title:'業務系統 - 標題',navsettings_bizsys_sub:'業務系統 - 副標題',
|
||
navsettings_consult_title_label:'諮詢區 - 標題',navsettings_consult_desc_label:'諮詢區 - 描述',
|
||
navsettings_consult_success_label:'提交成功文案',navsettings_footer_label:'頁尾文案',
|
||
navsettings_company_label:'工作室/公司名稱',navsettings_save:'儲存所有設定',
|
||
navsettings_save_success:'儲存成功!重新整理導航頁即可看到效果',navsettings_save_failed:'儲存失敗',
|
||
appshow_title:'📱 App 候選碼管理',appshow_add:'+ 新增App',
|
||
appshow_category:'分類',appshow_platform:'平台',appshow_version:'版本',
|
||
appshow_rating:'評分',appshow_status:'狀態',appshow_listed:'上架',appshow_unlisted:'下架',
|
||
appshow_edit:'編輯App',appshow_add_title:'新增App',appshow_name_label:'App名稱 *',
|
||
appshow_category_placeholder:'工具/社交/教育/生活/娛樂',appshow_platform_placeholder:'Android/iOS/跨平台',
|
||
appshow_desc_label:'簡介',appshow_icon_label:'圖示URL',appshow_download_label:'下載連結',
|
||
appshow_video_label:'錄影影片URL',appshow_size_label:'套件大小',appshow_rating_label:'評分',
|
||
appshow_sort_label:'排序',appshow_status_label:'狀態',
|
||
bizsys_title:'💼 業務系統管理',bizsys_add:'+ 新增系統',
|
||
bizsys_category:'分類',bizsys_price_type:'價格類型',bizsys_price:'價格',
|
||
bizsys_online:'上線',bizsys_offline:'下線',bizsys_edit:'編輯系統',bizsys_add_title:'新增系統',
|
||
bizsys_name_label:'系統名稱 *',bizsys_category_placeholder:'進銷存/OA/財務/工單/ERP/CRM',
|
||
bizsys_intro_label:'一句話介紹',bizsys_detail_label:'詳細說明',
|
||
bizsys_price_type_yearly:'按年',bizsys_price_type_monthly:'按月',
|
||
bizsys_price_type_onetime:'一次性',bizsys_price_type_custom:'客製開發',
|
||
bizsys_price_desc_label:'價格描述',bizsys_price_placeholder:'如 3000元/年',
|
||
bizsys_demo_label:'演示連結',bizsys_sort_label:'排序',bizsys_status_label:'狀態',
|
||
confirm_delete_app:'確定刪除?',confirm_delete_bizsys:'確定刪除?',
|
||
pagination_prev:'上一頁',pagination_next:'下一頁',consult_section_title:'💬 諮詢區 & 頁尾'
|
||
}
|
||
};
|
||
|
||
const LANG_LABELS = {'zh-CN':'中文','en':'EN','zh-TW':'繁中'};
|
||
let currentLang = (function(){
|
||
try{var u=new URLSearchParams(location.search).get('lang');if(u&&I18N_LOCALES[u]) return u;}catch(e){}
|
||
try{var s=localStorage.getItem('lang');if(s&&I18N_LOCALES[s]) return s;}catch(e){}
|
||
var b=navigator.language||navigator.userLanguage||'';
|
||
if(b.startsWith('zh-TW')||b.startsWith('zh-Hant')) return 'zh-TW';
|
||
if(b.startsWith('zh')) return 'en';
|
||
return 'en';
|
||
})();
|
||
function t(key){return (I18N_LOCALES[currentLang]&&I18N_LOCALES[currentLang][key])||(I18N_LOCALES['zh-CN'][key])||key;}
|
||
function applyI18n(){
|
||
document.querySelectorAll('[data-i18n]').forEach(function(el){el.textContent=t(el.getAttribute('data-i18n'));});
|
||
document.querySelectorAll('[data-i18n-placeholder]').forEach(function(el){el.placeholder=t(el.getAttribute('data-i18n-placeholder'));});
|
||
document.documentElement.lang=currentLang==='zh-TW'?'zh-TW':currentLang==='zh-CN'?'zh-CN':'en';
|
||
var lbl=document.getElementById('langLabel');if(lbl) lbl.textContent=LANG_LABELS[currentLang]||currentLang;
|
||
document.querySelectorAll('.lang-option').forEach(function(opt){opt.classList.toggle('active',opt.getAttribute('data-lang')===currentLang);});
|
||
}
|
||
function switchLang(lang){if(!I18N_LOCALES[lang]) return;currentLang=lang;try{localStorage.setItem('lang',lang);}catch(e){}applyI18n();if(typeof onLangChange==='function') onLangChange(lang);document.getElementById('langDropdown').classList.remove('open');}
|
||
function toggleLangDropdown(){document.getElementById('langDropdown').classList.toggle('open');}
|
||
document.addEventListener('click',function(e){var sw=document.getElementById('langSwitcher');if(sw&&!sw.contains(e.target)) document.getElementById('langDropdown').classList.remove('open');});
|
||
|
||
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;
|
||
|
||
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 || t('loading_error')); }
|
||
return data;
|
||
} catch (e) {
|
||
if (e.message !== 'Failed to fetch') toast(e.message, 'error');
|
||
throw e;
|
||
}
|
||
}
|
||
|
||
function toast(msg, type = 'info') {
|
||
const container = document.getElementById('toastContainer');
|
||
const el = document.createElement('div');
|
||
el.className = 'toast toast-' + type;
|
||
const icon = type === 'success' ? '✅' : type === 'error' ? '❌' : 'ℹ️';
|
||
el.innerHTML = icon + ' ' + msg;
|
||
container.appendChild(el);
|
||
setTimeout(() => { el.style.opacity = '0'; el.style.transform = 'translateX(100%)'; setTimeout(() => el.remove(), 300); }, 3000);
|
||
}
|
||
|
||
async function checkAdminExists() {
|
||
try { const resp = await fetch(API_BASE + '/admin/check'); const data = await resp.json(); return data.data && data.data.initialized; } catch (e) { return false; }
|
||
}
|
||
|
||
async function doInitAdmin() {
|
||
const btn = document.getElementById('initBtn');
|
||
const username = document.getElementById('initUsername').value.trim();
|
||
const password = document.getElementById('initPassword').value;
|
||
const confirm = document.getElementById('initPasswordConfirm').value;
|
||
const errEl = document.getElementById('initError');
|
||
if (!username || !password) { errEl.textContent = t('init_empty_error'); return; }
|
||
if (password.length < 6) { errEl.textContent = t('init_password_short'); return; }
|
||
if (password !== confirm) { errEl.textContent = t('init_password_mismatch'); return; }
|
||
btn.disabled = true; btn.textContent = t('init_initializing'); errEl.textContent = '';
|
||
try {
|
||
const resp = await fetch(API_BASE + '/admin/init', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) });
|
||
const data = await resp.json();
|
||
if (data.code !== 0) { errEl.textContent = data.message || t('init_failed'); return; }
|
||
toast(t('init_success'), 'success');
|
||
document.getElementById('initForm').style.display = 'none';
|
||
document.getElementById('loginForm').style.display = 'block';
|
||
document.getElementById('loginSubtitle').textContent = t('init_subtitle_after');
|
||
} catch (e) { errEl.textContent = t('login_network_error'); } finally { btn.disabled = false; btn.textContent = t('init_btn'); }
|
||
}
|
||
|
||
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 = t('login_empty_error'); return; }
|
||
btn.disabled = true; btn.textContent = t('login_logging'); 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 || t('login_failed'); return; }
|
||
if (data.data.user.role !== 'admin') { errEl.textContent = t('login_no_permission'); 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 = t('login_network_error'); } finally { btn.disabled = false; btn.textContent = t('login_btn'); }
|
||
}
|
||
|
||
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 titleKeys = { users: 'sidebar_users', content: 'sidebar_content', settings: 'sidebar_settings', sites: 'sidebar_sites', accesslog: 'sidebar_accesslog', webhook: 'sidebar_webhook', navsettings: 'sidebar_navsettings', appshowcase: 'sidebar_appshowcase', bizsystems: 'sidebar_bizsystems' };
|
||
document.getElementById('pageTitle').textContent = t(titleKeys[page] || 'sidebar_content');
|
||
if (page === 'users') renderUsers();
|
||
else if (page === 'content') renderContent(params.table);
|
||
else if (page === 'settings') renderSettings();
|
||
else if (page === 'sites') renderSites();
|
||
else if (page === 'accesslog') renderAccessLogs();
|
||
else if (page === 'webhook') renderWebhook();
|
||
else if (page === 'navsettings') renderNavSettings();
|
||
else if (page === 'appshowcase') renderAppShowcase();
|
||
else if (page === 'bizsystems') renderBizSystems();
|
||
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="${t('search_placeholder_users')}" 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)">${t('search')}</button></div><button class="btn btn-primary btn-sm" onclick="openUserModal()">${t('new_user')}</button></div><div class="table-wrap"><table><thead><tr><th>${t('table_id')}</th><th>${t('table_username')}</th><th>${t('table_phone')}</th><th>${t('table_role')}</th><th>${t('table_status')}</th><th>${t('table_created')}</th><th>${t('table_actions')}</th></tr></thead><tbody>${d.list.length === 0 ? `<tr class="empty-row"><td colspan="7">${t('no_data')}</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'?t('admin_role'):t('normal_user')}</span></td><td><span class="badge-status ${u.status===1?'badge-active':'badge-inactive'}">${u.status===1?t('status_active'):t('status_inactive')}</span></td><td>${formatDate(u.created_at)}</td><td><button class="btn btn-outline btn-sm" onclick="openUserModal(${u.id})">${t('edit')}</button> <button class="btn btn-danger btn-sm" onclick="deleteUser(${u.id})">${t('delete')}</button></td></tr>`).join('')}</tbody></table></div>${renderPagination(d, 'renderUsers')}</div>`;
|
||
} catch (e) { el.innerHTML = `<div class="loading">${t('loading_error')}</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 ? t('edit_user') : t('add_user');
|
||
document.getElementById('modalBody').innerHTML = `<div class="form-row"><div class="form-group"><label>${t('user_modal_username')} ${!id ? '<span style="color:var(--danger)">*</span>' : ''}</label><input type="text" id="m_username" value="${user?.username||''}" ${id?'disabled':''} placeholder="${t('user_modal_username')}"></div><div class="form-group"><label>${t('user_modal_password')} ${id ? t('user_modal_password_hint') : '<span style="color:var(--danger)">*</span>'}</label><input type="password" id="m_password" placeholder="${id?t('user_modal_password_placeholder'):t('user_modal_password_placeholder_new')}"></div></div><div class="form-row"><div class="form-group"><label>${t('user_modal_phone')}</label><input type="text" id="m_phone" value="${user?.phone||''}" placeholder="${t('user_modal_phone')}"></div><div class="form-group"><label>${t('user_modal_role')}</label><select id="m_role"><option value="user" ${user?.role==='user'?'selected':''}>${t('normal_user')}</option><option value="admin" ${user?.role==='admin'?'selected':''}>${t('admin_role')}</option></select></div></div>${id ? `<div class="form-group"><label>${t('user_modal_status')}</label><select id="m_status"><option value="1" ${user?.status===1?'selected':''}>${t('status_active')}</option><option value="0" ${user?.status===0?'selected':''}>${t('status_inactive')}</option></select></div>` : ''}`;
|
||
_modalSaveFn = 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(t('update_success'), 'success');
|
||
} else {
|
||
if (!username || !password) { toast(t('user_empty_error'), 'error'); return; }
|
||
await api('/admin/users', { method: 'POST', body: JSON.stringify({ username, password, phone, role }) });
|
||
toast(t('create_success'), 'success');
|
||
}
|
||
closeModal(); renderUsers();
|
||
} catch (e) {}
|
||
}
|
||
|
||
async function deleteUser(id) {
|
||
if (!confirm(t('confirm_delete_user'))) return;
|
||
try { await api('/admin/users/' + id, { method: 'DELETE' }); toast(t('delete_success'), 'success'); renderUsers(); } catch (e) {}
|
||
}
|
||
|
||
const contentModules = [
|
||
{ groupKey: 'content_enterprise', icon: '🏢', tables: [
|
||
{ key: 'enterprise_company', labelKey: 'content_enterprise_company' },
|
||
{ key: 'enterprise_news', labelKey: 'content_enterprise_news' },
|
||
{ key: 'enterprise_services', labelKey: 'content_enterprise_services' },
|
||
{ key: 'enterprise_team', labelKey: 'content_enterprise_team' }
|
||
]},
|
||
{ groupKey: 'content_shop', icon: '🛒', tables: [
|
||
{ key: 'shop_products', labelKey: 'content_shop_products' },
|
||
{ key: 'shop_categories', labelKey: 'content_shop_categories' }
|
||
]},
|
||
{ groupKey: 'content_food', icon: '🍜', tables: [
|
||
{ key: 'food_restaurants', labelKey: 'content_food_restaurants' },
|
||
{ key: 'food_categories', labelKey: 'content_food_categories' },
|
||
{ key: 'food_menu_items', labelKey: 'content_food_menu_items' },
|
||
{ key: 'food_menu_categories', labelKey: 'content_food_menu_categories' }
|
||
]},
|
||
{ groupKey: 'content_game', icon: '🎮', tables: [
|
||
{ key: 'game_games', labelKey: 'content_game_games' },
|
||
{ key: 'game_categories', labelKey: 'content_game_categories' }
|
||
]},
|
||
{ groupKey: 'content_app', icon: '📱', tables: [
|
||
{ key: 'app_apps', labelKey: 'content_app_apps' },
|
||
{ key: 'app_categories', labelKey: 'content_app_categories' }
|
||
]},
|
||
{ groupKey: 'content_music', icon: '🎵', tables: [
|
||
{ key: 'music_songs', labelKey: 'content_music_songs' },
|
||
{ key: 'music_categories', labelKey: 'content_music_categories' }
|
||
]}
|
||
];
|
||
|
||
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(ta => ta.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>${t(mod.groupKey)}</span><span class="arrow">▶</span></div><div class="content-group-items">`;
|
||
for (const ta of mod.tables) {
|
||
sidebarHtml += `<div class="content-item ${ta.key===currentTable?'active':''}" onclick="navigate('content',{table:'${ta.key}'})">${t(ta.labelKey)}</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);
|
||
el.innerHTML = `<div class="table-card"><div class="table-toolbar"><div class="search-box"><input type="text" placeholder="${t('search_placeholder_content')}" 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)">${t('search')}</button></div><div style="display:flex;gap:8px"><button class="btn btn-success btn-sm" onclick="openContentModal(null)">+ ${t('modal_add')}</button></div></div><div class="table-wrap"><table><thead><tr>${showKeys.map(k => `<th>${formatKey(k)}</th>`).join('')}<th>${t('table_actions')}</th></tr></thead><tbody>${d.list.length === 0 ? `<tr class="empty-row"><td colspan="${showKeys.length+1}">${t('no_data')}</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})">${t('edit')}</button> <button class="btn btn-danger btn-sm" onclick="deleteContent(${row.id})">${t('delete')}</button></td></tr>`).join('')}</tbody></table></div>${renderPagination(d, 'loadContentTable')}</div>`;
|
||
} catch (e) { el.innerHTML = `<div class="loading">${t('loading_error')}</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 ? t('edit') : t('modal_add')) + ' - ' + 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':''}>${t('bool_false')}</option><option value="true" ${val===true?'selected':''}>${t('bool_true')}</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="${t('upload_placeholder')}" style="flex:1"><button type="button" class="btn btn-primary btn-sm" onclick="uploadToField('${key}','${mod}')">${t('upload_btn')}</button></div><div style="font-size:11px;color:var(--text-muted);margin-top:4px">${t('upload_format_hint')}</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(t('update_success'), 'success');
|
||
} else {
|
||
await api('/admin/dashboard/' + editingTable, { method: 'POST', body: JSON.stringify(body) });
|
||
toast(t('create_success'), 'success');
|
||
}
|
||
closeModal(); loadContentTable();
|
||
} catch (e) {}
|
||
}
|
||
|
||
async function deleteContent(id) {
|
||
if (!confirm(t('confirm_delete'))) return;
|
||
try { await api('/admin/dashboard/' + currentTable + '/' + id, { method: 'DELETE' }); toast(t('delete_success'), 'success'); loadContentTable(); } catch (e) {}
|
||
}
|
||
|
||
let uploadModule = 'common';
|
||
const moduleList = [
|
||
{ key: 'enterprise', labelKey: 'content_enterprise', icon: '🏢' },
|
||
{ key: 'shop', labelKey: 'content_shop', icon: '🛒' },
|
||
{ key: 'food', labelKey: 'content_food', icon: '🍜' },
|
||
{ key: 'game', labelKey: 'content_game', icon: '🎮' },
|
||
{ key: 'app', labelKey: 'content_app', icon: '📱' },
|
||
{ key: 'music', labelKey: 'content_music', icon: '🎵' },
|
||
{ key: 'common', label: '📂 公共资源', icon: '📂' }
|
||
];
|
||
|
||
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.icon} ${m.labelKey?t(m.labelKey):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">${t('upload_click_or_drag')}</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">${t('upload_max_size')}</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">${t('upload_progress')}</div></div></div></div><div class="section-header" style="margin-top:24px"><h4>${t('upload_uploaded_files')}</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 = `${t('upload_progress_text')} (${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(t('upload_failed') + ': ' + data.message, 'error'); } else { toast(t('upload_success') + ': ' + file.name, 'success'); }
|
||
} catch (e) { toast(t('upload_network_error') + ': ' + file.name, 'error'); }
|
||
}
|
||
fillEl.style.width = '100%'; textEl.textContent = t('upload_complete');
|
||
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">${t('upload_no_files')}</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 = isImage ? `<img src="${f.url}" onerror="this.parentElement.innerHTML='<div class=\\'file-icon\\'>📄</div>'">` : isVideo ? `<div class="file-icon">🎬</div>` : `<div class="file-icon">📄</div>`;
|
||
html += `<div class="file-card"><div class="file-preview">${preview}</div><div class="file-actions"><button title="${t('copy_link')}" onclick="copyFileUrl('${f.url}')">🔗</button><button title="${t('delete')}" 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">${t('loading_error')}</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(t('link_copied') + ': ' + 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(t('link_copied') + ': ' + fullUrl, 'success');
|
||
});
|
||
}
|
||
|
||
async function deleteFile(fileUrl) {
|
||
if (!confirm(t('confirm_delete_file'))) return;
|
||
const path = fileUrl.replace('/uploads/', '');
|
||
try { await api('/upload?module=' + uploadModule + '&filename=' + encodeURIComponent(path), { method: 'DELETE' }); toast(t('delete_success'), '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(t('upload_failed') + ': ' + 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(t('upload_success'), 'success');
|
||
} catch (e) { toast(t('upload_network_error'), '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">${t('settings_service_status')}</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">${t('settings_database')}</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">${t('settings_env')}</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">${t('settings_server_time')}</div></div></div><div class="table-card"><div class="section-header" style="padding:16px"><h4>${t('settings_system_info')}</h4></div><div class="table-wrap"><table><tbody><tr><td style="font-weight:600;width:200px">${t('settings_system_name')}</td><td>${t('settings_system_name_val')}</td></tr><tr><td style="font-weight:600">${t('settings_tech_stack')}</td><td>${t('settings_tech_stack_val')}</td></tr><tr><td style="font-weight:600">${t('settings_templates')}</td><td>${t('settings_templates_val')}</td></tr><tr><td style="font-weight:600">${t('settings_api_version')}</td><td>${t('settings_api_version_val')}</td></tr><tr><td style="font-weight:600">${t('settings_auth_method')}</td><td>${t('settings_auth_method_val')}</td></tr><tr><td style="font-weight:600">${t('settings_admin_version')}</td><td>${t('settings_admin_version_val')}</td></tr></tbody></table></div></div>`;
|
||
} catch (e) { el.innerHTML = `<div class="loading">${t('loading_error')}</div>`; }
|
||
}
|
||
|
||
function renderSites() {
|
||
const el = document.getElementById('content');
|
||
const sites = [
|
||
{ groupKey: 'sites_enterprise', icon: '🏢', items: [
|
||
{ name: '科技风格', path: '/site1', color: '#3b82f6' }, { name: '制造风格', path: '/site2', color: '#f97316' }, { name: '咨询风格', path: '/site3', color: '#10b981' }
|
||
]},
|
||
{ groupKey: 'sites_shop', icon: '🛒', items: [
|
||
{ name: '红色风格', path: '/shop', color: '#ef4444' }, { name: '暗黑风格', path: '/shop2', color: '#1e293b' }, { name: '北欧风格', path: '/shop3', color: '#06b6d4' }
|
||
]},
|
||
{ groupKey: 'sites_food', icon: '🍜', items: [
|
||
{ name: '橙黄风格', path: '/food1', color: '#f59e0b' }, { name: '深色风格', path: '/food2', color: '#334155' }, { name: '清新风格', path: '/food3', color: '#34d399' }
|
||
]},
|
||
{ groupKey: 'sites_game', icon: '🎮', items: [
|
||
{ name: '赛博风格', path: '/game1', color: '#8b5cf6' }, { name: '像素风格', path: '/game2', color: '#ec4899' }, { name: '云游风格', path: '/game3', color: '#3b82f6' }
|
||
]},
|
||
{ groupKey: 'sites_app', icon: '📱', items: [
|
||
{ name: '极简风格', path: '/app1', color: '#64748b' }, { name: '暗黑风格', path: '/app2', color: '#1e293b' }, { name: '清新风格', path: '/app3', color: '#10b981' }
|
||
]},
|
||
{ groupKey: 'sites_music', 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} ${t(mod.groupKey)}</h4></div><div class="stats-grid" style="margin-bottom:24px">${cards}</div>`;
|
||
}
|
||
el.innerHTML = html;
|
||
}
|
||
|
||
let accessLogPage = 1, accessLogPageSize = 20;
|
||
async function renderAccessLogs(page) {
|
||
if (page) accessLogPage = page;
|
||
const el = document.getElementById('content');
|
||
el.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
|
||
try {
|
||
const [statsData, logsData] = await Promise.all([ api('/admin/access-logs/stats'), api(`/admin/access-logs?page=${accessLogPage}&page_size=${accessLogPageSize}`) ]);
|
||
const stats = statsData.data;
|
||
const logs = logsData.data;
|
||
let hourlyChart = '';
|
||
if (stats.hourly && stats.hourly.length > 0) {
|
||
const maxCount = Math.max(...stats.hourly.map(h => h.count));
|
||
hourlyChart = stats.hourly.map(h => { const pct = maxCount > 0 ? (h.count / maxCount * 100) : 0; const hour = h.hour ? h.hour.split(' ')[1] || h.hour : ''; return `<div style="display:flex;align-items:end;gap:4px;flex-direction:column;align-items:center;flex:1"><div style="font-size:11px;color:var(--text-muted)">${h.count}</div><div style="width:100%;max-width:32px;background:var(--primary);border-radius:4px 4px 0 0;height:${Math.max(4, pct)}%;min-height:4px;transition:height .3s"></div><div style="font-size:10px;color:var(--text-muted)">${hour}</div></div>`; }).join('');
|
||
}
|
||
el.innerHTML = `<div class="stats-grid" style="margin-bottom:24px"><div class="stat-card"><div class="stat-icon bg-blue">📈</div><div class="stat-value">${stats.total_visits || 0}</div><div class="stat-label">${t('accesslog_total')}</div></div><div class="stat-card"><div class="stat-icon bg-green">📅</div><div class="stat-value">${stats.today_visits || 0}</div><div class="stat-label">${t('accesslog_today')}</div></div><div class="stat-card"><div class="stat-icon bg-purple">🌐</div><div class="stat-value">${stats.unique_ips || 0}</div><div class="stat-label">${t('accesslog_unique_ip')}</div></div></div>${hourlyChart ? `<div class="table-card" style="margin-bottom:24px"><div class="section-header" style="padding:16px"><h4>${t('accesslog_hourly_trend')}</h4></div><div style="padding:16px;display:flex;align-items:end;gap:6px;height:180px">${hourlyChart}</div></div>` : ''}<div class="table-card"><div class="table-toolbar"><h4 style="font-size:14px;font-weight:600">${t('accesslog_records')}</h4><button class="btn btn-danger btn-sm" onclick="clearAccessLogs()">${t('accesslog_clear')}</button></div><div class="table-wrap"><table><thead><tr><th>${t('accesslog_time')}</th><th>${t('accesslog_ip')}</th><th>${t('accesslog_location')}</th><th>${t('accesslog_path')}</th><th>${t('accesslog_method')}</th><th>${t('accesslog_status')}</th><th>${t('accesslog_cost')}</th></tr></thead><tbody>${logs.list.length === 0 ? `<tr class="empty-row"><td colspan="7">${t('accesslog_no_records')}</td></tr>` : logs.list.map(l => `<tr><td>${formatDate(l.created_at)}</td><td><code style="background:#f1f5f9;padding:2px 6px;border-radius:4px;font-size:12px">${l.ip}</code></td><td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${l.location||''}">${l.location || '-'}</td><td style="max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${l.path}">${l.path}</td><td><span class="badge-status ${l.method==='GET'?'badge-active':'badge-inactive'}">${l.method}</span></td><td><span class="badge-status ${l.status<400?'badge-active':'badge-inactive'}">${l.status}</span></td><td>${l.cost}ms</td></tr>`).join('')}</tbody></table></div>${renderPagination(logs, 'renderAccessLogs')}</div>`;
|
||
} catch (e) { el.innerHTML = `<div class="loading">${t('loading_error')}</div>`; }
|
||
}
|
||
|
||
async function clearAccessLogs() {
|
||
if (!confirm(t('confirm_clear_logs'))) return;
|
||
try { await api('/admin/access-logs', { method: 'DELETE' }); toast(t('clear_success'), 'success'); renderAccessLogs(1); } catch (e) {}
|
||
}
|
||
|
||
async function renderWebhook() {
|
||
const el = document.getElementById('content');
|
||
el.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
|
||
try {
|
||
const data = await api('/admin/config/wechat_webhook_url');
|
||
const url = data.data ? data.data.value : '';
|
||
el.innerHTML = `<div class="table-card"><div class="section-header" style="padding:16px"><h4>${t('webhook_title')}</h4></div><div style="padding:24px;max-width:700px"><div class="form-group"><label>${t('webhook_url_label')}</label><input type="text" id="webhookUrl" value="${url || ''}" placeholder="${t('webhook_url_placeholder')}" style="width:100%"><div style="font-size:12px;color:var(--text-muted);margin-top:6px">${t('webhook_url_hint')}</div></div><div style="display:flex;gap:8px;margin-top:20px"><button class="btn btn-primary" onclick="saveWebhook()">${t('webhook_save')}</button><button class="btn btn-outline" onclick="testWebhook()">${t('webhook_test')}</button></div><div id="webhookTestResult" style="margin-top:12px;display:none"></div></div></div><div class="table-card" style="margin-top:16px"><div class="section-header" style="padding:16px"><h4>${t('webhook_usage_title')}</h4></div><div style="padding:0 24px 24px"><ol style="line-height:2;color:var(--text-muted);font-size:13px"><li>${t('webhook_step1')}</li><li>${t('webhook_step2')}</li><li>${t('webhook_step3')}</li><li>${t('webhook_step4')}</li><li>${t('webhook_step5')}</li></ol></div></div>`;
|
||
} catch (e) { el.innerHTML = `<div class="loading">${t('loading_error')}</div>`; }
|
||
}
|
||
|
||
async function saveWebhook() {
|
||
const url = document.getElementById('webhookUrl').value.trim();
|
||
try { await api('/admin/config/wechat_webhook_url', { method: 'PUT', body: JSON.stringify({ value: url, remark: t('webhook_remark') }) }); toast(t('update_success'), 'success'); } catch (e) {}
|
||
}
|
||
|
||
async function testWebhook() {
|
||
const resultEl = document.getElementById('webhookTestResult');
|
||
resultEl.style.display = 'block';
|
||
resultEl.innerHTML = `<div style="color:var(--text-muted)">${t('webhook_test_sending')}</div>`;
|
||
try { await api('/admin/webhook/test', { method: 'POST' }); resultEl.innerHTML = `<div style="color:var(--success);font-weight:600">${t('webhook_test_success')}</div>`; }
|
||
catch (e) { resultEl.innerHTML = `<div style="color:var(--danger)">${t('webhook_test_failed')}: ${e.message || ''}</div>`; }
|
||
}
|
||
|
||
async function renderNavSettings() {
|
||
const el = document.getElementById('content');
|
||
el.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
|
||
try {
|
||
const data = await api('/admin/config');
|
||
const configs = {};
|
||
(data.data || []).forEach(c => { configs[c.key] = c.value; });
|
||
el.innerHTML = `<div class="table-card"><div class="section-header" style="padding:16px"><h4>${t('navsettings_title')}</h4></div><div style="padding:24px;max-width:800px"><div class="form-group"><label>${t('navsettings_page_title')}</label><input type="text" id="ns_hero_title" value="${configs.nav_hero_title||''}" placeholder="Gotest"></div><div class="form-group"><label>${t('navsettings_page_highlight')}</label><input type="text" id="ns_hero_highlight" value="${configs.nav_hero_highlight||''}"></div><div class="form-group"><label>${t('navsettings_subtitle')}</label><input type="text" id="ns_hero_desc" value="${configs.nav_hero_desc||''}"></div><div class="form-row"><div class="form-group"><label>${t('navsettings_pill_templates')}</label><input type="text" id="ns_pill_templates" value="${configs.nav_pill_templates||''}" placeholder="19"></div><div class="form-group"><label>${t('navsettings_pill_lines')}</label><input type="text" id="ns_pill_lines" value="${configs.nav_pill_lines||''}" placeholder="6"></div></div><div class="form-row"><div class="form-group"><label>${t('navsettings_pill_per_industry')}</label><input type="text" id="ns_pill_per_industry" value="${configs.nav_pill_per_industry||''}" placeholder="3"></div><div class="form-group"><label>${t('navsettings_pill_responsive')}</label><input type="text" id="ns_pill_responsive" value="${configs.nav_pill_responsive||''}"></div></div></div></div><div class="table-card" style="margin-top:16px"><div class="section-header" style="padding:16px"><h4>${t('navsettings_category_title')}</h4></div><div style="padding:24px;max-width:800px"><div class="form-row"><div class="form-group"><label>${t('navsettings_enterprise_title')}</label><input type="text" id="ns_sec_enterprise_title" value="${configs.nav_sec_enterprise_title||''}"></div><div class="form-group"><label>${t('navsettings_enterprise_sub')}</label><input type="text" id="ns_sec_enterprise_sub" value="${configs.nav_sec_enterprise_sub||''}"></div></div><div class="form-row"><div class="form-group"><label>${t('navsettings_shop_title')}</label><input type="text" id="ns_sec_shop_title" value="${configs.nav_sec_shop_title||''}"></div><div class="form-group"><label>${t('navsettings_shop_sub')}</label><input type="text" id="ns_sec_shop_sub" value="${configs.nav_sec_shop_sub||''}"></div></div><div class="form-row"><div class="form-group"><label>${t('navsettings_food_title')}</label><input type="text" id="ns_sec_food_title" value="${configs.nav_sec_food_title||''}"></div><div class="form-group"><label>${t('navsettings_food_sub')}</label><input type="text" id="ns_sec_food_sub" value="${configs.nav_sec_food_sub||''}"></div></div><div class="form-row"><div class="form-group"><label>${t('navsettings_game_title')}</label><input type="text" id="ns_sec_game_title" value="${configs.nav_sec_game_title||''}"></div><div class="form-group"><label>${t('navsettings_game_sub')}</label><input type="text" id="ns_sec_game_sub" value="${configs.nav_sec_game_sub||''}"></div></div><div class="form-row"><div class="form-group"><label>${t('navsettings_app_title')}</label><input type="text" id="ns_sec_app_title" value="${configs.nav_sec_app_title||''}"></div><div class="form-group"><label>${t('navsettings_app_sub')}</label><input type="text" id="ns_sec_app_sub" value="${configs.nav_sec_app_sub||''}"></div></div><div class="form-row"><div class="form-group"><label>${t('navsettings_music_title')}</label><input type="text" id="ns_sec_music_title" value="${configs.nav_sec_music_title||''}"></div><div class="form-group"><label>${t('navsettings_music_sub')}</label><input type="text" id="ns_sec_music_sub" value="${configs.nav_sec_music_sub||''}"></div></div><div class="form-row"><div class="form-group"><label>${t('navsettings_appshow_title')}</label><input type="text" id="ns_sec_appshow_title" value="${configs.nav_sec_appshow_title||''}"></div><div class="form-group"><label>${t('navsettings_appshow_sub')}</label><input type="text" id="ns_sec_appshow_sub" value="${configs.nav_sec_appshow_sub||''}"></div></div><div class="form-row"><div class="form-group"><label>${t('navsettings_bizsys_title')}</label><input type="text" id="ns_sec_bizsys_title" value="${configs.nav_sec_bizsys_title||''}"></div><div class="form-group"><label>${t('navsettings_bizsys_sub')}</label><input type="text" id="ns_sec_bizsys_sub" value="${configs.nav_sec_bizsys_sub||''}"></div></div></div></div><div class="table-card" style="margin-top:16px"><div class="section-header" style="padding:16px"><h4>${t('consult_section_title')}</h4></div><div style="padding:24px;max-width:800px"><div class="form-group"><label>${t('navsettings_consult_title_label')}</label><input type="text" id="ns_consult_title" value="${configs.nav_consult_title||''}"></div><div class="form-group"><label>${t('navsettings_consult_desc_label')}</label><input type="text" id="ns_consult_desc" value="${configs.nav_consult_desc||''}"></div><div class="form-group"><label>${t('navsettings_consult_success_label')}</label><input type="text" id="ns_consult_success" value="${configs.nav_consult_success||''}"></div><div class="form-group"><label>${t('navsettings_footer_label')}</label><input type="text" id="ns_footer_text" value="${configs.nav_footer_text||''}"></div><div class="form-group"><label>${t('navsettings_company_label')}</label><input type="text" id="ns_company_name" value="${configs.nav_company_name||''}"></div><div style="margin-top:20px"><button class="btn btn-primary" onclick="saveNavSettings()">${t('navsettings_save')}</button></div></div></div>`;
|
||
} catch (e) { el.innerHTML = `<div class="loading">${t('loading_error')}</div>`; }
|
||
}
|
||
|
||
async function saveNavSettings() {
|
||
const fields = ['hero_title','hero_highlight','hero_desc','pill_templates','pill_lines','pill_per_industry','pill_responsive','sec_enterprise_title','sec_enterprise_sub','sec_shop_title','sec_shop_sub','sec_food_title','sec_food_sub','sec_game_title','sec_game_sub','sec_app_title','sec_app_sub','sec_music_title','sec_music_sub','sec_appshow_title','sec_appshow_sub','sec_bizsys_title','sec_bizsys_sub','consult_title','consult_desc','consult_success','footer_text','company_name'];
|
||
const items = fields.map(f => ({ key: 'nav_' + f, value: (document.getElementById('ns_' + f)?.value || '').trim(), remark: '导航页配置' }));
|
||
try { await api('/admin/config-batch', { method: 'PUT', body: JSON.stringify({ items }) }); toast(t('navsettings_save_success'), 'success'); } catch (e) { toast(t('navsettings_save_failed'), 'error'); }
|
||
}
|
||
|
||
let appShowPage = 1;
|
||
async function renderAppShowcase(page) {
|
||
if (page) appShowPage = page;
|
||
const el = document.getElementById('content');
|
||
el.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
|
||
try {
|
||
const data = await api('/admin/showcase/apps?page=' + appShowPage + '&page_size=20');
|
||
const d = data.data;
|
||
el.innerHTML = `<div class="table-card"><div class="table-toolbar"><h4 style="font-size:15px">${t('appshow_title')}</h4><button class="btn btn-primary btn-sm" onclick="openAppShowModal()">${t('appshow_add')}</button></div><div class="table-wrap"><table><thead><tr><th>${t('table_id')}</th><th>${t('field_name')}</th><th>${t('appshow_category')}</th><th>${t('appshow_platform')}</th><th>${t('appshow_version')}</th><th>${t('appshow_rating')}</th><th>${t('appshow_status')}</th><th>${t('table_actions')}</th></tr></thead><tbody>${d.list.length===0?`<tr class="empty-row"><td colspan="8">${t('no_data')}</td></tr>`:d.list.map(a=>`<tr><td>${a.id}</td><td><strong>${a.name}</strong></td><td>${a.category||'-'}</td><td>${a.platform||'-'}</td><td>${a.version||'-'}</td><td>⭐${a.rating}</td><td><span class="badge-status ${a.status===1?'badge-active':'badge-inactive'}">${a.status===1?t('appshow_listed'):t('appshow_unlisted')}</span></td><td><button class="btn btn-outline btn-sm" onclick="openAppShowModal(${a.id})">${t('edit')}</button> <button class="btn btn-danger btn-sm" onclick="deleteAppShow(${a.id})">${t('delete')}</button></td></tr>`).join('')}</tbody></table></div>${renderPagination(d,'renderAppShowcase')}</div>`;
|
||
} catch(e) { el.innerHTML = `<div class="loading">${t('loading_error')}</div>`; }
|
||
}
|
||
|
||
let appShowData = [];
|
||
async function openAppShowModal(id) {
|
||
if (!appShowData.length) { try { const r=await api('/admin/showcase/apps?page_size=100'); appShowData=r.data.list||[]; } catch(e){} }
|
||
const item = id ? appShowData.find(a=>a.id===id) : null;
|
||
document.getElementById('modalTitle').textContent = id ? t('appshow_edit') : t('appshow_add_title');
|
||
document.getElementById('modalBody').innerHTML = `<div class="form-row"><div class="form-group"><label>${t('appshow_name_label')}</label><input type="text" id="m_name" value="${item?.name||''}"></div><div class="form-group"><label>${t('appshow_category')}</label><input type="text" id="m_category" value="${item?.category||''}" placeholder="${t('appshow_category_placeholder')}"></div></div><div class="form-row"><div class="form-group"><label>${t('appshow_platform')}</label><input type="text" id="m_platform" value="${item?.platform||''}" placeholder="${t('appshow_platform_placeholder')}"></div><div class="form-group"><label>${t('appshow_version')}</label><input type="text" id="m_version" value="${item?.version||''}"></div></div><div class="form-group"><label>${t('appshow_desc_label')}</label><textarea id="m_description" rows="2">${item?.description||''}</textarea></div><div class="form-row"><div class="form-group"><label>${t('appshow_icon_label')}</label><input type="text" id="m_icon" value="${item?.icon||''}"></div><div class="form-group"><label>${t('appshow_download_label')}</label><input type="text" id="m_download_url" value="${item?.download_url||''}"></div></div><div class="form-row"><div class="form-group"><label>${t('appshow_video_label')}</label><input type="text" id="m_video_url" value="${item?.video_url||''}"></div><div class="form-group"><label>${t('appshow_size_label')}</label><input type="text" id="m_size" value="${item?.size||''}"></div></div><div class="form-row"><div class="form-group"><label>${t('appshow_rating_label')}</label><input type="number" id="m_rating" value="${item?.rating||5}" step="0.1" min="0" max="5"></div><div class="form-group"><label>${t('appshow_sort_label')}</label><input type="number" id="m_sort" value="${item?.sort||0}"></div></div><div class="form-group"><label>${t('appshow_status_label')}</label><select id="m_status"><option value="1" ${item?.status!==0?'selected':''}>${t('appshow_listed')}</option><option value="0" ${item?.status===0?'selected':''}>${t('appshow_unlisted')}</option></select></div>`;
|
||
editingId = id; _modalSaveFn = saveAppShow; openModal();
|
||
}
|
||
|
||
async function saveAppShow() {
|
||
const body = { name: document.getElementById('m_name').value, category: document.getElementById('m_category').value, platform: document.getElementById('m_platform').value, version: document.getElementById('m_version').value, description: document.getElementById('m_description').value, icon: document.getElementById('m_icon').value, download_url: document.getElementById('m_download_url').value, video_url: document.getElementById('m_video_url').value, size: document.getElementById('m_size').value, rating: parseFloat(document.getElementById('m_rating').value)||5, sort: parseInt(document.getElementById('m_sort').value)||0, status: parseInt(document.getElementById('m_status').value) };
|
||
try {
|
||
if (editingId) { await api('/admin/showcase/apps/' + editingId, { method:'PUT', body:JSON.stringify(body) }); }
|
||
else { await api('/admin/showcase/apps', { method:'POST', body:JSON.stringify(body) }); }
|
||
closeModal(); toast(t('update_success'),'success'); renderAppShowcase();
|
||
} catch(e) {}
|
||
}
|
||
|
||
async function deleteAppShow(id) {
|
||
if (!confirm(t('confirm_delete_app'))) return;
|
||
try { await api('/admin/showcase/apps/'+id,{method:'DELETE'}); toast(t('delete_success'),'success'); renderAppShowcase(); } catch(e){}
|
||
}
|
||
|
||
let bizSysPage = 1;
|
||
async function renderBizSystems(page) {
|
||
if (page) bizSysPage = page;
|
||
const el = document.getElementById('content');
|
||
el.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
|
||
try {
|
||
const data = await api('/admin/showcase/systems?page=' + bizSysPage + '&page_size=20');
|
||
const d = data.data;
|
||
el.innerHTML = `<div class="table-card"><div class="table-toolbar"><h4 style="font-size:15px">${t('bizsys_title')}</h4><button class="btn btn-primary btn-sm" onclick="openBizSysModal()">${t('bizsys_add')}</button></div><div class="table-wrap"><table><thead><tr><th>${t('table_id')}</th><th>${t('field_name')}</th><th>${t('bizsys_category')}</th><th>${t('bizsys_price_type')}</th><th>${t('bizsys_price')}</th><th>${t('bizsys_status')}</th><th>${t('table_actions')}</th></tr></thead><tbody>${d.list.length===0?`<tr class="empty-row"><td colspan="7">${t('no_data')}</td></tr>`:d.list.map(s=>`<tr><td>${s.id}</td><td><strong>${s.name}</strong></td><td>${s.category||'-'}</td><td>${s.price_type||'-'}</td><td>${s.price||'-'}</td><td><span class="badge-status ${s.status===1?'badge-active':'badge-inactive'}">${s.status===1?t('bizsys_online'):t('bizsys_offline')}</span></td><td><button class="btn btn-outline btn-sm" onclick="openBizSysModal(${s.id})">${t('edit')}</button> <button class="btn btn-danger btn-sm" onclick="deleteBizSys(${s.id})">${t('delete')}</button></td></tr>`).join('')}</tbody></table></div>${renderPagination(d,'renderBizSystems')}</div>`;
|
||
} catch(e) { el.innerHTML = `<div class="loading">${t('loading_error')}</div>`; }
|
||
}
|
||
|
||
let bizSysData = [];
|
||
async function openBizSysModal(id) {
|
||
if (!bizSysData.length) { try { const r=await api('/admin/showcase/systems?page_size=100'); bizSysData=r.data.list||[]; } catch(e){} }
|
||
const item = id ? bizSysData.find(s=>s.id===id) : null;
|
||
document.getElementById('modalTitle').textContent = id ? t('bizsys_edit') : t('bizsys_add_title');
|
||
document.getElementById('modalBody').innerHTML = `<div class="form-row"><div class="form-group"><label>${t('bizsys_name_label')}</label><input type="text" id="m_name" value="${item?.name||''}"></div><div class="form-group"><label>${t('bizsys_category')}</label><input type="text" id="m_category" value="${item?.category||''}" placeholder="${t('bizsys_category_placeholder')}"></div></div><div class="form-group"><label>${t('bizsys_intro_label')}</label><input type="text" id="m_description" value="${item?.description||''}"></div><div class="form-group"><label>${t('bizsys_detail_label')}</label><textarea id="m_detail" rows="3">${item?.detail||''}</textarea></div><div class="form-row"><div class="form-group"><label>${t('bizsys_price_type')}</label><select id="m_price_type"><option value="按年" ${item?.price_type==='按年'?'selected':''}>${t('bizsys_price_type_yearly')}</option><option value="按月" ${item?.price_type==='按月'?'selected':''}>${t('bizsys_price_type_monthly')}</option><option value="一次性" ${item?.price_type==='一次性'?'selected':''}>${t('bizsys_price_type_onetime')}</option><option value="定制" ${item?.price_type==='定制'?'selected':''}>${t('bizsys_price_type_custom')}</option></select></div><div class="form-group"><label>${t('bizsys_price_desc_label')}</label><input type="text" id="m_price" value="${item?.price||''}" placeholder="${t('bizsys_price_placeholder')}"></div></div><div class="form-row"><div class="form-group"><label>${t('bizsys_demo_label')}</label><input type="text" id="m_demo_url" value="${item?.demo_url||''}"></div><div class="form-group"><label>${t('bizsys_sort_label')}</label><input type="number" id="m_sort" value="${item?.sort||0}"></div></div><div class="form-group"><label>${t('bizsys_status_label')}</label><select id="m_status"><option value="1" ${item?.status!==0?'selected':''}>${t('bizsys_online')}</option><option value="0" ${item?.status===0?'selected':''}>${t('bizsys_offline')}</option></select></div>`;
|
||
editingId = id; _modalSaveFn = saveBizSys; openModal();
|
||
}
|
||
|
||
async function saveBizSys() {
|
||
const body = { name: document.getElementById('m_name').value, category: document.getElementById('m_category').value, description: document.getElementById('m_description').value, detail: document.getElementById('m_detail').value, price_type: document.getElementById('m_price_type').value, price: document.getElementById('m_price').value, demo_url: document.getElementById('m_demo_url').value, sort: parseInt(document.getElementById('m_sort').value)||0, status: parseInt(document.getElementById('m_status').value) };
|
||
try {
|
||
if (editingId) { await api('/admin/showcase/systems/' + editingId, { method:'PUT', body:JSON.stringify(body) }); }
|
||
else { await api('/admin/showcase/systems', { method:'POST', body:JSON.stringify(body) }); }
|
||
closeModal(); toast(t('update_success'),'success'); renderBizSystems();
|
||
} catch(e) {}
|
||
}
|
||
|
||
async function deleteBizSys(id) {
|
||
if (!confirm(t('confirm_delete_bizsys'))) return;
|
||
try { await api('/admin/showcase/systems/'+id,{method:'DELETE'}); toast(t('delete_success'),'success'); renderBizSystems(); } catch(e){}
|
||
}
|
||
|
||
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:'角色' };
|
||
const fieldKey = 'field_' + k;
|
||
return (I18N_LOCALES[currentLang] && I18N_LOCALES[currentLang][fieldKey]) || 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?t('status_active'):t('status_inactive')}</span>`;
|
||
if (key === 'is_hot' || key === 'is_new' || key === 'is_featured') return val ? '✅' : '❌';
|
||
if (key === 'image' || key === 'cover' || key === 'logo' || key === 'avatar' || key === 'icon') {
|
||
if (typeof val === 'string' && val.startsWith('http')) return `<img class="cell-img" src="${val}" onerror="this.style.display='none'">`;
|
||
return String(val).substring(0, 30);
|
||
}
|
||
if (typeof val === 'string' && val.length > 100) return `<span class="cell-text-truncate" title="${val.replace(/"/g,'"')}">${val.substring(0,100)}...</span>`;
|
||
if (key === 'created_at') return formatDate(val);
|
||
if (typeof val === 'object') return JSON.stringify(val).substring(0, 50);
|
||
return String(val);
|
||
}
|
||
|
||
function getTableLabel(key) {
|
||
for (const mod of contentModules) {
|
||
for (const ta of mod.tables) {
|
||
if (ta.key === key) return t(ta.labelKey);
|
||
}
|
||
}
|
||
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})">${t('pagination_prev')}</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})">${t('pagination_next')}</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 openModal() { showModal(); }
|
||
function closeModal() { document.getElementById('modalOverlay').classList.remove('show'); editingId = null; }
|
||
function saveModal() { if (typeof _modalSaveFn === 'function') _modalSaveFn(); }
|
||
let _modalSaveFn = null;
|
||
|
||
function onLangChange() {
|
||
applyI18n();
|
||
navigate(currentPage);
|
||
}
|
||
|
||
document.getElementById('modalOverlay').addEventListener('click', (e) => { if (e.target === e.currentTarget) closeModal(); });
|
||
document.getElementById('loginPassword').addEventListener('keydown', (e) => { if (e.key === 'Enter') doLogin(); });
|
||
document.getElementById('initPasswordConfirm')?.addEventListener('keydown', (e) => { if (e.key === 'Enter') doInitAdmin(); });
|
||
|
||
(async function() {
|
||
applyI18n();
|
||
const hasAdmin = await checkAdminExists();
|
||
if (!hasAdmin) {
|
||
document.getElementById('loginForm').style.display = 'none';
|
||
document.getElementById('initForm').style.display = 'block';
|
||
document.getElementById('loginTitle').innerHTML = '🚀 <span data-i18n="init_title">' + t('init_title') + '</span>';
|
||
document.getElementById('loginSubtitle').textContent = t('init_subtitle');
|
||
}
|
||
})();
|
||
|
||
if (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>
|