diff --git a/src/common/messages.ts b/src/common/messages.ts
index 9c8a671..c8343a9 100644
--- a/src/common/messages.ts
+++ b/src/common/messages.ts
@@ -74,6 +74,9 @@ export const MESSAGES = {
},
TASK: {
+ RECORD_NOT_FOUND: '调压记录不存在或无权限访问',
+ UPDATE_ONLY_PENDING: '仅待处理调压记录可编辑',
+ DELETE_ONLY_PENDING_CANCELLED: '仅待处理/已取消调压记录可删除',
ITEMS_REQUIRED: '任务明细 items 不能为空',
DEVICE_NOT_FOUND: '存在设备不在当前医院或设备不存在',
DEVICE_MULTI_HOSPITAL: '同一批调压任务中的设备必须属于同一家医院',
diff --git a/src/patients/patient-lifecycle.util.ts b/src/patients/patient-lifecycle.util.ts
index 2195f39..a354b5a 100644
--- a/src/patients/patient-lifecycle.util.ts
+++ b/src/patients/patient-lifecycle.util.ts
@@ -63,6 +63,7 @@ export function buildPatientLifecyclePatient(patient: PatientLifecycleSource) {
id: toJsonNumber(patient.id),
name: patient.name,
inpatientNo: patient.inpatientNo,
+ phone: patient.phone,
projectName: patient.projectName,
};
}
diff --git a/src/tasks/b-tasks/b-tasks.controller.ts b/src/tasks/b-tasks/b-tasks.controller.ts
index c4bc48a..339b091 100644
--- a/src/tasks/b-tasks/b-tasks.controller.ts
+++ b/src/tasks/b-tasks/b-tasks.controller.ts
@@ -1,4 +1,13 @@
-import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
+import {
+ Body,
+ Controller,
+ Get,
+ Param,
+ ParseIntPipe,
+ Post,
+ Query,
+ UseGuards,
+} from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
@@ -19,9 +28,6 @@ import { CancelTaskDto } from '../dto/cancel-task.dto.js';
import { TaskRecordQueryDto } from '../dto/task-record-query.dto.js';
import { AssignableEngineerQueryDto } from '../dto/assignable-engineer-query.dto.js';
-/**
- * B 端任务控制器:封装调压任务状态流转接口。
- */
@ApiTags('调压任务(B端)')
@ApiBearerAuth('bearer')
@Controller('b/tasks')
@@ -29,9 +35,6 @@ import { AssignableEngineerQueryDto } from '../dto/assignable-engineer-query.dto
export class BTasksController {
constructor(private readonly taskService: TaskService) {}
- /**
- * 查询当前角色可见的医院工程师列表。
- */
@Get('engineers')
@Roles(
Role.SYSTEM_ADMIN,
@@ -53,9 +56,6 @@ export class BTasksController {
return this.taskService.findAssignableEngineers(actor, query.hospitalId);
}
- /**
- * 查询当前角色可见的调压记录列表。
- */
@Get()
@Roles(
Role.SYSTEM_ADMIN,
@@ -78,9 +78,28 @@ export class BTasksController {
return this.taskService.findTaskRecords(actor, query);
}
- /**
- * 系统管理员/医院管理员/医生/主任/组长发布调压任务。
- */
+ @Get('items/:id')
+ @Roles(
+ Role.SYSTEM_ADMIN,
+ Role.HOSPITAL_ADMIN,
+ Role.DOCTOR,
+ Role.DIRECTOR,
+ Role.LEADER,
+ Role.ENGINEER,
+ )
+ @ApiOperation({ summary: '查询单条调压记录详情' })
+ @ApiQuery({
+ name: 'id',
+ required: true,
+ description: '调压记录 ID (taskItem.id)',
+ })
+ findRecordItem(
+ @CurrentActor() actor: ActorContext,
+ @Param('id', ParseIntPipe) id: number,
+ ) {
+ return this.taskService.getTaskRecordItem(actor, id);
+ }
+
@Post('publish')
@Roles(
Role.SYSTEM_ADMIN,
@@ -96,9 +115,6 @@ export class BTasksController {
return this.taskService.publishTask(actor, dto);
}
- /**
- * 工程师接收调压任务。
- */
@Post('accept')
@Roles(Role.ENGINEER)
@ApiOperation({ summary: '接收任务(ENGINEER)' })
@@ -106,9 +122,6 @@ export class BTasksController {
return this.taskService.acceptTask(actor, dto);
}
- /**
- * 工程师完成调压任务。
- */
@Post('complete')
@Roles(Role.ENGINEER)
@ApiOperation({ summary: '完成任务(ENGINEER)' })
@@ -116,9 +129,6 @@ export class BTasksController {
return this.taskService.completeTask(actor, dto);
}
- /**
- * 系统管理员/医院管理员/医生/主任/组长可取消自己创建的任务;工程师可取消自己已接收的任务。
- */
@Post('cancel')
@Roles(
Role.SYSTEM_ADMIN,
diff --git a/src/tasks/task.service.ts b/src/tasks/task.service.ts
index 010a12e..805b33b 100644
--- a/src/tasks/task.service.ts
+++ b/src/tasks/task.service.ts
@@ -23,6 +23,64 @@ import { TaskRecordQueryDto } from './dto/task-record-query.dto.js';
import { MESSAGES } from '../common/messages.js';
import { normalizePressureLabel } from '../common/pressure-level.util.js';
+const TASK_RECORD_DETAIL_SELECT = {
+ id: true,
+ oldPressure: true,
+ targetPressure: true,
+ task: {
+ select: {
+ id: true,
+ status: true,
+ createdAt: true,
+ creatorId: true,
+ engineerId: true,
+ hospitalId: true,
+ completionMaterials: true,
+ },
+ },
+ device: {
+ select: {
+ id: true,
+ currentPressure: true,
+ status: true,
+ isAbandoned: true,
+ implantModel: true,
+ implantManufacturer: true,
+ implantName: true,
+ isPressureAdjustable: true,
+ implantCatalog: {
+ select: {
+ pressureLevels: true,
+ },
+ },
+ patient: {
+ select: {
+ id: true,
+ name: true,
+ inpatientNo: true,
+ phone: true,
+ hospitalId: true,
+ doctorId: true,
+ doctor: {
+ select: {
+ departmentId: true,
+ groupId: true,
+ },
+ },
+ },
+ },
+ surgery: {
+ select: {
+ id: true,
+ surgeryName: true,
+ surgeryDate: true,
+ primaryDisease: true,
+ },
+ },
+ },
+ },
+} as const;
+
/**
* 任务服务:封装调压任务状态机、院内隔离与事件发布逻辑。
*/
@@ -67,6 +125,20 @@ export class TaskService {
});
}
+ async getTaskRecordItem(actor: ActorContext, id: number) {
+ this.assertRole(actor, [
+ Role.SYSTEM_ADMIN,
+ Role.HOSPITAL_ADMIN,
+ Role.DOCTOR,
+ Role.DIRECTOR,
+ Role.LEADER,
+ Role.ENGINEER,
+ ]);
+
+ const item = await this.findTaskRecordItemWithScope(actor, id);
+ return this.mapTaskRecordItemDetail(item);
+ }
+
/**
* 查询当前角色可见的调压记录列表。
*/
@@ -530,6 +602,106 @@ export class TaskService {
/**
* 校验角色权限。
*/
+ private async findTaskRecordItemWithScope(
+ actor: ActorContext,
+ id: number,
+ ) {
+ const item = await this.prisma.taskItem.findUnique({
+ where: { id },
+ select: TASK_RECORD_DETAIL_SELECT,
+ });
+
+ if (!item) {
+ throw new NotFoundException(MESSAGES.TASK.RECORD_NOT_FOUND);
+ }
+
+ this.assertTaskRecordScope(actor, item);
+ return item;
+ }
+
+ private assertTaskRecordScope(
+ actor: ActorContext,
+ item: Prisma.TaskItemGetPayload<{
+ select: typeof TASK_RECORD_DETAIL_SELECT;
+ }>,
+ ) {
+ const patient = item.device.patient;
+
+ switch (actor.role) {
+ case Role.SYSTEM_ADMIN:
+ return;
+ case Role.HOSPITAL_ADMIN:
+ case Role.ENGINEER: {
+ const hospitalId = this.requireHospitalId(actor);
+ if (hospitalId !== item.task.hospitalId) {
+ throw new NotFoundException(MESSAGES.TASK.RECORD_NOT_FOUND);
+ }
+ return;
+ }
+ case Role.DOCTOR:
+ if (patient.doctorId !== actor.id) {
+ throw new NotFoundException(MESSAGES.TASK.RECORD_NOT_FOUND);
+ }
+ return;
+ case Role.LEADER:
+ if (!actor.groupId) {
+ throw new BadRequestException(MESSAGES.PATIENT.GROUP_REQUIRED);
+ }
+ if (patient.doctor?.groupId !== actor.groupId) {
+ throw new NotFoundException(MESSAGES.TASK.RECORD_NOT_FOUND);
+ }
+ return;
+ case Role.DIRECTOR:
+ if (!actor.departmentId) {
+ throw new BadRequestException(MESSAGES.PATIENT.DEPARTMENT_REQUIRED);
+ }
+ if (patient.doctor?.departmentId !== actor.departmentId) {
+ throw new NotFoundException(MESSAGES.TASK.RECORD_NOT_FOUND);
+ }
+ return;
+ default:
+ throw new ForbiddenException(MESSAGES.TASK.ACTOR_ROLE_FORBIDDEN);
+ }
+ }
+
+ private mapTaskRecordItemDetail(
+ item: Prisma.TaskItemGetPayload<{
+ select: typeof TASK_RECORD_DETAIL_SELECT;
+ }>,
+ ) {
+ return {
+ id: item.id,
+ oldPressure: item.oldPressure,
+ targetPressure: item.targetPressure,
+ task: {
+ id: item.task.id,
+ status: item.task.status,
+ createdAt: item.task.createdAt,
+ creatorId: item.task.creatorId,
+ engineerId: item.task.engineerId,
+ hospitalId: item.task.hospitalId,
+ completionMaterials: Array.isArray(item.task.completionMaterials)
+ ? item.task.completionMaterials
+ : [],
+ },
+ patient: item.device.patient,
+ surgery: item.device.surgery,
+ device: {
+ id: item.device.id,
+ currentPressure: item.device.currentPressure,
+ status: item.device.status,
+ isAbandoned: item.device.isAbandoned,
+ implantModel: item.device.implantModel,
+ implantManufacturer: item.device.implantManufacturer,
+ implantName: item.device.implantName,
+ isPressureAdjustable: item.device.isPressureAdjustable,
+ pressureLevels: Array.isArray(item.device.implantCatalog?.pressureLevels)
+ ? item.device.implantCatalog.pressureLevels
+ : [],
+ },
+ };
+ }
+
private assertRole(actor: ActorContext, allowedRoles: Role[]) {
if (!allowedRoles.includes(actor.role)) {
throw new ForbiddenException(MESSAGES.TASK.ACTOR_ROLE_FORBIDDEN);
diff --git a/tyt-admin/src/views/patients/Patients.vue b/tyt-admin/src/views/patients/Patients.vue
index 2927e49..50af6d6 100644
--- a/tyt-admin/src/views/patients/Patients.vue
+++ b/tyt-admin/src/views/patients/Patients.vue
@@ -632,6 +632,16 @@
>
调压
+
+ 删除设备
+
@@ -806,7 +816,7 @@ import {
updatePatient,
updatePatientSurgery,
} from '../../api/patients';
-import { getImplantCatalogs } from '../../api/devices';
+import { deleteDevice, getImplantCatalogs } from '../../api/devices';
import { getDictionaries } from '../../api/dictionaries';
import { publishTask } from '../../api/tasks';
import {
@@ -847,6 +857,11 @@ const canPublishAdjustTask = computed(() =>
userStore.role,
),
);
+const canDeleteDeviceAction = computed(() =>
+ ['SYSTEM_ADMIN', 'HOSPITAL_ADMIN', 'DOCTOR', 'DIRECTOR', 'LEADER'].includes(
+ userStore.role,
+ ),
+);
const loading = ref(false);
const submitLoading = ref(false);
@@ -891,6 +906,7 @@ const detailTab = ref('profile');
const detailPatient = ref(null);
const detailLifecycle = ref([]);
const detailAdjustDeviceId = ref(null);
+const deletingDeviceId = ref(null);
const patientForm = reactive({
name: '',
@@ -1379,6 +1395,15 @@ function formatAdjustDeviceLabel(device) {
].join(' | ');
}
+function formatDeviceDetailLabel(device) {
+ return (
+ device?.implantName ||
+ device?.implantModel ||
+ device?.implantManufacturer ||
+ `设备 ${device?.id || ''}`
+ );
+}
+
function formatDateTime(value) {
if (!value) {
return '-';
@@ -1934,6 +1959,37 @@ async function handleSubmitAdjustTask() {
}
}
+async function handleDeleteDevice(device) {
+ if (!detailPatient.value?.id || !device?.id || deletingDeviceId.value) {
+ return;
+ }
+
+ try {
+ await ElMessageBox.confirm(
+ `确定要删除设备“${formatDeviceDetailLabel(device)}”吗?`,
+ '删除设备',
+ {
+ confirmButtonText: '确定',
+ cancelButtonText: '取消',
+ type: 'warning',
+ },
+ );
+ } catch {
+ return;
+ }
+
+ deletingDeviceId.value = device.id;
+ try {
+ await deleteDevice(device.id);
+ ElMessage.success('设备已删除');
+ await fetchData();
+ await openDetailDialog({ id: detailPatient.value.id });
+ detailTab.value = 'surgeries';
+ } finally {
+ deletingDeviceId.value = null;
+ }
+}
+
function handleDelete(row) {
ElMessageBox.confirm(`确定要删除患者 "${row.name}" 吗?`, '警告', {
confirmButtonText: '确定',
@@ -1972,9 +2028,14 @@ async function openDetailDialog(row) {
const fullLifecycle = Array.isArray(lifecycle?.lifecycle)
? lifecycle.lifecycle
: [];
- detailLifecycle.value = fullLifecycle.filter(
- (item) => item.patient?.id === detail.id,
- );
+ detailLifecycle.value = fullLifecycle.filter((item) => {
+ const eventPatientId = item?.patient?.id;
+ if (eventPatientId == null || eventPatientId === '') {
+ return true;
+ }
+
+ return Number(eventPatientId) === Number(detail.id);
+ });
const deviceOptions = buildDetailAdjustDeviceOptions(
detail,
detailLifecycle.value,
diff --git a/tyt-admin/src/views/tasks/Tasks.vue b/tyt-admin/src/views/tasks/Tasks.vue
index 629acc2..7682125 100644
--- a/tyt-admin/src/views/tasks/Tasks.vue
+++ b/tyt-admin/src/views/tasks/Tasks.vue
@@ -222,7 +222,12 @@
>
完成
- -