feat(dashboard): 经营看板「本量利·保本点」卡片 + 只读BEP概览端点 (cma-bep-kpi Phase2)

- 新增 GET /api/cma/dashboard/bep-card:账面/现金双口径只读数据
  (5001月销113.6/5602管理费26.1/折旧560213 7.51/渠补率84.61%)
  账面: 保本169.9万 覆盖率66.9%(黄) 缺口33.1%; 现金: 保本121.0万 覆盖率93.9%
- 新增 components/BEPBreakEvenCard.vue:主指标覆盖率红黄绿圆点+副指标
  并排+下钻面板(公式/输入KPI/双口径切换/解读),A区只读
- Dashboard.vue CEO/财务视图顶部引入卡片
This commit is contained in:
Hermes CI Fix
2026-09-08 13:58:01 +08:00
parent 1a5356a2d1
commit 9eb38cfa89
3 changed files with 357 additions and 1 deletions
@@ -0,0 +1,196 @@
<template>
<div v-if="card" class="bep-card-wrap">
<div class="bep-card" :class="'lvl-' + level" @click="expanded = !expanded">
<div class="bep-head">
<div class="bep-title">
<span class="bep-icon">⚖️</span>
<span>本量利 · 保本点</span>
</div>
<div class="bep-meta">
<span class="bep-period">{{ fmtPeriod(card.period) }}</span>
<span class="bep-expand-hint">{{ expanded ? '收起 ▲' : '下钻 ▼' }}</span>
</div>
</div>
<div class="bep-body">
<!-- 主指标:保本覆盖率 -->
<div class="bep-main">
<div class="bep-cover">
<span class="dot" :class="'dot-' + level"></span>
<span class="bep-cover-num">{{ current.cover.toFixed(1) }}</span>
<span class="bep-cover-unit">%</span>
</div>
<div class="bep-cover-label">保本覆盖率<span class="lvl-text">{{ lvlText }}</span></div>
</div>
<!-- 副指标并排 -->
<div class="bep-sub-grid">
<div class="bep-sub">
<div class="sub-val">{{ current.bep_revenue.toFixed(1) }}</div>
<div class="sub-label">月保本销售额(万)</div>
</div>
<div class="bep-sub">
<div class="sub-val">{{ inputs.sales_avg.toFixed(1) }}</div>
<div class="sub-label">当前月销(万)</div>
</div>
<div class="bep-sub">
<div class="sub-val" :style="{ color: gapColor }">{{ current.gap.toFixed(1) }}%</div>
<div class="sub-label">缺口</div>
</div>
</div>
<!-- 辅助行 -->
<div class="bep-formula-line">
固定成本 {{ current.admin_avg.toFixed(1) }}万 ÷ 边际贡献率 {{ inputs.cm_pct.toFixed(2) }}%
<span v-if="mode === 'cash'" class="tag-cash">现金口径</span>
<span v-else class="tag-book">账面口径</span>
</div>
</div>
</div>
<!-- 下钻面板 -->
<div v-if="expanded" class="bep-detail">
<!-- 口径切换 -->
<div class="detail-mode">
<el-radio-group v-model="mode" size="small">
<el-radio-button value="book">账面口径</el-radio-button>
<el-radio-button value="cash">现金口径</el-radio-button>
</el-radio-group>
<span class="mode-note">{{ modeNote }}</span>
</div>
<div class="detail-grid">
<div class="detail-col">
<div class="detail-title">📐 公式</div>
<div class="detail-line">{{ card.formula }}</div>
<div class="detail-line sub">账面:{{ inputs.admin_avg.toFixed(1) }}万 ÷ {{ inputs.cm_pct.toFixed(2) }}% = {{ current.bep_revenue.toFixed(1) }}万</div>
<div v-if="mode === 'cash'" class="detail-line sub">
现金:管理费{{ inputs.admin_avg.toFixed(1) }} − 折旧{{ inputs.dep_avg.toFixed(1) }} = {{ current.admin_avg.toFixed(1) }}万 ÷ {{ inputs.cm_pct.toFixed(2) }}%
</div>
</div>
<div class="detail-col">
<div class="detail-title">🔍 解读</div>
<div class="detail-line">{{ insight }}</div>
</div>
</div>
<div class="detail-kpis">
<div class="detail-title">📊 输入KPI</div>
<div class="kpi-chips">
<span class="kpi-chip">月管理费 <b>{{ inputs.admin_avg.toFixed(1) }}</b>万</span>
<span class="kpi-chip">月收入(5001) <b>{{ inputs.sales_avg.toFixed(1) }}</b>万</span>
<span class="kpi-chip">渠补率 <b>{{ inputs.rebate_pct.toFixed(2) }}</b>%</span>
<span class="kpi-chip">折旧 <b>{{ inputs.dep_avg.toFixed(1) }}</b>万/月</span>
</div>
<div class="kpi-src-note">数据源:voucher_details 凭证聚合(YTD月均口径)· {{ card.period }} · 纯只读</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import api from '../api/index'
const card = ref<any>(null)
const expanded = ref(false)
const mode = ref<'book' | 'cash'>('book')
async function load() {
try {
const r: any = await api.get('/dashboard/bep-card')
card.value = (r && (r.data || r)) || null
if (!card.value?.available) card.value = null
} catch (e) {
console.error('加载保本点卡片失败', e)
card.value = null
}
}
const inputs = computed(() => card.value?.inputs || { sales_avg: 0, admin_avg: 0, dep_avg: 0, rebate_pct: 0, cm_pct: 0 })
const current = computed(() => {
const d = card.value?.[mode.value === 'cash' ? 'cash' : 'book'] || { bep_revenue: 0, cover: 0, gap: 0, admin_avg: 0 }
return {
...d,
admin_avg: mode.value === 'cash' ? (card.value?.cash?.admin_cash ?? 0) : inputs.value.admin_avg,
}
})
const level = computed(() => current.value.cover_level || 'none')
const lvlText = computed(() => ({ green: '已保本', yellow: '未达保本', red: '远低保本' } as any)[level.value] || '—')
const gapColor = computed(() => (current.value.gap <= 0 ? '#67c23a' : current.value.gap <= 50 ? '#e6a23c' : '#f56c6c'))
const modeNote = computed(() =>
mode.value === 'cash'
? '固定成本=管理费−折旧(现金视角)'
: '固定成本=管理费(含折旧,账面视角)'
)
const insight = computed(() => {
const sales = inputs.value.sales_avg || 0
const bep = current.value.bep_revenue || 0
const gap = current.value.gap || 0
if (!bep) return '暂无数据'
const need = bep - sales
const pct = sales > 0 ? (need / sales) * 100 : 0
if (gap <= 0) return `当前月销 ${sales.toFixed(1)}万 已超过保本线 ${bep.toFixed(1)}万,实现保本(余量 ${(-gap).toFixed(1)}%)。`
return `当前月销距保本差 ${gap.toFixed(1)}%,月销需达 ${bep.toFixed(1)}万(再增 ${need.toFixed(1)}万 / +${pct.toFixed(0)}%)。`
})
function fmtPeriod(p: string): string {
if (!p) return ''
const m = /^(\d{4})-(\d{1,2})/.exec(p)
if (!m) return p
const now = new Date()
return (Number(m[1]) === now.getFullYear() ? '' : m[1] + '年') + Number(m[2]) + '月'
}
onMounted(load)
</script>
<style scoped>
.bep-card-wrap { margin-bottom: 18px; }
.bep-card {
background: #fff; border-radius: 12px; padding: 16px 20px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
border-left: 4px solid #e6a23c; cursor: pointer; transition: box-shadow .15s, transform .15s;
}
.bep-card.lvl-green { border-left-color: #67c23a; }
.bep-card.lvl-yellow { border-left-color: #e6a23c; }
.bep-card.lvl-red { border-left-color: #f56c6c; }
.bep-card:hover { box-shadow: 0 4px 14px rgba(0,0,0,0.1); transform: translateY(-1px); }
.bep-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.bep-title { font-size: 15px; font-weight: 600; color: #1a1a2e; display: flex; align-items: center; gap: 8px; }
.bep-icon { font-size: 17px; }
.bep-meta { display: flex; align-items: center; gap: 10px; font-size: 12px; color: #999; }
.bep-expand-hint { color: #409eff; }
.bep-body { display: flex; align-items: center; gap: 26px; flex-wrap: wrap; }
.bep-main { display: flex; align-items: center; gap: 10px; min-width: 150px; }
.bep-cover { display: flex; align-items: baseline; gap: 4px; }
.bep-cover-num { font-size: 42px; font-weight: 800; color: #1a1a2e; letter-spacing: -1px; line-height: 1; }
.bep-cover-unit { font-size: 15px; color: #888; }
.dot { width: 12px; height: 12px; border-radius: 50%; display: inline-block; }
.dot-green { background: #67c23a; } .dot-yellow { background: #e6a23c; } .dot-red { background: #f56c6c; } .dot-none { background: #ccc; }
.bep-cover-label { font-size: 12px; color: #888; display: flex; flex-direction: column; gap: 3px; }
.lvl-text { color: #e6a23c; font-weight: 600; }
.lvl-green .lvl-text { color: #67c23a; } .lvl-red .lvl-text { color: #f56c6c; }
.bep-sub-grid { display: flex; gap: 22px; }
.bep-sub { text-align: left; }
.sub-val { font-size: 19px; font-weight: 700; color: #1a1a2e; }
.sub-label { font-size: 11px; color: #999; margin-top: 2px; }
.bep-formula-line { margin-left: auto; font-size: 12px; color: #777; background: #f7f8fa; padding: 5px 10px; border-radius: 6px; }
.tag-cash { color: #67c23a; margin-left: 4px; font-weight: 600; }
.tag-book { color: #409eff; margin-left: 4px; font-weight: 600; }
/* 下钻 */
.bep-detail { background: #fafbfc; border: 1px solid #eee; border-radius: 0 0 12px 12px; padding: 14px 18px; margin-top: 2px; }
.detail-mode { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
.mode-note { font-size: 12px; color: #999; }
.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 12px; }
.detail-col { background: #fff; border: 1px solid #f0f0f0; border-radius: 8px; padding: 10px 12px; }
.detail-title { font-size: 13px; font-weight: 600; color: #1a1a2e; margin-bottom: 6px; }
.detail-line { font-size: 13px; color: #444; line-height: 1.7; }
.detail-line.sub { font-size: 12px; color: #888; }
.detail-kpis { background: #fff; border: 1px solid #f0f0f0; border-radius: 8px; padding: 10px 12px; }
.kpi-chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; }
.kpi-chip { font-size: 12px; background: #f0f7ff; color: #3a6ea5; padding: 4px 10px; border-radius: 12px; }
.kpi-chip b { color: #1a1a2e; }
.kpi-src-note { font-size: 11px; color: #bbb; margin-top: 8px; }
@media (max-width: 900px) { .detail-grid { grid-template-columns: 1fr; } .bep-formula-line { margin-left: 0; } }
</style>