Files
emsfront/src/views/ems/site/zdlb/MonitorPointMapping.vue

1889 lines
67 KiB
Vue
Raw Normal View History

2026-02-12 21:19:23 +08:00
<template>
<div class="ems-dashboard-editor-container" style="background-color:#ffffff">
<div class="header-row">
<div class="title-block">
<div class="page-title">单站监控项目点位配置</div>
2026-02-13 21:46:12 +08:00
<div class="page-desc">
<span class="site-label">站点</span>
<span>{{ siteName || siteId || '-' }}</span>
</div>
2026-02-12 21:19:23 +08:00
</div>
</div>
<el-alert
title="当前字段清单与点位映射均从后端表读取,保存后可直接用于单站监控查询。"
type="info"
:closable="false"
style="margin-top: 16px;"
/>
2026-02-15 16:24:29 +08:00
<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 }">
2026-04-01 14:27:35 +08:00
<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>
2026-04-01 14:27:35 +08:00
<input
ref="configInput"
type="file"
accept=".csv"
style="display: none"
@change="handleConfigFileChange"
/>
</div>
2026-02-15 16:24:29 +08:00
</div>
<el-tabs v-model="activeTopMenu" style="margin-top: 16px;">
2026-02-12 21:19:23 +08:00
<el-tab-pane
v-for="topMenu in topMenuList"
:key="topMenu.code"
:label="topMenu.name"
:name="topMenu.code"
>
2026-02-15 16:24:29 +08:00
<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>
2026-02-12 21:19:23 +08:00
<el-table
2026-02-15 16:24:29 +08:00
v-else-if="!topMenu.children || topMenu.children.length === 0"
2026-02-12 21:19:23 +08:00
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" />
2026-02-15 16:24:29 +08:00
<el-table-column prop="deviceName" label="设备" min-width="220">
<template slot-scope="scope">
<span>{{ scope.row.deviceName || '-' }}</span>
</template>
</el-table-column>
2026-02-13 21:46:12 +08:00
<el-table-column label="点位" min-width="300">
2026-02-12 21:19:23 +08:00
<template slot-scope="scope">
<el-input
v-model.trim="scope.row.point"
2026-02-15 16:24:29 +08:00
:readonly="!!scope.row.deviceId"
:placeholder="scope.row.deviceId ? '点击选择点位' : '请输入点位'"
2026-02-12 21:19:23 +08:00
clearable
2026-02-13 21:46:12 +08:00
class="short-input"
2026-02-15 16:24:29 +08:00
@focus="handlePointInputFocus(scope.row)"
@mousedown.native.prevent="handlePointInputFocus(scope.row)"
2026-02-12 21:19:23 +08:00
/>
</template>
</el-table-column>
2026-02-13 21:46:12 +08:00
<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"
2026-02-15 16:24:29 +08:00
:disabled="!!scope.row.deviceId"
2026-02-13 21:46:12 +08:00
@click="handleDeleteItem(scope.$index, topMenu.items)"
>
删除
</el-button>
</template>
</el-table-column>
2026-02-12 21:19:23 +08:00
</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" />
2026-02-15 16:24:29 +08:00
<el-table-column prop="deviceName" label="设备" min-width="220">
<template slot-scope="scope">
<span>{{ scope.row.deviceName || '-' }}</span>
</template>
</el-table-column>
2026-02-13 21:46:12 +08:00
<el-table-column label="点位" min-width="300">
2026-02-12 21:19:23 +08:00
<template slot-scope="scope">
<el-input
v-model.trim="scope.row.point"
2026-02-15 16:24:29 +08:00
:readonly="!!scope.row.deviceId"
:placeholder="scope.row.deviceId ? '点击选择点位' : '请输入点位'"
2026-02-12 21:19:23 +08:00
clearable
2026-02-13 21:46:12 +08:00
class="short-input"
2026-02-15 16:24:29 +08:00
@focus="handlePointInputFocus(scope.row)"
@mousedown.native.prevent="handlePointInputFocus(scope.row)"
2026-02-13 21:46:12 +08:00
/>
</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"
2026-02-12 21:19:23 +08:00
/>
</template>
</el-table-column>
2026-02-13 21:46:12 +08:00
<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"
2026-02-15 16:24:29 +08:00
:disabled="!!scope.row.deviceId"
2026-02-13 21:46:12 +08:00
@click="handleDeleteItem(scope.$index, child.items)"
>
删除
</el-button>
</template>
</el-table-column>
2026-02-12 21:19:23 +08:00
</el-table>
</el-tab-pane>
</el-tabs>
</el-tab-pane>
2026-02-15 16:24:29 +08:00
</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>
2026-02-12 21:19:23 +08:00
</el-tabs>
2026-02-15 16:24:29 +08:00
<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">
2026-04-01 14:27:35 +08:00
<el-input v-model.trim="pointSelectorQuery.pointId" clearable placeholder="请输入点位ID支持模糊" style="width: 180px" />
2026-02-15 16:24:29 +08:00
</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>
2026-02-12 21:19:23 +08:00
</div>
</template>
<script>
2026-02-15 16:24:29 +08:00
import {
getPointMatchList,
getSingleMonitorProjectPointMapping,
getSingleMonitorWorkStatusEnumMappings,
initializeSingleBatteryMonitorMappings,
2026-02-15 16:24:29 +08:00
saveSingleMonitorProjectPointMapping,
saveSingleMonitorWorkStatusEnumMappings
} from '@/api/ems/site'
import { getClusterNameList, getStackNameList } from '@/api/ems/dzjk'
2026-02-12 21:19:23 +08:00
export default {
name: 'MonitorPointMapping',
data() {
return {
2026-02-13 21:46:12 +08:00
siteId: '',
siteName: '',
2026-02-15 16:24:29 +08:00
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: '',
2026-02-12 21:19:23 +08:00
activeTopMenu: 'HOME',
activeChildMap: {},
2026-02-15 16:24:29 +08:00
sectionActiveMap: {},
2026-02-13 21:46:12 +08:00
topMenuList: [],
2026-02-15 16:24:29 +08:00
deletedFieldCodes: [],
currentPointRow: null,
pointSelectorVisible: false,
pointSelectorLoading: false,
pointSelectorTotal: 0,
pointSelectorList: [],
pointSelectorQuery: {
pageNum: 1,
pageSize: 10,
siteId: '',
deviceCategory: '',
deviceId: '',
pointId: '',
pointDesc: ''
},
autoSaveTimer: null,
autoSaveDelay: 900,
2026-04-01 14:27:35 +08:00
pointSelectorQueryTimer: null,
pointSelectorQueryDelay: 280,
2026-02-15 16:24:29 +08:00
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: ''
},
2026-02-15 16:24:29 +08:00
lastSavedPointSignature: '',
lastSavedEnumSignature: '',
workStatusEnumMappings: []
2026-02-12 21:19:23 +08:00
}
},
2026-02-13 21:46:12 +08:00
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()
}
2026-02-15 16:24:29 +08:00
},
topMenuList: {
deep: true,
handler() {
this.scheduleAutoSave()
}
},
deletedFieldCodes: {
deep: true,
handler() {
this.scheduleAutoSave()
}
},
workStatusEnumMappings: {
deep: true,
handler() {
this.scheduleAutoSave()
}
2026-04-01 14:27:35 +08:00
},
'pointSelectorQuery.pointId'() {
this.schedulePointSelectorSearch()
},
'pointSelectorQuery.pointDesc'() {
this.schedulePointSelectorSearch()
2026-02-12 21:19:23 +08:00
}
},
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'
}
},
2026-02-12 21:19:23 +08:00
methods: {
async initMenuStructure() {
if (!this.siteId) {
this.topMenuList = []
2026-02-13 21:46:12 +08:00
this.deletedFieldCodes = []
2026-02-15 16:24:29 +08:00
this.workStatusEnumMappings = this.buildDefaultWorkStatusEnumMappings()
this.activeEnumScope = this.enumScopeOptions[0]?.key || 'PCS|workStatus'
2026-02-12 21:19:23 +08:00
return
}
2026-02-15 16:24:29 +08:00
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 : []
2026-02-12 21:19:23 +08:00
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 = {
2026-02-15 16:24:29 +08:00
code: `${moduleCode}_${menuCode}_${row.fieldCode || index}_${row.deviceId || 'NA'}`,
2026-02-12 21:19:23 +08:00
section: row.sectionName || '-',
name: row.fieldName || '-',
field: row.fieldCode || '',
2026-02-15 16:24:29 +08:00
deviceId: row.deviceId || '',
deviceName: row.deviceName || '',
2026-02-13 21:46:12 +08:00
point: row.dataPoint || '',
fixedValue: row.fixedDataPoint || '',
useFixedDisplay: row.useFixedDisplay === 1 ? 1 : 0
2026-02-12 21:19:23 +08:00
}
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 = {}
2026-02-15 16:24:29 +08:00
this.sectionActiveMap = {}
2026-02-13 21:46:12 +08:00
this.deletedFieldCodes = []
2026-02-15 16:24:29 +08:00
this.workStatusEnumMappings = this.normalizeWorkStatusEnumMappings(enumList)
if (!this.enumScopeOptions.find(item => item.key === this.activeEnumScope)) {
this.activeEnumScope = this.enumScopeOptions[0]?.key || 'PCS|workStatus'
}
2026-02-12 21:19:23 +08:00
this.topMenuList.forEach(top => {
if (top.children && top.children.length > 0) {
this.$set(this.activeChildMap, top.code, top.children[0].code)
}
})
2026-02-15 16:24:29 +08:00
this.$nextTick(() => {
this.lastSavedPointSignature = this.buildPointPayloadSignature()
this.lastSavedEnumSignature = this.buildEnumPayloadSignature()
this.suppressAutoSave = false
this.saveStatusText = '自动保存已开启'
})
2026-02-12 21:19:23 +08:00
},
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
},
2026-02-15 16:24:29 +08:00
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()
},
2026-04-01 14:27:35 +08:00
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
}
},
2026-04-01 14:27:35 +08:00
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
2026-02-15 16:24:29 +08:00
if (!this.pointSelectorQuery.siteId) {
this.pointSelectorList = []
this.pointSelectorTotal = 0
return
}
2026-04-01 14:27:35 +08:00
if (!silent) {
this.pointSelectorLoading = true
}
2026-02-15 16:24:29 +08:00
const query = {
...this.pointSelectorQuery
}
delete query.deviceCategory
delete query.deviceId
getPointMatchList(query)
.then(response => {
this.pointSelectorList = response?.rows || []
this.pointSelectorTotal = response?.total || 0
})
.finally(() => {
2026-04-01 14:27:35 +08:00
if (!silent) {
this.pointSelectorLoading = false
}
2026-02-15 16:24:29 +08:00
})
},
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
}
},
2026-02-13 21:46:12 +08:00
handleDeleteItem(index, list) {
if (!Array.isArray(list) || index < 0 || index >= list.length) {
return
}
2026-02-15 16:24:29 +08:00
const currentRow = list[index]
if (currentRow && currentRow.deviceId) {
currentRow.point = ''
currentRow.fixedValue = ''
currentRow.useFixedDisplay = 0
this.$message.success('已清空该设备映射,系统将自动保存')
return
}
2026-02-13 21:46:12 +08:00
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)
2026-02-15 16:24:29 +08:00
this.$message.success('已删除该条明细记录,系统将自动保存')
2026-02-13 21:46:12 +08:00
}).catch(() => {})
},
2026-02-15 16:24:29 +08:00
buildSavePayload() {
2026-02-12 21:19:23 +08:00
const mappings = this.getAllMappingRows()
.filter(item => item.field)
2026-02-13 21:46:12 +08:00
.map(item => ({
fieldCode: item.field,
2026-02-15 16:24:29 +08:00
deviceId: item.deviceId || '',
2026-02-13 21:46:12 +08:00
dataPoint: item.point || '',
fixedDataPoint: item.fixedValue || '',
useFixedDisplay: item.useFixedDisplay === 1 ? 1 : 0
}))
2026-02-15 16:24:29 +08:00
return {
2026-02-13 21:46:12 +08:00
siteId: this.siteId,
mappings,
deletedFieldCodes: this.deletedFieldCodes
}
2026-02-15 16:24:29 +08:00
},
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)
2026-02-12 21:19:23 +08:00
}
2026-04-01 14:27:35 +08:00
if (this.pointSelectorQueryTimer) {
clearTimeout(this.pointSelectorQueryTimer)
this.pointSelectorQueryTimer = null
}
2026-02-12 21:19:23 +08:00
},
async mounted() {
2026-02-13 21:46:12 +08:00
this.siteId = this.$route.query.siteId || ''
this.siteName = this.$route.query.siteName || this.siteId
2026-02-12 21:19:23 +08:00
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;
2026-02-13 21:46:12 +08:00
display: flex;
align-items: center;
}
.site-label {
margin-right: 8px;
}
2026-02-15 16:24:29 +08:00
.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;
}
2026-02-13 21:46:12 +08:00
.short-input {
width: 220px;
2026-02-12 21:19:23 +08:00
}
.child-menu-tabs {
margin-top: 8px;
}
2026-02-15 16:24:29 +08:00
.mode-switch-row {
margin-top: 12px;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 12px;
2026-04-01 14:27:35 +08:00
flex-wrap: wrap;
2026-02-15 16:24:29 +08:00
}
.save-status-text {
font-size: 12px;
color: #909399;
min-width: 120px;
}
.quick-filter-input {
width: 280px;
}
2026-04-01 14:27:35 +08:00
.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;
}
2026-02-15 16:24:29 +08:00
.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));
}
}
2026-02-12 21:19:23 +08:00
</style>