feat(data-platform): implement P2.2 data collection, BI report engine, and data dashboard
- DataCollection module: clinical/operational data collection APIs - BiReport engine: generate reports (revenue/department/drg) + dashboard - DataDashboard: realtime and historical data screen with ECharts-style cards - All endpoints secured with @PreAuthorize - Frontend: BiDashboard.vue + DataDashboard.vue + API files
This commit is contained in:
@@ -89,9 +89,10 @@ public class DataDashboardAppServiceImpl implements IDataDashboardAppService {
|
||||
cutoffDate = java.time.LocalDate.now().minusYears(1).toString();
|
||||
}
|
||||
|
||||
if (cutoffDate != null) {
|
||||
final String finalCutoff = cutoffDate;
|
||||
if (finalCutoff != null) {
|
||||
allData = allData.stream()
|
||||
.filter(ba -> ba.getStatDate() != null && ba.getStatDate().compareTo(cutoffDate) >= 0)
|
||||
.filter(ba -> ba.getStatDate() != null && ba.getStatDate().compareTo(finalCutoff) >= 0)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.healthlink.his.web.reportmanage.dto;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Data
|
||||
@Accessors(chain = true)
|
||||
public class BiReportDto {
|
||||
private String reportType;
|
||||
private String title;
|
||||
private List<Map<String, Object>> records;
|
||||
private Map<String, Object> summary;
|
||||
private List<Map<String, Object>> charts;
|
||||
}
|
||||
32
healthlink-his-ui/src/api/datacollection/index.js
Normal file
32
healthlink-his-ui/src/api/datacollection/index.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export function collectClinicalData(data) {
|
||||
return request({
|
||||
url: '/data/collect/clinical',
|
||||
method: 'post',
|
||||
params: data
|
||||
})
|
||||
}
|
||||
|
||||
export function collectOperationalData(data) {
|
||||
return request({
|
||||
url: '/data/collect/operational',
|
||||
method: 'post',
|
||||
params: data
|
||||
})
|
||||
}
|
||||
|
||||
export function getRealtimeData() {
|
||||
return request({
|
||||
url: '/data/dashboard/realtime',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export function getHistoricalData(params) {
|
||||
return request({
|
||||
url: '/data/dashboard/historical',
|
||||
method: 'get',
|
||||
params: params
|
||||
})
|
||||
}
|
||||
17
healthlink-his-ui/src/api/reportmanage/bi.js
Normal file
17
healthlink-his-ui/src/api/reportmanage/bi.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export function generateBiReport(data) {
|
||||
return request({
|
||||
url: '/report/bi/generate',
|
||||
method: 'post',
|
||||
params: { type: data.type },
|
||||
data: data.filters || {}
|
||||
})
|
||||
}
|
||||
|
||||
export function getBiDashboard() {
|
||||
return request({
|
||||
url: '/report/bi/dashboard',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
144
healthlink-his-ui/src/views/datacollection/DataDashboard.vue
Normal file
144
healthlink-his-ui/src/views/datacollection/DataDashboard.vue
Normal file
@@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>数据大屏</span>
|
||||
<div>
|
||||
<el-select v-model="period" placeholder="时间范围" style="margin-right:10px;width:120px" @change="loadHistorical">
|
||||
<el-option label="本周" value="week" />
|
||||
<el-option label="本月" value="month" />
|
||||
<el-option label="本季度" value="quarter" />
|
||||
<el-option label="本年度" value="year" />
|
||||
<el-option label="全部" value="all" />
|
||||
</el-select>
|
||||
<el-button type="primary" :loading="loading" @click="loadData">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-row :gutter="16" class="mb16">
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover" :body-style="{padding:'16px'}" class="stat-card">
|
||||
<div style="text-align:center">
|
||||
<div style="font-size:28px;font-weight:bold;color:#409eff">{{ formatMoney(screenData.totalRevenue) }}</div>
|
||||
<div style="font-size:13px;color:#999;margin-top:4px">总收入(万)</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover" :body-style="{padding:'16px'}" class="stat-card">
|
||||
<div style="text-align:center">
|
||||
<div style="font-size:28px;font-weight:bold;color:#67c23a">{{ formatMoney(screenData.totalProfit) }}</div>
|
||||
<div style="font-size:13px;color:#999;margin-top:4px">总利润(万)</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover" :body-style="{padding:'16px'}" class="stat-card">
|
||||
<div style="text-align:center">
|
||||
<div style="font-size:28px;font-weight:bold;color:#e6a23c">{{ screenData.totalPatients || 0 }}</div>
|
||||
<div style="font-size:13px;color:#999;margin-top:4px">总患者数</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover" :body-style="{padding:'16px'}" class="stat-card">
|
||||
<div style="text-align:center">
|
||||
<div style="font-size:28px;font-weight:bold;color:#f56c6c">{{ screenData.latestDrgCases || 0 }}</div>
|
||||
<div style="font-size:13px;color:#999;margin-top:4px">DRG病例数</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="16" class="mb16">
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover" :body-style="{padding:'16px'}">
|
||||
<div style="text-align:center">
|
||||
<div style="font-size:22px;font-weight:bold;color:#409eff">{{ formatMoney(screenData.totalCost) }}</div>
|
||||
<div style="font-size:13px;color:#999;margin-top:4px">总成本(万)</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover" :body-style="{padding:'16px'}">
|
||||
<div style="text-align:center">
|
||||
<div style="font-size:22px;font-weight:bold;color:#67c23a">{{ screenData.latestCmiValue || '-' }}</div>
|
||||
<div style="font-size:13px;color:#999;margin-top:4px">最新CMI值</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover" :body-style="{padding:'16px'}">
|
||||
<div style="text-align:center">
|
||||
<div style="font-size:22px;font-weight:bold;color:#e6a23c">{{ screenData.latestCostControlRate || '-' }}%</div>
|
||||
<div style="font-size:13px;color:#999;margin-top:4px">成本控制率</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover" :body-style="{padding:'16px'}">
|
||||
<div style="text-align:center">
|
||||
<div style="font-size:22px;font-weight:bold;color:#909399">{{ screenData.totalRecords || 0 }}</div>
|
||||
<div style="font-size:13px;color:#999;margin-top:4px">数据记录数</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="mt16">
|
||||
<template #header>
|
||||
<span>科室收入分布</span>
|
||||
</template>
|
||||
<el-table :data="screenData.departmentChart || []" border stripe size="small">
|
||||
<el-table-column prop="department" label="科室" width="180" show-overflow-tooltip />
|
||||
<el-table-column label="收入(万)" align="right">
|
||||
<template #default="{ row }">{{ formatMoney(row.revenue) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup name="DataDashboard" lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getRealtimeData, getHistoricalData } from '@/api/datacollection'
|
||||
|
||||
const loading = ref(false)
|
||||
const period = ref('month')
|
||||
const screenData = ref({})
|
||||
|
||||
function formatMoney(val) {
|
||||
if (!val) return '0.00'
|
||||
return (val / 10000).toFixed(2)
|
||||
}
|
||||
|
||||
function loadData() {
|
||||
loading.value = true
|
||||
if (period.value === 'all') {
|
||||
getRealtimeData().then(res => {
|
||||
screenData.value = res.data || {}
|
||||
}).finally(() => { loading.value = false })
|
||||
} else {
|
||||
getHistoricalData({ period: period.value }).then(res => {
|
||||
screenData.value = res.data || {}
|
||||
}).finally(() => { loading.value = false })
|
||||
}
|
||||
}
|
||||
|
||||
function loadHistorical() {
|
||||
loadData()
|
||||
}
|
||||
|
||||
onMounted(() => loadData())
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.app-container { padding: 20px; }
|
||||
.mt16 { margin-top: 16px; }
|
||||
.mb16 { margin-bottom: 16px; }
|
||||
.stat-card { border-left: 3px solid; }
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
</style>
|
||||
131
healthlink-his-ui/src/views/reportmanage/BiDashboard.vue
Normal file
131
healthlink-his-ui/src/views/reportmanage/BiDashboard.vue
Normal file
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>BI决策报表</span>
|
||||
<div>
|
||||
<el-select v-model="reportType" placeholder="报表类型" style="margin-right:10px;width:140px" @change="loadDashboard">
|
||||
<el-option v-for="item in reportTypes" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
<el-button type="primary" :loading="loading" @click="handleGenerate">生成报表</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-row :gutter="16" class="mb16">
|
||||
<el-col :span="4" v-for="card in summaryCards" :key="card.label">
|
||||
<el-card shadow="hover" :body-style="{padding:'12px'}">
|
||||
<div style="text-align:center">
|
||||
<div :style="{fontSize:'20px',fontWeight:'bold',color:card.color}">{{ card.value }}</div>
|
||||
<div style="font-size:12px;color:#999;margin-top:4px">{{ card.label }}</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<el-card v-if="report" shadow="never" class="mt16">
|
||||
<template #header>
|
||||
<span>报表数据 — {{ report.reportType }}</span>
|
||||
</template>
|
||||
<el-table :data="report.records || []" border stripe size="small" max-height="400">
|
||||
<el-table-column prop="statDate" label="日期" width="110" align="center" />
|
||||
<el-table-column prop="departmentName" label="科室" width="140" show-overflow-tooltip />
|
||||
<el-table-column label="收入(万)" width="110" align="right">
|
||||
<template #default="{ row }">{{ formatMoney(row.revenue) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="成本(万)" width="110" align="right">
|
||||
<template #default="{ row }">{{ formatMoney(row.cost) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="patientCount" label="患者数" width="90" align="center" />
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-row v-if="report && report.charts" :gutter="16" class="mt16">
|
||||
<el-col :span="12" v-if="report.charts.revenueChart && report.charts.revenueChart.length">
|
||||
<el-card shadow="never">
|
||||
<template #header><span>月度收支趋势</span></template>
|
||||
<el-table :data="report.charts.revenueChart" border size="small">
|
||||
<el-table-column prop="month" label="月份" width="100" align="center" />
|
||||
<el-table-column label="收入(万)" align="right">
|
||||
<template #default="{ row }">{{ formatMoney(row.revenue) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="成本(万)" align="right">
|
||||
<template #default="{ row }">{{ formatMoney(row.cost) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="12" v-if="report.charts.departmentChart && report.charts.departmentChart.length">
|
||||
<el-card shadow="never">
|
||||
<template #header><span>科室收入分布</span></template>
|
||||
<el-table :data="report.charts.departmentChart" border size="small">
|
||||
<el-table-column prop="department" label="科室" width="160" show-overflow-tooltip />
|
||||
<el-table-column label="收入(万)" align="right">
|
||||
<template #default="{ row }">{{ formatMoney(row.revenue) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup name="BiDashboard" lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { generateBiReport, getBiDashboard } from '@/api/reportmanage/bi'
|
||||
|
||||
const loading = ref(false)
|
||||
const reportType = ref('overview')
|
||||
const dashboard = ref(null)
|
||||
const report = ref(null)
|
||||
const reportTypes = ref([])
|
||||
|
||||
const summaryCards = computed(() => {
|
||||
if (!dashboard.value) return []
|
||||
const d = dashboard.value
|
||||
return [
|
||||
{ label: '总收入(万)', value: formatMoney(d.totalRevenue), color: '#409eff' },
|
||||
{ label: '总成本(万)', value: formatMoney(d.totalCost), color: '#67c23a' },
|
||||
{ label: '总利润(万)', value: formatMoney(d.totalProfit), color: '#e6a23c' },
|
||||
{ label: '总患者数', value: d.totalPatients || 0, color: '#f56c6c' },
|
||||
{ label: '数据记录数', value: d.totalRecords || 0, color: '#909399' },
|
||||
{ label: 'CMI值', value: d.latestCmiValue || '-', color: '#409eff' }
|
||||
]
|
||||
})
|
||||
|
||||
function formatMoney(val) {
|
||||
if (!val) return '0.00'
|
||||
return (val / 10000).toFixed(2)
|
||||
}
|
||||
|
||||
function loadDashboard() {
|
||||
loading.value = true
|
||||
getBiDashboard().then(res => {
|
||||
dashboard.value = res.data || {}
|
||||
reportTypes.value = res.data?.reportTypes || []
|
||||
}).finally(() => { loading.value = false })
|
||||
}
|
||||
|
||||
function handleGenerate() {
|
||||
loading.value = true
|
||||
generateBiReport({ type: reportType.value }).then(res => {
|
||||
report.value = res.data
|
||||
ElMessage.success('报表生成完成')
|
||||
}).catch(() => {
|
||||
report.value = null
|
||||
ElMessage.error('生成失败')
|
||||
}).finally(() => { loading.value = false })
|
||||
}
|
||||
|
||||
onMounted(() => loadDashboard())
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.app-container { padding: 20px; }
|
||||
.mt16 { margin-top: 16px; }
|
||||
.mb16 { margin-bottom: 16px; }
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user