Files
cma-management/frontend/src/views/DupontAnalysis.vue
T

317 lines
11 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="dupont-analysis" v-loading="loading">
<div class="page-header">
<h3 class="page-title">杜邦分析</h3>
</div>
<!-- 顶部控制 -->
<div class="flex-row" style="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>
<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>
<!-- 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 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>
</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="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 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>
<!-- 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 axios from 'axios'
const loading = ref(false)
const entity = ref('bohai')
const data = ref<any>({})
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 === 'financial_leverage') return val.toFixed(2) + 'x'
if (key === 'asset_turnover') return val.toFixed(2) + 'x'
return val.toFixed(2) + '%'
}
function formatMoney(val: number): string {
if (!val) return '-'
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 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
}
}
onMounted(fetchDupont)
</script>
<style scoped>
.dupont-analysis {
padding: 20px;
max-width: 960px;
margin: 0 auto;
}
/* ROE大数字 */
.roe-hero {
text-align: center;
padding: 32px 20px;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
border-radius: 16px;
margin-bottom: 24px;
color: #fff;
}
.roe-value {
font-size: 56px;
font-weight: 700;
letter-spacing: -2px;
}
.roe-unit { font-size: 24px; color: #8892b0; }
.roe-label { font-size: 14px; color: #8892b0; margin-top: 4px; }
.roe-change {
margin-top: 8px;
font-size: 16px;
font-weight: 600;
padding: 4px 16px;
border-radius: 20px;
display: inline-block;
}
.roe-change.up { color: #2ecc71; background: rgba(46,204,113,0.15); }
.roe-change.down { color: #e74c3c; background: rgba(231,76,60,0.15); }
.roe-change.stable { color: #f39c12; background: rgba(243,156,18,0.15); }
.change-sub { font-size: 12px; color: #8892b0; margin-left: 4px; font-weight: 400; }
/* 三因子卡片 */
.factor-cards {
display: flex;
gap: 16px;
margin-bottom: 24px;
}
.factor-card {
flex: 1;
background: #fff;
border: 1px solid #e8e8e8;
border-radius: 12px;
padding: 20px;
text-align: center;
transition: box-shadow .2s;
}
.factor-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.08); }
.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 {
background: #fff;
border: 1px solid #e8e8e8;
border-radius: 12px;
padding: 24px;
margin-bottom: 24px;
}
.tree-title { font-size: 16px; font-weight: 600; margin-bottom: 20px; color: #2c3e50; }
.tree-container {
display: flex;
flex-direction: column;
align-items: center;
}
.tree-node {
text-align: center;
padding: 12px 20px;
border-radius: 10px;
min-width: 130px;
}
.tree-root {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
font-size: 20px;
}
.tree-root .node-value { font-size: 32px; font-weight: 700; }
.tree-root .node-label { font-size: 12px; opacity: 0.85; margin-top: 4px; }
.tree-factor {
background: #f0f4ff;
border: 1px solid #d0d8f0;
color: #2c3e50;
}
.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;
min-width: 110px;
padding: 10px 16px;
}
.tree-leaf .node-value { font-size: 16px; font-weight: 600; color: #2c3e50; }
.tree-leaf .node-label { font-size: 12px; color: #95a5a6; }
.tree-leaf .node-label.sub { font-size: 11px; color: #b0b0b0; margin-top: 2px; }
.tree-leaf .node-hint { font-size: 14px; font-weight: 600; color: #667eea; margin-top: 8px; }
/* 连线 */
.tree-lines, .tree-lines-second {
display: flex;
flex-direction: column;
align-items: center;
height: 40px;
}
.line-vertical { width: 2px; height: 20px; background: #d0d8f0; }
.line-horizontal {
display: flex;
gap: 160px;
height: 20px;
align-items: flex-start;
}
.line-point { width: 2px; height: 20px; background: #d0d8f0; }
.tree-row {
display: flex;
gap: 80px;
justify-content: center;
width: 100%;
}
.tree-lines-second {
flex-direction: row;
gap: 80px;
justify-content: center;
height: 40px;
margin-top: 4px;
width: 100%;
}
.sub-lines {
display: flex;
flex-direction: column;
align-items: center;
}
.sub-lines .line-horizontal { gap: 50px; }
/* 分析解读 */
.ai-insight {
background: #fffbe6;
border: 1px solid #ffe58f;
border-radius: 12px;
padding: 16px 20px;
}
.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>