Files
web_template/web/dist/admin/index.html
Geliebte 10979351f8 feat: 默认语言改为英语 + 导航页添加i18n三语支持
- 导航页(index.html)添加完整i18n引擎,支持zh-CN/zh-TW/en
- 导航页添加语言切换按钮(EN/中/繁)
- 所有20个前端模板默认语言从zh-CN改为en
- 导航页SEO meta标签改为英文
2026-09-09 19:48:45 +08:00

1325 lines
117 KiB
HTML
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title 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()">&times;</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,'&quot;')}">${val.substring(0,100)}...</span>`;
if (key === 'created_at') return formatDate(val);
if (typeof val === 'object') return JSON.stringify(val).substring(0, 50);
return String(val);
}
function getTableLabel(key) {
for (const mod of contentModules) {
for (const 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>