首页工作流表单对话卡片化,历史回显走消息流
选中工作流即以表单卡片推送进入对话消息流,卡片内填写并提交执行, 移除整页表单区。历史 workflow 会话回显为带值表单卡片 + 结果消息, 不再跳整页表单页;回显数据源改用 session/get 记录(requestParams 即完整 flowContent,含 __start__ 值快照),失败判断以 errorMsg 为准。 - 新增 WorkflowFormCard.vue 表单卡片组件(迁移原整页表单渲染/校验逻辑) - ChatList 渲染 form 类型消息,透传 form-submit - MainContent 精简为两态(对话页/占位页) - SessionResults workflow 结果精简摘要,状态以 errorMsg 为准 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -9,9 +9,19 @@
|
||||
v-for="msg in messages"
|
||||
:key="msg.id"
|
||||
class="message-row"
|
||||
:class="{ 'is-user': msg.isUser, 'is-error': !msg.isUser && isErrorMsg(msg) }"
|
||||
:class="{ 'is-user': msg.isUser, 'is-form': msg.type === 'form', 'is-error': !msg.isUser && isErrorMsg(msg) }"
|
||||
>
|
||||
<div class="bubble-wrap">
|
||||
<!-- 工作流表单卡片:不套气泡,独立卡片渲染 -->
|
||||
<WorkflowFormCard
|
||||
v-if="msg.type === 'form'"
|
||||
:detail="msg.form"
|
||||
:readonly="msg.formStatus === 'done' || msg.formStatus === 'failed'"
|
||||
:submitting="msg.formStatus === 'running'"
|
||||
:form-error="msg.formError"
|
||||
@submit="emit('form-submit', msg, $event)"
|
||||
/>
|
||||
<template v-else>
|
||||
<div class="bubble">
|
||||
<template v-if="msg.isUser">{{ msg.content }}</template>
|
||||
<template v-else>
|
||||
@@ -37,15 +47,15 @@
|
||||
v-html="renderAnswer(msg.content)"
|
||||
@click="handleAnswerClick"
|
||||
></div>
|
||||
<!-- 失败重试 -->
|
||||
<button v-if="isErrorMsg(msg)" type="button" class="retry-btn" @click="emit('retry', msg)">
|
||||
<!-- 失败重试(工作流结果不提供文本重试,需重新提交表单卡片) -->
|
||||
<button v-if="isErrorMsg(msg) && msg.recordType !== 'workflow'" type="button" class="retry-btn" @click="emit('retry', msg)">
|
||||
<span class="retry-icon">↻</span> 重新生成
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<!-- AI 消息操作栏(hover 显示) -->
|
||||
<div v-if="!msg.isUser && msg.content && !isErrorMsg(msg)" class="msg-actions">
|
||||
<button class="msg-action" title="重新生成" @click="emit('regenerate', msg)">
|
||||
<button v-if="msg.recordType !== 'workflow'" class="msg-action" title="重新生成" @click="emit('regenerate', msg)">
|
||||
<el-icon><RefreshRight /></el-icon>
|
||||
</button>
|
||||
<button class="msg-action" title="复制回答" @click="copyText(msg.content)">
|
||||
@@ -61,6 +71,7 @@
|
||||
<el-icon><Delete /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<div class="time">{{ msg.time }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -73,6 +84,7 @@ import { ElMessage } from 'element-plus';
|
||||
import { Delete, DocumentCopy, RefreshRight } from '@element-plus/icons-vue';
|
||||
import 'highlight.js/styles/github-dark.css';
|
||||
import { renderMarkdown } from '../utils/markdown';
|
||||
import WorkflowFormCard from './WorkflowFormCard.vue';
|
||||
|
||||
interface ChatMessage {
|
||||
id: string;
|
||||
@@ -86,12 +98,18 @@ interface ChatMessage {
|
||||
// 后端记录 id / 类型(来自 session/get 的每条结果),存在时才显示删除按钮
|
||||
recordId?: string;
|
||||
recordType?: string;
|
||||
// 工作流表单卡片消息(type==='form' 时渲染 WorkflowFormCard)
|
||||
type?: 'form';
|
||||
form?: any;
|
||||
formStatus?: 'editing' | 'running' | 'done' | 'failed';
|
||||
formError?: string;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'retry', msg: ChatMessage): void;
|
||||
(e: 'regenerate', msg: ChatMessage): void;
|
||||
(e: 'delete', msg: ChatMessage): void;
|
||||
(e: 'form-submit', msg: ChatMessage, payload: any): void;
|
||||
(e: 'load-more'): void;
|
||||
}
|
||||
|
||||
@@ -339,6 +357,16 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
/* 工作流表单卡片消息:左对齐,卡片自带背景不套气泡 */
|
||||
.message-row.is-form {
|
||||
justify-content: flex-start;
|
||||
.bubble-wrap {
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
.time { padding-left: 2px; }
|
||||
}
|
||||
}
|
||||
|
||||
/* AI 消息操作栏(hover 显示) */
|
||||
.msg-actions {
|
||||
display: flex;
|
||||
|
||||
@@ -1,146 +1,9 @@
|
||||
<template>
|
||||
<div class="main-content">
|
||||
<Transition name="content-fade" mode="out-in">
|
||||
<!-- 工作流动态表单 -->
|
||||
<div v-if="workflowDetail" :key="'workflow'" class="content-body workflow-form-body">
|
||||
<div class="workflow-form-card">
|
||||
<div class="workflow-form-header">
|
||||
<div>
|
||||
<h3>{{ workflowDetail.flowName || '工作流表单' }}</h3>
|
||||
<div class="workflow-form-sub">{{ workflowDetail.description || '请填写以下参数' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="workflow-form-scroll">
|
||||
<el-form label-position="top" class="workflow-form">
|
||||
<template v-if="workflowDetail.nodeInputParams">
|
||||
<template v-for="node in workflowDetail.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)]"
|
||||
: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"
|
||||
: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"
|
||||
:placeholder="field.required ? '必填' : '选填'"
|
||||
show-word-limit
|
||||
:maxlength="500"
|
||||
/>
|
||||
|
||||
<!-- 开关 -->
|
||||
<el-switch
|
||||
v-else-if="field.type === 'switch'"
|
||||
v-model="formValues[getFieldKey(node, field)]"
|
||||
active-text="开启"
|
||||
inactive-text="关闭"
|
||||
/>
|
||||
|
||||
<!-- 下拉选择 -->
|
||||
<el-select
|
||||
v-else-if="field.type === 'select' && field.options"
|
||||
v-model="formValues[getFieldKey(node, field)]"
|
||||
: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)"
|
||||
:on-change="(file: any) => handleFileUpload(node, field, file)"
|
||||
>
|
||||
<el-button
|
||||
size="small"
|
||||
type="primary"
|
||||
:loading="uploadingFields[getFieldKey(node, field)]"
|
||||
:disabled="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 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)]"
|
||||
: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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 对话页 — 无工作流但有消息或会话结果时展示:结果卡片(仅工作流结果时显示)+ 消息流(下)。
|
||||
<!-- 对话页 — 有消息或会话结果时展示:结果卡片(仅工作流结果时显示)+ 消息流(下)。
|
||||
key 绑定会话 id:切换会话时重建内容区,触发 Transition 过渡动画(固定 key 会复用 DOM 不触发) -->
|
||||
<div v-else-if="hasMessages || hasResults" :key="activeHistoryId || 'chat'" class="content-body chat-body">
|
||||
<div v-if="hasMessages || hasResults" :key="activeHistoryId || 'chat'" class="content-body chat-body">
|
||||
<SessionResults
|
||||
v-if="hasWorkflowResults"
|
||||
:results="results"
|
||||
@@ -157,6 +20,7 @@
|
||||
@retry="emit('retry', $event)"
|
||||
@regenerate="emit('regenerate', $event)"
|
||||
@delete="emit('delete', $event)"
|
||||
@form-submit="(msg: any, payload: any) => emit('form-submit', msg, payload)"
|
||||
@load-more="emit('load-more', activeHistoryId)"
|
||||
/>
|
||||
</div>
|
||||
@@ -188,11 +52,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import PatchTemplateEditor from '/@/components/patchTemplate/PatchTemplateEditor.vue';
|
||||
import { uploadFile } from '/@/api/common/upload';
|
||||
import { collectHomeFormFields } from '../utils/flowDsl';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { getWorkflowList } from '/@/api/settings/creation';
|
||||
import { Promotion } from '@element-plus/icons-vue';
|
||||
import ChatList from './ChatList.vue';
|
||||
@@ -201,7 +61,6 @@ import type { VOSessionInfoResult } from '/@/api/settings/workflow/session';
|
||||
|
||||
interface Props {
|
||||
activeMenu: string;
|
||||
workflowDetail: any;
|
||||
activeHistoryId?: string | null;
|
||||
messages?: any[];
|
||||
results?: VOSessionInfoResult[];
|
||||
@@ -215,6 +74,7 @@ interface Emits {
|
||||
(e: 'regenerate', msg: any): void;
|
||||
(e: 'workflow-select', id: string, isTemplate?: boolean): void;
|
||||
(e: 'delete', msg: any): void;
|
||||
(e: 'form-submit', msg: any, payload: any): void;
|
||||
(e: 'load-more', sid: string | null | undefined): void;
|
||||
}
|
||||
|
||||
@@ -231,250 +91,7 @@ const emit = defineEmits<Emits>();
|
||||
const hasMessages = computed(() => Array.isArray(props.messages) && props.messages.length > 0);
|
||||
const hasResults = computed(() => Array.isArray(props.results) && props.results.length > 0);
|
||||
const hasWorkflowResults = computed(() => Array.isArray(props.results) && props.results.some((r) => r.type === 'workflow'));
|
||||
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[]>([]);
|
||||
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 定位上游节点名 + 字段
|
||||
const getSourceDisplay = (field: any): string => {
|
||||
const vs = field?.valueSource;
|
||||
if (!vs || typeof vs !== 'object') return '';
|
||||
const nodes = props.workflowDetail?.nodeInputParams || [];
|
||||
const srcNode = nodes.find((n: any) => String(n?.id) === String(vs.nodeId));
|
||||
const nodeName = srcNode?.name || srcNode?.nodeName || (vs.nodeId ? `节点 ${vs.nodeId}` : '上游节点');
|
||||
return vs.field ? `${nodeName} · ${vs.field}` : nodeName;
|
||||
};
|
||||
|
||||
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.workflowDetail?.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.workflowDetail?.nodeInputParams) return false;
|
||||
return props.workflowDetail.nodeInputParams.some((node: any) => hasFormConfig(node));
|
||||
});
|
||||
|
||||
const validateFormFields = (): boolean => {
|
||||
if (!props.workflowDetail?.nodeInputParams) return true;
|
||||
for (const node of props.workflowDetail.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.workflowDetail,
|
||||
(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]);
|
||||
// 尝试从执行详情恢复贴片模板
|
||||
const ext = (detail as any)?.extension;
|
||||
const restoredTemplates = 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 }
|
||||
);
|
||||
// 占位页工作流卡片(DeepSeek 式引导:居中标题 + 工作流卡片)
|
||||
const placeWorkflows = ref<any[]>([]);
|
||||
const loadPlaceWorkflows = async () => {
|
||||
@@ -496,8 +113,6 @@ const handlePlaceSelect = (wf: any) => {
|
||||
onMounted(() => {
|
||||
loadPlaceWorkflows();
|
||||
});
|
||||
|
||||
defineExpose({ formValues, fieldFiles, formFileNames, templates, validateFormFields });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -530,144 +145,6 @@ defineExpose({ formValues, fieldFiles, formFileNames, templates, validateFormFie
|
||||
&::-webkit-scrollbar { display: none; }
|
||||
}
|
||||
|
||||
/* ===== 工作流表单 ===== */
|
||||
.workflow-form-body {
|
||||
width: min(880px, calc(100% - 40px));
|
||||
margin: 0 auto;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 16px 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.workflow-form-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e8ee;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 100%;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 12px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.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; }
|
||||
.el-button { flex-shrink: 0; }
|
||||
}
|
||||
|
||||
.workflow-form-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
&::-webkit-scrollbar { width: 4px; }
|
||||
&::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
|
||||
&::-webkit-scrollbar-track { background: transparent; }
|
||||
}
|
||||
|
||||
.form-node-group {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 16px;
|
||||
&:last-child { margin-bottom: 0; }
|
||||
}
|
||||
|
||||
.form-node-group-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
margin-bottom: 14px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
letter-spacing: 0.3px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
: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; }
|
||||
}
|
||||
|
||||
:deep(.el-divider) {
|
||||
border-top-color: #e2e8f0;
|
||||
margin-top: 6px;
|
||||
margin-bottom: 12px;
|
||||
.el-divider__text { font-weight: 600; font-size: 12px; color: #64748b; background: transparent; padding-left: 0; }
|
||||
}
|
||||
|
||||
:deep(.upload-area) {
|
||||
width: 100%;
|
||||
.el-upload-dragger {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 100%; height: auto; padding: 20px;
|
||||
border: 2px dashed #e2e8f0; border-radius: 10px;
|
||||
background: #fff; cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
&:hover { border-color: #93c5fd; background: #f8faff; }
|
||||
}
|
||||
.upload-trigger { display: flex; align-items: center; gap: 12px; }
|
||||
.upload-icon { font-size: 28px; color: #93c5fd; }
|
||||
.upload-text { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: #475569; text-align: left; }
|
||||
.upload-rule { font-size: 11px; color: #94a3b8; }
|
||||
&.is-filled .el-upload-dragger { border-color: #bfdbfe; background: #eff6ff; }
|
||||
}
|
||||
|
||||
.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: #fff;
|
||||
border: 1px solid #e2e8f0; border-radius: 8px;
|
||||
transition: background 0.15s;
|
||||
&:hover { background: #f1f5f9; }
|
||||
.file-type-icon { font-size: 16px; color: #3b82f6; flex-shrink: 0; }
|
||||
.file-name { flex: 1; font-size: 12px; color: #475569; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.el-button { flex-shrink: 0; }
|
||||
}
|
||||
|
||||
.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; }
|
||||
}
|
||||
}
|
||||
|
||||
/* 引用字段只读展示 */
|
||||
.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; }
|
||||
}
|
||||
|
||||
.w100 { width: 100%; }
|
||||
.chat-container { width: min(1060px, 82%); margin: 0 auto; padding: 20px 0 130px; }
|
||||
|
||||
/* ===== 对话页 ===== */
|
||||
.chat-body {
|
||||
width: min(880px, calc(100% - 40px));
|
||||
@@ -764,4 +241,4 @@ defineExpose({ formValues, fieldFiles, formFileNames, templates, validateFormFie
|
||||
color: #cbd5e1;
|
||||
padding: 16px 0;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -11,11 +11,11 @@
|
||||
<div v-else-if="results.length === 0" class="results-empty">暂无会话结果</div>
|
||||
<!-- 结果列表 -->
|
||||
<div v-else class="results-list">
|
||||
<div v-for="r in results" :key="r.id" class="result-card" :class="`is-${statusKey(r.status)}`">
|
||||
<div v-for="r in results" :key="r.id" class="result-card" :class="`is-${statusKey(r)}`">
|
||||
<div class="result-main">
|
||||
<span class="result-type">{{ r.type === 'workflow' ? '工作流' : '对话' }}</span>
|
||||
<span class="result-status" :class="`is-${statusKey(r.status)}`">
|
||||
<i v-if="r.status === 1" class="status-dot"></i>{{ statusText(r.status) }}
|
||||
<span class="result-status" :class="`is-${statusKey(r)}`">
|
||||
<i v-if="!r.errorMsg && r.status === 1" class="status-dot"></i>{{ statusText(r) }}
|
||||
</span>
|
||||
<span v-if="r.totalTokens" class="result-meta">{{ r.totalTokens }} tokens</span>
|
||||
<span v-if="r.totalFee" class="result-meta">¥{{ r.totalFee }}</span>
|
||||
@@ -73,23 +73,29 @@ const handleScroll = () => {
|
||||
if (el.scrollHeight - el.scrollTop - el.clientHeight <= 4) emit('load-more');
|
||||
};
|
||||
|
||||
// 状态键:1-运行中, 2-成功, 3-失败
|
||||
const statusKey = (status: number): string => {
|
||||
if (status === 2) return 'success';
|
||||
if (status === 3) return 'failed';
|
||||
// 状态键:以 errorMsg 为准(部分失败记录 status 仍是 1,仅凭 status 会误判为运行中);
|
||||
// 无错误时按 status:2-成功, 其余运行中
|
||||
const statusKey = (r: VOSessionInfoResult): string => {
|
||||
if (r.errorMsg) return 'failed';
|
||||
if (r.status === 2) return 'success';
|
||||
return 'running';
|
||||
};
|
||||
|
||||
const statusText = (status: number): string => {
|
||||
if (status === 2) return '成功';
|
||||
if (status === 3) return '失败';
|
||||
const statusText = (r: VOSessionInfoResult): string => {
|
||||
if (r.errorMsg) return '失败';
|
||||
if (r.status === 2) return '成功';
|
||||
return '运行中';
|
||||
};
|
||||
|
||||
// 卡片问题/参数摘要:chat 取 requestParams.question,其余输出参数 JSON 摘要
|
||||
// 卡片问题/参数摘要:chat 取 requestParams.question;workflow 不再平铺 flowContent JSON,
|
||||
// 改为精简节点摘要(完整表单与结果已在消息流中,此处只需让结果卡片保持简洁)
|
||||
const getQuestionText = (r: VOSessionInfoResult): string => {
|
||||
const q = r.requestParams?.question;
|
||||
if (q) return String(q);
|
||||
if (r.type === 'workflow') {
|
||||
const nodes = Array.isArray(r.requestParams?.nodes) ? r.requestParams.nodes : [];
|
||||
return nodes.length > 0 ? `包含 ${nodes.length} 个节点` : '';
|
||||
}
|
||||
const params = r.requestParams;
|
||||
if (params && Object.keys(params).length > 0) {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,561 @@
|
||||
<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>
|
||||
<el-button v-if="submitting" type="primary" loading disabled>执行中...</el-button>
|
||||
<el-button v-else-if="!readonly" type="primary" :disabled="submitting" @click="handleSubmit">提交执行</el-button>
|
||||
<el-tag v-else :type="isFailed ? 'danger' : 'success'" size="small" effect="light">
|
||||
{{ isFailed ? '执行失败' : '执行完成' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
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;
|
||||
}
|
||||
|
||||
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: '',
|
||||
});
|
||||
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);
|
||||
|
||||
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 定位上游节点名 + 字段
|
||||
const getSourceDisplay = (field: any): string => {
|
||||
const vs = field?.valueSource;
|
||||
if (!vs || typeof vs !== 'object') return '';
|
||||
const nodes = props.detail?.nodeInputParams || [];
|
||||
const srcNode = nodes.find((n: any) => String(n?.id) === String(vs.nodeId));
|
||||
const nodeName = srcNode?.name || srcNode?.nodeName || (vs.nodeId ? `节点 ${vs.nodeId}` : '上游节点');
|
||||
return vs.field ? `${nodeName} · ${vs.field}` : nodeName;
|
||||
};
|
||||
|
||||
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]);
|
||||
// 尝试从执行详情恢复贴片模板
|
||||
const ext = (detail as any)?.extension;
|
||||
const restoredTemplates = 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 = () => {
|
||||
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;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.w100 { width: 100%; }
|
||||
</style>
|
||||
+199
-65
@@ -16,9 +16,7 @@
|
||||
/>
|
||||
<div class="main-wrapper">
|
||||
<MainContent
|
||||
ref="mainContentRef"
|
||||
:active-menu="activeMenu"
|
||||
:workflow-detail="selectedWorkflowDetail"
|
||||
:active-history-id="activeHistoryId"
|
||||
:messages="currentMessages"
|
||||
:results="currentSessionResults"
|
||||
@@ -28,6 +26,7 @@
|
||||
@retry="handleRetry"
|
||||
@regenerate="handleRetry"
|
||||
@delete="handleDeleteMessage"
|
||||
@form-submit="handleFormSubmit"
|
||||
@workflow-select="handlePlaceWorkflowSelect"
|
||||
@load-more="handleLoadMore"
|
||||
/>
|
||||
@@ -94,7 +93,6 @@ import type { ExecutionTreeItem } from '/@/api/settings/creation';
|
||||
import {
|
||||
getExecutionList,
|
||||
getWorkflowDetail,
|
||||
getExecutionDetail,
|
||||
deleteExecutionResult,
|
||||
downloadToFile,
|
||||
} from '/@/api/settings/creation';
|
||||
@@ -121,6 +119,11 @@ interface ChatMessage {
|
||||
// 后端记录 id / 类型(来自 session/get 的每条结果),用于删除对话记录
|
||||
recordId?: string;
|
||||
recordType?: string;
|
||||
// 工作流表单卡片消息(type==='form' 时渲染 WorkflowFormCard)
|
||||
type?: 'form';
|
||||
form?: any;
|
||||
formStatus?: 'editing' | 'running' | 'done' | 'failed';
|
||||
formError?: string;
|
||||
}
|
||||
|
||||
interface TreeNode {
|
||||
@@ -358,7 +361,6 @@ const handleSessionModelSaved = async (model: { id: string; modelName: string })
|
||||
};
|
||||
|
||||
const selectedWorkflowDetail = ref<any>(null);
|
||||
const mainContentRef = ref<any>(null);
|
||||
const sendingSessions = reactive<Record<string, boolean>>({});
|
||||
// ===== 会话级长连接(单活跃连接)=====
|
||||
// 连接状态/helper 见 formatTime/addMessage 之后统一定义;这里声明本轮 handler 与当前连接路由
|
||||
@@ -373,6 +375,8 @@ interface RoundHandler {
|
||||
let activeHandler: RoundHandler | null = null;
|
||||
// 当前活跃的会话级连接(单活跃:切会话时关旧开新)
|
||||
let wsState: { ws: WebSocket; sid: string; sessionId: string; ready: Promise<WebSocket> } | null = null;
|
||||
// 当前进行中的一轮类型:true=工作流执行,false=普通对话(sendCancel 协议判断用)
|
||||
let activeRunIsWorkflow = false;
|
||||
const isHistoryWorkflow = ref(false);
|
||||
|
||||
// 生成中:有活跃会话且该会话正处于发送状态(发送按钮切换为停止按钮)
|
||||
@@ -436,6 +440,8 @@ const getSessionId = () => {
|
||||
|
||||
const handleWorkflowSelect = async (workflowId: string | null, isTemplate?: boolean) => {
|
||||
if (workflowId === null) {
|
||||
// 取消选择:移除最新未提交(editing)的表单卡片草稿,已提交/定格的保留
|
||||
removeDraftFormCard();
|
||||
selectedWorkflowDetail.value = null;
|
||||
return;
|
||||
}
|
||||
@@ -453,11 +459,60 @@ const handleWorkflowSelect = async (workflowId: string | null, isTemplate?: bool
|
||||
try {
|
||||
const res = await getWorkflowDetail(workflowId);
|
||||
selectedWorkflowDetail.value = res.data || null;
|
||||
// 对话卡片化:选中工作流即推送一张可填表单卡片进入消息流
|
||||
if (res.data) pushFormCard(res.data);
|
||||
} catch {
|
||||
selectedWorkflowDetail.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
// 无活跃会话时自动创建虚拟会话(选中工作流 / 发送消息共用),返回当前 sid
|
||||
const ensureActiveSession = (): { sid: string } => {
|
||||
if (!activeHistoryId.value) {
|
||||
const newId = `virtual_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
|
||||
historyList.value.unshift({
|
||||
id: newId,
|
||||
sessionId: newId,
|
||||
title: '新会话 ' + (historyList.value.length + 1),
|
||||
time: '刚刚',
|
||||
});
|
||||
activeHistoryId.value = newId;
|
||||
sessionMessages.value.set(newId, []);
|
||||
}
|
||||
return { sid: activeHistoryId.value };
|
||||
};
|
||||
|
||||
// 选中工作流:向当前会话消息流推送一张可填表单卡片(对话卡片化)
|
||||
const pushFormCard = (detail: any) => {
|
||||
const { sid } = ensureActiveSession();
|
||||
const msg: ChatMessage = {
|
||||
id: 'form-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6),
|
||||
content: '',
|
||||
time: formatTime(new Date()),
|
||||
isUser: false,
|
||||
type: 'form',
|
||||
form: detail,
|
||||
formStatus: 'editing',
|
||||
};
|
||||
const list = sessionMessages.value.get(sid);
|
||||
if (list) list.push(msg);
|
||||
else sessionMessages.value.set(sid, [msg]);
|
||||
};
|
||||
|
||||
// 取消工作流选择:移除当前会话中最新一条未提交(editing)的表单卡片草稿,已提交的保留
|
||||
const removeDraftFormCard = () => {
|
||||
const id = activeHistoryId.value;
|
||||
if (!id) return;
|
||||
const list = sessionMessages.value.get(id);
|
||||
if (!list) return;
|
||||
for (let i = list.length - 1; i >= 0; i--) {
|
||||
if (list[i].type === 'form' && list[i].formStatus === 'editing') {
|
||||
list.splice(i, 1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 模板补全保存完成:刷新列表并自动选中新保存的用户工作流
|
||||
const handleTemplateSaved = async (newId: string) => {
|
||||
templateDialogVisible.value = false;
|
||||
@@ -570,7 +625,7 @@ const teardownActiveRound = () => {
|
||||
const st = wsState;
|
||||
if (st && st.ws.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
sendCancel(st.ws, !!selectedWorkflowDetail.value);
|
||||
sendCancel(st.ws, activeRunIsWorkflow);
|
||||
} catch {
|
||||
/* 连接异常时忽略,仅做本地定格 */
|
||||
}
|
||||
@@ -587,31 +642,9 @@ const teardownSession = () => {
|
||||
|
||||
const handleSend = async (message: string) => {
|
||||
// 无活跃会话时自动创建
|
||||
if (!activeHistoryId.value) {
|
||||
const newId = `virtual_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
|
||||
historyList.value.unshift({
|
||||
id: newId,
|
||||
sessionId: newId,
|
||||
title: '新会话 ' + (historyList.value.length + 1),
|
||||
time: '刚刚',
|
||||
});
|
||||
activeHistoryId.value = newId;
|
||||
sessionMessages.value.set(newId, []);
|
||||
}
|
||||
const sid = activeHistoryId.value;
|
||||
const { sid } = ensureActiveSession();
|
||||
if (sendingSessions[sid]) return;
|
||||
sendingSessions[sid] = true;
|
||||
const mc = mainContentRef.value;
|
||||
if (!mc) {
|
||||
delete sendingSessions[sid];
|
||||
return;
|
||||
}
|
||||
|
||||
// 工作流模式:发送前校验必填表单字段;普通对话无需表单
|
||||
if (selectedWorkflowDetail.value && !mc.validateFormFields()) {
|
||||
delete sendingSessions[sid];
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取当前会话的 sessionId
|
||||
const curSession = historyList.value.find((h) => h.id === sid);
|
||||
@@ -623,7 +656,7 @@ const handleSend = async (message: string) => {
|
||||
// 添加用户消息
|
||||
addMessage({
|
||||
id: 'msg-' + Date.now() + '-user',
|
||||
content: message || (selectedWorkflowDetail.value ? '执行工作流' : ''),
|
||||
content: message,
|
||||
time: formatTime(new Date()),
|
||||
isUser: true,
|
||||
});
|
||||
@@ -632,16 +665,14 @@ const handleSend = async (message: string) => {
|
||||
|
||||
const sessionId = curSession.sessionId || getSessionId();
|
||||
|
||||
// 分支:有工作流 → 执行工作流;无工作流 → 普通对话
|
||||
if (selectedWorkflowDetail.value) {
|
||||
await runWorkflow(sid, sessionId, mc);
|
||||
} else {
|
||||
await runChat(sid, sessionId, message);
|
||||
}
|
||||
// 工作流执行由表单卡片提交触发(handleFormSubmit);输入框发送恒走普通对话
|
||||
await runChat(sid, sessionId, message);
|
||||
};
|
||||
|
||||
// ===== 失败重试:重新发送同一条用户消息 =====
|
||||
const handleRetry = async (msg: ChatMessage) => {
|
||||
// 工作流结果消息不提供文本重试(重试需重新填写表单卡片提交)
|
||||
if (msg.recordType === 'workflow') return;
|
||||
let sid = '';
|
||||
for (const [k, list] of sessionMessages.value) {
|
||||
if (list.some((m) => m.id === msg.id)) {
|
||||
@@ -719,7 +750,18 @@ const handleDeleteMessage = async (msg: ChatMessage) => {
|
||||
ElMessage.success('已删除');
|
||||
// 删除成功后重新拉取会话结果,消息流与结果卡片同步
|
||||
const results = await loadSessionResults(sid);
|
||||
loadSessionMessagesFromResults(sid, results);
|
||||
if (Array.isArray(results) && results.some((r) => r.type === 'workflow')) {
|
||||
// 仍含工作流结果:完整重建消息流(带值表单卡片 + 结果消息)
|
||||
const session = historyList.value.find((h) => h.id === sid);
|
||||
if (session) {
|
||||
isHistoryWorkflow.value = true;
|
||||
await loadWorkflowSessionMessages(sid, results);
|
||||
}
|
||||
} else {
|
||||
isHistoryWorkflow.value = false;
|
||||
inputBarRef.value?.clearWorkflow?.();
|
||||
loadSessionMessagesFromResults(sid, results);
|
||||
}
|
||||
} catch {
|
||||
// 后端已提示错误,保持原状
|
||||
}
|
||||
@@ -728,7 +770,7 @@ const handleDeleteMessage = async (msg: ChatMessage) => {
|
||||
// ===== 停止生成:按执行类型发送对应取消消息并关闭连接,保留已生成内容 =====
|
||||
// 普通对话走 agent 协议(启动为 {type:'agent'},取消为 {type:'agent_cancel'});
|
||||
// 工作流走 workflow 协议(取消为 {type:'workflow_cancel'})。
|
||||
// 判断依据与 handleSend 的分支一致:selectedWorkflowDetail 有值 → 工作流,无值 → 普通对话。
|
||||
// 判断依据:activeRunIsWorkflow 标记当前轮类型(runWorkflow/runChat 各自设置)。
|
||||
const handleStopGenerate = () => {
|
||||
const sid = activeHistoryId.value;
|
||||
if (!sid || !sendingSessions[sid]) return;
|
||||
@@ -738,7 +780,13 @@ const handleStopGenerate = () => {
|
||||
};
|
||||
|
||||
// ===== 工作流执行:选中工作流 → 表单页 WS 执行 → 完成后切回对话页 =====
|
||||
const runWorkflow = async (sid: string, sessionId: string, mc: any) => {
|
||||
const runWorkflow = async (
|
||||
sid: string,
|
||||
sessionId: string,
|
||||
detail: any,
|
||||
opts: { formValues: Record<string, any>; formFileNames: Record<string, string | string[]>; templates?: any[] },
|
||||
formMsg: ChatMessage
|
||||
) => {
|
||||
const curSession = historyList.value.find((h) => h.id === sid);
|
||||
if (!curSession) {
|
||||
delete sendingSessions[sid];
|
||||
@@ -749,6 +797,9 @@ const runWorkflow = async (sid: string, sessionId: string, mc: any) => {
|
||||
const finishExec = (success: boolean, errorMsg?: string) => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
// 定格表单卡片:done/failed + 失败信息
|
||||
formMsg.formStatus = success ? 'done' : 'failed';
|
||||
formMsg.formError = success ? undefined : errorMsg || '执行失败';
|
||||
curSession.status = success ? 'completed' : 'failed';
|
||||
addMessage({
|
||||
id: 'msg-' + Date.now() + (success ? '-done' : '-fail'),
|
||||
@@ -804,9 +855,9 @@ const runWorkflow = async (sid: string, sessionId: string, mc: any) => {
|
||||
};
|
||||
|
||||
try {
|
||||
// 1. 构建节点输入参数:深拷贝 DSL,把首页表单值写回开始节点(唯一表单源)运行字段
|
||||
const nodeInputParams = JSON.parse(JSON.stringify(selectedWorkflowDetail.value.nodeInputParams || []));
|
||||
applyHomeFormValues(nodeInputParams, mc.formValues, mc.formFileNames);
|
||||
// 1. 构建节点输入参数:深拷贝 DSL,把表单卡片值写回开始节点(唯一表单源)运行字段
|
||||
const nodeInputParams = JSON.parse(JSON.stringify(detail.nodeInputParams || []));
|
||||
applyHomeFormValues(nodeInputParams, opts.formValues, opts.formFileNames);
|
||||
// 开始节点为唯一表单源:执行时 model 节点不带参数结构、form 节点不带自定义字段
|
||||
// (值已汇总进开始节点 outputConfig 一并提交,避免重复/冗余参数)
|
||||
nodeInputParams.forEach((n: any) => {
|
||||
@@ -821,7 +872,7 @@ const runWorkflow = async (sid: string, sessionId: string, mc: any) => {
|
||||
|
||||
// 2. 构建 flowContent
|
||||
const updatedFlowContent = {
|
||||
...selectedWorkflowDetail.value.flowContent,
|
||||
...detail.flowContent,
|
||||
nodes: nodeInputParams,
|
||||
};
|
||||
|
||||
@@ -857,6 +908,9 @@ const runWorkflow = async (sid: string, sessionId: string, mc: any) => {
|
||||
abort: () => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
// 停止/切会话:表单卡片定格为已停止(只读),不再追加汇总消息
|
||||
formMsg.formStatus = 'failed';
|
||||
formMsg.formError = '执行已停止';
|
||||
curSession.status = 'completed';
|
||||
markStopped(sid);
|
||||
delete sendingSessions[sid];
|
||||
@@ -870,8 +924,9 @@ const runWorkflow = async (sid: string, sessionId: string, mc: any) => {
|
||||
return;
|
||||
}
|
||||
if (activeHandler !== handler) return; // 等待期被终止/切走 → 不再发送启动帧
|
||||
activeRunIsWorkflow = true;
|
||||
sendWorkflowStart(ws, {
|
||||
flowId: selectedWorkflowDetail.value.id,
|
||||
flowId: detail.id,
|
||||
flowContent: updatedFlowContent,
|
||||
});
|
||||
} catch (e: any) {
|
||||
@@ -879,8 +934,43 @@ const runWorkflow = async (sid: string, sessionId: string, mc: any) => {
|
||||
}
|
||||
};
|
||||
|
||||
// ===== 表单卡片提交:工作流执行唯一入口(卡片内按钮触发) =====
|
||||
const handleFormSubmit = async (
|
||||
msg: ChatMessage,
|
||||
payload: { formValues: Record<string, any>; formFileNames: Record<string, string | string[]>; templates?: any[] }
|
||||
) => {
|
||||
// 定位卡片所在会话
|
||||
let sid = '';
|
||||
for (const [k, list] of sessionMessages.value) {
|
||||
if (list.some((m) => m.id === msg.id)) {
|
||||
sid = k;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!sid || sendingSessions[sid]) return;
|
||||
const session = historyList.value.find((h) => h.id === sid);
|
||||
if (!session || !msg.form) return;
|
||||
|
||||
// 卡片定格执行中,禁用重复提交
|
||||
msg.formStatus = 'running';
|
||||
msg.formError = undefined;
|
||||
|
||||
// 追加用户消息「执行工作流」
|
||||
addMessage({
|
||||
id: 'msg-' + Date.now() + '-wfuser',
|
||||
content: '执行工作流',
|
||||
time: formatTime(new Date()),
|
||||
isUser: true,
|
||||
});
|
||||
|
||||
session.status = 'executing';
|
||||
const sessionId = session.sessionId || getSessionId();
|
||||
await runWorkflow(sid, sessionId, msg.form, payload, msg);
|
||||
};
|
||||
|
||||
// ===== 普通对话:未选工作流 → 纯问答,AI 回复进消息流 =====
|
||||
const runChat = async (sid: string, sessionId: string, message: string) => {
|
||||
activeRunIsWorkflow = false;
|
||||
// AI loading 占位气泡
|
||||
const aiMsgId = 'msg-' + Date.now() + '-ai';
|
||||
addMessage({ id: aiMsgId, content: '', time: formatTime(new Date()), isUser: false, loading: true });
|
||||
@@ -1133,33 +1223,18 @@ const handleSelectHistory = async (id: string) => {
|
||||
teardownSession();
|
||||
activeHistoryId.value = id;
|
||||
activeMenu.value = 'chat';
|
||||
// 切换会话不继承工作流选择(回显的锁定标签由 InputBar 单独维护)
|
||||
selectedWorkflowDetail.value = null;
|
||||
const session = historyList.value.find((h) => h.id === id);
|
||||
// 加载会话内结果列表(session/get),用它判断会话类型:普通会话(无 workflow 结果)只依赖这一个接口,不再调 execution/get
|
||||
const results = await loadSessionResults(id);
|
||||
const hasWorkflow = Array.isArray(results) && results.some((r) => r.type === 'workflow');
|
||||
let asForm = false;
|
||||
if (session && hasWorkflow) {
|
||||
// 工作流会话:查 execution/get 回显表单(此处逻辑暂保留,后续再优化为用 session/get 数据)
|
||||
try {
|
||||
const res = await getExecutionDetail(session.id);
|
||||
if (res.data) {
|
||||
selectedWorkflowDetail.value = res.data;
|
||||
isHistoryWorkflow.value = true;
|
||||
asForm = true;
|
||||
// 同步回显 InputBar 的工作流选择
|
||||
const ib = inputBarRef.value as any;
|
||||
if (ib?.commonWorkflows && res.data.flowName) {
|
||||
const match = ib.commonWorkflows.find((w: any) => !w.isTemplate && w.name === res.data.flowName);
|
||||
if (match) ib.selectedWorkflowId = match.id;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 无执行详情 → 按普通对话会话处理
|
||||
}
|
||||
}
|
||||
if (!asForm) {
|
||||
// 工作流会话:回显为消息流(带值表单卡片 + 结果消息),不再跳整页表单页
|
||||
if (hasWorkflow) {
|
||||
isHistoryWorkflow.value = true; // InputBar 锁定(禁止更换工作流)
|
||||
await loadWorkflowSessionMessages(id, results);
|
||||
} else {
|
||||
// 普通对话会话 / 虚拟会话:切回对话页,把 session/get 结果转换为正常对话消息流展示
|
||||
selectedWorkflowDetail.value = null;
|
||||
isHistoryWorkflow.value = false;
|
||||
inputBarRef.value?.clearWorkflow?.();
|
||||
loadSessionMessagesFromResults(id, results);
|
||||
@@ -1174,6 +1249,65 @@ const handleSelectHistory = async (id: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 工作流会话回显:把 session/get 结果重建为消息流(带值表单卡片 + 结果消息),
|
||||
// 表单卡片直接用结果记录的 requestParams(完整 flowContent,含 __start__ outputConfig 值快照)构造,
|
||||
// 不再依赖 execution/get;chat 结果按原逻辑转消息流
|
||||
const loadWorkflowSessionMessages = async (sid: string, results: VOSessionInfoResult[]) => {
|
||||
// 最近一次 workflow 结果记录(results 时间倒序,第一条即最新):它的 requestParams 即回显表单值来源
|
||||
const latestWf = Array.isArray(results) ? results.find((r) => r.type === 'workflow') : undefined;
|
||||
// 同步回显 InputBar 的工作流选择(锁定标签展示):按 flowId 匹配(commonWorkflows 的 id 即工作流 id)
|
||||
let flowName = '';
|
||||
if (latestWf?.flowId) {
|
||||
const ib = inputBarRef.value as any;
|
||||
if (ib?.commonWorkflows) {
|
||||
const match = ib.commonWorkflows.find((w: any) => String(w.id) === String(latestWf.flowId));
|
||||
if (match) {
|
||||
ib.selectedWorkflowId = match.id;
|
||||
flowName = String(match.name || '');
|
||||
}
|
||||
}
|
||||
}
|
||||
const msgs: ChatMessage[] = [];
|
||||
// results 时间倒序(新→旧),转正序(旧→新)重建消息流
|
||||
[...results].reverse().forEach((r) => {
|
||||
if (r.type === 'chat') {
|
||||
const q = r.requestParams?.question;
|
||||
if (q) msgs.push({ id: 'rq-' + r.id, content: String(q), time: r.createdAt || '', isUser: true, recordId: String(r.id), recordType: r.type });
|
||||
if (r.resultContent) msgs.push({ id: 'ra-' + r.id, content: String(r.resultContent), time: r.createdAt || '', isUser: false, recordId: String(r.id), recordType: r.type });
|
||||
} else if (r.type === 'workflow') {
|
||||
// 失败判断:以 errorMsg 为准(部分失败记录 status 仍是 1,仅凭 status 会误判为成功)
|
||||
const failed = !!r.errorMsg;
|
||||
// 表单卡片:仅最近一次 workflow 结果渲染带值表单卡片(requestParams.nodes 即 nodeInputParams 结构)
|
||||
if (r === latestWf && Array.isArray(r.requestParams?.nodes)) {
|
||||
msgs.push({
|
||||
id: 'form-' + r.id,
|
||||
content: '',
|
||||
time: r.createdAt || '',
|
||||
isUser: false,
|
||||
type: 'form',
|
||||
form: {
|
||||
flowName,
|
||||
nodeInputParams: r.requestParams.nodes,
|
||||
flowContent: r.requestParams,
|
||||
},
|
||||
formStatus: failed ? 'failed' : 'done',
|
||||
formError: failed ? r.errorMsg || '执行失败' : '',
|
||||
});
|
||||
}
|
||||
// 结果消息
|
||||
msgs.push({
|
||||
id: failed ? 'wfail-' + r.id : 'wok-' + r.id,
|
||||
content: failed ? '❌ ' + (r.errorMsg || '执行失败,请重试或联系管理员') : '✅ 执行完成,可前往工作空间查看产出',
|
||||
time: r.createdAt || '',
|
||||
isUser: false,
|
||||
recordId: String(r.id),
|
||||
recordType: r.type,
|
||||
});
|
||||
}
|
||||
});
|
||||
sessionMessages.value.set(sid, msgs);
|
||||
};
|
||||
|
||||
// 加载会话内结果(session/get 第一页):workflow+chat 混排,按时间倒序
|
||||
const loadSessionResults = async (sid: string) => {
|
||||
if (!sid) return [];
|
||||
|
||||
Reference in New Issue
Block a user