新增任务模板界面

This commit is contained in:
18086616529
2026-06-03 10:14:40 +08:00
parent 7382e85598
commit 5716c4e8ff
18 changed files with 3590 additions and 0 deletions
@@ -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>