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:
+5
-7
@@ -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>
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user