feat(health): H5端新增用药统计页面

- 新增 medicationAdherence.js:统计API接口
- 新增 statistic/medication/index.vue:用药统计页面
  - 概览卡片:总任务、已服药、漏服、服药率、准时率
  - 趋势图:服药率和准时率折线图
  - 时段分布:四时段统计卡片
  - 日历视图:月度服药日历
- 修改 pages.json:添加页面路由
- 修改 statistic/index.vue:添加健康统计分析入口
This commit is contained in:
bot5
2026-03-19 09:05:52 +08:00
parent d12bf90050
commit 6af1a84b81
4 changed files with 668 additions and 0 deletions

View File

@@ -0,0 +1,46 @@
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 getDashboard(personId) {
return request({
url: '/health/adherence/dashboard',
method: 'get',
params: { personId }
})
}

View File

@@ -238,6 +238,14 @@
}
}
,
{
"path": "pages/statistic/medication/index",
"style": {
"navigationBarTitleText": "用药统计",
"navigationStyle": "custom"
}
}
,
{
"path": "pages/work/accounts/accountDealRecord/list",
"style": {

View File

@@ -74,6 +74,26 @@
</view>
</view>
</view>
<!-- 健康统计分析 -->
<view class="section-header" v-show="auth.hasPermi('health:medicationStatistic:query')">
<text class="section-title">健康统计分析</text>
</view>
<view class="grid-body" v-show="auth.hasPermi('health:medicationStatistic:query')">
<view class="grid-wrapper">
<view
v-for="(item, index) in healthGridList"
:key="index"
v-show="auth.hasPermi(item.permission)"
@click="navigateTo(item.path)"
class="grid-item">
<view class="item-icon" :style="{ background: item.color }">
<uni-icons :type="item.icon" size="22" color="#ffffff"></uni-icons>
</view>
<text class="item-text">{{ item.text }}</text>
</view>
</view>
</view>
</view>
<!-- <refresh></refresh> -->
</template>
@@ -109,6 +129,9 @@
{ path: '/pages/statistic/bill/investAnalysis/index', text: '收益统计分析', icon: 'settings', color: 'linear-gradient(135deg, #a29bfe 0%, #6c5ce7 100%)', permission: 'invest:investAnalysis:list'},
{ path: '/pages/statistic/bill/futuresAnalysis/index', text: '期货统计分析', icon: 'fire', color: 'linear-gradient(135deg, #fc5c65 0%, #fd79a8 100%)', permission: 'invest:futuresAnalysis:list'},
{ path: '/pages/statistic/bill/stocksAnalysis/index', text: '股票统计分析', icon: 'flag-filled', color: 'linear-gradient(135deg, #5f72bd 0%, #9b23ea 100%)', permission: 'invest:stocksAnalysis:list' }
])
const healthGridList=ref([
{ path: '/pages/statistic/medication/index', text: '用药统计', icon: 'heart-filled', color: 'linear-gradient(135deg, #409EFF 0%, #67C23A 100%)', permission: 'health:medicationStatistic:query' }
])
function navigateTo(path) {
uni.navigateTo({

View File

@@ -0,0 +1,591 @@
<template>
<view class="page-container">
<!-- 自定义导航栏 -->
<view class="custom-navbar">
<view class="navbar-content">
<view class="navbar-left" @click="goBack">
<uni-icons type="back" size="20" color="#ffffff"></uni-icons>
</view>
<view class="navbar-title">用药统计</view>
<view class="navbar-right"></view>
</view>
</view>
<!-- 日期选择 -->
<view class="date-selector">
<view class="date-item" :class="{ active: dateType === 'week' }" @click="changeDateType('week')">近7天</view>
<view class="date-item" :class="{ active: dateType === 'month' }" @click="changeDateType('month')">近30天</view>
<view class="date-item" :class="{ active: dateType === 'quarter' }" @click="changeDateType('quarter')">近90天</view>
</view>
<!-- 概览卡片 -->
<view class="overview-section" v-if="overview">
<view class="stat-row">
<view class="stat-item">
<view class="stat-value">{{ overview.totalTasks || 0 }}</view>
<view class="stat-label">总任务</view>
</view>
<view class="stat-item">
<view class="stat-value success">{{ overview.completedTasks || 0 }}</view>
<view class="stat-label">已服药</view>
</view>
<view class="stat-item">
<view class="stat-value danger">{{ overview.missedTasks || 0 }}</view>
<view class="stat-label">漏服</view>
</view>
</view>
<view class="stat-row">
<view class="stat-item large">
<view class="progress-ring">
<view class="ring-value">{{ overview.adherenceRate || 0 }}%</view>
<view class="ring-label">服药率</view>
</view>
<u-line-progress :percentage="overview.adherenceRate || 0" activeColor="#67C23A" height="8"></u-line-progress>
</view>
<view class="stat-item large">
<view class="progress-ring">
<view class="ring-value">{{ overview.ontimeRate || 0 }}%</view>
<view class="ring-label">准时率</view>
</view>
<u-line-progress :percentage="overview.ontimeRate || 0" activeColor="#409EFF" height="8"></u-line-progress>
</view>
</view>
</view>
<!-- 趋势图 -->
<view class="chart-section">
<view class="section-title">服药趋势</view>
<view class="chart-container" v-if="dailyList.length > 0">
<qiun-data-charts type="line" :chartData="trendChartData" :opts="trendOpts" />
</view>
<u-empty v-else text="暂无数据" mode="data"></u-empty>
</view>
<!-- 时段分布 -->
<view class="chart-section">
<view class="section-title">时段分布</view>
<view class="time-slot-grid" v-if="timeSlotData">
<view class="time-slot-item" v-for="(item, key) in timeSlotData" :key="key">
<view class="slot-name">{{ timeSlotNames[key] }}</view>
<view class="slot-rate">{{ item.adherenceRate || 0 }}%</view>
<view class="slot-detail">
<text>已服 {{ item.completedTasks || 0 }}</text>
<text>/ {{ item.totalTasks || 0 }}</text>
</view>
</view>
</view>
</view>
<!-- 日历视图 -->
<view class="calendar-section">
<view class="section-header">
<view class="section-title">日历视图</view>
<view class="month-nav">
<uni-icons type="left" size="18" @click="changeMonth(-1)"></uni-icons>
<text class="current-month">{{ currentMonthText }}</text>
<uni-icons type="right" size="18" @click="changeMonth(1)"></uni-icons>
</view>
</view>
<view class="calendar-grid">
<view class="calendar-header">
<text v-for="day in ['一', '二', '三', '四', '五', '六', '日']" :key="day" class="header-cell">{{ day }}</text>
</view>
<view class="calendar-body">
<view
v-for="(day, index) in calendarDays"
:key="index"
class="calendar-cell"
:class="{
'other-month': day.otherMonth,
'today': day.isToday,
'has-data': day.data && day.data.totalTasks > 0
}"
>
<text class="day-number">{{ day.day }}</text>
<view class="day-indicator" v-if="day.data && day.data.totalTasks > 0">
<view class="indicator-dot" :class="getIndicatorClass(day.data)"></view>
</view>
</view>
</view>
</view>
<view class="calendar-legend">
<view class="legend-item">
<view class="legend-dot good"></view>
<text>服药率 80%</text>
</view>
<view class="legend-item">
<view class="legend-dot normal"></view>
<text>服药率 50-80%</text>
</view>
<view class="legend-item">
<view class="legend-dot bad"></view>
<text>服药率 < 50%</text>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { getOverallAdherence, getDailyAdherence, getCalendarData, getTimeSlotAdherence } from '@/api/health/medicationAdherence'
const dateType = ref('week')
const overview = ref(null)
const dailyList = ref([])
const timeSlotData = ref(null)
const calendarData = ref([])
// 当前月份
const currentYearMonth = ref('')
const currentMonthText = ref('')
// 时段名称映射
const timeSlotNames = {
morning: '上午(6-12)',
afternoon: '下午(12-18)',
evening: '晚间(18-24)',
night: '凌晨(0-6)'
}
// 趋势图配置
const trendOpts = ref({
color: ['#409EFF', '#67C23A'],
padding: [15, 10, 0, 15],
legend: {},
xAxis: {
disableGrid: true
},
yAxis: {
data: [{ min: 0, max: 100 }]
},
extra: {
line: {
type: 'curve',
width: 2,
activeType: 'hollow'
}
}
})
// 趋势图数据
const trendChartData = computed(() => {
const categories = dailyList.value.map(d => d.date?.substring(5) || '')
const series = [
{
name: '服药率',
data: dailyList.value.map(d => d.adherenceRate || 0)
},
{
name: '准时率',
data: dailyList.value.map(d => d.ontimeRate || 0)
}
]
return { categories, series }
})
// 日历天数计算
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
// 上月填充
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 getDateRange = () => {
const today = new Date()
let days = 7
if (dateType.value === 'month') days = 30
if (dateType.value === 'quarter') days = 90
const endDate = today.toISOString().split('T')[0]
const startDate = new Date(today.getTime() - days * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
return { startDate, endDate }
}
// 加载统计数据
const loadStatistics = async () => {
const { startDate, endDate } = getDateRange()
const params = { startDate, endDate }
try {
// 总体统计
const overviewRes = await getOverallAdherence(params)
overview.value = overviewRes.data
// 每日趋势
const dailyRes = await getDailyAdherence(params)
dailyList.value = dailyRes.data || []
// 时段统计
const timeSlotRes = await getTimeSlotAdherence(params)
timeSlotData.value = timeSlotRes.data
} catch (e) {
console.error('加载统计数据失败', e)
}
}
// 加载日历数据
const loadCalendarData = async () => {
try {
const res = await getCalendarData(null, currentYearMonth.value)
calendarData.value = res.data || []
} catch (e) {
console.error('加载日历数据失败', e)
}
}
// 切换日期类型
const changeDateType = (type) => {
dateType.value = type
loadStatistics()
}
// 切换月份
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')}`
currentMonthText.value = `${newDate.getFullYear()}${newDate.getMonth() + 1}`
loadCalendarData()
}
// 获取日历单元格指示器样式
const getIndicatorClass = (data) => {
const rate = data.adherenceRate || 0
if (rate >= 80) return 'good'
if (rate >= 50) return 'normal'
return 'bad'
}
// 返回
const goBack = () => {
uni.navigateBack()
}
onMounted(() => {
// 初始化月份
const today = new Date()
currentYearMonth.value = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}`
currentMonthText.value = `${today.getFullYear()}${today.getMonth() + 1}`
loadStatistics()
loadCalendarData()
})
</script>
<style lang="scss" scoped>
.page-container {
min-height: 100vh;
background: #f5f7fa;
padding-bottom: 40rpx;
}
.custom-navbar {
background: linear-gradient(135deg, #409EFF 0%, #66b1ff 100%);
padding-top: 88rpx;
padding-bottom: 30rpx;
.navbar-content {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 30rpx;
}
.navbar-title {
color: #ffffff;
font-size: 36rpx;
font-weight: bold;
}
.navbar-left, .navbar-right {
width: 60rpx;
}
}
.date-selector {
display: flex;
background: #ffffff;
padding: 20rpx 30rpx;
gap: 20rpx;
.date-item {
flex: 1;
text-align: center;
padding: 16rpx 0;
border-radius: 8rpx;
font-size: 28rpx;
color: #606266;
background: #f5f7fa;
&.active {
background: #409EFF;
color: #ffffff;
}
}
}
.overview-section {
background: #ffffff;
margin: 20rpx;
border-radius: 16rpx;
padding: 30rpx;
.stat-row {
display: flex;
justify-content: space-around;
margin-bottom: 30rpx;
&:last-child {
margin-bottom: 0;
}
}
.stat-item {
text-align: center;
flex: 1;
&.large {
padding: 0 20rpx;
}
.stat-value {
font-size: 48rpx;
font-weight: bold;
color: #303133;
&.success { color: #67C23A; }
&.danger { color: #F56C6C; }
}
.stat-label {
font-size: 24rpx;
color: #909399;
margin-top: 8rpx;
}
.progress-ring {
text-align: center;
margin-bottom: 20rpx;
.ring-value {
font-size: 40rpx;
font-weight: bold;
color: #303133;
}
.ring-label {
font-size: 24rpx;
color: #909399;
}
}
}
}
.chart-section {
background: #ffffff;
margin: 20rpx;
border-radius: 16rpx;
padding: 30rpx;
.section-title {
font-size: 32rpx;
font-weight: bold;
color: #303133;
margin-bottom: 20rpx;
}
.chart-container {
height: 400rpx;
}
}
.time-slot-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20rpx;
.time-slot-item {
background: #f5f7fa;
border-radius: 12rpx;
padding: 24rpx;
text-align: center;
.slot-name {
font-size: 26rpx;
color: #606266;
margin-bottom: 10rpx;
}
.slot-rate {
font-size: 40rpx;
font-weight: bold;
color: #409EFF;
}
.slot-detail {
font-size: 24rpx;
color: #909399;
margin-top: 8rpx;
}
}
}
.calendar-section {
background: #ffffff;
margin: 20rpx;
border-radius: 16rpx;
padding: 30rpx;
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
}
.section-title {
font-size: 32rpx;
font-weight: bold;
color: #303133;
}
.month-nav {
display: flex;
align-items: center;
gap: 16rpx;
.current-month {
font-size: 28rpx;
color: #606266;
}
}
.calendar-grid {
.calendar-header {
display: grid;
grid-template-columns: repeat(7, 1fr);
text-align: center;
padding: 20rpx 0;
border-bottom: 1rpx solid #EBEEF5;
.header-cell {
font-size: 26rpx;
color: #909399;
}
}
.calendar-body {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4rpx;
padding-top: 10rpx;
.calendar-cell {
aspect-ratio: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border-radius: 8rpx;
&.other-month {
.day-number { color: #C0C4CC; }
}
&.today {
background: #ECF5FF;
.day-number { color: #409EFF; font-weight: bold; }
}
&.has-data {
.day-indicator {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
margin-top: 4rpx;
}
}
.day-number {
font-size: 26rpx;
color: #303133;
}
.indicator-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
&.good { background: #67C23A; }
&.normal { background: #E6A23C; }
&.bad { background: #F56C6C; }
}
}
}
}
.calendar-legend {
display: flex;
justify-content: center;
gap: 30rpx;
margin-top: 20rpx;
padding-top: 20rpx;
border-top: 1rpx solid #EBEEF5;
.legend-item {
display: flex;
align-items: center;
gap: 8rpx;
font-size: 22rpx;
color: #909399;
.legend-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
&.good { background: #67C23A; }
&.normal { background: #E6A23C; }
&.bad { background: #F56C6C; }
}
}
}
}
</style>