feat(cost): 新增成本综合页的异常分析功能
1. 新增异常分析弹窗组件AnomalyAnalysis,支持基于3σ法则检测异常数据 2. 在综合成本页面右上角添加异常分析入口按钮 3. 为异常单元格添加红色高亮样式,标记异常数据 4. 实现异常数据的接收、存储和表格标记逻辑
This commit is contained in:
640
klp-ui/src/views/cost/components/AnomalyAnalysis.vue
Normal file
640
klp-ui/src/views/cost/components/AnomalyAnalysis.vue
Normal file
@@ -0,0 +1,640 @@
|
||||
<template>
|
||||
<el-dialog title="异常分析" :visible.sync="dialogVisible" width="960px" top="3vh" append-to-body :close-on-click-modal="false" @opened="onOpened" @closed="onClosed">
|
||||
<div class="anomaly-top-bar">
|
||||
<el-date-picker v-model="dateRange" type="daterange" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" value-format="yyyy-MM-dd" size="small" style="width:260px" />
|
||||
<el-button type="primary" size="small" icon="el-icon-search" :loading="running" @click="startAnalysis">开始分析</el-button>
|
||||
<el-button size="small" :disabled="!anomalyList.length" @click="$emit('anomaly-found', anomalyList)">应用到表格</el-button>
|
||||
</div>
|
||||
|
||||
<!-- Progress -->
|
||||
<div class="anomaly-progress" v-if="running || steps.length">
|
||||
<div v-for="(s, i) in steps" :key="i" class="anomaly-step">
|
||||
<i v-if="s.status==='pending'" class="el-icon-time step-icon pending"></i>
|
||||
<i v-else-if="s.status==='running'" class="el-icon-loading step-icon running"></i>
|
||||
<i v-else-if="s.status==='success'" class="el-icon-circle-check step-icon success"></i>
|
||||
<i v-else class="el-icon-circle-close step-icon fail"></i>
|
||||
<span class="step-label">{{ s.label }}</span>
|
||||
<span v-if="s.msg" class="step-msg">{{ s.msg }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chart -->
|
||||
<div v-if="chartReady" style="margin-top:12px">
|
||||
<div style="margin-bottom:6px;display:flex;align-items:center;gap:8px">
|
||||
<span style="font-size:13px;color:#303133">吨钢指标分布(3σ)</span>
|
||||
<el-select v-model="chartItemId" size="mini" style="width:200px" @change="updateChart">
|
||||
<el-option v-for="it in analysisItems" :key="it.itemId" :label="it.itemName" :value="String(it.itemId)" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div ref="chart" style="width:100%;height:380px"></div>
|
||||
</div>
|
||||
|
||||
<!-- Anomaly table -->
|
||||
<div v-if="anomalyList.length" style="margin-top:12px">
|
||||
<div style="font-size:13px;color:#303133;margin-bottom:6px">异常数据列表(共 {{ anomalyList.length }} 条)</div>
|
||||
<el-table :data="anomalyList" border stripe size="mini" max-height="280">
|
||||
<el-table-column label="日期" prop="detailDate" width="110" />
|
||||
<el-table-column label="指标" prop="itemName" min-width="120" />
|
||||
<el-table-column label="吨钢值" prop="perTon" width="90" align="center" />
|
||||
<el-table-column label="均值 μ" prop="mu" width="90" align="center" />
|
||||
<el-table-column label="标准差 σ" prop="sigma" width="90" align="center" />
|
||||
<el-table-column label="偏离(σ)" width="90" align="center">
|
||||
<template slot-scope="s">
|
||||
<span :style="{color: Math.abs(s.row.deviationSigma) > 3 ? '#f56c6c' : '#e6a23c'}">{{ s.row.deviationSigma }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<div v-else-if="chartReady && !running" style="margin-top:12px;text-align:center;padding:20px;color:#67c23a">
|
||||
<i class="el-icon-success" /> 未发现异常数据
|
||||
</div>
|
||||
|
||||
<div slot="footer">
|
||||
<el-button @click="dialogVisible = false">关 闭</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as echarts from 'echarts'
|
||||
import { listProdDetail } from '@/api/cost/prodDetail'
|
||||
|
||||
export default {
|
||||
name: 'AnomalyAnalysis',
|
||||
props: {
|
||||
visible: { type: Boolean, default: false },
|
||||
reportId: { type: Number, default: null },
|
||||
allItems: { type: Array, default: () => [] },
|
||||
gridRows: { type: Array, default: () => [] },
|
||||
allCols: { type: Array, default: () => [] }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
dateRange: this.defaultDateRange(),
|
||||
running: false,
|
||||
steps: [],
|
||||
chartReady: false,
|
||||
chart: null,
|
||||
chartItemId: '',
|
||||
anomalyList: [],
|
||||
// cached for toggling chart by item
|
||||
_analysisCache: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
dialogVisible: {
|
||||
get() { return this.visible },
|
||||
set(v) { this.$emit('update:visible', v) }
|
||||
},
|
||||
analysisItems() {
|
||||
if (!this._analysisCache) return []
|
||||
return this._analysisCache.items || []
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
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: {
|
||||
defaultDateRange() {
|
||||
const end = new Date()
|
||||
const start = new Date()
|
||||
start.setDate(start.getDate() - 180)
|
||||
const fmt = d => d.toISOString().slice(0,10)
|
||||
return [fmt(start), fmt(end)]
|
||||
},
|
||||
|
||||
async startAnalysis() {
|
||||
if (!this.allItems || !this.allItems.length) { this.$modal.msgWarning('请先加载成本项目数据'); return }
|
||||
if (!this.dateRange || this.dateRange.length !== 2) { this.$modal.msgWarning('请选择日期范围'); return }
|
||||
this.running = true
|
||||
this.chartReady = false
|
||||
this.anomalyList = []
|
||||
this._analysisCache = null
|
||||
this.steps = [
|
||||
{ label: '正在获取参考数据集', status: 'pending', msg: '' },
|
||||
{ label: '正在处理参考吨钢数据', status: 'pending', msg: '' },
|
||||
{ label: '正在计算3σ基线', status: 'pending', msg: '' },
|
||||
{ label: '正在比对当前表格数据', status: 'pending', msg: '' },
|
||||
{ label: '正在生成分析图表', status: 'pending', msg: '' }
|
||||
]
|
||||
|
||||
try {
|
||||
// Step 1: fetch historical reference dataset
|
||||
this.steps[0].status = 'running'
|
||||
const res = await listProdDetail({
|
||||
detailDateStart: this.dateRange[0],
|
||||
detailDateEnd: this.dateRange[1],
|
||||
pageSize: 99999
|
||||
})
|
||||
const allDetails = res.rows || []
|
||||
this.steps[0].status = 'success'
|
||||
this.steps[0].msg = `共 ${allDetails.length} 条记录`
|
||||
|
||||
// Step 2: build per-ton from reference data
|
||||
this.steps[1].status = 'running'
|
||||
const refPerTon = this.buildPerTonData(allDetails)
|
||||
const totalSamples = refPerTon.items.reduce((s, it) => s + it.series.filter(v => v !== null).length, 0)
|
||||
this.steps[1].status = 'success'
|
||||
this.steps[1].msg = `${refPerTon.items.length} 个指标,${refPerTon.dates.length} 个日期,${totalSamples} 个有效样本`
|
||||
|
||||
// Step 3: compute 3σ baseline from reference data (no anomaly detection yet)
|
||||
this.steps[2].status = 'running'
|
||||
const baseline = this.compute3SigmaBaseline(refPerTon)
|
||||
this.steps[2].status = 'success'
|
||||
this.steps[2].msg = `${baseline.items.length} 个指标基线已建立`
|
||||
|
||||
// Step 4: build per-ton from current gridRows, check against baseline
|
||||
this.steps[3].status = 'running'
|
||||
const gridPerTonItems = this.buildPerTonFromGrid()
|
||||
const gridAnomalies = this.checkGridAgainstBaseline(gridPerTonItems, baseline)
|
||||
this.anomalyList = gridAnomalies
|
||||
this.steps[3].status = 'success'
|
||||
this.steps[3].msg = `发现 ${gridAnomalies.length} 条异常`
|
||||
|
||||
// Step 5: chart (show reference distribution)
|
||||
this.steps[4].status = 'running'
|
||||
this._analysisCache = baseline
|
||||
if (baseline.items.length && !this.chartItemId) {
|
||||
this.chartItemId = String(baseline.items[0].itemId)
|
||||
}
|
||||
this.chartReady = true
|
||||
await this.$nextTick()
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
this.initChart()
|
||||
this.steps[4].status = 'success'
|
||||
} catch (e) {
|
||||
const active = this.steps.find(s => s.status === 'running')
|
||||
if (active) { active.status = 'fail'; active.msg = e.message || '执行失败' }
|
||||
} finally {
|
||||
this.running = false
|
||||
}
|
||||
},
|
||||
|
||||
buildPerTonData(details) {
|
||||
// 找出产出item(分母)和辅料/能耗item(分子)
|
||||
const outputItems = this.allItems.filter(it => it.category === '产出')
|
||||
const auxItems = this.allItems.filter(it => it.category === '辅料' || it.category === '能耗')
|
||||
if (!outputItems.length || !auxItems.length) return { items: [], dates: [] }
|
||||
|
||||
// 按日期分组:{ date: { itemId_shift: quantity } }
|
||||
const dateMap = {}
|
||||
details.forEach(d => {
|
||||
if (!d.detailDate) return
|
||||
const dt = (d.detailDate || '').slice ? d.detailDate.slice(0, 10) : String(d.detailDate).slice(0, 10)
|
||||
if (!dateMap[dt]) dateMap[dt] = {}
|
||||
const sfx = (d.shift && d.shift !== '0') ? '_' + d.shift : ''
|
||||
const key = String(d.itemId) + sfx
|
||||
const qty = parseFloat(d.quantity) || 0
|
||||
dateMap[dt][key] = (dateMap[dt][key] || 0) + qty
|
||||
})
|
||||
const dates = Object.keys(dateMap).sort()
|
||||
|
||||
// 计算每个日期的总产出(所有产出item的quantity之和,含分班次)
|
||||
const outputIds = new Set(outputItems.map(out => String(out.itemId)))
|
||||
const outputTotals = {}
|
||||
dates.forEach(dt => {
|
||||
const map = dateMap[dt]
|
||||
let total = 0
|
||||
Object.keys(map).forEach(key => {
|
||||
// Match any key that starts with an output itemId (handles shift suffixes like _1, _2)
|
||||
const baseId = key.replace(/_\d+$/, '')
|
||||
if (outputIds.has(baseId)) total += map[key] || 0
|
||||
})
|
||||
outputTotals[dt] = total
|
||||
})
|
||||
|
||||
// 计算每个辅料/能耗 item 的吨钢值序列
|
||||
const items = []
|
||||
auxItems.forEach(aux => {
|
||||
const auxId = String(aux.itemId)
|
||||
const series = dates.map(dt => {
|
||||
const map = dateMap[dt]
|
||||
// Sum all quantities for this aux item (handles shift suffixes)
|
||||
let auxQty = 0
|
||||
Object.keys(map).forEach(key => {
|
||||
const baseId = key.replace(/_\d+$/, '')
|
||||
if (baseId === auxId) auxQty += map[key] || 0
|
||||
})
|
||||
const outQty = outputTotals[dt] || 0
|
||||
if (outQty === 0) return null
|
||||
return auxQty / outQty
|
||||
})
|
||||
if (series.every(v => v === null)) return
|
||||
|
||||
items.push({
|
||||
itemId: aux.itemId,
|
||||
itemName: aux.itemName || aux.itemCode,
|
||||
itemCode: aux.itemCode,
|
||||
category: aux.category,
|
||||
series,
|
||||
dates
|
||||
})
|
||||
})
|
||||
|
||||
return { items, dates }
|
||||
},
|
||||
|
||||
compute3SigmaBaseline(perTonData) {
|
||||
// Compute μ/σ baseline from reference data (no anomaly detection)
|
||||
const { items, dates } = perTonData
|
||||
const itemResults = []
|
||||
|
||||
items.forEach(item => {
|
||||
const rawValues = item.series.filter(v => v !== null)
|
||||
if (rawValues.length < 3) {
|
||||
itemResults.push({ ...item, mu: 0, sigma: 0, anomalies: [] })
|
||||
return
|
||||
}
|
||||
|
||||
const computeMuSigma = (vals) => {
|
||||
const n = vals.length
|
||||
const mu = vals.reduce((a, b) => a + b, 0) / n
|
||||
const variance = vals.reduce((s, v) => s + (v - mu) * (v - mu), 0) / n
|
||||
return { mu, sigma: Math.sqrt(variance) }
|
||||
}
|
||||
|
||||
// Iterative: remove gross outliers before establishing baseline
|
||||
const p1 = computeMuSigma(rawValues)
|
||||
const cleanValues = rawValues.filter(v => p1.sigma === 0 || Math.abs(v - p1.mu) <= 5 * p1.sigma)
|
||||
const p2 = cleanValues.length >= 3 ? computeMuSigma(cleanValues) : p1
|
||||
|
||||
itemResults.push({ ...item, mu: p2.mu, sigma: p2.sigma, anomalies: [] })
|
||||
})
|
||||
|
||||
return { items: itemResults, dates }
|
||||
},
|
||||
|
||||
buildPerTonFromGrid() {
|
||||
// Build per-ton values from current gridRows
|
||||
const outputItems = this.allItems.filter(it => it.category === '产出')
|
||||
const auxItems = this.allItems.filter(it => it.category === '辅料' || it.category === '能耗')
|
||||
if (!outputItems.length || !auxItems.length) return []
|
||||
|
||||
const outputIds = new Set(outputItems.map(o => String(o.itemId)))
|
||||
const items = []
|
||||
|
||||
auxItems.forEach(aux => {
|
||||
const auxId = String(aux.itemId)
|
||||
const auxCol = this.allCols.find(c => c.$type === 'detail' && String(c.itemId) === auxId)
|
||||
const auxIsShift = auxCol ? auxCol.isShift : false
|
||||
const points = []
|
||||
|
||||
this.gridRows.forEach(row => {
|
||||
if (!row.detailDate) return
|
||||
const dt = (row.detailDate || '').slice ? row.detailDate.slice(0, 10) : String(row.detailDate).slice(0, 10)
|
||||
|
||||
// Sum output quantities for this date from grid row
|
||||
let outQty = 0
|
||||
this.allCols.forEach(col => {
|
||||
if (col.$type !== 'detail') return
|
||||
const cid = String(col.itemId)
|
||||
if (!outputIds.has(cid)) return
|
||||
if (col.isShift) {
|
||||
outQty += parseFloat(row['q' + cid + '_1']) || 0
|
||||
outQty += parseFloat(row['q' + cid + '_2']) || 0
|
||||
} else {
|
||||
outQty += parseFloat(row['q' + cid]) || 0
|
||||
}
|
||||
})
|
||||
|
||||
// Sum aux quantity for this item
|
||||
let auxQty = 0
|
||||
if (auxIsShift) {
|
||||
auxQty += parseFloat(row['q' + auxId + '_1']) || 0
|
||||
auxQty += parseFloat(row['q' + auxId + '_2']) || 0
|
||||
} else {
|
||||
auxQty += parseFloat(row['q' + auxId]) || 0
|
||||
}
|
||||
|
||||
if (outQty > 0) {
|
||||
points.push({ detailDate: dt, perTon: auxQty / outQty })
|
||||
}
|
||||
})
|
||||
|
||||
if (points.length) {
|
||||
items.push({
|
||||
itemId: aux.itemId,
|
||||
itemName: aux.itemName || aux.itemCode,
|
||||
itemCode: aux.itemCode,
|
||||
category: aux.category,
|
||||
points
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return items
|
||||
},
|
||||
|
||||
checkGridAgainstBaseline(gridItems, baseline) {
|
||||
// Check grid data points against reference baseline μ/σ
|
||||
const anomalies = []
|
||||
const baseMap = {}
|
||||
baseline.items.forEach(it => {
|
||||
if (it.mu != null && it.sigma > 0) baseMap[String(it.itemId)] = it
|
||||
})
|
||||
|
||||
gridItems.forEach(gItem => {
|
||||
const base = baseMap[String(gItem.itemId)]
|
||||
if (!base) return
|
||||
|
||||
gItem.points.forEach(p => {
|
||||
const diff = Math.abs(p.perTon - base.mu)
|
||||
if (diff > 3 * base.sigma) {
|
||||
const deviationSigma = (p.perTon - base.mu) / base.sigma
|
||||
anomalies.push({
|
||||
detailDate: p.detailDate,
|
||||
itemId: gItem.itemId,
|
||||
itemName: gItem.itemName,
|
||||
itemCode: gItem.itemCode,
|
||||
category: gItem.category,
|
||||
perTon: Math.round(p.perTon * 1e6) / 1e6,
|
||||
mu: Math.round(base.mu * 1e6) / 1e6,
|
||||
sigma: Math.round(base.sigma * 1e6) / 1e6,
|
||||
deviationSigma: Math.round(deviationSigma * 100) / 100
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return anomalies
|
||||
},
|
||||
|
||||
initChart() {
|
||||
if (this.chart) { this.chart.dispose(); this.chart = null }
|
||||
if (!this.$refs.chart) {
|
||||
console.warn('AnomalyAnalysis: chart ref not found')
|
||||
return
|
||||
}
|
||||
this.chart = echarts.init(this.$refs.chart)
|
||||
this.updateChart()
|
||||
},
|
||||
|
||||
updateChart() {
|
||||
if (!this.chart || !this._analysisCache) return
|
||||
const { items, dates } = this._analysisCache
|
||||
const filtered = this.chartItemId
|
||||
? items.filter(it => String(it.itemId) === this.chartItemId)
|
||||
: items
|
||||
if (!filtered.length) return
|
||||
|
||||
const colors = ['#409eff', '#67c23a', '#e6a23c', '#f56c6c', '#909399', '#9b59b6', '#1abc9c', '#e74c3c']
|
||||
const series = []
|
||||
|
||||
// Collect all per-ton values for histogram
|
||||
const allValues = []
|
||||
filtered.forEach(item => {
|
||||
item.series.forEach(v => { if (v !== null) allValues.push(v) })
|
||||
})
|
||||
if (!allValues.length) return
|
||||
|
||||
// Compute global range and histogram binning first (needed for scaling)
|
||||
const globalMin = Math.min(...allValues)
|
||||
const globalMax = Math.max(...allValues)
|
||||
|
||||
// Compute x-axis range from first item's μ/σ (used for both histogram & x-axis)
|
||||
let axisMin, axisMax
|
||||
const firstItem = filtered[0]
|
||||
if (firstItem && firstItem.mu != null && firstItem.sigma != null && firstItem.sigma > 0) {
|
||||
const margin = 3.5 * firstItem.sigma
|
||||
axisMin = firstItem.mu - margin
|
||||
axisMax = firstItem.mu + margin
|
||||
} else {
|
||||
axisMin = globalMin
|
||||
axisMax = globalMax
|
||||
}
|
||||
|
||||
// Histogram binning within the x-axis range
|
||||
const binCount = Math.min(30, Math.max(10, Math.ceil(Math.sqrt(allValues.length))))
|
||||
const binWidth = (axisMax - axisMin) / binCount
|
||||
const bins = new Array(binCount).fill(0)
|
||||
allValues.forEach(v => {
|
||||
const i = Math.min(binCount - 1, Math.max(0, Math.floor((v - axisMin) / binWidth)))
|
||||
bins[i]++
|
||||
})
|
||||
|
||||
filtered.forEach((item, idx) => {
|
||||
const color = colors[idx % colors.length]
|
||||
const { mu, sigma } = item
|
||||
if (mu == null || sigma == null || sigma <= 0) return
|
||||
|
||||
// Use ±4σ as curve range to show tails
|
||||
const xMin = mu - 4 * sigma
|
||||
const xMax = mu + 4 * sigma
|
||||
const N = 300
|
||||
const step = (xMax - xMin) / N
|
||||
// Scale factor: convert PDF density → proportion (%) per histogram bin-width
|
||||
const scale = binWidth * 100
|
||||
|
||||
// Generate normal curve scaled to proportion (%)
|
||||
const curve = []
|
||||
for (let i = 0; i <= N; i++) {
|
||||
const x = xMin + i * step
|
||||
const y = (1 / (sigma * Math.sqrt(2 * Math.PI))) * Math.exp(-0.5 * ((x - mu) / sigma) ** 2) * scale
|
||||
curve.push([x, y])
|
||||
}
|
||||
|
||||
// Band 1: within ±1σ (center, darkest)
|
||||
const band1 = curve.map(([x, y]) => ({
|
||||
value: [x, (x >= mu - sigma && x <= mu + sigma) ? y : 0]
|
||||
}))
|
||||
series.push({
|
||||
name: '±1σ (68.3%)',
|
||||
type: 'line', data: band1,
|
||||
lineStyle: { color: 'transparent', width: 0 },
|
||||
areaStyle: { color: 'rgba(64,158,255,0.35)', origin: 'start' },
|
||||
symbol: 'none', silent: true, z: 1
|
||||
})
|
||||
|
||||
// Band 2: ±2σ excluding ±1σ
|
||||
const band2 = curve.map(([x, y]) => ({
|
||||
value: [x, ((x >= mu - 2*sigma && x < mu - sigma) || (x > mu + sigma && x <= mu + 2*sigma)) ? y : 0]
|
||||
}))
|
||||
series.push({
|
||||
name: '±2σ (95.4%)',
|
||||
type: 'line', data: band2,
|
||||
lineStyle: { color: 'transparent', width: 0 },
|
||||
areaStyle: { color: 'rgba(64,158,255,0.20)', origin: 'start' },
|
||||
symbol: 'none', silent: true, z: 1
|
||||
})
|
||||
|
||||
// Band 3: ±3σ excluding inner bands
|
||||
const band3 = curve.map(([x, y]) => ({
|
||||
value: [x, ((x >= mu - 3*sigma && x < mu - 2*sigma) || (x > mu + 2*sigma && x <= mu + 3*sigma)) ? y : 0]
|
||||
}))
|
||||
series.push({
|
||||
name: '±3σ (99.7%)',
|
||||
type: 'line', data: band3,
|
||||
lineStyle: { color: 'transparent', width: 0 },
|
||||
areaStyle: { color: 'rgba(64,158,255,0.10)', origin: 'start' },
|
||||
symbol: 'none', silent: true, z: 1
|
||||
})
|
||||
|
||||
// Bell curve outline
|
||||
series.push({
|
||||
name: item.itemName + ' 正态分布',
|
||||
type: 'line', data: curve,
|
||||
lineStyle: { color, width: 2 },
|
||||
symbol: 'none', z: 5, silent: true
|
||||
})
|
||||
|
||||
// Vertical reference lines using markLine
|
||||
series.push({
|
||||
name: item.itemName + ' 参考线',
|
||||
type: 'scatter', data: [],
|
||||
markLine: {
|
||||
silent: true,
|
||||
symbol: 'none',
|
||||
label: { fontSize: 10, formatter: '{b}' },
|
||||
lineStyle: { type: 'dashed', width: 1 },
|
||||
data: [
|
||||
{ name: 'μ=' + mu.toFixed(4), xAxis: mu, lineStyle: { color, width: 1.5, type: 'solid' } },
|
||||
{ name: 'μ+σ', xAxis: mu + sigma, lineStyle: { color: '#67c23a' } },
|
||||
{ name: 'μ-σ', xAxis: mu - sigma, lineStyle: { color: '#67c23a' } },
|
||||
{ name: 'μ+2σ', xAxis: mu + 2*sigma, lineStyle: { color: '#e6a23c' } },
|
||||
{ name: 'μ-2σ', xAxis: mu - 2*sigma, lineStyle: { color: '#e6a23c' } },
|
||||
{ name: 'μ+3σ', xAxis: mu + 3*sigma, lineStyle: { color: '#f56c6c' } },
|
||||
{ name: 'μ-3σ', xAxis: mu - 3*sigma, lineStyle: { color: '#f56c6c' } }
|
||||
]
|
||||
},
|
||||
z: 3
|
||||
})
|
||||
|
||||
// Anomaly markers: grid data anomalies overlaid at y=0
|
||||
const itemAnomalies = (this.anomalyList || []).filter(a => String(a.itemId) === String(item.itemId))
|
||||
if (itemAnomalies.length) {
|
||||
series.push({
|
||||
name: item.itemName + ' 异常点',
|
||||
type: 'scatter',
|
||||
data: itemAnomalies.map(a => ({
|
||||
name: a.detailDate + ' ' + item.itemName + ': ' + a.perTon.toFixed(4),
|
||||
value: [a.perTon, 0]
|
||||
})),
|
||||
symbolSize: 12,
|
||||
itemStyle: { color: '#f56c6c', borderColor: '#fff', borderWidth: 1.5 },
|
||||
z: 20
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Histogram: actual proportion per bin
|
||||
const total = allValues.length
|
||||
const histData = bins.map((count, i) => {
|
||||
const x = axisMin + (i + 0.5) * binWidth
|
||||
return [x, (count / total) * 100]
|
||||
})
|
||||
// Step-area chart for actual distribution (steps look like histogram bars)
|
||||
// Extend data to create step effect at bin boundaries
|
||||
const stepData = []
|
||||
histData.forEach((d, i) => {
|
||||
const lo = axisMin + i * binWidth
|
||||
const hi = axisMin + (i + 1) * binWidth
|
||||
stepData.push([lo, d[1]])
|
||||
stepData.push([hi, d[1]])
|
||||
})
|
||||
// Close to y=0 at ends
|
||||
if (stepData.length) {
|
||||
stepData.unshift([stepData[0][0], 0])
|
||||
stepData.push([stepData[stepData.length - 1][0], 0])
|
||||
}
|
||||
series.push({
|
||||
name: '实际分布',
|
||||
type: 'line',
|
||||
data: stepData,
|
||||
step: 'end',
|
||||
lineStyle: { color: 'rgba(150,150,150,0.8)', width: 1 },
|
||||
areaStyle: { color: 'rgba(180,180,180,0.4)', origin: 'start' },
|
||||
symbol: 'none',
|
||||
z: 0
|
||||
})
|
||||
|
||||
// Capture for tooltip closure
|
||||
const _binWidth = binWidth
|
||||
const _axisMin = axisMin
|
||||
const _binCount = binCount
|
||||
|
||||
this.chart.setOption({
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: function(p) {
|
||||
if (!p.seriesName) return ''
|
||||
const sn = p.seriesName
|
||||
if (sn.includes('±') || sn.includes('参考线')) return ''
|
||||
const v = p.value
|
||||
const x = Array.isArray(v) ? v[0] : null
|
||||
const y = Array.isArray(v) ? v[1] : v
|
||||
if (sn.includes('异常')) {
|
||||
return p.name + '<br/><b style="color:#f56c6c">⚠ 超出3σ</b>'
|
||||
}
|
||||
if (sn.includes('实际分布')) {
|
||||
// Find which bin this boundary falls in
|
||||
const rel = (x - _axisMin) / _binWidth
|
||||
const idx = Math.min(_binCount - 1, Math.max(0, Math.floor(rel)))
|
||||
const lo = (_axisMin + idx * _binWidth).toFixed(4)
|
||||
const hi = (_axisMin + (idx + 1) * _binWidth).toFixed(4)
|
||||
return '实际分布<br/>区间: ' + lo + ' ~ ' + hi +
|
||||
'<br/>占比: ' + (typeof y === 'number' ? y.toFixed(2) + '%' : '')
|
||||
}
|
||||
if (sn.includes('正态分布')) {
|
||||
return sn + '<br/>吨钢值: ' + (typeof x === 'number' ? x.toFixed(4) : '') +
|
||||
'<br/>占比: ' + (typeof y === 'number' ? y.toFixed(2) + '%' : '')
|
||||
}
|
||||
return ''
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
type: 'scroll',
|
||||
bottom: 0,
|
||||
textStyle: { fontSize: 10 }
|
||||
},
|
||||
grid: { left: '3%', right: '4%', top: '5%', bottom: '14%', containLabel: true },
|
||||
xAxis: {
|
||||
type: 'value',
|
||||
name: '吨钢值',
|
||||
min: axisMin,
|
||||
max: axisMax,
|
||||
axisLabel: { fontSize: 10 }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: '占比 (%)',
|
||||
axisLabel: { fontSize: 10 },
|
||||
splitLine: { lineStyle: { type: 'dashed', color: '#e8e8e8' } }
|
||||
},
|
||||
series
|
||||
}, true)
|
||||
},
|
||||
|
||||
onOpened() {
|
||||
if (this.chartReady) {
|
||||
this.$nextTick(() => { if (!this.chart) this.initChart(); else { this.chart.resize(); this.updateChart() } })
|
||||
}
|
||||
},
|
||||
onClosed() {
|
||||
if (this.chart) { this.chart.dispose(); this.chart = null }
|
||||
this.chartReady = false
|
||||
this.anomalyList = []
|
||||
this.steps = []
|
||||
this._analysisCache = null
|
||||
this.chartItemId = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.anomaly-top-bar { display:flex; align-items:center; gap:10px; }
|
||||
.anomaly-progress { margin-top:10px; display:flex; flex-wrap:wrap; gap:6px 20px; background:#f5f7fa; border-radius:3px; padding:8px 12px; border:1px solid #e4e7ed; }
|
||||
.anomaly-step { display:inline-flex; align-items:center; gap:4px; font-size:13px; }
|
||||
.step-icon { font-size:14px; }
|
||||
.step-icon.pending { color:#c0c4cc; }
|
||||
.step-icon.running { color:#409eff; }
|
||||
.step-icon.success { color:#67c23a; }
|
||||
.step-icon.fail { color:#f56c6c; }
|
||||
.step-label { color:#606266; }
|
||||
.step-msg { color:#909399; font-size:12px; margin-left:2px; }
|
||||
</style>
|
||||
@@ -22,6 +22,7 @@
|
||||
<el-button type="primary" size="mini" style="float:right;margin-left:8px" @click="saveGrid" :loading="saving">保存</el-button>
|
||||
<el-button size="mini" style="float:right;margin-left:8px" @click="openColCfg">列配置</el-button>
|
||||
<el-button size="mini" style="float:right;margin-left:8px" icon="el-icon-money" @click="openPriceMgr">价格管理</el-button>
|
||||
<el-button size="mini" style="float:right;margin-left:8px" icon="el-icon-warning-outline" @click="anomalyOpen = true">异常分析</el-button>
|
||||
<!-- <el-button size="mini" style="float:right;margin-left:8px" icon="el-icon-upload2" @click="backfillCost" :loading="backfilling">反填</el-button> -->
|
||||
<span style="float:right;margin-right:12px;font-size:12px;color:#606266;display:flex;align-items:center">
|
||||
<span style="margin-right:4px">{{ inputMode ? '录入' : '查看' }}</span>
|
||||
@@ -39,7 +40,7 @@
|
||||
<div class="col-hd">{{ col.itemName }}{{ col.unit ? '('+col.unit+')' : '' }}</div>
|
||||
</template>
|
||||
<template slot-scope="s">
|
||||
<el-input v-model="s.row['q'+col.itemId]" size="mini" @input="recalcAll">
|
||||
<el-input v-model="s.row['q'+col.itemId]" size="mini" @input="recalcAll" :class="{'anomaly-input': isAnomalyCell(s.row.detailDate, col.itemId)}">
|
||||
<!-- <span slot="suffix" v-if="col.queryCondition" class="input-suffix-actions">
|
||||
<i title="反填" v-if="col.category==='辅料'||col.category==='能耗'" :class="backfillLoading[col.itemId]?'el-icon-loading':'el-icon-upload2'" class="ica ica-backfill" @click.stop="backfillCell(col, s.row)" />
|
||||
<i title="自动获取" :class="autoLoading[col.itemId]?'el-icon-loading':'el-icon-refresh'" class="ica ica-fetch" @click.stop="fetchAutoData(col, s.row)" />
|
||||
@@ -54,13 +55,13 @@
|
||||
<template slot-scope="s">
|
||||
<div class="shift-cell">
|
||||
<span class="shift-tag">甲</span>
|
||||
<el-input v-model="s.row['q'+col.itemId+'_1']" size="mini" class="shift-input" @input="recalcAll">
|
||||
<el-input v-model="s.row['q'+col.itemId+'_1']" size="mini" class="shift-input" @input="recalcAll" :class="{'anomaly-input': isAnomalyCell(s.row.detailDate, col.itemId)}">
|
||||
<!-- <span slot="suffix" v-if="col.queryCondition" class="input-suffix-actions"><i v-if="col.category==='辅料'||col.category==='能耗'" :class="backfillLoading[col.itemId]?'el-icon-loading':'el-icon-upload2'" class="ica ica-backfill" @click.stop="backfillCell(col, s.row, '1')" /><i :class="autoLoading[col.itemId]?'el-icon-loading':'el-icon-refresh'" class="ica ica-fetch" @click.stop="fetchAutoData(col, s.row, '1')" /></span> -->
|
||||
</el-input>
|
||||
</div>
|
||||
<div class="shift-cell">
|
||||
<span class="shift-tag">乙</span>
|
||||
<el-input v-model="s.row['q'+col.itemId+'_2']" size="mini" class="shift-input" @input="recalcAll">
|
||||
<el-input v-model="s.row['q'+col.itemId+'_2']" size="mini" class="shift-input" @input="recalcAll" :class="{'anomaly-input': isAnomalyCell(s.row.detailDate, col.itemId)}">
|
||||
<!-- <span slot="suffix" v-if="col.queryCondition" class="input-suffix-actions"><i v-if="col.category==='辅料'||col.category==='能耗'" :class="backfillLoading[col.itemId]?'el-icon-loading':'el-icon-upload2'" class="ica ica-backfill" @click.stop="backfillCell(col, s.row, '2')" /><i :class="autoLoading[col.itemId]?'el-icon-loading':'el-icon-refresh'" class="ica ica-fetch" @click.stop="fetchAutoData(col, s.row, '2')" /></span> -->
|
||||
</el-input>
|
||||
</div>
|
||||
@@ -317,6 +318,9 @@
|
||||
</el-table>
|
||||
<div slot="footer"><el-button @click="progressOpen=false">关 闭</el-button></div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Anomaly analysis -->
|
||||
<AnomalyAnalysis :visible.sync="anomalyOpen" :reportId="activeReport ? activeReport.reportId : null" :allItems="allItems" :gridRows="gridRows" :allCols="allCols" @anomaly-found="onAnomalyFound" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -334,6 +338,7 @@ import { listAuxiliaryConsume, addAuxiliaryConsume, updateAuxiliaryConsume } fro
|
||||
import { listProductionLine } from "@/api/wms/productionLine"
|
||||
import { listRollGrindAll } from "@/api/mes/roll/rollGrind"
|
||||
import { listEnergyRecord, addEnergyRecord, updateEnergyRecord } from "@/api/ems/energyRecord"
|
||||
import AnomalyAnalysis from "./components/AnomalyAnalysis"
|
||||
|
||||
function parseDateRange(detailDate) {
|
||||
const d = (detailDate || '').slice(0, 10)
|
||||
@@ -444,6 +449,7 @@ registerBackfillHandler('能耗', async (queryCondition, row, col, report, shift
|
||||
|
||||
export default {
|
||||
name: "CostComprehensive",
|
||||
components: { AnomalyAnalysis },
|
||||
data() {
|
||||
return {
|
||||
loading: false, list: [], tabs: [], total: 0, sel: 0, selIds: [], showSearch: true,
|
||||
@@ -465,7 +471,8 @@ export default {
|
||||
inputMode: false,
|
||||
priceOpen: false, priceList: [], priceSaving: false,
|
||||
backfilling: false,
|
||||
progressOpen: false, progressTitle: '', progressTasks: []
|
||||
progressOpen: false, progressTitle: '', progressTasks: [],
|
||||
anomalyOpen: false, anomalyMap: {}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -1060,6 +1067,27 @@ export default {
|
||||
else return row.lineType || '-'
|
||||
}
|
||||
},
|
||||
onAnomalyFound(list) {
|
||||
const map = {}
|
||||
list.forEach(a => {
|
||||
// Normalize date to yyyy-MM-dd string
|
||||
const dt = (a.detailDate || '').slice(0, 10)
|
||||
if (!dt) return
|
||||
if (!map[dt]) map[dt] = new Set()
|
||||
map[dt].add(String(a.itemId))
|
||||
})
|
||||
this.anomalyMap = map
|
||||
this.anomalyOpen = false
|
||||
this.$forceUpdate()
|
||||
this.$modal.msgSuccess(`已将 ${list.length} 条异常数据标记到表格`)
|
||||
},
|
||||
isAnomalyCell(detailDate, itemId) {
|
||||
if (!detailDate) return false
|
||||
const dt = (detailDate || '').slice ? detailDate.slice(0, 10) : String(detailDate).slice(0, 10)
|
||||
const set = this.anomalyMap[dt]
|
||||
if (!set) return false
|
||||
return set.has(String(itemId))
|
||||
},
|
||||
async loadItems() { if (!this.allItems.length) { const r = await listItem({ pageNum:1, pageSize:999 }); this.allItems = r.rows || [] } },
|
||||
async loadAllMetrics(rid) {
|
||||
const q = { pageNum:1, pageSize:99999 }; if (rid) q.reportId = rid
|
||||
@@ -1113,4 +1141,5 @@ export default {
|
||||
.ica-fetch { color: #409eff; }
|
||||
.ica-backfill { color: #67c23a; margin-right: 1px; }
|
||||
.ica:hover { opacity: 0.7; }
|
||||
/deep/ .anomaly-input .el-input__inner { background: #fef0f0 !important; border-color: #f56c6c !important; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user