新增 B 端设备模块(后端 CRUD、分页筛选、权限隔离)并接入前端设备管理页面与路由菜单
鉴权改为登录态回库校验,新增 tokenValidAfter 失效时间,支持密码变更与 seed 重置后旧 token 立即失效 患者字段由 idCardHash 统一迁移为 idCard,新增身份证标准化逻辑并同步 C 端生命周期查询参数 组织模块增加小组删除限制(有成员时返回 409)并补充中文错误消息 任务取消接口支持可选 reason 字段(先透传事件层) 补齐 Prisma 迁移、文档说明和 E2E 用例(含设备模块与 token 失效场景)
This commit is contained in:
parent
5fdf4c80e6
commit
6ec2d0b0e0
16
docs/auth.md
16
docs/auth.md
@ -14,14 +14,17 @@
|
||||
## 3. 鉴权流程
|
||||
|
||||
1. `AccessTokenGuard` 从 `Authorization` 读取 Bearer Token。
|
||||
2. 校验 JWT 签名与载荷字段。
|
||||
3. 载荷映射为 `ActorContext` 注入 `request.actor`。
|
||||
4. `RolesGuard` 根据 `@Roles(...)` 判断角色是否允许访问。
|
||||
2. 校验 JWT 签名、`id`、`iat` 等关键载荷字段。
|
||||
3. 根据 `id` 回库读取用户当前角色与组织归属,不再直接信任 token 里的角色和范围。
|
||||
4. 校验 `iat >= user.tokenValidAfter`,若用户被重置密码、seed 重刷或账号被清理,则旧 token 立即失效。
|
||||
5. 当前数据库用户映射为 `ActorContext` 注入 `request.actor`。
|
||||
6. `RolesGuard` 根据 `@Roles(...)` 判断角色是否允许访问。
|
||||
|
||||
## 4. Token 约定
|
||||
|
||||
- Header:`Authorization: Bearer <token>`
|
||||
- 载荷关键字段:`id`、`role`、`hospitalId`、`departmentId`、`groupId`
|
||||
- 载荷关键字段:`id`、`iat`
|
||||
- 角色和组织范围以数据库当前用户记录为准,不以 token 历史载荷为准
|
||||
|
||||
## 5. 错误码与中文消息
|
||||
|
||||
@ -30,3 +33,8 @@
|
||||
- 参数非法:`400` + 中文 `msg`
|
||||
|
||||
统一由全局异常过滤器输出:`{ code, msg, data: null }`。
|
||||
|
||||
## 6. 失效策略
|
||||
|
||||
- 用户密码被修改后,会刷新 `user.tokenValidAfter`,旧 token 全部失效。
|
||||
- 执行 E2E 重置并重新 seed 后,seed 账号的 `tokenValidAfter` 也会刷新,历史 token 不可继续复用。
|
||||
|
||||
27
docs/devices.md
Normal file
27
docs/devices.md
Normal file
@ -0,0 +1,27 @@
|
||||
# 设备模块说明(`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,6 +14,7 @@
|
||||
2. `node prisma/seed.mjs`
|
||||
|
||||
这会清空 `.env` 中 `DATABASE_URL` 指向数据库的全部数据,请仅在测试库执行。
|
||||
另外,seed 账号会刷新 `tokenValidAfter`,所以重置前签发的旧 token 会全部失效,需要重新登录获取新 token。
|
||||
|
||||
## 3. 运行命令
|
||||
|
||||
|
||||
@ -4,16 +4,21 @@
|
||||
|
||||
- 登录页:`/auth/login`,支持可选 `hospitalId`。
|
||||
- 首页看板:按角色拉取组织与患者统计。
|
||||
- 设备页:新增管理员专用设备 CRUD,复用真实设备接口。
|
||||
- 任务页:接入 `publish/accept/complete/cancel` 四个真实任务接口。
|
||||
- 用户页:修复用户列表响应结构、组织字段联动、工程师分配医院参数。
|
||||
- 患者页:接入真实患者字段与生命周期查询参数(`phone + idCardHash`)。
|
||||
- 患者页:接入真实患者字段与生命周期查询参数(`phone + idCard`),
|
||||
后端直接保存身份证号原文,不再做哈希转换。
|
||||
|
||||
## 2. 接口契约对齐点
|
||||
|
||||
- `GET /users` 当前返回数组,前端已在 `api/users.js` 做本地分页与筛选适配。
|
||||
- `PATCH /b/users/:id/assign-engineer-hospital` 参数为单个 `hospitalId`,非数组。
|
||||
- `GET /b/patients` 返回数组,前端已改为本地分页与筛选。
|
||||
- `GET /c/patients/lifecycle` 必须同时传 `phone` 和 `idCardHash`。
|
||||
- `GET /b/devices` 已支持服务端分页与筛选,前端直接透传 `page/pageSize`。
|
||||
- `GET /c/patients/lifecycle` 必须同时传 `phone` 和 `idCard`。
|
||||
- 患者表单中的 `idCard` 字段直接传身份证号;
|
||||
服务端只会做去空格与 `x/X` 标准化,不会转哈希。
|
||||
- 任务模块暂无任务列表接口,前端改为“表单操作 + 最近结果”模式。
|
||||
|
||||
## 3. 角色权限提示
|
||||
@ -37,13 +42,14 @@
|
||||
- `organization/tree`、`organization/departments`、`organization/groups`、`users`
|
||||
- `organization/tree`、`organization/departments`、`organization/groups`:
|
||||
`SYSTEM_ADMIN`、`HOSPITAL_ADMIN`、`DIRECTOR`、`LEADER` 可访问
|
||||
- `users`:仅 `SYSTEM_ADMIN`、`HOSPITAL_ADMIN` 可访问
|
||||
- `users`:仅 `SYSTEM_ADMIN`、`HOSPITAL_ADMIN` 可访问
|
||||
- `devices`:仅 `SYSTEM_ADMIN`、`HOSPITAL_ADMIN` 可访问
|
||||
- `organization/hospitals`
|
||||
- 仅 `SYSTEM_ADMIN` 可访问
|
||||
- `tasks`
|
||||
- 仅 `DOCTOR`、`DIRECTOR`、`LEADER`、`ENGINEER` 可访问
|
||||
- `patients`
|
||||
- 仅 `SYSTEM_ADMIN`、`HOSPITAL_ADMIN`、`DIRECTOR`、`LEADER` 可访问
|
||||
- `SYSTEM_ADMIN`、`HOSPITAL_ADMIN`、`DIRECTOR`、`LEADER`、`DOCTOR` 可访问
|
||||
|
||||
前端已在路由守卫和侧边栏菜单同时做权限控制,无权限角色会被拦截并跳转到首页,避免进入页面后触发接口 `403`。
|
||||
|
||||
|
||||
@ -3,7 +3,9 @@
|
||||
## 1. 目标
|
||||
|
||||
- B 端:按组织与角色范围查询患者(强依赖 `hospitalId`)。
|
||||
- C 端:按 `phone + idCardHash` 做跨院聚合查询。
|
||||
- C 端:按 `phone + idCard` 做跨院聚合查询。
|
||||
- 患者档案直接保存身份证号原文,不再做哈希转换。
|
||||
- 服务端只做轻量格式整理:去空格、统一末尾 `x/X` 为大写。
|
||||
|
||||
## 2. B 端可见性
|
||||
|
||||
@ -28,12 +30,12 @@
|
||||
|
||||
## 3. C 端生命周期聚合
|
||||
|
||||
接口:`GET /c/patients/lifecycle?phone=...&idCardHash=...`
|
||||
接口:`GET /c/patients/lifecycle?phone=...&idCard=...`
|
||||
|
||||
查询策略:
|
||||
|
||||
1. 不做医院隔离(跨租户)
|
||||
2. 双字段精确匹配 `phone + idCardHash`
|
||||
2. 先将 `idCard` 做轻量标准化,再做双字段精确匹配
|
||||
3. 关联查询 `Patient -> Device -> TaskItem -> Task`
|
||||
4. 返回扁平生命周期列表(按 `Task.createdAt DESC`)
|
||||
|
||||
|
||||
@ -18,6 +18,11 @@
|
||||
- 工程师:接收任务、完成自己接收的任务
|
||||
- 其他角色:默认拒绝
|
||||
|
||||
补充:
|
||||
|
||||
- `POST /b/tasks/cancel` 现支持可选 `reason` 字段,便于前端保留取消原因输入。
|
||||
- 当前取消原因仅透传到事件层,数据库暂未持久化该字段。
|
||||
|
||||
## 4. 事件触发
|
||||
|
||||
状态变化后会发出事件:
|
||||
|
||||
8
prisma/migrations/20260318100229/migration.sql
Normal file
8
prisma/migrations/20260318100229/migration.sql
Normal file
@ -0,0 +1,8 @@
|
||||
/*
|
||||
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");
|
||||
@ -0,0 +1,2 @@
|
||||
ALTER TABLE "User"
|
||||
ADD COLUMN "tokenValidAfter" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||
@ -0,0 +1,5 @@
|
||||
ALTER TABLE "Patient"
|
||||
RENAME COLUMN "idCardHash" TO "idCard";
|
||||
|
||||
ALTER INDEX "Patient_phone_idCardHash_idx"
|
||||
RENAME TO "Patient_phone_idCard_idx";
|
||||
@ -0,0 +1,8 @@
|
||||
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,22 +71,25 @@ model Group {
|
||||
|
||||
// 用户表:支持后台密码登录与小程序 openId。
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
phone String
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
phone String
|
||||
// 后台登录密码哈希(bcrypt)。
|
||||
passwordHash String?
|
||||
openId String? @unique
|
||||
role Role
|
||||
hospitalId Int?
|
||||
departmentId Int?
|
||||
groupId Int?
|
||||
hospital Hospital? @relation(fields: [hospitalId], references: [id])
|
||||
department Department? @relation(fields: [departmentId], references: [id])
|
||||
group Group? @relation(fields: [groupId], references: [id])
|
||||
doctorPatients Patient[] @relation("DoctorPatients")
|
||||
createdTasks Task[] @relation("TaskCreator")
|
||||
acceptedTasks Task[] @relation("TaskEngineer")
|
||||
passwordHash String?
|
||||
// 该时间点之前签发的 token 一律失效。
|
||||
tokenValidAfter DateTime @default(now())
|
||||
openId String? @unique
|
||||
role Role
|
||||
hospitalId Int?
|
||||
departmentId Int?
|
||||
groupId Int?
|
||||
hospital Hospital? @relation(fields: [hospitalId], references: [id])
|
||||
department Department? @relation(fields: [departmentId], references: [id])
|
||||
// 小组删除必须先清理成员,避免静默把用户 groupId 置空。
|
||||
group Group? @relation(fields: [groupId], references: [id], onDelete: Restrict)
|
||||
doctorPatients Patient[] @relation("DoctorPatients")
|
||||
createdTasks Task[] @relation("TaskCreator")
|
||||
acceptedTasks Task[] @relation("TaskEngineer")
|
||||
|
||||
@@unique([phone, role, hospitalId])
|
||||
@@index([phone])
|
||||
@ -100,14 +103,15 @@ model Patient {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
phone String
|
||||
idCardHash String
|
||||
// 患者身份证号,录入与查询都使用原始证件号。
|
||||
idCard String
|
||||
hospitalId Int
|
||||
doctorId Int
|
||||
hospital Hospital @relation(fields: [hospitalId], references: [id])
|
||||
doctor User @relation("DoctorPatients", fields: [doctorId], references: [id])
|
||||
devices Device[]
|
||||
|
||||
@@index([phone, idCardHash])
|
||||
@@index([phone, idCard])
|
||||
@@index([hospitalId, doctorId])
|
||||
}
|
||||
|
||||
|
||||
@ -48,7 +48,11 @@ async function ensureGroup(departmentId, name) {
|
||||
async function upsertUserByOpenId(openId, data) {
|
||||
return prisma.user.upsert({
|
||||
where: { openId },
|
||||
update: data,
|
||||
// 每次重置/补种子时推进失效时间,确保历史 token 无法继续访问。
|
||||
update: {
|
||||
...data,
|
||||
tokenValidAfter: new Date(),
|
||||
},
|
||||
create: {
|
||||
...data,
|
||||
openId,
|
||||
@ -56,18 +60,12 @@ async function upsertUserByOpenId(openId, data) {
|
||||
});
|
||||
}
|
||||
|
||||
async function ensurePatient({
|
||||
hospitalId,
|
||||
doctorId,
|
||||
name,
|
||||
phone,
|
||||
idCardHash,
|
||||
}) {
|
||||
async function ensurePatient({ hospitalId, doctorId, name, phone, idCard }) {
|
||||
const existing = await prisma.patient.findFirst({
|
||||
where: {
|
||||
hospitalId,
|
||||
phone,
|
||||
idCardHash,
|
||||
idCard,
|
||||
},
|
||||
});
|
||||
|
||||
@ -87,7 +85,7 @@ async function ensurePatient({
|
||||
doctorId,
|
||||
name,
|
||||
phone,
|
||||
idCardHash,
|
||||
idCard,
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -224,7 +222,7 @@ async function main() {
|
||||
doctorId: doctorA.id,
|
||||
name: 'Seed Patient A1',
|
||||
phone: '13800002001',
|
||||
idCardHash: 'seed-id-card-cross-hospital',
|
||||
idCard: '110101199001010011',
|
||||
});
|
||||
|
||||
const patientA2 = await ensurePatient({
|
||||
@ -232,7 +230,7 @@ async function main() {
|
||||
doctorId: doctorA2.id,
|
||||
name: 'Seed Patient A2',
|
||||
phone: '13800002002',
|
||||
idCardHash: 'seed-id-card-a2',
|
||||
idCard: '110101199002020022',
|
||||
});
|
||||
|
||||
const patientA3 = await ensurePatient({
|
||||
@ -240,7 +238,7 @@ async function main() {
|
||||
doctorId: doctorA3.id,
|
||||
name: 'Seed Patient A3',
|
||||
phone: '13800002003',
|
||||
idCardHash: 'seed-id-card-a3',
|
||||
idCard: '110101199003030033',
|
||||
});
|
||||
|
||||
const patientB1 = await ensurePatient({
|
||||
@ -248,7 +246,7 @@ async function main() {
|
||||
doctorId: doctorB.id,
|
||||
name: 'Seed Patient B1',
|
||||
phone: '13800002001',
|
||||
idCardHash: 'seed-id-card-cross-hospital',
|
||||
idCard: '110101199001010011',
|
||||
});
|
||||
|
||||
const deviceA1 = await prisma.device.upsert({
|
||||
|
||||
@ -7,6 +7,7 @@ import { PatientsModule } from './patients/patients.module.js';
|
||||
import { AuthModule } from './auth/auth.module.js';
|
||||
import { OrganizationModule } from './organization/organization.module.js';
|
||||
import { NotificationsModule } from './notifications/notifications.module.js';
|
||||
import { DevicesModule } from './devices/devices.module.js';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@ -18,6 +19,7 @@ import { NotificationsModule } from './notifications/notifications.module.js';
|
||||
AuthModule,
|
||||
OrganizationModule,
|
||||
NotificationsModule,
|
||||
DevicesModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@ -5,25 +5,25 @@ import {
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { 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';
|
||||
|
||||
/**
|
||||
* AccessToken 守卫:校验 Bearer JWT 并把 actor 注入到 request 上下文。
|
||||
*/
|
||||
@Injectable()
|
||||
export class AccessTokenGuard implements CanActivate {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* 守卫入口:认证通过返回 true,失败抛出 401。
|
||||
*/
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest<
|
||||
{
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
actor?: unknown;
|
||||
}
|
||||
>();
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<{
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
actor?: unknown;
|
||||
}>();
|
||||
|
||||
const authorization = request.headers.authorization;
|
||||
const headerValue = Array.isArray(authorization)
|
||||
@ -35,15 +35,15 @@ export class AccessTokenGuard implements CanActivate {
|
||||
}
|
||||
|
||||
const token = headerValue.slice('Bearer '.length).trim();
|
||||
request.actor = this.verifyAndExtractActor(token);
|
||||
request.actor = await this.verifyAndExtractActor(token);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析并验证 token,同时提取最小化 actor 上下文。
|
||||
* 解析并验证 token,同时回库确认当前用户仍然有效。
|
||||
*/
|
||||
private verifyAndExtractActor(token: string): ActorContext {
|
||||
private async verifyAndExtractActor(token: string): Promise<ActorContext> {
|
||||
const secret = process.env.AUTH_TOKEN_SECRET;
|
||||
if (!secret) {
|
||||
throw new UnauthorizedException(MESSAGES.AUTH.TOKEN_SECRET_MISSING);
|
||||
@ -63,17 +63,39 @@ export class AccessTokenGuard implements CanActivate {
|
||||
throw new UnauthorizedException(MESSAGES.AUTH.TOKEN_PAYLOAD_INVALID);
|
||||
}
|
||||
|
||||
const role = payload.role;
|
||||
if (typeof role !== 'string' || !Object.values(Role).includes(role as Role)) {
|
||||
throw new UnauthorizedException(MESSAGES.AUTH.TOKEN_ROLE_INVALID);
|
||||
const userId = this.asInt(payload.id, 'id');
|
||||
const issuedAt = this.asInt(payload.iat, 'iat');
|
||||
const user = await this.prisma.user.findUnique({
|
||||
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 {
|
||||
id: this.asInt(payload.id, 'id'),
|
||||
role: role as Role,
|
||||
hospitalId: this.asNullableInt(payload.hospitalId, 'hospitalId'),
|
||||
departmentId: this.asNullableInt(payload.departmentId, 'departmentId'),
|
||||
groupId: this.asNullableInt(payload.groupId, 'groupId'),
|
||||
id: user.id,
|
||||
role: user.role,
|
||||
hospitalId: user.hospitalId,
|
||||
departmentId: user.departmentId,
|
||||
groupId: user.groupId,
|
||||
};
|
||||
}
|
||||
|
||||
@ -82,20 +104,9 @@ export class AccessTokenGuard implements CanActivate {
|
||||
*/
|
||||
private asInt(value: unknown, field: string): number {
|
||||
if (typeof value !== 'number' || !Number.isInteger(value)) {
|
||||
throw new UnauthorizedException(`${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}`);
|
||||
throw new UnauthorizedException(
|
||||
`${MESSAGES.AUTH.TOKEN_FIELD_INVALID}: ${field}`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
@ -21,6 +21,8 @@ export const MESSAGES = {
|
||||
TOKEN_SECRET_MISSING: '服务端未配置认证密钥',
|
||||
TOKEN_INVALID: 'Token 无效或已过期',
|
||||
TOKEN_PAYLOAD_INVALID: 'Token 载荷不合法',
|
||||
TOKEN_USER_NOT_FOUND: 'Token 对应用户不存在,请重新登录',
|
||||
TOKEN_REVOKED: 'Token 已失效,请重新登录',
|
||||
TOKEN_ROLE_INVALID: 'Token 中角色信息不合法',
|
||||
TOKEN_FIELD_INVALID: 'Token 中字段不合法',
|
||||
INVALID_CREDENTIALS: '手机号、角色或密码错误',
|
||||
@ -82,12 +84,25 @@ export const MESSAGES = {
|
||||
DOCTOR_ROLE_REQUIRED: '归属用户必须为医生/主任/组长角色',
|
||||
DOCTOR_SCOPE_FORBIDDEN: '仅可选择当前权限范围内医生/主任/组长',
|
||||
DELETE_CONFLICT: '患者存在关联设备,无法删除',
|
||||
PHONE_IDCARD_REQUIRED: 'phone 与 idCardHash 均为必填',
|
||||
LIFE_CYCLE_NOT_FOUND: '未找到匹配的患者档案,请先确认手机号与身份证哈希',
|
||||
PHONE_IDCARD_REQUIRED: 'phone 与 idCard 均为必填',
|
||||
LIFE_CYCLE_NOT_FOUND: '未找到匹配的患者档案,请先确认手机号与身份证号',
|
||||
SYSTEM_ADMIN_HOSPITAL_REQUIRED: '系统管理员查询必须显式传入 hospitalId',
|
||||
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: {
|
||||
HOSPITAL_NOT_FOUND: '医院不存在',
|
||||
DEPARTMENT_NOT_FOUND: '科室不存在',
|
||||
@ -108,6 +123,7 @@ export const MESSAGES = {
|
||||
GROUP_DEPARTMENT_MISMATCH: '小组不属于指定科室',
|
||||
DEPARTMENT_REPARENT_FORBIDDEN: '科室不允许更换所属医院',
|
||||
GROUP_REPARENT_FORBIDDEN: '小组不允许更换所属科室',
|
||||
GROUP_DELETE_HAS_USERS: '小组下仍有成员,无法删除,请先调整用户归属',
|
||||
DELETE_CONFLICT:
|
||||
'存在关联数据,无法删除,请先清理用户、患者、任务或下级组织后重试',
|
||||
},
|
||||
|
||||
102
src/devices/b-devices/b-devices.controller.ts
Normal file
102
src/devices/b-devices/b-devices.controller.ts
Normal file
@ -0,0 +1,102 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
12
src/devices/devices.module.ts
Normal file
12
src/devices/devices.module.ts
Normal file
@ -0,0 +1,12 @@
|
||||
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 {}
|
||||
403
src/devices/devices.service.ts
Normal file
403
src/devices/devices.service.ts
Normal file
@ -0,0 +1,403 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
34
src/devices/dto/create-device.dto.ts
Normal file
34
src/devices/dto/create-device.dto.ts
Normal file
@ -0,0 +1,34 @@
|
||||
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;
|
||||
}
|
||||
68
src/devices/dto/device-query.dto.ts
Normal file
68
src/devices/dto/device-query.dto.ts
Normal file
@ -0,0 +1,68 @@
|
||||
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;
|
||||
}
|
||||
7
src/devices/dto/update-device.dto.ts
Normal file
7
src/devices/dto/update-device.dto.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateDeviceDto } from './create-device.dto.js';
|
||||
|
||||
/**
|
||||
* 更新设备 DTO。
|
||||
*/
|
||||
export class UpdateDeviceDto extends PartialType(CreateDeviceDto) {}
|
||||
@ -1,5 +1,6 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
@ -33,7 +34,10 @@ export class GroupsService {
|
||||
Role.HOSPITAL_ADMIN,
|
||||
Role.DIRECTOR,
|
||||
]);
|
||||
const departmentId = this.access.toInt(dto.departmentId, MESSAGES.ORG.DEPARTMENT_ID_REQUIRED);
|
||||
const departmentId = this.access.toInt(
|
||||
dto.departmentId,
|
||||
MESSAGES.ORG.DEPARTMENT_ID_REQUIRED,
|
||||
);
|
||||
const department = await this.access.ensureDepartmentExists(departmentId);
|
||||
if (actor.role === Role.HOSPITAL_ADMIN) {
|
||||
this.access.assertHospitalScope(actor, department.hospitalId);
|
||||
@ -47,7 +51,10 @@ export class GroupsService {
|
||||
|
||||
return this.prisma.group.create({
|
||||
data: {
|
||||
name: this.access.normalizeName(dto.name, MESSAGES.ORG.GROUP_NAME_REQUIRED),
|
||||
name: this.access.normalizeName(
|
||||
dto.name,
|
||||
MESSAGES.ORG.GROUP_NAME_REQUIRED,
|
||||
),
|
||||
departmentId,
|
||||
},
|
||||
});
|
||||
@ -70,18 +77,26 @@ export class GroupsService {
|
||||
where.name = { contains: query.keyword.trim(), mode: 'insensitive' };
|
||||
}
|
||||
if (query.departmentId != null) {
|
||||
where.departmentId = this.access.toInt(query.departmentId, MESSAGES.ORG.DEPARTMENT_ID_REQUIRED);
|
||||
where.departmentId = this.access.toInt(
|
||||
query.departmentId,
|
||||
MESSAGES.ORG.DEPARTMENT_ID_REQUIRED,
|
||||
);
|
||||
}
|
||||
|
||||
if (actor.role === Role.HOSPITAL_ADMIN) {
|
||||
where.department = { hospitalId: this.access.requireActorHospitalId(actor) };
|
||||
where.department = {
|
||||
hospitalId: this.access.requireActorHospitalId(actor),
|
||||
};
|
||||
} else if (actor.role === Role.DIRECTOR) {
|
||||
where.departmentId = this.access.requireActorDepartmentId(actor);
|
||||
} else if (actor.role === Role.LEADER) {
|
||||
where.id = this.access.requireActorGroupId(actor);
|
||||
} else if (query.hospitalId != null) {
|
||||
where.department = {
|
||||
hospitalId: this.access.toInt(query.hospitalId, MESSAGES.ORG.HOSPITAL_ID_REQUIRED),
|
||||
hospitalId: this.access.toInt(
|
||||
query.hospitalId,
|
||||
MESSAGES.ORG.HOSPITAL_ID_REQUIRED,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@ -153,7 +168,10 @@ export class GroupsService {
|
||||
}
|
||||
|
||||
if (dto.name !== undefined) {
|
||||
data.name = this.access.normalizeName(dto.name, MESSAGES.ORG.GROUP_NAME_REQUIRED);
|
||||
data.name = this.access.normalizeName(
|
||||
dto.name,
|
||||
MESSAGES.ORG.GROUP_NAME_REQUIRED,
|
||||
);
|
||||
}
|
||||
|
||||
return this.prisma.group.update({
|
||||
@ -172,6 +190,12 @@ export class GroupsService {
|
||||
Role.DIRECTOR,
|
||||
]);
|
||||
const current = await this.findOne(actor, id);
|
||||
|
||||
// 业务层先拦截,给前端稳定中文提示;数据库层仍保留 RESTRICT 兜底。
|
||||
if (current._count.users > 0) {
|
||||
throw new ConflictException(MESSAGES.ORG.GROUP_DELETE_HAS_USERS);
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.prisma.group.delete({ where: { id: current.id } });
|
||||
} catch (error) {
|
||||
|
||||
@ -12,6 +12,7 @@ import type { ActorContext } from '../../common/actor-context.js';
|
||||
import { MESSAGES } from '../../common/messages.js';
|
||||
import { CreatePatientDto } from '../dto/create-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];
|
||||
|
||||
@ -98,7 +99,8 @@ export class BPatientsService {
|
||||
data: {
|
||||
name: this.normalizeRequiredString(dto.name, 'name'),
|
||||
phone: this.normalizePhone(dto.phone),
|
||||
idCardHash: this.normalizeRequiredString(dto.idCardHash, 'idCardHash'),
|
||||
// 身份证统一做轻量标准化后落库,数据库中保存原始证件号而不是哈希。
|
||||
idCard: this.normalizeIdCard(dto.idCard),
|
||||
hospitalId: doctor.hospitalId!,
|
||||
doctorId: doctor.id,
|
||||
},
|
||||
@ -133,8 +135,9 @@ export class BPatientsService {
|
||||
if (dto.phone !== undefined) {
|
||||
data.phone = this.normalizePhone(dto.phone);
|
||||
}
|
||||
if (dto.idCardHash !== undefined) {
|
||||
data.idCardHash = this.normalizeRequiredString(dto.idCardHash, 'idCardHash');
|
||||
if (dto.idCard !== undefined) {
|
||||
// 更新时沿用同一标准化逻辑,保证查询条件与落库格式一致。
|
||||
data.idCard = this.normalizeIdCard(dto.idCard);
|
||||
}
|
||||
if (dto.doctorId !== undefined) {
|
||||
const doctor = await this.resolveWritableDoctor(actor, dto.doctorId);
|
||||
@ -234,7 +237,10 @@ export class BPatientsService {
|
||||
}
|
||||
return;
|
||||
case Role.DIRECTOR:
|
||||
if (!actor.departmentId || patient.doctor.departmentId !== actor.departmentId) {
|
||||
if (
|
||||
!actor.departmentId ||
|
||||
patient.doctor.departmentId !== actor.departmentId
|
||||
) {
|
||||
throw new ForbiddenException(MESSAGES.PATIENT.ROLE_FORBIDDEN);
|
||||
}
|
||||
return;
|
||||
@ -360,7 +366,9 @@ export class BPatientsService {
|
||||
normalizedHospitalId == null ||
|
||||
!Number.isInteger(normalizedHospitalId)
|
||||
) {
|
||||
throw new BadRequestException(MESSAGES.PATIENT.SYSTEM_ADMIN_HOSPITAL_REQUIRED);
|
||||
throw new BadRequestException(
|
||||
MESSAGES.PATIENT.SYSTEM_ADMIN_HOSPITAL_REQUIRED,
|
||||
);
|
||||
}
|
||||
return normalizedHospitalId;
|
||||
}
|
||||
@ -390,4 +398,12 @@ export class BPatientsService {
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一整理身份证号,避免空格和末尾 x 大小写带来重复数据。
|
||||
*/
|
||||
private normalizeIdCard(value: unknown) {
|
||||
const normalized = this.normalizeRequiredString(value, 'idCard');
|
||||
return normalizePatientIdCard(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,16 +20,16 @@ export class CPatientsController {
|
||||
constructor(private readonly patientsService: CPatientsService) {}
|
||||
|
||||
/**
|
||||
* 根据手机号和身份证哈希查询跨院生命周期。
|
||||
* 根据手机号和身份证号查询跨院生命周期。
|
||||
*/
|
||||
@Get('lifecycle')
|
||||
@ApiOperation({ summary: '跨院患者生命周期查询' })
|
||||
@ApiQuery({ name: 'phone', description: '手机号' })
|
||||
@ApiQuery({ name: 'idCardHash', description: '身份证哈希' })
|
||||
@ApiQuery({ name: 'idCard', description: '身份证号' })
|
||||
getLifecycle(@Query() query: FamilyLifecycleQueryDto) {
|
||||
return this.patientsService.getFamilyLifecycleByIdentity(
|
||||
query.phone,
|
||||
query.idCardHash,
|
||||
query.idCard,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma.service.js';
|
||||
import { MESSAGES } from '../../common/messages.js';
|
||||
import { normalizePatientIdCard } from '../patient-id-card.util.js';
|
||||
|
||||
/**
|
||||
* C 端患者服务:承载家属跨院生命周期聚合查询。
|
||||
@ -14,17 +15,20 @@ export class CPatientsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* C 端查询:按 phone + idCardHash 跨院聚合患者生命周期记录。
|
||||
* C 端查询:按 phone + idCard 跨院聚合患者生命周期记录。
|
||||
*/
|
||||
async getFamilyLifecycleByIdentity(phone: string, idCardHash: string) {
|
||||
if (!phone || !idCardHash) {
|
||||
async getFamilyLifecycleByIdentity(phone: string, idCard: string) {
|
||||
if (!phone || !idCard) {
|
||||
throw new BadRequestException(MESSAGES.PATIENT.PHONE_IDCARD_REQUIRED);
|
||||
}
|
||||
|
||||
// 查询侧统一整理身份证格式,避免空格或末尾 x 大小写导致查不到。
|
||||
const normalizedIdCard = normalizePatientIdCard(idCard);
|
||||
|
||||
const patients = await this.prisma.patient.findMany({
|
||||
where: {
|
||||
phone,
|
||||
idCardHash,
|
||||
idCard: normalizedIdCard,
|
||||
},
|
||||
include: {
|
||||
hospital: { select: { id: true, name: true } },
|
||||
@ -89,6 +93,9 @@ export class CPatientsService {
|
||||
);
|
||||
|
||||
return {
|
||||
// 前端详情弹窗和现有 E2E 都依赖这两个回显字段。
|
||||
phone,
|
||||
idCard: normalizedIdCard,
|
||||
patientCount: patients.length,
|
||||
lifecycle,
|
||||
};
|
||||
|
||||
@ -1,11 +1,6 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsInt,
|
||||
IsString,
|
||||
Matches,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { IsInt, IsString, Matches, Min } from 'class-validator';
|
||||
|
||||
/**
|
||||
* 患者创建 DTO:B 端新增患者使用。
|
||||
@ -21,11 +16,11 @@ export class CreatePatientDto {
|
||||
phone!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '身份证哈希(前端传加密后值)',
|
||||
example: 'id-card-hash-demo',
|
||||
description: '身份证号原文',
|
||||
example: '110101199001010011',
|
||||
})
|
||||
@IsString({ message: 'idCardHash 必须是字符串' })
|
||||
idCardHash!: string;
|
||||
@IsString({ message: 'idCard 必须是字符串' })
|
||||
idCard!: string;
|
||||
|
||||
@ApiProperty({ description: '归属人员 ID(医生/主任/组长)', example: 10001 })
|
||||
@Type(() => Number)
|
||||
|
||||
@ -10,7 +10,10 @@ export class FamilyLifecycleQueryDto {
|
||||
@Matches(/^1\d{10}$/, { message: 'phone 必须是合法手机号' })
|
||||
phone!: string;
|
||||
|
||||
@ApiProperty({ description: '身份证哈希值', example: 'seed-id-card-hash' })
|
||||
@IsString({ message: 'idCardHash 必须是字符串' })
|
||||
idCardHash!: string;
|
||||
@ApiProperty({
|
||||
description: '身份证号原文',
|
||||
example: '110101199001010011',
|
||||
})
|
||||
@IsString({ message: 'idCard 必须是字符串' })
|
||||
idCard!: string;
|
||||
}
|
||||
|
||||
8
src/patients/patient-id-card.util.ts
Normal file
8
src/patients/patient-id-card.util.ts
Normal file
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* 统一整理身份证号:
|
||||
* 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 { Type } from 'class-transformer';
|
||||
import { IsInt, Min } from 'class-validator';
|
||||
import { IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator';
|
||||
|
||||
/**
|
||||
* 取消任务 DTO。
|
||||
@ -11,4 +11,14 @@ export class CancelTaskDto {
|
||||
@IsInt({ message: 'taskId 必须是整数' })
|
||||
@Min(1, { message: 'taskId 必须大于 0' })
|
||||
taskId!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '取消原因(可选,当前仅用于接口兼容与后续通知扩展)',
|
||||
example: '后台手动取消',
|
||||
required: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString({ message: 'reason 必须是字符串' })
|
||||
@MaxLength(100, { message: 'reason 长度不能超过 100 个字符' })
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
@ -275,6 +275,8 @@ export class TaskService {
|
||||
hospitalId: cancelledTask.hospitalId,
|
||||
actorId: actor.id,
|
||||
status: cancelledTask.status,
|
||||
// 当前库表未持久化取消原因,但先透传到事件层,方便通知链路后续接入。
|
||||
reason: dto.reason?.trim() || null,
|
||||
});
|
||||
|
||||
return cancelledTask;
|
||||
|
||||
@ -337,10 +337,12 @@ export class UsersService {
|
||||
data.openId = nextOpenId;
|
||||
}
|
||||
if (updateUserDto.password) {
|
||||
// 密码变更后立即吊销旧 token,避免旧会话继续使用。
|
||||
data.passwordHash = await hash(
|
||||
this.normalizePassword(updateUserDto.password),
|
||||
12,
|
||||
);
|
||||
data.tokenValidAfter = new Date();
|
||||
}
|
||||
|
||||
return this.prisma.user.update({
|
||||
|
||||
@ -81,16 +81,14 @@ async function requirePatientId(
|
||||
prisma: PrismaService,
|
||||
hospitalId: number,
|
||||
phone: string,
|
||||
idCardHash: string,
|
||||
idCard: string,
|
||||
): Promise<number> {
|
||||
const patient = await prisma.patient.findFirst({
|
||||
where: { hospitalId, phone, idCardHash },
|
||||
where: { hospitalId, phone, idCard },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!patient) {
|
||||
throw new NotFoundException(
|
||||
`Seed patient not found: ${phone}/${idCardHash}`,
|
||||
);
|
||||
throw new NotFoundException(`Seed patient not found: ${phone}/${idCard}`);
|
||||
}
|
||||
return patient.id;
|
||||
}
|
||||
@ -163,25 +161,25 @@ export async function loadSeedFixtures(
|
||||
prisma,
|
||||
hospitalAId,
|
||||
'13800002001',
|
||||
'seed-id-card-cross-hospital',
|
||||
'110101199001010011',
|
||||
),
|
||||
patientA2Id: await requirePatientId(
|
||||
prisma,
|
||||
hospitalAId,
|
||||
'13800002002',
|
||||
'seed-id-card-a2',
|
||||
'110101199002020022',
|
||||
),
|
||||
patientA3Id: await requirePatientId(
|
||||
prisma,
|
||||
hospitalAId,
|
||||
'13800002003',
|
||||
'seed-id-card-a3',
|
||||
'110101199003030033',
|
||||
),
|
||||
patientB1Id: await requirePatientId(
|
||||
prisma,
|
||||
hospitalBId,
|
||||
'13800002001',
|
||||
'seed-id-card-cross-hospital',
|
||||
'110101199001010011',
|
||||
),
|
||||
},
|
||||
devices: {
|
||||
|
||||
59
test/e2e/specs/auth-token-revocation.e2e-spec.ts
Normal file
59
test/e2e/specs/auth-token-revocation.e2e-spec.ts
Normal file
@ -0,0 +1,59 @@
|
||||
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,39 +24,33 @@ describe('AuthController (e2e)', () => {
|
||||
await closeE2EContext(ctx);
|
||||
});
|
||||
|
||||
describe('POST /auth/register', () => {
|
||||
it('成功:注册医生账号', async () => {
|
||||
describe('POST /auth/system-admin', () => {
|
||||
it('成功:创建系统管理员账号', async () => {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.post('/auth/register')
|
||||
.post('/auth/system-admin')
|
||||
.send({
|
||||
name: uniqueSeedValue('Auth 注册医生'),
|
||||
name: uniqueSeedValue('Auth 系统管理员'),
|
||||
phone: uniquePhone(),
|
||||
password: 'Seed@1234',
|
||||
role: Role.DOCTOR,
|
||||
hospitalId: ctx.fixtures.hospitalAId,
|
||||
departmentId: ctx.fixtures.departmentA1Id,
|
||||
groupId: ctx.fixtures.groupA1Id,
|
||||
openId: uniqueSeedValue('auth-register-openid'),
|
||||
openId: uniqueSeedValue('auth-system-admin-openid'),
|
||||
systemAdminBootstrapKey: process.env.SYSTEM_ADMIN_BOOTSTRAP_KEY,
|
||||
});
|
||||
|
||||
expectSuccessEnvelope(response, 201);
|
||||
expect(response.body.data.role).toBe(Role.DOCTOR);
|
||||
expect(response.body.data.role).toBe(Role.SYSTEM_ADMIN);
|
||||
});
|
||||
|
||||
it('失败:参数不合法返回 400', async () => {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.post('/auth/register')
|
||||
.post('/auth/system-admin')
|
||||
.send({
|
||||
name: 'bad-register',
|
||||
name: 'bad-system-admin',
|
||||
phone: '13800009999',
|
||||
password: '123',
|
||||
role: Role.DOCTOR,
|
||||
hospitalId: ctx.fixtures.hospitalAId,
|
||||
departmentId: ctx.fixtures.departmentA1Id,
|
||||
groupId: ctx.fixtures.groupA1Id,
|
||||
systemAdminBootstrapKey: process.env.SYSTEM_ADMIN_BOOTSTRAP_KEY,
|
||||
});
|
||||
|
||||
expectErrorEnvelope(response, 400, 'password 长度至少 8 位');
|
||||
expectErrorEnvelope(response, 400, '密码长度至少 8 位');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
161
test/e2e/specs/devices.e2e-spec.ts
Normal file
161
test/e2e/specs/devices.e2e-spec.ts
Normal file
@ -0,0 +1,161 @@
|
||||
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');
|
||||
});
|
||||
|
||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可访问,其他角色 403,未登录 401', async () => {
|
||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER 可访问,其余角色 403,未登录 401', async () => {
|
||||
await assertRoleMatrix({
|
||||
name: 'GET /b/organization/hospitals role matrix',
|
||||
tokens: ctx.tokens,
|
||||
expectedStatusByRole: {
|
||||
[Role.SYSTEM_ADMIN]: 200,
|
||||
[Role.HOSPITAL_ADMIN]: 200,
|
||||
[Role.DIRECTOR]: 403,
|
||||
[Role.LEADER]: 403,
|
||||
[Role.DIRECTOR]: 200,
|
||||
[Role.LEADER]: 200,
|
||||
[Role.DOCTOR]: 403,
|
||||
[Role.ENGINEER]: 403,
|
||||
},
|
||||
@ -126,15 +126,15 @@ describe('Organization Controllers (e2e)', () => {
|
||||
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
|
||||
});
|
||||
|
||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可访问,其他角色 403,未登录 401', async () => {
|
||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER 可访问,其余角色 403,未登录 401', async () => {
|
||||
await assertRoleMatrix({
|
||||
name: 'GET /b/organization/hospitals/:id role matrix',
|
||||
tokens: ctx.tokens,
|
||||
expectedStatusByRole: {
|
||||
[Role.SYSTEM_ADMIN]: 200,
|
||||
[Role.HOSPITAL_ADMIN]: 200,
|
||||
[Role.DIRECTOR]: 403,
|
||||
[Role.LEADER]: 403,
|
||||
[Role.DIRECTOR]: 200,
|
||||
[Role.LEADER]: 200,
|
||||
[Role.DOCTOR]: 403,
|
||||
[Role.ENGINEER]: 403,
|
||||
},
|
||||
@ -321,15 +321,15 @@ describe('Organization Controllers (e2e)', () => {
|
||||
expectErrorEnvelope(response, 401, '缺少 Bearer Token');
|
||||
});
|
||||
|
||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可访问,其他角色 403,未登录 401', async () => {
|
||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER 可访问,其余角色 403,未登录 401', async () => {
|
||||
await assertRoleMatrix({
|
||||
name: 'GET /b/organization/departments role matrix',
|
||||
tokens: ctx.tokens,
|
||||
expectedStatusByRole: {
|
||||
[Role.SYSTEM_ADMIN]: 200,
|
||||
[Role.HOSPITAL_ADMIN]: 200,
|
||||
[Role.DIRECTOR]: 403,
|
||||
[Role.LEADER]: 403,
|
||||
[Role.DIRECTOR]: 200,
|
||||
[Role.LEADER]: 200,
|
||||
[Role.DOCTOR]: 403,
|
||||
[Role.ENGINEER]: 403,
|
||||
},
|
||||
@ -361,15 +361,15 @@ describe('Organization Controllers (e2e)', () => {
|
||||
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
|
||||
});
|
||||
|
||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可访问,其他角色 403,未登录 401', async () => {
|
||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER 可访问,其余角色 403,未登录 401', async () => {
|
||||
await assertRoleMatrix({
|
||||
name: 'GET /b/organization/departments/:id role matrix',
|
||||
tokens: ctx.tokens,
|
||||
expectedStatusByRole: {
|
||||
[Role.SYSTEM_ADMIN]: 200,
|
||||
[Role.HOSPITAL_ADMIN]: 200,
|
||||
[Role.DIRECTOR]: 403,
|
||||
[Role.LEADER]: 403,
|
||||
[Role.DIRECTOR]: 200,
|
||||
[Role.LEADER]: 200,
|
||||
[Role.DOCTOR]: 403,
|
||||
[Role.ENGINEER]: 403,
|
||||
},
|
||||
@ -413,15 +413,15 @@ describe('Organization Controllers (e2e)', () => {
|
||||
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
|
||||
});
|
||||
|
||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其他角色 403,未登录 401', async () => {
|
||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER 可进入业务,其余角色 403,未登录 401', async () => {
|
||||
await assertRoleMatrix({
|
||||
name: 'PATCH /b/organization/departments/:id role matrix',
|
||||
tokens: ctx.tokens,
|
||||
expectedStatusByRole: {
|
||||
[Role.SYSTEM_ADMIN]: 404,
|
||||
[Role.HOSPITAL_ADMIN]: 404,
|
||||
[Role.DIRECTOR]: 403,
|
||||
[Role.LEADER]: 403,
|
||||
[Role.DIRECTOR]: 404,
|
||||
[Role.LEADER]: 404,
|
||||
[Role.DOCTOR]: 403,
|
||||
[Role.ENGINEER]: 403,
|
||||
},
|
||||
@ -516,14 +516,14 @@ describe('Organization Controllers (e2e)', () => {
|
||||
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
|
||||
});
|
||||
|
||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其他角色 403,未登录 401', async () => {
|
||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR 可进入业务,其余角色 403,未登录 401', async () => {
|
||||
await assertRoleMatrix({
|
||||
name: 'POST /b/organization/groups role matrix',
|
||||
tokens: ctx.tokens,
|
||||
expectedStatusByRole: {
|
||||
[Role.SYSTEM_ADMIN]: 400,
|
||||
[Role.HOSPITAL_ADMIN]: 400,
|
||||
[Role.DIRECTOR]: 403,
|
||||
[Role.DIRECTOR]: 400,
|
||||
[Role.LEADER]: 403,
|
||||
[Role.DOCTOR]: 403,
|
||||
[Role.ENGINEER]: 403,
|
||||
@ -558,15 +558,15 @@ describe('Organization Controllers (e2e)', () => {
|
||||
expectErrorEnvelope(response, 401, '缺少 Bearer Token');
|
||||
});
|
||||
|
||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可访问,其他角色 403,未登录 401', async () => {
|
||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER 可访问,其余角色 403,未登录 401', async () => {
|
||||
await assertRoleMatrix({
|
||||
name: 'GET /b/organization/groups role matrix',
|
||||
tokens: ctx.tokens,
|
||||
expectedStatusByRole: {
|
||||
[Role.SYSTEM_ADMIN]: 200,
|
||||
[Role.HOSPITAL_ADMIN]: 200,
|
||||
[Role.DIRECTOR]: 403,
|
||||
[Role.LEADER]: 403,
|
||||
[Role.DIRECTOR]: 200,
|
||||
[Role.LEADER]: 200,
|
||||
[Role.DOCTOR]: 403,
|
||||
[Role.ENGINEER]: 403,
|
||||
},
|
||||
@ -598,15 +598,15 @@ describe('Organization Controllers (e2e)', () => {
|
||||
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
|
||||
});
|
||||
|
||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可访问,其他角色 403,未登录 401', async () => {
|
||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER 可访问,其余角色 403,未登录 401', async () => {
|
||||
await assertRoleMatrix({
|
||||
name: 'GET /b/organization/groups/:id role matrix',
|
||||
tokens: ctx.tokens,
|
||||
expectedStatusByRole: {
|
||||
[Role.SYSTEM_ADMIN]: 200,
|
||||
[Role.HOSPITAL_ADMIN]: 200,
|
||||
[Role.DIRECTOR]: 403,
|
||||
[Role.LEADER]: 403,
|
||||
[Role.DIRECTOR]: 200,
|
||||
[Role.LEADER]: 200,
|
||||
[Role.DOCTOR]: 403,
|
||||
[Role.ENGINEER]: 403,
|
||||
},
|
||||
@ -650,15 +650,15 @@ describe('Organization Controllers (e2e)', () => {
|
||||
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
|
||||
});
|
||||
|
||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其他角色 403,未登录 401', async () => {
|
||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER 可进入业务,其余角色 403,未登录 401', async () => {
|
||||
await assertRoleMatrix({
|
||||
name: 'PATCH /b/organization/groups/:id role matrix',
|
||||
tokens: ctx.tokens,
|
||||
expectedStatusByRole: {
|
||||
[Role.SYSTEM_ADMIN]: 404,
|
||||
[Role.HOSPITAL_ADMIN]: 404,
|
||||
[Role.DIRECTOR]: 403,
|
||||
[Role.LEADER]: 403,
|
||||
[Role.DIRECTOR]: 404,
|
||||
[Role.LEADER]: 404,
|
||||
[Role.DOCTOR]: 403,
|
||||
[Role.ENGINEER]: 403,
|
||||
},
|
||||
@ -702,14 +702,22 @@ describe('Organization Controllers (e2e)', () => {
|
||||
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
|
||||
});
|
||||
|
||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其他角色 403,未登录 401', async () => {
|
||||
it('失败:删除有成员的小组返回 409', 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({
|
||||
name: 'DELETE /b/organization/groups/:id role matrix',
|
||||
tokens: ctx.tokens,
|
||||
expectedStatusByRole: {
|
||||
[Role.SYSTEM_ADMIN]: 404,
|
||||
[Role.HOSPITAL_ADMIN]: 404,
|
||||
[Role.DIRECTOR]: 403,
|
||||
[Role.DIRECTOR]: 404,
|
||||
[Role.LEADER]: 403,
|
||||
[Role.DOCTOR]: 403,
|
||||
[Role.ENGINEER]: 403,
|
||||
|
||||
@ -146,17 +146,18 @@ describe('Patients Controllers (e2e)', () => {
|
||||
});
|
||||
|
||||
describe('GET /c/patients/lifecycle', () => {
|
||||
it('成功:可按 phone + idCardHash 查询跨院生命周期', async () => {
|
||||
it('成功:已登录用户可按 phone + idCard 查询跨院生命周期', async () => {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.get('/c/patients/lifecycle')
|
||||
.query({
|
||||
phone: '13800002001',
|
||||
idCardHash: 'seed-id-card-cross-hospital',
|
||||
});
|
||||
idCard: '110101199001010011',
|
||||
})
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`);
|
||||
|
||||
expectSuccessEnvelope(response, 200);
|
||||
expect(response.body.data.phone).toBe('13800002001');
|
||||
expect(response.body.data.idCardHash).toBe('seed-id-card-cross-hospital');
|
||||
expect(response.body.data.idCard).toBe('110101199001010011');
|
||||
expect(response.body.data.patientCount).toBeGreaterThanOrEqual(2);
|
||||
expect(Array.isArray(response.body.data.lifecycle)).toBe(true);
|
||||
});
|
||||
@ -166,9 +167,10 @@ describe('Patients Controllers (e2e)', () => {
|
||||
.get('/c/patients/lifecycle')
|
||||
.query({
|
||||
phone: '13800002001',
|
||||
});
|
||||
})
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`);
|
||||
|
||||
expectErrorEnvelope(response, 400, 'idCardHash 必须是字符串');
|
||||
expectErrorEnvelope(response, 400, 'idCard 必须是字符串');
|
||||
});
|
||||
|
||||
it('失败:不存在患者返回 404', async () => {
|
||||
@ -176,8 +178,9 @@ describe('Patients Controllers (e2e)', () => {
|
||||
.get('/c/patients/lifecycle')
|
||||
.query({
|
||||
phone: '13800009999',
|
||||
idCardHash: 'not-exists-idcard-hash',
|
||||
});
|
||||
idCard: '110101199009090099',
|
||||
})
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`);
|
||||
|
||||
expectErrorEnvelope(response, 404, '未找到匹配的患者档案');
|
||||
});
|
||||
|
||||
@ -74,15 +74,15 @@ describe('BTasksController (e2e)', () => {
|
||||
expectErrorEnvelope(response, 404, '存在设备不在当前医院或设备不存在');
|
||||
});
|
||||
|
||||
it('角色矩阵:仅 DOCTOR 可进入业务,其他角色 403,未登录 401', async () => {
|
||||
it('角色矩阵:DOCTOR/DIRECTOR/LEADER 可进入业务,其余角色 403,未登录 401', async () => {
|
||||
await assertRoleMatrix({
|
||||
name: 'POST /b/tasks/publish role matrix',
|
||||
tokens: ctx.tokens,
|
||||
expectedStatusByRole: {
|
||||
[Role.SYSTEM_ADMIN]: 403,
|
||||
[Role.HOSPITAL_ADMIN]: 403,
|
||||
[Role.DIRECTOR]: 403,
|
||||
[Role.LEADER]: 403,
|
||||
[Role.DIRECTOR]: 400,
|
||||
[Role.LEADER]: 400,
|
||||
[Role.DOCTOR]: 400,
|
||||
[Role.ENGINEER]: 403,
|
||||
},
|
||||
@ -298,15 +298,15 @@ describe('BTasksController (e2e)', () => {
|
||||
expectErrorEnvelope(cancelResponse, 409, '仅待接收/已接收任务可取消');
|
||||
});
|
||||
|
||||
it('角色矩阵:仅 DOCTOR 可进入业务,其他角色 403,未登录 401', async () => {
|
||||
it('角色矩阵:DOCTOR/DIRECTOR/LEADER 可进入业务,其余角色 403,未登录 401', async () => {
|
||||
await assertRoleMatrix({
|
||||
name: 'POST /b/tasks/cancel role matrix',
|
||||
tokens: ctx.tokens,
|
||||
expectedStatusByRole: {
|
||||
[Role.SYSTEM_ADMIN]: 403,
|
||||
[Role.HOSPITAL_ADMIN]: 403,
|
||||
[Role.DIRECTOR]: 403,
|
||||
[Role.LEADER]: 403,
|
||||
[Role.DIRECTOR]: 404,
|
||||
[Role.LEADER]: 404,
|
||||
[Role.DOCTOR]: 404,
|
||||
[Role.ENGINEER]: 403,
|
||||
},
|
||||
|
||||
@ -120,15 +120,15 @@ describe('UsersController + BUsersController (e2e)', () => {
|
||||
expectErrorEnvelope(response, 401, '缺少 Bearer Token');
|
||||
});
|
||||
|
||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可访问,其他角色 403,未登录 401', async () => {
|
||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER 可访问,其余角色 403,未登录 401', async () => {
|
||||
await assertRoleMatrix({
|
||||
name: 'GET /users role matrix',
|
||||
tokens: ctx.tokens,
|
||||
expectedStatusByRole: {
|
||||
[Role.SYSTEM_ADMIN]: 200,
|
||||
[Role.HOSPITAL_ADMIN]: 200,
|
||||
[Role.DIRECTOR]: 403,
|
||||
[Role.LEADER]: 403,
|
||||
[Role.DIRECTOR]: 200,
|
||||
[Role.LEADER]: 200,
|
||||
[Role.DOCTOR]: 403,
|
||||
[Role.ENGINEER]: 403,
|
||||
},
|
||||
@ -207,7 +207,11 @@ describe('UsersController + BUsersController (e2e)', () => {
|
||||
groupId: ctx.fixtures.groupA1Id,
|
||||
});
|
||||
|
||||
expectErrorEnvelope(response, 400, '仅医生/主任/组长允许调整科室/小组归属');
|
||||
expectErrorEnvelope(
|
||||
response,
|
||||
400,
|
||||
'仅医生/主任/组长允许调整科室/小组归属',
|
||||
);
|
||||
});
|
||||
|
||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其他角色 403,未登录 401', async () => {
|
||||
|
||||
24
tyt-admin/src/api/devices.js
Normal file
24
tyt-admin/src/api/devices.js
Normal file
@ -0,0 +1,24 @@
|
||||
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,11 +4,12 @@ import { useUserStore } from '../store/user';
|
||||
import router from '../router';
|
||||
|
||||
const service = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || '/api', // Use /api as default proxy prefix
|
||||
// 开发环境默认走 Vite /api 代理,生产环境可用环境变量覆盖。
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Request Interceptor
|
||||
// 请求拦截:统一挂载 Bearer Token,避免各页面重复拼接鉴权头。
|
||||
service.interceptors.request.use(
|
||||
(config) => {
|
||||
const userStore = useUserStore();
|
||||
@ -19,19 +20,18 @@ service.interceptors.request.use(
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Response Interceptor
|
||||
// 响应拦截:对齐后端统一响应包裹 { code, msg, data }。
|
||||
service.interceptors.response.use(
|
||||
(response) => {
|
||||
const res = response.data;
|
||||
// Backend standard format: { code: number, msg: string, data: any }
|
||||
// Accept code 0 or 2xx as success
|
||||
|
||||
// 后端成功响应统一为 code=0;这里兼容少量 code=2xx 的历史结构。
|
||||
if (res.code === 0 || (res.code >= 200 && res.code < 300)) {
|
||||
return res.data;
|
||||
} else {
|
||||
// If backend returns code !== 0/2xx but HTTP status is 200
|
||||
ElMessage.error(res.msg || '请求失败');
|
||||
return Promise.reject(new Error(res.msg || 'Error'));
|
||||
}
|
||||
@ -39,28 +39,29 @@ service.interceptors.response.use(
|
||||
(error) => {
|
||||
const userStore = useUserStore();
|
||||
let message = error.message;
|
||||
|
||||
|
||||
if (error.response) {
|
||||
const { status, data } = error.response;
|
||||
// Backend error response format: { code: number, msg: string, data: null }
|
||||
message = data?.msg || message;
|
||||
|
||||
|
||||
if (status === 401) {
|
||||
// Token expired or invalid
|
||||
// 401 统一视为登录态失效,先清理本地态再跳登录页。
|
||||
userStore.logout();
|
||||
router.push(`/login?redirect=${encodeURIComponent(router.currentRoute.value.fullPath)}`);
|
||||
router.push(
|
||||
`/login?redirect=${encodeURIComponent(router.currentRoute.value.fullPath)}`,
|
||||
);
|
||||
ElMessage.error(message || '登录状态已过期,请重新登录');
|
||||
return Promise.reject(new Error('Unauthorized'));
|
||||
} else if (status === 403) {
|
||||
ElMessage.error(message || '没有权限执行该操作');
|
||||
} else {
|
||||
ElMessage.error(message || '请求失败');
|
||||
ElMessage.error(message || '请求失败');
|
||||
}
|
||||
} else {
|
||||
ElMessage.error(message || '网络连接异常');
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default service;
|
||||
|
||||
@ -14,6 +14,8 @@ const PATIENT_ROLES = Object.freeze([
|
||||
'HOSPITAL_ADMIN',
|
||||
'DIRECTOR',
|
||||
'LEADER',
|
||||
// 后端患者接口允许医生访问,页面侧也应放开,避免前端先把医生拦掉。
|
||||
'DOCTOR',
|
||||
]);
|
||||
|
||||
export const ROLE_PERMISSIONS = Object.freeze({
|
||||
@ -22,6 +24,7 @@ export const ROLE_PERMISSIONS = Object.freeze({
|
||||
ORG_DEPARTMENTS: ORG_MANAGER_ROLES,
|
||||
ORG_GROUPS: ORG_MANAGER_ROLES,
|
||||
USERS: ADMIN_ROLES,
|
||||
DEVICES: ADMIN_ROLES,
|
||||
TASKS: TASK_ROLES,
|
||||
PATIENTS: PATIENT_ROLES,
|
||||
});
|
||||
|
||||
@ -14,7 +14,7 @@
|
||||
<el-icon><DataLine /></el-icon>
|
||||
<span>首页</span>
|
||||
</el-menu-item>
|
||||
|
||||
|
||||
<template v-if="userStore.role === 'SYSTEM_ADMIN'">
|
||||
<el-sub-menu index="/organization">
|
||||
<template #title>
|
||||
@ -22,8 +22,12 @@
|
||||
<span>组织架构</span>
|
||||
</template>
|
||||
<el-menu-item index="/organization/tree">结构图视图</el-menu-item>
|
||||
<el-menu-item index="/organization/hospitals">医院管理</el-menu-item>
|
||||
<el-menu-item index="/organization/departments">科室管理</el-menu-item>
|
||||
<el-menu-item index="/organization/hospitals"
|
||||
>医院管理</el-menu-item
|
||||
>
|
||||
<el-menu-item index="/organization/departments"
|
||||
>科室管理</el-menu-item
|
||||
>
|
||||
<el-menu-item index="/organization/groups">小组管理</el-menu-item>
|
||||
</el-sub-menu>
|
||||
</template>
|
||||
@ -47,6 +51,11 @@
|
||||
<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 v-if="canAccessTasks" index="/tasks">
|
||||
<el-icon><List /></el-icon>
|
||||
<span>任务管理</span>
|
||||
@ -58,11 +67,11 @@
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
|
||||
|
||||
<el-container>
|
||||
<el-header class="header">
|
||||
<div class="header-left">
|
||||
<!-- Breadcrumbs can go here -->
|
||||
<!-- Breadcrumbs can go here -->
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-dropdown @command="handleCommand">
|
||||
@ -80,7 +89,7 @@
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
|
||||
<el-main class="main">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade-transform" mode="out-in">
|
||||
@ -100,7 +109,17 @@ import {
|
||||
ROLE_PERMISSIONS,
|
||||
hasRolePermission,
|
||||
} from '../constants/role-permissions';
|
||||
import { DataLine, OfficeBuilding, User, List, Avatar, ArrowDown, Connection, Share } from '@element-plus/icons-vue';
|
||||
import {
|
||||
DataLine,
|
||||
OfficeBuilding,
|
||||
User,
|
||||
List,
|
||||
Avatar,
|
||||
ArrowDown,
|
||||
Connection,
|
||||
Share,
|
||||
Monitor,
|
||||
} from '@element-plus/icons-vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@ -113,6 +132,9 @@ const activeMenu = computed(() => {
|
||||
const canAccessUsers = computed(() =>
|
||||
hasRolePermission(userStore.role, ROLE_PERMISSIONS.USERS),
|
||||
);
|
||||
const canAccessDevices = computed(() =>
|
||||
hasRolePermission(userStore.role, ROLE_PERMISSIONS.DEVICES),
|
||||
);
|
||||
const canAccessOrgTree = computed(() =>
|
||||
hasRolePermission(userStore.role, ROLE_PERMISSIONS.ORG_TREE),
|
||||
);
|
||||
@ -174,7 +196,7 @@ const handleCommand = (command) => {
|
||||
/* fade-transform transition */
|
||||
.fade-transform-leave-active,
|
||||
.fade-transform-enter-active {
|
||||
transition: all .3s;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.fade-transform-enter-from {
|
||||
opacity: 0;
|
||||
|
||||
@ -77,6 +77,16 @@ const routes = [
|
||||
allowedRoles: ROLE_PERMISSIONS.USERS,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'devices',
|
||||
name: 'Devices',
|
||||
component: () => import('../views/devices/Devices.vue'),
|
||||
meta: {
|
||||
title: '设备管理',
|
||||
requiresAuth: true,
|
||||
allowedRoles: ROLE_PERMISSIONS.DEVICES,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'tasks',
|
||||
name: 'Tasks',
|
||||
@ -96,7 +106,7 @@ const routes = [
|
||||
requiresAuth: true,
|
||||
allowedRoles: ROLE_PERMISSIONS.PATIENTS,
|
||||
},
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
531
tyt-admin/src/views/devices/Devices.vue
Normal file
531
tyt-admin/src/views/devices/Devices.vue
Normal file
@ -0,0 +1,531 @@
|
||||
<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>
|
||||
@ -59,7 +59,7 @@
|
||||
<el-table-column prop="id" label="ID" width="80" align="center" />
|
||||
<el-table-column prop="name" label="姓名" min-width="120" />
|
||||
<el-table-column prop="phone" label="手机号" min-width="140" />
|
||||
<el-table-column prop="idCardHash" label="身份证" min-width="200" />
|
||||
<el-table-column prop="idCard" label="身份证号" min-width="200" />
|
||||
<el-table-column label="归属医院" min-width="160">
|
||||
<template #default="{ row }">
|
||||
{{ row.hospital?.name || '-' }}
|
||||
@ -116,8 +116,8 @@
|
||||
<el-form-item label="手机号" prop="phone">
|
||||
<el-input v-model="form.phone" placeholder="请输入手机号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="身份证" prop="idCardHash">
|
||||
<el-input v-model="form.idCardHash" placeholder="请输入身份证号" />
|
||||
<el-form-item label="身份证号" prop="idCard">
|
||||
<el-input v-model="form.idCard" placeholder="请输入身份证号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="归属医生" prop="doctorId">
|
||||
<el-tree-select
|
||||
@ -155,8 +155,8 @@
|
||||
<el-descriptions-item label="手机号">{{
|
||||
recordSummary.phone || '-'
|
||||
}}</el-descriptions-item>
|
||||
<el-descriptions-item label="身份证">{{
|
||||
recordSummary.idCardHash || '-'
|
||||
<el-descriptions-item label="身份证号">{{
|
||||
recordSummary.idCard || '-'
|
||||
}}</el-descriptions-item>
|
||||
<el-descriptions-item label="记录数">{{
|
||||
recordList.length
|
||||
@ -262,7 +262,7 @@ const doctorOptions = ref([]);
|
||||
const form = reactive({
|
||||
name: '',
|
||||
phone: '',
|
||||
idCardHash: '',
|
||||
idCard: '',
|
||||
doctorId: null,
|
||||
});
|
||||
|
||||
@ -272,7 +272,7 @@ const rules = {
|
||||
{ required: true, message: '请输入手机号', trigger: 'blur' },
|
||||
{ pattern: /^1\d{10}$/, message: '请输入正确的手机号', trigger: 'blur' },
|
||||
],
|
||||
idCardHash: [{ required: true, message: '请输入证件哈希', trigger: 'blur' }],
|
||||
idCard: [{ required: true, message: '请输入身份证号', trigger: 'blur' }],
|
||||
doctorId: [{ required: true, message: '请选择归属人员', trigger: 'change' }],
|
||||
};
|
||||
|
||||
@ -281,7 +281,7 @@ const recordLoading = ref(false);
|
||||
const currentPatientName = ref('');
|
||||
const recordSummary = reactive({
|
||||
phone: '',
|
||||
idCardHash: '',
|
||||
idCard: '',
|
||||
});
|
||||
const recordList = ref([]);
|
||||
|
||||
@ -432,6 +432,8 @@ const fetchData = async () => {
|
||||
if (userStore.role === 'SYSTEM_ADMIN') {
|
||||
params.hospitalId = searchForm.hospitalId;
|
||||
}
|
||||
|
||||
// 后端当前返回数组,页面层自行做关键词筛选和本地分页。
|
||||
const res = await getPatients(params);
|
||||
allPatients.value = Array.isArray(res) ? res : [];
|
||||
applyFiltersAndPagination();
|
||||
@ -462,7 +464,7 @@ const resetForm = () => {
|
||||
formRef.value?.resetFields();
|
||||
form.name = '';
|
||||
form.phone = '';
|
||||
form.idCardHash = '';
|
||||
form.idCard = '';
|
||||
form.doctorId = null;
|
||||
currentEditId.value = null;
|
||||
};
|
||||
@ -487,7 +489,7 @@ const openEditDialog = async (row) => {
|
||||
currentEditId.value = detail.id;
|
||||
form.name = detail.name;
|
||||
form.phone = detail.phone;
|
||||
form.idCardHash = detail.idCardHash;
|
||||
form.idCard = detail.idCard;
|
||||
form.doctorId = detail.doctorId;
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
@ -499,10 +501,11 @@ const handleSubmit = async () => {
|
||||
|
||||
submitLoading.value = true;
|
||||
try {
|
||||
// 身份字段统一改为原始身份证号,前后端都使用同一命名。
|
||||
const payload = {
|
||||
name: form.name,
|
||||
phone: form.phone,
|
||||
idCardHash: form.idCardHash,
|
||||
idCard: form.idCard,
|
||||
doctorId: form.doctorId,
|
||||
};
|
||||
if (isEdit.value) {
|
||||
@ -535,8 +538,8 @@ const handleDelete = (row) => {
|
||||
};
|
||||
|
||||
const openRecordDialog = async (row) => {
|
||||
if (!row.phone || !row.idCardHash) {
|
||||
ElMessage.warning('缺少 phone 或 idCardHash,无法查询调压记录');
|
||||
if (!row.phone || !row.idCard) {
|
||||
ElMessage.warning('缺少 phone 或 idCard,无法查询调压记录');
|
||||
return;
|
||||
}
|
||||
|
||||
@ -544,16 +547,17 @@ const openRecordDialog = async (row) => {
|
||||
recordLoading.value = true;
|
||||
currentPatientName.value = row.name || '';
|
||||
recordSummary.phone = '';
|
||||
recordSummary.idCardHash = '';
|
||||
recordSummary.idCard = '';
|
||||
recordList.value = [];
|
||||
|
||||
try {
|
||||
// 该接口是跨院聚合接口,但当前后台页面只聚焦当前选中患者的记录展示。
|
||||
const res = await getPatientLifecycle({
|
||||
phone: row.phone,
|
||||
idCardHash: row.idCardHash,
|
||||
idCard: row.idCard,
|
||||
});
|
||||
recordSummary.phone = res.phone || '';
|
||||
recordSummary.idCardHash = res.idCardHash || '';
|
||||
recordSummary.idCard = res.idCard || '';
|
||||
const fullList = Array.isArray(res.lifecycle) ? res.lifecycle : [];
|
||||
recordList.value = fullList.filter((item) => item.patient?.id === row.id);
|
||||
} finally {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user