Files
cma-management/frontend/src/views/MapCanvas.vue
T
wecom-fullstack 03254bd4c7 fix(maps): 战略地图写通道乐观并发 + 画布自动保存仅脏时写
生产缺陷 cma-strategy-map-autosave-stale-overwrite(D4 批次 19 行 map_objectives 被过期快照覆写):
- 后端 update_map: 覆写 dimensions/canvas_data 须带 base_updated_at,与服务端 updated_at 不符 → 409;
  校验在任何写入之前完成(校验不过时一行都不删/不改)
- 连线写通道(POST/DELETE connections)响应补 updated_at,供前端采纳新基准、避免自伤 409
- 前端 MapCanvas: 自动保存仅在 draft + isDirty 时触发;请求体带 base_updated_at;收到 409 停自动保存不重试
- 修复 async onMounted 竞态:await 期间被卸载后其续段仍注册定时器(清理钩子先跑完、无人回收)
  → 泄漏写者永久在后台用过期快照覆写(生产 referer=/maps 仍每 30–60s 写)。加 disposed 守卫 + stopAutoSave
- 测试: backend/tests/test_map_concurrency_20261012.py(8 例,改前 5 败);前端行为台架 11/11(改前 3/11)

任务: yanxue-20261012-cma-map-autosave-stale-overwrite-defect-fullstack
2026-10-12 11:42:56 +08:00

1518 lines
54 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="map-canvas-page" ref="canvasRef">
<MapCanvasToolbar
:maps="maps"
:selected-map="selectedMap"
:zoom-level="zoomLevel"
:view-direction="viewDirection"
:current-map="currentMap"
:linking-from="linkingFrom"
:chain-mode="chainMode"
@select-map="onSelectMap"
@update:view-direction="v => viewDirection = v"
@zoom-in="zoomIn"
@zoom-out="zoomOut"
@zoom-reset="zoomReset"
@zoom-fit="zoomFit"
@export-image="exportImage"
@show-causality="showCausalityRecommendations"
@show-connection-manager="showConnManager = true"
@save-canvas="saveCanvas(false)"
@publish-map="publishMap"
@show-versions="showVersions = true; loadVersions()"
@go-alignment="goAlignment"
@cancel-link="cancelLink"
@toggle-chain-mode="toggleChainMode"
/>
<!-- 因果链查看模式提示 -->
<div v-if="chainMode" class="chain-mode-bar">
<span>🔍 因果链查看模式 — 点击节点聚焦其相关因果链(再次点击取消),其余节点自动变淡</span>
<el-button size="small" type="warning" @click="toggleChainMode">退出模式</el-button>
</div>
<!-- 连线模式提示 -->
<div v-if="linkingFrom" class="linking-bar">
<span>🔗 从「{{ linkingFrom.obj.name }}」连线 — 点击另一个目标完成连线</span>
<el-button size="small" @click="cancelLink">取消</el-button>
</div>
<!-- 空状态提示 -->
<div v-if="!loading && dimensions.every(d => d.objectives.length === 0)" class="empty-state">
<el-empty description="四层泳道已就绪,点击各层的「+ 添加目标」开始填写">
<template #image>
<svg viewBox="0 0 120 120" width="120" height="120">
<rect x="10" y="10" width="100" height="22" rx="4" fill="#FEF0F0" />
<rect x="10" y="38" width="100" height="22" rx="4" fill="#ECF5FF" />
<rect x="10" y="66" width="100" height="22" rx="4" fill="#F0F9EB" />
<rect x="10" y="94" width="100" height="22" rx="4" fill="#FDF6EC" />
</svg>
</template>
</el-empty>
</div>
<!-- 画布主体(田字格2×2布局,transform: scale 缩放) -->
<div class="canvas-scroll-wrap" ref="scrollWrapRef">
<div class="canvas-zoom-wrap" :style="{ width: zoomWrapWidth, height: zoomWrapHeight }">
<div class="canvas-body" :class="{ 'grid-mode': gridMode }" ref="bodyRef" :style="bodyZoomStyle">
<!-- 四层泳道(纵向排列,固定顺序:财务→客户→流程→学习) -->
<div
v-for="(cfg, idx) in orderedLayerConfigs"
:key="cfg.key"
:ref="el => setLayerRef(cfg.key, el)"
class="layer-swimlane-wrapper"
:style="{ borderLeft: '4px solid ' + cfg.color }"
>
<StrategyLayer
:ref="el => setLayerCompRef(cfg.key, el)"
:layer-key="cfg.key"
:label="cfg.label"
:icon="cfg.icon"
:color="cfg.color"
:objectives="getLayerNodes(cfg.key)"
:linking-from-key="linkingFrom?.key ?? null"
:drag-connect-target="dragConnectTarget"
:link-eligibility-map="linkEligibilityMap"
:icon-map="iconMap"
:kpi-name-map="kpiNameMap"
:all-kpis="allKpis"
:get-node-level="getNodeLevel"
:get-node-progress="getNodeProgress"
:chain-node-keys="chainNodeKeys"
@add-objective="openAddDialog"
@edit-objective="(p: any) => openEditDialog(p.dimKey, p.idx, p.obj)"
@delete-objective="(p: any) => deleteNode(p.dimKey, p.idx)"
@start-link="(p: any) => { const ps = p.key.split('-'); startLink(ps[0], parseInt(ps[ps.length-1]), p.obj) }"
@link-drag-start="(e: MouseEvent, dk: string, oi: number, obj: any) => onLinkDragStart(e, dk, oi, obj)"
@kpi-click="(code: string) => goKPI(code)"
@show-plan-list="(p: any) => showPlanList(p.dimKey, p.idx, p.obj)"
@node-click="(dk: string, oi: number, obj: any) => onNodeClick(dk, oi, obj)"
@reorder="(dk: string) => onLayerReorder(dk)"
@edit-kr="(p: any) => onEditKr(p.dimKey, p.idx, p.obj, p.ki, p.kr)"
@delete-kr="(p: any) => onDeleteKr(p.dimKey, p.idx, p.obj, p.ki, p.kr)"
@add-kr="(p: any) => onAddKr(p.dimKey, p.idx, p.obj)"
/>
<!-- 跨层箭头指示器(田字格模式下由连线层绘制同列箭头,此处隐藏) -->
<div v-if="!gridMode && idx < orderedLayerConfigs.length - 1" class="cross-layer-arrow">
<svg width="24" height="32" viewBox="0 0 24 32">
<line x1="12" y1="0" x2="12" y2="24" stroke="#909399" stroke-width="2" stroke-dasharray="4,3" />
<polygon v-if="viewDirection === 'cascade'" points="4,22 12,30 20,22" fill="#909399" />
<polygon v-else points="4,8 12,0 20,8" fill="#909399" />
</svg>
</div>
</div>
</div><!-- end canvas-body -->
</div><!-- end canvas-zoom-wrap -->
</div><!-- end canvas-scroll-wrap -->
<!-- SVG 连线层(覆盖整个画布,绘制跨层+同层箭头) -->
<ConnectionLines
ref="connectionLinesRef"
:layer-refs="layerRefs"
:node-refs="compiledNodeRefs"
:layer-keys="layerKeysOrdered"
:connections="connections"
:temp-line="tempLine"
:container-key="recalcTrigger"
:view-direction="viewDirection"
:grid-mode="gridMode"
:chain-mode="chainMode"
:chain-focus-key="chainFocusKey"
@select-connection="onSelectConnection"
@delete-connection="onDeleteConnection"
@chain-hover="onChainHover"
@chain-focus="onChainFocus"
@update-connection="onUpdateConnection"
/>
<!-- 所有弹窗统一管理 -->
<MapCanvasDialogs
:show-node-dialog="showNodeDialog"
:show-causality-dialog="showCausalityDialog"
:show-versions="showVersions"
:show-kpi-detail="showKpiDetail"
:show-plan-popup="showPlanPopup"
:is-editing="editingNodeData?._index != null"
:form="dialogForm"
:causality-loading="causalityLoading"
:causality-recommendations="causalityRecommendations"
:versions="versions"
:kpi-detail-obj-name="kpiDetailObjName"
:kpi-detail-list="kpiDetailList"
:plan-popup-obj-name="planPopupObjName"
:plan-popup-list="planPopupList"
:editing-layer-key="editingLayerKey"
:editing-obj="editingNodeData"
:all-kpis="allKpis"
@update:show-node-dialog="v => showNodeDialog = v"
@update:show-causality-dialog="v => showCausalityDialog = v"
@update:show-versions="v => showVersions = v"
@update:show-kpi-detail="v => showKpiDetail = v"
@update:show-plan-popup="v => showPlanPopup = v"
@save-node-dialog="onSaveDialog"
@apply-causality="applyCausality"
@apply-all-causality="applyAllCausality"
@rollback-version="rollback"
@edit-kpi-obj="editKpiDetailObj"
@quick-create-plan="quickCreatePlan"
/>
<!-- 连接管理面板(非拖拽方式创建/维护连线) -->
<ConnectionManagerDialog
v-model="showConnManager"
:connections="connections"
:node-options="connectionNodeOptions"
:layer-labels="layerLabelMap"
@add-connection="onManagerAddConnection"
@delete-connection="onManagerDeleteConnection"
@update-connection="onManagerUpdateConnection"
/>
<!-- KR 删除二次确认(2026-09-16):项目统一弹窗组件 MyDialog(el-dialog 有历史 bug) -->
<MyDialog v-model="showKrDeleteConfirm" title="删除关键结果" :width="420">
<div class="kr-del-confirm-text">
确定删除关键结果「<strong>{{ krDeleteTarget?.name }}</strong>」?
</div>
<div class="kr-del-confirm-hint">
删除后该 KR 会从目标中移除,并同步删除 krs 表中对应记录(不可撤销)。
</div>
<template #footer>
<el-button size="small" @click="cancelDeleteKr">取消</el-button>
<el-button size="small" type="danger" :loading="krDeleting" @click="confirmDeleteKr">删除</el-button>
</template>
</MyDialog>
</div>
<!-- P1-3: 关联知识点 -->
<div style="margin-top:12px;">
<KnowledgePanel related-page="/maps" />
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch, onMounted, nextTick, onUnmounted } from "vue"
import { useRoute } from "vue-router"
import { ElMessage, ElMessageBox } from "element-plus"
import { mapApi, kpiApi, okrTemplateApi, okrApi } from "../api/index"
import KnowledgePanel from '../components/KnowledgePanel.vue'
import api from "../api/index"
import { LAYER_CONFIG, LAYER_KEYS, getLayerList } from "../config/layers"
import { useFormGuard } from "../composables/useFormGuard"
import { sanitizeKrName } from "../utils/krName"
import StrategyLayer from "../components/strategy-map/StrategyLayer.vue"
import ConnectionLines from "../components/strategy/ConnectionLines.vue"
import MapCanvasToolbar from "../components/map-canvas/MapCanvasToolbar.vue"
import MapCanvasDialogs from "../components/map-canvas/MapCanvasDialogs.vue"
import ConnectionManagerDialog from "../components/map-canvas/ConnectionManagerDialog.vue"
import MyDialog from "../components/MyDialog.vue"
import html2canvas from "html2canvas"
// ── 四层泳道配置 ──
const layerKeysOrdered = [...LAYER_KEYS]
const orderedLayerConfigs = getLayerList()
const route = useRoute()
// ── 状态 ──
const maps = ref<any[]>([])
const selectedMap = ref<number | null>(null)
const currentMap = ref<any>(null)
const isDirty = ref(false)
const { markDirty, markClean } = useFormGuard(isDirty)
// 乐观并发基准(缺陷修复 2026-10-12):本地快照对应的服务端 updated_at
const baseUpdatedAt = ref<string | null>(null)
// 收到 409(服务端已被他人修改)后置位:停止自动保存,绝不重试覆写
const autoSaveBlocked = ref(false)
const allKpis = ref<any[]>([])
const kpiNameMap = reactive<Record<string, string>>({})
// 维度数据(对应backend的dimensions)
const dimensions = reactive<any[]>([])
const loading = ref(true)
// 连线
const connections = ref<any[]>([])
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)
const selectedConnIdx = ref<number | null>(null)
// ── 因果链查看模式 ──
const chainMode = ref(false)
const chainFocusKey = ref<string | null>(null)
const chainHoverNodes = ref<string[] | null>(null)
const chainFocusNodes = ref<string[] | null>(null)
/** 当前因果链涉及的节点 key 集合(悬停链 ∪ 聚焦链),用于节点变淡 */
const chainNodeKeys = computed<string[]>(() => {
const set = new Set<string>()
chainHoverNodes.value?.forEach(k => set.add(k))
chainFocusNodes.value?.forEach(k => set.add(k))
return [...set]
})
function toggleChainMode() {
chainMode.value = !chainMode.value
chainFocusKey.value = null
chainHoverNodes.value = null
chainFocusNodes.value = null
if (!chainMode.value) selectedConnIdx.value = null
}
function onChainHover(nodes: string[] | null) { chainHoverNodes.value = nodes }
function onChainFocus(nodes: string[] | null) { chainFocusNodes.value = nodes }
/** 更新连线语义(effect/label),落库 */
function onUpdateConnection(idx: number, patch: any) {
if (!connections.value[idx]) return
Object.assign(connections.value[idx], patch)
markDirty()
saveConnections()
}
// 缩放(transform: scale + 滚动容器宽度补偿)
const zoomLevel = ref(1)
const scrollWrapRef = ref<HTMLElement | null>(null)
/** 田字格 2×2 模式(滚动容器宽度 >= 900px 时启用;<900 降级纵向) */
const gridMode = ref(true)
/** canvas-body 缩放样式:transform 只影响视觉(GPU合成),不触发整页重排 */
const bodyZoomStyle = computed(() => ({
transform: `scale(${zoomLevel.value})`,
transformOrigin: 'top left',
transition: 'transform 0.18s ease',
}))
/** 缩放包裹层宽度:body 布局宽度 = 容器宽 / zoom,缩放后视觉宽度恒等于容器宽 */
const zoomWrapWidth = computed(() => `calc(100% / ${zoomLevel.value})`)
/** 缩放包裹层高度:动态补偿,使滚动区域恰好覆盖缩放后的视觉内容 */
const zoomWrapHeight = ref('auto')
/** 更新缩放包裹层高度(zoom/内容/窗口变化后调用) */
function updateZoomWrapHeight() {
nextTick(() => {
const body = bodyRef.value
if (!body) return
const h = body.scrollHeight
zoomWrapHeight.value = h > 0 ? `${Math.round(h * zoomLevel.value) + 24}px` : 'auto'
})
}
/** 根据滚动容器实际宽度判断是否启用田字格(>1200 及 800-1200 用田字格,<800 降级纵向;以 900 为阈值保底) */
function updateGridMode() {
const w = scrollWrapRef.value?.clientWidth ?? window.innerWidth
gridMode.value = w >= 900
nextTick(() => {
recalcTrigger.value++
connectionLinesRef.value?.recalcAll()
})
}
// 节点状态(红黄绿灯)
const objectiveLevels = reactive<Record<string, string>>({})
interface KpiProgressItem {
ratio: number
level: string
kpiList: { code: string; name: string; actual: number|null; target: number|null; unit: string|null }[]
planSummary: { total: number; pending: number; in_progress: number; completed: number; overdue: number } | null
}
const kpiProgressMap = reactive<Record<string, KpiProgressItem>>({})
// 弹窗
const showNodeDialog = ref(false)
const editingNodeData = ref<any>(null)
const editingLayerKey = ref('')
const dialogRefreshKey = ref(0)
const dialogForm = reactive({ name: '', description: '', icon: 'target', targetValue: null, currentValue: null, unit: '%', owner: '', isLeading: false, kpis: [] as string[] })
function onSaveDialog(data: any) {
markDirty()
// 新格式:O+KR 结构(来自两步弹窗)
if (data.o_name) {
if (!data.o_name?.trim()) { ElMessage.warning('请输入目标名称'); return }
onNodeSave({
...data,
name: data.o_name.trim(),
description: data.o_description?.trim() || '',
layer: editingLayerKey.value,
icon: 'target',
})
return
}
// 旧格式兼容(保留)
if (!dialogForm.name?.trim()) { ElMessage.warning('请输入目标名称'); return }
onNodeSave({ ...dialogForm, name: dialogForm.name.trim(), layer: editingLayerKey.value })
showNodeDialog.value = false
}
const showVersions = ref(false)
const versions = ref<any[]>([])
const showCausalityDialog = ref(false)
const causalityRecommendations = ref<any[]>([])
const causalityLoading = ref(false)
// DOM引用
const bodyRef = ref<HTMLElement | null>(null)
const layerRefs: Record<string, HTMLElement> = {}
const nodeRefs: Record<string, HTMLElement> = {}
const connectionLinesRef = ref<any>(null)
const recalcTrigger = ref(0)
const viewDirection = ref('cascade')
// computed node refs for ConnectionLines
const compiledNodeRefs = computed(() => nodeRefs)
watch(viewDirection, () => {
recalcTrigger.value++
})
// 泳道组件实例引用(用于汇聚各层内部的nodeRefs给ConnectionLines)
const layerCompRefs: Record<string, any> = {}
function setLayerCompRef(key: string, el: any) {
if (el) layerCompRefs[key] = el
}
/** 从四个泳道组件汇聚所有节点DOM引用用于连线渲染,同步到nodeRefs */
function collectAllNodeRefs(): Record<string, any> {
void (0) // keep as side-effect function
// 清空旧refs
Object.keys(nodeRefs).forEach(k => delete (nodeRefs as any)[k])
// 从各泳道组件汇聚
for (const key of layerKeysOrdered) {
const comp = layerCompRefs[key]
if (comp?.nodeRefs) {
Object.assign(nodeRefs, comp.nodeRefs)
}
}
return nodeRefs
}
// KPI详情浮层
const showKpiDetail = ref(false)
const kpiDetailObjName = ref("")
const kpiDetailDimKey = ref("")
const kpiDetailObjIdx = ref(-1)
const kpiDetailList = ref<any[]>([])
// 行动方案弹窗
const showPlanPopup = ref(false)
const planPopupObjName = ref("")
const planPopupDimKey = ref("")
const planPopupObjIdx = ref(-1)
const planPopupList = ref<any[]>([])
// ── 图标映射(从旧MapCanvas复用) ──
const iconMap: Record<string, string> = {
target: "Star", star: "StarFilled", rocket: "Top", chart: "DataLine",
team: "UserFilled", light: "Lightning", shield: "Shield", gear: "Setting",
handshake: "Handshake", medal: "Trophy",
}
// ── 辅助方法 ──
function getLayerNodes(key: string): any[] {
const dim = dimensions.find((d: any) => d.key === key)
return dim?.objectives || []
}
function setLayerRef(key: string, el: any) {
if (el) layerRefs[key] = el
}
function getNodeLevel(key: string): string {
return objectiveLevels[key] || 'gray'
}
function getNodeProgress(key: string): any {
return kpiProgressMap[key] || null
}
/** 确保dimensions中存在所有4层 */
function ensureFourLayers() {
for (const cfg of orderedLayerConfigs) {
if (!dimensions.find((d: any) => d.key === cfg.key)) {
dimensions.push({
key: cfg.key,
name: cfg.label,
label: cfg.label,
icon: cfg.icon,
color: cfg.color,
objectives: [],
})
}
}
}
// ── 地图选择 ──
function onSelectMap(id: number) {
selectedMap.value = id
loadCanvas()
}
// ── 缩放 ──
function zoomIn() {
zoomLevel.value = Math.min(1.5, Math.round((zoomLevel.value + 0.1) * 10) / 10)
updateZoomWrapHeight()
}
function zoomOut() {
zoomLevel.value = Math.max(0.5, Math.round((zoomLevel.value - 0.1) * 10) / 10)
updateZoomWrapHeight()
}
function zoomReset() {
zoomLevel.value = 1
updateZoomWrapHeight()
}
function zoomFit() {
// 自适应缩放:按画布视觉高度与视口高度比计算
if (!scrollWrapRef.value || !bodyRef.value) return
const wrapH = scrollWrapRef.value.clientHeight
const contentH = bodyRef.value.scrollHeight * zoomLevel.value
if (contentH > 0 && wrapH > 0) {
zoomLevel.value = Math.max(0.5, Math.min(1.5, Math.round((wrapH / contentH) * 10) / 10))
}
updateZoomWrapHeight()
}
watch(zoomLevel, () => {
updateZoomWrapHeight()
nextTick(() => connectionLinesRef.value?.recalcAll())
})
// ── 画布加载 ──
function loadCanvas() {
if (!selectedMap.value) return
dimensions.splice(0, dimensions.length)
connections.value = []
linkingFrom.value = null
selectedConnIdx.value = null
chainMode.value = false
chainFocusKey.value = null
chainHoverNodes.value = null
chainFocusNodes.value = null
loading.value = true
api.get("/maps").then((r: any) => {
const data = r.data || []
const map = data.find((m: any) => m.id === selectedMap.value)
currentMap.value = map
// 并发基准:本地快照对应服务端 updated_at(后续保存须带此基准)
baseUpdatedAt.value = map?.updated_at || null
autoSaveBlocked.value = false
if (map?.dimensions) {
for (const dim of map.dimensions) {
dimensions.push({
key: dim.key,
label: dim.name || dim.label,
icon: dim.icon || '📋',
color: dim.color || LAYER_CONFIG[dim.key]?.color || '#409eff',
objectives: dim.objectives || [],
})
}
}
// 确保四层都存在
ensureFourLayers()
// 数据迁移:旧格式 kpis[] → krs[]
migrateOldKpiFormat()
connections.value = map?.canvas_data?.connections || []
loading.value = false
nextTick(() => {
triggerRecalc()
updateGridMode()
updateZoomWrapHeight()
loadObjectiveLevels()
})
}).catch(() => {
ensureFourLayers()
baseUpdatedAt.value = null // 载入失败 ⇒ 无基准(服务端 fail-closed)
loading.value = false
})
}
// ── 触发连线重算 ──
function triggerRecalc() {
recalcTrigger.value++
collectAllNodeRefs()
nextTick(() => {
connectionLinesRef.value?.recalcAll()
})
}
// ── 连线交互 ──
function startLink(dimKey: string, oi: number, obj: any) {
selectedConnIdx.value = null
linkingFrom.value = { key: `${dimKey}-${oi}`, obj }
}
function cancelLink() {
linkingFrom.value = null
tempLine.value = null
dragConnectTarget.value = null
}
function onNodeClick(dimKey: string, oi: number, obj: any) {
const key = `${dimKey}-${oi}`
// 连线模式
if (linkingFrom.value) {
if (linkingFrom.value.key === key) { cancelLink(); return }
completeConnection(linkingFrom.value.key, key)
return
}
// 因果链查看模式:点击节点聚焦其相关因果链,再次点击取消
if (chainMode.value) {
chainFocusKey.value = chainFocusKey.value === key ? null : key
return
}
// 有KPI则展示详情
if (kpiProgressMap[key]?.kpiList?.length > 0) {
showKpiDetailModal(dimKey, oi, obj)
} else {
openEditDialog(dimKey, oi, obj)
}
}
/**
* 层内重排完成回调 — 由 vuedraggable 在拖拽排序后触发
* SortableJS 已通过 :list 引用直接更新了响应式数组,
* 这里只需触发连线重算和自动保存
*/
function onLayerReorder(dimKey: string) {
nextTick(() => {
triggerRecalc()
debouncedSaveCanvas()
})
}
function completeConnection(fromKey: string, toKey: string) {
// 检查重复
if (connections.value.some((c: any) => c.from === fromKey && c.to === toKey)) {
ElMessage.warning("已存在相同连线"); cancelLink(); return
}
api.post(`/maps/${selectedMap.value}/connections`, {
from: fromKey, to: toKey,
}).then((r: any) => {
if (r?.updated_at) baseUpdatedAt.value = r.updated_at // 服务端已写 ⇒ 采纳新基准(防自伤 409)
ElMessage.success("连线已添加")
connections.value.push({ from: fromKey, to: toKey, style: "solid", effect: "positive", label: "" })
markDirty()
cancelLink()
nextTick(() => { triggerRecalc(); saveConnections() })
}).catch((e: any) => {
if (e?.response?.status !== 400) {
connections.value.push({ from: fromKey, to: toKey, style: "solid", effect: "positive", label: "" })
markDirty()
ElMessage.success("连线已添加(本地)")
} else {
ElMessage.warning(e?.response?.data?.detail || "连线失败")
}
cancelLink()
nextTick(triggerRecalc)
})
}
// 跳转KPI详情
function goKPI(kpiCode: string) {
const found = allKpis.value.find((k: any) => k.kpi_code === kpiCode)
window.location.href = found ? '/kpis/' + found.id : '/kpis'
}
// ── 拖拽连线的鼠标跟踪(增强:实时高亮 + BSC方向合法性 + 吸附) ──
/** 获取节点 key 对应的层顺序 index(finance=0 … learning=3,越大越下层) */
function layerIndexOf(key: string): number {
const dim = key.split('-')[0]
const idx = LAYER_KEYS.indexOf(dim)
return idx >= 0 ? idx : -1
}
/** 拖起时的合法目标映射:BSC 支撑方向(下层→上层 或 同层)为 valid,反方向 forbidden(灰显) */
const linkEligibilityMap = computed<Record<string, 'valid' | 'forbidden'>>(() => {
const map: Record<string, 'valid' | 'forbidden'> = {}
if (!linkingFrom.value) return map
const srcIdx = layerIndexOf(linkingFrom.value.key)
if (srcIdx < 0) return map
for (const key of Object.keys(nodeRefs)) {
if (key === linkingFrom.value.key) continue
const tIdx = layerIndexOf(key)
map[key] = tIdx >= 0 && tIdx <= srcIdx ? 'valid' : 'forbidden'
}
return map
})
function onLinkDragStart(e: MouseEvent, dimKey: string, oi: number, obj: any) {
const startX = e.clientX
const startY = e.clientY
startLink(dimKey, oi, obj)
const onMove = (mv: MouseEvent) => {
// 实时高亮 hover 节点(合法/非法样式由 linkEligibilityMap 决定)
let hoverKey: string | null = null
for (const key of Object.keys(nodeRefs)) {
if (key === linkingFrom.value?.key) continue
const el = nodeRefs[key]
if (!el) continue
const rect = el.getBoundingClientRect()
if (mv.clientX >= rect.left && mv.clientX <= rect.right &&
mv.clientY >= rect.top && mv.clientY <= rect.bottom) {
hoverKey = key; break
}
}
dragConnectTarget.value = hoverKey
}
const onUp = (up: MouseEvent) => {
document.removeEventListener('mouseup', onUp)
document.removeEventListener('mousemove', onMove)
const dist = Math.hypot(up.clientX - startX, up.clientY - startY)
if (dist >= 5 && linkingFrom.value) {
let targetKey: string | null = null
// 1) 精确命中 hover 节点
if (dragConnectTarget.value) {
targetKey = dragConnectTarget.value
} else {
// 2) 吸附:最近节点中心距离 < 48px 自动贴合
let best: string | null = null
let bestDist = 48
for (const key of Object.keys(nodeRefs)) {
if (key === linkingFrom.value.key) continue
const el = nodeRefs[key]
if (!el) continue
const rect = el.getBoundingClientRect()
const cx = rect.left + rect.width / 2
const cy = rect.top + rect.height / 2
const d = Math.hypot(up.clientX - cx, up.clientY - cy)
if (d < bestDist) { bestDist = d; best = key }
}
targetKey = best
}
if (targetKey) {
// BSC 方向合法性:下层→上层 或 同层;反方向拒绝(灰显)
const srcIdx = layerIndexOf(linkingFrom.value.key)
const tIdx = layerIndexOf(targetKey)
if (srcIdx >= 0 && tIdx >= 0 && tIdx > srcIdx) {
ElMessage.warning('因果方向应为「下层支撑上层」(如 学习层→流程层),已取消连线')
cancelLink()
dragConnectTarget.value = null
return
}
const parts = targetKey.split('-')
const tDim = parts[0]
const tIdxNum = parseInt(parts[1])
const dim = dimensions.find((d: any) => d.key === tDim)
if (dim && dim.objectives[tIdxNum]) {
completeConnection(linkingFrom.value.key, targetKey)
}
}
cancelLink()
}
dragConnectTarget.value = null
}
document.addEventListener('mouseup', onUp)
document.addEventListener('mousemove', onMove)
}
// ── 连接管理面板(非拖拽方式维护连线) ──
const showConnManager = ref(false)
const layerLabelMap = computed<Record<string, string>>(() => {
const m: Record<string, string> = {}
for (const cfg of orderedLayerConfigs) m[cfg.key] = cfg.label
return m
})
const connectionNodeOptions = computed(() => {
const opts: { key: string; name: string; layerName: string }[] = []
for (const dim of dimensions) {
const layerName = layerLabelMap.value[dim.key] || dim.key
;(dim.objectives || []).forEach((obj: any, oi: number) => {
opts.push({ key: `${dim.key}-${oi}`, name: obj.name || `目标${oi + 1}`, layerName })
})
}
return opts
})
function connLabel(key: string): string {
const o = connectionNodeOptions.value.find((n: any) => n.key === key)
return o ? `${o.layerName} · ${o.name}` : key
}
async function onManagerAddConnection(payload: { from: string; to: string; effect?: string }) {
if (!selectedMap.value) return
if (payload.from === payload.to) { ElMessage.warning('源目标与目标目标不能相同'); return }
if (connections.value.some((c: any) => c.from === payload.from && c.to === payload.to)) {
ElMessage.warning('已存在相同连线'); return
}
try {
const r: any = await api.post(`/maps/${selectedMap.value}/connections`, { from: payload.from, to: payload.to })
if (r?.updated_at) baseUpdatedAt.value = r.updated_at // 服务端已写 ⇒ 采纳新基准(防自伤 409)
connections.value.push({ from: payload.from, to: payload.to, style: 'solid', effect: payload.effect || 'positive', label: '' })
markDirty()
ElMessage.success('连线已添加')
nextTick(() => { triggerRecalc(); saveConnections() })
} catch (e: any) {
if (e?.response?.status === 400) {
ElMessage.warning(e?.response?.data?.detail || '连线失败')
} else {
connections.value.push({ from: payload.from, to: payload.to, style: 'solid', effect: payload.effect || 'positive', label: '' })
markDirty()
ElMessage.success('连线已添加(本地)')
nextTick(triggerRecalc)
}
}
}
function onManagerDeleteConnection(idx: number) {
const conn = connections.value[idx]
if (!conn) return
api.delete(`/maps/${selectedMap.value}/connections`, { data: { from: conn.from, to: conn.to } })
.then((r: any) => {
if (r?.updated_at) baseUpdatedAt.value = r.updated_at
connections.value.splice(idx, 1); nextTick(triggerRecalc)
})
.catch(() => { connections.value.splice(idx, 1); nextTick(triggerRecalc) })
}
function onManagerUpdateConnection(idx: number, patch: any) {
if (!connections.value[idx]) return
Object.assign(connections.value[idx], patch)
markDirty()
saveConnections()
}
function onSelectConnection(idx: number) {
selectedConnIdx.value = selectedConnIdx.value === idx ? null : idx
}
function onDeleteConnection(idx: number) {
if (selectedConnIdx.value !== idx) return
const conn = connections.value[idx]
if (!conn) return
api.delete(`/maps/${selectedMap.value}/connections`, {
data: { from: conn.from, to: conn.to },
}).then((r: any) => {
if (r?.updated_at) baseUpdatedAt.value = r.updated_at
ElMessage.success("连线已删除")
connections.value.splice(idx, 1)
selectedConnIdx.value = null
nextTick(triggerRecalc)
}).catch(() => {
connections.value.splice(idx, 1)
selectedConnIdx.value = null
nextTick(triggerRecalc)
})
}
// ── 节点CRUD ──
function openAddDialog(layerKey: string) {
editingLayerKey.value = layerKey
editingNodeData.value = null
dialogForm.name = ''
dialogForm.description = ''
dialogForm.icon = 'target'
dialogForm.targetValue = null
dialogForm.currentValue = null
dialogForm.unit = '%'
dialogForm.owner = ''
dialogForm.isLeading = false
dialogForm.kpis = []
dialogRefreshKey.value++
showNodeDialog.value = true
}
function openEditDialog(dimKey: string, oi: number, obj: any) {
editingLayerKey.value = dimKey
editingNodeData.value = {
id: obj.id,
name: obj.name,
targetValue: obj.targetValue ?? obj.target_value ?? null,
currentValue: obj.currentValue ?? obj.current_value ?? null,
unit: obj.unit || '%',
owner: obj.owner || '',
isLeading: obj.isLeading ?? obj.is_leading ?? false,
description: obj.description || '',
// O+KR 扩展字段(传递给弹窗预填)
krs: obj.krs || [],
o_name: obj.name,
o_description: obj.description || '',
_rawObjective: obj, // 保留原始引用以便直接修改KR
}
// 保存编辑索引以便更新
editingNodeData.value._dimKey = dimKey
editingNodeData.value._index = oi
showNodeDialog.value = true
}
function onNodeSave(data: any) {
const layer = editingLayerKey.value || data.layer || data.dimKey
const dim = dimensions.find((d: any) => d.key === layer)
if (!dim) return
// 构建节点对象(兼容新旧两种格式)
const node = {
name: data.name,
targetValue: data.targetValue ?? null,
currentValue: data.currentValue ?? null,
unit: data.unit || '%',
owner: data.owner || '',
isLeading: data.isLeading ?? false,
description: data.description || '',
layer: layer,
kpis: data.kpis || [],
icon: data.icon || 'target',
// O+KR 扩展字段
krs: data.krs || [], // 关键结果数组 [{name, target_value, weight, sort_order}]
template_name: data.template_name || null, // 来源模板名
}
// 用户自定义O自动入库(第3层:用户贡献模板)
if (data.template_id == null && Array.isArray(data.krs) && data.krs.length >= 2) {
saveUserTemplate(data, layer)
}
if (editingNodeData.value?._index != null && editingNodeData.value._dimKey === layer) {
// 编辑已有节点
const idx = editingNodeData.value._index
if (dim.objectives[idx]) {
// 保留原有kpis/krs等额外字段(当新数据未提供时)
const extra = {
kpis: dim.objectives[idx].kpis || [],
icon: dim.objectives[idx].icon || 'target',
}
// 如果新数据没有提供krs,保留旧krs
if (!data.krs || data.krs.length === 0) {
node.krs = dim.objectives[idx].krs || []
}
// 保留后端回写的 objective_id(KR落库用)
if (dim.objectives[idx]._objective_id) {
node._objective_id = dim.objectives[idx]._objective_id
}
dim.objectives[idx] = { ...node, ...extra }
ElMessage.success("节点已更新")
// KR完整修复: 同步krs表(objective_id存在时直接调API)
if (node._objective_id && data.krs && data.krs.length > 0) {
syncNodeKrs(node._objective_id, data.krs)
}
}
} else {
// 新增节点
dim.objectives.push({
...node,
kpis: [],
icon: 'target',
})
ElMessage.success("节点已添加")
}
editingNodeData.value = null
showNodeDialog.value = false
debouncedSaveCanvas()
nextTick(triggerRecalc)
}
// ── KR完整修复(2026-08-27): 节点KR同步到krs表 ──
async function syncNodeKrs(objectiveId: number, krs: any[]) {
try {
const payload = (krs || []).map((kr: any) => ({
id: kr._kr_id || kr.id || null,
title: kr.name || kr.title || "",
operator: kr.operator || ">=",
target_value: kr.target_value ?? null,
weight: kr.weight ?? null,
kpi_code: kr.kpi_code || null,
due_date: kr.due_date || null,
})).filter((k: any) => k.title)
if (payload.length === 0) return
const res: any = await okrApi.syncKrs(objectiveId, payload)
// 回写 _kr_id,避免下次重复创建
if (res?.krs) {
const idByTitle: Record<string, number> = {}
res.krs.forEach((r: any) => { idByTitle[r.title] = r.id })
krs.forEach((kr: any) => {
if (kr.name && idByTitle[kr.name]) kr._kr_id = idByTitle[kr.name]
})
}
} catch (e: any) {
// 静默失败:地图JSON仍是权威保存,krs表同步失败不阻塞画布
console.warn("[KR同步] 节点KR同步失败:", e?.response?.data?.detail || e?.message)
}
}
// ── 用户自定义O自动入库(OKR模板库第3层) ──
let userTemplateCooldown: Record<string, number> = {}
async function saveUserTemplate(data: any, layer: string) {
const name = (data.name || '').trim()
if (!name) return
// 防抖:同一名称30秒内不重复入库
const now = Date.now()
if (userTemplateCooldown[name] && now - userTemplateCooldown[name] < 30000) return
userTemplateCooldown[name] = now
try {
await okrTemplateApi.create({
name,
description: (data.description || '').trim(),
dimension: layer,
preset_krs: (data.krs || []).map((kr: any) => ({
// 护栏(2026-09-16):画布 KR 名 → 模板库 透传点,历史污染尾巴(截断值)在此规整,
// 防污染经用户自定义模板再次传染新目标
name: sanitizeKrName(kr.name, kr.target_value).name,
target_value: kr.target_value,
weight: kr.weight || 33,
})),
})
// 静默成功,不打扰用户
} catch (e: any) {
// 已存在同名模板等错误静默忽略
const detail = e?.response?.data?.detail || ''
if (detail && !String(detail).includes('重复')) {
console.warn('[OKR模板库] 用户模板入库失败:', detail)
}
}
}
async function deleteNode(dimKey: string, oi: number) {
try {
const dim = dimensions.find((d: any) => d.key === dimKey)
if (dim) {
const key = `${dimKey}-${oi}`
connections.value = connections.value.filter(c => c.from !== key && c.to !== key)
dim.objectives.splice(oi, 1)
ElMessage.success("节点已删除")
nextTick(() => { triggerRecalc(); debouncedSaveCanvas() })
}
} catch { /* cancel */ }
}
// ── KR操作 ──
function onEditKr(dimKey: string, oi: number, obj: any, ki: number, kr: any) {
// 打开O编辑弹窗并定位到对应KR
openEditDialog(dimKey, oi, obj)
}
function onAddKr(dimKey: string, oi: number, obj: any) {
const dim = dimensions.find((d: any) => d.key === dimKey)
if (!dim || !dim.objectives[oi]) return
const target = dim.objectives[oi]
if (!target.krs) target.krs = []
if (target.krs.length >= 5) {
ElMessage.warning('每个目标最多5个关键结果')
return
}
target.krs.push({
name: "",
target_value: "",
weight: 33,
kpi_code: null,
sort_order: target.krs.length + 1,
})
ElMessage.success("已添加空白KR,请编辑完善")
debouncedSaveCanvas()
}
// ── KR 删除(2026-09-16:泳道内删除入口,补齐「目标展开后没有删除图标」缺口) ──
const showKrDeleteConfirm = ref(false)
const krDeleting = ref(false)
const krDeleteTarget = ref<{ dimKey: string; oi: number; ki: number; name: string } | null>(null)
/**
* KR 删除入口:单条时不允许删到 0(口径见 _reports/cma-map-kr-delete-entry-20260916-return.md)
* 依据:① 后端 maps.py `_sync_objectives_krs` 在目标 krs 为空时 `continue`(跳过同步),
* 删到 0 不会传播到 krs 表 → 残留孤儿行;② 无 KR 的目标无法度量达成率。
* 单条 KR 时按钮仍然显示(可发现),点击给出明确提示,避免"图标消失=功能缺失"的观感。
*/
function onDeleteKr(dimKey: string, oi: number, obj: any, ki: number, kr: any) {
const dim = dimensions.find((d: any) => d.key === dimKey)
const target = dim?.objectives?.[oi]
const list: any[] = target?.krs || []
if (!target || ki < 0 || ki >= list.length) return
if (list.length <= 1) {
ElMessage.warning('每个目标至少保留 1 条关键结果;如需替换请先添加新的 KR')
return
}
krDeleteTarget.value = { dimKey, oi, ki, name: kr?.name || `KR ${ki + 1}` }
showKrDeleteConfirm.value = true
}
function cancelDeleteKr() {
showKrDeleteConfirm.value = false
krDeleteTarget.value = null
}
async function confirmDeleteKr() {
const t = krDeleteTarget.value
if (!t || krDeleting.value) return
const dim = dimensions.find((d: any) => d.key === t.dimKey)
const target = dim?.objectives?.[t.oi]
const kr = target?.krs?.[t.ki]
if (!target || !kr) { cancelDeleteKr(); return }
krDeleting.value = true
// 落库 id:_objective_id 由地图保存时后端回写;_kr_id 由 KR 同步/地图保存时回写
const objectiveId: number | null = target._objective_id || null
const krId: number | null = kr._kr_id || kr.id || null
const removedName = kr.name || `KR ${t.ki + 1}`
// 1) 本地移除(界面即时无残留)
target.krs.splice(t.ki, 1)
cancelDeleteKr()
try {
if (objectiveId && krId) {
// 2) 精确删除 krs 表对应行
await okrApi.deleteKr(objectiveId, krId)
}
// 3) 地图 JSON 为权威:整体保存(服务端 _sync_objectives_krs 亦会清理标题不在 JSON 中的 KR 行)
if (isSaving) await new Promise(r => setTimeout(r, 600))
await saveCanvas(true)
ElMessage.success(`关键结果「${removedName}」已删除`)
} catch (e: any) {
ElMessage.error(`删除失败:${e?.response?.data?.detail || e?.message || '未知错误'}`)
} finally {
krDeleting.value = false
}
nextTick(triggerRecalc)
}
// ── 数据迁移:旧格式 kpis[] 自动转为 krs[] ──
function migrateOldKpiFormat() {
for (const dim of dimensions) {
for (const obj of dim.objectives || []) {
// 仅当有 kpis 数组 且 没有 krs 时执行迁移
if (obj.kpis && Array.isArray(obj.kpis) && obj.kpis.length > 0 && (!obj.krs || obj.krs.length === 0)) {
obj.krs = obj.kpis.map((kpiCode: string, idx: number) => ({
name: kpiNameMap[kpiCode] || kpiCode,
target_value: "",
weight: Math.round(100 / obj.kpis.length),
kpi_code: kpiCode,
sort_order: idx + 1,
}))
console.log(`[迁移] 目标「${obj.name}」: ${obj.kpis.length} 条KPI已转为KR`)
}
}
}
}
// ── 保存 ──
let saveTimer: ReturnType<typeof setTimeout> | null = null
let autoSaveTimer: ReturnType<typeof setInterval> | null = null
let isSaving = false
// 组件销毁标记(缺陷修复 2026-10-12): onMounted 是 async —— await 期间若已卸载,
// 其后续代码仍会执行并把定时器注册到「已销毁」的实例上(清理钩子早已跑完、无人回收),
// 于是该定时器永久在后台用过期快照覆写服务端(生产实测:离开画布页后仍每 30–60s 写一次)。
let disposed = false
function stopAutoSave() {
if (autoSaveTimer) { clearInterval(autoSaveTimer); autoSaveTimer = null }
}
function debouncedSaveCanvas() {
if (isSaving) return
if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(() => { saveCanvas() }, 500)
}
// 服务端基准(乐观并发):本地快照对应的 updated_at。null ⇒ 不带基准,服务端 fail-closed 409。
function currentBaseStamp(): string | undefined {
return baseUpdatedAt.value || undefined
}
async function saveCanvas(silent?: boolean) {
if (!selectedMap.value || disposed) return
if (isSaving) return
isSaving = true
const mapData = dimensions.map((d: any) => ({
key: d.key, name: d.label || d.name, icon: d.icon, color: d.color,
objectives: d.objectives,
}))
try {
const res: any = await mapApi.update(selectedMap.value, {
dimensions: mapData,
canvas_data: { connections: connections.value },
version: currentMap.value?.version,
base_updated_at: currentBaseStamp(), // 并发基准:过期则由服务端 409 拒绝覆写
})
if (res?.updated_at) baseUpdatedAt.value = res.updated_at // 采纳服务端新基准
markClean()
if (!silent) ElMessage.success("已保存")
} catch (e: any) {
if (e?.response?.status === 409) {
// 服务端已被他人修改:停止自动保存且不重试覆写(防过期快照整表覆写)
autoSaveBlocked.value = true
stopAutoSave()
ElMessage.warning("地图已被他人修改,自动保存已停止,请刷新页面后重试")
} else {
console.error("saveCanvas error:", e)
}
} finally {
isSaving = false
}
}
async function saveConnections() {
if (!selectedMap.value || disposed) return
try {
const res: any = await mapApi.update(selectedMap.value, {
canvas_data: { connections: connections.value },
base_updated_at: currentBaseStamp(),
})
if (res?.updated_at) baseUpdatedAt.value = res.updated_at
} catch (e: any) {
if (e?.response?.status === 409) {
autoSaveBlocked.value = true
stopAutoSave()
ElMessage.warning("地图已被他人修改,请刷新页面后重试")
}
}
}
// ── 发布/版本 ──
async function publishMap() {
if (!selectedMap.value) return
try {
await mapApi.update(selectedMap.value, { status: "published" })
ElMessage.success("已发布(自动创建版本快照)")
currentMap.value.status = "published"
loadCanvas()
} catch (e) {
ElMessage.error("发布失败")
}
}
async function loadVersions() {
if (!selectedMap.value) return
try {
const r: any = await api.get(`/maps/${selectedMap.value}/versions`)
versions.value = r.data || []
} catch { versions.value = [] }
}
async function rollback(verId: number) {
try {
await ElMessageBox.confirm("确定回滚到此版本?当前内容将被覆盖。")
await api.post(`/maps/${selectedMap.value}/versions/${verId}/rollback`)
ElMessage.success("回滚成功,刷新页面")
window.location.reload()
} catch (e: any) {
if (e !== "cancel") ElMessage.error("回滚失败")
}
}
// ── 加载目标状态 ──
async function loadObjectiveLevels() {
if (!selectedMap.value) return
try {
const r: any = await api.get(`/maps/${selectedMap.value}/review?level=strategic`)
for (const key of Object.keys(objectiveLevels)) delete objectiveLevels[key]
for (const key of Object.keys(kpiProgressMap)) delete kpiProgressMap[key]
const dims = r.dimensions || []
for (const dim of dims) {
const dimKey = dim.key
const localDim = dimensions.find((d: any) => d.key === dimKey)
if (!localDim) continue
for (let i = 0; i < localDim.objectives.length; i++) {
const key = `${dimKey}-${i}`
kpiProgressMap[key] = { ratio: 0, level: 'gray', kpiList: [], planSummary: null }
objectiveLevels[key] = 'gray'
}
dim.objectives.forEach((obj: any) => {
const localIdx = localDim.objectives.findIndex((o: any) => o.name === obj.name)
if (localIdx >= 0) {
const key = `${dimKey}-${localIdx}`
objectiveLevels[key] = obj.level || 'gray'
const kpis = obj.kpis || []
const kpiList: any[] = []
let totalRatio = 0
let count = 0
for (const k of kpis) {
kpiList.push({
code: k.kpi_code, name: k.kpi_name, actual: k.actual_value, target: k.target_value, unit: k.unit,
})
if (k.actual_value != null && k.target_value) {
totalRatio += k.actual_value / k.target_value; count++
}
}
if (kpiList.length > 0) {
kpiProgressMap[key] = {
ratio: count > 0 ? Math.round((totalRatio / count) * 100) : 0,
level: obj.level || 'gray', kpiList,
planSummary: obj.action_plan_summary || null,
}
} else if (obj.action_plan_summary?.total > 0) {
kpiProgressMap[key] = { ratio: 0, level: obj.level || 'gray', kpiList: [], planSummary: obj.action_plan_summary }
}
}
})
}
} catch { /* silent */ }
}
// ── KPI详情浮层 ──
function showKpiDetailModal(dimKey: string, oi: number, obj: any) {
const key = `${dimKey}-${oi}`
kpiDetailObjName.value = obj.name
kpiDetailDimKey.value = dimKey
kpiDetailObjIdx.value = oi
kpiDetailList.value = kpiProgressMap[key]?.kpiList || []
showKpiDetail.value = true
}
function editKpiDetailObj() {
showKpiDetail.value = false
const dim = dimensions.find((d: any) => d.key === kpiDetailDimKey.value)
if (dim && dim.objectives[kpiDetailObjIdx.value]) {
openEditDialog(kpiDetailDimKey.value, kpiDetailObjIdx.value, dim.objectives[kpiDetailObjIdx.value])
}
}
// ── 行动方案 ──
async function showPlanList(dimKey: string, oi: number, obj: any) {
planPopupObjName.value = obj.name
planPopupDimKey.value = dimKey
planPopupObjIdx.value = oi
planPopupList.value = []
try {
const r: any = await api.get(`/maps/${selectedMap.value}/review?level=strategic`)
const dims = r.dimensions || []
const allPlans = r.action_plans || []
for (const dim of dims) {
if (dim.key !== dimKey) continue
for (const robj of dim.objectives || []) {
if (robj.name !== obj.name) continue
const objKpiIds = (robj.kpis || []).map((k: any) => k.kpi_id)
planPopupList.value = allPlans.filter((p: any) => objKpiIds.includes(p.kpi_id))
break
}
break
}
} catch { /* silent */ }
showPlanPopup.value = true
}
function quickCreatePlan() {
showPlanPopup.value = false
window.location.href = `/action-plans?dimension=${planPopupDimKey.value}`
}
function goAlignment() {
const url = currentMap.value ? `/alignment?map_id=${currentMap.value.id}` : '/alignment'
window.location.href = url
}
/** 导出画布为PNG图片(transform缩放下截图不完整:临时回100%导出) */
async function exportImage() {
const el = document.querySelector('.canvas-scroll-wrap') as HTMLElement
if (!el) { ElMessage.warning('画布尚未加载'); return }
const prevZoom = zoomLevel.value
if (prevZoom !== 1) {
zoomLevel.value = 1
updateZoomWrapHeight()
await nextTick()
await new Promise(r => setTimeout(r, 250))
}
try {
const canvas = await html2canvas(el, {
useCORS: true,
backgroundColor: '#ffffff',
scale: 2,
})
const link = document.createElement('a')
link.download = '战略地图.png'
link.href = canvas.toDataURL('image/png')
link.click()
ElMessage.success('图片已导出')
} catch (e) {
console.error('exportImage error:', e)
ElMessage.error('导出失败')
} finally {
if (prevZoom !== 1) {
zoomLevel.value = prevZoom
updateZoomWrapHeight()
}
}
}
// ── KPI因果链推荐 ──
async function showCausalityRecommendations() {
if (!currentMap.value) { ElMessage.warning('请先选择或创建战略地图'); return }
causalityLoading.value = true
showCausalityDialog.value = true
try {
const r: any = await api.get('/kpi-causality')
const items = r.data || r.items || r || []
causalityRecommendations.value = Array.isArray(items) ? items : []
} catch (e) {
ElMessage.error('加载因果链失败')
causalityRecommendations.value = []
}
causalityLoading.value = false
}
async function applyCausality(rec: any) {
try {
await api.post(`/maps/${currentMap.value?.id}/connections`, { from: rec.source_kpi_code, to: rec.target_kpi_code })
ElMessage.success('连线已添加')
showCausalityDialog.value = false
loadCanvas()
} catch { ElMessage.error('添加失败') }
}
async function applyAllCausality() {
let added = 0
for (const rec of causalityRecommendations.value) {
try {
await api.post(`/maps/${currentMap.value?.id}/connections`, { from: rec.source_kpi_code, to: rec.target_kpi_code })
added++
} catch {}
}
ElMessage.success(`已添加 ${added}/${causalityRecommendations.value.length} 条因果链`)
showCausalityDialog.value = false
loadCanvas()
}
// 滚动触发连线重算(rAF节流,避免滚动/拖拽时卡顿)
let scrollRaf = 0
function onCanvasScroll() {
if (scrollRaf) return
scrollRaf = requestAnimationFrame(() => {
scrollRaf = 0
triggerRecalc()
})
}
function onWindowResize() {
updateGridMode()
updateZoomWrapHeight()
triggerRecalc()
}
onMounted(async () => {
const r: any = await mapApi.list()
maps.value = r.data || []
// 路由带 id 优先加载对应地图(2026-09-08 修复:原忽略 :id 恒取第一张,
// 致 /maps/canvas/53 打开却显示最新图54、新增节点落错地图)
const routeId = Number(route.params.id)
const target = maps.value.find((m: any) => m.id === routeId) || maps.value[0]
if (target) {
selectedMap.value = target.id
loadCanvas()
} else {
ensureFourLayers()
loading.value = false
}
const k: any = await kpiApi.list({ page_size: 100 })
allKpis.value = k.data || []
for (const kpi of allKpis.value) {
if (kpi.kpi_code) kpiNameMap[kpi.kpi_code] = kpi.kpi_name || kpi.kpi_code
}
// 自动保存:仅在「草稿 + 本地有未保存变更(isDirty) + 未被 409 停写 + 未销毁」时写入
// (缺陷修复 2026-10-12:原来无条件每 30s 用本地快照整表覆写,会回置他方写入)
if (!disposed) {
autoSaveTimer = setInterval(() => {
if (disposed || autoSaveBlocked.value) return
if (currentMap.value?.status === 'draft' && isDirty.value) saveCanvas(true)
}, 30000)
// 注册/销毁竞态兜底:若 await 期间已被卸载,立即回收
if (disposed) stopAutoSave()
}
nextTick(() => {
updateGridMode()
updateZoomWrapHeight()
})
scrollWrapRef.value?.addEventListener('scroll', onCanvasScroll, { passive: true })
window.addEventListener('resize', onWindowResize)
})
onUnmounted(() => {
disposed = true // 先置位:阻断仍在 await 中的 onMounted 续段再注册定时器(防泄漏)
scrollWrapRef.value?.removeEventListener('scroll', onCanvasScroll)
window.removeEventListener('resize', onWindowResize)
if (saveTimer) clearTimeout(saveTimer)
stopAutoSave()
})
</script>
<style scoped>
/* ── 页面布局 ── */
.map-canvas-page { padding: 16px; height: 100vh; display: flex; flex-direction: column; position: relative; overflow: hidden; }
.linking-bar {
display: flex; align-items: center; gap: 12px;
padding: 8px 16px; background: #fdf6ec; border: 1px solid #f5dab1;
border-radius: 8px; margin-bottom: 8px; font-size: 14px; flex-shrink: 0;
}
.chain-mode-bar {
display: flex; align-items: center; gap: 12px;
padding: 8px 16px; background: #fdf6ec; border: 1px solid #f5dab1;
border-radius: 8px; margin-bottom: 8px; font-size: 13px; flex-shrink: 0;
color: #b88230;
}
.chain-mode-bar span { flex: 1; }
.empty-state {
flex: 1; display: flex; align-items: center; justify-content: center;
}
/* ── 画布滚动容器 ── */
.canvas-scroll-wrap {
flex: 1; min-height: 0; overflow: auto;
position: relative; width: 100%;
}
/* transform 缩放的布局补偿层:宽度 = 容器/zoom(视觉宽度恒等于容器宽),高度由 JS 动态设置 */
.canvas-zoom-wrap {
position: relative;
min-width: 100%;
}
.canvas-body {
display: flex; flex-direction: column; gap: 0;
position: relative; isolation: isolate;
width: 100%; padding: 8px 0;
will-change: transform;
}
/* 田字格 2×2 布局:财务/客户 上排,流程/学习 下排(grid 自动流序,保留 BSC 顺序) */
.canvas-body.grid-mode {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-auto-rows: auto;
gap: 10px;
align-items: stretch;
}
/* 田字格下每层泳道等宽、行内等高 */
.canvas-body.grid-mode .layer-swimlane-wrapper {
margin-bottom: 0;
min-width: 0;
}
/* 响应式降级:视口 <800px 时强制纵向(JS gridMode 兜底) */
@media (max-width: 800px) {
.canvas-body.grid-mode {
display: flex;
flex-direction: column;
}
.canvas-body.grid-mode .layer-swimlane-wrapper {
margin-bottom: 4px;
}
}
/* ── 泳道外层包装 ── */
.layer-swimlane-wrapper {
width: 100%;
margin-bottom: 4px;
}
/* ── 跨层箭头指示器 ── */
.cross-layer-arrow {
display: flex;
justify-content: center;
align-items: center;
height: 32px;
flex-shrink: 0;
opacity: 0.5;
pointer-events: none;
}
/* ── KR 删除确认(MyDialog 内容样式,2026-09-16) ── */
.kr-del-confirm-text {
font-size: 14px;
color: #303133;
line-height: 1.6;
}
.kr-del-confirm-text strong {
color: #f56c6c;
word-break: break-all;
}
.kr-del-confirm-hint {
margin-top: 8px;
font-size: 12px;
color: #909399;
line-height: 1.5;
}
</style>