diff --git a/src/api/module/flow.ts b/src/api/module/flow.ts index 4e67cb8..25511fe 100644 --- a/src/api/module/flow.ts +++ b/src/api/module/flow.ts @@ -67,6 +67,10 @@ class FlowApi { done(params: { page?: number; size?: number }) { return get(`${ROOT}/done`, params); } + /** 我的抄送(NOTIFY 节点落库;知会不占待办,独立页签) */ + ccMy(params: { page?: number; size?: number }) { + return get(`${ROOT}/cc/my`, params); + } /** 我提交的办件 */ my(params: { status?: number; page?: number; size?: number }) { return get(`${ROOT}/my`, params); @@ -204,6 +208,31 @@ class FlowApi { return post(`${ROOT}/admin/callback/repush`, ids); } + // ==================== 管理面:条件路由注册 / 重跑 ==================== + + /** 判断服务注册列表(flw_router 全局表;编辑器下拉与管理页共用) */ + routerList() { + return get(`${ROOT}/admin/router/list`); + } + routerSave(body: { + id?: number; + routerKey: string; + name: string; + serviceKey: string; + path: string; + basePath?: string; + }) { + return post(`${ROOT}/admin/router/save`, body); + } + /** 启用/停用注册(停用后发布校验拦截新引用) */ + routerStatus(id: number | string, enabled: boolean) { + return post(`${ROOT}/admin/router/status/${id}?enabled=${enabled}`); + } + /** 重跑路由(判断失败挂起的恢复入口:重新调判断,成功即按出口续走) */ + instanceReroute(instanceId: number | string) { + return post(`${ROOT}/admin/instance/${instanceId}/reroute`); + } + // ==================== 管理面:办件查询 ==================== adminInstances(params: { status?: number; keyword?: string; page?: number; size?: number }) { return get(`${ROOT}/admin/instance/page`, params); diff --git a/src/router/routes.ts b/src/router/routes.ts index 6a0ef28..d52b2aa 100644 --- a/src/router/routes.ts +++ b/src/router/routes.ts @@ -77,6 +77,13 @@ const routes: RouteRecordRaw[] = [ component: () => import("@/views/flow/admin/Callbacks.vue"), meta: { title: "回调管理", modId: 105 }, }, + { + // 条件路由维护(flw_router 注册表,2026-09-24):菜单需在 UserBase 侧配置同 path 的 app_module + path: "/admin/routers", + name: "flow_admin_routers", + component: () => import("@/views/flow/admin/Routers.vue"), + meta: { title: "条件路由", modId: 106 }, + }, { path: "/", redirect: "/workbench" }, // 兜底:UserMsg jumpPath / bizUrl 等外部深链若落到未登记路由,避免 no-match 静默空白页 { path: "/:pathMatch(.*)*", redirect: "/workbench" }, diff --git a/src/utils/flowLayout.ts b/src/utils/flowLayout.ts index 08835f3..7ede0b8 100644 --- a/src/utils/flowLayout.ts +++ b/src/utils/flowLayout.ts @@ -14,8 +14,10 @@ export const NODE_BOX: Record = { START: { w: 104, h: 40 }, END: { w: 104, h: 40 }, APPROVAL: { w: 184, h: 64 }, + NOTIFY: { w: 184, h: 64 }, PARALLEL_SPLIT: { w: 128, h: 72 }, PARALLEL_JOIN: { w: 128, h: 72 }, + CONDITION: { w: 128, h: 72 }, }; export interface Pos { diff --git a/src/views/flow/FlowDiagram.vue b/src/views/flow/FlowDiagram.vue index 9c41736..16c4599 100644 --- a/src/views/flow/FlowDiagram.vue +++ b/src/views/flow/FlowDiagram.vue @@ -403,6 +403,33 @@ watch(() => route.query, () => load()); {{ n.label }} + + + {{ n.label }}{{ n.subLabel ? '\n' + n.subLabel : '' }} + + {{ trunc(n.label, 8) }} + + + + {{ n.label }}{{ n.subLabel ? '\n' + n.subLabel : '' }} + + + {{ trunc(n.label, 10) }} + + + {{ trunc(n.subLabel, 15) }} + + {{ n.label }}{{ n.subLabel ? '\n' + n.subLabel : '' }} diff --git a/src/views/flow/Workbench.vue b/src/views/flow/Workbench.vue index 1d7cfd4..8287964 100644 --- a/src/views/flow/Workbench.vue +++ b/src/views/flow/Workbench.vue @@ -9,9 +9,10 @@ import { flowApi, TASK_STATUS, TASK_STATUS_COLOR, INSTANCE_STATUS } from "@/api/ import Utils from "@/utils"; const router = useRouter(); -const tab = ref<"todo" | "done">("todo"); +const tab = ref<"todo" | "done" | "cc">("todo"); const todoTableRef = ref | null>(null); const doneTableRef = ref | null>(null); +const ccTableRef = ref | null>(null); // tabs 页:表格高度在常规骨架上再扣 Tab 条 const tableHeight = ref(Utils.useTableHeight({ isTabs: true, isOperat: true, isPage: true })); @@ -48,22 +49,39 @@ const doneColumns = [ Utils.operatingHandle(h, "详情", "icon-browse", () => router.push(`/instance/${p.row.instanceId}?pid=100`)) }, ]; +// 抄送行是 NOTIFY 知会(非任务):无 taskId/status,直接跳办件详情 +const ccColumns = [ + { title: "序号", type: "index", width: 70, align: "center" }, + { title: "办件标题", key: "bizTitle", minWidth: 220 }, + { title: "抄送节点", key: "nodeName", width: 160 }, + { title: "抄送时间", width: 170, render: (h: any, p: any) => h("span", (p.row.sentTime || "").replace("T", " ")) }, + { title: "办件状态", width: 100, render: (h: any, p: any) => h("span", INSTANCE_STATUS[p.row.instanceStatus] || p.row.instanceStatus || "-") }, + { title: "操作", width: 100, align: "center", fixed: "right", render: (h: any, p: any) => + Utils.operatingHandle(h, "详情", "icon-browse", () => router.push(`/instance/${p.row.instanceId}?pid=100`)) }, +]; + // dataApi 返回 Method(alova usePagination 负责发送与解包),不能调 .send() const todoApi = (p: { pageIndex: number; pageSize: number }) => flowApi.todo({ page: p.pageIndex, size: p.pageSize }); const doneApi = (p: { pageIndex: number; pageSize: number }) => flowApi.done({ page: p.pageIndex, size: p.pageSize }); +const ccApi = (p: { pageIndex: number; pageSize: number }) => flowApi.ccMy({ page: p.pageIndex, size: p.pageSize }); function goHandle(row: any) { router.push(`/task/${row.taskId}?pid=100`); } -// 两个内容区 v-show 共存,已办首次切换才拉取(保持原惰性加载行为) +// 两个内容区 v-show 共存,已办/抄送首次切换才拉取(保持原惰性加载行为) const doneLoaded = ref(false); +const ccLoaded = ref(false); function switchTab(name: string) { tab.value = name as any; if (name === "done" && !doneLoaded.value) { doneLoaded.value = true; doneTableRef.value?.reloadData(OperatorEnum.sel); } + if (name === "cc" && !ccLoaded.value) { + ccLoaded.value = true; + ccTableRef.value?.reloadData(OperatorEnum.sel); + } } @@ -72,6 +90,7 @@ function switchTab(name: string) { +
@@ -80,5 +99,8 @@ function switchTab(name: string) {
+
+ +
diff --git a/src/views/flow/admin/Instances.vue b/src/views/flow/admin/Instances.vue index 7dffa8a..a4313fa 100644 --- a/src/views/flow/admin/Instances.vue +++ b/src/views/flow/admin/Instances.vue @@ -1,7 +1,10 @@ + + diff --git a/src/views/flow/admin/def-editor/bpmn/custom.ts b/src/views/flow/admin/def-editor/bpmn/custom.ts index 98b6997..747ba81 100644 --- a/src/views/flow/admin/def-editor/bpmn/custom.ts +++ b/src/views/flow/admin/def-editor/bpmn/custom.ts @@ -18,10 +18,13 @@ const TRANSLATIONS: Record = { "Create EndEvent": "创建结束", "Create Task": "创建审批节点", "Create User Task": "创建审批节点", + "Create Service Task": "创建抄送节点", "Create Gateway": "创建并行分支", "Create Gateways": "创建并行分支", "Create ParallelGateway": "创建并行分支", "Create expanded ParallelGateway": "创建并行分支", + "Create ExclusiveGateway": "创建条件分支", + "Create expanded ExclusiveGateway": "创建条件分支", "Append anything": "追加", "Change type": "变更类型", "Remove element": "删除", @@ -89,7 +92,9 @@ function createPaletteModule() { "lc-start": action("bpmn:StartEvent", "创建开始", "bpmn-icon-start-event-none"), "lc-end": action("bpmn:EndEvent", "创建结束", "bpmn-icon-end-event-none"), "lc-task": action("bpmn:UserTask", "创建审批节点", "bpmn-icon-user-task"), + "lc-notify": action("bpmn:ServiceTask", "创建抄送节点", "bpmn-icon-send-task"), "lc-gateway": action("bpmn:ParallelGateway", "创建并行分支", "bpmn-icon-gateway-parallel"), + "lc-condition": action("bpmn:ExclusiveGateway", "创建条件分支", "bpmn-icon-gateway-xor"), }; }; return { __init__: ["paletteProvider"], paletteProvider: ["type", CustomPaletteProvider] }; diff --git a/src/views/flow/admin/def-editor/bpmn/serialize.ts b/src/views/flow/admin/def-editor/bpmn/serialize.ts index 2980fc1..e8a530c 100644 --- a/src/views/flow/admin/def-editor/bpmn/serialize.ts +++ b/src/views/flow/admin/def-editor/bpmn/serialize.ts @@ -29,8 +29,10 @@ const BPMN_TYPE: Record = { START: "bpmn:StartEvent", END: "bpmn:EndEvent", APPROVAL: "bpmn:UserTask", + NOTIFY: "bpmn:ServiceTask", PARALLEL_SPLIT: "bpmn:ParallelGateway", PARALLEL_JOIN: "bpmn:ParallelGateway", + CONDITION: "bpmn:ExclusiveGateway", }; /** 把 contentJson 画到画布上(缺 layout 时自动布局) */ @@ -95,10 +97,17 @@ export function fromBpmn(modeler: any, base: CanvasJson, code: string, name: str case "bpmn:UserTask": { return "APPROVAL"; } + case "bpmn:ServiceTask": { + return "NOTIFY"; + } case "bpmn:ParallelGateway": { // 读出推断:>1 入 = 汇聚;否则分发(存储在 contentJson,故多数载入已在 JSON 有显式类型) return (n.incoming || []).length > 1 ? "PARALLEL_JOIN" : "PARALLEL_SPLIT"; } + case "bpmn:ExclusiveGateway": { + // CONDITION 无 join 对应物(排他网关不用于汇聚):多入口也按条件分支读出 + return "CONDITION"; + } default: return null; } @@ -113,11 +122,14 @@ export function fromBpmn(modeler: any, base: CanvasJson, code: string, name: str type, }; if (biz.name) node.name = biz.name; - if (type === "APPROVAL" && base) { - // 审批人等业务属性:从 base 中同 id 继承(编辑连线不影响已有配置) + if (type === "APPROVAL" || type === "NOTIFY" || type === "CONDITION") { + // 审批人/抄送对象/判断服务等业务属性:从 base 中同 id 继承(编辑连线不影响已有配置) const prev = base.nodes.find((x) => String(x.id) === String(node.id)); if (prev) { - ["assignee", "countersign", "deadlineHours"].forEach((k) => { + const keys = type === "APPROVAL" + ? ["assignee", "countersign", "deadlineHours"] + : type === "NOTIFY" ? ["receivers"] : ["routerKey"]; + keys.forEach((k) => { if (prev[k] !== undefined) node[k] = prev[k]; }); if (!node.name && prev.name) node.name = prev.name; @@ -168,8 +180,10 @@ const DI_SIZE: Record = { START: { w: 36, h: 36 }, END: { w: 36, h: 36 }, APPROVAL: { w: 150, h: 76 }, + NOTIFY: { w: 150, h: 76 }, PARALLEL_SPLIT: { w: 50, h: 50 }, PARALLEL_JOIN: { w: 50, h: 50 }, + CONDITION: { w: 50, h: 50 }, }; /** diff --git a/src/views/flow/admin/def-editor/components/ConditionBox.vue b/src/views/flow/admin/def-editor/components/ConditionBox.vue new file mode 100644 index 0000000..5bf9114 --- /dev/null +++ b/src/views/flow/admin/def-editor/components/ConditionBox.vue @@ -0,0 +1,183 @@ + + + + + diff --git a/src/views/flow/admin/def-editor/components/ConditionDrawer.vue b/src/views/flow/admin/def-editor/components/ConditionDrawer.vue new file mode 100644 index 0000000..3c71bfa --- /dev/null +++ b/src/views/flow/admin/def-editor/components/ConditionDrawer.vue @@ -0,0 +1,164 @@ + + + + + diff --git a/src/views/flow/admin/def-editor/components/NodeList.vue b/src/views/flow/admin/def-editor/components/NodeList.vue index e492c4b..8346b45 100644 --- a/src/views/flow/admin/def-editor/components/NodeList.vue +++ b/src/views/flow/admin/def-editor/components/NodeList.vue @@ -1,13 +1,15 @@ @@ -17,7 +19,9 @@ defineProps<{ diff --git a/src/views/flow/admin/def-editor/components/NotifyCard.vue b/src/views/flow/admin/def-editor/components/NotifyCard.vue new file mode 100644 index 0000000..118340d --- /dev/null +++ b/src/views/flow/admin/def-editor/components/NotifyCard.vue @@ -0,0 +1,133 @@ + + + + + diff --git a/src/views/flow/admin/def-editor/components/NotifyDrawer.vue b/src/views/flow/admin/def-editor/components/NotifyDrawer.vue new file mode 100644 index 0000000..ca410bd --- /dev/null +++ b/src/views/flow/admin/def-editor/components/NotifyDrawer.vue @@ -0,0 +1,239 @@ + + + + + diff --git a/src/views/flow/admin/def-editor/components/PlusButton.vue b/src/views/flow/admin/def-editor/components/PlusButton.vue index 3b2fc93..3af60b1 100644 --- a/src/views/flow/admin/def-editor/components/PlusButton.vue +++ b/src/views/flow/admin/def-editor/components/PlusButton.vue @@ -12,7 +12,7 @@ const props = defineProps<{ const ctx = inject(EDITOR_CTX)!; const visible = ref(false); -function pick(kind: "approval" | "parallel"): void { +function pick(kind: "approval" | "parallel" | "notify" | "condition"): void { visible.value = false; ctx.insertItem(props.container, props.index, kind); } @@ -30,6 +30,13 @@ function pick(kind: "approval" | "parallel"): void { 配置审批人 / 会签 / 时限 + + @@ -105,9 +119,15 @@ function pick(kind: "approval" | "parallel"): void { .mi-icon.approval { background: #2d8cf0; } +.mi-icon.notify { + background: #13a8a8; +} .mi-icon.parallel { background: #ff9900; } +.mi-icon.condition { + background: #9254de; +} .mi-text { display: flex; flex-direction: column; diff --git a/src/views/flow/admin/def-editor/convert.ts b/src/views/flow/admin/def-editor/convert.ts index 379d853..5e64864 100644 --- a/src/views/flow/admin/def-editor/convert.ts +++ b/src/views/flow/admin/def-editor/convert.ts @@ -2,8 +2,10 @@ * 编辑树 ⇄ contentJson 双向转换(方案 §3.3)。 * * toContentJson:隐式补 start/end,并行展开为 PARALLEL_SPLIT + PARALLEL_JOIN(waitFor=ALL), - * edges 按容器顺序自动串联——结构类校验(DefValidator 的孤儿/环/度约束)恒不触发。 - * fromContentJson:反解 SPLIT/JOIN 配对(各出边可达集求交,取 BFS 序最早的 JOIN),还原为编辑树。 + * 条件分支(2026-09-24)展开为 CONDITION 节点 + 命名出边(无 JOIN,分支尾隐式汇合 + * 到容器后继),edges 按容器顺序自动串联——结构类校验(DefValidator 的孤儿/环/度约束)恒不触发。 + * fromContentJson:反解 SPLIT/JOIN 配对(各出边可达集求交,取 BFS 序最早的 JOIN)与 CONDITION + * 汇合点(同法不限节点类型),还原为编辑树。 * 无法还原为卡片视图的非常规 DAG 抛中文错误,引导用户用"源码"高级模式。 * * 算法与《流程编辑器原型.html》同源,已在原型阶段全链路验证(含嵌套并行/环/跨分支连线)。 @@ -11,6 +13,7 @@ import { ApprovalNode, Assignee, + ConditionNode, Countersign, DEFAULT_URGE, DefEdgeJson, @@ -19,6 +22,7 @@ import { EditorItem, EditorModel, EditorSettings, + NotifyNode, ParallelNode, createApproval, newUid, @@ -35,8 +39,27 @@ export function toContentJson(model: EditorModel): DefJson { if (model.startName) startNode.name = model.startName; nodes.push(startNode); - const walk = (items: EditorItem[], prev: string): string => { - let cursor = prev; + /** + * 条件分支(2026-09-24)无 JOIN 语义:分支尾不互连,隐式汇合到容器后继——cursor 置 null 后 + * 下一个节点(或 END)只接收各分支尾的连线;firstEdgeName 只作用于分支首边(出口名)。 + * walk 返回"尾集合"(普通序列=末节点,末尾含条件分支=各分支尾)。 + */ + const walk = (items: EditorItem[], prev: string, firstEdgeName?: string): string[] => { + let cursor: string | null = prev; + let pending: string[] = []; + let first = true; + const linkTo = (toId: string): void => { + if (cursor !== null) { + if (first && firstEdgeName) { + edges.push({ from: cursor, to: toId, name: firstEdgeName }); + } else { + edges.push({ from: cursor, to: toId }); + } + } + pending.forEach((t) => edges.push({ from: t, to: toId })); + pending = []; + first = false; + }; items.forEach((item) => { if (item.kind === "approval") { const id = item.uid; @@ -49,26 +72,57 @@ export function toContentJson(model: EditorModel): DefJson { if (item.countersign === "ANY") node.countersign = "ANY"; if (item.deadlineHours) node.deadlineHours = item.deadlineHours; nodes.push(node); - edges.push({ from: cursor, to: id }); + linkTo(id); cursor = id; return; } + if (item.kind === "notify") { + const id = item.uid; + const node: DefNodeJson = { + id, + type: "NOTIFY", + name: item.name || "抄送", + receivers: cloneAssignee(item.receivers), + }; + nodes.push(node); + linkTo(id); + cursor = id; + return; + } + if (item.kind === "condition") { + const condId = `${item.uid}c`; + nodes.push({ + id: condId, + type: "CONDITION", + name: item.name || "条件判断", + routerKey: item.routerKey || "", + }); + linkTo(condId); + const tails: string[] = []; + item.branches.forEach((branch, bi) => { + tails.push(...walk(branch, condId, item.outlets[bi])); + }); + cursor = null; + pending = tails; + return; + } const split = `${item.uid}s`; const join = `${item.uid}j`; nodes.push({ id: split, type: "PARALLEL_SPLIT" }); nodes.push({ id: join, type: "PARALLEL_JOIN", waitFor: "ALL" }); - edges.push({ from: cursor, to: split }); + linkTo(split); item.branches.forEach((branch) => { - const tail = walk(branch, split); - edges.push({ from: tail, to: join }); + walk(branch, split).forEach((tail) => edges.push({ from: tail, to: join })); }); cursor = join; }); - return cursor; + const tails: string[] = []; + if (cursor !== null) tails.push(cursor); + tails.push(...pending); + return tails; }; - const tail = walk(model.list, "start"); - edges.push({ from: tail, to: "end" }); + walk(model.list, "start").forEach((tail) => edges.push({ from: tail, to: "end" })); const endNode: DefNodeJson = { id: "end", type: "END" }; if (model.endName) endNode.name = model.endName; nodes.push(endNode); @@ -176,6 +230,89 @@ export function fromContentJson(def: any): ParsedDef { return candidates[0].id; } + /** + * CONDITION 汇合点(2026-09-24):无 JOIN 节点,分支尾隐式连到同一后继—— + * 各出边可达集求交(不限节点类型)取 BFS 序最早者即汇合点(分支内部节点不可能被全分支公共可达)。 + */ + function findMerge(condId: string): string { + const order = new Map([[condId, 0]]); + const queue: string[] = [condId]; + while (queue.length) { + const cur = queue.shift()!; + (out[cur] || []).forEach((to) => { + if (!order.has(to)) { + order.set(to, order.size); + queue.push(to); + } + }); + } + const reachSets = (out[condId] || []).map((head) => { + const set = new Set(); + const q: string[] = [head]; + while (q.length) { + const cur = q.shift()!; + if (set.has(cur)) continue; + set.add(cur); + (out[cur] || []).forEach((to) => q.push(to)); + } + return set; + }); + let common = reachSets[0] || new Set(); + reachSets.forEach((set) => { + common = new Set([...common].filter((x) => set.has(x))); + }); + const candidates = nodes + .filter((n) => n.id !== condId && common.has(n.id)) + .sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)); + if (!candidates.length) { + throw new Error("条件分支未汇聚到同一个节点,无法还原为卡片视图"); + } + return candidates[0].id; + } + + /** CONDITION 反解:出口名取 cond→分支首节点的边 name(判断接口 outlets 契约) */ + function makeCondition(condId: string): { item: ConditionNode; mergeId: string } { + const n = byId[condId]; + const heads = out[condId] || []; + if (heads.length < 2) { + throw new Error(`条件分支节点「${n.name || condId}」至少 2 条出边`); + } + const mergeId = findMerge(condId); + // 不预占 seen:汇合点是普通节点,交回外层循环正常消费(区别于并行 join 的"跳过式"处理) + const branches = heads.map((head) => { + const r = walkList(head, mergeId); + if (r.endedAt !== mergeId) { + throw new Error("条件分支未汇聚到同一个节点,无法还原为卡片视图"); + } + return r.list; + }); + const outlets = heads.map((head) => { + const e = edges.find((x) => x.from === condId && x.to === head); + return e && e.name ? String(e.name) : ""; + }); + if (outlets.some((o) => !o || !o.trim())) { + throw new Error(`条件分支节点「${n.name || condId}」的每个出口必须命名(出口名即判断依据)`); + } + const dup = outlets.find((o, i) => outlets.indexOf(o) !== i); + if (dup) { + throw new Error(`条件分支出口名重复:「${dup}」(同一节点的出口名必须唯一)`); + } + // uid 复用 JSON id 并剥掉生成态的 "c" 尾缀:uid+"c" 是本编辑器的 JSON id 生成约定, + // 剥尾保证"图→树→图→树→图"的 id 收敛(否则每轮追加一个 c,往返不幂等) + const uidRaw = condId.endsWith("c") ? condId.slice(0, -1) : condId; + return { + item: { + uid: UsedIds.take(uidRaw), + kind: "condition", + name: n.name || "条件判断", + routerKey: n.routerKey || "", + outlets, + branches: branches.map((b) => (b.length ? b : [createApproval()])), + }, + mergeId, + }; + } + function walkList(cur: string, stopAt?: string): { list: EditorItem[]; endedAt: string } { const list: EditorItem[] = []; while (true) { @@ -191,6 +328,12 @@ export function fromContentJson(def: any): ParsedDef { } list.push(toApprovalNode(n)); cur = out[cur][0]; + } else if (n.type === "NOTIFY") { + if ((out[cur] || []).length !== 1) { + throw new Error(`抄送节点「${n.name || cur}」的出边必须恰好 1 条`); + } + list.push(toNotifyNode(n)); + cur = out[cur][0]; } else if (n.type === "PARALLEL_SPLIT") { const heads = out[cur] || []; if (!heads.length) throw new Error("并行分发节点没有分支"); @@ -206,6 +349,11 @@ export function fromContentJson(def: any): ParsedDef { list.push(makeParallel(branches)); if ((out[joinId] || []).length !== 1) throw new Error("汇聚节点出边必须恰好 1 条"); cur = out[joinId][0]; + } else if (n.type === "CONDITION") { + // 条件分支容器:反解后 cur=汇合点(下一节点/END/外层容器的 join),循环顶部统一续走 + const made = makeCondition(cur); + list.push(made.item); + cur = made.mergeId; } else if (n.type === "END") { return { list, endedAt: cur }; } else if (n.type === "PARALLEL_JOIN") { @@ -258,6 +406,15 @@ function toApprovalNode(n: any): ApprovalNode { }; } +function toNotifyNode(n: any): NotifyNode { + return { + uid: UsedIds.take(n.id), + kind: "notify", + name: n.name || "抄送", + receivers: n.receivers ? cloneAssignee(n.receivers as Assignee) : null, + }; +} + /** 载入会话内已占用的 uid 集(防重复/缺 id 撞车) */ const UsedIds = { set: new Set(), diff --git a/src/views/flow/admin/def-editor/index.vue b/src/views/flow/admin/def-editor/index.vue index 4fe42f0..b754833 100644 --- a/src/views/flow/admin/def-editor/index.vue +++ b/src/views/flow/admin/def-editor/index.vue @@ -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(createEmptyModel()); const depts = reactive([]); /** userId → 姓名快照(mapLookupUser 检索补全,失败降级 #id) */ const userNames = reactive>({}); +/** 条件分支的判断服务注册(flw_router;拉取失败为空数组,校验降级提示) */ +const routers = reactive([]); // ==================== 双模式统一(2026-09-17):卡片树 = 唯一数据源,画布 = 其可视化投影 ==================== /** @@ -78,30 +87,58 @@ const canvasConfigs = reactive>({}); 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(() => validateModel(model, depts)); +const issues = computed(() => 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(() => { 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(() => { + 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(() => { + 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 { @@ -245,6 +332,16 @@ async function resolveUserNames(): Promise { } } +/** 条件分支的判断服务注册下拉(flw_router 启用项;失败不阻塞编辑,校验降级提示) */ +async function loadRouters(): Promise { + 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 { 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(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(); + 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" />
-
点击卡片配置节点 · 悬停虚线处 + 插入节点(审批 / 并行分支)
+
点击卡片配置节点 · 悬停虚线处 + 插入节点(审批 / 抄送 / 并行分支)
发起开始
@@ -696,6 +853,8 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload)); + +
diff --git a/src/views/flow/admin/def-editor/model.ts b/src/views/flow/admin/def-editor/model.ts index f337365..c61ae5b 100644 --- a/src/views/flow/admin/def-editor/model.ts +++ b/src/views/flow/admin/def-editor/model.ts @@ -30,6 +30,14 @@ export interface ApprovalNode { deadlineHours: number | null; } +/** 抄送节点(2026-09-23):瞬时知会,不产生待办;receivers 复用审批人结构(DEPT_KEY/USERS) */ +export interface NotifyNode { + uid: string; + kind: "notify"; + name: string; + receivers: Assignee | null; +} + export interface ParallelNode { uid: string; kind: "parallel"; @@ -37,7 +45,23 @@ export interface ParallelNode { branches: EditorItem[][]; } -export type EditorItem = ApprovalNode | ParallelNode; +/** + * 条件分支容器(2026-09-24 CONDITION):运行时同步调外部判断服务(flw_router,routerKey 寻址) + * 按返回出口名互斥选路——与并行的本质差异:无 JOIN 语义,分支尾隐式汇合到容器后继。 + * outlets[i] 即第 i 条分支首边的 name(判断接口的 outlets 契约),必须非空且唯一。 + */ +export interface ConditionNode { + uid: string; + kind: "condition"; + name: string; + /** 判断服务注册键(flw_router.router_key,发布校验须已注册且启用) */ + routerKey: string; + /** 各分支出口名(与 branches 一一对应) */ + outlets: string[]; + branches: EditorItem[][]; +} + +export type EditorItem = ApprovalNode | NotifyNode | ParallelNode | ConditionNode; export interface UrgeSetting { enabled: boolean; @@ -72,6 +96,10 @@ export interface DefNodeJson { type: string; name?: string; assignee?: Assignee; + /** NOTIFY 节点的抄送接收人(与审批人同结构) */ + receivers?: Assignee; + /** CONDITION 节点的判断服务注册键(flw_router.router_key) */ + routerKey?: string; countersign?: Countersign; waitFor?: string; deadlineHours?: number; @@ -125,10 +153,26 @@ export function createApproval(name = "审批节点", assignee: Assignee | null return { uid: newUid(), kind: "approval", name, assignee, countersign: "ANY", deadlineHours: null }; } +export function createNotify(name = "抄送节点"): NotifyNode { + return { uid: newUid(), kind: "notify", name, receivers: { type: "USERS", userIds: [] } }; +} + export function createParallel(): ParallelNode { return { uid: newUid(), kind: "parallel", branches: [[createApproval()], [createApproval()]] }; } +/** 条件分支:默认 2 分支占位出口名,插入后弹配置抽屉选判断服务 */ +export function createCondition(): ConditionNode { + return { + uid: newUid(), + kind: "condition", + name: "条件判断", + routerKey: "", + outlets: ["出口一", "出口二"], + branches: [[createApproval()], [createApproval()]], + }; +} + /** 新建流程的默认模板:一个待配置的审批节点(引导用户点卡片配置) */ export function createEmptyModel(): EditorModel { return { @@ -142,12 +186,18 @@ export function createEmptyModel(): EditorModel { // ==================== 遍历/查询 ==================== export interface ContainerRef { - /** 容器标识:root 或 `${parallelUid}:${branchIndex}` */ + /** 容器标识:root 或 `${容器uid}:${branchIndex}`(并行/条件分支容器同格式,uid 天然不撞) */ container: string; index: number; items: EditorItem[]; } +/** 容器型节点(并行/条件分支)的分支数组取用(resolveContainer/removeItem 等共用口径) */ +export function branchesOf(item: EditorItem | null): EditorItem[][] | null { + if (item && (item.kind === "parallel" || item.kind === "condition")) return item.branches; + return null; +} + export function walkItems( items: EditorItem[], fn: (item: EditorItem, container: string, index: number) => void, @@ -155,8 +205,9 @@ export function walkItems( ): void { items.forEach((item, index) => { fn(item, container, index); - if (item.kind === "parallel") { - item.branches.forEach((branch, bi) => walkItems(branch, fn, `${item.uid}:${bi}`)); + const branches = branchesOf(item); + if (branches) { + branches.forEach((branch, bi) => walkItems(branch, fn, `${item.uid}:${bi}`)); } }); } @@ -174,13 +225,13 @@ export function findParallel(model: EditorModel, uid: string): ParallelNode | nu return item && item.kind === "parallel" ? item : null; } -/** 解析容器标识为可变数组引用(插入/删除用) */ +/** 解析容器标识为可变数组引用(插入/删除用;并行/条件分支容器同格式) */ export function resolveContainer(model: EditorModel, container: string): EditorItem[] | null { if (container === "root") return model.list; - const [parallelUid, bi] = container.split(":"); - const parent = findParallel(model, parallelUid); - if (!parent) return null; - return parent.branches[Number(bi)] || null; + const [parentUid, bi] = container.split(":"); + const branches = branchesOf(findItem(model, parentUid)); + if (!branches) return null; + return branches[Number(bi)] || null; } /** 按 uid 删除节点(跨层级);返回是否命中 */ @@ -194,26 +245,40 @@ export function removeItem(model: EditorModel, uid: string): boolean { return; } items.forEach((it) => { - if (!removed && it.kind === "parallel") it.branches.forEach(rec); + if (!removed) branchesOf(it)?.forEach(rec); }); }; rec(model.list); return removed; } -/** 收集全部 USERS 型审批人 userId(用于补全姓名快照) */ +/** 收集全部 USERS 型审批人/抄送对象 userId(用于补全姓名快照) */ export function collectUserIds(model: EditorModel): number[] { const ids = new Set(); walkItems(model.list, (item) => { if (item.kind === "approval" && item.assignee?.type === "USERS") { item.assignee.userIds.forEach((id) => ids.add(Number(id))); } + if (item.kind === "notify" && item.receivers?.type === "USERS") { + item.receivers.userIds.forEach((id) => ids.add(Number(id))); + } }); return [...ids]; } // ==================== 展示辅助 ==================== +/** flw_router 注册下拉项(管理端 /admin/router/list;status=1 启用) */ +export interface RouterOption { + id: number; + routerKey: string; + name: string; + serviceKey: string; + path: string; + basePath?: string | null; + status: number; +} + export interface DeptOption { deptKey: string; deptName: string; @@ -242,18 +307,35 @@ export function nodeTags(n: ApprovalNode): string[] { return tags; } +/** 抄送对象摘要文案(抄送卡片第二行;口径同 assigneeText,前缀换"抄送") */ +export function receiversText(r: Assignee | null, depts: DeptOption[], userNames: Record): string { + if (!r) return "未配置抄送对象"; + if (r.type === "DEPT_KEY") { + if (!r.deptKey) return "抄送:未选择科室"; + const d = depts.find((x) => x.deptKey === r.deptKey); + return d + ? `抄送:${d.deptName || d.deptKey}(${d.deptKey})· ${d.count} 人` + : `抄送:${r.deptKey}(未配置映射)`; + } + if (!r.userIds.length) return "抄送:未选择用户"; + return `抄送:${r.userIds.map((id) => userNames[id] || `#${id}`).join("、")}`; +} + // ==================== 编辑器上下文(画布子树共享,避免逐层透传回调) ==================== export interface EditorCtx { model: EditorModel; depts: DeptOption[]; userNames: Record; + /** 条件分支的判断服务注册下拉(flw_router 启用项;拉取失败为空数组,校验降级提示) */ + routers: RouterOption[]; issueOf: (uid: string) => { level: "error" | "warn"; text: string }[]; openNode: (uid: string) => void; removeNode: (uid: string) => void; - insertItem: (container: string, index: number, kind: "approval" | "parallel") => void; - addBranch: (parallelUid: string) => void; - removeBranch: (parallelUid: string, index: number) => void; + insertItem: (container: string, index: number, kind: "approval" | "parallel" | "notify" | "condition") => void; + /** 添加分支(并行/条件分支容器通用;条件分支同步补一个占位出口名) */ + addBranch: (containerUid: string) => void; + removeBranch: (containerUid: string, index: number) => void; } export const EDITOR_CTX: InjectionKey = Symbol("flowDefEditorCtx"); diff --git a/src/views/flow/admin/def-editor/validate.ts b/src/views/flow/admin/def-editor/validate.ts index 8819d83..99f6dba 100644 --- a/src/views/flow/admin/def-editor/validate.ts +++ b/src/views/flow/admin/def-editor/validate.ts @@ -5,7 +5,15 @@ * 结构类规则(id 唯一/度约束/孤儿/环/START·END 唯一)由结构化生成保证,前端无需校验。 * 后端 DefValidator 仍是发布期权威,此处仅作体验前置。 */ -import { BRANCH_LABELS, DeptOption, EditorItem, EditorModel, MAX_BRANCHES, MIN_BRANCHES } from "./model"; +import { + BRANCH_LABELS, + DeptOption, + EditorItem, + EditorModel, + MAX_BRANCHES, + MIN_BRANCHES, + RouterOption, +} from "./model"; export type IssueLevel = "error" | "warn"; @@ -20,7 +28,7 @@ export interface Issue { text: string; } -export function validateModel(model: EditorModel, depts: DeptOption[]): Issue[] { +export function validateModel(model: EditorModel, depts: DeptOption[], routers: RouterOption[] = []): Issue[] { const issues: Issue[] = []; const push = (uid: string, level: IssueLevel, path: string, msg: string): void => { issues.push({ uid, level, path, msg, text: `${path}:${msg}` }); @@ -50,6 +58,62 @@ export function validateModel(model: EditorModel, depts: DeptOption[]): Issue[] return; } + if (item.kind === "notify") { + // 抄送节点(运行时宽容:解析不出人只跳过不阻塞,但发布期后端仍拦配置缺失) + if (!item.name.trim()) push(item.uid, "error", path, "节点名称不能为空"); + const r = item.receivers; + if (!r) { + push(item.uid, "error", path, "未配置抄送对象"); + } else if (r.type === "DEPT_KEY") { + if (!r.deptKey) { + push(item.uid, "error", path, "未选择抄送科室"); + } else { + const d = depts.find((x) => x.deptKey === r.deptKey); + if (!d) { + push(item.uid, "warn", path, `科室映射「${r.deptKey}」不存在,发布会被拦截`); + } else if (!d.count) { + push(item.uid, "warn", path, `科室「${d.deptName || r.deptKey}」无启用人员,抄送会落空`); + } + } + } else if (!r.userIds.length) { + push(item.uid, "error", path, "抄送用户不能为空"); + } + return; + } + + if (item.kind === "condition") { + // 条件分支(2026-09-24):判断服务必选(未注册仅 warn,发布期后端权威拦截); + // 出口名即判断接口 outlets 契约,非空唯一(镜像后端 CONDITION 校验口径) + if (!item.name.trim()) push(item.uid, "error", path, "节点名称不能为空"); + if (!item.routerKey) { + push(item.uid, "error", path, "未选择判断服务(发布后运行到该节点会挂起)"); + } else if (routers.length && !routers.some((r) => r.routerKey === item.routerKey && r.status === 1)) { + push(item.uid, "warn", path, `判断服务「${item.routerKey}」未注册或已停用,发布会被拦截`); + } + if (item.branches.length < MIN_BRANCHES) { + push(item.uid, "error", path, `条件分支至少保留 ${MIN_BRANCHES} 个`); + } + if (item.branches.length > MAX_BRANCHES) { + push(item.uid, "error", path, `条件分支最多 ${MAX_BRANCHES} 个`); + } + item.outlets.forEach((o, i) => { + if (!o.trim()) { + push(item.uid, "error", path, `第 ${i + 1} 条分支的出口名不能为空`); + } else if (item.outlets.indexOf(o) !== i) { + push(item.uid, "error", path, `出口名重复:「${o}」(出口名即判断依据,必须唯一)`); + } + }); + item.branches.forEach((branch, i) => { + const branchPath = `${path}·出口「${item.outlets[i] || BRANCH_LABELS[i] || i + 1}」`; + if (!branch.length) { + push(item.uid, "error", branchPath, "分支为空,请添加节点"); + return; + } + rec(branch, branchPath); + }); + return; + } + if (item.branches.length < MIN_BRANCHES) { push(item.uid, "error", path, `并行分支至少保留 ${MIN_BRANCHES} 个`); } diff --git a/src/views/flow/components/TraceTimeline.vue b/src/views/flow/components/TraceTimeline.vue index f4a9ba5..69af007 100644 --- a/src/views/flow/components/TraceTimeline.vue +++ b/src/views/flow/components/TraceTimeline.vue @@ -25,6 +25,8 @@ const KIND_DOT: Record = { TRANSFER: "cyan", INVALID: "gray", PENDING: "blue", + CC: "cyan", + ROUTE: "purple", }; /** 事件类型 → 动作标签主题色(标签文案取 TASK_STATUS,避免两处口径漂移) */ @@ -38,6 +40,8 @@ const KIND_TAG: Record = { TRANSFER: "cyan", INVALID: "default", PENDING: "blue", + CC: "cyan", + ROUTE: "purple", }; const rows = computed(() => props.trace || []); @@ -59,6 +63,7 @@ function roundTitle(round: number): string { /** 操作者:按人员=姓名;按科室=科室名/指定办理人/提交人(不暴露具体人名) */ function actor(t: any): string { + if (t.kind === "ROUTE") return "流程引擎"; if (props.mode !== "dept") { return t.assigneeName || t.assigneeId || "-"; } @@ -74,8 +79,11 @@ function actor(t: any): string { : (t.assigneeName || "-"); } -/** 动作标签:失效行标"失效"而不是笼统的"提交" */ +/** 动作标签:失效行标"失效"而不是笼统的"提交";抄送行(flw_cc,非任务无状态码)标"已抄送"; + * 路由判断行(flw_route_log)标"系统判断"——outlet 挂在行上,空=判断失败挂起 */ function actionText(t: any): string { + if (t.kind === "CC") return "已抄送"; + if (t.kind === "ROUTE") return "系统判断"; return TASK_STATUS[t.status] || String(t.status ?? ""); } @@ -99,6 +107,10 @@ function fmtTime(v: string | null | undefined): string { {{ actionText(t) }} 「{{ t.opinion }}」 去向:{{ t.edgeName }} + → {{ t.outlet }} + + 判断失败已挂起(第 {{ t.attempt || 1 }} 次),等待管理端重跑路由 +
{{ fmtTime(t.time || t.handleTime || t.arriveTime) }}
@@ -122,6 +134,8 @@ function fmtTime(v: string | null | undefined): string { .line { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; } .node { font-weight: 600; } .opinion { color: #666; } +.opinion.route { color: #722ed1; font-weight: 600; } +.opinion.route-hung { color: #ed4014; } .time { color: #aaa; font-size: 12px; } .muted { color: #999; }