Compare commits
No commits in common. "64d1ad78965d95972d045d3a781799bdf55262eb" and "602694814f020ef463def20f04de5f2162745e9e" have entirely different histories.
64d1ad7896
...
602694814f
@ -40,17 +40,11 @@ docs/
|
|||||||
|
|
||||||
```env
|
```env
|
||||||
DATABASE_URL="postgresql://user:password@127.0.0.1:5432/tyt?schema=public"
|
DATABASE_URL="postgresql://user:password@127.0.0.1:5432/tyt?schema=public"
|
||||||
AUTH_TOKEN_SECRET="请替换为强随机密钥"
|
JWT_SECRET="请替换为强随机密钥"
|
||||||
JWT_EXPIRES_IN="7d"
|
JWT_EXPIRES_IN="7d"
|
||||||
SYSTEM_ADMIN_BOOTSTRAP_KEY="初始化系统管理员用密钥"
|
SYSTEM_ADMIN_BOOTSTRAP_KEY="初始化系统管理员用密钥"
|
||||||
```
|
```
|
||||||
|
|
||||||
管理员创建链路:
|
|
||||||
|
|
||||||
- 可通过 `POST /auth/system-admin` 创建系统管理员(需引导密钥)。
|
|
||||||
- 系统管理员负责创建医院、系统管理员与医院管理员。
|
|
||||||
- 医院管理员负责创建本院下级角色(主任/组长/医生/工程师)。
|
|
||||||
|
|
||||||
## 4. 启动流程
|
## 4. 启动流程
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
20
docs/auth.md
20
docs/auth.md
@ -2,29 +2,26 @@
|
|||||||
|
|
||||||
## 1. 目标
|
## 1. 目标
|
||||||
|
|
||||||
- 提供系统管理员创建、登录、`/me` 身份查询。
|
- 提供注册、登录、`/me` 身份查询。
|
||||||
- 使用 JWT 做认证,Guard 做鉴权,RolesGuard 做 RBAC。
|
- 使用 JWT 做认证,Guard 做鉴权,RolesGuard 做 RBAC。
|
||||||
|
|
||||||
## 2. 核心接口
|
## 2. 核心接口
|
||||||
|
|
||||||
- `POST /auth/system-admin`:创建系统管理员(需引导密钥)
|
- `POST /auth/register`:注册账号(支持医生/工程师/院管等角色约束)
|
||||||
- `POST /auth/login`:手机号 + 角色 + 密码登录(支持同手机号多院场景)
|
- `POST /auth/login`:手机号 + 角色 + 密码登录(支持同手机号多院场景)
|
||||||
- `GET /auth/me`:返回当前登录用户上下文
|
- `GET /auth/me`:返回当前登录用户上下文
|
||||||
|
|
||||||
## 3. 鉴权流程
|
## 3. 鉴权流程
|
||||||
|
|
||||||
1. `AccessTokenGuard` 从 `Authorization` 读取 Bearer Token。
|
1. `AccessTokenGuard` 从 `Authorization` 读取 Bearer Token。
|
||||||
2. 校验 JWT 签名、`id`、`iat` 等关键载荷字段。
|
2. 校验 JWT 签名与载荷字段。
|
||||||
3. 根据 `id` 回库读取用户当前角色与组织归属,不再直接信任 token 里的角色和范围。
|
3. 载荷映射为 `ActorContext` 注入 `request.user`。
|
||||||
4. 校验 `iat >= user.tokenValidAfter`,若用户被重置密码、seed 重刷或账号被清理,则旧 token 立即失效。
|
4. `RolesGuard` 根据 `@Roles(...)` 判断角色是否允许访问。
|
||||||
5. 当前数据库用户映射为 `ActorContext` 注入 `request.actor`。
|
|
||||||
6. `RolesGuard` 根据 `@Roles(...)` 判断角色是否允许访问。
|
|
||||||
|
|
||||||
## 4. Token 约定
|
## 4. Token 约定
|
||||||
|
|
||||||
- Header:`Authorization: Bearer <token>`
|
- Header:`Authorization: Bearer <token>`
|
||||||
- 载荷关键字段:`id`、`iat`
|
- 载荷关键字段:`sub`、`role`、`hospitalId`、`departmentId`、`groupId`
|
||||||
- 角色和组织范围以数据库当前用户记录为准,不以 token 历史载荷为准
|
|
||||||
|
|
||||||
## 5. 错误码与中文消息
|
## 5. 错误码与中文消息
|
||||||
|
|
||||||
@ -33,8 +30,3 @@
|
|||||||
- 参数非法:`400` + 中文 `msg`
|
- 参数非法:`400` + 中文 `msg`
|
||||||
|
|
||||||
统一由全局异常过滤器输出:`{ code, msg, data: null }`。
|
统一由全局异常过滤器输出:`{ code, msg, data: null }`。
|
||||||
|
|
||||||
## 6. 失效策略
|
|
||||||
|
|
||||||
- 用户密码被修改后,会刷新 `user.tokenValidAfter`,旧 token 全部失效。
|
|
||||||
- 执行 E2E 重置并重新 seed 后,seed 账号的 `tokenValidAfter` 也会刷新,历史 token 不可继续复用。
|
|
||||||
|
|||||||
@ -1,27 +0,0 @@
|
|||||||
# 设备模块说明(`src/devices`)
|
|
||||||
|
|
||||||
## 1. 目标
|
|
||||||
|
|
||||||
- 提供 B 端设备 CRUD。
|
|
||||||
- 管理设备与患者的归属关系。
|
|
||||||
- 支持管理员按医院、患者、状态和关键词分页查询设备。
|
|
||||||
|
|
||||||
## 2. 权限
|
|
||||||
|
|
||||||
- `SYSTEM_ADMIN`:可跨院查询和维护设备。
|
|
||||||
- `HOSPITAL_ADMIN`:仅可操作本院患者名下设备。
|
|
||||||
- 其他角色:默认拒绝。
|
|
||||||
|
|
||||||
## 3. 接口
|
|
||||||
|
|
||||||
- `GET /b/devices`:分页查询设备列表
|
|
||||||
- `GET /b/devices/:id`:查询设备详情
|
|
||||||
- `POST /b/devices`:创建设备
|
|
||||||
- `PATCH /b/devices/:id`:更新设备
|
|
||||||
- `DELETE /b/devices/:id`:删除设备
|
|
||||||
|
|
||||||
## 4. 约束
|
|
||||||
|
|
||||||
- 设备必须绑定到一个患者。
|
|
||||||
- 设备 SN 在全库唯一,服务端会统一转成大写后再校验。
|
|
||||||
- 删除已被任务明细引用的设备会返回 `409`。
|
|
||||||
@ -14,7 +14,6 @@
|
|||||||
2. `node prisma/seed.mjs`
|
2. `node prisma/seed.mjs`
|
||||||
|
|
||||||
这会清空 `.env` 中 `DATABASE_URL` 指向数据库的全部数据,请仅在测试库执行。
|
这会清空 `.env` 中 `DATABASE_URL` 指向数据库的全部数据,请仅在测试库执行。
|
||||||
另外,seed 账号会刷新 `tokenValidAfter`,所以重置前签发的旧 token 会全部失效,需要重新登录获取新 token。
|
|
||||||
|
|
||||||
## 3. 运行命令
|
## 3. 运行命令
|
||||||
|
|
||||||
|
|||||||
@ -4,21 +4,16 @@
|
|||||||
|
|
||||||
- 登录页:`/auth/login`,支持可选 `hospitalId`。
|
- 登录页:`/auth/login`,支持可选 `hospitalId`。
|
||||||
- 首页看板:按角色拉取组织与患者统计。
|
- 首页看板:按角色拉取组织与患者统计。
|
||||||
- 设备页:新增管理员专用设备 CRUD,复用真实设备接口。
|
|
||||||
- 任务页:接入 `publish/accept/complete/cancel` 四个真实任务接口。
|
- 任务页:接入 `publish/accept/complete/cancel` 四个真实任务接口。
|
||||||
- 用户页:修复用户列表响应结构、组织字段联动、工程师分配医院参数。
|
- 用户页:修复用户列表响应结构、组织字段联动、工程师分配医院参数。
|
||||||
- 患者页:接入真实患者字段与生命周期查询参数(`phone + idCard`),
|
- 患者页:接入真实患者字段与生命周期查询参数(`phone + idCardHash`)。
|
||||||
后端直接保存身份证号原文,不再做哈希转换。
|
|
||||||
|
|
||||||
## 2. 接口契约对齐点
|
## 2. 接口契约对齐点
|
||||||
|
|
||||||
- `GET /users` 当前返回数组,前端已在 `api/users.js` 做本地分页与筛选适配。
|
- `GET /users` 当前返回数组,前端已在 `api/users.js` 做本地分页与筛选适配。
|
||||||
- `PATCH /b/users/:id/assign-engineer-hospital` 参数为单个 `hospitalId`,非数组。
|
- `PATCH /b/users/:id/assign-engineer-hospital` 参数为单个 `hospitalId`,非数组。
|
||||||
- `GET /b/patients` 返回数组,前端已改为本地分页与筛选。
|
- `GET /b/patients` 返回数组,前端已改为本地分页与筛选。
|
||||||
- `GET /b/devices` 已支持服务端分页与筛选,前端直接透传 `page/pageSize`。
|
- `GET /c/patients/lifecycle` 必须同时传 `phone` 和 `idCardHash`。
|
||||||
- `GET /c/patients/lifecycle` 必须同时传 `phone` 和 `idCard`。
|
|
||||||
- 患者表单中的 `idCard` 字段直接传身份证号;
|
|
||||||
服务端只会做去空格与 `x/X` 标准化,不会转哈希。
|
|
||||||
- 任务模块暂无任务列表接口,前端改为“表单操作 + 最近结果”模式。
|
- 任务模块暂无任务列表接口,前端改为“表单操作 + 最近结果”模式。
|
||||||
|
|
||||||
## 3. 角色权限提示
|
## 3. 角色权限提示
|
||||||
@ -29,10 +24,8 @@
|
|||||||
- 患者列表权限:
|
- 患者列表权限:
|
||||||
- `SYSTEM_ADMIN` 查询时必须传 `hospitalId`
|
- `SYSTEM_ADMIN` 查询时必须传 `hospitalId`
|
||||||
- 用户管理接口:
|
- 用户管理接口:
|
||||||
- `SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR` 可访问列表与创建
|
- `SYSTEM_ADMIN/HOSPITAL_ADMIN` 可访问列表与创建
|
||||||
- `DIRECTOR` 页面语义调整为“医生管理”,仅管理本科室医生
|
- 删除和工程师绑定医院仅 `SYSTEM_ADMIN`
|
||||||
- 工程师绑定医院仅 `SYSTEM_ADMIN`
|
|
||||||
- 删除:`SYSTEM_ADMIN` 可删除任意无关联用户;`DIRECTOR` 可删除本科室无关联医生
|
|
||||||
|
|
||||||
## 3.1 结构图页面交互调整
|
## 3.1 结构图页面交互调整
|
||||||
|
|
||||||
@ -42,32 +35,27 @@
|
|||||||
## 3.2 后台页面路由权限(与后端 RBAC 对齐)
|
## 3.2 后台页面路由权限(与后端 RBAC 对齐)
|
||||||
|
|
||||||
- `organization/tree`、`organization/departments`、`organization/groups`、`users`
|
- `organization/tree`、`organization/departments`、`organization/groups`、`users`
|
||||||
- `organization/tree`、`organization/groups`:
|
- `organization/tree`、`organization/departments`、`organization/groups`:
|
||||||
`SYSTEM_ADMIN`、`HOSPITAL_ADMIN`、`DIRECTOR`、`LEADER` 可访问
|
`SYSTEM_ADMIN`、`HOSPITAL_ADMIN`、`DIRECTOR`、`LEADER` 可访问
|
||||||
- `organization/departments`:
|
- `users`:仅 `SYSTEM_ADMIN`、`HOSPITAL_ADMIN` 可访问
|
||||||
仅 `SYSTEM_ADMIN`、`HOSPITAL_ADMIN` 可访问
|
|
||||||
- `users`:`SYSTEM_ADMIN`、`HOSPITAL_ADMIN`、`DIRECTOR` 可访问
|
|
||||||
- `devices`:仅 `SYSTEM_ADMIN`、`HOSPITAL_ADMIN` 可访问
|
|
||||||
- `organization/hospitals`
|
- `organization/hospitals`
|
||||||
- 仅 `SYSTEM_ADMIN` 可访问
|
- 仅 `SYSTEM_ADMIN` 可访问
|
||||||
- `tasks`
|
- `tasks`
|
||||||
- 仅 `DOCTOR`、`DIRECTOR`、`LEADER`、`ENGINEER` 可访问
|
- 仅 `DOCTOR`、`DIRECTOR`、`LEADER`、`ENGINEER` 可访问
|
||||||
- `patients`
|
- `patients`
|
||||||
- `SYSTEM_ADMIN`、`HOSPITAL_ADMIN`、`DIRECTOR`、`LEADER`、`DOCTOR` 可访问
|
- 仅 `SYSTEM_ADMIN`、`HOSPITAL_ADMIN`、`DIRECTOR`、`LEADER` 可访问
|
||||||
|
|
||||||
前端已在路由守卫和侧边栏菜单同时做权限控制,无权限角色会被拦截并跳转到首页,避免进入页面后触发接口 `403`。
|
前端已在路由守卫和侧边栏菜单同时做权限控制,无权限角色会被拦截并跳转到首页,避免进入页面后触发接口 `403`。
|
||||||
|
|
||||||
## 3.3 主任/组长组织管理范围
|
## 3.3 主任/组长组织管理范围
|
||||||
|
|
||||||
- `DIRECTOR`
|
- `DIRECTOR`
|
||||||
- 可查看组织架构、小组列表(限定本科室范围)
|
- 可查看组织架构、科室列表、小组列表(限定本科室范围)
|
||||||
- 可创建/编辑/删除本科室下小组
|
- 可编辑本科室名称、创建/编辑/删除本科室下小组
|
||||||
- 可进入“医生管理”页,创建/维护本科室医生
|
|
||||||
- `LEADER`
|
- `LEADER`
|
||||||
- 可查看组织架构、小组列表(限定本科室/本小组范围)
|
- 可查看组织架构、科室列表、小组列表(限定本科室/本小组范围)
|
||||||
- 可编辑本小组名称
|
- 可编辑本科室名称与本小组名称
|
||||||
- 主任/组长不再显示独立“科室管理”页面。
|
- 负责人设置(设主任/设组长)与人员管理入口仍仅 `SYSTEM_ADMIN`、`HOSPITAL_ADMIN` 显示。
|
||||||
- 负责人设置(设主任/设组长)入口仍仅 `SYSTEM_ADMIN`、`HOSPITAL_ADMIN` 显示。
|
|
||||||
|
|
||||||
## 4. 本地运行
|
## 4. 本地运行
|
||||||
|
|
||||||
|
|||||||
@ -3,9 +3,7 @@
|
|||||||
## 1. 目标
|
## 1. 目标
|
||||||
|
|
||||||
- B 端:按组织与角色范围查询患者(强依赖 `hospitalId`)。
|
- B 端:按组织与角色范围查询患者(强依赖 `hospitalId`)。
|
||||||
- C 端:按 `phone + idCard` 做跨院聚合查询。
|
- C 端:按 `phone + idCardHash` 做跨院聚合查询。
|
||||||
- 患者档案直接保存身份证号原文,不再做哈希转换。
|
|
||||||
- 服务端只做轻量格式整理:去空格、统一末尾 `x/X` 为大写。
|
|
||||||
|
|
||||||
## 2. B 端可见性
|
## 2. B 端可见性
|
||||||
|
|
||||||
@ -30,12 +28,12 @@
|
|||||||
|
|
||||||
## 3. C 端生命周期聚合
|
## 3. C 端生命周期聚合
|
||||||
|
|
||||||
接口:`GET /c/patients/lifecycle?phone=...&idCard=...`
|
接口:`GET /c/patients/lifecycle?phone=...&idCardHash=...`
|
||||||
|
|
||||||
查询策略:
|
查询策略:
|
||||||
|
|
||||||
1. 不做医院隔离(跨租户)
|
1. 不做医院隔离(跨租户)
|
||||||
2. 先将 `idCard` 做轻量标准化,再做双字段精确匹配
|
2. 双字段精确匹配 `phone + idCardHash`
|
||||||
3. 关联查询 `Patient -> Device -> TaskItem -> Task`
|
3. 关联查询 `Patient -> Device -> TaskItem -> Task`
|
||||||
4. 返回扁平生命周期列表(按 `Task.createdAt DESC`)
|
4. 返回扁平生命周期列表(按 `Task.createdAt DESC`)
|
||||||
|
|
||||||
|
|||||||
@ -18,11 +18,6 @@
|
|||||||
- 工程师:接收任务、完成自己接收的任务
|
- 工程师:接收任务、完成自己接收的任务
|
||||||
- 其他角色:默认拒绝
|
- 其他角色:默认拒绝
|
||||||
|
|
||||||
补充:
|
|
||||||
|
|
||||||
- `POST /b/tasks/cancel` 现支持可选 `reason` 字段,便于前端保留取消原因输入。
|
|
||||||
- 当前取消原因仅透传到事件层,数据库暂未持久化该字段。
|
|
||||||
|
|
||||||
## 4. 事件触发
|
## 4. 事件触发
|
||||||
|
|
||||||
状态变化后会发出事件:
|
状态变化后会发出事件:
|
||||||
|
|||||||
@ -19,7 +19,6 @@
|
|||||||
- 医院内数据按 `hospitalId` 强隔离。
|
- 医院内数据按 `hospitalId` 强隔离。
|
||||||
- 仅 `SYSTEM_ADMIN` 可执行工程师绑定医院。
|
- 仅 `SYSTEM_ADMIN` 可执行工程师绑定医院。
|
||||||
- `DIRECTOR/LEADER` 可读取用户列表,但仅返回当前科室可见用户。
|
- `DIRECTOR/LEADER` 可读取用户列表,但仅返回当前科室可见用户。
|
||||||
- `DIRECTOR` 可创建、查看、编辑、删除本科室医生,但不能跨科室操作,也不能把医生改成其他角色。
|
|
||||||
- 用户组织字段校验:
|
- 用户组织字段校验:
|
||||||
- 院管/医生/工程师等需有医院归属;
|
- 院管/医生/工程师等需有医院归属;
|
||||||
- 主任/组长需有科室/小组等必要归属;
|
- 主任/组长需有科室/小组等必要归属;
|
||||||
@ -32,13 +31,6 @@
|
|||||||
- `GET /users`、`GET /users/:id`、`PATCH /users/:id`、`DELETE /users/:id`
|
- `GET /users`、`GET /users/:id`、`PATCH /users/:id`、`DELETE /users/:id`
|
||||||
- `POST /b/users/:id/assign-engineer-hospital`
|
- `POST /b/users/:id/assign-engineer-hospital`
|
||||||
|
|
||||||
其中主任侧的常用链路为:
|
|
||||||
|
|
||||||
- `POST /users`:创建本科室医生
|
|
||||||
- `GET /users/:id`:查看本科室医生详情
|
|
||||||
- `PATCH /users/:id`:修改本科室医生信息
|
|
||||||
- `DELETE /users/:id`:删除无关联数据的本科室医生
|
|
||||||
|
|
||||||
## 5. 开发改造建议
|
## 5. 开发改造建议
|
||||||
|
|
||||||
- 若增加角色,请同步修改:
|
- 若增加角色,请同步修改:
|
||||||
|
|||||||
@ -1,8 +0,0 @@
|
|||||||
/*
|
|
||||||
Warnings:
|
|
||||||
|
|
||||||
- A unique constraint covering the columns `[phone,role,hospitalId]` on the table `User` will be added. If there are existing duplicate values, this will fail.
|
|
||||||
|
|
||||||
*/
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "User_phone_role_hospitalId_key" ON "User"("phone", "role", "hospitalId");
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
ALTER TABLE "User"
|
|
||||||
ADD COLUMN "tokenValidAfter" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
ALTER TABLE "Patient"
|
|
||||||
RENAME COLUMN "idCardHash" TO "idCard";
|
|
||||||
|
|
||||||
ALTER INDEX "Patient_phone_idCardHash_idx"
|
|
||||||
RENAME TO "Patient_phone_idCard_idx";
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
ALTER TABLE "User"
|
|
||||||
DROP CONSTRAINT "User_groupId_fkey";
|
|
||||||
|
|
||||||
ALTER TABLE "User"
|
|
||||||
ADD CONSTRAINT "User_groupId_fkey"
|
|
||||||
FOREIGN KEY ("groupId") REFERENCES "Group"("id")
|
|
||||||
ON DELETE RESTRICT
|
|
||||||
ON UPDATE CASCADE;
|
|
||||||
@ -71,27 +71,23 @@ model Group {
|
|||||||
|
|
||||||
// 用户表:支持后台密码登录与小程序 openId。
|
// 用户表:支持后台密码登录与小程序 openId。
|
||||||
model User {
|
model User {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String
|
name String
|
||||||
phone String
|
phone String
|
||||||
// 后台登录密码哈希(bcrypt)。
|
// 后台登录密码哈希(bcrypt)。
|
||||||
passwordHash String?
|
passwordHash String?
|
||||||
// 该时间点之前签发的 token 一律失效。
|
openId String? @unique
|
||||||
tokenValidAfter DateTime @default(now())
|
role Role
|
||||||
openId String? @unique
|
hospitalId Int?
|
||||||
role Role
|
departmentId Int?
|
||||||
hospitalId Int?
|
groupId Int?
|
||||||
departmentId Int?
|
hospital Hospital? @relation(fields: [hospitalId], references: [id])
|
||||||
groupId Int?
|
department Department? @relation(fields: [departmentId], references: [id])
|
||||||
hospital Hospital? @relation(fields: [hospitalId], references: [id])
|
group Group? @relation(fields: [groupId], references: [id])
|
||||||
department Department? @relation(fields: [departmentId], references: [id])
|
doctorPatients Patient[] @relation("DoctorPatients")
|
||||||
// 小组删除必须先清理成员,避免静默把用户 groupId 置空。
|
createdTasks Task[] @relation("TaskCreator")
|
||||||
group Group? @relation(fields: [groupId], references: [id], onDelete: Restrict)
|
acceptedTasks Task[] @relation("TaskEngineer")
|
||||||
doctorPatients Patient[] @relation("DoctorPatients")
|
|
||||||
createdTasks Task[] @relation("TaskCreator")
|
|
||||||
acceptedTasks Task[] @relation("TaskEngineer")
|
|
||||||
|
|
||||||
@@unique([phone, role, hospitalId])
|
|
||||||
@@index([phone])
|
@@index([phone])
|
||||||
@@index([hospitalId, role])
|
@@index([hospitalId, role])
|
||||||
@@index([departmentId, role])
|
@@index([departmentId, role])
|
||||||
@ -103,15 +99,14 @@ model Patient {
|
|||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String
|
name String
|
||||||
phone String
|
phone String
|
||||||
// 患者身份证号,录入与查询都使用原始证件号。
|
idCardHash String
|
||||||
idCard String
|
|
||||||
hospitalId Int
|
hospitalId Int
|
||||||
doctorId Int
|
doctorId Int
|
||||||
hospital Hospital @relation(fields: [hospitalId], references: [id])
|
hospital Hospital @relation(fields: [hospitalId], references: [id])
|
||||||
doctor User @relation("DoctorPatients", fields: [doctorId], references: [id])
|
doctor User @relation("DoctorPatients", fields: [doctorId], references: [id])
|
||||||
devices Device[]
|
devices Device[]
|
||||||
|
|
||||||
@@index([phone, idCard])
|
@@index([phone, idCardHash])
|
||||||
@@index([hospitalId, doctorId])
|
@@index([hospitalId, doctorId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -48,11 +48,7 @@ async function ensureGroup(departmentId, name) {
|
|||||||
async function upsertUserByOpenId(openId, data) {
|
async function upsertUserByOpenId(openId, data) {
|
||||||
return prisma.user.upsert({
|
return prisma.user.upsert({
|
||||||
where: { openId },
|
where: { openId },
|
||||||
// 每次重置/补种子时推进失效时间,确保历史 token 无法继续访问。
|
update: data,
|
||||||
update: {
|
|
||||||
...data,
|
|
||||||
tokenValidAfter: new Date(),
|
|
||||||
},
|
|
||||||
create: {
|
create: {
|
||||||
...data,
|
...data,
|
||||||
openId,
|
openId,
|
||||||
@ -60,12 +56,18 @@ async function upsertUserByOpenId(openId, data) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensurePatient({ hospitalId, doctorId, name, phone, idCard }) {
|
async function ensurePatient({
|
||||||
|
hospitalId,
|
||||||
|
doctorId,
|
||||||
|
name,
|
||||||
|
phone,
|
||||||
|
idCardHash,
|
||||||
|
}) {
|
||||||
const existing = await prisma.patient.findFirst({
|
const existing = await prisma.patient.findFirst({
|
||||||
where: {
|
where: {
|
||||||
hospitalId,
|
hospitalId,
|
||||||
phone,
|
phone,
|
||||||
idCard,
|
idCardHash,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -85,7 +87,7 @@ async function ensurePatient({ hospitalId, doctorId, name, phone, idCard }) {
|
|||||||
doctorId,
|
doctorId,
|
||||||
name,
|
name,
|
||||||
phone,
|
phone,
|
||||||
idCard,
|
idCardHash,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -222,7 +224,7 @@ async function main() {
|
|||||||
doctorId: doctorA.id,
|
doctorId: doctorA.id,
|
||||||
name: 'Seed Patient A1',
|
name: 'Seed Patient A1',
|
||||||
phone: '13800002001',
|
phone: '13800002001',
|
||||||
idCard: '110101199001010011',
|
idCardHash: 'seed-id-card-cross-hospital',
|
||||||
});
|
});
|
||||||
|
|
||||||
const patientA2 = await ensurePatient({
|
const patientA2 = await ensurePatient({
|
||||||
@ -230,7 +232,7 @@ async function main() {
|
|||||||
doctorId: doctorA2.id,
|
doctorId: doctorA2.id,
|
||||||
name: 'Seed Patient A2',
|
name: 'Seed Patient A2',
|
||||||
phone: '13800002002',
|
phone: '13800002002',
|
||||||
idCard: '110101199002020022',
|
idCardHash: 'seed-id-card-a2',
|
||||||
});
|
});
|
||||||
|
|
||||||
const patientA3 = await ensurePatient({
|
const patientA3 = await ensurePatient({
|
||||||
@ -238,7 +240,7 @@ async function main() {
|
|||||||
doctorId: doctorA3.id,
|
doctorId: doctorA3.id,
|
||||||
name: 'Seed Patient A3',
|
name: 'Seed Patient A3',
|
||||||
phone: '13800002003',
|
phone: '13800002003',
|
||||||
idCard: '110101199003030033',
|
idCardHash: 'seed-id-card-a3',
|
||||||
});
|
});
|
||||||
|
|
||||||
const patientB1 = await ensurePatient({
|
const patientB1 = await ensurePatient({
|
||||||
@ -246,7 +248,7 @@ async function main() {
|
|||||||
doctorId: doctorB.id,
|
doctorId: doctorB.id,
|
||||||
name: 'Seed Patient B1',
|
name: 'Seed Patient B1',
|
||||||
phone: '13800002001',
|
phone: '13800002001',
|
||||||
idCard: '110101199001010011',
|
idCardHash: 'seed-id-card-cross-hospital',
|
||||||
});
|
});
|
||||||
|
|
||||||
const deviceA1 = await prisma.device.upsert({
|
const deviceA1 = await prisma.device.upsert({
|
||||||
|
|||||||
@ -7,7 +7,6 @@ import { PatientsModule } from './patients/patients.module.js';
|
|||||||
import { AuthModule } from './auth/auth.module.js';
|
import { AuthModule } from './auth/auth.module.js';
|
||||||
import { OrganizationModule } from './organization/organization.module.js';
|
import { OrganizationModule } from './organization/organization.module.js';
|
||||||
import { NotificationsModule } from './notifications/notifications.module.js';
|
import { NotificationsModule } from './notifications/notifications.module.js';
|
||||||
import { DevicesModule } from './devices/devices.module.js';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -19,7 +18,6 @@ import { DevicesModule } from './devices/devices.module.js';
|
|||||||
AuthModule,
|
AuthModule,
|
||||||
OrganizationModule,
|
OrganizationModule,
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
DevicesModule,
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@ -5,25 +5,25 @@ import {
|
|||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { Role } from '../generated/prisma/enums.js';
|
||||||
import type { ActorContext } from '../common/actor-context.js';
|
import type { ActorContext } from '../common/actor-context.js';
|
||||||
import { MESSAGES } from '../common/messages.js';
|
import { MESSAGES } from '../common/messages.js';
|
||||||
import { PrismaService } from '../prisma.service.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AccessToken 守卫:校验 Bearer JWT 并把 actor 注入到 request 上下文。
|
* AccessToken 守卫:校验 Bearer JWT 并把 actor 注入到 request 上下文。
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AccessTokenGuard implements CanActivate {
|
export class AccessTokenGuard implements CanActivate {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 守卫入口:认证通过返回 true,失败抛出 401。
|
* 守卫入口:认证通过返回 true,失败抛出 401。
|
||||||
*/
|
*/
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
canActivate(context: ExecutionContext): boolean {
|
||||||
const request = context.switchToHttp().getRequest<{
|
const request = context.switchToHttp().getRequest<
|
||||||
headers: Record<string, string | string[] | undefined>;
|
{
|
||||||
actor?: unknown;
|
headers: Record<string, string | string[] | undefined>;
|
||||||
}>();
|
actor?: unknown;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
const authorization = request.headers.authorization;
|
const authorization = request.headers.authorization;
|
||||||
const headerValue = Array.isArray(authorization)
|
const headerValue = Array.isArray(authorization)
|
||||||
@ -35,15 +35,15 @@ export class AccessTokenGuard implements CanActivate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const token = headerValue.slice('Bearer '.length).trim();
|
const token = headerValue.slice('Bearer '.length).trim();
|
||||||
request.actor = await this.verifyAndExtractActor(token);
|
request.actor = this.verifyAndExtractActor(token);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析并验证 token,同时回库确认当前用户仍然有效。
|
* 解析并验证 token,同时提取最小化 actor 上下文。
|
||||||
*/
|
*/
|
||||||
private async verifyAndExtractActor(token: string): Promise<ActorContext> {
|
private verifyAndExtractActor(token: string): ActorContext {
|
||||||
const secret = process.env.AUTH_TOKEN_SECRET;
|
const secret = process.env.AUTH_TOKEN_SECRET;
|
||||||
if (!secret) {
|
if (!secret) {
|
||||||
throw new UnauthorizedException(MESSAGES.AUTH.TOKEN_SECRET_MISSING);
|
throw new UnauthorizedException(MESSAGES.AUTH.TOKEN_SECRET_MISSING);
|
||||||
@ -63,39 +63,17 @@ export class AccessTokenGuard implements CanActivate {
|
|||||||
throw new UnauthorizedException(MESSAGES.AUTH.TOKEN_PAYLOAD_INVALID);
|
throw new UnauthorizedException(MESSAGES.AUTH.TOKEN_PAYLOAD_INVALID);
|
||||||
}
|
}
|
||||||
|
|
||||||
const userId = this.asInt(payload.id, 'id');
|
const role = payload.role;
|
||||||
const issuedAt = this.asInt(payload.iat, 'iat');
|
if (typeof role !== 'string' || !Object.values(Role).includes(role as Role)) {
|
||||||
const user = await this.prisma.user.findUnique({
|
throw new UnauthorizedException(MESSAGES.AUTH.TOKEN_ROLE_INVALID);
|
||||||
where: { id: userId },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
role: true,
|
|
||||||
hospitalId: true,
|
|
||||||
departmentId: true,
|
|
||||||
groupId: true,
|
|
||||||
tokenValidAfter: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 数据库里已经没有该用户时,旧 token 必须立即失效。
|
|
||||||
if (!user) {
|
|
||||||
throw new UnauthorizedException(MESSAGES.AUTH.TOKEN_USER_NOT_FOUND);
|
|
||||||
}
|
|
||||||
|
|
||||||
// JWT 的 iat 精度是秒,这里按秒比较,避免同秒登录被误伤。
|
|
||||||
const tokenValidAfterUnix = Math.floor(
|
|
||||||
user.tokenValidAfter.getTime() / 1000,
|
|
||||||
);
|
|
||||||
if (issuedAt < tokenValidAfterUnix) {
|
|
||||||
throw new UnauthorizedException(MESSAGES.AUTH.TOKEN_REVOKED);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: this.asInt(payload.id, 'id'),
|
||||||
role: user.role,
|
role: role as Role,
|
||||||
hospitalId: user.hospitalId,
|
hospitalId: this.asNullableInt(payload.hospitalId, 'hospitalId'),
|
||||||
departmentId: user.departmentId,
|
departmentId: this.asNullableInt(payload.departmentId, 'departmentId'),
|
||||||
groupId: user.groupId,
|
groupId: this.asNullableInt(payload.groupId, 'groupId'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -104,9 +82,20 @@ export class AccessTokenGuard implements CanActivate {
|
|||||||
*/
|
*/
|
||||||
private asInt(value: unknown, field: string): number {
|
private asInt(value: unknown, field: string): number {
|
||||||
if (typeof value !== 'number' || !Number.isInteger(value)) {
|
if (typeof value !== 'number' || !Number.isInteger(value)) {
|
||||||
throw new UnauthorizedException(
|
throw new UnauthorizedException(`${MESSAGES.AUTH.TOKEN_FIELD_INVALID}: ${field}`);
|
||||||
`${MESSAGES.AUTH.TOKEN_FIELD_INVALID}: ${field}`,
|
}
|
||||||
);
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 严格校验 token 中可空整数的字段。
|
||||||
|
*/
|
||||||
|
private asNullableInt(value: unknown, field: string): number | null {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof value !== 'number' || !Number.isInteger(value)) {
|
||||||
|
throw new UnauthorizedException(`${MESSAGES.AUTH.TOKEN_FIELD_INVALID}: ${field}`);
|
||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,14 +1,18 @@
|
|||||||
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiOperation,
|
||||||
|
ApiTags,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
import { AuthService } from './auth.service.js';
|
import { AuthService } from './auth.service.js';
|
||||||
|
import { RegisterUserDto } from '../users/dto/register-user.dto.js';
|
||||||
import { LoginDto } from '../users/dto/login.dto.js';
|
import { LoginDto } from '../users/dto/login.dto.js';
|
||||||
import { AccessTokenGuard } from './access-token.guard.js';
|
import { AccessTokenGuard } from './access-token.guard.js';
|
||||||
import { CurrentActor } from './current-actor.decorator.js';
|
import { CurrentActor } from './current-actor.decorator.js';
|
||||||
import type { ActorContext } from '../common/actor-context.js';
|
import type { ActorContext } from '../common/actor-context.js';
|
||||||
import { CreateSystemAdminDto } from './dto/create-system-admin.dto.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 认证控制器:提供系统管理员创建、登录、获取当前登录用户信息接口。
|
* 认证控制器:提供注册、登录、获取当前登录用户信息接口。
|
||||||
*/
|
*/
|
||||||
@ApiTags('认证')
|
@ApiTags('认证')
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
@ -16,12 +20,12 @@ export class AuthController {
|
|||||||
constructor(private readonly authService: AuthService) {}
|
constructor(private readonly authService: AuthService) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建系统管理员(需引导密钥)。
|
* 注册账号。
|
||||||
*/
|
*/
|
||||||
@Post('system-admin')
|
@Post('register')
|
||||||
@ApiOperation({ summary: '创建系统管理员' })
|
@ApiOperation({ summary: '注册账号' })
|
||||||
createSystemAdmin(@Body() dto: CreateSystemAdminDto) {
|
register(@Body() dto: RegisterUserDto) {
|
||||||
return this.authService.createSystemAdmin(dto);
|
return this.authService.register(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import type { ActorContext } from '../common/actor-context.js';
|
import type { ActorContext } from '../common/actor-context.js';
|
||||||
import { UsersService } from '../users/users.service.js';
|
import { UsersService } from '../users/users.service.js';
|
||||||
|
import { RegisterUserDto } from '../users/dto/register-user.dto.js';
|
||||||
import { LoginDto } from '../users/dto/login.dto.js';
|
import { LoginDto } from '../users/dto/login.dto.js';
|
||||||
import { CreateSystemAdminDto } from './dto/create-system-admin.dto.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 认证服务:将控制层输入转发到用户域能力,避免控制器直接操作用户仓储。
|
* 认证服务:将控制层输入转发到用户域能力,避免控制器直接操作用户仓储。
|
||||||
@ -12,10 +12,10 @@ export class AuthService {
|
|||||||
constructor(private readonly usersService: UsersService) {}
|
constructor(private readonly usersService: UsersService) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 系统管理员创建能力委托给用户服务。
|
* 注册能力委托给用户服务。
|
||||||
*/
|
*/
|
||||||
createSystemAdmin(dto: CreateSystemAdminDto) {
|
register(dto: RegisterUserDto) {
|
||||||
return this.usersService.createSystemAdmin(dto);
|
return this.usersService.register(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -1,32 +0,0 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|
||||||
import { IsOptional, IsString } from 'class-validator';
|
|
||||||
|
|
||||||
export class CreateSystemAdminDto {
|
|
||||||
@ApiProperty({ description: '姓名', example: '系统管理员' })
|
|
||||||
@IsString({ message: 'name 必须是字符串' })
|
|
||||||
name!: string;
|
|
||||||
|
|
||||||
@ApiProperty({ description: '手机号', example: '13800000000' })
|
|
||||||
@IsString({ message: 'phone 必须是字符串' })
|
|
||||||
phone!: string;
|
|
||||||
|
|
||||||
@ApiProperty({ description: '密码(至少 8 位)', example: 'Admin@12345' })
|
|
||||||
@IsString({ message: 'password 必须是字符串' })
|
|
||||||
password!: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: '可选微信 openId',
|
|
||||||
example: 'o123abcxyz',
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsString({ message: 'openId 必须是字符串' })
|
|
||||||
openId?: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
description:
|
|
||||||
'系统管理员创建引导密钥(来自环境变量 SYSTEM_ADMIN_BOOTSTRAP_KEY)',
|
|
||||||
example: 'init-admin-secret',
|
|
||||||
})
|
|
||||||
@IsString({ message: 'systemAdminBootstrapKey 必须是字符串' })
|
|
||||||
systemAdminBootstrapKey!: string;
|
|
||||||
}
|
|
||||||
@ -21,13 +21,10 @@ export const MESSAGES = {
|
|||||||
TOKEN_SECRET_MISSING: '服务端未配置认证密钥',
|
TOKEN_SECRET_MISSING: '服务端未配置认证密钥',
|
||||||
TOKEN_INVALID: 'Token 无效或已过期',
|
TOKEN_INVALID: 'Token 无效或已过期',
|
||||||
TOKEN_PAYLOAD_INVALID: 'Token 载荷不合法',
|
TOKEN_PAYLOAD_INVALID: 'Token 载荷不合法',
|
||||||
TOKEN_USER_NOT_FOUND: 'Token 对应用户不存在,请重新登录',
|
|
||||||
TOKEN_REVOKED: 'Token 已失效,请重新登录',
|
|
||||||
TOKEN_ROLE_INVALID: 'Token 中角色信息不合法',
|
TOKEN_ROLE_INVALID: 'Token 中角色信息不合法',
|
||||||
TOKEN_FIELD_INVALID: 'Token 中字段不合法',
|
TOKEN_FIELD_INVALID: 'Token 中字段不合法',
|
||||||
INVALID_CREDENTIALS: '手机号、角色或密码错误',
|
INVALID_CREDENTIALS: '手机号、角色或密码错误',
|
||||||
PASSWORD_NOT_ENABLED: '该账号未启用密码登录',
|
PASSWORD_NOT_ENABLED: '该账号未启用密码登录',
|
||||||
REGISTER_DISABLED: '注册接口已关闭,请联系管理员创建账号',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
USER: {
|
USER: {
|
||||||
@ -56,9 +53,6 @@ export const MESSAGES = {
|
|||||||
DELETE_CONFLICT: '用户存在关联患者或任务,无法删除',
|
DELETE_CONFLICT: '用户存在关联患者或任务,无法删除',
|
||||||
MULTI_ACCOUNT_REQUIRE_HOSPITAL:
|
MULTI_ACCOUNT_REQUIRE_HOSPITAL:
|
||||||
'检测到多个同手机号账号,请传 hospitalId 指定登录医院',
|
'检测到多个同手机号账号,请传 hospitalId 指定登录医院',
|
||||||
CREATE_FORBIDDEN: '当前角色无权限创建该用户',
|
|
||||||
HOSPITAL_ADMIN_SCOPE_FORBIDDEN: '医院管理员仅可操作本院非管理员账号',
|
|
||||||
DIRECTOR_SCOPE_FORBIDDEN: '科室主任仅可操作本科室医生账号',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
TASK: {
|
TASK: {
|
||||||
@ -85,25 +79,12 @@ export const MESSAGES = {
|
|||||||
DOCTOR_ROLE_REQUIRED: '归属用户必须为医生/主任/组长角色',
|
DOCTOR_ROLE_REQUIRED: '归属用户必须为医生/主任/组长角色',
|
||||||
DOCTOR_SCOPE_FORBIDDEN: '仅可选择当前权限范围内医生/主任/组长',
|
DOCTOR_SCOPE_FORBIDDEN: '仅可选择当前权限范围内医生/主任/组长',
|
||||||
DELETE_CONFLICT: '患者存在关联设备,无法删除',
|
DELETE_CONFLICT: '患者存在关联设备,无法删除',
|
||||||
PHONE_IDCARD_REQUIRED: 'phone 与 idCard 均为必填',
|
PHONE_IDCARD_REQUIRED: 'phone 与 idCardHash 均为必填',
|
||||||
LIFE_CYCLE_NOT_FOUND: '未找到匹配的患者档案,请先确认手机号与身份证号',
|
LIFE_CYCLE_NOT_FOUND: '未找到匹配的患者档案,请先确认手机号与身份证哈希',
|
||||||
SYSTEM_ADMIN_HOSPITAL_REQUIRED: '系统管理员查询必须显式传入 hospitalId',
|
SYSTEM_ADMIN_HOSPITAL_REQUIRED: '系统管理员查询必须显式传入 hospitalId',
|
||||||
ACTOR_HOSPITAL_REQUIRED: '当前登录上下文缺少医院信息',
|
ACTOR_HOSPITAL_REQUIRED: '当前登录上下文缺少医院信息',
|
||||||
},
|
},
|
||||||
|
|
||||||
DEVICE: {
|
|
||||||
NOT_FOUND: '设备不存在或无权限访问',
|
|
||||||
SN_CODE_REQUIRED: 'snCode 不能为空',
|
|
||||||
SN_CODE_DUPLICATE: '设备 SN 已存在',
|
|
||||||
CURRENT_PRESSURE_INVALID: 'currentPressure 必须为大于等于 0 的整数',
|
|
||||||
STATUS_INVALID: '设备状态不合法',
|
|
||||||
PATIENT_REQUIRED: 'patientId 必填且必须为整数',
|
|
||||||
PATIENT_NOT_FOUND: '归属患者不存在',
|
|
||||||
PATIENT_SCOPE_FORBIDDEN: '仅可绑定当前权限范围内患者',
|
|
||||||
DELETE_CONFLICT: '设备存在关联任务记录,无法删除',
|
|
||||||
ACTOR_HOSPITAL_REQUIRED: '当前登录上下文缺少医院信息',
|
|
||||||
},
|
|
||||||
|
|
||||||
ORG: {
|
ORG: {
|
||||||
HOSPITAL_NOT_FOUND: '医院不存在',
|
HOSPITAL_NOT_FOUND: '医院不存在',
|
||||||
DEPARTMENT_NOT_FOUND: '科室不存在',
|
DEPARTMENT_NOT_FOUND: '科室不存在',
|
||||||
@ -124,7 +105,6 @@ export const MESSAGES = {
|
|||||||
GROUP_DEPARTMENT_MISMATCH: '小组不属于指定科室',
|
GROUP_DEPARTMENT_MISMATCH: '小组不属于指定科室',
|
||||||
DEPARTMENT_REPARENT_FORBIDDEN: '科室不允许更换所属医院',
|
DEPARTMENT_REPARENT_FORBIDDEN: '科室不允许更换所属医院',
|
||||||
GROUP_REPARENT_FORBIDDEN: '小组不允许更换所属科室',
|
GROUP_REPARENT_FORBIDDEN: '小组不允许更换所属科室',
|
||||||
GROUP_DELETE_HAS_USERS: '小组下仍有成员,无法删除,请先调整用户归属',
|
|
||||||
DELETE_CONFLICT:
|
DELETE_CONFLICT:
|
||||||
'存在关联数据,无法删除,请先清理用户、患者、任务或下级组织后重试',
|
'存在关联数据,无法删除,请先清理用户、患者、任务或下级组织后重试',
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,102 +0,0 @@
|
|||||||
import {
|
|
||||||
Body,
|
|
||||||
Controller,
|
|
||||||
Delete,
|
|
||||||
Get,
|
|
||||||
Param,
|
|
||||||
ParseIntPipe,
|
|
||||||
Patch,
|
|
||||||
Post,
|
|
||||||
Query,
|
|
||||||
UseGuards,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import {
|
|
||||||
ApiBearerAuth,
|
|
||||||
ApiOperation,
|
|
||||||
ApiParam,
|
|
||||||
ApiTags,
|
|
||||||
} from '@nestjs/swagger';
|
|
||||||
import { AccessTokenGuard } from '../../auth/access-token.guard.js';
|
|
||||||
import { CurrentActor } from '../../auth/current-actor.decorator.js';
|
|
||||||
import { Roles } from '../../auth/roles.decorator.js';
|
|
||||||
import { RolesGuard } from '../../auth/roles.guard.js';
|
|
||||||
import type { ActorContext } from '../../common/actor-context.js';
|
|
||||||
import { Role } from '../../generated/prisma/enums.js';
|
|
||||||
import { CreateDeviceDto } from '../dto/create-device.dto.js';
|
|
||||||
import { DeviceQueryDto } from '../dto/device-query.dto.js';
|
|
||||||
import { UpdateDeviceDto } from '../dto/update-device.dto.js';
|
|
||||||
import { DevicesService } from '../devices.service.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* B 端设备控制器:仅管理员可访问设备 CRUD。
|
|
||||||
*/
|
|
||||||
@ApiTags('设备管理(B端)')
|
|
||||||
@ApiBearerAuth('bearer')
|
|
||||||
@Controller('b/devices')
|
|
||||||
@UseGuards(AccessTokenGuard, RolesGuard)
|
|
||||||
export class BDevicesController {
|
|
||||||
constructor(private readonly devicesService: DevicesService) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 查询设备列表。
|
|
||||||
*/
|
|
||||||
@Get()
|
|
||||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
|
||||||
@ApiOperation({ summary: '查询设备列表' })
|
|
||||||
findAll(@CurrentActor() actor: ActorContext, @Query() query: DeviceQueryDto) {
|
|
||||||
return this.devicesService.findAll(actor, query);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 查询设备详情。
|
|
||||||
*/
|
|
||||||
@Get(':id')
|
|
||||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
|
||||||
@ApiOperation({ summary: '查询设备详情' })
|
|
||||||
@ApiParam({ name: 'id', description: '设备 ID' })
|
|
||||||
findOne(
|
|
||||||
@CurrentActor() actor: ActorContext,
|
|
||||||
@Param('id', ParseIntPipe) id: number,
|
|
||||||
) {
|
|
||||||
return this.devicesService.findOne(actor, id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建设备。
|
|
||||||
*/
|
|
||||||
@Post()
|
|
||||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
|
||||||
@ApiOperation({ summary: '创建设备' })
|
|
||||||
create(@CurrentActor() actor: ActorContext, @Body() dto: CreateDeviceDto) {
|
|
||||||
return this.devicesService.create(actor, dto);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 更新设备。
|
|
||||||
*/
|
|
||||||
@Patch(':id')
|
|
||||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
|
||||||
@ApiOperation({ summary: '更新设备' })
|
|
||||||
@ApiParam({ name: 'id', description: '设备 ID' })
|
|
||||||
update(
|
|
||||||
@CurrentActor() actor: ActorContext,
|
|
||||||
@Param('id', ParseIntPipe) id: number,
|
|
||||||
@Body() dto: UpdateDeviceDto,
|
|
||||||
) {
|
|
||||||
return this.devicesService.update(actor, id, dto);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 删除设备。
|
|
||||||
*/
|
|
||||||
@Delete(':id')
|
|
||||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
|
||||||
@ApiOperation({ summary: '删除设备' })
|
|
||||||
@ApiParam({ name: 'id', description: '设备 ID' })
|
|
||||||
remove(
|
|
||||||
@CurrentActor() actor: ActorContext,
|
|
||||||
@Param('id', ParseIntPipe) id: number,
|
|
||||||
) {
|
|
||||||
return this.devicesService.remove(actor, id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { AccessTokenGuard } from '../auth/access-token.guard.js';
|
|
||||||
import { RolesGuard } from '../auth/roles.guard.js';
|
|
||||||
import { BDevicesController } from './b-devices/b-devices.controller.js';
|
|
||||||
import { DevicesService } from './devices.service.js';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
controllers: [BDevicesController],
|
|
||||||
providers: [DevicesService, AccessTokenGuard, RolesGuard],
|
|
||||||
exports: [DevicesService],
|
|
||||||
})
|
|
||||||
export class DevicesModule {}
|
|
||||||
@ -1,403 +0,0 @@
|
|||||||
import {
|
|
||||||
BadRequestException,
|
|
||||||
ConflictException,
|
|
||||||
ForbiddenException,
|
|
||||||
Injectable,
|
|
||||||
NotFoundException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { Prisma } from '../generated/prisma/client.js';
|
|
||||||
import { DeviceStatus, Role } from '../generated/prisma/enums.js';
|
|
||||||
import type { ActorContext } from '../common/actor-context.js';
|
|
||||||
import { MESSAGES } from '../common/messages.js';
|
|
||||||
import { PrismaService } from '../prisma.service.js';
|
|
||||||
import { CreateDeviceDto } from './dto/create-device.dto.js';
|
|
||||||
import { DeviceQueryDto } from './dto/device-query.dto.js';
|
|
||||||
import { UpdateDeviceDto } from './dto/update-device.dto.js';
|
|
||||||
|
|
||||||
const DEVICE_DETAIL_INCLUDE = {
|
|
||||||
patient: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
phone: true,
|
|
||||||
hospitalId: true,
|
|
||||||
hospital: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
doctor: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
role: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
_count: {
|
|
||||||
select: {
|
|
||||||
taskItems: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 设备服务:承载管理员设备 CRUD、租户隔离与分页筛选。
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class DevicesService {
|
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 查询设备列表:系统管理员可跨院查询,院管仅限本院。
|
|
||||||
*/
|
|
||||||
async findAll(actor: ActorContext, query: DeviceQueryDto) {
|
|
||||||
this.assertAdmin(actor);
|
|
||||||
|
|
||||||
const paging = this.resolvePaging(query);
|
|
||||||
const scopedHospitalId = this.resolveScopedHospitalId(
|
|
||||||
actor,
|
|
||||||
query.hospitalId,
|
|
||||||
);
|
|
||||||
const where = this.buildListWhere(query, scopedHospitalId);
|
|
||||||
|
|
||||||
const [total, list] = await this.prisma.$transaction([
|
|
||||||
this.prisma.device.count({ where }),
|
|
||||||
this.prisma.device.findMany({
|
|
||||||
where,
|
|
||||||
include: DEVICE_DETAIL_INCLUDE,
|
|
||||||
skip: paging.skip,
|
|
||||||
take: paging.take,
|
|
||||||
orderBy: { id: 'desc' },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
total,
|
|
||||||
...paging,
|
|
||||||
list,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 查询设备详情。
|
|
||||||
*/
|
|
||||||
async findOne(actor: ActorContext, id: number) {
|
|
||||||
this.assertAdmin(actor);
|
|
||||||
|
|
||||||
const deviceId = this.toInt(id, 'id');
|
|
||||||
const device = await this.prisma.device.findUnique({
|
|
||||||
where: { id: deviceId },
|
|
||||||
include: DEVICE_DETAIL_INCLUDE,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!device) {
|
|
||||||
throw new NotFoundException(MESSAGES.DEVICE.NOT_FOUND);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.assertDeviceReadable(actor, device.patient.hospitalId);
|
|
||||||
return device;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建设备:归属患者必须在当前管理员可写范围内。
|
|
||||||
*/
|
|
||||||
async create(actor: ActorContext, dto: CreateDeviceDto) {
|
|
||||||
this.assertAdmin(actor);
|
|
||||||
|
|
||||||
const snCode = this.normalizeSnCode(dto.snCode);
|
|
||||||
const patient = await this.resolveWritablePatient(actor, dto.patientId);
|
|
||||||
await this.assertSnCodeUnique(snCode);
|
|
||||||
|
|
||||||
return this.prisma.device.create({
|
|
||||||
data: {
|
|
||||||
snCode,
|
|
||||||
currentPressure: this.normalizePressure(dto.currentPressure),
|
|
||||||
status: dto.status ?? DeviceStatus.ACTIVE,
|
|
||||||
patientId: patient.id,
|
|
||||||
},
|
|
||||||
include: DEVICE_DETAIL_INCLUDE,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 更新设备:允许修改 SN、当前压力、状态和归属患者。
|
|
||||||
*/
|
|
||||||
async update(actor: ActorContext, id: number, dto: UpdateDeviceDto) {
|
|
||||||
const current = await this.findOne(actor, id);
|
|
||||||
|
|
||||||
const data: Prisma.DeviceUpdateInput = {};
|
|
||||||
if (dto.snCode !== undefined) {
|
|
||||||
const snCode = this.normalizeSnCode(dto.snCode);
|
|
||||||
await this.assertSnCodeUnique(snCode, current.id);
|
|
||||||
data.snCode = snCode;
|
|
||||||
}
|
|
||||||
if (dto.currentPressure !== undefined) {
|
|
||||||
data.currentPressure = this.normalizePressure(dto.currentPressure);
|
|
||||||
}
|
|
||||||
if (dto.status !== undefined) {
|
|
||||||
data.status = this.normalizeStatus(dto.status);
|
|
||||||
}
|
|
||||||
if (dto.patientId !== undefined) {
|
|
||||||
const patient = await this.resolveWritablePatient(actor, dto.patientId);
|
|
||||||
data.patient = { connect: { id: patient.id } };
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.prisma.device.update({
|
|
||||||
where: { id: current.id },
|
|
||||||
data,
|
|
||||||
include: DEVICE_DETAIL_INCLUDE,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 删除设备:若设备已被任务明细引用,则返回 409。
|
|
||||||
*/
|
|
||||||
async remove(actor: ActorContext, id: number) {
|
|
||||||
const current = await this.findOne(actor, id);
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await this.prisma.device.delete({
|
|
||||||
where: { id: current.id },
|
|
||||||
include: DEVICE_DETAIL_INCLUDE,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (
|
|
||||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
|
||||||
(error.code === 'P2003' || error.code === 'P2014')
|
|
||||||
) {
|
|
||||||
throw new ConflictException(MESSAGES.DEVICE.DELETE_CONFLICT);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 构造列表筛选:支持按医院、患者、状态和关键词组合查询。
|
|
||||||
*/
|
|
||||||
private buildListWhere(query: DeviceQueryDto, scopedHospitalId?: number) {
|
|
||||||
const andConditions: Prisma.DeviceWhereInput[] = [];
|
|
||||||
const keyword = query.keyword?.trim();
|
|
||||||
|
|
||||||
if (scopedHospitalId != null) {
|
|
||||||
andConditions.push({
|
|
||||||
patient: {
|
|
||||||
is: {
|
|
||||||
hospitalId: scopedHospitalId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (query.patientId != null) {
|
|
||||||
andConditions.push({
|
|
||||||
patientId: query.patientId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (query.status != null) {
|
|
||||||
andConditions.push({
|
|
||||||
status: query.status,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (keyword) {
|
|
||||||
andConditions.push({
|
|
||||||
OR: [
|
|
||||||
{
|
|
||||||
snCode: {
|
|
||||||
contains: keyword,
|
|
||||||
mode: 'insensitive',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
patient: {
|
|
||||||
is: {
|
|
||||||
name: {
|
|
||||||
contains: keyword,
|
|
||||||
mode: 'insensitive',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
patient: {
|
|
||||||
is: {
|
|
||||||
phone: {
|
|
||||||
contains: keyword,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return andConditions.length > 0 ? { AND: andConditions } : {};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 解析列表分页。
|
|
||||||
*/
|
|
||||||
private resolvePaging(query: DeviceQueryDto) {
|
|
||||||
const page = query.page && query.page > 0 ? query.page : 1;
|
|
||||||
const pageSize =
|
|
||||||
query.pageSize && query.pageSize > 0 && query.pageSize <= 100
|
|
||||||
? query.pageSize
|
|
||||||
: 20;
|
|
||||||
|
|
||||||
return {
|
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
skip: (page - 1) * pageSize,
|
|
||||||
take: pageSize,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 解析当前查询实际生效的医院作用域。
|
|
||||||
*/
|
|
||||||
private resolveScopedHospitalId(
|
|
||||||
actor: ActorContext,
|
|
||||||
hospitalId?: number,
|
|
||||||
): number | undefined {
|
|
||||||
if (actor.role === Role.SYSTEM_ADMIN) {
|
|
||||||
return hospitalId;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.requireActorHospitalId(actor);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 读取并校验当前管理员可写的患者。
|
|
||||||
*/
|
|
||||||
private async resolveWritablePatient(actor: ActorContext, patientId: number) {
|
|
||||||
const normalizedPatientId = this.toInt(
|
|
||||||
patientId,
|
|
||||||
MESSAGES.DEVICE.PATIENT_REQUIRED,
|
|
||||||
);
|
|
||||||
|
|
||||||
const patient = await this.prisma.patient.findUnique({
|
|
||||||
where: { id: normalizedPatientId },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
hospitalId: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!patient) {
|
|
||||||
throw new NotFoundException(MESSAGES.DEVICE.PATIENT_NOT_FOUND);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
actor.role === Role.HOSPITAL_ADMIN &&
|
|
||||||
patient.hospitalId !== this.requireActorHospitalId(actor)
|
|
||||||
) {
|
|
||||||
throw new ForbiddenException(MESSAGES.DEVICE.PATIENT_SCOPE_FORBIDDEN);
|
|
||||||
}
|
|
||||||
|
|
||||||
return patient;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 校验当前用户是否可读/写该设备。
|
|
||||||
*/
|
|
||||||
private assertDeviceReadable(actor: ActorContext, hospitalId: number) {
|
|
||||||
if (actor.role === Role.SYSTEM_ADMIN) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hospitalId !== this.requireActorHospitalId(actor)) {
|
|
||||||
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 管理员角色校验:仅系统管理员与院管可操作设备。
|
|
||||||
*/
|
|
||||||
private assertAdmin(actor: ActorContext) {
|
|
||||||
if (
|
|
||||||
actor.role !== Role.SYSTEM_ADMIN &&
|
|
||||||
actor.role !== Role.HOSPITAL_ADMIN
|
|
||||||
) {
|
|
||||||
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 设备 SN 标准化:统一去空白并转大写,避免大小写重复。
|
|
||||||
*/
|
|
||||||
private normalizeSnCode(value: unknown) {
|
|
||||||
if (typeof value !== 'string') {
|
|
||||||
throw new BadRequestException(MESSAGES.DEVICE.SN_CODE_REQUIRED);
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalized = value.trim().toUpperCase();
|
|
||||||
if (!normalized) {
|
|
||||||
throw new BadRequestException(MESSAGES.DEVICE.SN_CODE_REQUIRED);
|
|
||||||
}
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 压力值必须是非负整数。
|
|
||||||
*/
|
|
||||||
private normalizePressure(value: unknown) {
|
|
||||||
const parsed = Number(value);
|
|
||||||
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
||||||
throw new BadRequestException(MESSAGES.DEVICE.CURRENT_PRESSURE_INVALID);
|
|
||||||
}
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 设备状态枚举校验。
|
|
||||||
*/
|
|
||||||
private normalizeStatus(value: unknown): DeviceStatus {
|
|
||||||
if (!Object.values(DeviceStatus).includes(value as DeviceStatus)) {
|
|
||||||
throw new BadRequestException(MESSAGES.DEVICE.STATUS_INVALID);
|
|
||||||
}
|
|
||||||
return value as DeviceStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 统一整数参数校验。
|
|
||||||
*/
|
|
||||||
private toInt(value: unknown, message: string) {
|
|
||||||
const parsed = Number(value);
|
|
||||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
||||||
throw new BadRequestException(message);
|
|
||||||
}
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 当前登录上下文中的医院 ID 对院管是必填项。
|
|
||||||
*/
|
|
||||||
private requireActorHospitalId(actor: ActorContext) {
|
|
||||||
if (
|
|
||||||
typeof actor.hospitalId !== 'number' ||
|
|
||||||
!Number.isInteger(actor.hospitalId) ||
|
|
||||||
actor.hospitalId <= 0
|
|
||||||
) {
|
|
||||||
throw new BadRequestException(MESSAGES.DEVICE.ACTOR_HOSPITAL_REQUIRED);
|
|
||||||
}
|
|
||||||
return actor.hospitalId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 确保设备 SN 唯一;更新时允许命中自身。
|
|
||||||
*/
|
|
||||||
private async assertSnCodeUnique(snCode: string, selfId?: number) {
|
|
||||||
const existing = await this.prisma.device.findUnique({
|
|
||||||
where: { snCode },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existing && existing.id !== selfId) {
|
|
||||||
throw new ConflictException(MESSAGES.DEVICE.SN_CODE_DUPLICATE);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|
||||||
import { DeviceStatus } from '../../generated/prisma/enums.js';
|
|
||||||
import { Type } from 'class-transformer';
|
|
||||||
import { IsEnum, IsInt, IsOptional, IsString, Min } from 'class-validator';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建设备 DTO。
|
|
||||||
*/
|
|
||||||
export class CreateDeviceDto {
|
|
||||||
@ApiProperty({ description: '设备 SN', example: 'TYT-SN-10001' })
|
|
||||||
@IsString({ message: 'snCode 必须是字符串' })
|
|
||||||
snCode!: string;
|
|
||||||
|
|
||||||
@ApiProperty({ description: '当前压力值', example: 120 })
|
|
||||||
@Type(() => Number)
|
|
||||||
@IsInt({ message: 'currentPressure 必须是整数' })
|
|
||||||
@Min(0, { message: 'currentPressure 必须大于等于 0' })
|
|
||||||
currentPressure!: number;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: '设备状态,默认 ACTIVE',
|
|
||||||
enum: DeviceStatus,
|
|
||||||
example: DeviceStatus.ACTIVE,
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsEnum(DeviceStatus, { message: 'status 枚举值不合法' })
|
|
||||||
status?: DeviceStatus;
|
|
||||||
|
|
||||||
@ApiProperty({ description: '归属患者 ID', example: 1 })
|
|
||||||
@Type(() => Number)
|
|
||||||
@IsInt({ message: 'patientId 必须是整数' })
|
|
||||||
@Min(1, { message: 'patientId 必须大于 0' })
|
|
||||||
patientId!: number;
|
|
||||||
}
|
|
||||||
@ -1,68 +0,0 @@
|
|||||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
|
||||||
import { DeviceStatus } from '../../generated/prisma/enums.js';
|
|
||||||
import { Type } from 'class-transformer';
|
|
||||||
import { EmptyStringToUndefined } from '../../common/transforms/empty-string-to-undefined.transform.js';
|
|
||||||
import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 设备列表查询 DTO:支持管理员后台按设备、患者和医院筛选。
|
|
||||||
*/
|
|
||||||
export class DeviceQueryDto {
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: '关键词(支持设备 SN / 患者姓名 / 患者手机号)',
|
|
||||||
example: 'SN-A',
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsString({ message: 'keyword 必须是字符串' })
|
|
||||||
keyword?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: '设备状态',
|
|
||||||
enum: DeviceStatus,
|
|
||||||
example: DeviceStatus.ACTIVE,
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsEnum(DeviceStatus, { message: 'status 枚举值不合法' })
|
|
||||||
status?: DeviceStatus;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: '医院 ID', example: 1 })
|
|
||||||
@IsOptional()
|
|
||||||
@EmptyStringToUndefined()
|
|
||||||
@Type(() => Number)
|
|
||||||
@IsInt({ message: 'hospitalId 必须是整数' })
|
|
||||||
@Min(1, { message: 'hospitalId 必须大于 0' })
|
|
||||||
hospitalId?: number;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: '患者 ID', example: 1 })
|
|
||||||
@IsOptional()
|
|
||||||
@EmptyStringToUndefined()
|
|
||||||
@Type(() => Number)
|
|
||||||
@IsInt({ message: 'patientId 必须是整数' })
|
|
||||||
@Min(1, { message: 'patientId 必须大于 0' })
|
|
||||||
patientId?: number;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: '页码(默认 1)',
|
|
||||||
example: 1,
|
|
||||||
default: 1,
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@EmptyStringToUndefined()
|
|
||||||
@Type(() => Number)
|
|
||||||
@IsInt({ message: 'page 必须是整数' })
|
|
||||||
@Min(1, { message: 'page 最小为 1' })
|
|
||||||
page?: number = 1;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: '每页数量(默认 20,最大 100)',
|
|
||||||
example: 20,
|
|
||||||
default: 20,
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@EmptyStringToUndefined()
|
|
||||||
@Type(() => Number)
|
|
||||||
@IsInt({ message: 'pageSize 必须是整数' })
|
|
||||||
@Min(1, { message: 'pageSize 最小为 1' })
|
|
||||||
@Max(100, { message: 'pageSize 最大为 100' })
|
|
||||||
pageSize?: number = 20;
|
|
||||||
}
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
import { PartialType } from '@nestjs/swagger';
|
|
||||||
import { CreateDeviceDto } from './create-device.dto.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 更新设备 DTO。
|
|
||||||
*/
|
|
||||||
export class UpdateDeviceDto extends PartialType(CreateDeviceDto) {}
|
|
||||||
@ -1,6 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
ConflictException,
|
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
@ -34,10 +33,7 @@ export class GroupsService {
|
|||||||
Role.HOSPITAL_ADMIN,
|
Role.HOSPITAL_ADMIN,
|
||||||
Role.DIRECTOR,
|
Role.DIRECTOR,
|
||||||
]);
|
]);
|
||||||
const departmentId = this.access.toInt(
|
const departmentId = this.access.toInt(dto.departmentId, MESSAGES.ORG.DEPARTMENT_ID_REQUIRED);
|
||||||
dto.departmentId,
|
|
||||||
MESSAGES.ORG.DEPARTMENT_ID_REQUIRED,
|
|
||||||
);
|
|
||||||
const department = await this.access.ensureDepartmentExists(departmentId);
|
const department = await this.access.ensureDepartmentExists(departmentId);
|
||||||
if (actor.role === Role.HOSPITAL_ADMIN) {
|
if (actor.role === Role.HOSPITAL_ADMIN) {
|
||||||
this.access.assertHospitalScope(actor, department.hospitalId);
|
this.access.assertHospitalScope(actor, department.hospitalId);
|
||||||
@ -51,10 +47,7 @@ export class GroupsService {
|
|||||||
|
|
||||||
return this.prisma.group.create({
|
return this.prisma.group.create({
|
||||||
data: {
|
data: {
|
||||||
name: this.access.normalizeName(
|
name: this.access.normalizeName(dto.name, MESSAGES.ORG.GROUP_NAME_REQUIRED),
|
||||||
dto.name,
|
|
||||||
MESSAGES.ORG.GROUP_NAME_REQUIRED,
|
|
||||||
),
|
|
||||||
departmentId,
|
departmentId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -77,26 +70,18 @@ export class GroupsService {
|
|||||||
where.name = { contains: query.keyword.trim(), mode: 'insensitive' };
|
where.name = { contains: query.keyword.trim(), mode: 'insensitive' };
|
||||||
}
|
}
|
||||||
if (query.departmentId != null) {
|
if (query.departmentId != null) {
|
||||||
where.departmentId = this.access.toInt(
|
where.departmentId = this.access.toInt(query.departmentId, MESSAGES.ORG.DEPARTMENT_ID_REQUIRED);
|
||||||
query.departmentId,
|
|
||||||
MESSAGES.ORG.DEPARTMENT_ID_REQUIRED,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (actor.role === Role.HOSPITAL_ADMIN) {
|
if (actor.role === Role.HOSPITAL_ADMIN) {
|
||||||
where.department = {
|
where.department = { hospitalId: this.access.requireActorHospitalId(actor) };
|
||||||
hospitalId: this.access.requireActorHospitalId(actor),
|
|
||||||
};
|
|
||||||
} else if (actor.role === Role.DIRECTOR) {
|
} else if (actor.role === Role.DIRECTOR) {
|
||||||
where.departmentId = this.access.requireActorDepartmentId(actor);
|
where.departmentId = this.access.requireActorDepartmentId(actor);
|
||||||
} else if (actor.role === Role.LEADER) {
|
} else if (actor.role === Role.LEADER) {
|
||||||
where.id = this.access.requireActorGroupId(actor);
|
where.id = this.access.requireActorGroupId(actor);
|
||||||
} else if (query.hospitalId != null) {
|
} else if (query.hospitalId != null) {
|
||||||
where.department = {
|
where.department = {
|
||||||
hospitalId: this.access.toInt(
|
hospitalId: this.access.toInt(query.hospitalId, MESSAGES.ORG.HOSPITAL_ID_REQUIRED),
|
||||||
query.hospitalId,
|
|
||||||
MESSAGES.ORG.HOSPITAL_ID_REQUIRED,
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -168,10 +153,7 @@ export class GroupsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (dto.name !== undefined) {
|
if (dto.name !== undefined) {
|
||||||
data.name = this.access.normalizeName(
|
data.name = this.access.normalizeName(dto.name, MESSAGES.ORG.GROUP_NAME_REQUIRED);
|
||||||
dto.name,
|
|
||||||
MESSAGES.ORG.GROUP_NAME_REQUIRED,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.prisma.group.update({
|
return this.prisma.group.update({
|
||||||
@ -190,12 +172,6 @@ export class GroupsService {
|
|||||||
Role.DIRECTOR,
|
Role.DIRECTOR,
|
||||||
]);
|
]);
|
||||||
const current = await this.findOne(actor, id);
|
const current = await this.findOne(actor, id);
|
||||||
|
|
||||||
// 业务层先拦截,给前端稳定中文提示;数据库层仍保留 RESTRICT 兜底。
|
|
||||||
if (current._count.users > 0) {
|
|
||||||
throw new ConflictException(MESSAGES.ORG.GROUP_DELETE_HAS_USERS);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await this.prisma.group.delete({ where: { id: current.id } });
|
return await this.prisma.group.delete({ where: { id: current.id } });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import { ResponseEnvelopeInterceptor } from './common/response-envelope.intercep
|
|||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
// 创建应用实例并加载核心模块。
|
// 创建应用实例并加载核心模块。
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
app.enableCors();
|
|
||||||
// 全局校验:清理未知字段、自动类型转换,并将校验错误统一为中文信息。
|
// 全局校验:清理未知字段、自动类型转换,并将校验错误统一为中文信息。
|
||||||
app.useGlobalPipes(
|
app.useGlobalPipes(
|
||||||
new ValidationPipe({
|
new ValidationPipe({
|
||||||
@ -39,7 +39,7 @@ async function bootstrap() {
|
|||||||
.setTitle('TYT 多租户医疗调压系统 API')
|
.setTitle('TYT 多租户医疗调压系统 API')
|
||||||
.setDescription('后端接口文档(含认证、RBAC、任务流转与患者聚合)')
|
.setDescription('后端接口文档(含认证、RBAC、任务流转与患者聚合)')
|
||||||
.setVersion('1.0.0')
|
.setVersion('1.0.0')
|
||||||
.addServer('http://192.168.0.140:3000', 'localhost')
|
.addServer('http://localhost:3000', 'localhost')
|
||||||
.addBearerAuth(
|
.addBearerAuth(
|
||||||
{
|
{
|
||||||
type: 'http',
|
type: 'http',
|
||||||
|
|||||||
@ -15,10 +15,7 @@ export class WechatNotifyService {
|
|||||||
/**
|
/**
|
||||||
* 任务通知发送入口:后续可在此接入微信服务号/小程序订阅消息 API。
|
* 任务通知发送入口:后续可在此接入微信服务号/小程序订阅消息 API。
|
||||||
*/
|
*/
|
||||||
async notifyTaskChange(
|
async notifyTaskChange(openIds: Array<string | null | undefined>, payload: TaskNotifyPayload) {
|
||||||
openIds: Array<string | null | undefined>,
|
|
||||||
payload: TaskNotifyPayload,
|
|
||||||
) {
|
|
||||||
const targets = Array.from(
|
const targets = Array.from(
|
||||||
new Set(
|
new Set(
|
||||||
openIds
|
openIds
|
||||||
@ -35,22 +32,10 @@ export class WechatNotifyService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const openId of targets) {
|
for (const openId of targets) {
|
||||||
const maskedOpenId = this.maskOpenId(openId);
|
|
||||||
// TODO: 在此处调用微信服务号/小程序消息推送 API。
|
// TODO: 在此处调用微信服务号/小程序消息推送 API。
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`模拟推送任务通知 event=${payload.event}, taskId=${payload.taskId}, openId=${maskedOpenId}`,
|
`模拟推送任务通知 event=${payload.event}, taskId=${payload.taskId}, openId=${openId}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 日志脱敏:仅保留 openId 首尾片段,避免完整标识泄露。
|
|
||||||
*/
|
|
||||||
private maskOpenId(openId: string) {
|
|
||||||
if (openId.length <= 6) {
|
|
||||||
return '***';
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${openId.slice(0, 3)}***${openId.slice(-3)}`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,7 +12,6 @@ import type { ActorContext } from '../../common/actor-context.js';
|
|||||||
import { MESSAGES } from '../../common/messages.js';
|
import { MESSAGES } from '../../common/messages.js';
|
||||||
import { CreatePatientDto } from '../dto/create-patient.dto.js';
|
import { CreatePatientDto } from '../dto/create-patient.dto.js';
|
||||||
import { UpdatePatientDto } from '../dto/update-patient.dto.js';
|
import { UpdatePatientDto } from '../dto/update-patient.dto.js';
|
||||||
import { normalizePatientIdCard } from '../patient-id-card.util.js';
|
|
||||||
|
|
||||||
const PATIENT_OWNER_ROLES: Role[] = [Role.DOCTOR, Role.DIRECTOR, Role.LEADER];
|
const PATIENT_OWNER_ROLES: Role[] = [Role.DOCTOR, Role.DIRECTOR, Role.LEADER];
|
||||||
|
|
||||||
@ -99,8 +98,7 @@ export class BPatientsService {
|
|||||||
data: {
|
data: {
|
||||||
name: this.normalizeRequiredString(dto.name, 'name'),
|
name: this.normalizeRequiredString(dto.name, 'name'),
|
||||||
phone: this.normalizePhone(dto.phone),
|
phone: this.normalizePhone(dto.phone),
|
||||||
// 身份证统一做轻量标准化后落库,数据库中保存原始证件号而不是哈希。
|
idCardHash: this.normalizeRequiredString(dto.idCardHash, 'idCardHash'),
|
||||||
idCard: this.normalizeIdCard(dto.idCard),
|
|
||||||
hospitalId: doctor.hospitalId!,
|
hospitalId: doctor.hospitalId!,
|
||||||
doctorId: doctor.id,
|
doctorId: doctor.id,
|
||||||
},
|
},
|
||||||
@ -135,9 +133,8 @@ export class BPatientsService {
|
|||||||
if (dto.phone !== undefined) {
|
if (dto.phone !== undefined) {
|
||||||
data.phone = this.normalizePhone(dto.phone);
|
data.phone = this.normalizePhone(dto.phone);
|
||||||
}
|
}
|
||||||
if (dto.idCard !== undefined) {
|
if (dto.idCardHash !== undefined) {
|
||||||
// 更新时沿用同一标准化逻辑,保证查询条件与落库格式一致。
|
data.idCardHash = this.normalizeRequiredString(dto.idCardHash, 'idCardHash');
|
||||||
data.idCard = this.normalizeIdCard(dto.idCard);
|
|
||||||
}
|
}
|
||||||
if (dto.doctorId !== undefined) {
|
if (dto.doctorId !== undefined) {
|
||||||
const doctor = await this.resolveWritableDoctor(actor, dto.doctorId);
|
const doctor = await this.resolveWritableDoctor(actor, dto.doctorId);
|
||||||
@ -237,10 +234,7 @@ export class BPatientsService {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
case Role.DIRECTOR:
|
case Role.DIRECTOR:
|
||||||
if (
|
if (!actor.departmentId || patient.doctor.departmentId !== actor.departmentId) {
|
||||||
!actor.departmentId ||
|
|
||||||
patient.doctor.departmentId !== actor.departmentId
|
|
||||||
) {
|
|
||||||
throw new ForbiddenException(MESSAGES.PATIENT.ROLE_FORBIDDEN);
|
throw new ForbiddenException(MESSAGES.PATIENT.ROLE_FORBIDDEN);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@ -366,9 +360,7 @@ export class BPatientsService {
|
|||||||
normalizedHospitalId == null ||
|
normalizedHospitalId == null ||
|
||||||
!Number.isInteger(normalizedHospitalId)
|
!Number.isInteger(normalizedHospitalId)
|
||||||
) {
|
) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(MESSAGES.PATIENT.SYSTEM_ADMIN_HOSPITAL_REQUIRED);
|
||||||
MESSAGES.PATIENT.SYSTEM_ADMIN_HOSPITAL_REQUIRED,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return normalizedHospitalId;
|
return normalizedHospitalId;
|
||||||
}
|
}
|
||||||
@ -398,12 +390,4 @@ export class BPatientsService {
|
|||||||
}
|
}
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 统一整理身份证号,避免空格和末尾 x 大小写带来重复数据。
|
|
||||||
*/
|
|
||||||
private normalizeIdCard(value: unknown) {
|
|
||||||
const normalized = this.normalizeRequiredString(value, 'idCard');
|
|
||||||
return normalizePatientIdCard(normalized);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,5 @@
|
|||||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Query } from '@nestjs/common';
|
||||||
import {
|
import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
|
||||||
ApiBearerAuth,
|
|
||||||
ApiOperation,
|
|
||||||
ApiQuery,
|
|
||||||
ApiTags,
|
|
||||||
} from '@nestjs/swagger';
|
|
||||||
import { AccessTokenGuard } from '../../auth/access-token.guard.js';
|
|
||||||
import { FamilyLifecycleQueryDto } from '../dto/family-lifecycle-query.dto.js';
|
import { FamilyLifecycleQueryDto } from '../dto/family-lifecycle-query.dto.js';
|
||||||
import { CPatientsService } from './c-patients.service.js';
|
import { CPatientsService } from './c-patients.service.js';
|
||||||
|
|
||||||
@ -13,23 +7,21 @@ import { CPatientsService } from './c-patients.service.js';
|
|||||||
* C 端患者控制器:家属跨院聚合查询。
|
* C 端患者控制器:家属跨院聚合查询。
|
||||||
*/
|
*/
|
||||||
@ApiTags('患者管理(C端)')
|
@ApiTags('患者管理(C端)')
|
||||||
@ApiBearerAuth('bearer')
|
|
||||||
@Controller('c/patients')
|
@Controller('c/patients')
|
||||||
@UseGuards(AccessTokenGuard)
|
|
||||||
export class CPatientsController {
|
export class CPatientsController {
|
||||||
constructor(private readonly patientsService: CPatientsService) {}
|
constructor(private readonly patientsService: CPatientsService) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据手机号和身份证号查询跨院生命周期。
|
* 根据手机号和身份证哈希查询跨院生命周期。
|
||||||
*/
|
*/
|
||||||
@Get('lifecycle')
|
@Get('lifecycle')
|
||||||
@ApiOperation({ summary: '跨院患者生命周期查询' })
|
@ApiOperation({ summary: '跨院患者生命周期查询' })
|
||||||
@ApiQuery({ name: 'phone', description: '手机号' })
|
@ApiQuery({ name: 'phone', description: '手机号' })
|
||||||
@ApiQuery({ name: 'idCard', description: '身份证号' })
|
@ApiQuery({ name: 'idCardHash', description: '身份证哈希' })
|
||||||
getLifecycle(@Query() query: FamilyLifecycleQueryDto) {
|
getLifecycle(@Query() query: FamilyLifecycleQueryDto) {
|
||||||
return this.patientsService.getFamilyLifecycleByIdentity(
|
return this.patientsService.getFamilyLifecycleByIdentity(
|
||||||
query.phone,
|
query.phone,
|
||||||
query.idCard,
|
query.idCardHash,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,6 @@
|
|||||||
import {
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
BadRequestException,
|
|
||||||
Injectable,
|
|
||||||
NotFoundException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { PrismaService } from '../../prisma.service.js';
|
import { PrismaService } from '../../prisma.service.js';
|
||||||
import { MESSAGES } from '../../common/messages.js';
|
import { MESSAGES } from '../../common/messages.js';
|
||||||
import { normalizePatientIdCard } from '../patient-id-card.util.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* C 端患者服务:承载家属跨院生命周期聚合查询。
|
* C 端患者服务:承载家属跨院生命周期聚合查询。
|
||||||
@ -15,20 +10,17 @@ export class CPatientsService {
|
|||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* C 端查询:按 phone + idCard 跨院聚合患者生命周期记录。
|
* C 端查询:按 phone + idCardHash 跨院聚合患者生命周期记录。
|
||||||
*/
|
*/
|
||||||
async getFamilyLifecycleByIdentity(phone: string, idCard: string) {
|
async getFamilyLifecycleByIdentity(phone: string, idCardHash: string) {
|
||||||
if (!phone || !idCard) {
|
if (!phone || !idCardHash) {
|
||||||
throw new BadRequestException(MESSAGES.PATIENT.PHONE_IDCARD_REQUIRED);
|
throw new BadRequestException(MESSAGES.PATIENT.PHONE_IDCARD_REQUIRED);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查询侧统一整理身份证格式,避免空格或末尾 x 大小写导致查不到。
|
|
||||||
const normalizedIdCard = normalizePatientIdCard(idCard);
|
|
||||||
|
|
||||||
const patients = await this.prisma.patient.findMany({
|
const patients = await this.prisma.patient.findMany({
|
||||||
where: {
|
where: {
|
||||||
phone,
|
phone,
|
||||||
idCard: normalizedIdCard,
|
idCardHash,
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
hospital: { select: { id: true, name: true } },
|
hospital: { select: { id: true, name: true } },
|
||||||
@ -65,6 +57,7 @@ export class CPatientsService {
|
|||||||
patient: {
|
patient: {
|
||||||
id: this.toJsonNumber(patient.id),
|
id: this.toJsonNumber(patient.id),
|
||||||
name: patient.name,
|
name: patient.name,
|
||||||
|
phone: patient.phone,
|
||||||
},
|
},
|
||||||
device: {
|
device: {
|
||||||
id: this.toJsonNumber(device.id),
|
id: this.toJsonNumber(device.id),
|
||||||
@ -75,6 +68,9 @@ export class CPatientsService {
|
|||||||
task: {
|
task: {
|
||||||
id: this.toJsonNumber(task.id),
|
id: this.toJsonNumber(task.id),
|
||||||
status: task.status,
|
status: task.status,
|
||||||
|
creatorId: this.toJsonNumber(task.creatorId),
|
||||||
|
engineerId: this.toJsonNumber(task.engineerId),
|
||||||
|
hospitalId: this.toJsonNumber(task.hospitalId),
|
||||||
createdAt: task.createdAt,
|
createdAt: task.createdAt,
|
||||||
},
|
},
|
||||||
taskItem: {
|
taskItem: {
|
||||||
@ -93,9 +89,8 @@ export class CPatientsService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// 前端详情弹窗和现有 E2E 都依赖这两个回显字段。
|
|
||||||
phone,
|
phone,
|
||||||
idCard: normalizedIdCard,
|
idCardHash,
|
||||||
patientCount: patients.length,
|
patientCount: patients.length,
|
||||||
lifecycle,
|
lifecycle,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,6 +1,11 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { IsInt, IsString, Matches, Min } from 'class-validator';
|
import {
|
||||||
|
IsInt,
|
||||||
|
IsString,
|
||||||
|
Matches,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 患者创建 DTO:B 端新增患者使用。
|
* 患者创建 DTO:B 端新增患者使用。
|
||||||
@ -16,11 +21,11 @@ export class CreatePatientDto {
|
|||||||
phone!: string;
|
phone!: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: '身份证号原文',
|
description: '身份证哈希(前端传加密后值)',
|
||||||
example: '110101199001010011',
|
example: 'id-card-hash-demo',
|
||||||
})
|
})
|
||||||
@IsString({ message: 'idCard 必须是字符串' })
|
@IsString({ message: 'idCardHash 必须是字符串' })
|
||||||
idCard!: string;
|
idCardHash!: string;
|
||||||
|
|
||||||
@ApiProperty({ description: '归属人员 ID(医生/主任/组长)', example: 10001 })
|
@ApiProperty({ description: '归属人员 ID(医生/主任/组长)', example: 10001 })
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
|
|||||||
@ -10,10 +10,7 @@ export class FamilyLifecycleQueryDto {
|
|||||||
@Matches(/^1\d{10}$/, { message: 'phone 必须是合法手机号' })
|
@Matches(/^1\d{10}$/, { message: 'phone 必须是合法手机号' })
|
||||||
phone!: string;
|
phone!: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({ description: '身份证哈希值', example: 'seed-id-card-hash' })
|
||||||
description: '身份证号原文',
|
@IsString({ message: 'idCardHash 必须是字符串' })
|
||||||
example: '110101199001010011',
|
idCardHash!: string;
|
||||||
})
|
|
||||||
@IsString({ message: 'idCard 必须是字符串' })
|
|
||||||
idCard!: string;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +0,0 @@
|
|||||||
/**
|
|
||||||
* 统一整理身份证号:
|
|
||||||
* 1. 去掉前后空白与中间空格
|
|
||||||
* 2. 将末尾可能出现的小写 x 规范成大写 X
|
|
||||||
*/
|
|
||||||
export function normalizePatientIdCard(value: string): string {
|
|
||||||
return value.trim().replace(/\s+/g, '').toUpperCase();
|
|
||||||
}
|
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator';
|
import { IsInt, Min } from 'class-validator';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 取消任务 DTO。
|
* 取消任务 DTO。
|
||||||
@ -11,14 +11,4 @@ export class CancelTaskDto {
|
|||||||
@IsInt({ message: 'taskId 必须是整数' })
|
@IsInt({ message: 'taskId 必须是整数' })
|
||||||
@Min(1, { message: 'taskId 必须大于 0' })
|
@Min(1, { message: 'taskId 必须大于 0' })
|
||||||
taskId!: number;
|
taskId!: number;
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
description: '取消原因(可选,当前仅用于接口兼容与后续通知扩展)',
|
|
||||||
example: '后台手动取消',
|
|
||||||
required: false,
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsString({ message: 'reason 必须是字符串' })
|
|
||||||
@MaxLength(100, { message: 'reason 长度不能超过 100 个字符' })
|
|
||||||
reason?: string;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -43,9 +43,7 @@ export class TaskService {
|
|||||||
throw new BadRequestException(`deviceId 非法: ${item.deviceId}`);
|
throw new BadRequestException(`deviceId 非法: ${item.deviceId}`);
|
||||||
}
|
}
|
||||||
if (!Number.isInteger(item.targetPressure)) {
|
if (!Number.isInteger(item.targetPressure)) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(`targetPressure 非法: ${item.targetPressure}`);
|
||||||
`targetPressure 非法: ${item.targetPressure}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return item.deviceId;
|
return item.deviceId;
|
||||||
}),
|
}),
|
||||||
@ -140,30 +138,14 @@ export class TaskService {
|
|||||||
throw new ForbiddenException(MESSAGES.TASK.ENGINEER_ALREADY_ASSIGNED);
|
throw new ForbiddenException(MESSAGES.TASK.ENGINEER_ALREADY_ASSIGNED);
|
||||||
}
|
}
|
||||||
|
|
||||||
const accepted = await this.prisma.task.updateMany({
|
const updatedTask = await this.prisma.task.update({
|
||||||
where: {
|
where: { id: task.id },
|
||||||
id: task.id,
|
|
||||||
hospitalId,
|
|
||||||
status: TaskStatus.PENDING,
|
|
||||||
OR: [{ engineerId: null }, { engineerId: actor.id }],
|
|
||||||
},
|
|
||||||
data: {
|
data: {
|
||||||
status: TaskStatus.ACCEPTED,
|
status: TaskStatus.ACCEPTED,
|
||||||
engineerId: actor.id,
|
engineerId: actor.id,
|
||||||
},
|
},
|
||||||
});
|
|
||||||
|
|
||||||
if (accepted.count !== 1) {
|
|
||||||
throw new ConflictException(MESSAGES.TASK.ACCEPT_ONLY_PENDING);
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedTask = await this.prisma.task.findUnique({
|
|
||||||
where: { id: task.id },
|
|
||||||
include: { items: true },
|
include: { items: true },
|
||||||
});
|
});
|
||||||
if (!updatedTask) {
|
|
||||||
throw new NotFoundException(MESSAGES.TASK.TASK_NOT_FOUND);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.eventEmitter.emitAsync('task.accepted', {
|
await this.eventEmitter.emitAsync('task.accepted', {
|
||||||
taskId: updatedTask.id,
|
taskId: updatedTask.id,
|
||||||
@ -275,8 +257,6 @@ export class TaskService {
|
|||||||
hospitalId: cancelledTask.hospitalId,
|
hospitalId: cancelledTask.hospitalId,
|
||||||
actorId: actor.id,
|
actorId: actor.id,
|
||||||
status: cancelledTask.status,
|
status: cancelledTask.status,
|
||||||
// 当前库表未持久化取消原因,但先透传到事件层,方便通知链路后续接入。
|
|
||||||
reason: dto.reason?.trim() || null,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return cancelledTask;
|
return cancelledTask;
|
||||||
|
|||||||
@ -38,20 +38,22 @@ export class UsersController {
|
|||||||
* 创建用户。
|
* 创建用户。
|
||||||
*/
|
*/
|
||||||
@Post()
|
@Post()
|
||||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR)
|
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||||
@ApiOperation({ summary: '创建用户' })
|
@ApiOperation({ summary: '创建用户' })
|
||||||
create(
|
create(@Body() createUserDto: CreateUserDto) {
|
||||||
@CurrentActor() actor: ActorContext,
|
return this.usersService.create(createUserDto);
|
||||||
@Body() createUserDto: CreateUserDto,
|
|
||||||
) {
|
|
||||||
return this.usersService.create(actor, createUserDto);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询用户列表。
|
* 查询用户列表。
|
||||||
*/
|
*/
|
||||||
@Get()
|
@Get()
|
||||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER)
|
@Roles(
|
||||||
|
Role.SYSTEM_ADMIN,
|
||||||
|
Role.HOSPITAL_ADMIN,
|
||||||
|
Role.DIRECTOR,
|
||||||
|
Role.LEADER,
|
||||||
|
)
|
||||||
@ApiOperation({ summary: '查询用户列表' })
|
@ApiOperation({ summary: '查询用户列表' })
|
||||||
findAll(@CurrentActor() actor: ActorContext) {
|
findAll(@CurrentActor() actor: ActorContext) {
|
||||||
return this.usersService.findAll(actor);
|
return this.usersService.findAll(actor);
|
||||||
@ -61,36 +63,32 @@ export class UsersController {
|
|||||||
* 查询用户详情。
|
* 查询用户详情。
|
||||||
*/
|
*/
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR)
|
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||||
@ApiOperation({ summary: '查询用户详情' })
|
@ApiOperation({ summary: '查询用户详情' })
|
||||||
@ApiParam({ name: 'id', description: '用户 ID' })
|
@ApiParam({ name: 'id', description: '用户 ID' })
|
||||||
findOne(@CurrentActor() actor: ActorContext, @Param('id') id: string) {
|
findOne(@Param('id') id: string) {
|
||||||
return this.usersService.findOne(actor, +id);
|
return this.usersService.findOne(+id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新用户。
|
* 更新用户。
|
||||||
*/
|
*/
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR)
|
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||||
@ApiOperation({ summary: '更新用户' })
|
@ApiOperation({ summary: '更新用户' })
|
||||||
@ApiParam({ name: 'id', description: '用户 ID' })
|
@ApiParam({ name: 'id', description: '用户 ID' })
|
||||||
update(
|
update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) {
|
||||||
@CurrentActor() actor: ActorContext,
|
return this.usersService.update(+id, updateUserDto);
|
||||||
@Param('id') id: string,
|
|
||||||
@Body() updateUserDto: UpdateUserDto,
|
|
||||||
) {
|
|
||||||
return this.usersService.update(actor, +id, updateUserDto);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 删除用户。
|
* 删除用户。
|
||||||
*/
|
*/
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@Roles(Role.SYSTEM_ADMIN, Role.DIRECTOR)
|
@Roles(Role.SYSTEM_ADMIN)
|
||||||
@ApiOperation({ summary: '删除用户' })
|
@ApiOperation({ summary: '删除用户' })
|
||||||
@ApiParam({ name: 'id', description: '用户 ID' })
|
@ApiParam({ name: 'id', description: '用户 ID' })
|
||||||
remove(@CurrentActor() actor: ActorContext, @Param('id') id: string) {
|
remove(@Param('id') id: string) {
|
||||||
return this.usersService.remove(actor, +id);
|
return this.usersService.remove(+id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,9 +15,9 @@ import { Role } from '../generated/prisma/enums.js';
|
|||||||
import { PrismaService } from '../prisma.service.js';
|
import { PrismaService } from '../prisma.service.js';
|
||||||
import type { ActorContext } from '../common/actor-context.js';
|
import type { ActorContext } from '../common/actor-context.js';
|
||||||
import { AssignEngineerHospitalDto } from './dto/assign-engineer-hospital.dto.js';
|
import { AssignEngineerHospitalDto } from './dto/assign-engineer-hospital.dto.js';
|
||||||
|
import { RegisterUserDto } from './dto/register-user.dto.js';
|
||||||
import { LoginDto } from './dto/login.dto.js';
|
import { LoginDto } from './dto/login.dto.js';
|
||||||
import { MESSAGES } from '../common/messages.js';
|
import { MESSAGES } from '../common/messages.js';
|
||||||
import { CreateSystemAdminDto } from '../auth/dto/create-system-admin.dto.js';
|
|
||||||
|
|
||||||
const SAFE_USER_SELECT = {
|
const SAFE_USER_SELECT = {
|
||||||
id: true,
|
id: true,
|
||||||
@ -35,27 +35,25 @@ export class UsersService {
|
|||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 注册接口已关闭,避免绕过管理员创建链路。
|
* 注册账号:根据角色与组织范围进行约束,并写入 bcrypt 密码摘要。
|
||||||
*/
|
*/
|
||||||
async register() {
|
async register(dto: RegisterUserDto) {
|
||||||
throw new ForbiddenException(MESSAGES.AUTH.REGISTER_DISABLED);
|
const role = this.normalizeRole(dto.role);
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建系统管理员:要求引导密钥。
|
|
||||||
*/
|
|
||||||
async createSystemAdmin(dto: CreateSystemAdminDto) {
|
|
||||||
const name = this.normalizeRequiredString(dto.name, 'name');
|
const name = this.normalizeRequiredString(dto.name, 'name');
|
||||||
const phone = this.normalizePhone(dto.phone);
|
const phone = this.normalizePhone(dto.phone);
|
||||||
const password = this.normalizePassword(dto.password);
|
const password = this.normalizePassword(dto.password);
|
||||||
const openId = this.normalizeOptionalString(dto.openId);
|
const openId = this.normalizeOptionalString(dto.openId);
|
||||||
|
const hospitalId = this.normalizeOptionalInt(dto.hospitalId, 'hospitalId');
|
||||||
this.assertSystemAdminBootstrapKey(
|
const departmentId = this.normalizeOptionalInt(
|
||||||
Role.SYSTEM_ADMIN,
|
dto.departmentId,
|
||||||
dto.systemAdminBootstrapKey,
|
'departmentId',
|
||||||
);
|
);
|
||||||
|
const groupId = this.normalizeOptionalInt(dto.groupId, 'groupId');
|
||||||
|
|
||||||
|
this.assertSystemAdminBootstrapKey(role, dto.systemAdminBootstrapKey);
|
||||||
|
await this.assertOrganizationScope(role, hospitalId, departmentId, groupId);
|
||||||
await this.assertOpenIdUnique(openId);
|
await this.assertOpenIdUnique(openId);
|
||||||
await this.assertPhoneRoleScopeUnique(phone, Role.SYSTEM_ADMIN, null);
|
await this.assertPhoneRoleScopeUnique(phone, role, hospitalId);
|
||||||
|
|
||||||
const passwordHash = await hash(password, 12);
|
const passwordHash = await hash(password, 12);
|
||||||
|
|
||||||
@ -65,10 +63,10 @@ export class UsersService {
|
|||||||
phone,
|
phone,
|
||||||
passwordHash,
|
passwordHash,
|
||||||
openId,
|
openId,
|
||||||
role: Role.SYSTEM_ADMIN,
|
role,
|
||||||
hospitalId: null,
|
hospitalId,
|
||||||
departmentId: null,
|
departmentId,
|
||||||
groupId: null,
|
groupId,
|
||||||
},
|
},
|
||||||
select: SAFE_USER_SELECT,
|
select: SAFE_USER_SELECT,
|
||||||
});
|
});
|
||||||
@ -135,13 +133,13 @@ export class UsersService {
|
|||||||
* 获取当前登录用户详情。
|
* 获取当前登录用户详情。
|
||||||
*/
|
*/
|
||||||
async me(actor: ActorContext) {
|
async me(actor: ActorContext) {
|
||||||
return this.findOne(actor, actor.id);
|
return this.findOne(actor.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* B 端创建用户(通常由管理员使用)。
|
* B 端创建用户(通常由管理员使用)。
|
||||||
*/
|
*/
|
||||||
async create(actor: ActorContext, createUserDto: CreateUserDto) {
|
async create(createUserDto: CreateUserDto) {
|
||||||
const role = this.normalizeRole(createUserDto.role);
|
const role = this.normalizeRole(createUserDto.role);
|
||||||
const name = this.normalizeRequiredString(createUserDto.name, 'name');
|
const name = this.normalizeRequiredString(createUserDto.name, 'name');
|
||||||
const phone = this.normalizePhone(createUserDto.phone);
|
const phone = this.normalizePhone(createUserDto.phone);
|
||||||
@ -159,22 +157,9 @@ export class UsersService {
|
|||||||
);
|
);
|
||||||
const groupId = this.normalizeOptionalInt(createUserDto.groupId, 'groupId');
|
const groupId = this.normalizeOptionalInt(createUserDto.groupId, 'groupId');
|
||||||
|
|
||||||
const scoped = this.resolveCreateScope(
|
await this.assertOrganizationScope(role, hospitalId, departmentId, groupId);
|
||||||
actor,
|
|
||||||
role,
|
|
||||||
hospitalId,
|
|
||||||
departmentId,
|
|
||||||
groupId,
|
|
||||||
);
|
|
||||||
|
|
||||||
await this.assertOrganizationScope(
|
|
||||||
role,
|
|
||||||
scoped.hospitalId,
|
|
||||||
scoped.departmentId,
|
|
||||||
scoped.groupId,
|
|
||||||
);
|
|
||||||
await this.assertOpenIdUnique(openId);
|
await this.assertOpenIdUnique(openId);
|
||||||
await this.assertPhoneRoleScopeUnique(phone, role, scoped.hospitalId);
|
await this.assertPhoneRoleScopeUnique(phone, role, hospitalId);
|
||||||
|
|
||||||
return this.prisma.user.create({
|
return this.prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
@ -183,9 +168,9 @@ export class UsersService {
|
|||||||
passwordHash: password ? await hash(password, 12) : null,
|
passwordHash: password ? await hash(password, 12) : null,
|
||||||
openId,
|
openId,
|
||||||
role,
|
role,
|
||||||
hospitalId: scoped.hospitalId,
|
hospitalId,
|
||||||
departmentId: scoped.departmentId,
|
departmentId,
|
||||||
groupId: scoped.groupId,
|
groupId,
|
||||||
},
|
},
|
||||||
select: SAFE_USER_SELECT,
|
select: SAFE_USER_SELECT,
|
||||||
});
|
});
|
||||||
@ -225,7 +210,7 @@ export class UsersService {
|
|||||||
/**
|
/**
|
||||||
* 查询用户详情。
|
* 查询用户详情。
|
||||||
*/
|
*/
|
||||||
async findOne(actor: ActorContext, id: number) {
|
async findOne(id: number) {
|
||||||
const userId = this.normalizeRequiredInt(id, 'id');
|
const userId = this.normalizeRequiredInt(id, 'id');
|
||||||
|
|
||||||
const user = await this.prisma.user.findUnique({
|
const user = await this.prisma.user.findUnique({
|
||||||
@ -236,15 +221,13 @@ export class UsersService {
|
|||||||
throw new NotFoundException(MESSAGES.USER.NOT_FOUND);
|
throw new NotFoundException(MESSAGES.USER.NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.assertUserReadable(actor, user);
|
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新用户信息(含可选密码重置)。
|
* 更新用户信息(含可选密码重置)。
|
||||||
*/
|
*/
|
||||||
async update(actor: ActorContext, id: number, updateUserDto: UpdateUserDto) {
|
async update(id: number, updateUserDto: UpdateUserDto) {
|
||||||
const userId = this.normalizeRequiredInt(id, 'id');
|
const userId = this.normalizeRequiredInt(id, 'id');
|
||||||
const current = await this.prisma.user.findUnique({
|
const current = await this.prisma.user.findUnique({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
@ -257,12 +240,8 @@ export class UsersService {
|
|||||||
throw new NotFoundException(MESSAGES.USER.NOT_FOUND);
|
throw new NotFoundException(MESSAGES.USER.NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.assertUserWritable(actor, current);
|
|
||||||
|
|
||||||
const nextRole =
|
const nextRole =
|
||||||
updateUserDto.role != null
|
updateUserDto.role != null ? this.normalizeRole(updateUserDto.role) : current.role;
|
||||||
? this.normalizeRole(updateUserDto.role)
|
|
||||||
: current.role;
|
|
||||||
const nextHospitalId =
|
const nextHospitalId =
|
||||||
updateUserDto.hospitalId !== undefined
|
updateUserDto.hospitalId !== undefined
|
||||||
? this.normalizeOptionalInt(updateUserDto.hospitalId, 'hospitalId')
|
? this.normalizeOptionalInt(updateUserDto.hospitalId, 'hospitalId')
|
||||||
@ -276,10 +255,6 @@ export class UsersService {
|
|||||||
? this.normalizeOptionalInt(updateUserDto.groupId, 'groupId')
|
? this.normalizeOptionalInt(updateUserDto.groupId, 'groupId')
|
||||||
: current.groupId;
|
: current.groupId;
|
||||||
|
|
||||||
this.assertUpdateTargetRoleAllowed(actor, nextRole);
|
|
||||||
this.assertUpdateHospitalScopeAllowed(actor, nextHospitalId);
|
|
||||||
this.assertUpdateDepartmentScopeAllowed(actor, nextDepartmentId);
|
|
||||||
|
|
||||||
const assigningDepartmentOrGroup =
|
const assigningDepartmentOrGroup =
|
||||||
(updateUserDto.departmentId !== undefined && nextDepartmentId != null) ||
|
(updateUserDto.departmentId !== undefined && nextDepartmentId != null) ||
|
||||||
(updateUserDto.groupId !== undefined && nextGroupId != null);
|
(updateUserDto.groupId !== undefined && nextGroupId != null);
|
||||||
@ -338,12 +313,10 @@ export class UsersService {
|
|||||||
data.openId = nextOpenId;
|
data.openId = nextOpenId;
|
||||||
}
|
}
|
||||||
if (updateUserDto.password) {
|
if (updateUserDto.password) {
|
||||||
// 密码变更后立即吊销旧 token,避免旧会话继续使用。
|
|
||||||
data.passwordHash = await hash(
|
data.passwordHash = await hash(
|
||||||
this.normalizePassword(updateUserDto.password),
|
this.normalizePassword(updateUserDto.password),
|
||||||
12,
|
12,
|
||||||
);
|
);
|
||||||
data.tokenValidAfter = new Date();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.prisma.user.update({
|
return this.prisma.user.update({
|
||||||
@ -356,10 +329,9 @@ export class UsersService {
|
|||||||
/**
|
/**
|
||||||
* 删除用户。
|
* 删除用户。
|
||||||
*/
|
*/
|
||||||
async remove(actor: ActorContext, id: number) {
|
async remove(id: number) {
|
||||||
const userId = this.normalizeRequiredInt(id, 'id');
|
const userId = this.normalizeRequiredInt(id, 'id');
|
||||||
const target = await this.findOne(actor, userId);
|
await this.findOne(userId);
|
||||||
this.assertUserWritable(actor, target);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await this.prisma.user.delete({
|
return await this.prisma.user.delete({
|
||||||
@ -425,9 +397,7 @@ export class UsersService {
|
|||||||
/**
|
/**
|
||||||
* 去除密码摘要,避免泄露敏感信息。
|
* 去除密码摘要,避免泄露敏感信息。
|
||||||
*/
|
*/
|
||||||
private toSafeUser(
|
private toSafeUser(user: { passwordHash?: string | null } & Record<string, unknown>) {
|
||||||
user: { passwordHash?: string | null } & Record<string, unknown>,
|
|
||||||
) {
|
|
||||||
const { passwordHash, ...safe } = user;
|
const { passwordHash, ...safe } = user;
|
||||||
return safe;
|
return safe;
|
||||||
}
|
}
|
||||||
@ -542,9 +512,7 @@ export class UsersService {
|
|||||||
select: { id: true, hospitalId: true },
|
select: { id: true, hospitalId: true },
|
||||||
});
|
});
|
||||||
if (!department || department.hospitalId !== hospitalId) {
|
if (!department || department.hospitalId !== hospitalId) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(MESSAGES.USER.DEPARTMENT_HOSPITAL_MISMATCH);
|
||||||
MESSAGES.USER.DEPARTMENT_HOSPITAL_MISMATCH,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -662,268 +630,6 @@ export class UsersService {
|
|||||||
return role as Role;
|
return role as Role;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 解析创建用户作用域并执行角色链路约束。
|
|
||||||
*/
|
|
||||||
private resolveCreateScope(
|
|
||||||
actor: ActorContext,
|
|
||||||
targetRole: Role,
|
|
||||||
hospitalId: number | null,
|
|
||||||
departmentId: number | null,
|
|
||||||
groupId: number | null,
|
|
||||||
) {
|
|
||||||
if (actor.role === Role.SYSTEM_ADMIN) {
|
|
||||||
if (targetRole === Role.SYSTEM_ADMIN) {
|
|
||||||
return { hospitalId: null, departmentId: null, groupId: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 系统管理员可创建任意角色,具体归属由后续组织范围校验保证合法。
|
|
||||||
return { hospitalId, departmentId, groupId };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (actor.role !== Role.HOSPITAL_ADMIN) {
|
|
||||||
if (actor.role !== Role.DIRECTOR) {
|
|
||||||
throw new ForbiddenException(MESSAGES.USER.CREATE_FORBIDDEN);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 科室主任仅允许创建本科室医生。
|
|
||||||
if (targetRole !== Role.DOCTOR) {
|
|
||||||
throw new ForbiddenException(MESSAGES.USER.CREATE_FORBIDDEN);
|
|
||||||
}
|
|
||||||
|
|
||||||
const actorHospitalId = this.requireActorScopeInt(
|
|
||||||
actor.hospitalId,
|
|
||||||
MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED,
|
|
||||||
);
|
|
||||||
const actorDepartmentId = this.requireActorScopeInt(
|
|
||||||
actor.departmentId,
|
|
||||||
MESSAGES.ORG.ACTOR_DEPARTMENT_REQUIRED,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (hospitalId != null && hospitalId !== actorHospitalId) {
|
|
||||||
throw new ForbiddenException(MESSAGES.USER.CREATE_FORBIDDEN);
|
|
||||||
}
|
|
||||||
if (departmentId != null && departmentId !== actorDepartmentId) {
|
|
||||||
throw new ForbiddenException(MESSAGES.USER.CREATE_FORBIDDEN);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
hospitalId: actorHospitalId,
|
|
||||||
departmentId: actorDepartmentId,
|
|
||||||
groupId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
targetRole === Role.SYSTEM_ADMIN ||
|
|
||||||
targetRole === Role.HOSPITAL_ADMIN
|
|
||||||
) {
|
|
||||||
throw new ForbiddenException(MESSAGES.USER.CREATE_FORBIDDEN);
|
|
||||||
}
|
|
||||||
|
|
||||||
const actorHospitalId = this.requireActorScopeInt(
|
|
||||||
actor.hospitalId,
|
|
||||||
MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED,
|
|
||||||
);
|
|
||||||
const scopedHospitalId = hospitalId ?? actorHospitalId;
|
|
||||||
if (scopedHospitalId !== actorHospitalId) {
|
|
||||||
throw new ForbiddenException(
|
|
||||||
MESSAGES.USER.HOSPITAL_ADMIN_SCOPE_FORBIDDEN,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
hospitalId: scopedHospitalId,
|
|
||||||
departmentId,
|
|
||||||
groupId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 读取权限:系统管理员可读全量;院管可读本院;主任可读本科室医生。
|
|
||||||
*/
|
|
||||||
private assertUserReadable(
|
|
||||||
actor: ActorContext,
|
|
||||||
target: Pick<typeof SAFE_USER_SELECT, never> & {
|
|
||||||
id: number;
|
|
||||||
role: Role;
|
|
||||||
hospitalId: number | null;
|
|
||||||
departmentId: number | null;
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
if (actor.role === Role.SYSTEM_ADMIN) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (actor.id === target.id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (actor.role === Role.HOSPITAL_ADMIN) {
|
|
||||||
const actorHospitalId = this.requireActorScopeInt(
|
|
||||||
actor.hospitalId,
|
|
||||||
MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED,
|
|
||||||
);
|
|
||||||
if (target.hospitalId === actorHospitalId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (actor.role === Role.DIRECTOR) {
|
|
||||||
const actorHospitalId = this.requireActorScopeInt(
|
|
||||||
actor.hospitalId,
|
|
||||||
MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED,
|
|
||||||
);
|
|
||||||
const actorDepartmentId = this.requireActorScopeInt(
|
|
||||||
actor.departmentId,
|
|
||||||
MESSAGES.ORG.ACTOR_DEPARTMENT_REQUIRED,
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
target.role === Role.DOCTOR &&
|
|
||||||
target.hospitalId === actorHospitalId &&
|
|
||||||
target.departmentId === actorDepartmentId
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new ForbiddenException(MESSAGES.USER.DIRECTOR_SCOPE_FORBIDDEN);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 写权限:院管可写本院非管理员账号;主任仅可写本科室医生。
|
|
||||||
*/
|
|
||||||
private assertUserWritable(
|
|
||||||
actor: ActorContext,
|
|
||||||
target: {
|
|
||||||
id: number;
|
|
||||||
role: Role;
|
|
||||||
hospitalId: number | null;
|
|
||||||
departmentId: number | null;
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
if (actor.role === Role.SYSTEM_ADMIN) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (actor.role === Role.DIRECTOR) {
|
|
||||||
const actorHospitalId = this.requireActorScopeInt(
|
|
||||||
actor.hospitalId,
|
|
||||||
MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED,
|
|
||||||
);
|
|
||||||
const actorDepartmentId = this.requireActorScopeInt(
|
|
||||||
actor.departmentId,
|
|
||||||
MESSAGES.ORG.ACTOR_DEPARTMENT_REQUIRED,
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
target.role !== Role.DOCTOR ||
|
|
||||||
target.hospitalId !== actorHospitalId ||
|
|
||||||
target.departmentId !== actorDepartmentId
|
|
||||||
) {
|
|
||||||
throw new ForbiddenException(MESSAGES.USER.DIRECTOR_SCOPE_FORBIDDEN);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (actor.role !== Role.HOSPITAL_ADMIN) {
|
|
||||||
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
|
|
||||||
}
|
|
||||||
|
|
||||||
const actorHospitalId = this.requireActorScopeInt(
|
|
||||||
actor.hospitalId,
|
|
||||||
MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED,
|
|
||||||
);
|
|
||||||
if (target.hospitalId !== actorHospitalId) {
|
|
||||||
throw new ForbiddenException(
|
|
||||||
MESSAGES.USER.HOSPITAL_ADMIN_SCOPE_FORBIDDEN,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
target.role === Role.HOSPITAL_ADMIN ||
|
|
||||||
target.role === Role.SYSTEM_ADMIN
|
|
||||||
) {
|
|
||||||
throw new ForbiddenException(
|
|
||||||
MESSAGES.USER.HOSPITAL_ADMIN_SCOPE_FORBIDDEN,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 写入时角色边界校验,避免医院管理员提升权限。
|
|
||||||
*/
|
|
||||||
private assertUpdateTargetRoleAllowed(actor: ActorContext, nextRole: Role) {
|
|
||||||
if (actor.role === Role.SYSTEM_ADMIN) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (actor.role === Role.DIRECTOR && nextRole !== Role.DOCTOR) {
|
|
||||||
throw new ForbiddenException(MESSAGES.USER.DIRECTOR_SCOPE_FORBIDDEN);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
actor.role === Role.HOSPITAL_ADMIN &&
|
|
||||||
(nextRole === Role.SYSTEM_ADMIN || nextRole === Role.HOSPITAL_ADMIN)
|
|
||||||
) {
|
|
||||||
throw new ForbiddenException(
|
|
||||||
MESSAGES.USER.HOSPITAL_ADMIN_SCOPE_FORBIDDEN,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 写入时医院范围校验,避免医院管理员跨院更新。
|
|
||||||
*/
|
|
||||||
private assertUpdateHospitalScopeAllowed(
|
|
||||||
actor: ActorContext,
|
|
||||||
hospitalId: number | null,
|
|
||||||
) {
|
|
||||||
if (actor.role === Role.DIRECTOR) {
|
|
||||||
const actorHospitalId = this.requireActorScopeInt(
|
|
||||||
actor.hospitalId,
|
|
||||||
MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED,
|
|
||||||
);
|
|
||||||
if (hospitalId !== actorHospitalId) {
|
|
||||||
throw new ForbiddenException(MESSAGES.USER.DIRECTOR_SCOPE_FORBIDDEN);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (actor.role !== Role.HOSPITAL_ADMIN) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const actorHospitalId = this.requireActorScopeInt(
|
|
||||||
actor.hospitalId,
|
|
||||||
MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED,
|
|
||||||
);
|
|
||||||
if (hospitalId !== actorHospitalId) {
|
|
||||||
throw new ForbiddenException(
|
|
||||||
MESSAGES.USER.HOSPITAL_ADMIN_SCOPE_FORBIDDEN,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 写入时科室范围校验,避免主任跨科调整医生归属。
|
|
||||||
*/
|
|
||||||
private assertUpdateDepartmentScopeAllowed(
|
|
||||||
actor: ActorContext,
|
|
||||||
departmentId: number | null,
|
|
||||||
) {
|
|
||||||
if (actor.role !== Role.DIRECTOR) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const actorDepartmentId = this.requireActorScopeInt(
|
|
||||||
actor.departmentId,
|
|
||||||
MESSAGES.ORG.ACTOR_DEPARTMENT_REQUIRED,
|
|
||||||
);
|
|
||||||
if (departmentId !== actorDepartmentId) {
|
|
||||||
throw new ForbiddenException(MESSAGES.USER.DIRECTOR_SCOPE_FORBIDDEN);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 签发访问令牌。
|
* 签发访问令牌。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -81,14 +81,16 @@ async function requirePatientId(
|
|||||||
prisma: PrismaService,
|
prisma: PrismaService,
|
||||||
hospitalId: number,
|
hospitalId: number,
|
||||||
phone: string,
|
phone: string,
|
||||||
idCard: string,
|
idCardHash: string,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const patient = await prisma.patient.findFirst({
|
const patient = await prisma.patient.findFirst({
|
||||||
where: { hospitalId, phone, idCard },
|
where: { hospitalId, phone, idCardHash },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
if (!patient) {
|
if (!patient) {
|
||||||
throw new NotFoundException(`Seed patient not found: ${phone}/${idCard}`);
|
throw new NotFoundException(
|
||||||
|
`Seed patient not found: ${phone}/${idCardHash}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return patient.id;
|
return patient.id;
|
||||||
}
|
}
|
||||||
@ -161,25 +163,25 @@ export async function loadSeedFixtures(
|
|||||||
prisma,
|
prisma,
|
||||||
hospitalAId,
|
hospitalAId,
|
||||||
'13800002001',
|
'13800002001',
|
||||||
'110101199001010011',
|
'seed-id-card-cross-hospital',
|
||||||
),
|
),
|
||||||
patientA2Id: await requirePatientId(
|
patientA2Id: await requirePatientId(
|
||||||
prisma,
|
prisma,
|
||||||
hospitalAId,
|
hospitalAId,
|
||||||
'13800002002',
|
'13800002002',
|
||||||
'110101199002020022',
|
'seed-id-card-a2',
|
||||||
),
|
),
|
||||||
patientA3Id: await requirePatientId(
|
patientA3Id: await requirePatientId(
|
||||||
prisma,
|
prisma,
|
||||||
hospitalAId,
|
hospitalAId,
|
||||||
'13800002003',
|
'13800002003',
|
||||||
'110101199003030033',
|
'seed-id-card-a3',
|
||||||
),
|
),
|
||||||
patientB1Id: await requirePatientId(
|
patientB1Id: await requirePatientId(
|
||||||
prisma,
|
prisma,
|
||||||
hospitalBId,
|
hospitalBId,
|
||||||
'13800002001',
|
'13800002001',
|
||||||
'110101199001010011',
|
'seed-id-card-cross-hospital',
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
devices: {
|
devices: {
|
||||||
|
|||||||
@ -1,59 +0,0 @@
|
|||||||
import request from 'supertest';
|
|
||||||
import { Role } from '../../../src/generated/prisma/enums.js';
|
|
||||||
import {
|
|
||||||
closeE2EContext,
|
|
||||||
createE2EContext,
|
|
||||||
type E2EContext,
|
|
||||||
} from '../helpers/e2e-context.helper.js';
|
|
||||||
import {
|
|
||||||
expectErrorEnvelope,
|
|
||||||
expectSuccessEnvelope,
|
|
||||||
} from '../helpers/e2e-http.helper.js';
|
|
||||||
|
|
||||||
describe('Auth token revocation (e2e)', () => {
|
|
||||||
let ctx: E2EContext;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
ctx = await createE2EContext();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await closeE2EContext(ctx);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('旧 token 在 tokenValidAfter 推进后失效', async () => {
|
|
||||||
const token = ctx.tokens[Role.DOCTOR];
|
|
||||||
const originalUser = await ctx.prisma.user.findUnique({
|
|
||||||
where: { id: ctx.fixtures.users.doctorAId },
|
|
||||||
select: { tokenValidAfter: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
const beforeResponse = await request(ctx.app.getHttpServer())
|
|
||||||
.get('/auth/me')
|
|
||||||
.set('Authorization', `Bearer ${token}`);
|
|
||||||
|
|
||||||
expectSuccessEnvelope(beforeResponse, 200);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await ctx.prisma.user.update({
|
|
||||||
where: { id: ctx.fixtures.users.doctorAId },
|
|
||||||
// 往未来推进一分钟,确保当前 token 的 iat 一定早于失效时间。
|
|
||||||
data: { tokenValidAfter: new Date(Date.now() + 60_000) },
|
|
||||||
});
|
|
||||||
|
|
||||||
const afterResponse = await request(ctx.app.getHttpServer())
|
|
||||||
.get('/auth/me')
|
|
||||||
.set('Authorization', `Bearer ${token}`);
|
|
||||||
|
|
||||||
expectErrorEnvelope(afterResponse, 401, 'Token 已失效,请重新登录');
|
|
||||||
} finally {
|
|
||||||
if (originalUser) {
|
|
||||||
// 恢复种子用户状态,避免串行 E2E 后续用例继续拿到失效 token。
|
|
||||||
await ctx.prisma.user.update({
|
|
||||||
where: { id: ctx.fixtures.users.doctorAId },
|
|
||||||
data: { tokenValidAfter: originalUser.tokenValidAfter },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -24,33 +24,39 @@ describe('AuthController (e2e)', () => {
|
|||||||
await closeE2EContext(ctx);
|
await closeE2EContext(ctx);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /auth/system-admin', () => {
|
describe('POST /auth/register', () => {
|
||||||
it('成功:创建系统管理员账号', async () => {
|
it('成功:注册医生账号', async () => {
|
||||||
const response = await request(ctx.app.getHttpServer())
|
const response = await request(ctx.app.getHttpServer())
|
||||||
.post('/auth/system-admin')
|
.post('/auth/register')
|
||||||
.send({
|
.send({
|
||||||
name: uniqueSeedValue('Auth 系统管理员'),
|
name: uniqueSeedValue('Auth 注册医生'),
|
||||||
phone: uniquePhone(),
|
phone: uniquePhone(),
|
||||||
password: 'Seed@1234',
|
password: 'Seed@1234',
|
||||||
openId: uniqueSeedValue('auth-system-admin-openid'),
|
role: Role.DOCTOR,
|
||||||
systemAdminBootstrapKey: process.env.SYSTEM_ADMIN_BOOTSTRAP_KEY,
|
hospitalId: ctx.fixtures.hospitalAId,
|
||||||
|
departmentId: ctx.fixtures.departmentA1Id,
|
||||||
|
groupId: ctx.fixtures.groupA1Id,
|
||||||
|
openId: uniqueSeedValue('auth-register-openid'),
|
||||||
});
|
});
|
||||||
|
|
||||||
expectSuccessEnvelope(response, 201);
|
expectSuccessEnvelope(response, 201);
|
||||||
expect(response.body.data.role).toBe(Role.SYSTEM_ADMIN);
|
expect(response.body.data.role).toBe(Role.DOCTOR);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('失败:参数不合法返回 400', async () => {
|
it('失败:参数不合法返回 400', async () => {
|
||||||
const response = await request(ctx.app.getHttpServer())
|
const response = await request(ctx.app.getHttpServer())
|
||||||
.post('/auth/system-admin')
|
.post('/auth/register')
|
||||||
.send({
|
.send({
|
||||||
name: 'bad-system-admin',
|
name: 'bad-register',
|
||||||
phone: '13800009999',
|
phone: '13800009999',
|
||||||
password: '123',
|
password: '123',
|
||||||
systemAdminBootstrapKey: process.env.SYSTEM_ADMIN_BOOTSTRAP_KEY,
|
role: Role.DOCTOR,
|
||||||
|
hospitalId: ctx.fixtures.hospitalAId,
|
||||||
|
departmentId: ctx.fixtures.departmentA1Id,
|
||||||
|
groupId: ctx.fixtures.groupA1Id,
|
||||||
});
|
});
|
||||||
|
|
||||||
expectErrorEnvelope(response, 400, '密码长度至少 8 位');
|
expectErrorEnvelope(response, 400, 'password 长度至少 8 位');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,161 +0,0 @@
|
|||||||
import request from 'supertest';
|
|
||||||
import { DeviceStatus, Role } from '../../../src/generated/prisma/enums.js';
|
|
||||||
import {
|
|
||||||
closeE2EContext,
|
|
||||||
createE2EContext,
|
|
||||||
type E2EContext,
|
|
||||||
} from '../helpers/e2e-context.helper.js';
|
|
||||||
import { assertRoleMatrix } from '../helpers/e2e-matrix.helper.js';
|
|
||||||
import {
|
|
||||||
expectErrorEnvelope,
|
|
||||||
expectSuccessEnvelope,
|
|
||||||
uniqueSeedValue,
|
|
||||||
} from '../helpers/e2e-http.helper.js';
|
|
||||||
|
|
||||||
describe('BDevicesController (e2e)', () => {
|
|
||||||
let ctx: E2EContext;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
ctx = await createE2EContext();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await closeE2EContext(ctx);
|
|
||||||
});
|
|
||||||
|
|
||||||
async function createDevice(token: string, patientId: number) {
|
|
||||||
const response = await request(ctx.app.getHttpServer())
|
|
||||||
.post('/b/devices')
|
|
||||||
.set('Authorization', `Bearer ${token}`)
|
|
||||||
.send({
|
|
||||||
snCode: uniqueSeedValue('device-sn'),
|
|
||||||
currentPressure: 118,
|
|
||||||
status: DeviceStatus.ACTIVE,
|
|
||||||
patientId,
|
|
||||||
});
|
|
||||||
|
|
||||||
expectSuccessEnvelope(response, 201);
|
|
||||||
return response.body.data as {
|
|
||||||
id: number;
|
|
||||||
snCode: string;
|
|
||||||
status: DeviceStatus;
|
|
||||||
patient: { id: number };
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('GET /b/devices', () => {
|
|
||||||
it('成功:SYSTEM_ADMIN 可分页查询设备列表', async () => {
|
|
||||||
const response = await request(ctx.app.getHttpServer())
|
|
||||||
.get('/b/devices')
|
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`);
|
|
||||||
|
|
||||||
expectSuccessEnvelope(response, 200);
|
|
||||||
expect(Array.isArray(response.body.data.list)).toBe(true);
|
|
||||||
expect(response.body.data.total).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('成功:HOSPITAL_ADMIN 仅能看到本院设备', async () => {
|
|
||||||
const response = await request(ctx.app.getHttpServer())
|
|
||||||
.get('/b/devices')
|
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`);
|
|
||||||
|
|
||||||
expectSuccessEnvelope(response, 200);
|
|
||||||
const hospitalIds = (
|
|
||||||
response.body.data.list as Array<{
|
|
||||||
patient?: { hospital?: { id: number } };
|
|
||||||
}>
|
|
||||||
)
|
|
||||||
.map((item) => item.patient?.hospital?.id)
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
expect(hospitalIds.every((id) => id === ctx.fixtures.hospitalAId)).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('角色矩阵:仅 SYSTEM_ADMIN/HOSPITAL_ADMIN 可访问列表,其他角色 403,未登录 401', async () => {
|
|
||||||
await assertRoleMatrix({
|
|
||||||
name: 'GET /b/devices role matrix',
|
|
||||||
tokens: ctx.tokens,
|
|
||||||
expectedStatusByRole: {
|
|
||||||
[Role.SYSTEM_ADMIN]: 200,
|
|
||||||
[Role.HOSPITAL_ADMIN]: 200,
|
|
||||||
[Role.DIRECTOR]: 403,
|
|
||||||
[Role.LEADER]: 403,
|
|
||||||
[Role.DOCTOR]: 403,
|
|
||||||
[Role.ENGINEER]: 403,
|
|
||||||
},
|
|
||||||
sendAsRole: async (_role, token) =>
|
|
||||||
request(ctx.app.getHttpServer())
|
|
||||||
.get('/b/devices')
|
|
||||||
.set('Authorization', `Bearer ${token}`),
|
|
||||||
sendWithoutToken: async () =>
|
|
||||||
request(ctx.app.getHttpServer()).get('/b/devices'),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('设备 CRUD 流程', () => {
|
|
||||||
it('成功:HOSPITAL_ADMIN 可创建设备', async () => {
|
|
||||||
const created = await createDevice(
|
|
||||||
ctx.tokens[Role.HOSPITAL_ADMIN],
|
|
||||||
ctx.fixtures.patients.patientA1Id,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(created.status).toBe(DeviceStatus.ACTIVE);
|
|
||||||
expect(created.patient.id).toBe(ctx.fixtures.patients.patientA1Id);
|
|
||||||
expect(created.snCode).toMatch(/^DEVICE-SN-/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('失败:HOSPITAL_ADMIN 绑定跨院患者返回 403', async () => {
|
|
||||||
const response = await request(ctx.app.getHttpServer())
|
|
||||||
.post('/b/devices')
|
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
|
|
||||||
.send({
|
|
||||||
snCode: uniqueSeedValue('cross-hospital-device'),
|
|
||||||
currentPressure: 120,
|
|
||||||
status: DeviceStatus.ACTIVE,
|
|
||||||
patientId: ctx.fixtures.patients.patientB1Id,
|
|
||||||
});
|
|
||||||
|
|
||||||
expectErrorEnvelope(response, 403, '仅可绑定当前权限范围内患者');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('成功:SYSTEM_ADMIN 可更新设备状态与归属患者', async () => {
|
|
||||||
const created = await createDevice(
|
|
||||||
ctx.tokens[Role.SYSTEM_ADMIN],
|
|
||||||
ctx.fixtures.patients.patientA1Id,
|
|
||||||
);
|
|
||||||
|
|
||||||
const response = await request(ctx.app.getHttpServer())
|
|
||||||
.patch(`/b/devices/${created.id}`)
|
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
|
|
||||||
.send({
|
|
||||||
status: DeviceStatus.INACTIVE,
|
|
||||||
patientId: ctx.fixtures.patients.patientA2Id,
|
|
||||||
currentPressure: 99,
|
|
||||||
});
|
|
||||||
|
|
||||||
expectSuccessEnvelope(response, 200);
|
|
||||||
expect(response.body.data.status).toBe(DeviceStatus.INACTIVE);
|
|
||||||
expect(response.body.data.patient.id).toBe(
|
|
||||||
ctx.fixtures.patients.patientA2Id,
|
|
||||||
);
|
|
||||||
expect(response.body.data.currentPressure).toBe(99);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('成功:SYSTEM_ADMIN 可删除未被任务引用的设备', async () => {
|
|
||||||
const created = await createDevice(
|
|
||||||
ctx.tokens[Role.SYSTEM_ADMIN],
|
|
||||||
ctx.fixtures.patients.patientA1Id,
|
|
||||||
);
|
|
||||||
|
|
||||||
const response = await request(ctx.app.getHttpServer())
|
|
||||||
.delete(`/b/devices/${created.id}`)
|
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`);
|
|
||||||
|
|
||||||
expectSuccessEnvelope(response, 200);
|
|
||||||
expect(response.body.data.id).toBe(created.id);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -86,15 +86,15 @@ describe('Organization Controllers (e2e)', () => {
|
|||||||
expectErrorEnvelope(response, 401, '缺少 Bearer Token');
|
expectErrorEnvelope(response, 401, '缺少 Bearer Token');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER 可访问,其余角色 403,未登录 401', async () => {
|
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可访问,其他角色 403,未登录 401', async () => {
|
||||||
await assertRoleMatrix({
|
await assertRoleMatrix({
|
||||||
name: 'GET /b/organization/hospitals role matrix',
|
name: 'GET /b/organization/hospitals role matrix',
|
||||||
tokens: ctx.tokens,
|
tokens: ctx.tokens,
|
||||||
expectedStatusByRole: {
|
expectedStatusByRole: {
|
||||||
[Role.SYSTEM_ADMIN]: 200,
|
[Role.SYSTEM_ADMIN]: 200,
|
||||||
[Role.HOSPITAL_ADMIN]: 200,
|
[Role.HOSPITAL_ADMIN]: 200,
|
||||||
[Role.DIRECTOR]: 200,
|
[Role.DIRECTOR]: 403,
|
||||||
[Role.LEADER]: 200,
|
[Role.LEADER]: 403,
|
||||||
[Role.DOCTOR]: 403,
|
[Role.DOCTOR]: 403,
|
||||||
[Role.ENGINEER]: 403,
|
[Role.ENGINEER]: 403,
|
||||||
},
|
},
|
||||||
@ -126,15 +126,15 @@ describe('Organization Controllers (e2e)', () => {
|
|||||||
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
|
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER 可访问,其余角色 403,未登录 401', async () => {
|
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可访问,其他角色 403,未登录 401', async () => {
|
||||||
await assertRoleMatrix({
|
await assertRoleMatrix({
|
||||||
name: 'GET /b/organization/hospitals/:id role matrix',
|
name: 'GET /b/organization/hospitals/:id role matrix',
|
||||||
tokens: ctx.tokens,
|
tokens: ctx.tokens,
|
||||||
expectedStatusByRole: {
|
expectedStatusByRole: {
|
||||||
[Role.SYSTEM_ADMIN]: 200,
|
[Role.SYSTEM_ADMIN]: 200,
|
||||||
[Role.HOSPITAL_ADMIN]: 200,
|
[Role.HOSPITAL_ADMIN]: 200,
|
||||||
[Role.DIRECTOR]: 200,
|
[Role.DIRECTOR]: 403,
|
||||||
[Role.LEADER]: 200,
|
[Role.LEADER]: 403,
|
||||||
[Role.DOCTOR]: 403,
|
[Role.DOCTOR]: 403,
|
||||||
[Role.ENGINEER]: 403,
|
[Role.ENGINEER]: 403,
|
||||||
},
|
},
|
||||||
@ -321,15 +321,15 @@ describe('Organization Controllers (e2e)', () => {
|
|||||||
expectErrorEnvelope(response, 401, '缺少 Bearer Token');
|
expectErrorEnvelope(response, 401, '缺少 Bearer Token');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER 可访问,其余角色 403,未登录 401', async () => {
|
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可访问,其他角色 403,未登录 401', async () => {
|
||||||
await assertRoleMatrix({
|
await assertRoleMatrix({
|
||||||
name: 'GET /b/organization/departments role matrix',
|
name: 'GET /b/organization/departments role matrix',
|
||||||
tokens: ctx.tokens,
|
tokens: ctx.tokens,
|
||||||
expectedStatusByRole: {
|
expectedStatusByRole: {
|
||||||
[Role.SYSTEM_ADMIN]: 200,
|
[Role.SYSTEM_ADMIN]: 200,
|
||||||
[Role.HOSPITAL_ADMIN]: 200,
|
[Role.HOSPITAL_ADMIN]: 200,
|
||||||
[Role.DIRECTOR]: 200,
|
[Role.DIRECTOR]: 403,
|
||||||
[Role.LEADER]: 200,
|
[Role.LEADER]: 403,
|
||||||
[Role.DOCTOR]: 403,
|
[Role.DOCTOR]: 403,
|
||||||
[Role.ENGINEER]: 403,
|
[Role.ENGINEER]: 403,
|
||||||
},
|
},
|
||||||
@ -361,15 +361,15 @@ describe('Organization Controllers (e2e)', () => {
|
|||||||
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
|
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER 可访问,其余角色 403,未登录 401', async () => {
|
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可访问,其他角色 403,未登录 401', async () => {
|
||||||
await assertRoleMatrix({
|
await assertRoleMatrix({
|
||||||
name: 'GET /b/organization/departments/:id role matrix',
|
name: 'GET /b/organization/departments/:id role matrix',
|
||||||
tokens: ctx.tokens,
|
tokens: ctx.tokens,
|
||||||
expectedStatusByRole: {
|
expectedStatusByRole: {
|
||||||
[Role.SYSTEM_ADMIN]: 200,
|
[Role.SYSTEM_ADMIN]: 200,
|
||||||
[Role.HOSPITAL_ADMIN]: 200,
|
[Role.HOSPITAL_ADMIN]: 200,
|
||||||
[Role.DIRECTOR]: 200,
|
[Role.DIRECTOR]: 403,
|
||||||
[Role.LEADER]: 200,
|
[Role.LEADER]: 403,
|
||||||
[Role.DOCTOR]: 403,
|
[Role.DOCTOR]: 403,
|
||||||
[Role.ENGINEER]: 403,
|
[Role.ENGINEER]: 403,
|
||||||
},
|
},
|
||||||
@ -413,15 +413,15 @@ describe('Organization Controllers (e2e)', () => {
|
|||||||
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
|
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER 可进入业务,其余角色 403,未登录 401', async () => {
|
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其他角色 403,未登录 401', async () => {
|
||||||
await assertRoleMatrix({
|
await assertRoleMatrix({
|
||||||
name: 'PATCH /b/organization/departments/:id role matrix',
|
name: 'PATCH /b/organization/departments/:id role matrix',
|
||||||
tokens: ctx.tokens,
|
tokens: ctx.tokens,
|
||||||
expectedStatusByRole: {
|
expectedStatusByRole: {
|
||||||
[Role.SYSTEM_ADMIN]: 404,
|
[Role.SYSTEM_ADMIN]: 404,
|
||||||
[Role.HOSPITAL_ADMIN]: 404,
|
[Role.HOSPITAL_ADMIN]: 404,
|
||||||
[Role.DIRECTOR]: 404,
|
[Role.DIRECTOR]: 403,
|
||||||
[Role.LEADER]: 404,
|
[Role.LEADER]: 403,
|
||||||
[Role.DOCTOR]: 403,
|
[Role.DOCTOR]: 403,
|
||||||
[Role.ENGINEER]: 403,
|
[Role.ENGINEER]: 403,
|
||||||
},
|
},
|
||||||
@ -516,14 +516,14 @@ describe('Organization Controllers (e2e)', () => {
|
|||||||
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
|
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR 可进入业务,其余角色 403,未登录 401', async () => {
|
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其他角色 403,未登录 401', async () => {
|
||||||
await assertRoleMatrix({
|
await assertRoleMatrix({
|
||||||
name: 'POST /b/organization/groups role matrix',
|
name: 'POST /b/organization/groups role matrix',
|
||||||
tokens: ctx.tokens,
|
tokens: ctx.tokens,
|
||||||
expectedStatusByRole: {
|
expectedStatusByRole: {
|
||||||
[Role.SYSTEM_ADMIN]: 400,
|
[Role.SYSTEM_ADMIN]: 400,
|
||||||
[Role.HOSPITAL_ADMIN]: 400,
|
[Role.HOSPITAL_ADMIN]: 400,
|
||||||
[Role.DIRECTOR]: 400,
|
[Role.DIRECTOR]: 403,
|
||||||
[Role.LEADER]: 403,
|
[Role.LEADER]: 403,
|
||||||
[Role.DOCTOR]: 403,
|
[Role.DOCTOR]: 403,
|
||||||
[Role.ENGINEER]: 403,
|
[Role.ENGINEER]: 403,
|
||||||
@ -558,15 +558,15 @@ describe('Organization Controllers (e2e)', () => {
|
|||||||
expectErrorEnvelope(response, 401, '缺少 Bearer Token');
|
expectErrorEnvelope(response, 401, '缺少 Bearer Token');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER 可访问,其余角色 403,未登录 401', async () => {
|
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可访问,其他角色 403,未登录 401', async () => {
|
||||||
await assertRoleMatrix({
|
await assertRoleMatrix({
|
||||||
name: 'GET /b/organization/groups role matrix',
|
name: 'GET /b/organization/groups role matrix',
|
||||||
tokens: ctx.tokens,
|
tokens: ctx.tokens,
|
||||||
expectedStatusByRole: {
|
expectedStatusByRole: {
|
||||||
[Role.SYSTEM_ADMIN]: 200,
|
[Role.SYSTEM_ADMIN]: 200,
|
||||||
[Role.HOSPITAL_ADMIN]: 200,
|
[Role.HOSPITAL_ADMIN]: 200,
|
||||||
[Role.DIRECTOR]: 200,
|
[Role.DIRECTOR]: 403,
|
||||||
[Role.LEADER]: 200,
|
[Role.LEADER]: 403,
|
||||||
[Role.DOCTOR]: 403,
|
[Role.DOCTOR]: 403,
|
||||||
[Role.ENGINEER]: 403,
|
[Role.ENGINEER]: 403,
|
||||||
},
|
},
|
||||||
@ -598,15 +598,15 @@ describe('Organization Controllers (e2e)', () => {
|
|||||||
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
|
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER 可访问,其余角色 403,未登录 401', async () => {
|
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可访问,其他角色 403,未登录 401', async () => {
|
||||||
await assertRoleMatrix({
|
await assertRoleMatrix({
|
||||||
name: 'GET /b/organization/groups/:id role matrix',
|
name: 'GET /b/organization/groups/:id role matrix',
|
||||||
tokens: ctx.tokens,
|
tokens: ctx.tokens,
|
||||||
expectedStatusByRole: {
|
expectedStatusByRole: {
|
||||||
[Role.SYSTEM_ADMIN]: 200,
|
[Role.SYSTEM_ADMIN]: 200,
|
||||||
[Role.HOSPITAL_ADMIN]: 200,
|
[Role.HOSPITAL_ADMIN]: 200,
|
||||||
[Role.DIRECTOR]: 200,
|
[Role.DIRECTOR]: 403,
|
||||||
[Role.LEADER]: 200,
|
[Role.LEADER]: 403,
|
||||||
[Role.DOCTOR]: 403,
|
[Role.DOCTOR]: 403,
|
||||||
[Role.ENGINEER]: 403,
|
[Role.ENGINEER]: 403,
|
||||||
},
|
},
|
||||||
@ -650,15 +650,15 @@ describe('Organization Controllers (e2e)', () => {
|
|||||||
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
|
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER 可进入业务,其余角色 403,未登录 401', async () => {
|
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其他角色 403,未登录 401', async () => {
|
||||||
await assertRoleMatrix({
|
await assertRoleMatrix({
|
||||||
name: 'PATCH /b/organization/groups/:id role matrix',
|
name: 'PATCH /b/organization/groups/:id role matrix',
|
||||||
tokens: ctx.tokens,
|
tokens: ctx.tokens,
|
||||||
expectedStatusByRole: {
|
expectedStatusByRole: {
|
||||||
[Role.SYSTEM_ADMIN]: 404,
|
[Role.SYSTEM_ADMIN]: 404,
|
||||||
[Role.HOSPITAL_ADMIN]: 404,
|
[Role.HOSPITAL_ADMIN]: 404,
|
||||||
[Role.DIRECTOR]: 404,
|
[Role.DIRECTOR]: 403,
|
||||||
[Role.LEADER]: 404,
|
[Role.LEADER]: 403,
|
||||||
[Role.DOCTOR]: 403,
|
[Role.DOCTOR]: 403,
|
||||||
[Role.ENGINEER]: 403,
|
[Role.ENGINEER]: 403,
|
||||||
},
|
},
|
||||||
@ -702,22 +702,14 @@ describe('Organization Controllers (e2e)', () => {
|
|||||||
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
|
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('失败:删除有成员的小组返回 409', async () => {
|
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其他角色 403,未登录 401', async () => {
|
||||||
const response = await request(ctx.app.getHttpServer())
|
|
||||||
.delete(`/b/organization/groups/${ctx.fixtures.groupA1Id}`)
|
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`);
|
|
||||||
|
|
||||||
expectErrorEnvelope(response, 409, '小组下仍有成员,无法删除');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR 可进入业务,其余角色 403,未登录 401', async () => {
|
|
||||||
await assertRoleMatrix({
|
await assertRoleMatrix({
|
||||||
name: 'DELETE /b/organization/groups/:id role matrix',
|
name: 'DELETE /b/organization/groups/:id role matrix',
|
||||||
tokens: ctx.tokens,
|
tokens: ctx.tokens,
|
||||||
expectedStatusByRole: {
|
expectedStatusByRole: {
|
||||||
[Role.SYSTEM_ADMIN]: 404,
|
[Role.SYSTEM_ADMIN]: 404,
|
||||||
[Role.HOSPITAL_ADMIN]: 404,
|
[Role.HOSPITAL_ADMIN]: 404,
|
||||||
[Role.DIRECTOR]: 404,
|
[Role.DIRECTOR]: 403,
|
||||||
[Role.LEADER]: 403,
|
[Role.LEADER]: 403,
|
||||||
[Role.DOCTOR]: 403,
|
[Role.DOCTOR]: 403,
|
||||||
[Role.ENGINEER]: 403,
|
[Role.ENGINEER]: 403,
|
||||||
|
|||||||
@ -146,18 +146,17 @@ describe('Patients Controllers (e2e)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('GET /c/patients/lifecycle', () => {
|
describe('GET /c/patients/lifecycle', () => {
|
||||||
it('成功:已登录用户可按 phone + idCard 查询跨院生命周期', async () => {
|
it('成功:可按 phone + idCardHash 查询跨院生命周期', async () => {
|
||||||
const response = await request(ctx.app.getHttpServer())
|
const response = await request(ctx.app.getHttpServer())
|
||||||
.get('/c/patients/lifecycle')
|
.get('/c/patients/lifecycle')
|
||||||
.query({
|
.query({
|
||||||
phone: '13800002001',
|
phone: '13800002001',
|
||||||
idCard: '110101199001010011',
|
idCardHash: 'seed-id-card-cross-hospital',
|
||||||
})
|
});
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`);
|
|
||||||
|
|
||||||
expectSuccessEnvelope(response, 200);
|
expectSuccessEnvelope(response, 200);
|
||||||
expect(response.body.data.phone).toBe('13800002001');
|
expect(response.body.data.phone).toBe('13800002001');
|
||||||
expect(response.body.data.idCard).toBe('110101199001010011');
|
expect(response.body.data.idCardHash).toBe('seed-id-card-cross-hospital');
|
||||||
expect(response.body.data.patientCount).toBeGreaterThanOrEqual(2);
|
expect(response.body.data.patientCount).toBeGreaterThanOrEqual(2);
|
||||||
expect(Array.isArray(response.body.data.lifecycle)).toBe(true);
|
expect(Array.isArray(response.body.data.lifecycle)).toBe(true);
|
||||||
});
|
});
|
||||||
@ -167,10 +166,9 @@ describe('Patients Controllers (e2e)', () => {
|
|||||||
.get('/c/patients/lifecycle')
|
.get('/c/patients/lifecycle')
|
||||||
.query({
|
.query({
|
||||||
phone: '13800002001',
|
phone: '13800002001',
|
||||||
})
|
});
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`);
|
|
||||||
|
|
||||||
expectErrorEnvelope(response, 400, 'idCard 必须是字符串');
|
expectErrorEnvelope(response, 400, 'idCardHash 必须是字符串');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('失败:不存在患者返回 404', async () => {
|
it('失败:不存在患者返回 404', async () => {
|
||||||
@ -178,9 +176,8 @@ describe('Patients Controllers (e2e)', () => {
|
|||||||
.get('/c/patients/lifecycle')
|
.get('/c/patients/lifecycle')
|
||||||
.query({
|
.query({
|
||||||
phone: '13800009999',
|
phone: '13800009999',
|
||||||
idCard: '110101199009090099',
|
idCardHash: 'not-exists-idcard-hash',
|
||||||
})
|
});
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`);
|
|
||||||
|
|
||||||
expectErrorEnvelope(response, 404, '未找到匹配的患者档案');
|
expectErrorEnvelope(response, 404, '未找到匹配的患者档案');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -74,15 +74,15 @@ describe('BTasksController (e2e)', () => {
|
|||||||
expectErrorEnvelope(response, 404, '存在设备不在当前医院或设备不存在');
|
expectErrorEnvelope(response, 404, '存在设备不在当前医院或设备不存在');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('角色矩阵:DOCTOR/DIRECTOR/LEADER 可进入业务,其余角色 403,未登录 401', async () => {
|
it('角色矩阵:仅 DOCTOR 可进入业务,其他角色 403,未登录 401', async () => {
|
||||||
await assertRoleMatrix({
|
await assertRoleMatrix({
|
||||||
name: 'POST /b/tasks/publish role matrix',
|
name: 'POST /b/tasks/publish role matrix',
|
||||||
tokens: ctx.tokens,
|
tokens: ctx.tokens,
|
||||||
expectedStatusByRole: {
|
expectedStatusByRole: {
|
||||||
[Role.SYSTEM_ADMIN]: 403,
|
[Role.SYSTEM_ADMIN]: 403,
|
||||||
[Role.HOSPITAL_ADMIN]: 403,
|
[Role.HOSPITAL_ADMIN]: 403,
|
||||||
[Role.DIRECTOR]: 400,
|
[Role.DIRECTOR]: 403,
|
||||||
[Role.LEADER]: 400,
|
[Role.LEADER]: 403,
|
||||||
[Role.DOCTOR]: 400,
|
[Role.DOCTOR]: 400,
|
||||||
[Role.ENGINEER]: 403,
|
[Role.ENGINEER]: 403,
|
||||||
},
|
},
|
||||||
@ -298,15 +298,15 @@ describe('BTasksController (e2e)', () => {
|
|||||||
expectErrorEnvelope(cancelResponse, 409, '仅待接收/已接收任务可取消');
|
expectErrorEnvelope(cancelResponse, 409, '仅待接收/已接收任务可取消');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('角色矩阵:DOCTOR/DIRECTOR/LEADER 可进入业务,其余角色 403,未登录 401', async () => {
|
it('角色矩阵:仅 DOCTOR 可进入业务,其他角色 403,未登录 401', async () => {
|
||||||
await assertRoleMatrix({
|
await assertRoleMatrix({
|
||||||
name: 'POST /b/tasks/cancel role matrix',
|
name: 'POST /b/tasks/cancel role matrix',
|
||||||
tokens: ctx.tokens,
|
tokens: ctx.tokens,
|
||||||
expectedStatusByRole: {
|
expectedStatusByRole: {
|
||||||
[Role.SYSTEM_ADMIN]: 403,
|
[Role.SYSTEM_ADMIN]: 403,
|
||||||
[Role.HOSPITAL_ADMIN]: 403,
|
[Role.HOSPITAL_ADMIN]: 403,
|
||||||
[Role.DIRECTOR]: 404,
|
[Role.DIRECTOR]: 403,
|
||||||
[Role.LEADER]: 404,
|
[Role.LEADER]: 403,
|
||||||
[Role.DOCTOR]: 404,
|
[Role.DOCTOR]: 404,
|
||||||
[Role.ENGINEER]: 403,
|
[Role.ENGINEER]: 403,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -65,10 +65,6 @@ describe('UsersController + BUsersController (e2e)', () => {
|
|||||||
await createDoctorUser(ctx.tokens[Role.SYSTEM_ADMIN]);
|
await createDoctorUser(ctx.tokens[Role.SYSTEM_ADMIN]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('成功:DIRECTOR 可创建本科室医生', async () => {
|
|
||||||
await createDoctorUser(ctx.tokens[Role.DIRECTOR]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('失败:参数校验失败返回 400', async () => {
|
it('失败:参数校验失败返回 400', async () => {
|
||||||
const response = await request(ctx.app.getHttpServer())
|
const response = await request(ctx.app.getHttpServer())
|
||||||
.post('/users')
|
.post('/users')
|
||||||
@ -86,31 +82,14 @@ describe('UsersController + BUsersController (e2e)', () => {
|
|||||||
expectErrorEnvelope(response, 400, 'phone 必须是合法手机号');
|
expectErrorEnvelope(response, 400, 'phone 必须是合法手机号');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('失败:DIRECTOR 创建非医生角色返回 403', async () => {
|
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其他角色 403,未登录 401', async () => {
|
||||||
const response = await request(ctx.app.getHttpServer())
|
|
||||||
.post('/users')
|
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`)
|
|
||||||
.send({
|
|
||||||
name: uniqueSeedValue('主任创建组长'),
|
|
||||||
phone: uniquePhone(),
|
|
||||||
password: 'Seed@1234',
|
|
||||||
role: Role.LEADER,
|
|
||||||
hospitalId: ctx.fixtures.hospitalAId,
|
|
||||||
departmentId: ctx.fixtures.departmentA1Id,
|
|
||||||
groupId: ctx.fixtures.groupA1Id,
|
|
||||||
});
|
|
||||||
|
|
||||||
expectErrorEnvelope(response, 403, '当前角色无权限创建该用户');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR 可进入业务,其他角色 403,未登录 401', async () => {
|
|
||||||
await assertRoleMatrix({
|
await assertRoleMatrix({
|
||||||
name: 'POST /users role matrix',
|
name: 'POST /users role matrix',
|
||||||
tokens: ctx.tokens,
|
tokens: ctx.tokens,
|
||||||
expectedStatusByRole: {
|
expectedStatusByRole: {
|
||||||
[Role.SYSTEM_ADMIN]: 400,
|
[Role.SYSTEM_ADMIN]: 400,
|
||||||
[Role.HOSPITAL_ADMIN]: 400,
|
[Role.HOSPITAL_ADMIN]: 400,
|
||||||
[Role.DIRECTOR]: 400,
|
[Role.DIRECTOR]: 403,
|
||||||
[Role.LEADER]: 403,
|
[Role.LEADER]: 403,
|
||||||
[Role.DOCTOR]: 403,
|
[Role.DOCTOR]: 403,
|
||||||
[Role.ENGINEER]: 403,
|
[Role.ENGINEER]: 403,
|
||||||
@ -141,15 +120,15 @@ describe('UsersController + BUsersController (e2e)', () => {
|
|||||||
expectErrorEnvelope(response, 401, '缺少 Bearer Token');
|
expectErrorEnvelope(response, 401, '缺少 Bearer Token');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER 可访问,其余角色 403,未登录 401', async () => {
|
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可访问,其他角色 403,未登录 401', async () => {
|
||||||
await assertRoleMatrix({
|
await assertRoleMatrix({
|
||||||
name: 'GET /users role matrix',
|
name: 'GET /users role matrix',
|
||||||
tokens: ctx.tokens,
|
tokens: ctx.tokens,
|
||||||
expectedStatusByRole: {
|
expectedStatusByRole: {
|
||||||
[Role.SYSTEM_ADMIN]: 200,
|
[Role.SYSTEM_ADMIN]: 200,
|
||||||
[Role.HOSPITAL_ADMIN]: 200,
|
[Role.HOSPITAL_ADMIN]: 200,
|
||||||
[Role.DIRECTOR]: 200,
|
[Role.DIRECTOR]: 403,
|
||||||
[Role.LEADER]: 200,
|
[Role.LEADER]: 403,
|
||||||
[Role.DOCTOR]: 403,
|
[Role.DOCTOR]: 403,
|
||||||
[Role.ENGINEER]: 403,
|
[Role.ENGINEER]: 403,
|
||||||
},
|
},
|
||||||
@ -173,15 +152,6 @@ describe('UsersController + BUsersController (e2e)', () => {
|
|||||||
expect(response.body.data.id).toBe(ctx.fixtures.users.doctorAId);
|
expect(response.body.data.id).toBe(ctx.fixtures.users.doctorAId);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('成功:DIRECTOR 可查询本科室医生详情', async () => {
|
|
||||||
const response = await request(ctx.app.getHttpServer())
|
|
||||||
.get(`/users/${ctx.fixtures.users.doctorAId}`)
|
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`);
|
|
||||||
|
|
||||||
expectSuccessEnvelope(response, 200);
|
|
||||||
expect(response.body.data.id).toBe(ctx.fixtures.users.doctorAId);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('失败:查询不存在用户返回 404', async () => {
|
it('失败:查询不存在用户返回 404', async () => {
|
||||||
const response = await request(ctx.app.getHttpServer())
|
const response = await request(ctx.app.getHttpServer())
|
||||||
.get('/users/99999999')
|
.get('/users/99999999')
|
||||||
@ -190,22 +160,14 @@ describe('UsersController + BUsersController (e2e)', () => {
|
|||||||
expectErrorEnvelope(response, 404, '用户不存在');
|
expectErrorEnvelope(response, 404, '用户不存在');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('失败:DIRECTOR 查询非本科室医生返回 403', async () => {
|
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可访问,其他角色 403,未登录 401', async () => {
|
||||||
const response = await request(ctx.app.getHttpServer())
|
|
||||||
.get(`/users/${ctx.fixtures.users.doctorA3Id}`)
|
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`);
|
|
||||||
|
|
||||||
expectErrorEnvelope(response, 403, '科室主任仅可操作本科室医生账号');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR 可访问,其他角色 403,未登录 401', async () => {
|
|
||||||
await assertRoleMatrix({
|
await assertRoleMatrix({
|
||||||
name: 'GET /users/:id role matrix',
|
name: 'GET /users/:id role matrix',
|
||||||
tokens: ctx.tokens,
|
tokens: ctx.tokens,
|
||||||
expectedStatusByRole: {
|
expectedStatusByRole: {
|
||||||
[Role.SYSTEM_ADMIN]: 200,
|
[Role.SYSTEM_ADMIN]: 200,
|
||||||
[Role.HOSPITAL_ADMIN]: 200,
|
[Role.HOSPITAL_ADMIN]: 200,
|
||||||
[Role.DIRECTOR]: 200,
|
[Role.DIRECTOR]: 403,
|
||||||
[Role.LEADER]: 403,
|
[Role.LEADER]: 403,
|
||||||
[Role.DOCTOR]: 403,
|
[Role.DOCTOR]: 403,
|
||||||
[Role.ENGINEER]: 403,
|
[Role.ENGINEER]: 403,
|
||||||
@ -236,19 +198,6 @@ describe('UsersController + BUsersController (e2e)', () => {
|
|||||||
expect(response.body.data.name).toBe(nextName);
|
expect(response.body.data.name).toBe(nextName);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('成功:DIRECTOR 可更新本科室医生姓名', async () => {
|
|
||||||
const created = await createDoctorUser(ctx.tokens[Role.DIRECTOR]);
|
|
||||||
const nextName = uniqueSeedValue('主任更新医生名');
|
|
||||||
|
|
||||||
const response = await request(ctx.app.getHttpServer())
|
|
||||||
.patch(`/users/${created.id}`)
|
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`)
|
|
||||||
.send({ name: nextName });
|
|
||||||
|
|
||||||
expectSuccessEnvelope(response, 200);
|
|
||||||
expect(response.body.data.name).toBe(nextName);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('失败:非医生调整科室/小组返回 400', async () => {
|
it('失败:非医生调整科室/小组返回 400', async () => {
|
||||||
const response = await request(ctx.app.getHttpServer())
|
const response = await request(ctx.app.getHttpServer())
|
||||||
.patch(`/users/${ctx.fixtures.users.engineerAId}`)
|
.patch(`/users/${ctx.fixtures.users.engineerAId}`)
|
||||||
@ -258,39 +207,17 @@ describe('UsersController + BUsersController (e2e)', () => {
|
|||||||
groupId: ctx.fixtures.groupA1Id,
|
groupId: ctx.fixtures.groupA1Id,
|
||||||
});
|
});
|
||||||
|
|
||||||
expectErrorEnvelope(
|
expectErrorEnvelope(response, 400, '仅医生/主任/组长允许调整科室/小组归属');
|
||||||
response,
|
|
||||||
400,
|
|
||||||
'仅医生/主任/组长允许调整科室/小组归属',
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('失败:DIRECTOR 不能把医生改成其他角色', async () => {
|
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其他角色 403,未登录 401', async () => {
|
||||||
const response = await request(ctx.app.getHttpServer())
|
|
||||||
.patch(`/users/${ctx.fixtures.users.doctorAId}`)
|
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`)
|
|
||||||
.send({ role: Role.LEADER });
|
|
||||||
|
|
||||||
expectErrorEnvelope(response, 403, '科室主任仅可操作本科室医生账号');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('失败:DIRECTOR 不能把医生调整到其他科室', async () => {
|
|
||||||
const response = await request(ctx.app.getHttpServer())
|
|
||||||
.patch(`/users/${ctx.fixtures.users.doctorAId}`)
|
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`)
|
|
||||||
.send({ departmentId: ctx.fixtures.departmentA2Id });
|
|
||||||
|
|
||||||
expectErrorEnvelope(response, 403, '科室主任仅可操作本科室医生账号');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR 可进入业务,其他角色 403,未登录 401', async () => {
|
|
||||||
await assertRoleMatrix({
|
await assertRoleMatrix({
|
||||||
name: 'PATCH /users/:id role matrix',
|
name: 'PATCH /users/:id role matrix',
|
||||||
tokens: ctx.tokens,
|
tokens: ctx.tokens,
|
||||||
expectedStatusByRole: {
|
expectedStatusByRole: {
|
||||||
[Role.SYSTEM_ADMIN]: 404,
|
[Role.SYSTEM_ADMIN]: 404,
|
||||||
[Role.HOSPITAL_ADMIN]: 404,
|
[Role.HOSPITAL_ADMIN]: 404,
|
||||||
[Role.DIRECTOR]: 404,
|
[Role.DIRECTOR]: 403,
|
||||||
[Role.LEADER]: 403,
|
[Role.LEADER]: 403,
|
||||||
[Role.DOCTOR]: 403,
|
[Role.DOCTOR]: 403,
|
||||||
[Role.ENGINEER]: 403,
|
[Role.ENGINEER]: 403,
|
||||||
@ -319,16 +246,6 @@ describe('UsersController + BUsersController (e2e)', () => {
|
|||||||
expect(response.body.data.id).toBe(created.id);
|
expect(response.body.data.id).toBe(created.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('成功:DIRECTOR 可删除本科室医生', async () => {
|
|
||||||
const created = await createDoctorUser(ctx.tokens[Role.DIRECTOR]);
|
|
||||||
const response = await request(ctx.app.getHttpServer())
|
|
||||||
.delete(`/users/${created.id}`)
|
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`);
|
|
||||||
|
|
||||||
expectSuccessEnvelope(response, 200);
|
|
||||||
expect(response.body.data.id).toBe(created.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('失败:存在关联患者/任务时返回 409', async () => {
|
it('失败:存在关联患者/任务时返回 409', async () => {
|
||||||
const response = await request(ctx.app.getHttpServer())
|
const response = await request(ctx.app.getHttpServer())
|
||||||
.delete(`/users/${ctx.fixtures.users.doctorAId}`)
|
.delete(`/users/${ctx.fixtures.users.doctorAId}`)
|
||||||
@ -337,14 +254,6 @@ describe('UsersController + BUsersController (e2e)', () => {
|
|||||||
expectErrorEnvelope(response, 409, '用户存在关联患者或任务,无法删除');
|
expectErrorEnvelope(response, 409, '用户存在关联患者或任务,无法删除');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('失败:DIRECTOR 删除非本科室医生返回 403', async () => {
|
|
||||||
const response = await request(ctx.app.getHttpServer())
|
|
||||||
.delete(`/users/${ctx.fixtures.users.doctorA3Id}`)
|
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`);
|
|
||||||
|
|
||||||
expectErrorEnvelope(response, 403, '科室主任仅可操作本科室医生账号');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('失败:HOSPITAL_ADMIN 无法删除返回 403', async () => {
|
it('失败:HOSPITAL_ADMIN 无法删除返回 403', async () => {
|
||||||
const response = await request(ctx.app.getHttpServer())
|
const response = await request(ctx.app.getHttpServer())
|
||||||
.delete(`/users/${ctx.fixtures.users.doctorAId}`)
|
.delete(`/users/${ctx.fixtures.users.doctorAId}`)
|
||||||
@ -353,14 +262,14 @@ describe('UsersController + BUsersController (e2e)', () => {
|
|||||||
expectErrorEnvelope(response, 403, '无权限执行当前操作');
|
expectErrorEnvelope(response, 403, '无权限执行当前操作');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('角色矩阵:SYSTEM_ADMIN/DIRECTOR 可进入业务,其他角色 403,未登录 401', async () => {
|
it('角色矩阵:仅 SYSTEM_ADMIN 可进入业务,其他角色 403,未登录 401', async () => {
|
||||||
await assertRoleMatrix({
|
await assertRoleMatrix({
|
||||||
name: 'DELETE /users/:id role matrix',
|
name: 'DELETE /users/:id role matrix',
|
||||||
tokens: ctx.tokens,
|
tokens: ctx.tokens,
|
||||||
expectedStatusByRole: {
|
expectedStatusByRole: {
|
||||||
[Role.SYSTEM_ADMIN]: 404,
|
[Role.SYSTEM_ADMIN]: 404,
|
||||||
[Role.HOSPITAL_ADMIN]: 403,
|
[Role.HOSPITAL_ADMIN]: 403,
|
||||||
[Role.DIRECTOR]: 404,
|
[Role.DIRECTOR]: 403,
|
||||||
[Role.LEADER]: 403,
|
[Role.LEADER]: 403,
|
||||||
[Role.DOCTOR]: 403,
|
[Role.DOCTOR]: 403,
|
||||||
[Role.ENGINEER]: 403,
|
[Role.ENGINEER]: 403,
|
||||||
|
|||||||
1
tyt-admin/components.d.ts
vendored
1
tyt-admin/components.d.ts
vendored
@ -45,7 +45,6 @@ declare module 'vue' {
|
|||||||
ElTimeline: typeof import('element-plus/es')['ElTimeline']
|
ElTimeline: typeof import('element-plus/es')['ElTimeline']
|
||||||
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
|
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
|
||||||
ElTree: typeof import('element-plus/es')['ElTree']
|
ElTree: typeof import('element-plus/es')['ElTree']
|
||||||
ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect']
|
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,24 +0,0 @@
|
|||||||
import request from './request';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 设备列表:后端已支持服务端分页与筛选。
|
|
||||||
*/
|
|
||||||
export const getDevices = (params) => {
|
|
||||||
return request.get('/b/devices', { params });
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getDeviceById = (id) => {
|
|
||||||
return request.get(`/b/devices/${id}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createDevice = (data) => {
|
|
||||||
return request.post('/b/devices', data);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateDevice = (id, data) => {
|
|
||||||
return request.patch(`/b/devices/${id}`, data);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteDevice = (id) => {
|
|
||||||
return request.delete(`/b/devices/${id}`);
|
|
||||||
};
|
|
||||||
@ -4,12 +4,11 @@ import { useUserStore } from '../store/user';
|
|||||||
import router from '../router';
|
import router from '../router';
|
||||||
|
|
||||||
const service = axios.create({
|
const service = axios.create({
|
||||||
// 开发环境默认走 Vite /api 代理,生产环境可用环境变量覆盖。
|
baseURL: import.meta.env.VITE_API_BASE_URL || '/api', // Use /api as default proxy prefix
|
||||||
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
|
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 请求拦截:统一挂载 Bearer Token,避免各页面重复拼接鉴权头。
|
// Request Interceptor
|
||||||
service.interceptors.request.use(
|
service.interceptors.request.use(
|
||||||
(config) => {
|
(config) => {
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
@ -20,18 +19,19 @@ service.interceptors.request.use(
|
|||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// 响应拦截:对齐后端统一响应包裹 { code, msg, data }。
|
// Response Interceptor
|
||||||
service.interceptors.response.use(
|
service.interceptors.response.use(
|
||||||
(response) => {
|
(response) => {
|
||||||
const res = response.data;
|
const res = response.data;
|
||||||
|
// Backend standard format: { code: number, msg: string, data: any }
|
||||||
// 后端成功响应统一为 code=0;这里兼容少量 code=2xx 的历史结构。
|
// Accept code 0 or 2xx as success
|
||||||
if (res.code === 0 || (res.code >= 200 && res.code < 300)) {
|
if (res.code === 0 || (res.code >= 200 && res.code < 300)) {
|
||||||
return res.data;
|
return res.data;
|
||||||
} else {
|
} else {
|
||||||
|
// If backend returns code !== 0/2xx but HTTP status is 200
|
||||||
ElMessage.error(res.msg || '请求失败');
|
ElMessage.error(res.msg || '请求失败');
|
||||||
return Promise.reject(new Error(res.msg || 'Error'));
|
return Promise.reject(new Error(res.msg || 'Error'));
|
||||||
}
|
}
|
||||||
@ -42,26 +42,25 @@ service.interceptors.response.use(
|
|||||||
|
|
||||||
if (error.response) {
|
if (error.response) {
|
||||||
const { status, data } = error.response;
|
const { status, data } = error.response;
|
||||||
|
// Backend error response format: { code: number, msg: string, data: null }
|
||||||
message = data?.msg || message;
|
message = data?.msg || message;
|
||||||
|
|
||||||
if (status === 401) {
|
if (status === 401) {
|
||||||
// 401 统一视为登录态失效,先清理本地态再跳登录页。
|
// Token expired or invalid
|
||||||
userStore.logout();
|
userStore.logout();
|
||||||
router.push(
|
router.push(`/login?redirect=${encodeURIComponent(router.currentRoute.value.fullPath)}`);
|
||||||
`/login?redirect=${encodeURIComponent(router.currentRoute.value.fullPath)}`,
|
|
||||||
);
|
|
||||||
ElMessage.error(message || '登录状态已过期,请重新登录');
|
ElMessage.error(message || '登录状态已过期,请重新登录');
|
||||||
return Promise.reject(new Error('Unauthorized'));
|
return Promise.reject(new Error('Unauthorized'));
|
||||||
} else if (status === 403) {
|
} else if (status === 403) {
|
||||||
ElMessage.error(message || '没有权限执行该操作');
|
ElMessage.error(message || '没有权限执行该操作');
|
||||||
} else {
|
} else {
|
||||||
ElMessage.error(message || '请求失败');
|
ElMessage.error(message || '请求失败');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
ElMessage.error(message || '网络连接异常');
|
ElMessage.error(message || '网络连接异常');
|
||||||
}
|
}
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export default service;
|
export default service;
|
||||||
|
|||||||
@ -8,29 +8,20 @@ const ORG_MANAGER_ROLES = Object.freeze([
|
|||||||
'DIRECTOR',
|
'DIRECTOR',
|
||||||
'LEADER',
|
'LEADER',
|
||||||
]);
|
]);
|
||||||
const USER_MANAGER_ROLES = Object.freeze([
|
|
||||||
'SYSTEM_ADMIN',
|
|
||||||
'HOSPITAL_ADMIN',
|
|
||||||
'DIRECTOR',
|
|
||||||
]);
|
|
||||||
const TASK_ROLES = Object.freeze(['DOCTOR', 'DIRECTOR', 'LEADER', 'ENGINEER']);
|
const TASK_ROLES = Object.freeze(['DOCTOR', 'DIRECTOR', 'LEADER', 'ENGINEER']);
|
||||||
const PATIENT_ROLES = Object.freeze([
|
const PATIENT_ROLES = Object.freeze([
|
||||||
'SYSTEM_ADMIN',
|
'SYSTEM_ADMIN',
|
||||||
'HOSPITAL_ADMIN',
|
'HOSPITAL_ADMIN',
|
||||||
'DIRECTOR',
|
'DIRECTOR',
|
||||||
'LEADER',
|
'LEADER',
|
||||||
// 后端患者接口允许医生访问,页面侧也应放开,避免前端先把医生拦掉。
|
|
||||||
'DOCTOR',
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const ROLE_PERMISSIONS = Object.freeze({
|
export const ROLE_PERMISSIONS = Object.freeze({
|
||||||
ORG_TREE: ORG_MANAGER_ROLES,
|
ORG_TREE: ORG_MANAGER_ROLES,
|
||||||
ORG_HOSPITALS: Object.freeze(['SYSTEM_ADMIN']),
|
ORG_HOSPITALS: Object.freeze(['SYSTEM_ADMIN']),
|
||||||
// 主任/组长仍可通过接口读取科室信息,但不再开放独立“科室管理”页面。
|
ORG_DEPARTMENTS: ORG_MANAGER_ROLES,
|
||||||
ORG_DEPARTMENTS: ADMIN_ROLES,
|
|
||||||
ORG_GROUPS: ORG_MANAGER_ROLES,
|
ORG_GROUPS: ORG_MANAGER_ROLES,
|
||||||
USERS: USER_MANAGER_ROLES,
|
USERS: ADMIN_ROLES,
|
||||||
DEVICES: ADMIN_ROLES,
|
|
||||||
TASKS: TASK_ROLES,
|
TASKS: TASK_ROLES,
|
||||||
PATIENTS: PATIENT_ROLES,
|
PATIENTS: PATIENT_ROLES,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -22,12 +22,8 @@
|
|||||||
<span>组织架构</span>
|
<span>组织架构</span>
|
||||||
</template>
|
</template>
|
||||||
<el-menu-item index="/organization/tree">结构图视图</el-menu-item>
|
<el-menu-item index="/organization/tree">结构图视图</el-menu-item>
|
||||||
<el-menu-item index="/organization/hospitals"
|
<el-menu-item index="/organization/hospitals">医院管理</el-menu-item>
|
||||||
>医院管理</el-menu-item
|
<el-menu-item index="/organization/departments">科室管理</el-menu-item>
|
||||||
>
|
|
||||||
<el-menu-item index="/organization/departments"
|
|
||||||
>科室管理</el-menu-item
|
|
||||||
>
|
|
||||||
<el-menu-item index="/organization/groups">小组管理</el-menu-item>
|
<el-menu-item index="/organization/groups">小组管理</el-menu-item>
|
||||||
</el-sub-menu>
|
</el-sub-menu>
|
||||||
</template>
|
</template>
|
||||||
@ -36,7 +32,7 @@
|
|||||||
<el-icon><Share /></el-icon>
|
<el-icon><Share /></el-icon>
|
||||||
<span>组织架构图</span>
|
<span>组织架构图</span>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
<el-menu-item v-if="canAccessDepartments" index="/organization/departments">
|
<el-menu-item index="/organization/departments">
|
||||||
<el-icon><OfficeBuilding /></el-icon>
|
<el-icon><OfficeBuilding /></el-icon>
|
||||||
<span>科室管理</span>
|
<span>科室管理</span>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
@ -48,12 +44,7 @@
|
|||||||
|
|
||||||
<el-menu-item v-if="canAccessUsers" index="/users">
|
<el-menu-item v-if="canAccessUsers" index="/users">
|
||||||
<el-icon><User /></el-icon>
|
<el-icon><User /></el-icon>
|
||||||
<span>{{ usersMenuLabel }}</span>
|
<span>用户管理</span>
|
||||||
</el-menu-item>
|
|
||||||
|
|
||||||
<el-menu-item v-if="canAccessDevices" index="/devices">
|
|
||||||
<el-icon><Monitor /></el-icon>
|
|
||||||
<span>设备管理</span>
|
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
|
|
||||||
<el-menu-item v-if="canAccessTasks" index="/tasks">
|
<el-menu-item v-if="canAccessTasks" index="/tasks">
|
||||||
@ -71,7 +62,7 @@
|
|||||||
<el-container>
|
<el-container>
|
||||||
<el-header class="header">
|
<el-header class="header">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<!-- Breadcrumbs can go here -->
|
<!-- Breadcrumbs can go here -->
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<el-dropdown @command="handleCommand">
|
<el-dropdown @command="handleCommand">
|
||||||
@ -109,17 +100,7 @@ import {
|
|||||||
ROLE_PERMISSIONS,
|
ROLE_PERMISSIONS,
|
||||||
hasRolePermission,
|
hasRolePermission,
|
||||||
} from '../constants/role-permissions';
|
} from '../constants/role-permissions';
|
||||||
import {
|
import { DataLine, OfficeBuilding, User, List, Avatar, ArrowDown, Connection, Share } from '@element-plus/icons-vue';
|
||||||
DataLine,
|
|
||||||
OfficeBuilding,
|
|
||||||
User,
|
|
||||||
List,
|
|
||||||
Avatar,
|
|
||||||
ArrowDown,
|
|
||||||
Connection,
|
|
||||||
Share,
|
|
||||||
Monitor,
|
|
||||||
} from '@element-plus/icons-vue';
|
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -129,28 +110,18 @@ const activeMenu = computed(() => {
|
|||||||
return route.path;
|
return route.path;
|
||||||
});
|
});
|
||||||
|
|
||||||
const isDirector = computed(() => userStore.role === 'DIRECTOR');
|
|
||||||
const canAccessUsers = computed(() =>
|
const canAccessUsers = computed(() =>
|
||||||
hasRolePermission(userStore.role, ROLE_PERMISSIONS.USERS),
|
hasRolePermission(userStore.role, ROLE_PERMISSIONS.USERS),
|
||||||
);
|
);
|
||||||
const canAccessDevices = computed(() =>
|
|
||||||
hasRolePermission(userStore.role, ROLE_PERMISSIONS.DEVICES),
|
|
||||||
);
|
|
||||||
const canAccessOrgTree = computed(() =>
|
const canAccessOrgTree = computed(() =>
|
||||||
hasRolePermission(userStore.role, ROLE_PERMISSIONS.ORG_TREE),
|
hasRolePermission(userStore.role, ROLE_PERMISSIONS.ORG_TREE),
|
||||||
);
|
);
|
||||||
const canAccessDepartments = computed(() =>
|
|
||||||
hasRolePermission(userStore.role, ROLE_PERMISSIONS.ORG_DEPARTMENTS),
|
|
||||||
);
|
|
||||||
const canAccessTasks = computed(() =>
|
const canAccessTasks = computed(() =>
|
||||||
hasRolePermission(userStore.role, ROLE_PERMISSIONS.TASKS),
|
hasRolePermission(userStore.role, ROLE_PERMISSIONS.TASKS),
|
||||||
);
|
);
|
||||||
const canAccessPatients = computed(() =>
|
const canAccessPatients = computed(() =>
|
||||||
hasRolePermission(userStore.role, ROLE_PERMISSIONS.PATIENTS),
|
hasRolePermission(userStore.role, ROLE_PERMISSIONS.PATIENTS),
|
||||||
);
|
);
|
||||||
const usersMenuLabel = computed(() =>
|
|
||||||
isDirector.value ? '医生管理' : '用户管理',
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleCommand = (command) => {
|
const handleCommand = (command) => {
|
||||||
if (command === 'logout') {
|
if (command === 'logout') {
|
||||||
@ -203,7 +174,7 @@ const handleCommand = (command) => {
|
|||||||
/* fade-transform transition */
|
/* fade-transform transition */
|
||||||
.fade-transform-leave-active,
|
.fade-transform-leave-active,
|
||||||
.fade-transform-enter-active {
|
.fade-transform-enter-active {
|
||||||
transition: all 0.3s;
|
transition: all .3s;
|
||||||
}
|
}
|
||||||
.fade-transform-enter-from {
|
.fade-transform-enter-from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|||||||
@ -77,16 +77,6 @@ const routes = [
|
|||||||
allowedRoles: ROLE_PERMISSIONS.USERS,
|
allowedRoles: ROLE_PERMISSIONS.USERS,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'devices',
|
|
||||||
name: 'Devices',
|
|
||||||
component: () => import('../views/devices/Devices.vue'),
|
|
||||||
meta: {
|
|
||||||
title: '设备管理',
|
|
||||||
requiresAuth: true,
|
|
||||||
allowedRoles: ROLE_PERMISSIONS.DEVICES,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'tasks',
|
path: 'tasks',
|
||||||
name: 'Tasks',
|
name: 'Tasks',
|
||||||
@ -106,7 +96,7 @@ const routes = [
|
|||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
allowedRoles: ROLE_PERMISSIONS.PATIENTS,
|
allowedRoles: ROLE_PERMISSIONS.PATIENTS,
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -5,13 +5,17 @@
|
|||||||
<p>当前角色:{{ userStore.role || '未登录' }}</p>
|
<p>当前角色:{{ userStore.role || '未登录' }}</p>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<el-card v-if="isSystemAdmin" shadow="never" class="filter-card">
|
<el-card
|
||||||
|
v-if="isSystemAdmin"
|
||||||
|
shadow="never"
|
||||||
|
class="filter-card"
|
||||||
|
>
|
||||||
<el-form inline>
|
<el-form inline>
|
||||||
<el-form-item label="患者统计医院">
|
<el-form-item label="患者统计医院">
|
||||||
<el-select
|
<el-select
|
||||||
v-model="selectedHospitalId"
|
v-model="selectedHospitalId"
|
||||||
placeholder="请选择医院"
|
placeholder="请选择医院"
|
||||||
style="width: 280px"
|
style="width: 280px;"
|
||||||
@change="fetchDashboardData"
|
@change="fetchDashboardData"
|
||||||
>
|
>
|
||||||
<el-option
|
<el-option
|
||||||
@ -26,13 +30,7 @@
|
|||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<el-row :gutter="16" v-loading="loading">
|
<el-row :gutter="16" v-loading="loading">
|
||||||
<el-col
|
<el-col :xs="24" :sm="12" :lg="6" v-for="item in statCards" :key="item.key">
|
||||||
:xs="24"
|
|
||||||
:sm="12"
|
|
||||||
:lg="6"
|
|
||||||
v-for="item in statCards"
|
|
||||||
:key="item.key"
|
|
||||||
>
|
|
||||||
<el-card shadow="hover" class="stat-card">
|
<el-card shadow="hover" class="stat-card">
|
||||||
<div class="stat-title">{{ item.title }}</div>
|
<div class="stat-title">{{ item.title }}</div>
|
||||||
<div class="stat-value">{{ item.value }}</div>
|
<div class="stat-value">{{ item.value }}</div>
|
||||||
@ -77,44 +75,19 @@ const isSystemAdmin = computed(() => userStore.role === 'SYSTEM_ADMIN');
|
|||||||
const canViewOrg = computed(() =>
|
const canViewOrg = computed(() =>
|
||||||
['SYSTEM_ADMIN', 'HOSPITAL_ADMIN'].includes(userStore.role),
|
['SYSTEM_ADMIN', 'HOSPITAL_ADMIN'].includes(userStore.role),
|
||||||
);
|
);
|
||||||
const canViewUsers = computed(() =>
|
|
||||||
['SYSTEM_ADMIN', 'HOSPITAL_ADMIN', 'DIRECTOR'].includes(userStore.role),
|
|
||||||
);
|
|
||||||
const canViewPatients = computed(() =>
|
const canViewPatients = computed(() =>
|
||||||
['SYSTEM_ADMIN', 'HOSPITAL_ADMIN', 'DIRECTOR', 'LEADER', 'DOCTOR'].includes(
|
['SYSTEM_ADMIN', 'HOSPITAL_ADMIN', 'DIRECTOR', 'LEADER', 'DOCTOR'].includes(
|
||||||
userStore.role,
|
userStore.role,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const statCards = computed(() => {
|
const statCards = computed(() => [
|
||||||
const cards = [];
|
{ key: 'hospitals', title: '医院总数', value: stats.value.hospitals },
|
||||||
|
{ key: 'departments', title: '科室总数', value: stats.value.departments },
|
||||||
if (canViewOrg.value) {
|
{ key: 'groups', title: '小组总数', value: stats.value.groups },
|
||||||
cards.push(
|
{ key: 'users', title: '用户总数', value: stats.value.users },
|
||||||
{ key: 'hospitals', title: '医院总数', value: stats.value.hospitals },
|
{ key: 'patients', title: '可见患者数', value: stats.value.patients },
|
||||||
{ key: 'departments', title: '科室总数', value: stats.value.departments },
|
]);
|
||||||
{ key: 'groups', title: '小组总数', value: stats.value.groups },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (canViewUsers.value) {
|
|
||||||
cards.push({
|
|
||||||
key: 'users',
|
|
||||||
title: userStore.role === 'DIRECTOR' ? '本科室医生数' : '用户总数',
|
|
||||||
value: stats.value.users,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (canViewPatients.value) {
|
|
||||||
cards.push({
|
|
||||||
key: 'patients',
|
|
||||||
title: '可见患者数',
|
|
||||||
value: stats.value.patients,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return cards;
|
|
||||||
});
|
|
||||||
|
|
||||||
const fetchHospitalsForFilter = async () => {
|
const fetchHospitalsForFilter = async () => {
|
||||||
if (!isSystemAdmin.value) {
|
if (!isSystemAdmin.value) {
|
||||||
@ -131,23 +104,16 @@ const fetchDashboardData = async () => {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
if (canViewOrg.value) {
|
if (canViewOrg.value) {
|
||||||
const [hospitalRes, departmentRes, groupRes] = await Promise.all([
|
const [hospitalRes, departmentRes, groupRes, usersRes] = await Promise.all([
|
||||||
getHospitals({ page: 1, pageSize: 1 }),
|
getHospitals({ page: 1, pageSize: 1 }),
|
||||||
getDepartments({ page: 1, pageSize: 1 }),
|
getDepartments({ page: 1, pageSize: 1 }),
|
||||||
getGroups({ page: 1, pageSize: 1 }),
|
getGroups({ page: 1, pageSize: 1 }),
|
||||||
|
getUsers({ page: 1, pageSize: 1 }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
stats.value.hospitals = hospitalRes.total ?? 0;
|
stats.value.hospitals = hospitalRes.total ?? 0;
|
||||||
stats.value.departments = departmentRes.total ?? 0;
|
stats.value.departments = departmentRes.total ?? 0;
|
||||||
stats.value.groups = groupRes.total ?? 0;
|
stats.value.groups = groupRes.total ?? 0;
|
||||||
}
|
|
||||||
|
|
||||||
if (canViewUsers.value) {
|
|
||||||
const usersRes = await getUsers({
|
|
||||||
page: 1,
|
|
||||||
pageSize: 1,
|
|
||||||
role: userStore.role === 'DIRECTOR' ? 'DOCTOR' : undefined,
|
|
||||||
});
|
|
||||||
stats.value.users = usersRes.total ?? 0;
|
stats.value.users = usersRes.total ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -160,9 +126,7 @@ const fetchDashboardData = async () => {
|
|||||||
stats.value.patients = 0;
|
stats.value.patients = 0;
|
||||||
} else {
|
} else {
|
||||||
const patientRes = await getPatients(params);
|
const patientRes = await getPatients(params);
|
||||||
stats.value.patients = Array.isArray(patientRes)
|
stats.value.patients = Array.isArray(patientRes) ? patientRes.length : 0;
|
||||||
? patientRes.length
|
|
||||||
: 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@ -4,38 +4,19 @@
|
|||||||
<template #header>
|
<template #header>
|
||||||
<h2 class="login-title">调压通管理后台</h2>
|
<h2 class="login-title">调压通管理后台</h2>
|
||||||
</template>
|
</template>
|
||||||
<el-form
|
<el-form :model="loginForm" :rules="rules" ref="loginFormRef" @keyup.enter="handleLogin">
|
||||||
:model="loginForm"
|
|
||||||
:rules="rules"
|
|
||||||
ref="loginFormRef"
|
|
||||||
@keyup.enter="handleLogin"
|
|
||||||
>
|
|
||||||
<el-form-item prop="phone">
|
<el-form-item prop="phone">
|
||||||
<el-input
|
<el-input v-model="loginForm.phone" placeholder="请输入手机号" :prefix-icon="User" />
|
||||||
v-model="loginForm.phone"
|
|
||||||
placeholder="请输入手机号"
|
|
||||||
:prefix-icon="User"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item prop="password">
|
<el-form-item prop="password">
|
||||||
<el-input
|
<el-input v-model="loginForm.password" type="password" placeholder="请输入密码" show-password :prefix-icon="Lock" />
|
||||||
v-model="loginForm.password"
|
|
||||||
type="password"
|
|
||||||
placeholder="请输入密码"
|
|
||||||
show-password
|
|
||||||
:prefix-icon="Lock"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item prop="role">
|
<el-form-item prop="role">
|
||||||
<el-select
|
<el-select v-model="loginForm.role" placeholder="请选择登录角色" style="width: 100%;">
|
||||||
v-model="loginForm.role"
|
|
||||||
placeholder="请选择登录角色"
|
|
||||||
style="width: 100%"
|
|
||||||
>
|
|
||||||
<el-option label="系统管理员" value="SYSTEM_ADMIN" />
|
<el-option label="系统管理员" value="SYSTEM_ADMIN" />
|
||||||
<el-option label="医院管理员" value="HOSPITAL_ADMIN" />
|
<el-option label="医院管理员" value="HOSPITAL_ADMIN" />
|
||||||
<el-option label="科室主任" value="DIRECTOR" />
|
<el-option label="科室主任" value="DIRECTOR" />
|
||||||
<el-option label="小组组长" value="LEADER" />
|
<el-option label="医疗组长" value="LEADER" />
|
||||||
<el-option label="医生" value="DOCTOR" />
|
<el-option label="医生" value="DOCTOR" />
|
||||||
<el-option label="工程师" value="ENGINEER" />
|
<el-option label="工程师" value="ENGINEER" />
|
||||||
</el-select>
|
</el-select>
|
||||||
@ -46,23 +27,17 @@
|
|||||||
:min="1"
|
:min="1"
|
||||||
:controls="false"
|
:controls="false"
|
||||||
placeholder="医院 ID(多账号场景建议填写)"
|
placeholder="医院 ID(多账号场景建议填写)"
|
||||||
style="width: 100%"
|
style="width: 100%;"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-alert
|
<el-alert
|
||||||
type="info"
|
type="info"
|
||||||
:closable="false"
|
:closable="false"
|
||||||
title="若同一手机号在多个医院有同角色账号,请填写医院 ID。"
|
title="若同一手机号在多个医院有同角色账号,请填写医院 ID。"
|
||||||
style="margin-bottom: 16px"
|
style="margin-bottom: 16px;"
|
||||||
/>
|
/>
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
<el-button
|
<el-button type="primary" class="login-btn" :loading="loading" @click="handleLogin">登录</el-button>
|
||||||
type="primary"
|
|
||||||
class="login-btn"
|
|
||||||
:loading="loading"
|
|
||||||
@click="handleLogin"
|
|
||||||
>登录</el-button
|
|
||||||
>
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
</el-card>
|
</el-card>
|
||||||
@ -93,13 +68,13 @@ const loginForm = reactive({
|
|||||||
const rules = {
|
const rules = {
|
||||||
phone: [
|
phone: [
|
||||||
{ required: true, message: '请输入手机号', trigger: 'blur' },
|
{ required: true, message: '请输入手机号', trigger: 'blur' },
|
||||||
{ pattern: /^1\d{10}$/, message: '请输入正确的手机号', trigger: 'blur' },
|
{ pattern: /^1\d{10}$/, message: '请输入正确的手机号', trigger: 'blur' }
|
||||||
],
|
],
|
||||||
password: [
|
password: [
|
||||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||||
{ min: 8, message: '密码长度至少为 8 位', trigger: 'blur' },
|
{ min: 8, message: '密码长度至少为 8 位', trigger: 'blur' }
|
||||||
],
|
],
|
||||||
role: [{ required: true, message: '请选择角色', trigger: 'change' }],
|
role: [{ required: true, message: '请选择角色', trigger: 'change' }]
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLogin = async () => {
|
const handleLogin = async () => {
|
||||||
|
|||||||
@ -1,531 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="devices-container">
|
|
||||||
<el-card>
|
|
||||||
<div class="header-actions">
|
|
||||||
<el-form :inline="true" :model="searchForm" class="search-form">
|
|
||||||
<el-form-item label="所属医院" v-if="isSystemAdmin">
|
|
||||||
<el-select
|
|
||||||
v-model="searchForm.hospitalId"
|
|
||||||
clearable
|
|
||||||
filterable
|
|
||||||
placeholder="全部医院"
|
|
||||||
style="width: 220px"
|
|
||||||
@change="handleSearchHospitalChange"
|
|
||||||
>
|
|
||||||
<el-option
|
|
||||||
v-for="hospital in hospitals"
|
|
||||||
:key="hospital.id"
|
|
||||||
:label="hospital.name"
|
|
||||||
:value="hospital.id"
|
|
||||||
/>
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
|
|
||||||
<el-form-item label="归属患者">
|
|
||||||
<el-select
|
|
||||||
v-model="searchForm.patientId"
|
|
||||||
clearable
|
|
||||||
filterable
|
|
||||||
placeholder="全部患者"
|
|
||||||
style="width: 260px"
|
|
||||||
:disabled="isSystemAdmin && !searchForm.hospitalId"
|
|
||||||
>
|
|
||||||
<el-option
|
|
||||||
v-for="patient in searchPatients"
|
|
||||||
:key="patient.id"
|
|
||||||
:label="formatPatientLabel(patient)"
|
|
||||||
:value="patient.id"
|
|
||||||
/>
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
|
|
||||||
<el-form-item label="设备状态">
|
|
||||||
<el-select
|
|
||||||
v-model="searchForm.status"
|
|
||||||
clearable
|
|
||||||
placeholder="全部状态"
|
|
||||||
style="width: 160px"
|
|
||||||
>
|
|
||||||
<el-option
|
|
||||||
v-for="item in DEVICE_STATUS_OPTIONS"
|
|
||||||
:key="item.value"
|
|
||||||
:label="item.label"
|
|
||||||
:value="item.value"
|
|
||||||
/>
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
|
|
||||||
<el-form-item label="关键词">
|
|
||||||
<el-input
|
|
||||||
v-model="searchForm.keyword"
|
|
||||||
clearable
|
|
||||||
placeholder="设备 SN / 患者姓名 / 手机号"
|
|
||||||
style="width: 260px"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
|
||||||
|
|
||||||
<el-form-item>
|
|
||||||
<el-button type="primary" @click="handleSearch" icon="Search">
|
|
||||||
查询
|
|
||||||
</el-button>
|
|
||||||
<el-button @click="resetSearch" icon="Refresh">重置</el-button>
|
|
||||||
<el-button type="success" @click="openCreateDialog" icon="Plus">
|
|
||||||
新增设备
|
|
||||||
</el-button>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<el-table
|
|
||||||
:data="tableData"
|
|
||||||
v-loading="loading"
|
|
||||||
border
|
|
||||||
stripe
|
|
||||||
style="width: 100%"
|
|
||||||
>
|
|
||||||
<el-table-column prop="id" label="ID" width="80" align="center" />
|
|
||||||
<el-table-column prop="snCode" label="设备 SN" min-width="180" />
|
|
||||||
<el-table-column
|
|
||||||
prop="currentPressure"
|
|
||||||
label="当前压力"
|
|
||||||
width="120"
|
|
||||||
align="center"
|
|
||||||
/>
|
|
||||||
<el-table-column label="设备状态" width="120" align="center">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-tag :type="getStatusTagType(row.status)">
|
|
||||||
{{ getStatusName(row.status) }}
|
|
||||||
</el-tag>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="归属患者" min-width="140">
|
|
||||||
<template #default="{ row }">
|
|
||||||
{{ row.patient?.name || '-' }}
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="患者手机号" min-width="150">
|
|
||||||
<template #default="{ row }">
|
|
||||||
{{ row.patient?.phone || '-' }}
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="所属医院" min-width="160">
|
|
||||||
<template #default="{ row }">
|
|
||||||
{{ row.patient?.hospital?.name || '-' }}
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="归属医生" min-width="140">
|
|
||||||
<template #default="{ row }">
|
|
||||||
{{ row.patient?.doctor?.name || '-' }}
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="关联任务数" width="120" align="center">
|
|
||||||
<template #default="{ row }">
|
|
||||||
{{ row._count?.taskItems ?? 0 }}
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="操作" width="180" fixed="right" align="center">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-button size="small" type="primary" @click="openEditDialog(row)">
|
|
||||||
编辑
|
|
||||||
</el-button>
|
|
||||||
<el-button size="small" type="danger" @click="handleDelete(row)">
|
|
||||||
删除
|
|
||||||
</el-button>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
|
||||||
|
|
||||||
<div class="pagination-container">
|
|
||||||
<el-pagination
|
|
||||||
v-model:current-page="page"
|
|
||||||
v-model:page-size="pageSize"
|
|
||||||
:page-sizes="[10, 20, 50, 100]"
|
|
||||||
:total="total"
|
|
||||||
background
|
|
||||||
layout="total, sizes, prev, pager, next, jumper"
|
|
||||||
@size-change="fetchData"
|
|
||||||
@current-change="fetchData"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</el-card>
|
|
||||||
|
|
||||||
<el-dialog
|
|
||||||
:title="isEdit ? '编辑设备' : '新增设备'"
|
|
||||||
v-model="dialogVisible"
|
|
||||||
width="560px"
|
|
||||||
@close="resetForm"
|
|
||||||
>
|
|
||||||
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
|
|
||||||
<el-form-item label="所属医院" prop="hospitalId" v-if="isSystemAdmin">
|
|
||||||
<el-select
|
|
||||||
v-model="form.hospitalId"
|
|
||||||
filterable
|
|
||||||
placeholder="请选择医院"
|
|
||||||
style="width: 100%"
|
|
||||||
@change="handleFormHospitalChange"
|
|
||||||
>
|
|
||||||
<el-option
|
|
||||||
v-for="hospital in hospitals"
|
|
||||||
:key="hospital.id"
|
|
||||||
:label="hospital.name"
|
|
||||||
:value="hospital.id"
|
|
||||||
/>
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
|
|
||||||
<el-form-item label="归属患者" prop="patientId">
|
|
||||||
<el-select
|
|
||||||
v-model="form.patientId"
|
|
||||||
filterable
|
|
||||||
placeholder="请选择患者"
|
|
||||||
style="width: 100%"
|
|
||||||
:disabled="isSystemAdmin && !form.hospitalId"
|
|
||||||
>
|
|
||||||
<el-option
|
|
||||||
v-for="patient in formPatients"
|
|
||||||
:key="patient.id"
|
|
||||||
:label="formatPatientLabel(patient)"
|
|
||||||
:value="patient.id"
|
|
||||||
/>
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
|
|
||||||
<el-form-item label="设备 SN" prop="snCode">
|
|
||||||
<el-input
|
|
||||||
v-model="form.snCode"
|
|
||||||
placeholder="请输入设备 SN"
|
|
||||||
maxlength="64"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
|
||||||
|
|
||||||
<el-form-item label="当前压力" prop="currentPressure">
|
|
||||||
<el-input-number
|
|
||||||
v-model="form.currentPressure"
|
|
||||||
:min="0"
|
|
||||||
:step="1"
|
|
||||||
:controls="false"
|
|
||||||
style="width: 100%"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
|
||||||
|
|
||||||
<el-form-item label="设备状态" prop="status">
|
|
||||||
<el-select
|
|
||||||
v-model="form.status"
|
|
||||||
placeholder="请选择状态"
|
|
||||||
style="width: 100%"
|
|
||||||
>
|
|
||||||
<el-option
|
|
||||||
v-for="item in DEVICE_STATUS_OPTIONS"
|
|
||||||
:key="item.value"
|
|
||||||
:label="item.label"
|
|
||||||
:value="item.value"
|
|
||||||
/>
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
|
|
||||||
<template #footer>
|
|
||||||
<div class="dialog-footer">
|
|
||||||
<el-button @click="dialogVisible = false">取消</el-button>
|
|
||||||
<el-button
|
|
||||||
type="primary"
|
|
||||||
:loading="submitLoading"
|
|
||||||
@click="handleSubmit"
|
|
||||||
>
|
|
||||||
确定
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</el-dialog>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { computed, onMounted, reactive, ref } from 'vue';
|
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
|
||||||
import {
|
|
||||||
getDevices,
|
|
||||||
createDevice,
|
|
||||||
updateDevice,
|
|
||||||
deleteDevice,
|
|
||||||
} from '../../api/devices';
|
|
||||||
import { getHospitals } from '../../api/organization';
|
|
||||||
import { getPatients } from '../../api/patients';
|
|
||||||
import { useUserStore } from '../../store/user';
|
|
||||||
|
|
||||||
const userStore = useUserStore();
|
|
||||||
|
|
||||||
const DEVICE_STATUS_OPTIONS = [
|
|
||||||
{ label: '启用', value: 'ACTIVE' },
|
|
||||||
{ label: '停用', value: 'INACTIVE' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const isSystemAdmin = computed(() => userStore.role === 'SYSTEM_ADMIN');
|
|
||||||
|
|
||||||
const loading = ref(false);
|
|
||||||
const submitLoading = ref(false);
|
|
||||||
const dialogVisible = ref(false);
|
|
||||||
const isEdit = ref(false);
|
|
||||||
const formRef = ref(null);
|
|
||||||
const currentId = ref(null);
|
|
||||||
|
|
||||||
const hospitals = ref([]);
|
|
||||||
const searchPatients = ref([]);
|
|
||||||
const formPatients = ref([]);
|
|
||||||
|
|
||||||
const tableData = ref([]);
|
|
||||||
const total = ref(0);
|
|
||||||
const page = ref(1);
|
|
||||||
const pageSize = ref(10);
|
|
||||||
|
|
||||||
const searchForm = reactive({
|
|
||||||
hospitalId: null,
|
|
||||||
patientId: null,
|
|
||||||
status: '',
|
|
||||||
keyword: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
const form = reactive({
|
|
||||||
hospitalId: null,
|
|
||||||
patientId: null,
|
|
||||||
snCode: '',
|
|
||||||
currentPressure: 0,
|
|
||||||
status: 'ACTIVE',
|
|
||||||
});
|
|
||||||
|
|
||||||
const rules = computed(() => ({
|
|
||||||
hospitalId: isSystemAdmin.value
|
|
||||||
? [{ required: true, message: '请选择所属医院', trigger: 'change' }]
|
|
||||||
: [],
|
|
||||||
patientId: [{ required: true, message: '请选择归属患者', trigger: 'change' }],
|
|
||||||
snCode: [{ required: true, message: '请输入设备 SN', trigger: 'blur' }],
|
|
||||||
currentPressure: [
|
|
||||||
{ required: true, message: '请输入当前压力', trigger: 'blur' },
|
|
||||||
],
|
|
||||||
status: [{ required: true, message: '请选择设备状态', trigger: 'change' }],
|
|
||||||
}));
|
|
||||||
|
|
||||||
const getStatusName = (status) => {
|
|
||||||
return (
|
|
||||||
DEVICE_STATUS_OPTIONS.find((item) => item.value === status)?.label || status
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusTagType = (status) => {
|
|
||||||
return status === 'ACTIVE' ? 'success' : 'info';
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatPatientLabel = (patient) => {
|
|
||||||
const hospitalName = patient.hospital?.name
|
|
||||||
? ` / ${patient.hospital.name}`
|
|
||||||
: '';
|
|
||||||
return `${patient.name}(${patient.phone}${hospitalName})`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchHospitals = async () => {
|
|
||||||
if (!isSystemAdmin.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await getHospitals({ page: 1, pageSize: 100 });
|
|
||||||
hospitals.value = res.list || [];
|
|
||||||
};
|
|
||||||
|
|
||||||
// 搜索区患者下拉只跟筛选条件联动,避免和弹窗下拉状态互相干扰。
|
|
||||||
const fetchSearchPatients = async () => {
|
|
||||||
if (isSystemAdmin.value && !searchForm.hospitalId) {
|
|
||||||
searchPatients.value = [];
|
|
||||||
searchForm.patientId = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const params = {};
|
|
||||||
if (isSystemAdmin.value) {
|
|
||||||
params.hospitalId = searchForm.hospitalId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await getPatients(params);
|
|
||||||
searchPatients.value = Array.isArray(res) ? res : [];
|
|
||||||
|
|
||||||
if (!searchPatients.value.some((item) => item.id === searchForm.patientId)) {
|
|
||||||
searchForm.patientId = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 表单患者下拉按当前选择的医院动态刷新,确保 patientId 可写且合法。
|
|
||||||
const fetchFormPatients = async (hospitalId = form.hospitalId) => {
|
|
||||||
if (isSystemAdmin.value && !hospitalId) {
|
|
||||||
formPatients.value = [];
|
|
||||||
form.patientId = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const params = {};
|
|
||||||
if (isSystemAdmin.value) {
|
|
||||||
params.hospitalId = hospitalId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await getPatients(params);
|
|
||||||
formPatients.value = Array.isArray(res) ? res : [];
|
|
||||||
|
|
||||||
if (!formPatients.value.some((item) => item.id === form.patientId)) {
|
|
||||||
form.patientId = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchData = async () => {
|
|
||||||
loading.value = true;
|
|
||||||
try {
|
|
||||||
const params = {
|
|
||||||
page: page.value,
|
|
||||||
pageSize: pageSize.value,
|
|
||||||
keyword: searchForm.keyword || undefined,
|
|
||||||
status: searchForm.status || undefined,
|
|
||||||
patientId: searchForm.patientId || undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isSystemAdmin.value && searchForm.hospitalId) {
|
|
||||||
params.hospitalId = searchForm.hospitalId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await getDevices(params);
|
|
||||||
tableData.value = res.list || [];
|
|
||||||
total.value = res.total || 0;
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSearchHospitalChange = async () => {
|
|
||||||
page.value = 1;
|
|
||||||
await fetchSearchPatients();
|
|
||||||
await fetchData();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFormHospitalChange = async (hospitalId) => {
|
|
||||||
form.patientId = null;
|
|
||||||
await fetchFormPatients(hospitalId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSearch = () => {
|
|
||||||
page.value = 1;
|
|
||||||
fetchData();
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetSearch = async () => {
|
|
||||||
searchForm.hospitalId = null;
|
|
||||||
searchForm.patientId = null;
|
|
||||||
searchForm.status = '';
|
|
||||||
searchForm.keyword = '';
|
|
||||||
page.value = 1;
|
|
||||||
await fetchSearchPatients();
|
|
||||||
await fetchData();
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetForm = () => {
|
|
||||||
formRef.value?.resetFields();
|
|
||||||
form.hospitalId = null;
|
|
||||||
form.patientId = null;
|
|
||||||
form.snCode = '';
|
|
||||||
form.currentPressure = 0;
|
|
||||||
form.status = 'ACTIVE';
|
|
||||||
currentId.value = null;
|
|
||||||
formPatients.value = [];
|
|
||||||
};
|
|
||||||
|
|
||||||
const openCreateDialog = async () => {
|
|
||||||
isEdit.value = false;
|
|
||||||
resetForm();
|
|
||||||
|
|
||||||
// 系统管理员可沿用当前筛选医院,院管则固定为本人医院。
|
|
||||||
if (isSystemAdmin.value) {
|
|
||||||
form.hospitalId = searchForm.hospitalId || null;
|
|
||||||
} else {
|
|
||||||
form.hospitalId = userStore.userInfo?.hospitalId || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
await fetchFormPatients(form.hospitalId);
|
|
||||||
dialogVisible.value = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const openEditDialog = async (row) => {
|
|
||||||
isEdit.value = true;
|
|
||||||
currentId.value = row.id;
|
|
||||||
form.snCode = row.snCode;
|
|
||||||
form.currentPressure = row.currentPressure;
|
|
||||||
form.status = row.status;
|
|
||||||
form.hospitalId =
|
|
||||||
row.patient?.hospital?.id || row.patient?.hospitalId || null;
|
|
||||||
|
|
||||||
await fetchFormPatients(form.hospitalId);
|
|
||||||
form.patientId = row.patient?.id || null;
|
|
||||||
dialogVisible.value = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
if (!formRef.value) return;
|
|
||||||
|
|
||||||
await formRef.value.validate(async (valid) => {
|
|
||||||
if (!valid) return;
|
|
||||||
|
|
||||||
submitLoading.value = true;
|
|
||||||
try {
|
|
||||||
const payload = {
|
|
||||||
snCode: form.snCode,
|
|
||||||
currentPressure: Number(form.currentPressure),
|
|
||||||
status: form.status,
|
|
||||||
patientId: form.patientId,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isEdit.value) {
|
|
||||||
await updateDevice(currentId.value, payload);
|
|
||||||
ElMessage.success('更新成功');
|
|
||||||
} else {
|
|
||||||
await createDevice(payload);
|
|
||||||
ElMessage.success('创建成功');
|
|
||||||
}
|
|
||||||
|
|
||||||
dialogVisible.value = false;
|
|
||||||
await fetchData();
|
|
||||||
} finally {
|
|
||||||
submitLoading.value = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = (row) => {
|
|
||||||
ElMessageBox.confirm(`确定要删除设备 "${row.snCode}" 吗?`, '警告', {
|
|
||||||
confirmButtonText: '确定',
|
|
||||||
cancelButtonText: '取消',
|
|
||||||
type: 'warning',
|
|
||||||
})
|
|
||||||
.then(async () => {
|
|
||||||
await deleteDevice(row.id);
|
|
||||||
ElMessage.success('删除成功');
|
|
||||||
await fetchData();
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await fetchHospitals();
|
|
||||||
await fetchSearchPatients();
|
|
||||||
await fetchData();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.devices-container {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-actions {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination-container {
|
|
||||||
margin-top: 20px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -3,35 +3,16 @@
|
|||||||
<el-card>
|
<el-card>
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<span
|
<span>科室管理 {{ currentHospitalName ? `(${currentHospitalName})` : '' }}</span>
|
||||||
>科室管理
|
<el-button v-if="currentHospitalName" @click="clearHospitalFilter" type="info" size="small">清除医院筛选</el-button>
|
||||||
{{ currentHospitalName ? `(${currentHospitalName})` : '' }}</span
|
|
||||||
>
|
|
||||||
<el-button
|
|
||||||
v-if="currentHospitalName"
|
|
||||||
@click="clearHospitalFilter"
|
|
||||||
type="info"
|
|
||||||
size="small"
|
|
||||||
>清除医院筛选</el-button
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Header / Actions -->
|
<!-- Header / Actions -->
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<el-form :inline="true" :model="searchForm" class="search-form">
|
<el-form :inline="true" :model="searchForm" class="search-form">
|
||||||
<el-form-item
|
<el-form-item label="所属医院" v-if="userStore.role === 'SYSTEM_ADMIN' && !currentHospitalIdFromQuery">
|
||||||
label="所属医院"
|
<el-select v-model="searchForm.hospitalId" placeholder="请选择医院" clearable @change="fetchData">
|
||||||
v-if="
|
|
||||||
userStore.role === 'SYSTEM_ADMIN' && !currentHospitalIdFromQuery
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<el-select
|
|
||||||
v-model="searchForm.hospitalId"
|
|
||||||
placeholder="请选择医院"
|
|
||||||
clearable
|
|
||||||
@change="fetchData"
|
|
||||||
>
|
|
||||||
<el-option
|
<el-option
|
||||||
v-for="h in hospitals"
|
v-for="h in hospitals"
|
||||||
:key="h.id"
|
:key="h.id"
|
||||||
@ -41,44 +22,21 @@
|
|||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="科室名称">
|
<el-form-item label="科室名称">
|
||||||
<el-input
|
<el-input v-model="searchForm.keyword" placeholder="请输入关键词" clearable />
|
||||||
v-model="searchForm.keyword"
|
|
||||||
placeholder="请输入关键词"
|
|
||||||
clearable
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
<el-button type="primary" @click="fetchData" icon="Search"
|
<el-button type="primary" @click="fetchData" icon="Search">查询</el-button>
|
||||||
>查询</el-button
|
|
||||||
>
|
|
||||||
<el-button @click="resetSearch" icon="Refresh">重置</el-button>
|
<el-button @click="resetSearch" icon="Refresh">重置</el-button>
|
||||||
<el-button
|
<el-button v-if="canCreateDepartment" type="success" @click="openCreateDialog" icon="Plus">新增科室</el-button>
|
||||||
v-if="canCreateDepartment"
|
|
||||||
type="success"
|
|
||||||
@click="openCreateDialog"
|
|
||||||
icon="Plus"
|
|
||||||
>新增科室</el-button
|
|
||||||
>
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Table -->
|
<!-- Table -->
|
||||||
<el-table
|
<el-table :data="tableData" v-loading="loading" border stripe style="width: 100%">
|
||||||
:data="tableData"
|
|
||||||
v-loading="loading"
|
|
||||||
border
|
|
||||||
stripe
|
|
||||||
style="width: 100%"
|
|
||||||
>
|
|
||||||
<el-table-column prop="id" label="ID" width="80" align="center" />
|
<el-table-column prop="id" label="ID" width="80" align="center" />
|
||||||
<el-table-column prop="name" label="科室名称" min-width="150" />
|
<el-table-column prop="name" label="科室名称" min-width="150" />
|
||||||
<el-table-column
|
<el-table-column prop="hospital.name" label="所属医院" min-width="200" v-if="userStore.role === 'SYSTEM_ADMIN'" />
|
||||||
prop="hospital.name"
|
|
||||||
label="所属医院"
|
|
||||||
min-width="200"
|
|
||||||
v-if="userStore.role === 'SYSTEM_ADMIN'"
|
|
||||||
/>
|
|
||||||
<el-table-column label="科室主任" min-width="180">
|
<el-table-column label="科室主任" min-width="180">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
{{ getDirectorDisplay(row.id) }}
|
{{ getDirectorDisplay(row.id) }}
|
||||||
@ -91,23 +49,9 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="220" fixed="right" align="center">
|
<el-table-column label="操作" width="220" fixed="right" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button size="small" @click="goToGroups(row)"
|
<el-button size="small" @click="goToGroups(row)">管理小组</el-button>
|
||||||
>管理小组</el-button
|
<el-button v-if="canEditDepartment" size="small" type="primary" @click="openEditDialog(row)">编辑</el-button>
|
||||||
>
|
<el-button v-if="canDeleteDepartment" size="small" type="danger" @click="handleDelete(row)">删除</el-button>
|
||||||
<el-button
|
|
||||||
v-if="canEditDepartment"
|
|
||||||
size="small"
|
|
||||||
type="primary"
|
|
||||||
@click="openEditDialog(row)"
|
|
||||||
>编辑</el-button
|
|
||||||
>
|
|
||||||
<el-button
|
|
||||||
v-if="canDeleteDepartment"
|
|
||||||
size="small"
|
|
||||||
type="danger"
|
|
||||||
@click="handleDelete(row)"
|
|
||||||
>删除</el-button
|
|
||||||
>
|
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@ -128,24 +72,10 @@
|
|||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<!-- Dialog for Create / Edit -->
|
<!-- Dialog for Create / Edit -->
|
||||||
<el-dialog
|
<el-dialog :title="isEdit ? '编辑科室' : '新增科室'" v-model="dialogVisible" width="500px" @close="resetForm">
|
||||||
:title="isEdit ? '编辑科室' : '新增科室'"
|
|
||||||
v-model="dialogVisible"
|
|
||||||
width="500px"
|
|
||||||
@close="resetForm"
|
|
||||||
>
|
|
||||||
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
|
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
|
||||||
<el-form-item
|
<el-form-item label="所属医院" prop="hospitalId" v-if="userStore.role === 'SYSTEM_ADMIN'">
|
||||||
label="所属医院"
|
<el-select v-model="form.hospitalId" placeholder="请选择所属医院" style="width: 100%;">
|
||||||
prop="hospitalId"
|
|
||||||
v-if="userStore.role === 'SYSTEM_ADMIN'"
|
|
||||||
>
|
|
||||||
<el-select
|
|
||||||
v-model="form.hospitalId"
|
|
||||||
placeholder="请选择所属医院"
|
|
||||||
style="width: 100%"
|
|
||||||
@change="handleFormHospitalChange"
|
|
||||||
>
|
|
||||||
<el-option
|
<el-option
|
||||||
v-for="h in hospitals"
|
v-for="h in hospitals"
|
||||||
:key="h.id"
|
:key="h.id"
|
||||||
@ -157,32 +87,11 @@
|
|||||||
<el-form-item label="科室名称" prop="name">
|
<el-form-item label="科室名称" prop="name">
|
||||||
<el-input v-model="form.name" placeholder="请输入科室名称" />
|
<el-input v-model="form.name" placeholder="请输入科室名称" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="科室主任" v-if="canAssignDirectorInDialog">
|
|
||||||
<el-select
|
|
||||||
v-model="form.directorUserId"
|
|
||||||
placeholder="可选:选择后将任命为科室主任"
|
|
||||||
clearable
|
|
||||||
filterable
|
|
||||||
style="width: 100%"
|
|
||||||
>
|
|
||||||
<el-option
|
|
||||||
v-for="user in directorOptions"
|
|
||||||
:key="user.id"
|
|
||||||
:label="`${user.name}(${user.phone} / ${getRoleName(user.role)})`"
|
|
||||||
:value="user.id"
|
|
||||||
/>
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="dialog-footer">
|
<div class="dialog-footer">
|
||||||
<el-button @click="dialogVisible = false">取消</el-button>
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
<el-button
|
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">确定</el-button>
|
||||||
type="primary"
|
|
||||||
@click="handleSubmit"
|
|
||||||
:loading="submitLoading"
|
|
||||||
>确定</el-button
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
@ -193,14 +102,8 @@
|
|||||||
import { ref, reactive, onMounted, computed } from 'vue';
|
import { ref, reactive, onMounted, computed } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
import {
|
import { getDepartments, createDepartment, updateDepartment, deleteDepartment, getHospitals } from '../../api/organization';
|
||||||
getDepartments,
|
import { getUsers } from '../../api/users';
|
||||||
createDepartment,
|
|
||||||
updateDepartment,
|
|
||||||
deleteDepartment,
|
|
||||||
getHospitals,
|
|
||||||
} from '../../api/organization';
|
|
||||||
import { getUsers, updateUser } from '../../api/users';
|
|
||||||
import { useUserStore } from '../../store/user';
|
import { useUserStore } from '../../store/user';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@ -215,15 +118,6 @@ const page = ref(1);
|
|||||||
const pageSize = ref(10);
|
const pageSize = ref(10);
|
||||||
const hospitals = ref([]);
|
const hospitals = ref([]);
|
||||||
const directorNameMap = ref({});
|
const directorNameMap = ref({});
|
||||||
const directorOptions = ref([]);
|
|
||||||
|
|
||||||
const roleMap = {
|
|
||||||
DIRECTOR: '科室主任',
|
|
||||||
LEADER: '小组组长',
|
|
||||||
DOCTOR: '医生',
|
|
||||||
};
|
|
||||||
|
|
||||||
const getRoleName = (role) => roleMap[role] || role;
|
|
||||||
|
|
||||||
const currentHospitalIdFromQuery = computed(() => {
|
const currentHospitalIdFromQuery = computed(() => {
|
||||||
return route.query.hospitalId ? parseInt(route.query.hospitalId) : null;
|
return route.query.hospitalId ? parseInt(route.query.hospitalId) : null;
|
||||||
@ -235,7 +129,7 @@ const currentHospitalName = computed(() => {
|
|||||||
|
|
||||||
const searchForm = reactive({
|
const searchForm = reactive({
|
||||||
keyword: '',
|
keyword: '',
|
||||||
hospitalId: null,
|
hospitalId: null
|
||||||
});
|
});
|
||||||
|
|
||||||
// Dialog State
|
// Dialog State
|
||||||
@ -247,31 +141,22 @@ const currentId = ref(null);
|
|||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
hospitalId: null,
|
hospitalId: null,
|
||||||
name: '',
|
name: ''
|
||||||
directorUserId: null,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const rules = computed(() => ({
|
const rules = computed(() => ({
|
||||||
hospitalId:
|
hospitalId: userStore.role === 'SYSTEM_ADMIN' ? [{ required: true, message: '请选择所属医院', trigger: 'change' }] : [],
|
||||||
userStore.role === 'SYSTEM_ADMIN'
|
name: [{ required: true, message: '请输入科室名称', trigger: 'blur' }]
|
||||||
? [{ required: true, message: '请选择所属医院', trigger: 'change' }]
|
|
||||||
: [],
|
|
||||||
name: [{ required: true, message: '请输入科室名称', trigger: 'blur' }],
|
|
||||||
}));
|
}));
|
||||||
const canCreateDepartment = computed(() =>
|
const canCreateDepartment = computed(() =>
|
||||||
['SYSTEM_ADMIN', 'HOSPITAL_ADMIN'].includes(userStore.role),
|
['SYSTEM_ADMIN', 'HOSPITAL_ADMIN'].includes(userStore.role),
|
||||||
);
|
);
|
||||||
const canEditDepartment = computed(() =>
|
const canEditDepartment = computed(() =>
|
||||||
['SYSTEM_ADMIN', 'HOSPITAL_ADMIN', 'DIRECTOR', 'LEADER'].includes(
|
['SYSTEM_ADMIN', 'HOSPITAL_ADMIN', 'DIRECTOR', 'LEADER'].includes(userStore.role),
|
||||||
userStore.role,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
const canDeleteDepartment = computed(() =>
|
const canDeleteDepartment = computed(() =>
|
||||||
['SYSTEM_ADMIN', 'HOSPITAL_ADMIN'].includes(userStore.role),
|
['SYSTEM_ADMIN', 'HOSPITAL_ADMIN'].includes(userStore.role),
|
||||||
);
|
);
|
||||||
const canAssignDirectorInDialog = computed(() =>
|
|
||||||
['SYSTEM_ADMIN', 'HOSPITAL_ADMIN'].includes(userStore.role),
|
|
||||||
);
|
|
||||||
|
|
||||||
// --- Methods ---
|
// --- Methods ---
|
||||||
const fetchHospitals = async () => {
|
const fetchHospitals = async () => {
|
||||||
@ -286,8 +171,7 @@ const fetchHospitals = async () => {
|
|||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const activeHospitalId =
|
const activeHospitalId = currentHospitalIdFromQuery.value || searchForm.hospitalId;
|
||||||
currentHospitalIdFromQuery.value || searchForm.hospitalId;
|
|
||||||
const [departmentRes, directorRes] = await Promise.all([
|
const [departmentRes, directorRes] = await Promise.all([
|
||||||
getDepartments({
|
getDepartments({
|
||||||
page: page.value,
|
page: page.value,
|
||||||
@ -343,20 +227,15 @@ const goToGroups = (row) => {
|
|||||||
query: {
|
query: {
|
||||||
departmentId: row.id,
|
departmentId: row.id,
|
||||||
departmentName: row.name,
|
departmentName: row.name,
|
||||||
hospitalId: row.hospitalId,
|
hospitalId: row.hospitalId
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const openCreateDialog = () => {
|
const openCreateDialog = () => {
|
||||||
isEdit.value = false;
|
isEdit.value = false;
|
||||||
currentId.value = null;
|
currentId.value = null;
|
||||||
form.hospitalId =
|
form.hospitalId = userStore.role === 'SYSTEM_ADMIN' ? (currentHospitalIdFromQuery.value || searchForm.hospitalId || null) : userStore.userInfo?.hospitalId;
|
||||||
userStore.role === 'SYSTEM_ADMIN'
|
|
||||||
? currentHospitalIdFromQuery.value || searchForm.hospitalId || null
|
|
||||||
: userStore.userInfo?.hospitalId;
|
|
||||||
form.directorUserId = null;
|
|
||||||
loadDirectorOptions(form.hospitalId);
|
|
||||||
dialogVisible.value = true;
|
dialogVisible.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -365,8 +244,6 @@ const openEditDialog = (row) => {
|
|||||||
currentId.value = row.id;
|
currentId.value = row.id;
|
||||||
form.name = row.name;
|
form.name = row.name;
|
||||||
form.hospitalId = row.hospitalId;
|
form.hospitalId = row.hospitalId;
|
||||||
form.directorUserId = null;
|
|
||||||
loadDirectorOptions(row.hospitalId, row.id);
|
|
||||||
dialogVisible.value = true;
|
dialogVisible.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -376,34 +253,6 @@ const resetForm = () => {
|
|||||||
}
|
}
|
||||||
form.name = '';
|
form.name = '';
|
||||||
form.hospitalId = null;
|
form.hospitalId = null;
|
||||||
form.directorUserId = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFormHospitalChange = (hospitalId) => {
|
|
||||||
form.directorUserId = null;
|
|
||||||
loadDirectorOptions(hospitalId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadDirectorOptions = async (hospitalId, departmentId) => {
|
|
||||||
if (!canAssignDirectorInDialog.value || !hospitalId) {
|
|
||||||
directorOptions.value = [];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const userRes = await getUsers();
|
|
||||||
const users = Array.isArray(userRes?.list) ? userRes.list : [];
|
|
||||||
directorOptions.value = users.filter((user) => {
|
|
||||||
if (user.role !== 'DIRECTOR') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (user.hospitalId !== hospitalId) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (departmentId == null) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return user.departmentId == null || user.departmentId === departmentId;
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
@ -412,41 +261,14 @@ const handleSubmit = async () => {
|
|||||||
if (valid) {
|
if (valid) {
|
||||||
submitLoading.value = true;
|
submitLoading.value = true;
|
||||||
try {
|
try {
|
||||||
let targetDepartmentId = null;
|
|
||||||
let targetHospitalId = form.hospitalId;
|
|
||||||
if (isEdit.value) {
|
if (isEdit.value) {
|
||||||
// Some backend update APIs don't allow changing hospitalId, but we'll send it if needed, or just name
|
// Some backend update APIs don't allow changing hospitalId, but we'll send it if needed, or just name
|
||||||
const updated = await updateDepartment(currentId.value, {
|
await updateDepartment(currentId.value, { name: form.name });
|
||||||
name: form.name,
|
|
||||||
});
|
|
||||||
targetDepartmentId = updated?.id ?? currentId.value;
|
|
||||||
targetHospitalId = updated?.hospitalId ?? form.hospitalId;
|
|
||||||
ElMessage.success('更新成功');
|
ElMessage.success('更新成功');
|
||||||
} else {
|
} else {
|
||||||
const created = await createDepartment({
|
await createDepartment(form);
|
||||||
hospitalId: form.hospitalId,
|
|
||||||
name: form.name,
|
|
||||||
});
|
|
||||||
targetDepartmentId = created?.id;
|
|
||||||
targetHospitalId = created?.hospitalId ?? form.hospitalId;
|
|
||||||
ElMessage.success('创建成功');
|
ElMessage.success('创建成功');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
canAssignDirectorInDialog.value &&
|
|
||||||
form.directorUserId &&
|
|
||||||
targetDepartmentId &&
|
|
||||||
targetHospitalId
|
|
||||||
) {
|
|
||||||
await updateUser(form.directorUserId, {
|
|
||||||
role: 'DIRECTOR',
|
|
||||||
hospitalId: targetHospitalId,
|
|
||||||
departmentId: targetDepartmentId,
|
|
||||||
groupId: null,
|
|
||||||
});
|
|
||||||
ElMessage.success('科室主任已设置');
|
|
||||||
}
|
|
||||||
|
|
||||||
dialogVisible.value = false;
|
dialogVisible.value = false;
|
||||||
fetchData();
|
fetchData();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -459,21 +281,23 @@ const handleSubmit = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = (row) => {
|
const handleDelete = (row) => {
|
||||||
ElMessageBox.confirm(`确定要删除科室 "${row.name}" 吗?`, '警告', {
|
ElMessageBox.confirm(
|
||||||
confirmButtonText: '确定',
|
`确定要删除科室 "${row.name}" 吗?`,
|
||||||
cancelButtonText: '取消',
|
'警告',
|
||||||
type: 'warning',
|
{
|
||||||
})
|
confirmButtonText: '确定',
|
||||||
.then(async () => {
|
cancelButtonText: '取消',
|
||||||
try {
|
type: 'warning',
|
||||||
await deleteDepartment(row.id);
|
}
|
||||||
ElMessage.success('删除成功');
|
).then(async () => {
|
||||||
fetchData();
|
try {
|
||||||
} catch (error) {
|
await deleteDepartment(row.id);
|
||||||
console.error('Delete failed', error);
|
ElMessage.success('删除成功');
|
||||||
}
|
fetchData();
|
||||||
})
|
} catch (error) {
|
||||||
.catch(() => {});
|
console.error('Delete failed', error);
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Lifecycle ---
|
// --- Lifecycle ---
|
||||||
@ -489,7 +313,7 @@ watch(
|
|||||||
() => {
|
() => {
|
||||||
page.value = 1;
|
page.value = 1;
|
||||||
fetchData();
|
fetchData();
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -3,37 +3,16 @@
|
|||||||
<el-card>
|
<el-card>
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<span
|
<span>小组管理 {{ currentDepartmentName ? `(${currentDepartmentName})` : '' }}</span>
|
||||||
>小组管理
|
<el-button v-if="currentDepartmentIdFromQuery" @click="clearDepartmentFilter" type="info" size="small">清除科室筛选</el-button>
|
||||||
{{
|
|
||||||
currentDepartmentName ? `(${currentDepartmentName})` : ''
|
|
||||||
}}</span
|
|
||||||
>
|
|
||||||
<el-button
|
|
||||||
v-if="currentDepartmentIdFromQuery"
|
|
||||||
@click="clearDepartmentFilter"
|
|
||||||
type="info"
|
|
||||||
size="small"
|
|
||||||
>清除科室筛选</el-button
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Header / Actions -->
|
<!-- Header / Actions -->
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<el-form :inline="true" :model="searchForm" class="search-form">
|
<el-form :inline="true" :model="searchForm" class="search-form">
|
||||||
<el-form-item
|
<el-form-item label="所属医院" v-if="userStore.role === 'SYSTEM_ADMIN' && !currentDepartmentIdFromQuery">
|
||||||
label="所属医院"
|
<el-select v-model="searchForm.hospitalId" placeholder="请选择医院" clearable @change="handleSearchHospitalChange">
|
||||||
v-if="
|
|
||||||
userStore.role === 'SYSTEM_ADMIN' && !currentDepartmentIdFromQuery
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<el-select
|
|
||||||
v-model="searchForm.hospitalId"
|
|
||||||
placeholder="请选择医院"
|
|
||||||
clearable
|
|
||||||
@change="handleSearchHospitalChange"
|
|
||||||
>
|
|
||||||
<el-option
|
<el-option
|
||||||
v-for="h in hospitals"
|
v-for="h in hospitals"
|
||||||
:key="h.id"
|
:key="h.id"
|
||||||
@ -43,15 +22,7 @@
|
|||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="所属科室" v-if="!currentDepartmentIdFromQuery">
|
<el-form-item label="所属科室" v-if="!currentDepartmentIdFromQuery">
|
||||||
<el-select
|
<el-select v-model="searchForm.departmentId" placeholder="请选择科室" clearable @change="fetchData" :disabled="userStore.role === 'SYSTEM_ADMIN' && !searchForm.hospitalId">
|
||||||
v-model="searchForm.departmentId"
|
|
||||||
placeholder="请选择科室"
|
|
||||||
clearable
|
|
||||||
@change="fetchData"
|
|
||||||
:disabled="
|
|
||||||
userStore.role === 'SYSTEM_ADMIN' && !searchForm.hospitalId
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<el-option
|
<el-option
|
||||||
v-for="d in searchDepartments"
|
v-for="d in searchDepartments"
|
||||||
:key="d.id"
|
:key="d.id"
|
||||||
@ -61,49 +32,22 @@
|
|||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="小组名称">
|
<el-form-item label="小组名称">
|
||||||
<el-input
|
<el-input v-model="searchForm.keyword" placeholder="请输入关键词" clearable />
|
||||||
v-model="searchForm.keyword"
|
|
||||||
placeholder="请输入关键词"
|
|
||||||
clearable
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
<el-button type="primary" @click="fetchData" icon="Search"
|
<el-button type="primary" @click="fetchData" icon="Search">查询</el-button>
|
||||||
>查询</el-button
|
|
||||||
>
|
|
||||||
<el-button @click="resetSearch" icon="Refresh">重置</el-button>
|
<el-button @click="resetSearch" icon="Refresh">重置</el-button>
|
||||||
<el-button
|
<el-button v-if="canCreateGroup" type="success" @click="openCreateDialog" icon="Plus">新增小组</el-button>
|
||||||
v-if="canCreateGroup"
|
|
||||||
type="success"
|
|
||||||
@click="openCreateDialog"
|
|
||||||
icon="Plus"
|
|
||||||
>新增小组</el-button
|
|
||||||
>
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Table -->
|
<!-- Table -->
|
||||||
<el-table
|
<el-table :data="tableData" v-loading="loading" border stripe style="width: 100%">
|
||||||
:data="tableData"
|
|
||||||
v-loading="loading"
|
|
||||||
border
|
|
||||||
stripe
|
|
||||||
style="width: 100%"
|
|
||||||
>
|
|
||||||
<el-table-column prop="id" label="ID" width="80" align="center" />
|
<el-table-column prop="id" label="ID" width="80" align="center" />
|
||||||
<el-table-column prop="name" label="小组名称" min-width="150" />
|
<el-table-column prop="name" label="小组名称" min-width="150" />
|
||||||
<el-table-column
|
<el-table-column prop="department.name" label="所属科室" min-width="150" />
|
||||||
prop="department.name"
|
<el-table-column prop="department.hospital.name" label="所属医院" min-width="150" v-if="userStore.role === 'SYSTEM_ADMIN'" />
|
||||||
label="所属科室"
|
|
||||||
min-width="150"
|
|
||||||
/>
|
|
||||||
<el-table-column
|
|
||||||
prop="department.hospital.name"
|
|
||||||
label="所属医院"
|
|
||||||
min-width="150"
|
|
||||||
v-if="userStore.role === 'SYSTEM_ADMIN'"
|
|
||||||
/>
|
|
||||||
<el-table-column label="小组组长" min-width="180">
|
<el-table-column label="小组组长" min-width="180">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
{{ getLeaderDisplay(row.id) }}
|
{{ getLeaderDisplay(row.id) }}
|
||||||
@ -116,20 +60,8 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="180" fixed="right" align="center">
|
<el-table-column label="操作" width="180" fixed="right" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button
|
<el-button v-if="canEditGroup" size="small" type="primary" @click="openEditDialog(row)">编辑</el-button>
|
||||||
v-if="canEditGroup"
|
<el-button v-if="canDeleteGroup" size="small" type="danger" @click="handleDelete(row)">删除</el-button>
|
||||||
size="small"
|
|
||||||
type="primary"
|
|
||||||
@click="openEditDialog(row)"
|
|
||||||
>编辑</el-button
|
|
||||||
>
|
|
||||||
<el-button
|
|
||||||
v-if="canDeleteGroup"
|
|
||||||
size="small"
|
|
||||||
type="danger"
|
|
||||||
@click="handleDelete(row)"
|
|
||||||
>删除</el-button
|
|
||||||
>
|
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@ -150,24 +82,10 @@
|
|||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<!-- Dialog for Create / Edit -->
|
<!-- Dialog for Create / Edit -->
|
||||||
<el-dialog
|
<el-dialog :title="isEdit ? '编辑小组' : '新增小组'" v-model="dialogVisible" width="500px" @close="resetForm">
|
||||||
:title="isEdit ? '编辑小组' : '新增小组'"
|
|
||||||
v-model="dialogVisible"
|
|
||||||
width="500px"
|
|
||||||
@close="resetForm"
|
|
||||||
>
|
|
||||||
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
|
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
|
||||||
<el-form-item
|
<el-form-item label="所属医院" prop="hospitalId" v-if="userStore.role === 'SYSTEM_ADMIN'">
|
||||||
label="所属医院"
|
<el-select v-model="form.hospitalId" placeholder="请选择所属医院" style="width: 100%;" @change="handleFormHospitalChange">
|
||||||
prop="hospitalId"
|
|
||||||
v-if="userStore.role === 'SYSTEM_ADMIN'"
|
|
||||||
>
|
|
||||||
<el-select
|
|
||||||
v-model="form.hospitalId"
|
|
||||||
placeholder="请选择所属医院"
|
|
||||||
style="width: 100%"
|
|
||||||
@change="handleFormHospitalChange"
|
|
||||||
>
|
|
||||||
<el-option
|
<el-option
|
||||||
v-for="h in hospitals"
|
v-for="h in hospitals"
|
||||||
:key="h.id"
|
:key="h.id"
|
||||||
@ -177,12 +95,7 @@
|
|||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="所属科室" prop="departmentId">
|
<el-form-item label="所属科室" prop="departmentId">
|
||||||
<el-select
|
<el-select v-model="form.departmentId" placeholder="请选择所属科室" style="width: 100%;" :disabled="userStore.role === 'SYSTEM_ADMIN' && !form.hospitalId">
|
||||||
v-model="form.departmentId"
|
|
||||||
placeholder="请选择所属科室"
|
|
||||||
style="width: 100%"
|
|
||||||
:disabled="userStore.role === 'SYSTEM_ADMIN' && !form.hospitalId"
|
|
||||||
>
|
|
||||||
<el-option
|
<el-option
|
||||||
v-for="d in formDepartments"
|
v-for="d in formDepartments"
|
||||||
:key="d.id"
|
:key="d.id"
|
||||||
@ -194,32 +107,11 @@
|
|||||||
<el-form-item label="小组名称" prop="name">
|
<el-form-item label="小组名称" prop="name">
|
||||||
<el-input v-model="form.name" placeholder="请输入小组名称" />
|
<el-input v-model="form.name" placeholder="请输入小组名称" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="小组组长" v-if="canAssignLeaderInDialog">
|
|
||||||
<el-select
|
|
||||||
v-model="form.leaderUserId"
|
|
||||||
placeholder="可选:选择后将任命为小组组长"
|
|
||||||
clearable
|
|
||||||
filterable
|
|
||||||
style="width: 100%"
|
|
||||||
>
|
|
||||||
<el-option
|
|
||||||
v-for="user in leaderOptions"
|
|
||||||
:key="user.id"
|
|
||||||
:label="`${user.name}(${user.phone} / ${getRoleName(user.role)})`"
|
|
||||||
:value="user.id"
|
|
||||||
/>
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="dialog-footer">
|
<div class="dialog-footer">
|
||||||
<el-button @click="dialogVisible = false">取消</el-button>
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
<el-button
|
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">确定</el-button>
|
||||||
type="primary"
|
|
||||||
@click="handleSubmit"
|
|
||||||
:loading="submitLoading"
|
|
||||||
>确定</el-button
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
@ -230,15 +122,8 @@
|
|||||||
import { ref, reactive, onMounted, computed, watch } from 'vue';
|
import { ref, reactive, onMounted, computed, watch } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
import {
|
import { getGroups, createGroup, updateGroup, deleteGroup, getHospitals, getDepartments } from '../../api/organization';
|
||||||
getGroups,
|
import { getUsers } from '../../api/users';
|
||||||
createGroup,
|
|
||||||
updateGroup,
|
|
||||||
deleteGroup,
|
|
||||||
getHospitals,
|
|
||||||
getDepartments,
|
|
||||||
} from '../../api/organization';
|
|
||||||
import { getUsers, updateUser } from '../../api/users';
|
|
||||||
import { useUserStore } from '../../store/user';
|
import { useUserStore } from '../../store/user';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@ -253,14 +138,6 @@ const page = ref(1);
|
|||||||
const pageSize = ref(10);
|
const pageSize = ref(10);
|
||||||
const hospitals = ref([]);
|
const hospitals = ref([]);
|
||||||
const leaderNameMap = ref({});
|
const leaderNameMap = ref({});
|
||||||
const leaderOptions = ref([]);
|
|
||||||
|
|
||||||
const roleMap = {
|
|
||||||
LEADER: '小组组长',
|
|
||||||
DOCTOR: '医生',
|
|
||||||
};
|
|
||||||
|
|
||||||
const getRoleName = (role) => roleMap[role] || role;
|
|
||||||
|
|
||||||
const searchDepartments = ref([]);
|
const searchDepartments = ref([]);
|
||||||
const formDepartments = ref([]);
|
const formDepartments = ref([]);
|
||||||
@ -280,7 +157,7 @@ const currentDepartmentName = computed(() => {
|
|||||||
const searchForm = reactive({
|
const searchForm = reactive({
|
||||||
keyword: '',
|
keyword: '',
|
||||||
hospitalId: null,
|
hospitalId: null,
|
||||||
departmentId: null,
|
departmentId: null
|
||||||
});
|
});
|
||||||
|
|
||||||
// Dialog State
|
// Dialog State
|
||||||
@ -293,34 +170,23 @@ const currentId = ref(null);
|
|||||||
const form = reactive({
|
const form = reactive({
|
||||||
hospitalId: null,
|
hospitalId: null,
|
||||||
departmentId: null,
|
departmentId: null,
|
||||||
name: '',
|
name: ''
|
||||||
leaderUserId: null,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const rules = computed(() => ({
|
const rules = computed(() => ({
|
||||||
hospitalId:
|
hospitalId: userStore.role === 'SYSTEM_ADMIN' ? [{ required: true, message: '请选择所属医院', trigger: 'change' }] : [],
|
||||||
userStore.role === 'SYSTEM_ADMIN'
|
departmentId: [{ required: true, message: '请选择所属科室', trigger: 'change' }],
|
||||||
? [{ required: true, message: '请选择所属医院', trigger: 'change' }]
|
name: [{ required: true, message: '请输入小组名称', trigger: 'blur' }]
|
||||||
: [],
|
|
||||||
departmentId: [
|
|
||||||
{ required: true, message: '请选择所属科室', trigger: 'change' },
|
|
||||||
],
|
|
||||||
name: [{ required: true, message: '请输入小组名称', trigger: 'blur' }],
|
|
||||||
}));
|
}));
|
||||||
const canCreateGroup = computed(() =>
|
const canCreateGroup = computed(() =>
|
||||||
['SYSTEM_ADMIN', 'HOSPITAL_ADMIN', 'DIRECTOR'].includes(userStore.role),
|
['SYSTEM_ADMIN', 'HOSPITAL_ADMIN', 'DIRECTOR'].includes(userStore.role),
|
||||||
);
|
);
|
||||||
const canEditGroup = computed(() =>
|
const canEditGroup = computed(() =>
|
||||||
['SYSTEM_ADMIN', 'HOSPITAL_ADMIN', 'DIRECTOR', 'LEADER'].includes(
|
['SYSTEM_ADMIN', 'HOSPITAL_ADMIN', 'DIRECTOR', 'LEADER'].includes(userStore.role),
|
||||||
userStore.role,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
const canDeleteGroup = computed(() =>
|
const canDeleteGroup = computed(() =>
|
||||||
['SYSTEM_ADMIN', 'HOSPITAL_ADMIN', 'DIRECTOR'].includes(userStore.role),
|
['SYSTEM_ADMIN', 'HOSPITAL_ADMIN', 'DIRECTOR'].includes(userStore.role),
|
||||||
);
|
);
|
||||||
const canAssignLeaderInDialog = computed(() =>
|
|
||||||
['SYSTEM_ADMIN', 'HOSPITAL_ADMIN'].includes(userStore.role),
|
|
||||||
);
|
|
||||||
|
|
||||||
// --- Methods ---
|
// --- Methods ---
|
||||||
const fetchHospitals = async () => {
|
const fetchHospitals = async () => {
|
||||||
@ -346,7 +212,6 @@ const handleSearchHospitalChange = async (hospitalId) => {
|
|||||||
|
|
||||||
const handleFormHospitalChange = async (hospitalId) => {
|
const handleFormHospitalChange = async (hospitalId) => {
|
||||||
form.departmentId = null;
|
form.departmentId = null;
|
||||||
form.leaderUserId = null;
|
|
||||||
formDepartments.value = [];
|
formDepartments.value = [];
|
||||||
if (hospitalId) {
|
if (hospitalId) {
|
||||||
try {
|
try {
|
||||||
@ -356,35 +221,11 @@ const handleFormHospitalChange = async (hospitalId) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadLeaderOptions = async (hospitalId, departmentId, groupId) => {
|
|
||||||
if (!canAssignLeaderInDialog.value || !hospitalId || !departmentId) {
|
|
||||||
leaderOptions.value = [];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const userRes = await getUsers();
|
|
||||||
const users = Array.isArray(userRes?.list) ? userRes.list : [];
|
|
||||||
leaderOptions.value = users.filter((user) => {
|
|
||||||
if (user.role !== 'LEADER') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (user.hospitalId !== hospitalId || user.departmentId !== departmentId) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (groupId == null) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return user.groupId == null || user.groupId === groupId;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const activeDepartmentId =
|
const activeDepartmentId = currentDepartmentIdFromQuery.value || searchForm.departmentId;
|
||||||
currentDepartmentIdFromQuery.value || searchForm.departmentId;
|
const activeHospitalId = currentHospitalIdFromQuery.value || searchForm.hospitalId;
|
||||||
const activeHospitalId =
|
|
||||||
currentHospitalIdFromQuery.value || searchForm.hospitalId;
|
|
||||||
const [groupRes, leaderRes] = await Promise.all([
|
const [groupRes, leaderRes] = await Promise.all([
|
||||||
getGroups({
|
getGroups({
|
||||||
page: page.value,
|
page: page.value,
|
||||||
@ -440,17 +281,11 @@ const clearDepartmentFilter = () => {
|
|||||||
const openCreateDialog = async () => {
|
const openCreateDialog = async () => {
|
||||||
isEdit.value = false;
|
isEdit.value = false;
|
||||||
currentId.value = null;
|
currentId.value = null;
|
||||||
form.hospitalId =
|
form.hospitalId = userStore.role === 'SYSTEM_ADMIN' ? (currentHospitalIdFromQuery.value || searchForm.hospitalId || null) : userStore.userInfo?.hospitalId;
|
||||||
userStore.role === 'SYSTEM_ADMIN'
|
|
||||||
? currentHospitalIdFromQuery.value || searchForm.hospitalId || null
|
|
||||||
: userStore.userInfo?.hospitalId;
|
|
||||||
if (userStore.role === 'SYSTEM_ADMIN' && form.hospitalId) {
|
if (userStore.role === 'SYSTEM_ADMIN' && form.hospitalId) {
|
||||||
await handleFormHospitalChange(form.hospitalId);
|
await handleFormHospitalChange(form.hospitalId);
|
||||||
}
|
}
|
||||||
form.departmentId =
|
form.departmentId = currentDepartmentIdFromQuery.value || searchForm.departmentId || null;
|
||||||
currentDepartmentIdFromQuery.value || searchForm.departmentId || null;
|
|
||||||
form.leaderUserId = null;
|
|
||||||
await loadLeaderOptions(form.hospitalId, form.departmentId, null);
|
|
||||||
dialogVisible.value = true;
|
dialogVisible.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -468,8 +303,6 @@ const openEditDialog = async (row) => {
|
|||||||
await handleFormHospitalChange(hospitalId);
|
await handleFormHospitalChange(hospitalId);
|
||||||
}
|
}
|
||||||
form.departmentId = row.departmentId;
|
form.departmentId = row.departmentId;
|
||||||
form.leaderUserId = null;
|
|
||||||
await loadLeaderOptions(form.hospitalId, form.departmentId, row.id);
|
|
||||||
|
|
||||||
dialogVisible.value = true;
|
dialogVisible.value = true;
|
||||||
};
|
};
|
||||||
@ -481,9 +314,7 @@ const resetForm = () => {
|
|||||||
form.name = '';
|
form.name = '';
|
||||||
form.hospitalId = null;
|
form.hospitalId = null;
|
||||||
form.departmentId = null;
|
form.departmentId = null;
|
||||||
form.leaderUserId = null;
|
|
||||||
formDepartments.value = [];
|
formDepartments.value = [];
|
||||||
leaderOptions.value = [];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
@ -492,44 +323,14 @@ const handleSubmit = async () => {
|
|||||||
if (valid) {
|
if (valid) {
|
||||||
submitLoading.value = true;
|
submitLoading.value = true;
|
||||||
try {
|
try {
|
||||||
let targetGroupId = null;
|
|
||||||
let targetDepartmentId = form.departmentId;
|
|
||||||
let targetHospitalId = form.hospitalId;
|
|
||||||
if (isEdit.value) {
|
if (isEdit.value) {
|
||||||
// Backend patch dto might just accept name. Sending departmentId may not be allowed or needed.
|
// Backend patch dto might just accept name. Sending departmentId may not be allowed or needed.
|
||||||
const updated = await updateGroup(currentId.value, {
|
await updateGroup(currentId.value, { name: form.name });
|
||||||
name: form.name,
|
|
||||||
});
|
|
||||||
targetGroupId = updated?.id ?? currentId.value;
|
|
||||||
targetDepartmentId = updated?.departmentId ?? form.departmentId;
|
|
||||||
targetHospitalId = updated?.department?.hospitalId ?? form.hospitalId;
|
|
||||||
ElMessage.success('更新成功');
|
ElMessage.success('更新成功');
|
||||||
} else {
|
} else {
|
||||||
const created = await createGroup({
|
await createGroup({ name: form.name, departmentId: form.departmentId });
|
||||||
name: form.name,
|
|
||||||
departmentId: form.departmentId,
|
|
||||||
});
|
|
||||||
targetGroupId = created?.id;
|
|
||||||
targetDepartmentId = created?.departmentId ?? form.departmentId;
|
|
||||||
ElMessage.success('创建成功');
|
ElMessage.success('创建成功');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
canAssignLeaderInDialog.value &&
|
|
||||||
form.leaderUserId &&
|
|
||||||
targetGroupId &&
|
|
||||||
targetDepartmentId &&
|
|
||||||
targetHospitalId
|
|
||||||
) {
|
|
||||||
await updateUser(form.leaderUserId, {
|
|
||||||
role: 'LEADER',
|
|
||||||
hospitalId: targetHospitalId,
|
|
||||||
departmentId: targetDepartmentId,
|
|
||||||
groupId: targetGroupId,
|
|
||||||
});
|
|
||||||
ElMessage.success('小组组长已设置');
|
|
||||||
}
|
|
||||||
|
|
||||||
dialogVisible.value = false;
|
dialogVisible.value = false;
|
||||||
fetchData();
|
fetchData();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -542,21 +343,23 @@ const handleSubmit = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = (row) => {
|
const handleDelete = (row) => {
|
||||||
ElMessageBox.confirm(`确定要删除小组 "${row.name}" 吗?`, '警告', {
|
ElMessageBox.confirm(
|
||||||
confirmButtonText: '确定',
|
`确定要删除小组 "${row.name}" 吗?`,
|
||||||
cancelButtonText: '取消',
|
'警告',
|
||||||
type: 'warning',
|
{
|
||||||
})
|
confirmButtonText: '确定',
|
||||||
.then(async () => {
|
cancelButtonText: '取消',
|
||||||
try {
|
type: 'warning',
|
||||||
await deleteGroup(row.id);
|
}
|
||||||
ElMessage.success('删除成功');
|
).then(async () => {
|
||||||
fetchData();
|
try {
|
||||||
} catch (error) {
|
await deleteGroup(row.id);
|
||||||
console.error('Delete failed', error);
|
ElMessage.success('删除成功');
|
||||||
}
|
fetchData();
|
||||||
})
|
} catch (error) {
|
||||||
.catch(() => {});
|
console.error('Delete failed', error);
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Lifecycle ---
|
// --- Lifecycle ---
|
||||||
@ -579,21 +382,7 @@ watch(
|
|||||||
() => {
|
() => {
|
||||||
page.value = 1;
|
page.value = 1;
|
||||||
fetchData();
|
fetchData();
|
||||||
},
|
}
|
||||||
);
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => [form.hospitalId, form.departmentId],
|
|
||||||
async ([hospitalId, departmentId]) => {
|
|
||||||
if (!dialogVisible.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await loadLeaderOptions(
|
|
||||||
hospitalId,
|
|
||||||
departmentId,
|
|
||||||
isEdit.value ? currentId.value : null,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -5,43 +5,20 @@
|
|||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<el-form :inline="true" :model="searchForm" class="search-form">
|
<el-form :inline="true" :model="searchForm" class="search-form">
|
||||||
<el-form-item label="医院名称">
|
<el-form-item label="医院名称">
|
||||||
<el-input
|
<el-input v-model="searchForm.keyword" placeholder="请输入关键词" clearable />
|
||||||
v-model="searchForm.keyword"
|
|
||||||
placeholder="请输入关键词"
|
|
||||||
clearable
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
<el-button type="primary" @click="fetchData" icon="Search"
|
<el-button type="primary" @click="fetchData" icon="Search">查询</el-button>
|
||||||
>查询</el-button
|
|
||||||
>
|
|
||||||
<el-button @click="resetSearch" icon="Refresh">重置</el-button>
|
<el-button @click="resetSearch" icon="Refresh">重置</el-button>
|
||||||
<el-button
|
<el-button v-if="userStore.role === 'SYSTEM_ADMIN'" type="success" @click="openCreateDialog" icon="Plus">新增医院</el-button>
|
||||||
v-if="userStore.role === 'SYSTEM_ADMIN'"
|
|
||||||
type="success"
|
|
||||||
@click="openCreateDialog"
|
|
||||||
icon="Plus"
|
|
||||||
>新增医院</el-button
|
|
||||||
>
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Table -->
|
<!-- Table -->
|
||||||
<el-table
|
<el-table :data="tableData" v-loading="loading" border stripe style="width: 100%">
|
||||||
:data="tableData"
|
|
||||||
v-loading="loading"
|
|
||||||
border
|
|
||||||
stripe
|
|
||||||
style="width: 100%"
|
|
||||||
>
|
|
||||||
<el-table-column prop="id" label="ID" width="80" align="center" />
|
<el-table-column prop="id" label="ID" width="80" align="center" />
|
||||||
<el-table-column prop="name" label="医院名称" min-width="200" />
|
<el-table-column prop="name" label="医院名称" min-width="200" />
|
||||||
<el-table-column prop="adminDisplay" label="医院管理员" min-width="220">
|
|
||||||
<template #default="{ row }">
|
|
||||||
{{ row.adminDisplay || '未设置' }}
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column prop="createdAt" label="创建时间" width="180">
|
<el-table-column prop="createdAt" label="创建时间" width="180">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
{{ new Date(row.createdAt).toLocaleString() }}
|
{{ new Date(row.createdAt).toLocaleString() }}
|
||||||
@ -49,19 +26,9 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="250" fixed="right" align="center">
|
<el-table-column label="操作" width="250" fixed="right" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button size="small" @click="goToDepartments(row)"
|
<el-button size="small" @click="goToDepartments(row)">管理科室</el-button>
|
||||||
>管理科室</el-button
|
<el-button size="small" type="primary" @click="openEditDialog(row)">编辑</el-button>
|
||||||
>
|
<el-button v-if="userStore.role === 'SYSTEM_ADMIN'" size="small" type="danger" @click="handleDelete(row)">删除</el-button>
|
||||||
<el-button size="small" type="primary" @click="openEditDialog(row)"
|
|
||||||
>编辑</el-button
|
|
||||||
>
|
|
||||||
<el-button
|
|
||||||
v-if="userStore.role === 'SYSTEM_ADMIN'"
|
|
||||||
size="small"
|
|
||||||
type="danger"
|
|
||||||
@click="handleDelete(row)"
|
|
||||||
>删除</el-button
|
|
||||||
>
|
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@ -82,45 +49,16 @@
|
|||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<!-- Dialog for Create / Edit -->
|
<!-- Dialog for Create / Edit -->
|
||||||
<el-dialog
|
<el-dialog :title="isEdit ? '编辑医院' : '新增医院'" v-model="dialogVisible" width="500px" @close="resetForm">
|
||||||
:title="isEdit ? '编辑医院' : '新增医院'"
|
|
||||||
v-model="dialogVisible"
|
|
||||||
width="500px"
|
|
||||||
@close="resetForm"
|
|
||||||
>
|
|
||||||
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
|
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
|
||||||
<el-form-item label="医院名称" prop="name">
|
<el-form-item label="医院名称" prop="name">
|
||||||
<el-input v-model="form.name" placeholder="请输入医院名称" />
|
<el-input v-model="form.name" placeholder="请输入医院名称" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item
|
|
||||||
label="医院管理员"
|
|
||||||
v-if="userStore.role === 'SYSTEM_ADMIN'"
|
|
||||||
>
|
|
||||||
<el-select
|
|
||||||
v-model="form.adminUserId"
|
|
||||||
placeholder="可选:选择后将任命为医院管理员"
|
|
||||||
clearable
|
|
||||||
filterable
|
|
||||||
style="width: 100%"
|
|
||||||
>
|
|
||||||
<el-option
|
|
||||||
v-for="user in hospitalAdminOptions"
|
|
||||||
:key="user.id"
|
|
||||||
:label="`${user.name}(${user.phone} / ${getRoleName(user.role)})`"
|
|
||||||
:value="user.id"
|
|
||||||
/>
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="dialog-footer">
|
<div class="dialog-footer">
|
||||||
<el-button @click="dialogVisible = false">取消</el-button>
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
<el-button
|
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">确定</el-button>
|
||||||
type="primary"
|
|
||||||
@click="handleSubmit"
|
|
||||||
:loading="submitLoading"
|
|
||||||
>确定</el-button
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
@ -131,13 +69,7 @@
|
|||||||
import { ref, reactive, onMounted } from 'vue';
|
import { ref, reactive, onMounted } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
import {
|
import { getHospitals, createHospital, updateHospital, deleteHospital } from '../../api/organization';
|
||||||
getHospitals,
|
|
||||||
createHospital,
|
|
||||||
updateHospital,
|
|
||||||
deleteHospital,
|
|
||||||
} from '../../api/organization';
|
|
||||||
import { getUsers, updateUser } from '../../api/users';
|
|
||||||
import { useUserStore } from '../../store/user';
|
import { useUserStore } from '../../store/user';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -149,21 +81,9 @@ const tableData = ref([]);
|
|||||||
const total = ref(0);
|
const total = ref(0);
|
||||||
const page = ref(1);
|
const page = ref(1);
|
||||||
const pageSize = ref(10);
|
const pageSize = ref(10);
|
||||||
const hospitalAdminOptions = ref([]);
|
|
||||||
|
|
||||||
const roleMap = {
|
|
||||||
SYSTEM_ADMIN: '系统管理员',
|
|
||||||
HOSPITAL_ADMIN: '医院管理员',
|
|
||||||
DIRECTOR: '科室主任',
|
|
||||||
LEADER: '小组组长',
|
|
||||||
DOCTOR: '医生',
|
|
||||||
ENGINEER: '工程师',
|
|
||||||
};
|
|
||||||
|
|
||||||
const getRoleName = (role) => roleMap[role] || role;
|
|
||||||
|
|
||||||
const searchForm = reactive({
|
const searchForm = reactive({
|
||||||
keyword: '',
|
keyword: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
// Dialog State
|
// Dialog State
|
||||||
@ -174,12 +94,11 @@ const formRef = ref(null);
|
|||||||
const currentId = ref(null);
|
const currentId = ref(null);
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
name: '',
|
name: ''
|
||||||
adminUserId: null,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const rules = {
|
const rules = {
|
||||||
name: [{ required: true, message: '请输入医院名称', trigger: 'blur' }],
|
name: [{ required: true, message: '请输入医院名称', trigger: 'blur' }]
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Methods ---
|
// --- Methods ---
|
||||||
@ -189,32 +108,9 @@ const fetchData = async () => {
|
|||||||
const res = await getHospitals({
|
const res = await getHospitals({
|
||||||
page: page.value,
|
page: page.value,
|
||||||
pageSize: pageSize.value,
|
pageSize: pageSize.value,
|
||||||
keyword: searchForm.keyword || undefined,
|
keyword: searchForm.keyword || undefined
|
||||||
});
|
});
|
||||||
|
tableData.value = res.list || [];
|
||||||
let hospitalAdminNameMap = {};
|
|
||||||
try {
|
|
||||||
const userRes = await getUsers();
|
|
||||||
const users = Array.isArray(userRes?.list) ? userRes.list : [];
|
|
||||||
hospitalAdminNameMap = users.reduce((acc, user) => {
|
|
||||||
if (user.role !== 'HOSPITAL_ADMIN' || !user.hospitalId) {
|
|
||||||
return acc;
|
|
||||||
}
|
|
||||||
if (!acc[user.hospitalId]) {
|
|
||||||
acc[user.hospitalId] = [];
|
|
||||||
}
|
|
||||||
acc[user.hospitalId].push(user.name || '-');
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch hospital admins', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
tableData.value = (res.list || []).map((hospital) => ({
|
|
||||||
...hospital,
|
|
||||||
adminDisplay:
|
|
||||||
(hospitalAdminNameMap[hospital.id] || []).join('、') || '未设置',
|
|
||||||
}));
|
|
||||||
total.value = res.total || 0;
|
total.value = res.total || 0;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch hospitals', error);
|
console.error('Failed to fetch hospitals', error);
|
||||||
@ -232,7 +128,6 @@ const resetSearch = () => {
|
|||||||
const openCreateDialog = () => {
|
const openCreateDialog = () => {
|
||||||
isEdit.value = false;
|
isEdit.value = false;
|
||||||
currentId.value = null;
|
currentId.value = null;
|
||||||
loadHospitalAdminOptions();
|
|
||||||
dialogVisible.value = true;
|
dialogVisible.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -240,7 +135,6 @@ const openEditDialog = (row) => {
|
|||||||
isEdit.value = true;
|
isEdit.value = true;
|
||||||
currentId.value = row.id;
|
currentId.value = row.id;
|
||||||
form.name = row.name;
|
form.name = row.name;
|
||||||
loadHospitalAdminOptions(row.id);
|
|
||||||
dialogVisible.value = true;
|
dialogVisible.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -249,27 +143,6 @@ const resetForm = () => {
|
|||||||
formRef.value.resetFields();
|
formRef.value.resetFields();
|
||||||
}
|
}
|
||||||
form.name = '';
|
form.name = '';
|
||||||
form.adminUserId = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadHospitalAdminOptions = async (hospitalId) => {
|
|
||||||
if (userStore.role !== 'SYSTEM_ADMIN') {
|
|
||||||
hospitalAdminOptions.value = [];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const userRes = await getUsers();
|
|
||||||
const users = Array.isArray(userRes?.list) ? userRes.list : [];
|
|
||||||
hospitalAdminOptions.value = users.filter((user) => {
|
|
||||||
// 仅允许选择“医院管理员”角色,避免误选普通人员。
|
|
||||||
if (user.role !== 'HOSPITAL_ADMIN') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!hospitalId) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return user.hospitalId == null || user.hospitalId === hospitalId;
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
@ -278,37 +151,13 @@ const handleSubmit = async () => {
|
|||||||
if (valid) {
|
if (valid) {
|
||||||
submitLoading.value = true;
|
submitLoading.value = true;
|
||||||
try {
|
try {
|
||||||
let targetHospitalId = null;
|
|
||||||
if (isEdit.value) {
|
if (isEdit.value) {
|
||||||
const updated = await updateHospital(currentId.value, {
|
await updateHospital(currentId.value, form);
|
||||||
name: form.name,
|
|
||||||
});
|
|
||||||
targetHospitalId = updated?.id ?? currentId.value;
|
|
||||||
ElMessage.success('更新成功');
|
ElMessage.success('更新成功');
|
||||||
} else {
|
} else {
|
||||||
const created = await createHospital({ name: form.name });
|
await createHospital(form);
|
||||||
targetHospitalId = created?.id;
|
|
||||||
ElMessage.success('创建成功');
|
ElMessage.success('创建成功');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (form.adminUserId && targetHospitalId) {
|
|
||||||
const selectedAdmin = hospitalAdminOptions.value.find(
|
|
||||||
(user) => user.id === form.adminUserId,
|
|
||||||
);
|
|
||||||
if (!selectedAdmin || selectedAdmin.role !== 'HOSPITAL_ADMIN') {
|
|
||||||
ElMessage.error('仅可选择医院管理员角色人员');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await updateUser(form.adminUserId, {
|
|
||||||
role: 'HOSPITAL_ADMIN',
|
|
||||||
hospitalId: targetHospitalId,
|
|
||||||
departmentId: null,
|
|
||||||
groupId: null,
|
|
||||||
});
|
|
||||||
ElMessage.success('医院管理员已设置');
|
|
||||||
}
|
|
||||||
|
|
||||||
dialogVisible.value = false;
|
dialogVisible.value = false;
|
||||||
fetchData();
|
fetchData();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -321,27 +170,29 @@ const handleSubmit = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = (row) => {
|
const handleDelete = (row) => {
|
||||||
ElMessageBox.confirm(`确定要删除医院 "${row.name}" 吗?`, '警告', {
|
ElMessageBox.confirm(
|
||||||
confirmButtonText: '确定',
|
`确定要删除医院 "${row.name}" 吗?`,
|
||||||
cancelButtonText: '取消',
|
'警告',
|
||||||
type: 'warning',
|
{
|
||||||
})
|
confirmButtonText: '确定',
|
||||||
.then(async () => {
|
cancelButtonText: '取消',
|
||||||
try {
|
type: 'warning',
|
||||||
await deleteHospital(row.id);
|
}
|
||||||
ElMessage.success('删除成功');
|
).then(async () => {
|
||||||
fetchData();
|
try {
|
||||||
} catch (error) {
|
await deleteHospital(row.id);
|
||||||
console.error('Delete failed', error);
|
ElMessage.success('删除成功');
|
||||||
}
|
fetchData();
|
||||||
})
|
} catch (error) {
|
||||||
.catch(() => {});
|
console.error('Delete failed', error);
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
};
|
};
|
||||||
|
|
||||||
const goToDepartments = (row) => {
|
const goToDepartments = (row) => {
|
||||||
router.push({
|
router.push({
|
||||||
path: '/organization/departments',
|
path: '/organization/departments',
|
||||||
query: { hospitalId: row.id, hospitalName: row.name },
|
query: { hospitalId: row.id, hospitalName: row.name }
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -6,17 +6,8 @@
|
|||||||
<el-card shadow="never" class="tree-card">
|
<el-card shadow="never" class="tree-card">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<span class="header-title"
|
<span class="header-title"><el-icon><Connection /></el-icon> 组织架构全景图</span>
|
||||||
><el-icon><Connection /></el-icon> 组织架构全景图</span
|
<el-button type="primary" @click="fetchTreeData" icon="Refresh" round size="small">刷新</el-button>
|
||||||
>
|
|
||||||
<el-button
|
|
||||||
type="primary"
|
|
||||||
@click="fetchTreeData"
|
|
||||||
icon="Refresh"
|
|
||||||
round
|
|
||||||
size="small"
|
|
||||||
>刷新</el-button
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -35,37 +26,17 @@
|
|||||||
<div class="custom-tree-node" :class="`node-${data.type}`">
|
<div class="custom-tree-node" :class="`node-${data.type}`">
|
||||||
<div class="node-main">
|
<div class="node-main">
|
||||||
<div class="node-icon-wrapper">
|
<div class="node-icon-wrapper">
|
||||||
<el-icon v-if="data.type === 'hospital'"
|
<el-icon v-if="data.type === 'hospital'"><OfficeBuilding /></el-icon>
|
||||||
><OfficeBuilding
|
<el-icon v-else-if="data.type === 'department'"><Filter /></el-icon>
|
||||||
/></el-icon>
|
<el-icon v-else-if="data.type === 'group'"><Connection /></el-icon>
|
||||||
<el-icon v-else-if="data.type === 'department'"
|
<el-icon v-else-if="data.type === 'user'"><UserFilled /></el-icon>
|
||||||
><Filter
|
|
||||||
/></el-icon>
|
|
||||||
<el-icon v-else-if="data.type === 'group'"
|
|
||||||
><Connection
|
|
||||||
/></el-icon>
|
|
||||||
<el-icon v-else-if="data.type === 'user'"
|
|
||||||
><UserFilled
|
|
||||||
/></el-icon>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="node-text">
|
<div class="node-text">
|
||||||
<span class="node-label">{{ node.label }}</span>
|
<span class="node-label">{{ node.label }}</span>
|
||||||
<span
|
<span v-if="data.type === 'department'" class="node-sub-label">
|
||||||
v-if="data.type === 'hospital'"
|
|
||||||
class="node-sub-label"
|
|
||||||
>
|
|
||||||
医院管理员:{{ data.adminDisplay || '未设置' }}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-else-if="data.type === 'department'"
|
|
||||||
class="node-sub-label"
|
|
||||||
>
|
|
||||||
主任:{{ data.directorDisplay || '未设置' }}
|
主任:{{ data.directorDisplay || '未设置' }}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span v-else-if="data.type === 'group'" class="node-sub-label">
|
||||||
v-else-if="data.type === 'group'"
|
|
||||||
class="node-sub-label"
|
|
||||||
>
|
|
||||||
组长:{{ data.leaderDisplay || '未设置' }}
|
组长:{{ data.leaderDisplay || '未设置' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -83,10 +54,7 @@
|
|||||||
|
|
||||||
<div class="node-actions" v-if="data.type !== 'user'">
|
<div class="node-actions" v-if="data.type !== 'user'">
|
||||||
<el-button
|
<el-button
|
||||||
v-if="
|
v-if="canAssignOwner && (data.type === 'department' || data.type === 'group')"
|
||||||
canAssignOwner &&
|
|
||||||
(data.type === 'department' || data.type === 'group')
|
|
||||||
"
|
|
||||||
type="warning"
|
type="warning"
|
||||||
link
|
link
|
||||||
size="small"
|
size="small"
|
||||||
@ -94,34 +62,17 @@
|
|||||||
>
|
>
|
||||||
{{ data.type === 'department' ? '设主任' : '设组长' }}
|
{{ data.type === 'department' ? '设主任' : '设组长' }}
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button
|
<el-button v-if="canEditNode(data)" type="info" link size="small" @click.stop="openEditDialog(data)" icon="EditPen">
|
||||||
v-if="canEditNode(data)"
|
|
||||||
type="info"
|
|
||||||
link
|
|
||||||
size="small"
|
|
||||||
@click.stop="openEditDialog(data)"
|
|
||||||
icon="EditPen"
|
|
||||||
>
|
|
||||||
编辑
|
编辑
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button
|
<el-button v-if="canDeleteNode(data)" type="danger" link size="small" @click.stop="handleDelete(data)" icon="Delete">
|
||||||
v-if="canDeleteNode(data)"
|
|
||||||
type="danger"
|
|
||||||
link
|
|
||||||
size="small"
|
|
||||||
@click.stop="handleDelete(data)"
|
|
||||||
icon="Delete"
|
|
||||||
>
|
|
||||||
删除
|
删除
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-tree>
|
</el-tree>
|
||||||
<el-empty
|
<el-empty v-if="!loading && treeData.length === 0" description="暂无组织架构数据" />
|
||||||
v-if="!loading && treeData.length === 0"
|
|
||||||
description="暂无组织架构数据"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
</el-col>
|
</el-col>
|
||||||
@ -135,10 +86,7 @@
|
|||||||
<el-icon><Menu /></el-icon>
|
<el-icon><Menu /></el-icon>
|
||||||
{{ activePanelTitle }}
|
{{ activePanelTitle }}
|
||||||
</span>
|
</span>
|
||||||
<div
|
<div v-if="activeNode && activeNode.type !== 'user'" class="header-actions">
|
||||||
v-if="activeNode && activeNode.type !== 'user'"
|
|
||||||
class="header-actions"
|
|
||||||
>
|
|
||||||
<el-button
|
<el-button
|
||||||
v-if="canCreateDepartment(activeNode)"
|
v-if="canCreateDepartment(activeNode)"
|
||||||
type="primary"
|
type="primary"
|
||||||
@ -167,27 +115,18 @@
|
|||||||
新增人员
|
新增人员
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="
|
v-if="canAssignOwner && (activeNode.type === 'department' || activeNode.type === 'group')"
|
||||||
canAssignOwner &&
|
|
||||||
(activeNode.type === 'department' ||
|
|
||||||
activeNode.type === 'group')
|
|
||||||
"
|
|
||||||
type="primary"
|
type="primary"
|
||||||
size="small"
|
size="small"
|
||||||
@click="openSetOwnerDialog(activeNode)"
|
@click="openSetOwnerDialog(activeNode)"
|
||||||
>
|
>
|
||||||
{{
|
{{ activeNode.type === 'department' ? '设置主任' : '设置组长' }}
|
||||||
activeNode.type === 'department' ? '设置主任' : '设置组长'
|
|
||||||
}}
|
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div
|
<div v-if="activeNode && activeNode.type !== 'user'" class="node-detail-panel">
|
||||||
v-if="activeNode && activeNode.type !== 'user'"
|
|
||||||
class="node-detail-panel"
|
|
||||||
>
|
|
||||||
<el-alert
|
<el-alert
|
||||||
v-if="activeNodeMeta"
|
v-if="activeNodeMeta"
|
||||||
:title="activeNodeMeta"
|
:title="activeNodeMeta"
|
||||||
@ -195,55 +134,30 @@
|
|||||||
:closable="false"
|
:closable="false"
|
||||||
class="mb-12"
|
class="mb-12"
|
||||||
/>
|
/>
|
||||||
<el-table
|
<el-table :data="activeNodeChildren" border stripe style="width: 100%" max-height="600">
|
||||||
:data="activeNodeChildren"
|
|
||||||
border
|
|
||||||
stripe
|
|
||||||
style="width: 100%"
|
|
||||||
max-height="600"
|
|
||||||
>
|
|
||||||
<el-table-column prop="name" label="名称" />
|
<el-table-column prop="name" label="名称" />
|
||||||
<el-table-column label="类型" width="100" align="center">
|
<el-table-column label="类型" width="100" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag size="small" :type="getNodeTypeTag(row.type)">{{
|
<el-tag size="small" :type="getNodeTypeTag(row.type)">{{ getTypeName(row.type) }}</el-tag>
|
||||||
getTypeName(row.type)
|
|
||||||
}}</el-tag>
|
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="负责人" width="180" align="center">
|
<el-table-column label="负责人" width="180" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span v-if="row.type === 'hospital'"
|
<span v-if="row.type === 'department'">主任:{{ row.directorDisplay || '未设置' }}</span>
|
||||||
>医院管理员:{{ row.adminDisplay || '未设置' }}</span
|
<span v-else-if="row.type === 'group'">组长:{{ row.leaderDisplay || '未设置' }}</span>
|
||||||
>
|
|
||||||
<span v-else-if="row.type === 'department'"
|
|
||||||
>主任:{{ row.directorDisplay || '未设置' }}</span
|
|
||||||
>
|
|
||||||
<span v-else-if="row.type === 'group'"
|
|
||||||
>组长:{{ row.leaderDisplay || '未设置' }}</span
|
|
||||||
>
|
|
||||||
<span v-else>-</span>
|
<span v-else>-</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="角色" width="120" align="center">
|
<el-table-column label="角色" width="120" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span v-if="row.type === 'user'">{{
|
<span v-if="row.type === 'user'">{{ getRoleName(row.role) }}</span>
|
||||||
getRoleName(row.role)
|
|
||||||
}}</span>
|
|
||||||
<span v-else>-</span>
|
<span v-else>-</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column
|
<el-table-column label="操作" width="220" align="center" fixed="right">
|
||||||
label="操作"
|
|
||||||
width="220"
|
|
||||||
align="center"
|
|
||||||
fixed="right"
|
|
||||||
>
|
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button
|
<el-button
|
||||||
v-if="
|
v-if="canAssignOwner && (row.type === 'department' || row.type === 'group')"
|
||||||
canAssignOwner &&
|
|
||||||
(row.type === 'department' || row.type === 'group')
|
|
||||||
"
|
|
||||||
type="warning"
|
type="warning"
|
||||||
link
|
link
|
||||||
size="small"
|
size="small"
|
||||||
@ -251,22 +165,8 @@
|
|||||||
>
|
>
|
||||||
{{ row.type === 'department' ? '设主任' : '设组长' }}
|
{{ row.type === 'department' ? '设主任' : '设组长' }}
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button
|
<el-button v-if="canEditNode(row)" type="primary" link size="small" @click="openEditDialog(row)">编辑</el-button>
|
||||||
v-if="canEditNode(row)"
|
<el-button v-if="canDeleteNode(row)" type="danger" link size="small" @click="handleDelete(row)">删除</el-button>
|
||||||
type="primary"
|
|
||||||
link
|
|
||||||
size="small"
|
|
||||||
@click="openEditDialog(row)"
|
|
||||||
>编辑</el-button
|
|
||||||
>
|
|
||||||
<el-button
|
|
||||||
v-if="canDeleteNode(row)"
|
|
||||||
type="danger"
|
|
||||||
link
|
|
||||||
size="small"
|
|
||||||
@click="handleDelete(row)"
|
|
||||||
>删除</el-button
|
|
||||||
>
|
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@ -274,24 +174,12 @@
|
|||||||
|
|
||||||
<div v-else-if="selectedUserDetail" class="user-detail-panel">
|
<div v-else-if="selectedUserDetail" class="user-detail-panel">
|
||||||
<el-descriptions :column="2" border>
|
<el-descriptions :column="2" border>
|
||||||
<el-descriptions-item label="姓名">{{
|
<el-descriptions-item label="姓名">{{ selectedUserDetail.name || '-' }}</el-descriptions-item>
|
||||||
selectedUserDetail.name || '-'
|
<el-descriptions-item label="角色">{{ getRoleName(selectedUserDetail.role) }}</el-descriptions-item>
|
||||||
}}</el-descriptions-item>
|
<el-descriptions-item label="手机号">{{ selectedUserDetail.phone || '-' }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="角色">{{
|
<el-descriptions-item label="医院">{{ selectedUserDetail.hospitalName || '-' }}</el-descriptions-item>
|
||||||
getRoleName(selectedUserDetail.role)
|
<el-descriptions-item label="科室">{{ selectedUserDetail.departmentName || '-' }}</el-descriptions-item>
|
||||||
}}</el-descriptions-item>
|
<el-descriptions-item label="小组">{{ selectedUserDetail.groupName || '-' }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="手机号">{{
|
|
||||||
selectedUserDetail.phone || '-'
|
|
||||||
}}</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="医院">{{
|
|
||||||
selectedUserDetail.hospitalName || '-'
|
|
||||||
}}</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="科室">{{
|
|
||||||
selectedUserDetail.departmentName || '-'
|
|
||||||
}}</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="小组">{{
|
|
||||||
selectedUserDetail.groupName || '-'
|
|
||||||
}}</el-descriptions-item>
|
|
||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
<el-alert
|
<el-alert
|
||||||
title="如需修改人员角色或组织归属,请前往“用户管理”页面操作。"
|
title="如需修改人员角色或组织归属,请前往“用户管理”页面操作。"
|
||||||
@ -306,37 +194,16 @@
|
|||||||
</el-row>
|
</el-row>
|
||||||
|
|
||||||
<!-- Dialog for Create / Edit -->
|
<!-- Dialog for Create / Edit -->
|
||||||
<el-dialog
|
<el-dialog :title="dialogTitle" v-model="dialogVisible" width="450px" @close="resetForm" destroy-on-close>
|
||||||
:title="dialogTitle"
|
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px" @submit.prevent>
|
||||||
v-model="dialogVisible"
|
|
||||||
width="450px"
|
|
||||||
@close="resetForm"
|
|
||||||
destroy-on-close
|
|
||||||
>
|
|
||||||
<el-form
|
|
||||||
:model="form"
|
|
||||||
:rules="rules"
|
|
||||||
ref="formRef"
|
|
||||||
label-width="100px"
|
|
||||||
@submit.prevent
|
|
||||||
>
|
|
||||||
<el-form-item :label="formLabel" prop="name">
|
<el-form-item :label="formLabel" prop="name">
|
||||||
<el-input
|
<el-input v-model="form.name" :placeholder="`请输入${formLabel}`" clearable />
|
||||||
v-model="form.name"
|
|
||||||
:placeholder="`请输入${formLabel}`"
|
|
||||||
clearable
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="dialog-footer">
|
<div class="dialog-footer">
|
||||||
<el-button @click="dialogVisible = false">取消</el-button>
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
<el-button
|
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">确定</el-button>
|
||||||
type="primary"
|
|
||||||
@click="handleSubmit"
|
|
||||||
:loading="submitLoading"
|
|
||||||
>确定</el-button
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
@ -353,7 +220,7 @@
|
|||||||
v-model="selectedOwnerUserId"
|
v-model="selectedOwnerUserId"
|
||||||
filterable
|
filterable
|
||||||
placeholder="请选择人员"
|
placeholder="请选择人员"
|
||||||
style="width: 100%"
|
style="width: 100%;"
|
||||||
>
|
>
|
||||||
<el-option
|
<el-option
|
||||||
v-for="user in ownerCandidates"
|
v-for="user in ownerCandidates"
|
||||||
@ -372,11 +239,7 @@
|
|||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="dialog-footer">
|
<div class="dialog-footer">
|
||||||
<el-button @click="ownerDialogVisible = false">取消</el-button>
|
<el-button @click="ownerDialogVisible = false">取消</el-button>
|
||||||
<el-button
|
<el-button type="primary" :loading="ownerSubmitLoading" @click="handleSetOwner">
|
||||||
type="primary"
|
|
||||||
:loading="ownerSubmitLoading"
|
|
||||||
@click="handleSetOwner"
|
|
||||||
>
|
|
||||||
确定
|
确定
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
@ -389,33 +252,10 @@
|
|||||||
import { ref, reactive, onMounted, computed } from 'vue';
|
import { ref, reactive, onMounted, computed } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
import {
|
import { getHospitals, getDepartments, getGroups, createDepartment, updateDepartment, deleteDepartment, createGroup, updateGroup, deleteGroup, updateHospital, deleteHospital } from '../../api/organization';
|
||||||
getHospitals,
|
|
||||||
getDepartments,
|
|
||||||
getGroups,
|
|
||||||
createDepartment,
|
|
||||||
updateDepartment,
|
|
||||||
deleteDepartment,
|
|
||||||
createGroup,
|
|
||||||
updateGroup,
|
|
||||||
deleteGroup,
|
|
||||||
updateHospital,
|
|
||||||
deleteHospital,
|
|
||||||
} from '../../api/organization';
|
|
||||||
import { getUsers, updateUser } from '../../api/users';
|
import { getUsers, updateUser } from '../../api/users';
|
||||||
import { useUserStore } from '../../store/user';
|
import { useUserStore } from '../../store/user';
|
||||||
import {
|
import { OfficeBuilding, Filter, Connection, UserFilled, Refresh, Plus, EditPen, Delete, Menu, User } from '@element-plus/icons-vue';
|
||||||
OfficeBuilding,
|
|
||||||
Filter,
|
|
||||||
Connection,
|
|
||||||
UserFilled,
|
|
||||||
Refresh,
|
|
||||||
Plus,
|
|
||||||
EditPen,
|
|
||||||
Delete,
|
|
||||||
Menu,
|
|
||||||
User,
|
|
||||||
} from '@element-plus/icons-vue';
|
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
@ -442,9 +282,9 @@ const roleMap = {
|
|||||||
SYSTEM_ADMIN: '系统管理员',
|
SYSTEM_ADMIN: '系统管理员',
|
||||||
HOSPITAL_ADMIN: '医院管理员',
|
HOSPITAL_ADMIN: '医院管理员',
|
||||||
DIRECTOR: '科室主任',
|
DIRECTOR: '科室主任',
|
||||||
LEADER: '小组组长',
|
LEADER: '医疗组长',
|
||||||
DOCTOR: '医生',
|
DOCTOR: '医生',
|
||||||
ENGINEER: '工程师',
|
ENGINEER: '工程师'
|
||||||
};
|
};
|
||||||
|
|
||||||
const getRoleName = (role) => roleMap[role] || role;
|
const getRoleName = (role) => roleMap[role] || role;
|
||||||
@ -458,22 +298,12 @@ const getRoleTagType = (role) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getTypeName = (type) => {
|
const getTypeName = (type) => {
|
||||||
const map = {
|
const map = { hospital: '医院', department: '科室', group: '小组', user: '人员' };
|
||||||
hospital: '医院',
|
|
||||||
department: '科室',
|
|
||||||
group: '小组',
|
|
||||||
user: '人员',
|
|
||||||
};
|
|
||||||
return map[type] || type;
|
return map[type] || type;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getNodeTypeTag = (type) => {
|
const getNodeTypeTag = (type) => {
|
||||||
const map = {
|
const map = { hospital: 'primary', department: 'success', group: 'warning', user: 'info' };
|
||||||
hospital: 'primary',
|
|
||||||
department: 'success',
|
|
||||||
group: 'warning',
|
|
||||||
user: 'info',
|
|
||||||
};
|
|
||||||
return map[type] || 'info';
|
return map[type] || 'info';
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -492,16 +322,16 @@ const canCreateDepartment = (node) =>
|
|||||||
|
|
||||||
const canCreateGroup = (node) =>
|
const canCreateGroup = (node) =>
|
||||||
Boolean(
|
Boolean(
|
||||||
node &&
|
node
|
||||||
node.type === 'department' &&
|
&& node.type === 'department'
|
||||||
(isOrgAdmin.value || isDirector.value),
|
&& (isOrgAdmin.value || isDirector.value),
|
||||||
);
|
);
|
||||||
|
|
||||||
const canAddUser = (node) =>
|
const canAddUser = (node) =>
|
||||||
Boolean(
|
Boolean(
|
||||||
node &&
|
node
|
||||||
(node.type === 'department' || node.type === 'group') &&
|
&& (node.type === 'department' || node.type === 'group')
|
||||||
isOrgAdmin.value,
|
&& isOrgAdmin.value,
|
||||||
);
|
);
|
||||||
|
|
||||||
const canEditNode = (node) => {
|
const canEditNode = (node) => {
|
||||||
@ -548,9 +378,9 @@ const activePanelTitle = computed(() => {
|
|||||||
|
|
||||||
const activeNodeChildren = computed(() => {
|
const activeNodeChildren = computed(() => {
|
||||||
if (
|
if (
|
||||||
!activeNode.value ||
|
!activeNode.value
|
||||||
activeNode.value.type === 'user' ||
|
|| activeNode.value.type === 'user'
|
||||||
!Array.isArray(activeNode.value.children)
|
|| !Array.isArray(activeNode.value.children)
|
||||||
) {
|
) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@ -576,9 +406,7 @@ const selectedUserDetail = computed(() => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const current = allUsers.value.find(
|
const current = allUsers.value.find((user) => user.id === activeNode.value.id);
|
||||||
(user) => user.id === activeNode.value.id,
|
|
||||||
);
|
|
||||||
const userData = current || activeNode.value;
|
const userData = current || activeNode.value;
|
||||||
const hospitalId = userData.hospitalId || null;
|
const hospitalId = userData.hospitalId || null;
|
||||||
const departmentId = userData.departmentId || null;
|
const departmentId = userData.departmentId || null;
|
||||||
@ -596,9 +424,6 @@ const activeNodeMeta = computed(() => {
|
|||||||
if (!activeNode.value) {
|
if (!activeNode.value) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
if (activeNode.value.type === 'hospital') {
|
|
||||||
return `当前医院管理员:${activeNode.value.adminDisplay || '未设置'}`;
|
|
||||||
}
|
|
||||||
if (activeNode.value.type === 'department') {
|
if (activeNode.value.type === 'department') {
|
||||||
return `当前科室主任:${activeNode.value.directorDisplay || '未设置'}`;
|
return `当前科室主任:${activeNode.value.directorDisplay || '未设置'}`;
|
||||||
}
|
}
|
||||||
@ -634,7 +459,7 @@ const fetchTreeData = async () => {
|
|||||||
const [deptRes, groupRes, userRes] = await Promise.all([
|
const [deptRes, groupRes, userRes] = await Promise.all([
|
||||||
getDepartments({ pageSize: 100 }),
|
getDepartments({ pageSize: 100 }),
|
||||||
getGroups({ pageSize: 100 }),
|
getGroups({ pageSize: 100 }),
|
||||||
getUsers(),
|
getUsers()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const departments = deptRes.list || [];
|
const departments = deptRes.list || [];
|
||||||
@ -653,14 +478,7 @@ const fetchTreeData = async () => {
|
|||||||
|
|
||||||
const directorNameMap = {};
|
const directorNameMap = {};
|
||||||
const leaderNameMap = {};
|
const leaderNameMap = {};
|
||||||
const hospitalAdminNameMap = {};
|
|
||||||
users.forEach((user) => {
|
users.forEach((user) => {
|
||||||
if (user.role === 'HOSPITAL_ADMIN' && user.hospitalId) {
|
|
||||||
if (!hospitalAdminNameMap[user.hospitalId]) {
|
|
||||||
hospitalAdminNameMap[user.hospitalId] = [];
|
|
||||||
}
|
|
||||||
hospitalAdminNameMap[user.hospitalId].push(user.name);
|
|
||||||
}
|
|
||||||
if (user.role === 'DIRECTOR' && user.departmentId) {
|
if (user.role === 'DIRECTOR' && user.departmentId) {
|
||||||
if (!directorNameMap[user.departmentId]) {
|
if (!directorNameMap[user.departmentId]) {
|
||||||
directorNameMap[user.departmentId] = [];
|
directorNameMap[user.departmentId] = [];
|
||||||
@ -675,86 +493,49 @@ const fetchTreeData = async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const tree = hospitals.map((h) => {
|
const tree = hospitals.map(h => {
|
||||||
const hDepts = departments.filter((d) => d.hospitalId === h.id);
|
const hDepts = departments.filter(d => d.hospitalId === h.id);
|
||||||
const adminDisplay =
|
|
||||||
(hospitalAdminNameMap[h.id] || []).join('、') || '未设置';
|
|
||||||
|
|
||||||
const deptNodes = hDepts.map((d) => {
|
const deptNodes = hDepts.map(d => {
|
||||||
const dGroups = groups.filter((g) => g.departmentId === d.id);
|
const dGroups = groups.filter(g => g.departmentId === d.id);
|
||||||
const directorDisplay =
|
const directorDisplay =
|
||||||
(directorNameMap[d.id] || []).join('、') || '未设置';
|
(directorNameMap[d.id] || []).join('、') || '未设置';
|
||||||
|
|
||||||
const groupNodes = dGroups.map((g) => {
|
const groupNodes = dGroups.map(g => {
|
||||||
const gUsers = users.filter((u) => u.groupId === g.id);
|
const gUsers = users.filter(u => u.groupId === g.id);
|
||||||
const leaderDisplay =
|
const leaderDisplay =
|
||||||
(leaderNameMap[g.id] || []).join('、') || '未设置';
|
(leaderNameMap[g.id] || []).join('、') || '未设置';
|
||||||
const userNodes = gUsers.map((u) => ({
|
const userNodes = gUsers.map(u => ({
|
||||||
key: `u_${u.id}`,
|
key: `u_${u.id}`, id: u.id, name: u.name, type: 'user', role: u.role,
|
||||||
id: u.id,
|
hospitalId: h.id, departmentId: d.id, groupId: g.id
|
||||||
name: u.name,
|
|
||||||
type: 'user',
|
|
||||||
role: u.role,
|
|
||||||
hospitalId: h.id,
|
|
||||||
departmentId: d.id,
|
|
||||||
groupId: g.id,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
key: `g_${g.id}`,
|
key: `g_${g.id}`, id: g.id, name: g.name, type: 'group',
|
||||||
id: g.id,
|
departmentId: d.id, hospitalId: h.id, leaderDisplay, children: userNodes
|
||||||
name: g.name,
|
|
||||||
type: 'group',
|
|
||||||
departmentId: d.id,
|
|
||||||
hospitalId: h.id,
|
|
||||||
leaderDisplay,
|
|
||||||
children: userNodes,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const dUsers = users.filter(
|
const dUsers = users.filter(u => u.departmentId === d.id && !u.groupId);
|
||||||
(u) => u.departmentId === d.id && !u.groupId,
|
const dUserNodes = dUsers.map(u => ({
|
||||||
);
|
key: `u_${u.id}`, id: u.id, name: u.name, type: 'user', role: u.role,
|
||||||
const dUserNodes = dUsers.map((u) => ({
|
hospitalId: h.id, departmentId: d.id
|
||||||
key: `u_${u.id}`,
|
|
||||||
id: u.id,
|
|
||||||
name: u.name,
|
|
||||||
type: 'user',
|
|
||||||
role: u.role,
|
|
||||||
hospitalId: h.id,
|
|
||||||
departmentId: d.id,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
key: `d_${d.id}`,
|
key: `d_${d.id}`, id: d.id, name: d.name, type: 'department',
|
||||||
id: d.id,
|
hospitalId: h.id, directorDisplay, children: [...groupNodes, ...dUserNodes]
|
||||||
name: d.name,
|
|
||||||
type: 'department',
|
|
||||||
hospitalId: h.id,
|
|
||||||
directorDisplay,
|
|
||||||
children: [...groupNodes, ...dUserNodes],
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const hUsers = users.filter(
|
const hUsers = users.filter(u => u.hospitalId === h.id && !u.departmentId);
|
||||||
(u) => u.hospitalId === h.id && !u.departmentId,
|
const hUserNodes = hUsers.map(u => ({
|
||||||
);
|
key: `u_${u.id}`, id: u.id, name: u.name, type: 'user', role: u.role,
|
||||||
const hUserNodes = hUsers.map((u) => ({
|
hospitalId: h.id
|
||||||
key: `u_${u.id}`,
|
|
||||||
id: u.id,
|
|
||||||
name: u.name,
|
|
||||||
type: 'user',
|
|
||||||
role: u.role,
|
|
||||||
hospitalId: h.id,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
key: `h_${h.id}`,
|
key: `h_${h.id}`, id: h.id, name: h.name, type: 'hospital', children: [...deptNodes, ...hUserNodes]
|
||||||
id: h.id,
|
|
||||||
name: h.name,
|
|
||||||
type: 'hospital',
|
|
||||||
adminDisplay,
|
|
||||||
children: [...deptNodes, ...hUserNodes],
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -783,19 +564,17 @@ const openSetOwnerDialog = (node) => {
|
|||||||
selectedOwnerUserId.value = null;
|
selectedOwnerUserId.value = null;
|
||||||
|
|
||||||
if (node.type === 'department') {
|
if (node.type === 'department') {
|
||||||
ownerCandidates.value = allUsers.value.filter(
|
ownerCandidates.value = allUsers.value.filter((user) =>
|
||||||
(user) =>
|
user.hospitalId === node.hospitalId
|
||||||
user.hospitalId === node.hospitalId &&
|
&& user.departmentId === node.id
|
||||||
user.departmentId === node.id &&
|
&& ['DIRECTOR', 'LEADER'].includes(user.role),
|
||||||
user.role === 'DIRECTOR',
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
ownerCandidates.value = allUsers.value.filter(
|
ownerCandidates.value = allUsers.value.filter((user) =>
|
||||||
(user) =>
|
user.hospitalId === node.hospitalId
|
||||||
user.hospitalId === node.hospitalId &&
|
&& user.departmentId === node.departmentId
|
||||||
user.departmentId === node.departmentId &&
|
&& user.groupId === node.id
|
||||||
user.groupId === node.id &&
|
&& user.role === 'LEADER',
|
||||||
user.role === 'LEADER',
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -805,9 +584,7 @@ const openSetOwnerDialog = (node) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const currentOwner = ownerCandidates.value.find((user) =>
|
const currentOwner = ownerCandidates.value.find((user) =>
|
||||||
node.type === 'department'
|
node.type === 'department' ? user.role === 'DIRECTOR' : user.role === 'LEADER',
|
||||||
? user.role === 'DIRECTOR'
|
|
||||||
: user.role === 'LEADER',
|
|
||||||
);
|
);
|
||||||
selectedOwnerUserId.value = currentOwner?.id ?? ownerCandidates.value[0].id;
|
selectedOwnerUserId.value = currentOwner?.id ?? ownerCandidates.value[0].id;
|
||||||
ownerDialogVisible.value = true;
|
ownerDialogVisible.value = true;
|
||||||
@ -830,14 +607,14 @@ const handleSetOwner = async () => {
|
|||||||
const isDepartment = ownerTargetNode.value.type === 'department';
|
const isDepartment = ownerTargetNode.value.type === 'department';
|
||||||
const payload = isDepartment
|
const payload = isDepartment
|
||||||
? {
|
? {
|
||||||
role: 'DIRECTOR',
|
role: 'DIRECTOR',
|
||||||
// 后端约束:非 DOCTOR 不允许“调整”科室/小组归属。
|
// 后端约束:非 DOCTOR 不允许“调整”科室/小组归属。
|
||||||
// 这里仅做角色变更,并清空小组归属,避免触发该约束。
|
// 这里仅做角色变更,并清空小组归属,避免触发该约束。
|
||||||
groupId: null,
|
groupId: null,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
role: 'LEADER',
|
role: 'LEADER',
|
||||||
};
|
};
|
||||||
|
|
||||||
ownerSubmitLoading.value = true;
|
ownerSubmitLoading.value = true;
|
||||||
try {
|
try {
|
||||||
@ -858,9 +635,8 @@ const goToAddUser = (nodeData) => {
|
|||||||
query: {
|
query: {
|
||||||
action: 'create',
|
action: 'create',
|
||||||
hospitalId: nodeData.hospitalId,
|
hospitalId: nodeData.hospitalId,
|
||||||
departmentId:
|
departmentId: nodeData.type === 'department' ? nodeData.id : nodeData.departmentId,
|
||||||
nodeData.type === 'department' ? nodeData.id : nodeData.departmentId,
|
}
|
||||||
},
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -874,45 +650,17 @@ const parentId = ref(null);
|
|||||||
const currentId = ref(null);
|
const currentId = ref(null);
|
||||||
|
|
||||||
const form = reactive({ name: '' });
|
const form = reactive({ name: '' });
|
||||||
const rules = {
|
const rules = { name: [{ required: true, message: '请输入名称', trigger: 'blur' }] };
|
||||||
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
|
|
||||||
};
|
|
||||||
|
|
||||||
const dialogTitle = computed(() => {
|
const dialogTitle = computed(() => {
|
||||||
const typeName =
|
const typeName = dialogType.value === 'hospital' ? '医院' : (dialogType.value === 'department' ? '科室' : '小组');
|
||||||
dialogType.value === 'hospital'
|
|
||||||
? '医院'
|
|
||||||
: dialogType.value === 'department'
|
|
||||||
? '科室'
|
|
||||||
: '小组';
|
|
||||||
return dialogMode.value === 'create' ? `新增${typeName}` : `编辑${typeName}`;
|
return dialogMode.value === 'create' ? `新增${typeName}` : `编辑${typeName}`;
|
||||||
});
|
});
|
||||||
const formLabel = computed(() =>
|
const formLabel = computed(() => dialogType.value === 'hospital' ? '医院名称' : (dialogType.value === 'department' ? '科室名称' : '小组名称'));
|
||||||
dialogType.value === 'hospital'
|
|
||||||
? '医院名称'
|
|
||||||
: dialogType.value === 'department'
|
|
||||||
? '科室名称'
|
|
||||||
: '小组名称',
|
|
||||||
);
|
|
||||||
|
|
||||||
const openCreateDialog = (type, pId) => {
|
const openCreateDialog = (type, pId) => { dialogType.value = type; dialogMode.value = 'create'; parentId.value = pId; currentId.value = null; dialogVisible.value = true; };
|
||||||
dialogType.value = type;
|
const openEditDialog = (data) => { dialogType.value = data.type; dialogMode.value = 'edit'; currentId.value = data.id; form.name = data.name; dialogVisible.value = true; };
|
||||||
dialogMode.value = 'create';
|
const resetForm = () => { if (formRef.value) formRef.value.resetFields(); form.name = ''; };
|
||||||
parentId.value = pId;
|
|
||||||
currentId.value = null;
|
|
||||||
dialogVisible.value = true;
|
|
||||||
};
|
|
||||||
const openEditDialog = (data) => {
|
|
||||||
dialogType.value = data.type;
|
|
||||||
dialogMode.value = 'edit';
|
|
||||||
currentId.value = data.id;
|
|
||||||
form.name = data.name;
|
|
||||||
dialogVisible.value = true;
|
|
||||||
};
|
|
||||||
const resetForm = () => {
|
|
||||||
if (formRef.value) formRef.value.resetFields();
|
|
||||||
form.name = '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!formRef.value) return;
|
if (!formRef.value) return;
|
||||||
@ -921,78 +669,45 @@ const handleSubmit = async () => {
|
|||||||
submitLoading.value = true;
|
submitLoading.value = true;
|
||||||
try {
|
try {
|
||||||
if (dialogMode.value === 'create') {
|
if (dialogMode.value === 'create') {
|
||||||
if (dialogType.value === 'department')
|
if (dialogType.value === 'department') await createDepartment({ name: form.name, hospitalId: parentId.value });
|
||||||
await createDepartment({
|
else if (dialogType.value === 'group') await createGroup({ name: form.name, departmentId: parentId.value });
|
||||||
name: form.name,
|
|
||||||
hospitalId: parentId.value,
|
|
||||||
});
|
|
||||||
else if (dialogType.value === 'group')
|
|
||||||
await createGroup({
|
|
||||||
name: form.name,
|
|
||||||
departmentId: parentId.value,
|
|
||||||
});
|
|
||||||
ElMessage.success('创建成功');
|
ElMessage.success('创建成功');
|
||||||
} else {
|
} else {
|
||||||
if (dialogType.value === 'hospital')
|
if (dialogType.value === 'hospital') await updateHospital(currentId.value, { name: form.name });
|
||||||
await updateHospital(currentId.value, { name: form.name });
|
else if (dialogType.value === 'department') await updateDepartment(currentId.value, { name: form.name });
|
||||||
else if (dialogType.value === 'department')
|
else if (dialogType.value === 'group') await updateGroup(currentId.value, { name: form.name });
|
||||||
await updateDepartment(currentId.value, { name: form.name });
|
|
||||||
else if (dialogType.value === 'group')
|
|
||||||
await updateGroup(currentId.value, { name: form.name });
|
|
||||||
ElMessage.success('更新成功');
|
ElMessage.success('更新成功');
|
||||||
|
|
||||||
// Update activeNode locally if it's the one edited
|
// Update activeNode locally if it's the one edited
|
||||||
if (
|
if (activeNode.value && activeNode.value.id === currentId.value && activeNode.value.type === dialogType.value) {
|
||||||
activeNode.value &&
|
|
||||||
activeNode.value.id === currentId.value &&
|
|
||||||
activeNode.value.type === dialogType.value
|
|
||||||
) {
|
|
||||||
activeNode.value.name = form.name;
|
activeNode.value.name = form.name;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
dialogVisible.value = false;
|
dialogVisible.value = false;
|
||||||
fetchTreeData();
|
fetchTreeData();
|
||||||
} catch (error) {
|
} catch (error) { console.error(error); } finally { submitLoading.value = false; }
|
||||||
console.error(error);
|
|
||||||
} finally {
|
|
||||||
submitLoading.value = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = (data) => {
|
const handleDelete = (data) => {
|
||||||
const typeName =
|
const typeName = data.type === 'hospital' ? '医院' : (data.type === 'department' ? '科室' : '小组');
|
||||||
data.type === 'hospital'
|
ElMessageBox.confirm(`确定要删除${typeName} "${data.name}" 吗?`, '警告', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
|
||||||
? '医院'
|
.then(async () => {
|
||||||
: data.type === 'department'
|
try {
|
||||||
? '科室'
|
if (data.type === 'hospital') await deleteHospital(data.id);
|
||||||
: '小组';
|
else if (data.type === 'department') await deleteDepartment(data.id);
|
||||||
ElMessageBox.confirm(`确定要删除${typeName} "${data.name}" 吗?`, '警告', {
|
else if (data.type === 'group') await deleteGroup(data.id);
|
||||||
confirmButtonText: '确定',
|
ElMessage.success('删除成功');
|
||||||
cancelButtonText: '取消',
|
if (activeNode.value && activeNode.value.key === data.key) {
|
||||||
type: 'warning',
|
activeNode.value = null; // Clear active node if deleted
|
||||||
})
|
|
||||||
.then(async () => {
|
|
||||||
try {
|
|
||||||
if (data.type === 'hospital') await deleteHospital(data.id);
|
|
||||||
else if (data.type === 'department') await deleteDepartment(data.id);
|
|
||||||
else if (data.type === 'group') await deleteGroup(data.id);
|
|
||||||
ElMessage.success('删除成功');
|
|
||||||
if (activeNode.value && activeNode.value.key === data.key) {
|
|
||||||
activeNode.value = null; // Clear active node if deleted
|
|
||||||
}
|
|
||||||
fetchTreeData();
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
}
|
||||||
})
|
fetchTreeData();
|
||||||
.catch(() => {});
|
} catch (error) { console.error(error); }
|
||||||
|
}).catch(() => {});
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => { fetchTreeData(); });
|
||||||
fetchTreeData();
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@ -1000,8 +715,7 @@ onMounted(() => {
|
|||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-card,
|
.tree-card, .detail-card {
|
||||||
.detail-card {
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
height: calc(100vh - 120px);
|
height: calc(100vh - 120px);
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -1116,19 +830,10 @@ onMounted(() => {
|
|||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.node-hospital .node-icon-wrapper {
|
.node-hospital .node-icon-wrapper { color: #409EFF; }
|
||||||
color: #409eff;
|
.node-department .node-icon-wrapper { color: #67C23A; }
|
||||||
}
|
.node-group .node-icon-wrapper { color: #E6A23C; }
|
||||||
.node-department .node-icon-wrapper {
|
.node-user .node-icon-wrapper { color: #909399; font-size: 14px; }
|
||||||
color: #67c23a;
|
|
||||||
}
|
|
||||||
.node-group .node-icon-wrapper {
|
|
||||||
color: #e6a23c;
|
|
||||||
}
|
|
||||||
.node-user .node-icon-wrapper {
|
|
||||||
color: #909399;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.node-label {
|
.node-label {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
|||||||
@ -11,7 +11,7 @@
|
|||||||
v-model="searchForm.hospitalId"
|
v-model="searchForm.hospitalId"
|
||||||
placeholder="系统管理员必须选择医院"
|
placeholder="系统管理员必须选择医院"
|
||||||
clearable
|
clearable
|
||||||
style="width: 240px"
|
style="width: 240px;"
|
||||||
@change="handleSearchHospitalChange"
|
@change="handleSearchHospitalChange"
|
||||||
>
|
>
|
||||||
<el-option
|
<el-option
|
||||||
@ -29,14 +29,17 @@
|
|||||||
clearable
|
clearable
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item label="设备 SN">
|
||||||
|
<el-input
|
||||||
|
v-model="searchForm.deviceSn"
|
||||||
|
placeholder="按设备 SN 过滤"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
<el-button type="primary" @click="handleSearch" icon="Search"
|
<el-button type="primary" @click="handleSearch" icon="Search">查询</el-button>
|
||||||
>查询</el-button
|
|
||||||
>
|
|
||||||
<el-button @click="resetSearch" icon="Refresh">重置</el-button>
|
<el-button @click="resetSearch" icon="Refresh">重置</el-button>
|
||||||
<el-button type="success" @click="openCreateDialog" icon="Plus"
|
<el-button type="success" @click="openCreateDialog" icon="Plus">新增患者</el-button>
|
||||||
>新增患者</el-button
|
|
||||||
>
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
</div>
|
</div>
|
||||||
@ -46,37 +49,37 @@
|
|||||||
type="warning"
|
type="warning"
|
||||||
:closable="false"
|
:closable="false"
|
||||||
title="系统管理员查询患者时必须先选择医院。"
|
title="系统管理员查询患者时必须先选择医院。"
|
||||||
style="margin-bottom: 16px"
|
style="margin-bottom: 16px;"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<el-table
|
<el-table :data="tableData" v-loading="loading" border stripe style="width: 100%">
|
||||||
:data="tableData"
|
|
||||||
v-loading="loading"
|
|
||||||
border
|
|
||||||
stripe
|
|
||||||
style="width: 100%"
|
|
||||||
>
|
|
||||||
<el-table-column prop="id" label="ID" width="80" align="center" />
|
<el-table-column prop="id" label="ID" width="80" align="center" />
|
||||||
<el-table-column prop="name" label="姓名" min-width="120" />
|
<el-table-column prop="name" label="姓名" min-width="120" />
|
||||||
<el-table-column prop="phone" label="手机号" min-width="140" />
|
<el-table-column prop="phone" label="手机号" min-width="140" />
|
||||||
<el-table-column prop="idCard" label="身份证号" min-width="200" />
|
<el-table-column prop="idCardHash" label="证件哈希" min-width="200" />
|
||||||
<el-table-column label="归属医院" min-width="160">
|
<el-table-column label="归属医院" min-width="160">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
{{ row.hospital?.name || '-' }}
|
{{ row.hospital?.name || '-' }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="归属医生" min-width="140">
|
<el-table-column label="归属人员" min-width="140">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
{{ row.doctor?.name || '-' }}
|
{{ row.doctor?.name || '-' }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column label="设备数" width="100" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.devices?.length || 0 }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="设备 SN" min-width="220">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatDeviceSn(row.devices) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="260" fixed="right" align="center">
|
<el-table-column label="操作" width="260" fixed="right" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button
|
<el-button size="small" type="primary" @click="openRecordDialog(row)">
|
||||||
size="small"
|
|
||||||
type="primary"
|
|
||||||
@click="openRecordDialog(row)"
|
|
||||||
>
|
|
||||||
详情
|
详情
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button size="small" @click="openEditDialog(row)">
|
<el-button size="small" @click="openEditDialog(row)">
|
||||||
@ -116,31 +119,30 @@
|
|||||||
<el-form-item label="手机号" prop="phone">
|
<el-form-item label="手机号" prop="phone">
|
||||||
<el-input v-model="form.phone" placeholder="请输入手机号" />
|
<el-input v-model="form.phone" placeholder="请输入手机号" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="身份证号" prop="idCard">
|
<el-form-item label="证件哈希" prop="idCardHash">
|
||||||
<el-input v-model="form.idCard" placeholder="请输入身份证号" />
|
<el-input v-model="form.idCardHash" placeholder="请输入证件哈希" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="归属医生" prop="doctorId">
|
<el-form-item label="归属人员" prop="doctorId">
|
||||||
<el-tree-select
|
<el-select
|
||||||
v-model="form.doctorId"
|
v-model="form.doctorId"
|
||||||
:data="doctorTreeOptions"
|
|
||||||
:props="doctorTreeProps"
|
|
||||||
check-strictly
|
|
||||||
filterable
|
filterable
|
||||||
clearable
|
placeholder="请选择归属人员(医生/主任/组长)"
|
||||||
placeholder="请选择归属医生(按科室/小组)"
|
style="width: 100%;"
|
||||||
style="width: 100%"
|
|
||||||
:disabled="userStore.role === 'DOCTOR'"
|
:disabled="userStore.role === 'DOCTOR'"
|
||||||
/>
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="doctor in doctorOptions"
|
||||||
|
:key="doctor.id"
|
||||||
|
:label="`${doctor.name}(${getRoleName(doctor.role)} / ${doctor.phone})`"
|
||||||
|
:value="doctor.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="dialog-footer">
|
<div class="dialog-footer">
|
||||||
<el-button @click="dialogVisible = false">取消</el-button>
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
<el-button
|
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">
|
||||||
type="primary"
|
|
||||||
:loading="submitLoading"
|
|
||||||
@click="handleSubmit"
|
|
||||||
>
|
|
||||||
确定
|
确定
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
@ -149,27 +151,13 @@
|
|||||||
|
|
||||||
<el-dialog title="调压记录详情" v-model="recordDialogVisible" width="860px">
|
<el-dialog title="调压记录详情" v-model="recordDialogVisible" width="860px">
|
||||||
<el-descriptions :column="4" border class="mb-16">
|
<el-descriptions :column="4" border class="mb-16">
|
||||||
<el-descriptions-item label="患者">{{
|
<el-descriptions-item label="患者">{{ currentPatientName || '-' }}</el-descriptions-item>
|
||||||
currentPatientName || '-'
|
<el-descriptions-item label="手机号">{{ recordSummary.phone || '-' }}</el-descriptions-item>
|
||||||
}}</el-descriptions-item>
|
<el-descriptions-item label="证件哈希">{{ recordSummary.idCardHash || '-' }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="手机号">{{
|
<el-descriptions-item label="记录数">{{ recordList.length }}</el-descriptions-item>
|
||||||
recordSummary.phone || '-'
|
|
||||||
}}</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="身份证号">{{
|
|
||||||
recordSummary.idCard || '-'
|
|
||||||
}}</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="记录数">{{
|
|
||||||
recordList.length
|
|
||||||
}}</el-descriptions-item>
|
|
||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
|
|
||||||
<el-table
|
<el-table :data="recordList" v-loading="recordLoading" border stripe max-height="520">
|
||||||
:data="recordList"
|
|
||||||
v-loading="recordLoading"
|
|
||||||
border
|
|
||||||
stripe
|
|
||||||
max-height="520"
|
|
||||||
>
|
|
||||||
<el-table-column label="时间" width="180">
|
<el-table-column label="时间" width="180">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
{{ new Date(row.occurredAt).toLocaleString() }}
|
{{ new Date(row.occurredAt).toLocaleString() }}
|
||||||
@ -187,8 +175,7 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="压力变更" min-width="140">
|
<el-table-column label="压力变更" min-width="140">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
{{ row.taskItem?.oldPressure ?? '-' }} ->
|
{{ row.taskItem?.oldPressure ?? '-' }} -> {{ row.taskItem?.targetPressure ?? '-' }}
|
||||||
{{ row.taskItem?.targetPressure ?? '-' }}
|
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="医院" min-width="140">
|
<el-table-column label="医院" min-width="140">
|
||||||
@ -212,7 +199,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { reactive, ref, onMounted, computed } from 'vue';
|
import { reactive, ref, onMounted } from 'vue';
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
import {
|
import {
|
||||||
getPatients,
|
getPatients,
|
||||||
@ -224,14 +211,13 @@ import {
|
|||||||
getPatientLifecycle,
|
getPatientLifecycle,
|
||||||
} from '../../api/patients';
|
} from '../../api/patients';
|
||||||
import { getHospitals } from '../../api/organization';
|
import { getHospitals } from '../../api/organization';
|
||||||
import { getDepartments, getGroups } from '../../api/organization';
|
|
||||||
import { useUserStore } from '../../store/user';
|
import { useUserStore } from '../../store/user';
|
||||||
|
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
|
|
||||||
const roleMap = {
|
const roleMap = {
|
||||||
DIRECTOR: '科室主任',
|
DIRECTOR: '科室主任',
|
||||||
LEADER: '小组组长',
|
LEADER: '医疗组长',
|
||||||
DOCTOR: '医生',
|
DOCTOR: '医生',
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -244,11 +230,10 @@ const total = ref(0);
|
|||||||
const page = ref(1);
|
const page = ref(1);
|
||||||
const pageSize = ref(10);
|
const pageSize = ref(10);
|
||||||
const hospitals = ref([]);
|
const hospitals = ref([]);
|
||||||
const departments = ref([]);
|
|
||||||
const groups = ref([]);
|
|
||||||
|
|
||||||
const searchForm = reactive({
|
const searchForm = reactive({
|
||||||
keyword: '',
|
keyword: '',
|
||||||
|
deviceSn: '',
|
||||||
hospitalId: null,
|
hospitalId: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -262,7 +247,7 @@ const doctorOptions = ref([]);
|
|||||||
const form = reactive({
|
const form = reactive({
|
||||||
name: '',
|
name: '',
|
||||||
phone: '',
|
phone: '',
|
||||||
idCard: '',
|
idCardHash: '',
|
||||||
doctorId: null,
|
doctorId: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -272,7 +257,7 @@ const rules = {
|
|||||||
{ required: true, message: '请输入手机号', trigger: 'blur' },
|
{ required: true, message: '请输入手机号', trigger: 'blur' },
|
||||||
{ pattern: /^1\d{10}$/, message: '请输入正确的手机号', trigger: 'blur' },
|
{ pattern: /^1\d{10}$/, message: '请输入正确的手机号', trigger: 'blur' },
|
||||||
],
|
],
|
||||||
idCard: [{ required: true, message: '请输入身份证号', trigger: 'blur' }],
|
idCardHash: [{ required: true, message: '请输入证件哈希', trigger: 'blur' }],
|
||||||
doctorId: [{ required: true, message: '请选择归属人员', trigger: 'change' }],
|
doctorId: [{ required: true, message: '请选择归属人员', trigger: 'change' }],
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -281,93 +266,30 @@ const recordLoading = ref(false);
|
|||||||
const currentPatientName = ref('');
|
const currentPatientName = ref('');
|
||||||
const recordSummary = reactive({
|
const recordSummary = reactive({
|
||||||
phone: '',
|
phone: '',
|
||||||
idCard: '',
|
idCardHash: '',
|
||||||
});
|
});
|
||||||
const recordList = ref([]);
|
const recordList = ref([]);
|
||||||
|
|
||||||
const doctorTreeProps = {
|
const formatDeviceSn = (devices = []) => {
|
||||||
value: 'value',
|
if (!Array.isArray(devices) || devices.length === 0) {
|
||||||
label: 'label',
|
return '-';
|
||||||
children: 'children',
|
}
|
||||||
disabled: 'disabled',
|
return devices.map((item) => item.snCode).join(',');
|
||||||
};
|
};
|
||||||
|
|
||||||
const departmentNameMap = computed(() => {
|
|
||||||
return Object.fromEntries(
|
|
||||||
(departments.value || []).map((item) => [item.id, item.name]),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const groupNameMap = computed(() => {
|
|
||||||
return Object.fromEntries(
|
|
||||||
(groups.value || []).map((item) => [item.id, item.name]),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const doctorTreeOptions = computed(() => {
|
|
||||||
const options = Array.isArray(doctorOptions.value) ? doctorOptions.value : [];
|
|
||||||
const deptMap = new Map();
|
|
||||||
|
|
||||||
options.forEach((doctor) => {
|
|
||||||
const deptId = doctor.departmentId ?? 0;
|
|
||||||
const groupId = doctor.groupId ?? 0;
|
|
||||||
const deptKey = `dept_${deptId}`;
|
|
||||||
const groupKey = `group_${groupId}`;
|
|
||||||
|
|
||||||
if (!deptMap.has(deptKey)) {
|
|
||||||
const deptLabel = deptId
|
|
||||||
? departmentNameMap.value[deptId] || `科室#${deptId}`
|
|
||||||
: '未分配科室';
|
|
||||||
deptMap.set(deptKey, {
|
|
||||||
value: deptKey,
|
|
||||||
label: deptLabel,
|
|
||||||
disabled: true,
|
|
||||||
children: [],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const deptNode = deptMap.get(deptKey);
|
|
||||||
|
|
||||||
if (groupId) {
|
|
||||||
let groupNode = deptNode.children.find((item) => item.value === groupKey);
|
|
||||||
if (!groupNode) {
|
|
||||||
groupNode = {
|
|
||||||
value: groupKey,
|
|
||||||
label: groupNameMap.value[groupId] || `小组#${groupId}`,
|
|
||||||
disabled: true,
|
|
||||||
children: [],
|
|
||||||
};
|
|
||||||
deptNode.children.push(groupNode);
|
|
||||||
}
|
|
||||||
|
|
||||||
groupNode.children.push({
|
|
||||||
value: doctor.id,
|
|
||||||
label: `${doctor.name}(${getRoleName(doctor.role)} / ${doctor.phone})`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
deptNode.children.push({
|
|
||||||
value: doctor.id,
|
|
||||||
label: `${doctor.name}(${getRoleName(doctor.role)} / ${doctor.phone})`,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return Array.from(deptMap.values()).sort((a, b) =>
|
|
||||||
String(a.label).localeCompare(String(b.label), 'zh-Hans-CN'),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const applyFiltersAndPagination = () => {
|
const applyFiltersAndPagination = () => {
|
||||||
const keyword = searchForm.keyword.trim();
|
const keyword = searchForm.keyword.trim();
|
||||||
|
const deviceSn = searchForm.deviceSn.trim();
|
||||||
|
|
||||||
const filtered = allPatients.value.filter((patient) => {
|
const filtered = allPatients.value.filter((patient) => {
|
||||||
const hitKeyword =
|
const hitKeyword = !keyword
|
||||||
!keyword ||
|
|| patient.name?.includes(keyword)
|
||||||
patient.name?.includes(keyword) ||
|
|| patient.phone?.includes(keyword);
|
||||||
patient.phone?.includes(keyword);
|
|
||||||
|
|
||||||
return hitKeyword;
|
const hitDevice = !deviceSn
|
||||||
|
|| (patient.devices || []).some((device) => device.snCode?.includes(deviceSn));
|
||||||
|
|
||||||
|
return hitKeyword && hitDevice;
|
||||||
});
|
});
|
||||||
|
|
||||||
total.value = filtered.length;
|
total.value = filtered.length;
|
||||||
@ -399,25 +321,6 @@ const fetchDoctorOptions = async () => {
|
|||||||
doctorOptions.value = Array.isArray(res) ? res : [];
|
doctorOptions.value = Array.isArray(res) ? res : [];
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchOrgNodesForDoctorTree = async () => {
|
|
||||||
const params = { pageSize: 100 };
|
|
||||||
if (userStore.role === 'SYSTEM_ADMIN') {
|
|
||||||
if (!searchForm.hospitalId) {
|
|
||||||
departments.value = [];
|
|
||||||
groups.value = [];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
params.hospitalId = searchForm.hospitalId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [deptRes, groupRes] = await Promise.all([
|
|
||||||
getDepartments(params),
|
|
||||||
getGroups(params),
|
|
||||||
]);
|
|
||||||
departments.value = Array.isArray(deptRes?.list) ? deptRes.list : [];
|
|
||||||
groups.value = Array.isArray(groupRes?.list) ? groupRes.list : [];
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
if (userStore.role === 'SYSTEM_ADMIN' && !searchForm.hospitalId) {
|
if (userStore.role === 'SYSTEM_ADMIN' && !searchForm.hospitalId) {
|
||||||
allPatients.value = [];
|
allPatients.value = [];
|
||||||
@ -432,8 +335,6 @@ const fetchData = async () => {
|
|||||||
if (userStore.role === 'SYSTEM_ADMIN') {
|
if (userStore.role === 'SYSTEM_ADMIN') {
|
||||||
params.hospitalId = searchForm.hospitalId;
|
params.hospitalId = searchForm.hospitalId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 后端当前返回数组,页面层自行做关键词筛选和本地分页。
|
|
||||||
const res = await getPatients(params);
|
const res = await getPatients(params);
|
||||||
allPatients.value = Array.isArray(res) ? res : [];
|
allPatients.value = Array.isArray(res) ? res : [];
|
||||||
applyFiltersAndPagination();
|
applyFiltersAndPagination();
|
||||||
@ -444,7 +345,6 @@ const fetchData = async () => {
|
|||||||
|
|
||||||
const handleSearchHospitalChange = async () => {
|
const handleSearchHospitalChange = async () => {
|
||||||
page.value = 1;
|
page.value = 1;
|
||||||
await fetchOrgNodesForDoctorTree();
|
|
||||||
await fetchDoctorOptions();
|
await fetchDoctorOptions();
|
||||||
await fetchData();
|
await fetchData();
|
||||||
};
|
};
|
||||||
@ -456,6 +356,7 @@ const handleSearch = () => {
|
|||||||
|
|
||||||
const resetSearch = () => {
|
const resetSearch = () => {
|
||||||
searchForm.keyword = '';
|
searchForm.keyword = '';
|
||||||
|
searchForm.deviceSn = '';
|
||||||
page.value = 1;
|
page.value = 1;
|
||||||
fetchData();
|
fetchData();
|
||||||
};
|
};
|
||||||
@ -464,7 +365,7 @@ const resetForm = () => {
|
|||||||
formRef.value?.resetFields();
|
formRef.value?.resetFields();
|
||||||
form.name = '';
|
form.name = '';
|
||||||
form.phone = '';
|
form.phone = '';
|
||||||
form.idCard = '';
|
form.idCardHash = '';
|
||||||
form.doctorId = null;
|
form.doctorId = null;
|
||||||
currentEditId.value = null;
|
currentEditId.value = null;
|
||||||
};
|
};
|
||||||
@ -472,7 +373,6 @@ const resetForm = () => {
|
|||||||
const openCreateDialog = async () => {
|
const openCreateDialog = async () => {
|
||||||
isEdit.value = false;
|
isEdit.value = false;
|
||||||
resetForm();
|
resetForm();
|
||||||
await fetchOrgNodesForDoctorTree();
|
|
||||||
await fetchDoctorOptions();
|
await fetchDoctorOptions();
|
||||||
|
|
||||||
if (userStore.role === 'DOCTOR') {
|
if (userStore.role === 'DOCTOR') {
|
||||||
@ -483,13 +383,12 @@ const openCreateDialog = async () => {
|
|||||||
|
|
||||||
const openEditDialog = async (row) => {
|
const openEditDialog = async (row) => {
|
||||||
isEdit.value = true;
|
isEdit.value = true;
|
||||||
await fetchOrgNodesForDoctorTree();
|
|
||||||
await fetchDoctorOptions();
|
await fetchDoctorOptions();
|
||||||
const detail = await getPatientById(row.id);
|
const detail = await getPatientById(row.id);
|
||||||
currentEditId.value = detail.id;
|
currentEditId.value = detail.id;
|
||||||
form.name = detail.name;
|
form.name = detail.name;
|
||||||
form.phone = detail.phone;
|
form.phone = detail.phone;
|
||||||
form.idCard = detail.idCard;
|
form.idCardHash = detail.idCardHash;
|
||||||
form.doctorId = detail.doctorId;
|
form.doctorId = detail.doctorId;
|
||||||
dialogVisible.value = true;
|
dialogVisible.value = true;
|
||||||
};
|
};
|
||||||
@ -501,11 +400,10 @@ const handleSubmit = async () => {
|
|||||||
|
|
||||||
submitLoading.value = true;
|
submitLoading.value = true;
|
||||||
try {
|
try {
|
||||||
// 身份字段统一改为原始身份证号,前后端都使用同一命名。
|
|
||||||
const payload = {
|
const payload = {
|
||||||
name: form.name,
|
name: form.name,
|
||||||
phone: form.phone,
|
phone: form.phone,
|
||||||
idCard: form.idCard,
|
idCardHash: form.idCardHash,
|
||||||
doctorId: form.doctorId,
|
doctorId: form.doctorId,
|
||||||
};
|
};
|
||||||
if (isEdit.value) {
|
if (isEdit.value) {
|
||||||
@ -524,11 +422,15 @@ const handleSubmit = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = (row) => {
|
const handleDelete = (row) => {
|
||||||
ElMessageBox.confirm(`确定要删除患者 "${row.name}" 吗?`, '警告', {
|
ElMessageBox.confirm(
|
||||||
confirmButtonText: '确定',
|
`确定要删除患者 "${row.name}" 吗?`,
|
||||||
cancelButtonText: '取消',
|
'警告',
|
||||||
type: 'warning',
|
{
|
||||||
})
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
},
|
||||||
|
)
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
await deletePatient(row.id);
|
await deletePatient(row.id);
|
||||||
ElMessage.success('删除成功');
|
ElMessage.success('删除成功');
|
||||||
@ -538,8 +440,8 @@ const handleDelete = (row) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openRecordDialog = async (row) => {
|
const openRecordDialog = async (row) => {
|
||||||
if (!row.phone || !row.idCard) {
|
if (!row.phone || !row.idCardHash) {
|
||||||
ElMessage.warning('缺少 phone 或 idCard,无法查询调压记录');
|
ElMessage.warning('缺少 phone 或 idCardHash,无法查询调压记录');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -547,17 +449,16 @@ const openRecordDialog = async (row) => {
|
|||||||
recordLoading.value = true;
|
recordLoading.value = true;
|
||||||
currentPatientName.value = row.name || '';
|
currentPatientName.value = row.name || '';
|
||||||
recordSummary.phone = '';
|
recordSummary.phone = '';
|
||||||
recordSummary.idCard = '';
|
recordSummary.idCardHash = '';
|
||||||
recordList.value = [];
|
recordList.value = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 该接口是跨院聚合接口,但当前后台页面只聚焦当前选中患者的记录展示。
|
|
||||||
const res = await getPatientLifecycle({
|
const res = await getPatientLifecycle({
|
||||||
phone: row.phone,
|
phone: row.phone,
|
||||||
idCard: row.idCard,
|
idCardHash: row.idCardHash,
|
||||||
});
|
});
|
||||||
recordSummary.phone = res.phone || '';
|
recordSummary.phone = res.phone || '';
|
||||||
recordSummary.idCard = res.idCard || '';
|
recordSummary.idCardHash = res.idCardHash || '';
|
||||||
const fullList = Array.isArray(res.lifecycle) ? res.lifecycle : [];
|
const fullList = Array.isArray(res.lifecycle) ? res.lifecycle : [];
|
||||||
recordList.value = fullList.filter((item) => item.patient?.id === row.id);
|
recordList.value = fullList.filter((item) => item.patient?.id === row.id);
|
||||||
} finally {
|
} finally {
|
||||||
@ -567,7 +468,6 @@ const openRecordDialog = async (row) => {
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await fetchHospitalsForAdmin();
|
await fetchHospitalsForAdmin();
|
||||||
await fetchOrgNodesForDoctorTree();
|
|
||||||
await fetchDoctorOptions();
|
await fetchDoctorOptions();
|
||||||
await fetchData();
|
await fetchData();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -10,18 +10,18 @@
|
|||||||
clearable
|
clearable
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item v-if="!isDirector" label="角色">
|
<el-form-item label="角色">
|
||||||
<el-select
|
<el-select
|
||||||
v-model="searchForm.role"
|
v-model="searchForm.role"
|
||||||
placeholder="请选择角色"
|
placeholder="请选择角色"
|
||||||
clearable
|
clearable
|
||||||
>
|
>
|
||||||
<el-option
|
<el-option label="系统管理员" value="SYSTEM_ADMIN" />
|
||||||
v-for="option in roleOptions"
|
<el-option label="医院管理员" value="HOSPITAL_ADMIN" />
|
||||||
:key="option.value"
|
<el-option label="科室主任" value="DIRECTOR" />
|
||||||
:label="option.label"
|
<el-option label="医疗组长" value="LEADER" />
|
||||||
:value="option.value"
|
<el-option label="医生" value="DOCTOR" />
|
||||||
/>
|
<el-option label="工程师" value="ENGINEER" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
@ -30,19 +30,13 @@
|
|||||||
</el-button>
|
</el-button>
|
||||||
<el-button @click="resetSearch" icon="Refresh">重置</el-button>
|
<el-button @click="resetSearch" icon="Refresh">重置</el-button>
|
||||||
<el-button type="success" @click="openCreateDialog" icon="Plus">
|
<el-button type="success" @click="openCreateDialog" icon="Plus">
|
||||||
{{ createButtonText }}
|
新增用户
|
||||||
</el-button>
|
</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table
|
<el-table :data="tableData" v-loading="loading" border stripe style="width: 100%">
|
||||||
:data="tableData"
|
|
||||||
v-loading="loading"
|
|
||||||
border
|
|
||||||
stripe
|
|
||||||
style="width: 100%"
|
|
||||||
>
|
|
||||||
<el-table-column prop="id" label="ID" width="80" align="center" />
|
<el-table-column prop="id" label="ID" width="80" align="center" />
|
||||||
<el-table-column prop="name" label="姓名" min-width="120" />
|
<el-table-column prop="name" label="姓名" min-width="120" />
|
||||||
<el-table-column prop="phone" label="手机号" min-width="150" />
|
<el-table-column prop="phone" label="手机号" min-width="150" />
|
||||||
@ -71,9 +65,7 @@
|
|||||||
<el-table-column label="操作" width="260" fixed="right" align="center">
|
<el-table-column label="操作" width="260" fixed="right" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button
|
<el-button
|
||||||
v-if="
|
v-if="row.role === 'ENGINEER' && userStore.role === 'SYSTEM_ADMIN'"
|
||||||
row.role === 'ENGINEER' && userStore.role === 'SYSTEM_ADMIN'
|
|
||||||
"
|
|
||||||
size="small"
|
size="small"
|
||||||
type="warning"
|
type="warning"
|
||||||
@click="openAssignDialog(row)"
|
@click="openAssignDialog(row)"
|
||||||
@ -84,7 +76,7 @@
|
|||||||
编辑
|
编辑
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="canDeleteUser(row)"
|
v-if="userStore.role === 'SYSTEM_ADMIN'"
|
||||||
size="small"
|
size="small"
|
||||||
type="danger"
|
type="danger"
|
||||||
@click="handleDelete(row)"
|
@click="handleDelete(row)"
|
||||||
@ -110,7 +102,7 @@
|
|||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<el-dialog
|
<el-dialog
|
||||||
:title="dialogTitle"
|
:title="isEdit ? '编辑用户' : '新增用户'"
|
||||||
v-model="dialogVisible"
|
v-model="dialogVisible"
|
||||||
width="620px"
|
width="620px"
|
||||||
@close="resetForm"
|
@close="resetForm"
|
||||||
@ -131,23 +123,13 @@
|
|||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="角色" prop="role">
|
<el-form-item label="角色" prop="role">
|
||||||
<el-input
|
<el-select v-model="form.role" placeholder="请选择角色" style="width: 100%;">
|
||||||
v-if="isDirector"
|
<el-option label="系统管理员" value="SYSTEM_ADMIN" />
|
||||||
:model-value="getRoleName('DOCTOR')"
|
<el-option label="医院管理员" value="HOSPITAL_ADMIN" />
|
||||||
disabled
|
<el-option label="科室主任" value="DIRECTOR" />
|
||||||
/>
|
<el-option label="医疗组长" value="LEADER" />
|
||||||
<el-select
|
<el-option label="医生" value="DOCTOR" />
|
||||||
v-else
|
<el-option label="工程师" value="ENGINEER" />
|
||||||
v-model="form.role"
|
|
||||||
placeholder="请选择角色"
|
|
||||||
style="width: 100%"
|
|
||||||
>
|
|
||||||
<el-option
|
|
||||||
v-for="option in roleOptions"
|
|
||||||
:key="option.value"
|
|
||||||
:label="option.label"
|
|
||||||
:value="option.value"
|
|
||||||
/>
|
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
@ -155,8 +137,7 @@
|
|||||||
<el-select
|
<el-select
|
||||||
v-model="form.hospitalId"
|
v-model="form.hospitalId"
|
||||||
placeholder="请选择医院"
|
placeholder="请选择医院"
|
||||||
style="width: 100%"
|
style="width: 100%;"
|
||||||
:disabled="lockHospital"
|
|
||||||
>
|
>
|
||||||
<el-option
|
<el-option
|
||||||
v-for="hospital in hospitals"
|
v-for="hospital in hospitals"
|
||||||
@ -167,16 +148,11 @@
|
|||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item
|
<el-form-item label="所属科室" prop="departmentId" v-if="needDepartment">
|
||||||
label="所属科室"
|
|
||||||
prop="departmentId"
|
|
||||||
v-if="needDepartment"
|
|
||||||
>
|
|
||||||
<el-select
|
<el-select
|
||||||
v-model="form.departmentId"
|
v-model="form.departmentId"
|
||||||
placeholder="请选择科室"
|
placeholder="请选择科室"
|
||||||
style="width: 100%"
|
style="width: 100%;"
|
||||||
:disabled="lockDepartment"
|
|
||||||
>
|
>
|
||||||
<el-option
|
<el-option
|
||||||
v-for="department in formDepartments"
|
v-for="department in formDepartments"
|
||||||
@ -191,7 +167,7 @@
|
|||||||
<el-select
|
<el-select
|
||||||
v-model="form.groupId"
|
v-model="form.groupId"
|
||||||
placeholder="请选择小组"
|
placeholder="请选择小组"
|
||||||
style="width: 100%"
|
style="width: 100%;"
|
||||||
>
|
>
|
||||||
<el-option
|
<el-option
|
||||||
v-for="group in formGroups"
|
v-for="group in formGroups"
|
||||||
@ -205,11 +181,7 @@
|
|||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="dialog-footer">
|
<div class="dialog-footer">
|
||||||
<el-button @click="dialogVisible = false">取消</el-button>
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
<el-button
|
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">
|
||||||
type="primary"
|
|
||||||
@click="handleSubmit"
|
|
||||||
:loading="submitLoading"
|
|
||||||
>
|
|
||||||
确定
|
确定
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
@ -229,7 +201,7 @@
|
|||||||
<el-select
|
<el-select
|
||||||
v-model="assignHospitalId"
|
v-model="assignHospitalId"
|
||||||
placeholder="请选择医院"
|
placeholder="请选择医院"
|
||||||
style="width: 100%"
|
style="width: 100%;"
|
||||||
>
|
>
|
||||||
<el-option
|
<el-option
|
||||||
v-for="hospital in hospitals"
|
v-for="hospital in hospitals"
|
||||||
@ -243,11 +215,7 @@
|
|||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="dialog-footer">
|
<div class="dialog-footer">
|
||||||
<el-button @click="assignDialogVisible = false">取消</el-button>
|
<el-button @click="assignDialogVisible = false">取消</el-button>
|
||||||
<el-button
|
<el-button type="primary" @click="handleAssignSubmit" :loading="submitLoading">
|
||||||
type="primary"
|
|
||||||
@click="handleAssignSubmit"
|
|
||||||
:loading="submitLoading"
|
|
||||||
>
|
|
||||||
确定
|
确定
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
@ -274,15 +242,6 @@ import {
|
|||||||
} from '../../api/organization';
|
} from '../../api/organization';
|
||||||
import { useUserStore } from '../../store/user';
|
import { useUserStore } from '../../store/user';
|
||||||
|
|
||||||
const roleOptions = [
|
|
||||||
{ label: '系统管理员', value: 'SYSTEM_ADMIN' },
|
|
||||||
{ label: '医院管理员', value: 'HOSPITAL_ADMIN' },
|
|
||||||
{ label: '科室主任', value: 'DIRECTOR' },
|
|
||||||
{ label: '小组组长', value: 'LEADER' },
|
|
||||||
{ label: '医生', value: 'DOCTOR' },
|
|
||||||
{ label: '工程师', value: 'ENGINEER' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
|
|
||||||
@ -296,10 +255,9 @@ const hospitals = ref([]);
|
|||||||
const departments = ref([]);
|
const departments = ref([]);
|
||||||
const groups = ref([]);
|
const groups = ref([]);
|
||||||
|
|
||||||
const isDirector = computed(() => userStore.role === 'DIRECTOR');
|
|
||||||
const searchForm = reactive({
|
const searchForm = reactive({
|
||||||
keyword: '',
|
keyword: '',
|
||||||
role: isDirector.value ? 'DOCTOR' : '',
|
role: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const dialogVisible = ref(false);
|
const dialogVisible = ref(false);
|
||||||
@ -324,24 +282,13 @@ const assignDialogVisible = ref(false);
|
|||||||
const currentAssignUser = ref(null);
|
const currentAssignUser = ref(null);
|
||||||
const assignHospitalId = ref(null);
|
const assignHospitalId = ref(null);
|
||||||
|
|
||||||
const createButtonText = computed(() =>
|
|
||||||
isDirector.value ? '新增医生' : '新增用户',
|
|
||||||
);
|
|
||||||
const dialogTitle = computed(() => {
|
|
||||||
if (isDirector.value) {
|
|
||||||
return isEdit.value ? '编辑医生' : '新增医生';
|
|
||||||
}
|
|
||||||
return isEdit.value ? '编辑用户' : '新增用户';
|
|
||||||
});
|
|
||||||
const needHospital = computed(() => form.role && form.role !== 'SYSTEM_ADMIN');
|
const needHospital = computed(() => form.role && form.role !== 'SYSTEM_ADMIN');
|
||||||
const needDepartment = computed(() =>
|
const needDepartment = computed(() =>
|
||||||
['DIRECTOR', 'LEADER', 'DOCTOR'].includes(form.role),
|
['DIRECTOR', 'LEADER', 'DOCTOR'].includes(form.role),
|
||||||
);
|
);
|
||||||
const needGroup = computed(() => ['LEADER', 'DOCTOR'].includes(form.role));
|
const needGroup = computed(() =>
|
||||||
const lockHospital = computed(() =>
|
['LEADER', 'DOCTOR'].includes(form.role),
|
||||||
['HOSPITAL_ADMIN', 'DIRECTOR'].includes(userStore.role),
|
|
||||||
);
|
);
|
||||||
const lockDepartment = computed(() => isDirector.value);
|
|
||||||
|
|
||||||
const rules = computed(() => ({
|
const rules = computed(() => ({
|
||||||
name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
|
name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
|
||||||
@ -352,9 +299,9 @@ const rules = computed(() => ({
|
|||||||
password: isEdit.value
|
password: isEdit.value
|
||||||
? []
|
? []
|
||||||
: [
|
: [
|
||||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||||
{ min: 8, message: '密码长度至少为 8 位', trigger: 'blur' },
|
{ min: 8, message: '密码长度至少为 8 位', trigger: 'blur' },
|
||||||
],
|
],
|
||||||
role: [{ required: true, message: '请选择角色', trigger: 'change' }],
|
role: [{ required: true, message: '请选择角色', trigger: 'change' }],
|
||||||
hospitalId: needHospital.value
|
hospitalId: needHospital.value
|
||||||
? [{ required: true, message: '请选择所属医院', trigger: 'change' }]
|
? [{ required: true, message: '请选择所属医院', trigger: 'change' }]
|
||||||
@ -371,7 +318,7 @@ const roleMap = {
|
|||||||
SYSTEM_ADMIN: '系统管理员',
|
SYSTEM_ADMIN: '系统管理员',
|
||||||
HOSPITAL_ADMIN: '医院管理员',
|
HOSPITAL_ADMIN: '医院管理员',
|
||||||
DIRECTOR: '科室主任',
|
DIRECTOR: '科室主任',
|
||||||
LEADER: '小组组长',
|
LEADER: '医疗组长',
|
||||||
DOCTOR: '医生',
|
DOCTOR: '医生',
|
||||||
ENGINEER: '工程师',
|
ENGINEER: '工程师',
|
||||||
};
|
};
|
||||||
@ -385,14 +332,14 @@ const getRoleTagType = (role) => {
|
|||||||
return 'info';
|
return 'info';
|
||||||
};
|
};
|
||||||
|
|
||||||
const hospitalMap = computed(
|
const hospitalMap = computed(() =>
|
||||||
() => new Map(hospitals.value.map((item) => [item.id, item.name])),
|
new Map(hospitals.value.map((item) => [item.id, item.name])),
|
||||||
);
|
);
|
||||||
const departmentMap = computed(
|
const departmentMap = computed(() =>
|
||||||
() => new Map(departments.value.map((item) => [item.id, item.name])),
|
new Map(departments.value.map((item) => [item.id, item.name])),
|
||||||
);
|
);
|
||||||
const groupMap = computed(
|
const groupMap = computed(() =>
|
||||||
() => new Map(groups.value.map((item) => [item.id, item.name])),
|
new Map(groups.value.map((item) => [item.id, item.name])),
|
||||||
);
|
);
|
||||||
|
|
||||||
const resolveHospitalName = (id) => {
|
const resolveHospitalName = (id) => {
|
||||||
@ -410,16 +357,6 @@ const resolveGroupName = (id) => {
|
|||||||
return groupMap.value.get(id) || `#${id}`;
|
return groupMap.value.get(id) || `#${id}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveDirectorScope = () => {
|
|
||||||
const hospitalId = userStore.userInfo?.hospitalId || null;
|
|
||||||
const departmentId = userStore.userInfo?.departmentId || null;
|
|
||||||
if (!hospitalId || !departmentId) {
|
|
||||||
ElMessage.error('当前主任账号缺少医院或科室归属');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return { hospitalId, departmentId };
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchCommonData = async () => {
|
const fetchCommonData = async () => {
|
||||||
const [hospitalRes, departmentRes, groupRes] = await Promise.all([
|
const [hospitalRes, departmentRes, groupRes] = await Promise.all([
|
||||||
getHospitals({ page: 1, pageSize: 100 }),
|
getHospitals({ page: 1, pageSize: 100 }),
|
||||||
@ -468,7 +405,7 @@ const fetchData = async () => {
|
|||||||
const res = await getUsers({
|
const res = await getUsers({
|
||||||
page: page.value,
|
page: page.value,
|
||||||
pageSize: pageSize.value,
|
pageSize: pageSize.value,
|
||||||
role: (isDirector.value ? 'DOCTOR' : searchForm.role) || undefined,
|
role: searchForm.role || undefined,
|
||||||
keyword: searchForm.keyword || undefined,
|
keyword: searchForm.keyword || undefined,
|
||||||
});
|
});
|
||||||
tableData.value = res.list || [];
|
tableData.value = res.list || [];
|
||||||
@ -485,29 +422,15 @@ const handleSearch = () => {
|
|||||||
|
|
||||||
const resetSearch = () => {
|
const resetSearch = () => {
|
||||||
searchForm.keyword = '';
|
searchForm.keyword = '';
|
||||||
searchForm.role = isDirector.value ? 'DOCTOR' : '';
|
searchForm.role = '';
|
||||||
page.value = 1;
|
page.value = 1;
|
||||||
fetchData();
|
fetchData();
|
||||||
};
|
};
|
||||||
|
|
||||||
const openCreateDialog = async () => {
|
const openCreateDialog = async () => {
|
||||||
resetForm();
|
|
||||||
isEdit.value = false;
|
isEdit.value = false;
|
||||||
currentId.value = null;
|
currentId.value = null;
|
||||||
|
dialogVisible.value = true;
|
||||||
if (isDirector.value) {
|
|
||||||
const directorScope = resolveDirectorScope();
|
|
||||||
if (!directorScope) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
form.role = 'DOCTOR';
|
|
||||||
form.hospitalId = directorScope.hospitalId;
|
|
||||||
await fetchDepartmentsForForm(form.hospitalId);
|
|
||||||
form.departmentId = directorScope.departmentId;
|
|
||||||
await fetchGroupsForForm(form.departmentId);
|
|
||||||
dialogVisible.value = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (userStore.role === 'HOSPITAL_ADMIN') {
|
if (userStore.role === 'HOSPITAL_ADMIN') {
|
||||||
form.hospitalId = userStore.userInfo?.hospitalId || null;
|
form.hospitalId = userStore.userInfo?.hospitalId || null;
|
||||||
@ -515,16 +438,9 @@ const openCreateDialog = async () => {
|
|||||||
await fetchDepartmentsForForm(form.hospitalId);
|
await fetchDepartmentsForForm(form.hospitalId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dialogVisible.value = true;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEditDialog = async (row) => {
|
const openEditDialog = async (row) => {
|
||||||
if (isDirector.value && row.role !== 'DOCTOR') {
|
|
||||||
ElMessage.warning('主任仅可编辑本科室医生');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isEdit.value = true;
|
isEdit.value = true;
|
||||||
currentId.value = row.id;
|
currentId.value = row.id;
|
||||||
|
|
||||||
@ -567,31 +483,6 @@ const resetForm = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const buildSubmitPayload = () => {
|
const buildSubmitPayload = () => {
|
||||||
if (isDirector.value) {
|
|
||||||
const directorScope = resolveDirectorScope();
|
|
||||||
if (!directorScope) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
name: form.name,
|
|
||||||
phone: form.phone,
|
|
||||||
role: 'DOCTOR',
|
|
||||||
hospitalId: directorScope.hospitalId,
|
|
||||||
departmentId: directorScope.departmentId,
|
|
||||||
groupId: form.groupId,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isEdit.value) {
|
|
||||||
return {
|
|
||||||
...payload,
|
|
||||||
password: form.password,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return payload;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
name: form.name,
|
name: form.name,
|
||||||
phone: form.phone,
|
phone: form.phone,
|
||||||
@ -662,9 +553,6 @@ const handleSubmit = async () => {
|
|||||||
submitLoading.value = true;
|
submitLoading.value = true;
|
||||||
try {
|
try {
|
||||||
const payload = buildSubmitPayload();
|
const payload = buildSubmitPayload();
|
||||||
if (!payload) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (isEdit.value) {
|
if (isEdit.value) {
|
||||||
await updateUser(currentId.value, payload);
|
await updateUser(currentId.value, payload);
|
||||||
ElMessage.success('更新成功');
|
ElMessage.success('更新成功');
|
||||||
@ -682,16 +570,15 @@ const handleSubmit = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = (row) => {
|
const handleDelete = (row) => {
|
||||||
if (isDirector.value && row.role !== 'DOCTOR') {
|
ElMessageBox.confirm(
|
||||||
ElMessage.warning('主任仅可删除本科室医生');
|
`确定要删除用户 "${row.name}" 吗?`,
|
||||||
return;
|
'警告',
|
||||||
}
|
{
|
||||||
|
confirmButtonText: '确定',
|
||||||
ElMessageBox.confirm(`确定要删除用户 "${row.name}" 吗?`, '警告', {
|
cancelButtonText: '取消',
|
||||||
confirmButtonText: '确定',
|
type: 'warning',
|
||||||
cancelButtonText: '取消',
|
},
|
||||||
type: 'warning',
|
)
|
||||||
})
|
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
await deleteUser(row.id);
|
await deleteUser(row.id);
|
||||||
ElMessage.success('删除成功');
|
ElMessage.success('删除成功');
|
||||||
@ -714,10 +601,7 @@ const handleAssignSubmit = async () => {
|
|||||||
|
|
||||||
submitLoading.value = true;
|
submitLoading.value = true;
|
||||||
try {
|
try {
|
||||||
await assignEngineerHospital(
|
await assignEngineerHospital(currentAssignUser.value.id, assignHospitalId.value);
|
||||||
currentAssignUser.value.id,
|
|
||||||
assignHospitalId.value,
|
|
||||||
);
|
|
||||||
ElMessage.success('分配成功');
|
ElMessage.success('分配成功');
|
||||||
assignDialogVisible.value = false;
|
assignDialogVisible.value = false;
|
||||||
await fetchCommonData();
|
await fetchCommonData();
|
||||||
@ -793,24 +677,16 @@ onMounted(async () => {
|
|||||||
|
|
||||||
if (route.query.action === 'create') {
|
if (route.query.action === 'create') {
|
||||||
await openCreateDialog();
|
await openCreateDialog();
|
||||||
if (!isDirector.value && route.query.hospitalId) {
|
if (route.query.hospitalId) {
|
||||||
form.hospitalId = Number(route.query.hospitalId);
|
form.hospitalId = Number(route.query.hospitalId);
|
||||||
await fetchDepartmentsForForm(form.hospitalId);
|
await fetchDepartmentsForForm(form.hospitalId);
|
||||||
}
|
}
|
||||||
if (!isDirector.value && route.query.departmentId) {
|
if (route.query.departmentId) {
|
||||||
form.departmentId = Number(route.query.departmentId);
|
form.departmentId = Number(route.query.departmentId);
|
||||||
await fetchGroupsForForm(form.departmentId);
|
await fetchGroupsForForm(form.departmentId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const canDeleteUser = (row) => {
|
|
||||||
if (userStore.role === 'SYSTEM_ADMIN') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return isDirector.value && row.role === 'DOCTOR';
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user