fix(maps): 画布连线键与目标改名解耦(objective_id 锚定)+ nodeRefs 位置兜底 + 保守反错绑断言(P3)
ci/woodpecker/push/main Pipeline failed

- map_connections 读侧以 from/to_objective_id 反查当前 dimensions 树定位节点,改名/重排不再静默断线
- 悬停/拖拽期 map_objectives 未返回时按位置兜底(仅同层同序位有效),并记录 stale_objective_id
- 新增 tests/test_conn_key_rename_stability.py(含 2 条保守反错绑负测用例)
- 零 DDL、零 health_score 改动(问3=A)
This commit is contained in:
wecom-fullstack
2026-10-23 15:49:36 +08:00
parent 986dec516e
commit b3f31f9ac9
5 changed files with 785 additions and 54 deletions
@@ -136,12 +136,18 @@
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { ElMessageBox } from 'element-plus'
import { getChainForConnection, getChainForNode } from '../../utils/chainGraph'
import { resolveEndpoints, relRectOf } from '../../utils/connKeys'
const props = defineProps({
/** 各层DOM元素引用,格式:{ financial: HTMLElement, customer: HTMLElement, ... } */
layerRefs: { type: Object, required: true },
/** 节点DOM元素引用,格式:{ 'financial-0': HTMLElement, ... } */
nodeRefs: { type: Object, default: () => ({}) },
/**
* 服务端解析键(P3):与 connections 逐下标对齐的 [{from_key,to_key}]。
* key = `${层}-${序号}`,由 objective_id 反查而来 ⇒ 目标改名后线仍指向同一节点。
*/
connKeys: { type: Array, default: () => [] },
/** 层顺序列表 */
layerKeys: { type: Array, default: () => ['financial', 'customer', 'process', 'learning'] },
/** 同层连线数据 [{from: 'financial-0', to: 'financial-1', effect, label}] */
@@ -328,26 +334,22 @@ function calcLayerConnections() {
// 节点坐标缓存(相对 svg):同一节点被多条连线引用时只 getBoundingClientRect 一次
const rectCache = {}
const origin = { left: svgRect.left, top: svgRect.top }
const getRect = (key) => {
if (rectCache[key]) return rectCache[key]
const el = props.nodeRefs[key]
if (!el) return null
const r = el.getBoundingClientRect()
rectCache[key] = {
left: r.left - svgRect.left,
top: r.top - svgRect.top,
right: r.right - svgRect.left,
bottom: r.bottom - svgRect.top,
width: r.width,
height: r.height,
}
if (!key) return null
if (key in rectCache) return rectCache[key]
// relRectOf:缺 ref / 已卸载 / 0×0(隐藏或未布局)一律 null —— 宁可不画,不画到 (0,0)
rectCache[key] = relRectOf(props.nodeRefs, key, origin) || null
return rectCache[key]
}
for (const conn of props.connections) {
const fr = getRect(conn.from)
const tr = getRect(conn.to)
if (!fr || !tr) continue
let skipped = 0
props.connections.forEach((conn, idx) => {
// P3:优先服务端解析键(objective_id 反查,改名不断线),缺失回落原标识串
const ep = resolveEndpoints(conn, idx, props.connKeys)
const fr = getRect(ep.from)
const tr = getRect(ep.to)
if (!fr || !tr) { skipped += 1; return }
// 源节点右侧中点 → 目标节点左侧中点
const x1 = fr.right
@@ -373,7 +375,11 @@ function calcLayerConnections() {
effect: conn.effect || 'positive',
label: conn.label || '',
})
}
})
// 悬停/拖拽期节点可能尚未注册(预取未回、改名后 DOM 重建)⇒ 有跳过就做有界重算,
// 而不是留一条静默消失的线(P2 暴露的「零绘制」同类问题)。
if (skipped > 0 && depth < 6) scheduleRetry(depth + 1)
layerConnections.value = result
}
+101
View File
@@ -0,0 +1,101 @@
/**
* 连线端点解析(P3 cma-map-canvas-conn-key-rename-stable)
*
* 为什么需要它:
* `canvas_data.connections[].from/to` 存的是**写入时刻**的节点标识串
* (`层:中文名` 或 `层-序号`)。目标一改名,名字串指向不到任何节点 ⇒ 该线静默跳过。
* 服务端 `/api/cma/maps/{id}/connections` 现在为每条连接附 `from_key/to_key`
* (= `${dim}-${idx}`,由 `from/to_objective_id` 在当前 dimensions 树反查得出),
* 渲染按 key 取节点 ⇒ 与中文名解耦;key 缺失(目标已删/历史脏 id)才回落到原值。
*
* 同时收口两类"悬停/拖拽期取不到节点"的兜底:
* · 节点 ref 未注册(预取未返回/刚改名重建)⇒ 返回 null,交给调用方重试,绝不画到 (0,0);
* · 元素已卸载或隐藏(0×0 矩形)⇒ 视同缺失,同样不画。
*/
export type ConnectionKeyEntry = { from_key?: string | null; to_key?: string | null }
export type ConnEndpoints = {
from: string | null
to: string | null
fromSource: 'key' | 'raw' | null
toSource: 'key' | 'raw' | null
}
/** 画布渲染键(`层-序号`):只有这种键可以参与命中/吸附/合法目标判定 */
export function isCanonicalNodeKey(key: unknown): boolean {
return typeof key === 'string' && /^[a-z_]+-\d+$/.test(key)
}
/**
* 单条连线两端取什么键去查 DOM。
* 优先服务端解析键(改名不断线),缺失回落数据里的原值(历史/降级路径)。
*/
export function resolveEndpoints(
conn: any,
idx: number,
resolved?: ConnectionKeyEntry[] | null,
): ConnEndpoints {
const hit: ConnectionKeyEntry = (resolved && resolved[idx]) || {}
const pick = (side: 'from' | 'to') => {
const key = side === 'from' ? hit.from_key : hit.to_key
if (typeof key === 'string' && key) return { key, source: 'key' as const }
const raw = conn?.[side]
if (typeof raw === 'string' && raw) return { key: raw, source: 'raw' as const }
return { key: null, source: null }
}
const f = pick('from')
const t = pick('to')
return { from: f.key, to: t.key, fromSource: f.source, toSource: t.source }
}
export type RelRect = { left: number; top: number; right: number; bottom: number; width: number; height: number }
/**
* 取节点矩形(相对容器原点)。
* 缺 ref / 已卸载 / 0×0(隐藏、未布局)一律 null —— 上层据此跳过该线并安排一次重算,
* 这条判据是「线不画到左上角 (0,0)」的唯一关卡。
*/
export function relRectOf(
nodeRefs: Record<string, any> | null | undefined,
key: string | null,
origin: { left: number; top: number },
): RelRect | null {
if (!key || !nodeRefs) return null
const el: any = nodeRefs[key]
if (!el) return null
if (el.isConnected === false) return null
if (typeof el.getBoundingClientRect !== 'function') return null
const r = el.getBoundingClientRect()
if (!r || (!r.width && !r.height)) return null
return {
left: r.left - origin.left,
top: r.top - origin.top,
right: r.right - origin.left,
bottom: r.bottom - origin.top,
width: r.width,
height: r.height,
}
}
/**
* 解析键索引(服务端 /connections → 与本地 connections 数组逐下标对齐)。
* 按 `from→to` 配对消费(同名同向重复连线按出现顺序配对),配不上的留空 ⇒ 回落原值。
*/
export function buildKeyIndex(
localConnections: any[],
serverConnections: any[],
): ConnectionKeyEntry[] {
const pool = new Map<string, any[]>()
for (const it of serverConnections || []) {
const pk = `${it?.from}\u0000${it?.to}`
const arr = pool.get(pk) || []
arr.push(it)
pool.set(pk, arr)
}
return (localConnections || []).map((c: any) => {
const arr = pool.get(`${c?.from}\u0000${c?.to}`) || []
const hit = arr.shift()
return { from_key: hit?.from_key ?? null, to_key: hit?.to_key ?? null }
})
}
+28
View File
@@ -210,6 +210,7 @@
:layer-refs="layerRefs"
:node-refs="compiledNodeRefs"
:layer-keys="layerKeysOrdered"
:conn-keys="connKeys"
:connections="connections"
:temp-line="tempLine"
:container-key="recalcTrigger"
@@ -319,6 +320,8 @@ import MapTargetTree from "../components/strategy-map/MapTargetTree.vue"
import MapNodeDetail from "../components/strategy-map/MapNodeDetail.vue"
import MapCompleteness from "../components/strategy-map/MapCompleteness.vue"
import ConnectionLines from "../components/strategy/ConnectionLines.vue"
// P3:连线端点解析键(`层-序号`)—— 目标改名后线仍指向同一节点
import { buildKeyIndex, type ConnectionKeyEntry } from "../utils/connKeys"
import MapCanvasToolbar from "../components/map-canvas/MapCanvasToolbar.vue"
import MapDimensionFilter from "../components/map-canvas/MapDimensionFilter.vue"
import CaliberBadge from "../components/CaliberBadge.vue"
@@ -403,6 +406,8 @@ const undoStack = ref<{ dimensions: any; connections: any[] }[]>([])
// 连线
const connections = ref<any[]>([])
/** P3:服务端解析键(与 connections 逐下标对齐);空 ⇒ 回落原标识串(历史/降级) */
const connKeys = ref<ConnectionKeyEntry[]>([])
const linkingFrom = ref<{ key: string; obj: any } | null>(null)
const tempLine = ref<{ x1: number; y1: number; x2: number; y2: number } | null>(null)
const dragConnectTarget = ref<string | null>(null)
@@ -797,6 +802,22 @@ watch(zoomLevel, () => {
nextTick(() => connectionLinesRef.value?.recalcAll())
})
// ── P3:服务端解析键 ──
// 为什么必须取服务端:canvas_data 里存的是写入时刻的标识串(`层:中文名`),
// 目标改名后名字串失效;服务端能由 from/to_objective_id 反查当前 `${层}-${序号}`。
// 取不到(接口异常/离线)⇒ 置空,画布回落原标识串,行为与改造前一致(不更坏)。
async function refreshConnKeys() {
const id = selectedMap.value
if (!id) { connKeys.value = []; return }
try {
const r: any = await api.get(`/maps/${id}/connections`)
const serverConns = r?.data?.connections || []
connKeys.value = buildKeyIndex(loadedCanvas || connections.value || [], serverConns)
} catch (e) {
connKeys.value = []
}
}
// ── 画布加载 ──
function loadCanvas() {
caliberFacts.value = caliberGapFor(currentCaliberEntityId())
@@ -842,6 +863,7 @@ function loadCanvas() {
loadedDims = deepClone(dimensions)
connections.value = map?.canvas_data?.connections || []
loading.value = false
refreshConnKeys()
nextTick(() => {
triggerRecalc()
updateGridMode()
@@ -982,6 +1004,7 @@ function undoLast() {
connections.value = JSON.parse(JSON.stringify(snap.connections))
selectedNode.value = null
markDirty()
refreshConnKeys() // P3:撤销可能改动目标顺序/名字,解析键随之刷新
nextTick(() => { triggerRecalc(); updateZoomWrapHeight(); loadObjectiveLevels() })
ElMessage.success('已撤销上一步')
}
@@ -1716,6 +1739,9 @@ async function saveCanvas(silent?: boolean) {
if (res?.updated_at) baseUpdatedAt.value = res.updated_at // 采纳服务端新基准
refreshBaselines(res?.dimensions) // 基线回到服务端现值(响应体为权威)
markClean()
// P3:保存后刷新解析键(改名/调序都会让 `${层}-${序号}` 变,线要跟着节点走)
if (payload.canvas_data) loadedCanvas = deepClone(connections.value)
refreshConnKeys()
if (!silent) ElMessage.success("已保存")
} catch (e: any) {
if (e?.response?.status === 409) {
@@ -1742,6 +1768,8 @@ async function saveConnections() {
base_updated_at: currentBaseStamp(),
})
if (res?.updated_at) baseUpdatedAt.value = res.updated_at
loadedCanvas = deepClone(connections.value)
refreshConnKeys() // P3:连线新增/删除后服务端重算解析键
} catch (e: any) {
if (e?.response?.status === 409) {
autoSaveBlocked.value = true