feat: 波士顿产品矩阵 — 四象限分析(明星/现金牛/问题/瘦狗)
- 数据层: product_sales表+导入脚本(93条, 酣客1-8月商品销售排行榜) - 后端: GET /api/cma/products/matrix?entity_id&months 横轴=销售趋势(线性回归), 纵轴=加权毛利率, 气泡=销售额 - 前端: ProductMatrix.vue ECharts散点图+象限卡片+明细表, 支持酣客/博海切换 - 验证: API四象限分类正确, 数据联动, 前端路由200, 全部通过
This commit is contained in:
@@ -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 } },
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user