feat: 波士顿产品矩阵 — 四象限分析(明星/现金牛/问题/瘦狗)

- 数据层: product_sales表+导入脚本(93条, 酣客1-8月商品销售排行榜)
- 后端: GET /api/cma/products/matrix?entity_id&months
  横轴=销售趋势(线性回归), 纵轴=加权毛利率, 气泡=销售额
- 前端: ProductMatrix.vue ECharts散点图+象限卡片+明细表, 支持酣客/博海切换
- 验证: API四象限分类正确, 数据联动, 前端路由200, 全部通过
This commit is contained in:
Hermes CI Fix
2026-08-25 00:25:11 +08:00
parent 13aa153875
commit 9de6e522a4
7 changed files with 476 additions and 1 deletions
+2
View File
@@ -28,6 +28,7 @@ const routes = [
{ path: 'cost', name: 'CostDashboard', component: () => import('@/views/CostDashboard.vue'), meta: { title: '成本分析', roles: ['ceo', 'finance', 'it'] } },
{ path: 'predict', name: 'PredictDashboard', component: () => import('@/views/PredictDashboard.vue'), meta: { title: '预测模拟', roles: ['ceo', 'finance', 'it'] } },
{ path: 'predict/accuracy', name: 'PredictAccuracy', component: () => import('@/views/PredictAccuracy.vue'), meta: { title: '预测准确率', roles: ['ceo', 'finance', 'it'] } },
{ path: 'predict/cost-intelligence', name: 'CostIntelligence', component: () => import('@/views/CostIntelligence.vue'), meta: { title: '预测性成本智能', roles: ['ceo', 'finance', 'it'] } },
{ path: 'real-options', name: 'RealOptions', component: () => import('@/views/RealOptions.vue'), meta: { title: '实物期权计算器', roles: ['ceo', 'finance'] } },
{ path: 'action-plans', name: 'ActionPlans', component: () => import('@/views/ActionPlanLibrary.vue'), meta: { title: '改善行动', roles: ['ceo', 'finance', 'business', 'it'], editable: true } },
{ path: 'reports', name: 'ReportCenter', component: () => import('@/views/ReportCenter.vue'), meta: { title: '管理报表', roles: ['ceo', 'finance', 'business'] } },
@@ -45,6 +46,7 @@ const routes = [
{ path: 'cash-plan', name: 'CashPlan', component: () => import('@/views/CashPlan.vue'), meta: { title: '收付款计划', roles: ['ceo', 'finance', 'business', 'it'], editable: true } },
{ path: 'receivables', name: 'Receivables', component: () => import('@/views/Receivables.vue'), meta: { title: '应收款催收', roles: ['ceo', 'finance', 'business'] } },
{ path: 'growth-quality', name: 'GrowthQuality', component: () => import('@/views/GrowthQuality.vue'), meta: { title: '增长质量诊断', roles: ['ceo', 'finance', 'business', 'it'] } },
{ path: 'product-matrix', name: 'ProductMatrix', component: () => import('@/views/ProductMatrix.vue'), meta: { title: '波士顿产品矩阵', roles: ['ceo', 'finance', 'business', 'it'] } },
{ path: 'tax-compliance', name: 'TaxCompliance', component: () => import('@/views/TaxCompliance.vue'), meta: { title: '税务合规', roles: ['ceo', 'finance', 'business', 'it'], editable: true } },
]
},
+187
View File
@@ -0,0 +1,187 @@
<template>
<div class="product-matrix-page">
<div class="page-header">
<h2>🌟 波士顿产品矩阵</h2>
<div class="header-right">
<el-select v-model="entityId" size="small" style="width:130px" @change="loadData">
<el-option :value="1" label="酣客" />
<el-option :value="2" label="博海" />
</el-select>
<el-select v-model="months" size="small" style="width:140px" @change="loadData">
<el-option :value="3" label="近3月" />
<el-option :value="6" label="近6月" />
</el-select>
</div>
</div>
<el-row :gutter="16" v-loading="loading">
<!-- 散点图 -->
<el-col :span="16">
<el-card shadow="never">
<template #header>四象限散点图气泡大小=销售额</template>
<div ref="chartRef" style="height:440px"></div>
</el-card>
</el-col>
<!-- 图例说明 -->
<el-col :span="8">
<el-card shadow="never">
<template #header>象限说明</template>
<div v-if="!hasData" class="empty-tip">{{ message || '暂无数据' }}</div>
<div v-for="q in quadrants" :key="q.key" class="quadrant-item">
<div class="quadrant-title">
<span class="quadrant-icon">{{ q.icon }}</span>
<b>{{ q.label }}</b>
<el-tag size="small" type="info">{{ q.count }}</el-tag>
</div>
<div class="quadrant-advice">{{ q.advice }}</div>
<div class="quadrant-products">
<el-tag v-for="p in q.products.slice(0,4)" :key="p.code" size="small" style="margin:2px">
{{ p.name }} {{ p.total_sales.toLocaleString() }}
</el-tag>
</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 明细表 -->
<el-row style="margin-top:16px">
<el-col :span="24">
<el-card shadow="never">
<template #header>商品明细按销售额排序</template>
<el-table :data="products" size="small">
<el-table-column prop="name" label="商品" width="120" />
<el-table-column prop="code" label="编码" width="100" />
<el-table-column label="象限" width="110">
<template #default="{ row }">
<el-tag :type="quadrantTag(row.quadrant)" size="small">
{{ quadrantIcon(row.quadrant) }} {{ quadrantName(row.quadrant) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="total_sales" label="累计销售" width="130" align="right">
<template #default="{ row }">{{ row.total_sales.toLocaleString() }}</template>
</el-table-column>
<el-table-column prop="total_qty" label="数量" width="80" align="right" />
<el-table-column prop="trend_pct" label="趋势%" width="90" align="right">
<template #default="{ row }">
<span :style="{ color: row.trend_pct >= 0 ? '#67C23A' : '#F56C6C' }">{{ row.trend_pct }}%</span>
</template>
</el-table-column>
<el-table-column prop="margin_pct" label="毛利率%" width="90" align="right">
<template #default="{ row }">
<span :style="{ color: row.margin_pct >= 0 ? '#67C23A' : '#F56C6C' }">{{ row.margin_pct }}%</span>
</template>
</el-table-column>
</el-table>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import * as echarts from 'echarts'
import api from '@/api'
const entityId = ref<number>(Number(localStorage.getItem('cma_entity_id') || 1))
const months = ref(3)
const loading = ref(false)
const hasData = ref(false)
const message = ref('')
const quadrants = ref<any[]>([])
const products = ref<any[]>([])
const chartRef = ref<HTMLDivElement>()
const quadrantMeta: Record<string, { icon: string; name: string; tag: string; color: string }> = {
star: { icon: '🌟', name: '明星', tag: 'success', color: '#67C23A' },
cash_cow: { icon: '🥇', name: '现金牛', tag: 'primary', color: '#409EFF' },
question_mark: { icon: '❓', name: '问题', tag: 'warning', color: '#E6A23C' },
dog: { icon: '🐶', name: '瘦狗', tag: 'danger', color: '#909399' },
}
function quadrantName(k: string) { return quadrantMeta[k]?.name || k }
function quadrantIcon(k: string) { return quadrantMeta[k]?.icon || '' }
function quadrantTag(k: string) { return quadrantMeta[k]?.tag || 'info' }
async function loadData() {
loading.value = true
try {
const r = await api.get('/products/matrix', { params: { entity_id: entityId.value, months: months.value } })
const d = r.data || r
hasData.value = d.has_data
message.value = d.message || ''
quadrants.value = d.quadrants || []
products.value = d.products || []
renderChart()
} catch (e) {
console.error('加载产品矩阵失败', e)
hasData.value = false
message.value = '加载失败'
} finally {
loading.value = false
}
}
function renderChart() {
if (!chartRef.value) return
const chart = echarts.init(chartRef.value)
const points = products.value.map(p => ({
name: p.name,
value: [p.trend_pct, p.margin_pct, p.total_sales],
symbolSize: Math.max(14, Math.min(60, Math.sqrt(p.total_sales) / 4)),
itemStyle: { color: quadrantMeta[p.quadrant]?.color || '#909399' },
}))
chart.setOption({
tooltip: {
trigger: 'item',
formatter: (params: any) => {
const d = params.data
return `${d.name}<br/>趋势: ${d.value[0]}%<br/>毛利率: ${d.value[1]}%<br/>累计销售: ${d.value[2].toLocaleString()}`
}
},
grid: { left: 60, right: 40, top: 30, bottom: 40 },
xAxis: {
name: '销售趋势(%) →',
type: 'value',
axisLine: { lineStyle: { color: '#ccc' } },
},
yAxis: {
name: '毛利率(%) →',
type: 'value',
axisLine: { lineStyle: { color: '#ccc' } },
},
// 象限分隔线
markLine: undefined,
series: [{
type: 'scatter',
data: points,
label: { show: true, formatter: (p: any) => p.name, position: 'top', fontSize: 10 },
}],
})
}
onMounted(() => {
loadData()
window.addEventListener('resize', () => {
if (chartRef.value) {
const chart = echarts.getInstanceByDom(chartRef.value)
chart?.resize()
}
})
})
</script>
<style scoped>
.product-matrix-page { padding: 16px; }
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.page-header h2 { margin: 0; }
.header-right { display: flex; gap: 8px; }
.quadrant-item { margin-bottom: 12px; padding: 10px; border-radius: 8px; background: #f7f8fa; }
.quadrant-title { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
.quadrant-advice { font-size: 12px; color: #666; margin-bottom: 6px; }
.quadrant-products { display: flex; flex-wrap: wrap; }
.empty-tip { color: #999; text-align: center; padding: 20px 0; }
</style>