feat: 添加PDF和图片生成库并优化打印功能

refactor(FurCurrent): 重构参数显示逻辑,优化闪烁效果
style(FurnaceHistoryPanel): 调整表格列宽和溢出处理
feat(LabelPrint): 使用dom-to-image提升打印质量
feat(QualityCertificate): 添加生产过程曲线图表
feat: 新增ParamEcharts组件用于统一图表渲染
refactor(line): 使用ParamEcharts重构监控图表组件
This commit is contained in:
砂糖
2026-01-03 13:37:27 +08:00
parent a96c3ae8a3
commit 98678cc043
7 changed files with 685 additions and 580 deletions

View File

@@ -30,14 +30,17 @@
"clipboard": "2.0.8",
"core-js": "3.37.1",
"dayjs": "^1.11.18",
"dom-to-image": "^2.6.0",
"echarts": "5.4.0",
"element-ui": "2.15.14",
"file-saver": "2.0.5",
"fuse.js": "6.4.3",
"highlight.js": "9.18.5",
"html2canvas": "^1.4.1",
"js-beautify": "1.13.0",
"js-cookie": "3.0.1",
"jsencrypt": "3.0.0-rc.1",
"jspdf": "^2.5.2",
"konva": "^10.0.2",
"nprogress": "0.2.0",
"print-js": "^1.6.0",

View File

@@ -14,60 +14,54 @@
<div class="grid-item label">Coil ID</div>
<!-- 钢卷号 -->
<div class="grid-item value">
<input v-model="editableData.exitMatId" :border="false" class="editable-input"
placeholder="Coil ID"></input>
<!-- 钢卷号 -->
<input v-model="editableData.exitMatId" :border="false" class="editable-input" placeholder="Coil ID">
<!-- 钢卷号 -->
</div>
<div class="grid-item label">Hot Coil ID</div>
<!-- 热卷号 -->
<div class="grid-item value">
<input v-model="editableData.entryMatId" :border="false" class="editable-input"
placeholder="Hot Coil ID"></input>
<!-- 热卷号 -->
<input v-model="editableData.entryMatId" :border="false" class="editable-input" placeholder="Hot Coil ID">
<!-- 热卷号 -->
</div>
<div class="grid-item label">Specification</div>
<!-- 规格 -->
<div class="grid-item value">
<input v-model="editableData.spec" :border="false" class="editable-input" placeholder="Specification"></input>
<input v-model="editableData.spec" :border="false" class="editable-input" placeholder="Specification">
<!-- 规格 -->
</div>
<div class="grid-item label">Material</div>
<!-- 材质 -->
<div class="grid-item value">
<input v-model="editableData.steelGrade" :border="false" class="editable-input"
placeholder="Material"></input>
<!-- 材质 -->
<input v-model="editableData.steelGrade" :border="false" class="editable-input" placeholder="Material">
<!-- 材质 -->
</div>
<div class="grid-item label">Net Weight</div>
<!-- 净重 -->
<div class="grid-item value">
<input v-model="editableData.actualWeight" :border="false" class="editable-input"
placeholder="Net Weight"></input>
<!-- 净重 -->
<input v-model="editableData.actualWeight" :border="false" class="editable-input" placeholder="Net Weight">
<!-- 净重 -->
</div>
<div class="grid-item label">Production Shift</div>
<!-- 生产班组 -->
<div class="grid-item value">
<input v-model="editableData.groupNo" :border="false" class="editable-input"
placeholder="Production Shift"></input>
<!-- 生产班组 -->
<input v-model="editableData.groupNo" :border="false" class="editable-input" placeholder="Production Shift">
<!-- 生产班组 -->
</div>
<div class="grid-item label">Product Name</div>
<!-- 产品名称 -->
<div class="grid-item value">
<input v-model="editableData.prodCode" :border="false" class="editable-input"
placeholder="Product Name"></input>
<!-- 产品名称 -->
<input v-model="editableData.prodCode" :border="false" class="editable-input" placeholder="Product Name">
<!-- 产品名称 -->
</div>
<div class="grid-item label">Production Date</div>
<!-- 生产日期 -->
<div class="grid-item value">
<input v-model="editableData.productionDate" :border="false" class="editable-input"
placeholder="Production Date"></input>
<!-- 生产日期 -->
placeholder="Production Date">
<!-- 生产日期 -->
</div>
</div>
</div>
@@ -87,6 +81,8 @@
<script>
import printJS from 'print-js';
import dayjs from 'dayjs';
// 导入 dom-to-image 库
import domtoimage from 'dom-to-image';
export default {
name: 'PdoLabelPrint',
@@ -145,22 +141,28 @@ export default {
};
},
// Handle print logic / 处理打印逻辑
handlePrint() {
// Call printJS to print / 调用printJS进行打印
printJS({
printable: 'printContent', // Element ID to print / 要打印的元素ID
type: 'html',
header: null, // Don't show default header / 不显示默认页眉
footer: null, // Don't show default footer / 不显示默认页脚
// style: printStyle, // Apply print styles / 应用打印样式
scanStyles: true, // Don't scan existing page styles / 不扫描页面现有样式
targetStyles: ['*'], // Allow all target styles / 允许所有目标样式
documentTitle: 'Label Print', // Print document title / 打印文档标题
onPrintDialogClose: () => {
console.log('Print dialog closed'); // 打印对话框已关闭
}
});
// 替换原dom-to-image的转换逻辑
async handlePrint() {
try {
const printElement = document.getElementById('printContent');
// 关键设置scale提升分辨率建议3-4
const imageBase64 = await domtoimage.toPng(printElement, {
backgroundColor: '#ffffff',
scale: 2, // 缩放倍数(越大图片越清晰,文件也越大)
});
// 打印图片时强制按实际尺寸渲染
printJS({
printable: imageBase64,
type: 'image',
imageStyle: `
width: 100mm; /* 与标签实际物理尺寸一致 */
height: 80mm;
object-fit: contain;
`,
documentTitle: 'Label Print'
});
} catch (e) { /* 错误处理 */ }
}
}
}
@@ -178,12 +180,17 @@ export default {
/* 标签内容样式(与打印尺寸等比例) */
.label-content {
width: 500px; /* 200mm × 2.5 = 500px */
height: 400px; /* 160mm × 2.5 = 400px */
width: 500px;
/* 200mm × 2.5 = 500px */
height: 400px;
/* 160mm × 2.5 = 400px */
border: 1px solid #ccc;
padding: 25px; /* 10mm × 2.5 = 25px */
padding: 25px;
/* 10mm × 2.5 = 25px */
box-sizing: border-box;
font-family: SimSun;
background: #ffffff;
/* 确保DOM背景为白色转换图片后样式一致 */
}
.company-name {
@@ -239,4 +246,4 @@ export default {
text-align: left;
margin-top: 10px;
}
</style>
</style>

View File

@@ -0,0 +1,454 @@
<template>
<div class="param-echarts-container" v-loading="loading">
<!-- 图表标题 -->
<div class="chart-title">{{ chartTitle }}</div>
<!-- ECharts 渲染容器 -->
<div ref="chartDom" class="chart-content"></div>
</div>
</template>
<script>
import * as echarts from 'echarts'
import { getSegmentList } from '@/api/business/segment'
export default {
name: 'ParamEcharts',
// 接收父组件传入的核心参数
props: {
enCoilID: {
type: String,
required: true,
description: '线圈ID用于请求对应数据'
},
paramField: {
type: String,
required: true,
description: '参数字段名(对应原 paramFields 的 value'
}
},
data() {
return {
chartInstance: null, // ECharts 实例
loading: false, // 数据加载状态
chartDataObj: { // 图表数据
timeStamps: [],
chartData: [],
originalTimeStamps: [],
originalChartData: []
},
stat: { // 参数统计值
latestValue: 0,
maxValue: 0,
minValue: 0,
avgValue: 0
},
maxXAxisLabels: 40, // X轴最大显示标签数
paramLabelMap: { // 参数字段与标签的映射(与父组件保持一致)
stripSpeed: 'Strip Speed',
tensionPorBr1: 'Pay-off Tension 1#',
tensionPorBr2: 'Pay-off Tension 2#',
cleaningVoltage: 'Cleaning Voltage',
cleaningCurrent: 'Cleaning Current',
alkaliConcentration: 'Alkali Concentration',
alkaliTemperature: 'Alkali Temperature',
phfExitStripTemp: 'PH Furnace Exit Temp',
rtfExitStripTemp: 'Heating Section Exit Temp',
jcsExitStripTemp: 'Cooling Section Exit Temp',
scsExitStripTemp: 'Equilibrium Section Exit Temp',
potTemperature: 'Pot Temperature',
zincPotPower: 'Zinc Pot Power',
gasConsumption: 'Gas Consumption',
coolingTowerStripTemp: 'Cooling Tower Temp',
tensionBr5Tm: 'TM Tension',
stripSpeedTmExit: 'TM Exit Speed',
tlElongation: 'TL Elongation',
tensionTlBr7: 'TL Tension'
}
}
},
computed: {
// 图表标题(根据 paramField 自动匹配)
chartTitle() {
return this.paramLabelMap[this.paramField] || this.paramField
}
},
watch: {
// 监听线圈ID变化重新请求数据
enCoilID: {
handler(newVal) {
this.fetchChartData()
},
immediate: true
},
// 监听参数字段变化,重新请求数据
paramField: {
handler(newVal) {
this.fetchChartData()
},
immediate: true
}
},
mounted() {
// 初始化 ECharts 实例
this.initEchartsInstance()
// 监听窗口 resize自适应图表尺寸
window.addEventListener('resize', this.handleResize)
},
beforeDestroy() {
// 销毁 ECharts 实例,释放资源
if (this.chartInstance && this.chartInstance.dispose) {
this.chartInstance.dispose()
}
// 移除 resize 监听
window.removeEventListener('resize', this.handleResize)
},
methods: {
// 初始化 ECharts 实例
initEchartsInstance() {
this.$nextTick(() => {
const chartDom = this.$refs.chartDom
if (!chartDom) {
console.warn('ECharts 渲染容器未找到,初始化失败')
return
}
// 创建 ECharts 实例
this.chartInstance = echarts.init(chartDom)
// 初始绘制空图表
this.drawEmptyChart()
})
},
// 获取图表数据
fetchChartData() {
// 前置校验:参数无效时绘制空图表并关闭加载
if (!this.enCoilID || !this.paramField) {
this.drawEmptyChart()
this.loading = false
return
}
// 开启加载状态
this.loading = true
// 请求数据
getSegmentList({
enCoilID: this.enCoilID,
paramField: this.paramField
}).then(res => {
if (res.data && res.data.length > 0) {
// 处理数据
const processedData = this.processChartData(res.data)
this.chartDataObj = processedData
this.stat = processedData.stat
// 绘制折线图
this.drawLineChart()
} else {
// 无数据时绘制无数据图表
this.drawNoDataChart()
}
}).catch(err => {
console.error(`获取${this.paramField}数据失败:`, err)
// 数据请求失败时绘制错误图表
this.drawErrorChart()
}).finally(() => {
// 无论成功失败,都关闭加载状态
this.loading = false
})
},
// 处理原始数据
processChartData(rawData) {
// 按 segNo 排序
rawData.sort((a, b) => a.segNo - b.segNo)
const originalTimeStamps = rawData.map(item => item.segNo)
const originalChartData = rawData.map(item => parseFloat(item.value))
let timeStamps = originalTimeStamps
let chartData = originalChartData
// X轴标签过多时进行采样保持原有逻辑
if (rawData.length > this.maxXAxisLabels) {
const sampleInterval = Math.ceil(rawData.length / this.maxXAxisLabels)
timeStamps = []
chartData = []
for (let i = 0; i < rawData.length; i += sampleInterval) {
timeStamps.push(rawData[i].segNo)
chartData.push(parseFloat(rawData[i].value))
}
// 确保最后一个数据点被保留
if (timeStamps[timeStamps.length - 1] !== rawData[rawData.length - 1].segNo) {
timeStamps.push(rawData[rawData.length - 1].segNo)
chartData.push(parseFloat(rawData[rawData.length - 1].value))
}
}
// 计算统计值
let stat = { latestValue: 0, maxValue: 0, minValue: 0, avgValue: 0 }
if (originalChartData.length) {
stat.latestValue = originalChartData[originalChartData.length - 1]
stat.maxValue = Math.max(...originalChartData)
stat.minValue = Math.min(...originalChartData)
stat.avgValue = originalChartData.reduce((sum, val) => sum + val, 0) / originalChartData.length
}
return {
timeStamps,
chartData,
originalTimeStamps,
originalChartData,
stat
}
},
// 绘制折线图(核心逻辑)
drawLineChart() {
if (!this.chartInstance || !this.chartDataObj.timeStamps.length) {
return
}
const option = {
backgroundColor: 'transparent',
tooltip: {
trigger: 'axis',
axisPointer: { type: 'line' },
backgroundColor: 'rgba(255, 255, 255, 0.95)',
borderColor: '#d4d4d4',
borderWidth: 1,
textStyle: { color: '#333' },
formatter: (params) => {
const paramItem = params[0]
return `
Sequence: ${paramItem.name}<br>
Value: ${paramItem.value.toFixed(2)}<br>
Unit: ${this.getParamUnit()}
`
}
},
grid: {
left: '3%',
right: '4%',
bottom: '10%',
top: '15%',
containLabel: true
},
xAxis: {
type: 'category',
boundaryGap: false,
data: this.chartDataObj.timeStamps,
axisLine: { lineStyle: { color: '#d4d4d4' } },
axisLabel: {
color: '#666',
rotate: 45,
interval: (index) => {
return index % Math.max(1, Math.ceil(this.chartDataObj.timeStamps.length / this.maxXAxisLabels)) === 0
}
},
splitLine: { show: false }
},
yAxis: {
type: 'value',
name: this.getParamUnit(),
nameLocation: 'middle',
nameGap: 30,
nameTextStyle: { color: '#666' },
axisLine: { lineStyle: { color: '#d4d4d4' } },
axisLabel: { color: '#666' },
splitLine: {
lineStyle: { color: '#e8e8e8', type: 'dashed' }
}
},
series: [
{
name: this.chartTitle,
type: 'line',
data: this.chartDataObj.chartData,
smooth: true,
symbol: 'circle',
symbolSize: 4,
lineStyle: { color: '#666', width: 2 },
itemStyle: { color: '#999' },
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(153, 153, 153, 0.3)' },
{ offset: 1, color: 'rgba(153, 153, 153, 0.05)' }
]
}
},
markPoint: {
data: [{ type: 'max', name: 'Max' }, { type: 'min', name: 'Min' }],
itemStyle: { color: '#999' },
label: { color: '#333' }
},
markLine: {
data: [{ type: 'average', name: 'Average' }],
lineStyle: { color: '#999', type: 'dashed' },
label: { color: '#333' }
}
}
]
}
this.chartInstance.setOption(option, true)
},
// 绘制空图表(初始无参数/无线圈ID时
drawEmptyChart() {
if (!this.chartInstance) return
const option = {
graphic: {
elements: [
{
type: 'text',
left: 'center',
top: 'center',
style: {
text: 'Please select performance',
fontSize: 16,
fontWeight: 'bold',
fill: '#999'
}
}
]
},
xAxis: { type: 'category', data: [] },
yAxis: { type: 'value' },
series: []
}
this.chartInstance.setOption(option)
},
// 绘制无数据图表
drawNoDataChart() {
if (!this.chartInstance) return
const option = {
graphic: {
elements: [
{
type: 'text',
left: 'center',
top: 'center',
style: {
text: 'No data',
fontSize: 16,
fontWeight: 'bold',
fill: '#999'
}
}
]
},
xAxis: { type: 'category', data: [] },
yAxis: { type: 'value' },
series: []
}
this.chartInstance.setOption(option)
},
// 绘制数据加载失败图表
drawErrorChart() {
if (!this.chartInstance) return
const option = {
graphic: {
elements: [
{
type: 'text',
left: 'center',
top: 'center',
style: {
text: 'Data loading failed',
fontSize: 16,
fontWeight: 'bold',
fill: '#f56c6c'
}
}
]
},
xAxis: { type: 'category', data: [] },
yAxis: { type: 'value' },
series: []
}
this.chartInstance.setOption(option)
},
// 窗口 resize 时自适应图表尺寸
handleResize() {
if (this.chartInstance && this.chartInstance.resize) {
this.chartInstance.resize()
}
},
// 获取参数单位(保持原有逻辑)
getParamUnit() {
switch (this.paramField) {
case 'stripSpeed':
case 'stripSpeedTmExit':
return 'm/s'
case 'tensionPorBr1':
case 'tensionPorBr2':
case 'tensionBr5Tm':
case 'tensionTlBr7':
return 'N'
case 'cleaningVoltage':
return 'V'
case 'cleaningCurrent':
return 'A'
case 'alkaliConcentration':
case 'tlElongation':
return '%'
case 'alkaliTemperature':
case 'phfExitStripTemp':
case 'rtfExitStripTemp':
case 'jcsExitStripTemp':
case 'scsExitStripTemp':
case 'potTemperature':
case 'coolingTowerStripTemp':
return '°C'
case 'zincPotPower':
return 'kW'
case 'gasConsumption':
return 'm³/h'
default:
return ''
}
}
}
}
</script>
<style lang="scss" scoped>
.param-echarts-container {
position: relative;
width: 100%;
height: 100%;
border: 1px solid #e8e8e8;
border-radius: 8px;
padding: 10px;
box-sizing: border-box;
}
.chart-title {
font-size: 14px;
font-weight: 500;
color: #333;
margin-bottom: 8px;
text-align: center;
}
.chart-content {
width: 100%;
height: calc(100% - 24px);
box-sizing: border-box;
min-height: 180px; /* 兜底高度,确保 ECharts 初始化有尺寸 */
}
</style>

View File

@@ -17,7 +17,7 @@
<!-- QR code placeholder / 二维码占位 -->
</div>
</div>
<div class="header-info">
<div class="info-row">
<div class="info-item">
@@ -192,6 +192,23 @@
</table>
</div>
<div class="certificate-charts">
<div class="charts-title">Production Process Curves</div>
<!-- 生产过程曲线标题 -->
<div class="charts-wrapper">
<!-- 带钢速度曲线stripSpeed -->
<div class="chart-item">
<div class="chart-subtitle">Strip Speed (m/s)</div>
<ParamEcharts :enCoilID="getEnCoilID" paramField="stripSpeed" />
</div>
<!-- 1#放卷张力曲线tensionPorBr1 -->
<div class="chart-item">
<div class="chart-subtitle">Pay-off Tension 1# (N)</div>
<ParamEcharts :enCoilID="getEnCoilID" paramField="tensionPorBr1" />
</div>
</div>
</div>
<!-- Footer information / 底部信息 -->
<div class="certificate-footer">
<div class="footer-row">
@@ -252,9 +269,11 @@
<div class="remarks-title">Remarks:</div>
<!-- 备注 -->
<div class="remarks-content">
<p>1. It is certified herein that the products have been produced and tested according to above standards and the products are all qualified.</p>
<p>1. It is certified herein that the products have been produced and tested according to above standards and
the products are all qualified.</p>
<!-- 本产品已按上述标准生产和检验,其结果符合要求,特此证明 -->
<p>2. The original quality certificate is the basis for acceptance, copies are for reference only and not to be used as acceptance vouchers.</p>
<p>2. The original quality certificate is the basis for acceptance, copies are for reference only and not to
be used as acceptance vouchers.</p>
<!-- 质量证明书原件是验收依据,复印件仅供参考,不作为验收凭证 -->
</div>
</div>
@@ -273,9 +292,16 @@
<script>
import printJS from 'print-js';
import dayjs from 'dayjs';
// 1. 导入 dom-to-image 库
import domtoimage from 'dom-to-image';
// 获取生产过程曲线数据
import ParamEcharts from './ParamEcharts.vue'
export default {
name: 'QualityCertificate',
components: {
ParamEcharts
},
props: {
// Single coil detail / 单卷详情
detail: {
@@ -301,6 +327,12 @@ export default {
deep: true
}
},
computed: {
getEnCoilID() {
// 优先从detail中取有效线圈ID与后端接口保持一致
return this.detail.enCoilID || this.detail.exitMatId || this.detail.coilid || '';
}
},
methods: {
// Initialize certificate data for single coil / 初始化单卷质保书数据
initCertificateData(detail) {
@@ -388,32 +420,46 @@ export default {
return num.toFixed(2);
},
// Handle print / 处理打印
handlePrint() {
const printStyle = `
@page {
size: A4 landscape;
margin: 10mm;
// 2. 重构打印方法DOM -> 高清图片 -> 打印
async handlePrint() {
try {
// 获取打印目标DOM元素
const printElement = document.getElementById('qualityCertificateContent');
if (!printElement) {
this.$message?.error('未找到质保书打印内容');
return;
}
.certificate-content {
width: 100%;
font-family: Arial, sans-serif;
}
`;
printJS({
printable: 'qualityCertificateContent',
type: 'html',
header: null,
footer: null,
style: printStyle,
scanStyles: true,
targetStyles: ['*'],
documentTitle: 'Quality Certificate',
onPrintDialogClose: () => {
console.log('Print dialog closed');
}
});
// 将DOM转为高清PNG图片base64格式解决虚化问题
const imageBase64 = await domtoimage.toPng(printElement, {
backgroundColor: '#ffffff', // 强制白色背景,避免透明区域
scale: 4, // 缩放倍数3-4最佳越大越清晰文件体积略有增加
quality: 1.0 // 图片质量拉满
});
// 调用printJS打印图片适配A4横向布局
printJS({
printable: imageBase64, // 传入base64图片
type: 'image', // 打印类型改为image关键变更
header: null,
footer: null,
documentTitle: 'Quality Certificate',
// 配置图片打印样式适配A4横向防止变形
imageStyle: `
width: 100%;
max-width: 297mm; /* A4横向宽度297mm×210mm */
height: auto;
object-fit: contain;
`,
onPrintDialogClose: () => {
console.log('Print dialog closed');
}
});
} catch (error) {
// 捕获转换或打印异常,提升用户体验
console.error('质保书打印失败:', error);
this.$message?.error('打印失败,请重试');
}
}
}
};
@@ -593,50 +639,56 @@ export default {
margin-top: 20px;
}
/* Print styles / 打印样式 */
/* 生产过程曲线区域样式 */
.certificate-charts {
margin: 20px 0;
padding: 10px;
border: 1px solid #e8e8e8;
border-radius: 8px;
}
.charts-title {
font-size: 14px;
font-weight: bold;
color: #333;
text-align: center;
margin-bottom: 15px;
padding-bottom: 8px;
border-bottom: 1px solid #e8e8e8;
}
.charts-wrapper {
display: flex;
gap: 20px;
width: 100%;
height: 300px; /* 固定图表区域高度确保ECharts正常渲染 */
}
.chart-item {
flex: 1; /* 两个图表平分宽度 */
height: 100%;
display: flex;
flex-direction: column;
}
.chart-subtitle {
font-size: 12px;
color: #666;
text-align: center;
margin-bottom: 8px;
font-weight: 500;
}
/* Print styles / 打印样式仅隐藏按钮图片打印样式已在printJS中配置 */
@media print {
.print-btn-container {
display: none;
}
.quality-certificate-container {
padding: 0;
background: #fff;
}
.certificate-content {
font-size: 10px;
page-break-inside: avoid;
}
.certificate-header {
page-break-after: avoid;
}
.certificate-table {
page-break-inside: auto;
}
.data-table {
font-size: 8px;
}
.data-table th,
.data-table td {
padding: 3px 2px;
}
.data-table tbody tr {
page-break-inside: avoid;
}
.certificate-footer {
page-break-before: avoid;
}
.certificate-remarks {
page-break-inside: avoid;
}
}
</style>
</style>

View File

@@ -17,12 +17,11 @@
'count-3': checkedCount === 3,
'count-4': checkedCount === 4
}" :style="{ height: '100%', width: '100%' }">
<!-- 预生成4个固定图表容器初始隐藏 -->
<div v-for="(chartKey, index) in presetChartKeys" :key="chartKey" class="preset-chart-item" :style="{
display: index < checkedCount ? 'block' : 'none', // 根据勾选数量显示对应图表
}" v-loading="getChartLoading(index)">
<div class="chart-title">{{ getChartTitle(index) }}</div>
<div :ref="chartKey" class="chart-content"></div>
<!-- 替换为封装后的 ECharts 组件循环渲染 -->
<div v-for="(param, index) in checkedNodes" :key="param.value" class="preset-chart-item"
:style="{ display: index < maxCheckCount ? 'block' : 'none' }">
<!-- 传入 enCoilID paramField param.value -->
<ParamEcharts :enCoilID="enCoilID" :paramField="param.value" />
</div>
<!-- 无勾选时显示提示 -->
@@ -36,13 +35,11 @@
</template>
<script>
import * as echarts from 'echarts'
import { getSegmentList } from '@/api/business/segment'
// 引入Vue用于Vue.set实现响应式赋值Vue2必备
import Vue from 'vue'
import ParamEcharts from './ParamEcharts.vue' // 引入封装后的 ECharts 组件
export default {
name: 'DeviceMonitoring',
components: { ParamEcharts }, // 注册组件
props: {
enCoilID: {
type: String,
@@ -79,84 +76,28 @@ export default {
},
checkedNodes: [], // 已勾选参数列表
checkedCount: 0, // 已勾选数量(用于布局控制)
presetChartKeys: ['chart1', 'chart2', 'chart3', 'chart4'], // 4个固定图表key
chartInstances: {}, // 4个固定图表实例 { chart1: echartsInstance, ... }
chartDataMap: {}, // 参数数据映射 { paramValue: { timeStamps: [], chartData: [], ... } }
statMap: {}, // 参数统计值映射 { paramValue: { latestValue: 0, ... } }
loadingMap: {}, // 参数加载状态映射 { paramValue: boolean }
maxXAxisLabels: 40,
maxCheckCount: 4
maxCheckCount: 4 // 最大勾选数量
}
},
watch: {
// 监听线圈ID变化无需手动更新图表子组件已自行监听
enCoilID: {
handler(newVal) {
console.log('enCoilID changed:', newVal);
if (newVal) {
// 批量更新所有已勾选参数的图表
this.checkedNodes.forEach((param, index) => {
if (index < this.presetChartKeys.length) {
this.fetchChartData(param.value, index);
}
});
} else {
// 清空所有图表并关闭对应loading
this.checkedNodes.forEach((param, index) => {
const chartKey = this.presetChartKeys[index];
this.drawEmptyChart(chartKey);
// 强制关闭loading
Vue.set(this.loadingMap, param.value, false);
});
}
},
immediate: true
},
// 监听勾选数量变化,触发布局更新(可选,确保响应式)
// 监听勾选数量变化,触发布局更新
checkedCount: {
handler() {
this.$nextTick(() => {
// 窗口resize强制刷新图表尺寸
this.handleResize();
// 窗口resize强制刷新可选子组件已监听resize此处为兜底
window.dispatchEvent(new Event('resize'))
});
}
}
},
mounted() {
// 初始化4个固定图表实例预生成隐藏状态
this.initPresetCharts();
// 监听窗口resize
window.addEventListener('resize', this.handleResize);
},
beforeDestroy() {
// 销毁4个固定图表实例
Object.values(this.chartInstances).forEach(chart => {
if (chart && chart.dispose) {
chart.dispose();
}
});
window.removeEventListener('resize', this.handleResize);
},
methods: {
// 初始化4个固定图表
initPresetCharts() {
this.$nextTick(() => {
this.presetChartKeys.forEach(chartKey => {
// v-for中的ref返回数组取第0项单个DOM元素
const chartDomArr = this.$refs[chartKey];
// 严格校验DOM是否存在避免传入无效值
const chartDom = chartDomArr && chartDomArr.length > 0 ? chartDomArr[0] : null;
if (chartDom) {
// 创建实例并存储
this.chartInstances[chartKey] = echarts.init(chartDom);
// 初始绘制空图表(隐藏状态,不显示)
this.drawEmptyChart(chartKey);
} else {
console.warn(`图表${chartKey} DOM元素未找到初始化失败`);
}
});
});
},
// 处理树形节点勾选限制最多4个
handleCheckChange() {
const checkedNodes = this.$refs.paramTree.getCheckedNodes();
@@ -170,367 +111,9 @@ export default {
return;
}
// 先关闭所有旧勾选参数的loading
this.checkedNodes.forEach(param => {
Vue.set(this.loadingMap, param.value, false);
});
// 更新勾选列表和数量
this.checkedNodes = this.$refs.paramTree.getCheckedNodes();
this.checkedCount = this.checkedNodes.length;
// 批量更新图表仅setOption不重新创建实例
this.checkedNodes.forEach((param, index) => {
if (index < this.presetChartKeys.length) {
const chartKey = this.presetChartKeys[index];
if (this.enCoilID) {
this.fetchChartData(param.value, index);
} else {
this.drawEmptyChart(chartKey);
// 确保关闭loading
Vue.set(this.loadingMap, param.value, false);
}
}
});
// 隐藏未用到的图表已通过display控制此处可选
for (let i = this.checkedCount; i < this.presetChartKeys.length; i++) {
const chartKey = this.presetChartKeys[i];
if (this.chartInstances[chartKey] && this.chartInstances[chartKey].clear) {
this.chartInstances[chartKey].clear();
}
}
},
// 获取对应索引图表的加载状态
getChartLoading(index) {
// 增加兜底校验避免index越界导致返回undefinedv-loading需要布尔值
if (index >= this.checkedNodes.length || !this.checkedNodes[index]?.value) {
return false;
}
const paramValue = this.checkedNodes[index].value;
// 兜底若loadingMap中无该属性返回false
return this.loadingMap[paramValue] || false;
},
// 获取对应索引图表的标题
getChartTitle(index) {
if (index >= this.checkedNodes.length) return '';
return this.checkedNodes[index].label || '';
},
// 窗口resize调整所有图表尺寸
handleResize() {
Object.values(this.chartInstances).forEach(chart => {
if (chart && chart.resize) {
chart.resize();
}
});
},
// 获取单个参数数据并更新对应图表【核心修复loading响应式赋值】
fetchChartData(paramValue, chartIndex) {
// 前置校验参数无效直接关闭loading并绘制无数据图表
if (!this.enCoilID || !paramValue || chartIndex >= this.presetChartKeys.length) {
const chartKey = this.presetChartKeys[chartIndex];
this.drawNoDataChart(chartKey);
// 强制关闭loading
Vue.set(this.loadingMap, paramValue, false);
return;
}
console.log(`请求参数: enCoilID=${this.enCoilID}, paramField=${paramValue}`);
// 修复1使用Vue.set实现响应式赋值让视图感知状态变化
Vue.set(this.loadingMap, paramValue, true);
getSegmentList({
enCoilID: this.enCoilID,
paramField: paramValue
}).then(res => {
const chartKey = this.presetChartKeys[chartIndex];
if (res.data && res.data.length > 0) {
// 处理数据
const { timeStamps, chartData, originalTimeStamps, originalChartData, stat } = this.processChartData(res.data);
this.chartDataMap[paramValue] = {
timeStamps,
chartData,
originalTimeStamps,
originalChartData
};
this.statMap[paramValue] = stat;
// 仅setOption更新图表不重新初始化
console.log(res)
this.drawLineChart(chartKey, paramValue);
} else {
this.drawNoDataChart(chartKey);
}
}).catch(err => {
console.error(`获取${paramValue}数据失败:`, err);
const chartKey = this.presetChartKeys[chartIndex];
this.drawErrorChart(chartKey);
}).finally(() => {
console.log(`完成${paramValue}数据获取, loading关闭`);
// 修复2finally中同样使用Vue.set确保无论成功失败都关闭loading
Vue.set(this.loadingMap, paramValue, false);
});
},
// 处理图表数据
processChartData(rawData) {
rawData.sort((a, b) => a.segNo - b.segNo);
const originalTimeStamps = rawData.map(item => item.segNo);
const originalChartData = rawData.map(item => parseFloat(item.value));
let timeStamps = originalTimeStamps;
let chartData = originalChartData;
// 可选:数据采样逻辑
// if (rawData.length > this.maxXAxisLabels) {
// const sampleInterval = Math.ceil(rawData.length / this.maxXAxisLabels);
// timeStamps = [];
// chartData = [];
// for (let i = 0; i < rawData.length; i += sampleInterval) {
// timeStamps.push(rawData[i].segNo);
// chartData.push(parseFloat(rawData[i].value));
// }
// if (timeStamps[timeStamps.length - 1] !== rawData[rawData.length - 1].segNo) {
// timeStamps.push(rawData[rawData.length - 1].segNo);
// chartData.push(parseFloat(rawData[rawData.length - 1].value));
// }
// }
let stat = { latestValue: 0, maxValue: 0, minValue: 0, avgValue: 0 };
if (originalChartData.length) {
stat.latestValue = originalChartData[originalChartData.length - 1];
stat.maxValue = Math.max(...originalChartData);
stat.minValue = Math.min(...originalChartData);
stat.avgValue = originalChartData.reduce((sum, val) => sum + val, 0) / originalChartData.length;
}
return { timeStamps, chartData, originalTimeStamps, originalChartData, stat };
},
// 绘制折线图仅setOption
drawLineChart(chartKey, paramValue) {
const chart = this.chartInstances[chartKey];
const chartDataObj = this.chartDataMap[paramValue];
const stat = this.statMap[paramValue];
const param = this.paramFields.find(item => item.value === paramValue);
if (!chart || !chartDataObj) return;
const option = {
backgroundColor: 'transparent',
graphic: [],
tooltip: {
trigger: 'axis',
axisPointer: { type: 'line' },
backgroundColor: 'rgba(255, 255, 255, 0.95)',
borderColor: '#d4d4d4',
borderWidth: 1,
textStyle: { color: '#333' },
formatter: (params) => {
const paramItem = params[0];
return `
Sequence: ${paramItem.name}<br>
Value: ${paramItem.value.toFixed(2)}<br>
Unit: ${this.getParamUnit(paramValue)}
`;
}
},
grid: {
left: '3%',
right: '4%',
bottom: '10%',
top: '15%',
containLabel: true
},
xAxis: {
type: 'category',
boundaryGap: false,
data: chartDataObj.timeStamps,
axisLine: { lineStyle: { color: '#d4d4d4' } },
axisLabel: {
color: '#666',
rotate: 45,
interval: (index) => {
return index % Math.max(1, Math.ceil(chartDataObj.timeStamps.length / this.maxXAxisLabels)) === 0;
}
},
splitLine: { show: false }
},
yAxis: {
type: 'value',
name: this.getParamUnit(paramValue),
nameLocation: 'middle',
nameGap: 30,
nameTextStyle: { color: '#666' },
axisLine: { lineStyle: { color: '#d4d4d4' } },
axisLabel: { color: '#666' },
splitLine: {
lineStyle: { color: '#e8e8e8', type: 'dashed' }
}
},
series: [
{
name: param?.label || paramValue,
type: 'line',
data: chartDataObj.chartData,
smooth: true,
symbol: 'circle',
symbolSize: 4,
lineStyle: { color: '#666', width: 2 },
itemStyle: { color: '#999' },
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(153, 153, 153, 0.3)' },
{ offset: 1, color: 'rgba(153, 153, 153, 0.05)' }
]
}
},
markPoint: {
data: [{ type: 'max', name: 'Max' }, { type: 'min', name: 'Min' }],
itemStyle: { color: '#999' },
label: { color: '#333' }
},
markLine: {
data: [{ type: 'average', name: 'Average' }],
lineStyle: { color: '#999', type: 'dashed' },
label: { color: '#333' }
}
}
]
};
chart.setOption(option, true);
},
// 绘制空图表
drawEmptyChart(chartKey) {
const chart = this.chartInstances[chartKey];
if (!chart) return;
const option = {
graphic: {
elements: [
{
type: 'text',
left: 'center',
top: 'center',
style: {
text: 'Please select performance',
fontSize: 16,
fontWeight: 'bold',
fill: '#999'
}
}
]
},
xAxis: { type: 'category', data: [] },
yAxis: { type: 'value' },
series: []
};
chart.setOption(option);
},
// 绘制无数据图表
drawNoDataChart(chartKey) {
const chart = this.chartInstances[chartKey];
if (!chart) return;
const option = {
graphic: {
elements: [
{
type: 'text',
left: 'center',
top: 'center',
style: {
text: 'No data',
fontSize: 16,
fontWeight: 'bold',
fill: '#999'
}
}
]
},
xAxis: { type: 'category', data: [] },
yAxis: { type: 'value' },
series: []
};
chart.setOption(option);
},
// 绘制错误图表
drawErrorChart(chartKey) {
const chart = this.chartInstances[chartKey];
if (!chart) return;
const option = {
graphic: {
elements: [
{
type: 'text',
left: 'center',
top: 'center',
style: {
text: 'Data loading failed',
fontSize: 16,
fontWeight: 'bold',
fill: '#f56c6c'
}
}
]
},
xAxis: { type: 'category', data: [] },
yAxis: { type: 'value' },
series: []
};
chart.setOption(option);
},
// 获取参数单位
getParamUnit(paramValue) {
switch (paramValue) {
case 'stripSpeed':
case 'stripSpeedTmExit':
return 'm/s';
case 'tensionPorBr1':
case 'tensionPorBr2':
case 'tensionBr5Tm':
case 'tensionTlBr7':
return 'N';
case 'cleaningVoltage':
return 'V';
case 'cleaningCurrent':
return 'A';
case 'alkaliConcentration':
case 'tlElongation':
return '%';
case 'alkaliTemperature':
case 'phfExitStripTemp':
case 'rtfExitStripTemp':
case 'jcsExitStripTemp':
case 'scsExitStripTemp':
case 'potTemperature':
case 'coolingTowerStripTemp':
return '°C';
case 'zincPotPower':
return 'kW';
case 'gasConsumption':
return 'm³/h';
default:
return '';
}
}
}
}
@@ -538,7 +121,7 @@ export default {
<style lang="scss" scoped>
.monitoring-container {
height: 100%; /* 根容器高度100%可根据父容器调整为100% */
height: 100%;
box-sizing: border-box;
}
@@ -588,30 +171,12 @@ export default {
}
}
/* 图表项通用样式 */
/* 图表项容器样式 */
.preset-chart-item {
position: relative;
border: 1px solid #e8e8e8;
border-radius: 8px;
padding: 10px;
box-sizing: border-box;
}
.chart-title {
font-size: 14px;
font-weight: 500;
color: #333;
margin-bottom: 8px;
text-align: center;
}
.chart-content {
width: 100%;
height: calc(100% - 24px); /* 减去标题高度 */
box-sizing: border-box;
min-height: 180px; /* 兜底高度确保ECharts初始化有尺寸 */
}
/* 无勾选提示 */
.no-param-tip {
width: 100%;

View File

@@ -23,8 +23,8 @@
<el-table-column label="Device" prop="deviceName" />
<el-table-column label="Status" prop="status" />
<el-table-column label="Create Time" prop="createTime" />
<el-table-column label="Finish Time" prop="finishTime" />
<el-table-column label="Action" fixed="right">
<el-table-column label="Finish Time" prop="finishTime" width="170" show-overflow-tooltip />
<el-table-column label="Action">
<template slot-scope="scope">
<el-button type="text" size="mini" @click="apply(scope.row)">Apply</el-button>
<el-button type="text" size="mini" @click="openDetail(scope.row.jobId)">Detail</el-button>

View File

@@ -1,14 +1,13 @@
<template>
<!-- 炉火参数表单 -->
<div class="app-container">
<h3>炉火实时参数</h3>
<!-- flex布局容器开启自动换行 -->
<div class="params-list">
<!-- 遍历驱动数据的所有键值对 -->
<!-- 遍历筛选后的驱动数据键值对 -->
<div
v-for="[key, value] in Object.entries(driveData)"
v-for="[key, value] in filteredDriveData"
:key="key"
:class="['param-item', { blink: changedKeys.includes(key) }]"
:class="['param-item', { blink: blinkKeyMap[key] }]"
>
<!-- 上方label -->
<span class="param-label">{{ formatLabel(key) }}</span>
@@ -31,7 +30,20 @@ export default {
data() {
return {
prevDriveData: {}, // 存储上一次的驱动数据,用于对比变化
changedKeys: [] // 存储当前变化的属性名,用于控制闪烁样式
blinkKeyMap: {}, // 每个key独立的闪烁状态对象形式{ key1: true, key2: false }
timerMap: {} // 每个key独立的定时器缓存用于清除旧定时器
}
},
// 筛选包含Actual不区分大小写的键值对
computed: {
filteredDriveData() {
// 校验
if (!this.driveData || typeof this.driveData !== 'object') {
return [];
}
return Object.entries(this.driveData).filter(([key]) => {
return key.toLowerCase().includes('actual');
});
}
},
watch: {
@@ -44,33 +56,39 @@ export default {
}
// 对比新值和旧值,找出变化的属性名
const changedKeys = [];
Object.entries(newVal).forEach(([key, value]) => {
// 排除首次不存在的属性,仅对比已有属性的变化
if (this.prevDriveData.hasOwnProperty(key)) {
// 由于是数字类型,直接对比值是否不同
// 仅对比已有属性的变化且仅处理筛选后的key包含Actual
if (this.prevDriveData.hasOwnProperty(key) && key.toLowerCase().includes('actual')) {
if (this.prevDriveData[key] !== value) {
changedKeys.push(key);
this.triggerBlink(key); // 为每个变化的key单独触发闪烁
}
}
});
if (changedKeys.length > 0) {
this.changedKeys = changedKeys; // 设置变化的属性,触发闪烁
// 1秒后清空变化的属性移除闪烁样式
setTimeout(() => {
this.changedKeys = [];
}, 1000);
}
// 更新缓存的旧数据
this.prevDriveData = JSON.parse(JSON.stringify(newVal));
console.log('driveData updated:', newVal, 'changed keys:', changedKeys);
},
deep: true
}
},
methods: {
// 单独触发某个key的闪烁核心优化独立控制
triggerBlink(key) {
// 清除该key的旧定时器避免1秒内多次更新导致定时器叠加
if (this.timerMap[key]) {
clearTimeout(this.timerMap[key]);
}
// 设置当前key为闪烁状态触发动画
this.$set(this.blinkKeyMap, key, true);
// 单独为该key设置定时器到期后关闭闪烁状态
this.timerMap[key] = setTimeout(() => {
this.$set(this.blinkKeyMap, key, false);
// 定时器执行后清除缓存的定时器ID
delete this.timerMap[key];
}, 500); // 闪烁时长可自定义1秒
},
// 格式化标签名(将驼峰命名转为中文式分段,提升可读性)
formatLabel(key) {
if (!key) return '';
@@ -86,6 +104,12 @@ export default {
}
return value;
}
},
// 组件销毁时清除所有定时器,避免内存泄漏
beforeUnmount() {
Object.values(this.timerMap).forEach(timer => {
clearTimeout(timer);
});
}
}
</script>
@@ -109,7 +133,7 @@ export default {
width: 200px; /* 固定宽度,可根据需求调整 */
padding: 12px 16px;
background-color: #ffffff;
border: 1px solid #e6e6e6;
border: 2px solid #e6e6e6;
border-radius: 6px;
transition: all 0.3s ease;
display: flex; /* 内部flex垂直布局 */
@@ -131,7 +155,7 @@ export default {
font-size: 15px;
}
/* 闪烁动画定义 */
/* 闪烁动画定义(保留原有动画效果,优化触发逻辑) */
@keyframes borderBlink {
0% {
border-color: #4cd964; /* 初始绿色 */
@@ -147,9 +171,9 @@ export default {
}
}
/* 闪烁样式 */
/* 闪烁样式每次blink类添加时动画重新触发 */
.blink {
border: 2px solid #4cd964; /* 绿色边框 */
animation: borderBlink 1s ease-in-out; /* 执行闪烁动画 */
animation: borderBlink 0.5s ease-in-out 1; /* 强制执行1次动画避免叠加 */
}
</style>