生产缺陷 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
1518 lines
54 KiB
Vue
1518 lines
54 KiB
Vue
<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>
|