From dc2b766ef6ff72efe6b83b3ba6e2d95ec95a74e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E9=BE=99=E5=AE=89?= <297888591+wanglongan587@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:08:53 +0800 Subject: [PATCH 01/12] fix(v2): keep workflow nodes on their own run stages --- .../workspace-panel/workspace-panel.tsx | 4 +- .../chat/workflow-run/run-flow.test.tsx | 41 +++++++++++- .../features/chat/workflow-run/run-pane.tsx | 35 +++++----- .../workflow-run/run-tasks-stage.test.tsx | 26 ++++++++ .../chat/workflow-run/run-tasks-stage.tsx | 65 +++++++++++++++++++ .../chat/workflow-run/run-theater.tsx | 29 +++++++++ 6 files changed, 179 insertions(+), 21 deletions(-) create mode 100644 frontend-v2/src/features/chat/workflow-run/run-tasks-stage.test.tsx create mode 100644 frontend-v2/src/features/chat/workflow-run/run-tasks-stage.tsx diff --git a/frontend-v2/src/components/workspace-panel/workspace-panel.tsx b/frontend-v2/src/components/workspace-panel/workspace-panel.tsx index 0df263985..891a0d284 100644 --- a/frontend-v2/src/components/workspace-panel/workspace-panel.tsx +++ b/frontend-v2/src/components/workspace-panel/workspace-panel.tsx @@ -131,9 +131,9 @@ export function WorkspacePanel({ /** The header button that shows or hides a page's workspace panel. */ export function WorkspaceToggle({ open, toggle }: { open: boolean; toggle: () => void }) { return ( - ) } diff --git a/frontend-v2/src/features/chat/workflow-run/run-flow.test.tsx b/frontend-v2/src/features/chat/workflow-run/run-flow.test.tsx index adaf1ea8c..d81070bcd 100644 --- a/frontend-v2/src/features/chat/workflow-run/run-flow.test.tsx +++ b/frontend-v2/src/features/chat/workflow-run/run-flow.test.tsx @@ -33,12 +33,12 @@ const renderPage = async () => { const sidebar = () => screen.getByRole('complementary', { name: '聊天记录' }) const project = () => within(sidebar()).getByRole('region', { name: '项目 ora-space/cloud' }) -/** Creates a run of the review sample from the project's + menu, as a person would. */ -async function createRun(user: ReturnType, name: string) { +/** Creates a run of a published workflow from the project's + menu, as a person would. */ +async function createRunOf(user: ReturnType, workflow: RegExp, name: string) { await user.click(within(project()).getByRole('button', { name: '在 ora-space/cloud 新建对话' })) await user.click(await screen.findByRole('menuitem', { name: '运行工作流' })) // A pointer leaving the submenu's trigger closes the submenu in jsdom, so the item is clicked in place. - fireEvent.click(await screen.findByRole('menuitem', { name: /示例:代码审查/ })) + fireEvent.click(await screen.findByRole('menuitem', { name: workflow })) const dialog = await screen.findByRole('dialog', { name: '运行工作流' }) const field = within(dialog).getByLabelText('运行名称') await user.clear(field) @@ -46,6 +46,11 @@ async function createRun(user: ReturnType, name: string) await user.click(within(dialog).getByRole('button', { name: '创建运行' })) } +/** Creates a run of the review sample from the project's + menu, as a person would. */ +async function createRun(user: ReturnType, name: string) { + await createRunOf(user, /示例:代码审查/, name) +} + describe('workflow runs in chat', () => { let workflows: WorkflowServer let runs: RunServer @@ -148,6 +153,36 @@ describe('workflow runs in chat', () => { expect(screen.getByRole('textbox', { name: '消息' })).toHaveValue(followup) }, 30_000) + it('keeps each node on its own stage when the run contains a form node', async () => { + const graph = { + nodes: [ + { id: 'start', data: { kind: 'start', title: '开始:巡检', description: '接收巡检输入' } }, + { id: 'review', data: { kind: 'form', title: '确认巡检问题', description: '人工确认巡检结果' } }, + { id: 'output', data: { kind: 'output', title: '输出处置结果', description: '', outputs: [] } }, + ].map((node, index) => ({ ...node, type: 'workflow', position: { x: index * 340, y: 100 } })), + edges: [['start', 'review'], ['review', 'output']].map(([source, target]) => ({ id: `${source}-${target}`, source, target })), + } + const created = workflows.backend.create({ name: '巡检节点验收', graph: JSON.stringify(graph) }) + workflows.backend.publish({ workflowId: created.workflow.id, version: 'v1' }) + + const user = userEvent.setup() + await renderPage() + await createRunOf(user, /巡检节点验收/, '巡检节点验收运行') + const pane = await screen.findByRole('region', { name: '工作流运行' }) + await within(pane).findByRole('heading', { level: 2, name: '巡检节点验收运行' }, { timeout: 10_000 }) + await user.click(within(pane).getByRole('button', { name: '启动' })) + await user.click(within(await screen.findByRole('dialog')).getByRole('button', { name: '启动' })) + + // A finished run opens on its result act, not on the form node. + expect(await within(pane).findByText('运行完成', {}, { timeout: 15_000 })).toBeInTheDocument() + expect(within(pane).queryByRole('region', { name: '确认巡检问题' })).not.toBeInTheDocument() + + // Clicking a non-form node opens that node's own stage and never the form. + await user.click(within(pane).getByRole('button', { name: '开始:巡检: 成功' })) + expect(within(pane).getAllByText('接收巡检输入').length).toBeGreaterThan(0) + expect(within(pane).queryByRole('region', { name: '确认巡检问题' })).not.toBeInTheDocument() + }, 30_000) + it('reads an executing run again whenever its events stream says it changed', async () => { runs.hold = true const user = userEvent.setup() diff --git a/frontend-v2/src/features/chat/workflow-run/run-pane.tsx b/frontend-v2/src/features/chat/workflow-run/run-pane.tsx index a65bd53fe..6736a0f1f 100644 --- a/frontend-v2/src/features/chat/workflow-run/run-pane.tsx +++ b/frontend-v2/src/features/chat/workflow-run/run-pane.tsx @@ -1,4 +1,3 @@ -import { RunForm } from './run-form' import { QueryClientProvider, useQuery, useQueryClient } from '@tanstack/react-query' import { MapIcon, MessageSquareIcon, PlayIcon, RotateCcwIcon, SquareIcon, TheaterIcon, WorkflowIcon } from 'lucide-react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -120,7 +119,7 @@ function RunWorkspace({ tenantId, run, refreshProjectRuns }: { tenantId: string; const revealedArtifactId: string | null = null const selectRun = useChatStore((s) => s.selectRun) // A live run opens on its Theater; a pending or finished one on Overview, as desktop primes it. - const [viewMode, setViewMode] = useState(run.status === 'running' || run.status === 'awaiting_input' || run.definitionSnapshot.nodes.some(n=>n.data.kind==='form') ? 'theater' : 'overview') + const [viewMode, setViewMode] = useState(run.status === 'running' || run.status === 'awaiting_input' ? 'theater' : 'overview') const [focusNodeId, setFocusNodeId] = useState(null) /** Node whose conversation is open; it pins the act until closed. */ const [conversationNodeId, setConversationNodeId] = useState(null) @@ -247,8 +246,9 @@ function RunWorkspace({ tenantId, run, refreshProjectRuns }: { tenantId: string; setConversationNodeId(null) setFocusNodeId(nodeId) const waiting = run.nodeStates[nodeId]?.status === 'awaiting_input' + const kind = nodes.find((node) => node.id === nodeId)?.data.kind const stageWidth = stageAreaRef.current?.getBoundingClientRect().width ?? Number.POSITIVE_INFINITY - setOpenInspectorOnTheaterEnter(!waiting && stageWidth >= NARROW_THEATER_INSPECTOR_AUTO_OPEN_WIDTH) + setOpenInspectorOnTheaterEnter(!waiting && kind !== 'form' && kind !== 'createTasks' && stageWidth >= NARROW_THEATER_INSPECTOR_AUTO_OPEN_WIDTH) setViewMode('theater') } @@ -265,10 +265,10 @@ function RunWorkspace({ tenantId, run, refreshProjectRuns }: { tenantId: string; return ( -
+
-

+

@@ -277,15 +277,16 @@ function RunWorkspace({ tenantId, run, refreshProjectRuns }: { tenantId: string;
-
{run.status === 'pending' && ( - )} {(run.status === 'running' || run.status === 'awaiting_input') && ( - )} {finished && ( @@ -313,6 +314,7 @@ function RunWorkspace({ tenantId, run, refreshProjectRuns }: { tenantId: string; variant="outline" size="sm" className="h-7 px-2.5 text-xs" + aria-label="再跑一次" disabled={busy} onClick={() => act(async () => { @@ -323,11 +325,11 @@ function RunWorkspace({ tenantId, run, refreshProjectRuns }: { tenantId: string; } > - 再跑一次 + 再跑一次 )} {run.status === 'succeeded' && run.finalOutput && ( - )} workspace.setOpen(!workspace.open)} /> @@ -355,8 +357,9 @@ function RunWorkspace({ tenantId, run, refreshProjectRuns }: { tenantId: string; 还没开始:点右上角「启动」,填写输入后运行

)} - {viewMode === 'theater' && run.definitionSnapshot.nodes.some(n => n.data.kind === 'form' && ['awaiting_input','succeeded','cancelled'].includes(run.nodeStates[n.id]?.status ?? '')) ? : viewMode === 'theater' ? ( + {viewMode === 'theater' ? ( ({ status: 'succeeded', output: { summary: output, detail: output } }) + +it('lists created and reused tasks with their own links', () => { + const state = succeeded(JSON.stringify({ tasks: [ + { key: 'api', taskId: 'task-created', action: 'created' }, + { key: 'disk', taskId: 'task-reused', action: 'reused' }, + ] })) + render() + expect(screen.getByRole('region', { name: '任务处理结果' })).toBeInTheDocument() + expect(screen.getByText('已创建任务')).toBeInTheDocument() + expect(screen.getByText('已关联现有任务')).toBeInTheDocument() + expect(screen.getAllByRole('link', { name: '打开任务' })).toHaveLength(2) +}) + +it('does not invent tasks when the form confirmed nothing', () => { + const state = succeeded(JSON.stringify({ tasks: [] })) + render() + expect(screen.getByText('没有需要创建的任务')).toBeInTheDocument() + expect(screen.queryByRole('link', { name: '打开任务' })).not.toBeInTheDocument() +}) diff --git a/frontend-v2/src/features/chat/workflow-run/run-tasks-stage.tsx b/frontend-v2/src/features/chat/workflow-run/run-tasks-stage.tsx new file mode 100644 index 000000000..784106398 --- /dev/null +++ b/frontend-v2/src/features/chat/workflow-run/run-tasks-stage.tsx @@ -0,0 +1,65 @@ +import { ArrowUpRight, CheckCircle2, ClipboardCheck, Link2, Loader2, TriangleAlert } from 'lucide-react' +import { Link } from 'react-router' +import { Badge } from '@/components/ui/badge' +import { buttonVariants } from '@/components/ui/button' +import { cn } from '@/lib/utils' +import type { GraphWorkflowNodeState } from '@/lib/workflows/runtime' + +type TaskResult = { + key?: string + taskId?: string + action?: string +} + +function readTasks(state: GraphWorkflowNodeState | null): TaskResult[] { + const raw = state?.output?.detail ?? state?.output?.summary ?? '' + if (!raw) return [] + try { + const parsed: unknown = JSON.parse(raw) + const value = parsed !== null && typeof parsed === 'object' ? parsed as Record : {} + const rows = Array.isArray(value.tasks) ? value.tasks : Array.isArray(parsed) ? parsed : [] + return rows.filter((row): row is TaskResult => row !== null && typeof row === 'object') + } catch { + return [] + } +} + +/** CreateTasks owns the task outcome, so its stage lists exact created or reused tasks. */ +export function RunTasksStage({ nodeTitle, state }: { nodeTitle: string; state: GraphWorkflowNodeState | null }) { + const tasks = readTasks(state) + const status = state?.status ?? 'idle' + const pending = status === 'running' || status === 'awaiting_input' || status === 'retry_waiting' + return
+
+
+

协作任务

+

{nodeTitle || '创建或关联任务'}

+

确认结果已写入这次运行,任务由表单提交后确定性创建或关联。

+
+ + {pending ? : status === 'failed' ? : } + {pending ? '处理中' : status === 'failed' ? '失败' : status === 'succeeded' ? `${tasks.length} 项` : '待执行'} + +
+ {tasks.length > 0 ?
    + {tasks.map((task, index) =>
  • +
    +
    + + {task.action === 'reused' ? : } + +
    +

    {task.action === 'reused' ? '已关联现有任务' : '已创建任务'}

    +

    {task.taskId ?? task.key ?? '任务编号不可用'}

    +
    +
    + {task.taskId ? 打开任务 : null} +
    +
  • )} +
:
+ +

{pending ? '正在创建或关联任务' : status === 'failed' ? '任务处理失败' : '没有需要创建的任务'}

+

{pending ? '表单提交后,这里会列出每个问题的处理结果。' : status === 'failed' ? state?.errorMessage || '请回到确认节点检查提交结果后重试。' : '本次确认没有需要创建或关联的协作任务。'}

+
} +
+} diff --git a/frontend-v2/src/features/chat/workflow-run/run-theater.tsx b/frontend-v2/src/features/chat/workflow-run/run-theater.tsx index 191065c48..80120ec19 100644 --- a/frontend-v2/src/features/chat/workflow-run/run-theater.tsx +++ b/frontend-v2/src/features/chat/workflow-run/run-theater.tsx @@ -19,6 +19,8 @@ import { import { RunActInspector } from "./run-act-inspector"; import { RunResultAct } from "./run-result-act"; import { RunLoopStage } from "./run-loop-stage"; +import { RunForm } from "./run-form"; +import { RunTasksStage } from "./run-tasks-stage"; import { RunTheaterActCard } from "./run-theater-act-card"; import { RunTheaterParallelStage } from "./run-theater-parallel-stage"; import { RunTheaterPathRail } from "./run-theater-path-rail"; @@ -50,6 +52,7 @@ const INSPECTOR_FADE_START = 120; const PANEL_SETTLE_DURATION = 180; interface RunTheaterProps { + tenantId: string; run: GraphWorkflowRun; focusNodeId: string | null; onFocusNode: (nodeId: string) => void; @@ -85,6 +88,7 @@ interface RunTheaterProps { * Terminal + no path pin → result act. Esc (workspace) returns to Overview. */ export function RunTheater({ + tenantId, run, focusNodeId, onFocusNode, @@ -164,6 +168,12 @@ export function RunTheater({ ); const primaryId = focus.primaryId; const primaryNode = primaryId === null ? undefined : nodeById.get(primaryId); + const primaryStage = + primaryNode?.data.kind === "form" + ? "form" + : primaryNode?.data.kind === "createTasks" + ? "tasks" + : null; const primaryLoopId = primaryNode?.data.kind === "loop" ? primaryNode.id @@ -185,6 +195,12 @@ export function RunTheater({ return next; }); } + if ( + nodeById.get(nodeId)?.data.kind === "form" || + nodeById.get(nodeId)?.data.kind === "createTasks" + ) { + closeInspector(); + } setLoopResultId(null); onFocusNode(nodeId); } @@ -649,6 +665,8 @@ export function RunTheater({ "relative w-full", primaryConversationOpen ? "flex h-full min-h-0 max-w-none flex-1 flex-col" + : primaryStage !== null + ? "mx-auto w-full max-w-6xl shrink-0" : "mx-auto my-auto max-w-xl shrink-0", )} > @@ -670,6 +688,17 @@ export function RunTheater({ : undefined } /> + ) : primaryStage === "form" ? ( + + ) : primaryStage === "tasks" ? ( + ) : primaryNode?.data.kind === "loop" ? ( Date: Fri, 6 Nov 2026 15:09:03 +0800 Subject: [PATCH 02/12] feat(v2): redesign inspection review and DTS receipt --- .../chat/workflow-run/dts-receipt.tsx | 37 +- .../features/chat/workflow-run/run-form.tsx | 331 ++++++++++++++---- 2 files changed, 292 insertions(+), 76 deletions(-) diff --git a/frontend-v2/src/features/chat/workflow-run/dts-receipt.tsx b/frontend-v2/src/features/chat/workflow-run/dts-receipt.tsx index 1af8b1782..99f51b08c 100644 --- a/frontend-v2/src/features/chat/workflow-run/dts-receipt.tsx +++ b/frontend-v2/src/features/chat/workflow-run/dts-receipt.tsx @@ -1,7 +1,9 @@ import { useQuery } from '@tanstack/react-query' import { Link } from 'react-router' -import { Button } from '@/components/ui/button' -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' +import { ArrowUpRight, ExternalLink, ShieldAlert } from 'lucide-react' +import { Badge } from '@/components/ui/badge' +import { Button, buttonVariants } from '@/components/ui/button' +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' import { simulateDtsSubmission } from '@/lib/demo/dts' import { demoMockEnabled } from '@/lib/demo/flags' @@ -12,10 +14,33 @@ export function DtsReceipt({ taskId, title, description, severity, module, runId if (receipt.isError) return

DTS 模拟提交失败,Cloud 任务已保留。

if (!receipt.data) return

正在生成 DTS 模拟回执…

return }>DTS 演示 · {receipt.data.number} - DTS 模拟问题单 -

仅供演示,尚未向 DTS 提交。

-
单号
{receipt.data.number}
标题
{title}
问题描述
{description}
严重程度
{severity || '未指定'}
所属模块
{module || '未指定'}
处理人
沿用关联 Cloud 任务的负责人
- 打开 Cloud 任务来源巡检报告 + + +
+ +
+ DTS 模拟问题单 + 用真实的 Cloud 任务信息生成,尚未向外部 DTS 写入。 +
+
+
+
+
+ {receipt.data.number} + 仅演示 + {severity || '未定级'} +
+
+
标题
{title}
+
问题描述
{description}
+
所属模块
{module || '未指定'}
+
处理人
沿用关联 Cloud 任务的负责人
+
+
+ + 来源巡检报告 + 打开 Cloud 任务 +
} diff --git a/frontend-v2/src/features/chat/workflow-run/run-form.tsx b/frontend-v2/src/features/chat/workflow-run/run-form.tsx index a337f8ce5..4661a1e06 100644 --- a/frontend-v2/src/features/chat/workflow-run/run-form.tsx +++ b/frontend-v2/src/features/chat/workflow-run/run-form.tsx @@ -6,116 +6,307 @@ import { DtsReceipt } from './dts-receipt' import { useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Link } from 'react-router' -import { Button } from '@/components/ui/button' +import { + ArrowUpRight, + CheckCircle2, + ChevronDown, + CircleDashed, + ClipboardCheck, + FileText, + ListFilter, + RotateCcw, + Save, + Send, + ShieldCheck, + TriangleAlert, + XCircle, +} from 'lucide-react' +import { Badge } from '@/components/ui/badge' +import { Button, buttonVariants } from '@/components/ui/button' import { Field, FieldGroup, FieldLabel, FieldError } from '@/components/ui/field' import { Input } from '@/components/ui/input' -import { Textarea } from '@/components/ui/textarea' import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select' +import { Progress } from '@/components/ui/progress' +import { Textarea } from '@/components/ui/textarea' import { getWorkflowForm, saveWorkflowFormDraft, submitWorkflowForm, retryWorkflowFormTasks, type WorkflowForm } from '@/lib/workflows/api/generated' import { TaskAssigneePicker } from '@/components/task-assignee-picker' import type { TaskAssigneeRef } from '@/features/collaboration/tasks-api/generated' import { listSquads } from '@/lib/squads-api/generated' +import { cn } from '@/lib/utils' type Answer = { key: string; decision: string; assignee?: TaskAssigneeRef; values: Record } +type FindingFilter = 'all' | 'pending' | 'confirmed' | 'rejected' const text = (v: unknown) => typeof v === 'string' ? v : '' -function readAnswers(form:WorkflowForm):Answer[]{return form.findings.map(row=>{ - const saved=form.answers.find(a=>a.key===row.key) - return {key:row.key,decision:saved?.decision||'pending',values:saved?.values??{},assignee:saved?.assignee??form.existingTasks?.find(t=>t.key===row.key)?.assignee??undefined} -})} +function readAnswers(form: WorkflowForm): Answer[] { + return form.findings.map(row => { + const saved = form.answers.find(a => a.key === row.key) + return { + key: row.key, + decision: saved?.decision || 'pending', + values: saved?.values ?? {}, + assignee: saved?.assignee ?? form.existingTasks?.find(t => t.key === row.key)?.assignee ?? undefined, + } + }) +} + +const severityTone = (severity: string) => { + if (severity.includes('高') || /critical|high/i.test(severity)) return 'border-destructive/30 bg-destructive/10 text-destructive' + if (severity.includes('中') || /medium/i.test(severity)) return 'border-warning/35 bg-warning/10 text-warning' + if (severity.includes('低') || /low/i.test(severity)) return 'border-info/30 bg-info/10 text-info' + return 'border-border bg-muted text-muted-foreground' +} + +const decisionLabel = (decision: string) => decision === 'confirmed' ? '是问题' : decision === 'rejected' ? '不是问题' : '待确认' /** Reads a durable review; refreshing never invents a new form or task. */ export function RunForm({ tenantId, runId, runStatus }: { tenantId: string; runId: string; runStatus: string }) { const query = useQuery({ queryKey: ['workflow-form', tenantId, runId], queryFn: () => getWorkflowForm(tenantId, runId), refetchInterval: 3000, retry: false }) - if (query.isPending) return

正在读取确认表单…

- if (!query.data) return
表单尚未生成,运行到确认步骤后会自动显示。
+ if (query.isPending) return
正在读取确认表单…
+ if (!query.data) return
表单尚未生成,运行到确认步骤后会自动显示。
return } function FormEditor({ form, tenantId, runStatus }: { form: WorkflowForm; tenantId: string; runStatus: string }) { - const roster=useGetApiV1TenantsTidMembers(tenantId,undefined,{query:{enabled:!!tenantId}}) + const roster = useGetApiV1TenantsTidMembers(tenantId, undefined, { query: { enabled: !!tenantId } }) const client = useQueryClient() const [version, setVersion] = useState(form.version) const [answers, setAnswers] = useState(() => readAnswers(form)) - const [dirty,setDirty]=useState(false) - const [locallySubmitted,setLocallySubmitted]=useState(false) - if(!dirty && form.version>version){setVersion(form.version);setAnswers(readAnswers(form))} - const displayAnswers = form.status==='open' ? answers : readAnswers(form) + const [dirty, setDirty] = useState(false) + const [locallySubmitted, setLocallySubmitted] = useState(false) + if (!dirty && form.version > version) { + setVersion(form.version) + setAnswers(readAnswers(form)) + } + const displayAnswers = form.status === 'open' ? answers : readAnswers(form) const [notice, setNotice] = useState('') - const [errors,setErrors]=useState>({}) - const [pendingOnly, setPendingOnly] = useState(false) + const [errors, setErrors] = useState>({}) + const [filter, setFilter] = useState('all') const [submitKey] = useState(() => crypto.randomUUID()) const squads = useQuery({ queryKey: ['squads', tenantId], queryFn: () => listSquads(tenantId) }) - const mutation = useMutation({ mutationFn: (final: boolean) => final - ? submitWorkflowForm(tenantId, form.runId, { version, answers }, { headers: { 'Idempotency-Key': submitKey } }) - : saveWorkflowFormDraft(tenantId, form.runId, { version, answers }), + const mutation = useMutation({ + mutationFn: (final: boolean) => final + ? submitWorkflowForm(tenantId, form.runId, { version, answers }, { headers: { 'Idempotency-Key': submitKey } }) + : saveWorkflowFormDraft(tenantId, form.runId, { version, answers }), onError: (error) => { - if(isAxiosError(error)){const data=error.response?.data as {key?:string;field?:string}|undefined;if(data?.key&&data.field)setErrors({[`${data.key}:${data.field}`]:'请检查这一项的输入。'})} + if (isAxiosError(error)) { + const data = error.response?.data as { key?: string; field?: string } | undefined + if (data?.key && data.field) setErrors({ [`${data.key}:${data.field}`]: '请检查这一项的输入。' }) + } }, onSuccess: (saved, final) => { - setDirty(false);if(final)setLocallySubmitted(true) + setDirty(false) + if (final) setLocallySubmitted(true) setVersion(saved.version) setNotice(final ? '确认已提交,正在创建或关联任务。' : '草稿已保存。') void client.invalidateQueries({ queryKey: ['workflow-form', tenantId, form.runId] }) void client.invalidateQueries({ queryKey: ['workflow-runs', tenantId] }) }, }) - const retry = useMutation({mutationFn:()=>retryWorkflowFormTasks(tenantId,form.runId),onSuccess:()=>client.invalidateQueries({queryKey:['workflow-form',tenantId,form.runId]})}) + const retry = useMutation({ + mutationFn: () => retryWorkflowFormTasks(tenantId, form.runId), + onSuccess: () => client.invalidateQueries({ queryKey: ['workflow-form', tenantId, form.runId] }), + }) const readOnly = !form.canEdit || mutation.isPending || locallySubmitted const remaining = displayAnswers.filter(a => a.decision === 'pending').length - const complete = remaining === 0 && answers.every(a => a.decision !== 'confirmed' || a.assignee || form.existingTasks?.some(t=>t.key===a.key)) + const complete = remaining === 0 && answers.every(a => a.decision !== 'confirmed' || a.assignee || form.existingTasks?.some(t => t.key === a.key)) const fields = form.config.fields - function patch(key: string, change: Partial) { setDirty(true);setErrors({}); setAnswers(current => current.map(a => a.key === key ? { ...a, ...change } : a)); setNotice('有未保存的修改') } - function save(final:boolean){ - const found:Record={} - if(final)for(const a of answers){ - if(a.decision==='pending')found[`${a.key}:decision`]='请选择确认结果。' - if(a.decision==='confirmed'){ - if(!a.assignee&&!form.existingTasks?.some(t=>t.key===a.key))found[`${a.key}:assignee`]='请选择负责人。' - for(const f of fields)if(f.required===true&&f.type!=='readonly'&&!a.values[text(f.name)]?.trim())found[`${a.key}:${text(f.name)}`]='请填写这一项。' + const counts = { + confirmed: displayAnswers.filter(a => a.decision === 'confirmed').length, + rejected: displayAnswers.filter(a => a.decision === 'rejected').length, + pending: remaining, + } + const total = displayAnswers.length + const progress = total === 0 ? 100 : Math.round(((total - remaining) / total) * 100) + const visibleFindings = form.findings.filter(row => { + if (filter === 'all') return true + return (displayAnswers.find(a => a.key === row.key)?.decision || 'pending') === filter + }) + const title = text(form.config.title) || '问题确认' + const submittedBy = roster.data?.items.find(m => m.userId === form.submittedBy)?.displayName ?? '确认人' + + function patch(key: string, change: Partial) { + setDirty(true) + setErrors({}) + setAnswers(current => current.map(a => a.key === key ? { ...a, ...change } : a)) + setNotice('有未保存的修改') + } + + function save(final: boolean) { + const found: Record = {} + if (final) { + for (const a of answers) { + if (a.decision === 'pending') found[`${a.key}:decision`] = '请选择确认结果。' + if (a.decision === 'confirmed') { + if (!a.assignee && !form.existingTasks?.some(t => t.key === a.key)) found[`${a.key}:assignee`] = '请选择负责人。' + for (const f of fields) if (f.required === true && f.type !== 'readonly' && !a.values[text(f.name)]?.trim()) found[`${a.key}:${text(f.name)}`] = '请填写这一项。' + } } } - setErrors(found);if(Object.keys(found).length)return;mutation.mutate(final) + setErrors(found) + if (Object.keys(found).length) return + mutation.mutate(final) } - return
-

{text(form.config.title) || '问题确认'}

{form.status === 'cancelled' ? '运行已取消,表单不可提交' : form.status === 'submitted' ? runStatus==='failed' ? '确认已保存 · 提单失败,可重试' : runStatus==='succeeded' ? '确认完成 · 查看下方处理结果' : '确认已保存 · 正在生成任务' : `已确认 ${answers.length - remaining}/${answers.length} 项`}

- {form.status==='submitted' &&

提交人:{roster.data?.items.find(m=>m.userId===form.submittedBy)?.displayName ?? '确认人'}{form.submittedAt ? ` · ${new Date(form.submittedAt).toLocaleString()}` : ''}

} -
报告
{form.report}
- - {form.findings.length === 0 &&

本次未发现需要人工处理的问题。

} - {form.findings.map(row => { - const key = text(row.key), answer = displayAnswers.find(a => a.key === key)! - if (pendingOnly && answer.decision !== 'pending') return null - const result = form.results.find(r => r.key === key) - const existing = form.existingTasks?.find(r=>r.key===key) - return
-

{text(row.title)}

{text(row.module)} · {text(row.severity)}

-

{text(row.description)}

-
查看证据

{text(row.evidence)}

- - 是否为问题 patch(key, { decision: e.target.value })}>待确认是问题不是问题 - {existing && !result &&

确认后将关联已有任务,沿用其负责人:查看任务

} - {answer.decision === 'confirmed' && {existing ? '新建时的备用负责人(不改变已有任务)' : '负责人'} patch(key, { assignee: assignee ?? undefined })} />} - {fields.map(f => { - const name = text(f.name), value = answer.values[name] ?? '' - if(f.type==='readonly')return {text(f.label)}

{text(Object.entries(row).find(([key])=>key===name)?.[1])}

- const update = (value: string) => patch(key, { values: { ...answer.values, [name]: value } }) - return {text(f.label)}{f.required === true ? ' *' : ''} - {f.type === 'select' ? update(e.target.value)}>请选择{(Array.isArray(f.options) ? f.options : []).map(o => {String(o)})} : f.type === 'textarea' ?