feat(health+miniprogram): 健康数据录入 + 趋势图
后端: - 新增 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:
@@ -2,6 +2,8 @@ export default defineAppConfig({
|
||||
pages: [
|
||||
'pages/index/index',
|
||||
'pages/health/index',
|
||||
'pages/health/input/index',
|
||||
'pages/health/trend/index',
|
||||
'pages/appointment/index',
|
||||
'pages/article/index',
|
||||
'pages/profile/index',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
59
apps/miniprogram/src/pages/health/input/index.scss
Normal file
59
apps/miniprogram/src/pages/health/input/index.scss
Normal 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;
|
||||
}
|
||||
93
apps/miniprogram/src/pages/health/input/index.tsx
Normal file
93
apps/miniprogram/src/pages/health/input/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
122
apps/miniprogram/src/pages/health/trend/index.scss
Normal file
122
apps/miniprogram/src/pages/health/trend/index.scss
Normal 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;
|
||||
}
|
||||
73
apps/miniprogram/src/pages/health/trend/index.tsx
Normal file
73
apps/miniprogram/src/pages/health/trend/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
apps/miniprogram/src/services/health.ts
Normal file
29
apps/miniprogram/src/services/health.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { api } from './request';
|
||||
|
||||
export interface VitalSignInput {
|
||||
indicator_type: string;
|
||||
value: number;
|
||||
measured_at?: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface TodaySummary {
|
||||
blood_pressure?: { systolic: number; diastolic: number; status: string };
|
||||
heart_rate?: { value: number; status: string };
|
||||
blood_sugar?: { value: number; status: string };
|
||||
weight?: { value: number; status: string };
|
||||
}
|
||||
|
||||
export async function getTodaySummary() {
|
||||
return api.get<TodaySummary>('/health/vital-signs?date=today');
|
||||
}
|
||||
|
||||
export async function inputVitalSign(patientId: string, data: VitalSignInput) {
|
||||
return api.post(`/health/patients/${patientId}/vital-signs`, data);
|
||||
}
|
||||
|
||||
export async function getTrend(indicator: string, range: string) {
|
||||
return api.get<{ indicator: string; data_points: { date: string; value: number }[] }>(
|
||||
`/health/vital-signs/trend?indicator=${indicator}&range=${range}`
|
||||
);
|
||||
}
|
||||
41
apps/miniprogram/src/stores/health.ts
Normal file
41
apps/miniprogram/src/stores/health.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { create } from 'zustand';
|
||||
import * as healthApi from '../services/health';
|
||||
|
||||
interface HealthState {
|
||||
todaySummary: healthApi.TodaySummary | null;
|
||||
trendData: Record<string, { date: string; value: number }[]>;
|
||||
loading: boolean;
|
||||
refreshToday: () => Promise<void>;
|
||||
getTrend: (indicator: string, range: string) => Promise<{ date: string; value: number }[]>;
|
||||
}
|
||||
|
||||
export const useHealthStore = create<HealthState>((set, get) => ({
|
||||
todaySummary: null,
|
||||
trendData: {},
|
||||
loading: false,
|
||||
|
||||
refreshToday: async () => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
const data = await healthApi.getTodaySummary();
|
||||
set({ todaySummary: data, loading: false });
|
||||
} catch {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
getTrend: async (indicator: string, range: string) => {
|
||||
const cacheKey = `${indicator}_${range}`;
|
||||
const cached = get().trendData[cacheKey];
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const resp = await healthApi.getTrend(indicator, range);
|
||||
const points = resp.data_points || [];
|
||||
set((s) => ({ trendData: { ...s.trendData, [cacheKey]: points } }));
|
||||
return points;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -121,3 +121,34 @@ pub struct IndicatorTimeseriesResp {
|
||||
pub indicator: String,
|
||||
pub data: Vec<(NaiveDate, f64)>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 小程序趋势查询(通过当前用户关联 patient)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 小程序趋势查询参数
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct MiniTrendQueryParams {
|
||||
/// 指标名称,如 "blood_pressure_systolic", "heart_rate" 等
|
||||
pub indicator: String,
|
||||
/// 时间范围:"7d"(默认), "30d", "90d"
|
||||
pub range: Option<String>,
|
||||
}
|
||||
|
||||
/// 小程序趋势数据点
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct DataPoint {
|
||||
/// 日期,格式 YYYY-MM-DD
|
||||
pub date: String,
|
||||
/// 指标数值
|
||||
pub value: f64,
|
||||
}
|
||||
|
||||
/// 小程序趋势响应
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct MiniTrendResp {
|
||||
/// 指标名称
|
||||
pub indicator: String,
|
||||
/// 数据点列表(按日期升序)
|
||||
pub data_points: Vec<DataPoint>,
|
||||
}
|
||||
|
||||
@@ -322,6 +322,27 @@ where
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 小程序趋势查询(通过当前用户关联 patient,无需传 patient_id)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn get_mini_trend<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Query(params): Query<MiniTrendQueryParams>,
|
||||
) -> Result<Json<ApiResponse<MiniTrendResp>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.health-data.list")?;
|
||||
let result = health_data_service::get_mini_trend(
|
||||
&state, ctx.tenant_id, ctx.user_id, params.indicator, params.range,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 带版本号的更新请求包装
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -112,6 +112,11 @@ impl HealthModule {
|
||||
"/health/patients/{id}/trends/{indicator}",
|
||||
axum::routing::get(health_data_handler::get_indicator_timeseries),
|
||||
)
|
||||
// 小程序趋势查询(通过 JWT user_id 关联 patient,无需传 patient_id)
|
||||
.route(
|
||||
"/health/vital-signs/trend",
|
||||
axum::routing::get(health_data_handler::get_mini_trend),
|
||||
)
|
||||
// 预约排班
|
||||
.route(
|
||||
"/health/appointments",
|
||||
|
||||
@@ -591,3 +591,94 @@ pub async fn get_indicator_timeseries(
|
||||
|
||||
Ok(IndicatorTimeseriesResp { indicator, data })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 小程序趋势查询(通过 user_id 关联 patient)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 根据 user_id 查找关联的 patient_id。
|
||||
/// patient 表的 user_id 字段关联 erp-auth 的用户。
|
||||
/// 如果未关联则返回 Ok(None)。
|
||||
async fn find_patient_by_user_id(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
) -> HealthResult<Option<Uuid>> {
|
||||
let patient_model = patient::Entity::find()
|
||||
.filter(patient::Column::TenantId.eq(tenant_id))
|
||||
.filter(patient::Column::UserId.eq(user_id))
|
||||
.filter(patient::Column::DeletedAt.is_null())
|
||||
.one(&state.db)
|
||||
.await?;
|
||||
|
||||
Ok(patient_model.map(|p| p.id))
|
||||
}
|
||||
|
||||
/// 解析 range 参数为天数,默认 7 天。
|
||||
/// 支持 "7d", "30d", "90d" 格式。
|
||||
fn parse_range_days(range: &Option<String>) -> i64 {
|
||||
match range.as_deref() {
|
||||
Some("30d") => 30,
|
||||
Some("90d") => 90,
|
||||
// 默认 7 天(包括 "7d" 和 None)
|
||||
_ => 7,
|
||||
}
|
||||
}
|
||||
|
||||
/// 小程序趋势查询:通过当前用户的 user_id 关联 patient,查询指定指标的时间序列。
|
||||
///
|
||||
/// 逻辑流程:
|
||||
/// 1. 解析 range 参数计算 start_date/end_date
|
||||
/// 2. 通过 user_id 查找关联的 patient(patient.user_id 字段)
|
||||
/// 3. 复用 get_indicator_timeseries 的查询逻辑
|
||||
/// 4. 转换为 DataPoint 格式返回
|
||||
pub async fn get_mini_trend(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
indicator: String,
|
||||
range: Option<String>,
|
||||
) -> HealthResult<MiniTrendResp> {
|
||||
// 1. 通过 user_id 查找关联的 patient
|
||||
let patient_id = find_patient_by_user_id(state, tenant_id, user_id).await?;
|
||||
|
||||
// 如果用户未关联 patient,返回空数据
|
||||
let Some(patient_id) = patient_id else {
|
||||
return Ok(MiniTrendResp {
|
||||
indicator,
|
||||
data_points: vec![],
|
||||
});
|
||||
};
|
||||
|
||||
// 2. 根据 range 计算日期范围
|
||||
let days = parse_range_days(&range);
|
||||
let today = chrono::Local::now().date_naive();
|
||||
let start_date = today - chrono::Duration::days(days);
|
||||
let end_date = today;
|
||||
|
||||
// 3. 复用已有逻辑查询时间序列数据
|
||||
let timeseries = get_indicator_timeseries(
|
||||
state,
|
||||
tenant_id,
|
||||
patient_id,
|
||||
indicator.clone(),
|
||||
Some(start_date),
|
||||
Some(end_date),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 4. 转换为 DataPoint 格式
|
||||
let data_points = timeseries
|
||||
.data
|
||||
.into_iter()
|
||||
.map(|(date, value)| DataPoint {
|
||||
date: date.to_string(),
|
||||
value,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(MiniTrendResp {
|
||||
indicator,
|
||||
data_points,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user