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
@@ -90,9 +90,32 @@
回全景 回全景
</el-button> </el-button>
<el-button size="small" text @click="$emit('toggle-tree')"> <el-button size="small" text @click="$emit('toggle-tree')" :title="treeOpen ? '收起左侧目标树(画布全宽)' : '展开左侧目标树'">
{{ treeOpen ? '收起目标树' : '展开目标树' }} {{ treeOpen ? '收起目标树' : '展开目标树' }}
</el-button> </el-button>
<!-- C 方案:聚焦开关(全景 = 默认四层等宽) -->
<div class="eb-chips eb-focus" role="group" aria-label="聚焦层">
<button
type="button"
class="eb-chip"
:class="{ on: !focusLayer }"
title="四层全景(默认)"
@click="$emit('focus-layer', null)"
>全景</button>
<button
v-for="cfg in layerConfigs"
:key="'focus-' + cfg.key"
type="button"
class="eb-chip"
:class="{ on: focusLayer === cfg.key }"
:title="focusLayer === cfg.key ? '再次点击退出聚焦' : '聚焦' + cfg.label"
@click="$emit('focus-layer', focusLayer === cfg.key ? null : cfg.key)"
>
<span class="layer-dot" :style="{ background: cfg.color }"></span>
{{ cfg.short }}
</button>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -105,6 +128,8 @@ defineProps<{
layerConfigs: { key: string; label: string; short: string; color: string }[] layerConfigs: { key: string; label: string; short: string; color: string }[]
canUndo: boolean canUndo: boolean
treeOpen: boolean treeOpen: boolean
/** C 方案聚焦层:null = 四层全景 */
focusLayer?: string | null
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -117,6 +142,7 @@ const emit = defineEmits<{
(e: 'fit'): void (e: 'fit'): void
(e: 'toggle-tree'): void (e: 'toggle-tree'): void
(e: 'completeness'): void (e: 'completeness'): void
(e: 'focus-layer', key: string | null): void
}>() }>()
const filterOptions: { value: 'all' | 'risk' | 'nodata'; label: string }[] = [ const filterOptions: { value: 'all' | 'risk' | 'nodata'; label: string }[] = [
@@ -1,89 +1,150 @@
<template> <template>
<section class="map-summary" aria-label="战略地图摘要"> <section class="map-summary" aria-label="战略地图摘要">
<!-- ① 营业收入:分业务线全额售价(总额法)· 禁净额 / 禁合并 --> <!-- C 方案(2026-10-23):汇总条压成单行(≤44px),5 项指标同排;明细收进「明细」展开位 -->
<div class="sum-card sum-card--wide"> <div class="ms-row">
<div class="sum-head"> <!-- ① 营业收入:分业务线全额售价(总额法)· 禁净额 / 禁合并 -->
<span class="sum-label">营业收入</span> <div class="ms-metric ms-metric--rev">
<span v-if="revenue && revenue.caliber_label" class="sum-badge calm">{{ revenue.caliber_label }}</span> <span class="ms-label">营业收入</span>
<span class="ms-value mono" :class="{ muted: revenueTotal == null }">
<template v-if="revenueTotal != null">{{ fmtNum(revenueTotal) }}<i class="ms-unit">万元</i></template>
<template v-else>—<i class="ms-unit">无数据</i></template>
</span>
<span v-if="revenue && revenue.caliber_label" class="ms-badge calm">{{ revenue.caliber_label }}</span>
</div> </div>
<div v-if="revenueLines.length" class="rev-lines">
<div v-for="ln in revenueLines" :key="ln.key" class="rev-line"> <span class="ms-sep" aria-hidden="true"></span>
<span class="rev-line-label">{{ ln.label }}</span>
<span class="rev-line-value mono" :class="{ muted: ln.amount == null }"> <!-- ② 毛利率 -->
<template v-if="ln.amount != null">{{ fmtNum(ln.amount) }}<i class="sum-unit">万元</i></template> <div class="ms-metric">
<template v-else>—<i class="sum-unit">无数据</i></template> <span class="ms-label">毛利率</span>
</span> <span class="ms-value mono" :class="{ muted: gmValue == null }">
<template v-if="gmValue != null">{{ gmValue.toFixed(2) }}<i class="ms-unit">%</i></template>
<template v-else>—<i class="ms-unit">无数据</i></template>
</span>
<span v-if="grossMargin && grossMargin.data_tag" class="ms-badge warn">{{ grossMargin.data_tag }}</span>
</div>
<span class="ms-sep" aria-hidden="true"></span>
<!-- ③ 战略目标达成率 -->
<div class="ms-metric">
<span class="ms-label">战略目标达成率</span>
<span class="ms-value mono" :class="achievement == null ? 'muted' : 'lv-' + achievementLevel">
<template v-if="achievement != null">{{ Math.round(achievement) }}<i class="ms-unit">%</i></template>
<template v-else>—<i class="ms-unit">无数据</i></template>
</span>
</div>
<span class="ms-sep" aria-hidden="true"></span>
<!-- ④ 风险目标数 -->
<div class="ms-metric">
<span class="ms-label">风险目标</span>
<span class="ms-value mono" :class="riskCount == null ? 'muted' : (riskCount > 0 ? 'lv-red' : 'lv-green')">
<template v-if="riskCount != null">{{ riskCount }}</template>
<template v-else>—<i class="ms-unit">无数据</i></template>
</span>
</div>
<span class="ms-sep" aria-hidden="true"></span>
<!-- ⑤ 目标·连线(当前画布规模) -->
<div class="ms-metric ms-metric--scale">
<span class="ms-label">目标·连线</span>
<span class="ms-value mono">
<template v-if="objectivesTotal != null">{{ objectivesTotal }}</template>
<template v-else>—</template>
<i class="ms-unit">个目标</i>
<span class="ms-div">/</span>
<template v-if="connectionsTotal != null">{{ connectionsTotal }}</template>
<template v-else>—</template>
<i class="ms-unit">条连线</i>
</span>
</div>
<button
type="button"
class="ms-detail-btn"
:aria-expanded="detailOpen ? 'true' : 'false'"
@click="detailOpen = !detailOpen"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path :d="detailOpen ? 'M6 15l6-6 6 6' : 'M6 9l6 6 6-6'" />
</svg>
{{ detailOpen ? '收起' : '明细' }}
</button>
</div>
<!-- 明细(默认收起;展开后保留分线金额 / 口径 / 管理指标全部信息,不丢信息) -->
<div v-if="detailOpen" class="ms-detail">
<div class="ms-card">
<div class="ms-detail-head">
<span class="ms-label">营业收入 · 分业务线</span>
<span v-if="revenue && revenue.caliber_label" class="ms-badge calm">{{ revenue.caliber_label }}</span>
</div> </div>
<div class="rev-line rev-line--total"> <div v-if="revenueLines.length" class="rev-lines">
<span class="rev-line-label">{{ revenueTotalLabel }}</span> <div v-for="ln in revenueLines" :key="ln.key" class="rev-line">
<span class="rev-line-value mono"> <span class="rev-line-label">{{ ln.label }}</span>
<template v-if="revenueTotal != null">{{ fmtNum(revenueTotal) }}<i class="sum-unit">万元</i></template> <span class="rev-line-value mono" :class="{ muted: ln.amount == null }">
<template v-else>—<i class="sum-unit">无数据</i></template> <template v-if="ln.amount != null">{{ fmtNum(ln.amount) }}<i class="ms-unit">万元</i></template>
</span> <template v-else>—<i class="ms-unit">无数据</i></template>
</span>
</div>
<div class="rev-line rev-line--total">
<span class="rev-line-label">{{ revenueTotalLabel }}</span>
<span class="rev-line-value mono">
<template v-if="revenueTotal != null">{{ fmtNum(revenueTotal) }}<i class="ms-unit">万元</i></template>
<template v-else>—<i class="ms-unit">无数据</i></template>
</span>
</div>
</div>
<div v-else class="ms-value muted">—<i class="ms-unit">无数据</i></div>
<div class="ms-sub">{{ revenuePeriod }}</div>
</div>
<div class="ms-card">
<div class="ms-detail-head"><span class="ms-label">毛利率口径</span></div>
<div class="ms-value mono" :class="{ muted: gmValue == null }">
<template v-if="gmValue != null">{{ gmValue.toFixed(2) }}<i class="ms-unit">%</i></template>
<template v-else>—<i class="ms-unit">无数据</i></template>
</div>
<div class="ms-sub">{{ gmSub }}</div>
</div>
<div class="ms-card">
<div class="ms-detail-head"><span class="ms-label">达成率 / 风险分布</span></div>
<div class="ms-sub">
<template v-if="achievement != null">{{ achievementCount }} 个目标有实测值 · 其余无数据</template>
<template v-else>所有目标均无可用 KPI 实测值</template>
</div>
<div class="ms-sub">
<template v-if="summary">达标 {{ summary.green }} · 关注 {{ summary.yellow }} · 风险 {{ summary.red }} · 共 {{ summary.total }} 个目标</template>
<template v-else>战略回顾数据未加载</template>
</div> </div>
</div> </div>
<div v-else class="sum-value muted">—<i class="sum-unit">无数据</i></div>
<div class="sum-sub">{{ revenuePeriod }}</div>
</div>
<!-- ② 毛利率:新式口径(折扣后净进价),历史缺票期带标识 --> <div class="ms-card">
<div class="sum-card"> <div class="ms-detail-head"><span class="ms-label">口径变更 / 管理指标</span></div>
<div class="sum-head"> <div class="ms-foot">
<span class="sum-label">毛利率</span> <span v-if="switchInfo && switchInfo.has_switch" class="ms-badge warn" :title="switchInfo.note || ''">
<span v-if="grossMargin && grossMargin.data_tag" class="sum-badge warn">{{ grossMargin.data_tag }}</span> {{ switchInfo.label }}<template v-if="switchInfo.effective_date"> · 自 {{ switchInfo.effective_date }}</template>
<em v-if="switchInfo.comparability"> · {{ switchInfo.comparability }}</em>
</span>
<span v-for="m in management" :key="m.key" class="mgmt" :title="m.caliber_note || ''">
<span class="mgmt-label">{{ m.label }}</span>
<span class="mgmt-value mono">{{ m.value == null ? '—' : m.value.toFixed(2) + '%' }}</span>
<span class="mgmt-tag">{{ m.tag }}</span>
<span v-if="m.abnormal" class="mgmt-abnormal">异常·须核查</span>
</span>
<span v-if="!(switchInfo && switchInfo.has_switch) && management.length === 0" class="ms-sub">无口径变更与管理指标</span>
</div>
</div> </div>
<div class="sum-value mono" :class="{ muted: gmValue == null }">
<template v-if="gmValue != null">{{ gmValue.toFixed(2) }}<i class="sum-unit">%</i></template>
<template v-else>—<i class="sum-unit">无数据</i></template>
</div>
<div class="sum-sub">{{ gmSub }}</div>
</div>
<!-- ③ 战略目标达成率 -->
<div class="sum-card">
<div class="sum-head"><span class="sum-label">战略目标达成率</span></div>
<div class="sum-value mono" :class="achievement == null ? 'muted' : 'lv-' + achievementLevel">
<template v-if="achievement != null">{{ Math.round(achievement) }}<i class="sum-unit">%</i></template>
<template v-else>—<i class="sum-unit">无数据</i></template>
</div>
<div class="sum-sub">
<template v-if="achievement != null">{{ achievementCount }} 个目标有实测值 · 其余无数据</template>
<template v-else>所有目标均无可用 KPI 实测值</template>
</div>
</div>
<!-- ④ 风险目标数 -->
<div class="sum-card">
<div class="sum-head"><span class="sum-label">风险目标</span></div>
<div class="sum-value mono" :class="riskCount == null ? 'muted' : (riskCount > 0 ? 'lv-red' : 'lv-green')">
<template v-if="riskCount != null">{{ riskCount }}</template>
<template v-else>—<i class="sum-unit">无数据</i></template>
</div>
<div class="sum-sub">
<template v-if="summary">达标 {{ summary.green }} · 关注 {{ summary.yellow }} · 共 {{ summary.total }} 个目标</template>
<template v-else>战略回顾数据未加载</template>
</div>
</div>
<!-- ⑤ 口径变更标识位 + 管理指标(非会计科目,单列、不占主位) -->
<div class="sum-foot">
<span v-if="switchInfo && switchInfo.has_switch" class="sum-badge warn" :title="switchInfo.note || ''">
{{ switchInfo.label }}<template v-if="switchInfo.effective_date"> · 自 {{ switchInfo.effective_date }}</template>
<em v-if="switchInfo.comparability"> · {{ switchInfo.comparability }}</em>
</span>
<span v-for="m in management" :key="m.key" class="mgmt" :title="m.caliber_note || ''">
<span class="mgmt-label">{{ m.label }}</span>
<span class="mgmt-value mono">{{ m.value == null ? '—' : m.value.toFixed(2) + '%' }}</span>
<span class="mgmt-tag">{{ m.tag }}</span>
<span v-if="m.abnormal" class="mgmt-abnormal">异常·须核查</span>
<span class="mgmt-caliber">{{ fmtNum(m.numerator) }} ÷ {{ fmtNum(m.denominator) }}<template v-if="m.period"> · {{ m.period }}</template></span>
</span>
</div> </div>
</section> </section>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, ref } from 'vue'
type RevLine = { key: string; label: string; amount: number | null; unit?: string; missing_reason?: string | null } type RevLine = { key: string; label: string; amount: number | null; unit?: string; missing_reason?: string | null }
@@ -122,8 +183,14 @@ const props = defineProps<{
summary: { total: number; green: number; yellow: number; red: number; health_score?: number } | null summary: { total: number; green: number; yellow: number; red: number; health_score?: number } | null
switchInfo?: { has_switch?: boolean; label?: string; effective_date?: string | null; comparability?: string; note?: string } | null switchInfo?: { has_switch?: boolean; label?: string; effective_date?: string | null; comparability?: string; note?: string } | null
loading?: boolean loading?: boolean
/** ⑤ 目标·连线:当前地图目标数(null ⇒ 显式「—」) */
objectivesTotal?: number | null
/** ⑤ 目标·连线:当前地图连线数(null ⇒ 显式「—」) */
connectionsTotal?: number | null
}>() }>()
const detailOpen = ref(false)
const revenueLines = computed<RevLine[]>(() => (props.revenue?.lines || []).filter(l => !!l && !!l.key)) const revenueLines = computed<RevLine[]>(() => (props.revenue?.lines || []).filter(l => !!l && !!l.key))
/** 合计 = 各线相加的复算校验位,不与分线并列成第二套口径 */ /** 合计 = 各线相加的复算校验位,不与分线并列成第二套口径 */
const revenueTotal = computed<number | null>(() => { const revenueTotal = computed<number | null>(() => {
@@ -163,92 +230,125 @@ function fmtNum(v: number | null | undefined): string {
</script> </script>
<style scoped> <style scoped>
/* C 方案:单行汇总条(行高 40px + 上下 1px 边框 ≤44px) */
.map-summary { .map-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
flex-shrink: 0; flex-shrink: 0;
margin-bottom: 10px; margin-bottom: 8px;
}
.sum-card {
background: var(--bh-bg-card, #fff); background: var(--bh-bg-card, #fff);
border: 1px solid var(--bh-border, #E5E7EB); border: 1px solid var(--bh-border, #E5E7EB);
border-radius: 8px; border-radius: 8px;
padding: 10px 14px; padding: 0 10px;
}
.ms-row {
display: flex;
align-items: center;
gap: 10px;
height: 40px;
min-width: 0; min-width: 0;
} }
.sum-card--wide { grid-column: span 2; } .ms-metric {
.sum-head { display: flex; align-items: center; gap: 8px; min-width: 0; } display: flex;
.sum-label { align-items: baseline;
gap: 6px;
min-width: 0;
white-space: nowrap;
}
.ms-metric--rev { flex: 0 1 auto; overflow: hidden; }
.ms-label {
font-size: 12px; font-size: 12px;
color: var(--bh-text-secondary, #64748B); color: var(--bh-text-secondary, #64748B);
letter-spacing: .02em; letter-spacing: .02em;
white-space: nowrap; flex-shrink: 0;
} }
.sum-badge { .ms-value {
font-size: 17px;
font-weight: 700;
line-height: 1.1;
color: var(--bh-text-primary, #0F172A);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ms-value.muted { font-size: 13px; color: var(--bh-text-muted, #94A3B8); font-weight: 500; }
.ms-unit {
font-size: 10px;
font-weight: 500;
font-style: normal;
color: var(--bh-text-secondary, #64748B);
margin-left: 2px;
}
.ms-div { color: var(--bh-text-muted, #94A3B8); margin: 0 2px; }
.ms-sep { width: 1px; height: 18px; background: var(--bh-border, #E5E7EB); flex-shrink: 0; }
.ms-badge {
font-size: 10px; font-size: 10px;
line-height: 1.5; line-height: 1.5;
padding: 1px 6px; padding: 0 5px;
border-radius: 4px; border-radius: 4px;
border: 1px solid transparent;
white-space: nowrap; white-space: nowrap;
overflow: hidden; flex-shrink: 0;
text-overflow: ellipsis;
} }
.sum-badge.calm { color: #635BFF; background: rgba(99, 91, 255, .08); } .ms-badge.calm { color: #0369A1; background: #F0F9FF; border-color: #BAE6FD; }
.sum-badge.warn { color: #B45309; background: #FEF3C7; } .ms-badge.warn { color: #B45309; background: #FFFBEB; border-color: #FDE68A; }
.sum-badge.warn em { font-style: normal; } .ms-badge.warn em { font-style: normal; }
.sum-value { .ms-detail-btn {
margin-top: 4px; margin-left: auto;
font-size: 22px; flex-shrink: 0;
font-weight: 700; display: inline-flex;
line-height: 1.2;
color: var(--bh-text-primary, #0F172A);
display: flex;
align-items: baseline;
gap: 3px;
}
.sum-value.muted { font-size: 15px; color: var(--bh-text-muted, #94A3B8); font-weight: 500; }
.sum-unit { font-size: 11px; font-weight: 500; font-style: normal; color: var(--bh-text-secondary, #64748B); margin-left: 2px; }
.sum-sub {
margin-top: 4px;
font-size: 11px;
color: var(--bh-text-muted, #94A3B8);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rev-lines { margin-top: 4px; display: flex; flex-direction: column; gap: 2px; }
.rev-line { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
.rev-line-label { font-size: 12px; color: var(--bh-text-secondary, #64748B); }
.rev-line-value { font-size: 18px; font-weight: 700; color: var(--bh-text-primary, #0F172A); }
.rev-line-value.muted { font-size: 14px; font-weight: 500; color: var(--bh-text-muted, #94A3B8); }
.rev-line--total { border-top: 1px dashed var(--bh-border, #E5E7EB); padding-top: 3px; margin-top: 2px; }
.rev-line--total .rev-line-label,
.rev-line--total .rev-line-value { font-size: 12px; font-weight: 500; color: var(--bh-text-secondary, #64748B); }
.sum-foot {
grid-column: 1 / -1;
display: flex;
flex-wrap: wrap;
align-items: center; align-items: center;
gap: 6px 16px; gap: 3px;
padding: 6px 12px; height: 24px;
border: 1px dashed var(--bh-border, #E5E7EB); padding: 0 8px;
border-radius: 6px;
font-size: 11px; font-size: 11px;
color: var(--bh-text-muted, #94A3B8); color: var(--bh-text-secondary, #64748B);
background: transparent;
border: 1px solid var(--bh-border, #E5E7EB);
border-radius: 6px;
cursor: pointer;
} }
.mgmt { display: inline-flex; align-items: baseline; gap: 6px; } .ms-detail-btn:hover { color: #0F172A; border-color: #CBD5E1; }
.ms-detail-btn svg { width: 12px; height: 12px; }
/* 明细展开区 */
.ms-detail {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 10px;
padding: 0 0 10px;
}
.ms-card {
border: 1px solid var(--bh-border, #E5E7EB);
border-radius: 8px;
padding: 8px 12px;
min-width: 0;
background: var(--bh-bg-subtle, #FCFCFD);
}
.ms-detail-head {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
.rev-lines { display: flex; flex-direction: column; gap: 2px; }
.rev-line { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; min-width: 0; }
.rev-line-label { font-size: 11px; color: var(--bh-text-secondary, #64748B); }
.rev-line-value { font-size: 12px; color: var(--bh-text-primary, #0F172A); }
.rev-line-value.muted { color: var(--bh-text-muted, #94A3B8); }
.rev-line--total { border-top: 1px dashed var(--bh-border, #E5E7EB); margin-top: 2px; padding-top: 2px; }
.ms-sub { margin-top: 4px; font-size: 11px; color: var(--bh-text-muted, #94A3B8); line-height: 1.5; }
.ms-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; }
.mgmt { display: inline-flex; align-items: baseline; gap: 4px; font-size: 11px; }
.mgmt-label { color: var(--bh-text-secondary, #64748B); } .mgmt-label { color: var(--bh-text-secondary, #64748B); }
.mgmt-value { font-size: 13px; font-weight: 700; color: var(--bh-text-primary, #0F172A); } .mgmt-value { color: var(--bh-text-primary, #0F172A); font-weight: 600; }
.mgmt-tag { font-size: 10px; padding: 1px 5px; border-radius: 4px; background: #F1F5F9; color: #475569; } .mgmt-tag { font-size: 10px; padding: 0 4px; border-radius: 4px; background: #F1F5F9; color: #475569; }
.mgmt-abnormal { color: #B91C1C; } .mgmt-abnormal { font-size: 10px; color: #B91C1C; }
.mgmt-caliber { color: var(--bh-text-muted, #94A3B8); }
.mono { font-family: 'IBM Plex Mono', Consolas, 'SFMono-Regular', monospace; font-variant-numeric: tabular-nums; } .mono { font-family: 'IBM Plex Mono', Consolas, 'SFMono-Regular', monospace; font-variant-numeric: tabular-nums; }
.lv-green { color: #00C896; } .lv-green { color: #00C896; }
.lv-yellow { color: #F59E0B; } .lv-yellow { color: #F59E0B; }
.lv-red { color: #EF4444; } .lv-red { color: #EF4444; }
@media (max-width: 1200px) {
.map-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); } /* 窄屏:单行改为可横向滚动,保留全部 5 项、不换行不截断 */
.sum-card--wide { grid-column: span 2; } @media (max-width: 1280px) {
.ms-row { overflow-x: auto; }
} }
</style> </style>
@@ -1,6 +1,17 @@
<template> <template>
<div class="strategy-layer" :class="`layer-${layerKey}`" :style="{ borderColor: color }"> <div class="strategy-layer" :class="`layer-${layerKey}`" :style="{ borderColor: color }">
<div class="layer-header" :style="{ background: color }"> <div
class="layer-header"
:class="{ 'is-focusable': true, 'is-focused': focused }"
:style="{ background: color }"
:title="focused ? '再次点击退出聚焦,恢复四层等宽' : '点击聚焦该层(其余三层压成单行摘要)'"
role="button"
tabindex="0"
:aria-pressed="focused ? 'true' : 'false'"
@click="$emit('focus')"
@keydown.enter.prevent="$emit('focus')"
@keydown.space.prevent="$emit('focus')"
>
<button <button
type="button" type="button"
class="layer-collapse-btn" class="layer-collapse-btn"
@@ -14,6 +25,12 @@
</button> </button>
<span class="layer-icon">{{ icon }}</span> <span class="layer-icon">{{ icon }}</span>
<span class="layer-name">{{ label }}</span> <span class="layer-name">{{ label }}</span>
<!-- 压缩态(C 方案聚焦模式):保「目标数 + 达成率色条」,整层信息不全隐 -->
<span v-if="compact" class="layer-mini" :aria-label="'该层达成率 ' + ratioText">
<span class="mini-track"><i class="mini-fill" :style="{ width: ratioBarWidth }"></i></span>
<span class="mini-ratio mono">{{ ratioText }}</span>
</span>
<span v-if="focused" class="layer-focus-flag">聚焦中</span>
<span class="layer-objective-count">{{ objectives.length }} 个目标</span> <span class="layer-objective-count">{{ objectives.length }} 个目标</span>
<el-dropdown trigger="click" @command="onLayerAction"> <el-dropdown trigger="click" @command="onLayerAction">
<el-button class="layer-more-btn" size="small" text> <el-button class="layer-more-btn" size="small" text>
@@ -90,7 +107,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive } from 'vue' import { ref, reactive, computed } from 'vue'
import { MoreFilled } from '@element-plus/icons-vue' import { MoreFilled } from '@element-plus/icons-vue'
import draggable from 'vuedraggable' import draggable from 'vuedraggable'
import MapNode from './MapNode.vue' import MapNode from './MapNode.vue'
@@ -115,6 +132,12 @@ const props = defineProps<{
dimmedKeys?: string[] dimmedKeys?: string[]
/** 层折叠(仅隐藏层体,保留层头) */ /** 层折叠(仅隐藏层体,保留层头) */
collapsed?: boolean collapsed?: boolean
/** C 方案聚焦模式:其它层压成单行(层头 + 目标数 + 达成率色条) */
compact?: boolean
/** 该层达成率均值(无实测值传 null,界面显式「—」,不用示例数兜底) */
layerRatio?: number | null
/** 本层是否处于聚焦展开态 */
focused?: boolean
getNodeLevel: (key: string) => string getNodeLevel: (key: string) => string
getNodeProgress: (key: string) => any getNodeProgress: (key: string) => any
}>() }>()
@@ -138,8 +161,21 @@ const emit = defineEmits<{
(e: 'edit-kr', payload: { dimKey: string; idx: number; obj: any; ki: number; kr: any }): void (e: 'edit-kr', payload: { dimKey: string; idx: number; obj: any; ki: number; kr: any }): void
(e: 'delete-kr', payload: { dimKey: string; idx: number; obj: any; ki: number; kr: any }): void (e: 'delete-kr', payload: { dimKey: string; idx: number; obj: any; ki: number; kr: any }): void
(e: 'add-kr', payload: { dimKey: string; idx: number; obj: any }): void (e: 'add-kr', payload: { dimKey: string; idx: number; obj: any }): void
(e: 'focus'): void
}>() }>()
/** 压缩态达成率文案:无实测值显式「—」,禁止示例数字兜底 */
const ratioText = computed(() => {
const r = props.layerRatio
return r == null || Number.isNaN(Number(r)) ? '—' : `${Math.round(Number(r))}%`
})
/** 压缩态色条宽度(0–100%,无数据时 0 宽 + 灰色轨道) */
const ratioBarWidth = computed(() => {
const r = Number(props.layerRatio)
if (props.layerRatio == null || Number.isNaN(r)) return '0%'
return `${Math.max(0, Math.min(100, Math.round(r)))}%`
})
const bodyRef = ref<HTMLElement | null>(null) const bodyRef = ref<HTMLElement | null>(null)
// Node DOM refs for line calculation (reactive so parent can aggregate) // Node DOM refs for line calculation (reactive so parent can aggregate)
@@ -251,6 +287,54 @@ export default {
letter-spacing: 0.3px; letter-spacing: 0.3px;
} }
/* C 方案:层头可点击聚焦 */
.layer-header.is-focusable { cursor: pointer; }
.layer-header.is-focusable:focus-visible {
outline: 2px solid #fff;
outline-offset: -3px;
}
.layer-header.is-focused { box-shadow: inset 0 -2px 0 rgba(255, 255, 255, 0.55); }
.layer-focus-flag {
font-size: 10px;
font-weight: 500;
letter-spacing: .04em;
padding: 1px 6px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.24);
flex-shrink: 0;
}
/* 压缩单行态:达成率色条 + 百分比(保信息不全隐) */
.layer-mini {
display: inline-flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
min-width: 96px;
}
.mini-track {
width: 56px;
height: 4px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.28);
overflow: hidden;
display: inline-block;
}
.mini-fill {
display: block;
height: 100%;
border-radius: 2px;
background: #fff;
transition: width .2s ease;
}
.mini-ratio {
font-size: 11px;
font-weight: 600;
font-variant-numeric: tabular-nums;
opacity: .95;
}
.layer-icon { .layer-icon {
font-size: 18px; font-size: 18px;
line-height: 1; line-height: 1;
+333 -13
View File
@@ -34,6 +34,8 @@
:achievement-count="achievementCount" :achievement-count="achievementCount"
:summary="reviewSummary" :summary="reviewSummary"
:loading="loading" :loading="loading"
:objectives-total="totalObjectiveCount"
:connections-total="connections.length"
/> />
<MapEditBar <MapEditBar
@@ -43,6 +45,7 @@
:layer-configs="layerUiConfigs" :layer-configs="layerUiConfigs"
:can-undo="undoStack.length > 0" :can-undo="undoStack.length > 0"
:tree-open="treeOpen" :tree-open="treeOpen"
:focus-layer="focusLayer"
@update:search="v => searchText = v" @update:search="v => searchText = v"
@update:filter="v => filterMode = v" @update:filter="v => filterMode = v"
@toggle-layer="toggleLayer" @toggle-layer="toggleLayer"
@@ -52,6 +55,7 @@
@fit="zoomFit" @fit="zoomFit"
@toggle-tree="treeOpen = !treeOpen" @toggle-tree="treeOpen = !treeOpen"
@completeness="showCompleteness = true" @completeness="showCompleteness = true"
@focus-layer="toggleFocusLayer"
/> />
<!-- 口径标识(常驻,非 hover 临时提示):A 口径 v1.1 来自会计科目、非发票同票列明 --> <!-- 口径标识(常驻,非 hover 临时提示):A 口径 v1.1 来自会计科目、非发票同票列明 -->
@@ -80,7 +84,7 @@
<el-button size="small" @click="cancelLink">取消</el-button> <el-button size="small" @click="cancelLink">取消</el-button>
</div> </div>
<!-- 仪表盘主视图:左目标树 / 中画布 / 右详情 --> <!-- 仪表盘主视图:左目标树(默认收起,留 46px 图标条)/ 中画布 / 详情浮层 -->
<div class="map-body-row"> <div class="map-body-row">
<MapTargetTree <MapTargetTree
v-show="treeOpen" v-show="treeOpen"
@@ -98,6 +102,22 @@
@drag-end="onTreeDragEnd" @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"> <div class="map-canvas-col">
<!-- 画布主体(田字格2×2布局,transform: scale 缩放) --> <!-- 画布主体(田字格2×2布局,transform: scale 缩放) -->
<div <div
@@ -108,7 +128,7 @@
@drop.prevent="onTreeDrop" @drop.prevent="onTreeDrop"
> >
<div class="canvas-zoom-wrap" :style="{ width: zoomWrapWidth, height: zoomWrapHeight }"> <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 <div
v-for="(cfg, idx) in orderedLayerConfigs" v-for="(cfg, idx) in orderedLayerConfigs"
@@ -116,6 +136,10 @@
v-show="visibleLayers[cfg.key]" v-show="visibleLayers[cfg.key]"
:ref="el => setLayerRef(cfg.key, el)" :ref="el => setLayerRef(cfg.key, el)"
class="layer-swimlane-wrapper" class="layer-swimlane-wrapper"
:class="{
'is-focused': focusLayer === cfg.key,
'is-compact': !!focusLayer && focusLayer !== cfg.key,
}"
:style="{ borderLeft: '4px solid ' + layerColor(cfg.key) }" :style="{ borderLeft: '4px solid ' + layerColor(cfg.key) }"
> >
<StrategyLayer <StrategyLayer
@@ -126,6 +150,10 @@
:color="layerColor(cfg.key)" :color="layerColor(cfg.key)"
:objectives="getLayerNodes(cfg.key)" :objectives="getLayerNodes(cfg.key)"
:collapsed="!!collapsedLayers[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" :dim-active="filterActive"
:dimmed-keys="matchedKeyList" :dimmed-keys="matchedKeyList"
@toggle-collapse="toggleLayerCollapse(cfg.key)" @toggle-collapse="toggleLayerCollapse(cfg.key)"
@@ -174,10 +202,40 @@
<span class="lg-item"><i class="lg-dot lv-gray"></i>无数据</span> <span class="lg-item"><i class="lg-dot lv-gray"></i>无数据</span>
<span class="lg-note">达成率按指标方向归一,取目标关联 KPI 均值</span> <span class="lg-note">达成率按指标方向归一,取目标关联 KPI 均值</span>
</div> </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 <MapNodeDetail
v-if="selectedNode"
:obj="selectedNode.obj" :obj="selectedNode.obj"
:dim-key="selectedNode.dimKey" :dim-key="selectedNode.dimKey"
:index="selectedNode.index" :index="selectedNode.index"
@@ -196,13 +254,7 @@
@select-key="selectByKey" @select-key="selectByKey"
@go-kpi-library="goKpiLibrary" @go-kpi-library="goKpiLibrary"
/> />
<aside v-else class="map-detail-empty" aria-label="目标详情占位"> </div>
<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 -->
<!-- SVG 连线层(覆盖整个画布,绘制跨层+同层箭头) --> <!-- SVG 连线层(覆盖整个画布,绘制跨层+同层箭头) -->
<ConnectionLines <ConnectionLines
@@ -211,7 +263,7 @@
:node-refs="compiledNodeRefs" :node-refs="compiledNodeRefs"
:layer-keys="layerKeysOrdered" :layer-keys="layerKeysOrdered"
:conn-keys="connKeys" :conn-keys="connKeys"
:connections="connections" :connections="renderedConnections"
:temp-line="tempLine" :temp-line="tempLine"
:container-key="recalcTrigger" :container-key="recalcTrigger"
:view-direction="viewDirection" :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 visibleLayers = reactive<Record<string, boolean>>({ finance: true, customer: true, process: true, learning: true })
const collapsedLayers = reactive<Record<string, boolean>>({}) const collapsedLayers = reactive<Record<string, boolean>>({})
const collapsedGroups = 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 selectedNode = ref<{ dimKey: string; index: number; obj: any } | null>(null)
const selectedKey = computed(() => (selectedNode.value ? `${selectedNode.value.dimKey}-${selectedNode.value.index}` : 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 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') const filterActive = computed(() => searchText.value.trim() !== '' || filterMode.value !== 'all')
@@ -2176,6 +2362,140 @@ onUnmounted(() => {
margin-bottom: 4px; 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 { .cross-layer-arrow {
display: flex; display: flex;