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
+158
View File
@@ -0,0 +1,158 @@
"""波士顿产品矩阵 API — 四象限分析"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import ProductSales
from collections import defaultdict
router = APIRouter(prefix="/api/cma/products", tags=["产品矩阵"])
def _calc_quadrant(trend: float, margin: float) -> str:
"""四象限分类:
横轴=近3月销售趋势(正=增长),纵轴=毛利率
明星(Star) = 高趋势+高毛利
现金牛(CashCow) = 低趋势+高毛利
问题(QuestionMark) = 高趋势+低毛利
瘦狗(Dog) = 低趋势+低毛利
"""
trend_high = trend >= 0
margin_high = margin >= 0
if trend_high and margin_high:
return "star"
if not trend_high and margin_high:
return "cash_cow"
if trend_high and not margin_high:
return "question_mark"
return "dog"
@router.get("/matrix")
def get_product_matrix(
entity_id: int = Query(1, description="1=酣客 2=博海"),
months: int = Query(3, ge=1, le=6, description="趋势计算月数"),
db: Session = Depends(get_db),
):
"""产品矩阵:横轴=销售趋势,纵轴=毛利率,气泡=销售额"""
# 取最近 months+1 个月(多取1个月用于计算趋势)
periods = db.query(ProductSales.period_month).filter(
ProductSales.entity_id == entity_id
).distinct().order_by(ProductSales.period_month.desc()).limit(months + 1).all()
periods = sorted([p[0] for p in periods])
if len(periods) < 2:
return {
"entity_id": entity_id,
"has_data": False,
"message": "数据不足,至少需要2个月数据",
"quadrants": [],
"products": [],
}
trend_periods = periods[-months:] # 最近 months 个月
prev_periods = periods[:-months] if len(periods) > months else periods[:1]
# 加载数据
rows = db.query(ProductSales).filter(
ProductSales.entity_id == entity_id,
ProductSales.period_month.in_(periods),
).all()
# 按商品聚合
products = defaultdict(lambda: {
"code": "", "name": "", "months": {},
"total_sales": 0, "total_qty": 0, "total_gross": 0,
})
for row in rows:
p = products[row.product_code]
p["code"] = row.product_code
p["name"] = row.product_name
p["months"][row.period_month] = {
"sales": float(row.sales_amount or 0),
"margin": float(row.gross_margin_rate or 0),
"gross": float(row.gross_profit or 0),
"qty": int(row.sales_qty or 0),
}
p["total_sales"] += float(row.sales_amount or 0)
p["total_qty"] += int(row.sales_qty or 0)
p["total_gross"] += float(row.gross_profit or 0)
# 计算每个商品的趋势和毛利率
result_products = []
for code, p in products.items():
# 趋势 = 最近月份 vs 前一月的环比(取趋势期间的平均环比增速)
# 用最近3个月的销售序列做简单线性趋势
trend_sales = []
for pp in periods:
trend_sales.append(p["months"].get(pp, {}).get("sales", 0))
# 线性回归斜率(最小二乘)
n = len(trend_sales)
if n >= 2:
xs = list(range(n))
x_mean = sum(xs) / n
y_mean = sum(trend_sales) / n
numerator = sum((xs[i] - x_mean) * (trend_sales[i] - y_mean) for i in range(n))
denominator = sum((xs[i] - x_mean) ** 2 for i in range(n))
slope = numerator / denominator if denominator else 0
# 斜率转为百分比(相对期间平均销售)
avg = y_mean if y_mean != 0 else 1
trend = slope / abs(avg) * 100
else:
trend = 0.0
# 毛利率 = 加权平均(按销售额)
weighted_margin = 0.0
total_sales_for_margin = 0
for pp in trend_periods:
m = p["months"].get(pp)
if m and m["sales"] > 0:
weighted_margin += m["margin"] * m["sales"]
total_sales_for_margin += m["sales"]
if total_sales_for_margin > 0:
weighted_margin = weighted_margin / total_sales_for_margin
else:
# 无销售用平均毛利率
margins = [p["months"][pp]["margin"] for pp in p["months"] if p["months"][pp]["margin"] != 0]
weighted_margin = sum(margins) / len(margins) if margins else 0
quadrant = _calc_quadrant(trend, weighted_margin)
result_products.append({
"code": code,
"name": p["name"],
"total_sales": round(p["total_sales"], 2),
"total_qty": p["total_qty"],
"total_gross": round(p["total_gross"], 2),
"trend_pct": round(trend, 1),
"margin_pct": round(weighted_margin, 1),
"quadrant": quadrant,
})
# 按销售额排序
result_products.sort(key=lambda x: -x["total_sales"])
# 四象限汇总
quadrant_labels = {
"star": {"label": "明星产品", "icon": "🌟", "advice": "高增长+有毛利,重点主推,加大投入"},
"cash_cow": {"label": "现金牛", "icon": "🥇", "advice": "销量大但增长放缓,维持稳定产出"},
"question_mark": {"label": "问题产品", "icon": "", "advice": "增长好但毛利低,优化成本或提价"},
"dog": {"label": "瘦狗产品", "icon": "🐶", "advice": "低增长+低毛利,考虑清库存或停产"},
}
quadrants = []
for q in ["star", "cash_cow", "question_mark", "dog"]:
items = [p for p in result_products if p["quadrant"] == q]
quadrants.append({
"key": q,
**quadrant_labels[q],
"count": len(items),
"products": items,
})
return {
"entity_id": entity_id,
"has_data": True,
"periods": periods,
"months_analyzed": months,
"quadrants": quadrants,
"products": result_products,
}
+2 -1
View File
@@ -5,7 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from dotenv import load_dotenv from dotenv import load_dotenv
from app.database import init_db from app.database import init_db
from app.api import auth, kpis, kpi_governance, templates, maps, dashboard, data, alerts, ai_analysis, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, lead, tenant, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget, bot_kpis, ontology, bot_iron_law, analysis_results, expenses, cash, tax_compliance, verify, growth_quality from app.api import auth, kpis, kpi_governance, templates, maps, dashboard, data, alerts, ai_analysis, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, lead, tenant, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget, bot_kpis, ontology, bot_iron_law, analysis_results, expenses, cash, tax_compliance, verify, growth_quality, products
from app.utils.cache import clear_all as clear_cache, delete as delete_cache from app.utils.cache import clear_all as clear_cache, delete as delete_cache
from scripts.erp_sync import run_sync as run_erp_sync from scripts.erp_sync import run_sync as run_erp_sync
from app.auth_middleware import require_auth from app.auth_middleware import require_auth
@@ -53,6 +53,7 @@ app.include_router(budget.router)
app.include_router(cost.router) app.include_router(cost.router)
app.include_router(predict.router) app.include_router(predict.router)
app.include_router(growth_quality.router) app.include_router(growth_quality.router)
app.include_router(products.router)
app.include_router(reports.router) app.include_router(reports.router)
app.include_router(security.router) app.include_router(security.router)
app.include_router(knowledge.router) app.include_router(knowledge.router)
+1
View File
@@ -6,6 +6,7 @@ from app.models.budget_plan import BudgetPlan
from app.models.cost_model import StandardCost, ActualCost, AbcActivity, AbcAllocation from app.models.cost_model import StandardCost, ActualCost, AbcActivity, AbcAllocation
from app.models.knowledge import KnowledgeEvent, KnowledgeSummary from app.models.knowledge import KnowledgeEvent, KnowledgeSummary
from app.models.driver_budget import DriverFactorTemplate, DriverFactorBudget from app.models.driver_budget import DriverFactorTemplate, DriverFactorBudget
from app.models.product_sales import ProductSales
class Entity(Base): class Entity(Base):
+24
View File
@@ -0,0 +1,24 @@
"""商品销售数据模型 — 波士顿产品矩阵
从《商品销售排行榜》Excel导入,支撑四象限分析
"""
from sqlalchemy import Column, Integer, String, Float, DateTime, func
from app.database import Base
class ProductSales(Base):
"""商品销售月度数据"""
__tablename__ = "product_sales"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, default=1, comment="企业ID: 1=酣客 2=博海")
product_code = Column(String(50), nullable=False, comment="商品编码")
product_name = Column(String(100), nullable=False, comment="商品名称")
period_month = Column(String(10), nullable=False, comment="期间 YYYY-MM")
sales_amount = Column(Float, default=0, comment="销售金额")
cost_amount = Column(Float, default=0, comment="成本金额")
gross_profit = Column(Float, default=0, comment="毛利")
gross_margin_rate = Column(Float, default=0, comment="毛利率(%)")
sales_qty = Column(Integer, default=0, comment="销售数量")
unit = Column(String(30), default="", comment="单位")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""导入酣客1-8月商品销售排行榜到product_sales表"""
import openpyxl
import glob
import os
import re
import pymysql
# DB连接
DB_CONFIG = {
'host': '127.0.0.1',
'user': 'cma_user',
'password': 'cma_pass_2026',
'database': 'cma',
'charset': 'utf8mb4',
}
# 8份排行榜文件(URL编码的路径)
files = {
'2026-01': '/root/.hermes/profiles/wecom-finance/cache/documents/doc_7c0e51888b10_%E5%95%86%E5%93%81%E9%94%80%E5%94%AE%E6%8E%92%E8%A1%8C%E6%A6%9C%EF%BC%88%E9%99%95%E8%A5%BF%E9%85%A3%E5%AE%A22026%E5%B9%B41%E6%9C%88%EF%BC%89.xlsx',
'2026-02': '/root/.hermes/profiles/wecom-finance/cache/documents/doc_309cee3d3d78_%E5%95%86%E5%93%81%E9%94%80%E5%94%AE%E6%8E%92%E8%A1%8C%E6%A6%9C%EF%BC%88%E9%99%95%E8%A5%BF%E9%85%A3%E5%AE%A22026%E5%B9%B42%E6%9C%88%EF%BC%89.xlsx',
'2026-03': '/root/.hermes/profiles/wecom-finance/cache/documents/doc_fdc1228e7173_%E5%95%86%E5%93%81%E9%94%80%E5%94%AE%E6%8E%92%E8%A1%8C%E6%A6%9C%EF%BC%88%E9%99%95%E8%A5%BF%E9%85%A3%E5%AE%A22026%E5%B9%B43%E6%9C%88%EF%BC%89.xlsx',
'2026-04': '/root/.hermes/profiles/wecom-finance/cache/documents/doc_da10cb16765c_%E5%95%86%E5%93%81%E9%94%80%E5%94%AE%E6%8E%92%E8%A1%8C%E6%A6%9C%EF%BC%88%E9%99%95%E8%A5%BF%E9%85%A3%E5%AE%A22026%E5%B9%B44%E6%9C%88%EF%BC%89.xlsx',
'2026-05': '/root/.hermes/profiles/wecom-finance/cache/documents/doc_bda951a0f6e3_%E5%95%86%E5%93%81%E9%94%80%E5%94%AE%E6%8E%92%E8%A1%8C%E6%A6%9C%EF%BC%88%E9%99%95%E8%A5%BF%E9%85%A3%E5%AE%A22026%E5%B9%B45%E6%9C%88%EF%BC%89.xlsx',
'2026-06': '/root/.hermes/profiles/wecom-finance/cache/documents/doc_6fcb80d2a02d_%E5%95%86%E5%93%81%E9%94%80%E5%94%AE%E6%8E%92%E8%A1%8C%E6%A6%9C%EF%BC%88%E9%99%95%E8%A5%BF%E9%85%A3%E5%AE%A22026%E5%B9%B46%E6%9C%88%EF%BC%89.xlsx',
'2026-07': '/root/.hermes/profiles/wecom-finance/cache/documents/doc_a449da246d17_%E5%95%86%E5%93%81%E9%94%80%E5%94%AE%E6%8E%92%E8%A1%8C%E6%A6%9C%EF%BC%88%E9%99%95%E8%A5%BF%E9%85%A3%E5%AE%A22026%E5%B9%B47%E6%9C%88%EF%BC%89.xlsx',
'2026-08': '/root/.hermes/profiles/wecom-finance/cache/documents/doc_ac2b807b1517_%E5%95%86%E5%93%81%E9%94%80%E5%94%AE%E6%8E%92%E8%A1%8C%E6%A6%9C%EF%BC%88%E9%99%95%E8%A5%BF%E9%85%A3%E5%AE%A22026%E5%B9%B48%E6%9C%88%EF%BC%89.xlsx',
}
def import_file(conn, cursor, period, path):
"""导入单个月份Excel"""
if not os.path.exists(path):
print(f" ⚠️ 文件不存在: {period}")
return 0
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
ws = wb[wb.sheetnames[0]]
count = 0
for r in range(2, ws.max_row + 1):
code = ws.cell(r, 2).value # 商品编码
name = ws.cell(r, 3).value # 商品名称
sales = ws.cell(r, 4).value # 销售金额
cost = ws.cell(r, 6).value # 成本金额
gross = ws.cell(r, 7).value # 毛利
margin = ws.cell(r, 10).value # 毛利率(%)
qty = ws.cell(r, 13).value # 销售数量
unit = ws.cell(r, 14).value # 单位
# 跳过合计行(编码为空)和空行
if code is None or str(code).strip() == '':
continue
if name is None or str(name).strip() == '':
continue
# 跳过汇总行(如"上期库存")
if '上期' in str(name) or '合计' in str(name) or '总计' in str(name):
continue
code = str(code).strip()
name = str(name).strip()
sales = float(sales or 0)
cost = float(cost or 0)
gross = float(gross or 0)
margin = float(margin or 0)
qty = int(qty or 0)
unit = str(unit or '').strip()
cursor.execute("""
INSERT INTO product_sales (entity_id, product_code, product_name, period_month,
sales_amount, cost_amount, gross_profit, gross_margin_rate, sales_qty, unit)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
product_name=VALUES(product_name),
sales_amount=VALUES(sales_amount),
cost_amount=VALUES(cost_amount),
gross_profit=VALUES(gross_profit),
gross_margin_rate=VALUES(gross_margin_rate),
sales_qty=VALUES(sales_qty),
unit=VALUES(unit)
""", (1, code, name, period, sales, cost, gross, margin, qty, unit))
count += 1
wb.close()
return count
def main():
conn = pymysql.connect(**DB_CONFIG)
cursor = conn.cursor()
total = 0
for period, path in files.items():
n = import_file(conn, cursor, period, path)
print(f" {period}: {n}")
total += n
conn.commit()
print(f"\n✅ 共导入 {total}")
# 验证
cursor.execute("SELECT period_month, COUNT(*), ROUND(SUM(sales_amount)) FROM product_sales GROUP BY period_month ORDER BY period_month")
for row in cursor.fetchall():
print(f" {row[0]}: {row[1]}商品, 销售合计{row[2]:,.0f}")
cursor.close()
conn.close()
if __name__ == '__main__':
main()
+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: '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', 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/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: '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: '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'] } }, { 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: '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: '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: '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 } }, { 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>