工作流节点库 outputField 引用 + totalDuration 字段级引用;首页选项卡拆分/懒加载、工作空间结果接口 resultList;引用标签与 KeyValue prop 校验修复

- 节点库 outputField 作为可引用输出项:NodeLibraryItem 补类型、getNodeOutputFields 优先返回、upstreamNodes 来源扩展
- script_transcribe 视频总时长支持引用上级输出/手动输入:NodeConfigPanel 引用按钮 + valueSource 存 formConfig 条目,buildOutputConfig 序列化、buildNodeFormConfigFromDsl 回显
- 首页工作空间结果接口切 /ai-agent/session/resultList,会话列表/结果树选项卡拆分独立容错与按需懒加载
- ChatList 滚底感知;引用标签 ElTag type 空值、KeyValueEditor modelValue 空值兜底修复

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-20 00:38:19 +08:00
co-authored by Claude
parent 998e13e6be
commit c1658380a7
9 changed files with 382 additions and 98 deletions
+2
View File
@@ -36,6 +36,8 @@ export interface NodeLibraryItem {
isSaveFileOption: boolean;
formConfigOption: boolean;
modelConfigOption: boolean;
// 节点自带输出项([{ field, label }]):空/null 表示无输出;非空时该节点可作为下游节点的引用来源
outputField?: { field: string; label: string }[] | null;
presetOption: NodeLibraryPresetOption[] | null;
}
+36
View File
@@ -64,3 +64,39 @@ export function deleteSessionRecord(ids: { id: string; type: string }[], request
requestOptions,
});
}
// ===== 工作空间结果列表(/ai-agent/session/resultList=====
// 与内容创作 execution/list 同结构:data.tree(按 createDate 分组)+ data.imgAddressPrefix
export interface ExecutionItem {
id: string;
timestamp: string;
content: string;
label: string;
type?: string;
}
export interface ExecutionTreeItem {
createDate: string;
items: ExecutionItem[];
}
export interface ExecutionListData {
tree: ExecutionTreeItem[];
imgAddressPrefix: string;
}
export interface ExecutionListResponse {
code: number;
message: string;
data: ExecutionListData;
}
/** 获取工作空间结果列表(结果树) */
export function getWorkspaceResultList(params?: Record<string, any>, requestOptions?: RequestOptions) {
return request({
url: '/ai-agent/session/resultList',
method: 'get',
params,
requestOptions,
}) as Promise<ExecutionListResponse>;
}
+10 -2
View File
@@ -206,6 +206,10 @@ const thinkingTitle = (msg: ChatMessage): string => {
return msg.thinkingSeconds != null ? `已深度思考(用时 ${msg.thinkingSeconds} 秒)` : '已深度思考';
};
// 距底部小于该阈值(px)视为"正在查看最新":新消息到达自动滚底;用户上滑阅读历史(超过阈值)则不强制拉回
const NEAR_BOTTOM_THRESHOLD = 120;
// 是否处于"查看最新"状态:初始为真(挂载即定位最新),由用户真实滚动(isTrusted)更新
let isAtBottom = true;
// autoLoading 标记本次 load-more 是否由"自动续载"触发:自动续载不保持位置(落在最新),用户上滑保持视口
let autoLoading = false;
// 自动续载累计次数上限:连续自动加载超过上限即停,防止异常数据(如 hasMore 恒 true 且视口不满)
@@ -224,7 +228,8 @@ watch(
nextTick(() => {
const el = chatListRef.value;
if (!el) return;
el.scrollTop = el.scrollHeight;
// 仅当用户贴近底部(isAtBottom)时才滚底;上滑阅读历史期间新消息不打断阅读位置
if (isAtBottom) el.scrollTop = el.scrollHeight;
if (props.messages.length > 0 && props.hasMore && el.scrollHeight <= el.clientHeight) {
if (canAutoLoad()) {
autoLoading = true;
@@ -284,7 +289,10 @@ const handleScroll = (e: Event) => {
// 自动续载/滚底时 scrollTop 不满屏被钳制为 0,会被误判为"用户上滑"而清空 autoLoading、破坏自动定位到最新
if (!e.isTrusted) return;
const el = chatListRef.value;
if (!el || !props.hasMore || props.loadingMore) return;
if (!el) return;
// 感知用户阅读位置:贴近底部视为"正在查看最新"(新消息自动滚底),上滑阅读历史则不再强制拉回
isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < NEAR_BOTTOM_THRESHOLD;
if (!props.hasMore || props.loadingMore) return;
if (el.scrollTop <= 4) {
// 用户主动上滑加载更早:重置自动续载计数,使用户操作不受自动续载上限约束
autoLoading = false;
+19 -3
View File
@@ -51,7 +51,7 @@
<button
class="send-btn"
:class="{ 'is-stop': generating && !isWorkflowRunning }"
:disabled="selectedWorkflowId !== null || isWorkflowRunning || (!generating && sendDisabled)"
:disabled="selectedWorkflowId !== null || isWorkflowRunning || (!generating && (sendDisabled || !message.trim()))"
@click="generating && !isWorkflowRunning ? emit('stop') : handleSend()"
>
<el-icon v-if="generating && !isWorkflowRunning"><VideoPause /></el-icon>
@@ -123,6 +123,8 @@ const message = ref('');
const isFocused = ref(false);
const selectedWorkflowId = ref<string | null>(null);
const commonWorkflows = ref<Workflow[]>([]);
// 取消工作流前的选择:父组件确认「继续编辑」时回滚,避免 InputBar 先置空、父侧保留草稿造成状态脱钩
const previousSelectedWorkflowId = ref<string | null>(null);
// 工作流胶囊始终展示全部工作流:历史工作流会话回显预选上次执行的工作流,
// 但仍允许自由切换/取消(不锁定为单个工作流)
@@ -164,7 +166,9 @@ const fetchWorkflows = async () => {
const handleSend = () => {
if (props.sendDisabled) return;
const msg = message.value.trim();
emit('send', msg || '');
// 空消息不发送(避免产生空用户气泡与空问题请求)
if (!msg) return;
emit('send', msg);
message.value = '';
// 不清除工作流选择,保持表单可见
};
@@ -177,6 +181,8 @@ const toggleWorkflow = (id: string) => {
if (lockAll.value) return;
const item = commonWorkflows.value.find((w) => w.id === id);
const newId = selectedWorkflowId.value === id ? null : id;
// 取消(newId 为 null)前记录当前选择,供父组件确认「继续编辑」时回滚
if (newId === null) previousSelectedWorkflowId.value = selectedWorkflowId.value;
selectedWorkflowId.value = newId;
emit('workflow-select', newId, item?.isTemplate || false);
};
@@ -189,6 +195,8 @@ const resetAll = () => {
const clearWorkflow = () => {
if (lockAll.value) return;
// 清空(取消)前记录当前选择,供父组件确认「继续编辑」时回滚
previousSelectedWorkflowId.value = selectedWorkflowId.value;
selectedWorkflowId.value = null;
emit('workflow-select', null);
};
@@ -199,6 +207,14 @@ const selectWorkflow = (id: string | null) => {
emit('workflow-select', id, false);
};
// 父组件确认「放弃未提交表单」失败时回滚选择:恢复取消前的工作流(标签与禁用态归位),
// 不发 emit(父侧草稿与 selectedWorkflowDetail 保留,状态由父组件维持)
const restoreWorkflowSelection = () => {
if (previousSelectedWorkflowId.value !== null) {
selectedWorkflowId.value = previousSelectedWorkflowId.value;
}
};
onMounted(() => {
fetchWorkflows();
});
@@ -226,7 +242,7 @@ const onCapsuleAfterLeave = (el: Element) => {
(el as HTMLElement).style.maxHeight = '';
};
defineExpose({ resetAll, clearWorkflow, selectWorkflow, fetchWorkflows, selectedWorkflowId, commonWorkflows });
defineExpose({ resetAll, clearWorkflow, selectWorkflow, restoreWorkflowSelection, fetchWorkflows, selectedWorkflowId, commonWorkflows });
</script>
<style scoped lang="scss">
+12 -2
View File
@@ -15,11 +15,11 @@
<!-- Tab 切换 -->
<div class="sidebar-tabs">
<button class="tab-btn" :class="{ active: activeTab === 'history' }" @click="activeTab = 'history'">
<button class="tab-btn" :class="{ active: activeTab === 'history' }" @click="handleTabChange('history')">
<el-icon><ChatLineRound /></el-icon>
会话记录
</button>
<button class="tab-btn" :class="{ active: activeTab === 'workspace' }" @click="activeTab = 'workspace'">
<button class="tab-btn" :class="{ active: activeTab === 'workspace' }" @click="handleTabChange('workspace')">
<el-icon><FolderOpened /></el-icon>
工作空间
</button>
@@ -110,12 +110,22 @@ interface Emits {
(e: 'preview-node', data: TreeNode): void;
(e: 'download-node', data: TreeNode): void;
(e: 'delete-node', data: TreeNode): void;
(e: 'load-workspace'): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const activeTab = ref<'history' | 'workspace'>('history');
// 工作空间懒加载标记:首次切换到「工作空间」tab 时才通知父组件加载结果树,避免进入页面即拉取
const workspaceLoaded = ref(false);
const handleTabChange = (tab: 'history' | 'workspace') => {
activeTab.value = tab;
if (tab === 'workspace' && !workspaceLoaded.value) {
workspaceLoaded.value = true;
emit('load-workspace');
}
};
const getFileIcon = (fileType?: string) => {
+30 -16
View File
@@ -13,6 +13,7 @@
@preview-node="previewNode"
@download-node="downloadNode"
@delete-node="handleDeleteNode"
@load-workspace="loadWorkspaceTree"
/>
<div class="main-wrapper">
<MainContent
@@ -95,15 +96,13 @@ import type { WorkflowOutput } from './utils/flowDsl';
import { getChatModel, listModelManage } from '/@/api/settings/modelConfigV2';
import { connectSessionSocket, sendAgentStart, sendWorkflowStart, sendCancel } from './utils/wsExecute';
import { parseWsMessage, getDelta, getAnswer, getRecordId, getErrorText, getNodeProgress, getToolCallName, getToolResultText } from './utils/wsMessage';
import type { ExecutionTreeItem } from '/@/api/settings/creation';
import {
getExecutionList,
getWorkflowDetail,
deleteExecutionResult,
downloadToFile,
} from '/@/api/settings/creation';
import { getSessionListV2, getSessionResults, deleteSessionV2, deleteSessionRecord } from '/@/api/settings/workflow/session';
import type { VOSessionInfoResult } from '/@/api/settings/workflow/session';
import { getSessionListV2, getSessionResults, deleteSessionV2, deleteSessionRecord, getWorkspaceResultList } from '/@/api/settings/workflow/session';
import type { ExecutionTreeItem, VOSessionInfoResult } from '/@/api/settings/workflow/session';
interface HistoryItem {
id: string;
@@ -282,12 +281,25 @@ const getSessionData = (res: any): any[] => {
return [];
};
const getList = async () => {
// 工作空间结果树加载(独立容错:结果树失败不影响会话记录列表)
const loadWorkspaceTree = async () => {
treeLoading.value = true;
try {
const [execRes, sessionRes] = await Promise.all([getExecutionList(), getSessionListV2({ pageNum: 1, pageSize: 10 })]);
const execRes = await getWorkspaceResultList();
imgAddressPrefix.value = execRes.data?.imgAddressPrefix || '';
treeNodes.value = buildTreeNodes(execRes.data?.tree || []);
} catch {
treeNodes.value = [];
imgAddressPrefix.value = '';
} finally {
treeLoading.value = false;
}
};
// 会话记录列表加载(独立容错:会话列表失败不影响工作空间结果树)
const loadSessionHistory = async () => {
try {
const sessionRes = await getSessionListV2({ pageNum: 1, pageSize: 10 });
const d = new Date();
const todayStr = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
const sessions = getSessionData(sessionRes)
@@ -320,11 +332,7 @@ const getList = async () => {
}
});
} catch {
treeNodes.value = [];
historyList.value = [];
imgAddressPrefix.value = '';
} finally {
treeLoading.value = false;
}
};
@@ -526,7 +534,9 @@ const handleWorkflowSelect = async (workflowId: string | null, isTemplate?: bool
type: 'warning',
});
} catch {
return; // 用户选择继续编辑不清除草稿与选择
// 用户选择继续编辑不清除草稿与选择;InputBar 已先置空选择,回滚恢复标签与禁用态
inputBarRef.value?.restoreWorkflowSelection?.();
return;
}
}
removeDraftFormCard();
@@ -924,7 +934,7 @@ const runWorkflow = async (
if (success) {
ElMessage.success('✅ 执行完成');
// 刷新工作空间树(查询所有结果)
getExecutionList().then((res) => {
getWorkspaceResultList().then((res) => {
imgAddressPrefix.value = res.data?.imgAddressPrefix || '';
treeNodes.value = buildTreeNodes(res.data?.tree || []);
});
@@ -950,6 +960,8 @@ const runWorkflow = async (
const updatedFlowContent = {
...detail.flowContent,
nodes: nodeInputParams,
// 贴片模板:表单卡片编辑后随提交回传(后端从 flowContent.templates 读取,空数组也如实提交表示清空)
...(Array.isArray(opts.templates) ? { templates: opts.templates } : {}),
};
// 3. 工作流模型由后端按 flow 配置决定,无需前端取模型 id(普通对话取模型见 runChat)
@@ -970,8 +982,9 @@ const runWorkflow = async (
} else if (msg.type === 'error') {
finishExec(false, getErrorText(msg));
} else if (msg.type === 'flow_complete') {
// flow_complete 为后端新增的工作流完成信号,事件自带产出文件 URL 列表 → 直接渲染产出卡片
finishExec(true, undefined, buildOutputsFromUrls(msg?.data?.resultFileUrls));
// flow_complete 为后端新增的工作流完成信号,事件自带产出文件 URL 列表 → 直接渲染产出卡片
// 以 round_start 首帧填充的 roundRecordId 作 backendId,保证实时产出可删除(与回显路径一致)
finishExec(true, undefined, buildOutputsFromUrls(msg?.data?.resultFileUrls, roundRecordId));
} else if (msg.type === 'answer') {
// answer 为最终回答完成信号(无产出文件)
finishExec(true);
@@ -1101,7 +1114,7 @@ const handleOutputDelete = async (msg: ChatMessage, output: WorkflowOutput) => {
const idx = msg.outputs.findIndex((o) => o.url === output.url);
if (idx >= 0) msg.outputs.splice(idx, 1);
}
getExecutionList().then((res) => {
getWorkspaceResultList().then((res) => {
imgAddressPrefix.value = res.data?.imgAddressPrefix || '';
treeNodes.value = buildTreeNodes(res.data?.tree || []);
});
@@ -1612,7 +1625,8 @@ const handleDeleteHistory = async (id: string) => {
};
onMounted(() => {
getList();
// 默认只加载会话记录列表;工作空间结果树为懒加载(首次点击「工作空间」tab 时由 Sidebar 触发)
loadSessionHistory();
loadCurrentChatModel();
// 不自动创建 / 选中会话,默认显示占位引导页
});
@@ -83,7 +83,7 @@
:key="`${ref.nodeId}-${ref.field}-${i}`"
size="small"
class="mf-ref-tag"
:type="ref.invalid ? 'danger' : ''"
:type="ref.invalid ? 'danger' : 'primary'"
closable
@close="removeValueSource(ref)"
>{{ ref.label }}</el-tag>
@@ -116,62 +116,86 @@
<template v-if="nodeConfig?.formConfig && nodeConfig.formConfig.length > 0">
<el-divider content-position="left">节点参数</el-divider>
<template v-for="field in nodeConfig.formConfig" :key="field.field">
<el-form-item :label="field.label" :required="field.required">
<el-input
v-if="field.type === 'input' || field.type === 'string'"
:model-value="getFieldValue(field.field)"
@update:model-value="updateFieldValue(field.field, $event)"
:placeholder="field.required ? '必填' : '选填'"
/>
<el-input-number
v-else-if="field.type === 'number' || field.type === 'inputNumber'"
:model-value="getFieldValue(field.field)"
@update:model-value="updateFieldValue(field.field, $event)"
class="w100"
/>
<el-input
v-else-if="field.type === 'textarea'"
:model-value="getFieldValue(field.field)"
@update:model-value="updateFieldValue(field.field, $event)"
type="textarea"
:rows="3"
:placeholder="field.required ? '必填' : '选填'"
/>
<el-switch
v-else-if="field.type === 'switch'"
:model-value="getFieldValue(field.field)"
@update:model-value="updateFieldValue(field.field, $event)"
/>
<el-select
v-else-if="field.type === 'select' && field.options"
:model-value="getFieldValue(field.field)"
@update:model-value="updateSelectFieldValue(field, $event)"
:placeholder="field.required ? '必填' : '选填'"
class="w100"
>
<el-option
v-for="opt in field.options"
:key="opt.key || opt.value"
:label="opt.value ?? opt.key"
:value="opt.key ?? opt.value"
<el-form-item :required="field.required">
<template #label>
<span class="dyn-field-label">{{ field.label }}</span>
<!-- 脚本转写节点的视频总时长支持引用上级节点输出引用后隐藏输入显示引用来源 -->
<MfReferenceButton v-if="isTotalDurationField(field)" :field-def="fieldRefDef(field)" :upstream-nodes="upstreamNodes" :multi="false" />
</template>
<!-- 脚本转写节点的视频总时长引用上级输出后隐藏输入控件显示引用来源标签 -->
<template v-if="isTotalDurationField(field) && hasFieldRef(field.field)">
<div class="mf-ref-bound">
<div class="mf-ref-tags">
<el-tag
v-for="(ref, i) in fieldRefList(field.field)"
:key="`${ref.nodeId}-${ref.field}-${i}`"
size="small"
class="mf-ref-tag"
:type="ref.invalid ? 'danger' : 'primary'"
closable
@close="removeFieldRef(field, ref)"
>{{ ref.label }}</el-tag>
</div>
<el-button size="small" text type="danger" @click="clearFieldRef(field)">清空</el-button>
</div>
</template>
<template v-else>
<el-input
v-if="field.type === 'input' || field.type === 'string'"
:model-value="getFieldValue(field.field)"
@update:model-value="updateFieldValue(field.field, $event)"
:placeholder="field.required ? '必填' : '选填'"
/>
</el-select>
<KeyValueEditor
v-else-if="field.type === 'keyValue'"
:model-value="getFieldValue(field.field)"
@update:model-value="(v: any) => updateFieldValue(field.field, v)"
/>
<el-button
v-else-if="field.type === 'schemaJson'"
@click="openSchemaEditor(field.field)"
style="width: 100%"
>配置</el-button>
<el-input
v-else
:model-value="getFieldValue(field.field)"
@update:model-value="updateFieldValue(field.field, $event)"
:placeholder="field.required ? '必填' : '选填'"
/>
<el-input-number
v-else-if="field.type === 'number' || field.type === 'inputNumber'"
:model-value="getFieldValue(field.field)"
@update:model-value="updateFieldValue(field.field, $event)"
class="w100"
/>
<el-input
v-else-if="field.type === 'textarea'"
:model-value="getFieldValue(field.field)"
@update:model-value="updateFieldValue(field.field, $event)"
type="textarea"
:rows="3"
:placeholder="field.required ? '必填' : '选填'"
/>
<el-switch
v-else-if="field.type === 'switch'"
:model-value="getFieldValue(field.field)"
@update:model-value="updateFieldValue(field.field, $event)"
/>
<el-select
v-else-if="field.type === 'select' && field.options"
:model-value="getFieldValue(field.field)"
@update:model-value="updateSelectFieldValue(field, $event)"
:placeholder="field.required ? '必填' : '选填'"
class="w100"
>
<el-option
v-for="opt in field.options"
:key="opt.key || opt.value"
:label="opt.value ?? opt.key"
:value="opt.key ?? opt.value"
/>
</el-select>
<KeyValueEditor
v-else-if="field.type === 'keyValue'"
:model-value="getKeyValueValue(field.field)"
@update:model-value="(v: any) => updateFieldValue(field.field, v)"
/>
<el-button
v-else-if="field.type === 'schemaJson'"
@click="openSchemaEditor(field.field)"
style="width: 100%"
>配置</el-button>
<el-input
v-else
:model-value="getFieldValue(field.field)"
@update:model-value="updateFieldValue(field.field, $event)"
:placeholder="field.required ? '必填' : '选填'"
/>
</template>
</el-form-item>
<!-- 下拉选中选项的嵌套配置(responseType 级联) -->
@@ -215,7 +239,7 @@
</el-select>
<KeyValueEditor
v-else-if="sub.type === 'keyValue'"
:model-value="getExpandValue(field.field, sub.field)"
:model-value="getExpandKeyValueValue(field.field, sub.field)"
@update:model-value="(v: any) => updateExpandValue(field.field, sub.field, v)"
/>
<el-button
@@ -303,9 +327,10 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import { ref, computed, watch } from 'vue';
import type { Node } from '@vue-flow/core';
import KeyValueEditor from './KeyValueEditor.vue';
import MfReferenceButton from './MfReferenceButton.vue';
import FormFieldsEditor, { type FormField } from './FormFieldsEditor.vue';
import ModelParamsForm from './ModelParamsForm.vue';
import PromptEditor from './PromptEditor.vue';
@@ -562,6 +587,12 @@ const getFieldValue = (fieldName: string) => {
return field?.value ?? '';
};
// keyValue 类型字段的值以对象形式传给编辑器:空值兜底空对象,避免 prop 类型告警(组件期望 Record<string, unknown>
const getKeyValueValue = (fieldName: string) => {
const v = getFieldValue(fieldName);
return v && typeof v === 'object' ? v : {};
};
const updateFieldValue = (fieldName: string, value: any) => {
if (!props.selectedNode?.data) return;
@@ -597,6 +628,108 @@ const getFormConfigEntry = (fieldName: string) => {
return props.selectedNode.data.formConfig.find((f: any) => f.field === fieldName);
};
// ===== 脚本转写节点「视频总时长」字段级引用:支持引用上级节点输出 / 手动输入 =====
// valueSource 契约统一数组 [{ nodeId, field }],存到 node.data.formConfig 对应条目;
// 仅脚本转写节点的 totalDuration 字段启用引用能力(值可由用户手动输入或引用上游输出)
const isTotalDurationField = (field: any) => !!field && field.field === 'totalDuration' && field.isFormField !== true;
// 读字段引用(统一数组;兼容旧 DSL 单对象)
const getFieldValueSource = (fieldName: string) => {
const entry = getFormConfigEntry(fieldName);
const vs = entry?.valueSource;
if (Array.isArray(vs)) return vs;
if (vs && typeof vs === 'object') return [vs];
return [];
};
// 写字段引用:无条目先创建(复用字段定义);引用清空时移除 valueSource 键
const setFieldValueSource = (field: any, valueSource: any) => {
if (!props.selectedNode?.data) return;
const formConfig = props.selectedNode.data.formConfig || [];
const idx = formConfig.findIndex((f: any) => f && f.field === field.field);
const arr = Array.isArray(valueSource) ? valueSource : [];
let updatedFormConfig;
if (idx >= 0) {
updatedFormConfig = [...formConfig];
const { valueSource: _vs, ...rest } = updatedFormConfig[idx];
updatedFormConfig[idx] = arr.length > 0 ? { ...rest, valueSource: arr } : rest;
} else {
updatedFormConfig = [...formConfig, { ...field, value: '', ...(arr.length > 0 ? { valueSource: arr } : {}) }];
}
const updatedNode = {
...props.selectedNode,
data: { ...props.selectedNode.data, formConfig: updatedFormConfig },
};
emit('update:selectedNode', updatedNode);
};
const hasFieldRef = (fieldName: string) => getFieldValueSource(fieldName).length > 0;
// 引用展示列表:label 用字段中文名(不带「节点名.」前缀);引用来源失效标红
const fieldRefList = (fieldName: string) =>
getFieldValueSource(fieldName).map((r: any) => {
const node = props.upstreamNodes?.find((n) => n.id === r?.nodeId);
const fieldLabel = node?.outputFields.find((f: any) => f.field === r?.field)?.label || r?.field || '';
const invalid = !node || !node.outputFields.some((f: any) => f.field === r?.field);
return { nodeId: r?.nodeId, field: r?.field, label: fieldLabel, invalid };
});
const removeFieldRef = (field: any, ref: any) => {
const keep = getFieldValueSource(field.field).filter((r: any) => !(r?.nodeId === ref.nodeId && r?.field === ref.field));
setFieldValueSource(field, keep);
};
const clearFieldRef = (field: any) => setFieldValueSource(field, []);
// 字段引用可写代理:供 MfReferenceButton 就地读写 valueSourceset/deleteProperty 拦截到 formConfig 条目)
const fieldRefDef = (field: any) =>
new Proxy(
{ ...field },
{
get(t, key) {
if (key === 'valueSource') return getFieldValueSource(field.field);
return Reflect.get(t, key);
},
set(t, key, value) {
if (key === 'valueSource') {
setFieldValueSource(field, value);
return true;
}
return Reflect.set(t, key, value);
},
deleteProperty() {
setFieldValueSource(field, undefined);
return true;
},
}
);
// 失效引用自动清理:引用来源节点/字段被移除时过滤脏数据,避免保存时写入失效引用
watch(
() => {
if (!props.selectedNode?.data?.nodeCode) return null;
const entry = getFormConfigEntry('totalDuration');
// 依赖 upstreamNodes 引用,保证删除上游节点/字段时触发清理
return { vs: entry?.valueSource, upstream: props.upstreamNodes };
},
(state: any) => {
const vs = state?.vs;
if (!vs || typeof vs !== 'object') return;
const arr = Array.isArray(vs) ? vs : [vs];
const anyInvalid = arr.some((r: any) => {
const node = props.upstreamNodes?.find((n) => n.id === r?.nodeId);
return !node || !node.outputFields.some((f: any) => f.field === r?.field);
});
if (!anyInvalid) return;
const keep = arr.filter((r: any) => {
const node = props.upstreamNodes?.find((n) => n.id === r?.nodeId);
return node && node.outputFields.some((f: any) => f.field === r?.field);
});
setFieldValueSource({ field: 'totalDuration' }, keep);
},
{ immediate: true }
);
// 当前选中下拉选项的嵌套 config 定义(来自节点库 presetOption
const getSelectedOptionConfig = (fieldDef: any) => {
if (fieldDef?.type !== 'select' || !Array.isArray(fieldDef.options)) return [];
@@ -612,6 +745,12 @@ const getExpandValue = (hostField: string, subField: string) => {
return sub?.value ?? '';
};
// 嵌套 keyValue 类型字段对象化(同 getKeyValueValue,供下拉选项嵌套配置使用)
const getExpandKeyValueValue = (hostField: string, subField: string) => {
const v = getExpandValue(hostField, subField);
return v && typeof v === 'object' ? v : {};
};
// 写嵌套 expand 字段值(单次 emit
const updateExpandValue = (hostField: string, subField: string, value: any) => {
if (!props.selectedNode?.data) return;
@@ -672,6 +811,35 @@ const updateSelectFieldValue = (fieldDef: any, value: any) => {
</script>
<style scoped lang="scss">
/* 动态字段 label 行:字段名与引用按钮并排 */
.dyn-field-label {
margin-right: 2px;
}
/* 字段引用来源标签区(脚本转写节点「视频总时长」引用上级输出后展示) */
.mf-ref-bound {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
width: 100%;
.mf-ref-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
flex: 1;
min-width: 0;
}
.mf-ref-tag {
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.config-panel {
background: #ffffff;
border-radius: 10px;
+47 -17
View File
@@ -215,6 +215,8 @@ const nodeConfigMap = computed(() => {
promptOption: boolean;
negativePromptOption: boolean;
isSaveFileOption: boolean;
// 节点自带输出项(节点库 outputField):非空时该节点可作为下游引用来源
outputField: any[];
}
>();
nodeLibraryGroups.value.forEach((group) => {
@@ -231,6 +233,8 @@ const nodeConfigMap = computed(() => {
promptOption: item.promptOption || false,
negativePromptOption: item.negativePromptOption || false,
isSaveFileOption: item.isSaveFileOption || false,
// 节点自带输出项:可被其它节点引用的字段(脚本转写等节点提供;空表示无输出)
outputField: item.outputField || [],
});
});
});
@@ -799,6 +803,11 @@ const collectHttpOutputFields = (node: any, prefix = '', isRoot = true, suppress
// 取节点在设计时的可引用输出字段(运行时这些字段会产出实际值)
const getNodeOutputFields = (node: Node<NodeData, any, string>): UpstreamField[] => {
const nodeCode = node.data?.nodeCode || '';
// 节点库自带输出项(outputField):非空时优先作为该节点的可引用输出(脚本转写等节点)
const ownOutput = nodeConfigMap.value.get(nodeCode)?.outputField || [];
if (ownOutput.length > 0) {
return ownOutput.map((o: any) => ({ field: o.field || o.label || '', label: o.label || o.field || '' }));
}
if (nodeCode === 'form') {
// form 节点:自定义表单字段即输出
return (node.data?.formConfig || []).map((f: any) => ({
@@ -865,8 +874,11 @@ const upstreamNodes = computed<UpstreamNodeInfo[]>(() => {
visited.add(p);
const n = nodes.value.find((x) => x.id === p);
if (n) {
// 只有模型/HTTP/表单节点有输出参数,可作为下游引用来源
if (OUTPUT_NODE_CODES.includes(n.data?.nodeCode || '')) {
// 可作为下游引用来源:模型/HTTP/表单节点,或节点库声明了自带输出项(outputField)的节点(如脚本转写)
const hasOutput =
OUTPUT_NODE_CODES.includes(n.data?.nodeCode || '') ||
(nodeConfigMap.value.get(n.data?.nodeCode || '')?.outputField?.length ?? 0) > 0;
if (hasOutput) {
result.push({
id: n.id,
label: n.data?.label || n.id,
@@ -1033,7 +1045,7 @@ const editWorkflow = async (workflow: WorkflowItem) => {
};
// 从 DSL 节点重建 formConfig(新格式:outputConfig;旧格式:formConfig 兜底)
const buildNodeFormConfigFromDsl = (n: any, startRunFields: any[] = []) => {
const buildNodeFormConfigFromDsl = (n: any, startRunFields: any[] = [], startNodeId = '') => {
// form 节点:自定义字段(完整结构 {type,field,label,value,required},兼容命名对象 [{key:value}]
if (n.nodeCode === 'form' && Array.isArray(n.outputConfig)) {
return n.outputConfig.map((o: any) => {
@@ -1089,7 +1101,16 @@ const buildNodeFormConfigFromDsl = (n: any, startRunFields: any[] = []) => {
// isFormField:true 字段不随节点 outputConfig 保存(值在开始节点运行表单),
// 从开始节点反查恢复,保证编辑器参数面板回显与运行表单一致
const startValue = startRunFields.find((r: any) => r && r.field === def.field)?.value;
return { ...def, value: out?.value ?? startValue ?? '' };
// 引用上级输出(如脚本转写的视频总时长):DSL valueSource 指向非开始节点时恢复为字段引用,
// 编辑器隐藏输入、显示引用来源(指向开始节点的 valueSource 是表单展示标记,由 isFormField 分支处理)
const refVs = Array.isArray(out?.valueSource)
? out.valueSource.filter((r: any) => r && r.nodeId && r.nodeId !== startNodeId && r.field)
: [];
return {
...def,
value: out?.value ?? startValue ?? '',
...(refVs.length ? { valueSource: refVs.map((r: any) => ({ nodeId: r.nodeId, field: r.field })) } : {}),
};
});
}
// 无 presetOption 定义:outputConfig 为数组时透传(form 节点已提前处理)
@@ -1100,8 +1121,15 @@ const buildNodeFormConfigFromDsl = (n: any, startRunFields: any[] = []) => {
return null;
};
// 引用来源 label 反查:字段中文名(不带「节点名.」前缀;无索引/未命中时兜底 field)
const resolveRefLabel = (nodeId: string, field: string, outputIndex?: Map<string, { name: string; fields: Map<string, string> }>): string => {
const info = outputIndex?.get(nodeId);
if (!info) return field;
return info.fields.get(field) || field;
};
// 构建保存用的 outputConfigHTTP/form 节点),其余返回 null(开始节点的运行表单字段由保存处单独写入)
const buildOutputConfig = (node: Node<NodeData>, startNodeId = '') => {
const buildOutputConfig = (node: Node<NodeData>, startNodeId = '', outputIndex?: Map<string, { name: string; fields: Map<string, string> }>) => {
const nodeCode = node.data?.nodeCode || '';
if (nodeCode === 'http') {
const defs = nodeConfigMap.value.get(nodeCode)?.formConfig || [];
@@ -1171,13 +1199,21 @@ const buildOutputConfig = (node: Node<NodeData>, startNodeId = '') => {
.map((def: any) => {
const entry = formConfig.find((f: any) => f && f.field === def.field);
const isForm = Boolean(def && typeof def === 'object' && def.isFormField === true);
// 引用上级输出(如脚本转写的视频总时长):formConfig 条目挂 valueSource 数组;
// 值由上游节点运行时产出,序列化时不重复保存 value(回显从引用来源恢复)
const refVs = Array.isArray(entry?.valueSource) ? entry.valueSource.filter((r: any) => r && r.nodeId && r.field) : [];
const hasRef = refVs.length > 0;
return {
type: def.type || 'input',
field: def.field,
label: def.label || def.field,
// isFormField 字段值在开始节点运行表单,不重复保存(回显从开始节点反查)
...(isForm ? {} : { value: entry?.value ?? '' }),
...(isForm ? { valueSource: [{ nodeId: startNodeId, field: def.field, label: def.label || def.field }] } : {}),
// isFormField 字段值在开始节点运行表单、引用型字段值由上游节点产出,均不重复保存
...(isForm || hasRef ? {} : { value: entry?.value ?? '' }),
...(isForm
? { valueSource: [{ nodeId: startNodeId, field: def.field, label: def.label || def.field }] }
: hasRef
? { valueSource: refVs.map((r: any) => ({ nodeId: r.nodeId, field: r.field, label: resolveRefLabel(r.nodeId, r.field, outputIndex) })) }
: {}),
};
});
};
@@ -1185,12 +1221,6 @@ const buildOutputConfig = (node: Node<NodeData>, startNodeId = '') => {
// 子流程节点:编辑器态 subFlowConfig → DSL 保存态 subConfigruntimeShow → isFormField
const serializeSubFlowConfig = (config: SubFlowConfig | null | undefined, node?: Node<NodeData>, startNodeId = '', outputIndex?: Map<string, { name: string; fields: Map<string, string> }>) => {
if (!config || !config.workflowId) return null;
// 引用来源 label:字段中文名(不带「节点名.」前缀;无索引/未命中时兜底 field)
const resolveRefLabel = (nodeId: string, field: string): string => {
const info = outputIndex?.get(nodeId);
if (!info) return field;
return info.fields.get(field) || field;
};
// 生成次数(节点库 presetOption 的 maxConcurrency):勾选表单展示时聚合进开始节点表单,
// 此处同步保存到 subConfig,保证 sub_flow 节点自身也带该配置(后端执行时可读)
const formConfig = Array.isArray(node?.data?.formConfig) ? node.data.formConfig : [];
@@ -1219,7 +1249,7 @@ const serializeSubFlowConfig = (config: SubFlowConfig | null | undefined, node?:
valueSource: f.runtimeShow
? [{ nodeId: startNodeId, field: f.field, label: f.label || f.field }]
: Array.isArray(f.valueSource)
? f.valueSource.map((vs: any) => ({ nodeId: vs.nodeId, field: vs.field, label: resolveRefLabel(vs?.nodeId, vs?.field) }))
? f.valueSource.map((vs: any) => ({ nodeId: vs.nodeId, field: vs.field, label: resolveRefLabel(vs?.nodeId, vs?.field, outputIndex) }))
: null,
isFormField: Boolean(f.runtimeShow),
})),
@@ -1303,7 +1333,7 @@ const loadWorkflowFromDsl = (dsl: any) => {
const loadedNodes = (dsl.nodes || []).map((n: any) => {
const isStart = n.nodeCode === '__start__';
// 子流程节点:参数面板的生成次数为空时,用 subConfig.maxConcurrency 兜底恢复(兼容边界数据)
let formConfig = buildNodeFormConfigFromDsl(n, startRunFields);
let formConfig = buildNodeFormConfigFromDsl(n, startRunFields, startNodeId);
if (n.nodeCode === 'sub_flow' && Array.isArray(formConfig) && formConfig.length > 0) {
const mcVal = n.subConfig?.maxConcurrency;
if (mcVal !== undefined && mcVal !== null && mcVal !== 0) {
@@ -1478,7 +1508,7 @@ const confirmSaveWorkflow = async () => {
modelResponseBodyMapping: n.data?.modelConfig?.modelResponseBodyMapping ?? null,
},
// 开始节点:运行表单字段存入 outputConfig(不再单独存顶层 runFormFields 字段)
outputConfig: isStartNode(n) ? (n.data?.runFormFields ?? []) : buildOutputConfig(n, startNode?.id || ''),
outputConfig: isStartNode(n) ? (n.data?.runFormFields ?? []) : buildOutputConfig(n, startNode?.id || '', outputFieldsIndex),
...(n.data?.skillName ? { skillName: n.data.skillName } : {}),
...(n.data?.prompt ? { prompt: n.data.prompt } : {}),
...(n.data?.negativePrompt ? { negativePrompt: n.data.negativePrompt } : {}),