355 lines
9.9 KiB
Vue
355 lines
9.9 KiB
Vue
<!-- 递归渲染线性分支:节点 → + → 下一节点 -->
|
||
<template>
|
||
<div class="flow-branch">
|
||
<template v-if="node.type === 'end'">
|
||
<div class="flow-end">{{ endLabel }}</div>
|
||
</template>
|
||
|
||
<template v-else>
|
||
<div
|
||
class="flow-node"
|
||
:class="node.type"
|
||
@click="emit('edit', node)"
|
||
>
|
||
<div class="hd">
|
||
<span>{{ node.name || typeLabel(node.type) }}</span>
|
||
<el-button
|
||
v-if="node.type !== 'start'"
|
||
class="flow-node__del"
|
||
text
|
||
type="danger"
|
||
@click.stop="emit('remove', node.id)"
|
||
>
|
||
<el-icon><Delete /></el-icon>
|
||
</el-button>
|
||
</div>
|
||
<div class="bd">{{ nodeSummary(node) }} ›</div>
|
||
</div>
|
||
|
||
<template v-if="node.type === 'condition'">
|
||
<div class="flow-cond-wrap">
|
||
<div
|
||
v-for="(g, idx) in node.conditionGroups || []"
|
||
:key="`${node.id}_g_${idx}`"
|
||
class="flow-cond-col"
|
||
>
|
||
<div class="flow-cond-col__title" @click.stop="emit('edit', node)">
|
||
<span class="flow-cond-col__name">{{ branchName(g, idx) }}</span>
|
||
<span
|
||
v-for="(line, lineIdx) in branchConditionLines(g)"
|
||
:key="`${node.id}_g_${idx}_r_${lineIdx}`"
|
||
class="muted"
|
||
>
|
||
{{ line }}
|
||
</span>
|
||
</div>
|
||
<!-- 分支仅有结束节点时,先露出「+」才能往分支内插入 -->
|
||
<el-dropdown
|
||
v-if="!g.child || g.child.type === 'end'"
|
||
trigger="click"
|
||
@command="(cmd: string) => onAddInGroup(idx, cmd)"
|
||
>
|
||
<button type="button" class="flow-plus" title="在本分支添加节点">+</button>
|
||
<template #dropdown>
|
||
<el-dropdown-menu>
|
||
<el-dropdown-item command="approval">审批人</el-dropdown-item>
|
||
<el-dropdown-item command="cc">抄送人</el-dropdown-item>
|
||
<el-dropdown-item command="condition">条件分支</el-dropdown-item>
|
||
</el-dropdown-menu>
|
||
</template>
|
||
</el-dropdown>
|
||
<FlowBranch
|
||
v-if="g.child && g.child.type !== 'end'"
|
||
:node="g.child"
|
||
:depth="depth + 1"
|
||
:end-label="branchEndLabel"
|
||
:candidates="candidates"
|
||
:value-label-maps="valueLabelMaps"
|
||
@edit="(n) => emit('edit', n)"
|
||
@remove="(id) => emit('remove', id)"
|
||
@add="(payload) => emit('add', payload)"
|
||
/>
|
||
<div v-else class="flow-end">{{ branchEndLabel }}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 条件分支汇合后继续主链 -->
|
||
<el-dropdown trigger="click" @command="(cmd: string) => onAdd(cmd)">
|
||
<button type="button" class="flow-plus" title="在条件分支后添加节点">+</button>
|
||
<template #dropdown>
|
||
<el-dropdown-menu>
|
||
<el-dropdown-item command="approval">审批人</el-dropdown-item>
|
||
<el-dropdown-item command="cc">抄送人</el-dropdown-item>
|
||
<el-dropdown-item command="condition">条件分支</el-dropdown-item>
|
||
</el-dropdown-menu>
|
||
</template>
|
||
</el-dropdown>
|
||
<FlowBranch
|
||
v-if="node.child"
|
||
:node="node.child"
|
||
:depth="depth"
|
||
:end-label="endLabel"
|
||
:candidates="candidates"
|
||
:value-label-maps="valueLabelMaps"
|
||
@edit="(n) => emit('edit', n)"
|
||
@remove="(id) => emit('remove', id)"
|
||
@add="(payload) => emit('add', payload)"
|
||
/>
|
||
<div v-else class="flow-end">{{ endLabel }}</div>
|
||
</template>
|
||
|
||
<template v-else>
|
||
<el-dropdown trigger="click" @command="(cmd: string) => onAdd(cmd)">
|
||
<button type="button" class="flow-plus" title="添加节点">+</button>
|
||
<template #dropdown>
|
||
<el-dropdown-menu>
|
||
<el-dropdown-item command="approval">审批人</el-dropdown-item>
|
||
<el-dropdown-item command="cc">抄送人</el-dropdown-item>
|
||
<el-dropdown-item command="condition">条件分支</el-dropdown-item>
|
||
</el-dropdown-menu>
|
||
</template>
|
||
</el-dropdown>
|
||
|
||
<FlowBranch
|
||
v-if="node.child"
|
||
:node="node.child"
|
||
:depth="depth"
|
||
:end-label="endLabel"
|
||
:candidates="candidates"
|
||
:value-label-maps="valueLabelMaps"
|
||
@edit="(n) => emit('edit', n)"
|
||
@remove="(id) => emit('remove', id)"
|
||
@add="(payload) => emit('add', payload)"
|
||
/>
|
||
<div v-else class="flow-end">{{ endLabel }}</div>
|
||
</template>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { Delete } from '@element-plus/icons-vue'
|
||
import {
|
||
formatConditionPreviewLines,
|
||
type ConditionCandidate,
|
||
type ConditionValueLabelMaps
|
||
} from '@/components/oa-approval/oa-condition-fields'
|
||
import type { ConditionGroup, FlowNode } from './types'
|
||
import { summarizeApprover } from './types'
|
||
// 递归自引用
|
||
import FlowBranch from './FlowBranch.vue'
|
||
|
||
defineOptions({ name: 'OaFlowBranch' })
|
||
|
||
const props = withDefaults(
|
||
defineProps<{
|
||
node: FlowNode
|
||
depth?: number
|
||
/** 分支/主链末端文案;有汇合后主链时分支内传「汇合」 */
|
||
endLabel?: string
|
||
candidates?: ConditionCandidate[]
|
||
valueLabelMaps?: ConditionValueLabelMaps
|
||
}>(),
|
||
{ depth: 0, endLabel: '流程结束', candidates: () => [], valueLabelMaps: () => ({}) }
|
||
)
|
||
|
||
/** 本条件有汇合后 child,或外层已标明汇合时,分支列末端用「汇合」 */
|
||
const branchEndLabel = computed(() =>
|
||
props.node.child || props.endLabel === '汇合' ? '汇合' : props.endLabel
|
||
)
|
||
|
||
const emit = defineEmits<{
|
||
edit: [node: FlowNode]
|
||
remove: [nodeId: string]
|
||
add: [
|
||
payload: {
|
||
parentId: string
|
||
type: 'approval' | 'cc' | 'condition'
|
||
/** 写入条件节点的第 N 个分支;缺省则写入 parent.child(汇合后主链) */
|
||
groupIndex?: number
|
||
}
|
||
]
|
||
}>()
|
||
|
||
function typeLabel(t: string) {
|
||
if (t === 'start') return '发起人'
|
||
if (t === 'approval') return '审批人'
|
||
if (t === 'cc') return '抄送人'
|
||
if (t === 'condition') return '条件分支'
|
||
return t
|
||
}
|
||
|
||
function branchName(g: ConditionGroup, idx: number) {
|
||
if (g.isDefault) return g.name || '默认 · 其他情况'
|
||
return g.name || `条件${idx + 1}`
|
||
}
|
||
|
||
function branchConditionLines(g: ConditionGroup) {
|
||
if (g.isDefault) return [] as string[]
|
||
return formatConditionPreviewLines(
|
||
g.rules || [],
|
||
props.candidates || [],
|
||
props.valueLabelMaps
|
||
)
|
||
}
|
||
|
||
function nodeSummary(n: FlowNode) {
|
||
if (n.type === 'start') return '谁可以发起本审批'
|
||
if (n.type === 'condition') {
|
||
const g = n.conditionGroups?.length || 0
|
||
return `${g} 个分支`
|
||
}
|
||
return summarizeApprover(n.approverConfig, n.type)
|
||
}
|
||
|
||
function onAdd(cmd: string) {
|
||
if (cmd === 'approval' || cmd === 'cc' || cmd === 'condition') {
|
||
emit('add', { parentId: props.node.id, type: cmd })
|
||
}
|
||
}
|
||
|
||
function onAddInGroup(groupIndex: number, cmd: string) {
|
||
if (cmd === 'approval' || cmd === 'cc' || cmd === 'condition') {
|
||
emit('add', { parentId: props.node.id, type: cmd, groupIndex })
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.flow-branch {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
}
|
||
|
||
.flow-node {
|
||
width: 220px;
|
||
overflow: hidden;
|
||
cursor: pointer;
|
||
background: #fff;
|
||
border: 1px solid var(--mint-border, #e2e8f0);
|
||
border-radius: 8px;
|
||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||
transition: border-color 0.15s, box-shadow 0.15s;
|
||
|
||
&:hover {
|
||
border-color: var(--primary, #10b981);
|
||
box-shadow: 0 0 0 3px var(--primary-bg, #ecfdf5);
|
||
}
|
||
|
||
.hd {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 8px 12px;
|
||
color: #fff;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.bd {
|
||
padding: 10px 12px;
|
||
color: var(--mint-text-muted, #64748b);
|
||
font-size: 13px;
|
||
}
|
||
|
||
&.start .hd {
|
||
background: #0f766e;
|
||
}
|
||
|
||
&.approval .hd {
|
||
background: #d97706;
|
||
}
|
||
|
||
&.cc .hd {
|
||
background: #0891b2;
|
||
}
|
||
|
||
&.condition .hd {
|
||
background: #7c3aed;
|
||
}
|
||
}
|
||
|
||
.flow-node__del {
|
||
padding: 0;
|
||
color: #fff !important;
|
||
opacity: 0.85;
|
||
background: transparent !important;
|
||
|
||
&:hover,
|
||
&:focus {
|
||
color: #ef4444 !important;
|
||
opacity: 1;
|
||
background: transparent !important;
|
||
}
|
||
}
|
||
|
||
.flow-plus {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 24px;
|
||
height: 24px;
|
||
margin: 8px 0;
|
||
color: var(--primary, #10b981);
|
||
font-weight: 700;
|
||
cursor: pointer;
|
||
background: #fff;
|
||
border: 1px solid var(--primary, #10b981);
|
||
border-radius: 50%;
|
||
}
|
||
|
||
.flow-end {
|
||
margin-top: 8px;
|
||
padding: 6px 16px;
|
||
color: var(--mint-text-muted, #64748b);
|
||
font-size: 12px;
|
||
background: #e2e8f0;
|
||
border-radius: 999px;
|
||
}
|
||
|
||
.flow-cond-wrap {
|
||
display: flex;
|
||
gap: 16px;
|
||
align-items: flex-start;
|
||
width: max-content;
|
||
margin-top: 8px;
|
||
padding: 12px;
|
||
background: #f1f5f9;
|
||
border-radius: 10px;
|
||
}
|
||
|
||
.flow-cond-col {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
min-width: 240px;
|
||
}
|
||
|
||
.flow-cond-col__title {
|
||
margin-bottom: 8px;
|
||
padding: 4px 10px;
|
||
color: #5b21b6;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
text-align: center;
|
||
cursor: pointer;
|
||
background: #ede9fe;
|
||
border-radius: 6px;
|
||
|
||
.flow-cond-col__name {
|
||
display: block;
|
||
}
|
||
|
||
.muted {
|
||
display: block;
|
||
margin-top: 2px;
|
||
color: var(--mint-text-muted, #94a3b8);
|
||
font-weight: 400;
|
||
line-height: 1.4;
|
||
text-align: center;
|
||
word-break: break-word;
|
||
}
|
||
}
|
||
</style>
|