- 新建 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>
490 lines
18 KiB
TypeScript
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'}`}>▸</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'}`}>▸</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'}`}>▸</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>
|
|
);
|
|
}
|