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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user