From 1cd99a5a9027e604c0156a3b3062fc8b5a0836d2 Mon Sep 17 00:00:00 2001 From: fengxiangdebaozi <10845145+fengxiangdebaozi@user.noreply.gitee.com> Date: Tue, 21 Jul 2026 20:16:52 +0800 Subject: [PATCH] feat(position-roundtable): add delivery rejection workflow --- .../src/position-roundtable/api/sessions.ts | 57 ++ .../components/PositionNodeChat.tsx | 7 +- .../pages/PositionRoundtablePage.tsx | 828 +++++++++++++++--- .../gateway/routers/position_roundtable.py | 280 +++++- ...60721_02_position_roundtable_rejections.py | 54 ++ ...3_position_roundtable_builtin_snapshots.py | 49 ++ .../persistence/position_roundtable/model.py | 7 +- .../persistence/position_roundtable/sql.py | 105 +++ .../test_position_roundtable_sessions.py | 183 ++++ 9 files changed, 1422 insertions(+), 148 deletions(-) create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260721_02_position_roundtable_rejections.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260721_03_position_roundtable_builtin_snapshots.py diff --git a/frontend-web/src/position-roundtable/api/sessions.ts b/frontend-web/src/position-roundtable/api/sessions.ts index fbb10e10..4980d46d 100644 --- a/frontend-web/src/position-roundtable/api/sessions.ts +++ b/frontend-web/src/position-roundtable/api/sessions.ts @@ -30,6 +30,9 @@ export interface PositionRoundtableNode { artifactManifest: Array>; revision: number; upstreamRevision: Record | null; + rejectionCount: number; + lastRejection: Record | null; + invalidatedBy: string | null; createdAt?: string; updatedAt?: string; } @@ -42,6 +45,8 @@ export interface PositionRoundtableSession { chainSnapshot: Record | null; intentThreadId: string | null; intentSnapshot: Record | null; + summarySnapshot: Record | null; + actionPlanSnapshot: Record | null; status: "intent_pending" | "active" | "completed" | "archived"; createdAt?: string; updatedAt?: string; @@ -62,6 +67,9 @@ interface ServerNode { artifact_manifest?: Array>; revision?: number; upstream_revision?: Record | null; + rejection_count?: number; + last_rejection?: Record | null; + invalidated_by?: string | null; created_at?: string; updated_at?: string; } @@ -74,6 +82,8 @@ interface ServerSession { chain_snapshot?: Record | null; intent_thread_id: string | null; intent_snapshot?: Record | null; + summary_snapshot?: Record | null; + action_plan_snapshot?: Record | null; status: PositionRoundtableSession["status"]; created_at?: string; updated_at?: string; @@ -105,6 +115,9 @@ function toNode(node: ServerNode): PositionRoundtableNode { artifactManifest: node.artifact_manifest ?? [], revision: node.revision ?? 0, upstreamRevision: node.upstream_revision ?? null, + rejectionCount: node.rejection_count ?? 0, + lastRejection: node.last_rejection ?? null, + invalidatedBy: node.invalidated_by ?? null, createdAt: node.created_at, updatedAt: node.updated_at, }; @@ -119,6 +132,8 @@ function toSession(session: ServerSession): PositionRoundtableSession { chainSnapshot: session.chain_snapshot ?? null, intentThreadId: session.intent_thread_id, intentSnapshot: session.intent_snapshot ?? null, + summarySnapshot: session.summary_snapshot ?? null, + actionPlanSnapshot: session.action_plan_snapshot ?? null, status: session.status, createdAt: session.created_at, updatedAt: session.updated_at, @@ -263,3 +278,45 @@ export async function completePositionNodeTurn( ); return toNode(result); } + +export async function rejectPositionNode( + sessionId: string, + nodeKey: string, + input: { title: string; reason: string }, +): Promise { + const result = await readJson( + await apiFetch( + `${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/reject`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title: input.title, + reason: input.reason, + }), + }, + ), + "reject position node", + ); + return toSession(result); +} + +export async function generatePositionSummary(sessionId: string): Promise { + const result = await readJson( + await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/summary/start`, { + method: "POST", + }), + "generate position summary", + ); + return toSession(result); +} + +export async function generatePositionActionPlan(sessionId: string): Promise { + const result = await readJson( + await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/action-plan/start`, { + method: "POST", + }), + "generate position action plan", + ); + return toSession(result); +} diff --git a/frontend-web/src/position-roundtable/components/PositionNodeChat.tsx b/frontend-web/src/position-roundtable/components/PositionNodeChat.tsx index 306dd84a..f329916c 100644 --- a/frontend-web/src/position-roundtable/components/PositionNodeChat.tsx +++ b/frontend-web/src/position-roundtable/components/PositionNodeChat.tsx @@ -78,7 +78,8 @@ export function PositionNodeChat({ sessionStatus !== "archived" && node.positionId === currentPositionId && node.status !== "locked" && - node.status !== "error"; + node.status !== "error" && + !node.invalidatedBy; useEffect(() => { setBoundThreadId(node.threadId); @@ -152,6 +153,8 @@ export function PositionNodeChat({ toast.info( sessionStatus === "archived" ? "该会话已归档,仅可查看历史记录" + : node.invalidatedBy + ? "上游产物已被驳回或重做,当前节点需等待前序节点重新完成" : node.status === "locked" ? "该节点尚未解锁" : "当前岗位只能查看该节点对话", @@ -181,6 +184,8 @@ export function PositionNodeChat({ const readOnlyHint = sessionStatus === "archived" ? "该会话已归档,仅可查看历史对话和产物" + : node.invalidatedBy + ? "上游产物已变更,需等待前序节点重新完成后再重做" : node.status === "locked" ? "等待上游岗位完成后解锁" : node.positionId !== currentPositionId diff --git a/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx b/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx index 468b24ea..3b2036f3 100644 --- a/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx +++ b/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx @@ -12,13 +12,14 @@ import { Circle, CircleDashed, Clock3, - Eye, FileOutput, + FileText, GitBranch, Layers3, Loader2, MessageSquareText, Plus, + RotateCcw, Search, Settings, Sparkles, @@ -40,9 +41,11 @@ import { useAgentModuleSidebarData } from "@/components/workspace/agents/agent-m import { Dialog, DialogContent, + DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { Textarea } from "@/components/ui/textarea"; import { useTheme } from "@/contexts/ThemeContext"; import { useAgent } from "@/core/agents"; import type { Agent } from "@/core/agents"; @@ -67,12 +70,16 @@ import { activatePositionSession, archivePositionSession, createPositionSession, + generatePositionActionPlan, + generatePositionSummary, getPositionSession, listPositionSessions, + rejectPositionNode, savePositionIntent, type PositionRoundtableNode, type PositionRoundtableSession, } from "@/position-roundtable/api/sessions"; +import { ArtifactPreviewModal } from "@/roundtable-planning/components/ArtifactPreviewModal"; import { DEFAULT_MOCK_POSITION_TASK_ID, getMockPositionTask, @@ -174,6 +181,59 @@ function allExecutableNodesDone(nodes: PositionRoundtableNode[]): boolean { return nodes.length > 0 && nodes.every((node) => node.status === "done"); } +function nodeHasDelivery(node: PositionRoundtableNode): boolean { + return ( + node.revision > 0 || + Boolean(node.latestAnswer?.trim()) || + node.artifactManifest.some((artifact) => typeof artifact.path === "string" && artifact.path.trim()) + ); +} + +function canRejectNodeDelivery(node: PositionRoundtableNode): boolean { + return node.status === "done" && nodeHasDelivery(node); +} + +function recordString(record: Record | null | undefined, key: string): string { + const value = record?.[key]; + return typeof value === "string" ? value : ""; +} + +function nodeRejectionTitle(node: PositionRoundtableNode): string { + return recordString(node.lastRejection, "title"); +} + +function nodeRejectionReason(node: PositionRoundtableNode): string { + return recordString(node.lastRejection, "reason"); +} + +function snapshotStatus(snapshot: Record | null | undefined): string { + const value = snapshot?.status; + return typeof value === "string" ? value : ""; +} + +function snapshotText(snapshot: Record | null | undefined, key: string): string { + const value = snapshot?.[key]; + return typeof value === "string" ? value : ""; +} + +function builtinNodeStatus( + snapshot: Record | null | undefined, + ready: boolean, +): NodeVisualStatus { + const status = snapshotStatus(snapshot); + if (status === "done") return "done"; + if (status === "stale") return "stale"; + if (status === "running") return "running"; + if (status === "error") return "error"; + return ready ? "ready" : "locked"; +} + +function resultPositionName(positionId: string | null): string { + if (positionId === "summary") return "内置总结节点"; + if (positionId === "action_plan") return "内置规划节点"; + return positionNameById(positionId); +} + function parseIntentSummary(snapshot: Record | null | undefined): IntentSummary | null { if (!snapshot || typeof snapshot.objective !== "string" || !Array.isArray(snapshot.constraints)) { return null; @@ -558,6 +618,8 @@ export default function PositionRoundtablePage() { > {positionSession.status === "archived" ? "会话已归档" + : positionSession.status === "completed" + ? "行动规划已生成" : positionSession.status === "active" ? "业务链已启动" : "意图识别中"} @@ -635,6 +697,7 @@ export default function PositionRoundtablePage() { { setActiveNodeKey(node.nodeKey); @@ -726,6 +789,7 @@ export default function PositionRoundtablePage() { intentCompletedAt={intentCompletedAt} assignedAgentIds={assignedAgentIds} hasSelectedChain={Boolean(selectedChain)} + session={positionSession} onConfigureChain={ selectedChain ? () => openPositionConfig(selectedChain.id) : undefined } @@ -735,6 +799,7 @@ export default function PositionRoundtablePage() { setActiveNodeKey(node.nodeKey); setSelectedAgentId(node.agentId); }} + onSessionUpdate={setPositionSession} /> @@ -1109,11 +1174,13 @@ const FLOW_NODE_GAP = 10; function BusinessChainFlow({ chain, nodes, + session, activeNodeKey, onSelectNode, }: { chain: BusinessChain | null; nodes: PositionRoundtableNode[]; + session: PositionRoundtableSession | null; activeNodeKey: string | null; onSelectNode: (node: PositionRoundtableNode) => void; }) { @@ -1134,11 +1201,11 @@ function BusinessChainFlow({ } + shape="circle" + icon={} onClick={() => setFlowDialogOpen(true)} - > - 查看 - + title="查看完整流程图" + /> )} {!chain ? ( @@ -1150,12 +1217,13 @@ function BusinessChainFlow({ - + @@ -1166,6 +1234,7 @@ function BusinessChainFlow({ { onSelectNode(node); @@ -1186,6 +1255,7 @@ function BusinessChainFlow({ function ChainFlowCanvas({ chain, nodes, + session, activeNodeKey, onSelectNode, className, @@ -1193,6 +1263,7 @@ function ChainFlowCanvas({ }: { chain: BusinessChain; nodes: PositionRoundtableNode[]; + session: PositionRoundtableSession | null; activeNodeKey: string | null; onSelectNode: (node: PositionRoundtableNode) => void; className?: string; @@ -1210,6 +1281,11 @@ function ChainFlowCanvas({ const nodeWidth = expanded ? 190 : FLOW_NODE_WIDTH; const nodeGap = expanded ? 16 : FLOW_NODE_GAP; const allDone = allExecutableNodesDone(nodes); + const summaryStatus = builtinNodeStatus(session?.summarySnapshot, allDone); + const actionPlanStatus = builtinNodeStatus( + session?.actionPlanSnapshot, + snapshotStatus(session?.summarySnapshot) === "done", + ); const onPanStart = (event: React.PointerEvent) => { if (event.button !== 0) return; @@ -1322,18 +1398,18 @@ function ChainFlowCanvas({ title="方案总结智能体" description="内置总结节点" accent="#6366f1" - nodeStatus={allDone ? "ready" : "locked"} + nodeStatus={summaryStatus} width={nodeWidth} - titleAttr="待接入:对所有有效交付产物进行方案总结" + titleAttr="对所有有效交付产物进行方案总结" /> @@ -1353,7 +1429,11 @@ function VerticalFlowConnector({ }) { const nodeCenter = nodeWidth / 2; if (seatCount <= 1) { - return ; + return ( + + + + ); } return ( @@ -1370,7 +1450,9 @@ function VerticalFlowConnector({ style={{ left: `${((index * (nodeWidth + nodeGap) + nodeCenter) / width) * 100}%`, }} - /> + > + + ))} ); @@ -1442,10 +1524,12 @@ function RightWorkspace({ intentCompletedAt, assignedAgentIds, hasSelectedChain, + session, onConfigureChain, nodes, chain, onSelectNode, + onSessionUpdate, }: { selectedAgent: Agent | null; selectedAgentId: string | null; @@ -1455,10 +1539,12 @@ function RightWorkspace({ intentCompletedAt: string; assignedAgentIds: Set; hasSelectedChain: boolean; + session: PositionRoundtableSession | null; onConfigureChain?: () => void; nodes: PositionRoundtableNode[]; chain: BusinessChain | null; onSelectNode: (node: PositionRoundtableNode) => void; + onSessionUpdate: (session: PositionRoundtableSession) => void; }) { const [tab, setTab] = useState("agents"); @@ -1540,7 +1626,9 @@ function RightWorkspace({ intentCompletedAt={intentCompletedAt} nodes={nodes} chain={chain} + session={session} onSelectNode={onSelectNode} + onSessionUpdate={onSessionUpdate} /> )} @@ -1706,39 +1794,64 @@ function PositionAgentList({ ); } +type ResultKind = "intent" | "delivery" | "artifact" | "summary" | "action_plan"; + +interface ResultItem { + id: string; + kind: ResultKind; + node: PositionRoundtableNode | null; + agentName: string; + productName: string; + summary: string; + positionId: string | null; + updatedAt: string | undefined; + status?: NodeVisualStatus; + artifactPath?: string; + artifactMimeType?: string | null; +} + function AnalysisResults({ intentObjective, intentCompletedAt, nodes, chain, + session, onSelectNode, + onSessionUpdate, }: { intentObjective?: string; intentCompletedAt: string; nodes: PositionRoundtableNode[]; chain: BusinessChain | null; + session: PositionRoundtableSession | null; onSelectNode: (node: PositionRoundtableNode) => void; + onSessionUpdate: (session: PositionRoundtableSession) => void; }) { - type ResultKind = "intent" | "delivery" | "artifact"; - interface ResultItem { - id: string; - kind: ResultKind; - node: PositionRoundtableNode | null; - agentName: string; - productName: string; - summary: string; - positionId: string | null; - updatedAt: string | undefined; - status?: NodeVisualStatus; - } - const [positionFilter, setPositionFilter] = useState("all"); const [nodeFilter, setNodeFilter] = useState("all"); const [kindFilter, setKindFilter] = useState<"all" | ResultKind>("all"); + const [detailItem, setDetailItem] = useState(null); + const [rejectNode, setRejectNode] = useState(null); + const [rejectTitle, setRejectTitle] = useState(""); + const [rejectReason, setRejectReason] = useState(""); + const [rejectSubmitting, setRejectSubmitting] = useState(false); + const [summarySubmitting, setSummarySubmitting] = useState(false); + const [actionPlanSubmitting, setActionPlanSubmitting] = useState(false); + const [previewArtifact, setPreviewArtifact] = useState<{ + threadId: string; + path: string; + name: string; + mimeType: string | null; + } | null>(null); const seatsByAgentId = useMemo( () => new Map((chain?.seats ?? []).map((seat) => [seat.agent_id, seat])), [chain], ); + const allDelivered = allExecutableNodesDone(nodes); + const summaryDone = snapshotStatus(session?.summarySnapshot) === "done"; + const summaryStale = snapshotStatus(session?.summarySnapshot) === "stale"; + const actionPlanDone = snapshotStatus(session?.actionPlanSnapshot) === "done"; + const actionPlanStale = snapshotStatus(session?.actionPlanSnapshot) === "stale"; const resultItems = useMemo(() => { const items: ResultItem[] = []; if (intentObjective) { @@ -1773,7 +1886,9 @@ function AnalysisResults({ const summary = node.latestAnswer || (node.status === "locked" - ? "等待上游阶段完成后解锁。" + ? node.invalidatedBy + ? "上游产物已被驳回或重做,等待前序节点重新完成。" + : "等待上游阶段完成后解锁。" : node.status === "running" ? "智能体正在生成回答与产物。" : node.status === "stale" @@ -1814,11 +1929,49 @@ function AnalysisResults({ positionId: node.positionId, updatedAt: node.updatedAt, status: node.status, + artifactPath: path, + artifactMimeType: mimeType, }); }); } + const summarySnapshot = session?.summarySnapshot; + if (summarySnapshot) { + items.push({ + id: "builtin:summary", + kind: "summary", + node: null, + agentName: "方案总结智能体", + productName: snapshotText(summarySnapshot, "title") || "岗位会商方案总结报告", + summary: + snapshotText(summarySnapshot, "content") || + (snapshotStatus(summarySnapshot) === "stale" + ? "前序岗位产物已变更,需要重新生成方案总结。" + : "方案总结已生成。"), + positionId: "summary", + updatedAt: snapshotText(summarySnapshot, "generated_at") || undefined, + status: builtinNodeStatus(summarySnapshot, true), + }); + } + const actionPlanSnapshot = session?.actionPlanSnapshot; + if (actionPlanSnapshot) { + items.push({ + id: "builtin:action-plan", + kind: "action_plan", + node: null, + agentName: "行动规划智能体", + productName: snapshotText(actionPlanSnapshot, "title") || "岗位会商行动规划", + summary: + snapshotText(actionPlanSnapshot, "content") || + (snapshotStatus(actionPlanSnapshot) === "stale" + ? "前序岗位产物已变更,需要重新生成行动规划。" + : "行动规划已生成。"), + positionId: "action_plan", + updatedAt: snapshotText(actionPlanSnapshot, "generated_at") || undefined, + status: builtinNodeStatus(actionPlanSnapshot, true), + }); + } return items.sort((left, right) => (right.updatedAt ?? "").localeCompare(left.updatedAt ?? "")); - }, [intentObjective, nodes, seatsByAgentId]); + }, [intentObjective, nodes, seatsByAgentId, session?.actionPlanSnapshot, session?.summarySnapshot]); const positionOptions = useMemo( () => [ { label: "全部岗位", value: "all" }, @@ -1851,6 +2004,91 @@ function AnalysisResults({ [kindFilter, nodeFilter, positionFilter, resultItems], ); + const openDetail = useCallback( + (item: ResultItem) => { + setDetailItem(item); + if (item.node) { + onSelectNode(item.node); + } + }, + [onSelectNode], + ); + + const openRejectDialog = useCallback((node: PositionRoundtableNode) => { + setRejectNode(node); + setRejectTitle(nodeRejectionTitle(node)); + setRejectReason(""); + }, []); + + const handleRejectSubmit = useCallback(async () => { + if (!rejectNode) return; + const title = rejectTitle.trim(); + const reason = rejectReason.trim(); + if (!title || !reason) { + toast.warning("请填写驳回标题和理由"); + return; + } + setRejectSubmitting(true); + try { + const session = await rejectPositionNode(rejectNode.sessionId, rejectNode.nodeKey, { + title, + reason, + }); + onSessionUpdate(session); + setRejectNode(null); + setRejectTitle(""); + setRejectReason(""); + setDetailItem(null); + toast.success("已驳回该产物,后续阶段已标记为需重新完成"); + } catch (error) { + toast.error(`驳回失败:${error instanceof Error ? error.message : String(error)}`); + } finally { + setRejectSubmitting(false); + } + }, [onSessionUpdate, rejectNode, rejectReason, rejectTitle]); + + const handleGenerateSummary = useCallback(async () => { + if (!session) { + toast.warning("请先启动业务链"); + return; + } + if (!allDelivered) { + toast.warning("所有岗位产物完成后才能生成方案总结"); + return; + } + setSummarySubmitting(true); + try { + const next = await generatePositionSummary(session.id); + onSessionUpdate(next); + toast.success("方案总结已生成"); + } catch (error) { + toast.error(`生成方案总结失败:${error instanceof Error ? error.message : String(error)}`); + } finally { + setSummarySubmitting(false); + } + }, [allDelivered, onSessionUpdate, session]); + + const handleGenerateActionPlan = useCallback(async () => { + if (!session) { + toast.warning("请先启动业务链"); + return; + } + if (!summaryDone) { + toast.warning("请先生成有效的方案总结"); + return; + } + setActionPlanSubmitting(true); + try { + const next = await generatePositionActionPlan(session.id); + onSessionUpdate(next); + toast.success("行动规划已生成"); + } catch (error) { + toast.error(`生成行动规划失败:${error instanceof Error ? error.message : String(error)}`); + } finally { + setActionPlanSubmitting(false); + } + }, [onSessionUpdate, session, summaryDone]); + if (resultItems.length === 0) { return (
@@ -1864,123 +2102,431 @@ function AnalysisResults({ } return ( -
-
-
-

交付产物列表

-

按岗位节点与生成时间展示

+ <> +
+
+
+

交付产物列表

+

按岗位节点与生成时间展示

+
+ + {visibleItems.length}/{resultItems.length} 项结果 +
- - {visibleItems.length}/{resultItems.length} 项结果 - -
-
- setPositionFilter(String(value))} /> - setNodeFilter(String(value))} /> - setKindFilter(String(value) as "all" | ResultKind)} - /> -
- {visibleItems.length === 0 ? ( -
- 当前筛选条件下没有结果 +
+ setPositionFilter(String(value))} /> + setNodeFilter(String(value))} /> + setKindFilter(String(value) as "all" | ResultKind)} + />
- ) : ( - visibleItems.map((item) => { - const displayedTime = item.updatedAt - ? new Intl.DateTimeFormat("zh-CN", { - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - hour12: false, - }).format(new Date(item.updatedAt)) - : intentCompletedAt || "刚刚"; - const stale = item.node?.status === "stale"; - const status = item.status ? nodeStatusMeta(item.status) : null; - const StatusIcon = status?.Icon; - const contents = ( - <> -
- - {item.kind === "artifact" ? : } - -
-
-

{desensitize(item.agentName)}

- {status && StatusIcon && ( - - - {status.label} - - )} - {stale && ( - - 上游已更新 - - )} -
-

- {desensitize(item.productName)} -

-

- {desensitize(item.summary)} -

-
+ {nodes.length > 0 && ( +
+
+
+

+ 内置收口智能体 +

+

+ 全部岗位交付后生成方案总结,再生成行动规划 +

-
- - - {positionNameById(item.positionId)} - - - - {displayedTime} - -
- - ); - return item.node ? ( - - ) : ( -
- {contents} -
- ); - }) + + {nodes.filter((node) => node.status === "done").length}/{nodes.length} 已交付 + +
+
+ } + onClick={() => void handleGenerateSummary()} + > + {summaryDone ? "重新生成方案总结" : summaryStale ? "重新生成失效总结" : "生成方案总结"} + + } + onClick={() => void handleGenerateActionPlan()} + > + {actionPlanDone ? "重新生成行动规划" : actionPlanStale ? "重新生成失效规划" : "生成行动规划"} + +
+
+ )} + {visibleItems.length === 0 ? ( +
+ 当前筛选条件下没有结果 +
+ ) : ( + visibleItems.map((item) => { + const displayedTime = item.updatedAt + ? new Intl.DateTimeFormat("zh-CN", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }).format(new Date(item.updatedAt)) + : intentCompletedAt || "刚刚"; + const stale = item.node?.status === "stale"; + const invalidated = Boolean(item.node?.invalidatedBy); + const status = item.status ? nodeStatusMeta(item.status) : null; + const StatusIcon = status?.Icon; + const contents = ( + <> +
+ + {item.kind === "artifact" ? : } + +
+
+

{desensitize(item.agentName)}

+ {status && StatusIcon && ( + + + {status.label} + + )} + {(stale || invalidated) && ( + + 上游已更新 + + )} +
+

+ {desensitize(item.productName)} +

+

+ {desensitize(item.summary)} +

+
+
+
+ + + {resultPositionName(item.positionId)} + + + + {displayedTime} + +
+ + ); + return item.node ? ( + + ) : ( + + ); + }) + )} +
+ + { + if (!open) setDetailItem(null); + }} + onReject={(node) => openRejectDialog(node)} + onPreviewArtifact={(item) => { + if (!item.node?.threadId || !item.artifactPath) { + toast.info("该文件产物暂无可预览路径"); + return; + } + setPreviewArtifact({ + threadId: item.node.threadId, + path: item.artifactPath, + name: item.productName, + mimeType: item.artifactMimeType ?? null, + }); + }} + /> + + { + if (!open && !rejectSubmitting) { + setRejectNode(null); + setRejectTitle(""); + setRejectReason(""); + } + }} + onTitleChange={setRejectTitle} + onReasonChange={setRejectReason} + onSubmit={() => void handleRejectSubmit()} + /> + + {previewArtifact && ( + setPreviewArtifact(null)} + /> )} -
+ + ); +} + +function DeliverableDetailDialog({ + item, + onOpenChange, + onReject, + onPreviewArtifact, +}: { + item: ResultItem | null; + onOpenChange: (open: boolean) => void; + onReject: (node: PositionRoundtableNode) => void; + onPreviewArtifact: (item: ResultItem) => void; +}) { + const status = item?.status ? nodeStatusMeta(item.status) : null; + const StatusIcon = status?.Icon; + const canReject = item?.node ? canRejectNodeDelivery(item.node) : false; + const rejectionTitle = item?.node ? nodeRejectionTitle(item.node) : ""; + const rejectionReason = item?.node ? nodeRejectionReason(item.node) : ""; + const displayedTime = item?.updatedAt + ? new Intl.DateTimeFormat("zh-CN", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }).format(new Date(item.updatedAt)) + : ""; + + return ( + + + + + + 交付产物详情 + + + {!item ? null : ( +
+
+
+

+ {desensitize(item.productName)} +

+

+ {desensitize(item.agentName)} · {resultPositionName(item.positionId)} +

+
+ {status && StatusIcon && ( + + + {status.label} + + )} +
+ +
+
+ 岗位:{resultPositionName(item.positionId)} +
+
+ 时间:{displayedTime || "暂无"} +
+ {item.node && ( + <> +
+ 版本:第 {item.node.revision} 版 +
+
+ 驳回次数:{item.node.rejectionCount} +
+ + )} +
+ + {item.node?.invalidatedBy && ( +
+ 该产物受上游节点「{item.node.invalidatedBy}」影响,已不再作为后续总结或问答输入。 +
+ )} + + {rejectionTitle && ( +
+

最近驳回:{desensitize(rejectionTitle)}

+ {rejectionReason &&

{desensitize(rejectionReason)}

} +
+ )} + +
+
+

+ {item.kind === "artifact" ? "文件产物" : "产物内容"} +

+ {item.kind === "artifact" && ( + } + onClick={() => onPreviewArtifact(item)} + > + 查看文件 + + )} +
+

+ {desensitize(item.summary || "暂无产物正文")} +

+
+
+ )} + + {item?.node && ( + } + disabled={!canReject} + onClick={() => onReject(item.node!)} + > + 驳回产物 + + )} + onOpenChange(false)}> + 关闭 + + +
+
+ ); +} + +function RejectDeliveryDialog({ + node, + title, + reason, + submitting, + onOpenChange, + onTitleChange, + onReasonChange, + onSubmit, +}: { + node: PositionRoundtableNode | null; + title: string; + reason: string; + submitting: boolean; + onOpenChange: (open: boolean) => void; + onTitleChange: (value: string) => void; + onReasonChange: (value: string) => void; + onSubmit: () => void; +}) { + return ( + + + + + + 驳回交付产物 + + +
+
+ 驳回后,该节点会回到需重做状态;其后续阶段的产物会一起失效,但历史内容仍可查看。 +
+ +