feat(mapcanvas): 田字格2x2布局+拖拽辅助(合法高亮/灰显/吸附)+连接管理面板+zoom transform缩放+连线rAF节流
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
<template>
|
||||
<MyDialog :model-value="modelValue" @update:model-value="$emit('update:modelValue', $event)" title="连接管理" :width="780">
|
||||
<!-- 新增连线(非拖拽方式) -->
|
||||
<div class="cm-add-row">
|
||||
<el-select v-model="form.from" placeholder="源目标" filterable style="width:230px;" @change="form.to = ''">
|
||||
<el-option-group v-for="g in groupedOptions" :key="g.label" :label="g.label">
|
||||
<el-option v-for="o in g.items" :key="o.key" :label="o.name" :value="o.key" />
|
||||
</el-option-group>
|
||||
</el-select>
|
||||
<span class="cm-arrow">→</span>
|
||||
<el-select v-model="form.to" placeholder="目标目标" filterable style="width:230px;">
|
||||
<el-option-group v-for="g in groupedOptions" :key="g.label" :label="g.label">
|
||||
<el-option v-for="o in g.items" :key="o.key" :label="o.name" :value="o.key" />
|
||||
</el-option-group>
|
||||
</el-select>
|
||||
<el-select v-model="form.effect" style="width:96px;">
|
||||
<el-option label="正向" value="positive" />
|
||||
<el-option label="负向" value="negative" />
|
||||
</el-select>
|
||||
<el-button type="primary" :disabled="!form.from || !form.to" @click="onAdd">添加连线</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="connections.length === 0" class="cm-empty">
|
||||
暂无连线,请通过上方表单或画布拖拽(下层→上层)创建
|
||||
</div>
|
||||
<table v-else class="cm-table">
|
||||
<thead>
|
||||
<tr><th>#</th><th>源目标</th><th>方向</th><th>目标目标</th><th>强度</th><th>操作</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(conn, idx) in connections" :key="idx">
|
||||
<td class="cm-idx">{{ idx + 1 }}</td>
|
||||
<td :title="conn.from">{{ nodeLabel(conn.from) }}</td>
|
||||
<td class="cm-dir">→</td>
|
||||
<td :title="conn.to">{{ nodeLabel(conn.to) }}</td>
|
||||
<td>
|
||||
<el-tag
|
||||
:type="conn.effect === 'negative' ? 'danger' : 'success'"
|
||||
size="small"
|
||||
style="cursor:pointer;"
|
||||
@click="toggleEffect(idx)"
|
||||
title="点击切换正/负向"
|
||||
>{{ conn.effect === 'negative' ? '负向' : '正向' }} ↻</el-tag>
|
||||
</td>
|
||||
<td>
|
||||
<el-button size="small" type="danger" text @click="$emit('delete-connection', idx)">删除</el-button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="cm-hint">
|
||||
提示:拖拽连线按 BSC 支撑方向(下层→上层 或 同层)自动校验;本面板可维护任意方向连线。
|
||||
</div>
|
||||
</MyDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import MyDialog from '../MyDialog.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
connections: any[]
|
||||
nodeOptions: { key: string; name: string; layerName: string }[]
|
||||
layerLabels?: Record<string, string>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: boolean): void
|
||||
(e: 'add-connection', payload: { from: string; to: string; effect?: string }): void
|
||||
(e: 'delete-connection', idx: number): void
|
||||
(e: 'update-connection', idx: number, patch: any): void
|
||||
}>()
|
||||
|
||||
const form = ref<{ from: string; to: string; effect: string }>({ from: '', to: '', effect: 'positive' })
|
||||
|
||||
/** 按层分组的节点选项 */
|
||||
const groupedOptions = computed(() => {
|
||||
const groups: { label: string; items: { key: string; name: string }[] }[] = []
|
||||
const byLayer: Record<string, { key: string; name: string }[]> = {}
|
||||
for (const o of props.nodeOptions) {
|
||||
const ln = o.layerName || '其他'
|
||||
if (!byLayer[ln]) byLayer[ln] = []
|
||||
byLayer[ln].push({ key: o.key, name: o.name })
|
||||
}
|
||||
for (const label of Object.keys(byLayer)) {
|
||||
groups.push({ label, items: byLayer[label] })
|
||||
}
|
||||
return groups
|
||||
})
|
||||
|
||||
function nodeLabel(key: string): string {
|
||||
const o = props.nodeOptions.find(n => n.key === key)
|
||||
return o ? `${o.layerName} · ${o.name}` : key
|
||||
}
|
||||
|
||||
function toggleEffect(idx: number) {
|
||||
const conn = props.connections[idx]
|
||||
if (!conn) return
|
||||
emit('update-connection', idx, { effect: conn.effect === 'negative' ? 'positive' : 'negative' })
|
||||
}
|
||||
|
||||
function onAdd() {
|
||||
if (!form.value.from || !form.value.to) return
|
||||
emit('add-connection', { from: form.value.from, to: form.value.to, effect: form.value.effect })
|
||||
form.value = { from: '', to: '', effect: 'positive' }
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cm-add-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 0 14px;
|
||||
border-bottom: 1px dashed #e4e7ed;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.cm-arrow {
|
||||
color: #909399;
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cm-empty {
|
||||
text-align: center;
|
||||
color: #909399;
|
||||
padding: 32px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.cm-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
.cm-table th {
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
background: #f5f7fa;
|
||||
color: #606266;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
}
|
||||
.cm-table td {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
color: #303133;
|
||||
max-width: 240px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cm-table tr:hover td {
|
||||
background: #f8faff;
|
||||
}
|
||||
.cm-idx { color: #909399; width: 32px; }
|
||||
.cm-dir { color: #409eff; text-align: center; width: 36px; }
|
||||
.cm-hint {
|
||||
margin-top: 12px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
background: #f8f9fb;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
</style>
|
||||
@@ -30,6 +30,7 @@
|
||||
title="点击节点可聚焦查看其相关因果链,其余自动变淡"
|
||||
>🔍 因果链查看{{ chainMode ? '中' : '' }}</el-button>
|
||||
<el-button @click="$emit('show-causality')">🔗 因果链推荐</el-button>
|
||||
<el-button @click="$emit('show-connection-manager')" title="以表格方式查看/创建/维护连线">🔗 连接管理</el-button>
|
||||
<el-button type="primary" @click="$emit('save-canvas')">保存</el-button>
|
||||
<el-button type="success" @click="$emit('publish-map')" v-if="currentMap?.status === 'draft'">发布</el-button>
|
||||
<el-button @click="$emit('show-versions')">版本历史</el-button>
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, string>
|
||||
kpiNameMap?: Record<string, string>
|
||||
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; }
|
||||
|
||||
@@ -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<string, 'valid' | 'forbidden'>
|
||||
iconMap?: Record<string, string>
|
||||
kpiNameMap?: Record<string, string>
|
||||
allKpis?: any[]
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 @@
|
||||
</el-empty>
|
||||
</div>
|
||||
|
||||
<!-- 画布主体(纵向泳道布局,CSS zoom缩放) -->
|
||||
<div class="canvas-scroll-wrap" ref="scrollWrapRef" :style="{ zoom: zoomLevel }">
|
||||
<div class="canvas-body" ref="bodyRef">
|
||||
<!-- 画布主体(田字格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"
|
||||
@@ -70,6 +72,7 @@
|
||||
: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"
|
||||
@@ -89,8 +92,8 @@
|
||||
@add-kr="(p: any) => onAddKr(p.dimKey, p.idx, p.obj)"
|
||||
/>
|
||||
|
||||
<!-- 跨层箭头指示器(根据视角切换方向) -->
|
||||
<div v-if="idx < orderedLayerConfigs.length - 1" class="cross-layer-arrow">
|
||||
<!-- 跨层箭头指示器(田字格模式下由连线层绘制同列箭头,此处隐藏) -->
|
||||
<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" />
|
||||
@@ -99,6 +102,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- end canvas-body -->
|
||||
</div><!-- end canvas-zoom-wrap -->
|
||||
</div><!-- end canvas-scroll-wrap -->
|
||||
|
||||
<!-- SVG 连线层(覆盖整个画布,绘制跨层+同层箭头) -->
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
<!-- 连接管理面板(非拖拽方式创建/维护连线) -->
|
||||
<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;">
|
||||
@@ -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<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>>({})
|
||||
|
||||
@@ -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<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
|
||||
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<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) {
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 泳道外层包装 ── */
|
||||
|
||||
Reference in New Issue
Block a user