Files
cma-management/frontend/src/views/MpmCalculator.vue
T
Hermes CI Fix 7b74736980 refactor(cma-frontend): route all API calls through shared axios instance (cma-frontend-axios-unify-20260915)
api/index.ts: add permissionApi.getModules/getConfig/saveConfig + alignmentApi.config/tree.

views: replace 13 raw fetch calls and 6 local axios.create instances (each hand-building the Authorization header, without the shared 401 redirect / timeout toast) with the shared instance from api/index.ts.

KPIAlignment: fixes a live bug - the header value was the string literal 'Bearer ' + token, so /alignment/tree always returned 401 (reproduced against the production bundle).

Unwrap semantics preserved: the shared response interceptor returns response.data, so call sites that used res.data were adjusted accordingly.

Verified: scan-frontend-tokenless RAW-TOKENLESS=0; vite build ok; headless A/B (new dist vs production bundle, 8 affected pages, real login) -> identical rendered rows and identical pre-existing error profile, /alignment 401->200.

Push not executed: main is ahead 4 with other tickets' gated commits (see cma-main-ahead3-push-gate-notice-20260915).
2026-09-15 22:21:56 +08:00

365 lines
16 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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="mpm-page">
<div class="mpm-header">
<h3 class="page-title">MPM管理层指标计算器</h3>
<div class="header-right">
<el-date-picker
v-model="period"
type="month"
placeholder="选择月份"
value-format="YYYY-MM"
size="small"
style="width:140px"
/>
<el-button type="primary" size="small" :loading="calculating" @click="calculate">计算</el-button>
</div>
</div>
<el-row :gutter="16">
<!-- 左侧:参数面板 -->
<el-col :span="10">
<el-card class="param-card">
<template #header>
<span>参数设置</span>
</template>
<div class="param-section">
<label class="param-label">选择指标类型</label>
<el-radio-group v-model="indicatorType" class="indicator-types">
<el-radio
v-for="opt in indicatorOptions"
:key="opt.value"
:value="opt.value"
class="indicator-radio"
>
<div class="indicator-opt">
<span class="indicator-name">{{ opt.label }}</span>
<span class="indicator-desc">{{ opt.desc }}</span>
</div>
</el-radio>
</el-radio-group>
</div>
<el-divider />
<div class="param-section">
<label class="param-label">调整项目
<span class="param-hint">勾选需要调整的项目,未勾选的项目不计入调节</span>
</label>
<div class="adjustment-list">
<div v-for="(adj, idx) in adjustments" :key="adj.code" class="adjustment-row">
<el-checkbox v-model="adj.checked" :disabled="adjusting" @change="onAdjustmentChange">
<span :class="{ 'adj-sign-positive': adj.sign > 0, 'adj-sign-negative': adj.sign < 0 }">
{{ adj.sign > 0 ? '加回' : '减' }}
</span>
{{ adj.name }}
</el-checkbox>
<el-input-number
v-model="adj.amount"
:disabled="!adj.checked || adjusting"
size="small"
:min="0"
:precision="2"
controls-position="right"
placeholder="0.00"
style="width:150px"
@change="onAdjustmentChange"
/>
</div>
</div>
<div v-if="adjustments.length === 0" class="empty-hint">该指标类型暂无可选调整项</div>
</div>
<el-divider />
<div style="text-align:right;">
<el-button size="small" @click="resetDefaults">重置默认</el-button>
</div>
</el-card>
</el-col>
<!-- 右侧:调节表结果 -->
<el-col :span="14">
<el-card class="result-card">
<template #header>
<div class="result-header">
<span>调节表(附注披露用)</span>
<el-tag v-if="indicatorType === 'ebitda'" type="warning">EBITDA</el-tag>
<el-tag v-else-if="indicatorType === 'adjusted_net_profit'" type="success">调整后净利润</el-tag>
<el-tag v-else-if="indicatorType === 'free_cash_flow'" type="primary">自由现金流</el-tag>
<el-tag v-else type="info">自定义</el-tag>
</div>
</template>
<div v-loading="calculating">
<div v-if="result" class="reconciliation-table">
<div
v-for="item in result.reconciliation_items"
:key="item.step"
:class="['rec-item', { 'rec-base': item.is_base, 'rec-checked': item.checked && !item.is_base, 'rec-unchecked': !item.checked && !item.is_base }]"
>
<div class="rec-step">
<span v-if="item.is_base" class="rec-dot-base"></span>
<span v-else-if="item.checked" class="rec-dot-checked"></span>
<span v-else class="rec-dot-unchecked"></span>
<span class="rec-name">{{ item.name }}</span>
</div>
<div class="rec-amount" :class="{ 'rec-negative': item.effective != null && item.effective < 0 }">
<template v-if="item.effective != null">
{{ item.sign > 0 && !item.is_base ? '+ ' : '' }}{{ item.sign < 0 && !item.is_base ? '- ' : '' }}¥ {{ Math.abs(item.effective).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }}
</template>
<span v-else class="rec-no-data">无数据</span>
</div>
<div v-if="item.running_total != null" class="rec-running">
= ¥ {{ item.running_total.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }}
</div>
</div>
<div class="rec-divider"></div>
<div class="rec-item rec-final">
<div class="rec-step">
<span class="rec-dot-final"></span>
<span class="rec-name rec-final-label">{{ result.final_label }}</span>
</div>
<div class="rec-amount rec-final-amount" :class="{ 'rec-negative': result.final_value < 0 }">
¥ {{ Math.abs(result.final_value).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }}
</div>
</div>
<div class="rec-summary">
<span class="rec-summary-item">基准值:¥ {{ result.base_value.toLocaleString() }}</span>
<span class="rec-summary-item">调整项:{{ result.adjustment_count }}/{{ result.total_adjustments }}</span>
<span class="rec-summary-item">最终值:¥ {{ result.final_value.toLocaleString() }}</span>
</div>
</div>
<el-empty v-else-if="!calculating" description="选择指标类型,点击「计算」生成合规调节表" />
<div v-if="!result?.has_real_data && result" class="demo-hint">
⚠️ 当前部分数据为示例数据,实际数据将在科目打标后显示
</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 底部操作 -->
<div class="mpm-actions" v-if="result">
<el-button type="primary" @click="exportDetail">导出附注明细</el-button>
<el-button @click="saveTemplate">保存为模板</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
// 统一走 api/index.ts 的 axios 实例(拦截器自动带 Bearer / 401 跳登录)
import api from '../api/index'
// ── 状态 ──
const period = ref('2026-06')
const indicatorType = ref('adjusted_net_profit')
const calculating = ref(false)
const result = ref<any>(null)
const adjusting = ref(false)
const indicatorOptions = [
{ value: 'ebitda', label: 'EBITDA', desc: '息税折旧摊销前利润' },
{ value: 'adjusted_net_profit', label: '调整后净利润', desc: '剔除非经常性项目' },
{ value: 'free_cash_flow', label: '自由现金流', desc: '经营现金流减资本支出' },
{ value: 'custom', label: '自定义', desc: '自定义管理层指标' },
]
// ── 调整项 ──
const adjustments = ref<any[]>([])
// 默认调整项配置
const DEFAULT_ADJUSTMENTS: Record<string, any[]> = {
ebitda: [
{ code: 'tax', name: '加:所得税费用', sign: 1, checked: true, amount: null },
{ code: 'interest', name: '加:利息支出', sign: 1, checked: true, amount: null },
{ code: 'depreciation', name: '加:折旧与摊销', sign: 1, checked: true, amount: null },
{ code: 'impairment', name: '加:资产减值损失', sign: 1, checked: false, amount: null },
],
adjusted_net_profit: [
{ code: 'impairment', name: '加:资产减值损失', sign: 1, checked: true, amount: null },
{ code: 'equity_incentive', name: '加:股权激励费用', sign: 1, checked: true, amount: null },
{ code: 'ma_cost', name: '加:并购相关费用', sign: 1, checked: false, amount: null },
{ code: 'nonrecurring_income', name: '减:非经常性投资收益', sign: -1, checked: true, amount: null },
{ code: 'asset_disposal', name: '减:资产处置收益', sign: -1, checked: false, amount: null },
{ code: 'government_grant', name: '减:政府补助', sign: -1, checked: false, amount: null },
],
free_cash_flow: [
{ code: 'capex', name: '减:资本支出', sign: -1, checked: true, amount: null },
{ code: 'working_capital', name: '减:营运资本增加', sign: -1, checked: true, amount: null },
{ code: 'maintenance_capex', name: '减:维护性资本支出', sign: -1, checked: false, amount: null },
{ code: 'dividend', name: '加:股息收入', sign: 1, checked: false, amount: null },
],
custom: [
{ code: 'adjustment_1', name: '调整项目1', sign: 1, checked: true, amount: null },
{ code: 'adjustment_2', name: '调整项目2', sign: -1, checked: true, amount: null },
{ code: 'adjustment_3', name: '调整项目3', sign: 1, checked: false, amount: null },
],
}
// 切换指标类型时更新调整项
watch(indicatorType, (val) => {
const defaults = DEFAULT_ADJUSTMENTS[val] || DEFAULT_ADJUSTMENTS.adjusted_net_profit
adjustments.value = JSON.parse(JSON.stringify(defaults))
result.value = null
}, { immediate: true })
function resetDefaults() {
const defaults = DEFAULT_ADJUSTMENTS[indicatorType.value] || DEFAULT_ADJUSTMENTS.adjusted_net_profit
adjustments.value = JSON.parse(JSON.stringify(defaults))
calculate()
}
function onAdjustmentChange() {
// 当用户勾选/取消或改金额时自动重新计算
calculate()
}
async function calculate() {
calculating.value = true
try {
const payload = {
indicator_type: indicatorType.value,
period: period.value,
adjustments: adjustments.value.map(a => ({
code: a.code,
name: a.name,
sign: a.sign,
checked: a.checked,
amount: a.amount,
})),
}
const r = await api.post('/reports/mpm-calculate', payload)
result.value = (r as any) || {}
} catch (e) {
ElMessage.error('计算失败,请检查参数')
result.value = null
} finally {
calculating.value = false
}
}
function exportDetail() {
if (!result.value) return
const items = result.value.reconciliation_items || []
let text = `MPM管理层指标调节表\n`
text += `指标类型:${result.value.indicator_name}\n`
text += `期间:${result.value.period}\n`
text += `基准值(${result.value.base_label}):¥ ${result.value.base_value.toLocaleString()}\n`
text += `\n调节过程:\n`
text += `──────────────────────────\n`
items.forEach((item: any) => {
if (item.is_base) {
text += `${item.name}\t¥ ${item.amount?.toLocaleString() || '-'}\n`
} else if (item.checked && item.effective != null) {
const signStr = item.sign > 0 ? '+' : '-'
text += ` ${signStr} ${item.name}\t¥ ${Math.abs(item.effective).toLocaleString()}\t余额:¥ ${item.running_total?.toLocaleString() || ''}\n`
}
})
text += `──────────────────────────\n`
text += `${result.value.final_label}\t¥ ${result.value.final_value.toLocaleString()}\n`
// 下载文本文件
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `MPM调节表_${result.value.period}_${result.value.indicator_name}.txt`
a.click()
URL.revokeObjectURL(url)
ElMessage.success('调节表明细已导出')
}
function saveTemplate() {
const payload = {
indicator_type: indicatorType.value,
adjustments: adjustments.value,
period: period.value,
}
try {
const saved = JSON.parse(localStorage.getItem('mpm_templates') || '[]')
const name = `${indicatorType.value}_${period.value}_${Date.now()}`
saved.push({ name, data: payload, savedAt: new Date().toISOString() })
localStorage.setItem('mpm_templates', JSON.stringify(saved))
ElMessage.success(`模板已保存:${name}`)
} catch {
ElMessage.error('保存模板失败')
}
}
</script>
<style scoped>
.mpm-page { max-width: 1400px; margin: 0 auto; }
.mpm-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.header-right { display: flex; gap: 8px; align-items: center; }
.param-card { min-height: 400px; }
.result-card { min-height: 400px; }
.result-header { display: flex; align-items: center; gap: 8px; }
.param-section { margin-bottom: 8px; }
.param-label { font-size: 14px; font-weight: 600; color: #1a1a2e; display: block; margin-bottom: 8px; }
.param-hint { font-size: 11px; color: #999; font-weight: 400; margin-left: 8px; }
.indicator-types { display: flex; flex-direction: column; gap: 6px; }
.indicator-radio { display: flex; align-items: center; padding: 6px 0; }
.indicator-opt { display: flex; flex-direction: column; }
.indicator-name { font-weight: 600; font-size: 13px; }
.indicator-desc { font-size: 11px; color: #999; }
.adjustment-list { display: flex; flex-direction: column; gap: 6px; }
.adjustment-row {
display: flex; align-items: center; justify-content: space-between;
padding: 4px 8px; border-radius: 4px; transition: background 0.2s;
}
.adjustment-row:hover { background: #f5f7fa; }
.adj-sign-positive { color: #67c23a; font-weight: 600; }
.adj-sign-negative { color: #f56c6c; font-weight: 600; }
.empty-hint { color: #ccc; font-size: 12px; text-align: center; padding: 20px; }
/* 调节表样式 */
.reconciliation-table { padding: 4px 0; }
.rec-item {
display: flex; align-items: center; gap: 8px;
padding: 8px 12px; border-radius: 4px;
margin-bottom: 2px;
transition: background 0.2s;
}
.rec-base { background: #f0f9eb; border-left: 3px solid #67c23a; }
.rec-checked { background: #f5f7fa; border-left: 3px solid #409eff; }
.rec-unchecked { opacity: 0.4; border-left: 3px solid #ddd; }
.rec-final { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; border-radius: 6px; margin-top: 4px; }
.rec-step { flex: 1; display: flex; align-items: center; gap: 6px; }
.rec-dot-base { width: 8px; height: 8px; border-radius: 50%; background: #67c23a; flex-shrink: 0; }
.rec-dot-checked { width: 8px; height: 8px; border-radius: 50%; background: #409eff; flex-shrink: 0; }
.rec-dot-unchecked { width: 8px; height: 8px; border-radius: 50%; background: #ddd; flex-shrink: 0; }
.rec-dot-final { width: 8px; height: 8px; border-radius: 50%; background: #fff; flex-shrink: 0; }
.rec-name { font-size: 13px; }
.rec-amount { font-size: 13px; font-weight: 500; min-width: 160px; text-align: right; font-variant-numeric: tabular-nums; }
.rec-negative { color: #f56c6c; }
.rec-final .rec-amount { font-size: 16px; font-weight: 700; }
.rec-final .rec-name { font-weight: 700; }
.rec-no-data { color: #999; font-size: 11px; }
.rec-running { font-size: 12px; color: #999; min-width: 160px; text-align: right; font-variant-numeric: tabular-nums; }
.rec-final .rec-running { color: rgba(255,255,255,0.7); }
.rec-divider { height: 1px; background: #ebeef5; margin: 8px 12px; }
.rec-summary {
display: flex; gap: 16px; padding: 8px 12px; margin-top: 8px;
background: #fafafa; border-radius: 4px; font-size: 12px; color: #666;
}
.rec-summary-item { flex: 1; }
.demo-hint { font-size: 11px; color: #d46b08; margin-top: 8px; padding: 4px 12px; background: #fff7e6; border-radius: 4px; }
.mpm-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; }
</style>