首页工作流执行:表单默认收起可展开、节点执行过程可视化(仿豆包)、失败支持重新执行/重新编辑、错误信息去重

- 提交后表单卡片默认收起(仅显示工作流标题),头部加明确展开/收起按钮
- node_start/node_complete 事件驱动节点步骤流,执行过程区仿豆包竖向展示
- 失败卡片拆分「重新执行」(复用已填值直接重跑)与「重新编辑」两按钮
- 失败消息只显示「 执行失败」,错误详情由卡片 footer 展示,避免双处重复

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-21 17:07:40 +08:00
co-authored by Claude
parent a95fe2d73f
commit cb6792cb29
5 changed files with 298 additions and 33 deletions
+5 -2
View File
@@ -21,6 +21,7 @@
:form-error="msg.formError"
:progress="msg.formProgress"
@submit="emit('form-submit', msg, $event)"
@retry="emit('form-retry', msg, $event)"
@re-edit="emit('form-re-edit', msg)"
@cancel="emit('form-cancel', msg)"
/>
@@ -100,6 +101,7 @@ import { Delete, DocumentCopy, RefreshRight } from '@element-plus/icons-vue';
import 'highlight.js/styles/github-dark.css';
import { renderMarkdown } from '../utils/markdown';
import type { WorkflowOutput } from '../utils/flowDsl';
import type { WorkflowNodeProgress } from '../utils/wsMessage';
import WorkflowFormCard from './WorkflowFormCard.vue';
import WorkflowOutputCard from './WorkflowOutputCard.vue';
@@ -120,8 +122,8 @@ interface ChatMessage {
form?: any;
formStatus?: 'editing' | 'running' | 'done' | 'failed';
formError?: string;
// 工作流执行节点进度(后端 node_start/node_complete 事件推进,运行中显示
formProgress?: { current: number; total: number; nodeName: string };
// 工作流执行节点进度(node_start/node_complete 事件驱动的节点步骤列表,渲染执行过程区
formProgress?: WorkflowNodeProgress;
// 工作流执行产出文件列表(挂在 workflow 结果消息上,渲染产出卡片)
outputs?: WorkflowOutput[];
}
@@ -131,6 +133,7 @@ interface Emits {
(e: 'regenerate', msg: ChatMessage): void;
(e: 'delete', msg: ChatMessage): void;
(e: 'form-submit', msg: ChatMessage, payload: any): void;
(e: 'form-retry', msg: ChatMessage, payload: any): void;
(e: 'form-re-edit', msg: ChatMessage): void;
(e: 'form-cancel', msg: ChatMessage): void;
(e: 'output-preview', msg: ChatMessage, output: WorkflowOutput): void;
@@ -13,6 +13,7 @@
@regenerate="emit('regenerate', $event)"
@delete="emit('delete', $event)"
@form-submit="(msg: any, payload: any) => emit('form-submit', msg, payload)"
@form-retry="(msg: any, payload: any) => emit('form-retry', msg, payload)"
@form-re-edit="(msg: any) => emit('form-re-edit', msg)"
@form-cancel="(msg: any) => emit('form-cancel', msg)"
@output-preview="(msg: any, output: any) => emit('output-preview', msg, output)"
@@ -71,6 +72,7 @@ interface Emits {
(e: 'workflow-select', id: string, isTemplate?: boolean): void;
(e: 'delete', msg: any): void;
(e: 'form-submit', msg: any, payload: any): void;
(e: 'form-retry', msg: any, payload: any): void;
(e: 'form-re-edit', msg: any): void;
(e: 'form-cancel', msg: any): void;
(e: 'output-preview', msg: any, output: any): void;
+194 -19
View File
@@ -1,12 +1,37 @@
<template>
<div class="workflow-form-card" :class="{ readonly, submitting, failed: isFailed }">
<div class="workflow-form-header">
<div>
<div class="workflow-form-header" @click="toggleCollapse">
<div class="header-left">
<h3>{{ detail?.flowName || '工作流表单' }}</h3>
<div class="workflow-form-sub">{{ detail?.description || '请填写以下参数' }}</div>
</div>
<div class="header-right">
<el-tag v-if="submitting" size="small" type="primary">执行中</el-tag>
<el-tag v-else-if="isFailed" size="small" type="danger">执行失败</el-tag>
<el-tag v-else-if="readonly" size="small" type="success">执行完成</el-tag>
<!-- 折叠切换明确按钮提示用户可展开/收起已填参数点击标题行同样可切换 -->
<button type="button" class="collapse-toggle" @click.stop="toggleCollapse">
<el-icon class="collapse-arrow" :class="{ collapsed }"><ArrowDown /></el-icon>
<span>{{ collapsed ? '展开' : '收起' }}</span>
</button>
</div>
</div>
<div class="workflow-form-scroll">
<!-- 节点执行过程区仿豆包步骤流有节点事件才显示始终可见不受参数收起影响 -->
<div v-if="progress?.nodes?.length" class="node-progress-list">
<div v-for="node in progress.nodes" :key="node.nodeId" class="node-step" :class="'is-' + node.status">
<span class="node-step-icon">
<span v-if="node.status === 'running'" class="node-spinner" />
<el-icon v-else-if="node.status === 'done'" class="node-icon-done"><CircleCheckFilled /></el-icon>
<el-icon v-else-if="node.status === 'failed'" class="node-icon-failed"><CircleCloseFilled /></el-icon>
<span v-else class="node-dot" />
</span>
<span class="node-step-name">{{ node.nodeName }}</span>
<span class="node-step-status">{{ nodeStatusText(node) }}</span>
</div>
</div>
<div v-show="!collapsed" class="workflow-form-scroll">
<el-form label-position="top" class="workflow-form">
<template v-if="detail?.nodeInputParams">
<template v-for="node in detail.nodeInputParams" :key="node.id || node.nodeCode">
@@ -153,10 +178,13 @@
<el-tag :type="isFailed ? 'danger' : 'success'" size="small" effect="light">
{{ isFailed ? '执行失败' : '执行完成' }}
</el-tag>
<!-- 失败卡片可「重新编辑并执行」:保留已填值切回可编辑,改完重新提交 -->
<el-button v-if="isFailed" size="small" @click="emit('re-edit')">
<el-icon><RefreshRight /></el-icon>重新编辑并执行
</el-button>
<!-- 失败卡片:可直接「重新执行」(复用当前已填值重跑),或「重新编辑」修改参数后再提交 -->
<template v-if="isFailed">
<el-button size="small" type="primary" @click="handleRetry">
<el-icon><RefreshRight /></el-icon>重新执行
</el-button>
<el-button size="small" @click="emit('re-edit')">重新编辑</el-button>
</template>
</template>
</div>
</div>
@@ -165,10 +193,11 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import { RefreshRight } from '@element-plus/icons-vue';
import { ArrowDown, CircleCheckFilled, CircleCloseFilled, RefreshRight } from '@element-plus/icons-vue';
import PatchTemplateEditor from '/@/components/patchTemplate/PatchTemplateEditor.vue';
import { uploadFile } from '/@/api/common/upload';
import { collectHomeFormFields } from '../utils/flowDsl';
import type { WorkflowNodeStep } from '../utils/wsMessage';
interface Props {
// 工作流详情(含 nodeInputParams / flowContent
@@ -179,14 +208,16 @@ interface Props {
submitting?: boolean;
// 执行失败信息(有值即 failed 态)
formError?: string;
// 执行进度(后端 node_start/node_complete 事件推进):当前节点序 / 总节点数 / 节点名
progress?: { current: number; total: number; nodeName: string };
// 执行节点进度(node_start/node_complete 事件驱动的步骤列表,渲染执行过程区)
progress?: { nodes: WorkflowNodeStep[] };
}
interface Emits {
(e: 'submit', payload: { formValues: Record<string, any>; formFileNames: Record<string, string | string[]>; templates: any[] }): void;
// 失败卡片「重新编辑并执行」:由父组件把 formStatus 切回 editing,保留已填值
(e: 're-edit'): void;
// 失败卡片「重新执行」:复用当前已填值直接重跑(不进入编辑态)
(e: 'retry', payload: { formValues: Record<string, any>; formFileNames: Record<string, string | string[]>; templates: any[] }): void;
// 执行中取消:工作流取消入口(InputBar 停止按钮对工作流禁用)
(e: 'cancel'): void;
}
@@ -195,7 +226,7 @@ const props = withDefaults(defineProps<Props>(), {
readonly: false,
submitting: false,
formError: '',
progress: () => ({ current: 0, total: 0, nodeName: '' }),
progress: () => ({ nodes: [] }),
});
const emit = defineEmits<Emits>();
@@ -210,15 +241,52 @@ const templates = ref<any[]>([]);
const isFailed = computed(() => !!props.formError);
const isDisabled = computed(() => props.readonly || props.submitting);
// 执行进度文本:有节点进度(current/total>0)显示「正在执行 N/M:节点名」,否则兜底「执行中...」
const progressText = computed(() => {
const p = props.progress;
if (p && p.total > 0 && p.current > 0) {
const name = p.nodeName ? `${p.nodeName}` : '';
return `正在执行 ${p.current}/${p.total}${name}...`;
}
return '执行中...';
// 表单收起/展开:editing(待提交/回显)默认展开;提交(running)自动收起;
// done/failed 保持提交时状态(默认收起,用户可手动展开查看已填值)
const collapsed = ref(false);
const formStatus = computed(() => {
if (props.submitting) return 'running';
if (props.formError) return 'failed';
if (props.readonly) return 'done';
return 'editing';
});
watch(
formStatus,
(st, prev) => {
if (st === 'editing') {
collapsed.value = false; // 回显/重新编辑:默认展开
} else if (prev === undefined) {
collapsed.value = true; // 初始即非编辑态(执行中/完成/失败)→ 收起
} else if (st === 'running') {
collapsed.value = true; // 提交瞬间自动收起
}
// done/failed 到达时保持当前(提交时已收起,用户手动展开则保留)
},
{ immediate: true }
);
const toggleCollapse = () => {
collapsed.value = !collapsed.value;
};
// 执行进度文本:有进行中节点显示「正在执行:节点名」,否则兜底「执行中...」
const progressText = computed(() => {
const running = (props.progress?.nodes || []).find((n) => n.status === 'running');
return running ? `正在执行:${running.nodeName}` : '执行中...';
});
// 节点步骤状态文案(执行过程区)
const nodeStatusText = (node: WorkflowNodeStep): string => {
switch (node.status) {
case 'running':
return '执行中';
case 'done':
return '完成';
case 'failed':
return '失败';
default:
return '待执行';
}
};
const getFieldKey = (node: any, field: any): string => {
const id = node.id || node.nodeCode;
@@ -481,6 +549,16 @@ const handleSubmit = () => {
templates: JSON.parse(JSON.stringify(templates.value || [])),
});
};
// 失败后直接重跑:复用当前已填值(必填此前已校验通过),不进入编辑态原样重新提交
const handleRetry = () => {
if (!isFailed.value || props.submitting) return;
emit('retry', {
formValues: JSON.parse(JSON.stringify(formValues)),
formFileNames: JSON.parse(JSON.stringify(formFileNames)),
templates: JSON.parse(JSON.stringify(templates.value || [])),
});
};
</script>
<style scoped lang="scss">
@@ -512,9 +590,106 @@ const handleSubmit = () => {
padding: 12px 20px;
border-bottom: 1px solid #f1f5f9;
flex-shrink: 0;
cursor: pointer;
user-select: none;
transition: background 0.15s;
&:hover { background: #f8fafc; }
h3 { margin: 0; font-size: 14px; font-weight: 600; color: #0f172a; }
.workflow-form-sub { font-size: 12px; color: #94a3b8; margin-top: 2px; }
}
.header-left { min-width: 0; }
.header-right {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
/* 折叠切换按钮:文字 + 箭头,明确提示可展开/收起参数区 */
.collapse-toggle {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 3px 9px;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #fff;
color: #64748b;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: all 0.15s;
&:hover {
border-color: #93c5fd;
color: #2563eb;
background: #eff6ff;
}
.collapse-arrow {
font-size: 12px;
line-height: 1;
transition: transform 0.2s;
&.collapsed { transform: rotate(-180deg); }
}
}
/* 节点执行过程区(豆包式步骤流) */
.node-progress-list {
background: #f8fafc;
border-bottom: 1px solid #f1f5f9;
padding: 10px 20px;
display: flex;
flex-direction: column;
gap: 8px;
}
.node-step {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
.node-step-icon {
width: 18px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.node-step-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #1e293b;
}
.node-step-status {
flex-shrink: 0;
font-size: 12px;
color: #64748b;
}
&.is-running .node-step-status { color: #2563eb; }
&.is-done .node-step-status { color: #22c55e; }
&.is-failed .node-step-status { color: #ef4444; }
}
.node-spinner {
width: 14px;
height: 14px;
border: 2px solid #dbeafe;
border-top-color: #2563eb;
border-radius: 50%;
animation: node-spin 0.8s linear infinite;
}
.node-icon-done { color: #22c55e; font-size: 15px; }
.node-icon-failed { color: #ef4444; font-size: 15px; }
.node-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #cbd5e1;
}
@keyframes node-spin {
to { transform: rotate(360deg); }
}
.workflow-form-scroll {
max-height: 55vh;
+58 -12
View File
@@ -32,6 +32,7 @@
@regenerate="handleRetry"
@delete="handleDeleteMessage"
@form-submit="handleFormSubmit"
@form-retry="handleFormRetry"
@form-re-edit="handleFormReEdit"
@form-cancel="handleFormCancel"
@output-preview="handleOutputPreview"
@@ -100,7 +101,7 @@ import { applyHomeFormValues, buildOutputsFromUrls } from './utils/flowDsl';
import type { WorkflowOutput } from './utils/flowDsl';
import { getChatModel, listModelManage } from '/@/api/settings/modelConfigV2';
import { connectSessionSocket, sendAgentStart, sendWorkflowStart, sendCancel } from './utils/wsExecute';
import { parseWsMessage, getDelta, getAnswer, getRecordId, getErrorText, getNodeProgress, getToolCallName, getToolResultText } from './utils/wsMessage';
import { parseWsMessage, getDelta, getAnswer, getRecordId, getErrorText, getNodeEvent, getToolCallName, getToolResultText, type WorkflowNodeProgress } from './utils/wsMessage';
import {
getWorkflowDetail,
deleteExecutionResult,
@@ -134,8 +135,8 @@ interface ChatMessage {
form?: any;
formStatus?: 'editing' | 'running' | 'done' | 'failed';
formError?: string;
// 工作流执行节点进度(后端 node_start/node_complete 事件推进,运行中显示
formProgress?: { current: number; total: number; nodeName: string };
// 工作流执行节点进度(node_start/node_complete 事件驱动的节点步骤列表,渲染执行过程区
formProgress?: WorkflowNodeProgress;
// 工作流执行产出文件列表(挂在结果消息上,渲染产出卡片)
outputs?: WorkflowOutput[];
}
@@ -961,6 +962,24 @@ const handleStopGenerate = () => {
};
// ===== 工作流执行:选中工作流 → 表单页 WS 执行 → 完成后切回对话页 =====
// 工作流节点进度推进:node_start → 该节点置「进行中」;node_complete → 置「完成」。
// 节点按 nodeIndex 顺序渐进累积(执行过程区随事件实时增长),重复事件以最近一次为准(重跑场景回到进行中)。
const updateNodeProgress = (formMsg: ChatMessage, msg: any) => {
const ev = getNodeEvent(msg);
if (!ev) return;
if (!formMsg.formProgress) formMsg.formProgress = { nodes: [] };
const list = formMsg.formProgress.nodes;
let step = list.find((n) => n.nodeId === ev.nodeId);
if (!step) {
step = { nodeId: ev.nodeId, nodeName: ev.nodeName, nodeIndex: ev.nodeIndex, status: 'pending' };
list.push(step);
list.sort((a, b) => a.nodeIndex - b.nodeIndex);
}
step.nodeName = ev.nodeName || step.nodeName;
step.status = ev.phase === 'start' ? 'running' : 'done';
};
const runWorkflow = async (
sid: string,
sessionId: string,
@@ -988,14 +1007,15 @@ const runWorkflow = async (
// 定格表单卡片:done/failed + 失败信息;执行结束清进度(避免残留影响重新编辑)
formMsg.formStatus = success ? 'done' : 'failed';
formMsg.formError = success ? undefined : errorMsg || '执行失败';
formMsg.formProgress = undefined;
// 保留 formProgress:执行过程区展示最终节点状态(完成全勾 / 失败处红叉)
curSession.status = success ? 'completed' : 'failed';
// 结果消息:成功时携带本次执行产出(outputs),直接以产出卡片渲染在对话里。
// 产出来源为 flow_complete 事件自带的 resultFileUrlsbuildOutputsFromUrls);无产出时提示「本次未生成产出文件」
const resultMsg: ChatMessage = {
id: 'msg-' + Date.now() + (success ? '-done' : '-fail'),
// 成功但无产出:明确告知「本次未生成产出文件」,避免用户以为产出丢失(产出来自 flow_complete.resultFileUrls
content: success ? (outputs?.length ? '✅ 执行完成' : '✅ 执行完成,本次未生成产出文件') : `${errorMsg || '执行失败,请重试或联系管理员'}`,
// 失败消息仅展示状态;错误详情由表单卡片 footer 的 form-error-text 展示,避免双处重复
content: success ? (outputs?.length ? '✅ 执行完成' : '✅ 执行完成,本次未生成产出文件') : '❌ 执行失败',
time: formatTime(new Date()),
isUser: false,
// 本轮记录 idround_start 首帧填充):与表单卡片共享同一记录,删除该记录即删除整组
@@ -1058,18 +1078,29 @@ const runWorkflow = async (
formMsg.recordType = 'workflow';
}
} else if (msg.type === 'error') {
// 失败:执行中的节点标记为失败,执行过程区展示中断位置
if (formMsg.formProgress) {
formMsg.formProgress.nodes.forEach((n) => {
if (n.status === 'running') n.status = 'failed';
});
}
finishExec(false, getErrorText(msg));
} else if (msg.type === 'flow_complete') {
// flow_complete 为后端新增的工作流完成信号,事件自带产出文件 URL 列表 → 直接渲染产出卡片;
// 以 round_start 首帧填充的 roundRecordId 作 backendId,保证实时产出可删除(与回显路径一致)
// 全部节点补完状态(个别节点未收到 complete 事件也视为已执行完成)
if (formMsg.formProgress) {
formMsg.formProgress.nodes.forEach((n) => {
if (n.status !== 'done') n.status = 'done';
});
}
finishExec(true, undefined, buildOutputsFromUrls(msg?.data?.resultFileUrls, roundRecordId));
} else if (msg.type === 'answer') {
// answer 为最终回答完成信号(无产出文件)
finishExec(true);
} else {
// 工作流节点进度推进:node_start / node_complete → 更新表单卡片运行态进度文本
const prog = getNodeProgress(msg);
if (prog) formMsg.formProgress = prog;
// 工作流节点进度推进:node_start / node_complete → 更新表单卡片节点步骤列表
updateNodeProgress(formMsg, msg);
}
return;
}
@@ -1090,10 +1121,15 @@ const runWorkflow = async (
abort: () => {
if (finished) return;
finished = true;
// 取消/删除:表单卡片定格为已取消(只读、保留已填值),不再追加汇总消息
// 取消/删除:表单卡片定格为已取消(只读、保留已填值),不再追加汇总消息
// 执行中的节点标记为失败,过程区保留展示中断位置
formMsg.formStatus = 'failed';
formMsg.formError = '执行已取消';
formMsg.formProgress = undefined;
if (formMsg.formProgress) {
formMsg.formProgress.nodes.forEach((n) => {
if (n.status === 'running') n.status = 'failed';
});
}
curSession.status = 'completed';
delete sendingSessions[sid];
},
@@ -1119,8 +1155,8 @@ const runWorkflow = async (
}
};
// ===== 表单卡片提交:工作流执行唯一入口(卡片内按钮触发) =====
const handleFormSubmit = async (
// ===== 表单卡片执行入口:提交 / 失败重试共用(卡片内按钮触发) =====
const startWorkflowFromCard = async (
msg: ChatMessage,
payload: { formValues: Record<string, any>; formFileNames: Record<string, string | string[]>; templates?: any[] }
) => {
@@ -1141,17 +1177,27 @@ const handleFormSubmit = async (
// 卡片定格执行中,禁用重复提交;表单卡片 running 态即提交意图,不追加用户消息
msg.formStatus = 'running';
msg.formError = undefined;
// 新执行开始:重置节点进度,从空列表随 node_start 事件渐进累积
msg.formProgress = undefined;
session.status = 'executing';
const sessionId = resolveSessionIdForConnect(session);
await runWorkflow(sid, sessionId, msg.form, payload, msg);
};
// 表单提交:用户填写后点击「提交执行」
const handleFormSubmit = (msg: ChatMessage, payload: any) => startWorkflowFromCard(msg, payload);
// 失败后「重新执行」:复用当前已填值直接重跑(卡片 handleRetry 收集当前值后触发,不进入编辑态)
const handleFormRetry = (msg: ChatMessage, payload: any) => startWorkflowFromCard(msg, payload);
// 失败卡片「重新编辑并执行」:切回 editing 保留已填值(组件内 formValues 随消息保留,
// detail 未变不触发重置),改完重新提交
const handleFormReEdit = (msg: ChatMessage) => {
msg.formStatus = 'editing';
msg.formError = undefined;
// 重新编辑:清除上次执行的节点进度,待再次提交后重新累积
msg.formProgress = undefined;
};
// 工作流执行中取消(卡片 footer「取消执行」):复用 teardownRound —— 发 cancel 帧 + 本轮 abort
+39
View File
@@ -92,6 +92,45 @@ export function getNodeProgress(msg: WsStreamMessage): { current: number; total:
};
}
/** 工作流执行节点步骤(node_start/node_complete/error 事件驱动,渲染表单卡片执行过程区) */
export interface WorkflowNodeStep {
nodeId: string;
nodeName: string;
nodeIndex: number;
status: 'pending' | 'running' | 'done' | 'failed';
}
/** 工作流执行节点进度(挂在表单卡片消息 formProgress 上) */
export interface WorkflowNodeProgress {
nodes: WorkflowNodeStep[];
}
/** 工作流节点事件解析结果:node_start / node_complete → { nodeId, nodeName, nodeIndex, phase } */
export interface WorkflowNodeEvent {
nodeId: string;
nodeName: string;
nodeIndex: number;
phase: 'start' | 'complete';
}
/**
* 解析工作流节点事件(node_start / node_complete)。
* 返回完整节点信息(nodeId/nodeName/nodeIndex/phase),供执行过程区步骤列表驱动;非节点事件返回 null。
*/
export function getNodeEvent(msg: WsStreamMessage): WorkflowNodeEvent | null {
const t = msg?.type;
if (t !== WsEventType.NodeStart && t !== WsEventType.NodeComplete) return null;
const d = msg?.data;
const nodeIndex = d?.nodeIndex;
if (typeof nodeIndex !== 'number') return null;
return {
nodeId: typeof d?.nodeId === 'string' ? d.nodeId : '',
nodeName: typeof d?.nodeName === 'string' ? d.nodeName : '',
nodeIndex,
phase: t === WsEventType.NodeStart ? 'start' : 'complete',
};
}
/** tool_call → 工具名称(tool / toolName / name 依次尝试,缺失返回空串) */
export function getToolCallName(msg: WsStreamMessage): string {
const d = msg?.data;