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