feat(mes/roll,cost/trend): 优化轧辊报表和成本趋势页面

1. 轧辊报表:修复产线关联逻辑,通过轧辊映射获取产线信息,移除冗余的lineId请求参数
2. 成本趋势页:重构为迷你多图表布局,优化筛选栏样式和交互细节
This commit is contained in:
砂糖
2026-07-06 15:43:08 +08:00
parent 8839693ccd
commit 72bc19fab2
2 changed files with 211 additions and 152 deletions

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class="app-container cost-trend-page"> <div class="app-container cost-trend-page">
<!-- 筛选区 --> <!-- 筛选区 -->
<el-card class="mb8"> <el-card class="mb8 filter-card">
<div class="filter-bar"> <div class="filter-bar">
<el-radio-group <el-radio-group
v-model="selectedLine" v-model="selectedLine"
@@ -16,7 +16,6 @@
{{ line.lineName }} {{ line.lineName }}
</el-radio-button> </el-radio-button>
</el-radio-group> </el-radio-group>
<el-button type="primary" size="small" icon="el-icon-search" :loading="loading" @click="fetchData"> <el-button type="primary" size="small" icon="el-icon-search" :loading="loading" @click="fetchData">
查询 查询
</el-button> </el-button>
@@ -47,10 +46,10 @@
multiple multiple
collapse-tags collapse-tags
placeholder="成本类别(全部)" placeholder="成本类别(全部)"
size="small"
style="width:160px"
@change="onCategoryChange"
clearable clearable
size="small"
style="width:150px"
@change="onCategoryChange"
> >
<el-option label="原料" value="原料" /> <el-option label="原料" value="原料" />
<el-option label="能耗" value="能耗" /> <el-option label="能耗" value="能耗" />
@@ -67,10 +66,10 @@
collapse-tags collapse-tags
filterable filterable
placeholder="成本项(全部)" placeholder="成本项(全部)"
size="small"
style="width:220px"
@change="onItemChange"
clearable clearable
size="small"
style="width:200px"
@change="onItemChange"
> >
<el-option <el-option
v-for="it in filteredItemOptions" v-for="it in filteredItemOptions"
@@ -81,13 +80,21 @@
</el-select> </el-select>
</div> </div>
<!-- 图表区 --> <!-- 迷你图表网格 -->
<div class="chart-box" v-loading="loading"> <div v-loading="loading" class="mini-charts-grid">
<div ref="trendChart" class="chart-body" /> <template v-if="seriesList.length">
</div> <div v-for="s in seriesList" :key="s.key" class="mini-chart-card">
<div class="mini-chart-header">
<div v-if="summary.seriesCount > 15" class="legend-hint"> <span class="mini-chart-dot" :style="{ background: s.color }" />
<i class="el-icon-info" /> 图例较多可在下方图例区滚动查看或缩小筛选范围以减少趋势线数量 <span class="mini-chart-name">{{ s.name }}</span>
<span class="mini-chart-max">峰值 {{ formatMiniMax(s.maxVal) }}</span>
</div>
<div ref="miniCharts" class="mini-chart-body" />
</div>
</template>
<div v-else-if="!loading && reports.length" class="empty-hint">
暂无匹配的成本项数据
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -116,7 +123,8 @@ export default {
selectedItems: [], selectedItems: [],
allItems: [], allItems: [],
loading: false, loading: false,
chart: null, chartInstances: [],
seriesList: [],
reports: [], reports: [],
cachedDetails: [], cachedDetails: [],
summary: { seriesCount: 0, maxLabel: '-' } summary: { seriesCount: 0, maxLabel: '-' }
@@ -128,10 +136,6 @@ export default {
if (!this.selectedLine || !this.productionLines.length) return null if (!this.selectedLine || !this.productionLines.length) return null
return this.productionLines.find(l => l.lineId === this.selectedLine) || null return this.productionLines.find(l => l.lineId === this.selectedLine) || null
}, },
lineNameDisplay() {
const line = this.selectedLineObj
return line ? line.lineName : ''
},
filteredItemOptions() { filteredItemOptions() {
if (!this.selectedCategories.length) return this.allItems if (!this.selectedCategories.length) return this.allItems
return this.allItems.filter(it => this.selectedCategories.includes(it.category)) return this.allItems.filter(it => this.selectedCategories.includes(it.category))
@@ -143,12 +147,12 @@ export default {
this.$nextTick(() => this.fetchData()) this.$nextTick(() => this.fetchData())
}) })
this.loadItems() this.loadItems()
this._resizeHandler = () => { if (this.chart) this.chart.resize() } this._resizeHandler = () => this.resizeAllCharts()
window.addEventListener('resize', this._resizeHandler) window.addEventListener('resize', this._resizeHandler)
}, },
beforeDestroy() { beforeDestroy() {
window.removeEventListener('resize', this._resizeHandler) window.removeEventListener('resize', this._resizeHandler)
if (this.chart) { this.chart.dispose(); this.chart = null } this.disposeAllCharts()
}, },
methods: { methods: {
initDefaultLine() { initDefaultLine() {
@@ -186,11 +190,9 @@ export default {
this.allItems = r.rows || [] this.allItems = r.rows || []
} catch (e) { /* ignore */ } } catch (e) { /* ignore */ }
}, },
async fetchData() { async fetchData() {
if (this.selectedLine == null) { if (this.selectedLine == null) return
return
}
this.loading = true this.loading = true
try { try {
@@ -204,13 +206,13 @@ export default {
const allReports = (reportRes.rows || []).sort((a, b) => { const allReports = (reportRes.rows || []).sort((a, b) => {
return (a.reportDate || '').localeCompare(b.reportDate || '') 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) { if (!allReports.length) {
this.$modal.msgWarning('该产线暂无报表') this.$modal.msgWarning('该产线暂无报表')
this.reports = [] this.reports = []
this.seriesList = []
this.summary = { seriesCount: 0, maxLabel: '-' } this.summary = { seriesCount: 0, maxLabel: '-' }
if (this.chart) this.chart.clear() this.disposeAllCharts()
return return
} }
this.reports = allReports this.reports = allReports
@@ -224,15 +226,11 @@ export default {
} }
}, },
// ==================== 成本项模式 ====================
async fetchDetailsAndCache(reports) { async fetchDetailsAndCache(reports) {
const allDetailResults = await Promise.all( const allDetailResults = await Promise.all(
reports.map(r => reports.map(r =>
listProdDetail({ reportId: r.reportId, pageNum: 1, pageSize: 99999 }) listProdDetail({ reportId: r.reportId, pageNum: 1, pageSize: 99999 })
.then(res => { .then(res => ({ reportId: r.reportId, rows: res.rows || [] }))
console.log('[CostTrend] detail for report', r.reportId, 'rows:', (res.rows || []).length)
return { reportId: r.reportId, rows: res.rows || [] }
})
.catch(e => { .catch(e => {
console.error('[CostTrend] detail fetch error for report', r.reportId, e) console.error('[CostTrend] detail fetch error for report', r.reportId, e)
return { reportId: r.reportId, rows: [] } return { reportId: r.reportId, rows: [] }
@@ -279,7 +277,7 @@ export default {
}) })
const xLabels = reports.map(r => this.reportLabel(r)) const xLabels = reports.map(r => this.reportLabel(r))
const series = [] const list = []
let globalMax = 0 let globalMax = 0
Array.from(allItemIds).forEach((key, idx) => { Array.from(allItemIds).forEach((key, idx) => {
@@ -288,115 +286,121 @@ export default {
const data = reports.map(r => { const data = reports.map(r => {
const sums = reportSums[r.reportId] || {} const sums = reportSums[r.reportId] || {}
const val = sums[key] ?? null const val = sums[key] ?? null
if (val != null && val > globalMax) globalMax = val
return val return val
}) })
if (data.every(v => v === null)) return if (data.every(v => v === null)) return
series.push({ const maxVal = Math.max(...data.filter(v => v != null))
if (maxVal > globalMax) globalMax = maxVal
list.push({
key,
name, name,
type: 'line', color: COLORS[idx % COLORS.length],
data, data,
smooth: true, xLabels,
symbol: 'circle', maxVal
symbolSize: 6,
lineStyle: { width: 2, color: COLORS[idx % COLORS.length] },
itemStyle: { color: COLORS[idx % COLORS.length] },
emphasis: { focus: 'series' }
}) })
}) })
this.seriesList = list
this.summary = { this.summary = {
seriesCount: series.length, seriesCount: list.length,
maxLabel: this.formatMoney(globalMax) maxLabel: this.formatMoney(globalMax)
} }
this.$nextTick(() => { this.$nextTick(() => this.renderMiniCharts())
setTimeout(() => this.renderChart(xLabels, series, '数量'), 50) },
// ==================== 迷你图表渲染 ====================
disposeAllCharts() {
this.chartInstances.forEach(c => {
try { c.dispose() } catch (e) { /* ignore */ }
})
this.chartInstances = []
},
resizeAllCharts() {
this.chartInstances.forEach(c => {
try { c.resize() } catch (e) { /* ignore */ }
}) })
}, },
// ==================== 图表渲染 ==================== renderMiniCharts() {
renderChart(xLabels, series, yAxisName) { this.disposeAllCharts()
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) { const containers = this.$refs.miniCharts
this.chart.setOption({ if (!containers || !containers.length) return
title: { text: '暂无数据', left: 'center', top: 'center', textStyle: { color: '#999', fontSize: 14 } },
xAxis: { show: false },
yAxis: { show: false },
series: []
})
return
}
this.chart.setOption({ this.seriesList.forEach((s, idx) => {
tooltip: { const el = containers[idx]
trigger: 'axis', if (!el) return
formatter: function (params) {
if (!params || !params.length) return '' const chart = echarts.init(el)
let html = '<b>' + params[0].axisValue + '</b><br/>' chart.setOption({
params.forEach(p => { tooltip: {
if (p.value != null) { trigger: 'axis',
html += '<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:' + formatter: function(params) {
p.color + ';margin-right:5px;"></span>' + if (!params || !params.length) return ''
p.seriesName + ': <b>' + const p = params[0]
Number(p.value).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + return '<b>' + p.axisValue + '</b><br/>' +
'</b><br/>' '<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:' +
} p.color + ';margin-right:4px;"></span>' +
}) p.seriesName + ': <b>' +
return html Number(p.value).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) +
} '</b>'
},
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' } } grid: {
}, left: 8,
dataZoom: xLabels.length > 10 ? [ right: 12,
{ top: 8,
type: 'slider', bottom: 24,
bottom: series.length > 10 ? (series.length > 20 ? 60 : 50) : 35, containLabel: true
height: 16,
textStyle: { fontSize: 10 }
}, },
{ type: 'inside' } xAxis: {
] : [], type: 'category',
series data: s.xLabels,
}, true) boundaryGap: false,
axisLabel: {
fontSize: 10,
rotate: s.xLabels.length > 8 ? 25 : 0,
interval: s.xLabels.length > 12 ? 'auto' : 0
},
axisTick: { show: false },
axisLine: { lineStyle: { color: '#ddd' }}
},
yAxis: {
type: 'value',
axisLabel: {
fontSize: 10,
formatter: function(v) {
if (v >= 10000) return (v / 10000).toFixed(1) + '万'
return v
}
},
splitLine: { lineStyle: { type: 'dashed', color: '#eee' }},
splitNumber: 3
},
series: [{
name: s.name,
type: 'line',
data: s.data,
smooth: true,
symbol: 'none',
lineStyle: { width: 1.5, color: s.color },
itemStyle: { color: s.color },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: s.color + '30' },
{ offset: 1, color: s.color + '05' }
])
}
}]
}, true)
this.chartInstances.push(chart)
})
}, },
reportLabel(r) { reportLabel(r) {
@@ -411,78 +415,132 @@ export default {
const num = Number(val) const num = Number(val)
if (num >= 10000) return (num / 10000).toFixed(2) + '万' if (num >= 10000) return (num / 10000).toFixed(2) + '万'
return num.toFixed(2) return num.toFixed(2)
},
formatMiniMax(val) {
if (val == null || isNaN(val)) return '-'
if (val >= 10000) return (val / 10000).toFixed(1) + '万'
return Number(val).toFixed(1)
} }
} }
} }
</script> </script>
<style scoped> <style scoped>
.mb8 { margin-bottom: 10px; } .mb8 { margin-bottom: 8px; }
.filter-card {
border: none;
box-shadow: none;
background: transparent;
}
.filter-card >>> .el-card__body {
padding: 0;
}
.filter-bar { .filter-bar {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 8px;
flex-wrap: wrap; flex-wrap: wrap;
} }
/* 统计摘要 */
.stats-row { .stats-row {
display: flex; display: flex;
gap: 8px; gap: 6px;
margin-bottom: 10px; margin-bottom: 8px;
} }
.stat-item { .stat-item {
flex: 1; flex: 1;
min-width: 100px; min-width: 80px;
background: #fff; background: #fff;
border: 1px solid #ebeef5; border: 1px solid #ebeef5;
border-radius: 2px; border-radius: 4px;
padding: 10px 12px; padding: 8px 10px;
text-align: center; text-align: center;
} }
.stat-val { .stat-val {
display: block; display: block;
font-size: 22px; font-size: 20px;
font-weight: 600; font-weight: 600;
color: #303133; color: #303133;
line-height: 1.2; line-height: 1.2;
} }
.stat-label { .stat-label {
display: block; display: block;
font-size: 12px; font-size: 11px;
color: #909399; color: #909399;
margin-top: 2px; margin-top: 1px;
} }
.stat-blue .stat-val { color: #409eff; } .stat-blue .stat-val { color: #409eff; }
.stat-orange .stat-val { color: #e6a23c; } .stat-orange .stat-val { color: #e6a23c; }
.stat-green .stat-val { color: #67c23a; } .stat-green .stat-val { color: #67c23a; }
.chart-box { /* 图表筛选 */
background: #fff;
border: 1px solid #ebeef5;
border-radius: 2px;
}
.chart-filter-bar { .chart-filter-bar {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 8px;
margin-bottom: 8px; margin-bottom: 8px;
} }
.chart-filter-bar .filter-label { .chart-filter-bar .filter-label {
font-size: 13px; font-size: 12px;
color: #606266; color: #606266;
white-space: nowrap; white-space: nowrap;
} }
.chart-body {
width: 100%; /* 迷你图表网格 */
height: 480px; .mini-charts-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 10px;
} }
.legend-hint {
margin-top: 8px; .mini-chart-card {
font-size: 12px; background: #fff;
color: #909399; border: 1px solid #ebeef5;
border-radius: 4px;
overflow: hidden;
}
.mini-chart-header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 6px;
padding: 8px 10px 0 10px;
font-size: 13px;
color: #303133;
}
.mini-chart-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.mini-chart-name {
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mini-chart-max {
margin-left: auto;
font-size: 11px;
color: #909399;
flex-shrink: 0;
}
.mini-chart-body {
width: 100%;
height: 220px;
}
.empty-hint {
grid-column: 1 / -1;
text-align: center;
padding: 60px 0;
font-size: 13px;
color: #909399;
} }
</style> </style>

View File

@@ -244,8 +244,8 @@ export default {
this.stats = statsRes.data || {} this.stats = statsRes.data || {}
} }
// 2. 获取全部轧辊(用于辊型映射) // 2. 获取全部轧辊(用于辊型映射和产线关联
const rollRes = await listRollInfo({ pageNum: 1, pageSize: 99999, lineId: this.lineId || undefined }) const rollRes = await listRollInfo({ pageNum: 1, pageSize: 99999 })
const rolls = rollRes.rows || [] const rolls = rollRes.rows || []
const rollMap = {} const rollMap = {}
rolls.forEach(r => { rollMap[r.rollId] = r }) rolls.forEach(r => { rollMap[r.rollId] = r })
@@ -270,7 +270,7 @@ export default {
if (this._beginTime) allParams.beginTime = this._beginTime if (this._beginTime) allParams.beginTime = this._beginTime
if (this._endTime) allParams.endTime = this._endTime + ' 23:59:59' if (this._endTime) allParams.endTime = this._endTime + ' 23:59:59'
const allGrindRes = await listRollGrindAll(allParams) const allGrindRes = await listRollGrindAll(allParams)
this.computeMonthlyPivot(allGrindRes.data || []) this.computeMonthlyPivot(allGrindRes.data || [], rollMap)
this.updateCharts() this.updateCharts()
} catch (e) { } catch (e) {
@@ -528,13 +528,14 @@ export default {
document.body.removeChild(link) document.body.removeChild(link)
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
}, },
computeMonthlyPivot(records) { computeMonthlyPivot(records, rollMap) {
const lineMonthMap = {} const lineMonthMap = {}
const monthSet = new Set() const monthSet = new Set()
records.forEach(r => { records.forEach(r => {
if (!r.grindTime) return if (!r.grindTime) return
const month = r.grindTime.substring(0, 7) const month = r.grindTime.substring(0, 7)
const lid = Number(r.lineId) || 0 const roll = rollMap[r.rollId]
const lid = Number(roll?.lineId) || 0
if (!lineMonthMap[lid]) { if (!lineMonthMap[lid]) {
const line = this.productionLines.find(l => Number(l.lineId) === lid) const line = this.productionLines.find(l => Number(l.lineId) === lid)
lineMonthMap[lid] = { lineId: lid, lineName: line ? line.lineName : (r.lineName || '未知产线'), months: {} } lineMonthMap[lid] = { lineId: lid, lineName: line ? line.lineName : (r.lineName || '未知产线'), months: {} }