fastbee/yack-web/src/views/platform/device-mapping/index.vue
2026-09-19 18:08:33 +08:00

1008 lines
49 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="platform-device-mapping">
<!-- 搜索 -->
<el-card v-show="showSearch" style="margin-bottom:10px">
<el-form :model="queryParams" ref="queryForm" size="mini" :inline="true" style="margin-bottom:-18px">
<el-form-item label="平台" prop="platformId">
<el-select v-model="queryParams.platformId" placeholder="全部平台" clearable style="width:140px" @change="handleQuery">
<el-option v-for="p in platformList" :key="p.id" :label="p.name" :value="p.id" />
</el-select>
</el-form-item>
<el-form-item label="设备编号" prop="serialNumber">
<el-input v-model="queryParams.serialNumber" placeholder="主设备/子设备编号" clearable style="width:170px" @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="设备类型" prop="sensorType">
<el-select v-model="queryParams.sensorType" placeholder="全部" clearable filterable allow-create default-first-option style="width:140px">
<el-option v-for="s in sensorTypeOptions" :key="s.value" :label="s.label" :value="s.value" />
</el-select>
</el-form-item>
<el-form-item label="入网状态" prop="netStatus">
<el-select v-model="queryParams.netStatus" placeholder="全部" clearable style="width:100px">
<el-option label="已入网" :value="1" />
<el-option label="未入网" :value="0" />
</el-select>
</el-form-item>
<el-form-item style="float:right">
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card>
<el-row :gutter="10" style="margin-bottom:15px">
<el-col :span="1.5">
<el-button type="primary" plain icon="el-icon-plus" size="mini" @click="handleAdd" v-hasPermi="['platform:deviceMapping:add']">新增绑定</el-button>
</el-col>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList" />
</el-row>
<el-table v-loading="loading" :data="list" :row-key="row => row.id">
<el-table-column type="expand" width="30">
<template slot-scope="scope">
<div v-if="scope.row.deviceType === 1 && getSubDevicePairs(scope.row).length > 0" style="padding:10px 50px">
<el-table :data="getSubDevicePairs(scope.row)" size="mini" border>
<el-table-column label="子设备序列号" prop="subSerialNumber" min-width="140" />
<el-table-column label="子设备名称" prop="subDeviceName" min-width="140" show-overflow-tooltip />
<el-table-column label="角色" prop="deviceRole" width="80">
<template slot-scope="s">
<el-tag size="mini" :type="s.row.deviceRole === 'reference' ? 'warning' : ''">{{ s.row.deviceRole === 'reference' ? '基准' : '监测' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="平台设备ID" prop="platformDeviceId" min-width="200" show-overflow-tooltip />
<el-table-column label="平台测点ID" prop="platformPointId" min-width="200" show-overflow-tooltip />
<el-table-column label="入网" prop="netStatus" width="70" align="center">
<template slot-scope="s">
<el-tag size="mini" :type="s.row.netStatus === 1 ? 'success' : 'info'">{{ s.row.netStatus === 1 ? '是' : '否' }}</el-tag>
</template>
</el-table-column>
</el-table>
</div>
<div v-else style="padding:10px 50px;color:#909399;font-size:12px">无子设备映射</div>
</template>
</el-table-column>
<el-table-column label="平台" width="120">
<template slot-scope="scope">{{ platformName(scope.row.platformId) }}</template>
</el-table-column>
<el-table-column label="项目" min-width="120" show-overflow-tooltip>
<template slot-scope="scope">
<span v-if="scope.row.platformProjectName">{{ scope.row.platformProjectName }}</span>
<span v-else style="color:#909399">-</span>
</template>
</el-table-column>
<el-table-column label="设备名称" min-width="140">
<template slot-scope="scope">{{ scope.row.deviceName || deviceName(scope.row.deviceId) }}</template>
</el-table-column>
<el-table-column label="序列号" prop="serialNumber" width="140" />
<el-table-column label="传感器类型" width="110" align="center">
<template slot-scope="scope">
<el-tag size="small">{{ sensorLabel(scope.row.sensorType) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="设备类型" width="90" align="center">
<template slot-scope="scope">
<el-tag size="small" :type="scope.row.deviceType === 1 ? 'warning' : ''">{{ scope.row.deviceType === 1 ? '网关' : '直连' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="平台设备ID" width="200" show-overflow-tooltip>
<template slot-scope="scope">
<span v-if="scope.row.deviceType === 1">
<el-tag size="small" type="warning">子表({{ getSubDevicePairs(scope.row).length }})</el-tag>
</span>
<span v-else>{{ scope.row.platformDeviceId }}</span>
</template>
</el-table-column>
<el-table-column label="入网状态" width="90" align="center">
<template slot-scope="scope">
<el-tag size="small" type="success" v-if="scope.row.netStatus===1">已入网</el-tag>
<el-tag size="small" type="info" v-else>未入网</el-tag>
</template>
</el-table-column>
<el-table-column label="启用" width="70" align="center">
<template slot-scope="scope">
<el-switch v-model="scope.row.enable" active-value="1" inactive-value="0" @change="handleEnableChange(scope.row)" />
</template>
</el-table-column>
<el-table-column label="推送模式" width="100" align="center">
<template slot-scope="scope">
<el-tag size="small" :type="scope.row.pushMode === 2 ? 'warning' : 'success'">
{{ scope.row.pushMode === 2 ? '定时' : '实时' }}
</el-tag>
<div v-if="scope.row.pushMode === 2 && scope.row.pushInterval" style="font-size:12px;color:#909399;margin-top:2px">
{{ scope.row.pushInterval }}分钟
</div>
</template>
</el-table-column>
<el-table-column label="操作" fixed="right" width="200" align="center">
<template slot-scope="scope">
<el-button size="mini" type="text" icon="el-icon-connection" @click="handleOnboard(scope.row)" v-hasPermi="['platform:deviceMapping:edit']"
:disabled="scope.row.netStatus===1">入网</el-button>
<el-button size="mini" type="text" icon="el-icon-edit" @click="handleUpdate(scope.row)" v-hasPermi="['platform:deviceMapping:edit']">编辑</el-button>
<el-button size="mini" type="text" icon="el-icon-delete" @click="handleDelete(scope.row)" v-hasPermi="['platform:deviceMapping:remove']">删除</el-button>
</template>
</el-table-column>
</el-table>
<pagination v-show="total>0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" />
</el-card>
<!-- 新增/编辑弹窗 -->
<el-dialog :title="title" :visible.sync="open" width="820px" append-to-body :close-on-click-modal="false">
<el-form ref="form" :model="form" :rules="rules" label-width="110px">
<el-form-item label="所属平台" prop="platformId">
<el-select v-model="form.platformId" placeholder="请选择平台" style="width:100%" @change="onPlatformChange">
<el-option v-for="p in platformList" :key="p.id" :label="p.name" :value="p.id" />
</el-select>
</el-form-item>
<el-form-item label="所属用户" prop="platformUserAuthId">
<el-select v-model="form.platformUserAuthId" placeholder="请选择已初始化的用户" style="width:100%" @change="onUserAuthChange" :disabled="!form.platformId || form.netStatus === 1" clearable>
<el-option v-for="u in initializedUserList" :key="u.id" :label="u.platformUserName || u.platformUserId" :value="u.id" />
</el-select>
</el-form-item>
<el-form-item label="所属项目" prop="platformProjectId">
<el-select v-model="form.platformProjectId" placeholder="请选择项目" style="width:100%" @change="onProjectChange" :disabled="!form.platformUserAuthId || form.netStatus === 1" clearable>
<el-option v-for="proj in projectOptions" :key="proj.id" :label="proj.name" :value="String(proj.id)" />
</el-select>
</el-form-item>
<el-form-item label="系统设备" prop="deviceId">
<el-select v-model="form.deviceId" placeholder="选择直连或网关设备,支持名称/编号搜索" filterable remote clearable
:remote-method="searchDevice" :loading="deviceSearchLoading" style="width:100%"
@visible-change="onDeviceSelectVisible"
@change="onDeviceChange">
<el-option v-for="d in deviceOptions" :key="d.deviceId" :label="deviceOptionLabel(d)" :value="d.deviceId" />
</el-select>
</el-form-item>
<el-form-item label="序列号">
<el-input v-model="form.serialNumber" disabled />
</el-form-item>
<el-form-item label="设备名称">
<el-input v-model="form.deviceName" />
</el-form-item>
<el-form-item label="传感器类型" prop="sensorType">
<el-select v-model="form.sensorType" placeholder="选择或输入设备类型" filterable allow-create default-first-option style="width:100%">
<el-option v-for="s in sensorTypeOptions" :key="s.value" :label="s.label" :value="s.value" />
</el-select>
</el-form-item>
<el-form-item label="设备类型" prop="deviceType">
<el-radio-group v-model="form.deviceType" @change="onDeviceTypeChange">
<el-radio :label="0">直连设备</el-radio>
<el-radio :label="1">网关设备(多子设备)</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="推送接口" prop="pushInterfaceId">
<el-select v-model="form.pushInterfaceId" placeholder="选择该平台的推送接口" style="width:100%" @change="onPushInterfaceChange" clearable>
<el-option v-for="iface in interfaceOptions" :key="iface.id" :label="`${iface.name}(${iface.method} ${iface.path})`" :value="iface.id" />
</el-select>
</el-form-item>
<!-- 推送配置 -->
<el-divider content-position="left">推送配置</el-divider>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="推送模式" prop="pushMode">
<el-radio-group v-model="form.pushMode">
<el-radio :label="1">
实时推送
<el-tooltip content="设备上报即刻推送" placement="top">
<i class="el-icon-question" style="color:#909399;margin-left:5px;cursor:help;"></i>
</el-tooltip>
</el-radio>
<el-radio :label="2">
定时推送
<el-tooltip content="按间隔推送最新数据" placement="top">
<i class="el-icon-question" style="color:#909399;margin-left:5px;cursor:help;"></i>
</el-tooltip>
</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :span="12" v-if="form.pushMode === 2">
<el-form-item label="推送间隔" prop="pushInterval">
<el-input-number v-model="form.pushInterval" :min="1" :max="1440" :step="5" style="width:160px" />
<span style="margin-left:8px;color:#606266">分钟</span>
<div style="font-size:12px;color:#909399;margin-top:4px">建议 5~60 分钟</div>
</el-form-item>
</el-col>
</el-row>
<!-- 直连设备:平台ID 字段 -->
<template v-if="form.deviceType !== 1">
<el-form-item label="平台设备ID">
<el-input v-model="form.platformDeviceId" placeholder="入网后自动回填,也可手动填写" />
</el-form-item>
<el-form-item label="平台测点ID">
<el-input v-model="form.platformPointId" placeholder="入网后自动回填,也可手动填写" />
</el-form-item>
</template>
<!-- 网关设备:子设备映射配置 -->
<div v-if="form.deviceType === 1" class="array-fields-section">
<div class="section-label">子设备映射</div>
<div class="schema-tip" style="margin-bottom:8px">
配置网关下参与上报的子设备,指定基准点(不会单独推送,与基准点一起推送)和监测点角色。入网时每个监测点独立创建测点和绑定。
</div>
<el-table :data="subMappingRows" size="mini" border style="width:100%">
<el-table-column label="子设备编号" min-width="160">
<template slot-scope="scope">
<el-select v-if="subDeviceOptions.length > 0"
v-model="scope.row.subSerialNumber" size="mini" placeholder="选择子设备" filterable style="width:100%"
@change="onSubSerialChange(scope.row)">
<el-option v-for="device in subDeviceOptions" :key="device.serialNumber" :label="subDeviceOptionLabel(device)" :value="device.serialNumber" />
</el-select>
<el-input v-else v-model="scope.row.subSerialNumber" size="mini" placeholder="填写子设备编号" @change="onSubSerialChange(scope.row)" />
</template>
</el-table-column>
<el-table-column label="子设备名称" min-width="140">
<template slot-scope="scope">
<el-input v-model="scope.row.subDeviceName" size="mini" placeholder="默认同子设备编号" />
</template>
</el-table-column>
<el-table-column label="角色" width="120" align="center">
<template slot-scope="scope">
<el-select v-model="scope.row.deviceRole" size="mini" style="width:100%" @change="onSubRoleChange(scope.row)">
<el-option label="基准点" value="reference" />
<el-option label="监测点" value="monitor" />
</el-select>
</template>
</el-table-column>
<template v-if="form.sensorType === 'deep_inclino'">
<el-table-column label="传感器深度(m)" min-width="140">
<template slot-scope="scope">
<el-input-number v-model="scope.row.sensorDepth" :min="0" :precision="2" size="mini" style="width:100%" @change="refreshOnboardConfig" />
</template>
</el-table-column>
<el-table-column label="孔深(m)" min-width="120">
<template slot-scope="scope">
<el-input-number v-model="scope.row.holeDepth" :min="0" :precision="2" size="mini" style="width:100%" @change="syncHoleDepthToAllMonitors" />
</template>
</el-table-column>
<el-table-column label="孔口高程(m)" min-width="140">
<template slot-scope="scope">
<el-input-number v-model="scope.row.holeElevation" :min="0" :precision="2" size="mini" style="width:100%" @change="syncHoleElevationToAllMonitors" />
</template>
</el-table-column>
</template>
<el-table-column label="入网参数 (JSON)" min-width="220">
<template slot-scope="scope">
<el-input v-model="scope.row.onboardConfig" size="mini" :placeholder="getOnboardConfigPlaceholder(scope.row)" />
</template>
</el-table-column>
<el-table-column label="平台设备ID" width="140" v-if="form.netStatus===1">
<template slot-scope="scope">
<span style="font-size:12px;color:#606266">{{ scope.row.platformDeviceId || '—' }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="50" align="center">
<template slot-scope="scope">
<el-button size="mini" type="text" icon="el-icon-delete" style="color:#f56c6c" @click="subMappingRows.splice(scope.$index, 1)" />
</template>
</el-table-column>
</el-table>
<el-button size="mini" icon="el-icon-plus" style="margin-top:8px" @click="addSubMapping">添加子设备</el-button>
<!-- 数据映射配置 -->
<el-form-item label="上报间隔" label-width="100px" style="margin-top:12px;margin-bottom:0">
<el-input-number v-model="form.reportIntervalSec" :min="10" :max="3600" :step="10" style="width:160px" /> 秒
</el-form-item>
<el-form-item v-if="form.sensorType === 'static_level'" label="跨网关基准点" label-width="100px" style="margin-top:12px;margin-bottom:0">
<el-select v-model="form.crossGatewayRefSerial" clearable filterable
placeholder="选择其他网关已配置的基准点" style="width:100%;max-width:420px" @change="onCrossGatewayRefChange">
<el-option v-for="refItem in referenceOptions" :key="refItem.referenceSerialNumber"
:label="`${refItem.referenceSerialNumber}(网关:${refItem.gatewayDeviceName})`"
:value="refItem.referenceSerialNumber" />
</el-select>
<div style="font-size:12px;color:#909399;margin-top:4px">
本网关子设备全部为监测点时使用:选择其他网关已配置的基准点后,本网关子设备无需再指定基准点。
</div>
</el-form-item>
<el-form-item v-if="itemFieldMappingRows.length > 0" label="数组项映射" label-width="100px" style="margin-top:12px;margin-bottom:0">
<el-table :data="itemFieldMappingRows" size="mini" border style="width:100%">
<el-table-column label="平台字段" width="140">
<template slot-scope="scope">{{ scope.row.platformField }}</template>
</el-table-column>
<el-table-column label="物模型标识" min-width="200">
<template slot-scope="scope">
<el-select v-model="scope.row.identifier" size="mini" placeholder="选择物模型字段" filterable style="width:100%">
<el-option v-for="tm in uniqueThingsModelOptions" :key="tm.identifier" :label="`${tm.modelName}(${tm.identifier})`" :value="tm.identifier" />
</el-select>
</template>
</el-table-column>
</el-table>
</el-form-item>
</div>
<!-- 标量字段映射 -->
<el-form-item v-if="fieldMappingRows.length > 0" label="字段映射">
<el-table :data="fieldMappingRows" size="mini" border style="width:100%">
<el-table-column label="平台字段" min-width="160">
<template slot-scope="scope">{{ scope.row.platformField }}</template>
</el-table-column>
<el-table-column label="物模型标识" min-width="180">
<template slot-scope="scope">
<el-select v-model="scope.row.identifier" size="mini" placeholder="选择物模型字段" filterable style="width:100%">
<el-option v-for="tm in uniqueThingsModelOptions" :key="tm.identifier" :label="`${tm.modelName}(${tm.identifier})`" :value="tm.identifier" />
</el-select>
</template>
</el-table-column>
<el-table-column label="操作" width="60" align="center">
<template slot-scope="scope">
<el-button size="mini" type="text" icon="el-icon-delete" @click="fieldMappingRows.splice(scope.$index,1)" />
</template>
</el-table-column>
</el-table>
<el-button size="mini" icon="el-icon-plus" style="margin-top:8px" @click="addFieldMapping">添加字段</el-button>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.remark" type="textarea" :rows="2" />
</el-form-item>
</el-form>
<div slot="footer">
<el-button @click="cancel">取消</el-button>
<el-button type="primary" @click="submitForm" :loading="submitLoading">确定</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { listPlatformDeviceMapping, addPlatformDeviceMapping, updatePlatformDeviceMapping, delPlatformDeviceMapping, onboardDevice, listSubMappings, listSubMappingsBatch, syncSubMappings, listStaticLevelReferencePoints } from '@/api/platform/deviceMapping'
import { listPlatformInstance } from '@/api/platform/instance'
import { getInitializedUsers, getProjectsByUserAuthId } from '@/api/platform/userAuth'
import { listPlatformInterface } from '@/api/platform/interface'
import { listPlatformFlow, getPlatformFlow } from '@/api/platform/flow'
import { listDevice, listThingsModel, getDevice, listDeviceShort } from '@/api/iot/device'
import { listGateway } from '@/api/iot/gateway'
import { getDicts } from '@/api/system/dict/data'
const SENSOR_TYPE_DICT = 'platform_sensor_type'
export default {
name: 'PlatformDeviceMapping',
data() {
return {
loading: false, submitLoading: false, showSearch: true,
total: 0, list: [], platformList: [], deviceOptions: [], deviceMap: {},
subDeviceOptions: [],
deviceSearchLoading: false, thingsModelOptions: [],
interfaceOptions: [],
projectOptions: [],
initializedUserList: [],
open: false, title: '',
fieldMappingRows: [],
// 网关子设备映射行
subMappingRows: [],
// 网关数据映射
itemFieldMappingRows: [],
// 跨网关基准点下拉选项(static_level 专用)
referenceOptions: [],
// 子表缓存 (列表展开行使用)
subMappingCache: {},
sensorTypeDictOptions: [],
queryParams: { pageNum: 1, pageSize: 20, platformId: null, serialNumber: '', sensorType: '', netStatus: null },
form: {
enable: '1', deviceType: 0, reportIntervalSec: 300, deviceName: '',
platformId: null, platformUserAuthId: null, platformProjectId: null, platformProjectName: '',
pushInterfaceId: null, pushMode: 1, pushInterval: 5,
deviceId: null, serialNumber: '', sensorType: '', remark: ''
},
rules: {
platformId: [{ required: true, message: '请选择平台', trigger: 'change' }],
platformUserAuthId: [{ required: true, message: '请选择用户', trigger: 'change' }],
platformProjectId: [{ required: true, message: '请选择所属项目', trigger: 'change' }],
deviceId: [{ required: true, message: '请选择设备', trigger: 'change' }],
sensorType: [{ required: true, message: '请选择或输入设备类型', trigger: 'change' }],
pushInterfaceId: [{ required: true, message: '请选择推送接口', trigger: 'change' }]
}
}
},
computed: {
uniqueThingsModelOptions() {
const deduped = []
const seen = new Set()
;(this.thingsModelOptions || []).forEach(item => {
if (!item || !item.identifier || seen.has(item.identifier)) return
seen.add(item.identifier)
deduped.push(item)
})
return deduped
},
sensorTypeOptions() {
const optionMap = new Map()
this.sensorTypeDictOptions.forEach(item => optionMap.set(item.value, { ...item }))
;(this.list || []).forEach(item => {
if (item && item.sensorType && !optionMap.has(item.sensorType)) {
optionMap.set(item.sensorType, { value: item.sensorType, label: item.sensorType })
}
})
return Array.from(optionMap.values())
}
},
created() {
this.loadSensorTypeOptions()
listPlatformInstance({ pageNum: 1, pageSize: 100 }).then(res => { this.platformList = res.rows || [] })
this.getList()
},
methods: {
loadSensorTypeOptions() {
getDicts(SENSOR_TYPE_DICT).then(res => {
this.sensorTypeDictOptions = ((res && res.data) || []).map(item => ({
value: item.dictValue,
label: item.dictLabel || item.dictValue
})).filter(item => item.value)
}).catch(() => {
this.sensorTypeDictOptions = []
})
},
getList() {
this.loading = true
listPlatformDeviceMapping(this.queryParams).then(res => {
this.list = res.rows; this.total = res.total; this.loading = false
// deviceName 由后端列表查询批量补全,前端无需再逐个 getDevice(N→0)
// 预加载网关设备的子映射(批量一次拉取,避免 N 次请求)
this.subMappingCache = {}
const gatewayIds = res.rows.filter(r => r.deviceType === 1).map(r => r.id).filter(Boolean)
if (gatewayIds.length) {
listSubMappingsBatch(gatewayIds).then(subRes => {
this.subMappingCache = subRes.data || {}
})
}
})
},
platformName(id) { const p = this.platformList.find(x => x.id === id); return p ? p.name : id },
deviceName(id) { return this.deviceMap[id] || id },
deviceOptionLabel(device) {
const typeText = device.deviceType === 2 || device.subDeviceCount > 0 ? '网关' : '直连'
const name = device.deviceName || device.serialNumber || device.deviceId
const sn = device.serialNumber ? `(${device.serialNumber})` : ''
return `${name}${sn} - ${typeText}`
},
subDeviceOptionLabel(device) {
const name = device.deviceName || device.serialNumber
const sn = device.serialNumber && device.deviceName ? `(${device.serialNumber})` : ''
return `${name}${sn}`
},
sensorLabel(v) {
const option = this.sensorTypeOptions.find(item => item.value === v)
return option ? option.label : v
},
getSubDevicePairs(row) {
return this.subMappingCache[row.id] || []
},
getOnboardConfigPlaceholder(row) {
return row.deviceRole === 'reference'
? '{"sensorId":"..."}'
: '{"sensorId":"...","refSensorId":"...","refSerialNumber":"..."}'
},
handleQuery() { this.queryParams.pageNum = 1; this.getList() },
resetQuery() { this.resetForm('queryForm'); this.handleQuery() },
handleAdd() {
this.form = {
enable: '1', deviceType: 0, pushInterfaceId: null, pushMode: 1, pushInterval: 5, reportIntervalSec: 300, deviceName: '',
platformId: null, platformUserAuthId: null, platformProjectId: null, platformProjectName: '',
deviceId: null, serialNumber: '', sensorType: '', remark: '', crossGatewayRefSerial: null
}
this.fieldMappingRows = []
this.subMappingRows = []
this.itemFieldMappingRows = []
this.referenceOptions = []
this.deviceOptions = []
this.subDeviceOptions = []
this.thingsModelOptions = []
this.interfaceOptions = []
this.projectOptions = []
this.initializedUserList = []
this.title = '新增设备绑定'; this.open = true
},
handleUpdate(row) {
this.form = { ...row, deviceName: row.deviceName || '', pushMode: row.pushMode || 1, pushInterval: row.pushInterval || 5, reportIntervalSec: 300, crossGatewayRefSerial: null }
this.fieldMappingRows = this.safeParseJson(row.fieldMapping, [])
this.subMappingRows = []
this.itemFieldMappingRows = []
this.referenceOptions = []
// 解析 extra_mapping 中的数据映射配置
if (row.extraMapping) {
try {
const extra = JSON.parse(row.extraMapping)
this.form.reportIntervalSec = extra.reportIntervalSec || 300
this.itemFieldMappingRows = extra.itemFieldMappings || []
if (extra.staticLevel && extra.staticLevel.referenceSerialNumber) {
this.form.crossGatewayRefSerial = extra.staticLevel.referenceSerialNumber
}
} catch (e) { /* ignore */ }
}
// 加载该平台的接口列表和用户列表
if (row.platformId) {
this.loadInterfaces(row.platformId)
this.loadInitializedUsers(row.platformId)
this.loadReferenceOptions(row.platformId, row.id)
}
// 加载用户的项目列表
if (row.platformUserAuthId) {
this.loadProjectsByUserAuth(row.platformUserAuthId)
}
// 加载子设备映射(网关设备)
if (row.deviceType === 1 && row.id) {
listSubMappings(row.id).then(res => {
this.subMappingRows = (res.data || []).map(s => {
const config = this.safeParseJson(s.onboardConfig, {})
return {
...s,
subDeviceName: s.subDeviceName || s.subSerialNumber,
sensorDepth: config.sensorDepth != null ? config.sensorDepth : null,
holeDepth: config.holeDepth != null ? config.holeDepth : null,
holeElevation: config.holeElevation != null ? config.holeElevation : null
}
})
})
}
this.loadDeviceContext(row.deviceId)
this.title = '编辑设备绑定'; this.open = true
},
handleDelete(row) {
this.$confirm('确认删除?').then(() => {
delPlatformDeviceMapping(row.id).then(() => { this.getList(); this.$modal.msgSuccess('删除成功') })
})
},
handleEnableChange(row) {
updatePlatformDeviceMapping({ id: row.id, enable: row.enable })
},
handleOnboard(row) {
this.$confirm(`确认对设备【${this.deviceName(row.deviceId)}】执行入网操作?`).then(() => {
onboardDevice(row.id).then(() => { this.$modal.msgSuccess('入网成功'); this.getList() })
})
},
onPlatformChange(platformId) {
this.interfaceOptions = []
this.form.pushInterfaceId = null
this.initializedUserList = []
this.form.platformUserAuthId = null
this.projectOptions = []
this.form.platformProjectId = null
this.form.platformProjectName = ''
this.referenceOptions = []
this.form.crossGatewayRefSerial = null
if (platformId) {
this.loadInterfaces(platformId)
this.loadInitializedUsers(platformId)
this.loadReferenceOptions(platformId, this.form.id)
}
},
loadInitializedUsers(platformId) {
if (!platformId) { this.initializedUserList = []; return }
getInitializedUsers(platformId).then(res => {
this.initializedUserList = res.data || []
}).catch(() => { this.initializedUserList = [] })
},
// 加载同平台其他静力水准网关已配置的基准点(跨网关基准点下拉框数据源)
loadReferenceOptions(platformId, excludeMappingId) {
if (!platformId) { this.referenceOptions = []; return }
listStaticLevelReferencePoints({ platformId, excludeMappingId: excludeMappingId || undefined }).then(res => {
this.referenceOptions = res.data || []
}).catch(() => { this.referenceOptions = [] })
},
onUserAuthChange(userAuthId) {
this.projectOptions = []
this.form.platformProjectId = null
this.form.platformProjectName = ''
if (userAuthId) {
this.loadProjectsByUserAuth(userAuthId)
}
},
loadProjectsByUserAuth(userAuthId) {
if (!userAuthId) { this.projectOptions = []; return }
getProjectsByUserAuthId(userAuthId).then(res => {
this.projectOptions = res.data || []
}).catch(() => { this.projectOptions = [] })
},
onProjectChange(projectId) {
if (projectId) {
const proj = this.projectOptions.find(p => String(p.id) === String(projectId))
this.form.platformProjectName = proj ? proj.name : ''
} else {
this.form.platformProjectName = ''
}
},
onDeviceTypeChange(val) {
if (val === 1 && this.subMappingRows.length === 0 && this.subDeviceOptions.length > 0) {
// 切换为网关设备时,自动用已加载的子设备列表预填
this.subMappingRows = this.subDeviceOptions.map(device => {
return {
subSerialNumber: device.serialNumber,
subDeviceName: device.deviceName || device.serialNumber,
deviceRole: 'monitor',
onboardConfig: JSON.stringify({ sensorId: device.serialNumber }),
platformDeviceId: '',
platformPointId: ''
}
})
}
},
loadInterfaces(platformId) {
listPlatformInterface({ platformId, pageNum: 1, pageSize: 100 }).then(res => {
this.interfaceOptions = res.rows || []
})
},
onPushInterfaceChange(interfaceId) {
// 根据接口的 isBatchPush 自动设置推送模式:0=实时推送(1),1=定时推送(2)
if (interfaceId) {
const iface = this.interfaceOptions.find(x => x.id === interfaceId)
if (iface) {
this.form.pushMode = iface.isBatchPush === 1 ? 2 : 1
}
} else {
this.form.pushMode = 1
}
// 从推送接口的 requestSchema 提取字段作为数据映射模板,切换接口时全量刷新
if (!interfaceId) return
const iface = this.interfaceOptions.find(x => x.id === interfaceId)
if (!iface || !iface.requestSchema) return
try {
const schema = JSON.parse(iface.requestSchema)
const oldFields = this.fieldMappingRows || []
const oldItemFields = this.itemFieldMappingRows || []
// 标量字段映射(body 层):所有设备类型都提取,同名平台字段保留已选标识
const scalarFields = schema.filter(f =>
f.type !== 'array' && (f.source === 'thingsModel' || f.source === 'identifier')
)
this.fieldMappingRows = scalarFields.map(s => ({
identifier: (oldFields.find(r => r.platformField === s.name) || {}).identifier || '',
platformField: s.name
}))
// 网关设备额外提取数组字段的 itemSchema
if (this.form.deviceType === 1) {
const arrayField = schema.find(f => f.type === 'array' && f.itemSchema && f.itemSchema.length > 0)
if (arrayField) {
const identifierFields = (arrayField.itemSchema || []).filter(s => s.source === 'thingsModel' || s.source === 'identifier')
this.itemFieldMappingRows = identifierFields.map(s => ({
platformField: s.name,
identifier: (oldItemFields.find(r => r.platformField === s.name) || {}).identifier || ''
}))
} else {
this.itemFieldMappingRows = []
}
}
} catch (e) { /* ignore */ }
},
onDeviceChange(deviceId) {
const d = this.deviceOptions.find(x => x.deviceId === deviceId)
if (d) {
this.form.serialNumber = d.serialNumber
this.form.deviceName = d.deviceName || d.serialNumber
this.loadSubDeviceOptions(d)
} else {
this.form.serialNumber = ''
this.form.deviceName = ''
this.subDeviceOptions = []
this.thingsModelOptions = []
}
this.loadThingsModel(deviceId)
},
onDeviceSelectVisible(visible) {
if (visible && this.deviceOptions.length === 0) {
this.searchDevice('')
}
},
searchDevice(query) {
const keyword = (query || '').trim()
this.deviceSearchLoading = true
const base = { pageNum: 1, pageSize: 50, entrance: 2, showChild: true }
const requests = []
if (!keyword) {
requests.push(listDeviceShort(base))
} else {
requests.push(listDeviceShort({ ...base, deviceName: keyword }))
requests.push(listDeviceShort({ ...base, serialNumber: keyword }))
}
Promise.all(requests.map(req => req.catch(() => ({ rows: [] })))).then(results => {
this.deviceOptions = this.mergeDeviceOptions(results.flatMap(res => res.rows || []))
}).finally(() => {
this.deviceSearchLoading = false
})
},
mergeDeviceOptions(rows) {
const map = new Map()
;(rows || []).forEach(item => {
if (!item || !item.deviceId || item.gwDevCode) return
if (!map.has(item.deviceId)) {
map.set(item.deviceId, item)
}
})
return Array.from(map.values())
},
loadDeviceContext(deviceId) {
if (!deviceId) return
getDevice(deviceId).then(res => {
const device = res.data || res
if (!device) return
this.deviceOptions = [{ deviceId: device.deviceId, deviceName: device.deviceName, serialNumber: device.serialNumber }]
this.form.deviceId = device.deviceId
this.form.serialNumber = device.serialNumber
this.form.deviceName = this.form.deviceName || device.deviceName || device.serialNumber
this.loadThingsModel(device.deviceId)
this.loadSubDeviceOptions(device)
}).catch(() => {
this.loadThingsModel(deviceId)
})
},
loadSubDeviceOptions(gateway) {
const gatewayDeviceId = gateway.deviceId
const gatewaySerialNumber = gateway.serialNumber
if (!gatewayDeviceId && !gatewaySerialNumber) {
this.subDeviceOptions = []
return
}
const promises = []
if (gatewayDeviceId) {
promises.push(listGateway({ gwDeviceId: gatewayDeviceId, pageNum: 1, pageSize: 500 }).then(res => res.rows || []).catch(() => []))
}
if (gatewaySerialNumber) {
promises.push(listDevice({ gwDevCode: gatewaySerialNumber, pageNum: 1, pageSize: 500 }).then(res => res.rows || []).catch(() => []))
}
Promise.all(promises).then(results => {
const optionMap = new Map()
results.forEach(rows => {
;(rows || []).forEach(item => {
const sn = (item.serialNumber || item.subDeviceNo || '').trim()
if (sn && !optionMap.has(sn)) {
optionMap.set(sn, { serialNumber: sn, deviceName: item.deviceName || item.subDeviceName || sn })
}
})
})
this.subDeviceOptions = Array.from(optionMap.values())
// 新增模式下,根据是否有子设备自动推断设备类型,减少人工选择出错
if (!this.form.id) {
this.form.deviceType = this.subDeviceOptions.length > 0 ? 1 : 0
}
// 网关设备且子映射为空时,自动预填所有子设备;基准点由用户明确指定
if (this.form.deviceType === 1 && this.subMappingRows.length === 0 && this.subDeviceOptions.length > 0) {
this.subMappingRows = this.subDeviceOptions.map(device => {
return {
subSerialNumber: device.serialNumber,
subDeviceName: device.deviceName || device.serialNumber,
deviceRole: 'monitor',
onboardConfig: JSON.stringify({ sensorId: device.serialNumber }),
platformDeviceId: '',
platformPointId: ''
}
})
}
})
},
loadThingsModel(deviceId) {
if (!deviceId) return
listThingsModel({ deviceId, pageNum: 1, pageSize: 200 }).then(res => {
this.thingsModelOptions = res.rows || []
})
},
addFieldMapping() { this.fieldMappingRows.push({ identifier: '', platformField: '' }) },
addSubMapping() {
this.subMappingRows.push({ subSerialNumber: '', subDeviceName: '', deviceRole: 'monitor', onboardConfig: '', sensorDepth: null, holeDepth: null, holeElevation: null, platformDeviceId: '', platformPointId: '' })
},
onSubRoleChange(row) {
if (row.deviceRole === 'reference') {
this.subMappingRows.forEach(item => {
if (item !== row && item.deviceRole === 'reference') {
item.deviceRole = 'monitor'
}
})
}
this.refreshSubMappingOnboardConfigs()
if (this.form.sensorType === 'deep_inclino') this.refreshOnboardConfig()
},
onSubSerialChange(row) {
if (!row.subSerialNumber) return
const selected = this.subDeviceOptions.find(device => device.serialNumber === row.subSerialNumber)
if (selected) {
row.subDeviceName = selected.deviceName || selected.serialNumber
}
const config = this.safeParseJson(row.onboardConfig, {})
config.sensorId = config.sensorId || row.subSerialNumber
row.onboardConfig = JSON.stringify(config)
this.refreshSubMappingOnboardConfigs()
if (this.form.sensorType === 'deep_inclino') this.refreshOnboardConfig()
},
findReferenceRow(rows = this.subMappingRows) {
return (rows || []).find(r => r.deviceRole === 'reference' && r.subSerialNumber) || this.crossGatewayRefRow()
},
// 跨网关基准点(由下拉框选择,不在子表中)构造为虚拟基准点行,用于生成监测点入网参数
crossGatewayRefRow() {
if (this.form.sensorType !== 'static_level' || !this.form.crossGatewayRefSerial) return null
const option = this.referenceOptions.find(o => o.referenceSerialNumber === this.form.crossGatewayRefSerial)
const sensorId = (option && option.referenceSensorId) || this.form.crossGatewayRefSerial
return { subSerialNumber: this.form.crossGatewayRefSerial, onboardConfig: JSON.stringify({ sensorId }) }
},
onCrossGatewayRefChange() {
this.refreshSubMappingOnboardConfigs()
},
buildOnboardConfig(row, refRow) {
const config = this.safeParseJson(row.onboardConfig, {})
const next = { ...config, sensorId: config.sensorId || row.subSerialNumber }
if (row.deviceRole === 'reference') {
delete next.refSensorId
delete next.refSerialNumber
} else if (refRow && refRow.subSerialNumber) {
const refConfig = this.safeParseJson(refRow.onboardConfig, {})
next.refSensorId = refConfig.sensorId || refRow.subSerialNumber
next.refSerialNumber = refRow.subSerialNumber
} else {
delete next.refSensorId
delete next.refSerialNumber
}
return JSON.stringify(next)
},
refreshSubMappingOnboardConfigs() {
const refRow = this.findReferenceRow()
if (refRow && refRow.subSerialNumber) {
refRow.onboardConfig = this.buildOnboardConfig(refRow, null)
}
this.subMappingRows.forEach(row => {
if (!row.subSerialNumber || row === refRow) return
row.onboardConfig = this.buildOnboardConfig(row, refRow)
})
},
refreshOnboardConfig() {
this.subMappingRows.forEach(row => {
const existing = this.safeParseJson(row.onboardConfig, {})
if (!existing.sensorId) existing.sensorId = row.subSerialNumber
if (this.form.sensorType === 'deep_inclino' && row.deviceRole === 'monitor') {
if (row.sensorDepth != null) existing.sensorDepth = row.sensorDepth
if (row.holeDepth != null) existing.holeDepth = row.holeDepth
if (row.holeElevation != null) existing.holeElevation = row.holeElevation
} else {
delete existing.sensorDepth
delete existing.holeDepth
delete existing.holeElevation
}
row.onboardConfig = JSON.stringify(existing)
})
},
syncHoleDepthToAllMonitors(value) {
this.subMappingRows.forEach(row => {
if (row.deviceRole === 'monitor') row.holeDepth = value
})
this.refreshOnboardConfig()
},
syncHoleElevationToAllMonitors(value) {
this.subMappingRows.forEach(row => {
if (row.deviceRole === 'monitor') row.holeElevation = value
})
this.refreshOnboardConfig()
},
normalizeSubMappingsForSubmit() {
const rows = (this.subMappingRows || [])
.filter(r => r.subSerialNumber)
.map(r => ({ ...r, deviceRole: r.deviceRole || 'monitor' }))
if (new Set(rows.map(r => r.subSerialNumber)).size !== rows.length) {
this.$modal.msgError('子设备编号不能重复')
return null
}
const references = rows.filter(r => r.deviceRole === 'reference')
const monitors = rows.filter(r => r.deviceRole === 'monitor')
if (references.length > 1) {
this.$modal.msgError('只能指定一个基准点')
return null
}
if (this.form.sensorType === 'static_level') {
const hasCrossRef = !!this.form.crossGatewayRefSerial
if (hasCrossRef && references.length === 1) {
this.$modal.msgError('已选择跨网关基准点,子设备中不能再指定基准点')
return null
}
if ((references.length !== 1 && !hasCrossRef) || monitors.length === 0) {
this.$modal.msgError('静力水准需配置 1 个基准点(或选择跨网关基准点)和至少 1 个监测点')
return null
}
}
if (this.form.sensorType === 'deep_inclino') {
if (monitors.length === 0) {
this.$modal.msgError('深部位移至少配置 1 个监测点')
return null
}
const invalid = monitors.find(r => r.sensorDepth == null || r.holeDepth == null || r.holeElevation == null || r.sensorDepth > r.holeDepth)
if (invalid) {
this.$modal.msgError(`子设备 ${invalid.subSerialNumber} 的传感器深度、孔深、孔口高程必填,且传感器深度不能大于孔深`)
return null
}
}
const refRow = references[0] || this.crossGatewayRefRow()
return rows.map(row => {
const onboardConfig = this.safeParseJson(this.buildOnboardConfig(row, refRow), {})
if (this.form.sensorType === 'deep_inclino' && row.deviceRole === 'monitor') {
onboardConfig.sensorDepth = row.sensorDepth
onboardConfig.holeDepth = row.holeDepth
onboardConfig.holeElevation = row.holeElevation
}
return { ...row, onboardConfig: JSON.stringify(onboardConfig) }
})
},
safeParseJson(value, defaultValue) {
if (!value) return defaultValue
try { return JSON.parse(value) } catch (e) { return defaultValue }
},
cancel() { this.open = false },
submitForm() {
this.$refs.form.validate(valid => {
if (!valid) {
// 自动定位到第一个校验失败的字段
this.$nextTick(() => {
const firstError = this.$refs.form.$el.querySelector('.el-form-item.is-error')
if (firstError) {
firstError.scrollIntoView({ behavior: 'smooth', block: 'center' })
const input = firstError.querySelector('input, .el-select .el-input__inner, textarea')
if (input) input.focus()
}
})
return
}
this.form.fieldMapping = this.fieldMappingRows.length > 0
? JSON.stringify(this.fieldMappingRows) : null
const normalizedSubRows = this.form.deviceType === 1
? this.normalizeSubMappingsForSubmit()
: []
if (this.form.deviceType === 1 && normalizedSubRows === null) return
if (this.form.deviceType === 1) {
this.subMappingRows = normalizedSubRows
}
// 网关设备:extra_mapping 只存数据映射配置
if (this.form.deviceType === 1) {
this.form.extraMapping = JSON.stringify({
reportIntervalSec: this.form.reportIntervalSec || 300,
itemFieldMappings: this.itemFieldMappingRows.filter(im => im.identifier),
staticLevel: this.form.crossGatewayRefSerial
? { referenceSerialNumber: this.form.crossGatewayRefSerial }
: undefined
})
} else {
this.form.extraMapping = null
}
this.submitLoading = true
const api = this.form.id ? updatePlatformDeviceMapping : addPlatformDeviceMapping
api(this.form).then(res => {
const mappingId = this.form.id || (res.data && res.data.id) || res.data
// 网关设备:保存后同步子映射
if (this.form.deviceType === 1 && mappingId) {
const subData = normalizedSubRows
.map(r => ({
subSerialNumber: r.subSerialNumber,
subDeviceName: r.subDeviceName || r.subSerialNumber,
deviceRole: r.deviceRole || 'monitor',
onboardConfig: r.onboardConfig || null,
platformDeviceId: r.platformDeviceId || null,
platformPointId: r.platformPointId || null
}))
syncSubMappings(mappingId, subData).then(() => {
this.$modal.msgSuccess(this.form.id ? '修改成功' : '新增成功')
this.open = false; this.getList()
})
} else {
this.$modal.msgSuccess(this.form.id ? '修改成功' : '新增成功')
this.open = false; this.getList()
}
}).finally(() => { this.submitLoading = false })
})
}
}
}
</script>
<style scoped>
.schema-tip {
font-size: 12px;
color: #909399;
line-height: 1.6;
}
.array-fields-section {
margin-bottom: 22px;
padding: 0 10px;
}
.array-fields-section .section-label {
font-size: 14px;
color: #606266;
font-weight: 600;
margin-bottom: 8px;
}
</style>