Files
cma-management/frontend/src/views/CashFlowStructure.vue
T
wecom-fullstack f51fc72ac7
ci/woodpecker/push/main Pipeline failed
style(page-title): B 全站规范——页面内同名标题清零 + 增量副标题弱化 + 标题 emoji 换 Lucide
- 39+ 页删除与布局标题同名/基本同名的页面内 h1-h4(同屏三标题→两处)
- 增量副标题弱化为 14px #64748B 小字:收付款计划/战略回顾/地图版本/会计科目打标
- 标题 emoji 清零,保留标题处改 Lucide 内联 SVG(bar-chart-3/video/scale/clipboard-list/alert-circle/map/history)
- KPIListView 增加 hideTitle(仅展示),客户/学习看板隐藏组件内重复页头
- 不动 MainLayout、不改接口/业务逻辑、不碰 MapCanvas.vue
2026-10-18 19:33:11 +08:00

613 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="cashflow-structure-page">
<div class="page-header">
<div class="header-right">
<el-tag size="small" type="info" effect="plain">{{ entityName || '账套' }}</el-tag>
<el-select v-model="period" size="small" style="width:140px" placeholder="选择期间" @change="loadData" :loading="loadingPeriods" clearable>
<el-option v-for="p in periods" :key="p" :value="p" :label="p" />
</el-select>
<el-button size="small" :icon="Refresh" @click="reload" :loading="loading">刷新</el-button>
<el-button v-if="canWrite" size="small" type="primary" @click="openDialog()">录入三大活动净额</el-button>
</div>
</div>
<div v-loading="loading" style="min-height:200px">
<!-- 无数据空态 -->
<el-empty v-if="!dataAvailable && !loading" :description="emptyMessage" style="padding:60px 0">
<el-button v-if="canWrite" type="primary" size="small" @click="openDialog()">录入官方现金流量表净额</el-button>
</el-empty>
<template v-if="dataAvailable">
<!-- 判定结果横幅 -->
<el-row :gutter="16">
<el-col :span="24">
<el-card shadow="never" class="verdict-card">
<div class="verdict-left" v-if="classification.classified">
<div class="verdict-badge">
<span class="verdict-type" v-if="isTwoDim">二维判定</span>
<span class="verdict-type" v-else>类型{{ classification.type_no }}</span>
<span class="verdict-name" v-if="isTwoDim">🧭 按 经营(±) × 筹资(±) 二维映射</span>
<span class="verdict-name" v-else>{{ classification.health_emoji }} {{ classification.name }}</span>
</div>
<div class="verdict-health">
<el-tag v-if="isTwoDim" size="small" type="warning">投资活动为 0:{{ investingNote }}</el-tag>
<el-tag v-else :type="healthTagType" size="small">健康度:{{ classification.health_emoji }} {{ classification.health_label }}</el-tag>
<el-tag v-if="classification.lifecycle" size="small" type="info" effect="plain" style="margin-left:8px">生命周期:{{ classification.lifecycle }}</el-tag>
<el-tag size="small" effect="plain" style="margin-left:8px">{{ source === 'official' ? '📄 官方现金流量表' : '✍️ 人工录入' }}</el-tag>
</div>
<!-- 裁定1:投资活动为 0 → 显式列出二维映射的候选类型(不得只给单一类型名) -->
<div v-if="isTwoDim" style="margin-top:8px">
<el-tag v-for="c in (classification.candidates || [])" :key="c.type_no"
size="small" effect="plain" style="margin-right:8px">
候选 类型{{ c.type_no }} {{ c.name }}(O{{ c.o_sign }} I{{ c.i_sign }} F{{ c.f_sign }})
</el-tag>
</div>
</div>
<div class="verdict-left" v-else>
<el-alert type="warning" :closable="false" :title="classification.reason || '净额数据不足,暂无法判定类型'" show-icon />
</div>
<div class="verdict-nets">
<div class="net-item" v-for="n in netList" :key="n.key">
<div class="net-name">{{ n.name }}</div>
<div class="net-value" :style="{ color: netColor(n) }">
{{ fmt(n.value) }}
</div>
<div class="net-sign" :style="{ color: netColor(n) }">
{{ n.sign === '+' ? '进钱 ▲' : n.sign === '-' ? '出钱 ▼' : '为0' }}
</div>
<!-- 裁定1:投资活动=0 的栏位显式标注「本期无投资活动」 -->
<div class="net-note" v-if="n.note">{{ n.note }}</div>
<div class="net-hint">{{ n.hint }}</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 瀑布图 + 画像/风险 -->
<el-row :gutter="16" style="margin-top:16px">
<el-col :span="15">
<el-card shadow="never" header="现金流瀑布图 — 期初现金 → 经营 → 投资 → 筹资 → 期末现金">
<div ref="waterfallRef" style="height:300px"></div>
<div v-if="!waterfall" style="color:#909399;font-size:12px;text-align:center;padding:8px">
暂缺期初/期末现金余额,仅显示三大活动净额
</div>
</el-card>
</el-col>
<el-col :span="9">
<el-card shadow="never" header="判定说明(CMA P2 · 现金流量表分析)">
<template v-if="classification.classified">
<el-alert v-if="isTwoDim" type="warning" :closable="false" title="投资活动为 0(本期无投资活动)" show-icon style="margin-bottom:8px">
<span>投资活动栏位显式标注「本期无投资活动」;按「经营(±) × 筹资(±)」二维映射到候选类型:{{ candidateLabel }}——投资活动无正负号,需结合业务实质在候选中确认。</span>
</el-alert>
<p class="desc-p">{{ classification.profile }}</p>
<el-alert type="error" :closable="false" title="风险提示" show-icon>
<span>{{ classification.risk }}</span>
</el-alert>
</template>
<div v-else style="color:#909399;font-size:13px;line-height:1.8">
八类判定需要经营(O)/投资(I)/筹资(F)三大活动净额符号:
<br />黄金法则:长期看经营现金流必须为正,且应大于净利润(利润质量)。
<br />当前账套请先录入官方现金流量表三大活动净额。
</div>
</el-card>
</el-col>
</el-row>
<!-- 八类判定规则表 -->
<el-row :gutter="16" style="margin-top:16px">
<el-col :span="24">
<el-card shadow="never" header="八类企业定性判定规则表(O/I/F 符号组合)">
<el-table :data="rules" size="small" stripe :row-class-name="ruleRowClass">
<el-table-column prop="type_no" label="类型" width="60" align="center">
<template #default="{ row }">
<el-tag :type="isHighlighted(row.type_no) ? 'primary' : 'info'" size="small" effect="plain">
{{ row.type_no }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="组合" width="150" align="center">
<template #default="{ row }">
<span class="sign-combo">
<b :style="{ color: row.o_sign === '+' ? '#67C23A' : '#F56C6C' }">O{{ row.o_sign }}</b>
<b :style="{ color: row.i_sign === '+' ? '#67C23A' : '#F56C6C' }">I{{ row.i_sign }}</b>
<b :style="{ color: row.f_sign === '+' ? '#67C23A' : '#F56C6C' }">F{{ row.f_sign }}</b>
</span>
</template>
</el-table-column>
<el-table-column prop="name" label="定性名称" width="120" />
<el-table-column label="健康度" width="120">
<template #default="{ row }">{{ row.health_emoji }} {{ row.health_label }}</template>
</el-table-column>
<el-table-column prop="lifecycle" label="生命周期" width="160" />
<el-table-column prop="profile" label="画像(行为特征)" min-width="300" show-overflow-tooltip />
<el-table-column prop="risk" label="风险提示" min-width="260" show-overflow-tooltip />
</el-table>
</el-card>
</el-col>
</el-row>
<!-- 已录入数据 -->
<el-row :gutter="16" style="margin-top:16px">
<el-col :span="24">
<el-card shadow="never" header="三大活动净额入库记录(万元)">
<el-table :data="valueRows" size="small" stripe>
<el-table-column prop="period" label="期间" width="100" />
<el-table-column label="经营活动净额" width="130" align="right">
<template #default="{ row }"><span :style="{ color: row.operating_net >= 0 ? '#67C23A' : '#F56C6C' }">{{ fmt(row.operating_net) }}</span></template>
</el-table-column>
<el-table-column label="投资活动净额" width="130" align="right">
<template #default="{ row }"><span :style="{ color: row.investing_net >= 0 ? '#67C23A' : '#F56C6C' }">{{ fmt(row.investing_net) }}</span></template>
</el-table-column>
<el-table-column label="筹资活动净额" width="130" align="right">
<template #default="{ row }"><span :style="{ color: row.financing_net >= 0 ? '#67C23A' : '#F56C6C' }">{{ fmt(row.financing_net) }}</span></template>
</el-table-column>
<el-table-column label="期初现金" width="100" align="right"><template #default="{ row }">{{ row.begin_cash != null ? fmt(row.begin_cash) : '-' }}</template></el-table-column>
<el-table-column label="期末现金" width="100" align="right"><template #default="{ row }">{{ row.end_cash != null ? fmt(row.end_cash) : '-' }}</template></el-table-column>
<el-table-column prop="source" label="来源" width="130" />
<el-table-column prop="remark" label="备注" min-width="160" show-overflow-tooltip />
<el-table-column prop="updated_at" label="更新时间" width="150" />
<el-table-column v-if="canWrite" label="操作" width="80" align="center">
<template #default="{ row }">
<el-button size="small" text type="primary" @click="openDialog(row)">更新</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</el-col>
</el-row>
</template>
</div>
<!-- 录入/更新弹窗(MyDialog 项目铁律) -->
<MyDialog v-model="showDialog" title="录入现金流量表三大活动净额" :width="620">
<el-form label-width="140px" size="small">
<el-form-item label="期间" required>
<el-input v-model="form.period" placeholder="YYYY-MM,如 2026-08" style="width:220px" />
<div class="form-tip">官方现金流量表所属期间(月度)</div>
</el-form-item>
<el-form-item label="经营活动净额" required>
<el-input-number v-model="form.operating_net" :precision="2" :step="1" :controls="true" style="width:220px" />
<div class="form-tip">万元;正=造血进钱,负=经营失血</div>
</el-form-item>
<el-form-item label="投资活动净额" required>
<el-input-number v-model="form.investing_net" :precision="2" :step="1" style="width:220px" />
<div class="form-tip">万元;负=扩张投入,正=处置/回报回笼</div>
</el-form-item>
<el-form-item label="筹资活动净额" required>
<el-input-number v-model="form.financing_net" :precision="2" :step="1" style="width:220px" />
<div class="form-tip">万元;正=融资输血,负=还债/分红</div>
</el-form-item>
<el-form-item label="期初现金余额">
<el-input-number v-model="form.begin_cash" :precision="2" :step="1" style="width:220px" />
<div class="form-tip">可选;用于瀑布图(万元)</div>
</el-form-item>
<el-form-item label="期末现金余额">
<el-input-number v-model="form.end_cash" :precision="2" :step="1" style="width:220px" />
<div class="form-tip">可选;用于瀑布图(万元)</div>
</el-form-item>
<el-form-item label="来源">
<el-radio-group v-model="form.source">
<el-radio value="official">官方现金流量表</el-radio>
<el-radio value="manual">人工录入</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="口径说明 / 数据来源,如:2026年8月官方现金流量表" />
</el-form-item>
</el-form>
<template #footer>
<el-button size="small" @click="showDialog = false">取消</el-button>
<el-button size="small" type="primary" :loading="saving" @click="saveValue">保存</el-button>
</template>
</MyDialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import { Refresh } from '@element-plus/icons-vue'
import * as echarts from 'echarts'
import api from '@/api'
import MyDialog from '@/components/MyDialog.vue'
const entityName = ref(localStorage.getItem('cma_entity_name') || '')
const userRole = ref<string>((JSON.parse(localStorage.getItem('cma_user') || '{}') as any).role || '')
const canWrite = computed(() => ['ceo', 'finance'].includes(userRole.value))
const period = ref('')
const periods = ref<string[]>([])
const loading = ref(false)
const loadingPeriods = ref(false)
const dataAvailable = ref(false)
const emptyMessage = ref('该期间暂无官方现金流量表净额数据')
const source = ref('official')
const nets = ref<any>(null)
const waterfall = ref<any>(null)
const classification = ref<any>({ classified: false })
const rules = ref<any[]>([])
const valueRows = ref<any[]>([])
const showDialog = ref(false)
const saving = ref(false)
const form = ref<any>({
period: '',
operating_net: 0,
investing_net: 0,
financing_net: 0,
begin_cash: null,
end_cash: null,
source: 'official',
remark: '',
})
const waterfallRef = ref<HTMLElement>()
let waterfallChart: echarts.ECharts | null = null
const netList = computed(() => {
const n = nets.value || {}
const meta: any = {
operating: { name: '经营活动 O', hint: '造血:主业赚不赚钱' },
investing: { name: '投资活动 I', hint: '成长:扩张 or 收缩' },
financing: { name: '筹资活动 F', hint: '输血:融资 or 还债' },
}
return ['operating', 'investing', 'financing']
.filter((k) => n[k] && n[k].value != null)
.map((k) => ({ key: k, ...meta[k], ...n[k] }))
})
// ── 裁定1(2026-09-17):投资活动=0 → 二维判定(候选 2 个 + 投资栏标注「本期无投资活动」)──
const isTwoDim = computed(() => classification.value?.dimension === '2D' && !!classification.value?.classified)
const investingNote = computed(() => classification.value?.investing_note || '本期无投资活动')
const candidateLabel = computed(() =>
(classification.value?.candidates || []).map((c: any) => `类型${c.type_no} ${c.name}`).join(' / ')
)
// 二维判定时高亮全部候选类型行(不给单一类型名)
const highlightTypes = computed<number[]>(() => {
if (!classification.value?.classified) return []
return isTwoDim.value
? (classification.value.candidate_type_nos || [])
: [classification.value.type_no]
})
function isHighlighted(typeNo: number): boolean {
return highlightTypes.value.includes(typeNo)
}
const healthTagType = computed(() => {
const label = classification.value?.health_label || ''
if (['最优', '良好'].includes(label)) return 'success'
if (['看增速', '需辨析'].includes(label)) return 'warning'
if (['高风险'].includes(label)) return 'warning'
return 'danger'
})
function fmt(v: any): string {
if (v === null || v === undefined || Number.isNaN(Number(v))) return '-'
return Number(v).toFixed(2)
}
function netColor(n: any): string {
if (n.sign === '+') return '#67C23A'
if (n.sign === '-') return '#F56C6C'
return '#909399'
}
function ruleRowClass({ row }: any) {
return isHighlighted(row.type_no) ? 'rule-row-current' : ''
}
async function loadPeriods() {
loadingPeriods.value = true
try {
const r: any = await api.get('/cashflow-structure/periods')
periods.value = (r && (r.periods || [])) || []
} catch (_) { /* 静默 */ }
loadingPeriods.value = false
}
async function loadValues() {
try {
const r: any = await api.get('/cashflow-structure/values')
valueRows.value = (r && (r.items || [])) || []
} catch (_) { valueRows.value = [] }
}
async function loadRules() {
try {
const r: any = await api.get('/cashflow-structure/rules')
rules.value = (r && (r.rules || [])) || []
} catch (_) { rules.value = [] }
}
async function loadData() {
loading.value = true
try {
const params: any = {}
if (period.value) params.period = period.value
const d: any = await api.get('/cashflow-structure/analysis', { params })
dataAvailable.value = !!d?.data_available
source.value = d?.source || 'official'
nets.value = d?.nets || null
waterfall.value = d?.waterfall || null
classification.value = d?.classification || { classified: false }
if (!dataAvailable.value) {
emptyMessage.value = d?.message || '该期间暂无官方现金流量表净额数据'
}
if (!period.value && d?.period) period.value = d.period
await nextTick()
renderWaterfall()
} catch (e: any) {
console.error('load cashflow structure analysis failed', e)
dataAvailable.value = false
emptyMessage.value = '加载失败:' + (e?.message || '请稍后重试')
} finally {
loading.value = false
}
}
async function reload() {
await Promise.all([loadPeriods(), loadValues()])
if (!period.value && periods.value.length) period.value = periods.value[0]
await loadData()
}
function renderWaterfall() {
if (!waterfallRef.value) return
if (!waterfallChart) waterfallChart = echarts.init(waterfallRef.value)
const steps = waterfall.value?.steps || []
if (!steps.length) {
waterfallChart.clear()
return
}
const baseData: number[] = []
const valueData: number[] = []
const colorData: string[] = []
const labelData: string[] = []
let cum = 0
steps.forEach((s: any, idx: number) => {
if (idx === 0 && s.type === 'begin') {
baseData.push(0)
valueData.push(Number(s.value) || 0)
colorData.push('#409EFF')
labelData.push(fmt(s.value))
cum = Number(s.value) || 0
} else if (s.type === 'end') {
baseData.push(0)
valueData.push(Number(s.value) || 0)
colorData.push('#409EFF')
labelData.push(fmt(s.value))
cum = Number(s.value) || 0
} else {
const v = Number(s.value) || 0
baseData.push(cum)
valueData.push(v)
colorData.push(v >= 0 ? '#67C23A' : '#F56C6C')
labelData.push((v >= 0 ? '+' : '') + fmt(v))
cum += v
}
})
const names = steps.map((s: any) => s.step)
waterfallChart.setOption({
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: (params: any[]) => {
const i = params[0]?.dataIndex ?? 0
const s = steps[i]
let tip = `<b>${s.step}(${period.value})</b><br/>`
if (s.type === 'flow') {
const v = Number(s.value) || 0
tip += `净额:<span style="color:${v >= 0 ? '#67C23A' : '#F56C6C'}">${v >= 0 ? '+' : ''}${v.toFixed(2)} 万元</span>`
} else {
tip += `余额:${Number(s.value).toFixed(2)} 万元`
}
return tip
},
},
grid: { left: 20, right: 20, top: 40, bottom: 30, containLabel: true },
xAxis: { type: 'category', data: names, axisLabel: { interval: 0 } },
yAxis: { type: 'value', name: '万元', axisLabel: { formatter: '{value}' } },
series: [
{
name: '底', type: 'bar', stack: 'wf',
itemStyle: { color: 'transparent' },
emphasis: { itemStyle: { color: 'transparent' } },
data: baseData,
silent: true,
},
{
name: '净变动', type: 'bar', stack: 'wf',
data: valueData,
barWidth: 46,
itemStyle: { borderRadius: 2 },
label: {
show: true, position: 'top', formatter: (p: any) => labelData[p.dataIndex],
color: '#333', fontSize: 11,
},
emphasis: { focus: 'series' },
},
],
})
}
function openDialog(row?: any) {
form.value = {
period: row?.period || '',
operating_net: row ? row.operating_net : 0,
investing_net: row ? row.investing_net : 0,
financing_net: row ? row.financing_net : 0,
begin_cash: row?.begin_cash ?? null,
end_cash: row?.end_cash ?? null,
source: row?.source || 'official',
remark: row?.remark || '',
}
showDialog.value = true
}
async function saveValue() {
const f = form.value
if (!/^\d{4}-\d{2}$/.test(f.period || '')) {
ElMessage.warning('期间格式须为 YYYY-MM')
return
}
if ([f.operating_net, f.investing_net, f.financing_net].some((v) => v === null || v === undefined || Number.isNaN(Number(v)))) {
ElMessage.warning('三大活动净额必填')
return
}
saving.value = true
try {
const payload: any = {
period: f.period,
operating_net: Number(f.operating_net),
investing_net: Number(f.investing_net),
financing_net: Number(f.financing_net),
source: f.source,
}
if (f.begin_cash !== null && f.begin_cash !== undefined && !Number.isNaN(Number(f.begin_cash))) payload.begin_cash = Number(f.begin_cash)
if (f.end_cash !== null && f.end_cash !== undefined && !Number.isNaN(Number(f.end_cash))) payload.end_cash = Number(f.end_cash)
if (f.remark) payload.remark = f.remark
const r: any = await api.post('/cashflow-structure/values', payload)
const cls = r?.classification || {}
if (cls?.classified) {
ElMessage.success(`已保存:${r.period} → 类型${cls.type_no} ${cls.name}`)
} else {
ElMessage.success(`已保存:${r.period}${cls?.reason ? '(' + cls.reason + ')' : ''}`)
}
showDialog.value = false
await reload()
} catch (e: any) {
ElMessage.error('保存失败:' + (e?.response?.data?.detail || e?.message || '未知错误'))
} finally {
saving.value = false
}
}
function resizeCharts() {
waterfallChart?.resize()
}
onMounted(async () => {
window.addEventListener('resize', resizeCharts)
await loadRules()
await loadPeriods()
if (periods.value.length && !period.value) period.value = periods.value[0]
await loadValues()
await loadData()
})
</script>
<style scoped>
.cashflow-structure-page {
padding: 4px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
flex-wrap: wrap;
gap: 8px;
}
.page-header h2 {
margin: 0;
font-size: 18px;
}
.header-right {
display: flex;
align-items: center;
gap: 8px;
}
.verdict-card {
border-left: 4px solid #409EFF;
}
.verdict-left {
margin-bottom: 14px;
}
.verdict-badge {
display: flex;
align-items: baseline;
gap: 12px;
margin-bottom: 8px;
}
.verdict-type {
font-size: 26px;
font-weight: 700;
color: #409EFF;
}
.verdict-name {
font-size: 20px;
font-weight: 600;
}
.verdict-health {
display: flex;
align-items: center;
}
.verdict-nets {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.net-item {
flex: 1;
min-width: 140px;
background: #f7f9fc;
border-radius: 6px;
padding: 10px 14px;
text-align: center;
}
.net-name {
font-size: 12px;
color: #606266;
}
.net-value {
font-size: 22px;
font-weight: 700;
margin: 2px 0;
}
.net-sign {
font-size: 13px;
font-weight: 600;
}
/* 裁定1:投资活动=0 → 「本期无投资活动」标注 */
.net-note {
font-size: 11px;
color: #e6a23c;
margin-top: 2px;
font-weight: 600;
}
.net-hint {
font-size: 11px;
color: #909399;
margin-top: 2px;
}
.desc-p {
font-size: 13px;
line-height: 1.8;
color: #303133;
margin: 0 0 10px;
}
.sign-combo {
display: inline-flex;
gap: 10px;
font-size: 15px;
letter-spacing: 1px;
}
.form-tip {
font-size: 12px;
color: #909399;
line-height: 1.5;
margin-left: 8px;
}
:deep(.rule-row-current td) {
background: #ecf5ff !important;
}
</style>