feat: 增长质量诊断模块 — 五维评分+雷达图+跨期对比+趋势
This commit is contained in:
@@ -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)──
|
||||
|
||||
@@ -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'] } },
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user