feat(flow-editor): 多出口泳道 UI(抽屉开关+出口路由容器+分支增删/恢复单出口)
This commit is contained in:
@@ -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>
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
// ==================== 保存 / 离开 ====================
|
||||
|
||||
Reference in New Issue
Block a user