P1: - 全局 23 个页面 Taro.navigateTo → safeNavigateTo,防止页栈超10层 - 生产构建保留 console.warn/error,便于线上问题排查 - 添加 preloadRule 分包预加载(首页预加载健康/医生/文章分包) P2: - logout 时清理 ai_chat_history + BLE DataBuffer 缓存 - restore() 移除冗余的双重 Storage 读取(secureGet 已包含 getStorageSync) - 首页文章图片添加 lazyLoad
295 lines
11 KiB
TypeScript
295 lines
11 KiB
TypeScript
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>
|
||
);
|
||
}
|