feat: 增长质量诊断模块 — 五维评分+雷达图+跨期对比+趋势

This commit is contained in:
Hermes CI Fix
2026-08-11 00:21:10 +08:00
parent 0b191d278e
commit b1e8a55745
7 changed files with 796 additions and 3 deletions
+2 -1
View File
@@ -9,7 +9,7 @@ interface MenuItem {
// ── 角色路由映射 ──
export const ROLE_ROUTES: Record<string, string[]> = {
ceo: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/notifications', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/tax-compliance'],
ceo: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/notifications', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/growth-quality', '/tax-compliance'],
finance: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/data', '/budget', '/deviations', '/cost', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/tax-compliance'],
business: ['/my-dashboard', '/dashboard', '/kpis', '/alerts', '/budget', '/deviations', '/action-plans', '/knowledge', '/guide', '/customer', '/expenses', '/cash-plan', '/tax-compliance'],
it: ['/my-dashboard', '/dashboard', '/kpis', '/alerts', '/data', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/analysis-confidence', '/expenses', '/cash-plan', '/tax-compliance'],
@@ -35,6 +35,7 @@ export const MENU_ITEMS: MenuItem[] = [
{ path: '/expenses', label: '费用审核', icon: 'Money', roles: ['ceo', 'finance', 'business', 'it'], group: '🟢 D 执行与控制' },
{ path: '/tax-compliance', label: '税务合规', icon: 'DataAnalysis', roles: ['ceo', 'finance', 'business', 'it'], group: '🟢 D 执行与控制' },
{ path: '/cash-plan', label: '收付款计划', icon: 'Money', roles: ['ceo', 'finance', 'business', 'it'], group: '🟢 D 执行与控制' },
{ path: '/growth-quality', label: '增长质量诊断', icon: 'TrendCharts', roles: ['ceo', 'finance', 'business', 'it'], group: '🟡 C 监控与评价' },
{ path: '/deviations', label: '差异分析', icon: 'DataAnalysis', roles: ['ceo', 'finance', 'business', 'it'], group: '🟢 D 执行与控制' },
// ── GROUP 3: 监控与评价(Check)──
+1
View File
@@ -43,6 +43,7 @@ const routes = [
{ path: 'analysis-confidence', name: 'AnalysisConfidence', component: () => import('@/views/AnalysisConfidence.vue'), meta: { title: '分析置信度', roles: ['ceo', 'finance', 'it'] } },
{ path: 'expenses', name: 'ExpenseManage', component: () => import('@/views/ExpenseManage.vue'), meta: { title: '费用审核', roles: ['ceo', 'finance', 'business', 'it'] } },
{ path: 'cash-plan', name: 'CashPlan', component: () => import('@/views/CashPlan.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: 'tax-compliance', name: 'TaxCompliance', component: () => import('@/views/TaxCompliance.vue'), meta: { title: '税务合规', roles: ['ceo', 'finance', 'business', 'it'] } },
]
},
+238
View File
@@ -0,0 +1,238 @@
<template>
<div class="growth-quality-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="period" size="small" style="width:140px" @change="loadData" :loading="loadingPeriods">
<el-option v-for="p in periods" :key="p" :value="p" :label="p" />
</el-select>
</div>
</div>
<el-row :gutter="16" v-loading="loading">
<!-- 总体评分 -->
<el-col :span="8">
<el-card class="score-card" shadow="never">
<div class="overall-score" :style="{ color: overallColor }">
{{ overall != null ? overall.toFixed(1) : '-' }}
</div>
<div class="overall-label">综合增长质量评分</div>
<el-tag :type="overallTag" size="small" style="margin-top:8px">{{ level }}</el-tag>
<div class="diagnosis-text">{{ diagnosis }}</div>
</el-card>
</el-col>
<!-- 雷达图 -->
<el-col :span="8">
<el-card shadow="never" header="五维雷达图">
<div ref="radarRef" style="height:300px"></div>
</el-card>
</el-col>
<!-- 跨期对比 -->
<el-col :span="8">
<el-card shadow="never" header="跨期对比">
<div ref="compareRef" style="height:300px"></div>
</el-card>
</el-col>
</el-row>
<!-- 五维明细 -->
<el-row :gutter="16" style="margin-top:16px">
<el-col :span="24">
<el-card shadow="never">
<template #header>五维明细</template>
<el-table :data="dimensionRows" size="small">
<el-table-column prop="name" label="维度" width="140" />
<el-table-column prop="score" label="评分" width="90">
<template #default="{ row }">
<span :style="{ color: row.color, fontWeight: 600 }">{{ row.score }}</span>
</template>
</el-table-column>
<el-table-column prop="level" label="等级" width="80" />
<el-table-column label="关键指标" min-width="300">
<template #default="{ row }">
<el-tag v-for="ind in row.indicators" :key="ind.label" size="small" style="margin:2px" :type="ind.status || 'info'">
{{ ind.label }}: {{ ind.value }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="suggestion" label="建议" min-width="220" />
</el-table>
</el-card>
</el-col>
</el-row>
<!-- 趋势 -->
<el-row style="margin-top:16px">
<el-col :span="24">
<el-card shadow="never" header="12个月趋势">
<div ref="trendRef" style="height:260px"></div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue'
import * as echarts from 'echarts'
import { api } from '@/api'
const entityId = ref(Number(localStorage.getItem('cma_entity_id') || 1))
const period = ref('2026-06')
const periods = ref<string[]>([])
const loading = ref(false)
const loadingPeriods = ref(false)
const overall = ref<number | null>(null)
const level = ref('')
const levelType = ref('info')
const diagnosis = ref('')
const dimensions = ref<any>({})
const comparison = ref<any>({})
const trend = ref<any[]>([])
const radarRef = ref<HTMLElement>()
const compareRef = ref<HTMLElement>()
const trendRef = ref<HTMLElement>()
const overallTag = computed(() => levelType.value as any)
const overallColor = computed(() => {
if (levelType.value === 'success') return '#67c23a'
if (levelType.value === 'warning') return '#e6a23c'
return '#f56c6c'
})
const dimensionRows = computed(() => {
const meta: Record<string, { name: string }> = {
revenueGrowth: { name: '营收增长' },
profitQuality: { name: '利润质量' },
cashQuality: { name: '现金质量' },
growthEfficiency: { name: '增长效率' },
orgHealth: { name: '组织健康' },
}
return Object.entries(dimensions.value || {}).map(([key, v]: any) => ({
key,
name: meta[key]?.name || key,
score: v?.score ?? 0,
level: v?.level || (v?.score >= 80 ? '好' : v?.score >= 60 ? '中' : '差'),
color: v?.score >= 80 ? '#67c23a' : v?.score >= 60 ? '#e6a23c' : '#f56c6c',
indicators: v?.indicators || [],
suggestion: v?.suggestion || '-',
}))
})
async function loadPeriods() {
loadingPeriods.value = true
try {
const r: any = await api.get('/growth-quality/periods', { params: { entity_id: entityId.value } })
periods.value = r.data || r || []
if (periods.value.length && !periods.value.includes(period.value)) {
period.value = periods.value[periods.value.length - 1]
}
} catch (_) {}
loadingPeriods.value = false
}
async function loadData() {
loading.value = true
try {
const r: any = await api.get('/growth-quality/diagnosis', { params: { entity_id: entityId.value, period: period.value } })
const d = r.data || r || {}
overall.value = d.overall ?? null
level.value = d.level || ''
levelType.value = d.level_type || 'info'
diagnosis.value = d.diagnosis || ''
dimensions.value = d.dimensions || {}
comparison.value = d.comparison || {}
trend.value = d.trend || []
await nextTick()
renderCharts()
} catch (e: any) {
console.error('load growth quality failed', e)
} finally {
loading.value = false
}
}
function renderCharts() {
// 雷达图
if (radarRef.value) {
const chart = echarts.init(radarRef.value)
const dims = dimensionRows.value
chart.setOption({
radar: {
indicator: dims.map((d: any) => ({ name: d.name, max: 100 })),
radius: '65%',
},
series: [{
type: 'radar',
data: [{
value: dims.map((d: any) => d.score),
name: period.value,
areaStyle: { color: 'rgba(64,158,255,0.2)' },
lineStyle: { color: '#409EFF' },
itemStyle: { color: '#409EFF' },
}],
}],
})
}
// 跨期对比
if (compareRef.value) {
const chart = echarts.init(compareRef.value)
const dims = dimensionRows.value
const cur = comparison.value?.current || {}
const prev = comparison.value?.previous || {}
chart.setOption({
tooltip: { trigger: 'axis' },
legend: { data: ['本期', '上期'], bottom: 0 },
radar: {
indicator: dims.map((d: any) => ({ name: d.name, max: 100 })),
radius: '60%',
},
series: [{
type: 'radar',
data: [
{ value: dims.map((d: any) => cur[d.key]?.score ?? d.score), name: '本期', areaStyle: { opacity: 0.2 } },
{ value: dims.map((d: any) => prev[d.key]?.score ?? 0), name: '上期' },
],
}],
})
}
// 趋势
if (trendRef.value) {
const chart = echarts.init(trendRef.value)
chart.setOption({
tooltip: { trigger: 'axis' },
xAxis: { type: 'category', data: trend.value.map((t: any) => t.period) },
yAxis: { type: 'value', min: 0, max: 100 },
series: [{
type: 'line',
data: trend.value.map((t: any) => t.overall),
smooth: true,
areaStyle: { opacity: 0.15 },
itemStyle: { color: '#409EFF' },
}],
})
}
}
onMounted(async () => {
await loadPeriods()
await loadData()
})
</script>
<style scoped>
.growth-quality-page { max-width: 1400px; margin: 0 auto; padding: 16px; }
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.header-right { display: flex; gap: 8px; }
.score-card { text-align: center; min-height: 300px; }
.overall-score { font-size: 56px; font-weight: 700; margin: 20px 0 4px; }
.overall-label { font-size: 14px; color: #888; }
.diagnosis-text { font-size: 13px; color: #666; text-align: left; margin-top: 16px; line-height: 1.6; padding: 8px; background: #f8f8f8; border-radius: 6px; }
</style>