feat(health): 新增依从性统计前端页面
- 新增 medicationAdherence.js:统计API接口
- 新增 medicationStatistic/index.vue:统计页面
- 概览卡片:总任务、已服药、漏服、服药率、准时率
- 趋势图:服药率和准时率折线图
- 时段分布:饼图展示
- 日历视图:月度服药日历
(cherry picked from commit f090aeb9b9)
This commit is contained in:
73
src/api/health/medicationAdherence.js
Normal file
73
src/api/health/medicationAdherence.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 获取总体依从性统计
|
||||
export function getOverallAdherence(query) {
|
||||
return request({
|
||||
url: '/health/adherence/overall',
|
||||
method: 'get',
|
||||
params: query
|
||||
})
|
||||
}
|
||||
|
||||
// 获取每日依从性统计列表
|
||||
export function getDailyAdherence(query) {
|
||||
return request({
|
||||
url: '/health/adherence/daily',
|
||||
method: 'get',
|
||||
params: query
|
||||
})
|
||||
}
|
||||
|
||||
// 获取日历视图数据
|
||||
export function getCalendarData(personId, yearMonth) {
|
||||
return request({
|
||||
url: '/health/adherence/calendar',
|
||||
method: 'get',
|
||||
params: { personId, yearMonth }
|
||||
})
|
||||
}
|
||||
|
||||
// 获取各时段服药统计
|
||||
export function getTimeSlotAdherence(query) {
|
||||
return request({
|
||||
url: '/health/adherence/timeSlot',
|
||||
method: 'get',
|
||||
params: query
|
||||
})
|
||||
}
|
||||
|
||||
// 获取各药品依从性统计
|
||||
export function getMedicineAdherence(query) {
|
||||
return request({
|
||||
url: '/health/adherence/medicine',
|
||||
method: 'get',
|
||||
params: query
|
||||
})
|
||||
}
|
||||
|
||||
// 获取各人员依从性统计
|
||||
export function getPersonAdherence(query) {
|
||||
return request({
|
||||
url: '/health/adherence/person',
|
||||
method: 'get',
|
||||
params: query
|
||||
})
|
||||
}
|
||||
|
||||
// 获取漏服原因统计
|
||||
export function getMissedReasonStats(query) {
|
||||
return request({
|
||||
url: '/health/adherence/missedReason',
|
||||
method: 'get',
|
||||
params: query
|
||||
})
|
||||
}
|
||||
|
||||
// 获取仪表盘概览数据
|
||||
export function getDashboard(personId) {
|
||||
return request({
|
||||
url: '/health/adherence/dashboard',
|
||||
method: 'get',
|
||||
params: { personId }
|
||||
})
|
||||
}
|
||||
547
src/views/health/medicationStatistic/index.vue
Normal file
547
src/views/health/medicationStatistic/index.vue
Normal file
@@ -0,0 +1,547 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<!-- 查询条件 -->
|
||||
<div class="search-con">
|
||||
<div class="title">查询条件</div>
|
||||
<el-form :model="queryParams" ref="queryRef" :inline="true" label-width="80px">
|
||||
<el-form-item label="人员" prop="personId">
|
||||
<el-select v-model="queryParams.personId" placeholder="请选择人员" clearable filterable>
|
||||
<el-option v-for="item in personList" :key="item.id" :label="item.name" :value="item.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="统计周期">
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
range-separator="-"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
style="width: 240px"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="search-btn-con">
|
||||
<el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
|
||||
<el-button type="info" icon="Refresh" @click="resetQuery">重置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 概览卡片 -->
|
||||
<div class="overview-cards" v-loading="loading">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon" style="background: #409EFF;">
|
||||
<el-icon><Calendar /></el-icon>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ overview.totalTasks || 0 }}</div>
|
||||
<div class="stat-label">总任务数</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon" style="background: #67C23A;">
|
||||
<el-icon><Check /></el-icon>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ overview.completedTasks || 0 }}</div>
|
||||
<div class="stat-label">已服药</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon" style="background: #F56C6C;">
|
||||
<el-icon><Close /></el-icon>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ overview.missedTasks || 0 }}</div>
|
||||
<div class="stat-label">漏服</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon" style="background: #E6A23C;">
|
||||
<el-icon><Timer /></el-icon>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ overview.adherenceRate || 0 }}%</div>
|
||||
<div class="stat-label">服药率</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon" style="background: #909399;">
|
||||
<el-icon><Clock /></el-icon>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ overview.ontimeRate || 0 }}%</div>
|
||||
<div class="stat-label">准时率</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图表区域 -->
|
||||
<div class="charts-row">
|
||||
<!-- 趋势图 -->
|
||||
<div class="chart-card">
|
||||
<div class="chart-title">服药趋势(最近7天)</div>
|
||||
<div ref="trendChartRef" class="chart-container"></div>
|
||||
</div>
|
||||
<!-- 时段统计 -->
|
||||
<div class="chart-card">
|
||||
<div class="chart-title">时段分布</div>
|
||||
<div ref="timeSlotChartRef" class="chart-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 日历视图 -->
|
||||
<div class="calendar-section">
|
||||
<div class="calendar-header">
|
||||
<span class="calendar-title">日历视图</span>
|
||||
<div class="calendar-nav">
|
||||
<el-button-group>
|
||||
<el-button @click="changeMonth(-1)" icon="ArrowLeft">上月</el-button>
|
||||
<el-button>{{ currentMonth }}</el-button>
|
||||
<el-button @click="changeMonth(1)">下月<el-icon class="el-icon--right"><ArrowRight /></el-icon></el-button>
|
||||
</el-button-group>
|
||||
</div>
|
||||
</div>
|
||||
<div class="calendar-grid" v-loading="calendarLoading">
|
||||
<div class="calendar-weekdays">
|
||||
<div v-for="day in weekdays" :key="day" class="weekday">{{ day }}</div>
|
||||
</div>
|
||||
<div class="calendar-days">
|
||||
<div
|
||||
v-for="(day, index) in calendarDays"
|
||||
:key="index"
|
||||
class="calendar-day"
|
||||
:class="{ 'other-month': day.otherMonth, 'today': day.isToday }"
|
||||
>
|
||||
<div class="day-number">{{ day.day }}</div>
|
||||
<div class="day-stats" v-if="day.data && day.data.totalTasks > 0">
|
||||
<span class="stat-dot completed" :title="`已服药: ${day.data.completedTasks}`"></span>
|
||||
<span class="stat-dot missed" v-if="day.data.missedTasks > 0" :title="`漏服: ${day.data.missedTasks}`"></span>
|
||||
<span class="stat-rate">{{ day.data.adherenceRate }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup name="MedicationStatistic">
|
||||
import { ref, reactive, onMounted, watch, computed, nextTick } from 'vue'
|
||||
import { getOverallAdherence, getDailyAdherence, getCalendarData, getTimeSlotAdherence } from '@/api/health/medicationAdherence'
|
||||
import { listPerson } from '@/api/health/person'
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
const { proxy } = getCurrentInstance()
|
||||
|
||||
const loading = ref(false)
|
||||
const calendarLoading = ref(false)
|
||||
const personList = ref([])
|
||||
const queryParams = reactive({
|
||||
personId: null
|
||||
})
|
||||
const dateRange = ref([])
|
||||
const overview = ref({})
|
||||
|
||||
// 日历相关
|
||||
const currentMonth = ref('')
|
||||
const currentYearMonth = ref('')
|
||||
const calendarData = ref([])
|
||||
const weekdays = ['一', '二', '三', '四', '五', '六', '日']
|
||||
|
||||
// 图表
|
||||
const trendChartRef = ref(null)
|
||||
const timeSlotChartRef = ref(null)
|
||||
let trendChart = null
|
||||
let timeSlotChart = null
|
||||
|
||||
// 计算日历天数
|
||||
const calendarDays = computed(() => {
|
||||
const days = []
|
||||
if (!currentYearMonth.value) return days
|
||||
|
||||
const [year, month] = currentYearMonth.value.split('-').map(Number)
|
||||
const firstDay = new Date(year, month - 1, 1)
|
||||
const lastDay = new Date(year, month, 0)
|
||||
const startWeekday = (firstDay.getDay() + 6) % 7 // 周一为0
|
||||
|
||||
// 上月填充
|
||||
const prevMonthLastDay = new Date(year, month - 1, 0).getDate()
|
||||
for (let i = startWeekday - 1; i >= 0; i--) {
|
||||
days.push({
|
||||
day: prevMonthLastDay - i,
|
||||
otherMonth: true,
|
||||
isToday: false,
|
||||
data: null
|
||||
})
|
||||
}
|
||||
|
||||
// 当月
|
||||
const today = new Date()
|
||||
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
|
||||
|
||||
const dataMap = {}
|
||||
calendarData.value.forEach(d => {
|
||||
dataMap[d.date] = d
|
||||
})
|
||||
|
||||
for (let i = 1; i <= lastDay.getDate(); i++) {
|
||||
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(i).padStart(2, '0')}`
|
||||
days.push({
|
||||
day: i,
|
||||
otherMonth: false,
|
||||
isToday: dateStr === todayStr,
|
||||
data: dataMap[dateStr] || null
|
||||
})
|
||||
}
|
||||
|
||||
// 下月填充
|
||||
const remaining = 42 - days.length
|
||||
for (let i = 1; i <= remaining; i++) {
|
||||
days.push({
|
||||
day: i,
|
||||
otherMonth: true,
|
||||
isToday: false,
|
||||
data: null
|
||||
})
|
||||
}
|
||||
|
||||
return days
|
||||
})
|
||||
|
||||
// 获取人员列表
|
||||
const getPersonList = async () => {
|
||||
try {
|
||||
const res = await listPerson({})
|
||||
personList.value = res.rows || []
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
// 查询统计数据
|
||||
const getStatistics = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = { ...queryParams }
|
||||
if (dateRange.value && dateRange.value.length === 2) {
|
||||
params.startDate = dateRange.value[0]
|
||||
params.endDate = dateRange.value[1]
|
||||
} else {
|
||||
// 默认最近7天
|
||||
const today = new Date()
|
||||
const weekAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000)
|
||||
params.startDate = weekAgo.toISOString().split('T')[0]
|
||||
params.endDate = today.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
// 获取总体统计
|
||||
const overviewRes = await getOverallAdherence(params)
|
||||
overview.value = overviewRes.data || {}
|
||||
|
||||
// 获取每日趋势
|
||||
const dailyRes = await getDailyAdherence(params)
|
||||
renderTrendChart(dailyRes.data || [])
|
||||
|
||||
// 获取时段统计
|
||||
const timeSlotRes = await getTimeSlotAdherence(params)
|
||||
renderTimeSlotChart(timeSlotRes.data || {})
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染趋势图
|
||||
const renderTrendChart = (data) => {
|
||||
nextTick(() => {
|
||||
if (!trendChartRef.value) return
|
||||
if (!trendChart) {
|
||||
trendChart = echarts.init(trendChartRef.value)
|
||||
}
|
||||
|
||||
const option = {
|
||||
tooltip: {
|
||||
trigger: 'axis'
|
||||
},
|
||||
legend: {
|
||||
data: ['服药率', '准时率']
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '3%',
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: data.map(d => d.date?.substring(5) || '')
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
max: 100
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '服药率',
|
||||
type: 'line',
|
||||
data: data.map(d => d.adherenceRate || 0),
|
||||
smooth: true,
|
||||
itemStyle: { color: '#409EFF' }
|
||||
},
|
||||
{
|
||||
name: '准时率',
|
||||
type: 'line',
|
||||
data: data.map(d => d.ontimeRate || 0),
|
||||
smooth: true,
|
||||
itemStyle: { color: '#67C23A' }
|
||||
}
|
||||
]
|
||||
}
|
||||
trendChart.setOption(option)
|
||||
})
|
||||
}
|
||||
|
||||
// 渲染时段统计图
|
||||
const renderTimeSlotChart = (data) => {
|
||||
nextTick(() => {
|
||||
if (!timeSlotChartRef.value) return
|
||||
if (!timeSlotChart) {
|
||||
timeSlotChart = echarts.init(timeSlotChartRef.value)
|
||||
}
|
||||
|
||||
const slotNames = {
|
||||
morning: '上午(6-12)',
|
||||
afternoon: '下午(12-18)',
|
||||
evening: '晚间(18-24)',
|
||||
night: '凌晨(0-6)'
|
||||
}
|
||||
|
||||
const option = {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{b}: {c}%'
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['40%', '70%'],
|
||||
data: Object.entries(data).map(([key, value]) => ({
|
||||
name: slotNames[key] || key,
|
||||
value: value.adherenceRate || 0
|
||||
})),
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
shadowBlur: 10,
|
||||
shadowOffsetX: 0,
|
||||
shadowColor: 'rgba(0, 0, 0, 0.5)'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
timeSlotChart.setOption(option)
|
||||
})
|
||||
}
|
||||
|
||||
// 获取日历数据
|
||||
const getCalendar = async () => {
|
||||
calendarLoading.value = true
|
||||
try {
|
||||
const res = await getCalendarData(queryParams.personId, currentYearMonth.value)
|
||||
calendarData.value = res.data || []
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
calendarLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 切换月份
|
||||
const changeMonth = (delta) => {
|
||||
const [year, month] = currentYearMonth.value.split('-').map(Number)
|
||||
const newDate = new Date(year, month - 1 + delta, 1)
|
||||
currentYearMonth.value = `${newDate.getFullYear()}-${String(newDate.getMonth() + 1).padStart(2, '0')}`
|
||||
currentMonth.value = `${newDate.getFullYear()}年${newDate.getMonth() + 1}月`
|
||||
getCalendar()
|
||||
}
|
||||
|
||||
const handleQuery = () => {
|
||||
getStatistics()
|
||||
getCalendar()
|
||||
}
|
||||
|
||||
const resetQuery = () => {
|
||||
queryParams.personId = null
|
||||
dateRange.value = []
|
||||
handleQuery()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 初始化日期
|
||||
const today = new Date()
|
||||
currentYearMonth.value = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}`
|
||||
currentMonth.value = `${today.getFullYear()}年${today.getMonth() + 1}月`
|
||||
|
||||
getPersonList()
|
||||
getStatistics()
|
||||
getCalendar()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.overview-cards {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
min-width: 180px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||
|
||||
.stat-icon {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
color: #fff;
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #303133;
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.charts-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
flex: 1;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||
|
||||
.chart-title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 12px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-section {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||
|
||||
.calendar-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.calendar-title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #303133;
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-grid {
|
||||
.calendar-weekdays {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
text-align: center;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.weekday {
|
||||
padding: 8px;
|
||||
color: #909399;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-days {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 4px;
|
||||
|
||||
.calendar-day {
|
||||
min-height: 80px;
|
||||
border: 1px solid #EBEEF5;
|
||||
border-radius: 4px;
|
||||
padding: 4px;
|
||||
|
||||
&.other-month {
|
||||
background: #F5F7FA;
|
||||
.day-number { color: #C0C4CC; }
|
||||
}
|
||||
|
||||
&.today {
|
||||
border-color: #409EFF;
|
||||
.day-number {
|
||||
color: #409EFF;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.day-number {
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.day-stats {
|
||||
margin-top: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
.stat-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
|
||||
&.completed { background: #67C23A; }
|
||||
&.missed { background: #F56C6C; }
|
||||
}
|
||||
|
||||
.stat-rate {
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user