Files
klp-oa/klp-ui/src/views/micro/pages/acid/components/ActualPerformance.vue
2026-06-11 15:10:56 +08:00

1146 lines
40 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>
<div class="actual-page">
<div class="actual-container">
<!-- 顶部实绩列表 (PLTCM_PDO_EXCOIL) -->
<div class="top-section">
<el-table
ref="excoilTable"
:data="excoilRows"
size="mini"
highlight-current-row
border
:height="topTableHeight"
style="width:100%"
@row-click="handleRowClick"
>
<el-table-column label="子卷号" min-width="110" show-overflow-tooltip>
<template slot-scope="{ row }">{{ row.EXCOILID || row.excoilid }}</template>
</el-table-column>
<el-table-column label="热卷号" min-width="100" show-overflow-tooltip>
<template slot-scope="{ row }">{{ row.HOT_COILID || row.hot_coilid || '—' }}</template>
</el-table-column>
<el-table-column label="班" width="40" align="center">
<template slot-scope="{ row }">{{ row.SHIFT || row.shift || '—' }}</template>
</el-table-column>
<el-table-column label="组" width="40" align="center">
<template slot-scope="{ row }">{{ row.CREW || row.crew || '—' }}</template>
</el-table-column>
<el-table-column label="钢种" min-width="80" show-overflow-tooltip>
<template slot-scope="{ row }">{{ row.ORDER_QUALITY || row.order_quality || row.GRADE || row.grade || '—' }}</template>
</el-table-column>
<el-table-column label="来料厚度" width="68" align="right">
<template slot-scope="{ row }">{{ row.ENTRY_THICK || row.entry_thick || '—' }}</template>
</el-table-column>
<el-table-column label="出口厚度" width="68" align="right">
<template slot-scope="{ row }">{{ row.EXIT_THICK || row.exit_thick || '—' }}</template>
</el-table-column>
<el-table-column label="偏差上限" width="60" align="right">
<template slot-scope="{ row }">{{ row.EXIT_POS_DEV || row.exit_pos_dev || '0' }}</template>
</el-table-column>
<el-table-column label="偏差下限" width="60" align="right">
<template slot-scope="{ row }">{{ row.EXIT_NEG_DEV || row.exit_neg_dev || '0' }}</template>
</el-table-column>
<el-table-column label="来料宽度" width="60" align="right">
<template slot-scope="{ row }">{{ row.ENTRY_WIDTH || row.entry_width || '—' }}</template>
</el-table-column>
<el-table-column label="出口宽度" width="60" align="right">
<template slot-scope="{ row }">{{ row.EXIT_WIDTH || row.exit_width || '—' }}</template>
</el-table-column>
<el-table-column label="来料重量" width="60" align="right">
<template slot-scope="{ row }">{{ row.USED_ENTRY_WEIGHT || row.used_entry_weight || row.ENTRY_WEIGHT || row.entry_weight || '—' }}</template>
</el-table-column>
<el-table-column label="称重重量" width="60" align="right">
<template slot-scope="{ row }">{{ row.MEAS_EXIT_WEIGHT || row.meas_exit_weight || '—' }}</template>
</el-table-column>
<el-table-column label="包装要求" width="60">
<template slot-scope="{ row }">{{ row.PARK_TYPE || row.park_type || '—' }}</template>
</el-table-column>
<el-table-column label="切边要求" width="60">
<template slot-scope="{ row }">{{ row.SIDE_TRIM || row.side_trim || '—' }}</template>
</el-table-column>
<el-table-column label="成品质量" width="58" align="right">
<template slot-scope="{ row }">{{ row.QUALITY || row.quality || '—' }}</template>
</el-table-column>
<el-table-column label="成品长度" width="60" align="right">
<template slot-scope="{ row }">{{ row.EXIT_LENGTH || row.exit_length || '—' }}</template>
</el-table-column>
<el-table-column label="吨钢长度" width="60" align="right">
<template slot-scope="{ row }">{{ calcLengthPerTon(row) }}</template>
</el-table-column>
<el-table-column label="下线时间" width="140">
<template slot-scope="{ row }">{{ formatDate(row.END_DATE || row.end_date) }}</template>
</el-table-column>
<el-table-column label="状态" width="68" align="center">
<template slot-scope="{ row }">
<el-tag type="primary" size="mini" effect="plain">{{ row.STATUS || row.status || '产出' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="" width="64" align="center" fixed="right">
<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="table-pagination">
<el-pagination
small layout="total, prev, pager, next"
:total="pagination.total"
:page-size="pagination.pageSize"
:current-page="pagination.page"
@current-change="handlePageChange"
/>
</div>
</div>
<!-- 底部图表区域 -->
<div class="bottom-section">
<div class="chart-section">
<el-tabs v-model="activeTab" size="small" class="chart-tabs" @tab-click="handleTabSwitch">
<!-- 趋势参数左树形目录 + 右单图 -->
<el-tab-pane label="趋势参数" name="trend">
<div v-if="!selectedRow" class="no-data-hint">请在上方选择钢卷</div>
<div v-else-if="segLoading" class="no-data-hint">加载中</div>
<div v-else-if="!segData" class="no-data-hint">暂无 SEG 数据</div>
<div v-else class="trend-layout">
<!-- 左侧目录树 -->
<div class="trend-tree">
<div v-for="group in trendGroups" :key="group.label" class="tree-group">
<div class="tree-group-label" @click="toggleGroup(group.label)">
<i :class="expandedGroups[group.label] ? 'el-icon-caret-bottom' : 'el-icon-caret-right'" />
{{ group.label }}
</div>
<div v-show="expandedGroups[group.label]" class="tree-children">
<div
v-for="item in group.children"
:key="item.col"
class="tree-item"
:class="{ active: selectedTrendParam && selectedTrendParam.col === item.col }"
@click="selectTrendParam(item)"
>{{ item.label }}</div>
</div>
</div>
</div>
<!-- 右侧图表 -->
<div class="trend-chart-area">
<div v-if="!selectedTrendParam" class="no-data-hint"> 点击左侧参数查看曲线</div>
<!-- 保持 DOM 存在仅用 v-show 控制显示避免 ref 失效 -->
<div ref="trendSingleChart" :style="{ display: selectedTrendParam ? 'block' : 'none', height: '100%', width: '100%' }" />
</div>
</div>
</el-tab-pane>
<!-- 厚度曲线 -->
<el-tab-pane label="厚度曲线" name="thickness">
<div v-if="!selectedRow" class="no-data-hint">请在上方选择钢卷</div>
<div v-else-if="realtimeLoading" class="no-data-hint">加载中</div>
<div v-else-if="!gaugeRows || !gaugeRows.length" class="no-data-hint">暂无厚度数据</div>
<div v-else class="charts-scroll charts-grid">
<div ref="chartGauge1" class="chart-box" />
<div ref="chartGauge2" class="chart-box" />
<div ref="chartGauge3" class="chart-box" />
<div ref="chartGauge4" class="chart-box" />
</div>
</el-tab-pane>
<!-- 带钢板形 -->
<el-tab-pane label="带钢板形" name="flatness3d">
<div v-if="!selectedRow" class="no-data-hint">请在上方选择钢卷</div>
<div v-else-if="realtimeLoading" class="no-data-hint">加载中</div>
<div v-else-if="!shapeRows || !shapeRows.length" class="no-data-hint">暂无板形数据</div>
<div v-else class="charts-scroll">
<div ref="chartFlatness3d" class="chart-box chart-box-tall" />
</div>
</el-tab-pane>
<!-- 板形曲线一行两图 -->
<el-tab-pane label="板形曲线" name="flatness">
<div v-if="!selectedRow" class="no-data-hint">请在上方选择钢卷</div>
<div v-else-if="realtimeLoading" class="no-data-hint">加载中</div>
<div v-else-if="!shapeRows || !shapeRows.length" class="no-data-hint">暂无板形数据</div>
<div v-else class="charts-scroll charts-grid">
<div ref="chartFlatDev" class="chart-box" />
<div ref="chartTilt" class="chart-box" />
<div ref="chartWrBend" class="chart-box" />
<div ref="chartIrBend" class="chart-box" />
</div>
</el-tab-pane>
</el-tabs>
</div>
<!-- 右侧查找面板 + 质保书入口 -->
<div class="search-panel">
<div class="panel-title">查找</div>
<div class="search-type-group">
<el-radio v-model="searchType" label="coil">按钢卷号</el-radio>
<div v-if="searchType === 'coil'" class="search-field">
<span class="search-label">钢卷号</span>
<el-input v-model="searchCoilId" size="mini" style="width:140px" placeholder="EXCOILID" />
</div>
</div>
<div class="search-type-group">
<el-radio v-model="searchType" label="time">按时间</el-radio>
<template v-if="searchType === 'time'">
<div class="search-field">
<span class="search-label">开始时间</span>
<el-date-picker v-model="searchStartDate" type="datetime" size="mini" style="width:160px"
value-format="yyyy-MM-dd HH:mm:ss" placeholder="开始时间" />
</div>
<div class="search-field">
<span class="search-label">结束时间</span>
<el-date-picker v-model="searchEndDate" type="datetime" size="mini" style="width:160px"
value-format="yyyy-MM-dd HH:mm:ss" placeholder="结束时间" />
</div>
</template>
</div>
<div class="search-actions">
<el-button type="primary" size="mini" :loading="excoilLoading" @click="handleFindSearch">查找</el-button>
<el-button size="mini" @click="handleFindReset">重置</el-button>
</div>
</div>
</div>
</div>
<quality-report-dialog ref="qualityReport" />
</div>
</template>
<script>
import * as echarts from 'echarts'
import 'echarts-gl'
import QualityReportDialog from './QualityReportDialog.vue'
import {
getExcoilList,
getExcoilCount,
getTimingSegByEncoilId,
getTimingRealtimeData,
getPresetSetupByCoilId
} from '@/api/l2/timing'
// 趋势参数树结构,对应 PLTCM_PRO_SEG 列名
const TREND_GROUPS = [
{
label: '张力',
children: [
{ label: '开卷张力', col: 'PORTENS' },
{ label: '入口活套张力', col: 'ENLTENS' },
{ label: '拉矫张力', col: 'TLTENS' },
{ label: '酸洗张力', col: 'PLTENS' },
{ label: '出口活套张力', col: 'CXLTENS' },
{ label: '圆盘剪张力', col: 'TRIMTENS' }
]
},
{
label: '速度',
children: [
{ label: '开卷速度', col: 'PORSPEED' },
{ label: '酸洗速度', col: 'PLSPEED' },
{ label: '圆盘剪速度', col: 'TRIMSPEED' },
{ label: '轧机入口速度', col: 'MILLENTRYSPEED' },
{ label: '轧机出口速度', col: 'MILLEXITSPEED' }
]
},
{
label: '拉矫机',
children: [
{ label: '1#插入量', col: 'TLMESH1' },
{ label: '2#插入量', col: 'TLMESH2' },
{ label: '3#插入量', col: 'TLMESH3' },
{ label: '延伸率', col: 'TLELONG' }
]
},
{
label: '酸洗段',
children: [
{ label: '1#温度', col: 'TK1TEMP' },
{ label: '2#温度', col: 'TK2TEMP' },
{ label: '3#温度', col: 'TK3TEMP' },
{ label: '漂洗温度', col: 'RINSETEMP' }
]
}
]
// V_VBDA_GAUGE 厚度曲线4 个图,列名来自 DDL
// 厚度列是相对设定值的偏差百分比,图中以 100% 为目标值;速度图保持原单位
const GAUGE_COLS = [
{ col: 'THICK0', title: '入口测厚仪 [%]' },
{ col: 'THICK1', title: '1架出口厚度 [%]' },
{ col: 'THICK5', title: '末架出口厚度 [%]' },
{ col: 'EXIT_SPEED', title: '轧制速度 [m/min]' }
]
// V_VBDA_SHAPE 板形曲线4 个图,列名来自 DDL
const SHAPE_SCALAR_COLS = [
{ col: 'ABSDEVIATION', title: '总板形偏差 [IU]' },
{ col: 'TILT', title: '末架倾斜量 [mm]' },
{ col: 'WRBEND', title: '工作辊弯辊力 [kN]' },
{ col: 'IRBEND', title: '中间辊弯辊力 [kN]' }
]
// 参数单位映射
const TREND_UNIT_MAP = {
PORTENS: 'N/mm²', ENLTENS: 'N/mm²', TLTENS: 'N/mm²', PLTENS: 'N/mm²',
CXLTENS: 'N/mm²', TRIMTENS: 'N/mm²', TRTENS: 'N/mm²', TELTENS: 'N/mm²',
PORSPEED: 'm/min', PLSPEED: 'm/min', TRIMSPEED: 'm/min',
MILLENTRYSPEED: 'm/min', MILLEXITSPEED: 'm/min',
TLMESH1: 'mm', TLMESH2: 'mm', TLMESH3: 'mm',
TLELONG: '%',
TK1TEMP: '℃', TK2TEMP: '℃', TK3TEMP: '℃', RINSETEMP: '℃'
}
// PLTCM_PRO_SEG 列 → PLTCM_PRESET_SETUP 设定值列
const TREND_PRESET_MAP = {
PORTENS: 'POR_TEN', ENLTENS: 'CEL_TEN', TLTENS: 'TLV_TEN',
PLTENS: 'CPL_TEN', CXLTENS: 'CXL_TEN', TRIMTENS: 'TRIM_TEN',
TRTENS: 'TR_TEN', TELTENS: 'TEL_TEN',
TLMESH1: 'TLV_MESH_1', TLMESH2: 'TLV_MESH_2', TLMESH3: 'TLV_MESH_3',
TLELONG: 'TLV_ELONG', PLSPEED: 'CPL_MAX_SPEED'
}
function calcYRange(vals) {
const nums = vals.filter(v => v != null && isFinite(Number(v))).map(Number)
if (!nums.length) return {}
const min = Math.min(...nums)
const max = Math.max(...nums)
if (min === max) {
const base = Math.abs(min) || 1
return { min: parseFloat((min - base * 0.2).toFixed(4)), max: parseFloat((max + base * 0.2).toFixed(4)) }
}
const pad = (max - min) * 0.15
return { min: parseFloat((min - pad).toFixed(4)), max: parseFloat((max + pad).toFixed(4)) }
}
/**
* 厚度百分比图专用纵轴范围:实测列在带头/带尾存在测厚仪不在带上的离群值
* (可达 ±1000% 以上),直接用全量 min/max 会把纵轴撑爆,
* 导致实际值、目标值、上下限挤成几条横线无法分辨。
* 这里对实测值取 2%~98% 分位数,再并入上下限/目标线所在区间。
*/
function calcPercentRange(yVals, bandVals) {
const nums = yVals.filter(v => v != null && isFinite(Number(v))).map(Number).sort((a, b) => a - b)
const band = bandVals.filter(v => v != null && isFinite(Number(v))).map(Number)
if (!nums.length && !band.length) return {}
const lo = nums.length ? nums[Math.floor(nums.length * 0.02)] : Infinity
const hi = nums.length ? nums[Math.min(nums.length - 1, Math.ceil(nums.length * 0.98))] : -Infinity
const min = Math.min(lo, ...band)
const max = Math.max(hi, ...band)
const pad = (max - min) * 0.15 || Math.abs(max) * 0.2 || 1
return { min: parseFloat((min - pad).toFixed(3)), max: parseFloat((max + pad).toFixed(3)) }
}
/**
* 生成折线图 option。
* extras: [{ name, data, color, dash }] — 上下限或参考线
* yAxisSuffix: 纵轴标签后缀,如 '%'
* yRange: 可选 { min, max },不传则按全量数据自适应
*/
function makeLine(title, xData, yData, extras = [], yAxisSuffix = '', yRange = null) {
const allVals = [yData, ...extras.map(e => e.data)].flat()
const range = yRange || calcYRange(allVals)
const hasExtras = extras.length > 0
const mainSeries = {
name: title, type: 'line', smooth: false, symbol: 'none',
lineStyle: { width: 1.5, color: '#409EFF' }, data: yData, z: 3
}
const extraSeries = extras.map(e => ({
name: e.name,
type: 'line', smooth: false, symbol: 'none',
lineStyle: { width: 1, color: e.color || '#E6A23C', type: e.dash !== false ? 'dashed' : 'solid' },
data: e.data, z: 2
}))
return {
title: { text: title, textStyle: { fontSize: 12, fontWeight: 'normal' }, top: 4, left: 8 },
legend: hasExtras
? { data: [title, ...extras.map(e => e.name)], top: 4, right: 4,
textStyle: { fontSize: 9 }, itemWidth: 14, itemHeight: 8 }
: { show: false },
tooltip: {
trigger: 'axis',
formatter: yAxisSuffix
? params => params.map(p => `${p.marker}${p.seriesName}: ${p.value != null ? p.value + yAxisSuffix : '—'}`).join('<br/>')
: undefined
},
grid: { top: hasExtras ? 44 : 36, bottom: 28, left: 8, right: 16, containLabel: true },
xAxis: {
type: 'category', data: xData,
name: 'pos(m)', nameTextStyle: { fontSize: 10 }, axisLabel: { fontSize: 10 }
},
yAxis: {
type: 'value', min: range.min, max: range.max,
nameTextStyle: { fontSize: 10 },
axisLabel: {
fontSize: 10,
formatter: yAxisSuffix ? val => val + yAxisSuffix : undefined
}
},
dataZoom: [
{ type: 'inside', xAxisIndex: 0, zoomOnMouseWheel: true, moveOnMouseMove: true },
{ type: 'inside', yAxisIndex: 0, zoomOnMouseWheel: false, moveOnMouseWheel: true }
],
series: [mainSeries, ...extraSeries]
}
}
function getRowVal(row, col) {
const v = row[col] !== undefined ? row[col] : row[col.toLowerCase()]
return v == null ? null : Number(v)
}
function xLocData(rows) {
return rows.map(r => {
const v = r.XLOCATION !== undefined ? r.XLOCATION : r.xlocation
return v == null ? '' : Number(v).toFixed(1)
})
}
export default {
name: 'ActualPerformance',
components: { QualityReportDialog },
data() {
return {
excoilLoading: false,
segLoading: false,
realtimeLoading: false,
excoilRows: [],
selectedRow: null,
segData: null,
gaugeRows: null,
shapeRows: null,
activeTab: 'trend',
// 趋势参数树状态
trendGroups: TREND_GROUPS,
expandedGroups: { '张力': true, '速度': true, '拉矫机': true, '酸洗段': true },
selectedTrendParam: null,
trendChartInst: null,
presetData: null,
// 查找
searchType: 'coil',
searchCoilId: '',
searchStartDate: '',
searchEndDate: '',
pagination: { page: 1, pageSize: 50, total: 0 },
currentFilters: { coilId: null, startDate: null, endDate: null },
topTableHeight: 'calc(40vh - 80px)',
chartInstances: [],
resizeHandler: null,
}
},
created() {
this._clickRev = 0
this.loadExcoilCount()
this.loadExcoilList()
},
beforeDestroy() {
this.disposeAllCharts()
},
methods: {
async loadExcoilCount(coilId, startDate, endDate) {
try {
const res = await getExcoilCount(coilId, startDate, endDate)
this.pagination.total = res?.data?.total ?? 0
} catch (_) {}
},
async loadExcoilList(coilId, startDate, endDate) {
this.excoilLoading = true
try {
const res = await getExcoilList(this.pagination.page, this.pagination.pageSize, coilId, startDate, endDate)
this.excoilRows = res?.data?.rows || []
} finally {
this.excoilLoading = false
}
},
handlePageChange(page) {
this.pagination.page = page
this.loadExcoilList(this.currentFilters.coilId, this.currentFilters.startDate, this.currentFilters.endDate)
},
async handleRowClick(row) {
// 快速点击防重:每次点击递增版本号,旧的 sync 任务检测到版本号变更后自动放弃
const clickRev = ++this._clickRev
this.selectedRow = row
this.segData = null
this.gaugeRows = null
this.shapeRows = null
this.presetData = null
this.selectedTrendParam = null
this.disposeAllCharts()
const encoilId = row.ENCOILID || row.encoilid
const excoilId = row.EXCOILID || row.excoilid
await Promise.all([
encoilId ? this.loadSeg(encoilId) : Promise.resolve(),
encoilId ? this.loadPreset(encoilId) : Promise.resolve(),
excoilId ? this.loadRealtime(excoilId) : Promise.resolve()
])
// 如果期间又点击了其他行则放弃后续操作
if (this._clickRev !== clickRev) return
await this.$nextTick()
// 加载完成后自动选中第一个趋势参数
if (this.activeTab === 'trend' && this.segData) {
this.selectTrendParam(TREND_GROUPS[0].children[0])
} else {
this.renderCurrentTab()
}
},
async loadSeg(encoilId) {
this.segLoading = true
try {
const res = await getTimingSegByEncoilId(encoilId)
const rows = res?.data?.rows || []
this.segData = rows.length ? (res?.data?.series || null) : null
} catch (_) {
} finally {
this.segLoading = false
}
},
async loadRealtime(excoilId) {
this.realtimeLoading = true
try {
const res = await getTimingRealtimeData(excoilId)
const g = res?.data?.gauge?.result
const s = res?.data?.shape?.result
this.gaugeRows = Array.isArray(g) ? g : null
this.shapeRows = Array.isArray(s) ? s : null
} catch (_) {
} finally {
this.realtimeLoading = false
}
},
async loadPreset(coilId) {
try {
const res = await getPresetSetupByCoilId(coilId)
this.presetData = res?.data?.data || null
} catch (_) {
this.presetData = null
}
},
// ── 趋势参数树 ──────────────────────────────
toggleGroup(label) {
this.$set(this.expandedGroups, label, !this.expandedGroups[label])
},
isGroupExpanded(label) {
return !!this.expandedGroups[label]
},
selectTrendParam(item) {
this.selectedTrendParam = item
this.$nextTick(() => this.renderTrendSingleChart())
},
renderTrendSingleChart() {
if (!this.selectedTrendParam || !this.segData) return
const el = this.$refs.trendSingleChart
if (!el) return
// 复用已有实例,避免重复 init
if (!this.trendChartInst || this.trendChartInst.isDisposed()) {
this.trendChartInst = echarts.init(el)
// 滚轮缩放支持
const resizeFn = () => this.trendChartInst && !this.trendChartInst.isDisposed() && this.trendChartInst.resize()
window.addEventListener('resize', resizeFn)
this._trendResizeFn = resizeFn
}
const col = this.selectedTrendParam.col
const x = this.segX()
const yData = this.seg(col)
// 实测最大值 / 最小值:来自 PLTCM_PRO_SEG 的段内统计 MAX / MIN
const maxData = this.seg(col + 'MAX')
const minData = this.seg(col + 'MIN')
const extras = []
if (maxData.some(v => v != null)) {
extras.push({ name: '最大值', data: maxData, color: '#F56C6C', dash: true })
}
if (minData.some(v => v != null)) {
extras.push({ name: '最小值', data: minData, color: '#67C23A', dash: true })
}
// 设定值:来自 PLTCM_PRESET_SETUP若有映射
const presetCol = TREND_PRESET_MAP[col]
if (presetCol && this.presetData) {
const setVal = this.presetData[presetCol] !== undefined
? this.presetData[presetCol]
: this.presetData[presetCol.toLowerCase()]
if (setVal != null && Number(setVal) !== 0) {
const sv = Number(Number(setVal).toFixed(3))
extras.push({ name: '设定值', data: new Array(x.length).fill(sv), color: '#E6A23C', dash: true })
}
}
this.trendChartInst.setOption(makeLine(this.selectedTrendParam.label, x, yData, extras), true)
},
// ── Tab 切换 ────────────────────────────────
handleTabSwitch() {
this.$nextTick(() => {
if (this.activeTab === 'trend' && this.selectedTrendParam && this.segData) {
this.renderTrendSingleChart()
} else {
this.renderCurrentTab()
}
})
},
renderCurrentTab() {
this.disposeSideCharts()
if (this.activeTab === 'thickness' && this.gaugeRows?.length) this.renderGaugeCharts()
if (this.activeTab === 'flatness3d' && this.shapeRows?.length) this.renderFlatness3d()
if (this.activeTab === 'flatness' && this.shapeRows?.length) this.renderFlatnessCharts()
},
// ── 销毁 ────────────────────────────────────
disposeSideCharts() {
if (this.resizeHandler) {
window.removeEventListener('resize', this.resizeHandler)
this.resizeHandler = null
}
this.chartInstances.forEach(c => { if (c && !c.isDisposed()) c.dispose() })
this.chartInstances = []
},
disposeAllCharts() {
this.disposeSideCharts()
if (this._trendResizeFn) {
window.removeEventListener('resize', this._trendResizeFn)
this._trendResizeFn = null
}
if (this.trendChartInst && !this.trendChartInst.isDisposed()) {
this.trendChartInst.dispose()
this.trendChartInst = null
}
},
// ── SEG 数据辅助 ─────────────────────────────
seg(col) {
const s = this.segData
// Oracle 返回大写列名,兼容小写
const arr = s[col] !== undefined ? s[col] : (s[col.toLowerCase()] || [])
return arr.map(v => v == null ? null : Number(Number(v).toFixed(3)))
},
segX() {
const s = this.segData
const arr = s['STARTPOS'] !== undefined ? s['STARTPOS'] : (s['startpos'] || [])
return arr.map(v => v == null ? '' : Number(v).toFixed(1))
},
// ── 图表初始化 ───────────────────────────────
makeChart(ref, option) {
const el = this.$refs[ref]
if (!el) return null
const chart = echarts.init(el)
chart.setOption(option)
return chart
},
setupResize() {
this.resizeHandler = () => this.chartInstances.forEach(c => {
if (c && !c.isDisposed()) c.resize()
})
window.addEventListener('resize', this.resizeHandler)
},
// ── 厚度曲线 (V_VBDA_GAUGE) ──────────────────
// 厚度列为相对设定值的偏差百分比,纵轴以 100% 为目标值显示
renderGaugeCharts() {
const rows = this.gaugeRows
if (!rows || !rows.length) return
const xData = xLocData(rows)
// THICK0~THICK4 对应的参考列
const refColMap = { THICK0: 'THICK0REF', THICK1: 'THICK1REF', THICK4: 'THICK4REF', THICK5: 'THICK5REF' }
const chartRefs = ['chartGauge1', 'chartGauge2', 'chartGauge3', 'chartGauge4']
const charts = chartRefs.map((ref, i) => {
const { col, title } = GAUGE_COLS[i]
const refCol = refColMap[col]
if (refCol) {
// ── 厚度图THICKx 本身就是相对设定值的偏差百分比 ──
// 实测数据验证(卷 26061045000THICK0≈-3.0 而 THICK0REF=3.0mm
// 绝对厚度不可能为负,故 THICKx 是偏差%,纵轴画 100 + 偏差
const yData = rows.map(r => {
const v = getRowVal(r, col)
const rv = getRowVal(r, refCol)
if (v == null || rv == null || rv === 0) return null
return parseFloat((100 + v).toFixed(3))
})
const extras = []
// 目标值恒为 100%(各通道相对各自设定值)
const refLine = rows.map(r => {
const rv = getRowVal(r, refCol)
return (rv != null && rv !== 0) ? 100 : null
})
if (refLine.some(v => v != null)) extras.push({ name: '目标值(100%)', data: refLine, color: '#909399', dash: false })
// TOPLIMIT/BOTLIMIT 是冷轧机出口厚度的 AGC 公差带(逐行变化,
// 头尾加减速段约 ±2.5%、稳态段收紧到约 ±1%),只约束出口厚度。
// 入口测厚仪测的是来料热卷,天然偏离名义值且不受此公差约束,
// 故公差带仅画在出口侧图上THICK1/THICK5入口图只画偏差曲线。
if (col !== 'THICK0') {
const upData = rows.map(r => {
const rv = getRowVal(r, refCol)
const tl = getRowVal(r, 'TOPLIMIT')
if (rv == null || rv === 0) return null
return parseFloat((100 + (tl ?? 3)).toFixed(3))
})
const loData = rows.map(r => {
const rv = getRowVal(r, refCol)
const bl = getRowVal(r, 'BOTLIMIT')
if (rv == null || rv === 0) return null
return parseFloat((100 + (bl ?? -3)).toFixed(3))
})
if (upData.some(v => v != null)) extras.push({ name: '上限', data: upData, color: '#F56C6C', dash: true })
if (loData.some(v => v != null)) extras.push({ name: '下限', data: loData, color: '#67C23A', dash: true })
}
const yRange = calcPercentRange(yData, extras.flatMap(e => e.data))
return this.makeChart(ref, makeLine(title, xData, yData, extras, '%', yRange))
} else {
// ── 速度等无参考值的图:保持原始单位 ──
const yData = rows.map(r => {
const v = getRowVal(r, col)
return v == null ? null : parseFloat(v.toFixed(4))
})
return this.makeChart(ref, makeLine(title, xData, yData))
}
})
this.chartInstances = charts.filter(Boolean)
this.setupResize()
},
// ── 带钢板形 3D 线图 (V_VBDA_SHAPE) ────────────
// 每个通道画一条独立 line3D通道之间不连面形成镂空效果
renderFlatness3d() {
const rows = this.shapeRows
if (!rows || !rows.length) return
const firstRow = rows[0]
const high = parseInt(getRowVal(firstRow, 'HIGHZONEID')) || 26
const low = parseInt(getRowVal(firstRow, 'LOWZONEID')) || 1
const numZones = Math.min(Math.max(high - low + 1, 1), 26)
const zoneCols = Array.from({ length: numZones }, (_, i) =>
`VALUES${String(low + i).padStart(2, '0')}`
)
// X 方向降采样,最多 200 个点
const step = Math.max(1, Math.floor(rows.length / 200))
const sampled = rows.filter((_, i) => i % step === 0)
const numX = sampled.length
// X 轴标签(位置,单位 m
const xLabels = sampled.map(r => {
const v = r.XLOCATION !== undefined ? r.XLOCATION : r.xlocation
return v == null ? '' : Number(v).toFixed(0)
})
// 收集值域用于 visualMap
let minV = Infinity, maxV = -Infinity
sampled.forEach(row => {
zoneCols.forEach(col => {
const v = getRowVal(row, col)
if (v != null) {
if (v < minV) minV = v
if (v > maxV) maxV = v
}
})
})
if (!isFinite(minV)) { minV = -30; maxV = 30 }
const absMax = Math.max(Math.abs(minV), Math.abs(maxV))
// ① 沿 X 方向网格线(每通道一条,按 Z 值着色)
const channelLines = zoneCols.map((col, yi) => ({
type: 'line3D',
coordinateSystem: 'cartesian3D',
data: sampled.map((row, xi) => {
const v = getRowVal(row, col)
return v == null ? null : [xi, yi, parseFloat(v.toFixed(2))]
}).filter(Boolean),
lineStyle: { width: 2, opacity: 1 }
}))
// ② 沿 Y 方向网格线(每隔若干位置连通各通道,按 Z 值着色)
const xStride = Math.max(1, Math.floor(numX / 60))
const crossLines = []
for (let xi = 0; xi < numX; xi += xStride) {
const pts = zoneCols.map((col, yi) => {
const v = getRowVal(sampled[xi], col)
return v == null ? null : [xi, yi, parseFloat(v.toFixed(2))]
}).filter(Boolean)
if (pts.length > 1) {
crossLines.push({
type: 'line3D',
coordinateSystem: 'cartesian3D',
data: pts,
lineStyle: { width: 1.5, opacity: 1 }
})
}
}
const series = [...channelLines, ...crossLines]
const option = {
title: { text: '实测平直度 [IU]', textStyle: { fontSize: 13, fontWeight: 'normal' }, top: 6, left: 10 },
tooltip: {},
visualMap: {
show: true,
dimension: 2,
min: -absMax,
max: absMax,
calculable: true,
orient: 'vertical',
right: 10,
top: 'center',
textStyle: { fontSize: 10 },
inRange: {
// 负值红色 → 零值绿色 → 正值蓝紫
color: ['#8B0000','#CC2200','#E84C00','#F46D43',
'#FDAE61','#FEE08B',
'#66BD63','#1A9850','#006837',
'#3288BD','#5E4FA2','#762A83']
}
},
grid3D: {
boxWidth: 200,
boxHeight: 60,
boxDepth: 80,
viewControl: {
projection: 'orthographic',
autoRotate: false,
rotateSensitivity: 1,
zoomSensitivity: 1
},
light: {
main: { intensity: 1.2, shadow: false },
ambient: { intensity: 0.3 }
}
},
xAxis3D: {
type: 'value',
name: '位置',
min: 0,
max: numX - 1,
nameTextStyle: { fontSize: 10 },
axisLabel: {
fontSize: 9,
formatter: v => xLabels[Math.round(v)] || ''
}
},
yAxis3D: {
type: 'value',
name: '通道',
min: 0,
max: numZones - 1,
nameTextStyle: { fontSize: 10 },
axisLabel: {
fontSize: 9,
formatter: v => String(low + Math.round(v))
}
},
zAxis3D: {
type: 'value',
name: 'IU',
nameTextStyle: { fontSize: 10 },
axisLabel: { fontSize: 9 }
},
series
}
const el = this.$refs.chartFlatness3d
if (!el) return
const chart = echarts.init(el)
chart.setOption(option)
this.chartInstances = [chart]
this.setupResize()
},
// ── 板形曲线 (V_VBDA_SHAPE) ──────────────────
renderFlatnessCharts() {
const rows = this.shapeRows
if (!rows || !rows.length) return
const xData = xLocData(rows)
const refs = ['chartFlatDev', 'chartTilt', 'chartWrBend', 'chartIrBend']
const charts = refs.map((ref, i) => {
const { col, title } = SHAPE_SCALAR_COLS[i]
const yData = rows.map(r => {
const v = getRowVal(r, col)
return v == null ? null : parseFloat(v.toFixed(3))
})
return this.makeChart(ref, makeLine(title, xData, yData))
})
this.chartInstances = charts.filter(Boolean)
this.setupResize()
},
// ── 查找 ─────────────────────────────────────
async handleFindSearch() {
const coilId = this.searchType === 'coil' ? (this.searchCoilId || null) : null
const startDate = this.searchType === 'time' ? (this.searchStartDate || null) : null
const endDate = this.searchType === 'time' ? (this.searchEndDate || null) : null
this.currentFilters = { coilId, startDate, endDate }
this.pagination.page = 1
this.selectedRow = null
this.segData = null
this.gaugeRows = null
this.shapeRows = null
this.selectedTrendParam = null
this.disposeAllCharts()
await this.loadExcoilCount(coilId, startDate, endDate)
await this.loadExcoilList(coilId, startDate, endDate)
},
handleFindReset() {
this.searchCoilId = ''
this.searchStartDate = ''
this.searchEndDate = ''
this.currentFilters = { coilId: null, startDate: null, endDate: null }
this.selectedRow = null
this.segData = null
this.gaugeRows = null
this.shapeRows = null
this.selectedTrendParam = null
this.disposeAllCharts()
this.pagination.page = 1
this.loadExcoilCount()
this.loadExcoilList()
},
openQualityReport(row) {
// If the clicked row is already the selected row, use in-memory data
const isSame = this.selectedRow &&
(row.EXCOILID || row.excoilid) === (this.selectedRow.EXCOILID || this.selectedRow.excoilid)
const segData = isSame ? this.segData : null
const gaugeRows = isSame ? this.gaugeRows : null
const shapeRows = isSame ? this.shapeRows : null
const presetData = isSame ? this.presetData : null
this.$refs.qualityReport.open(row, segData, gaugeRows, shapeRows, presetData)
},
calcLengthPerTon(row) {
const len = parseFloat(row.EXIT_LENGTH || row.exit_length)
const wt = parseFloat(row.MEAS_EXIT_WEIGHT || row.meas_exit_weight)
if (!len || !wt || wt === 0) return '—'
return (len / wt).toFixed(2)
},
formatDate(v) {
if (!v) return '—'
return String(v).replace('T', ' ').substring(0, 19)
},
}
}
</script>
<style scoped lang="scss">
/* 外层 wrapper必须限定高度否则 flex 百分比高度失效,导致内容溢出屏幕 */
.actual-page {
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
}
.actual-container {
display: flex;
flex-direction: column;
height: 100%;
padding: 8px;
background: #f0f2f5;
gap: 6px;
box-sizing: border-box;
overflow: hidden;
}
.top-section {
flex-shrink: 0;
background: #fff;
border-radius: 4px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
overflow: hidden;
}
.table-pagination {
display: flex;
justify-content: flex-end;
padding: 4px 8px;
border-top: 1px solid #ebeef5;
background: #fafafa;
}
.bottom-section {
flex: 1;
min-height: 0;
display: flex;
gap: 6px;
}
.chart-section {
flex: 1;
min-height: 0;
background: #fff;
border-radius: 4px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
padding: 8px;
display: flex;
flex-direction: column;
overflow: hidden;
.chart-tabs {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
::v-deep .el-tabs__content {
flex: 1;
overflow: hidden;
min-height: 0;
padding: 0;
}
::v-deep .el-tab-pane {
height: 100%;
}
}
}
/* ── 趋势参数:左树 + 右图 ── */
.trend-layout {
display: flex;
height: 100%;
gap: 0;
}
.trend-tree {
width: 140px;
flex-shrink: 0;
overflow-y: auto;
border-right: 1px solid #ebeef5;
padding: 4px 0;
&::-webkit-scrollbar { width: 3px; }
&::-webkit-scrollbar-thumb { background: #dcdfe6; border-radius: 2px; }
}
.tree-group {
user-select: none;
}
.tree-group-label {
display: flex;
align-items: center;
gap: 4px;
padding: 5px 8px;
font-size: 12px;
font-weight: 600;
color: #303133;
cursor: pointer;
&:hover { background: #f5f7fa; }
i { font-size: 10px; color: #909399; }
}
.tree-children { padding-left: 4px; }
.tree-item {
padding: 4px 8px 4px 18px;
font-size: 12px;
color: #606266;
cursor: pointer;
border-radius: 3px;
margin: 1px 4px;
&:hover { background: #ecf5ff; color: #409eff; }
&.active {
background: #ecf5ff;
color: #409eff;
font-weight: 500;
}
}
.trend-chart-area {
flex: 1;
min-width: 0;
height: 100%;
padding: 4px 4px 4px 8px;
display: flex;
align-items: stretch;
}
/* ── 其他图表 ── */
.charts-scroll {
height: 100%;
overflow-y: auto;
padding: 4px;
&::-webkit-scrollbar { width: 4px; }
&::-webkit-scrollbar-thumb { background: #dcdfe6; border-radius: 2px; }
}
/* 一行两图 */
.charts-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
align-content: start;
.chart-box { margin-bottom: 0; }
}
.chart-box {
width: 100%;
height: 200px;
margin-bottom: 8px;
}
.chart-box-tall { height: 480px; }
/* ── 查找面板 ── */
.search-panel {
width: 210px;
flex-shrink: 0;
background: #fff;
border-radius: 4px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
padding: 10px;
display: flex;
flex-direction: column;
gap: 14px;
overflow-y: auto;
}
.panel-title {
font-size: 13px;
font-weight: 600;
color: #303133;
padding-bottom: 6px;
border-bottom: 1px solid #ebeef5;
}
.search-type-group { display: flex; flex-direction: column; gap: 8px; }
.search-field {
display: flex;
flex-direction: column;
gap: 4px;
padding-left: 20px;
}
.search-label { font-size: 11px; color: #909399; }
.search-actions {
display: flex;
gap: 8px;
margin-top: auto;
}
.no-data-hint {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 120px;
color: #c0c4cc;
font-size: 13px;
}
</style>