feat: 增长质量诊断模块 — 后端评分API+前端驾驶舱卡片+雷达图+明细下钻+双实体对比

- 新增 POST /api/cma/predict/growth-quality 评分API
  - 五维度评分引擎(营收结构/利润结构/现金资产/增长驱动/组织效率)
  - 自动诊断结论生成
  - 明细指标+改善建议
  - 双实体对比模式

- 前端 GrowthQuality.vue 组件
  - 综合评分大字展示
  - 五维度评分条 + 状态颜色
  - ECharts雷达图
  - 明细下钻弹窗(指标+建议)
  - 双实体并列对比(VS模式)

- 集成到 Dashboard.vue CEO驾驶舱视图
- 酣客测试验证:综合1.4分(越增长越重)
- 博海对比验证:综合3.6分(增长质量中等)
This commit is contained in:
Hermes CI Fix
2026-07-19 10:46:43 +08:00
parent 2d471aa07b
commit f85c3d2c4e
5 changed files with 753 additions and 0 deletions
+272
View File
@@ -331,3 +331,275 @@ def api_real_option(data: dict):
raise
except Exception as e:
raise HTTPException(400, f"实物期权计算失败: {str(e)}")
# ── 增长质量诊断 ─────────────────────────────────────────────
def _score_revenue_structure(entity: dict) -> int:
"""营收结构评分:渠补率越低越好"""
rebate_rate = float(entity.get("rebateRate", entity.get("rebate_rate", 0)))
if rebate_rate > 80: return 1
if rebate_rate > 60: return 2
if rebate_rate > 40: return 3
if rebate_rate > 20: return 4
return 5
def _score_profit_structure(entity: dict) -> int:
"""利润结构评分:真实毛利率越高越好"""
gross_margin = float(entity.get("trueGrossMargin", entity.get("true_gross_margin", 0)))
if gross_margin < 0: return 1
if gross_margin < 10: return 2
if gross_margin < 20: return 3
if gross_margin < 30: return 4
return 5
def _score_cash_assets(entity: dict) -> int:
"""现金资产评分:现金比率越高越好"""
cash_ratio = float(entity.get("cashRatio", entity.get("cash_ratio", 0)))
if cash_ratio < 10: return 1
if cash_ratio < 30: return 2
if cash_ratio < 50: return 3
if cash_ratio < 100: return 4
return 5
def _score_growth_driver(entity: dict) -> int:
"""增长驱动评分:费用增速相对收入增速越低越好"""
expense_growth = float(entity.get("expenseGrowthRate", entity.get("expense_growth_rate", 0)))
revenue_growth = float(entity.get("revenueGrowthRate", entity.get("revenue_growth_rate", 1)))
if revenue_growth <= 0: revenue_growth = 1 # prevent div by zero
ratio = expense_growth / revenue_growth
if ratio > 1.5: return 1
if ratio > 1.2: return 2
if ratio > 1.0: return 3
if ratio > 0.8: return 4
return 5
def _score_org_efficiency(entity: dict) -> int:
"""组织效率评分:管理费/净收入越低越好"""
mgmt_ratio = float(entity.get("mgmtRatio", entity.get("mgmt_ratio", 0)))
if mgmt_ratio > 300: return 1
if mgmt_ratio > 200: return 2
if mgmt_ratio > 100: return 3
if mgmt_ratio > 50: return 4
return 5
def _diagnosis_text(overall: float, dimensions: dict, entity_name: str) -> str:
"""根据评分生成诊断结论"""
lines = []
low_dims = {k: v for k, v in dimensions.items() if v["score"] <= 2}
mid_dims = {k: v for k, v in dimensions.items() if 2 < v["score"] < 4}
dim_labels = {
"revenueStructure": "营收结构",
"profitStructure": "利润结构",
"cashAssets": "现金资产",
"growthDriver": "增长驱动",
"orgEfficiency": "组织效率",
}
if overall < 2:
lines.append(f"{entity_name}的增长质量评分仅{overall}分,属于「越增长越重」类型。")
lines.append("增长主要依赖资源投入而非核心能力积累,可持续性堪忧。")
elif overall < 3:
lines.append(f"{entity_name}的增长质量评分{overall}分,需重点关注。")
lines.append("部分维度存在风险,增长质量有待改善。")
elif overall < 4:
lines.append(f"{entity_name}的增长质量评分{overall}分,处于中等水平。")
lines.append("多数维度表现尚可,仍有优化空间。")
else:
lines.append(f"{entity_name}的增长质量评分{overall}分,「越增长越轻」。")
lines.append("增长模式健康,具备持续增长能力。")
if low_dims:
low_names = [dim_labels.get(k, k) for k in low_dims]
lines.append(f"⚠️ 需重点关注:{''.join(low_names)}评分偏低(≤2分)。")
if mid_dims:
mid_names = [dim_labels.get(k, k) for k in mid_dims]
lines.append(f"💡 可优化:{''.join(mid_names)}有提升空间。")
# 具体建议(硬编码的关键诊断)
if dimensions.get("revenueStructure", {}).get("score", 5) <= 2:
lines.append("• 营收依赖渠道返利,建议降低渠补率、拓展直销渠道。")
if dimensions.get("orgEfficiency", {}).get("score", 5) <= 2:
lines.append("• 管理费率高企,建议精简费用结构、优化运营效率。")
if dimensions.get("cashAssets", {}).get("score", 5) <= 2:
lines.append("• 现金比率极低,存在断流风险,建议加强现金流管理。")
if dimensions.get("growthDriver", {}).get("score", 5) <= 2:
lines.append("• 费用增速远超收入增速,增长不可持续,需控制费用膨胀。")
return "\n".join(lines)
def _generate_improvement_suggestions(dimension: str, score: int, entity: dict) -> list:
"""为指定维度生成改善建议"""
suggestions = []
if dimension == "revenueStructure":
rebate = float(entity.get("rebateRate", entity.get("rebate_rate", 0)))
if score <= 2:
target_rebate = max(rebate - 10, 0)
savings = f"释放现金{round(rebate - target_rebate, 1)}%/月"
suggestions.append(f"渠补谈判:{rebate}%→{target_rebate}%{savings}")
suggestions.append("客户分散:拓展直销渠道,降低渠道依赖")
suggestions.append("渠补制度:分级管理,差异化返利")
else:
suggestions.append("维持现有渠补政策")
elif dimension == "profitStructure":
gm = float(entity.get("trueGrossMargin", entity.get("true_gross_margin", 0)))
if score <= 2:
suggestions.append(f"成本优化:毛利率仅{gm}%,需分析成本构成")
suggestions.append("产品结构:提高高毛利产品占比")
suggestions.append("定价策略:评估提价空间")
else:
suggestions.append("维持毛利率水平")
elif dimension == "cashAssets":
cr = float(entity.get("cashRatio", entity.get("cash_ratio", 0)))
if score <= 2:
suggestions.append(f"现金管理:现金比率仅{cr}%,存在断流风险")
suggestions.append("应收账款:加快回款周期")
suggestions.append("融资安排:准备短期授信额度")
else:
suggestions.append("维持现金流健康")
elif dimension == "growthDriver":
eg = float(entity.get("expenseGrowthRate", entity.get("expense_growth_rate", 0)))
if score <= 2:
suggestions.append(f"费用管控:费用增速{eg}倍于收入,需严控费用")
suggestions.append("预算管理:建立费用增长红线机制")
suggestions.append("投资回报:评估每项投入的ROI")
else:
suggestions.append("维持费用增长与收入增长匹配")
elif dimension == "orgEfficiency":
mr = float(entity.get("mgmtRatio", entity.get("mgmt_ratio", 0)))
if score <= 2:
suggestions.append(f"管理效率:管理费/净收入{mr}%,急需降本增效")
suggestions.append("组织精简:评估管理层级压缩空间")
suggestions.append("流程优化:推进数字化降本")
else:
suggestions.append("维持管理效率水平")
return suggestions
def _get_dim_detail_indicators(dimension: str, entity: dict) -> list:
"""获取维度的明细诊断指标"""
indicators = []
if dimension == "revenueStructure":
rebate = float(entity.get("rebateRate", entity.get("rebate_rate", 0)))
net_ratio = round(100 - rebate, 1)
indicators.append({"label": "渠补率", "value": f"{rebate}%",
"verdict": "收入依赖渠道返利" if rebate > 50 else "渠道依赖程度中等",
"status": "danger" if rebate > 50 else "warning" if rebate > 20 else "success"})
indicators.append({"label": "净收入占比", "value": f"{net_ratio}%",
"verdict": f"{net_ratio}%归公司" if net_ratio < 30 else "净收入占比合理",
"status": "danger" if net_ratio < 30 else "success"})
elif dimension == "profitStructure":
gm = float(entity.get("trueGrossMargin", entity.get("true_gross_margin", 0)))
indicators.append({"label": "真实毛利率", "value": f"{gm}%",
"verdict": "毛利偏低" if gm < 15 else "毛利正常",
"status": "danger" if gm < 10 else "warning" if gm < 20 else "success"})
elif dimension == "cashAssets":
cr = float(entity.get("cashRatio", entity.get("cash_ratio", 0)))
indicators.append({"label": "现金比率", "value": f"{cr}%",
"verdict": "断流风险" if cr < 5 else "现金紧张" if cr < 30 else "现金充足",
"status": "danger" if cr < 5 else "warning" if cr < 30 else "success"})
elif dimension == "growthDriver":
eg = float(entity.get("expenseGrowthRate", entity.get("expense_growth_rate", 0)))
rg = float(entity.get("revenueGrowthRate", entity.get("revenue_growth_rate", 1)))
ratio = eg / rg if rg > 0 else 99
indicators.append({"label": "费用增速/收入增速", "value": f"{ratio:.1f}",
"verdict": "费用增速过快" if ratio > 1.5 else "费用可控" if ratio > 1 else "增长健康",
"status": "danger" if ratio > 1.5 else "warning" if ratio > 1 else "success"})
elif dimension == "orgEfficiency":
mr = float(entity.get("mgmtRatio", entity.get("mgmt_ratio", 0)))
indicators.append({"label": "管理费/净收入", "value": f"{mr}%",
"verdict": "管理费极高" if mr > 200 else "管理费偏高" if mr > 100 else "管理费正常",
"status": "danger" if mr > 200 else "warning" if mr > 100 else "success"})
return indicators
@router.post("/growth-quality")
def api_growth_quality(data: dict):
"""增长质量诊断 — 五维度评分+综合评分+诊断结论"""
try:
entity = data.get("entity", data)
entity_name = entity.get("entity", entity.get("name", "该企业"))
period = entity.get("period", data.get("period", "当前"))
# 五维度评分
dim_scores = {
"revenueStructure": _score_revenue_structure(entity),
"profitStructure": _score_profit_structure(entity),
"cashAssets": _score_cash_assets(entity),
"growthDriver": _score_growth_driver(entity),
"orgEfficiency": _score_org_efficiency(entity),
}
overall = round(sum(dim_scores.values()) / 5, 1)
# 综合等级
if overall >= 4:
level = "🟢 越增长越轻"
level_type = "excellent"
elif overall >= 3:
level = "🟡 增长质量中等"
level_type = "medium"
elif overall >= 2:
level = "🟠 需关注"
level_type = "warning"
else:
level = "🔴 越增长越重"
level_type = "danger"
# 诊断结论
dimensions_payload = {}
detail_payload = {}
for dim, score in dim_scores.items():
dimensions_payload[dim] = {"score": score, "weight": 20}
detail_payload[dim] = {
"score": score,
"indicators": _get_dim_detail_indicators(dim, entity),
"suggestions": _generate_improvement_suggestions(dim, score, entity),
}
diagnosis = _diagnosis_text(overall, dimensions_payload, entity_name)
# 对比数据(如果请求中包含多个实体)
compare = data.get("compare", None)
compare_result = None
if compare:
compare_entity = compare
compare_name = compare_entity.get("entity", compare_entity.get("name", "对比企业"))
cdims = {
"revenueStructure": _score_revenue_structure(compare_entity),
"profitStructure": _score_profit_structure(compare_entity),
"cashAssets": _score_cash_assets(compare_entity),
"growthDriver": _score_growth_driver(compare_entity),
"orgEfficiency": _score_org_efficiency(compare_entity),
}
compare_overall = round(sum(cdims.values()) / 5, 1)
compare_result = {
"entity_name": compare_name,
"overall": compare_overall,
"dimensions": {k: {"score": v, "weight": 20} for k, v in cdims.items()},
"level": ("🟢 越增长越轻" if compare_overall >= 4 else
"🟡 增长质量中等" if compare_overall >= 3 else
"🟠 需关注" if compare_overall >= 2 else "🔴 越增长越重"),
}
return {
"entity_name": entity_name,
"period": period,
"overall": overall,
"level": level,
"level_type": level_type,
"dimensions": dimensions_payload,
"detail": detail_payload,
"diagnosis": diagnosis,
"compare": compare_result,
}
except Exception as e:
raise HTTPException(400, f"增长质量诊断失败: {str(e)}")
+1
View File
@@ -62,6 +62,7 @@ declare module 'vue' {
ElTree: typeof import('element-plus/es')['ElTree']
ElUpload: typeof import('element-plus/es')['ElUpload']
GaugeChart: typeof import('./src/components/charts/GaugeChart.vue')['default']
GrowthQuality: typeof import('./src/components/GrowthQuality.vue')['default']
KnowledgePanel: typeof import('./src/components/KnowledgePanel.vue')['default']
KPIListView: typeof import('./src/components/KPIListView.vue')['default']
MapCanvasDialogs: typeof import('./src/components/map-canvas/MapCanvasDialogs.vue')['default']
+1
View File
@@ -164,6 +164,7 @@ export const predictApi = {
investment: (data: any) => api.post('/predict/investment', data),
sensitivity: (data: any) => api.post('/predict/sensitivity', data),
scenario: (data: any) => api.post('/predict/scenario', data),
growthQuality: (data: any) => api.post('/predict/growth-quality', data),
}
export const deviationPushApi = {
+475
View File
@@ -0,0 +1,475 @@
<template>
<div class="growth-quality">
<!-- 实体选择 -->
<div class="gq-header">
<h3 class="gq-title">📊 增长质量诊断</h3>
<div class="gq-controls">
<el-select v-model="selectedEntityId" placeholder="选择企业" size="small" style="width:160px" @change="loadQuality">
<el-option v-for="e in entities" :key="e.id" :label="e.short_name || e.name" :value="e.id" />
</el-select>
<el-select v-model="compareEntityId" placeholder="对比企业(可选)" size="small" style="width:160px" clearable @change="loadQuality">
<el-option v-for="e in entities" :key="e.id" :label="e.short_name || e.name" :value="e.id" />
</el-select>
<el-button size="small" type="primary" @click="loadQuality" :loading="loading">诊断</el-button>
</div>
</div>
<!-- 主卡片区域 -->
<div v-if="result" class="gq-main">
<div class="gq-body">
<!-- 左侧综合评分 + 维度的详情 -->
<div class="gq-left">
<!-- 综合评分卡 -->
<div class="gq-score-card" :class="'level-' + result.level_type">
<div class="score-main">
<div class="score-big">{{ result.overall }}</div>
<div class="score-label">综合评分</div>
<div class="score-badge">{{ result.level }}</div>
</div>
</div>
<!-- 五维度评分条 -->
<div class="gq-dims">
<div v-for="(dim, key) in dimConfig" :key="key" class="gq-dim-row"
@click="openDetail(key)" style="cursor:pointer">
<div class="dim-label-row">
<span class="dim-icon">{{ dim.icon }}</span>
<span class="dim-name">{{ dim.label }}</span>
<span class="dim-score" :class="scoreClass(result.dimensions[key]?.score)">{{ starDisplay(result.dimensions[key]?.score) }}</span>
</div>
<div class="dim-bar-track">
<div class="dim-bar-fill" :class="scoreClass(result.dimensions[key]?.score)"
:style="{ width: (result.dimensions[key]?.score || 0) * 20 + '%' }"></div>
</div>
<div class="dim-pct">{{ (result.dimensions[key]?.score || 0) * 20 }}%</div>
</div>
</div>
</div>
<!-- 右侧雷达图 -->
<div class="gq-right">
<VChart :option="radarOption" class="gq-radar" autoresize />
</div>
</div>
<!-- 诊断结论 -->
<div class="gq-diagnosis" v-if="result.diagnosis">
<div class="diag-title">🔍 诊断结论</div>
<div class="diag-text">{{ result.diagnosis }}</div>
<div class="diag-actions">
<el-button size="small" @click="openDetail('revenueStructure')">查看明细</el-button>
<el-button size="small" v-if="!showCompare && entities.length >= 2" @click="enableCompare">双实体对比</el-button>
</div>
</div>
<!-- 双实体对比区域 -->
<div v-if="showCompare && compareResult" class="gq-compare">
<div class="compare-title">📊 增长质量对比</div>
<div class="compare-grid">
<div class="compare-col">
<div class="compare-entity-name">{{ result.entity_name }}</div>
<div class="compare-score" :class="'level-' + result.level_type">{{ result.overall }}</div>
<div class="compare-dims">
<div v-for="(dim, key) in dimConfig" :key="key" class="compare-dim">
<span class="cd-label">{{ dim.label }}</span>
<span class="cd-stars" :class="scoreClass(result.dimensions[key]?.score)">{{ starDisplay(result.dimensions[key]?.score) }}</span>
</div>
</div>
</div>
<div class="compare-vs">
<div class="vs-text">VS</div>
<div class="vs-diff" v-if="compareResult.overall !== result.overall">
{{ compareResult.overall > result.overall ? '+' : '' }}{{ (compareResult.overall - result.overall).toFixed(1) }}
</div>
</div>
<div class="compare-col">
<div class="compare-entity-name">{{ compareResult.entity_name }}</div>
<div class="compare-score" :class="'level-' + compareResult.level_type">{{ compareResult.overall }}</div>
<div class="compare-dims">
<div v-for="(dim, key) in dimConfig" :key="key" class="compare-dim">
<span class="cd-label">{{ dim.label }}</span>
<span class="cd-stars" :class="scoreClass(compareResult.dimensions[key]?.score)">{{ starDisplay(compareResult.dimensions[key]?.score) }}</span>
</div>
</div>
</div>
</div>
<div class="compare-insight">
💡 横向启示{{ result.entity_name }}综合{{ result.overall }} vs {{ compareResult.entity_name }}综合{{ compareResult.overall }}
</div>
</div>
</div>
<!-- 空状态 -->
<el-empty v-else-if="!loading" description="请选择企业进行增长质量诊断" />
<!-- 明细下钻弹窗 -->
<el-dialog v-model="showDetail" :title="currentDimLabel + ' — ' + (result?.entity_name || '')" width="550px">
<template v-if="currentDimKey && result?.detail?.[currentDimKey]">
<div class="detail-header">
<span class="detail-score-badge" :class="scoreClass(result.detail[currentDimKey].score)">
{{ starDisplay(result.detail[currentDimKey].score) }} {{ result.detail[currentDimKey].score }}/5
</span>
<span class="detail-risk" v-if="result.detail[currentDimKey].score <= 2">🔴 高风险</span>
<span class="detail-risk" v-else-if="result.detail[currentDimKey].score <= 3">🟡 需关注</span>
<span class="detail-risk" v-else>🟢 良好</span>
</div>
<!-- 诊断指标 -->
<div class="detail-section">
<div class="detail-section-title">📋 诊断指标</div>
<div v-for="ind in result.detail[currentDimKey].indicators" :key="ind.label" class="detail-indicator">
<div class="ind-row">
<span class="ind-label">{{ ind.label }}</span>
<span class="ind-value">{{ ind.value }}</span>
<el-tag :type="ind.status" size="small">{{ ind.verdict }}</el-tag>
</div>
</div>
</div>
<!-- 改善建议 -->
<div class="detail-section">
<div class="detail-section-title">💡 改善建议</div>
<div v-for="(s, i) in result.detail[currentDimKey].suggestions" :key="i" class="detail-suggestion">
<span class="sug-num">{{ i + 1 }}.</span>
<span class="sug-text">{{ s }}</span>
</div>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import VChart from 'vue-echarts'
import 'echarts'
import { predictApi, entityApi } from '../api/index'
import { ElMessage } from 'element-plus'
const loading = ref(false)
const entities = ref<any[]>([])
const selectedEntityId = ref<number | null>(null)
const compareEntityId = ref<number | null>(null)
const result = ref<any>(null)
const compareResult = ref<any>(null)
const showCompare = ref(false)
const showDetail = ref(false)
const currentDimKey = ref<string | null>(null)
// 内置测试数据的实体(当无数据库实体可用时)
const fallbackEntities = [
{ id: -1, short_name: '陕西酣客', name: '陕西酣客文化传媒' },
{ id: -2, short_name: '陕西博海', name: '陕西博海网络科技' },
]
// 预设测试数据(酣客 + 博海)
const testDataMap: Record<number, any> = {
[-1]: {
entity: '陕西酣客文化传媒',
period: '2026H1',
revenue: 7130000,
rebateRate: 82.8,
trueGrossMargin: 18.6,
cashRatio: 0.6,
expenseGrowthRate: 2.2,
revenueGrowthRate: 1.0,
mgmtRatio: 447,
},
[-2]: {
entity: '陕西博海网络科技',
period: '2026H1',
revenue: 3830000,
rebateRate: 0,
trueGrossMargin: 13.1,
cashRatio: 6.7,
expenseGrowthRate: 0.8,
revenueGrowthRate: 1.0,
mgmtRatio: 1.4,
},
}
const dimConfig: Record<string, { icon: string; label: string }> = {
revenueStructure: { icon: '💰', label: '营收结构' },
profitStructure: { icon: '📈', label: '利润结构' },
cashAssets: { icon: '🏦', label: '现金资产' },
growthDriver: { icon: '🚀', label: '增长驱动' },
orgEfficiency: { icon: '⚡', label: '组织效率' },
}
const currentDimLabel = computed(() => {
if (!currentDimKey.value) return ''
return dimConfig[currentDimKey.value]?.label || currentDimKey.value
})
function scoreClass(score: number): string {
if (score <= 2) return 'sc-danger'
if (score <= 3) return 'sc-warn'
return 'sc-good'
}
function starDisplay(score: number): string {
const full = Math.floor(score)
const half = score - full >= 0.5
const empty = 5 - full - (half ? 1 : 0)
return '⭐'.repeat(full) + (half ? '✨' : '') + '☆'.repeat(empty)
}
const radarOption = computed(() => {
if (!result.value) return {}
const dims = result.value.dimensions || {}
const values = [
dims.revenueStructure?.score || 0,
dims.profitStructure?.score || 0,
dims.cashAssets?.score || 0,
dims.growthDriver?.score || 0,
dims.orgEfficiency?.score || 0,
]
let compareValues: number[] | null = null
if (compareResult.value) {
const cd = compareResult.value.dimensions || {}
compareValues = [
cd.revenueStructure?.score || 0,
cd.profitStructure?.score || 0,
cd.cashAssets?.score || 0,
cd.growthDriver?.score || 0,
cd.orgEfficiency?.score || 0,
]
}
const series: any[] = [
{
name: result.value.entity_name || '当前企业',
type: 'radar',
data: [values],
areaStyle: { color: 'rgba(64, 158, 255, 0.2)' },
lineStyle: { color: '#409eff', width: 2 },
itemStyle: { color: '#409eff' },
symbol: 'circle',
symbolSize: 6,
},
]
if (compareValues) {
series.push({
name: compareResult.value.entity_name || '对比企业',
type: 'radar',
data: [compareValues],
areaStyle: { color: 'rgba(103, 194, 58, 0.2)' },
lineStyle: { color: '#67c23a', width: 2 },
itemStyle: { color: '#67c23a' },
symbol: 'circle',
symbolSize: 6,
})
}
return {
radar: {
indicator: [
{ name: '营收结构', max: 5 },
{ name: '利润结构', max: 5 },
{ name: '现金资产', max: 5 },
{ name: '增长驱动', max: 5 },
{ name: '组织效率', max: 5 },
],
radius: '65%',
axisName: { color: '#333', fontSize: 12 },
splitArea: { areaStyle: { color: ['rgba(64,158,255,0.02)', 'rgba(64,158,255,0.05)'] } },
},
series,
tooltip: { trigger: 'item' },
legend: compareValues ? {
data: [result.value.entity_name || '当前企业', compareResult.value.entity_name || '对比企业'],
bottom: 0,
textStyle: { fontSize: 11 },
} : undefined,
}
})
async function loadEntities() {
try {
const r = await entityApi.list()
if (r?.data?.length) {
entities.value = r.data
// 如果有数据库实体,尝试预设选择
if (!selectedEntityId.value && entities.value.length >= 1) {
selectedEntityId.value = entities.value[0].id
}
} else {
entities.value = fallbackEntities
selectedEntityId.value = -1
}
} catch {
entities.value = fallbackEntities
selectedEntityId.value = -1
}
}
async function loadQuality() {
if (!selectedEntityId.value) return
loading.value = true
showCompare.value = false
compareResult.value = null
try {
// 获取实体数据(测试数据或从API拉取)
let entityData = testDataMap[selectedEntityId.value]
if (!entityData) {
// 从数据库取KPI数据(简化版:使用默认值)
entityData = {
entity: entities.value.find((e: any) => e.id === selectedEntityId.value)?.name || '企业',
period: '2026H1',
rebateRate: 0,
trueGrossMargin: 0,
cashRatio: 0,
expenseGrowthRate: 0,
revenueGrowthRate: 1.0,
mgmtRatio: 0,
}
}
const payload: any = { entity: entityData }
// 如果选择了对比实体
if (compareEntityId.value && compareEntityId.value !== selectedEntityId.value) {
let compareData = testDataMap[compareEntityId.value]
if (!compareData) {
compareData = {
entity: entities.value.find((e: any) => e.id === compareEntityId.value)?.name || '对比企业',
period: '2026H1',
rebateRate: 0,
trueGrossMargin: 0,
cashRatio: 0,
expenseGrowthRate: 0,
revenueGrowthRate: 1.0,
mgmtRatio: 0,
}
}
payload.compare = compareData
}
const r = await predictApi.growthQuality(payload) as any
result.value = r
if (r.compare) {
compareResult.value = r.compare
showCompare.value = true
}
} catch (e: any) {
ElMessage.error('增长质量诊断失败: ' + (e?.message || '未知错误'))
} finally {
loading.value = false
}
}
function openDetail(dimKey: string) {
currentDimKey.value = dimKey
showDetail.value = true
}
function enableCompare() {
// 自动选择第一个不同的实体作为对比
const other = entities.value.find((e: any) => e.id !== selectedEntityId.value)
if (other) {
compareEntityId.value = other.id
loadQuality()
}
}
onMounted(() => {
loadEntities()
})
</script>
<style scoped>
.growth-quality { background:#fff; border-radius:10px; padding:20px; box-shadow:0 1px 3px rgba(0,0,0,0.05); margin-bottom:20px; }
.gq-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:16px; }
.gq-title { font-size:16px; font-weight:600; margin:0; }
.gq-controls { display:flex; gap:8px; align-items:center; }
.gq-main { }
.gq-body { display:flex; gap:24px; }
.gq-left { flex:1; min-width:0; }
.gq-right { flex:1; min-width:280px; }
.gq-radar { width:100%; height:300px; }
/* 评分卡 */
.gq-score-card { text-align:center; padding:20px; border-radius:12px; margin-bottom:16px; background:linear-gradient(135deg, #f8faff 0%, #fff 100%); }
.gq-score-card.level-excellent { border:1px solid #e1f3d8; }
.gq-score-card.level-medium { border:1px solid #faecd8; }
.gq-score-card.level-warning { border:1px solid #fde2e2; }
.gq-score-card.level-danger { border:1px solid #fde2e2; background:linear-gradient(135deg, #fff5f5 0%, #fff 100%); }
.score-big { font-size:48px; font-weight:800; color:#1a1a2e; line-height:1; }
.score-label { font-size:13px; color:#999; margin:4px 0; }
.score-badge { display:inline-block; font-size:14px; font-weight:600; padding:4px 16px; border-radius:20px; }
.level-excellent .score-badge { background:#e1f3d8; color:#529b2e; }
.level-medium .score-badge { background:#faecd8; color:#b88230; }
.level-warning .score-badge { background:#fde2e2; color:#c0392b; }
.level-danger .score-badge { background:#fde2e2; color:#c0392b; }
/* 维度条 */
.gq-dims { }
.gq-dim-row { display:flex; align-items:center; gap:10px; padding:8px 4px; border-radius:6px; transition:background .12s; }
.gq-dim-row:hover { background:#f5f7fa; }
.dim-label-row { display:flex; align-items:center; gap:4px; width:100px; flex-shrink:0; }
.dim-icon { font-size:14px; }
.dim-name { font-size:12px; color:#666; white-space:nowrap; }
.dim-score { font-size:11px; margin-left:auto; }
.sc-danger { color:#f56c6c; }
.sc-warn { color:#e6a23c; }
.sc-good { color:#67c23a; }
.dim-bar-track { flex:1; height:8px; background:#f0f0f0; border-radius:4px; overflow:hidden; min-width:80px; }
.dim-bar-fill { height:100%; border-radius:4px; transition:width .4s ease; }
.dim-bar-fill.sc-danger { background:linear-gradient(90deg, #f56c6c, #f89898); }
.dim-bar-fill.sc-warn { background:linear-gradient(90deg, #e6a23c, #f0c78a); }
.dim-bar-fill.sc-good { background:linear-gradient(90deg, #67c23a, #95d475); }
.dim-pct { font-size:11px; color:#999; width:36px; text-align:right; flex-shrink:0; }
/* 诊断结论 */
.gq-diagnosis { margin-top:16px; padding:16px; background:#f8faff; border-radius:10px; border:1px solid #e8edf5; }
.diag-title { font-size:14px; font-weight:600; margin-bottom:8px; color:#333; }
.diag-text { font-size:13px; color:#666; line-height:1.6; white-space:pre-wrap; }
.diag-actions { margin-top:12px; display:flex; gap:8px; }
/* 双实体对比 */
.gq-compare { margin-top:20px; padding:20px; background:#fafbfc; border-radius:10px; border:1px solid #e8edf5; }
.compare-title { font-size:14px; font-weight:600; margin-bottom:14px; }
.compare-grid { display:flex; align-items:stretch; gap:16px; }
.compare-col { flex:1; background:#fff; border-radius:10px; padding:16px; text-align:center; box-shadow:0 1px 3px rgba(0,0,0,0.04); }
.compare-entity-name { font-size:13px; font-weight:600; color:#333; margin-bottom:8px; }
.compare-score { font-size:36px; font-weight:800; margin-bottom:12px; }
.compare-score.level-excellent { color:#67c23a; }
.compare-score.level-medium { color:#e6a23c; }
.compare-score.level-warning { color:#f56c6c; }
.compare-score.level-danger { color:#f56c6c; }
.compare-dims { display:flex; flex-direction:column; gap:6px; }
.compare-dim { display:flex; justify-content:space-between; align-items:center; padding:4px 8px; background:#f8f8f8; border-radius:4px; font-size:12px; }
.cd-label { color:#888; }
.cd-stars { font-size:11px; }
.compare-vs { display:flex; flex-direction:column; align-items:center; justify-content:center; min-width:50px; }
.vs-text { font-size:18px; font-weight:700; color:#ccc; }
.vs-diff { font-size:14px; font-weight:700; color:#409eff; margin-top:4px; }
.compare-insight { margin-top:12px; font-size:12px; color:#666; background:#eef5ff; padding:8px 12px; border-radius:6px; }
/* 明细弹窗 */
.detail-header { display:flex; align-items:center; gap:12px; margin-bottom:16px; }
.detail-score-badge { font-size:16px; font-weight:600; }
.detail-risk { font-size:13px; font-weight:500; }
.detail-section { margin-bottom:16px; }
.detail-section-title { font-size:14px; font-weight:600; margin-bottom:8px; color:#333; }
.detail-indicator { margin-bottom:6px; }
.ind-row { display:flex; align-items:center; gap:10px; padding:8px 12px; background:#f8f8f8; border-radius:6px; }
.ind-label { font-size:13px; color:#666; min-width:100px; }
.ind-value { font-size:14px; font-weight:600; color:#1a1a2e; min-width:80px; }
.detail-suggestion { display:flex; gap:8px; padding:6px 0; font-size:13px; color:#555; }
.sug-num { color:#409eff; font-weight:600; flex-shrink:0; }
.sug-text { line-height:1.5; }
@media (max-width:768px) {
.gq-body { flex-direction:column; }
.gq-header { flex-direction:column; gap:10px; }
.gq-controls { width:100%; }
.gq-controls .el-select { flex:1; }
}
</style>
+4
View File
@@ -124,6 +124,9 @@
</div>
</div>
</div>
<!-- 增长质量诊断 -->
<GrowthQuality />
</template>
<!-- ====== 业务视图 ====== -->
@@ -240,6 +243,7 @@ import { ElMessage } from 'element-plus'
import { Loading, ArrowRight } from '@element-plus/icons-vue'
import { dashboardApi, alertApi } from '../api/index'
import axios from 'axios'
import GrowthQuality from '../components/GrowthQuality.vue'
const router = useRouter()