做双端审批流

This commit is contained in:
hero920103 committed 2026-10-07 13:46:32 +08:00
1 parent 5b4dbb2b46
commit ffaadc26ae
69 files changed
+6662 -1071

No files matched your search

@@ -0,0 +1,562 @@
<template>
<div class="flow-canvas">
<!-- 左侧节点面板 -->
<div class="palette">
<div class="palette-title">节点面板</div>
<div
v-for="p in palette"
:key="p.type"
class="palette-item"
:class="'pal-' + p.cls"
draggable="true"
@dragstart="onDragStart($event, p)"
>
<el-icon class="pal-icon"><component :is="p.icon" /></el-icon>
<span>{{ p.label }}</span>
</div>
<div class="palette-tip">
拖入画布添加节点;从节点右侧圆点拖到目标节点连线;双击节点配置;点击连线设置分支条件。
</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 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>
</el-form>
<template #footer>
<el-button type="danger" plain :disabled="!editingNodeId" @click="removeCurrentNode">删除节点</el-button>
<span style="flex: 1"></span>
<el-button @click="nodeDialog = false">取消</el-button>
<el-button type="primary" @click="confirmNode">确定</el-button>
</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">支持 &gt; &gt;= &lt; &lt;= == != 与 &amp;&amp; / ||。条件仅在“条件网关”的出边上生效。</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 } 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, CONDITION: 4 }
const palette = [
{ type: NODE_TYPE.START, cls: 'start', label: '开始', icon: VideoPlay },
{ type: NODE_TYPE.APPROVAL, cls: 'approval', label: '审批节点', icon: User },
{ type: NODE_TYPE.CONDITION, cls: 'condition', label: '条件网关', icon: Share },
{ type: NODE_TYPE.END, cls: 'end', label: '结束', icon: SwitchButton },
]
const clsOf = (t: number) =>
t === NODE_TYPE.START ? 'start' : t === NODE_TYPE.END ? 'end' : t === NODE_TYPE.CONDITION ? 'condition' : 'approval'
const typeShort = (t: number) =>
t === NODE_TYPE.START ? '开始' : t === NODE_TYPE.END ? '结束' : t === NODE_TYPE.CONDITION ? '条件' : '审批'
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.CONDITION) return '按分支条件路由'
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 = {
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,
}
data.summary = approverSummary(data)
return data
}
// 初始化 / 外部切换流程时重建
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
function onDragStart(_evt: DragEvent, p: any) {
dragType = p.type
}
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
}
function defaultName(t: number) {
return t === NODE_TYPE.START ? '开始' : t === NODE_TYPE.END ? '结束' : t === NODE_TYPE.CONDITION ? '条件网关' : '审批节点'
}
// ==================== 连线 ====================
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,
})
const nodeDialogTitle = computed(() => {
if (nodeForm.nodeType === NODE_TYPE.CONDITION) 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] || [] : []
})
function onNodeDblClick(evt: any) {
const node = evt.node
if (!node) return
editingNodeId.value = node.id
const d = node.data
Object.assign(nodeForm, {
name: d.name,
nodeType: d.nodeType,
approverType: d.approverType,
approverIds: [...(d.approverIds || [])],
multiMode: d.multiMode,
allowReturn: d.allowReturn,
timeoutHours: d.timeoutHours,
})
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
}
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) => ({
node_key: n.id,
node_name: n.data.name,
node_type: n.data.nodeType,
approver_type: n.data.approverType,
approver_ids: n.data.approverIds,
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: 160px;
flex: 0 0 160px;
padding: 12px;
background: #fafafa;
border-right: 1px solid #e4e7ed;
.palette-title {
font-weight: 600;
margin-bottom: 10px;
font-size: 14px;
}
.palette-item {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 10px;
margin-bottom: 8px;
border-radius: 6px;
border: 1px solid #dcdfe6;
background: #fff;
cursor: grab;
font-size: 13px;
&:hover {
border-color: #409eff;
}
.pal-icon {
font-size: 15px;
}
}
.pal-start { border-left: 3px solid #67c23a; }
.pal-approval { border-left: 3px solid #409eff; }
.pal-condition { border-left: 3px solid #e6a23c; }
.pal-end { border-left: 3px solid #909399; }
.palette-tip {
margin-top: 10px;
font-size: 12px;
color: #909399;
line-height: 1.5;
}
}
.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-condition { border-top: 3px solid #e6a23c; .fn-badge { background: #e6a23c; } }
.fn-end { border-top: 3px solid #909399; .fn-badge { background: #909399; } }
.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;
}
</style>