feat(mapcanvas): 田字格2x2布局+拖拽辅助(合法高亮/灰显/吸附)+连接管理面板+zoom transform缩放+连线rAF节流

This commit is contained in:
Hermes CI Fix
2026-08-30 17:41:07 +08:00
parent ad68471b29
commit 2da4cfc42d
9 changed files with 595 additions and 51 deletions
@@ -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 })