Files
cma-management/frontend/src/views/MapCanvas.vue
T

1083 lines
36 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"
@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>
<!-- 画布主体(纵向泳道布局,CSS zoom缩放) -->
<div class="canvas-scroll-wrap" ref="scrollWrapRef" :style="{ zoom: zoomLevel }">
<div class="canvas-body" ref="bodyRef">
<!-- 四层泳道(纵向排列,固定顺序:财务→客户→流程→学习) -->
<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"
: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)"
@add-kr="(p: any) => onAddKr(p.dimKey, p.idx, p.obj)"
/>
<!-- 跨层箭头指示器(根据视角切换方向) -->
<div v-if="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-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"
: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"
/>
</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 { ElMessage, ElMessageBox } from "element-plus"
import { mapApi, kpiApi, okrTemplateApi } 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 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 html2canvas from "html2canvas"
// ── 四层泳道配置 ──
const layerKeysOrdered = [...LAYER_KEYS]
const orderedLayerConfigs = getLayerList()
// ── 状态 ──
const maps = ref<any[]>([])
const selectedMap = ref<number | null>(null)
const currentMap = ref<any>(null)
const isDirty = ref(false)
const { markDirty, markClean } = useFormGuard(isDirty)
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()
}
// 缩放
const zoomLevel = ref(1)
const scrollWrapRef = ref<HTMLElement | null>(null)
// 节点状态(红黄绿灯)
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) }
function zoomOut() { zoomLevel.value = Math.max(0.5, Math.round((zoomLevel.value - 0.1) * 10) / 10) }
function zoomReset() { zoomLevel.value = 1 }
function zoomFit() {
// 自适应缩放:大致按视口高度计算
if (!scrollWrapRef.value) return
const wrapH = scrollWrapRef.value.clientHeight
const contentH = scrollWrapRef.value.scrollHeight
if (contentH > 0 && wrapH > 0) {
zoomLevel.value = Math.max(0.5, Math.min(1.5, Math.round((wrapH / contentH) * 10) / 10))
}
}
// ── 画布加载 ──
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
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()
loadObjectiveLevels()
})
}).catch(() => {
ensureFourLayers()
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(() => {
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'
}
// ── 拖拽连线的鼠标跟踪 ──
function onLinkDragStart(e: MouseEvent, dimKey: string, oi: number, obj: any) {
const startX = e.clientX
const startY = e.clientY
startLink(dimKey, oi, obj)
const onUp = (up: MouseEvent) => {
document.removeEventListener('mouseup', onUp)
const dist = Math.hypot(up.clientX - startX, up.clientY - startY)
if (dist >= 5 && linkingFrom.value) {
let targetKey: 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 (up.clientX >= rect.left && up.clientX <= rect.right &&
up.clientY >= rect.top && up.clientY <= rect.bottom) {
targetKey = key; break
}
}
if (targetKey) {
const parts = targetKey.split('-')
const tDim = parts[0]
const tIdx = parseInt(parts[1])
const dim = dimensions.find((d: any) => d.key === tDim)
if (dim && dim.objectives[tIdx]) {
completeConnection(linkingFrom.value.key, targetKey)
}
}
cancelLink()
}
dragConnectTarget.value = null
}
document.addEventListener('mouseup', onUp)
}
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(() => {
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 || []
}
dim.objectives[idx] = { ...node, ...extra }
ElMessage.success("节点已更新")
}
} else {
// 新增节点
dim.objectives.push({
...node,
kpis: [],
icon: 'target',
})
ElMessage.success("节点已添加")
}
editingNodeData.value = null
showNodeDialog.value = false
debouncedSaveCanvas()
nextTick(triggerRecalc)
}
// ── 用户自定义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) => ({
name: kr.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()
}
// ── 数据迁移:旧格式 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
function debouncedSaveCanvas() {
if (isSaving) return
if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(() => { saveCanvas() }, 500)
}
async function saveCanvas(silent?: boolean) {
if (!selectedMap.value) 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 {
await mapApi.update(selectedMap.value, {
dimensions: mapData,
canvas_data: { connections: connections.value },
version_num: currentMap.value?.version_num,
})
markClean()
if (!silent) ElMessage.success("已保存")
} catch (e: any) {
if (e?.response?.status === 409) {
ElMessage.warning("地图已被修改刷新后重试")
} else {
console.error("saveCanvas error:", e)
}
} finally {
isSaving = false
}
}
async function saveConnections() {
if (!selectedMap.value) return
try {
await mapApi.update(selectedMap.value, {
canvas_data: { connections: connections.value },
})
} catch { /* silent */ }
}
// ── 发布/版本 ──
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图片 */
async function exportImage() {
const el = document.querySelector('.canvas-scroll-wrap') as HTMLElement
if (!el) { ElMessage.warning('画布尚未加载'); return }
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('导出失败')
}
}
// ── 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()
}
onMounted(async () => {
const r: any = await mapApi.list()
maps.value = r.data || []
if (maps.value.length > 0) {
selectedMap.value = maps.value[0].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
}
// 自动保存:每30秒自动保存草稿
autoSaveTimer = setInterval(() => {
if (currentMap?.status === 'draft') saveCanvas(true)
}, 30000)
window.addEventListener('resize', triggerRecalc)
})
onUnmounted(() => {
window.removeEventListener('resize', triggerRecalc)
if (saveTimer) clearTimeout(saveTimer)
if (autoSaveTimer) clearInterval(autoSaveTimer)
})
</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%;
}
.canvas-body {
display: flex; flex-direction: column; gap: 0;
position: relative; isolation: isolate;
width: 100%; padding: 8px 0;
}
/* ── 泳道外层包装 ── */
.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;
}
</style>