新增app和跟踪页面,现已经调通

This commit is contained in:
2026-05-13 16:43:38 +08:00
parent 5fdaa89afd
commit ba7593e825
10 changed files with 1838 additions and 2 deletions

View File

@@ -0,0 +1,623 @@
<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>

View File

@@ -1,4 +1,5 @@
<template>
<div>
<div class="acid-view">
<div class="filter-bar">
<el-input
@@ -38,6 +39,16 @@
</el-table-column>
<el-table-column prop="exit_thick" label="出口厚" />
<el-table-column prop="entry_weight" label="重量(t)" />
<el-table-column label="" width="70" align="center">
<template slot-scope="{ row }">
<el-button
type="text"
size="mini"
style="color:#2c5282;font-weight:600"
@click.stop="openQualityReport(row)"
>质保书</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-bar">
@@ -60,6 +71,16 @@
</div>
<template v-else>
<!-- 质保书按钮右侧详情区顶部 -->
<div style="display:flex;justify-content:flex-end;margin-bottom:8px">
<el-button
size="mini"
icon="el-icon-document"
style="color:#2c5282;border-color:#2c5282"
@click="openQualityReport(selectedPlan)"
>导出质保书</el-button>
</div>
<div class="detail-grid">
<div v-for="f in planFields" :key="f.key" class="detail-cell">
<span class="cell-label">{{ f.label }}</span>
@@ -84,6 +105,10 @@
</el-col>
</el-row>
</div>
<!-- 质保书 -->
<quality-report-dialog ref="qualityReport" />
</div>
</template>
<script>
@@ -93,6 +118,7 @@ import {
getTimingPlanCount,
getPlanWithSeg
} from '@/api/l2/timing'
import QualityReportDialog from './QualityReportDialog.vue'
const PLAN_FIELDS = [
{ key: 'status', label: '状态' },
@@ -136,6 +162,7 @@ function baseOption(title, xData, series, yName) {
export default {
name: 'TimingAcidPage',
components: { QualityReportDialog },
data() {
return {
loading: false,
@@ -271,6 +298,21 @@ export default {
this.resizeHandler = () => this.chartInstances.forEach(c => { if (c) c.resize() })
window.addEventListener('resize', this.resizeHandler)
},
openQualityReport(row) {
const dialog = this.$refs.qualityReport
// Pass currently loaded series if the row matches the selectedPlan
const sameCoil = this.selectedPlan &&
(row.encoilid || row.coilid) === (this.selectedPlan.encoilid || this.selectedPlan.coilid)
if (sameCoil && this.perfSeries) {
dialog.plan = { ...this.selectedPlan }
dialog.series = this.perfSeries
} else {
dialog.plan = row
dialog.series = null
}
dialog.visible = true
},
handleReset() {
this.queryForm.coilId = ''
this.selectedPlan = null