feat(cost): 新增成本趋势图表管理功能与汇总数据保存

1.  为comprehensive.vue新增保存汇总数据到报表配置的逻辑
2.  重构trend.vue的筛选交互为图表管理面板,支持显示隐藏、排序系列,配置持久化到localStorage
3.  替换原有的明细数据拉取逻辑为读取报表配置获取汇总数据
This commit is contained in:
砂糖
2026-07-11 15:43:28 +08:00
parent 534fd20342
commit 656df92fa5
2 changed files with 325 additions and 98 deletions

View File

@@ -938,6 +938,41 @@ export default {
await batchSaveProdMetricResult({ resultIds, prodMetricResultList: metricResultList }) await batchSaveProdMetricResult({ resultIds, prodMetricResultList: metricResultList })
} }
// 保存汇总数据到报表config
const summaryRows = this.summaryRows
const sumRow = summaryRows.find(r => r.$summaryType === 'sum')
const avgRow = summaryRows.find(r => r.$summaryType === 'avg')
const buildSummaryMap = (row) => {
const map = {}
if (!row) return map
this.allCols.forEach(col => {
const hdr = col.$type === 'detail'
? (col.itemName || col.itemCode) + (col.unit ? '(' + col.unit + ')' : '')
: col.metricName + (col.unit ? '(' + col.unit + ')' : '')
if (col.isShift) {
if (col.$type === 'detail') {
const v1 = row['q' + col.itemId + '_1']
const v2 = row['q' + col.itemId + '_2']
if (v1 != null) map[hdr + '(甲)'] = v1
if (v2 != null) map[hdr + '(乙)'] = v2
} else {
const v1 = row['mv' + col.mIdx + '_1']
const v2 = row['mv' + col.mIdx + '_2']
if (v1 != null) map[hdr + '(甲)'] = v1
if (v2 != null) map[hdr + '(乙)'] = v2
}
} else {
const v = col.$type === 'detail' ? row['q' + col.itemId] : row['mv' + col.mIdx]
if (v != null) map[hdr] = v
}
})
return map
}
const cfg = JSON.parse(this.activeReport.colConfig || '{}')
cfg.avg = buildSummaryMap(avgRow)
cfg.sum = buildSummaryMap(sumRow)
await updateProdReport({ reportId: rid, colConfig: JSON.stringify(cfg) })
this.$modal.msgSuccess("保存成功"); await this.loadGrid() this.$modal.msgSuccess("保存成功"); await this.loadGrid()
} catch(e) { this.$modal.msgError("保存失败") } finally { this.saving = false } } catch(e) { this.$modal.msgError("保存失败") } finally { this.saving = false }
}, },

View File

@@ -38,48 +38,54 @@
</div> </div>
</div> </div>
<!-- 图表筛选 --> <!-- 图表管理 -->
<div v-if="reports.length > 0" class="chart-filter-bar"> <div v-if="reports.length > 0" class="series-panel">
<span class="filter-label">图表筛选</span> <span class="panel-label">图表</span>
<el-select <div class="series-chips">
v-model="selectedCategories" <span
multiple v-for="(sk, idx) in orderedSeriesKeys"
collapse-tags :key="sk.key"
placeholder="成本类别(全部)" :class="['series-chip', { off: sk._hidden }]"
clearable :title="sk._hidden ? '点击显示' : '点击隐藏'"
size="small" @click="toggleSeries(sk.key)"
style="width:150px" >
@change="onCategoryChange" <span class="chip-dot" :style="{ background: sk._color }" />
> <span class="chip-name">{{ sk.name }}</span>
<el-option label="原料" value="原料" /> <span class="chip-arrows">
<el-option label="能耗" value="能耗" /> <i class="el-icon-caret-top" :class="{ disabled: idx === 0 }" @click.stop="moveSeries(idx, -1)" />
<el-option label="辅料" value="辅料" /> <i class="el-icon-caret-bottom" :class="{ disabled: idx === orderedSeriesKeys.length - 1 }" @click.stop="moveSeries(idx, 1)" />
<el-option label="设备" value="设备" /> </span>
<el-option label="人工" value="人工" /> </span>
<el-option label="产出" value="产出" /> </div>
<el-option label="轧辊" value="轧辊" /> <el-button size="mini" icon="el-icon-s-operation" circle @click="mgrOpen = true" />
<el-option label="其他" value="其他" />
</el-select>
<el-select
v-model="selectedItems"
multiple
collapse-tags
filterable
placeholder="成本项(全部)"
clearable
size="small"
style="width:200px"
@change="onItemChange"
>
<el-option
v-for="it in filteredItemOptions"
:key="it.itemId"
:label="it.itemName + ' (' + it.category + ')'"
:value="it.itemId"
/>
</el-select>
</div> </div>
<!-- 图表管理弹窗 -->
<el-dialog title="图表管理" :visible.sync="mgrOpen" width="680px" append-to-body top="8vh">
<el-table :data="orderedSeriesKeys" size="small" max-height="460">
<el-table-column label="排序" width="100" align="center">
<template slot-scope="s">
<el-button icon="el-icon-top" size="mini" circle :disabled="s.$index === 0" @click="moveSeries(s.$index, -1)" />
<el-button icon="el-icon-bottom" size="mini" circle :disabled="s.$index === orderedSeriesKeys.length - 1" @click="moveSeries(s.$index, 1)" />
</template>
</el-table-column>
<el-table-column label="名称" min-width="250">
<template slot-scope="s">
<span class="mgr-chip-dot" :style="{ background: s.row._color }" />
<span>{{ s.row.name }}</span>
</template>
</el-table-column>
<el-table-column label="可见" width="80" align="center">
<template slot-scope="s">
<el-switch :value="!s.row._hidden" @change="toggleSeries(s.row.key)" />
</template>
</el-table-column>
</el-table>
<div slot="footer">
<el-button type="primary" @click="mgrOpen = false"> </el-button>
</div>
</el-dialog>
<!-- 迷你图表网格 --> <!-- 迷你图表网格 -->
<div v-loading="loading" class="mini-charts-grid"> <div v-loading="loading" class="mini-charts-grid">
<template v-if="seriesList.length"> <template v-if="seriesList.length">
@@ -102,8 +108,7 @@
<script> <script>
import * as echarts from 'echarts' import * as echarts from 'echarts'
import { mapGetters } from 'vuex' import { mapGetters } from 'vuex'
import { listProdReport } from '@/api/cost/prodReport' import { listProdReport, getProdReport } from '@/api/cost/prodReport'
import { listProdDetail } from '@/api/cost/prodDetail'
import { listItem } from '@/api/cost/item' import { listItem } from '@/api/cost/item'
const COLORS = [ const COLORS = [
@@ -119,14 +124,15 @@ export default {
data() { data() {
return { return {
selectedLine: null, selectedLine: null,
selectedCategories: [],
selectedItems: [],
allItems: [], allItems: [],
loading: false, loading: false,
chartInstances: [], chartInstances: [],
seriesList: [], seriesList: [],
reports: [], reports: [],
cachedDetails: [], cachedConfigs: [],
allSeriesKeys: [],
seriesConfig: { hidden: {}, order: [] },
mgrOpen: false,
summary: { seriesCount: 0, maxLabel: '-' } summary: { seriesCount: 0, maxLabel: '-' }
} }
}, },
@@ -136,9 +142,23 @@ 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
}, },
filteredItemOptions() { orderedSeriesKeys() {
if (!this.selectedCategories.length) return this.allItems const order = this.seriesConfig.order || []
return this.allItems.filter(it => this.selectedCategories.includes(it.category)) const hidden = this.seriesConfig.hidden || {}
const keys = [...this.allSeriesKeys]
// Sort: items in saved order first, then new items by original index
const orderMap = {}
order.forEach((k, i) => { orderMap[k] = i })
keys.sort((a, b) => {
const ai = orderMap[a.key] ?? 9999
const bi = orderMap[b.key] ?? 9999
if (ai !== bi) return ai - bi
// preserve original order for new items
return 0
})
// Attach _hidden flag
keys.forEach(k => { k._hidden = !!hidden[k.key] })
return keys
} }
}, },
mounted() { mounted() {
@@ -172,15 +192,47 @@ export default {
} }
}, },
onCategoryChange() { /* ---- 系列配置localStorage ---- */
this.selectedItems = this.selectedItems.filter(id => { storageKey() {
const it = this.allItems.find(item => item.itemId === id) return 'cost_trend_cfg_' + (this.selectedLine || 'default')
return it && this.selectedCategories.includes(it.category) },
}) loadSeriesConfig() {
try {
const raw = localStorage.getItem(this.storageKey())
if (raw) {
const cfg = JSON.parse(raw)
this.seriesConfig = {
hidden: cfg.hidden || {},
order: cfg.order || []
}
} else {
this.seriesConfig = { hidden: {}, order: [] }
}
} catch (e) {
this.seriesConfig = { hidden: {}, order: [] }
}
},
saveSeriesConfig() {
try {
localStorage.setItem(this.storageKey(), JSON.stringify(this.seriesConfig))
} catch (e) { /* ignore */ }
},
isSeriesHidden(key) {
return !!this.seriesConfig.hidden[key]
},
toggleSeries(key) {
this.$set(this.seriesConfig.hidden, key, !this.seriesConfig.hidden[key])
this.saveSeriesConfig()
this.renderFromCache() this.renderFromCache()
}, },
moveSeries(idx, dir) {
onItemChange() { const keys = this.orderedSeriesKeys.map(sk => sk.key)
const t = idx + dir
if (t < 0 || t >= keys.length) return
const item = keys.splice(idx, 1)[0]
keys.splice(t, 0, item)
this.seriesConfig.order = keys
this.saveSeriesConfig()
this.renderFromCache() this.renderFromCache()
}, },
@@ -217,7 +269,7 @@ export default {
} }
this.reports = allReports this.reports = allReports
await this.fetchDetailsAndCache(allReports) await this.fetchConfigsAndCache(allReports)
} catch (e) { } catch (e) {
console.error('CostTrend fetchData error:', e) console.error('CostTrend fetchData error:', e)
this.$modal.msgError('数据加载失败: ' + (e.message || '未知错误')) this.$modal.msgError('数据加载失败: ' + (e.message || '未知错误'))
@@ -226,18 +278,21 @@ export default {
} }
}, },
async fetchDetailsAndCache(reports) { async fetchConfigsAndCache(reports) {
const allDetailResults = await Promise.all( const fullReports = await Promise.all(
reports.map(r => reports.map(r =>
listProdDetail({ reportId: r.reportId, pageNum: 1, pageSize: 99999 }) getProdReport(r.reportId)
.then(res => ({ reportId: r.reportId, rows: res.rows || [] })) .then(res => res.data || {})
.catch(e => { .catch(e => {
console.error('[CostTrend] detail fetch error for report', r.reportId, e) console.error('[CostTrend] get report error', r.reportId, e)
return { reportId: r.reportId, rows: [] } return {}
}) })
) )
) )
this.cachedDetails = allDetailResults this.cachedConfigs = fullReports.map((r, i) => ({
reportId: reports[i].reportId,
colConfig: r.colConfig || '{}'
}))
if (!this.allItems.length) await this.loadItems() if (!this.allItems.length) await this.loadItems()
@@ -246,47 +301,109 @@ export default {
renderFromCache() { renderFromCache() {
const reports = this.reports const reports = this.reports
const allDetailResults = this.cachedDetails const configs = this.cachedConfigs
if (!reports.length || !allDetailResults.length) return if (!reports.length || !configs.length) return
const itemMap = {} const itemMap = {}
this.allItems.forEach(it => { itemMap[String(it.itemId)] = it }) this.allItems.forEach(it => { itemMap[String(it.itemId)] = it })
const itemSet = this.selectedItems.length ? new Set(this.selectedItems.map(String)) : null // Parse each report's colConfig to extract sum values
const categorySet = this.selectedCategories.length ? new Set(this.selectedCategories) : null const reportData = []
const allKeys = new Map() // key -> { key, name, category, itemId }
const reportSums = {} configs.forEach((cfg, i) => {
allDetailResults.forEach(({ reportId, rows }) => { const report = reports[i]
const sums = {} let parsed = {}
rows.forEach(d => { try { parsed = JSON.parse(cfg.colConfig || '{}') } catch (e) { /* ignore */ }
if (!d.itemId) return const sum = parsed.sum || {}
const key = String(d.itemId) const columns = parsed.columns || []
if (itemSet && !itemSet.has(key)) return
if (categorySet) { // Build header text -> category mapping from columns
const it = itemMap[key] const headerMeta = {}
if (!it || !categorySet.has(it.category)) return columns.forEach(col => {
if (col.t === 'd') {
const item = itemMap[String(col.id)]
if (item) {
const baseHdr = item.itemName + (item.unit ? '(' + item.unit + ')' : '')
headerMeta[baseHdr] = { category: item.category, itemId: item.itemId }
headerMeta[baseHdr + '(甲)'] = { category: item.category, itemId: item.itemId }
headerMeta[baseHdr + '(乙)'] = { category: item.category, itemId: item.itemId }
}
} }
sums[key] = (sums[key] || 0) + (parseFloat(d.quantity) || 0)
}) })
reportSums[reportId] = sums
const sumMap = {}
Object.entries(sum).forEach(([hdr, val]) => {
const num = parseFloat(val)
if (isNaN(num)) return
sumMap[hdr] = num
const meta = headerMeta[hdr] || {}
if (!allKeys.has(hdr)) {
allKeys.set(hdr, {
key: hdr,
name: hdr,
category: meta.category || null,
itemId: meta.itemId || null
})
}
})
reportData.push({
reportId: cfg.reportId,
sumMap,
label: this.reportLabel(report)
})
}) })
const allItemIds = new Set() // Load saved config & merge
Object.values(reportSums).forEach(sums => { this.allSeriesKeys = Array.from(allKeys.values())
Object.keys(sums).forEach(id => allItemIds.add(id))
// Assign stable colors to each key (before config load so orderedSeriesKeys gets them)
this.allSeriesKeys.forEach((sk, i) => {
sk._color = COLORS[i % COLORS.length]
}) })
const xLabels = reports.map(r => this.reportLabel(r)) this.loadSeriesConfig()
// Persist any new keys in order
const curKeys = this.allSeriesKeys.map(sk => sk.key)
const savedOrder = this.seriesConfig.order || []
const knownSet = new Set(savedOrder)
let dirty = false
curKeys.forEach(k => {
if (!knownSet.has(k)) {
savedOrder.push(k)
dirty = true
}
})
// Remove stale keys from order
const curSet = new Set(curKeys)
const cleanOrder = savedOrder.filter(k => curSet.has(k))
if (cleanOrder.length !== savedOrder.length) dirty = true
this.seriesConfig.order = cleanOrder
if (dirty) this.saveSeriesConfig()
// Build series list (respect order & visibility)
const orderMap = {}
cleanOrder.forEach((k, i) => { orderMap[k] = i })
const ordered = [...this.allSeriesKeys].sort((a, b) => {
const ai = orderMap[a.key] ?? 9999
const bi = orderMap[b.key] ?? 9999
return ai - bi
})
const xLabels = reportData.map(r => r.label)
const list = [] const list = []
let globalMax = 0 let globalMax = 0
Array.from(allItemIds).forEach((key, idx) => { ordered.forEach(sk => {
const it = itemMap[key] // Skip hidden
const name = it ? it.itemName : `未知(${key})` if (this.seriesConfig.hidden[sk.key]) return
const data = reports.map(r => {
const sums = reportSums[r.reportId] || {} const data = reportData.map(r => {
const val = sums[key] ?? null const v = r.sumMap[sk.key]
return val return (v != null && isFinite(v)) ? v : 0
}) })
if (data.every(v => v === null)) return if (data.every(v => v === null)) return
@@ -294,9 +411,9 @@ export default {
if (maxVal > globalMax) globalMax = maxVal if (maxVal > globalMax) globalMax = maxVal
list.push({ list.push({
key, key: sk.key,
name, name: sk.name,
color: COLORS[idx % COLORS.length], color: sk._color,
data, data,
xLabels, xLabels,
maxVal maxVal
@@ -477,17 +594,92 @@ export default {
.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-filter-bar { .series-panel {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 4px;
margin-bottom: 8px; margin-bottom: 8px;
} }
.chart-filter-bar .filter-label { .series-panel .panel-label {
font-size: 12px; font-size: 12px;
color: #606266; color: #909399;
white-space: nowrap; white-space: nowrap;
flex-shrink: 0;
}
.series-chips {
display: flex;
gap: 4px;
flex: 1;
overflow-x: auto;
padding-bottom: 2px;
}
.series-chips::-webkit-scrollbar {
height: 4px;
}
.series-chips::-webkit-scrollbar-thumb {
background: #dcdfe6;
border-radius: 2px;
}
.series-chip {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 2px 7px;
border: 1px solid #dcdfe6;
border-radius: 3px;
font-size: 12px;
background: #fff;
cursor: pointer;
user-select: none;
white-space: nowrap;
flex-shrink: 0;
transition: opacity .15s, border-color .15s;
}
.series-chip:hover {
border-color: #409eff;
}
.series-chip.off {
opacity: 0.4;
}
.series-chip .chip-dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.series-chip .chip-name {
color: #303133;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
}
.series-chip .chip-arrows {
display: inline-flex;
flex-direction: column;
line-height: 1;
margin-left: 1px;
}
.series-chip .chip-arrows i {
font-size: 10px;
color: #c0c4cc;
cursor: pointer;
line-height: 1;
}
.series-chip .chip-arrows i:hover:not(.disabled) {
color: #409eff;
}
.series-chip .chip-arrows i.disabled {
color: #e8e8e8;
cursor: default;
}
.mgr-chip-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
vertical-align: middle;
margin-right: 6px;
} }
/* 迷你图表网格 */ /* 迷你图表网格 */