Files
cma-management/frontend/src/components/strategy-map/MapSummaryBar.vue
T
wecom-fullstack 63a2bac424
ci/woodpecker/push/main Pipeline was successful
feat(maps): 画布聚焦减负(聚焦层/详情浮层/汇总条/目标树收起/跨层摘要)— C 方案
- MapCanvas: 层聚焦与回全景联动;右栏停用改自绘可拖详情浮层;目标树默认收起+localStorage 持久化;底部跨层关系摘要条;聚焦态只绘聚焦层同层连线
- StrategyLayer: 层标题可点击聚焦并标「聚焦中」;非聚焦层压缩为 ≤48px 单行摘要(层名—达成率/目标数/mini 色条)
- MapSummaryBar: 汇总条压缩 ≤44px、5 项同排;窄屏无横向溢出
- MapEditBar: 新增「全景 / L1–L4」聚焦切换 chip(内联 SVG,无 emoji)
- 只读渲染层改动:不写 canvas_data / map_connections,无新增写链路调用
2026-10-24 20:03:32 +08:00

355 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<section class="map-summary" aria-label="战略地图摘要">
<!-- C 方案(2026-10-23):汇总条压成单行(≤44px),5 项指标同排;明细收进「明细」展开位 -->
<div class="ms-row">
<!-- ① 营业收入:分业务线全额售价(总额法)· 禁净额 / 禁合并 -->
<div class="ms-metric ms-metric--rev">
<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>
<span class="ms-sep" aria-hidden="true"></span>
<!-- ② 毛利率 -->
<div class="ms-metric">
<span class="ms-label">毛利率</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 v-if="revenueLines.length" class="rev-lines">
<div v-for="ln in revenueLines" :key="ln.key" class="rev-line">
<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="ms-unit">万元</i></template>
<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 class="ms-card">
<div class="ms-detail-head"><span class="ms-label">口径变更 / 管理指标</span></div>
<div class="ms-foot">
<span v-if="switchInfo && switchInfo.has_switch" class="ms-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>
<span v-if="!(switchInfo && switchInfo.has_switch) && management.length === 0" class="ms-sub">无口径变更与管理指标</span>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
type RevLine = { key: string; label: string; amount: number | null; unit?: string; missing_reason?: string | null }
const props = defineProps<{
revenue: {
available?: boolean
caliber_label?: string
unit?: string
period?: string | null
lines?: RevLine[]
total?: number | null
total_label?: string
} | null
grossMargin: {
available?: boolean
value: number | null
caliber_label?: string
data_tag?: string
period?: string | null
note?: string
} | null
management?: Array<{
key: string
label: string
value: number | null
unit?: string
numerator?: number | null
denominator?: number | null
period?: string | null
tag?: string
abnormal?: boolean
caliber_note?: string
}>
achievement: number | null
achievementCount: number
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
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 revenueTotal = computed<number | null>(() => {
const v = props.revenue?.total
return v == null || Number.isNaN(Number(v)) ? null : Number(v)
})
const revenueTotalLabel = computed(() => props.revenue?.total_label || '合计(复算校验)')
const revenuePeriod = computed(() => {
const p = props.revenue?.period
if (!p) return '凭证期未取到 · 无数据'
return p + ' 实际值 · 各业务线全额售价分列(禁净额、禁合并)'
})
const gmValue = computed<number | null>(() => {
const v = props.grossMargin?.value
return v == null || Number.isNaN(Number(v)) ? null : Number(v)
})
const gmSub = computed(() => {
if (gmValue.value == null) return '财务口径未取到 · 无数据'
const p = props.grossMargin?.period || ''
const cal = props.grossMargin?.caliber_label || ''
return [p ? p + ' 实际值' : '', cal].filter(Boolean).join(' · ')
})
const riskCount = computed<number | null>(() => (props.summary ? props.summary.red : null))
const achievementLevel = computed(() => {
const a = props.achievement
if (a == null) return 'gray'
if (a >= 90) return 'green'
if (a >= 60) return 'yellow'
return 'red'
})
/** 千分位展示;null ⇒ 「—」,不做任何兜底造数 */
function fmtNum(v: number | null | undefined): string {
if (v == null || Number.isNaN(Number(v))) return '—'
return Number(v).toLocaleString('zh-CN', { maximumFractionDigits: 2, minimumFractionDigits: 0 })
}
</script>
<style scoped>
/* C 方案:单行汇总条(行高 40px + 上下 1px 边框 ≤44px) */
.map-summary {
flex-shrink: 0;
margin-bottom: 8px;
background: var(--bh-bg-card, #fff);
border: 1px solid var(--bh-border, #E5E7EB);
border-radius: 8px;
padding: 0 10px;
}
.ms-row {
display: flex;
align-items: center;
gap: 10px;
height: 40px;
min-width: 0;
}
.ms-metric {
display: flex;
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;
color: var(--bh-text-secondary, #64748B);
letter-spacing: .02em;
flex-shrink: 0;
}
.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;
line-height: 1.5;
padding: 0 5px;
border-radius: 4px;
border: 1px solid transparent;
white-space: nowrap;
flex-shrink: 0;
}
.ms-badge.calm { color: #0369A1; background: #F0F9FF; border-color: #BAE6FD; }
.ms-badge.warn { color: #B45309; background: #FFFBEB; border-color: #FDE68A; }
.ms-badge.warn em { font-style: normal; }
.ms-detail-btn {
margin-left: auto;
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 3px;
height: 24px;
padding: 0 8px;
font-size: 11px;
color: var(--bh-text-secondary, #64748B);
background: transparent;
border: 1px solid var(--bh-border, #E5E7EB);
border-radius: 6px;
cursor: pointer;
}
.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-value { color: var(--bh-text-primary, #0F172A); font-weight: 600; }
.mgmt-tag { font-size: 10px; padding: 0 4px; border-radius: 4px; background: #F1F5F9; color: #475569; }
.mgmt-abnormal { font-size: 10px; color: #B91C1C; }
.mono { font-family: 'IBM Plex Mono', Consolas, 'SFMono-Regular', monospace; font-variant-numeric: tabular-nums; }
.lv-green { color: #00C896; }
.lv-yellow { color: #F59E0B; }
.lv-red { color: #EF4444; }
/* 窄屏:单行改为可横向滚动,保留全部 5 项、不换行不截断 */
@media (max-width: 1280px) {
.ms-row { overflow-x: auto; }
}
</style>