feat: update Excel template management and export handling

This commit is contained in:
meswork
2026-06-03 08:46:56 +08:00
parent 9cfb8d16f0
commit 57f804818e
8 changed files with 1092 additions and 124 deletions

View File

@@ -0,0 +1,541 @@
# Excel 导入模板功能设计文档:单条模板 / 批量模板
## 1. 设计目标
当前项目已经具备“模板管理”能力,但需要进一步明确模板类型,避免把单条变量模板和从 Excel 导入的一整套变量混在一起。
新的设计目标是把模板管理拆成两类:
```text
单条模板管理:每次新增变量时,复制/套用一条变量模板。
批量模板管理:导入 Excel读取 MES_基本变量原始数据保存为一整套批量模板。
```
其中:
- `单条模板` 保留现在的使用方式,用于新增或编辑单条变量时快速填充字段。
- `批量模板` 用于把外部 Excel 中的 `MES_基本变量原始数据` 整批复制到当前设备模块。
- 批量模板只读取导入 Excel 的 `MES_基本变量原始数据` Sheet。
- 批量模板不读取、不解析、不使用 `设备类型对应表`
- 批量模板不会根据外部 Excel 修改当前网页中的设备模块、工位、设备 IP、DB。
## 2. 模板管理分类
模板管理分为两个 Tab 或两个区域:
```text
单条模板管理 | 批量模板管理
```
| 类型 | 数据来源 | 使用方式 | 结果 |
| --- | --- | --- | --- |
| 单条模板管理 | 用户手动维护的一条变量字段模板 | 新增变量时选择并套用 | 新增 1 条变量 |
| 批量模板管理 | 外部 Excel 的 `MES_基本变量原始数据` | 选择批量模板并使用 | 一次新增多条变量 |
第一版不做设备模板、不做工位模板、不从 Excel 反向生成设备配置。
## 3. 单条模板管理
单条模板管理保留当前功能。
### 3.1 功能定位
单条模板用于减少单条变量录入时的重复填写。
例如用户新增变量时,可以选择:
- `Bool 信号模板`
- `Int 数值模板`
- `Real 浮点模板`
- `String 字符串模板`
然后自动填充:
- `TagTypeID`
- `TagLong`
- `变量类型`
- `是否启用`
- `是否监控`
- 其它常用字段
### 3.2 单条模板使用规则
新增变量时:
1. 用户点击 `新增变量`
2. 系统打开变量编辑弹窗。
3. 用户选择一个单条模板。
4. 点击 `使用模板`
5. 模板字段填入当前变量表单。
6. 用户继续补充 `TagID / TagName / 变量地址 / 测量项目` 等字段。
7. 保存后只新增一条共享变量。
### 3.3 单条模板保存内容
单条模板保存的是一条变量的部分字段,不需要保存完整 38 字段。
建议结构:
```json
{
"id": "tpl-bool-signal",
"templateName": "Bool 信号模板",
"description": "常用 Bool 开关量信号",
"fields": {
"TagTypeID": "11",
"TagLong": "1",
"变量类型": "Bool",
"是否启用": "1",
"是否监控": "1"
}
}
```
## 4. 批量模板管理
批量模板管理是本次新增的重点。
### 4.1 功能定位
批量模板用于把外部 Excel 中的一整套基本变量原始数据保存成可复用模板。
用户后续可以选择这个批量模板,并一次性追加到当前设备模块的共享变量数据中。
效果类似之前版本:
```text
直接复制 Excel 中的 MES_基本变量原始数据
```
### 4.2 批量模板来源
批量模板只来自外部 Excel 的:
```text
MES_基本变量原始数据
```
即使导入 Excel 中存在:
```text
设备类型对应表
```
系统也不读取、不解析、不使用。
### 4.3 批量模板使用结果
用户使用批量模板后:
- 批量模板中的所有变量追加到当前选中的设备模块。
- 当前设备模块右侧 `MES_基本变量原始数据展开预览` 立即刷新。
- 如果当前设备模块有多个配置行,右侧按多个工位展开显示。
- 导出 Excel 时,新追加的变量会出现在 `MES_基本变量原始数据` 中。
## 5. Excel 导入规则
### 5.1 Sheet 读取规则
导入 Excel 时只查找并读取:
```text
MES_基本变量原始数据
```
如果没有该 Sheet提示
```text
未找到 MES_基本变量原始数据 Sheet无法导入批量模板。
```
不会读取:
```text
设备类型对应表
```
也不会读取其它 Sheet。
### 5.2 表头规则
第一行作为表头。
字段以当前系统中的 `MES_HEADERS` 为准。
处理规则:
| 情况 | 处理方式 |
| --- | --- |
| Excel 存在系统支持字段 | 读取该字段 |
| Excel 存在未知字段 | 忽略 |
| Excel 缺少系统字段 | 补为空 |
| Excel 空行 | 忽略 |
至少应存在:
- `TagID`
- `TagName`
建议必须填写:
- `TagTypeID`
- `TagLong`
### 5.3 字段保留规则
批量模板保存导入 Excel 中识别出的多条基本变量原始数据。
保存时字段仍按 38 列标准字段管理:
```text
TagID
TagName
工位号
数据来源
IP
TagTypeID
TagLong
DbName
...
IsWebSocket
```
但导出和右侧预览时,以下字段仍以当前网页设备模块配置为准:
- `工位号`
- `数据来源`
- `IP`
- `DbName`
也就是说,外部 Excel 中这些字段可以被导入保存,但不会覆盖当前网页的设备模块配置。
### 5.4 TagID 规则
外部 Excel 中可能存在两种格式:
```text
TAG_0000001
ST04_TAG_0000001
```
批量模板保存时建议统一保存为基础格式:
```text
TAG_0000001
```
规则:
```text
如果 TagID 中包含 TAG_则从 TAG_ 开始截取。
```
示例:
| 导入值 | 模板保存值 |
| --- | --- |
| `TAG_0000001` | `TAG_0000001` |
| `ST04_TAG_0000001` | `TAG_0000001` |
| `ST11_TAG_0000023` | `TAG_0000023` |
使用批量模板追加到设备模块后,页面预览和 Excel 导出再按当前工位展开:
```text
ST04_TAG_0000001
ST05_TAG_0000001
```
## 6. 批量模板使用规则
### 6.1 使用入口
在模板管理中进入:
```text
批量模板管理
```
选择一个批量模板后点击:
```text
使用批量模板
```
### 6.2 使用前提
必须先选择左侧设备模块。
如果未选择设备模块,提示:
```text
请先选择设备模块,再使用批量模板。
```
### 6.3 追加方式
使用批量模板时,不覆盖当前设备模块已有变量,而是追加。
示例:
```text
当前设备模块已有变量3 条
批量模板变量23 条
使用后当前设备模块变量26 条
```
### 6.4 TagID 冲突处理
如果批量模板中的 `TagID` 与当前设备模块已有变量重复,推荐第一版自动重排。
示例:
当前已有:
```text
TAG_0000001
TAG_0000002
```
批量模板中有:
```text
TAG_0000001
TAG_0000002
TAG_0000003
```
追加后生成:
```text
TAG_0000003
TAG_0000004
TAG_0000005
```
这样可以避免同一设备模块中基础 `TagID` 重复。
### 6.5 展开预览
批量模板追加后,右侧表格按当前设备模块配置行展开。
例如:
```text
当前设备模块配置行ST04、ST05
批量模板变量23 条
右侧展开显示46 行
```
## 7. 数据结构建议
### 7.1 总配置结构
建议 OPFS/localStorage 中保存:
```json
{
"version": 3,
"updatedAt": "2026-06-02T10:00:00.000Z",
"devices": [],
"variableTemplates": [],
"batchVariableTemplates": []
}
```
### 7.2 单条模板结构
```json
{
"id": "tpl-bool-signal",
"templateName": "Bool 信号模板",
"description": "常用 Bool 开关量信号",
"fields": {
"TagTypeID": "11",
"TagLong": "1",
"变量类型": "Bool"
}
}
```
### 7.3 批量模板结构
```json
{
"id": "batch-tpl-001",
"templateName": "放油标准批量模板",
"description": "从外部 Excel 导入",
"sourceFileName": "SignalTable_CZ.xlsx",
"createdAt": "2026-06-02T10:00:00.000Z",
"variables": [
{
"TagID": "TAG_0000001",
"TagName": "PLC_PC_工件到位",
"TagTypeID": "11",
"TagLong": "1",
"变量类型": "Bool",
"变量地址": "0",
"变量其它": ".0"
}
]
}
```
## 8. 页面交互设计
### 8.1 模板管理弹窗
模板管理弹窗分为两个 Tab
```text
单条模板管理 | 批量模板管理
```
### 8.2 单条模板管理
保留当前能力:
- 新增单条模板
- 编辑单条模板
- 删除单条模板
- 在新增变量弹窗中使用单条模板
- 从当前变量保存为单条模板
### 8.3 批量模板管理
新增能力:
- `导入 Excel`
- `预览批量模板`
- `使用批量模板`
- `删除批量模板`
批量模板列表展示:
- 模板名称
- 来源文件
- 变量数量
- 创建时间
### 8.4 导入 Excel 确认弹窗
导入 Excel 后先展示确认信息:
```text
来源文件SignalTable_CZ.xlsx
读取 SheetMES_基本变量原始数据
有效变量数23
忽略空行0
忽略未知列2
模板名称:[放油标准批量模板]
模板说明:[从外部 Excel 导入]
[取消] [保存为批量模板]
```
## 9. 测试方案
### 9.1 单条模板测试
1. 打开新增变量弹窗。
2. 选择一个单条模板。
3. 点击 `使用模板`
4. 确认只填充当前变量表单。
5. 保存后只新增一条变量。
### 9.2 批量模板导入测试
1. 打开模板管理。
2. 进入 `批量模板管理`
3. 点击 `导入 Excel`
4. 选择包含 `MES_基本变量原始数据` 的 Excel。
5. 确认系统只读取该 Sheet。
6. 保存后生成一个批量模板。
### 9.3 非目标 Sheet 测试
1. 导入包含 `设备类型对应表` 的 Excel。
2. 确认系统不读取、不解析、不使用该 Sheet。
3. 当前设备模块配置不发生变化。
### 9.4 批量模板使用测试
1. 选择左侧一个设备模块。
2. 在批量模板管理中选择一个模板。
3. 点击 `使用批量模板`
4. 当前设备模块一次性新增多条变量。
5. 右侧 `MES_基本变量原始数据展开预览` 立即刷新。
### 9.5 Excel 导出测试
1. 使用批量模板批量新增变量。
2. 点击 `生成 Excel`
3. 导出的 `MES_基本变量原始数据` 包含新增变量。
4. 导出的 `工位号 / 数据来源 / IP / DbName` 使用当前网页设备模块配置。
5. 导出的 Excel 仍只包含两个 Sheet。
### 9.6 异常测试
需要验证:
- Excel 没有 `MES_基本变量原始数据` 时提示错误。
- Excel 表头缺少 `TagID``TagName` 时提示错误。
- Excel 空行被忽略。
- Excel 未知列被忽略。
- 批量模板保存后刷新页面仍存在。
## 10. 页面草图
### 10.1 模板管理
```text
┌──────────────────────────────────────────────────────────────────────────────┐
│ 模板管理 │
├──────────────────────────────────────────────────────────────────────────────┤
│ [单条模板管理] [批量模板管理] │
├──────────────────────────────┬───────────────────────────────────────────────┤
│ 批量模板列表 │ 批量模板预览 │
│ [导入 Excel] │ 模板名称:放油标准批量模板 │
│ ┌──────────────────────────┐ │ 来源文件SignalTable_CZ.xlsx │
│ │ 放油标准批量模板 23 条 │ │ 变量数量23 │
│ │ 称重批量模板 12 条 │ │ │
│ └──────────────────────────┘ │ ┌───────────────────────────────────────────┐ │
│ │ │ TagID TagName TagTypeID │ │
│ │ │ TAG_0000001 PLC_PC_工件到位 11 │ │
│ │ │ TAG_0000002 PLC_PC_读取完成 11 │ │
│ │ └───────────────────────────────────────────┘ │
├──────────────────────────────┴───────────────────────────────────────────────┤
│ [删除模板] [使用批量模板] [关闭] │
└──────────────────────────────────────────────────────────────────────────────┘
```
### 10.2 导入确认
```text
┌──────────────────────────────────────────────────────┐
│ 导入 Excel 为批量模板 │
├──────────────────────────────────────────────────────┤
│ 来源文件SignalTable_CZ.xlsx │
│ 读取 SheetMES_基本变量原始数据 │
│ 有效变量数23 │
│ 忽略空行0 │
│ 忽略未知列2 │
│ │
│ 模板名称:[放油标准批量模板 ] │
│ 模板说明:[从外部 Excel 导入 ] │
├──────────────────────────────────────────────────────┤
│ [取消] [保存为批量模板] │
└──────────────────────────────────────────────────────┘
```
## 11. 后续迭代方向
后续可以继续增强:
- 支持导入 Excel 后直接追加到当前设备模块,不先保存为批量模板。
- 支持批量模板内变量字段编辑。
- 支持批量模板导出为 JSON。
- 支持从当前设备模块变量保存为批量模板。
- 支持批量模板使用时选择 TagID 策略:自动重排、保留原值、覆盖重复。
- 支持导入前筛选变量,例如按 `变量类型``TagName``是否启用` 筛选。

View File

@@ -0,0 +1,74 @@
import XLSX from 'xlsx'
import { MES_HEADERS } from './signalTableBuilder'
import { normalizeBatchTemplateVariables, validateUniqueTagIds } from './templateBuilder'
const MES_SHEET_NAME = 'MES_基本变量原始数据'
const REQUIRED_HEADERS = ['TagID', 'TagName']
function isEmptyRow(row) {
return !row.some(value => value !== undefined && value !== null && String(value).trim() !== '')
}
export async function importBatchTemplateFromExcel(file) {
const content = await file.arrayBuffer()
const workbook = XLSX.read(content, { type: 'array' })
const worksheet = workbook.Sheets[MES_SHEET_NAME]
if (!worksheet) {
throw new Error(`未找到 ${MES_SHEET_NAME} Sheet无法导入批量模板。`)
}
const rows = XLSX.utils.sheet_to_json(worksheet, {
header: 1,
defval: '',
raw: false
})
if (!rows.length) {
throw new Error(`${MES_SHEET_NAME} Sheet 为空,无法导入批量模板。`)
}
const headers = rows[0].map(value => String(value || '').trim())
const missingHeaders = REQUIRED_HEADERS.filter(header => !headers.includes(header))
if (missingHeaders.length) {
throw new Error(`缺少必需表头:${missingHeaders.join('、')}`)
}
const ignoredHeaders = headers.filter(header => header && !MES_HEADERS.includes(header))
const variables = []
let ignoredEmptyRows = 0
rows.slice(1).forEach(sourceRow => {
if (isEmptyRow(sourceRow)) {
ignoredEmptyRows += 1
return
}
const variable = {}
MES_HEADERS.forEach(header => {
const index = headers.indexOf(header)
variable[header] = index === -1 ? '' : sourceRow[index]
})
variables.push(variable)
})
if (!variables.length) {
throw new Error(`${MES_SHEET_NAME} Sheet 中没有可导入的变量数据。`)
}
const normalizedVariables = normalizeBatchTemplateVariables(variables)
const duplicateTagIds = validateUniqueTagIds(normalizedVariables)
if (duplicateTagIds.length) {
throw new Error(`导入数据存在重复基础 TagID${duplicateTagIds.join('、')}`)
}
return {
sourceFileName: file.name,
sheetName: MES_SHEET_NAME,
variables: normalizedVariables,
variableCount: normalizedVariables.length,
ignoredEmptyRows,
ignoredUnknownColumns: ignoredHeaders.length
}
}

View File

@@ -14,7 +14,7 @@ export function createSignalTableWorkbook(data) {
return workbook
}
export async function exportSignalTable(data, filename = 'SignalTable_CZ.xlsx') {
export async function exportSignalTable(data, filename = 'SignalTable.xlsx') {
const workbook = createSignalTableWorkbook(data)
const content = XLSX.write(workbook, {
bookType: 'xlsx',

View File

@@ -55,3 +55,23 @@ export async function openJsonFile() {
if (!handles.length) return null
return handles[0].getFile()
}
export async function openExcelFile() {
if (!isOpenPickerSupported()) return null
const handles = await window.showOpenFilePicker({
multiple: false,
types: [
{
description: 'Excel 工作簿',
accept: {
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
'application/vnd.ms-excel': ['.xls']
}
}
]
})
if (!handles.length) return null
return handles[0].getFile()
}

View File

@@ -9,11 +9,12 @@ export function isOpfsSupported() {
function wrapConfig(data) {
return {
version: 2,
version: 3,
updatedAt: new Date().toISOString(),
devices: Array.isArray(data.devices) ? data.devices : [],
variables: Array.isArray(data.variables) ? data.variables : [],
variableTemplates: Array.isArray(data.variableTemplates) ? data.variableTemplates : []
variableTemplates: Array.isArray(data.variableTemplates) ? data.variableTemplates : [],
batchVariableTemplates: Array.isArray(data.batchVariableTemplates) ? data.batchVariableTemplates : []
}
}

View File

@@ -220,7 +220,8 @@ export function normalizeData(data) {
return {
devices,
variables: templateVariables.length ? templateVariables : fallbackVariables.map(normalizeVariable),
variableTemplates: Array.isArray(source.variableTemplates) ? cloneData(source.variableTemplates) : []
variableTemplates: Array.isArray(source.variableTemplates) ? cloneData(source.variableTemplates) : [],
batchVariableTemplates: Array.isArray(source.batchVariableTemplates) ? cloneData(source.batchVariableTemplates) : []
}
}
@@ -340,45 +341,26 @@ export function buildMesRows(data) {
;(device.stations || []).forEach(station => {
;(device.variables || []).filter(hasMeaningfulVariableData).forEach(variable => {
const row = applyStationToVariable(variable, device, station)
rows.push(MES_HEADERS.map(header => row[header]))
rows.push(MES_HEADERS.map(header => row[header] == null ? '' : row[header]))
})
})
})
return rows
return normalizeRowLength(rows, MES_HEADERS.length)
}
function removeEmptyColumns(rows, cols = []) {
if (!rows.length) return { rows, cols }
const dataRows = rows.slice(1)
if (!dataRows.length) return { rows, cols }
const keepIndexes = rows[0]
.map((header, index) => ({ header, index }))
.filter(({ index }) => {
return dataRows.some(row => {
const value = row[index]
return value !== undefined && value !== null && String(value).trim() !== ''
})
})
.map(item => item.index)
return {
rows: rows.map(row => keepIndexes.map(index => row[index])),
cols: keepIndexes.map(index => cols[index]).filter(width => width !== undefined)
}
function normalizeRowLength(rows, length) {
return (rows || []).map(row => {
const next = Array.from({ length }, (_, index) => row[index] == null ? '' : row[index])
return next
})
}
export function buildWorkbookSheets(data) {
const mesSheet = removeEmptyColumns(
buildMesRows(data),
[18, 28, 16, 16, 12, 12, 9, 12, 12, 10, 10, 8, 8, 12, 24, 10, 14, 58, 8, 8, 10, 8, 10, 20, 10, 14, 12, 12, 12, 10, 12, 10, 10, 10, 10, 12, 12, 12]
)
return [
{
name: 'MES_基本变量原始数据',
rows: mesSheet.rows,
cols: mesSheet.cols
rows: buildMesRows(data),
cols: [18, 28, 16, 16, 12, 12, 9, 12, 12, 10, 10, 8, 8, 12, 24, 10, 14, 58, 8, 8, 10, 8, 10, 20, 10, 14, 12, 12, 12, 10, 12, 10, 10, 10, 10, 12, 12, 12]
},
{
name: '设备类型对应表',

View File

@@ -3,6 +3,12 @@ import { cloneData, MES_HEADERS } from './signalTableBuilder'
const SOURCE_FIELDS = new Set(['工位号', '数据来源', 'IP', 'DbName'])
const NON_TEMPLATE_FIELDS = new Set(['TagID', 'TagName', ...SOURCE_FIELDS])
export function normalizeBaseTagId(tagId) {
const text = String(tagId || '').trim()
const tagIndex = text.indexOf('TAG_')
return tagIndex === -1 ? text : text.slice(tagIndex)
}
export function normalizeVariableTemplates(templates, fallbackTemplates = []) {
const source = Array.isArray(templates) && templates.length ? templates : fallbackTemplates
@@ -100,3 +106,82 @@ export function sanitizeTemplateForSave(template) {
return next
}
export function normalizeBatchTemplateVariables(variables) {
return (variables || []).map(variable => {
const row = {}
MES_HEADERS.forEach(header => {
row[header] = Object.prototype.hasOwnProperty.call(variable || {}, header) ? variable[header] : ''
})
row.TagID = normalizeBaseTagId(row.TagID)
row['工位号'] = 'OPNAME'
row['数据来源'] = 'OPNAME'
row.IP = 'IP'
row.DbName = 'DB'
return row
})
}
export function normalizeBatchVariableTemplates(templates) {
return (templates || []).map((template, index) => {
return {
id: template.id || `batch-tpl-${Date.now()}-${index}`,
templateName: template.templateName || `批量模板${index + 1}`,
description: template.description || '',
sourceFileName: template.sourceFileName || '',
createdAt: template.createdAt || new Date().toISOString(),
variables: normalizeBatchTemplateVariables(template.variables)
}
})
}
export function createBatchVariableTemplate(candidate, templateName, description = '') {
return {
id: `batch-tpl-${Date.now()}`,
templateName: String(templateName || '').trim(),
description: String(description || '').trim(),
sourceFileName: candidate.sourceFileName || '',
createdAt: new Date().toISOString(),
variables: normalizeBatchTemplateVariables(candidate.variables)
}
}
export function validateUniqueTagIds(variables) {
const counts = {}
;(variables || []).forEach(variable => {
const tagId = normalizeBaseTagId(variable.TagID)
if (!tagId) return
counts[tagId] = (counts[tagId] || 0) + 1
})
return Object.keys(counts).filter(tagId => counts[tagId] > 1)
}
export function findBatchTemplateTagIdConflicts(currentVariables, templateVariables) {
const existing = new Set((currentVariables || []).map(variable => normalizeBaseTagId(variable.TagID)).filter(Boolean))
return Array.from(new Set(
(templateVariables || [])
.map(variable => normalizeBaseTagId(variable.TagID))
.filter(tagId => tagId && existing.has(tagId))
))
}
export function batchTemplateNameExists(templates, name, ignoreId = '') {
const target = String(name || '').trim().toLowerCase()
return (templates || []).some(template => {
return template.id !== ignoreId && String(template.templateName || '').trim().toLowerCase() === target
})
}
export function getCopyTemplateName(existingTemplates, baseName) {
const rawName = String(baseName || '模板').trim() || '模板'
const sourceName = rawName.replace(/COPY\d+$/i, '') || rawName
const names = new Set((existingTemplates || []).map(template => String(template.templateName || '').trim().toLowerCase()))
let index = 1
let nextName = `${sourceName}COPY${index}`
while (names.has(nextName.toLowerCase())) {
index += 1
nextName = `${sourceName}COPY${index}`
}
return nextName
}

View File

@@ -15,6 +15,7 @@
<el-button icon="el-icon-upload2" @click="triggerImport">导入配置</el-button>
<el-button icon="el-icon-document" @click="handleExportConfig">导出配置</el-button>
<input ref="configFileInput" class="hidden-input" type="file" accept="application/json,.json" @change="handleImportConfig">
<input ref="batchExcelFileInput" class="hidden-input" type="file" accept=".xlsx,.xls" @change="handleBatchExcelInput">
</div>
</header>
@@ -124,14 +125,15 @@
:visible.sync="deviceDialogVisible"
width="1040px"
>
<button class="dialog-plus" type="button" title="新增完整工位" @click="addStationToDeviceForm()">
<i class="el-icon-plus"></i>
</button>
<section class="dialog-station-section">
<div class="dialog-station-head">
<span>设备模块配置</span>
<small>点击左上角 + 新增一条完整配置表格中每一行都会导出为设备类型对应表的一行</small>
<div class="dialog-station-title">
<span>设备模块配置</span>
<small>点击新增模块配置表格会新增一行每一行都会导出为设备类型对应表的一行</small>
</div>
<el-button size="mini" type="primary" icon="el-icon-plus" @click="addStationToDeviceForm()">
新增模块配置
</el-button>
</div>
<el-table
ref="deviceStationTable"
@@ -214,7 +216,6 @@
/>
</el-select>
<el-button size="small" :disabled="!variableTemplateSelectId" @click="applySelectedTemplateToVariable">使用模板</el-button>
<el-button size="small" @click="saveVariableFormAsTemplate">保存为模板</el-button>
</section>
<el-form ref="variableForm" :model="variableForm" :rules="variableRules" label-width="126px">
@@ -239,49 +240,133 @@
width="1040px"
class="template-dialog"
>
<div class="template-manager">
<aside class="template-list">
<div class="template-list-head">
<span>模板列表</span>
<el-button size="mini" type="primary" icon="el-icon-plus" @click="createTemplateInManager">新增模板</el-button>
</div>
<button
v-for="template in variableTemplates"
:key="template.id"
type="button"
class="template-row"
:class="{ active: selectedTemplateId === template.id }"
@click="selectTemplateForEdit(template.id)"
>
<strong>{{ template.templateName }}</strong>
<small>{{ template.description || '暂无说明' }}</small>
</button>
<div v-if="!variableTemplates.length" class="template-empty">暂无模板</div>
</aside>
<el-tabs v-model="templateManagerTab">
<el-tab-pane label="单条模板管理" name="single">
<div class="template-manager">
<aside class="template-list">
<div class="template-list-head">
<span>单条模板列表</span>
<el-button size="mini" type="primary" icon="el-icon-plus" @click="createTemplateInManager">新增模板</el-button>
</div>
<button
v-for="template in variableTemplates"
:key="template.id"
type="button"
class="template-row"
:class="{ active: selectedTemplateId === template.id }"
@click="selectTemplateForEdit(template.id)"
>
<strong>{{ template.templateName }}</strong>
<small>{{ template.description || '暂无说明' }}</small>
</button>
<div v-if="!variableTemplates.length" class="template-empty">暂无模板</div>
</aside>
<section class="template-editor">
<el-form label-width="104px">
<el-form-item label="模板名称" required>
<el-input v-model="templateForm.templateName" placeholder="请输入模板名称"/>
</el-form-item>
<el-form-item label="模板说明">
<el-input v-model="templateForm.description" placeholder="请输入模板说明"/>
</el-form-item>
</el-form>
<section class="template-editor">
<el-form label-width="104px">
<el-form-item label="模板名称" required>
<el-input v-model="templateForm.templateName" placeholder="请输入模板名称"/>
</el-form-item>
<el-form-item label="模板说明">
<el-input v-model="templateForm.description" placeholder="请输入模板说明"/>
</el-form-item>
</el-form>
<div class="template-field-title">模板字段</div>
<div class="template-field-grid">
<label v-for="field in templateEditableFields" :key="field" class="template-field">
<span>{{ field }}</span>
<el-input v-model="templateForm.fields[field]" size="mini"/>
</label>
<div class="template-field-title">模板字段</div>
<div class="template-field-grid">
<label v-for="field in templateEditableFields" :key="field" class="template-field">
<span>{{ field }}</span>
<el-input v-model="templateForm.fields[field]" size="mini"/>
</label>
</div>
</section>
</div>
</section>
</div>
</el-tab-pane>
<el-tab-pane label="批量模板管理" name="batch">
<div class="template-manager batch-template-manager">
<aside class="template-list">
<div class="template-list-head">
<span>批量模板列表</span>
<el-button size="mini" type="primary" icon="el-icon-upload2" @click="triggerBatchExcelImport">导入 Excel</el-button>
</div>
<button
v-for="template in batchVariableTemplates"
:key="template.id"
type="button"
class="template-row"
:class="{ active: selectedBatchTemplateId === template.id }"
@click="selectedBatchTemplateId = template.id"
>
<strong>{{ template.templateName }}</strong>
<small>{{ template.sourceFileName || '外部 Excel' }} / {{ template.variables.length }} </small>
</button>
<div v-if="!batchVariableTemplates.length" class="template-empty">暂无批量模板</div>
</aside>
<section class="template-editor batch-template-preview">
<template v-if="selectedBatchTemplate">
<div class="batch-template-summary">
<strong>{{ selectedBatchTemplate.templateName }}</strong>
<span>来源文件{{ selectedBatchTemplate.sourceFileName || '未知' }}</span>
<span>创建时间{{ formatTime(selectedBatchTemplate.createdAt) }}</span>
<span>变量数量{{ selectedBatchTemplate.variables.length }}</span>
<span v-if="selectedBatchTemplate.description">模板说明{{ selectedBatchTemplate.description }}</span>
</div>
<el-table :data="selectedBatchTemplate.variables" border size="mini" height="420">
<el-table-column prop="TagID" label="TagID" min-width="150" show-overflow-tooltip/>
<el-table-column prop="TagName" label="TagName" min-width="210" show-overflow-tooltip/>
<el-table-column prop="TagTypeID" label="TagTypeID" min-width="90"/>
<el-table-column prop="TagLong" label="TagLong" min-width="80"/>
<el-table-column prop="变量类型" label="变量类型" min-width="90"/>
<el-table-column prop="变量地址" label="变量地址" min-width="90"/>
<el-table-column prop="变量其它" label="变量其它" min-width="90"/>
</el-table>
</template>
<div v-else class="batch-preview-empty">请选择或导入一个批量模板</div>
</section>
</div>
</el-tab-pane>
</el-tabs>
<span slot="footer">
<el-button type="danger" plain :disabled="!selectedTemplateId" @click="deleteTemplateInManager">删除</el-button>
<el-button @click="templateDialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveTemplateInManager">保存模板</el-button>
<template v-if="templateManagerTab === 'single'">
<el-button type="danger" plain :disabled="!selectedTemplateId" @click="deleteTemplateInManager">删除</el-button>
<el-button :disabled="!selectedTemplateId" @click="cloneSelectedTemplate">克隆模板</el-button>
<el-button @click="templateDialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveTemplateInManager">保存模板</el-button>
</template>
<template v-else>
<el-button type="danger" plain :disabled="!selectedBatchTemplate" @click="deleteSelectedBatchTemplate">删除模板</el-button>
<el-button :disabled="!selectedBatchTemplate" @click="cloneSelectedBatchTemplate">克隆模板</el-button>
<el-button @click="templateDialogVisible = false">关闭</el-button>
<el-button type="primary" :disabled="!selectedBatchTemplate" @click="applySelectedBatchTemplate">使用批量模板</el-button>
</template>
</span>
</el-dialog>
<el-dialog
title="导入 Excel 为批量模板"
:visible.sync="batchImportDialogVisible"
width="620px"
>
<div v-if="batchImportCandidate" class="batch-import-summary">
<span>来源文件{{ batchImportCandidate.sourceFileName }}</span>
<span>读取 Sheet{{ batchImportCandidate.sheetName }}</span>
<span>有效变量数{{ batchImportCandidate.variableCount }}</span>
<span>忽略空行{{ batchImportCandidate.ignoredEmptyRows }}</span>
<span>忽略未知列{{ batchImportCandidate.ignoredUnknownColumns }}</span>
</div>
<el-form label-width="96px">
<el-form-item label="模板名称" required>
<el-input v-model="batchImportForm.templateName" placeholder="请输入批量模板名称"/>
</el-form-item>
<el-form-item label="模板说明">
<el-input v-model="batchImportForm.description" placeholder="请输入模板说明"/>
</el-form-item>
</el-form>
<span slot="footer">
<el-button @click="batchImportDialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveImportedBatchTemplate">保存为批量模板</el-button>
</span>
</el-dialog>
</div>
@@ -290,6 +375,7 @@
<script>
import defaultData from '@/data/default-signal-table.json'
import defaultVariableTemplates from '@/data/default-variable-templates.json'
import { importBatchTemplateFromExcel } from '@/utils/excelTemplateImporter'
import { exportSignalTable } from '@/utils/excelWriter'
import {
MES_HEADERS,
@@ -306,8 +392,13 @@ import {
} from '@/utils/signalTableBuilder'
import {
applyTemplateToVariableForm,
batchTemplateNameExists,
createEmptyTemplate,
createTemplateFromVariable,
createBatchVariableTemplate,
findBatchTemplateTagIdConflicts,
getCopyTemplateName,
normalizeBatchTemplateVariables,
normalizeBatchVariableTemplates,
normalizeVariableTemplates,
sanitizeTemplateForSave,
templateHasFields,
@@ -320,7 +411,7 @@ import {
loadConfigFromOpfs,
saveConfigToOpfs
} from '@/utils/opfsStore'
import { isAbortError, openJsonFile } from '@/utils/filePicker'
import { isAbortError, openExcelFile, openJsonFile } from '@/utils/filePicker'
function createEmptyDevice() {
return {
@@ -359,6 +450,7 @@ function createTemplateEditorForm(template) {
function normalizeConfigData(data) {
const normalized = normalizeData(data)
normalized.variableTemplates = normalizeVariableTemplates(normalized.variableTemplates, defaultVariableTemplates)
normalized.batchVariableTemplates = normalizeBatchVariableTemplates(normalized.batchVariableTemplates)
return normalized
}
@@ -391,9 +483,17 @@ export default {
variableColumns: MES_HEADERS,
variableGroups: VARIABLE_FIELD_GROUPS,
templateDialogVisible: false,
templateManagerTab: 'single',
selectedTemplateId: normalized.variableTemplates[0] ? normalized.variableTemplates[0].id : '',
templateForm: createTemplateEditorForm(normalized.variableTemplates[0]),
templateEditableFields: TEMPLATE_EDITABLE_FIELDS,
selectedBatchTemplateId: normalized.batchVariableTemplates[0] ? normalized.batchVariableTemplates[0].id : '',
batchImportDialogVisible: false,
batchImportCandidate: null,
batchImportForm: {
templateName: '',
description: ''
},
variableRules: {
TagID: [{ required: true, message: '请输入TagID', trigger: 'blur' }],
TagName: [{ required: true, message: '请输入TagName', trigger: 'blur' }],
@@ -424,6 +524,12 @@ export default {
variableTemplates() {
return Array.isArray(this.tableData.variableTemplates) ? this.tableData.variableTemplates : []
},
batchVariableTemplates() {
return Array.isArray(this.tableData.batchVariableTemplates) ? this.tableData.batchVariableTemplates : []
},
selectedBatchTemplate() {
return this.batchVariableTemplates.find(template => template.id === this.selectedBatchTemplateId) || null
},
filteredDevices() {
const key = this.keyword.trim().toLowerCase()
if (!key) return this.devices
@@ -692,30 +798,6 @@ export default {
})
this.$message.success('已使用模板填充空字段')
},
saveVariableFormAsTemplate() {
this.$prompt('请输入模板名称', '保存为模板', {
confirmButtonText: '保存',
cancelButtonText: '取消',
inputPattern: /.+/,
inputErrorMessage: '模板名称不能为空'
}).then(async ({ value }) => {
const name = String(value || '').trim()
if (templateNameExists(this.variableTemplates, name)) {
this.$message.warning('模板名称已存在')
return
}
const template = createTemplateFromVariable(this.variableForm, name)
if (!templateHasFields(template)) {
this.$message.warning('当前变量没有可保存的模板字段')
return
}
this.tableData.variableTemplates.push(template)
this.variableTemplateSelectId = template.id
await this.persistConfigSafely(true)
}).catch(() => {})
},
openTemplateManager() {
if (!this.variableTemplates.length) {
this.selectedTemplateId = ''
@@ -726,6 +808,9 @@ export default {
} else {
this.templateForm = createTemplateEditorForm(this.getTemplateById(this.selectedTemplateId))
}
if (this.batchVariableTemplates.length && !this.selectedBatchTemplate) {
this.selectedBatchTemplateId = this.batchVariableTemplates[0].id
}
this.templateDialogVisible = true
},
selectTemplateForEdit(id) {
@@ -780,6 +865,166 @@ export default {
this.persistConfigSafely(true)
}).catch(() => {})
},
async cloneSelectedTemplate() {
const template = this.getTemplateById(this.selectedTemplateId)
if (!template) return
const copy = {
id: `tpl-${Date.now()}`,
templateName: getCopyTemplateName(this.variableTemplates, template.templateName),
description: template.description || '',
fields: cloneData(template.fields || {})
}
this.tableData.variableTemplates.push(copy)
this.selectedTemplateId = copy.id
this.templateForm = createTemplateEditorForm(copy)
await this.persistConfigSafely(true)
},
async triggerBatchExcelImport() {
try {
const file = await openExcelFile()
if (file) {
await this.importBatchExcelFile(file)
return
}
} catch (error) {
if (isAbortError(error)) return
this.$alert(error && error.message ? error.message : String(error), '打开 Excel 失败', {
type: 'error'
})
return
}
this.$refs.batchExcelFileInput.click()
},
async handleBatchExcelInput(event) {
const file = event.target.files && event.target.files[0]
event.target.value = ''
if (!file) return
await this.importBatchExcelFile(file)
},
async importBatchExcelFile(file) {
try {
const candidate = await importBatchTemplateFromExcel(file)
this.batchImportCandidate = candidate
this.batchImportForm = {
templateName: String(file.name || '批量模板').replace(/\.(xlsx|xls)$/i, ''),
description: '从外部 Excel 导入'
}
this.batchImportDialogVisible = true
} catch (error) {
this.$alert(error && error.message ? error.message : String(error), '导入 Excel 失败', {
type: 'error'
})
}
},
async saveImportedBatchTemplate() {
if (!this.batchImportCandidate) return
const name = String(this.batchImportForm.templateName || '').trim()
if (!name) {
this.$message.warning('批量模板名称不能为空')
return
}
if (batchTemplateNameExists(this.batchVariableTemplates, name)) {
this.$message.warning('批量模板名称不能重复')
return
}
const variables = normalizeBatchTemplateVariables(this.batchImportCandidate.variables)
const duplicateTagIds = this.validateBatchTemplateUniqueTagIds(variables)
if (duplicateTagIds.length) {
this.$alert(`批量模板中存在重复基础 TagID${duplicateTagIds.join('、')}`, '保存失败', {
type: 'error'
})
return
}
const template = createBatchVariableTemplate({
...this.batchImportCandidate,
variables
}, name, this.batchImportForm.description)
this.tableData.batchVariableTemplates.push(template)
this.selectedBatchTemplateId = template.id
this.batchImportDialogVisible = false
this.batchImportCandidate = null
this.templateManagerTab = 'batch'
await this.persistConfigSafely(true)
},
validateBatchTemplateUniqueTagIds(variables) {
const counts = {}
;(variables || []).forEach(variable => {
const tagId = String(variable.TagID || '').trim()
if (!tagId) return
counts[tagId] = (counts[tagId] || 0) + 1
})
return Object.keys(counts).filter(tagId => counts[tagId] > 1)
},
deleteSelectedBatchTemplate() {
if (!this.selectedBatchTemplate) return
this.$confirm(`确认删除批量模板「${this.selectedBatchTemplate.templateName}」?`, '提示', {
type: 'warning'
}).then(() => {
const index = this.batchVariableTemplates.findIndex(template => template.id === this.selectedBatchTemplateId)
if (index !== -1) this.tableData.batchVariableTemplates.splice(index, 1)
const next = this.batchVariableTemplates[0]
this.selectedBatchTemplateId = next ? next.id : ''
this.persistConfigSafely(true)
}).catch(() => {})
},
async cloneSelectedBatchTemplate() {
if (!this.selectedBatchTemplate) return
const copy = {
id: `batch-tpl-${Date.now()}`,
templateName: getCopyTemplateName(this.batchVariableTemplates, this.selectedBatchTemplate.templateName),
description: this.selectedBatchTemplate.description || '',
sourceFileName: this.selectedBatchTemplate.sourceFileName || '',
createdAt: new Date().toISOString(),
variables: normalizeBatchTemplateVariables(cloneData(this.selectedBatchTemplate.variables || []))
}
this.tableData.batchVariableTemplates.push(copy)
this.selectedBatchTemplateId = copy.id
await this.persistConfigSafely(true)
},
async applySelectedBatchTemplate() {
if (!this.selectedDevice) {
this.$message.warning('请先选择设备模块,再使用批量模板。')
return
}
if (!this.selectedBatchTemplate) return
const deviceIndex = this.devices.findIndex(device => device.id === this.selectedDeviceId)
if (deviceIndex === -1) return
const variables = this.devices[deviceIndex].variables || []
const templateVariables = normalizeBatchTemplateVariables(this.selectedBatchTemplate.variables)
const duplicateTagIds = this.validateBatchTemplateUniqueTagIds(templateVariables)
if (duplicateTagIds.length) {
this.$alert(`批量模板中存在重复基础 TagID${duplicateTagIds.join('、')}`, '无法使用批量模板', {
type: 'error'
})
return
}
const conflicts = findBatchTemplateTagIdConflicts(variables, templateVariables)
const startIndex = conflicts.length ? 0 : variables.length
const importedVariables = templateVariables.map((variable, index) => ({
...variable,
'变量排序': String(startIndex + index)
}))
const nextVariables = conflicts.length ? importedVariables : variables.concat(importedVariables)
this.$set(this.tableData.devices[deviceIndex], 'variables', nextVariables)
this.selectedVariableIndex = startIndex
this.selectedDisplayVariable = null
this.$message.success(conflicts.length
? `检测到 TagID 冲突,已清空当前设备模块原变量并导入 ${templateVariables.length} 条变量`
: `已追加 ${templateVariables.length} 条变量`)
await this.persistConfigSafely(true)
},
applyChangedVariableFields(baseVariable, originalForm, currentForm) {
const next = this.stripSharedVariablePreviewFields(baseVariable)
@@ -886,7 +1131,7 @@ export default {
try {
const exportData = buildSingleDeviceExportData(this.tableData, this.selectedDevice.id)
const result = await exportSignalTable(exportData)
this.$message.success(result.method === 'picker' ? '已保存 SignalTable_CZ.xlsx' : '已下载 SignalTable_CZ.xlsx')
this.$message.success(result.method === 'picker' ? '已保存 SignalTable.xlsx' : '已下载 SignalTable.xlsx')
} catch (error) {
if (isAbortError(error)) return
this.$alert(error && error.message ? error.message : String(error), '导出失败', {
@@ -1295,6 +1540,37 @@ export default {
overflow: auto;
}
.batch-template-manager {
min-height: 560px;
}
.batch-template-preview {
min-width: 0;
}
.batch-template-summary,
.batch-import-summary {
display: flex;
flex-direction: column;
gap: 7px;
margin-bottom: 12px;
color: #526273;
font-size: 13px;
}
.batch-template-summary strong {
color: #1f2d3d;
font-size: 15px;
}
.batch-preview-empty {
height: 420px;
display: flex;
align-items: center;
justify-content: center;
color: #8b98a8;
}
.template-field-title {
margin: 8px 0 10px;
font-weight: 650;
@@ -1334,37 +1610,26 @@ export default {
column-gap: 12px;
}
.dialog-plus {
width: 34px;
height: 34px;
margin: -6px 0 12px;
border: 0;
background: transparent;
color: #f04b52;
font-size: 28px;
line-height: 1;
cursor: pointer;
}
.dialog-plus:hover {
color: #d9363e;
}
.dialog-station-section {
margin-top: 4px;
border-top: 1px solid #e3e8ef;
padding-top: 14px;
}
.dialog-station-head {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: space-between;
gap: 4px;
margin-bottom: 10px;
color: #1f2d3d;
font-weight: 650;
}
.dialog-station-title {
display: flex;
flex-direction: column;
gap: 4px;
}
.dialog-station-head small {
color: #8b98a8;
font-weight: 400;