From 08ce60344d092cb5cecfee7530f14590cc4fb145 Mon Sep 17 00:00:00 2001 From: litianming Date: Thu, 4 Jun 2026 10:17:09 +0800 Subject: [PATCH] =?UTF-8?q?Initial=20commit:=20=E5=BF=83=E7=90=86=E5=92=A8?= =?UTF-8?q?=E8=AF=A2=E9=A2=84=E7=BA=A6=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PHP 前后端一体:首页咨询师列表、预约表单、管理后台、二维码准入与邮件通知;敏感配置与提交数据通过 .gitignore 排除。 Co-authored-by: Cursor --- .gitignore | 17 ++ .htaccess | 25 +++ README.md | 129 ++++++++++++ admin.html | 10 + admin.php | 55 +++++ api/bootstrap.php | 178 ++++++++++++++++ api/check.php | 13 ++ api/config.php | 18 ++ api/diagnostics.php | 129 ++++++++++++ api/doctors.php | 11 + api/export.php | 50 +++++ api/login.php | 23 ++ api/logout.php | 25 +++ api/mailer.php | 338 ++++++++++++++++++++++++++++++ api/submissions.php | 17 ++ api/submit.php | 75 +++++++ api/topics.php | 11 + api/version.php | 13 ++ assets/avatars/li.svg | 5 + assets/avatars/wang.svg | 5 + assets/avatars/zhang.svg | 5 + assets/hero.svg | 11 + config/.htaccess | 12 ++ config/README.md | 58 ++++++ config/app.example.json | 9 + config/mail.example.json | 17 ++ consult.html | 10 + consult.php | 86 ++++++++ css/admin.css | 143 +++++++++++++ css/base.css | 322 ++++++++++++++++++++++++++++ css/form.css | 382 ++++++++++++++++++++++++++++++++++ css/home.css | 311 +++++++++++++++++++++++++++ css/variables.css | 46 ++++ data/.htaccess | 12 ++ data/doctors.json | 29 +++ data/submissions.example.json | 1 + data/topics.json | 8 + debug.html | 10 + debug.php | 159 ++++++++++++++ index.html | 10 + index.php | 58 ++++++ js/access.js | 51 +++++ js/admin.js | 120 +++++++++++ js/api.js | 85 ++++++++ js/config-loader.js | 28 +++ js/consult.js | 174 ++++++++++++++++ js/home.js | 84 ++++++++ qr.html | 10 + qr.php | 92 ++++++++ 49 files changed, 3490 insertions(+) create mode 100644 .gitignore create mode 100644 .htaccess create mode 100644 README.md create mode 100644 admin.html create mode 100644 admin.php create mode 100644 api/bootstrap.php create mode 100644 api/check.php create mode 100644 api/config.php create mode 100644 api/diagnostics.php create mode 100644 api/doctors.php create mode 100644 api/export.php create mode 100644 api/login.php create mode 100644 api/logout.php create mode 100644 api/mailer.php create mode 100644 api/submissions.php create mode 100644 api/submit.php create mode 100644 api/topics.php create mode 100644 api/version.php create mode 100644 assets/avatars/li.svg create mode 100644 assets/avatars/wang.svg create mode 100644 assets/avatars/zhang.svg create mode 100644 assets/hero.svg create mode 100644 config/.htaccess create mode 100644 config/README.md create mode 100644 config/app.example.json create mode 100644 config/mail.example.json create mode 100644 consult.html create mode 100644 consult.php create mode 100644 css/admin.css create mode 100644 css/base.css create mode 100644 css/form.css create mode 100644 css/home.css create mode 100644 css/variables.css create mode 100644 data/.htaccess create mode 100644 data/doctors.json create mode 100644 data/submissions.example.json create mode 100644 data/topics.json create mode 100644 debug.html create mode 100644 debug.php create mode 100644 index.html create mode 100644 index.php create mode 100644 js/access.js create mode 100644 js/admin.js create mode 100644 js/api.js create mode 100644 js/config-loader.js create mode 100644 js/consult.js create mode 100644 js/home.js create mode 100644 qr.html create mode 100644 qr.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..511afa9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# 敏感配置(使用 *.example.json 模板) +config/app.json +config/mail.json + +# 运行时数据 +data/submissions.json + +# 文档与归档 +prd/ +*.zip +*.docx + +# 系统文件 +.DS_Store +*.log +.idea/ +.vscode/ diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..131f1fa --- /dev/null +++ b/.htaccess @@ -0,0 +1,25 @@ +# 禁止直接访问提交数据 + + + Require all denied + + + + + + Order allow,deny + Deny from all + + + +# HTML/PHP 页面不缓存,静态资源可长期缓存(靠 ?v= 版本号更新) + + + Header set Cache-Control "no-cache, no-store, must-revalidate" + Header set Pragma "no-cache" + Header set Expires "0" + + + Header set Cache-Control "public, max-age=31536000, immutable" + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..3a94e62 --- /dev/null +++ b/README.md @@ -0,0 +1,129 @@ +# 心理咨询 H5 + +移动端心理咨询预约系统。纯 HTML/CSS/JS 前端 + PHP 后端,医生与议题由 JSON 配置,支持二维码准入与管理端数据导出。 + +## 功能 + +- 首页展示可配置咨询师列表 +- 点击咨询师或「预约服务」进入咨询表单 +- 联系方式必填,咨询议题可选(配置于 `data/topics.json`) +- 提交记录本地存储,管理端登录后导出 CSV/JSON +- 二维码访问控制(URL 携带 `access` 令牌) + +## 环境要求 + +- PHP 7.4+(需开启 `json`、`session` 扩展) +- 可写权限:`data/` 目录 + +## 快速启动 + +```bash +cd 心理咨询 +php -S 0.0.0.0:8080 +``` + +浏览器访问: + +| 页面 | 地址 | +|------|------| +| 首页(需带 token) | http://localhost:8080/index.html?access=你的accessToken | +| 生成二维码 | http://localhost:8080/qr.html | +| 管理后台 | http://localhost:8080/admin.html | + +默认管理账号:`frankfrank` / `frankfrank`(见 `config/app.json`) + +## 配置说明 + +详见 [config/README.md](config/README.md) + +| 文件 | 用途 | +|------|------| +| `config/app.json` | 站点标题、访问令牌、管理账密、客服电话 | +| `data/doctors.json` | 咨询师列表 | +| `data/topics.json` | 咨询议题选项 | +| `assets/avatars/` | 咨询师头像图片 | + +**部署前务必修改** `config/app.json` 中的 `accessToken` 为随机长字符串。 + +## 二维码访问 + +1. 修改 `accessToken` 后访问 `qr.html` +2. 扫描生成的二维码或分发链接(含 `?access=token`) +3. 用户首次访问校验通过后,同浏览器 session 内可继续访问 + +> 说明:若用户复制完整链接仍可访问。如需更高安全性,可定期更换 token 并重新制码。 + +## 生产部署 + +### Apache + +- 站点根目录指向本项目 +- 确保启用 `mod_authz_core`,`.htaccess` 会禁止直接下载 `data/submissions.json` + +### Nginx + +```nginx +location ~ /data/submissions\.json$ { + deny all; +} +``` + +确保 PHP-FPM 正常处理 `.php` 文件,`data/` 目录对 PHP 进程可写。 + +### 提交无反应 / 提交失败排查 + +API **不会**写死 localhost,地址随当前访问域名自动计算。若点击提交无反应,按下面检查: + +1. **浏览器直接访问** `https://你的域名/api/config.php` + - 应返回 JSON;若 404 → `api/` 目录未上传或站点根目录不对 + - 若下载文件 / 显示源码 → PHP 未启用,在宝塔里给站点选 PHP 版本 + +2. **`data/` 目录可写**(宝塔 → 文件 → 权限设为 755 或 775,属主 `www`) + - 不可写时提交会提示「保存失败」 + +3. **重新上传** 最新的 `js/api.js`、`js/consult.js`(旧版若初始化报错会导致提交按钮未绑定) + +4. **F12 开发者工具 → Network**,点提交看 `submit.php` 的请求 URL 是否为当前域名下的 `/api/submit.php` + +5. 须先通过二维码进入(或 URL 带 `?access=token`),否则会被准入拦截 + +### 调试页面 + +部署后若提交异常,可访问(**须带与二维码相同的 access 令牌**): + +``` +https://你的域名/debug.html?access=你的accessToken +``` + +功能:环境检测、data 目录写权限、测试提交、测试邮件发送,并在页面底部输出详细日志。 + +**提交成功但页面无反应**:通常是发邮件耗时过长导致超时。现已改为「先返回成功、再后台发邮件」,请重新上传 `api/submit.php`、`api/mailer.php`、`js/api.js`、`js/consult.js`。 + + +| 接口 | 方法 | 说明 | +|------|------|------| +| `api/config.php` | GET | 公开配置 | +| `api/doctors.php` | GET | 医生列表 | +| `api/topics.php` | GET | 议题列表 | +| `api/submit.php` | POST | 提交咨询 | +| `api/login.php` | POST | 管理登录 | +| `api/logout.php` | POST | 管理退出 | +| `api/check.php` | GET | 检查登录状态 | +| `api/submissions.php` | GET | 提交列表(需登录) | +| `api/export.php` | GET | 导出 `?format=csv\|json`(需登录) | + +## 目录结构 + +``` +心理咨询/ +├── index.html # 首页 +├── consult.html # 预约表单 +├── admin.html # 管理后台 +├── qr.html # 二维码生成 +├── api/ # PHP 接口 +├── config/ # 系统配置 +├── data/ # 业务数据 +├── assets/ # 静态资源 +├── css/ # 样式 +└── js/ # 脚本 +``` diff --git a/admin.html b/admin.html new file mode 100644 index 0000000..9eb264a --- /dev/null +++ b/admin.html @@ -0,0 +1,10 @@ + + + + + + + 跳转中… + + + diff --git a/admin.php b/admin.php new file mode 100644 index 0000000..2d50c47 --- /dev/null +++ b/admin.php @@ -0,0 +1,55 @@ + + + + + + + + + 管理后台 - 心理咨询 + + + + + +
+ + + +
+ + + + + diff --git a/api/bootstrap.php b/api/bootstrap.php new file mode 100644 index 0000000..0f4bca3 --- /dev/null +++ b/api/bootstrap.php @@ -0,0 +1,178 @@ + false, 'message' => $message], $code); +} + +function readJsonFile(string $path): array +{ + if (!is_file($path)) { + jsonError('数据文件不存在: ' . basename($path), 500); + } + $data = json_decode(file_get_contents($path) ?: '[]', true); + return is_array($data) ? $data : []; +} + +function getRequestBody(): array +{ + $raw = file_get_contents('php://input'); + $data = json_decode($raw ?: '{}', true); + return is_array($data) ? $data : []; +} + +function requireAdmin(): void +{ + if (empty($_SESSION['admin'])) { + jsonError('未登录或会话已过期', 401); + } +} + +function readSubmissions(): array +{ + if (!is_file(SUBMISSIONS_FILE)) { + return []; + } + $data = json_decode(file_get_contents(SUBMISSIONS_FILE) ?: '[]', true); + return is_array($data) ? $data : []; +} + +function getDataWriteError(): ?string +{ + if (!is_dir(DATA_DIR)) { + if (!@mkdir(DATA_DIR, 0755, true) && !is_dir(DATA_DIR)) { + return 'data 目录无法创建。请在宝塔:文件 → data → 权限设为 755,属主 www'; + } + } + + if (!is_writable(DATA_DIR)) { + return 'data 目录不可写。请在宝塔:文件 → data → 权限 755,属主 www,勾选「应用到子目录」'; + } + + if (is_file(SUBMISSIONS_FILE) && !is_writable(SUBMISSIONS_FILE)) { + return 'submissions.json 不可写。请在宝塔:data/submissions.json → 权限 664,属主 www'; + } + + return null; +} + +function saveSubmissions(array $submissions): bool +{ + $writeError = getDataWriteError(); + if ($writeError !== null) { + error_log('[data] ' . $writeError); + return false; + } + + $json = json_encode($submissions, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); + $written = @file_put_contents(SUBMISSIONS_FILE, $json, LOCK_EX); + + if ($written === false && !is_file(SUBMISSIONS_FILE)) { + $written = @file_put_contents(SUBMISSIONS_FILE, $json, LOCK_EX); + } + + return $written !== false; +} + +function getSaveSubmissionsError(): string +{ + $writeError = getDataWriteError(); + if ($writeError !== null) { + return $writeError; + } + return '保存失败,请稍后重试'; +} + +function validatePhone(string $phone): bool +{ + return (bool) preg_match('/^1[3-9]\d{9}$/', $phone); +} + +function getAssetVersion(): string +{ + static $version = null; + if ($version !== null) { + return $version; + } + + try { + $config = loadAppConfig(); + if (!empty($config['assetVersion'])) { + $version = (string) $config['assetVersion']; + return $version; + } + } catch (Throwable $e) { + // 页面未就绪时回退到文件时间戳 + } + + $max = 0; + foreach (['css', 'js', 'assets', 'assets/avatars'] as $dirName) { + $dir = ROOT_DIR . '/' . $dirName; + if (!is_dir($dir)) { + continue; + } + $files = glob($dir . '/*'); + if ($files === false) { + continue; + } + foreach ($files as $file) { + if (is_file($file)) { + $max = max($max, (int) filemtime($file)); + } + } + } + + $version = $max > 0 ? (string) $max : date('YmdHis'); + return $version; +} + +function asset(string $path): string +{ + $path = ltrim($path, '/'); + $sep = str_contains($path, '?') ? '&' : '?'; + return $path . $sep . 'v=' . rawurlencode(getAssetVersion()); +} + +function sendHtmlNoCacheHeaders(): void +{ + header('Cache-Control: no-cache, no-store, must-revalidate'); + header('Pragma: no-cache'); + header('Expires: 0'); +} diff --git a/api/check.php b/api/check.php new file mode 100644 index 0000000..d281800 --- /dev/null +++ b/api/check.php @@ -0,0 +1,13 @@ + true, + 'loggedIn' => !empty($_SESSION['admin']), +]); diff --git a/api/config.php b/api/config.php new file mode 100644 index 0000000..01ecbfe --- /dev/null +++ b/api/config.php @@ -0,0 +1,18 @@ + true, + 'siteTitle' => $config['siteTitle'] ?? '心理咨询', + 'accessToken' => $config['accessToken'] ?? '', + 'servicePhone' => $config['servicePhone'] ?? '', + 'confirmPhone' => $config['confirmPhone'] ?? '', +]); diff --git a/api/diagnostics.php b/api/diagnostics.php new file mode 100644 index 0000000..4e7a40f --- /dev/null +++ b/api/diagnostics.php @@ -0,0 +1,129 @@ + false, 'message' => '目录不存在']; + } + if (!is_writable($dir)) { + return ['ok' => false, 'message' => '目录不可写,请检查权限']; + } + $testFile = $dir . '/.write_test_' . uniqid(); + $written = @file_put_contents($testFile, 'ok'); + if ($written === false) { + return ['ok' => false, 'message' => '无法写入测试文件']; + } + @unlink($testFile); + return ['ok' => true, 'message' => '可写']; +} + +if ($_SERVER['REQUEST_METHOD'] === 'GET') { + requireDebugAccess(); + + $dataDir = checkWritableDir(DATA_DIR); + $submissions = readSubmissions(); + $mailConfig = loadMailConfig(); + $recipients = getMailRecipients($mailConfig); + + jsonResponse([ + 'ok' => true, + 'time' => date('Y-m-d H:i:s'), + 'phpVersion' => PHP_VERSION, + 'assetVersion' => getAssetVersion(), + 'extensions' => [ + 'json' => extension_loaded('json'), + 'openssl' => extension_loaded('openssl'), + 'session' => extension_loaded('session'), + ], + 'paths' => [ + 'root' => ROOT_DIR, + 'dataDir' => DATA_DIR, + 'submissionsFile' => SUBMISSIONS_FILE, + 'mailConfig' => MAIL_CONFIG_FILE, + ], + 'dataDir' => $dataDir, + 'submissions' => [ + 'count' => count($submissions), + 'fileExists' => is_file(SUBMISSIONS_FILE), + 'last' => $submissions !== [] ? end($submissions) : null, + ], + 'mail' => [ + 'enabled' => !empty($mailConfig['enabled']), + 'recipientCount' => count($recipients), + 'recipients' => $recipients, + 'smtpHost' => $mailConfig['smtp']['host'] ?? '', + ], + 'apiUrls' => [ + 'config' => 'api/config.php', + 'doctors' => 'api/doctors.php', + 'submit' => 'api/submit.php', + 'diagnostics' => 'api/diagnostics.php', + ], + ]); +} + +if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + jsonError('方法不允许', 405); +} + +requireDebugAccess(); + +$action = trim((string) ($_GET['action'] ?? 'submit')); + +if ($action === 'mail') { + $submissions = readSubmissions(); + if ($submissions === []) { + jsonError('暂无提交记录,无法测试邮件'); + } + $sent = sendSubmissionMail($submissions); + if (!$sent) { + jsonError('邮件发送失败,请查看服务器 PHP 错误日志', 500); + } + jsonResponse(['ok' => true, 'message' => '测试邮件已发送', 'count' => count($submissions)]); +} + +if ($action === 'submit') { + $suffix = date('His'); + $record = [ + 'id' => uniqid('debug_', true), + 'createdAt' => date('c'), + 'doctorId' => 'song', + 'doctorName' => '宋崇升', + 'nickname' => '调试用户' . $suffix, + 'gender' => '男', + 'contact' => '1380013' . str_pad((string) random_int(0, 9999), 4, '0', STR_PAD_LEFT), + 'description' => '这是一条调试提交 ' . date('Y-m-d H:i:s'), + ]; + + $submissions = readSubmissions(); + $submissions[] = $record; + + if (!saveSubmissions($submissions)) { + jsonError(getSaveSubmissionsError(), 500); + } + + $mailSent = sendSubmissionMail($submissions); + + jsonResponse([ + 'ok' => true, + 'message' => '调试提交成功', + 'record' => $record, + 'total' => count($submissions), + 'mailSent' => $mailSent, + ]); +} + +jsonError('未知操作'); diff --git a/api/doctors.php b/api/doctors.php new file mode 100644 index 0000000..aedf4d5 --- /dev/null +++ b/api/doctors.php @@ -0,0 +1,11 @@ + true, 'data' => $doctors]); diff --git a/api/export.php b/api/export.php new file mode 100644 index 0000000..b0c8c07 --- /dev/null +++ b/api/export.php @@ -0,0 +1,50 @@ + true, 'message' => '登录成功']); diff --git a/api/logout.php b/api/logout.php new file mode 100644 index 0000000..186275b --- /dev/null +++ b/api/logout.php @@ -0,0 +1,25 @@ + true, 'message' => '已退出']); diff --git a/api/mailer.php b/api/mailer.php new file mode 100644 index 0000000..6d0e8e4 --- /dev/null +++ b/api/mailer.php @@ -0,0 +1,338 @@ + false]; + } + $data = json_decode(file_get_contents(MAIL_CONFIG_FILE) ?: '{}', true); + $config = is_array($data) ? $data : ['enabled' => false]; + return $config; +} + +function getMailRecipients(array $mailConfig): array +{ + $recipients = []; + + if (!empty($mailConfig['recipients']) && is_array($mailConfig['recipients'])) { + foreach ($mailConfig['recipients'] as $email) { + $email = trim((string) $email); + if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) { + $recipients[] = $email; + } + } + } + + $single = trim((string) ($mailConfig['recipient'] ?? '')); + if ($single !== '' && filter_var($single, FILTER_VALIDATE_EMAIL)) { + $recipients[] = $single; + } + + return array_values(array_unique($recipients)); +} + +function formatSubmissionTime(string $iso): string +{ + $ts = strtotime($iso); + if ($ts === false) { + return $iso; + } + return date('Y-m-d H:i:s', $ts); +} + +function sendSubmissionMail(array $allSubmissions): bool +{ + $mailConfig = loadMailConfig(); + if (empty($mailConfig['enabled'])) { + return false; + } + + $recipients = getMailRecipients($mailConfig); + $smtp = $mailConfig['smtp'] ?? []; + if ($recipients === [] || empty($smtp['host'])) { + error_log('[mail] 邮件配置不完整'); + return false; + } + + $count = count($allSubmissions); + $subject = (string) ($mailConfig['subject'] ?? '【心理咨询】新预约提交'); + if ($count > 0) { + $subject .= '(共' . $count . '条)'; + } + + $htmlBody = buildAllSubmissionsMailHtml($allSubmissions); + $plainBody = buildAllSubmissionsMailPlain($allSubmissions); + $from = (string) ($smtp['from'] ?? $smtp['username'] ?? ''); + $fromName = (string) ($smtp['fromName'] ?? '心理咨询系统'); + + try { + return smtpSend($smtp, $recipients, $subject, $htmlBody, $plainBody, $from, $fromName); + } catch (Throwable $e) { + error_log('[mail] 发送失败: ' . $e->getMessage()); + return false; + } +} + +function sortSubmissions(array $submissions): array +{ + usort($submissions, static function ($a, $b) { + return strcmp($a['createdAt'] ?? '', $b['createdAt'] ?? ''); + }); + return $submissions; +} + +function escapeMailHtml(string $text): string +{ + return htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); +} + +function normalizeSubmissionRow(array $record, int $index): array +{ + $doctorName = trim((string) ($record['doctorName'] ?? '')); + + return [ + 'index' => (string) $index, + 'time' => formatSubmissionTime((string) ($record['createdAt'] ?? '')), + 'nickname' => (string) ($record['nickname'] ?? ''), + 'gender' => (string) ($record['gender'] ?? ''), + 'contact' => (string) ($record['contact'] ?? ''), + 'doctor' => $doctorName !== '' ? $doctorName : '未指定', + 'description' => (string) ($record['description'] ?? ''), + ]; +} + +function buildAllSubmissionsMailHtml(array $submissions): string +{ + $submissions = sortSubmissions($submissions); + $count = count($submissions); + $generatedAt = date('Y-m-d H:i:s'); + + if ($submissions === []) { + return '

当前暂无提交记录。

'; + } + + $rows = ''; + foreach ($submissions as $i => $record) { + $row = normalizeSubmissionRow($record, $i + 1); + $bg = ($i % 2 === 0) ? '#ffffff' : '#f8fbf9'; + $rows .= '' + . '' . escapeMailHtml($row['index']) . '' + . '' . escapeMailHtml($row['time']) . '' + . '' . escapeMailHtml($row['nickname']) . '' + . '' . escapeMailHtml($row['gender']) . '' + . '' . escapeMailHtml($row['contact']) . '' + . '' . escapeMailHtml($row['doctor']) . '' + . '' . nl2br(escapeMailHtml($row['description'])) . '' + . ''; + } + + return << +

心理咨询预约记录汇总

+

共 {$count} 条记录 · 生成时间 {$generatedAt}

+ + + + + + + + + + + + + + {$rows} + +
序号提交时间提交人性别联系电话咨询师需求简述
+

—— 心理咨询系统自动通知

+ +HTML; +} + +function buildAllSubmissionsMailPlain(array $submissions): string +{ + $submissions = sortSubmissions($submissions); + if ($submissions === []) { + return "当前暂无提交记录。\n"; + } + + $lines = [ + '心理咨询预约记录汇总(共' . count($submissions) . '条)', + str_repeat('=', 80), + sprintf('%-4s %-19s %-8s %-4s %-13s %-10s %s', '序号', '提交时间', '提交人', '性别', '联系电话', '咨询师', '需求简述'), + str_repeat('-', 80), + ]; + + foreach ($submissions as $i => $record) { + $row = normalizeSubmissionRow($record, $i + 1); + $desc = preg_replace('/\s+/', ' ', $row['description']); + $lines[] = sprintf( + '%-4s %-19s %-8s %-4s %-13s %-10s %s', + $row['index'], + $row['time'], + $row['nickname'], + $row['gender'], + $row['contact'], + $row['doctor'], + $desc + ); + } + + $lines[] = str_repeat('-', 80); + $lines[] = '—— 心理咨询系统自动通知'; + + return implode("\n", $lines); +} + +function smtpSend(array $smtp, array $recipients, string $subject, string $htmlBody, string $plainBody, string $from, string $fromName): bool +{ + if ($recipients === []) { + throw new RuntimeException('收件人为空'); + } + + $host = (string) $smtp['host']; + $port = (int) ($smtp['port'] ?? 465); + $username = (string) ($smtp['username'] ?? ''); + $password = (string) ($smtp['password'] ?? ''); + $encryption = strtolower((string) ($smtp['encryption'] ?? 'ssl')); + + if ($username === '' || $password === '' || $from === '') { + throw new RuntimeException('SMTP 账号或发件人未配置'); + } + + $remote = ($encryption === 'ssl' ? 'ssl://' : '') . $host . ':' . $port; + $context = stream_context_create([ + 'ssl' => [ + 'verify_peer' => true, + 'verify_peer_name' => true, + 'allow_self_signed' => false, + ], + ]); + + $socket = @stream_socket_client($remote, $errno, $errstr, 30, STREAM_CLIENT_CONNECT, $context); + if (!$socket) { + throw new RuntimeException("无法连接 SMTP 服务器: {$errstr} ({$errno})"); + } + + stream_set_timeout($socket, 30); + + try { + smtpExpect($socket, [220]); + smtpCommand($socket, 'EHLO localhost'); + smtpExpect($socket, [250]); + + smtpCommand($socket, 'AUTH LOGIN'); + smtpExpect($socket, [334]); + smtpCommand($socket, base64_encode($username)); + smtpExpect($socket, [334]); + smtpCommand($socket, base64_encode($password)); + smtpExpect($socket, [235]); + + smtpCommand($socket, 'MAIL FROM:<' . $from . '>'); + smtpExpect($socket, [250]); + foreach ($recipients as $to) { + smtpCommand($socket, 'RCPT TO:<' . $to . '>'); + smtpExpect($socket, [250, 251]); + } + smtpCommand($socket, 'DATA'); + smtpExpect($socket, [354]); + + $message = buildMimeMessage($from, $fromName, $recipients, $subject, $htmlBody, $plainBody); + fwrite($socket, $message . "\r\n.\r\n"); + smtpExpect($socket, [250]); + + smtpCommand($socket, 'QUIT'); + smtpExpect($socket, [221]); + } finally { + fclose($socket); + } + + return true; +} + +function buildMimeMessage(string $from, string $fromName, array $recipients, string $subject, string $htmlBody, string $plainBody): string +{ + $encodedSubject = '=?UTF-8?B?' . base64_encode($subject) . '?='; + $encodedFromName = '=?UTF-8?B?' . base64_encode($fromName) . '?='; + $toHeader = implode(', ', array_map(static fn($email) => '<' . $email . '>', $recipients)); + $boundary = 'b_' . md5(uniqid((string) mt_rand(), true)); + + $headers = [ + 'From: ' . $encodedFromName . ' <' . $from . '>', + 'To: ' . $toHeader, + 'Subject: ' . $encodedSubject, + 'MIME-Version: 1.0', + 'Content-Type: multipart/alternative; boundary="' . $boundary . '"', + 'Date: ' . date('r'), + ]; + + $plainPart = chunk_split(base64_encode($plainBody), 76, "\r\n"); + $htmlPart = chunk_split(base64_encode($htmlBody), 76, "\r\n"); + + $body = "--{$boundary}\r\n" + . "Content-Type: text/plain; charset=UTF-8\r\n" + . "Content-Transfer-Encoding: base64\r\n\r\n" + . rtrim($plainPart) . "\r\n\r\n" + . "--{$boundary}\r\n" + . "Content-Type: text/html; charset=UTF-8\r\n" + . "Content-Transfer-Encoding: base64\r\n\r\n" + . rtrim($htmlPart) . "\r\n\r\n" + . "--{$boundary}--"; + + return implode("\r\n", $headers) . "\r\n\r\n" . $body; +} + +function smtpCommand($socket, string $command): void +{ + fwrite($socket, $command . "\r\n"); +} + +function smtpExpect($socket, array $validCodes): string +{ + $response = ''; + while (($line = fgets($socket, 515)) !== false) { + $response .= $line; + if (isset($line[3]) && $line[3] === ' ') { + break; + } + } + + if ($response === '') { + throw new RuntimeException('SMTP 无响应'); + } + + $code = (int) substr($response, 0, 3); + if (!in_array($code, $validCodes, true)) { + throw new RuntimeException('SMTP 错误: ' . trim($response)); + } + + return $response; +} + +function finishResponseAndContinue(): void +{ + if (function_exists('fastcgi_finish_request')) { + fastcgi_finish_request(); + return; + } + + if (ob_get_level() > 0) { + ob_end_flush(); + } + flush(); + + if (function_exists('session_write_close')) { + session_write_close(); + } + + ignore_user_abort(true); +} diff --git a/api/submissions.php b/api/submissions.php new file mode 100644 index 0000000..8057e69 --- /dev/null +++ b/api/submissions.php @@ -0,0 +1,17 @@ + true, 'data' => $submissions, 'total' => count($submissions)]); diff --git a/api/submit.php b/api/submit.php new file mode 100644 index 0000000..e439c40 --- /dev/null +++ b/api/submit.php @@ -0,0 +1,75 @@ + 200) { + jsonError('需求简述不能超过200字'); +} + +$record = [ + 'id' => uniqid('sub_', true), + 'createdAt' => date('c'), + 'doctorId' => $doctorId, + 'doctorName' => $doctorName, + 'nickname' => $nickname, + 'gender' => $gender, + 'contact' => $contact, + 'description' => $description, +]; + +$submissions = readSubmissions(); +$submissions[] = $record; + +if (!saveSubmissions($submissions)) { + jsonError(getSaveSubmissionsError(), 500); +} + +$response = [ + 'ok' => true, + 'message' => '提交成功', + 'id' => $record['id'], + 'total' => count($submissions), +]; + +http_response_code(200); +header('Content-Type: application/json; charset=utf-8'); +echo json_encode($response, JSON_UNESCAPED_UNICODE); + +finishResponseAndContinue(); + +$mailSent = sendSubmissionMail($submissions); +if (!$mailSent) { + error_log('[submit] 数据已保存,邮件通知未发送,ID=' . $record['id']); +} + +exit; diff --git a/api/topics.php b/api/topics.php new file mode 100644 index 0000000..6a4c65c --- /dev/null +++ b/api/topics.php @@ -0,0 +1,11 @@ + true, 'data' => $topics]); diff --git a/api/version.php b/api/version.php new file mode 100644 index 0000000..2c77cc8 --- /dev/null +++ b/api/version.php @@ -0,0 +1,13 @@ + true, + 'version' => getAssetVersion(), +]); diff --git a/assets/avatars/li.svg b/assets/avatars/li.svg new file mode 100644 index 0000000..603c03b --- /dev/null +++ b/assets/avatars/li.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/avatars/wang.svg b/assets/avatars/wang.svg new file mode 100644 index 0000000..936f3a8 --- /dev/null +++ b/assets/avatars/wang.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/avatars/zhang.svg b/assets/avatars/zhang.svg new file mode 100644 index 0000000..df847ff --- /dev/null +++ b/assets/avatars/zhang.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/hero.svg b/assets/hero.svg new file mode 100644 index 0000000..0bed2d1 --- /dev/null +++ b/assets/hero.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/config/.htaccess b/config/.htaccess new file mode 100644 index 0000000..eb850ba --- /dev/null +++ b/config/.htaccess @@ -0,0 +1,12 @@ + + + Require all denied + + + + + + Order allow,deny + Deny from all + + diff --git a/config/README.md b/config/README.md new file mode 100644 index 0000000..7bf3cd9 --- /dev/null +++ b/config/README.md @@ -0,0 +1,58 @@ +# 配置说明 + +修改以下文件后,刷新页面即可生效(无需重启 PHP)。 + +## 1. 系统配置 `config/app.json` + +| 字段 | 说明 | +|------|------| +| `siteTitle` | 页面标题 | +| `accessToken` | 二维码访问密钥,**部署前务必改为随机长字符串** | +| `adminUser` | 管理后台登录账号(默认 `frankfrank`) | +| `adminPassword` | 管理后台登录密码(默认 `frankfrank`) | +| `servicePhone` | 客服/投诉电话(默认 `13122315169`) | +| `confirmPhone` | 确认来电号码(默认 `13122315169`) | +| `assetVersion` | (可选)静态资源版本号。留空则**自动**按 css/js 文件修改时间生成;更新后用户会自动加载新样式 | + +## 2. 咨询师列表 `data/doctors.json` + +数组,每位咨询师字段: + +- `id`:唯一标识(英文) +- `name`:姓名 +- `avatar`:头像路径,如 `assets/avatars/song.jpg` +- `role`:标签文字,如「副主任医师」「热门咨询师」 +- `hospital`:所属机构/医院 +- `fields`:专业领域数组 +- `bio`:简介(可选) + +头像图片放在 `assets/avatars/` 目录,支持 jpg、png、svg。建议尺寸 **240×240** 正方形,页面会完整展示头像(不裁切)。 + +## 3. 提交记录 `data/submissions.json` + +由系统自动写入,**请勿手动编辑**。通过 `admin.html` 登录后导出。 + +导出字段:昵称、性别、联系电话、需求简述、所选咨询师等。 + +## 4. 邮件通知 `config/mail.json` + +有人提交预约后,系统自动发邮件通知收件人。只需 **SMTP 发信** 配置(POP/IMAP 不需要)。 + +| 字段 | 说明 | +|------|------| +| `enabled` | 是否启用邮件通知(`true` / `false`) | +| `recipients` | 收件人邮箱数组,如 `["a@example.com", "b@example.com"]` | +| `recipient` | (可选)单个收件人,与 `recipients` 可同时使用 | +| `subject` | 邮件标题 | +| `smtp.host` | SMTP 服务器,如 `smtp.qiye.aliyun.com` | +| `smtp.port` | 端口,SSL 一般为 `465` | +| `smtp.encryption` | 加密方式,填 `ssl` | +| `smtp.username` | 发信邮箱账号 | +| `smtp.password` | 邮箱 SMTP 授权码/密码 | +| `smtp.from` | 发件人地址(通常与 username 相同) | +| `smtp.fromName` | 发件人显示名称 | + +参考模板见 `config/mail.example.json`。**请勿将含密码的 mail.json 提交到公开仓库。** + +邮件发送失败不影响用户提交成功(数据仍会保存),错误会写入服务器 PHP 日志。 + diff --git a/config/app.example.json b/config/app.example.json new file mode 100644 index 0000000..b7600c2 --- /dev/null +++ b/config/app.example.json @@ -0,0 +1,9 @@ +{ + "siteTitle": "心理咨询", + "accessToken": "change_me_to_random_token", + "adminUser": "frankfrank", + "adminPassword": "change_me", + "servicePhone": "13122315169", + "confirmPhone": "13122315169", + "assetVersion": "" +} diff --git a/config/mail.example.json b/config/mail.example.json new file mode 100644 index 0000000..bfa30db --- /dev/null +++ b/config/mail.example.json @@ -0,0 +1,17 @@ +{ + "enabled": true, + "recipients": [ + "your-recipient@example.com", + "another@example.com" + ], + "subject": "【心理咨询】新预约提交", + "smtp": { + "host": "smtp.qiye.aliyun.com", + "port": 465, + "encryption": "ssl", + "username": "your-email@example.com", + "password": "your-smtp-password", + "from": "your-email@example.com", + "fromName": "心理咨询系统" + } +} diff --git a/consult.html b/consult.html new file mode 100644 index 0000000..bced99d --- /dev/null +++ b/consult.html @@ -0,0 +1,10 @@ + + + + + + + 跳转中… + + + diff --git a/consult.php b/consult.php new file mode 100644 index 0000000..3554fe7 --- /dev/null +++ b/consult.php @@ -0,0 +1,86 @@ + + + + + + + + + + 心理咨询 - 预约 + + + + + +
+
+ +

心理咨询

+
+ + + +
+
+

基本信息

+ +
+ + +
+ +
+ 性别* +
+ + +
+
+ +
+ + +
+
+ +
+ +
+ +
+ +
+
+ +
+

心理咨询服务说明

+

1. 服务时效:申请后实时响应,我们将在2h内确认您与心理咨询师的沟通时间。请注意接听13122315169的号码来电。

+

2. 服务旨在为用户提供专业的心理支持和建议,帮助用户理解和管理自己的情绪、行为及人际关系等问题。不涉及心理治疗、药物治疗或其他医疗行为。

+

客服/投诉专线:13122315169

+
+ +
+ +
+
+ + + + + + + diff --git a/css/admin.css b/css/admin.css new file mode 100644 index 0000000..a9b2bb3 --- /dev/null +++ b/css/admin.css @@ -0,0 +1,143 @@ +.admin-page { + min-height: 100vh; + padding: var(--space-md); + padding-bottom: calc(var(--space-xl) + var(--safe-bottom)); +} + +.admin-login { + max-width: 360px; + margin: 80px auto 0; +} + +.admin-login__title { + font-size: 22px; + font-weight: 700; + text-align: center; + margin-bottom: var(--space-xl); +} + +.admin-login .form-field { + margin-bottom: var(--space-lg); +} + +.admin-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-lg); + padding-top: var(--space-md); +} + +.admin-header__title { + font-size: 20px; + font-weight: 700; +} + +.admin-actions { + display: flex; + gap: var(--space-sm); + margin-bottom: var(--space-md); + flex-wrap: wrap; +} + +.admin-actions .btn { + flex: 1; + min-width: 120px; + font-size: 14px; + min-height: 40px; +} + +.submission-card { + background: var(--color-surface); + border-radius: var(--radius-md); + padding: var(--space-md); + margin-bottom: var(--space-md); + box-shadow: var(--shadow-sm); + border-left: 3px solid var(--color-primary); +} + +.submission-card__time { + font-size: 12px; + color: var(--color-text-muted); + margin-bottom: var(--space-sm); +} + +.submission-card__row { + font-size: 14px; + margin-bottom: 4px; +} + +.submission-card__row strong { + color: var(--color-text-secondary); + font-weight: 500; +} + +.submission-card__desc { + font-size: 13px; + color: var(--color-text-muted); + margin-top: var(--space-sm); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.admin-empty { + text-align: center; + padding: var(--space-xl); + color: var(--color-text-muted); +} + +.qr-page { + padding: var(--space-lg) var(--space-md); + text-align: center; + max-width: 400px; + margin: 0 auto; +} + +.qr-page__title { + font-size: 20px; + font-weight: 700; + margin-bottom: var(--space-sm); +} + +.qr-page__desc { + font-size: 14px; + color: var(--color-text-secondary); + margin-bottom: var(--space-xl); +} + +.qr-canvas-wrap { + display: inline-block; + padding: var(--space-md); + background: #fff; + border-radius: var(--radius-lg); + box-shadow: var(--shadow-md); + margin-bottom: var(--space-lg); +} + +.qr-url-box { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--space-md); + font-size: 12px; + word-break: break-all; + text-align: left; + margin-bottom: var(--space-md); + color: var(--color-text-secondary); +} + +.qr-actions { + display: flex; + flex-direction: column; + gap: var(--space-sm); +} + +.qr-note { + margin-top: var(--space-xl); + font-size: 12px; + color: var(--color-text-muted); + line-height: 1.6; + text-align: left; +} diff --git a/css/base.css b/css/base.css new file mode 100644 index 0000000..066f861 --- /dev/null +++ b/css/base.css @@ -0,0 +1,322 @@ +@import url("variables.css"); + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + font-size: 16px; + -webkit-text-size-adjust: 100%; +} + +body { + font-family: var(--font-family); + background: var(--color-bg); + color: var(--color-text); + line-height: 1.5; + min-height: 100vh; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + color: inherit; + text-decoration: none; +} + +img { + display: block; + max-width: 100%; +} + +button, +input, +textarea, +select { + font-family: inherit; + font-size: inherit; +} + +.page { + max-width: 480px; + margin: 0 auto; + min-height: 100vh; + position: relative; +} + +.page--fade-in { + animation: fadeIn 0.35s ease; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.header { + position: sticky; + top: 0; + z-index: 100; + height: var(--header-height); + display: flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.88); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border-bottom: 1px solid var(--color-border); + padding: 0 var(--space-md); +} + +.header__title { + font-size: 17px; + font-weight: 600; +} + +.header__back { + position: absolute; + left: var(--space-md); + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + border: none; + background: var(--color-primary-light); + cursor: pointer; + color: var(--color-primary-dark); + border-radius: var(--radius-full); + font-size: 18px; + transition: background 0.15s, transform 0.15s; +} + +.header__back:active { + background: var(--color-primary-light); + transform: scale(0.95); +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 48px; + padding: 0 var(--space-lg); + border: none; + border-radius: var(--radius-xl); + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: transform 0.15s, opacity 0.15s, background 0.15s; +} + +.btn:active { + transform: scale(0.98); +} + +.btn--primary { + background: var(--gradient-primary); + color: #fff; + box-shadow: var(--shadow-lg); + position: relative; + overflow: hidden; +} + +.btn--primary:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +.btn--block { + width: 100%; +} + +.btn--ghost { + background: transparent; + color: var(--color-primary); + border: 1px solid var(--color-primary); +} + +.btn--shine::after { + content: ""; + position: absolute; + top: 0; + left: -100%; + width: 60%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.25), transparent); + transform: skewX(-20deg); + animation: btnShine 3s ease-in-out infinite; +} + +@keyframes btnShine { + 0%, 100% { left: -100%; } + 50% { left: 150%; } +} + +.footer-cta { + position: fixed; + bottom: 0; + left: 50%; + transform: translateX(-50%); + width: 100%; + max-width: 480px; + padding: var(--space-md) 20px; + padding-bottom: calc(var(--space-md) + var(--safe-bottom)); + background: linear-gradient(to top, rgba(255, 255, 255, 0.98) 70%, transparent); + z-index: 50; +} + +.footer-cta--glass { + background: linear-gradient(to top, rgba(240, 245, 242, 0.95) 60%, transparent); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); +} + +.toast { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: rgba(0, 0, 0, 0.75); + color: #fff; + padding: 12px 20px; + border-radius: var(--radius-md); + font-size: 14px; + z-index: 9999; + pointer-events: none; + animation: toastIn 0.25s ease; +} + +@keyframes toastIn { + from { + opacity: 0; + transform: translate(-50%, -50%) scale(0.9); + } +} + +.access-denied { + position: fixed; + inset: 0; + z-index: 10000; + background: var(--color-bg); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--space-xl); + text-align: center; +} + +.access-denied__icon { + font-size: 64px; + margin-bottom: var(--space-lg); +} + +.access-denied__title { + font-size: 18px; + font-weight: 600; + margin-bottom: var(--space-sm); +} + +.access-denied__desc { + color: var(--color-text-secondary); + font-size: 14px; +} + +.required { + color: var(--color-danger); + margin-left: 2px; +} + +.picker-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.4); + z-index: 200; + opacity: 0; + visibility: hidden; + transition: opacity 0.25s, visibility 0.25s; +} + +.picker-overlay.is-open { + opacity: 1; + visibility: visible; +} + +.picker-sheet { + position: fixed; + left: 0; + right: 0; + bottom: 0; + max-width: 480px; + margin: 0 auto; + background: var(--color-surface); + border-radius: var(--radius-lg) var(--radius-lg) 0 0; + z-index: 201; + transform: translateY(100%); + transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1); + padding-bottom: var(--safe-bottom); +} + +.picker-overlay.is-open .picker-sheet { + transform: translateY(0); +} + +.picker-sheet__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-md); + border-bottom: 1px solid var(--color-border); +} + +.picker-sheet__title { + font-weight: 600; +} + +.picker-sheet__cancel, +.picker-sheet__confirm { + border: none; + background: none; + font-size: 15px; + cursor: pointer; + padding: 4px 8px; +} + +.picker-sheet__cancel { + color: var(--color-text-muted); +} + +.picker-sheet__confirm { + color: var(--color-primary); + font-weight: 600; +} + +.picker-sheet__list { + max-height: 50vh; + overflow-y: auto; +} + +.picker-item { + padding: var(--space-md) var(--space-lg); + border-bottom: 1px solid var(--color-border); + cursor: pointer; + transition: background 0.15s; +} + +.picker-item:active, +.picker-item.is-selected { + background: var(--color-primary-light); + color: var(--color-primary); +} diff --git a/css/form.css b/css/form.css new file mode 100644 index 0000000..3d04bc4 --- /dev/null +++ b/css/form.css @@ -0,0 +1,382 @@ +.form-page { + padding-bottom: calc(88px + var(--safe-bottom)); + background: var(--color-bg); +} + +.form-body { + padding: var(--space-md); +} + +.form-card { + background: var(--gradient-surface); + border-radius: var(--radius-lg); + padding: 18px; + margin-bottom: 14px; + box-shadow: var(--shadow-card); + border: 1px solid var(--color-border); +} + +.form-card__title { + font-size: 16px; + font-weight: 700; + margin-bottom: var(--space-md); + color: var(--color-text); + display: flex; + align-items: center; + gap: 8px; +} + +.form-card__title::before { + content: ""; + width: 4px; + height: 16px; + background: var(--gradient-primary); + border-radius: 2px; +} + +.form-field { + margin-bottom: var(--space-md); +} + +.form-field:last-child { + margin-bottom: 0; +} + +.form-field__label { + display: block; + font-size: 13px; + font-weight: 600; + color: var(--color-text-secondary); + margin-bottom: var(--space-sm); +} + +.form-field__input, +.form-field__select { + width: 100%; + min-height: 46px; + padding: 11px 14px; + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-md); + background: var(--color-bg-warm); + color: var(--color-text); + outline: none; + transition: border-color 0.2s, box-shadow 0.2s; + font-size: 15px; +} + +.form-field__input::placeholder { + color: var(--color-text-muted); +} + +.form-field__input:focus, +.form-field__select:focus { + border-color: var(--color-primary); + box-shadow: 0 0 0 3px rgba(76, 175, 138, 0.15); +} + +.form-field__input.is-error, +.form-field__select.is-error { + border-color: var(--color-danger); +} + +.form-field__picker { + width: 100%; + min-height: 46px; + padding: 11px 14px; + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-md); + background: var(--color-bg-warm); + display: flex; + align-items: center; + justify-content: space-between; + cursor: pointer; + color: var(--color-text); +} + +.form-field__picker.is-placeholder { + color: var(--color-text-muted); +} + +.form-field__picker-arrow { + color: var(--color-text-muted); + font-size: 12px; +} + +.form-field__doctor { + display: flex; + align-items: center; + gap: var(--space-md); + padding: var(--space-sm); + background: var(--color-primary-light); + border-radius: var(--radius-md); +} + +.form-field__doctor-avatar { + width: 48px; + height: 48px; + border-radius: var(--radius-full); + object-fit: cover; +} + +.form-field__doctor-name { + font-weight: 600; + font-size: 16px; +} + +.form-field__doctor-role { + font-size: 12px; + color: var(--color-primary); +} + +.textarea-wrap { + position: relative; +} + +.form-field__textarea { + width: 100%; + min-height: 120px; + padding: 12px 14px; + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-md); + background: var(--color-bg-warm); + resize: none; + outline: none; + line-height: 1.65; + font-size: 15px; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.form-field__textarea:focus { + border-color: var(--color-primary); + box-shadow: 0 0 0 3px rgba(76, 175, 138, 0.15); +} + +.textarea-wrap__footer { + display: flex; + align-items: center; + justify-content: flex-end; + margin-top: var(--space-sm); +} + +.char-count { + font-size: 12px; + color: var(--color-text-muted); +} + +.service-info { + padding: 16px; + margin: 0 var(--space-md) var(--space-md); + background: var(--gradient-surface); + border-radius: var(--radius-lg); + border: 1px solid var(--color-border); + box-shadow: var(--shadow-sm); +} + +.service-info__title { + font-size: 14px; + font-weight: 700; + color: var(--color-text); + margin-bottom: var(--space-md); + display: flex; + align-items: center; + gap: 6px; +} + +.service-info__title::before { + content: "ℹ"; + width: 20px; + height: 20px; + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--color-primary-light); + color: var(--color-primary-dark); + border-radius: var(--radius-full); + font-size: 12px; + font-weight: 700; +} + +.service-info__item { + font-size: 13px; + color: var(--color-text-secondary); + line-height: 1.75; + margin-bottom: var(--space-sm); + padding-left: 1.2em; + text-indent: -1.2em; +} + +.service-info__phone { + font-size: 13px; + color: var(--color-primary-dark); + margin-top: var(--space-md); + font-weight: 600; + padding: 10px 12px; + background: var(--color-primary-light); + border-radius: var(--radius-sm); +} + +.submit-bar { + position: fixed; + bottom: 0; + left: 50%; + transform: translateX(-50%); + width: 100%; + max-width: 480px; + padding: var(--space-md) 20px; + padding-bottom: calc(var(--space-md) + var(--safe-bottom)); + background: rgba(255, 255, 255, 0.92); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border-top: 1px solid var(--color-border); + z-index: 50; +} + +.success-overlay { + position: fixed; + inset: 0; + background: rgba(240, 245, 242, 0.96); + backdrop-filter: blur(8px); + z-index: 300; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--space-xl); + text-align: center; +} + +.success-overlay__icon { + font-size: 56px; + margin-bottom: var(--space-md); +} + +.success-overlay__title { + font-size: 22px; + font-weight: 700; + margin-bottom: var(--space-sm); + color: var(--color-text); +} + +.success-overlay__desc { + color: var(--color-text-secondary); + margin-bottom: var(--space-xl); + line-height: 1.7; +} + +.doctor-banner { + display: flex; + align-items: center; + gap: var(--space-md); + margin: var(--space-md); + padding: 14px 16px; + background: var(--gradient-surface); + border-radius: var(--radius-lg); + border: 1px solid var(--color-border); + box-shadow: var(--shadow-card); +} + +.doctor-banner__avatar-wrap { + position: relative; + flex-shrink: 0; + width: 62px; + height: 62px; + overflow: visible; +} + +.doctor-banner__avatar-ring { + width: 56px; + height: 56px; + border-radius: var(--radius-full); + overflow: hidden; + background: #eef5f1; + border: 2px solid #fff; + box-shadow: 0 4px 12px rgba(76, 175, 138, 0.15); +} + +.doctor-banner__avatar { + width: 100%; + height: 100%; + object-fit: cover; + object-position: center top; + display: block; +} + +.doctor-banner__verified { + position: absolute; + right: 0; + bottom: 0; + z-index: 2; + width: 20px; + height: 20px; + background: linear-gradient(145deg, #ffb04a 0%, #ff9226 100%); + color: #fff; + border-radius: var(--radius-full); + display: flex; + align-items: center; + justify-content: center; + border: 2px solid #fff; + box-shadow: 0 2px 6px rgba(255, 146, 38, 0.4); +} + +.doctor-banner__name { + font-weight: 700; + font-size: 15px; + margin-bottom: 4px; +} + +.doctor-banner__tag { + font-size: 10px; + padding: 2px 8px; + background: #fff4e8; + color: #e07b2a; + border-radius: var(--radius-full); + font-weight: 600; + border: 1px solid rgba(224, 123, 42, 0.15); +} + +.doctor-banner__hospital { + font-size: 13px; + color: var(--color-hospital); + font-weight: 600; +} + +.gender-group { + display: flex; + gap: 12px; +} + +.gender-option { + flex: 1; + cursor: pointer; +} + +.gender-option input { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.gender-option__text { + display: flex; + align-items: center; + justify-content: center; + min-height: 46px; + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-md); + background: var(--color-bg-warm); + color: var(--color-text-secondary); + font-weight: 500; + transition: all 0.2s; +} + +.gender-option input:checked + .gender-option__text { + border-color: var(--color-primary); + background: var(--color-primary-light); + color: var(--color-primary-dark); + font-weight: 700; + box-shadow: 0 0 0 3px rgba(76, 175, 138, 0.12); +} + +.gender-option:active .gender-option__text { + transform: scale(0.98); +} diff --git a/css/home.css b/css/home.css new file mode 100644 index 0000000..c3b9a41 --- /dev/null +++ b/css/home.css @@ -0,0 +1,311 @@ +.page--home { + background: var(--color-bg); + overflow-x: hidden; +} + +/* ── Hero ── */ +.hero { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + padding: calc(14px + env(safe-area-inset-top, 0px)) 20px 18px; + background: var(--gradient-hero); +} + +.hero__decor { + position: absolute; + inset: 0; + pointer-events: none; + overflow: hidden; +} + +.hero__blob { + position: absolute; + border-radius: 50%; + filter: blur(40px); + opacity: 0.5; +} + +.hero__blob--1 { + width: 160px; + height: 160px; + background: rgba(126, 203, 168, 0.3); + top: -30px; + right: -20px; +} + +.hero__blob--2 { + width: 120px; + height: 120px; + background: rgba(76, 175, 138, 0.18); + bottom: 10px; + left: -30px; +} + +.hero__badge { + position: relative; + z-index: 1; + display: inline-flex; + padding: 5px 14px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.1em; + color: var(--color-primary-dark); + background: rgba(255, 255, 255, 0.85); + border: 1px solid rgba(255, 255, 255, 0.95); + border-radius: var(--radius-full); + box-shadow: 0 2px 10px rgba(45, 150, 104, 0.08); +} + +.hero__content { + position: relative; + z-index: 1; + width: 100%; + text-align: center; +} + +.hero__title { + font-size: 20px; + font-weight: 700; + color: var(--color-text); + line-height: 1.45; + margin-bottom: 6px; +} + +.hero__title br { + display: none; +} + +.hero__desc { + font-size: 13px; + color: var(--color-text-secondary); + line-height: 1.6; + margin: 0 auto; + max-width: 280px; +} + +.hero__image-wrap { + position: relative; + z-index: 1; + margin-top: 2px; + padding: 10px; + background: rgba(255, 255, 255, 0.65); + border-radius: 20px; + box-shadow: 0 8px 28px rgba(76, 175, 138, 0.12); +} + +.hero__illustration { + width: min(76vw, 236px); + height: auto; + border-radius: 14px; + display: block; +} + +/* ── Doctor list ── */ +.doctor-list { + padding: 18px 16px calc(var(--footer-cta-height) + var(--space-lg) + var(--safe-bottom)); +} + +.doctor-list__header { + margin-bottom: 14px; + padding: 0 2px; +} + +.doctor-list__heading { + font-size: 17px; + font-weight: 700; + color: var(--color-text); + margin-bottom: 4px; + display: flex; + align-items: center; + gap: 8px; +} + +.doctor-list__heading::before { + content: ""; + width: 4px; + height: 16px; + background: var(--gradient-primary); + border-radius: 2px; + flex-shrink: 0; +} + +.doctor-list__sub { + font-size: 12px; + color: var(--color-text-muted); + padding-left: 12px; +} + +.doctor-list__cards { + display: flex; + flex-direction: column; + gap: 12px; +} + +/* ── Doctor card ── */ +.doctor-card { + position: relative; + display: flex; + gap: 14px; + align-items: flex-start; + background: var(--color-surface); + border-radius: var(--radius-lg); + padding: 14px 44px 14px 14px; + box-shadow: var(--shadow-card); + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease; + border: 1px solid var(--color-border); + animation: cardIn 0.4s ease backwards; +} + +.doctor-card:nth-child(1) { animation-delay: 0.04s; } +.doctor-card:nth-child(2) { animation-delay: 0.1s; } +.doctor-card:nth-child(3) { animation-delay: 0.16s; } + +@keyframes cardIn { + from { + opacity: 0; + transform: translateY(10px); + } +} + +.doctor-card:active { + transform: scale(0.99); + box-shadow: var(--shadow-sm); +} + +/* 头像容器:overflow 可见,避免认证角标被裁切 */ +.doctor-card__avatar-wrap { + position: relative; + flex-shrink: 0; + width: 86px; + height: 86px; + overflow: visible; +} + +.doctor-card__avatar-ring { + width: 80px; + height: 80px; + border-radius: var(--radius-full); + overflow: hidden; + background: #eef5f1; + border: 2px solid #fff; + box-shadow: 0 4px 12px rgba(76, 175, 138, 0.15); +} + +.doctor-card__avatar { + width: 100%; + height: 100%; + object-fit: cover; + object-position: center top; + display: block; +} + +/* 认证对号:参考设计橙色角标,叠在头像右下 */ +.doctor-card__verified { + position: absolute; + right: 0; + bottom: 0; + z-index: 3; + width: 24px; + height: 24px; + background: linear-gradient(145deg, #ffb04a 0%, #ff9226 100%); + color: #fff; + border-radius: var(--radius-full); + display: flex; + align-items: center; + justify-content: center; + border: 2.5px solid #fff; + box-shadow: 0 2px 8px rgba(255, 146, 38, 0.45); + pointer-events: none; +} + +.doctor-card__body { + flex: 1; + min-width: 0; + padding-top: 2px; +} + +.doctor-card__name-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 5px; +} + +.doctor-card__name { + font-size: 16px; + font-weight: 700; + color: var(--color-text); +} + +.doctor-card__tag { + font-size: 10px; + font-weight: 600; + padding: 2px 8px; + background: #fff4e8; + color: #e07b2a; + border-radius: var(--radius-full); + white-space: nowrap; + border: 1px solid rgba(224, 123, 42, 0.15); +} + +.doctor-card__hospital { + font-size: 13px; + color: var(--color-hospital); + font-weight: 600; + margin-bottom: 8px; + line-height: 1.4; +} + +.doctor-card__fields { + font-size: 12px; + color: var(--color-text-secondary); + line-height: 1.55; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.doctor-card__fields-label { + color: var(--color-text-muted); + font-weight: 500; + margin-right: 2px; +} + +.doctor-card__fields-label::after { + content: ":"; +} + +.doctor-card__arrow { + position: absolute; + right: 12px; + top: 50%; + transform: translateY(-50%); + width: 26px; + height: 26px; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + color: var(--color-primary-dark); + background: var(--color-primary-light); + border-radius: var(--radius-full); +} + +.doctor-list__empty { + text-align: center; + padding: var(--space-xl); + color: var(--color-text-muted); + font-size: 14px; +} + +@media (min-width: 360px) { + .hero__title br { + display: inline; + } +} diff --git a/css/variables.css b/css/variables.css new file mode 100644 index 0000000..c19aa6a --- /dev/null +++ b/css/variables.css @@ -0,0 +1,46 @@ +:root { + --color-primary: #4caf8a; + --color-primary-dark: #2d9668; + --color-primary-light: #e8f7ef; + --color-primary-soft: #f3fbf7; + --color-accent: #7ecba8; + --color-bg: #f0f5f2; + --color-bg-warm: #fafcf9; + --color-surface: #ffffff; + --color-text: #1a2e24; + --color-text-secondary: #5a6f64; + --color-text-muted: #8fa396; + --color-border: rgba(45, 150, 104, 0.1); + --color-border-strong: rgba(45, 150, 104, 0.18); + --color-danger: #e54d4d; + --color-tag-bg: linear-gradient(135deg, #edf9f2 0%, #e0f3ea 100%); + --color-tag-text: #2d9668; + --color-hospital: #3a9d72; + + --radius-sm: 10px; + --radius-md: 14px; + --radius-lg: 20px; + --radius-xl: 28px; + --radius-full: 9999px; + + --shadow-sm: 0 2px 12px rgba(45, 120, 90, 0.06); + --shadow-md: 0 8px 24px rgba(45, 120, 90, 0.08); + --shadow-lg: 0 12px 32px rgba(45, 120, 90, 0.14); + --shadow-card: 0 4px 20px rgba(26, 46, 36, 0.05); + --shadow-glow: 0 8px 32px rgba(76, 175, 138, 0.22); + + --space-xs: 4px; + --space-sm: 8px; + --space-md: 16px; + --space-lg: 24px; + --space-xl: 32px; + + --font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", sans-serif; + --header-height: 48px; + --footer-cta-height: 80px; + --safe-bottom: env(safe-area-inset-bottom, 0px); + + --gradient-primary: linear-gradient(135deg, #5ec99a 0%, #3da876 50%, #2d9668 100%); + --gradient-hero: linear-gradient(165deg, #e8f7ef 0%, #f5fbf8 45%, #f0f5f2 100%); + --gradient-surface: linear-gradient(180deg, #ffffff 0%, #fafdfb 100%); +} diff --git a/data/.htaccess b/data/.htaccess new file mode 100644 index 0000000..806d7d7 --- /dev/null +++ b/data/.htaccess @@ -0,0 +1,12 @@ + + + Require all denied + + + + + + Order allow,deny + Deny from all + + diff --git a/data/doctors.json b/data/doctors.json new file mode 100644 index 0000000..d05b268 --- /dev/null +++ b/data/doctors.json @@ -0,0 +1,29 @@ +[ + { + "id": "song", + "name": "宋崇升", + "avatar": "assets/avatars/song.jpg", + "role": "副主任医师", + "hospital": "北京回龙观医院", + "fields": ["抑郁障碍", "焦虑障碍", "双相情感障碍", "青少年行为情绪障碍", "睡眠障碍等"], + "bio": "擅长抑郁障碍、焦虑障碍、双相情感障碍、青少年行为情绪障碍、睡眠障碍等。" + }, + { + "id": "zeng", + "name": "曾媛", + "avatar": "assets/avatars/zeng.jpg", + "role": "热门咨询师", + "hospital": "上海六一儿童医院", + "fields": ["抑郁", "焦虑", "职业关系", "家庭关系", "自我成长", "青少年休学厌学"], + "bio": "擅长抑郁、焦虑、职业关系、家庭关系、自我成长、青少年休学厌学。" + }, + { + "id": "li", + "name": "李相伟", + "avatar": "assets/avatars/li.jpg", + "role": "热门咨询师", + "hospital": "上海市精神卫生中心", + "fields": ["焦虑", "抑郁", "两性情感", "家庭及亲密关系", "个人心理成长", "亲子教育等"], + "bio": "擅长焦虑、抑郁、两性情感、家庭及亲密关系、个人心理成长、亲子教育等。" + } +] diff --git a/data/submissions.example.json b/data/submissions.example.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/data/submissions.example.json @@ -0,0 +1 @@ +[] diff --git a/data/topics.json b/data/topics.json new file mode 100644 index 0000000..1ee49f6 --- /dev/null +++ b/data/topics.json @@ -0,0 +1,8 @@ +[ + { "id": "interpersonal", "label": "人际关系" }, + { "id": "emotion", "label": "情绪压力" }, + { "id": "career", "label": "职业发展" }, + { "id": "family", "label": "家庭婚姻" }, + { "id": "adolescent", "label": "青少年成长" }, + { "id": "self", "label": "自我探索" } +] diff --git a/debug.html b/debug.html new file mode 100644 index 0000000..286129c --- /dev/null +++ b/debug.html @@ -0,0 +1,10 @@ + + + + + + + 跳转中… + + + diff --git a/debug.php b/debug.php new file mode 100644 index 0000000..16e0e4e --- /dev/null +++ b/debug.php @@ -0,0 +1,159 @@ + + + + + + + + + 系统调试 - 心理咨询 + + + + + +
+

系统调试

+

访问地址须带访问令牌,例如:

+ +
+

环境检测

+
加载中…
+
+ +
+

操作

+
+ + + + 打开预约页 + 管理后台 +
+
+ +
+

日志

+
等待操作…
+
+
+ + + + + diff --git a/index.html b/index.html new file mode 100644 index 0000000..329fd6a --- /dev/null +++ b/index.html @@ -0,0 +1,10 @@ + + + + + + + 跳转中… + + + diff --git a/index.php b/index.php new file mode 100644 index 0000000..2e21e1c --- /dev/null +++ b/index.php @@ -0,0 +1,58 @@ + + + + + + + + + + + 心理咨询 + + + + + +
+
+ + 专业 · 安心 · 保密 +
+

开启您的
专业心理沟通之旅

+

简单描述您的困扰,为您匹配契合的咨询师

+
+
+ +
+
+ +
+
+

热门咨询师

+

精选资深心理咨询师,一对一专业陪伴

+
+
+
+ + +
+ + + + + + + diff --git a/js/access.js b/js/access.js new file mode 100644 index 0000000..20fb895 --- /dev/null +++ b/js/access.js @@ -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 = ` +
🔒
+

请使用专属二维码访问

+

本服务仅对指定用户开放
请扫描管理员提供的二维码进入

+ `; + document.body.appendChild(overlay); +} + +async function initAccessGuard() { + const allowed = await checkAccess(); + if (!allowed) { + showAccessDenied(); + return false; + } + return true; +} diff --git a/js/admin.js b/js/admin.js new file mode 100644 index 0000000..2aa6364 --- /dev/null +++ b/js/admin.js @@ -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 = ` +
${formatTime(item.createdAt)}
+
昵称:${escapeHtml(item.nickname || "-")}
+
性别:${escapeHtml(item.gender || "-")}
+
联系电话:${escapeHtml(item.contact || "-")}
+
咨询师:${escapeHtml(doctorInfo)}
+

${escapeHtml(item.description || "(无描述)")}

+ `; + 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 = "

加载中…

"; + + 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 = '

暂无提交记录

'; + return; + } + + listEl.innerHTML = ""; + items.forEach((item) => listEl.appendChild(renderSubmissionCard(item))); + } catch (e) { + if (e.message && e.message.includes("未登录")) { + showLogin(); + return; + } + listEl.innerHTML = '

加载失败

'; + 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); diff --git a/js/api.js b/js/api.js new file mode 100644 index 0000000..9210f5d --- /dev/null +++ b/js/api.js @@ -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(/\/$/, ""); +})(); diff --git a/js/config-loader.js b/js/config-loader.js new file mode 100644 index 0000000..ad6588c --- /dev/null +++ b/js/config-loader.js @@ -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; +} diff --git a/js/consult.js b/js/consult.js new file mode 100644 index 0000000..cad4325 --- /dev/null +++ b/js/consult.js @@ -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 = ` +
+
+ ${escapeHtml(doctor.name)} +
+ +
+
+
${escapeHtml(doctor.name)} ${escapeHtml(doctor.role || "")}
+
${escapeHtml(doctor.hospital || "")}
+
+ `; +} + +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 = ` +
+

提交成功

+

我们已收到您的预约信息
工作人员将在2小时内与您联系确认

+ + `; + 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); diff --git a/js/home.js b/js/home.js new file mode 100644 index 0000000..3371380 --- /dev/null +++ b/js/home.js @@ -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 = ` +
+
+ ${escapeHtml(doctor.name)} +
+ +
+
+
+ ${escapeHtml(doctor.name)} + ${escapeHtml(doctor.role || "咨询师")} +
+

${escapeHtml(doctor.hospital || "")}

+

+ 专业领域${escapeHtml(fields)} +

+
+ + `; + + 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 = '

暂无咨询师信息

'; + } else { + doctors.forEach((d) => listEl.appendChild(renderDoctorCard(d))); + } + } catch (e) { + listEl.innerHTML = '

加载失败,请刷新重试

'; + showToast(e.message || "加载失败"); + } + + ctaBtn.addEventListener("click", () => goConsult()); +} + +document.addEventListener("DOMContentLoaded", initHome); diff --git a/qr.html b/qr.html new file mode 100644 index 0000000..3779f06 --- /dev/null +++ b/qr.html @@ -0,0 +1,10 @@ + + + + + + + 跳转中… + + + diff --git a/qr.php b/qr.php new file mode 100644 index 0000000..7833bf0 --- /dev/null +++ b/qr.php @@ -0,0 +1,92 @@ + + + + + + + + + 生成访问二维码 - 心理咨询 + + + + +
+

访问二维码

+

用户扫描下方二维码即可进入心理咨询服务(仅限持有链接者访问)

+ +
+
+
+ +
加载中…
+ +
+ + 预览首页 + 管理后台 +
+ +
+

使用说明:

+

1. 部署前请在 config/app.json 中修改 accessToken 为随机长字符串。

+

2. 将本页生成的二维码打印或发送给用户,勿公开传播链接。

+

3. 更换 token 后需重新生成二维码,旧链接将失效。

+
+
+ + + + + +