- MapCanvas: 层聚焦与回全景联动;右栏停用改自绘可拖详情浮层;目标树默认收起+localStorage 持久化;底部跨层关系摘要条;聚焦态只绘聚焦层同层连线 - StrategyLayer: 层标题可点击聚焦并标「聚焦中」;非聚焦层压缩为 ≤48px 单行摘要(层名—达成率/目标数/mini 色条) - MapSummaryBar: 汇总条压缩 ≤44px、5 项同排;窄屏无横向溢出 - MapEditBar: 新增「全景 / L1–L4」聚焦切换 chip(内联 SVG,无 emoji) - 只读渲染层改动:不写 canvas_data / map_connections,无新增写链路调用
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user