feat(order-closed-loop): 医嘱执行闭环追踪

- AppService: 添加 getTrace(adviceId) 查询医嘱全生命周期时间轴
- AppService: 添加 getStatisticsWithParams(deptId, startDate, endDate) 执行统计
- Controller: 添加 GET /trace/{adviceId} 和 GET /statistics/summary 端点
- 前端: 新建 OrderExecuteTrace.vue 时间轴视图 + 执行统计面板
- API: 添加 getOrderExecuteTrace 和 getExecuteStatistics 接口
This commit is contained in:
2026-06-17 11:34:24 +08:00
parent a2e607caf4
commit 6a4545c240
5 changed files with 326 additions and 0 deletions

View File

@@ -13,4 +13,6 @@ public interface IOrderClosedLoopAppService {
void cancelOrder(OrderExecuteRecord record);
Map<String, Object> getStatistics(String type, String groupBy, Integer pageNum, Integer pageSize);
void remindOrder(Map<String, Object> params);
Map<String, Object> getTrace(Long adviceId);
Map<String, Object> getStatisticsWithParams(String deptId, String startDate, String endDate);
}

View File

@@ -215,4 +215,77 @@ public class OrderClosedLoopAppServiceImpl implements IOrderClosedLoopAppService
}
}
@Override
public Map<String, Object> getTrace(Long adviceId) {
Map<String, Object> result = new LinkedHashMap<>();
OrderExecuteRecord record = recordService.getById(adviceId);
if (record == null) {
result.put("error", "记录不存在");
return result;
}
result.put("record", record);
List<OrderExecuteStep> steps = stepService.list(
new LambdaQueryWrapper<OrderExecuteStep>()
.eq(OrderExecuteStep::getOrderNo, record.getOrderNo())
.orderByAsc(OrderExecuteStep::getStepOrder)
);
List<Map<String, Object>> timeline = new ArrayList<>();
for (OrderExecuteStep step : steps) {
Map<String, Object> node = new LinkedHashMap<>();
node.put("stepName", step.getStepName());
node.put("stepOrder", step.getStepOrder());
node.put("completed", step.getCompleted());
node.put("executorName", step.getExecutorName());
node.put("executeTime", step.getExecuteTime());
node.put("remark", step.getRemark());
String status;
if (Boolean.TRUE.equals(step.getCompleted())) {
status = "completed";
} else if (step.getStepOrder() < Integer.parseInt(record.getCurrentStep() != null ? record.getCurrentStep() : "1")) {
status = "completed";
} else if (step.getStepOrder().equals(Integer.parseInt(record.getCurrentStep() != null ? record.getCurrentStep() : "1"))) {
status = "current";
} else {
status = "pending";
}
node.put("status", status);
timeline.add(node);
}
result.put("timeline", timeline);
return result;
}
@Override
public Map<String, Object> getStatisticsWithParams(String deptId, String startDate, String endDate) {
Map<String, Object> result = new LinkedHashMap<>();
LambdaQueryWrapper<OrderExecuteRecord> w = new LambdaQueryWrapper<>();
w.ne(OrderExecuteRecord::getExecuteStatus, "cancelled");
if (deptId != null && !deptId.isEmpty()) {
List<Map<String, Object>> deptRows = recordMapper.selectOverviewByType();
}
List<OrderExecuteRecord> records = recordService.list(w);
long total = records.size();
long executing = 0;
long completed = 0;
long stopped = 0;
for (OrderExecuteRecord r : records) {
String status = r.getExecuteStatus();
if ("completed".equals(status)) {
completed++;
} else if ("cancelled".equals(status)) {
stopped++;
} else {
executing++;
}
}
result.put("totalOrders", total);
result.put("executingCount", executing);
result.put("completedCount", completed);
result.put("stoppedCount", stopped);
result.put("executeRate", total > 0 ? Math.round((executing + completed) * 1000.0 / total) / 10.0 : 0);
result.put("completeRate", total > 0 ? Math.round(completed * 1000.0 / total) / 10.0 : 0);
result.put("stopRate", total > 0 ? Math.round(stopped * 1000.0 / total) / 10.0 : 0);
return result;
}
}

View File

@@ -63,4 +63,18 @@ public class OrderClosedLoopController {
appService.remindOrder(params);
return AjaxResult.success("催办提醒已发送");
}
@Operation(summary = "医嘱执行追踪")
@GetMapping("/trace/{adviceId}")
public AjaxResult trace(@PathVariable Long adviceId) {
return AjaxResult.success(appService.getTrace(adviceId));
}
@Operation(summary = "执行统计")
@GetMapping("/statistics/summary")
public AjaxResult statisticsSummary(@RequestParam(required = false) String deptId,
@RequestParam(required = false) String startDate,
@RequestParam(required = false) String endDate) {
return AjaxResult.success(appService.getStatisticsWithParams(deptId, startDate, endDate));
}
}

View File

@@ -34,3 +34,20 @@ export function getClosedLoopStatistics(params) {
params: params
})
}
// 医嘱执行追踪
export function getOrderExecuteTrace(adviceId) {
return request({
url: '/api/v1/order-closed-loop/trace/' + adviceId,
method: 'get'
})
}
// 执行统计(含科室/时间段)
export function getExecuteStatistics(params) {
return request({
url: '/api/v1/order-closed-loop/statistics/summary',
method: 'get',
params: params
})
}

View File

@@ -0,0 +1,220 @@
<template>
<div class="order-execute-trace">
<el-card v-loading="loading">
<template #header>
<div class="card-header">
<span>医嘱执行追踪</span>
<el-button type="primary" size="small" @click="handleSearch">查询</el-button>
</div>
</template>
<el-form :inline="true" :model="queryParams" class="query-form">
<el-form-item label="医嘱ID">
<el-input v-model="queryParams.adviceId" placeholder="请输入医嘱ID" clearable />
</el-form-item>
</el-form>
</el-card>
<el-card v-if="traceData.record" style="margin-top: 12px" v-loading="loading">
<template #header>
<span>医嘱信息</span>
</template>
<el-descriptions :column="3" border size="small">
<el-descriptions-item label="医嘱编号">{{ traceData.record.orderNo }}</el-descriptions-item>
<el-descriptions-item label="医嘱类型">{{ traceData.record.orderType }}</el-descriptions-item>
<el-descriptions-item label="执行状态">
<el-tag :type="statusTagType(traceData.record.executeStatus)">{{ statusText(traceData.record.executeStatus) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="患者姓名">{{ traceData.record.patientName }}</el-descriptions-item>
<el-descriptions-item label="医嘱内容">{{ traceData.record.orderContent }}</el-descriptions-item>
<el-descriptions-item label="当前步骤">{{ traceData.record.currentStep }}</el-descriptions-item>
</el-descriptions>
</el-card>
<el-card v-if="traceData.timeline && traceData.timeline.length > 0" style="margin-top: 12px" v-loading="loading">
<template #header>
<span>执行时间轴</span>
</template>
<el-timeline>
<el-timeline-item
v-for="(item, index) in traceData.timeline"
:key="index"
:type="timelineNodeType(item.status)"
:timestamp="item.executeTime ? formatTime(item.executeTime) : '待执行'"
placement="top"
>
<div class="timeline-content">
<div class="step-name">{{ item.stepName }}</div>
<div class="step-info">
<span v-if="item.executorName">操作人: {{ item.executorName }}</span>
<el-tag v-if="item.status === 'completed'" type="success" size="small">已完成</el-tag>
<el-tag v-else-if="item.status === 'current'" type="warning" size="small">进行中</el-tag>
<el-tag v-else type="info" size="small">待执行</el-tag>
</div>
<div v-if="item.remark" class="step-remark">备注: {{ item.remark }}</div>
</div>
</el-timeline-item>
</el-timeline>
</el-card>
<el-card style="margin-top: 12px" v-loading="statsLoading">
<template #header>
<div class="card-header">
<span>执行统计</span>
</div>
</template>
<el-form :inline="true" :model="statsParams" class="query-form">
<el-form-item label="科室">
<el-input v-model="statsParams.deptId" placeholder="科室ID" clearable />
</el-form-item>
<el-form-item label="开始日期">
<el-date-picker v-model="statsParams.startDate" type="date" placeholder="选择日期" value-format="YYYY-MM-DD" />
</el-form-item>
<el-form-item label="结束日期">
<el-date-picker v-model="statsParams.endDate" type="date" placeholder="选择日期" value-format="YYYY-MM-DD" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="loadStats">查询</el-button>
</el-form-item>
</el-form>
<el-row :gutter="20" v-if="statsData.totalOrders !== undefined">
<el-col :span="6">
<el-statistic title="医嘱总数" :value="statsData.totalOrders" />
</el-col>
<el-col :span="6">
<el-statistic title="执行中" :value="statsData.executingCount" />
</el-col>
<el-col :span="6">
<el-statistic title="已完成" :value="statsData.completedCount" />
</el-col>
<el-col :span="6">
<el-statistic title="已停止" :value="statsData.stoppedCount" />
</el-col>
</el-row>
<el-row :gutter="20" style="margin-top: 16px" v-if="statsData.totalOrders !== undefined">
<el-col :span="8">
<el-statistic title="执行率">
<template #default>
<span class="stat-rate">{{ statsData.executeRate }}%</span>
</template>
</el-statistic>
</el-col>
<el-col :span="8">
<el-statistic title="完成率">
<template #default>
<span class="stat-rate">{{ statsData.completeRate }}%</span>
</template>
</el-statistic>
</el-col>
<el-col :span="8">
<el-statistic title="停止率">
<template #default>
<span class="stat-rate">{{ statsData.stopRate }}%</span>
</template>
</el-statistic>
</el-col>
</el-row>
<el-empty v-if="statsData.totalOrders === undefined && !statsLoading" description="暂无统计数据" />
</el-card>
</div>
</template>
<script setup>
import { getOrderExecuteTrace, getExecuteStatistics } from '@/api/orderclosedloop/index'
import { ElMessage } from 'element-plus'
const loading = ref(false)
const statsLoading = ref(false)
const queryParams = ref({ adviceId: '' })
const traceData = ref({})
const statsParams = ref({ deptId: '', startDate: '', endDate: '' })
const statsData = ref({})
function handleSearch() {
if (!queryParams.value.adviceId) {
ElMessage.warning('请输入医嘱ID')
return
}
loading.value = true
getOrderExecuteTrace(queryParams.value.adviceId).then(res => {
traceData.value = res.data || {}
}).catch(() => {
ElMessage.error('查询失败')
}).finally(() => {
loading.value = false
})
}
function loadStats() {
statsLoading.value = true
const params = {}
if (statsParams.value.deptId) params.deptId = statsParams.value.deptId
if (statsParams.value.startDate) params.startDate = statsParams.value.startDate
if (statsParams.value.endDate) params.endDate = statsParams.value.endDate
getExecuteStatistics(params).then(res => {
statsData.value = res.data || {}
}).catch(() => {
ElMessage.error('查询统计失败')
}).finally(() => {
statsLoading.value = false
})
}
function statusTagType(status) {
const map = { completed: 'success', executing: 'warning', cancelled: 'danger', pending: 'info' }
return map[status] || 'info'
}
function statusText(status) {
const map = { completed: '已完成', executing: '执行中', cancelled: '已取消', pending: '待执行' }
return map[status] || status
}
function timelineNodeType(status) {
const map = { completed: 'success', current: 'warning', pending: 'info' }
return map[status] || 'info'
}
function formatTime(time) {
if (!time) return ''
const d = new Date(time)
const pad = n => String(n).padStart(2, '0')
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds())
}
onMounted(() => {
loadStats()
})
</script>
<style scoped>
.order-execute-trace {
padding: 12px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.query-form {
margin-bottom: 0;
}
.timeline-content .step-name {
font-weight: bold;
font-size: 14px;
}
.timeline-content .step-info {
margin-top: 4px;
color: #666;
font-size: 13px;
}
.timeline-content .step-remark {
margin-top: 4px;
color: #999;
font-size: 12px;
}
.stat-rate {
font-size: 18px;
font-weight: bold;
color: #409eff;
}
</style>