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,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;