优化审批流
This commit is contained in:
1 parent
ffaadc26ae
commit
438145cae7
17 files changed
+1131
-187
No files matched your search
@@ -3,19 +3,29 @@
|
||||
<!-- 左侧节点面板 -->
|
||||
<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 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>
|
||||
|
||||
@@ -31,7 +41,33 @@
|
||||
@edge-click="onEdgeClick"
|
||||
>
|
||||
<template #node-flow="nodeProps">
|
||||
<div class="fn" :class="'fn-' + nodeProps.data.cls">
|
||||
<!-- 判断节点:菱形 -->
|
||||
<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>
|
||||
@@ -105,8 +141,26 @@
|
||||
</el-form-item>
|
||||
</template>
|
||||
<el-form-item v-else-if="nodeForm.nodeType === 4" label=" ">
|
||||
<span class="hint">条件网关:拖出多条连线,点击连线分别填写分支条件(留空为默认分支)。</span>
|
||||
<span class="hint">判断节点:拖出多条连线,点击连线分别填写分支条件(留空为默认分支)。运行期按条件命中其中一条分支。</span>
|
||||
</el-form-item>
|
||||
<template v-if="nodeForm.nodeType === 5">
|
||||
<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 label=" ">
|
||||
<span class="hint">通知节点不阻塞流程,仅发送消息给指定人员,流程自动继续往下走。</span>
|
||||
</el-form-item>
|
||||
</template>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button type="danger" plain :disabled="!editingNodeId" @click="removeCurrentNode">删除节点</el-button>
|
||||
@@ -123,7 +177,7 @@
|
||||
<el-input v-model="edgeForm.condition" placeholder="如 amount>5000 且 dept=='研发' ;留空为默认分支" />
|
||||
</el-form-item>
|
||||
<el-form-item label=" ">
|
||||
<span class="hint">支持 > >= < <= == != 与 && / ||。条件仅在“条件网关”的出边上生效。</span>
|
||||
<span class="hint">支持 > >= < <= == != 与 && / ||。条件仅在“判断节点”的出边上生效。</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
@@ -139,7 +193,7 @@
|
||||
<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 { 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'
|
||||
@@ -164,20 +218,21 @@ 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 NODE_TYPE = { START: 1, APPROVAL: 2, END: 3, DECISION: 4, NOTIFY: 5 }
|
||||
|
||||
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 },
|
||||
{ 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.CONDITION ? 'condition' : 'approval'
|
||||
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.CONDITION ? '条件' : '审批'
|
||||
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)
|
||||
@@ -193,7 +248,8 @@ function approverSummary(node: any) {
|
||||
const mode = node.multiMode === 2 ? '会签' : '或签'
|
||||
return `${approverTypeLabel(node.approverType)}:${names.length ? names.join('、') : '未选'}(${mode})`
|
||||
}
|
||||
if (node.nodeType === NODE_TYPE.CONDITION) return '按分支条件路由'
|
||||
if (node.nodeType === NODE_TYPE.DECISION) return '按分支条件路由'
|
||||
if (node.nodeType === NODE_TYPE.NOTIFY) return '发送通知给指定人员'
|
||||
return ''
|
||||
}
|
||||
|
||||
@@ -258,8 +314,15 @@ 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) {
|
||||
@@ -270,8 +333,23 @@ function onDrop(evt: DragEvent) {
|
||||
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.CONDITION ? '条件网关' : '审批节点'
|
||||
return t === NODE_TYPE.START ? '开始' : t === NODE_TYPE.END ? '结束' : t === NODE_TYPE.DECISION ? '判断节点' : t === NODE_TYPE.NOTIFY ? '通知节点' : '审批节点'
|
||||
}
|
||||
|
||||
// ==================== 连线 ====================
|
||||
@@ -303,7 +381,8 @@ const nodeForm = reactive<any>({
|
||||
})
|
||||
|
||||
const nodeDialogTitle = computed(() => {
|
||||
if (nodeForm.nodeType === NODE_TYPE.CONDITION) return '配置条件网关'
|
||||
if (nodeForm.nodeType === NODE_TYPE.DECISION) return '配置判断节点'
|
||||
if (nodeForm.nodeType === NODE_TYPE.NOTIFY) return '配置通知节点'
|
||||
if (nodeForm.nodeType === NODE_TYPE.APPROVAL) return '配置审批节点'
|
||||
return '配置节点'
|
||||
})
|
||||
@@ -348,8 +427,8 @@ function confirmNode() {
|
||||
return
|
||||
}
|
||||
const meta = currentApproverMeta.value
|
||||
if (nodeForm.nodeType === NODE_TYPE.APPROVAL && meta && meta.needsSelection && !(nodeForm.approverIds || []).length) {
|
||||
ElMessage.warning('请选择审批人')
|
||||
if ((nodeForm.nodeType === NODE_TYPE.APPROVAL || nodeForm.nodeType === NODE_TYPE.NOTIFY) && meta && meta.needsSelection && !(nodeForm.approverIds || []).length) {
|
||||
ElMessage.warning(nodeForm.nodeType === NODE_TYPE.NOTIFY ? '请选择通知对象' : '请选择审批人')
|
||||
return
|
||||
}
|
||||
node.data = makeNodeData({
|
||||
@@ -440,49 +519,156 @@ defineExpose({ serialize, fitView })
|
||||
}
|
||||
|
||||
.palette {
|
||||
width: 160px;
|
||||
flex: 0 0 160px;
|
||||
padding: 12px;
|
||||
background: #fafafa;
|
||||
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: 10px;
|
||||
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: 6px;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 6px;
|
||||
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);
|
||||
}
|
||||
|
||||
.pal-icon {
|
||||
font-size: 15px;
|
||||
&: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-condition { border-left: 3px solid #e6a23c; }
|
||||
.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: 10px;
|
||||
font-size: 12px;
|
||||
margin-top: 14px;
|
||||
padding: 10px 12px;
|
||||
font-size: 11.5px;
|
||||
color: #909399;
|
||||
line-height: 1.5;
|
||||
line-height: 1.7;
|
||||
background: rgba(64, 158, 255, 0.05);
|
||||
border: 1px dashed rgba(64, 158, 255, 0.2);
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -528,9 +714,105 @@ defineExpose({ serialize, fitView })
|
||||
|
||||
.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; } }
|
||||
|
||||
// 开始 / 结束:胶囊
|
||||
.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;
|
||||
|
||||
Reference in new issue
Block a user