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:
@@ -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}`}
|
||||
|
||||
Reference in New Issue
Block a user