1114 lines
58 KiB
JavaScript
1114 lines
58 KiB
JavaScript
/// 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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'); }
|
||
});
|
||
});
|
||
};
|
||
|
||
// ============ 页面:意向客户 leads(Phase 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 地址,支持多个 例如: 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();
|
||
}
|
||
})();
|