NOTIFY(2026-09-23):编辑器抄送卡片+抽屉+画布 bpmn:ServiceTask 映射、工作台「抄送我的」页签、轨迹 kind=CC 行、示意图青框卡片 CONDITION(2026-09-24):ConditionBox 互斥分支容器(分支列出口名就地编辑、无 JOIN 语义分支尾隐式汇合)、ConditionDrawer 判断服务下拉(flw_router)、convert 双向序列化(CONDITION+命名出边、uid+c 尾缀往返收敛)、validate 镜像后端口径、画布 bpmn:ExclusiveGateway 映射、轨迹「系统判断→出口」行(失败挂起提示)、示意图紫菱形(SVG 与服务端 PNG 同配色)、管理面 flw_router 维护页(/admin/routers)与办件记录「重跑路由」入口 convert 往返 54 项全过、npm run build 通过
This commit is contained in:
@@ -10,15 +10,20 @@ import { Message, Modal } from "view-ui-plus";
|
||||
import { flowApi } from "@/api/module/flow";
|
||||
import {
|
||||
ApprovalNode,
|
||||
ConditionNode,
|
||||
DeptOption,
|
||||
EDITOR_CTX,
|
||||
EditorCtx,
|
||||
EditorItem,
|
||||
EditorModel,
|
||||
NotifyNode,
|
||||
RouterOption,
|
||||
UrgeSetting,
|
||||
collectUserIds,
|
||||
createApproval,
|
||||
createCondition,
|
||||
createEmptyModel,
|
||||
createNotify,
|
||||
createParallel,
|
||||
findItem,
|
||||
removeItem,
|
||||
@@ -29,6 +34,8 @@ import { ParsedDef, fromContentJson, toContentJson } from "./convert";
|
||||
import { Issue, countErrors, countWarns, validateModel } from "./validate";
|
||||
import NodeList from "./components/NodeList.vue";
|
||||
import ApprovalDrawer from "./components/ApprovalDrawer.vue";
|
||||
import NotifyDrawer from "./components/NotifyDrawer.vue";
|
||||
import ConditionDrawer from "./components/ConditionDrawer.vue";
|
||||
import SettingsDrawer from "./components/SettingsDrawer.vue";
|
||||
import JsonDrawer from "./components/JsonDrawer.vue";
|
||||
import BpmnCanvas from "./components/BpmnCanvas.vue";
|
||||
@@ -53,6 +60,8 @@ const model = reactive<EditorModel>(createEmptyModel());
|
||||
const depts = reactive<DeptOption[]>([]);
|
||||
/** userId → 姓名快照(mapLookupUser 检索补全,失败降级 #id) */
|
||||
const userNames = reactive<Record<number, string>>({});
|
||||
/** 条件分支的判断服务注册(flw_router;拉取失败为空数组,校验降级提示) */
|
||||
const routers = reactive<RouterOption[]>([]);
|
||||
|
||||
// ==================== 双模式统一(2026-09-17):卡片树 = 唯一数据源,画布 = 其可视化投影 ====================
|
||||
/**
|
||||
@@ -78,30 +87,58 @@ const canvasConfigs = reactive<Record<string, any>>({});
|
||||
const sourceJson = ref("");
|
||||
|
||||
function onCanvasNode(_el: any): void {
|
||||
// selection.changed 自画布 emit:选中审批节点 → 打开配置抽屉
|
||||
if (!_el || _el.type !== "bpmn:UserTask") return;
|
||||
// selection.changed 自画布 emit:选中审批/抄送/条件分支节点 → 打开对应配置抽屉
|
||||
const isApproval = _el && _el.type === "bpmn:UserTask";
|
||||
const isNotify = _el && _el.type === "bpmn:ServiceTask";
|
||||
const isCondition = _el && _el.type === "bpmn:ExclusiveGateway";
|
||||
if (!isApproval && !isNotify && !isCondition) return;
|
||||
const id = String(_el.businessObject.id || _el.id);
|
||||
const item = findItem(model, id);
|
||||
if (item && item.kind === "approval") {
|
||||
// 条件分支的画布元素 id 是 JSON id(uid+"c" 后缀),卡片树按 uid 查(uid 由本编辑器生成,不带 c 尾)
|
||||
const lookupId = isCondition && id.endsWith("c") ? id.slice(0, -1) : id;
|
||||
const item = findItem(model, lookupId);
|
||||
if (item && (item.kind === "approval" || item.kind === "notify" || item.kind === "condition")) {
|
||||
// 卡片树已有节点:抽屉直接编辑卡片本体(id=uid 天然对应)
|
||||
editingUid.value = id;
|
||||
nodeDrawer.value = true;
|
||||
openNode(item.uid);
|
||||
return;
|
||||
}
|
||||
// 画布上新画的节点(卡片树里没有):配置进 canvasConfigs,保存时并入
|
||||
const kind = isNotify ? "notify" : isCondition ? "condition" : "approval";
|
||||
const baseNode = (canvasBase.value?.nodes || []).find((n: any) => n.id === id) || {};
|
||||
if (!canvasConfigs[id]) {
|
||||
canvasConfigs[id] = {
|
||||
uid: id,
|
||||
kind: "approval",
|
||||
name: _el.businessObject.name || baseNode.name || "审批",
|
||||
assignee: baseNode.assignee || null,
|
||||
countersign: baseNode.countersign || "ANY",
|
||||
deadlineHours: baseNode.deadlineHours || null,
|
||||
};
|
||||
canvasConfigs[id] =
|
||||
kind === "notify"
|
||||
? {
|
||||
uid: id,
|
||||
kind: "notify",
|
||||
name: _el.businessObject.name || baseNode.name || "抄送",
|
||||
receivers: baseNode.receivers || { type: "USERS", userIds: [] },
|
||||
}
|
||||
: kind === "condition"
|
||||
? {
|
||||
uid: id,
|
||||
kind: "condition",
|
||||
name: _el.businessObject.name || baseNode.name || "条件判断",
|
||||
routerKey: baseNode.routerKey || "",
|
||||
outlets: [],
|
||||
branches: [],
|
||||
}
|
||||
: {
|
||||
uid: id,
|
||||
kind: "approval",
|
||||
name: _el.businessObject.name || baseNode.name || "审批",
|
||||
assignee: baseNode.assignee || null,
|
||||
countersign: baseNode.countersign || "ANY",
|
||||
deadlineHours: baseNode.deadlineHours || null,
|
||||
};
|
||||
}
|
||||
editingUid.value = id;
|
||||
nodeDrawer.value = true;
|
||||
if (canvasConfigs[id].kind === "notify") {
|
||||
notifyDrawer.value = true;
|
||||
} else if (canvasConfigs[id].kind === "condition") {
|
||||
conditionDrawer.value = true;
|
||||
} else {
|
||||
nodeDrawer.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
function canvasRemoveNode(uid: string): void {
|
||||
@@ -110,27 +147,34 @@ function canvasRemoveNode(uid: string): void {
|
||||
}
|
||||
|
||||
/** 画布序列化 → contentJson 字符串:结构性内容(节点/连线/布局)出自画布,
|
||||
* 业务配置(审批人/会签/时限)以卡片树为 base 按 id 继承,settings(含催办)随 base 带出 */
|
||||
* 业务配置(审批人/会签/时限/抄送对象)以卡片树为 base 按 id 继承,settings(含催办)随 base 带出 */
|
||||
function serializeUnified(): string {
|
||||
const canvasJson = snapshotCanvasObject();
|
||||
// canvasConfigs(画布新画节点的抽屉配置)按 id 并入
|
||||
(canvasJson.nodes || []).forEach((n: any) => {
|
||||
const cfg = canvasConfigs[String(n.id)];
|
||||
if (cfg && n.type === "APPROVAL") {
|
||||
if (!cfg) return;
|
||||
if (cfg.kind === "notify" && n.type === "NOTIFY") {
|
||||
n.name = cfg.name || n.name;
|
||||
if (cfg.receivers) n.receivers = cfg.receivers;
|
||||
} else if (cfg.kind === "approval" && n.type === "APPROVAL") {
|
||||
n.name = cfg.name || n.name;
|
||||
if (cfg.assignee) n.assignee = cfg.assignee;
|
||||
if (cfg.countersign) n.countersign = cfg.countersign;
|
||||
if (cfg.deadlineHours) n.deadlineHours = cfg.deadlineHours;
|
||||
} else if (cfg.kind === "condition" && n.type === "CONDITION") {
|
||||
n.name = cfg.name || n.name;
|
||||
if (cfg.routerKey) n.routerKey = cfg.routerKey;
|
||||
}
|
||||
});
|
||||
return JSON.stringify(canvasJson);
|
||||
}
|
||||
|
||||
const issues = computed<Issue[]>(() => validateModel(model, depts));
|
||||
const issues = computed<Issue[]>(() => validateModel(model, depts, routers));
|
||||
const defJson = computed(() => toContentJson(model));
|
||||
|
||||
/** 序列化 base:原始 contentJson 为底(保住卡片树不支持结构/新画节点的既有业务配置),
|
||||
* code/name/settings 与卡片树审批节点配置(审批人/会签/时限)以卡片树为准覆盖 */
|
||||
* code/name/settings 与卡片树审批/抄送节点配置(审批人/会签/时限/抄送对象)以卡片树为准覆盖 */
|
||||
function buildSerializeBase(): any {
|
||||
const modelDef = toContentJson(model);
|
||||
const base = JSON.parse(JSON.stringify(canvasBase.value || {}));
|
||||
@@ -141,17 +185,35 @@ function buildSerializeBase(): any {
|
||||
(base.nodes || []).forEach((n: any) => {
|
||||
byId[String(n.id)] = n;
|
||||
});
|
||||
model.list.forEach((item) => {
|
||||
if (item.kind !== "approval") return;
|
||||
const merged: any = { ...(byId[item.uid] || {}) };
|
||||
merged.id = item.uid;
|
||||
merged.type = "APPROVAL";
|
||||
merged.name = item.name || "审批";
|
||||
if (item.assignee) merged.assignee = JSON.parse(JSON.stringify(item.assignee));
|
||||
if (item.countersign === "ANY") merged.countersign = "ANY";
|
||||
else delete merged.countersign; // 后端缺省即会签,不回写
|
||||
if (item.deadlineHours) merged.deadlineHours = item.deadlineHours;
|
||||
byId[item.uid] = merged;
|
||||
// 全层级遍历(含并行分支内节点):只覆盖 id 能对上的,画布独有节点不受影响
|
||||
walkItems(model.list, (item) => {
|
||||
if (item.kind === "approval") {
|
||||
const merged: any = { ...(byId[item.uid] || {}) };
|
||||
merged.id = item.uid;
|
||||
merged.type = "APPROVAL";
|
||||
merged.name = item.name || "审批";
|
||||
if (item.assignee) merged.assignee = JSON.parse(JSON.stringify(item.assignee));
|
||||
if (item.countersign === "ANY") merged.countersign = "ANY";
|
||||
else delete merged.countersign; // 后端缺省即会签,不回写
|
||||
if (item.deadlineHours) merged.deadlineHours = item.deadlineHours;
|
||||
byId[item.uid] = merged;
|
||||
} else if (item.kind === "notify") {
|
||||
const merged: any = { ...(byId[item.uid] || {}) };
|
||||
merged.id = item.uid;
|
||||
merged.type = "NOTIFY";
|
||||
merged.name = item.name || "抄送";
|
||||
if (item.receivers) merged.receivers = JSON.parse(JSON.stringify(item.receivers));
|
||||
byId[item.uid] = merged;
|
||||
} else if (item.kind === "condition") {
|
||||
// 条件分支的 JSON id 带 c 后缀(convert.ts 生成约定),routerKey/名称随卡片树覆盖
|
||||
const jsonId = `${item.uid}c`;
|
||||
const merged: any = { ...(byId[jsonId] || {}) };
|
||||
merged.id = jsonId;
|
||||
merged.type = "CONDITION";
|
||||
merged.name = item.name || "条件判断";
|
||||
merged.routerKey = item.routerKey || "";
|
||||
byId[jsonId] = merged;
|
||||
}
|
||||
});
|
||||
base.nodes = Object.values(byId);
|
||||
return base;
|
||||
@@ -197,6 +259,8 @@ const currentSnapshot = computed(() =>
|
||||
const isDirty = computed(() => !loading.value && currentSnapshot.value !== savedSnapshot.value);
|
||||
|
||||
const nodeDrawer = ref(false);
|
||||
const notifyDrawer = ref(false);
|
||||
const conditionDrawer = ref(false);
|
||||
const settingsDrawer = ref(false);
|
||||
const jsonDrawer = ref(false);
|
||||
const editingUid = ref("");
|
||||
@@ -205,12 +269,35 @@ const editingNode = computed<ApprovalNode | null>(() => {
|
||||
if (editMode.value === "canvas") {
|
||||
const item = editingUid.value ? findItem(model, editingUid.value) : null;
|
||||
if (item && item.kind === "approval") return item;
|
||||
return canvasConfigs[editingUid.value] || null;
|
||||
const cfg = canvasConfigs[editingUid.value];
|
||||
return cfg && cfg.kind === "approval" ? cfg : null;
|
||||
}
|
||||
const item = editingUid.value ? findItem(model, editingUid.value) : null;
|
||||
return item && item.kind === "approval" ? item : null;
|
||||
});
|
||||
|
||||
const editingNotify = computed<NotifyNode | null>(() => {
|
||||
if (editMode.value === "canvas") {
|
||||
const item = editingUid.value ? findItem(model, editingUid.value) : null;
|
||||
if (item && item.kind === "notify") return item;
|
||||
const cfg = canvasConfigs[editingUid.value];
|
||||
return cfg && cfg.kind === "notify" ? cfg : null;
|
||||
}
|
||||
const item = editingUid.value ? findItem(model, editingUid.value) : null;
|
||||
return item && item.kind === "notify" ? item : null;
|
||||
});
|
||||
|
||||
const editingCondition = computed<ConditionNode | null>(() => {
|
||||
if (editMode.value === "canvas") {
|
||||
const item = editingUid.value ? findItem(model, editingUid.value) : null;
|
||||
if (item && item.kind === "condition") return item;
|
||||
const cfg = canvasConfigs[editingUid.value];
|
||||
return cfg && cfg.kind === "condition" ? cfg : null;
|
||||
}
|
||||
const item = editingUid.value ? findItem(model, editingUid.value) : null;
|
||||
return item && item.kind === "condition" ? item : null;
|
||||
});
|
||||
|
||||
// ==================== 载入 ====================
|
||||
|
||||
async function loadDepts(): Promise<void> {
|
||||
@@ -245,6 +332,16 @@ async function resolveUserNames(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** 条件分支的判断服务注册下拉(flw_router 启用项;失败不阻塞编辑,校验降级提示) */
|
||||
async function loadRouters(): Promise<void> {
|
||||
try {
|
||||
const rows: any[] = (await flowApi.routerList().send()) || [];
|
||||
routers.splice(0, routers.length, ...rows);
|
||||
} catch {
|
||||
Message.warning("判断服务注册拉取失败,条件分支的判断服务下拉可能不完整");
|
||||
}
|
||||
}
|
||||
|
||||
function applyParsed(parsed: ParsedDef): void {
|
||||
model.code = parsed.code || model.code;
|
||||
model.name = parsed.name || model.name;
|
||||
@@ -260,6 +357,7 @@ async function load(): Promise<void> {
|
||||
loading.value = true;
|
||||
try {
|
||||
await loadDepts();
|
||||
await loadRouters();
|
||||
|
||||
const queryId = route.query.id ? Number(route.query.id) : null;
|
||||
const queryGuid = (route.query.baseGuid as string) || "";
|
||||
@@ -376,7 +474,14 @@ function issueOf(uid: string): Issue[] {
|
||||
|
||||
function openNode(uid: string): void {
|
||||
editingUid.value = uid;
|
||||
nodeDrawer.value = true;
|
||||
const item = findItem(model, uid);
|
||||
if (item && item.kind === "notify") {
|
||||
notifyDrawer.value = true;
|
||||
} else if (item && item.kind === "condition") {
|
||||
conditionDrawer.value = true;
|
||||
} else {
|
||||
nodeDrawer.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
function removeNode(uid: string): void {
|
||||
@@ -394,7 +499,7 @@ function removeNode(uid: string): void {
|
||||
removeItem(model, uid);
|
||||
}
|
||||
|
||||
function insertItem(container: string, index: number, kind: "approval" | "parallel"): void {
|
||||
function insertItem(container: string, index: number, kind: "approval" | "parallel" | "notify" | "condition"): void {
|
||||
const list = resolveContainer(model, container);
|
||||
if (!list) return;
|
||||
if (kind === "approval") {
|
||||
@@ -402,31 +507,49 @@ function insertItem(container: string, index: number, kind: "approval" | "parall
|
||||
list.splice(index, 0, node);
|
||||
// 新建即弹出配置,引导填审批人(避免留下"任务无主"节点)
|
||||
openNode(node.uid);
|
||||
} else if (kind === "notify") {
|
||||
const node = createNotify();
|
||||
list.splice(index, 0, node);
|
||||
openNode(node.uid);
|
||||
} else if (kind === "condition") {
|
||||
const node = createCondition();
|
||||
list.splice(index, 0, node);
|
||||
// 新建即弹配置选判断服务(未配置的判断服务运行到该节点会挂起)
|
||||
openNode(node.uid);
|
||||
} else {
|
||||
list.splice(index, 0, createParallel());
|
||||
}
|
||||
}
|
||||
|
||||
function addBranch(parallelUid: string): void {
|
||||
const item = findItem(model, parallelUid);
|
||||
if (!item || item.kind !== "parallel") return;
|
||||
item.branches.push([createApproval()]);
|
||||
/** 添加分支(并行/条件分支容器通用;条件分支同步补一个占位出口名) */
|
||||
function addBranch(containerUid: string): void {
|
||||
const item = findItem(model, containerUid);
|
||||
if (!item) return;
|
||||
if (item.kind === "parallel") {
|
||||
item.branches.push([createApproval()]);
|
||||
} else if (item.kind === "condition") {
|
||||
const no = item.branches.length + 1;
|
||||
item.branches.push([createApproval()]);
|
||||
item.outlets.push(`出口${["一", "二", "三", "四", "五", "六"][no - 1] || no}`);
|
||||
}
|
||||
}
|
||||
|
||||
function removeBranch(parallelUid: string, index: number): void {
|
||||
const item = findItem(model, parallelUid);
|
||||
if (!item || item.kind !== "parallel") return;
|
||||
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) {
|
||||
Message.warning("并行分支至少保留 2 个");
|
||||
Message.warning("分支至少保留 2 个");
|
||||
return;
|
||||
}
|
||||
item.branches.splice(index, 1);
|
||||
if (item.kind === "condition") item.outlets.splice(index, 1);
|
||||
}
|
||||
|
||||
provide<EditorCtx>(EDITOR_CTX, {
|
||||
model,
|
||||
depts,
|
||||
userNames,
|
||||
routers,
|
||||
issueOf,
|
||||
openNode,
|
||||
removeNode,
|
||||
@@ -442,13 +565,20 @@ function onUrgeSave(urge: UrgeSetting): void {
|
||||
}
|
||||
|
||||
/** 抽屉关闭(确定/删除)后,画布上的节点名回写(配置改的是卡片树本体) */
|
||||
watch(nodeDrawer, (open, was) => {
|
||||
if (open || !was) return;
|
||||
watch([nodeDrawer, notifyDrawer, conditionDrawer], ([aOpen, nOpen, cOpen], [aWas, nWas, cWas]) => {
|
||||
const closed = (open: boolean, was: boolean) => !open && was;
|
||||
if (!closed(aOpen, aWas) && !closed(nOpen, nWas) && !closed(cOpen, cWas)) return;
|
||||
if (editMode.value !== "canvas" || !bpmnWrap.value) return;
|
||||
const item = findItem(model, editingUid.value);
|
||||
if (item && item.kind === "approval") {
|
||||
bpmnWrap.value.renameNode(item.uid, item.name);
|
||||
}
|
||||
if (item && item.kind === "notify") {
|
||||
bpmnWrap.value.renameNode(item.uid, item.name);
|
||||
}
|
||||
if (item && item.kind === "condition") {
|
||||
bpmnWrap.value.renameNode(`${item.uid}c`, item.name);
|
||||
}
|
||||
});
|
||||
|
||||
/** 源码抽屉应用:写回卡片树;画布模式下画布源同步换投影,避免看到旧结构 */
|
||||
@@ -541,6 +671,33 @@ function checkCanvas(content: string): string[] {
|
||||
if (!n.name) problems.push("审批节点未命名");
|
||||
if (!n.assignee) problems.push("审批节点「" + (n.name || n.id) + "」缺少审批人配置");
|
||||
}
|
||||
if (n.type === "NOTIFY") {
|
||||
if (!n.name) problems.push("抄送节点未命名");
|
||||
if (!n.receivers) problems.push("抄送节点「" + (n.name || n.id) + "」缺少抄送对象配置");
|
||||
else if (n.receivers.type === "USERS" && !(n.receivers.userIds || []).length) {
|
||||
problems.push("抄送节点「" + (n.name || n.id) + "」未选择用户");
|
||||
}
|
||||
}
|
||||
if (n.type === "CONDITION") {
|
||||
// 条件分支(镜像后端 DefValidator 的 CONDITION 口径):routerKey 必填 + 出口命名唯一 + 出边 ≥2
|
||||
if (!n.routerKey) problems.push("条件分支「" + (n.name || n.id) + "」未配置判断服务(routerKey)");
|
||||
const outs = edges.filter((e: any) => e.from === n.id);
|
||||
if (outs.length < 2) {
|
||||
problems.push("条件分支「" + (n.name || n.id) + "」至少 2 条出边(当前 " + outs.length + ")");
|
||||
} else {
|
||||
const names = new Set<string>();
|
||||
outs.forEach((e: any) => {
|
||||
const label = e.name?.trim() || "";
|
||||
if (!label) {
|
||||
problems.push(`条件分支「${n.name || n.id}」每个出口必须命名(出口名即判断依据)`);
|
||||
} else if (names.has(label)) {
|
||||
problems.push(`条件分支「${n.name || n.id}」出口名重复:「${label}」`);
|
||||
} else {
|
||||
names.add(label);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
edges.forEach((e: any) => {
|
||||
if (!nodeIds.has(e.to) || !nodeIds.has(e.from)) {
|
||||
@@ -674,7 +831,7 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
||||
:source="sourceJson" @node-config="onCanvasNode" />
|
||||
|
||||
<main v-if="!loading && editMode === 'cards'" class="ed-canvas">
|
||||
<div class="ed-hint">点击卡片配置节点 · 悬停虚线处 + 插入节点(审批 / 并行分支)</div>
|
||||
<div class="ed-hint">点击卡片配置节点 · 悬停虚线处 + 插入节点(审批 / 抄送 / 并行分支)</div>
|
||||
<div class="ed-flow">
|
||||
<div class="capsule start">发起开始</div>
|
||||
<NodeList :items="model.list" container="root" />
|
||||
@@ -696,6 +853,8 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
||||
</footer>
|
||||
|
||||
<ApprovalDrawer v-model="nodeDrawer" :node="editingNode" @removed="nodeDrawer = false" />
|
||||
<NotifyDrawer v-model="notifyDrawer" :node="editingNotify" @removed="notifyDrawer = false" />
|
||||
<ConditionDrawer v-model="conditionDrawer" :node="editingCondition" @removed="conditionDrawer = false" />
|
||||
<SettingsDrawer v-model="settingsDrawer" :settings="model.settings" @save="onUrgeSave" />
|
||||
<JsonDrawer v-model="jsonDrawer" :def="defJson" @applied="onJsonApplied" />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user