<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)"
            @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"
    />
  </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, 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 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"

// ── 四层泳道配置 ──
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()
}

// 缩放（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
    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()
    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'
}

// ── 拖拽连线的鼠标跟踪（增强：实时高亮 + 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 {
    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) {
  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 || []
      }
      // 保留后端回写的 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) => ({
        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: currentMap.value?.version,
    })
    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图片（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 || []
  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.value?.status === 'draft') saveCanvas(true)
  }, 30000)
  nextTick(() => {
    updateGridMode()
    updateZoomWrapHeight()
  })
  scrollWrapRef.value?.addEventListener('scroll', onCanvasScroll, { passive: true })
  window.addEventListener('resize', onWindowResize)
})

onUnmounted(() => {
  scrollWrapRef.value?.removeEventListener('scroll', onCanvasScroll)
  window.removeEventListener('resize', onWindowResize)
  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%;
}

/* 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;
}
</style>
