feat(admin): 抄送(NOTIFY)与条件分支(CONDITION)全栈接入编辑器/轨迹/管理面
build / build (push) Successful in 1m55s

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:
chenxing
2026-09-24 01:19:15 +08:00
parent cdf04f0930
commit e30e7ab665
20 changed files with 1614 additions and 84 deletions
+203 -44
View File
@@ -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>