Initial commit: 心理咨询预约系统

PHP 前后端一体:首页咨询师列表、预约表单、管理后台、二维码准入与邮件通知;敏感配置与提交数据通过 .gitignore 排除。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
litianming
2026-06-04 10:17:09 +08:00
commit 08ce60344d
49 changed files with 3490 additions and 0 deletions

17
.gitignore vendored Normal file
View File

@@ -0,0 +1,17 @@
# 敏感配置(使用 *.example.json 模板)
config/app.json
config/mail.json
# 运行时数据
data/submissions.json
# 文档与归档
prd/
*.zip
*.docx
# 系统文件
.DS_Store
*.log
.idea/
.vscode/

25
.htaccess Normal file
View File

@@ -0,0 +1,25 @@
# 禁止直接访问提交数据
<IfModule mod_authz_core.c>
<Files "submissions.json">
Require all denied
</Files>
</IfModule>
<IfModule !mod_authz_core.c>
<Files "submissions.json">
Order allow,deny
Deny from all
</Files>
</IfModule>
# HTML/PHP 页面不缓存,静态资源可长期缓存(靠 ?v= 版本号更新)
<IfModule mod_headers.c>
<FilesMatch "\.(html|php)$">
Header set Cache-Control "no-cache, no-store, must-revalidate"
Header set Pragma "no-cache"
Header set Expires "0"
</FilesMatch>
<FilesMatch "\.(css|js|png|jpg|jpeg|svg|webp)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
</IfModule>

129
README.md Normal file
View File

@@ -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/ # 脚本
```

10
admin.html Normal file
View File

@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta http-equiv="refresh" content="0;url=admin.php">
<script>location.replace("admin.php" + location.search + location.hash);</script>
<title>跳转中…</title>
</head>
<body></body>
</html>

55
admin.php Normal file
View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
require __DIR__ . '/api/bootstrap.php';
sendHtmlNoCacheHeaders();
$v = getAssetVersion();
?><!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<title>管理后台 - 心理咨询</title>
<link rel="stylesheet" href="<?= asset('css/base.css') ?>">
<link rel="stylesheet" href="<?= asset('css/form.css') ?>">
<link rel="stylesheet" href="<?= asset('css/admin.css') ?>">
</head>
<body>
<div class="page admin-page">
<section id="login-section" class="admin-login">
<h1 class="admin-login__title">管理后台</h1>
<form id="login-form">
<div class="form-field">
<label class="form-field__label" for="admin-user">账号</label>
<input type="text" id="admin-user" class="form-field__input" placeholder="请输入账号" autocomplete="username" required>
</div>
<div class="form-field">
<label class="form-field__label" for="admin-password">密码</label>
<input type="password" id="admin-password" class="form-field__input" placeholder="请输入密码" autocomplete="current-password" required>
</div>
<button type="submit" class="btn btn--primary btn--block">登录</button>
</form>
</section>
<section id="dashboard-section" style="display:none">
<div class="admin-header">
<h1 class="admin-header__title">提交记录 (<span id="submission-count">0</span>)</h1>
<button type="button" class="btn btn--ghost" id="logout-btn" style="min-height:36px;padding:0 12px;font-size:13px">退出</button>
</div>
<div class="admin-actions">
<button type="button" class="btn btn--primary" id="export-csv">下载 CSV</button>
<button type="button" class="btn btn--primary" id="export-json">下载 JSON</button>
<button type="button" class="btn btn--ghost" id="refresh-btn">刷新</button>
</div>
<div id="submission-list"></div>
</section>
</div>
<script src="<?= asset('js/api.js') ?>"></script>
<script src="<?= asset('js/admin.js') ?>"></script>
</body>
</html>

178
api/bootstrap.php Normal file
View File

@@ -0,0 +1,178 @@
<?php
declare(strict_types=1);
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
ini_set('display_errors', '0');
define('ROOT_DIR', dirname(__DIR__));
define('DATA_DIR', ROOT_DIR . '/data');
define('CONFIG_FILE', ROOT_DIR . '/config/app.json');
define('SUBMISSIONS_FILE', DATA_DIR . '/submissions.json');
function loadAppConfig(): array
{
static $config = null;
if ($config !== null) {
return $config;
}
if (!is_file(CONFIG_FILE)) {
jsonError('配置文件不存在', 500);
}
$raw = file_get_contents(CONFIG_FILE);
$config = json_decode($raw ?: '{}', true);
if (!is_array($config)) {
jsonError('配置文件格式错误', 500);
}
return $config;
}
function jsonResponse($data, int $code = 200): void
{
http_response_code($code);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($data, JSON_UNESCAPED_UNICODE);
exit;
}
function jsonError(string $message, int $code = 400): void
{
jsonResponse(['ok' => 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');
}

13
api/check.php Normal file
View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
jsonError('方法不允许', 405);
}
jsonResponse([
'ok' => true,
'loggedIn' => !empty($_SESSION['admin']),
]);

18
api/config.php Normal file
View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
jsonError('方法不允许', 405);
}
$config = loadAppConfig();
jsonResponse([
'ok' => true,
'siteTitle' => $config['siteTitle'] ?? '心理咨询',
'accessToken' => $config['accessToken'] ?? '',
'servicePhone' => $config['servicePhone'] ?? '',
'confirmPhone' => $config['confirmPhone'] ?? '',
]);

129
api/diagnostics.php Normal file
View File

@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
require __DIR__ . '/mailer.php';
function requireDebugAccess(): void
{
$token = trim((string) ($_GET['access'] ?? $_POST['access'] ?? ''));
$config = loadAppConfig();
if ($token === '' || $token !== ($config['accessToken'] ?? '')) {
jsonError('无权访问,请在 URL 中携带 ?access=访问令牌', 403);
}
}
function checkWritableDir(string $dir): array
{
if (!is_dir($dir)) {
return ['ok' => 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('未知操作');

11
api/doctors.php Normal file
View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
jsonError('方法不允许', 405);
}
$doctors = readJsonFile(DATA_DIR . '/doctors.json');
jsonResponse(['ok' => true, 'data' => $doctors]);

50
api/export.php Normal file
View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
jsonError('方法不允许', 405);
}
requireAdmin();
$format = strtolower(trim((string) ($_GET['format'] ?? 'json')));
if (!in_array($format, ['json', 'csv'], true)) {
jsonError('不支持的导出格式');
}
$submissions = readSubmissions();
usort($submissions, function ($a, $b) {
return strcmp($b['createdAt'] ?? '', $a['createdAt'] ?? '');
});
$filename = 'consultations_' . date('Ymd_His');
if ($format === 'json') {
header('Content-Type: application/json; charset=utf-8');
header('Content-Disposition: attachment; filename="' . $filename . '.json"');
echo json_encode($submissions, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
exit;
}
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="' . $filename . '.csv"');
echo "\xEF\xBB\xBF";
$out = fopen('php://output', 'w');
fputcsv($out, ['ID', '提交时间', '咨询师', '咨询师ID', '昵称', '性别', '联系电话', '需求简述']);
foreach ($submissions as $row) {
fputcsv($out, [
$row['id'] ?? '',
$row['createdAt'] ?? '',
$row['doctorName'] ?? '',
$row['doctorId'] ?? '',
$row['nickname'] ?? '',
$row['gender'] ?? '',
$row['contact'] ?? '',
$row['description'] ?? ($row['topic'] ?? ''),
]);
}
fclose($out);
exit;

23
api/login.php Normal file
View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonError('方法不允许', 405);
}
$body = getRequestBody();
$user = trim((string) ($body['user'] ?? ''));
$password = trim((string) ($body['password'] ?? ''));
$config = loadAppConfig();
$adminUser = (string) ($config['adminUser'] ?? 'frankfrank');
$adminPassword = (string) ($config['adminPassword'] ?? 'frankfrank');
if ($user !== $adminUser || $password !== $adminPassword) {
jsonError('账号或密码错误', 401);
}
$_SESSION['admin'] = true;
jsonResponse(['ok' => true, 'message' => '登录成功']);

25
api/logout.php Normal file
View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonError('方法不允许', 405);
}
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$params = session_get_cookie_params();
setcookie(
session_name(),
'',
time() - 42000,
$params['path'],
$params['domain'],
$params['secure'],
$params['httponly']
);
}
session_destroy();
jsonResponse(['ok' => true, 'message' => '已退出']);

338
api/mailer.php Normal file
View File

@@ -0,0 +1,338 @@
<?php
declare(strict_types=1);
define('MAIL_CONFIG_FILE', ROOT_DIR . '/config/mail.json');
function loadMailConfig(): array
{
static $config = null;
if ($config !== null) {
return $config;
}
if (!is_file(MAIL_CONFIG_FILE)) {
return ['enabled' => 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 '<p style="font-family:sans-serif;color:#666;">当前暂无提交记录。</p>';
}
$rows = '';
foreach ($submissions as $i => $record) {
$row = normalizeSubmissionRow($record, $i + 1);
$bg = ($i % 2 === 0) ? '#ffffff' : '#f8fbf9';
$rows .= '<tr style="background:' . $bg . ';">'
. '<td style="padding:10px 8px;border:1px solid #e0ebe4;text-align:center;color:#333;">' . escapeMailHtml($row['index']) . '</td>'
. '<td style="padding:10px 8px;border:1px solid #e0ebe4;white-space:nowrap;color:#333;">' . escapeMailHtml($row['time']) . '</td>'
. '<td style="padding:10px 8px;border:1px solid #e0ebe4;color:#333;">' . escapeMailHtml($row['nickname']) . '</td>'
. '<td style="padding:10px 8px;border:1px solid #e0ebe4;text-align:center;color:#333;">' . escapeMailHtml($row['gender']) . '</td>'
. '<td style="padding:10px 8px;border:1px solid #e0ebe4;white-space:nowrap;color:#333;">' . escapeMailHtml($row['contact']) . '</td>'
. '<td style="padding:10px 8px;border:1px solid #e0ebe4;color:#40916c;font-weight:600;">' . escapeMailHtml($row['doctor']) . '</td>'
. '<td style="padding:10px 8px;border:1px solid #e0ebe4;color:#555;line-height:1.6;">' . nl2br(escapeMailHtml($row['description'])) . '</td>'
. '</tr>';
}
return <<<HTML
<div style="font-family:-apple-system,BlinkMacSystemFont,'PingFang SC','Helvetica Neue',Arial,sans-serif;color:#333;max-width:960px;">
<h2 style="margin:0 0 8px;font-size:18px;color:#40916c;">心理咨询预约记录汇总</h2>
<p style="margin:0 0 16px;font-size:13px;color:#888;">共 {$count} 条记录 · 生成时间 {$generatedAt}</p>
<table style="width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;">
<thead>
<tr style="background:#40916c;color:#fff;">
<th style="padding:10px 8px;border:1px solid #358f5a;width:40px;text-align:center;">序号</th>
<th style="padding:10px 8px;border:1px solid #358f5a;width:140px;">提交时间</th>
<th style="padding:10px 8px;border:1px solid #358f5a;width:72px;">提交人</th>
<th style="padding:10px 8px;border:1px solid #358f5a;width:48px;text-align:center;">性别</th>
<th style="padding:10px 8px;border:1px solid #358f5a;width:110px;">联系电话</th>
<th style="padding:10px 8px;border:1px solid #358f5a;width:88px;">咨询师</th>
<th style="padding:10px 8px;border:1px solid #358f5a;">需求简述</th>
</tr>
</thead>
<tbody>
{$rows}
</tbody>
</table>
<p style="margin:20px 0 0;font-size:12px;color:#aaa;">—— 心理咨询系统自动通知</p>
</div>
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);
}

17
api/submissions.php Normal file
View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
jsonError('方法不允许', 405);
}
requireAdmin();
$submissions = readSubmissions();
usort($submissions, function ($a, $b) {
return strcmp($b['createdAt'] ?? '', $a['createdAt'] ?? '');
});
jsonResponse(['ok' => true, 'data' => $submissions, 'total' => count($submissions)]);

75
api/submit.php Normal file
View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
require __DIR__ . '/mailer.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonError('方法不允许', 405);
}
$body = getRequestBody();
$doctorId = trim((string) ($body['doctorId'] ?? ''));
$doctorName = trim((string) ($body['doctorName'] ?? ''));
$nickname = trim((string) ($body['nickname'] ?? ''));
$gender = trim((string) ($body['gender'] ?? ''));
$contact = trim((string) ($body['contact'] ?? ''));
$description = trim((string) ($body['description'] ?? ''));
if ($nickname === '') {
jsonError('请填写昵称');
}
if (!in_array($gender, ['男', '女'], true)) {
jsonError('请选择性别');
}
if ($contact === '') {
jsonError('请填写联系电话');
}
if (!validatePhone($contact)) {
jsonError('请输入正确的11位手机号码');
}
if ($description === '') {
jsonError('请填写需求简述');
}
if (mb_strlen($description) > 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;

11
api/topics.php Normal file
View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
jsonError('方法不允许', 405);
}
$topics = readJsonFile(DATA_DIR . '/topics.json');
jsonResponse(['ok' => true, 'data' => $topics]);

13
api/version.php Normal file
View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
jsonError('方法不允许', 405);
}
jsonResponse([
'ok' => true,
'version' => getAssetVersion(),
]);

5
assets/avatars/li.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
<circle cx="60" cy="60" r="60" fill="#E8F4F8"/>
<circle cx="60" cy="48" r="22" fill="#5BA4B8"/>
<ellipse cx="60" cy="95" rx="32" ry="24" fill="#5BA4B8"/>
</svg>

After

Width:  |  Height:  |  Size: 255 B

5
assets/avatars/wang.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
<circle cx="60" cy="60" r="60" fill="#F0EDE8"/>
<circle cx="60" cy="48" r="22" fill="#8B7355"/>
<ellipse cx="60" cy="95" rx="32" ry="24" fill="#8B7355"/>
</svg>

After

Width:  |  Height:  |  Size: 255 B

5
assets/avatars/zhang.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
<circle cx="60" cy="60" r="60" fill="#FFE4D6"/>
<circle cx="60" cy="48" r="22" fill="#E87B3A"/>
<ellipse cx="60" cy="95" rx="32" ry="24" fill="#E87B3A"/>
</svg>

After

Width:  |  Height:  |  Size: 255 B

11
assets/hero.svg Normal file
View File

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 200" width="320" height="200">
<rect width="320" height="200" fill="none"/>
<ellipse cx="160" cy="170" rx="100" ry="12" fill="#F5D5C8" opacity="0.5"/>
<rect x="80" y="100" width="160" height="8" rx="4" fill="#D4A574"/>
<rect x="100" y="108" width="40" height="50" rx="4" fill="#E87B3A" opacity="0.3"/>
<circle cx="120" cy="75" r="18" fill="#FFD4C2"/>
<rect x="108" y="93" width="24" height="35" rx="6" fill="#E87B3A"/>
<circle cx="200" cy="80" r="16" fill="#FFE4D6"/>
<rect x="190" y="96" width="20" height="30" rx="5" fill="#5BA4B8"/>
<path d="M130 60 Q160 40 190 55" stroke="#E87B3A" stroke-width="2" fill="none" opacity="0.4"/>
</svg>

After

Width:  |  Height:  |  Size: 713 B

12
config/.htaccess Normal file
View File

@@ -0,0 +1,12 @@
<IfModule mod_authz_core.c>
<FilesMatch "\.(json)$">
Require all denied
</FilesMatch>
</IfModule>
<IfModule !mod_authz_core.c>
<FilesMatch "\.(json)$">
Order allow,deny
Deny from all
</FilesMatch>
</IfModule>

58
config/README.md Normal file
View File

@@ -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 日志。

9
config/app.example.json Normal file
View File

@@ -0,0 +1,9 @@
{
"siteTitle": "心理咨询",
"accessToken": "change_me_to_random_token",
"adminUser": "frankfrank",
"adminPassword": "change_me",
"servicePhone": "13122315169",
"confirmPhone": "13122315169",
"assetVersion": ""
}

17
config/mail.example.json Normal file
View File

@@ -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": "心理咨询系统"
}
}

10
consult.html Normal file
View File

@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta http-equiv="refresh" content="0;url=consult.php">
<script>location.replace("consult.php" + location.search + location.hash);</script>
<title>跳转中…</title>
</head>
<body></body>
</html>

86
consult.php Normal file
View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
require __DIR__ . '/api/bootstrap.php';
sendHtmlNoCacheHeaders();
$v = getAssetVersion();
?><!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
<meta name="format-detection" content="telephone=no">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<title>心理咨询 - 预约</title>
<link rel="stylesheet" href="<?= asset('css/base.css') ?>">
<link rel="stylesheet" href="<?= asset('css/form.css') ?>">
<script>window.__ASSET_V__="<?= htmlspecialchars($v, ENT_QUOTES) ?>";</script>
</head>
<body>
<div class="page page--fade-in form-page">
<header class="header">
<button type="button" class="header__back" id="back-btn" aria-label="返回">←</button>
<h1 class="header__title">心理咨询</h1>
</header>
<div id="doctor-banner" class="doctor-banner" style="display:none"></div>
<div class="form-body">
<div class="form-card">
<h2 class="form-card__title">基本信息</h2>
<div class="form-field">
<label class="form-field__label" for="nickname">昵称<span class="required">*</span></label>
<input type="text" id="nickname" class="form-field__input" placeholder="请输入昵称" maxlength="20" aria-required="true">
</div>
<div class="form-field">
<span class="form-field__label">性别<span class="required">*</span></span>
<div class="gender-group" role="radiogroup" aria-label="性别">
<label class="gender-option">
<input type="radio" name="gender" value="男">
<span class="gender-option__text">男</span>
</label>
<label class="gender-option">
<input type="radio" name="gender" value="女">
<span class="gender-option__text">女</span>
</label>
</div>
</div>
<div class="form-field">
<label class="form-field__label" for="contact">联系电话<span class="required">*</span></label>
<input type="tel" id="contact" class="form-field__input" placeholder="请输入手机号码" maxlength="11" inputmode="numeric" aria-required="true">
</div>
</div>
<div class="form-card">
<label class="form-field__label" for="description">需求简述<span class="required">*</span></label>
<div class="textarea-wrap">
<textarea id="description" class="form-field__textarea" placeholder="请简要描述您的咨询需求" maxlength="200" aria-required="true"></textarea>
</div>
<div class="textarea-wrap__footer">
<span class="char-count" id="char-count">(0/200字)</span>
</div>
</div>
</div>
<section class="service-info">
<h3 class="service-info__title">心理咨询服务说明</h3>
<p class="service-info__item">1. 服务时效申请后实时响应我们将在2h内确认您与心理咨询师的沟通时间。请注意接听<strong id="confirm-phone">13122315169</strong>的号码来电。</p>
<p class="service-info__item">2. 服务旨在为用户提供专业的心理支持和建议,帮助用户理解和管理自己的情绪、行为及人际关系等问题。不涉及心理治疗、药物治疗或其他医疗行为。</p>
<p class="service-info__phone">客服/投诉专线:<span id="service-hotline">13122315169</span></p>
</section>
<div class="submit-bar">
<button type="button" class="btn btn--primary btn--block btn--shine" id="submit-btn">提交</button>
</div>
</div>
<script src="<?= asset('js/api.js') ?>"></script>
<script src="<?= asset('js/config-loader.js') ?>"></script>
<script src="<?= asset('js/access.js') ?>"></script>
<script src="<?= asset('js/consult.js') ?>"></script>
</body>
</html>

143
css/admin.css Normal file
View File

@@ -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;
}

322
css/base.css Normal file
View File

@@ -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);
}

382
css/form.css Normal file
View File

@@ -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);
}

311
css/home.css Normal file
View File

@@ -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;
}
}

46
css/variables.css Normal file
View File

@@ -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%);
}

12
data/.htaccess Normal file
View File

@@ -0,0 +1,12 @@
<IfModule mod_authz_core.c>
<Files "submissions.json">
Require all denied
</Files>
</IfModule>
<IfModule !mod_authz_core.c>
<Files "submissions.json">
Order allow,deny
Deny from all
</Files>
</IfModule>

29
data/doctors.json Normal file
View File

@@ -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": "擅长焦虑、抑郁、两性情感、家庭及亲密关系、个人心理成长、亲子教育等。"
}
]

View File

@@ -0,0 +1 @@
[]

8
data/topics.json Normal file
View File

@@ -0,0 +1,8 @@
[
{ "id": "interpersonal", "label": "人际关系" },
{ "id": "emotion", "label": "情绪压力" },
{ "id": "career", "label": "职业发展" },
{ "id": "family", "label": "家庭婚姻" },
{ "id": "adolescent", "label": "青少年成长" },
{ "id": "self", "label": "自我探索" }
]

10
debug.html Normal file
View File

@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta http-equiv="refresh" content="0;url=debug.php">
<script>location.replace("debug.php" + location.search + location.hash);</script>
<title>跳转中…</title>
</head>
<body></body>
</html>

159
debug.php Normal file
View File

@@ -0,0 +1,159 @@
<?php
declare(strict_types=1);
require __DIR__ . '/api/bootstrap.php';
sendHtmlNoCacheHeaders();
$v = getAssetVersion();
?><!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<title>系统调试 - 心理咨询</title>
<link rel="stylesheet" href="<?= asset('css/base.css') ?>">
<link rel="stylesheet" href="<?= asset('css/admin.css') ?>">
<style>
.debug-page { padding: 16px; max-width: 640px; margin: 0 auto; }
.debug-card { background: #fff; border-radius: 12px; padding: 16px; margin-bottom: 16px; box-shadow: 0 2px 8px rgba(0,0,0,.06); }
.debug-card h2 { font-size: 16px; margin-bottom: 12px; }
.debug-row { font-size: 14px; margin-bottom: 8px; line-height: 1.6; word-break: break-all; }
.debug-row.ok { color: #40916c; }
.debug-row.fail { color: #e54d4d; }
.debug-actions { display: flex; flex-direction: column; gap: 8px; }
.debug-log { background: #1e1e1e; color: #d4d4d4; padding: 12px; border-radius: 8px; font-size: 12px; white-space: pre-wrap; word-break: break-all; max-height: 320px; overflow: auto; }
.debug-note { font-size: 13px; color: #666; line-height: 1.7; }
</style>
</head>
<body>
<div class="page debug-page">
<h1 class="admin-header__title">系统调试</h1>
<p class="debug-note">访问地址须带访问令牌,例如:<br><code id="sample-url"></code></p>
<div class="debug-card">
<h2>环境检测</h2>
<div id="env-result">加载中…</div>
</div>
<div class="debug-card">
<h2>操作</h2>
<div class="debug-actions">
<button type="button" class="btn btn--primary btn--block" id="btn-refresh">刷新检测</button>
<button type="button" class="btn btn--primary btn--block" id="btn-test-submit">测试提交(写入一条记录)</button>
<button type="button" class="btn btn--ghost btn--block" id="btn-test-mail">测试邮件(发送全部历史记录)</button>
<a href="consult.php" class="btn btn--ghost btn--block" style="text-align:center;line-height:48px">打开预约页</a>
<a href="admin.php" class="btn btn--ghost btn--block" style="text-align:center;line-height:48px">管理后台</a>
</div>
</div>
<div class="debug-card">
<h2>日志</h2>
<pre class="debug-log" id="debug-log">等待操作…</pre>
</div>
</div>
<script src="<?= asset('js/api.js') ?>"></script>
<script>
function getAccessToken() {
return new URLSearchParams(window.location.search).get("access") || "";
}
function diagUrl(action) {
const token = getAccessToken();
const base = new URL("api/diagnostics.php", window.location.href);
if (action) base.searchParams.set("action", action);
if (token) base.searchParams.set("access", token);
return base.href;
}
function log(msg) {
const el = document.getElementById("debug-log");
const time = new Date().toLocaleString("zh-CN", { hour12: false });
el.textContent = `[${time}] ${msg}\n\n` + el.textContent;
}
function row(label, ok, detail) {
return `<div class="debug-row ${ok ? "ok" : "fail"}">${ok ? "✓" : "✗"} ${label}${detail ? "" + detail : ""}</div>`;
}
async function loadDiagnostics() {
const token = getAccessToken();
const envEl = document.getElementById("env-result");
document.getElementById("sample-url").textContent =
window.location.origin + window.location.pathname.replace(/debug\.php$/, "debug.php") + "?access=你的accessToken";
if (!token) {
envEl.innerHTML = row("访问令牌", false, "URL 缺少 ?access= 参数");
return;
}
envEl.innerHTML = "检测中…";
try {
const res = await fetch(diagUrl(), { credentials: "same-origin" });
const text = await res.text();
let data;
try { data = JSON.parse(text); } catch {
envEl.innerHTML = row("接口响应", false, `非 JSONHTTP ${res.status}`);
log("GET diagnostics 失败:\n" + text.slice(0, 800));
return;
}
if (!res.ok) throw new Error(data.message || "检测失败");
const ext = data.extensions || {};
envEl.innerHTML = [
row("PHP 版本", true, data.phpVersion),
row("资源版本", true, data.assetVersion || "-"),
row("json 扩展", !!ext.json),
row("openssl 扩展", !!ext.openssl),
row("data 目录可写", !!data.dataDir?.ok, data.dataDir?.message),
row("提交记录数", true, String(data.submissions?.count ?? 0)),
row("邮件通知", !!data.mail?.enabled, data.mail?.enabled ? `${data.mail.recipientCount} 个收件人` : "未启用"),
row("submit 接口", true, new URL("api/submit.php", window.location.href).href),
].join("");
log("环境检测成功:\n" + JSON.stringify(data, null, 2));
} catch (e) {
envEl.innerHTML = row("环境检测", false, e.message);
log("环境检测异常: " + e.message);
}
}
async function postAction(action) {
const token = getAccessToken();
if (!token) {
alert("请先在 URL 中添加 ?access=访问令牌");
return;
}
log(`开始 ${action}…`);
try {
const res = await fetch(diagUrl(action), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
credentials: "same-origin",
});
const text = await res.text();
let data;
try { data = JSON.parse(text); } catch {
log(`${action} 失败HTTP ${res.status},非 JSON:\n` + text.slice(0, 800));
alert("请求失败,详见日志");
return;
}
if (!res.ok) throw new Error(data.message || `HTTP ${res.status}`);
log(`${action} 成功:\n` + JSON.stringify(data, null, 2));
alert(data.message || "操作成功");
await loadDiagnostics();
} catch (e) {
log(`${action} 异常: ` + e.message);
alert(e.message);
}
}
document.getElementById("btn-refresh").addEventListener("click", loadDiagnostics);
document.getElementById("btn-test-submit").addEventListener("click", () => postAction("submit"));
document.getElementById("btn-test-mail").addEventListener("click", () => postAction("mail"));
loadDiagnostics();
</script>
</body>
</html>

10
index.html Normal file
View File

@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta http-equiv="refresh" content="0;url=index.php">
<script>location.replace("index.php" + location.search + location.hash);</script>
<title>跳转中…</title>
</head>
<body></body>
</html>

58
index.php Normal file
View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
require __DIR__ . '/api/bootstrap.php';
sendHtmlNoCacheHeaders();
$v = getAssetVersion();
?><!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
<meta name="format-detection" content="telephone=no">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<meta name="theme-color" content="#e8f7ef">
<title>心理咨询</title>
<script>window.__ASSET_V__="<?= htmlspecialchars($v, ENT_QUOTES) ?>";</script>
<link rel="stylesheet" href="<?= asset('css/base.css') ?>">
<link rel="stylesheet" href="<?= asset('css/home.css') ?>">
</head>
<body>
<div class="page page--home page--fade-in">
<section class="hero">
<div class="hero__decor" aria-hidden="true">
<span class="hero__blob hero__blob--1"></span>
<span class="hero__blob hero__blob--2"></span>
</div>
<span class="hero__badge">专业 · 安心 · 保密</span>
<div class="hero__content">
<h1 class="hero__title">开启您的<br>专业心理沟通之旅</h1>
<p class="hero__desc">简单描述您的困扰,为您匹配契合的咨询师</p>
</div>
<div class="hero__image-wrap">
<img class="hero__illustration" src="<?= asset('assets/hero.png') ?>" alt="" width="240" height="260">
</div>
</section>
<section class="doctor-list">
<div class="doctor-list__header">
<h2 class="doctor-list__heading">热门咨询师</h2>
<p class="doctor-list__sub">精选资深心理咨询师,一对一专业陪伴</p>
</div>
<div id="doctor-list" class="doctor-list__cards"></div>
</section>
<div class="footer-cta footer-cta--glass">
<button type="button" class="btn btn--primary btn--block btn--shine" id="cta-book">
<span class="btn__text">预约服务</span>
</button>
</div>
</div>
<script src="<?= asset('js/api.js') ?>"></script>
<script src="<?= asset('js/config-loader.js') ?>"></script>
<script src="<?= asset('js/access.js') ?>"></script>
<script src="<?= asset('js/home.js') ?>"></script>
</body>
</html>

51
js/access.js Normal file
View 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
View 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
View 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
View 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
View 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
View 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);

10
qr.html Normal file
View File

@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta http-equiv="refresh" content="0;url=qr.php">
<script>location.replace("qr.php" + location.search + location.hash);</script>
<title>跳转中…</title>
</head>
<body></body>
</html>

92
qr.php Normal file
View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
require __DIR__ . '/api/bootstrap.php';
sendHtmlNoCacheHeaders();
$v = getAssetVersion();
?><!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<title>生成访问二维码 - 心理咨询</title>
<link rel="stylesheet" href="<?= asset('css/base.css') ?>">
<link rel="stylesheet" href="<?= asset('css/admin.css') ?>">
</head>
<body>
<div class="page qr-page page--fade-in">
<h1 class="qr-page__title">访问二维码</h1>
<p class="qr-page__desc">用户扫描下方二维码即可进入心理咨询服务(仅限持有链接者访问)</p>
<div class="qr-canvas-wrap">
<div id="qrcode"></div>
</div>
<div class="qr-url-box" id="access-url">加载中…</div>
<div class="qr-actions">
<button type="button" class="btn btn--primary btn--block" id="copy-url">复制链接</button>
<a href="index.php" class="btn btn--ghost btn--block" style="text-align:center;line-height:48px">预览首页</a>
<a href="admin.php" class="btn btn--ghost btn--block" style="text-align:center;line-height:48px;margin-top:8px">管理后台</a>
</div>
<div class="qr-note">
<p><strong>使用说明:</strong></p>
<p>1. 部署前请在 <code>config/app.json</code> 中修改 <code>accessToken</code> 为随机长字符串。</p>
<p>2. 将本页生成的二维码打印或发送给用户,勿公开传播链接。</p>
<p>3. 更换 token 后需重新生成二维码,旧链接将失效。</p>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js"></script>
<script src="<?= asset('js/api.js') ?>"></script>
<script>
async function initQr() {
const urlBox = document.getElementById("access-url");
let token = "";
try {
const config = await apiGet("config.php");
token = config.accessToken || "";
document.title = (config.siteTitle || "心理咨询") + " - 二维码";
} catch (e) {
urlBox.textContent = "无法加载配置,请确认 PHP 服务已启动";
return;
}
const base = window.location.origin + window.location.pathname.replace(/qr\.php$/, "").replace(/qr\.html$/, "");
const accessUrl = base + "index.php?access=" + encodeURIComponent(token);
urlBox.textContent = accessUrl;
document.getElementById("qrcode").innerHTML = "";
new QRCode(document.getElementById("qrcode"), {
text: accessUrl,
width: 200,
height: 200,
colorDark: "#2c2c2c",
colorLight: "#ffffff",
correctLevel: QRCode.CorrectLevel.H,
});
document.getElementById("copy-url").addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(accessUrl);
alert("链接已复制");
} catch {
const input = document.createElement("input");
input.value = accessUrl;
document.body.appendChild(input);
input.select();
document.execCommand("copy");
input.remove();
alert("链接已复制");
}
});
}
document.addEventListener("DOMContentLoaded", initQr);
</script>
</body>
</html>