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

801 lines
40 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="report-page">
<!-- 顶部 -->
<div class="report-header">
<h3 class="page-title">📊 CMA管理报表</h3>
<div class="header-right">
<el-date-picker
v-model="reportPeriod"
type="month"
placeholder="选择月份"
value-format="YYYY-MM"
size="small"
style="width:140px"
@change="onPeriodChange"
/>
<el-button text size="small" :loading="loading" @click="loadAll">刷新</el-button>
<el-button type="primary" size="small" @click="openDupont">📊 杜邦分析</el-button>
<el-button type="success" size="small" @click="openSubjectManage">📋 科目打标</el-button>
</div>
</div>
<!-- 标签页 -->
<el-tabs v-model="activeTab" type="border-card" class="report-tabs">
<!-- ════ 报表1管理利润表 ════ -->
<el-tab-pane label="💰 管理利润表" name="profit">
<div v-loading="loading">
<div class="report-desc">基于管理会计视角的利润结构分析收入 变动成本 边际贡献 固定成本 息税前利润</div>
<el-table :data="profitItems" border stripe size="small" style="width:100%;margin-top:12px;" :summary-method="profitSummary" show-summary>
<el-table-column prop="name" label="项目" width="200">
<template #default="{ row }">
<span :style="{ fontWeight: row.is_total ? 700 : row.is_subtotal ? 600 : 400 }">{{ row.name }}</span>
</template>
</el-table-column>
<el-table-column label="本期金额" width="160" align="right">
<template #default="{ row }">
<span :style="{ fontWeight: row.is_total ? 700 : 400, color: (row.name?.includes('利润') || row.name?.includes('贡献')) && row.value != null && row.value < 0 ? '#f56c6c' : '#333' }">
{{ row.value != null ? '¥ ' + row.value.toLocaleString() : '-' }}
</span>
</template>
</el-table-column>
<el-table-column label="上期金额" width="160" align="right">
<template #default="{ row }">
<span style="color:#999;">{{ row.prev_value != null ? '¥ ' + row.prev_value.toLocaleString() : '-' }}</span>
</template>
</el-table-column>
<el-table-column label="环比变化" width="120" align="right">
<template #default="{ row }">
<span v-if="row.change_rate != null" :style="{ color: row.change_rate > 0 ? '#f56c6c' : row.change_rate < 0 ? '#67c23a' : '#999', fontWeight: 600 }">
{{ row.change_rate > 0 ? '↑' : '↓' }}{{ Math.abs(row.change_rate).toFixed(1) }}%
</span>
<span v-else style="color:#ccc;">-</span>
</template>
</el-table-column>
<el-table-column label="占比" width="100" align="right">
<template #default="{ row }">
<span v-if="row.ratio != null" style="color:#666;">{{ row.ratio.toFixed(1) }}%</span>
<span v-else style="color:#ccc;">-</span>
</template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
<!-- ════ 新30号准则利润表 ════ -->
<el-tab-pane label="📋 新30号准则" name="new30">
<div v-loading="loadingNew30">
<div class="report-desc">基于新30号准则的五板块利润表列报2027)。经营/投资/筹资分类列示财务费用拆解</div>
<div class="new30-blocks">
<div v-for="block in new30Blocks" :key="block.key" class="new30-block">
<div class="new30-block-header" @click="block.expanded = !block.expanded">
<span class="new30-toggle">{{ block.expanded ? '▼' : '▶' }}</span>
<span class="new30-block-name">{{ block.name }}</span>
<span class="new30-block-subtotal" :class="{ negative: block.subtotal < 0 }">
{{ block.subtotal != null ? formatMoney(block.subtotal) : '-' }}
</span>
<span class="new30-block-label">{{ block.subtotal_name }}</span>
<span v-if="!block.has_real_data" class="new30-demo-tag">示例数据</span>
</div>
<div v-if="block.expanded" class="new30-block-body">
<div v-for="item in block.items" :key="item.code" class="new30-item">
<span class="new30-item-name">{{ item.name }}</span>
<span class="new30-item-amount" :class="{ negative: item.effective != null && item.effective < 0 }">
{{ item.effective != null ? formatMoney(item.effective) : '-' }}
</span>
<span v-if="item.effective != null && block.subtotal != null && block.subtotal !== 0" class="new30-item-ratio">
{{ (Math.abs(item.effective) / Math.abs(block.subtotal) * 100).toFixed(1) }}%
</span>
</div>
</div>
</div>
</div>
<div class="new30-net-profit" v-if="new30Format !== 'dual'">
<div class="new30-profit-row">
<span class="new30-profit-label">合计</span>
<span class="new30-profit-value" :class="{ negative: new30NetProfit < 0 }">
{{ formatMoney(new30NetProfit) }}
</span>
<span class="new30-profit-name">净利润</span>
</div>
</div>
<!-- 双列对比模式 -->
<div v-if="new30Format === 'dual' && dualComparisonData" class="dual-comparison">
<div class="dual-desc">新旧准则双列对比 旧准则一行到底 vs 新准则五板块</div>
<div class="dual-grid">
<!-- 旧准则列 -->
<div class="dual-col">
<div class="dual-col-header old-col">旧准则格式</div>
<el-table :data="dualComparisonData.old_format?.items || []" border stripe size="small" style="width:100%;">
<el-table-column prop="name" label="项目" width="180">
<template #default="{ row }">
<span :style="{ fontWeight: row.is_total ? 700 : row.is_subtotal ? 600 : 400 }">{{ row.name }}</span>
</template>
</el-table-column>
<el-table-column label="金额" width="140" align="right">
<template #default="{ row }">
<span :style="{ fontWeight: row.is_total ? 700 : 400 }">
{{ row.value != null ? '¥ ' + row.value.toLocaleString() : '-' }}
</span>
</template>
</el-table-column>
</el-table>
</div>
<!-- 新准则列 -->
<div class="dual-col">
<div class="dual-col-header new-col">新30号准则格式</div>
<div class="dual-blocks">
<div v-for="block in dualComparisonData.new_format?.blocks || []" :key="block.key" class="dual-block">
<div class="dual-block-header">
<span class="dual-block-name">{{ block.short_name }}</span>
<span class="dual-block-subtotal">{{ formatMoney(block.subtotal) }}</span>
</div>
<div v-for="item in block.items" :key="item.code" class="dual-block-item">
<span class="dual-item-name">{{ item.name }}</span>
<span class="dual-item-amount">{{ item.effective != null ? formatMoney(item.effective) : '-' }}</span>
</div>
</div>
</div>
<div class="dual-net-profit">
<span>净利润</span>
<span class="dual-net-val">{{ formatMoney(dualComparisonData.new_format?.net_profit) }}</span>
</div>
</div>
</div>
</div>
<div class="new30-footer">
<el-button size="small" :type="new30Format === 'new' ? 'primary' : ''" @click="switchNew30Format('new')">新准则</el-button>
<el-button size="small" :type="new30Format === 'old' ? 'primary' : ''" @click="switchNew30Format('old')">旧准则</el-button>
<el-button size="small" :type="new30Format === 'dual' ? 'primary' : ''" @click="switchNew30Format('dual')">双列对比</el-button>
<el-button size="small" @click="exportNew30">导出</el-button>
<span v-if="new30DemoMode" class="new30-demo-hint"> 当前显示示例数据科目打标后显示实际数据</span>
</div>
</div>
</el-tab-pane>
<!-- ════ 报表2预算执行报告 ════ -->
<el-tab-pane label="📋 预算执行报告" name="budget">
<div v-loading="loading">
<div class="report-desc">各KPI预算 vs 实际差异分析正值=超预算负值=节约</div>
<div class="summary-row" style="margin-top:12px;">
<div class="stat-card blue"><div class="stat-val">{{ budgetSummary.total }}</div><div class="stat-label">KPI总数</div></div>
<div class="stat-card green"><div class="stat-val">{{ budgetSummary.with_budget }}</div><div class="stat-label">有预算</div></div>
<div class="stat-card red"><div class="stat-val">{{ budgetSummary.over_budget }}</div><div class="stat-label">超预算</div></div>
<div class="stat-card gray"><div class="stat-val">{{ budgetSummary.under_budget }}</div><div class="stat-label">节约</div></div>
<div class="stat-card yellow"><div class="stat-val">{{ budgetSummary.normal }}</div><div class="stat-label">正常</div></div>
</div>
<div style="display:flex;gap:8px;margin:12px 0;">
<el-select v-model="budgetDim" placeholder="维度" clearable size="small" style="width:120px;" @change="loadBudget">
<el-option v-for="d in dimensions" :key="d.value" :label="d.label" :value="d.value" />
</el-select>
<el-select v-model="budgetLevel" placeholder="状态" clearable size="small" style="width:120px;" @change="loadBudget">
<el-option label="超预算" value="red" />
<el-option label="预警" value="yellow" />
<el-option label="正常" value="normal" />
</el-select>
</div>
<el-table :data="budgetItems" border stripe size="small" style="width:100%;" @sort-change="onBudgetSort">
<el-table-column prop="kpi_name" label="KPI名称" min-width="150" sortable="custom" />
<el-table-column prop="dimension" label="维度" width="90">
<template #default="{ row }">{{ dimLabel(row.dimension) }}</template>
</el-table-column>
<el-table-column label="实际值" width="120" align="right" prop="actual_value" sortable="custom">
<template #default="{ row }">{{ row.actual_value != null ? row.actual_value.toLocaleString() : '-' }}</template>
</el-table-column>
<el-table-column label="预算值" width="120" align="right" prop="budget_value" sortable="custom">
<template #default="{ row }">{{ row.budget_value != null ? row.budget_value.toLocaleString() : '-' }}</template>
</el-table-column>
<el-table-column label="差异额" width="120" align="right" prop="deviation_amount" sortable="custom">
<template #default="{ row }">
<span :style="{ color: row.deviation_amount > 0 ? '#f56c6c' : row.deviation_amount < 0 ? '#67c23a' : '#999' }">
{{ row.deviation_amount != null ? (row.deviation_amount > 0 ? '+' : '') + row.deviation_amount.toLocaleString() : '-' }}
</span>
</template>
</el-table-column>
<el-table-column label="差异率" width="100" align="right" prop="deviation_rate" sortable="custom">
<template #default="{ row }">
<el-tag v-if="row.deviation_rate != null" :type="row.alert_level === 'red' ? 'danger' : row.alert_level === 'yellow' ? 'warning' : 'success'" size="small">
{{ row.deviation_rate > 0 ? '+' : '' }}{{ row.deviation_rate.toFixed(1) }}%
</el-tag>
<span v-else style="color:#ccc;">-</span>
</template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
<!-- ════ 报表3KPI趋势报告 ════ -->
<el-tab-pane label="📈 KPI趋势报告" name="trends">
<div v-loading="loading">
<div class="report-desc">选定KPI的历史趋势分析含目标线标记</div>
<div style="display:flex;gap:8px;margin-top:12px;flex-wrap:wrap;">
<el-select v-model="trendDim" placeholder="维度" clearable size="small" style="width:120px;" @change="loadTrends">
<el-option v-for="d in dimensions" :key="d.value" :label="d.label" :value="d.value" />
</el-select>
<el-select v-model="trendKpiId" placeholder="选择KPI" clearable filterable size="small" style="width:200px;" @change="loadTrends">
<el-option v-for="k in kpiOptions" :key="k.id" :label="k.kpi_name" :value="k.id" />
</el-select>
<el-select v-model="trendMonths" size="small" style="width:100px;" @change="loadTrends">
<el-option label="12期" :value="12" />
<el-option label="24期" :value="24" />
<el-option label="30期" :value="30" />
</el-select>
</div>
<div v-if="trendData.length > 0" class="trend-grid">
<el-card v-for="t in trendData" :key="t.kpi_id" class="trend-card-2">
<template #header>
<div class="trend-hd">
<span class="trend-name">{{ t.kpi_name }}</span>
<span class="trend-unit">{{ t.unit }}</span>
<span class="trend-stat">均值 {{ t.avg }} | 最高 {{ t.max }} | 最低 {{ t.min }}</span>
<span class="trend-dir" :class="t.trend_dir">{{ t.trend_dir === 'up' ? '↑ 上升' : t.trend_dir === 'down' ? '↓ 下降' : '→ 平稳' }}</span>
</div>
</template>
<div :ref="el => setTrendChartRef(t.kpi_id, el)" style="height:180px;"></div>
</el-card>
</div>
<el-empty v-else description="请选择KPI查看趋势" />
</div>
</el-tab-pane>
<!-- ════ 报表4BSC评分卡 ════ -->
<el-tab-pane label="🎯 四维度绩效评分卡" name="bsc">
<div v-loading="loading">
<div class="report-desc">基于BSC框架的四维度绩效评分从已发布战略地图构建</div>
<div v-if="bscData.overall_score != null" style="margin-top:12px;">
<div class="bsc-overall">
<div class="bsc-score-ring" :style="{ borderColor: bscScoreColor(bscData.overall_score) }">
<span class="bsc-score-val">{{ bscData.overall_score }}</span>
<span class="bsc-score-label">综合评分</span>
</div>
<div class="bsc-map-info" v-if="bscData.map_title">基于{{ bscData.map_title }} | 周期{{ bscData.period }}</div>
</div>
<div class="bsc-dims">
<div v-for="dim in bscData.dimensions" :key="dim.key" class="bsc-dim-card">
<div class="bsc-dim-head" :style="{ borderLeft: '4px solid ' + dim.color }">
<span class="bsc-dim-icon">{{ dim.icon }}</span>
<span class="bsc-dim-name">{{ dim.name }}</span>
<span class="bsc-dim-score" :style="{ color: bscScoreColor(dim.score) }">{{ dim.score }}</span>
</div>
<div class="bsc-dim-body">
<div v-for="obj in dim.objectives" :key="obj.name" class="bsc-obj-row">
<div class="bsc-obj-name">{{ obj.name }}</div>
<div class="bsc-obj-kpis" v-if="obj.kpis && obj.kpis.length > 0">
<div v-for="k in obj.kpis.slice(0, 3)" :key="k.code" class="bsc-kpi-mini">
<span class="bsc-kpi-dot" :class="'dot-' + k.level"></span>
<span class="bsc-kpi-name">{{ k.name }}</span>
<span class="bsc-kpi-score">{{ k.score ?? '-' }}</span>
</div>
<div v-if="obj.kpis.length > 3" class="bsc-kpi-more">+{{ obj.kpis.length - 3 }} 更多</div>
</div>
<div v-else class="bsc-obj-empty">暂无KPI数据</div>
</div>
</div>
</div>
</div>
</div>
<el-empty v-else description="暂无已发布的战略地图,请先在战略地图中发布" />
</div>
</el-tab-pane>
<!-- ════ 追溯调整2026数据按新30号准则重述 ════ -->
<el-tab-pane label="🔄 追溯调整" name="restatement">
<div v-loading="loadingRestatement">
<div class="report-desc">2026年数据按新30号准则重述 旧口径vs新口径双列对比差异自动高亮</div>
<!-- 净利润对比 -->
<div v-if="restatementNetProfit" class="restate-net-box">
<div class="restate-net-row">
<span class="restate-net-label">旧口径净利润</span>
<span class="restate-net-val">{{ formatMoney(restatementNetProfit.old_net_profit) }}</span>
<span class="restate-net-arrow"></span>
<span class="restate-net-label">新口径净利润</span>
<span class="restate-net-val" :class="{ negative: restatementNetProfit.new_net_profit < 0 }">{{ formatMoney(restatementNetProfit.new_net_profit) }}</span>
<span class="restate-net-diff" :class="{ up: restatementNetProfit.difference > 0, down: restatementNetProfit.difference < 0 }">
差异{{ restatementNetProfit.difference > 0 ? '+' : '' }}{{ formatMoney(restatementNetProfit.difference) }}
</span>
</div>
</div>
<!-- 汇总统计 -->
<div v-if="restatementSummary" class="restate-summary-row">
<div class="restate-stat green"><span class="restate-stat-val">{{ restatementSummary.total_items }}</span> 总项目</div>
<div class="restate-stat red"><span class="restate-stat-val">{{ restatementSummary.adjusted_items }}</span> 需调整</div>
<div class="restate-stat blue"><span class="restate-stat-val">{{ restatementSummary.new_items }}</span> 新增</div>
<div class="restate-stat gray"><span class="restate-stat-val">{{ restatementSummary.unchanged_items }}</span> 不变</div>
</div>
<!-- 对比表格 -->
<el-table :data="restatementItems" border stripe size="small" style="width:100%;margin-top:12px;" :row-class-name="restatementRowClass">
<el-table-column prop="item_name" label="项目" width="240">
<template #default="{ row }">
<div class="restate-item-name">
<el-tag v-if="row.needs_adjustment" type="danger" size="small" effect="plain" class="restate-tag">调整</el-tag>
<el-tag v-else size="small" effect="plain" type="info" class="restate-tag">不变</el-tag>
<span :class="{ 'restate-breakdown-item': row.category === '_breakdown' }">{{ row.item_name }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="旧准则" width="160" align="right">
<template #default="{ row }">
<span :class="{ 'restate-highlight-red': row.needs_adjustment && row.old_value != null }">
{{ row.old_value != null ? formatMoney(row.old_value) : '-' }}
</span>
</template>
</el-table-column>
<el-table-column label="新准则" width="160" align="right">
<template #default="{ row }">
<span :class="{ 'restate-highlight-green': row.needs_adjustment && row.new_value != null }">
{{ row.new_value != null ? formatMoney(row.new_value) : '-' }}
</span>
</template>
</el-table-column>
<el-table-column label="差异" width="140" align="right">
<template #default="{ row }">
<span v-if="row.difference != null" :class="restateDiffClass(row.difference)">
{{ row.difference > 0 ? '+' : '' }}{{ formatMoney(row.difference) }}
</span>
<span v-else-if="row.needs_adjustment" class="restate-new-badge"></span>
<span v-else style="color:#ccc;">-</span>
</template>
</el-table-column>
<el-table-column label="调整原因" width="260">
<template #default="{ row }">
<span v-if="row.adjustment_reason" class="restate-reason">{{ row.adjustment_reason }}</span>
<span v-else style="color:#ccc;">-</span>
</template>
</el-table-column>
</el-table>
<div class="restate-legend">
<span class="restate-legend-item"><span class="restate-dot restate-dot-red"></span> 旧口径</span>
<span class="restate-legend-item"><span class="restate-dot restate-dot-green"></span> 新口径</span>
<span class="restate-legend-item"><span class="restate-dot restate-dot-orange"></span> 需调整项</span>
</div>
</div>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import axios from 'axios'
// ── 状态 ──
const loading = ref(false)
const activeTab = ref('profit')
const reportPeriod = ref('2026-06')
function getEntityId() {
return Number(localStorage.getItem('cma_entity_id') || 1)
}
// API 配置
const api = axios.create({ baseURL: '/api/cma', timeout: 30000 })
api.interceptors.request.use((config: any) => {
const token = localStorage.getItem('cma_token')
if (token) config.headers.Authorization = `'Bearer ' + token`
return config
})
// ── 报表1:管理利润表 ──
const profitItems = ref<any[]>([])
function profitSummary(param: any) {
const sums: string[] = ['合计']
const last = profitItems.value[profitItems.value.length - 1]
if (last) {
sums.push(last.value != null ? '¥ ' + last.value.toLocaleString() : '-')
sums.push(last.prev_value != null ? '¥ ' + last.prev_value.toLocaleString() : '-')
sums.push(last.change_rate != null ? (last.change_rate > 0 ? '↑' : '↓') + Math.abs(last.change_rate).toFixed(1) + '%' : '-')
sums.push(last.ratio != null ? last.ratio.toFixed(1) + '%' : '-')
} else {
sums.push('-', '-', '-', '-')
}
return sums
}
// ── 新30号准则利润表 ──
const loadingNew30 = ref(false)
const new30Blocks = ref<any[]>([])
const new30NetProfit = ref(0)
const new30Format = ref('new')
const new30DemoMode = ref(false)
const dualComparisonData = ref<any>(null)
async function loadNew30() {
loadingNew30.value = true
dualComparisonData.value = null
try {
const params: any = { period: reportPeriod.value, entity_id: getEntityId() }
if (new30Format.value === 'dual') {
params.format = 'dual'
} else {
params.format = 'new'
}
const r = await api.get('/reports/profit-statement', { params })
const d = (r as any).data || {}
if (new30Format.value === 'dual') {
dualComparisonData.value = d
new30Blocks.value = d.new_format?.blocks || []
new30NetProfit.value = d.new_format?.net_profit ?? 0
new30DemoMode.value = !d.new_format?.all_items_have_data
} else {
dualComparisonData.value = null
new30Blocks.value = d.blocks || []
new30NetProfit.value = d.net_profit ?? 0
new30DemoMode.value = !d.all_items_have_data
}
} catch (e) {
new30Blocks.value = []
new30NetProfit.value = 0
dualComparisonData.value = null
ElMessage.error('加载新30号准则利润表失败')
} finally {
loadingNew30.value = false
}
}
function formatMoney(val: number): string {
if (val == null) return '-'
const abs = Math.abs(val)
let formatted = '¥ ' + abs.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
if (val < 0) formatted = '-' + formatted
return formatted
}
function switchNew30Format(fmt: string) {
new30Format.value = fmt
if (fmt === 'new' || fmt === 'dual') {
loadNew30()
} else {
// 切换到旧准则tab
activeTab.value = 'profit'
}
}
function exportNew30() {
ElMessage.success('导出功能将在P2实现')
}
function openSubjectManage() {
window.open('/subjects', '_blank')
}
// ── 报表2:预算执行报告 ──
const budgetSummary = ref({ total: 0, with_budget: 0, over_budget: 0, under_budget: 0, normal: 0 })
const budgetItems = ref<any[]>([])
const budgetDim = ref('')
const budgetLevel = ref('')
// ── 报表3:KPI趋势报告 ──
const trendDim = ref('')
const trendKpiId = ref<number | null>(null)
const trendMonths = ref(24)
const trendData = ref<any[]>([])
const kpiOptions = ref<any[]>([])
const trendChartRefs: Record<string, HTMLElement> = {}
function setTrendChartRef(id: number, el: any) {
if (el) trendChartRefs['chart-' + id] = el
}
// ── 报表4:BSC评分卡 ──
const bscData = ref<any>({})
// ── 追溯调整:2026年数据按新30号准则重述 ──
const loadingRestatement = ref(false)
const restatementItems = ref<any[]>([])
const restatementSummary = ref<any>(null)
const restatementNetProfit = ref<any>(null)
async function loadRestatement() {
loadingRestatement.value = true
try {
const r = await api.get('/reports/restatement', { params: { period: reportPeriod.value, entity_id: getEntityId() } })
const d = (r as any).data || {}
restatementItems.value = d.items || []
restatementSummary.value = d.summary || null
restatementNetProfit.value = d.net_profit_comparison || null
} catch (e) {
restatementItems.value = []
restatementSummary.value = null
restatementNetProfit.value = null
ElMessage.error('加载追溯调整数据失败')
} finally {
loadingRestatement.value = false
}
}
function restatementRowClass({ row }: any) {
if (row.needs_adjustment) return 'restate-row-adjusted'
return ''
}
function restatementDiffClass(diff: number) {
if (diff > 0) return 'restate-diff-up'
if (diff < 0) return 'restate-diff-down'
return 'restate-diff-zero'
}
// ── 公共 ──
const dimensions = [
{ value: 'finance', label: '财务维度' },
{ value: 'customer', label: '客户维度' },
{ value: 'process', label: '内部流程' },
{ value: 'learning', label: '学习成长' },
]
function dimLabel(d: string): string {
const map: Record<string, string> = { finance: '财务', customer: '客户', process: '内部流程', learning: '学习成长' }
return map[d] || d
}
function bscScoreColor(score: number): string {
if (score >= 80) return 'var(--bsc-process)'
if (score >= 60) return 'var(--bsc-learning)'
return '#f56c6c'
}
function onPeriodChange() {
loadAll()
}
// ── 加载 ──
async function loadProfit() {
try {
const r = await api.get('/reports/profit-summary', { params: { period: reportPeriod.value, entity_id: getEntityId() } })
profitItems.value = (r as any).data?.items || []
} catch { profitItems.value = [] }
}
async function loadBudget() {
try {
const params: any = { period: reportPeriod.value, entity_id: getEntityId() }
if (budgetDim.value) params.dimension = budgetDim.value
if (budgetLevel.value) params.alert_level = budgetLevel.value
const r = await api.get('/reports/budget-execution', { params })
const d = (r as any).data || {}
budgetSummary.value = d.summary || {}
budgetItems.value = d.items || []
} catch { budgetItems.value = [] }
}
async function loadTrends() {
try {
const params: any = { months: trendMonths.value, entity_id: getEntityId() }
if (trendKpiId.value) params.kpi_id = trendKpiId.value
else if (trendDim.value) params.dimension = trendDim.value
const r = await api.get('/reports/kpi-trends', { params })
trendData.value = (r as any).data?.data || []
nextTick(() => renderTrendCharts())
} catch { trendData.value = [] }
}
async function loadBsc() {
try {
const r = await api.get('/reports/bsc-scorecard', { params: { period: reportPeriod.value, entity_id: getEntityId() } })
bscData.value = (r as any).data || {}
} catch { bscData.value = {} }
}
async function loadKpiOptions() {
try {
const r = await api.get('/kpis', { params: { page_size: 100, entity_id: getEntityId() } })
kpiOptions.value = (r as any).data?.data || []
} catch {}
}
function loadAll() {
loading.value = true
Promise.all([loadProfit(), loadNew30(), loadBudget(), loadTrends(), loadBsc(), loadRestatement()]).finally(() => { loading.value = false })
}
function openDupont() {
window.open('/dupont-analysis', '_blank')
}
// ── ECharts 渲染 ──
function renderTrendCharts() {
import('echarts').then(echarts => {
trendData.value.forEach((t: any) => {
const el = trendChartRefs['chart-' + t.kpi_id]
if (!el) return
const existing = echarts.getInstanceByDom(el)
if (existing) existing.dispose()
const chart = echarts.init(el)
const dates = t.trend.map((d: any) => d.period)
const values = t.trend.map((d: any) => d.value)
const series: any[] = [{
type: 'line', data: values, smooth: true, lineStyle: { width: 2 },
areaStyle: { opacity: 0.1 }, symbol: 'circle', symbolSize: 4,
name: '实际值',
}]
if (t.target_value != null) {
series.push({
type: 'line', data: Array(dates.length).fill(t.target_value),
lineStyle: { type: 'dashed', width: 1, color: '#f56c6c' },
symbol: 'none', name: '目标值',
})
}
chart.setOption({
grid: { left: 50, right: 15, top: 10, bottom: 28 },
xAxis: { type: 'category', data: dates, axisLabel: { fontSize: 10, rotate: 45 } },
yAxis: { type: 'value', splitLine: { lineStyle: { type: 'dashed', color: '#eee' } } },
series,
tooltip: { trigger: 'axis' },
legend: { show: true, bottom: 0, icon: 'circle', itemWidth: 8, itemHeight: 8 },
})
})
})
}
// ── 排序 ──
function onBudgetSort(sort: any) {
if (!sort.prop || !sort.order) return
const items = [...budgetItems.value]
items.sort((a: any, b: any) => {
const av = a[sort.prop] ?? 0
const bv = b[sort.prop] ?? 0
return sort.order === 'ascending' ? av - bv : bv - av
})
budgetItems.value = items
}
onMounted(() => {
loadKpiOptions()
loadAll()
})
</script>
<style scoped>
.report-page { max-width: 1400px; margin: 0 auto; }
.report-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.header-right { display: flex; gap: 8px; align-items: center; }
.report-tabs { margin-bottom: 20px; }
.report-desc { font-size: 13px; color: #888; padding: 8px 0; }
/* 统计卡片 */
.summary-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 12px; }
.stat-card { background: #fff; border-radius: 8px; padding: 12px 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.05); border-top: 3px solid #ccc; }
.stat-card.blue { border-top-color: #409eff; }
.stat-card.red { border-top-color: #f56c6c; }
.stat-card.green { border-top-color: #67c23a; }
.stat-card.yellow { border-top-color: var(--bsc-learning); }
.stat-card.gray { border-top-color: #909399; }
.stat-val { font-size: 22px; font-weight: 700; color: #1a1a2e; }
.stat-label { font-size: 12px; color: #888; margin-top: 2px; }
/* 趋势卡片 */
.trend-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 14px; margin-top: 12px; }
.trend-card-2 { border-radius: 10px; }
.trend-hd { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.trend-name { font-weight: 600; font-size: 14px; flex: 1; }
.trend-unit { color: #999; font-size: 12px; }
.trend-stat { font-size: 11px; color: #999; }
.trend-dir { font-size: 11px; padding: 2px 8px; border-radius: 4px; }
.trend-dir.up { background: #fff1f0; color: #cf1322; }
.trend-dir.down { background: #f6ffed; color: #389e0d; }
.trend-dir.stable { background: #f5f5f5; color: #999; }
/* BSC评分卡 */
.bsc-overall { text-align: center; padding: 20px 0; }
.bsc-score-ring {
display: inline-flex; flex-direction: column; align-items: center; justify-content: center;
width: 120px; height: 120px; border-radius: 50%; border: 6px solid #67c23a;
background: #fff; box-shadow: 0 2px 12px rgba(0,0,0,0.08);
}
.bsc-score-val { font-size: 36px; font-weight: 700; color: #1a1a2e; line-height: 1.1; }
.bsc-score-label { font-size: 12px; color: #999; margin-top: 2px; }
.bsc-map-info { font-size: 12px; color: #999; margin-top: 8px; }
.bsc-dims { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 14px; margin-top: 16px; }
.bsc-dim-card { background: #fff; border-radius: 10px; box-shadow: 0 1px 4px rgba(0,0,0,0.06); overflow: hidden; }
.bsc-dim-head { display: flex; align-items: center; gap: 8px; padding: 12px 14px; background: #fafafa; }
.bsc-dim-icon { font-size: 18px; }
.bsc-dim-name { font-weight: 600; font-size: 14px; flex: 1; }
.bsc-dim-score { font-size: 24px; font-weight: 700; }
.bsc-dim-body { padding: 10px 14px; }
.bsc-obj-row { margin-bottom: 10px; }
.bsc-obj-name { font-size: 13px; font-weight: 500; color: #555; margin-bottom: 4px; }
.bsc-obj-kpis { display: flex; flex-direction: column; gap: 3px; }
.bsc-kpi-mini { display: flex; align-items: center; gap: 6px; font-size: 12px; padding: 2px 6px; background: #f9f9f9; border-radius: 4px; }
.bsc-kpi-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
.dot-green { background: #67c23a; }
.dot-yellow { background: var(--bsc-learning); }
.dot-red { background: #f56c6c; }
.dot-gray { background: #ddd; }
.bsc-kpi-name { flex: 1; color: #666; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bsc-kpi-score { font-weight: 600; color: #333; }
.bsc-kpi-more { font-size: 11px; color: #409eff; text-align: center; padding: 2px; }
.bsc-obj-empty { font-size: 11px; color: #ccc; text-align: center; padding: 4px; }
/* 新30号准则样式 */
.new30-blocks { margin-top: 12px; }
.new30-block { background: #fff; border-radius: 8px; margin-bottom: 10px; box-shadow: 0 1px 3px rgba(0,0,0,0.04); border: 1px solid #ebeef5; overflow: hidden; }
.new30-block-header { display: flex; align-items: center; gap: 8px; padding: 10px 14px; cursor: pointer; background: #f9fafc; border-bottom: 1px solid #ebeef5; transition: background 0.2s; }
.new30-block-header:hover { background: #f0f2f5; }
.new30-toggle { font-size: 10px; color: #999; width: 16px; }
.new30-block-name { font-weight: 600; font-size: 14px; flex: 1; color: #1a1a2e; }
.new30-block-subtotal { font-size: 16px; font-weight: 700; color: #1a1a2e; min-width: 120px; text-align: right; }
.new30-block-subtotal.negative { color: #f56c6c; }
.new30-block-label { font-size: 11px; color: #999; width: 80px; }
.new30-demo-tag { font-size: 10px; background: #fff7e6; color: #d46b08; padding: 1px 6px; border-radius: 3px; }
.new30-block-body { padding: 6px 14px 10px 38px; }
.new30-item { display: flex; align-items: center; gap: 8px; padding: 4px 0; border-bottom: 1px dashed #f0f0f0; }
.new30-item:last-child { border-bottom: none; }
.new30-item-name { flex: 1; font-size: 13px; color: #555; }
.new30-item-amount { font-size: 13px; font-weight: 500; min-width: 120px; text-align: right; color: #333; }
.new30-item-amount.negative { color: #f56c6c; }
.new30-item-ratio { font-size: 11px; color: #bbb; width: 60px; text-align: right; }
.new30-net-profit { margin-top: 16px; padding: 12px 16px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 8px; color: #fff; }
.new30-profit-row { display: flex; align-items: center; gap: 12px; }
.new30-profit-label { font-size: 14px; opacity: 0.9; }
.new30-profit-value { font-size: 24px; font-weight: 700; flex: 1; }
.new30-profit-value.negative { color: #ffccc7; }
.new30-profit-name { font-size: 13px; opacity: 0.8; }
.new30-footer { margin-top: 12px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.new30-demo-hint { font-size: 11px; color: #d46b08; margin-left: 8px; }
/* 追溯调整样式 */
.restate-net-box { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 8px; padding: 14px 18px; color: #fff; margin-top: 12px; }
.restate-net-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.restate-net-label { font-size: 13px; opacity: 0.9; }
.restate-net-val { font-size: 18px; font-weight: 700; }
.restate-net-val.negative { color: #ffccc7; }
.restate-net-arrow { font-size: 18px; opacity: 0.7; }
.restate-net-diff { font-size: 13px; padding: 2px 10px; border-radius: 4px; }
.restate-net-diff.up { background: rgba(255,255,255,0.2); color: #b7eb8f; }
.restate-net-diff.down { background: rgba(255,255,255,0.2); color: #ffccc7; }
.restate-summary-row { display: flex; gap: 12px; margin-top: 12px; }
.restate-stat { padding: 8px 14px; border-radius: 6px; font-size: 13px; color: #555; border-left: 3px solid #ccc; background: #fafafa; }
.restate-stat.green { border-left-color: #67c23a; }
.restate-stat.red { border-left-color: #f56c6c; }
.restate-stat.blue { border-left-color: #409eff; }
.restate-stat.gray { border-left-color: #909399; }
.restate-stat-val { font-weight: 700; font-size: 16px; margin-right: 4px; }
.restate-item-name { display: flex; align-items: center; gap: 6px; }
.restate-tag { flex-shrink: 0; }
.restate-breakdown-item { padding-left: 16px; color: #666; font-size: 12px; }
.restate-highlight-red { background: #fff1f0; padding: 1px 4px; border-radius: 2px; font-weight: 600; color: #cf1322; }
.restate-highlight-green { background: #f6ffed; padding: 1px 4px; border-radius: 2px; font-weight: 600; color: #389e0d; }
.restate-diff-up { color: #f56c6c; font-weight: 600; }
.restate-diff-down { color: #67c23a; font-weight: 600; }
.restate-diff-zero { color: #999; }
.restate-new-badge { background: #409eff; color: #fff; padding: 1px 6px; border-radius: 3px; font-size: 11px; }
.restate-reason { font-size: 12px; color: #d46b08; }
.restate-legend { display: flex; gap: 16px; margin-top: 12px; padding: 8px 12px; background: #fafafa; border-radius: 6px; font-size: 12px; color: #666; }
.restate-legend-item { display: flex; align-items: center; gap: 4px; }
.restate-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
.restate-dot-red { background: #cf1322; }
.restate-dot-green { background: #389e0d; }
.restate-dot-orange { background: #d46b08; }
/* 双列对比样式 */
.dual-comparison { margin-top: 12px; }
.dual-desc { font-size: 13px; color: #888; margin-bottom: 12px; }
.dual-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
@media screen and (max-width: 1000px) { .dual-grid { grid-template-columns: 1fr; } }
.dual-col { background: #fff; border-radius: 8px; border: 1px solid #ebeef5; overflow: hidden; }
.dual-col-header { padding: 10px 14px; font-weight: 600; font-size: 14px; color: #fff; }
.dual-col-header.old-col { background: #909399; }
.dual-col-header.new-col { background: #409eff; }
.dual-blocks { padding: 8px; }
.dual-block { margin-bottom: 8px; border: 1px solid #ebeef5; border-radius: 4px; overflow: hidden; }
.dual-block-header { display: flex; justify-content: space-between; padding: 6px 10px; background: #f9fafc; font-size: 13px; font-weight: 600; }
.dual-block-subtotal { color: #1a1a2e; }
.dual-block-item { display: flex; justify-content: space-between; padding: 4px 10px 4px 16px; font-size: 12px; color: #555; border-top: 1px dashed #f0f0f0; }
.dual-item-name { flex: 1; }
.dual-item-amount { font-weight: 500; }
.dual-net-profit { padding: 10px 14px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; border-radius: 4px; margin: 8px; display: flex; align-items: center; gap: 8px; font-size: 14px; }
.dual-net-val { font-size: 18px; font-weight: 700; flex: 1; }
</style>