Files
hms/apps/miniprogram/src/pages/pkg-health/input/index.tsx
iven 59dd5ef38e fix(mp): P1+P2 稳定性加固 — 导航安全+生产日志+分包预加载+logout清理
P1:
- 全局 23 个页面 Taro.navigateTo → safeNavigateTo,防止页栈超10层
- 生产构建保留 console.warn/error,便于线上问题排查
- 添加 preloadRule 分包预加载(首页预加载健康/医生/文章分包)

P2:
- logout 时清理 ai_chat_history + BLE DataBuffer 缓存
- restore() 移除冗余的双重 Storage 读取(secureGet 已包含 getStorageSync)
- 首页文章图片添加 lazyLoad
2026-05-17 17:13:35 +08:00

295 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 { safeNavigateTo } from '@/utils/navigate';
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 PageShell from '@/components/ui/PageShell';
import ContentCard from '@/components/ui/ContentCard';
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 (
<PageShell padding="none" safeBottom className={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={() => safeNavigateTo('/pages/pkg-health/device-sync/index?returnTo=input')}>
<Text className='input-sync-entry-text'></Text>
<Text className='input-sync-entry-hint'></Text>
</View>
{/* 指标类型选择 */}
<ContentCard 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>
</ContentCard>
{/* 数值输入 */}
{BP_INDICATORS.includes(INDICATORS[indicatorIdx].value) ? (
<ContentCard 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>
</ContentCard>
) : (
<ContentCard 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>
</ContentCard>
)}
{/* 备注 */}
<ContentCard 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)}
/>
</ContentCard>
{/* 提交 */}
<View
className={`input-submit ${submitting ? 'input-submit-disabled' : ''}`}
onClick={submitting ? undefined : handleSubmit}
>
<Text className='input-submit-text'>{submitting ? '提交中...' : '提交录入'}</Text>
</View>
</>
)}
</PageShell>
);
}