fix(web): member drafting edit + reject await + expand right rail

This commit is contained in:
weichen
2026-09-26 07:29:27 +00:00
parent 7555818753
commit 7513ce61ed
11 changed files with 206 additions and 24 deletions
+8
View File
@@ -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}
+11 -1
View File
@@ -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<BlockRendererProps> = ({
ast,
currentRole,
isActiveProjectMember = false,
onEditBlock,
onInsertBlock,
onMoveBlock,
@@ -90,7 +94,13 @@ export const BlockRenderer: React.FC<BlockRendererProps> = ({
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 || [];
+67 -22
View File
@@ -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<void>;
onSubmitReview: () => void | Promise<void>;
onPassReview: () => void | Promise<void>;
onRejectReview: (reason: string) => void | Promise<void>;
onApprove: () => void | Promise<void>;
onRejectApprove: (reason: string) => void | Promise<void>;
onShareToProject?: (docId: string) => void;
onRevokeShare?: (docId: string) => void;
onRequestReopen?: () => void;
@@ -51,21 +51,46 @@ export const DocGovActionBar: React.FC<DocGovActionBarProps> = ({
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<string | null>(null);
const rejectReasonRef = useRef('');
const rejectTextareaRef = useRef<HTMLTextAreaElement | null>(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<DocGovActionBarProps> = ({
请填写法定驳回原因 (必填)
</div>
<textarea
ref={rejectTextareaRef}
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
onChange={(e) => {
const next = e.target.value;
rejectReasonRef.current = next;
setRejectReason(next);
}}
placeholder="说明驳回修改的具体意见,例如:缺少第3章电气安全检验标准,或技术参数指标不符合临床预期..."
rows={4}
disabled={rejectSubmitting}
style={{
width: '100%',
padding: '8px 10px',
@@ -139,23 +170,37 @@ export const DocGovActionBar: React.FC<DocGovActionBarProps> = ({
resize: 'none',
}}
/>
{rejectError ? (
<div style={{ fontSize: '12px', color: 'var(--danger)' }} role="alert">
驳回失败:{rejectError}
</div>
) : null}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
<button
onClick={() => setRejectModalOpen(false)}
type="button"
onClick={() => {
if (rejectSubmitting) return;
setRejectModalOpen(false);
}}
disabled={rejectSubmitting}
style={{
padding: '6px 12px',
borderRadius: 'var(--r-sm)',
background: 'var(--bg-subtle)',
border: '1px solid var(--border)',
color: 'var(--text)',
cursor: 'pointer',
cursor: rejectSubmitting ? 'not-allowed' : 'pointer',
opacity: rejectSubmitting ? 0.6 : 1,
}}
>
取消
</button>
<button
onClick={confirmReject}
disabled={!rejectReason.trim()}
type="button"
onClick={() => {
void confirmReject();
}}
disabled={rejectSubmitting}
style={{
padding: '6px 14px',
borderRadius: 'var(--r-sm)',
@@ -163,11 +208,11 @@ export const DocGovActionBar: React.FC<DocGovActionBarProps> = ({
border: 'none',
color: '#fff',
fontWeight: 600,
opacity: rejectReason.trim() ? 1 : 0.5,
cursor: rejectReason.trim() ? 'pointer' : 'not-allowed',
opacity: rejectSubmitting ? 0.5 : 1,
cursor: rejectSubmitting ? 'not-allowed' : 'pointer',
}}
>
确认驳回退回编制
{rejectSubmitting ? '驳回中…' : '确认驳回退回编制'}
</button>
</div>
</div>
+4
View File
@@ -56,6 +56,8 @@ export interface DocumentPaneProps {
retryControlledDocLoad: () => void;
docAST: DocumentAST;
currentDocRole: UserRole;
/** 项目活跃成员 drafting 可编辑;不伪造 author 职责。 */
isActiveProjectMember?: boolean;
openBlockEditor: (block: DocumentBlock) => void;
handleInsertBlock: (index: number, position: 'above' | 'below') => void;
handleMoveBlock: (index: number, direction: 'up' | 'down') => void;
@@ -109,6 +111,7 @@ export function DocumentPane(props: DocumentPaneProps) {
retryControlledDocLoad,
docAST,
currentDocRole,
isActiveProjectMember = false,
openBlockEditor,
handleInsertBlock,
handleMoveBlock,
@@ -342,6 +345,7 @@ isCurrentProjectExited ? (
<BlockRenderer
ast={docAST}
currentRole={currentDocRole}
isActiveProjectMember={isActiveProjectMember}
onEditBlock={openBlockEditor}
onInsertBlock={handleInsertBlock}
onMoveBlock={handleMoveBlock}
+8
View File
@@ -8,6 +8,7 @@ import { ErrorBoundary } from '../ErrorBoundary';
import { ProjectSession } from '../../types/workbench';
import { UserRole, UserProfile } from '../../types/docgov';
import { loadRightRailCollapsed, saveRightRailCollapsed } from '../../data/storage';
import { RIGHT_DOC_RAIL_EXPAND_EVENT } from '../../lib/expandRightDocRail';
interface AppShellProps {
sessions: ProjectSession[];
@@ -65,6 +66,13 @@ export const AppShell: React.FC<AppShellProps> = ({
const [leftCollapsed, setLeftCollapsed] = useState<boolean>(false);
const [rightWidth, setRightWidth] = useState<number>(500);
const [rightCollapsed, setRightCollapsed] = useState<boolean>(() => loadRightRailCollapsed(true));
useEffect(() => {
const onExpandRightRail = () => {
setRightCollapsed(false);
};
window.addEventListener(RIGHT_DOC_RAIL_EXPAND_EVENT, onExpandRightRail);
return () => window.removeEventListener(RIGHT_DOC_RAIL_EXPAND_EVENT, onExpandRightRail);
}, []);
useEffect(() => {
const onResize = () => {
const narrow = window.innerWidth <= 880;
+5 -1
View File
@@ -49,7 +49,9 @@ export function useDocumentLifecycleActions(input: DocumentLifecycleInput): Docu
} = input;
const applyDocGovRecord = (doc: DocGovRecord) => {
const nextState = (doc.current_state || docAST.lifecycleState) as DocLifecycleState;
// Prefer API current_state; never silently keep reviewing after reject if server omitted field.
const apiState = (doc.current_state || '').trim();
const nextState = (apiState || docAST.lifecycleState) as DocLifecycleState;
const nextClaimed = doc.author_claimed ?? docAST.isClaimed;
const lease = mapLeaseToAstEditLease(doc);
const nextAst: DocumentAST = {
@@ -166,6 +168,7 @@ export function useDocumentLifecycleActions(input: DocumentLifecycleInput): Docu
]);
} catch (err) {
pushDocGovError('审核驳回', err);
throw err;
}
};
@@ -230,6 +233,7 @@ export function useDocumentLifecycleActions(input: DocumentLifecycleInput): Docu
]);
} catch (err) {
pushDocGovError('批准驳回', err);
throw err;
}
};
+3
View File
@@ -7,6 +7,7 @@ import {
} from '../api/freeformDoc';
import { markdownToBlockAST } from '../engine/markdownToBlock';
import { deriveReplyTitle } from '../features/replyExport/title';
import { expandRightDocRail } from '../lib/expandRightDocRail';
import type { DocumentAST } from '../types/block';
import type { UserProfile } from '../types/docgov';
import type { ChatMessage, DeliverableCategory, DeliverableItem } from '../types/workbench';
@@ -129,6 +130,7 @@ export function useFreeformDocActions(input: FreeformDocActionsInput): FreeformD
const remaining = deliverables.filter((d) => d.docId !== docId);
const fallback = remaining.find((d) => d.category === 'workflow') || remaining[0];
if (fallback) {
expandRightDocRail();
setActiveDocId(fallback.docId);
setActiveCategory(fallback.category);
}
@@ -208,6 +210,7 @@ export function useFreeformDocActions(input: FreeformDocActionsInput): FreeformD
[docId]: newAST,
}));
expandRightDocRail();
setActiveDocId(docId);
setActiveCategory('private');
setDeckDrawerOpen(false);
+3
View File
@@ -16,6 +16,7 @@ import { createClientTurnId } from '../lib/createClientTurnId';
import { canonicalDocIdForTask } from '../lib/controlledDocHydrate';
import { holdsDocDuty } from '../lib/docGovActionVisibility';
import { resolveSessionProjectId } from '../lib/sessionAdapter';
import { expandRightDocRail } from '../lib/expandRightDocRail';
import { registerUploadAttachments } from '../lib/uploadFreeformRegister';
import type { DocumentAST } from '../types/block';
import type { UserProfile, UserRole } from '../types/docgov';
@@ -254,6 +255,7 @@ export function useSendMessage(input: SendMessageInput) {
}));
if (processedAttachments.length > 0 && processedAttachments[0].docId) {
expandRightDocRail();
setActiveDocId(processedAttachments[0].docId);
setActiveCategory('private');
}
@@ -507,6 +509,7 @@ export function useSendMessage(input: SendMessageInput) {
setDeliverables((prev) => [newMeta, ...prev]);
setDocCache((prev) => ({ ...prev, [newDocId]: parsedAst }));
setActiveCategory('private');
expandRightDocRail();
setActiveDocId(newDocId);
setMessages((prev) => [
@@ -0,0 +1,63 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { canEditDraftingDocument } from './draftingEditAffordance';
test('author can edit drafting when unlocked', () => {
assert.equal(
canEditDraftingDocument({
currentRole: 'author',
isActiveProjectMember: false,
lifecycleState: 'drafting',
leaseActive: false,
}),
true,
);
});
test('active project member can edit drafting when unlocked (deny_only_submit)', () => {
assert.equal(
canEditDraftingDocument({
currentRole: 'viewer',
isActiveProjectMember: true,
lifecycleState: 'drafting',
leaseActive: false,
}),
true,
);
});
test('non-member viewer cannot edit drafting', () => {
assert.equal(
canEditDraftingDocument({
currentRole: 'viewer',
isActiveProjectMember: false,
lifecycleState: 'drafting',
leaseActive: false,
}),
false,
);
});
test('lease blocks edit even for author and member', () => {
assert.equal(
canEditDraftingDocument({
currentRole: 'author',
isActiveProjectMember: true,
lifecycleState: 'drafting',
leaseActive: true,
}),
false,
);
});
test('reviewing is not editable via drafting affordance', () => {
assert.equal(
canEditDraftingDocument({
currentRole: 'author',
isActiveProjectMember: true,
lifecycleState: 'reviewing',
leaseActive: false,
}),
false,
);
});
+20
View File
@@ -0,0 +1,20 @@
import type { UserRole, DocLifecycleState } from '../types/docgov';
export type DraftingEditAffordanceInput = {
currentRole: UserRole;
/** 当前用户是否为项目活跃成员(非 exited);来自 currentProject.members,不伪造 duty author。 */
isActiveProjectMember: boolean;
lifecycleState: DocLifecycleState | string;
leaseActive: boolean;
};
/**
* drafting 且无编辑租约时,编制人职责或项目活跃成员均可编辑(deny_only_submit:run/edit 成员级)。
* 送审仍由职责门单独控制,不在此放行。
*/
export function canEditDraftingDocument(input: DraftingEditAffordanceInput): boolean {
if (input.leaseActive) return false;
if (input.lifecycleState !== 'drafting') return false;
if (input.currentRole === 'author') return true;
return input.isActiveProjectMember;
}
+14
View File
@@ -0,0 +1,14 @@
import { saveRightRailCollapsed } from '../data/storage';
/** AppShell 监听此事件并将右侧文档栏设为展开。 */
export const RIGHT_DOC_RAIL_EXPAND_EVENT = 'smdf:expand-right-doc-rail';
/**
* 持久化展开偏好并通知布局展开右侧文档栏。
* RightDocRail 在 collapsed 时 return null,不展开则 active-document-pane 不在 DOM。
*/
export function expandRightDocRail(): void {
saveRightRailCollapsed(false);
if (typeof window === 'undefined') return;
window.dispatchEvent(new CustomEvent(RIGHT_DOC_RAIL_EXPAND_EVENT));
}