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 { Message } from "view-ui-plus";
|
||||||
import UserCheckModal from "@/components/UserCheckModal.vue";
|
import UserCheckModal from "@/components/UserCheckModal.vue";
|
||||||
import { flowApi } from "@/api/module/flow";
|
import { flowApi } from "@/api/module/flow";
|
||||||
import { ApprovalNode, EDITOR_CTX } from "../model";
|
import { ApprovalNode, EDITOR_CTX, createApproval } from "../model";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: boolean;
|
modelValue: boolean;
|
||||||
@@ -28,6 +28,7 @@ const form = reactive({
|
|||||||
userIds: [] as number[],
|
userIds: [] as number[],
|
||||||
countersign: "ANY" as "ALL" | "ANY",
|
countersign: "ANY" as "ALL" | "ANY",
|
||||||
deadlineHours: null as number | null,
|
deadlineHours: null as number | null,
|
||||||
|
multiOutlet: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const userCheckModal = ref<any>();
|
const userCheckModal = ref<any>();
|
||||||
@@ -47,6 +48,7 @@ function syncFromNode(): void {
|
|||||||
form.userIds = n.assignee?.type === "USERS" ? [...n.assignee.userIds] : [];
|
form.userIds = n.assignee?.type === "USERS" ? [...n.assignee.userIds] : [];
|
||||||
form.countersign = n.countersign;
|
form.countersign = n.countersign;
|
||||||
form.deadlineHours = n.deadlineHours;
|
form.deadlineHours = n.deadlineHours;
|
||||||
|
form.multiOutlet = !!(n.outlets && n.outlets.length >= 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -119,6 +121,12 @@ function onOk(): void {
|
|||||||
: { type: "DEPT_KEY", deptKey: form.deptKey };
|
: { type: "DEPT_KEY", deptKey: form.deptKey };
|
||||||
n.countersign = form.countersign;
|
n.countersign = form.countersign;
|
||||||
n.deadlineHours = form.deadlineHours && form.deadlineHours > 0 ? form.deadlineHours : null;
|
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);
|
emit("update:modelValue", false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,6 +205,18 @@ function onRemove(): void {
|
|||||||
<InputNumber v-model="form.deadlineHours" :min="1" placeholder="可空" style="width: 100%" />
|
<InputNumber v-model="form.deadlineHours" :min="1" placeholder="可空" style="width: 100%" />
|
||||||
<div class="hint">超过时限后进入催办提醒(取到达时间 + N 小时)</div>
|
<div class="hint">超过时限后进入催办提醒(取到达时间 + N 小时)</div>
|
||||||
</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>
|
</Form>
|
||||||
|
|
||||||
<div class="dw-foot">
|
<div class="dw-foot">
|
||||||
@@ -249,6 +269,14 @@ function onRemove(): void {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 4px 0;
|
gap: 4px 0;
|
||||||
}
|
}
|
||||||
|
.mo-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.mo-label {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
.dw-foot {
|
.dw-foot {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
bottom: -16px;
|
bottom: -16px;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import NodeCard from "./NodeCard.vue";
|
|||||||
import NotifyCard from "./NotifyCard.vue";
|
import NotifyCard from "./NotifyCard.vue";
|
||||||
import ParallelBox from "./ParallelBox.vue";
|
import ParallelBox from "./ParallelBox.vue";
|
||||||
import ConditionBox from "./ConditionBox.vue";
|
import ConditionBox from "./ConditionBox.vue";
|
||||||
|
import OutletLanes from "./OutletLanes.vue";
|
||||||
import PlusButton from "./PlusButton.vue";
|
import PlusButton from "./PlusButton.vue";
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -19,6 +20,7 @@ defineProps<{
|
|||||||
<PlusButton :container="container" :index="0" />
|
<PlusButton :container="container" :index="0" />
|
||||||
<template v-for="(item, i) in items" :key="item.uid">
|
<template v-for="(item, i) in items" :key="item.uid">
|
||||||
<NodeCard v-if="item.kind === 'approval'" :node="item" />
|
<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" />
|
<NotifyCard v-else-if="item.kind === 'notify'" :node="item" />
|
||||||
<ParallelBox v-else-if="item.kind === 'parallel'" :node="item" />
|
<ParallelBox v-else-if="item.kind === 'parallel'" :node="item" />
|
||||||
<ConditionBox v-else :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,
|
createEmptyModel,
|
||||||
createNotify,
|
createNotify,
|
||||||
createParallel,
|
createParallel,
|
||||||
|
branchesOf,
|
||||||
findItem,
|
findItem,
|
||||||
removeItem,
|
removeItem,
|
||||||
resolveContainer,
|
resolveContainer,
|
||||||
@@ -521,7 +522,7 @@ function insertItem(container: string, index: number, kind: "approval" | "parall
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 添加分支(并行/条件分支容器通用;条件分支同步补一个占位出口名) */
|
/** 添加分支(并行/条件分支/多出口审批容器通用;条件分支与出口同步补一个占位出口名) */
|
||||||
function addBranch(containerUid: string): void {
|
function addBranch(containerUid: string): void {
|
||||||
const item = findItem(model, containerUid);
|
const item = findItem(model, containerUid);
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
@@ -531,18 +532,42 @@ function addBranch(containerUid: string): void {
|
|||||||
const no = item.branches.length + 1;
|
const no = item.branches.length + 1;
|
||||||
item.branches.push([createApproval()]);
|
item.branches.push([createApproval()]);
|
||||||
item.outlets.push(`出口${["一", "二", "三", "四", "五", "六"][no - 1] || no}`);
|
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 {
|
function removeBranch(containerUid: string, index: number): void {
|
||||||
const item = findItem(model, containerUid);
|
const item = findItem(model, containerUid);
|
||||||
if (!item || (item.kind !== "parallel" && item.kind !== "condition")) return;
|
const isOutlet = item.kind === "approval" && !!(item.outlets && item.outlets.length >= 2);
|
||||||
if (item.branches.length <= 2) {
|
if (!item || (item.kind !== "parallel" && item.kind !== "condition" && !isOutlet)) return;
|
||||||
|
const branches = item.branches!;
|
||||||
|
if (branches.length <= 2) {
|
||||||
Message.warning("分支至少保留 2 个");
|
Message.warning("分支至少保留 2 个");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
item.branches.splice(index, 1);
|
branches.splice(index, 1);
|
||||||
if (item.kind === "condition") item.outlets.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, {
|
provide<EditorCtx>(EDITOR_CTX, {
|
||||||
@@ -556,6 +581,7 @@ provide<EditorCtx>(EDITOR_CTX, {
|
|||||||
insertItem,
|
insertItem,
|
||||||
addBranch,
|
addBranch,
|
||||||
removeBranch,
|
removeBranch,
|
||||||
|
exitOutlets,
|
||||||
});
|
});
|
||||||
|
|
||||||
// ==================== 保存 / 离开 ====================
|
// ==================== 保存 / 离开 ====================
|
||||||
|
|||||||
Reference in New Issue
Block a user