Files
memind/src/pages/HealthPage.tsx
T
john 7bf16500a1 feat(health): wire H5 health page, WeChat channel, and observation API.
Expose /health and /api/health/observations behind MEMIND_HEALTH_ENABLED, redirect ordinary chat health intents without persisting, and run a dedicated WeChat menu flow with confirm-before-write semantics.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 18:04:26 +08:00

181 lines
6.8 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 { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { listHealthObservations, saveConfirmedBloodPressure } from '../api/health';
import {
HEALTH_CHANNEL_STEPS,
HEALTH_ACTIONS,
createIdleHealthChannelState,
reduceHealthChannel,
} from '../../health-channel-state.mjs';
import { inferBloodPressureContext, validateBloodPressurePair } from '../../health-observation-validate.mjs';
export function HealthPage() {
const navigate = useNavigate();
const [state, setState] = useState(() => createIdleHealthChannelState({ channel: 'h5' }));
const [systolic, setSystolic] = useState('');
const [diastolic, setDiastolic] = useState('');
const [context, setContext] = useState<'morning' | 'evening' | 'other'>(
inferBloodPressureContext(Date.now()),
);
const [draft, setDraft] = useState<{ systolic: number; diastolic: number; context: 'morning' | 'evening' | 'other' } | null>(null);
const [rows, setRows] = useState<Array<{ id: number; metricType: string; valueNum: number | null; context?: string | null; createdAt: number }>>([]);
const [message, setMessage] = useState('');
const [saving, setSaving] = useState(false);
const refresh = async () => {
try {
setRows(await listHealthObservations(30));
} catch (error) {
setMessage(error instanceof Error ? error.message : '读取失败');
}
};
useEffect(() => {
void refresh();
}, []);
const startRecord = () => {
const next = reduceHealthChannel(state, { type: 'select_action', action: HEALTH_ACTIONS.RECORD });
const picked = reduceHealthChannel(next.state, { type: 'pick_metric', metricSet: 'blood_pressure' });
const ready = reduceHealthChannel(picked.state, { type: 'pick_context', context });
setState(ready.state);
setMessage('请填写血压并确认。数值不会在确认前保存。');
};
const goConfirm = () => {
const pair = validateBloodPressurePair(Number(systolic), Number(diastolic));
if (!pair.ok) {
setMessage('请输入有效血压,例如 128 / 76。收缩压必须大于舒张压。');
return;
}
const next = reduceHealthChannel(state, { type: 'submit_value', draftId: 'h5-bp' });
setState(next.state);
setDraft({ systolic: pair.systolic, diastolic: pair.diastolic, context });
setMessage(`确认保存:${pair.systolic}/${pair.diastolic}(${context === 'morning' ? '晨间' : context === 'evening' ? '晚间' : '其他'})`);
};
const commit = async () => {
if (!draft) return;
setSaving(true);
try {
await saveConfirmedBloodPressure(draft);
const next = reduceHealthChannel(state, { type: 'commit' });
const reset = reduceHealthChannel(next.state, { type: 'continue' });
setState(reset.state);
setDraft(null);
setSystolic('');
setDiastolic('');
setMessage('已写入你的健康档案。这不是医疗诊断。');
await refresh();
} catch (error) {
setMessage(error instanceof Error ? error.message : '保存失败');
} finally {
setSaving(false);
}
};
const grouped = useMemo(() => {
const byTime = new Map<string, { systolic?: number; diastolic?: number; context?: string | null }>();
for (const row of rows) {
const key = String(row.createdAt);
const current = byTime.get(key) ?? {};
if (row.metricType === 'bp_systolic') current.systolic = Number(row.valueNum);
if (row.metricType === 'bp_diastolic') current.diastolic = Number(row.valueNum);
current.context = row.context;
byTime.set(key, current);
}
return [...byTime.entries()].map(([createdAt, value]) => ({ createdAt: Number(createdAt), ...value }));
}, [rows]);
return (
<div className="health-page">
<header className="health-page-header">
<button type="button" className="ghost-btn" onClick={() => navigate('/')}>
返回聊天
</button>
<h1>健康助手</h1>
<p>只记录你本人的数据。确认前不会写入,也不会在普通聊天落库。</p>
</header>
{state.step === HEALTH_CHANNEL_STEPS.IDLE && (
<div className="health-actions">
<button type="button" className="primary-btn" onClick={startRecord}>
1 健康录入
</button>
<button type="button" className="ghost-btn" onClick={() => setMessage('评估将在基线成熟后开启。请先连续记录。')}>
2 健康评估
</button>
<button type="button" className="ghost-btn" onClick={() => navigate('/space?category=health')}>
4 健康档案区
</button>
</div>
)}
{state.step !== HEALTH_CHANNEL_STEPS.IDLE && (
<form
className="health-form"
onSubmit={(event) => {
event.preventDefault();
if (state.step === HEALTH_CHANNEL_STEPS.CONFIRM) void commit();
else goConfirm();
}}
>
<label>
情境
<select value={context} onChange={(event) => setContext(event.target.value as typeof context)}>
<option value="morning">晨间</option>
<option value="evening">晚间</option>
<option value="other">其他</option>
</select>
</label>
<label>
收缩压
<input inputMode="numeric" value={systolic} onChange={(event) => setSystolic(event.target.value)} />
</label>
<label>
舒张压
<input inputMode="numeric" value={diastolic} onChange={(event) => setDiastolic(event.target.value)} />
</label>
{state.step === HEALTH_CHANNEL_STEPS.CONFIRM ? (
<button type="submit" className="primary-btn" disabled={saving}>
确认保存
</button>
) : (
<button type="submit" className="primary-btn">
下一步确认
</button>
)}
<button
type="button"
className="ghost-btn"
onClick={() => {
setState(createIdleHealthChannelState({ channel: 'h5' }));
setDraft(null);
setMessage('已取消本次录入。');
}}
>
取消
</button>
</form>
)}
{message && <p className="health-message">{message}</p>}
<section>
<h2>最近记录</h2>
{grouped.length === 0 ? (
<p className="health-empty">还没有确认过的血压记录。</p>
) : (
<ul className="health-timeline">
{grouped.map((item) => (
<li key={item.createdAt}>
{new Date(item.createdAt).toLocaleString()} · {item.systolic}/{item.diastolic} · {item.context || 'other'}
</li>
))}
</ul>
)}
</section>
</div>
);
}