Files
klp-oa/klp-ui/src/views/timing/acid/QualityReportDialog.vue

624 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<el-dialog
title="产品质量报表预览"
:visible.sync="visible"
width="880px"
append-to-body
:close-on-click-modal="false"
@open="onOpen"
@close="onClose"
>
<!-- 工具栏 -->
<div class="rpt-toolbar">
<span class="rpt-coil-hint">{{ coilLabel }}</span>
<div class="rpt-toolbar-right">
<el-button
size="small"
type="primary"
icon="el-icon-download"
:loading="exporting"
@click="exportPdf"
>导出 PDF</el-button>
<el-button size="small" @click="visible = false">关闭</el-button>
</div>
</div>
<!-- 加载状态 -->
<div v-if="dataLoading" class="rpt-loading">
<i class="el-icon-loading" /> 正在加载实绩数据
</div>
<!-- 报告主体 -->
<div v-show="!dataLoading" ref="reportContent" class="report-body">
<!-- 页眉 -->
<div class="rpt-header">
<div class="rpt-header-left">
<div class="company-line">中国五矿</div>
<div class="mcc-line">MCC 中冶赛迪信息</div>
</div>
<div class="rpt-header-title">产品质量报表</div>
<div class="rpt-header-right">
<div class="brand-name">科伦普</div>
<div class="brand-sub">KE LUN PU</div>
</div>
</div>
<!-- 钢卷规格 -->
<div class="rpt-section-bar">钢卷规格</div>
<table class="rpt-table spec-table">
<tbody>
<tr>
<td class="cell-label">钢卷号</td>
<td class="cell-val cell-coilid">{{ v('hot_coilid') || v('coilid') }}</td>
<td class="cell-label">来料厚度[mm]</td>
<td class="cell-val">{{ v('entry_thick') }}</td>
<td class="cell-label">来料宽度[mm]</td>
<td class="cell-val">{{ v('entry_width') }}</td>
<td class="cell-label">来料重量[t]</td>
<td class="cell-val">{{ v('entry_weight') }}</td>
</tr>
<tr>
<td class="cell-label">钢种</td>
<td class="cell-val">{{ v('steel_grade') || v('steelgrade') || v('process_code') }}</td>
<td class="cell-label">成品厚度[mm]</td>
<td class="cell-val">{{ v('exit_thick') }}</td>
<td class="cell-label">成品宽度[mm]</td>
<td class="cell-val">{{ v('exit_width') }}</td>
<td class="cell-label">成品重量[t]</td>
<td class="cell-val">{{ v('exit_weight') || v('entry_weight') }}</td>
</tr>
<tr>
<td class="cell-label">班组</td>
<td class="cell-val">{{ v('shift') || v('class_no') || '—' }}</td>
<td class="cell-label">偏差上限[mm]</td>
<td class="cell-val">{{ v('thick_upper') || v('up_tol') || '—' }}</td>
<td class="cell-label">压下率[%]</td>
<td class="cell-val">{{ reductionRate }}</td>
<td class="cell-label">成品长度[m]</td>
<td class="cell-val">{{ v('exit_length') }}</td>
</tr>
<tr>
<td class="cell-label">下工序</td>
<td class="cell-val">{{ v('next_process') || v('next_proc') || '—' }}</td>
<td class="cell-label">偏差下限[mm]</td>
<td class="cell-val">{{ v('thick_lower') || v('dn_tol') || '—' }}</td>
<td class="cell-label">原料卷号</td>
<td class="cell-val" colspan="3">{{ v('coilid') }}</td>
</tr>
<tr>
<td class="cell-label">生产时间</td>
<td class="cell-val" colspan="3">{{ v('prod_time') || v('start_time') || v('createtime') || '—' }}</td>
<td class="cell-label">生产时长</td>
<td class="cell-val" colspan="3">{{ productionDuration }}</td>
</tr>
</tbody>
</table>
<!-- 质量判定 -->
<div class="rpt-section-bar">质量判定</div>
<table class="rpt-table quality-table">
<thead>
<tr>
<th>轧制总长[m]</th>
<th>头尾超差[m]</th>
<th>厚度合格率[%]</th>
<th>目标板形</th>
<th>板形合格率[%]</th>
<th>板形质量</th>
</tr>
</thead>
<tbody>
<tr>
<td>{{ rolledLength }}</td>
<td>{{ v('head_tail_dev') || '—' }}</td>
<td>{{ thickPassRate }}</td>
<td>{{ v('target_shape') || '平直' }}</td>
<td>{{ v('shape_pass_rate') || '—' }}</td>
<td :class="qualityClass">{{ qualityLabel }}</td>
</tr>
</tbody>
</table>
<!-- 图表区 -->
<template v-if="hasSeries">
<!-- 厚度偏差 -->
<template v-if="hasField('thick_dev') || hasField('act_thick')">
<div class="rpt-chart-title">
成品厚度偏差
<span class="rpt-chart-subtitle">产出厚度 - {{ v('exit_thick') }} [mm]</span>
</div>
<div ref="chartThick" class="rpt-chart" />
</template>
<!-- 速度趋势 -->
<template v-if="hasField('plspeed') || hasField('trimspeed') || hasField('millexitspeed')">
<div class="rpt-chart-title">速度趋势 [m/min]</div>
<div ref="chartSpeed" class="rpt-chart" />
</template>
<!-- 轧机出口速度 -->
<template v-if="hasField('millexitspeed') && (hasField('plspeed') || hasField('trimspeed'))">
<div class="rpt-chart-title">轧机出口速度 [m/min]</div>
<div ref="chartMillSpeed" class="rpt-chart" />
</template>
<!-- 张力趋势 -->
<template v-if="hasField('pltens') || hasField('enltens') || hasField('cxltens')">
<div class="rpt-chart-title">张力趋势 [N]</div>
<div ref="chartTension" class="rpt-chart" />
</template>
<!-- 额外字段图表 -->
<template v-for="grp in extraChartGroups">
<div :key="grp.title + '_t'" class="rpt-chart-title">{{ grp.title }}</div>
<div :key="grp.title + '_c'" :ref="'chartExtra_' + grp.refKey" class="rpt-chart" />
</template>
</template>
<div v-else class="rpt-no-data">暂无实绩曲线数据</div>
<!-- 页脚 -->
<div class="rpt-footer">生成时间&nbsp;{{ nowStr }}</div>
</div>
</el-dialog>
</template>
<script>
import * as echarts from 'echarts'
import { getPlanWithSeg } from '@/api/l2/timing'
// Series fields consumed by named charts — remaining fields get auto-grouped
const NAMED_KEYS = new Set(['startpos', 'thick_dev', 'act_thick', 'plspeed', 'trimspeed', 'millexitspeed', 'pltens', 'enltens', 'cxltens'])
// Groups of extra series fields to chart together
const EXTRA_GROUPS = [
{ keys: ['bendf1', 'bendf2', 'bendf3', 'bendf4', 'bendf5'], title: '弯辊力 [kN]', unit: 'kN' },
{ keys: ['rollf1', 'rollf2', 'rollf3', 'rollf4', 'rollf5'], title: '轧制力 [kN]', unit: 'kN' },
{ keys: ['fwd_slip1', 'fwd_slip2', 'fwd_slip3', 'fwd_slip4', 'fwd_slip5'], title: '前滑值', unit: '' },
{ keys: ['temp_in', 'temp_out', 'cool_temp'], title: '温度 [°C]', unit: '°C' },
]
function lineOption(title, xData, series, yName) {
return {
title: { text: title, textStyle: { fontSize: 12, fontWeight: 'normal', color: '#303133' }, top: 4, left: 8 },
tooltip: { trigger: 'axis', confine: true },
legend: { top: 4, right: 8, textStyle: { fontSize: 10 } },
grid: { top: 40, bottom: 32, left: 10, right: 12, containLabel: true },
xAxis: {
type: 'category', data: xData, name: '带钢长度[m]',
nameTextStyle: { fontSize: 10 }, axisLabel: { fontSize: 9, interval: 'auto' }
},
yAxis: { type: 'value', name: yName, nameTextStyle: { fontSize: 10 }, axisLabel: { fontSize: 9 } },
series: series.map((s, i) => ({
name: s.name, type: 'line', smooth: false, symbol: 'none',
lineStyle: { width: 1.5 },
data: s.data
}))
}
}
function dualAxisOption(title, xData, devSeries, thickSeries, exitThick) {
return {
title: { text: title, textStyle: { fontSize: 12, fontWeight: 'normal', color: '#303133' }, top: 4, left: 8 },
tooltip: { trigger: 'axis', confine: true },
legend: { top: 4, right: 8, textStyle: { fontSize: 10 } },
grid: { top: 40, bottom: 32, left: 10, right: 12, containLabel: true },
xAxis: {
type: 'category', data: xData, name: '带钢长度[m]',
nameTextStyle: { fontSize: 10 }, axisLabel: { fontSize: 9, interval: 'auto' }
},
yAxis: [
{ type: 'value', name: 'μm', nameTextStyle: { fontSize: 10 }, axisLabel: { fontSize: 9 } },
{ type: 'value', name: 'mm', nameTextStyle: { fontSize: 10 }, axisLabel: { fontSize: 9 }, splitLine: { show: false } }
],
series: [
{ name: '厚度偏差', type: 'line', yAxisIndex: 0, smooth: false, symbol: 'none', lineStyle: { width: 1, color: '#5F7BA0' }, data: devSeries },
thickSeries && { name: '实际厚度', type: 'line', yAxisIndex: 1, smooth: false, symbol: 'none', lineStyle: { width: 1.5, color: '#e6a23c' }, data: thickSeries }
].filter(Boolean)
}
}
export default {
name: 'QualityReportDialog',
data() {
return {
visible: false,
dataLoading: false,
exporting: false,
plan: null,
series: null,
nowStr: '',
chartInsts: []
}
},
computed: {
coilLabel() {
const id = this.plan?.hot_coilid || this.plan?.coilid || ''
return id ? `钢卷号:${id}` : ''
},
hasSeries() {
return !!this.series && Object.keys(this.series).length > 0
},
reductionRate() {
const e = parseFloat(this.plan?.entry_thick)
const x = parseFloat(this.plan?.exit_thick)
if (!e || !x || e <= 0) return '—'
return ((e - x) / e * 100).toFixed(2)
},
rolledLength() {
if (this.plan?.exit_length) return this.plan.exit_length
if (this.series?.startpos?.length) {
const max = Math.max(...this.series.startpos.filter(v => v != null))
return max > 0 ? max.toFixed(1) : '—'
}
return '—'
},
thickPassRate() {
const r = this.plan?.thick_pass_rate || this.plan?.thick_passrate
return r != null ? r : '—'
},
qualityLabel() {
const r = this.plan?.quality || this.plan?.quality_result
if (r) return r
const tp = parseFloat(this.thickPassRate)
if (!isNaN(tp)) return tp >= 99 ? '合格' : '不合格'
return '—'
},
qualityClass() {
return this.qualityLabel === '合格' ? 'cell-ok' : this.qualityLabel === '不合格' ? 'cell-ng' : ''
},
productionDuration() {
const d = this.plan?.prod_duration || this.plan?.duration
if (!d) return '—'
const sec = parseInt(d)
if (isNaN(sec)) return d
const h = Math.floor(sec / 3600)
const m = Math.floor((sec % 3600) / 60)
const s = sec % 60
return `${String(h).padStart(2, '0')}${String(m).padStart(2, '0')}${String(s).padStart(2, '0')}`
},
extraChartGroups() {
if (!this.series) return []
const result = []
// first try the predefined groups
for (const grp of EXTRA_GROUPS) {
const presentKeys = grp.keys.filter(k => this.hasField(k))
if (presentKeys.length) {
result.push({ title: grp.title, unit: grp.unit, keys: presentKeys, refKey: grp.title.replace(/\s/g, '_') })
}
}
// then collect truly unknown keys
const knownInExtra = new Set(EXTRA_GROUPS.flatMap(g => g.keys))
const unknown = Object.keys(this.series).filter(k => !NAMED_KEYS.has(k) && !knownInExtra.has(k) && Array.isArray(this.series[k]) && this.series[k].length)
if (unknown.length) {
result.push({ title: '其他工艺参数', unit: '', keys: unknown, refKey: 'other' })
}
return result
}
},
methods: {
v(key) {
const val = this.plan?.[key]
return val != null && val !== '' ? val : '—'
},
hasField(key) {
return !!(this.series?.[key]?.length)
},
// Public: open with a row object
open(row) {
this.plan = row
this.series = null
this.visible = true
},
onOpen() {
this.nowStr = new Date().toLocaleString('zh-CN').replace(/\//g, '-')
this.$nextTick(() => {
if (this.series) {
this.renderAllCharts()
} else if (this.plan) {
this.fetchSeries()
}
})
},
onClose() {
this.disposeCharts()
},
async fetchSeries() {
const id = this.plan?.encoilid || this.plan?.coilid
if (!id) return
this.dataLoading = true
try {
const res = await getPlanWithSeg(id)
const plan = res?.data?.plan || res?.data?.firstRow || this.plan
const series = res?.data?.series || null
if (plan) this.plan = plan
this.series = series
this.$nextTick(() => this.renderAllCharts())
} finally {
this.dataLoading = false
}
},
disposeCharts() {
this.chartInsts.forEach(c => { try { c.dispose() } catch (_) {} })
this.chartInsts = []
},
pick(key) {
return (this.series?.[key] || []).map(v => v == null ? null : +parseFloat(v).toFixed(3))
},
xData() {
return (this.series?.startpos || []).map(v => v == null ? '' : (+parseFloat(v).toFixed(1)))
},
renderAllCharts() {
this.disposeCharts()
if (!this.series) return
const x = this.xData()
// 厚度偏差
if (this.$refs.chartThick) {
const devKey = this.hasField('thick_dev') ? 'thick_dev' : null
const actKey = this.hasField('act_thick') ? 'act_thick' : null
if (devKey || actKey) {
const devData = devKey ? this.pick(devKey).map(v => v == null ? null : v * 1000) : []
const actData = actKey ? this.pick(actKey) : null
const c = echarts.init(this.$refs.chartThick, null, { renderer: 'canvas' })
if (devKey) {
c.setOption(dualAxisOption('成品厚度偏差', x, devData, actData, this.plan?.exit_thick))
} else {
c.setOption(lineOption('实际厚度 [mm]', x, [{ name: '实际厚度', data: actData }], 'mm'))
}
this.chartInsts.push(c)
}
}
// 速度趋势
if (this.$refs.chartSpeed) {
const series = []
if (this.hasField('plspeed')) series.push({ name: '轧制速度 plspeed', data: this.pick('plspeed') })
if (this.hasField('trimspeed')) series.push({ name: '剪切速度 trimspeed', data: this.pick('trimspeed') })
if (!this.hasField('plspeed') && !this.hasField('trimspeed') && this.hasField('millexitspeed')) {
series.push({ name: '轧机出口速度', data: this.pick('millexitspeed') })
}
if (series.length) {
const c = echarts.init(this.$refs.chartSpeed, null, { renderer: 'canvas' })
c.setOption(lineOption('速度趋势', x, series, 'm/min'))
this.chartInsts.push(c)
}
}
// 轧机出口速度(单独显示)
if (this.$refs.chartMillSpeed && this.hasField('millexitspeed')) {
const c = echarts.init(this.$refs.chartMillSpeed, null, { renderer: 'canvas' })
c.setOption(lineOption('轧机出口速度', x, [{ name: 'millexitspeed', data: this.pick('millexitspeed') }], 'm/min'))
this.chartInsts.push(c)
}
// 张力趋势
if (this.$refs.chartTension) {
const series = []
if (this.hasField('pltens')) series.push({ name: '出口张力 pltens', data: this.pick('pltens') })
if (this.hasField('enltens')) series.push({ name: '入口张力 enltens', data: this.pick('enltens') })
if (this.hasField('cxltens')) series.push({ name: 'cxltens', data: this.pick('cxltens') })
if (series.length) {
const c = echarts.init(this.$refs.chartTension, null, { renderer: 'canvas' })
c.setOption(lineOption('张力趋势', x, series, 'N'))
this.chartInsts.push(c)
}
}
// 额外图表组
for (const grp of this.extraChartGroups) {
const refKey = 'chartExtra_' + grp.refKey
const refEl = this.$refs[refKey]
const el = Array.isArray(refEl) ? refEl[0] : refEl
if (!el) continue
const series = grp.keys.filter(k => this.hasField(k)).map(k => ({ name: k, data: this.pick(k) }))
if (series.length) {
const c = echarts.init(el, null, { renderer: 'canvas' })
c.setOption(lineOption(grp.title, x, series, grp.unit))
this.chartInsts.push(c)
}
}
},
async exportPdf() {
this.exporting = true
try {
const [html2canvas, { jsPDF }] = await Promise.all([
import('html2canvas').then(m => m.default),
import('jspdf')
])
const el = this.$refs.reportContent
const canvas = await html2canvas(el, {
scale: 2,
useCORS: true,
allowTaint: true,
backgroundColor: '#ffffff',
logging: false
})
const imgData = canvas.toDataURL('image/jpeg', 0.95)
const pdf = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' })
const pageW = pdf.internal.pageSize.getWidth() // 210
const pageH = pdf.internal.pageSize.getHeight() // 297
const margin = 8
const printW = pageW - margin * 2
const ratio = canvas.width / printW
const imgH = canvas.height / ratio
let remaining = imgH
let srcY = 0
while (remaining > 0) {
const sliceH = Math.min(pageH - margin * 2, remaining)
const slicePx = sliceH * ratio
const sliceCanvas = document.createElement('canvas')
sliceCanvas.width = canvas.width
sliceCanvas.height = slicePx
const ctx = sliceCanvas.getContext('2d')
ctx.drawImage(canvas, 0, srcY, canvas.width, slicePx, 0, 0, canvas.width, slicePx)
pdf.addImage(sliceCanvas.toDataURL('image/jpeg', 0.95), 'JPEG', margin, margin, printW, sliceH)
srcY += slicePx
remaining -= sliceH
if (remaining > 0) pdf.addPage()
}
const coilId = this.plan?.hot_coilid || this.plan?.coilid || 'report'
pdf.save(`质量报表_${coilId}.pdf`)
} catch (e) {
this.$message.error('PDF导出失败' + e.message)
console.error(e)
} finally {
this.exporting = false
}
}
}
}
</script>
<style scoped>
/* ── 工具栏 ── */
.rpt-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 0 10px;
border-bottom: 1px solid #f0f2f5;
margin-bottom: 10px;
}
.rpt-coil-hint { font-size: 13px; color: #606266; font-weight: 500; }
.rpt-toolbar-right { display: flex; gap: 8px; }
/* ── 加载 ── */
.rpt-loading {
padding: 40px 0;
text-align: center;
font-size: 14px;
color: #909399;
}
/* ── 报告主体 ── */
.report-body {
background: #fff;
padding: 20px 24px;
font-size: 12px;
color: #303133;
min-height: 300px;
}
/* 页眉 */
.rpt-header {
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 2px solid #2c5282;
padding-bottom: 10px;
margin-bottom: 14px;
}
.rpt-header-left { font-size: 11px; color: #555; line-height: 1.6; }
.company-line { font-weight: 600; font-size: 13px; }
.mcc-line { font-size: 10px; color: #888; }
.rpt-header-title { font-size: 22px; font-weight: 700; color: #1a365d; letter-spacing: 2px; }
.rpt-header-right { text-align: right; }
.brand-name { font-size: 16px; font-weight: 700; color: #2c5282; letter-spacing: 1px; }
.brand-sub { font-size: 9px; color: #888; letter-spacing: 2px; }
/* 节标题 */
.rpt-section-bar {
background: #2c5282;
color: #fff;
font-size: 12px;
font-weight: 600;
padding: 4px 10px;
border-radius: 2px;
margin: 10px 0 6px;
letter-spacing: 1px;
}
/* 规格表 & 质量判定表 */
.rpt-table {
width: 100%;
border-collapse: collapse;
font-size: 11.5px;
margin-bottom: 4px;
}
.rpt-table td, .rpt-table th {
border: 1px solid #c9d4e0;
padding: 4px 8px;
text-align: left;
white-space: nowrap;
}
.rpt-table th {
background: #dce8f4;
color: #1a365d;
font-weight: 600;
text-align: center;
font-size: 11px;
}
.cell-label {
background: #eef3f9;
color: #4a6080;
font-weight: 500;
width: 110px;
}
.cell-val { color: #1a1a2e; font-weight: 600; }
.cell-coilid { font-size: 13px; color: #1a365d; letter-spacing: 0.5px; }
.cell-ok { color: #2e7d32; font-weight: 700; text-align: center; }
.cell-ng { color: #c62828; font-weight: 700; text-align: center; }
.quality-table td { text-align: center; }
/* 图表 */
.rpt-chart-title {
font-size: 12px;
font-weight: 600;
color: #2c5282;
margin: 14px 0 4px;
padding-left: 6px;
border-left: 3px solid #2c5282;
}
.rpt-chart-subtitle {
font-size: 11px;
font-weight: normal;
color: #888;
margin-left: 8px;
}
.rpt-chart {
width: 100%;
height: 180px;
border: 1px solid #e8edf2;
border-radius: 3px;
background: #fafbfc;
}
/* 无数据 */
.rpt-no-data {
padding: 28px 0;
text-align: center;
font-size: 13px;
color: #c0c4cc;
}
/* 页脚 */
.rpt-footer {
margin-top: 20px;
padding-top: 8px;
border-top: 1px solid #e2e8f0;
text-align: center;
font-size: 10px;
color: #888;
}
/* 覆盖 dialog padding */
::v-deep .el-dialog__body { padding: 12px 20px 20px; }
::v-deep .el-button--primary {
background: #2c5282 !important; border-color: #2c5282 !important;
}
::v-deep .el-button--primary:hover { background: #1a365d !important; border-color: #1a365d !important; }
</style>