Files
2026-07-05 14:43:34 +08:00

1114 lines
58 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
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.
/// app.js
/// 苏州肛泰中医院 · 运营管理后台
/// 描述:单页应用 - 登录 / 概览 / 首页内容/医院简介/系统设置/预约管理/意向客户/账号设置
/// 版本:v2.0
(function () {
'use strict';
// ============ 版本号 ============
const APP_VERSION = 'v2.0';
// ============ 配置 ============
// 允许通过 ?api= 覆盖 API 地址,例如 admin/index.html?api=http://localhost:3000
const params = new URLSearchParams(location.search);
const API_BASE = (params.get('api') || localStorage.getItem('yygh_api_base') || '').replace(/\/$/, '');
// 业务常量
const APPT_STATUS = {
pending: { label: '待确认', tag: 'tag-warning' },
confirmed: { label: '已确认', tag: 'tag-primary' },
completed: { label: '已完成', tag: 'tag-success' },
cancelled: { label: '已取消', tag: 'tag-error' }
};
// ============ 工具 ============
const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
const toast = (msg, type = '') => {
const el = $('#toast');
el.textContent = msg;
el.className = 'toast show ' + type;
clearTimeout(toast._t);
toast._t = setTimeout(() => { el.className = 'toast ' + type; }, 2400);
};
// API 版本前缀(Phase 1 起统一为 v1)
const API_VERSION = '/v1';
const api = async (path, opts = {}) => {
// 自动给以 '/api/' 开头的 path 加版本号前缀(向后兼容:已带 /v1 不重复加)
let versionedPath = path;
if (path.startsWith('/api/') && !path.startsWith('/api/v1/')) {
versionedPath = '/api' + API_VERSION + path.slice(4);
}
const headers = { 'Content-Type': 'application/json', ...(opts.headers || {}) };
const token = localStorage.getItem('yygh_token');
if (token) headers.Authorization = 'Bearer ' + token;
const url = (API_BASE || '') + versionedPath;
const res = await fetch(url, { ...opts, headers: { ...headers, ...(opts.headers || {}) } });
let data;
try { data = await res.json(); } catch (e) { data = null; }
if (res.status === 401) {
// 鉴权失效,回到登录
localStorage.removeItem('yygh_token');
localStorage.removeItem('yygh_admin');
showLogin();
throw new Error(data?.message || '请先登录');
}
if (!res.ok || (data && data.code && data.code !== 0)) {
throw new Error((data && data.message) || `请求失败 (${res.status})`);
}
return data ? data.data : null;
};
const formatDate = (s) => {
if (!s) return '-';
const d = new Date(s);
if (Number.isNaN(d.getTime())) return s;
const pad = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
};
const escapeHtml = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
// ============ 登录 ============
const showLogin = () => {
$('#loginPage').classList.remove('hidden');
$('#appPage').classList.add('hidden');
};
const showApp = () => {
$('#loginPage').classList.add('hidden');
$('#appPage').classList.remove('hidden');
const admin = JSON.parse(localStorage.getItem('yygh_admin') || '{}');
$('#userName').textContent = admin.nickname || admin.username || '管理员';
$('#userChip').querySelector('.user-avatar').textContent = (admin.nickname || admin.username || 'A').charAt(0).toUpperCase();
};
$('#loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const username = $('#loginUsername').value.trim();
const password = $('#loginPassword').value;
if (!username || !password) return toast('请输入账号和密码', 'error');
try {
const data = await api('/api/admin/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) });
localStorage.setItem('yygh_token', data.token);
localStorage.setItem('yygh_admin', JSON.stringify(data.admin));
showApp();
navigate(location.hash.replace('#', '') || 'dashboard');
toast('登录成功', 'success');
} catch (err) {
toast(err.message || '登录失败', 'error');
}
});
$('#logoutBtn').addEventListener('click', () => {
localStorage.removeItem('yygh_token');
localStorage.removeItem('yygh_admin');
showLogin();
toast('已退出登录');
});
// ============ Modal ============
const openModal = ({ title, body, footer, size = '' }) => {
$('#modalTitle').textContent = title;
$('#modalBody').innerHTML = body;
$('#modalFooter').innerHTML = footer || '';
$('#modal').classList.remove('hidden');
$('.modal-card').classList.toggle('modal-lg', size === 'lg');
};
const closeModal = () => { $('#modal').classList.add('hidden'); };
document.addEventListener('click', (e) => {
if (e.target.matches('[data-modal-close]')) closeModal();
});
// ============ 路由 ============
const routes = {
dashboard: renderDashboard,
appointments: renderAppointments,
homeContent: renderHomeContent,
hospitalIntro: renderHospitalIntro,
siteConfig: renderSiteConfig,
account: renderAccount,
leads: renderLeads
};
const navigate = (name) => {
if (!routes[name]) name = 'dashboard';
location.hash = name;
};
const renderCurrent = () => {
const name = location.hash.replace('#', '') || 'dashboard';
$$('.side-link').forEach((el) => el.classList.toggle('active', el.dataset.route === name));
const main = $('#appMain');
main.innerHTML = '<div class="page-loading">加载中…</div>';
Promise.resolve(routes[name]()).then((html) => { main.innerHTML = html; bindPage(name); })
.catch((err) => { main.innerHTML = `<div class="empty-state">加载失败:${escapeHtml(err.message)}</div>`; });
};
window.addEventListener('hashchange', renderCurrent);
$$('.side-link').forEach((el) => el.addEventListener('click', (e) => { e.preventDefault(); navigate(el.dataset.route); }));
// ============ 页面:概览 ============
async function renderDashboard() {
const [stats, recent] = await Promise.all([
api('/api/appointments/stats').catch(() => ({ total: 0, pending: 0, confirmed: 0, completed: 0, cancelled: 0 })),
api('/api/appointments').catch(() => [])
]);
const upcoming = recent.slice(0, 6);
return `
<div class="page">
<div class="page-head">
<div>
<h1 class="page-title">概览</h1>
<p class="page-desc">系统运行状态与近期预约一览</p>
</div>
<div class="page-actions">
<a class="btn" href="${API_BASE || ''}/api/v1/appointments" target="_blank" rel="noreferrer">查看 API</a>
<button class="btn btn-primary" data-go="appointments">前往预约管理 →</button>
</div>
</div>
<div class="stats">
${[
{ label: '累计预约', value: stats.total, foot: '所有时间段' },
{ label: '待确认', value: stats.pending, foot: '需要尽快处理', tag: 'tag-warning' },
{ label: '已确认', value: stats.confirmed, foot: '等待到诊', tag: 'tag-primary' },
{ label: '已完成', value: stats.completed, foot: '本月完成', tag: 'tag-success' }
].map((s) => `
<div class="stat-card">
<div class="stat-label">${s.label}</div>
<div class="stat-value">${s.value}</div>
<div class="stat-foot">${s.foot}</div>
</div>
`).join('')}
</div>
<div class="card">
<div class="card-head">
<div>
<h3 class="card-title">最近预约</h3>
<p class="card-subtitle">按创建时间倒序,最多 6 条</p>
</div>
<a class="btn btn-ghost btn-sm" data-go="appointments">查看全部</a>
</div>
${upcoming.length === 0 ? '<div class="empty-state">暂无预约数据</div>' : `
<div class="table-wrap" style="border:none;box-shadow:none">
<table class="data-table">
<thead><tr><th>患者</th><th>科室</th><th>日期</th><th>状态</th><th>提交时间</th></tr></thead>
<tbody>
${upcoming.map((a) => `
<tr>
<td>
<div class="name-cell">${escapeHtml(a.patientName)}</div>
<div class="sub">${escapeHtml(a.patientPhone)} · ${a.patientAge}岁</div>
</td>
<td>${escapeHtml(a.departmentName || '-')}</td>
<td>${escapeHtml(a.date || '-')}</td>
<td><span class="tag ${APPT_STATUS[a.status]?.tag || ''}">${APPT_STATUS[a.status]?.label || a.status}</span></td>
<td>${formatDate(a.createdAt)}</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`}
</div>
</div>
`;
}
// ============ 页面:预约管理 ============
async function renderAppointments() {
const list = await api('/api/appointments');
list.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
return `
<div class="page">
<div class="page-head">
<div>
<h1 class="page-title">预约管理</h1>
<p class="page-desc">查看、确认、标记完成或删除预约</p>
</div>
</div>
<div class="toolbar">
<input id="aptSearch" placeholder="🔍 输入手机号或姓名搜索" />
<select id="aptStatus">
<option value="">全部状态</option>
<option value="pending">待确认</option>
<option value="confirmed">已确认</option>
<option value="completed">已完成</option>
<option value="cancelled">已取消</option>
</select>
</div>
<div class="table-wrap">
${list.length === 0 ? '<div class="empty-state">暂无预约</div>' : `
<table class="data-table">
<thead>
<tr>
<th>患者</th><th>科室</th><th>预约日期</th><th>症状</th><th>状态</th><th>提交时间</th><th style="text-align:right">操作</th>
</tr>
</thead>
<tbody id="aptTbody">
${list.map(aptRow).join('')}
</tbody>
</table>
`}
</div>
</div>
`;
}
const aptRow = (a) => `
<tr data-id="${a.id}" data-phone="${escapeHtml(a.patientPhone)}" data-name="${escapeHtml(a.patientName)}" data-status="${a.status}">
<td>
<div class="name-cell">${escapeHtml(a.patientName)}</div>
<div class="sub">${escapeHtml(a.patientPhone)} · ${a.patientAge}岁</div>
</td>
<td>${escapeHtml(a.departmentName || '-')}</td>
<td>${escapeHtml(a.date || '-')}</td>
<td style="max-width:200px">${escapeHtml(a.symptom || '-')}</td>
<td><span class="tag ${APPT_STATUS[a.status]?.tag || ''}">${APPT_STATUS[a.status]?.label || a.status}</span></td>
<td>${formatDate(a.createdAt)}</td>
<td>
<div class="actions">
${a.status === 'pending' ? `<button class="btn btn-primary btn-sm" data-act="confirm">确认</button>` : ''}
${a.status === 'confirmed' ? `<button class="btn btn-success btn-sm" data-act="complete">完成</button>` : ''}
${['pending', 'confirmed'].includes(a.status) ? `<button class="btn btn-warning btn-sm" data-act="cancel">取消</button>` : ''}
<button class="btn btn-danger btn-sm" data-act="delete">删除</button>
</div>
</td>
</tr>
`;
const bindAppointments = () => {
const filter = () => {
const q = $('#aptSearch').value.trim().toLowerCase();
const s = $('#aptStatus').value;
$$('#aptTbody tr').forEach((tr) => {
const ok = (!q || tr.dataset.phone.toLowerCase().includes(q) || tr.dataset.name.toLowerCase().includes(q))
&& (!s || tr.dataset.status === s);
tr.style.display = ok ? '' : 'none';
});
};
$('#aptSearch')?.addEventListener('input', filter);
$('#aptStatus')?.addEventListener('change', filter);
$$('#aptTbody button[data-act]').forEach((btn) => {
btn.addEventListener('click', async () => {
const tr = btn.closest('tr');
const id = tr.dataset.id;
const act = btn.dataset.act;
const actions = {
confirm: { method: 'PUT', path: `/api/appointments/${id}/confirm`, msg: '已确认' },
complete: { method: 'PUT', path: `/api/appointments/${id}/complete`, msg: '已标记完成' },
cancel: { method: 'PUT', path: `/api/appointments/${id}/cancel`, msg: '已取消' },
delete: { method: 'DELETE', path: `/api/admin/appointments/${id}`, msg: '已删除' }
};
if (act === 'delete' && !confirm('确定删除该预约?此操作不可恢复')) return;
try {
await api(actions[act].path, { method: actions[act].method });
toast(actions[act].msg, 'success');
reloadPage('appointments');
} catch (err) { toast(err.message, 'error'); }
});
});
};
// ============ 页面:意向客户 leadsPhase 3 ============
// 问卷题目映射(与前端 self-test 页面保持一致)
const QUESTION_MAP = [
{ text: '您最近是否出现过大便带血或便后滴血的情况?', options: { no: '无', sometimes: '偶尔', often: '经常' } },
{ text: '您是否有肛门疼痛、瘙痒或异物感?', options: { no: '无', sometimes: '偶尔', often: '经常' } },
{ text: '您是否长期便秘、腹泻或两者交替出现?', options: { no: '无', sometimes: '偶尔', often: '经常' } },
{ text: '您是否感到腹胀、腹痛或饭后不适?', options: { no: '无', sometimes: '偶尔', often: '经常' } },
{ text: '您是否出现过肛门肿物脱出?', options: { no: '无', self: '可自行回纳', manual: '需用手推回', none: '无法回纳' } },
{ text: '您的排便习惯是否近期发生明显变化?', options: { no: '无变化', sometimes: '偶有变化', obvious: '明显变化' } },
{ text: '您是否有胃肠疾病家族史?', options: { no: '无', unknown: '不清楚', yes: '有' } }
];
const LEAD_SOURCES = [
{ value: 'miniapp-phone', label: '小程序获取' },
{ value: 'self-test', label: '在线自测' },
{ value: 'home', label: '首页落地' },
{ value: 'appointment', label: '预约入口' },
{ value: 'other', label: '其他' }
];
async function renderLeads() {
const keyword = new URLSearchParams(location.search).get('kw') || '';
const source = new URLSearchParams(location.search).get('src') || '';
const params = new URLSearchParams();
if (keyword) params.set('keyword', keyword);
if (source) params.set('source', source);
params.set('limit', '50');
const qs = params.toString();
const data = await api(`/api/admin/leads${qs ? `?${qs}` : ''}`);
const list = data?.list || [];
const total = data?.total || 0;
const stats = await api('/api/leads/stats').catch(() => ({ total: 0, last24h: 0, bySource: {} }));
return `
<div class="page">
<div class="page-head">
<div>
<h1 class="page-title">意向客户</h1>
<p class="page-desc">自主问卷等渠道收集的留资线索(累计 ${stats.total} 条 · 24h 新增 ${stats.last24h}</p>
</div>
</div>
<div class="stats">
${[
{ label: '累计留资', value: stats.total },
{ label: '24h 新增', value: stats.last24h },
{ label: '本次查询', value: total }
].map((s) => `
<div class="stat-card">
<div class="stat-label">${s.label}</div>
<div class="stat-value">${s.value}</div>
</div>
`).join('')}
</div>
<div class="card">
<div class="card-head">
<form class="toolbar" id="leadFilter">
<input class="input" name="keyword" placeholder="按姓名搜索" value="${escapeHtml(keyword)}" />
<select class="input" name="source">
<option value="">全部渠道</option>
${LEAD_SOURCES.map((s) => `<option value="${s.value}" ${source === s.value ? 'selected' : ''}>${s.label}</option>`).join('')}
</select>
<button class="btn btn-primary btn-sm" type="submit">查询</button>
<button class="btn btn-sm" type="button" data-act="reset">重置</button>
</form>
</div>
<div class="table-wrap" style="border:none;box-shadow:none">
${list.length === 0 ? '<div class="empty-state">暂无意向客户</div>' : `
<table class="data-table">
<thead><tr><th>提交时间</th><th>姓名</th><th>手机</th><th>来源</th><th>得分</th><th>症状</th><th>备注</th><th style="text-align:right">操作</th></tr></thead>
<tbody id="leadTbody">
${list.map((l) => `
<tr data-id="${l.id}">
<td><div>${formatDate(l.createdAt)}</div></td>
<td><div class="name-cell">${escapeHtml(l.name)}</div></td>
<td>${escapeHtml(l.phone)}</td>
<td>${LEAD_SOURCES.find((s) => s.value === l.source)?.label || l.source}${l.source === 'miniapp-phone' ? ' 📱' : ''}</td>
<td>${Number(l.score) || 0}</td>
<td><div class="text-ellipsis-2" style="max-width:240rpx">${escapeHtml(l.symptom || '-')}</div></td>
<td>${escapeHtml(l.remark || '')}</td>
<td>
<div class="actions">
<button class="btn btn-sm" data-act="view">查看问卷</button>
<button class="btn btn-sm" data-act="remark">${l.remark ? '改备注' : '+ 备注'}</button>
<button class="btn btn-danger btn-sm" data-act="delete">删除</button>
</div>
</td>
</tr>
`).join('')}
</tbody>
</table>
`}
</div>
</div>
</div>
`;
}
const bindLeads = () => {
$('#leadFilter')?.addEventListener('submit', (e) => {
e.preventDefault();
const fd = new FormData(e.target);
const params = new URLSearchParams();
const kw = fd.get('keyword'); if (kw) params.set('kw', String(kw));
const src = fd.get('source'); if (src) params.set('src', String(src));
location.search = params.toString();
});
$('button[data-act="reset"]')?.addEventListener('click', () => { location.search = ''; });
$$('#leadTbody button[data-act]').forEach((btn) => {
btn.addEventListener('click', async () => {
const id = btn.closest('tr').dataset.id;
const act = btn.dataset.act;
try {
if (act === 'view') {
const l = await api(`/api/admin/leads/${id}`);
const q = l.questionnaire || {};
const locationHtml = q.location
? `<div class="form-field"><label>授权位置</label><div>📍 ${q.location.address || (q.location.latitude + ',' + q.location.longitude)}</div></div>`
: '';
const authTimeHtml = q.authTime
? `<div class="form-field"><label>授权时间</label><div>${formatDate(q.authTime)}</div></div>`
: '';
const ansHtml = Object.entries(q.answers || {})
.map(([k, v]) => {
const idx = Number(k);
const qItem = QUESTION_MAP[idx];
const qText = qItem ? qItem.text : `第 ${idx + 1} 题`;
const aText = qItem ? (qItem.options[v] || String(v)) : String(v);
return `<div class="lead-q"><span class="lead-qi">Q${idx + 1}</span><span class="lead-qtext">${escapeHtml(qText)}</span><span class="lead-qv">${escapeHtml(aText)}</span></div>`;
})
.join('') || '<div class="sub">(无)</div>';
openModal({
size: 'lg',
title: '意向客户详情',
body: `
<div class="form-grid">
<div class="form-field"><label>姓名</label><div>${escapeHtml(l.name)}</div></div>
<div class="form-field"><label>手机</label><div>${escapeHtml(l.phone)}</div></div>
<div class="form-field"><label>来源</label><div>${LEAD_SOURCES.find((s) => s.value === l.source)?.label || l.source}${l.source === 'miniapp-phone' ? ' 📱' : ''}</div></div>
<div class="form-field"><label>得分</label><div>${Number(l.score) || 0} 分</div></div>
${authTimeHtml}
${locationHtml}
<div class="form-field full"><label>症状描述</label><div style="white-space:pre-wrap">${escapeHtml(l.symptom || '-')}</div></div>
<div class="form-field full"><label>问卷得分</label><div>${Number(q.total) || 0} 分</div></div>
<div class="form-field full"><label>问卷答案</label>${ansHtml}</div>
<div class="form-field"><label>提交时间</label><div>${formatDate(l.createdAt)}</div></div>
<div class="form-field"><label>Lead ID</label><div class="sub">${escapeHtml(l.id)}</div></div>
</div>
`,
footer: `<button class="btn btn-primary" data-modal-close>关闭</button>`
});
} else if (act === 'remark') {
const l = await api(`/api/admin/leads/${id}`);
openModal({
title: `${l.remark ? '修改' : '添加'}备注`,
body: `<form id="remarkForm"><div class="form-field"><label>备注(≤500 字)</label><textarea name="remark" rows="4" maxlength="500">${escapeHtml(l.remark || '')}</textarea></div></form>`,
footer: `<button class="btn" data-modal-close>取消</button><button class="btn btn-primary" id="remarkSave">保存</button>`
});
$('#remarkSave').addEventListener('click', async () => {
try {
const fd = new FormData($('#remarkForm'));
await api(`/api/admin/leads/${id}/remark`, { method: 'PUT', body: JSON.stringify({ remark: fd.get('remark') }) });
toast('已更新', 'success'); closeModal(); reloadPage('leads');
} catch (err) { toast(err.message, 'error'); }
});
} else if (act === 'delete') {
if (!confirm('确定删除该意向客户?此操作不可恢复。')) return;
try {
await api(`/api/admin/leads/${id}`, { method: 'DELETE' });
toast('已删除', 'success'); reloadPage('leads');
} catch (err) { toast(err.message, 'error'); }
}
} catch (err) { toast(err.message, 'error'); }
});
});
};
// ============ 通用图片上传 (file → /uploads/image → 返回可访问 URL) ============
const fileToBase64 = (file) => new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(file);
});
const handleImageFile = async (input, previewSelector) => {
const file = input.files?.[0];
if (!file) return;
try {
const dataUrl = await fileToBase64(file);
const out = await api('/api/uploads/image', { method: 'POST', body: JSON.stringify({ base64: dataUrl }) });
input.dataset.url = out.url;
const prev = $(previewSelector);
if (prev) { prev.src = out.url; prev.style.display = 'block'; }
} catch (err) { toast(err.message || '上传失败', 'error'); }
input.value = '';
};
// ============ 页面:首页内容(含 3 子 tab:轮播 / 中心 / 长图)============
const HOME_CONTENT_TABS = [
{ id: 'banner', label: '顶部轮播图' },
{ id: 'center', label: '三个中心' },
{ id: 'intro', label: '简介长图' }
];
// ---------- 子 tab 1: 轮播图 ----------
const renderBannerList = async () => {
const list = await api('/api/banners');
return `
<div class="table-wrap">
${list.length === 0 ? '<div class="empty-state">暂无 Banner, 点击右上角"新增"</div>' : `
<table class="data-table">
<thead><tr><th style="width:140px">预览</th><th>标题</th><th>链接</th><th>排序</th><th>状态</th><th style="text-align:right">操作</th></tr></thead>
<tbody>
${list.map((b) => `
<tr data-id="${b.id}">
<td><img src="${escapeHtml(b.image || '')}" style="width:120px;height:60px;object-fit:cover;border-radius:6px" onerror="this.style.background='var(--color-bg-hover)';this.removeAttribute('src')" /></td>
<td><div class="name-cell">${escapeHtml(b.title || '-')}</div><div class="sub">${escapeHtml(b.subtitle || '')}</div></td>
<td><a href="${escapeHtml(b.link || '#')}" target="_blank" rel="noreferrer" style="color:var(--color-primary)">${escapeHtml(b.link || '-')}</a></td>
<td>${b.sort}</td>
<td>${b.enabled ? '<span class="tag tag-success">启用</span>' : '<span class="tag">停用</span>'}</td>
<td><div class="actions">
<button class="btn btn-sm" data-act="edit-banner">编辑</button>
<button class="btn btn-danger btn-sm" data-act="del-banner">删除</button>
</div></td>
</tr>
`).join('')}
</tbody>
</table>
`}
<div style="padding:16px"><button class="btn btn-primary" data-act="add-banner">+ 新增轮播图</button></div>
</div>
`;
};
const bannerForm = (b = {}) => `
<form id="bnForm" class="form-grid">
<div class="form-field full"><label>标题 *</label><input name="title" value="${escapeHtml(b.title || '')}" required /></div>
<div class="form-field full"><label>副标题</label><input name="subtitle" value="${escapeHtml(b.subtitle || '')}" /></div>
<div class="form-field full">
<label>图片 * (≤10MB, 推荐 750×420 比例)</label>
<input type="file" id="bnFile" accept="image/*" />
<img id="bnPreview" src="${escapeHtml(b.image || '')}" style="margin-top:8px;width:200px;max-height:120px;object-fit:cover;border-radius:8px;${b.image ? '' : 'display:none'}" />
<input type="hidden" name="image" value="${escapeHtml(b.image || '')}" />
<p class="form-hint" style="margin-top:6px;color:var(--color-text-tertiary);font-size:12px">支持选择本地文件自动上传, 也可直接粘贴外链 URL (后端会落盘到 /uploads/)</p>
</div>
<div class="form-field full"><label>跳转链接 (可留空)</label><input name="link" value="${escapeHtml(b.link || '')}" placeholder="/pages/xxx" /></div>
<div class="form-field"><label>排序</label><input name="sort" type="number" value="${b.sort ?? 99}" /></div>
<div class="form-field"><label>启用</label>
<label class="switch"><input type="checkbox" name="enabled" ${b.enabled === false ? '' : 'checked'} /><span class="slider"></span></label>
</div>
</form>
`;
const readBannerForm = () => {
const fd = new FormData($('#bnForm'));
return { title: fd.get('title'), subtitle: fd.get('subtitle'), image: fd.get('image'),
link: fd.get('link'), sort: Number(fd.get('sort') || 99), enabled: fd.get('enabled') !== null };
};
// ---------- 子 tab 2: 三个中心 ----------
const renderCenterList = async () => {
const list = await api('/api/centers');
return `
<div class="table-wrap">
${list.length === 0 ? '<div class="empty-state">暂无中心, 点击右上角"新增"</div>' : `
<table class="data-table">
<thead><tr><th style="width:100px">图标</th><th>名称</th><th>slug</th><th>简介</th><th>排序</th><th>状态</th><th style="text-align:right">操作</th></tr></thead>
<tbody>
${list.map((c) => `
<tr data-id="${c.id}">
<td>${c.iconImage
? `<img src="${escapeHtml(c.iconImage)}" style="width:60px;height:60px;object-fit:cover;border-radius:8px" onerror="this.style.background='var(--color-bg-hover)';this.removeAttribute('src')" />`
: `<span style="font-size:32px">${escapeHtml(c.icon || '🩺')}</span>`}</td>
<td class="name-cell">${escapeHtml(c.name)}</td>
<td><code>${escapeHtml(c.slug)}</code></td>
<td>${escapeHtml(c.intro || '-')}</td>
<td>${c.sort}</td>
<td>${c.enabled ? '<span class="tag tag-success">启用</span>' : '<span class="tag">停用</span>'}</td>
<td><div class="actions">
<button class="btn btn-sm" data-act="edit-center">编辑</button>
<button class="btn btn-danger btn-sm" data-act="del-center">删除</button>
</div></td>
</tr>
`).join('')}
</tbody>
</table>
`}
<div style="padding:16px"><button class="btn btn-primary" data-act="add-center">+ 新增中心</button></div>
</div>
`;
};
const centerForm = (c = {}) => `
<form id="ctForm" class="form-grid">
<div class="form-field"><label>名称 *</label><input name="name" value="${escapeHtml(c.name || '')}" required /></div>
<div class="form-field"><label>slug * (英文唯一)</label><input name="slug" value="${escapeHtml(c.slug || '')}" required pattern="[a-z0-9-]+" placeholder="gangchang" /></div>
<div class="form-field full"><label>简介 (≤30字)</label><input name="intro" value="${escapeHtml(c.intro || '')}" maxlength="60" /></div>
<div class="form-field full">
<label>图标 (emoji, 仅无图标图片时显示)</label>
<input name="icon" value="${escapeHtml(c.icon || '')}" placeholder="🩺" maxlength="4" />
</div>
<div class="form-field full">
<label>首页图标图片 (≤10MB, 推荐方形 96×96 以上)</label>
<input type="file" id="ctIconFile" accept="image/*" />
<img id="ctIconPreview" src="${escapeHtml(c.iconImage || '')}" style="margin-top:8px;width:96px;height:96px;object-fit:cover;border-radius:12px;${c.iconImage ? '' : 'display:none'}" />
<input type="hidden" name="iconImage" value="${escapeHtml(c.iconImage || '')}" />
</div>
<div class="form-field full">
<label>中心详情配图 (≤10MB, 推荐宽 750px)</label>
<input type="file" id="ctFile" accept="image/*" />
<img id="ctPreview" src="${escapeHtml(c.image || '')}" style="margin-top:8px;width:120px;height:120px;object-fit:cover;border-radius:12px;${c.image ? '' : 'display:none'}" />
<input type="hidden" name="image" value="${escapeHtml(c.image || '')}" />
</div>
<div class="form-field"><label>排序</label><input name="sort" type="number" value="${c.sort ?? 99}" /></div>
<div class="form-field"><label>启用</label>
<label class="switch"><input type="checkbox" name="enabled" ${c.enabled === false ? '' : 'checked'} /><span class="slider"></span></label>
</div>
</form>
`;
const readCenterForm = () => {
const fd = new FormData($('#ctForm'));
return { name: fd.get('name'), slug: fd.get('slug'), intro: fd.get('intro'),
icon: fd.get('icon'), iconImage: fd.get('iconImage'), image: fd.get('image'),
sort: Number(fd.get('sort') || 99), enabled: fd.get('enabled') !== null };
};
// ---------- 子 tab 3: 简介长图 ----------
const renderIntroEditor = async () => {
const cur = await api('/api/home-intro');
return `
<div class="card">
<div class="card-head">
<div><h3 class="card-title">首页简介长图</h3><p class="card-subtitle">此图会显示在首页底部 (3 中心卡下方), 单张长图, 可竖向滚动浏览</p></div>
</div>
<form id="hiForm" class="form-grid">
<div class="form-field full">
<label>长图 * (≤10MB, 推荐 750×2000+ 像素长条图)</label>
<input type="file" id="hiFile" accept="image/*" />
<img id="hiPreview" src="${escapeHtml(cur?.image || '')}" style="margin-top:8px;max-width:100%;max-height:400px;object-fit:contain;border-radius:8px;${cur?.image ? '' : 'display:none'}" />
<input type="hidden" name="image" value="${escapeHtml(cur?.image || '')}" />
<p class="form-hint" style="margin-top:6px;color:var(--color-text-tertiary);font-size:12px">支持选择本地文件自动上传, 也可粘贴外链 URL</p>
</div>
<div class="form-field full"><label>说明 (仅管理可见)</label><input name="caption" value="${escapeHtml(cur?.caption || '')}" /></div>
<div class="form-field"><label>启用</label>
<label class="switch"><input type="checkbox" name="enabled" ${cur?.enabled === false ? '' : 'checked'} /><span class="slider"></span></label>
</div>
<div class="form-field" style="display:flex;align-items:flex-end"><button type="button" class="btn btn-primary" id="hiSave">保存</button></div>
</form>
</div>
`;
};
// ---------- 父页 ----------
async function renderHomeContent() {
return `
<div class="page">
<div class="page-head">
<div><h1 class="page-title">首页内容</h1><p class="page-desc">统一管理小程序首页所有可视化资源</p></div>
</div>
<div class="subtab-bar" id="hcTabs">
${HOME_CONTENT_TABS.map((t, i) => `<button class="subtab ${i === 0 ? 'active' : ''}" data-tab="${t.id}">${t.label}</button>`).join('')}
</div>
<div id="hcPanel"></div>
</div>
`;
}
const bindHomeContent = () => {
let activeTab = 'banner';
const renderPanel = async (tabId) => {
activeTab = tabId;
$$('#hcTabs .subtab').forEach((b) => b.classList.toggle('active', b.dataset.tab === tabId));
const panel = $('#hcPanel');
panel.innerHTML = '<div class="page-loading">加载中…</div>';
let html = '';
if (tabId === 'banner') html = await renderBannerList();
else if (tabId === 'center') html = await renderCenterList();
else if (tabId === 'intro') html = await renderIntroEditor();
panel.innerHTML = html;
bindPanel(tabId);
};
const bindPanel = (tabId) => {
if (tabId === 'banner') {
$('button[data-act="add-banner"]')?.addEventListener('click', () => {
openModal({ title: '新增轮播图', body: bannerForm(),
footer: `<button class="btn" data-modal-close>取消</button><button class="btn btn-primary" id="bnSave">保存</button>` });
$('#bnFile').addEventListener('change', (e) => handleImageFile(e.target, '#bnPreview').then(() => {
$('input[name="image"]').value = e.target.dataset.url || '';
}));
$('#bnSave').addEventListener('click', async () => {
try { const body = readBannerForm(); if (!body.image) return toast('请上传图片', 'error');
await api('/api/banners', { method: 'POST', body: JSON.stringify(body) });
toast('已新增', 'success'); closeModal(); renderPanel('banner');
} catch (err) { toast(err.message, 'error'); }
});
});
$$('button[data-act="edit-banner"]').forEach((b) => b.addEventListener('click', async () => {
const id = b.closest('tr').dataset.id;
const list = await api('/api/banners');
const cur = list.find((x) => x.id === id);
openModal({ title: '编辑轮播图', body: bannerForm(cur),
footer: `<button class="btn" data-modal-close>取消</button><button class="btn btn-primary" id="bnSave">保存</button>` });
$('#bnFile').addEventListener('change', (e) => handleImageFile(e.target, '#bnPreview').then(() => {
$('input[name="image"]').value = e.target.dataset.url || '';
}));
$('#bnSave').addEventListener('click', async () => {
try { const body = readBannerForm(); if (!body.image) return toast('请上传图片', 'error');
await api(`/api/banners/${id}`, { method: 'PUT', body: JSON.stringify(body) });
toast('已更新', 'success'); closeModal(); renderPanel('banner');
} catch (err) { toast(err.message, 'error'); }
});
}));
$$('button[data-act="del-banner"]').forEach((b) => b.addEventListener('click', async () => {
if (!confirm('确定删除?')) return;
try { await api(`/api/banners/${b.closest('tr').dataset.id}`, { method: 'DELETE' });
toast('已删除', 'success'); renderPanel('banner');
} catch (err) { toast(err.message, 'error'); }
}));
} else if (tabId === 'center') {
$('button[data-act="add-center"]')?.addEventListener('click', () => {
openModal({ size: 'lg', title: '新增中心', body: centerForm(),
footer: `<button class="btn" data-modal-close>取消</button><button class="btn btn-primary" id="ctSave">保存</button>` });
$('#ctIconFile').addEventListener('change', (e) => handleImageFile(e.target, '#ctIconPreview').then(() => {
$('input[name="iconImage"]').value = e.target.dataset.url || '';
}));
$('#ctFile').addEventListener('change', (e) => handleImageFile(e.target, '#ctPreview').then(() => {
$('input[name="image"]').value = e.target.dataset.url || '';
}));
$('#ctSave').addEventListener('click', async () => {
try { const body = readCenterForm();
if (!body.iconImage && !body.icon) return toast('请上传图标图片或填写 emoji 图标', 'error');
await api('/api/centers', { method: 'POST', body: JSON.stringify(body) });
toast('已新增', 'success'); closeModal(); renderPanel('center');
} catch (err) { toast(err.message, 'error'); }
});
});
$$('button[data-act="edit-center"]').forEach((b) => b.addEventListener('click', async () => {
const id = b.closest('tr').dataset.id;
const list = await api('/api/centers');
const cur = list.find((x) => x.id === id);
openModal({ size: 'lg', title: '编辑中心', body: centerForm(cur),
footer: `<button class="btn" data-modal-close>取消</button><button class="btn btn-primary" id="ctSave">保存</button>` });
$('#ctIconFile').addEventListener('change', (e) => handleImageFile(e.target, '#ctIconPreview').then(() => {
$('input[name="iconImage"]').value = e.target.dataset.url || '';
}));
$('#ctFile').addEventListener('change', (e) => handleImageFile(e.target, '#ctPreview').then(() => {
$('input[name="image"]').value = e.target.dataset.url || '';
}));
$('#ctSave').addEventListener('click', async () => {
try { const body = readCenterForm();
await api(`/api/centers/${id}`, { method: 'PUT', body: JSON.stringify(body) });
toast('已更新', 'success'); closeModal(); renderPanel('center');
} catch (err) { toast(err.message, 'error'); }
});
}));
$$('button[data-act="del-center"]').forEach((b) => b.addEventListener('click', async () => {
if (!confirm('确定删除?')) return;
try { await api(`/api/centers/${b.closest('tr').dataset.id}`, { method: 'DELETE' });
toast('已删除', 'success'); renderPanel('center');
} catch (err) { toast(err.message, 'error'); }
}));
} else if (tabId === 'intro') {
$('#hiFile')?.addEventListener('change', (e) => handleImageFile(e.target, '#hiPreview').then(() => {
$('input[name="image"]').value = e.target.dataset.url || '';
}));
$('#hiSave')?.addEventListener('click', async () => {
try { const fd = new FormData($('#hiForm'));
const body = { image: fd.get('image'), caption: fd.get('caption'), enabled: fd.get('enabled') !== null };
if (!body.image) return toast('请上传长图', 'error');
await api('/api/home-intro', { method: 'PUT', body: JSON.stringify(body) });
toast('已保存', 'success');
} catch (err) { toast(err.message, 'error'); }
});
}
};
$$('#hcTabs .subtab').forEach((b) => b.addEventListener('click', () => renderPanel(b.dataset.tab)));
renderPanel('banner');
};
// ============ 页面:医院简介 (Hospital Intro) ============
async function renderHospitalIntro() {
const list = await api('/api/hospital-images');
return `
<div class="page">
<div class="page-head">
<div>
<h1 class="page-title">医院简介</h1>
<p class="page-desc">管理小程序"医院简介" tab 页展示的图片列表</p>
</div>
<div class="page-actions"><button class="btn btn-primary" data-act="add-hi">+ 新增图片</button></div>
</div>
<div class="table-wrap">
${list.length === 0 ? '<div class="empty-state">暂无图片</div>' : `
<table class="data-table">
<thead><tr><th style="width:140px">预览</th><th>说明</th><th>排序</th><th>状态</th><th style="text-align:right">操作</th></tr></thead>
<tbody>
${list.map((it) => `
<tr data-id="${it.id}">
<td><img src="${escapeHtml(it.image || '')}" style="width:120px;height:70px;object-fit:cover;border-radius:6px" onerror="this.style.background='var(--color-bg-hover)';this.removeAttribute('src')" /></td>
<td>${escapeHtml(it.caption || '-')}</td>
<td>${it.sort}</td>
<td>${it.enabled ? '<span class="tag tag-success">启用</span>' : '<span class="tag">停用</span>'}</td>
<td><div class="actions">
<button class="btn btn-sm" data-act="edit-hi">编辑</button>
<button class="btn btn-danger btn-sm" data-act="del-hi">删除</button>
</div></td>
</tr>
`).join('')}
</tbody>
</table>
`}
</div>
</div>
`;
}
const hospitalImageForm = (it = {}) => `
<form id="hiImgForm" class="form-grid">
<div class="form-field full">
<label>图片 * (≤10MB, jpg/png/webp)</label>
<input type="file" id="hiImgFile" accept="image/*" />
<img id="hiImgPreview" src="${escapeHtml(it.image || '')}" style="margin-top:8px;max-width:100%;max-height:240px;object-fit:cover;border-radius:8px;${it.image ? '' : 'display:none'}" />
<input type="hidden" name="image" value="${escapeHtml(it.image || '')}" />
</div>
<div class="form-field full"><label>说明 / 标题</label><input name="caption" value="${escapeHtml(it.caption || '')}" maxlength="200" /></div>
<div class="form-field"><label>排序</label><input name="sort" type="number" value="${it.sort ?? 99}" /></div>
<div class="form-field"><label>启用</label>
<label class="switch"><input type="checkbox" name="enabled" ${it.enabled === false ? '' : 'checked'} /><span class="slider"></span></label>
</div>
</form>
`;
const readHospitalImageForm = () => {
const fd = new FormData($('#hiImgForm'));
return { image: fd.get('image'), caption: fd.get('caption'),
sort: Number(fd.get('sort') || 99), enabled: fd.get('enabled') !== null };
};
const bindHospitalIntro = () => {
$('button[data-act="add-hi"]')?.addEventListener('click', () => {
openModal({ size: 'lg', title: '新增医院图片', body: hospitalImageForm(),
footer: `<button class="btn" data-modal-close>取消</button><button class="btn btn-primary" id="hiImgSave">保存</button>` });
$('#hiImgFile').addEventListener('change', (e) => handleImageFile(e.target, '#hiImgPreview').then(() => {
$('input[name="image"]').value = e.target.dataset.url || '';
}));
$('#hiImgSave').addEventListener('click', async () => {
try { const body = readHospitalImageForm(); if (!body.image) return toast('请上传图片', 'error');
await api('/api/hospital-images', { method: 'POST', body: JSON.stringify(body) });
toast('已新增', 'success'); closeModal(); reloadPage('hospitalIntro');
} catch (err) { toast(err.message, 'error'); }
});
});
$$('button[data-act="edit-hi"]').forEach((b) => b.addEventListener('click', async () => {
const id = b.closest('tr').dataset.id;
const list = await api('/api/hospital-images');
const cur = list.find((x) => x.id === id);
openModal({ size: 'lg', title: '编辑医院图片', body: hospitalImageForm(cur),
footer: `<button class="btn" data-modal-close>取消</button><button class="btn btn-primary" id="hiImgSave">保存</button>` });
$('#hiImgFile').addEventListener('change', (e) => handleImageFile(e.target, '#hiImgPreview').then(() => {
$('input[name="image"]').value = e.target.dataset.url || '';
}));
$('#hiImgSave').addEventListener('click', async () => {
try { const body = readHospitalImageForm(); if (!body.image) return toast('请上传图片', 'error');
await api(`/api/hospital-images/${id}`, { method: 'PUT', body: JSON.stringify(body) });
toast('已更新', 'success'); closeModal(); reloadPage('hospitalIntro');
} catch (err) { toast(err.message, 'error'); }
});
}));
$$('button[data-act="del-hi"]').forEach((b) => b.addEventListener('click', async () => {
if (!confirm('确定删除?')) return;
try { await api(`/api/hospital-images/${b.closest('tr').dataset.id}`, { method: 'DELETE' });
toast('已删除', 'success'); reloadPage('hospitalIntro');
} catch (err) { toast(err.message, 'error'); }
}));
};
// ============ 页面:系统设置(站点配置)============
async function renderSiteConfig() {
const cur = await api('/api/config/site');
return `
<div class="page">
<div class="page-head">
<div>
<h1 class="page-title">系统设置</h1>
<p class="page-desc">维护小程序底栏"电话咨询"号码 & 微信手机号授权配置</p>
</div>
</div>
<div class="card" style="max-width:560px">
<div class="card-head">
<div>
<h3 class="card-title">咨询电话</h3>
<p class="card-subtitle">此号码会在小程序底栏最右"电话咨询"按钮上使用, 用户点击直接拨号</p>
</div>
</div>
<div class="form-grid">
<div class="form-field full">
<label>咨询电话 * (座机或手机号)</label>
<input id="sitePhone" value="${escapeHtml(cur?.phone || '')}" placeholder="如: 0512-65863999 或 13800000000" />
<p class="form-hint">支持格式: 座机 (区号-号码) 或 11 位手机号, 用户点击时小程序会自动清除非数字字符后拨号。此号码也用于预约页"健康咨询热线"</p>
</div>
<div class="form-field full">
<label>医院地址</label>
<input id="siteAddress" value="${escapeHtml(cur?.address || '')}" placeholder="如: 江苏省苏州市姑苏区吴中西路 888 号" />
<p class="form-hint">显示在预约页面底部</p>
</div>
<div class="form-field full">
<label>医院名称</label>
<input id="siteName" value="${escapeHtml(cur?.name || '')}" placeholder="如: 苏州肛泰中医院" />
<p class="form-hint">显示在小程序首页导航栏标题,留空则使用默认值</p>
</div>
</div>
</div>
<div class="card" style="max-width:560px;margin-top:20px">
<div class="card-head">
<div>
<h3 class="card-title">📱 微信手机号授权</h3>
<p class="card-subtitle">开启后,用户首次打开小程序时会弹出授权手机号弹窗,授权后自动填充到预约表单</p>
</div>
</div>
<div class="form-grid">
<div class="form-field full">
<label>开启手机号获取</label>
<label class="switch"><input type="checkbox" id="requirePhone" ${cur?.requirePhone ? 'checked' : ''} /><span class="slider"></span></label>
<p class="form-hint">开启后用户打开小程序时弹出微信官方手机号授权弹窗,关闭则不弹出</p>
</div>
<div class="form-field full">
<label>微信 AppID</label>
<input id="wechatAppid" value="${escapeHtml(cur?.wechatAppid || '')}" placeholder="wx5008ec49a5dba8f9" />
<p class="form-hint">在微信公众平台 → 开发管理 → 开发设置 中获取</p>
</div>
<div class="form-field full">
<label>微信 AppSecret</label>
<input id="wechatAppsecret" type="password" value="${escapeHtml(cur?.wechatAppsecret || '')}" placeholder="请输入 AppSecret" />
<p class="form-hint">在微信公众平台 → 开发管理 → 开发设置 中获取,重置后需同步更新</p>
</div>
<div class="form-field full">
<label>高德地图 Key(选填)</label>
<input id="amapKey" value="${escapeHtml(cur?.amapKey || '')}" placeholder="用于将用户经纬度转为地区名" />
<p class="form-hint">免费申请:登录 <a href="https://lbs.amap.com/" target="_blank" rel="noreferrer" style="color:var(--color-primary)">lbs.amap.com</a> → 控制台 → 创建应用 → 添加 Key(Web服务类型)· 不填则只记录经纬度</p>
</div>
<div class="form-field full">
<label>开启位置采集</label>
<label class="switch"><input type="checkbox" id="collectLocation" ${cur?.collectLocation ? 'checked' : ''} /><span class="slider"></span></label>
<p class="form-hint">开启后在用户授权手机号时同时获取模糊定位(市级),需要先在微信公众平台开通"模糊定位"接口权限。未开通请保持关闭,否则会报错</p>
</div>
<div class="form-field full" style="display:flex;gap:8px">
<button type="button" class="btn btn-primary" id="saveSite">保存全部设置</button>
<button type="button" class="btn" id="testDial">📞 模拟拨号测试</button>
</div>
</div>
</div>
<div class="card" style="max-width:560px;margin-top:20px">
<div class="card-head">
<div>
<h3 class="card-title">🔔 通知设置</h3>
<p class="card-subtitle">新预约、新意向客户时自动推送消息到企业微信群</p>
</div>
</div>
<div class="form-grid">
<div class="form-field full">
<label>开启通知</label>
<label class="switch"><input type="checkbox" id="notifyEnabled" ${cur?.notifyEnabled ? 'checked' : ''} /><span class="slider"></span></label>
<p class="form-hint">开启后,每次新预约或新意向客户时自动推送消息</p>
</div>
<div class="form-field full">
<label>企业微信 Webhook 地址</label>
<textarea id="notifyWebhooks" rows="4" placeholder="每行一个 Webhook 地址,支持多个&#10;例如: https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx">${escapeHtml(cur?.notifyWebhooks || '')}</textarea>
<p class="form-hint">在企业微信群 → 群设置 → 群机器人 → 添加机器人 → 复制 Webhook 地址。每行一个,可配置多个群</p>
</div>
<div class="form-field full">
<button type="button" class="btn" id="testNotify">发送测试通知</button>
</div>
</div>
</div>
<div class="card">
<div class="card-head">
<div>
<h3 class="card-title">📡 接口自检</h3>
<p class="card-subtitle">点击"测试"按钮可直接拉取最新数据, 验证小程序能看到</p>
</div>
</div>
<button class="btn" id="testFetch">测试拉取 /api/config/site</button>
<pre id="testResult" style="margin-top:12px;padding:12px;background:var(--color-bg-page);border-radius:8px;font-size:12px;white-space:pre-wrap;max-height:240px;overflow:auto"></pre>
</div>
</div>
`;
}
const bindSiteConfig = () => {
$('#saveSite')?.addEventListener('click', async () => {
try {
const phone = $('#sitePhone').value.trim();
const address = $('#siteAddress').value.trim();
const name = $('#siteName').value.trim();
const requirePhone = $('#requirePhone').checked;
const wechatAppid = $('#wechatAppid').value.trim();
const wechatAppsecret = $('#wechatAppsecret').value.trim();
const amapKey = $('#amapKey').value.trim();
const collectLocation = $('#collectLocation').checked;
const notifyEnabled = $('#notifyEnabled').checked;
const notifyWebhooks = $('#notifyWebhooks').value.trim();
if (!phone) return toast('咨询电话必填', 'error');
if (requirePhone && (!wechatAppid || !wechatAppsecret)) {
return toast('开启手机号获取需要填写 AppID 和 AppSecret', 'error');
}
await api('/api/config/site', { method: 'PUT', body: JSON.stringify({
phone, address, name, requirePhone, wechatAppid, wechatAppsecret, amapKey, collectLocation, notifyEnabled, notifyWebhooks
}) });
toast('已保存', 'success');
} catch (err) { toast(err.message, 'error'); }
});
$('#testDial')?.addEventListener('click', () => {
const p = $('#sitePhone').value.trim();
if (!p) return toast('请先填写咨询电话', 'error');
const cleaned = p.replace(/[^\d+]/g, '');
$('#testResult').textContent = '清洗后号码: ' + cleaned + '\n微信小程序会调用 Taro.makePhoneCall({ phoneNumber: "' + cleaned + '" })';
});
$('#testFetch')?.addEventListener('click', async () => {
try {
const r = await api('/api/config/site');
$('#testResult').textContent = JSON.stringify(r, null, 2);
} catch (err) {
$('#testResult').textContent = '错误: ' + err.message;
}
});
$('#testNotify')?.addEventListener('click', async () => {
const webhooks = $('#notifyWebhooks').value.trim();
if (!webhooks) return toast('请先填写 Webhook 地址', 'error');
try {
await api('/api/v1/notification/test', { method: 'POST' });
toast('测试通知已发送,请检查企业微信群', 'success');
} catch (err) {
toast('发送失败: ' + err.message, 'error');
}
});
};
// ============ 页面:账号设置 ============
async function renderAccount() {
const me = await api('/api/admin/auth/me');
return `
<div class="page">
<div class="page-head">
<div><h1 class="page-title">账号设置</h1><p class="page-desc">修改登录密码</p></div>
</div>
<div class="card" style="max-width:480px">
<div class="form-field" style="margin-bottom:12px"><label>账号</label><input value="${escapeHtml(me.username || '')}" disabled /></div>
<div class="form-field" style="margin-bottom:12px"><label>昵称</label><input value="${escapeHtml(me.nickname || '')}" disabled /></div>
<div class="form-field" style="margin-bottom:12px"><label>角色</label><input value="${escapeHtml(me.role || '')}" disabled /></div>
<div class="form-field" style="margin-bottom:12px"><label>最近登录</label><input value="${formatDate(me.last_login_at)}" disabled /></div>
</div>
<div class="card" style="max-width:480px">
<div class="card-head"><div><h3 class="card-title">修改密码</h3></div></div>
<form id="pwdForm">
<div class="form-field" style="margin-bottom:12px"><label>原密码</label><input type="password" name="oldPassword" required /></div>
<div class="form-field" style="margin-bottom:12px"><label>新密码(至少 8 位)</label><input type="password" name="newPassword" minlength="8" required /></div>
<button class="btn btn-primary" type="submit">提交</button>
</form>
</div>
</div>
`;
}
const bindAccount = () => {
$('#pwdForm')?.addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
try {
await api('/api/admin/auth/change-password', { method: 'POST', body: JSON.stringify({
oldPassword: fd.get('oldPassword'),
newPassword: fd.get('newPassword')
}) });
toast('密码已修改', 'success');
e.target.reset();
} catch (err) { toast(err.message, 'error'); }
});
};
// ============ 通用绑定 ============
const bindPage = (name) => {
// 全局:data-go 链接
$$('[data-go]').forEach((el) => el.addEventListener('click', (e) => { e.preventDefault(); navigate(el.dataset.go); }));
if (name === 'appointments') bindAppointments();
else if (name === 'homeContent') bindHomeContent();
else if (name === 'hospitalIntro') bindHospitalIntro();
else if (name === 'siteConfig') bindSiteConfig();
else if (name === 'account') bindAccount();
else if (name === 'leads') bindLeads();
};
const reloadPage = (name) => {
if (!name) name = location.hash.replace('#', '') || 'dashboard';
if (routes[name]) {
const main = $('#appMain');
main.innerHTML = '<div class="page-loading">加载中…</div>';
Promise.resolve(routes[name]()).then((html) => { main.innerHTML = html; bindPage(name); });
}
};
// ============ 启动 ============
if (localStorage.getItem('yygh_token')) {
showApp();
renderCurrent();
} else {
showLogin();
}
})();