From 842a4cfb14698cee698fc8739017f6e926cb94bb Mon Sep 17 00:00:00 2001 From: chenxing Date: Wed, 16 Sep 2026 01:21:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8A=9E=E7=90=86=E4=BA=BA=E6=98=A0?= =?UTF-8?q?=E5=B0=84=E9=A1=B5=E4=B8=80=E9=94=AE=E6=8B=89=E5=8F=96=E6=95=99?= =?UTF-8?q?=E5=B8=88=E7=BB=84=EF=BC=88=E9=A2=84=E8=A7=88=E7=A1=AE=E8=AE=A4?= =?UTF-8?q?+=E6=89=B9=E9=87=8F=E5=AF=BC=E5=85=A5=EF=BC=89+=20=E6=B5=81?= =?UTF-8?q?=E7=A8=8B=E7=BC=96=E8=BE=91=E5=99=A8=E5=AD=98=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Maps.vue:一键拉取教师组按钮 + 预览弹窗(科室/成员明细)+ 确认导入 - flow.ts:mapGroupPreview/mapImportGroups API - 含流程编辑器(def-editor)、路由 catch-all、示意图入口存量 --- src/api/module/flow.ts | 8 + src/router/routes.ts | 8 + src/views/flow/admin/Defs.vue | 85 +-- src/views/flow/admin/Maps.vue | 51 ++ .../def-editor/components/ApprovalDrawer.vue | 259 +++++++++ .../def-editor/components/JsonDrawer.vue | 128 +++++ .../admin/def-editor/components/NodeCard.vue | 136 +++++ .../admin/def-editor/components/NodeList.vue | 32 ++ .../def-editor/components/ParallelBox.vue | 136 +++++ .../def-editor/components/PlusButton.vue | 123 +++++ .../def-editor/components/SettingsDrawer.vue | 125 +++++ src/views/flow/admin/def-editor/convert.ts | 260 +++++++++ src/views/flow/admin/def-editor/index.vue | 493 ++++++++++++++++++ src/views/flow/admin/def-editor/model.ts | 252 +++++++++ src/views/flow/admin/def-editor/validate.ts | 81 +++ 15 files changed, 2105 insertions(+), 72 deletions(-) create mode 100644 src/views/flow/admin/def-editor/components/ApprovalDrawer.vue create mode 100644 src/views/flow/admin/def-editor/components/JsonDrawer.vue create mode 100644 src/views/flow/admin/def-editor/components/NodeCard.vue create mode 100644 src/views/flow/admin/def-editor/components/NodeList.vue create mode 100644 src/views/flow/admin/def-editor/components/ParallelBox.vue create mode 100644 src/views/flow/admin/def-editor/components/PlusButton.vue create mode 100644 src/views/flow/admin/def-editor/components/SettingsDrawer.vue create mode 100644 src/views/flow/admin/def-editor/convert.ts create mode 100644 src/views/flow/admin/def-editor/index.vue create mode 100644 src/views/flow/admin/def-editor/model.ts create mode 100644 src/views/flow/admin/def-editor/validate.ts diff --git a/src/api/module/flow.ts b/src/api/module/flow.ts index 50b70f7..cdb8799 100644 --- a/src/api/module/flow.ts +++ b/src/api/module/flow.ts @@ -114,6 +114,14 @@ class FlowApi { mapLookupUser(userId: number) { return get(`${ROOT}/admin/map/lookupUser`, { userId }); } + /** 一键拉取教师组·预览(学校子树教师组容器 → 科室+组内成员) */ + mapGroupPreview() { + return get(`${ROOT}/admin/map/groupPreview`); + } + /** 一键拉取教师组·导入(批量落映射,已存在跳过) */ + mapImportGroups() { + return post(`${ROOT}/admin/map/importGroups`); + } // ==================== 管理面:回调注册/日志/死信 ==================== regList() { diff --git a/src/router/routes.ts b/src/router/routes.ts index c0f168b..e329525 100644 --- a/src/router/routes.ts +++ b/src/router/routes.ts @@ -52,6 +52,14 @@ const routes: RouteRecordRaw[] = [ component: () => import("@/views/flow/admin/Defs.vue"), meta: { title: "流程定义", modId: 103 }, }, + { + // 流程编辑器(钉钉式卡片流;深链页不挂菜单,同 /diagram 模式)。 + // query:空=新建;id=编辑草稿;id+baseGuid=升版本(载入该版内容为底稿) + path: "/admin/defs/editor", + name: "flow_def_editor", + component: () => import("@/views/flow/admin/def-editor/index.vue"), + meta: { title: "流程编辑器" }, + }, { path: "/admin/maps", name: "flow_admin_maps", diff --git a/src/views/flow/admin/Defs.vue b/src/views/flow/admin/Defs.vue index 6d0ca0f..85e8e15 100644 --- a/src/views/flow/admin/Defs.vue +++ b/src/views/flow/admin/Defs.vue @@ -1,5 +1,7 @@ + + + + diff --git a/src/views/flow/admin/def-editor/components/JsonDrawer.vue b/src/views/flow/admin/def-editor/components/JsonDrawer.vue new file mode 100644 index 0000000..b63b378 --- /dev/null +++ b/src/views/flow/admin/def-editor/components/JsonDrawer.vue @@ -0,0 +1,128 @@ + + + + + diff --git a/src/views/flow/admin/def-editor/components/NodeCard.vue b/src/views/flow/admin/def-editor/components/NodeCard.vue new file mode 100644 index 0000000..552e8eb --- /dev/null +++ b/src/views/flow/admin/def-editor/components/NodeCard.vue @@ -0,0 +1,136 @@ + + + + + diff --git a/src/views/flow/admin/def-editor/components/NodeList.vue b/src/views/flow/admin/def-editor/components/NodeList.vue new file mode 100644 index 0000000..e492c4b --- /dev/null +++ b/src/views/flow/admin/def-editor/components/NodeList.vue @@ -0,0 +1,32 @@ + + + + + diff --git a/src/views/flow/admin/def-editor/components/ParallelBox.vue b/src/views/flow/admin/def-editor/components/ParallelBox.vue new file mode 100644 index 0000000..909bfab --- /dev/null +++ b/src/views/flow/admin/def-editor/components/ParallelBox.vue @@ -0,0 +1,136 @@ + + + + + diff --git a/src/views/flow/admin/def-editor/components/PlusButton.vue b/src/views/flow/admin/def-editor/components/PlusButton.vue new file mode 100644 index 0000000..3b2fc93 --- /dev/null +++ b/src/views/flow/admin/def-editor/components/PlusButton.vue @@ -0,0 +1,123 @@ + + + + + diff --git a/src/views/flow/admin/def-editor/components/SettingsDrawer.vue b/src/views/flow/admin/def-editor/components/SettingsDrawer.vue new file mode 100644 index 0000000..36935ee --- /dev/null +++ b/src/views/flow/admin/def-editor/components/SettingsDrawer.vue @@ -0,0 +1,125 @@ + + + + + diff --git a/src/views/flow/admin/def-editor/convert.ts b/src/views/flow/admin/def-editor/convert.ts new file mode 100644 index 0000000..b6a8bd4 --- /dev/null +++ b/src/views/flow/admin/def-editor/convert.ts @@ -0,0 +1,260 @@ +/** + * 编辑树 ⇄ contentJson 双向转换(方案 §3.3)。 + * + * toContentJson:隐式补 start/end,并行展开为 PARALLEL_SPLIT + PARALLEL_JOIN(waitFor=ALL), + * edges 按容器顺序自动串联——结构类校验(DefValidator 的孤儿/环/度约束)恒不触发。 + * fromContentJson:反解 SPLIT/JOIN 配对(各出边可达集求交,取 BFS 序最早的 JOIN),还原为编辑树。 + * 无法还原为卡片视图的非常规 DAG 抛中文错误,引导用户用"源码"高级模式。 + * + * 算法与《流程编辑器原型.html》同源,已在原型阶段全链路验证(含嵌套并行/环/跨分支连线)。 + */ +import { + ApprovalNode, + Assignee, + Countersign, + DEFAULT_URGE, + DefEdgeJson, + DefJson, + DefNodeJson, + EditorItem, + EditorModel, + EditorSettings, + ParallelNode, + createApproval, + newUid, +} from "./model"; + +/** 编辑树 → 后端 contentJson(code/name 恒取表单值,规避 contentJson 覆盖行字段的既有坑) */ +export function toContentJson(model: EditorModel): DefJson { + const nodes: DefNodeJson[] = []; + const edges: DefEdgeJson[] = []; + let seq = 1; + const nid = (p: string) => `${p}${seq++}`; + + // 端点 name 仅在既有定义带值时回写(新建流程不生成,保持最小 JSON) + const startNode: DefNodeJson = { id: "start", type: "START" }; + if (model.startName) startNode.name = model.startName; + nodes.push(startNode); + + const walk = (items: EditorItem[], prev: string): string => { + let cursor = prev; + items.forEach((item) => { + if (item.kind === "approval") { + const id = nid("n"); + const node: DefNodeJson = { + id, + type: "APPROVAL", + name: item.name || "审批", + assignee: cloneAssignee(item.assignee), + }; + if (item.countersign === "ANY") node.countersign = "ANY"; + if (item.deadlineHours) node.deadlineHours = item.deadlineHours; + nodes.push(node); + edges.push({ from: cursor, to: id }); + cursor = id; + return; + } + const split = nid("split"); + const join = nid("join"); + nodes.push({ id: split, type: "PARALLEL_SPLIT" }); + nodes.push({ id: join, type: "PARALLEL_JOIN", waitFor: "ALL" }); + edges.push({ from: cursor, to: split }); + item.branches.forEach((branch) => { + const tail = walk(branch, split); + edges.push({ from: tail, to: join }); + }); + cursor = join; + }); + return cursor; + }; + + const tail = walk(model.list, "start"); + edges.push({ from: tail, to: "end" }); + const endNode: DefNodeJson = { id: "end", type: "END" }; + if (model.endName) endNode.name = model.endName; + nodes.push(endNode); + + return { + code: model.code, + name: model.name, + start: "start", + nodes, + edges, + settings: { + rejectPolicy: "END_REJECTED", + urge: { ...model.settings.urge }, + }, + }; +} + +function cloneAssignee(a: Assignee | null): Assignee { + if (!a) return { type: "DEPT_KEY", deptKey: "" }; + return a.type === "USERS" ? { type: "USERS", userIds: [...a.userIds] } : { type: "DEPT_KEY", deptKey: a.deptKey }; +} + +export interface ParsedDef { + code: string; + name: string; + list: EditorItem[]; + settings: EditorSettings; + /** 端点 name 原样带出(见 EditorModel.startName 注释) */ + startName?: string; + endName?: string; +} + +/** + * contentJson → 编辑树。抛错即为"不可还原为卡片视图",调用方展示错误并建议源码模式。 + */ +export function fromContentJson(def: any): ParsedDef { + if (!def || !Array.isArray(def.nodes) || !Array.isArray(def.edges)) { + throw new Error("JSON 需包含 nodes 与 edges 数组"); + } + const nodes: any[] = def.nodes; + const edges: any[] = def.edges; + + const byId: Record = {}; + nodes.forEach((n) => { + byId[n.id] = n; + }); + + const out: Record = {}; + edges.forEach((e) => { + if (!byId[e.from] || !byId[e.to]) { + throw new Error(`边引用了不存在的节点:${e.from} → ${e.to}`); + } + (out[e.from] = out[e.from] || []).push(e.to); + }); + + const starts = nodes.filter((n) => n.type === "START"); + if (starts.length !== 1) throw new Error("必须有且仅有 1 个 START 节点"); + const first = (out[starts[0].id] || [])[0]; + if (!first) throw new Error("START 节点没有后继"); + + const seen = new Set([starts[0].id]); + + /** SPLIT 配对:各出边可达集求交 → 交集内取 BFS 序最早的 PARALLEL_JOIN(嵌套并行天然区分:内层 join 非全分支公共可达) */ + function findJoin(splitId: string): string { + const order = new Map([[splitId, 0]]); + const queue: string[] = [splitId]; + while (queue.length) { + const cur = queue.shift()!; + (out[cur] || []).forEach((to) => { + if (!order.has(to)) { + order.set(to, order.size); + queue.push(to); + } + }); + } + const branches = out[splitId] || []; + const reachSets = branches.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.type === "PARALLEL_JOIN" && 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; + } + + function walkList(cur: string, stopAt?: string): { list: EditorItem[]; endedAt: string } { + const list: EditorItem[] = []; + while (true) { + if (stopAt !== undefined && cur === stopAt) return { list, endedAt: cur }; + if (seen.has(cur)) throw new Error(`检测到环或重复连线:${cur}`); + seen.add(cur); + const n = byId[cur]; + if (!n) throw new Error(`引用了不存在的节点:${cur}`); + + if (n.type === "APPROVAL") { + if ((out[cur] || []).length !== 1) { + throw new Error(`审批节点「${n.name || cur}」的出边必须恰好 1 条`); + } + list.push(toApprovalNode(n)); + cur = out[cur][0]; + } else if (n.type === "PARALLEL_SPLIT") { + const heads = out[cur] || []; + if (!heads.length) throw new Error("并行分发节点没有分支"); + const joinId = findJoin(cur); + seen.add(joinId); + const branches = heads.map((head) => { + const r = walkList(head, joinId); + if (r.endedAt !== joinId) { + throw new Error("并行分支未汇聚到同一个节点,无法还原为卡片视图"); + } + return r.list; + }); + list.push(makeParallel(branches)); + if ((out[joinId] || []).length !== 1) throw new Error("汇聚节点出边必须恰好 1 条"); + cur = out[joinId][0]; + } else if (n.type === "END") { + return { list, endedAt: cur }; + } else if (n.type === "PARALLEL_JOIN") { + throw new Error("汇聚节点出现在不受支持的位置(存在跨分支连线)"); + } else { + throw new Error(`不支持的节点类型:${n.type}`); + } + } + } + + const firstWalk = walkList(first); + if (byId[firstWalk.endedAt] && byId[firstWalk.endedAt].type !== "END") { + throw new Error("主流程未正确走到 END 节点"); + } + const orphans = nodes.filter((n) => !seen.has(n.id)); + if (orphans.length) { + throw new Error(`存在未能从开始到达的节点:${orphans.map((n) => n.name || n.id).join("、")}`); + } + + const st = def.settings || {}; + const urge = st.urge || {}; + return { + code: def.code || "", + name: def.name || "", + list: firstWalk.list, + startName: starts[0].name || undefined, + endName: byId[firstWalk.endedAt]?.name || undefined, + settings: { + rejectPolicy: "END_REJECTED", + urge: { + enabled: urge.enabled !== false, + intervalHours: Number(urge.intervalHours) || DEFAULT_URGE.intervalHours, + maxTimes: Number(urge.maxTimes) || DEFAULT_URGE.maxTimes, + }, + }, + }; +} + +function toApprovalNode(n: any): ApprovalNode { + const countersign: Countersign = n.countersign === "ANY" ? "ANY" : "ALL"; + return { + uid: newUid(), + kind: "approval", + name: n.name || "审批", + assignee: n.assignee ? cloneAssignee(n.assignee as Assignee) : null, + countersign, + deadlineHours: n.deadlineHours ? Number(n.deadlineHours) : null, + }; +} + +/** 还原时保留真实分支结构(含 1 分支的非常规定义,交给校验提示);仅空分支补占位,避免画布出现空容器 */ +function makeParallel(branches: EditorItem[][]): ParallelNode { + return { + uid: newUid(), + kind: "parallel", + branches: branches.length ? branches : [[createApproval()], [createApproval()]], + }; +} diff --git a/src/views/flow/admin/def-editor/index.vue b/src/views/flow/admin/def-editor/index.vue new file mode 100644 index 0000000..5ed2af3 --- /dev/null +++ b/src/views/flow/admin/def-editor/index.vue @@ -0,0 +1,493 @@ + + + + + diff --git a/src/views/flow/admin/def-editor/model.ts b/src/views/flow/admin/def-editor/model.ts new file mode 100644 index 0000000..475442f --- /dev/null +++ b/src/views/flow/admin/def-editor/model.ts @@ -0,0 +1,252 @@ +/** + * 流程编辑器数据模型(方案 §3.3)。 + * + * 编辑态是"结构化树":线性节点序列 + 并行分支嵌套;START/END 为隐式固定端点, + * 不进模型、不可删。结构合法性由构造保证——孤儿节点/悬空边/环/度错误不可能出现。 + * 保存时经 convert.ts 生成与后端 FlowDefModel 对齐的 contentJson。 + */ +import type { InjectionKey } from "vue"; + +export type Countersign = "ALL" | "ANY"; + +export interface AssigneeDept { + type: "DEPT_KEY"; + deptKey: string; +} +export interface AssigneeUsers { + type: "USERS"; + userIds: number[]; +} +export type Assignee = AssigneeDept | AssigneeUsers; + +export interface ApprovalNode { + uid: string; + kind: "approval"; + name: string; + assignee: Assignee | null; + /** 多人审批方式:ALL=会签(默认,生成 JSON 时省略)|ANY=或签 */ + countersign: Countersign; + /** 办理时限(小时):null=不限,生成 JSON 时省略 */ + deadlineHours: number | null; +} + +export interface ParallelNode { + uid: string; + kind: "parallel"; + /** 分支列表;每个分支是一条节点序列(可再嵌套并行) */ + branches: EditorItem[][]; +} + +export type EditorItem = ApprovalNode | ParallelNode; + +export interface UrgeSetting { + enabled: boolean; + intervalHours: number; + maxTimes: number; +} + +export interface EditorSettings { + /** 当前引擎唯一支持值(DefValidator 仅接受 END_REJECTED) */ + rejectPolicy: "END_REJECTED"; + urge: UrgeSetting; +} + +export interface EditorModel { + code: string; + name: string; + list: EditorItem[]; + settings: EditorSettings; + /** + * START/END 节点的 name:编辑器里端点是隐式固定的(不展示、不可编辑),但载入既有定义时 + * 原样保留,生成 JSON 时回写——避免保存一次就静默丢掉平台里已存在的自定义端点名。 + * 新建流程两者为空,此时不生成 name 字段(后端不依赖,图示用固定文案)。 + */ + startName?: string; + endName?: string; +} + +// ==================== contentJson 形状(与后端 engine/FlowDefModel 对齐) ==================== + +export interface DefNodeJson { + id: string; + type: string; + name?: string; + assignee?: Assignee; + countersign?: Countersign; + waitFor?: string; + deadlineHours?: number; +} + +export interface DefEdgeJson { + from: string; + to: string; + name?: string; +} + +export interface DefJson { + code: string; + name: string; + start: string; + nodes: DefNodeJson[]; + edges: DefEdgeJson[]; + settings: EditorSettings; +} + +// ==================== 常量 ==================== + +/** 并行分支数下限(后端仅要求 ≥1,2 为业务下限) */ +export const MIN_BRANCHES = 2; +/** 并行分支数上限(超出则画布不可读,封顶添加按钮) */ +export const MAX_BRANCHES = 6; + +/** 分支序号文案(下限用) */ +export const BRANCH_LABELS = ["①", "②", "③", "④", "⑤", "⑥"]; + +export const DEFAULT_URGE: UrgeSetting = { enabled: true, intervalHours: 24, maxTimes: 3 }; + +// ==================== 工厂 ==================== + +let uidSeq = 0; + +/** 编辑态 uid(仅前端用,每次载入模型重建,不写入 JSON) */ +export function newUid(): string { + uidSeq += 1; + return `u${uidSeq}`; +} + +export function createApproval(name = "审批节点", assignee: Assignee | null = { type: "DEPT_KEY", deptKey: "" }): ApprovalNode { + return { uid: newUid(), kind: "approval", name, assignee, countersign: "ALL", deadlineHours: null }; +} + +export function createParallel(): ParallelNode { + return { uid: newUid(), kind: "parallel", branches: [[createApproval()], [createApproval()]] }; +} + +/** 新建流程的默认模板:一个待配置的审批节点(引导用户点卡片配置) */ +export function createEmptyModel(): EditorModel { + return { + code: "", + name: "", + list: [createApproval()], + settings: { rejectPolicy: "END_REJECTED", urge: { ...DEFAULT_URGE } }, + }; +} + +// ==================== 遍历/查询 ==================== + +export interface ContainerRef { + /** 容器标识:root 或 `${parallelUid}:${branchIndex}` */ + container: string; + index: number; + items: EditorItem[]; +} + +export function walkItems( + items: EditorItem[], + fn: (item: EditorItem, container: string, index: number) => void, + container = "root" +): void { + items.forEach((item, index) => { + fn(item, container, index); + if (item.kind === "parallel") { + item.branches.forEach((branch, bi) => walkItems(branch, fn, `${item.uid}:${bi}`)); + } + }); +} + +export function findItem(model: EditorModel, uid: string): EditorItem | null { + let hit: EditorItem | null = null; + walkItems(model.list, (item) => { + if (item.uid === uid) hit = item; + }); + return hit; +} + +export function findParallel(model: EditorModel, uid: string): ParallelNode | null { + const item = findItem(model, uid); + 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; +} + +/** 按 uid 删除节点(跨层级);返回是否命中 */ +export function removeItem(model: EditorModel, uid: string): boolean { + let removed = false; + const rec = (items: EditorItem[]): void => { + const i = items.findIndex((it) => it.uid === uid); + if (i >= 0) { + items.splice(i, 1); + removed = true; + return; + } + items.forEach((it) => { + if (!removed && it.kind === "parallel") it.branches.forEach(rec); + }); + }; + rec(model.list); + return removed; +} + +/** 收集全部 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))); + } + }); + return [...ids]; +} + +// ==================== 展示辅助 ==================== + +export interface DeptOption { + deptKey: string; + deptName: string; + /** 启用状态的办理人数(发布校验要求 ≥1) */ + count: number; +} + +/** 审批人摘要文案(卡片第二行;deptOptions 来自 mapList,userNames 为 userId→姓名 快照) */ +export function assigneeText(a: Assignee | null, depts: DeptOption[], userNames: Record): string { + if (!a) return "未配置审批人"; + if (a.type === "DEPT_KEY") { + if (!a.deptKey) return "审批人:未选择科室"; + const d = depts.find((x) => x.deptKey === a.deptKey); + return d + ? `审批人:${d.deptName || d.deptKey}(${d.deptKey})· ${d.count} 人` + : `审批人:${a.deptKey}(未配置映射)`; + } + if (!a.userIds.length) return "审批人:未选择用户"; + return `审批人:${a.userIds.map((id) => userNames[id] || `#${id}`).join("、")}`; +} + +/** 卡片标签行(会签/或签 + 时限) */ +export function nodeTags(n: ApprovalNode): string[] { + const tags = [n.countersign === "ANY" ? "或签" : "会签"]; + if (n.deadlineHours) tags.push(`${n.deadlineHours} 小时时限`); + return tags; +} + +// ==================== 编辑器上下文(画布子树共享,避免逐层透传回调) ==================== + +export interface EditorCtx { + model: EditorModel; + depts: DeptOption[]; + userNames: Record; + 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; +} + +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 new file mode 100644 index 0000000..8819d83 --- /dev/null +++ b/src/views/flow/admin/def-editor/validate.ts @@ -0,0 +1,81 @@ +/** + * 编辑器实时校验(方案 §3.5):镜像后端 DefValidator 中"可前端化"的业务规则。 + * + * 分层:error 阻断保存(配置缺失类);warn 仅提示(映射缺失类,允许存草稿、发布时后端拦截)。 + * 结构类规则(id 唯一/度约束/孤儿/环/START·END 唯一)由结构化生成保证,前端无需校验。 + * 后端 DefValidator 仍是发布期权威,此处仅作体验前置。 + */ +import { BRANCH_LABELS, DeptOption, EditorItem, EditorModel, MAX_BRANCHES, MIN_BRANCHES } from "./model"; + +export type IssueLevel = "error" | "warn"; + +export interface Issue { + /** 命中节点 uid(画布就地标注);空容器等无归属问题为空 */ + uid: string; + level: IssueLevel; + /** 定位路径(主流程 / 主流程·分支①) */ + path: string; + msg: string; + /** 状态栏/卡片展示用完整文案 */ + text: string; +} + +export function validateModel(model: EditorModel, depts: DeptOption[]): Issue[] { + const issues: Issue[] = []; + const push = (uid: string, level: IssueLevel, path: string, msg: string): void => { + issues.push({ uid, level, path, msg, text: `${path}:${msg}` }); + }; + + const rec = (items: EditorItem[], path: string): void => { + items.forEach((item) => { + if (item.kind === "approval") { + if (!item.name.trim()) push(item.uid, "error", path, "节点名称不能为空"); + const a = item.assignee; + if (!a) { + push(item.uid, "error", path, "未配置审批人"); + } else if (a.type === "DEPT_KEY") { + if (!a.deptKey) { + push(item.uid, "error", path, "未选择科室映射"); + } else { + const d = depts.find((x) => x.deptKey === a.deptKey); + if (!d) { + push(item.uid, "warn", path, `科室映射「${a.deptKey}」不存在,发布会被拦截`); + } else if (!d.count) { + push(item.uid, "warn", path, `科室「${d.deptName || d.deptKey}」无启用办理人,发布会被拦截`); + } + } + } else if (!a.userIds.length) { + push(item.uid, "error", path, "指定用户不能为空"); + } + return; + } + + 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.branches.forEach((branch, i) => { + const branchPath = `${path}·分支${BRANCH_LABELS[i] || i + 1}`; + if (!branch.length) { + // 结构化模型不允许空分支(删除最后一个节点会被父级拦截),此处仅兜底 + push(item.uid, "error", branchPath, "分支为空,请添加审批节点"); + return; + } + rec(branch, branchPath); + }); + }); + }; + + rec(model.list, "主流程"); + return issues; +} + +export function countErrors(issues: Issue[]): Issue[] { + return issues.filter((i) => i.level === "error"); +} + +export function countWarns(issues: Issue[]): Issue[] { + return issues.filter((i) => i.level === "warn"); +}