7bf16500a1
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>
181 lines
6.8 KiB
TypeScript
181 lines
6.8 KiB
TypeScript
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>
|
||
);
|
||
}
|