Files
cma-management/frontend/src/components/GrowthQuality.vue
T
Hermes CI Fix f85c3d2c4e feat: 增长质量诊断模块 — 后端评分API+前端驾驶舱卡片+雷达图+明细下钻+双实体对比
- 新增 POST /api/cma/predict/growth-quality 评分API
  - 五维度评分引擎(营收结构/利润结构/现金资产/增长驱动/组织效率)
  - 自动诊断结论生成
  - 明细指标+改善建议
  - 双实体对比模式

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

- 集成到 Dashboard.vue CEO驾驶舱视图
- 酣客测试验证:综合1.4分(越增长越重)
- 博海对比验证:综合3.6分(增长质量中等)
2026-07-19 10:46:43 +08:00

476 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>