优化审批流
This commit is contained in:
1 parent
ffaadc26ae
commit
438145cae7
17 files changed
+1045
-101
No files matched your search
@@ -52,3 +52,8 @@ export function getApprovalFlowPick(params) {
|
|||||||
params,
|
params,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 业务侧发起前拉可用流(按当前用户部门过滤)
|
||||||
|
export function getAvailableFlows(params) {
|
||||||
|
return request({ url: '/backend/approval/flow/available', method: 'get', params });
|
||||||
|
}
|
||||||
@@ -3,19 +3,29 @@
|
|||||||
<!-- 左侧节点面板 -->
|
<!-- 左侧节点面板 -->
|
||||||
<div class="palette">
|
<div class="palette">
|
||||||
<div class="palette-title">节点面板</div>
|
<div class="palette-title">节点面板</div>
|
||||||
|
<div class="palette-list">
|
||||||
<div
|
<div
|
||||||
v-for="p in palette"
|
v-for="p in palette"
|
||||||
:key="p.type"
|
:key="p.type"
|
||||||
class="palette-item"
|
class="palette-item"
|
||||||
:class="'pal-' + p.cls"
|
:class="'pal-' + p.cls"
|
||||||
draggable="true"
|
draggable="true"
|
||||||
|
:title="'点击或拖拽添加「' + p.label + '」'"
|
||||||
@dragstart="onDragStart($event, p)"
|
@dragstart="onDragStart($event, p)"
|
||||||
|
@dragend="onDragEnd"
|
||||||
|
@click="onPaletteAdd(p)"
|
||||||
>
|
>
|
||||||
<el-icon class="pal-icon"><component :is="p.icon" /></el-icon>
|
<span class="pal-shape-wrap">
|
||||||
<span>{{ p.label }}</span>
|
<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>
|
||||||
<div class="palette-tip">
|
<div class="palette-tip">
|
||||||
拖入画布添加节点;从节点右侧圆点拖到目标节点连线;双击节点配置;点击连线设置分支条件。
|
点击或拖拽即可添加节点;<br />从右侧小圆点拖到目标节点连线;<br />双击节点配置 · 点击连线设条件。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -31,7 +41,33 @@
|
|||||||
@edge-click="onEdgeClick"
|
@edge-click="onEdgeClick"
|
||||||
>
|
>
|
||||||
<template #node-flow="nodeProps">
|
<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" />
|
<Handle type="target" :position="Position.Left" />
|
||||||
<div class="fn-badge">{{ typeShort(nodeProps.data.nodeType) }}</div>
|
<div class="fn-badge">{{ typeShort(nodeProps.data.nodeType) }}</div>
|
||||||
<div class="fn-title">{{ nodeProps.data.name || '未命名' }}</div>
|
<div class="fn-title">{{ nodeProps.data.name || '未命名' }}</div>
|
||||||
@@ -105,8 +141,26 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
</template>
|
</template>
|
||||||
<el-form-item v-else-if="nodeForm.nodeType === 4" label=" ">
|
<el-form-item v-else-if="nodeForm.nodeType === 4" label=" ">
|
||||||
<span class="hint">条件网关:拖出多条连线,点击连线分别填写分支条件(留空为默认分支)。</span>
|
<span class="hint">判断节点:拖出多条连线,点击连线分别填写分支条件(留空为默认分支)。运行期按条件命中其中一条分支。</span>
|
||||||
</el-form-item>
|
</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>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button type="danger" plain :disabled="!editingNodeId" @click="removeCurrentNode">删除节点</el-button>
|
<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-input v-model="edgeForm.condition" placeholder="如 amount>5000 且 dept=='研发' ;留空为默认分支" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label=" ">
|
<el-form-item label=" ">
|
||||||
<span class="hint">支持 > >= < <= == != 与 && / ||。条件仅在“条件网关”的出边上生效。</span>
|
<span class="hint">支持 > >= < <= == != 与 && / ||。条件仅在“判断节点”的出边上生效。</span>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
@@ -139,7 +193,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, computed, watch, nextTick } from 'vue'
|
import { ref, reactive, computed, watch, nextTick } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
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 { VueFlow, Handle, Position, MarkerType, useVueFlow } from '@vue-flow/core'
|
||||||
import { Background } from '@vue-flow/background'
|
import { Background } from '@vue-flow/background'
|
||||||
import { Controls } from '@vue-flow/controls'
|
import { Controls } from '@vue-flow/controls'
|
||||||
@@ -164,20 +218,21 @@ const { screenToFlowCoordinate, onConnect } = useVueFlow()
|
|||||||
let keySeq = 1
|
let keySeq = 1
|
||||||
const nextKey = () => `n${Date.now().toString(36)}${(keySeq++).toString(36)}`
|
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 = [
|
const palette = [
|
||||||
{ type: NODE_TYPE.START, cls: 'start', label: '开始', icon: VideoPlay },
|
{ type: NODE_TYPE.START, cls: 'start', label: '开始', desc: '流程唯一入口', icon: VideoPlay },
|
||||||
{ type: NODE_TYPE.APPROVAL, cls: 'approval', label: '审批节点', icon: User },
|
{ type: NODE_TYPE.APPROVAL, cls: 'approval', label: '审批节点', desc: '指定审批人处理', icon: User },
|
||||||
{ type: NODE_TYPE.CONDITION, cls: 'condition', label: '条件网关', icon: Share },
|
{ type: NODE_TYPE.DECISION, cls: 'decision', label: '判断节点', desc: '按条件分支路由', icon: Share },
|
||||||
{ type: NODE_TYPE.END, cls: 'end', label: '结束', icon: SwitchButton },
|
{ type: NODE_TYPE.NOTIFY, cls: 'notify', label: '通知节点', desc: '通知不阻塞流程', icon: Bell },
|
||||||
|
{ type: NODE_TYPE.END, cls: 'end', label: '结束', desc: '流程出口', icon: SwitchButton },
|
||||||
]
|
]
|
||||||
|
|
||||||
const clsOf = (t: number) =>
|
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) =>
|
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 approverTypeLabel = (t: number) => {
|
||||||
const m = (props.approverTypes as any[]).find((x) => x.value === t)
|
const m = (props.approverTypes as any[]).find((x) => x.value === t)
|
||||||
@@ -193,7 +248,8 @@ function approverSummary(node: any) {
|
|||||||
const mode = node.multiMode === 2 ? '会签' : '或签'
|
const mode = node.multiMode === 2 ? '会签' : '或签'
|
||||||
return `${approverTypeLabel(node.approverType)}:${names.length ? names.join('、') : '未选'}(${mode})`
|
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 ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,8 +314,15 @@ watch(() => props.modelValue, reload, { deep: false, immediate: true })
|
|||||||
|
|
||||||
// ==================== 拖拽添加 ====================
|
// ==================== 拖拽添加 ====================
|
||||||
let dragType = 0
|
let dragType = 0
|
||||||
|
let dragging = false
|
||||||
function onDragStart(_evt: DragEvent, p: any) {
|
function onDragStart(_evt: DragEvent, p: any) {
|
||||||
dragType = p.type
|
dragType = p.type
|
||||||
|
dragging = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragEnd() {
|
||||||
|
// 延迟重置,避免拖拽后紧接的 click 事件误新增
|
||||||
|
setTimeout(() => { dragging = false; dragType = 0 }, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDrop(evt: DragEvent) {
|
function onDrop(evt: DragEvent) {
|
||||||
@@ -270,8 +333,23 @@ function onDrop(evt: DragEvent) {
|
|||||||
dragType = 0
|
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) {
|
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(() => {
|
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 '配置审批节点'
|
if (nodeForm.nodeType === NODE_TYPE.APPROVAL) return '配置审批节点'
|
||||||
return '配置节点'
|
return '配置节点'
|
||||||
})
|
})
|
||||||
@@ -348,8 +427,8 @@ function confirmNode() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const meta = currentApproverMeta.value
|
const meta = currentApproverMeta.value
|
||||||
if (nodeForm.nodeType === NODE_TYPE.APPROVAL && meta && meta.needsSelection && !(nodeForm.approverIds || []).length) {
|
if ((nodeForm.nodeType === NODE_TYPE.APPROVAL || nodeForm.nodeType === NODE_TYPE.NOTIFY) && meta && meta.needsSelection && !(nodeForm.approverIds || []).length) {
|
||||||
ElMessage.warning('请选择审批人')
|
ElMessage.warning(nodeForm.nodeType === NODE_TYPE.NOTIFY ? '请选择通知对象' : '请选择审批人')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
node.data = makeNodeData({
|
node.data = makeNodeData({
|
||||||
@@ -440,49 +519,156 @@ defineExpose({ serialize, fitView })
|
|||||||
}
|
}
|
||||||
|
|
||||||
.palette {
|
.palette {
|
||||||
width: 160px;
|
width: 220px;
|
||||||
flex: 0 0 160px;
|
flex: 0 0 220px;
|
||||||
padding: 12px;
|
padding: 14px;
|
||||||
background: #fafafa;
|
background: #fafbfc;
|
||||||
border-right: 1px solid #e4e7ed;
|
border-right: 1px solid #e4e7ed;
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
.palette-title {
|
.palette-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 12px;
|
||||||
font-size: 14px;
|
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 {
|
.palette-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 12px;
|
||||||
padding: 8px 10px;
|
padding: 12px 14px;
|
||||||
margin-bottom: 8px;
|
min-height: 48px;
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
border: 1px solid #dcdfe6;
|
border: 1px solid #dcdfe6;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
font-size: 13px;
|
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 {
|
&:hover {
|
||||||
border-color: #409eff;
|
border-color: #409eff;
|
||||||
|
background: #f5faff;
|
||||||
|
box-shadow: 0 3px 10px rgba(64, 158, 255, 0.15);
|
||||||
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pal-icon {
|
&:active {
|
||||||
font-size: 15px;
|
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-start { border-left: 3px solid #67c23a; }
|
||||||
.pal-approval { border-left: 3px solid #409eff; }
|
.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; }
|
.pal-end { border-left: 3px solid #909399; }
|
||||||
|
|
||||||
.palette-tip {
|
.palette-tip {
|
||||||
margin-top: 10px;
|
margin-top: 14px;
|
||||||
font-size: 12px;
|
padding: 10px 12px;
|
||||||
|
font-size: 11.5px;
|
||||||
color: #909399;
|
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-start { border-top: 3px solid #67c23a; .fn-badge { background: #67c23a; } }
|
||||||
.fn-approval { border-top: 3px solid #409eff; .fn-badge { background: #409eff; } }
|
.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-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 {
|
.hint {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #909399;
|
color: #909399;
|
||||||
|
|||||||
@@ -33,7 +33,6 @@
|
|||||||
>
|
>
|
||||||
<div class="flow-name">
|
<div class="flow-name">
|
||||||
{{ f.flow_name }}
|
{{ f.flow_name }}
|
||||||
<el-tag v-if="f.is_default === 1" size="small" type="success">默认</el-tag>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flow-code">{{ f.flow_code || '(保存后生成编号)' }}</div>
|
<div class="flow-code">{{ f.flow_code || '(保存后生成编号)' }}</div>
|
||||||
<div class="flow-sub">
|
<div class="flow-sub">
|
||||||
@@ -75,18 +74,23 @@
|
|||||||
<el-input v-model="current.description" type="textarea" :rows="2" placeholder="流程用途说明" />
|
<el-input v-model="current.description" type="textarea" :rows="2" placeholder="流程用途说明" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-row :gutter="12">
|
<el-row :gutter="12">
|
||||||
<el-col :span="12">
|
|
||||||
<el-form-item label="设为默认">
|
|
||||||
<el-switch v-model="current.is_default" :active-value="1" :inactive-value="0" />
|
|
||||||
<span class="hint">同分类仅一条默认,新申请走默认流程</span>
|
|
||||||
</el-form-item>
|
|
||||||
</el-col>
|
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item label="启用状态">
|
<el-form-item label="启用状态">
|
||||||
<el-switch v-model="current.status" :active-value="1" :inactive-value="0" />
|
<el-switch v-model="current.status" :active-value="1" :inactive-value="0" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
|
<el-form-item label="适用部门">
|
||||||
|
<el-tree-select v-model="current.apply_dept_ids" :data="orgTree"
|
||||||
|
multiple show-checkbox check-strictly default-expand-all
|
||||||
|
:render-after-expand="false"
|
||||||
|
node-key="id" :props="{ label: 'name', children: 'children', value: 'id' }"
|
||||||
|
clearable filterable
|
||||||
|
@change="onDeptChange"
|
||||||
|
placeholder="留空表示全公司通用(选公司=全员;选部门=覆及子部门)"
|
||||||
|
style="width:100%" />
|
||||||
|
<!-- <div class="hint">祖先链命中:选中上级部门或公司时,其下子部门成员都会命中本流程</div> -->
|
||||||
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
<el-divider content-position="left">流程编排(拖拽节点 · 手动连线 · 条件分支)</el-divider>
|
<el-divider content-position="left">流程编排(拖拽节点 · 手动连线 · 条件分支)</el-divider>
|
||||||
@@ -119,6 +123,7 @@ import {
|
|||||||
saveApprovalFlow,
|
saveApprovalFlow,
|
||||||
deleteApprovalFlow
|
deleteApprovalFlow
|
||||||
} from '@/api/approval'
|
} from '@/api/approval'
|
||||||
|
import { buildOrgTree } from '@/views/apps/organization/composables'
|
||||||
|
|
||||||
const modules = ref<any[]>([])
|
const modules = ref<any[]>([])
|
||||||
const activeModule = ref<string>('')
|
const activeModule = ref<string>('')
|
||||||
@@ -127,7 +132,6 @@ const current = ref<any>(null)
|
|||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
|
|
||||||
const canvasRef = ref<any>(null)
|
const canvasRef = ref<any>(null)
|
||||||
// 传给画布的模型:{ nodes, edges }
|
|
||||||
const graphModel = ref<any>({ nodes: [], edges: [] })
|
const graphModel = ref<any>({ nodes: [], edges: [] })
|
||||||
|
|
||||||
const pools = reactive<{ users: any[]; roles: any[]; orgs: any[]; positions: any[] }>({
|
const pools = reactive<{ users: any[]; roles: any[]; orgs: any[]; positions: any[] }>({
|
||||||
@@ -137,6 +141,44 @@ const pools = reactive<{ users: any[]; roles: any[]; orgs: any[]; positions: any
|
|||||||
positions: []
|
positions: []
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 将平面部门列表拼成树(复用组织管理页的 buildOrgTree,内部做 Number 强转)
|
||||||
|
// 去除空 children 数组,避免 el-tree-select 叶节点渲染异常导致无法点击选中
|
||||||
|
function stripEmptyChildren(nodes: any[]): any[] {
|
||||||
|
return nodes.map((n) => {
|
||||||
|
const node = { ...n }
|
||||||
|
if (node.children && node.children.length > 0) {
|
||||||
|
node.children = stripEmptyChildren(node.children)
|
||||||
|
} else {
|
||||||
|
delete node.children
|
||||||
|
}
|
||||||
|
return node
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const orgTree = computed(() => stripEmptyChildren(buildOrgTree(pools.orgs)))
|
||||||
|
|
||||||
|
// 祖先剪枝:若某选中节点的上级(含公司)也已被勾选,则子节点冗余,自动剔除
|
||||||
|
function pruneRedundantDepts(ids: any[]): number[] {
|
||||||
|
if (!ids || !ids.length) return []
|
||||||
|
const numIds = ids.map((v) => Number(v))
|
||||||
|
const set = new Set(numIds)
|
||||||
|
const parentOf = new Map<number, number>()
|
||||||
|
pools.orgs.forEach((o: any) => parentOf.set(Number(o.id), Number(o.parent_id || 0)))
|
||||||
|
return numIds.filter((id) => {
|
||||||
|
let p = parentOf.get(id) || 0
|
||||||
|
const guard = new Set<number>([id])
|
||||||
|
while (p && parentOf.has(p) && !guard.has(p)) {
|
||||||
|
if (set.has(p)) return false // 祖先已选 → 冗余
|
||||||
|
guard.add(p)
|
||||||
|
p = parentOf.get(p) || 0
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDeptChange(ids: any[]) {
|
||||||
|
if (current.value) current.value.apply_dept_ids = pruneRedundantDepts(ids)
|
||||||
|
}
|
||||||
|
|
||||||
// 租户侧 6 种审批人类型
|
// 租户侧 6 种审批人类型
|
||||||
const approverTypes = [
|
const approverTypes = [
|
||||||
{ value: 1, label: '指定用户', needsSelection: true, selectLabel: '审批人', poolKey: 'users' },
|
{ value: 1, label: '指定用户', needsSelection: true, selectLabel: '审批人', poolKey: 'users' },
|
||||||
@@ -185,6 +227,8 @@ async function selectFlow(id: number) {
|
|||||||
const res: any = await getApprovalFlowDetail(id)
|
const res: any = await getApprovalFlowDetail(id)
|
||||||
if (res.code === 200) {
|
if (res.code === 200) {
|
||||||
const d = res.data || {}
|
const d = res.data || {}
|
||||||
|
// 回显时对历史冗余选择做一次剪枝
|
||||||
|
d.apply_dept_ids = pruneRedundantDepts(d.apply_dept_ids || [])
|
||||||
current.value = d
|
current.value = d
|
||||||
graphModel.value = { nodes: d.nodes || [], edges: d.edges || [] }
|
graphModel.value = { nodes: d.nodes || [], edges: d.edges || [] }
|
||||||
}
|
}
|
||||||
@@ -199,7 +243,8 @@ function openNewFlow() {
|
|||||||
flow_code: '',
|
flow_code: '',
|
||||||
description: '',
|
description: '',
|
||||||
is_default: 0,
|
is_default: 0,
|
||||||
status: 1
|
status: 1,
|
||||||
|
apply_dept_ids: [] as number[]
|
||||||
}
|
}
|
||||||
// 预置开始/结束,方便用户直接拖审批节点连线
|
// 预置开始/结束,方便用户直接拖审批节点连线
|
||||||
graphModel.value = {
|
graphModel.value = {
|
||||||
@@ -231,6 +276,7 @@ async function saveFlow() {
|
|||||||
description: current.value.description,
|
description: current.value.description,
|
||||||
is_default: current.value.is_default,
|
is_default: current.value.is_default,
|
||||||
status: current.value.status,
|
status: current.value.status,
|
||||||
|
apply_dept_ids: current.value.apply_dept_ids || [],
|
||||||
nodes: graph.nodes,
|
nodes: graph.nodes,
|
||||||
edges: graph.edges
|
edges: graph.edges
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"server/models"
|
"server/models"
|
||||||
"server/services"
|
"server/services"
|
||||||
|
approvalEngine "server/services/approval"
|
||||||
|
|
||||||
"github.com/beego/beego/v2/client/orm"
|
"github.com/beego/beego/v2/client/orm"
|
||||||
beego "github.com/beego/beego/v2/server/web"
|
beego "github.com/beego/beego/v2/server/web"
|
||||||
@@ -72,6 +73,7 @@ func (c *BackendApprovalController) FlowList() {
|
|||||||
Status: r.Status,
|
Status: r.Status,
|
||||||
NodeCount: int(cnt),
|
NodeCount: int(cnt),
|
||||||
CreateTime: r.CreateTime.Format("2006-01-02 15:04:05"),
|
CreateTime: r.CreateTime.Format("2006-01-02 15:04:05"),
|
||||||
|
ApplyDeptIDs: parseIDSlice(r.ApplyDeptIDs),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "ok", "data": list}
|
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "ok", "data": list}
|
||||||
@@ -115,6 +117,7 @@ func tenantFlowToView(f models.TenantApprovalFlow) approvalFlowView {
|
|||||||
Status: f.Status,
|
Status: f.Status,
|
||||||
NodeCount: len(nodes),
|
NodeCount: len(nodes),
|
||||||
CreateTime: f.CreateTime.Format("2006-01-02 15:04:05"),
|
CreateTime: f.CreateTime.Format("2006-01-02 15:04:05"),
|
||||||
|
ApplyDeptIDs: parseIDSlice(f.ApplyDeptIDs),
|
||||||
Nodes: make([]approvalNodePayload, 0, len(nodes)),
|
Nodes: make([]approvalNodePayload, 0, len(nodes)),
|
||||||
}
|
}
|
||||||
edgeNodes := make([]flowEdgeNode, 0, len(nodes))
|
edgeNodes := make([]flowEdgeNode, 0, len(nodes))
|
||||||
@@ -176,6 +179,18 @@ func (c *BackendApprovalController) FlowSave() {
|
|||||||
g := buildGraph(p.Nodes, p.Edges)
|
g := buildGraph(p.Nodes, p.Edges)
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
|
// 适用部门写入前校验:需全部属于当前租户(空列表=全公司通用,直接放行)
|
||||||
|
if len(p.ApplyDeptIDs) > 0 {
|
||||||
|
count, _ := models.Orm.QueryTable(new(models.BackendOrganization)).
|
||||||
|
Filter("tid", tid).Filter("id__in", p.ApplyDeptIDs).Filter("delete_time__isnull", true).
|
||||||
|
Count()
|
||||||
|
if int(count) != len(p.ApplyDeptIDs) {
|
||||||
|
jsonErr(&c.Controller, 400, 400, "适用部门包含非本租户组织,请重新选择")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
applyDeptCSV := joinIDs(p.ApplyDeptIDs)
|
||||||
|
|
||||||
o := models.Orm
|
o := models.Orm
|
||||||
var flowID uint64
|
var flowID uint64
|
||||||
var flowCode string
|
var flowCode string
|
||||||
@@ -196,6 +211,7 @@ func (c *BackendApprovalController) FlowSave() {
|
|||||||
"description": p.Description,
|
"description": p.Description,
|
||||||
"is_default": p.IsDefault,
|
"is_default": p.IsDefault,
|
||||||
"status": p.Status,
|
"status": p.Status,
|
||||||
|
"apply_dept_ids": applyDeptCSV,
|
||||||
"update_time": now,
|
"update_time": now,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -211,6 +227,7 @@ func (c *BackendApprovalController) FlowSave() {
|
|||||||
Description: p.Description,
|
Description: p.Description,
|
||||||
IsDefault: p.IsDefault,
|
IsDefault: p.IsDefault,
|
||||||
Status: p.Status,
|
Status: p.Status,
|
||||||
|
ApplyDeptIDs: applyDeptCSV,
|
||||||
CreateTime: now,
|
CreateTime: now,
|
||||||
}
|
}
|
||||||
id, err := o.Insert(&row)
|
id, err := o.Insert(&row)
|
||||||
@@ -305,6 +322,7 @@ func (c *BackendApprovalController) Pick() {
|
|||||||
list := make([]map[string]interface{}, 0, len(rows))
|
list := make([]map[string]interface{}, 0, len(rows))
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
list = append(list, map[string]interface{}{
|
list = append(list, map[string]interface{}{
|
||||||
|
"id": r.ID,
|
||||||
"module_code": r.ModuleCode,
|
"module_code": r.ModuleCode,
|
||||||
"flow_code": r.FlowCode,
|
"flow_code": r.FlowCode,
|
||||||
"flow_name": r.FlowName,
|
"flow_name": r.FlowName,
|
||||||
@@ -404,7 +422,10 @@ func (c *BackendApprovalController) Options() {
|
|||||||
OrderBy("sort", "id").All(&og)
|
OrderBy("sort", "id").All(&og)
|
||||||
orgList := make([]map[string]interface{}, 0, len(og))
|
orgList := make([]map[string]interface{}, 0, len(og))
|
||||||
for _, o := range og {
|
for _, o := range og {
|
||||||
orgList = append(orgList, map[string]interface{}{"id": o.ID, "name": o.OrgName})
|
orgList = append(orgList, map[string]interface{}{
|
||||||
|
"id": o.ID, "name": o.OrgName,
|
||||||
|
"parent_id": o.ParentID, "is_company": o.IsCompany,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
var ps []models.BackendPosition
|
var ps []models.BackendPosition
|
||||||
@@ -429,6 +450,38 @@ func (c *BackendApprovalController) Options() {
|
|||||||
_ = c.ServeJSON()
|
_ = c.ServeJSON()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AvailableFlows GET /backend/approval/flow/available?biz_type=reimburse
|
||||||
|
// 业务侧“发起前拉可用流”:基于当前登录用户的部门,过滤启用的流程。
|
||||||
|
// 前端基于返回结果判定:空则提示配置;一条自动带入;多条下拉选。
|
||||||
|
func (c *BackendApprovalController) AvailableFlows() {
|
||||||
|
claims, err := requireBackend(&c.Controller)
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(&c.Controller, 401, 401, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tid := uint64(claims.TenantId)
|
||||||
|
bizType := strings.TrimSpace(c.GetString("biz_type"))
|
||||||
|
if bizType == "" {
|
||||||
|
jsonErr(&c.Controller, 400, 400, "缺少 biz_type 参数")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 取当前登录用户的部门(未分配时 OrgID=0,仅命中“全公司通用”流)
|
||||||
|
var tu models.SystemTenantUser
|
||||||
|
var userOrgID uint64
|
||||||
|
if e := models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||||
|
Filter("tid", tid).Filter("uid", claims.UserID).Filter("delete_time__isnull", true).
|
||||||
|
One(&tu); e == nil {
|
||||||
|
userOrgID = tu.OrgID
|
||||||
|
}
|
||||||
|
list, err := approvalEngine.ListAvailableFlows(models.ApprovalScopeTenant, tid, bizType, userOrgID)
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(&c.Controller, 500, 500, "查询失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "ok", "data": list}
|
||||||
|
_ = c.ServeJSON()
|
||||||
|
}
|
||||||
|
|
||||||
// ============================ 辅助函数 ============================
|
// ============================ 辅助函数 ============================
|
||||||
|
|
||||||
// firstNonEmptyStr 取字符串指针/普通字符串中第一个非空值
|
// firstNonEmptyStr 取字符串指针/普通字符串中第一个非空值
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"server/models"
|
"server/models"
|
||||||
"server/services"
|
"server/services"
|
||||||
|
approvalEngine "server/services/approval"
|
||||||
|
|
||||||
"github.com/beego/beego/v2/client/orm"
|
"github.com/beego/beego/v2/client/orm"
|
||||||
beego "github.com/beego/beego/v2/server/web"
|
beego "github.com/beego/beego/v2/server/web"
|
||||||
@@ -26,12 +27,12 @@ type PlatformApprovalController struct {
|
|||||||
// ---- 配置侧 JSON 结构 ----
|
// ---- 配置侧 JSON 结构 ----
|
||||||
|
|
||||||
// approvalNodePayload 单个节点(前端画布节点)。
|
// approvalNodePayload 单个节点(前端画布节点)。
|
||||||
// node_type:1开始 2审批 3结束 4条件网关;pos_x/pos_y 为画布坐标回显;
|
// node_type:1开始 2审批 3结束 4判断 5通知;pos_x/pos_y 为画布坐标回显;
|
||||||
// condition_expr 为该节点作为「条件网关出边目标」时的进入条件(由 edges 反推落库)。
|
// condition_expr 为该节点作为「判断节点出边目标」时的进入条件(由 edges 反推落库)。
|
||||||
type approvalNodePayload struct {
|
type approvalNodePayload struct {
|
||||||
NodeKey string `json:"node_key"`
|
NodeKey string `json:"node_key"`
|
||||||
NodeName string `json:"node_name"`
|
NodeName string `json:"node_name"`
|
||||||
NodeType int8 `json:"node_type"` // 1开始 2审批 3结束 4条件网关
|
NodeType int8 `json:"node_type"` // 1开始 2审批 3结束 4判断 5通知
|
||||||
ApproverType int8 `json:"approver_type"` // 1指定用户 2指定角色 ...
|
ApproverType int8 `json:"approver_type"` // 1指定用户 2指定角色 ...
|
||||||
ApproverIDs []uint64 `json:"approver_ids"`
|
ApproverIDs []uint64 `json:"approver_ids"`
|
||||||
MultiMode int8 `json:"multi_mode"` // 1或签 2会签
|
MultiMode int8 `json:"multi_mode"` // 1或签 2会签
|
||||||
@@ -59,6 +60,7 @@ type approvalFlowSave struct {
|
|||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
IsDefault int8 `json:"is_default"`
|
IsDefault int8 `json:"is_default"`
|
||||||
Status int8 `json:"status"`
|
Status int8 `json:"status"`
|
||||||
|
ApplyDeptIDs []uint64 `json:"apply_dept_ids"` // 仅租户侧使用,空=全公司通用
|
||||||
Nodes []approvalNodePayload `json:"nodes"`
|
Nodes []approvalNodePayload `json:"nodes"`
|
||||||
Edges []approvalEdge `json:"edges"`
|
Edges []approvalEdge `json:"edges"`
|
||||||
}
|
}
|
||||||
@@ -75,6 +77,7 @@ type approvalFlowView struct {
|
|||||||
Status int8 `json:"status"`
|
Status int8 `json:"status"`
|
||||||
NodeCount int `json:"node_count"`
|
NodeCount int `json:"node_count"`
|
||||||
CreateTime string `json:"create_time"`
|
CreateTime string `json:"create_time"`
|
||||||
|
ApplyDeptIDs []uint64 `json:"apply_dept_ids"` // 仅租户侧回显
|
||||||
Nodes []approvalNodePayload `json:"nodes"`
|
Nodes []approvalNodePayload `json:"nodes"`
|
||||||
Edges []approvalEdge `json:"edges"`
|
Edges []approvalEdge `json:"edges"`
|
||||||
}
|
}
|
||||||
@@ -355,6 +358,7 @@ func (c *PlatformApprovalController) Pick() {
|
|||||||
list := make([]map[string]interface{}, 0, len(rows))
|
list := make([]map[string]interface{}, 0, len(rows))
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
list = append(list, map[string]interface{}{
|
list = append(list, map[string]interface{}{
|
||||||
|
"id": r.ID,
|
||||||
"module_code": r.ModuleCode,
|
"module_code": r.ModuleCode,
|
||||||
"biz_type": r.BizType,
|
"biz_type": r.BizType,
|
||||||
"flow_code": r.FlowCode,
|
"flow_code": r.FlowCode,
|
||||||
@@ -457,6 +461,28 @@ func (c *PlatformApprovalController) BizTypes() {
|
|||||||
_ = c.ServeJSON()
|
_ = c.ServeJSON()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AvailableFlows GET /platform/approval/flow/available?biz_type=domain
|
||||||
|
// 业务侧“发起前拉可用流”:返回当前 biz_type 启用的平台流程列表。
|
||||||
|
// 平台无部门概念,userOrgID 固定为 0(引擎内平台侧不做部门过滤)。
|
||||||
|
func (c *PlatformApprovalController) AvailableFlows() {
|
||||||
|
if _, err := requirePlatform(&c.Controller); err != nil {
|
||||||
|
jsonErr(&c.Controller, 401, 401, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bizType := strings.TrimSpace(c.GetString("biz_type"))
|
||||||
|
if bizType == "" {
|
||||||
|
jsonErr(&c.Controller, 400, 400, "缺少 biz_type 参数")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
list, err := approvalEngine.ListAvailableFlows(models.ApprovalScopePlatform, 0, bizType, 0)
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(&c.Controller, 500, 500, "查询失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "ok", "data": list}
|
||||||
|
_ = c.ServeJSON()
|
||||||
|
}
|
||||||
|
|
||||||
// ============================ 辅助函数 ============================
|
// ============================ 辅助函数 ============================
|
||||||
|
|
||||||
// approverDisplayName 优先姓名,回退账号(AdminUser.Account 为普通字符串)
|
// approverDisplayName 优先姓名,回退账号(AdminUser.Account 为普通字符串)
|
||||||
@@ -467,7 +493,7 @@ func approverDisplayName(name *string, account string) string {
|
|||||||
return strings.TrimSpace(account)
|
return strings.TrimSpace(account)
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateApprovalNodes 校验审批节点:必须选定审批人来源且非空(开始/结束/条件网关跳过)
|
// validateApprovalNodes 校验审批节点:必须选定审批人来源且非空(开始/结束/判断/通知跳过)
|
||||||
func validateApprovalNodes(nodes []approvalNodePayload) error {
|
func validateApprovalNodes(nodes []approvalNodePayload) error {
|
||||||
for i, nd := range nodes {
|
for i, nd := range nodes {
|
||||||
if nd.NodeType == models.ApprovalNodeStart || nd.NodeType == models.ApprovalNodeEnd ||
|
if nd.NodeType == models.ApprovalNodeStart || nd.NodeType == models.ApprovalNodeEnd ||
|
||||||
@@ -587,7 +613,7 @@ func rebuildEdges(nodes []flowEdgeNode) []approvalEdge {
|
|||||||
return edges
|
return edges
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateApprovalGraph 校验画布图的完整性:单一开始、审批节点≥1、网关出边≥2、全部可达、连线端点有效
|
// validateApprovalGraph 校验画布图的完整性:单一开始、审批节点≥1、判断节点出边≥2、全部可达、连线端点有效
|
||||||
func validateApprovalGraph(nodes []approvalNodePayload, edges []approvalEdge) error {
|
func validateApprovalGraph(nodes []approvalNodePayload, edges []approvalEdge) error {
|
||||||
if len(nodes) == 0 {
|
if len(nodes) == 0 {
|
||||||
return fmt.Errorf("流程至少需包含开始与审批节点")
|
return fmt.Errorf("流程至少需包含开始与审批节点")
|
||||||
@@ -632,7 +658,7 @@ func validateApprovalGraph(nodes []approvalNodePayload, edges []approvalEdge) er
|
|||||||
}
|
}
|
||||||
for _, k := range condKeys {
|
for _, k := range condKeys {
|
||||||
if len(nextAdj[k]) < 2 {
|
if len(nextAdj[k]) < 2 {
|
||||||
return fmt.Errorf("条件网关需至少两条分支连线")
|
return fmt.Errorf("判断节点需至少两条分支连线")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
visited := map[string]bool{}
|
visited := map[string]bool{}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- 审批流双层绑定重构 v2 - 增量迁移
|
||||||
|
--
|
||||||
|
-- 场景:上一版 create_approval_binding.sql 已建表但含 condition_expr/priority
|
||||||
|
-- 本次将绑定层简化为纯关联,流程表加 apply_dept_ids 部门作用域。
|
||||||
|
--
|
||||||
|
-- 执行顺序:
|
||||||
|
-- 1) DROP + 重建绑定表(结构变简,无法原地迁移)
|
||||||
|
-- 2) 后端流程表补 apply_dept_ids
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
-- 1. 绑定表:先删后建(如已有绑定数据请手工备份后再执行)
|
||||||
|
DROP TABLE IF EXISTS `yz_approval_binding`;
|
||||||
|
|
||||||
|
CREATE TABLE `yz_approval_binding` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`scope` TINYINT NOT NULL DEFAULT 1 COMMENT '1租户 2平台',
|
||||||
|
`tid` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '租户ID(平台侧为0)',
|
||||||
|
`biz_type` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '功能/单据类型:reimburse/bid/domain',
|
||||||
|
`flow_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '绑定的审批流ID',
|
||||||
|
`flow_code` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '审批流编号(冗余)',
|
||||||
|
`remark` VARCHAR(200) NOT NULL DEFAULT '' COMMENT '备注',
|
||||||
|
`is_deleted` TINYINT NOT NULL DEFAULT 0,
|
||||||
|
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`update_time` DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_scope_tid_biz_flow` (`scope`,`tid`,`biz_type`,`flow_id`),
|
||||||
|
KEY `idx_flow` (`flow_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审批流功能绑定(biz_type↔flow 多对多)';
|
||||||
|
|
||||||
|
-- 2. 后端流程表:补部门作用域列
|
||||||
|
ALTER TABLE `yz_backend_approval_flow`
|
||||||
|
ADD COLUMN `apply_dept_ids` VARCHAR(500) NOT NULL DEFAULT ''
|
||||||
|
COMMENT '适用部门ID列表 CSV,空=全公司通用' AFTER `flow_code`;
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- 审批流图编辑器 - 增量迁移(对已存在的旧表补列)
|
||||||
|
-- MySQL 兼容写法:如列已存在会报错,跳过该条即可
|
||||||
|
-- 如果表本身不存在,请先执行 create_approval_flow.sql
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
-- 1. 租户侧流程表:补 flow_code
|
||||||
|
ALTER TABLE `yz_backend_approval_flow`
|
||||||
|
ADD COLUMN `flow_code` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '审批流编号(自动生成 FL%06d)' AFTER `biz_type`;
|
||||||
|
|
||||||
|
ALTER TABLE `yz_backend_approval_flow`
|
||||||
|
ADD INDEX `idx_tid_code` (`tid`, `flow_code`);
|
||||||
|
|
||||||
|
-- 2. 租户侧节点表:补 pos_x / pos_y
|
||||||
|
ALTER TABLE `yz_backend_approval_node`
|
||||||
|
ADD COLUMN `pos_x` INT NOT NULL DEFAULT 0 COMMENT '画布X坐标' AFTER `sort`,
|
||||||
|
ADD COLUMN `pos_y` INT NOT NULL DEFAULT 0 COMMENT '画布Y坐标' AFTER `pos_x`;
|
||||||
|
|
||||||
|
-- 3. 平台侧流程表:补 flow_code
|
||||||
|
ALTER TABLE `yz_platform_approval_flow`
|
||||||
|
ADD COLUMN `flow_code` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '审批流编号(自动生成 FL%06d)' AFTER `biz_type`;
|
||||||
|
|
||||||
|
ALTER TABLE `yz_platform_approval_flow`
|
||||||
|
ADD INDEX `idx_code` (`flow_code`);
|
||||||
|
|
||||||
|
-- 4. 平台侧节点表:补 pos_x / pos_y
|
||||||
|
ALTER TABLE `yz_platform_approval_node`
|
||||||
|
ADD COLUMN `pos_x` INT NOT NULL DEFAULT 0 COMMENT '画布X坐标' AFTER `sort`,
|
||||||
|
ADD COLUMN `pos_y` INT NOT NULL DEFAULT 0 COMMENT '画布Y坐标' AFTER `pos_x`;
|
||||||
|
|
||||||
|
-- 5. 实例表:补 form_data(条件求值用表单快照 JSON)
|
||||||
|
ALTER TABLE `yz_approval_instance`
|
||||||
|
ADD COLUMN `form_data` TEXT NULL COMMENT '发起表单数据快照(JSON)' AFTER `submit_dept_id`;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- 审批流功能绑定(biz_type ↔ flow 多对多关联)
|
||||||
|
--
|
||||||
|
-- 对应模型:go/models/approval.go ApprovalBinding
|
||||||
|
-- 用途:给某个功能(biz_type)绑定"可选审批流"清单,业务代码通过
|
||||||
|
-- ListAvailableFlows(scope, tid, bizType, userOrgID) 拿到过滤后的
|
||||||
|
-- 可用流;若一条流程设置了 apply_dept_ids,则仅该部门成员可见。
|
||||||
|
--
|
||||||
|
-- 注意:本表**不含条件表达式 / 优先级**——条件分支在流程内部(条件网关)
|
||||||
|
-- 实现,与绑定层无关。
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `yz_approval_binding` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`scope` TINYINT NOT NULL DEFAULT 1 COMMENT '1租户 2平台',
|
||||||
|
`tid` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '租户ID(平台侧为0)',
|
||||||
|
`biz_type` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '功能/单据类型:reimburse/bid/domain',
|
||||||
|
`flow_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '绑定的审批流ID',
|
||||||
|
`flow_code` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '审批流编号(冗余,方便查询)',
|
||||||
|
`remark` VARCHAR(200) NOT NULL DEFAULT '' COMMENT '备注',
|
||||||
|
`is_deleted` TINYINT NOT NULL DEFAULT 0,
|
||||||
|
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`update_time` DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_scope_tid_biz_flow` (`scope`,`tid`,`biz_type`,`flow_id`),
|
||||||
|
KEY `idx_flow` (`flow_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审批流功能绑定(biz_type↔flow 多对多)';
|
||||||
@@ -24,6 +24,7 @@ CREATE TABLE IF NOT EXISTS `yz_backend_approval_flow` (
|
|||||||
`module_code` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '所属功能模块 code(yz_system_modules.code):oa/erp/crm/cms',
|
`module_code` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '所属功能模块 code(yz_system_modules.code):oa/erp/crm/cms',
|
||||||
`biz_type` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '适用单据类型:reimburse/bid/domain/...',
|
`biz_type` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '适用单据类型:reimburse/bid/domain/...',
|
||||||
`flow_code` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '审批流编号(自动生成、全局唯一,供各程序按编号发起):FL000007',
|
`flow_code` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '审批流编号(自动生成、全局唯一,供各程序按编号发起):FL000007',
|
||||||
|
`apply_dept_ids` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '适用部门ID列表 CSV(yz_backend_organization.id),空=全公司通用',
|
||||||
`flow_name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '流程名称',
|
`flow_name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '流程名称',
|
||||||
`description` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '流程说明',
|
`description` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '流程说明',
|
||||||
`is_default` TINYINT NOT NULL DEFAULT 0 COMMENT '是否该单据类型的默认流程:1是 0否',
|
`is_default` TINYINT NOT NULL DEFAULT 0 COMMENT '是否该单据类型的默认流程:1是 0否',
|
||||||
|
|||||||
+23
-1
@@ -22,7 +22,8 @@ const (
|
|||||||
ApprovalNodeStart = 1 // 开始
|
ApprovalNodeStart = 1 // 开始
|
||||||
ApprovalNodeApproval = 2 // 审批
|
ApprovalNodeApproval = 2 // 审批
|
||||||
ApprovalNodeEnd = 3 // 结束
|
ApprovalNodeEnd = 3 // 结束
|
||||||
ApprovalNodeCondition = 4 // 条件分支(预留)
|
ApprovalNodeCondition = 4 // 判断节点
|
||||||
|
ApprovalNodeNotify = 5 // 通知节点
|
||||||
)
|
)
|
||||||
|
|
||||||
// 审批人类型
|
// 审批人类型
|
||||||
@@ -57,6 +58,7 @@ type TenantApprovalFlow struct {
|
|||||||
ModuleCode string `orm:"column(module_code);size(50);default()" json:"module_code"`
|
ModuleCode string `orm:"column(module_code);size(50);default()" json:"module_code"`
|
||||||
BizType string `orm:"column(biz_type);size(50);default()" json:"biz_type"`
|
BizType string `orm:"column(biz_type);size(50);default()" json:"biz_type"`
|
||||||
FlowCode string `orm:"column(flow_code);size(64);default()" json:"flow_code"`
|
FlowCode string `orm:"column(flow_code);size(64);default()" json:"flow_code"`
|
||||||
|
ApplyDeptIDs string `orm:"column(apply_dept_ids);size(500);default()" json:"apply_dept_ids"`
|
||||||
FlowName string `orm:"column(flow_name);size(100);default()" json:"flow_name"`
|
FlowName string `orm:"column(flow_name);size(100);default()" json:"flow_name"`
|
||||||
Description string `orm:"column(description);size(500);default()" json:"description"`
|
Description string `orm:"column(description);size(500);default()" json:"description"`
|
||||||
IsDefault int8 `orm:"column(is_default);default(0)" json:"is_default"`
|
IsDefault int8 `orm:"column(is_default);default(0)" json:"is_default"`
|
||||||
@@ -172,6 +174,7 @@ const (
|
|||||||
ApprovalTaskReturned = 3 // 已退回
|
ApprovalTaskReturned = 3 // 已退回
|
||||||
ApprovalTaskSkipped = 4 // 已跳过
|
ApprovalTaskSkipped = 4 // 已跳过
|
||||||
ApprovalTaskTimeout = 5 // 已超时
|
ApprovalTaskTimeout = 5 // 已超时
|
||||||
|
ApprovalTaskNotified = 6 // 已通知
|
||||||
)
|
)
|
||||||
|
|
||||||
// 任务动作
|
// 任务动作
|
||||||
@@ -244,3 +247,22 @@ type ApprovalTask struct {
|
|||||||
func (m *ApprovalTask) TableName() string {
|
func (m *ApprovalTask) TableName() string {
|
||||||
return "yz_approval_task"
|
return "yz_approval_task"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ApprovalBinding 审批流功能绑定 yz_approval_binding
|
||||||
|
// 纯关联表:一个 biz_type 可绑多条流程;条件分支在流程内部(条件网关)实现,本表不包含条件字段。
|
||||||
|
type ApprovalBinding struct {
|
||||||
|
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||||
|
Scope int8 `orm:"column(scope);default(1)" json:"scope"`
|
||||||
|
Tid uint64 `orm:"column(tid);default(0)" json:"tid"`
|
||||||
|
BizType string `orm:"column(biz_type);size(50);default()" json:"biz_type"`
|
||||||
|
FlowID uint64 `orm:"column(flow_id);default(0)" json:"flow_id"`
|
||||||
|
FlowCode string `orm:"column(flow_code);size(64);default()" json:"flow_code"`
|
||||||
|
Remark string `orm:"column(remark);size(200);default()" json:"remark"`
|
||||||
|
IsDeleted int8 `orm:"column(is_deleted);default(0)" json:"is_deleted"`
|
||||||
|
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
|
||||||
|
UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ApprovalBinding) TableName() string {
|
||||||
|
return "yz_approval_binding"
|
||||||
|
}
|
||||||
@@ -57,6 +57,8 @@ func Init(_ string) {
|
|||||||
new(PlatformApprovalNode),
|
new(PlatformApprovalNode),
|
||||||
new(ApprovalInstance),
|
new(ApprovalInstance),
|
||||||
new(ApprovalTask),
|
new(ApprovalTask),
|
||||||
|
// 审批流功能绑定(docs/sql/create_approval_binding.sql)
|
||||||
|
new(ApprovalBinding),
|
||||||
new(BackendReimbursement),
|
new(BackendReimbursement),
|
||||||
new(BackendReimbursementItem),
|
new(BackendReimbursementItem),
|
||||||
new(BackendReimbursementExpenseType),
|
new(BackendReimbursementExpenseType),
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ func RegisterApprovalRoutes() {
|
|||||||
beego.Router("/backend/approval/flow/save", &controllers.BackendApprovalController{}, "post:FlowSave")
|
beego.Router("/backend/approval/flow/save", &controllers.BackendApprovalController{}, "post:FlowSave")
|
||||||
beego.Router("/backend/approval/flow/delete/:id", &controllers.BackendApprovalController{}, "delete:FlowDelete")
|
beego.Router("/backend/approval/flow/delete/:id", &controllers.BackendApprovalController{}, "delete:FlowDelete")
|
||||||
beego.Router("/backend/approval/flow/pick", &controllers.BackendApprovalController{}, "get:Pick")
|
beego.Router("/backend/approval/flow/pick", &controllers.BackendApprovalController{}, "get:Pick")
|
||||||
|
beego.Router("/backend/approval/flow/available", &controllers.BackendApprovalController{}, "get:AvailableFlows")
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterProductRoutes 注册租户端「产品购买」路由。
|
// RegisterProductRoutes 注册租户端「产品购买」路由。
|
||||||
|
|||||||
@@ -178,6 +178,7 @@ func Register() {
|
|||||||
beego.Router("/platform/approval/options", &controllers.PlatformApprovalController{}, "get:Options")
|
beego.Router("/platform/approval/options", &controllers.PlatformApprovalController{}, "get:Options")
|
||||||
beego.Router("/platform/approval/biz-types", &controllers.PlatformApprovalController{}, "get:BizTypes")
|
beego.Router("/platform/approval/biz-types", &controllers.PlatformApprovalController{}, "get:BizTypes")
|
||||||
beego.Router("/platform/approval/flow/pick", &controllers.PlatformApprovalController{}, "get:Pick")
|
beego.Router("/platform/approval/flow/pick", &controllers.PlatformApprovalController{}, "get:Pick")
|
||||||
|
beego.Router("/platform/approval/flow/available", &controllers.PlatformApprovalController{}, "get:AvailableFlows")
|
||||||
|
|
||||||
// 模块管理(yz_system_modules)
|
// 模块管理(yz_system_modules)
|
||||||
beego.Router("/platform/modules/list", &controllers.PlatformModulesController{}, "get:GetList")
|
beego.Router("/platform/modules/list", &controllers.PlatformModulesController{}, "get:GetList")
|
||||||
|
|||||||
@@ -568,6 +568,99 @@ func dedupeApprovers(in []Approver) []Approver {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================ 运行时:发起前的可用流查询 ============================
|
||||||
|
|
||||||
|
// AvailableFlow 业务侧选择用的轻量结构
|
||||||
|
type AvailableFlow struct {
|
||||||
|
FlowID uint64 `json:"flow_id"`
|
||||||
|
FlowCode string `json:"flow_code"`
|
||||||
|
FlowName string `json:"flow_name"`
|
||||||
|
BizType string `json:"biz_type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAvailableFlows 按 bizType + 用户部门 过滤“当前可用”审批流。
|
||||||
|
// 直接查询 flow 表中 biz_type 匹配且 status=1 未删除的流程。
|
||||||
|
// 租户侧额外按 apply_dept_ids 部门过滤。
|
||||||
|
// 平台侧(scope=2)忽略部门过滤(因平台 flow 无 apply_dept_ids)。
|
||||||
|
func ListAvailableFlows(scope int8, tid uint64, bizType string, userOrgID uint64) ([]AvailableFlow, error) {
|
||||||
|
// 租户侧预计算一次当前用户组织的“祖先链”(含自身),选中上级部门或公司均命中
|
||||||
|
var path []uint64
|
||||||
|
if scope == models.ApprovalScopeTenant && userOrgID > 0 {
|
||||||
|
path = buildOrgPath(tid, userOrgID)
|
||||||
|
}
|
||||||
|
out := make([]AvailableFlow, 0)
|
||||||
|
if scope == models.ApprovalScopePlatform {
|
||||||
|
var rows []models.PlatformApprovalFlow
|
||||||
|
_, _ = models.Orm.QueryTable(new(models.PlatformApprovalFlow)).
|
||||||
|
Filter("biz_type", bizType).Filter("is_deleted", 0).Filter("status", 1).
|
||||||
|
OrderBy("id").All(&rows)
|
||||||
|
for _, f := range rows {
|
||||||
|
out = append(out, AvailableFlow{FlowID: f.ID, FlowCode: f.FlowCode, FlowName: f.FlowName, BizType: f.BizType})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
var rows []models.TenantApprovalFlow
|
||||||
|
_, _ = models.Orm.QueryTable(new(models.TenantApprovalFlow)).
|
||||||
|
Filter("tid", tid).Filter("biz_type", bizType).Filter("is_deleted", 0).Filter("status", 1).
|
||||||
|
OrderBy("id").All(&rows)
|
||||||
|
for _, f := range rows {
|
||||||
|
if !deptMatch(f.ApplyDeptIDs, path) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, AvailableFlow{FlowID: f.ID, FlowCode: f.FlowCode, FlowName: f.FlowName, BizType: f.BizType})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// deptMatch csv 空视为全公司通用;否则与 path (包含自身与祖先部门ID) 任一命中即命中。
|
||||||
|
func deptMatch(csv string, path []uint64) bool {
|
||||||
|
csv = strings.TrimSpace(csv)
|
||||||
|
if csv == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if len(path) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
inPath := make(map[uint64]bool, len(path))
|
||||||
|
for _, id := range path {
|
||||||
|
inPath[id] = true
|
||||||
|
}
|
||||||
|
for _, part := range strings.Split(csv, ",") {
|
||||||
|
if v, err := strconv.ParseUint(strings.TrimSpace(part), 10, 64); err == nil && inPath[v] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildOrgPath 从当前部门沿 parent_id 向上追到根,返回含自身的 ID 链(环保护)
|
||||||
|
func buildOrgPath(tid, orgID uint64) []uint64 {
|
||||||
|
if orgID == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var all []models.BackendOrganization
|
||||||
|
_, _ = models.Orm.QueryTable(new(models.BackendOrganization)).
|
||||||
|
Filter("tid", tid).Filter("delete_time__isnull", true).
|
||||||
|
All(&all, "id", "parent_id")
|
||||||
|
parent := make(map[uint64]uint64, len(all))
|
||||||
|
for _, o := range all {
|
||||||
|
parent[o.ID] = o.ParentID
|
||||||
|
}
|
||||||
|
out := []uint64{orgID}
|
||||||
|
seen := map[uint64]bool{orgID: true}
|
||||||
|
cur := orgID
|
||||||
|
for {
|
||||||
|
p, ok := parent[cur]
|
||||||
|
if !ok || p == 0 || seen[p] {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
out = append(out, p)
|
||||||
|
seen[p] = true
|
||||||
|
cur = p
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// ============================ 运行时:提交 ============================
|
// ============================ 运行时:提交 ============================
|
||||||
|
|
||||||
// Submit 提交单据进入审批,创建实例并按图展开首个可执行节点的任务。
|
// Submit 提交单据进入审批,创建实例并按图展开首个可执行节点的任务。
|
||||||
@@ -614,7 +707,8 @@ func Submit(in SubmitInput) (uint64, error) {
|
|||||||
|
|
||||||
start := flow.ByKey[flow.StartKey]
|
start := flow.ByKey[flow.StartKey]
|
||||||
hasApproval, firstApprovalName, firstApprovalKey := anyApprovalNode(flow)
|
hasApproval, firstApprovalName, firstApprovalKey := anyApprovalNode(flow)
|
||||||
node, approvers := nextExecutable(flow, in.FormData, start, in)
|
notifyFn := func(n *Node) { fireNotifyTasks(uint64(instID), in, n) }
|
||||||
|
node, approvers := nextExecutable(flow, in.FormData, start, in, notifyFn)
|
||||||
if node == nil {
|
if node == nil {
|
||||||
// 无可执行审批节点
|
// 无可执行审批节点
|
||||||
if hasApproval {
|
if hasApproval {
|
||||||
@@ -647,8 +741,9 @@ func anyApprovalNode(flow *Flow) (bool, string, string) {
|
|||||||
return false, "", ""
|
return false, "", ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// advanceFrom 从 cur 沿出边前进,遇条件网关求值选路,返回首个审批节点(nil 表示到终点)
|
// advanceFrom 从 cur 沿出边前进,遇条件网关求值选路,返回首个审批节点(nil 表示到终点)。
|
||||||
func advanceFrom(flow *Flow, formData map[string]interface{}, cur *Node) *Node {
|
// onNotify 可选回调:当路径经过通知节点时触发,由调用方负责创建通知任务。
|
||||||
|
func advanceFrom(flow *Flow, formData map[string]interface{}, cur *Node, onNotify ...func(*Node)) *Node {
|
||||||
visited := make(map[string]bool)
|
visited := make(map[string]bool)
|
||||||
node := pickNext(flow, formData, cur)
|
node := pickNext(flow, formData, cur)
|
||||||
for node != nil {
|
for node != nil {
|
||||||
@@ -661,6 +756,11 @@ func advanceFrom(flow *Flow, formData map[string]interface{}, cur *Node) *Node {
|
|||||||
return node
|
return node
|
||||||
case models.ApprovalNodeEnd:
|
case models.ApprovalNodeEnd:
|
||||||
return nil
|
return nil
|
||||||
|
case models.ApprovalNodeNotify:
|
||||||
|
if len(onNotify) > 0 && onNotify[0] != nil {
|
||||||
|
onNotify[0](node)
|
||||||
|
}
|
||||||
|
node = pickNext(flow, formData, node)
|
||||||
default: // start / condition:继续向后
|
default: // start / condition:继续向后
|
||||||
node = pickNext(flow, formData, node)
|
node = pickNext(flow, formData, node)
|
||||||
}
|
}
|
||||||
@@ -700,8 +800,9 @@ func pickNext(flow *Flow, formData map[string]interface{}, node *Node) *Node {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// nextExecutable 沿图找到下一个「有有效审批人」的审批节点;无人审批节点自动跳过
|
// nextExecutable 沿图找到下一个「有有效审批人」的审批节点;无人审批节点自动跳过
|
||||||
func nextExecutable(flow *Flow, formData map[string]interface{}, cur *Node, in SubmitInput) (*Node, []Approver) {
|
// nextExecutable 获取下一个可执行审批节点及其审批人(跳过通知节点并通过 onNotify 回调触发)
|
||||||
node := advanceFrom(flow, formData, cur)
|
func nextExecutable(flow *Flow, formData map[string]interface{}, cur *Node, in SubmitInput, onNotify ...func(*Node)) (*Node, []Approver) {
|
||||||
|
node := advanceFrom(flow, formData, cur, onNotify...)
|
||||||
visited := make(map[string]bool)
|
visited := make(map[string]bool)
|
||||||
for node != nil {
|
for node != nil {
|
||||||
if visited[node.NodeKey] {
|
if visited[node.NodeKey] {
|
||||||
@@ -715,7 +816,7 @@ func nextExecutable(flow *Flow, formData map[string]interface{}, cur *Node, in S
|
|||||||
return node, approvers
|
return node, approvers
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
node = advanceFrom(flow, formData, node)
|
node = advanceFrom(flow, formData, node, onNotify...)
|
||||||
}
|
}
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -792,6 +893,34 @@ func createTasks(instID uint64, in SubmitInput, node Node, approvers []Approver)
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fireNotifyTasks 为通知节点创建已读通知任务(不阻塞流程)
|
||||||
|
func fireNotifyTasks(instID uint64, in SubmitInput, notifyNode *Node) {
|
||||||
|
recipients, _ := resolveApprovers(in.Scope, *notifyNode, in)
|
||||||
|
recipients = dedupeApprovers(recipients)
|
||||||
|
if len(recipients) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
tasks := make([]models.ApprovalTask, 0, len(recipients))
|
||||||
|
for _, r := range recipients {
|
||||||
|
tasks = append(tasks, models.ApprovalTask{
|
||||||
|
InstanceID: instID,
|
||||||
|
Scope: in.Scope,
|
||||||
|
Tid: in.Tid,
|
||||||
|
BizType: in.BizType,
|
||||||
|
BizID: in.BizID,
|
||||||
|
NodeKey: notifyNode.NodeKey,
|
||||||
|
NodeName: notifyNode.NodeName,
|
||||||
|
Sort: notifyNode.Sort,
|
||||||
|
ApproverID: r.ID,
|
||||||
|
ApproverName: r.Name,
|
||||||
|
Status: models.ApprovalTaskNotified,
|
||||||
|
FinishTime: &now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_, _ = models.Orm.InsertMulti(len(tasks), tasks)
|
||||||
|
}
|
||||||
|
|
||||||
// finishInstance 结束实例
|
// finishInstance 结束实例
|
||||||
func finishInstance(instID uint64, status int8) {
|
func finishInstance(instID uint64, status int8) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
@@ -947,7 +1076,8 @@ func advance(instID uint64, currentKey string, in ApproveInput) (*ApproveResult,
|
|||||||
formData := instanceFormData(&inst)
|
formData := instanceFormData(&inst)
|
||||||
si := submitFromInstance(&inst, formData)
|
si := submitFromInstance(&inst, formData)
|
||||||
cur := flow.ByKey[currentKey]
|
cur := flow.ByKey[currentKey]
|
||||||
node, approvers := nextExecutable(flow, formData, cur, si)
|
notifyFn := func(n *Node) { fireNotifyTasks(instID, si, n) }
|
||||||
|
node, approvers := nextExecutable(flow, formData, cur, si, notifyFn)
|
||||||
if node == nil {
|
if node == nil {
|
||||||
finishInstance(instID, models.ApprovalInstanceApproved)
|
finishInstance(instID, models.ApprovalInstanceApproved)
|
||||||
return &ApproveResult{
|
return &ApproveResult{
|
||||||
|
|||||||
@@ -60,3 +60,8 @@ export function getApprovalFlowPick(params) {
|
|||||||
params
|
params
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 业务侧发起前拉可用流
|
||||||
|
export function getAvailableFlows(params) {
|
||||||
|
return request({ url: '/platform/approval/flow/available', method: 'get', params })
|
||||||
|
}
|
||||||
@@ -3,19 +3,29 @@
|
|||||||
<!-- 左侧节点面板 -->
|
<!-- 左侧节点面板 -->
|
||||||
<div class="palette">
|
<div class="palette">
|
||||||
<div class="palette-title">节点面板</div>
|
<div class="palette-title">节点面板</div>
|
||||||
|
<div class="palette-list">
|
||||||
<div
|
<div
|
||||||
v-for="p in palette"
|
v-for="p in palette"
|
||||||
:key="p.type"
|
:key="p.type"
|
||||||
class="palette-item"
|
class="palette-item"
|
||||||
:class="'pal-' + p.cls"
|
:class="'pal-' + p.cls"
|
||||||
draggable="true"
|
draggable="true"
|
||||||
|
:title="'点击或拖拽添加「' + p.label + '」'"
|
||||||
@dragstart="onDragStart($event, p)"
|
@dragstart="onDragStart($event, p)"
|
||||||
|
@dragend="onDragEnd"
|
||||||
|
@click="onPaletteAdd(p)"
|
||||||
>
|
>
|
||||||
<el-icon class="pal-icon"><component :is="p.icon" /></el-icon>
|
<span class="pal-shape-wrap">
|
||||||
<span>{{ p.label }}</span>
|
<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>
|
||||||
<div class="palette-tip">
|
<div class="palette-tip">
|
||||||
拖入画布添加节点;从节点右侧圆点拖到目标节点连线;双击节点配置;点击连线设置分支条件。
|
点击或拖拽即可添加节点;<br />从右侧小圆点拖到目标节点连线;<br />双击节点配置 · 点击连线设条件。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -31,7 +41,33 @@
|
|||||||
@edge-click="onEdgeClick"
|
@edge-click="onEdgeClick"
|
||||||
>
|
>
|
||||||
<template #node-flow="nodeProps">
|
<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" />
|
<Handle type="target" :position="Position.Left" />
|
||||||
<div class="fn-badge">{{ typeShort(nodeProps.data.nodeType) }}</div>
|
<div class="fn-badge">{{ typeShort(nodeProps.data.nodeType) }}</div>
|
||||||
<div class="fn-title">{{ nodeProps.data.name || '未命名' }}</div>
|
<div class="fn-title">{{ nodeProps.data.name || '未命名' }}</div>
|
||||||
@@ -105,8 +141,26 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
</template>
|
</template>
|
||||||
<el-form-item v-else-if="nodeForm.nodeType === 4" label=" ">
|
<el-form-item v-else-if="nodeForm.nodeType === 4" label=" ">
|
||||||
<span class="hint">条件网关:拖出多条连线,点击连线分别填写分支条件(留空为默认分支)。</span>
|
<span class="hint">判断节点:拖出多条连线,点击连线分别填写分支条件(留空为默认分支)。运行期按条件命中其中一条分支。</span>
|
||||||
</el-form-item>
|
</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>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button type="danger" plain :disabled="!editingNodeId" @click="removeCurrentNode">删除节点</el-button>
|
<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-input v-model="edgeForm.condition" placeholder="如 amount>5000 且 dept=='研发' ;留空为默认分支" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label=" ">
|
<el-form-item label=" ">
|
||||||
<span class="hint">支持 > >= < <= == != 与 && / ||。条件仅在“条件网关”的出边上生效。</span>
|
<span class="hint">支持 > >= < <= == != 与 && / ||。条件仅在“判断节点”的出边上生效。</span>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
@@ -139,7 +193,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, computed, watch, nextTick } from 'vue'
|
import { ref, reactive, computed, watch, nextTick } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
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 { VueFlow, Handle, Position, MarkerType, useVueFlow } from '@vue-flow/core'
|
||||||
import { Background } from '@vue-flow/background'
|
import { Background } from '@vue-flow/background'
|
||||||
import { Controls } from '@vue-flow/controls'
|
import { Controls } from '@vue-flow/controls'
|
||||||
@@ -164,20 +218,21 @@ const { screenToFlowCoordinate, onConnect } = useVueFlow()
|
|||||||
let keySeq = 1
|
let keySeq = 1
|
||||||
const nextKey = () => `n${Date.now().toString(36)}${(keySeq++).toString(36)}`
|
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 = [
|
const palette = [
|
||||||
{ type: NODE_TYPE.START, cls: 'start', label: '开始', icon: VideoPlay },
|
{ type: NODE_TYPE.START, cls: 'start', label: '开始', desc: '流程唯一入口', icon: VideoPlay },
|
||||||
{ type: NODE_TYPE.APPROVAL, cls: 'approval', label: '审批节点', icon: User },
|
{ type: NODE_TYPE.APPROVAL, cls: 'approval', label: '审批节点', desc: '指定审批人处理', icon: User },
|
||||||
{ type: NODE_TYPE.CONDITION, cls: 'condition', label: '条件网关', icon: Share },
|
{ type: NODE_TYPE.DECISION, cls: 'decision', label: '判断节点', desc: '按条件分支路由', icon: Share },
|
||||||
{ type: NODE_TYPE.END, cls: 'end', label: '结束', icon: SwitchButton },
|
{ type: NODE_TYPE.NOTIFY, cls: 'notify', label: '通知节点', desc: '通知不阻塞流程', icon: Bell },
|
||||||
|
{ type: NODE_TYPE.END, cls: 'end', label: '结束', desc: '流程出口', icon: SwitchButton },
|
||||||
]
|
]
|
||||||
|
|
||||||
const clsOf = (t: number) =>
|
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) =>
|
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 approverTypeLabel = (t: number) => {
|
||||||
const m = (props.approverTypes as any[]).find((x) => x.value === t)
|
const m = (props.approverTypes as any[]).find((x) => x.value === t)
|
||||||
@@ -193,7 +248,8 @@ function approverSummary(node: any) {
|
|||||||
const mode = node.multiMode === 2 ? '会签' : '或签'
|
const mode = node.multiMode === 2 ? '会签' : '或签'
|
||||||
return `${approverTypeLabel(node.approverType)}:${names.length ? names.join('、') : '未选'}(${mode})`
|
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 ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,8 +314,14 @@ watch(() => props.modelValue, reload, { deep: false, immediate: true })
|
|||||||
|
|
||||||
// ==================== 拖拽添加 ====================
|
// ==================== 拖拽添加 ====================
|
||||||
let dragType = 0
|
let dragType = 0
|
||||||
|
let dragging = false
|
||||||
function onDragStart(_evt: DragEvent, p: any) {
|
function onDragStart(_evt: DragEvent, p: any) {
|
||||||
dragType = p.type
|
dragType = p.type
|
||||||
|
dragging = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragEnd() {
|
||||||
|
setTimeout(() => { dragging = false; dragType = 0 }, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDrop(evt: DragEvent) {
|
function onDrop(evt: DragEvent) {
|
||||||
@@ -270,8 +332,23 @@ function onDrop(evt: DragEvent) {
|
|||||||
dragType = 0
|
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) {
|
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 +380,8 @@ const nodeForm = reactive<any>({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const nodeDialogTitle = computed(() => {
|
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 '配置审批节点'
|
if (nodeForm.nodeType === NODE_TYPE.APPROVAL) return '配置审批节点'
|
||||||
return '配置节点'
|
return '配置节点'
|
||||||
})
|
})
|
||||||
@@ -348,8 +426,8 @@ function confirmNode() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const meta = currentApproverMeta.value
|
const meta = currentApproverMeta.value
|
||||||
if (nodeForm.nodeType === NODE_TYPE.APPROVAL && meta && meta.needsSelection && !(nodeForm.approverIds || []).length) {
|
if ((nodeForm.nodeType === NODE_TYPE.APPROVAL || nodeForm.nodeType === NODE_TYPE.NOTIFY) && meta && meta.needsSelection && !(nodeForm.approverIds || []).length) {
|
||||||
ElMessage.warning('请选择审批人')
|
ElMessage.warning(nodeForm.nodeType === NODE_TYPE.NOTIFY ? '请选择通知对象' : '请选择审批人')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
node.data = makeNodeData({
|
node.data = makeNodeData({
|
||||||
@@ -440,49 +518,156 @@ defineExpose({ serialize, fitView })
|
|||||||
}
|
}
|
||||||
|
|
||||||
.palette {
|
.palette {
|
||||||
width: 160px;
|
width: 220px;
|
||||||
flex: 0 0 160px;
|
flex: 0 0 220px;
|
||||||
padding: 12px;
|
padding: 14px;
|
||||||
background: #fafafa;
|
background: #fafbfc;
|
||||||
border-right: 1px solid #e4e7ed;
|
border-right: 1px solid #e4e7ed;
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
.palette-title {
|
.palette-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 12px;
|
||||||
font-size: 14px;
|
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 {
|
.palette-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 12px;
|
||||||
padding: 8px 10px;
|
padding: 12px 14px;
|
||||||
margin-bottom: 8px;
|
min-height: 48px;
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
border: 1px solid #dcdfe6;
|
border: 1px solid #dcdfe6;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
font-size: 13px;
|
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 {
|
&:hover {
|
||||||
border-color: #409eff;
|
border-color: #409eff;
|
||||||
|
background: #f5faff;
|
||||||
|
box-shadow: 0 3px 10px rgba(64, 158, 255, 0.15);
|
||||||
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pal-icon {
|
&:active {
|
||||||
font-size: 15px;
|
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-start { border-left: 3px solid #67c23a; }
|
||||||
.pal-approval { border-left: 3px solid #409eff; }
|
.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; }
|
.pal-end { border-left: 3px solid #909399; }
|
||||||
|
|
||||||
.palette-tip {
|
.palette-tip {
|
||||||
margin-top: 10px;
|
margin-top: 14px;
|
||||||
font-size: 12px;
|
padding: 10px 12px;
|
||||||
|
font-size: 11.5px;
|
||||||
color: #909399;
|
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 +713,105 @@ defineExpose({ serialize, fitView })
|
|||||||
|
|
||||||
.fn-start { border-top: 3px solid #67c23a; .fn-badge { background: #67c23a; } }
|
.fn-start { border-top: 3px solid #67c23a; .fn-badge { background: #67c23a; } }
|
||||||
.fn-approval { border-top: 3px solid #409eff; .fn-badge { background: #409eff; } }
|
.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-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 {
|
.hint {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #909399;
|
color: #909399;
|
||||||
|
|||||||
@@ -34,7 +34,6 @@
|
|||||||
>
|
>
|
||||||
<div class="flow-name">
|
<div class="flow-name">
|
||||||
{{ f.flow_name }}
|
{{ f.flow_name }}
|
||||||
<el-tag v-if="f.is_default === 1" size="small" type="success">默认</el-tag>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flow-code">{{ f.flow_code || '(保存后生成编号)' }}</div>
|
<div class="flow-code">{{ f.flow_code || '(保存后生成编号)' }}</div>
|
||||||
<div class="flow-sub">
|
<div class="flow-sub">
|
||||||
@@ -76,12 +75,6 @@
|
|||||||
<el-input v-model="current.description" type="textarea" :rows="2" placeholder="流程用途说明" />
|
<el-input v-model="current.description" type="textarea" :rows="2" placeholder="流程用途说明" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-row :gutter="12">
|
<el-row :gutter="12">
|
||||||
<el-col :span="12">
|
|
||||||
<el-form-item label="设为默认">
|
|
||||||
<el-switch v-model="current.is_default" :active-value="1" :inactive-value="0" />
|
|
||||||
<span class="hint">同分类仅一条默认,新申请走默认流程</span>
|
|
||||||
</el-form-item>
|
|
||||||
</el-col>
|
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item label="启用状态">
|
<el-form-item label="启用状态">
|
||||||
<el-switch v-model="current.status" :active-value="1" :inactive-value="0" />
|
<el-switch v-model="current.status" :active-value="1" :inactive-value="0" />
|
||||||
|
|||||||
Reference in new issue
Block a user