feat(health+miniprogram): 健康数据录入 + 趋势图
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

后端:
- 新增 GET /health/vital-signs/trend 小程序趋势查询 API
- 通过 JWT user_id 自动关联 patient,支持 range 参数 (7d/30d/90d)
- 新增 MiniTrendQueryParams, MiniTrendResp, DataPoint DTO

前端:
- 实现健康数据首页(今日概览 + 趋势入口 + 录入按钮)
- 实现健康数据录入页(指标选择 + 数值输入 + 提交)
- 实现趋势图页(时间范围切换 + 柱状图 + 数据列表)
- 新增 health service 和 store(趋势缓存 + 今日摘要)
- 修复所有页面相对路径引用问题
This commit is contained in:
iven
2026-04-24 00:36:30 +08:00
parent 0f84c881ef
commit affb3a5578
13 changed files with 714 additions and 15 deletions

View File

@@ -1,27 +1,104 @@
@import '../../styles/variables.scss';
.placeholder-page {
.health-page {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: $bg;
padding-bottom: 40px;
}
.placeholder-icon {
font-size: 80px;
margin-bottom: 20px;
.health-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24px 32px;
}
.placeholder-title {
.health-header-title {
font-size: 36px;
font-weight: bold;
color: $tx;
}
.health-header-btn {
background: $pri;
padding: 12px 28px;
border-radius: $r-sm;
}
.health-header-btn-text {
font-size: 26px;
color: white;
font-weight: bold;
}
.health-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
padding: 0 24px;
margin-bottom: 32px;
}
.health-card {
background: $card;
border-radius: $r;
padding: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.health-card-label {
font-size: 24px;
color: $tx2;
display: block;
margin-bottom: 12px;
}
.health-card-value {
font-size: 40px;
font-weight: bold;
color: $pri;
display: block;
margin-bottom: 8px;
}
.placeholder-desc {
font-size: 26px;
.health-card-bottom {
display: flex;
justify-content: space-between;
}
.health-card-unit {
font-size: 22px;
color: $tx3;
}
.health-card-status {
font-size: 22px;
color: $acc;
}
.health-actions {
display: flex;
gap: 16px;
padding: 0 24px;
}
.action-card {
flex: 1;
background: $card;
border-radius: $r;
padding: 24px;
display: flex;
flex-direction: column;
align-items: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.action-icon {
font-size: 40px;
margin-bottom: 8px;
}
.action-label {
font-size: 24px;
color: $tx2;
}

View File

@@ -1,12 +1,67 @@
import { View, Text } from '@tarojs/components';
import Taro, { useDidShow } from '@tarojs/taro';
import { useHealthStore } from '../../stores/health';
import './index.scss';
export default function Health() {
const { todaySummary, loading, refreshToday } = useHealthStore();
useDidShow(() => {
refreshToday();
});
const goToInput = () => {
Taro.navigateTo({ url: '/pages/health/input/index' });
};
const goToTrend = (indicator: string) => {
Taro.navigateTo({ url: `/pages/health/trend/index?indicator=${indicator}` });
};
const summary = todaySummary || {};
const items = [
{ label: '血压', value: summary.blood_pressure ? `${summary.blood_pressure.systolic}/${summary.blood_pressure.diastolic}` : '--/--', unit: 'mmHg', indicator: 'blood_pressure_systolic', status: summary.blood_pressure?.status },
{ label: '心率', value: summary.heart_rate ? `${summary.heart_rate.value}` : '--', unit: 'bpm', indicator: 'heart_rate', status: summary.heart_rate?.status },
{ label: '血糖', value: summary.blood_sugar ? `${summary.blood_sugar.value}` : '--', unit: 'mmol/L', indicator: 'blood_sugar_fasting', status: summary.blood_sugar?.status },
{ label: '体重', value: summary.weight ? `${summary.weight.value}` : '--', unit: 'kg', indicator: 'weight', status: summary.weight?.status },
];
return (
<View className='placeholder-page'>
<Text className='placeholder-icon'>📊</Text>
<Text className='placeholder-title'></Text>
<Text className='placeholder-desc'></Text>
<View className='health-page'>
<View className='health-header'>
<Text className='health-header-title'></Text>
<View className='health-header-btn' onClick={goToInput}>
<Text className='health-header-btn-text'>+ </Text>
</View>
</View>
<View className='health-grid'>
{items.map((item) => (
<View className='health-card' key={item.label} onClick={() => goToTrend(item.indicator)}>
<Text className='health-card-label'>{item.label}</Text>
<Text className='health-card-value'>{item.value}</Text>
<View className='health-card-bottom'>
<Text className='health-card-unit'>{item.unit}</Text>
{item.status && <Text className='health-card-status'>{item.status}</Text>}
</View>
</View>
))}
</View>
<View className='health-actions'>
<View className='action-card' onClick={() => goToTrend('blood_pressure_systolic')}>
<Text className='action-icon'>📈</Text>
<Text className='action-label'></Text>
</View>
<View className='action-card' onClick={() => goToTrend('heart_rate')}>
<Text className='action-icon'></Text>
<Text className='action-label'></Text>
</View>
<View className='action-card' onClick={() => goToTrend('blood_sugar_fasting')}>
<Text className='action-icon'>🩸</Text>
<Text className='action-label'></Text>
</View>
</View>
</View>
);
}

View File

@@ -0,0 +1,59 @@
@import '../../../styles/variables.scss';
.input-page {
min-height: 100vh;
background: $bg;
padding: 24px;
}
.input-section {
margin-bottom: 32px;
}
.input-label {
font-size: 28px;
color: $tx;
font-weight: bold;
margin-bottom: 12px;
display: block;
}
.input-picker {
background: $card;
border-radius: $r-sm;
padding: 20px 24px;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 28px;
color: $tx;
}
.picker-arrow {
color: $tx3;
font-size: 28px;
}
.input-field {
background: $card;
border-radius: $r-sm;
padding: 20px 24px;
font-size: 28px;
color: $tx;
width: 100%;
box-sizing: border-box;
}
.input-submit {
background: $pri;
border-radius: $r-sm;
padding: 24px;
text-align: center;
margin-top: 48px;
}
.submit-text {
font-size: 32px;
color: white;
font-weight: bold;
}

View File

@@ -0,0 +1,93 @@
import { useState } from 'react';
import { View, Text, Input, Picker } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { inputVitalSign } from '../../../services/health';
import { useAuthStore } from '../../../stores/auth';
import './index.scss';
const INDICATORS = [
{ value: 'blood_pressure', label: '血压 (mmHg)' },
{ value: 'heart_rate', label: '心率 (bpm)' },
{ value: 'blood_sugar_fasting', label: '空腹血糖 (mmol/L)' },
{ value: 'blood_sugar_postprandial', label: '餐后血糖 (mmol/L)' },
{ value: 'weight', label: '体重 (kg)' },
{ value: 'temperature', label: '体温 (℃)' },
];
export default function HealthInput() {
const [indicatorIdx, setIndicatorIdx] = useState(0);
const [value, setValue] = useState('');
const [note, setNote] = useState('');
const [submitting, setSubmitting] = useState(false);
const { currentPatient } = useAuthStore();
const handleSubmit = async () => {
if (!value) {
Taro.showToast({ title: '请输入数值', icon: 'none' });
return;
}
if (!currentPatient) {
Taro.showToast({ title: '请先选择就诊人', icon: 'none' });
return;
}
setSubmitting(true);
try {
await inputVitalSign(currentPatient.id, {
indicator_type: INDICATORS[indicatorIdx].value,
value: parseFloat(value),
note: note || undefined,
});
Taro.showToast({ title: '录入成功', icon: 'success' });
setTimeout(() => Taro.navigateBack(), 1000);
} catch (e: any) {
Taro.showToast({ title: e.message || '录入失败', icon: 'none' });
} finally {
setSubmitting(false);
}
};
return (
<View className='input-page'>
<View className='input-section'>
<Text className='input-label'></Text>
<Picker
mode='selector'
range={INDICATORS.map((i) => i.label)}
value={indicatorIdx}
onChange={(e) => setIndicatorIdx(Number(e.detail.value))}
>
<View className='input-picker'>
<Text>{INDICATORS[indicatorIdx].label}</Text>
<Text className='picker-arrow'></Text>
</View>
</Picker>
</View>
<View className='input-section'>
<Text className='input-label'></Text>
<Input
type='digit'
className='input-field'
placeholder='请输入数值'
value={value}
onInput={(e) => setValue(e.detail.value)}
/>
</View>
<View className='input-section'>
<Text className='input-label'></Text>
<Input
className='input-field'
placeholder='如饭后2小时'
value={note}
onInput={(e) => setNote(e.detail.value)}
/>
</View>
<View className='input-submit' onClick={submitting ? undefined : handleSubmit}>
<Text className='submit-text'>{submitting ? '提交中...' : '提交'}</Text>
</View>
</View>
);
}

View File

@@ -0,0 +1,122 @@
@import '../../../styles/variables.scss';
.trend-page {
min-height: 100vh;
background: $bg;
}
.trend-header {
padding: 24px 32px;
}
.trend-title {
font-size: 34px;
font-weight: bold;
color: $tx;
display: block;
margin-bottom: 16px;
}
.trend-tabs {
display: flex;
gap: 16px;
}
.trend-tab {
padding: 10px 28px;
border-radius: 20px;
background: $card;
}
.trend-tab.active {
background: $pri;
}
.trend-tab-text {
font-size: 24px;
color: $tx2;
}
.trend-tab.active .trend-tab-text {
color: white;
}
.trend-chart {
margin: 24px;
background: $card;
border-radius: $r;
padding: 24px;
min-height: 300px;
display: flex;
align-items: flex-end;
}
.trend-empty {
font-size: 26px;
color: $tx3;
text-align: center;
width: 100%;
align-self: center;
}
.chart-bars {
display: flex;
align-items: flex-end;
gap: 8px;
width: 100%;
height: 240px;
}
.chart-bar-wrap {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
height: 100%;
justify-content: flex-end;
}
.chart-bar {
width: 100%;
background: linear-gradient(to top, $pri, $pri-l);
border-radius: 4px 4px 0 0;
min-height: 4px;
}
.chart-bar-date {
font-size: 18px;
color: $tx3;
margin-top: 6px;
}
.trend-list {
margin: 0 24px;
}
.trend-item {
display: flex;
justify-content: space-between;
background: $card;
padding: 20px 24px;
border-bottom: 1px solid $bd-l;
}
.trend-item:first-child {
border-radius: $r $r 0 0;
}
.trend-item:last-child {
border-radius: 0 0 $r $r;
border-bottom: none;
}
.trend-item-date {
font-size: 26px;
color: $tx2;
}
.trend-item-value {
font-size: 26px;
color: $pri;
font-weight: bold;
}

View File

@@ -0,0 +1,73 @@
import { useState, useEffect } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import { useHealthStore } from '../../../stores/health';
import './index.scss';
const RANGE_OPTIONS = [
{ value: '7d', label: '7天' },
{ value: '30d', label: '30天' },
{ value: '90d', label: '90天' },
];
export default function Trend() {
const router = useRouter();
const indicator = router.params.indicator || 'heart_rate';
const [range, setRange] = useState('7d');
const [points, setPoints] = useState<{ date: string; value: number }[]>([]);
const { getTrend } = useHealthStore();
useEffect(() => {
getTrend(indicator, range).then(setPoints);
}, [indicator, range]);
const maxVal = points.length ? Math.max(...points.map((p) => p.value)) : 1;
return (
<View className='trend-page'>
<View className='trend-header'>
<Text className='trend-title'>{indicator.replace(/_/g, ' ')} </Text>
<View className='trend-tabs'>
{RANGE_OPTIONS.map((opt) => (
<View
key={opt.value}
className={`trend-tab ${range === opt.value ? 'active' : ''}`}
onClick={() => setRange(opt.value)}
>
<Text className='trend-tab-text'>{opt.label}</Text>
</View>
))}
</View>
</View>
{/* 简易柱状图 */}
<View className='trend-chart'>
{points.length === 0 ? (
<Text className='trend-empty'></Text>
) : (
<View className='chart-bars'>
{points.slice(-14).map((p, i) => (
<View className='chart-bar-wrap' key={i}>
<View
className='chart-bar'
style={{ height: `${(p.value / maxVal) * 100}%` }}
/>
<Text className='chart-bar-date'>{p.date.slice(5)}</Text>
</View>
))}
</View>
)}
</View>
{/* 数据列表 */}
<View className='trend-list'>
{points.slice().reverse().map((p, i) => (
<View className='trend-item' key={i}>
<Text className='trend-item-date'>{p.date}</Text>
<Text className='trend-item-value'>{p.value}</Text>
</View>
))}
</View>
</View>
);
}