feat(cost): 新增成本趋势分析页面
实现了基于产线筛选的成本数据趋势可视化页面,包含报表筛选、成本类别和成本项多选过滤,支持多趋势线对比展示,自动适配图表布局与图例滚动,同时展示统计摘要信息。
This commit is contained in:
488
klp-ui/src/views/cost/trend.vue
Normal file
488
klp-ui/src/views/cost/trend.vue
Normal file
@@ -0,0 +1,488 @@
|
|||||||
|
<template>
|
||||||
|
<div class="app-container cost-trend-page">
|
||||||
|
<!-- 筛选区 -->
|
||||||
|
<el-card class="mb8">
|
||||||
|
<div class="filter-bar">
|
||||||
|
<el-radio-group
|
||||||
|
v-model="selectedLine"
|
||||||
|
size="small"
|
||||||
|
@change="onLineChange"
|
||||||
|
>
|
||||||
|
<el-radio-button
|
||||||
|
v-for="line in productionLines"
|
||||||
|
:key="line.lineId"
|
||||||
|
:label="line.lineId"
|
||||||
|
>
|
||||||
|
{{ line.lineName }}
|
||||||
|
</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
|
||||||
|
<el-button type="primary" size="small" icon="el-icon-search" :loading="loading" @click="fetchData">
|
||||||
|
查询
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 统计摘要 -->
|
||||||
|
<div v-if="reports.length > 0" class="stats-row">
|
||||||
|
<div class="stat-item stat-blue">
|
||||||
|
<span class="stat-val">{{ reports.length }}</span>
|
||||||
|
<span class="stat-label">参与报表数</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item stat-orange">
|
||||||
|
<span class="stat-val">{{ summary.seriesCount }}</span>
|
||||||
|
<span class="stat-label">趋势线数</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item stat-green">
|
||||||
|
<span class="stat-val">{{ summary.maxLabel }}</span>
|
||||||
|
<span class="stat-label">单报表最高数量</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 图表筛选 -->
|
||||||
|
<div v-if="reports.length > 0" class="chart-filter-bar">
|
||||||
|
<span class="filter-label">图表筛选:</span>
|
||||||
|
<el-select
|
||||||
|
v-model="selectedCategories"
|
||||||
|
multiple
|
||||||
|
collapse-tags
|
||||||
|
placeholder="成本类别(全部)"
|
||||||
|
size="small"
|
||||||
|
style="width:160px"
|
||||||
|
@change="onCategoryChange"
|
||||||
|
clearable
|
||||||
|
>
|
||||||
|
<el-option label="原料" value="原料" />
|
||||||
|
<el-option label="能耗" value="能耗" />
|
||||||
|
<el-option label="辅料" value="辅料" />
|
||||||
|
<el-option label="设备" value="设备" />
|
||||||
|
<el-option label="人工" value="人工" />
|
||||||
|
<el-option label="产出" value="产出" />
|
||||||
|
<el-option label="轧辊" value="轧辊" />
|
||||||
|
<el-option label="其他" value="其他" />
|
||||||
|
</el-select>
|
||||||
|
<el-select
|
||||||
|
v-model="selectedItems"
|
||||||
|
multiple
|
||||||
|
collapse-tags
|
||||||
|
filterable
|
||||||
|
placeholder="成本项(全部)"
|
||||||
|
size="small"
|
||||||
|
style="width:220px"
|
||||||
|
@change="onItemChange"
|
||||||
|
clearable
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="it in filteredItemOptions"
|
||||||
|
:key="it.itemId"
|
||||||
|
:label="it.itemName + ' (' + it.category + ')'"
|
||||||
|
:value="it.itemId"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 图表区 -->
|
||||||
|
<div class="chart-box" v-loading="loading">
|
||||||
|
<div ref="trendChart" class="chart-body" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="summary.seriesCount > 15" class="legend-hint">
|
||||||
|
<i class="el-icon-info" /> 图例较多,可在下方图例区滚动查看,或缩小筛选范围以减少趋势线数量
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import * as echarts from 'echarts'
|
||||||
|
import { mapGetters } from 'vuex'
|
||||||
|
import { listProdReport } from '@/api/cost/prodReport'
|
||||||
|
import { listProdDetail } from '@/api/cost/prodDetail'
|
||||||
|
import { listItem } from '@/api/cost/item'
|
||||||
|
|
||||||
|
const COLORS = [
|
||||||
|
'#409eff', '#67c23a', '#e6a23c', '#f56c6c', '#909399',
|
||||||
|
'#9b59b6', '#1abc9c', '#e74c3c', '#3498db', '#2ecc71',
|
||||||
|
'#f39c12', '#e91e63', '#00bcd4', '#8bc34a', '#ff5722',
|
||||||
|
'#607d8b', '#3f51b5', '#cddc39', '#ff9800', '#795548',
|
||||||
|
'#2196f3', '#4caf50', '#ffc107', '#9c27b0', '#00bcd4'
|
||||||
|
]
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'CostTrend',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
selectedLine: null,
|
||||||
|
selectedCategories: [],
|
||||||
|
selectedItems: [],
|
||||||
|
allItems: [],
|
||||||
|
loading: false,
|
||||||
|
chart: null,
|
||||||
|
reports: [],
|
||||||
|
cachedDetails: [],
|
||||||
|
summary: { seriesCount: 0, maxLabel: '-' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
...mapGetters(['productionLines']),
|
||||||
|
selectedLineObj() {
|
||||||
|
if (!this.selectedLine || !this.productionLines.length) return null
|
||||||
|
return this.productionLines.find(l => l.lineId === this.selectedLine) || null
|
||||||
|
},
|
||||||
|
lineNameDisplay() {
|
||||||
|
const line = this.selectedLineObj
|
||||||
|
return line ? line.lineName : ''
|
||||||
|
},
|
||||||
|
filteredItemOptions() {
|
||||||
|
if (!this.selectedCategories.length) return this.allItems
|
||||||
|
return this.allItems.filter(it => this.selectedCategories.includes(it.category))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.$store.dispatch('productionLine/getProductionLines').then(() => {
|
||||||
|
this.initDefaultLine()
|
||||||
|
this.$nextTick(() => this.fetchData())
|
||||||
|
})
|
||||||
|
this.loadItems()
|
||||||
|
this._resizeHandler = () => { if (this.chart) this.chart.resize() }
|
||||||
|
window.addEventListener('resize', this._resizeHandler)
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
window.removeEventListener('resize', this._resizeHandler)
|
||||||
|
if (this.chart) { this.chart.dispose(); this.chart = null }
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
initDefaultLine() {
|
||||||
|
if (this.selectedLine != null) return
|
||||||
|
const lines = this.productionLines || []
|
||||||
|
const dzLine = lines.find(l => l.lineName && l.lineName.includes('镀锌'))
|
||||||
|
if (dzLine) {
|
||||||
|
this.selectedLine = dzLine.lineId
|
||||||
|
} else if (lines.length) {
|
||||||
|
this.selectedLine = lines[0].lineId
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onLineChange() {
|
||||||
|
if (this.selectedLine != null) {
|
||||||
|
this.fetchData()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onCategoryChange() {
|
||||||
|
this.selectedItems = this.selectedItems.filter(id => {
|
||||||
|
const it = this.allItems.find(item => item.itemId === id)
|
||||||
|
return it && this.selectedCategories.includes(it.category)
|
||||||
|
})
|
||||||
|
this.renderFromCache()
|
||||||
|
},
|
||||||
|
|
||||||
|
onItemChange() {
|
||||||
|
this.renderFromCache()
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadItems() {
|
||||||
|
try {
|
||||||
|
const r = await listItem({ pageNum: 1, pageSize: 999 })
|
||||||
|
this.allItems = r.rows || []
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchData() {
|
||||||
|
if (this.selectedLine == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loading = true
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
lineType: this.selectedLine,
|
||||||
|
pageNum: 1,
|
||||||
|
pageSize: 999
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportRes = await listProdReport(params)
|
||||||
|
const allReports = (reportRes.rows || []).sort((a, b) => {
|
||||||
|
return (a.reportDate || '').localeCompare(b.reportDate || '')
|
||||||
|
})
|
||||||
|
console.log('[CostTrend] reports:', allReports.length, allReports.map(r => ({ id: r.reportId, title: r.reportTitle, date: r.reportDate })))
|
||||||
|
|
||||||
|
if (!allReports.length) {
|
||||||
|
this.$modal.msgWarning('该产线暂无报表')
|
||||||
|
this.reports = []
|
||||||
|
this.summary = { seriesCount: 0, maxLabel: '-' }
|
||||||
|
if (this.chart) this.chart.clear()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.reports = allReports
|
||||||
|
|
||||||
|
await this.fetchDetailsAndCache(allReports)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('CostTrend fetchData error:', e)
|
||||||
|
this.$modal.msgError('数据加载失败: ' + (e.message || '未知错误'))
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==================== 成本项模式 ====================
|
||||||
|
async fetchDetailsAndCache(reports) {
|
||||||
|
const allDetailResults = await Promise.all(
|
||||||
|
reports.map(r =>
|
||||||
|
listProdDetail({ reportId: r.reportId, pageNum: 1, pageSize: 99999 })
|
||||||
|
.then(res => {
|
||||||
|
console.log('[CostTrend] detail for report', r.reportId, 'rows:', (res.rows || []).length)
|
||||||
|
return { reportId: r.reportId, rows: res.rows || [] }
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
console.error('[CostTrend] detail fetch error for report', r.reportId, e)
|
||||||
|
return { reportId: r.reportId, rows: [] }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
this.cachedDetails = allDetailResults
|
||||||
|
|
||||||
|
if (!this.allItems.length) await this.loadItems()
|
||||||
|
|
||||||
|
this.renderFromCache()
|
||||||
|
},
|
||||||
|
|
||||||
|
renderFromCache() {
|
||||||
|
const reports = this.reports
|
||||||
|
const allDetailResults = this.cachedDetails
|
||||||
|
if (!reports.length || !allDetailResults.length) return
|
||||||
|
|
||||||
|
const itemMap = {}
|
||||||
|
this.allItems.forEach(it => { itemMap[String(it.itemId)] = it })
|
||||||
|
|
||||||
|
const itemSet = this.selectedItems.length ? new Set(this.selectedItems.map(String)) : null
|
||||||
|
const categorySet = this.selectedCategories.length ? new Set(this.selectedCategories) : null
|
||||||
|
|
||||||
|
const reportSums = {}
|
||||||
|
allDetailResults.forEach(({ reportId, rows }) => {
|
||||||
|
const sums = {}
|
||||||
|
rows.forEach(d => {
|
||||||
|
if (!d.itemId) return
|
||||||
|
const key = String(d.itemId)
|
||||||
|
if (itemSet && !itemSet.has(key)) return
|
||||||
|
if (categorySet) {
|
||||||
|
const it = itemMap[key]
|
||||||
|
if (!it || !categorySet.has(it.category)) return
|
||||||
|
}
|
||||||
|
sums[key] = (sums[key] || 0) + (parseFloat(d.quantity) || 0)
|
||||||
|
})
|
||||||
|
reportSums[reportId] = sums
|
||||||
|
})
|
||||||
|
|
||||||
|
const allItemIds = new Set()
|
||||||
|
Object.values(reportSums).forEach(sums => {
|
||||||
|
Object.keys(sums).forEach(id => allItemIds.add(id))
|
||||||
|
})
|
||||||
|
|
||||||
|
const xLabels = reports.map(r => this.reportLabel(r))
|
||||||
|
const series = []
|
||||||
|
let globalMax = 0
|
||||||
|
|
||||||
|
Array.from(allItemIds).forEach((key, idx) => {
|
||||||
|
const it = itemMap[key]
|
||||||
|
const name = it ? it.itemName : `未知(${key})`
|
||||||
|
const data = reports.map(r => {
|
||||||
|
const sums = reportSums[r.reportId] || {}
|
||||||
|
const val = sums[key] ?? null
|
||||||
|
if (val != null && val > globalMax) globalMax = val
|
||||||
|
return val
|
||||||
|
})
|
||||||
|
if (data.every(v => v === null)) return
|
||||||
|
|
||||||
|
series.push({
|
||||||
|
name,
|
||||||
|
type: 'line',
|
||||||
|
data,
|
||||||
|
smooth: true,
|
||||||
|
symbol: 'circle',
|
||||||
|
symbolSize: 6,
|
||||||
|
lineStyle: { width: 2, color: COLORS[idx % COLORS.length] },
|
||||||
|
itemStyle: { color: COLORS[idx % COLORS.length] },
|
||||||
|
emphasis: { focus: 'series' }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
this.summary = {
|
||||||
|
seriesCount: series.length,
|
||||||
|
maxLabel: this.formatMoney(globalMax)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$nextTick(() => {
|
||||||
|
setTimeout(() => this.renderChart(xLabels, series, '数量'), 50)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==================== 图表渲染 ====================
|
||||||
|
renderChart(xLabels, series, yAxisName) {
|
||||||
|
console.log('[CostTrend] renderChart called, ref:', !!this.$refs.trendChart, 'series:', series.length, 'xLabels:', xLabels.length)
|
||||||
|
if (!this.$refs.trendChart) return
|
||||||
|
if (this.chart) this.chart.dispose()
|
||||||
|
this.chart = echarts.init(this.$refs.trendChart)
|
||||||
|
|
||||||
|
if (!series.length) {
|
||||||
|
this.chart.setOption({
|
||||||
|
title: { text: '暂无数据', left: 'center', top: 'center', textStyle: { color: '#999', fontSize: 14 } },
|
||||||
|
xAxis: { show: false },
|
||||||
|
yAxis: { show: false },
|
||||||
|
series: []
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.chart.setOption({
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
formatter: function (params) {
|
||||||
|
if (!params || !params.length) return ''
|
||||||
|
let html = '<b>' + params[0].axisValue + '</b><br/>'
|
||||||
|
params.forEach(p => {
|
||||||
|
if (p.value != null) {
|
||||||
|
html += '<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:' +
|
||||||
|
p.color + ';margin-right:5px;"></span>' +
|
||||||
|
p.seriesName + ': <b>' +
|
||||||
|
Number(p.value).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) +
|
||||||
|
'</b><br/>'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
type: 'scroll',
|
||||||
|
bottom: 0,
|
||||||
|
textStyle: { fontSize: 11 },
|
||||||
|
pageIconSize: 12,
|
||||||
|
pageTextStyle: { fontSize: 11 }
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
left: '3%',
|
||||||
|
right: '4%',
|
||||||
|
top: '3%',
|
||||||
|
bottom: series.length > 20 ? '18%' : (series.length > 10 ? '14%' : '10%'),
|
||||||
|
containLabel: true
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: xLabels,
|
||||||
|
boundaryGap: false,
|
||||||
|
axisLabel: {
|
||||||
|
fontSize: 11,
|
||||||
|
rotate: xLabels.length > 8 ? 30 : 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value',
|
||||||
|
name: yAxisName,
|
||||||
|
axisLabel: {
|
||||||
|
fontSize: 11,
|
||||||
|
formatter: function (v) {
|
||||||
|
if (v >= 10000) return (v / 10000).toFixed(1) + '万'
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
},
|
||||||
|
splitLine: { lineStyle: { type: 'dashed', color: '#e8e8e8' } }
|
||||||
|
},
|
||||||
|
dataZoom: xLabels.length > 10 ? [
|
||||||
|
{
|
||||||
|
type: 'slider',
|
||||||
|
bottom: series.length > 10 ? (series.length > 20 ? 60 : 50) : 35,
|
||||||
|
height: 16,
|
||||||
|
textStyle: { fontSize: 10 }
|
||||||
|
},
|
||||||
|
{ type: 'inside' }
|
||||||
|
] : [],
|
||||||
|
series
|
||||||
|
}, true)
|
||||||
|
},
|
||||||
|
|
||||||
|
reportLabel(r) {
|
||||||
|
const date = r.reportDate
|
||||||
|
? (typeof r.reportDate === 'string' ? r.reportDate.slice(0, 10) : String(r.reportDate).slice(0, 10))
|
||||||
|
: ''
|
||||||
|
return r.reportTitle || date || `报表#${r.reportId}`
|
||||||
|
},
|
||||||
|
|
||||||
|
formatMoney(val) {
|
||||||
|
if (val == null || val === '' || isNaN(val)) return '-'
|
||||||
|
const num = Number(val)
|
||||||
|
if (num >= 10000) return (num / 10000).toFixed(2) + '万'
|
||||||
|
return num.toFixed(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mb8 { margin-bottom: 10px; }
|
||||||
|
|
||||||
|
.filter-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.stat-item {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 100px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #ebeef5;
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.stat-val {
|
||||||
|
display: block;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #303133;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.stat-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.stat-blue .stat-val { color: #409eff; }
|
||||||
|
.stat-orange .stat-val { color: #e6a23c; }
|
||||||
|
.stat-green .stat-val { color: #67c23a; }
|
||||||
|
|
||||||
|
.chart-box {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #ebeef5;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
.chart-filter-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.chart-filter-bar .filter-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #606266;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.chart-body {
|
||||||
|
width: 100%;
|
||||||
|
height: 480px;
|
||||||
|
}
|
||||||
|
.legend-hint {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user