Files
cma-management/frontend/src/views/ReportCenter.vue
T
Hermes CI Fix 72c1bbce16 chore(ui): drop remaining emoji in ReportCenter header block
Same page header as the P3-1 item; keeps the header free of emoji.

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

1183 lines
61 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-tag size="small" type="primary" effect="plain" class="entity-tag">{{ currentEntityNameTag }}</el-tag>
<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>
<!-- ════ 预编报表(预算版三张报表) ════ -->
<el-tab-pane label="📋 预编报表" name="proforma">
<div v-loading="loadingProforma">
<div class="report-desc">预算版三张报表:预算 vs 实际 vs 差异(口径与预算执行报告一致)。无预算映射的行显式标注,不填示例数据。</div>
<div style="display:flex;gap:8px;margin:12px 0;align-items:center;">
<el-radio-group v-model="proformaType" size="small" @change="loadProforma">
<el-radio-button value="profit">预算版利润表</el-radio-button>
<el-radio-button value="balance">预算版资产负债表</el-radio-button>
<el-radio-button value="cash">预算版现金流量表</el-radio-button>
</el-radio-group>
<el-tag v-if="proformaBudgetVersion" size="small" type="info">预算版本: {{ proformaBudgetVersion }}</el-tag>
</div>
<el-table :data="proformaRows" border stripe size="small" style="width:100%;" :row-class-name="proformaRowClass">
<el-table-column prop="name" label="项目" min-width="180" />
<el-table-column label="预算值" width="130" align="right">
<template #default="{ row }">{{ row.budget_value != null ? row.budget_value.toLocaleString() : (row.has_budget ? '-' : '—') }}</template>
</el-table-column>
<el-table-column label="实际值" width="130" align="right">
<template #default="{ row }">{{ row.actual_value != null ? row.actual_value.toLocaleString() : '-' }}</template>
</el-table-column>
<el-table-column label="差异" width="140" align="right">
<template #default="{ row }">
<span v-if="row.subtotal" style="color:#909399;">小计</span>
<span v-else-if="row.ratio_kpi" style="color:#909399;">比率型</span>
<span v-else-if="!row.has_budget" style="color:#bbb;">无预算</span>
<span v-else :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="110" align="right">
<template #default="{ row }">
<span v-if="row.subtotal" style="color:#909399;">—</span>
<el-tag v-else-if="row.ratio_kpi" type="info" size="small">比率型</el-tag>
<span v-else-if="!row.has_budget" style="color:#bbb;">无预算</span>
<el-tag v-else-if="row.deviation_rate != null" :type="proformaRateType(row.deviation_rate)" 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-column label="预算来源" width="110">
<template #default="{ row }">
<el-tag v-if="row.has_budget && !row.subtotal" :type="row.budget_source === 'budget_plan' ? 'success' : 'warning'" size="small">
{{ row.budget_source === 'budget_plan' ? '预算方案' : row.budget_source === 'target_split' ? '目标分摊' : '—' }}
</el-tag>
<span v-else style="color:#bbb;">—</span>
</template>
</el-table-column>
<el-table-column prop="note" label="备注" min-width="200" show-overflow-tooltip>
<template #default="{ row }"><span style="color:#909399;">{{ row.note || '' }}</span></template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
<!-- ════ 报表3:KPI趋势报告 ════ -->
<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>
<!-- ════ 报表4:BSC评分卡 ════ -->
<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>
<!-- ════ 对外法定报表:新30号准则三表合一 ════ -->
<el-tab-pane label="📑 对外法定报表" name="statutory">
<div v-loading="loadingStatutory">
<div class="report-desc">对外法定报表(新30号准则2027)— 利润表五板块+附注明细 / 资产负债表新准则科目分类 / 现金流量表三活动,支持导出Excel。</div>
<div class="statutory-toolbar">
<el-radio-group v-model="statutoryView" size="small">
<el-radio-button value="profit">利润表</el-radio-button>
<el-radio-button value="balance">资产负债表</el-radio-button>
<el-radio-button value="cashflow">现金流量表</el-radio-button>
</el-radio-group>
<el-button type="primary" size="small" :loading="exporting" @click="exportStatutory">📥 导出Excel(三表合一)</el-button>
<span v-if="statutoryDemoMode" class="new30-demo-hint">⚠️ 部分数据为示例数据,导入凭证/打标后显示实际数据</span>
</div>
<!-- ── 利润表(五板块+附注) ── -->
<div v-if="statutoryView === 'profit' && statutoryData.profit">
<div class="statutory-sub-title">利润表 — 新30号准则五板块({{ statutoryData.period }})</div>
<div class="new30-blocks">
<div v-for="block in statutoryData.profit.blocks || []" :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 }">{{ 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>
</div>
</div>
</div>
</div>
<div class="new30-net-profit" v-if="statutoryData.profit.net_profit != null">
<div class="new30-profit-row">
<span class="new30-profit-label">净利润:</span>
<span class="new30-profit-value" :class="{ negative: statutoryData.profit.net_profit < 0 }">{{ formatMoney(statutoryData.profit.net_profit) }}</span>
<span class="new30-profit-name">= 经营 + 投资 + 筹资 + 所得税 + 终止经营</span>
</div>
</div>
<!-- 附注明细 -->
<div v-if="statutoryData.profit.notes" class="statutory-notes">
<div class="statutory-notes-title">附注明细</div>
<div class="statutory-notes-grid">
<!-- 收入构成 -->
<div class="statutory-note-card">
<div class="statutory-note-head">附注一、收入构成</div>
<div v-for="n in statutoryData.profit.notes.revenue_breakdown || []" :key="n.code" class="statutory-note-row">
<span>{{ n.name }}({{ n.code }})</span>
<span>{{ n.amount != null ? formatMoney(n.amount) : '-' }}</span>
</div>
<div class="statutory-note-row total">
<span>营业收入合计</span>
<span>{{ statutoryData.profit.notes.revenue_total != null ? formatMoney(statutoryData.profit.notes.revenue_total) : '-' }}</span>
</div>
</div>
<!-- 费用构成 -->
<div class="statutory-note-card">
<div class="statutory-note-head">附注二、费用构成</div>
<div v-for="n in statutoryData.profit.notes.expense_breakdown || []" :key="n.code" class="statutory-note-row">
<span>{{ n.name }}({{ n.code }})</span>
<span>{{ n.amount != null ? formatMoney(n.amount) : '-' }}</span>
</div>
</div>
<!-- 财务费用拆解 -->
<div class="statutory-note-card">
<div class="statutory-note-head">附注三、财务费用拆解</div>
<div v-for="n in statutoryData.profit.notes.finance_breakdown || []" :key="n.code" class="statutory-note-row">
<span>{{ n.name }}({{ n.code }})</span>
<span>{{ n.amount != null ? formatMoney(n.amount) : '-' }}</span>
</div>
</div>
<!-- 板块勾稽 + 关键比率 -->
<div class="statutory-note-card">
<div class="statutory-note-head">附注四、板块勾稽</div>
<div v-for="n in statutoryData.profit.notes.block_reconciliation || []" :key="n.key" class="statutory-note-row">
<span>{{ n.name }}</span>
<span>{{ formatMoney(n.amount) }}</span>
</div>
<div class="statutory-note-row total">
<span>净利润</span>
<span>{{ formatMoney(statutoryData.profit.notes.net_profit) }}</span>
</div>
</div>
<div class="statutory-note-card">
<div class="statutory-note-head">附注五、关键比率</div>
<div v-for="n in statutoryData.profit.notes.key_ratios || []" :key="n.name" class="statutory-note-row">
<span>{{ n.name }}</span>
<span>{{ n.value != null ? n.value.toFixed(2) + '%' : '-' }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- ── 资产负债表(新准则科目分类) ── -->
<div v-else-if="statutoryView === 'balance' && statutoryData.balance_sheet">
<div class="statutory-sub-title">资产负债表 — 新30号准则科目分类({{ statutoryData.period }})</div>
<el-table :data="balanceSheetRows" border stripe size="small" style="width:100%;margin-top:12px;" :row-class-name="statutoryBsRowClass">
<el-table-column label="项目" min-width="220">
<template #default="{ row }">
<span :style="{ fontWeight: row.is_section ? 700 : row.is_total ? 700 : 400, paddingLeft: row.is_section ? '0' : '16px' }">{{ row.name }}</span>
</template>
</el-table-column>
<el-table-column label="新准则分类" width="110">
<template #default="{ row }">
<el-tag v-if="row.ns_category_label && !row.is_section" size="small" :type="statutoryNsTagType(row.ns_category)">{{ row.ns_category_label }}</el-tag>
<span v-else-if="row.is_section" style="color:#999;">{{ row.category_label }}</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="期末余额" width="150" align="right">
<template #default="{ row }">
<span :style="{ fontWeight: row.is_section || row.is_total ? 700 : 400 }">{{ row.end_value != null ? formatMoney(row.end_value) : '-' }}</span>
</template>
</el-table-column>
<el-table-column label="期初余额" width="150" align="right">
<template #default="{ row }">
<span :style="{ fontWeight: row.is_section || row.is_total ? 700 : 400 }">{{ row.begin_value != null ? formatMoney(row.begin_value) : '-' }}</span>
</template>
</el-table-column>
<el-table-column label="数据" width="80">
<template #default="{ row }">
<span v-if="row.is_demo && !row.is_section" class="new30-demo-tag">示例</span>
<span v-else-if="row.is_section" style="color:#999;font-size:12px;">小计</span>
<span v-else style="color:#999;font-size:12px;">凭证</span>
</template>
</el-table-column>
</el-table>
<div class="statutory-totals">
<div class="statutory-total-card" :class="{ ok: statutoryData.balance_sheet.totals?.balanced }">
<div class="statutory-total-label">资产总计</div>
<div class="statutory-total-val">{{ formatMoney(statutoryData.balance_sheet.totals?.assets?.end) }}</div>
</div>
<div class="statutory-total-card">
<div class="statutory-total-label">负债合计</div>
<div class="statutory-total-val">{{ formatMoney(statutoryData.balance_sheet.totals?.liabilities?.end) }}</div>
</div>
<div class="statutory-total-card">
<div class="statutory-total-label">所有者权益合计</div>
<div class="statutory-total-val">{{ formatMoney(statutoryData.balance_sheet.totals?.equity?.end) }}</div>
</div>
<div class="statutory-total-card" :class="{ ok: statutoryData.balance_sheet.totals?.balanced }">
<div class="statutory-total-label">勾稽校验(资产=负债+权益)</div>
<div class="statutory-total-val" style="font-size:14px;">{{ statutoryData.balance_sheet.totals?.balanced ? '✅ 平衡' : '⚠️ 不平' }}</div>
</div>
</div>
</div>
<!-- ── 现金流量表(三活动) ── -->
<div v-else-if="statutoryView === 'cashflow' && statutoryData.cash_flow">
<div class="statutory-sub-title">现金流量表 — 新30号准则三活动({{ statutoryData.period }})</div>
<div v-for="sec in statutoryData.cash_flow.sections || []" :key="sec.key" class="cf-section">
<div class="cf-section-head">
<span class="cf-section-name">{{ sec.name }}</span>
<span class="cf-section-net" :class="{ negative: sec.net < 0 }">{{ formatMoney(sec.net) }}</span>
</div>
<div v-for="line in sec.lines" :key="line.code" class="cf-line">
<span class="cf-line-name">{{ line.name }}</span>
<span class="cf-line-code">{{ line.code }}</span>
<span class="cf-line-value" :class="{ negative: line.value != null && line.value < 0 }">{{ line.value != null ? formatMoney(line.value) : '-' }}</span>
<span v-if="line.is_demo" class="new30-demo-tag">示例</span>
</div>
</div>
<div class="cf-section">
<div class="cf-section-head">
<span class="cf-section-name">{{ statutoryData.cash_flow.fx_effect?.name }}</span>
<span class="cf-section-net">{{ formatMoney(statutoryData.cash_flow.fx_effect?.value) }}</span>
</div>
</div>
<div class="cf-summary">
<div class="cf-summary-row">
<span>现金及现金等价物净增加额</span>
<span class="cf-summary-val">{{ formatMoney(statutoryData.cash_flow.summary?.net_increase) }}</span>
</div>
<div class="cf-summary-row">
<span>加:期初现金及现金等价物余额</span>
<span class="cf-summary-val">{{ formatMoney(statutoryData.cash_flow.summary?.begin_cash) }}</span>
</div>
<div class="cf-summary-row total">
<span>期末现金及现金等价物余额</span>
<span class="cf-summary-val">{{ formatMoney(statutoryData.cash_flow.summary?.end_cash) }}</span>
</div>
</div>
</div>
</div>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import axios from 'axios'
import { getFixedEntityId } from '../utils/entity'
// ── 状态 ──
const loading = ref(false)
const activeTab = ref('profit')
const reportPeriod = ref('2026-06')
// ── 当前企业(只读):2026-09-14 需求——登录即定企业,页内无切换入口
const currentEntityId = ref(Number(localStorage.getItem('cma_entity_id') || 1))
// 右上角只读企业标识(不可点)
const currentEntityNameTag = computed(() => {
const f = getFixedEntityId()
const n = localStorage.getItem('cma_entity_name')
return n || (f !== undefined ? `企业 #${f}` : '')
})
function getEntityId() {
// 固定企业版恒取构建锁定值(防 localStorage 残留串账套);默认版取登录时写入值
const f = getFixedEntityId()
if (f !== undefined) return f
return Number(localStorage.getItem('cma_entity_id') || currentEntityId.value || 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() {
// 新30号准则利润表导出 → 复用对外法定报表Excel(Sheet1=五板块利润表+附注)
exportStatutory()
}
function openSubjectManage() {
window.open('/subjects', '_blank')
}
// ════ 对外法定报表(新30号准则三表合一)════
const statutoryData = ref<any>({})
const statutoryView = ref('profit')
const statutoryDemoMode = ref(false)
const statutoryLoading = ref(false)
const exporting = ref(false)
async function loadStatutory() {
statutoryLoading.value = true
try {
const r = await api.get('/reports/statutory', { params: { period: reportPeriod.value, entity_id: getEntityId() } })
const d = (r as any).data || {}
statutoryData.value = d
statutoryDemoMode.value = !d.cash_flow?.all_items_have_data
} catch (e) {
ElMessage.error('加载法定报表失败')
} finally {
statutoryLoading.value = false
}
}
async function exportStatutory() {
exporting.value = true
try {
const token = localStorage.getItem('cma_token') || ''
const url = `/api/cma/reports/statutory/export?period=${reportPeriod.value}&entity_id=${getEntityId()}`
const resp = await fetch(url, { headers: { 'Authorization': `Bearer ${token}` } })
if (!resp.ok) throw new Error('导出失败')
const blob = await resp.blob()
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = `对外法定报表_${reportPeriod.value}.xlsx`
a.click()
URL.revokeObjectURL(a.href)
ElMessage.success('导出成功')
} catch (e) {
ElMessage.error('导出失败')
} finally {
exporting.value = false
}
}
// ── 报表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()
loadStatutory()
}
// ── 加载 ──
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 = [] }
}
// ── 预编报表(预算版三张报表)──
const loadingProforma = ref(false)
const proformaType = ref('profit')
const proformaData = ref<any>(null)
const proformaBudgetVersion = computed(() => {
const v = proformaData.value?.budget_version
return Array.isArray(v) ? v.join(', ') : (v || '')
})
const proformaRows = computed(() => {
const d = proformaData.value
if (!d) return []
const rows: any[] = []
if (proformaType.value === 'profit') {
for (const b of (d.blocks || [])) {
rows.push({ name: b.name, subtotal: true, actual_value: b.subtotal_actual, budget_value: b.subtotal_budget, has_budget: b.has_budget })
for (const it of (b.items || [])) rows.push({ ...it, name: it.name })
}
rows.push({ name: '= 净利润', subtotal: true, actual_value: d.net_profit_actual, budget_value: d.net_profit_budget, has_budget: true })
} else if (proformaType.value === 'balance') {
for (const s of (d.sections || [])) {
rows.push({ name: s.name, subtotal: true, actual_value: s.subtotal_actual, budget_value: s.subtotal_budget, has_budget: s.has_budget })
for (const ln of (s.lines || [])) rows.push({ ...ln, name: ln.name })
}
} else {
for (const s of (d.sections || [])) {
rows.push({ name: s.name, subtotal: true, actual_value: s.net_actual, budget_value: s.net_budget, has_budget: s.has_budget })
for (const ln of (s.lines || [])) rows.push({ ...ln, name: ln.name })
}
const sm = d.summary || {}
rows.push({ name: '= 现金净增加额', subtotal: true, actual_value: sm.net_increase_actual, budget_value: sm.net_increase_budget, has_budget: true })
}
return rows
})
async function loadProforma() {
loadingProforma.value = true
try {
const map: Record<string, string> = {
profit: '/reports/proforma/profit-statement',
balance: '/reports/proforma/balance-sheet',
cash: '/reports/proforma/cash-flow',
}
const r = await api.get(map[proformaType.value], { params: { period: reportPeriod.value, entity_id: getEntityId() } })
proformaData.value = (r as any).data || {}
} catch (e) {
proformaData.value = null
ElMessage.error('加载预编报表失败')
} finally {
loadingProforma.value = false
}
}
function proformaRowClass({ row }: any) {
if (row.subtotal) return 'proforma-subtotal'
if (!row.has_budget) return 'proforma-no-budget'
return ''
}
function proformaRateType(rate: number): string {
const abs = Math.abs(rate)
if (abs > 20) return 'danger'
if (abs > 10) return 'warning'
return 'success'
}
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(), loadProforma(), 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()
loadStatutory()
})
</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; }
/* 预编报表(预算版) */
:deep(.proforma-no-budget) { opacity: 0.55; }
:deep(.proforma-subtotal) { background: #f0f7ff !important; font-weight: 600; color: #1f2d3d; }
/* 统计卡片 */
.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>