feat(health): complete P0 health channel — baseline engine, page-data, MindSpace UI

Deliver encrypted health zone, observation API, baseline maturity pipeline,
page-data bindings, and H5/WeChat channel integration for health P0.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-09-09 18:04:22 +08:00
parent 7bf16500a1
commit 2baf29b3ae
94 changed files with 7283 additions and 194 deletions
+4 -1
View File
@@ -1,4 +1,4 @@
import { ChangeEvent, useCallback, useEffect, useLayoutEffect, useRef, useState, type ClipboardEvent, type MutableRefObject } from 'react';
import { ChangeEvent, useCallback, useEffect, useLayoutEffect, useRef, useState, type ClipboardEvent, type MutableRefObject, type ReactNode } from 'react';
import { BrainCircuit } from 'lucide-react';
import { useNetworkStatus } from '../hooks/useNetworkStatus';
import { openAvatarPicker } from '../utils/userAvatar';
@@ -186,6 +186,7 @@ export function ChatPanel({
onGrantedSkillsUpdate,
onOpenRecharge,
taskLoadingTip = null,
messageAreaPrefix = null,
}: {
variant: 'full' | 'compact';
user?: PortalUser | null;
@@ -198,6 +199,7 @@ export function ChatPanel({
session: Session | null;
capabilities?: CapabilityMap;
grantedSkills?: string[];
messageAreaPrefix?: ReactNode;
onSubmit: (
text: string,
imageUrls?: string[],
@@ -1088,6 +1090,7 @@ export function ChatPanel({
: `向上滚动加载更早消息${historyTotal > 0 ? ` · 已加载 ${messages.length} / ${historyTotal}` : ''}`}
</div>
)}
{messageAreaPrefix}
<MessageList
messages={messages}
streaming={showAssistantTyping}
+62 -27
View File
@@ -1,8 +1,8 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useChat } from '../context/ChatProvider';
import { INSUFFICIENT_BALANCE_NOTICE } from '../hooks/useTKMindChat';
import { useGoalRunBanner } from '../hooks/useGoalRunBanner';
import { useHealthChannel } from '../hooks/useHealthChannel';
import type { CapabilityMap, PortalUser, SessionSummary } from '../types';
import { useNetworkStatus } from '../hooks/useNetworkStatus';
import { getSessionDisplayName } from '../utils/sessions';
@@ -19,11 +19,12 @@ import { WechatAccountButton } from './WechatAccountButton';
import { ChatHeaderMoreMenu } from './ChatHeaderMoreMenu';
import { NotificationCenter } from './NotificationCenter';
import { GoalRunAwaitingBanner } from './GoalRunAwaitingBanner';
import { HealthChannelBanner } from './HealthChannelBanner';
import { HealthAlertsPanel } from './HealthAlertsPanel';
import { HealthTimelinePanel } from './HealthTimelinePanel';
import type { MindSpaceSaveCategory } from '../types';
import {
HEALTH_REDIRECT_COPY,
shouldRedirectOrdinaryChatToHealth,
} from '../../health-intent-rules.mjs';
import { fetchHealthAgentContext } from '../api/health';
import { wrapHealthAgentUserMessage } from '../../health-agent-context.mjs';
function RecentSessionRail({
sessions,
@@ -229,7 +230,17 @@ export function ChatView({
goalRunEnabled,
healthEnabled,
} = useChat();
const navigate = useNavigate();
const healthChannel = useHealthChannel({
enabled: healthEnabled,
newSession,
});
const displayMessages = useMemo(
() =>
healthChannel.overlayMessages.length > 0
? [...messages, ...healthChannel.overlayMessages]
: messages,
[healthChannel.overlayMessages, messages],
);
const online = useNetworkStatus();
const goalRunBanner = useGoalRunBanner({
userId: user?.id,
@@ -319,9 +330,6 @@ export function ChatView({
...(onOpenAdmin
? [{ id: 'admin', label: '管理', onClick: () => onOpenAdmin() }]
: []),
...(healthEnabled
? [{ id: 'health', label: '健康', onClick: () => navigate('/health') }]
: []),
...(onLogout
? [{ id: 'logout', label: '登出', onClick: () => onLogout(), danger: true }]
: []),
@@ -419,16 +427,6 @@ export function ChatView({
管理
</button>
)}
{healthEnabled && (
<button
type="button"
className="ghost-btn"
data-analytics-action="open_health"
onClick={() => navigate('/health')}
>
健康
</button>
)}
{onOpenSpace && (
<button
ref={spaceButtonRef}
@@ -581,10 +579,33 @@ export function ChatView({
</div>
)}
{healthChannel.active && (
<HealthChannelBanner onExit={() => healthChannel.exitChannel()} />
)}
<ChatPanel
variant="full"
user={user}
messages={messages}
messageAreaPrefix={
healthChannel.active ? (
<>
{!healthChannel.alertsGateOpen && healthChannel.unreadAlerts.length > 0 ? (
<HealthAlertsPanel
alerts={healthChannel.unreadAlerts}
acknowledging={healthChannel.acknowledgingAlerts}
onAcknowledgeAll={() => void healthChannel.acknowledgeAllAlerts()}
/>
) : null}
{healthChannel.showTimeline ? (
<HealthTimelinePanel
days={14}
onClose={() => healthChannel.setShowTimeline(false)}
/>
) : null}
</>
) : null
}
messages={displayMessages}
memoryRecallByMessageId={memoryRecallByMessageId}
historyLoadingMore={messageHistoryLoadingMore}
historyHasMore={messageHistoryHasMore}
@@ -599,20 +620,34 @@ export function ChatView({
onGrantedSkillsUpdate={onGrantedSkillsUpdate}
onOpenRecharge={() => openRecharge(false)}
taskLoadingTip={taskLoadingTip}
onSubmit={(text, imageUrls, previewImageUrls, options) => {
if (healthEnabled && shouldRedirectOrdinaryChatToHealth(text) && !options?.selectedChatSkill) {
window.alert(HEALTH_REDIRECT_COPY);
navigate('/health');
return;
onSubmit={async (text, imageUrls, previewImageUrls, options) => {
const handled = await healthChannel.handleChannelSubmit(text, {
messageId: options?.messageId,
hasImages: imageUrls.length > 0 || previewImageUrls.length > 0,
imageUrls: imageUrls.length > 0 ? imageUrls : previewImageUrls,
});
if (handled) return;
let outgoingText = text;
if (healthChannel.active) {
try {
const context = await fetchHealthAgentContext();
if (context.trim()) {
outgoingText = wrapHealthAgentUserMessage(context, text);
}
} catch {
// Agent 仍可回答,只是缺少档案摘要
}
}
void submit(
text,
outgoingText,
{
messageId: options?.messageId,
forceDeepReasoning: options?.forceDeepReasoning,
pgRequired: options?.pgRequired,
imageGenerationMode: options?.imageGenerationMode,
selectedChatSkill: options?.selectedChatSkill,
selectedChatSkill:
options?.selectedChatSkill
?? (healthChannel.active ? 'health-assistant' : undefined),
rainTimePreset: options?.rainTimePreset,
fileAttachments: options?.fileAttachments,
},
+48
View File
@@ -0,0 +1,48 @@
import type { HealthUnreadAlert } from '../api/health';
type HealthAlertsPanelProps = {
alerts: HealthUnreadAlert[];
acknowledging?: boolean;
onAcknowledgeAll: () => void;
};
export function HealthAlertsPanel({
alerts,
acknowledging = false,
onAcknowledgeAll,
}: HealthAlertsPanelProps) {
if (!alerts.length) return null;
return (
<section className="health-alerts-panel">
<div className="health-alerts-panel-header">
<strong>未读健康提醒</strong>
<span className="health-alerts-badge">{alerts.length} 条</span>
</div>
<p className="health-alerts-hint">
以下变化已记录在你的健康档案,不是医疗诊断。确认已知晓后才会显示功能菜单。
</p>
<ul className="health-alerts-list">
{alerts.map((alert) => (
<li
key={alert.id}
className={alert.severity === 'alert' ? 'health-alert-critical' : 'health-alert-watch'}
>
<span className="health-alert-severity">
{alert.severity === 'alert' ? '需关注' : '提示'}
</span>
<span>{alert.message ?? alert.agentSummary ?? '健康变化'}</span>
</li>
))}
</ul>
<button
type="button"
className="mindspace-primary health-alerts-ack-btn"
disabled={acknowledging}
onClick={onAcknowledgeAll}
>
{acknowledging ? '确认中…' : '我知道了'}
</button>
</section>
);
}
+17
View File
@@ -0,0 +1,17 @@
type HealthChannelBannerProps = {
onExit: () => void;
};
export function HealthChannelBanner({ onExit }: HealthChannelBannerProps) {
return (
<div className="banner banner-info health-channel-banner">
<div className="health-channel-banner-body">
<strong>健康助手专用通道</strong>
<span>本通道只处理健康相关内容,数据确认后才会写入你的健康档案。回复「0」可退出。</span>
</div>
<button type="button" className="ghost-btn" onClick={onExit}>
退出通道
</button>
</div>
);
}
+130
View File
@@ -0,0 +1,130 @@
import { useEffect, useState } from 'react';
import { HealthTimelinePanel } from './HealthTimelinePanel';
import {
bootstrapHealthWorkspace,
fetchHealthAssessSummary,
fetchHealthDocuments,
fetchHealthTimeline,
listHealthObservations,
} from '../api/health';
type HealthDocument = {
id?: string | number;
title?: string | null;
notes?: string | null;
createdAt?: number;
archivedAt?: number;
};
type HealthMindSpacePanelProps = {
onOpenPage?: (pageId: string) => void;
};
export function HealthMindSpacePanel({ onOpenPage }: HealthMindSpacePanelProps) {
const [timelineDays, setTimelineDays] = useState(0);
const [observationCount, setObservationCount] = useState(0);
const [documentCount, setDocumentCount] = useState(0);
const [documents, setDocuments] = useState<HealthDocument[]>([]);
const [assessPreview, setAssessPreview] = useState('');
const [timelinePageId, setTimelinePageId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
let cancelled = false;
void (async () => {
setLoading(true);
setError('');
try {
const bootstrapResponse = await bootstrapHealthWorkspace().catch(() => null);
const pageId = bootstrapResponse?.bootstrap?.timelinePage?.pageId;
if (!cancelled && typeof pageId === 'string' && pageId) {
setTimelinePageId(pageId);
}
const [timeline, summary, observations, documentPayload] = await Promise.all([
fetchHealthTimeline(30),
fetchHealthAssessSummary().catch(() => ''),
listHealthObservations(100).catch(() => []),
fetchHealthDocuments(20).catch(() => []),
]);
if (cancelled) return;
setTimelineDays(Array.isArray(timeline) ? timeline.length : 0);
setObservationCount(Array.isArray(observations) ? observations.length : 0);
setDocuments(Array.isArray(documentPayload) ? documentPayload : []);
setDocumentCount(Array.isArray(documentPayload) ? documentPayload.length : 0);
setAssessPreview(typeof summary === 'string' ? summary : '');
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : '读取健康档案失败');
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, []);
return (
<section className="health-mindspace-panel">
<div className="health-mindspace-panel-header">
<strong>健康档案</strong>
<span className="health-mindspace-badge">加密分区 · 禁止完全公开</span>
</div>
{loading ? <p className="health-empty">正在同步健康数据…</p> : null}
{error ? <p className="health-message">{error}</p> : null}
{!loading && !error ? (
<>
<div className="health-mindspace-stats">
<span>近 30 天 <strong>{timelineDays}</strong> 天有记录</span>
<span>已确认指标 <strong>{observationCount}</strong> 条</span>
<span>归档报告 <strong>{documentCount}</strong> 份</span>
</div>
{timelinePageId && onOpenPage ? (
<button
type="button"
className="mindspace-secondary health-mindspace-open-page"
onClick={() => onOpenPage(timelinePageId)}
>
打开 Timeline 摘要页
</button>
) : null}
<HealthTimelinePanel days={30} />
{assessPreview ? (
<div className="health-mindspace-assess">
<strong>健康评估摘要</strong>
<pre className="health-mindspace-preview">{assessPreview}</pre>
</div>
) : null}
{documents.length > 0 ? (
<div className="health-mindspace-documents">
<strong>归档报告</strong>
<ul className="health-mindspace-document-list">
{documents.map((document, index) => (
<li key={String(document.id ?? `${document.title ?? 'doc'}-${index}`)}>
{document.title || document.notes || '健康报告'}
</li>
))}
</ul>
</div>
) : (
<p className="health-empty">还没有归档报告。可在健康助手通道回复「2」上传,或点上方「上传报告」。</p>
)}
<ul className="health-mindspace-tips">
<li>本人登录可直接查看;分享给医生请发布为「口令访问」,不要选完全公开。</li>
<li>下方卡片是本分区页面与资料;Timeline 摘要页可单独发布给医生只读查看。</li>
</ul>
</>
) : null}
</section>
);
}
+88
View File
@@ -0,0 +1,88 @@
import { useEffect, useRef, useState } from 'react';
import { fetchHealthTimeline } from '../api/health';
type TimelineDay = {
date: string;
count: number;
metrics: Record<
string,
{
label: string;
value: string;
context?: string | null;
qualityFlag?: string;
}
>;
};
type HealthTimelinePanelProps = {
days?: number;
onClose?: () => void;
};
export function HealthTimelinePanel({ days = 14, onClose }: HealthTimelinePanelProps) {
const panelRef = useRef<HTMLElement>(null);
const [timeline, setTimeline] = useState<TimelineDay[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
panelRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}, []);
useEffect(() => {
let cancelled = false;
void (async () => {
setLoading(true);
setError('');
try {
const rows = await fetchHealthTimeline(days);
if (!cancelled) setTimeline(rows as TimelineDay[]);
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : '读取 Timeline 失败');
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [days]);
return (
<section ref={panelRef} className="health-timeline-panel" id="health-timeline-panel">
<div className="health-timeline-panel-header">
<strong>健康 Timeline(近 {days} 天)</strong>
{onClose ? (
<button type="button" className="ghost-btn" onClick={onClose}>
收起
</button>
) : null}
</div>
{loading ? <p className="health-empty">加载中…</p> : null}
{error ? <p className="health-message">{error}</p> : null}
{!loading && !error && timeline.length === 0 ? (
<p className="health-empty">还没有确认过的健康记录。回复「1」开始录入。</p>
) : null}
{!loading && timeline.length > 0 ? (
<ul className="health-timeline">
{timeline.map((day) => (
<li key={day.date}>
<div className="health-timeline-date">{day.date}</div>
<ul className="health-timeline-metrics">
{Object.values(day.metrics).map((metric) => (
<li key={`${day.date}-${metric.label}-${metric.value}`}>
{metric.label}: {metric.value}
{metric.context ? ` · ${metric.context}` : ''}
</li>
))}
</ul>
</li>
))}
</ul>
) : null}
</section>
);
}
+35 -5
View File
@@ -41,6 +41,13 @@ import { MindSpaceDeletePlazaOption } from './MindSpaceDeletePlazaOption';
import { MindSpacePublishSuccess } from './MindSpacePublishSuccess';
import { MindSpacePageDataOpsPanel } from './MindSpacePageDataOpsPanel';
import { prepareHtmlPageBrandMarkers } from '../../mindspace-page-tag.mjs';
import {
defaultAccessModeForCategory,
filterAccessModesForCategory,
filterPageDataDatasetsForCategory,
healthCategoryPageDataDefaults,
isHealthCategoryCode,
} from '../../health-mindspace.mjs';
type AccessMode = MindSpacePublishCheck['accessMode'];
@@ -330,8 +337,11 @@ export function MindSpacePageDetail({
void listPageDataDatasets()
.then((datasets) => {
if (cancelled) return;
setPageDataDatasets(datasets);
setPageDataDataset((current) => current || datasets[0]?.name || '');
const filtered = page?.categoryCode
? filterPageDataDatasetsForCategory(page.categoryCode, datasets)
: datasets;
setPageDataDatasets(filtered);
setPageDataDataset((current) => current || filtered[0]?.name || '');
})
.catch((err) => {
if (cancelled) return;
@@ -343,7 +353,18 @@ export function MindSpacePageDetail({
return () => {
cancelled = true;
};
}, [publishOpen]);
}, [publishOpen, page?.categoryCode]);
useEffect(() => {
if (!publishOpen || !isHealthCategoryCode(page?.categoryCode ?? '')) return;
const defaults = healthCategoryPageDataDefaults();
setPageDataEnabled(true);
setPageDataRead(defaults.read);
setPageDataInsert(defaults.insert);
setPageDataUpdate(defaults.update);
setPageDataSoftDelete(defaults.softDelete);
setAccessMode((current) => defaultAccessModeForCategory(page?.categoryCode ?? '', current));
}, [publishOpen, page?.categoryCode]);
useEffect(() => {
if (!publishOpen || !page?.id) return;
@@ -419,7 +440,9 @@ export function MindSpacePageDetail({
setPreviewKey((value) => value + 1);
setTemplateId(next.templateId);
setUrlSlug(next.publication?.urlSlug ?? slugFromTitle(next.title, next.id));
setAccessMode(next.publication?.accessMode ?? 'public');
setAccessMode(
defaultAccessModeForCategory(next.categoryCode, next.publication?.accessMode ?? 'public'),
);
setAccessPassword('');
setExpiresAt(
next.publication?.expiresAt
@@ -1252,13 +1275,20 @@ export function MindSpacePageDetail({
setFixNotice(null);
}}
>
{Object.entries(ACCESS_MODE_LABELS).map(([value, label]) => (
{Object.entries(
filterAccessModesForCategory(page?.categoryCode ?? '', ACCESS_MODE_LABELS),
).map(([value, label]) => (
<option value={value} key={value}>
{label}
</option>
))}
</select>
</label>
{isHealthCategoryCode(page?.categoryCode ?? '') ? (
<p className="mindspace-page-data-publish-hint">
健康档案区禁止完全公开发布;分享请使用口令或登录访问,并绑定脱敏摘要 dataset。
</p>
) : null}
{PAGE_DATA_PUBLISH_MODES.has(accessMode) ? (
<div className="mindspace-page-data-publish-panel">
<label className="mindspace-page-data-publish-toggle">
+30 -6
View File
@@ -60,6 +60,8 @@ import { MindSpaceFeedCard } from './MindSpaceFeedCard';
import { MindSpaceAchievementsPanel } from './MindSpaceAchievementsPanel';
import { MindSpacePageDetail } from './MindSpacePageDetail';
import { MindSpaceModal } from './MindSpaceModal';
import { HealthMindSpacePanel } from './HealthMindSpacePanel';
import { archiveHealthDocument, bootstrapHealthWorkspace } from '../api/health';
import { MindSpaceDeletePlazaOption } from './MindSpaceDeletePlazaOption';
import { ShareSheet } from './ShareSheet';
import { WorkCover } from './WorkCover';
@@ -533,6 +535,7 @@ function MindSpaceNotificationCenter() {
export function MindSpaceView({
user,
previewMode = false,
healthEnabled = true,
initialPageId,
initialCategoryCode,
initialAchievementsOpen = false,
@@ -543,8 +546,9 @@ export function MindSpaceView({
}: {
user: PortalUser;
previewMode?: boolean;
healthEnabled?: boolean;
initialPageId?: string | null;
initialCategoryCode?: MindSpaceSaveCategory | null;
initialCategoryCode?: MindSpaceSaveCategory | 'health' | null;
initialAchievementsOpen?: boolean;
onBack: () => void;
onLogout: () => void;
@@ -1227,9 +1231,10 @@ export function MindSpaceView({
useEffect(() => {
if (!space || !initialCategoryCode || initialCategoryCode === 'draft' || initialAchievementsOpen) return;
if (initialCategoryCode === 'health' && !healthEnabled) return;
const category = space.categories.find((item) => item.code === initialCategoryCode);
if (category) void openCategory(category);
}, [space, initialCategoryCode, initialAchievementsOpen]);
}, [space, initialCategoryCode, initialAchievementsOpen, healthEnabled]);
useEffect(() => {
if (!initialAchievementsOpen || previewMode) return;
@@ -1284,6 +1289,9 @@ export function MindSpaceView({
}
return;
}
if (category.code === 'health' && healthEnabled) {
await bootstrapHealthWorkspace().catch(() => undefined);
}
setAssets(await listMindSpaceAssets(category.code));
if (['draft', 'oa', 'public', 'health'].includes(category.code)) {
const { items } = await listMindSpacePages({ categoryCode: category.code, limit: 100 });
@@ -1337,9 +1345,20 @@ export function MindSpaceView({
setError(null);
setUploadError(null);
try {
await uploadMindSpaceAsset(selectedCategory.id, selectedFile, {
const asset = await uploadMindSpaceAsset(selectedCategory.id, selectedFile, {
maxImageBytes: MINDSPACE_IMAGE_UPLOAD_MAX_BYTES,
});
if (
selectedCategory.code === 'health'
&& healthEnabled
&& selectedFile.type.startsWith('image/')
) {
const imageUrl = `/api/mindspace/v1/assets/${asset.id}/download?inline=1&v=${asset.updatedAt}`;
await archiveHealthDocument({
imageUrl,
notes: selectedFile.name,
}).catch(() => undefined);
}
setUploadOpen(false);
setSelectedFile(null);
await load();
@@ -2802,17 +2821,21 @@ export function MindSpaceView({
</p>
<h2>{selectedCategory.name}</h2>
</div>
{['oa', 'public'].includes(selectedCategory.code) && (
{['oa', 'public', 'health'].includes(selectedCategory.code) && (
<button
type="button"
className="mindspace-primary"
onClick={() => openUpload(selectedCategory)}
>
上传资料
{selectedCategory.code === 'health' ? '上传报告' : '上传资料'}
</button>
)}
</div>
{selectedCategory.code === 'health' ? (
<HealthMindSpacePanel onOpenPage={(pageId) => showPage(pageId)} />
) : null}
{selectedCategory.code === 'draft' ? (
pages.length > 0 ? (
<>
@@ -3188,7 +3211,8 @@ export function MindSpaceView({
<div className="mindspace-category-layout">
<div className="mindspace-grid-zones">
{space.categories
.filter((category) => ['oa', 'public', 'health', 'archive'].includes(category.code))
.filter((category) => ['oa', 'public', 'archive'].includes(category.code)
|| (category.code === 'health' && healthEnabled))
.map((category) => (
<article
className={`mindspace-card mindspace-card-compact mindspace-card-${category.code}`}