fix(web): member drafting edit + reject await + expand right rail
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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 || [];
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user