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
+5 -7
View File
@@ -10,7 +10,6 @@ import { ChatProvider } from './context/ChatProvider';
import { PREVIEW_USER } from './dev/mindspacePreviewData';
import { MindSpaceRoute } from './routes/MindSpaceRoute';
import { FeedbackRoutes } from './routes/FeedbackRoute';
import { HealthPage } from './pages/HealthPage';
import { useProductAnalytics } from './analytics/productAnalytics';
import type { CapabilityMap, PortalUser } from './types';
@@ -24,9 +23,11 @@ function isMindSpacePreview() {
function MindSpaceAuthGate({
user,
onLogout,
healthEnabled,
}: {
user: PortalUser | null;
onLogout: () => void;
healthEnabled?: boolean;
}) {
const location = useLocation();
if (!user) {
@@ -36,7 +37,7 @@ function MindSpaceAuthGate({
if (utmSource) params.set('utm_source', utmSource);
return <Navigate to={`/?${params.toString()}`} replace />;
}
return <MindSpaceRoute user={user} onLogout={onLogout} />;
return <MindSpaceRoute user={user} onLogout={onLogout} healthEnabled={healthEnabled} />;
}
function AuthenticatedApp({
@@ -113,16 +114,13 @@ function AuthenticatedApp({
<Routes>
<Route
path="/space/*"
element={<MindSpaceAuthGate user={user} onLogout={handleLogout} />}
element={<MindSpaceAuthGate user={user} onLogout={handleLogout} healthEnabled={healthEnabled} />}
/>
<Route
path="/feedback/*"
element={<FeedbackRoutes user={user} onLogout={handleLogout} />}
/>
<Route
path="/health"
element={healthEnabled ? <HealthPage /> : <Navigate to="/" replace />}
/>
<Route path="/health" element={<Navigate to="/" replace />} />
<Route path="/" element={chatElement} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
+100
View File
@@ -25,3 +25,103 @@ export async function saveConfirmedBloodPressure(input: {
}),
});
}
export async function extractHealthImage(input: {
imageUrl: string;
metricSet?: string | null;
}) {
return apiFetch('/health/extract-image', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
imageUrl: input.imageUrl,
metricSet: input.metricSet ?? null,
}),
});
}
export async function archiveHealthDocument(input: {
imageUrl: string;
notes?: string | null;
}) {
return apiFetch('/health/documents', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
confirmed: true,
imageUrl: input.imageUrl,
source: 'manual',
notes: input.notes ?? null,
}),
});
}
export async function saveHealthObservation(body: Record<string, unknown>) {
return apiFetch('/health/observations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ confirmed: true, source: 'manual', ...body }),
});
}
export async function fetchHealthAssessSummary() {
const data = await apiFetch('/health/assess-summary');
return typeof data?.summary === 'string' ? data.summary : '';
}
export async function fetchHealthTimeline(days = 30) {
const data = await apiFetch(`/health/timeline?days=${days}`);
return Array.isArray(data?.timeline) ? data.timeline : [];
}
export async function fetchHealthDocuments(limit = 50) {
const data = await apiFetch(`/health/documents?limit=${limit}`);
return Array.isArray(data?.documents) ? data.documents : [];
}
export async function fetchHealthAgentContext() {
const data = await apiFetch('/health/agent-context');
return typeof data?.context === 'string' ? data.context : '';
}
export async function createHealthReportPage() {
const data = await apiFetch('/health/report-page', { method: 'POST' });
return {
relativePath: typeof data?.relativePath === 'string' ? data.relativePath : '',
url: typeof data?.url === 'string' ? data.url : '',
summary: typeof data?.summary === 'string' ? data.summary : '',
size: typeof data?.size === 'number' ? data.size : 0,
};
}
export async function bootstrapHealthWorkspace() {
return apiFetch('/health/workspace/bootstrap', { method: 'POST' });
}
export async function commitHealthDraft(draftKey: string, source = 'photo') {
return apiFetch(`/health/drafts/${encodeURIComponent(draftKey)}/commit`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source }),
});
}
export type HealthUnreadAlert = {
id: number;
severity: 'info' | 'watch' | 'alert' | string;
message?: string;
agentSummary?: string;
eventType?: string;
ruleId?: string | null;
metricType?: string | null;
detectedAt?: number;
};
export async function fetchHealthUnreadAlerts(limit = 20): Promise<HealthUnreadAlert[]> {
const data = await apiFetch(`/health/alerts/unread?limit=${limit}`);
return Array.isArray(data?.alerts) ? data.alerts : [];
}
export async function acknowledgeAllHealthAlerts() {
return apiFetch('/health/alerts/acknowledge-all', { method: 'POST' });
}
+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}`}
+612
View File
@@ -0,0 +1,612 @@
import { useCallback, useRef, useState } from 'react';
import {
applyHealthChannelTurn,
HEALTH_MENU_TEXT,
} from '../../health-wechat-turn.mjs';
import {
HEALTH_REDIRECT_COPY,
isHealthEnterText,
shouldRedirectOrdinaryChatToHealth,
looksLikeHealthReportPageRequest,
looksLikeHealthQuickAssess,
} from '../../health-intent-rules.mjs';
import { HEALTH_ACTIONS } from '../../health-channel-state.mjs';
import { isHealthAlertsAckText } from '../../health-event-notification.mjs';
import {
saveHealthObservation,
extractHealthImage,
archiveHealthDocument,
fetchHealthAssessSummary,
createHealthReportPage,
bootstrapHealthWorkspace,
fetchHealthUnreadAlerts,
acknowledgeAllHealthAlerts,
fetchHealthTimeline,
type HealthUnreadAlert,
} from '../api/health';
import type { Message } from '../types';
type HealthSessionWrapper = {
channelState: Record<string, unknown>;
pendingValues: Record<string, unknown> | null;
};
type HealthCommit = {
confirmed?: boolean;
values?: {
metricSet?: string;
systolic?: number;
diastolic?: number;
context?: 'morning' | 'evening' | 'other';
observedAt?: number;
valueNum?: number;
symptomCode?: string;
severity?: number;
};
};
function extractionToTurnText(extraction: {
metricSet: string;
values: Record<string, number>;
}) {
if (extraction.metricSet === 'blood_pressure') {
const { systolic, diastolic } = extraction.values;
return `${systolic}/${diastolic}`;
}
if (extraction.metricSet === 'heart_rate' && extraction.values.hr != null) {
return `心率 ${extraction.values.hr}`;
}
if (extraction.metricSet === 'weight' && extraction.values.weight != null) {
return `体重 ${extraction.values.weight}`;
}
if (extraction.metricSet === 'spo2' && extraction.values.spo2 != null) {
return `血氧 ${extraction.values.spo2}`;
}
if (extraction.metricSet === 'temperature' && extraction.values.temperature != null) {
return `体温 ${extraction.values.temperature}`;
}
return '';
}
function channelMetricHint(wrapper: HealthSessionWrapper | null) {
const metricSet = wrapper?.channelState?.metricSet;
return typeof metricSet === 'string' ? metricSet : null;
}
function isDocumentUploadMode(wrapper: HealthSessionWrapper | null) {
return wrapper?.channelState?.action === HEALTH_ACTIONS.DOCUMENT;
}
function channelStateStep(wrapper: HealthSessionWrapper | null) {
const step = wrapper?.channelState?.step;
return typeof step === 'string' ? step : 'idle';
}
function buildHealthMessage(
role: 'user' | 'assistant',
text: string,
messageId?: string,
): Message {
const trimmed = text.trim();
return {
id: messageId ?? `health-${role}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
role,
created: Math.floor(Date.now() / 1000),
content: [{ type: 'text', text: trimmed }],
metadata: {
userVisible: true,
agentVisible: true,
displayText: trimmed,
},
};
}
function formatTimelineChatSummary(
timeline: Array<{
date: string;
metrics: Record<string, { label: string; value: string; context?: string | null }>;
}>,
) {
if (!timeline.length) {
return [
'近 14 天还没有确认过的健康记录。',
'完整档案请打开:/space?category=health',
'回复「1」继续录入。',
].join('\n');
}
const previewDays = timeline.slice(0, 7);
const lines = previewDays.flatMap((day) => {
const metrics = Object.values(day.metrics)
.map((metric) => {
const context = metric.context ? ` · ${metric.context}` : '';
return `${metric.label} ${metric.value}${context}`;
})
.join(';');
return metrics ? [`${day.date}:${metrics}`] : [];
});
return [
`近 14 天共 ${timeline.length} 天有确认记录:`,
...lines,
timeline.length > previewDays.length
? `… 其余 ${timeline.length - previewDays.length} 天见下方 Timeline 卡片`
: '',
'完整档案请打开:/space?category=health',
'回复「1」继续录入。',
]
.filter(Boolean)
.join('\n');
}
export function useHealthChannel({
enabled,
newSession,
}: {
enabled?: boolean;
newSession: () => Promise<void>;
}) {
const [active, setActive] = useState(false);
const [showTimeline, setShowTimeline] = useState(false);
const [unreadAlerts, setUnreadAlerts] = useState<HealthUnreadAlert[]>([]);
const [alertsGateOpen, setAlertsGateOpen] = useState(true);
const [acknowledgingAlerts, setAcknowledgingAlerts] = useState(false);
const [overlayMessages, setOverlayMessages] = useState<Message[]>([]);
const sessionRef = useRef<HealthSessionWrapper | null>(null);
const sourceRefRef = useRef<string | null>(null);
const draftKeyRef = useRef<string | null>(null);
const appendMessages = useCallback((items: Message[]) => {
setOverlayMessages((current) => [...current, ...items]);
}, []);
const resetChannel = useCallback(() => {
sessionRef.current = null;
setActive(false);
setShowTimeline(false);
setUnreadAlerts([]);
setAlertsGateOpen(true);
setAcknowledgingAlerts(false);
setOverlayMessages([]);
}, []);
const deactivateChannel = useCallback(() => {
sessionRef.current = null;
setActive(false);
setShowTimeline(false);
setUnreadAlerts([]);
setAlertsGateOpen(true);
}, []);
const openAlertsGateWithMenu = useCallback(() => {
setAlertsGateOpen(true);
appendMessages([buildHealthMessage('assistant', HEALTH_MENU_TEXT)]);
}, [appendMessages]);
const acknowledgeAllAlerts = useCallback(async () => {
setAcknowledgingAlerts(true);
try {
await acknowledgeAllHealthAlerts();
setUnreadAlerts([]);
openAlertsGateWithMenu();
} catch (error) {
appendMessages([
buildHealthMessage(
'assistant',
error instanceof Error ? error.message : '确认提醒失败,请稍后重试。',
),
]);
} finally {
setAcknowledgingAlerts(false);
}
}, [appendMessages, openAlertsGateWithMenu]);
const persistCommit = useCallback(async (commit: HealthCommit) => {
const values = commit.values;
if (!values?.metricSet && values?.systolic == null) return;
await saveHealthObservation({
...values,
confirmed: true,
source: sourceRefRef.current ? 'photo' : 'manual',
sourceRef: values.sourceRef ?? sourceRefRef.current ?? null,
} as Record<string, unknown>);
sourceRefRef.current = null;
draftKeyRef.current = null;
}, []);
const finalizeTurn = useCallback(
async (result: ReturnType<typeof applyHealthChannelTurn>) => {
if (result.session) {
sessionRef.current = result.session as HealthSessionWrapper;
setActive(true);
} else {
deactivateChannel();
}
if (result.commit) {
try {
await persistCommit(result.commit as HealthCommit);
} catch (error) {
appendMessages([
buildHealthMessage(
'assistant',
error instanceof Error ? error.message : '保存失败,请稍后重试。',
),
]);
}
}
},
[appendMessages, deactivateChannel, persistCommit],
);
const enterChannel = useCallback(
async ({
userText,
messageId,
seedText,
showRedirect = false,
}: {
userText?: string;
messageId?: string;
seedText?: string;
showRedirect?: boolean;
} = {}) => {
if (!enabled) return;
await newSession();
sessionRef.current = null;
setOverlayMessages([]);
setActive(true);
setAlertsGateOpen(true);
setUnreadAlerts([]);
void bootstrapHealthWorkspace().catch(() => undefined);
const alerts = await fetchHealthUnreadAlerts().catch(() => []);
if (alerts.length > 0) {
setUnreadAlerts(alerts);
setAlertsGateOpen(false);
sessionRef.current = {
channelState: {
sessionKind: 'health',
channel: 'h5',
step: 'idle',
action: null,
metricSet: null,
context: null,
pendingDraftId: null,
lastPrompt: null,
},
pendingValues: null,
};
const initialMessages: Message[] = [];
if (userText?.trim()) {
initialMessages.push(buildHealthMessage('user', userText, messageId));
}
if (showRedirect) {
initialMessages.push(buildHealthMessage('assistant', HEALTH_REDIRECT_COPY));
}
initialMessages.push(
buildHealthMessage(
'assistant',
'你有未读健康提醒。请先阅读上方提醒并点击「我知道了」,确认后才会显示功能菜单。',
),
);
appendMessages(initialMessages);
return;
}
const enterResult = applyHealthChannelTurn({
channel: 'h5',
session: null,
text: '',
forceEnter: true,
});
let welcome = enterResult.reply ?? HEALTH_MENU_TEXT;
if (showRedirect) {
welcome = `${HEALTH_REDIRECT_COPY}\n\n${welcome}`;
}
sessionRef.current = (enterResult.session as HealthSessionWrapper | null) ?? null;
const initialMessages: Message[] = [];
if (userText?.trim()) {
initialMessages.push(buildHealthMessage('user', userText, messageId));
}
initialMessages.push(buildHealthMessage('assistant', welcome));
appendMessages(initialMessages);
const seed = seedText?.trim();
if (!seed) return;
const turnResult = applyHealthChannelTurn({
channel: 'h5',
session: sessionRef.current,
text: seed,
});
if (!turnResult.handled) return;
if (!showRedirect) {
appendMessages([buildHealthMessage('user', seed)]);
}
appendMessages([buildHealthMessage('assistant', turnResult.reply ?? '')]);
await finalizeTurn(turnResult);
},
[appendMessages, enabled, finalizeTurn, newSession],
);
const handleChannelSubmit = useCallback(
async (
text: string,
options?: {
messageId?: string;
msgType?: string;
hasImages?: boolean;
imageUrls?: string[];
},
) => {
if (!enabled) return false;
const trimmed = text.trim();
const imageUrls = options?.imageUrls ?? [];
const hasImages = Boolean(options?.hasImages && imageUrls.length > 0);
const msgType = hasImages && !trimmed ? 'image' : options?.msgType ?? 'text';
const displayText = trimmed || (hasImages ? '(图片)' : '');
if (!active) {
if (isHealthEnterText(trimmed)) {
await enterChannel({ userText: trimmed, messageId: options?.messageId });
return true;
}
if (shouldRedirectOrdinaryChatToHealth(trimmed)) {
await enterChannel({
userText: trimmed,
messageId: options?.messageId,
seedText: trimmed,
showRedirect: true,
});
return true;
}
return false;
}
if (!alertsGateOpen) {
if (displayText) {
appendMessages([buildHealthMessage('user', displayText, options?.messageId)]);
}
if (isHealthAlertsAckText(trimmed)) {
await acknowledgeAllAlerts();
return true;
}
appendMessages([
buildHealthMessage(
'assistant',
'请先阅读上方提醒并点击「我知道了」,或回复「知道了」确认已读。',
),
]);
return true;
}
if (hasImages && imageUrls[0]) {
appendMessages([
buildHealthMessage('user', displayText, options?.messageId),
]);
if (isDocumentUploadMode(sessionRef.current)) {
appendMessages([buildHealthMessage('assistant', '正在归档报告…')]);
try {
const archiveResult = await archiveHealthDocument({ imageUrl: imageUrls[0] });
const ocr = archiveResult?.ocr as { title?: string; extractedMetrics?: Array<{ name: string; value: string }> } | null;
sessionRef.current = {
channelState: {
sessionKind: 'health',
channel: 'h5',
step: 'idle',
action: null,
metricSet: null,
context: null,
pendingDraftId: null,
lastPrompt: null,
},
pendingValues: null,
};
appendMessages([
buildHealthMessage(
'assistant',
ocr?.extractedMetrics?.length
? `报告已归档。OCR 提取到 ${ocr.extractedMetrics.length} 项指标(${ocr.title ?? '报告'}),关键数值仍需你确认后才会写入基线。回复「1」手输录入,或「0」退出。`
: '报告已归档到健康档案。若有关键数值,请回复「1」手输录入确认。回复「0」退出通道。',
),
]);
} catch (error) {
appendMessages([
buildHealthMessage(
'assistant',
error instanceof Error ? error.message : '报告归档失败,请稍后重试。',
),
]);
}
return true;
}
appendMessages([buildHealthMessage('assistant', '正在识别图片读数…')]);
try {
const response = await extractHealthImage({
imageUrl: imageUrls[0],
metricSet: channelMetricHint(sessionRef.current),
});
sourceRefRef.current = typeof response?.sourceRef === 'string' ? response.sourceRef : null;
draftKeyRef.current = typeof response?.draftKey === 'string' ? response.draftKey : null;
const extraction = response?.extraction;
const turnText = extraction?.ok ? extractionToTurnText(extraction) : '';
if (!turnText) {
appendMessages([
buildHealthMessage(
'assistant',
'未能从图片识别可靠读数。请先回复「1」选择录入类型,或手输数值(如 137/82)。',
),
]);
return true;
}
const turnResult = applyHealthChannelTurn({
channel: 'h5',
session: sessionRef.current,
text: turnText,
msgType: 'text',
});
if (!turnResult.handled) {
appendMessages([
buildHealthMessage('assistant', '识别完成,但当前步骤无法录入。请按菜单选择后再发送图片。'),
]);
return true;
}
appendMessages([buildHealthMessage('assistant', turnResult.reply ?? '')]);
await finalizeTurn(turnResult);
return true;
} catch (error) {
appendMessages([
buildHealthMessage(
'assistant',
error instanceof Error ? error.message : '图片识别失败,请手输数值(如 137/82)。',
),
]);
return true;
}
}
if (
trimmed
&& channelStateStep(sessionRef.current) === 'idle'
&& isHealthEnterText(trimmed)
) {
appendMessages([
buildHealthMessage('user', displayText, options?.messageId),
buildHealthMessage('assistant', HEALTH_MENU_TEXT),
]);
return true;
}
if (
trimmed
&& channelStateStep(sessionRef.current) === 'idle'
&& looksLikeHealthQuickAssess(trimmed)
&& !looksLikeHealthReportPageRequest(trimmed)
) {
appendMessages([buildHealthMessage('user', displayText, options?.messageId)]);
try {
const summary = await fetchHealthAssessSummary();
appendMessages([buildHealthMessage('assistant', summary)]);
} catch (error) {
appendMessages([
buildHealthMessage(
'assistant',
error instanceof Error ? error.message : '暂时无法生成健康评估,请稍后重试。',
),
]);
}
return true;
}
if (
trimmed
&& channelStateStep(sessionRef.current) === 'idle'
&& looksLikeHealthReportPageRequest(trimmed)
) {
appendMessages([buildHealthMessage('user', displayText, options?.messageId)]);
try {
const report = await createHealthReportPage();
const lines = [
report.summary || '已基于你的健康档案生成报告页。',
report.url ? `报告页(已落盘):${report.url}` : '',
'如需进一步解读,可继续提问;回复「1」录入,「0」退出。',
].filter(Boolean);
appendMessages([buildHealthMessage('assistant', lines.join('\n\n'))]);
} catch (error) {
appendMessages([
buildHealthMessage(
'assistant',
error instanceof Error
? `${error.message}\n\n可先回复「1」录入数据,或稍后再试。`
: '暂时无法生成报告页,请稍后重试。',
),
]);
}
return true;
}
if (trimmed === '4' && channelStateStep(sessionRef.current) === 'idle') {
appendMessages([buildHealthMessage('user', displayText, options?.messageId)]);
setShowTimeline(true);
try {
const timeline = await fetchHealthTimeline(14);
appendMessages([
buildHealthMessage('assistant', formatTimelineChatSummary(timeline)),
]);
} catch (error) {
appendMessages([
buildHealthMessage(
'assistant',
error instanceof Error
? `${error.message}\n\n完整档案请打开:/space?category=health\n回复「1」继续录入。`
: '已展开近 14 天健康 Timeline。完整档案请打开:/space?category=health\n回复「1」继续录入。',
),
]);
}
return true;
}
if (trimmed === '3' && channelStateStep(sessionRef.current) === 'idle') {
appendMessages([buildHealthMessage('user', displayText, options?.messageId)]);
try {
const summary = await fetchHealthAssessSummary();
appendMessages([buildHealthMessage('assistant', summary)]);
} catch (error) {
appendMessages([
buildHealthMessage(
'assistant',
error instanceof Error ? error.message : '暂时无法生成健康评估,请稍后重试。',
),
]);
}
return true;
}
const turnResult = applyHealthChannelTurn({
channel: 'h5',
session: sessionRef.current,
text: trimmed,
msgType,
});
if (!turnResult.handled) return false;
appendMessages([
buildHealthMessage('user', displayText, options?.messageId),
buildHealthMessage('assistant', turnResult.reply ?? ''),
]);
await finalizeTurn(turnResult);
return true;
},
[active, alertsGateOpen, acknowledgeAllAlerts, appendMessages, enabled, enterChannel, finalizeTurn],
);
const exitChannel = useCallback(() => {
appendMessages([
buildHealthMessage('assistant', '已退出健康通道。之后的消息会回到普通聊天。'),
]);
deactivateChannel();
}, [appendMessages, deactivateChannel]);
return {
active,
showTimeline,
setShowTimeline,
overlayMessages,
unreadAlerts,
alertsGateOpen,
acknowledgingAlerts,
acknowledgeAllAlerts,
enterChannel,
exitChannel,
handleChannelSubmit,
};
}
+182
View File
@@ -1081,6 +1081,19 @@ body,
justify-content: flex-end;
}
.health-channel-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.health-channel-banner-body {
display: flex;
flex-direction: column;
gap: 4px;
}
.health-page {
max-width: 720px;
margin: 0 auto;
@@ -1135,6 +1148,175 @@ body,
list-style: none;
}
.health-timeline-panel {
margin: 0 0 12px;
padding: 12px 14px;
border-radius: var(--radius-md);
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
color: var(--color-text-primary);
}
.health-timeline-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.health-timeline-date {
font-weight: 600;
font-size: 13px;
margin-bottom: 4px;
}
.health-mindspace-panel {
margin-bottom: 16px;
padding: 14px 16px;
border-radius: var(--radius-md);
background: rgba(16, 185, 129, 0.06);
border: 1px solid rgba(16, 185, 129, 0.18);
}
.health-mindspace-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 10px;
}
.health-mindspace-badge {
font-size: 12px;
color: #047857;
background: rgba(16, 185, 129, 0.12);
padding: 2px 8px;
border-radius: 999px;
}
.health-mindspace-stat {
margin: 0 0 8px;
font-size: 14px;
}
.health-mindspace-stats {
display: flex;
flex-wrap: wrap;
gap: 10px 16px;
margin: 0 0 12px;
font-size: 13px;
color: var(--color-text-secondary);
}
.health-mindspace-open-page {
margin: 0 0 12px;
}
.health-mindspace-assess,
.health-mindspace-documents {
margin-top: 12px;
}
.health-mindspace-document-list {
margin: 8px 0 0;
padding-left: 18px;
font-size: 13px;
color: var(--color-text-secondary);
}
.health-mindspace-preview {
margin: 0 0 10px;
padding: 10px 12px;
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.65);
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
overflow: auto;
}
.health-mindspace-tips {
margin: 0;
padding-left: 18px;
font-size: 13px;
color: var(--color-text-secondary);
}
.health-alerts-panel {
margin: 0 0 12px;
padding: 14px 16px;
border-radius: var(--radius-md);
border: 1px solid var(--color-border-warning);
background: var(--color-bg-warning);
color: var(--color-text-primary);
}
.health-alerts-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.health-alerts-badge {
font-size: 12px;
padding: 2px 8px;
border-radius: 999px;
background: rgba(245, 158, 11, 0.18);
}
.health-alerts-hint {
margin: 0 0 10px;
font-size: 13px;
color: var(--color-text-secondary);
line-height: 1.5;
}
.health-alerts-list {
margin: 0 0 12px;
padding-left: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 8px;
}
.health-alerts-list li {
display: flex;
gap: 8px;
align-items: flex-start;
font-size: 13px;
line-height: 1.45;
}
.health-alert-severity {
flex: 0 0 auto;
font-size: 11px;
font-weight: 600;
padding: 2px 6px;
border-radius: var(--radius-sm);
}
.health-alert-critical .health-alert-severity {
background: rgba(239, 68, 68, 0.15);
color: #b91c1c;
}
.health-alert-watch .health-alert-severity {
background: rgba(245, 158, 11, 0.18);
color: #b45309;
}
.health-alerts-ack-btn {
width: 100%;
}
.mindspace-card-health {
border-color: rgba(16, 185, 129, 0.25);
}
.text-error {
color: var(--color-text-error);
}
+11 -4
View File
@@ -2,19 +2,25 @@ import { matchPath, useLocation, useNavigate, useSearchParams } from 'react-rout
import { MindSpaceView } from '../components/MindSpaceView';
import type { MindSpaceSaveCategory, PortalUser } from '../types';
const CATEGORY_CODES = new Set<MindSpaceSaveCategory>(['draft', 'oa', 'private', 'public']);
const CATEGORY_CODES = new Set<MindSpaceSaveCategory | 'health'>(['draft', 'oa', 'private', 'public', 'health']);
function parseCategory(value: string | null): MindSpaceSaveCategory | null {
if (!value || !CATEGORY_CODES.has(value as MindSpaceSaveCategory)) return null;
function parseCategory(value: string | null, healthEnabled = true): MindSpaceSaveCategory | 'health' | null {
if (!value) return null;
if (value === 'health') {
return healthEnabled ? 'health' : null;
}
if (!CATEGORY_CODES.has(value as MindSpaceSaveCategory)) return null;
return value as MindSpaceSaveCategory;
}
export function MindSpaceRoute({
user,
onLogout,
healthEnabled = true,
}: {
user: PortalUser;
onLogout: () => void;
healthEnabled?: boolean;
}) {
const navigate = useNavigate();
const location = useLocation();
@@ -22,11 +28,12 @@ export function MindSpaceRoute({
const pageMatch = matchPath('/space/page/:pageId', location.pathname);
const achievementsMatch = matchPath('/space/achievements', location.pathname);
const pageId = pageMatch?.params.pageId ?? null;
const categoryCode = parseCategory(searchParams.get('category'));
const categoryCode = parseCategory(searchParams.get('category'), healthEnabled);
return (
<MindSpaceView
user={user}
healthEnabled={healthEnabled}
initialPageId={pageId}
initialCategoryCode={categoryCode}
initialAchievementsOpen={Boolean(achievementsMatch)}