新增调压记录详情接口并完善设备删除能力与前台操作

This commit is contained in:
EL 2026-04-03 13:30:37 +08:00
parent cfd2f1e8dc
commit 5f7d66ce54
6 changed files with 279 additions and 27 deletions

View File

@ -74,6 +74,9 @@ export const MESSAGES = {
}, },
TASK: { TASK: {
RECORD_NOT_FOUND: '调压记录不存在或无权限访问',
UPDATE_ONLY_PENDING: '仅待处理调压记录可编辑',
DELETE_ONLY_PENDING_CANCELLED: '仅待处理/已取消调压记录可删除',
ITEMS_REQUIRED: '任务明细 items 不能为空', ITEMS_REQUIRED: '任务明细 items 不能为空',
DEVICE_NOT_FOUND: '存在设备不在当前医院或设备不存在', DEVICE_NOT_FOUND: '存在设备不在当前医院或设备不存在',
DEVICE_MULTI_HOSPITAL: '同一批调压任务中的设备必须属于同一家医院', DEVICE_MULTI_HOSPITAL: '同一批调压任务中的设备必须属于同一家医院',

View File

@ -63,6 +63,7 @@ export function buildPatientLifecyclePatient(patient: PatientLifecycleSource) {
id: toJsonNumber(patient.id), id: toJsonNumber(patient.id),
name: patient.name, name: patient.name,
inpatientNo: patient.inpatientNo, inpatientNo: patient.inpatientNo,
phone: patient.phone,
projectName: patient.projectName, projectName: patient.projectName,
}; };
} }

View File

@ -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 { import {
ApiBearerAuth, ApiBearerAuth,
ApiOperation, ApiOperation,
@ -19,9 +28,6 @@ import { CancelTaskDto } from '../dto/cancel-task.dto.js';
import { TaskRecordQueryDto } from '../dto/task-record-query.dto.js'; import { TaskRecordQueryDto } from '../dto/task-record-query.dto.js';
import { AssignableEngineerQueryDto } from '../dto/assignable-engineer-query.dto.js'; import { AssignableEngineerQueryDto } from '../dto/assignable-engineer-query.dto.js';
/**
* B
*/
@ApiTags('调压任务(B端)') @ApiTags('调压任务(B端)')
@ApiBearerAuth('bearer') @ApiBearerAuth('bearer')
@Controller('b/tasks') @Controller('b/tasks')
@ -29,9 +35,6 @@ import { AssignableEngineerQueryDto } from '../dto/assignable-engineer-query.dto
export class BTasksController { export class BTasksController {
constructor(private readonly taskService: TaskService) {} constructor(private readonly taskService: TaskService) {}
/**
*
*/
@Get('engineers') @Get('engineers')
@Roles( @Roles(
Role.SYSTEM_ADMIN, Role.SYSTEM_ADMIN,
@ -53,9 +56,6 @@ export class BTasksController {
return this.taskService.findAssignableEngineers(actor, query.hospitalId); return this.taskService.findAssignableEngineers(actor, query.hospitalId);
} }
/**
*
*/
@Get() @Get()
@Roles( @Roles(
Role.SYSTEM_ADMIN, Role.SYSTEM_ADMIN,
@ -78,9 +78,28 @@ export class BTasksController {
return this.taskService.findTaskRecords(actor, query); 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') @Post('publish')
@Roles( @Roles(
Role.SYSTEM_ADMIN, Role.SYSTEM_ADMIN,
@ -96,9 +115,6 @@ export class BTasksController {
return this.taskService.publishTask(actor, dto); return this.taskService.publishTask(actor, dto);
} }
/**
*
*/
@Post('accept') @Post('accept')
@Roles(Role.ENGINEER) @Roles(Role.ENGINEER)
@ApiOperation({ summary: '接收任务ENGINEER' }) @ApiOperation({ summary: '接收任务ENGINEER' })
@ -106,9 +122,6 @@ export class BTasksController {
return this.taskService.acceptTask(actor, dto); return this.taskService.acceptTask(actor, dto);
} }
/**
*
*/
@Post('complete') @Post('complete')
@Roles(Role.ENGINEER) @Roles(Role.ENGINEER)
@ApiOperation({ summary: '完成任务ENGINEER' }) @ApiOperation({ summary: '完成任务ENGINEER' })
@ -116,9 +129,6 @@ export class BTasksController {
return this.taskService.completeTask(actor, dto); return this.taskService.completeTask(actor, dto);
} }
/**
* ////
*/
@Post('cancel') @Post('cancel')
@Roles( @Roles(
Role.SYSTEM_ADMIN, Role.SYSTEM_ADMIN,

View File

@ -23,6 +23,64 @@ import { TaskRecordQueryDto } from './dto/task-record-query.dto.js';
import { MESSAGES } from '../common/messages.js'; import { MESSAGES } from '../common/messages.js';
import { normalizePressureLabel } from '../common/pressure-level.util.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[]) { private assertRole(actor: ActorContext, allowedRoles: Role[]) {
if (!allowedRoles.includes(actor.role)) { if (!allowedRoles.includes(actor.role)) {
throw new ForbiddenException(MESSAGES.TASK.ACTOR_ROLE_FORBIDDEN); throw new ForbiddenException(MESSAGES.TASK.ACTOR_ROLE_FORBIDDEN);

View File

@ -632,6 +632,16 @@
> >
调压 调压
</el-button> </el-button>
<el-button
v-if="canDeleteDeviceAction"
size="small"
type="danger"
plain
:loading="deletingDeviceId === device.id"
@click="handleDeleteDevice(device)"
>
删除设备
</el-button>
</div> </div>
</div> </div>
@ -806,7 +816,7 @@ import {
updatePatient, updatePatient,
updatePatientSurgery, updatePatientSurgery,
} from '../../api/patients'; } from '../../api/patients';
import { getImplantCatalogs } from '../../api/devices'; import { deleteDevice, getImplantCatalogs } from '../../api/devices';
import { getDictionaries } from '../../api/dictionaries'; import { getDictionaries } from '../../api/dictionaries';
import { publishTask } from '../../api/tasks'; import { publishTask } from '../../api/tasks';
import { import {
@ -847,6 +857,11 @@ const canPublishAdjustTask = computed(() =>
userStore.role, userStore.role,
), ),
); );
const canDeleteDeviceAction = computed(() =>
['SYSTEM_ADMIN', 'HOSPITAL_ADMIN', 'DOCTOR', 'DIRECTOR', 'LEADER'].includes(
userStore.role,
),
);
const loading = ref(false); const loading = ref(false);
const submitLoading = ref(false); const submitLoading = ref(false);
@ -891,6 +906,7 @@ const detailTab = ref('profile');
const detailPatient = ref(null); const detailPatient = ref(null);
const detailLifecycle = ref([]); const detailLifecycle = ref([]);
const detailAdjustDeviceId = ref(null); const detailAdjustDeviceId = ref(null);
const deletingDeviceId = ref(null);
const patientForm = reactive({ const patientForm = reactive({
name: '', name: '',
@ -1379,6 +1395,15 @@ function formatAdjustDeviceLabel(device) {
].join(' '); ].join(' ');
} }
function formatDeviceDetailLabel(device) {
return (
device?.implantName ||
device?.implantModel ||
device?.implantManufacturer ||
`设备 ${device?.id || ''}`
);
}
function formatDateTime(value) { function formatDateTime(value) {
if (!value) { if (!value) {
return '-'; 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) { function handleDelete(row) {
ElMessageBox.confirm(`确定要删除患者 "${row.name}" 吗?`, '警告', { ElMessageBox.confirm(`确定要删除患者 "${row.name}" 吗?`, '警告', {
confirmButtonText: '确定', confirmButtonText: '确定',
@ -1972,9 +2028,14 @@ async function openDetailDialog(row) {
const fullLifecycle = Array.isArray(lifecycle?.lifecycle) const fullLifecycle = Array.isArray(lifecycle?.lifecycle)
? lifecycle.lifecycle ? lifecycle.lifecycle
: []; : [];
detailLifecycle.value = fullLifecycle.filter( detailLifecycle.value = fullLifecycle.filter((item) => {
(item) => item.patient?.id === detail.id, const eventPatientId = item?.patient?.id;
); if (eventPatientId == null || eventPatientId === '') {
return true;
}
return Number(eventPatientId) === Number(detail.id);
});
const deviceOptions = buildDetailAdjustDeviceOptions( const deviceOptions = buildDetailAdjustDeviceOptions(
detail, detail,
detailLifecycle.value, detailLifecycle.value,

View File

@ -222,7 +222,12 @@
> >
完成 完成
</el-button> </el-button>
<span v-if="!canAccept(row) && !canCancel(row) && !canComplete(row)" <span
v-if="
!canAccept(row) &&
!canCancel(row) &&
!canComplete(row)
"
>-</span >-</span
> >
</template> </template>