feat(diagram): 前端竖排镜像——edgePath/标签锚点方向自适应(发布顺序无关)、名单 2 行 tspan+等N人、补 instance 图例、auto-fit 与 375px 适配
build / build (push) Successful in 3m30s

This commit is contained in:
chenxing
2026-10-09 19:42:51 +08:00
parent 8b02f05494
commit 053eb0047b
2 changed files with 195 additions and 61 deletions
+47 -39
View File
@@ -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<string, { w: number; h: number }> = {
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<string, Pos> {
return pos;
}
/** Kahn 分层 + 同列按定义顺序纵排(基础网格,保证无重叠) */
/** Kahn 分层 + 同层按定义顺序横排(基础网格,保证无重叠) */
function layered(data: any): Record<string, Pos> {
const nodes: any[] = data?.nodes || [];
const edges: any[] = data?.edges || [];
@@ -56,31 +57,38 @@ function layered(data: any): Record<string, Pos> {
if (--inDeg[e.to] === 0) order.push(e.to);
});
}
const rowsIn: Record<number, number> = {};
const rowsIn: Record<number, any[]> = {};
const pos: Record<string, Pos> = {};
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<string, Pos>): void {
const nodes: any[] = data?.nodes || [];
@@ -144,23 +152,23 @@ function centerParallelGroups(data: any, pos: Record<string, Pos>): 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; });
}
}
+148 -22
View File
@@ -38,7 +38,7 @@ const pngSrc = computed(() => {
const DEF_STATUS: Record<number, string> = { 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<Record<string, Pos>>(() => {
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<string, string> = {
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());
<!-- 示意图:手写 SVG 自动布局 -->
<div class="canvas" v-else-if="vo">
<svg :width="vbSize.w" :height="vbSize.h" :viewBox="`0 0 ${vbSize.w} ${vbSize.h}`"
xmlns="http://www.w3.org/2000/svg" ref="svgRef"
xmlns="http://www.w3.org/2000/svg" ref="svgRef" :style="svgStyle"
style="font-family: 'Microsoft YaHei', 'PingFang SC', sans-serif">
<defs>
<marker id="flow-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
@@ -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" />
<text :x="pos(n).x + pos(n).w / 2" :y="pos(n).y + 27"
<text :x="pos(n).x + pos(n).w / 2"
:y="pos(n).y + (nameLines(n).length >= 2 ? 25 : 27)"
text-anchor="middle" fill="#13a8a8" font-size="13" font-weight="600">
{{ trunc(n.label, 10) }}
{{ trunc(n.label, 18) }}
</text>
<text v-if="n.subLabel" :x="pos(n).x + pos(n).w / 2" :y="pos(n).y + 47"
text-anchor="middle" fill="#808695" font-size="11">
{{ trunc(n.subLabel, 15) }}
<text v-if="n.subLabel" text-anchor="middle" fill="#808695" font-size="11">
<tspan v-for="(ln, i) in nameLines(n)" :key="i"
:x="pos(n).x + pos(n).w / 2"
:y="pos(n).y + (nameLines(n).length >= 2 ? 44 + i * 17 : 50)">{{ ln }}</tspan>
</text>
</g>
<!-- APPROVAL:圆角卡片两行文本 -->
@@ -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" />
<text :x="pos(n).x + pos(n).w / 2" :y="pos(n).y + 27"
<text :x="pos(n).x + pos(n).w / 2"
:y="pos(n).y + (nameLines(n).length >= 2 ? 25 : 27)"
text-anchor="middle" fill="#17233d" font-size="13" font-weight="600">
{{ trunc(n.label, 10) }}
{{ trunc(n.label, 18) }}
</text>
<text v-if="n.subLabel" :x="pos(n).x + pos(n).w / 2" :y="pos(n).y + 47"
text-anchor="middle" fill="#808695" font-size="11">
{{ trunc(n.subLabel, 15) }}
<text v-if="n.subLabel" text-anchor="middle" fill="#808695" font-size="11">
<tspan v-for="(ln, i) in nameLines(n)" :key="i"
:x="pos(n).x + pos(n).w / 2"
:y="pos(n).y + (nameLines(n).length >= 2 ? 44 + i * 17 : 50)">{{ ln }}</tspan>
</text>
</g>
</template>
<!-- 图例(instance 口径四态,与 PNG 图例同口径同配色,画布底部左侧) -->
<g v-if="legendItems.length">
<g v-for="it in legendItems" :key="'legend-' + it.state">
<rect :x="it.x" :y="it.y - 10" width="18" height="12" rx="6"
fill="#fff" :stroke="it.stroke" stroke-width="1.4" />
<text :x="it.x + 24" :y="it.y + 2" fill="#17233d" font-size="12">{{ it.label }}</text>
</g>
</g>
</svg>
</div>
<div v-else-if="!loading" class="muted empty">未指定流程或加载失败</div>
@@ -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; }