fix(maps): 画布连线键与目标改名解耦(objective_id 锚定)+ nodeRefs 位置兜底 + 保守反错绑断言(P3)
ci/woodpecker/push/main Pipeline failed
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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user