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
+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; }