Files
hms/apps/miniprogram/src/pages/pkg-health/input/index.tsx
iven 1fd2c7a533 refactor(mp): 架构重构 — usePageData 统一数据加载 + Store 解耦 + 大页面拆分
新增 usePageData hook(useDidShow 节流 + usePullDownRefresh + loadingRef 防重入 + enabled 条件守卫),
44/58 页面迁移接入,消灭 4 种数据加载模式并存。

- 新增 hooks/usePageData.ts — 统一页面数据加载生命周期
- 新增 stores/index.ts — resetAllStores() 解耦 auth↔health store 依赖
- 新增 pages/index/useHomeData.ts — 首页数据 hook(424→282 行)
- 新增 pages/health/useHealthData.ts — 健康页数据 hook(422→254 行)
- 44 个页面迁移到 usePageData(9 患者端 + 15 医生端 + 20 子包)
- auth store logout 不再直接导入 health store

构建通过,测试 74/75(1 个预存失败)。
2026-05-15 01:13:01 +08:00

292 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useCallback } from 'react';
import { View, Text, Input, Picker } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { usePageData } from '@/hooks/usePageData';
import { num, validateStr } from '@/utils/validate';
import { inputVitalSign, getHealthThresholds, findThreshold, DEFAULT_THRESHOLDS, type HealthThreshold } from '../../../services/health';
import { useAuthStore } from '../../../stores/auth';
import { useHealthStore } from '@/stores/health';
import { usePointsStore } from '@/stores/points';
import { clearRequestCache } from '@/services/request';
import { useSafeTimeout } from '@/hooks/useSafeTimeout';
import { trackEvent } from '@/services/analytics';
import { useElderClass } from '../../../hooks/useElderClass';
import Loading from '../../../components/Loading';
import './index.scss';
const INDICATORS = [
{ value: 'blood_pressure', label: '晨间血压 (mmHg)' },
{ value: 'blood_pressure_evening', 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: '体温 (℃)' },
];
const BP_INDICATORS = ['blood_pressure', 'blood_pressure_evening'];
const valueCheck = num({ posMsg: '请输入有效数值' });
const systolicCheck = num({ min: 60, minMsg: '收缩压过低', max: 250, maxMsg: '收缩压过高,请及时就医', optional: true });
const diastolicCheck = num({ min: 40, minMsg: '舒张压过低', max: 150, maxMsg: '舒张压过高,请及时就医', optional: true });
/** 根据动态阈值生成警告配置 */
function getWarnForIndicator(
thresholds: HealthThreshold[],
indicator: string,
): { max?: number; min?: number; warning: string } | null {
const isBp = BP_INDICATORS.includes(indicator);
const high = findThreshold(thresholds, isBp ? 'systolic_bp' : indicator, 'high');
const low = findThreshold(thresholds, isBp ? 'systolic_bp' : indicator, 'low');
if (!high && !low) return null;
const warningMap: Record<string, string> = {
blood_pressure: '收缩压偏高,建议及时就医',
blood_pressure_evening: '收缩压偏高,建议及时就医',
heart_rate: '心率异常,请注意休息',
blood_sugar_fasting: '血糖偏高,建议就医检查',
blood_sugar_postprandial: '血糖偏高,建议就医检查',
};
return {
max: high?.threshold_value,
min: low?.threshold_value,
warning: warningMap[indicator] ?? '数值异常,请关注',
};
}
export default function HealthInput() {
const modeClass = useElderClass();
const [indicatorIdx, setIndicatorIdx] = useState(0);
const [thresholds, setThresholds] = useState<HealthThreshold[]>(DEFAULT_THRESHOLDS);
const [value, setValue] = useState('');
const [systolic, setSystolic] = useState('');
const [diastolic, setDiastolic] = useState('');
const [note, setNote] = useState('');
const [submitting, setSubmitting] = useState(false);
const { safeSetTimeout } = useSafeTimeout();
const [loadingThresholds, setLoadingThresholds] = useState(true);
const currentPatient = useAuthStore((s) => s.currentPatient);
const clearCache = useHealthStore((s) => s.clearCache);
/** 从 storage 中读取设备同步回传的数据并自动填充表单 */
const loadThresholdsAndSync = useCallback(async () => {
setLoadingThresholds(true);
try {
const t = await getHealthThresholds();
if (t.length > 0) setThresholds(t);
} finally {
setLoadingThresholds(false);
}
try {
const raw = Taro.getStorageSync('device_sync_result');
if (!raw) return;
Taro.removeStorageSync('device_sync_result');
const syncData: Record<string, number> = typeof raw === 'string' ? JSON.parse(raw) : raw;
// 字段映射:设备同步数据 → 表单字段
if (syncData.systolic != null && syncData.diastolic != null) {
// 有血压数据 → 切换到血压指标并填充
setIndicatorIdx(0);
setSystolic(String(syncData.systolic));
setDiastolic(String(syncData.diastolic));
} else if (syncData.blood_sugar != null) {
setIndicatorIdx(2); // 空腹血糖
setValue(String(syncData.blood_sugar));
} else if (syncData.heart_rate != null) {
setIndicatorIdx(1); // 心率
setValue(String(syncData.heart_rate));
}
} catch {
// 解析失败则忽略,不影响正常使用
}
}, []);
usePageData(loadThresholdsAndSync, { throttleMs: 10000 });
const handleSubmit = async () => {
if (!currentPatient) {
Taro.showToast({ title: '请先选择就诊人', icon: 'none' });
return;
}
const currentIndicator = INDICATORS[indicatorIdx].value;
if (BP_INDICATORS.includes(currentIndicator)) {
if (!systolic || !diastolic) {
Taro.showToast({ title: '请填写收缩压和舒张压', icon: 'none' });
return;
}
} else {
if (!value) {
Taro.showToast({ title: '请输入数值', icon: 'none' });
return;
}
}
const input = BP_INDICATORS.includes(currentIndicator)
? { indicator_type: currentIndicator as 'blood_pressure' | 'blood_pressure_evening', value: parseFloat(systolic), extra: { systolic: parseFloat(systolic), diastolic: parseFloat(diastolic) } }
: { indicator_type: currentIndicator as any, value: parseFloat(value) };
const valueResult = valueCheck.safeParse(input.value);
if (!valueResult.ok) {
Taro.showToast({ title: valueResult.message, icon: 'none' });
return;
}
if (input.extra?.systolic !== undefined) {
const r = systolicCheck.safeParse(input.extra.systolic);
if (!r.ok) { Taro.showToast({ title: r.message, icon: 'none' }); return; }
}
if (input.extra?.diastolic !== undefined) {
const r = diastolicCheck.safeParse(input.extra.diastolic);
if (!r.ok) { Taro.showToast({ title: r.message, icon: 'none' }); return; }
}
if (note) {
const err = validateStr(note, 200, '备注');
if (err) { Taro.showToast({ title: err, icon: 'none' }); return; }
}
const threshold = getWarnForIndicator(thresholds, currentIndicator);
if (threshold) {
const val = input.value;
if ((threshold.max && val > threshold.max) || (threshold.min && val < threshold.min)) {
await Taro.showModal({ title: '健康提示', content: threshold.warning, showCancel: false });
}
}
setSubmitting(true);
try {
await inputVitalSign(currentPatient.id, {
...input,
note: note || undefined,
});
clearCache();
clearRequestCache('/health/');
usePointsStore.getState().invalidate();
Taro.showToast({ title: '录入成功', icon: 'success' });
trackEvent('health_data_input', { type: currentIndicator });
safeSetTimeout(() => Taro.navigateBack(), 1000);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '录入失败';
Taro.showToast({ title: msg, icon: 'none' });
} finally {
setSubmitting(false);
}
};
const indicatorInitial = INDICATORS[indicatorIdx].label.charAt(0);
return (
<View className={`input-page ${modeClass}`}>
{loadingThresholds && <Loading />}
{!loadingThresholds && (
<>
{/* 页面标题 */}
<View className='input-hero'>
<View className='input-hero-icon'>
<Text className='input-hero-icon-text'></Text>
</View>
<Text className='input-hero-title'></Text>
<Text className='input-hero-sub'></Text>
</View>
{/* 从设备同步入口 */}
<View className='input-sync-entry' onClick={() => Taro.navigateTo({ url: '/pages/device-sync/index?returnTo=input' })}>
<Text className='input-sync-entry-text'></Text>
<Text className='input-sync-entry-hint'></Text>
</View>
{/* 指标类型选择 */}
<View className='input-card'>
<View className='input-card-header'>
<View className='input-card-indicator'>
<Text className='input-card-indicator-char'>{indicatorInitial}</Text>
</View>
<Text className='input-card-label'></Text>
</View>
<Picker
mode='selector'
range={INDICATORS.map((i) => i.label)}
value={indicatorIdx}
onChange={(e) => setIndicatorIdx(Number(e.detail.value))}
>
<View className='input-picker-row'>
<Text className='input-picker-value'>{INDICATORS[indicatorIdx].label}</Text>
<Text className='input-picker-arrow'>V</Text>
</View>
</Picker>
</View>
{/* 数值输入 */}
{BP_INDICATORS.includes(INDICATORS[indicatorIdx].value) ? (
<View className='input-card'>
<Text className='input-section-title'></Text>
<View className='input-bp-group'>
<View className='input-bp-field'>
<Text className='input-field-label'></Text>
<Input
type='digit'
className='input-field-box'
placeholder='如 120'
value={systolic}
onInput={(e) => setSystolic(e.detail.value)}
/>
</View>
<View className='input-bp-divider'>
<View className='input-bp-line' />
<Text className='input-bp-slash'>/</Text>
<View className='input-bp-line' />
</View>
<View className='input-bp-field'>
<Text className='input-field-label'></Text>
<Input
type='digit'
className='input-field-box'
placeholder='如 80'
value={diastolic}
onInput={(e) => setDiastolic(e.detail.value)}
/>
</View>
</View>
<Text className='input-field-unit'>mmHg</Text>
</View>
) : (
<View className='input-card'>
<Text className='input-section-title'></Text>
<Input
type='digit'
className='input-field-box input-field-full'
placeholder='请输入数值'
value={value}
onInput={(e) => setValue(e.detail.value)}
/>
<Text className='input-field-unit'>
{INDICATORS[indicatorIdx].label.match(/\((.+)\)/)?.[1] || ''}
</Text>
</View>
)}
{/* 备注 */}
<View className='input-card'>
<Text className='input-section-title'></Text>
<Input
className='input-field-box input-field-full'
placeholder='如饭后2小时可选'
value={note}
onInput={(e) => setNote(e.detail.value)}
/>
</View>
{/* 提交 */}
<View
className={`input-submit ${submitting ? 'input-submit-disabled' : ''}`}
onClick={submitting ? undefined : handleSubmit}
>
<Text className='input-submit-text'>{submitting ? '提交中...' : '提交录入'}</Text>
</View>
</>
)}
</View>
);
}