diff --git a/apps/miniprogram/src/app.config.ts b/apps/miniprogram/src/app.config.ts index 17a793f..a27f902 100644 --- a/apps/miniprogram/src/app.config.ts +++ b/apps/miniprogram/src/app.config.ts @@ -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', diff --git a/apps/miniprogram/src/pages/health/index.scss b/apps/miniprogram/src/pages/health/index.scss index 702c26f..015745e 100644 --- a/apps/miniprogram/src/pages/health/index.scss +++ b/apps/miniprogram/src/pages/health/index.scss @@ -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; +} diff --git a/apps/miniprogram/src/pages/health/index.tsx b/apps/miniprogram/src/pages/health/index.tsx index 2f5de05..3487f8d 100644 --- a/apps/miniprogram/src/pages/health/index.tsx +++ b/apps/miniprogram/src/pages/health/index.tsx @@ -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 ( - - 📊 - 健康数据 - 体征录入、趋势分析 + + + 健康数据 + + + 录入 + + + + + {items.map((item) => ( + goToTrend(item.indicator)}> + {item.label} + {item.value} + + {item.unit} + {item.status && {item.status}} + + + ))} + + + + goToTrend('blood_pressure_systolic')}> + 📈 + 血压趋势 + + goToTrend('heart_rate')}> + ❤️ + 心率趋势 + + goToTrend('blood_sugar_fasting')}> + 🩸 + 血糖趋势 + + ); } diff --git a/apps/miniprogram/src/pages/health/input/index.scss b/apps/miniprogram/src/pages/health/input/index.scss new file mode 100644 index 0000000..7e2acd4 --- /dev/null +++ b/apps/miniprogram/src/pages/health/input/index.scss @@ -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; +} diff --git a/apps/miniprogram/src/pages/health/input/index.tsx b/apps/miniprogram/src/pages/health/input/index.tsx new file mode 100644 index 0000000..a64c557 --- /dev/null +++ b/apps/miniprogram/src/pages/health/input/index.tsx @@ -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 ( + + + 指标类型 + i.label)} + value={indicatorIdx} + onChange={(e) => setIndicatorIdx(Number(e.detail.value))} + > + + {INDICATORS[indicatorIdx].label} + + + + + + + 数值 + setValue(e.detail.value)} + /> + + + + 备注(可选) + setNote(e.detail.value)} + /> + + + + {submitting ? '提交中...' : '提交'} + + + ); +} diff --git a/apps/miniprogram/src/pages/health/trend/index.scss b/apps/miniprogram/src/pages/health/trend/index.scss new file mode 100644 index 0000000..48a1ad3 --- /dev/null +++ b/apps/miniprogram/src/pages/health/trend/index.scss @@ -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; +} diff --git a/apps/miniprogram/src/pages/health/trend/index.tsx b/apps/miniprogram/src/pages/health/trend/index.tsx new file mode 100644 index 0000000..ddf151f --- /dev/null +++ b/apps/miniprogram/src/pages/health/trend/index.tsx @@ -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 ( + + + {indicator.replace(/_/g, ' ')} 趋势 + + {RANGE_OPTIONS.map((opt) => ( + setRange(opt.value)} + > + {opt.label} + + ))} + + + + {/* 简易柱状图 */} + + {points.length === 0 ? ( + 暂无数据 + ) : ( + + {points.slice(-14).map((p, i) => ( + + + {p.date.slice(5)} + + ))} + + )} + + + {/* 数据列表 */} + + {points.slice().reverse().map((p, i) => ( + + {p.date} + {p.value} + + ))} + + + ); +} diff --git a/apps/miniprogram/src/services/health.ts b/apps/miniprogram/src/services/health.ts new file mode 100644 index 0000000..064f2e2 --- /dev/null +++ b/apps/miniprogram/src/services/health.ts @@ -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('/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}` + ); +} diff --git a/apps/miniprogram/src/stores/health.ts b/apps/miniprogram/src/stores/health.ts new file mode 100644 index 0000000..e842afc --- /dev/null +++ b/apps/miniprogram/src/stores/health.ts @@ -0,0 +1,41 @@ +import { create } from 'zustand'; +import * as healthApi from '../services/health'; + +interface HealthState { + todaySummary: healthApi.TodaySummary | null; + trendData: Record; + loading: boolean; + refreshToday: () => Promise; + getTrend: (indicator: string, range: string) => Promise<{ date: string; value: number }[]>; +} + +export const useHealthStore = create((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 []; + } + }, +})); diff --git a/crates/erp-health/src/dto/health_data_dto.rs b/crates/erp-health/src/dto/health_data_dto.rs index a05e527..3ba0ea2 100644 --- a/crates/erp-health/src/dto/health_data_dto.rs +++ b/crates/erp-health/src/dto/health_data_dto.rs @@ -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, +} + +/// 小程序趋势数据点 +#[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, +} diff --git a/crates/erp-health/src/handler/health_data_handler.rs b/crates/erp-health/src/handler/health_data_handler.rs index 358d4d3..71fbdd3 100644 --- a/crates/erp-health/src/handler/health_data_handler.rs +++ b/crates/erp-health/src/handler/health_data_handler.rs @@ -322,6 +322,27 @@ where Ok(Json(ApiResponse::ok(result))) } +// --------------------------------------------------------------------------- +// 小程序趋势查询(通过当前用户关联 patient,无需传 patient_id) +// --------------------------------------------------------------------------- + +pub async fn get_mini_trend( + State(state): State, + Extension(ctx): Extension, + Query(params): Query, +) -> Result>, AppError> +where + HealthState: FromRef, + 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))) +} + // --------------------------------------------------------------------------- // 带版本号的更新请求包装 // --------------------------------------------------------------------------- diff --git a/crates/erp-health/src/module.rs b/crates/erp-health/src/module.rs index e87f35e..00def14 100644 --- a/crates/erp-health/src/module.rs +++ b/crates/erp-health/src/module.rs @@ -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", diff --git a/crates/erp-health/src/service/health_data_service.rs b/crates/erp-health/src/service/health_data_service.rs index 0861061..a5a0d36 100644 --- a/crates/erp-health/src/service/health_data_service.rs +++ b/crates/erp-health/src/service/health_data_service.rs @@ -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> { + 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) -> 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, +) -> HealthResult { + // 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, + }) +}