新增任务模板界面
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<div class="edge-inspector">
|
||||
<div class="inspector-head">
|
||||
<div class="inspector-title">连线</div>
|
||||
<div class="inspector-id">ID: {{ edge.id }}</div>
|
||||
</div>
|
||||
|
||||
<dl class="edge-meta">
|
||||
<div class="edge-meta-row">
|
||||
<dt>起点</dt>
|
||||
<dd>{{ edge.sourceLabel }}</dd>
|
||||
</div>
|
||||
<div class="edge-meta-row">
|
||||
<dt>终点</dt>
|
||||
<dd>{{ edge.targetLabel }}</dd>
|
||||
</div>
|
||||
<div v-if="edge.label" class="edge-meta-row">
|
||||
<dt>出口</dt>
|
||||
<dd>{{ edge.label }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<p class="edge-hint muted">按 Delete / Backspace 可快速删除;或点击下方按钮。</p>
|
||||
|
||||
<el-button type="danger" plain size="small" class="edge-delete-btn" @click="emit('delete')">
|
||||
删除连线
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
export interface EdgeInspectorModel {
|
||||
id: string
|
||||
sourceLabel: string
|
||||
targetLabel: string
|
||||
label?: string | null
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
edge: EdgeInspectorModel
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
delete: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.edge-inspector {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.inspector-head {
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
}
|
||||
.inspector-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
.inspector-id {
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.edge-meta {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.edge-meta-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.edge-meta-row dt {
|
||||
flex-shrink: 0;
|
||||
width: 36px;
|
||||
color: #909399;
|
||||
}
|
||||
.edge-meta-row dd {
|
||||
margin: 0;
|
||||
color: #303133;
|
||||
font-weight: 500;
|
||||
}
|
||||
.edge-hint {
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.muted {
|
||||
color: #909399;
|
||||
}
|
||||
.edge-delete-btn {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,237 @@
|
||||
<template>
|
||||
<div class="node-inspector">
|
||||
<template v-if="node">
|
||||
<div class="inspector-head">
|
||||
<div class="inspector-title">{{ nodeDef?.label ?? node.type }}</div>
|
||||
<div class="inspector-id">ID: {{ node.id }}</div>
|
||||
</div>
|
||||
|
||||
<el-form label-position="top" size="small" class="inspector-form">
|
||||
<el-form-item label="显示名称">
|
||||
<el-input v-model="localLabel" @change="emitLabel" />
|
||||
</el-form-item>
|
||||
|
||||
<template v-for="field in visibleFields" :key="field.key">
|
||||
<el-form-item :label="field.label" :required="field.required">
|
||||
<el-input
|
||||
v-if="field.type === 'string'"
|
||||
:model-value="String(localParams[field.key] ?? '')"
|
||||
:placeholder="field.placeholder"
|
||||
@update:model-value="(v) => setParam(field.key, v)"
|
||||
/>
|
||||
<el-input-number
|
||||
v-else-if="field.type === 'number'"
|
||||
:model-value="Number(localParams[field.key] ?? 0)"
|
||||
controls-position="right"
|
||||
style="width: 100%"
|
||||
@update:model-value="(v) => setParam(field.key, v ?? 0)"
|
||||
/>
|
||||
<el-switch
|
||||
v-else-if="field.type === 'boolean'"
|
||||
class="inspector-switch"
|
||||
:model-value="toBool(localParams[field.key])"
|
||||
active-text="是"
|
||||
inactive-text="否"
|
||||
inline-prompt
|
||||
@update:model-value="(v) => setParam(field.key, v)"
|
||||
/>
|
||||
<el-select
|
||||
v-else-if="field.type === 'select'"
|
||||
:model-value="localParams[field.key]"
|
||||
style="width: 100%"
|
||||
placement="bottom-start"
|
||||
teleported
|
||||
fit-input-width
|
||||
:popper-options="selectPopperOptions"
|
||||
@update:model-value="(v) => setParam(field.key, v)"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in field.options ?? []"
|
||||
:key="String(opt.value)"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-input
|
||||
v-else-if="field.type === 'textarea'"
|
||||
:model-value="String(localParams[field.key] ?? '')"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
:placeholder="field.placeholder"
|
||||
@update:model-value="(v) => setParam(field.key, v)"
|
||||
/>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<el-empty v-if="visibleFields.length === 0" description="此节点无可配置参数" :image-size="48" />
|
||||
</el-form>
|
||||
|
||||
<div class="inspector-actions">
|
||||
<el-button type="danger" plain size="small" class="inspector-delete-btn" @click="emit('delete')">
|
||||
删除节点
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-empty v-else description="选中画布上的节点以编辑参数" :image-size="64" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { ParamFieldDef, WorkflowNodeDef } from '@/types/workflow'
|
||||
import { NODE_CATALOG_MAP } from '@/workflow/nodeCatalog'
|
||||
|
||||
const props = defineProps<{
|
||||
node: WorkflowNodeDef | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
update: [patch: Partial<WorkflowNodeDef>]
|
||||
delete: []
|
||||
}>()
|
||||
|
||||
const nodeDef = ref(NODE_CATALOG_MAP.get(props.node?.type ?? ''))
|
||||
const localLabel = ref('')
|
||||
type ParamValue = string | number | boolean
|
||||
const localParams = ref<Record<string, ParamValue>>({})
|
||||
|
||||
const visibleFields = computed<ParamFieldDef[]>(() => {
|
||||
const params = nodeDef.value?.params ?? []
|
||||
return params.filter((f) => {
|
||||
if (!f.when) return true
|
||||
return localParams.value[f.when.key] === f.when.equals
|
||||
})
|
||||
})
|
||||
|
||||
/** 右侧面板较窄,禁止 Popper 横向翻转到左侧 */
|
||||
const selectPopperOptions = {
|
||||
modifiers: [
|
||||
{
|
||||
name: 'flip',
|
||||
options: { fallbackPlacements: ['bottom-start', 'top-start'] }
|
||||
},
|
||||
{
|
||||
name: 'preventOverflow',
|
||||
options: { boundary: 'viewport', padding: 8 }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.node,
|
||||
(n) => {
|
||||
nodeDef.value = n ? NODE_CATALOG_MAP.get(n.type) : undefined
|
||||
localLabel.value = n?.label ?? ''
|
||||
localParams.value = n
|
||||
? Object.fromEntries(
|
||||
Object.entries(n.params).map(([k, v]) => [k, v as ParamValue])
|
||||
)
|
||||
: {}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function toBool(value: unknown): boolean {
|
||||
if (typeof value === 'boolean') return value
|
||||
if (value === 'true' || value === 1 || value === '1') return true
|
||||
return false
|
||||
}
|
||||
|
||||
function isFieldVisible(field: ParamFieldDef): boolean {
|
||||
if (!field.when) return true
|
||||
return localParams.value[field.when.key] === field.when.equals
|
||||
}
|
||||
|
||||
function pruneHiddenParams() {
|
||||
const fields = nodeDef.value?.params ?? []
|
||||
for (const field of fields) {
|
||||
if (!isFieldVisible(field)) delete localParams.value[field.key]
|
||||
}
|
||||
}
|
||||
|
||||
function setParam(key: string, value: ParamValue) {
|
||||
localParams.value[key] = value
|
||||
pruneHiddenParams()
|
||||
emitParams()
|
||||
}
|
||||
|
||||
function emitLabel() {
|
||||
if (!props.node) return
|
||||
emit('update', { label: localLabel.value })
|
||||
}
|
||||
|
||||
function emitParams() {
|
||||
if (!props.node) return
|
||||
emit('update', { params: { ...localParams.value } })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.node-inspector {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.inspector-head {
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
}
|
||||
.inspector-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
.inspector-id {
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.inspector-form :deep(.el-form-item__label) {
|
||||
font-weight: 500;
|
||||
color: #606266 !important;
|
||||
}
|
||||
.inspector-form :deep(.el-input__inner),
|
||||
.inspector-form :deep(.el-textarea__inner) {
|
||||
color: #303133 !important;
|
||||
}
|
||||
.inspector-form :deep(.el-input-number .el-input__inner) {
|
||||
color: #303133 !important;
|
||||
}
|
||||
.inspector-form :deep(.inspector-switch) {
|
||||
--el-switch-on-color: #7c3aed;
|
||||
--el-switch-off-color: #dcdfe6;
|
||||
}
|
||||
.inspector-form :deep(.inspector-switch .el-switch__core) {
|
||||
border-color: #dcdfe6;
|
||||
background: #dcdfe6;
|
||||
}
|
||||
.inspector-form :deep(.inspector-switch.is-checked .el-switch__core) {
|
||||
border-color: #7c3aed;
|
||||
background: #7c3aed;
|
||||
}
|
||||
.inspector-form :deep(.inspector-switch .el-switch__inner) {
|
||||
color: #606266 !important;
|
||||
}
|
||||
.inspector-form :deep(.inspector-switch.is-checked .el-switch__inner) {
|
||||
color: #fff !important;
|
||||
}
|
||||
.inspector-form :deep(.el-select-dropdown__item) {
|
||||
white-space: normal;
|
||||
line-height: 1.4;
|
||||
height: auto;
|
||||
min-height: 34px;
|
||||
padding-top: 6px;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
.inspector-actions {
|
||||
margin-top: 16px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #ebeef5;
|
||||
}
|
||||
.inspector-delete-btn {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,141 @@
|
||||
<template>
|
||||
<div class="node-palette">
|
||||
<el-input v-model="search" size="small" clearable placeholder="搜索控件" class="palette-search">
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
|
||||
<div v-for="group in grouped" :key="group.category" class="palette-group">
|
||||
<div class="palette-group-title">
|
||||
<span class="palette-group-dot" :style="{ background: group.color }" />
|
||||
{{ group.category }}
|
||||
</div>
|
||||
<div
|
||||
v-for="item in group.items"
|
||||
:key="item.type"
|
||||
class="palette-item"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart($event, item.type)"
|
||||
>
|
||||
<span class="palette-dot" :style="{ background: item.color }" />
|
||||
<div class="palette-item-text">
|
||||
<div class="palette-item-label">{{ item.label }}</div>
|
||||
<div v-if="item.description" class="palette-item-desc">{{ item.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { Search } from '@element-plus/icons-vue'
|
||||
import { CATEGORY_ORDER, NODE_CATALOG } from '@/workflow/nodeCatalog'
|
||||
|
||||
const search = ref('')
|
||||
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
流程入口: '#67c23a',
|
||||
调度动作: '#e6a23c',
|
||||
流程逻辑: '#9b59b6',
|
||||
事件与信号: '#409eff',
|
||||
协议控制: '#13c2c2'
|
||||
}
|
||||
|
||||
const grouped = computed(() => {
|
||||
const q = search.value.trim().toLowerCase()
|
||||
const filtered = NODE_CATALOG.filter((n) => {
|
||||
if (!q) return true
|
||||
return (
|
||||
n.label.toLowerCase().includes(q) ||
|
||||
n.type.toLowerCase().includes(q) ||
|
||||
n.category.toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
|
||||
const byCategory = new Map<string, typeof NODE_CATALOG>()
|
||||
for (const item of filtered) {
|
||||
const list = byCategory.get(item.category) ?? []
|
||||
list.push(item)
|
||||
byCategory.set(item.category, list)
|
||||
}
|
||||
|
||||
return CATEGORY_ORDER.filter((cat) => byCategory.has(cat)).map((category) => ({
|
||||
category,
|
||||
color: CATEGORY_COLORS[category] ?? '#909399',
|
||||
items: byCategory.get(category) ?? []
|
||||
}))
|
||||
})
|
||||
|
||||
function onDragStart(e: DragEvent, type: string) {
|
||||
if (!e.dataTransfer) return
|
||||
e.dataTransfer.setData('application/workflow-node', type)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.node-palette {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.palette-search {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.palette-group {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.palette-group-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #606266;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.palette-group-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.palette-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 6px;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 6px;
|
||||
background: #fafafa;
|
||||
cursor: grab;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.palette-item:hover {
|
||||
border-color: #c6e2ff;
|
||||
background: #ecf5ff;
|
||||
}
|
||||
.palette-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
margin-top: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.palette-item-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
}
|
||||
.palette-item-desc {
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
margin-top: 2px;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,696 @@
|
||||
<template>
|
||||
<div class="workflow-editor">
|
||||
<div class="wf-toolbar">
|
||||
<div class="wf-toolbar-left">
|
||||
<el-input v-model="workflow.name" size="small" class="wf-name-input" placeholder="流程名称" />
|
||||
<el-tag size="small" type="info">流程库 {{ library.workflows.length }} 个</el-tag>
|
||||
</div>
|
||||
<div class="wf-toolbar-center">
|
||||
<el-tag v-for="(issue, i) in validationIssues" :key="i" size="small" :type="issue.level === 'error' ? 'danger' : 'warning'" class="wf-issue-tag">
|
||||
{{ issue.message }}
|
||||
</el-tag>
|
||||
<el-tag v-if="validationIssues.length === 0" size="small" type="success">校验通过</el-tag>
|
||||
</div>
|
||||
<div class="wf-toolbar-right">
|
||||
<el-button size="small" type="danger" plain :disabled="!hasDeletableSelection" @click="deleteSelection">
|
||||
删除选中
|
||||
</el-button>
|
||||
<el-button size="small" @click="resetDemo">加载示例</el-button>
|
||||
<el-button size="small" @click="onImportJson">导入</el-button>
|
||||
<el-button size="small" type="primary" plain @click="onExportAll">导出</el-button>
|
||||
<el-button size="small" type="danger" plain @click="onClearCanvas">清除画布</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wf-body">
|
||||
<aside class="wf-panel wf-panel-left">
|
||||
<WorkflowListPanel
|
||||
:workflows="library.workflows"
|
||||
:active-id="library.activeWorkflowId"
|
||||
@select="switchToWorkflow"
|
||||
@create="onCreateWorkflow"
|
||||
@save="onSave"
|
||||
@duplicate="onDuplicateWorkflow"
|
||||
@delete="onDeleteWorkflow"
|
||||
/>
|
||||
<div class="wf-panel-title">控件库</div>
|
||||
<NodePalette class="wf-palette-body" />
|
||||
</aside>
|
||||
|
||||
<main class="wf-panel wf-panel-center">
|
||||
<VueFlow
|
||||
v-model:nodes="flowNodes"
|
||||
v-model:edges="flowEdges"
|
||||
:node-types="nodeTypes"
|
||||
:default-viewport="workflow.viewport ?? { x: 0, y: 0, zoom: 0.85 }"
|
||||
:delete-key-code="['Delete', 'Backspace']"
|
||||
fit-view-on-init
|
||||
@drop="onDrop"
|
||||
@dragover="onDragOver"
|
||||
@node-click="onNodeClick"
|
||||
@edge-click="onEdgeClick"
|
||||
@pane-click="onPaneClick"
|
||||
@connect="onConnect"
|
||||
@nodes-change="onNodesChange"
|
||||
@edges-change="onEdgesChange"
|
||||
>
|
||||
<Background pattern-color="#e8eaed" :gap="16" />
|
||||
<Controls position="bottom-right" />
|
||||
</VueFlow>
|
||||
</main>
|
||||
|
||||
<aside class="wf-panel wf-panel-right">
|
||||
<div class="wf-panel-title">参数配置</div>
|
||||
<EdgeInspector v-if="selectedEdge" :edge="selectedEdge" @delete="deleteSelectedEdge" />
|
||||
<NodeInspector
|
||||
v-else
|
||||
:node="selectedNode"
|
||||
@update="onInspectorUpdate"
|
||||
@delete="confirmDeleteSelectedNode"
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, markRaw, nextTick, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
VueFlow,
|
||||
type Connection,
|
||||
type EdgeChange,
|
||||
type GraphEdge,
|
||||
type GraphNode,
|
||||
type NodeChange,
|
||||
type NodeTypesObject,
|
||||
useVueFlow
|
||||
} from '@vue-flow/core'
|
||||
import { Background } from '@vue-flow/background'
|
||||
import { Controls } from '@vue-flow/controls'
|
||||
import '@vue-flow/core/dist/style.css'
|
||||
import '@vue-flow/core/dist/theme-default.css'
|
||||
import '@vue-flow/controls/dist/style.css'
|
||||
|
||||
import NodePalette from './NodePalette.vue'
|
||||
import NodeInspector from './NodeInspector.vue'
|
||||
import EdgeInspector from './EdgeInspector.vue'
|
||||
import WorkflowNode from './WorkflowNode.vue'
|
||||
import WorkflowListPanel from './WorkflowListPanel.vue'
|
||||
import type { WorkflowDefinition, WorkflowNodeDef } from '@/types/workflow'
|
||||
import { NODE_CATALOG_MAP, defaultParamsForType } from '@/workflow/nodeCatalog'
|
||||
import {
|
||||
addWorkflow,
|
||||
createEmptyWorkflow,
|
||||
duplicateWorkflow,
|
||||
createInitialWorkflowLibrary,
|
||||
ensureDemoInLibrary,
|
||||
getActiveWorkflow,
|
||||
getWorkflowById,
|
||||
mergeWorkflowsIntoLibrary,
|
||||
removeWorkflow,
|
||||
setActiveWorkflowId,
|
||||
upsertWorkflow,
|
||||
type StoredWorkflowLibrary
|
||||
} from '@/workflow/workflowLibrary'
|
||||
import {
|
||||
importResultSummary,
|
||||
parseWorkflowJsonText,
|
||||
pickJsonFile,
|
||||
prepareImportedWorkflow,
|
||||
readFileAsText,
|
||||
resolveWorkflowIdConflicts,
|
||||
WorkflowImportError
|
||||
} from '@/workflow/workflowImport'
|
||||
import {
|
||||
cloneWorkflowDefinition,
|
||||
downloadWorkflowCatalogExport,
|
||||
fromFlowState,
|
||||
nextEdgeId,
|
||||
nextNodeId,
|
||||
normalizeNodeParams,
|
||||
toFlowEdges,
|
||||
toFlowNodes,
|
||||
validateWorkflow
|
||||
} from '@/workflow/workflowUtils'
|
||||
|
||||
const nodeTypes = { workflow: markRaw(WorkflowNode) } as NodeTypesObject
|
||||
|
||||
const library = ref<StoredWorkflowLibrary>(createInitialWorkflowLibrary())
|
||||
const workflow = reactive<WorkflowDefinition>(cloneWorkflowDefinition(getActiveWorkflow(library.value)))
|
||||
const flowNodes = ref(toFlowNodes(workflow.nodes))
|
||||
const flowEdges = ref(toFlowEdges(workflow.edges))
|
||||
const selectedNodeId = ref<string | null>(null)
|
||||
const selectedEdgeId = ref<string | null>(null)
|
||||
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const { screenToFlowCoordinate, addEdges, getViewport, setViewport, removeNodes, removeEdges, getSelectedNodes, getSelectedEdges } =
|
||||
useVueFlow()
|
||||
|
||||
const selectedNode = computed<WorkflowNodeDef | null>(() => {
|
||||
if (!selectedNodeId.value) return null
|
||||
return workflow.nodes.find((n) => n.id === selectedNodeId.value) ?? null
|
||||
})
|
||||
|
||||
const selectedEdge = computed(() => {
|
||||
if (!selectedEdgeId.value) return null
|
||||
const edge = flowEdges.value.find((e) => e.id === selectedEdgeId.value)
|
||||
if (!edge) return null
|
||||
const sourceNode = workflow.nodes.find((n) => n.id === edge.source)
|
||||
const targetNode = workflow.nodes.find((n) => n.id === edge.target)
|
||||
return {
|
||||
id: edge.id,
|
||||
sourceLabel: sourceNode?.label ?? edge.source,
|
||||
targetLabel: targetNode?.label ?? edge.target,
|
||||
label: edge.label
|
||||
}
|
||||
})
|
||||
|
||||
const hasDeletableSelection = computed(() => {
|
||||
return (
|
||||
getSelectedNodes.value.length > 0 ||
|
||||
getSelectedEdges.value.length > 0 ||
|
||||
selectedNodeId.value !== null ||
|
||||
selectedEdgeId.value !== null
|
||||
)
|
||||
})
|
||||
|
||||
const validationIssues = computed(() => validateWorkflow(workflow))
|
||||
|
||||
watch([flowNodes, flowEdges], () => {
|
||||
syncWorkflowFromFlow()
|
||||
scheduleSyncToLibrary()
|
||||
}, { deep: true })
|
||||
|
||||
watch(() => workflow.name, scheduleSyncToLibrary)
|
||||
|
||||
function syncWorkflowFromFlow() {
|
||||
const { nodes, edges } = fromFlowState(flowNodes.value, flowEdges.value)
|
||||
workflow.nodes = nodes
|
||||
workflow.edges = edges
|
||||
}
|
||||
|
||||
function buildSnapshot(): WorkflowDefinition {
|
||||
syncWorkflowFromFlow()
|
||||
const vp = getViewport()
|
||||
return {
|
||||
...cloneWorkflowDefinition(workflow),
|
||||
version: workflow.version,
|
||||
viewport: { x: vp.x, y: vp.y, zoom: vp.zoom }
|
||||
}
|
||||
}
|
||||
|
||||
/** 将当前画布同步到内存流程库(不写浏览器存储) */
|
||||
function syncCurrentToLibrary(): WorkflowDefinition {
|
||||
const snapshot = buildSnapshot()
|
||||
library.value = upsertWorkflow(library.value, snapshot)
|
||||
return snapshot
|
||||
}
|
||||
|
||||
function scheduleSyncToLibrary() {
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
saveTimer = setTimeout(() => {
|
||||
syncCurrentToLibrary()
|
||||
}, 400)
|
||||
}
|
||||
|
||||
function applyWorkflowToCanvas(def: WorkflowDefinition) {
|
||||
const copy = cloneWorkflowDefinition(def)
|
||||
Object.assign(workflow, copy)
|
||||
flowNodes.value = toFlowNodes(workflow.nodes)
|
||||
flowEdges.value = toFlowEdges(workflow.edges)
|
||||
selectedNodeId.value = null
|
||||
selectedEdgeId.value = null
|
||||
void nextTick(() => {
|
||||
const vp = copy.viewport ?? { x: 0, y: 0, zoom: 0.85 }
|
||||
setViewport(vp)
|
||||
})
|
||||
}
|
||||
|
||||
function switchToWorkflow(id: string) {
|
||||
if (id === library.value.activeWorkflowId) return
|
||||
syncCurrentToLibrary()
|
||||
const next = setActiveWorkflowId(library.value, id)
|
||||
if (!next) return
|
||||
library.value = next
|
||||
applyWorkflowToCanvas(getActiveWorkflow(library.value))
|
||||
}
|
||||
|
||||
function onCreateWorkflow() {
|
||||
syncCurrentToLibrary()
|
||||
const created = createEmptyWorkflow()
|
||||
library.value = addWorkflow(library.value, created)
|
||||
applyWorkflowToCanvas(created)
|
||||
ElMessage.success('已创建新流程')
|
||||
}
|
||||
|
||||
async function onDeleteWorkflow(id: string) {
|
||||
const target = library.value.workflows.find((w) => w.id === id)
|
||||
if (!target) return
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定从流程库删除「${target.name}」?`, '删除流程', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
confirmButtonClass: 'el-button--danger'
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (id === library.value.activeWorkflowId) {
|
||||
syncCurrentToLibrary()
|
||||
}
|
||||
const wasActive = id === library.value.activeWorkflowId
|
||||
const next = removeWorkflow(library.value, id)
|
||||
if (!next) {
|
||||
ElMessage.warning('至少保留一个流程')
|
||||
return
|
||||
}
|
||||
library.value = next
|
||||
if (wasActive) {
|
||||
applyWorkflowToCanvas(getActiveWorkflow(library.value))
|
||||
}
|
||||
ElMessage.success('流程已删除')
|
||||
}
|
||||
|
||||
function onDuplicateWorkflow(id: string) {
|
||||
syncCurrentToLibrary()
|
||||
const next = duplicateWorkflow(library.value, id)
|
||||
if (!next) return
|
||||
library.value = next
|
||||
applyWorkflowToCanvas(getActiveWorkflow(library.value))
|
||||
ElMessage.success('已复制流程')
|
||||
}
|
||||
|
||||
function onSave() {
|
||||
const snapshot = syncCurrentToLibrary()
|
||||
const errors = validateWorkflow(snapshot).filter((i) => i.level === 'error')
|
||||
if (errors.length > 0) {
|
||||
ElMessage.error(errors[0]!.message)
|
||||
return
|
||||
}
|
||||
const warnings = validateWorkflow(snapshot).filter((i) => i.level === 'warning')
|
||||
if (warnings.length > 0) {
|
||||
ElMessage.warning(`已保存「${snapshot.name || '未命名流程'}」,但有 ${warnings.length} 条校验警告`)
|
||||
} else {
|
||||
ElMessage.success(`已保存「${snapshot.name || '未命名流程'}」`)
|
||||
}
|
||||
}
|
||||
|
||||
function onExportAll() {
|
||||
syncCurrentToLibrary()
|
||||
const workflows = library.value.workflows
|
||||
const errorWorkflows = workflows.filter((w) => validateWorkflow(w).some((i) => i.level === 'error'))
|
||||
if (errorWorkflows.length > 0) {
|
||||
ElMessage.error(`「${errorWorkflows[0]!.name}」存在校验错误,请先修复后再导出`)
|
||||
return
|
||||
}
|
||||
downloadWorkflowCatalogExport(workflows, 'workflow-catalog')
|
||||
ElMessage.success(`已导出 ${workflows.length} 个流程到 workflow-catalog.json`)
|
||||
}
|
||||
|
||||
async function onImportJson() {
|
||||
const file = await pickJsonFile()
|
||||
if (!file) return
|
||||
|
||||
let text: string
|
||||
try {
|
||||
text = await readFileAsText(file)
|
||||
} catch {
|
||||
ElMessage.error('读取文件失败')
|
||||
return
|
||||
}
|
||||
|
||||
let parsed
|
||||
try {
|
||||
parsed = parseWorkflowJsonText(text)
|
||||
} catch (e) {
|
||||
ElMessage.error(e instanceof WorkflowImportError ? e.message : '导入失败')
|
||||
return
|
||||
}
|
||||
|
||||
const summary = importResultSummary(parsed)
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`${summary}。将追加到流程库(流程 ID 与已有重复时自动分配新 ID),是否继续?`,
|
||||
'导入 JSON',
|
||||
{ type: 'info', confirmButtonText: '导入', cancelButtonText: '取消' }
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
syncCurrentToLibrary()
|
||||
const existingIds = new Set(library.value.workflows.map((w) => w.id))
|
||||
const prepared = resolveWorkflowIdConflicts(
|
||||
parsed.workflows.map((w) => prepareImportedWorkflow(w)),
|
||||
existingIds
|
||||
)
|
||||
|
||||
const merged = mergeWorkflowsIntoLibrary(library.value, prepared)
|
||||
if (!merged) {
|
||||
ElMessage.error('没有可导入的流程')
|
||||
return
|
||||
}
|
||||
|
||||
library.value = merged.library
|
||||
|
||||
const active = getWorkflowById(library.value, merged.lastImportedId)
|
||||
if (active) {
|
||||
applyWorkflowToCanvas(active)
|
||||
}
|
||||
|
||||
const warnCount = prepared.reduce(
|
||||
(sum, w) => sum + validateWorkflow(w).filter((i) => i.level === 'warning').length,
|
||||
0
|
||||
)
|
||||
if (warnCount > 0) {
|
||||
ElMessage.warning(`已导入 ${prepared.length} 个流程,共 ${warnCount} 条校验警告,请在顶栏查看`)
|
||||
} else {
|
||||
ElMessage.success(
|
||||
prepared.length === 1
|
||||
? `已导入流程「${prepared[0]!.name}」`
|
||||
: `已导入 ${prepared.length} 个流程`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function onDragOver(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
const type = e.dataTransfer?.getData('application/workflow-node')
|
||||
if (!type) return
|
||||
|
||||
const def = NODE_CATALOG_MAP.get(type)
|
||||
if (!def) return
|
||||
|
||||
if (def.singleton && workflow.nodes.some((n) => n.type === type)) {
|
||||
ElMessage.warning(`「${def.label}」节点在流程中只能有一个`)
|
||||
return
|
||||
}
|
||||
|
||||
const pos = screenToFlowCoordinate({ x: e.clientX, y: e.clientY })
|
||||
const id = nextNodeId()
|
||||
flowNodes.value = [
|
||||
...flowNodes.value,
|
||||
{
|
||||
id,
|
||||
type: 'workflow',
|
||||
position: pos,
|
||||
data: {
|
||||
type,
|
||||
label: def.label,
|
||||
params: normalizeNodeParams(type, defaultParamsForType(type))
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function onNodeClick({ node }: { node: { id: string } }) {
|
||||
selectedNodeId.value = node.id
|
||||
selectedEdgeId.value = null
|
||||
}
|
||||
|
||||
function onEdgeClick({ edge }: { edge: { id: string } }) {
|
||||
selectedEdgeId.value = edge.id
|
||||
selectedNodeId.value = null
|
||||
}
|
||||
|
||||
function onPaneClick() {
|
||||
selectedNodeId.value = null
|
||||
selectedEdgeId.value = null
|
||||
}
|
||||
|
||||
function onConnect(conn: Connection) {
|
||||
if (!conn.source || !conn.target) return
|
||||
addEdges([
|
||||
{
|
||||
id: nextEdgeId(),
|
||||
source: conn.source,
|
||||
target: conn.target,
|
||||
sourceHandle: conn.sourceHandle ?? undefined,
|
||||
animated: true,
|
||||
style: { stroke: '#409eff', strokeWidth: 2 }
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
function onNodesChange(changes: NodeChange[]) {
|
||||
for (const change of changes) {
|
||||
if (change.type === 'remove' && change.id === selectedNodeId.value) {
|
||||
selectedNodeId.value = null
|
||||
}
|
||||
}
|
||||
syncWorkflowFromFlow()
|
||||
}
|
||||
|
||||
function onEdgesChange(changes: EdgeChange[]) {
|
||||
for (const change of changes) {
|
||||
if (change.type === 'remove' && change.id === selectedEdgeId.value) {
|
||||
selectedEdgeId.value = null
|
||||
}
|
||||
}
|
||||
syncWorkflowFromFlow()
|
||||
}
|
||||
|
||||
function collectNodesToDelete(): GraphNode[] {
|
||||
const selected = getSelectedNodes.value
|
||||
if (selected.length > 0) return selected
|
||||
if (!selectedNodeId.value) return []
|
||||
const node = flowNodes.value.find((n) => n.id === selectedNodeId.value)
|
||||
return node ? [node as GraphNode] : []
|
||||
}
|
||||
|
||||
function collectEdgesToDelete(): GraphEdge[] {
|
||||
const selected = getSelectedEdges.value
|
||||
if (selected.length > 0) return selected
|
||||
if (!selectedEdgeId.value) return []
|
||||
const edge = flowEdges.value.find((e) => e.id === selectedEdgeId.value)
|
||||
return edge ? [edge as GraphEdge] : []
|
||||
}
|
||||
|
||||
async function confirmDeleteSelectedNode() {
|
||||
const nodes = collectNodesToDelete()
|
||||
if (nodes.length === 0) return
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
nodes.length === 1
|
||||
? `确定删除节点「${nodes[0]!.data.label}」?关联连线将一并移除。`
|
||||
: `确定删除选中的 ${nodes.length} 个节点?关联连线将一并移除。`,
|
||||
'删除节点',
|
||||
{
|
||||
type: 'warning',
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
confirmButtonClass: 'el-button--danger'
|
||||
}
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
removeNodes(nodes)
|
||||
selectedNodeId.value = null
|
||||
ElMessage.success(nodes.length === 1 ? '节点已删除' : `已删除 ${nodes.length} 个节点`)
|
||||
}
|
||||
|
||||
function deleteSelectedEdge() {
|
||||
const edges = collectEdgesToDelete()
|
||||
if (edges.length === 0) return
|
||||
removeEdges(edges)
|
||||
selectedEdgeId.value = null
|
||||
ElMessage.success(edges.length === 1 ? '连线已删除' : `已删除 ${edges.length} 条连线`)
|
||||
}
|
||||
|
||||
async function deleteSelection() {
|
||||
const nodes = collectNodesToDelete()
|
||||
let edgesToDelete = collectEdgesToDelete()
|
||||
if (nodes.length === 0 && edgesToDelete.length === 0) {
|
||||
ElMessage.info('请先选中要删除的节点或连线')
|
||||
return
|
||||
}
|
||||
|
||||
if (nodes.length > 0) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
nodes.length === 1
|
||||
? `确定删除节点「${nodes[0]!.data.label}」?关联连线将一并移除。`
|
||||
: `确定删除选中的 ${nodes.length} 个节点?关联连线将一并移除。`,
|
||||
'删除节点',
|
||||
{
|
||||
type: 'warning',
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
confirmButtonClass: 'el-button--danger'
|
||||
}
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
removeNodes(nodes)
|
||||
selectedNodeId.value = null
|
||||
const removedNodeIds = new Set(nodes.map((n) => n.id))
|
||||
edgesToDelete = edgesToDelete.filter((e) => !removedNodeIds.has(e.source) && !removedNodeIds.has(e.target))
|
||||
}
|
||||
|
||||
edgesToDelete = edgesToDelete.filter((e) => flowEdges.value.some((fe) => fe.id === e.id))
|
||||
if (edgesToDelete.length > 0) {
|
||||
removeEdges(edgesToDelete)
|
||||
selectedEdgeId.value = null
|
||||
}
|
||||
|
||||
const parts: string[] = []
|
||||
if (nodes.length > 0) parts.push(`${nodes.length} 个节点`)
|
||||
if (edgesToDelete.length > 0) parts.push(`${edgesToDelete.length} 条连线`)
|
||||
ElMessage.success(`已删除 ${parts.join('、')}`)
|
||||
}
|
||||
|
||||
function onInspectorUpdate(patch: Partial<WorkflowNodeDef>) {
|
||||
if (!selectedNodeId.value) return
|
||||
const idx = flowNodes.value.findIndex((n) => n.id === selectedNodeId.value)
|
||||
if (idx < 0) return
|
||||
|
||||
const node = flowNodes.value[idx]
|
||||
if (patch.label !== undefined) node.data.label = patch.label
|
||||
if (patch.params !== undefined) node.data.params = { ...patch.params }
|
||||
flowNodes.value = [...flowNodes.value]
|
||||
}
|
||||
|
||||
function resetDemo() {
|
||||
syncCurrentToLibrary()
|
||||
const { library: lib, demoId } = ensureDemoInLibrary(library.value)
|
||||
library.value = setActiveWorkflowId(lib, demoId) ?? lib
|
||||
applyWorkflowToCanvas(getActiveWorkflow(library.value))
|
||||
ElMessage.success('已切换到示例流程')
|
||||
}
|
||||
|
||||
async function onClearCanvas() {
|
||||
if (flowNodes.value.length === 0 && flowEdges.value.length === 0) {
|
||||
ElMessage.info('画布已是空的')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.confirm('确定清空当前流程画布上的所有节点和连线?', '清除画布', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '清除',
|
||||
cancelButtonText: '取消',
|
||||
confirmButtonClass: 'el-button--danger'
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
flowNodes.value = []
|
||||
flowEdges.value = []
|
||||
workflow.nodes = []
|
||||
workflow.edges = []
|
||||
selectedNodeId.value = null
|
||||
selectedEdgeId.value = null
|
||||
syncCurrentToLibrary()
|
||||
ElMessage.success('画布已清空')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.workflow-editor {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #f5f7fa;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #ebeef5;
|
||||
}
|
||||
.wf-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.wf-toolbar-left,
|
||||
.wf-toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.wf-toolbar-center {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
justify-content: center;
|
||||
min-width: 120px;
|
||||
}
|
||||
.wf-name-input {
|
||||
width: 200px;
|
||||
}
|
||||
.wf-issue-tag {
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.wf-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
}
|
||||
.wf-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
min-height: 0;
|
||||
}
|
||||
.wf-panel-left {
|
||||
width: 220px;
|
||||
border-right: 1px solid #ebeef5;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wf-palette-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.wf-panel-center {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.wf-panel-right {
|
||||
width: 280px;
|
||||
border-left: 1px solid #ebeef5;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wf-panel-title {
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #606266;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wf-panel-center :deep(.vue-flow) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.wf-panel-center :deep(.vue-flow__node) {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
padding: 0 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.wf-panel-center :deep(.vue-flow__edge.selected .vue-flow__edge-path) {
|
||||
stroke-width: 3 !important;
|
||||
filter: drop-shadow(0 0 2px rgba(64, 158, 255, 0.45));
|
||||
}
|
||||
.wf-panel-center :deep(.vue-flow__edge.selected .vue-flow__edge-text) {
|
||||
fill: #409eff;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<div class="wf-list-panel">
|
||||
<div class="wf-list-head">
|
||||
<span class="wf-list-title">流程库</span>
|
||||
<div class="wf-list-actions">
|
||||
<el-button size="small" type="primary" link @click="emit('create')">+ 新建</el-button>
|
||||
<el-button size="small" type="success" link @click="emit('save')">保存</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="wf-list">
|
||||
<li
|
||||
v-for="item in workflows"
|
||||
:key="item.id"
|
||||
class="wf-list-item"
|
||||
:class="{ active: item.id === activeId }"
|
||||
@click="emit('select', item.id)"
|
||||
>
|
||||
<div class="wf-list-item-main">
|
||||
<span class="wf-list-item-name">{{ item.name || '未命名流程' }}</span>
|
||||
<span class="wf-list-item-meta">{{ item.nodes.length }} 节点</span>
|
||||
</div>
|
||||
<el-dropdown
|
||||
trigger="click"
|
||||
@click.stop
|
||||
@command="(cmd) => onCommand(cmd, item.id)"
|
||||
>
|
||||
<el-button size="small" text class="wf-list-more" @click.stop>⋯</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="duplicate">复制</el-dropdown-item>
|
||||
<el-dropdown-item command="delete" :disabled="workflows.length <= 1">删除</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p class="wf-list-hint">共 {{ workflows.length }} 个流程(仅当前会话内存);持久化请点顶栏「导出」下载 JSON。</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { WorkflowDefinition } from '@/types/workflow'
|
||||
|
||||
defineProps<{
|
||||
workflows: WorkflowDefinition[]
|
||||
activeId: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [id: string]
|
||||
create: []
|
||||
save: []
|
||||
duplicate: [id: string]
|
||||
delete: [id: string]
|
||||
}>()
|
||||
|
||||
function onCommand(cmd: string | number | object, id: string) {
|
||||
if (cmd === 'duplicate') emit('duplicate', id)
|
||||
if (cmd === 'delete') emit('delete', id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.wf-list-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
flex-shrink: 0;
|
||||
max-height: 220px;
|
||||
}
|
||||
.wf-list-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px 4px;
|
||||
}
|
||||
.wf-list-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #606266;
|
||||
}
|
||||
.wf-list-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.wf-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 4px 8px 8px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.wf-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.wf-list-item:hover {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
.wf-list-item.active {
|
||||
background: #ecf5ff;
|
||||
border: 1px solid #b3d8ff;
|
||||
}
|
||||
.wf-list-item-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.wf-list-item-name {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.wf-list-item-meta {
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
}
|
||||
.wf-list-more {
|
||||
padding: 0 4px;
|
||||
min-height: auto;
|
||||
}
|
||||
.wf-list-hint {
|
||||
margin: 0;
|
||||
padding: 6px 12px 10px;
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
line-height: 1.4;
|
||||
border-top: 1px solid #f0f2f5;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,213 @@
|
||||
<template>
|
||||
<div
|
||||
class="wf-node"
|
||||
:class="{ selected: selected, 'wf-node-dual': hasDualOutlets }"
|
||||
:style="{ '--node-color': color }"
|
||||
>
|
||||
<Handle type="target" :position="Position.Left" class="wf-handle wf-handle-target" />
|
||||
|
||||
<template v-if="hasDualOutlets">
|
||||
<div class="wf-node-body">
|
||||
<div class="wf-node-head">
|
||||
<span class="wf-node-dot" />
|
||||
<span class="wf-node-type">{{ typeLabel }}</span>
|
||||
</div>
|
||||
<div class="wf-node-label">{{ data.label }}</div>
|
||||
</div>
|
||||
<div class="wf-outlets">
|
||||
<div class="wf-outlet-row">
|
||||
<span class="wf-port-label wf-port-top">{{ dualOutletLabels.top }}</span>
|
||||
<Handle
|
||||
v-if="data.type === 'branch'"
|
||||
id="success"
|
||||
type="source"
|
||||
:position="Position.Right"
|
||||
class="wf-handle wf-handle-success wf-outlet-handle"
|
||||
/>
|
||||
<Handle
|
||||
v-else-if="data.type === 'waitEvent'"
|
||||
id="triggered"
|
||||
type="source"
|
||||
:position="Position.Right"
|
||||
class="wf-handle wf-handle-success wf-outlet-handle"
|
||||
/>
|
||||
</div>
|
||||
<div class="wf-outlet-row">
|
||||
<span class="wf-port-label wf-port-bottom">{{ dualOutletLabels.bottom }}</span>
|
||||
<Handle
|
||||
v-if="data.type === 'branch'"
|
||||
id="fail"
|
||||
type="source"
|
||||
:position="Position.Right"
|
||||
class="wf-handle wf-handle-fail wf-outlet-handle"
|
||||
/>
|
||||
<Handle
|
||||
v-else-if="data.type === 'waitEvent'"
|
||||
id="timeout"
|
||||
type="source"
|
||||
:position="Position.Right"
|
||||
class="wf-handle wf-handle-fail wf-outlet-handle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="wf-node-body">
|
||||
<div class="wf-node-head">
|
||||
<span class="wf-node-dot" />
|
||||
<span class="wf-node-type">{{ typeLabel }}</span>
|
||||
</div>
|
||||
<div class="wf-node-label">{{ data.label }}</div>
|
||||
</div>
|
||||
<Handle
|
||||
v-if="data.type !== 'end'"
|
||||
type="source"
|
||||
:position="Position.Right"
|
||||
class="wf-handle wf-handle-source"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Handle, Position } from '@vue-flow/core'
|
||||
import { NODE_CATALOG_MAP } from '@/workflow/nodeCatalog'
|
||||
import { WAIT_EVENT_HANDLE_LABELS } from '@/workflow/waitEventParams'
|
||||
|
||||
const props = defineProps<{
|
||||
id: string
|
||||
data: { type: string; label: string; params: Record<string, unknown> }
|
||||
selected?: boolean
|
||||
}>()
|
||||
|
||||
const typeLabel = computed(() => NODE_CATALOG_MAP.get(props.data.type)?.label ?? props.data.type)
|
||||
const color = computed(() => NODE_CATALOG_MAP.get(props.data.type)?.color ?? '#409eff')
|
||||
|
||||
const hasDualOutlets = computed(() => props.data.type === 'branch' || props.data.type === 'waitEvent')
|
||||
|
||||
const dualOutletLabels = computed(() => {
|
||||
if (props.data.type === 'waitEvent') {
|
||||
return {
|
||||
top: WAIT_EVENT_HANDLE_LABELS.triggered,
|
||||
bottom: WAIT_EVENT_HANDLE_LABELS.timeout
|
||||
}
|
||||
}
|
||||
if (props.data.type === 'branch') {
|
||||
return { top: '成立', bottom: '不成立' }
|
||||
}
|
||||
return { top: '', bottom: '' }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.wf-node {
|
||||
min-width: 140px;
|
||||
padding: 8px 0 8px 12px;
|
||||
border-radius: 8px;
|
||||
border: 2px solid var(--node-color, #409eff);
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
font-size: 12px;
|
||||
position: relative;
|
||||
}
|
||||
.wf-node-dual {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 88px;
|
||||
padding-right: 0;
|
||||
}
|
||||
.wf-node.selected {
|
||||
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.35), 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
.wf-node-body {
|
||||
flex: 1;
|
||||
padding-right: 8px;
|
||||
}
|
||||
.wf-node-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.wf-node-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--node-color);
|
||||
}
|
||||
.wf-node-type {
|
||||
color: #909399;
|
||||
font-size: 11px;
|
||||
}
|
||||
.wf-node-label {
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.wf-outlets {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
gap: 2px;
|
||||
margin-top: 4px;
|
||||
padding-right: 0;
|
||||
}
|
||||
.wf-outlet-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
min-height: 22px;
|
||||
padding-right: 0;
|
||||
}
|
||||
.wf-port-label {
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
padding: 2px 5px;
|
||||
border-radius: 3px;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
.wf-port-top {
|
||||
color: #529b2e;
|
||||
background: #f0f9eb;
|
||||
}
|
||||
.wf-port-bottom {
|
||||
color: #c45656;
|
||||
background: #fef0f0;
|
||||
}
|
||||
:deep(.wf-handle) {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: var(--node-color, #409eff);
|
||||
border: 2px solid #fff;
|
||||
}
|
||||
:deep(.wf-handle-target) {
|
||||
left: -5px !important;
|
||||
top: 50% !important;
|
||||
transform: translate(-50%, -50%) !important;
|
||||
}
|
||||
:deep(.wf-handle-source) {
|
||||
right: -5px !important;
|
||||
top: 50% !important;
|
||||
transform: translate(50%, -50%) !important;
|
||||
}
|
||||
:deep(.wf-outlet-handle) {
|
||||
position: relative !important;
|
||||
top: auto !important;
|
||||
right: auto !important;
|
||||
left: auto !important;
|
||||
bottom: auto !important;
|
||||
transform: none !important;
|
||||
flex-shrink: 0;
|
||||
margin-right: -5px;
|
||||
}
|
||||
:deep(.wf-handle-success) {
|
||||
background: #67c23a;
|
||||
}
|
||||
:deep(.wf-handle-fail) {
|
||||
background: #f56c6c;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user