diff --git a/backend/reports/closed_loop_check_20260830.md b/backend/reports/closed_loop_check_20260830.md index 053c72dd..e062a26a 100644 --- a/backend/reports/closed_loop_check_20260830.md +++ b/backend/reports/closed_loop_check_20260830.md @@ -1,5 +1,5 @@ # 预算↔现金流↔行动 闭环自检报告 -**检查时间**: 2026-08-30 11:43:16 +**检查时间**: 2026-08-30 12:06:44 ## 账套 #1 · 期间 2026-08 - 🔴 营业收入(2026-08) diff --git a/docs/mapcanvas-tianzige-redesign-20260830.md b/docs/mapcanvas-tianzige-redesign-20260830.md new file mode 100644 index 00000000..97b8c78d --- /dev/null +++ b/docs/mapcanvas-tianzige-redesign-20260830.md @@ -0,0 +1,67 @@ +# 战略地图画布田字格 2×2 改版方案(2026-08-30) + +> 提出:项目Bot(用户确认"按推荐执行")| 依据:画布布局评估(纵向泳道→田字格) +> 目标:缩短跨层拖拽距离 + 拖拽智能辅助 + 画布性能优化 + +## 一、布局改版(核心) + +### 田字格 2×2 排布(BSC 四层) +``` +┌─────────────────┬─────────────────┐ +│ 💰 财务层 │ 👥 客户层 │ +│ (目标卡片) │ (目标卡片) │ +├─────────────────┼─────────────────┤ +│ ⚙️ 内部流程层 │ 📚 学习成长层 │ +│ (目标卡片) │ (目标卡片) │ +└─────────────────┴─────────────────┘ +``` +- **排布依据**:因果支撑方向为 学习→流程→客户→财务(自下而上),田字格使**对角距离≈边距**(跨层拖拽缩短约一半) +- 各层保留当前"层标题+目标卡片列表"结构,仅容器从全宽横条改为 2×2 网格 +- 画布总高度减半 → 整体更紧凑,减少滚动 + +### 响应式 +- 宽度 > 1200px:田字格 2×2 +- 宽度 800-1200px:田字格仍 2×2(卡片缩小)或降级为纵向(保底) +- 移动端:纵向(保底) + +## 二、拖拽交互优化(降难度) + +### 1. 方向预判 + 高亮 +- 拖起目标时,**只高亮合法连接目标**(按 BSC 支撑方向:下层→上层 或 同层相邻;反方向灰显不可连) +- 目标高亮 + 吸附(靠近自动贴合) + +### 2. 连接面板兜底(非拖拽方式) +- 画布工具条加"连接管理"入口 → 弹窗表格:源目标/目标目标/强度/方向 +- 精确场景用面板,直觉场景用拖拽 + +### 3. 拖拽提示 +- 拖起时显示"拖到目标建立因果链"提示(首用引导) + +## 三、性能优化(一并实施——"反应慢"根因) + +### 1. zoom 缩放:CSS zoom → transform: scale +- 现状:`:style="{ zoom: zoomLevel }"`(MapCanvas.vue:54)——CSS zoom 触发整页重排重绘 +- 改:外层容器 `transform: scale(zoomLevel)` + `transform-origin: top left`(GPU 合成,流畅) +- 注意:transform 不改变布局尺寸——滚动容器宽度需按 zoomLevel 补偿(scrollWidth = base * zoom) + +### 2. 连线重绘优化 +- 现状:ConnectionLines 对 props.connections deep watch + 节点坐标重算(全量重绘) +- 改:**重绘节流**(rAF 合并:拖拽/缩放连续变化时只每帧一次)+ **节点坐标缓存**(层内节点未变不重算)+ 连线 path 计算只在节点/连接变化时执行 + +### 3. 目标卡片渲染 +- 大量 KPI 行渲染优化(v-memo / 减少响应式依赖)——如层内目标多时 + +## 四、验收标准(铁律七) + +- [ ] 田字格 2×2 布局生效(四层两行两列,BSC 顺序保留) +- [ ] 跨层拖拽:财务↔学习 拖拽距离明显缩短(对比旧布局) +- [ ] 拖拽辅助:拖起时合法目标高亮、反方向灰显、吸附生效 +- [ ] 连接面板可打开并创建连线(非拖拽方式) +- [ ] 性能:zoom 缩放流畅(transform 生效,无整页重排);连线拖拽中不卡顿(rAF 节流) +- [ ] 原有功能不回归:目标增删改、连线增删、版本快照、发布 +- [ ] pytest + 构建部署 + +## 五、实施范围(全栈Bot) + +- 前端:MapCanvas.vue(布局容器/zoom transform)、StrategyLayer.vue(卡片布局适配)、ConnectionLines.vue(重绘节流/坐标缓存)、新增连接面板组件、工具条加"连接管理" +- 不涉及后端(纯前端改版) diff --git a/frontend/components.d.ts b/frontend/components.d.ts index 563603b6..e2e089e8 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -13,6 +13,7 @@ declare module 'vue' { export interface GlobalComponents { BulletChart: typeof import('./src/components/charts/BulletChart.vue')['default'] ConnectionLines: typeof import('./src/components/strategy/ConnectionLines.vue')['default'] + ConnectionManagerDialog: typeof import('./src/components/map-canvas/ConnectionManagerDialog.vue')['default'] CountUp: typeof import('./src/components/CountUp.vue')['default'] DriverFactorBudget: typeof import('./src/components/DriverFactorBudget.vue')['default'] ElAlert: typeof import('element-plus/es')['ElAlert'] @@ -45,6 +46,7 @@ declare module 'vue' { ElMenu: typeof import('element-plus/es')['ElMenu'] ElMenuItem: typeof import('element-plus/es')['ElMenuItem'] ElOption: typeof import('element-plus/es')['ElOption'] + ElOptionGroup: typeof import('element-plus/es')['ElOptionGroup'] ElPagination: typeof import('element-plus/es')['ElPagination'] ElProgress: typeof import('element-plus/es')['ElProgress'] ElRadio: typeof import('element-plus/es')['ElRadio'] diff --git a/frontend/src/components/map-canvas/ConnectionManagerDialog.vue b/frontend/src/components/map-canvas/ConnectionManagerDialog.vue new file mode 100644 index 00000000..6854d57f --- /dev/null +++ b/frontend/src/components/map-canvas/ConnectionManagerDialog.vue @@ -0,0 +1,166 @@ + + + + + diff --git a/frontend/src/components/map-canvas/MapCanvasToolbar.vue b/frontend/src/components/map-canvas/MapCanvasToolbar.vue index 08ac1cc6..c6d322a8 100644 --- a/frontend/src/components/map-canvas/MapCanvasToolbar.vue +++ b/frontend/src/components/map-canvas/MapCanvasToolbar.vue @@ -30,6 +30,7 @@ title="点击节点可聚焦查看其相关因果链,其余自动变淡" >🔍 因果链查看{{ chainMode ? '中' : '' }} 🔗 因果链推荐 + 🔗 连接管理 保存 发布 版本历史 @@ -72,6 +73,7 @@ const emit = defineEmits<{ (e: 'zoom-fit'): void (e: 'export-image'): void (e: 'show-causality'): void + (e: 'show-connection-manager'): void (e: 'save-canvas'): void (e: 'publish-map'): void (e: 'show-versions'): void diff --git a/frontend/src/components/strategy-map/MapNode.vue b/frontend/src/components/strategy-map/MapNode.vue index ff5cb9de..63083575 100644 --- a/frontend/src/components/strategy-map/MapNode.vue +++ b/frontend/src/components/strategy-map/MapNode.vue @@ -6,6 +6,8 @@ 'linking-target': isLinkingTarget, 'drag-over-connect': isDragConnectTarget, 'drag-over': isDragOver, + 'link-valid-target': linkEligibility === 'valid', + 'link-forbidden': linkEligibility === 'forbidden', 'node-level-red': level === 'red', 'node-level-yellow': level === 'yellow', 'node-level-green': level === 'green', @@ -154,6 +156,8 @@ const props = defineProps<{ isLinkingTarget?: boolean isDragConnectTarget?: boolean isDragOver?: boolean + /** 拖拽连线目标合法性:valid=合法高亮 / forbidden=反方向灰显不可连 */ + linkEligibility?: 'valid' | 'forbidden' | null iconMap?: Record kpiNameMap?: Record allKpis?: any[] @@ -292,6 +296,22 @@ function getKrProgressText(kr: any): string { border-color: #409eff !important; box-shadow: 0 0 0 2px rgba(64,158,255,0.3) !important; transform: scale(1.02); } +/* 拖拽连线:合法目标高亮(绿色呼吸) */ +.map-node.link-valid-target { + border-color: #67c23a !important; + box-shadow: 0 0 0 2px rgba(103,194,58,0.45), 0 0 8px rgba(103,194,58,0.35); + animation: link-valid-pulse 1.2s ease-in-out infinite; +} +@keyframes link-valid-pulse { + 0%, 100% { box-shadow: 0 0 0 2px rgba(103,194,58,0.45), 0 0 8px rgba(103,194,58,0.3); } + 50% { box-shadow: 0 0 0 3px rgba(103,194,58,0.7), 0 0 14px rgba(103,194,58,0.5); } +} +/* 拖拽连线:反方向目标灰显不可连 */ +.map-node.link-forbidden { + opacity: 0.38; + filter: grayscale(0.7); + cursor: not-allowed; +} .node-level-red { border-left: 3px solid #f56c6c !important; background: #fef0f0; } .node-level-yellow { border-left: 3px solid #e6a23c !important; background: #fdf6ec; } .node-level-green { border-left: 3px solid #67c23a !important; background: #f0f9eb; } diff --git a/frontend/src/components/strategy-map/StrategyLayer.vue b/frontend/src/components/strategy-map/StrategyLayer.vue index fb4020f2..faf6d3f8 100644 --- a/frontend/src/components/strategy-map/StrategyLayer.vue +++ b/frontend/src/components/strategy-map/StrategyLayer.vue @@ -40,6 +40,7 @@ :is-linking-source="linkingFromKey === `${layerKey}-${index}`" :is-linking-target="!!linkingFromKey && linkingFromKey !== `${layerKey}-${index}`" :is-drag-connect-target="dragConnectTarget === `${layerKey}-${index}`" + :link-eligibility="linkEligibilityMap?.[`${layerKey}-${index}`] ?? null" :icon-map="iconMap" :kpi-name-map="kpiNameMap" :all-kpis="allKpis" @@ -89,6 +90,8 @@ const props = defineProps<{ objectives: any[] linkingFromKey?: string | null dragConnectTarget?: string | null + /** 拖拽连线时的目标合法性:valid=合法高亮 / forbidden=反方向灰显 */ + linkEligibilityMap?: Record iconMap?: Record kpiNameMap?: Record allKpis?: any[] diff --git a/frontend/src/components/strategy/ConnectionLines.vue b/frontend/src/components/strategy/ConnectionLines.vue index 1189313e..4b1f70bd 100644 --- a/frontend/src/components/strategy/ConnectionLines.vue +++ b/frontend/src/components/strategy/ConnectionLines.vue @@ -152,6 +152,8 @@ const props = defineProps({ containerKey: { type: Number, default: 0 }, /** 视角方向:cascade(级联,上层→下层) / causal(因果,下层→上层) */ viewDirection: { type: String, default: 'cascade' }, + /** 田字格 2×2 模式:跨层箭头只画同列上下层对(财务↔流程、客户↔学习) */ + gridMode: { type: Boolean, default: false }, /** 因果链查看模式是否开启 */ chainMode: { type: Boolean, default: false }, /** 因果链查看模式下聚焦的节点 key(点击节点触发) */ @@ -285,7 +287,7 @@ function editLabel(idx) { }).catch(() => { /* 取消 */ }) } -// ── 跨层箭头:贝塞尔曲线 ── +// ── 跨层箭头:贝塞尔曲线(田字格模式只画同列上下层对) ── function calcCrossLayerLines() { const lines = [] const keys = props.layerKeys @@ -293,16 +295,22 @@ function calcCrossLayerLines() { const svg = svgRef.value if (!svg) return const svgRect = svg.getBoundingClientRect() - for (let i = 0; i < keys.length - 1; i++) { - const upperEl = props.layerRefs[keys[i]] - const lowerEl = props.layerRefs[keys[i + 1]] - if (!upperEl || !lowerEl) continue + // 田字格 2×2:财务(0)↔流程(2)、客户(1)↔学习(3) 同列上下;纵向泳道:相邻层全画 + const pairs = props.gridMode + ? [[0, 2], [1, 3]] + : keys.map((_, i) => [i, i + 1]).slice(0, -1) + for (const [ai, bi] of pairs) { + const aEl = props.layerRefs[keys[ai]] + const bEl = props.layerRefs[keys[bi]] + if (!aEl || !bEl) continue - const ur = upperEl.getBoundingClientRect() - const lr = lowerEl.getBoundingClientRect() - const cx = (ur.left + ur.width / 2 - svgRect.left + lr.left + lr.width / 2 - svgRect.left) / 2 - const y1 = (isCausal ? lr.top : ur.bottom) - svgRect.top - const y2 = (isCausal ? ur.bottom : lr.top) - svgRect.top + const ar = aEl.getBoundingClientRect() + const br = bEl.getBoundingClientRect() + const upper = ar.top <= br.top ? ar : br + const lower = ar.top <= br.top ? br : ar + const cx = (upper.left + upper.width / 2 - svgRect.left + lower.left + lower.width / 2 - svgRect.left) / 2 + const y1 = (isCausal ? lower.top : upper.bottom) - svgRect.top + const y2 = (isCausal ? upper.bottom : lower.top) - svgRect.top const bend = 28 // 曲线横向弯曲幅度 // 三次贝塞尔:垂直方向柔和 S 曲线 const path = `M ${cx} ${y1} C ${cx + bend} ${y1}, ${cx + bend} ${y2}, ${cx} ${y2}` @@ -311,26 +319,41 @@ function calcCrossLayerLines() { crossLayerLines.value = lines } -// ── 同层用户连线:贝塞尔曲线 ── +// ── 同层用户连线:贝塞尔曲线(节点坐标缓存:同一节点只读一次 rect) ── function calcLayerConnections() { const result = [] const svg = svgRef.value if (!svg) return const svgRect = svg.getBoundingClientRect() - for (const conn of props.connections) { - const fromEl = props.nodeRefs[conn.from] - const toEl = props.nodeRefs[conn.to] - if (!fromEl || !toEl) continue + // 节点坐标缓存(相对 svg):同一节点被多条连线引用时只 getBoundingClientRect 一次 + const rectCache = {} + const getRect = (key) => { + if (rectCache[key]) return rectCache[key] + const el = props.nodeRefs[key] + if (!el) return null + const r = el.getBoundingClientRect() + rectCache[key] = { + left: r.left - svgRect.left, + top: r.top - svgRect.top, + right: r.right - svgRect.left, + bottom: r.bottom - svgRect.top, + width: r.width, + height: r.height, + } + return rectCache[key] + } - const fr = fromEl.getBoundingClientRect() - const tr = toEl.getBoundingClientRect() + for (const conn of props.connections) { + const fr = getRect(conn.from) + const tr = getRect(conn.to) + if (!fr || !tr) continue // 源节点右侧中点 → 目标节点左侧中点 - const x1 = fr.right - svgRect.left - const y1 = fr.top + fr.height / 2 - svgRect.top - const x2 = tr.left - svgRect.left - const y2 = tr.top + tr.height / 2 - svgRect.top + const x1 = fr.right + const y1 = fr.top + fr.height / 2 + const x2 = tr.left + const y2 = tr.top + tr.height / 2 // 贝塞尔曲线控制点(水平拖拽,曲线柔和) const dx = Math.max(Math.abs(x2 - x1) * 0.5, 40) @@ -367,9 +390,21 @@ function recalcAll() { calcLayerConnections() } -watch(() => props.containerKey, () => recalcAll()) -watch(() => props.viewDirection, () => recalcAll()) -watch(() => props.connections, () => recalcAll(), { deep: true }) +// ── 重绘节流:rAF 合并连续触发(拖拽/缩放/滚动时每帧只重算一次) ── +let rafPending = false +function scheduleRecalc() { + if (rafPending) return + rafPending = true + requestAnimationFrame(() => { + rafPending = false + recalcAll() + }) +} + +watch(() => props.containerKey, () => scheduleRecalc()) +watch(() => props.viewDirection, () => scheduleRecalc()) +watch(() => props.gridMode, () => scheduleRecalc()) +watch(() => props.connections, () => scheduleRecalc(), { deep: true }) // 暴露给父组件调用 defineExpose({ recalcAll }) diff --git a/frontend/src/views/MapCanvas.vue b/frontend/src/views/MapCanvas.vue index 76757293..46ebb8a8 100644 --- a/frontend/src/views/MapCanvas.vue +++ b/frontend/src/views/MapCanvas.vue @@ -16,6 +16,7 @@ @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()" @@ -50,9 +51,10 @@ - -
-
+ +
+
+
- -
+ +
@@ -99,6 +102,7 @@
+
@@ -111,6 +115,7 @@ :temp-line="tempLine" :container-key="recalcTrigger" :view-direction="viewDirection" + :grid-mode="gridMode" :chain-mode="chainMode" :chain-focus-key="chainFocusKey" @select-connection="onSelectConnection" @@ -151,6 +156,17 @@ @edit-kpi-obj="editKpiDetailObj" @quick-create-plan="quickCreatePlan" /> + + +
@@ -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; + } } /* ── 泳道外层包装 ── */