+
+
@@ -170,6 +186,7 @@ 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 html2canvas from "html2canvas"
// ── 四层泳道配置 ──
@@ -229,10 +246,46 @@ function onUpdateConnection(idx: number, patch: any) {
saveConnections()
}
-// 缩放
+// 缩放(transform: scale + 滚动容器宽度补偿)
const zoomLevel = ref(1)
const scrollWrapRef = ref(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>({})
@@ -373,18 +426,32 @@ function onSelectMap(id: number) {
}
// ── 缩放 ──
-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 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) return
+ // 自适应缩放:按画布视觉高度与视口高度比计算
+ if (!scrollWrapRef.value || !bodyRef.value) return
const wrapH = scrollWrapRef.value.clientHeight
- const contentH = scrollWrapRef.value.scrollHeight
+ 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() {
@@ -422,6 +489,8 @@ function loadCanvas() {
loading.value = false
nextTick(() => {
triggerRecalc()
+ updateGridMode()
+ updateZoomWrapHeight()
loadObjectiveLevels()
})
}).catch(() => {
@@ -516,32 +585,87 @@ function goKPI(kpiCode: string) {
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>(() => {
+ const map: Record = {}
+ 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
- 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
+ // 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 tIdx = parseInt(parts[1])
+ const tIdxNum = parseInt(parts[1])
const dim = dimensions.find((d: any) => d.key === tDim)
- if (dim && dim.objectives[tIdx]) {
+ if (dim && dim.objectives[tIdxNum]) {
completeConnection(linkingFrom.value.key, targetKey)
}
}
@@ -550,6 +674,65 @@ function onLinkDragStart(e: MouseEvent, dimKey: string, oi: number, obj: any) {
dragConnectTarget.value = null
}
document.addEventListener('mouseup', onUp)
+ document.addEventListener('mousemove', onMove)
+}
+
+// ── 连接管理面板(非拖拽方式维护连线) ──
+const showConnManager = ref(false)
+const layerLabelMap = computed>(() => {
+ const m: Record = {}
+ 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 {
+ await api.post(`/maps/${selectedMap.value}/connections`, { from: payload.from, to: payload.to })
+ 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(() => { 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) {
@@ -977,10 +1160,17 @@ function goAlignment() {
window.location.href = url
}
-/** 导出画布为PNG图片 */
+/** 导出画布为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,
@@ -995,6 +1185,11 @@ async function exportImage() {
} catch (e) {
console.error('exportImage error:', e)
ElMessage.error('导出失败')
+ } finally {
+ if (prevZoom !== 1) {
+ zoomLevel.value = prevZoom
+ updateZoomWrapHeight()
+ }
}
}
@@ -1036,6 +1231,21 @@ async function applyAllCausality() {
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 || []
@@ -1055,11 +1265,17 @@ onMounted(async () => {
autoSaveTimer = setInterval(() => {
if (currentMap.value?.status === 'draft') saveCanvas(true)
}, 30000)
- window.addEventListener('resize', triggerRecalc)
+ nextTick(() => {
+ updateGridMode()
+ updateZoomWrapHeight()
+ })
+ scrollWrapRef.value?.addEventListener('scroll', onCanvasScroll, { passive: true })
+ window.addEventListener('resize', onWindowResize)
})
onUnmounted(() => {
- window.removeEventListener('resize', triggerRecalc)
+ scrollWrapRef.value?.removeEventListener('scroll', onCanvasScroll)
+ window.removeEventListener('resize', onWindowResize)
if (saveTimer) clearTimeout(saveTimer)
if (autoSaveTimer) clearInterval(autoSaveTimer)
})
@@ -1093,10 +1309,43 @@ onUnmounted(() => {
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;
+ }
}
/* ── 泳道外层包装 ── */