This commit is contained in:
2026-04-01 14:27:35 +08:00
parent 9272a0162a
commit f88e9bedc2
18 changed files with 2264 additions and 353 deletions

View File

@ -33,6 +33,17 @@
class="quick-filter-input"
placeholder="按字段名/展示名/设备名筛选"
/>
<div class="filter-actions">
<el-button size="small" :disabled="!siteId" @click="openImportConfigDialog">导入配置</el-button>
<el-button size="small" type="primary" :disabled="!siteId" @click="exportConfig">导出配置</el-button>
<input
ref="configInput"
type="file"
accept=".csv"
style="display: none"
@change="handleConfigFileChange"
/>
</div>
</div>
<el-tabs v-model="activeTopMenu" style="margin-top: 16px;">
@ -407,7 +418,7 @@
>
<el-form :inline="true" class="select-container">
<el-form-item label="点位ID">
<el-input v-model.trim="pointSelectorQuery.pointId" clearable placeholder="请输入点位ID" style="width: 180px" />
<el-input v-model.trim="pointSelectorQuery.pointId" clearable placeholder="请输入点位ID(支持模糊)" style="width: 180px" />
</el-form-item>
<el-form-item label="点位描述">
<el-input v-model.trim="pointSelectorQuery.pointDesc" clearable placeholder="请输入点位描述" style="width: 180px" />
@ -503,6 +514,8 @@ export default {
},
autoSaveTimer: null,
autoSaveDelay: 900,
pointSelectorQueryTimer: null,
pointSelectorQueryDelay: 280,
suppressAutoSave: false,
isSaving: false,
saveStatusText: '自动保存已开启',
@ -540,6 +553,12 @@ export default {
handler() {
this.scheduleAutoSave()
}
},
'pointSelectorQuery.pointId'() {
this.schedulePointSelectorSearch()
},
'pointSelectorQuery.pointDesc'() {
this.schedulePointSelectorSearch()
}
},
methods: {
@ -687,13 +706,446 @@ export default {
this.pointSelectorQuery.pageNum = pageNum
this.loadPointSelectorList()
},
loadPointSelectorList() {
openImportConfigDialog() {
if (!this.siteId) {
this.$message.warning('请先选择站点')
return
}
if (this.$refs.configInput) {
this.$refs.configInput.value = ''
this.$refs.configInput.click()
}
},
handleConfigFileChange(event) {
const file = event && event.target && event.target.files && event.target.files[0]
if (!file) {
return
}
const isCsv = /\.csv$/i.test(file.name || '')
if (!isCsv) {
this.$message.error('仅支持导入 CSV 文件')
return
}
const reader = new FileReader()
reader.onload = async () => {
const text = (reader.result || '').toString()
const parsed = this.parseCsvImportData(text)
if (!parsed) {
this.$message.error('CSV 格式不正确,导入失败')
return
}
await this.confirmAndApplyImport(parsed, file.name || '配置文件')
}
reader.onerror = () => {
this.$message.error('读取文件失败,请重试')
}
reader.readAsText(file, 'utf-8')
},
async confirmAndApplyImport(parsedData, fileName) {
if (!this.siteId) {
this.$message.warning('请先选择站点')
return
}
const importedSiteId = (((parsedData || {}).meta || {}).siteId || (parsedData || {}).siteId || '').toString().trim()
if (importedSiteId && importedSiteId !== this.siteId) {
try {
await this.$confirm(
`文件站点(${importedSiteId})与当前站点(${this.siteId})不一致,仍要导入并覆盖当前配置吗?`,
'提示',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
showClose: false,
closeOnClickModal: false
}
)
} catch (error) {
return
}
} else {
try {
await this.$confirm(
`确认导入文件 ${fileName} 并覆盖当前页面配置吗?`,
'提示',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
showClose: false,
closeOnClickModal: false
}
)
} catch (error) {
return
}
}
this.applyImportedConfig(parsedData)
},
normalizeImportMappings(parsedData) {
const data = parsedData || {}
const mappings = []
if (Array.isArray(data.mappings)) {
return data.mappings
}
if (data.pointMapping && Array.isArray(data.pointMapping.mappings)) {
return data.pointMapping.mappings
}
if (data.pointPayload && Array.isArray(data.pointPayload.mappings)) {
return data.pointPayload.mappings
}
return mappings
},
normalizeImportDeletedFieldCodes(parsedData) {
const data = parsedData || {}
const source = data.deletedFieldCodes || ((data.pointMapping || {}).deletedFieldCodes) || ((data.pointPayload || {}).deletedFieldCodes) || []
if (!Array.isArray(source)) {
return []
}
return source
.map(item => (item === null || item === undefined ? '' : String(item).trim()))
.filter(item => !!item)
},
normalizeImportEnumMappings(parsedData) {
const data = parsedData || {}
if (Array.isArray(data.enumMappings)) {
return data.enumMappings
}
if (data.workStatusEnum && Array.isArray(data.workStatusEnum.mappings)) {
return data.workStatusEnum.mappings
}
return []
},
escapeCsvCell(value) {
if (value === null || value === undefined) {
return ''
}
const text = String(value)
if (/[",\n\r]/.test(text)) {
return `"${text.replace(/"/g, '""')}"`
}
return text
},
parseCsvText(text) {
if (!text) {
return []
}
const normalized = String(text).replace(/^\uFEFF/, '')
const rows = []
let row = []
let cell = ''
let inQuotes = false
for (let i = 0; i < normalized.length; i += 1) {
const ch = normalized[i]
if (inQuotes) {
if (ch === '"') {
if (normalized[i + 1] === '"') {
cell += '"'
i += 1
} else {
inQuotes = false
}
} else {
cell += ch
}
continue
}
if (ch === '"') {
inQuotes = true
continue
}
if (ch === ',') {
row.push(cell)
cell = ''
continue
}
if (ch === '\n') {
row.push(cell)
rows.push(row)
row = []
cell = ''
continue
}
if (ch === '\r') {
if (normalized[i + 1] === '\n') {
continue
}
row.push(cell)
rows.push(row)
row = []
cell = ''
continue
}
cell += ch
}
row.push(cell)
if (row.some(item => item !== '')) {
rows.push(row)
}
return rows
},
mapCsvHeaderIndexes(headers) {
const headerMap = {}
const aliasMap = {
type: ['type'],
siteId: ['siteId'],
siteName: ['siteName'],
exportedAt: ['exportedAt'],
fieldCode: ['fieldCode'],
deviceId: ['deviceId'],
dataPoint: ['dataPoint', 'pointId'],
fixedDataPoint: ['fixedDataPoint'],
useFixedDisplay: ['useFixedDisplay'],
deletedFieldCode: ['deletedFieldCode'],
deviceCategory: ['deviceCategory'],
matchField: ['matchField'],
enumCode: ['enumCode'],
enumName: ['enumName'],
dataEnumCode: ['dataEnumCode'],
enumDesc: ['enumDesc']
}
const normalized = headers.map(item => String(item || '').trim())
Object.keys(aliasMap).forEach(key => {
const aliases = aliasMap[key]
const index = normalized.findIndex(item => aliases.includes(item))
if (index >= 0) {
headerMap[key] = index
}
})
return headerMap
},
parseCsvImportData(text) {
const rows = this.parseCsvText(text)
if (!rows.length) {
return null
}
const headerIndexes = this.mapCsvHeaderIndexes(rows[0] || [])
if (Object.keys(headerIndexes).length === 0) {
return null
}
const meta = {}
const mappings = []
const deletedFieldCodes = []
const parseUseFixedDisplay = value => {
const textValue = String(value || '').trim().toLowerCase()
if (['1', 'true', '是', 'y', 'yes'].includes(textValue)) {
return 1
}
return 0
}
for (let i = 1; i < rows.length; i += 1) {
const row = rows[i]
if (!row || row.every(cell => String(cell || '').trim() === '')) {
continue
}
const getValue = key => {
const index = headerIndexes[key]
if (index === undefined) {
return ''
}
return row[index] === undefined || row[index] === null ? '' : String(row[index]).trim()
}
const type = getValue('type').toLowerCase()
const siteId = getValue('siteId')
const siteName = getValue('siteName')
const exportedAt = getValue('exportedAt')
if (type === 'meta' || (!type && (siteId || siteName || exportedAt))) {
if (siteId) {
meta.siteId = siteId
}
if (siteName) {
meta.siteName = siteName
}
if (exportedAt) {
meta.exportedAt = exportedAt
}
continue
}
const fieldCode = getValue('fieldCode')
const deviceId = getValue('deviceId')
const dataPoint = getValue('dataPoint')
const fixedDataPoint = getValue('fixedDataPoint')
const useFixedDisplay = parseUseFixedDisplay(getValue('useFixedDisplay'))
const deletedFieldCode = getValue('deletedFieldCode')
if (type === 'deleted' || (!type && deletedFieldCode && !fieldCode)) {
if (deletedFieldCode) {
deletedFieldCodes.push(deletedFieldCode)
}
continue
}
if (type === 'point' || fieldCode) {
if (!fieldCode) {
continue
}
mappings.push({
fieldCode,
deviceId,
dataPoint,
fixedDataPoint,
useFixedDisplay
})
}
}
return {
meta,
mappings,
deletedFieldCodes
}
},
buildMappingKey(fieldCode, deviceId) {
return `${(fieldCode || '').toString().trim()}::${(deviceId || '').toString().trim()}`
},
applyImportedConfig(parsedData) {
const importedMappings = this.normalizeImportMappings(parsedData)
const importedDeletedFieldCodes = this.normalizeImportDeletedFieldCodes(parsedData)
if (!Array.isArray(importedMappings) || importedMappings.length === 0) {
this.$message.error('导入文件缺少 mappings 数据')
return
}
const mappingByKey = new Map()
importedMappings.forEach(item => {
const fieldCode = ((item && item.fieldCode) || '').toString().trim()
if (!fieldCode) {
return
}
const deviceId = ((item && item.deviceId) || '').toString().trim()
const key = this.buildMappingKey(fieldCode, deviceId)
mappingByKey.set(key, {
dataPoint: ((item && item.dataPoint) || '').toString(),
fixedDataPoint: ((item && item.fixedDataPoint) || '').toString(),
useFixedDisplay: item && item.useFixedDisplay === 1 ? 1 : 0
})
})
const allRows = this.getAllMappingRows()
let hitCount = 0
allRows.forEach(row => {
const key = this.buildMappingKey(row && row.field, row && row.deviceId)
const mapped = mappingByKey.get(key)
if (mapped) {
row.point = mapped.dataPoint
row.fixedValue = mapped.fixedDataPoint
row.useFixedDisplay = mapped.useFixedDisplay
hitCount += 1
} else {
row.point = ''
row.fixedValue = ''
row.useFixedDisplay = 0
}
})
this.suppressAutoSave = true
this.deletedFieldCodes = importedDeletedFieldCodes
this.$nextTick(() => {
this.suppressAutoSave = false
this.scheduleAutoSave()
})
const missCount = Math.max(mappingByKey.size - hitCount, 0)
if (missCount > 0) {
this.$message.warning(`导入完成:匹配 ${hitCount} 条,忽略 ${missCount} 条(当前页面无对应字段/设备)`)
} else {
this.$message.success(`导入完成:匹配 ${hitCount} 条,系统将自动保存`)
}
},
escapeJsonValue(value) {
if (value === undefined || value === null) {
return ''
}
return String(value)
},
exportConfig() {
if (!this.siteId) {
this.$message.warning('请先选择站点')
return
}
const pointPayload = this.buildSavePayload()
const headers = [
'type',
'siteId',
'siteName',
'exportedAt',
'fieldCode',
'deviceId',
'dataPoint',
'fixedDataPoint',
'useFixedDisplay',
'deletedFieldCode'
]
const rows = []
rows.push([
'meta',
this.siteId,
this.siteName || '',
new Date().toISOString(),
'',
'',
'',
'',
'',
''
])
;(pointPayload.mappings || []).forEach(item => {
rows.push([
'point',
this.siteId,
this.siteName || '',
'',
item.fieldCode || '',
item.deviceId || '',
item.dataPoint || '',
item.fixedDataPoint || '',
item.useFixedDisplay === 1 ? '1' : '0',
''
])
})
;(pointPayload.deletedFieldCodes || []).forEach(code => {
rows.push([
'deleted',
this.siteId,
this.siteName || '',
'',
'',
'',
'',
'',
'',
code || ''
])
})
const csvText = [headers.map(cell => this.escapeCsvCell(cell)).join(','), ...rows.map(row => row.map(cell => this.escapeCsvCell(cell)).join(','))].join('\n')
const blob = new Blob([`\uFEFF${csvText}`], { type: 'text/csv;charset=utf-8;' })
const safeSiteId = this.escapeJsonValue(this.siteId).replace(/[^\w-]/g, '_')
const fileName = `single_monitor_mapping_${safeSiteId || 'site'}_${Date.now()}.csv`
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = fileName
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
this.$message.success('导出成功')
},
schedulePointSelectorSearch() {
if (!this.pointSelectorVisible) {
return
}
if (this.pointSelectorQueryTimer) {
clearTimeout(this.pointSelectorQueryTimer)
}
this.pointSelectorQueryTimer = setTimeout(() => {
this.pointSelectorQuery.pageNum = 1
this.loadPointSelectorList()
}, this.pointSelectorQueryDelay)
},
loadPointSelectorList(options = {}) {
const { silent = false } = options
if (!this.pointSelectorQuery.siteId) {
this.pointSelectorList = []
this.pointSelectorTotal = 0
return
}
this.pointSelectorLoading = true
if (!silent) {
this.pointSelectorLoading = true
}
const query = {
...this.pointSelectorQuery
}
@ -705,7 +1157,9 @@ export default {
this.pointSelectorTotal = response?.total || 0
})
.finally(() => {
this.pointSelectorLoading = false
if (!silent) {
this.pointSelectorLoading = false
}
})
},
getFilteredQuickGroups(list) {
@ -1018,6 +1472,10 @@ export default {
if (this.autoSaveTimer) {
clearTimeout(this.autoSaveTimer)
}
if (this.pointSelectorQueryTimer) {
clearTimeout(this.pointSelectorQueryTimer)
this.pointSelectorQueryTimer = null
}
},
async mounted() {
this.siteId = this.$route.query.siteId || ''
@ -1093,6 +1551,7 @@ export default {
align-items: center;
justify-content: flex-start;
gap: 12px;
flex-wrap: wrap;
}
.save-status-text {
@ -1105,6 +1564,12 @@ export default {
width: 280px;
}
.filter-actions {
display: flex;
align-items: center;
gap: 8px;
}
.quick-wrapper {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));