Files
admin-ui/src/views/home/components/WorkflowFormCard.vue
T
2910410219andClaude b140bff5f1 首页工作流与历史会话:修复执行后表单清空/默认展开,工作流最多展示9个+更多入口,历史会话分页加载更多
- 修复:执行时把用户填的表单值写回消息对象,会话认领重建后能恢复,不再清空;执行完成/历史回显卡片默认收起,可手动展开改参
- 功能:首页工作流最多展示 9 个(用户优先、模板补足),超出显示「更多」入口跳转工作流管理页
- 功能:历史会话分页「加载更多」,逐页追加去重
- 重构:工作流执行信号统一走提交(移除 retry/re-edit/cancel),失败卡片重跑/重编辑并入提交路径
- 其他:删除消息时终止进行中的轮;Markdown 渲染缓存设上限

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-21 19:17:12 +08:00

807 lines
27 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" @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 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 = () => {
// 定格卡片默认只读不可提交;最后一张已执行卡片(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>