979 lines
31 KiB
Vue
979 lines
31 KiB
Vue
<template>
|
||
<div class="flow-canvas">
|
||
<!-- 左侧节点面板 -->
|
||
<div class="palette">
|
||
<div class="palette-title">节点面板</div>
|
||
<div class="palette-list">
|
||
<div
|
||
v-for="p in palette"
|
||
:key="p.type"
|
||
class="palette-item"
|
||
:class="'pal-' + p.cls"
|
||
draggable="true"
|
||
:title="'点击或拖拽添加「' + p.label + '」'"
|
||
@dragstart="onDragStart($event, p)"
|
||
@dragend="onDragEnd"
|
||
@click="onPaletteAdd(p)"
|
||
>
|
||
<span class="pal-shape-wrap">
|
||
<span class="pal-shape" :class="'shape-' + p.cls"></span>
|
||
</span>
|
||
<div class="pal-info">
|
||
<div class="pal-label">{{ p.label }}</div>
|
||
<div class="pal-desc">{{ p.desc }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="palette-tip">
|
||
点击或拖拽即可添加节点;<br />从右侧小圆点拖到目标节点连线;<br />双击节点配置 · 点击连线设条件。
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 画布 -->
|
||
<div ref="canvasWrap" class="canvas-wrap" @drop="onDrop" @dragover.prevent @dragenter.prevent>
|
||
<VueFlow
|
||
v-model:nodes="nodes"
|
||
v-model:edges="edges"
|
||
:default-viewport="{ zoom: 1 }"
|
||
:min-zoom="0.4"
|
||
:max-zoom="2"
|
||
@node-double-click="onNodeDblClick"
|
||
@edge-click="onEdgeClick"
|
||
>
|
||
<template #node-flow="nodeProps">
|
||
<!-- 判断节点:菱形 -->
|
||
<div v-if="nodeProps.data.cls === 'decision'" class="fn-diamond">
|
||
<Handle type="target" :position="Position.Left" />
|
||
<div class="fn-diamond-shape"></div>
|
||
<div class="fn-diamond-label">{{ nodeProps.data.name || '判断' }}</div>
|
||
<Handle type="source" :position="Position.Right" />
|
||
</div>
|
||
<!-- 开始 / 结束:胶囊 -->
|
||
<div
|
||
v-else-if="nodeProps.data.cls === 'start' || nodeProps.data.cls === 'end'"
|
||
class="fn-pill"
|
||
:class="'fn-pill-' + nodeProps.data.cls"
|
||
>
|
||
<Handle type="target" :position="Position.Left" />
|
||
<el-icon class="fn-pill-icon"><component :is="nodeProps.data.cls === 'start' ? VideoPlay : SwitchButton" /></el-icon>
|
||
<span>{{ nodeProps.data.name || (nodeProps.data.cls === 'start' ? '开始' : '结束') }}</span>
|
||
<Handle type="source" :position="Position.Right" />
|
||
</div>
|
||
<!-- 通知节点:圆形 -->
|
||
<div v-else-if="nodeProps.data.cls === 'notify'" class="fn-circle fn-circle-notify">
|
||
<Handle type="target" :position="Position.Left" />
|
||
<el-icon class="fn-circle-icon"><Bell /></el-icon>
|
||
<div class="fn-circle-label">{{ nodeProps.data.name || '通知' }}</div>
|
||
<Handle type="source" :position="Position.Right" />
|
||
</div>
|
||
<!-- 审批节点:矩形卡片 -->
|
||
<div v-else class="fn" :class="'fn-' + nodeProps.data.cls">
|
||
<Handle type="target" :position="Position.Left" />
|
||
<div class="fn-badge">{{ typeShort(nodeProps.data.nodeType) }}</div>
|
||
<div class="fn-title">{{ nodeProps.data.name || '未命名' }}</div>
|
||
<div class="fn-sub">{{ nodeProps.data.summary }}</div>
|
||
<Handle type="source" :position="Position.Right" />
|
||
</div>
|
||
</template>
|
||
<Background />
|
||
<Controls />
|
||
</VueFlow>
|
||
</div>
|
||
|
||
<!-- 节点配置对话框 -->
|
||
<el-dialog v-model="nodeDialog" :title="nodeDialogTitle" width="560px" append-to-body>
|
||
<el-form :model="nodeForm" label-width="96px">
|
||
<el-form-item label="节点名称" required>
|
||
<el-input v-model="nodeForm.name" placeholder="如:部门主管审批" />
|
||
</el-form-item>
|
||
<template v-if="nodeForm.nodeType === 2">
|
||
<el-form-item label="审批人类型">
|
||
<el-radio-group v-model="nodeForm.approverType" @change="onApproverTypeChange">
|
||
<el-radio v-for="t in approverTypes" :key="t.value" :value="t.value">{{ t.label }}</el-radio>
|
||
</el-radio-group>
|
||
</el-form-item>
|
||
<el-form-item v-if="currentApproverMeta && currentApproverMeta.needsSelection" :label="currentApproverMeta.selectLabel" required>
|
||
<el-select v-model="nodeForm.approverIds" multiple filterable style="width: 100%" :placeholder="'选择' + currentApproverMeta.selectLabel + '(可多选)'">
|
||
<el-option v-for="opt in currentPool" :key="opt.id" :label="opt.name" :value="opt.id" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item v-else label=" ">
|
||
<span class="hint">该类型运行期自动定位审批人,无需指定对象</span>
|
||
</el-form-item>
|
||
<el-form-item
|
||
v-if="currentApproverMeta && currentApproverMeta.needsSelection && nodeForm.approverIds && nodeForm.approverIds.length > 1"
|
||
label="多人方式"
|
||
>
|
||
<el-radio-group v-model="nodeForm.multiMode">
|
||
<el-radio :value="1">
|
||
或签
|
||
<el-tooltip placement="top" effect="dark">
|
||
<template #content>
|
||
<div class="mode-tip">
|
||
<div class="mode-tip-title">或签 (OR)</div>
|
||
<div>所选审批人任一人通过/驳回,即视为本节点完成;</div>
|
||
<div>其余待办任务将自动作废并推进至下一节点。</div>
|
||
</div>
|
||
</template>
|
||
<el-icon class="mode-tip-icon"><QuestionFilled /></el-icon>
|
||
</el-tooltip>
|
||
</el-radio>
|
||
<el-radio :value="2">
|
||
会签
|
||
<el-tooltip placement="top" effect="dark">
|
||
<template #content>
|
||
<div class="mode-tip">
|
||
<div class="mode-tip-title">会签 (AND)</div>
|
||
<div>所选审批人须全部通过,本节点才算完成;</div>
|
||
<div>任一人驳回则本节点立即驳回,流程回退。</div>
|
||
</div>
|
||
</template>
|
||
<el-icon class="mode-tip-icon"><QuestionFilled /></el-icon>
|
||
</el-tooltip>
|
||
</el-radio>
|
||
</el-radio-group>
|
||
</el-form-item>
|
||
<el-form-item label="允许退回">
|
||
<el-switch v-model="nodeForm.allowReturn" :active-value="1" :inactive-value="0" />
|
||
</el-form-item>
|
||
<el-form-item label="超时(小时)">
|
||
<el-input-number v-model="nodeForm.timeoutHours" :min="1" :max="720" />
|
||
</el-form-item>
|
||
</template>
|
||
<el-form-item v-else-if="nodeForm.nodeType === 4" label=" ">
|
||
<span class="hint">判断节点:拖出多条连线,点击连线分别填写分支条件(留空为默认分支)。运行期按条件命中其中一条分支。</span>
|
||
</el-form-item>
|
||
<template v-if="nodeForm.nodeType === 5">
|
||
<el-form-item label="通知类型" required>
|
||
<el-select
|
||
v-model="nodeForm.notifyTypes"
|
||
multiple
|
||
collapse-tags
|
||
style="width: 100%"
|
||
placeholder="选择通知类型(可多选)"
|
||
@change="onNotifyTypesChange"
|
||
>
|
||
<el-option v-for="t in approverTypes" :key="t.value" :label="t.label" :value="t.value" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item
|
||
v-for="t in selectedNotifyMetas"
|
||
:key="t.value"
|
||
:label="t.selectLabel || t.label"
|
||
required
|
||
>
|
||
<el-select v-model="nodeForm.notifyIdsMap[t.value]" multiple filterable style="width: 100%" :placeholder="'选择' + (t.selectLabel || t.label) + '(可多选)'">
|
||
<el-option v-for="opt in poolOf(t.poolKey)" :key="opt.id" :label="opt.name" :value="opt.id" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item v-if="!selectedNotifyMetas.length" label=" ">
|
||
<span class="hint">所选类型运行期自动定位通知对象,无需指定</span>
|
||
</el-form-item>
|
||
<el-form-item label=" ">
|
||
<span class="hint">通知节点不阻塞流程,流程自动继续往下走。可同时选择多种类型(如「发起人本人」+「指定用户」)。</span>
|
||
</el-form-item>
|
||
</template>
|
||
</el-form>
|
||
<template #footer>
|
||
<div class="node-dialog-footer">
|
||
<el-button type="danger" plain :disabled="!editingNodeId" @click="removeCurrentNode">删除节点</el-button>
|
||
<span class="footer-spacer"></span>
|
||
<el-button @click="nodeDialog = false">取消</el-button>
|
||
<el-button type="primary" @click="confirmNode">确定</el-button>
|
||
</div>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 连线条件对话框 -->
|
||
<el-dialog v-model="edgeDialog" title="设置分支条件" width="460px" append-to-body>
|
||
<el-form label-width="88px">
|
||
<el-form-item label="条件表达式">
|
||
<el-input v-model="edgeForm.condition" placeholder="如 amount>5000 且 dept=='研发' ;留空为默认分支" />
|
||
</el-form-item>
|
||
<el-form-item label=" ">
|
||
<span class="hint">支持 > >= < <= == != 与 && / ||。条件仅在“判断节点”的出边上生效。</span>
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button type="danger" plain @click="removeCurrentEdge">删除连线</el-button>
|
||
<span style="flex: 1"></span>
|
||
<el-button @click="edgeDialog = false">取消</el-button>
|
||
<el-button type="primary" @click="confirmEdge">确定</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, reactive, computed, watch, nextTick } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import { VideoPlay, User, Share, SwitchButton, QuestionFilled, Bell } from '@element-plus/icons-vue'
|
||
import { VueFlow, Handle, Position, MarkerType, 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'
|
||
|
||
const props = defineProps({
|
||
// { nodes: [payloadNode], edges: [payloadEdge] }
|
||
modelValue: { type: Object, default: () => ({ nodes: [], edges: [] }) },
|
||
// 审批人类型:[{ value, label, needsSelection, selectLabel, poolKey }]
|
||
approverTypes: { type: Array, default: () => [] },
|
||
// 候选池:{ [poolKey]: [{ id, name }] }
|
||
pools: { type: Object, default: () => ({}) },
|
||
})
|
||
|
||
const nodes = ref<any[]>([])
|
||
const edges = ref<any[]>([])
|
||
const canvasWrap = ref<HTMLElement | null>(null)
|
||
const { screenToFlowCoordinate, onConnect } = useVueFlow()
|
||
|
||
let keySeq = 1
|
||
const nextKey = () => `n${Date.now().toString(36)}${(keySeq++).toString(36)}`
|
||
|
||
const NODE_TYPE = { START: 1, APPROVAL: 2, END: 3, DECISION: 4, NOTIFY: 5 }
|
||
|
||
const palette = [
|
||
{ type: NODE_TYPE.START, cls: 'start', label: '开始', desc: '流程唯一入口', icon: VideoPlay },
|
||
{ type: NODE_TYPE.APPROVAL, cls: 'approval', label: '审批节点', desc: '指定审批人处理', icon: User },
|
||
{ type: NODE_TYPE.DECISION, cls: 'decision', label: '判断节点', desc: '按条件分支路由', icon: Share },
|
||
{ type: NODE_TYPE.NOTIFY, cls: 'notify', label: '通知节点', desc: '通知不阻塞流程', icon: Bell },
|
||
{ type: NODE_TYPE.END, cls: 'end', label: '结束', desc: '流程出口', icon: SwitchButton },
|
||
]
|
||
|
||
const clsOf = (t: number) =>
|
||
t === NODE_TYPE.START ? 'start' : t === NODE_TYPE.END ? 'end' : t === NODE_TYPE.DECISION ? 'decision' : t === NODE_TYPE.NOTIFY ? 'notify' : 'approval'
|
||
|
||
const typeShort = (t: number) =>
|
||
t === NODE_TYPE.START ? '开始' : t === NODE_TYPE.END ? '结束' : t === NODE_TYPE.DECISION ? '判断' : t === NODE_TYPE.NOTIFY ? '通知' : '审批'
|
||
|
||
const approverTypeLabel = (t: number) => {
|
||
const m = (props.approverTypes as any[]).find((x) => x.value === t)
|
||
return m ? m.label : '未知'
|
||
}
|
||
|
||
function approverSummary(node: any) {
|
||
if (node.nodeType === NODE_TYPE.APPROVAL) {
|
||
const meta = (props.approverTypes as any[]).find((x) => x.value === node.approverType)
|
||
if (!meta || !meta.needsSelection) return approverTypeLabel(node.approverType)
|
||
const pool = (props.pools as any)[meta.poolKey] || []
|
||
const names = (node.approverIds || []).map((id: number) => pool.find((p: any) => p.id === id)?.name).filter(Boolean)
|
||
const mode = node.multiMode === 2 ? '会签' : '或签'
|
||
return `${approverTypeLabel(node.approverType)}:${names.length ? names.join('、') : '未选'}(${mode})`
|
||
}
|
||
if (node.nodeType === NODE_TYPE.DECISION) return '按分支条件路由'
|
||
if (node.nodeType === NODE_TYPE.NOTIFY) {
|
||
const types: number[] = node.notifyTypes?.length ? node.notifyTypes : [node.approverType]
|
||
const parts = types.map((t) => {
|
||
const meta = (props.approverTypes as any[]).find((x) => x.value === t)
|
||
if (!meta) return '未知类型'
|
||
if (!meta.needsSelection) return meta.label
|
||
const pool = (props.pools as any)[meta.poolKey] || []
|
||
const ids = (node.notifyIdsMap || {})[t] || []
|
||
const names = ids.map((id: number) => pool.find((p: any) => p.id === id)?.name).filter(Boolean)
|
||
return `${meta.label}:${names.length ? names.join('、') : '未选'}`
|
||
})
|
||
return '通知 ' + parts.join(' + ')
|
||
}
|
||
return ''
|
||
}
|
||
|
||
// 由 payload 节点/边构建画布内部结构
|
||
function buildGraph(payload: any) {
|
||
const pNodes = payload?.nodes || []
|
||
const pEdges = payload?.edges || []
|
||
const ns = pNodes.map((n: any) => ({
|
||
id: n.node_key || nextKey(),
|
||
type: 'flow',
|
||
position: { x: n.pos_x || 0, y: n.pos_y || 0 },
|
||
data: makeNodeData(n),
|
||
}))
|
||
const es = pEdges
|
||
.filter((e: any) => e.from && e.to)
|
||
.map((e: any) => ({
|
||
id: `e_${e.from}_${e.to}`,
|
||
source: e.from,
|
||
target: e.to,
|
||
label: e.condition || '',
|
||
data: { condition: e.condition || '' },
|
||
markerEnd: { type: MarkerType.ArrowClosed },
|
||
animated: !!e.condition,
|
||
}))
|
||
return { ns, es }
|
||
}
|
||
|
||
function makeNodeData(n: any) {
|
||
const nodeType = n.node_type || n.nodeType || NODE_TYPE.APPROVAL
|
||
const data: any = {
|
||
nodeType,
|
||
cls: clsOf(nodeType),
|
||
name: n.node_name || n.name || '',
|
||
approverType: n.approver_type ?? n.approverType ?? 1,
|
||
approverIds: n.approver_ids ?? n.approverIds ?? [],
|
||
multiMode: n.multi_mode ?? n.multiMode ?? 1,
|
||
allowReturn: n.allow_return ?? n.allowReturn ?? 0,
|
||
timeoutHours: n.timeout_hours ?? n.timeoutHours ?? 48,
|
||
}
|
||
if (nodeType === NODE_TYPE.NOTIFY) {
|
||
applyNotifyData(data, n)
|
||
}
|
||
data.summary = approverSummary(data)
|
||
return data
|
||
}
|
||
|
||
// 通知节点:解析多类型存储 notify_targets: ["1:52","5:0"];兼容旧单类型格式
|
||
//(纯数字 approver_ids + approver_type),并统一归一化为 notifyTargets 字符串数组。
|
||
function applyNotifyData(data: any, n: any) {
|
||
const targets: string[] = n.notify_targets || n.notifyTargets || []
|
||
if (targets.length) {
|
||
const types: number[] = []
|
||
const map: Record<number, number[]> = {}
|
||
targets.forEach((s) => {
|
||
const [tStr, idStr] = String(s).split(':')
|
||
const t = Number(tStr)
|
||
if (!t) return
|
||
if (!map[t]) {
|
||
map[t] = []
|
||
types.push(t)
|
||
}
|
||
const id = Number(idStr)
|
||
if (id > 0) map[t].push(id)
|
||
})
|
||
data.notifyTargets = targets.map(String)
|
||
data.notifyTypes = types
|
||
data.notifyIdsMap = map
|
||
data.approverType = types[0] || data.approverType
|
||
} else {
|
||
const t = data.approverType
|
||
const ids: number[] = [...(data.approverIds || [])]
|
||
data.notifyTypes = [t]
|
||
data.notifyIdsMap = ids.length ? { [t]: ids } : {}
|
||
data.notifyTargets = ids.length ? ids.map((id: number) => `${t}:${id}`) : [`${t}:0`]
|
||
}
|
||
}
|
||
|
||
// 初始化 / 外部切换流程时重建
|
||
function reload() {
|
||
const { ns, es } = buildGraph(props.modelValue || {})
|
||
// 避免坐标全 0 重叠:无坐标时自动铺开
|
||
let autoX = 60
|
||
let autoY = 60
|
||
ns.forEach((n: any) => {
|
||
if (!n.position.x && !n.position.y) {
|
||
n.position = { x: autoX, y: autoY }
|
||
autoY += 140
|
||
}
|
||
})
|
||
nodes.value = ns
|
||
edges.value = es
|
||
void autoX
|
||
}
|
||
|
||
watch(() => props.modelValue, reload, { deep: false, immediate: true })
|
||
|
||
// ==================== 拖拽添加 ====================
|
||
let dragType = 0
|
||
let dragging = false
|
||
function onDragStart(_evt: DragEvent, p: any) {
|
||
dragType = p.type
|
||
dragging = true
|
||
}
|
||
|
||
function onDragEnd() {
|
||
// 延迟重置,避免拖拽后紧接的 click 事件误新增
|
||
setTimeout(() => { dragging = false; dragType = 0 }, 0)
|
||
}
|
||
|
||
function onDrop(evt: DragEvent) {
|
||
if (!dragType) return
|
||
const position = screenToFlowCoordinate({ x: evt.clientX, y: evt.clientY })
|
||
const data = makeNodeData({ node_type: dragType, node_name: defaultName(dragType) })
|
||
nodes.value.push({ id: nextKey(), type: 'flow', position, data })
|
||
dragType = 0
|
||
}
|
||
|
||
// 单击 palette 项:自动在画布中心附近新增节点(小量抖动避免重叠)
|
||
function onPaletteAdd(p: any) {
|
||
if (dragging) return
|
||
if (!canvasWrap.value) return
|
||
const rect = canvasWrap.value.getBoundingClientRect()
|
||
const base = screenToFlowCoordinate({
|
||
x: rect.left + rect.width / 2,
|
||
y: rect.top + rect.height / 2,
|
||
})
|
||
const jitter = (nodes.value.length % 6) * 26
|
||
const position = { x: base.x - 80 + jitter, y: base.y - 40 + jitter }
|
||
const data = makeNodeData({ node_type: p.type, node_name: defaultName(p.type) })
|
||
nodes.value.push({ id: nextKey(), type: 'flow', position, data })
|
||
}
|
||
|
||
function defaultName(t: number) {
|
||
return t === NODE_TYPE.START ? '开始' : t === NODE_TYPE.END ? '结束' : t === NODE_TYPE.DECISION ? '判断节点' : t === NODE_TYPE.NOTIFY ? '通知节点' : '审批节点'
|
||
}
|
||
|
||
// ==================== 连线 ====================
|
||
onConnect((params: any) => {
|
||
const exists = edges.value.some((e) => e.source === params.source && e.target === params.target)
|
||
if (exists) return
|
||
edges.value.push({
|
||
id: `e_${params.source}_${params.target}`,
|
||
source: params.source,
|
||
target: params.target,
|
||
label: '',
|
||
data: { condition: '' },
|
||
markerEnd: { type: MarkerType.ArrowClosed },
|
||
animated: false,
|
||
})
|
||
})
|
||
|
||
// ==================== 节点配置 ====================
|
||
const nodeDialog = ref(false)
|
||
const editingNodeId = ref('')
|
||
const nodeForm = reactive<any>({
|
||
name: '',
|
||
nodeType: NODE_TYPE.APPROVAL,
|
||
approverType: 1,
|
||
approverIds: [],
|
||
multiMode: 1,
|
||
allowReturn: 0,
|
||
timeoutHours: 48,
|
||
// 通知节点多类型:选中的类型 + 各类型对应的对象 ID
|
||
notifyTypes: [] as number[],
|
||
notifyIdsMap: {} as Record<number, number[]>,
|
||
})
|
||
|
||
const nodeDialogTitle = computed(() => {
|
||
if (nodeForm.nodeType === NODE_TYPE.DECISION) return '配置判断节点'
|
||
if (nodeForm.nodeType === NODE_TYPE.NOTIFY) return '配置通知节点'
|
||
if (nodeForm.nodeType === NODE_TYPE.APPROVAL) return '配置审批节点'
|
||
return '配置节点'
|
||
})
|
||
|
||
const currentApproverMeta = computed(() =>
|
||
(props.approverTypes as any[]).find((x) => x.value === nodeForm.approverType) || null
|
||
)
|
||
const currentPool = computed(() => {
|
||
const meta = currentApproverMeta.value
|
||
return meta ? (props.pools as any)[meta.poolKey] || [] : []
|
||
})
|
||
|
||
const poolOf = (poolKey: string) => (props.pools as any)[poolKey] || []
|
||
|
||
// 通知节点:已选类型中需要手动指定对象的元信息
|
||
const selectedNotifyMetas = computed(() =>
|
||
(props.approverTypes as any[]).filter(
|
||
(t) => (nodeForm.notifyTypes || []).includes(t.value) && t.needsSelection
|
||
)
|
||
)
|
||
|
||
function onNotifyTypesChange(vals: number[]) {
|
||
const keep = new Set<number>(vals)
|
||
Object.keys(nodeForm.notifyIdsMap).forEach((k) => {
|
||
if (!keep.has(Number(k))) delete nodeForm.notifyIdsMap[k]
|
||
})
|
||
}
|
||
|
||
function onNodeDblClick(evt: any) {
|
||
const node = evt.node
|
||
if (!node) return
|
||
editingNodeId.value = node.id
|
||
const d = node.data
|
||
const isNotify = d.nodeType === NODE_TYPE.NOTIFY
|
||
Object.assign(nodeForm, {
|
||
name: d.name,
|
||
nodeType: d.nodeType,
|
||
approverType: d.approverType,
|
||
approverIds: [...(d.approverIds || [])],
|
||
multiMode: d.multiMode,
|
||
allowReturn: d.allowReturn,
|
||
timeoutHours: d.timeoutHours,
|
||
notifyTypes: isNotify ? [...(d.notifyTypes || [d.approverType])] : [],
|
||
notifyIdsMap: isNotify ? JSON.parse(JSON.stringify(d.notifyIdsMap || {})) : {},
|
||
})
|
||
nodeDialog.value = true
|
||
}
|
||
|
||
function onApproverTypeChange() {
|
||
nodeForm.approverIds = []
|
||
}
|
||
|
||
function confirmNode() {
|
||
const node = nodes.value.find((n) => n.id === editingNodeId.value)
|
||
if (!node) {
|
||
nodeDialog.value = false
|
||
return
|
||
}
|
||
if (!String(nodeForm.name).trim()) {
|
||
ElMessage.warning('请填写节点名称')
|
||
return
|
||
}
|
||
// 通知节点:多类型 + 各类型对象,归一化为 "类型:ID" 列表
|
||
if (nodeForm.nodeType === NODE_TYPE.NOTIFY) {
|
||
if (!nodeForm.notifyTypes.length) {
|
||
ElMessage.warning('请选择通知类型')
|
||
return
|
||
}
|
||
for (const t of nodeForm.notifyTypes) {
|
||
const m = (props.approverTypes as any[]).find((x) => x.value === t)
|
||
if (m?.needsSelection && !(nodeForm.notifyIdsMap[t] || []).length) {
|
||
ElMessage.warning(`请选择「${m.label}」的通知对象`)
|
||
return
|
||
}
|
||
}
|
||
const targets: string[] = []
|
||
nodeForm.notifyTypes.forEach((t: number) => {
|
||
const ids = nodeForm.notifyIdsMap[t] || []
|
||
if (ids.length) {
|
||
ids.forEach((id: number) => targets.push(`${t}:${id}`))
|
||
} else {
|
||
targets.push(`${t}:0`)
|
||
}
|
||
})
|
||
node.data = makeNodeData({
|
||
node_type: NODE_TYPE.NOTIFY,
|
||
node_name: nodeForm.name,
|
||
notify_targets: targets,
|
||
approver_type: nodeForm.notifyTypes[0],
|
||
approver_ids: [],
|
||
multi_mode: nodeForm.multiMode,
|
||
allow_return: nodeForm.allowReturn,
|
||
timeout_hours: nodeForm.timeoutHours,
|
||
})
|
||
nodeDialog.value = false
|
||
return
|
||
}
|
||
const meta = currentApproverMeta.value
|
||
if (nodeForm.nodeType === NODE_TYPE.APPROVAL && meta && meta.needsSelection && !(nodeForm.approverIds || []).length) {
|
||
ElMessage.warning('请选择审批人')
|
||
return
|
||
}
|
||
node.data = makeNodeData({
|
||
node_type: nodeForm.nodeType,
|
||
node_name: nodeForm.name,
|
||
approver_type: nodeForm.approverType,
|
||
approver_ids: nodeForm.approverIds,
|
||
multi_mode: nodeForm.multiMode,
|
||
allow_return: nodeForm.allowReturn,
|
||
timeout_hours: nodeForm.timeoutHours,
|
||
})
|
||
nodeDialog.value = false
|
||
}
|
||
|
||
function removeCurrentNode() {
|
||
const id = editingNodeId.value
|
||
if (!id) return
|
||
nodes.value = nodes.value.filter((n) => n.id !== id)
|
||
edges.value = edges.value.filter((e) => e.source !== id && e.target !== id)
|
||
nodeDialog.value = false
|
||
}
|
||
|
||
// ==================== 连线条件 ====================
|
||
const edgeDialog = ref(false)
|
||
const editingEdgeId = ref('')
|
||
const edgeForm = reactive<any>({ condition: '' })
|
||
|
||
function onEdgeClick(evt: any) {
|
||
const edge = evt.edge
|
||
if (!edge) return
|
||
editingEdgeId.value = edge.id
|
||
edgeForm.condition = edge.data?.condition || ''
|
||
edgeDialog.value = true
|
||
}
|
||
|
||
function confirmEdge() {
|
||
const edge = edges.value.find((e) => e.id === editingEdgeId.value)
|
||
if (edge) {
|
||
edge.data = { condition: edgeForm.condition }
|
||
edge.label = edgeForm.condition || ''
|
||
edge.animated = !!edgeForm.condition
|
||
}
|
||
edgeDialog.value = false
|
||
}
|
||
|
||
function removeCurrentEdge() {
|
||
edges.value = edges.value.filter((e) => e.id !== editingEdgeId.value)
|
||
edgeDialog.value = false
|
||
}
|
||
|
||
// ==================== 对外序列化 ====================
|
||
function serialize() {
|
||
const outNodes = nodes.value.map((n: any, i: number) => {
|
||
const isNotify = n.data.nodeType === NODE_TYPE.NOTIFY
|
||
return {
|
||
node_key: n.id,
|
||
node_name: n.data.name,
|
||
node_type: n.data.nodeType,
|
||
approver_type: isNotify
|
||
? (n.data.notifyTypes?.length ? n.data.notifyTypes[0] : n.data.approverType)
|
||
: n.data.approverType,
|
||
approver_ids: isNotify ? [] : n.data.approverIds,
|
||
notify_targets: isNotify ? n.data.notifyTargets || [] : [],
|
||
multi_mode: n.data.multiMode,
|
||
allow_return: n.data.allowReturn,
|
||
timeout_hours: n.data.timeoutHours,
|
||
sort: i + 1,
|
||
pos_x: Math.round(n.position.x),
|
||
pos_y: Math.round(n.position.y),
|
||
}
|
||
})
|
||
const outEdges = edges.value.map((e: any) => ({
|
||
from: e.source,
|
||
to: e.target,
|
||
condition: e.data?.condition || '',
|
||
}))
|
||
return { nodes: outNodes, edges: outEdges }
|
||
}
|
||
|
||
async function fitView() {
|
||
await nextTick()
|
||
}
|
||
|
||
defineExpose({ serialize, fitView })
|
||
</script>
|
||
|
||
<style lang="less" scoped>
|
||
.flow-canvas {
|
||
display: flex;
|
||
height: 560px;
|
||
border: 1px solid #e4e7ed;
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.palette {
|
||
width: 220px;
|
||
flex: 0 0 220px;
|
||
padding: 14px;
|
||
background: #fafbfc;
|
||
border-right: 1px solid #e4e7ed;
|
||
overflow-y: auto;
|
||
|
||
.palette-title {
|
||
display: flex;
|
||
align-items: center;
|
||
font-weight: 600;
|
||
margin-bottom: 12px;
|
||
font-size: 14px;
|
||
color: #303133;
|
||
letter-spacing: 0.5px;
|
||
|
||
&::before {
|
||
content: '';
|
||
display: inline-block;
|
||
width: 3px;
|
||
height: 14px;
|
||
background: #409eff;
|
||
border-radius: 2px;
|
||
margin-right: 8px;
|
||
}
|
||
}
|
||
|
||
.palette-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
|
||
.palette-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 12px 14px;
|
||
min-height: 48px;
|
||
border-radius: 8px;
|
||
border: 1px solid #dcdfe6;
|
||
background: #fff;
|
||
cursor: grab;
|
||
font-size: 13px;
|
||
user-select: none;
|
||
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease, background 0.18s ease;
|
||
|
||
&:hover {
|
||
border-color: #409eff;
|
||
background: #f5faff;
|
||
box-shadow: 0 3px 10px rgba(64, 158, 255, 0.15);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
&:active {
|
||
cursor: grabbing;
|
||
transform: translateY(0);
|
||
box-shadow: 0 1px 4px rgba(64, 158, 255, 0.1);
|
||
}
|
||
|
||
.pal-shape-wrap {
|
||
flex: 0 0 26px;
|
||
width: 26px;
|
||
height: 26px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.pal-shape {
|
||
display: inline-block;
|
||
}
|
||
|
||
// 开始/结束:胶囊
|
||
.shape-start,
|
||
.shape-end {
|
||
width: 22px;
|
||
height: 11px;
|
||
border-radius: 999px;
|
||
background: #67c23a;
|
||
}
|
||
.shape-end {
|
||
background: #909399;
|
||
}
|
||
|
||
// 审批节点:矩形
|
||
.shape-approval {
|
||
width: 20px;
|
||
height: 15px;
|
||
border-radius: 3px;
|
||
background: #409eff;
|
||
}
|
||
|
||
// 判断节点:菱形(旋转方块)
|
||
.shape-decision {
|
||
width: 15px;
|
||
height: 15px;
|
||
transform: rotate(45deg);
|
||
border-radius: 2px;
|
||
background: #e6a23c;
|
||
}
|
||
|
||
// 通知节点:圆形
|
||
.shape-notify {
|
||
width: 17px;
|
||
height: 17px;
|
||
border-radius: 50%;
|
||
background: #9254de;
|
||
}
|
||
|
||
.pal-info {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
}
|
||
|
||
.pal-label {
|
||
font-weight: 600;
|
||
color: #303133;
|
||
font-size: 13.5px;
|
||
line-height: 1.3;
|
||
}
|
||
|
||
.pal-desc {
|
||
color: #909399;
|
||
font-size: 11.5px;
|
||
line-height: 1.3;
|
||
overflow: hidden;
|
||
white-space: nowrap;
|
||
text-overflow: ellipsis;
|
||
}
|
||
}
|
||
|
||
.pal-start { border-left: 3px solid #67c23a; }
|
||
.pal-approval { border-left: 3px solid #409eff; }
|
||
.pal-decision { border-left: 3px solid #e6a23c; }
|
||
.pal-notify { border-left: 3px solid #9254de; }
|
||
.pal-end { border-left: 3px solid #909399; }
|
||
|
||
.palette-tip {
|
||
margin-top: 14px;
|
||
padding: 10px 12px;
|
||
font-size: 11.5px;
|
||
color: #909399;
|
||
line-height: 1.7;
|
||
background: rgba(64, 158, 255, 0.05);
|
||
border: 1px dashed rgba(64, 158, 255, 0.2);
|
||
border-radius: 6px;
|
||
}
|
||
}
|
||
|
||
.canvas-wrap {
|
||
flex: 1;
|
||
position: relative;
|
||
background: #f7f8fb;
|
||
}
|
||
|
||
.fn {
|
||
min-width: 150px;
|
||
padding: 8px 12px;
|
||
border-radius: 8px;
|
||
background: #fff;
|
||
border: 1px solid #dcdfe6;
|
||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
|
||
font-size: 13px;
|
||
|
||
.fn-badge {
|
||
display: inline-block;
|
||
font-size: 11px;
|
||
padding: 0 6px;
|
||
border-radius: 8px;
|
||
color: #fff;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.fn-title {
|
||
font-weight: 600;
|
||
color: #303133;
|
||
}
|
||
|
||
.fn-sub {
|
||
margin-top: 2px;
|
||
color: #909399;
|
||
font-size: 12px;
|
||
max-width: 180px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
}
|
||
|
||
.fn-start { border-top: 3px solid #67c23a; .fn-badge { background: #67c23a; } }
|
||
.fn-approval { border-top: 3px solid #409eff; .fn-badge { background: #409eff; } }
|
||
.fn-end { border-top: 3px solid #909399; .fn-badge { background: #909399; } }
|
||
|
||
// 开始 / 结束:胶囊
|
||
.fn-pill {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
min-width: 90px;
|
||
padding: 8px 16px;
|
||
border-radius: 999px;
|
||
background: #fff;
|
||
border: 1px solid #dcdfe6;
|
||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: #303133;
|
||
|
||
.fn-pill-icon {
|
||
font-size: 15px;
|
||
}
|
||
}
|
||
.fn-pill-start {
|
||
border-color: #67c23a;
|
||
color: #529b2e;
|
||
background: #f0f9eb;
|
||
}
|
||
.fn-pill-end {
|
||
border-color: #909399;
|
||
color: #73767a;
|
||
background: #f4f4f5;
|
||
}
|
||
|
||
// 判断节点:菱形
|
||
.fn-diamond {
|
||
position: relative;
|
||
width: 120px;
|
||
height: 120px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
|
||
.fn-diamond-shape {
|
||
position: absolute;
|
||
inset: 18px;
|
||
transform: rotate(45deg);
|
||
background: #fdf6ec;
|
||
border: 2px solid #e6a23c;
|
||
border-radius: 8px;
|
||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
|
||
}
|
||
|
||
.fn-diamond-label {
|
||
position: relative;
|
||
z-index: 1;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: #b88230;
|
||
text-align: center;
|
||
max-width: 80px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
}
|
||
|
||
// 通知节点:圆形
|
||
.fn-circle {
|
||
position: relative;
|
||
width: 88px;
|
||
height: 88px;
|
||
border-radius: 50%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 2px;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
text-align: center;
|
||
background: #fff;
|
||
border: 2px solid transparent;
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||
}
|
||
.fn-circle-notify {
|
||
border-color: #d3adf7;
|
||
background: #f9f0ff;
|
||
color: #531dab;
|
||
}
|
||
.fn-circle-icon {
|
||
font-size: 16px;
|
||
}
|
||
.fn-circle-label {
|
||
max-width: 70px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
line-height: 1.2;
|
||
}
|
||
|
||
.hint {
|
||
font-size: 12px;
|
||
color: #909399;
|
||
}
|
||
</style>
|
||
|
||
<style lang="less">
|
||
/* 多人方式问号图标 + 气泡内容:因 el-dialog / el-tooltip 均 append-to-body,需非 scoped 样式 */
|
||
.el-radio .mode-tip-icon {
|
||
margin-left: 4px;
|
||
color: #909399;
|
||
cursor: help;
|
||
vertical-align: -2px;
|
||
transition: color 0.2s;
|
||
}
|
||
.el-radio .mode-tip-icon:hover {
|
||
color: #409eff;
|
||
}
|
||
.el-popper.is-dark .mode-tip {
|
||
font-size: 12px;
|
||
line-height: 1.7;
|
||
max-width: 260px;
|
||
}
|
||
.el-popper.is-dark .mode-tip .mode-tip-title {
|
||
font-weight: 600;
|
||
margin-bottom: 4px;
|
||
color: #ffd04b;
|
||
}
|
||
|
||
// 节点弹窗底部:删除靠左,取消/确定靠右
|
||
.node-dialog-footer {
|
||
display: flex;
|
||
align-items: center;
|
||
width: 100%;
|
||
|
||
.footer-spacer {
|
||
flex: 1;
|
||
}
|
||
}
|
||
</style>
|