Files
emsfront/src/views/ems/site/zdlb/MonitorPointMapping.vue
xiaoyang 801d8eab1d 1. 单体电池批量更改为一个批量生成的按钮
2. 运行曲线数据改用电表报表的数据
2026-04-15 22:44:21 +08:00

1889 lines
67 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="ems-dashboard-editor-container" style="background-color:#ffffff">
<div class="header-row">
<div class="title-block">
<div class="page-title">单站监控项目点位配置</div>
<div class="page-desc">
<span class="site-label">站点</span>
<span>{{ siteName || siteId || '-' }}</span>
</div>
</div>
</div>
<el-alert
title="当前字段清单与点位映射均从后端表读取,保存后可直接用于单站监控查询。"
type="info"
:closable="false"
style="margin-top: 16px;"
/>
<el-tabs v-model="activeConfigTab" style="margin-top: 16px;">
<el-tab-pane label="点位配置" name="point">
<div class="mode-switch-row">
<el-radio-group v-model="configMode" size="small">
<el-radio-button label="table">列表配置</el-radio-button>
<el-radio-button label="quick">快速配置</el-radio-button>
<el-radio-button label="section">分组填报</el-radio-button>
</el-radio-group>
<div class="save-status-text">{{ saveStatusText }}</div>
<el-input
v-model.trim="quickFilter"
size="small"
clearable
class="quick-filter-input"
placeholder="按字段名/展示名/设备名筛选"
/>
<div class="filter-actions" :class="{ 'single-battery-actions-visible': showSingleBatteryImportActions }">
<el-button size="small" :disabled="!siteId" @click="openImportConfigDialog">导入配置</el-button>
<el-button size="small" type="primary" :disabled="!siteId" @click="exportConfig">导出配置</el-button>
<el-button v-if="showSingleBatteryImportActions" size="small" type="success" :disabled="!siteId || singleBatteryInitLoading" @click="openSingleBatteryInitDialog">
{{ singleBatteryInitLoading ? '初始化中...' : '初始化单体电池配置' }}
</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;">
<el-tab-pane
v-for="topMenu in topMenuList"
:key="topMenu.code"
:label="topMenu.name"
:name="topMenu.code"
>
<template v-if="configMode === 'section'">
<div v-if="!topMenu.children || topMenu.children.length === 0" class="section-wrapper">
<el-collapse
accordion
class="section-collapse"
:value="getSectionActiveValue(buildSectionPanelKey(topMenu.code, 'ROOT'), getFilteredSectionBlocks(topMenu.items))"
@input="setSectionActiveValue(buildSectionPanelKey(topMenu.code, 'ROOT'), $event)"
>
<el-collapse-item
v-for="sectionBlock in getFilteredSectionBlocks(topMenu.items)"
:key="sectionBlock.key"
:name="sectionBlock.key"
>
<template slot="title">
<div class="section-title-row">
<span>{{ sectionBlock.section }}</span>
<span class="section-badge">{{ sectionBlock.fields.length }} 个字段</span>
</div>
</template>
<div class="section-field-list">
<div v-for="group in sectionBlock.fields" :key="group.key" class="section-field-card">
<div class="quick-card-header">
<div class="quick-title-block">
<div class="quick-title">{{ group.name }}</div>
<div class="quick-sub-title">{{ group.field }}</div>
</div>
<el-button type="text" size="mini" @click="clearGroupMapping(group)">清空</el-button>
</div>
<div v-for="row in group.rows" :key="row.code" class="quick-row">
<div class="quick-device-name">{{ row.deviceName || '默认映射' }}</div>
<el-input
v-model.trim="row.point"
:readonly="!!row.deviceId"
:placeholder="row.deviceId ? '点击选择点位' : '请输入点位'"
clearable
class="quick-input"
@focus="handlePointInputFocus(row)"
@mousedown.native.prevent="handlePointInputFocus(row)"
/>
<el-input v-model.trim="row.fixedValue" placeholder="固定展示值" clearable class="quick-input" />
<el-checkbox v-model="row.useFixedDisplay" :true-label="1" :false-label="0">固定展示</el-checkbox>
</div>
</div>
</div>
</el-collapse-item>
</el-collapse>
<el-empty v-if="getFilteredSectionBlocks(topMenu.items).length === 0" description="暂无可配置项" />
</div>
<el-tabs
v-else
v-model="activeChildMap[topMenu.code]"
class="child-menu-tabs"
type="border-card"
>
<el-tab-pane
v-for="child in topMenu.children"
:key="child.code"
:label="child.name"
:name="child.code"
>
<div class="section-wrapper">
<el-collapse
accordion
class="section-collapse"
:value="getSectionActiveValue(buildSectionPanelKey(topMenu.code, child.code), getFilteredSectionBlocks(child.items))"
@input="setSectionActiveValue(buildSectionPanelKey(topMenu.code, child.code), $event)"
>
<el-collapse-item
v-for="sectionBlock in getFilteredSectionBlocks(child.items)"
:key="sectionBlock.key"
:name="sectionBlock.key"
>
<template slot="title">
<div class="section-title-row">
<span>{{ sectionBlock.section }}</span>
<span class="section-badge">{{ sectionBlock.fields.length }} 个字段</span>
</div>
</template>
<div class="section-field-list">
<div v-for="group in sectionBlock.fields" :key="group.key" class="section-field-card">
<div class="quick-card-header">
<div class="quick-title-block">
<div class="quick-title">{{ group.name }}</div>
<div class="quick-sub-title">{{ group.field }}</div>
</div>
<el-button type="text" size="mini" @click="clearGroupMapping(group)">清空</el-button>
</div>
<div v-for="row in group.rows" :key="row.code" class="quick-row">
<div class="quick-device-name">{{ row.deviceName || '默认映射' }}</div>
<el-input
v-model.trim="row.point"
:readonly="!!row.deviceId"
:placeholder="row.deviceId ? '点击选择点位' : '请输入点位'"
clearable
class="quick-input"
@focus="handlePointInputFocus(row)"
@mousedown.native.prevent="handlePointInputFocus(row)"
/>
<el-input v-model.trim="row.fixedValue" placeholder="固定展示值" clearable class="quick-input" />
<el-checkbox v-model="row.useFixedDisplay" :true-label="1" :false-label="0">固定展示</el-checkbox>
</div>
</div>
</div>
</el-collapse-item>
</el-collapse>
<el-empty v-if="getFilteredSectionBlocks(child.items).length === 0" description="暂无可配置项" />
</div>
</el-tab-pane>
</el-tabs>
</template>
<template v-else-if="configMode === 'quick'">
<div v-if="!topMenu.children || topMenu.children.length === 0" class="quick-wrapper">
<div
v-for="group in getFilteredQuickGroups(topMenu.items)"
:key="group.key"
class="quick-card"
>
<div class="quick-card-header">
<div class="quick-title-block">
<div class="quick-title">{{ group.name }}</div>
<div class="quick-sub-title">{{ group.section }} | {{ group.field }}</div>
</div>
<el-button type="text" size="mini" @click="clearGroupMapping(group)">清空</el-button>
</div>
<div v-for="row in group.rows" :key="row.code" class="quick-row">
<div class="quick-device-name">{{ row.deviceName || '默认映射' }}</div>
<el-input
v-model.trim="row.point"
:readonly="!!row.deviceId"
:placeholder="row.deviceId ? '点击选择点位' : '请输入点位'"
clearable
class="quick-input"
@focus="handlePointInputFocus(row)"
@mousedown.native.prevent="handlePointInputFocus(row)"
/>
<el-input v-model.trim="row.fixedValue" placeholder="固定展示值" clearable class="quick-input" />
<el-checkbox v-model="row.useFixedDisplay" :true-label="1" :false-label="0">固定展示</el-checkbox>
</div>
</div>
<el-empty v-if="getFilteredQuickGroups(topMenu.items).length === 0" description="暂无可配置项" />
</div>
<el-tabs
v-else
v-model="activeChildMap[topMenu.code]"
class="child-menu-tabs"
type="border-card"
>
<el-tab-pane
v-for="child in topMenu.children"
:key="child.code"
:label="child.name"
:name="child.code"
>
<div class="quick-wrapper">
<div
v-for="group in getFilteredQuickGroups(child.items)"
:key="group.key"
class="quick-card"
>
<div class="quick-card-header">
<div class="quick-title-block">
<div class="quick-title">{{ group.name }}</div>
<div class="quick-sub-title">{{ group.section }} | {{ group.field }}</div>
</div>
<el-button type="text" size="mini" @click="clearGroupMapping(group)">清空</el-button>
</div>
<div v-for="row in group.rows" :key="row.code" class="quick-row">
<div class="quick-device-name">{{ row.deviceName || '默认映射' }}</div>
<el-input
v-model.trim="row.point"
:readonly="!!row.deviceId"
:placeholder="row.deviceId ? '点击选择点位' : '请输入点位'"
clearable
class="quick-input"
@focus="handlePointInputFocus(row)"
@mousedown.native.prevent="handlePointInputFocus(row)"
/>
<el-input v-model.trim="row.fixedValue" placeholder="固定展示值" clearable class="quick-input" />
<el-checkbox v-model="row.useFixedDisplay" :true-label="1" :false-label="0">固定展示</el-checkbox>
</div>
</div>
<el-empty v-if="getFilteredQuickGroups(child.items).length === 0" description="暂无可配置项" />
</div>
</el-tab-pane>
</el-tabs>
</template>
<el-table
v-else-if="!topMenu.children || topMenu.children.length === 0"
class="common-table"
:data="topMenu.items"
stripe
>
<el-table-column prop="section" label="分组" min-width="180" />
<el-table-column prop="name" label="页面展示名称" min-width="280" />
<el-table-column prop="field" label="字段名" min-width="260" />
<el-table-column prop="deviceName" label="设备" min-width="220">
<template slot-scope="scope">
<span>{{ scope.row.deviceName || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="点位" min-width="300">
<template slot-scope="scope">
<el-input
v-model.trim="scope.row.point"
:readonly="!!scope.row.deviceId"
:placeholder="scope.row.deviceId ? '点击选择点位' : '请输入点位'"
clearable
class="short-input"
@focus="handlePointInputFocus(scope.row)"
@mousedown.native.prevent="handlePointInputFocus(scope.row)"
/>
</template>
</el-table-column>
<el-table-column label="固定展示值" min-width="300">
<template slot-scope="scope">
<el-input
v-model.trim="scope.row.fixedValue"
placeholder="请输入固定展示值"
clearable
class="short-input"
/>
</template>
</el-table-column>
<el-table-column label="固定展示" width="120" align="center">
<template slot-scope="scope">
<el-checkbox v-model="scope.row.useFixedDisplay" :true-label="1" :false-label="0" />
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template slot-scope="scope">
<el-button
type="text"
size="small"
:disabled="!!scope.row.deviceId"
@click="handleDeleteItem(scope.$index, topMenu.items)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<el-tabs
v-else
v-model="activeChildMap[topMenu.code]"
class="child-menu-tabs"
type="border-card"
>
<el-tab-pane
v-for="child in topMenu.children"
:key="child.code"
:label="child.name"
:name="child.code"
>
<el-table class="common-table" :data="child.items" stripe>
<el-table-column prop="section" label="分组" min-width="180" />
<el-table-column prop="name" label="页面展示名称" min-width="280" />
<el-table-column prop="field" label="字段名" min-width="260" />
<el-table-column prop="deviceName" label="设备" min-width="220">
<template slot-scope="scope">
<span>{{ scope.row.deviceName || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="点位" min-width="300">
<template slot-scope="scope">
<el-input
v-model.trim="scope.row.point"
:readonly="!!scope.row.deviceId"
:placeholder="scope.row.deviceId ? '点击选择点位' : '请输入点位'"
clearable
class="short-input"
@focus="handlePointInputFocus(scope.row)"
@mousedown.native.prevent="handlePointInputFocus(scope.row)"
/>
</template>
</el-table-column>
<el-table-column label="固定展示值" min-width="300">
<template slot-scope="scope">
<el-input
v-model.trim="scope.row.fixedValue"
placeholder="请输入固定展示值"
clearable
class="short-input"
/>
</template>
</el-table-column>
<el-table-column label="固定展示" width="120" align="center">
<template slot-scope="scope">
<el-checkbox v-model="scope.row.useFixedDisplay" :true-label="1" :false-label="0" />
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template slot-scope="scope">
<el-button
type="text"
size="small"
:disabled="!!scope.row.deviceId"
@click="handleDeleteItem(scope.$index, child.items)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
</el-tab-pane>
</el-tabs>
</el-tab-pane>
<el-tab-pane label="状态枚举映射" name="workStatusEnum">
<el-card class="enum-mapping-card" shadow="never">
<div slot="header" class="enum-mapping-header">
<span>状态枚举映射</span>
<div>
<el-button type="text" size="mini" @click="resetWorkStatusEnumMappings">恢复默认</el-button>
<el-button type="text" size="mini" @click="addWorkStatusEnumMapping">新增映射</el-button>
</div>
</div>
<el-radio-group v-model="activeEnumScope" size="mini" class="enum-scope-group">
<el-radio-button v-for="scope in enumScopeOptions" :key="scope.key" :label="scope.key">{{ scope.label }}</el-radio-button>
</el-radio-group>
<el-table class="common-table" :data="getEnumMappingsByScope(activeEnumScope)" size="mini" stripe>
<el-table-column label="系统状态编码" width="130">
<template slot-scope="scope">
<el-input v-model.trim="scope.row.enumCode" placeholder="如 3" />
</template>
</el-table-column>
<el-table-column label="系统状态名称" width="160">
<template slot-scope="scope">
<el-input v-model.trim="scope.row.enumName" placeholder="如 待机" />
</template>
</el-table-column>
<el-table-column label="站点上送值" width="180">
<template slot-scope="scope">
<el-input v-model.trim="scope.row.dataEnumCode" placeholder="如 1 / 3 / 4" />
</template>
</el-table-column>
<el-table-column label="描述" min-width="220">
<template slot-scope="scope">
<el-input v-model.trim="scope.row.enumDesc" placeholder="可选描述" />
</template>
</el-table-column>
<el-table-column label="操作" width="90" align="center">
<template slot-scope="scope">
<el-button type="text" size="mini" @click="removeWorkStatusEnumMapping(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="enum-mapping-tip">说明按上方状态项分别维护站点上送值 -> 系统状态编码/名称的映射关系</div>
</el-card>
</el-tab-pane>
</el-tabs>
<el-dialog
title="选择点位"
:visible.sync="pointSelectorVisible"
width="900px"
:close-on-click-modal="false"
>
<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-form-item>
<el-form-item label="点位描述">
<el-input v-model.trim="pointSelectorQuery.pointDesc" clearable placeholder="请输入点位描述" style="width: 180px" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="searchPointSelector">搜索</el-button>
<el-button @click="resetPointSelector">重置</el-button>
</el-form-item>
</el-form>
<el-table
v-loading="pointSelectorLoading"
:data="pointSelectorList"
class="common-table"
stripe
max-height="460px"
>
<el-table-column prop="pointId" label="点位ID" min-width="260" />
<el-table-column prop="pointName" label="点位名称" min-width="160" />
<el-table-column prop="pointDesc" label="点位描述" min-width="220" />
<el-table-column prop="deviceCategory" label="设备类型" width="120" />
<el-table-column prop="deviceId" label="设备ID" min-width="140" />
<el-table-column label="操作" width="90" align="center" fixed="right">
<template slot-scope="scope">
<el-button type="text" size="mini" @click="selectPointFromDialog(scope.row)">选择</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-show="pointSelectorTotal > 0"
background
layout="total, prev, pager, next"
:current-page="pointSelectorQuery.pageNum"
:page-size="pointSelectorQuery.pageSize"
:total="pointSelectorTotal"
style="margin-top: 12px; text-align: center"
@current-change="handlePointSelectorPageChange"
/>
</el-dialog>
<el-dialog
title="初始化单体电池配置"
:visible.sync="singleBatteryInitDialogVisible"
width="560px"
append-to-body
class="ems-dialog"
>
<el-form label-width="110px" size="small">
<el-form-item label="电池堆">
<el-select v-model="singleBatteryInitForm.stackDeviceId" placeholder="请选择电池堆" style="width: 100%" @change="handleSingleBatteryStackChange">
<el-option v-for="item in singleBatteryStackOptions" :key="item.id || item.deviceId || item" :label="item.name || item.deviceName || item.id || item.deviceId || item" :value="item.id || item.deviceId || item" />
</el-select>
</el-form-item>
<el-form-item label="电池簇">
<el-select v-model="singleBatteryInitForm.clusterDeviceId" placeholder="请选择电池簇" style="width: 100%">
<el-option v-for="item in singleBatteryClusterOptions" :key="item.id || item.deviceId || item" :label="item.name || item.deviceName || item.id || item.deviceId || item" :value="item.id || item.deviceId || item" />
</el-select>
</el-form-item>
<el-form-item label="目标数量">
<el-input-number v-model="singleBatteryInitForm.targetCount" :min="1" :precision="0" controls-position="right" style="width: 220px" />
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button @click="singleBatteryInitDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="singleBatteryInitLoading" @click="submitSingleBatteryInit">确定初始化</el-button>
</div>
</el-dialog>
<el-dialog
title="初始化结果"
:visible.sync="singleBatteryInitResultVisible"
width="980px"
append-to-body
class="ems-dialog"
>
<el-alert
:title="singleBatteryInitResult.message || '初始化完成'"
:type="singleBatteryInitResult.committed ? 'success' : 'warning'"
:closable="false"
style="margin-bottom: 16px;"
/>
<el-descriptions :column="3" border size="small">
<el-descriptions-item label="站点ID">{{ singleBatteryInitResult.siteId || '-' }}</el-descriptions-item>
<el-descriptions-item label="初始化范围">{{ singleBatteryInitResult.scopeType === 'stack' ? '电池堆' : '电池簇' }}</el-descriptions-item>
<el-descriptions-item label="范围设备ID">{{ singleBatteryInitResult.scopeDeviceId || '-' }}</el-descriptions-item>
<el-descriptions-item label="目标数量">{{ singleBatteryInitResult.targetCount || 0 }}</el-descriptions-item>
<el-descriptions-item label="现有单体">{{ singleBatteryInitResult.existingBatteryCount || 0 }}</el-descriptions-item>
<el-descriptions-item label="已初始化单体">{{ singleBatteryInitResult.initializedBatteryCount || 0 }}</el-descriptions-item>
<el-descriptions-item label="新增单体">{{ singleBatteryInitResult.insertedBatteryCount || 0 }}</el-descriptions-item>
<el-descriptions-item label="命中点位">{{ singleBatteryInitResult.pointHitCount || 0 }}</el-descriptions-item>
<el-descriptions-item label="固定值回填">{{ singleBatteryInitResult.fixedValueFallbackCount || 0 }}</el-descriptions-item>
<el-descriptions-item label="新增映射">{{ singleBatteryInitResult.insertedMappingCount || 0 }}</el-descriptions-item>
<el-descriptions-item label="更新映射">{{ singleBatteryInitResult.updatedMappingCount || 0 }}</el-descriptions-item>
<el-descriptions-item label="是否成功">{{ singleBatteryInitResult.committed ? '是' : '否' }}</el-descriptions-item>
</el-descriptions>
</el-dialog>
</div>
</template>
<script>
import {
getPointMatchList,
getSingleMonitorProjectPointMapping,
getSingleMonitorWorkStatusEnumMappings,
initializeSingleBatteryMonitorMappings,
saveSingleMonitorProjectPointMapping,
saveSingleMonitorWorkStatusEnumMappings
} from '@/api/ems/site'
import { getClusterNameList, getStackNameList } from '@/api/ems/dzjk'
export default {
name: 'MonitorPointMapping',
data() {
return {
siteId: '',
siteName: '',
activeConfigTab: 'point',
activeEnumScope: 'PCS|workStatus',
enumScopeOptions: [
{ key: 'PCS|workStatus', label: 'PCS-工作状态' },
{ key: 'PCS|gridStatus', label: 'PCS-并网状态' },
{ key: 'PCS|deviceStatus', label: 'PCS-设备状态' },
{ key: 'PCS|controlMode', label: 'PCS-控制模式' },
{ key: 'STACK|workStatus', label: 'BMS总览-工作状态' },
{ key: 'STACK|pcsCommunicationStatus', label: 'BMS总览-与PCS通信' },
{ key: 'STACK|emsCommunicationStatus', label: 'BMS总览-与EMS通信' },
{ key: 'CLUSTER|workStatus', label: 'BMS电池簇-工作状态' },
{ key: 'CLUSTER|pcsCommunicationStatus', label: 'BMS电池簇-与PCS通信' },
{ key: 'CLUSTER|emsCommunicationStatus', label: 'BMS电池簇-与EMS通信' }
],
configMode: 'table',
quickFilter: '',
activeTopMenu: 'HOME',
activeChildMap: {},
sectionActiveMap: {},
topMenuList: [],
deletedFieldCodes: [],
currentPointRow: null,
pointSelectorVisible: false,
pointSelectorLoading: false,
pointSelectorTotal: 0,
pointSelectorList: [],
pointSelectorQuery: {
pageNum: 1,
pageSize: 10,
siteId: '',
deviceCategory: '',
deviceId: '',
pointId: '',
pointDesc: ''
},
autoSaveTimer: null,
autoSaveDelay: 900,
pointSelectorQueryTimer: null,
pointSelectorQueryDelay: 280,
suppressAutoSave: false,
isSaving: false,
saveStatusText: '自动保存已开启',
singleBatteryInitLoading: false,
singleBatteryInitDialogVisible: false,
singleBatteryInitResultVisible: false,
singleBatteryStackOptions: [],
singleBatteryClusterOptions: [],
singleBatteryInitForm: {
stackDeviceId: '',
clusterDeviceId: '',
targetCount: 1
},
singleBatteryInitResult: {
committed: false,
siteId: '',
scopeType: 'stack',
scopeDeviceId: '',
targetCount: 0,
existingBatteryCount: 0,
initializedBatteryCount: 0,
insertedBatteryCount: 0,
pointHitCount: 0,
fixedValueFallbackCount: 0,
insertedMappingCount: 0,
updatedMappingCount: 0,
message: ''
},
lastSavedPointSignature: '',
lastSavedEnumSignature: '',
workStatusEnumMappings: []
}
},
watch: {
'$route.query.siteId': {
immediate: false,
async handler(newSiteId) {
if (newSiteId === this.siteId) {
return
}
this.siteId = newSiteId || ''
this.siteName = this.$route.query.siteName || this.siteId
await this.initMenuStructure()
}
},
topMenuList: {
deep: true,
handler() {
this.scheduleAutoSave()
}
},
deletedFieldCodes: {
deep: true,
handler() {
this.scheduleAutoSave()
}
},
workStatusEnumMappings: {
deep: true,
handler() {
this.scheduleAutoSave()
}
},
'pointSelectorQuery.pointId'() {
this.schedulePointSelectorSearch()
},
'pointSelectorQuery.pointDesc'() {
this.schedulePointSelectorSearch()
}
},
computed: {
showSingleBatteryImportActions() {
const currentTopMenu = this.topMenuList.find(item => item.code === this.activeTopMenu)
if (!currentTopMenu) {
return false
}
if (currentTopMenu.code === 'SBJK_DTDC') {
return true
}
const activeChildCode = this.activeChildMap[this.activeTopMenu] || ''
return activeChildCode === 'SBJK_DTDC'
}
},
methods: {
async initMenuStructure() {
if (!this.siteId) {
this.topMenuList = []
this.deletedFieldCodes = []
this.workStatusEnumMappings = this.buildDefaultWorkStatusEnumMappings()
this.activeEnumScope = this.enumScopeOptions[0]?.key || 'PCS|workStatus'
return
}
this.suppressAutoSave = true
const [mappingResp, enumResp] = await Promise.all([
getSingleMonitorProjectPointMapping(this.siteId),
getSingleMonitorWorkStatusEnumMappings(this.siteId)
])
const list = mappingResp?.data || []
const enumList = Array.isArray(enumResp?.data) ? enumResp.data : []
const topMap = new Map()
const childMap = new Map()
list.forEach((row, index) => {
const moduleCode = row.moduleCode || 'UNKNOWN'
const moduleName = row.moduleName || '未分组'
const menuCode = row.menuCode || moduleCode
const menuName = row.menuName || moduleName
const topKey = moduleCode
if (!topMap.has(topKey)) {
topMap.set(topKey, {
code: moduleCode,
name: moduleName,
children: [],
items: []
})
}
const item = {
code: `${moduleCode}_${menuCode}_${row.fieldCode || index}_${row.deviceId || 'NA'}`,
section: row.sectionName || '-',
name: row.fieldName || '-',
field: row.fieldCode || '',
deviceId: row.deviceId || '',
deviceName: row.deviceName || '',
point: row.dataPoint || '',
fixedValue: row.fixedDataPoint || '',
useFixedDisplay: row.useFixedDisplay === 1 ? 1 : 0
}
const topItem = topMap.get(topKey)
const isTopDirect = moduleCode === menuCode || moduleCode === 'HOME'
if (isTopDirect) {
topItem.items.push(item)
return
}
const childKey = `${moduleCode}_${menuCode}`
if (!childMap.has(childKey)) {
const child = { code: menuCode, name: menuName, items: [] }
childMap.set(childKey, child)
topItem.children.push(child)
}
childMap.get(childKey).items.push(item)
})
this.topMenuList = Array.from(topMap.values())
const firstTop = this.topMenuList[0]
this.activeTopMenu = firstTop ? firstTop.code : 'HOME'
this.activeChildMap = {}
this.sectionActiveMap = {}
this.deletedFieldCodes = []
this.workStatusEnumMappings = this.normalizeWorkStatusEnumMappings(enumList)
if (!this.enumScopeOptions.find(item => item.key === this.activeEnumScope)) {
this.activeEnumScope = this.enumScopeOptions[0]?.key || 'PCS|workStatus'
}
this.topMenuList.forEach(top => {
if (top.children && top.children.length > 0) {
this.$set(this.activeChildMap, top.code, top.children[0].code)
}
})
this.$nextTick(() => {
this.lastSavedPointSignature = this.buildPointPayloadSignature()
this.lastSavedEnumSignature = this.buildEnumPayloadSignature()
this.suppressAutoSave = false
this.saveStatusText = '自动保存已开启'
})
},
getAllMappingRows() {
const rows = []
this.topMenuList.forEach(top => {
;(top.items || []).forEach(item => rows.push(item))
;(top.children || []).forEach(child => {
;(child.items || []).forEach(item => rows.push(item))
})
})
return rows
},
getDeviceCategoryByField(fieldCode) {
if (!fieldCode || fieldCode.indexOf('__') === -1) {
return ''
}
const menuCode = fieldCode.split('__')[0]
const menuMap = {
SBJK_SSYX: 'PCS',
SBJK_EMS: 'EMS',
SBJK_PCS: 'PCS',
SBJK_BMSZL: 'STACK',
SBJK_BMSDCC: 'CLUSTER',
SBJK_DTDC: 'BATTERY',
SBJK_DB: 'AMMETER',
SBJK_YL: 'COOLING',
SBJK_DH: 'DH',
SBJK_XF: 'XF'
}
return menuMap[menuCode] || ''
},
handlePointInputFocus(row) {
if (!row) {
return
}
this.currentPointRow = row
this.pointSelectorQuery.pageNum = 1
this.pointSelectorQuery.siteId = this.siteId
this.pointSelectorQuery.deviceCategory = ''
this.pointSelectorQuery.deviceId = ''
this.pointSelectorVisible = true
this.loadPointSelectorList()
},
selectPointFromDialog(pointRow) {
if (!this.currentPointRow) {
return
}
this.currentPointRow.point = pointRow?.pointId || ''
this.currentPointRow.useFixedDisplay = 0
this.currentPointRow = null
this.pointSelectorVisible = false
},
searchPointSelector() {
this.pointSelectorQuery.pageNum = 1
this.loadPointSelectorList()
},
resetPointSelector() {
this.pointSelectorQuery.pageNum = 1
this.pointSelectorQuery.pointId = ''
this.pointSelectorQuery.pointDesc = ''
this.loadPointSelectorList()
},
handlePointSelectorPageChange(pageNum) {
this.pointSelectorQuery.pageNum = pageNum
this.loadPointSelectorList()
},
openImportConfigDialog() {
if (!this.siteId) {
this.$message.warning('请先选择站点')
return
}
if (this.$refs.configInput) {
this.$refs.configInput.value = ''
this.$refs.configInput.click()
}
},
async openSingleBatteryInitDialog() {
if (!this.siteId) {
this.$message.warning('请先选择站点')
return
}
this.singleBatteryInitDialogVisible = true
this.singleBatteryInitForm.stackDeviceId = ''
this.singleBatteryInitForm.clusterDeviceId = ''
this.singleBatteryInitForm.targetCount = 1
await this.loadSingleBatteryStackOptions()
this.singleBatteryClusterOptions = []
},
async loadSingleBatteryStackOptions() {
const response = await getStackNameList(this.siteId)
this.singleBatteryStackOptions = Array.isArray(response?.data) ? response.data : []
},
async handleSingleBatteryStackChange(stackDeviceId) {
this.singleBatteryInitForm.clusterDeviceId = ''
if (!stackDeviceId) {
this.singleBatteryClusterOptions = []
return
}
const response = await getClusterNameList({ stackDeviceId, siteId: this.siteId })
this.singleBatteryClusterOptions = Array.isArray(response?.data) ? response.data : []
},
normalizeSingleBatteryInitResult(result) {
const source = result || {}
return {
committed: !!source.committed,
siteId: source.siteId || '',
scopeType: source.scopeType || 'stack',
scopeDeviceId: source.scopeDeviceId || '',
targetCount: Number(source.targetCount || 0),
existingBatteryCount: Number(source.existingBatteryCount || 0),
initializedBatteryCount: Number(source.initializedBatteryCount || 0),
insertedBatteryCount: Number(source.insertedBatteryCount || 0),
pointHitCount: Number(source.pointHitCount || 0),
fixedValueFallbackCount: Number(source.fixedValueFallbackCount || 0),
insertedMappingCount: Number(source.insertedMappingCount || 0),
updatedMappingCount: Number(source.updatedMappingCount || 0),
message: source.message || ''
}
},
async submitSingleBatteryInit() {
if (!this.siteId) {
this.$message.warning('请先选择站点')
return
}
if (!this.singleBatteryInitForm.stackDeviceId) {
this.$message.warning('请选择电池堆')
return
}
if (!this.singleBatteryInitForm.clusterDeviceId) {
this.$message.warning('请选择电池簇')
return
}
const scopeType = 'cluster'
const scopeDeviceId = this.singleBatteryInitForm.clusterDeviceId
this.singleBatteryInitLoading = true
try {
const response = await initializeSingleBatteryMonitorMappings({
siteId: this.siteId,
scopeType,
scopeDeviceId,
targetCount: this.singleBatteryInitForm.targetCount
})
this.singleBatteryInitResult = this.normalizeSingleBatteryInitResult(response?.data || {})
this.singleBatteryInitDialogVisible = false
this.singleBatteryInitResultVisible = true
if (this.singleBatteryInitResult.committed) {
await this.initMenuStructure()
this.$message.success('初始化单体电池配置成功')
} else {
this.$message.warning(this.singleBatteryInitResult.message || '初始化未完成')
}
} catch (error) {
this.$message.error(error?.message || '初始化失败,请稍后重试')
} finally {
this.singleBatteryInitLoading = false
}
},
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
}
if (!silent) {
this.pointSelectorLoading = true
}
const query = {
...this.pointSelectorQuery
}
delete query.deviceCategory
delete query.deviceId
getPointMatchList(query)
.then(response => {
this.pointSelectorList = response?.rows || []
this.pointSelectorTotal = response?.total || 0
})
.finally(() => {
if (!silent) {
this.pointSelectorLoading = false
}
})
},
getFilteredQuickGroups(list) {
const sourceList = Array.isArray(list) ? list : []
const groupMap = new Map()
sourceList.forEach(item => {
const groupKey = `${item.section || '-'}__${item.field || '-'}__${item.name || '-'}`
if (!groupMap.has(groupKey)) {
groupMap.set(groupKey, {
key: groupKey,
section: item.section || '-',
field: item.field || '-',
name: item.name || '-',
rows: []
})
}
groupMap.get(groupKey).rows.push(item)
})
const keyword = (this.quickFilter || '').toLowerCase()
const groups = Array.from(groupMap.values())
if (!keyword) {
return groups
}
return groups.filter(group => {
const groupMatch = [group.section, group.field, group.name].some(text => (text || '').toLowerCase().includes(keyword))
if (groupMatch) {
return true
}
return group.rows.some(row => (row.deviceName || '').toLowerCase().includes(keyword))
})
},
getFilteredSectionBlocks(list) {
const fieldGroups = this.getFilteredQuickGroups(list)
const sectionMap = new Map()
fieldGroups.forEach(group => {
const sectionName = group.section || '-'
if (!sectionMap.has(sectionName)) {
sectionMap.set(sectionName, {
key: sectionName,
section: sectionName,
fields: []
})
}
sectionMap.get(sectionName).fields.push(group)
})
return Array.from(sectionMap.values())
},
buildSectionPanelKey(topCode, childCode) {
return `${topCode || 'ROOT'}__${childCode || 'ROOT'}`
},
getSectionActiveValue(panelKey, blocks) {
if (Object.prototype.hasOwnProperty.call(this.sectionActiveMap, panelKey)) {
return this.sectionActiveMap[panelKey]
}
return Array.isArray(blocks) && blocks.length > 0 ? blocks[0].key : ''
},
setSectionActiveValue(panelKey, value) {
this.$set(this.sectionActiveMap, panelKey, value || '')
},
clearGroupMapping(group) {
if (!group || !Array.isArray(group.rows)) {
return
}
group.rows.forEach(row => {
row.point = ''
row.fixedValue = ''
row.useFixedDisplay = 0
})
this.$message.success('已清空该字段映射,系统将自动保存')
},
buildDefaultWorkStatusEnumMappings() {
return [
this.buildEnumItem('PCS', 'workStatus', '0', '运行'),
this.buildEnumItem('PCS', 'workStatus', '1', '停机'),
this.buildEnumItem('PCS', 'workStatus', '2', '故障'),
this.buildEnumItem('PCS', 'workStatus', '3', '待机'),
this.buildEnumItem('PCS', 'workStatus', '4', '充电'),
this.buildEnumItem('PCS', 'workStatus', '5', '放电'),
this.buildEnumItem('PCS', 'gridStatus', '0', '并网'),
this.buildEnumItem('PCS', 'gridStatus', '1', '未并网'),
this.buildEnumItem('PCS', 'deviceStatus', '0', '离线'),
this.buildEnumItem('PCS', 'deviceStatus', '1', '在线'),
this.buildEnumItem('PCS', 'controlMode', '0', '远程'),
this.buildEnumItem('PCS', 'controlMode', '1', '本地'),
this.buildEnumItem('STACK', 'workStatus', '0', '静置'),
this.buildEnumItem('STACK', 'workStatus', '1', '充电'),
this.buildEnumItem('STACK', 'workStatus', '2', '放电'),
this.buildEnumItem('STACK', 'workStatus', '3', '浮充'),
this.buildEnumItem('STACK', 'workStatus', '4', '待机'),
this.buildEnumItem('STACK', 'workStatus', '5', '运行'),
this.buildEnumItem('STACK', 'workStatus', '9', '故障'),
this.buildEnumItem('STACK', 'pcsCommunicationStatus', '0', '正常'),
this.buildEnumItem('STACK', 'pcsCommunicationStatus', '1', '通讯中断'),
this.buildEnumItem('STACK', 'pcsCommunicationStatus', '2', '异常'),
this.buildEnumItem('STACK', 'emsCommunicationStatus', '0', '正常'),
this.buildEnumItem('STACK', 'emsCommunicationStatus', '1', '通讯中断'),
this.buildEnumItem('STACK', 'emsCommunicationStatus', '2', '异常'),
this.buildEnumItem('CLUSTER', 'workStatus', '0', '静置'),
this.buildEnumItem('CLUSTER', 'workStatus', '1', '充电'),
this.buildEnumItem('CLUSTER', 'workStatus', '2', '放电'),
this.buildEnumItem('CLUSTER', 'workStatus', '3', '待机'),
this.buildEnumItem('CLUSTER', 'workStatus', '5', '运行'),
this.buildEnumItem('CLUSTER', 'workStatus', '9', '故障'),
this.buildEnumItem('CLUSTER', 'pcsCommunicationStatus', '0', '正常'),
this.buildEnumItem('CLUSTER', 'pcsCommunicationStatus', '1', '通讯中断'),
this.buildEnumItem('CLUSTER', 'pcsCommunicationStatus', '2', '异常'),
this.buildEnumItem('CLUSTER', 'emsCommunicationStatus', '0', '正常'),
this.buildEnumItem('CLUSTER', 'emsCommunicationStatus', '1', '通讯中断'),
this.buildEnumItem('CLUSTER', 'emsCommunicationStatus', '2', '异常')
]
},
buildEnumItem(deviceCategory, matchField, enumCode = '', enumName = '', enumDesc = '', dataEnumCode = '') {
return {
deviceCategory: deviceCategory || '',
matchField: matchField || '',
enumCode: enumCode || '',
enumName: enumName || '',
enumDesc: enumDesc || '',
dataEnumCode: dataEnumCode || ''
}
},
parseEnumScopeKey(scopeKey) {
const text = scopeKey || ''
const splitIndex = text.indexOf('|')
if (splitIndex < 0) {
return { deviceCategory: '', matchField: '' }
}
return {
deviceCategory: text.slice(0, splitIndex),
matchField: text.slice(splitIndex + 1)
}
},
getEnumMappingsByScope(scopeKey) {
const scope = this.parseEnumScopeKey(scopeKey)
return (this.workStatusEnumMappings || []).filter(item => {
return (item.deviceCategory || '') === scope.deviceCategory && (item.matchField || '') === scope.matchField
})
},
normalizeWorkStatusEnumMappings(list) {
const source = Array.isArray(list) ? list : []
const normalized = source
.map(item => this.buildEnumItem(
(item && item.deviceCategory) || '',
(item && item.matchField) || '',
(item && item.enumCode) || '',
(item && item.enumName) || '',
(item && item.enumDesc) || '',
(item && item.dataEnumCode) || ''
))
.filter(item => (item.deviceCategory && item.matchField) && (item.enumCode || item.enumName || item.dataEnumCode))
if (normalized.length > 0) {
return normalized
}
return this.buildDefaultWorkStatusEnumMappings()
},
addWorkStatusEnumMapping() {
const scope = this.parseEnumScopeKey(this.activeEnumScope)
this.workStatusEnumMappings.push(this.buildEnumItem(scope.deviceCategory, scope.matchField))
},
removeWorkStatusEnumMapping(row) {
const index = this.workStatusEnumMappings.findIndex(item => item === row)
if (index < 0) {
return
}
this.workStatusEnumMappings.splice(index, 1)
},
resetWorkStatusEnumMappings() {
this.workStatusEnumMappings = this.buildDefaultWorkStatusEnumMappings()
this.$message.success('已恢复默认工作状态枚举')
},
buildWorkStatusEnumPayload() {
const mappings = (this.workStatusEnumMappings || [])
.map(item => ({
deviceCategory: (item && item.deviceCategory) || '',
matchField: (item && item.matchField) || '',
enumCode: (item && item.enumCode) || '',
enumName: (item && item.enumName) || '',
enumDesc: (item && item.enumDesc) || '',
dataEnumCode: (item && item.dataEnumCode) || ''
}))
.filter(item => (item.deviceCategory && item.matchField) && (item.enumCode || item.enumName || item.dataEnumCode))
return {
siteId: this.siteId,
mappings
}
},
handleDeleteItem(index, list) {
if (!Array.isArray(list) || index < 0 || index >= list.length) {
return
}
const currentRow = list[index]
if (currentRow && currentRow.deviceId) {
currentRow.point = ''
currentRow.fixedValue = ''
currentRow.useFixedDisplay = 0
this.$message.success('已清空该设备映射,系统将自动保存')
return
}
this.$confirm('是否确认删除该条明细记录?删除后需点击“保存”才会生效。', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
const fieldCode = list[index] && list[index].field
if (fieldCode && !this.deletedFieldCodes.includes(fieldCode)) {
this.deletedFieldCodes.push(fieldCode)
}
list.splice(index, 1)
this.$message.success('已删除该条明细记录,系统将自动保存')
}).catch(() => {})
},
buildSavePayload() {
const mappings = this.getAllMappingRows()
.filter(item => item.field)
.map(item => ({
fieldCode: item.field,
deviceId: item.deviceId || '',
dataPoint: item.point || '',
fixedDataPoint: item.fixedValue || '',
useFixedDisplay: item.useFixedDisplay === 1 ? 1 : 0
}))
return {
siteId: this.siteId,
mappings,
deletedFieldCodes: this.deletedFieldCodes
}
},
buildPointPayloadSignature() {
if (!this.siteId) {
return ''
}
return JSON.stringify(this.buildSavePayload())
},
buildEnumPayloadSignature() {
if (!this.siteId) {
return ''
}
return JSON.stringify(this.buildWorkStatusEnumPayload())
},
scheduleAutoSave() {
if (this.suppressAutoSave || !this.siteId) {
return
}
const pointChanged = this.buildPointPayloadSignature() !== this.lastSavedPointSignature
const enumChanged = this.buildEnumPayloadSignature() !== this.lastSavedEnumSignature
if (!pointChanged && !enumChanged) {
return
}
this.saveStatusText = '有未保存修改...'
if (this.autoSaveTimer) {
clearTimeout(this.autoSaveTimer)
}
this.autoSaveTimer = setTimeout(() => {
this.persistMappings()
}, this.autoSaveDelay)
},
async persistMappings() {
if (!this.siteId) {
return
}
if (this.isSaving) {
return
}
const pointSignature = this.buildPointPayloadSignature()
const enumSignature = this.buildEnumPayloadSignature()
const pointChanged = !!pointSignature && pointSignature !== this.lastSavedPointSignature
const enumChanged = !!enumSignature && enumSignature !== this.lastSavedEnumSignature
if (!pointChanged && !enumChanged) {
return
}
this.isSaving = true
let pointError = null
let enumError = null
try {
if (pointChanged) {
try {
await saveSingleMonitorProjectPointMapping(this.buildSavePayload())
this.lastSavedPointSignature = pointSignature
} catch (error) {
pointError = error
}
}
if (enumChanged) {
try {
await saveSingleMonitorWorkStatusEnumMappings(this.buildWorkStatusEnumPayload())
this.lastSavedEnumSignature = enumSignature
} catch (error) {
enumError = error
}
}
if (!pointError && !enumError) {
this.saveStatusText = '已自动保存'
return
}
if (pointError && !enumError) {
this.saveStatusText = '状态映射已保存,点位映射保存失败'
return
}
if (!pointError && enumError) {
this.saveStatusText = '点位映射已保存,状态映射保存失败'
return
}
this.saveStatusText = '自动保存失败,请稍后重试'
} finally {
this.isSaving = false
}
}
},
beforeDestroy() {
if (this.autoSaveTimer) {
clearTimeout(this.autoSaveTimer)
}
if (this.pointSelectorQueryTimer) {
clearTimeout(this.pointSelectorQueryTimer)
this.pointSelectorQueryTimer = null
}
},
async mounted() {
this.siteId = this.$route.query.siteId || ''
this.siteName = this.$route.query.siteName || this.siteId
await this.initMenuStructure()
}
}
</script>
<style scoped lang="scss">
.header-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.title-block {
display: flex;
flex-direction: column;
}
.page-title {
font-size: 18px;
font-weight: 600;
color: #303133;
}
.page-desc {
margin-top: 6px;
font-size: 13px;
color: #909399;
display: flex;
align-items: center;
}
.site-label {
margin-right: 8px;
}
.enum-mapping-card {
margin-top: 12px;
}
.enum-mapping-header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 14px;
font-weight: 600;
}
.enum-scope-group {
margin-bottom: 12px;
}
.enum-mapping-tip {
margin-top: 8px;
font-size: 12px;
color: #909399;
}
.short-input {
width: 220px;
}
.child-menu-tabs {
margin-top: 8px;
}
.mode-switch-row {
margin-top: 12px;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 12px;
flex-wrap: wrap;
}
.save-status-text {
font-size: 12px;
color: #909399;
min-width: 120px;
}
.quick-filter-input {
width: 280px;
}
.filter-actions {
display: flex;
align-items: center;
gap: 8px;
}
.filter-actions:not(.single-battery-actions-visible) > .el-button:nth-of-type(3),
.filter-actions:not(.single-battery-actions-visible) > .el-button:nth-of-type(4) {
display: none;
}
.import-result-title {
margin-bottom: 10px;
font-size: 14px;
font-weight: 600;
color: #303133;
}
.quick-wrapper {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.quick-card {
border: 1px solid #ebeef5;
border-radius: 6px;
padding: 10px 12px;
}
.quick-card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.quick-title-block {
display: flex;
flex-direction: column;
gap: 3px;
}
.quick-title {
font-size: 14px;
font-weight: 600;
color: #303133;
}
.quick-sub-title {
font-size: 12px;
color: #909399;
}
.quick-row {
padding: 8px 0;
}
.quick-row + .quick-row {
border-top: 1px dashed #ebeef5;
}
.quick-device-name {
width: auto;
font-size: 13px;
color: #606266;
margin-bottom: 6px;
}
.quick-input {
width: 100%;
margin-bottom: 8px;
}
.section-wrapper {
margin-top: 2px;
}
.section-collapse {
border-top: 0;
border-bottom: 0;
}
.section-title-row {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 14px;
font-weight: 600;
color: #303133;
}
.section-badge {
font-size: 12px;
font-weight: 400;
color: #909399;
margin-right: 8px;
}
.section-field-list {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
padding-bottom: 8px;
}
.section-field-card {
border: 1px solid #ebeef5;
border-radius: 6px;
padding: 10px 12px;
}
@media (min-width: 2200px) {
.quick-wrapper {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.section-field-list {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
}
@media (max-width: 1680px) {
.quick-wrapper {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.section-field-list {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 1280px) {
.quick-wrapper {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.section-field-list {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>