代码推送

This commit is contained in:
2026-09-09 02:39:57 +08:00
parent a112893947
commit 5e47757c73
24 changed files with 1156 additions and 226 deletions

View File

@@ -216,19 +216,38 @@ tbody tr:last-child td{border-bottom:none}
<!-- Login Overlay -->
<div class="login-overlay" id="loginOverlay">
<div class="login-card">
<h1>🔐 管理后台</h1>
<p class="subtitle">Gotest Admin Dashboard</p>
<div class="form-group">
<label>用户名</label>
<input type="text" id="loginUsername" placeholder="请输入用户名" value="admin">
<h1 id="loginTitle">🔐 管理后台</h1>
<p class="subtitle" id="loginSubtitle">Gotest Admin Dashboard</p>
<!-- 登录表单 -->
<div id="loginForm">
<div class="form-group">
<label>用户名</label>
<input type="text" id="loginUsername" placeholder="请输入用户名">
</div>
<div class="form-group">
<label>密码</label>
<input type="password" id="loginPassword" placeholder="请输入密码">
</div>
<button class="login-btn" id="loginBtn" onclick="doLogin()">登 录</button>
<div class="login-error" id="loginError"></div>
</div>
<div class="form-group">
<label>密码</label>
<input type="password" id="loginPassword" placeholder="请输入密码" value="admin123">
<!-- 初始化表单(无管理员时显示) -->
<div id="initForm" style="display:none">
<div class="form-group">
<label>管理员用户名</label>
<input type="text" id="initUsername" placeholder="请设置管理员用户名">
</div>
<div class="form-group">
<label>管理员密码</label>
<input type="password" id="initPassword" placeholder="请设置密码(至少6位)">
</div>
<div class="form-group">
<label>确认密码</label>
<input type="password" id="initPasswordConfirm" placeholder="请再次输入密码">
</div>
<button class="login-btn" id="initBtn" onclick="doInitAdmin()">初始化管理员</button>
<div class="login-error" id="initError"></div>
</div>
<button class="login-btn" id="loginBtn" onclick="doLogin()">登 录</button>
<div class="login-error" id="loginError"></div>
<div class="login-hint">默认账号: admin / admin123</div>
</div>
</div>
@@ -248,6 +267,12 @@ tbody tr:last-child td{border-bottom:none}
<span class="icon">📦</span> 内容管理
</a>
<div class="sidebar-section">系统</div>
<a class="sidebar-link" onclick="navigate('accesslog')" data-page="accesslog">
<span class="icon">📊</span> 访问日志
</a>
<a class="sidebar-link" onclick="navigate('webhook')" data-page="webhook">
<span class="icon">🔔</span> Webhook配置
</a>
<a class="sidebar-link" onclick="navigate('settings')" data-page="settings">
<span class="icon">⚙️</span> 系统设置
</a>
@@ -343,6 +368,45 @@ function toast(msg, type = 'info') {
}
// ===== 登录/登出 =====
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 = '请输入用户名和密码'; return; }
if (password.length < 6) { errEl.textContent = '密码至少6位'; return; }
if (password !== confirm) { errEl.textContent = '两次密码不一致'; return; }
btn.disabled = true; btn.textContent = '初始化中...'; 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 || '初始化失败'; return; }
toast('初始化成功,请登录', 'success');
document.getElementById('initForm').style.display = 'none';
document.getElementById('loginForm').style.display = 'block';
document.getElementById('loginSubtitle').textContent = '请输入刚创建的管理员账号';
} catch (e) {
errEl.textContent = '网络错误,请重试';
} finally {
btn.disabled = false; btn.textContent = '初始化管理员';
}
}
async function doLogin() {
const btn = document.getElementById('loginBtn');
const username = document.getElementById('loginUsername').value.trim();
@@ -391,12 +455,14 @@ function navigate(page, params = {}) {
document.querySelectorAll('.sidebar-link').forEach(el => el.classList.remove('active'));
const link = document.querySelector(`[data-page="${page}"]`);
if (link) link.classList.add('active');
const titles = { users: '用户管理', content: '内容管理', settings: '系统设置', sites: '站点导航' };
const titles = { users: '用户管理', content: '内容管理', settings: '系统设置', sites: '站点导航', accesslog: '访问日志', webhook: 'Webhook配置' };
document.getElementById('pageTitle').textContent = titles[page] || page;
if (page === 'users') renderUsers();
else if (page === 'content') renderContent(params.table);
else if (page === 'settings') renderSettings();
else if (page === 'sites') renderSites();
else if (page === 'accesslog') renderAccessLogs();
else if (page === 'webhook') renderWebhook();
if (window.innerWidth <= 768) document.getElementById('sidebar').classList.remove('open');
}
@@ -1050,6 +1116,175 @@ function renderSites() {
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">总访问量</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">今日访问</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">独立IP</div>
</div>
</div>
${hourlyChart ? `<div class="table-card" style="margin-bottom:24px">
<div class="section-header" style="padding:16px"><h4>📊 最近24小时访问趋势</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">📋 访问记录</h4>
<button class="btn btn-danger btn-sm" onclick="clearAccessLogs()">清空日志</button>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>时间</th><th>IP</th><th>归属地</th><th>路径</th><th>方法</th><th>状态</th><th>耗时</th></tr></thead>
<tbody>
${logs.list.length === 0 ? '<tr class="empty-row"><td colspan="7">暂无访问记录</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">加载失败</div>'; }
}
async function clearAccessLogs() {
if (!confirm('确定清空所有访问日志?')) return;
try {
await api('/admin/access-logs', { method: 'DELETE' });
toast('清空成功', 'success');
renderAccessLogs(1);
} catch (e) {}
}
// ===== Webhook 配置 =====
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>🔔 企业微信 Webhook 配置</h4></div>
<div style="padding:24px;max-width:700px">
<div class="form-group">
<label>Webhook URL</label>
<input type="text" id="webhookUrl" value="${url || ''}" placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" style="width:100%">
<div style="font-size:12px;color:var(--text-muted);margin-top:6px">
企业微信群机器人的 Webhook 地址,用于接收客户咨询通知和系统告警
</div>
</div>
<div style="display:flex;gap:8px;margin-top:20px">
<button class="btn btn-primary" onclick="saveWebhook()">保存配置</button>
<button class="btn btn-outline" onclick="testWebhook()">测试发送</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>📖 使用说明</h4></div>
<div style="padding:0 24px 24px">
<ol style="line-height:2;color:var(--text-muted);font-size:13px">
<li>在企业微信群中添加一个<strong>群机器人</strong></li>
<li>点击机器人头像,复制 <strong>Webhook 地址</strong></li>
<li>将地址粘贴到上方输入框并保存</li>
<li>点击「测试发送」验证配置是否正确</li>
<li>配置成功后,客户咨询通知将自动推送到该群</li>
</ol>
</div>
</div>`;
} catch (e) { el.innerHTML = '<div class="loading">加载失败</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: '企业微信群机器人Webhook地址' })
});
toast('保存成功', 'success');
} catch (e) {}
}
async function testWebhook() {
const resultEl = document.getElementById('webhookTestResult');
resultEl.style.display = 'block';
resultEl.innerHTML = '<div style="color:var(--text-muted)">发送中...</div>';
try {
await api('/admin/config/wechat_webhook_url', {
method: 'PUT',
body: JSON.stringify({ value: document.getElementById('webhookUrl').value.trim(), remark: '企业微信群机器人Webhook地址' })
});
// 通过客户咨询接口发送测试消息
const resp = await fetch(API_BASE + '/customer/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: '测试用户',
phone: '13800000000',
company: '测试公司',
email: 'test@example.com',
needs: '这是一条测试消息,用于验证 Webhook 配置是否正确'
})
});
const data = await resp.json();
if (data.code === 0) {
resultEl.innerHTML = '<div style="color:var(--success);font-weight:600">✅ 测试消息已发送,请检查企业微信群</div>';
} else {
resultEl.innerHTML = `<div style="color:var(--danger)">❌ 发送失败: ${data.message}</div>`;
}
} catch (e) {
resultEl.innerHTML = '<div style="color:var(--danger)">❌ 网络错误</div>';
}
}
// ===== 通用工具 =====
function formatDate(d) {
if (!d) return '-';
@@ -1126,6 +1361,20 @@ document.getElementById('modalOverlay').addEventListener('click', (e) => {
document.getElementById('loginPassword').addEventListener('keydown', (e) => {
if (e.key === 'Enter') doLogin();
});
document.getElementById('initPasswordConfirm')?.addEventListener('keydown', (e) => {
if (e.key === 'Enter') doInitAdmin();
});
// 检查管理员是否存在,决定显示登录还是初始化
(async function() {
const hasAdmin = await checkAdminExists();
if (!hasAdmin) {
document.getElementById('loginForm').style.display = 'none';
document.getElementById('initForm').style.display = 'block';
document.getElementById('loginTitle').textContent = '🚀 初始化系统';
document.getElementById('loginSubtitle').textContent = '首次使用,请创建管理员账号';
}
})();
// 检查登录状态
if (token) {