Files
cma-management/frontend/src/views/GrowthQuality.vue
T
Hermes CI Fix e6dd8f61f0 feat(ui): converge 6 page-level entity selectors to read-only session tag
- DataQuality / ProductMatrix / AlertList / ActionPlanLibrary /
  views.GrowthQuality / PredictAccuracy: drop entity el-select
  (including the "all entities" option), show read-only session
  entity tag (not clickable), same style as the top bar tag
- drop page-level entity param from those requests; backend resolves
  entity from the session token (assert_entity_scope / get_entity_id)
- utils/entity.ts: add sessionEntityName() (cma_entity_name else id map)
- P3: ReportCenter header emoji removed; components/GrowthQuality
  detail drill-down el-dialog -> MyDialog

(cma-entity-selectors-converge-6pages-20260914)
2026-09-14 11:32:52 +08:00

239 lines
8.0 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-page">
<div class="page-header">
<h2>📈 增长质量诊断</h2>
<div class="header-right">
<!-- 企业只读标识(不可点):账套由登录窗口选定,页内无切换入口 -->
<el-tag size="small" type="primary" effect="plain">{{ entityLabel }}</el-tag>
<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'
import { sessionEntityName } from '@/utils/entity'
// 企业只读标识(账套由登录窗口选定,页内无切换入口)
const entityLabel = ref(sessionEntityName())
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')
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: { 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>