feat: CMA P1+P2 Round1 — 成本法对比+杜邦分析+本量利
This commit is contained in:
@@ -1,129 +1,158 @@
|
||||
<template>
|
||||
<div class="dupont-analysis" v-loading="loading">
|
||||
<!-- 1. ROE大数字 -->
|
||||
<div class="roe-hero" v-if="data.roe !== null">
|
||||
<div class="roe-value">{{ data.roe }}<span class="roe-unit">%</span></div>
|
||||
<div class="roe-label">净资产收益率 (ROE)</div>
|
||||
<div class="roe-change" :class="data.history?.trend || 'stable'">
|
||||
<span v-if="data.history?.trend === 'up'">↑</span>
|
||||
<span v-else-if="data.history?.trend === 'down'">↓</span>
|
||||
<span v-else>→</span>
|
||||
{{ data.history?.change?.roe_label || '无对比' }}
|
||||
<span class="change-sub">较上期</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="杜邦分析数据不足以计算,请确认营收、总资产、净资产数据已同步" />
|
||||
|
||||
<!-- 2. 三因子卡片 -->
|
||||
<div class="factor-cards" v-if="data.factors && Object.keys(data.factors).length">
|
||||
<div class="factor-card" v-for="(f, key) in data.factors" :key="key">
|
||||
<div class="factor-value">{{ formatFactor(f.value, key) }}</div>
|
||||
<div class="factor-label">{{ f.label }}</div>
|
||||
<div class="factor-desc">{{ f.desc }}</div>
|
||||
</div>
|
||||
<!-- 顶部控制 -->
|
||||
<div style="display:flex;gap:12px;margin-bottom:16px;align-items:center;">
|
||||
<span style="font-weight:600;font-size:14px;">分析对象:</span>
|
||||
<el-radio-group v-model="entity" @change="fetchDupont">
|
||||
<el-radio value="bohai">陕西博海科技(IT服务)</el-radio>
|
||||
<el-radio value="hanke">陕西酣客(白酒经销)</el-radio>
|
||||
</el-radio-group>
|
||||
<el-button type="primary" size="small" @click="fetchDupont" :loading="loading">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 3. 杜邦拆解树 -->
|
||||
<div class="dupont-tree" v-if="data.roe !== null && data.raw_data">
|
||||
<div class="tree-title">杜邦拆解链路</div>
|
||||
<div class="tree-container">
|
||||
<!-- 第一层:ROE -->
|
||||
<div class="tree-node tree-root">
|
||||
<div class="node-value">{{ data.roe }}%</div>
|
||||
<div class="node-label">ROE</div>
|
||||
<template v-if="data.roe !== null && data.roe !== undefined">
|
||||
<!-- 1. ROE大数字 -->
|
||||
<div class="roe-hero">
|
||||
<div class="roe-value">{{ formatPct(data.roe) }}<span class="roe-unit">%</span></div>
|
||||
<div class="roe-label">净资产收益率 (ROE) — {{ data.entity_name }} {{ data.period }}</div>
|
||||
<div class="roe-change" :class="data.roe_trend || 'stable'">
|
||||
<span v-if="data.roe_trend === 'up'">↑</span>
|
||||
<span v-else-if="data.roe_trend === 'down'">↓</span>
|
||||
<span v-else>→</span>
|
||||
{{ data.roe_change != null ? (data.roe_change > 0 ? '+' : '') + data.roe_change.toFixed(2) + '%' : '无对比' }}
|
||||
<span class="change-sub">较上期 ({{ data.prev_roe != null ? data.prev_roe + '%' : '-' }})</span>
|
||||
</div>
|
||||
<!-- 连线 -->
|
||||
<div class="tree-lines">
|
||||
<div class="line-vertical"></div>
|
||||
<div class="line-horizontal">
|
||||
<div class="line-point"></div>
|
||||
<div class="line-point"></div>
|
||||
<div class="line-point"></div>
|
||||
</div>
|
||||
|
||||
<!-- 2. 三因子卡片 -->
|
||||
<div class="factor-cards" v-if="data.factors">
|
||||
<div class="factor-card" v-for="(f, key) in data.factors" :key="key">
|
||||
<div class="factor-value">{{ formatFactor(f.value, key) }}</div>
|
||||
<div class="factor-label">{{ f.label }}</div>
|
||||
<div class="factor-desc">{{ f.desc }}</div>
|
||||
<div class="factor-assessment">{{ f.status }} {{ f.assessment }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. 杜邦拆解树 -->
|
||||
<div class="dupont-tree">
|
||||
<div class="tree-title">杜邦拆解链路</div>
|
||||
<div class="tree-container">
|
||||
<!-- 第一层:ROE -->
|
||||
<div class="tree-node tree-root">
|
||||
<div class="node-value">{{ formatPct(data.roe) }}%</div>
|
||||
<div class="node-label">ROE = 净利润率 × 资产周转率 × 财务杠杆</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 第二层:三因子 -->
|
||||
<div class="tree-row">
|
||||
<div class="tree-node tree-factor" v-for="(f, key) in data.factors" :key="'f-'+key">
|
||||
<div class="node-value">{{ formatFactor(f.value, key) }}</div>
|
||||
<div class="node-label">{{ f.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 连线 第二层到第三层 -->
|
||||
<div class="tree-lines-second">
|
||||
<div class="sub-lines">
|
||||
<!-- 连线 -->
|
||||
<div class="tree-lines">
|
||||
<div class="line-vertical"></div>
|
||||
<div class="line-horizontal"><div class="line-point"></div><div class="line-point"></div></div>
|
||||
<div class="line-horizontal">
|
||||
<div class="line-point"></div>
|
||||
<div class="line-point"></div>
|
||||
<div class="line-point"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sub-lines">
|
||||
<div class="line-vertical"></div>
|
||||
<div class="line-horizontal"><div class="line-point"></div><div class="line-point"></div></div>
|
||||
<!-- 第二层:三因子 -->
|
||||
<div class="tree-row" v-if="data.factors">
|
||||
<div class="tree-node tree-factor" v-for="(f, key) in data.factors" :key="'f-'+key">
|
||||
<div class="node-value">{{ formatFactor(f.value, key) }}</div>
|
||||
<div class="node-label">{{ f.label }}</div>
|
||||
<div class="node-desc">{{ f.desc }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sub-lines">
|
||||
<div class="line-vertical"></div>
|
||||
<div class="line-horizontal"><div class="line-point"></div><div class="line-point"></div></div>
|
||||
<!-- 连线 第二层到第三层 -->
|
||||
<div class="tree-lines-second">
|
||||
<div class="sub-lines">
|
||||
<div class="line-vertical"></div>
|
||||
<div class="line-horizontal"><div class="line-point"></div><div class="line-point"></div></div>
|
||||
</div>
|
||||
<div class="sub-lines">
|
||||
<div class="line-vertical"></div>
|
||||
<div class="line-horizontal"><div class="line-point"></div><div class="line-point"></div></div>
|
||||
</div>
|
||||
<div class="sub-lines">
|
||||
<div class="line-vertical"></div>
|
||||
<div class="line-horizontal"><div class="line-point"></div><div class="line-point"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 第三层:原始数据 -->
|
||||
<div class="tree-row" v-if="data.raw_data">
|
||||
<div class="tree-node tree-leaf">
|
||||
<div class="node-value">{{ formatMoney(data.raw_data.revenue) }}</div>
|
||||
<div class="node-label">营收</div>
|
||||
<div class="node-hint">{{ formatMoney(data.raw_data.profit_net) }}</div>
|
||||
<div class="node-label sub">净利润</div>
|
||||
</div>
|
||||
<div class="tree-node tree-leaf">
|
||||
<div class="node-value">{{ formatMoney(data.raw_data.revenue) }}</div>
|
||||
<div class="node-label">营收</div>
|
||||
<div class="node-hint">{{ formatMoney(data.raw_data.asset_total) }}</div>
|
||||
<div class="node-label sub">总资产</div>
|
||||
</div>
|
||||
<div class="tree-node tree-leaf">
|
||||
<div class="node-value">{{ formatMoney(data.raw_data.asset_total) }}</div>
|
||||
<div class="node-label">总资产</div>
|
||||
<div class="node-hint">{{ formatMoney(data.raw_data.equity_total) }}</div>
|
||||
<div class="node-label sub">净资产</div>
|
||||
<!-- 第三层:原始数据 -->
|
||||
<div class="tree-row" v-if="data.raw_data">
|
||||
<div class="tree-node tree-leaf">
|
||||
<div class="node-value">{{ formatMoney(data.raw_data.net_profit) }}</div>
|
||||
<div class="node-label">净利润</div>
|
||||
<div class="node-hint">÷ {{ formatMoney(data.raw_data.revenue) }}</div>
|
||||
<div class="node-label sub">营收</div>
|
||||
</div>
|
||||
<div class="tree-node tree-leaf">
|
||||
<div class="node-value">{{ formatMoney(data.raw_data.revenue) }}</div>
|
||||
<div class="node-label">营收</div>
|
||||
<div class="node-hint">÷ {{ formatMoney(data.raw_data.total_assets) }}</div>
|
||||
<div class="node-label sub">总资产</div>
|
||||
</div>
|
||||
<div class="tree-node tree-leaf">
|
||||
<div class="node-value">{{ formatMoney(data.raw_data.total_assets) }}</div>
|
||||
<div class="node-label">总资产</div>
|
||||
<div class="node-hint">÷ {{ formatMoney(data.raw_data.equity) }}</div>
|
||||
<div class="node-label sub">净资产</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 4. 一句话AI解读 -->
|
||||
<div class="ai-insight" v-if="aiAnalysis">
|
||||
<div class="insight-label">💡 AI解读</div>
|
||||
<div class="insight-text">{{ aiAnalysis }}</div>
|
||||
</div>
|
||||
<!-- 4. 分析解读 -->
|
||||
<div class="ai-insight" v-if="data.insight">
|
||||
<div class="insight-label">💡 分析解读</div>
|
||||
<div class="insight-text">{{ data.insight.detail }}</div>
|
||||
<div class="insight-improvement">改善方向:{{ data.insight.improvement }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-empty v-else-if="!loading" description="杜邦分析数据不足以计算,请确认营收、总资产、净资产数据已同步" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { dashboardApi } from '@/api'
|
||||
import axios from 'axios'
|
||||
|
||||
const loading = ref(false)
|
||||
const entity = ref('bohai')
|
||||
const data = ref<any>({})
|
||||
const aiAnalysis = ref('')
|
||||
|
||||
const api = axios.create({ baseURL: '/api/cma', timeout: 15000 })
|
||||
api.interceptors.request.use((config: any) => {
|
||||
const token = localStorage.getItem('cma_token')
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||
return config
|
||||
})
|
||||
api.interceptors.response.use((r: any) => r.data, (e: any) => Promise.reject(e))
|
||||
|
||||
function formatPct(val: number): string {
|
||||
if (val === null || val === undefined) return '-'
|
||||
return val.toFixed(2)
|
||||
}
|
||||
|
||||
function formatFactor(val: number, key: string): string {
|
||||
if (val === null || val === undefined) return '-'
|
||||
if (key === 'equity_multiplier') return val.toFixed(2) + 'x'
|
||||
if (key === 'financial_leverage') return val.toFixed(2) + 'x'
|
||||
if (key === 'asset_turnover') return val.toFixed(2) + 'x'
|
||||
return (val * 100).toFixed(2) + '%'
|
||||
return val.toFixed(2) + '%'
|
||||
}
|
||||
|
||||
function formatMoney(val: number): string {
|
||||
if (!val) return '-'
|
||||
if (val >= 100000000) return (val / 100000000).toFixed(2) + '亿'
|
||||
if (val >= 10000) return (val / 10000).toFixed(0) + '万'
|
||||
if (val >= 10000) return (val / 10000).toFixed(1) + '亿'
|
||||
if (val >= 1) return val.toFixed(1) + '万'
|
||||
return val.toLocaleString()
|
||||
}
|
||||
|
||||
async function fetchDupont() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await dashboardApi.dupont?.() || await (await fetch('/api/cma/dashboard/dupont')).json()
|
||||
data.value = res
|
||||
const r: any = await api.get('/reports/dupont', { params: { entity: entity.value } })
|
||||
data.value = r.data || r
|
||||
} catch (e) {
|
||||
console.error('杜邦分析加载失败:', e)
|
||||
data.value = {}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -135,7 +164,7 @@ onMounted(fetchDupont)
|
||||
<style scoped>
|
||||
.dupont-analysis {
|
||||
padding: 20px;
|
||||
max-width: 900px;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@@ -187,6 +216,7 @@ onMounted(fetchDupont)
|
||||
.factor-value { font-size: 28px; font-weight: 700; color: #2c3e50; }
|
||||
.factor-label { font-size: 14px; color: #7f8c8d; margin-top: 4px; }
|
||||
.factor-desc { font-size: 11px; color: #b0b0b0; margin-top: 8px; }
|
||||
.factor-assessment { font-size: 12px; color: #666; margin-top: 8px; padding-top: 8px; border-top: 1px solid #f0f0f0; }
|
||||
|
||||
/* 杜邦拆解树 */
|
||||
.dupont-tree {
|
||||
@@ -206,7 +236,7 @@ onMounted(fetchDupont)
|
||||
text-align: center;
|
||||
padding: 12px 20px;
|
||||
border-radius: 10px;
|
||||
min-width: 120px;
|
||||
min-width: 130px;
|
||||
}
|
||||
.tree-root {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
@@ -214,7 +244,7 @@ onMounted(fetchDupont)
|
||||
font-size: 20px;
|
||||
}
|
||||
.tree-root .node-value { font-size: 32px; font-weight: 700; }
|
||||
.tree-root .node-label { font-size: 13px; opacity: 0.85; }
|
||||
.tree-root .node-label { font-size: 12px; opacity: 0.85; margin-top: 4px; }
|
||||
.tree-factor {
|
||||
background: #f0f4ff;
|
||||
border: 1px solid #d0d8f0;
|
||||
@@ -222,6 +252,7 @@ onMounted(fetchDupont)
|
||||
}
|
||||
.tree-factor .node-value { font-size: 22px; font-weight: 700; }
|
||||
.tree-factor .node-label { font-size: 13px; color: #7f8c8d; }
|
||||
.tree-factor .node-desc { font-size: 11px; color: #b0b0b0; margin-top: 4px; }
|
||||
.tree-leaf {
|
||||
background: #fafafa;
|
||||
border: 1px solid #e8e8e8;
|
||||
@@ -269,7 +300,7 @@ onMounted(fetchDupont)
|
||||
}
|
||||
.sub-lines .line-horizontal { gap: 50px; }
|
||||
|
||||
/* AI解读 */
|
||||
/* 分析解读 */
|
||||
.ai-insight {
|
||||
background: #fffbe6;
|
||||
border: 1px solid #ffe58f;
|
||||
@@ -278,4 +309,5 @@ onMounted(fetchDupont)
|
||||
}
|
||||
.insight-label { font-size: 14px; font-weight: 600; color: #d48806; margin-bottom: 8px; }
|
||||
.insight-text { font-size: 14px; color: #5a4e2b; line-height: 1.7; }
|
||||
.insight-improvement { font-size: 14px; color: #2c3e50; font-weight: 600; margin-top: 8px; padding-top: 8px; border-top: 1px solid #ffe58f; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user