工作流节点库 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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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 就地读写 valueSource(set/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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
// 构建保存用的 outputConfig(HTTP/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 保存态 subConfig(runtimeShow → 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 } : {}),
|
||||
|
||||
Reference in New Issue
Block a user