Files
klp-oa/klp-ui/src/views/micro/pages/acid/components/ActualPerformance.vue
2026-05-12 17:15:29 +08:00

1236 lines
43 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-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>
<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>
</template>
<script>
import * as echarts from 'echarts'
import 'echarts-gl'
import {
getExcoilList,
getExcoilCount,
getTimingSegByEncoilId,
getTimingRealtimeData,
getPresetSetupByCoilId
} from '@/api/l2/timing'
import { listProcessSpecVersion } from '@/api/wms/processSpecVersion'
import { listProcessPlan, addProcessPlan } from '@/api/wms/processPlan'
import { listProcessPlanParam, addProcessPlanParam, updateProcessPlanParam } from '@/api/wms/processPlanParam'
import { upsertProcessCoilRecord } from '@/api/wms/processCoilRecord'
import { batchAddProcessAnomaly } from '@/api/wms/processAnomaly'
// 趋势参数树结构,对应 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
const GAUGE_COLS = [
{ col: 'THICK0', title: '入口测厚仪 [mm]' },
{ col: 'THICK1', title: '1架出口厚度 [mm]' },
{ col: 'THICK4', title: '末架出口厚度 [mm]' },
{ 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)) }
}
/**
* 生成折线图 option。
* extras: [{ name, data, color, dash }] — 上下限或参考线
*/
function makeLine(title, xData, yData, extras = []) {
const allVals = [yData, ...extras.map(e => e.data)].flat()
const range = 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' },
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 }
},
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',
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 },
topTableHeight: 'calc(40vh - 80px)',
chartInstances: [],
resizeHandler: null
}
},
created() {
this._clickRev = 0
this.loadExcoilCount()
this.loadExcoilList()
},
beforeDestroy() {
this.disposeAllCharts()
},
methods: {
async loadExcoilCount() {
try {
const res = await getExcoilCount()
this.pagination.total = res?.data?.total ?? 0
} catch (_) {}
},
async loadExcoilList() {
this.excoilLoading = true
try {
const res = await getExcoilList(this.pagination.page, this.pagination.pageSize)
this.excoilRows = res?.data?.rows || []
} finally {
this.excoilLoading = false
}
},
handlePageChange(page) {
this.pagination.page = page
this.loadExcoilList()
},
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
// 后台静默同步到规程(不阻塞 UI
this.autoSyncToActiveSpec(excoilId || encoilId, clickRev)
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) ──────────────────
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 yData = rows.map(r => {
const v = getRowVal(r, col)
return v == null ? null : parseFloat(v.toFixed(4))
})
const extras = []
const refCol = refColMap[col]
if (refCol) {
const refData = rows.map(r => {
const rv = getRowVal(r, refCol)
return rv == null ? null : parseFloat(rv.toFixed(4))
})
// 上限 = REF + TOPLIMIT下限 = REF + BOTLIMITTOPLIMIT/BOTLIMIT 单位与测厚仪一致)
const upData = rows.map((r, j) => {
const rv = getRowVal(r, refCol)
const tl = getRowVal(r, 'TOPLIMIT')
return rv == null ? null : parseFloat((rv + (tl ?? 3)).toFixed(4))
})
const loData = rows.map((r, j) => {
const rv = getRowVal(r, refCol)
const bl = getRowVal(r, 'BOTLIMIT')
return rv == null ? null : parseFloat((rv + (bl ?? -3)).toFixed(4))
})
if (refData.some(v => v != null)) extras.push({ name: '目标值', data: refData, color: '#909399', dash: false })
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 })
}
return this.makeChart(ref, makeLine(title, xData, yData, extras))
})
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()
},
// ── 查找 ─────────────────────────────────────
handleFindSearch() {
if (this.searchType === 'coil' && this.searchCoilId) {
const found = this.excoilRows.find(r =>
(r.EXCOILID || r.excoilid || '').includes(this.searchCoilId)
)
if (found) {
this.$refs.excoilTable && this.$refs.excoilTable.setCurrentRow(found)
this.handleRowClick(found)
} else {
this.$message.info('当前页未找到该卷号,请翻页查找')
}
} else {
this.pagination.page = 1
this.loadExcoilList()
}
},
handleFindReset() {
this.searchCoilId = ''
this.searchStartDate = ''
this.searchEndDate = ''
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()
},
// ── 自动同步到生效规程 ────────────────────────
async autoSyncToActiveSpec(coilId, rev) {
if (!this.presetData && !this.segData) return
const guard = () => rev !== undefined && this._clickRev !== rev
try {
// ① 查找生效版本
const verRes = await listProcessSpecVersion({ isActive: 1, pageNum: 1, pageSize: 10 })
if (guard()) return
const activeVer = (verRes.rows || []).find(v => v.isActive === 1)
if (!activeVer) return
const versionId = activeVer.versionId
// ② 构建本次写入条目
const items = this.buildSpecSyncItems()
if (!items.length) return
// ③ 加载已有 plan 点位
const plansRes = await listProcessPlan({ versionId, pageNum: 1, pageSize: 500 })
if (guard()) return
const planMap = {}
;(plansRes.rows || []).forEach(p => { planMap[p.pointCode] = p })
// ④ 逐条 upsert 点位 & 参数,收集异常
const anomalies = []
const detectedAt = new Date().toISOString()
// 提取本次钢卷的 enCoilId入口卷号
const enCoilId = this.selectedRow ? (this.selectedRow.ENCOILID || this.selectedRow.encoilid || null) : null
for (const item of items) {
if (guard()) return
// 确保 plan 点位存在
let planId
const ep = planMap[item.pointCode]
if (ep) {
planId = ep.planId
} else {
const r = await addProcessPlan({
versionId,
segmentType: 'PROCESS',
segmentName: item.groupLabel,
pointName: item.pointName,
pointCode: item.pointCode,
sortOrder: 0
})
if (guard()) return
planId = r.data
}
// 查已存储参数
const prRes = await listProcessPlanParam({ planId, pageNum: 1, pageSize: 100 })
if (guard()) return
const stored = (prRes.rows || []).find(p => p.paramCode === item.paramCode)
// 异常检测:与已有上下限比对
if (stored) {
const sUp = stored.upperLimit != null ? Number(stored.upperLimit) : null
const sLo = stored.lowerLimit != null ? Number(stored.lowerLimit) : null
const aUp = item.upperLimit
const aLo = item.lowerLimit
const overTypes = []
if (sUp != null && aUp != null && aUp > sUp) overTypes.push('OVER_MAX')
if (sLo != null && aLo != null && aLo < sLo) overTypes.push('UNDER_MIN')
if (overTypes.length) {
anomalies.push({
versionId,
planId,
paramId: stored.paramId || null,
coilId,
enCoilId,
paramCode: item.paramCode,
paramName: item.paramName,
unit: item.unit,
anomalyType: overTypes.length === 2 ? 'BOTH' : overTypes[0],
storedTarget: stored.targetValue != null ? Number(stored.targetValue) : null,
storedUpper: sUp,
storedLower: sLo,
actualTarget: item.targetValue,
actualMax: aUp,
actualMin: aLo,
deviationMax: sUp != null && aUp != null ? parseFloat((aUp - sUp).toFixed(4)) : null,
deviationMin: sLo != null && aLo != null ? parseFloat((aLo - sLo).toFixed(4)) : null,
detectedAt
})
}
}
// 写入/更新参数
// target_value 始终覆盖反映最新L1设定
// upper/lower 仅首次写入null 时写入作为基线)
// actualSrcId / presetSrcId 仅首次写入
if (stored) {
await updateProcessPlanParam({
...stored,
targetValue: item.targetValue ?? stored.targetValue,
upperLimit: stored.upperLimit ?? item.upperLimit,
lowerLimit: stored.lowerLimit ?? item.lowerLimit,
unit: item.unit || stored.unit,
actualSrcId: stored.actualSrcId || enCoilId,
presetSrcId: stored.presetSrcId || coilId
})
} else {
await addProcessPlanParam({
planId,
paramCode: item.paramCode,
paramName: item.paramName,
targetValue: item.targetValue,
upperLimit: item.upperLimit,
lowerLimit: item.lowerLimit,
unit: item.unit,
actualSrcId: enCoilId,
presetSrcId: coilId
})
}
}
if (guard()) return
// ⑤ 写入钢卷服役记录(幂等 upsert
await upsertProcessCoilRecord({
versionId,
coilId,
enCoilId,
hasAnomaly: anomalies.length > 0 ? 1 : 0,
anomalyCnt: anomalies.length,
processTime: detectedAt
})
// ⑥ 持久化异常到数据库
if (anomalies.length) {
await batchAddProcessAnomaly(anomalies)
console.log(`[规程同步] 检测到 ${anomalies.length} 个参数异常,已写入数据库`)
}
} catch (e) {
console.warn('[规程同步] 后台同步失败:', e)
}
},
/** 从当前 segData + presetData 构建写入条目 */
buildSpecSyncItems() {
const items = []
for (const group of TREND_GROUPS) {
for (const item of group.children) {
const col = item.col
const maxArr = this.segData ? this.seg(col + 'MAX').filter(v => v != null) : []
const minArr = this.segData ? this.seg(col + 'MIN').filter(v => v != null) : []
const presetCol = TREND_PRESET_MAP[col]
let targetValue = null
if (presetCol && this.presetData) {
const sv = this.presetData[presetCol] !== undefined
? this.presetData[presetCol]
: this.presetData[presetCol.toLowerCase()]
if (sv != null && Number(sv) !== 0) targetValue = parseFloat(Number(sv).toFixed(4))
}
const upperLimit = maxArr.length ? parseFloat(Math.max(...maxArr).toFixed(4)) : null
const lowerLimit = minArr.length ? parseFloat(Math.min(...minArr).toFixed(4)) : null
if (targetValue == null && upperLimit == null && lowerLimit == null) continue
items.push({
groupLabel: group.label,
pointCode: col,
pointName: item.label,
paramCode: col,
paramName: item.label,
targetValue,
upperLimit,
lowerLimit,
unit: TREND_UNIT_MAP[col] || ''
})
}
}
return items
},
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">
.actual-container {
display: flex;
flex-direction: column;
height: 100%;
padding: 8px;
background: #f0f2f5;
gap: 6px;
box-sizing: border-box;
}
.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;
::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>