refactor(web): render the active document column outside App
The revision toolbar, file preview, and governance bars stay with the document pane so the workbench shell no longer owns that column. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+51
-307
@@ -1,20 +1,10 @@
|
||||
import { useState, useMemo, useEffect, useRef, useCallback } from 'react';
|
||||
import { AppShell } from './components/layout/AppShell';
|
||||
import { DocumentPane } from './components/doc/DocumentPane';
|
||||
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 { DocumentRevisionToolbar } from './components/doc/DocumentRevisionToolbar';
|
||||
import { CommentSidePanel } from './components/doc/CommentSidePanel';
|
||||
import { DocGovActionBar } from './components/doc/DocGovActionBar';
|
||||
import { FreeformDocumentActionBar } from './components/doc/FreeformDocumentActionBar';
|
||||
import { fetchFreeformDoc } from './api/freeformDoc';
|
||||
import type { DiffResult, OverlayMode } from './features/documentRevision/types';
|
||||
import {
|
||||
documentRevisionInactiveHint,
|
||||
revisionToolbarMountMode,
|
||||
} from './features/documentRevision/revisionToolbarMount';
|
||||
import { fetchProjectLineage, getProject } from './api/projectWorkflow';
|
||||
import { listProjectMembers } from './api/projectMembers';
|
||||
import {
|
||||
@@ -45,8 +35,6 @@ import {
|
||||
import { DocumentLineage } from './types/projectWorkflow';
|
||||
import { StaleDocumentNotice } from './components/workflow/StaleDocumentNotice';
|
||||
import { StageAdvanceBanner } from './components/workflow/StageAdvanceBanner';
|
||||
import { copyRichReplyToClipboard, copyMarkdownReply } from './features/replyExport/clipboard';
|
||||
import { downloadMarkdownReply } from './features/replyExport/download';
|
||||
import { ProposalConfirmCard } from './components/doc/ProposalConfirmCard';
|
||||
import { UserProjectRightsModal } from './components/user/UserProjectRightsModal';
|
||||
import { UserSettingsModal } from './components/user/UserSettingsModal';
|
||||
@@ -56,7 +44,6 @@ import { CreateProjectModal } from './components/project/CreateProjectModal';
|
||||
import { ProjectMembersModal } from './components/project/ProjectMembersModal';
|
||||
import { ProjectSettingsModal } from './components/project/ProjectSettingsModal';
|
||||
import { fetchProjectSettings } from './api/projectSettings';
|
||||
import { ShieldAlert } from 'lucide-react';
|
||||
import { LoginView } from './components/auth/LoginView';
|
||||
import { FirstLoginPasswordModal } from './components/auth/FirstLoginPasswordModal';
|
||||
import { authClient } from './api/auth';
|
||||
@@ -145,14 +132,10 @@ export {
|
||||
MOCK_USERS,
|
||||
} from '@demo-seeds';
|
||||
|
||||
const loadFilePreviewPanel = () =>
|
||||
import('./components/doc/preview/FilePreviewPanel').then((m) => ({ default: m.FilePreviewPanel }));
|
||||
const loadAdminConsoleView = () =>
|
||||
import('./components/admin/console/AdminConsoleView').then((m) => ({ default: m.AdminConsoleView }));
|
||||
const loadChatExportModal = () =>
|
||||
import('./components/history/ChatExportModal').then((m) => ({ default: m.ChatExportModal }));
|
||||
const loadBlockEditorModal = () =>
|
||||
import('./components/doc/BlockEditorModal').then((m) => ({ default: m.BlockEditorModal }));
|
||||
|
||||
export default function App() {
|
||||
// Wave 2:启动时幂等剔除 localStorage 中的 demo seed 残留(须先于 load*)
|
||||
@@ -1722,295 +1705,56 @@ export default function App() {
|
||||
</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' }}
|
||||
data-testid="active-document-pane"
|
||||
data-doc-id={activeDocId}
|
||||
data-doc-title={
|
||||
deliverables.find((d) => d.docId === activeDocId)?.title ||
|
||||
visibleDeliverables.find((d) => d.docId === activeDocId)?.title ||
|
||||
''
|
||||
}
|
||||
>
|
||||
{/* 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 (
|
||||
<OnDemand
|
||||
label="文件预览"
|
||||
load={loadFilePreviewPanel}
|
||||
componentProps={{
|
||||
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>
|
||||
{docLoadErrorInfo[activeDocId]
|
||||
? [
|
||||
docLoadErrorInfo[activeDocId].code,
|
||||
docLoadErrorInfo[activeDocId].message,
|
||||
docLoadErrorInfo[activeDocId].correlationId
|
||||
? `corr=${docLoadErrorInfo[activeDocId].correlationId}`
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
: '受控文档正文加载失败,请检查网络后重试。'}
|
||||
</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={openBlockEditor}
|
||||
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={handleRejectApprove}
|
||||
onShareToProject={handleShareToProject}
|
||||
onRevokeShare={handleRevokeShare}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 块就地编辑弹窗 (支持富文本工具栏、多维表格与未保存拦截) */}
|
||||
{editorSession && (
|
||||
<OnDemand
|
||||
label="块编辑器"
|
||||
load={loadBlockEditorModal}
|
||||
componentProps={{
|
||||
block: editorSession.block,
|
||||
actorLabel: `${currentUser.name} (${currentUser.position || '成员'})`,
|
||||
onSave: handleSaveBlockEdit,
|
||||
onClose: closeBlockEditor,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 审核批注侧栏 */}
|
||||
{commentingBlock && (
|
||||
<CommentSidePanel
|
||||
block={commentingBlock}
|
||||
currentRole={currentDocRole}
|
||||
currentUserName={currentUser.name}
|
||||
onAddComment={handleAddComment}
|
||||
onClose={() => setCommentingBlock(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
<DocumentPane
|
||||
isCurrentProjectExited={isCurrentProjectExited}
|
||||
activeDocId={activeDocId}
|
||||
deliverables={deliverables}
|
||||
visibleDeliverables={visibleDeliverables}
|
||||
activeCategory={activeCategory}
|
||||
setActiveCategory={setActiveCategory}
|
||||
handleSelectDoc={handleSelectDoc}
|
||||
deckDrawerOpen={deckDrawerOpen}
|
||||
setDeckDrawerOpen={setDeckDrawerOpen}
|
||||
handleExportDocx={handleExportDocx}
|
||||
handleExportXlsx={handleExportXlsx}
|
||||
handleExportMarkdown={handleExportMarkdown}
|
||||
docRevOverlayMode={docRevOverlayMode}
|
||||
setDocRevOverlayMode={setDocRevOverlayMode}
|
||||
setDocRevOverlay={setDocRevOverlay}
|
||||
docRevRefreshKey={docRevRefreshKey}
|
||||
docRevConflict={docRevConflict}
|
||||
docRevOverlay={docRevOverlay}
|
||||
currentUser={currentUser}
|
||||
handleShareToProject={handleShareToProject}
|
||||
handleRevokeShare={handleRevokeShare}
|
||||
handleDeleteDoc={handleDeleteDoc}
|
||||
setTargetDocForVisibility={setTargetDocForVisibility}
|
||||
setDocVisibilityModalOpen={setDocVisibilityModalOpen}
|
||||
docLoadErrors={docLoadErrors}
|
||||
docLoadErrorInfo={docLoadErrorInfo}
|
||||
retryControlledDocLoad={retryControlledDocLoad}
|
||||
docAST={docAST}
|
||||
currentDocRole={currentDocRole}
|
||||
openBlockEditor={openBlockEditor}
|
||||
handleInsertBlock={handleInsertBlock}
|
||||
handleMoveBlock={handleMoveBlock}
|
||||
handleDeleteBlock={handleDeleteBlock}
|
||||
setCommentingBlock={setCommentingBlock}
|
||||
handleSendMessage={handleSendMessage}
|
||||
highlightedBlockId={highlightedBlockId}
|
||||
canOverrideVisibility={canOverrideVisibility}
|
||||
handleClaim={handleClaim}
|
||||
handleSubmitReview={handleSubmitReview}
|
||||
handlePassReview={handlePassReview}
|
||||
handleRejectReview={handleRejectReview}
|
||||
handleApprove={handleApprove}
|
||||
handleRejectApprove={handleRejectApprove}
|
||||
editorSession={editorSession}
|
||||
handleSaveBlockEdit={handleSaveBlockEdit}
|
||||
closeBlockEditor={closeBlockEditor}
|
||||
commentingBlock={commentingBlock}
|
||||
handleAddComment={handleAddComment}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
test('active document pane lives outside the workbench shell', () => {
|
||||
const app = readFileSync(new URL('../../App.tsx', import.meta.url), 'utf8');
|
||||
const pane = readFileSync(new URL('./DocumentPane.tsx', import.meta.url), 'utf8');
|
||||
assert.equal((app.match(/<DocumentPane\b/g) || []).length, 1);
|
||||
assert.equal(app.includes('data-testid="active-document-pane"'), false);
|
||||
assert.equal(pane.includes('data-testid="active-document-pane"'), true);
|
||||
assert.equal(pane.includes('revisionToolbarMountMode'), true);
|
||||
assert.equal(pane.includes('FreeformDocumentActionBar'), true);
|
||||
});
|
||||
@@ -0,0 +1,424 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { ShieldAlert } from 'lucide-react';
|
||||
import { OnDemand } from '../lazy/OnDemand';
|
||||
import { SlimTabBar } from './SlimTabBar';
|
||||
import { DeckDrawer } from './DeckDrawer';
|
||||
import { BlockRenderer } from './BlockRenderer';
|
||||
import { DocumentRevisionToolbar } from './DocumentRevisionToolbar';
|
||||
import { CommentSidePanel } from './CommentSidePanel';
|
||||
import { DocGovActionBar } from './DocGovActionBar';
|
||||
import { FreeformDocumentActionBar } from './FreeformDocumentActionBar';
|
||||
import {
|
||||
documentRevisionInactiveHint,
|
||||
revisionToolbarMountMode,
|
||||
} from '../../features/documentRevision/revisionToolbarMount';
|
||||
import { copyMarkdownReply, copyRichReplyToClipboard } from '../../features/replyExport/clipboard';
|
||||
import { downloadMarkdownReply } from '../../features/replyExport/download';
|
||||
import { blockASTToMarkdown } from '../../engine/blockToMarkdown';
|
||||
import type { DiffResult, OverlayMode } from '../../features/documentRevision/types';
|
||||
import type { BlockEditorSession } from '../../lib/blockSavePolicy';
|
||||
import type { BlockComment, DocumentAST, DocumentBlock } from '../../types/block';
|
||||
import type { UserProfile, UserRole } from '../../types/docgov';
|
||||
import type { DeliverableCategory, DeliverableItem } from '../../types/workbench';
|
||||
|
||||
const loadFilePreviewPanel = () =>
|
||||
import('./preview/FilePreviewPanel').then((m) => ({ default: m.FilePreviewPanel }));
|
||||
const loadBlockEditorModal = () =>
|
||||
import('./BlockEditorModal').then((m) => ({ default: m.BlockEditorModal }));
|
||||
|
||||
export interface DocumentPaneProps {
|
||||
isCurrentProjectExited: boolean;
|
||||
activeDocId: string;
|
||||
deliverables: DeliverableItem[];
|
||||
visibleDeliverables: DeliverableItem[];
|
||||
activeCategory: DeliverableCategory;
|
||||
setActiveCategory: Dispatch<SetStateAction<DeliverableCategory>>;
|
||||
handleSelectDoc: (id: string) => void;
|
||||
deckDrawerOpen: boolean;
|
||||
setDeckDrawerOpen: Dispatch<SetStateAction<boolean>>;
|
||||
handleExportDocx: () => void | Promise<void>;
|
||||
handleExportXlsx: () => void | Promise<void>;
|
||||
handleExportMarkdown: () => void;
|
||||
docRevOverlayMode: OverlayMode;
|
||||
setDocRevOverlayMode: Dispatch<SetStateAction<OverlayMode>>;
|
||||
setDocRevOverlay: Dispatch<SetStateAction<DiffResult | null>>;
|
||||
docRevRefreshKey: number;
|
||||
docRevConflict: string | null;
|
||||
docRevOverlay: DiffResult | null;
|
||||
currentUser: UserProfile;
|
||||
handleShareToProject: (docId: string) => Promise<void>;
|
||||
handleRevokeShare: (docId: string) => Promise<void>;
|
||||
handleDeleteDoc: (docId: string) => Promise<void>;
|
||||
setTargetDocForVisibility: Dispatch<SetStateAction<DeliverableItem | null>>;
|
||||
setDocVisibilityModalOpen: Dispatch<SetStateAction<boolean>>;
|
||||
docLoadErrors: Record<string, boolean>;
|
||||
docLoadErrorInfo: Record<string, { code?: string; message: string; correlationId?: string }>;
|
||||
retryControlledDocLoad: () => void;
|
||||
docAST: DocumentAST;
|
||||
currentDocRole: UserRole;
|
||||
openBlockEditor: (block: DocumentBlock) => void;
|
||||
handleInsertBlock: (index: number, position: 'above' | 'below') => void;
|
||||
handleMoveBlock: (index: number, direction: 'up' | 'down') => void;
|
||||
handleDeleteBlock: (index: number) => void;
|
||||
setCommentingBlock: Dispatch<SetStateAction<DocumentBlock | null>>;
|
||||
handleSendMessage: (text: string) => void | Promise<unknown>;
|
||||
highlightedBlockId: string | null;
|
||||
canOverrideVisibility: boolean;
|
||||
handleClaim: () => void | Promise<void>;
|
||||
handleSubmitReview: () => void | Promise<void>;
|
||||
handlePassReview: () => void | Promise<void>;
|
||||
handleRejectReview: (reason: string) => void | Promise<void>;
|
||||
handleApprove: () => void | Promise<void>;
|
||||
handleRejectApprove: (reason: string) => void | Promise<void>;
|
||||
editorSession: BlockEditorSession | null;
|
||||
handleSaveBlockEdit: (updated: DocumentBlock) => void | Promise<void>;
|
||||
closeBlockEditor: () => void;
|
||||
commentingBlock: DocumentBlock | null;
|
||||
handleAddComment: (comment: Omit<BlockComment, 'id' | 'createdAt'>) => void;
|
||||
}
|
||||
|
||||
export function DocumentPane(props: DocumentPaneProps) {
|
||||
const {
|
||||
isCurrentProjectExited,
|
||||
activeDocId,
|
||||
deliverables,
|
||||
visibleDeliverables,
|
||||
activeCategory,
|
||||
setActiveCategory,
|
||||
handleSelectDoc,
|
||||
deckDrawerOpen,
|
||||
setDeckDrawerOpen,
|
||||
handleExportDocx,
|
||||
handleExportXlsx,
|
||||
handleExportMarkdown,
|
||||
docRevOverlayMode,
|
||||
setDocRevOverlayMode,
|
||||
setDocRevOverlay,
|
||||
docRevRefreshKey,
|
||||
docRevConflict,
|
||||
docRevOverlay,
|
||||
currentUser,
|
||||
handleShareToProject,
|
||||
handleRevokeShare,
|
||||
handleDeleteDoc,
|
||||
setTargetDocForVisibility,
|
||||
setDocVisibilityModalOpen,
|
||||
docLoadErrors,
|
||||
docLoadErrorInfo,
|
||||
retryControlledDocLoad,
|
||||
docAST,
|
||||
currentDocRole,
|
||||
openBlockEditor,
|
||||
handleInsertBlock,
|
||||
handleMoveBlock,
|
||||
handleDeleteBlock,
|
||||
setCommentingBlock,
|
||||
handleSendMessage,
|
||||
highlightedBlockId,
|
||||
canOverrideVisibility,
|
||||
handleClaim,
|
||||
handleSubmitReview,
|
||||
handlePassReview,
|
||||
handleRejectReview,
|
||||
handleApprove,
|
||||
handleRejectApprove,
|
||||
editorSession,
|
||||
handleSaveBlockEdit,
|
||||
closeBlockEditor,
|
||||
commentingBlock,
|
||||
handleAddComment,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
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' }}
|
||||
data-testid="active-document-pane"
|
||||
data-doc-id={activeDocId}
|
||||
data-doc-title={
|
||||
deliverables.find((d) => d.docId === activeDocId)?.title ||
|
||||
visibleDeliverables.find((d) => d.docId === activeDocId)?.title ||
|
||||
''
|
||||
}
|
||||
>
|
||||
{/* 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 (
|
||||
<OnDemand
|
||||
label="文件预览"
|
||||
load={loadFilePreviewPanel}
|
||||
componentProps={{
|
||||
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>
|
||||
{docLoadErrorInfo[activeDocId]
|
||||
? [
|
||||
docLoadErrorInfo[activeDocId].code,
|
||||
docLoadErrorInfo[activeDocId].message,
|
||||
docLoadErrorInfo[activeDocId].correlationId
|
||||
? `corr=${docLoadErrorInfo[activeDocId].correlationId}`
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
: '受控文档正文加载失败,请检查网络后重试。'}
|
||||
</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={openBlockEditor}
|
||||
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={handleRejectApprove}
|
||||
onShareToProject={handleShareToProject}
|
||||
onRevokeShare={handleRevokeShare}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 块就地编辑弹窗 (支持富文本工具栏、多维表格与未保存拦截) */}
|
||||
{editorSession && (
|
||||
<OnDemand
|
||||
label="块编辑器"
|
||||
load={loadBlockEditorModal}
|
||||
componentProps={{
|
||||
block: editorSession.block,
|
||||
actorLabel: `${currentUser.name} (${currentUser.position || '成员'})`,
|
||||
onSave: handleSaveBlockEdit,
|
||||
onClose: closeBlockEditor,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 审核批注侧栏 */}
|
||||
{commentingBlock && (
|
||||
<CommentSidePanel
|
||||
block={commentingBlock}
|
||||
currentRole={currentDocRole}
|
||||
currentUserName={currentUser.name}
|
||||
onAddComment={handleAddComment}
|
||||
onClose={() => setCommentingBlock(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user