mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-09-23 13:13:09 +00:00
refactor(workflow): 移除工作流编排功能及相关代码
删除前后端工作流编排功能的所有相关代码,包括模型、控制器、服务、API、前端组件等 更新依赖版本并移除不再需要的依赖包 调整系统菜单配置移除工作流相关菜单项
This commit is contained in:
@@ -1,112 +0,0 @@
|
||||
import request from "@/utils/request";
|
||||
|
||||
const API_PATH = "/task/workflow";
|
||||
|
||||
const WorkflowAPI = {
|
||||
getWorkflowList(query: WorkflowPageQuery) {
|
||||
return request<ApiResponse<PageResult<WorkflowTable[]>>>({
|
||||
url: `${API_PATH}/list`,
|
||||
method: "get",
|
||||
params: query,
|
||||
});
|
||||
},
|
||||
|
||||
getWorkflowDetail(query: number) {
|
||||
return request<ApiResponse<WorkflowTable>>({
|
||||
url: `${API_PATH}/detail/${query}`,
|
||||
method: "get",
|
||||
});
|
||||
},
|
||||
|
||||
createWorkflow(body: WorkflowForm) {
|
||||
return request<ApiResponse<WorkflowTable>>({
|
||||
url: `${API_PATH}/create`,
|
||||
method: "post",
|
||||
data: body,
|
||||
});
|
||||
},
|
||||
|
||||
updateWorkflow(id: number, body: WorkflowForm) {
|
||||
return request<ApiResponse<WorkflowTable>>({
|
||||
url: `${API_PATH}/update/${id}`,
|
||||
method: "put",
|
||||
data: body,
|
||||
});
|
||||
},
|
||||
|
||||
deleteWorkflow(body: number[]) {
|
||||
return request<ApiResponse>({
|
||||
url: `${API_PATH}/delete`,
|
||||
method: "delete",
|
||||
data: body,
|
||||
});
|
||||
},
|
||||
|
||||
publishWorkflow(id: number, body: WorkflowPublishForm) {
|
||||
return request<ApiResponse<WorkflowTable>>({
|
||||
url: `${API_PATH}/publish/${id}`,
|
||||
method: "post",
|
||||
data: body,
|
||||
});
|
||||
},
|
||||
|
||||
executeWorkflow(body: WorkflowExecuteForm) {
|
||||
return request<ApiResponse<WorkflowExecuteResult>>({
|
||||
url: `${API_PATH}/execute`,
|
||||
method: "post",
|
||||
data: body,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default WorkflowAPI;
|
||||
export { WorkflowAPI };
|
||||
|
||||
export interface WorkflowPageQuery extends PageQuery {
|
||||
name?: string;
|
||||
code?: string;
|
||||
status?: string;
|
||||
created_time?: string[];
|
||||
updated_time?: string[];
|
||||
created_id?: number;
|
||||
updated_id?: number;
|
||||
}
|
||||
|
||||
export interface WorkflowTable extends BaseType {
|
||||
name?: string;
|
||||
code?: string;
|
||||
status?: string;
|
||||
description?: string;
|
||||
nodes?: any[];
|
||||
edges?: any[];
|
||||
created_by?: CommonType;
|
||||
updated_by?: CommonType;
|
||||
}
|
||||
|
||||
export interface WorkflowForm extends BaseFormType {
|
||||
name?: string;
|
||||
code?: string;
|
||||
status?: string;
|
||||
description?: string;
|
||||
nodes?: any[];
|
||||
edges?: any[];
|
||||
}
|
||||
|
||||
export interface WorkflowPublishForm {}
|
||||
|
||||
export interface WorkflowExecuteForm {
|
||||
workflow_id: number;
|
||||
variables?: Record<string, any>;
|
||||
business_key?: string;
|
||||
job_id?: number;
|
||||
}
|
||||
|
||||
export interface WorkflowExecuteResult {
|
||||
workflow_id: number;
|
||||
workflow_name: string;
|
||||
status: string;
|
||||
start_time?: string;
|
||||
end_time?: string;
|
||||
variables?: Record<string, any>;
|
||||
node_results?: Record<string, any>;
|
||||
}
|
||||
@@ -2,9 +2,3 @@
|
||||
// AI 相关
|
||||
export { useAiAction } from "./ai/useAiAction";
|
||||
export type { UseAiActionOptions, AiActionHandler } from "./ai/useAiAction";
|
||||
|
||||
// 任务相关
|
||||
export { useDebounce, useThrottle } from "./task/usePerformance";
|
||||
export { useNodeDrag } from "./task/useNodeDrag";
|
||||
export { useNodeOperations } from "./task/useNodeOperations";
|
||||
export { useWorkflowHistory } from "./task/useWorkflowHistory";
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
import { Ref, ref } from "vue";
|
||||
|
||||
interface DragItem {
|
||||
id: string;
|
||||
data: {
|
||||
label: string;
|
||||
type: string;
|
||||
args?: string;
|
||||
kwargs?: string;
|
||||
nodeId?: number;
|
||||
category?: string;
|
||||
};
|
||||
type: string;
|
||||
position: { x: number; y: number };
|
||||
class?: string;
|
||||
}
|
||||
|
||||
interface NodeItem {
|
||||
id?: number;
|
||||
type: string;
|
||||
name: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
class?: string;
|
||||
args?: string;
|
||||
kwargs?: string;
|
||||
}
|
||||
|
||||
interface Coordinate {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface ScreenToFlowCoordinate {
|
||||
(coordinate: Coordinate): Coordinate;
|
||||
}
|
||||
|
||||
interface OnNodesInitialized {
|
||||
(callback: () => void): { off: () => void };
|
||||
}
|
||||
|
||||
interface UpdateNode {
|
||||
(nodeId: string, updater: (node: any) => any): void;
|
||||
}
|
||||
|
||||
interface AddNodes {
|
||||
(nodes: any): void;
|
||||
}
|
||||
|
||||
export function useNodeDrag() {
|
||||
const dragItem: Ref<DragItem | null> = ref(null);
|
||||
|
||||
function onDragStart(event: DragEvent, item: NodeItem) {
|
||||
dragItem.value = {
|
||||
id: `node-${Date.now()}`,
|
||||
data: {
|
||||
label: item.name,
|
||||
type: item.type,
|
||||
args: item.args || "",
|
||||
kwargs: item.kwargs || "{}",
|
||||
nodeId: item.id,
|
||||
category: (item as any).category,
|
||||
},
|
||||
type: item.type,
|
||||
position: { x: 0, y: 0 },
|
||||
class: item.class || "light",
|
||||
};
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
dragItem.value = null;
|
||||
}
|
||||
|
||||
function onDragOver(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function onDrop(
|
||||
event: DragEvent,
|
||||
screenToFlowCoordinate: ScreenToFlowCoordinate,
|
||||
onNodesInitialized: OnNodesInitialized,
|
||||
updateNode: UpdateNode,
|
||||
addNodes: AddNodes
|
||||
) {
|
||||
if (!dragItem.value) return;
|
||||
|
||||
const position = screenToFlowCoordinate({
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
|
||||
const newNode = {
|
||||
...dragItem.value,
|
||||
position,
|
||||
};
|
||||
|
||||
const { off } = onNodesInitialized(() => {
|
||||
updateNode(dragItem.value?.id || "", (node: any) => ({
|
||||
position: {
|
||||
x: node.position.x - node.dimensions.width / 2,
|
||||
y: node.position.y - node.dimensions.height / 2,
|
||||
},
|
||||
}));
|
||||
|
||||
off();
|
||||
});
|
||||
|
||||
dragItem.value = null;
|
||||
addNodes(newNode);
|
||||
}
|
||||
|
||||
return {
|
||||
dragItem,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
};
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
import { Ref, ref } from "vue";
|
||||
|
||||
interface WorkflowNode {
|
||||
id: string;
|
||||
type: string;
|
||||
position: { x: number; y: number };
|
||||
data: any;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface WorkflowEdge {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
label?: string;
|
||||
type?: string;
|
||||
animated?: boolean;
|
||||
style?: any;
|
||||
data?: any;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface EdgeData {
|
||||
label: string;
|
||||
type: string;
|
||||
animated: boolean;
|
||||
color: string;
|
||||
strokeWidth: number;
|
||||
condition?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface GetNodes {
|
||||
(): WorkflowNode[];
|
||||
}
|
||||
|
||||
interface SetNodes {
|
||||
(nodes: WorkflowNode[]): void;
|
||||
}
|
||||
|
||||
interface GetEdges {
|
||||
(): WorkflowEdge[];
|
||||
}
|
||||
|
||||
interface SetEdges {
|
||||
(edges: WorkflowEdge[]): void;
|
||||
}
|
||||
|
||||
interface AddNodes {
|
||||
(nodes: WorkflowNode | WorkflowNode[]): void;
|
||||
}
|
||||
|
||||
export function useNodeOperations() {
|
||||
const clipboard: Ref<WorkflowNode | null> = ref(null);
|
||||
|
||||
function copyNode(node: WorkflowNode | null): boolean {
|
||||
if (node?.id) {
|
||||
clipboard.value = JSON.parse(JSON.stringify(node));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function pasteNode(addNodes: AddNodes): WorkflowNode | null {
|
||||
if (clipboard.value) {
|
||||
const newNode: WorkflowNode = {
|
||||
...clipboard.value,
|
||||
id: `node-${Date.now()}`,
|
||||
position: {
|
||||
x: clipboard.value.position.x + 50,
|
||||
y: clipboard.value.position.y + 50,
|
||||
},
|
||||
};
|
||||
addNodes(newNode);
|
||||
return newNode;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function deleteNode(
|
||||
nodeId: string,
|
||||
getNodes: GetNodes,
|
||||
setNodes: SetNodes,
|
||||
getEdges: GetEdges,
|
||||
setEdges: SetEdges
|
||||
): boolean {
|
||||
if (!nodeId) return false;
|
||||
|
||||
setNodes(getNodes().filter((n) => n.id !== nodeId));
|
||||
setEdges(getEdges().filter((e) => e.source !== nodeId && e.target !== nodeId));
|
||||
return true;
|
||||
}
|
||||
|
||||
function updateNodeData(
|
||||
nodeId: string,
|
||||
data: any,
|
||||
getNodes: GetNodes,
|
||||
setNodes: SetNodes
|
||||
): boolean {
|
||||
if (!nodeId) return false;
|
||||
|
||||
const allNodes = getNodes();
|
||||
const targetNode = allNodes.find((n) => n.id === nodeId);
|
||||
if (!targetNode) return false;
|
||||
|
||||
setNodes([
|
||||
...allNodes.filter((n) => n.id !== nodeId),
|
||||
{
|
||||
...targetNode,
|
||||
data: { ...targetNode.data, ...data },
|
||||
},
|
||||
]);
|
||||
return true;
|
||||
}
|
||||
|
||||
function deleteEdge(edgeId: string, getEdges: GetEdges, setEdges: SetEdges): boolean {
|
||||
if (!edgeId) return false;
|
||||
|
||||
setEdges(getEdges().filter((e) => e.id !== edgeId));
|
||||
return true;
|
||||
}
|
||||
|
||||
function updateEdgeData(
|
||||
edgeId: string,
|
||||
data: EdgeData,
|
||||
getEdges: GetEdges,
|
||||
setEdges: SetEdges
|
||||
): boolean {
|
||||
if (!edgeId) return false;
|
||||
|
||||
const allEdges = getEdges();
|
||||
const targetEdge = allEdges.find((e) => e.id === edgeId);
|
||||
if (!targetEdge) return false;
|
||||
|
||||
setEdges([
|
||||
...allEdges.filter((e) => e.id !== edgeId),
|
||||
{
|
||||
...targetEdge,
|
||||
label: data.label,
|
||||
type: data.type,
|
||||
animated: data.animated,
|
||||
style: {
|
||||
stroke: data.color,
|
||||
strokeWidth: data.strokeWidth,
|
||||
},
|
||||
data: {
|
||||
condition: data.condition,
|
||||
description: data.description,
|
||||
},
|
||||
},
|
||||
]);
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
clipboard,
|
||||
copyNode,
|
||||
pasteNode,
|
||||
deleteNode,
|
||||
updateNodeData,
|
||||
deleteEdge,
|
||||
updateEdgeData,
|
||||
};
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
export function useDebounce<T extends (...args: any[]) => any>(
|
||||
fn: T,
|
||||
delay: number = 300
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timeoutId: NodeJS.Timeout | null = null;
|
||||
|
||||
return function (this: any, ...args: Parameters<T>) {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
fn.apply(this, args);
|
||||
}, delay);
|
||||
};
|
||||
}
|
||||
|
||||
export function useThrottle<T extends (...args: any[]) => any>(
|
||||
fn: T,
|
||||
delay: number = 300
|
||||
): (...args: Parameters<T>) => void {
|
||||
let lastCall: number = 0;
|
||||
|
||||
return function (this: any, ...args: Parameters<T>) {
|
||||
const now = Date.now();
|
||||
if (now - lastCall >= delay) {
|
||||
lastCall = now;
|
||||
fn.apply(this, args);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import { Ref, ref } from "vue";
|
||||
|
||||
interface WorkflowNode {
|
||||
id: string;
|
||||
type: string;
|
||||
position: { x: number; y: number };
|
||||
data: any;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface WorkflowEdge {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
label?: string;
|
||||
type?: string;
|
||||
animated?: boolean;
|
||||
style?: any;
|
||||
data?: any;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface WorkflowState {
|
||||
nodes: WorkflowNode[];
|
||||
edges: WorkflowEdge[];
|
||||
}
|
||||
|
||||
export function useWorkflowHistory(maxHistory = 50) {
|
||||
const history: Ref<WorkflowState[]> = ref([]);
|
||||
const historyIndex: Ref<number> = ref(-1);
|
||||
|
||||
function saveToHistory(nodes: WorkflowNode[], edges: WorkflowEdge[]) {
|
||||
const state: WorkflowState = {
|
||||
nodes: JSON.parse(JSON.stringify(nodes)),
|
||||
edges: JSON.parse(JSON.stringify(edges)),
|
||||
};
|
||||
|
||||
if (historyIndex.value < history.value.length - 1) {
|
||||
history.value = history.value.slice(0, historyIndex.value + 1);
|
||||
}
|
||||
|
||||
history.value.push(state);
|
||||
|
||||
if (history.value.length > maxHistory) {
|
||||
history.value.shift();
|
||||
} else {
|
||||
historyIndex.value++;
|
||||
}
|
||||
}
|
||||
|
||||
function undo(): WorkflowState | null {
|
||||
if (historyIndex.value > 0) {
|
||||
historyIndex.value--;
|
||||
const state = history.value[historyIndex.value];
|
||||
return {
|
||||
nodes: JSON.parse(JSON.stringify(state.nodes)),
|
||||
edges: JSON.parse(JSON.stringify(state.edges)),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function redo(): WorkflowState | null {
|
||||
if (historyIndex.value < history.value.length - 1) {
|
||||
historyIndex.value++;
|
||||
const state = history.value[historyIndex.value];
|
||||
return {
|
||||
nodes: JSON.parse(JSON.stringify(state.nodes)),
|
||||
edges: JSON.parse(JSON.stringify(state.edges)),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function clear() {
|
||||
history.value = [];
|
||||
historyIndex.value = -1;
|
||||
}
|
||||
|
||||
function canUndo(): boolean {
|
||||
return historyIndex.value > 0;
|
||||
}
|
||||
|
||||
function canRedo(): boolean {
|
||||
return historyIndex.value < history.value.length - 1;
|
||||
}
|
||||
|
||||
function getCurrentState(): WorkflowState | null {
|
||||
if (historyIndex.value >= 0 && historyIndex.value < history.value.length) {
|
||||
return history.value[historyIndex.value];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
history,
|
||||
historyIndex,
|
||||
saveToHistory,
|
||||
undo,
|
||||
redo,
|
||||
clear,
|
||||
canUndo,
|
||||
canRedo,
|
||||
getCurrentState,
|
||||
};
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="dynamic-node"
|
||||
:class="nodeClass"
|
||||
@mouseenter="showHandles = true"
|
||||
@mouseleave="showHandles = false"
|
||||
>
|
||||
<div class="node-content">
|
||||
<span class="node-label">{{ data.label }}</span>
|
||||
<span v-if="data.config && Object.keys(data.config).length > 0" class="node-badge">
|
||||
{{ Object.keys(data.config).length }}
|
||||
</span>
|
||||
</div>
|
||||
<Handle
|
||||
v-if="nodeType.code !== 'input'"
|
||||
:id="'top-' + id"
|
||||
type="target"
|
||||
position="top"
|
||||
:class="{ 'handle-visible': showHandles }"
|
||||
:style="{ background: nodeType.color || '#3b82f6' }"
|
||||
/>
|
||||
<Handle
|
||||
v-if="nodeType.code !== 'input'"
|
||||
:id="'left-' + id"
|
||||
type="target"
|
||||
position="left"
|
||||
:class="{ 'handle-visible': showHandles }"
|
||||
:style="{ background: nodeType.color || '#3b82f6' }"
|
||||
/>
|
||||
<Handle
|
||||
v-if="nodeType.code !== 'output'"
|
||||
:id="'right-' + id"
|
||||
type="source"
|
||||
position="right"
|
||||
:class="{ 'handle-visible': showHandles }"
|
||||
:style="{ background: nodeType.color || '#3b82f6' }"
|
||||
/>
|
||||
<Handle
|
||||
v-if="nodeType.code !== 'output'"
|
||||
:id="'bottom-' + id"
|
||||
type="source"
|
||||
position="bottom"
|
||||
:class="{ 'handle-visible': showHandles }"
|
||||
:style="{ background: nodeType.color || '#3b82f6' }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
import { Handle } from "@vue-flow/core";
|
||||
|
||||
const props = defineProps({
|
||||
id: String,
|
||||
data: Object,
|
||||
nodeStatus: String,
|
||||
});
|
||||
|
||||
const showHandles = ref(false);
|
||||
|
||||
const nodeType = computed(() => {
|
||||
return {
|
||||
code: props.data?.type || "custom",
|
||||
name: props.data?.label || "自定义节点",
|
||||
color: getCategoryColor(props.data?.category),
|
||||
};
|
||||
});
|
||||
|
||||
function getCategoryColor(category) {
|
||||
const colorMap = {
|
||||
trigger: "#e6a23c",
|
||||
action: "#409eff",
|
||||
condition: "#67c23a",
|
||||
control: "#909399",
|
||||
};
|
||||
return colorMap[category] || "#409eff";
|
||||
}
|
||||
|
||||
const nodeClass = computed(() => {
|
||||
if (props.data?.type === "input") {
|
||||
return "start-node";
|
||||
}
|
||||
if (props.data?.type === "output") {
|
||||
return "end-node";
|
||||
}
|
||||
return "custom-node";
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.vue-flow__node-input {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
width: 80px !important;
|
||||
height: 80px !important;
|
||||
padding: 0 !important;
|
||||
color: #ffffff !important;
|
||||
cursor: pointer !important;
|
||||
background: linear-gradient(135deg, #67c23a 0%, #5daf34 100%) !important;
|
||||
border: 3px solid #5daf34 !important;
|
||||
border-radius: 50% !important;
|
||||
box-shadow:
|
||||
0 6px 12px rgba(103, 194, 58, 0.4),
|
||||
0 2px 4px rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
|
||||
.vue-flow__node-output {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
width: 80px !important;
|
||||
height: 80px !important;
|
||||
padding: 0 !important;
|
||||
color: #ffffff !important;
|
||||
cursor: pointer !important;
|
||||
background: linear-gradient(135deg, #f56c6c 0%, #e04e4e 100%) !important;
|
||||
border: 3px solid #e04e4e !important;
|
||||
border-radius: 50% !important;
|
||||
box-shadow:
|
||||
0 6px 12px rgba(245, 108, 108, 0.4),
|
||||
0 2px 4px rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
|
||||
.dynamic-node {
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
border: 2px solid #409eff;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.2);
|
||||
}
|
||||
|
||||
.vue-flow__node-input .dynamic-node,
|
||||
.vue-flow__node-output .dynamic-node {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.node-content {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.node-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.node-badge {
|
||||
padding: 0 6px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
background: #409eff;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.vue-flow__handle {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.vue-flow__handle.handle-visible,
|
||||
.vue-flow__handle.vue-flow__handle-connecting,
|
||||
.vue-flow__handle.vue-flow__handle-valid {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -1,167 +0,0 @@
|
||||
<template>
|
||||
<div class="edge-config-panel">
|
||||
<div class="panel-header">
|
||||
<span>连线配置</span>
|
||||
<ElButton type="text" class="close-btn" @click="handleClose">
|
||||
<ElIcon><Close /></ElIcon>
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
<div class="panel-content">
|
||||
<ElForm :model="formData" label-width="80px" size="small">
|
||||
<ElFormItem label="连线名称">
|
||||
<ElInput v-model="formData.label" placeholder="请输入连线名称" />
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="连线类型">
|
||||
<ElSelect v-model="formData.type" placeholder="请选择连线类型">
|
||||
<ElOption label="折线" value="smoothstep" />
|
||||
<ElOption label="曲线" value="default" />
|
||||
<ElOption label="直线" value="straight" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="连线颜色">
|
||||
<ElColorPicker v-model="formData.color" />
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="线条宽度">
|
||||
<ElInputNumber v-model="formData.strokeWidth" :min="1" :max="10" />
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="启用动画">
|
||||
<ElSwitch v-model="formData.animated" />
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="条件表达式">
|
||||
<ElInput
|
||||
v-model="formData.condition"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入条件表达式"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="描述">
|
||||
<ElInput
|
||||
v-model="formData.description"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="请输入描述信息"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
|
||||
<div class="panel-actions">
|
||||
<ElButton type="primary" size="small" @click="handleSave">保存</ElButton>
|
||||
<ElButton type="danger" size="small" @click="handleDelete">删除连线</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from "vue";
|
||||
import {
|
||||
ElButton,
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElInput,
|
||||
ElSelect,
|
||||
ElOption,
|
||||
ElInputNumber,
|
||||
ElSwitch,
|
||||
ElColorPicker,
|
||||
ElMessage,
|
||||
ElIcon,
|
||||
} from "element-plus";
|
||||
import { Close } from "@element-plus/icons-vue";
|
||||
|
||||
const props = defineProps({
|
||||
edge: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close", "save", "delete"]);
|
||||
|
||||
const formData = ref({
|
||||
label: props.edge?.label || "",
|
||||
type: props.edge?.type || "smoothstep",
|
||||
color: props.edge?.style?.stroke || "#000000",
|
||||
strokeWidth: props.edge?.style?.strokeWidth || 2,
|
||||
animated: props.edge?.animated || false,
|
||||
condition: props.edge?.data?.condition || "",
|
||||
description: props.edge?.data?.description || "",
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.edge,
|
||||
(newEdge) => {
|
||||
if (newEdge) {
|
||||
formData.value = {
|
||||
label: newEdge.label || "",
|
||||
type: newEdge.type || "smoothstep",
|
||||
color: newEdge.style?.stroke || "#000000",
|
||||
strokeWidth: newEdge.style?.strokeWidth || 2,
|
||||
animated: newEdge.animated || false,
|
||||
condition: newEdge.data?.condition || "",
|
||||
description: newEdge.data?.description || "",
|
||||
};
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
function handleClose() {
|
||||
emit("close");
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
emit("save", formData.value);
|
||||
ElMessage.success("保存成功");
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
emit("delete");
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.edge-config-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.panel-actions .el-button {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -1,217 +0,0 @@
|
||||
<template>
|
||||
<div class="node-config-panel">
|
||||
<div class="panel-header">
|
||||
<span>节点配置</span>
|
||||
<ElButton type="text" class="close-btn" @click="handleClose">
|
||||
<ElIcon><Close /></ElIcon>
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
<div class="panel-content">
|
||||
<ElForm :model="formData" label-width="80px" size="small">
|
||||
<ElFormItem label="节点类型">
|
||||
<ElSelect v-model="formData.type" placeholder="请选择节点类型" @change="handleTypeChange">
|
||||
<ElOption
|
||||
v-for="type in nodeTypes"
|
||||
:key="type.id"
|
||||
:label="type.name"
|
||||
:value="type.code"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="节点名称">
|
||||
<ElInput v-model="formData.label" placeholder="请输入节点名称" />
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="位置参数">
|
||||
<ElInput v-model="formData.args" placeholder="多个参数用逗号分隔,如: arg1, arg2, arg3" />
|
||||
<div class="field-hint">多个参数用逗号分隔</div>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="关键字参数">
|
||||
<ElInput
|
||||
v-model="formData.kwargsStr"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder='JSON格式,如: {"key": "value", "count": 10}'
|
||||
/>
|
||||
<div class="field-hint">JSON 格式的关键字参数</div>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="描述">
|
||||
<ElInput
|
||||
v-model="formData.description"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="请输入描述信息"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
|
||||
<div class="panel-actions">
|
||||
<ElButton type="primary" size="small" @click="handleSave">保存</ElButton>
|
||||
<ElButton type="danger" size="small" @click="handleDelete">删除节点</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from "vue";
|
||||
import {
|
||||
ElButton,
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElInput,
|
||||
ElSelect,
|
||||
ElOption,
|
||||
ElMessage,
|
||||
ElIcon,
|
||||
} from "element-plus";
|
||||
import { Close } from "@element-plus/icons-vue";
|
||||
import NodeAPI, { type NodeType } from "@/api/module_task/node";
|
||||
|
||||
const props = defineProps({
|
||||
node: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close", "save", "delete"]);
|
||||
|
||||
const nodeTypes = ref<NodeType[]>([]);
|
||||
|
||||
const formData = ref({
|
||||
type: props.node?.type || "",
|
||||
label: props.node?.data?.label || "",
|
||||
args: props.node?.data?.args || "",
|
||||
kwargsStr: props.node?.data?.kwargsStr || "{}",
|
||||
description: props.node?.data?.description || "",
|
||||
});
|
||||
|
||||
const loadNodeTypes = async () => {
|
||||
try {
|
||||
const res = await NodeAPI.getNodeTypeOptions();
|
||||
if (res.data) {
|
||||
nodeTypes.value = res.data.data || [];
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error("加载节点类型失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleTypeChange = async (typeCode: string) => {
|
||||
const nodeType = nodeTypes.value.find((t) => t.code === typeCode);
|
||||
if (nodeType) {
|
||||
formData.value.args = nodeType.args || "";
|
||||
formData.value.kwargsStr = nodeType.kwargs || "{}";
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.node,
|
||||
(newNode) => {
|
||||
if (newNode) {
|
||||
const kwargsData = newNode.data?.kwargs;
|
||||
let kwargsStr = "{}";
|
||||
if (kwargsData) {
|
||||
if (typeof kwargsData === "string") {
|
||||
kwargsStr = kwargsData;
|
||||
} else if (typeof kwargsData === "object") {
|
||||
kwargsStr = JSON.stringify(kwargsData, null, 2);
|
||||
}
|
||||
}
|
||||
|
||||
formData.value = {
|
||||
type: newNode.type || "",
|
||||
label: newNode.data?.label || "",
|
||||
args: newNode.data?.args || "",
|
||||
kwargsStr,
|
||||
description: newNode.data?.description || "",
|
||||
};
|
||||
}
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
);
|
||||
|
||||
function handleClose() {
|
||||
emit("close");
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
try {
|
||||
if (formData.value.kwargsStr && formData.value.kwargsStr.trim()) {
|
||||
JSON.parse(formData.value.kwargsStr);
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error("关键字参数 JSON 格式错误");
|
||||
return;
|
||||
}
|
||||
|
||||
emit("save", {
|
||||
type: formData.value.type,
|
||||
label: formData.value.label,
|
||||
args: formData.value.args,
|
||||
kwargs: formData.value.kwargsStr,
|
||||
description: formData.value.description,
|
||||
});
|
||||
ElMessage.success("保存成功");
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
emit("delete");
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadNodeTypes();
|
||||
if (props.node?.type) {
|
||||
handleTypeChange(props.node.type);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.node-config-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.panel-actions .el-button {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -1,854 +0,0 @@
|
||||
<template>
|
||||
<el-drawer
|
||||
v-model="dialogVisible"
|
||||
:title="drawerTitle"
|
||||
:close-on-click-modal="true"
|
||||
size="80%"
|
||||
class="workflow-drawer"
|
||||
@close="handleClose"
|
||||
>
|
||||
<el-container class="workflow-create-content">
|
||||
<el-splitter direction="horizontal" style="height: 100%">
|
||||
<el-splitter-panel size="250px" :min="200" :max="400">
|
||||
<el-scrollbar style="height: 100%">
|
||||
<div class="panel-section">
|
||||
<div class="section-title">基础信息</div>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
label-width="50px"
|
||||
:rules="formRules"
|
||||
size="small"
|
||||
>
|
||||
<el-form-item label="编码" prop="code">
|
||||
<el-input v-model="formData.code" placeholder="请输入流程编码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="名称" prop="name">
|
||||
<el-input v-model="formData.name" placeholder="请输入流程名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述" prop="description">
|
||||
<el-input
|
||||
v-model="formData.description"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="请输入流程描述"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<el-divider style="margin: 4px 0" />
|
||||
|
||||
<div class="panel-section">
|
||||
<div class="section-title">节点</div>
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索节点名称"
|
||||
clearable
|
||||
size="small"
|
||||
class="search-box"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-space direction="vertical" :size="8" fill style="width: 100%; margin-top: 8px">
|
||||
<el-tag
|
||||
v-for="item in filteredNodes"
|
||||
:key="item.id"
|
||||
:type="getCategoryType(item.category) as any"
|
||||
effect="plain"
|
||||
draggable="true"
|
||||
style="justify-content: center; cursor: move; user-select: none"
|
||||
@dragstart="onDragStart($event, item)"
|
||||
@dragend="onDragEnd"
|
||||
>
|
||||
{{ item.name }}
|
||||
<span style="margin-left: 4px; font-size: 10px; opacity: 0.7">
|
||||
[{{ getCategoryText(item.category) }}]
|
||||
</span>
|
||||
</el-tag>
|
||||
</el-space>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
</el-splitter-panel>
|
||||
|
||||
<el-splitter-panel>
|
||||
<div class="canvas-main">
|
||||
<div class="canvas-container" @click="handleCanvasClick">
|
||||
<VueFlow
|
||||
v-model:nodes="nodes"
|
||||
v-model:edges="edges"
|
||||
class="basic-flow"
|
||||
:default-viewport="{ zoom: 1.5 }"
|
||||
:min-zoom="0.2"
|
||||
:max-zoom="4"
|
||||
:node-types="nodeTypesRegistry"
|
||||
:default-edge-options="defaultEdgeOptions"
|
||||
@node-click="onNodeClick"
|
||||
@edge-click="onEdgeClick"
|
||||
@drop="onDrop"
|
||||
@dragover="onDragOver"
|
||||
>
|
||||
<Controls />
|
||||
<Background pattern-color="#aaa" :gap="16" />
|
||||
<Panel position="top-right" class="workflow-toolbar">
|
||||
<el-button
|
||||
class="vue-flow__controls-button"
|
||||
title="格式化画布"
|
||||
:icon="Grid"
|
||||
@click="handleFormatCanvas"
|
||||
/>
|
||||
<el-dropdown trigger="click" @command="handleEdgeStyleChange">
|
||||
<el-button class="vue-flow__controls-button" title="连线样式" :icon="Share" />
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item
|
||||
command="bezier"
|
||||
:class="{ active: edgeStyle === 'bezier' }"
|
||||
>
|
||||
平滑曲线
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item
|
||||
command="smoothstep"
|
||||
:class="{ active: edgeStyle === 'smoothstep' }"
|
||||
>
|
||||
阶梯折线
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item
|
||||
command="straight"
|
||||
:class="{ active: edgeStyle === 'straight' }"
|
||||
>
|
||||
直线
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
<el-button
|
||||
class="vue-flow__controls-button"
|
||||
:title="edgeAnimated ? '关闭动画' : '开启动画'"
|
||||
:icon="VideoPlay"
|
||||
@click="handleEdgeAnimatedChange(!edgeAnimated)"
|
||||
/>
|
||||
<el-dropdown trigger="click">
|
||||
<el-button class="vue-flow__controls-button" title="布局方向">
|
||||
<el-icon><Rank /></el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item
|
||||
@click="
|
||||
layoutDirection = 'LR';
|
||||
handleLayout();
|
||||
"
|
||||
>
|
||||
横向布局
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item
|
||||
@click="
|
||||
layoutDirection = 'TB';
|
||||
handleLayout();
|
||||
"
|
||||
>
|
||||
纵向布局
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</Panel>
|
||||
<MiniMap pannable zoomable />
|
||||
</VueFlow>
|
||||
</div>
|
||||
</div>
|
||||
</el-splitter-panel>
|
||||
|
||||
<el-splitter-panel v-if="updateState" size="320px" :min="280" :max="400">
|
||||
<NodeConfigPanel
|
||||
v-if="updateState === 'node'"
|
||||
:node="selectedNode"
|
||||
@close="handleClosePanel"
|
||||
@save="handleSaveNode"
|
||||
@delete="handleDeleteNode"
|
||||
/>
|
||||
<EdgeConfigPanel
|
||||
v-if="updateState === 'edge'"
|
||||
:edge="selectedEdge"
|
||||
@close="handleClosePanel"
|
||||
@save="handleSaveEdge"
|
||||
@delete="handleDeleteEdge"
|
||||
/>
|
||||
</el-splitter-panel>
|
||||
</el-splitter>
|
||||
</el-container>
|
||||
|
||||
<template #footer>
|
||||
<div class="drawer-footer">
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" @click="handleFinish">保存</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch, computed, onMounted, markRaw, type Component } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { Panel, VueFlow, useVueFlow } from "@vue-flow/core";
|
||||
import { Background } from "@vue-flow/background";
|
||||
import { MiniMap } from "@vue-flow/minimap";
|
||||
import { Controls } from "@vue-flow/controls";
|
||||
import { Search, Share, VideoPlay, Rank, Grid } from "@element-plus/icons-vue";
|
||||
import type { Node, Edge, DefaultEdgeOptions, MarkerType } from "@vue-flow/core";
|
||||
import dagre from "dagre";
|
||||
import "@vue-flow/core/dist/style.css";
|
||||
import "@vue-flow/core/dist/theme-default.css";
|
||||
import "@vue-flow/controls/dist/style.css";
|
||||
import "@vue-flow/minimap/dist/style.css";
|
||||
import "element-plus/dist/index.css";
|
||||
|
||||
import DynamicNode from "./DynamicNode.vue";
|
||||
import NodeConfigPanel from "./NodeConfigPanel.vue";
|
||||
import EdgeConfigPanel from "./EdgeConfigPanel.vue";
|
||||
import NodeAPI from "@/api/module_task/node";
|
||||
import WorkflowAPI, { type WorkflowTable, type WorkflowForm } from "@/api/module_task/workflow";
|
||||
import { useWorkflowHistory } from "@/composables/task/useWorkflowHistory";
|
||||
import { useNodeDrag } from "@/composables/task/useNodeDrag";
|
||||
import { useNodeOperations } from "@/composables/task/useNodeOperations";
|
||||
|
||||
defineOptions({
|
||||
name: "WorkflowCreateDrawer",
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
workflow: {
|
||||
type: Object as () => WorkflowTable | undefined,
|
||||
default: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:visible", "refresh"]);
|
||||
|
||||
const formRef = ref();
|
||||
const workflowId = ref<number>();
|
||||
|
||||
const formData = reactive<Partial<WorkflowForm>>({
|
||||
code: "",
|
||||
name: "",
|
||||
description: "",
|
||||
});
|
||||
|
||||
const formRules = {
|
||||
code: [{ required: true, message: "请输入流程编码", trigger: "blur" }],
|
||||
name: [{ required: true, message: "请输入流程名称", trigger: "blur" }],
|
||||
};
|
||||
|
||||
const dialogVisible = computed({
|
||||
get: () => props.visible,
|
||||
set: (val) => emit("update:visible", val),
|
||||
});
|
||||
|
||||
const drawerTitle = computed(() => {
|
||||
return props.workflow ? "编辑工作流" : "创建工作流";
|
||||
});
|
||||
|
||||
const {
|
||||
onInit,
|
||||
onConnect,
|
||||
addEdges,
|
||||
getNodes: getNodesRef,
|
||||
getEdges: getEdgesRef,
|
||||
setEdges,
|
||||
setNodes,
|
||||
screenToFlowCoordinate,
|
||||
onNodesInitialized,
|
||||
updateNode,
|
||||
addNodes,
|
||||
} = useVueFlow();
|
||||
|
||||
const defaultEdgeOptions: DefaultEdgeOptions = {
|
||||
type: "smoothstep",
|
||||
animated: true,
|
||||
markerEnd: "arrowclosed" as MarkerType,
|
||||
};
|
||||
|
||||
const edgeStyle = ref<string>("smoothstep");
|
||||
const edgeAnimated = ref<boolean>(true);
|
||||
|
||||
const handleEdgeStyleChange = (value: string) => {
|
||||
edgeStyle.value = value;
|
||||
defaultEdgeOptions.type = value;
|
||||
setEdges(
|
||||
getEdgesRef.value.map((edge) => ({
|
||||
...edge,
|
||||
type: value,
|
||||
}))
|
||||
);
|
||||
};
|
||||
|
||||
const handleEdgeAnimatedChange = (value: boolean) => {
|
||||
edgeAnimated.value = value;
|
||||
defaultEdgeOptions.animated = value;
|
||||
setEdges(
|
||||
getEdgesRef.value.map((edge) => ({
|
||||
...edge,
|
||||
animated: value,
|
||||
}))
|
||||
);
|
||||
};
|
||||
|
||||
const layoutDirection = ref<"LR" | "TB">("LR");
|
||||
|
||||
const handleLayout = () => {
|
||||
const currentNodes = getNodesRef.value;
|
||||
const currentEdges = getEdgesRef.value;
|
||||
|
||||
if (currentNodes.length === 0) {
|
||||
ElMessage.warning("画布中没有节点,无法布局");
|
||||
return;
|
||||
}
|
||||
|
||||
const dagreGraph = new dagre.graphlib.Graph();
|
||||
dagreGraph.setDefaultEdgeLabel(() => ({}));
|
||||
|
||||
const nodeWidth = 180;
|
||||
const nodeHeight = 60;
|
||||
|
||||
dagreGraph.setGraph({
|
||||
rankdir: layoutDirection.value,
|
||||
nodesep: 80,
|
||||
ranksep: 120,
|
||||
marginx: 50,
|
||||
marginy: 50,
|
||||
});
|
||||
|
||||
currentNodes.forEach((node) => {
|
||||
dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight });
|
||||
});
|
||||
|
||||
currentEdges.forEach((edge) => {
|
||||
dagreGraph.setEdge(edge.source, edge.target);
|
||||
});
|
||||
|
||||
dagre.layout(dagreGraph);
|
||||
|
||||
const layoutedNodes = currentNodes.map((node) => {
|
||||
const nodeWithPosition = dagreGraph.node(node.id);
|
||||
return {
|
||||
...node,
|
||||
position: {
|
||||
x: nodeWithPosition.x - nodeWidth / 2,
|
||||
y: nodeWithPosition.y - nodeHeight / 2,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
setNodes(layoutedNodes);
|
||||
setEdges(
|
||||
currentEdges.map((edge) => ({
|
||||
...edge,
|
||||
type: edgeStyle.value,
|
||||
animated: edgeAnimated.value,
|
||||
}))
|
||||
);
|
||||
ElMessage.success("画布布局完成");
|
||||
};
|
||||
|
||||
const handleFormatCanvas = () => {
|
||||
const currentNodes = getNodesRef.value;
|
||||
const currentEdges = getEdgesRef.value;
|
||||
|
||||
if (currentNodes.length === 0) {
|
||||
ElMessage.warning("画布中没有节点,无法格式化");
|
||||
return;
|
||||
}
|
||||
|
||||
const dagreGraph = new dagre.graphlib.Graph();
|
||||
dagreGraph.setDefaultEdgeLabel(() => ({}));
|
||||
|
||||
const nodeWidth = 180;
|
||||
const nodeHeight = 60;
|
||||
|
||||
dagreGraph.setGraph({
|
||||
rankdir: layoutDirection.value,
|
||||
nodesep: 100,
|
||||
ranksep: 150,
|
||||
marginx: 80,
|
||||
marginy: 80,
|
||||
});
|
||||
|
||||
currentNodes.forEach((node) => {
|
||||
dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight });
|
||||
});
|
||||
|
||||
currentEdges.forEach((edge) => {
|
||||
dagreGraph.setEdge(edge.source, edge.target);
|
||||
});
|
||||
|
||||
dagre.layout(dagreGraph);
|
||||
|
||||
const layoutedNodes = currentNodes.map((node) => {
|
||||
const nodeWithPosition = dagreGraph.node(node.id);
|
||||
return {
|
||||
...node,
|
||||
position: {
|
||||
x: nodeWithPosition.x - nodeWidth / 2,
|
||||
y: nodeWithPosition.y - nodeHeight / 2,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
setNodes(layoutedNodes);
|
||||
setEdges(
|
||||
currentEdges.map((edge) => ({
|
||||
...edge,
|
||||
type: edgeStyle.value,
|
||||
animated: edgeAnimated.value,
|
||||
}))
|
||||
);
|
||||
ElMessage.success("画布格式化完成");
|
||||
};
|
||||
|
||||
const nodes = ref<Node[]>([]);
|
||||
const edges = ref<Edge[]>([]);
|
||||
|
||||
const searchKeyword = ref("");
|
||||
|
||||
type LoadedNodeType = {
|
||||
id: number;
|
||||
type: string;
|
||||
name: string;
|
||||
category: string;
|
||||
args?: string;
|
||||
kwargs?: string;
|
||||
};
|
||||
|
||||
const allNodes = ref<LoadedNodeType[]>([]);
|
||||
|
||||
const filteredNodes = computed(() => {
|
||||
if (!searchKeyword.value) {
|
||||
return allNodes.value;
|
||||
}
|
||||
const keyword = searchKeyword.value.toLowerCase();
|
||||
return allNodes.value.filter((node) => node.name.toLowerCase().includes(keyword));
|
||||
});
|
||||
|
||||
const getCategoryType = (category: string) => {
|
||||
const typeMap: Record<string, string> = {
|
||||
trigger: "warning",
|
||||
action: "primary",
|
||||
condition: "success",
|
||||
control: "info",
|
||||
};
|
||||
return typeMap[category] || "info";
|
||||
};
|
||||
|
||||
const getCategoryText = (category: string) => {
|
||||
const textMap: Record<string, string> = {
|
||||
trigger: "触发器",
|
||||
action: "动作",
|
||||
condition: "条件",
|
||||
control: "控制",
|
||||
};
|
||||
return textMap[category] || category;
|
||||
};
|
||||
|
||||
const nodeTypesRegistry = ref<Record<string, Component>>({});
|
||||
|
||||
const updateState = ref("");
|
||||
const selectedEdge = ref<Edge>();
|
||||
const selectedNode = ref<Node>();
|
||||
const loading = ref(false);
|
||||
|
||||
const { saveToHistory } = useWorkflowHistory(50);
|
||||
const { onDragStart, onDragEnd, onDragOver, onDrop: handleNodeDrop } = useNodeDrag();
|
||||
const { deleteNode, updateNodeData, deleteEdge, updateEdgeData } = useNodeOperations();
|
||||
|
||||
const getNodes = () => getNodesRef.value;
|
||||
const getEdges = () =>
|
||||
getEdgesRef.value.map((edge) => ({
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
label: typeof edge.label === "string" ? edge.label : undefined,
|
||||
type: edge.type,
|
||||
animated: edge.animated,
|
||||
style: edge.style,
|
||||
data: edge.data,
|
||||
}));
|
||||
|
||||
const loadNodeTypes = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await NodeAPI.getNodeTypeOptions();
|
||||
if (res.data && res.data.data) {
|
||||
allNodes.value = res.data.data.map((nodeType: any) => ({
|
||||
id: nodeType.id,
|
||||
type: nodeType.code,
|
||||
name: nodeType.name,
|
||||
category: nodeType.category || "action",
|
||||
args: nodeType.args || "",
|
||||
kwargs: nodeType.kwargs || "{}",
|
||||
}));
|
||||
|
||||
const newTypes: Record<string, Component> = {};
|
||||
res.data.data.forEach((nodeType: any) => {
|
||||
newTypes[nodeType.code] = markRaw(DynamicNode);
|
||||
});
|
||||
|
||||
nodeTypesRegistry.value = newTypes;
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error("加载节点类型失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadNodeTypes();
|
||||
});
|
||||
|
||||
onInit((vueFlowInstance) => {
|
||||
vueFlowInstance.fitView();
|
||||
if (workflowId.value) {
|
||||
WorkflowAPI.getWorkflowDetail(workflowId.value)
|
||||
.then((res) => {
|
||||
if (res.data && res.data.data) {
|
||||
nodes.value = res.data.data.nodes || [];
|
||||
edges.value = res.data.data.edges || [];
|
||||
saveToHistory(nodes.value as any, edges.value as any);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
ElMessage.error("流程加载失败");
|
||||
});
|
||||
} else {
|
||||
saveToHistory(nodes.value as any, edges.value as any);
|
||||
}
|
||||
});
|
||||
|
||||
onConnect((connection) => {
|
||||
addEdges({
|
||||
...connection,
|
||||
type: edgeStyle.value,
|
||||
animated: edgeAnimated.value,
|
||||
});
|
||||
saveToHistory(nodes.value as any, edges.value as any);
|
||||
});
|
||||
|
||||
function handleValidate() {
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
|
||||
const allNodesList = getNodes();
|
||||
const allEdgesList = getEdges();
|
||||
|
||||
if (allNodesList.length === 0) {
|
||||
errors.push("流程中没有节点");
|
||||
}
|
||||
|
||||
const nodeIds = new Set(allNodesList.map((n: Node) => n.id));
|
||||
allEdgesList.forEach((edge: Edge) => {
|
||||
if (!nodeIds.has(edge.source)) {
|
||||
errors.push(`连线 ${edge.label || edge.id} 的源节点不存在`);
|
||||
}
|
||||
if (!nodeIds.has(edge.target)) {
|
||||
errors.push(`连线 ${edge.label || edge.id} 的目标节点不存在`);
|
||||
}
|
||||
});
|
||||
|
||||
const orphanNodes = allNodesList.filter(
|
||||
(node: Node) => !allEdgesList.some((e: Edge) => e.source === node.id || e.target === node.id)
|
||||
);
|
||||
|
||||
if (orphanNodes.length > 0) {
|
||||
warnings.push(
|
||||
`有 ${orphanNodes.length} 个孤立节点: ${orphanNodes.map((n: Node) => n.data.label).join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
ElMessageBox.alert(
|
||||
`<div style="max-height: 300px; overflow-y: auto;">
|
||||
<strong>错误 (${errors.length}):</strong>
|
||||
<ul>${errors.map((e) => `<li style="color: #f56c6c;">${e}</li>`).join("")}</ul>
|
||||
${
|
||||
warnings.length > 0
|
||||
? `<strong>警告 (${warnings.length}):</strong>
|
||||
<ul>${warnings.map((w) => `<li style="color: #e6a23c;">${w}</li>`).join("")}</ul>`
|
||||
: ""
|
||||
}
|
||||
</div>`,
|
||||
"流程验证结果",
|
||||
{
|
||||
confirmButtonText: "确定",
|
||||
dangerouslyUseHTMLString: true,
|
||||
}
|
||||
);
|
||||
throw new Error("验证失败");
|
||||
} else if (warnings.length > 0) {
|
||||
ElMessageBox.alert(
|
||||
`<div style="max-height: 300px; overflow-y: auto;">
|
||||
<strong>流程验证通过,但有警告 (${warnings.length}):</strong>
|
||||
<ul>${warnings.map((w) => `<li style="color: #e6a23c;">${w}</li>`).join("")}</ul>
|
||||
</div>`,
|
||||
"流程验证结果",
|
||||
{
|
||||
confirmButtonText: "确定",
|
||||
dangerouslyUseHTMLString: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const onEdgeClick = (event: any) => {
|
||||
event.event.stopPropagation();
|
||||
selectedEdge.value = event.edge;
|
||||
updateState.value = "edge";
|
||||
};
|
||||
|
||||
const handleCanvasClick = (event: MouseEvent) => {
|
||||
if (
|
||||
event.target instanceof HTMLElement &&
|
||||
(event.target.classList.contains("vue-flow__node") || event.target.closest(".vue-flow__node"))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
updateState.value = "";
|
||||
selectedNode.value = undefined;
|
||||
selectedEdge.value = undefined;
|
||||
};
|
||||
|
||||
const onNodeClick = (event: any) => {
|
||||
event.event.stopPropagation();
|
||||
selectedNode.value = event.node;
|
||||
updateState.value = "node";
|
||||
};
|
||||
|
||||
function onDrop(event: DragEvent) {
|
||||
handleNodeDrop(event, screenToFlowCoordinate, onNodesInitialized, updateNode, addNodes);
|
||||
}
|
||||
|
||||
function handleClosePanel() {
|
||||
updateState.value = "";
|
||||
selectedNode.value = undefined;
|
||||
selectedEdge.value = undefined;
|
||||
}
|
||||
|
||||
function handleSaveNode(data: any) {
|
||||
if (!selectedNode.value) return;
|
||||
const nodeId = selectedNode.value!.id;
|
||||
if (nodeId && updateNodeData(nodeId, data, getNodes, setNodes)) {
|
||||
saveToHistory(nodes.value as any, edges.value as any);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeleteNode() {
|
||||
if (!selectedNode.value) return;
|
||||
const nodeId = selectedNode.value!.id;
|
||||
if (!nodeId) return;
|
||||
|
||||
ElMessageBox.confirm("确定要删除该节点吗?", "提示", {
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
}).then(() => {
|
||||
deleteNode(nodeId, getNodes, setNodes, getEdges, setEdges);
|
||||
ElMessage.success("节点删除成功");
|
||||
handleClosePanel();
|
||||
saveToHistory(nodes.value as any, edges.value as any);
|
||||
});
|
||||
}
|
||||
|
||||
function handleSaveEdge(data: any) {
|
||||
if (!selectedEdge.value) return;
|
||||
const edgeId = selectedEdge.value!.id;
|
||||
if (edgeId && updateEdgeData(edgeId, data, getEdges, setEdges)) {
|
||||
saveToHistory(nodes.value as any, edges.value as any);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeleteEdge() {
|
||||
if (!selectedEdge.value) return;
|
||||
const edgeId = selectedEdge.value!.id;
|
||||
if (!edgeId) return;
|
||||
|
||||
ElMessageBox.confirm("确定要删除该连线吗?", "提示", {
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
}).then(() => {
|
||||
deleteEdge(edgeId, getEdges, setEdges);
|
||||
ElMessage.success("连线删除成功");
|
||||
handleClosePanel();
|
||||
saveToHistory(nodes.value as any, edges.value as any);
|
||||
});
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
const workflowData = {
|
||||
nodes: nodes.value,
|
||||
edges: edges.value,
|
||||
};
|
||||
|
||||
const saveData = {
|
||||
...formData,
|
||||
nodes: workflowData.nodes,
|
||||
edges: workflowData.edges,
|
||||
};
|
||||
|
||||
if (workflowId.value) {
|
||||
return WorkflowAPI.updateWorkflow(workflowId.value, saveData as WorkflowForm);
|
||||
} else {
|
||||
return WorkflowAPI.createWorkflow(saveData as WorkflowForm).then((res) => {
|
||||
if (res.data && res.data.data) {
|
||||
workflowId.value = res.data.data.id;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.workflow,
|
||||
(newWorkflow) => {
|
||||
if (newWorkflow) {
|
||||
Object.assign(formData, {
|
||||
code: newWorkflow.code,
|
||||
name: newWorkflow.name,
|
||||
description: newWorkflow.description,
|
||||
});
|
||||
workflowId.value = newWorkflow.id;
|
||||
nodes.value = newWorkflow.nodes || [];
|
||||
edges.value = newWorkflow.edges || [];
|
||||
} else {
|
||||
Object.assign(formData, {
|
||||
code: "",
|
||||
name: "",
|
||||
description: "",
|
||||
});
|
||||
workflowId.value = undefined;
|
||||
nodes.value = [];
|
||||
edges.value = [];
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const handleFinish = async () => {
|
||||
if (!formRef.value) return;
|
||||
|
||||
try {
|
||||
await formRef.value.validate();
|
||||
await handleValidate();
|
||||
await handleSave();
|
||||
emit("refresh");
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
console.error("保存流程失败", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
emit("update:visible", false);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.workflow-drawer {
|
||||
:deep(.el-drawer__body) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.workflow-create-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
:deep(.el-splitter) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
:deep(.el-splitter-panel) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.basic-info-section {
|
||||
padding: 12px;
|
||||
|
||||
.section-title {
|
||||
margin-bottom: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-section {
|
||||
padding: 12px;
|
||||
|
||||
.section-title {
|
||||
margin-bottom: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.search-box {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.canvas-main {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.canvas-container {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.vue-flow__controls) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
:deep(.vue-flow__controls-button) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
color: #000;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
:deep(.el-dropdown) {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.workflow-toolbar {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.drawer-footer {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -1,376 +0,0 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<!-- 搜索区域 -->
|
||||
<div v-show="visible" class="search-container">
|
||||
<el-form
|
||||
ref="queryFormRef"
|
||||
:model="searchForm"
|
||||
label-suffix=":"
|
||||
:inline="true"
|
||||
@submit.prevent="handleQuery"
|
||||
>
|
||||
<el-form-item prop="name" label="流程名称">
|
||||
<el-input v-model="searchForm.name" placeholder="请输入流程名称" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item prop="code" label="流程编码">
|
||||
<el-input v-model="searchForm.code" placeholder="请输入流程编码" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button
|
||||
v-hasPerm="['module_task:workflow:query']"
|
||||
type="primary"
|
||||
icon="search"
|
||||
@click="handleQuery"
|
||||
>
|
||||
查询
|
||||
</el-button>
|
||||
<el-button
|
||||
v-hasPerm="['module_task:workflow:query']"
|
||||
icon="refresh"
|
||||
@click="handleResetQuery"
|
||||
>
|
||||
重置
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<el-card class="data-table">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-space>
|
||||
工作流管理
|
||||
<el-tooltip content="工作流管理列表">
|
||||
<QuestionFilled class="w-4 h-4 mx-1" />
|
||||
</el-tooltip>
|
||||
</el-space>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 功能区域 -->
|
||||
<div class="data-table__toolbar">
|
||||
<div class="data-table__toolbar--left">
|
||||
<el-row :gutter="10">
|
||||
<el-col :span="1.5">
|
||||
<el-button
|
||||
v-hasPerm="['module_task:workflow:create']"
|
||||
type="success"
|
||||
icon="plus"
|
||||
@click="handleCreate"
|
||||
>
|
||||
新增
|
||||
</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
<div class="data-table__toolbar--right">
|
||||
<el-row :gutter="10">
|
||||
<el-col :span="1.5">
|
||||
<el-tooltip content="搜索显示/隐藏">
|
||||
<el-button
|
||||
v-hasPerm="['*:*:*']"
|
||||
type="info"
|
||||
icon="search"
|
||||
circle
|
||||
@click="visible = !visible"
|
||||
/>
|
||||
</el-tooltip>
|
||||
</el-col>
|
||||
<el-col :span="1.5">
|
||||
<el-tooltip content="刷新">
|
||||
<el-button
|
||||
v-hasPerm="['module_task:workflow:query']"
|
||||
type="primary"
|
||||
icon="refresh"
|
||||
circle
|
||||
@click="handleRefresh"
|
||||
/>
|
||||
</el-tooltip>
|
||||
</el-col>
|
||||
<el-col :span="1.5">
|
||||
<el-popover placement="bottom" trigger="click">
|
||||
<template #reference>
|
||||
<el-button type="danger" icon="operation" circle></el-button>
|
||||
</template>
|
||||
<el-scrollbar max-height="350px">
|
||||
<template v-for="column in tableColumns" :key="column.prop">
|
||||
<el-checkbox v-if="column.prop" v-model="column.show" :label="column.label" />
|
||||
</template>
|
||||
</el-scrollbar>
|
||||
</el-popover>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
ref="tableRef"
|
||||
v-loading="loading"
|
||||
:data="dataSource"
|
||||
highlight-current-row
|
||||
class="data-table__content"
|
||||
height="450"
|
||||
max-height="450"
|
||||
border
|
||||
stripe
|
||||
@sort-change="handleTableChange"
|
||||
>
|
||||
<template #empty>
|
||||
<el-empty :image-size="80" description="暂无数据" />
|
||||
</template>
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="name" label="名称" width="200" />
|
||||
<el-table-column prop="code" label="编码" width="150" />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getStatusType(row.status) as any">
|
||||
{{ getStatusText(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="description" label="描述" show-overflow-tooltip />
|
||||
<el-table-column prop="created_time" label="创建时间" width="180" />
|
||||
<el-table-column label="操作" width="300" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-space class="flex">
|
||||
<el-button
|
||||
v-if="row.status === 'draft'"
|
||||
type="success"
|
||||
size="small"
|
||||
link
|
||||
icon="upload"
|
||||
@click="handlePublish(row)"
|
||||
>
|
||||
发布
|
||||
</el-button>
|
||||
<el-dropdown
|
||||
v-if="row.status === 'published'"
|
||||
@command="(e) => handleExecute(e, row)"
|
||||
>
|
||||
<el-button type="warning" size="small" link icon="video-play">
|
||||
执行
|
||||
<el-icon><ArrowDown /></el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="execute">立即执行</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
<el-button type="primary" size="small" link icon="edit" @click="handleEdit(row)">
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button type="danger" size="small" link icon="delete" @click="handleDelete(row)">
|
||||
删除
|
||||
</el-button>
|
||||
</el-space>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页区域 -->
|
||||
<template #footer>
|
||||
<pagination
|
||||
v-model:total="workflowPagination.total"
|
||||
v-model:page="workflowPagination.page_no"
|
||||
v-model:limit="workflowPagination.page_size"
|
||||
@pagination="loadData"
|
||||
/>
|
||||
</template>
|
||||
</el-card>
|
||||
|
||||
<WorkflowDesignDrawer
|
||||
v-model:visible="createVisible"
|
||||
:workflow="selectedWorkflow"
|
||||
@refresh="handleRefresh"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { ArrowDown } from "@element-plus/icons-vue";
|
||||
import WorkflowAPI, {
|
||||
type WorkflowTable,
|
||||
type WorkflowPageQuery,
|
||||
} from "@/api/module_task/workflow";
|
||||
import WorkflowDesignDrawer from "./components/WorkflowDesignDrawer.vue";
|
||||
|
||||
const visible = ref(true);
|
||||
const loading = ref(false);
|
||||
const dataSource = ref<WorkflowTable[]>([]);
|
||||
const selectedWorkflow = ref<WorkflowTable>();
|
||||
const createVisible = ref(false);
|
||||
|
||||
const searchForm = reactive<Partial<WorkflowPageQuery>>({
|
||||
name: undefined,
|
||||
code: undefined,
|
||||
});
|
||||
|
||||
const workflowPagination = reactive({
|
||||
page_no: 1,
|
||||
page_size: 10,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
// 表格列配置
|
||||
const tableColumns = ref([
|
||||
{ prop: "selection", label: "选择框", show: true },
|
||||
{ prop: "index", label: "序号", show: true },
|
||||
{ prop: "name", label: "名称", show: true },
|
||||
{ prop: "code", label: "编码", show: true },
|
||||
{ prop: "status", label: "状态", show: true },
|
||||
{ prop: "description", label: "描述", show: true },
|
||||
{ prop: "created_time", label: "创建时间", show: true },
|
||||
]);
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const params: WorkflowPageQuery = {
|
||||
page_no: workflowPagination.page_no,
|
||||
page_size: workflowPagination.page_size,
|
||||
...searchForm,
|
||||
};
|
||||
const res = await WorkflowAPI.getWorkflowList(params);
|
||||
if (res.data && res.data.data) {
|
||||
dataSource.value = res.data.data.items || [];
|
||||
workflowPagination.total = res.data.data.total || 0;
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error("加载数据失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuery = () => {
|
||||
workflowPagination.page_no = 1;
|
||||
loadData();
|
||||
};
|
||||
|
||||
const handleResetQuery = () => {
|
||||
Object.assign(searchForm, {
|
||||
name: undefined,
|
||||
code: undefined,
|
||||
});
|
||||
handleQuery();
|
||||
};
|
||||
|
||||
const handleTableChange = () => {
|
||||
loadData();
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
selectedWorkflow.value = undefined;
|
||||
createVisible.value = true;
|
||||
};
|
||||
|
||||
const handleEdit = (record: WorkflowTable) => {
|
||||
selectedWorkflow.value = record;
|
||||
createVisible.value = true;
|
||||
};
|
||||
|
||||
const handlePublish = (record: WorkflowTable) => {
|
||||
ElMessageBox.confirm("确定要发布此工作流吗?发布后可执行。", "确认发布", {
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
})
|
||||
.then(async () => {
|
||||
try {
|
||||
if (!record.id) {
|
||||
ElMessage.error("工作流ID不存在");
|
||||
return;
|
||||
}
|
||||
await WorkflowAPI.publishWorkflow(record.id, {});
|
||||
ElMessage.success("发布成功");
|
||||
loadData();
|
||||
} catch {
|
||||
ElMessage.error("发布失败");
|
||||
}
|
||||
})
|
||||
.catch();
|
||||
};
|
||||
|
||||
const handleExecute = async (action: string, record: WorkflowTable) => {
|
||||
if (action === "execute") {
|
||||
ElMessageBox.confirm("确定要立即执行此工作流吗?", "确认执行", {
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
})
|
||||
.then(async () => {
|
||||
try {
|
||||
if (!record.id) {
|
||||
ElMessage.error("工作流ID不存在");
|
||||
return;
|
||||
}
|
||||
const res = await WorkflowAPI.executeWorkflow({
|
||||
workflow_id: record.id,
|
||||
variables: {},
|
||||
});
|
||||
if (res.data?.data) {
|
||||
const result = res.data.data;
|
||||
ElMessage.success(`工作流执行${result.status === "completed" ? "成功" : "失败"}`);
|
||||
}
|
||||
loadData();
|
||||
} catch {
|
||||
ElMessage.error("执行失败");
|
||||
}
|
||||
})
|
||||
.catch();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (record: WorkflowTable) => {
|
||||
ElMessageBox.confirm("确定要删除此工作流吗?", "确认删除", {
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
})
|
||||
.then(async () => {
|
||||
try {
|
||||
if (!record.id) {
|
||||
ElMessage.error("工作流ID不存在");
|
||||
return;
|
||||
}
|
||||
await WorkflowAPI.deleteWorkflow([record.id]);
|
||||
ElMessage.success("删除成功");
|
||||
loadData();
|
||||
} catch {
|
||||
ElMessage.error("删除失败");
|
||||
}
|
||||
})
|
||||
.catch();
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
loadData();
|
||||
};
|
||||
|
||||
const getStatusType = (status: string) => {
|
||||
const typeMap: Record<string, string> = {
|
||||
draft: "info",
|
||||
published: "success",
|
||||
archived: "warning",
|
||||
};
|
||||
return typeMap[status] || "";
|
||||
};
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
const textMap: Record<string, string> = {
|
||||
draft: "草稿",
|
||||
published: "已发布",
|
||||
archived: "已归档",
|
||||
};
|
||||
return textMap[status] || status;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -1,162 +0,0 @@
|
||||
import type { CSSProperties } from "vue";
|
||||
|
||||
export type NodeType =
|
||||
| "input"
|
||||
| "output"
|
||||
| "trigger"
|
||||
| "action"
|
||||
| "condition"
|
||||
| "control"
|
||||
| "integration"
|
||||
| "custom";
|
||||
|
||||
export type EdgeType = "default" | "straight" | "step" | "smoothstep" | "bezier";
|
||||
|
||||
export type HandlePosition = "left" | "right" | "top" | "bottom";
|
||||
|
||||
export interface NodeConfigSchema {
|
||||
type: string;
|
||||
properties: Record<string, PropertySchema>;
|
||||
}
|
||||
|
||||
export interface PropertySchema {
|
||||
type: "string" | "number" | "boolean" | "select" | "textarea" | "json" | "code";
|
||||
label: string;
|
||||
description?: string;
|
||||
default?: any;
|
||||
required?: boolean;
|
||||
options?: Array<{ label: string; value: any }>;
|
||||
placeholder?: string;
|
||||
validation?: {
|
||||
min?: number;
|
||||
max?: number;
|
||||
pattern?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DynamicNodeData {
|
||||
label: string;
|
||||
nodeTypeCode: string;
|
||||
config: Record<string, any>;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface Node {
|
||||
id: string;
|
||||
position: { x: number; y: number };
|
||||
type?: NodeType;
|
||||
data?: DynamicNodeData;
|
||||
label?: string;
|
||||
style?: CSSProperties;
|
||||
class?: string | string[];
|
||||
sourcePosition?: HandlePosition;
|
||||
targetPosition?: HandlePosition;
|
||||
hidden?: boolean;
|
||||
selected?: boolean;
|
||||
draggable?: boolean;
|
||||
connectable?: boolean;
|
||||
deletable?: boolean;
|
||||
selectable?: boolean;
|
||||
focusable?: boolean;
|
||||
dragHandle?: string;
|
||||
extent?: "parent" | [number, number] | [[number, number], [number, number]];
|
||||
parentNode?: string;
|
||||
expandParent?: boolean;
|
||||
zIndex?: number;
|
||||
}
|
||||
|
||||
export interface Edge {
|
||||
id?: string;
|
||||
source: string;
|
||||
target: string;
|
||||
sourceHandle?: string;
|
||||
targetHandle?: string;
|
||||
type?: EdgeType;
|
||||
label?: string;
|
||||
labelStyle?: CSSProperties;
|
||||
labelShowBg?: boolean;
|
||||
labelBgStyle?: CSSProperties;
|
||||
labelBgPadding?: [number, number];
|
||||
labelBgBorderRadius?: number;
|
||||
style?: CSSProperties;
|
||||
class?: string | string[];
|
||||
animated?: boolean;
|
||||
hidden?: boolean;
|
||||
selected?: boolean;
|
||||
deletable?: boolean;
|
||||
selectable?: boolean;
|
||||
focusable?: boolean;
|
||||
updatable?: boolean | "source" | "target";
|
||||
markerStart?: Marker | string;
|
||||
markerEnd?: Marker | string;
|
||||
pathOptions?: {
|
||||
offset?: number;
|
||||
borderRadius?: number;
|
||||
curvature?: number;
|
||||
};
|
||||
interactionWidth?: number;
|
||||
}
|
||||
|
||||
export interface Marker {
|
||||
type: "arrow" | "arrowclosed";
|
||||
color?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
orient?: "auto" | "auto-start-reverse";
|
||||
}
|
||||
|
||||
export interface WorkflowTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
nodes: Node[];
|
||||
edges: Edge[];
|
||||
}
|
||||
|
||||
export interface WorkflowStats {
|
||||
totalNodes: number;
|
||||
totalEdges: number;
|
||||
nodeTypes: Record<NodeType, number>;
|
||||
}
|
||||
|
||||
export interface NodeConfig {
|
||||
id: string;
|
||||
type: NodeType;
|
||||
data: DynamicNodeData;
|
||||
}
|
||||
|
||||
export interface EdgeConfig {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
label?: string;
|
||||
type?: EdgeType;
|
||||
animated?: boolean;
|
||||
}
|
||||
|
||||
export interface NodeTypeDefinition {
|
||||
code: string;
|
||||
name: string;
|
||||
category: "trigger" | "action" | "condition" | "control" | "integration" | "custom";
|
||||
description?: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
configSchema: NodeConfigSchema;
|
||||
inputSchema?: Record<string, any>;
|
||||
outputSchema?: Record<string, any>;
|
||||
handler: string;
|
||||
isSystem: boolean;
|
||||
isActive: boolean;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface NodeTemplate {
|
||||
id: string;
|
||||
nodeTypeCode: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
defaultConfig: Record<string, any>;
|
||||
isPublic: boolean;
|
||||
tags?: string[];
|
||||
thumbnail?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user