- 修复:执行时把用户填的表单值写回消息对象,会话认领重建后能恢复,不再清空;执行完成/历史回显卡片默认收起,可手动展开改参 - 功能:首页工作流最多展示 9 个(用户优先、模板补足),超出显示「更多」入口跳转工作流管理页 - 功能:历史会话分页「加载更多」,逐页追加去重 - 重构:工作流执行信号统一走提交(移除 retry/re-edit/cancel),失败卡片重跑/重编辑并入提交路径 - 其他:删除消息时终止进行中的轮;Markdown 渲染缓存设上限 Co-Authored-By: Claude <noreply@anthropic.com>
807 lines
27 KiB
Vue
807 lines
27 KiB
Vue
<template>
|
||
<div class="workflow-form-card" :class="{ readonly, submitting, failed: isFailed }">
|
||
<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="formStatus === 'done'" 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 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">
|
||
<template v-if="hasFormConfig(node)">
|
||
<el-form-item
|
||
v-for="field in getVisibleFields(node)"
|
||
:key="getFieldKey(node, field)"
|
||
:label="field.label"
|
||
:required="field.required"
|
||
>
|
||
<!-- 引用其他节点输出:只读展示,值由上游节点运行时提供 -->
|
||
<div v-if="field.valueSource" class="field-source-readonly">
|
||
<el-tag type="info" size="small" class="source-tag">引用</el-tag>
|
||
<span class="source-text">由「{{ getSourceDisplay(field) }}」自动提供</span>
|
||
</div>
|
||
|
||
<template v-else>
|
||
<!-- 文本输入 -->
|
||
<el-input
|
||
v-if="field.type === 'input' || field.type === 'string'"
|
||
v-model="formValues[getFieldKey(node, field)]"
|
||
:disabled="isDisabled"
|
||
:placeholder="field.required ? '必填' : '选填'"
|
||
clearable
|
||
/>
|
||
|
||
<!-- 数字输入 -->
|
||
<el-input-number
|
||
v-else-if="field.type === 'number' || field.type === 'inputNumber'"
|
||
v-model="formValues[getFieldKey(node, field)]"
|
||
class="w100"
|
||
:controls="true"
|
||
:disabled="isDisabled"
|
||
:min="field.fieldConstraint?.minValue ?? undefined"
|
||
:max="field.fieldConstraint?.maxValue ?? undefined"
|
||
/>
|
||
|
||
<!-- 多行文本 -->
|
||
<el-input
|
||
v-else-if="field.type === 'textarea'"
|
||
v-model="formValues[getFieldKey(node, field)]"
|
||
type="textarea"
|
||
:rows="3"
|
||
:disabled="isDisabled"
|
||
:placeholder="field.required ? '必填' : '选填'"
|
||
show-word-limit
|
||
:maxlength="500"
|
||
/>
|
||
|
||
<!-- 开关 -->
|
||
<el-switch
|
||
v-else-if="field.type === 'switch'"
|
||
v-model="formValues[getFieldKey(node, field)]"
|
||
:disabled="isDisabled"
|
||
active-text="开启"
|
||
inactive-text="关闭"
|
||
/>
|
||
|
||
<!-- 下拉选择 -->
|
||
<el-select
|
||
v-else-if="field.type === 'select' && field.options"
|
||
v-model="formValues[getFieldKey(node, field)]"
|
||
:disabled="isDisabled"
|
||
:placeholder="field.required ? '必填' : '选填'"
|
||
class="w100"
|
||
>
|
||
<el-option v-for="opt in field.options" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||
</el-select>
|
||
|
||
<!-- 文件上传 -->
|
||
<div v-else-if="isFileField(field)" class="field-upload-wrapper">
|
||
<el-upload
|
||
:key="`upload-${getFieldKey(node, field)}`"
|
||
:auto-upload="false"
|
||
:multiple="field.type === 'uploadMultiple'"
|
||
:show-file-list="false"
|
||
:accept="getFileAccept(field)"
|
||
:disabled="isDisabled"
|
||
:on-change="(file: any) => handleFileUpload(node, field, file)"
|
||
>
|
||
<el-button
|
||
size="small"
|
||
type="primary"
|
||
:loading="uploadingFields[getFieldKey(node, field)]"
|
||
:disabled="isDisabled || uploadingFields[getFieldKey(node, field)]"
|
||
>
|
||
{{ uploadingFields[getFieldKey(node, field)] ? '上传中...' : '选择文件' }}
|
||
</el-button>
|
||
</el-upload>
|
||
<div class="upload-rules">
|
||
<span>{{ getFileRuleText(field) }}</span>
|
||
<span v-if="getFieldFileList(node, field).length > 0" class="upload-count">
|
||
已上传 {{ getFieldFileCountText(node, field) }}
|
||
</span>
|
||
</div>
|
||
<div v-if="getFieldFileList(node, field).length > 0" class="uploaded-files-list">
|
||
<div
|
||
v-for="(f, fileIdx) in getFieldFileList(node, field)"
|
||
:key="fileIdx"
|
||
class="uploaded-file-item"
|
||
>
|
||
<span class="file-name">{{ f.name }}</span>
|
||
<el-button v-if="!isDisabled" type="danger" link size="small" @click="removeFieldFile(node, field, fileIdx)">
|
||
删除
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 兜底文本输入 -->
|
||
<el-input
|
||
v-else
|
||
v-model="formValues[getFieldKey(node, field)]"
|
||
:disabled="isDisabled"
|
||
:placeholder="field.required ? '必填' : '选填'"
|
||
/>
|
||
</template>
|
||
</el-form-item>
|
||
</template>
|
||
</template>
|
||
</template>
|
||
</el-form>
|
||
|
||
<!-- 贴片模板编辑器 -->
|
||
<div v-if="currentWorkflowHasPatchLayout" class="patch-template-section">
|
||
<PatchTemplateEditor v-model="templates" />
|
||
</div>
|
||
|
||
<el-empty v-if="!hasFormFields && !currentWorkflowHasPatchLayout" description="该工作流无需填写参数" :image-size="60" />
|
||
</div>
|
||
|
||
<!-- 底部操作区:editing 提示 / running 执行中 / done·failed 状态标签(执行统一由 InputBar 发送按钮触发) -->
|
||
<div class="workflow-form-footer">
|
||
<div v-if="isFailed" class="form-error-text">{{ formError }}</div>
|
||
<!-- 执行中:节点文本进度推进(后端 node_start/node_complete 事件),无进度时兜底「执行中...」;
|
||
停止统一由 InputBar 发送按钮(变停止)触发,卡片内不再提供取消入口 -->
|
||
<div v-if="submitting" class="executing-progress">
|
||
<span class="exec-spinner" />
|
||
<span class="exec-text">{{ progressText }}</span>
|
||
</div>
|
||
<!-- 已执行卡片(done/failed,含可编辑的最后一张):显示状态标签,参数是否可编辑由 editable 决定 -->
|
||
<el-tag v-else-if="formStatus === 'done' || formStatus === 'failed'" :type="isFailed ? 'danger' : 'success'" size="small" effect="light">
|
||
{{ isFailed ? '执行失败' : '执行完成' }}
|
||
</el-tag>
|
||
<!-- 编辑态:无执行按钮,提示通过下方发送按钮执行 -->
|
||
<span v-else class="form-edit-tip">填写参数后,点击下方发送按钮执行工作流</span>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, reactive, ref, watch } from 'vue';
|
||
import { ElMessage } from 'element-plus';
|
||
import { ArrowDown, CircleCheckFilled, CircleCloseFilled } 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)
|
||
detail: any;
|
||
// 定格只读(done/failed 态):禁用表单与提交
|
||
readonly?: boolean;
|
||
// 执行中:禁用表单,底部显示加载中
|
||
submitting?: boolean;
|
||
// 执行失败信息(有值即 failed 态)
|
||
formError?: string;
|
||
// 执行节点进度(node_start/node_complete 事件驱动的步骤列表,渲染执行过程区)
|
||
progress?: { nodes: WorkflowNodeStep[] };
|
||
// InputBar 发送按钮触发执行信号:仅当本卡片为目标时非 null(统一走提交,含必填校验)
|
||
executeSignal?: { action: 'submit'; nonce: number } | null;
|
||
// 已执行卡片(done/failed)参数可编辑(会话中最后一张执行过的工作流):改参后经发送按钮重新执行
|
||
editable?: boolean;
|
||
}
|
||
|
||
interface Emits {
|
||
(e: 'submit', payload: { formValues: Record<string, any>; formFileNames: Record<string, string | string[]>; templates: any[] }): void;
|
||
}
|
||
|
||
const props = withDefaults(defineProps<Props>(), {
|
||
readonly: false,
|
||
submitting: false,
|
||
formError: '',
|
||
progress: () => ({ nodes: [] }),
|
||
executeSignal: null,
|
||
editable: false,
|
||
});
|
||
const emit = defineEmits<Emits>();
|
||
|
||
const formValues = reactive<Record<string, any>>({});
|
||
const fieldFiles = reactive<Record<string, { name: string; url: string }[]>>({});
|
||
// 上传字段的文件名(与 formValues 平行;单文件为字符串、多文件为数组),执行时随 value 一并写回开始节点传给后端
|
||
const formFileNames = reactive<Record<string, string | string[]>>({});
|
||
const uploadingFields = reactive<Record<string, boolean>>({});
|
||
const templates = ref<any[]>([]);
|
||
|
||
// 状态派生:失败 = 携带 formError;禁用编辑 = 定格(done/failed)卡片默认只读,
|
||
// 但最后一张已执行卡片(editable)允许改参;执行中(submitting)一律禁用
|
||
const isFailed = computed(() => !!props.formError);
|
||
const isDisabled = computed(() => (props.readonly && !props.editable) || props.submitting);
|
||
|
||
// 表单收起/展开: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;
|
||
return `${id}|${field.path || field.field || field.label}`;
|
||
};
|
||
|
||
const getVisibleFields = (node: any): any[] => {
|
||
return collectHomeFormFields(node);
|
||
};
|
||
|
||
// 引用来源展示:valueSource 统一数组 [{ nodeId, field }](兼容旧 DSL 单对象),逐项定位上游节点名 + 字段
|
||
const getSourceDisplay = (field: any): string => {
|
||
const vs = field?.valueSource;
|
||
if (!vs || typeof vs !== 'object') return '';
|
||
const arr = Array.isArray(vs) ? vs : [vs];
|
||
const nodes = props.detail?.nodeInputParams || [];
|
||
const parts = arr
|
||
.filter((r: any) => r && typeof r === 'object')
|
||
.map((r: any) => {
|
||
const srcNode = nodes.find((n: any) => String(n?.id) === String(r.nodeId));
|
||
const nodeName = srcNode?.name || srcNode?.nodeName || (r.nodeId ? `节点 ${r.nodeId}` : '上游节点');
|
||
return r.field ? `${nodeName} · ${r.field}` : nodeName;
|
||
});
|
||
return parts.join(',');
|
||
};
|
||
|
||
const isFileField = (field: any): boolean => {
|
||
return field.type === 'upload' || field.type === 'uploadMultiple' || field.type === 'fileUpload';
|
||
};
|
||
|
||
const getFileAccept = (field: any): string => {
|
||
return field.fieldConstraint?.fileTypes ? '.' + field.fieldConstraint.fileTypes.replace(/,/g, ',.') : '*';
|
||
};
|
||
|
||
const getFileRuleText = (field: any): string => {
|
||
const rules: string[] = [];
|
||
if (field.fieldConstraint?.fileTypes) rules.push('格式: ' + field.fieldConstraint.fileTypes);
|
||
if (field.fieldConstraint?.maxFileSize) rules.push('最大: ' + field.fieldConstraint.maxFileSize + 'MB');
|
||
if (field.fieldConstraint?.maxFileCount) rules.push('最多: ' + field.fieldConstraint.maxFileCount + '个');
|
||
return rules.join(' | ');
|
||
};
|
||
|
||
const getFieldFileList = (node: any, field: any): { name: string; url: string }[] => {
|
||
return fieldFiles[getFieldKey(node, field)] || [];
|
||
};
|
||
|
||
const getFieldConstraint = (field: any): any => {
|
||
return field?.fieldConstraint && typeof field.fieldConstraint === 'object' ? field.fieldConstraint : {};
|
||
};
|
||
|
||
const getFieldFileCountText = (node: any, field: any): string => {
|
||
const current = getFieldFileList(node, field).length;
|
||
const max = Number(getFieldConstraint(field).maxFileCount);
|
||
if (!Number.isNaN(max) && max > 0) {
|
||
return `${current} / ${max}`;
|
||
}
|
||
return `${current}`;
|
||
};
|
||
|
||
const handleFileUpload = async (node: any, field: any, file: any) => {
|
||
const raw = file.raw;
|
||
if (!raw) return;
|
||
|
||
const key = getFieldKey(node, field);
|
||
const fc = getFieldConstraint(field);
|
||
|
||
const typeRules = String(fc.fileTypes || '')
|
||
.split(',')
|
||
.map((t: string) => t.trim().toLowerCase().replace(/^\./, ''))
|
||
.filter(Boolean);
|
||
if (typeRules.length > 0) {
|
||
const ext = (raw.name?.split('.').pop() || '').toLowerCase();
|
||
if (!typeRules.includes(ext)) {
|
||
ElMessage.warning(`文件格式不符合要求,仅支持:${typeRules.join(', ')}`);
|
||
return;
|
||
}
|
||
}
|
||
|
||
const maxFileSize = Number(fc.maxFileSize);
|
||
if (!Number.isNaN(maxFileSize) && maxFileSize > 0) {
|
||
const sizeMB = raw.size / 1024 / 1024;
|
||
if (sizeMB > maxFileSize) {
|
||
ElMessage.warning(`文件大小超限,最大 ${maxFileSize}MB`);
|
||
return;
|
||
}
|
||
}
|
||
|
||
const maxFileCount = Number(fc.maxFileCount);
|
||
if (!Number.isNaN(maxFileCount) && maxFileCount > 0) {
|
||
const currentCount = fieldFiles[key]?.length || 0;
|
||
if (currentCount >= maxFileCount) {
|
||
ElMessage.warning(`文件数量超限,最多 ${maxFileCount} 个`);
|
||
return;
|
||
}
|
||
}
|
||
|
||
uploadingFields[key] = true;
|
||
|
||
try {
|
||
const uploadRes = await uploadFile(raw, { timeout: 0 });
|
||
if (!uploadRes?.data?.fileURL) throw new Error('上传失败:未返回文件URL');
|
||
|
||
const fileUrl = uploadRes.data.fileAddressPrefix
|
||
? `${uploadRes.data.fileAddressPrefix}${uploadRes.data.fileURL}`
|
||
: uploadRes.data.fileURL;
|
||
|
||
if (!fieldFiles[key]) fieldFiles[key] = [];
|
||
// 文件名取服务器返回 fileName(OSS 存储名,已确认),随 value 一并写回开始节点传给后端
|
||
fieldFiles[key].push({ name: uploadRes.data.fileName || raw.name, url: fileUrl });
|
||
|
||
if (field.type === 'upload') {
|
||
formValues[key] = fileUrl;
|
||
formFileNames[key] = fieldFiles[key][0]?.name;
|
||
} else {
|
||
formValues[key] = fieldFiles[key].map((f: any) => f.url);
|
||
formFileNames[key] = fieldFiles[key].map((f: any) => f.name);
|
||
}
|
||
} catch (error: any) {
|
||
ElMessage.error(error?.message || '文件上传失败');
|
||
} finally {
|
||
uploadingFields[key] = false;
|
||
}
|
||
};
|
||
|
||
const removeFieldFile = (node: any, field: any, fileIdx: number) => {
|
||
const key = getFieldKey(node, field);
|
||
if (!fieldFiles[key]) return;
|
||
fieldFiles[key].splice(fileIdx, 1);
|
||
if (field.type === 'upload') {
|
||
formValues[key] = '';
|
||
formFileNames[key] = '';
|
||
} else {
|
||
formValues[key] = fieldFiles[key].map((f) => f.url);
|
||
formFileNames[key] = fieldFiles[key].map((f) => f.name);
|
||
}
|
||
};
|
||
|
||
const currentWorkflowHasPatchLayout = computed(() => {
|
||
const nodes = props.detail?.nodeInputParams || [];
|
||
return nodes.some((node: any) => node.patchLayout === true);
|
||
});
|
||
|
||
const hasFormConfig = (node: any): boolean => {
|
||
// 开始节点为唯一表单源(runFormFields 汇总各 model 勾选字段 + form 自定义字段)
|
||
return String(node?.nodeCode || '').toLowerCase() === '__start__' && collectHomeFormFields(node).length > 0;
|
||
};
|
||
|
||
const hasFormFields = computed(() => {
|
||
if (!props.detail?.nodeInputParams) return false;
|
||
return props.detail.nodeInputParams.some((node: any) => hasFormConfig(node));
|
||
});
|
||
|
||
const validateFormFields = (): boolean => {
|
||
if (!props.detail?.nodeInputParams) return true;
|
||
for (const node of props.detail.nodeInputParams as any[]) {
|
||
if (String(node?.nodeCode || '').toLowerCase() !== '__start__') continue;
|
||
const fields = getVisibleFields(node);
|
||
for (const field of fields) {
|
||
if (!field.required) continue;
|
||
// 引用其他节点输出的字段:值由上游节点运行时提供,非用户填写项,跳过必填校验
|
||
if (field.valueSource) continue;
|
||
const key = getFieldKey(node, field);
|
||
const value = formValues[key];
|
||
if (value === undefined || value === null || value === '' || (Array.isArray(value) && value.length === 0)) {
|
||
ElMessage.warning(`请填写必填项:${field.label}`);
|
||
return false;
|
||
}
|
||
}
|
||
}
|
||
return true;
|
||
};
|
||
|
||
// 初始化或重置表单(与旧整页表单一致:含默认值 + 文件回填 + 贴片模板恢复)
|
||
watch(
|
||
() => props.detail,
|
||
(detail) => {
|
||
Object.keys(formValues).forEach((key) => delete formValues[key]);
|
||
Object.keys(fieldFiles).forEach((key) => delete fieldFiles[key]);
|
||
Object.keys(formFileNames).forEach((key) => delete formFileNames[key]);
|
||
Object.keys(uploadingFields).forEach((key) => delete uploadingFields[key]);
|
||
// 尝试从执行详情恢复贴片模板
|
||
// 提交路径:templates 写入开启贴片布局的节点内部(patchLayout === true 的 node.templates),顶层已删除;
|
||
// 回显时 detail.nodeInputParams 即提交时的 nodes,从该处优先恢复;兼容旧数据(extension / detail / flowContent 顶层)
|
||
const patchNodes = Array.isArray(detail?.nodeInputParams)
|
||
? (detail.nodeInputParams as any[]).filter((n: any) => n.patchLayout === true && Array.isArray(n.templates))
|
||
: [];
|
||
const nodeTemplates = patchNodes[0]?.templates;
|
||
const ext = (detail as any)?.extension;
|
||
const restoredTemplates =
|
||
nodeTemplates || ext?.templates || (detail as any)?.templates || detail?.flowContent?.templates || [];
|
||
templates.value = Array.isArray(restoredTemplates) ? restoredTemplates : [];
|
||
|
||
if (!detail?.nodeInputParams) return;
|
||
const nodes = detail.nodeInputParams as any[];
|
||
|
||
// 1) 初始化表单值(model → modelRequestParams runtimeShow;form → outputConfig;旧数据 → formConfig)
|
||
nodes.forEach((node) => {
|
||
collectHomeFormFields(node).forEach((field) => {
|
||
const key = getFieldKey(node, field);
|
||
const hasValue = field.value !== undefined && field.value !== null && field.value !== '';
|
||
if (field.type === 'number' || field.type === 'inputNumber') {
|
||
formValues[key] = hasValue ? Number(field.value) : (field.default ?? null);
|
||
} else if (field.type === 'switch') {
|
||
formValues[key] = hasValue ? Boolean(field.value) : (field.default ?? false);
|
||
} else if (field.type === 'upload' || field.type === 'uploadMultiple' || field.type === 'fileUpload') {
|
||
if (field.type === 'fileUpload') {
|
||
formValues[key] = hasValue
|
||
? Array.isArray(field.value)
|
||
? field.value
|
||
: field.value
|
||
? [field.value]
|
||
: []
|
||
: Array.isArray(field.default)
|
||
? field.default
|
||
: field.default
|
||
? [field.default]
|
||
: [];
|
||
} else {
|
||
formValues[key] = hasValue ? field.value : (field.default ?? (field.type === 'upload' ? '' : []));
|
||
}
|
||
} else {
|
||
formValues[key] = hasValue ? field.value : (field.default ?? '');
|
||
}
|
||
});
|
||
});
|
||
|
||
// 2) 文件字段回填已上传文件列表
|
||
nodes.forEach((node) => {
|
||
collectHomeFormFields(node).forEach((field) => {
|
||
if (!isFileField(field)) return;
|
||
const key = getFieldKey(node, field);
|
||
const rawValue = formValues[key];
|
||
const urls = Array.isArray(rawValue) ? rawValue : rawValue ? [rawValue] : [];
|
||
if (urls.length === 0) return;
|
||
// 文件名优先取运行字段携带的服务器名(新数据),旧数据从 url 提取兜底
|
||
const rawFn = (field as any).fileName;
|
||
fieldFiles[key] = urls.map((url: string, i: number) => ({
|
||
name:
|
||
(Array.isArray(rawFn) ? rawFn[i] : i === 0 ? rawFn : undefined) ||
|
||
String(url || '').split('/').pop() ||
|
||
'file-' + Math.random().toString(36).slice(2, 8),
|
||
url,
|
||
}));
|
||
formFileNames[key] =
|
||
field.type === 'upload' && fieldFiles[key].length === 1
|
||
? fieldFiles[key][0].name
|
||
: fieldFiles[key].map((f) => f.name);
|
||
});
|
||
});
|
||
},
|
||
{ immediate: true }
|
||
);
|
||
|
||
const handleSubmit = () => {
|
||
// 定格卡片默认只读不可提交;最后一张已执行卡片(editable)允许改参后重新提交
|
||
if ((props.readonly && !props.editable) || props.submitting) return;
|
||
if (!validateFormFields()) return;
|
||
emit('submit', {
|
||
formValues: JSON.parse(JSON.stringify(formValues)),
|
||
formFileNames: JSON.parse(JSON.stringify(formFileNames)),
|
||
templates: JSON.parse(JSON.stringify(templates.value || [])),
|
||
});
|
||
};
|
||
|
||
// InputBar 发送按钮触发执行:父组件把执行信号下发给目标卡片(本卡片为目标时 executeSignal 非 null),
|
||
// 统一调用提交(含必填校验);卡片参数在组件内部,父组件经信号间接触发
|
||
watch(
|
||
() => props.executeSignal,
|
||
(sig) => {
|
||
if (!sig) return;
|
||
handleSubmit();
|
||
}
|
||
);
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
/* ===== 表单卡片(对话消息流内) ===== */
|
||
.workflow-form-card {
|
||
background: #fff;
|
||
border: 1px solid #e5e8ee;
|
||
border-radius: 14px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
box-shadow: 0 2px 12px rgba(15, 23, 42, 0.06);
|
||
width: min(640px, calc(100% - 4px));
|
||
max-width: 100%;
|
||
transition: opacity 0.2s;
|
||
|
||
&.readonly {
|
||
box-shadow: none;
|
||
}
|
||
&.submitting {
|
||
opacity: 0.9;
|
||
}
|
||
}
|
||
|
||
.workflow-form-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
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;
|
||
overflow-y: auto;
|
||
padding: 20px;
|
||
&::-webkit-scrollbar { width: 4px; }
|
||
&::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
|
||
&::-webkit-scrollbar-track { background: transparent; }
|
||
}
|
||
|
||
:deep(.el-form-item) {
|
||
margin-bottom: 14px;
|
||
&:last-child { margin-bottom: 0; }
|
||
.el-form-item__label { font-size: 13px; font-weight: 500; color: #334155; padding-bottom: 4px; line-height: 1.5; }
|
||
.el-form-item__label::before { color: #ef4444; margin-right: 4px; }
|
||
.el-input-number { width: 100%; }
|
||
.el-select { width: 100%; }
|
||
.el-textarea__inner { font-size: 13px; }
|
||
}
|
||
|
||
/* 引用字段只读展示 */
|
||
.field-source-readonly {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 8px 12px;
|
||
background: #f8fafc;
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: 8px;
|
||
font-size: 12px;
|
||
color: #64748b;
|
||
.source-tag { flex-shrink: 0; }
|
||
.source-text { line-height: 1.5; }
|
||
}
|
||
|
||
.uploaded-files-list { margin-top: 8px; display: flex; flex-direction: column; gap: 6px; }
|
||
|
||
.uploaded-file-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 6px 12px;
|
||
background: #f8fafc;
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: 8px;
|
||
.file-name {
|
||
flex: 1;
|
||
font-size: 12px;
|
||
color: #475569;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
}
|
||
|
||
.patch-template-section {
|
||
margin-top: 16px;
|
||
:deep(.patch-template-editor) {
|
||
> .el-divider { margin-top: 4px; margin-bottom: 12px; border-top-color: #e2e8f0;
|
||
.el-divider__text { font-weight: 600; font-size: 13px; color: #475569; background: transparent; padding-left: 0; }
|
||
}
|
||
.template-card { border: 1px solid #e2e8f0; border-radius: 10px; margin-bottom: 10px; }
|
||
}
|
||
}
|
||
|
||
/* 底部操作区 */
|
||
.workflow-form-footer {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 12px 20px;
|
||
border-top: 1px solid #f1f5f9;
|
||
background: #fafbfc;
|
||
flex-shrink: 0;
|
||
.form-error-text {
|
||
flex: 1;
|
||
font-size: 12px;
|
||
color: #ef4444;
|
||
line-height: 1.5;
|
||
white-space: pre-wrap; /* 失败详情可能含换行(error 字段多行原因),允许换行展示 */
|
||
word-break: break-word;
|
||
}
|
||
.form-edit-tip {
|
||
flex: 1;
|
||
font-size: 12px;
|
||
color: #94a3b8;
|
||
}
|
||
}
|
||
|
||
/* 执行进度:spinner + 文本,节点事件推进 */
|
||
.executing-progress {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex: 1;
|
||
.exec-spinner {
|
||
width: 14px;
|
||
height: 14px;
|
||
flex-shrink: 0;
|
||
border: 2px solid #bfdbfe;
|
||
border-top-color: #2563eb;
|
||
border-radius: 50%;
|
||
animation: exec-spin 0.8s linear infinite;
|
||
}
|
||
.exec-text {
|
||
font-size: 13px;
|
||
color: #1e293b;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
}
|
||
@keyframes exec-spin {
|
||
to { transform: rotate(360deg); }
|
||
}
|
||
|
||
.w100 { width: 100%; }
|
||
</style>
|