feat(health): 新增依从性统计前端页面

- 新增 medicationAdherence.js:统计API接口
- 新增 medicationStatistic/index.vue:统计页面
  - 概览卡片:总任务、已服药、漏服、服药率、准时率
  - 趋势图:服药率和准时率折线图
  - 时段分布:饼图展示
  - 日历视图:月度服药日历
This commit is contained in:
bot5
2026-03-19 08:35:08 +08:00
parent d8caa6c7e6
commit f090aeb9b9
2 changed files with 620 additions and 0 deletions

View 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 }
})
}

View 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>