소스 검색

init: 肠愈同行后端项目初始化

master
leiyun 3 일 전
커밋
13566b2aeb
100개의 변경된 파일17838개의 추가작업 그리고 0개의 파일을 삭제
  1. +3
    -0
      .eslintrc
  2. +37
    -0
      .gitignore
  3. +67
    -0
      README.md
  4. +12
    -0
      development.js
  5. +50
    -0
      nginx.conf
  6. +59
    -0
      package.json
  7. +15
    -0
      pm2.json
  8. +8624
    -0
      pnpm-lock.yaml
  9. +11
    -0
      production.js
  10. +26
    -0
      restart.sh
  11. +22
    -0
      sql/content.sql
  12. +148
    -0
      sql/init.sql
  13. +13
    -0
      sql/message.sql
  14. +9
    -0
      sql/myinfo.sql
  15. +9
    -0
      sql/sign.sql
  16. +15
    -0
      sql/subscribe.sql
  17. +40
    -0
      sql/verify.sql
  18. +18
    -0
      sql/wechat_user.sql
  19. +1
    -0
      src/bootstrap/master.js
  20. +1
    -0
      src/bootstrap/worker.js
  21. +112
    -0
      src/config/adapter.js
  22. +46
    -0
      src/config/adapter.production.js
  23. +13
    -0
      src/config/config.js
  24. +4
    -0
      src/config/config.production.js
  25. +39
    -0
      src/config/cos.js
  26. +11
    -0
      src/config/extend.js
  27. +10
    -0
      src/config/faceid.js
  28. +44
    -0
      src/config/middleware.js
  29. +9
    -0
      src/config/ocr.js
  30. +87
    -0
      src/config/router.js
  31. +34
    -0
      src/config/sms.js
  32. +127
    -0
      src/controller/admin/auth.js
  33. +94
    -0
      src/controller/admin/content.js
  34. +33
    -0
      src/controller/admin/dashboard.js
  35. +429
    -0
      src/controller/admin/patient.js
  36. +62
    -0
      src/controller/admin/system/log.js
  37. +281
    -0
      src/controller/admin/system/role.js
  38. +50
    -0
      src/controller/admin/system/sms.js
  39. +268
    -0
      src/controller/admin/system/user.js
  40. +306
    -0
      src/controller/admin/upload.js
  41. +324
    -0
      src/controller/base.js
  42. +184
    -0
      src/controller/common.js
  43. +5
    -0
      src/controller/index.js
  44. +499
    -0
      src/controller/mp.js
  45. +5
    -0
      src/logic/index.js
  46. +31
    -0
      src/model/content.js
  47. +3
    -0
      src/model/index.js
  48. +5
    -0
      src/model/message.js
  49. +2
    -0
      src/model/operation_log.js
  50. +170
    -0
      src/model/patient.js
  51. +5
    -0
      src/model/patient_audit.js
  52. +5
    -0
      src/model/sms_log.js
  53. +9
    -0
      src/model/sys_region.js
  54. +19
    -0
      src/model/wechat_user.js
  55. +165
    -0
      src/service/screenshot.js
  56. +212
    -0
      src/service/wechat.js
  57. +7
    -0
      test/index.js
  58. +277
    -0
      view/admin/auth_login.html
  59. +70
    -0
      view/admin/common/_header.html
  60. +67
    -0
      view/admin/common/_sidebar.html
  61. +301
    -0
      view/admin/content_index.html
  62. +219
    -0
      view/admin/dashboard_index.html
  63. +129
    -0
      view/admin/layout.html
  64. +294
    -0
      view/admin/patient_detail.html
  65. +490
    -0
      view/admin/patient_index.html
  66. +145
    -0
      view/admin/system/log_index.html
  67. +310
    -0
      view/admin/system/role_index.html
  68. +139
    -0
      view/admin/system/sms_index.html
  69. +410
    -0
      view/admin/system/user_index.html
  70. +42
    -0
      view/error_404.html
  71. +37
    -0
      view/error_500.html
  72. +0
    -0
     
  73. BIN
     
  74. BIN
     
  75. BIN
     
  76. BIN
     
  77. BIN
     
  78. BIN
     
  79. BIN
     
  80. +0
    -0
     
  81. +226
    -0
      www/static/css/admin.css
  82. +1409
    -0
      www/static/css/web.css
  83. +28
    -0
      www/static/css/web_1024.css
  84. +78
    -0
      www/static/css/web_480.css
  85. +235
    -0
      www/static/css/web_768.css
  86. BIN
     
  87. +43
    -0
      www/static/iconfont/iconfont.css
  88. BIN
     
  89. BIN
     
  90. BIN
     
  91. BIN
     
  92. BIN
     
  93. BIN
     
  94. BIN
     
  95. BIN
     
  96. BIN
     
  97. BIN
     
  98. BIN
     
  99. BIN
     
  100. BIN
     

+ 3
- 0
.eslintrc 파일 보기

@@ -0,0 +1,3 @@
{
"extends": "think"
}

+ 37
- 0
.gitignore 파일 보기

@@ -0,0 +1,37 @@
# Logs
logs
*.log

# Runtime data
pids
*.pid
*.seed

# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov

# Coverage directory used by tools like istanbul
coverage/

# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt

# node-waf configuration
.lock-wscript

# Dependency directory
# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git
node_modules/

# IDE config
.idea

# output
output/
output.tar.gz

runtime/
app/

config.development.js
adapter.development.js

+ 67
- 0
README.md 파일 보기

@@ -0,0 +1,67 @@
# 北京维康慈善基金会官网

基于 ThinkJS 3.x 的慈善基金会官网系统,包含前台展示和后台管理。

## 技术栈

- **后端**: ThinkJS 3.x + MySQL + Redis
- **前台**: Nunjucks模板 + Swiper.js + 原生CSS
- **后台**: Vue 3 + Element Plus + Tailwind CSS
- **认证**: JWT Token

## 功能模块

### 前台
- 首页全屏滚动(Swiper Fullpage)
- Banner轮播(支持毛玻璃效果)
- 捐赠数据公示(统计卡片 + 滚动明细)
- 公益项目展示(Tab切换 + 背景联动)
- 新闻动态
- 合作伙伴
- 响应式适配(移动端汉堡菜单)

### 后台管理
- 用户/角色管理
- 栏目管理(树形结构)
- 内容类型:Banner、图文、文字列表、图片、单页、人员、岗位、捐赠收支
- 网站配置
- 药品援助数据管理

## 快速开始

```bash
# 安装依赖
pnpm install

# 开发环境
pnpm start

# 生产环境
node production.js
```

## 目录结构

```
├── src/
│ ├── controller/ # 控制器
│ ├── model/ # 数据模型
│ ├── config/ # 配置文件
│ └── logic/ # 逻辑层
├── view/
│ ├── admin/ # 后台视图
│ ├── common/ # 前台公共组件
│ ├── layout.html # 前台布局
│ └── index_index.html # 首页
├── www/static/ # 静态资源
└── sql/ # 数据库脚本
```

## 数据库

执行 `sql/` 目录下的SQL脚本初始化数据库。

## 访问地址

- 前台: http://localhost:8360/
- 后台: http://localhost:8360/admin/login.html

+ 12
- 0
development.js 파일 보기

@@ -0,0 +1,12 @@
const path = require('path');
const Application = require('thinkjs');
const watcher = require('think-watcher');

const instance = new Application({
ROOT_PATH: __dirname,
APP_PATH: path.join(__dirname, 'src'),
watcher: watcher,
env: 'development'
});

instance.run();

+ 50
- 0
nginx.conf 파일 보기

@@ -0,0 +1,50 @@
# HTTP -> HTTPS 强制跳转
server {
listen 80;
server_name wk.aionline.cc;
return 301 https://$host$request_uri;
}

# HTTPS
server {
listen 443 ssl;
http2 on;
server_name wk.aionline.cc;
root /home/www/pap_web/www;

ssl_certificate /etc/nginx/ssl/wk.aionline.cc.pem;
ssl_certificate_key /etc/nginx/ssl/wk.aionline.cc.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;

set $node_port 8361;

index index.js index.html index.htm;

# 静态资源直接返回,长缓存
location ~ /static/ {
etag on;
expires max;
}

# 上传文件目录
location /upload/ {
alias /home/www/pap_web/www/upload/;
etag on;
expires 7d;
}

# 其余请求代理到 Node
location / {
proxy_http_version 1.1;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $http_host;
proxy_set_header X-NginX-Proxy true;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_pass http://127.0.0.1:$node_port;
proxy_redirect off;
}
}

+ 59
- 0
package.json 파일 보기

@@ -0,0 +1,59 @@
{
"name": "cytx_api",
"description": "cytx_api management system",
"version": "1.0.0",
"author": "leiyun <leiyun@home.com>",
"scripts": {
"start": "node development.js",
"test": "THINK_UNIT_TEST=1 nyc ava test/ && nyc report --reporter=html",
"lint": "eslint src/",
"lint-fix": "eslint --fix src/"
},
"dependencies": {
"axios": "^1.13.5",
"cheerio": "^1.2.0",
"cos-nodejs-sdk-v5": "^2.14.0",
"dayjs": "^1.11.20",
"jsonwebtoken": "^9.0.3",
"puppeteer": "^24.39.1",
"sharp": "^0.33.0",
"svg-captcha": "^1.4.0",
"tencentcloud-sdk-nodejs": "^4.1.198",
"think-cache": "^1.0.0",
"think-cache-file": "^1.0.8",
"think-cache-redis": "^1.2.6",
"think-logger3": "^1.0.0",
"think-model": "^1.0.0",
"think-model-mysql": "^1.0.0",
"think-session": "^1.0.0",
"think-session-file": "^1.0.5",
"think-view": "^1.0.0",
"think-view-nunjucks": "^1.0.1",
"thinkjs": "^3.0.0"
},
"devDependencies": {
"ava": "^0.18.0",
"eslint": "^4.2.0",
"eslint-config-think": "^1.0.0",
"nyc": "^7.0.0",
"think-watcher": "^3.0.0"
},
"repository": "",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
},
"readmeFilename": "README.md",
"thinkjs": {
"metadata": {
"name": "cytx_api",
"description": "cytx_api management system",
"author": "leiyun <leiyun@home.com>",
"babel": false
},
"projectName": "cytx_api",
"template": "D:\\Program Files\\nvm\\nvm\\v24.10.0\\node_modules\\think-cli\\default_template",
"clone": false,
"isMultiModule": false
}
}

+ 15
- 0
pm2.json 파일 보기

@@ -0,0 +1,15 @@
{
"apps": [{
"name": "cytx_api",
"script": "production.js",
"cwd": "/home/www/cytx_api",
"exec_mode": "fork",
"max_memory_restart": "1G",
"autorestart": true,
"node_args": [],
"args": [],
"env": {
}
}]
}

+ 8624
- 0
pnpm-lock.yaml
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
파일 보기


+ 11
- 0
production.js 파일 보기

@@ -0,0 +1,11 @@
const path = require('path');
const Application = require('thinkjs');

const instance = new Application({
ROOT_PATH: __dirname,
APP_PATH: path.join(__dirname, 'src'),
proxy: true, // use proxy
env: 'production'
});

instance.run();

+ 26
- 0
restart.sh 파일 보기

@@ -0,0 +1,26 @@
#!/bin/bash
set -e

echo "===== 1. 拉取最新代码 ====="
git fetch --all
git reset --hard origin/master
echo "代码更新完成"

echo ""
echo "===== 2. 安装依赖 ====="
pnpm i
echo "依赖安装完成"

echo ""
echo "===== 3. 重启服务 ====="
if pm2 describe vk_web > /dev/null 2>&1; then
pm2 restart vk_web
echo "pm2 已重启 vk_web"
else
pm2 start pm2.json
echo "pm2 已启动 vk_web"
fi

echo ""
pm2 status
echo "===== 部署完成 ====="

+ 22
- 0
sql/content.sql 파일 보기

@@ -0,0 +1,22 @@
CREATE TABLE `content` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键',
`content_key` varchar(50) NOT NULL COMMENT '内容标识(唯一)',
`title` varchar(100) NOT NULL COMMENT '标题',
`remark` varchar(200) DEFAULT NULL COMMENT '备注',
`content` longtext COMMENT '富文本内容',
`status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '状态(1:启用 0:禁用)',
`sort` int NOT NULL DEFAULT '0' COMMENT '排序(数值越大越靠前)',
`create_by` int DEFAULT NULL COMMENT '创建人',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_by` int DEFAULT NULL COMMENT '修改人',
`update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
`is_deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否删除(0:否 1:是)',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_content_key` (`content_key`)
) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='内容管理表';

-- 初始化首页内容
INSERT INTO `content` (`content_key`, `title`, `content`, `remark`, `status`, `sort`) VALUES
('index_content', '肠愈同行患者关爱项目', '<p>您好!安心医患者关爱项目——肠愈同行患者关爱项目(下称"项目")是由北京维康慈善基金会发起(下称"基金会"),提供基因检测(基因检测列表包含但不限于:KRAS NRAS BRAF ERBB2 PIK3CA)帮助结直肠癌患者及时得到规范化、有效的治疗,减轻肿瘤患者因疾病诊断产生的家庭经济负担,从而帮助到更多中国结直肠癌患者获益。</p>', '首页项目介绍内容', 1, 0),
('privacy_policy', '隐私协议', '<p>隐私协议内容待补充</p>', '隐私协议', 1, 0),
('about_us', '关于我们', '<p>关于我们内容待补充</p>', '关于我们', 1, 0);

+ 148
- 0
sql/init.sql 파일 보기

@@ -0,0 +1,148 @@
-- cytx_api 初始化脚本
-- 数据库:pap_cytx
-- 需要的表:admin_user, admin_role, operation_log

CREATE DATABASE IF NOT EXISTS `pap_cytx` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
USE `pap_cytx`;

SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

-- ========================================
-- 管理员用户表
-- ========================================
CREATE TABLE IF NOT EXISTS `admin_user` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`username` varchar(50) NOT NULL COMMENT '用户名',
`password` varchar(64) NOT NULL COMMENT '密码(MD5)',
`nickname` varchar(50) DEFAULT '' COMMENT '昵称',
`avatar` varchar(255) DEFAULT '' COMMENT '头像',
`email` varchar(100) DEFAULT '' COMMENT '邮箱',
`phone` varchar(20) DEFAULT '' COMMENT '手机号',
`role_id` int unsigned DEFAULT 0 COMMENT '角色ID',
`last_login_time` datetime DEFAULT NULL COMMENT '最后登录时间',
`last_login_ip` varchar(50) DEFAULT '' COMMENT '最后登录IP',
`login_fail_count` int DEFAULT 0 COMMENT '当日登录失败次数',
`login_fail_date` date DEFAULT NULL COMMENT '最后登录失败日期',
`status` tinyint(1) DEFAULT 1 COMMENT '状态: 1启用 0停用',
`is_deleted` tinyint(1) DEFAULT 0 COMMENT '软删除: 0正常 1已删除',
`create_by` int unsigned DEFAULT 0 COMMENT '创建人ID',
`update_by` int unsigned DEFAULT 0 COMMENT '修改人ID',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_username` (`username`),
KEY `idx_role_id` (`role_id`),
KEY `idx_status` (`status`),
KEY `idx_is_deleted` (`is_deleted`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='管理员用户表';

-- ========================================
-- 角色表
-- ========================================
CREATE TABLE IF NOT EXISTS `admin_role` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(50) NOT NULL COMMENT '角色名称',
`code` varchar(50) DEFAULT '' COMMENT '角色编码',
`description` varchar(200) DEFAULT '' COMMENT '角色描述',
`permissions` json DEFAULT NULL COMMENT '权限列表(JSON)',
`is_default` tinyint(1) DEFAULT 0 COMMENT '是否默认角色: 0否 1是',
`sort` int DEFAULT 0 COMMENT '排序',
`status` tinyint(1) DEFAULT 1 COMMENT '状态: 1启用 0停用',
`is_deleted` tinyint(1) DEFAULT 0 COMMENT '软删除: 0正常 1已删除',
`create_by` int unsigned DEFAULT 0 COMMENT '创建人ID',
`update_by` int unsigned DEFAULT 0 COMMENT '修改人ID',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_status` (`status`),
KEY `idx_is_deleted` (`is_deleted`),
KEY `idx_sort` (`sort`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色表';

-- ========================================
-- 操作日志表
-- ========================================
CREATE TABLE IF NOT EXISTS `operation_log` (
`id` int NOT NULL AUTO_INCREMENT,
`user_id` int DEFAULT 0 COMMENT '操作人ID',
`username` varchar(50) DEFAULT '' COMMENT '操作人用户名',
`action` varchar(20) DEFAULT '' COMMENT '操作类型: login/logout/add/edit/delete/export',
`module` varchar(50) DEFAULT '' COMMENT '操作模块',
`description` varchar(500) DEFAULT '' COMMENT '操作描述',
`ip` varchar(50) DEFAULT '' COMMENT 'IP地址',
`user_agent` varchar(500) DEFAULT '' COMMENT '浏览器UA',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_user` (`user_id`),
KEY `idx_action` (`action`),
KEY `idx_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='操作日志';

-- ========================================
-- 患者表
-- ========================================
CREATE TABLE IF NOT EXISTS `patient` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`patient_no` varchar(30) NOT NULL COMMENT '患者编号(YYYYMM+雪花ID)',
`name` varchar(50) NOT NULL COMMENT '姓名',
`phone` varchar(20) NOT NULL COMMENT '手机号',
`id_card` varchar(18) NOT NULL COMMENT '身份证号',
`gender` varchar(4) DEFAULT '' COMMENT '性别',
`birth_date` date DEFAULT NULL COMMENT '出生日期',
`province_code` varchar(6) DEFAULT '' COMMENT '省编码',
`city_code` varchar(6) DEFAULT '' COMMENT '市编码',
`district_code` varchar(6) DEFAULT '' COMMENT '区编码',
`address` varchar(255) DEFAULT '' COMMENT '详细地址',
`tag` varchar(50) DEFAULT '' COMMENT '标识(如直肠癌)',
`documents` json DEFAULT NULL COMMENT '上传资料(检查报告等)JSON数组',
`sign_income` varchar(255) DEFAULT '' COMMENT '个人可支配收入声明文件URL',
`sign_privacy` varchar(255) DEFAULT '' COMMENT '个人信息处理同意书文件URL',
`sign_promise` varchar(255) DEFAULT '' COMMENT '声明与承诺文件URL',
`status` tinyint(1) DEFAULT 0 COMMENT '状态: 0待审核 1审核通过 2已驳回',
`is_deleted` tinyint(1) DEFAULT 0 COMMENT '软删除: 0正常 1已删除',
`create_by` int unsigned DEFAULT 0 COMMENT '创建人ID',
`update_by` int unsigned DEFAULT 0 COMMENT '修改人ID',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_patient_no` (`patient_no`),
KEY `idx_status` (`status`),
KEY `idx_is_deleted` (`is_deleted`),
KEY `idx_phone` (`phone`),
KEY `idx_id_card` (`id_card`),
KEY `idx_tag` (`tag`),
KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='患者表';

-- ========================================
-- 患者审核记录表
-- ========================================
CREATE TABLE IF NOT EXISTS `patient_audit` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`patient_id` int unsigned NOT NULL COMMENT '患者ID',
`action` varchar(20) NOT NULL COMMENT '操作: submit/approve/reject',
`reason` varchar(500) DEFAULT '' COMMENT '驳回原因',
`operator_id` int unsigned DEFAULT 0 COMMENT '操作人ID',
`operator_name` varchar(50) DEFAULT '' COMMENT '操作人姓名',
`is_deleted` tinyint(1) DEFAULT 0 COMMENT '软删除: 0正常 1已删除',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '操作时间',
PRIMARY KEY (`id`),
KEY `idx_patient_id` (`patient_id`),
KEY `idx_action` (`action`),
KEY `idx_is_deleted` (`is_deleted`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='患者审核记录表';

-- ========================================
-- 初始化数据
-- ========================================

-- 默认角色
INSERT INTO `admin_role` (`id`, `name`, `code`, `description`, `permissions`, `is_default`, `sort`) VALUES
(1, '超级管理员', 'ADMIN', '拥有所有权限', '["*"]', 1, 1);

-- 默认管理员 (密码: Admin123)
INSERT INTO `admin_user` (`username`, `password`, `nickname`, `role_id`) VALUES
('admin', '0192023a7bbd73250516f069df18b500', '超级管理员', 1);

SET FOREIGN_KEY_CHECKS = 1;

+ 13
- 0
sql/message.sql 파일 보기

@@ -0,0 +1,13 @@
-- 消息表
CREATE TABLE `message` (
`id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`patient_id` INT UNSIGNED NOT NULL COMMENT '患者ID',
`type` TINYINT NOT NULL DEFAULT 0 COMMENT '类型:1=审核通过 2=审核拒绝',
`title` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '标题',
`content` TEXT COMMENT '详细内容',
`reason` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '驳回原因',
`is_read` TINYINT NOT NULL DEFAULT 0 COMMENT '0=未读 1=已读',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
KEY `idx_patient_id` (`patient_id`),
KEY `idx_is_read` (`patient_id`, `is_read`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='消息通知';

+ 9
- 0
sql/myinfo.sql 파일 보기

@@ -0,0 +1,9 @@
-- 我的资料相关表结构变更
-- 数据库:pap_cytx

USE `pap_cytx`;

-- patient 表新增应急联系人字段
ALTER TABLE `patient`
ADD COLUMN `emergency_contact` varchar(50) DEFAULT '' COMMENT '应急联系人姓名' AFTER `address`,
ADD COLUMN `emergency_phone` varchar(20) DEFAULT '' COMMENT '应急联系人电话' AFTER `emergency_contact`;

+ 9
- 0
sql/sign.sql 파일 보기

@@ -0,0 +1,9 @@
-- 签署功能:patient 表新增金额字段
ALTER TABLE `patient`
ADD COLUMN `income_amount` decimal(10,2) DEFAULT NULL COMMENT '月可支配收入金额' AFTER `sign_promise`;

-- content 表插入3条协议内容(content_key 唯一,如已存在请跳过)
INSERT IGNORE INTO `content` (`content_key`, `title`, `remark`, `content`, `status`, `sort`, `is_deleted`, `create_time`) VALUES
('sign_income', '个人可支配收入声明', '签署协议-收入声明', '<p>本人在此声明,本人目前的个人可支配收入情况如下。本人承诺以下信息真实、准确、完整,如有虚假,愿承担相应法律责任。</p><p>本人理解,该声明将用于评估本人参与患者援助项目的资格,平台有权对所提供的信息进行核实。</p>', 1, 0, 0, NOW()),
('sign_privacy', '个人信息处理同意书', '签署协议-隐私同意书', '<p>为了向您提供患者援助服务,我们需要收集和处理您的个人信息。根据《中华人民共和国个人信息保护法》等相关法律法规,特向您告知以下事项:</p><p>一、我们将收集您的姓名、身份证号、联系方式、医疗诊断信息等个人信息,用于患者身份核实、援助资格评估及药品配送等服务。</p><p>二、您的个人信息将被严格保密,仅用于本项目相关服务,未经您的同意不会向第三方披露,法律法规另有规定的除外。</p><p>三、您有权随时查阅、更正或删除您的个人信息,也有权撤回本同意书。撤回同意不影响撤回前基于同意所进行的个人信息处理活动的合法性。</p><p>四、我们将采取必要的安全技术措施保护您的个人信息安全,防止信息泄露、篡改或丢失。</p>', 1, 0, 0, NOW()),
('sign_promise', '声明与承诺', '签署协议-声明承诺', '<p>本人在此郑重声明并承诺如下:</p><p>一、本人提交的所有资料和信息均真实、准确、完整,不存在任何虚假、遗漏或误导性内容。</p><p>二、本人承诺所申请的援助药品仅供本人使用,不会转让、转售或以其他方式提供给他人。</p><p>三、本人理解并同意,如提供虚假信息或违反上述承诺,项目方有权取消本人的援助资格,并追究相应法律责任。</p><p>四、本人自愿参与本患者援助项目,并已充分了解项目的相关规则和要求。</p>', 1, 0, 0, NOW());

+ 15
- 0
sql/subscribe.sql 파일 보기

@@ -0,0 +1,15 @@
-- 订阅消息模板配置
-- 数据库:pap_cytx

USE `pap_cytx`;

-- sys_wechat_app 新增订阅消息模板字段
ALTER TABLE `sys_wechat_app`
ADD COLUMN `subscribe_templates` text DEFAULT NULL COMMENT '订阅消息模板配置JSON' AFTER `token_expires_at`;

-- wechat_user 新增小程序版本字段(用于订阅消息推送到对应版本)
ALTER TABLE `wechat_user`
ADD COLUMN `mp_env_version` varchar(20) DEFAULT 'release' COMMENT '小程序版本: develop/trial/release' AFTER `patient_id`;

-- 更新 pap_mini_cytx 应用的模板配置
UPDATE `sys_wechat_app` SET `subscribe_templates` = '{"audit_result":"OcMLIGS8pMnpGzZmPkdMEOmmWmvvvU_nN-kmiK5sUGE"}' WHERE `remark` = 'pap_mini_cytx';

+ 40
- 0
sql/verify.sql 파일 보기

@@ -0,0 +1,40 @@
-- 实名认证相关表结构变更
-- 数据库:pap_cytx

USE `pap_cytx`;

-- ========================================
-- patient 表新增实名认证字段
-- ========================================
ALTER TABLE `patient`
ADD COLUMN `auth_status` tinyint(1) DEFAULT 0 COMMENT '认证状态: 0未认证 1已认证' AFTER `status`,
ADD COLUMN `id_card_type` tinyint DEFAULT 1 COMMENT '证件类型: 1身份证 2无证件儿童 3临时身份证' AFTER `id_card`,
ADD COLUMN `id_card_front` varchar(500) DEFAULT '' COMMENT '证件正面照URL' AFTER `id_card_type`,
ADD COLUMN `id_card_back` varchar(500) DEFAULT '' COMMENT '证件反面照URL' AFTER `id_card_front`,
ADD COLUMN `photo` varchar(500) DEFAULT '' COMMENT '免冠照片URL(无证件儿童)' AFTER `id_card_back`,
ADD COLUMN `issuing_authority` varchar(100) DEFAULT '' COMMENT '发证机关' AFTER `birth_date`,
ADD COLUMN `valid_period` varchar(50) DEFAULT '' COMMENT '有效期限' AFTER `issuing_authority`,
ADD COLUMN `auth_time` datetime DEFAULT NULL COMMENT '认证时间' AFTER `auth_status`;

-- ========================================
-- 短信日志表
-- ========================================
CREATE TABLE IF NOT EXISTS `sms_log` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`mobile` varchar(20) NOT NULL COMMENT '手机号',
`template_id` varchar(50) DEFAULT '' COMMENT '模板ID',
`template_params` varchar(500) DEFAULT '' COMMENT '模板参数JSON',
`sms_type` tinyint DEFAULT 1 COMMENT '类型: 1验证码',
`biz_type` varchar(50) DEFAULT '' COMMENT '业务类型: real_name_auth/bind_mobile',
`code` varchar(10) DEFAULT '' COMMENT '验证码',
`expire_time` datetime DEFAULT NULL COMMENT '过期时间',
`status` tinyint DEFAULT 0 COMMENT '状态: 0待发送 1已发送 2发送失败',
`fail_reason` varchar(500) DEFAULT NULL COMMENT '失败原因',
`provider_msg_id` varchar(100) DEFAULT NULL COMMENT '运营商消息ID',
`ip` varchar(50) DEFAULT '' COMMENT '请求IP',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_mobile` (`mobile`),
KEY `idx_biz_type` (`biz_type`),
KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='短信日志表';

+ 18
- 0
sql/wechat_user.sql 파일 보기

@@ -0,0 +1,18 @@
-- 微信用户表
CREATE TABLE IF NOT EXISTS `wechat_user` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`open_id` varchar(64) NOT NULL COMMENT '微信openid',
`union_id` varchar(64) DEFAULT NULL COMMENT '微信unionid',
`app_remark` varchar(50) NOT NULL COMMENT '应用标识(对应sys_wechat_app.remark)',
`nickname` varchar(100) DEFAULT '' COMMENT '昵称',
`avatar` varchar(500) DEFAULT '' COMMENT '头像URL',
`phone` varchar(20) DEFAULT '' COMMENT '手机号(微信授权)',
`patient_id` int unsigned DEFAULT NULL COMMENT '关联患者ID',
`status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '状态: 1启用 0停用',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_openid_app` (`open_id`, `app_remark`),
KEY `idx_patient_id` (`patient_id`),
KEY `idx_phone` (`phone`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='微信用户表';

+ 1
- 0
src/bootstrap/master.js 파일 보기

@@ -0,0 +1 @@
// invoked in master

+ 1
- 0
src/bootstrap/worker.js 파일 보기

@@ -0,0 +1 @@
// invoked in worker

+ 112
- 0
src/config/adapter.js 파일 보기

@@ -0,0 +1,112 @@
const fileCache = require('think-cache-file');
const nunjucks = require('think-view-nunjucks');
const fileSession = require('think-session-file');
const mysql = require('think-model-mysql');
const {Console, File, DateFile} = require('think-logger3');
const path = require('path');
const isDev = think.env === 'development';

/**
* cache adapter config
* @type {Object}
*/
exports.cache = {
type: 'file',
common: {
timeout: 24 * 60 * 60 * 1000 // millisecond
},
file: {
handle: fileCache,
cachePath: path.join(think.ROOT_PATH, 'runtime/cache'), // absoulte path is necessarily required
pathDepth: 1,
gcInterval: 24 * 60 * 60 * 1000 // gc interval
}
};

/**
* model adapter config
* @type {Object}
*/
exports.model = {
type: 'pap',
common: {
logConnect: isDev,
logSql: isDev,
logger: msg => think.logger.info(msg)
},
// pap 测试数据库
pap: {
handle: mysql,
database: 'pap_cytx',
prefix: '',
encoding: 'utf8mb4',
charset: 'UTF8MB4_UNICODE_CI',
host: 'sh-cynosdbmysql-grp-jjq5h0fk.sql.tencentcdb.com',
port: '26821',
user: 'root',
password: '5orKUdDN3QhESVcS',
dateStrings: true,
connectionLimit: 10
}
};

/**
* session adapter config
* @type {Object}
*/
exports.session = {
type: 'file',
common: {
cookie: {
name: 'thinkjs'
// keys: ['werwer', 'werwer'],
// signed: true
}
},
file: {
handle: fileSession,
sessionPath: path.join(think.ROOT_PATH, 'runtime/session')
}
};

/**
* view adapter config
* @type {Object}
*/
exports.view = {
type: 'nunjucks',
common: {
viewPath: path.join(think.ROOT_PATH, 'view'),
sep: '_',
extname: '.html'
},
nunjucks: {
handle: nunjucks
}
};

/**
* logger adapter config
* @type {Object}
*/
exports.logger = {
type: isDev ? 'console' : 'dateFile',
console: {
handle: Console
},
file: {
handle: File,
backups: 10, // max chunk number
absolute: true,
maxLogSize: 50 * 1024, // 50M
filename: path.join(think.ROOT_PATH, 'logs/app.log')
},
dateFile: {
handle: DateFile,
level: 'ALL',
absolute: true,
pattern: '-yyyy-MM-dd',
alwaysIncludePattern: true,
filename: path.join(think.ROOT_PATH, 'logs/app.log')
}
};

+ 46
- 0
src/config/adapter.production.js 파일 보기

@@ -0,0 +1,46 @@
const mysql = require('think-model-mysql');
const redisCache = require('think-cache-redis');

/**
* 生产环境 cache adapter config
* @type {Object}
*/
exports.cache = {
type: 'redis',
common: {
timeout: 24 * 60 * 60 * 1000
},
redis: {
handle: redisCache,
host: '127.0.0.1',
port: 6319,
password: '8cLgEgZSdWr57CTe',
timeout: 0,
log_connect: true
}
};

/**
* 生产环境 model adapter config
* @type {Object}
*/
exports.model = {
type: 'pap',
common: {
logConnect: false,
logSql: false,
logger: msg => think.logger.info(msg)
},
// pap 线上数据库
// pap: {
// handle: mysql,
// database: 'pap_web',
// prefix: '',
// encoding: 'utf8mb4',
// host: 'sh-cynosdbmysql-grp-jjq5h0fk.sql.tencentcdb.com',
// port: '26822',
// user: 'root',
// password: '5orKUdDN3QhESVcS',
// dateStrings: true
// }
};

+ 13
- 0
src/config/config.js 파일 보기

@@ -0,0 +1,13 @@
const path = require('path');

// default config
module.exports = {
workers: 1,
port: 8361,
version: '2026031822', // 静态资源版本号,发版时更新,用于缓存刷新
errnoField: 'code', // errno field
errmsgField: 'msg', // errmsg field
error: {
template_404: path.join(think.ROOT_PATH, 'view/error_404.html')
}
};

+ 4
- 0
src/config/config.production.js 파일 보기

@@ -0,0 +1,4 @@
// production config, it will load in production enviroment
module.exports = {
workers: 0
};

+ 39
- 0
src/config/cos.js 파일 보기

@@ -0,0 +1,39 @@
// 腾讯云 COS 配置
module.exports = {
// COS 配置
secretId: process.env.COS_SECRET_ID || 'AKIDcH6qOipkT0Lo9HJ8kEhaJrBML8f4GF1l',
secretKey: process.env.COS_SECRET_KEY || 'Yt1vc5LNCnBSdeS2RdbMvHOIyTkoeIjw',
bucket: process.env.COS_BUCKET || 'pap-1314297964',
region: process.env.COS_REGION || 'ap-shanghai',

// URL 配置
bucketUrl: 'https://pap-1314297964.cos.ap-shanghai.myqcloud.com',
cdnUrl: 'https://cdn.csybhelp.com',

// 文件配置
allowedImageTypes: ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', 'image/bmp', 'image/svg+xml'],
allowedDocTypes: [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'text/plain'
],
allowedVideoTypes: ['video/mp4', 'video/mpeg', 'video/quicktime', 'video/x-msvideo', 'video/x-ms-wmv'],

// 文件大小限制(字节)
maxImageSize: 10 * 1024 * 1024, // 10MB
maxDocSize: 50 * 1024 * 1024, // 50MB
maxVideoSize: 200 * 1024 * 1024, // 200MB

// 图片压缩配置
imageCompress: {
quality: 80, // 压缩质量 0-100
maxWidth: 1920, // 最大宽度
maxHeight: 1920, // 最大高度
minSize: 100 * 1024 // 最小压缩大小(字节),小于此值不压缩,默认 100KB
}
};

+ 11
- 0
src/config/extend.js 파일 보기

@@ -0,0 +1,11 @@
const view = require('think-view');
const model = require('think-model');
const cache = require('think-cache');
const session = require('think-session');

module.exports = [
view, // make application support view
model(think.app),
cache,
session
];

+ 10
- 0
src/config/faceid.js 파일 보기

@@ -0,0 +1,10 @@
// 腾讯云身份核验配置(密钥与COS共用)
const cosConfig = require('./cos.js');

module.exports = {
secretId: cosConfig.secretId,
secretKey: cosConfig.secretKey,
region: 'ap-guangzhou',
// 是否启用身份核验(开发环境可关闭,跳过核验)
enabled: false
};

+ 44
- 0
src/config/middleware.js 파일 보기

@@ -0,0 +1,44 @@
const path = require('path');
const isDev = think.env === 'development';

module.exports = [
{
handle: 'meta',
options: {
logRequest: isDev,
sendResponseTime: isDev
}
},
{
handle: 'resource',
enable: isDev,
options: {
root: path.join(think.ROOT_PATH, 'www'),
publicPath: /^\/(static|favicon\.ico)/
}
},
{
handle: 'trace',
enable: !think.isCli,
options: {
debug: isDev,
templates: isDev ? {} : {
404: path.join(think.ROOT_PATH, 'view/error_404.html'),
500: path.join(think.ROOT_PATH, 'view/error_500.html')
}
}
},
{
handle: 'payload',
options: {
keepExtensions: true,
limit: '5mb'
}
},
{
handle: 'router',
options: {}
},
'logic',
'controller'
];

+ 9
- 0
src/config/ocr.js 파일 보기

@@ -0,0 +1,9 @@
// 腾讯云 OCR 配置(密钥与COS共用)
const cosConfig = require('./cos.js');

module.exports = {
secretId: cosConfig.secretId,
secretKey: cosConfig.secretKey,
region: 'ap-guangzhou',
enabled: true
};

+ 87
- 0
src/config/router.js 파일 보기

@@ -0,0 +1,87 @@
module.exports = [
// 后台管理路由
['/admin/login', 'admin/auth/login', 'get'],
['/admin/auth/login', 'admin/auth/doLogin', 'post'],
['/admin/auth/captcha', 'admin/auth/captcha', 'get'],
['/admin/logout', 'admin/auth/logout'],

// 后台页面
['/admin/dashboard', 'admin/dashboard/index'],
['/admin/dashboard/stats', 'admin/dashboard/stats'],

// 系统管理 - 用户
['/admin/system/user', 'admin/system/user/index'],
['/admin/system/user/list', 'admin/system/user/list'],
['/admin/system/user/detail', 'admin/system/user/detail'],
['/admin/system/user/add', 'admin/system/user/add', 'post'],
['/admin/system/user/edit', 'admin/system/user/edit', 'post'],
['/admin/system/user/resetPassword', 'admin/system/user/resetPassword', 'post'],
['/admin/system/user/delete', 'admin/system/user/delete', 'post'],
['/admin/system/user/toggleStatus', 'admin/system/user/toggleStatus', 'post'],
['/admin/system/user/unlock', 'admin/system/user/unlock', 'post'],

// 系统管理 - 角色
['/admin/system/role', 'admin/system/role/index'],
['/admin/system/role/list', 'admin/system/role/list'],
['/admin/system/role/all', 'admin/system/role/all'],
['/admin/system/role/detail', 'admin/system/role/detail'],
['/admin/system/role/add', 'admin/system/role/add', 'post'],
['/admin/system/role/edit', 'admin/system/role/edit', 'post'],
['/admin/system/role/delete', 'admin/system/role/delete', 'post'],
['/admin/system/role/batchDelete', 'admin/system/role/batchDelete', 'post'],
['/admin/system/role/assignPermissions', 'admin/system/role/assignPermissions', 'post'],

// 系统管理 - 操作日志
['/admin/system/log', 'admin/system/log/index'],
['/admin/system/log/list', 'admin/system/log/list'],
['/admin/system/log/clear', 'admin/system/log/clear', 'post'],

// 系统管理 - 短信记录
['/admin/system/sms', 'admin/system/sms/index'],
['/admin/system/sms/list', 'admin/system/sms/list'],

// 患者管理
['/admin/patient', 'admin/patient/index'],
['/admin/patient/list', 'admin/patient/list'],
['/admin/patient/detail', 'admin/patient/detail'],
['/admin/patient/info', 'admin/patient/info'],
['/admin/patient/add', 'admin/patient/add', 'post'],
['/admin/patient/edit', 'admin/patient/edit', 'post'],
['/admin/patient/approve', 'admin/patient/approve', 'post'],
['/admin/patient/reject', 'admin/patient/reject', 'post'],
['/admin/patient/export', 'admin/patient/export'],
// 公共接口
['/common/regions', 'common/regions'],
['/common/ocr/idcard', 'common/ocrIdcard', 'post'],
['/common/ocr/hmt', 'common/ocrHmt', 'post'],
['/api/content', 'common/content'],

// 小程序接口
['/api/mp/login', 'mp/login', 'post'],
['/api/mp/userinfo', 'mp/userinfo'],
['/api/mp/upload', 'mp/upload', 'post'],
['/api/mp/sendSmsCode', 'mp/sendSmsCode', 'post'],
['/api/mp/authInfo', 'mp/authInfo'],
['/api/mp/authSubmit', 'mp/authSubmit', 'post'],
['/api/mp/changePhone', 'mp/changePhone', 'post'],
['/api/mp/myInfo', 'mp/myInfo'],
['/api/mp/saveMyInfo', 'mp/saveMyInfo', 'post'],
['/api/mp/sign', 'mp/sign', 'post'],
['/api/mp/updateAvatar', 'mp/updateAvatar', 'post'],
['/api/mp/messages', 'mp/messages'],
['/api/mp/messageDetail', 'mp/messageDetail'],
['/api/mp/unreadCount', 'mp/unreadCount'],
['/api/mp/subscribeConfig', 'mp/subscribeConfig'],

// 内容管理
['/admin/content', 'admin/content/index'],
['/admin/content/list', 'admin/content/list'],
['/admin/content/detail', 'admin/content/detail'],
['/admin/content/add', 'admin/content/add', 'post'],
['/admin/content/edit', 'admin/content/edit', 'post'],
['/admin/content/delete', 'admin/content/delete', 'post'],

// 文件上传
['/admin/upload', 'admin/upload/index', 'post'],
['/admin/upload/config', 'admin/upload/config'],
];

+ 34
- 0
src/config/sms.js 파일 보기

@@ -0,0 +1,34 @@
// 腾讯云短信配置(密钥与COS共用)
const cosConfig = require('./cos.js');

module.exports = {
secretId: cosConfig.secretId,
secretKey: cosConfig.secretKey,

// 短信应用ID(腾讯云短信控制台获取)
smsSdkAppId: '1401080111',

// 短信签名
signName: '上海成善创研',

// 地域
region: 'ap-guangzhou',

// 模板ID
templates: {
code: '2614695', // 验证码:{1}验证码, {2}分钟
auditApproved: '2614698', // 审核通过(无变量)
auditRejected: '2614700' // 审核失败:{1}驳回原因
},

// 验证码配置
code: {
length: 6,
expireMinutes: 5,
intervalSeconds: 60,
dailyLimit: 10
},

// 是否启用短信发送(关闭时仅打印日志不真实发送)
enabled: true
};

+ 127
- 0
src/controller/admin/auth.js 파일 보기

@@ -0,0 +1,127 @@
const Base = require('../base');
const svgCaptcha = require('svg-captcha');

module.exports = class extends Base {
// 登录页面
async loginAction() {
// 已登录状态访问登录页,自动退出
if (this.cookie('admin_token')) {
this.cookie('admin_token', null);
}
this.assign('siteConfig', {});
this.assign('now_year', new Date().getFullYear());
return this.display();
}

// 图形验证码
async captchaAction() {
const captcha = svgCaptcha.create({
size: 4,
ignoreChars: '0oO1lIi',
noise: 3,
color: true,
background: '#f0f2f5',
width: 120,
height: 40
});
// 存入session
await this.session('captcha', captcha.text.toLowerCase());
this.ctx.type = 'image/svg+xml';
this.ctx.body = captcha.data;
}

// 登录接口
async doLoginAction() {
const { username, password, remember, captcha } = this.post();

if (!username || !password) {
return this.fail('请输入用户名和密码');
}

// 验证码校验
if (!captcha) {
return this.fail('请输入验证码');
}
const sessionCaptcha = await this.session('captcha');
// 用完即清
await this.session('captcha', null);
if (!sessionCaptcha || captcha.toLowerCase() !== sessionCaptcha) {
return this.fail('验证码错误');
}

// 查找用户(不限status,锁定判断在后面)
const user = await this.model('admin_user')
.where({ username, is_deleted: 0 })
.find();

if (think.isEmpty(user)) {
return this.fail('用户名或密码错误');
}

// 账号停用
if (user.status !== 1) {
return this.fail('账号已被禁用,请联系管理员');
}

// 锁定判断:当日错误>=5次
const today = new Date().toISOString().slice(0, 10);
const failDate = user.login_fail_date ? new Date(user.login_fail_date).toISOString().slice(0, 10) : null;
const failCount = (failDate === today) ? (user.login_fail_count || 0) : 0;

if (failCount >= 5) {
return this.fail('密码错误次数过多,账号已锁定,请联系管理员或次日自动解锁');
}

// 密码验证
if (user.password !== think.md5(password)) {
const newCount = failCount + 1;
await this.model('admin_user').where({ id: user.id }).update({
login_fail_count: newCount,
login_fail_date: today
});
const remain = 5 - newCount;
if (remain <= 0) {
return this.fail('密码错误次数过多,账号已锁定,请联系管理员或次日自动解锁');
}
return this.fail(`密码错误,还剩${remain}次机会`);
}

// 登录成功,清除错误计数,更新登录信息
await this.model('admin_user').where({ id: user.id }).update({
login_fail_count: 0,
login_fail_date: null,
last_login_time: think.datetime(),
last_login_ip: this.ctx.ip || ''
});

// 生成JWT Token
const token = Base.generateToken({
id: user.id,
username: user.username,
role_id: user.role_id
});

const cookieOpts = {
httpOnly: true,
path: '/'
};
if (remember) {
cookieOpts.maxAge = Base.JWT_EXPIRES_IN * 1000;
}
this.cookie('admin_token', token, cookieOpts);

this.adminUser = { id: user.id, username: user.username };
await this.log('login', '系统', `${user.username} 登录系统`);

return this.success({ token });
}

// 退出登录
async logoutAction() {
if (this.adminUser) {
await this.log('logout', '系统', `${this.adminUser.username} 退出系统`);
}
this.cookie('admin_token', null);
return this.redirect('/admin/login.html');
}
};

+ 94
- 0
src/controller/admin/content.js 파일 보기

@@ -0,0 +1,94 @@
const Base = require('../base');

module.exports = class extends Base {
// 内容管理页面
async indexAction() {
this.assign('currentPage', 'content');
this.assign('pageTitle', '内容管理');
this.assign('breadcrumb', [{ name: '内容管理' }]);
this.assign('adminUser', this.adminUser || {});
this.assign('canAdd', this.isSuperAdmin || (this.userPermissions || []).includes('content:add'));
this.assign('canEdit', this.isSuperAdmin || (this.userPermissions || []).includes('content:edit'));
this.assign('canDelete', this.isSuperAdmin || (this.userPermissions || []).includes('content:delete'));
return this.display();
}

// 列表接口
async listAction() {
const { keyword, status, page = 1, pageSize = 10 } = this.get();
const list = await this.model('content').getList({ keyword, status, page, pageSize });
return this.success(list);
}

// 详情接口
async detailAction() {
const { id } = this.get();
if (!id) return this.fail('参数错误');
const item = await this.model('content').where({ id, is_deleted: 0 }).find();
if (think.isEmpty(item)) return this.fail('内容不存在');
return this.success(item);
}

// 新增
async addAction() {
const { content_key, title, remark, content, status = 1, sort = 0 } = this.post();
if (!content_key || !title) return this.fail('内容标识和标题不能为空');

// 检查 key 唯一
const exist = await this.model('content')
.where({ content_key, is_deleted: 0 })
.find();
if (!think.isEmpty(exist)) return this.fail('内容标识已存在');

const id = await this.model('content').add({
content_key, title,
remark: remark || '',
content: content || '',
status: status ? 1 : 0,
sort: sort || 0,
create_by: this.adminUser?.id || 0
});

await this.log('add', '内容管理', `新增内容「${title}」(key:${content_key})`);
return this.success({ id });
}

// 编辑(content_key 不可修改)
async editAction() {
const { id, title, remark, content, status, sort } = this.post();
if (!id) return this.fail('参数错误');
if (!title) return this.fail('标题不能为空');

const item = await this.model('content').where({ id, is_deleted: 0 }).find();
if (think.isEmpty(item)) return this.fail('内容不存在');

await this.model('content').where({ id }).update({
title,
remark: remark !== undefined ? remark : item.remark,
content: content !== undefined ? content : item.content,
status: status !== undefined ? (status ? 1 : 0) : item.status,
sort: sort !== undefined ? sort : item.sort,
update_by: this.adminUser?.id || 0
});

await this.log('edit', '内容管理', `编辑内容「${title}」(ID:${id})`);
return this.success();
}

// 删除
async deleteAction() {
const { id } = this.post();
if (!id) return this.fail('参数错误');

const item = await this.model('content').where({ id, is_deleted: 0 }).find();
if (think.isEmpty(item)) return this.fail('内容不存在');

await this.model('content').where({ id }).update({
is_deleted: 1,
update_by: this.adminUser?.id || 0
});

await this.log('delete', '内容管理', `删除内容「${item.title}」(ID:${id})`);
return this.success();
}
};

+ 33
- 0
src/controller/admin/dashboard.js 파일 보기

@@ -0,0 +1,33 @@
const Base = require('../base');

module.exports = class extends Base {
async indexAction() {
this.assign('currentPage', 'dashboard');
this.assign('pageTitle', '控制台');
this.assign('adminUser', this.adminUser || {});
this.assign('canView', this.isSuperAdmin || (this.userPermissions || []).includes('patient:view'));
return this.display();
}

// 控制台统计数据接口
async statsAction() {
const model = this.model('patient');
const [counts, todayCounts, trend, recent] = await Promise.all([
model.getStatusCounts(),
model.getTodayCounts(),
model.getDailyTrend(30),
model.getRecentList(10)
]);

return this.success({
counts,
todayCounts,
trend: trend.map(r => ({
date: r.date,
total: parseInt(r.total, 10) || 0,
rectal: parseInt(r.rectal, 10) || 0
})),
recent
});
}
};

+ 429
- 0
src/controller/admin/patient.js 파일 보기

@@ -0,0 +1,429 @@
const Base = require('../base.js');

module.exports = class extends Base {
// 患者列表页面
async indexAction() {
this.assign('currentPage', 'patient');
this.assign('pageTitle', '患者管理');
this.assign('breadcrumb', [{ name: '患者管理' }]);
this.assign('adminUser', this.adminUser || {});
this.assign('canAdd', this.isSuperAdmin || (this.userPermissions || []).includes('patient:add'));
this.assign('canEdit', this.isSuperAdmin || (this.userPermissions || []).includes('patient:edit'));
this.assign('canExport', this.isSuperAdmin || (this.userPermissions || []).includes('patient:export'));
this.assign('canAudit', this.isSuperAdmin || (this.userPermissions || []).includes('patient:audit'));
this.assign('canView', this.isSuperAdmin || (this.userPermissions || []).includes('patient:view'));
return this.display();
}

// 获取患者列表接口
async listAction() {
const { keyword, tag, status, startDate, endDate, province_code, city_code, district_code, page = 1, pageSize = 10 } = this.get();
const model = this.model('patient');
const list = await model.getList({ keyword, tag, status, startDate, endDate, province_code, city_code, district_code, page, pageSize });

// 收集所有省市区 code 批量查询名称
const allCodes = new Set();
list.data.forEach(item => {
if (item.province_code) allCodes.add(item.province_code);
if (item.city_code) allCodes.add(item.city_code);
if (item.district_code) allCodes.add(item.district_code);
});
const regionMap = {};
if (allCodes.size) {
const regions = await this.model('sys_region')
.where({ code: ['in', [...allCodes]] })
.select();
regions.forEach(r => { regionMap[r.code] = r.name; });
}

// 脱敏 + 拼接地区
list.data.forEach(item => {
if (item.id_card && item.id_card.length === 18) {
item.id_card_mask = item.id_card.slice(0, 3) + '***********' + item.id_card.slice(-4);
} else {
item.id_card_mask = item.id_card || '';
}
if (item.phone && item.phone.length === 11) {
item.phone_mask = item.phone.slice(0, 3) + '****' + item.phone.slice(-4);
} else {
item.phone_mask = item.phone || '';
}
const pName = regionMap[item.province_code] || '';
const cName = regionMap[item.city_code] || '';
const dName = regionMap[item.district_code] || '';
item.region_name = [pName, cName, dName].filter(Boolean).join(' ');
});

const counts = await model.getStatusCounts();
return this.success({ ...list, counts });
}

// 患者详情页面
async detailAction() {
const { id } = this.get();
if (!id) return this.redirect('/admin/patient.html');

this.assign('currentPage', 'patient');
this.assign('pageTitle', '患者详情');
this.assign('breadcrumb', [
{ name: '患者管理', url: '/admin/patient.html' },
{ name: '患者详情' }
]);
this.assign('patientId', id);
this.assign('adminUser', this.adminUser || {});
this.assign('canAudit', this.isSuperAdmin || (this.userPermissions || []).includes('patient:audit'));
return this.display();
}

// 获取患者详情接口(不脱敏)
async infoAction() {
const { id } = this.get();
if (!id) return this.fail('参数错误');

const patient = await this.model('patient')
.where({ id, is_deleted: 0 })
.find();

if (think.isEmpty(patient)) {
return this.fail('患者不存在');
}

// 解析 JSON 字段
try {
patient.documents = JSON.parse(patient.documents || '[]');
} catch (e) {
patient.documents = [];
}

// 查询省市区名称
if (patient.province_code || patient.city_code || patient.district_code) {
const codes = [patient.province_code, patient.city_code, patient.district_code].filter(Boolean);
if (codes.length) {
const regions = await this.model('sys_region')
.where({ code: ['in', codes] })
.select();
const regionMap = {};
regions.forEach(r => { regionMap[r.code] = r.name; });
patient.province_name = regionMap[patient.province_code] || '';
patient.city_name = regionMap[patient.city_code] || '';
patient.district_name = regionMap[patient.district_code] || '';
}
}

// 获取审核记录
const audits = await this.model('patient_audit')
.where({ patient_id: id })
.order('id DESC')
.select();

return this.success({ patient, audits });
}

// 新增患者
async addAction() {
const data = this.post();
const { name, phone, id_card, gender, birth_date, province_code, city_code, district_code, address, emergency_contact, emergency_phone, tag, documents, sign_income, sign_privacy, sign_promise } = data;

if (!name || !phone || !id_card || !gender || !birth_date) {
return this.fail('请填写完整信息');
}

if (!province_code || !city_code || !district_code) {
return this.fail('请选择省市区');
}

if (!address) {
return this.fail('请填写详细地址');
}

if (!/^1\d{10}$/.test(phone)) {
return this.fail('手机号格式不正确');
}

if (!/^\d{17}[\dXx]$/.test(id_card)) {
return this.fail('身份证号格式不正确');
}

const model = this.model('patient');
const patientNo = model.generatePatientNo();

const id = await model.add({
patient_no: patientNo,
name,
phone,
id_card,
gender,
birth_date,
province_code: province_code || '',
city_code: city_code || '',
district_code: district_code || '',
address: address || '',
emergency_contact: emergency_contact || '',
emergency_phone: emergency_phone || '',
tag: tag || '',
documents: JSON.stringify(documents || []),
sign_income: sign_income || '',
sign_privacy: sign_privacy || '',
sign_promise: sign_promise || '',
status: 0,
create_by: this.adminUser?.id || 0
});

// 记录审核日志
await this.model('patient_audit').add({
patient_id: id,
action: 'submit',
operator_id: this.adminUser?.id || 0,
operator_name: this.adminUser?.nickname || this.adminUser?.username || ''
});

await this.log('add', '患者管理', `新增患者「${name}」编号:${patientNo}`);
return this.success({ id, patient_no: patientNo });
}

// 审核通过
async approveAction() {
const { id } = this.post();
if (!id) return this.fail('参数错误');

const patient = await this.model('patient')
.where({ id, is_deleted: 0 })
.find();

if (think.isEmpty(patient)) return this.fail('患者不存在');
if (patient.status === 1) return this.fail('该患者已审核通过');

await this.model('patient').where({ id }).update({
status: 1,
update_by: this.adminUser?.id || 0
});

await this.model('patient_audit').add({
patient_id: id,
action: 'approve',
operator_id: this.adminUser?.id || 0,
operator_name: this.adminUser?.nickname || this.adminUser?.username || ''
});

// 发送消息通知
await this.model('message').add({
patient_id: id,
type: 1,
title: '审核通过',
content: '您提交的个人资料已审核通过。',
reason: ''
});

// 发送订阅消息
await this._sendAuditSubscribeMessage(id, patient.name, '审核通过');

// 发送短信通知
await this._sendAuditSms(patient.phone, 'approved');

await this.log('edit', '患者管理', `审核通过患者(ID:${id})`);
return this.success();
}

// 驳回
async rejectAction() {
const { id, reason } = this.post();
if (!id) return this.fail('参数错误');
if (!reason) return this.fail('请填写驳回原因');

const patient = await this.model('patient')
.where({ id, is_deleted: 0 })
.find();

if (think.isEmpty(patient)) return this.fail('患者不存在');
if (patient.status === 2) return this.fail('该患者已被驳回');

await this.model('patient').where({ id }).update({
status: 2,
update_by: this.adminUser?.id || 0
});

await this.model('patient_audit').add({
patient_id: id,
action: 'reject',
reason,
operator_id: this.adminUser?.id || 0,
operator_name: this.adminUser?.nickname || this.adminUser?.username || ''
});

// 发送消息通知
await this.model('message').add({
patient_id: id,
type: 2,
title: '审核未通过',
content: '您提交的个人资料未通过审核,请根据以下原因修改后重新提交。',
reason: reason
});

// 发送订阅消息
await this._sendAuditSubscribeMessage(id, patient.name, '审核驳回');

// 发送短信通知
await this._sendAuditSms(patient.phone, 'rejected', reason);

await this.log('edit', '患者管理', `驳回患者(ID:${id}),原因:${reason}`);
return this.success();
}

// 编辑患者
async editAction() {
const data = this.post();
const { id, name, phone, id_card, gender, birth_date, province_code, city_code, district_code, address, emergency_contact, emergency_phone, tag, documents, sign_income, sign_privacy, sign_promise } = data;

if (!id) return this.fail('参数错误');
if (!name || !phone || !id_card || !gender || !birth_date) return this.fail('请填写完整信息');
if (!province_code || !city_code || !district_code) return this.fail('请选择省市区');
if (!address) return this.fail('请填写详细地址');
if (!/^1\d{10}$/.test(phone)) return this.fail('手机号格式不正确');
if (!/^\d{17}[\dXx]$/.test(id_card)) return this.fail('身份证号格式不正确');

const patient = await this.model('patient').where({ id, is_deleted: 0 }).find();
if (think.isEmpty(patient)) return this.fail('患者不存在');

await this.model('patient').where({ id }).update({
name, phone, id_card, gender, birth_date,
province_code, city_code, district_code,
address: address || '',
emergency_contact: emergency_contact || '',
emergency_phone: emergency_phone || '',
tag: tag || '',
documents: JSON.stringify(documents || []),
sign_income: sign_income || '',
sign_privacy: sign_privacy || '',
sign_promise: sign_promise || '',
update_by: this.adminUser?.id || 0
});

await this.log('edit', '患者管理', `编辑患者「${name}」(ID:${id})`);
return this.success();
}

// 导出患者数据(CSV,不脱敏)
async exportAction() {
const { keyword, tag, status, startDate, endDate, province_code, city_code, district_code } = this.get();
const model = this.model('patient');
const list = await model.getAll({ keyword, tag, status, startDate, endDate, province_code, city_code, district_code });

// 批量查省市区名称
const allCodes = new Set();
list.forEach(item => {
if (item.province_code) allCodes.add(item.province_code);
if (item.city_code) allCodes.add(item.city_code);
if (item.district_code) allCodes.add(item.district_code);
});
const regionMap = {};
if (allCodes.size) {
const regions = await this.model('sys_region')
.where({ code: ['in', [...allCodes]] })
.select();
regions.forEach(r => { regionMap[r.code] = r.name; });
}

// 批量查最近一条审核记录(审核日期、驳回原因)
const patientIds = list.map(item => item.id);
const auditMap = {};
if (patientIds.length) {
const audits = await this.model('patient_audit')
.where({ patient_id: ['in', patientIds], action: ['in', ['approve', 'reject']] })
.order('id DESC')
.select();
audits.forEach(a => {
if (!auditMap[a.patient_id]) auditMap[a.patient_id] = a;
});
}

const statusMap = { '-1': '待提交', 0: '待审核', 1: '审核通过', 2: '已驳回' };
const header = ['ID', '姓名', '性别', '身份证', '手机号', '省份', '城市', '审核状态', '审核日期', '审核驳回原因', '瘤种'];
const rows = list.map(item => {
const audit = auditMap[item.id];
return [
item.patient_no,
item.name,
item.gender,
item.id_card,
item.phone,
regionMap[item.province_code] || '',
regionMap[item.city_code] || '',
statusMap[item.status] || '',
audit ? (audit.create_time || '') : '',
(audit && audit.action === 'reject') ? (audit.reason || '') : '',
item.tag || ''
];
});

// 生成 CSV(带 BOM 以支持 Excel 中文)
const csvContent = [header, ...rows]
.map(row => row.map(cell => `"${String(cell || '').replace(/"/g, '""')}"`).join(','))
.join('\n');
const bom = '\uFEFF';

this.ctx.set('Content-Type', 'text/csv; charset=utf-8');
this.ctx.set('Content-Disposition', `attachment; filename=patients_${Date.now()}.csv`);
this.ctx.body = bom + csvContent;

await this.log('export', '患者管理', `导出患者数据 ${list.length} 条`);
}

// @private 发送审核结果订阅消息
async _sendAuditSubscribeMessage(patientId, patientName, result) {
try {
const APP_REMARK = 'pap_mini_cytx';
// 查找该患者关联的微信用户
const wechatUser = await this.model('wechat_user')
.where({ patient_id: patientId, app_remark: APP_REMARK, status: 1 })
.find();
if (think.isEmpty(wechatUser) || !wechatUser.open_id) return;

const wechatService = this.service('wechat');
const templates = await wechatService.getSubscribeTemplates(APP_REMARK);
const templateId = templates.audit_result;
if (!templateId) return;

// 映射小程序版本: __wxConfig.envVersion -> miniprogram_state
// develop -> developer, trial -> trial, release -> formal
const envMap = { develop: 'developer', trial: 'trial', release: 'formal' };
const miniprogramState = envMap[wechatUser.mp_env_version] || 'formal';

const dayjs = require('dayjs');
await wechatService.sendSubscribeMessage({
remark: APP_REMARK,
openid: wechatUser.open_id,
templateId,
page: 'pages/profile/profile',
miniprogramState,
data: {
thing2: { value: '肠愈同行患者关爱' },
thing14: { value: patientName || '用户' },
phrase1: { value: result },
time13: { value: dayjs().format('YYYY-MM-DD HH:mm:ss') }
}
});
} catch (error) {
// 订阅消息发送失败不影响审核流程
think.logger.error('[Subscribe] 审核消息发送失败:', error);
}
}

// @private 发送审核结果短信通知
async _sendAuditSms(phone, type, reason) {
if (!phone) return;
try {
const smsConfig = require('../../config/sms.js');
const templates = smsConfig.templates;
if (type === 'approved' && templates.auditApproved) {
await this.sendNotifySms(phone, templates.auditApproved, [], 'audit_approved');
} else if (type === 'rejected' && templates.auditRejected) {
// 驳回原因截取前20字符(短信模板变量有长度限制)
const shortReason = (reason || '资料不符合要求').slice(0, 20);
await this.sendNotifySms(phone, templates.auditRejected, [shortReason], 'audit_rejected');
}
} catch (error) {
think.logger.error('[SMS] 审核短信发送失败:', error);
}
}

};

+ 62
- 0
src/controller/admin/system/log.js 파일 보기

@@ -0,0 +1,62 @@
const Base = require('../../base');

module.exports = class extends Base {
async indexAction() {
this.assign('currentPage', 'sys-log');
this.assign('pageTitle', '操作日志');
this.assign('breadcrumb', [
{ name: '系统管理', url: '/admin/system/user.html' },
{ name: '操作日志' }
]);
return this.display();
}

async listAction() {
const keyword = this.get('keyword') || '';
const action = this.get('action') || '';
const startDate = this.get('startDate') || '';
const endDate = this.get('endDate') || '';
const page = this.get('page') || 1;
const pageSize = this.get('pageSize') || 20;

const model = this.model('operation_log');
const where = {};

if (keyword) {
where._complex = {
username: ['like', `%${keyword}%`],
description: ['like', `%${keyword}%`],
_logic: 'or'
};
}
if (action) {
where.action = action;
}
if (startDate) {
where.create_time = where.create_time || {};
where.create_time = ['>=', startDate + ' 00:00:00'];
}
if (startDate && endDate) {
where.create_time = ['between', [startDate + ' 00:00:00', endDate + ' 23:59:59']];
} else if (endDate) {
where.create_time = ['<=', endDate + ' 23:59:59'];
}

const list = await model.where(where)
.order('id DESC')
.page(page, pageSize)
.countSelect();

return this.json({ code: 0, data: list });
}

async clearAction() {
const days = this.post('days') || 30;
const date = new Date();
date.setDate(date.getDate() - days);
const dateStr = date.toISOString().slice(0, 10);
await this.model('operation_log').where({ create_time: ['<', dateStr + ' 00:00:00'] }).delete();
await this.log('delete', '操作日志', `清理${days}天前的日志`);
return this.json({ code: 0, msg: `已清理${days}天前的日志` });
}
};

+ 281
- 0
src/controller/admin/system/role.js 파일 보기

@@ -0,0 +1,281 @@
const Base = require('../../base');

module.exports = class extends Base {
// 角色列表页面
async indexAction() {
this.assign('currentPage', 'sys-role');
this.assign('pageTitle', '角色权限');
this.assign('breadcrumb', [
{ name: '系统管理', url: '/admin/system/user.html' },
{ name: '角色权限' }
]);

// 权限树数据
this.assign('permissionTree', await this.getPermissionTree());

this.assign('adminUser', this.adminUser || {});

return this.display();
}

// 获取角色列表接口
async listAction() {
const { keyword, page = 1, pageSize = 20 } = this.get();

const where = { is_deleted: 0 };

if (keyword) {
where.name = ['like', `%${keyword}%`];
}

const list = await this.model('admin_role')
.where(where)
.order('sort ASC, id ASC')
.page(page, pageSize)
.countSelect();

return this.success(list);
}

// 获取所有角色(下拉用)
async allAction() {
const list = await this.model('admin_role')
.where({ is_deleted: 0, status: 1 })
.order('sort ASC')
.select();

return this.success(list);
}

// 获取单个角色
async detailAction() {
const { id } = this.get();
if (!id) return this.fail('参数错误');

const role = await this.model('admin_role')
.where({ id, is_deleted: 0 })
.find();

if (think.isEmpty(role)) {
return this.fail('角色不存在');
}

// 解析 permissions JSON 字符串为数组
try {
role.permissions = JSON.parse(role.permissions || '[]');
} catch (e) {
role.permissions = [];
}

return this.success(role);
}

// 新增角色
async addAction() {
const { name, code, description, is_default = 0, sort = 0 } = this.post();

if (!name) {
return this.fail('角色名称不能为空');
}

// 检查名称是否存在
const exist = await this.model('admin_role')
.where({ name, is_deleted: 0 })
.find();

if (!think.isEmpty(exist)) {
return this.fail('角色名称已存在');
}

const data = {
name,
code: code || '',
description: description || '',
permissions: JSON.stringify([]),
is_default: is_default ? 1 : 0,
sort: sort || 0,
create_by: this.adminUser?.id || 0
};

const id = await this.model('admin_role').add(data);
await this.log('add', '角色管理', `新增角色「${name}」`);
return this.success({ id });
}

// 编辑角色
async editAction() {
const { id, name, code, description, is_default, sort } = this.post();

if (!id) return this.fail('参数错误');

const role = await this.model('admin_role')
.where({ id, is_deleted: 0 })
.find();

if (think.isEmpty(role)) {
return this.fail('角色不存在');
}

// 默认角色不能编辑
if (role.is_default === 1) {
return this.fail('默认角色不能编辑');
}

// 检查名称是否重复
if (name && name !== role.name) {
const exist = await this.model('admin_role')
.where({ name, is_deleted: 0, id: ['!=', id] })
.find();

if (!think.isEmpty(exist)) {
return this.fail('角色名称已存在');
}
}

const data = {
name: name || role.name,
code: code !== undefined ? code : role.code,
description: description !== undefined ? description : role.description,
is_default: is_default !== undefined ? (is_default ? 1 : 0) : role.is_default,
sort: sort !== undefined ? sort : role.sort,
update_by: this.adminUser?.id || 0
};

await this.model('admin_role').where({ id }).update(data);
await this.log('edit', '角色管理', `编辑角色「${name || role.name}」(ID:${id})`);
return this.success();
}

// 分配权限
async assignPermissionsAction() {
const { id, permissions } = this.post();

if (!id) return this.fail('参数错误');

const role = await this.model('admin_role')
.where({ id, is_deleted: 0 })
.find();

if (think.isEmpty(role)) {
return this.fail('角色不存在');
}

await this.model('admin_role')
.where({ id })
.update({
permissions: JSON.stringify(permissions || []),
update_by: this.adminUser?.id || 0
});

await this.log('edit', '角色管理', `分配权限「${role.name}」(ID:${id})`);
return this.success();
}

// 删除角色
async deleteAction() {
const { id } = this.post();

if (!id) return this.fail('参数错误');

const role = await this.model('admin_role')
.where({ id, is_deleted: 0 })
.find();

if (think.isEmpty(role)) {
return this.fail('角色不存在');
}

// 默认角色不能删除
if (role.is_default === 1) {
return this.fail('默认角色不能删除');
}

// 检查是否有用户使用该角色
const userCount = await this.model('admin_user')
.where({ role_id: id, is_deleted: 0 })
.count();

if (userCount > 0) {
return this.fail(`该角色下有 ${userCount} 个用户,无法删除`);
}

await this.model('admin_role')
.where({ id })
.update({
is_deleted: 1,
update_by: this.adminUser?.id || 0
});

await this.log('delete', '角色管理', `删除角色「${role.name}」(ID:${id})`);
return this.success();
}

// 批量删除
async batchDeleteAction() {
const { ids } = this.post();

if (!ids || !ids.length) return this.fail('参数错误');

// 检查是否包含默认角色
const defaultCount = await this.model('admin_role')
.where({ id: ['in', ids], is_default: 1, is_deleted: 0 })
.count();

if (defaultCount > 0) {
return this.fail('选中的角色包含默认角色,无法删除');
}

// 检查是否有用户使用这些角色
const userCount = await this.model('admin_user')
.where({ role_id: ['in', ids], is_deleted: 0 })
.count();

if (userCount > 0) {
return this.fail(`选中的角色下有 ${userCount} 个用户,无法删除`);
}

await this.model('admin_role')
.where({ id: ['in', ids] })
.update({
is_deleted: 1,
update_by: this.adminUser?.id || 0
});

await this.log('delete', '角色管理', `批量删除角色(IDs:${ids.join(',')})`);
return this.success();
}

// 获取权限树配置
async getPermissionTree() {
return [
{
name: '患者管理', key: 'patient',
children: [
{ name: '查看', key: 'patient:view' },
{ name: '新增', key: 'patient:add' },
{ name: '编辑', key: 'patient:edit' },
{ name: '导出', key: 'patient:export' },
{ name: '审核', key: 'patient:audit' }
]
},
{
name: '内容管理', key: 'content',
children: [
{ name: '查看', key: 'content:view' },
{ name: '新增', key: 'content:add' },
{ name: '编辑', key: 'content:edit' },
{ name: '删除', key: 'content:delete' }
]
},
{
name: '系统管理', key: 'setting:system',
children: [
{ name: '用户管理', key: 'setting:system:user' },
{ name: '角色权限', key: 'setting:system:role' },
{ name: '操作日志', key: 'setting:system:log' },
{ name: '短信记录', key: 'setting:system:sms' }
]
}
];
}
};

+ 50
- 0
src/controller/admin/system/sms.js 파일 보기

@@ -0,0 +1,50 @@
const Base = require('../../base');

module.exports = class extends Base {
async indexAction() {
this.assign('currentPage', 'sys-sms');
this.assign('pageTitle', '短信记录');
this.assign('breadcrumb', [
{ name: '系统管理', url: '/admin/system/user.html' },
{ name: '短信记录' }
]);
return this.display();
}

async listAction() {
const keyword = this.get('keyword') || '';
const status = this.get('status');
const bizType = this.get('bizType') || '';
const startDate = this.get('startDate') || '';
const endDate = this.get('endDate') || '';
const page = this.get('page') || 1;
const pageSize = this.get('pageSize') || 20;

const model = this.model('sms_log');
const where = {};

if (keyword) {
where.mobile = ['like', `%${keyword}%`];
}
if (status !== '' && status !== undefined) {
where.status = parseInt(status);
}
if (bizType) {
where.biz_type = bizType;
}
if (startDate && endDate) {
where.create_time = ['between', [startDate + ' 00:00:00', endDate + ' 23:59:59']];
} else if (startDate) {
where.create_time = ['>=', startDate + ' 00:00:00'];
} else if (endDate) {
where.create_time = ['<=', endDate + ' 23:59:59'];
}

const list = await model.where(where)
.order('id DESC')
.page(page, pageSize)
.countSelect();

return this.json({ code: 0, data: list });
}
};

+ 268
- 0
src/controller/admin/system/user.js 파일 보기

@@ -0,0 +1,268 @@
const Base = require('../../base');

module.exports = class extends Base {
// 用户列表页面
async indexAction() {
this.assign('currentPage', 'sys-user');
this.assign('pageTitle', '用户管理');
this.assign('breadcrumb', [
{ name: '系统管理', url: '/admin/system/user.html' },
{ name: '用户管理' }
]);

// 获取角色列表
const roles = await this.model('admin_role')
.where({ is_deleted: 0 })
.select();
this.assign('roles', roles);

// 当前登录用户
this.assign('adminUser', this.adminUser || {});

return this.display();
}

// 获取用户列表接口
async listAction() {
const { keyword, role_id, status, page = 1, pageSize = 10 } = this.get();

const where = { is_deleted: 0 };

if (keyword) {
where._complex = {
username: ['like', `%${keyword}%`],
nickname: ['like', `%${keyword}%`],
_logic: 'or'
};
}

if (role_id) {
where.role_id = role_id;
}

if (status !== undefined && status !== '') {
where.status = parseInt(status, 10);
}

const list = await this.model('admin_user')
.where(where)
.order('id ASC')
.page(page, pageSize)
.countSelect();

// 获取角色名称
const roleIds = [...new Set(list.data.map(item => item.role_id))];
const roles = roleIds.length ? await this.model('admin_role')
.where({ id: ['in', roleIds] })
.select() : [];
const roleMap = {};
roles.forEach(r => roleMap[r.id] = r.name);

list.data.forEach(item => {
item.role_name = roleMap[item.role_id] || '-';
delete item.password;
});

return this.success(list);
}

// 获取单个用户
async detailAction() {
const { id } = this.get();
if (!id) return this.fail('参数错误');

const user = await this.model('admin_user')
.where({ id, is_deleted: 0 })
.find();

if (think.isEmpty(user)) {
return this.fail('用户不存在');
}

delete user.password;
return this.success(user);
}

// 新增用户
async addAction() {
const { username, password, nickname, email, phone, role_id, status = 1 } = this.post();

if (!username || !password) {
return this.fail('用户名和密码不能为空');
}

// 密码强度校验
const pwdErr = this.checkPasswordStrength(password);
if (pwdErr) return this.fail(pwdErr);

// 检查用户名是否存在
const exist = await this.model('admin_user')
.where({ username, is_deleted: 0 })
.find();

if (!think.isEmpty(exist)) {
return this.fail('用户名已存在');
}

const data = {
username,
password: think.md5(password),
nickname: nickname || '',
email: email || '',
phone: phone || '',
role_id: role_id || 0,
status,
create_by: this.adminUser?.id || 0
};

const id = await this.model('admin_user').add(data);
await this.log('add', '用户管理', `新增用户「${username}」`);
return this.success({ id });
}

// 编辑用户
async editAction() {
const { id, nickname, email, phone, role_id, status } = this.post();

if (!id) return this.fail('参数错误');

const user = await this.model('admin_user')
.where({ id, is_deleted: 0 })
.find();

if (think.isEmpty(user)) {
return this.fail('用户不存在');
}

const data = {
nickname: nickname || user.nickname,
email: email || user.email,
phone: phone || user.phone,
role_id: role_id !== undefined ? role_id : user.role_id,
status: status !== undefined ? status : user.status,
update_by: this.adminUser?.id || 0
};

await this.model('admin_user').where({ id }).update(data);
await this.log('edit', '用户管理', `编辑用户(ID:${id})`);
return this.success();
}

// 重置密码
async resetPasswordAction() {
const { id, password } = this.post();

if (!id || !password) {
return this.fail('参数错误');
}

// 密码强度校验
const pwdErr = this.checkPasswordStrength(password);
if (pwdErr) return this.fail(pwdErr);

const user = await this.model('admin_user')
.where({ id, is_deleted: 0 })
.find();

if (think.isEmpty(user)) {
return this.fail('用户不存在');
}

await this.model('admin_user')
.where({ id })
.update({
password: think.md5(password),
update_by: this.adminUser?.id || 0
});

await this.log('edit', '用户管理', `重置用户密码(ID:${id})`);
return this.success();
}

// 删除用户(软删除)
async deleteAction() {
const { id } = this.post();

if (!id) return this.fail('参数错误');

// 不能删除自己
if (id == this.adminUser?.id) {
return this.fail('不能删除当前登录用户');
}

await this.model('admin_user')
.where({ id })
.update({
is_deleted: 1,
update_by: this.adminUser?.id || 0
});

await this.log('delete', '用户管理', `删除用户(ID:${id})`);
return this.success();
}

// 切换状态
async toggleStatusAction() {
const { id } = this.post();

if (!id) return this.fail('参数错误');

const user = await this.model('admin_user')
.where({ id, is_deleted: 0 })
.find();

if (think.isEmpty(user)) {
return this.fail('用户不存在');
}

// 不能禁用自己
if (id == this.adminUser?.id) {
return this.fail('不能禁用当前登录用户');
}

await this.model('admin_user')
.where({ id })
.update({
status: user.status === 1 ? 0 : 1,
update_by: this.adminUser?.id || 0
});

return this.success();
}

// 解锁用户(清除登录失败计数)
async unlockAction() {
const { id } = this.post();
if (!id) return this.fail('参数错误');

const user = await this.model('admin_user')
.where({ id, is_deleted: 0 })
.find();

if (think.isEmpty(user)) {
return this.fail('用户不存在');
}

await this.model('admin_user')
.where({ id })
.update({ login_fail_count: 0, login_fail_date: null });

await this.log('edit', '用户管理', `解锁用户「${user.username}」`);
return this.success();
}

// 密码强度校验:至少8位,大写/小写/数字至少包含两种
checkPasswordStrength(pwd) {
if (!pwd || pwd.length < 8) {
return '密码长度不能少于8位';
}
let types = 0;
if (/[a-z]/.test(pwd)) types++;
if (/[A-Z]/.test(pwd)) types++;
if (/\d/.test(pwd)) types++;
if (types < 2) {
return '密码需包含大写字母、小写字母、数字中的至少两种';
}
return null;
}
};

+ 306
- 0
src/controller/admin/upload.js 파일 보기

@@ -0,0 +1,306 @@
const Base = require('../base.js');
const COS = require('cos-nodejs-sdk-v5');
const fs = require('fs');
const path = require('path');
const cosConfig = require('../../config/cos.js');

module.exports = class extends Base {
/**
* 文件上传
* POST /admin/upload
*/
async indexAction() {
// 辅助函数:延迟
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));

// 获取文件(处理 ThinkJS 文件获取延迟问题)
let file = this.file('file');
let filePath = file ? file.path : '';

if (!filePath) {
await sleep(1000);
file = this.file('file');
filePath = file ? file.path : '';
if (!filePath) {
await sleep(1000);
file = this.file('file');
filePath = file ? file.path : '';
if (!filePath) {
return this.fail('请选择要上传的文件');
}
}
}

try {
// 获取文件信息
const originalName = file.name;
const fileSize = file.size;
const mimeType = file.type;

// 验证文件类型和大小
const validation = this.validateFile(mimeType, fileSize);
if (!validation.valid) {
// 删除临时文件
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
return this.fail(validation.message);
}

// 生成存储路径(按日期分类)
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const dateFolder = `${year}/${month}/${day}`;

// 生成文件名(保留原文件名,添加时间戳避免重复)
const timestamp = Date.now();
const ext = path.extname(originalName);
const baseName = path.basename(originalName, ext);
const fileName = `${baseName}_${timestamp}${ext}`;
const cosKey = `uploads/${dateFolder}/${fileName}`;

// 如果是图片,进行压缩处理
let uploadFilePath = filePath;
let actualFileSize = fileSize;

if (this.isImage(mimeType)) {
// 检查文件大小是否需要压缩
if (fileSize < cosConfig.imageCompress.minSize) {
think.logger.info(`图片小于 ${cosConfig.imageCompress.minSize / 1024}KB,跳过压缩`);
} else {
try {
const compressResult = await this.compressImage(filePath, mimeType);
// 比较压缩后的文件大小,如果压缩后更大则使用原图
if (compressResult.compressedPath && compressResult.compressedSize < fileSize) {
uploadFilePath = compressResult.compressedPath;
actualFileSize = compressResult.compressedSize;
think.logger.info(`图片压缩成功: ${fileSize} -> ${compressResult.compressedSize} (节省 ${((1 - compressResult.compressedSize / fileSize) * 100).toFixed(2)}%)`);
} else {
think.logger.info('压缩后文件更大,使用原图上传');
// 删除压缩后的文件
if (compressResult.compressedPath && fs.existsSync(compressResult.compressedPath)) {
try {
fs.unlinkSync(compressResult.compressedPath);
} catch (e) {
think.logger.warn('删除压缩文件失败:', e.message);
}
}
}
} catch (compressError) {
think.logger.warn('图片压缩失败,使用原图上传:', compressError);
// 压缩失败时使用原图
}
}
}

// 初始化 COS 客户端
const cos = new COS({
SecretId: cosConfig.secretId,
SecretKey: cosConfig.secretKey
});

// 上传文件到 COS
const uploadResult = await new Promise((resolve, reject) => {
cos.putObject({
Bucket: cosConfig.bucket,
Region: cosConfig.region,
Key: cosKey,
Body: fs.createReadStream(uploadFilePath),
ContentType: mimeType,
onProgress: (progressData) => {
// 可以在这里处理上传进度
}
}, (err, data) => {
if (err) {
reject(err);
} else {
resolve(data);
}
});
});

// 清理临时文件(延迟删除,避免 Windows 文件句柄未释放问题)
setTimeout(() => {
try {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
} catch (e) {
think.logger.warn('删除临时文件失败:', e.message);
}
try {
if (uploadFilePath !== filePath && fs.existsSync(uploadFilePath)) {
fs.unlinkSync(uploadFilePath);
}
} catch (e) {
think.logger.warn('删除压缩文件失败:', e.message);
}
}, 1000);

// 返回结果
if (uploadResult.statusCode === 200) {
const bucketUrl = `https://${uploadResult.Location}`;
const cdnUrl = bucketUrl.replace(cosConfig.bucketUrl, cosConfig.cdnUrl);

return this.success({
url: cdnUrl,
name: originalName,
bucket_url: bucketUrl,
file_name: originalName,
file_size: actualFileSize,
mime_type: mimeType
});
} else {
return this.fail('文件上传失败');
}
} catch (error) {
think.logger.error('文件上传失败:', error);
// 清理临时文件
if (file && file.path && fs.existsSync(file.path)) {
try {
fs.unlinkSync(file.path);
} catch (cleanError) {
think.logger.error('清理临时文件失败:', cleanError);
}
}
return this.fail('文件上传失败: ' + error.message);
}
}

/**
* 验证文件类型和大小
*/
validateFile(mimeType, fileSize) {
const allAllowedTypes = [
...cosConfig.allowedImageTypes,
...cosConfig.allowedDocTypes,
...cosConfig.allowedVideoTypes
];

// 验证文件类型
if (!allAllowedTypes.includes(mimeType)) {
return {
valid: false,
message: '不支持的文件类型'
};
}

// 验证文件大小
let maxSize = 0;
if (cosConfig.allowedImageTypes.includes(mimeType)) {
maxSize = cosConfig.maxImageSize;
} else if (cosConfig.allowedDocTypes.includes(mimeType)) {
maxSize = cosConfig.maxDocSize;
} else if (cosConfig.allowedVideoTypes.includes(mimeType)) {
maxSize = cosConfig.maxVideoSize;
}

if (fileSize > maxSize) {
const maxSizeMB = (maxSize / 1024 / 1024).toFixed(0);
return {
valid: false,
message: `文件大小不能超过 ${maxSizeMB}MB`
};
}

return { valid: true };
}

/**
* 判断是否为图片
*/
isImage(mimeType) {
return cosConfig.allowedImageTypes.includes(mimeType);
}

/**
* 压缩图片
* 返回压缩后的文件路径和文件大小
*/
async compressImage(filePath, mimeType) {
// 尝试加载 sharp,如果没有安装则跳过压缩
let sharp;
try {
sharp = require('sharp');
} catch (e) {
think.logger.warn('sharp 模块未安装,跳过图片压缩');
return { compressedPath: null, compressedSize: 0 };
}

const compressedPath = filePath + '_compressed';
const { quality, maxWidth, maxHeight } = cosConfig.imageCompress;

try {
const image = sharp(filePath);
const metadata = await image.metadata();

// 计算缩放尺寸
let resizeOptions = {};
if (metadata.width > maxWidth || metadata.height > maxHeight) {
resizeOptions = {
width: maxWidth,
height: maxHeight,
fit: 'inside',
withoutEnlargement: true
};
}

// 根据图片类型选择压缩格式
let outputOptions = {};
if (mimeType === 'image/jpeg' || mimeType === 'image/jpg') {
outputOptions = { quality };
await image.resize(resizeOptions).jpeg(outputOptions).toFile(compressedPath);
} else if (mimeType === 'image/png') {
outputOptions = { quality, compressionLevel: 9 };
await image.resize(resizeOptions).png(outputOptions).toFile(compressedPath);
} else if (mimeType === 'image/webp') {
outputOptions = { quality };
await image.resize(resizeOptions).webp(outputOptions).toFile(compressedPath);
} else {
// 其他格式不压缩
return {
compressedPath: null,
compressedSize: 0
};
}

// 获取压缩后的文件大小
const stats = fs.statSync(compressedPath);
const compressedSize = stats.size;

return {
compressedPath,
compressedSize
};
} catch (error) {
think.logger.error('图片压缩失败:', error);
throw error;
}
}

/**
* 获取上传配置信息
* GET /admin/upload/config
*/
async configAction() {
try {
return this.success({
allowedTypes: {
images: cosConfig.allowedImageTypes,
documents: cosConfig.allowedDocTypes,
videos: cosConfig.allowedVideoTypes
},
maxSize: {
image: cosConfig.maxImageSize,
document: cosConfig.maxDocSize,
video: cosConfig.maxVideoSize
}
});
} catch (error) {
think.logger.error('获取上传配置失败:', error);
return this.fail('获取上传配置失败');
}
}
};

+ 324
- 0
src/controller/base.js 파일 보기

@@ -0,0 +1,324 @@
const jwt = require('jsonwebtoken');

// JWT配置
const JWT_SECRET = think.md5('yzx');
const JWT_EXPIRES_IN = 24 * 60 * 60;

// 白名单配置
const WHITE_LIST = [
'/admin/login',
'/admin/auth/login',
'/admin/auth/captcha',
'/admin/logout',
'/api/content',
'/api/mp/login',
'/common/ocr/idcard',
'/common/ocr/hmt',
];

module.exports = class extends think.Controller {
async __before() {
const path = this.ctx.path;

// 白名单放行(同时匹配带.html后缀的路径)
const pathNoSuffix = path.replace(/\.html$/, '');
if (WHITE_LIST.includes(path) || WHITE_LIST.includes(pathNoSuffix)) {
return;
}

// 后台路由需要验证JWT
if (path.startsWith('/admin')) {
const token = this.cookie('admin_token') || this.header('authorization')?.replace('Bearer ', '');

if (!token) {
return this.handleUnauthorized();
}

try {
const decoded = jwt.verify(token, JWT_SECRET);
this.adminUser = decoded;

if (!this.isAjax()) {
this.assign('version', think.config('version'));
await this.loadUserPermissions();
this.assign('adminUser', this.adminUser || {});
}
} catch (err) {
return this.handleUnauthorized();
}
}

// 小程序API路由验证JWT
if (path.startsWith('/api/mp/')) {
const token = this.header('authorization')?.replace('Bearer ', '');
if (token) {
try {
const decoded = jwt.verify(token, JWT_SECRET);
if (decoded.type === 'mp') {
this.mpUser = decoded;
}
} catch (err) {
// token无效,mpUser为空,由具体action判断是否需要登录
}
}
}
}

// 加载用户权限
async loadUserPermissions() {
if (!this.adminUser) return;

// 查询用户昵称补充到 adminUser
const userInfo = await this.model('admin_user')
.field('nickname')
.where({ id: this.adminUser.id, is_deleted: 0 })
.find();
if (!think.isEmpty(userInfo)) {
this.adminUser.nickname = userInfo.nickname || '';
}

const role = await this.model('admin_role')
.where({ id: this.adminUser.role_id, is_deleted: 0 })
.find();

let perms = [];
let isSuper = false;
if (!think.isEmpty(role)) {
isSuper = role.is_default === 1 && (role.code === 'ADMIN' || role.name === '超级管理员');
if (!isSuper) {
try {
perms = JSON.parse(role.permissions || '[]');
} catch (e) {
perms = [];
}
}
}
this.userPermissions = perms;
this.isSuperAdmin = isSuper;
this.assign('userPermissions', perms);
this.assign('isSuperAdmin', isSuper);
}

// 未授权处理
handleUnauthorized() {
if (this.isAjax()) {
return this.fail(401, '请先登录');
}
return this.redirect('/admin/login.html');
}

// 判断是否Ajax请求
isAjax() {
return this.header('x-requested-with') === 'XMLHttpRequest' ||
this.header('accept')?.includes('application/json');
}

// 生成JWT Token
static generateToken(payload) {
return jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
}

// 记录操作日志
async log(action, module, description) {
try {
const user = this.adminUser || {};
await this.model('operation_log').add({
user_id: user.id || 0,
username: user.username || '',
action: action || '',
module: module || '',
description: description || '',
ip: this.ctx.ip || '',
user_agent: (this.header('user-agent') || '').slice(0, 500)
});
} catch (e) {
// 日志写入失败不影响业务
}
}

/**
* 发送短信验证码
* @param {string} mobile - 手机号
* @param {string} bizType - 业务类型
* @returns {Object} { success, message, code? }
*/
async sendSmsCode(mobile, bizType) {
const dayjs = require('dayjs');
const smsConfig = require('../config/sms.js');
const codeConfig = smsConfig.code;
const ip = this.ctx.ip || '';

// 检查发送频率
const rateLimitKey = `sms:rate_limit:${mobile}:${bizType}`;
const lastSendTime = await think.cache(rateLimitKey);
if (lastSendTime) {
const elapsed = Math.floor((Date.now() - lastSendTime) / 1000);
const remaining = codeConfig.intervalSeconds - elapsed;
if (remaining > 0) {
return { success: false, message: `请${remaining}秒后再试` };
}
}

// 检查每日发送次数
const today = dayjs().format('YYYY-MM-DD');
const dailyKey = `sms:daily:${mobile}:${today}`;
const dailyCount = (await think.cache(dailyKey)) || 0;
if (dailyCount >= codeConfig.dailyLimit) {
return { success: false, message: '今日发送次数已达上限' };
}

// 生成验证码
const code = String(Math.floor(Math.pow(10, codeConfig.length - 1) + Math.random() * 9 * Math.pow(10, codeConfig.length - 1)));
const expireTime = dayjs().add(codeConfig.expireMinutes, 'minute').toDate();

// 记录到数据库
const smsLogModel = this.model('sms_log');
const logId = await smsLogModel.add({
mobile,
template_id: smsConfig.templates.code,
template_params: JSON.stringify({ code }),
sms_type: 1,
biz_type: bizType,
code,
expire_time: think.datetime(expireTime),
status: 0,
ip,
create_time: think.datetime(new Date())
});

// 发送短信
let sendResult = { success: true, msgId: null };
if (smsConfig.enabled && smsConfig.smsSdkAppId && smsConfig.templates.code) {
sendResult = await this._sendTencentSms(mobile, smsConfig.templates.code, [code, String(codeConfig.expireMinutes)]);
} else {
think.logger.info(`[SMS-DEV] 手机号: ${mobile}, 验证码: ${code}, 业务: ${bizType}`);
}

// 更新发送状态
await smsLogModel.where({ id: logId }).update({
status: sendResult.success ? 1 : 2,
fail_reason: sendResult.success ? null : sendResult.message,
provider_msg_id: sendResult.msgId
});

if (!sendResult.success) {
return { success: false, message: sendResult.message || '短信发送失败' };
}

// 存入缓存
const codeKey = `sms:code:${mobile}:${bizType}`;
await think.cache(codeKey, code, { timeout: codeConfig.expireMinutes * 60 * 1000 });
await think.cache(rateLimitKey, Date.now(), { timeout: codeConfig.intervalSeconds * 1000 });
await think.cache(dailyKey, dailyCount + 1, { timeout: 24 * 60 * 60 * 1000 });

return { success: true, message: '验证码已发送', code };
}

/**
* 校验短信验证码
*/
async verifySmsCode(mobile, bizType, code) {
const codeKey = `sms:code:${mobile}:${bizType}`;
const cachedCode = await think.cache(codeKey);

if (!cachedCode) {
return { success: false, message: '验证码已过期,请重新获取' };
}
if (cachedCode !== code) {
return { success: false, message: '验证码错误' };
}

await think.cache(codeKey, null);
return { success: true, message: '验证成功' };
}

/**
* 发送通知短信(非验证码类)
* @param {string} mobile - 手机号
* @param {string} templateId - 模板ID
* @param {string[]} templateParams - 模板参数数组
* @param {string} bizType - 业务类型(用于日志)
* @returns {Object} { success, message }
*/
async sendNotifySms(mobile, templateId, templateParams = [], bizType = 'notify') {
const smsConfig = require('../config/sms.js');
const ip = this.ctx?.ip || '';

// 记录到数据库
const smsLogModel = this.model('sms_log');
const logId = await smsLogModel.add({
mobile,
template_id: templateId,
template_params: JSON.stringify(templateParams),
sms_type: 2,
biz_type: bizType,
code: '',
status: 0,
ip,
create_time: think.datetime(new Date())
});

let sendResult = { success: true, msgId: null };
if (smsConfig.enabled && smsConfig.smsSdkAppId) {
sendResult = await this._sendTencentSms(mobile, templateId, templateParams);
} else {
think.logger.info(`[SMS-DEV] 通知短信 手机号: ${mobile}, 模板: ${templateId}, 参数: ${JSON.stringify(templateParams)}, 业务: ${bizType}`);
}

await smsLogModel.where({ id: logId }).update({
status: sendResult.success ? 1 : 2,
fail_reason: sendResult.success ? null : sendResult.message,
provider_msg_id: sendResult.msgId
});

return sendResult;
}

/**
* 发送腾讯云短信
* @private
*/
async _sendTencentSms(mobile, templateId, templateParams) {
const smsConfig = require('../config/sms.js');
try {
const tencentcloud = require('tencentcloud-sdk-nodejs');
const SmsClient = tencentcloud.sms.v20210111.Client;

const client = new SmsClient({
credential: {
secretId: smsConfig.secretId,
secretKey: smsConfig.secretKey
},
region: smsConfig.region,
profile: {
httpProfile: { endpoint: 'sms.tencentcloudapi.com' }
}
});

const result = await client.SendSms({
PhoneNumberSet: [`+86${mobile}`],
SmsSdkAppId: smsConfig.smsSdkAppId,
SignName: smsConfig.signName,
TemplateId: templateId,
TemplateParamSet: templateParams
});

const sendStatus = result.SendStatusSet?.[0];
if (sendStatus?.Code === 'Ok') {
return { success: true, msgId: sendStatus.SerialNo };
}
return { success: false, message: sendStatus?.Message || '发送失败' };
} catch (error) {
think.logger.error('[SMS] 腾讯云短信发送失败:', error);
return { success: false, message: error.message };
}
}

static get JWT_SECRET() {
return JWT_SECRET;
}

static get JWT_EXPIRES_IN() {
return JWT_EXPIRES_IN;
}
};

+ 184
- 0
src/controller/common.js 파일 보기

@@ -0,0 +1,184 @@
module.exports = class extends think.Controller {
/**
* 根据 content_key 获取内容
* GET /api/content?key=xxx
*/
async contentAction() {
const key = this.get('key');
if (!key) return this.json({ code: 1, msg: '参数错误' });

try {
const item = await this.model('content').getByKey(key);
if (think.isEmpty(item)) {
return this.json({ code: 1, msg: '内容不存在' });
}
return this.json({ code: 0, data: item });
} catch (error) {
think.logger.error('获取内容失败:', error);
return this.json({ code: 1, msg: '获取内容失败' });
}
}

/**
* 身份证OCR识别
* POST /common/ocr/idcard
*/
async ocrIdcardAction() {
const { imageUrl, cardSide = 'FRONT' } = this.post();
if (!imageUrl) {
return this.json({ code: 1, msg: '请上传证件图片' });
}

const ocrConfig = require('../config/ocr.js');

if (!ocrConfig.enabled || !ocrConfig.secretId) {
return this.json({ code: 0, data: this._getMockIdCardData(cardSide), msg: 'OCR识别成功(模拟数据)' });
}

try {
const tencentcloud = require('tencentcloud-sdk-nodejs');
const OcrClient = tencentcloud.ocr.v20181119.Client;

const client = new OcrClient({
credential: { secretId: ocrConfig.secretId, secretKey: ocrConfig.secretKey },
region: ocrConfig.region,
profile: { httpProfile: { endpoint: 'ocr.tencentcloudapi.com' } }
});

const result = await client.IDCardOCR({ ImageUrl: imageUrl, CardSide: cardSide });

const data = cardSide === 'FRONT' ? {
name: result.Name || '',
sex: result.Sex || '',
nation: result.Nation || '',
birth: result.Birth || '',
address: result.Address || '',
idNum: result.IdNum || ''
} : {
authority: result.Authority || '',
validDate: result.ValidDate || ''
};

return this.json({ code: 0, data, msg: 'OCR识别成功' });
} catch (error) {
think.logger.error('[OCR] 身份证识别失败:', error);
return this.json({ code: 1, msg: error.message || 'OCR识别失败' });
}
}

/**
* 港澳台通行证OCR识别
* POST /common/ocr/hmt
*/
async ocrHmtAction() {
const { imageUrl } = this.post();
if (!imageUrl) {
return this.json({ code: 1, msg: '请上传证件图片' });
}

const ocrConfig = require('../config/ocr.js');

if (!ocrConfig.enabled || !ocrConfig.secretId) {
return this.json({ code: 0, data: this._getMockHmtData(), msg: 'OCR识别成功(模拟数据)' });
}

try {
const tencentcloud = require('tencentcloud-sdk-nodejs');
const OcrClient = tencentcloud.ocr.v20181119.Client;

const client = new OcrClient({
credential: { secretId: ocrConfig.secretId, secretKey: ocrConfig.secretKey },
region: ocrConfig.region,
profile: { httpProfile: { endpoint: 'ocr.tencentcloudapi.com' } }
});

const result = await client.MainlandPermitOCR({ ImageUrl: imageUrl });

return this.json({
code: 0,
data: {
name: result.Name || '',
englishName: result.EnglishName || '',
sex: result.Sex || '',
birth: result.Birthday || '',
idNum: result.Number || '',
issueAuthority: result.IssueAuthority || '',
validDate: result.ValidDate || '',
issueAddress: result.IssueAddress || '',
type: result.Type || ''
},
msg: 'OCR识别成功'
});
} catch (error) {
think.logger.error('[OCR] 港澳台通行证识别失败:', error);
return this.json({ code: 1, msg: error.message || 'OCR识别失败' });
}
}

_getMockIdCardData(cardSide) {
if (cardSide === 'FRONT') {
return {
name: '张三', sex: '男', nation: '汉',
birth: '1990/01/01', address: '北京市朝阳区xxx街道xxx号',
idNum: '110101199001011234'
};
}
return { authority: '北京市公安局朝阳分局', validDate: '2020.01.01-2040.01.01' };
}

_getMockHmtData() {
return {
name: '李小红', englishName: 'LI XIAOHONG', sex: '女',
birth: '1992.08.20', idNum: 'H12345678',
issueAuthority: '公安部出入境管理局', validDate: '2020.01.01-2030.01.01',
issueAddress: '广东', type: '港澳居民来往内地通行证'
};
}

/**
* 获取省市区树形数据(一次返回)
* GET /common/regions
*/
async regionsAction() {
try {
const regionModel = this.model('sys_region');
const regions = await regionModel
.where({ status: 1 })
.order('code ASC')
.select();

// 分离省、市、区
const provinces = [];
const cityMap = {};
const districtMap = {};

regions.forEach(r => {
const code = r.code;
if (code.endsWith('0000')) {
provinces.push({ code, name: r.name, children: [] });
} else if (code.endsWith('00')) {
const parentCode = code.substring(0, 2) + '0000';
if (!cityMap[parentCode]) cityMap[parentCode] = [];
cityMap[parentCode].push({ code, name: r.name, children: [] });
} else {
const parentCode = code.substring(0, 4) + '00';
if (!districtMap[parentCode]) districtMap[parentCode] = [];
districtMap[parentCode].push({ code, name: r.name });
}
});

// 组装树
provinces.forEach(p => {
p.children = cityMap[p.code] || [];
p.children.forEach(c => {
c.children = districtMap[c.code] || [];
});
});

return this.json({ code: 0, data: provinces });
} catch (error) {
think.logger.error('获取省市区数据失败:', error);
return this.json({ code: 1, msg: '获取省市区数据失败' });
}
}
};

+ 5
- 0
src/controller/index.js 파일 보기

@@ -0,0 +1,5 @@
module.exports = class extends think.Controller {
indexAction() {
return this.redirect('/admin/dashboard.html');
}
};

+ 499
- 0
src/controller/mp.js 파일 보기

@@ -0,0 +1,499 @@
const Base = require('./base');
const jwt = require('jsonwebtoken');
const COS = require('cos-nodejs-sdk-v5');
const fs = require('fs');
const path = require('path');
const cosConfig = require('../config/cos.js');

const APP_REMARK = 'pap_mini_cytx';

module.exports = class extends Base {
// POST /api/mp/login
async loginAction() {
const code = this.post('code');
if (!code) return this.json({ code: 1, msg: '缺少code参数' });
try {
const wechatService = this.service('wechat');
const session = await wechatService.code2Session(code, APP_REMARK);
const { openid, unionid } = session;
if (!openid) return this.json({ code: 1, msg: '获取openid失败' });

const userModel = this.model('wechat_user');
let user = await userModel.findByOpenId(openid, APP_REMARK);
if (think.isEmpty(user)) {
const id = await userModel.createUser({
open_id: openid, union_id: unionid || '', app_remark: APP_REMARK,
nickname: '微信用户', status: 1
});
user = await userModel.where({ id }).find();
}
if (user.status !== 1) return this.json({ code: 1, msg: '账号已被停用' });

const token = jwt.sign(
{ id: user.id, open_id: user.open_id, type: 'mp' },
Base.JWT_SECRET, { expiresIn: 7 * 24 * 60 * 60 }
);
let patient = null;
if (user.patient_id) {
patient = await this.model('patient')
.field('id, patient_no, name, phone, status, auth_status')
.where({ id: user.patient_id, is_deleted: 0 }).find();
if (think.isEmpty(patient)) patient = null;
}
return this.json({ code: 0, data: { token, userInfo: {
id: user.id, nickname: user.nickname || '', avatar: user.avatar || '',
phone: user.phone || '', patient_id: user.patient_id || null, patient
}}});
} catch (error) {
think.logger.error('login error:', error);
return this.json({ code: 1, msg: error.message || '登录失败' });
}
}
// GET /api/mp/userinfo
async userinfoAction() {
const mpUser = this.mpUser;
if (!mpUser) return this.json({ code: 1009, msg: '请先登录' });
try {
const user = await this.model('wechat_user').where({ id: mpUser.id, status: 1 }).find();
if (think.isEmpty(user)) return this.json({ code: 1009, msg: '用户不存在' });
let patient = null;
if (user.patient_id) {
patient = await this.model('patient')
.field('id, patient_no, name, phone, status, auth_status')
.where({ id: user.patient_id, is_deleted: 0 }).find();
if (think.isEmpty(patient)) patient = null;
}
return this.json({ code: 0, data: {
id: user.id, nickname: user.nickname || '', avatar: user.avatar || '',
phone: user.phone || '', patient_id: user.patient_id || null, patient
}});
} catch (error) {
think.logger.error('userinfo error:', error);
return this.json({ code: 1, msg: '获取用户信息失败' });
}
}

// POST /api/mp/upload
async uploadAction() {
const mpUser = this.mpUser;
if (!mpUser) return this.json({ code: 1009, msg: '请先登录' });
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
let file = this.file('file');
let filePath = file ? file.path : '';
if (!filePath) {
await sleep(1000);
file = this.file('file');
filePath = file ? file.path : '';
if (!filePath) return this.json({ code: 1, msg: '请选择要上传的文件' });
}
try {
const originalName = file.name;
const mimeType = file.type;
const now = new Date();
const dateFolder = now.getFullYear() + '/' + String(now.getMonth() + 1).padStart(2, '0') + '/' + String(now.getDate()).padStart(2, '0');
const ext = path.extname(originalName);
const baseName = path.basename(originalName, ext);
const fileName = baseName + '_' + Date.now() + ext;
const cosKey = 'uploads/' + dateFolder + '/' + fileName;
const cos = new COS({ SecretId: cosConfig.secretId, SecretKey: cosConfig.secretKey });
const uploadResult = await new Promise((resolve, reject) => {
cos.putObject({
Bucket: cosConfig.bucket, Region: cosConfig.region, Key: cosKey,
Body: fs.createReadStream(filePath), ContentType: mimeType
}, (err, data) => { if (err) reject(err); else resolve(data); });
});
setTimeout(() => { try { if (fs.existsSync(filePath)) fs.unlinkSync(filePath); } catch (e) {} }, 1000);
if (uploadResult.statusCode === 200) {
const bucketUrl = 'https://' + uploadResult.Location;
const cdnUrl = bucketUrl.replace(cosConfig.bucketUrl, cosConfig.cdnUrl);
return this.json({ code: 0, data: { url: cdnUrl } });
}
return this.json({ code: 1, msg: '文件上传失败' });
} catch (error) {
think.logger.error('upload error:', error);
if (file && file.path && fs.existsSync(file.path)) { try { fs.unlinkSync(file.path); } catch (e) {} }
return this.json({ code: 1, msg: '文件上传失败: ' + error.message });
}
}
// POST /api/mp/sendSmsCode
async sendSmsCodeAction() {
const mpUser = this.mpUser;
if (!mpUser) return this.json({ code: 1009, msg: '请先登录' });
const { mobile, bizType = 'real_name_auth' } = this.post();
if (!mobile || !/^1[3-9]\d{9}$/.test(mobile)) {
return this.json({ code: 1, msg: '请输入正确的手机号' });
}
const result = await this.sendSmsCode(mobile, bizType);
if (!result.success) return this.json({ code: 1, msg: result.message });
const smsConfig = require('../config/sms.js');
const data = smsConfig.enabled ? {} : { code: result.code };
return this.json({ code: 0, data, msg: result.message });
}

// GET /api/mp/authInfo
async authInfoAction() {
const mpUser = this.mpUser;
if (!mpUser) return this.json({ code: 1009, msg: '请先登录' });
try {
const user = await this.model('wechat_user').where({ id: mpUser.id, status: 1 }).find();
if (think.isEmpty(user) || !user.patient_id) return this.json({ code: 0, data: { authStatus: 0 } });
const patient = await this.model('patient').where({ id: user.patient_id, is_deleted: 0 }).find();
if (think.isEmpty(patient)) return this.json({ code: 0, data: { authStatus: 0 } });
return this.json({ code: 0, data: {
authStatus: patient.auth_status || 0,
idCardType: patient.id_card_type || 1,
idCardFront: patient.id_card_front || '',
idCardBack: patient.id_card_back || '',
photo: patient.photo || '',
realName: patient.name || '',
idCard: patient.id_card || '',
gender: patient.gender || '',
birthday: patient.birth_date || '',
issuingAuthority: patient.issuing_authority || '',
validPeriod: patient.valid_period || '',
phone: patient.phone || '',
authTime: patient.auth_time || ''
}});
} catch (error) {
think.logger.error('authInfo error:', error);
return this.json({ code: 1, msg: '获取认证信息失败' });
}
}
// POST /api/mp/authSubmit
async authSubmitAction() {
const mpUser = this.mpUser;
if (!mpUser) return this.json({ code: 1009, msg: '请先登录' });
const { idCardType, idCardFront, idCardBack, photo, realName, idCard, gender, birthday, issuingAuthority, validPeriod, mobile, code } = this.post();
if (!realName) return this.json({ code: 1, msg: '请输入证件姓名' });
if (!idCard) return this.json({ code: 1, msg: '请输入证件号码' });
const cardTypeInt = parseInt(idCardType) || 1;
if (cardTypeInt === 2) {
if (!photo) return this.json({ code: 1, msg: '请上传免冠照片' });
} else {
if (!idCardFront) return this.json({ code: 1, msg: '请上传证件正面照片' });
if (!idCardBack) return this.json({ code: 1, msg: '请上传证件反面照片' });
}
if (!mobile || !/^1[3-9]\d{9}$/.test(mobile)) return this.json({ code: 1, msg: '请输入正确的手机号' });
if (!code || !/^\d{6}$/.test(code)) return this.json({ code: 1, msg: '请输入6位验证码' });

const verifyResult = await this.verifySmsCode(mobile, 'real_name_auth', code);
if (!verifyResult.success) return this.json({ code: 1, msg: verifyResult.message });

const patientModel = this.model('patient');
const userModel = this.model('wechat_user');
const currentUser = await userModel.where({ id: mpUser.id }).find();
const existPatient = await patientModel.where({
id_card: idCard, is_deleted: 0,
...(currentUser.patient_id ? { id: ['!=', currentUser.patient_id] } : {})
}).find();
if (!think.isEmpty(existPatient)) return this.json({ code: 1, msg: '该证件号已被其他用户认证' });

if (cardTypeInt === 1 || cardTypeInt === 3) {
const faceidConfig = require('../config/faceid.js');
if (faceidConfig.enabled) {
const verifyIdResult = await this._verifyIdCard(realName, idCard);
if (!verifyIdResult.success) return this.json({ code: 1, msg: verifyIdResult.message });
}
}

const now = think.datetime(new Date());
try {
if (currentUser.patient_id) {
await patientModel.where({ id: currentUser.patient_id }).update({
name: realName, phone: mobile, id_card: idCard, id_card_type: cardTypeInt,
id_card_front: idCardFront || '', id_card_back: idCardBack || '', photo: photo || '',
gender: gender || '', birth_date: birthday || null,
issuing_authority: issuingAuthority || '', valid_period: validPeriod || '',
auth_status: 1, auth_time: now, update_time: now
});
} else {
const patientNo = patientModel.generatePatientNo();
const patientId = await patientModel.add({
patient_no: patientNo, name: realName, phone: mobile, id_card: idCard,
id_card_type: cardTypeInt, id_card_front: idCardFront || '', id_card_back: idCardBack || '',
photo: photo || '', gender: gender || '', birth_date: birthday || null,
issuing_authority: issuingAuthority || '', valid_period: validPeriod || '',
auth_status: 1, auth_time: now, status: -1, is_deleted: 0, create_time: now, update_time: now
});
await userModel.where({ id: mpUser.id }).update({ patient_id: patientId, update_time: now });
}
return this.json({ code: 0, data: {}, msg: '实名认证成功' });
} catch (error) {
think.logger.error('authSubmit error:', error);
return this.json({ code: 1, msg: '认证失败: ' + error.message });
}
}
// GET /api/mp/myInfo
async myInfoAction() {
const mpUser = this.mpUser;
if (!mpUser) return this.json({ code: 1009, msg: '请先登录' });
try {
const user = await this.model('wechat_user').where({ id: mpUser.id, status: 1 }).find();
if (think.isEmpty(user) || !user.patient_id) return this.json({ code: 0, data: null });
const patient = await this.model('patient').where({ id: user.patient_id, is_deleted: 0 }).find();
if (think.isEmpty(patient)) return this.json({ code: 0, data: null });

const codes = [patient.province_code, patient.city_code, patient.district_code].filter(Boolean);
const regionMap = {};
if (codes.length) {
const regions = await this.model('sys_region').where({ code: ['in', codes] }).select();
regions.forEach(r => { regionMap[r.code] = r.name; });
}
let documents = [];
try { documents = JSON.parse(patient.documents || '[]'); } catch (e) { documents = []; }

// 如果是驳回状态,查最近一条驳回原因
let rejectReason = '';
if (patient.status === 2) {
const audit = await this.model('patient_audit')
.where({ patient_id: user.patient_id, action: 'reject' })
.order('id DESC')
.find();
if (!think.isEmpty(audit)) rejectReason = audit.reason || '';
}

return this.json({ code: 0, data: {
name: patient.name || '', id_card: patient.id_card || '', phone: patient.phone || '',
gender: patient.gender || '', birth_date: patient.birth_date || '',
province_code: patient.province_code || '', city_code: patient.city_code || '',
district_code: patient.district_code || '',
province_name: regionMap[patient.province_code] || '',
city_name: regionMap[patient.city_code] || '',
district_name: regionMap[patient.district_code] || '',
address: patient.address || '',
emergency_contact: patient.emergency_contact || '',
emergency_phone: patient.emergency_phone || '',
tag: patient.tag || '', documents,
sign_income: patient.sign_income || '',
sign_privacy: patient.sign_privacy || '',
sign_promise: patient.sign_promise || '',
income_amount: patient.income_amount || '',
status: patient.status, auth_status: patient.auth_status || 0,
reject_reason: rejectReason
}});
} catch (error) {
think.logger.error('myInfo error:', error);
return this.json({ code: 1, msg: '获取资料失败' });
}
}

// POST /api/mp/saveMyInfo
async saveMyInfoAction() {
const mpUser = this.mpUser;
if (!mpUser) return this.json({ code: 1009, msg: '请先登录' });
const { gender, province_code, city_code, district_code, address, emergency_contact, emergency_phone, tag, documents, sign_income, sign_privacy, sign_promise, income_amount, mp_env_version } = this.post();
const user = await this.model('wechat_user').where({ id: mpUser.id, status: 1 }).find();
if (think.isEmpty(user) || !user.patient_id) return this.json({ code: 1, msg: '请先完成实名认证' });
if (!province_code || !city_code || !district_code) return this.json({ code: 1, msg: '请选择省市区' });
if (!address) return this.json({ code: 1, msg: '请填写详细地址' });

const now = think.datetime(new Date());
try {
// 更新小程序版本标识(用于订阅消息推送到对应版本)
if (mp_env_version) {
await this.model('wechat_user').where({ id: mpUser.id }).update({ mp_env_version });
}
await this.model('patient').where({ id: user.patient_id }).update({
gender: gender || '', province_code: province_code || '', city_code: city_code || '',
district_code: district_code || '', address: address || '',
emergency_contact: emergency_contact || '', emergency_phone: emergency_phone || '',
tag: tag || '', documents: JSON.stringify(documents || []),
sign_income: sign_income || '',
sign_privacy: sign_privacy || '',
sign_promise: sign_promise || '',
income_amount: income_amount || null,
status: 0,
update_time: now
});
// 记录提交审核日志
await this.model('patient_audit').add({
patient_id: user.patient_id,
action: 'submit',
operator_id: 0,
operator_name: '用户自助提交'
});
return this.json({ code: 0, data: {}, msg: '提交成功' });
} catch (error) {
think.logger.error('saveMyInfo error:', error);
return this.json({ code: 1, msg: '保存失败: ' + error.message });
}
}
// POST /api/mp/changePhone
async changePhoneAction() {
const mpUser = this.mpUser;
if (!mpUser) return this.json({ code: 1009, msg: '请先登录' });
const { mobile, code } = this.post();
if (!mobile || !/^1[3-9]\d{9}$/.test(mobile)) return this.json({ code: 1, msg: '请输入正确的手机号' });
if (!code || !/^\d{6}$/.test(code)) return this.json({ code: 1, msg: '请输入6位验证码' });
const verifyResult = await this.verifySmsCode(mobile, 'change_phone', code);
if (!verifyResult.success) return this.json({ code: 1, msg: verifyResult.message });
const now = think.datetime(new Date());
const userModel = this.model('wechat_user');
try {
await userModel.where({ id: mpUser.id }).update({ phone: mobile, update_time: now });
const user = await userModel.where({ id: mpUser.id }).find();
if (user.patient_id) {
await this.model('patient').where({ id: user.patient_id }).update({ phone: mobile, update_time: now });
}
return this.json({ code: 0, data: {}, msg: '手机号修改成功' });
} catch (error) {
think.logger.error('changePhone error:', error);
return this.json({ code: 1, msg: '修改失败: ' + error.message });
}
}

// POST /api/mp/sign - 签署协议,生成合成图返回URL(不写库)
async signAction() {
const mpUser = this.mpUser;
if (!mpUser) return this.json({ code: 1009, msg: '请先登录' });
const { type, signImage, amount } = this.post();
const validTypes = ['income', 'privacy', 'promise'];
if (!validTypes.includes(type)) return this.json({ code: 1, msg: '签署类型错误' });
if (!signImage) return this.json({ code: 1, msg: '请先签名' });
if (type === 'income' && (!amount || Number(amount) <= 0)) {
return this.json({ code: 1, msg: '请填写有效的收入金额' });
}

try {
// 获取患者姓名
const user = await this.model('wechat_user').where({ id: mpUser.id, status: 1 }).find();
if (think.isEmpty(user) || !user.patient_id) return this.json({ code: 1, msg: '请先完成实名认证' });
const patient = await this.model('patient').field('name').where({ id: user.patient_id, is_deleted: 0 }).find();
if (think.isEmpty(patient)) return this.json({ code: 1, msg: '患者信息不存在' });

// 获取协议内容
const contentKey = 'sign_' + type;
const doc = await this.model('content').getByKey(contentKey);
if (think.isEmpty(doc)) return this.json({ code: 1, msg: '协议内容未配置' });

const signTime = think.datetime(new Date());

// 调用截图服务生成合成图
const screenshotService = this.service('screenshot');
const url = await screenshotService.generate({
title: doc.title,
content: doc.content,
signImageUrl: signImage,
signerName: patient.name,
signTime,
amount: type === 'income' ? amount : null
});

return this.json({ code: 0, data: { url }, msg: '签署成功' });
} catch (error) {
think.logger.error('sign error:', error);
return this.json({ code: 1, msg: '签署失败: ' + error.message });
}
}

// POST /api/mp/updateAvatar - 更新头像
async updateAvatarAction() {
const mpUser = this.mpUser;
if (!mpUser) return this.json({ code: 1009, msg: '请先登录' });
const { avatar } = this.post();
if (!avatar) return this.json({ code: 1, msg: '请上传头像' });
const now = think.datetime(new Date());
try {
await this.model('wechat_user').where({ id: mpUser.id }).update({ avatar, update_time: now });
return this.json({ code: 0, data: { avatar }, msg: '头像更新成功' });
} catch (error) {
think.logger.error('updateAvatar error:', error);
return this.json({ code: 1, msg: '更新失败: ' + error.message });
}
}

// GET /api/mp/messages - 消息列表
async messagesAction() {
const mpUser = this.mpUser;
if (!mpUser) return this.json({ code: 1009, msg: '请先登录' });
const { page = 1, pageSize = 20 } = this.get();
try {
const user = await this.model('wechat_user').where({ id: mpUser.id, status: 1 }).find();
if (think.isEmpty(user) || !user.patient_id) return this.json({ code: 0, data: { data: [], count: 0, totalPages: 0, currentPage: 1 } });
const list = await this.model('message')
.where({ patient_id: user.patient_id })
.order('id DESC')
.page(page, pageSize)
.countSelect();
return this.json({ code: 0, data: list });
} catch (error) {
think.logger.error('messages error:', error);
return this.json({ code: 1, msg: '获取消息失败' });
}
}

// GET /api/mp/messageDetail - 消息详情(同时标记已读)
async messageDetailAction() {
const mpUser = this.mpUser;
if (!mpUser) return this.json({ code: 1009, msg: '请先登录' });
const { id } = this.get();
if (!id) return this.json({ code: 1, msg: '参数错误' });
try {
const user = await this.model('wechat_user').where({ id: mpUser.id, status: 1 }).find();
if (think.isEmpty(user) || !user.patient_id) return this.json({ code: 1, msg: '无权访问' });
const msg = await this.model('message').where({ id, patient_id: user.patient_id }).find();
if (think.isEmpty(msg)) return this.json({ code: 1, msg: '消息不存在' });
// 标记已读
if (!msg.is_read) {
await this.model('message').where({ id }).update({ is_read: 1 });
}
// 查患者姓名
const patient = await this.model('patient').field('name').where({ id: user.patient_id }).find();
msg.patient_name = patient ? patient.name : '';
return this.json({ code: 0, data: msg });
} catch (error) {
think.logger.error('messageDetail error:', error);
return this.json({ code: 1, msg: '获取消息详情失败' });
}
}

// GET /api/mp/unreadCount - 未读消息数
async unreadCountAction() {
const mpUser = this.mpUser;
if (!mpUser) return this.json({ code: 1009, msg: '请先登录' });
try {
const user = await this.model('wechat_user').where({ id: mpUser.id, status: 1 }).find();
if (think.isEmpty(user) || !user.patient_id) return this.json({ code: 0, data: { count: 0 } });
const count = await this.model('message').where({ patient_id: user.patient_id, is_read: 0 }).count();
return this.json({ code: 0, data: { count } });
} catch (error) {
think.logger.error('unreadCount error:', error);
return this.json({ code: 0, data: { count: 0 } });
}
}

// GET /api/mp/subscribeConfig - 获取订阅消息模板配置
async subscribeConfigAction() {
try {
const wechatService = this.service('wechat');
const templates = await wechatService.getSubscribeTemplates(APP_REMARK);
return this.json({ code: 0, data: templates });
} catch (error) {
think.logger.error('subscribeConfig error:', error);
return this.json({ code: 0, data: {} });
}
}

// @private
async _verifyIdCard(name, idCard) {
const faceidConfig = require('../config/faceid.js');
try {
const tencentcloud = require('tencentcloud-sdk-nodejs');
const FaceidClient = tencentcloud.faceid.v20180301.Client;
const client = new FaceidClient({
credential: { secretId: faceidConfig.secretId, secretKey: faceidConfig.secretKey },
region: faceidConfig.region,
profile: { httpProfile: { endpoint: 'faceid.tencentcloudapi.com' } }
});
const result = await client.IdCardVerification({ IdCard: idCard, Name: name });
if (result.Result === '0') return { success: true, message: '身份核验通过' };
if (result.Result === '1') return { success: false, message: '姓名与身份证号不匹配' };
if (result.Result === '2') return { success: false, message: '身份证号格式错误' };
if (result.Result === '3') return { success: false, message: '姓名格式错误' };
return { success: false, message: result.Description || '身份核验失败' };
} catch (error) {
think.logger.error('[FaceId] verify error:', error);
return { success: false, message: error.message || '身份核验服务异常' };
}
}
};

+ 5
- 0
src/logic/index.js 파일 보기

@@ -0,0 +1,5 @@
module.exports = class extends think.Logic {
indexAction() {

}
};

+ 31
- 0
src/model/content.js 파일 보기

@@ -0,0 +1,31 @@
module.exports = class extends think.Model {
/**
* 分页列表
*/
async getList({ keyword, status, page = 1, pageSize = 10 }) {
const where = { is_deleted: 0 };
if (keyword) {
where._complex = {
title: ['like', `%${keyword}%`],
content_key: ['like', `%${keyword}%`],
_logic: 'or'
};
}
if (status !== undefined && status !== '') {
where.status = status;
}
return this.where(where)
.order('sort DESC, id DESC')
.page(page, pageSize)
.countSelect();
}

/**
* 根据 content_key 获取内容
*/
async getByKey(contentKey) {
return this.where({ content_key: contentKey, status: 1, is_deleted: 0 })
.field('title, content')
.find();
}
};

+ 3
- 0
src/model/index.js 파일 보기

@@ -0,0 +1,3 @@
module.exports = class extends think.Model {

};

+ 5
- 0
src/model/message.js 파일 보기

@@ -0,0 +1,5 @@
module.exports = class extends think.Model {
get tableName() {
return 'message';
}
};

+ 2
- 0
src/model/operation_log.js 파일 보기

@@ -0,0 +1,2 @@
module.exports = class extends think.Model {
};

+ 170
- 0
src/model/patient.js 파일 보기

@@ -0,0 +1,170 @@
// 简易雪花算法生成器(单进程适用)
let sequence = 0;
let lastTimestamp = -1;
function snowflakeId() {
let ts = Date.now();
if (ts === lastTimestamp) {
sequence = (sequence + 1) & 0xFFF;
if (sequence === 0) {
while (Date.now() <= ts) { /* wait */ }
ts = Date.now();
}
} else {
sequence = Math.floor(Math.random() * 16);
}
lastTimestamp = ts;
// 取时间戳后8位 + 序列号3位 = 11位数字
const tsStr = String(ts).slice(-8);
const seqStr = String(sequence).padStart(3, '0');
return tsStr + seqStr;
}

module.exports = class extends think.Model {
/**
* 生成患者编号: YYYYMM + 11位雪花ID
*/
generatePatientNo() {
const now = new Date();
const ym = String(now.getFullYear()) + String(now.getMonth() + 1).padStart(2, '0');
return ym + snowflakeId();
}

/**
* 获取患者列表(分页)
*/
async getList({ keyword, tag, status, startDate, endDate, province_code, city_code, district_code, page = 1, pageSize = 10 }) {
const where = this._buildWhere({ keyword, tag, status, startDate, endDate, province_code, city_code, district_code });
return this.where(where)
.order('id DESC')
.page(page, pageSize)
.countSelect();
}

/**
* 获取全部患者(不分页,用于导出)
*/
async getAll({ keyword, tag, status, startDate, endDate, province_code, city_code, district_code }) {
const where = this._buildWhere({ keyword, tag, status, startDate, endDate, province_code, city_code, district_code });
return this.where(where).order('id DESC').select();
}

/**
* 构建筛选条件
*/
_buildWhere({ keyword, tag, status, startDate, endDate, province_code, city_code, district_code }) {
const where = { is_deleted: 0 };

if (keyword) {
where._complex = {
name: ['like', `%${keyword}%`],
phone: ['like', `%${keyword}%`],
patient_no: ['like', `%${keyword}%`],
_logic: 'or'
};
}

if (tag !== undefined && tag !== '') {
if (tag === 'none') {
where.tag = ['=', ''];
} else {
where.tag = tag;
}
}

if (status !== undefined && status !== '') {
where.status = parseInt(status, 10);
}

if (startDate && endDate) {
where.create_time = ['between', `${startDate} 00:00:00`, `${endDate} 23:59:59`];
} else if (startDate) {
where.create_time = ['>=', `${startDate} 00:00:00`];
} else if (endDate) {
where.create_time = ['<=', `${endDate} 23:59:59`];
}

if (province_code) where.province_code = province_code;
if (city_code) where.city_code = city_code;
if (district_code) where.district_code = district_code;

return where;
}

/**
* 获取最近30天每日新增趋势(全部 + 直肠癌)
*/
async getDailyTrend(days = 30) {
const sql = `SELECT
DATE(create_time) AS date,
COUNT(*) AS total,
SUM(tag = '直肠癌') AS rectal
FROM patient WHERE is_deleted = 0
AND create_time >= DATE_SUB(CURDATE(), INTERVAL ${parseInt(days, 10)} DAY)
GROUP BY DATE(create_time) ORDER BY date ASC`;
return this.query(sql);
}

/**
* 获取最近提交记录
*/
async getRecentList(limit = 10) {
const list = await this.where({ is_deleted: 0 })
.order('id DESC')
.limit(limit)
.select();
// 脱敏
list.forEach(item => {
if (item.phone && item.phone.length === 11) {
item.phone_mask = item.phone.slice(0, 3) + '****' + item.phone.slice(-4);
} else {
item.phone_mask = item.phone || '';
}
});
return list;
}

/**
* 获取今日各状态新增数量
*/
async getTodayCounts() {
const sql = `SELECT
COUNT(*) AS \`all\`,
SUM(status = -1) AS draft,
SUM(status = 0) AS pending,
SUM(status = 1) AS approved,
SUM(status = 2) AS rejected
FROM patient WHERE is_deleted = 0
AND DATE(create_time) = CURDATE()`;
const rows = await this.query(sql);
const row = rows[0] || {};
return {
all: parseInt(row.all, 10) || 0,
draft: parseInt(row.draft, 10) || 0,
pending: parseInt(row.pending, 10) || 0,
approved: parseInt(row.approved, 10) || 0,
rejected: parseInt(row.rejected, 10) || 0
};
}

/**
* 获取各状态数量(单次查询)
*/
async getStatusCounts() {
const sql = `SELECT
COUNT(*) AS \`all\`,
SUM(status = -1) AS draft,
SUM(status = 0) AS pending,
SUM(status = 1) AS approved,
SUM(status = 2) AS rejected
FROM patient WHERE is_deleted = 0`;
const rows = await this.query(sql);
const row = rows[0] || {};
return {
all: parseInt(row.all, 10) || 0,
draft: parseInt(row.draft, 10) || 0,
pending: parseInt(row.pending, 10) || 0,
approved: parseInt(row.approved, 10) || 0,
rejected: parseInt(row.rejected, 10) || 0
};
}
};

+ 5
- 0
src/model/patient_audit.js 파일 보기

@@ -0,0 +1,5 @@
module.exports = class extends think.Model {
get tableName() {
return 'patient_audit';
}
};

+ 5
- 0
src/model/sms_log.js 파일 보기

@@ -0,0 +1,5 @@
module.exports = class extends think.Model {
get tableName() {
return 'sms_log';
}
};

+ 9
- 0
src/model/sys_region.js 파일 보기

@@ -0,0 +1,9 @@
module.exports = class extends think.Model {
get tableName() {
return 'sys_region';
}

get pk() {
return 'code';
}
};

+ 19
- 0
src/model/wechat_user.js 파일 보기

@@ -0,0 +1,19 @@
module.exports = class extends think.Model {
get tableName() {
return 'wechat_user';
}

/**
* 根据 openid + app_remark 查找用户
*/
async findByOpenId(openId, appRemark) {
return this.where({ open_id: openId, app_remark: appRemark }).find();
}

/**
* 创建微信用户
*/
async createUser(data) {
return this.add(data);
}
};

+ 165
- 0
src/service/screenshot.js 파일 보기

@@ -0,0 +1,165 @@
const COS = require('cos-nodejs-sdk-v5');
const cosConfig = require('../config/cos.js');

module.exports = class extends think.Service {
/**
* 生成签署合成图
* @param {Object} params
* @param {string} params.title - 协议标题
* @param {string} params.content - 协议富文本内容
* @param {string} params.signImageUrl - 签名图片URL
* @param {string} params.signerName - 签署人姓名
* @param {string} params.signTime - 签署时间
* @param {number} [params.amount] - 收入金额(仅income类型)
* @returns {string} 合成图COS URL
*/
async generate({ title, content, signImageUrl, signerName, signTime, amount }) {
const puppeteer = require('puppeteer');

const html = this._buildHtml({ title, content, signImageUrl, signerName, signTime, amount });

let browser;
try {
browser = await puppeteer.launch({
headless: 'new',
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-gpu',
'--disable-dev-shm-usage',
'--font-render-hinting=none'
]
});

const page = await browser.newPage();
await page.setViewport({ width: 750, height: 1000, deviceScaleFactor: 2 });
await page.setContent(html, { waitUntil: 'networkidle0', timeout: 15000 });

// 等签名图片加载
await page.waitForSelector('.sign-img', { timeout: 5000 }).catch(() => {});

const screenshot = await page.screenshot({ fullPage: true, type: 'png' });
await browser.close();
browser = null;

// 上传到 COS
const url = await this._uploadToCos(screenshot);
return url;
} catch (error) {
if (browser) await browser.close().catch(() => {});
throw error;
}
}

/**
* 构建 HTML 模板
*/
_buildHtml({ title, content, signImageUrl, signerName, signTime, amount }) {
const amountHtml = amount ? `
<div style="margin: 30px 0; padding: 20px; background: #f8f9fa; border-radius: 8px; border: 1px solid #e9ecef;">
<span style="font-size: 28px; color: #555;">个人月可支配收入:</span>
<span style="font-size: 32px; color: #0e63e3; font-weight: 600;">¥${amount}</span>
</div>` : '';

return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
width: 750px;
padding: 60px;
font-family: "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif;
background: #fff;
color: #333;
}
.title {
text-align: center;
font-size: 36px;
font-weight: bold;
margin-bottom: 40px;
color: #222;
}
.content {
font-size: 28px;
line-height: 2;
color: #444;
margin-bottom: 30px;
}
.content p {
margin-bottom: 16px;
text-indent: 2em;
}
.sign-area {
margin-top: 50px;
border-top: 2px solid #eee;
padding-top: 30px;
}
.sign-row {
display: flex;
align-items: center;
margin-bottom: 20px;
}
.sign-label {
font-size: 26px;
color: #666;
width: 120px;
flex-shrink: 0;
}
.sign-img {
height: 100px;
}
.sign-info {
font-size: 24px;
color: #888;
margin-bottom: 10px;
}
</style>
</head>
<body>
<div class="title">${title}</div>
<div class="content">${content}</div>
${amountHtml}
<div class="sign-area">
<div class="sign-row">
<span class="sign-label">签名:</span>
<img class="sign-img" src="${signImageUrl}" />
</div>
<div class="sign-info">签署人:${signerName}</div>
<div class="sign-info">签署时间:${signTime}</div>
</div>
</body>
</html>`;
}

/**
* 上传截图到 COS
*/
async _uploadToCos(buffer) {
const cos = new COS({ SecretId: cosConfig.secretId, SecretKey: cosConfig.secretKey });
const now = new Date();
const dateFolder = `${now.getFullYear()}/${String(now.getMonth() + 1).padStart(2, '0')}/${String(now.getDate()).padStart(2, '0')}`;
const fileName = `sign_${Date.now()}.png`;
const cosKey = `signs/${dateFolder}/${fileName}`;

const result = await new Promise((resolve, reject) => {
cos.putObject({
Bucket: cosConfig.bucket,
Region: cosConfig.region,
Key: cosKey,
Body: buffer,
ContentType: 'image/png'
}, (err, data) => {
if (err) reject(err);
else resolve(data);
});
});

if (result.statusCode === 200) {
const bucketUrl = `https://${result.Location}`;
return bucketUrl.replace(cosConfig.bucketUrl, cosConfig.cdnUrl);
}
throw new Error('截图上传COS失败');
}
};

+ 212
- 0
src/service/wechat.js 파일 보기

@@ -0,0 +1,212 @@
const dayjs = require('dayjs');
const axios = require('axios');

module.exports = class extends think.Service {
/**
* 刷新单个应用的 access_token
* @param {Object} app 应用配置
* @returns {Object} 刷新结果
*/
async refreshToken(app) {
const now = dayjs().unix();
const diffSeconds = app.token_expires_at ? app.token_expires_at - now : 0;

// 大于10分钟(600秒)跳过
if (diffSeconds > 600) {
return {
success: true,
app_name: app.app_name,
skipped: true,
expires_in: Math.floor(diffSeconds / 60) + '分钟'
};
}

// 调用稳定版接口
const url = 'https://api.weixin.qq.com/cgi-bin/stable_token';
try {
const { data: res } = await axios.post(url, {
grant_type: 'client_credential',
appid: app.app_id,
secret: app.app_secret,
force_refresh: diffSeconds <= 600 && diffSeconds > 0
});

if (res.access_token) {
const tokenExpiresAt = dayjs().unix() + res.expires_in;
await this.model('sys_wechat_app').where({ id: app.id }).update({
access_token: res.access_token,
token_expires_at: tokenExpiresAt
});
return {
success: true,
app_name: app.app_name,
refreshed: true,
expires_at: dayjs.unix(tokenExpiresAt).format('YYYY-MM-DD HH:mm:ss')
};
}
return { success: false, app_name: app.app_name, error: res.errmsg || '未知错误' };
} catch (error) {
think.logger.error(`刷新token失败[${app.app_name}]:`, error);
return { success: false, app_name: app.app_name, error: error.message };
}
}

/**
* 刷新所有启用应用的 token
* @returns {Array} 刷新结果列表
*/
async refreshAllTokens() {
const apps = await this.model('sys_wechat_app').where({ status: 1 }).select();
const results = [];
for (const app of apps) {
const result = await this.refreshToken(app);
results.push(result);
}
return results;
}

/**
* 检查并刷新过期的 token
* @param {Object} app 应用配置
* @returns {String|null} access_token
*/
async checkAndRefreshToken(app) {
if (think.isEmpty(app)) return null;

const now = dayjs().unix();
// token 不存在或已过期(小于1分钟视为过期)
if (!app.access_token || !app.token_expires_at || app.token_expires_at - now < 60) {
const result = await this.refreshToken(app);
if (result.success && result.refreshed) {
// 重新查询获取最新 token
const updated = await this.model('sys_wechat_app').where({ id: app.id }).find();
return updated.access_token;
}
return null;
}
return app.access_token;
}

/**
* 获取 access_token(供其他模块调用)
* @param {String} appId 应用AppID
* @returns {String|null} access_token
*/
async getAccessToken(appId) {
const app = await this.model('sys_wechat_app').where({ app_id: appId, status: 1 }).find();
return this.checkAndRefreshToken(app);
}

/**
* 根据 remark 获取 access_token
* @param {String} remark 备注标识,如 pap_mini
* @returns {String|null} access_token
*/
async getAccessTokenByRemark(remark) {
const app = await this.model('sys_wechat_app').where({ remark, status: 1 }).find();
return this.checkAndRefreshToken(app);
}

/**
* 用 code 换取 openid 和 session_key(通用方法)
* @param {String} code 微信登录 code
* @param {String} remark 应用标识,如 pap_mini、pap_mini_doctor
* @returns {Object} { openid, session_key, unionid }
*/
async code2Session(code, remark) {
const app = await this.model('sys_wechat_app').where({ remark, status: 1 }).find();
if (think.isEmpty(app)) {
throw new Error(`未找到应用配置: ${remark}`);
}

const url = `https://api.weixin.qq.com/sns/jscode2session?appid=${app.app_id}&secret=${app.app_secret}&js_code=${code}&grant_type=authorization_code`;

const { data } = await axios.get(url, { timeout: 10000 });

if (data.errcode) {
think.logger.error('code2Session失败:', data);
throw new Error(data.errmsg || '获取openid失败');
}

return {
openid: data.openid,
session_key: data.session_key,
unionid: data.unionid || null
};
}

/**
* 获取应用的订阅消息模板配置
* @param {String} remark 应用标识
* @returns {Object} 模板配置 { audit_result: 'xxx', ... }
*/
async getSubscribeTemplates(remark) {
const app = await this.model('sys_wechat_app').where({ remark, status: 1 }).find();
if (think.isEmpty(app) || !app.subscribe_templates) return {};
try {
return JSON.parse(app.subscribe_templates);
} catch (e) {
return {};
}
}

/**
* 发送订阅消息
* @param {Object} options
* @param {String} options.remark 应用标识
* @param {String} options.openid 接收者openid
* @param {String} options.templateId 模板ID
* @param {String} options.page 跳转页面
* @param {Object} options.data 模板数据
* @param {String} options.miniprogramState 小程序版本: developer/trial/formal,默认formal
* @returns {Object} { success, error }
*/
async sendSubscribeMessage({ remark, openid, templateId, page, data, miniprogramState }) {
try {
const accessToken = await this.getAccessTokenByRemark(remark);
if (!accessToken) {
think.logger.error('[Subscribe] 获取access_token失败');
return { success: false, error: '获取access_token失败' };
}
const url = `https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token=${accessToken}`;
const body = {
touser: openid,
template_id: templateId,
page: page || '',
data,
miniprogram_state: miniprogramState || 'formal'
};
const { data: res } = await axios.post(url, body, { timeout: 10000 });
if (res.errcode === 0) {
think.logger.info(`[Subscribe] 发送成功 openid=${openid} tmpl=${templateId}`);
return { success: true };
}
think.logger.error(`[Subscribe] 发送失败:`, res);
return { success: false, error: res.errmsg || '发送失败' };
} catch (error) {
think.logger.error('[Subscribe] 发送异常:', error);
return { success: false, error: error.message };
}
}

/**
* 用 phoneCode 换取用户手机号
* @param {String} phoneCode - 前端 getPhoneNumber 回调中的 code
* @param {String} remark - 应用标识,如 pap_mini
* @returns {Object} { phoneNumber, purePhoneNumber, countryCode }
*/
async getPhoneNumber(phoneCode, remark) {
const accessToken = await this.getAccessTokenByRemark(remark);
if (!accessToken) {
throw new Error('获取access_token失败');
}
const url = `https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=${accessToken}`;
const { data } = await axios.post(url, { code: phoneCode }, { timeout: 10000 });
if (data.errcode) {
think.logger.error('getPhoneNumber失败:', data);
throw new Error(data.errmsg || '获取手机号失败');
}
return data.phone_info;
}

};

+ 7
- 0
test/index.js 파일 보기

@@ -0,0 +1,7 @@
const test = require('ava');
const path = require('path');
require(path.join(process.cwd(), 'production.js'));

test('first test', t => {
const indexModel = think.model('index');
})

+ 277
- 0
view/admin/auth_login.html 파일 보기

@@ -0,0 +1,277 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录 - 肠愈同行患者关爱</title>
<link rel="icon" href="/static/favicon.ico" type="image/x-icon">
<style>
*,*::before,*::after{margin:0;padding:0;box-sizing:border-box;}
:root{
--primary:#E8751A;
--primary-dark:#C96012;
--primary-light:#F5A623;
--blue:#1A3550;
--white:#fff;
--bg:#f0f2f5;
--text:#303133;
--text-secondary:#909399;
--border:#dcdfe6;
--danger:#f56c6c;
--font-cn:"Source Han Sans SC","Noto Sans SC","PingFang SC","Microsoft YaHei",sans-serif;
}
html,body{height:100%;font-family:var(--font-cn);color:var(--text);}
.login-wrapper{display:flex;height:100vh;}
.login-left{
flex:0 0 50%;display:flex;flex-direction:column;align-items:center;justify-content:center;
background:linear-gradient(135deg,var(--primary) 0%,var(--primary-dark) 100%);
position:relative;overflow:hidden;
}
.login-left::before{
content:"";position:absolute;width:600px;height:600px;border-radius:50%;
background:rgba(255,255,255,.06);top:-120px;right:-180px;
}
.login-left::after{
content:"";position:absolute;width:400px;height:400px;border-radius:50%;
background:rgba(255,255,255,.04);bottom:-80px;left:-100px;
}
.brand{position:relative;z-index:1;text-align:center;color:var(--white);padding:0 60px;}
.brand-logo{width:120px;height:120px;border-radius:50%;background:rgba(255,255,255,.95);
display:flex;align-items:center;justify-content:center;margin:0 auto 28px;
box-shadow:0 4px 24px rgba(0,0,0,.1);}
.brand-logo img{width:80px;height:80px;object-fit:contain;}
.brand-logo.no-img{background:rgba(255,255,255,.15);backdrop-filter:blur(10px);}
.brand-logo.no-img svg{width:44px;height:44px;}
.brand h1{font-size:28px;font-weight:700;margin-bottom:12px;letter-spacing:1px;}
.brand p{font-size:14px;opacity:.75;line-height:1.8;max-width:360px;}
.login-right{flex:1;display:flex;align-items:center;justify-content:center;background:var(--bg);}
.login-box{
width:400px;background:var(--white);border-radius:12px;
box-shadow:0 2px 12px rgba(0,0,0,.08);padding:48px 40px;
animation:fadeUp .5s ease both;
}
@keyframes fadeUp{from{opacity:0;transform:translateY(24px);}to{opacity:1;transform:translateY(0);}}
.login-box h2{font-size:22px;font-weight:600;color:var(--text);margin-bottom:6px;}
.login-box .subtitle{font-size:13px;color:var(--text-secondary);margin-bottom:32px;}
</style>
</head>
<body>
<div style="display:none;">
<!-- split styles to avoid autofix issues -->
</div>
<style>
.form-item{margin-bottom:22px;}
.form-item label{display:block;font-size:13px;color:var(--text);font-weight:500;margin-bottom:8px;}
.input-wrap{
position:relative;display:flex;align-items:center;
border:1px solid var(--border);border-radius:8px;transition:.2s;background:var(--white);
}
.input-wrap:focus-within{border-color:var(--primary);box-shadow:0 0 0 3px rgba(232,117,26,.1);}
.input-wrap .icon{position:absolute;left:12px;color:var(--text-secondary);display:flex;align-items:center;}
.input-wrap .icon svg{width:18px;height:18px;}
.input-wrap input{
width:100%;border:none;outline:none;padding:11px 12px 11px 40px;
font-size:14px;font-family:var(--font-cn);color:var(--text);background:transparent;border-radius:8px;
}
.input-wrap input::placeholder{color:#c0c4cc;}
.input-wrap .toggle-pwd{
position:absolute;right:12px;cursor:pointer;color:var(--text-secondary);
display:flex;align-items:center;background:none;border:none;padding:0;
}
.input-wrap .toggle-pwd:hover{color:var(--primary);}
.input-wrap .toggle-pwd svg{width:18px;height:18px;}
.captcha-row{display:flex;gap:10px;align-items:center;}
.captcha-row .input-wrap{flex:1;}
.captcha-img{height:40px;border-radius:8px;cursor:pointer;border:1px solid var(--border);flex-shrink:0;}
.form-options{display:flex;align-items:center;justify-content:space-between;margin-bottom:28px;}
.remember{display:flex;align-items:center;gap:6px;cursor:pointer;font-size:13px;color:var(--text-secondary);}
.remember input[type="checkbox"]{width:16px;height:16px;accent-color:var(--primary);cursor:pointer;}
.btn-login{
width:100%;padding:12px 0;border:none;border-radius:8px;
background:linear-gradient(135deg,var(--primary),var(--primary-dark));
color:var(--white);font-size:15px;font-weight:600;cursor:pointer;
transition:.25s;letter-spacing:1px;font-family:var(--font-cn);
display:flex;align-items:center;justify-content:center;gap:8px;
}
.btn-login:hover{transform:translateY(-1px);box-shadow:0 6px 20px rgba(232,117,26,.35);}
.btn-login:active{transform:translateY(0);}
.btn-login:disabled{opacity:.6;cursor:not-allowed;transform:none;box-shadow:none;}
.btn-login .spinner{
width:16px;height:16px;border:2px solid rgba(255,255,255,.3);
border-top-color:#fff;border-radius:50%;animation:spin .6s linear infinite;display:none;
}
.btn-login.loading .spinner{display:block;}
@keyframes spin{to{transform:rotate(360deg);}}
.login-footer{text-align:center;margin-top:24px;font-size:12px;color:var(--text-secondary);}
.error-msg{color:var(--danger);font-size:13px;margin-bottom:16px;display:none;}
.error-msg.show{display:block;}
@media(max-width:960px){
.login-left{display:none;}
.login-right{background:linear-gradient(135deg,#fdf0e6 0%,var(--bg) 100%);}
}
@media(max-width:480px){
.login-box{width:100%;margin:0 20px;padding:36px 28px;}
}
</style>

<div class="login-wrapper">
<div class="login-left">
<div class="brand">
<div class="brand-logo">
<img src="/static/images/logo.png" alt="肠愈同行患者关爱">
</div>
<h1>肠愈同行患者关爱</h1>
<p>患者关爱管理平台<br>为患者提供全流程关爱服务</p>
</div>
</div>

<div class="login-right">
<div class="login-box">
<h2>欢迎回来</h2>
<p class="subtitle">请登录您的管理员账号</p>

<form id="loginForm">
<div class="error-msg" id="errorMsg"></div>

<div class="form-item">
<label>用户名</label>
<div class="input-wrap">
<span class="icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
</span>
<input type="text" name="username" id="username" placeholder="请输入用户名" autocomplete="username" required>
</div>
</div>

<div class="form-item">
<label>密码</label>
<div class="input-wrap">
<span class="icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
</span>
<input type="password" name="password" id="password" placeholder="请输入密码" autocomplete="current-password" required>
<button type="button" class="toggle-pwd" onclick="togglePassword()" aria-label="显示密码">
<svg id="eyeIcon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
</div>
</div>

<div class="form-item">
<label>验证码</label>
<div class="captcha-row">
<div class="input-wrap">
<span class="icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M9 9h.01M15 15h.01M9 15l6-6"/></svg>
</span>
<input type="text" name="captcha" id="captcha" placeholder="请输入验证码" autocomplete="off" maxlength="4" required>
</div>
<img class="captcha-img" id="captchaImg" src="/admin/auth/captcha" alt="验证码" title="点击刷新" onclick="this.src='/admin/auth/captcha?t='+Date.now()">
</div>
</div>

<div class="form-options">
<label class="remember">
<input type="checkbox" name="remember" id="remember"> 记住我
</label>
</div>

<button type="submit" class="btn-login" id="submitBtn">
<span class="spinner"></span>
<span class="btn-text">登 录</span>
</button>
</form>

<div class="login-footer">
© {{ now_year }} 肠愈同行患者关爱
</div>
</div>
</div>
</div>

<script>
var REMEMBER_KEY = 'cytx_admin_remember';

// 页面加载时回填记住的账号密码
(function() {
try {
var saved = localStorage.getItem(REMEMBER_KEY);
if (saved) {
var data = JSON.parse(saved);
if (data.username) document.getElementById('username').value = data.username;
if (data.password) document.getElementById('password').value = data.password;
document.getElementById('remember').checked = true;
}
} catch(e) {}
})();

function togglePassword(){
var input = document.getElementById('password');
input.type = input.type === 'password' ? 'text' : 'password';
}

function refreshCaptcha(){
document.getElementById('captchaImg').src = '/admin/auth/captcha?t=' + Date.now();
}

document.getElementById('loginForm').addEventListener('submit', function(e) {
e.preventDefault();
var btn = document.getElementById('submitBtn');
var errorMsg = document.getElementById('errorMsg');
var username = document.getElementById('username').value.trim();
var password = document.getElementById('password').value;
var captcha = document.getElementById('captcha').value.trim();
var remember = document.getElementById('remember').checked;

if (!username || !password) {
errorMsg.textContent = '请输入用户名和密码';
errorMsg.classList.add('show');
return;
}
if (!captcha) {
errorMsg.textContent = '请输入验证码';
errorMsg.classList.add('show');
return;
}

btn.disabled = true;
btn.classList.add('loading');
btn.querySelector('.btn-text').textContent = '登录中...';
errorMsg.classList.remove('show');

fetch('/admin/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: username, password: password, captcha: captcha, remember: remember })
}).then(function(res) {
return res.json();
}).then(function(data) {
if (data.code === 0) {
// 登录成功:记住我则存 localStorage,否则清除
if (remember) {
localStorage.setItem(REMEMBER_KEY, JSON.stringify({ username: username, password: password }));
} else {
localStorage.removeItem(REMEMBER_KEY);
}
window.location.href = '/admin/dashboard.html';
} else {
errorMsg.textContent = data.msg || '登录失败';
errorMsg.classList.add('show');
refreshCaptcha();
document.getElementById('captcha').value = '';
btn.disabled = false;
btn.classList.remove('loading');
btn.querySelector('.btn-text').textContent = '登 录';
}
}).catch(function() {
errorMsg.textContent = '网络错误,请稍后重试';
errorMsg.classList.add('show');
refreshCaptcha();
btn.disabled = false;
btn.classList.remove('loading');
btn.querySelector('.btn-text').textContent = '登 录';
});
});
</script>

</body>
</html>

+ 70
- 0
view/admin/common/_header.html 파일 보기

@@ -0,0 +1,70 @@
<header class="top-header">
<div class="header-left">
<div class="breadcrumb">
{% if currentPage == 'dashboard' %}
<span class="current">控制台</span>
{% else %}
<a href="/admin/dashboard.html">控制台</a>
{% if breadcrumb %}
{% for item in breadcrumb %}
<span class="sep">/</span>
{% if loop.last %}
<span class="current">{{ item.name }}</span>
{% else %}
<a href="{{ item.url }}">{{ item.name }}</a>
{% endif %}
{% endfor %}
{% elif pageTitle or (columnInfo and columnInfo.name) %}
<span class="sep">/</span>
<span class="current">{{ pageTitle or columnInfo.name }}</span>
{% endif %}
{% endif %}
</div>
</div>
<div class="header-right">
<button class="header-btn" title="刷新" onclick="location.reload()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
</button>
<button class="header-btn" title="全屏" onclick="toggleFullscreen()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
</button>
<div class="header-user" id="userDropdown">
<div class="header-avatar" onclick="document.getElementById('userDropdown').classList.toggle('open')">
<div class="avatar-placeholder">{{ adminUser.nickname[0] if adminUser.nickname else '管' }}</div>
<span>{{ adminUser.nickname or adminUser.username or '管理员' }}</span>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" style="margin-left:4px;"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="user-dropdown-menu">
<a href="javascript:void(0)" class="dropdown-item dropdown-item-danger" onclick="confirmLogout()">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
退出登录
</a>
</div>
</div>
</div>
</header>
<script>
function toggleFullscreen() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
} else {
document.exitFullscreen();
}
}
function confirmLogout() {
ElementPlus.ElMessageBox.confirm('确定要退出登录吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(function() {
window.location.href = '/admin/logout.html';
}).catch(function() {});
}
// 点击外部关闭下拉
document.addEventListener('click', function(e) {
var dd = document.getElementById('userDropdown');
if (dd && !dd.contains(e.target)) {
dd.classList.remove('open');
}
});
</script>

+ 67
- 0
view/admin/common/_sidebar.html 파일 보기

@@ -0,0 +1,67 @@
<aside class="sidebar" id="sidebarApp">
<div class="sidebar-logo" onclick="window.location.href='/admin/dashboard.html'" style="cursor:pointer;">
<img src="/static/images/logo.png" alt="肠愈同行">
<span class="sidebar-title">肠愈同行患者关爱</span>
</div>
<nav class="sidebar-menu">
{# 控制台 #}
<div class="menu-group">
<a class="menu-item {% if currentPage == 'dashboard' %}active{% endif %}" href="/admin/dashboard.html">
<span class="menu-icon"><svg viewBox="0 0 1024 1024" width="18" height="18"><path d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm0 820c-205.4 0-372-166.6-372-372s166.6-372 372-372 372 166.6 372 372-166.6 372-372 372z" fill="currentColor"/><path d="M686.7 638.6L544.1 535.5V288c0-4.4-3.6-8-8-8H488c-4.4 0-8 3.6-8 8v275.4c0 2.6 1.2 5 3.3 6.5l165.4 120.6c3.6 2.6 8.6 1.8 11.2-1.7l28.6-39c2.6-3.7 1.8-8.7-1.8-11.2z" fill="currentColor"/></svg></span>
<span>控制台</span>
</a>
</div>

{# 患者管理 #}
{% if isSuperAdmin or (userPermissions and 'patient' in userPermissions) %}
<div class="menu-group">
<a class="menu-item {% if currentPage == 'patient' %}active{% endif %}" href="/admin/patient.html">
<span class="menu-icon"><svg viewBox="0 0 1024 1024" width="18" height="18"><path d="M784 352H240c-17.7 0-32 14.3-32 32v480c0 17.7 14.3 32 32 32h544c17.7 0 32-14.3 32-32V384c0-17.7-14.3-32-32-32zM512 704c-53 0-96-43-96-96s43-96 96-96 96 43 96 96-43 96-96 96z" fill="currentColor"/><path d="M512 64C335.3 64 192 207.3 192 384h64c0-141.2 114.8-256 256-256s256 114.8 256 256h64c0-176.7-143.3-320-320-320z" fill="currentColor"/></svg></span>
<span>患者管理</span>
</a>
</div>
{% endif %}

{# 内容管理 #}
{% if isSuperAdmin or (userPermissions and 'content' in userPermissions) %}
<div class="menu-group">
<a class="menu-item {% if currentPage == 'content' %}active{% endif %}" href="/admin/content.html">
<span class="menu-icon"><svg viewBox="0 0 1024 1024" width="18" height="18"><path d="M832 64H192c-17.7 0-32 14.3-32 32v832c0 17.7 14.3 32 32 32h640c17.7 0 32-14.3 32-32V96c0-17.7-14.3-32-32-32zm-40 824H232V136h560v752z" fill="currentColor"/><path d="M320 304h384c8.8 0 16-7.2 16-16v-32c0-8.8-7.2-16-16-16H320c-8.8 0-16 7.2-16 16v32c0 8.8 7.2 16 16 16zm0 192h384c8.8 0 16-7.2 16-16v-32c0-8.8-7.2-16-16-16H320c-8.8 0-16 7.2-16 16v32c0 8.8 7.2 16 16 16zm0 192h256c8.8 0 16-7.2 16-16v-32c0-8.8-7.2-16-16-16H320c-8.8 0-16 7.2-16 16v32c0 8.8 7.2 16 16 16z" fill="currentColor"/></svg></span>
<span>内容管理</span>
</a>
</div>
{% endif %}

{# 系统设置 #}
{% set hasSysPerm = isSuperAdmin or (userPermissions and ('setting:system:user' in userPermissions or 'setting:system:role' in userPermissions or 'setting:system:log' in userPermissions or 'setting:system:sms' in userPermissions)) %}
{% if hasSysPerm %}
<div class="menu-group">
<div class="menu-group-title">系统管理</div>
{% if isSuperAdmin or (userPermissions and 'setting:system:user' in userPermissions) %}
<a class="menu-item {% if currentPage == 'sys-user' %}active{% endif %}" href="/admin/system/user.html">
<span class="menu-icon"><svg viewBox="0 0 1024 1024" width="18" height="18"><path d="M858.5 763.6a374 374 0 0 0-80.6-119.5 375.63 375.63 0 0 0-119.5-80.6c-.4-.2-.8-.3-1.2-.5C719.5 518 760 444.7 760 362c0-137-111-248-248-248S264 225 264 362c0 82.7 40.5 156 102.8 201.1-.4.2-.8.3-1.2.5-44.8 18.9-85 46-119.5 80.6a375.63 375.63 0 0 0-80.6 119.5A371.7 371.7 0 0 0 136 901.8a8 8 0 0 0 8 8.2h60c4.4 0 7.9-3.5 8-7.8 4.4-77.2 33.4-149.5 87.8-204.3 56.7-56.7 132-87.9 212.2-87.9s155.5 31.2 212.2 87.9c54.4 54.8 83.4 127.1 87.8 204.3.1 4.4 3.6 7.8 8 7.8h60a8 8 0 0 0 8-8.2c-1-47.8-10.9-94.3-29.5-138.2zM512 534c-45.9 0-89.1-17.9-121.6-50.4S340 407.9 340 362c0-45.9 17.9-89.1 50.4-121.6S466.1 190 512 190s89.1 17.9 121.6 50.4S684 316.1 684 362c0 45.9-17.9 89.1-50.4 121.6S557.9 534 512 534z" fill="currentColor"/></svg></span>
<span>用户管理</span>
</a>
{% endif %}
{% if isSuperAdmin or (userPermissions and 'setting:system:role' in userPermissions) %}
<a class="menu-item {% if currentPage == 'sys-role' %}active{% endif %}" href="/admin/system/role.html">
<span class="menu-icon"><svg viewBox="0 0 1024 1024" width="18" height="18"><path d="M832 464H192c-17.7 0-32 14.3-32 32v352c0 17.7 14.3 32 32 32h640c17.7 0 32-14.3 32-32V496c0-17.7-14.3-32-32-32zm-40 344H232V536h560v272z" fill="currentColor"/><path d="M512 624c-35.3 0-64 28.7-64 64s28.7 64 64 64 64-28.7 64-64-28.7-64-64-64z" fill="currentColor"/><path d="M736 464V288c0-123.7-100.3-224-224-224S288 164.3 288 288v176h64V288c0-88.2 71.8-160 160-160s160 71.8 160 160v176h64z" fill="currentColor"/></svg></span>
<span>角色权限</span>
</a>
{% endif %}
{% if isSuperAdmin or (userPermissions and 'setting:system:log' in userPermissions) %}
<a class="menu-item {% if currentPage == 'sys-log' %}active{% endif %}" href="/admin/system/log.html">
<span class="menu-icon"><svg viewBox="0 0 1024 1024" width="18" height="18"><path d="M880 112H144c-17.7 0-32 14.3-32 32v736c0 17.7 14.3 32 32 32h736c17.7 0 32-14.3 32-32V144c0-17.7-14.3-32-32-32zm-40 728H184V184h656v656z" fill="currentColor"/><path d="M312 544h400c4.4 0 8-3.6 8-8v-48c0-4.4-3.6-8-8-8H312c-4.4 0-8 3.6-8 8v48c0 4.4 3.6 8 8 8zm0 160h400c4.4 0 8-3.6 8-8v-48c0-4.4-3.6-8-8-8H312c-4.4 0-8 3.6-8 8v48c0 4.4 3.6 8 8 8zm0-320h400c4.4 0 8-3.6 8-8v-48c0-4.4-3.6-8-8-8H312c-4.4 0-8 3.6-8 8v48c0 4.4 3.6 8 8 8z" fill="currentColor"/></svg></span>
<span>操作日志</span>
</a>
{% endif %}
{% if isSuperAdmin or (userPermissions and 'setting:system:sms' in userPermissions) %}
<a class="menu-item {% if currentPage == 'sys-sms' %}active{% endif %}" href="/admin/system/sms.html">
<span class="menu-icon"><svg viewBox="0 0 1024 1024" width="18" height="18"><path d="M880 112H144c-17.7 0-32 14.3-32 32v736c0 17.7 14.3 32 32 32h736c17.7 0 32-14.3 32-32V144c0-17.7-14.3-32-32-32zm-40 728H184V184h656v656z" fill="currentColor"/><path d="M460 560h104c4.4 0 8-3.6 8-8v-48c0-4.4-3.6-8-8-8H460c-4.4 0-8 3.6-8 8v48c0 4.4 3.6 8 8 8z" fill="currentColor"/><path d="M348 416h328c4.4 0 8-3.6 8-8v-48c0-4.4-3.6-8-8-8H348c-4.4 0-8 3.6-8 8v48c0 4.4 3.6 8 8 8zm0 288h328c4.4 0 8-3.6 8-8v-48c0-4.4-3.6-8-8-8H348c-4.4 0-8 3.6-8 8v48c0 4.4 3.6 8 8 8z" fill="currentColor"/></svg></span>
<span>短信记录</span>
</a>
{% endif %}
</div>
{% endif %}
</nav>
</aside>

+ 301
- 0
view/admin/content_index.html 파일 보기

@@ -0,0 +1,301 @@
{% extends "./layout.html" %}

{% block title %}内容管理{% endblock %}

{% block css %}
<link rel="stylesheet" href="/static/lib/wangeditor/css/style.css">
<style>
.editor-toolbar { border: 1px solid #dcdfe6; border-bottom: none; border-radius: 4px 4px 0 0; }
.editor-container { border: 1px solid #dcdfe6; border-radius: 0 0 4px 4px; min-height: 300px; }
/* 手机模拟框 */
.phone-frame {
width: 375px; height: 720px; margin: 0 auto;
border: 8px solid #1a1a1a; border-radius: 40px;
background: #f5f5f5; position: relative; overflow: hidden;
box-shadow: 0 8px 32px rgba(0,0,0,0.15);
}
.phone-notch {
width: 120px; height: 24px; background: #1a1a1a;
border-radius: 0 0 16px 16px; margin: 0 auto;
position: relative; z-index: 2;
}
.phone-screen {
height: calc(100% - 24px); overflow-y: auto; background: #fff;
}
.phone-screen::-webkit-scrollbar { width: 0; }
/* 预览内容样式 */
.preview-header {
padding: 16px 16px 12px; border-bottom: 1px solid #f0f0f0;
}
.preview-title { font-size: 18px; font-weight: 600; color: #303133; line-height: 1.5; }
.preview-body { padding: 16px; font-size: 14px; color: #606266; line-height: 1.8; }
.preview-body img { max-width: 100% !important; height: auto !important; }
.preview-body p { margin-bottom: 12px; }
.preview-body p:last-child { margin-bottom: 0; }
</style>
{% endblock %}

{% block content %}
<div id="contentApp" v-cloak>
<!-- 搜索栏 -->
<el-card shadow="never" class="mb-4">
<el-form :inline="true" @submit.prevent="loadList()" class="flex items-center flex-wrap gap-2">
<el-form-item label="关键字" class="!mb-0">
<el-input v-model="keyword" placeholder="标题/标识" clearable style="width:180px;" />
</el-form-item>
<el-form-item label="状态" class="!mb-0">
<el-select v-model="statusFilter" placeholder="全部" clearable style="width:120px;">
<el-option label="启用" :value="1"></el-option>
<el-option label="禁用" :value="0"></el-option>
</el-select>
</el-form-item>
<el-form-item class="!mb-0">
<el-button type="primary" @click="loadList()">搜索</el-button>
<el-button @click="resetFilter">重置</el-button>
</el-form-item>
</el-form>
</el-card>

<!-- 列表 -->
<el-card shadow="never">
<template #header>
<el-button v-if="canAdd" type="primary" @click="showAdd">+ 新增</el-button>
</template>

<el-table :data="tableData" v-loading="loading" stripe border>
<el-table-column prop="content_key" label="内容标识" width="180"></el-table-column>
<el-table-column prop="title" label="标题" min-width="200"></el-table-column>
<el-table-column prop="remark" label="备注" min-width="160">
<template #default="{ row }">${ row.remark || '-' }</template>
</el-table-column>
<el-table-column label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">${ row.status === 1 ? '启用' : '禁用' }</el-tag>
</template>
</el-table-column>
<el-table-column prop="sort" label="排序" width="80" align="center"></el-table-column>
<el-table-column prop="update_time" label="更新时间" width="170">
<template #default="{ row }">${ row.update_time || row.create_time || '-' }</template>
</el-table-column>
<el-table-column label="操作" width="200" align="center">
<template #default="{ row }">
<el-button type="primary" link @click="showPreview(row)">预览</el-button>
<el-button v-if="canEdit" type="primary" link @click="showEdit(row)">编辑</el-button>
<el-button v-if="canDelete" type="danger" link @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>

<div class="flex justify-end mt-4">
<el-pagination
v-model:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
layout="total, prev, pager, next"
@current-change="loadList"
/>
</div>
</el-card>

<!-- 新增/编辑弹窗 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="1000px" top="5vh" destroy-on-close draggable :close-on-click-modal="false" @closed="onDialogClosed">
<div style="max-height: calc(90vh - 160px); overflow-y: auto; padding-right: 24px;">
<el-form :model="form" label-width="90px">
<el-form-item label="内容标识" required>
<el-input v-model="form.content_key" placeholder="如 index_content" :disabled="!!form.id" />
<div v-if="!form.id" class="text-xs text-gray-400 mt-1">创建后不可修改</div>
</el-form-item>
<el-form-item label="标题" required>
<el-input v-model="form.title" placeholder="请输入标题" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.remark" placeholder="请输入备注" />
</el-form-item>
<el-form-item label="富文本内容">
<div style="width:780px; max-width:100%;">
<div id="toolbar-container" class="editor-toolbar"></div>
<div id="editor-container" class="editor-container"></div>
</div>
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="form.sort" :min="0" :precision="0" :step="1" />
</el-form-item>
<el-form-item label="状态">
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" />
</el-form-item>
</el-form>
</div>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave" :loading="saving">确定</el-button>
</template>
</el-dialog>

<!-- 预览抽屉 -->
<el-drawer v-model="previewVisible" title="内容预览" size="480px" destroy-on-close>
<div class="phone-frame">
<div class="phone-notch"></div>
<div class="phone-screen">
<div class="preview-header">
<div class="preview-title">${ previewData.title }</div>
</div>
<div class="preview-body" v-html="previewData.content"></div>
</div>
</div>
</el-drawer>
</div>
{% endblock %}

{% block js %}
<script src="/static/lib/wangeditor/index.js"></script>
<script>
const canAdd = {{ canAdd | dump | safe }};
const canEdit = {{ canEdit | dump | safe }};
const canDelete = {{ canDelete | dump | safe }};

const { createApp, ref, reactive, onMounted, nextTick } = Vue;

const app = createApp({
delimiters: ['${', '}'],
setup() {
const keyword = ref('');
const statusFilter = ref('');
const loading = ref(false);
const tableData = ref([]);
const pagination = reactive({ page: 1, pageSize: 10, total: 0 });

const dialogVisible = ref(false);
const dialogTitle = ref('新增内容');
const saving = ref(false);
const form = reactive({ id: null, content_key: '', title: '', remark: '', content: '', sort: 0, status: 1 });

// 预览
const previewVisible = ref(false);
const previewData = reactive({ title: '', content: '' });

let editorInstance = null;

async function loadList(page) {
if (typeof page === 'number') pagination.page = page;
loading.value = true;
try {
const params = new URLSearchParams({
page: pagination.page, pageSize: pagination.pageSize,
keyword: keyword.value,
status: statusFilter.value !== '' ? statusFilter.value : ''
});
const res = await fetch('/admin/content/list?' + params).then(r => r.json());
if (res.code === 0) {
tableData.value = res.data.data || [];
pagination.total = res.data.count || 0;
} else {
ElementPlus.ElMessage.error(res.msg || '加载失败');
}
} finally { loading.value = false; }
}

function resetFilter() {
keyword.value = ''; statusFilter.value = ''; pagination.page = 1; loadList();
}

function showAdd() {
dialogTitle.value = '新增内容';
Object.assign(form, { id: null, content_key: '', title: '', remark: '', content: '', sort: 0, status: 1 });
dialogVisible.value = true;
nextTick(() => initEditor(''));
}

async function showEdit(row) {
dialogTitle.value = '编辑内容';
const res = await fetch('/admin/content/detail?id=' + row.id).then(r => r.json());
if (res.code !== 0) { ElementPlus.ElMessage.error(res.msg || '获取详情失败'); return; }
const item = res.data;
Object.assign(form, {
id: item.id, content_key: item.content_key, title: item.title,
remark: item.remark || '', content: item.content || '',
sort: item.sort || 0, status: item.status
});
dialogVisible.value = true;
nextTick(() => initEditor(form.content));
}

async function showPreview(row) {
const res = await fetch('/admin/content/detail?id=' + row.id).then(r => r.json());
if (res.code !== 0) { ElementPlus.ElMessage.error(res.msg || '获取详情失败'); return; }
previewData.title = res.data.title || '';
previewData.content = res.data.content || '';
previewVisible.value = true;
}

function initEditor(html) {
destroyEditor();
const { createEditor, createToolbar } = window.wangEditor;
editorInstance = createEditor({
selector: '#editor-container',
html: html || '',
config: {
placeholder: '请输入内容...',
MENU_CONF: {
uploadImage: {
server: '/admin/upload', fieldName: 'file', maxFileSize: 5 * 1024 * 1024,
customInsert(res, insertFn) {
if (res.code === 0 && res.data && res.data.url) { insertFn(res.data.url, res.data.name || '', ''); }
else { ElementPlus.ElMessage.error(res.msg || '上传失败'); }
}
}
},
onChange(editor) { form.content = editor.getHtml(); }
}
});
createToolbar({ editor: editorInstance, selector: '#toolbar-container', config: {} });
}

function destroyEditor() {
if (editorInstance) { try { editorInstance.destroy(); } catch (e) {} editorInstance = null; }
var toolbar = document.getElementById('toolbar-container');
var editor = document.getElementById('editor-container');
if (toolbar) toolbar.innerHTML = '';
if (editor) editor.innerHTML = '';
}

function onDialogClosed() { destroyEditor(); }

async function handleSave() {
if (!form.content_key.trim()) { ElementPlus.ElMessage.warning('内容标识不能为空'); return; }
if (!form.title.trim()) { ElementPlus.ElMessage.warning('标题不能为空'); return; }
saving.value = true;
try {
const url = form.id ? '/admin/content/edit' : '/admin/content/add';
const body = { title: form.title, remark: form.remark, content: form.content, sort: form.sort, status: form.status };
if (form.id) { body.id = form.id; } else { body.content_key = form.content_key; }
const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(r => r.json());
if (res.code === 0) { ElementPlus.ElMessage.success('保存成功'); dialogVisible.value = false; loadList(); }
else { ElementPlus.ElMessage.error(res.msg || '保存失败'); }
} finally { saving.value = false; }
}

async function handleDelete(row) {
try {
await ElementPlus.ElMessageBox.confirm('确定要删除「' + row.title + '」吗?', '提示', { type: 'warning' });
const res = await fetch('/admin/content/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: row.id }) }).then(r => r.json());
if (res.code === 0) { ElementPlus.ElMessage.success('删除成功'); loadList(); }
else { ElementPlus.ElMessage.error(res.msg || '删除失败'); }
} catch {}
}

onMounted(() => loadList());

return {
keyword, statusFilter, loading, tableData, pagination,
dialogVisible, dialogTitle, saving, form,
previewVisible, previewData,
canAdd, canEdit, canDelete,
loadList, resetFilter, showAdd, showEdit, showPreview, handleSave, handleDelete, onDialogClosed
};
}
});

app.use(ElementPlus, { locale: ElementPlusLocaleZhCn });
app.mount('#contentApp');
</script>
{% endblock %}

+ 219
- 0
view/admin/dashboard_index.html 파일 보기

@@ -0,0 +1,219 @@
{% extends "./layout.html" %}

{% block title %}控制台{% endblock %}

{% block css %}
<style>
.stat-cards { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 24px; }
.stat-card { background: #fff; border-radius: 10px; padding: 20px 24px; box-shadow: 0 1px 4px rgba(0,0,0,0.06); display: flex; align-items: center; gap: 16px; }
.stat-card .card-icon { width: 52px; height: 52px; border-radius: 12px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.stat-card .card-icon svg { width: 26px; height: 26px; }
.stat-card .card-info { flex: 1; }
.stat-card .label { font-size: 13px; color: #909399; margin-bottom: 6px; }
.stat-card .value { font-size: 28px; font-weight: 700; line-height: 1.2; }
.stat-card .trend { font-size: 12px; color: #909399; margin-top: 6px; }
.stat-card .trend .num { font-weight: 600; }
.stat-card.card-total .card-icon { background: rgba(255,120,0,0.1); }
.stat-card.card-total .value { color: #ff7800; }
.stat-card.card-total .trend .num { color: #ff7800; }
.stat-card.card-pending .card-icon { background: rgba(230,162,60,0.1); }
.stat-card.card-pending .value { color: #E6A23C; }
.stat-card.card-pending .trend .num { color: #E6A23C; }
.stat-card.card-rejected .card-icon { background: rgba(245,108,108,0.1); }
.stat-card.card-rejected .value { color: #F56C6C; }
.stat-card.card-rejected .trend .num { color: #F56C6C; }
.stat-card.card-approved .card-icon { background: rgba(103,194,58,0.1); }
.stat-card.card-approved .value { color: #67C23A; }
.stat-card.card-approved .trend .num { color: #67C23A; }
.panel { background: #fff; border-radius: 10px; padding: 24px; box-shadow: 0 1px 4px rgba(0,0,0,0.06); margin-bottom: 24px; }
.panel h3 { font-size: 16px; color: #303133; margin-bottom: 16px; padding-bottom: 12px; border-bottom: 1px solid #EBEEF5; }
</style>
{% endblock %}

{% block content %}
<div id="dashboardApp" v-cloak>
<!-- 统计卡片 -->
<div class="stat-cards" v-loading="loading">
<div class="stat-card card-total">
<div class="card-icon">
<svg viewBox="0 0 1024 1024" fill="#ff7800"><path d="M563.2 462.4a192 192 0 1 0-102.4 0A320 320 0 0 0 192 768a32 32 0 0 0 32 32h576a32 32 0 0 0 32-32 320 320 0 0 0-268.8-305.6zM352 320a160 160 0 1 1 320 0 160 160 0 0 1-320 0zm-128 448a288 288 0 0 1 576 0H224z"/></svg>
</div>
<div class="card-info">
<div class="label">患者总数</div>
<div class="value">${ counts.all.toLocaleString() }</div>
<div class="trend">今日新增 <span class="num">+${ todayCounts.all }</span></div>
</div>
</div>
<div class="stat-card card-pending">
<div class="card-icon">
<svg viewBox="0 0 1024 1024" fill="#E6A23C"><path d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm0 820c-205.4 0-372-166.6-372-372s166.6-372 372-372 372 166.6 372 372-166.6 372-372 372zm-32-588v272l208 124.8 32-53.6-176-104.8V296h-64z"/></svg>
</div>
<div class="card-info">
<div class="label">待审核</div>
<div class="value">${ counts.pending.toLocaleString() }</div>
<div class="trend">今日新增 <span class="num">+${ todayCounts.pending }</span></div>
</div>
</div>
<div class="stat-card card-rejected">
<div class="card-icon">
<svg viewBox="0 0 1024 1024" fill="#F56C6C"><path d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm0 820c-205.4 0-372-166.6-372-372s166.6-372 372-372 372 166.6 372 372-166.6 372-372 372zm158.4-489.6L557.6 512l112.8 117.6-45.2 45.2L512 557.6l-117.6 112.8-45.2-45.2L466.4 512 349.2 394.4l45.2-45.2L512 466.4l117.6-112.8z"/></svg>
</div>
<div class="card-info">
<div class="label">已驳回</div>
<div class="value">${ counts.rejected.toLocaleString() }</div>
<div class="trend">今日新增 <span class="num">+${ todayCounts.rejected }</span></div>
</div>
</div>
<div class="stat-card card-approved">
<div class="card-icon">
<svg viewBox="0 0 1024 1024" fill="#67C23A"><path d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm0 820c-205.4 0-372-166.6-372-372s166.6-372 372-372 372 166.6 372 372-166.6 372-372 372zm193.6-505.6L448 636.8l-129.6-129.6-45.2 45.2L448 727.2l302.8-302.8z"/></svg>
</div>
<div class="card-info">
<div class="label">审核通过</div>
<div class="value">${ counts.approved.toLocaleString() }</div>
<div class="trend">今日新增 <span class="num">+${ todayCounts.approved }</span></div>
</div>
</div>
</div>

<!-- 趋势折线图 -->
<div class="panel">
<h3>患者新增趋势(近30天)</h3>
<div id="trendChart" style="width:100%;height:360px;"></div>
</div>

<!-- 最近提交记录 -->
<div class="panel">
<h3>最近提交记录</h3>
<el-table :data="recentList" stripe border>
<el-table-column prop="patient_no" label="编号" min-width="180"></el-table-column>
<el-table-column prop="name" label="姓名" min-width="80"></el-table-column>
<el-table-column label="手机号" min-width="120">
<template #default="{ row }">${ row.phone_mask }</template>
</el-table-column>
<el-table-column label="标识" min-width="80">
<template #default="{ row }">
<el-tag v-if="row.tag" type="danger" size="small">${ row.tag }</el-tag>
<span v-else style="color:#999;">—</span>
</template>
</el-table-column>
<el-table-column label="提交时间" min-width="160">
<template #default="{ row }">${ row.create_time }</template>
</el-table-column>
<el-table-column label="状态" min-width="90" align="center">
<template #default="{ row }">
<el-tag v-if="row.status === -1" type="info" size="small">待提交</el-tag>
<el-tag v-else-if="row.status === 0" type="warning" size="small">待审核</el-tag>
<el-tag v-else-if="row.status === 1" type="success" size="small">审核通过</el-tag>
<el-tag v-else-if="row.status === 2" type="danger" size="small">已驳回</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" min-width="100" align="center">
<template #default="{ row }">
<el-button type="primary" link @click="viewDetail(row)">查看详情</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
{% endblock %}

{% block js %}
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
<script>
var { createApp, ref, reactive, onMounted, nextTick } = Vue;

var app = createApp({
delimiters: ['${', '}'],
setup() {
var loading = ref(true);
var counts = reactive({ all: 0, pending: 0, approved: 0, rejected: 0 });
var todayCounts = reactive({ all: 0, pending: 0, approved: 0, rejected: 0 });
var recentList = ref([]);
var trendData = ref([]);
var canView = {{ canView | dump | safe }};

async function loadStats() {
loading.value = true;
try {
var res = await fetch('/admin/dashboard/stats').then(function(r) { return r.json(); });
if (res.code === 0) {
Object.assign(counts, res.data.counts);
Object.assign(todayCounts, res.data.todayCounts || {});
recentList.value = res.data.recent || [];
trendData.value = res.data.trend || [];
nextTick(function() { renderChart(); });
}
} finally {
loading.value = false;
}
}

function renderChart() {
var dom = document.getElementById('trendChart');
if (!dom || !window.echarts) return;
var chart = echarts.init(dom);

// 生成完整30天日期序列
var dataMap = {};
trendData.value.forEach(function(r) { dataMap[r.date] = r; });
var dates = [], totals = [], rectals = [];
for (var i = 29; i >= 0; i--) {
var d = new Date();
d.setDate(d.getDate() - i);
var key = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
dates.push(key);
var row = dataMap[key];
totals.push(row ? row.total : 0);
rectals.push(row ? row.rectal : 0);
}

chart.setOption({
tooltip: { trigger: 'axis' },
legend: { data: ['患者总数', '直肠癌患者'], top: 0 },
grid: { left: 40, right: 24, top: 40, bottom: 30 },
xAxis: { type: 'category', data: dates, boundaryGap: false },
yAxis: { type: 'value', minInterval: 1 },
series: [
{
name: '患者总数',
type: 'line',
data: totals,
smooth: true,
itemStyle: { color: '#ff7800' },
areaStyle: { color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: 'rgba(255,120,0,0.25)' }, { offset: 1, color: 'rgba(255,120,0,0.02)' }] } }
},
{
name: '直肠癌患者',
type: 'line',
data: rectals,
smooth: true,
itemStyle: { color: '#409EFF' },
areaStyle: { color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: 'rgba(64,158,255,0.2)' }, { offset: 1, color: 'rgba(64,158,255,0.02)' }] } }
}
]
});
window.addEventListener('resize', function() { chart.resize(); });
}

function viewDetail(row) {
if (!canView) {
ElementPlus.ElMessageBox.alert('暂无查看详情权限,请先联系管理员授权', '温馨提示', {
confirmButtonText: '知道了',
type: 'warning'
});
return;
}
window.location.href = '/admin/patient/detail.html?id=' + row.id;
}

onMounted(function() { loadStats(); });

return { loading, counts, todayCounts, recentList, viewDetail };
}
});

app.use(ElementPlus, { locale: ElementPlusLocaleZhCn });
app.mount('#dashboardApp');
</script>
{% endblock %}

+ 129
- 0
view/admin/layout.html 파일 보기

@@ -0,0 +1,129 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}管理后台{% endblock %} - 肠愈同行管理系统</title>
<link rel="icon" href="/static/favicon.ico" type="image/x-icon">
<!-- Element Plus CSS -->
<link rel="stylesheet" href="/static/css/admin.css?v={{version}}">
<!-- Tailwind CSS 4 -->
<script src="/static/lib/tailwindcss/browser.js"></script>
<link rel="stylesheet" href="/static/lib/element-plus/index.css?v={{version}}">
<!-- Element Plus 主色调覆盖 -->
<style>
:root {
--el-color-primary: #ff7800;
--el-color-primary-light-1: #ff861a;
--el-color-primary-light-2: #ff9333;
--el-color-primary-light-3: #ffa14d;
--el-color-primary-light-4: #ffae66;
--el-color-primary-light-5: #ffbc80;
--el-color-primary-light-6: #ffc999;
--el-color-primary-light-7: #ffd7b3;
--el-color-primary-light-8: #ffe4cc;
--el-color-primary-light-9: #fff2e6;
--el-color-primary-dark-2: #cc6000;
}
/* 修复 Tailwind CSS 4 与 Element Plus 表格冲突 */
.el-table table { display: table; }
.el-table thead { display: table-header-group; }
.el-table tbody { display: table-row-group; }
.el-table tr { display: table-row; }
.el-table th, .el-table td { display: table-cell; }
/* 防止 Vue 挂载前闪烁未编译模板 */
[v-cloak] { display: none !important; }
/* 侧边栏栏目菜单:防止加载闪烁 */
#columnMenuGroup:empty { min-height: 200px; }
/* 防止残留遮罩层阻止页面交互 */
body > .el-overlay:not(:has(.el-dialog)):not(:has(.el-message-box)):not(:has(.el-drawer)) {
display: none !important;
}
</style>
{% block css %}{% endblock %}
</head>
<body data-page="{{ currentPage }}">
<div class="admin-layout" id="app">
{% include "./common/_sidebar.html" %}

<div class="main-area">
{% include "./common/_header.html" %}

<div class="page-content">
{% block content %}{% endblock %}
<div id="watermarkWrap" style="position:fixed;top:0;left:210px;right:0;bottom:0;pointer-events:none;z-index:999;overflow:hidden;"></div>
</div>
</div>
</div>

<!-- Vue 3.5 -->
<script src="/static/lib/vue/vue.global.prod.js"></script>
<!-- Element Plus -->
<script src="/static/lib/element-plus/index.full.min.js"></script>
<!-- Element Plus Icons -->
<script src="/static/lib/element-plus-icons/index.js"></script>
<!-- Element Plus 中文语言包 -->
<script src="/static/lib/element-plus/locale/zh-cn.min.js"></script>
<!-- 水印 -->
<script>
(function() {
var wmEl = document.getElementById('watermarkWrap');
if (!wmEl) return;
wmEl.innerHTML = '<el-watermark :content="text" :font="font" style="width:100%;height:100%;"></el-watermark>';
var wApp = Vue.createApp({
delimiters: ['[[', ']]'],
setup: function() {
return {
text: '{{ adminUser.nickname or adminUser.username or "" }}',
font: { fontSize: 14, color: 'rgba(0,0,0,0.08)' }
};
}
});
wApp.use(ElementPlus);
wApp.mount(wmEl);
})();
</script>
{% block js %}{% endblock %}
<script>
// 菜单展开/收起
document.querySelectorAll('.menu-parent > .menu-item').forEach(item => {
item.addEventListener('click', () => {
item.parentElement.classList.toggle('open');
});
});
// 清理残留的 Element Plus 遮罩层
(function cleanOverlays() {
function removeStaleOverlays() {
document.querySelectorAll('body > .el-overlay').forEach(function(el) {
// 只移除空的 overlay(没有弹窗内容的)
if (!el.querySelector('.el-dialog, .el-message-box, .el-drawer')) {
el.remove();
}
});
}
removeStaleOverlays();
// 延迟再清理一次(等待异步脚本执行完)
setTimeout(removeStaleOverlays, 100);
setTimeout(removeStaleOverlays, 500);
// 监听 body 子节点变化,及时清理新插入的空 overlay
if (window.MutationObserver) {
new MutationObserver(function(mutations) {
mutations.forEach(function(m) {
m.addedNodes.forEach(function(node) {
if (node.nodeType === 1 && node.classList && node.classList.contains('el-overlay') &&
!node.querySelector('.el-dialog, .el-message-box, .el-drawer')) {
// 延迟检查,给 Vue 渲染内容的时间
setTimeout(function() {
if (!node.querySelector('.el-dialog, .el-message-box, .el-drawer')) {
node.remove();
}
}, 50);
}
});
});
}).observe(document.body, { childList: true });
}
})();
</script>
</body>
</html>

+ 294
- 0
view/admin/patient_detail.html 파일 보기

@@ -0,0 +1,294 @@
{% extends "./layout.html" %}

{% block title %}患者详情{% endblock %}

{% block css %}
<style>
.detail-panel { background: #fff; border-radius: 8px; padding: 24px; box-shadow: 0 1px 4px rgba(0,0,0,0.06); margin-bottom: 16px; }
.detail-panel h3 { font-size: 16px; color: #303133; margin-bottom: 20px; padding-bottom: 12px; border-bottom: 1px solid #EBEEF5; }
.info-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }
.info-item { display: flex; }
.info-item .label { width: 100px; color: #909399; font-size: 14px; flex-shrink: 0; }
.info-item .value { color: #303133; font-size: 14px; }
.doc-images { display: flex; gap: 16px; flex-wrap: wrap; margin-top: 12px; }
.sign-doc-card { width: 200px; border: 1px solid #EBEEF5; border-radius: 8px; padding: 16px; text-align: center; cursor: pointer; transition: all 0.2s; }
.sign-doc-card:hover { border-color: var(--el-color-primary); box-shadow: 0 2px 12px rgba(255,120,0,0.15); }
.timeline-list { padding: 0; list-style: none; }
.timeline-list li { position: relative; padding: 0 0 20px 24px; border-left: 2px solid #EBEEF5; }
.timeline-list li:last-child { border-left-color: transparent; padding-bottom: 0; }
.timeline-list li::before { content: ''; position: absolute; left: -6px; top: 4px; width: 10px; height: 10px; border-radius: 50%; background: var(--el-color-primary); }
.timeline-list li .time { font-size: 12px; color: #909399; margin-bottom: 4px; }
.timeline-list li .desc { font-size: 14px; color: #303133; }
.timeline-list li .reason { font-size: 13px; color: #F56C6C; margin-top: 4px; }
.fixed-bottom-bar { position: fixed; bottom: 0; left: 220px; right: 0; height: 64px; background: #fff; box-shadow: 0 -2px 8px rgba(0,0,0,0.08); display: flex; align-items: center; justify-content: center; gap: 16px; z-index: 10; padding: 0 24px; }
</style>
{% endblock %}

{% block content %}
<div id="detailApp" v-cloak>
<div v-loading="loading" style="padding-bottom:80px;">
<!-- 页头 -->
<el-page-header @back="goBack" style="margin-bottom:16px;">
<template #content>
<span style="font-size:16px;font-weight:600;">患者详情</span>
<el-tag v-if="patient.status === -1" type="info" size="small" style="margin-left:12px;">待提交</el-tag>
<el-tag v-else-if="patient.status === 0" type="warning" size="small" style="margin-left:12px;">待审核</el-tag>
<el-tag v-else-if="patient.status === 1" type="success" size="small" style="margin-left:12px;">审核通过</el-tag>
<el-tag v-else-if="patient.status === 2" type="danger" size="small" style="margin-left:12px;">已驳回</el-tag>
</template>
</el-page-header>

<!-- 基本信息 -->
<div class="detail-panel">
<h3>基本信息</h3>
<div class="info-grid">
<div class="info-item"><span class="label">患者编号:</span><span class="value">${ patient.patient_no }</span></div>
<div class="info-item"><span class="label">姓名:</span><span class="value">${ patient.name }</span></div>
<div class="info-item"><span class="label">性别:</span><span class="value">${ patient.gender }</span></div>
<div class="info-item"><span class="label">手机号:</span><span class="value">${ patient.phone }</span></div>
<div class="info-item"><span class="label">身份证号:</span><span class="value">${ patient.id_card }</span></div>
<div class="info-item"><span class="label">出生日期:</span><span class="value">${ patient.birth_date }</span></div>
<div class="info-item">
<span class="label">所在地区:</span>
<span class="value">${ patient.province_name } ${ patient.city_name } ${ patient.district_name }</span>
</div>
<div class="info-item"><span class="label">详细地址:</span><span class="value">${ patient.address }</span></div>
<div class="info-item">
<span class="label">瘤种:</span>
<span class="value">
<el-tag v-if="patient.tag" type="danger" size="small">${ patient.tag }</el-tag>
<span v-else style="color:#999;">无</span>
</span>
</div>
<div class="info-item"><span class="label">提交时间:</span><span class="value">${ patient.create_time }</span></div>
<div class="info-item"><span class="label">紧急联系人:</span><span class="value">${ patient.emergency_contact || '—' }</span></div>
<div class="info-item"><span class="label">紧急联系电话:</span><span class="value">${ patient.emergency_phone || '—' }</span></div>
</div>
</div>

<!-- 上传资料 -->
<div class="detail-panel">
<h3>上传资料</h3>
<p style="font-size:13px;color:#909399;margin-bottom:12px;">患者上传的检查报告单或出院诊断证明书</p>
<div class="doc-images" v-if="patient.documents && patient.documents.length">
<div v-for="(doc, idx) in patient.documents" :key="idx">
<el-image :src="doc" fit="cover" :preview-src-list="patient.documents" :initial-index="idx"
style="width:200px;height:140px;border-radius:8px;border:1px solid #EBEEF5;" />
<div style="font-size:12px;color:#909399;text-align:center;margin-top:6px;">资料 ${ idx + 1 }</div>
</div>
</div>
<el-empty v-else description="暂无上传资料" :image-size="60" />
</div>

<!-- 签字材料 -->
<div class="detail-panel">
<h3>签字材料</h3>
<p style="font-size:13px;color:#909399;margin-bottom:16px;">患者签署的三份授权材料</p>
<div style="display:flex;gap:20px;flex-wrap:wrap;">
<div v-for="item in signDocs" :key="item.key" style="width:220px;">
<div style="font-size:13px;color:#303133;font-weight:500;margin-bottom:8px;">${ item.label }</div>
<template v-if="item.url && isImageUrl(item.url)">
<el-image :src="item.url" fit="cover" :preview-src-list="signImageList" :initial-index="signImageList.indexOf(item.url)"
style="width:220px;height:160px;border-radius:8px;border:1px solid #EBEEF5;cursor:pointer;" />
</template>
<template v-else-if="item.url">
<div class="sign-doc-card" @click="downloadSign(item.url, item.label)">
<div style="font-size:36px;margin-bottom:8px;">📝</div>
<div style="font-size:12px;color:#67C23A;margin-bottom:8px;">已签署</div>
<div style="font-size:13px;color:var(--el-color-primary);font-weight:500;">⬇ 下载</div>
</div>
</template>
<template v-else>
<div style="width:220px;height:160px;border:1px dashed #DCDFE6;border-radius:8px;display:flex;align-items:center;justify-content:center;">
<span style="font-size:13px;color:#909399;">未上传</span>
</div>
</template>
</div>
</div>
</div>

<!-- 审核记录 -->
<div class="detail-panel">
<h3>审核记录</h3>
<ul class="timeline-list" v-if="audits.length">
<li v-for="(item, idx) in audits" :key="idx">
<div class="time">${ item.create_time }</div>
<div class="desc" v-if="item.action === 'submit'">${ item.operator_name || '系统' } 提交了患者资料,等待审核</div>
<div class="desc" v-else-if="item.action === 'approve'">${ item.operator_name } 审核通过</div>
<div class="desc" v-else-if="item.action === 'reject'">${ item.operator_name } 驳回了资料</div>
<div class="reason" v-if="item.reason">驳回原因:${ item.reason }</div>
</li>
</ul>
<el-empty v-else description="暂无审核记录" :image-size="60" />
</div>
</div>

<!-- 固定底部操作栏 -->
<div class="fixed-bottom-bar">
<el-button @click="goBack">返 回</el-button>
<el-button v-if="patient.status === 0 && canAudit" type="success" @click="handleApprove">审核通过</el-button>
<el-button v-if="patient.status === 0 && canAudit" type="danger" @click="showRejectDialog">驳 回</el-button>
</div>

<!-- 驳回弹窗 -->
<el-dialog v-model="rejectVisible" title="驳回审核" width="540px" destroy-on-close :close-on-click-modal="false" :z-index="2000">
<p style="font-weight:500;color:#303133;margin-bottom:12px;">请选择或填写驳回原因(必填):</p>
<div style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:12px;">
<el-check-tag v-for="(r, i) in commonReasons" :key="i" :checked="selectedReasons.includes(i)"
@change="toggleReason(i)">${ r }</el-check-tag>
</div>
<el-input v-model="rejectReason" type="textarea" :rows="3" placeholder="请输入驳回原因,或点击上方常见原因快速选择"></el-input>
<div style="text-align:right;margin-top:20px;">
<el-button @click="rejectVisible = false">取消</el-button>
<el-button type="danger" @click="doReject" :loading="rejectSaving">确认驳回</el-button>
</div>
</el-dialog>
</div>
{% endblock %}

{% block js %}
<script>
var patientId = '{{ patientId }}';
var canAuditVal = {{ canAudit | dump | safe }};
var { createApp, ref, reactive, onMounted } = Vue;

var app = createApp({
delimiters: ['${', '}'],
setup() {
var loading = ref(true);
var canAudit = ref(canAuditVal);
var patient = reactive({
id: '', patient_no: '', name: '', phone: '', id_card: '', gender: '', birth_date: '',
province_code: '', city_code: '', district_code: '',
province_name: '', city_name: '', district_name: '',
address: '', tag: '', documents: [], sign_income: '', sign_privacy: '', sign_promise: '',
emergency_contact: '', emergency_phone: '',
status: -1, create_time: ''
});
var audits = ref([]);
var rejectVisible = ref(false);
var rejectSaving = ref(false);
var rejectReason = ref('');
var selectedReasons = ref([]);
var commonReasons = [
'身份证照片模糊,请重新上传',
'病历资料不完整,请补充',
'检查报告缺失,请上传',
'姓名与身份证信息不一致',
'手机号无法联系,请核实',
'提交信息存在明显错误',
'资料过期,请提供最新版本'
];

async function loadDetail() {
loading.value = true;
try {
var res = await fetch('/admin/patient/info?id=' + patientId).then(function(r) { return r.json(); });
if (res.code === 0) {
Object.assign(patient, res.data.patient);
audits.value = res.data.audits || [];
} else {
ElementPlus.ElMessage.error(res.msg || '加载失败');
}
} finally {
loading.value = false;
}
}

function goBack() {
window.location.href = '/admin/patient.html';
}

function downloadSign(url, name) {
if (!url) { ElementPlus.ElMessage.warning('该材料未上传'); return; }
var a = document.createElement('a');
a.href = url; a.target = '_blank'; a.download = name; a.click();
}

function isImageUrl(url) {
if (!url) return false;
var lower = url.split('?')[0].toLowerCase();
return /\.(png|jpg|jpeg|gif|bmp|webp|svg)$/.test(lower);
}

var signDocs = Vue.computed(function() {
return [
{ key: 'income', label: '个人可支配收入声明', url: patient.sign_income },
{ key: 'privacy', label: '个人信息处理同意书', url: patient.sign_privacy },
{ key: 'promise', label: '声明与承诺', url: patient.sign_promise }
];
});

var signImageList = Vue.computed(function() {
return [patient.sign_income, patient.sign_privacy, patient.sign_promise].filter(function(u) { return u && isImageUrl(u); });
});

async function handleApprove() {
try {
await ElementPlus.ElMessageBox.confirm(
'确定要通过该患者的审核吗?通过后患者将收到审核通过通知。',
'确认审核通过',
{ confirmButtonText: '确认通过', cancelButtonText: '取消', type: 'success' }
);
var res = await fetch('/admin/patient/approve', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: patient.id })
}).then(function(r) { return r.json(); });
if (res.code === 0) {
ElementPlus.ElMessage.success('审核已通过');
loadDetail();
} else {
ElementPlus.ElMessage.error(res.msg || '操作失败');
}
} catch(e) {}
}

function showRejectDialog() {
rejectReason.value = '';
selectedReasons.value = [];
rejectVisible.value = true;
}

function toggleReason(index) {
var pos = selectedReasons.value.indexOf(index);
if (pos > -1) { selectedReasons.value.splice(pos, 1); }
else { selectedReasons.value.push(index); }
rejectReason.value = selectedReasons.value.map(function(i) { return commonReasons[i]; }).join(';');
}

async function doReject() {
if (!rejectReason.value.trim()) { ElementPlus.ElMessage.warning('请填写驳回原因'); return; }
rejectSaving.value = true;
try {
var res = await fetch('/admin/patient/reject', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: patient.id, reason: rejectReason.value.trim() })
}).then(function(r) { return r.json(); });
if (res.code === 0) {
ElementPlus.ElMessage.success('已驳回');
rejectVisible.value = false;
loadDetail();
} else {
ElementPlus.ElMessage.error(res.msg || '操作失败');
}
} finally {
rejectSaving.value = false;
}
}

onMounted(function() { loadDetail(); });

return {
loading, patient, audits, canAudit,
rejectVisible, rejectSaving, rejectReason, selectedReasons, commonReasons,
goBack, downloadSign, isImageUrl, signDocs, signImageList, handleApprove, showRejectDialog, toggleReason, doReject
};
}
});

app.use(ElementPlus, { locale: ElementPlusLocaleZhCn });
app.mount('#detailApp');
</script>
{% endblock %}

+ 490
- 0
view/admin/patient_index.html 파일 보기

@@ -0,0 +1,490 @@
{% extends "./layout.html" %}

{% block title %}患者管理{% endblock %}

{% block content %}
<div id="patientApp" v-cloak>
<!-- 搜索栏 -->
<el-card shadow="never" class="mb-4">
<el-form :inline="true" @submit.prevent="loadList()" class="flex items-center flex-wrap gap-2">
<el-form-item label="关键词" class="!mb-0">
<el-input v-model="keyword" placeholder="姓名/手机号/编号" clearable style="width:180px;" />
</el-form-item>
<el-form-item label="日期范围" class="!mb-0">
<el-date-picker v-model="dateRange" type="daterange" range-separator="至"
start-placeholder="开始日期" end-placeholder="结束日期" value-format="YYYY-MM-DD"
style="width:260px;" />
</el-form-item>
<el-form-item label="瘤种" class="!mb-0">
<el-select v-model="tagFilter" placeholder="全部瘤种" clearable filterable style="width:160px;">
<el-option v-for="t in tagOptions" :key="t" :label="t" :value="t"></el-option>
<el-option label="无瘤种" value="none"></el-option>
</el-select>
</el-form-item>
<el-form-item label="地区" class="!mb-0">
<el-cascader v-model="regionFilter" :options="regionTree"
:props="{ value: 'code', label: 'name', children: 'children', checkStrictly: true }"
placeholder="全部地区" clearable style="width:220px;" />
</el-form-item>
<el-form-item class="!mb-0">
<el-button type="primary" @click="loadList()">搜索</el-button>
<el-button @click="resetFilter">重置</el-button>
</el-form-item>
</el-form>
<div style="display:flex;gap:8px;margin-top:12px;">
<el-button v-if="perms.canAdd" type="success" :icon="Plus" @click="showAddDialog">新增患者</el-button>
<el-button v-if="perms.canExport" :icon="Download" @click="handleExport" :loading="exporting">导出</el-button>
</div>
</el-card>

<!-- 状态 Tab -->
<el-card shadow="never">
<el-tabs v-model="activeTab" @tab-change="onTabChange">
<el-tab-pane name="all">
<template #label>全部 <el-badge :value="counts.all" type="info" class="ml-1" /></template>
</el-tab-pane>
<el-tab-pane name="draft">
<template #label>待提交 <el-badge :value="counts.draft" class="ml-1" /></template>
</el-tab-pane>
<el-tab-pane name="pending">
<template #label>待审核 <el-badge :value="counts.pending" type="warning" class="ml-1" /></template>
</el-tab-pane>
<el-tab-pane name="rejected">
<template #label>已驳回 <el-badge :value="counts.rejected" type="danger" class="ml-1" /></template>
</el-tab-pane>
<el-tab-pane name="approved">
<template #label>审核通过 <el-badge :value="counts.approved" type="success" class="ml-1" /></template>
</el-tab-pane>
</el-tabs>

<el-table :data="tableData" v-loading="loading" stripe border>
<el-table-column prop="patient_no" label="编号" min-width="200"></el-table-column>
<el-table-column prop="name" label="姓名" min-width="80"></el-table-column>
<el-table-column label="身份证号" min-width="180">
<template #default="{ row }">${ row.id_card_mask }</template>
</el-table-column>
<el-table-column label="手机号" min-width="130">
<template #default="{ row }">${ row.phone_mask }</template>
</el-table-column>
<el-table-column label="地区" min-width="160">
<template #default="{ row }">${ row.region_name || '—' }</template>
</el-table-column>
<el-table-column label="瘤种" min-width="120">
<template #default="{ row }">
<el-tag v-if="row.tag" type="danger" size="small">${ row.tag }</el-tag>
<span v-else style="color:#999;">—</span>
</template>
</el-table-column>
<el-table-column label="提交时间" min-width="170">
<template #default="{ row }">${ row.create_time }</template>
</el-table-column>
<el-table-column label="状态" min-width="100" align="center">
<template #default="{ row }">
<el-tag v-if="row.status === -1" type="info" size="small">待提交</el-tag>
<el-tag v-else-if="row.status === 0" type="warning" size="small">待审核</el-tag>
<el-tag v-else-if="row.status === 1" type="success" size="small">审核通过</el-tag>
<el-tag v-else-if="row.status === 2" type="danger" size="small">已驳回</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" align="center" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="viewDetail(row)">查看详情</el-button>
<el-button v-if="perms.canEdit" type="info" link @click="showEditDialog(row)">编辑</el-button>
<el-button v-if="row.status === 0 && perms.canAudit" type="warning" link @click="viewDetail(row)">去审核</el-button>
</template>
</el-table-column>
</el-table>

<div class="flex justify-end mt-4">
<el-pagination
v-model:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
layout="total, prev, pager, next"
@current-change="loadList"
/>
</div>
</el-card>

<!-- 新增患者弹窗 -->
<el-dialog v-model="addVisible" :title="editingId ? '编辑患者' : '新增患者'" width="720px" destroy-on-close draggable :close-on-click-modal="false">
<el-form :model="addForm" label-width="120px">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="姓名" required>
<el-input v-model="addForm.name" placeholder="请输入姓名" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="瘤种">
<el-select v-model="addForm.tag" placeholder="请选择或输入瘤种" clearable filterable allow-create style="width:100%;">
<el-option v-for="t in tagOptions" :key="t" :label="t" :value="t"></el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="身份证号" required>
<el-input v-model="addForm.id_card" placeholder="请输入身份证号" maxlength="18" @input="onIdCardInput" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="手机号" required>
<el-input v-model="addForm.phone" placeholder="请输入手机号" maxlength="11" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="出生日期" required>
<el-date-picker v-model="addForm.birth_date" type="date" placeholder="选择日期"
value-format="YYYY-MM-DD" style="width:100%;" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="性别" required>
<el-select v-model="addForm.gender" placeholder="请选择" style="width:100%;">
<el-option label="男" value="男"></el-option>
<el-option label="女" value="女"></el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="所在地区" required>
<el-cascader v-model="addForm.regionCodes" :options="regionTree"
:props="{ value: 'code', label: 'name', children: 'children' }"
placeholder="请选择省/市/区" clearable style="width:100%;" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="详细地址" required>
<el-input v-model="addForm.address" placeholder="请输入详细地址(街道门牌号等)" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="紧急联系人">
<el-input v-model="addForm.emergency_contact" placeholder="联系人姓名" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="紧急联系电话">
<el-input v-model="addForm.emergency_phone" placeholder="联系人电话" maxlength="11" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="上传资料">
<div class="flex flex-wrap gap-2">
<div v-for="(doc, idx) in addForm.documents" :key="idx"
class="relative" style="width:80px;height:80px;">
<el-image :src="doc" fit="cover" style="width:80px;height:80px;border-radius:6px;border:1px solid #eee;" />
<span @click="addForm.documents.splice(idx, 1)"
class="absolute top-0 right-0 cursor-pointer bg-black/50 text-white rounded-full w-5 h-5 flex items-center justify-center text-xs">×</span>
</div>
<el-upload action="/admin/upload" :show-file-list="false" accept="image/*"
:on-success="onDocUpload" :headers="uploadHeaders" style="width:80px;height:80px;">
<div class="flex items-center justify-center border-2 border-dashed border-gray-300 rounded-md cursor-pointer text-2xl text-gray-400"
style="width:80px;height:80px;">+</div>
</el-upload>
</div>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="签字材料">
<div class="flex flex-wrap gap-3">
<el-upload action="/admin/upload" :show-file-list="false" accept=".pdf,.png,.jpg,.jpeg"
:on-success="(res) => onSignUpload(res, 'sign_income')" :headers="uploadHeaders">
<el-button :type="addForm.sign_income ? 'success' : 'default'" plain>
${ addForm.sign_income ? '✅' : '📄' } 个人可支配收入声明
</el-button>
</el-upload>
<el-upload action="/admin/upload" :show-file-list="false" accept=".pdf,.png,.jpg,.jpeg"
:on-success="(res) => onSignUpload(res, 'sign_privacy')" :headers="uploadHeaders">
<el-button :type="addForm.sign_privacy ? 'success' : 'default'" plain>
${ addForm.sign_privacy ? '✅' : '📄' } 个人信息处理同意书
</el-button>
</el-upload>
<el-upload action="/admin/upload" :show-file-list="false" accept=".pdf,.png,.jpg,.jpeg"
:on-success="(res) => onSignUpload(res, 'sign_promise')" :headers="uploadHeaders">
<el-button :type="addForm.sign_promise ? 'success' : 'default'" plain>
${ addForm.sign_promise ? '✅' : '📄' } 声明与承诺
</el-button>
</el-upload>
</div>
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<el-button @click="addVisible = false">取消</el-button>
<el-button type="primary" @click="submitAdd" :loading="addSaving">${ editingId ? '保存修改' : '确认新增' }</el-button>
</template>
</el-dialog>
</div>
{% endblock %}

{% block js %}
<script>
const { createApp, ref, reactive, onMounted } = Vue;
const { Plus, Download } = ElementPlusIconsVue;

const perms = {
canAdd: {{ canAdd | dump | safe }},
canEdit: {{ canEdit | dump | safe }},
canExport: {{ canExport | dump | safe }},
canAudit: {{ canAudit | dump | safe }},
canView: {{ canView | dump | safe }}
};

const app = createApp({
delimiters: ['${', '}'],
setup() {
const keyword = ref('');
const dateRange = ref(null);
const tagFilter = ref('');
const activeTab = ref('all');
const loading = ref(false);
const tableData = ref([]);
const pagination = reactive({ page: 1, pageSize: 10, total: 0 });
const counts = reactive({ all: 0, pending: 0, approved: 0, rejected: 0 });
const uploadHeaders = {};

// 新增弹窗
const addVisible = ref(false);
const addSaving = ref(false);
const editingId = ref(null);
const addForm = reactive({
name: '', phone: '', id_card: '', gender: '', birth_date: '',
regionCodes: [], address: '', emergency_contact: '', emergency_phone: '',
tag: '', documents: [], sign_income: '', sign_privacy: '', sign_promise: ''
});

// 省市区树形数据
const regionTree = ref([]);

// 瘤种选项
const tagOptions = [
'直肠癌', '结肠癌', '十二指肠癌', '空肠癌', '回肠癌',
'胃肠道间质瘤(GIST)', '肠道神经内分泌肿瘤',
'腺瘤性息肉', '炎性息肉', '增生性息肉', '平滑肌瘤', '脂肪瘤'
];

const regionFilter = ref([]);
const exporting = ref(false);

const tabStatusMap = { all: '', draft: '-1', pending: '0', rejected: '2', approved: '1' };

async function loadRegionTree() {
var res = await fetch('/common/regions').then(function(r) { return r.json(); });
if (res.code === 0) regionTree.value = res.data || [];
}

async function loadList(page) {
if (typeof page === 'number') pagination.page = page;
loading.value = true;
try {
var params = new URLSearchParams({
page: pagination.page,
pageSize: pagination.pageSize,
keyword: keyword.value,
tag: tagFilter.value,
status: tabStatusMap[activeTab.value] || ''
});
if (dateRange.value && dateRange.value.length === 2) {
params.set('startDate', dateRange.value[0]);
params.set('endDate', dateRange.value[1]);
}
if (regionFilter.value && regionFilter.value.length >= 1) params.set('province_code', regionFilter.value[0]);
if (regionFilter.value && regionFilter.value.length >= 2) params.set('city_code', regionFilter.value[1]);
if (regionFilter.value && regionFilter.value.length >= 3) params.set('district_code', regionFilter.value[2]);
var res = await fetch('/admin/patient/list?' + params).then(function(r) { return r.json(); });
if (res.code === 0) {
tableData.value = res.data.data || [];
pagination.total = res.data.count || 0;
if (res.data.counts) Object.assign(counts, res.data.counts);
} else {
ElementPlus.ElMessage.error(res.msg || '加载失败');
}
} finally {
loading.value = false;
}
}

function resetFilter() {
keyword.value = '';
dateRange.value = null;
tagFilter.value = '';
regionFilter.value = [];
activeTab.value = 'all';
pagination.page = 1;
loadList();
}

function onTabChange() {
pagination.page = 1;
loadList();
}

function viewDetail(row) {
if (!perms.canView) {
ElementPlus.ElMessageBox.alert('暂无查看详情权限,请先联系管理员授权', '温馨提示', {
confirmButtonText: '知道了',
type: 'warning'
});
return;
}
window.location.href = '/admin/patient/detail.html?id=' + row.id;
}

function showAddDialog() {
editingId.value = null;
Object.assign(addForm, {
name: '', phone: '', id_card: '', gender: '', birth_date: '',
regionCodes: [], address: '', emergency_contact: '', emergency_phone: '',
tag: '', documents: [], sign_income: '', sign_privacy: '', sign_promise: ''
});
addVisible.value = true;
}

function onIdCardInput() {
var v = addForm.id_card;
if (v.length === 18) {
addForm.birth_date = v.substring(6, 10) + '-' + v.substring(10, 12) + '-' + v.substring(12, 14);
addForm.gender = parseInt(v.charAt(16)) % 2 === 0 ? '女' : '男';
}
}

function onDocUpload(res) {
if (res.code === 0 && res.data && res.data.url) {
addForm.documents.push(res.data.url);
} else {
ElementPlus.ElMessage.error(res.msg || '上传失败');
}
}

function onSignUpload(res, field) {
if (res.code === 0 && res.data && res.data.url) {
addForm[field] = res.data.url;
} else {
ElementPlus.ElMessage.error(res.msg || '上传失败');
}
}

async function showEditDialog(row) {
editingId.value = row.id;
// 先重置表单
Object.assign(addForm, {
name: '', phone: '', id_card: '', gender: '', birth_date: '',
regionCodes: [], address: '', emergency_contact: '', emergency_phone: '',
tag: '', documents: [], sign_income: '', sign_privacy: '', sign_promise: ''
});
try {
var res = await fetch('/admin/patient/info?id=' + row.id).then(function(r) { return r.json(); });
if (res.code === 0) {
var p = res.data.patient;
Object.assign(addForm, {
name: p.name || '',
phone: p.phone || '',
id_card: p.id_card || '',
gender: p.gender || '',
birth_date: p.birth_date || '',
address: p.address || '',
emergency_contact: p.emergency_contact || '',
emergency_phone: p.emergency_phone || '',
tag: p.tag || '',
documents: (typeof p.documents === 'string' ? JSON.parse(p.documents || '[]') : p.documents) || [],
sign_income: p.sign_income || '',
sign_privacy: p.sign_privacy || '',
sign_promise: p.sign_promise || '',
regionCodes: [p.province_code, p.city_code, p.district_code].filter(Boolean)
});
addVisible.value = true;
} else {
ElementPlus.ElMessage.error(res.msg || '获取患者信息失败');
}
} catch(e) {
ElementPlus.ElMessage.error('获取患者信息失败');
}
}

async function submitAdd() {
if (!addForm.name.trim()) return ElementPlus.ElMessage.warning('请输入姓名');
if (!addForm.phone || !/^1\d{10}$/.test(addForm.phone)) return ElementPlus.ElMessage.warning('请输入正确的手机号');
if (!addForm.id_card || !/^\d{17}[\dXx]$/.test(addForm.id_card)) return ElementPlus.ElMessage.warning('请输入正确的身份证号');
if (!addForm.birth_date) return ElementPlus.ElMessage.warning('请选择出生日期');
if (!addForm.gender) return ElementPlus.ElMessage.warning('请选择性别');
if (!addForm.regionCodes || addForm.regionCodes.length !== 3) return ElementPlus.ElMessage.warning('请选择省市区');
if (!addForm.address.trim()) return ElementPlus.ElMessage.warning('请输入详细地址');

addSaving.value = true;
try {
var url = editingId.value ? '/admin/patient/edit' : '/admin/patient/add';
var body = {
name: addForm.name.trim(),
phone: addForm.phone,
id_card: addForm.id_card,
gender: addForm.gender,
birth_date: addForm.birth_date,
province_code: addForm.regionCodes[0],
city_code: addForm.regionCodes[1],
district_code: addForm.regionCodes[2],
address: addForm.address.trim(),
emergency_contact: addForm.emergency_contact || '',
emergency_phone: addForm.emergency_phone || '',
tag: addForm.tag || '',
documents: addForm.documents,
sign_income: addForm.sign_income,
sign_privacy: addForm.sign_privacy,
sign_promise: addForm.sign_promise
};
if (editingId.value) body.id = editingId.value;

var res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
}).then(function(r) { return r.json(); });

if (res.code === 0) {
ElementPlus.ElMessage.success(editingId.value ? '保存成功' : '新增成功');
addVisible.value = false;
loadList();
} else {
ElementPlus.ElMessage.error(res.msg || '操作失败');
}
} finally {
addSaving.value = false;
}
}

function handleExport() {
var params = new URLSearchParams({
keyword: keyword.value,
tag: tagFilter.value,
status: tabStatusMap[activeTab.value] || ''
});
if (dateRange.value && dateRange.value.length === 2) {
params.set('startDate', dateRange.value[0]);
params.set('endDate', dateRange.value[1]);
}
if (regionFilter.value && regionFilter.value.length >= 1) params.set('province_code', regionFilter.value[0]);
if (regionFilter.value && regionFilter.value.length >= 2) params.set('city_code', regionFilter.value[1]);
if (regionFilter.value && regionFilter.value.length >= 3) params.set('district_code', regionFilter.value[2]);
window.open('/admin/patient/export?' + params.toString(), '_blank');
}

onMounted(function() {
loadList();
loadRegionTree();
});

return {
keyword, dateRange, tagFilter, regionFilter, activeTab, loading, tableData, pagination, counts,
uploadHeaders, addVisible, addSaving, addForm, exporting, editingId, perms,
regionTree, tagOptions, Plus, Download,
loadList, resetFilter, onTabChange, viewDetail, showAddDialog, showEditDialog, handleExport,
onIdCardInput, onDocUpload, onSignUpload, submitAdd
};
}
});

app.use(ElementPlus, { locale: ElementPlusLocaleZhCn });
app.mount('#patientApp');
</script>
{% endblock %}

+ 145
- 0
view/admin/system/log_index.html 파일 보기

@@ -0,0 +1,145 @@
{% extends "../layout.html" %}

{% block title %}操作日志{% endblock %}

{% block content %}
<div id="logApp" v-cloak>
<el-card shadow="never">
<template #header>
<div class="flex items-center justify-between">
<span class="font-medium">操作日志</span>
<!-- <el-button type="danger" size="small" @click="clearLogs">清理日志</el-button> -->
</div>
</template>

<!-- 筛选栏 -->
<div class="flex items-center gap-4 mb-4 flex-wrap">
<el-input v-model="query.keyword" placeholder="搜索用户/描述..." style="width:180px;" clearable @keyup.enter="loadList"></el-input>
<el-select v-model="query.action" placeholder="操作类型" style="width:120px;" clearable>
<el-option label="登录" value="login"></el-option>
<el-option label="退出" value="logout"></el-option>
<el-option label="新增" value="add"></el-option>
<el-option label="编辑" value="edit"></el-option>
<el-option label="删除" value="delete"></el-option>
<el-option label="导出" value="export"></el-option>
</el-select>
<el-date-picker v-model="dateRange" type="daterange" range-separator="至"
start-placeholder="开始日期" end-placeholder="结束日期"
value-format="YYYY-MM-DD" style="width:240px;" :teleported="false"></el-date-picker>
<el-button type="primary" @click="loadList">搜索</el-button>
<el-button @click="resetQuery">重置</el-button>
</div>

<!-- 信息栏 -->
<div class="flex items-center gap-3 mb-3 text-sm text-gray-500">
<span>共 <b class="text-orange-500">${ total }</b> 条记录</span>
</div>

<!-- 表格 -->
<el-table :data="list" v-loading="loading" border>
<el-table-column prop="id" label="ID" width="70"></el-table-column>
<el-table-column prop="username" label="操作人" width="100"></el-table-column>
<el-table-column prop="action" label="类型" width="80">
<template #default="{ row }">
<el-tag :type="actionMap[row.action]?.type || 'info'" size="small">${ actionMap[row.action]?.text || row.action }</el-tag>
</template>
</el-table-column>
<el-table-column prop="module" label="模块" width="100"></el-table-column>
<el-table-column prop="description" label="操作描述" min-width="260">
<template #default="{ row }">
<span class="text-gray-600">${ row.description }</span>
</template>
</el-table-column>
<el-table-column prop="ip" label="IP地址" width="130"></el-table-column>
<el-table-column prop="create_time" label="操作时间" width="170">
<template #default="{ row }">${ row.create_time?.slice(0,19) }</template>
</el-table-column>
</el-table>

<!-- 分页 -->
<div class="flex justify-end mt-4">
<el-pagination background layout="total, sizes, prev, pager, next" :total="total" v-model:page-size="pageSize" :page-sizes="[15, 30, 50, 100]" v-model:current-page="page" @current-change="loadList" @size-change="onSizeChange"></el-pagination>
</div>
</el-card>
</div>
{% endblock %}

{% block js %}
<script>
const { createApp, ref, reactive, onMounted } = Vue;

const app = createApp({
delimiters: ['${', '}'],
setup() {
const loading = ref(false);
const list = ref([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(15);
const query = reactive({ keyword: '', action: '' });
const dateRange = ref(null);

const actionMap = {
login: { type: 'success', text: '登录' },
logout: { type: 'info', text: '退出' },
add: { type: 'primary', text: '新增' },
edit: { type: 'warning', text: '编辑' },
delete: { type: 'danger', text: '删除' },
export: { type: '', text: '导出' }
};

async function loadList() {
loading.value = true;
try {
const params = new URLSearchParams({
page: page.value, pageSize: pageSize.value,
keyword: query.keyword, action: query.action,
startDate: dateRange.value ? dateRange.value[0] : '',
endDate: dateRange.value ? dateRange.value[1] : ''
});
const res = await fetch('/admin/system/log/list?' + params).then(r => r.json());
if (res.code === 0) {
list.value = res.data.data || [];
total.value = res.data.count || 0;
}
} finally { loading.value = false; }
}

function resetQuery() {
query.keyword = '';
query.action = '';
dateRange.value = null;
page.value = 1;
loadList();
}

function onSizeChange() {
page.value = 1;
loadList();
}

async function clearLogs() {
try {
await ElementPlus.ElMessageBox.confirm('确定清理30天前的日志?此操作不可恢复。', '清理日志', { type: 'warning' });
const res = await fetch('/admin/system/log/clear', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ days: 30 })
}).then(r => r.json());
if (res.code === 0) {
ElementPlus.ElMessage.success(res.msg);
loadList();
}
} catch {}
}

onMounted(() => loadList());

return { loading, list, total, page, pageSize, query, dateRange, actionMap, loadList, resetQuery, onSizeChange, clearLogs };
}
});

app.use(ElementPlus, { locale: ElementPlusLocaleZhCn });
app.mount('#logApp');
</script>
{% endblock %}

+ 310
- 0
view/admin/system/role_index.html 파일 보기

@@ -0,0 +1,310 @@
{% extends "../layout.html" %}

{% block title %}角色权限{% endblock %}

{% block content %}
<div id="roleApp" v-cloak>
<!-- 搜索栏 -->
<el-card shadow="never" class="mb-4">
<el-form :inline="true" @submit.prevent="loadList()" class="flex items-center flex-wrap gap-2">
<el-form-item label="关键字" class="!mb-0">
<el-input v-model="keyword" placeholder="角色名称" clearable style="width:180px;" />
</el-form-item>
<el-form-item class="!mb-0">
<el-button type="primary" @click="loadList()">搜索</el-button>
<el-button @click="resetFilter">重置</el-button>
</el-form-item>
</el-form>
</el-card>

<!-- 角色列表 -->
<el-card shadow="never">
<template #header>
<el-button type="primary" @click="showAddModal">+ 新增</el-button>
</template>

<el-table :data="tableData" v-loading="loading" stripe border>
<el-table-column prop="name" label="角色名称">
<template #default="{ row }">
<span class="text-orange-500 font-medium">${ row.name }</span>
</template>
</el-table-column>
<el-table-column label="默认" width="80" align="center">
<template #default="{ row }">
<el-tag v-if="row.is_default" type="success" size="small">是</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="code" label="角色编码">
<template #default="{ row }">${ row.code || '-' }</template>
</el-table-column>
<el-table-column prop="sort" label="排序" width="80" align="center"></el-table-column>
<el-table-column prop="description" label="描述">
<template #default="{ row }">${ row.description || '-' }</template>
</el-table-column>
<el-table-column label="操作" width="200" align="center">
<template #default="{ row }">
<el-button type="primary" link @click="openPermDrawer(row)" :disabled="row.is_default === 1">分配权限</el-button>
<el-button v-if="!row.is_default" type="primary" link @click="editRole(row)">编辑</el-button>
<el-button v-if="!row.is_default" type="danger" link @click="deleteRole(row)">删除</el-button>
</template>
</el-table-column>
</el-table>

<div class="flex justify-end mt-4">
<el-pagination
v-model:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
layout="total, prev, pager, next"
@current-change="loadList"
/>
</div>
</el-card>

<!-- 新增/编辑弹窗 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px" destroy-on-close draggable :close-on-click-modal="false">
<el-form :model="form" label-width="80px">
<el-form-item label="角色名称" required>
<el-input v-model="form.name" placeholder="请输入角色名称" />
</el-form-item>
<el-form-item label="角色编码">
<el-input v-model="form.code" placeholder="请输入角色编码(如 ADMIN)" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="3" placeholder="请输入角色描述" />
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="form.sort" :min="0" :precision="0" :step="1" />
</el-form-item>
<el-form-item label="默认角色">
<el-switch v-model="form.is_default" :active-value="1" :inactive-value="0" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveRole" :loading="saving">确定</el-button>
</template>
</el-dialog>

<!-- 权限分配抽屉 -->
<el-drawer v-model="permDrawerVisible" :title="permTitle" size="420px" destroy-on-close>
<template #header>
<span class="font-semibold">${ permTitle }</span>
</template>
<div class="mb-3">
<el-checkbox v-model="expandAll" @change="handleExpandAll">全部展开</el-checkbox>
</div>
<el-tree
ref="permTreeRef"
:data="permissionTree"
show-checkbox
node-key="key"
:default-expand-all="expandAll"
:props="{ label: 'name', children: 'children' }"
></el-tree>
<template #footer>
<el-button @click="permDrawerVisible = false">取消</el-button>
<el-button type="primary" @click="savePermissions" :loading="permSaving">确定</el-button>
</template>
</el-drawer>
</div>
{% endblock %}

{% block js %}
<script>
const permissionTreeData = {{ permissionTree | dump | safe }};

const { createApp, ref, reactive, onMounted, nextTick } = Vue;

const app = createApp({
delimiters: ['${', '}'],
setup() {
const keyword = ref('');
const loading = ref(false);
const tableData = ref([]);
const pagination = reactive({ page: 1, pageSize: 20, total: 0 });

// 弹窗
const dialogVisible = ref(false);
const dialogTitle = ref('新增角色');
const saving = ref(false);
const form = reactive({ id: null, name: '', code: '', description: '', sort: 0, is_default: 0 });

// 权限抽屉
const permDrawerVisible = ref(false);
const permTitle = ref('权限分配');
const permTreeRef = ref(null);
const expandAll = ref(false);
const permSaving = ref(false);
const currentPermRoleId = ref(null);
const permissionTree = ref(permissionTreeData || []);

// 加载列表
async function loadList(page) {
if (typeof page === 'number') pagination.page = page;
loading.value = true;
try {
const params = new URLSearchParams({ page: pagination.page, pageSize: pagination.pageSize, keyword: keyword.value });
const res = await fetch('/admin/system/role/list?' + params).then(r => r.json());
if (res.code === 0) {
tableData.value = res.data.data || [];
pagination.total = res.data.count || 0;
} else {
ElementPlus.ElMessage.error(res.msg || '加载失败');
}
} finally {
loading.value = false;
}
}

// 重置筛选
function resetFilter() {
keyword.value = '';
pagination.page = 1;
loadList();
}

// 显示新增弹窗
function showAddModal() {
dialogTitle.value = '新增角色';
Object.assign(form, { id: null, name: '', code: '', description: '', sort: 0, is_default: 0 });
dialogVisible.value = true;
}

// 编辑角色
function editRole(row) {
dialogTitle.value = '编辑角色';
Object.assign(form, { id: row.id, name: row.name, code: row.code || '', description: row.description || '', sort: row.sort || 0, is_default: row.is_default || 0 });
dialogVisible.value = true;
}

// 保存角色
async function saveRole() {
if (!form.name.trim()) {
ElementPlus.ElMessage.warning('角色名称不能为空');
return;
}
saving.value = true;
try {
const url = form.id ? '/admin/system/role/edit' : '/admin/system/role/add';
const body = { name: form.name, code: form.code, description: form.description, sort: form.sort, is_default: form.is_default };
if (form.id) body.id = form.id;

const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
}).then(r => r.json());

if (res.code === 0) {
ElementPlus.ElMessage.success('保存成功');
dialogVisible.value = false;
loadList();
} else {
ElementPlus.ElMessage.error(res.msg || '保存失败');
}
} finally {
saving.value = false;
}
}

// 删除角色
async function deleteRole(row) {
try {
await ElementPlus.ElMessageBox.confirm('确定要删除该角色吗?', '提示', { type: 'warning' });
const res = await fetch('/admin/system/role/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: row.id })
}).then(r => r.json());

if (res.code === 0) {
ElementPlus.ElMessage.success('删除成功');
loadList();
} else {
ElementPlus.ElMessage.error(res.msg || '删除失败');
}
} catch {}
}

// 打开权限抽屉
async function openPermDrawer(row) {
currentPermRoleId.value = row.id;
permTitle.value = `【${row.name}】权限分配`;
permDrawerVisible.value = true;

const res = await fetch('/admin/system/role/detail?id=' + row.id).then(r => r.json());
const currentPerms = res.code === 0 ? (res.data.permissions || []) : [];
// 收集所有叶子节点 key
const leafKeys = [];
function collectLeaves(nodes) {
nodes.forEach(function(n) {
if (n.children && n.children.length) {
collectLeaves(n.children);
} else {
leafKeys.push(n.key);
}
});
}
collectLeaves(permissionTree.value);
// 只设置叶子节点的选中状态,父节点会自动半选/全选
const leafPerms = currentPerms.filter(function(k) { return leafKeys.indexOf(k) !== -1; });
// 等待 drawer 和 tree 完全渲染
await nextTick();
await nextTick();
if (permTreeRef.value) {
permTreeRef.value.setCheckedKeys(leafPerms);
}
}

// 全部展开/折叠
function handleExpandAll(val) {
if (permTreeRef.value) {
const nodes = permTreeRef.value.store.nodesMap;
for (const key in nodes) {
nodes[key].expanded = val;
}
}
}

// 保存权限
async function savePermissions() {
permSaving.value = true;
try {
const checked = permTreeRef.value ? permTreeRef.value.getCheckedKeys() : [];
const halfChecked = permTreeRef.value ? permTreeRef.value.getHalfCheckedKeys() : [];
const permissions = [...new Set([...checked, ...halfChecked])];
const res = await fetch('/admin/system/role/assignPermissions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: currentPermRoleId.value, permissions })
}).then(r => r.json());

if (res.code === 0) {
ElementPlus.ElMessage.success('权限保存成功');
permDrawerVisible.value = false;
} else {
ElementPlus.ElMessage.error(res.msg || '保存失败');
}
} finally {
permSaving.value = false;
}
}

onMounted(() => loadList());

return {
keyword, loading, tableData, pagination,
dialogVisible, dialogTitle, saving, form,
permDrawerVisible, permTitle, permTreeRef, expandAll, permSaving, permissionTree,
loadList, resetFilter, showAddModal, editRole, saveRole, deleteRole,
openPermDrawer, handleExpandAll, savePermissions
};
}
});

app.use(ElementPlus, { locale: ElementPlusLocaleZhCn });
app.mount('#roleApp');
</script>
{% endblock %}

+ 139
- 0
view/admin/system/sms_index.html 파일 보기

@@ -0,0 +1,139 @@
{% extends "../layout.html" %}

{% block title %}短信记录{% endblock %}

{% block content %}
<div id="smsApp" v-cloak>
<el-card shadow="never">
<template #header>
<div class="flex items-center justify-between">
<span class="font-medium">短信发送记录</span>
</div>
</template>

<!-- 筛选栏 -->
<div class="flex items-center gap-4 mb-4 flex-wrap">
<el-input v-model="query.keyword" placeholder="搜索手机号..." style="width:180px;" clearable @keyup.enter="loadList"></el-input>
<el-select v-model="query.bizType" placeholder="业务类型" style="width:140px;" clearable>
<el-option label="实名认证" value="real_name_auth"></el-option>
<el-option label="修改手机号" value="change_phone"></el-option>
</el-select>
<el-select v-model="query.status" placeholder="发送状态" style="width:120px;" clearable>
<el-option label="待发送" :value="0"></el-option>
<el-option label="已发送" :value="1"></el-option>
<el-option label="发送失败" :value="2"></el-option>
</el-select>
<el-date-picker v-model="dateRange" type="daterange" range-separator="至"
start-placeholder="开始日期" end-placeholder="结束日期"
value-format="YYYY-MM-DD" style="width:240px;" :teleported="false"></el-date-picker>
<el-button type="primary" @click="loadList">搜索</el-button>
<el-button @click="resetQuery">重置</el-button>
</div>

<!-- 信息栏 -->
<div class="flex items-center gap-3 mb-3 text-sm text-gray-500">
<span>共 <b class="text-orange-500">${ total }</b> 条记录</span>
</div>

<!-- 表格 -->
<el-table :data="list" v-loading="loading" border>
<el-table-column prop="id" label="ID" width="70"></el-table-column>
<el-table-column prop="mobile" label="手机号" width="130"></el-table-column>
<el-table-column prop="code" label="验证码" width="90"></el-table-column>
<el-table-column prop="biz_type" label="业务类型" width="120">
<template #default="{ row }">
<el-tag size="small" :type="bizTypeMap[row.biz_type]?.type || 'info'">${ bizTypeMap[row.biz_type]?.text || row.biz_type }</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag size="small" :type="statusMap[row.status]?.type || 'info'">${ statusMap[row.status]?.text || '未知' }</el-tag>
</template>
</el-table-column>
<el-table-column prop="fail_reason" label="失败原因" min-width="180">
<template #default="{ row }">
<span class="text-gray-500">${ row.fail_reason || '-' }</span>
</template>
</el-table-column>
<el-table-column prop="ip" label="IP地址" width="130"></el-table-column>
<el-table-column prop="create_time" label="发送时间" width="170">
<template #default="{ row }">${ row.create_time?.slice(0,19) }</template>
</el-table-column>
</el-table>

<!-- 分页 -->
<div class="flex justify-end mt-4">
<el-pagination background layout="total, sizes, prev, pager, next" :total="total" v-model:page-size="pageSize" :page-sizes="[20, 50, 100]" v-model:current-page="page" @current-change="loadList" @size-change="onSizeChange"></el-pagination>
</div>
</el-card>
</div>
{% endblock %}

{% block js %}
<script>
const { createApp, ref, reactive, onMounted } = Vue;

const app = createApp({
delimiters: ['${', '}'],
setup() {
const loading = ref(false);
const list = ref([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(20);
const query = reactive({ keyword: '', bizType: '', status: '' });
const dateRange = ref(null);

const bizTypeMap = {
real_name_auth: { type: 'primary', text: '实名认证' },
change_phone: { type: 'warning', text: '修改手机号' }
};

const statusMap = {
0: { type: 'info', text: '待发送' },
1: { type: 'success', text: '已发送' },
2: { type: 'danger', text: '发送失败' }
};

async function loadList() {
loading.value = true;
try {
const params = new URLSearchParams({
page: page.value, pageSize: pageSize.value,
keyword: query.keyword, bizType: query.bizType,
status: query.status !== '' && query.status !== null ? query.status : '',
startDate: dateRange.value ? dateRange.value[0] : '',
endDate: dateRange.value ? dateRange.value[1] : ''
});
const res = await fetch('/admin/system/sms/list?' + params).then(r => r.json());
if (res.code === 0) {
list.value = res.data.data || [];
total.value = res.data.count || 0;
}
} finally { loading.value = false; }
}

function resetQuery() {
query.keyword = '';
query.bizType = '';
query.status = '';
dateRange.value = null;
page.value = 1;
loadList();
}

function onSizeChange() {
page.value = 1;
loadList();
}

onMounted(() => loadList());

return { loading, list, total, page, pageSize, query, dateRange, bizTypeMap, statusMap, loadList, resetQuery, onSizeChange };
}
});

app.use(ElementPlus, { locale: ElementPlusLocaleZhCn });
app.mount('#smsApp');
</script>
{% endblock %}

+ 410
- 0
view/admin/system/user_index.html 파일 보기

@@ -0,0 +1,410 @@
{% extends "../layout.html" %}

{% block title %}用户管理{% endblock %}

{% block content %}
<div id="userApp" v-cloak>
<!-- 搜索栏 -->
<el-card shadow="never" class="mb-4">
<el-form :inline="true" @submit.prevent="loadList()" class="flex items-center flex-wrap gap-2">
<el-form-item label="关键词" class="!mb-0">
<el-input v-model="keyword" placeholder="用户名/姓名..." clearable style="width:180px;" />
</el-form-item>
<el-form-item label="角色" class="!mb-0">
<el-select v-model="roleFilter" placeholder="全部" clearable style="width:120px;">
<el-option v-for="role in roles" :key="role.id" :label="role.name" :value="role.id" />
</el-select>
</el-form-item>
<el-form-item label="状态" class="!mb-0">
<el-select v-model="statusFilter" placeholder="全部" clearable style="width:100px;">
<el-option label="启用" :value="1"></el-option>
<el-option label="禁用" :value="0"></el-option>
</el-select>
</el-form-item>
<el-form-item class="!mb-0">
<el-button type="primary" @click="loadList()">搜索</el-button>
<el-button @click="resetFilter">重置</el-button>
</el-form-item>
</el-form>
</el-card>

<!-- 用户列表 -->
<el-card shadow="never">
<template #header>
<el-button type="primary" @click="showAddModal">+ 新增用户</el-button>
</template>

<el-table :data="tableData" v-loading="loading" stripe border>
<el-table-column prop="id" label="ID" width="60" ></el-table-column>
<el-table-column prop="username" label="用户名" ></el-table-column>
<el-table-column prop="nickname" label="姓名">
<template #default="{ row }">${ row.nickname || '-' }</template>
</el-table-column>
<el-table-column prop="role_name" label="角色" width="120">
<template #default="{ row }">
<el-tag type="primary" size="small">${ row.role_name || '-' }</el-tag>
</template>
</el-table-column>
<el-table-column prop="email" label="邮箱" width="180">
<template #default="{ row }">${ row.email || '-' }</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="120" align="center">
<template #default="{ row }">
<el-tag v-if="row.is_locked" type="danger" size="small">已锁定</el-tag>
<el-tag v-else-if="row.status === 1" type="success" size="small">启用</el-tag>
<el-tag v-else type="info" size="small">禁用</el-tag>
</template>
</el-table-column>
<el-table-column prop="last_login_time" label="最后登录" width="160">
<template #default="{ row }">${ row.last_login_time || '-' }</template>
</el-table-column>
<el-table-column label="操作" width="260" align="center">
<template #default="{ row }">
<el-button type="primary" link @click="editUser(row)">编辑</el-button>
<el-button type="primary" link @click="showResetModal(row)">重置密码</el-button>
<el-button v-if="row.is_locked" type="warning" link @click="unlockUser(row)">解锁</el-button>
<el-button :type="row.status === 1 ? 'danger' : 'success'" link @click="toggleStatus(row)">
${ row.status === 1 ? '禁用' : '启用' }
</el-button>
</template>
</el-table-column>
</el-table>

<div class="flex justify-end mt-4">
<el-pagination
v-model:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
layout="total, prev, pager, next"
@current-change="loadList"
/>
</div>
</el-card>

<!-- 新增/编辑弹窗 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px" destroy-on-close draggable :close-on-click-modal="false">
<el-form :model="form" label-width="80px">
<el-form-item label="用户名" required>
<el-input v-model="form.username" placeholder="请输入用户名" :disabled="!!form.id" />
</el-form-item>
<el-form-item v-if="!form.id" label="密码" required>
<el-input v-model="form.password" type="password" placeholder="至少8位,含大小写字母/数字中两种" show-password />
<div v-if="form.password" style="margin-top:4px;">
<div style="display:flex;gap:4px;margin-bottom:2px;">
<span :style="{flex:1,height:'4px',borderRadius:'2px',background: pwdStrength>=1?pwdStrengthColor:'#e4e7ed'}"></span>
<span :style="{flex:1,height:'4px',borderRadius:'2px',background: pwdStrength>=2?pwdStrengthColor:'#e4e7ed'}"></span>
<span :style="{flex:1,height:'4px',borderRadius:'2px',background: pwdStrength>=3?pwdStrengthColor:'#e4e7ed'}"></span>
</div>
<span :style="{fontSize:'12px',color:pwdStrengthColor}">${ pwdStrengthText }</span>
</div>
</el-form-item>
<el-form-item label="姓名">
<el-input v-model="form.nickname" placeholder="请输入姓名" />
</el-form-item>
<el-form-item label="邮箱">
<el-input v-model="form.email" type="email" placeholder="请输入邮箱" />
</el-form-item>
<el-form-item label="手机号">
<el-input v-model="form.phone" placeholder="请输入手机号" />
</el-form-item>
<el-form-item label="角色">
<el-select v-model="form.role_id" placeholder="请选择角色" style="width:100%;">
<el-option v-for="role in roles" :key="role.id" :label="role.name" :value="role.id" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveUser" :loading="saving">确定</el-button>
</template>
</el-dialog>

<!-- 重置密码弹窗 -->
<el-dialog v-model="resetPwdVisible" title="重置密码" width="400px" destroy-on-close draggable :close-on-click-modal="false">
<el-form label-width="80px">
<el-form-item label="新密码" required>
<el-input v-model="newPassword" type="password" placeholder="至少8位,含大小写字母/数字中两种" show-password />
<div v-if="newPassword" style="margin-top:4px;">
<div style="display:flex;gap:4px;margin-bottom:2px;">
<span :style="{flex:1,height:'4px',borderRadius:'2px',background: resetPwdStrength>=1?resetPwdStrengthColor:'#e4e7ed'}"></span>
<span :style="{flex:1,height:'4px',borderRadius:'2px',background: resetPwdStrength>=2?resetPwdStrengthColor:'#e4e7ed'}"></span>
<span :style="{flex:1,height:'4px',borderRadius:'2px',background: resetPwdStrength>=3?resetPwdStrengthColor:'#e4e7ed'}"></span>
</div>
<span :style="{fontSize:'12px',color:resetPwdStrengthColor}">${ resetPwdStrengthText }</span>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="resetPwdVisible = false">取消</el-button>
<el-button type="primary" @click="resetPassword" :loading="resetPwdSaving">确定</el-button>
</template>
</el-dialog>
</div>
{% endblock %}

{% block js %}
<script>
const rolesData = {{ roles | dump | safe }};

const { createApp, ref, reactive, computed, onMounted } = Vue;

const app = createApp({
delimiters: ['${', '}'],
setup() {
const keyword = ref('');
const roleFilter = ref('');
const statusFilter = ref('');
const loading = ref(false);
const tableData = ref([]);
const pagination = reactive({ page: 1, pageSize: 10, total: 0 });
const roles = ref(rolesData || []);

// 弹窗
const dialogVisible = ref(false);
const dialogTitle = ref('新增用户');
const saving = ref(false);
const form = reactive({ id: null, username: '', password: '', nickname: '', email: '', phone: '', role_id: '', status: 1 });

// 重置密码弹窗
const resetPwdVisible = ref(false);
const resetPwdSaving = ref(false);
const newPassword = ref('');
const resetUserId = ref(null);

// 密码强度计算
function calcPwdStrength(pwd) {
if (!pwd) return 0;
var s = 0;
if (pwd.length >= 8) s++;
var types = 0;
if (/[a-z]/.test(pwd)) types++;
if (/[A-Z]/.test(pwd)) types++;
if (/\d/.test(pwd)) types++;
if (types >= 2) s++;
if (types >= 3 && pwd.length >= 10) s++;
return s;
}
const pwdStrength = computed(function() { return calcPwdStrength(form.password); });
const pwdStrengthColor = computed(function() { return pwdStrength.value <= 1 ? '#f56c6c' : pwdStrength.value === 2 ? '#e6a23c' : '#67c23a'; });
const pwdStrengthText = computed(function() { return pwdStrength.value <= 1 ? '弱' : pwdStrength.value === 2 ? '中' : '强'; });
const resetPwdStrength = computed(function() { return calcPwdStrength(newPassword.value); });
const resetPwdStrengthColor = computed(function() { return resetPwdStrength.value <= 1 ? '#f56c6c' : resetPwdStrength.value === 2 ? '#e6a23c' : '#67c23a'; });
const resetPwdStrengthText = computed(function() { return resetPwdStrength.value <= 1 ? '弱' : resetPwdStrength.value === 2 ? '中' : '强'; });

// 前端密码校验
function validatePassword(pwd) {
if (!pwd || pwd.length < 8) return '密码长度不能少于8位';
var types = 0;
if (/[a-z]/.test(pwd)) types++;
if (/[A-Z]/.test(pwd)) types++;
if (/\d/.test(pwd)) types++;
if (types < 2) return '密码需包含大写字母、小写字母、数字中的至少两种';
return null;
}

// 加载列表
async function loadList(page) {
if (typeof page === 'number') pagination.page = page;
loading.value = true;
try {
const params = new URLSearchParams({
page: pagination.page,
pageSize: pagination.pageSize,
keyword: keyword.value,
role_id: roleFilter.value,
status: statusFilter.value
});
const res = await fetch('/admin/system/user/list?' + params).then(r => r.json());
if (res.code === 0) {
var today = new Date().toISOString().slice(0, 10);
(res.data.data || []).forEach(function(item) {
var failDate = item.login_fail_date ? item.login_fail_date.slice(0, 10) : null;
item.is_locked = (failDate === today && item.login_fail_count >= 5);
});
tableData.value = res.data.data || [];
pagination.total = res.data.count || 0;
} else {
ElementPlus.ElMessage.error(res.msg || '加载失败');
}
} finally {
loading.value = false;
}
}

// 重置筛选
function resetFilter() {
keyword.value = '';
roleFilter.value = '';
statusFilter.value = '';
pagination.page = 1;
loadList();
}

// 显示新增弹窗
function showAddModal() {
dialogTitle.value = '新增用户';
Object.assign(form, { id: null, username: '', password: '', nickname: '', email: '', phone: '', role_id: '', status: 1 });
dialogVisible.value = true;
}

// 编辑用户
async function editUser(row) {
const res = await fetch('/admin/system/user/detail?id=' + row.id).then(r => r.json());
if (res.code !== 0) {
ElementPlus.ElMessage.error(res.msg || '获取失败');
return;
}
const user = res.data;
dialogTitle.value = '编辑用户';
Object.assign(form, {
id: user.id,
username: user.username,
password: '',
nickname: user.nickname || '',
email: user.email || '',
phone: user.phone || '',
role_id: user.role_id || '',
status: user.status
});
dialogVisible.value = true;
}

// 保存用户
async function saveUser() {
if (!form.username.trim()) {
ElementPlus.ElMessage.warning('用户名不能为空');
return;
}
if (!form.id && !form.password) {
ElementPlus.ElMessage.warning('密码不能为空');
return;
}
if (!form.id) {
var pwdErr = validatePassword(form.password);
if (pwdErr) { ElementPlus.ElMessage.warning(pwdErr); return; }
}
saving.value = true;
try {
const url = form.id ? '/admin/system/user/edit' : '/admin/system/user/add';
const body = {
username: form.username,
password: form.password,
nickname: form.nickname,
email: form.email,
phone: form.phone,
role_id: form.role_id,
status: form.status
};
if (form.id) body.id = form.id;

const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
}).then(r => r.json());

if (res.code === 0) {
ElementPlus.ElMessage.success('保存成功');
dialogVisible.value = false;
loadList();
} else {
ElementPlus.ElMessage.error(res.msg || '保存失败');
}
} finally {
saving.value = false;
}
}

// 显示重置密码弹窗
function showResetModal(row) {
resetUserId.value = row.id;
newPassword.value = '';
resetPwdVisible.value = true;
}

// 重置密码
async function resetPassword() {
if (!newPassword.value) {
ElementPlus.ElMessage.warning('请输入新密码');
return;
}
var pwdErr = validatePassword(newPassword.value);
if (pwdErr) { ElementPlus.ElMessage.warning(pwdErr); return; }
resetPwdSaving.value = true;
try {
const res = await fetch('/admin/system/user/resetPassword', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: resetUserId.value, password: newPassword.value })
}).then(r => r.json());

if (res.code === 0) {
ElementPlus.ElMessage.success('密码重置成功');
resetPwdVisible.value = false;
} else {
ElementPlus.ElMessage.error(res.msg || '重置失败');
}
} finally {
resetPwdSaving.value = false;
}
}

// 解锁用户
async function unlockUser(row) {
try {
await ElementPlus.ElMessageBox.confirm('确定要解锁该用户吗?', '提示', { type: 'warning' });
const res = await fetch('/admin/system/user/unlock', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: row.id })
}).then(r => r.json());

if (res.code === 0) {
ElementPlus.ElMessage.success('解锁成功');
loadList();
} else {
ElementPlus.ElMessage.error(res.msg || '解锁失败');
}
} catch {}
}

// 切换状态
async function toggleStatus(row) {
try {
await ElementPlus.ElMessageBox.confirm('确定要切换该用户状态吗?', '提示', { type: 'warning' });
const res = await fetch('/admin/system/user/toggleStatus', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: row.id })
}).then(r => r.json());

if (res.code === 0) {
ElementPlus.ElMessage.success('操作成功');
loadList();
} else {
ElementPlus.ElMessage.error(res.msg || '操作失败');
}
} catch {}
}

onMounted(() => loadList());

return {
keyword, roleFilter, statusFilter, loading, tableData, pagination, roles,
dialogVisible, dialogTitle, saving, form,
resetPwdVisible, resetPwdSaving, newPassword,
pwdStrength, pwdStrengthColor, pwdStrengthText,
resetPwdStrength, resetPwdStrengthColor, resetPwdStrengthText,
loadList, resetFilter, showAddModal, editUser, saveUser,
showResetModal, resetPassword, unlockUser, toggleStatus
};
}
});

app.use(ElementPlus, { locale: ElementPlusLocaleZhCn });
app.mount('#userApp');
</script>
{% endblock %}

+ 42
- 0
view/error_404.html 파일 보기

@@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>页面未找到 - 404</title>
<style>
*{margin:0;padding:0;box-sizing:border-box;}
body{font-family:"Microsoft YaHei","PingFang SC",sans-serif;background:#F5F6F8;color:#303133;min-height:100vh;display:flex;flex-direction:column;}
.error-header{background:#fff;padding:0 40px;height:72px;display:flex;align-items:center;box-shadow:0 2px 12px rgba(0,0,0,.04);}
.error-header a{display:flex;align-items:center;text-decoration:none;}
.error-header img{height:44px;}
.error-body{flex:1;display:flex;align-items:center;justify-content:center;padding:40px 20px;}
.error-card{text-align:center;max-width:520px;}
.error-card img{max-width:360px;width:100%;margin-bottom:32px;}
.error-desc{font-size:15px;color:#909399;line-height:1.8;margin-bottom:32px;}
.error-actions{display:flex;gap:16px;justify-content:center;}
.btn-back{display:inline-block;padding:12px 36px;border-radius:30px;font-size:15px;font-weight:600;text-decoration:none;transition:.3s;letter-spacing:1px;}
.btn-primary{background:#E8751A;color:#fff;border:2px solid #E8751A;}
.btn-primary:hover{background:#C96012;border-color:#C96012;transform:translateY(-2px);box-shadow:0 8px 24px rgba(232,117,26,.3);}
.btn-ghost{background:transparent;color:#606266;border:2px solid #DCDFE6;}
.btn-ghost:hover{border-color:#E8751A;color:#E8751A;}
.error-footer{text-align:center;padding:24px;font-size:12px;color:#C0C4CC;}
</style>
</head>
<body>
<header class="error-header">
<a href="/"><img src="/static/images/logo.png" alt="首页"></a>
</header>
<div class="error-body">
<div class="error-card">
<img src="/static/images/404.png" alt="404">
<p class="error-desc">您访问的页面可能已被移除、名称已更改或暂时不可用。<br>请检查网址是否正确,或返回首页继续浏览。</p>
<div class="error-actions">
<a href="/" class="btn-back btn-primary">返回首页</a>
<a href="javascript:history.back()" class="btn-back btn-ghost">返回上页</a>
</div>
</div>
</div>
<footer class="error-footer">© 肠愈同行管理系统</footer>
</body>
</html>

+ 37
- 0
view/error_500.html 파일 보기

@@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>服务器错误 - 500</title>
<style>
*{margin:0;padding:0;box-sizing:border-box;}
body{font-family:"Microsoft YaHei","PingFang SC",sans-serif;background:#F5F6F8;color:#303133;min-height:100vh;display:flex;flex-direction:column;}
.error-header{background:#fff;padding:0 40px;height:72px;display:flex;align-items:center;box-shadow:0 2px 12px rgba(0,0,0,.04);}
.error-header a{display:flex;align-items:center;text-decoration:none;color:#1A3550;font-size:18px;font-weight:600;gap:10px;}
.error-header img{height:44px;}
.error-body{flex:1;display:flex;align-items:center;justify-content:center;padding:40px 20px;}
.error-card{text-align:center;max-width:480px;}
.error-code{font-size:120px;font-weight:800;color:#E84040;line-height:1;letter-spacing:-4px;opacity:.85;}
.error-title{font-size:24px;font-weight:600;color:#303133;margin:20px 0 12px;}
.error-desc{font-size:15px;color:#909399;line-height:1.8;margin-bottom:32px;}
.btn-back{display:inline-block;padding:12px 36px;border-radius:30px;font-size:15px;font-weight:600;text-decoration:none;background:#E8751A;color:#fff;border:2px solid #E8751A;transition:.3s;}
.btn-back:hover{background:#C96012;border-color:#C96012;}
.error-footer{text-align:center;padding:24px;font-size:12px;color:#C0C4CC;}
</style>
</head>
<body>
<header class="error-header">
<a href="/"><img src="/static/images/logo.png" alt="首页">肠愈同行管理系统</a>
</header>
<div class="error-body">
<div class="error-card">
<div class="error-code">500</div>
<h1 class="error-title">服务器开小差了</h1>
<p class="error-desc">服务器遇到了一些问题,请稍后再试。<br>如果问题持续存在,请联系管理员。</p>
<a href="/" class="btn-back">返回首页</a>
</div>
</div>
<footer class="error-footer">© 肠愈同行管理系统</footer>
</body>
</html>

+ 0
- 0
파일 보기









+ 0
- 0
파일 보기


+ 226
- 0
www/static/css/admin.css 파일 보기

@@ -0,0 +1,226 @@
/* ===== Admin Layout - vue3-element-admin style ===== */
/* 基础重置 - 使用较低优先级,不覆盖 Tailwind 工具类 */
*,*::before,*::after{box-sizing:border-box;}
ul,ol{list-style:none;margin:0;padding:0;}
:root{
--primary:#E8751A;
--primary-dark:#C96012;
--primary-light:#F5A623;
--primary-bg:rgba(232,117,26,.08);
--blue:#1A3550;
--white:#fff;
--bg:#f0f2f5;
--card:#fff;
--text:#303133;
--text-regular:#606266;
--text-secondary:#909399;
--text-placeholder:#c0c4cc;
--border:#e4e7ed;
--border-light:#ebeef5;
--success:#67c23a;
--warning:#e6a23c;
--danger:#f56c6c;
--info:#909399;
--sidebar-bg:#1d1e1f;
--sidebar-text:rgba(255,255,255,.65);
--sidebar-active-bg:var(--primary);
--sidebar-width:210px;
--header-height:50px;
--font-cn:"Source Han Sans SC","Noto Sans SC","PingFang SC","Microsoft YaHei",sans-serif;
--font-en:"Roboto",sans-serif;
--radius:4px;
--shadow:0 2px 12px rgba(0,0,0,.06);
}
html{font-size:14px;}
body{font-family:var(--font-cn);color:var(--text);background:var(--bg);min-height:100vh;}
a{text-decoration:none;color:inherit;}
img{max-width:100%;display:block;}

/* ===== Layout ===== */
.admin-layout{display:flex;min-height:100vh;}

/* ===== Sidebar ===== */
.sidebar{
width:var(--sidebar-width);background:var(--sidebar-bg);
position:fixed;top:0;left:0;bottom:0;z-index:100;
display:flex;flex-direction:column;transition:.3s;overflow:hidden;
}
.sidebar-logo{
height:var(--header-height);display:flex;align-items:center;
padding:0 16px;border-bottom:1px solid rgba(255,255,255,.08);gap:10px;flex-shrink:0;
background:var(--white);
}
.sidebar-logo img{height:36px;width:auto;}
.sidebar-logo svg{width:28px;height:28px;color:var(--primary);flex-shrink:0;}
.sidebar-logo span{font-size:14px;font-weight:600;color:#333;white-space:nowrap;overflow:hidden;}

.sidebar-menu{flex:1;overflow-y:auto;padding:6px 0;}
.sidebar-menu::-webkit-scrollbar{width:0;}

.menu-group{margin-bottom:2px;}
.menu-group-title{
padding:20px 16px 6px;font-size:11px;color:rgba(255,255,255,.3);
text-transform:uppercase;letter-spacing:1px;font-weight:500;
}
.menu-item{
display:flex;align-items:center;gap:10px;padding:0 16px;height:42px;
color:var(--sidebar-text);cursor:pointer;transition:.15s;position:relative;
font-size:13px;border-radius:0;
}
.menu-item:hover{color:rgba(255,255,255,.9);background:rgba(255,255,255,.06);}
.menu-item.active{color:var(--white);background:var(--primary);}
.menu-item svg{width:18px;height:18px;flex-shrink:0;opacity:1;}
.menu-item.active svg{opacity:1;}
.menu-item span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}

/* 子菜单 */
.submenu-items{display:none;overflow:hidden;}
.menu-parent.open .submenu-items{display:block;}
.menu-parent>.menu-item .arrow{
margin-left:auto;transition:.2s;opacity:.9;display:flex;align-items:center;
}
.menu-parent>.menu-item .arrow svg{width:12px;height:12px;}
.menu-parent.open>.menu-item .arrow{transform:rotate(180deg);}
.submenu-items .menu-item{padding-left:46px;height:38px;font-size:12.5px;}

/* ===== Main Area ===== */
.main-area{flex:1;margin-left:var(--sidebar-width);display:flex;flex-direction:column;height:100vh;min-width:0;overflow-x:hidden;overflow-y:auto;}

/* ===== Top Header ===== */
.top-header{
height:var(--header-height);background:var(--white);
border-bottom:1px solid var(--border-light);display:flex;
align-items:center;justify-content:space-between;padding:0 20px;
position:sticky;top:0;z-index:90;
}
.header-left{display:flex;align-items:center;gap:16px;}
.breadcrumb{display:flex;align-items:center;gap:6px;font-size:13px;color:var(--text-secondary);}
.breadcrumb a{color:var(--text-secondary);transition:.15s;}
.breadcrumb a:hover{color:var(--primary);}
.breadcrumb .sep{color:var(--text-placeholder);}
.breadcrumb .current{color:var(--text);}

.header-right{display:flex;align-items:center;gap:16px;}
.header-btn{
width:34px;height:34px;border-radius:50%;display:flex;align-items:center;justify-content:center;
cursor:pointer;transition:.15s;color:var(--text-secondary);background:none;border:none;
}
.header-btn:hover{background:var(--bg);color:var(--text);}
.header-btn svg{width:18px;height:18px;}
.header-avatar{
display:flex;align-items:center;gap:8px;cursor:pointer;padding:4px 8px;
border-radius:var(--radius);transition:.15s;
}
.header-avatar:hover{background:var(--bg);}
.header-avatar img{width:30px;height:30px;border-radius:50%;background:var(--border);}
.avatar-placeholder{
width:30px;height:30px;border-radius:50%;background:var(--primary);
color:var(--white);display:flex;align-items:center;justify-content:center;
font-size:12px;font-weight:600;
}
.header-avatar span{font-size:13px;color:var(--text-regular);}

/* User Dropdown */
.header-user{position:relative;}
.user-dropdown-menu{
position:absolute;top:100%;right:0;margin-top:6px;
min-width:160px;background:#fff;border-radius:var(--radius);
box-shadow:0 6px 24px rgba(0,0,0,.12);border:1px solid var(--border-light);
opacity:0;visibility:hidden;transform:translateY(-4px);transition:.2s;
z-index:100;padding:6px 0;
}
.header-user.open .user-dropdown-menu{opacity:1;visibility:visible;transform:translateY(0);}
.dropdown-item{
display:flex;align-items:center;gap:8px;padding:8px 16px;
font-size:13px;color:var(--text-regular);transition:.15s;cursor:pointer;text-decoration:none;
}
.dropdown-item:hover{background:var(--bg);color:var(--text);}
.dropdown-item-danger{color:var(--danger);}
.dropdown-item-danger:hover{background:rgba(245,108,108,.06);color:var(--danger);}
.dropdown-divider{height:1px;background:var(--border-light);margin:4px 0;}

/* ===== Content ===== */
.page-content{flex:1;padding:20px;overflow-x:auto;}

/* ===== Tags View ===== */
.tags-view{
display:flex;align-items:center;gap:6px;padding:6px 20px;
background:var(--white);border-bottom:1px solid var(--border-light);
overflow-x:auto;flex-shrink:0;
}
.tags-view::-webkit-scrollbar{height:0;}
.tag-item{
display:flex;align-items:center;gap:4px;padding:4px 12px;
border-radius:3px;font-size:12px;cursor:pointer;white-space:nowrap;
border:1px solid var(--border);color:var(--text-secondary);transition:.15s;
background:var(--white);
}
.tag-item:hover{color:var(--primary);border-color:var(--primary);}
.tag-item.active{background:var(--primary);color:var(--white);border-color:var(--primary);}
.tag-item .tag-close{
font-size:10px;margin-left:2px;opacity:.6;transition:.15s;
width:14px;height:14px;display:flex;align-items:center;justify-content:center;
border-radius:50%;
}
.tag-item .tag-close:hover{opacity:1;background:rgba(0,0,0,.1);}
.tag-item.active .tag-close:hover{background:rgba(255,255,255,.3);}

/* ===== Common Components ===== */
.card{background:var(--card);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;}
.card-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;}
.card-title{font-size:15px;font-weight:600;color:var(--text);}

/* Table - 仅保留颜色 */
.el-table{width:100%;border-collapse:collapse;font-size:13px;}
.el-table thead{background:#fafafa;}
.el-table th{padding:10px 12px;text-align:left;font-weight:600;color:var(--text-regular);border-bottom:2px solid var(--border-light);font-size:12px;}
.el-table td{padding:10px 12px;border-bottom:1px solid var(--border-light);color:var(--text-regular);}
.el-table tbody tr:hover td{background:#fafafa;}

/* Buttons - 仅保留颜色 */
.el-btn{color:var(--text-regular);cursor:pointer;}
.el-btn:hover{color:var(--primary);}
.el-btn-primary{background:var(--primary);color:var(--white);}
.el-btn-primary:hover{background:var(--primary-dark);color:var(--white);}
.el-btn-success{background:var(--success);color:var(--white);}
.el-btn-danger{background:var(--danger);color:var(--white);}
.el-btn-text{color:var(--primary);cursor:pointer;}
.el-btn-text:hover{color:var(--primary-dark);}

/* Badge / Tag - 仅保留颜色 */
.el-tag-success{background:#f0f9eb;color:var(--success);}
.el-tag-warning{background:#fdf6ec;color:var(--warning);}
.el-tag-danger{background:#fef0f0;color:var(--danger);}
.el-tag-info{background:#f4f4f5;color:var(--info);}
.el-tag-primary{background:var(--primary-bg);color:var(--primary);}

/* Pagination */
.pagination{display:flex;align-items:center;justify-content:flex-end;gap:4px;margin-top:16px;font-size:13px;}
.pagination .page-info{color:var(--text-secondary);margin-right:8px;}
.page-btn{
min-width:30px;height:30px;display:flex;align-items:center;justify-content:center;
border:1px solid var(--border);border-radius:var(--radius);cursor:pointer;
background:var(--white);color:var(--text-regular);font-size:13px;transition:.15s;
padding:0 6px;
}
.page-btn:hover{color:var(--primary);border-color:var(--primary);}
.page-btn.active{background:var(--primary);color:var(--white);border-color:var(--primary);}
.page-btn.disabled{color:var(--text-placeholder);cursor:not-allowed;}

/* Form inputs - 仅保留颜色 */
.el-input:focus{border-color:var(--primary);}
.el-input::placeholder{color:var(--text-placeholder);}

/* Search bar */
.filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:16px;flex-wrap:wrap;}
.filter-bar label{font-size:13px;color:var(--text-secondary);white-space:nowrap;}

/* ===== 页面标题栏(通用) ===== */
.page-title-bar{display:flex;align-items:center;gap:12px;margin-bottom:16px;}
.page-title-text{font-size:16px;font-weight:600;color:var(--text);}

/* WangEditor 编辑区域最大高度限制 */
.w-e-text-container .w-e-scroll { max-height: 80vh; }

/* 日期范围选择器宽度覆盖 */
.el-date-editor--daterange.el-input__wrapper { width: 220px !important; max-width: 220px !important; }

+ 1409
- 0
www/static/css/web.css
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
파일 보기


+ 28
- 0
www/static/css/web_1024.css 파일 보기

@@ -0,0 +1,28 @@
/* ===== max-width: 1440px ===== */
@media(max-width:1440px){
.nav-link{padding:8px 10px;font-size:14px;letter-spacing:0.5px;}
.header-nav{gap:2px;}
}

/* ===== max-width: 1024px ===== */
@media(max-width:1024px){
.fixed-header{padding:0 24px;}
.banner-slide-content{left:40px;max-width:600px;}
.banner-slide-content.pos-left{left:40px;}
.banner-slide-content.pos-right{right:40px;}
.banner-slide-content.glass-on{padding:36px 40px;}
.banner-slide-content h1{font-size:32px;}
.data-cards{gap:12px;}
.donation-tables{gap:12px;}
.donation-wrap,.news-wrap{padding:0 32px;}
.proj-info-card{width:340px;padding:36px 32px;right:5%;}
.proj-info-card h3{font-size:24px;}
.proj-info-card p{font-size:14px;}
.proj-tab{padding:12px 20px;font-size:13px;}
.news-grid{gap:24px;}
.partners-grid{grid-template-columns:repeat(4,1fr);}
.partners-section{padding:40px 32px;}
.slide-labels{display:none;}
.col-text-grid{grid-template-columns:repeat(3,1fr);gap:20px;}
.article-detail-wrap{grid-template-columns:1fr 240px;gap:28px;padding:20px 24px 60px;}
}

+ 78
- 0
www/static/css/web_480.css 파일 보기

@@ -0,0 +1,78 @@
/* ===== max-width: 480px ===== */
@media(max-width:480px){
.fixed-header{padding:0 16px;height:56px;}
.logo-img{height:38px;}
.banner-slide-content{left:16px;right:16px;max-width:calc(100% - 32px);}
.banner-slide-content.pos-left{left:16px;right:auto;}
.banner-slide-content.pos-right{left:auto;right:16px;}
.banner-slide-content.glass-on{padding:20px 24px;}
.banner-slide-content h1{font-size:20px;}
.banner-slide-content .desc{font-size:14px;}
.btn-primary,.btn-ghost{padding:10px 24px;font-size:13px;}
.donation-wrap,.news-wrap{padding:0 12px;}
.section-head h2{font-size:20px;}
.data-cards{gap:6px;}
.data-card{padding:8px 6px;border-radius:10px;}
.data-card .label{font-size:11px;margin-bottom:2px;}
.data-card .amount{font-size:16px;}
.data-card .note{font-size:10px;}
.donation-tables{gap:8px;}
.dtable{padding:8px 10px;border-radius:10px;}
.dtable h3{font-size:12px;margin-bottom:4px;padding-bottom:4px;}
.dtable td,.dtable th{font-size:11px;padding:3px 4px;}
.dtable-scroll-wrap{max-height:140px;}
.drug-scroll-wrap{max-height:140px;}
.dtable-drug th:nth-child(3),.dtable-drug td:nth-child(3){display:none;}
.proj-info-card{margin:0 12px;padding:20px 14px;}
.proj-info-card h3{font-size:18px;}
.proj-info-card p{font-size:13px;margin-bottom:12px;-webkit-line-clamp:3;display:-webkit-box;-webkit-box-orient:vertical;overflow:hidden;}
.proj-info-card .card-label{font-size:11px;margin-bottom:8px;}
.proj-info-card .card-btn{padding:8px 20px;font-size:12px;}
.proj-tabs{display:none;}
.news-item .date-box .day{font-size:22px;}
.news-item .text h4{font-size:13px;}
.partners-carousel{display:grid;grid-template-rows:none;grid-auto-flow:unset;grid-template-columns:repeat(2,1fr);grid-auto-columns:unset;gap:8px;transform:none!important;}
.partner-item{padding:12px;border-radius:8px;min-height:auto;height:auto;}
.partner-item img{max-height:40px;}
.footer-title{font-size:18px;margin-bottom:12px;}
.footer-info p{font-size:12px;}
.footer-qr img{width:80px;height:auto;}
.footer-qr span{font-size:11px;}
.section-head{margin-bottom:0.5vh;}
.section-head .en{display:none;}
.slide-footer{padding-top:56px;}
.slide-donation{padding-top:56px;}
.slide-news{padding-top:56px;}
}

/* Column Page */
@media(max-width:480px){
.col-banner{height:180px;}
.col-banner-content h1{font-size:22px;}
.col-banner-en{font-size:12px;}
.col-tab{padding:5px 16px;font-size:13px;}
.col-content{padding:0 16px 20px;}
.col-art-featured{height:220px;}
.col-art-featured-overlay h3{font-size:16px;}
.col-art-card{flex-direction:column;}
.col-art-card-cover{width:100%;height:180px;}
.col-art-card-body{padding:16px;}
.col-image-grid{grid-template-columns:1fr;gap:12px;}
.col-person-grid,.team-grid{grid-template-columns:1fr;gap:12px;}
}

/* Text Grid */
@media(max-width:480px){
.col-text-grid{grid-template-columns:repeat(2,1fr);gap:12px;}
.col-text-placeholder-title{font-size:13px;-webkit-line-clamp:3;}
.col-text-placeholder-year{font-size:24px;}
.col-text-card-info{padding:10px;}
.col-text-card-title{font-size:13px;}
}

/* Detail Meta */
@media(max-width:480px){
.proj-detail-meta,.article-detail-meta{flex-wrap:wrap;gap:8px 16px;font-size:13px;}
.proj-detail-meta .article-share,.article-detail-meta .article-share{width:100%;}
.proj-detail-title,.article-detail-title{font-size:22px;}
}

+ 235
- 0
www/static/css/web_768.css 파일 보기

@@ -0,0 +1,235 @@
/* ===== max-width: 768px ===== */

/* Footer */
@media(max-width:768px){
.footer-component{padding:0;}
.footer-content{flex-direction:column;gap:24px;padding:0 20px;}
.footer-left{flex:none;width:100%;padding-right:0;}
.footer-mid{flex-direction:column;gap:20px;width:100%;}
.footer-qr-section{width:100%;}
.footer-bottom-content{flex-direction:column;gap:8px;padding:0 20px;text-align:center;}
.footer-main{padding:24px 0 16px;}
.footer-links-list{grid-template-columns:repeat(3,auto);}
.footer-links-list li,
.footer-links-list li:nth-child(2n){padding:0 10px;border-right:1px solid rgba(255,255,255,.4);padding-left:10px;}
.footer-links-list li:nth-child(3n+1){padding-left:0;}
.footer-links-list li:nth-child(3n){border-right:none;padding-right:0;}
}

/* Swiper Fullpage & Slides */
@media(max-width:768px){
.swiper-fullpage{height:auto!important;overflow:visible!important;}
.swiper-fullpage>.swiper-wrapper{flex-direction:column!important;transform:none!important;}
.swiper-fullpage>.swiper-wrapper>.swiper-slide{height:auto!important;overflow:visible!important;position:static!important;transform:none!important;z-index:auto!important;}
.swiper-fullpage .swiper-slide{height:auto!important;overflow:visible!important;}
.slide-labels{display:none!important;}
.slide-banner{height:100vh;}
.banner-swiper,.banner-swiper .swiper-slide{height:100vh!important;}
.slide-donation{height:auto;min-height:auto;padding:80px 0 40px;}
.slide-news{height:auto;min-height:auto;padding:80px 0 40px;}
.slide-projects{height:80vh;min-height:500px;}
.slide-section{height:auto;min-height:auto;padding:40px 16px 20px;}
.slide-footer{height:auto;min-height:auto;padding-top:60px;}
.slide-with-footer{overflow:visible;overflow-y:visible;}
.slide-with-footer>.footer-component{margin:0;width:100%;}
.slide-with-footer .section-wrap{padding-bottom:0;}
.slide-with-footer.slide-section{padding-bottom:0;}
.donation-wrap,.news-wrap{opacity:1;transform:none;max-height:none;overflow:visible;}
.partners-section{opacity:1;transform:none;}
.swiper-slide-active .donation-wrap,
.swiper-slide-active .news-wrap,
.swiper-slide-active .partners-section{opacity:1;transform:none;}

/* Header & Mobile */
.header-nav{display:none;}
.mobile-menu-btn{display:flex;}
.header-search{display:none;}
.header-search-mobile{display:flex;}

/* Banner */
.banner-slide-content{left:24px;right:24px;max-width:calc(100% - 48px);}
.banner-slide-content.pos-left{left:24px;right:auto;max-width:calc(100% - 48px);}
.banner-slide-content.pos-center{left:50%;right:auto;}
.banner-slide-content.pos-right{left:auto;right:24px;max-width:calc(100% - 48px);}
.banner-slide-content.glass-on{padding:24px 28px;background:rgba(0,0,0,.1);backdrop-filter:blur(2px);-webkit-backdrop-filter:blur(2px);}
.banner-slide-content h1{font-size:26px;}

/* Donation */
.donation-wrap,.news-wrap{padding:0 20px;}
.data-cards{grid-template-columns:1fr;gap:8px;}
.data-card{padding:12px 8px;}
.data-card .amount{font-size:18px;}
.data-card .label{font-size:12px;}
.data-card .note{font-size:11px;}
.donation-tables{grid-template-columns:1fr;gap:8px;}
.dtable-drug{grid-column:1/-1;}
.dtable-scroll-wrap{max-height:140px;}
.drug-scroll-wrap{max-height:140px;}
.dtable{padding:10px 12px;}
.dtable h3{font-size:13px;margin-bottom:4px;padding-bottom:4px;}
.dtable td,.dtable th{font-size:12px;padding:4px 6px;}

/* Projects */
.proj-info-card{
position:relative;top:auto;right:auto;left:auto;bottom:auto;
transform:none;width:auto;margin:0 20px;padding:24px 20px;
opacity:1;max-height:none;overflow:visible;
background:rgba(232,117,26,.2);backdrop-filter:blur(2px);-webkit-backdrop-filter:blur(2px);
}
.swiper-slide-active .proj-info-card{transform:none;opacity:1;}
.proj-info-card h3{font-size:20px;margin-bottom:10px;}
.proj-info-card p{font-size:14px;margin-bottom:16px;line-height:1.6;}
.slide-projects{display:flex;flex-direction:column;justify-content:flex-end;padding-bottom:20px;}
.slide-projects .proj-bg{opacity:0;filter:brightness(.85);}
.slide-projects .proj-bg.active{opacity:1;}
.proj-tabs{display:none;}
.proj-mobile-nav{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px;}
.proj-tab{padding:6px 12px;font-size:12px;flex-shrink:0;max-width:none;}
.proj-more-link{display:none;}

/* News */
.news-grid{grid-template-columns:1fr;}
.news-featured img{height:200px;}

/* Partners */
.partners-carousel-viewport{margin:0;}
.partners-carousel{display:grid;grid-template-rows:none;grid-auto-flow:unset;grid-template-columns:repeat(2,1fr);grid-auto-columns:unset;gap:12px;transform:none!important;padding-bottom:20px;}
.partner-item{min-height:auto;height:auto;}
.partners-nav{display:none!important;}
.partners-section{padding:24px 20px;}
.section-head{margin-bottom:1vh;}
}

/* Column Page */
@media(max-width:768px){
.col-banner{height:220px;}
.col-banner-content h1{font-size:28px;}
.col-tabs-wrap{top:56px;}
.col-tabs{overflow-x:auto;justify-content:flex-start;padding:0 16px;-webkit-overflow-scrolling:touch;}
.col-tab{padding:6px 20px;font-size:14px;white-space:nowrap;}
.col-content{padding:0 20px 24px;}
.col-breadcrumb{padding:16px 20px;}
.col-art-featured{height:280px;}
.col-art-featured-overlay{padding:20px;}
.col-art-featured-overlay h3{font-size:18px;}
.col-art-card-cover{width:180px;height:140px;}
.col-image-grid{grid-template-columns:1fr;gap:16px;}
.col-image-thumb{height:280px;}
.col-person-grid,.team-grid{grid-template-columns:repeat(2,1fr);gap:16px;row-gap:24px;}
.member-photo{width:120px;height:120px;}
.board-featured{flex-direction:column;padding:24px;gap:24px;}
.board-featured .featured-photo{width:100%;min-width:unset;height:260px;}
.job-layout{grid-template-columns:1fr;}
.job-nav{display:none;}
.job-hero-banner{flex-direction:column;gap:20px;text-align:center;padding:28px 20px;}
.job-hero-stats{gap:24px;}
.job-card-header{flex-direction:column;gap:12px;align-items:flex-start;}
.job-card-footer{flex-direction:column;gap:12px;align-items:flex-start;}
}

/* Inline Form */
@media(max-width:768px){
.col-inline-form-wrap{padding:24px 20px;border-radius:12px;margin-top:32px;}
.col-inline-form-header h3{font-size:18px;}
.col-ifield-options{gap:10px;}
.col-ifield-submit{padding:12px 48px;font-size:15px;}
}

/* Article Detail */
@media(max-width:768px){
.article-detail-wrap{grid-template-columns:1fr;padding:20px 16px 40px;gap:32px;}
.article-title{font-size:24px;}
.article-meta{flex-direction:column;align-items:flex-start;gap:8px;}
.article-share{margin-left:0;}
.article-nav{flex-direction:column;gap:12px;}
.article-nav-item.next{text-align:left;}
}

/* Text Grid & Detail */
@media(max-width:768px){
.col-text-grid{grid-template-columns:repeat(2,1fr);gap:16px;}
.doc-item{flex-direction:column;align-items:flex-start;gap:8px;}
.doc-item-left{flex-direction:column;align-items:flex-start;gap:8px;}
.doc-date{margin-left:0;} .text-nav-next{text-align:left;}
}

/* Home Generic Section */
@media(max-width:768px){
.slide-section{height:auto;min-height:auto;padding:40px 16px 20px;}
.home-text-grid{grid-template-columns:repeat(2,1fr);gap:12px;}
.home-person-grid{grid-template-columns:repeat(2,1fr);gap:16px;}
.home-job-list{grid-template-columns:1fr;}
.home-form-wrap{max-width:100%;}
.slide-with-footer>.footer-component{margin:0 -16px;width:calc(100% + 32px);}
}

/* About Intro */
@media(max-width:768px){
.about-intro-hero{grid-template-columns:1fr;gap:24px;}
.about-stats{grid-template-columns:repeat(2,1fr);}
.about-values-grid{grid-template-columns:1fr;}
.about-strategy-row{grid-template-columns:1fr;}
.about-qual-grid{grid-template-columns:repeat(2,1fr);}
.about-compliance-banner{padding:24px 20px;}
.about-compliance-grid{grid-template-columns:1fr;}
.about-section-alt{padding:28px 20px;}
.about-section-header{margin-bottom:28px;flex-wrap:wrap;}
.about-section-header h2{font-size:20px;}
}

/* Contact */
@media(max-width:768px){
.ct-layout{grid-template-columns:1fr;gap:20px;}
.ct-brand-card{padding:20px 18px;}
.ct-map-body{min-height:260px;}
}

/* Search */
@media(max-width:768px){
.search-box{height:48px;padding:0 6px 0 20px;}
.search-box input{font-size:14px;}
.search-box-btn{width:36px;height:36px;}
.search-container{padding:24px 16px 40px;}
.result-item{padding:24px 20px;}
.result-title{font-size:18px;}
.result-desc{font-size:14px;}
.filter-tabs{gap:8px;}
.filter-tab{padding:6px 16px;font-size:13px;}
}

/* Project List */
@media(max-width:768px){
.project-item { grid-template-columns: 1fr; gap: 0; }
.project-cover { width: 100%; height: 220px; }
.project-info { padding: 24px 20px; }
.project-title { font-size: 18px; }
.project-desc { font-size: 14px; -webkit-line-clamp: 2; }
.project-footer { flex-direction: column; align-items: flex-start; gap: 16px; }
.project-stats { width: 100%; justify-content: space-between; }
}

/* Project Detail */
@media(max-width:768px){
.proj-detail-wrap { grid-template-columns: 1fr; padding: 30px 20px; gap: 0; }
.proj-detail-sidebar { display: none; }
.proj-detail-title { font-size: 24px; }
.proj-detail-meta { flex-wrap: wrap; gap: 8px 16px; font-size: 13px; }
.proj-detail-meta .article-share { width: 100%; margin-left: 0; }
.proj-section-title { font-size: 20px; }
.proj-timeline-item { display: block; margin-bottom: 20px; }
.proj-timeline-item::before, .proj-timeline-item::after { display: none; }
.proj-timeline-date { display: none; }
.proj-timeline-year-mobile { display: block; }
.proj-timeline-body { padding: 20px; }
.proj-timeline-title { font-size: 18px; }
.proj-timeline-period { font-size: 14px; }
.proj-timeline-stats { grid-template-columns: 1fr; gap: 10px; }
}

/* Side Toolbar */
@media(max-width:768px){
.side-toolbar { right: 12px; bottom: 80px; }
.side-tool { width: 42px; height: 42px; }
.side-tool-icon { width: 20px; height: 20px; }
.side-tool-qr { display: none; }
}


+ 43
- 0
www/static/iconfont/iconfont.css 파일 보기

@@ -0,0 +1,43 @@
@font-face {
font-family: "iconfont"; /* Project id 5139270 */
src: url('iconfont.woff2?t=1773493729847') format('woff2'),
url('iconfont.woff?t=1773493729847') format('woff'),
url('iconfont.ttf?t=1773493729847') format('truetype');
}

.iconfont {
font-family: "iconfont" !important;
font-size: 16px;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

.icon-clipboard:before {
content: "\eac1";
}

.icon-view:before {
content: "\e60d";
}

.icon-lock:before {
content: "\e62f";
}

.icon-email-empty:before {
content: "\e618";
}

.icon-dizhi:before {
content: "\e61f";
}

.icon-email:before {
content: "\e61a";
}

.icon-phone:before {
content: "\e8c6";
}















이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.

불러오는 중...
취소
저장