本次提交新增了完整的投标报表统计分析功能,包括: 添加用于数据检查与菜单初始化的 SQL 脚本 实现采购概览仪表板、采购成本分析及供应商绩效报告的后端服务、Mapper、Controller 及 VO 类 添加前端 API、路由配置以及使用 ECharts 可视化图表的页面组件 为仪表板添加通用的 KPI 卡片组件
279 lines
11 KiB
Vue
279 lines
11 KiB
Vue
<template>
|
|
<div class="app-container cost-page">
|
|
<div class="page-header">
|
|
<span class="page-title">💰 采购成本分析</span>
|
|
</div>
|
|
|
|
<div v-if="loading" class="loading-box">
|
|
<i class="el-icon-loading"></i> 加载中…
|
|
</div>
|
|
|
|
<div v-show="!loading && data" class="cost-content">
|
|
<el-row :gutter="20" class="summary-row">
|
|
<el-col :xs="8" :sm="6" v-for="s in summaryCards" :key="s.label" style="margin-bottom:16px">
|
|
<el-card shadow="hover" class="summary-card" :style="{ '--s-accent': s.color }">
|
|
<div class="s-label">{{ s.label }}</div>
|
|
<div class="s-value" :style="{ color: s.color }">{{ s.value }}</div>
|
|
<div class="s-sub" v-if="s.sub">{{ s.sub }}</div>
|
|
</el-card>
|
|
</el-col>
|
|
</el-row>
|
|
|
|
<el-card shadow="hover" style="margin-bottom:16px">
|
|
<div slot="header" class="card-header">
|
|
<span><i class="el-icon-data-board" style="color:#409EFF"></i> 月度预算 vs 实际成本</span>
|
|
</div>
|
|
<div ref="costTrendChart" style="height:350px;width:100%"></div>
|
|
</el-card>
|
|
|
|
<el-row :gutter="20">
|
|
<el-col :xs="24" :sm="12" style="margin-bottom:16px">
|
|
<el-card shadow="hover">
|
|
<div slot="header" class="card-header">
|
|
<span><i class="el-icon-s-data" style="color:#E6A23C"></i> 品类采购分布</span>
|
|
</div>
|
|
<div ref="categoryChart" style="height:300px;width:100%"></div>
|
|
</el-card>
|
|
</el-col>
|
|
<el-col :xs="24" :sm="12" style="margin-bottom:16px">
|
|
<el-card shadow="hover">
|
|
<div slot="header" class="card-header">
|
|
<span><i class="el-icon-success" style="color:#67C23A"></i> 节省分析</span>
|
|
</div>
|
|
<div ref="savedChart" style="height:300px;width:100%"></div>
|
|
</el-card>
|
|
</el-col>
|
|
</el-row>
|
|
|
|
<el-card shadow="hover">
|
|
<div slot="header" class="card-header">
|
|
<span><i class="el-icon-document" style="color:#909399"></i> RFQ 比价明细</span>
|
|
</div>
|
|
<el-table :data="data.rfqDetails || []" border size="small">
|
|
<el-table-column label="询价单号" prop="rfqNo" width="150" />
|
|
<el-table-column label="询价标题" prop="rfqTitle" min-width="180" show-overflow-tooltip />
|
|
<el-table-column label="预算价" width="130" align="right">
|
|
<template slot-scope="s"><span class="cell-expected">¥{{ formatMoney(s.row.expectedTotal) }}</span></template>
|
|
</el-table-column>
|
|
<el-table-column label="最低报价" width="130" align="right">
|
|
<template slot-scope="s"><span class="cell-lowest">¥{{ formatMoney(s.row.lowestQuote) }}</span></template>
|
|
</el-table-column>
|
|
<el-table-column label="采纳价格" width="130" align="right">
|
|
<template slot-scope="s"><span class="cell-actual">¥{{ formatMoney(s.row.acceptedQuote) }}</span></template>
|
|
</el-table-column>
|
|
<el-table-column label="节省金额" width="130" align="right">
|
|
<template slot-scope="s">
|
|
<span :class="Number(s.row.savedAmount) > 0 ? 'cell-saved' : 'cell-none'">
|
|
¥{{ formatMoney(s.row.savedAmount) }}
|
|
</span>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column label="参与供应商" width="100" align="center" prop="supplierCount" />
|
|
<el-table-column label="操作" width="80" align="center">
|
|
<template slot-scope="s">
|
|
<el-button type="text" size="mini" icon="el-icon-data-analysis"
|
|
@click="goCompare(s.row.rfqId)">比价</el-button>
|
|
</template>
|
|
</el-table-column>
|
|
</el-table>
|
|
<div v-if="!data.rfqDetails || !data.rfqDetails.length" class="no-data" style="padding:20px">暂无数据</div>
|
|
</el-card>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script>
|
|
import * as echarts from 'echarts'
|
|
require('echarts/theme/macarons') // echarts theme
|
|
import { debounce } from '@/utils'
|
|
import { getCostAnalysis } from '@/api/bid/report'
|
|
|
|
export default {
|
|
name: 'ReportCost',
|
|
data() {
|
|
return {
|
|
loading: false,
|
|
data: null,
|
|
trendChart: null,
|
|
categoryChart: null,
|
|
savedChart: null
|
|
}
|
|
},
|
|
computed: {
|
|
summaryCards() {
|
|
if (!this.data || !this.data.summary) return []
|
|
const s = this.data.summary
|
|
return [
|
|
{ label: '预算总额', value: '¥' + this.formatMoney(s.totalExpected), color: '#409EFF' },
|
|
{ label: '实际采购', value: '¥' + this.formatMoney(s.totalActual), color: '#67C23A' },
|
|
{ label: '节省金额', value: '¥' + this.formatMoney(s.savedAmount), color: Number(s.savedAmount) > 0 ? '#E6A23C' : '#C0C4CC', sub: Number(s.savedAmount) > 0 ? '为您节省了开支' : '' },
|
|
{ label: '节省比例', value: (Number(s.savedRate) || 0) + '%', color: '#F56C6C', sub: '相比预算' }
|
|
]
|
|
}
|
|
},
|
|
mounted() {
|
|
this.loadData()
|
|
this.__resizeHandler = debounce(() => {
|
|
this.trendChart && this.trendChart.resize()
|
|
this.categoryChart && this.categoryChart.resize()
|
|
this.savedChart && this.savedChart.resize()
|
|
}, 100)
|
|
window.addEventListener('resize', this.__resizeHandler)
|
|
this.$watch('$store.state.sidebar.opened', () => {
|
|
setTimeout(() => {
|
|
this.trendChart && this.trendChart.resize()
|
|
this.categoryChart && this.categoryChart.resize()
|
|
this.savedChart && this.savedChart.resize()
|
|
}, 300)
|
|
})
|
|
},
|
|
beforeDestroy() {
|
|
window.removeEventListener('resize', this.__resizeHandler)
|
|
this.trendChart && this.trendChart.dispose()
|
|
this.categoryChart && this.categoryChart.dispose()
|
|
this.savedChart && this.savedChart.dispose()
|
|
},
|
|
methods: {
|
|
loadData() {
|
|
this.loading = true
|
|
getCostAnalysis().then(r => {
|
|
this.data = r.data
|
|
this.loading = false
|
|
this.$nextTick(() => {
|
|
requestAnimationFrame(() => {
|
|
this.initTrendChart()
|
|
this.initCategoryChart()
|
|
this.initSavedChart()
|
|
})
|
|
})
|
|
}).catch(() => {
|
|
this.loading = false
|
|
this.$message.error('加载成本数据失败')
|
|
})
|
|
},
|
|
initTrendChart() {
|
|
const el = this.$refs.costTrendChart
|
|
if (!el) return
|
|
if (this.trendChart) this.trendChart.dispose()
|
|
this.trendChart = echarts.init(el, 'macarons')
|
|
const items = this.data.costTrend || []
|
|
this.trendChart.setOption({
|
|
tooltip: {
|
|
trigger: 'axis',
|
|
formatter: function(params) {
|
|
let s = '<strong>' + params[0].axisValue + '</strong><br/>'
|
|
params.forEach(p => {
|
|
s += p.marker + ' ' + p.seriesName + ':¥' + Number(p.value).toLocaleString() + '<br/>'
|
|
})
|
|
return s
|
|
}
|
|
},
|
|
legend: { data: ['预算金额', '实际金额'], left: 0 },
|
|
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
|
|
xAxis: { type: 'category', data: items.map(d => d.month || '') },
|
|
yAxis: { type: 'value', name: '金额(元)', axisLabel: { formatter: v => v >= 10000 ? (v/10000)+'万' : v } },
|
|
series: [
|
|
{
|
|
name: '预算金额', type: 'bar',
|
|
data: items.map(d => Number(d.expectedAmount) || 0),
|
|
itemStyle: { color: '#C0C4CC', borderRadius: [4,4,0,0] },
|
|
barWidth: '35%'
|
|
},
|
|
{
|
|
name: '实际金额', type: 'bar',
|
|
data: items.map(d => Number(d.actualAmount) || 0),
|
|
itemStyle: { color: '#409EFF', borderRadius: [4,4,0,0] },
|
|
barWidth: '35%',
|
|
label: { show: true, position: 'top', formatter: p => '¥' + Number(p.value).toLocaleString(), fontSize: 10 }
|
|
}
|
|
]
|
|
})
|
|
},
|
|
initCategoryChart() {
|
|
const el = this.$refs.categoryChart
|
|
if (!el) return
|
|
if (this.categoryChart) this.categoryChart.dispose()
|
|
this.categoryChart = echarts.init(el, 'macarons')
|
|
const list = (this.data.categoryDist || []).map(d => ({
|
|
name: d.categoryName || '未分类',
|
|
value: Number(d.amount) || 0
|
|
}))
|
|
this.categoryChart.setOption({
|
|
tooltip: { trigger: 'item', formatter: '{b}: ¥{c} ({d}%)' },
|
|
series: [{
|
|
type: 'pie',
|
|
radius: ['45%', '70%'],
|
|
center: ['50%', '55%'],
|
|
label: { show: true, formatter: '{b}\n{d}%', fontSize: 11 },
|
|
data: list.length ? list : [{ name: '暂无', value: 1 }],
|
|
color: ['#409EFF', '#67C23A', '#E6A23C', '#F56C6C', '#909399', '#22a4ff']
|
|
}]
|
|
})
|
|
},
|
|
initSavedChart() {
|
|
const el = this.$refs.savedChart
|
|
if (!el) return
|
|
if (this.savedChart) this.savedChart.dispose()
|
|
this.savedChart = echarts.init(el, 'macarons')
|
|
const items = this.data.costTrend || []
|
|
this.savedChart.setOption({
|
|
tooltip: {
|
|
trigger: 'axis',
|
|
formatter: function(params) {
|
|
return '<strong>' + params[0].axisValue + '</strong><br/>'
|
|
+ '节省:¥' + Number(params[0].value).toLocaleString()
|
|
}
|
|
},
|
|
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
|
|
xAxis: { type: 'category', data: items.map(d => d.month || '') },
|
|
yAxis: { type: 'value', name: '节省(元)', axisLabel: { formatter: v => v >= 10000 ? (v/10000)+'万' : v } },
|
|
series: [{
|
|
type: 'line',
|
|
data: items.map(d => Number(d.savedAmount) || 0),
|
|
itemStyle: { color: '#67C23A' },
|
|
areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
|
{ offset: 0, color: 'rgba(103,194,58,0.4)' },
|
|
{ offset: 1, color: 'rgba(103,194,58,0.05)' }
|
|
])},
|
|
lineStyle: { width: 3 },
|
|
symbol: 'diamond', symbolSize: 10,
|
|
label: { show: true, formatter: p => '¥' + Number(p.value).toLocaleString(), fontSize: 10 }
|
|
}]
|
|
})
|
|
},
|
|
goCompare(rfqId) {
|
|
this.$router.push({ path: '/bid/comparison/detail', query: { rfqId } })
|
|
},
|
|
formatMoney(v) {
|
|
const n = Number(v)
|
|
return isNaN(n) ? '0.00' : n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style lang="scss" scoped>
|
|
.cost-page { padding-bottom: 30px; }
|
|
.page-header {
|
|
display: flex; align-items: center; gap: 12px;
|
|
margin-bottom: 20px; padding-bottom: 16px; border-bottom: 1px solid #f0f2f5;
|
|
}
|
|
.page-title { font-size: 20px; font-weight: 700; color: #1a2c4e; }
|
|
.loading-box { text-align: center; padding: 120px 0; color: #909399; font-size: 15px; i { font-size: 24px; margin-right: 8px; } }
|
|
.card-header { font-weight: 600; color: #303133; font-size: 14px; }
|
|
.cost-content { min-height: 400px; }
|
|
.summary-card {
|
|
border-left: 4px solid var(--s-accent, #409EFF);
|
|
border-radius: 6px;
|
|
.s-label { font-size: 14px; color: #909399; margin-bottom: 8px; }
|
|
.s-value { font-size: 24px; font-weight: 700; margin-bottom: 4px; }
|
|
.s-sub { font-size: 12px; color: #C0C4CC; }
|
|
}
|
|
.cell-expected { color: #C0C4CC; }
|
|
.cell-lowest { color: #E6A23C; font-weight: 600; }
|
|
.cell-actual { color: #409EFF; font-weight: 600; }
|
|
.cell-saved { color: #67C23A; font-weight: 700; }
|
|
.cell-none { color: #C0C4CC; }
|
|
.no-data { text-align: center; color: #C0C4CC; }
|
|
</style>
|