feat(flow-editor): 多出口泳道 UI(抽屉开关+出口路由容器+分支增删/恢复单出口)

This commit is contained in:
chenxing
2026-10-03 14:09:02 +08:00
parent 16717a0867
commit 656c0d1bf5
4 changed files with 260 additions and 6 deletions
@@ -6,7 +6,7 @@ import { computed, inject, reactive, ref, watch } from "vue";
import { Message } from "view-ui-plus";
import UserCheckModal from "@/components/UserCheckModal.vue";
import { flowApi } from "@/api/module/flow";
import { ApprovalNode, EDITOR_CTX } from "../model";
import { ApprovalNode, EDITOR_CTX, createApproval } from "../model";
const props = defineProps<{
modelValue: boolean;
@@ -28,6 +28,7 @@ const form = reactive({
userIds: [] as number[],
countersign: "ANY" as "ALL" | "ANY",
deadlineHours: null as number | null,
multiOutlet: false,
});
const userCheckModal = ref<any>();
@@ -47,6 +48,7 @@ function syncFromNode(): void {
form.userIds = n.assignee?.type === "USERS" ? [...n.assignee.userIds] : [];
form.countersign = n.countersign;
form.deadlineHours = n.deadlineHours;
form.multiOutlet = !!(n.outlets && n.outlets.length >= 2);
}
watch(
@@ -119,6 +121,12 @@ function onOk(): void {
: { type: "DEPT_KEY", deptKey: form.deptKey };
n.countersign = form.countersign;
n.deadlineHours = form.deadlineHours && form.deadlineHours > 0 ? form.deadlineHours : null;
// 开启多出口(2026-10-03 P2):默认 同意/退回 两泳道各放一个待配置审批节点;
// 关闭不在抽屉做——泳道头部"恢复单出口"负责(要处理泳道内容的去向)
if (form.multiOutlet && !(n.outlets && n.outlets.length >= 2)) {
n.outlets = ["同意", "退回"];
n.branches = [[createApproval()], [createApproval()]];
}
emit("update:modelValue", false);
}
@@ -197,6 +205,18 @@ function onRemove(): void {
<InputNumber v-model="form.deadlineHours" :min="1" placeholder="可空" style="width: 100%" />
<div class="hint">超过时限后进入催办提醒(取到达时间 + N 小时)</div>
</div>
<div class="fld">
<label>出口路由</label>
<div class="mo-row">
<i-switch v-model="form.multiOutlet" />
<span class="mo-label">多出口审批(办理人按下按钮选路)</span>
</div>
<div class="hint">
开启后卡片下出现出口泳道(默认 同意/退回),出口名即办理按钮文案;泳道尾自动汇合到后继节点。
关闭请用泳道头部的"恢复单出口"(需处理泳道内容去向)
</div>
</div>
</Form>
<div class="dw-foot">
@@ -249,6 +269,14 @@ function onRemove(): void {
flex-wrap: wrap;
gap: 4px 0;
}
.mo-row {
display: flex;
align-items: center;
gap: 8px;
}
.mo-label {
font-size: 13px;
}
.dw-foot {
position: sticky;
bottom: -16px;
@@ -5,6 +5,7 @@ import NodeCard from "./NodeCard.vue";
import NotifyCard from "./NotifyCard.vue";
import ParallelBox from "./ParallelBox.vue";
import ConditionBox from "./ConditionBox.vue";
import OutletLanes from "./OutletLanes.vue";
import PlusButton from "./PlusButton.vue";
defineProps<{
@@ -19,6 +20,7 @@ defineProps<{
<PlusButton :container="container" :index="0" />
<template v-for="(item, i) in items" :key="item.uid">
<NodeCard v-if="item.kind === 'approval'" :node="item" />
<OutletLanes v-if="item.kind === 'approval' && item.outlets && item.outlets.length >= 2" :node="item" />
<NotifyCard v-else-if="item.kind === 'notify'" :node="item" />
<ParallelBox v-else-if="item.kind === 'parallel'" :node="item" />
<ConditionBox v-else :node="item" />
@@ -0,0 +1,198 @@
<script setup lang="ts">
// 多出口审批的出口泳道容器(2026-10-03 P2):蓝系区分条件紫。与条件分支容器完全同构——
// 无 JOIN 语义,办理人按下哪个出口按钮走哪条泳道,分支尾隐式汇合到容器后继。
// 出口名 Input 即 contentJson 里 APPROVAL 命名出边的 name(按钮文案,必填唯一)。
import { computed, inject } from "vue";
import { BRANCH_LABELS, EDITOR_CTX, MAX_BRANCHES, MIN_BRANCHES, ApprovalNode } from "../model";
import NodeList from "./NodeList.vue";
const props = defineProps<{ node: ApprovalNode }>();
const ctx = inject(EDITOR_CTX)!;
const issues = computed(() => ctx.issueOf(props.node.uid));
const hasError = computed(() => issues.value.some((i) => i.level === "error"));
const canRemoveBranch = computed(() => (props.node.branches?.length || 0) > MIN_BRANCHES);
const canAddBranch = computed(() => (props.node.branches?.length || 0) < MAX_BRANCHES);
/** 出口名就地编辑(重复/空由校验提示;与 branches 下标一一对应) */
function renameOutlet(i: number, v: string): void {
props.node.outlets![i] = v;
}
</script>
<template>
<div class="outlet-lanes" :class="{ error: hasError }">
<div class="ol-head">
<div class="ol-title" @click="ctx.openNode(node.uid)">
出口路由(按下按钮选路)
<span class="ol-hint">(出口名即办理按钮文案;点击审批卡片配置审批人)</span>
</div>
<Poptip
confirm
title="退出多出口将删除出口②起的全部泳道节点,仅保留出口①泳道?"
ok-text="退出多出口"
cancel-text="取消"
transfer
@on-ok="ctx.exitOutlets(node.uid)"
>
<span class="ol-exit">恢复单出口</span>
</Poptip>
</div>
<div class="ol-cols">
<div v-for="(branch, i) in node.branches" :key="i" class="ol-col">
<div class="ol-col-head">
<span class="ol-col-no">{{ BRANCH_LABELS[i] || i + 1 }}</span>
<input
class="ol-outlet"
:value="node.outlets![i]"
placeholder="出口名(按钮文案,必填唯一)"
maxlength="32"
@input="renameOutlet(i, ($event.target as HTMLInputElement).value)"
/>
<Poptip
v-if="canRemoveBranch"
confirm
title="删除该出口及其泳道节点?"
ok-text="删除"
cancel-text="取消"
transfer
@on-ok="ctx.removeBranch(node.uid, i)"
>
<span class="ol-col-del" title="删除出口">×</span>
</Poptip>
</div>
<NodeList :items="branch" :container="`${node.uid}:${i}`" />
</div>
</div>
<Button v-if="canAddBranch" class="ol-add" size="small" long @click="ctx.addBranch(node.uid)">
+ 添加出口
</Button>
<div v-for="(issue, k) in issues" :key="k" class="ol-issue" :class="issue.level">⚠ {{ issue.text }}</div>
</div>
</template>
<style scoped>
.outlet-lanes {
border: 1.5px dashed #5cadff;
border-radius: 10px;
padding: 14px 14px 12px;
background: rgba(240, 249, 255, 0.45);
}
.outlet-lanes.error {
border-color: #ed4014;
}
.ol-head {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}
.ol-title {
text-align: center;
color: #2d8cf0;
font-weight: 600;
font-size: 13px;
cursor: pointer;
}
.ol-title:hover {
text-decoration: underline;
}
.ol-hint {
font-weight: 400;
font-size: 11px;
color: #82a8d8;
}
.ol-exit {
flex: none;
cursor: pointer;
font-size: 12px;
color: #ed4014;
}
.ol-exit:hover {
text-decoration: underline;
}
.ol-cols {
display: flex;
gap: 14px;
align-items: flex-start;
justify-content: center;
margin-top: 10px;
}
.ol-col {
background: #fff;
border-radius: 8px;
padding: 12px 10px 14px;
min-width: 230px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
display: flex;
flex-direction: column;
align-items: center;
}
.ol-col-head {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
margin-bottom: 8px;
padding: 0 2px;
}
.ol-col-no {
flex: none;
color: #2d8cf0;
font-weight: 600;
font-size: 13px;
}
.ol-outlet {
flex: 1;
min-width: 0;
border: 1px solid #dfe6ee;
border-radius: 6px;
padding: 2px 8px;
font-size: 12px;
color: #2d8cf0;
font-weight: 600;
}
.ol-outlet:focus {
outline: none;
border-color: #2d8cf0;
}
.ol-col-del {
flex: none;
cursor: pointer;
color: #c5c9d1;
font-size: 16px;
line-height: 1;
}
.ol-col-del:hover {
color: #ed4014;
}
.ol-add {
margin-top: 12px;
border-style: dashed;
color: #2d8cf0;
border-color: #2d8cf0;
background: transparent;
width: auto;
padding: 0 16px;
display: block;
margin-left: auto;
margin-right: auto;
}
.ol-add:hover {
background: #f0faff;
color: #2b85e4;
border-color: #2d8cf0;
}
.ol-issue {
text-align: center;
font-size: 12px;
padding-top: 8px;
}
.ol-issue.error {
color: #ed4014;
}
.ol-issue.warn {
color: #ff9900;
}
</style>
+31 -5
View File
@@ -25,6 +25,7 @@ import {
createEmptyModel,
createNotify,
createParallel,
branchesOf,
findItem,
removeItem,
resolveContainer,
@@ -521,7 +522,7 @@ function insertItem(container: string, index: number, kind: "approval" | "parall
}
}
/** 添加分支(并行/条件分支容器通用;条件分支同步补一个占位出口名) */
/** 添加分支(并行/条件分支/多出口审批容器通用;条件分支与出口同步补一个占位出口名) */
function addBranch(containerUid: string): void {
const item = findItem(model, containerUid);
if (!item) return;
@@ -531,18 +532,42 @@ function addBranch(containerUid: string): void {
const no = item.branches.length + 1;
item.branches.push([createApproval()]);
item.outlets.push(`出口${["一", "二", "三", "四", "五", "六"][no - 1] || no}`);
} else if (item.kind === "approval" && item.outlets && item.outlets.length >= 2) {
const no = (item.branches?.length || 0) + 1;
(item.branches = item.branches || []).push([createApproval()]);
item.outlets.push(`出口${["一", "二", "三", "四", "五", "六"][no - 1] || no}`);
}
}
function removeBranch(containerUid: string, index: number): void {
const item = findItem(model, containerUid);
if (!item || (item.kind !== "parallel" && item.kind !== "condition")) return;
if (item.branches.length <= 2) {
const isOutlet = item.kind === "approval" && !!(item.outlets && item.outlets.length >= 2);
if (!item || (item.kind !== "parallel" && item.kind !== "condition" && !isOutlet)) return;
const branches = item.branches!;
if (branches.length <= 2) {
Message.warning("分支至少保留 2 个");
return;
}
item.branches.splice(index, 1);
if (item.kind === "condition") item.outlets.splice(index, 1);
branches.splice(index, 1);
if (item.kind === "condition" || isOutlet) item.outlets!.splice(index, 1);
}
/** 退出多出口:仅保留出口①泳道内容,替换为「本节点(去 outlets) + ①泳道节点序列」 */
function exitOutlets(uid: string): void {
const rec = (items: EditorItem[]): boolean => {
const i = items.findIndex((it) => it.uid === uid);
if (i >= 0) {
const node = items[i] as ApprovalNode;
const lane0 = node.branches?.[0] || [];
const clean: ApprovalNode = { ...node };
delete clean.outlets;
delete clean.branches;
items.splice(i, 1, clean, ...lane0);
return true;
}
return items.some((it) => branchesOf(it)?.some((b) => rec(b)) || false);
};
rec(model.list);
}
provide<EditorCtx>(EDITOR_CTX, {
@@ -556,6 +581,7 @@ provide<EditorCtx>(EDITOR_CTX, {
insertItem,
addBranch,
removeBranch,
exitOutlets,
});
// ==================== 保存 / 离开 ====================