AdminSettingsModal previously left onRefreshUsers as a dead prop, so newly created users never reached App allUsers/localStorage and Create Project member candidates stayed stale (UI-01). Wire syncAdminUserDirectories, surface refresh errors, and document the residual cross-device directory gap. Co-authored-by: Cursor <cursoragent@cursor.com>
4199 lines
158 KiB
TypeScript
4199 lines
158 KiB
TypeScript
import { useState, useMemo, useEffect, useRef, useCallback } from 'react';
|
||
import { AppShell } from './components/layout/AppShell';
|
||
import { MessageStream } from './components/chat/MessageStream';
|
||
import { InputArea } from './components/chat/InputArea';
|
||
import { SlimTabBar } from './components/doc/SlimTabBar';
|
||
import { DeckDrawer } from './components/doc/DeckDrawer';
|
||
import { BlockRenderer } from './components/doc/BlockRenderer';
|
||
import { BlockEditorModal } from './components/doc/BlockEditorModal';
|
||
import { DocumentRevisionToolbar } from './components/doc/DocumentRevisionToolbar';
|
||
import { CommentSidePanel } from './components/doc/CommentSidePanel';
|
||
import { DocGovActionBar } from './components/doc/DocGovActionBar';
|
||
import { FreeformDocumentActionBar } from './components/doc/FreeformDocumentActionBar';
|
||
import {
|
||
createFreeformDoc,
|
||
saveMessageToPrivate,
|
||
listFreeformDocs,
|
||
fetchFreeformDoc,
|
||
shareFreeformDoc,
|
||
unshareFreeformDoc,
|
||
deleteFreeformDoc,
|
||
} from './api/freeformDoc';
|
||
import { registerUploadAttachments } from './lib/uploadFreeformRegister';
|
||
import { fetchChatSessions, upsertChatSession } from './api/chatSessions';
|
||
import { listProjectWorkflowDocuments } from './api/projectDocuments';
|
||
import { patchDocumentContent, getRevisionState } from './api/documentRevision';
|
||
import type { DiffResult, OverlayMode } from './features/documentRevision/types';
|
||
import { applyHeadBlockMap, astToClientBlockMap } from './features/documentRevision/overlayMatch';
|
||
import {
|
||
documentRevisionInactiveHint,
|
||
revisionToolbarMountMode,
|
||
} from './features/documentRevision/revisionToolbarMount';
|
||
import { createProject, fetchProjectLineage, fetchProjects, getProject } from './api/projectWorkflow';
|
||
import {
|
||
addProjectMember,
|
||
listProjectMembers,
|
||
removeProjectMember,
|
||
updateProjectMember,
|
||
} from './api/projectMembers';
|
||
import {
|
||
mapApiMembersToProjectMembers,
|
||
toApiProjectRole,
|
||
} from './lib/projectMemberAdapter';
|
||
import { mergeWorkflowDocumentsFromServer } from './lib/deliverableSync';
|
||
import {
|
||
buildControlledLoadErrorSkeleton,
|
||
buildControlledLoadingSkeleton,
|
||
canonicalDocIdForTask,
|
||
hydrateControlledDocumentContent,
|
||
resolveCanonicalDocId,
|
||
shouldLoadControlledContent,
|
||
} from './lib/controlledDocHydrate';
|
||
import { DocumentLineage } from './types/projectWorkflow';
|
||
import { StaleDocumentNotice } from './components/workflow/StaleDocumentNotice';
|
||
import { StageAdvanceBanner } from './components/workflow/StageAdvanceBanner';
|
||
import { deriveReplyTitle } from './features/replyExport/title';
|
||
import { copyRichReplyToClipboard, copyMarkdownReply } from './features/replyExport/clipboard';
|
||
import { downloadMarkdownReply, downloadDocxReply } from './features/replyExport/download';
|
||
import { ProposalConfirmCard } from './components/doc/ProposalConfirmCard';
|
||
import { UserProjectRightsModal } from './components/user/UserProjectRightsModal';
|
||
import { UserSettingsModal } from './components/user/UserSettingsModal';
|
||
import { AdminSettingsModal } from './components/admin/AdminSettingsModal';
|
||
import { DocVisibilityModal } from './components/doc/DocVisibilityModal';
|
||
import { CreateProjectModal } from './components/project/CreateProjectModal';
|
||
import { ProjectMembersModal } from './components/project/ProjectMembersModal';
|
||
import { ProjectSettingsModal } from './components/project/ProjectSettingsModal';
|
||
import { fetchProjectSettings } from './api/projectSettings';
|
||
import { FilePreviewPanel } from './components/doc/preview/FilePreviewPanel';
|
||
import { ShieldAlert } from 'lucide-react';
|
||
import { LoginView } from './components/auth/LoginView';
|
||
import { FirstLoginPasswordModal } from './components/auth/FirstLoginPasswordModal';
|
||
import { authClient } from './api/auth';
|
||
import { streamChat, mapErrorInfo } from './api/chatStream';
|
||
import { streamTask, buildTaskRequest, buildResumeRequest } from './api/taskStream';
|
||
import type { StreamEvent } from './api/chatStream';
|
||
import { shouldAutoRetryChat, shouldAutoRetryTask, autoRetryDelayMs, fallbackErrorClass } from './api/chatRetry';
|
||
import { applyPhaseEvent } from './engine/phaseEventMapper';
|
||
import { LoginResult, SystemAuthConfig } from './types/auth';
|
||
import { markdownToBlockAST } from './engine/markdownToBlock';
|
||
import { blockASTToMarkdown } from './engine/blockToMarkdown';
|
||
import { recomputeNumbering } from './engine/autoNumber';
|
||
import { DocumentAST, DocumentBlock, BlockComment } from './types/block';
|
||
import {
|
||
ProjectSession,
|
||
ChatMessage,
|
||
ChatErrorInfo,
|
||
DeliverableItem,
|
||
DeliverableCategory,
|
||
InteractiveCardData,
|
||
PhaseEvent,
|
||
TaskRunContext,
|
||
TaskRunMaterial,
|
||
ProjectInfo,
|
||
ProjectMember,
|
||
ProjectMemberRole,
|
||
} from './types/workbench';
|
||
import { UserRole, UserProfile, DocPermissionRole } from './types/docgov';
|
||
import { RoleDefinition, DocVisibilityRule } from './types/rbac';
|
||
import {
|
||
DEFAULT_ROLES,
|
||
hasPermission,
|
||
filterVisibleDeliverablesWithRBAC,
|
||
} from './engine/rbac';
|
||
import { MOCK_DOCUMENTS } from './data/mockDocuments';
|
||
import {
|
||
NEUROPULSE_DELIVERABLES,
|
||
NEUROPULSE_DOCUMENTS,
|
||
NEUROPULSE_PERMISSIONS,
|
||
} from './data/neuropulseDemo';
|
||
import {
|
||
TEN_PROJECTS_DELIVERABLES,
|
||
TEN_PROJECTS_DOCUMENTS,
|
||
} from './data/tenProjectsDemo';
|
||
import { exportDocumentToDocx } from './engine/docxRenderer';
|
||
import { exportDocumentToXlsx } from './engine/xlsxRenderer';
|
||
import {
|
||
findMatchingBlockForReview,
|
||
createRejectConfirmationCard,
|
||
isAffirmativeResponse,
|
||
findLastActiveCard,
|
||
decrementActiveCardsTtl,
|
||
applyConfirmRejectToDoc,
|
||
applyUndoRejectToDoc,
|
||
resolveCardInMessages,
|
||
cancelCardInMessages,
|
||
} from './engine/cardActionEngine';
|
||
import {
|
||
createProjectTemplateDeliverables,
|
||
getTemplateDefinitionByTaskKey,
|
||
buildTemplateASTForProject,
|
||
} from './engine/templateFactory';
|
||
import { resolveTaskTemplateForMessage } from './engine/templateMatch';
|
||
import {
|
||
loadSessions,
|
||
saveSessions,
|
||
loadActiveSessionId,
|
||
saveActiveSessionId,
|
||
loadDeliverables,
|
||
saveDeliverables,
|
||
loadUsers,
|
||
saveUsers,
|
||
migrateAndClearLegacyMessages,
|
||
resolveActiveSessionForUser,
|
||
loadProjects,
|
||
saveProjects,
|
||
filterProjectSessionsForUser,
|
||
getProjectMembership,
|
||
buildPrivateSessionId,
|
||
} from './data/storage';
|
||
import { useChatWindow } from './hooks/useChatWindow';
|
||
import { useTurnRuntime } from './hooks/useTurnRuntime';
|
||
import { useTaskRunStream } from './hooks/useTaskRunStream';
|
||
import { fetchChatTurnStatus } from './api/chatTurn';
|
||
import { createClientTurnId } from './lib/createClientTurnId';
|
||
import { mergeSessionUser } from './lib/mergeSessionUser';
|
||
import { mapServerProjectToProjectInfo, mergeProjectLists } from './lib/projectAdapter';
|
||
import {
|
||
mapServerSessionToProjectSession,
|
||
mergeSessionLists,
|
||
resolveSessionProjectId,
|
||
} from './lib/sessionAdapter';
|
||
import { SendMessageResult } from './types/sendMessage';
|
||
import { CategoryFilterBar } from './components/history/CategoryFilterBar';
|
||
import { HistoryCalendar } from './components/history/HistoryCalendar';
|
||
import { ChatSearchModal } from './components/history/ChatSearchModal';
|
||
import { DigestTimelineModal } from './components/history/DigestTimelineModal';
|
||
import { navigateToAnchor, parseAnchorHash, resolveTurnAnchorId } from './engine/anchorNavigator';
|
||
import { ChatExportModal } from './components/history/ChatExportModal';
|
||
import { useUnreadMarks } from './hooks/useUnreadMarks';
|
||
import { CategoryDock } from './components/chat/CategoryDock';
|
||
import { fetchChatCategories, fetchChatDates, mapTaskStatusToMessageStatus } from './api/chatWindow';
|
||
import { ChatCategoryStat, ChatDateStat, QuotedMessage, AssistantMessageStatus } from './types/workbench';
|
||
import { saveFile, restoreFileRawToDeliverables } from './data/fileStore';
|
||
import { uploadProjectAsset } from './api/projectAssets';
|
||
import { subscribeTaskRunEvents, createTaskRun, cancelTaskRun, getTaskRun } from './api/taskRuns';
|
||
|
||
/**
|
||
* 将附件转为任务材料:二进制先上传 CAS 登记 asset_id;小文本可 inline Base64。
|
||
*/
|
||
async function readAttachmentsAsMaterials(
|
||
attachments: { name: string; type: string; docId?: string; fileRaw?: File | Blob; assetId?: number }[],
|
||
projectId?: number,
|
||
): Promise<TaskRunMaterial[]> {
|
||
const TEXT_EXTS = new Set(['txt', 'md', 'csv', 'json', 'xml', 'yaml', 'yml', 'log']);
|
||
const results: TaskRunMaterial[] = [];
|
||
for (const att of attachments) {
|
||
if (att.assetId && att.assetId > 0) {
|
||
results.push({ filename: att.name, asset_id: att.assetId });
|
||
continue;
|
||
}
|
||
if (!att.fileRaw) continue;
|
||
const extMatch = att.name.match(/\.([^.]+)$/);
|
||
const ext = extMatch ? extMatch[1].toLowerCase() : '';
|
||
if (!TEXT_EXTS.has(ext)) {
|
||
if (!projectId || projectId <= 0) {
|
||
throw new Error(`附件 ${att.name} 需要先登记为项目资产(缺少 project_id)`);
|
||
}
|
||
const uploaded = await uploadProjectAsset(projectId, att.fileRaw as Blob, att.name);
|
||
results.push({ filename: uploaded.filename || att.name, asset_id: uploaded.asset_id });
|
||
continue;
|
||
}
|
||
try {
|
||
const content = await new Promise<string>((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onload = () => {
|
||
const text = reader.result as string;
|
||
const b64 = btoa(unescape(encodeURIComponent(text)));
|
||
resolve(b64);
|
||
};
|
||
reader.onerror = () => reject(reader.error);
|
||
reader.readAsText(att.fileRaw as Blob, 'utf-8');
|
||
});
|
||
results.push({ filename: att.name, content });
|
||
} catch {
|
||
throw new Error(`读取文本附件 ${att.name} 失败`);
|
||
}
|
||
}
|
||
return results;
|
||
}
|
||
|
||
export const INITIAL_DELIVERABLES: DeliverableItem[] = [
|
||
{
|
||
docId: 'doc-001',
|
||
projectId: 1,
|
||
taskKey: 'feasibility_reg',
|
||
title: '4K荧光内窥镜立项与可行性报告',
|
||
stage: 'V0.5',
|
||
category: 'workflow',
|
||
creatorId: 2,
|
||
creatorName: '张工',
|
||
isFixedWorkflow: true,
|
||
isTemplateOnly: false,
|
||
lifecycleState: 'drafting',
|
||
isClaimed: true,
|
||
author: '张工',
|
||
reviewer: '李主管',
|
||
approver: '王总监',
|
||
updatedAt: '2026-09-08 14:15',
|
||
summary: '针对4K荧光腹腔镜分类界定、临床评价路径与法规标准准入全景评估。',
|
||
},
|
||
{
|
||
docId: 'doc-002',
|
||
projectId: 1,
|
||
taskKey: 'research',
|
||
title: '微创外科全球竞品深度调研',
|
||
stage: 'V0.5',
|
||
category: 'workflow',
|
||
creatorId: 2,
|
||
creatorName: '张工',
|
||
isFixedWorkflow: true,
|
||
isTemplateOnly: false,
|
||
lifecycleState: 'reviewing',
|
||
isClaimed: true,
|
||
author: '张工',
|
||
reviewer: '李主管',
|
||
approver: '王总监',
|
||
updatedAt: '2026-09-08 11:30',
|
||
summary: '针对史赛克1688、卡尔史托斯Rubina荧光内窥镜性能全方位对比分析。',
|
||
},
|
||
{
|
||
docId: 'doc-003',
|
||
projectId: 1,
|
||
taskKey: 'prelim_design',
|
||
title: '光学分光与电路初步设计规格书',
|
||
stage: 'V0.5',
|
||
category: 'workflow',
|
||
creatorId: 2,
|
||
creatorName: '张工',
|
||
isFixedWorkflow: true,
|
||
isTemplateOnly: false,
|
||
lifecycleState: 'approving',
|
||
isClaimed: true,
|
||
author: '张工',
|
||
reviewer: '李主管',
|
||
approver: '王总监',
|
||
updatedAt: '2026-09-07 17:00',
|
||
summary: '4K荧光双路分光棱镜光路设计与近红外消色差物镜参数设计。',
|
||
},
|
||
{
|
||
docId: 'doc-004',
|
||
projectId: 1,
|
||
taskKey: 'cdo_quote',
|
||
title: 'CDO商业报价单与工时清单',
|
||
stage: 'V1.0',
|
||
category: 'workflow',
|
||
creatorId: 4,
|
||
creatorName: '王商务',
|
||
isFixedWorkflow: true,
|
||
isTemplateOnly: false,
|
||
lifecycleState: 'drafting',
|
||
isClaimed: true,
|
||
author: '王商务',
|
||
reviewer: '张工',
|
||
approver: '李总',
|
||
updatedAt: '2026-09-08 15:30',
|
||
summary: '基于WBS千工时分解与CDO成本模型的真实商业报价核算。',
|
||
},
|
||
{
|
||
docId: 'doc-shared-001',
|
||
projectId: 1,
|
||
taskKey: 'supply_chain_analysis',
|
||
title: '4K双路CMOS供应链与成本灵敏度分析',
|
||
stage: 'V0.5',
|
||
category: 'shared',
|
||
creatorId: 2,
|
||
creatorName: '张工',
|
||
sharedAt: '2026-09-08 15:20',
|
||
isFixedWorkflow: false,
|
||
isTemplateOnly: false,
|
||
lifecycleState: 'drafting',
|
||
isClaimed: true,
|
||
author: '张工',
|
||
reviewer: '李主管',
|
||
approver: '王总监',
|
||
updatedAt: '2026-09-08 15:20',
|
||
summary: '张工在与助手对话中推演的 Sony IMX485 与国产长光辰芯双路 CMOS 传感器成本灵敏度测算。',
|
||
},
|
||
{
|
||
docId: 'doc-private-001',
|
||
projectId: 1,
|
||
taskKey: 'negotiation_memo',
|
||
title: '微创医疗战略商务谈判底线与预付款让利推演备忘',
|
||
stage: 'V1.0',
|
||
category: 'private',
|
||
creatorId: 2,
|
||
creatorName: '张工',
|
||
isFixedWorkflow: false,
|
||
isTemplateOnly: false,
|
||
lifecycleState: 'drafting',
|
||
isClaimed: true,
|
||
author: '张工',
|
||
reviewer: '李主管',
|
||
approver: '王总监',
|
||
updatedAt: '2026-09-08 16:10',
|
||
summary: '张工个人与助手对话进行的商务谈判底线、毛利率红线测算草案,含个人思考,仅本人可见。',
|
||
},
|
||
{
|
||
docId: 'doc-private-002',
|
||
projectId: 1,
|
||
taskKey: 'lead_capacity_memo',
|
||
title: '团队绩效核算与阶段资源饱和度评估',
|
||
stage: 'V0.5',
|
||
category: 'private',
|
||
creatorId: 2,
|
||
creatorName: '李主管',
|
||
isFixedWorkflow: false,
|
||
isTemplateOnly: false,
|
||
lifecycleState: 'drafting',
|
||
isClaimed: true,
|
||
author: '李主管',
|
||
reviewer: '李主管',
|
||
approver: '王总监',
|
||
updatedAt: '2026-09-08 15:50',
|
||
summary: '李主管个人对话生成的团队工时饱和度与绩效预算底稿,仅李主管可见。',
|
||
},
|
||
];
|
||
|
||
// 为默认第二项目 (便携式超声探头,projectId: 2) 预置全套 7 份生命周期模板副本
|
||
const P2_INITIAL = createProjectTemplateDeliverables({
|
||
projectId: 2,
|
||
projectName: '便携式掌上无线超声诊断仪',
|
||
deviceType: '便携式超声探头',
|
||
creatorId: 2,
|
||
creatorName: '张工',
|
||
});
|
||
|
||
// 为第三测试项目 (数字化高频超声手术吸引系统 CUSA-Ultra,projectId: 3) 预置交付物
|
||
export const CUSA_INITIAL_DELIVERABLES: DeliverableItem[] = [
|
||
{
|
||
docId: 'doc-cusa-intake',
|
||
projectId: 3,
|
||
taskKey: 'intake',
|
||
title: '数字化高频超声手术吸引系统立项需求与产品定义',
|
||
stage: 'V0.1',
|
||
category: 'workflow',
|
||
creatorId: 2,
|
||
creatorName: '张工',
|
||
isFixedWorkflow: true,
|
||
isTemplateOnly: false,
|
||
lifecycleState: 'approved',
|
||
isClaimed: true,
|
||
author: '张工',
|
||
reviewer: '李主管',
|
||
approver: '王总监',
|
||
updatedAt: '2026-09-08 17:00',
|
||
summary: '高频超声手术吸引系统 (CUSA-Ultra) 初始需求摸底、预期临床用途与整机工程规格基线。',
|
||
},
|
||
{
|
||
docId: 'doc-cusa-feasibility-reg',
|
||
projectId: 3,
|
||
taskKey: 'feasibility_reg',
|
||
title: '数字化高频超声手术吸引系统立项与可行性法规论证报告',
|
||
stage: 'V0.5',
|
||
category: 'workflow',
|
||
creatorId: 2,
|
||
creatorName: '张工',
|
||
isFixedWorkflow: true,
|
||
isTemplateOnly: false,
|
||
lifecycleState: 'approved',
|
||
isClaimed: true,
|
||
author: '张工',
|
||
reviewer: '李主管',
|
||
approver: '王总监',
|
||
updatedAt: '2026-09-08 17:30',
|
||
summary: '针对 CUSA 超声吸引刀头与主机的三类医疗器械分类准入、注册检验与临床评价路径全景评估。',
|
||
},
|
||
{
|
||
docId: 'doc-cusa-free-cavitation',
|
||
projectId: 3,
|
||
taskKey: 'free_cusa_cavitation_risk',
|
||
title: '24kHz高频空化钛合金刀头微裂纹与疲劳剥落风险专项分析报告',
|
||
stage: 'V0.5',
|
||
category: 'shared',
|
||
creatorId: 2,
|
||
creatorName: '张工',
|
||
isFixedWorkflow: false,
|
||
isTemplateOnly: false,
|
||
lifecycleState: 'approved',
|
||
isClaimed: true,
|
||
author: '张工',
|
||
reviewer: '李主管',
|
||
approver: '王总监',
|
||
updatedAt: '2026-09-08 17:15',
|
||
summary: '自由任务产出:基于超声高频交变应力与空化微射流冲击的钛合金刀头疲劳寿命与表面钝化防护策略。',
|
||
},
|
||
{
|
||
docId: 'doc-cusa-quote',
|
||
projectId: 3,
|
||
taskKey: 'cdo_quote',
|
||
title: 'CDO超声手术系统工程样机研发与型检预算投资核算单',
|
||
stage: 'V1.0',
|
||
category: 'workflow',
|
||
creatorId: 2,
|
||
creatorName: '张工',
|
||
isFixedWorkflow: true,
|
||
isTemplateOnly: false,
|
||
lifecycleState: 'drafting',
|
||
isClaimed: true,
|
||
author: '张工',
|
||
reviewer: '李主管',
|
||
approver: '王总监',
|
||
updatedAt: '2026-09-08 17:45',
|
||
summary: 'CDO 商业立项核算清单:高频超声驱动电源、压电换能器、钛合金刀头模具及三类型检规费。',
|
||
},
|
||
];
|
||
|
||
export const ALL_INITIAL_DELIVERABLES: DeliverableItem[] = [
|
||
...NEUROPULSE_DELIVERABLES,
|
||
...INITIAL_DELIVERABLES,
|
||
...P2_INITIAL.deliverables,
|
||
...CUSA_INITIAL_DELIVERABLES,
|
||
...TEN_PROJECTS_DELIVERABLES,
|
||
];
|
||
|
||
export const ALL_MOCK_DOCUMENTS = {
|
||
...MOCK_DOCUMENTS,
|
||
...NEUROPULSE_DOCUMENTS,
|
||
...TEN_PROJECTS_DOCUMENTS,
|
||
};
|
||
|
||
export const MOCK_USERS: UserProfile[] = [
|
||
{
|
||
id: 1,
|
||
name: '系统管理员',
|
||
employeeId: 'SYS-000',
|
||
department: '系统运维部',
|
||
position: '系统管理员',
|
||
roleId: 'role-admin',
|
||
projectPermissions: {
|
||
'doc-001': 'approver',
|
||
'doc-002': 'approver',
|
||
'doc-003': 'approver',
|
||
},
|
||
},
|
||
{
|
||
id: 2,
|
||
name: '张工',
|
||
employeeId: 'SMDF-0821',
|
||
department: '机器人与微创内窥镜研发中心',
|
||
position: '资深医疗器械方案工程师',
|
||
roleId: 'role-engineer',
|
||
projectPermissions: {
|
||
'doc-001': 'author',
|
||
'doc-002': 'reviewer',
|
||
'doc-003': 'approver',
|
||
'doc-004': 'viewer',
|
||
'doc-shared-001': 'author',
|
||
'doc-private-001': 'author',
|
||
'doc-cusa-intake': 'author',
|
||
'doc-cusa-feasibility-reg': 'author',
|
||
'doc-cusa-free-cavitation': 'author',
|
||
'doc-cusa-quote': 'author',
|
||
...(NEUROPULSE_PERMISSIONS['2'] || {}),
|
||
},
|
||
},
|
||
{
|
||
id: 3,
|
||
name: '李主管',
|
||
employeeId: 'SMDF-0205',
|
||
department: '机器人与微创内窥镜研发中心',
|
||
position: '研发项目技术主管',
|
||
roleId: 'role-tech-lead',
|
||
projectPermissions: {
|
||
'doc-001': 'reviewer',
|
||
'doc-002': 'author',
|
||
'doc-003': 'reviewer',
|
||
'doc-004': 'viewer',
|
||
'doc-shared-001': 'viewer',
|
||
'doc-private-002': 'author',
|
||
'doc-cusa-intake': 'reviewer',
|
||
'doc-cusa-feasibility-reg': 'reviewer',
|
||
'doc-cusa-free-cavitation': 'reviewer',
|
||
'doc-cusa-quote': 'reviewer',
|
||
...(NEUROPULSE_PERMISSIONS['3'] || {}),
|
||
},
|
||
},
|
||
{
|
||
id: 4,
|
||
name: '王总监',
|
||
employeeId: 'SMDF-0018',
|
||
department: '医疗器械研发事业部',
|
||
position: '微创器械研发总监',
|
||
roleId: 'role-director',
|
||
projectPermissions: {
|
||
'doc-001': 'approver',
|
||
'doc-002': 'approver',
|
||
'doc-003': 'author',
|
||
'doc-004': 'approver',
|
||
'doc-shared-001': 'approver',
|
||
'doc-cusa-intake': 'approver',
|
||
'doc-cusa-feasibility-reg': 'approver',
|
||
'doc-cusa-free-cavitation': 'approver',
|
||
'doc-cusa-quote': 'approver',
|
||
...(NEUROPULSE_PERMISSIONS['4'] || {}),
|
||
},
|
||
},
|
||
{
|
||
id: 5,
|
||
name: '访客',
|
||
employeeId: 'EXT-999',
|
||
department: '外协中心',
|
||
position: '访客/待指派',
|
||
roleId: 'role-guest',
|
||
projectPermissions: {},
|
||
},
|
||
];
|
||
|
||
export default function App() {
|
||
// 会话与用户状态 (支持 localStorage 刷新持久化)
|
||
const [sessions, setSessions] = useState<ProjectSession[]>(() => loadSessions());
|
||
const [activeSessionId, setActiveSessionId] = useState<string>(() => loadActiveSessionId('sess-001'));
|
||
|
||
// 自动持久化 sessions 与 activeSessionId
|
||
useEffect(() => {
|
||
saveSessions(sessions);
|
||
}, [sessions]);
|
||
|
||
useEffect(() => {
|
||
saveActiveSessionId(activeSessionId);
|
||
}, [activeSessionId]);
|
||
|
||
// 全局角色库与人员名单状态 (支持动态增删改与持久化)
|
||
const [roles, setRoles] = useState<RoleDefinition[]>(() => DEFAULT_ROLES);
|
||
const [allUsers, setAllUsers] = useState<UserProfile[]>(() => loadUsers(MOCK_USERS));
|
||
|
||
useEffect(() => {
|
||
saveUsers(allUsers);
|
||
}, [allUsers]);
|
||
|
||
// 当前用户身份 (职位在全公司唯一不变,绑定全局角色,但在不同文档有不同法定职责)
|
||
const [currentUser, setCurrentUser] = useState<UserProfile>(() => allUsers[0] || MOCK_USERS[0]);
|
||
|
||
// 全生命周期完整交付物大盘清单 (含工作流受控、项目协作共享、个人私密生成三大分类,支持持久化)
|
||
const [deliverables, setDeliverables] = useState<DeliverableItem[]>(() =>
|
||
loadDeliverables(ALL_INITIAL_DELIVERABLES)
|
||
);
|
||
|
||
// 自动持久化 deliverables(序列化时剔除不可持久化的 File 对象,见 storage.ts)
|
||
useEffect(() => {
|
||
saveDeliverables(deliverables);
|
||
}, [deliverables]);
|
||
|
||
// ✅ 启动时从 IndexedDB 恢复上传文件的 fileRaw(解决页面刷新后 File 对象丢失问题)
|
||
// 仅在组件 mount 时执行一次;恢复成功后对 deliverables 做最小化 patch。
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
restoreFileRawToDeliverables(deliverables).then((restored) => {
|
||
if (!cancelled && restored !== deliverables) {
|
||
// 只有真正有内容被恢复时才更新 state(避免无意义的 re-render)
|
||
setDeliverables(restored);
|
||
}
|
||
});
|
||
return () => { cancelled = true; };
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []); // 只在 mount 时运行一次
|
||
|
||
const [activeCategory, setActiveCategory] = useState<DeliverableCategory>('workflow');
|
||
|
||
// 项目与成员体系状态 (支持持久化)
|
||
const [projects, setProjects] = useState<ProjectInfo[]>(() => loadProjects());
|
||
useEffect(() => {
|
||
saveProjects(projects);
|
||
}, [projects]);
|
||
|
||
const [createProjectModalOpen, setCreateProjectModalOpen] = useState(false);
|
||
const [createProjectError, setCreateProjectError] = useState<string | null>(null);
|
||
const [projectSyncNotice, setProjectSyncNotice] = useState<string | null>(null);
|
||
const [sessionSyncNotice, setSessionSyncNotice] = useState<string | null>(null);
|
||
const [projectMembersModalOpen, setProjectMembersModalOpen] = useState(false);
|
||
const [projectMembersError, setProjectMembersError] = useState<string | null>(null);
|
||
const [projectSettingsOpen, setProjectSettingsOpen] = useState(false);
|
||
const [workflowGovernanceEnabled, setWorkflowGovernanceEnabled] = useState(false);
|
||
|
||
const isAdmin = currentUser.roleId === 'role-admin';
|
||
|
||
// 获取当前激活会话的项目ID (默认为 1)
|
||
const currentProjectId = useMemo(() => {
|
||
const activeSess = sessions.find((s) => s.sessionId === activeSessionId);
|
||
const resolved = resolveSessionProjectId(activeSess);
|
||
return resolved > 0 ? resolved : 1;
|
||
}, [sessions, activeSessionId]);
|
||
|
||
// 获取当前项目与当前用户在其中的成员职责
|
||
const currentProject = useMemo(() => {
|
||
return projects.find((p) => p.id === currentProjectId);
|
||
}, [projects, currentProjectId]);
|
||
|
||
const currentMembership = useMemo(() => {
|
||
return getProjectMembership(currentProject, currentUser.id);
|
||
}, [currentProject, currentUser.id]);
|
||
|
||
const applyMembersToProject = useCallback((projectId: number, members: ProjectMember[]) => {
|
||
setProjects((prev) => prev.map((p) => (p.id === projectId ? { ...p, members } : p)));
|
||
}, []);
|
||
|
||
const syncProjectMembersFromServer = useCallback(
|
||
async (projectId: number, creatorId: number) => {
|
||
try {
|
||
const records = await listProjectMembers(projectId);
|
||
const members = mapApiMembersToProjectMembers(records, creatorId);
|
||
if (members.length > 0) {
|
||
applyMembersToProject(projectId, members);
|
||
}
|
||
setProjectMembersError(null);
|
||
return members;
|
||
} catch (err) {
|
||
const msg = err instanceof Error ? err.message : '项目成员同步失败';
|
||
setProjectMembersError(msg);
|
||
return null;
|
||
}
|
||
},
|
||
[applyMembersToProject]
|
||
);
|
||
|
||
const isCurrentProjectExited = currentMembership?.status === 'exited';
|
||
|
||
// 当前用户专属的项目会话列表 (基于项目成员身份与退出留痕过滤)
|
||
const visibleSessions = useMemo(() => {
|
||
return filterProjectSessionsForUser(sessions, projects, currentUser.id, isAdmin);
|
||
}, [sessions, projects, currentUser.id, isAdmin]);
|
||
|
||
// 账号变动或项目退出时,确保当前激活的会话属于当前用户可见范围
|
||
useEffect(() => {
|
||
const cur = visibleSessions.find((s) => s.sessionId === activeSessionId);
|
||
if (!cur && visibleSessions.length > 0) {
|
||
const nextActiveId = resolveActiveSessionForUser(
|
||
visibleSessions,
|
||
currentUser.id,
|
||
currentProjectId,
|
||
activeSessionId
|
||
);
|
||
setActiveSessionId(nextActiveId || visibleSessions[0].sessionId);
|
||
}
|
||
}, [currentUser.id, visibleSessions, currentProjectId, activeSessionId]);
|
||
|
||
const [activeDocId, setActiveDocId] = useState<string>(() => {
|
||
const activeSess = sessions.find((s) => s.sessionId === activeSessionId);
|
||
const targetProjId = activeSess ? activeSess.projectId : currentProjectId;
|
||
const projDocs = deliverables.filter((d) => d.projectId === targetProjId);
|
||
if (projDocs.length > 0) {
|
||
return projDocs[0].docId;
|
||
}
|
||
if (targetProjId === 5201) return 'doc-neuropulse-feasibility_reg';
|
||
if (targetProjId === 3) return 'doc-cusa-feasibility-reg';
|
||
return deliverables[0]?.docId || 'doc-001';
|
||
});
|
||
const [docRevOverlayMode, setDocRevOverlayMode] = useState<OverlayMode>('manual');
|
||
const [docRevOverlay, setDocRevOverlay] = useState<DiffResult | null>(null);
|
||
const [docRevConflict, setDocRevConflict] = useState<string | null>(null);
|
||
/** 人工保存 / 受控正文 hydrate 后递增,驱动工具条重新拉取 revision-state 与 overlay */
|
||
const [docRevRefreshKey, setDocRevRefreshKey] = useState(0);
|
||
const [deckDrawerOpen, setDeckDrawerOpen] = useState(false);
|
||
|
||
// 认证与会话状态
|
||
const [session, setSession] = useState<LoginResult | null>(() => authClient.loadSavedSession());
|
||
const [sessionValidated, setSessionValidated] = useState<boolean | null>(() =>
|
||
authClient.loadSavedSession() ? null : false,
|
||
);
|
||
const [systemEnv, setSystemEnv] = useState<string>('');
|
||
const [systemAuthConfig, setSystemAuthConfig] = useState<SystemAuthConfig>({
|
||
allowRegistration: true,
|
||
requireApproval: false,
|
||
});
|
||
|
||
// 模态浮层控制
|
||
const [rightsModalOpen, setRightsModalOpen] = useState(false);
|
||
const [userSettingsOpen, setUserSettingsOpen] = useState(false);
|
||
const [adminSettingsOpen, setAdminSettingsOpen] = useState(false);
|
||
const [docVisibilityModalOpen, setDocVisibilityModalOpen] = useState(false);
|
||
const [targetDocForVisibility, setTargetDocForVisibility] = useState<DeliverableItem | null>(null);
|
||
|
||
const [capabilities, setCapabilities] = useState<string[]>(() => authClient.getCapabilities());
|
||
|
||
useEffect(() => {
|
||
authClient.getSystemAuthConfig().then(setSystemAuthConfig).catch(() => {});
|
||
const saved = authClient.loadSavedSession();
|
||
if (saved) {
|
||
setSession(saved);
|
||
if (saved.env) setSystemEnv(saved.env);
|
||
setCapabilities(saved.capabilities ?? []);
|
||
const match = allUsers.find((u) => u.id === saved.user.id);
|
||
setCurrentUser(mergeSessionUser(saved.user, match));
|
||
void authClient
|
||
.fetchMe()
|
||
.then((me) => {
|
||
setCapabilities(me.capabilities ?? []);
|
||
setCurrentUser(mergeSessionUser(me.user, match));
|
||
setSessionValidated(true);
|
||
})
|
||
.catch(() => {
|
||
authClient.clearLocalSession();
|
||
setSession(null);
|
||
setCapabilities([]);
|
||
setSessionValidated(false);
|
||
});
|
||
} else {
|
||
setSessionValidated(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!session || sessionValidated !== true) {
|
||
setProjectSyncNotice(null);
|
||
return;
|
||
}
|
||
|
||
let cancelled = false;
|
||
void (async () => {
|
||
try {
|
||
const serverProjects = await fetchProjects();
|
||
if (cancelled) return;
|
||
setProjects((prev) => {
|
||
const mapped = (serverProjects ?? []).map((project) =>
|
||
mapServerProjectToProjectInfo(
|
||
project,
|
||
allUsers,
|
||
prev.find((item) => item.id === project.id)
|
||
)
|
||
);
|
||
return mergeProjectLists(prev, mapped);
|
||
});
|
||
await Promise.all(
|
||
serverProjects.map((project) =>
|
||
syncProjectMembersFromServer(project.id, project.creator_user_id)
|
||
)
|
||
);
|
||
setProjectSyncNotice(null);
|
||
} catch (err) {
|
||
if (cancelled) return;
|
||
console.warn('[projects] 服务端项目列表同步失败,继续使用本地缓存:', err);
|
||
setProjectSyncNotice('项目列表暂用本地缓存:无法连接服务端,新建项目可能无法在其他设备同步。');
|
||
}
|
||
})();
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [session, sessionValidated, allUsers, syncProjectMembersFromServer]);
|
||
|
||
useEffect(() => {
|
||
if (!projectMembersModalOpen || !currentProject || !session || sessionValidated !== true) return;
|
||
void syncProjectMembersFromServer(currentProject.id, currentProject.creatorId);
|
||
}, [projectMembersModalOpen, currentProject, session, syncProjectMembersFromServer]);
|
||
|
||
useEffect(() => {
|
||
if (!session || sessionValidated !== true || !currentUser.id) {
|
||
setSessionSyncNotice(null);
|
||
return;
|
||
}
|
||
|
||
let cancelled = false;
|
||
void (async () => {
|
||
try {
|
||
const serverSessions = await fetchChatSessions(currentUser.id);
|
||
if (cancelled) return;
|
||
setSessions((prev) => {
|
||
const mapped = (serverSessions ?? []).map((item) =>
|
||
mapServerSessionToProjectSession(
|
||
item,
|
||
projects,
|
||
prev.find((sessionItem) => sessionItem.sessionId === item.session_id)
|
||
)
|
||
);
|
||
return mergeSessionLists(prev, mapped);
|
||
});
|
||
setSessionSyncNotice(null);
|
||
} catch (err) {
|
||
if (cancelled) return;
|
||
console.warn('[sessions] 服务端会话列表同步失败,继续使用本地缓存:', err);
|
||
setSessionSyncNotice(
|
||
'会话列表暂用本地缓存:无法连接服务端,新建会话可能无法在其他设备同步。'
|
||
);
|
||
}
|
||
})();
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [session, sessionValidated, currentUser.id, projects]);
|
||
|
||
const handleLogout = () => {
|
||
void authClient.logout().finally(() => {
|
||
setSession(null);
|
||
setCapabilities([]);
|
||
setSessionValidated(false);
|
||
});
|
||
};
|
||
|
||
// 核心功能权限计算
|
||
const canCreateProject = useMemo(() => {
|
||
return hasPermission(currentUser, roles, 'project:create', capabilities);
|
||
}, [currentUser, roles, capabilities]);
|
||
|
||
const canAssignLead = useMemo(() => {
|
||
return hasPermission(currentUser, roles, 'project:assign_lead', capabilities);
|
||
}, [currentUser, roles, capabilities]);
|
||
|
||
const canOverrideVisibility = useMemo(() => {
|
||
return (
|
||
hasPermission(currentUser, roles, 'doc:override_visibility', capabilities) ||
|
||
hasPermission(currentUser, roles, 'perm:manage', capabilities)
|
||
);
|
||
}, [currentUser, roles, capabilities]);
|
||
|
||
const canManageProjectMembers = useMemo(() => {
|
||
if (!currentProject) return false;
|
||
if (hasPermission(currentUser, roles, 'perm:manage', capabilities)) return true;
|
||
if (currentUser.id === currentProject.creatorId) return true;
|
||
const membership = currentProject.members.find((m) => m.userId === currentUser.id);
|
||
return membership?.role === 'creator' || membership?.role === 'owner';
|
||
}, [currentProject, currentUser, roles, capabilities]);
|
||
|
||
// 项目多工作流激活状态与文档血缘 (支持前端切换与状态联动)
|
||
const [projectWorkflowsState, setProjectWorkflowsState] = useState<
|
||
Record<number, { activeKey: string; version: number; creatorId: number; lifecycleStatus: string }>
|
||
>({});
|
||
const [projectLineages, setProjectLineages] = useState<DocumentLineage[]>([]);
|
||
|
||
const currentActiveSession =
|
||
sessions.find((s) => s.sessionId === activeSessionId) || sessions[0];
|
||
const currentWfState = projectWorkflowsState[currentProjectId] || {
|
||
activeKey: '',
|
||
version: 1,
|
||
creatorId: currentActiveSession?.userId || currentUser.id || 2,
|
||
lifecycleStatus: 'planning',
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (!session || sessionValidated !== true || !currentProjectId) return;
|
||
if (currentProjectId) {
|
||
getProject(currentProjectId)
|
||
.then((p) => {
|
||
if (p) {
|
||
setProjectWorkflowsState((prev) => ({
|
||
...prev,
|
||
[currentProjectId]: {
|
||
activeKey: p.active_workflow_key || '',
|
||
version: p.workflow_selection_version || 1,
|
||
creatorId: p.creator_user_id || 1,
|
||
lifecycleStatus: p.lifecycle_status || (p.active_workflow_key ? 'active' : 'planning'),
|
||
},
|
||
}));
|
||
}
|
||
})
|
||
.catch(() => {});
|
||
fetchProjectSettings(currentProjectId)
|
||
.then(() => setWorkflowGovernanceEnabled(true))
|
||
.catch(() => setWorkflowGovernanceEnabled(false));
|
||
}
|
||
}, [session, sessionValidated, currentProjectId]);
|
||
|
||
useEffect(() => {
|
||
if (!session || sessionValidated !== true || !currentProjectId) return;
|
||
if (currentProjectId) {
|
||
fetchProjectLineage(currentProjectId)
|
||
.then((lins) => setProjectLineages(lins))
|
||
.catch(() => {});
|
||
}
|
||
}, [session, sessionValidated, currentProjectId, currentWfState.activeKey]);
|
||
|
||
const workflowBanner = (
|
||
<>
|
||
{(projectSyncNotice || sessionSyncNotice) && (
|
||
<div
|
||
style={{
|
||
padding: '8px 12px',
|
||
margin: '0 12px 8px',
|
||
borderRadius: '8px',
|
||
background: 'rgba(245, 158, 11, 0.12)',
|
||
border: '1px solid rgba(245, 158, 11, 0.35)',
|
||
color: '#b45309',
|
||
fontSize: '12px',
|
||
}}
|
||
>
|
||
{[projectSyncNotice, sessionSyncNotice].filter(Boolean).join(' ')}
|
||
</div>
|
||
)}
|
||
{workflowGovernanceEnabled && currentWfState.lifecycleStatus === 'active' && (
|
||
<StageAdvanceBanner
|
||
projectId={currentProjectId}
|
||
isCreator={currentWfState.creatorId === currentUser.id}
|
||
/>
|
||
)}
|
||
<StaleDocumentNotice
|
||
lineages={projectLineages}
|
||
onTriggerRecheck={() => {
|
||
fetchProjectLineage(currentProjectId).then(setProjectLineages).catch(() => {});
|
||
}}
|
||
/>
|
||
</>
|
||
);
|
||
|
||
// 严格按 projectId 隔离当前项目的交付物
|
||
const currentProjectDeliverables = useMemo(() => {
|
||
return deliverables.filter((doc) => doc.projectId === currentProjectId);
|
||
}, [deliverables, currentProjectId]);
|
||
|
||
// 基于当前登录用户与 RBAC 规则过滤可见交付物 (私密文档严格按 creatorId 隔离,单文档覆盖优先于工作流基线)
|
||
const visibleDeliverables = useMemo(() => {
|
||
return filterVisibleDeliverablesWithRBAC(currentProjectDeliverables, currentUser, roles);
|
||
}, [currentProjectDeliverables, currentUser, roles]);
|
||
|
||
// 单文档可见性覆盖保存处理
|
||
const handleSaveDocVisibilityRule = (docId: string, rule: DocVisibilityRule | undefined) => {
|
||
setDeliverables((prev) =>
|
||
prev.map((d) => (d.docId === docId ? { ...d, visibilityRule: rule } : d))
|
||
);
|
||
setDocCache((prev) => {
|
||
if (!prev[docId]) return prev;
|
||
return {
|
||
...prev,
|
||
[docId]: {
|
||
...prev[docId],
|
||
visibilityRule: rule,
|
||
},
|
||
};
|
||
});
|
||
};
|
||
|
||
// 当用户切换或可见列表变动时,自动校准激活文档
|
||
useEffect(() => {
|
||
const isCurrentVisible = visibleDeliverables.some((d) => d.docId === activeDocId);
|
||
if (!isCurrentVisible) {
|
||
const fallback =
|
||
visibleDeliverables.find((d) => d.category === activeCategory) ||
|
||
visibleDeliverables[0];
|
||
if (fallback) {
|
||
setActiveDocId(fallback.docId);
|
||
setActiveCategory(fallback.category);
|
||
}
|
||
}
|
||
}, [currentUser.id, visibleDeliverables, activeDocId, activeCategory]);
|
||
|
||
// 文档缓存与当前 AST
|
||
const [docCache, setDocCache] = useState<Record<string, DocumentAST>>(() => {
|
||
const initialCache: Record<string, DocumentAST> = { ...P2_INITIAL.docASTMap };
|
||
Object.entries(ALL_MOCK_DOCUMENTS).forEach(([id, meta]) => {
|
||
const rawText = meta.markdown || (meta as any).content || '';
|
||
initialCache[id] = markdownToBlockAST(rawText, {
|
||
docId: meta.docId || id,
|
||
title: meta.title || '',
|
||
taskKey: meta.taskKey || 'system_reqs',
|
||
version: meta.version || 'V1.0',
|
||
category: meta.category || 'workflow',
|
||
creatorId: meta.creatorId || 1,
|
||
creatorName: meta.creatorName || '系统管理员',
|
||
sharedAt: meta.sharedAt,
|
||
lifecycleState: meta.lifecycleState || 'approved',
|
||
isClaimed: meta.isClaimed ?? true,
|
||
author: meta.author || '张工',
|
||
reviewer: meta.reviewer || '李主管',
|
||
approver: meta.approver || '王总监',
|
||
});
|
||
});
|
||
|
||
// 自动为持久化存储的自定义项目受控模板预热补全完整高质量 AST,杜绝刷新后丢失
|
||
try {
|
||
const savedDeliverables = loadDeliverables(ALL_INITIAL_DELIVERABLES);
|
||
const savedProjects = loadProjects();
|
||
savedDeliverables.forEach((item) => {
|
||
if (item.docId && !initialCache[item.docId] && item.taskKey && item.isTemplateOnly !== false) {
|
||
const tmpl = getTemplateDefinitionByTaskKey(item.taskKey);
|
||
if (tmpl) {
|
||
const proj = savedProjects.find((p) => p.id === item.projectId);
|
||
initialCache[item.docId] = buildTemplateASTForProject(tmpl, {
|
||
projectId: item.projectId || 0,
|
||
projectName: proj?.title || '医疗器械研发项目',
|
||
deviceType: proj?.deviceType || '通用医疗器械',
|
||
docId: item.docId,
|
||
creatorId: item.creatorId || 1,
|
||
creatorName: item.creatorName || '系统',
|
||
});
|
||
}
|
||
}
|
||
});
|
||
} catch {
|
||
// 忽略预热异常
|
||
}
|
||
|
||
return initialCache;
|
||
});
|
||
|
||
const [controlledContentReady, setControlledContentReady] = useState<Record<string, boolean>>({});
|
||
const [docLoadErrors, setDocLoadErrors] = useState<Record<string, boolean>>({});
|
||
|
||
const docAST = useMemo<DocumentAST>(() => {
|
||
const target = deliverables.find((d) => d.docId === activeDocId);
|
||
const needsServerContent = target ? shouldLoadControlledContent(target) : false;
|
||
|
||
// 1. 命中缓存(受控非模板须等服务端正文拉取完成)
|
||
if (docCache[activeDocId]) {
|
||
if (!needsServerContent || controlledContentReady[activeDocId]) {
|
||
return docCache[activeDocId];
|
||
}
|
||
}
|
||
// 2. 若缓存缺失,基于目标 deliverable 实时自愈高质量 AST
|
||
if (target) {
|
||
if (needsServerContent) {
|
||
return buildControlledLoadingSkeleton(target);
|
||
}
|
||
const proj = projects.find((p) => p.id === target.projectId);
|
||
const tmpl = target.taskKey ? getTemplateDefinitionByTaskKey(target.taskKey) : null;
|
||
if (tmpl && target.isTemplateOnly !== false) {
|
||
return buildTemplateASTForProject(tmpl, {
|
||
projectId: target.projectId || 0,
|
||
projectName: proj?.title || '医疗器械研发项目',
|
||
deviceType: proj?.deviceType || '通用医疗器械',
|
||
docId: target.docId,
|
||
creatorId: target.creatorId || 1,
|
||
creatorName: target.creatorName || '系统',
|
||
});
|
||
}
|
||
|
||
// 关键修复:对于用户上传文件或自由研讨文档,原地自愈生成 AST,绝不跳至第一份工作流文档
|
||
const skeleton = `# ${target.title}\n\n- 交付物阶段:${target.stage || 'V0.5'}\n- 归属分类:${target.category === 'private' ? '个人私密' : target.category === 'shared' ? '项目共享' : '流程受控'}\n- 上传/编制人:${target.author || target.creatorName || '本人'}\n- 更新时间:${target.updatedAt || new Date().toISOString().substring(0, 10)}\n\n## 1. 文档概述与基本信息\n${target.summary || '用户上传文档或外部参考资料。'}\n`;
|
||
const fallbackAST = markdownToBlockAST(skeleton, {
|
||
docId: target.docId,
|
||
title: target.title,
|
||
taskKey: target.taskKey || 'user_requirements',
|
||
version: target.stage || 'V0.5',
|
||
category: target.category,
|
||
creatorId: target.creatorId,
|
||
creatorName: target.creatorName,
|
||
lifecycleState: target.lifecycleState || 'drafting',
|
||
isClaimed: target.isClaimed ?? true,
|
||
author: target.author || target.creatorName || '本人',
|
||
reviewer: target.reviewer || '李主管',
|
||
approver: target.approver || '王总监',
|
||
});
|
||
fallbackAST.governanceMode = target.governanceMode || 'freeform';
|
||
fallbackAST.space = target.space || (target.category === 'shared' ? 'project_shared' : 'my_private');
|
||
return fallbackAST;
|
||
}
|
||
// 3. 当前项目第一个可见交付物
|
||
const firstProjDoc = visibleDeliverables[0];
|
||
if (firstProjDoc && docCache[firstProjDoc.docId]) {
|
||
return docCache[firstProjDoc.docId];
|
||
}
|
||
if (firstProjDoc) {
|
||
if (shouldLoadControlledContent(firstProjDoc)) {
|
||
return buildControlledLoadingSkeleton(firstProjDoc);
|
||
}
|
||
const proj = projects.find((p) => p.id === firstProjDoc.projectId);
|
||
const tmpl = firstProjDoc.taskKey ? getTemplateDefinitionByTaskKey(firstProjDoc.taskKey) : null;
|
||
if (tmpl && firstProjDoc.isTemplateOnly !== false) {
|
||
return buildTemplateASTForProject(tmpl, {
|
||
projectId: firstProjDoc.projectId || 0,
|
||
projectName: proj?.title || '医疗器械研发项目',
|
||
deviceType: proj?.deviceType || '通用医疗器械',
|
||
docId: firstProjDoc.docId,
|
||
creatorId: firstProjDoc.creatorId || 1,
|
||
creatorName: firstProjDoc.creatorName || '系统',
|
||
});
|
||
}
|
||
}
|
||
// 4. 极端兜底
|
||
return (
|
||
docCache['doc-neuropulse-feasibility_reg'] ||
|
||
docCache['doc-001'] ||
|
||
Object.values(docCache)[0]
|
||
);
|
||
}, [docCache, activeDocId, deliverables, projects, visibleDeliverables, controlledContentReady]);
|
||
|
||
// legacy doc-p{projectId}-{taskKey} → 服务端 doc_id 映射
|
||
useEffect(() => {
|
||
const canonicalId = resolveCanonicalDocId(activeDocId, deliverables);
|
||
if (canonicalId === activeDocId) return;
|
||
setActiveDocId(canonicalId);
|
||
setDocCache((prev) => {
|
||
if (!prev[activeDocId]) return prev;
|
||
const next = { ...prev };
|
||
delete next[activeDocId];
|
||
return next;
|
||
});
|
||
}, [deliverables, activeDocId]);
|
||
|
||
// 受控非模板文档:挂载/切换/同步后自动拉取服务端正文
|
||
useEffect(() => {
|
||
if (!session || sessionValidated !== true) return;
|
||
const target = deliverables.find((d) => d.docId === activeDocId);
|
||
if (!target || !shouldLoadControlledContent(target)) return;
|
||
|
||
let cancelled = false;
|
||
setDocLoadErrors((prev) => {
|
||
const next = { ...prev };
|
||
delete next[activeDocId];
|
||
return next;
|
||
});
|
||
void hydrateControlledDocumentContent(activeDocId, target)
|
||
.then((ast) => {
|
||
if (cancelled || !ast) return;
|
||
setDocCache((prev) => ({ ...prev, [activeDocId]: ast }));
|
||
setControlledContentReady((prev) => ({ ...prev, [activeDocId]: true }));
|
||
setDocLoadErrors((prev) => ({ ...prev, [activeDocId]: false }));
|
||
setDocRevRefreshKey((k) => k + 1);
|
||
})
|
||
.catch((err) => {
|
||
console.warn('自动拉取受控文档正文失败:', err);
|
||
if (cancelled) return;
|
||
setDocLoadErrors((prev) => ({ ...prev, [activeDocId]: true }));
|
||
setDocCache((prev) => ({
|
||
...prev,
|
||
[activeDocId]: buildControlledLoadErrorSkeleton(target),
|
||
}));
|
||
});
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [session, sessionValidated, activeDocId, deliverables]);
|
||
|
||
const retryControlledDocLoad = useCallback(() => {
|
||
const target = deliverables.find((d) => d.docId === activeDocId);
|
||
if (!target || !shouldLoadControlledContent(target)) return;
|
||
|
||
setDocLoadErrors((prev) => {
|
||
const next = { ...prev };
|
||
delete next[activeDocId];
|
||
return next;
|
||
});
|
||
setControlledContentReady((prev) => ({ ...prev, [activeDocId]: false }));
|
||
setDocCache((prev) => ({
|
||
...prev,
|
||
[activeDocId]: buildControlledLoadingSkeleton(target),
|
||
}));
|
||
|
||
void hydrateControlledDocumentContent(activeDocId, target)
|
||
.then((ast) => {
|
||
if (!ast) return;
|
||
setDocCache((prev) => ({ ...prev, [activeDocId]: ast }));
|
||
setControlledContentReady((prev) => ({ ...prev, [activeDocId]: true }));
|
||
setDocLoadErrors((prev) => ({ ...prev, [activeDocId]: false }));
|
||
setDocRevRefreshKey((k) => k + 1);
|
||
})
|
||
.catch((err) => {
|
||
console.warn('重试拉取受控文档正文失败:', err);
|
||
setDocLoadErrors((prev) => ({ ...prev, [activeDocId]: true }));
|
||
setDocCache((prev) => ({
|
||
...prev,
|
||
[activeDocId]: buildControlledLoadErrorSkeleton(target),
|
||
}));
|
||
});
|
||
}, [activeDocId, deliverables]);
|
||
|
||
// 自愈后自动回写入缓存,保障后续操作直接命中
|
||
useEffect(() => {
|
||
if (!docAST || docCache[docAST.docId]) return;
|
||
const target = deliverables.find((d) => d.docId === docAST.docId);
|
||
if (target && shouldLoadControlledContent(target)) return;
|
||
setDocCache((prev) => ({ ...prev, [docAST.docId]: docAST }));
|
||
}, [docAST, docCache, deliverables]);
|
||
|
||
// 根据当前激活文档与用户权限矩阵,计算当前生效角色
|
||
const currentDocRole: UserRole = (() => {
|
||
const perm = currentUser.projectPermissions?.[activeDocId];
|
||
if (perm === 'author' || perm === 'reviewer' || perm === 'approver') {
|
||
return perm;
|
||
}
|
||
// 降级回退
|
||
if (docAST.author === currentUser.name) return 'author';
|
||
if (docAST.reviewer === currentUser.name) return 'reviewer';
|
||
if (docAST.approver === currentUser.name) return 'approver';
|
||
return 'author'; // 默认
|
||
})();
|
||
|
||
const setDocAST = (newAst: DocumentAST) => {
|
||
setDocCache((prev) => ({
|
||
...prev,
|
||
[activeDocId]: newAst,
|
||
}));
|
||
};
|
||
|
||
// 切换文档
|
||
const handleSelectDoc = (id: string) => {
|
||
setActiveDocId(id);
|
||
let targetDoc = deliverables.find((d) => d.docId === id);
|
||
|
||
// 关键自愈:若该文档不在全局 deliverables 中(例如历史消息中的附件),自动从消息池中寻回并自愈注册
|
||
if (!targetDoc) {
|
||
for (const msg of messages) {
|
||
if (msg.attachments) {
|
||
const matchAtt = msg.attachments.find((att) => att.docId === id);
|
||
if (matchAtt) {
|
||
const extMatch = matchAtt.name.match(/\.([^.]+)$/);
|
||
const ext = extMatch ? extMatch[1].toLowerCase() : '';
|
||
const baseTitle = matchAtt.name.replace(/\.[^/.]+$/, '');
|
||
const recovered: DeliverableItem = {
|
||
docId: id,
|
||
projectId: currentProjectId,
|
||
taskKey: 'user_requirements',
|
||
title: baseTitle,
|
||
stage: 'V0.5',
|
||
category: 'private',
|
||
creatorId: currentUser.id,
|
||
creatorName: currentUser.name,
|
||
isFixedWorkflow: false,
|
||
isTemplateOnly: false,
|
||
lifecycleState: 'drafting',
|
||
isClaimed: true,
|
||
author: currentUser.name,
|
||
reviewer: '李主管',
|
||
approver: '王总监',
|
||
updatedAt: new Date().toISOString().substring(0, 10),
|
||
summary: `用户上传的私密需求文档《${matchAtt.name}》,归档至个人私密文档库。`,
|
||
space: 'my_private',
|
||
governanceMode: 'freeform',
|
||
sourceType: 'uploaded_file',
|
||
fileMeta: {
|
||
originalName: matchAtt.name,
|
||
size: matchAtt.size,
|
||
mimeType: matchAtt.type,
|
||
extension: ext,
|
||
fileRaw: matchAtt.fileRaw,
|
||
fileUrl: matchAtt.fileUrl,
|
||
uploadedAt: new Date().toISOString(),
|
||
},
|
||
};
|
||
targetDoc = recovered;
|
||
setDeliverables((prev) => [recovered, ...prev.filter((d) => d.docId !== id)]);
|
||
// ✅ 自愈注册时同步持久化文件内容到 IndexedDB(若 fileRaw 有效)
|
||
if (matchAtt.fileRaw) {
|
||
saveFile(id, matchAtt.fileRaw, matchAtt.name).catch(() => {});
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (targetDoc) {
|
||
setActiveCategory(targetDoc.category);
|
||
}
|
||
|
||
if (targetDoc && shouldLoadControlledContent(targetDoc)) {
|
||
void hydrateControlledDocumentContent(id, targetDoc)
|
||
.then((ast) => {
|
||
if (!ast) return;
|
||
setDocCache((prev) => ({ ...prev, [id]: ast }));
|
||
setControlledContentReady((prev) => ({ ...prev, [id]: true }));
|
||
})
|
||
.catch((err) => {
|
||
console.warn('获取受控文档正文失败:', err);
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (!docCache[id]) {
|
||
if (ALL_MOCK_DOCUMENTS[id]) {
|
||
const meta = ALL_MOCK_DOCUMENTS[id];
|
||
const parsed = markdownToBlockAST(meta.markdown, {
|
||
docId: meta.docId,
|
||
title: meta.title,
|
||
taskKey: meta.taskKey,
|
||
version: meta.version,
|
||
category: meta.category,
|
||
creatorId: meta.creatorId,
|
||
creatorName: meta.creatorName,
|
||
sharedAt: meta.sharedAt,
|
||
lifecycleState: meta.lifecycleState,
|
||
isClaimed: meta.isClaimed,
|
||
author: meta.author,
|
||
reviewer: meta.reviewer,
|
||
approver: meta.approver,
|
||
});
|
||
setDocCache((prev) => ({ ...prev, [id]: parsed }));
|
||
} else if (targetDoc) {
|
||
// 关键升级:若为工作流标准模板,必须以工业级规格生成完备待填报模板 AST,坚决杜绝 3 行摘要残缺占位符
|
||
const tmpl = targetDoc.taskKey ? getTemplateDefinitionByTaskKey(targetDoc.taskKey) : null;
|
||
if (tmpl && targetDoc.isTemplateOnly !== false) {
|
||
const proj = projects.find((p) => p.id === targetDoc.projectId);
|
||
const fullAst = buildTemplateASTForProject(tmpl, {
|
||
projectId: targetDoc.projectId || 0,
|
||
projectName: proj?.title || '医疗器械研发项目',
|
||
deviceType: proj?.deviceType || '通用医疗器械',
|
||
docId: targetDoc.docId,
|
||
creatorId: targetDoc.creatorId || 1,
|
||
creatorName: targetDoc.creatorName || '系统',
|
||
});
|
||
setDocCache((prev) => ({ ...prev, [id]: fullAst }));
|
||
} else {
|
||
// 仅当非受控模板时才使用普通草稿骨架
|
||
const skeleton = `# ${targetDoc.title}\n\n- 交付物阶段:${targetDoc.stage}\n- 归属分类:${targetDoc.category}\n- 编制人:${targetDoc.author || targetDoc.creatorName}\n\n## 1. 交付物摘要\n${targetDoc.summary || '暂无详细章节摘要,待智能体参谋与项目成员增补。'}\n`;
|
||
const parsed = markdownToBlockAST(skeleton, {
|
||
docId: targetDoc.docId,
|
||
title: targetDoc.title,
|
||
taskKey: targetDoc.taskKey,
|
||
version: targetDoc.stage,
|
||
category: targetDoc.category,
|
||
creatorId: targetDoc.creatorId,
|
||
creatorName: targetDoc.creatorName,
|
||
lifecycleState: targetDoc.lifecycleState,
|
||
isClaimed: targetDoc.isClaimed,
|
||
author: targetDoc.author,
|
||
reviewer: targetDoc.reviewer,
|
||
approver: targetDoc.approver,
|
||
});
|
||
parsed.governanceMode = targetDoc.governanceMode;
|
||
parsed.space = targetDoc.space;
|
||
setDocCache((prev) => ({ ...prev, [id]: parsed }));
|
||
}
|
||
|
||
if (id.startsWith('doc-free-') || targetDoc.governanceMode === 'freeform') {
|
||
fetchFreeformDoc(id)
|
||
.then((detail) => {
|
||
if (detail && detail.content_markdown) {
|
||
const fullAST = markdownToBlockAST(detail.content_markdown, {
|
||
docId: detail.document.doc_id,
|
||
title: detail.document.title,
|
||
taskKey: targetDoc.taskKey,
|
||
version: `${detail.document.current_version_id || 1}`,
|
||
category: targetDoc.category,
|
||
creatorId: detail.document.owner_user_id,
|
||
creatorName: currentUser.name,
|
||
lifecycleState: targetDoc.lifecycleState,
|
||
isClaimed: true,
|
||
author: targetDoc.author,
|
||
reviewer: targetDoc.reviewer,
|
||
approver: targetDoc.approver,
|
||
});
|
||
fullAST.governanceMode = 'freeform';
|
||
fullAST.space = detail.document.space;
|
||
setDocCache((prev) => ({ ...prev, [id]: fullAST }));
|
||
}
|
||
})
|
||
.catch((err) => {
|
||
console.warn('获取自由文档详情失败,保持本地草稿骨架:', err);
|
||
});
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
// 一键将个人私密文档共享至项目协作参考库
|
||
const handleShareToProject = async (docId: string) => {
|
||
try {
|
||
if (docId.startsWith('doc-free-')) {
|
||
await shareFreeformDoc(docId);
|
||
}
|
||
} catch (e) {
|
||
console.error('Share doc error:', e);
|
||
}
|
||
const nowStr = new Date().toISOString().replace('T', ' ').substring(0, 16);
|
||
setDeliverables((prev) =>
|
||
prev.map((d) => (d.docId === docId ? { ...d, category: 'shared', space: 'project_shared', sharedAt: nowStr } : d))
|
||
);
|
||
setDocCache((prev) => {
|
||
if (!prev[docId]) return prev;
|
||
return {
|
||
...prev,
|
||
[docId]: { ...prev[docId], category: 'shared', space: 'project_shared', sharedAt: nowStr },
|
||
};
|
||
});
|
||
setActiveCategory('shared');
|
||
const targetDoc = deliverables.find((d) => d.docId === docId);
|
||
setMessages((prev) => [
|
||
...prev,
|
||
{
|
||
id: 'ast-share-' + Date.now(),
|
||
sessionId: activeSessionId,
|
||
role: 'assistant',
|
||
authorName: '水木东方协同参谋',
|
||
intentTag: '🌐 一键共享至项目参考库',
|
||
content: `已为您将私密推演文档《${targetDoc?.title || '文档'}》一键共享至【项目协作参考库】!项目全员均可作为方案参考进行查阅与批注。您作为创建者,可随时点击「取消共享」恢复为个人私密状态。`,
|
||
timestamp: new Date().toTimeString().substring(0, 5),
|
||
},
|
||
]);
|
||
};
|
||
|
||
// 取消共享 (恢复为个人私有草稿)
|
||
const handleRevokeShare = async (docId: string) => {
|
||
try {
|
||
if (docId.startsWith('doc-free-')) {
|
||
await unshareFreeformDoc(docId);
|
||
}
|
||
} catch (e) {
|
||
console.error('Unshare doc error:', e);
|
||
}
|
||
setDeliverables((prev) =>
|
||
prev.map((d) => (d.docId === docId ? { ...d, category: 'private', space: 'my_private', sharedAt: undefined } : d))
|
||
);
|
||
setDocCache((prev) => {
|
||
if (!prev[docId]) return prev;
|
||
return {
|
||
...prev,
|
||
[docId]: { ...prev[docId], category: 'private', space: 'my_private', sharedAt: undefined },
|
||
};
|
||
});
|
||
setActiveCategory('private');
|
||
const targetDoc = deliverables.find((d) => d.docId === docId);
|
||
setMessages((prev) => [
|
||
...prev,
|
||
{
|
||
id: 'ast-revoke-' + Date.now(),
|
||
sessionId: activeSessionId,
|
||
role: 'assistant',
|
||
authorName: '水木东方协同参谋',
|
||
intentTag: '🔒 撤回共享至个人私密',
|
||
content: `已撤回共享!《${targetDoc?.title || '文档'}》已从项目协作库移除并恢复为您的【个人私密文档】,其他团队成员在界面与大盘中已不可见。`,
|
||
timestamp: new Date().toTimeString().substring(0, 5),
|
||
},
|
||
]);
|
||
};
|
||
|
||
// 删除私密文档
|
||
const handleDeleteDoc = async (docId: string) => {
|
||
try {
|
||
if (docId.startsWith('doc-free-')) {
|
||
await deleteFreeformDoc(docId);
|
||
}
|
||
} catch (e) {
|
||
console.error('Delete doc error:', e);
|
||
}
|
||
setDeliverables((prev) => prev.filter((d) => d.docId !== docId));
|
||
if (activeDocId === docId) {
|
||
const remaining = deliverables.filter((d) => d.docId !== docId);
|
||
const fallback = remaining.find((d) => d.category === 'workflow') || remaining[0];
|
||
if (fallback) {
|
||
setActiveDocId(fallback.docId);
|
||
setActiveCategory(fallback.category);
|
||
}
|
||
}
|
||
};
|
||
|
||
// 单条智能体回复保存到“我的私密”文档
|
||
const handleSaveMessageToPrivate = async (message: ChatMessage) => {
|
||
try {
|
||
const serverId = message.serverId || 0;
|
||
let resData;
|
||
if (serverId > 0) {
|
||
try {
|
||
const resp = await saveMessageToPrivate(
|
||
serverId,
|
||
currentProjectId,
|
||
undefined,
|
||
message.contentSha256,
|
||
);
|
||
resData = resp.data;
|
||
} catch (serverErr: any) {
|
||
console.warn('服务端自由文档落库失败,降级为本地草稿保存:', serverErr);
|
||
}
|
||
}
|
||
|
||
const docId = resData?.document.doc_id || `doc-free-${Date.now()}`;
|
||
const title = resData?.document.title || deriveReplyTitle(message.content, message.timestamp);
|
||
const markdown = resData?.content_markdown || message.content;
|
||
const nowStr = new Date().toISOString().replace('T', ' ').substring(0, 16);
|
||
|
||
const newDocItem: DeliverableItem = {
|
||
docId,
|
||
projectId: currentProjectId,
|
||
taskKey: 'freeform_private',
|
||
title,
|
||
stage: 'V0.1',
|
||
category: 'private',
|
||
creatorId: currentUser.id,
|
||
creatorName: currentUser.name,
|
||
isFixedWorkflow: false,
|
||
isTemplateOnly: false,
|
||
lifecycleState: 'drafting',
|
||
isClaimed: true,
|
||
author: currentUser.name,
|
||
reviewer: '',
|
||
approver: '',
|
||
updatedAt: nowStr,
|
||
summary: message.content.slice(0, 120),
|
||
governanceMode: 'freeform',
|
||
space: 'my_private',
|
||
};
|
||
|
||
const newAST = markdownToBlockAST(markdown, {
|
||
docId,
|
||
title,
|
||
taskKey: 'freeform_private',
|
||
version: `${resData?.document.current_version_id || 1}`,
|
||
category: 'private',
|
||
creatorId: currentUser.id,
|
||
creatorName: currentUser.name,
|
||
lifecycleState: 'drafting',
|
||
isClaimed: true,
|
||
author: currentUser.name,
|
||
reviewer: '',
|
||
approver: '',
|
||
});
|
||
newAST.governanceMode = 'freeform';
|
||
newAST.space = 'my_private';
|
||
|
||
setDeliverables((prev) => {
|
||
const exists = prev.some((d) => d.docId === docId);
|
||
if (exists) return prev;
|
||
return [newDocItem, ...prev];
|
||
});
|
||
|
||
setDocCache((prev) => ({
|
||
...prev,
|
||
[docId]: newAST,
|
||
}));
|
||
|
||
setActiveDocId(docId);
|
||
setActiveCategory('private');
|
||
setDeckDrawerOpen(false);
|
||
} catch (err: any) {
|
||
console.error('保存到我的私密失败:', err);
|
||
throw err;
|
||
}
|
||
};
|
||
|
||
// 挂载或项目切换时,拉取服务端的自由文档(我的私密与项目共享)并同步至 deliverables
|
||
useEffect(() => {
|
||
if (!session || sessionValidated !== true || !currentProjectId) return;
|
||
let active = true;
|
||
const fetchDocs = async () => {
|
||
try {
|
||
const [privateSummaries, sharedSummaries] = await Promise.all([
|
||
listFreeformDocs('my_private', currentProjectId).catch(() => []),
|
||
listFreeformDocs('project_shared', currentProjectId).catch(() => []),
|
||
]);
|
||
if (!active) return;
|
||
const allSummaries = [...privateSummaries, ...sharedSummaries];
|
||
if (allSummaries.length === 0) return;
|
||
|
||
setDeliverables((prev) => {
|
||
let updated = [...prev];
|
||
for (const s of allSummaries) {
|
||
const exists = updated.find((d) => d.docId === s.doc_id);
|
||
const category = s.space === 'my_private' ? 'private' : 'shared';
|
||
if (!exists) {
|
||
updated.unshift({
|
||
docId: s.doc_id,
|
||
projectId: s.project_id || currentProjectId,
|
||
taskKey: 'freeform_doc',
|
||
title: s.title,
|
||
stage: 'V0.1',
|
||
category,
|
||
creatorId: s.owner_user_id || currentUser.id,
|
||
creatorName: currentUser.name,
|
||
isFixedWorkflow: false,
|
||
isTemplateOnly: false,
|
||
lifecycleState: 'drafting',
|
||
isClaimed: true,
|
||
author: currentUser.name,
|
||
reviewer: '',
|
||
approver: '',
|
||
updatedAt: (s.updated_at || s.created_at || '').replace('T', ' ').substring(0, 16),
|
||
governanceMode: 'freeform',
|
||
space: s.space,
|
||
});
|
||
}
|
||
}
|
||
return updated;
|
||
});
|
||
} catch (e) {
|
||
console.warn('获取服务端自由文档列表异常:', e);
|
||
}
|
||
};
|
||
fetchDocs();
|
||
return () => {
|
||
active = false;
|
||
};
|
||
}, [session, sessionValidated, currentProjectId, currentUser.id]);
|
||
|
||
// 挂载或项目切换时,拉取服务端工作流受控文档并覆盖本地模板状态
|
||
useEffect(() => {
|
||
if (!session || sessionValidated !== true || !currentProjectId || currentProjectId <= 0) return;
|
||
let active = true;
|
||
const syncWorkflowDocs = async () => {
|
||
try {
|
||
const workflowDocs = await listProjectWorkflowDocuments(currentProjectId).catch(() => []);
|
||
if (!active || workflowDocs.length === 0) return;
|
||
setDeliverables((prev) => {
|
||
const merged = mergeWorkflowDocumentsFromServer(
|
||
prev,
|
||
workflowDocs,
|
||
currentProjectId,
|
||
currentUser,
|
||
);
|
||
const serverBackedIds = merged
|
||
.filter((d) => d.projectId === currentProjectId && shouldLoadControlledContent(d))
|
||
.map((d) => d.docId);
|
||
if (serverBackedIds.length > 0) {
|
||
setDocCache((cache) => {
|
||
const next = { ...cache };
|
||
serverBackedIds.forEach((docId) => {
|
||
delete next[docId];
|
||
});
|
||
return next;
|
||
});
|
||
setControlledContentReady((ready) => {
|
||
const next = { ...ready };
|
||
serverBackedIds.forEach((docId) => {
|
||
delete next[docId];
|
||
});
|
||
return next;
|
||
});
|
||
}
|
||
return merged;
|
||
});
|
||
} catch (e) {
|
||
console.warn('[deliverables] 工作流受控文档同步失败:', e);
|
||
}
|
||
};
|
||
void syncWorkflowDocs();
|
||
return () => {
|
||
active = false;
|
||
};
|
||
}, [session, sessionValidated, currentProjectId, currentUser.id, currentUser.name]);
|
||
|
||
// 文档占用是多人共享运行态:定期从服务端同步,避免其他标签页/成员开始编辑后本页无感知。
|
||
useEffect(() => {
|
||
if (!session || sessionValidated !== true || currentProjectId <= 0) return;
|
||
let cancelled = false;
|
||
const syncLeases = async () => {
|
||
const docs = await listProjectWorkflowDocuments(currentProjectId).catch(() => []);
|
||
if (cancelled || docs.length === 0) return;
|
||
const leases = new Map(
|
||
docs.map((doc) => [
|
||
doc.doc_id,
|
||
doc.lease_holder_type
|
||
? {
|
||
holderType: doc.lease_holder_type,
|
||
holderId: doc.lease_holder_id,
|
||
expiresAt: doc.lease_expires_at,
|
||
heartbeatAt: doc.lease_heartbeat_at,
|
||
}
|
||
: undefined,
|
||
]),
|
||
);
|
||
const sameLease = (
|
||
left: DeliverableItem['editLease'],
|
||
right: DeliverableItem['editLease'],
|
||
) =>
|
||
left?.holderType === right?.holderType &&
|
||
left?.holderId === right?.holderId &&
|
||
left?.expiresAt === right?.expiresAt &&
|
||
left?.heartbeatAt === right?.heartbeatAt;
|
||
setDeliverables((prev) =>
|
||
prev.map((doc) => {
|
||
if (doc.projectId !== currentProjectId || !leases.has(doc.docId)) return doc;
|
||
const editLease = leases.get(doc.docId);
|
||
return sameLease(doc.editLease, editLease) ? doc : { ...doc, editLease };
|
||
}),
|
||
);
|
||
setDocCache((prev) => {
|
||
let changed = false;
|
||
const next = { ...prev };
|
||
leases.forEach((editLease, docId) => {
|
||
if (!next[docId]) return;
|
||
if (sameLease(next[docId].editLease, editLease)) return;
|
||
next[docId] = { ...next[docId], editLease };
|
||
changed = true;
|
||
});
|
||
return changed ? next : prev;
|
||
});
|
||
};
|
||
const timer = window.setInterval(() => void syncLeases(), 5000);
|
||
return () => {
|
||
cancelled = true;
|
||
window.clearInterval(timer);
|
||
};
|
||
}, [session, sessionValidated, currentProjectId]);
|
||
|
||
// 模态弹窗与侧栏
|
||
const [editingBlock, setEditingBlock] = useState<DocumentBlock | null>(null);
|
||
const [commentingBlock, setCommentingBlock] = useState<DocumentBlock | null>(null);
|
||
const [highlightedBlockId, setHighlightedBlockId] = useState<string | null>(null);
|
||
const [pendingProposal, setPendingProposal] = useState<{
|
||
changes: string;
|
||
impact: string;
|
||
risk: string;
|
||
patchBlockId?: string;
|
||
} | null>(null);
|
||
|
||
// 消息流:权威来源为服务端窗口;不再全量持久化到 localStorage
|
||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||
|
||
useEffect(() => {
|
||
void migrateAndClearLegacyMessages();
|
||
}, []);
|
||
|
||
// 基于当前激活的项目会话严格隔离对话记录
|
||
const currentSessionMessages = useMemo(() => {
|
||
return messages.filter((m) => m.sessionId === activeSessionId);
|
||
}, [messages, activeSessionId]);
|
||
|
||
// P0: 会话按日窗口状态机 (处理首屏拉取、瞬时落底、触顶加载补偿)
|
||
const authenticatedSessionId =
|
||
session && sessionValidated === true ? activeSessionId : '';
|
||
const turnRuntime = useTurnRuntime(authenticatedSessionId);
|
||
const taskRunStream = useTaskRunStream(authenticatedSessionId);
|
||
const isThinking = turnRuntime.isThinking || taskRunStream.isThinking;
|
||
|
||
const chatWindow = useChatWindow({
|
||
sessionId: authenticatedSessionId,
|
||
userId: currentUser.id,
|
||
liveMessages: currentSessionMessages,
|
||
});
|
||
|
||
// 刷新后仍以服务端 run 状态为准;终态出现时重拉窗口,恢复正文、错误动作和产物信息。
|
||
const recoverableRunIds = useMemo(
|
||
() =>
|
||
chatWindow.allMessages
|
||
.filter(
|
||
(message) =>
|
||
message.role === 'assistant' &&
|
||
message.status === 'streaming_status' &&
|
||
!!message.taskRunId,
|
||
)
|
||
.map((message) => message.taskRunId as string)
|
||
.sort(),
|
||
[chatWindow.allMessages],
|
||
);
|
||
const recoverableRunKey = recoverableRunIds.join(',');
|
||
useEffect(() => {
|
||
if (!recoverableRunKey) return;
|
||
let cancelled = false;
|
||
const poll = async () => {
|
||
const records = await Promise.all(
|
||
recoverableRunIds.map((runId) => getTaskRun(runId).catch(() => null)),
|
||
);
|
||
if (cancelled) return;
|
||
if (records.some((record) => record && record.status !== 'running')) {
|
||
await chatWindow.refresh();
|
||
}
|
||
};
|
||
void poll();
|
||
const timer = window.setInterval(() => void poll(), 3000);
|
||
return () => {
|
||
cancelled = true;
|
||
window.clearInterval(timer);
|
||
};
|
||
}, [recoverableRunKey]);
|
||
|
||
// P1: 多维度历史导航与分类统计、日历、引用状态
|
||
const [categoryStats, setCategoryStats] = useState<ChatCategoryStat[]>([]);
|
||
const [dateStats, setDateStats] = useState<ChatDateStat[]>([]);
|
||
const [isCalendarOpen, setIsCalendarOpen] = useState(false);
|
||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||
const [isDigestOpen, setIsDigestOpen] = useState(false);
|
||
const [isExportOpen, setIsExportOpen] = useState(false);
|
||
const [activeQuotes, setActiveQuotes] = useState<QuotedMessage[]>([]);
|
||
|
||
// P3: 未读消息轮询与角标
|
||
const {
|
||
unreadMarks,
|
||
unreadTotal,
|
||
unreadMessageIds,
|
||
isAllReadCompleted,
|
||
consumeNextUnread,
|
||
} = useUnreadMarks(authenticatedSessionId, currentUser?.id);
|
||
|
||
// 会话切换时加载分类与日期统计
|
||
useEffect(() => {
|
||
if (!activeSessionId || sessionValidated !== true) return;
|
||
fetchChatCategories(activeSessionId)
|
||
.then((cats) => {
|
||
setCategoryStats(cats || []);
|
||
})
|
||
.catch((err) => {
|
||
console.warn('Failed to load category stats:', err);
|
||
});
|
||
|
||
fetchChatDates(activeSessionId)
|
||
.then((dates) => {
|
||
setDateStats(dates || []);
|
||
})
|
||
.catch((err) => {
|
||
console.warn('Failed to load date stats:', err);
|
||
});
|
||
}, [activeSessionId, sessionValidated]);
|
||
|
||
// 监听 URL 哈希 (#/s/{sessionId}/m/{messageId}) 实现深链接直接定位与脉冲动效
|
||
const activeSessionIdRef = useRef(activeSessionId);
|
||
useEffect(() => {
|
||
activeSessionIdRef.current = activeSessionId;
|
||
}, [activeSessionId]);
|
||
|
||
const loadDayForAnchorRef = useRef(chatWindow.loadDayForAnchor);
|
||
useEffect(() => {
|
||
loadDayForAnchorRef.current = chatWindow.loadDayForAnchor;
|
||
}, [chatWindow.loadDayForAnchor]);
|
||
|
||
const sessionValidatedRef = useRef(sessionValidated);
|
||
useEffect(() => {
|
||
sessionValidatedRef.current = sessionValidated;
|
||
}, [sessionValidated]);
|
||
|
||
useEffect(() => {
|
||
const handleHashChange = () => {
|
||
if (sessionValidatedRef.current !== true) return;
|
||
const parsed = parseAnchorHash(window.location.hash);
|
||
if (!parsed) return;
|
||
const currentActive = activeSessionIdRef.current;
|
||
if (parsed.sessionId && parsed.sessionId !== currentActive) {
|
||
setActiveSessionId(parsed.sessionId);
|
||
setTimeout(() => {
|
||
navigateToAnchor(parsed, {
|
||
sessionId: parsed.sessionId,
|
||
loadDayForAnchor: loadDayForAnchorRef.current,
|
||
});
|
||
}, 300);
|
||
} else {
|
||
navigateToAnchor(parsed, {
|
||
sessionId: currentActive,
|
||
loadDayForAnchor: loadDayForAnchorRef.current,
|
||
});
|
||
}
|
||
};
|
||
|
||
window.addEventListener('hashchange', handleHashChange);
|
||
// 仅在初始页面加载时检查一次 URL hash
|
||
if (window.location.hash) {
|
||
handleHashChange();
|
||
}
|
||
return () => window.removeEventListener('hashchange', handleHashChange);
|
||
}, []); // 仅在挂载时监听 hashchange,避免 activeSessionId 变更时因残留 hash 产生回弹
|
||
|
||
// 引用消息交互
|
||
const handleQuoteMessage = (message: ChatMessage) => {
|
||
let msgId = message.serverId;
|
||
if (!msgId && typeof message.id === 'string' && message.id.startsWith('msg-')) {
|
||
const parsedId = parseInt(message.id.slice(4), 10);
|
||
if (!isNaN(parsedId) && parsedId > 0) {
|
||
msgId = parsedId;
|
||
}
|
||
}
|
||
if (!msgId) {
|
||
msgId = Date.now();
|
||
}
|
||
const currentDay = (message as any).dayKey || new Date().toISOString().substring(0, 10);
|
||
setActiveQuotes((prev) => {
|
||
if (prev.some((q) => q.messageId === msgId)) return prev;
|
||
return [
|
||
...prev,
|
||
{
|
||
messageId: msgId!,
|
||
authorName: message.authorName || (message.role === 'user' ? '用户' : '项目助手'),
|
||
excerpt: message.content ? message.content.slice(0, 150) : '',
|
||
dayKey: currentDay,
|
||
},
|
||
];
|
||
});
|
||
};
|
||
|
||
const handleRemoveQuote = (index: number) => {
|
||
setActiveQuotes((prev) => prev.filter((_, i) => i !== index));
|
||
};
|
||
|
||
// 挂载时检测并迁移旧版 localStorage 巨大 MESSAGES 键
|
||
useEffect(() => {
|
||
migrateAndClearLegacyMessages();
|
||
}, []);
|
||
|
||
// 当前会话是否存在正在等待/流式/自动重试中的助手气泡 (驱动 InputArea 停止态)
|
||
const isStreamingActive = useMemo(() => {
|
||
return currentSessionMessages.some(
|
||
(m) =>
|
||
m.role === 'assistant' &&
|
||
(m.status === 'pending' || m.status === 'streaming_status' || m.status === 'auto_retrying')
|
||
);
|
||
}, [currentSessionMessages]);
|
||
|
||
// 处理交互卡片动作流转 (驳回门禁确认、撤回、取消与终审联动)
|
||
const handleCardAction = (cardId: string, actionId: string, payload?: any) => {
|
||
// 查找对应的卡片
|
||
let targetCard: InteractiveCardData | undefined;
|
||
for (const m of messages) {
|
||
if (m.interactiveCard && m.interactiveCard.cardId === cardId) {
|
||
targetCard = m.interactiveCard;
|
||
break;
|
||
}
|
||
}
|
||
|
||
const targetDocId =
|
||
payload?.targetDocId || payload?.docId || targetCard?.targetDocId || activeDocId;
|
||
const reason =
|
||
payload?.reason || '关键竞品性能指标与电气安全指标待补充,退回编制人重新修订。';
|
||
|
||
if (actionId === 'confirm_reject') {
|
||
// 1. 调用既有文档驳回逻辑 (将目标文档生命周期由 reviewing 回退为 drafting)
|
||
setDeliverables((prev) =>
|
||
prev.map((d) => (d.docId === targetDocId ? { ...d, lifecycleState: 'drafting' } : d))
|
||
);
|
||
|
||
// 2. 扫描目标文档 AST,在匹配块上追加 type: 'issue' 批注
|
||
let targetBlockId: string | null = null;
|
||
let targetSectionName = '第3节';
|
||
setDocCache((prev) => {
|
||
let currentDoc = prev[targetDocId];
|
||
if (!currentDoc && ALL_MOCK_DOCUMENTS[targetDocId]) {
|
||
const meta = ALL_MOCK_DOCUMENTS[targetDocId];
|
||
currentDoc = markdownToBlockAST(meta.markdown, {
|
||
docId: meta.docId,
|
||
title: meta.title,
|
||
taskKey: meta.taskKey,
|
||
version: meta.version,
|
||
category: meta.category,
|
||
creatorId: meta.creatorId,
|
||
creatorName: meta.creatorName,
|
||
sharedAt: meta.sharedAt,
|
||
lifecycleState: meta.lifecycleState,
|
||
isClaimed: meta.isClaimed,
|
||
author: meta.author,
|
||
reviewer: meta.reviewer,
|
||
approver: meta.approver,
|
||
});
|
||
}
|
||
if (!currentDoc) return prev;
|
||
|
||
const { updatedDoc, matchingBlock } = applyConfirmRejectToDoc(
|
||
currentDoc,
|
||
cardId,
|
||
currentUser.name,
|
||
reason,
|
||
targetCard?.sectionTitle || payload?.targetSection
|
||
);
|
||
targetBlockId = matchingBlock.id;
|
||
if (matchingBlock.numbering) {
|
||
targetSectionName = `第${matchingBlock.numbering.replace('.', '')}节`;
|
||
}
|
||
|
||
return {
|
||
...prev,
|
||
[targetDocId]: updatedDoc,
|
||
};
|
||
});
|
||
|
||
// 3. 更新卡片状态为 resolved
|
||
const sectionLabel =
|
||
targetCard?.sectionTitle?.match(/第[0-9一二三四五六七八九]+节/)?.[0] || targetSectionName || '第3节';
|
||
const resolutionSummary = `已正式退回编制阶段,批注已挂载至${sectionLabel}`;
|
||
|
||
setMessages((prev) => {
|
||
const updatedMessages = resolveCardInMessages(
|
||
prev,
|
||
cardId,
|
||
resolutionSummary,
|
||
currentUser.name
|
||
);
|
||
return [
|
||
...updatedMessages,
|
||
{
|
||
id: 'sys-rej-' + Date.now(),
|
||
sessionId: activeSessionId,
|
||
role: 'system',
|
||
content: `⚠️ ${currentUser.name}(审核负责人)驳回了《${targetCard?.targetDocTitle || targetDocId}》,已退回编制阶段。驳回原因:${reason}`,
|
||
timestamp: new Date().toTimeString().substring(0, 5),
|
||
},
|
||
];
|
||
});
|
||
|
||
// 4. 切换当前右侧激活文档为目标文档并高亮匹配块
|
||
setActiveDocId(targetDocId);
|
||
const targetDeliverable = deliverables.find((d) => d.docId === targetDocId);
|
||
if (targetDeliverable) {
|
||
setActiveCategory(targetDeliverable.category);
|
||
}
|
||
if (targetBlockId) {
|
||
setHighlightedBlockId(targetBlockId);
|
||
setTimeout(() => {
|
||
setHighlightedBlockId(null);
|
||
}, 2500);
|
||
}
|
||
} else if (actionId === 'cancel') {
|
||
// 标记卡片为 cancelled 并追加说明
|
||
setMessages((prev) => [
|
||
...cancelCardInMessages(prev, cardId),
|
||
{
|
||
id: 'ast-cancel-' + Date.now(),
|
||
sessionId: activeSessionId,
|
||
role: 'assistant',
|
||
authorName: '水木东方协同参谋',
|
||
intentTag: '🚫 取消操作',
|
||
content: '已取消驳回操作,文档保持待审核状态。',
|
||
timestamp: new Date().toTimeString().substring(0, 5),
|
||
},
|
||
]);
|
||
} else if (actionId === 'undo') {
|
||
// 撤销驳回:恢复为待审核状态 (reviewing)
|
||
setDeliverables((prev) =>
|
||
prev.map((d) => (d.docId === targetDocId ? { ...d, lifecycleState: 'reviewing' } : d))
|
||
);
|
||
|
||
// 移除注入的批注并恢复 reviewing 状态
|
||
setDocCache((prev) => {
|
||
const currentDoc = prev[targetDocId];
|
||
if (!currentDoc) return prev;
|
||
const updatedDoc = applyUndoRejectToDoc(currentDoc, cardId);
|
||
return {
|
||
...prev,
|
||
[targetDocId]: updatedDoc,
|
||
};
|
||
});
|
||
|
||
// 标记卡片为 cancelled 并追加说明
|
||
setMessages((prev) => [
|
||
...cancelCardInMessages(prev, cardId),
|
||
{
|
||
id: 'ast-undo-' + Date.now(),
|
||
sessionId: activeSessionId,
|
||
role: 'assistant',
|
||
authorName: '水木东方协同参谋',
|
||
intentTag: '↩️ 撤回驳回',
|
||
content: '已在合法窗口期撤回驳回,文档已恢复为待审核状态。',
|
||
timestamp: new Date().toTimeString().substring(0, 5),
|
||
},
|
||
]);
|
||
} else if (actionId === 'confirm_final_approve') {
|
||
// 终审批准
|
||
setDeliverables((prev) =>
|
||
prev.map((d) => (d.docId === targetDocId ? { ...d, lifecycleState: 'approved' } : d))
|
||
);
|
||
setDocCache((prev) => {
|
||
const currentDoc = prev[targetDocId];
|
||
if (!currentDoc) return prev;
|
||
return {
|
||
...prev,
|
||
[targetDocId]: { ...currentDoc, lifecycleState: 'approved' as const },
|
||
};
|
||
});
|
||
|
||
setMessages((prev) => [
|
||
...resolveCardInMessages(
|
||
prev,
|
||
cardId,
|
||
'已终审批准生效',
|
||
currentUser.name
|
||
),
|
||
{
|
||
id: 'appr-' + Date.now(),
|
||
sessionId: activeSessionId,
|
||
role: 'assistant',
|
||
authorName: '水木东方协同参谋',
|
||
intentTag: '🏆 终审批准生效',
|
||
content: `🎉 祝贺!${currentUser.name}(批准人)已终审批准《${targetCard?.targetDocTitle || targetDocId}》!系统已自动对该受控交付物进行微粒度章节切片与向量化,成功注入全局标杆案例经验库!`,
|
||
timestamp: new Date().toTimeString().substring(0, 5),
|
||
recommendedCases: [
|
||
{
|
||
docId: targetDocId,
|
||
taskKey: targetCard?.targetDocId || 'research',
|
||
artifactTitle: targetCard?.targetDocTitle || '受控交付物',
|
||
sectionTitle: '终审已生效标杆案例',
|
||
similarityScore: 1.0,
|
||
keyRegulations: ['GB 9706.1-2020', 'YY 0569-2011', 'IEC 60601-1-2'],
|
||
summarySnippet: '本交付物已通过编制、审核、批准法定三级签署闭环,作为往期标杆经验向全平台开放自推荐。',
|
||
},
|
||
],
|
||
},
|
||
]);
|
||
} else if (actionId === 'confirm_review_pass' || actionId === 'confirm_review') {
|
||
// 审核通过:流转至待终审状态 (approving)
|
||
setDeliverables((prev) =>
|
||
prev.map((d) => (d.docId === targetDocId ? { ...d, lifecycleState: 'approving' } : d))
|
||
);
|
||
setDocCache((prev) => {
|
||
const currentDoc = prev[targetDocId];
|
||
if (!currentDoc) return prev;
|
||
return {
|
||
...prev,
|
||
[targetDocId]: { ...currentDoc, lifecycleState: 'approving' as const },
|
||
};
|
||
});
|
||
|
||
setMessages((prev) => [
|
||
...resolveCardInMessages(
|
||
prev,
|
||
cardId,
|
||
'审核已通过,流转至终审阶段',
|
||
currentUser.name
|
||
),
|
||
{
|
||
id: 'rev-pass-' + Date.now(),
|
||
sessionId: activeSessionId,
|
||
role: 'assistant',
|
||
authorName: '水木东方协同参谋',
|
||
intentTag: '✅ 审核通过确认',
|
||
content: `✅ ${currentUser.name}(审核人)已审核通过《${targetCard?.targetDocTitle || targetDocId}》!正文继续保持锁定,已流转至终审批准人进行最终批准。`,
|
||
timestamp: new Date().toTimeString().substring(0, 5),
|
||
},
|
||
]);
|
||
} else if (actionId === 'confirm_submit_review' || actionId === 'submit_review') {
|
||
// 提交审核:流转至待审核状态 (reviewing)
|
||
setDeliverables((prev) =>
|
||
prev.map((d) => (d.docId === targetDocId ? { ...d, lifecycleState: 'reviewing' } : d))
|
||
);
|
||
setDocCache((prev) => {
|
||
const currentDoc = prev[targetDocId];
|
||
if (!currentDoc) return prev;
|
||
return {
|
||
...prev,
|
||
[targetDocId]: { ...currentDoc, lifecycleState: 'reviewing' as const },
|
||
};
|
||
});
|
||
|
||
setMessages((prev) => [
|
||
...resolveCardInMessages(
|
||
prev,
|
||
cardId,
|
||
'已正式提交审核,正文锁定',
|
||
currentUser.name
|
||
),
|
||
{
|
||
id: 'sub-rev-' + Date.now(),
|
||
sessionId: activeSessionId,
|
||
role: 'assistant',
|
||
authorName: '水木东方协同参谋',
|
||
intentTag: '📤 提交审核确认',
|
||
content: `📤 ${currentUser.name}(编制人)已正式提交《${targetCard?.targetDocTitle || targetDocId}》进入审核流程!正文已锁定,李主管(审核人)可在工作台或会话流中进行合规复核。`,
|
||
timestamp: new Date().toTimeString().substring(0, 5),
|
||
},
|
||
]);
|
||
}
|
||
};
|
||
|
||
// 合成网络级异常的结构化错误信息 (fetch/stream 抛出而非后端 error 事件时使用)
|
||
const buildNetworkErrorInfo = (
|
||
httpStatus?: number,
|
||
opts?: { errorCode?: string; detail?: string },
|
||
): ChatErrorInfo => {
|
||
const code = opts?.errorCode || '';
|
||
const detail = opts?.detail || '';
|
||
if (code === 'DOC_BUSY' || code === 'document_busy') {
|
||
return {
|
||
userMessage: detail || '该文档正在被其他用户或项目助手编辑,当前请求未创建新任务',
|
||
code: 'DOC_BUSY',
|
||
class: 'fatal',
|
||
actions: [],
|
||
};
|
||
}
|
||
if (code === 'DOC_TASK_NOT_IN_ACTIVE_STAGE') {
|
||
return {
|
||
userMessage: detail || '该文档任务不在当前活动阶段,请先完成当前阶段工作流后再试',
|
||
code,
|
||
class: 'transient',
|
||
actions: ['retry'],
|
||
};
|
||
}
|
||
if (code === 'DOC_DUTY_FORBIDDEN') {
|
||
return {
|
||
userMessage: detail || '当前账号不是该文档任务的职责人,无法执行',
|
||
code,
|
||
class: 'fatal',
|
||
actions: ['retry'],
|
||
};
|
||
}
|
||
if (httpStatus === 401) {
|
||
return {
|
||
userMessage: '登录已失效,请重新登录后再试',
|
||
code: 'ERR_UNAUTHENTICATED',
|
||
class: 'fatal',
|
||
actions: ['retry'],
|
||
};
|
||
}
|
||
if (httpStatus === 403) {
|
||
if (code === 'ERR_FORBIDDEN' || detail.includes('无权访问该项目')) {
|
||
return {
|
||
userMessage: detail || '无权访问该项目,请确认登录账号与项目成员身份',
|
||
code: code || 'ERR_FORBIDDEN',
|
||
class: 'fatal',
|
||
actions: ['retry'],
|
||
};
|
||
}
|
||
return {
|
||
userMessage: detail || '无权执行该操作,请确认账号权限与工作流阶段',
|
||
code: code || 'ERR_FORBIDDEN',
|
||
class: 'fatal',
|
||
actions: ['retry'],
|
||
};
|
||
}
|
||
return {
|
||
userMessage: '网络连接异常,请检查网络后重试',
|
||
code: 'NETWORK_ERROR',
|
||
class: fallbackErrorClass({ network: true }),
|
||
actions: ['retry'],
|
||
};
|
||
};
|
||
|
||
// 驱动一次真实后端 SSE 流式对话尝试:气泡状态机 pending → streaming_status → completed/failed/cancelled。
|
||
// 闲聊路径在无 client_turn_id 幂等前禁止自动重放 POST;失败仅展示手动「重试」。
|
||
type StreamAttemptOpts = {
|
||
userMsgId?: string;
|
||
clientTurnId?: string;
|
||
quote?: QuotedMessage;
|
||
};
|
||
|
||
const runStreamAttempt = (
|
||
userText: string,
|
||
asstId: string,
|
||
attemptIndex: number,
|
||
opts?: StreamAttemptOpts
|
||
) => {
|
||
const quote = opts?.quote;
|
||
const controller = new AbortController();
|
||
const streamSessionId = activeSessionId;
|
||
if (opts?.clientTurnId) {
|
||
turnRuntime.registerTurn({
|
||
clientTurnId: opts.clientTurnId,
|
||
sessionId: streamSessionId,
|
||
userMsgId: opts.userMsgId || '',
|
||
asstId,
|
||
status: 'pending',
|
||
controller,
|
||
startedAt: Date.now(),
|
||
});
|
||
}
|
||
|
||
const currentProj = sessions.find((s) => s.sessionId === streamSessionId);
|
||
let accumulated = '';
|
||
let settled = false;
|
||
|
||
const updateAsst = (patch: Partial<ChatMessage>) => {
|
||
setMessages((prev) => prev.map((m) => (m.id === asstId ? { ...m, ...patch } : m)));
|
||
};
|
||
|
||
const scheduleRetry = (delayMs: number) => {
|
||
setTimeout(() => {
|
||
if (controller.signal.aborted) {
|
||
updateAsst({ status: 'cancelled', livePhaseLabel: undefined });
|
||
if (opts?.clientTurnId) {
|
||
turnRuntime.updateTurnStatus(streamSessionId, opts.clientTurnId, 'cancelled');
|
||
}
|
||
return;
|
||
}
|
||
runStreamAttempt(userText, asstId, attemptIndex + 1, opts);
|
||
}, delayMs);
|
||
};
|
||
|
||
const finishTurn = (status: 'completed' | 'failed' | 'cancelled') => {
|
||
if (opts?.clientTurnId) {
|
||
turnRuntime.updateTurnStatus(streamSessionId, opts.clientTurnId, status);
|
||
}
|
||
};
|
||
|
||
const failOrRetry = (info: ChatErrorInfo) => {
|
||
settled = true;
|
||
if (shouldAutoRetryChat(info, attemptIndex)) {
|
||
const delayMs = autoRetryDelayMs(attemptIndex);
|
||
updateAsst({
|
||
status: 'auto_retrying',
|
||
livePhaseLabel: `连接异常,${Math.round(delayMs / 1000)}s 后自动重试…`,
|
||
});
|
||
scheduleRetry(delayMs);
|
||
return;
|
||
}
|
||
updateAsst({
|
||
status: 'failed',
|
||
errorInfo: {
|
||
...info,
|
||
userMessage:
|
||
attemptIndex > 0
|
||
? `${info.userMessage}(请确认上一请求是否已在处理后再手动重试)`
|
||
: info.userMessage,
|
||
},
|
||
livePhaseLabel: undefined,
|
||
});
|
||
finishTurn('failed');
|
||
};
|
||
|
||
updateAsst({
|
||
status: 'pending',
|
||
livePhaseLabel: attemptIndex > 0 ? '正在手动重试…' : '助手正在处理…',
|
||
content: '',
|
||
errorInfo: undefined,
|
||
});
|
||
if (opts?.clientTurnId) {
|
||
turnRuntime.updateTurnStatus(streamSessionId, opts.clientTurnId, 'pending');
|
||
}
|
||
|
||
const bindUserServerId = (serverId: number) => {
|
||
if (!opts?.userMsgId) return;
|
||
chatWindow.replaceMessageServerId(opts.userMsgId, serverId);
|
||
setMessages((prev) =>
|
||
prev.map((m) => (m.id === opts.userMsgId ? { ...m, serverId } : m))
|
||
);
|
||
};
|
||
|
||
streamChat(
|
||
{
|
||
session_id: streamSessionId,
|
||
user_id: currentUser.id,
|
||
project_id: currentProj?.projectId || currentProjectId || 1,
|
||
project_name: currentProj?.title || '肌骨超声AI辅助诊断系统',
|
||
device_type: currentProj?.deviceType || '肌骨超声',
|
||
stage: currentProj?.stage || 'V0.5',
|
||
user_name: currentUser.name,
|
||
user_role: currentDocRole,
|
||
message: userText,
|
||
client_turn_id: opts?.clientTurnId,
|
||
quote: quote,
|
||
},
|
||
{
|
||
onEvent: (ev) => {
|
||
if (controller.signal.aborted || settled) return;
|
||
switch (ev.type) {
|
||
case 'turn_ack': {
|
||
const userServerId = ev.turn?.user_message_id ?? ev.user_message_id;
|
||
if (typeof userServerId === 'number' && userServerId > 0) {
|
||
bindUserServerId(userServerId);
|
||
}
|
||
break;
|
||
}
|
||
case 'phase':
|
||
case 'status': {
|
||
const label = ev.phase?.message || ev.status?.message;
|
||
updateAsst({ status: 'streaming_status', livePhaseLabel: label || '助手正在处理…' });
|
||
if (opts?.clientTurnId) {
|
||
turnRuntime.updateTurnStatus(streamSessionId, opts.clientTurnId, 'streaming');
|
||
}
|
||
break;
|
||
}
|
||
case 'delta': {
|
||
accumulated += ev.delta || '';
|
||
updateAsst({ status: 'streaming_status', content: accumulated });
|
||
if (opts?.clientTurnId) {
|
||
turnRuntime.updateTurnStatus(streamSessionId, opts.clientTurnId, 'streaming');
|
||
}
|
||
break;
|
||
}
|
||
case 'error': {
|
||
failOrRetry(mapErrorInfo(ev));
|
||
break;
|
||
}
|
||
case 'done': {
|
||
settled = true;
|
||
const userServerId = ev.done?.user_message_id ?? ev.user_message_id;
|
||
const serverMsgId = ev.done?.message_id ?? ev.message_id ?? (ev as { messageId?: number }).messageId;
|
||
if (typeof userServerId === 'number' && userServerId > 0) {
|
||
bindUserServerId(userServerId);
|
||
}
|
||
if (typeof serverMsgId === 'number' && serverMsgId > 0) {
|
||
chatWindow.replaceMessageServerId(asstId, serverMsgId);
|
||
}
|
||
const exportCap = ev.export_capability || ev.done?.export_capability;
|
||
updateAsst({
|
||
status: 'completed',
|
||
livePhaseLabel: undefined,
|
||
serverId: typeof serverMsgId === 'number' ? serverMsgId : undefined,
|
||
exportCapability: exportCap,
|
||
});
|
||
finishTurn('completed');
|
||
break;
|
||
}
|
||
default:
|
||
break;
|
||
}
|
||
},
|
||
},
|
||
controller.signal,
|
||
opts?.clientTurnId ? { idempotencyKey: opts.clientTurnId } : undefined
|
||
).catch(async (_err) => {
|
||
if (settled) return;
|
||
if (controller.signal.aborted) {
|
||
updateAsst({ status: 'cancelled', livePhaseLabel: undefined });
|
||
finishTurn('cancelled');
|
||
return;
|
||
}
|
||
if (opts?.clientTurnId) {
|
||
try {
|
||
const turnStatus = await fetchChatTurnStatus(streamSessionId, opts.clientTurnId);
|
||
if (turnStatus?.status === 'completed' && turnStatus.assistantMessageId) {
|
||
settled = true;
|
||
if (turnStatus.userMessageId) bindUserServerId(turnStatus.userMessageId);
|
||
chatWindow.replaceMessageServerId(asstId, turnStatus.assistantMessageId);
|
||
updateAsst({
|
||
status: 'completed',
|
||
livePhaseLabel: undefined,
|
||
serverId: turnStatus.assistantMessageId,
|
||
});
|
||
finishTurn('completed');
|
||
return;
|
||
}
|
||
if (turnStatus?.status === 'processing') {
|
||
updateAsst({
|
||
status: 'streaming_status',
|
||
livePhaseLabel: '连接中断,服务端仍在处理…',
|
||
});
|
||
if (opts?.clientTurnId) {
|
||
turnRuntime.updateTurnStatus(streamSessionId, opts.clientTurnId, 'streaming');
|
||
}
|
||
return;
|
||
}
|
||
} catch {
|
||
// fall through to structured failure
|
||
}
|
||
}
|
||
const httpStatus =
|
||
typeof _err === 'object' && _err !== null && 'httpStatus' in _err
|
||
? Number((_err as { httpStatus?: number }).httpStatus)
|
||
: undefined;
|
||
const errMeta = _err as { errorCode?: string; detail?: string; message?: string };
|
||
failOrRetry(
|
||
buildNetworkErrorInfo(httpStatus, {
|
||
errorCode: errMeta.errorCode,
|
||
detail: errMeta.detail || errMeta.message?.split(': ').slice(1).join(': '),
|
||
}),
|
||
);
|
||
});
|
||
};
|
||
|
||
// P2:长任务尝试模式。'run' 新任务 | 'resume' 续跑失败工作区 | 'restart' 按原意图新工作区
|
||
const runTaskAttempt = (
|
||
task: TaskRunContext,
|
||
asstId: string,
|
||
mode: 'run' | 'resume' | 'restart',
|
||
attemptIndex: number,
|
||
opts?: { phaseEvents?: PhaseEvent[]; workDir?: string }
|
||
) => {
|
||
const sessionId = task.sessionId || activeSessionId;
|
||
taskRunStream.abortSession(sessionId);
|
||
taskRunStream.setRunId(sessionId, null);
|
||
const controller = new AbortController();
|
||
taskRunStream.setController(sessionId, controller);
|
||
|
||
let accumulated = '';
|
||
let settled = false;
|
||
|
||
const patchAsst = (patch: Partial<ChatMessage> | ((m: ChatMessage) => Partial<ChatMessage>)) => {
|
||
setMessages((prev) =>
|
||
prev.map((m) => (m.id === asstId ? { ...m, ...(typeof patch === 'function' ? patch(m) : patch) } : m))
|
||
);
|
||
};
|
||
|
||
const scheduleRetry = (delayMs: number) => {
|
||
setTimeout(() => {
|
||
if (controller.signal.aborted) {
|
||
patchAsst({ status: 'cancelled', livePhaseLabel: undefined });
|
||
taskRunStream.setThinking(sessionId, false);
|
||
return;
|
||
}
|
||
runTaskAttempt(task, asstId, mode, attemptIndex + 1, opts);
|
||
}, delayMs);
|
||
};
|
||
|
||
const failOrRetry = (info: ChatErrorInfo) => {
|
||
settled = true;
|
||
if (shouldAutoRetryTask(info, attemptIndex)) {
|
||
const delayMs = autoRetryDelayMs(attemptIndex);
|
||
patchAsst({
|
||
status: 'auto_retrying',
|
||
livePhaseLabel: `连接异常,${Math.round(delayMs / 1000)}s 后自动重试…`,
|
||
});
|
||
scheduleRetry(delayMs);
|
||
return;
|
||
}
|
||
patchAsst({ status: 'failed', errorInfo: info, livePhaseLabel: undefined });
|
||
taskRunStream.setThinking(sessionId, false);
|
||
};
|
||
|
||
// 等待态:resume 保留已有 phaseEvents 续接;run/restart 重置正文与步骤条
|
||
patchAsst({
|
||
status: 'pending',
|
||
livePhaseLabel:
|
||
mode === 'resume' ? '正在继续任务…' : mode === 'restart' ? '正在重新开始任务…' : '任务执行中…',
|
||
errorInfo: undefined,
|
||
...(mode === 'resume' ? {} : { content: '', phaseEvents: [], taskWorkspaceId: undefined }),
|
||
});
|
||
taskRunStream.setThinking(sessionId, true);
|
||
|
||
const onEvent = (ev: StreamEvent) => {
|
||
if (controller.signal.aborted || settled) return;
|
||
switch (ev.type) {
|
||
case 'phase': {
|
||
patchAsst((m) => ({
|
||
status: 'streaming_status',
|
||
livePhaseLabel: ev.phase?.message || m.livePhaseLabel,
|
||
phaseEvents: applyPhaseEvent(opts?.phaseEvents || m.phaseEvents || [], ev.phase),
|
||
}));
|
||
break;
|
||
}
|
||
case 'status': {
|
||
if (ev.status?.message) {
|
||
patchAsst({ status: 'streaming_status', livePhaseLabel: ev.status.message });
|
||
}
|
||
break;
|
||
}
|
||
case 'delta': {
|
||
accumulated += ev.delta || '';
|
||
patchAsst({ status: 'streaming_status', content: accumulated });
|
||
break;
|
||
}
|
||
case 'deliverable': {
|
||
const title = ev.deliverable?.title;
|
||
if (title) {
|
||
accumulated += (accumulated ? '\n\n' : '') + `📄 已生成文档:**${title}**`;
|
||
patchAsst({ status: 'streaming_status', content: accumulated });
|
||
}
|
||
break;
|
||
}
|
||
case 'governance': {
|
||
const governance = ev.governance;
|
||
const docId = governance?.doc_id;
|
||
if (docId) {
|
||
const leaseReleased = governance.action === 'lease_released';
|
||
const editLease = leaseReleased
|
||
? undefined
|
||
: { holderType: 'squad' as const, holderId: taskRunStream.activeTaskRunId || undefined };
|
||
setDeliverables((prev) =>
|
||
prev.map((doc) => (doc.docId === docId ? { ...doc, editLease } : doc)),
|
||
);
|
||
setDocCache((prev) => {
|
||
const ast = prev[docId];
|
||
return ast ? { ...prev, [docId]: { ...ast, editLease } } : prev;
|
||
});
|
||
}
|
||
if (governance?.notification_msg) {
|
||
patchAsst({
|
||
status: 'streaming_status',
|
||
livePhaseLabel: governance.notification_msg,
|
||
});
|
||
}
|
||
break;
|
||
}
|
||
case 'error': {
|
||
failOrRetry(mapErrorInfo(ev));
|
||
break;
|
||
}
|
||
case 'done': {
|
||
settled = true;
|
||
const finishedRunId = taskRunStream.activeTaskRunId;
|
||
taskRunStream.setRunId(sessionId, null);
|
||
taskRunStream.setThinking(sessionId, false);
|
||
void (async () => {
|
||
let nextStatus: AssistantMessageStatus = 'completed';
|
||
if (finishedRunId) {
|
||
const remote = await getTaskRun(finishedRunId).catch(() => null);
|
||
if (remote?.status) {
|
||
const mapped = mapTaskStatusToMessageStatus(remote.status, 'assistant');
|
||
if (mapped) nextStatus = mapped;
|
||
}
|
||
}
|
||
patchAsst({
|
||
status: nextStatus,
|
||
livePhaseLabel: undefined,
|
||
taskWorkspaceId: ev.done?.workspace_id,
|
||
});
|
||
await chatWindow.refresh().catch(() => undefined);
|
||
})();
|
||
break;
|
||
}
|
||
default:
|
||
break;
|
||
}
|
||
};
|
||
|
||
const startObserver = async () => {
|
||
const body =
|
||
mode === 'resume' && opts?.workDir
|
||
? buildResumeRequest(task, opts.workDir)
|
||
: buildTaskRequest(task);
|
||
|
||
if (task.sessionId && task.projectId && task.projectId > 0 && mode === 'run') {
|
||
const created = await createTaskRun({
|
||
...body,
|
||
session_id: task.sessionId,
|
||
project_id: task.projectId,
|
||
user_id: task.userId,
|
||
client_turn_id: task.clientTurnId,
|
||
user_message: task.userMessage,
|
||
});
|
||
taskRunStream.setRunId(sessionId, created.run_id);
|
||
patchAsst({ taskRunId: created.run_id });
|
||
await subscribeTaskRunEvents(created.run_id, { onEvent }, controller.signal);
|
||
return;
|
||
}
|
||
|
||
await streamTask(body, { onEvent }, controller.signal);
|
||
};
|
||
|
||
void startObserver().catch(async (_err) => {
|
||
if (settled) return;
|
||
if (controller.signal.aborted) {
|
||
patchAsst({ status: 'cancelled', livePhaseLabel: undefined });
|
||
taskRunStream.setThinking(sessionId, false);
|
||
return;
|
||
}
|
||
const runId = taskRunStream.activeTaskRunId;
|
||
if (runId) {
|
||
try {
|
||
const remote = await getTaskRun(runId);
|
||
if (remote.status === 'completed' || remote.status === 'quality_failed') {
|
||
settled = true;
|
||
patchAsst({
|
||
status: 'completed',
|
||
livePhaseLabel: undefined,
|
||
taskWorkspaceId: remote.workspace_id,
|
||
});
|
||
taskRunStream.setThinking(sessionId, false);
|
||
return;
|
||
}
|
||
if (remote.status === 'pending_author_confirmation') {
|
||
settled = true;
|
||
patchAsst({
|
||
status: 'pending_author_confirmation',
|
||
livePhaseLabel: undefined,
|
||
taskWorkspaceId: remote.workspace_id,
|
||
});
|
||
taskRunStream.setThinking(sessionId, false);
|
||
await chatWindow.refresh().catch(() => undefined);
|
||
return;
|
||
}
|
||
if (remote.status === 'failed' || remote.status === 'cancelled') {
|
||
failOrRetry(buildNetworkErrorInfo());
|
||
return;
|
||
}
|
||
} catch {
|
||
// fall through to network retry path
|
||
}
|
||
}
|
||
const httpStatus =
|
||
typeof _err === 'object' && _err !== null && 'httpStatus' in _err
|
||
? Number((_err as { httpStatus?: number }).httpStatus)
|
||
: undefined;
|
||
const errMeta = _err as { errorCode?: string; detail?: string; message?: string };
|
||
failOrRetry(
|
||
buildNetworkErrorInfo(httpStatus, {
|
||
errorCode: errMeta.errorCode,
|
||
detail: errMeta.detail || errMeta.message?.split(': ').slice(1).join(': '),
|
||
}),
|
||
);
|
||
});
|
||
};
|
||
|
||
// 结构化错误 / 已停止会话上的用户手动恢复动作 (P2:retry / continue / restart 分型恢复)
|
||
const handleErrorAction = (messageId: string, action: 'retry' | 'continue' | 'restart') => {
|
||
const target = messages.find((m) => m.id === messageId);
|
||
if (!target) return;
|
||
|
||
// 长任务路径:按 taskRequest 重发 / 续跑工作区 / 按原意图重启
|
||
if (target.taskRequest) {
|
||
if (action === 'retry') {
|
||
const userText = target.taskRequest.userMessage || '';
|
||
const reResolved = resolveTaskTemplateForMessage(userText, currentProjectDeliverables);
|
||
const retryTask = reResolved
|
||
? { ...target.taskRequest, taskKey: reResolved.taskKey }
|
||
: target.taskRequest;
|
||
runTaskAttempt(retryTask, messageId, 'run', 0);
|
||
} else if (action === 'continue') {
|
||
const wsId = target.errorInfo?.workspaceId || target.taskWorkspaceId;
|
||
if (!wsId) return;
|
||
runTaskAttempt(target.taskRequest, messageId, 'resume', 0, {
|
||
phaseEvents: target.phaseEvents,
|
||
workDir: wsId,
|
||
});
|
||
} else {
|
||
if (!window.confirm('将放弃当前工作区并按原意图重新开始任务,确认?')) return;
|
||
runTaskAttempt(target.taskRequest, messageId, 'restart', 0);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 闲聊路径(P1 行为不变:仅 retry)
|
||
if (action !== 'retry') return;
|
||
const sourceMsg = target.sourceUserMessageId
|
||
? messages.find((m) => m.id === target.sourceUserMessageId)
|
||
: undefined;
|
||
const originalText = sourceMsg?.content?.trim();
|
||
if (!originalText) return;
|
||
runStreamAttempt(originalText, messageId, 0, {
|
||
userMsgId: sourceMsg?.id,
|
||
clientTurnId: target.clientTurnId || sourceMsg?.clientTurnId,
|
||
});
|
||
};
|
||
|
||
// 用户主动停止:仅中止当前 session 的活跃 turn
|
||
const handleStopStreaming = () => {
|
||
turnRuntime.abortActiveSessionTurns();
|
||
taskRunStream.abortSession(activeSessionId);
|
||
const runId = taskRunStream.activeTaskRunId;
|
||
if (runId) {
|
||
void cancelTaskRun(runId).catch(() => undefined);
|
||
taskRunStream.setRunId(activeSessionId, null);
|
||
}
|
||
};
|
||
|
||
// 发送消息与对话驱动双轨逻辑
|
||
const handleSendMessage = async (
|
||
text: string,
|
||
attachments?: { name: string; size: string; type: string; docId?: string; fileRaw?: File | Blob; fileUrl?: string }[],
|
||
quotes?: QuotedMessage[]
|
||
): Promise<SendMessageResult> => {
|
||
const trimmed = text.trim();
|
||
let clientTurnId: string;
|
||
try {
|
||
clientTurnId = createClientTurnId();
|
||
} catch (err) {
|
||
return {
|
||
accepted: false,
|
||
error: err instanceof Error ? err.message : '无法生成发送标识,请重试',
|
||
};
|
||
}
|
||
|
||
// 附件必须先注册为服务端 freeform(server docId);失败硬拦,禁止本地 orphan 回退
|
||
let processedAttachments = attachments;
|
||
if (attachments && attachments.length > 0) {
|
||
let registered;
|
||
try {
|
||
registered = await registerUploadAttachments({
|
||
attachments,
|
||
projectId: currentProjectId,
|
||
userId: currentUser.id,
|
||
userName: currentUser.name,
|
||
createFreeformDoc,
|
||
});
|
||
} catch (err) {
|
||
return {
|
||
accepted: false,
|
||
error: err instanceof Error ? err.message : '附件服务端注册失败,请重试',
|
||
};
|
||
}
|
||
|
||
const newDeliverablesList = registered.map((r) => r.deliverable);
|
||
const newDocCacheEntries: Record<string, DocumentAST> = {};
|
||
for (const r of registered) {
|
||
const baseTitle = r.deliverable.title;
|
||
const initialMd = r.detail.content_markdown;
|
||
const newAST = markdownToBlockAST(initialMd, {
|
||
docId: r.serverDocId,
|
||
title: baseTitle,
|
||
taskKey: 'user_requirements',
|
||
version: 'V1.0',
|
||
category: 'private',
|
||
creatorId: currentUser.id,
|
||
creatorName: currentUser.name,
|
||
lifecycleState: 'drafting',
|
||
isClaimed: true,
|
||
author: currentUser.name,
|
||
reviewer: '李主管',
|
||
approver: '王总监',
|
||
});
|
||
newAST.governanceMode = 'freeform';
|
||
newAST.space = 'my_private';
|
||
newDocCacheEntries[r.serverDocId] = newAST;
|
||
if (r.attachment.fileRaw) {
|
||
saveFile(r.serverDocId, r.attachment.fileRaw, r.attachment.name).catch((e) =>
|
||
console.warn('[fileStore] 保存上传文件失败:', r.serverDocId, e),
|
||
);
|
||
}
|
||
}
|
||
|
||
processedAttachments = registered.map((r) => r.attachment);
|
||
const newDocIds = new Set(newDeliverablesList.map((d) => d.docId));
|
||
setDeliverables((prev) => [...newDeliverablesList, ...prev.filter((d) => !newDocIds.has(d.docId))]);
|
||
setDocCache((prev) => ({
|
||
...prev,
|
||
...newDocCacheEntries,
|
||
}));
|
||
|
||
if (processedAttachments.length > 0 && processedAttachments[0].docId) {
|
||
setActiveDocId(processedAttachments[0].docId);
|
||
setActiveCategory('private');
|
||
}
|
||
}
|
||
|
||
const userMsg: ChatMessage = {
|
||
id: 'msg-' + Date.now(),
|
||
clientTurnId,
|
||
clientMessageId: `${clientTurnId}:user`,
|
||
sessionId: activeSessionId,
|
||
role: 'user',
|
||
authorName: currentUser.name,
|
||
content: text,
|
||
timestamp: new Date().toTimeString().substring(0, 5),
|
||
attachments: processedAttachments,
|
||
quotes: quotes && quotes.length > 0 ? quotes : undefined,
|
||
};
|
||
setActiveQuotes([]);
|
||
|
||
// 检查是否为肯定回复(双轨同效判定:打字回复等同于点击卡片默认动作)
|
||
const isAffirmative = isAffirmativeResponse(trimmed);
|
||
const lastActive = findLastActiveCard(messages, activeSessionId);
|
||
|
||
if (isAffirmative && lastActive) {
|
||
const { card } = lastActive;
|
||
const defaultAction = card.actions.find((a) => a.isAffirmativeDefault) || card.actions[0];
|
||
if (defaultAction) {
|
||
setMessages((prev) => [...prev, userMsg]);
|
||
handleCardAction(card.cardId, defaultAction.actionId, defaultAction.payload);
|
||
return { accepted: true };
|
||
}
|
||
}
|
||
|
||
// 递减消息列表中尚未完结的 active 卡片 ttlTurns,若 <= 0 则标记为 expired
|
||
setMessages((prev) => [...decrementActiveCardsTtl(prev, activeSessionId), userMsg]);
|
||
|
||
// 命中以下任一演示用关键词捷径时才走本地模拟分支 (与真实后端 SSE 路径互斥,判定条件与下方分支体一一对应)
|
||
const isGenDocShortcut =
|
||
text.includes('生成文档') ||
|
||
text.includes('起草文档') ||
|
||
text.includes('测算') ||
|
||
text.includes('推演') ||
|
||
text.includes('备忘') ||
|
||
text.includes('生成报告');
|
||
const isRejectShortcut = text.includes('驳回') || text.includes('退回');
|
||
const isModifyShortcut =
|
||
(text.includes('修改') || text.includes('更新') || text.includes('调整')) &&
|
||
docAST.isClaimed &&
|
||
currentDocRole === 'author';
|
||
const isReviewPassShortcut =
|
||
(text.includes('通过') || text.includes('同意')) &&
|
||
((docAST.lifecycleState === 'reviewing' && currentDocRole === 'reviewer') ||
|
||
(docAST.lifecycleState === 'approving' && currentDocRole === 'approver'));
|
||
const resolvedTask = resolveTaskTemplateForMessage(text, currentProjectDeliverables);
|
||
const isMockShortcut = isGenDocShortcut || isRejectShortcut || isModifyShortcut || isReviewPassShortcut;
|
||
|
||
// P2:模板命中的长任务走真实后端 task stream(等待态 + PhaseEventStrip live + 分型恢复)
|
||
if (resolvedTask) {
|
||
const asstId = 'ast-' + Date.now();
|
||
const currentProj = sessions.find((s) => s.sessionId === activeSessionId);
|
||
// 项目五要素暂取会话标题/设备类型;客户名沿用 bizdev-cli 演示默认值,五要素完整建模留待后续
|
||
const clientTurnId = `turn_${Date.now()}`;
|
||
const projId = resolveSessionProjectId(currentProj, currentProjectId);
|
||
if (projId <= 0) {
|
||
console.error('[task] 无法解析会话所属项目', activeSessionId);
|
||
return { accepted: false };
|
||
}
|
||
const taskKey = resolvedTask.taskKey;
|
||
const matchedDeliverable = deliverables.find(
|
||
(d) => d.projectId === projId && d.taskKey === taskKey,
|
||
);
|
||
const activeLease = matchedDeliverable?.editLease;
|
||
const leaseIsActive =
|
||
!!activeLease?.holderType &&
|
||
(!activeLease.expiresAt || new Date(activeLease.expiresAt).getTime() > Date.now());
|
||
if (leaseIsActive) {
|
||
setMessages((prev) => [
|
||
...prev,
|
||
{
|
||
id: 'ast-busy-' + Date.now(),
|
||
sessionId: activeSessionId,
|
||
role: 'assistant',
|
||
authorName: '项目助手',
|
||
content: `《${matchedDeliverable?.title || '该文档'}》正在由${activeLease?.holderType === 'squad' ? '项目助手' : '其他用户'}编辑/生成中。当前请求未启动新任务;完成后状态会自动解除。`,
|
||
timestamp: new Date().toTimeString().substring(0, 5),
|
||
status: 'completed',
|
||
},
|
||
]);
|
||
return { accepted: true };
|
||
}
|
||
const baseTask: TaskRunContext = {
|
||
taskKey,
|
||
projectName: currentProj?.title || '医疗器械研发演示项目',
|
||
customerName: '北京水木东方',
|
||
deviceType: currentProj?.deviceType || '医疗器械',
|
||
sessionId: activeSessionId,
|
||
userId: currentUser.id,
|
||
projectId: projId,
|
||
clientTurnId,
|
||
userMessage: text,
|
||
docId: canonicalDocIdForTask(
|
||
projId,
|
||
taskKey,
|
||
deliverables,
|
||
matchedDeliverable?.docId,
|
||
),
|
||
};
|
||
const placeholder: ChatMessage = {
|
||
id: asstId,
|
||
sessionId: activeSessionId,
|
||
role: 'assistant',
|
||
authorName: '项目助手',
|
||
content: '',
|
||
timestamp: new Date().toTimeString().substring(0, 5),
|
||
status: 'pending',
|
||
livePhaseLabel: '任务执行中…',
|
||
taskRequest: baseTask,
|
||
};
|
||
setMessages((prev) => [...prev, placeholder]);
|
||
// 异步读取附件内容后再触发任务,避免文档材料丢失
|
||
void (async () => {
|
||
const materials = await readAttachmentsAsMaterials(
|
||
processedAttachments || [],
|
||
baseTask.projectId,
|
||
);
|
||
const task: TaskRunContext = materials.length > 0 ? { ...baseTask, materials } : baseTask;
|
||
runTaskAttempt(task, asstId, 'run', 0);
|
||
})();
|
||
return { accepted: true };
|
||
}
|
||
|
||
if (!isMockShortcut) {
|
||
// 真实后端 SSE 路径:立即挂起「等待中」占位气泡,无人为延迟,随后由 runStreamAttempt 驱动状态机
|
||
// (pending → streaming_status → completed/failed/auto_retrying/cancelled),严禁静默兜底话术
|
||
const asstId = 'ast-' + Date.now();
|
||
const placeholder: ChatMessage = {
|
||
id: asstId,
|
||
clientTurnId,
|
||
clientMessageId: `${clientTurnId}:assistant`,
|
||
sessionId: activeSessionId,
|
||
role: 'assistant',
|
||
authorName: '项目助手',
|
||
content: '',
|
||
timestamp: new Date().toTimeString().substring(0, 5),
|
||
status: 'pending',
|
||
livePhaseLabel: '助手正在处理…',
|
||
sourceUserMessageId: userMsg.id,
|
||
};
|
||
setMessages((prev) => [...prev, placeholder]);
|
||
runStreamAttempt(text, asstId, 0, {
|
||
userMsgId: userMsg.id,
|
||
clientTurnId,
|
||
quote: quotes && quotes.length > 0 ? quotes[0] : undefined,
|
||
});
|
||
return { accepted: true };
|
||
}
|
||
|
||
taskRunStream.setThinking(activeSessionId, true);
|
||
|
||
setTimeout(() => {
|
||
taskRunStream.setThinking(activeSessionId, false);
|
||
|
||
// 1. 用户要求助手生成新文档/测算/推演/备忘 (默认自动归档至【我的私密生成】,保护隐私)
|
||
if (isGenDocShortcut) {
|
||
const newDocId = `doc-private-${Date.now()}`;
|
||
const docTitle = text.includes('备忘')
|
||
? `${currentUser.name}专属:临床与商业推演备忘录`
|
||
: text.includes('测算')
|
||
? `${currentUser.name}专属:BOM器件成本与毛利灵敏度测算`
|
||
: `${currentUser.name}专属:AI协同专项推演草案`;
|
||
|
||
const newMeta: DeliverableItem = {
|
||
docId: newDocId,
|
||
projectId: resolveSessionProjectId(
|
||
sessions.find((s) => s.sessionId === activeSessionId),
|
||
currentProjectId,
|
||
) || 1,
|
||
taskKey: 'custom_draft',
|
||
title: docTitle,
|
||
stage: 'V0.5',
|
||
category: 'private',
|
||
creatorId: currentUser.id,
|
||
creatorName: currentUser.name,
|
||
isFixedWorkflow: false,
|
||
isTemplateOnly: false,
|
||
lifecycleState: 'drafting',
|
||
isClaimed: true,
|
||
author: currentUser.name,
|
||
reviewer: '李主管',
|
||
approver: '王总监',
|
||
updatedAt: new Date().toISOString().replace('T', ' ').substring(0, 16),
|
||
summary: `根据对话中您的需求“${text.slice(0, 60)}...”即时生成的个人私密草稿。`,
|
||
};
|
||
|
||
const sampleMd = `# ${docTitle}\n\n## 1. 产生背景与对话诉求\n根据用户【${currentUser.name}】于 ${new Date().toLocaleTimeString()} 提出的协同要求:“${text}”,参谋小队即时推演生成本专属文档。\n\n> 🔒 [隐私受控标记] 本文档为用户个人私密生成物,仅 ${currentUser.name} 本人可见,不进入项目固定受控审签体系。若推演成熟,可随时点击下方「一键共享给项目组」转为协作参考资料。\n\n## 2. 核心分析与推演结论\n依据系统知识库与工程模型,对关键指标与风险进行分解:\n- 技术可行性评级:A 级(具备清晰工程落地路径)\n- 建议跟进措施:优化元器件批量采购配额,规避长周期交货风险。\n`;
|
||
|
||
const parsedAst = markdownToBlockAST(sampleMd, {
|
||
docId: newDocId,
|
||
title: docTitle,
|
||
taskKey: 'custom_draft',
|
||
version: 'V0.1',
|
||
category: 'private',
|
||
creatorId: currentUser.id,
|
||
creatorName: currentUser.name,
|
||
lifecycleState: 'drafting',
|
||
isClaimed: true,
|
||
author: currentUser.name,
|
||
});
|
||
|
||
setDeliverables((prev) => [newMeta, ...prev]);
|
||
setDocCache((prev) => ({ ...prev, [newDocId]: parsedAst }));
|
||
setActiveCategory('private');
|
||
setActiveDocId(newDocId);
|
||
|
||
setMessages((prev) => [
|
||
...prev,
|
||
{
|
||
id: 'ast-' + Date.now(),
|
||
sessionId: activeSessionId,
|
||
role: 'assistant',
|
||
authorName: '水木东方协同参谋',
|
||
intentTag: '🔒 个人私密文档已生成',
|
||
content: `收到您的要求,已为您即时生成专属推演文档《${docTitle}》!\n\n根据系统隐私保护机制,该文档已自动存入【我的私密】库,**仅您本人可见**。右侧工作台已为您自动切至该文档,您可以自由查看与编辑。推演成熟后,可随时点击底部「一键共享给项目组」作为团队协作参考资料。`,
|
||
timestamp: new Date().toTimeString().substring(0, 5),
|
||
},
|
||
]);
|
||
return;
|
||
}
|
||
|
||
// 2. 用户发送驳回意图 (且存在处于 reviewing 的目标交付物)
|
||
if (isRejectShortcut) {
|
||
const targetReviewDoc =
|
||
docAST.lifecycleState === 'reviewing'
|
||
? docAST
|
||
: deliverables.find((d) => d.lifecycleState === 'reviewing')
|
||
? (docCache[deliverables.find((d) => d.lifecycleState === 'reviewing')!.docId] || docAST)
|
||
: null;
|
||
|
||
if (targetReviewDoc && targetReviewDoc.lifecycleState === 'reviewing') {
|
||
const matchingBlock = findMatchingBlockForReview(targetReviewDoc);
|
||
const card = createRejectConfirmationCard(targetReviewDoc, matchingBlock);
|
||
|
||
setMessages((prev) => [
|
||
...prev,
|
||
{
|
||
id: 'ast-reject-gate-' + Date.now(),
|
||
sessionId: activeSessionId,
|
||
role: 'assistant',
|
||
authorName: '水木东方协同参谋',
|
||
intentTag: '🛡️ 审核驳回门禁',
|
||
content: `检测到您对《${targetReviewDoc.title}》提出驳回审核意图。驳回将退回编制阶段并解除锁定,同时向编制人下达整改批注。请确认操作:`,
|
||
timestamp: new Date().toTimeString().substring(0, 5),
|
||
interactiveCard: card,
|
||
},
|
||
]);
|
||
return;
|
||
} else {
|
||
setMessages((prev) => [
|
||
...prev,
|
||
{
|
||
id: 'ast-' + Date.now(),
|
||
sessionId: activeSessionId,
|
||
role: 'assistant',
|
||
authorName: '水木东方协同参谋',
|
||
intentTag: '⚠️ 状态校验提示',
|
||
content: `当前受控文档《${docAST.title}》处于【${docAST.lifecycleState}】阶段,非待审核状态,无需执行驳回。`,
|
||
timestamp: new Date().toTimeString().substring(0, 5),
|
||
},
|
||
]);
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (text.includes('修改') || text.includes('更新') || text.includes('调整')) {
|
||
if (docAST.isClaimed && currentDocRole === 'author') {
|
||
setPendingProposal({
|
||
changes: '拟在第2节「监管分类与强制性合规标准矩阵」中,强化补充高频电刀共模抑制比设计指标。',
|
||
impact: '将直接影响电路原理图物料选型与型式检验 EMC 滤波网络参数。',
|
||
risk: '低风险。符合 GB 9706.1-2020 强标,提升系统抗扰度,无侵权风险。',
|
||
});
|
||
|
||
setMessages((prev) => [
|
||
...prev,
|
||
{
|
||
id: 'ast-' + Date.now(),
|
||
sessionId: activeSessionId,
|
||
role: 'assistant',
|
||
authorName: '水木东方协同参谋',
|
||
intentTag: '🛡️ 文档认领保护机制拦截',
|
||
content: `检测到《${docAST.title}》已处于【编制人已认领】状态。根据医疗器械质量体系规范,智能体小队不能擅自修改,已为您生成【变更方案提议】。请您确认是否准许本次修改。`,
|
||
timestamp: new Date().toTimeString().substring(0, 5),
|
||
},
|
||
]);
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (text.includes('通过') || text.includes('同意')) {
|
||
if (docAST.lifecycleState === 'reviewing' && currentDocRole === 'reviewer') {
|
||
handlePassReview();
|
||
setMessages((prev) => [
|
||
...prev,
|
||
{
|
||
id: 'ast-' + Date.now(),
|
||
sessionId: activeSessionId,
|
||
role: 'assistant',
|
||
authorName: '水木东方协同参谋',
|
||
intentTag: '📋 审核通过流转',
|
||
content: `已确认审核通过!《${docAST.title}》状态已流转至【待批准 (approving)】,已呈报王总监终审。`,
|
||
timestamp: new Date().toTimeString().substring(0, 5),
|
||
},
|
||
]);
|
||
return;
|
||
}
|
||
if (docAST.lifecycleState === 'approving' && currentDocRole === 'approver') {
|
||
handleApprove();
|
||
return;
|
||
}
|
||
}
|
||
|
||
// P2:模板填报演示已由真实 task stream 链路替代(见上方 matchedTemplate 分支)
|
||
}, 1000);
|
||
|
||
return { accepted: true };
|
||
};
|
||
|
||
// 编制人接受变更提议
|
||
const handleAcceptProposal = () => {
|
||
setPendingProposal(null);
|
||
const children = docAST.rootBlock.children || [];
|
||
const pBlock = children.find((c) => c.type === 'paragraph');
|
||
if (pBlock) {
|
||
pBlock.content += '\n\n【工程师确认增补】前端光路采集板已加装定制高频共模扼流圈,强标抗扰度提升至最高等级。';
|
||
pBlock.metadata = {
|
||
lastModifiedBy: '智能体协同小队 (经张工确认签署)',
|
||
lastModifiedAt: new Date().toISOString().replace('T', ' ').substring(0, 16),
|
||
};
|
||
setDocAST({ ...docAST });
|
||
}
|
||
|
||
setMessages((prev) => [
|
||
...prev,
|
||
{
|
||
id: 'ast-p-' + Date.now(),
|
||
sessionId: activeSessionId,
|
||
role: 'assistant',
|
||
authorName: '水木东方协同参谋',
|
||
intentTag: '✅ 协同变更已生效',
|
||
content: `变更提议已成功执行并落盘至《${docAST.title}》,受控文档已同步更新。`,
|
||
timestamp: new Date().toTimeString().substring(0, 5),
|
||
},
|
||
]);
|
||
};
|
||
|
||
// 块操作:先 PATCH,成功才提交 AST;失败保留编辑器并明确提示
|
||
const handleSaveBlockEdit = async (updated: DocumentBlock) => {
|
||
const children = docAST.rootBlock.children || [];
|
||
const idx = children.findIndex((c) => c.id === updated.id);
|
||
if (idx === -1) {
|
||
setEditingBlock(null);
|
||
return;
|
||
}
|
||
const nextRoot = {
|
||
...docAST.rootBlock,
|
||
children: children.map((c, i) => (i === idx ? updated : { ...c })),
|
||
};
|
||
recomputeNumbering(nextRoot);
|
||
const nextAST = { ...docAST, rootBlock: nextRoot };
|
||
|
||
const target = deliverables.find((d) => d.docId === activeDocId);
|
||
if (
|
||
target &&
|
||
(target.sourceType === 'uploaded_file' || Boolean(target.fileMeta)) &&
|
||
target.governanceMode === 'freeform' &&
|
||
!docAST.rootBlock?.children?.length
|
||
) {
|
||
setEditingBlock(null);
|
||
return;
|
||
}
|
||
try {
|
||
let etag = '0:0';
|
||
try {
|
||
const st = await getRevisionState(activeDocId);
|
||
etag = st.etag;
|
||
} catch (e) {
|
||
const err = e as Error & { code?: string };
|
||
if (err.code === 'DOCUMENT_REVISION_NEEDS_CONTENT') {
|
||
/* first PATCH bootstraps from markdown body */
|
||
} else if (err.code && err.code !== 'DOCUMENT_REVISION_NOT_FOUND') {
|
||
throw e;
|
||
}
|
||
}
|
||
const markdown = blockASTToMarkdown(nextAST);
|
||
const idempotencyKey =
|
||
(typeof crypto !== 'undefined' && 'randomUUID' in crypto && crypto.randomUUID()) ||
|
||
`edit-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||
const res = await patchDocumentContent(
|
||
activeDocId,
|
||
{
|
||
markdown,
|
||
edit_session_id: `ui-${currentUser.id}`,
|
||
client_block_map: astToClientBlockMap(nextAST),
|
||
},
|
||
{ etag, idempotencyKey },
|
||
);
|
||
const aligned = applyHeadBlockMap(nextAST, res.state?.head_block_map);
|
||
setDocAST(aligned);
|
||
setDocCache((prev) => ({ ...prev, [activeDocId]: aligned }));
|
||
setEditingBlock(null);
|
||
setDocRevConflict(null);
|
||
setDocRevOverlay(res.manual_overlay || null);
|
||
setDocRevRefreshKey((k) => k + 1);
|
||
} catch (e) {
|
||
const err = e as Error & { code?: string; conflict?: { current_revision_no?: number } };
|
||
if (err.code === 'DOCUMENT_HEAD_CONFLICT') {
|
||
setDocRevConflict('保存失败:文档已被他人更新。编辑器仍保留你的未保存修改,请刷新对照后重试。');
|
||
} else {
|
||
setDocRevConflict(`保存失败:${err.message || '请求未成功'}。编辑器仍保留当前修改,请重试。`);
|
||
}
|
||
}
|
||
};
|
||
|
||
const handleInsertBlock = (index: number, position: 'above' | 'below') => {
|
||
const newBlock: DocumentBlock = {
|
||
id: 'blk_' + Math.random().toString(36).substring(2, 9),
|
||
type: 'paragraph',
|
||
content: '【新插入技术要求】:在此填写具体受控技术规范或临床评定指标...',
|
||
metadata: {
|
||
lastModifiedBy: `${currentUser.name} (${currentUser.position})`,
|
||
lastModifiedAt: new Date().toISOString().replace('T', ' ').substring(0, 16),
|
||
},
|
||
};
|
||
const children = docAST.rootBlock.children || [];
|
||
const insertAt = position === 'above' ? index : index + 1;
|
||
children.splice(insertAt, 0, newBlock);
|
||
recomputeNumbering(docAST.rootBlock);
|
||
setDocAST({ ...docAST });
|
||
};
|
||
|
||
const handleMoveBlock = (index: number, direction: 'up' | 'down') => {
|
||
const children = docAST.rootBlock.children || [];
|
||
const targetIdx = direction === 'up' ? index - 1 : index + 1;
|
||
if (targetIdx >= 0 && targetIdx < children.length) {
|
||
const temp = children[index];
|
||
children[index] = children[targetIdx];
|
||
children[targetIdx] = temp;
|
||
recomputeNumbering(docAST.rootBlock);
|
||
setDocAST({ ...docAST });
|
||
}
|
||
};
|
||
|
||
const handleDeleteBlock = (index: number) => {
|
||
const children = docAST.rootBlock.children || [];
|
||
children.splice(index, 1);
|
||
recomputeNumbering(docAST.rootBlock);
|
||
setDocAST({ ...docAST });
|
||
};
|
||
|
||
// 批注操作
|
||
const handleAddComment = (comment: Omit<BlockComment, 'id' | 'createdAt'>) => {
|
||
if (!commentingBlock) return;
|
||
const newCmt: BlockComment = {
|
||
...comment,
|
||
id: 'cmt_' + Math.random().toString(36).substring(2, 8),
|
||
createdAt: new Date().toTimeString().substring(0, 5),
|
||
};
|
||
commentingBlock.comments = [...(commentingBlock.comments || []), newCmt];
|
||
setDocAST({ ...docAST });
|
||
};
|
||
|
||
// 法定生命周期流转
|
||
const handleClaim = () => {
|
||
docAST.isClaimed = true;
|
||
setDocAST({ ...docAST });
|
||
setDeliverables((prev) =>
|
||
prev.map((d) => (d.docId === activeDocId ? { ...d, isClaimed: true } : d))
|
||
);
|
||
};
|
||
|
||
const handleSubmitReview = () => {
|
||
docAST.lifecycleState = 'reviewing';
|
||
setDocAST({ ...docAST });
|
||
setDeliverables((prev) =>
|
||
prev.map((d) => (d.docId === activeDocId ? { ...d, lifecycleState: 'reviewing' } : d))
|
||
);
|
||
};
|
||
|
||
const handlePassReview = () => {
|
||
docAST.lifecycleState = 'approving';
|
||
setDocAST({ ...docAST });
|
||
setDeliverables((prev) =>
|
||
prev.map((d) => (d.docId === activeDocId ? { ...d, lifecycleState: 'approving' } : d))
|
||
);
|
||
};
|
||
|
||
const handleRejectReview = (reason: string) => {
|
||
docAST.lifecycleState = 'drafting';
|
||
setDocAST({ ...docAST });
|
||
setDeliverables((prev) =>
|
||
prev.map((d) => (d.docId === activeDocId ? { ...d, lifecycleState: 'drafting' } : d))
|
||
);
|
||
|
||
setMessages((prev) => [
|
||
...prev,
|
||
{
|
||
id: 'rej-' + Date.now(),
|
||
sessionId: activeSessionId,
|
||
role: 'system',
|
||
content: `⚠️ 李主管(审核负责人)驳回了《${docAST.title}》,已退回编制阶段。驳回原因:${reason}`,
|
||
timestamp: new Date().toTimeString().substring(0, 5),
|
||
},
|
||
]);
|
||
};
|
||
|
||
const handleApprove = () => {
|
||
docAST.lifecycleState = 'approved';
|
||
setDocAST({ ...docAST });
|
||
setDeliverables((prev) =>
|
||
prev.map((d) => (d.docId === activeDocId ? { ...d, lifecycleState: 'approved' } : d))
|
||
);
|
||
|
||
setMessages((prev) => [
|
||
...prev,
|
||
{
|
||
id: 'appr-' + Date.now(),
|
||
sessionId: activeSessionId,
|
||
role: 'assistant',
|
||
authorName: '水木东方协同参谋',
|
||
intentTag: '🏆 终审批准生效',
|
||
content: `🎉 祝贺!王总监(批准人)已终审批准《${docAST.title}》!系统已自动对该受控交付物进行微粒度章节切片与向量化,成功注入全局标杆案例经验库!`,
|
||
timestamp: new Date().toTimeString().substring(0, 5),
|
||
recommendedCases: [
|
||
{
|
||
docId: docAST.docId,
|
||
taskKey: docAST.taskKey,
|
||
artifactTitle: docAST.title,
|
||
sectionTitle: '终审已生效标杆案例',
|
||
similarityScore: 1.0,
|
||
keyRegulations: ['GB 9706.1-2020', 'YY 0569-2011', 'IEC 60601-1-2'],
|
||
summarySnippet: '本交付物已通过编制、审核、批准法定三级签署闭环,作为往期标杆经验向全平台开放自推荐。',
|
||
},
|
||
],
|
||
},
|
||
]);
|
||
};
|
||
|
||
// 导出 DOCX (Word 渲染器)
|
||
const handleExportDocx = async () => {
|
||
try {
|
||
await exportDocumentToDocx(docAST);
|
||
} catch (err) {
|
||
console.error('导出 DOCX 失败:', err);
|
||
}
|
||
};
|
||
|
||
// 导出单条智能体回复 DOCX (轻量专属 Word 导出)
|
||
const handleExportReplyDocx = async (message: ChatMessage) => {
|
||
try {
|
||
await downloadDocxReply(message.content || '', undefined, {
|
||
createdAt: message.createdAt || message.timestamp,
|
||
});
|
||
} catch (err: any) {
|
||
console.error('导出单条回复 DOCX 失败:', err);
|
||
}
|
||
};
|
||
|
||
|
||
// 导出 XLSX (Excel 渲染器)
|
||
const handleExportXlsx = async () => {
|
||
try {
|
||
await exportDocumentToXlsx(docAST);
|
||
} catch (err) {
|
||
console.error('导出 XLSX 失败:', err);
|
||
}
|
||
};
|
||
|
||
// 导出 Markdown 原文
|
||
const handleExportMarkdown = () => {
|
||
const md = blockASTToMarkdown(docAST);
|
||
const blob = new Blob([md], { type: 'text/markdown;charset=utf-8' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `${docAST.title}_${docAST.version}_受控交付物.md`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
};
|
||
|
||
// 测试演练:动态修改当前用户在某个文档的权限角色
|
||
const handleRoleChangeForDoc = (docId: string, role: DocPermissionRole) => {
|
||
setCurrentUser((prev) => ({
|
||
...prev,
|
||
projectPermissions: {
|
||
...(prev.projectPermissions || {}),
|
||
[docId]: role,
|
||
},
|
||
}));
|
||
};
|
||
|
||
// 未登录拦截门禁:展示现代化医疗合规登录与注册页面
|
||
if (!session) {
|
||
return (
|
||
<LoginView
|
||
onLoginSuccess={(newSession) => {
|
||
setSession(newSession);
|
||
setSessionValidated(true);
|
||
setCapabilities(newSession.capabilities ?? []);
|
||
if (newSession.env) setSystemEnv(newSession.env);
|
||
const match = allUsers.find((u) => u.id === newSession.user.id);
|
||
setCurrentUser(mergeSessionUser(newSession.user, match));
|
||
}}
|
||
systemConfig={systemAuthConfig}
|
||
env={systemEnv}
|
||
/>
|
||
);
|
||
}
|
||
|
||
// 发起医疗器械新项目立项 (通过 Modal 交互收集名称、品类与初始成员)
|
||
const handleCreateProjectConfirm = async (data: {
|
||
title: string;
|
||
deviceType: string;
|
||
description: string;
|
||
initialMembers: Array<{
|
||
userId: number;
|
||
userName: string;
|
||
department?: string;
|
||
position?: string;
|
||
role: ProjectMemberRole;
|
||
}>;
|
||
}) => {
|
||
setCreateProjectError(null);
|
||
|
||
let newProjId: number;
|
||
try {
|
||
const created = await createProject({
|
||
name: data.title,
|
||
device_type: data.deviceType,
|
||
intended_use: data.description,
|
||
});
|
||
newProjId = created.id;
|
||
} catch (err) {
|
||
const message = err instanceof Error ? err.message : '创建项目失败,请稍后重试';
|
||
setCreateProjectError(message);
|
||
return;
|
||
}
|
||
|
||
const nowStr = new Date().toLocaleString();
|
||
|
||
// 1. 构建项目与成员元数据
|
||
const members: ProjectMember[] = data.initialMembers.map((m) => ({
|
||
userId: m.userId,
|
||
userName: m.userName,
|
||
department: m.department,
|
||
position: m.position,
|
||
role: m.role,
|
||
status: 'active',
|
||
joinedAt: nowStr,
|
||
}));
|
||
|
||
const newProject: ProjectInfo = {
|
||
id: newProjId,
|
||
title: data.title,
|
||
deviceType: data.deviceType,
|
||
description: data.description,
|
||
creatorId: currentUser.id,
|
||
creatorName: currentUser.name,
|
||
createdAt: nowStr,
|
||
members,
|
||
};
|
||
|
||
// 2. 为每个选中的项目成员生成独立私聊会话
|
||
const newSessionsToAppend: ProjectSession[] = [];
|
||
const newMessagesToAppend: ChatMessage[] = [];
|
||
let creatorSessionId = '';
|
||
|
||
members.forEach((m) => {
|
||
const sessId = buildPrivateSessionId(`p${newProjId}`, m.userId);
|
||
if (m.userId === currentUser.id) {
|
||
creatorSessionId = sessId;
|
||
}
|
||
newSessionsToAppend.push({
|
||
sessionId: sessId,
|
||
userId: m.userId,
|
||
projectId: newProjId,
|
||
title: data.title,
|
||
deviceType: data.deviceType,
|
||
stage: 'V0.1',
|
||
stageLabel: 'V0.1 需求摸底',
|
||
messageCount: 1,
|
||
updatedAt: '刚刚',
|
||
});
|
||
|
||
newMessagesToAppend.push({
|
||
id: `m-init-${m.userId}-${Date.now()}`,
|
||
sessionId: sessId,
|
||
role: 'system',
|
||
content:
|
||
m.userId === currentUser.id
|
||
? `【立项成功】项目《${data.title}》已成功建立!全套研发工作流与阶段文档模板已克隆就绪,协同参谋小队已就位,进入【V0.1 需求摸底】阶段。您可以点击附件图标上传临床需求 URS,或直接输入您的产品构想。`
|
||
: `【项目协同通知】您已被项目创建人 ${currentUser.name} 邀请加入《${data.title}》,并分配法定职责为【${m.role}】。`,
|
||
timestamp: new Date().toTimeString().substring(0, 5),
|
||
});
|
||
});
|
||
|
||
// 3. 克隆全生命周期 7 份受控模板副本
|
||
const { deliverables: clonedDocs, docASTMap: clonedAsts } = createProjectTemplateDeliverables({
|
||
projectId: newProjId,
|
||
projectName: data.title,
|
||
deviceType: data.deviceType,
|
||
creatorId: currentUser.id,
|
||
creatorName: currentUser.name,
|
||
});
|
||
|
||
setProjects((prev) => [newProject, ...prev]);
|
||
setSessions((prev) => [...newSessionsToAppend, ...prev]);
|
||
setMessages((prev) => [...prev, ...newMessagesToAppend]);
|
||
|
||
void Promise.all(
|
||
newSessionsToAppend.map((sessionItem) =>
|
||
upsertChatSession({
|
||
session_id: sessionItem.sessionId,
|
||
title: sessionItem.title,
|
||
user_id: sessionItem.userId ?? currentUser.id,
|
||
project_id: sessionItem.projectId,
|
||
})
|
||
)
|
||
).catch((err) => {
|
||
console.warn('[sessions] 服务端会话注册失败,其他设备可能暂不可见:', err);
|
||
});
|
||
setDeliverables((prev) => [...prev, ...clonedDocs]);
|
||
setDocCache((prev) => ({ ...prev, ...clonedAsts }));
|
||
|
||
if (creatorSessionId) {
|
||
setActiveSessionId(creatorSessionId);
|
||
}
|
||
setActiveDocId(clonedDocs[0].docId);
|
||
setActiveCategory('workflow');
|
||
setCreateProjectModalOpen(false);
|
||
setCreateProjectError(null);
|
||
};
|
||
|
||
// 添加成员(持久化至服务端 project_members)
|
||
const handleAddProjectMember = async (newMember: Omit<ProjectMember, 'joinedAt' | 'status'>) => {
|
||
if (!currentProject) return;
|
||
try {
|
||
await addProjectMember(currentProject.id, {
|
||
user_id: newMember.userId,
|
||
project_role: toApiProjectRole(newMember.role, newMember.userId, currentProject.creatorId),
|
||
});
|
||
await syncProjectMembersFromServer(currentProject.id, currentProject.creatorId);
|
||
|
||
const newSessId = buildPrivateSessionId(`p${currentProject.id}`, newMember.userId);
|
||
setSessions((prev) => {
|
||
const exists = prev.some(
|
||
(s) => s.projectId === currentProject.id && s.userId === newMember.userId
|
||
);
|
||
if (exists) return prev;
|
||
return [
|
||
{
|
||
sessionId: newSessId,
|
||
userId: newMember.userId,
|
||
projectId: currentProject.id,
|
||
title: currentProject.title,
|
||
deviceType: currentProject.deviceType,
|
||
stage: 'V0.1',
|
||
stageLabel: 'V0.1 需求摸底',
|
||
messageCount: 1,
|
||
updatedAt: '刚刚',
|
||
},
|
||
...prev,
|
||
];
|
||
});
|
||
|
||
setMessages((prev) => [
|
||
...prev,
|
||
{
|
||
id: `m-join-${newMember.userId}-${Date.now()}`,
|
||
sessionId: newSessId,
|
||
role: 'system',
|
||
content: `【项目协同通知】您已被项目创建人 ${currentProject.creatorName} 加入项目《${currentProject.title}》,并分配项目角色为【${newMember.role}】。`,
|
||
timestamp: new Date().toTimeString().substring(0, 5),
|
||
},
|
||
]);
|
||
} catch (err) {
|
||
setProjectMembersError(err instanceof Error ? err.message : '添加成员失败');
|
||
throw err;
|
||
}
|
||
};
|
||
|
||
const handleUpdateProjectMemberRole = async (userId: number, newRole: ProjectMemberRole) => {
|
||
if (!currentProject) return;
|
||
try {
|
||
await updateProjectMember(currentProject.id, {
|
||
user_id: userId,
|
||
project_role: toApiProjectRole(newRole, userId, currentProject.creatorId),
|
||
});
|
||
await syncProjectMembersFromServer(currentProject.id, currentProject.creatorId);
|
||
} catch (err) {
|
||
setProjectMembersError(err instanceof Error ? err.message : '更新成员角色失败');
|
||
}
|
||
};
|
||
|
||
const handleRemoveProjectMember = async (userId: number) => {
|
||
if (!currentProject) return;
|
||
try {
|
||
await removeProjectMember(currentProject.id, userId);
|
||
await syncProjectMembersFromServer(currentProject.id, currentProject.creatorId);
|
||
} catch (err) {
|
||
setProjectMembersError(err instanceof Error ? err.message : '移出成员失败');
|
||
}
|
||
};
|
||
|
||
const handleRestoreProjectMember = async (userId: number) => {
|
||
if (!currentProject) return;
|
||
try {
|
||
const member = currentProject.members.find((m) => m.userId === userId);
|
||
await updateProjectMember(currentProject.id, {
|
||
user_id: userId,
|
||
project_role: toApiProjectRole(
|
||
member?.role ?? 'member',
|
||
userId,
|
||
currentProject.creatorId
|
||
),
|
||
status: 'active',
|
||
});
|
||
await syncProjectMembersFromServer(currentProject.id, currentProject.creatorId);
|
||
} catch (err) {
|
||
setProjectMembersError(err instanceof Error ? err.message : '恢复成员失败');
|
||
}
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<AppShell
|
||
sessions={visibleSessions}
|
||
activeSessionId={activeSessionId}
|
||
onSelectSession={(sessId) => {
|
||
if (window.location.hash) {
|
||
history.replaceState(null, '', window.location.pathname + window.location.search);
|
||
}
|
||
setActiveSessionId(sessId);
|
||
const targetSess = sessions.find((s) => s.sessionId === sessId);
|
||
if (targetSess && targetSess.projectId !== currentProjectId) {
|
||
const targetProjDocs = deliverables.filter((d) => d.projectId === targetSess.projectId);
|
||
if (targetProjDocs.length > 0) {
|
||
setActiveDocId(targetProjDocs[0].docId);
|
||
setActiveCategory(targetProjDocs[0].category);
|
||
}
|
||
}
|
||
}}
|
||
onNewSession={() => setCreateProjectModalOpen(true)}
|
||
canCreateProject={canCreateProject}
|
||
currentUser={currentUser}
|
||
currentRole={currentDocRole}
|
||
currentUserName={currentUser.name}
|
||
onChangeRole={(_, __) => {}}
|
||
onOpenRightsModal={() => setRightsModalOpen(true)}
|
||
onOpenUserSettings={() => setUserSettingsOpen(true)}
|
||
onOpenAdminSettings={() => setAdminSettingsOpen(true)}
|
||
onOpenProjectMembers={() => setProjectMembersModalOpen(true)}
|
||
activeWorkflowKey={currentWfState.activeKey}
|
||
workflowVersion={currentWfState.version}
|
||
lifecycleStatus={currentWfState.lifecycleStatus}
|
||
isCreator={currentWfState.creatorId === currentUser.id}
|
||
governanceEnabled={workflowGovernanceEnabled}
|
||
onOpenProjectSettings={() => setProjectSettingsOpen(true)}
|
||
onWorkflowChanged={(newKey, newVer) => {
|
||
setProjectWorkflowsState((prev) => ({
|
||
...prev,
|
||
[currentProjectId]: {
|
||
...currentWfState,
|
||
activeKey: newKey,
|
||
version: newVer,
|
||
lifecycleStatus: 'active',
|
||
},
|
||
}));
|
||
}}
|
||
workflowBanner={workflowBanner}
|
||
chatContent={
|
||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||
{/* P1, P2, P3: 顶部分类统计过滤栏、历史日历、三模搜索、智能摘要、导出与未读角标 */}
|
||
<CategoryFilterBar
|
||
categories={categoryStats}
|
||
selectedCategory={chatWindow.selectedCategory}
|
||
onSelectCategory={chatWindow.setSelectedCategory}
|
||
onNavigateToCategory={(_cat, lastMessageId) => {
|
||
if (lastMessageId && activeSessionId) {
|
||
const anchorId = resolveTurnAnchorId(lastMessageId, chatWindow.allMessages);
|
||
navigateToAnchor(
|
||
{ sessionId: activeSessionId, messageId: anchorId, highlightMs: 2000 },
|
||
{ sessionId: activeSessionId, loadDayForAnchor: chatWindow.loadDayForAnchor }
|
||
);
|
||
}
|
||
}}
|
||
onToggleCalendar={() => setIsCalendarOpen((prev) => !prev)}
|
||
isCalendarOpen={isCalendarOpen}
|
||
onOpenSearch={() => setIsSearchOpen(true)}
|
||
onOpenDigest={() => setIsDigestOpen(true)}
|
||
onOpenExport={() => setIsExportOpen(true)}
|
||
unreadTotal={unreadTotal}
|
||
/>
|
||
{isCalendarOpen && (
|
||
<HistoryCalendar
|
||
dateStats={dateStats}
|
||
unreadMarks={unreadMarks}
|
||
onSelectDate={(date) => {
|
||
chatWindow.jumpToDate(date);
|
||
setIsCalendarOpen(false);
|
||
}}
|
||
onClose={() => setIsCalendarOpen(false)}
|
||
/>
|
||
)}
|
||
{isSearchOpen && (
|
||
<ChatSearchModal
|
||
sessionId={activeSessionId}
|
||
onSelectHit={(hit) => {
|
||
navigateToAnchor(
|
||
{ sessionId: activeSessionId, messageId: hit.message_id },
|
||
{ sessionId: activeSessionId, loadDayForAnchor: chatWindow.loadDayForAnchor }
|
||
);
|
||
setIsSearchOpen(false);
|
||
}}
|
||
onClose={() => setIsSearchOpen(false)}
|
||
/>
|
||
)}
|
||
{isDigestOpen && (
|
||
<DigestTimelineModal
|
||
sessionId={activeSessionId}
|
||
onJumpToMessage={(messageId) => {
|
||
navigateToAnchor(
|
||
{ sessionId: activeSessionId, messageId },
|
||
{ sessionId: activeSessionId, loadDayForAnchor: chatWindow.loadDayForAnchor }
|
||
);
|
||
setIsDigestOpen(false);
|
||
}}
|
||
onClose={() => setIsDigestOpen(false)}
|
||
/>
|
||
)}
|
||
{isExportOpen && (
|
||
<ChatExportModal
|
||
isOpen={isExportOpen}
|
||
onClose={() => setIsExportOpen(false)}
|
||
sessionId={activeSessionId}
|
||
dayStack={chatWindow.displayDayStack}
|
||
/>
|
||
)}
|
||
<div style={{ position: 'relative', flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||
<MessageStream
|
||
dayStack={chatWindow.displayDayStack}
|
||
isLoadingOlder={chatWindow.phase === 'loading_older'}
|
||
hasMoreBefore={chatWindow.hasMoreBefore}
|
||
onLoadOlder={chatWindow.loadOlder}
|
||
onLoadFullDay={chatWindow.loadFullDay}
|
||
onOpenCase={handleSelectDoc}
|
||
onCardAction={handleCardAction}
|
||
onErrorAction={handleErrorAction}
|
||
onQuote={handleQuoteMessage}
|
||
onNavigateToAnchor={(targetAnchor) => {
|
||
navigateToAnchor(
|
||
{ sessionId: activeSessionId, messageId: targetAnchor },
|
||
{ sessionId: activeSessionId, loadDayForAnchor: chatWindow.loadDayForAnchor }
|
||
);
|
||
}}
|
||
selectedCategory={chatWindow.selectedCategory}
|
||
unreadMessageIds={unreadMessageIds}
|
||
onSaveToPrivate={handleSaveMessageToPrivate}
|
||
onExportDocx={handleExportReplyDocx}
|
||
onOpenDoc={handleSelectDoc}
|
||
/>
|
||
<CategoryDock
|
||
categories={categoryStats}
|
||
activeCategory={chatWindow.selectedCategory}
|
||
messages={chatWindow.allMessages}
|
||
unreadTotal={unreadTotal}
|
||
unreadMessageIds={unreadMessageIds}
|
||
isAllReadCompleted={isAllReadCompleted}
|
||
onConsumeNextUnread={async () => {
|
||
const targetId = await consumeNextUnread();
|
||
if (targetId && activeSessionId) {
|
||
navigateToAnchor(
|
||
{ sessionId: activeSessionId, messageId: targetId, highlightMs: 2000 },
|
||
{ sessionId: activeSessionId, loadDayForAnchor: chatWindow.loadDayForAnchor }
|
||
);
|
||
}
|
||
}}
|
||
onNavigateToCategory={(_cat, lastMessageId) => {
|
||
if (lastMessageId && activeSessionId) {
|
||
const anchorId = resolveTurnAnchorId(lastMessageId, chatWindow.allMessages);
|
||
navigateToAnchor(
|
||
{ sessionId: activeSessionId, messageId: anchorId, highlightMs: 2000 },
|
||
{ sessionId: activeSessionId, loadDayForAnchor: chatWindow.loadDayForAnchor }
|
||
);
|
||
}
|
||
}}
|
||
onSelectCategory={chatWindow.setSelectedCategory}
|
||
/>
|
||
</div>
|
||
{pendingProposal && (
|
||
<div style={{ padding: '0 16px' }}>
|
||
<ProposalConfirmCard
|
||
proposedChanges={pendingProposal.changes}
|
||
impactAnalysis={pendingProposal.impact}
|
||
potentialRisk={pendingProposal.risk}
|
||
onAccept={handleAcceptProposal}
|
||
onReject={() => setPendingProposal(null)}
|
||
/>
|
||
</div>
|
||
)}
|
||
<InputArea
|
||
onSendMessage={handleSendMessage}
|
||
isThinking={isThinking}
|
||
isStreaming={isStreamingActive}
|
||
onStopStreaming={handleStopStreaming}
|
||
quotes={activeQuotes}
|
||
onRemoveQuote={handleRemoveQuote}
|
||
disabled={isCurrentProjectExited}
|
||
disabledMessage="您已退出该研发项目。依据受控体系审计要求,会话已转为只读留痕模式,无法继续发送消息。"
|
||
/>
|
||
</div>
|
||
}
|
||
docContent={
|
||
isCurrentProjectExited ? (
|
||
<div
|
||
style={{
|
||
flex: 1,
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
height: '100%',
|
||
padding: '40px 30px',
|
||
background: 'var(--bg-subtle, #f8fafc)',
|
||
textAlign: 'center',
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
width: '64px',
|
||
height: '64px',
|
||
borderRadius: '16px',
|
||
background: 'rgba(239, 68, 68, 0.1)',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
color: '#dc2626',
|
||
marginBottom: '16px',
|
||
}}
|
||
>
|
||
<ShieldAlert size={32} />
|
||
</div>
|
||
<h3 style={{ margin: '0 0 8px', fontSize: '18px', fontWeight: 600, color: 'var(--text-strong, #0f172a)' }}>
|
||
受控文档已执行合规脱密隔离
|
||
</h3>
|
||
<p
|
||
style={{
|
||
margin: 0,
|
||
maxWidth: '440px',
|
||
fontSize: '13px',
|
||
color: 'var(--text-muted, #64748b)',
|
||
lineHeight: 1.6,
|
||
}}
|
||
>
|
||
您已退出该研发项目。依据医疗器械质量管理体系 (GMP/ISO 13485) 及商业保密规程,本项目技术规格书、设计输入草案与验证交付物已对您执行访问切断。历史聊天记录仅供审计留痕查阅。
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden', position: 'relative' }}>
|
||
{/* 36px 极简 Tab 条 (含三分类分段切换与私密/共享识别) */}
|
||
<SlimTabBar
|
||
deliverables={visibleDeliverables}
|
||
activeDocId={activeDocId}
|
||
activeCategory={activeCategory}
|
||
onSelectCategory={setActiveCategory}
|
||
onSelectDoc={handleSelectDoc}
|
||
onToggleDeckDrawer={() => setDeckDrawerOpen(!deckDrawerOpen)}
|
||
onExportDocx={handleExportDocx}
|
||
onExportXlsx={handleExportXlsx}
|
||
onExportMarkdown={handleExportMarkdown}
|
||
/>
|
||
{(() => {
|
||
const revDoc =
|
||
deliverables.find((d) => d.docId === activeDocId) ||
|
||
visibleDeliverables.find((d) => d.docId === activeDocId);
|
||
const mode = revisionToolbarMountMode(
|
||
revDoc ?? { docId: activeDocId, isTemplateOnly: true },
|
||
);
|
||
if (mode === 'toolbar' && revDoc) {
|
||
return (
|
||
<DocumentRevisionToolbar
|
||
docId={activeDocId}
|
||
overlayMode={docRevOverlayMode}
|
||
onOverlayModeChange={setDocRevOverlayMode}
|
||
onOverlay={setDocRevOverlay}
|
||
revisionRefreshKey={docRevRefreshKey}
|
||
/>
|
||
);
|
||
}
|
||
const hint = documentRevisionInactiveHint(
|
||
revDoc ?? { docId: activeDocId, isTemplateOnly: true },
|
||
);
|
||
if (!hint) return null;
|
||
return (
|
||
<div
|
||
data-testid="doc-rev-template-hint"
|
||
className="doc-rev-template-hint"
|
||
style={{
|
||
padding: '6px 12px',
|
||
borderBottom: '1px solid var(--border, #ddd)',
|
||
fontSize: 12,
|
||
color: 'var(--text-muted, #64748b)',
|
||
background: 'var(--bg-subtle, #f8fafc)',
|
||
}}
|
||
>
|
||
{hint}
|
||
</div>
|
||
);
|
||
})()}
|
||
{docRevConflict && (
|
||
<div role="alert" style={{ padding: '8px 12px', background: '#fff3cd', borderBottom: '1px solid #e0c36a' }}>
|
||
{docRevConflict}
|
||
<button type="button" style={{ marginLeft: 8 }} onClick={() => window.location.reload()}>
|
||
刷新后重做
|
||
</button>
|
||
</div>
|
||
)}
|
||
{docRevOverlayMode === 'manual' && docRevOverlay && docRevOverlay.hunks?.length > 0 && (
|
||
<div style={{ fontSize: 12, padding: '4px 12px', opacity: 0.85 }}>
|
||
人工差异 {docRevOverlay.summary.modifies + docRevOverlay.summary.inserts + docRevOverlay.summary.deletes} 处
|
||
</div>
|
||
)}
|
||
|
||
{/* 全景大盘抽屉 (支持受控/共享/私密三板块与一键流转操作) */}
|
||
<DeckDrawer
|
||
isOpen={deckDrawerOpen}
|
||
onClose={() => setDeckDrawerOpen(false)}
|
||
deliverables={visibleDeliverables}
|
||
activeDocId={activeDocId}
|
||
currentUserId={currentUser.id}
|
||
onSelectDoc={handleSelectDoc}
|
||
onShareToProject={handleShareToProject}
|
||
onRevokeShare={handleRevokeShare}
|
||
onDeleteDoc={handleDeleteDoc}
|
||
onOpenDocVisibility={(doc) => {
|
||
setTargetDocForVisibility(doc);
|
||
setDocVisibilityModalOpen(true);
|
||
}}
|
||
/>
|
||
|
||
{/* 核心文档内容渲染区:用户上传文档使用高保真文件预览器,工作流及对话入库文档使用结构化 BlockRenderer */}
|
||
{(() => {
|
||
const activeDeliverable = deliverables.find((d) => d.docId === activeDocId);
|
||
const isUploadedFile = Boolean(
|
||
activeDeliverable &&
|
||
(activeDeliverable.sourceType === 'uploaded_file' ||
|
||
Boolean(activeDeliverable.fileMeta) ||
|
||
activeDeliverable.docId.startsWith('doc-upload-') ||
|
||
Boolean(activeDeliverable.title?.match(/\.(docx?|pdf|xlsx?|csv|pptx?|png|jpe?g|svg|webp|txt|md|json)$/i)) ||
|
||
(activeDeliverable.taskKey === 'user_requirements' &&
|
||
!activeDeliverable.isFixedWorkflow &&
|
||
(activeDeliverable.category === 'private' || activeDeliverable.category === 'shared')))
|
||
);
|
||
if (isUploadedFile && activeDeliverable) {
|
||
return (
|
||
<FilePreviewPanel
|
||
deliverable={activeDeliverable}
|
||
onOpenDocVisibility={() => {
|
||
setTargetDocForVisibility(activeDeliverable);
|
||
setDocVisibilityModalOpen(true);
|
||
}}
|
||
/>
|
||
);
|
||
}
|
||
return (
|
||
<>
|
||
{docLoadErrors[activeDocId] ? (
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
gap: 12,
|
||
padding: '10px 16px',
|
||
background: 'rgba(180, 60, 40, 0.08)',
|
||
borderBottom: '1px solid rgba(180, 60, 40, 0.2)',
|
||
fontSize: 13,
|
||
}}
|
||
>
|
||
<span>受控文档正文加载失败,请检查网络后重试。</span>
|
||
<button
|
||
type="button"
|
||
onClick={retryControlledDocLoad}
|
||
style={{
|
||
border: '1px solid rgba(180, 60, 40, 0.35)',
|
||
background: '#fff',
|
||
borderRadius: 6,
|
||
padding: '4px 12px',
|
||
cursor: 'pointer',
|
||
}}
|
||
>
|
||
点击重试
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
<BlockRenderer
|
||
ast={docAST}
|
||
currentRole={currentDocRole}
|
||
onEditBlock={setEditingBlock}
|
||
onInsertBlock={handleInsertBlock}
|
||
onMoveBlock={handleMoveBlock}
|
||
onDeleteBlock={handleDeleteBlock}
|
||
onOpenComments={setCommentingBlock}
|
||
overlayMode={docRevOverlayMode}
|
||
overlayHunks={docRevOverlay?.hunks || []}
|
||
onAskAgent={(snippet) => {
|
||
handleSendMessage(
|
||
`针对受控文档《${docAST.title}》中的条款内容:\n\n“${snippet.slice(0, 150)}${snippet.length > 150 ? '...' : ''}”\n\n请帮我审查其合规性、技术可行性或提出修改建议。`
|
||
);
|
||
}}
|
||
highlightedBlockId={highlightedBlockId}
|
||
/>
|
||
</>
|
||
);
|
||
})()}
|
||
|
||
{/* 底部流转动作条 (受控流程文档使用 DocGovActionBar,自由私密/共享文档使用 FreeformDocumentActionBar) */}
|
||
{docAST.governanceMode === 'freeform' || docAST.space === 'my_private' || docAST.space === 'project_shared' || docAST.category === 'private' || docAST.category === 'shared' ? (
|
||
<FreeformDocumentActionBar
|
||
ast={docAST}
|
||
currentUserId={currentUser.id}
|
||
onShareToProject={handleShareToProject}
|
||
onRevokeShare={handleRevokeShare}
|
||
onDeleteDoc={handleDeleteDoc}
|
||
onCopyRich={() => copyRichReplyToClipboard(blockASTToMarkdown(docAST))}
|
||
onCopyMarkdown={() => copyMarkdownReply(blockASTToMarkdown(docAST))}
|
||
onDownloadMarkdown={() => downloadMarkdownReply(blockASTToMarkdown(docAST), docAST.title)}
|
||
onDownloadDocx={handleExportDocx}
|
||
/>
|
||
) : (
|
||
<DocGovActionBar
|
||
ast={docAST}
|
||
currentRole={currentDocRole}
|
||
currentUserId={currentUser.id}
|
||
canOverrideVisibility={canOverrideVisibility}
|
||
onOpenVisibilityModal={() => {
|
||
const currentDoc = deliverables.find((d) => d.docId === activeDocId) || null;
|
||
setTargetDocForVisibility(currentDoc);
|
||
setDocVisibilityModalOpen(true);
|
||
}}
|
||
onClaim={handleClaim}
|
||
onSubmitReview={handleSubmitReview}
|
||
onPassReview={handlePassReview}
|
||
onRejectReview={handleRejectReview}
|
||
onApprove={handleApprove}
|
||
onRejectApprove={handleRejectReview}
|
||
onShareToProject={handleShareToProject}
|
||
onRevokeShare={handleRevokeShare}
|
||
/>
|
||
)}
|
||
|
||
{/* 块就地编辑弹窗 (支持富文本工具栏、多维表格与未保存拦截) */}
|
||
{editingBlock && (
|
||
<BlockEditorModal
|
||
block={editingBlock}
|
||
actorLabel={`${currentUser.name} (${currentUser.position || '成员'})`}
|
||
onSave={handleSaveBlockEdit}
|
||
onClose={() => setEditingBlock(null)}
|
||
/>
|
||
)}
|
||
|
||
{/* 审核批注侧栏 */}
|
||
{commentingBlock && (
|
||
<CommentSidePanel
|
||
block={commentingBlock}
|
||
currentRole={currentDocRole}
|
||
currentUserName={currentUser.name}
|
||
onAddComment={handleAddComment}
|
||
onClose={() => setCommentingBlock(null)}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
/>
|
||
|
||
{/* 用户在当前激活项目中的法定职责与权限矩阵弹窗 */}
|
||
<UserProjectRightsModal
|
||
isOpen={rightsModalOpen}
|
||
onClose={() => setRightsModalOpen(false)}
|
||
currentUser={currentUser}
|
||
currentProjectTitle={sessions.find((s) => s.sessionId === activeSessionId)?.title || '当前项目'}
|
||
deliverables={visibleDeliverables}
|
||
activeDocId={activeDocId}
|
||
canAssignLead={canAssignLead}
|
||
onSelectDoc={handleSelectDoc}
|
||
onRoleChangeForDoc={handleRoleChangeForDoc}
|
||
/>
|
||
|
||
{/* 首次登录强制改密阻断弹窗 */}
|
||
<FirstLoginPasswordModal
|
||
isOpen={!!session?.mustChangePassword}
|
||
user={currentUser}
|
||
onPasswordChanged={() => {
|
||
const updated = { ...session, mustChangePassword: false };
|
||
setSession(updated);
|
||
authClient.saveSession(updated);
|
||
}}
|
||
onLogout={handleLogout}
|
||
/>
|
||
|
||
{/* 用户个人设置与偏好中心 (全员可用) */}
|
||
<UserSettingsModal
|
||
isOpen={userSettingsOpen}
|
||
onClose={() => setUserSettingsOpen(false)}
|
||
currentUser={currentUser}
|
||
roles={roles}
|
||
onUpdateProfile={(updatedProfile) => {
|
||
setAllUsers((prev) =>
|
||
prev.map((u) => (u.id === currentUser.id ? { ...u, ...updatedProfile } : u))
|
||
);
|
||
setCurrentUser((prev) => ({ ...prev, ...updatedProfile }));
|
||
}}
|
||
onLogout={handleLogout}
|
||
/>
|
||
|
||
{/* 系统管理员独占治理控制台 (仅 role-admin 可入) */}
|
||
<AdminSettingsModal
|
||
isOpen={adminSettingsOpen}
|
||
onClose={() => setAdminSettingsOpen(false)}
|
||
currentUser={currentUser}
|
||
roles={roles}
|
||
onSaveRoles={(newRoles) => setRoles(newRoles)}
|
||
allUsers={allUsers}
|
||
onUpdateUserRole={(userId, roleId) => {
|
||
const targetRole = roles.find((r) => r.id === roleId);
|
||
setAllUsers((prev) =>
|
||
prev.map((u) => (u.id === userId ? { ...u, roleId, position: targetRole?.name || u.position } : u))
|
||
);
|
||
if (currentUser.id === userId) {
|
||
setCurrentUser((prev) => ({ ...prev, roleId, position: targetRole?.name || prev.position }));
|
||
}
|
||
}}
|
||
systemConfig={systemAuthConfig}
|
||
onUpdateSystemConfig={(cfg) => setSystemAuthConfig((prev) => ({ ...prev, ...cfg }))}
|
||
onRefreshUsers={async () => {
|
||
const users = await authClient.getAdminUsers();
|
||
setAllUsers(users);
|
||
}}
|
||
env={systemEnv}
|
||
/>
|
||
|
||
{/* 单文档可见性覆盖弹窗 */}
|
||
<DocVisibilityModal
|
||
isOpen={docVisibilityModalOpen}
|
||
onClose={() => setDocVisibilityModalOpen(false)}
|
||
doc={targetDocForVisibility}
|
||
roles={roles}
|
||
currentUserName={currentUser.name}
|
||
onSaveRule={handleSaveDocVisibilityRule}
|
||
/>
|
||
|
||
{/* 发起医疗器械新项目立项弹窗 */}
|
||
<CreateProjectModal
|
||
isOpen={createProjectModalOpen}
|
||
onClose={() => {
|
||
setCreateProjectModalOpen(false);
|
||
setCreateProjectError(null);
|
||
}}
|
||
onSubmit={handleCreateProjectConfirm}
|
||
submitError={createProjectError}
|
||
currentUser={currentUser}
|
||
availableUsers={allUsers}
|
||
/>
|
||
|
||
{/* 项目成员与职责分配管理弹窗 */}
|
||
<ProjectMembersModal
|
||
isOpen={projectMembersModalOpen}
|
||
onClose={() => setProjectMembersModalOpen(false)}
|
||
project={currentProject}
|
||
currentUser={currentUser}
|
||
availableUsers={allUsers}
|
||
canManage={canManageProjectMembers}
|
||
membersError={projectMembersError}
|
||
onAddMember={handleAddProjectMember}
|
||
onUpdateMemberRole={handleUpdateProjectMemberRole}
|
||
onRemoveMember={handleRemoveProjectMember}
|
||
onRestoreMember={handleRestoreProjectMember}
|
||
/>
|
||
|
||
<ProjectSettingsModal
|
||
open={projectSettingsOpen}
|
||
projectId={currentProjectId}
|
||
projectName={currentActiveSession?.title || '当前项目'}
|
||
members={(currentProject?.members || [])
|
||
.filter((m) => m.status === 'active')
|
||
.map((m) => ({
|
||
userId: m.userId,
|
||
name: allUsers.find((u) => u.id === m.userId)?.name || `用户 ${m.userId}`,
|
||
}))}
|
||
onClose={() => setProjectSettingsOpen(false)}
|
||
onActivated={(activeKey) => {
|
||
setProjectWorkflowsState((prev) => ({
|
||
...prev,
|
||
[currentProjectId]: {
|
||
...currentWfState,
|
||
activeKey,
|
||
lifecycleStatus: 'active',
|
||
},
|
||
}));
|
||
}}
|
||
/>
|
||
</>
|
||
);
|
||
}
|