Files
yunzerwebsiteallinone/backend/src/components/approval/FlowCanvas.vue
T
2026-10-08 15:30:37 +08:00

979 lines
31 KiB
Vue
Raw Blame History

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