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:
weichen
2026-09-19 13:32:39 +00:00
co-authored by Cursor
parent 26d14b534b
commit 69e2cd259d
3 changed files with 488 additions and 307 deletions
+51 -307
View File
@@ -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);
});
+424
View File
@@ -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>
)
);
}