Initial commit: 心理咨询预约系统
PHP 前后端一体:首页咨询师列表、预约表单、管理后台、二维码准入与邮件通知;敏感配置与提交数据通过 .gitignore 排除。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
51
js/access.js
Normal file
51
js/access.js
Normal file
@@ -0,0 +1,51 @@
|
||||
const ACCESS_STORAGE_KEY = "access_ok";
|
||||
|
||||
async function checkAccess() {
|
||||
if (sessionStorage.getItem(ACCESS_STORAGE_KEY) === "1") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const urlToken = params.get("access");
|
||||
|
||||
if (!urlToken) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const config = await loadAppConfig();
|
||||
if (urlToken === config.accessToken) {
|
||||
sessionStorage.setItem(ACCESS_STORAGE_KEY, "1");
|
||||
params.delete("access");
|
||||
const newSearch = params.toString();
|
||||
const newUrl =
|
||||
window.location.pathname + (newSearch ? "?" + newSearch : "") + window.location.hash;
|
||||
window.history.replaceState({}, "", newUrl);
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Access check failed:", e);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function showAccessDenied() {
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "access-denied";
|
||||
overlay.innerHTML = `
|
||||
<div class="access-denied__icon">🔒</div>
|
||||
<h2 class="access-denied__title">请使用专属二维码访问</h2>
|
||||
<p class="access-denied__desc">本服务仅对指定用户开放<br>请扫描管理员提供的二维码进入</p>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
async function initAccessGuard() {
|
||||
const allowed = await checkAccess();
|
||||
if (!allowed) {
|
||||
showAccessDenied();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
120
js/admin.js
Normal file
120
js/admin.js
Normal file
@@ -0,0 +1,120 @@
|
||||
let isLoggedIn = false;
|
||||
|
||||
function formatTime(iso) {
|
||||
if (!iso) return "-";
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString("zh-CN", { hour12: false });
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function renderSubmissionCard(item) {
|
||||
const card = document.createElement("article");
|
||||
card.className = "submission-card";
|
||||
const doctorInfo = item.doctorName ? `${item.doctorName}` : "未指定咨询师";
|
||||
card.innerHTML = `
|
||||
<div class="submission-card__time">${formatTime(item.createdAt)}</div>
|
||||
<div class="submission-card__row"><strong>昵称:</strong>${escapeHtml(item.nickname || "-")}</div>
|
||||
<div class="submission-card__row"><strong>性别:</strong>${escapeHtml(item.gender || "-")}</div>
|
||||
<div class="submission-card__row"><strong>联系电话:</strong>${escapeHtml(item.contact || "-")}</div>
|
||||
<div class="submission-card__row"><strong>咨询师:</strong>${escapeHtml(doctorInfo)}</div>
|
||||
<p class="submission-card__desc">${escapeHtml(item.description || "(无描述)")}</p>
|
||||
`;
|
||||
return card;
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function showLogin() {
|
||||
document.getElementById("login-section").style.display = "block";
|
||||
document.getElementById("dashboard-section").style.display = "none";
|
||||
isLoggedIn = false;
|
||||
}
|
||||
|
||||
function showDashboard() {
|
||||
document.getElementById("login-section").style.display = "none";
|
||||
document.getElementById("dashboard-section").style.display = "block";
|
||||
isLoggedIn = true;
|
||||
}
|
||||
|
||||
async function loadSubmissions() {
|
||||
const listEl = document.getElementById("submission-list");
|
||||
listEl.innerHTML = "<p>加载中…</p>";
|
||||
|
||||
try {
|
||||
const res = await apiGet("submissions.php", true);
|
||||
const items = res.data || [];
|
||||
document.getElementById("submission-count").textContent = items.length;
|
||||
|
||||
if (items.length === 0) {
|
||||
listEl.innerHTML = '<p class="admin-empty">暂无提交记录</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = "";
|
||||
items.forEach((item) => listEl.appendChild(renderSubmissionCard(item)));
|
||||
} catch (e) {
|
||||
if (e.message && e.message.includes("未登录")) {
|
||||
showLogin();
|
||||
return;
|
||||
}
|
||||
listEl.innerHTML = '<p class="admin-empty">加载失败</p>';
|
||||
showToast(e.message || "加载失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function tryAutoLogin() {
|
||||
try {
|
||||
const res = await apiGet("check.php", true);
|
||||
if (res.loggedIn) {
|
||||
showDashboard();
|
||||
await loadSubmissions();
|
||||
return true;
|
||||
}
|
||||
} catch (_) {}
|
||||
return false;
|
||||
}
|
||||
|
||||
function downloadExport(format) {
|
||||
window.location.href = `${API_BASE}/export.php?format=${format}`;
|
||||
}
|
||||
|
||||
async function initAdmin() {
|
||||
document.getElementById("login-form").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const user = document.getElementById("admin-user").value.trim();
|
||||
const password = document.getElementById("admin-password").value;
|
||||
|
||||
try {
|
||||
await apiPost("login.php", { user, password }, true);
|
||||
showToast("登录成功");
|
||||
showDashboard();
|
||||
await loadSubmissions();
|
||||
} catch (err) {
|
||||
showToast(err.message || "登录失败");
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("logout-btn").addEventListener("click", async () => {
|
||||
try {
|
||||
await apiPost("logout.php", {}, true);
|
||||
} catch (_) {}
|
||||
showLogin();
|
||||
showToast("已退出");
|
||||
});
|
||||
|
||||
document.getElementById("export-json").addEventListener("click", () => downloadExport("json"));
|
||||
document.getElementById("export-csv").addEventListener("click", () => downloadExport("csv"));
|
||||
document.getElementById("refresh-btn").addEventListener("click", loadSubmissions);
|
||||
|
||||
const ok = await tryAutoLogin();
|
||||
if (!ok) showLogin();
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", initAdmin);
|
||||
85
js/api.js
Normal file
85
js/api.js
Normal file
@@ -0,0 +1,85 @@
|
||||
/** 基于当前页面 URL 解析 api 路径,适配根目录或子目录部署 */
|
||||
function apiUrl(endpoint) {
|
||||
return new URL(`api/${endpoint}`, window.location.href).href;
|
||||
}
|
||||
|
||||
/** 静态资源追加版本号,避免手机浏览器缓存旧 CSS/JS/图片 */
|
||||
function assetUrl(path) {
|
||||
if (!path || /^https?:\/\//i.test(path)) return path;
|
||||
const v = window.__ASSET_V__ || "";
|
||||
if (!v) return path;
|
||||
const sep = path.includes("?") ? "&" : "?";
|
||||
return `${path}${sep}v=${encodeURIComponent(v)}`;
|
||||
}
|
||||
|
||||
async function parseResponse(res) {
|
||||
const text = await res.text();
|
||||
if (!text) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
const hint = res.status === 404
|
||||
? "接口不存在,请确认 api 目录已上传"
|
||||
: res.status >= 500
|
||||
? "服务器错误,请检查 PHP 与 data 目录写权限"
|
||||
: "服务器返回异常,请确认 PHP 已启用";
|
||||
throw new Error(`${hint}(HTTP ${res.status})`);
|
||||
}
|
||||
}
|
||||
|
||||
async function apiGet(endpoint, useCredentials = false) {
|
||||
const res = await fetch(apiUrl(endpoint), {
|
||||
credentials: useCredentials ? "include" : "same-origin",
|
||||
});
|
||||
const data = await parseResponse(res);
|
||||
if (!res.ok) {
|
||||
throw new Error(data.message || `请求失败(HTTP ${res.status})`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async function apiPost(endpoint, body, useCredentials = false, timeoutMs = 30000) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(apiUrl(endpoint), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
credentials: useCredentials ? "include" : "same-origin",
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (e) {
|
||||
clearTimeout(timer);
|
||||
if (e.name === "AbortError") {
|
||||
throw new Error("请求超时,数据可能已保存,请稍后刷新或联系管理员");
|
||||
}
|
||||
throw new Error("网络请求失败,请检查网络或域名是否正确");
|
||||
}
|
||||
clearTimeout(timer);
|
||||
const data = await parseResponse(res);
|
||||
if (!res.ok) {
|
||||
throw new Error(data.message || `提交失败(HTTP ${res.status})`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function showToast(message, duration = 2500) {
|
||||
const existing = document.querySelector(".toast");
|
||||
if (existing) existing.remove();
|
||||
const el = document.createElement("div");
|
||||
el.className = "toast";
|
||||
el.textContent = message;
|
||||
document.body.appendChild(el);
|
||||
setTimeout(() => el.remove(), duration);
|
||||
}
|
||||
|
||||
/** 供管理端下载使用 */
|
||||
const API_BASE = (() => {
|
||||
const u = new URL("api/", window.location.href);
|
||||
return u.pathname.replace(/\/$/, "");
|
||||
})();
|
||||
28
js/config-loader.js
Normal file
28
js/config-loader.js
Normal file
@@ -0,0 +1,28 @@
|
||||
let appConfigCache = null;
|
||||
let doctorsCache = null;
|
||||
let topicsCache = null;
|
||||
|
||||
async function loadAppConfig() {
|
||||
if (appConfigCache) return appConfigCache;
|
||||
const data = await apiGet("config.php");
|
||||
appConfigCache = data;
|
||||
return data;
|
||||
}
|
||||
|
||||
async function loadDoctors() {
|
||||
if (doctorsCache) return doctorsCache;
|
||||
const res = await apiGet("doctors.php");
|
||||
doctorsCache = res.data || [];
|
||||
return doctorsCache;
|
||||
}
|
||||
|
||||
async function loadTopics() {
|
||||
if (topicsCache) return topicsCache;
|
||||
const res = await apiGet("topics.php");
|
||||
topicsCache = res.data || [];
|
||||
return topicsCache;
|
||||
}
|
||||
|
||||
function getDoctorById(doctors, id) {
|
||||
return doctors.find((d) => d.id === id) || null;
|
||||
}
|
||||
174
js/consult.js
Normal file
174
js/consult.js
Normal file
@@ -0,0 +1,174 @@
|
||||
let selectedDoctor = null;
|
||||
|
||||
function getQueryDoctorId() {
|
||||
return new URLSearchParams(window.location.search).get("doctorId");
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function validatePhone(phone) {
|
||||
return /^1[3-9]\d{9}$/.test(phone);
|
||||
}
|
||||
|
||||
function getSelectedGender() {
|
||||
const checked = document.querySelector('input[name="gender"]:checked');
|
||||
return checked ? checked.value : "";
|
||||
}
|
||||
|
||||
function renderDoctorBanner(doctor) {
|
||||
const banner = document.getElementById("doctor-banner");
|
||||
if (!doctor) {
|
||||
banner.style.display = "none";
|
||||
return;
|
||||
}
|
||||
selectedDoctor = doctor;
|
||||
banner.style.display = "flex";
|
||||
banner.innerHTML = `
|
||||
<div class="doctor-banner__avatar-wrap">
|
||||
<div class="doctor-banner__avatar-ring">
|
||||
<img class="doctor-banner__avatar" src="${escapeHtml(assetUrl(doctor.avatar))}" alt="${escapeHtml(doctor.name)}" onerror="this.src='${assetUrl('assets/avatars/song.jpg')}'">
|
||||
</div>
|
||||
<span class="doctor-banner__verified" aria-hidden="true">
|
||||
<svg viewBox="0 0 12 10" width="10" height="8" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1 5.2L4.2 8.4L11 1.2" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<div class="doctor-banner__info">
|
||||
<div class="doctor-banner__name">${escapeHtml(doctor.name)} <span class="doctor-banner__tag">${escapeHtml(doctor.role || "")}</span></div>
|
||||
<div class="doctor-banner__hospital">${escapeHtml(doctor.hospital || "")}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function updateCharCount() {
|
||||
const textarea = document.getElementById("description");
|
||||
const count = document.getElementById("char-count");
|
||||
count.textContent = `(${textarea.value.length}/200字)`;
|
||||
}
|
||||
|
||||
function showSuccess() {
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "success-overlay";
|
||||
overlay.innerHTML = `
|
||||
<div class="success-overlay__icon">✅</div>
|
||||
<h2 class="success-overlay__title">提交成功</h2>
|
||||
<p class="success-overlay__desc">我们已收到您的预约信息<br>工作人员将在2小时内与您联系确认</p>
|
||||
<button type="button" class="btn btn--primary" id="success-back">返回首页</button>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
document.getElementById("success-back").addEventListener("click", () => {
|
||||
window.location.href = "index.php";
|
||||
});
|
||||
}
|
||||
|
||||
function markError(el) {
|
||||
el.classList.add("is-error");
|
||||
}
|
||||
|
||||
function clearErrors() {
|
||||
document.querySelectorAll(".is-error").forEach((el) => el.classList.remove("is-error"));
|
||||
}
|
||||
|
||||
function setupSubmitHandler() {
|
||||
const textarea = document.getElementById("description");
|
||||
const btn = document.getElementById("submit-btn");
|
||||
|
||||
btn.addEventListener("click", async () => {
|
||||
clearErrors();
|
||||
|
||||
const nickname = document.getElementById("nickname").value.trim();
|
||||
const gender = getSelectedGender();
|
||||
const contact = document.getElementById("contact").value.trim();
|
||||
const description = textarea.value.trim();
|
||||
|
||||
if (!nickname) {
|
||||
markError(document.getElementById("nickname"));
|
||||
showToast("请填写昵称");
|
||||
return;
|
||||
}
|
||||
if (!gender) {
|
||||
showToast("请选择性别");
|
||||
return;
|
||||
}
|
||||
if (!contact) {
|
||||
markError(document.getElementById("contact"));
|
||||
showToast("请填写联系电话");
|
||||
return;
|
||||
}
|
||||
if (!validatePhone(contact)) {
|
||||
markError(document.getElementById("contact"));
|
||||
showToast("请输入正确的11位手机号码");
|
||||
return;
|
||||
}
|
||||
if (!description) {
|
||||
markError(textarea);
|
||||
showToast("请填写需求简述");
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = "提交中…";
|
||||
|
||||
try {
|
||||
const result = await apiPost("submit.php", {
|
||||
doctorId: selectedDoctor?.id || "",
|
||||
doctorName: selectedDoctor?.name || "",
|
||||
nickname,
|
||||
gender,
|
||||
contact,
|
||||
description,
|
||||
}, false, 60000);
|
||||
showSuccess();
|
||||
} catch (e) {
|
||||
showToast(e.message || "提交失败");
|
||||
btn.disabled = false;
|
||||
btn.textContent = "提交";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function initConsult() {
|
||||
setupSubmitHandler();
|
||||
|
||||
const textarea = document.getElementById("description");
|
||||
textarea.addEventListener("input", () => {
|
||||
if (textarea.value.length > 200) {
|
||||
textarea.value = textarea.value.slice(0, 200);
|
||||
}
|
||||
updateCharCount();
|
||||
});
|
||||
updateCharCount();
|
||||
|
||||
document.getElementById("back-btn").addEventListener("click", () => {
|
||||
window.location.href = "index.php";
|
||||
});
|
||||
|
||||
const allowed = await initAccessGuard();
|
||||
if (!allowed) return;
|
||||
|
||||
try {
|
||||
const config = await loadAppConfig();
|
||||
document.title = (config.siteTitle || "心理咨询") + " - 预约";
|
||||
document.querySelector(".header__title").textContent = config.siteTitle || "心理咨询";
|
||||
|
||||
const phone = config.confirmPhone || "13122315169";
|
||||
document.getElementById("confirm-phone").textContent = phone;
|
||||
document.getElementById("service-hotline").textContent = config.servicePhone || phone;
|
||||
|
||||
const doctors = await loadDoctors();
|
||||
const doctorId = getQueryDoctorId();
|
||||
if (doctorId) {
|
||||
renderDoctorBanner(getDoctorById(doctors, doctorId));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showToast(e.message || "页面数据加载失败,仍可尝试提交");
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", initConsult);
|
||||
84
js/home.js
Normal file
84
js/home.js
Normal file
@@ -0,0 +1,84 @@
|
||||
function renderDoctorCard(doctor) {
|
||||
const fields = (doctor.fields || []).join("、");
|
||||
const card = document.createElement("article");
|
||||
card.className = "doctor-card";
|
||||
card.setAttribute("role", "button");
|
||||
card.setAttribute("tabindex", "0");
|
||||
card.dataset.doctorId = doctor.id;
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="doctor-card__avatar-wrap">
|
||||
<div class="doctor-card__avatar-ring">
|
||||
<img class="doctor-card__avatar" src="${escapeHtml(assetUrl(doctor.avatar))}" alt="${escapeHtml(doctor.name)}" loading="lazy" onerror="this.src='${assetUrl('assets/avatars/song.jpg')}'">
|
||||
</div>
|
||||
<span class="doctor-card__verified" aria-hidden="true">
|
||||
<svg viewBox="0 0 12 10" width="11" height="9" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1 5.2L4.2 8.4L11 1.2" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<div class="doctor-card__body">
|
||||
<div class="doctor-card__name-row">
|
||||
<span class="doctor-card__name">${escapeHtml(doctor.name)}</span>
|
||||
<span class="doctor-card__tag">${escapeHtml(doctor.role || "咨询师")}</span>
|
||||
</div>
|
||||
<p class="doctor-card__hospital">${escapeHtml(doctor.hospital || "")}</p>
|
||||
<p class="doctor-card__fields">
|
||||
<span class="doctor-card__fields-label">专业领域</span>${escapeHtml(fields)}
|
||||
</p>
|
||||
</div>
|
||||
<span class="doctor-card__arrow" aria-hidden="true">›</span>
|
||||
`;
|
||||
|
||||
card.addEventListener("click", () => goConsult(doctor.id));
|
||||
card.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
goConsult(doctor.id);
|
||||
}
|
||||
});
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function goConsult(doctorId) {
|
||||
const url = doctorId ? `consult.php?doctorId=${encodeURIComponent(doctorId)}` : "consult.php";
|
||||
window.location.href = url;
|
||||
}
|
||||
|
||||
async function initHome() {
|
||||
const allowed = await initAccessGuard();
|
||||
if (!allowed) return;
|
||||
|
||||
try {
|
||||
const config = await loadAppConfig();
|
||||
document.title = config.siteTitle || "心理咨询";
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
}
|
||||
|
||||
const listEl = document.getElementById("doctor-list");
|
||||
const ctaBtn = document.getElementById("cta-book");
|
||||
|
||||
try {
|
||||
const doctors = await loadDoctors();
|
||||
if (doctors.length === 0) {
|
||||
listEl.innerHTML = '<p class="doctor-list__empty">暂无咨询师信息</p>';
|
||||
} else {
|
||||
doctors.forEach((d) => listEl.appendChild(renderDoctorCard(d)));
|
||||
}
|
||||
} catch (e) {
|
||||
listEl.innerHTML = '<p class="doctor-list__empty">加载失败,请刷新重试</p>';
|
||||
showToast(e.message || "加载失败");
|
||||
}
|
||||
|
||||
ctaBtn.addEventListener("click", () => goConsult());
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", initHome);
|
||||
Reference in New Issue
Block a user