Files
hms/apps/miniprogram/src/pages/pkg-health/input/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

267 lines
10 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 } from 'react';
import { View, Text, Input, Picker } from '@tarojs/components';
import Taro, { useDidShow } from '@tarojs/taro';
import { z } from 'zod';
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 { trackEvent } from '@/services/analytics';
import { useElderClass } from '../../../hooks/useElderClass';
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 vitalSignSchema = z.object({
indicator_type: z.enum(['blood_pressure', 'blood_pressure_evening', 'heart_rate', 'blood_sugar_fasting', 'blood_sugar_postprandial', 'weight', 'temperature']),
value: z.number().positive({ message: '请输入有效数值' }),
extra: z.object({
systolic: z.number().min(60, '收缩压过低').max(250, '收缩压过高,请及时就医').optional(),
diastolic: z.number().min(40, '舒张压过低').max(150, '舒张压过高,请及时就医').optional(),
}).optional(),
note: z.string().max(200, '备注不能超过200字').optional(),
});
/** 根据动态阈值生成警告配置 */
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 { currentPatient } = useAuthStore();
const { clearCache } = useHealthStore();
/** 从 storage 中读取设备同步回传的数据并自动填充表单 */
useDidShow(() => {
getHealthThresholds().then((t) => { if (t.length > 0) setThresholds(t); });
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 {
// 解析失败则忽略,不影响正常使用
}
});
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 result = vitalSignSchema.safeParse(input);
if (!result.success) {
Taro.showToast({ title: result.error.issues[0].message, 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 });
setTimeout(() => 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}`}>
{/* 页面标题 */}
<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>
);
}