针对工作流管理进行组件抽离和与内容创作进行安全隔离

This commit is contained in:
2026-08-04 14:28:57 +08:00
parent e1d369b205
commit 448620ed8a
8 changed files with 608 additions and 577 deletions
+1
View File
@@ -109,6 +109,7 @@ These rules capture long-term repository preferences confirmed by the user and s
5. **改动评分与总结**:每次修改完成后,对本次改动按 1-10 分打分,并附上总结和改进建议
6. **中文沟通**:所有与用户的沟通、说明、思考展示必须使用中文
7. **操作后清理**:每次文件操作(拆分、替换、脚本执行等)完成后,必须检查并清理产生的临时文件,不得遗留在项目目录中
8. **内容创作与工作流管理必须隔离**`src/views/settings/creation`(内容创作)是线上运行的旧版本,所有改动只服务于工作流管理(`src/views/settings/workflow`),绝对不修改内容创作的代码、API、组件或数据结构。工作流如需共享能力,应为自身新建独立版本(如独立的 `src/api/settings/workflow/`、本地组件),不得改动内容创作在用的东西。后端为工作流管理做的数据变化也属于工作流管理,不必适配内容创作。
### Pagination Rules
+80
View File
@@ -0,0 +1,80 @@
import request from '/@/utils/request';
// ===== 节点库(V2 结构,workflow 专用;creation 继续使用 creation/index.ts 的旧结构)=====
export interface NodeLibraryPresetOption {
value?: unknown;
field: string;
label: string;
type: string;
required: boolean;
options?: Array<{
key: string;
value: string;
config?: NodeLibraryPresetOption[] | null;
}> | null;
}
export interface NodeLibraryItem {
key: string;
name: string;
group: string;
sort: number;
desc?: string;
batchExecOption: boolean;
preToolOption: boolean;
postToolOption: boolean;
skillOption: boolean;
promptOption: boolean;
isSaveFileOption: boolean;
formConfigOption: boolean;
modelConfigOption: boolean;
presetOption: NodeLibraryPresetOption[] | null;
}
export interface NodeLibraryGroup {
group: { key: string; name: string };
nodes: NodeLibraryItem[];
}
export function getNodeLibraryList() {
return request({
url: '/ai-agent/node/library/list',
method: 'get',
}) as Promise<{ code: number; message: string; data: { groups: NodeLibraryGroup[] } }>;
}
// ===== 模型列表(不按类型过滤,显示全部)=====
export interface WorkflowModelItem {
id: string;
modelName: string;
modelType: number | string;
baseUrl?: string;
enabled?: number;
isOwner?: number;
[key: string]: any;
}
export function getWorkflowModelList(params?: { pageNum: number; pageSize: number; modelName?: string }) {
return request({
url: '/model-gateway/model/listModel',
method: 'get',
params,
}) as Promise<{ code: number; message: string; data: { list: WorkflowModelItem[]; total: number } }>;
}
// ===== 技能列表 =====
export interface WorkflowSkillItem {
id: number;
name: string;
description: string;
category: string;
[key: string]: any;
}
export function getWorkflowSkillList(params?: { pageNum: number; pageSize: number; keyword?: string }) {
return request({
url: '/ai-agent/skill/user/listUser',
method: 'get',
params,
}) as Promise<{ code: number; message: string; data: { list: WorkflowSkillItem[]; total: number } }>;
}
@@ -1,279 +0,0 @@
<template>
<div class="input-source-manager">
<el-divider content-position="left">上级参数引用</el-divider>
<!-- 已引用的参数列表 -->
<div v-if="currentInputSource && currentInputSource.length > 0" class="input-source-list">
<div v-for="(sourceNode, index) in currentInputSource" :key="index" class="input-source-item">
<div class="input-source-header">
<span class="input-source-node-name">{{ getNodeName(sourceNode.nodeId) }}</span>
</div>
<div v-if="sourceNode.field && sourceNode.field.length > 0" class="input-source-fields">
<div v-for="fieldName in sourceNode.field" :key="fieldName" class="field-tag">
<el-tag size="small">{{ fieldName }}</el-tag>
<el-button type="danger" link size="small" @click="emit('removeField', sourceNode.nodeId, fieldName)">删除</el-button>
</div>
</div>
<div class="input-source-output">
<el-switch
:model-value="sourceNode.quoteOutput === true"
@change="(val: boolean) => emit('toggleOutput', sourceNode.nodeId, val)"
size="small"
active-text="引入输出"
inactive-text=""
/>
</div>
</div>
</div>
<!-- 显示所有上级节点的输出引用选项 -->
<div v-if="availableParentNodes.length > 0" class="parent-nodes-output">
<div class="parent-nodes-title">上级节点输出</div>
<div v-for="parentNode in availableParentNodes" :key="parentNode.id" class="parent-node-output-item">
<span class="parent-node-name">{{ parentNode.name }}</span>
<el-switch
:model-value="isNodeOutputQuoted(parentNode.id)"
@change="(val: boolean) => emit('toggleOutput', parentNode.id, val)"
size="small"
active-text="引入输出"
inactive-text=""
/>
</div>
</div>
<!-- 选择参数下拉框 -->
<el-form-item label="选择参数">
<el-select :model-value="selectedParam" @update:model-value="handleParamSelect" placeholder="选择上级节点的参数" class="w100">
<el-option v-for="param in availableParams" :key="param.value" :label="param.label" :value="param.value" />
</el-select>
</el-form-item>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import type { Node } from '@vue-flow/core';
interface NodeData {
label?: string;
nodeCode?: string;
inputSource?: Array<{ nodeId: string; field: string[]; quoteOutput?: boolean }> | null;
formConfig?: any[];
modelConfig?: any;
skillName?: string;
}
interface ParentNode {
id: string;
name: string;
}
interface ParamOption {
label: string;
value: string;
}
const props = defineProps<{
selectedNode: Node<NodeData, any, string> | null;
nodes: Node<NodeData, any, string>[];
edges: any[];
}>();
const emit = defineEmits<{
(e: 'removeField', nodeId: string, fieldName: string): void;
(e: 'toggleOutput', nodeId: string, enabled: boolean): void;
(e: 'addParam', paramValue: string): void;
}>();
const selectedParam = ref('');
// 当前节点的 inputSource
const currentInputSource = computed(() => {
if (!props.selectedNode?.data?.inputSource) return [];
return props.selectedNode.data.inputSource.filter((item) => item.field && item.field.length > 0);
});
// 获取节点名称
const getNodeName = (nodeId: string) => {
const node = props.nodes.find((n) => n.id === nodeId);
return node?.data?.label || nodeId;
};
// 获取所有上级节点(用于显示输出引用选项)
const availableParentNodes = computed(() => {
if (!props.selectedNode?.data) return [];
// 获取已经引用了字段的节点ID列表
const inputSource = props.selectedNode.data.inputSource;
const nodesWithFields = new Set<string>();
if (Array.isArray(inputSource)) {
inputSource.forEach((item) => {
if (item.field && item.field.length > 0) {
nodesWithFields.add(item.nodeId);
}
});
}
// 递归查找所有上级节点
const findAllParentNodes = (nodeId: string, visited = new Set<string>()): string[] => {
if (visited.has(nodeId)) return [];
visited.add(nodeId);
const incomingEdges = props.edges.filter((e) => e.target === nodeId);
const parentIds: string[] = [];
incomingEdges.forEach((edge) => {
parentIds.push(edge.source);
parentIds.push(...findAllParentNodes(edge.source, visited));
});
return parentIds;
};
const allParentIds = findAllParentNodes(props.selectedNode.id);
const parentNodes = allParentIds
.map((parentId) => {
const parentNode = props.nodes.find((n) => n.id === parentId);
if (!parentNode?.data) return null;
const nodeCode = String(parentNode.data.nodeCode || '').toLowerCase();
const isJudge = ['判断', 'judge', 'condition', 'if', 'branch', 'gateway'].some((k) => nodeCode.includes(k));
const isStart = nodeCode === '__start__';
if (isJudge || isStart || nodesWithFields.has(parentId)) return null;
return {
id: parentId,
name: parentNode.data.label || parentId,
};
})
.filter(Boolean);
return parentNodes as ParentNode[];
});
// 检查节点输出是否被引用
const isNodeOutputQuoted = (nodeId: string): boolean => {
if (!props.selectedNode?.data) return false;
const inputSource = props.selectedNode.data.inputSource;
if (!Array.isArray(inputSource)) return false;
const node = inputSource.find((item) => item.nodeId === nodeId);
return node?.quoteOutput === true;
};
// 获取可用的参数选项
const availableParams = computed(() => {
if (!props.selectedNode) return [];
const params: ParamOption[] = [];
const visited = new Set<string>();
const findParents = (nodeId: string) => {
if (visited.has(nodeId)) return;
visited.add(nodeId);
props.edges
.filter((e) => e.target === nodeId)
.forEach((edge) => {
const parent = props.nodes.find((n) => n.id === edge.source);
if (parent?.data && parent.data.nodeCode !== '__start__' && parent.data.nodeCode !== 'judge') {
params.push({
label: `${parent.data.label}.output`,
value: `\${${parent.id}.output}`,
});
}
findParents(edge.source);
});
};
findParents(props.selectedNode.id);
return params;
});
const handleParamSelect = (value: string) => {
if (!value) return;
emit('addParam', value);
selectedParam.value = '';
};
</script>
<style scoped lang="scss">
.input-source-manager {
margin-top: 16px;
}
.input-source-list {
margin-bottom: 16px;
}
.input-source-item {
padding: 12px;
margin-bottom: 12px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
}
.input-source-header {
margin-bottom: 8px;
}
.input-source-node-name {
font-size: 14px;
font-weight: 600;
color: #334155;
}
.input-source-fields {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 8px;
}
.field-tag {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 8px;
background: #fff;
border-radius: 4px;
}
.input-source-output {
padding-top: 8px;
border-top: 1px solid #e2e8f0;
}
.parent-nodes-output {
margin-bottom: 16px;
}
.parent-nodes-title {
font-size: 13px;
font-weight: 600;
color: #64748b;
margin-bottom: 8px;
}
.parent-node-output-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
margin-bottom: 6px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
}
.parent-node-name {
font-size: 13px;
color: #475569;
}
.w100 {
width: 100%;
}
</style>
@@ -0,0 +1,268 @@
<template>
<el-dialog v-model="visible" title="选择模型" width="1000px" :close-on-click-modal="false" @close="handleClose">
<div class="model-selector-header">
<div class="search-bar">
<el-input v-model="searchParams.modelName" placeholder="搜索模型名称" clearable @clear="handleSearch">
<template #prefix
><el-icon> <Search /> </el-icon
></template>
</el-input>
<el-button type="primary" @click="handleSearch">搜索</el-button>
</div>
</div>
<div class="model-list" v-loading="loading">
<el-empty v-if="!loading && modelList.length === 0" description="暂无模型数据" :image-size="100" />
<div v-else class="model-grid">
<div
v-for="model in modelList"
:key="model.id"
class="model-card"
:class="{ selected: selectedModel?.id === model.id }"
@click="handleSelectModel(model)"
>
<div class="model-card-header">
<div class="model-type">{{ getModelTypeName(model.modelType) }}</div>
<el-icon v-if="selectedModel?.id === model.id" class="check-icon" color="#67c23a">
<CircleCheck />
</el-icon>
</div>
<div class="model-card-body">
<h3 class="model-name">{{ model.modelName }}</h3>
<p class="model-url">{{ model.baseUrl }}</p>
<div class="model-status">
<el-tag :type="model.enabled === 1 ? 'success' : 'info'" size="small">
{{ model.enabled === 1 ? '已启用' : '已禁用' }}
</el-tag>
</div>
</div>
</div>
</div>
</div>
<div v-if="pagination.total > 0" class="pagination-wrap">
<el-pagination
v-model:current-page="pagination.pageNum"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, prev, pager, next"
small
@current-change="handlePageChange"
/>
</div>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleConfirm" :disabled="!selectedModel">确定</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, watch } from 'vue';
import { Search, CircleCheck } from '@element-plus/icons-vue';
import { getWorkflowModelList, type WorkflowModelItem } from '/@/api/settings/workflow';
interface Props {
modelValue: boolean;
defaultModel?: WorkflowModelItem | null;
}
interface Emits {
(e: 'update:modelValue', value: boolean): void;
(e: 'confirm', model: WorkflowModelItem): void;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
defaultModel: null,
});
const emit = defineEmits<Emits>();
const visible = ref(false);
const searchParams = reactive({ modelName: '' });
const pagination = reactive({ pageNum: 1, pageSize: 10, total: 0 });
const modelList = ref<WorkflowModelItem[]>([]);
const loading = ref(false);
const selectedModel = ref<WorkflowModelItem | null>(null);
watch(
() => props.modelValue,
(val) => {
visible.value = val;
if (val) {
selectedModel.value = props.defaultModel || null;
pagination.pageNum = 1;
fetchModelList();
}
}
);
watch(visible, (val) => {
if (!val) {
emit('update:modelValue', false);
}
});
const getModelTypeName = (type: number | string) => {
const typeMap: Record<number, string> = {
100: '推理模型',
200: '图片模型',
201: '图片模型-文生图',
202: '图片模型-图生图',
203: '图片模型-图片编辑',
204: '图片模型-图片变体',
300: '音频模型',
301: '音频模型-文生音',
302: '音频模型-音生文',
303: '音频模型-音生音',
400: '向量化模型',
401: '向量化模型-文本嵌入',
402: '向量化模型-重排序',
500: '全模态模型',
501: '全模态模型-文图音',
502: '全模态模型-视觉理解',
600: '视频模型',
601: '视频模型-文生视频',
602: '视频模型-图生视频',
603: '视频模型-图文生视频',
604: '视频模型-视频生视频',
605: '视频模型-视频编辑',
};
return typeMap[Number(type)] || '未知类型';
};
const fetchModelList = async () => {
loading.value = true;
try {
const params = {
pageNum: pagination.pageNum,
pageSize: pagination.pageSize,
modelName: searchParams.modelName || undefined,
};
const res = await getWorkflowModelList(params);
modelList.value = res.data?.list || [];
pagination.total = res.data?.total || 0;
} catch {
modelList.value = [];
pagination.total = 0;
} finally {
loading.value = false;
}
};
const handleSearch = () => {
pagination.pageNum = 1;
fetchModelList();
};
const handlePageChange = () => {
fetchModelList();
};
const handleSelectModel = (model: WorkflowModelItem) => {
selectedModel.value = model;
};
const handleConfirm = () => {
if (selectedModel.value) {
emit('confirm', selectedModel.value);
handleClose();
}
};
const handleClose = () => {
visible.value = false;
selectedModel.value = null;
};
</script>
<style scoped lang="scss">
.model-selector-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
gap: 12px;
}
.search-bar {
display: flex;
gap: 12px;
flex: 1;
}
.model-list {
min-height: 300px;
max-height: 400px;
overflow-y: auto;
}
.model-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.model-card {
background: #f8fafc;
border-radius: 8px;
padding: 16px;
cursor: pointer;
transition: all 0.3s ease;
border: 2px solid transparent;
}
.model-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.model-card.selected {
border-color: #67c23a;
background: #f0f9ff;
}
.model-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.model-type {
display: inline-block;
padding: 2px 8px;
background: #eff6ff;
color: #3b82f6;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
}
.check-icon {
font-size: 20px;
}
.model-card-body {
flex: 1;
}
.model-name {
font-size: 16px;
font-weight: 600;
color: #1f2937;
margin: 0 0 8px 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.model-url {
font-size: 12px;
color: #94a3b8;
margin: 0 0 8px 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.model-status {
display: flex;
align-items: center;
}
.pagination-wrap {
display: flex;
justify-content: center;
margin-top: 20px;
}
</style>
@@ -11,7 +11,7 @@
</el-form-item>
<!-- 模型选择 -->
<el-form-item v-if="nodeConfig?.modelConfig && nodeConfig.modelConfig.length > 0" label="选择模型">
<el-form-item v-if="nodeConfig?.modelConfigOption" label="选择模型">
<el-button type="primary" @click="emit('openModelSelector')" style="width: 100%">选择模型</el-button>
<div v-if="selectedNode.data?.modelConfig?.modelName" class="selected-tag">
<el-tag type="success" size="large" closable @close="emit('removeModel')">
@@ -31,7 +31,7 @@
</el-form-item>
<!-- 贴片布局开关 -->
<el-form-item v-if="nodeConfig?.patchLayout" label="贴片布局">
<el-form-item v-if="isVideoModelSelected" label="贴片布局">
<el-switch
:model-value="selectedNode.data?.patchLayout ?? false"
@update:model-value="emit('update:patchLayout', $event)"
@@ -95,17 +95,6 @@
</el-form-item>
</template>
</el-form>
<!-- 上级参数管理 -->
<InputSourceManager
v-if="selectedNode.data?.nodeCode !== '__start__'"
:selected-node="selectedNode"
:nodes="allNodes"
:edges="allEdges"
@remove-field="(nodeId: string, fieldName: string) => emit('removeField', nodeId, fieldName)"
@toggle-output="(nodeId: string, enabled: boolean) => emit('toggleOutput', nodeId, enabled)"
@add-param="(paramValue: string) => emit('addParamByValue', paramValue)"
/>
</div>
<el-empty v-else description="请选择一个节点" :image-size="100" />
@@ -123,52 +112,45 @@
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { ref, computed } from 'vue';
import type { Node } from '@vue-flow/core';
import InputSourceManager from './InputSourceManager.vue';
import KeyValueEditor from './KeyValueEditor.vue';
import { JsonEditor } from '/@/components/json-schema-editor';
interface NodeData {
label?: string;
nodeCode?: string;
inputSource?: Array<{ nodeId: string; field: string[]; quoteOutput?: boolean }> | null;
formConfig?: any[];
modelConfig?: any;
skillName?: string;
patchLayout?: boolean;
}
interface ParamRef {
id: string;
label: string;
}
interface NodeConfig {
formConfig: any[];
modelConfig: any[];
modelConfigOption: boolean;
formConfigOption: boolean;
skillOption: boolean;
patchLayout: boolean;
promptOption: boolean;
isSaveFileOption: boolean;
}
const props = defineProps<{
selectedNode: Node<NodeData, any, string> | null;
availableParams: ParamRef[];
nodeConfig: NodeConfig | null;
allNodes: Node<NodeData, any, string>[];
allEdges: any[];
}>();
const isVideoModelSelected = computed(() => {
const t = Number(props.selectedNode?.data?.modelConfig?.modelType);
return t >= 600 && t <= 605;
});
const emit = defineEmits<{
(e: 'update:selectedNode', node: Node<NodeData, any, string>): void;
(e: 'addParam', param: ParamRef): void;
(e: 'openModelSelector'): void;
(e: 'removeModel'): void;
(e: 'openSkillSelector'): void;
(e: 'removeSkill'): void;
(e: 'removeField', nodeId: string, fieldName: string): void;
(e: 'toggleOutput', nodeId: string, enabled: boolean): void;
(e: 'addParamByValue', paramValue: string): void;
(e: 'update:patchLayout', value: boolean): void;
}>();
@@ -12,17 +12,17 @@
<div v-if="!collapsed" class="node-library-content">
<el-empty v-if="nodeLibraryGroups.length === 0" description="暂无节点" :image-size="40" />
<div v-else class="node-library-groups">
<div v-for="group in nodeLibraryGroups" :key="group.group" class="node-group">
<div class="node-group-title">{{ group.label }}</div>
<div v-for="group in nodeLibraryGroups" :key="group.group.key" class="node-group">
<div class="node-group-title">{{ group.group.name }}</div>
<div class="node-group-items">
<el-button
v-for="item in group.items"
:key="item.nodeCode"
v-for="item in group.nodes"
:key="item.key"
text
class="node-item"
@click="emit('addNode', item.nodeCode, item.nodeName)"
@click="emit('addNode', item.key, item.name)"
>
{{ item.nodeName }}
{{ item.name }}
</el-button>
</div>
</div>
@@ -33,7 +33,7 @@
<script setup lang="ts">
import { ArrowLeftBold, ArrowRightBold } from '@element-plus/icons-vue';
import type { NodeLibraryGroup } from '/@/api/settings/creation';
import type { NodeLibraryGroup } from '/@/api/settings/workflow';
defineProps<{
nodeLibraryGroups: NodeLibraryGroup[];
@@ -0,0 +1,215 @@
<template>
<el-dialog v-model="visible" title="选择技能" width="900px" :close-on-click-modal="false" @close="handleClose">
<div class="search-bar">
<el-input v-model="searchParams.keyword" placeholder="搜索技能名称或描述" clearable @clear="handleSearch">
<template #prefix
><el-icon><Search /></el-icon
></template>
</el-input>
<el-button type="primary" @click="handleSearch">搜索</el-button>
</div>
<div class="skill-list" v-loading="loading">
<el-empty v-if="!loading && skillList.length === 0" description="暂无技能数据" :image-size="100" />
<div v-else class="skill-grid">
<div
v-for="skill in skillList"
:key="skill.id"
class="skill-card"
:class="{ selected: selectedSkill?.id === skill.id }"
@click="handleSelectSkill(skill)"
>
<div class="skill-card-header">
<div class="skill-category">{{ skill.category }}</div>
<el-icon v-if="selectedSkill?.id === skill.id" class="check-icon" color="#67c23a"><CircleCheck /></el-icon>
</div>
<div class="skill-card-body">
<h3 class="skill-name">{{ skill.name }}</h3>
<p class="skill-desc">{{ skill.description || '暂无描述' }}</p>
</div>
</div>
</div>
</div>
<div v-if="pagination.total > 0" class="pagination-wrap">
<el-pagination
v-model:current-page="pagination.pageNum"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, prev, pager, next"
small
@current-change="handlePageChange"
/>
</div>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleConfirm" :disabled="!selectedSkill">确定</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, watch } from 'vue';
import { Search, CircleCheck } from '@element-plus/icons-vue';
import { getWorkflowSkillList, type WorkflowSkillItem } from '/@/api/settings/workflow';
interface Props {
modelValue: boolean;
defaultSkill?: WorkflowSkillItem | null;
}
interface Emits {
(e: 'update:modelValue', value: boolean): void;
(e: 'confirm', skill: WorkflowSkillItem): void;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
defaultSkill: null,
});
const emit = defineEmits<Emits>();
const visible = ref(false);
const searchParams = reactive({ keyword: '' });
const pagination = reactive({ pageNum: 1, pageSize: 10, total: 0 });
const skillList = ref<WorkflowSkillItem[]>([]);
const loading = ref(false);
const selectedSkill = ref<WorkflowSkillItem | null>(null);
watch(
() => props.modelValue,
(val) => {
visible.value = val;
if (val) {
selectedSkill.value = props.defaultSkill || null;
fetchSkillList();
}
}
);
watch(visible, (val) => {
if (!val) {
emit('update:modelValue', false);
}
});
const fetchSkillList = async () => {
loading.value = true;
try {
const params = { pageNum: pagination.pageNum, pageSize: pagination.pageSize, keyword: searchParams.keyword || undefined };
const res = await getWorkflowSkillList(params);
skillList.value = res.data?.list || [];
pagination.total = res.data?.total || 0;
} catch {
skillList.value = [];
pagination.total = 0;
} finally {
loading.value = false;
}
};
const handleSearch = () => {
pagination.pageNum = 1;
fetchSkillList();
};
const handlePageChange = () => {
fetchSkillList();
};
const handleSelectSkill = (skill: WorkflowSkillItem) => {
selectedSkill.value = skill;
};
const handleConfirm = () => {
if (selectedSkill.value) {
emit('confirm', selectedSkill.value);
handleClose();
}
};
const handleClose = () => {
visible.value = false;
selectedSkill.value = null;
};
</script>
<style scoped lang="scss">
.search-bar {
display: flex;
gap: 12px;
margin-bottom: 20px;
}
.skill-list {
min-height: 300px;
max-height: 400px;
overflow-y: auto;
}
.skill-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 16px;
}
.skill-card {
background: #f8fafc;
border-radius: 8px;
padding: 16px;
cursor: pointer;
transition: all 0.3s ease;
border: 2px solid transparent;
}
.skill-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.skill-card.selected {
border-color: #67c23a;
background: #f0f9ff;
}
.skill-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.skill-category {
display: inline-block;
padding: 2px 8px;
background: #eff6ff;
color: #3b82f6;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
}
.check-icon {
font-size: 20px;
}
.skill-card-body {
flex: 1;
}
.skill-name {
font-size: 16px;
font-weight: 600;
color: #1f2937;
margin: 0 0 8px 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.skill-desc {
font-size: 13px;
color: #64748b;
line-height: 1.5;
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
min-height: 40px;
}
.pagination-wrap {
display: flex;
justify-content: center;
margin-top: 20px;
}
</style>
+25 -261
View File
@@ -18,19 +18,12 @@
<!-- 左侧配置面板 -->
<NodeConfigPanel
:selected-node="selectedNode"
:available-params="availableParams"
:node-config="currentNodeConfig"
:all-nodes="nodes"
:all-edges="edges"
@update:selected-node="updateSelectedNode"
@add-param="addParam"
@open-model-selector="showModelSelector = true"
@remove-model="handleRemoveModel"
@open-skill-selector="showSkillSelector = true"
@remove-skill="handleRemoveSkill"
@remove-field="handleRemoveField"
@toggle-output="handleToggleOutput"
@add-param-by-value="handleAddParamByValue"
@update:patch-layout="handleTogglePatchLayout"
/>
@@ -81,7 +74,7 @@
/>
<!-- 模型选择器 -->
<ModelSelector v-model="showModelSelector" :default-model="selectedModelData" :model-type="currentNodeModelType" @confirm="handleModelConfirm" />
<ModelSelector v-model="showModelSelector" :default-model="selectedModelData" @confirm="handleModelConfirm" />
<!-- 技能选择器 -->
<SkillSelector v-model="showSkillSelector" :default-skill="selectedSkillData" @confirm="handleSkillConfirm" />
@@ -97,37 +90,30 @@ import { MiniMap } from '@vue-flow/minimap';
import { ElMessage, ElMessageBox } from 'element-plus';
import type { Node, Edge, Connection } from '@vue-flow/core';
import {
getNodeLibraryList,
getWorkflowList,
getWorkflowDetail,
saveWorkflow,
updateWorkflow,
deleteWorkflow,
type NodeLibraryGroup,
type WorkflowItem,
} from '/@/api/settings/creation';
import { getNodeLibraryList, type NodeLibraryGroup } from '/@/api/settings/workflow';
import NodeConfigPanel from './component/NodeConfigPanel.vue';
import NodeLibraryPanel from './component/NodeLibraryPanel.vue';
import WorkflowListPanel from './component/WorkflowListPanel.vue';
import SaveWorkflowDialog from './component/SaveWorkflowDialog.vue';
import ModelSelector from '/@/components/model/ModelSelector.vue';
import SkillSelector from '/@/components/skill/SkillSelector.vue';
import ModelSelector from './component/ModelSelector.vue';
import SkillSelector from './component/SkillSelector.vue';
interface NodeData {
label?: string;
nodeCode?: string;
inputSource?: Array<{ nodeId: string; field: string[]; quoteOutput?: boolean }> | null;
formConfig?: any[];
modelConfig?: any;
skillName?: string;
patchLayout?: boolean;
}
interface ParamRef {
id: string;
label: string;
}
const { addNodes, addEdges, findNode, removeNodes, getNodes, updateNode } = useVueFlow();
// 常量定义
@@ -144,14 +130,26 @@ const filteredNodeLibraryGroups = computed(() => {
// 节点配置映射:nodeCode -> 节点配置
const nodeConfigMap = computed(() => {
const map = new Map<string, { formConfig: any[]; modelConfig: any[]; skillOption: boolean; patchLayout: boolean }>();
const map = new Map<
string,
{
formConfig: any[];
modelConfigOption: boolean;
formConfigOption: boolean;
skillOption: boolean;
promptOption: boolean;
isSaveFileOption: boolean;
}
>();
nodeLibraryGroups.value.forEach((group) => {
group.items.forEach((item) => {
map.set(item.nodeCode, {
formConfig: item.formConfig || [],
modelConfig: item.modelConfig || [],
group.nodes.forEach((item) => {
map.set(item.key, {
formConfig: item.presetOption || [],
modelConfigOption: item.modelConfigOption || false,
formConfigOption: item.formConfigOption || false,
skillOption: item.skillOption || false,
patchLayout: item.patchLayout || false,
promptOption: item.promptOption || false,
isSaveFileOption: item.isSaveFileOption || false,
});
});
});
@@ -186,14 +184,6 @@ let nodeId = 0;
// 模型选择器相关状态
const showModelSelector = ref(false);
const selectedModelData = ref<any>(null);
const currentNodeModelType = computed(() => {
if (!selectedNode.value || !currentNodeConfig.value) return 0;
const modelConfig = currentNodeConfig.value.modelConfig;
if (modelConfig && modelConfig.length > 0) {
return modelConfig[0].modelType || 0;
}
return 0;
});
// 技能选择器相关状态
const showSkillSelector = ref(false);
@@ -266,74 +256,6 @@ const updateSelectedNode = (updatedNode: Node<NodeData, any, string>) => {
}
};
const availableParams = computed(() => {
if (!selectedNode.value) return [];
const parents: ParamRef[] = [];
const findParents = (nodeId: string, visited = new Set<string>()) => {
if (visited.has(nodeId)) return;
visited.add(nodeId);
edges.value
.filter((e) => e.target === nodeId)
.forEach((edge) => {
const parent = findNode(edge.source);
if (parent?.data && parent.data.nodeCode !== '__start__' && parent.data.nodeCode !== 'judge') {
parents.push({ id: parent.id, label: `${parent.data.label}.output` });
}
findParents(edge.source, visited);
});
};
findParents(selectedNode.value.id);
return parents;
});
const addParam = (param: ParamRef) => {
if (!selectedNode.value?.data) return;
const updatedNode: Node<NodeData> = {
...selectedNode.value,
data: {
...selectedNode.value.data,
inputSource: selectedNode.value.data.inputSource || [],
},
};
// 查找是否已存在该节点的引用
const existingIndex = updatedNode.data!.inputSource!.findIndex((item) => item.nodeId === param.id);
if (existingIndex >= 0) {
// 已存在,添加 output 到 field 数组
const existing = updatedNode.data!.inputSource![existingIndex];
if (!existing.field.includes('output')) {
existing.field.push('output');
selectedNode.value = updatedNode;
// 同步更新到 VueFlow 内部状态
updateNode(updatedNode.id, updatedNode);
const index = nodes.value.findIndex((n) => n.id === updatedNode.id);
if (index >= 0) {
nodes.value[index] = updatedNode;
}
ElMessage.success('已添加参数引用');
} else {
ElMessage.info('该参数已被引用');
}
} else {
// 不存在,创建新的引用
updatedNode.data!.inputSource!.push({
nodeId: param.id,
field: ['output'],
quoteOutput: false,
});
selectedNode.value = updatedNode;
// 同步更新到 VueFlow 内部状态
updateNode(updatedNode.id, updatedNode);
const index = nodes.value.findIndex((n) => n.id === updatedNode.id);
if (index >= 0) {
nodes.value[index] = updatedNode;
}
ElMessage.success('已添加参数引用');
}
};
// 模型选择确认
const handleModelConfirm = (model: any) => {
if (!selectedNode.value?.data) return;
@@ -344,6 +266,7 @@ const handleModelConfirm = (model: any) => {
...selectedNode.value.data,
modelConfig: {
modelName: model.modelName,
modelType: model.modelType,
modelApiKey: '',
modelForm: model.modelForm || [],
modelResponse: model.responseBody || {},
@@ -460,146 +383,6 @@ const handleTogglePatchLayout = (value: boolean) => {
}
};
// 删除上级参数字段
const handleRemoveField = (nodeId: string, fieldName: string) => {
if (!selectedNode.value?.data) return;
const inputSource = selectedNode.value.data.inputSource || [];
const nodeIndex = inputSource.findIndex((item) => item.nodeId === nodeId);
if (nodeIndex < 0) return;
const node = inputSource[nodeIndex];
const newField = node.field.filter((f) => f !== fieldName);
let updatedInputSource;
if (newField.length > 0) {
updatedInputSource = [...inputSource];
updatedInputSource[nodeIndex] = { ...node, field: newField };
} else {
updatedInputSource = inputSource.filter((_, idx) => idx !== nodeIndex);
}
const updatedNode: Node<NodeData> = {
...selectedNode.value,
data: {
...selectedNode.value.data,
inputSource: updatedInputSource.length > 0 ? updatedInputSource : null,
},
};
selectedNode.value = updatedNode;
// 同步更新到 VueFlow 内部状态
updateNode(updatedNode.id, updatedNode);
const index = nodes.value.findIndex((n) => n.id === updatedNode.id);
if (index >= 0) {
nodes.value[index] = updatedNode;
}
ElMessage.success(`已删除参数:${fieldName}`);
};
// 切换节点输出引用
const handleToggleOutput = (nodeId: string, enabled: boolean) => {
if (!selectedNode.value?.data) return;
const inputSource = selectedNode.value.data.inputSource || [];
const nodeIndex = inputSource.findIndex((item) => item.nodeId === nodeId);
let updatedInputSource;
if (nodeIndex >= 0) {
updatedInputSource = [...inputSource];
updatedInputSource[nodeIndex] = {
...updatedInputSource[nodeIndex],
quoteOutput: enabled,
};
} else {
updatedInputSource = [
...inputSource,
{
nodeId: nodeId,
field: [],
quoteOutput: enabled,
},
];
}
const updatedNode: Node<NodeData> = {
...selectedNode.value,
data: {
...selectedNode.value.data,
inputSource: updatedInputSource,
},
};
selectedNode.value = updatedNode;
// 同步更新到 VueFlow 内部状态
updateNode(updatedNode.id, updatedNode);
const index = nodes.value.findIndex((n) => n.id === updatedNode.id);
if (index >= 0) {
nodes.value[index] = updatedNode;
}
const parentNode = nodes.value.find((n) => n.id === nodeId);
const nodeName = parentNode?.data?.label || '节点';
ElMessage.success(enabled ? `已开启引入 ${nodeName} 的输出` : `已关闭引入 ${nodeName} 的输出`);
};
// 通过参数值添加上级参数
const handleAddParamByValue = (paramValue: string) => {
if (!selectedNode.value?.data) return;
const match = paramValue.match(/\$\{([^.]+)\.(.+)\}/);
if (!match) return;
const nodeId = match[1];
const paramName = match[2];
const inputSource = selectedNode.value.data.inputSource || [];
const existingIndex = inputSource.findIndex((item) => item.nodeId === nodeId);
let updatedInputSource;
if (existingIndex >= 0) {
const existing = inputSource[existingIndex];
if (!existing.field.includes(paramName)) {
updatedInputSource = [...inputSource];
updatedInputSource[existingIndex] = {
...existing,
field: [...existing.field, paramName],
};
} else {
ElMessage.info('该参数已被引用');
return;
}
} else {
updatedInputSource = [
...inputSource,
{
nodeId: nodeId,
field: [paramName],
quoteOutput: false,
},
];
}
const updatedNode: Node<NodeData> = {
...selectedNode.value,
data: {
...selectedNode.value.data,
inputSource: updatedInputSource,
},
};
selectedNode.value = updatedNode;
// 同步更新到 VueFlow 内部状态
updateNode(updatedNode.id, updatedNode);
const index = nodes.value.findIndex((n) => n.id === updatedNode.id);
if (index >= 0) {
nodes.value[index] = updatedNode;
}
ElMessage.success(`已添加上级参数:${paramName}`);
};
// 辅助函数:判断是否为开始节点
const isStartNode = (node: Node<NodeData, any, string>) => {
return node.data?.nodeCode === START_NODE_CODE;
@@ -686,7 +469,7 @@ const addNodeFromLibrary = (nodeCode: string, nodeName: string) => {
id: `node-${++nodeId}`,
type: 'default',
position: { x: spawnX, y: spawnY },
data: { label: nodeName, nodeCode, inputSource: null, patchLayout: false },
data: { label: nodeName, nodeCode, patchLayout: false },
style: { background: '#fff', border: '2px solid #3b82f6', borderRadius: '8px', padding: '10px 20px' },
},
]);
@@ -714,7 +497,7 @@ const addDefaultStartNode = () => {
id: 'start-node',
type: 'input',
position: { x: 200, y: 200 },
data: { label: '开始', nodeCode: '__start__', inputSource: null },
data: { label: '开始', nodeCode: '__start__' },
style: { background: '#10b981', color: '#fff', border: '2px solid #059669', borderRadius: '8px', padding: '10px 20px' },
};
addNodes([startNode]);
@@ -757,23 +540,6 @@ const loadWorkflowFromDsl = (dsl: any) => {
try {
const loadedNodes = (dsl.nodes || []).map((n: any) => {
// 确保 inputSource 使用新结构
let normalizedInputSource: Array<{ nodeId: string; field: string[]; quoteOutput?: boolean }> | null = null;
if (n.inputSource) {
if (Array.isArray(n.inputSource)) {
// 检查是否为新结构(对象数组)
if (n.inputSource.length > 0 && typeof n.inputSource[0] === 'object' && n.inputSource[0].nodeId) {
// 已经是新结构
normalizedInputSource = n.inputSource;
} else {
// 旧结构(字符串数组),需要转换
normalizedInputSource = [];
// 旧结构不再支持,设为空
}
}
}
return {
id: n.id,
type: 'default',
@@ -781,7 +547,6 @@ const loadWorkflowFromDsl = (dsl: any) => {
data: {
label: n.name || '',
nodeCode: n.nodeCode,
inputSource: normalizedInputSource,
formConfig: n.formConfig || null,
modelConfig: n.modelConfig || null,
skillName: n.skillName || null,
@@ -855,7 +620,6 @@ const confirmSaveWorkflow = async () => {
x: n.position?.x || 0,
y: n.position?.y || 0,
},
inputSource: n.data?.inputSource || null,
formConfig: n.data?.formConfig || null,
modelConfig: n.data?.modelConfig || null,
outputResult: null,