Files
admin-ui/src/views/home/components/WorkflowFormCard.vue
T
2910410219andClaude 4d74dd2d63 修复首页对话回显中贴片模板未恢复的问题
贴片模板提交时写入 patchLayout 节点的内部 templates 字段并删除顶层,
回显恢复逻辑仅从顶层读取导致模板丢失;现优先从 nodeInputParams 中
patchLayout 节点的 templates 恢复,顶层旧位置作为兼容兜底。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-20 17:54:33 +08:00

635 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="workflow-form-card" :class="{ readonly, submitting, failed: isFailed }">
<div class="workflow-form-header">
<div>
<h3>{{ detail?.flowName || '工作流表单' }}</h3>
<div class="workflow-form-sub">{{ detail?.description || '请填写以下参数' }}</div>
</div>
</div>
<div 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 定格状态 -->
<div class="workflow-form-footer">
<div v-if="isFailed" class="form-error-text">{{ formError }}</div>
<!-- 执行中:节点文本进度推进(后端 node_start/node_complete 事件),无进度时兜底「执行中...」 -->
<!-- 取消执行:工作流执行中的终止入口(InputBar 停止按钮对工作流禁用,改由卡片 footer 提供) -->
<div v-if="submitting" class="executing-progress">
<span class="exec-spinner" />
<span class="exec-text">{{ progressText }}</span>
<el-button size="small" class="exec-cancel-btn" @click="emit('cancel')">取消执行</el-button>
</div>
<el-button v-else-if="!readonly" type="primary" :disabled="submitting" @click="handleSubmit">提交执行</el-button>
<template v-else>
<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>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import { RefreshRight } from '@element-plus/icons-vue';
import PatchTemplateEditor from '/@/components/patchTemplate/PatchTemplateEditor.vue';
import { uploadFile } from '/@/api/common/upload';
import { collectHomeFormFields } from '../utils/flowDsl';
interface Props {
// 工作流详情(含 nodeInputParams / flowContent
detail: any;
// 定格只读(done/failed 态):禁用表单与提交
readonly?: boolean;
// 执行中:禁用表单,底部显示加载中
submitting?: boolean;
// 执行失败信息(有值即 failed 态)
formError?: string;
// 执行进度(后端 node_start/node_complete 事件推进):当前节点序 / 总节点数 / 节点名
progress?: { current: number; total: number; nodeName: string };
}
interface Emits {
(e: 'submit', payload: { formValues: Record<string, any>; formFileNames: Record<string, string | string[]>; templates: any[] }): void;
// 失败卡片「重新编辑并执行」:由父组件把 formStatus 切回 editing,保留已填值
(e: 're-edit'): void;
// 执行中取消:工作流取消入口(InputBar 停止按钮对工作流禁用)
(e: 'cancel'): void;
}
const props = withDefaults(defineProps<Props>(), {
readonly: false,
submitting: false,
formError: '',
progress: () => ({ current: 0, total: 0, nodeName: '' }),
});
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;禁用 = 定格或执行中
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 '执行中...';
});
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 runtimeShowform → 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 = () => {
if (props.readonly || 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 || [])),
});
};
</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;
h3 { margin: 0; font-size: 14px; font-weight: 600; color: #0f172a; }
.workflow-form-sub { font-size: 12px; color: #94a3b8; margin-top: 2px; }
}
.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;
}
}
/* 执行进度: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;
}
.exec-cancel-btn {
margin-left: auto;
flex-shrink: 0;
}
}
@keyframes exec-spin {
to { transform: rotate(360deg); }
}
.w100 { width: 100%; }
</style>