From 053eb0047bf3b59b51d152acb54d771678992a76 Mon Sep 17 00:00:00 2001 From: chenxing Date: Fri, 9 Oct 2026 19:42:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(diagram):=20=E5=89=8D=E7=AB=AF=E7=AB=96?= =?UTF-8?q?=E6=8E=92=E9=95=9C=E5=83=8F=E2=80=94=E2=80=94edgePath/=E6=A0=87?= =?UTF-8?q?=E7=AD=BE=E9=94=9A=E7=82=B9=E6=96=B9=E5=90=91=E8=87=AA=E9=80=82?= =?UTF-8?q?=E5=BA=94=EF=BC=88=E5=8F=91=E5=B8=83=E9=A1=BA=E5=BA=8F=E6=97=A0?= =?UTF-8?q?=E5=85=B3=EF=BC=89=E3=80=81=E5=90=8D=E5=8D=95=202=20=E8=A1=8C?= =?UTF-8?q?=20tspan+=E7=AD=89N=E4=BA=BA=E3=80=81=E8=A1=A5=20instance=20?= =?UTF-8?q?=E5=9B=BE=E4=BE=8B=E3=80=81auto-fit=20=E4=B8=8E=20375px=20?= =?UTF-8?q?=E9=80=82=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/flowLayout.ts | 86 +++++++++-------- src/views/flow/FlowDiagram.vue | 170 ++++++++++++++++++++++++++++----- 2 files changed, 195 insertions(+), 61 deletions(-) diff --git a/src/utils/flowLayout.ts b/src/utils/flowLayout.ts index 7ede0b8..68815ad 100644 --- a/src/utils/flowLayout.ts +++ b/src/utils/flowLayout.ts @@ -1,23 +1,24 @@ /** - * 流程图自动布局(从 FlowDiagram.vue 提取的设计器/查看器共用实现): - * Kahn 拓扑分层 depth(to)=max(depth(from))+1,从左到右布列、同列按定义顺序纵排。 - * 设计器(contentJson 无 layout 坐标时算初始坐标)与查看器(无坐标回退)共用同一口径。 + * 流程图自动布局(查看器在后端未下发 positions 时的灰度兜底):Kahn 拓扑分层 + * depth(to)=max(depth(from))+1,自上而下布层、同层按定义顺序横排、整层块对竖直中线水平居中。 + * 与后端 DiagramLayoutService 同规则同常量(2026-10-09 竖排改造镜像);设计器已是钉钉式 + * 竖向卡片流,不再消费本模块。 */ -export const SLOT_W = 200; // 节点槽位宽 -export const SLOT_H = 88; // 节点槽位高 -export const COL_GAP = 72; // 列间距(留给正交折线走线) -export const ROW_GAP = 24; // 行间距 +export const SLOT_W = 280; // 分支槽宽(盒型在槽内水平居中;与后端一致) +export const SLOT_H = 100; // 层槽高(盒型在槽内垂直居中;与后端一致) +export const GAP_X = 44; // 分支列间距(分支间无需走线空间) +export const GAP_Y = 56; // 层间距(留给竖直走线 + 多出口文案) export const PAD = 24; 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 }, + START: { w: 116, h: 44 }, + END: { w: 116, h: 44 }, + APPROVAL: { w: 264, h: 76 }, + NOTIFY: { w: 264, h: 76 }, + PARALLEL_SPLIT: { w: 132, h: 78 }, + PARALLEL_JOIN: { w: 132, h: 78 }, + CONDITION: { w: 132, h: 78 }, }; export interface Pos { @@ -36,7 +37,7 @@ export function layout(data: any): Record { return pos; } -/** Kahn 分层 + 同列按定义顺序纵排(基础网格,保证无重叠) */ +/** Kahn 分层 + 同层按定义顺序横排(基础网格,保证无重叠) */ function layered(data: any): Record { const nodes: any[] = data?.nodes || []; const edges: any[] = data?.edges || []; @@ -56,31 +57,38 @@ function layered(data: any): Record { if (--inDeg[e.to] === 0) order.push(e.to); }); } - const rowsIn: Record = {}; + const rowsIn: Record = {}; const pos: Record = {}; nodes.forEach((n) => { const d = depth[n.id] ?? 0; - depth[n.id] = d; - const row = rowsIn[d] ?? 0; - rowsIn[d] = row + 1; + const row = (rowsIn[d] = rowsIn[d] || []).length; + rowsIn[d].push(n); const box = NODE_BOX[n.type] || NODE_BOX.APPROVAL; pos[n.id] = { - x: PAD + d * (SLOT_W + COL_GAP) + (SLOT_W - box.w) / 2, - y: PAD + row * (SLOT_H + ROW_GAP) + (SLOT_H - box.h) / 2, + x: PAD + row * (SLOT_W + GAP_X) + (SLOT_W - box.w) / 2, + y: PAD + d * (SLOT_H + GAP_Y) + (SLOT_H - box.h) / 2, w: box.w, h: box.h, depth: d, row, }; }); + // 整层块对竖直中线水平居中(镜像后端:按实际占宽算块宽,不含尾随 GAP_X) + const maxCols = Math.max(...Object.values(rowsIn).map((a) => a.length)); + const axisW = (maxCols - 1) * (SLOT_W + GAP_X) + SLOT_W; + Object.values(rowsIn).forEach((arr) => { + const blockW = (arr.length - 1) * (SLOT_W + GAP_X) + SLOT_W; + const dx = (axisW - blockW) / 2; + arr.forEach((n) => { pos[n.id].x += dx; }); + }); return pos; } /** - * 并行分支组垂直居中:分支不再从 SPLIT 行向下堆叠,而是以 SPLIT 中心线为轴上下对称展开 - * (与钉钉/参考稿一致)。SPLIT↔JOIN 配对沿用编辑器口径(各出边可达集求交,取 BFS 序最早的 - * PARALLEL_JOIN),组员 = SPLIT 到 JOIN 之间可达的全部节点;JOIN 中线吸附回 SPLIT 中线。 - * 内层组先于外层组处理(嵌套并行整体平移不破坏内层居中);结束统一把最顶行抬回 PAD。 + * 并行分支组水平居中:分支不再从 SPLIT 行向右堆叠,而是以 SPLIT 竖直中线为轴左右对称展开 + * (2026-10-09 竖排镜像)。SPLIT↔JOIN 配对沿用编辑器口径(各出边可达集求交,取 BFS 序最早的 + * PARALLEL_JOIN),组员 = SPLIT 到 JOIN 之间可达的全部节点;JOIN 水平中点吸附回 SPLIT 中线。 + * 内层组先于外层组处理(嵌套并行整体平移不破坏内层居中);结束统一把最左列推回 PAD。 */ function centerParallelGroups(data: any, pos: Record): void { const nodes: any[] = data?.nodes || []; @@ -144,23 +152,23 @@ function centerParallelGroups(data: any, pos: Record): void { - Math.max(...a.members.map((m: string) => pos[m].depth))); groups.forEach((g: any) => { - const axisY = pos[g.split].y + pos[g.split].h / 2; - let top = Infinity; - let bottom = -Infinity; + const axisX = pos[g.split].x + pos[g.split].w / 2; + let left = Infinity; + let right = -Infinity; g.members.forEach((id: string) => { - top = Math.min(top, pos[id].y); - bottom = Math.max(bottom, pos[id].y + pos[id].h); + left = Math.min(left, pos[id].x); + right = Math.max(right, pos[id].x + pos[id].w); }); - const delta = axisY - (top + bottom) / 2; - g.members.forEach((id: string) => { pos[id].y += delta; }); - pos[g.join].y += axisY - (pos[g.join].y + pos[g.join].h / 2); + const delta = axisX - (left + right) / 2; + g.members.forEach((id: string) => { pos[id].x += delta; }); + pos[g.join].x += axisX - (pos[g.join].x + pos[g.join].w / 2); }); - // 上移出界的组把整图平移回 PAD(同 delta 平移不破坏各组居中) - let minY = Infinity; - Object.values(pos).forEach((p) => { minY = Math.min(minY, p.y); }); - if (minY < PAD) { - const lift = PAD - minY; - Object.values(pos).forEach((p) => { p.y += lift; }); + // 左移出界的组把整图平移回 PAD(同 delta 平移不破坏各组居中) + let minX = Infinity; + Object.values(pos).forEach((p) => { minX = Math.min(minX, p.x); }); + if (minX < PAD) { + const lift = PAD - minX; + Object.values(pos).forEach((p) => { p.x += lift; }); } } diff --git a/src/views/flow/FlowDiagram.vue b/src/views/flow/FlowDiagram.vue index 7ae9c8f..5b69534 100644 --- a/src/views/flow/FlowDiagram.vue +++ b/src/views/flow/FlowDiagram.vue @@ -38,7 +38,7 @@ const pngSrc = computed(() => { const DEF_STATUS: Record = { 0: "草稿", 1: "已发布", 2: "停用" }; -// 布局常量与拓扑分层算法在 src/utils/flowLayout.ts(与设计器画布共用;并行分支组垂直居中) +// 布局常量与拓扑分层算法在 src/utils/flowLayout.ts(后端 positions 单源,此处仅灰度兜底;竖排镜像同常量) import { layout as autoLayout, PAD, Pos } from "@/utils/flowLayout"; /** 布局:后端 positions 单源(内容区坐标);旧后端未下发时回退本地自动布局(灰度兜底) */ @@ -55,30 +55,72 @@ const positions = computed>(() => { return autoLayout({ nodes, edges: vo.value?.edges || [] }); }); +const hasState = computed(() => (vo.value?.nodes || []).some((n: any) => n.state)); + const vbSize = computed(() => { const ps = Object.values(positions.value); if (!ps.length) return { w: 480, h: 160 }; let maxX = 0; let maxY = 0; ps.forEach((p) => { maxX = Math.max(maxX, p.x + p.w); maxY = Math.max(maxY, p.y + p.h); }); - return { w: maxX + PAD, h: maxY + PAD }; + // instance 口径补图例带(与 PNG 出图同口径) + return { w: maxX + PAD, h: maxY + PAD + (hasState.value ? 44 : 0) }; }); +/** 图例(instance 口径四态,与 PNG 图例同口径同顺序),画布底部左侧 */ +const LEGEND_STATES = [ + { state: "active", label: "在办", stroke: "#ed4014" }, + { state: "done", label: "已办", stroke: "#19be6b" }, + { state: "pending", label: "未到", stroke: "#dcdee2" }, + { state: "rejected", label: "被拒绝", stroke: "#ed4014" }, +]; +const legendItems = computed(() => { + if (!hasState.value) return []; + const y = vbSize.value.h - 22; + let x = PAD; + return LEGEND_STATES.map((it) => { + const item = { ...it, x, y }; + x += 24 + it.label.length * 12 + 24; + return item; + }); +}); + +/** auto-fit:宽撑满容器、上限自然宽(>3 分支等比缩放;<0.55 转 min-width+横向滚动兜底,方案 §2.6) */ +const svgStyle = computed(() => ({ + width: "100%", + maxWidth: `${vbSize.value.w}px`, + minWidth: `${Math.round(vbSize.value.w * 0.55)}px`, + height: "auto", +})); + function pos(n: any): Pos { return positions.value[n.id]; } -/** 正交折线:源右侧中点 → 中线 → 目标左侧中点 */ +/** 正交折线(方向自适应,前后端独立部署/发布顺序无关):目标在源下方走竖排折线 + * (底中→竖直段→水平折→顶中,箭头 orient=auto 自动朝下);目标在源右侧走横排折线 + * (兼容旧 positions);对角/异常回退直线。 */ function edgePath(e: any): string { const a = positions.value[e.from]; const b = positions.value[e.to]; if (!a || !b) return ""; - const x1 = a.x + a.w; - const y1 = a.y + a.h / 2; - const x2 = b.x; - const y2 = b.y + b.h / 2; - const mid = (x1 + x2) / 2; - return `M ${x1} ${y1} L ${mid} ${y1} L ${mid} ${y2} L ${x2} ${y2}`; + if (b.y >= a.y + a.h) { + const x1 = a.x + a.w / 2; + const y1 = a.y + a.h; + const x2 = b.x + b.w / 2; + const y2 = b.y; + const mid = (y1 + y2) / 2; + return `M ${x1} ${y1} L ${x1} ${mid} L ${x2} ${mid} L ${x2} ${y2}`; + } + if (b.x >= a.x + a.w) { + const x1 = a.x + a.w; + const y1 = a.y + a.h / 2; + const x2 = b.x; + const y2 = b.y + b.h / 2; + const mid = (x1 + x2) / 2; + return `M ${x1} ${y1} L ${mid} ${y1} L ${mid} ${y2} L ${x2} ${y2}`; + } + return `M ${a.x + a.w / 2} ${a.y + a.h / 2} L ${b.x + b.w / 2} ${b.y + b.h / 2}`; } function diamondPoints(n: any): string { @@ -100,11 +142,19 @@ const STATE_STROKE: Record = { rejected: "#ed4014", }; +/** 边标签锚点(与 edgePath 同口径):竖排挂折线中点——同源多出口共享下方竖直段, + * 挂 (x1+x2)/2 折线中点才不会互相叠字;横排保持旧中点。 */ function edgeLabelAnchor(e: any) { const a = positions.value[e.from]; const b = positions.value[e.to]; if (!a || !b) return null; - return { x: (a.x + a.w + b.x) / 2, y: (a.y + a.h / 2 + b.y + b.h / 2) / 2 }; + if (b.y >= a.y + a.h) { + return { x: (a.x + a.w / 2 + b.x + b.w / 2) / 2, y: (a.y + a.h + b.y) / 2 }; + } + if (b.x >= a.x + a.w) { + return { x: (a.x + a.w + b.x) / 2, y: (a.y + a.h / 2 + b.y + b.h / 2) / 2 }; + } + return { x: (a.x + a.w / 2 + b.x + b.w / 2) / 2, y: (a.y + a.h / 2 + b.y + b.h / 2) / 2 }; } function trunc(s: string | null, n: number): string { @@ -112,6 +162,67 @@ function trunc(s: string | null, n: number): string { return s.length > n ? s.slice(0, n) + "…" : s; } +/** + * 名单换行(≤2 行,与后端 PNG wrapNameList 同规则):按「、」整名装箱换行;放不下时两行装满、 + * 第 2 行尾追加「…等 N 人」(N=未展示人数,第 2 行预留尾缀宽度)。前端无 FontMetrics, + * 按 CJK≈1em / ASCII≈0.56em 估宽(subLabel 字号 11px)。 + */ +function wrapNames(s: string | null | undefined, boxW: number): string[] { + if (!s) return []; + const em = 11; + const width = (t: string): number => { + let w = 0; + for (const ch of t) w += ch.charCodeAt(0) > 0x2e7f ? em : em * 0.56; + return w; + }; + const inner = boxW - 20; + const tok = (t: string): string => { + if (width(t) <= inner) return t; + let out = ""; + for (const ch of t) { + if (width(out + ch + "…") > inner) break; + out += ch; + } + return out + "…"; + }; + const people = s.split("、"); + // 全量贪心装箱:先判断是否真的放得下 + const packed: string[] = []; + let cur = ""; + for (const raw of people) { + const p = tok(raw); + const cand = cur === "" ? p : cur + "、" + p; + if (width(cand) <= inner) cur = cand; + else { packed.push(cur); cur = p; } + } + packed.push(cur); + if (packed.length <= 2) return packed; + // 放不下:两行装满 + 「…等 N 人」尾缀(第 2 行装箱预留尾缀宽度) + const n = people.length; + let l1 = ""; + let shown = 0; + while (shown < n) { + const cand = l1 === "" ? tok(people[shown]) : l1 + "、" + tok(people[shown]); + if (width(cand) <= inner) { l1 = cand; shown++; } else break; + } + let l2 = ""; + while (shown < n) { + const restAfter = n - shown - 1; + const tail = restAfter > 0 ? `…等 ${restAfter} 人` : ""; + const cand = l2 === "" ? tok(people[shown]) : l2 + "、" + tok(people[shown]); + if (width(cand) + (tail ? width(tail) : 0) <= inner) { l2 = cand; shown++; } else break; + } + const rest = n - shown; + if (l2 === "") return [l1, `…等 ${rest} 人`]; + return [l1, l2 + (rest > 0 ? `…等 ${rest} 人` : "")]; +} + +/** 名单卡换行结果(标题/名单基线随行数切换:2 行 25/44/61,1 行 27/50,与 PNG 同口径) */ +function nameLines(n: any): string[] { + const p = pos(n); + return wrapNames(n.subLabel, p ? p.w : 264); +} + /** 出图形态切换;进出画布/进度图无需重拉(数据 vo 共用),只换渲染形态 */ function switchChartStyle(val: string) { chartStyle.value = val; @@ -266,7 +377,7 @@ watch(() => route.query, () => load());
@@ -329,13 +440,15 @@ watch(() => route.query, () => load()); fill="#f0f9ff" :stroke="STATE_STROKE[nodeState(n) || ''] || '#13a8a8'" :stroke-width="nodeState(n) === 'active' ? 2.5 : 1.5" /> - - {{ trunc(n.label, 10) }} + {{ trunc(n.label, 18) }} - - {{ trunc(n.subLabel, 15) }} + + {{ ln }} @@ -347,16 +460,27 @@ watch(() => route.query, () => load()); :fill="nodeState(n) === 'rejected' ? '#fff1f0' : '#fff'" :stroke="STATE_STROKE[nodeState(n) || ''] || '#2d8cf0'" :stroke-width="nodeState(n) === 'active' ? 2.5 : 1.5" /> - - {{ trunc(n.label, 10) }} + {{ trunc(n.label, 18) }} - - {{ trunc(n.subLabel, 15) }} + + {{ ln }} + + + + + + {{ it.label }} + +
未指定流程或加载失败
@@ -371,7 +495,9 @@ watch(() => route.query, () => load()); .toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; flex-wrap: wrap; } .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; } +/* 纵向滚动为主(竖排长图);>3 分支缩放 <0.55 时 svg min-width 兜底转横向滚动(方案 §2.6) */ +.canvas { overflow: auto; background: #fafafa; border: 1px solid #e8eaec; border-radius: 6px; } +.canvas svg { display: block; margin: 0 auto; } .img-box { display: flex; justify-content: center; padding: 12px; } .img-box img { max-width: 100%; height: auto; } .empty { padding: 40px; text-align: center; }