yeg
2026-08-27 de70520fa0ce1b13e4b535f9b710f27074ff29d1
feat(test): connect test configuration flow
5个文件已修改
756 ■■■■■ 已修改文件
src/api/tests.ts 144 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/router/index.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/stores/tests.ts 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/TestCreate.vue 570 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/TestList.vue 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/tests.ts
@@ -1,5 +1,8 @@
import type { TestRecord, TestStatus } from '@/stores/tests'
import type { MonitoringStatus, TestRecord, TestStatus } from '@/stores/tests'
import { request, type ApiResponse } from './request'
export type TestStatusCode = 'WAITING' | 'RUNNING' | 'PAUSED' | 'RUNNING_ABNORMAL' | 'COMPLETION_PENDING' | 'COMPLETED' | 'TERMINATED'
export type MonitoringStatusCode = 'NOT_MONITORED' | 'WAITING_DEVICE_START' | 'MONITORING' | 'BACKFILLING' | 'MONITORING_ENDED' | 'CANCELLED'
export interface TestListParams {
  pageNum: number
@@ -7,55 +10,110 @@
  testName?: string
  testTypeId?: number
  batteryNo?: string
  status?: string
  testStatus?: TestStatusCode
  monitoringStatus?: MonitoringStatusCode
  beginCreateTime?: string
  endCreateTime?: string
}
export interface TestPlanStepPayload {
  planStepId?: number
  cycleNo: number
  stepNo: number
  stepType: string
  stepName: string
  parameters: Record<string, unknown>
  sortOrder: number
}
export interface TestBatteryPayload {
  relationId?: number
  groupId: number
  batteryNo: string
  collectorId: number | null
  binding: { bindingId?: number; channelId: number; expectedBarcode: string | null; claimStatus?: string }
  fields: Array<{ fieldId?: number; fieldName: string; fieldValue: string }>
}
export interface TestThresholdPayload {
  thresholdId?: number
  metricCode: string
  minValue: number | null
  maxValue: number | null
  unit: 'A' | 'V' | 'Ah' | 'Wh' | '℃'
  severity: 'WARNING'
}
export interface TestConfigurationPayload {
  testId?: number
  testName: string
  principalId: number
  testTypeId: number
  testPurpose: string
  monitorTimeoutMinutes: number
  remark: string | null
  batteries: TestBatteryPayload[]
  planSteps: TestPlanStepPayload[]
  thresholds: TestThresholdPayload[]
}
export interface TestConfigurationDetail extends TestConfigurationPayload {
  testId: number
  creatorId: number
  creatorName: string | null
  principalName: string | null
  testTypeName: string | null
  testStatus: TestStatusCode
  monitoringStatus: MonitoringStatusCode
}
export interface TestConfigurationOptions {
  users: Array<{ userId: number; userName: string; nickName: string | null }>
  testTypes: Array<{ testTypeId: number; testTypeName: string }>
  batteries: Array<{ relationId: number; groupId: number; groupName: string; batteryNo: string }>
  channels: Array<{ channelId: number; cabinetId: number; cabinetCode: string; cabinetName: string; adapterCode: string; devtype: number; devid: number; subdevid: number; chlid: number; displayNo: string | null }>
  collectors: Array<{ collectorId: number; collectorCode: string; collectorName: string }>
}
interface TestDto {
  testId: number
  testName: string | null
  testTypeName: string | null
  leaderName: string | null
  purpose: string | null
  status: string | null
  principalName: string | null
  testPurpose: string | null
  testStatus: TestStatusCode | null
  monitoringStatus: MonitoringStatusCode | null
  createTime: string | null
  startedAtUtc: string | null
  endedAtUtc: string | null
  remark: string | null
  batteryCount: number | null
}
interface TestListResponse extends ApiResponse {
  total: number
  rows: TestDto[]
}
interface TestListResponse extends ApiResponse { total: number; rows: TestDto[] }
interface TestDetailResponse extends ApiResponse { data: TestConfigurationDetail }
interface TestOptionsResponse extends ApiResponse { data: TestConfigurationOptions }
interface TestIdentifierResponse extends ApiResponse { data: number }
export interface TestListResult { total: number; rows: TestRecord[] }
export interface TestListResult {
  total: number
  rows: TestRecord[]
const statusMap: Record<TestStatusCode, TestStatus> = {
  WAITING: '待测试', RUNNING: '检测中', PAUSED: '暂停', RUNNING_ABNORMAL: '检测中(异常)',
  COMPLETION_PENDING: '完成确认中', COMPLETED: '检测完成', TERMINATED: '已终止',
}
const statusMap: Record<string, TestStatus> = {
  '0': '待测试',
  '1': '检测中',
  '2': '检测中(异常)',
  '3': '检测完成',
const monitoringStatusMap: Record<MonitoringStatusCode, MonitoringStatus> = {
  NOT_MONITORED: '未监测', WAITING_DEVICE_START: '等待设备启动', MONITORING: '监测中',
  BACKFILLING: '补采中', MONITORING_ENDED: '监测结束', CANCELLED: '已取消',
}
function mapTest(item: TestDto): TestRecord {
  const testStatus = item.testStatus || 'WAITING'
  const monitoringStatus = item.monitoringStatus || 'NOT_MONITORED'
  return {
    id: item.testId,
    name: item.testName || '-',
    type: item.testTypeName || '-',
    batteryNo: '-',
    batteryGroup: '-',
    batteryCount: Number(item.batteryCount) || 0,
    createdAt: item.createTime || '-',
    startedAt: '-',
    endedAt: '-',
    purpose: item.purpose || '-',
    owner: item.leaderName || '-',
    remark: item.remark || '-',
    status: statusMap[item.status || ''] || '待测试',
    id: item.testId, name: item.testName || '-', type: item.testTypeName || '-', batteryNo: '-', batteryGroup: '-',
    batteryCount: Number(item.batteryCount) || 0, createdAt: item.createTime || '-', startedAt: item.startedAtUtc || '-',
    endedAt: item.endedAtUtc || '-', purpose: item.testPurpose || '-', owner: item.principalName || '-',
    remark: item.remark || '-', status: statusMap[testStatus], monitoringStatus: monitoringStatusMap[monitoringStatus],
    editable: monitoringStatus === 'NOT_MONITORED',
  }
}
@@ -63,15 +121,21 @@
  const searchParams = new URLSearchParams()
  Object.entries(params).forEach(([key, value]) => {
    if (value === undefined || value === '') return
    const queryKey = key === 'beginCreateTime' || key === 'endCreateTime'
      ? `params[${key}]`
      : key
    searchParams.set(queryKey, String(value))
    searchParams.set(key, String(value))
  })
  const result = await request<TestListResponse>(`/system/test/list?${searchParams.toString()}`)
  return {
    total: Number(result.total) || 0,
    rows: (result.rows || []).map(mapTest),
  }
  return { total: Number(result.total) || 0, rows: (result.rows || []).map(mapTest) }
}
export async function getTestConfigurationOptions(): Promise<TestConfigurationOptions> {
  return (await request<TestOptionsResponse>('/system/test/options')).data
}
export async function getTestDetail(testId: number): Promise<TestConfigurationDetail> {
  return (await request<TestDetailResponse>(`/system/test/${testId}`)).data
}
export async function createTest(payload: TestConfigurationPayload): Promise<number> {
  return (await request<TestIdentifierResponse>('/system/test', { method: 'POST', body: JSON.stringify(payload) })).data
}
export async function updateTest(payload: TestConfigurationPayload & { testId: number }): Promise<void> {
  await request<ApiResponse>('/system/test', { method: 'PUT', body: JSON.stringify(payload) })
}
src/router/index.ts
@@ -35,6 +35,12 @@
          meta: { title: '新建测试' },
        },
        {
          path: 'test-list/:id/edit',
          name: 'test-edit',
          component: () => import('@/views/TestCreate.vue'),
          meta: { title: '编辑测试' },
        },
        {
          path: 'test-list/:id/console',
          name: 'test-console',
          component: () => import('@/views/TestConsole.vue'),
src/stores/tests.ts
@@ -1,6 +1,7 @@
import { ref } from 'vue'
export type TestStatus = '待测试' | '检测中' | '检测完成' | '暂停' | '检测中(异常)'
export type TestStatus = '待测试' | '检测中' | '暂停' | '检测中(异常)' | '完成确认中' | '检测完成' | '已终止'
export type MonitoringStatus = '未监测' | '等待设备启动' | '监测中' | '补采中' | '监测结束' | '已取消'
export interface TestRecord {
  id: number
@@ -16,6 +17,8 @@
  owner: string
  remark: string
  status: TestStatus
  monitoringStatus?: MonitoringStatus
  editable?: boolean
}
const STORAGE_KEY = 'battery_detection_tests_v1'
src/views/TestCreate.vue
@@ -1,19 +1,27 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router'
import { getTestTypeList } from '@/api/testTypes'
import { getEnabledUserOptions, type UserOption } from '@/api/users'
import { useTests } from '@/stores/tests'
import type { TestTypeRecord } from '@/stores/testTypes'
import { useRoute, useRouter } from 'vue-router'
import {
  createTest,
  getTestConfigurationOptions,
  getTestDetail,
  updateTest,
  type TestConfigurationOptions,
  type TestConfigurationPayload,
} from '@/api/tests'
type ThresholdUnit = 'A' | 'V' | 'Ah' | 'Wh' | '℃'
interface BatteryRow {
  key: number
  selected: boolean
  groupId: number | null
  batteryNo: string
  cabinet: string
  cabinetChannel: string
  collector: string
  cabinet: number | null
  cabinetChannel: number | null
  collector: number | null
  expectedBarcode: string
  temperature1: string
  temperature2: string
  temperature3: string
@@ -30,6 +38,7 @@
}
interface BatteryGroupOption {
  id: number
  name: string
  batteries: string[]
}
@@ -40,17 +49,32 @@
  type: string
  min: string
  max: string
  unit: ThresholdUnit
}
interface PlanStepRow {
  key: number
  selected: boolean
  cycleNo: number
  stepNo: number
  stepType: string
  stepName: string
  parametersJson: string
}
const router = useRouter()
const { createTest } = useTests()
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('')
const selectedBatteryGroup = ref<number | null>(null)
const selectedBatteryNos = ref<string[]>([])
let batteryKey = 3
let thresholdKey = 3
let batteryKey = 1
let thresholdKey = 1
let customChannelKey = 1
let planStepKey = 1
const form = reactive({
  name: '',
@@ -58,64 +82,84 @@
  testTypeId: null as number | null,
  purpose: '',
  remark: '',
  syncMode: '默认同步测试柜',
  monitorTimeoutMinutes: 30,
})
const owners = ref<UserOption[]>([])
const testTypes = ref<TestTypeRecord[]>([])
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 cabinetOptions = ['A-01 检测柜', 'A-02 检测柜', 'B-01 检测柜']
const collectorOptions = ['COL-A01', 'COL-A02', 'COL-B01']
const channelOptions = Array.from({ length: 16 }, (_, index) => `CH-${String(index + 1).padStart(2, '0')}`)
const configurationOptions = ref<TestConfigurationOptions | null>(null)
const cabinetOptions = ref<Array<{ id: number; label: string }>>([])
const collectorOptions = ref<Array<{ id: number; label: string }>>([])
const sensorChannels = Array.from({ length: 12 }, (_, index) => `S-${String(index + 1).padStart(2, '0')}`)
const thresholdTypes = ['温度', '电压', '电流', '容量', '测试柜返回项']
const thresholdTypes = [
  { 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 },
]
const fixedSensorFields = ['temperature1', 'temperature2', 'temperature3', 'positive1', 'positive2', 'negative1', 'negative2'] as const
type FixedSensorField = typeof fixedSensorFields[number]
const reservedChannelLabels = ['主温度通道', '温度通道 2', '温度通道 3', '正电位主通道', '正电位通道 2', '负电位主通道', '负电位通道 2']
const fixedSensorLabels: Record<FixedSensorField, string> = {
  temperature1: '主温度通道',
  temperature2: '温度通道 2',
  temperature3: '温度通道 3',
  positive1: '正电位主通道',
  positive2: '正电位通道 2',
  negative1: '负电位主通道',
  negative2: '负电位通道 2',
}
const fixedFieldByLabel = Object.fromEntries(
  Object.entries(fixedSensorLabels).map(([field, label]) => [label, field]),
) as Record<string, FixedSensorField>
const customChannelColumns = ref<CustomChannelColumn[]>([])
const batteryGroupOptions: BatteryGroupOption[] = [
  { name: 'SP26 循环测试组', batteries: ['SP26-1', 'SP26-2', 'SP26-3', 'SP26-4', 'SP26-5'] },
  { name: 'SP27 容量验证组', batteries: ['SP27-1', 'SP27-2', 'SP27-3', 'SP27-4'] },
  { name: 'LT25 低温性能组', batteries: ['LT25-01', 'LT25-02', 'LT25-03'] },
  { name: 'HP26 倍率性能组', batteries: ['HP26-01', 'HP26-02', 'HP26-03', 'HP26-04'] },
]
const batteryGroupOptions = ref<BatteryGroupOption[]>([])
function createBatteryRow(batteryNo = ''): BatteryRow {
function createBatteryRow(batteryNo = '', groupId: number | null = null): BatteryRow {
  return {
    key: batteryKey++, selected: false, batteryNo, cabinet: '', cabinetChannel: '', collector: '',
    key: batteryKey++, selected: false, groupId, batteryNo, cabinet: null, cabinetChannel: null, collector: null,
    expectedBarcode: '',
    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: '' }
  return {
    key: thresholdKey++, selected: false, type, min: '', max: '',
    unit: thresholdTypes.find(item => item.value === type)?.unit || 'V',
  }
}
const batteries = ref<BatteryRow[]>([createBatteryRow('SP26-1'), createBatteryRow('SP26-3')])
const thresholds = ref<ThresholdRow[]>([createThresholdRow('温度'), createThresholdRow('电压')])
const selectedOwnerName = computed(() => owners.value.find(item => item.id === form.principalId)?.name || '')
const selectedTestTypeName = computed(() => testTypes.value.find(item => item.id === form.testTypeId)?.name || '')
const groupBatteryNos = computed(() => batteryGroupOptions.find(group => group.name === selectedBatteryGroup.value)?.batteries || [])
function createPlanStepRow(): PlanStepRow {
  return { key: planStepKey++, selected: false, cycleNo: 1, stepNo: 1, stepType: '', stepName: '', parametersJson: '{}' }
}
const batteries = ref<BatteryRow[]>([])
const thresholds = ref<ThresholdRow[]>([createThresholdRow('VOLTAGE')])
const planSteps = ref<PlanStepRow[]>([createPlanStepRow()])
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 allPlanStepsSelected = computed({
  get: () => planSteps.value.length > 0 && planSteps.value.every(item => item.selected),
  set: (checked: boolean) => planSteps.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] : []
  },
  set: (checked: boolean) => { selectedBatteryNos.value = checked ? [...availableGroupBatteryNos.value] : [] },
})
function isBatteryAdded(batteryNo: string) {
@@ -123,7 +167,7 @@
}
function openBatteryDialog() {
  selectedBatteryGroup.value = ''
  selectedBatteryGroup.value = batteries.value[0]?.groupId || null
  selectedBatteryNos.value = []
  batteryDialogVisible.value = true
}
@@ -133,25 +177,19 @@
}
function confirmAddBatteries() {
  if (!selectedBatteryGroup.value) {
    ElMessage.warning('请先选择电池组')
    return
  if (!selectedBatteryGroup.value) return void ElMessage.warning('请先选择电池组')
  if (batteries.value.length && batteries.value[0].groupId !== selectedBatteryGroup.value) {
    return void ElMessage.warning('同一测试只能选择一个电池组')
  }
  if (!selectedBatteryNos.value.length) {
    ElMessage.warning('请至少选择一块电池')
    return
  }
  if (!selectedBatteryNos.value.length) return void ElMessage.warning('请至少选择一块电池')
  const newBatteryNos = selectedBatteryNos.value.filter(batteryNo => !isBatteryAdded(batteryNo))
  batteries.value.push(...newBatteryNos.map(batteryNo => createBatteryRow(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
  }
  if (!batteries.value.some(item => item.selected)) return void ElMessage.warning('请先选择需要删除的电池')
  batteries.value = batteries.value.filter(item => !item.selected)
}
@@ -163,16 +201,12 @@
}
function clearSensorSelection(item: BatteryRow, fieldId: string) {
  if (fieldId.startsWith('custom:')) {
    item.customChannels[fieldId.slice(7)] = ''
    return
  }
  item[fieldId as FixedSensorField] = ''
  if (fieldId.startsWith('custom:')) item.customChannels[fieldId.slice(7)] = ''
  else item[fieldId as FixedSensorField] = ''
}
function isCabinetChannelUsed(item: BatteryRow, channel: string) {
  if (!item.cabinet) return false
  return batteries.value.some(other => other.key !== item.key && other.cabinet === item.cabinet && other.cabinetChannel === channel)
function isCabinetChannelUsed(item: BatteryRow, channel: number) {
  return batteries.value.some(other => other.key !== item.key && other.cabinetChannel === channel)
}
function isSensorChannelUsed(item: BatteryRow, fieldId: string, channel: string) {
@@ -187,16 +221,18 @@
}
function handleCabinetChange(item: BatteryRow) {
  if (!item.cabinetChannel || !isCabinetChannelUsed(item, item.cabinetChannel)) return
  item.cabinetChannel = ''
  ElMessage.warning('该测试柜通道已被其他电池占用,请重新选择')
  if (item.cabinetChannel && !channelsForCabinet(item.cabinet).some(channel => channel.channelId === item.cabinetChannel)) {
    item.cabinetChannel = null
  }
}
function handleCollectorChange(item: BatteryRow) {
  if (!item.collector) return
  if (!item.collector) {
    getSensorSelections(item).forEach(selection => clearSensorSelection(item, selection.id))
    return
  }
  const occupiedChannels = new Set(
    batteries.value
      .filter(other => other.key !== item.key && other.collector === item.collector)
    batteries.value.filter(other => other.key !== item.key && other.collector === item.collector)
      .flatMap(other => getSensorSelections(other).map(selection => selection.value).filter(Boolean)),
  )
  const currentChannels = new Set<string>()
@@ -206,27 +242,23 @@
    if (occupiedChannels.has(selection.value) || currentChannels.has(selection.value)) {
      clearSensorSelection(item, selection.id)
      clearedCount += 1
      return
    }
    currentChannels.add(selection.value)
    } else currentChannels.add(selection.value)
  })
  if (clearedCount) ElMessage.warning(`已清空 ${clearedCount} 个重复通道,请重新选择`)
  if (clearedCount) ElMessage.warning(`已清除 ${clearedCount} 个重复通道,请重新选择`)
}
async function addChannelField() {
  try {
    const result = await ElMessageBox.prompt('输入字段名称后,将在表格最后新增一个可选择采集器通道的列。', '添加采集器字段', {
      confirmButtonText: '添加',
      cancelButtonText: '取消',
      inputPlaceholder: '例如:壳体温度通道',
      confirmButtonText: '添加', cancelButtonText: '取消', inputPlaceholder: '例如:壳体温度通道',
      inputValidator: (value: string) => {
        const label = value.trim()
        if (!label) return '请输入字段名称'
        if (label.length > 16) return '字段名称不能超过 16 个字'
        const normalizedLabel = label.toLocaleLowerCase()
        const labelExists = [...reservedChannelLabels, ...customChannelColumns.value.map(column => column.label)]
        const exists = [...Object.values(fixedSensorLabels), ...customChannelColumns.value.map(column => column.label)]
          .some(item => item.toLocaleLowerCase() === normalizedLabel)
        return labelExists ? '字段名称已存在' : true
        return exists ? '字段名称已存在' : true
      },
    })
    const label = result.value.trim()
@@ -235,33 +267,105 @@
    batteries.value.forEach(item => { item.customChannels[key] = '' })
    ElMessage.success(`已添加“${label}”通道列`)
  } catch {
    // 用户取消输入时保持当前配置。
    // 用户取消输入时保留当前配置。
  }
}
async function loadOwners() {
function channelsForCabinet(cabinetId: number | null) {
  return configurationOptions.value?.channels.filter(item => item.cabinetId === cabinetId) || []
}
function handleThresholdTypeChange(item: ThresholdRow) {
  item.unit = thresholdTypes.find(option => option.value === item.type)?.unit || item.unit
}
async function loadConfiguration() {
  loading.value = true
  ownersLoading.value = true
  try {
    owners.value = await getEnabledUserOptions()
  } catch (error) {
    owners.value = []
    ElMessage.error(error instanceof Error ? error.message : '测试负责人加载失败')
  } finally {
    ownersLoading.value = false
  }
}
async function loadTestTypes() {
  testTypesLoading.value = true
  try {
    const result = await getTestTypeList({ pageNum: 1, pageSize: 1000, status: '0' })
    testTypes.value = result.rows
    const options = await getTestConfigurationOptions()
    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 }))
    cabinetOptions.value = [...new Map(options.channels.map(item => [item.cabinetId, {
      id: item.cabinetId, label: `${item.cabinetName}(${item.cabinetCode})`,
    }])).values()]
    collectorOptions.value = options.collectors.map(item => ({
      id: item.collectorId, label: `${item.collectorName}(${item.collectorCode})`,
    }))
    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()]
    if (testId.value) await loadDetail(testId.value)
  } catch (error) {
    testTypes.value = []
    ElMessage.error(error instanceof Error ? error.message : '测试类型加载失败')
    ElMessage.error(error instanceof Error ? error.message : '测试配置加载失败')
    if (testId.value) await router.push('/test-list')
  } finally {
    loading.value = false
    ownersLoading.value = false
    testTypesLoading.value = false
  }
}
async function loadDetail(id: number) {
  const detail = await getTestDetail(id)
  if (detail.monitoringStatus !== 'NOT_MONITORED') throw new Error('开始监测后不能修改测试配置')
  form.name = detail.testName
  form.principalId = detail.principalId
  form.testTypeId = detail.testTypeId
  form.purpose = detail.testPurpose
  form.remark = detail.remark || ''
  form.monitorTimeoutMinutes = detail.monitorTimeoutMinutes
  const customFieldNames = new Set(
    detail.batteries.flatMap(item => item.fields.map(field => field.fieldName))
      .filter(fieldName => !fixedFieldByLabel[fieldName]),
  )
  customChannelColumns.value = [...customFieldNames].map(label => ({ key: `custom-channel-${customChannelKey++}`, label }))
  batteries.value = detail.batteries.map(item => {
    const channel = configurationOptions.value?.channels.find(option => option.channelId === item.binding.channelId)
    const row = createBatteryRow(item.batteryNo, item.groupId)
    row.cabinet = channel?.cabinetId || null
    row.cabinetChannel = item.binding.channelId
    row.collector = item.collectorId
    row.expectedBarcode = item.binding.expectedBarcode || ''
    item.fields.forEach(field => {
      const fixedField = fixedFieldByLabel[field.fieldName]
      if (fixedField) row[fixedField] = field.fieldValue
      const customColumn = customChannelColumns.value.find(column => column.label === field.fieldName)
      if (customColumn) row.customChannels[customColumn.key] = field.fieldValue
    })
    return row
  })
  planSteps.value = detail.planSteps.map(item => ({
    key: planStepKey++, selected: false, cycleNo: item.cycleNo, stepNo: item.stepNo,
    stepType: item.stepType, stepName: item.stepName, parametersJson: JSON.stringify(item.parameters),
  }))
  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,
  }))
}
function addPlanStep() {
  const row = createPlanStepRow()
  const previous = planSteps.value.at(-1)
  if (previous) {
    row.cycleNo = previous.cycleNo
    row.stepNo = previous.stepNo + 1
  }
  planSteps.value.push(row)
}
function removePlanSteps() {
  if (!planSteps.value.some(item => item.selected)) return void ElMessage.warning('请先选择需要删除的计划工步')
  planSteps.value = planSteps.value.filter(item => !item.selected)
}
function addThreshold() {
@@ -269,11 +373,19 @@
}
function removeThresholds() {
  if (!thresholds.value.some(item => item.selected)) {
    ElMessage.warning('请先选择需要删除的阈值')
    return
  }
  if (!thresholds.value.some(item => item.selected)) return void ElMessage.warning('请先选择需要删除的阈值')
  thresholds.value = thresholds.value.filter(item => !item.selected)
}
function parseParameters(value: string): Record<string, unknown> | null {
  try {
    const parsed: unknown = JSON.parse(value)
    return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
      ? parsed as Record<string, unknown>
      : null
  } catch {
    return null
  }
}
function validate() {
@@ -281,137 +393,201 @@
  if (!form.principalId) return '请选择测试负责人'
  if (!form.testTypeId) return '请选择测试类型'
  if (!form.purpose.trim()) return '请输入测试目的'
  if (!Number.isInteger(form.monitorTimeoutMinutes) || form.monitorTimeoutMinutes < 1) return '监测超时必须为正整数'
  if (!batteries.value.length) return '至少需要配置一块电池'
  const requiredFields: Array<[keyof BatteryRow, string]> = [
    ['batteryNo', '电池号'], ['cabinet', '测试柜'], ['cabinetChannel', '测试柜通道'], ['collector', '采集器'],
    ['temperature1', '主温度通道'], ['positive1', '正电位主通道'], ['negative1', '负电位主通道'],
  ]
  const groupIds = new Set(batteries.value.map(item => item.groupId))
  if (groupIds.has(null) || groupIds.size !== 1) return '同一测试只能选择一个电池组'
  for (let index = 0; index < batteries.value.length; index += 1) {
    for (const [field, label] of requiredFields) {
      if (!String(batteries.value[index][field]).trim()) return `第 ${index + 1} 行请选择${label}`
    }
    const item = batteries.value[index]
    if (!item.batteryNo.trim()) return `第 ${index + 1} 行缺少电池号`
    if (!item.cabinet) return `第 ${index + 1} 行请选择检测柜`
    if (!item.cabinetChannel) return `第 ${index + 1} 行请选择主要检测柜通道`
  }
  const cabinetChannels = batteries.value.map(item => `${item.cabinet}/${item.cabinetChannel}`)
  if (new Set(cabinetChannels).size !== cabinetChannels.length) return '同一测试柜的测试柜通道不可重复'
  const batteryNos = batteries.value.map(item => item.batteryNo.trim())
  if (new Set(batteryNos).size !== batteryNos.length) return '测试内电池号不可重复'
  const cabinetChannels = batteries.value.map(item => item.cabinetChannel)
  if (new Set(cabinetChannels).size !== cabinetChannels.length) return '同一检测柜通道不可重复绑定'
  const collectorChannels = batteries.value.flatMap(item => [
    ...fixedSensorFields.map(field => item[field]),
    ...customChannelColumns.value.map(column => item.customChannels[column.key]),
  ].filter(Boolean).map(channel => `${item.collector}/${channel}`))
  const collectorChannels = batteries.value.flatMap(item => item.collector
    ? getSensorSelections(item).filter(selection => selection.value).map(selection => `${item.collector}/${selection.value}`)
    : [])
  if (new Set(collectorChannels).size !== collectorChannels.length) return '同一采集器的采集通道不可重复'
  if (!planSteps.value.length) return '至少需要配置一个计划工步'
  const stepIdentities = new Set<string>()
  for (let index = 0; index < planSteps.value.length; index += 1) {
    const item = planSteps.value[index]
    if (!Number.isInteger(item.cycleNo) || item.cycleNo < 1 || !Number.isInteger(item.stepNo) || item.stepNo < 1) {
      return `第 ${index + 1} 行计划工步序号必须为正整数`
    }
    if (!item.stepType.trim() || !item.stepName.trim()) return `请完整填写第 ${index + 1} 行计划工步`
    if (!parseParameters(item.parametersJson)) return `第 ${index + 1} 行工步参数必须是 JSON 对象`
    const identity = `${item.cycleNo}/${item.stepNo}`
    if (stepIdentities.has(identity)) return '计划工步的循环号和工步号不可重复'
    stepIdentities.add(identity)
  }
  if (!thresholds.value.length) return '至少需要配置一项预警阈值'
  const metricCodes = new Set<string>()
  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} 行阈值最小值必须小于最大值`
    if (!item.type) return `请选择第 ${index + 1} 行阈值类型`
    if (metricCodes.has(item.type)) return '同一阈值类型不可重复配置'
    metricCodes.add(item.type)
    if (item.min === '' && item.max === '') return `第 ${index + 1} 行阈值至少填写最小值或最大值`
    if (item.min !== '' && item.max !== '' && Number(item.min) > Number(item.max)) {
      return `第 ${index + 1} 行阈值最小值不能大于最大值`
    }
  }
  return ''
}
async function submit() {
  const error = validate()
  if (error) {
    ElMessage.warning(error)
    return
function buildPayload(): TestConfigurationPayload {
  return {
    testName: form.name.trim(),
    principalId: form.principalId as number,
    testTypeId: form.testTypeId as number,
    testPurpose: form.purpose.trim(),
    monitorTimeoutMinutes: form.monitorTimeoutMinutes,
    remark: form.remark.trim() || null,
    batteries: batteries.value.map(item => ({
      groupId: item.groupId as number,
      batteryNo: item.batteryNo.trim(),
      collectorId: item.collector,
      binding: { channelId: item.cabinetChannel as number, expectedBarcode: item.expectedBarcode.trim() || null },
      fields: [
        ...fixedSensorFields.map(field => ({ fieldName: fixedSensorLabels[field], fieldValue: item[field] })),
        ...customChannelColumns.value.map(column => ({ fieldName: column.label, fieldValue: item.customChannels[column.key] || '' })),
      ].filter(field => field.fieldValue),
    })),
    planSteps: planSteps.value.map((item, index) => ({
      cycleNo: item.cycleNo, stepNo: item.stepNo, stepType: item.stepType.trim(), stepName: item.stepName.trim(),
      parameters: parseParameters(item.parametersJson) as Record<string, unknown>, sortOrder: index + 1,
    })),
    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: 'WARNING',
    })),
  }
  submitting.value = true
  await new Promise(resolve => window.setTimeout(resolve, 350))
  createTest({
    name: form.name.trim(),
    type: selectedTestTypeName.value,
    batteryNo: batteries.value.map(item => item.batteryNo).join('、'),
    batteryGroup: selectedBatteryGroup.value || '自定义电池组',
    batteryCount: batteries.value.length,
    purpose: form.purpose.trim(),
    owner: selectedOwnerName.value,
    remark: form.remark.trim() || '等待接入检测柜',
  })
  ElMessage.success('测试已创建并加入测试列表')
  submitting.value = false
  await router.push('/test-list')
}
onMounted(() => {
  loadOwners()
  loadTestTypes()
})
async function submit() {
  const validationError = validate()
  if (validationError) return void ElMessage.warning(validationError)
  submitting.value = true
  try {
    const payload = buildPayload()
    if (testId.value) {
      await updateTest({ ...payload, testId: testId.value })
      ElMessage.success('测试配置已更新')
    } else {
      await createTest(payload)
      ElMessage.success('测试已创建并加入测试列表')
    }
    await router.push('/test-list')
  } catch (error) {
    ElMessage.error(error instanceof Error ? error.message : '测试配置保存失败')
  } finally {
    submitting.value = false
  }
}
onMounted(loadConfiguration)
</script>
<template>
  <div class="create-page">
  <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>
      <span>{{ isEdit ? 'EDIT TEST CONFIGURATION' : 'NEW TEST CONFIGURATION' }}</span>
    </div>
    <section class="form-panel">
      <header class="panel-heading">
        <div><i></i><strong>测试参数</strong><span>TEST PARAMETERS</span></div>
        <div><i></i><strong>{{ isEdit ? '编辑测试参数' : '测试参数' }}</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-input v-model="form.name" maxlength="100" 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>
        <label><span><b>*</b>监测超时(分钟)</span><el-input-number v-model="form.monitorTimeoutMinutes" :min="1" :max="1440" :step="1" controls-position="right" /></label>
        <label class="textarea-field"><span><b>*</b>测试目的</span><el-input v-model="form.purpose" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="输入测试目的" /></label>
        <label class="textarea-field"><span>备注</span><el-input v-model="form.remark" type="textarea" :rows="4" maxlength="500" 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>
      <header class="panel-heading channel-heading"><div><i></i><strong>电池组和通道设置</strong><span>BATTERY & CHANNELS</span></div></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><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` }">
        <table class="config-table battery-table" :style="{ minWidth: `${1880 + 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><el-checkbox v-model="allBatteriesSelected" aria-label="选择全部电池" /></th><th>电池号 *</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" placeholder="电池号" /></td>
              <td><el-select v-model="item.cabinet" placeholder="选择" @change="handleCabinetChange(item)"><el-option v-for="option in cabinetOptions" :key="option" :label="option" :value="option" /></el-select></td>
              <td><el-select v-model="item.cabinetChannel" clearable :disabled="!item.cabinet" :placeholder="item.cabinet ? '选择' : '请先选择测试柜'"><el-option v-for="option in channelOptions" :key="option" :label="isCabinetChannelUsed(item, option) ? `${option}(已占用)` : option" :value="option" :disabled="isCabinetChannelUsed(item, option)" /></el-select></td>
              <td><el-select v-model="item.collector" placeholder="选择" @change="handleCollectorChange(item)"><el-option v-for="option in collectorOptions" :key="option" :label="option" :value="option" /></el-select></td>
              <td v-for="field in fixedSensorFields" :key="field">
                <el-select v-model="item[field]" clearable :disabled="!item.collector" :placeholder="item.collector ? '选择' : '请先选择采集器'"><el-option v-for="option in sensorChannels" :key="option" :label="isSensorChannelUsed(item, field, option) ? `${option}(已占用)` : option" :value="option" :disabled="isSensorChannelUsed(item, field, option)" /></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="option in sensorChannels" :key="option" :label="isSensorChannelUsed(item, `custom:${column.key}`, option) ? `${option}(已占用)` : option" :value="option" :disabled="isSensorChannelUsed(item, `custom:${column.key}`, option)" /></el-select>
              </td>
              <td><el-input v-model="item.batteryNo" disabled /></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-input v-model="item.expectedBarcode" maxlength="100" placeholder="选填" /></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="field in fixedSensorFields" :key="field"><el-select v-model="item[field]" clearable :disabled="!item.collector" :placeholder="item.collector ? '选填' : '未选采集器'"><el-option v-for="option in sensorChannels" :key="option" :label="isSensorChannelUsed(item, field, option) ? `${option}(已占用)` : option" :value="option" :disabled="isSensorChannelUsed(item, field, option)" /></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="option in sensorChannels" :key="option" :label="isSensorChannelUsed(item, `custom:${column.key}`, option) ? `${option}(已占用)` : option" :value="option" :disabled="isSensorChannelUsed(item, `custom:${column.key}`, option)" /></el-select></td>
            </tr>
            <tr v-if="!batteries.length"><td :colspan="12 + customChannelColumns.length" class="empty-row">尚未配置电池,请点击“添加电池”</td></tr>
            <tr v-if="!batteries.length"><td :colspan="13 + customChannelColumns.length" class="empty-row">暂未配置电池,请点击“添加电池”。</td></tr>
          </tbody>
        </table>
      </div>
    </section>
    <section class="form-panel plan-panel">
      <header class="panel-heading"><div><i></i><strong>计划工步</strong><span>TEST PLAN STEPS</span></div></header>
      <div class="section-actions"><div><button class="primary-button" type="button" @click="addPlanStep"><el-icon><Plus /></el-icon>添加工步</button><button class="secondary-button danger" type="button" @click="removePlanSteps"><el-icon><Delete /></el-icon>删除所选</button></div></div>
      <div class="config-table-wrap compact-table-wrap">
        <table class="config-table plan-table">
          <thead><tr><th><el-checkbox v-model="allPlanStepsSelected" aria-label="选择全部计划工步" /></th><th>循环号 *</th><th>工步号 *</th><th>工步类型 *</th><th>工步名称 *</th><th>参数(JSON 对象)*</th></tr></thead>
          <tbody>
            <tr v-for="item in planSteps" :key="item.key">
              <td><el-checkbox v-model="item.selected" :aria-label="`选择计划工步 ${item.key}`" /></td>
              <td><el-input-number v-model="item.cycleNo" :min="1" :step="1" controls-position="right" /></td>
              <td><el-input-number v-model="item.stepNo" :min="1" :step="1" controls-position="right" /></td>
              <td><el-input v-model="item.stepType" maxlength="50" placeholder="例如 CHARGE" /></td>
              <td><el-input v-model="item.stepName" maxlength="100" placeholder="输入工步名称" /></td>
              <td><el-input v-model="item.parametersJson" placeholder="{}" /></td>
            </tr>
            <tr v-if="!planSteps.length"><td colspan="6" 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>
      <header class="panel-heading"><div><i></i><strong>预警阈值</strong><span>ALERT THRESHOLDS</span></div><small>单位按 SI 业务单位保存</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>
          <thead><tr><th><el-checkbox v-model="allThresholdsSelected" aria-label="选择全部阈值" /></th><th>阈值类型 *</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="选择类型"><el-option v-for="option in thresholdTypes" :key="option" :label="option" :value="option" /></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>
              <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" /></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>
              <td><span class="unit-value">{{ item.unit }}</span></td>
            </tr>
            <tr v-if="!thresholds.length"><td colspan="4" class="empty-row">暂未设置预警阈值</td></tr>
            <tr v-if="!thresholds.length"><td colspan="5" class="empty-row">暂未设置预警阈值</td></tr>
          </tbody>
        </table>
      </div>
@@ -419,39 +595,24 @@
    <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 ? '提交中' : '提交测试' }}</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>
      <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.name" :label="group.name" :value="group.name" />
          </el-select>
        </label>
        <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>
            <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 v-model="selectedBatteryNos" :value="batteryNo" :disabled="isBatteryAdded(batteryNo)" :aria-label="`选择电池 ${batteryNo}`" /></td>
                <td>{{ selectedBatteryGroup }}</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>
              <tr v-for="batteryNo in groupBatteryNos" :key="batteryNo" :class="{ added: isBatteryAdded(batteryNo) }"><td><el-checkbox v-model="selectedBatteryNos" :value="batteryNo" :disabled="isBatteryAdded(batteryNo)" :aria-label="`选择电池 ${batteryNo}`" /></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>
      <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>
@@ -473,14 +634,11 @@
.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%; }
.parameter-grid :deep(.el-select), .parameter-grid :deep(.el-input-number) { 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; }
@@ -492,7 +650,7 @@
.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 { width: 100%; 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; }
@@ -500,12 +658,19 @@
.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-select), .config-table :deep(.el-input), .config-table :deep(.el-input-number) { width: 100%; }
.config-table :deep(.el-input__wrapper), .config-table :deep(.el-select__wrapper) { min-height: 32px; padding: 0 8px; background: #fff; }
.compact-table-wrap { width: calc(100% - 28px); margin: 0 14px 14px; border: 1px solid var(--line); }
.plan-table { min-width: 980px; }
.plan-table th:nth-child(2), .plan-table th:nth-child(3) { width: 110px; }
.plan-table th:nth-child(4) { width: 170px; }
.plan-table th:nth-child(5) { width: 220px; }
.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-wrap { width: min(760px, calc(100% - 28px)); margin: 0 14px; border: 1px solid var(--line); }
.threshold-table { min-width: 720px; }
.threshold-table th:nth-child(2) { width: 210px; }
.threshold-table th:last-child { width: 90px; }
.unit-value { display: inline-flex; min-width: 40px; height: 26px; align-items: center; justify-content: center; color: #255884; background: #eaf2f8; border: 1px solid #c7dce9; }
.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; }
@@ -541,7 +706,6 @@
  .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; }
@@ -550,9 +714,7 @@
  .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; }
  .compact-table-wrap, .threshold-wrap { width: calc(100% - 24px); margin-right: 12px; margin-left: 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; }
src/views/TestList.vue
@@ -2,14 +2,14 @@
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import { getTestList } from '@/api/tests'
import { getTestList, type TestStatusCode } from '@/api/tests'
import { getTestTypeList } from '@/api/testTypes'
import type { TestRecord, TestStatus } from '@/stores/tests'
interface FilterState {
  name: string
  typeId: number | null
  status: string
  status: TestStatusCode | ''
  batteryNo: string
  dateRange: [string, string] | null
}
@@ -19,11 +19,14 @@
  name: string
}
const statuses: Array<{ label: TestStatus; value: string }> = [
  { label: '待测试', value: '0' },
  { label: '检测中', value: '1' },
  { label: '检测中(异常)', value: '2' },
  { label: '检测完成', value: '3' },
const statuses: Array<{ label: TestStatus; value: TestStatusCode }> = [
  { label: '待测试', value: 'WAITING' },
  { label: '检测中', value: 'RUNNING' },
  { label: '暂停', value: 'PAUSED' },
  { label: '检测中(异常)', value: 'RUNNING_ABNORMAL' },
  { label: '完成确认中', value: 'COMPLETION_PENDING' },
  { label: '检测完成', value: 'COMPLETED' },
  { label: '已终止', value: 'TERMINATED' },
]
const router = useRouter()
const testTypes = ref<TestTypeOption[]>([])
@@ -54,7 +57,7 @@
      testName: query.name.trim() || undefined,
      testTypeId: query.typeId || undefined,
      batteryNo: query.batteryNo.trim() || undefined,
      status: query.status || undefined,
      testStatus: query.status || undefined,
      beginCreateTime: query.dateRange?.[0] ? `${query.dateRange[0]} 00:00:00` : undefined,
      endCreateTime: query.dateRange?.[1] ? `${query.dateRange[1]} 23:59:59` : undefined,
    })
@@ -99,6 +102,8 @@
    '检测完成': 'completed',
    '暂停': 'paused',
    '检测中(异常)': 'abnormal',
    '完成确认中': 'pending',
    '已终止': 'abnormal',
  }[status]
}
@@ -108,6 +113,10 @@
function openConsole(item: TestRecord) {
  router.push(`/test-list/${item.id}/console`)
}
function editTest(item: TestRecord) {
  router.push(`/test-list/${item.id}/edit`)
}
function unavailableAction(action: string) {
@@ -214,10 +223,14 @@
              <td :title="item.purpose">{{ item.purpose }}</td>
              <td>{{ item.owner }}</td>
              <td :title="item.remark">{{ item.remark }}</td>
              <td><span class="status-tag" :class="statusClass(item.status)"><i></i>{{ item.status }}</span></td>
              <td>
                <span class="status-tag" :class="statusClass(item.status)"><i></i>{{ item.status }}</span>
                <small v-if="item.monitoringStatus" class="monitoring-status">{{ item.monitoringStatus }}</small>
              </td>
              <td class="action-column">
                <div class="row-actions">
                  <button type="button" @click="openConsole(item)">控制台</button>
                  <button v-if="item.editable" type="button" @click="editTest(item)">编辑</button>
                  <button class="delete-action" type="button" @click="unavailableAction('删除')">删除</button>
                </div>
              </td>