Files
hms/apps/miniprogram/src/pages/pkg-health/daily-monitoring/index.tsx
iven e8ccee02d5
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
feat(miniprogram): 关怀模式 Phase 2 — Design Token + 15 页面批量接入
- 新建 useElderClass hook,替代每页 3 行样板代码
- 新建 CSS 自定义属性 Design Token 系统(tokens.scss)
  正常/关怀两套值:字号、间距、触控、布局参数
- 15 个页面批量接入关怀模式 class:
  TabBar: 商城页
  主流程: 预约列表/详情/创建、咨询详情
  子包: 体征录入/趋势/日常监测/告警、用药/档案/随访/报告/家庭/设置
- 新建 elder-toast 工具(关怀模式 3s + 触觉反馈)
- 页面覆盖率:4/59 → 22/59 (37%)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 22:17:58 +08:00

490 lines
18 KiB
TypeScript

import { useState } from 'react';
import { View, Text, Input, Picker } from '@tarojs/components';
import Taro, { useDidShow } from '@tarojs/taro';
import { z } from 'zod';
import { createDailyMonitoring } from '@/services/health';
import { useAuthStore } from '@/stores/auth';
import { useHealthStore } from '@/stores/health';
import { usePointsStore } from '@/stores/points';
import { clearRequestCache } from '@/services/request';
import { trackEvent } from '@/services/analytics';
import { useElderClass } from '../../../hooks/useElderClass';
import './index.scss';
const bpSchema = z.number().min(30, '血压值不能低于30').max(300, '血压值不能高于300').optional();
const weightSchema = z.number().min(1, '体重不能低于1kg').max(500, '体重不能高于500kg').optional();
const bloodSugarSchema = z.number().min(0.1, '血糖值不能低于0.1').max(50, '血糖值不能高于50').optional();
const volumeSchema = z.number().min(0, '数值不能为负').max(10000, '数值超出合理范围').optional();
function formatDate(date: Date): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
// ── Abnormal value detection ──
const REFERENCE_RANGES: Record<string, { min: number; max: number } | null> = {
systolic: { min: 90, max: 140 },
diastolic: { min: 60, max: 90 },
bloodSugar: { min: 3.9, max: 6.1 },
weight: null,
fluidIntake: null,
urineOutput: null,
};
type AbnormalResult = { abnormal: boolean; direction: 'high' | 'low' | null };
const checkAbnormal = (value: string, field: string): AbnormalResult => {
const ref = REFERENCE_RANGES[field];
if (!value || !ref) return { abnormal: false, direction: null };
const num = parseFloat(value);
if (isNaN(num)) return { abnormal: false, direction: null };
if (num > ref.max) return { abnormal: true, direction: 'high' };
if (num < ref.min) return { abnormal: true, direction: 'low' };
return { abnormal: false, direction: null };
};
// ── Section state type ──
type SectionKey = 'morning' | 'evening' | 'other';
const FIELD_LABELS: Record<string, string> = {
morningSystolic: '晨间收缩压',
morningDiastolic: '晨间舒张压',
eveningSystolic: '晚间收缩压',
eveningDiastolic: '晚间舒张压',
bloodSugar: '血糖',
};
export default function DailyMonitoring() {
const modeClass = useElderClass();
const { currentPatient } = useAuthStore();
const today = formatDate(new Date());
const [dateIdx, setDateIdx] = useState(0);
const [dateList] = useState(() => {
const list: string[] = [];
for (let i = 0; i < 30; i++) {
const d = new Date();
d.setDate(d.getDate() - i);
list.push(formatDate(d));
}
return list;
});
const recordDate = dateList[dateIdx];
const [morningSystolic, setMorningSystolic] = useState('');
const [morningDiastolic, setMorningDiastolic] = useState('');
const [eveningSystolic, setEveningSystolic] = useState('');
const [eveningDiastolic, setEveningDiastolic] = useState('');
const [weight, setWeight] = useState('');
const [bloodSugar, setBloodSugar] = useState('');
const [fluidIntake, setFluidIntake] = useState('');
const [urineOutput, setUrineOutput] = useState('');
const [notes, setNotes] = useState('');
const [submitting, setSubmitting] = useState(false);
// ── Collapsible sections ──
const [collapsed, setCollapsed] = useState<Record<SectionKey, boolean>>({
morning: false,
evening: false,
other: true,
});
const toggleSection = (key: SectionKey) => {
setCollapsed(prev => ({ ...prev, [key]: !prev[key] }));
};
useDidShow(() => {
Taro.setNavigationBarTitle({ title: '日常监测上报' });
});
const resetForm = () => {
setMorningSystolic('');
setMorningDiastolic('');
setEveningSystolic('');
setEveningDiastolic('');
setWeight('');
setBloodSugar('');
setFluidIntake('');
setUrineOutput('');
setNotes('');
};
// ── Abnormal field gathering for submit confirmation ──
const gatherAbnormalFields = (): string[] => {
const abnormalFields: string[] = [];
const checks: Array<[string, string]> = [
['morningSystolic', morningSystolic],
['morningDiastolic', morningDiastolic],
['eveningSystolic', eveningSystolic],
['eveningDiastolic', eveningDiastolic],
['bloodSugar', bloodSugar],
];
for (const [field, value] of checks) {
const result = checkAbnormal(value, field);
if (result.abnormal) {
abnormalFields.push(FIELD_LABELS[field]);
}
}
return abnormalFields;
};
const handleSubmit = async () => {
if (!currentPatient) {
Taro.showToast({ title: '请先选择就诊人', icon: 'none' });
return;
}
const hasData =
morningSystolic || morningDiastolic ||
eveningSystolic || eveningDiastolic ||
weight || bloodSugar || fluidIntake || urineOutput;
if (!hasData) {
Taro.showToast({ title: '请至少填写一项数据', icon: 'none' });
return;
}
if ((morningSystolic && !morningDiastolic) || (!morningSystolic && morningDiastolic)) {
Taro.showToast({ title: '晨起血压请同时填写收缩压和舒张压', icon: 'none' });
return;
}
if ((eveningSystolic && !eveningDiastolic) || (!eveningSystolic && eveningDiastolic)) {
Taro.showToast({ title: '晚间血压请同时填写收缩压和舒张压', icon: 'none' });
return;
}
const parseNum = (v: string) => v ? parseFloat(v) : undefined;
const fields = {
morningSystolic: parseNum(morningSystolic),
morningDiastolic: parseNum(morningDiastolic),
eveningSystolic: parseNum(eveningSystolic),
eveningDiastolic: parseNum(eveningDiastolic),
weight: parseNum(weight),
bloodSugar: parseNum(bloodSugar),
fluidIntake: parseNum(fluidIntake),
urineOutput: parseNum(urineOutput),
};
const validations: Array<[z.ZodTypeAny, number | undefined, string]> = [
[bpSchema, fields.morningSystolic, '晨起收缩压'],
[bpSchema, fields.morningDiastolic, '晨起舒张压'],
[bpSchema, fields.eveningSystolic, '晚间收缩压'],
[bpSchema, fields.eveningDiastolic, '晚间舒张压'],
[weightSchema, fields.weight, '体重'],
[bloodSugarSchema, fields.bloodSugar, '血糖'],
[volumeSchema, fields.fluidIntake, '饮水量'],
[volumeSchema, fields.urineOutput, '尿量'],
];
for (const [schema, value, label] of validations) {
if (value !== undefined) {
const result = schema.safeParse(value);
if (!result.success) {
Taro.showToast({ title: `${label}: ${result.error.errors[0].message}`, icon: 'none' });
return;
}
}
}
// ── Pre-submit abnormal confirmation ──
const abnormalFields = gatherAbnormalFields();
if (abnormalFields.length > 0) {
const confirmed = await Taro.showModal({
title: '数值异常提醒',
content: `以下指标超出正常范围:${abnormalFields.join('、')}。确认提交?`,
confirmText: '确认提交',
cancelText: '返回修改',
});
if (!confirmed.confirm) return;
}
setSubmitting(true);
try {
await createDailyMonitoring({
patient_id: currentPatient.id,
record_date: recordDate,
morning_bp_systolic: morningSystolic ? parseFloat(morningSystolic) : undefined,
morning_bp_diastolic: morningDiastolic ? parseFloat(morningDiastolic) : undefined,
evening_bp_systolic: eveningSystolic ? parseFloat(eveningSystolic) : undefined,
evening_bp_diastolic: eveningDiastolic ? parseFloat(eveningDiastolic) : undefined,
weight: weight ? parseFloat(weight) : undefined,
blood_sugar: bloodSugar ? parseFloat(bloodSugar) : undefined,
fluid_intake: fluidIntake ? parseFloat(fluidIntake) : undefined,
urine_output: urineOutput ? parseFloat(urineOutput) : undefined,
notes: notes || undefined,
});
trackEvent('daily_monitoring_submit', { date: recordDate });
useHealthStore.getState().clearCache();
clearRequestCache('/health/');
usePointsStore.getState().invalidate();
Taro.showToast({ title: '上报成功', icon: 'success' });
setTimeout(() => {
Taro.showToast({ title: '+10 健康积分', icon: 'none', duration: 1500 });
}, 1600);
setTimeout(() => {
Taro.navigateBack();
}, 3200);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '上报失败';
if (msg.includes('已有记录') || msg.includes('already exists')) {
Taro.showModal({
title: '提示',
content: '该日期已有监测记录,请选择其他日期',
showCancel: false,
});
} else {
Taro.showToast({ title: msg, icon: 'none' });
}
} finally {
setSubmitting(false);
}
};
const isToday = recordDate === today;
// ── Abnormal state helpers for rendering ──
const morningSysAbnormal = checkAbnormal(morningSystolic, 'systolic');
const morningDiaAbnormal = checkAbnormal(morningDiastolic, 'diastolic');
const eveningSysAbnormal = checkAbnormal(eveningSystolic, 'systolic');
const eveningDiaAbnormal = checkAbnormal(eveningDiastolic, 'diastolic');
const bloodSugarAbnormal = checkAbnormal(bloodSugar, 'bloodSugar');
return (
<View className={`dm-page ${modeClass}`}>
{/* 页面标题 */}
<View className='dm-hero'>
<View className='dm-hero-icon'>
<Text className='dm-hero-icon-text'></Text>
</View>
<Text className='dm-hero-title'></Text>
<Text className='dm-hero-sub'></Text>
</View>
{/* 日期选择 (standalone card) */}
<View className='dm-card'>
<View className='dm-card-header'>
<Text className='dm-card-title'></Text>
{isToday && (
<Text className='dm-card-badge'></Text>
)}
</View>
<Picker
mode='selector'
range={dateList}
value={dateIdx}
onChange={(e) => setDateIdx(Number(e.detail.value))}
>
<View className='dm-date-row'>
<Text className='dm-date-value'>{recordDate}</Text>
<Text className='dm-date-arrow'>V</Text>
</View>
</Picker>
</View>
{/* ── Group 1: 晨间体征 (default open) ── */}
<View className={`dm-group${collapsed.morning ? ' dm-group-collapsed' : ''}`}>
<View className='dm-group-header' onClick={() => toggleSection('morning')}>
<Text className='dm-group-title'></Text>
<Text className={`dm-group-arrow${collapsed.morning ? '' : ' dm-group-arrow-open'}`}>&#9656;</Text>
</View>
<View className='dm-group-body'>
<View className='dm-bp-group'>
<View className='dm-bp-field'>
<Text className='dm-field-label'></Text>
<Input
type='digit'
className={`dm-input-box${morningSysAbnormal.abnormal ? ' dm-input-abnormal' : ''}`}
placeholder='如 120'
value={morningSystolic}
onInput={(e) => setMorningSystolic(e.detail.value)}
/>
{morningSysAbnormal.abnormal && (
<Text className={`dm-field-warning${morningSysAbnormal.direction === 'low' ? ' dm-field-warning-low' : ''}`}>
{morningSysAbnormal.direction === 'high' ? '偏高' : '偏低'}
</Text>
)}
</View>
<View className='dm-bp-divider'>
<View className='dm-bp-line' />
<Text className='dm-bp-slash'>/</Text>
<View className='dm-bp-line' />
</View>
<View className='dm-bp-field'>
<Text className='dm-field-label'></Text>
<Input
type='digit'
className={`dm-input-box${morningDiaAbnormal.abnormal ? ' dm-input-abnormal' : ''}`}
placeholder='如 80'
value={morningDiastolic}
onInput={(e) => setMorningDiastolic(e.detail.value)}
/>
{morningDiaAbnormal.abnormal && (
<Text className={`dm-field-warning${morningDiaAbnormal.direction === 'low' ? ' dm-field-warning-low' : ''}`}>
{morningDiaAbnormal.direction === 'high' ? '偏高' : '偏低'}
</Text>
)}
</View>
</View>
<Text className='dm-field-unit'>mmHg</Text>
</View>
</View>
{/* ── Group 2: 晚间体征 (default open) ── */}
<View className={`dm-group${collapsed.evening ? ' dm-group-collapsed' : ''}`}>
<View className='dm-group-header' onClick={() => toggleSection('evening')}>
<Text className='dm-group-title'></Text>
<Text className={`dm-group-arrow${collapsed.evening ? '' : ' dm-group-arrow-open'}`}>&#9656;</Text>
</View>
<View className='dm-group-body'>
<View className='dm-bp-group'>
<View className='dm-bp-field'>
<Text className='dm-field-label'></Text>
<Input
type='digit'
className={`dm-input-box${eveningSysAbnormal.abnormal ? ' dm-input-abnormal' : ''}`}
placeholder='如 120'
value={eveningSystolic}
onInput={(e) => setEveningSystolic(e.detail.value)}
/>
{eveningSysAbnormal.abnormal && (
<Text className={`dm-field-warning${eveningSysAbnormal.direction === 'low' ? ' dm-field-warning-low' : ''}`}>
{eveningSysAbnormal.direction === 'high' ? '偏高' : '偏低'}
</Text>
)}
</View>
<View className='dm-bp-divider'>
<View className='dm-bp-line' />
<Text className='dm-bp-slash'>/</Text>
<View className='dm-bp-line' />
</View>
<View className='dm-bp-field'>
<Text className='dm-field-label'></Text>
<Input
type='digit'
className={`dm-input-box${eveningDiaAbnormal.abnormal ? ' dm-input-abnormal' : ''}`}
placeholder='如 80'
value={eveningDiastolic}
onInput={(e) => setEveningDiastolic(e.detail.value)}
/>
{eveningDiaAbnormal.abnormal && (
<Text className={`dm-field-warning${eveningDiaAbnormal.direction === 'low' ? ' dm-field-warning-low' : ''}`}>
{eveningDiaAbnormal.direction === 'high' ? '偏高' : '偏低'}
</Text>
)}
</View>
</View>
<Text className='dm-field-unit'>mmHg</Text>
</View>
</View>
{/* ── Group 3: 其他指标 (default collapsed) ── */}
<View className={`dm-group${collapsed.other ? ' dm-group-collapsed' : ''}`}>
<View className='dm-group-header' onClick={() => toggleSection('other')}>
<Text className='dm-group-title'></Text>
<Text className={`dm-group-arrow${collapsed.other ? '' : ' dm-group-arrow-open'}`}>&#9656;</Text>
</View>
<View className='dm-group-body'>
{/* 体重 */}
<View className='dm-inner-field'>
<Text className='dm-field-label'></Text>
<View className='dm-single-row'>
<Input
type='digit'
className='dm-input-box dm-input-flex'
placeholder='如 65.0'
value={weight}
onInput={(e) => setWeight(e.detail.value)}
/>
<Text className='dm-unit-inline'>kg</Text>
</View>
</View>
{/* 血糖 */}
<View className='dm-inner-field'>
<Text className='dm-field-label'></Text>
<View className='dm-single-row'>
<Input
type='digit'
className={`dm-input-box dm-input-flex${bloodSugarAbnormal.abnormal ? ' dm-input-abnormal' : ''}`}
placeholder='如 5.6'
value={bloodSugar}
onInput={(e) => setBloodSugar(e.detail.value)}
/>
<Text className='dm-unit-inline'>mmol/L</Text>
</View>
{bloodSugarAbnormal.abnormal && (
<Text className={`dm-field-warning${bloodSugarAbnormal.direction === 'low' ? ' dm-field-warning-low' : ''}`}>
{bloodSugarAbnormal.direction === 'high' ? '偏高' : '偏低'}
</Text>
)}
</View>
{/* 饮水量 */}
<View className='dm-inner-field'>
<Text className='dm-field-label'></Text>
<View className='dm-single-row'>
<Input
type='digit'
className='dm-input-box dm-input-flex'
placeholder='如 2000'
value={fluidIntake}
onInput={(e) => setFluidIntake(e.detail.value)}
/>
<Text className='dm-unit-inline'>ml</Text>
</View>
</View>
{/* 尿量 */}
<View className='dm-inner-field'>
<Text className='dm-field-label'>尿</Text>
<View className='dm-single-row'>
<Input
type='digit'
className='dm-input-box dm-input-flex'
placeholder='如 1500'
value={urineOutput}
onInput={(e) => setUrineOutput(e.detail.value)}
/>
<Text className='dm-unit-inline'>ml</Text>
</View>
</View>
{/* 备注 */}
<View className='dm-inner-field'>
<Text className='dm-field-label'></Text>
<Input
className='dm-input-box dm-input-full'
placeholder='如:头晕、乏力等(可选)'
value={notes}
onInput={(e) => setNotes(e.detail.value)}
/>
</View>
</View>
</View>
{/* 提交 */}
<View
className={`dm-submit ${submitting ? 'dm-submit-disabled' : ''}`}
onClick={submitting ? undefined : handleSubmit}
>
<Text className='dm-submit-text'>{submitting ? '提交中...' : '提交上报'}</Text>
</View>
{/* 重置 */}
<View className='dm-reset' onClick={resetForm}>
<Text className='dm-reset-text'></Text>
</View>
</View>
);
}