写入功能完成
This commit is contained in:
@@ -34,3 +34,12 @@ export function updateSendTemplateItems(data) {
|
|||||||
data
|
data
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 批量保存模板明细(新增/更新/删除)- 仅提交变更,避免请求体过大
|
||||||
|
export function batchSaveSendTemplateItems(data) {
|
||||||
|
return request({
|
||||||
|
url: '/business/sendTemplate/items/batchSave',
|
||||||
|
method: 'put',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
138
src/views/l2/send/components/FurnaceHistoryPanel.vue
Normal file
138
src/views/l2/send/components/FurnaceHistoryPanel.vue
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
<template>
|
||||||
|
<div class="fh-root">
|
||||||
|
<!-- Query -->
|
||||||
|
<el-form :model="query" inline size="mini" class="fh-toolbar">
|
||||||
|
<el-form-item label="Device">
|
||||||
|
<el-input v-model="query.deviceName" placeholder="Device name" clearable />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="Status">
|
||||||
|
<el-select v-model="query.status" placeholder="Status" clearable style="width: 160px">
|
||||||
|
<el-option label="COMPLETED" value="COMPLETED" />
|
||||||
|
<el-option label="PARTIAL_SUCCESS" value="PARTIAL_SUCCESS" />
|
||||||
|
<el-option label="FAILED" value="FAILED" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" icon="el-icon-search" @click="handleQuery">Search</el-button>
|
||||||
|
<el-button icon="el-icon-refresh" @click="resetQuery">Reset</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<el-table v-loading="loading" :data="list" border size="mini" height="380">
|
||||||
|
<el-table-column label="Job ID" prop="jobId" width="90" />
|
||||||
|
<el-table-column label="Device" prop="deviceName" width="140" />
|
||||||
|
<el-table-column label="Status" prop="status" width="140" />
|
||||||
|
<el-table-column label="Create Time" prop="createTime" width="170" />
|
||||||
|
<el-table-column label="Finish Time" prop="finishTime" width="170" />
|
||||||
|
<el-table-column label="Action" width="150" fixed="right">
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<pagination
|
||||||
|
v-show="total>0"
|
||||||
|
:total="total"
|
||||||
|
:page.sync="query.pageNum"
|
||||||
|
:limit.sync="query.pageSize"
|
||||||
|
@pagination="getList"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Detail dialog -->
|
||||||
|
<el-dialog title="Send Detail" :visible.sync="detailVisible" width="90%" append-to-body>
|
||||||
|
<div v-if="detail">
|
||||||
|
<el-tabs type="border-card">
|
||||||
|
<el-tab-pane
|
||||||
|
v-for="(g, idx) in (detail.groups || [])"
|
||||||
|
:key="g.groupId || idx"
|
||||||
|
:label="(g.groupName || g.groupType || ('Group ' + (idx+1)))"
|
||||||
|
>
|
||||||
|
<el-table :data="g.items || []" border size="small">
|
||||||
|
<el-table-column label="Param" prop="paramCode" width="180" />
|
||||||
|
<el-table-column label="Address" prop="address" min-width="320" />
|
||||||
|
<el-table-column label="Value" prop="valueRaw" width="160" />
|
||||||
|
<el-table-column label="Result" prop="resultStatus" width="120" />
|
||||||
|
<el-table-column label="Message" prop="resultMsg" min-width="180" />
|
||||||
|
<el-table-column label="Update Time" prop="updateTime" width="170" />
|
||||||
|
</el-table>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
</div>
|
||||||
|
<div slot="footer" class="dialog-footer">
|
||||||
|
<el-button @click="detailVisible=false">Close</el-button>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { listSendJob, getSendJob } from '@/api/l2/sendJob'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'FurnaceHistoryPanel',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
loading: false,
|
||||||
|
list: [],
|
||||||
|
total: 0,
|
||||||
|
query: {
|
||||||
|
pageNum: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
deviceName: '',
|
||||||
|
status: 'COMPLETED',
|
||||||
|
groupType: 'FURNACE'
|
||||||
|
},
|
||||||
|
detailVisible: false,
|
||||||
|
detail: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.getList()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
handleQuery() {
|
||||||
|
this.query.pageNum = 1
|
||||||
|
this.getList()
|
||||||
|
},
|
||||||
|
resetQuery() {
|
||||||
|
this.query = { pageNum: 1, pageSize: 10, deviceName: '', status: 'COMPLETED', groupType: 'FURNACE' }
|
||||||
|
this.getList()
|
||||||
|
},
|
||||||
|
async getList() {
|
||||||
|
this.loading = true
|
||||||
|
try {
|
||||||
|
const res = await listSendJob(this.query)
|
||||||
|
this.list = res.rows || []
|
||||||
|
this.total = res.total || 0
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async openDetail(jobId) {
|
||||||
|
const res = await getSendJob(jobId)
|
||||||
|
this.detail = res.data
|
||||||
|
this.detailVisible = true
|
||||||
|
},
|
||||||
|
async apply(row) {
|
||||||
|
const res = await getSendJob(row.jobId)
|
||||||
|
const detail = res.data
|
||||||
|
const values = {}
|
||||||
|
;(detail.groups || []).forEach(g => {
|
||||||
|
if ((g.groupType || '').toUpperCase() !== 'FURNACE') return
|
||||||
|
;(g.items || []).forEach(it => {
|
||||||
|
if (it.paramCode) values[it.paramCode] = it.valueRaw
|
||||||
|
})
|
||||||
|
})
|
||||||
|
this.$emit('apply', { jobId: row.jobId, values })
|
||||||
|
this.$message.success('Values applied')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fh-toolbar { margin-bottom: 10px; display:flex; flex-wrap:wrap; gap:8px; align-items:center; }
|
||||||
|
</style>
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<!-- Plan-driven editable form / 按计划参数渲染可编辑表单(不使用模板) -->
|
<!-- 可编辑表单:值 + OPC点位(点位可先为空,后续协商配置) -->
|
||||||
<el-form :model="plan.params" label-position="top" size="mini">
|
<el-form :model="plan.params" label-position="top" size="mini">
|
||||||
<el-row :gutter="10">
|
<el-row :gutter="10">
|
||||||
<el-col
|
<el-col
|
||||||
@@ -65,6 +65,14 @@
|
|||||||
v-model="plan.params[item.key]"
|
v-model="plan.params[item.key]"
|
||||||
:placeholder="getPlaceholder(item.key)"
|
:placeholder="getPlaceholder(item.key)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- OPC address input / OPC点位输入框(可先不填) -->
|
||||||
|
<el-input
|
||||||
|
v-model="driveAddress[item.key]"
|
||||||
|
size="mini"
|
||||||
|
class="addr-input"
|
||||||
|
placeholder="OPC address (optional)"
|
||||||
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
@@ -90,34 +98,52 @@ import { getLastSuccess } from '@/api/l2/sendTemplate'
|
|||||||
|
|
||||||
// Drive fields definition (English UI, Chinese comments) / 传动字段定义(英文界面,中文注释)
|
// Drive fields definition (English UI, Chinese comments) / 传动字段定义(英文界面,中文注释)
|
||||||
// 说明:key 必须与 setupForm 字段一致(来自 plan/components/setupForm.vue)
|
// 说明:key 必须与 setupForm 字段一致(来自 plan/components/setupForm.vue)
|
||||||
|
// Drive + Plan fields definition (show effect first; OPC address can be edited later)
|
||||||
|
// 传动 + 计划字段定义(先把效果做出来;OPC点位后续可协商配置)
|
||||||
const DRIVE_FIELDS = [
|
const DRIVE_FIELDS = [
|
||||||
{ key: 'porTension', label: 'Pay-off Reel Tension' },
|
// ---- Drive tension / 传动张力 ----
|
||||||
{ key: 'celTension', label: 'Entry Loop Tension' },
|
{ key: 'porTension', label: 'Pay-off Reel Tension', source: 'setup' },
|
||||||
{ key: 'cleanTension', label: 'Cleaning Section Tension' },
|
{ key: 'celTension', label: 'Entry Loop Tension', source: 'setup' },
|
||||||
{ key: 'furTension', label: 'Furnace Zone Tension' },
|
{ key: 'cleanTension', label: 'Cleaning Section Tension', source: 'setup' },
|
||||||
{ key: 'towerTension', label: 'Cooling Tower Tension' },
|
{ key: 'furTension', label: 'Furnace Zone Tension', source: 'setup' },
|
||||||
{ key: 'tmNoneTension', label: 'TM No Tension' },
|
{ key: 'towerTension', label: 'Cooling Tower Tension', source: 'setup' },
|
||||||
{ key: 'tmEntryTension', label: 'TM Entry Tension' },
|
{ key: 'tmNoneTension', label: 'TM No Tension', source: 'setup' },
|
||||||
{ key: 'tmExitTension', label: 'TM Exit Tension' },
|
{ key: 'tmEntryTension', label: 'TM Entry Tension', source: 'setup' },
|
||||||
{ key: 'tlNoneTension', label: 'TL No Tension' },
|
{ key: 'tmExitTension', label: 'TM Exit Tension', source: 'setup' },
|
||||||
{ key: 'tlExitTension', label: 'TL Exit Tension' },
|
{ key: 'tlNoneTension', label: 'TL No Tension', source: 'setup' },
|
||||||
{ key: 'coatTension', label: 'Post-treatment Tension' },
|
{ key: 'tlExitTension', label: 'TL Exit Tension', source: 'setup' },
|
||||||
{ key: 'cxlTension', label: 'Exit Loop Tension' },
|
{ key: 'coatTension', label: 'Post-treatment Tension', source: 'setup' },
|
||||||
{ key: 'trTension', label: 'Take-up Reel Tension' },
|
{ key: 'cxlTension', label: 'Exit Loop Tension', source: 'setup' },
|
||||||
|
{ key: 'trTension', label: 'Take-up Reel Tension', source: 'setup' },
|
||||||
|
|
||||||
{ key: 'tlElong', label: 'TL Elongation' },
|
// ---- TL / TM setup ----
|
||||||
{ key: 'tlLvlMesh1', label: 'TL Leveling Roll Mesh 1' },
|
{ key: 'tlElong', label: 'TL Elongation', source: 'setup' },
|
||||||
{ key: 'tlLvlMesh2', label: 'TL Leveling Roll Mesh 2' },
|
{ key: 'tlLvlMesh1', label: 'TL Leveling Roll Mesh 1', source: 'setup' },
|
||||||
{ key: 'tlAcbMesh', label: 'TL Anti-crossbow Mesh' },
|
{ key: 'tlLvlMesh2', label: 'TL Leveling Roll Mesh 2', source: 'setup' },
|
||||||
|
{ key: 'tlAcbMesh', label: 'TL Anti-crossbow Mesh', source: 'setup' },
|
||||||
|
|
||||||
{ key: 'tmBendforce', label: 'TM Bending Force' },
|
{ key: 'tmBendforce', label: 'TM Bending Force', source: 'setup' },
|
||||||
{ key: 'tmAcrMesh', label: 'TM Anti-crimping Roll Mesh' },
|
{ key: 'tmAcrMesh', label: 'TM Anti-crimping Roll Mesh', source: 'setup' },
|
||||||
{ key: 'tmBrMesh', label: 'TM Anti-tremor Roll Mesh' },
|
{ key: 'tmBrMesh', label: 'TM Anti-tremor Roll Mesh', source: 'setup' },
|
||||||
{ key: 'tmRollforce', label: 'TM Roll Force' }
|
{ key: 'tmRollforce', label: 'TM Roll Force', source: 'setup' },
|
||||||
|
|
||||||
|
// ---- Plan (from listPlan response) / 计划参数(来自 listPlan 返回)----
|
||||||
|
{ key: 'entryWidth', label: 'Entry Width', source: 'plan' },
|
||||||
|
{ key: 'entryThick', label: 'Entry Thick', source: 'plan' },
|
||||||
|
{ key: 'entryWeight', label: 'Entry Weight', source: 'plan' },
|
||||||
|
{ key: 'entryLength', label: 'Entry Length', source: 'plan' },
|
||||||
|
|
||||||
|
{ key: 'steelGrade', label: 'Steel Grade', source: 'plan' },
|
||||||
|
|
||||||
|
{ key: 'spmElongation', label: 'SPM Elongation', source: 'plan' },
|
||||||
|
{ key: 'spmRollforce', label: 'SPM Roll Force', source: 'plan' },
|
||||||
|
{ key: 'spmBendingForce', label: 'SPM Bending Force', source: 'plan' },
|
||||||
|
|
||||||
|
{ key: 'yieldPoint', label: 'Yield Point', source: 'plan' }
|
||||||
]
|
]
|
||||||
|
|
||||||
// OPC address mapping (must align with back-end OpcMessageIdsManager.pdiSetupIds) / OPC点位映射(需与后端一致)
|
// OPC address mapping / OPC点位映射
|
||||||
// 中文注释:这里用“字段名->OPC地址”的方式直接组装发送items
|
// 说明:此处后续可协商配置;当前允许在页面上编辑(默认可为空)
|
||||||
const DRIVE_ADDRESS = {
|
const DRIVE_ADDRESS = {
|
||||||
porTension: 'ns=2;s=ProcessCGL.PLCLine.L2Setup.tensionPorBR1',
|
porTension: 'ns=2;s=ProcessCGL.PLCLine.L2Setup.tensionPorBR1',
|
||||||
celTension: 'ns=2;s=ProcessCGL.PLCLine.L2Setup.tensionBR3',
|
celTension: 'ns=2;s=ProcessCGL.PLCLine.L2Setup.tensionBR3',
|
||||||
@@ -151,7 +177,9 @@ export default {
|
|||||||
loading: false,
|
loading: false,
|
||||||
lastSuccess: null,
|
lastSuccess: null,
|
||||||
plans: [],
|
plans: [],
|
||||||
driveFields: DRIVE_FIELDS
|
driveFields: DRIVE_FIELDS,
|
||||||
|
// 可编辑的 OPC 点位(默认从常量拷贝;你也可以后续改成从后端/本地存储加载)
|
||||||
|
driveAddress: { ...DRIVE_ADDRESS }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
created() {
|
created() {
|
||||||
@@ -167,7 +195,8 @@ export default {
|
|||||||
|
|
||||||
// plans / 获取计划
|
// plans / 获取计划
|
||||||
const planRes = await listPlan({ status: 'NEW,READY,ONLINE,PRODUCING' })
|
const planRes = await listPlan({ status: 'NEW,READY,ONLINE,PRODUCING' })
|
||||||
const planList = planRes.rows || []
|
// 兼容后端返回结构:既可能是 {rows: []} 也可能是 {data: []}
|
||||||
|
const planList = (planRes && (planRes.rows || planRes.data)) || []
|
||||||
|
|
||||||
const tasks = planList.map(async (p) => {
|
const tasks = planList.map(async (p) => {
|
||||||
let setup = {}
|
let setup = {}
|
||||||
@@ -180,8 +209,20 @@ export default {
|
|||||||
|
|
||||||
const params = {}
|
const params = {}
|
||||||
this.driveFields.forEach(f => {
|
this.driveFields.forEach(f => {
|
||||||
const fromSetup = setup[f.key]
|
const fromPlan = p ? p[f.key] : undefined
|
||||||
|
const fromSetup = setup ? setup[f.key] : undefined
|
||||||
const fromLast = this.lastSuccess?.values?.[f.key]
|
const fromLast = this.lastSuccess?.values?.[f.key]
|
||||||
|
|
||||||
|
// 优先级:setup(如果字段来自setup) / plan(如果字段来自plan) -> lastSuccess -> ''
|
||||||
|
if (f.source === 'plan') {
|
||||||
|
if (fromPlan !== undefined && fromPlan !== null && String(fromPlan) !== '') {
|
||||||
|
params[f.key] = String(fromPlan)
|
||||||
|
} else if (fromLast !== undefined && fromLast !== null) {
|
||||||
|
params[f.key] = String(fromLast)
|
||||||
|
} else {
|
||||||
|
params[f.key] = ''
|
||||||
|
}
|
||||||
|
} else {
|
||||||
if (fromSetup !== undefined && fromSetup !== null && String(fromSetup) !== '') {
|
if (fromSetup !== undefined && fromSetup !== null && String(fromSetup) !== '') {
|
||||||
params[f.key] = String(fromSetup)
|
params[f.key] = String(fromSetup)
|
||||||
} else if (fromLast !== undefined && fromLast !== null) {
|
} else if (fromLast !== undefined && fromLast !== null) {
|
||||||
@@ -189,6 +230,7 @@ export default {
|
|||||||
} else {
|
} else {
|
||||||
params[f.key] = ''
|
params[f.key] = ''
|
||||||
}
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -252,18 +294,24 @@ export default {
|
|||||||
try {
|
try {
|
||||||
const items = this.driveFields.map(f => ({
|
const items = this.driveFields.map(f => ({
|
||||||
paramCode: f.key,
|
paramCode: f.key,
|
||||||
address: DRIVE_ADDRESS[f.key],
|
// OPC点位允许为空:为空则本次不发送该字段(先做效果,后续再配置)
|
||||||
|
address: this.driveAddress[f.key],
|
||||||
valueRaw: String(plan.params[f.key] || ''),
|
valueRaw: String(plan.params[f.key] || ''),
|
||||||
setTime: new Date()
|
setTime: new Date()
|
||||||
})).filter(it => !!it.address)
|
})).filter(it => !!it.address)
|
||||||
|
|
||||||
|
if (!items.length) {
|
||||||
|
this.$message.warning('OPC点位为空:当前没有可发送的字段(请先在输入框里填写点位)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const dto = {
|
const dto = {
|
||||||
deviceName: 'CGL_LINE_1',
|
deviceName: 'CGL_LINE_1',
|
||||||
groups: [
|
groups: [
|
||||||
{
|
{
|
||||||
groupNo: 1,
|
groupNo: 1,
|
||||||
groupType: 'DRIVE',
|
groupType: 'DRIVE',
|
||||||
groupName: `Drive Params for ${plan.steelGrade || ''}`,
|
groupName: `Drive/Plan Params for ${plan.steelGrade || ''}`,
|
||||||
items
|
items
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -36,8 +36,31 @@
|
|||||||
>
|
>
|
||||||
Apply Last Success Values
|
Apply Last Success Values
|
||||||
</el-button>
|
</el-button>
|
||||||
|
|
||||||
|
<el-button
|
||||||
|
type="info"
|
||||||
|
plain
|
||||||
|
icon="el-icon-time"
|
||||||
|
size="small"
|
||||||
|
@click="openHistory"
|
||||||
|
>
|
||||||
|
History
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- History Floating Panel -->
|
||||||
|
<FloatingPanel
|
||||||
|
ref="historyPanel"
|
||||||
|
title="History"
|
||||||
|
storageKey="FURNACE_SEND_HISTORY_PANEL"
|
||||||
|
:defaultW="980"
|
||||||
|
:defaultH="520"
|
||||||
|
:defaultX="30"
|
||||||
|
:defaultY="60"
|
||||||
|
>
|
||||||
|
<FurnaceHistoryPanel @apply="applyHistoryValues" />
|
||||||
|
</FloatingPanel>
|
||||||
|
|
||||||
<!-- Furnace Parameter Form / 炉火参数表单 -->
|
<!-- Furnace Parameter Form / 炉火参数表单 -->
|
||||||
<div v-loading="loading" class="card-grid-container">
|
<div v-loading="loading" class="card-grid-container">
|
||||||
<el-card class="parameter-card" shadow="hover">
|
<el-card class="parameter-card" shadow="hover">
|
||||||
@@ -62,19 +85,44 @@
|
|||||||
|
|
||||||
<!-- Template-driven editable form / 按模板渲染可编辑表单 -->
|
<!-- Template-driven editable form / 按模板渲染可编辑表单 -->
|
||||||
<el-form :model="form" label-position="top" size="mini">
|
<el-form :model="form" label-position="top" size="mini">
|
||||||
|
<div class="group-list">
|
||||||
|
<div
|
||||||
|
v-for="group in groupedItems"
|
||||||
|
:key="group.groupKey"
|
||||||
|
class="group-section"
|
||||||
|
>
|
||||||
|
<div class="group-header">
|
||||||
|
<span class="group-title">{{ group.groupTitle }}</span>
|
||||||
|
<span class="group-count">({{ group.items.length }} items)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Three inputs per row / 每行三个输入框 -->
|
<!-- Three inputs per row / 每行三个输入框 -->
|
||||||
<el-row :gutter="20">
|
<el-row :gutter="20">
|
||||||
<el-col
|
<el-col
|
||||||
:span="8"
|
:span="8"
|
||||||
v-for="item in templateItems"
|
v-for="item in group.items"
|
||||||
:key="item.templateItemId || item.paramCode"
|
:key="item.templateItemId || item.paramCode"
|
||||||
>
|
>
|
||||||
<el-form-item :label="item.labelEn">
|
<el-form-item :label="item.labelEn">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="form[item.paramCode]"
|
v-model="form[item.paramCode]"
|
||||||
:placeholder="getPlaceholder(item)"
|
:placeholder="getPlaceholder(item)"
|
||||||
|
:class="{ 'is-changed': isChangedFromLast(item) }"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- 辅助信息:上次/默认值(常驻,不会像 placeholder 一样消失) -->
|
||||||
|
<div class="field-hint">
|
||||||
|
<span v-if="getLastValue(item) !== undefined" class="hint-item">
|
||||||
|
Last Success: <b>{{ getLastValue(item) }}</b>
|
||||||
|
</span>
|
||||||
|
<span v-if="getDefaultValue(item) !== undefined" class="hint-item">
|
||||||
|
Default: <b>{{ getDefaultValue(item) }}</b>
|
||||||
|
</span>
|
||||||
|
<span v-if="isChangedFromLast(item)" class="hint-item changed">
|
||||||
|
Modified
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Inline address editor / 编辑点位 -->
|
<!-- Inline address editor / 编辑点位 -->
|
||||||
<div v-if="editTemplate" class="addr-inline">
|
<div v-if="editTemplate" class="addr-inline">
|
||||||
<span class="addr-label">Address:</span>
|
<span class="addr-label">Address:</span>
|
||||||
@@ -83,6 +131,8 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
<div v-if="!loading && templateItems.length === 0" class="empty-data">
|
<div v-if="!loading && templateItems.length === 0" class="empty-data">
|
||||||
@@ -96,10 +146,18 @@
|
|||||||
<script>
|
<script>
|
||||||
// Import APIs / 引入接口
|
// Import APIs / 引入接口
|
||||||
import { createSendJob, executeSendJob } from '@/api/l2/sendJob'
|
import { createSendJob, executeSendJob } from '@/api/l2/sendJob'
|
||||||
import { getSendTemplate, getLastSuccess, updateSendTemplate, updateSendTemplateItems } from '@/api/l2/sendTemplate'
|
import { getSendTemplate, getLastSuccess, updateSendTemplate, batchSaveSendTemplateItems } from '@/api/l2/sendTemplate'
|
||||||
|
|
||||||
|
// Import Components / 引入组件
|
||||||
|
import FloatingPanel from '@/components/FloatingPanel.vue'
|
||||||
|
import FurnaceHistoryPanel from './components/FurnaceHistoryPanel.vue'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'FurnaceSend',
|
name: 'FurnaceSend',
|
||||||
|
components: {
|
||||||
|
FloatingPanel,
|
||||||
|
FurnaceHistoryPanel
|
||||||
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
loading: false, // Loading / 加载
|
loading: false, // Loading / 加载
|
||||||
@@ -109,7 +167,10 @@ export default {
|
|||||||
|
|
||||||
template: null, // Template / 模板
|
template: null, // Template / 模板
|
||||||
lastSuccess: null, // Last success / 上次成功
|
lastSuccess: null, // Last success / 上次成功
|
||||||
form: {} // Form values / 表单值
|
form: {}, // Form values / 表单值
|
||||||
|
|
||||||
|
// 仅用于 Edit Template:记录加载时的原始模板项快照,用来计算差异,避免全量提交
|
||||||
|
originalItemsSnapshot: []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -118,6 +179,28 @@ export default {
|
|||||||
return [...this.template.items]
|
return [...this.template.items]
|
||||||
.filter(i => i.enabled === undefined || i.enabled === 1)
|
.filter(i => i.enabled === undefined || i.enabled === 1)
|
||||||
.sort((a, b) => (a.itemNo || 0) - (b.itemNo || 0))
|
.sort((a, b) => (a.itemNo || 0) - (b.itemNo || 0))
|
||||||
|
},
|
||||||
|
|
||||||
|
// 按 paramCode 前缀分组(如 NOF1 / NOF2 / RTF1 / SF ...)
|
||||||
|
groupedItems() {
|
||||||
|
const groupsMap = new Map()
|
||||||
|
const items = this.templateItems
|
||||||
|
|
||||||
|
items.forEach(it => {
|
||||||
|
const key = this.getGroupKey(it)
|
||||||
|
if (!groupsMap.has(key)) groupsMap.set(key, [])
|
||||||
|
groupsMap.get(key).push(it)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Map -> Array,并按组名排序(NOF1, NOF2... 这种会自然排序更好)
|
||||||
|
const groups = Array.from(groupsMap.entries()).map(([groupKey, groupItems]) => ({
|
||||||
|
groupKey,
|
||||||
|
groupTitle: this.getGroupTitle(groupKey),
|
||||||
|
items: groupItems
|
||||||
|
}))
|
||||||
|
|
||||||
|
groups.sort((a, b) => String(a.groupKey).localeCompare(String(b.groupKey), undefined, { numeric: true }))
|
||||||
|
return groups
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
created() {
|
created() {
|
||||||
@@ -125,6 +208,69 @@ export default {
|
|||||||
this.reload()
|
this.reload()
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
pickItemFields(it) {
|
||||||
|
if (!it) return {}
|
||||||
|
// 仅挑后端支持保存的字段,避免把多余字段/响应结构带回去
|
||||||
|
return {
|
||||||
|
templateItemId: it.templateItemId,
|
||||||
|
templateId: it.templateId,
|
||||||
|
itemNo: it.itemNo,
|
||||||
|
paramCode: it.paramCode,
|
||||||
|
labelEn: it.labelEn,
|
||||||
|
groupNameEn: it.groupNameEn,
|
||||||
|
address: it.address,
|
||||||
|
defaultValueRaw: it.defaultValueRaw,
|
||||||
|
enabled: it.enabled,
|
||||||
|
remark: it.remark
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
isItemChanged(a, b) {
|
||||||
|
// a/b 都是 pickItemFields 之后的对象
|
||||||
|
const keys = ['itemNo', 'paramCode', 'labelEn', 'groupNameEn', 'address', 'defaultValueRaw', 'enabled', 'remark']
|
||||||
|
return keys.some(k => String(a?.[k] ?? '') !== String(b?.[k] ?? ''))
|
||||||
|
},
|
||||||
|
|
||||||
|
openHistory() {
|
||||||
|
// FloatingPanel 提供 open() 方法
|
||||||
|
this.$refs.historyPanel && this.$refs.historyPanel.open()
|
||||||
|
},
|
||||||
|
|
||||||
|
applyHistoryValues(payload) {
|
||||||
|
const values = payload && payload.values ? payload.values : {}
|
||||||
|
this.templateItems.forEach(item => {
|
||||||
|
const v = values[item.paramCode]
|
||||||
|
if (v !== undefined) {
|
||||||
|
this.$set(this.form, item.paramCode, String(v))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
getGroupKey(item) {
|
||||||
|
const code = (item && item.paramCode) ? String(item.paramCode) : ''
|
||||||
|
// 取第一段:NOF1_XXX / NOF1.XXX / NOF1-XXX
|
||||||
|
const m = code.match(/^([A-Za-z]+\d+|[A-Za-z]+)(?=[_.-]|$)/)
|
||||||
|
return m ? m[1] : 'OTHER'
|
||||||
|
},
|
||||||
|
|
||||||
|
getGroupTitle(groupKey) {
|
||||||
|
// 可按你们现场习惯改中文名
|
||||||
|
const map = {
|
||||||
|
NOF: 'NOF',
|
||||||
|
RTF: 'RTF',
|
||||||
|
SF: 'SF',
|
||||||
|
PH: 'PH',
|
||||||
|
JCF: 'JCF',
|
||||||
|
LTH: 'LTH',
|
||||||
|
TDS: 'TDS'
|
||||||
|
}
|
||||||
|
const base = String(groupKey).match(/^[A-Za-z]+/)?.[0] || String(groupKey)
|
||||||
|
// 例如 NOF1 -> NOF1(或 “NOF 1”)
|
||||||
|
const prefixTitle = map[base] || base
|
||||||
|
const suffix = String(groupKey).slice(base.length)
|
||||||
|
return suffix ? `${prefixTitle}${suffix}` : prefixTitle
|
||||||
|
},
|
||||||
|
|
||||||
async reload() {
|
async reload() {
|
||||||
this.loading = true
|
this.loading = true
|
||||||
const [templateRes, lastRes] = await Promise.all([
|
const [templateRes, lastRes] = await Promise.all([
|
||||||
@@ -135,6 +281,11 @@ export default {
|
|||||||
this.template = templateRes && templateRes.code === 200 ? templateRes.data : null
|
this.template = templateRes && templateRes.code === 200 ? templateRes.data : null
|
||||||
this.lastSuccess = lastRes && lastRes.code === 200 ? lastRes.data : null
|
this.lastSuccess = lastRes && lastRes.code === 200 ? lastRes.data : null
|
||||||
|
|
||||||
|
// 记录模板项原始快照(用于 Edit Template 计算差异,避免全量提交)
|
||||||
|
this.originalItemsSnapshot = this.template && Array.isArray(this.template.items)
|
||||||
|
? this.template.items.map(it => this.pickItemFields(it))
|
||||||
|
: []
|
||||||
|
|
||||||
this.initializeForm()
|
this.initializeForm()
|
||||||
this.loading = false
|
this.loading = false
|
||||||
},
|
},
|
||||||
@@ -166,13 +317,29 @@ export default {
|
|||||||
this.$message.success('Last success values applied')
|
this.$message.success('Last success values applied')
|
||||||
},
|
},
|
||||||
|
|
||||||
getPlaceholder(item) {
|
getPlaceholder() {
|
||||||
const lastValue = this.lastSuccess?.values?.[item.paramCode]
|
// placeholder 只保留引导,不承载关键信息(上次/默认值放到下方提示)
|
||||||
if (lastValue !== undefined) return `Last: ${lastValue}`
|
|
||||||
if (item.defaultValueRaw) return `Default: ${item.defaultValueRaw}`
|
|
||||||
return 'Please enter'
|
return 'Please enter'
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getLastValue(item) {
|
||||||
|
const v = this.lastSuccess?.values?.[item.paramCode]
|
||||||
|
return v === undefined || v === null || v === '' ? undefined : v
|
||||||
|
},
|
||||||
|
|
||||||
|
getDefaultValue(item) {
|
||||||
|
const v = item?.defaultValueRaw
|
||||||
|
return v === undefined || v === null || v === '' ? undefined : v
|
||||||
|
},
|
||||||
|
|
||||||
|
isChangedFromLast(item) {
|
||||||
|
const last = this.getLastValue(item)
|
||||||
|
if (last === undefined) return false
|
||||||
|
const cur = this.form?.[item.paramCode]
|
||||||
|
// 统一用字符串比较,避免 1 vs "1"
|
||||||
|
return String(cur ?? '') !== String(last)
|
||||||
|
},
|
||||||
|
|
||||||
formatTime(t) {
|
formatTime(t) {
|
||||||
if (!t) return ''
|
if (!t) return ''
|
||||||
return new Date(t).toLocaleString()
|
return new Date(t).toLocaleString()
|
||||||
@@ -186,6 +353,7 @@ export default {
|
|||||||
|
|
||||||
this.savingTemplate = true
|
this.savingTemplate = true
|
||||||
try {
|
try {
|
||||||
|
// 1) save template main
|
||||||
await updateSendTemplate({
|
await updateSendTemplate({
|
||||||
templateId: this.template.templateId,
|
templateId: this.template.templateId,
|
||||||
deviceName: this.template.deviceName,
|
deviceName: this.template.deviceName,
|
||||||
@@ -193,15 +361,54 @@ export default {
|
|||||||
remark: this.template.remark
|
remark: this.template.remark
|
||||||
})
|
})
|
||||||
|
|
||||||
const itemsPayload = this.templateItems.map(it => ({
|
// 2) diff items (ONLY send changed items)
|
||||||
templateItemId: it.templateItemId,
|
const currentAll = (this.template && Array.isArray(this.template.items))
|
||||||
address: it.address,
|
? this.template.items.map(it => this.pickItemFields(it))
|
||||||
defaultValueRaw: it.defaultValueRaw,
|
: []
|
||||||
enabled: it.enabled,
|
|
||||||
remark: it.remark
|
const originalAll = Array.isArray(this.originalItemsSnapshot)
|
||||||
}))
|
? this.originalItemsSnapshot
|
||||||
|
: []
|
||||||
|
|
||||||
|
const originalById = new Map(originalAll.filter(x => x.templateItemId != null).map(x => [x.templateItemId, x]))
|
||||||
|
const currentById = new Map(currentAll.filter(x => x.templateItemId != null).map(x => [x.templateItemId, x]))
|
||||||
|
|
||||||
|
// deleted: in original but not in current
|
||||||
|
const deleteIds = []
|
||||||
|
originalById.forEach((_, id) => {
|
||||||
|
if (!currentById.has(id)) deleteIds.push(id)
|
||||||
|
})
|
||||||
|
|
||||||
|
// upsert: new (no id) OR changed
|
||||||
|
const upserts = []
|
||||||
|
currentAll.forEach(it => {
|
||||||
|
if (!it.templateItemId) {
|
||||||
|
// new item
|
||||||
|
upserts.push(it)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const old = originalById.get(it.templateItemId)
|
||||||
|
if (!old) {
|
||||||
|
upserts.push(it)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (this.isItemChanged(it, old)) {
|
||||||
|
upserts.push(it)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!upserts.length && !deleteIds.length) {
|
||||||
|
this.$message.success('No changes')
|
||||||
|
this.editTemplate = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await batchSaveSendTemplateItems({
|
||||||
|
templateId: this.template.templateId,
|
||||||
|
items: upserts,
|
||||||
|
deleteIds
|
||||||
|
})
|
||||||
|
|
||||||
await updateSendTemplateItems(itemsPayload)
|
|
||||||
this.$message.success('Template saved')
|
this.$message.success('Template saved')
|
||||||
this.editTemplate = false
|
this.editTemplate = false
|
||||||
await this.reload()
|
await this.reload()
|
||||||
@@ -235,15 +442,28 @@ export default {
|
|||||||
|
|
||||||
this.sending = true
|
this.sending = true
|
||||||
try {
|
try {
|
||||||
const items = this.templateItems.map(item => ({
|
// 仅发送“有点位 + 相对 lastSuccess 有变化”的项,避免请求体过大导致超时
|
||||||
|
const items = this.templateItems
|
||||||
|
.map(item => {
|
||||||
|
const cur = String(this.form?.[item.paramCode] ?? '')
|
||||||
|
const last = this.lastSuccess?.values?.[item.paramCode]
|
||||||
|
const hasLast = last !== undefined && last !== null
|
||||||
|
// 有 lastSuccess 时:严格对比;没有 lastSuccess 时:只有非空才发送(避免全空全量下发)
|
||||||
|
const changed = hasLast ? (String(last) !== cur) : (cur !== '')
|
||||||
|
|
||||||
|
return {
|
||||||
paramCode: item.paramCode,
|
paramCode: item.paramCode,
|
||||||
address: item.address,
|
address: item.address,
|
||||||
valueRaw: String(this.form[item.paramCode] || ''),
|
valueRaw: cur,
|
||||||
setTime: new Date()
|
setTime: new Date(),
|
||||||
})).filter(it => !!it.address)
|
__changed: changed
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(it => !!it.address && it.__changed)
|
||||||
|
.map(({ __changed, ...rest }) => rest)
|
||||||
|
|
||||||
if (!items.length) {
|
if (!items.length) {
|
||||||
this.$message.error('No valid OPC address found in template')
|
this.$message.info('没有检测到变更项,无需发送')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,4 +509,35 @@ export default {
|
|||||||
.empty-data { margin-top: 20px; }
|
.empty-data { margin-top: 20px; }
|
||||||
.addr-inline { margin-top: 6px; }
|
.addr-inline { margin-top: 6px; }
|
||||||
.addr-label { display:inline-block; margin-right:6px; color:#909399; font-size:12px; }
|
.addr-label { display:inline-block; margin-right:6px; color:#909399; font-size:12px; }
|
||||||
|
|
||||||
|
.group-list { margin-top: 8px; }
|
||||||
|
.group-section { padding: 10px 0 6px; border-top: 1px solid #ebeef5; }
|
||||||
|
.group-section:first-child { border-top: none; padding-top: 0; }
|
||||||
|
.group-header { display:flex; align-items:baseline; gap:8px; margin: 4px 0 10px; }
|
||||||
|
.group-title { font-weight: 600; }
|
||||||
|
.group-count { color:#909399; font-size: 12px; margin-left: 6px; }
|
||||||
|
|
||||||
|
/* 字段提示信息 */
|
||||||
|
.field-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-top: 4px;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-item {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-item.changed {
|
||||||
|
color: #e6a23c;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 修改过的输入框高亮 */
|
||||||
|
:deep(.el-input.is-changed .el-input__inner) {
|
||||||
|
border-color: #e6a23c;
|
||||||
|
background-color: #fdf6ec;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
<el-tabs v-model="active" type="border-card" class="lsv-tabs">
|
<el-tabs v-model="active" type="border-card" class="lsv-tabs">
|
||||||
<el-tab-pane label="Drive" name="drive">
|
<el-tab-pane label="Drive" name="drive">
|
||||||
<div class="lsv-list">
|
<div class="lsv-list">
|
||||||
<div v-if="!driveList.length" class="lsv-empty">-</div>
|
<div v-if="!driveList.length" class="lsv-empty">未设定</div>
|
||||||
<div v-for="it in driveList" :key="it.key" class="lsv-item">
|
<div v-for="it in driveList" :key="it.key" class="lsv-item">
|
||||||
<span class="lsv-key">{{ it.key }}</span>
|
<span class="lsv-key">{{ it.key }}</span>
|
||||||
<span class="lsv-val">{{ it.val }}</span>
|
<span class="lsv-val">{{ it.val }}</span>
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
<el-tab-pane label="Furnace" name="furnace">
|
<el-tab-pane label="Furnace" name="furnace">
|
||||||
<div class="lsv-list">
|
<div class="lsv-list">
|
||||||
<div v-if="!furnaceList.length" class="lsv-empty">-</div>
|
<div v-if="!furnaceList.length" class="lsv-empty">未设定</div>
|
||||||
<div v-for="it in furnaceList" :key="it.key" class="lsv-item">
|
<div v-for="it in furnaceList" :key="it.key" class="lsv-item">
|
||||||
<span class="lsv-key">{{ it.key }}</span>
|
<span class="lsv-key">{{ it.key }}</span>
|
||||||
<span class="lsv-val">{{ it.val }}</span>
|
<span class="lsv-val">{{ it.val }}</span>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<div class="track-container">
|
<div class="track-container">
|
||||||
<!-- Set Values Floating Panel / 设定值悬浮窗 -->
|
<!-- Set Values Floating Panel / 设定值悬浮窗 -->
|
||||||
<FloatingPanel ref="setValuesPanel" title="Set Values" width="720px">
|
<FloatingPanel ref="setValuesPanel" title="Set Values" width="720px">
|
||||||
<LatestSetValues :setupValue="setupValue" />
|
<LatestSetValues :driveData="setupValue.drive" :furnaceData="setupValue.furnace" />
|
||||||
</FloatingPanel>
|
</FloatingPanel>
|
||||||
<el-row :gutter="20">
|
<el-row :gutter="20">
|
||||||
<!-- 左侧:设备列表 -->
|
<!-- 左侧:设备列表 -->
|
||||||
|
|||||||
Reference in New Issue
Block a user