feat(maps): 战略地图按 objective 粒度合并(granular 通道),不同 objective 并发不再互相 409
ci/woodpecker/push/main Pipeline failed
ci/woodpecker/push/main Pipeline failed
This commit is contained in:
@@ -222,6 +222,15 @@ const { markDirty, markClean } = useFormGuard(isDirty)
|
||||
const baseUpdatedAt = ref<string | null>(null)
|
||||
// 收到 409(服务端已被他人修改)后置位:停止自动保存,绝不重试覆写
|
||||
const autoSaveBlocked = ref(false)
|
||||
// ── 按 objective 粒度合并的基线(P2 2026-10-12)────────────────────────
|
||||
// 写通道从「整表覆写」收敛为「只提交本地脏的 objective」:
|
||||
// baseServerDims = /maps 返回的 dimensions 原始副本(补层/迁移之前)⇒ 脏项的 base(并发基准)取自此处,
|
||||
// 与服务端库内现值同源,故「同一 objective 并发改」判据不会因本地修补而误报;
|
||||
// loadedDims = 载入 + 本地修补(ensureFourLayers / 旧格式迁移)后的副本 ⇒ 脏判据基线;
|
||||
// loadedCanvas = 载入时连线快照 ⇒ 连线是否本地改动的判据(未改则不提交 canvas_data)。
|
||||
let baseServerDims: any[] = []
|
||||
let loadedDims: any[] = []
|
||||
let loadedCanvas: any[] = []
|
||||
const allKpis = ref<any[]>([])
|
||||
const kpiNameMap = reactive<Record<string, string>>({})
|
||||
|
||||
@@ -496,6 +505,9 @@ function loadCanvas() {
|
||||
// 并发基准:本地快照对应服务端 updated_at(后续保存须带此基准)
|
||||
baseUpdatedAt.value = map?.updated_at || null
|
||||
autoSaveBlocked.value = false
|
||||
// 服务端原文快照(补层/迁移之前)⇒ 脏项的 base 与服务端库内现值同源(P2 按 objective 粒度合并)
|
||||
baseServerDims = deepClone(map?.dimensions || [])
|
||||
loadedCanvas = deepClone(map?.canvas_data?.connections || [])
|
||||
if (map?.dimensions) {
|
||||
for (const dim of map.dimensions) {
|
||||
dimensions.push({
|
||||
@@ -511,6 +523,8 @@ function loadCanvas() {
|
||||
ensureFourLayers()
|
||||
// 数据迁移:旧格式 kpis[] → krs[]
|
||||
migrateOldKpiFormat()
|
||||
// 载入基线(本地修补后)⇒ 脏判据基线(迁移新增的 krs 不算本地改动)
|
||||
loadedDims = deepClone(dimensions)
|
||||
connections.value = map?.canvas_data?.connections || []
|
||||
loading.value = false
|
||||
nextTick(() => {
|
||||
@@ -522,6 +536,9 @@ function loadCanvas() {
|
||||
}).catch(() => {
|
||||
ensureFourLayers()
|
||||
baseUpdatedAt.value = null // 载入失败 ⇒ 无基准(服务端 fail-closed)
|
||||
baseServerDims = []
|
||||
loadedDims = deepClone(dimensions)
|
||||
loadedCanvas = []
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
@@ -1076,6 +1093,131 @@ function migrateOldKpiFormat() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 按 objective 粒度提交(P2 2026-10-12)──────────────────────────────
|
||||
function deepClone<T>(v: T): T { return JSON.parse(JSON.stringify(v ?? null)) as T }
|
||||
|
||||
/** objective 的服务端行 id(回写件 _objective_id / objective_id);无则 null */
|
||||
function objIdOf(o: any): number | null {
|
||||
const raw = o?._objective_id ?? o?.objective_id
|
||||
const n = Number(raw)
|
||||
return Number.isFinite(n) && n > 0 ? n : null
|
||||
}
|
||||
|
||||
/** 归一化序列化:丢 _ 前缀簿记键 / null / 空串(与服务端 _canon_obj 同口径) */
|
||||
function canonJson(v: any): string {
|
||||
const clean = (x: any): any => {
|
||||
if (Array.isArray(x)) return x.map(clean)
|
||||
if (x && typeof x === 'object') {
|
||||
const out: Record<string, any> = {}
|
||||
for (const k of Object.keys(x).sort()) {
|
||||
if (k.startsWith('_') || x[k] === null || x[k] === undefined || x[k] === '') continue
|
||||
out[k] = clean(x[k])
|
||||
}
|
||||
return out
|
||||
}
|
||||
return x
|
||||
}
|
||||
return JSON.stringify(clean(v))
|
||||
}
|
||||
|
||||
/** 在目标列表里按行 id → 名称定位(与服务端 _find_objective 同序) */
|
||||
function findObjective(list: any[], probe: any): any | null {
|
||||
const objs = list || []
|
||||
const id = objIdOf(probe)
|
||||
if (id !== null) {
|
||||
const hit = objs.find((o: any) => objIdOf(o) === id)
|
||||
if (hit) return hit
|
||||
}
|
||||
const name = String(probe?.name ?? '')
|
||||
return objs.find((o: any) => String(o?.name ?? '') === name) || null
|
||||
}
|
||||
|
||||
/** 层内目标身份序列(顺序变化也须能识别 ⇒ 该层整层标脏以采纳重排) */
|
||||
function dimIdentitySeq(objs: any[]): string {
|
||||
return (objs || []).map((o: any) => String(objIdOf(o) ?? `name:${o?.name ?? ''}`)).join('|')
|
||||
}
|
||||
|
||||
function dimsIndex(dims: any[]): Record<string, any[]> {
|
||||
const out: Record<string, any[]> = {}
|
||||
for (const d of dims || []) out[d.key] = d.objectives || []
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地脏 objective 明细(服务端只采纳这些条目,其余节点保留服务端现值):
|
||||
* 内容与载入基线不同 ⇒ 增/改;载入基线有而当前没有 ⇒ 删(value=null);有行 id 但服务端基线无 ⇒ 本地新增(base=null)。
|
||||
* 仅顺序变化(内容不变)⇒ 该层全部条目入明细(base=服务端原文,服务端据提交顺序采纳重排)。
|
||||
*/
|
||||
function buildDirtyObjectives(): any[] {
|
||||
const out: any[] = []
|
||||
const loaded = dimsIndex(loadedDims)
|
||||
const serverRaw = dimsIndex(baseServerDims)
|
||||
for (const dim of dimensions) {
|
||||
const dk = dim.key
|
||||
const cur: any[] = dim.objectives || []
|
||||
const baseLoaded: any[] = loaded[dk] || []
|
||||
const baseSrv: any[] = serverRaw[dk] || []
|
||||
// 重排判据只看「增删之外仍然两边都在」的目标子序列:单纯新增/删除不算重排
|
||||
// (否则删一个目标会把同层其余目标一并标脏 ⇒ 他方对这些目标的并发改会误报 409)
|
||||
const sharedLocal = cur.filter((o: any) => findObjective(baseLoaded, o))
|
||||
const sharedLoaded = baseLoaded.filter((l: any) => findObjective(cur, l))
|
||||
const reordered = dimIdentitySeq(sharedLocal) !== dimIdentitySeq(sharedLoaded)
|
||||
for (const obj of cur) {
|
||||
const seen = findObjective(baseLoaded, obj)
|
||||
const changed = !seen || canonJson(obj) !== canonJson(seen)
|
||||
if (!changed && !reordered) continue
|
||||
const srv = findObjective(baseSrv, obj)
|
||||
out.push({
|
||||
dim_key: dk,
|
||||
objective_id: objIdOf(obj),
|
||||
name: obj?.name ?? '',
|
||||
base: srv ? deepClone(srv) : null, // null ⇒ 本地新增(服务端不应已有同 id 节点)
|
||||
value: deepClone(obj),
|
||||
})
|
||||
}
|
||||
for (const l of baseLoaded) {
|
||||
if (findObjective(cur, l)) continue
|
||||
const srv = findObjective(baseSrv, l)
|
||||
out.push({
|
||||
dim_key: dk, objective_id: objIdOf(l), name: l?.name ?? '',
|
||||
base: srv ? deepClone(srv) : null, value: null,
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 连线是否本地改动(未改 ⇒ 不提交 canvas_data,避免整段覆写他方连线) */
|
||||
function connectionsDirty(): boolean {
|
||||
const norm = (v: any) => canonJson({ connections: Array.isArray(v) ? v : [] })
|
||||
return norm(connections.value) !== norm(loadedCanvas)
|
||||
}
|
||||
|
||||
/** 采纳服务端回写的行 id(本地新增节点落库后由服务端分配 ⇒ 下次编辑仍按 id 定位) */
|
||||
function adoptServerIds(serverDims: any[]) {
|
||||
if (!Array.isArray(serverDims)) return
|
||||
const byKey = dimsIndex(serverDims)
|
||||
for (const dim of dimensions) {
|
||||
const list: any[] = byKey[dim.key] || []
|
||||
for (const obj of dim.objectives || []) {
|
||||
const hit = objIdOf(obj) !== null
|
||||
? list.find((o: any) => objIdOf(o) === objIdOf(obj))
|
||||
: findObjective(list, obj)
|
||||
if (!hit) continue
|
||||
if (objIdOf(hit) !== null) obj._objective_id = objIdOf(hit)
|
||||
if (hit._kr_id) obj._kr_id = hit._kr_id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存成功后刷新基线:脏判据回到本地现值;并发基准回到服务端现值(响应体 dimensions 为权威口径) */
|
||||
function refreshBaselines(serverDims: any[]) {
|
||||
adoptServerIds(serverDims)
|
||||
loadedDims = deepClone(dimensions)
|
||||
loadedCanvas = deepClone(connections.value)
|
||||
baseServerDims = deepClone(Array.isArray(serverDims) && serverDims.length ? serverDims : dimensions)
|
||||
}
|
||||
|
||||
// ── 保存 ──
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let autoSaveTimer: ReturnType<typeof setInterval> | null = null
|
||||
@@ -1109,21 +1251,29 @@ async function saveCanvas(silent?: boolean) {
|
||||
objectives: d.objectives,
|
||||
}))
|
||||
try {
|
||||
const res: any = await mapApi.update(selectedMap.value, {
|
||||
// 只提交本地脏的 objective(服务端按 objective 粒度合并,他方未冲突改动一律保留);
|
||||
// 连线未改动则不提交 canvas_data(避免整段覆写他方连线)
|
||||
const payload: any = {
|
||||
dimensions: mapData,
|
||||
canvas_data: { connections: connections.value },
|
||||
dirty_objectives: buildDirtyObjectives(),
|
||||
version: currentMap.value?.version,
|
||||
base_updated_at: currentBaseStamp(), // 并发基准:过期则由服务端 409 拒绝覆写
|
||||
})
|
||||
base_updated_at: currentBaseStamp(), // 并发基准(缺 ⇒ 服务端 fail-closed 409)
|
||||
}
|
||||
if (connectionsDirty()) payload.canvas_data = { connections: connections.value }
|
||||
const res: any = await mapApi.update(selectedMap.value, payload)
|
||||
if (res?.updated_at) baseUpdatedAt.value = res.updated_at // 采纳服务端新基准
|
||||
refreshBaselines(res?.dimensions) // 基线回到服务端现值(响应体为权威)
|
||||
markClean()
|
||||
if (!silent) ElMessage.success("已保存")
|
||||
} catch (e: any) {
|
||||
if (e?.response?.status === 409) {
|
||||
// 服务端已被他人修改:停止自动保存且不重试覆写(防过期快照整表覆写)
|
||||
// 服务端已被他人修改(同一 objective 并发改 / 整段覆写冲突):停止自动保存且不重试覆写
|
||||
autoSaveBlocked.value = true
|
||||
stopAutoSave()
|
||||
ElMessage.warning("地图已被他人修改,自动保存已停止,请刷新页面后重试")
|
||||
const detail = e?.response?.data?.detail
|
||||
ElMessage.warning(detail
|
||||
? `地图已被他人修改,自动保存已停止,请刷新页面后重试(${detail})`
|
||||
: "地图已被他人修改,自动保存已停止,请刷新页面后重试")
|
||||
} else {
|
||||
console.error("saveCanvas error:", e)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user