From 2aaf6cdc6d7cc5c348e94264569835dfc4c8e9c4 Mon Sep 17 00:00:00 2001 From: chenxing Date: Thu, 17 Sep 2026 14:52:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E7=94=BB=E5=B8=83=E2=86=94?= =?UTF-8?q?=E5=8D=A1=E7=89=87=E6=95=B0=E6=8D=AE=E6=89=93=E9=80=9A=E2=80=94?= =?UTF-8?q?=E2=80=94=E5=8D=A1=E7=89=87=E6=A0=91=E4=B8=BA=E5=94=AF=E4=B8=80?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E6=BA=90(uid=20=E5=8D=B3=20contentJson=20?= =?UTF-8?q?=E8=8A=82=E7=82=B9=20id,=20=E7=94=BB=E5=B8=83=E5=87=A0-diagram?= =?UTF-8?q?=20=E6=8A=95=E5=BD=B1/=E5=8F=8D=E8=A7=A3);=20=E5=A4=9A=E4=BA=BA?= =?UTF-8?q?=E5=AE=A1=E6=89=B9=E9=BB=98=E8=AE=A4=E6=88=96=E7=AD=BE;=20?= =?UTF-8?q?=E9=80=89=E4=BA=BA=E5=BC=B9=E7=AA=97=E6=8D=A2=20/admin/user/tea?= =?UTF-8?q?cherTree=20=E6=95=99=E5=B8=88=E6=A0=91;=20=E7=A4=BA=E6=84=8F?= =?UTF-8?q?=E5=9B=BE=E9=A1=B5=E4=B8=89=E8=A7=86=E5=9B=BE(=E7=A4=BA?= =?UTF-8?q?=E6=84=8F=E5=9B=BE/=E7=94=BB=E5=B8=83=E8=A7=86=E5=9B=BE/?= =?UTF-8?q?=E8=BF=9B=E5=BA=A6=E5=9B=BEPNG)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/module/flow.ts | 4 + src/components/UserSelectInput.vue | 2 +- src/views/flow/FlowDiagram.vue | 42 ++- .../def-editor/components/ApprovalDrawer.vue | 4 +- src/views/flow/admin/def-editor/convert.ts | 36 ++- src/views/flow/admin/def-editor/index.vue | 241 +++++++++++++++--- src/views/flow/admin/def-editor/model.ts | 11 +- 7 files changed, 288 insertions(+), 52 deletions(-) diff --git a/src/api/module/flow.ts b/src/api/module/flow.ts index be01d7a..1666014 100644 --- a/src/api/module/flow.ts +++ b/src/api/module/flow.ts @@ -128,6 +128,10 @@ class FlowApi { adminUserTree() { return get(`${ROOT}/admin/user/tree`); } + /** 选人组件数据源·教师版(仿 Notice:组树骨架 + YschClassOpenApi 教师按 GroupIds 挂组) */ + adminTeacherTree() { + return get(`${ROOT}/admin/user/teacherTree`); + } /** 一键拉取教师组·预览(学校子树教师组容器 → 科室+组内成员) */ mapGroupPreview() { return get(`${ROOT}/admin/map/groupPreview`); diff --git a/src/components/UserSelectInput.vue b/src/components/UserSelectInput.vue index 10485dc..ebfd9fd 100644 --- a/src/components/UserSelectInput.vue +++ b/src/components/UserSelectInput.vue @@ -45,7 +45,7 @@ export default { sources: [ { name: "基础平台", - loader: () => flowApi.adminUserTree().send() as Promise, + loader: () => flowApi.adminTeacherTree().send() as Promise, }, ], }; diff --git a/src/views/flow/FlowDiagram.vue b/src/views/flow/FlowDiagram.vue index 9b57312..d1ed285 100644 --- a/src/views/flow/FlowDiagram.vue +++ b/src/views/flow/FlowDiagram.vue @@ -5,6 +5,7 @@ import { ref, computed, onMounted, watch } from "vue"; import { useRoute } from "vue-router"; import { Message } from "view-ui-plus"; import { flowApi } from "@/api/module/flow"; +import config from "@/assets/config/host.config"; const route = useRoute(); const loading = ref(false); @@ -12,16 +13,31 @@ const vo = ref(null); const mode = ref((route.query.mode as string) || "auto"); const svgRef = ref(null); +/** 出图形态(2026-09-17 三选一):auto=示意图(自动布局)|canvas=画布视图(设计器保存坐标)|img=进度图(服务端 PNG 流) */ +const chartStyle = ref((route.query.chart as string) || "auto"); +const showImg = computed(() => chartStyle.value === "img"); + +/** 进度图 img 直嵌源(同 InstanceDetail 的"CAS 验证码式",img 不走 alova 显式带 serviceHost;管理面 defId 走 admin 端点) */ +const pngSrc = computed(() => { + const q = route.query as Record; + const base = `${config.serviceHost}/api/flow`; + const suffix = `&mode=${mode.value}`; + if (q.defId) return `${base}/admin/def/diagram.png?id=${q.defId}${suffix}`; + if (q.flowGuid) return `${base}/diagram.png?flowGuid=${encodeURIComponent(q.flowGuid)}${suffix}`; + return `${base}/diagram.png?instanceId=${q.instanceId || ""}${suffix}`; +}); + const DEF_STATUS: Record = { 0: "草稿", 1: "已发布", 2: "停用" }; // 布局常量与拓扑分层算法在 src/utils/flowLayout.ts(与设计器画布共用) import { layout as autoLayout, NODE_BOX, SLOT_W, SLOT_H, COL_GAP, ROW_GAP, PAD, Pos } from "@/utils/flowLayout"; -/** 位置计算:优先用设计器保存的画布坐标(vo.layout,bpmn 画布 x/y),缺坐标回退自动布局 */ +/** 位置计算:画布视图用设计器保存的画布坐标(vo.layout,bpmn 画布 x/y),示意图忽略坐标强制自动布局; + * 画布视图缺坐标(旧定义未在设计器编辑过)时回退自动布局 */ const positions = computed>(() => { const nodes: any[] = vo.value?.nodes || []; const edges: any[] = vo.value?.edges || []; - const saved = vo.value?.layout as Record | undefined; + const saved = chartStyle.value === "canvas" ? vo.value?.layout as Record | undefined : undefined; if (saved && Object.keys(saved).length) { const pos: Record = {}; let complete = true; @@ -124,6 +140,11 @@ function trunc(s: string | null, n: number): string { return s.length > n ? s.slice(0, n) + "…" : s; } +/** 出图形态切换;进出进度图无需重拉(数据 vo 共用),只换渲染形态 */ +function switchChartStyle(val: string) { + chartStyle.value = val; +} + const viewMode = computed(() => (mode.value === "user" ? "user" : "dept")); function switchMode(val: string) { @@ -212,15 +233,24 @@ watch(() => route.query, () => load()); v{{ vo.version }} {{ DEF_STATUS[vo.status] || vo.status }} - + + 示意图 + 画布视图 + 进度图 + + 科室视图 人员视图 - + 示意图按流程定义 v{{ vo.version }} 生成 -
+ +
+ 流程示意图 +
+
@@ -298,6 +328,8 @@ watch(() => route.query, () => load()); .title { font-size: 16px; font-weight: 600; } .muted { color: #999; font-size: 12px; } .canvas { overflow-x: auto; background: #fafafa; border: 1px solid #e8eaec; border-radius: 6px; } +.img-box { display: flex; justify-content: center; padding: 12px; } +.img-box img { max-width: 100%; height: auto; } .empty { padding: 40px; text-align: center; } /* 当前在办节点呼吸高亮 */ .node-active { animation: nodePulse 1.6s ease-in-out infinite; } diff --git a/src/views/flow/admin/def-editor/components/ApprovalDrawer.vue b/src/views/flow/admin/def-editor/components/ApprovalDrawer.vue index 43e51e6..b5887cd 100644 --- a/src/views/flow/admin/def-editor/components/ApprovalDrawer.vue +++ b/src/views/flow/admin/def-editor/components/ApprovalDrawer.vue @@ -26,7 +26,7 @@ const form = reactive({ type: "DEPT_KEY" as "DEPT_KEY" | "USERS", deptKey: "", userIds: [] as number[], - countersign: "ALL" as "ALL" | "ANY", + countersign: "ANY" as "ALL" | "ANY", deadlineHours: null as number | null, }); @@ -34,7 +34,7 @@ const userCheckModal = ref(); const userModalSources = [ { name: "基础平台", - loader: () => flowApi.adminUserTree().send() as Promise, + loader: () => flowApi.adminTeacherTree().send() as Promise, }, ]; diff --git a/src/views/flow/admin/def-editor/convert.ts b/src/views/flow/admin/def-editor/convert.ts index b6a8bd4..379d853 100644 --- a/src/views/flow/admin/def-editor/convert.ts +++ b/src/views/flow/admin/def-editor/convert.ts @@ -22,14 +22,13 @@ import { ParallelNode, createApproval, newUid, + resetUidSeq, } 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" }; @@ -40,7 +39,7 @@ export function toContentJson(model: EditorModel): DefJson { let cursor = prev; items.forEach((item) => { if (item.kind === "approval") { - const id = nid("n"); + const id = item.uid; const node: DefNodeJson = { id, type: "APPROVAL", @@ -54,8 +53,8 @@ export function toContentJson(model: EditorModel): DefJson { cursor = id; return; } - const split = nid("split"); - const join = nid("join"); + 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 }); @@ -87,6 +86,11 @@ export function toContentJson(model: EditorModel): DefJson { }; } +/** + * toContentJson 的节点 id 直接取编辑态 uid(u 段 / uid 后缀 +s、+j):id 稳定可回溯, + * bpmn 画布与卡片共用同一套 id——画布上选中元素的 id 即卡片 uid,配置抽屉无需二次映射; + * id 仅要求"非空且唯一"(DefValidator 口径)。 + */ 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 }; @@ -106,6 +110,8 @@ export interface ParsedDef { * contentJson → 编辑树。抛错即为"不可还原为卡片视图",调用方展示错误并建议源码模式。 */ export function fromContentJson(def: any): ParsedDef { + UsedIds.set.clear(); + resetUidSeq(); if (!def || !Array.isArray(def.nodes) || !Array.isArray(def.edges)) { throw new Error("JSON 需包含 nodes 与 edges 数组"); } @@ -240,8 +246,10 @@ export function fromContentJson(def: any): ParsedDef { function toApprovalNode(n: any): ApprovalNode { const countersign: Countersign = n.countersign === "ANY" ? "ANY" : "ALL"; + // uid 复用 JSON 原节点 id(如 n3):载入→卡片→画布→保存全程同一套 id,卡片树与画布 + // 节点一一对应(业务配置按 id 继承的通道依赖这一点);缺 id/撞 id 才退回新生成 uid。 return { - uid: newUid(), + uid: UsedIds.take(n.id), kind: "approval", name: n.name || "审批", assignee: n.assignee ? cloneAssignee(n.assignee as Assignee) : null, @@ -250,6 +258,22 @@ function toApprovalNode(n: any): ApprovalNode { }; } +/** 载入会话内已占用的 uid 集(防重复/缺 id 撞车) */ +const UsedIds = { + set: new Set(), + take(raw: unknown): string { + const s = raw == null ? "" : String(raw).trim(); + if (s && !this.set.has(s)) { + this.set.add(s); + return s; + } + let u = newUid(); + while (this.set.has(u)) u = newUid(); + this.set.add(u); + return u; + }, +}; + /** 还原时保留真实分支结构(含 1 分支的非常规定义,交给校验提示);仅空分支补占位,避免画布出现空容器 */ function makeParallel(branches: EditorItem[][]): ParallelNode { return { diff --git a/src/views/flow/admin/def-editor/index.vue b/src/views/flow/admin/def-editor/index.vue index daa79dc..4fe42f0 100644 --- a/src/views/flow/admin/def-editor/index.vue +++ b/src/views/flow/admin/def-editor/index.vue @@ -4,7 +4,7 @@ // 入口三态(query):无参=新建;?id={defId}=编辑草稿;?id={defId}&baseGuid={flowGuid}=升版本(载入该版本内容为底稿)。 // 保存只产草稿,走既有 POST /api/flow/admin/def/save;发布仍留在流程定义列表页(后端 DefValidator 终审)。 // 编辑态为结构化树(START/END 隐式固定端点),结构类校验错误从构造上不可能出现。 -import { computed, onMounted, onUnmounted, provide, reactive, ref } from "vue"; +import { computed, onMounted, onUnmounted, provide, reactive, ref, watch } from "vue"; import { onBeforeRouteLeave, useRoute, useRouter } from "vue-router"; import { Message, Modal } from "view-ui-plus"; import { flowApi } from "@/api/module/flow"; @@ -54,21 +54,41 @@ const depts = reactive([]); /** userId → 姓名快照(mapLookupUser 检索补全,失败降级 #id) */ const userNames = reactive>({}); -// ==================== 画布模式(默认):bpmn-js 建模任意 DAG ==================== -/** 编辑形态:canvas=bpmn-js 画布(支持多出口/跨段连线),cards=卡片流(保留回退) */ +// ==================== 双模式统一(2026-09-17):卡片树 = 唯一数据源,画布 = 其可视化投影 ==================== +/** + * 旧实现画布/卡片各持一份基准(sourceJson vs model),切换后互相读不到对方改动,"切回去数据变初始化"。 + * 现统一为:EditorModel 永远持有业务配置(审批人/会签/时限)与卡片结构; + * - 进画布:canvasSource = toContentJson(model),节点 id 直接用卡片 uid(convert.ts),天然一一对应; + * - 出画布(切卡片/保存/源码应用后):serializeUnified 以 model 为 base 让 bpmn 画布结构性改动 + * (新增节点/改连线/布局)叠加其上,再经 fromContentJson 反解回卡片树;卡片树不支持的结构(多出口 + * DAG 等)拒绝切卡片,留在画布编辑。 + */ +/** 编辑形态:canvas=bpmn-js 画布(支持多出口/跨段连线),cards=卡片流 */ const editMode = ref<"canvas" | "cards">("canvas"); +/** 画布侧数据是否可反解为卡片树(载入时探测,决定卡片单选是否可用) */ +const cardsBroken = ref(false); const bpmnWrap = ref | null>(null); -/** 画布数据基准(载入时的 contentJson,供配置按 id 继承) */ +/** 画布数据源:载入用原始 contentJson;由卡片切回画布时用卡片树投影覆盖 */ +const canvasSource = ref(""); +/** 载入时的原始 contentJson(画布上"卡片树不存在"的新画节点配置兜底) */ const canvasBase = ref(null); -/** 画布上审批节点的配置快照(element id → ApprovalNode 配置),点卡片写入,保存时并入 nodes */ +/** 画布上新画节点的配置快照(element id → ApprovalNode 配置),保存时并入 nodes */ const canvasConfigs = reactive>({}); -/** 原始 contentJson 快照(画布脏检查基准) */ +/** 原始 contentJson 快照(画布裁剪兜底与 JsonDrawer 应用基准) */ const sourceJson = ref(""); function onCanvasNode(_el: any): void { // selection.changed 自画布 emit:选中审批节点 → 打开配置抽屉 if (!_el || _el.type !== "bpmn:UserTask") return; const id = String(_el.businessObject.id || _el.id); + const item = findItem(model, id); + if (item && item.kind === "approval") { + // 卡片树已有节点:抽屉直接编辑卡片本体(id=uid 天然对应) + editingUid.value = id; + nodeDrawer.value = true; + return; + } + // 画布上新画的节点(卡片树里没有):配置进 canvasConfigs,保存时并入 const baseNode = (canvasBase.value?.nodes || []).find((n: any) => n.id === id) || {}; if (!canvasConfigs[id]) { canvasConfigs[id] = { @@ -76,7 +96,7 @@ function onCanvasNode(_el: any): void { kind: "approval", name: _el.businessObject.name || baseNode.name || "审批", assignee: baseNode.assignee || null, - countersign: baseNode.countersign || "ALL", + countersign: baseNode.countersign || "ANY", deadlineHours: baseNode.deadlineHours || null, }; } @@ -89,35 +109,91 @@ function canvasRemoveNode(uid: string): void { delete canvasConfigs[uid]; } -/** 画布模式序列化:画布 → contentJson(叠加 code/name/settings 与审批配置) */ -function serializeCanvas(): string { - const canvasJson = bpmnWrap.value?.serialize( - canvasBase.value, - model.code.trim(), - model.name.trim() - ); - if (!canvasJson) return ""; - // 配置并入:canvasConfigs 以 element id 为键覆盖审批节点业务属性 - canvasJson.nodes.forEach((n: any) => { +/** 画布序列化 → contentJson 字符串:结构性内容(节点/连线/布局)出自画布, + * 业务配置(审批人/会签/时限)以卡片树为 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") { n.name = cfg.name || n.name; if (cfg.assignee) n.assignee = cfg.assignee; - if (cfg.countersign && cfg.countersign !== "ALL") n.countersign = cfg.countersign; + if (cfg.countersign) n.countersign = cfg.countersign; if (cfg.deadlineHours) n.deadlineHours = cfg.deadlineHours; } }); - canvasJson.settings = { rejectPolicy: "END_REJECTED" }; - void model.settings; // 设置抽屉暂绑定卡模型(画布模式直接采用卡模型设置) return JSON.stringify(canvasJson); } const issues = computed(() => validateModel(model, depts)); const defJson = computed(() => toContentJson(model)); -/** 脏检查基准(载入/保存后刷新);defJson 已含 code/name/settings,可完整代表待保存内容 */ +/** 序列化 base:原始 contentJson 为底(保住卡片树不支持结构/新画节点的既有业务配置), + * code/name/settings 与卡片树审批节点配置(审批人/会签/时限)以卡片树为准覆盖 */ +function buildSerializeBase(): any { + const modelDef = toContentJson(model); + const base = JSON.parse(JSON.stringify(canvasBase.value || {})); + base.code = model.code.trim(); + base.name = model.name.trim(); + base.settings = JSON.parse(JSON.stringify(modelDef.settings)); + const byId: Record = {}; + (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; + }); + base.nodes = Object.values(byId); + return base; +} + +/** 画布未挂载(载入中/刚切换未挂好)时,快照退回卡片树投影 */ +function snapshotCanvasObject(): any { + if (!bpmnWrap.value) return JSON.parse(JSON.stringify(defJson.value)); + const canvasJson = bpmnWrap.value?.serialize( + buildSerializeBase(), + model.code.trim(), + model.name.trim() + ); + if (!canvasJson) throw new Error("画布不可用"); + return canvasJson; +} + +/** 归一化(去 layout 坐标、节点按 id 排序):卡片树投影与画布序列化表示不同但内容等价,脏比较必须同表示 */ +function canonicalJson(content: any): string { + const copy: any = JSON.parse(JSON.stringify(content)); + delete copy.layout; + (copy.nodes || []).forEach((n: any) => { + // 后端语义缺省≡ALL:画布反序列化不携带 waitFor(引擎按 ALL 处理),归一化抹平避免假脏 + if (n.type === "PARALLEL_JOIN" && n.waitFor === "ALL") delete n.waitFor; + Object.keys(n).forEach((k) => { + if (n[k] === undefined) delete n[k]; + }); + }); + copy.nodes = (copy.nodes || []).slice().sort((a, b) => String(a.id).localeCompare(String(b.id))); + copy.edges = (copy.edges || []) + .slice() + .sort((a, b) => `${a.from}->${a.name}`.localeCompare(`${b.from}->${b.name}`)); + return JSON.stringify(copy); +} + +/** 脏检查基准(载入/保存后刷新);随编辑形态取同一内容的对应投影,切换模式后不因表示差异误报 */ const savedSnapshot = ref(""); -const currentSnapshot = computed(() => JSON.stringify(defJson.value)); +const currentSnapshot = computed(() => + editMode.value === "cards" + ? canonicalJson(JSON.parse(JSON.stringify(defJson.value))) + : canonicalJson(snapshotCanvasObject()) +); const isDirty = computed(() => !loading.value && currentSnapshot.value !== savedSnapshot.value); const nodeDrawer = ref(false); @@ -127,6 +203,8 @@ const editingUid = ref(""); 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 item = editingUid.value ? findItem(model, editingUid.value) : null; @@ -190,6 +268,8 @@ async function load(): Promise { if (queryId && !queryGuid) editId.value = queryId; if (!queryId) { + // 新建:画布源取卡片树投影(默认模板的审批节点 uid 与卡片一一对应),不用通用骨架 + canvasSource.value = JSON.stringify(defJson.value); savedSnapshot.value = currentSnapshot.value; return; } @@ -200,20 +280,17 @@ async function load(): Promise { model.code = d?.code || ""; model.name = d?.name || ""; sourceJson.value = d?.contentJson || "{}"; - try { - canvasBase.value = fromContentJson(JSON.parse(d?.contentJson || "{}")) as any; - } catch { - // 解析失败仍保留原始 JSON 供画布直接读取(fromContentJson 卡片树可能不支持) - canvasBase.value = JSON.parse(d?.contentJson || "{}"); - } + canvasBase.value = safeJson(sourceJson.value); + canvasSource.value = sourceJson.value; + let parsedOk = true; try { applyParsed(fromContentJson(JSON.parse(d?.contentJson || "{}"))); } catch (e: any) { - // 画布模式可编辑管线不支持的结构(多出口等),不再强制卡片树还原 - if (editMode.value === "cards") { - Message.error(`既有定义无法还原为卡片视图:${e?.message || e}。请切换画布模式编辑。`); - } + // 卡片树反解失败(多出口等结构):留在画布模式,卡片单选禁用 + parsedOk = false; + void e; } + cardsBroken.value = !parsedOk; await resolveUserNames(); savedSnapshot.value = currentSnapshot.value; } finally { @@ -221,6 +298,66 @@ async function load(): Promise { } } +function safeJson(s: string): any { + try { + return JSON.parse(s || "{}"); + } catch { + return {}; + } +} + +// ==================== 模式切换(双向互通) ==================== + +/** 卡片 → 画布:画布源换成卡片树投影(画布重挂载即拿到卡片最新结构/配置) */ +function toCanvas(): void { + canvasSource.value = JSON.stringify(defJson.value); + editMode.value = "canvas"; + bumpSnapshot(); +} + +/** 画布 → 卡片:画布结构经 fromContentJson 反解回卡片树;不支持的结构拒绝切换 */ +function toCards(): void { + let content = ""; + try { + content = serializeUnified(); + } catch (e: any) { + Message.error(`画布数据读取失败:${e?.message || e}`); + return; + } + if (!content) { + Message.warning("画布为空,无法切换到卡片视图"); + return; + } + try { + applyParsed(fromContentJson(JSON.parse(content))); + } catch (e: any) { + Message.error(`画布含卡片视图不支持的结构(${e?.message || e}),请继续用画布编辑或先简化结构`); + return; + } + Object.keys(canvasConfigs).forEach((k) => delete canvasConfigs[k]); + editMode.value = "cards"; + bumpSnapshot(); +} + +/** 切换后按"切换前是否已脏"重置脏基准:干净切换 representations 等价,直接对齐;脏切换保留脏态 */ +function bumpSnapshot(): void { + savedSnapshot.value = currentSnapshot.value; +} + +function onModeSwitch(next: string | number | boolean): void { + if (loading.value) return; + if (next === editMode.value) return; + if (next === "cards" && cardsBroken.value) { + Message.warning("该定义包含卡片视图不支持的结构(如多出口/退回环路),请用画布编辑"); + return; + } + if (next === "canvas") { + toCanvas(); + } else { + toCards(); + } +} + // ==================== 编辑操作(画布子树经 ctx 共享) ==================== /** 定位某节点所在的兄弟列表(删除前的"至少保留一个"判断用) */ @@ -244,6 +381,8 @@ function openNode(uid: string): void { function removeNode(uid: string): void { if (editMode.value === "canvas") { + // 卡片树是唯一数据源:卡片已有节点从两侧一起删;画布新画节点只删画布+配置快照 + if (findItem(model, uid)) removeItem(model, uid); canvasRemoveNode(uid); return; } @@ -302,6 +441,25 @@ function onUrgeSave(urge: UrgeSetting): void { model.settings.urge = urge; } +/** 抽屉关闭(确定/删除)后,画布上的节点名回写(配置改的是卡片树本体) */ +watch(nodeDrawer, (open, was) => { + if (open || !was) 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); + } +}); + +/** 源码抽屉应用:写回卡片树;画布模式下画布源同步换投影,避免看到旧结构 */ +function onJsonApplied(p: ParsedDef): void { + applyParsed(p); + if (editMode.value === "canvas") { + canvasSource.value = JSON.stringify(defJson.value); + bumpSnapshot(); + } +} + async function onSave(): Promise { if (!model.name.trim()) { Message.warning("请填写流程名称"); @@ -318,7 +476,12 @@ async function onSave(): Promise { } let content: string; if (editMode.value === "canvas") { - content = serializeCanvas(); + try { + content = serializeUnified(); + } catch (e: any) { + Message.error(`画布数据读取失败:${e?.message || e}`); + return; + } const problems = checkCanvas(content); if (problems.length) { Message.error(problems[0]); @@ -328,6 +491,12 @@ async function onSave(): Promise { Message.warning("画布为空,无法保存"); return; } + // 保存内容同步回卡片树(画布=数据源投影,保存后两侧一致,脏基准重算不误报) + try { + applyParsed(fromContentJson(JSON.parse(content))); + } catch { + // 理论不可达(checkCanvas 通过即可反解);失败不阻断保存 + } } else { content = JSON.stringify(toContentJson(model)); } @@ -489,9 +658,9 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
- + 画布 - 卡片 + 卡片 @@ -528,7 +697,7 @@ 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 475442f..f337365 100644 --- a/src/views/flow/admin/def-editor/model.ts +++ b/src/views/flow/admin/def-editor/model.ts @@ -24,7 +24,7 @@ export interface ApprovalNode { kind: "approval"; name: string; assignee: Assignee | null; - /** 多人审批方式:ALL=会签(默认,生成 JSON 时省略)|ANY=或签 */ + /** 多人审批方式:ANY=或签(2026-09-17 起新建节点默认)|ALL=会签(后端缺省值,生成 JSON 时省略) */ countersign: Countersign; /** 办理时限(小时):null=不限,生成 JSON 时省略 */ deadlineHours: number | null; @@ -114,8 +114,15 @@ export function newUid(): string { return `u${uidSeq}`; } +/** 重置 uid 序号(fromContentJson 反解前调用):uid 参与 contentJson 节点 id(画布↔卡片共用 id), + * 必须可复现,否则"图→树→图→树→图"连续两轮的 split/join id 漂移,往返不幂等 */ +export function resetUidSeq(): void { + uidSeq = 0; +} + export function createApproval(name = "审批节点", assignee: Assignee | null = { type: "DEPT_KEY", deptKey: "" }): ApprovalNode { - return { uid: newUid(), kind: "approval", name, assignee, countersign: "ALL", deadlineHours: null }; + // 默认或签(ANY)——需求 2026-09-17:多人审批默认一人同意即通过;后端缺省仍是 ALL,故 ANY 必须显式写 JSON + return { uid: newUid(), kind: "approval", name, assignee, countersign: "ANY", deadlineHours: null }; } export function createParallel(): ParallelNode {