<script setup lang="ts">
|
import { computed, onMounted, reactive, ref } from 'vue'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { useRoute, useRouter } from 'vue-router'
|
import {
|
createTest,
|
getTestConfigurationOptions,
|
getTestDetail,
|
updateTest,
|
type TestConfigurationDetail,
|
type TestConfigurationOptions,
|
type TestBatteryPayload,
|
type TestPlanStepPayload,
|
} from '@/api/tests'
|
|
interface BatteryRow {
|
key: number
|
selected: boolean
|
groupId: number | null
|
batteryNo: string
|
cabinet: number | null
|
cabinetChannel: number | null
|
collector: number | null
|
expectedBarcode: string | null
|
preservedFields: TestBatteryPayload['fields']
|
temperature1: string
|
temperature2: string
|
temperature3: string
|
positive1: string
|
positive2: string
|
negative1: string
|
negative2: string
|
customChannels: Record<string, string>
|
}
|
|
interface CustomChannelColumn {
|
key: string
|
label: string
|
}
|
|
interface BatteryGroupOption {
|
id: number
|
name: string
|
batteries: string[]
|
}
|
|
interface ThresholdRow {
|
key: number
|
selected: boolean
|
type: string
|
min: string
|
max: string
|
unit: 'A' | 'V' | 'Ah' | 'Wh' | '℃'
|
severity: string
|
}
|
|
const router = useRouter()
|
const route = useRoute()
|
const testId = computed(() => Number(route.params.id) || null)
|
const isEdit = computed(() => testId.value !== null)
|
const submitting = ref(false)
|
const loading = ref(false)
|
const batteryDialogVisible = ref(false)
|
const selectedBatteryGroup = ref<number | null>(null)
|
const selectedBatteryNos = ref<string[]>([])
|
const configurationOptions = ref<TestConfigurationOptions | null>(null)
|
const preservedPlanSteps = ref<TestPlanStepPayload[]>([])
|
const monitorTimeoutMinutes = ref(30)
|
let batteryKey = 1
|
let thresholdKey = 1
|
let customChannelKey = 1
|
|
const form = reactive({
|
name: '',
|
principalId: null as number | null,
|
testTypeId: null as number | null,
|
purpose: '',
|
remark: '',
|
syncMode: '默认同步检测柜',
|
})
|
|
const owners = ref<Array<{ id: number; name: string; userName: string }>>([])
|
const testTypes = ref<Array<{ id: number; name: string }>>([])
|
const ownersLoading = ref(false)
|
const testTypesLoading = ref(false)
|
const thresholdTypes: Array<{
|
label: string
|
value: string
|
unit: 'A' | 'V' | 'Ah' | 'Wh' | '℃'
|
disabled?: boolean
|
}> = [
|
{ label: '温度', value: 'TEMPERATURE', unit: '℃' as const },
|
{ label: '电压', value: 'VOLTAGE', unit: 'V' as const },
|
{ label: '电流', value: 'CURRENT', unit: 'A' as const },
|
{ label: '容量', value: 'CAPACITY', unit: 'Ah' as const },
|
{ label: '能量', value: 'ENERGY', unit: 'Wh' as const },
|
{ label: '正电位', value: 'POSITIVE_POTENTIAL', unit: 'V' as const },
|
{ label: '负电位', value: 'NEGATIVE_POTENTIAL', unit: 'V' as const },
|
{ label: '检测柜返回项(待现场验证)', value: 'CABINET_RETURN', unit: 'V' as const, disabled: true },
|
]
|
const fixedSensorFields = ['temperature1', 'temperature2', 'temperature3', 'positive1', 'positive2', 'negative1', 'negative2'] as const
|
type SensorField = typeof fixedSensorFields[number]
|
const sensorDefinitions: Array<{ field: SensorField; code: string; name: string; required: boolean }> = [
|
{ field: 'temperature1', code: 'PRIMARY_TEMPERATURE', name: '主温度通道', required: true },
|
{ field: 'temperature2', code: 'SECONDARY_TEMPERATURE_2', name: '温度通道 2', required: false },
|
{ field: 'temperature3', code: 'SECONDARY_TEMPERATURE_3', name: '温度通道 3', required: false },
|
{ field: 'positive1', code: 'PRIMARY_POSITIVE_POTENTIAL', name: '正电位主通道', required: true },
|
{ field: 'positive2', code: 'SECONDARY_POSITIVE_POTENTIAL', name: '正电位通道 2', required: false },
|
{ field: 'negative1', code: 'PRIMARY_NEGATIVE_POTENTIAL', name: '负电位主通道', required: true },
|
{ field: 'negative2', code: 'SECONDARY_NEGATIVE_POTENTIAL', name: '负电位通道 2', required: false },
|
]
|
const sensorByCode = new Map(sensorDefinitions.map(item => [item.code, item]))
|
const customChannelColumns = ref<CustomChannelColumn[]>([])
|
const batteryGroupOptions = ref<BatteryGroupOption[]>([])
|
const cabinetOptions = computed(() => {
|
const cabinets = new Map<number, string>()
|
configurationOptions.value?.channels.forEach(channel => {
|
cabinets.set(channel.cabinetId, [channel.cabinetCode, channel.cabinetName].filter(Boolean).join(' '))
|
})
|
return [...cabinets].map(([id, label]) => ({ id, label }))
|
})
|
const collectorOptions = computed(() => (configurationOptions.value?.collectors || []).map(item => ({
|
id: item.collectorId,
|
label: item.collectorName || item.collectorCode,
|
channelCount: item.channelCount,
|
})))
|
|
function createBatteryRow(batteryNo = '', groupId: number | null = null): BatteryRow {
|
return {
|
key: batteryKey++, selected: false, groupId, batteryNo, cabinet: null, cabinetChannel: null, collector: null,
|
expectedBarcode: null, preservedFields: [],
|
temperature1: '', temperature2: '', temperature3: '', positive1: '', positive2: '', negative1: '', negative2: '',
|
customChannels: Object.fromEntries(customChannelColumns.value.map(column => [column.key, ''])),
|
}
|
}
|
|
function createThresholdRow(type = ''): ThresholdRow {
|
return {
|
key: thresholdKey++, selected: false, type, min: '', max: '',
|
unit: thresholdTypes.find(item => item.value === type)?.unit || 'V', severity: 'WARNING',
|
}
|
}
|
|
const batteries = ref<BatteryRow[]>([])
|
const thresholds = ref<ThresholdRow[]>([createThresholdRow('TEMPERATURE'), createThresholdRow('VOLTAGE')])
|
const groupBatteryNos = computed(() => batteryGroupOptions.value.find(group => group.id === selectedBatteryGroup.value)?.batteries || [])
|
const availableGroupBatteryNos = computed(() => groupBatteryNos.value.filter(batteryNo => !isBatteryAdded(batteryNo)))
|
const selectedBatteryGroupName = computed(() => batteryGroupOptions.value.find(group => group.id === selectedBatteryGroup.value)?.name || '-')
|
|
const allBatteriesSelected = computed({
|
get: () => batteries.value.length > 0 && batteries.value.every(item => item.selected),
|
set: (checked: boolean) => batteries.value.forEach(item => { item.selected = checked }),
|
})
|
|
const allThresholdsSelected = computed({
|
get: () => thresholds.value.length > 0 && thresholds.value.every(item => item.selected),
|
set: (checked: boolean) => thresholds.value.forEach(item => { item.selected = checked }),
|
})
|
|
const allGroupBatteriesSelected = computed({
|
get: () => availableGroupBatteryNos.value.length > 0
|
&& availableGroupBatteryNos.value.every(batteryNo => selectedBatteryNos.value.includes(batteryNo)),
|
set: (checked: boolean) => {
|
selectedBatteryNos.value = checked ? [...availableGroupBatteryNos.value] : []
|
},
|
})
|
|
function isBatteryAdded(batteryNo: string) {
|
return batteries.value.some(item => item.batteryNo === batteryNo)
|
}
|
|
function openBatteryDialog() {
|
selectedBatteryGroup.value = batteries.value[0]?.groupId || null
|
selectedBatteryNos.value = []
|
batteryDialogVisible.value = true
|
}
|
|
function handleBatteryGroupChange() {
|
selectedBatteryNos.value = []
|
}
|
|
function toggleBatteryNo(batteryNo: string, selected: string | number | boolean) {
|
if (selected === true) {
|
if (!selectedBatteryNos.value.includes(batteryNo)) selectedBatteryNos.value.push(batteryNo)
|
return
|
}
|
selectedBatteryNos.value = selectedBatteryNos.value.filter(item => item !== batteryNo)
|
}
|
|
function confirmAddBatteries() {
|
if (!selectedBatteryGroup.value) {
|
ElMessage.warning('请先选择电池组')
|
return
|
}
|
if (batteries.value.length && batteries.value[0].groupId !== selectedBatteryGroup.value) {
|
ElMessage.warning('同一测试只能选择一个电池组')
|
return
|
}
|
if (!selectedBatteryNos.value.length) {
|
ElMessage.warning('请至少选择一块电池')
|
return
|
}
|
const newBatteryNos = selectedBatteryNos.value.filter(batteryNo => !isBatteryAdded(batteryNo))
|
batteries.value.push(...newBatteryNos.map(batteryNo => createBatteryRow(batteryNo, selectedBatteryGroup.value)))
|
batteryDialogVisible.value = false
|
ElMessage.success(`已添加 ${newBatteryNos.length} 块电池`)
|
}
|
|
function removeBatteries() {
|
if (!batteries.value.some(item => item.selected)) {
|
ElMessage.warning('请先选择需要删除的电池')
|
return
|
}
|
batteries.value = batteries.value.filter(item => !item.selected)
|
}
|
|
function channelsForCabinet(cabinetId: number | null) {
|
if (!cabinetId) return []
|
return (configurationOptions.value?.channels || []).filter(channel => channel.cabinetId === cabinetId)
|
}
|
|
function isCabinetChannelUsed(item: BatteryRow, channelId: number) {
|
return batteries.value.some(other => other.key !== item.key && other.cabinetChannel === channelId)
|
}
|
|
function collectorChannelsFor(collectorId: number | null) {
|
const channelCount = collectorOptions.value.find(item => item.id === collectorId)?.channelCount || 0
|
return Array.from({ length: channelCount }, (_, index) => `S-${String(index + 1).padStart(2, '0')}`)
|
}
|
|
function collectorFieldValues(item: BatteryRow) {
|
return [
|
...fixedSensorFields.map(field => ({ key: field, value: item[field] })),
|
...customChannelColumns.value.map(column => ({ key: column.key, value: item.customChannels[column.key] || '' })),
|
]
|
}
|
|
function isCollectorChannelUsed(item: BatteryRow, fieldKey: string, channelCode: string) {
|
return batteries.value.some(other => other.collector === item.collector
|
&& collectorFieldValues(other).some(field => field.value === channelCode
|
&& (other.key !== item.key || field.key !== fieldKey)))
|
}
|
|
function handleCabinetChange(item: BatteryRow) {
|
item.cabinetChannel = null
|
}
|
|
function handleCollectorChange(item: BatteryRow) {
|
fixedSensorFields.forEach(field => { item[field] = '' })
|
Object.keys(item.customChannels).forEach(key => { item.customChannels[key] = '' })
|
}
|
|
async function addChannelField() {
|
try {
|
const { value } = await ElMessageBox.prompt('请输入测量项名称', '添加采集器测量项', {
|
confirmButtonText: '添加', cancelButtonText: '取消', inputPattern: /^.{1,100}$/,
|
inputErrorMessage: '测量项名称长度必须为1到100个字符',
|
})
|
const label = value.trim()
|
if (!label) {
|
ElMessage.warning('请输入测量项名称')
|
return
|
}
|
if (customChannelColumns.value.some(item => item.label === label)) {
|
ElMessage.warning('测量项名称不可重复')
|
return
|
}
|
const key = `CUSTOM_${Date.now()}_${customChannelKey++}`
|
customChannelColumns.value.push({ key, label })
|
batteries.value.forEach(item => { item.customChannels[key] = '' })
|
} catch {
|
// 用户取消时保持当前配置。
|
}
|
}
|
|
function applyOptions(options: TestConfigurationOptions) {
|
configurationOptions.value = options
|
owners.value = options.users.map(item => ({
|
id: item.userId,
|
name: item.nickName || item.userName,
|
userName: item.userName,
|
}))
|
testTypes.value = options.testTypes.map(item => ({ id: item.testTypeId, name: item.testTypeName }))
|
const groups = new Map<number, BatteryGroupOption>()
|
options.batteries.forEach(item => {
|
const group = groups.get(item.groupId) || { id: item.groupId, name: item.groupName, batteries: [] }
|
group.batteries.push(item.batteryNo)
|
groups.set(item.groupId, group)
|
})
|
batteryGroupOptions.value = [...groups.values()]
|
}
|
|
function applyDetail(detail: TestConfigurationDetail) {
|
if (detail.receptionStatus !== 'NOT_STARTED') {
|
throw new Error('该测试已经开始接收,不能修改配置')
|
}
|
form.name = detail.testName
|
form.principalId = detail.principalId
|
form.testTypeId = detail.testTypeId
|
form.purpose = detail.testPurpose
|
form.remark = detail.remark || ''
|
monitorTimeoutMinutes.value = detail.monitorTimeoutMinutes || 30
|
preservedPlanSteps.value = detail.planSteps || []
|
|
const customMappings = detail.batteries.flatMap(battery => battery.collectorChannels || [])
|
.filter(mapping => !sensorByCode.has(mapping.measurementCode))
|
customChannelColumns.value = [...new Map(customMappings.map(mapping => [mapping.measurementCode, {
|
key: mapping.measurementCode,
|
label: mapping.measurementName,
|
}])).values()]
|
|
batteries.value = detail.batteries.map(battery => {
|
const row = createBatteryRow(battery.batteryNo, battery.groupId)
|
const channel = configurationOptions.value?.channels.find(item => item.channelId === battery.binding.channelId)
|
row.cabinet = channel?.cabinetId || null
|
row.cabinetChannel = battery.binding.channelId
|
row.collector = battery.collectorId
|
row.expectedBarcode = battery.binding.expectedBarcode
|
row.preservedFields = battery.fields
|
for (const mapping of battery.collectorChannels || []) {
|
const sensor = sensorByCode.get(mapping.measurementCode)
|
if (sensor) row[sensor.field] = mapping.channelCode
|
else row.customChannels[mapping.measurementCode] = mapping.channelCode
|
}
|
return row
|
})
|
thresholds.value = detail.thresholds.map(item => ({
|
key: thresholdKey++, selected: false, type: item.metricCode,
|
min: item.minValue === null ? '' : String(item.minValue),
|
max: item.maxValue === null ? '' : String(item.maxValue),
|
unit: item.unit, severity: item.severity || 'WARNING',
|
}))
|
}
|
|
async function loadPage() {
|
loading.value = true
|
ownersLoading.value = true
|
testTypesLoading.value = true
|
try {
|
applyOptions(await getTestConfigurationOptions())
|
if (testId.value) applyDetail(await getTestDetail(testId.value))
|
} catch (error) {
|
ElMessage.error(error instanceof Error ? error.message : '测试配置加载失败')
|
if (isEdit.value) await router.push('/test-list')
|
} finally {
|
loading.value = false
|
ownersLoading.value = false
|
testTypesLoading.value = false
|
}
|
}
|
|
function addThreshold() {
|
thresholds.value.push(createThresholdRow())
|
}
|
|
function handleThresholdTypeChange(item: ThresholdRow) {
|
item.unit = thresholdTypes.find(option => option.value === item.type)?.unit || item.unit
|
}
|
|
function removeThresholds() {
|
if (!thresholds.value.some(item => item.selected)) {
|
ElMessage.warning('请先选择需要删除的阈值')
|
return
|
}
|
thresholds.value = thresholds.value.filter(item => !item.selected)
|
}
|
|
function validate() {
|
if (!form.name.trim()) return '请输入测试名称'
|
if (!form.principalId) return '请选择测试负责人'
|
if (!form.testTypeId) return '请选择测试类型'
|
if (!form.purpose.trim()) return '请输入测试目的'
|
if (!batteries.value.length) return '至少需要配置一块电池'
|
|
const requiredFields: Array<[keyof BatteryRow, string]> = [
|
['batteryNo', '电池号'], ['groupId', '电池组'], ['cabinet', '检测柜'], ['cabinetChannel', '检测柜通道'],
|
['collector', '采集器'],
|
...sensorDefinitions.filter(sensor => sensor.required)
|
.map(sensor => [sensor.field, sensor.name] as [keyof BatteryRow, string]),
|
]
|
for (let index = 0; index < batteries.value.length; index += 1) {
|
for (const [field, label] of requiredFields) {
|
const value = batteries.value[index][field]
|
if (value === null || value === undefined || !String(value).trim()) return `第 ${index + 1} 行请选择${label}`
|
}
|
}
|
|
const groupIds = new Set(batteries.value.map(item => item.groupId))
|
if (groupIds.size !== 1) return '同一测试只能选择一个电池组'
|
const cabinetChannels = batteries.value.map(item => item.cabinetChannel)
|
if (new Set(cabinetChannels).size !== cabinetChannels.length) return '同一检测柜通道不可重复绑定'
|
const collectorChannels = batteries.value.flatMap(item => collectorFieldValues(item)
|
.filter(field => field.value)
|
.map(field => `${item.collector}:${field.value}`))
|
if (new Set(collectorChannels).size !== collectorChannels.length) return '同一采集器通道不可重复映射'
|
|
if (!thresholds.value.length) return '至少需要配置一项预警阈值'
|
for (let index = 0; index < thresholds.value.length; index += 1) {
|
const item = thresholds.value[index]
|
if (!item.type || item.min === '' || item.max === '') return `请完整填写第 ${index + 1} 行预警阈值`
|
if (Number(item.min) >= Number(item.max)) return `第 ${index + 1} 行阈值最小值必须小于最大值`
|
}
|
return ''
|
}
|
|
function buildBatteryFields(item: BatteryRow) {
|
return item.preservedFields
|
}
|
|
function buildCollectorChannels(item: BatteryRow) {
|
const fixed = sensorDefinitions
|
.filter(sensor => item[sensor.field])
|
.map(sensor => ({
|
measurementCode: sensor.code,
|
measurementName: sensor.name,
|
channelCode: item[sensor.field],
|
}))
|
const custom = customChannelColumns.value
|
.filter(column => item.customChannels[column.key])
|
.map(column => ({
|
measurementCode: column.key,
|
measurementName: column.label,
|
channelCode: item.customChannels[column.key],
|
}))
|
return [...fixed, ...custom]
|
}
|
|
async function submit() {
|
const error = validate()
|
if (error) {
|
ElMessage.warning(error)
|
return
|
}
|
submitting.value = true
|
try {
|
const payload = {
|
testName: form.name.trim(),
|
principalId: form.principalId!,
|
testTypeId: form.testTypeId!,
|
testPurpose: form.purpose.trim(),
|
monitorTimeoutMinutes: monitorTimeoutMinutes.value,
|
remark: form.remark.trim() || null,
|
batteries: batteries.value.map(item => ({
|
groupId: item.groupId!,
|
batteryNo: item.batteryNo.trim(),
|
collectorId: item.collector!,
|
binding: {
|
channelId: item.cabinetChannel!,
|
expectedBarcode: item.expectedBarcode,
|
},
|
collectorChannels: buildCollectorChannels(item),
|
fields: buildBatteryFields(item),
|
})),
|
planSteps: preservedPlanSteps.value,
|
thresholds: thresholds.value.map(item => ({
|
metricCode: item.type,
|
minValue: item.min === '' ? null : Number(item.min),
|
maxValue: item.max === '' ? null : Number(item.max),
|
unit: item.unit,
|
severity: item.severity,
|
})),
|
}
|
if (testId.value) await updateTest({ ...payload, testId: testId.value })
|
else await createTest(payload)
|
ElMessage.success(isEdit.value ? '测试配置已保存' : '测试已创建并加入测试列表')
|
await router.push('/test-list')
|
} catch (submitError) {
|
ElMessage.error(submitError instanceof Error ? submitError.message : '测试保存失败')
|
} finally {
|
submitting.value = false
|
}
|
}
|
|
onMounted(() => {
|
loadPage()
|
})
|
</script>
|
|
<template>
|
<div v-loading="loading" class="create-page">
|
<div class="page-tools">
|
<button class="back-button" type="button" @click="router.push('/test-list')"><el-icon><ArrowLeft /></el-icon>返回测试列表</button>
|
<span>NEW TEST CONFIGURATION</span>
|
</div>
|
|
<section class="form-panel">
|
<header class="panel-heading">
|
<div><i></i><strong>测试参数</strong><span>TEST PARAMETERS</span></div>
|
<small><b>*</b> 为必填项</small>
|
</header>
|
<div class="parameter-grid">
|
<label><span><b>*</b>测试名称</span><el-input v-model="form.name" maxlength="40" show-word-limit placeholder="输入测试名称" /></label>
|
<label><span><b>*</b>测试负责人</span><el-select v-model="form.principalId" :loading="ownersLoading" filterable clearable placeholder="请选择负责人"><el-option v-for="item in owners" :key="item.id" :label="item.name" :value="item.id"><span>{{ item.name }}</span><small v-if="item.userName && item.userName !== item.name">({{ item.userName }})</small></el-option></el-select></label>
|
<label><span><b>*</b>测试类型</span><el-select v-model="form.testTypeId" :loading="testTypesLoading" filterable clearable placeholder="请选择测试类型"><el-option v-for="item in testTypes" :key="item.id" :label="item.name" :value="item.id" /></el-select></label>
|
<label class="textarea-field"><span><b>*</b>测试目的</span><el-input v-model="form.purpose" type="textarea" :rows="4" maxlength="200" show-word-limit placeholder="输入测试目的" /></label>
|
<label class="textarea-field"><span>备注</span><el-input v-model="form.remark" type="textarea" :rows="4" maxlength="200" show-word-limit placeholder="输入备注(选填)" /></label>
|
</div>
|
</section>
|
|
<section class="form-panel">
|
<header class="panel-heading channel-heading">
|
<div><i></i><strong>电池组和通道设置</strong><span>BATTERY & CHANNELS</span></div>
|
<label class="sync-setting"><span>同步方式</span><el-select v-model="form.syncMode"><el-option label="默认同步检测柜" value="默认同步检测柜" /></el-select></label>
|
</header>
|
<div class="section-actions">
|
<div><button class="primary-button" type="button" @click="openBatteryDialog"><el-icon><Plus /></el-icon>添加电池</button><button class="secondary-button" type="button" @click="addChannelField"><el-icon><Plus /></el-icon>添加项</button><button class="secondary-button danger" type="button" @click="removeBatteries"><el-icon><Delete /></el-icon>删除所选</button></div>
|
<span>采集器通道为第一版演示合同,真实拓扑待现场验证</span>
|
</div>
|
<div class="config-table-wrap">
|
<table class="config-table battery-table" :style="{ minWidth: `${1700 + customChannelColumns.length * 150}px` }">
|
<thead><tr>
|
<th><el-checkbox v-model="allBatteriesSelected" aria-label="选择全部电池" /></th><th>电池号 *</th><th>检测柜 *</th><th>检测柜通道 *</th><th>采集器 *</th>
|
<th>主温度通道 *</th><th>温度通道 2</th><th>温度通道 3</th><th>正电位主通道 *</th><th>正电位通道 2</th><th>负电位主通道 *</th><th>负电位通道 2</th>
|
<th v-for="column in customChannelColumns" :key="column.key" class="custom-channel-header">{{ column.label }}</th>
|
</tr></thead>
|
<tbody>
|
<tr v-for="item in batteries" :key="item.key">
|
<td><el-checkbox v-model="item.selected" :aria-label="`选择电池 ${item.batteryNo || item.key}`" /></td>
|
<td><el-input v-model="item.batteryNo" disabled placeholder="电池号" /></td>
|
<td><el-select v-model="item.cabinet" placeholder="选择" @change="handleCabinetChange(item)"><el-option v-for="option in cabinetOptions" :key="option.id" :label="option.label" :value="option.id" /></el-select></td>
|
<td><el-select v-model="item.cabinetChannel" clearable :disabled="!item.cabinet" :placeholder="item.cabinet ? '选择' : '请先选择检测柜'"><el-option v-for="option in channelsForCabinet(item.cabinet)" :key="option.channelId" :label="isCabinetChannelUsed(item, option.channelId) ? `${option.displayNo || `CH-${option.chlid}`}(已占用)` : option.displayNo || `CH-${option.chlid}`" :value="option.channelId" :disabled="isCabinetChannelUsed(item, option.channelId)" /></el-select></td>
|
<td><el-select v-model="item.collector" clearable placeholder="选择" @change="handleCollectorChange(item)"><el-option v-for="option in collectorOptions" :key="option.id" :label="option.label" :value="option.id" /></el-select></td>
|
<td v-for="sensor in sensorDefinitions" :key="sensor.field">
|
<el-select v-model="item[sensor.field]" clearable :disabled="!item.collector" :placeholder="item.collector ? '选择' : '请先选择采集器'">
|
<el-option v-for="channelCode in collectorChannelsFor(item.collector)" :key="channelCode" :label="isCollectorChannelUsed(item, sensor.field, channelCode) ? `${channelCode}(已映射)` : channelCode" :value="channelCode" :disabled="isCollectorChannelUsed(item, sensor.field, channelCode)" />
|
</el-select>
|
</td>
|
<td v-for="column in customChannelColumns" :key="column.key">
|
<el-select v-model="item.customChannels[column.key]" clearable :disabled="!item.collector" :placeholder="item.collector ? '选择' : '请先选择采集器'">
|
<el-option v-for="channelCode in collectorChannelsFor(item.collector)" :key="channelCode" :label="isCollectorChannelUsed(item, column.key, channelCode) ? `${channelCode}(已映射)` : channelCode" :value="channelCode" :disabled="isCollectorChannelUsed(item, column.key, channelCode)" />
|
</el-select>
|
</td>
|
</tr>
|
<tr v-if="!batteries.length"><td :colspan="12 + customChannelColumns.length" class="empty-row">尚未配置电池,请点击“添加电池”</td></tr>
|
</tbody>
|
</table>
|
</div>
|
</section>
|
|
<section class="form-panel threshold-panel">
|
<header class="panel-heading"><div><i></i><strong>预警阈值</strong><span>ALERT THRESHOLDS</span></div><small>可配置温度、电压、电流、容量;检测柜返回项待现场验证</small></header>
|
<div class="section-actions">
|
<div><button class="primary-button" type="button" @click="addThreshold"><el-icon><Plus /></el-icon>添加阈值</button><button class="secondary-button danger" type="button" @click="removeThresholds"><el-icon><Delete /></el-icon>删除所选</button></div>
|
</div>
|
<div class="config-table-wrap threshold-wrap">
|
<table class="config-table threshold-table">
|
<thead><tr><th><el-checkbox v-model="allThresholdsSelected" aria-label="选择全部阈值" /></th><th>阈值类型</th><th>最小值</th><th>最大值</th></tr></thead>
|
<tbody>
|
<tr v-for="item in thresholds" :key="item.key">
|
<td><el-checkbox v-model="item.selected" :aria-label="`选择阈值 ${item.key}`" /></td>
|
<td><el-select v-model="item.type" placeholder="选择类型" @change="handleThresholdTypeChange(item)"><el-option v-for="option in thresholdTypes" :key="option.value" :label="option.label" :value="option.value" :disabled="option.disabled" /></el-select></td>
|
<td><el-input v-model="item.min" type="number" placeholder="输入最小值" /></td>
|
<td><el-input v-model="item.max" type="number" placeholder="输入最大值" /></td>
|
</tr>
|
<tr v-if="!thresholds.length"><td colspan="4" class="empty-row">暂未设置预警阈值</td></tr>
|
</tbody>
|
</table>
|
</div>
|
</section>
|
|
<footer class="submit-bar">
|
<button class="secondary-button" type="button" @click="router.push('/test-list')">取消</button>
|
<button class="primary-button submit-button" type="button" :disabled="submitting" @click="submit"><el-icon><Check /></el-icon>{{ submitting ? '提交中' : isEdit ? '保存修改' : '提交测试' }}</button>
|
</footer>
|
|
<el-dialog v-model="batteryDialogVisible" class="battery-dialog" width="560px" align-center destroy-on-close>
|
<template #header>
|
<div class="dialog-title"><strong>添加电池组</strong><span>ADD BATTERY GROUP</span></div>
|
</template>
|
<div class="battery-dialog-body">
|
<label class="battery-group-search">
|
<span>电池组</span>
|
<el-select v-model="selectedBatteryGroup" filterable clearable placeholder="输入电池组名称搜索" @change="handleBatteryGroupChange">
|
<el-option v-for="group in batteryGroupOptions" :key="group.id" :label="group.name" :value="group.id" :disabled="batteries.length > 0 && batteries[0].groupId !== group.id" />
|
</el-select>
|
</label>
|
|
<div class="battery-picker-table">
|
<table>
|
<thead><tr><th><el-checkbox v-model="allGroupBatteriesSelected" :disabled="!availableGroupBatteryNos.length" aria-label="选择全部可用电池" /></th><th>电池组</th><th>电池名</th><th>状态</th></tr></thead>
|
<tbody>
|
<tr v-for="batteryNo in groupBatteryNos" :key="batteryNo" :class="{ added: isBatteryAdded(batteryNo) }">
|
<td><el-checkbox :model-value="selectedBatteryNos.includes(batteryNo)" :disabled="isBatteryAdded(batteryNo)" :aria-label="`选择电池 ${batteryNo}`" @change="toggleBatteryNo(batteryNo, $event)" /></td>
|
<td>{{ selectedBatteryGroupName }}</td>
|
<td>{{ batteryNo }}</td>
|
<td><span :class="['battery-state', { used: isBatteryAdded(batteryNo) }]">{{ isBatteryAdded(batteryNo) ? '已添加' : '可添加' }}</span></td>
|
</tr>
|
<tr v-if="!selectedBatteryGroup"><td colspan="4" class="picker-empty">请选择或搜索电池组</td></tr>
|
</tbody>
|
</table>
|
</div>
|
</div>
|
<template #footer>
|
<div class="dialog-footer"><button class="secondary-button" type="button" @click="batteryDialogVisible = false">取消</button><button class="primary-button" type="button" @click="confirmAddBatteries"><el-icon><Check /></el-icon>提交</button></div>
|
</template>
|
</el-dialog>
|
</div>
|
</template>
|
|
<style scoped>
|
.create-page { width: 100%; display: flex; flex-direction: column; gap: 16px; }
|
.page-tools { height: 34px; display: flex; align-items: center; justify-content: space-between; }
|
.page-tools > span { color: #7f8d99; font-size: 10px; font-weight: 600; }
|
.back-button { display: inline-flex; align-items: center; gap: 6px; border: 0; color: #52606d; background: transparent; font-size: 13px; cursor: pointer; }
|
.back-button:hover { color: var(--blue); }
|
.form-panel { background: #fff; border: 1px solid #d8e1e9; }
|
.panel-heading { min-height: 48px; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 0 16px; background: var(--surface-soft); border-bottom: 1px solid var(--line); }
|
.panel-heading > div { display: flex; align-items: center; gap: 8px; }
|
.panel-heading > div > i { width: 3px; height: 16px; background: #2b78a3; }
|
.panel-heading strong { color: var(--ink); font-size: 15px; font-weight: 600; }
|
.panel-heading div span, .panel-heading small { color: #81909d; font-size: 10px; }
|
.panel-heading small b, .parameter-grid label > span b { color: #b34f4f; }
|
.parameter-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 18px; padding: 18px 16px; }
|
.parameter-grid label { min-width: 0; display: flex; flex-direction: column; gap: 7px; }
|
.parameter-grid label > span { color: #52606d; font-size: 12px; font-weight: 500; }
|
.textarea-field { grid-column: span 1; }
|
.parameter-grid :deep(.el-select), .sync-setting :deep(.el-select) { width: 100%; }
|
.create-page :deep(.el-input__wrapper), .create-page :deep(.el-select__wrapper), .create-page :deep(.el-textarea__inner) { border-radius: 3px; box-shadow: 0 0 0 1px #d8e1e9 inset; background: #f8fafc; }
|
.create-page :deep(.el-input__wrapper), .create-page :deep(.el-select__wrapper) { min-height: 34px; }
|
.create-page :deep(.el-textarea__inner) { resize: vertical; }
|
.channel-heading { min-height: 54px; }
|
.sync-setting { display: flex; align-items: center; gap: 10px; }
|
.sync-setting > span { color: #52606d; font-size: 12px; white-space: nowrap; }
|
.sync-setting :deep(.el-select) { width: 190px; }
|
.section-actions { min-height: 50px; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 14px; }
|
.section-actions > div { display: flex; gap: 8px; }
|
.section-actions > span { color: #7b8794; font-size: 11px; }
|
.primary-button, .secondary-button { height: 34px; display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 0 13px; border: 1px solid transparent; border-radius: 3px; font-size: 13px; font-weight: 500; cursor: pointer; white-space: nowrap; }
|
.primary-button { color: #fff; background: var(--blue); border-color: var(--blue); }
|
.primary-button:hover { background: #176fcb; border-color: #176fcb; }
|
.primary-button:disabled { opacity: .55; cursor: wait; }
|
.secondary-button { color: #52606d; background: #fff; border-color: var(--line-strong); }
|
.secondary-button:hover { color: var(--blue); border-color: #9dc8f5; }
|
.secondary-button.danger { color: #a84848; }
|
.config-table-wrap { overflow-x: auto; border-top: 1px solid var(--line); }
|
.config-table { width: 100%; min-width: 1700px; table-layout: fixed; border-collapse: collapse; color: #52606d; font-size: 12px; }
|
.config-table th, .config-table td { height: 48px; padding: 6px; border-right: 1px solid #cbd9e5; border-bottom: 1px solid #d8e1e9; text-align: center; }
|
.config-table th { height: 40px; color: #fff; background: #255884; font-size: 11px; font-weight: 600; }
|
.config-table th:first-child { width: 46px; }
|
.config-table th:nth-child(2) { width: 130px; }
|
.config-table th.custom-channel-header { width: 150px; }
|
.config-table th:last-child, .config-table td:last-child { border-right: 0; }
|
.config-table tbody tr:last-child td { border-bottom: 0; }
|
.config-table :deep(.el-select), .config-table :deep(.el-input) { width: 100%; }
|
.config-table :deep(.el-input__wrapper), .config-table :deep(.el-select__wrapper) { min-height: 32px; padding: 0 8px; background: #fff; }
|
.threshold-panel { padding-bottom: 14px; }
|
.threshold-wrap { width: min(620px, 100%); margin: 0 14px; border: 1px solid var(--line); }
|
.threshold-table { min-width: 600px; }
|
.threshold-table th:nth-child(2) { width: 210px; }
|
.empty-row { height: 88px !important; color: #8795a3; }
|
.submit-bar { position: sticky; bottom: 0; z-index: 20; min-height: 62px; display: flex; align-items: center; justify-content: flex-end; gap: 10px; padding: 10px 16px; background: rgba(234, 240, 246, .96); border-top: 1px solid #cbd9e5; }
|
.submit-button { min-width: 110px; }
|
.create-page :deep(.battery-dialog) { max-width: calc(100vw - 28px); border-radius: 4px; }
|
.create-page :deep(.battery-dialog .el-dialog__header) { margin: 0; padding: 17px 20px; border-bottom: 1px solid var(--line); }
|
.create-page :deep(.battery-dialog .el-dialog__headerbtn) { top: 7px; right: 8px; width: 42px; height: 42px; font-size: 20px; }
|
.create-page :deep(.battery-dialog .el-dialog__body) { padding: 20px; }
|
.create-page :deep(.battery-dialog .el-dialog__footer) { padding: 14px 20px 18px; border-top: 1px solid var(--line); }
|
.dialog-title { display: flex; align-items: baseline; gap: 9px; }
|
.dialog-title strong { color: var(--ink); font-size: 16px; font-weight: 600; }
|
.dialog-title span { color: #8795a3; font-size: 10px; }
|
.battery-dialog-body { display: flex; flex-direction: column; gap: 16px; }
|
.battery-group-search { display: grid; grid-template-columns: 64px minmax(0, 1fr); align-items: center; gap: 10px; }
|
.battery-group-search > span { color: #52606d; font-size: 13px; font-weight: 500; }
|
.battery-group-search :deep(.el-select) { width: 100%; }
|
.battery-picker-table { min-height: 190px; overflow-x: auto; border: 1px solid var(--line); }
|
.battery-picker-table table { width: 100%; min-width: 480px; border-collapse: collapse; table-layout: fixed; color: #52606d; font-size: 12px; }
|
.battery-picker-table th, .battery-picker-table td { height: 40px; padding: 6px 10px; border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); text-align: left; }
|
.battery-picker-table th { height: 38px; color: #fff; background: #255884; font-weight: 600; }
|
.battery-picker-table th:first-child, .battery-picker-table td:first-child { width: 48px; text-align: center; }
|
.battery-picker-table td:first-child :deep(.el-checkbox) { height: 18px; margin-right: 0; }
|
.battery-picker-table td:first-child :deep(.el-checkbox__label) { display: none; }
|
.battery-picker-table th:nth-child(2) { width: 190px; }
|
.battery-picker-table th:last-child, .battery-picker-table td:last-child { width: 76px; border-right: 0; text-align: center; }
|
.battery-picker-table tbody tr:last-child td { border-bottom: 0; }
|
.battery-picker-table tbody tr.added { color: #99a5af; background: #f5f7f9; }
|
.battery-state { color: #23815a; }
|
.battery-state.used { color: #8a97a3; }
|
.picker-empty { height: 150px !important; color: #8996a2; text-align: center !important; }
|
.dialog-footer { display: flex; justify-content: flex-end; gap: 8px; }
|
|
@media (max-width: 980px) {
|
.parameter-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
.channel-heading { align-items: flex-start; flex-direction: column; padding-top: 12px; padding-bottom: 12px; }
|
}
|
|
@media (max-width: 680px) {
|
.create-page { gap: 12px; }
|
.page-tools > span { display: none; }
|
.panel-heading { padding: 0 12px; }
|
.panel-heading div span, .panel-heading > small { display: none; }
|
.parameter-grid { grid-template-columns: 1fr; gap: 14px; padding: 14px 12px; }
|
.section-actions { align-items: flex-start; flex-direction: column; padding: 10px 12px; }
|
.section-actions > span { line-height: 1.5; }
|
.sync-setting { width: 100%; }
|
.sync-setting :deep(.el-select) { flex: 1; width: auto; }
|
.threshold-wrap { width: calc(100% - 24px); margin: 0 12px; }
|
.submit-bar { margin: 0 -14px -14px; padding-right: 14px; }
|
.create-page :deep(.battery-dialog .el-dialog__body) { padding: 16px 12px; }
|
.battery-group-search { grid-template-columns: 1fr; gap: 7px; }
|
}
|
</style>
|