refactor(workflow): 移除工作流编排功能及相关代码

删除前后端工作流编排功能的所有相关代码,包括模型、控制器、服务、API、前端组件等
更新依赖版本并移除不再需要的依赖包
调整系统菜单配置移除工作流相关菜单项
This commit is contained in:
zhangtao
2026-02-26 00:33:57 +08:00
parent 4e0eff3653
commit 1727110b46
26 changed files with 487 additions and 3897 deletions
-112
View File
@@ -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>;
}
-6
View File
@@ -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;
}