feat: update Excel template management and export handling
This commit is contained in:
74
src/utils/excelTemplateImporter.js
Normal file
74
src/utils/excelTemplateImporter.js
Normal 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 : []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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: '设备类型对应表',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user