From 7513ce61ed5136e673a719e48b69ee28080da018 Mon Sep 17 00:00:00 2001 From: weichen Date: Sat, 26 Sep 2026 07:29:27 +0000 Subject: [PATCH] fix(web): member drafting edit + reject await + expand right rail --- web/src/App.tsx | 8 ++ web/src/components/doc/BlockRenderer.tsx | 12 ++- web/src/components/doc/DocGovActionBar.tsx | 89 +++++++++++++++----- web/src/components/doc/DocumentPane.tsx | 4 + web/src/components/layout/AppShell.tsx | 8 ++ web/src/hooks/useDocumentLifecycleActions.ts | 6 +- web/src/hooks/useFreeformDocActions.ts | 3 + web/src/hooks/useSendMessage.ts | 3 + web/src/lib/draftingEditAffordance.test.ts | 63 ++++++++++++++ web/src/lib/draftingEditAffordance.ts | 20 +++++ web/src/lib/expandRightDocRail.ts | 14 +++ 11 files changed, 206 insertions(+), 24 deletions(-) create mode 100644 web/src/lib/draftingEditAffordance.test.ts create mode 100644 web/src/lib/draftingEditAffordance.ts create mode 100644 web/src/lib/expandRightDocRail.ts diff --git a/web/src/App.tsx b/web/src/App.tsx index 312d26de..e1b5a317 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -11,6 +11,7 @@ import { } from './lib/createProjectSubmitGuard'; import { mapApiMembersToProjectMembers } from './lib/projectMemberAdapter'; import { resolveCurrentDocRole } from './lib/currentDocRole'; +import { expandRightDocRail } from './lib/expandRightDocRail'; import { resolveChatComposerStreaming, } from './lib/chatComposerStreaming'; @@ -214,6 +215,11 @@ export default function App() { return getProjectMembership(currentProject, currentUser.id); }, [currentProject, currentUser.id]); + /** deny_only_submit:活跃成员可 drafting 编辑;exited/非成员不可。 */ + const isActiveProjectMember = useMemo(() => { + return !!currentMembership && currentMembership.status === 'active'; + }, [currentMembership]); + const applyMembersToProject = useCallback((projectId: number, members: ProjectMember[]) => { setProjects((prev) => prev.map((p) => (p.id === projectId ? { ...p, members } : p))); }, []); @@ -710,6 +716,7 @@ export default function App() { return; } editorHydrationGateRef.current.setActiveDocId(id); + expandRightDocRail(); setActiveDocId(id); let targetDoc = deliverables.find((d) => d.docId === id); @@ -1686,6 +1693,7 @@ export default function App() { retryControlledDocLoad={retryControlledDocLoad} docAST={docAST} currentDocRole={currentDocRole} + isActiveProjectMember={isActiveProjectMember} openBlockEditor={openBlockEditor} handleInsertBlock={handleInsertBlock} handleMoveBlock={handleMoveBlock} diff --git a/web/src/components/doc/BlockRenderer.tsx b/web/src/components/doc/BlockRenderer.tsx index ba61d02c..3cbefbd1 100644 --- a/web/src/components/doc/BlockRenderer.tsx +++ b/web/src/components/doc/BlockRenderer.tsx @@ -12,6 +12,7 @@ import { OnDemand } from '../lazy/OnDemand'; import { contentHasMath, ensureMathRenderer } from '../../engine/renderMarkdown'; import { BlockContextMenu } from './BlockContextMenu'; import { canAddBlockComment } from '../../lib/commentPolicy'; +import { canEditDraftingDocument } from '../../lib/draftingEditAffordance'; import { documentBlockDomAttrs } from '../../lib/documentBlockDom'; import { Plus, ArrowUp, ArrowDown, Trash2, MessageSquare } from 'lucide-react'; @@ -23,6 +24,8 @@ const loadMathBlock = () => export interface BlockRendererProps { ast: DocumentAST; currentRole: UserRole; + /** 项目活跃成员可在 drafting 编辑;勿用伪造 author 职责代替。 */ + isActiveProjectMember?: boolean; onEditBlock: (block: DocumentBlock) => void; onInsertBlock: (index: number, position: 'above' | 'below') => void; onMoveBlock: (index: number, direction: 'up' | 'down') => void; @@ -46,6 +49,7 @@ function subtreeHasInlineMath(block: DocumentBlock): boolean { export const BlockRenderer: React.FC = ({ ast, currentRole, + isActiveProjectMember = false, onEditBlock, onInsertBlock, onMoveBlock, @@ -90,7 +94,13 @@ export const BlockRenderer: React.FC = ({ const leaseActive = !!ast.editLease?.holderType && (!ast.editLease.expiresAt || new Date(ast.editLease.expiresAt).getTime() > Date.now()); - const isEditable = currentRole === 'author' && ast.lifecycleState === 'drafting' && !leaseActive; + // deny_only_submit:drafting 编辑对活跃成员开放;送审仍仅编制人(见 DocGovActionBar)。 + const isEditable = canEditDraftingDocument({ + currentRole, + isActiveProjectMember, + lifecycleState: ast.lifecycleState, + leaseActive, + }); const canAnnotate = !leaseActive && canAddBlockComment(currentRole); const children = ast.rootBlock.children || []; diff --git a/web/src/components/doc/DocGovActionBar.tsx b/web/src/components/doc/DocGovActionBar.tsx index c7ade995..008ecca2 100644 --- a/web/src/components/doc/DocGovActionBar.tsx +++ b/web/src/components/doc/DocGovActionBar.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useRef, useState } from 'react'; import { DocumentAST } from '../../types/block'; import { UserRole } from '../../types/docgov'; import { canShowDocGovApprovalAction, isUnmanagedDocDuties } from '../../lib/docGovActionVisibility'; @@ -21,12 +21,12 @@ interface DocGovActionBarProps { currentUserId?: number; canOverrideVisibility?: boolean; onOpenVisibilityModal?: () => void; - onClaim: () => void; - onSubmitReview: () => void; - onPassReview: () => void; - onRejectReview: (reason: string) => void; - onApprove: () => void; - onRejectApprove: (reason: string) => void; + onClaim: () => void | Promise; + onSubmitReview: () => void | Promise; + onPassReview: () => void | Promise; + onRejectReview: (reason: string) => void | Promise; + onApprove: () => void | Promise; + onRejectApprove: (reason: string) => void | Promise; onShareToProject?: (docId: string) => void; onRevokeShare?: (docId: string) => void; onRequestReopen?: () => void; @@ -51,21 +51,46 @@ export const DocGovActionBar: React.FC = ({ const [rejectModalOpen, setRejectModalOpen] = useState(false); const [rejectReason, setRejectReason] = useState(''); const [rejectTarget, setRejectTarget] = useState<'review' | 'approve'>('review'); + const [rejectSubmitting, setRejectSubmitting] = useState(false); + const [rejectError, setRejectError] = useState(null); + const rejectReasonRef = useRef(''); + const rejectTextareaRef = useRef(null); const openReject = (target: 'review' | 'approve') => { setRejectTarget(target); setRejectReason(''); + rejectReasonRef.current = ''; + setRejectError(null); + setRejectSubmitting(false); setRejectModalOpen(true); }; - const confirmReject = () => { - if (!rejectReason.trim()) return; - if (rejectTarget === 'review') { - onRejectReview(rejectReason.trim()); - } else { - onRejectApprove(rejectReason.trim()); + /** 优先读 textarea DOM,避免受控 state 与 Playwright fill 竞态导致空理由。 */ + const readRejectReason = (): string => { + const fromDom = rejectTextareaRef.current?.value ?? ''; + const raw = fromDom || rejectReasonRef.current || rejectReason; + return raw.trim(); + }; + + const confirmReject = async () => { + const reason = readRejectReason(); + if (!reason || rejectSubmitting) return; + setRejectSubmitting(true); + setRejectError(null); + try { + if (rejectTarget === 'review') { + await Promise.resolve(onRejectReview(reason)); + } else { + await Promise.resolve(onRejectApprove(reason)); + } + setRejectModalOpen(false); + setRejectReason(''); + rejectReasonRef.current = ''; + } catch (err) { + setRejectError(err instanceof Error ? err.message : String(err)); + } finally { + setRejectSubmitting(false); } - setRejectModalOpen(false); }; const isPrivate = ast.category === 'private'; @@ -124,10 +149,16 @@ export const DocGovActionBar: React.FC = ({ 请填写法定驳回原因 (必填)