wip(strategy-map): v4 口径落码(分线营收/新式毛利率/管理指标/C1 hold/57 归档)
This commit is contained in:
@@ -1,28 +1,50 @@
|
||||
<template>
|
||||
<section class="map-summary" aria-label="战略地图摘要">
|
||||
<div class="sum-card">
|
||||
<div class="sum-label">营业收入</div>
|
||||
<div class="sum-value mono" :class="{ muted: revenue == null }">
|
||||
<template v-if="revenue != null">{{ fmtWan(revenue) }}<span class="sum-unit">万元</span></template>
|
||||
<template v-else>无数据</template>
|
||||
<!-- ① 营业收入:分业务线全额售价(总额法)· 禁净额 / 禁合并 -->
|
||||
<div class="sum-card sum-card--wide">
|
||||
<div class="sum-head">
|
||||
<span class="sum-label">营业收入</span>
|
||||
<span v-if="revenue && revenue.caliber_label" class="sum-badge calm">{{ revenue.caliber_label }}</span>
|
||||
</div>
|
||||
<div class="sum-sub">{{ period ? period + ' 实际值' : '财务分析接口未返回期间' }}</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="sum-unit">万元</i></template>
|
||||
<template v-else>—<i class="sum-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="sum-unit">万元</i></template>
|
||||
<template v-else>—<i class="sum-unit">无数据</i></template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="sum-value muted">—<i class="sum-unit">无数据</i></div>
|
||||
<div class="sum-sub">{{ revenuePeriod }}</div>
|
||||
</div>
|
||||
|
||||
<!-- ② 毛利率:新式口径(折扣后净进价),历史缺票期带标识 -->
|
||||
<div class="sum-card">
|
||||
<div class="sum-label">毛利率</div>
|
||||
<div class="sum-value mono" :class="{ muted: grossMargin == null }">
|
||||
<template v-if="grossMargin != null">{{ grossMargin.toFixed(1) }}<span class="sum-unit">%</span></template>
|
||||
<template v-else>无数据</template>
|
||||
<div class="sum-head">
|
||||
<span class="sum-label">毛利率</span>
|
||||
<span v-if="grossMargin && grossMargin.data_tag" class="sum-badge warn">{{ grossMargin.data_tag }}</span>
|
||||
</div>
|
||||
<div class="sum-sub">{{ period ? period + ' 实际值' : '未取到财务分析数据' }}</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-label">战略目标达成率</div>
|
||||
<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) }}<span class="sum-unit">%</span></template>
|
||||
<template v-else>无数据</template>
|
||||
<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>
|
||||
@@ -30,40 +52,100 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ④ 风险目标数 -->
|
||||
<div class="sum-card">
|
||||
<div class="sum-label">风险目标</div>
|
||||
<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>无数据</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>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
type RevLine = { key: string; label: string; amount: number | null; unit?: string; missing_reason?: string | null }
|
||||
|
||||
const props = defineProps<{
|
||||
finance: { total_sales: number | null; gross_profit_rate: number | null; period?: string } | null
|
||||
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
|
||||
}>()
|
||||
|
||||
const revenue = computed<number | null>(() => {
|
||||
const v = props.finance?.total_sales
|
||||
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 grossMargin = computed<number | null>(() => {
|
||||
const v = props.finance?.gross_profit_rate
|
||||
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 period = computed(() => props.finance?.period || '')
|
||||
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
|
||||
@@ -73,9 +155,10 @@ const achievementLevel = computed(() => {
|
||||
return 'red'
|
||||
})
|
||||
|
||||
/** 金额统一折算为万元(保留 1 位小数),不做任何兜底造数 */
|
||||
function fmtWan(v: number): string {
|
||||
return (v / 10000).toLocaleString('zh-CN', { maximumFractionDigits: 1, minimumFractionDigits: 0 })
|
||||
/** 千分位展示;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>
|
||||
|
||||
@@ -94,11 +177,26 @@ function fmtWan(v: number): string {
|
||||
padding: 10px 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
.sum-card--wide { grid-column: span 2; }
|
||||
.sum-head { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.sum-label {
|
||||
font-size: 12px;
|
||||
color: var(--bh-text-secondary, #64748B);
|
||||
letter-spacing: .02em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sum-badge {
|
||||
font-size: 10px;
|
||||
line-height: 1.5;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.sum-badge.calm { color: #635BFF; background: rgba(99, 91, 255, .08); }
|
||||
.sum-badge.warn { color: #B45309; background: #FEF3C7; }
|
||||
.sum-badge.warn em { font-style: normal; }
|
||||
.sum-value {
|
||||
margin-top: 4px;
|
||||
font-size: 22px;
|
||||
@@ -110,7 +208,7 @@ function fmtWan(v: number): string {
|
||||
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; color: var(--bh-text-secondary, #64748B); }
|
||||
.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;
|
||||
@@ -119,11 +217,38 @@ function fmtWan(v: number): string {
|
||||
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;
|
||||
gap: 6px 16px;
|
||||
padding: 6px 12px;
|
||||
border: 1px dashed var(--bh-border, #E5E7EB);
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--bh-text-muted, #94A3B8);
|
||||
}
|
||||
.mgmt { display: inline-flex; align-items: baseline; gap: 6px; }
|
||||
.mgmt-label { color: var(--bh-text-secondary, #64748B); }
|
||||
.mgmt-value { font-size: 13px; font-weight: 700; color: var(--bh-text-primary, #0F172A); }
|
||||
.mgmt-tag { font-size: 10px; padding: 1px 5px; border-radius: 4px; background: #F1F5F9; color: #475569; }
|
||||
.mgmt-abnormal { 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; }
|
||||
.lv-green { color: #00C896; }
|
||||
.lv-yellow { color: #F59E0B; }
|
||||
.lv-red { color: #EF4444; }
|
||||
@media (max-width: 1200px) {
|
||||
.map-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.sum-card--wide { grid-column: span 2; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -26,7 +26,10 @@
|
||||
/>
|
||||
|
||||
<MapSummaryBar
|
||||
:finance="financeSummary"
|
||||
:revenue="summaryData ? summaryData.revenue : null"
|
||||
:gross-margin="summaryData ? summaryData.gross_margin : null"
|
||||
:management="summaryData && summaryData.management ? summaryData.management : []"
|
||||
:switch-info="summaryData ? summaryData.switch : null"
|
||||
:achievement="overallAchievement"
|
||||
:achievement-count="achievementCount"
|
||||
:summary="reviewSummary"
|
||||
@@ -377,7 +380,7 @@ const selectedNode = ref<{ dimKey: string; index: number; obj: any } | null>(nul
|
||||
const selectedKey = computed(() => (selectedNode.value ? `${selectedNode.value.dimKey}-${selectedNode.value.index}` : null))
|
||||
|
||||
const reviewSummary = ref<any>(null)
|
||||
const financeSummary = ref<any>(null)
|
||||
const summaryData = ref<any>(null)
|
||||
const trendMap = reactive<Record<string, { dir: 'up' | 'down' | 'flat' | null; deltaPct: number | null; period?: string | null }>>({})
|
||||
const trendLoading = ref(false)
|
||||
|
||||
@@ -1828,18 +1831,16 @@ function goKpiLibrary() {
|
||||
window.location.href = '/kpis'
|
||||
}
|
||||
|
||||
/** KPI 摘要条(营收/毛利率):来源 /dashboard/finance-analysis,缺失即 null(不造数) */
|
||||
/**
|
||||
* KPI 摘要条(分业务线营收 / 新式毛利率 / 管理指标):来源 /maps/summary
|
||||
* 口径 CMA-A1 终稿;缺失即 null(不造数)。
|
||||
*/
|
||||
async function loadFinanceSummary() {
|
||||
try {
|
||||
const r: any = await api.get('/dashboard/finance-analysis')
|
||||
const s = r?.summary || {}
|
||||
financeSummary.value = {
|
||||
total_sales: s.total_sales ?? null,
|
||||
gross_profit_rate: s.gross_profit_rate ?? null,
|
||||
period: r?.period || '',
|
||||
}
|
||||
const r: any = await api.get('/maps/summary')
|
||||
summaryData.value = r || null
|
||||
} catch {
|
||||
financeSummary.value = null
|
||||
summaryData.value = null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user