feat(maps): 画布聚焦减负(聚焦层/详情浮层/汇总条/目标树收起/跨层摘要)— C 方案
ci/woodpecker/push/main Pipeline was successful

- MapCanvas: 层聚焦与回全景联动;右栏停用改自绘可拖详情浮层;目标树默认收起+localStorage 持久化;底部跨层关系摘要条;聚焦态只绘聚焦层同层连线
- StrategyLayer: 层标题可点击聚焦并标「聚焦中」;非聚焦层压缩为 ≤48px 单行摘要(层名—达成率/目标数/mini 色条)
- MapSummaryBar: 汇总条压缩 ≤44px、5 项同排;窄屏无横向溢出
- MapEditBar: 新增「全景 / L1–L4」聚焦切换 chip(内联 SVG,无 emoji)
- 只读渲染层改动:不写 canvas_data / map_connections,无新增写链路调用
This commit is contained in:
wecom-fullstack
2026-10-24 20:03:32 +08:00
parent b3f31f9ac9
commit 63a2bac424
4 changed files with 682 additions and 152 deletions
+333 -13
View File
@@ -34,6 +34,8 @@
:achievement-count="achievementCount"
:summary="reviewSummary"
:loading="loading"
:objectives-total="totalObjectiveCount"
:connections-total="connections.length"
/>
<MapEditBar
@@ -43,6 +45,7 @@
:layer-configs="layerUiConfigs"
:can-undo="undoStack.length > 0"
:tree-open="treeOpen"
:focus-layer="focusLayer"
@update:search="v => searchText = v"
@update:filter="v => filterMode = v"
@toggle-layer="toggleLayer"
@@ -52,6 +55,7 @@
@fit="zoomFit"
@toggle-tree="treeOpen = !treeOpen"
@completeness="showCompleteness = true"
@focus-layer="toggleFocusLayer"
/>
<!-- 口径标识(常驻,非 hover 临时提示):A 口径 v1.1 来自会计科目、非发票同票列明 -->
@@ -80,7 +84,7 @@
<el-button size="small" @click="cancelLink">取消</el-button>
</div>
<!-- 仪表盘主视图:左目标树 / 中画布 / 右详情 -->
<!-- 仪表盘主视图:左目标树(默认收起,留 46px 图标条)/ 中画布 / 详情浮层 -->
<div class="map-body-row">
<MapTargetTree
v-show="treeOpen"
@@ -98,6 +102,22 @@
@drag-end="onTreeDragEnd"
/>
<!-- 目标树收起后的 46px 图标条(C 方案:默认收起,一键展开) -->
<div v-show="!treeOpen" class="tree-rail" aria-label="目标树已收起">
<button
type="button"
class="rail-btn"
title="展开目标树"
aria-label="展开目标树"
@click="treeOpen = true"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<path d="M4 6h6M4 12h6M4 18h6" /><path d="M14 6h6M14 12h6M14 18h6" opacity=".45" />
</svg>
</button>
<span class="rail-count mono" :title="'当前可见目标 / 总目标'">{{ visibleObjectiveCount }}</span>
</div>
<div class="map-canvas-col">
<!-- 画布主体(田字格2×2布局,transform: scale 缩放) -->
<div
@@ -108,7 +128,7 @@
@drop.prevent="onTreeDrop"
>
<div class="canvas-zoom-wrap" :style="{ width: zoomWrapWidth, height: zoomWrapHeight }">
<div class="canvas-body" :class="{ 'grid-mode': gridMode }" ref="bodyRef" :style="bodyZoomStyle">
<div class="canvas-body" :class="{ 'grid-mode': gridMode, 'focus-active': !!focusLayer }" ref="bodyRef" :style="bodyZoomStyle">
<!-- 四层泳道(纵向排列,固定顺序:财务→客户→流程→学习) -->
<div
v-for="(cfg, idx) in orderedLayerConfigs"
@@ -116,6 +136,10 @@
v-show="visibleLayers[cfg.key]"
:ref="el => setLayerRef(cfg.key, el)"
class="layer-swimlane-wrapper"
:class="{
'is-focused': focusLayer === cfg.key,
'is-compact': !!focusLayer && focusLayer !== cfg.key,
}"
:style="{ borderLeft: '4px solid ' + layerColor(cfg.key) }"
>
<StrategyLayer
@@ -126,6 +150,10 @@
:color="layerColor(cfg.key)"
:objectives="getLayerNodes(cfg.key)"
:collapsed="!!collapsedLayers[cfg.key]"
:compact="!!focusLayer && focusLayer !== cfg.key"
:focused="focusLayer === cfg.key"
:layer-ratio="layerStats[cfg.key] ? layerStats[cfg.key].ratio : null"
@focus="toggleFocusLayer(cfg.key)"
:dim-active="filterActive"
:dimmed-keys="matchedKeyList"
@toggle-collapse="toggleLayerCollapse(cfg.key)"
@@ -174,10 +202,40 @@
<span class="lg-item"><i class="lg-dot lv-gray"></i>无数据</span>
<span class="lg-note">达成率按指标方向归一,取目标关联 KPI 均值</span>
</div>
</div><!-- end map-canvas-col -->
<!-- 跨层关系摘要条(C 方案):数字由 connections 的 from/to 现算,不写死 -->
<div class="cross-summary" aria-label="跨层关系摘要">
<span class="cs-title">跨层关系摘要</span>
<span v-for="item in crossSummary" :key="item.key" class="cs-item">
<i class="cs-dot" :style="{ background: item.color }"></i>
<span class="cs-label">{{ item.label }}</span>
<b class="cs-num mono">{{ item.count }}</b>
</span>
<span class="cs-total">
合计 <b class="mono">{{ connections.length }}</b> 条 · 跨层 <b class="mono">{{ crossLayerCount }}</b> 条 · 同层 <b class="mono">{{ sameLayerCount }}</b> 条
</span>
</div>
</div><!-- end map-canvas-col -->
</div><!-- end map-body-row -->
<!-- 详情浮层(C 方案):右栏不再常驻占位,画布按视口 100% 宽;浮层可拖动、可关闭 -->
<div
v-if="selectedNode"
class="detail-float"
:style="{ left: floatPos.x + 'px', top: floatPos.y + 'px' }"
role="dialog"
aria-label="目标详情浮层"
>
<div class="float-bar" @mousedown="startFloatDrag">
<span class="float-bar-grip" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 8h.01M8 12h.01M8 16h.01M16 8h.01M16 12h.01M16 16h.01" /></svg>
</span>
<span class="float-bar-title">拖动可移动</span>
<button type="button" class="float-bar-close" aria-label="关闭详情浮层" @click="closeDetail">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18" /></svg>
</button>
</div>
<MapNodeDetail
v-if="selectedNode"
:obj="selectedNode.obj"
:dim-key="selectedNode.dimKey"
:index="selectedNode.index"
@@ -196,13 +254,7 @@
@select-key="selectByKey"
@go-kpi-library="goKpiLibrary"
/>
<aside v-else class="map-detail-empty" aria-label="目标详情占位">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
<rect x="3" y="4" width="18" height="16" rx="2" /><path d="M7 9h10M7 13h7M7 17h4" />
</svg>
<p>选中左侧目标或画布节点<br />查看达成率、KPI 与因果上下游</p>
</aside>
</div><!-- end map-body-row -->
</div>
<!-- SVG 连线层(覆盖整个画布,绘制跨层+同层箭头) -->
<ConnectionLines
@@ -211,7 +263,7 @@
:node-refs="compiledNodeRefs"
:layer-keys="layerKeysOrdered"
:conn-keys="connKeys"
:connections="connections"
:connections="renderedConnections"
:temp-line="tempLine"
:container-key="recalcTrigger"
:view-direction="viewDirection"
@@ -390,7 +442,35 @@ const filterMode = ref<'all' | 'risk' | 'nodata'>('all')
const visibleLayers = reactive<Record<string, boolean>>({ finance: true, customer: true, process: true, learning: true })
const collapsedLayers = reactive<Record<string, boolean>>({})
const collapsedGroups = reactive<Record<string, boolean>>({})
const treeOpen = ref(true)
/* ── C 方案:目标树默认收起 + 记住上次选择(localStorage 键名 cma.map.canvas.treeOpen)── */
const TREE_OPEN_STORAGE_KEY = 'cma.map.canvas.treeOpen'
function readTreeOpenPref(): boolean {
try {
return localStorage.getItem(TREE_OPEN_STORAGE_KEY) === '1'
} catch {
return false
}
}
const treeOpen = ref<boolean>(readTreeOpenPref())
watch(treeOpen, (v) => {
try {
localStorage.setItem(TREE_OPEN_STORAGE_KEY, v ? '1' : '0')
} catch {
/* 隐私模式/配额满:静默降级为不记忆,不影响画布功能 */
}
})
/* ── C 方案:聚焦模式(null = 四层全景;非 null = 该层展开、其余三层压成单行)── */
const focusLayer = ref<string | null>(null)
function toggleFocusLayer(key: string | null) {
focusLayer.value = !key || focusLayer.value === key ? null : key
nextTick(() => {
updateGridMode()
triggerRecalc()
updateZoomWrapHeight()
})
}
const selectedNode = ref<{ dimKey: string; index: number; obj: any } | null>(null)
const selectedKey = computed(() => (selectedNode.value ? `${selectedNode.value.dimKey}-${selectedNode.value.index}` : null))
@@ -525,6 +605,112 @@ const achievementCount = computed(() =>
Object.values(kpiProgressMap).filter((p: any) => p.hasData && p.ratio != null).length
)
/** 各层达成率均值(聚焦压缩态色条用):仅统计有实测值的目标,无数据 ⇒ null(界面显式「—」) */
const layerStats = computed<Record<string, { ratio: number | null; count: number }>>(() => {
const out: Record<string, { ratio: number | null; count: number }> = {}
for (const cfg of orderedLayerConfigs) {
const objs = getLayerNodes(cfg.key)
const vals: number[] = []
objs.forEach((_: any, i: number) => {
const p: any = kpiProgressMap[`${cfg.key}-${i}`]
if (p && p.hasData && p.ratio != null && !Number.isNaN(Number(p.ratio))) vals.push(Number(p.ratio))
})
out[cfg.key] = { ratio: vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : null, count: objs.length }
}
return out
})
/* ── C 方案:跨层关系摘要(全部由 connections 的 from/to 现算,禁写死)──
* 连线端点兼容两种键:渲染键 `finance-0` 与数据键 `learning:目标名`(StrategyLayer 双键注册)。
*/
function layerOfNodeKey(key: string | null | undefined): string | null {
if (!key || typeof key !== 'string') return null
const sep = key.includes(':') ? ':' : '-'
const layer = key.split(sep)[0]
return LAYER_KEYS.includes(layer as any) ? layer : null
}
const layerHue = (key: string) => LAYER_VISUAL[key]?.color || LAYER_CONFIG[key]?.color || '#635BFF'
/** 聚焦态只绘制聚焦层内部的同层连线(跨层线跨向被压缩层时无锚点);全景态全量绘制 */
const renderedConnections = computed<any[]>(() => {
const f = focusLayer.value
if (!f) return connections.value
return connections.value.filter(
(c: any) => layerOfNodeKey(c?.from) === f && layerOfNodeKey(c?.to) === f
)
})
const crossLayerCount = computed(() =>
connections.value.filter((c: any) => {
const f = layerOfNodeKey(c?.from)
const t = layerOfNodeKey(c?.to)
return !!f && !!t && f !== t
}).length
)
const sameLayerCount = computed(() => connections.value.length - crossLayerCount.value)
/** BSC 三段因果 + 「其他跨层」兜底桶 + 同层;合计必须等于 connections.length */
const crossSummary = computed(() => {
const dirs: { key: string; label: string; from: string; to: string }[] = [
{ key: 'learn2proc', label: '学习→流程', from: 'learning', to: 'process' },
{ key: 'proc2cust', label: '流程→客户', from: 'process', to: 'customer' },
{ key: 'cust2fin', label: '客户→财务', from: 'customer', to: 'finance' },
]
const counts: Record<string, number> = {}
let other = 0
for (const c of connections.value as any[]) {
const f = layerOfNodeKey(c?.from)
const t = layerOfNodeKey(c?.to)
if (!f || !t || f === t) continue
const hit = dirs.find(d => d.from === f && d.to === t)
if (hit) {
counts[hit.key] = (counts[hit.key] || 0) + 1
} else {
other++
}
}
const items = dirs.map(d => ({
key: d.key,
label: d.label,
count: counts[d.key] || 0,
color: layerHue(d.to),
}))
if (other > 0) items.push({ key: 'other', label: '其他跨层', count: other, color: '#94A3B8' })
return items
})
/* ── C 方案:详情浮层位置与拖动(自绘 div + transform,非 el-dialog)── */
const floatPos = ref<{ x: number; y: number }>({ x: 0, y: 0 })
const floatDragging = ref(false)
let floatDragOffset = { x: 0, y: 0 }
const FLOAT_W = 360
function clampFloat(x: number, y: number) {
const maxX = Math.max(8, window.innerWidth - FLOAT_W - 12)
const maxY = Math.max(8, window.innerHeight - 120)
return { x: Math.min(Math.max(8, x), maxX), y: Math.min(Math.max(8, y), maxY) }
}
/** 默认落位:右侧靠上(不遮挡左侧层名与中部卡片) */
function placeFloatDefault() {
floatPos.value = clampFloat(window.innerWidth - FLOAT_W - 24, 132)
}
function startFloatDrag(e: MouseEvent) {
floatDragging.value = true
floatDragOffset = { x: e.clientX - floatPos.value.x, y: e.clientY - floatPos.value.y }
window.addEventListener('mousemove', onFloatDragMove)
window.addEventListener('mouseup', stopFloatDrag)
e.preventDefault()
}
function onFloatDragMove(e: MouseEvent) {
if (!floatDragging.value) return
floatPos.value = clampFloat(e.clientX - floatDragOffset.x, e.clientY - floatDragOffset.y)
}
function stopFloatDrag() {
floatDragging.value = false
window.removeEventListener('mousemove', onFloatDragMove)
window.removeEventListener('mouseup', stopFloatDrag)
}
watch(selectedNode, (v) => {
if (v && !floatPos.value.x && !floatPos.value.y) placeFloatDefault()
})
onUnmounted(() => stopFloatDrag())
/** 搜索/筛选是否激活(激活时画布非匹配节点变淡) */
const filterActive = computed(() => searchText.value.trim() !== '' || filterMode.value !== 'all')
@@ -2176,6 +2362,140 @@ onUnmounted(() => {
margin-bottom: 4px;
}
/* ── C 方案:聚焦模式 ── */
/* 聚焦层:田字格下占满整行,卡片网格展开 */
.canvas-body.grid-mode .layer-swimlane-wrapper.is-focused {
grid-column: 1 / -1;
}
/* 其余三层:压成单行(≤48px,仅层头 + 目标数 + 达成率色条) */
.canvas-body.focus-active .layer-swimlane-wrapper.is-compact {
height: 48px;
min-height: 48px;
max-height: 48px;
overflow: hidden;
align-self: start;
}
.canvas-body.focus-active .layer-swimlane-wrapper.is-compact :deep(.strategy-layer) {
min-height: 0;
height: 48px;
}
.canvas-body.focus-active .layer-swimlane-wrapper.is-compact :deep(.layer-body) {
display: none;
}
.canvas-body.focus-active .layer-swimlane-wrapper.is-compact :deep(.layer-header) {
padding: 6px 14px;
gap: 8px;
}
.canvas-body.focus-active .layer-swimlane-wrapper.is-focused :deep(.strategy-layer) {
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.10);
}
/* ── 目标树收起后的 46px 图标条 ── */
.tree-rail {
width: 46px;
flex: 0 0 46px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 8px 0;
border: 1px solid var(--bh-border, #E5E7EB);
border-radius: 8px;
background: var(--bh-bg-card, #fff);
}
.rail-btn {
width: 30px;
height: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--bh-border, #E5E7EB);
border-radius: 6px;
background: #F8FAFC;
color: #475569;
cursor: pointer;
}
.rail-btn:hover { background: #EEF2F7; color: #0F172A; }
.rail-btn svg { width: 16px; height: 16px; }
.rail-count {
font-size: 11px;
color: var(--bh-text-secondary, #64748B);
font-variant-numeric: tabular-nums;
}
/* ── 详情浮层(自绘 div,可拖动,非 el-dialog) ── */
.detail-float {
position: absolute;
z-index: 60;
width: 360px;
max-height: calc(100vh - 180px);
display: flex;
flex-direction: column;
background: var(--bh-bg-card, #fff);
border: 1px solid #E5E7EB;
border-radius: 10px;
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.16), 0 2px 6px rgba(15, 23, 42, 0.08);
overflow: hidden;
}
.detail-float :deep(.node-detail) {
width: 100%;
flex: 1 1 auto;
border: 0;
border-radius: 0;
overflow: auto;
}
.float-bar {
display: flex;
align-items: center;
gap: 6px;
height: 28px;
padding: 0 8px;
background: #F8FAFC;
border-bottom: 1px solid #E5E7EB;
cursor: move;
user-select: none;
flex-shrink: 0;
}
.float-bar-grip { display: inline-flex; color: #94A3B8; }
.float-bar-grip svg { width: 14px; height: 14px; }
.float-bar-title { font-size: 11px; color: #94A3B8; flex: 1; }
.float-bar-close {
width: 20px;
height: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
background: transparent;
color: #64748B;
border-radius: 4px;
cursor: pointer;
}
.float-bar-close:hover { background: #E2E8F0; color: #0F172A; }
.float-bar-close svg { width: 12px; height: 12px; }
/* ── 跨层关系摘要条 ── */
.cross-summary {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 14px;
flex-wrap: nowrap;
overflow-x: auto;
padding: 6px 2px 0;
font-size: 11px;
color: #475569;
border-top: 1px dashed var(--bh-border, #E5E7EB);
margin-top: 4px;
}
.cs-title { color: #94A3B8; flex-shrink: 0; }
.cs-item { display: inline-flex; align-items: center; gap: 4px; flex-shrink: 0; }
.cs-dot { width: 7px; height: 7px; border-radius: 2px; display: inline-block; }
.cs-label { color: #64748B; }
.cs-num { color: #0F172A; font-variant-numeric: tabular-nums; }
.cs-total { margin-left: auto; color: #64748B; flex-shrink: 0; }
.cs-total b { color: #0F172A; }
/* ── 跨层箭头指示器 ── */
.cross-layer-arrow {
display: flex;