yeg
2026-09-02 cfdfa7eb61d5e906ec6350f3141aac714a9b2bd8
更新文档
4个文件已修改
137 ■■■■■ 已修改文件
AGENTS.md 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/tests.ts 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/ConsoleDataListPanel.vue 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/TestConsole.vue 93 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
AGENTS.md
@@ -11,23 +11,23 @@
## 测试与监测界面
- 正式测试状态只展示:`待测试`、`检测中`、`暂停`、`检测中(异常)`、`检测完成`、`已终止`。“完成确认中”是后端内部阶段,前端可用进度文案表达但不新增正式测试状态。
- 正式测试状态只展示:`待测试`、`检测中`、`暂停`、`检测完成`。`完成确认中`、`检测中(异常)`和`已终止`不属于新流程状态,历史值仅作兼容展示。
- 设备状态和接收状态单独并列展示。接收状态使用:`未开始接收`、`接收中`、`暂停接收`、`已结束接收`;不要把设备、接收和测试三套状态合并到一个字段或筛选项。
- 开始、暂停、继续和停止测试的页面动作只改变系统数据请求,不表示控制 BTS 或采集器。确认文案必须明确不控制设备;暂停期间停止实时请求、保留最后快照并标记“不纳入结果”,停止后进入“已终止”。
- 实时快照与历史结果在类型、接口和页面文案上分开;实时点不能加入最终原始数据列表。
- 开始、暂停、继续和停止测试的页面动作只改变系统数据请求,不表示控制 BTS 或采集器。确认文案必须明确不控制设备;暂停期间停止实时请求、保留最后快照,停止后进入“检测完成”。
- 实时快照与成功接收数据在类型、接口和页面文案上分开;数据列表和图表使用当前有效数据。
- 第一版每个测试电池号必须选择一个已登记采集器,以及主温度、正电位和负电位通道;可选次要通道和动态测量项保持现有交互。`S-01...S-N` 仅属于演示合同。
## API 与 SSE
- HTTP 请求复用 `src/api/request.ts` 的鉴权和错误处理;按业务域在 `src/api` 中维护带类型的接口,不在视图内散落 URL。
- SSE 使用 `fetch` 流式读取 `text/event-stream` 并携带 `Authorization` 请求头。JWT 不放入 URL,不使用原生 `EventSource` 绕开现有鉴权。
- SSE 重连后用服务端当前快照替换本地快照;历史数据按测试动作时间由后端查询。不要把 SSE 当作可靠消息队列或在浏览器累积所有采样点。
- SSE 重连后用服务端当前快照替换本地快照;成功接收数据和当前有效数据按测试查询接口读取。不要把 SSE 当作可靠消息队列或在浏览器累积所有采样点。
- API 时间按 ISO 8601 解析并以用户本地时区展示;单位换算只发生在展示或导入边界,并保留接口返回的原始单位。
## 交互与数据安全
- 运行中测试禁用导入。导入结果逐行展示校验信息,并明确分组成功与失败记录;不能用导入覆盖原始采样。
- 终态测试不提供重启入口。修订和认领纠正以新操作提交并展示审计结果,不提供静默撤销。
- 运行中测试禁用导入。导入结果逐行展示校验信息,并明确分组成功与失败记录;导入、编辑和删除统一生成数据修订,不能覆盖原始设备数据。
- 终态测试不提供重启入口。修订和删除以新操作提交并展示审计结果;当前有效数据使用修订后的值,原始设备数据保留追溯。
- SSE 断开、REST 失败和权限拒绝要有可见错误状态;不得乐观地把正式测试状态改为成功。
- 延续现有 Element Plus 布局与组件习惯,确保桌面和常用窄屏下按钮、状态文本和表格操作不重叠。
src/api/tests.ts
@@ -28,6 +28,7 @@
export interface TestBatteryPayload {
  relationId?: number
  groupId: number
  groupName?: string | null
  batteryNo: string
  collectorId: number
  collectorName?: string | null
@@ -66,11 +67,15 @@
export interface TestConfigurationDetail extends TestConfigurationPayload {
  testId: number
  creatorId: number
  createTime?: string | null
  updateTime?: string | null
  creatorName: string | null
  principalName: string | null
  testTypeName: string | null
  testStatus: TestStatusCode
  receptionStatus: ReceptionStatusCode
  startedAtUtc?: string | null
  endedAtUtc?: string | null
  fieldSnapshots: Array<{
    snapshotId: number
    fieldDefinitionId: number
@@ -328,6 +333,8 @@
  sequenceId: number
  recordedAt: string
  absoluteTime: string
  deviceTime?: string
  serverReceivedAt?: string
  voltage: number | string | null
  current: number | string | null
  capacity: number | string | null
src/components/ConsoleDataListPanel.vue
@@ -28,6 +28,7 @@
  sequenceId: number
  recordedAt: string
  absoluteTime: string
  serverReceivedAt: string
  stepDuration: string
  startVoltage: string
  endVoltage: string
@@ -95,7 +96,7 @@
          { label: '放电能量(Wh)', value: 'dischargeEnergy' },
        ],
)
function formatTime(value: string) {
function formatTime(value?: string | null) {
  return value
    ? value
        .replace('T', ' ')
@@ -104,11 +105,11 @@
    : '-'
}
function emptyRow(base: Partial<DataRow>): DataRow {
  return { id: '', recordNo: '', batteryNo: '', cycleNo: 0, stepNo: 0, stepName: '', actualStepLabel: '-', sequenceId: 0, recordedAt: '', absoluteTime: '', stepDuration: '-', startVoltage: '-', endVoltage: '-', chargePeakVoltage: '-', dischargeValleyVoltage: '-', voltage: '-', current: '-', capacity: '-', energy: '-', chargeCapacity: '-', dischargeCapacity: '-', efficiency: '-', chargeEnergy: '-', dischargeEnergy: '-', ...base }
  return { id: '', recordNo: '', batteryNo: '', cycleNo: 0, stepNo: 0, stepName: '', actualStepLabel: '-', sequenceId: 0, recordedAt: '', absoluteTime: '', serverReceivedAt: '', stepDuration: '-', startVoltage: '-', endVoltage: '-', chargePeakVoltage: '-', dischargeValleyVoltage: '-', voltage: '-', current: '-', capacity: '-', energy: '-', chargeCapacity: '-', dischargeCapacity: '-', efficiency: '-', chargeEnergy: '-', dischargeEnergy: '-', ...base }
}
function mapRecord(record: PersistedTestRecord): DataRow {
  const timestamp = formatTime(record.recordedAt)
  return emptyRow({ id: `${record.deviceId}:${record.channelId}:${record.sequenceId}`, recordNo: record.recordNo, batteryNo: record.batteryNo || '-', cycleNo: Number(record.cycleNo), stepNo: Number(record.stepNo), stepName: record.stepName || '-', sequenceId: Number(record.sequenceId), recordedAt: timestamp, absoluteTime: formatTime(record.absoluteTime || record.recordedAt), voltage: record.voltage == null ? '-' : Number(record.voltage).toFixed(4), current: record.current == null ? '-' : Number(record.current).toFixed(4), capacity: record.capacity == null ? '-' : Number(record.capacity).toFixed(4), energy: record.energy == null ? '-' : Number(record.energy).toFixed(4) })
  const deviceTime = formatTime(record.deviceTime || record.recordedAt)
  return emptyRow({ id: `${record.deviceId}:${record.channelId}:${record.sequenceId}`, recordNo: record.recordNo, batteryNo: record.batteryNo || '-', cycleNo: Number(record.cycleNo), stepNo: Number(record.stepNo), stepName: record.stepName || '-', sequenceId: Number(record.sequenceId), recordedAt: deviceTime, absoluteTime: deviceTime, serverReceivedAt: formatTime(record.serverReceivedAt), voltage: record.voltage == null ? '-' : Number(record.voltage).toFixed(4), current: record.current == null ? '-' : Number(record.current).toFixed(4), capacity: record.capacity == null ? '-' : Number(record.capacity).toFixed(4), energy: record.energy == null ? '-' : Number(record.energy).toFixed(4) })
}
function mapStep(record: PersistedTestStep): DataRow {
  const startedAt = formatTime(record.recordedAt)
@@ -213,7 +214,7 @@
  router.push({ path: '/data-revision', query: { testId: String(props.testId), dataType: dataType.value } })
}
function templateHeaders() {
  return activeTab.value === 'cycle' ? ['记录编号', '电池号', '循环序号', '充放电容量(Ah)', '效率(%)', '能量(Wh)'] : activeTab.value === 'step' ? ['记录编号', '电池号', '循环序号', '工步序号', '工步', '工步时间', '容量(Ah)', '能量(Wh)', '起止电压(V)', '充电峰值电压(V)', '放电谷值电压(V)'] : ['记录编号', '电池号', '记录时间', '循环序号', '工步序号', '工步名称', '记录序号', '电压(V)', '电流(A)', '容量(Ah)', '能量(Wh)']
  return activeTab.value === 'cycle' ? ['记录编号', '电池号', '循环序号', '充放电容量(Ah)', '效率(%)', '能量(Wh)'] : activeTab.value === 'step' ? ['记录编号', '电池号', '循环序号', '工步序号', '工步', '工步时间', '容量(Ah)', '能量(Wh)', '起止电压(V)', '充电峰值电压(V)', '放电谷值电压(V)'] : ['记录编号', '电池号', '设备时间', '循环序号', '工步序号', '工步名称', '记录序号', '服务器接收时间', '电压(V)', '电流(A)', '容量(Ah)', '能量(Wh)']
}
function downloadTemplate() {
  const worksheet = XLSX.utils.aoa_to_sheet([templateHeaders()])
@@ -333,8 +334,8 @@
    ElMessage.warning('没有可下载的数据')
    return
  }
  const headers = activeTab.value === 'cycle' ? ['记录编号', '电池', '循环号', '充电容量(Ah)', '放电容量(Ah)', '充放电效率(%)', '充电能量(Wh)', '放电能量(Wh)'] : activeTab.value === 'step' ? ['记录编号', '电池', '循环号', '工步序号', '工步', '工步时间', '容量(Ah)', '能量(Wh)', '起始电压(V)', '终止电压(V)', '充电峰值(V)', '放电谷值(V)'] : ['记录编号', '电池', '循环号', '实际工步号', '实际工步名称', '记录序号', '记录时间', '绝对时间', '电压(V)', '电流(A)', '容量(Ah)', '能量(Wh)']
  const records = activeTab.value === 'cycle' ? filteredRows.value.map(row => [row.recordNo, row.batteryNo, row.cycleNo, row.chargeCapacity, row.dischargeCapacity, row.efficiency, row.chargeEnergy, row.dischargeEnergy]) : activeTab.value === 'step' ? filteredRows.value.map(row => [row.recordNo, row.batteryNo, row.cycleNo, row.stepNo, row.stepName, row.stepDuration, row.capacity, row.energy, row.startVoltage, row.endVoltage, row.chargePeakVoltage, row.dischargeValleyVoltage]) : filteredRows.value.map(row => [row.recordNo, row.batteryNo, row.cycleNo, row.stepNo, row.stepName, row.sequenceId, row.recordedAt, row.absoluteTime, row.voltage, row.current, row.capacity, row.energy])
  const headers = activeTab.value === 'cycle' ? ['记录编号', '电池', '循环号', '充电容量(Ah)', '放电容量(Ah)', '充放电效率(%)', '充电能量(Wh)', '放电能量(Wh)'] : activeTab.value === 'step' ? ['记录编号', '电池', '循环号', '工步序号', '工步', '工步时间', '容量(Ah)', '能量(Wh)', '起始电压(V)', '终止电压(V)', '充电峰值(V)', '放电谷值(V)'] : ['记录编号', '电池', '循环号', '实际工步号', '实际工步名称', '记录序号', '设备时间', '服务器接收时间', '电压(V)', '电流(A)', '容量(Ah)', '能量(Wh)']
  const records = activeTab.value === 'cycle' ? filteredRows.value.map(row => [row.recordNo, row.batteryNo, row.cycleNo, row.chargeCapacity, row.dischargeCapacity, row.efficiency, row.chargeEnergy, row.dischargeEnergy]) : activeTab.value === 'step' ? filteredRows.value.map(row => [row.recordNo, row.batteryNo, row.cycleNo, row.stepNo, row.stepName, row.stepDuration, row.capacity, row.energy, row.startVoltage, row.endVoltage, row.chargePeakVoltage, row.dischargeValleyVoltage]) : filteredRows.value.map(row => [row.recordNo, row.batteryNo, row.cycleNo, row.stepNo, row.stepName, row.sequenceId, row.recordedAt, row.serverReceivedAt, row.voltage, row.current, row.capacity, row.energy])
  const blob = new Blob([`\uFEFF${[headers, ...records].map(item => item.map(escapeCsv).join(',')).join('\n')}`], { type: 'text/csv;charset=utf-8' })
  const url = URL.createObjectURL(blob)
  const anchor = document.createElement('a')
@@ -419,8 +420,8 @@
            <th>实际工步号</th>
            <th>实际工步名称</th>
            <th>记录序号</th>
            <th>记录时间</th>
            <th>绝对时间</th>
            <th>设备时间</th>
            <th>服务器接收时间</th>
            <th>电压(V)</th>
            <th>电流(A)</th>
            <th>容量(Ah)</th>
@@ -470,7 +471,7 @@
              <td>{{ row.stepName }}</td>
              <td>{{ row.sequenceId }}</td>
              <td>{{ row.recordedAt }}</td>
              <td>{{ row.absoluteTime }}</td>
              <td>{{ row.serverReceivedAt }}</td>
              <td>
                <el-input v-if="editing && draftRows[row.id]" v-model="draftRows[row.id].voltage" size="small" />
                <span v-else>{{ row.voltage }}</span>
@@ -605,6 +606,10 @@
            <dt>记录时间</dt>
            <dd>{{ singleRow.recordedAt }}</dd>
          </div>
          <div>
            <dt>服务器接收</dt>
            <dd>{{ singleRow.serverReceivedAt }}</dd>
          </div>
        </dl>
        <div class="single-form-grid">
          <label v-for="field in fieldOptions" :key="String(field.value)"
src/views/TestConsole.vue
@@ -5,17 +5,15 @@
import ConsoleChartPanel from '@/components/ConsoleChartPanel.vue'
import ConsoleProjectChartPanel from '@/components/ConsoleProjectChartPanel.vue'
import ConsoleDataListPanel from '@/components/ConsoleDataListPanel.vue'
import { useTests } from '@/stores/tests'
import { endTestReception, getTestDetail, getTestLiveState, openTestLiveStream, pauseTestReception, resumeTestReception, saveLogicStepMappings, startTestReception, type TestConfigurationDetail, type TestLiveState, type TestLogicStepMapping } from '@/api/tests'
type ConsoleState = '未开始接收' | '检测中' | '暂停' | '已结束'
type ConsoleState = '待测试' | '检测中' | '暂停' | '检测完成'
type ReceptionState = '未开始接收' | '接收中' | '暂停接收' | '已结束接收'
type ConsoleTab = 'realtime' | 'step-chart' | 'project-chart' | 'data-list'
const route = useRoute()
const router = useRouter()
const { records: testRecords } = useTests()
const testId = computed(() => Number(route.params.id) || 1024)
const testId = computed(() => Number(route.params.id))
const backendDetail = ref<TestConfigurationDetail | null>(null)
const liveState = ref<TestLiveState | null>(null)
const liveConnection = ref<'连接中' | '已连接' | '已断开' | '权限不足' | '空数据'>('连接中')
@@ -25,30 +23,23 @@
let reconnectTimer: number | null = null
const runtimeAttach = ref(false)
const receptionState = ref<ReceptionState>('未开始接收')
const testProfiles: Record<number, { name: string; owner: string; purpose: string; startedAt: string; batteryGroup: string; batteryCount: number; abnormal: boolean }> = {
  1024: { name: '高温循环性能测试', owner: '张工', purpose: '验证高温环境循环寿命', startedAt: '2026-07-10 14:40:00', batteryGroup: '高温循环 A 组', batteryCount: 8, abnormal: false },
  1023: { name: '电芯容量一致性测试', owner: '李工', purpose: '筛查批次容量离散度', startedAt: '2026-07-10 11:30:00', batteryGroup: '容量一致性 A 组', batteryCount: 6, abnormal: true },
function formatDateTime(value?: string | null) {
  return value ? value.replace('T', ' ').replace(/Z$/, '').replace(/\.\d{3,6}$/, '') : '-'
}
const currentTest = computed(() => testRecords.value.find(item => item.id === testId.value))
const profile = computed(() => {
  const record = currentTest.value
  const fallback = testProfiles[testId.value]
  const detail = backendDetail.value
  const batteries = detail?.batteries || []
  return {
    name: record?.name || fallback?.name || `测试任务 #${testId.value}`,
    owner: record?.owner || fallback?.owner || 'Admin',
    purpose: record?.purpose || fallback?.purpose || '电池性能检测',
    startedAt: record && record.startedAt !== '-' ? `${record.startedAt}:00` : fallback?.startedAt || record?.createdAt || '2026-08-07 09:00:00',
    batteryGroup: record?.batteryGroup || fallback?.batteryGroup || '演示电池组',
    batteryCount: backendDetail.value?.batteries.length || record?.batteryCount || fallback?.batteryCount || 4,
    batteryNos: backendDetail.value?.batteries.length
      ? backendDetail.value.batteries.map(item => item.batteryNo).filter(Boolean)
      : (record?.batteryNo && record.batteryNo !== '-' ? record.batteryNo.split('、').filter(Boolean) : []),
    abnormal: record?.status === '检测中(异常)' || fallback?.abnormal || false,
    name: detail?.testName || '-',
    owner: detail?.principalName || '-',
    purpose: detail?.testPurpose || '-',
    startedAt: formatDateTime(detail?.startedAtUtc),
    batteryGroup: batteries[0]?.groupName || '-',
    batteryCount: batteries.length,
  }
})
const initialStatus = currentTest.value?.status
const state = ref<ConsoleState>(initialStatus === '检测完成' || initialStatus === '完成确认中' || initialStatus === '已终止' || route.query.state === 'ended' ? '已结束' : initialStatus === '暂停' ? '暂停' : initialStatus === '待测试' ? '未开始接收' : '检测中')
const elapsedSeconds = ref(42 * 60 + 25)
const state = ref<ConsoleState>('待测试')
const elapsedSeconds = ref(0)
const stepSettings = ref<TestLogicStepMapping[]>([])
const stepDraft = ref<TestLogicStepMapping[]>([])
const selectedPlanSteps = ref<number[]>([])
@@ -69,7 +60,9 @@
  liveConnection.value = next.sources.some(source => Boolean(source.latestSnapshot?.observedAt)
    || Boolean(source.latestSnapshot?.measurements?.length)) ? '已连接' : '空数据'
  liveError.value = ''
  testStatusLabel.value = ({ WAITING: '待测试', RUNNING: '检测中', PAUSED: '暂停', RUNNING_ABNORMAL: '检测中(异常)', COMPLETION_PENDING: '检测完成', COMPLETED: '检测完成', TERMINATED: '检测完成' } as Record<string, string>)[next.testStatus] || '未知'
  const nextState = ({ WAITING: '待测试', RUNNING: '检测中', PAUSED: '暂停', RUNNING_ABNORMAL: '检测中', COMPLETION_PENDING: '检测完成', COMPLETED: '检测完成', TERMINATED: '检测完成' } as Record<string, ConsoleState | undefined>)[next.testStatus] || '待测试'
  state.value = nextState
  testStatusLabel.value = nextState
  const mapped = next.receptionStatus
  if (mapped === 'NOT_STARTED') receptionState.value = '未开始接收'
  else if (mapped === 'PAUSED') receptionState.value = '暂停接收'
@@ -117,17 +110,21 @@
    stepSettings.value = detail.logicStepMappings || []
    runtimeAttach.value = detail.runtimeAttach === true
    if (detail.receptionStatus === 'NOT_STARTED') {
      state.value = '未开始接收'
      state.value = '待测试'
      receptionState.value = '未开始接收'
    } else if (detail.receptionStatus === 'PAUSED') {
      state.value = '暂停'
      receptionState.value = '暂停接收'
    } else if (detail.receptionStatus === 'ENDED') {
      state.value = '已结束'
      state.value = '检测完成'
      receptionState.value = '已结束接收'
    } else {
      state.value = '检测中'
      receptionState.value = '接收中'
    }
    if (detail.startedAtUtc) {
      const started = Date.parse(detail.startedAtUtc)
      if (Number.isFinite(started)) elapsedSeconds.value = Math.max(0, Math.floor((Date.now() - started) / 1000))
    }
    try { applyLiveState(await getTestLiveState(testId.value)) } catch (error) {
      liveConnection.value = '已断开'
@@ -147,10 +144,9 @@
  return [hours, minutes, seconds].map(value => String(value).padStart(2, '0')).join(':')
})
const cells = computed(() => Array.from({ length: profile.value.batteryCount }, (_, index) => {
  const battery = backendDetail.value?.batteries[index]
const cells = computed(() => (backendDetail.value?.batteries || []).map((battery) => {
  const binding = battery?.binding
  const batteryNo = battery?.batteryNo || profile.value.batteryNos[index]
  const batteryNo = battery.batteryNo
  const batterySources = liveState.value?.sources?.filter(item => item.batteryNo === batteryNo) || []
  const source = batterySources.find(item => item.sourceType === 'CABINET_CHANNEL') || batterySources[0]
  const cabinetSource = batterySources.find(item => item.sourceType === 'CABINET_CHANNEL')
@@ -160,9 +156,9 @@
    return item && item.value !== undefined ? String(item.value) : undefined
  }
  return {
    no: battery?.batteryNo || profile.value.batteryNos[index] || `SP26-${214 + index}`,
    cabinet: binding?.cabinetName || binding?.cabinetCode || `检测柜 A-${String(Math.floor(index / 4) + 1).padStart(2, '0')}`,
    channel: binding?.displayNo ? `通道 ${binding.displayNo}` : `通道 ${String(index + 1).padStart(2, '0')}`,
    no: batteryNo,
    cabinet: binding?.cabinetName || binding?.cabinetCode || '-',
    channel: binding?.displayNo ? `通道 ${binding.displayNo}` : '-',
    voltage: metric(['VOLTAGE', 'voltage']) || '-',
    temperature: metric(['TEMPERATURE', 'temperature', 'PRIMARY_TEMPERATURE'], collectorSource) || '-',
    positive: metric(['POSITIVE_POTENTIAL', 'PRIMARY_POSITIVE_POTENTIAL'], collectorSource) || '-',
@@ -235,12 +231,18 @@
  return displayRows
})
const parameters = [
  ['测试名称', profile.value.name], ['创建者', '陈师傅'], ['负责人', profile.value.owner], ['测试项目', profile.value.purpose],
  ['创建时间', '2026-07-10 14:32:00'], ['上限电压', '2.500 V'], ['下限电压', '0.600 V'], ['上限电流', '8167.4 mA'],
  ['下限电流', '-8167.4 mA'], ['记录条件', '60000 ms / 0.000 V / 0.0 mA'], ['电压量程', '5 V'], ['电流量程', '+12000 / -12000 mA'],
  ['测试目的', profile.value.purpose], ['备注', '自动采集,异常时通知负责人'],
]
const parameters = computed(() => {
  const detail = backendDetail.value
  if (!detail) return [] as Array<[string, string]>
  const thresholds = (detail.thresholds || []).map(item => `${item.metricCode}: ${item.minValue ?? '-'} ~ ${item.maxValue ?? '-'} ${item.unit}`).join(';')
  const sharedFields = (detail.fieldSnapshots || []).map(item => `${item.fieldName}: ${item.fieldValue ?? '-'}`).join(';')
  return [
    ['测试名称', detail.testName || '-'], ['创建者', detail.creatorName || '-'], ['负责人', detail.principalName || '-'],
    ['测试类型', detail.testTypeName || '-'], ['测试目的', detail.testPurpose || '-'], ['创建时间', formatDateTime(detail.createTime)],
    ['开始接收', formatDateTime(detail.startedAtUtc)], ['监测超时', detail.monitorTimeoutMinutes == null ? '-' : `${detail.monitorTimeoutMinutes} 分钟`],
    ['预警阈值', thresholds || '-'], ['共享字段', sharedFields || '-'], ['备注', detail.remark || '-'],
  ] as Array<[string, string]>
})
const channels = computed(() => cells.value.map((cell, index) => {
  const sources = liveState.value?.sources?.filter(item => item.batteryNo === cell.no) || []
@@ -253,7 +255,7 @@
}))
async function togglePause() {
  if (state.value === '已结束' || state.value === '未开始接收') return
  if (state.value === '检测完成' || state.value === '待测试') return
  const pausing = state.value !== '暂停'
  try {
    await ElMessageBox.confirm(
@@ -276,7 +278,7 @@
}
async function startReception() {
  if (state.value !== '未开始接收') return
  if (state.value !== '待测试') return
  try {
    backendDetail.value = await startTestReception(testId.value)
    runtimeAttach.value = backendDetail.value.runtimeAttach === true
@@ -289,13 +291,13 @@
}
async function endTest() {
  if (state.value === '已结束') return
  if (state.value === '检测完成') return
  try {
    await ElMessageBox.confirm('停止测试后将不可恢复,系统会停止请求本测试的数据,但不会向设备发送停止命令,是否继续?', '确认停止测试', { confirmButtonText: '停止测试', cancelButtonText: '取消', type: 'warning', confirmButtonClass: 'danger-confirm' })
    backendDetail.value = await endTestReception(testId.value)
    state.value = '已结束'
    state.value = '检测完成'
    receptionState.value = '已结束接收'
    testStatusLabel.value = ({ WAITING: '待测试', RUNNING: '检测中', PAUSED: '暂停', RUNNING_ABNORMAL: '检测中(异常)', COMPLETION_PENDING: '检测完成', COMPLETED: '检测完成', TERMINATED: '检测完成' } as Record<string, string>)[backendDetail.value.testStatus || 'COMPLETED'] || '检测完成'
    testStatusLabel.value = ({ WAITING: '待测试', RUNNING: '检测中', PAUSED: '暂停', RUNNING_ABNORMAL: '检测中', COMPLETION_PENDING: '检测完成', COMPLETED: '检测完成', TERMINATED: '检测完成' } as Record<string, string>)[backendDetail.value.testStatus || 'COMPLETED'] || '检测完成'
    ElMessage.success('测试已停止,系统不再请求数据')
  } catch (error) {
    if (error === 'cancel' || error === 'close') return
@@ -315,7 +317,7 @@
      <header class="realtime-header">
        <div><strong>实时数据</strong><span class="cell-count">{{ cells.length }} 个电池</span><span class="running-state" :class="state"><i></i>{{ state }}</span><span class="test-state">测试状态:{{ testStatusLabel }}</span><span class="reception-state"><i></i>{{ receptionState }}</span><span class="live-connection" :class="liveConnection"><i></i>{{ liveConnection }}</span><span v-if="runtimeAttach" class="attach-state">运行中接入</span></div>
        <dl><div><dt>测试开始时间</dt><dd>{{ profile.startedAt }}</dd></div><div><dt>测试已进行时间</dt><dd>{{ elapsedText }}</dd></div></dl>
        <div class="test-actions"><button v-if="state === '未开始接收'" class="primary-button" type="button" @click="startReception"><el-icon><VideoPlay /></el-icon>开始接收</button><button v-else class="primary-button" type="button" :disabled="state === '已结束'" @click="togglePause"><el-icon><VideoPause v-if="state !== '暂停'" /><VideoPlay v-else /></el-icon>{{ state === '暂停' ? '继续接收' : '暂停接收' }}</button><button class="danger-button" type="button" :disabled="state === '已结束' || state === '未开始接收'" @click="endTest"><el-icon><CircleCloseFilled /></el-icon>停止测试</button></div>
        <div class="test-actions"><button v-if="state === '待测试'" class="primary-button" type="button" @click="startReception"><el-icon><VideoPlay /></el-icon>开始接收</button><button v-else class="primary-button" type="button" :disabled="state === '检测完成'" @click="togglePause"><el-icon><VideoPause v-if="state !== '暂停'" /><VideoPlay v-else /></el-icon>{{ state === '暂停' ? '继续接收' : '暂停接收' }}</button><button class="danger-button" type="button" :disabled="state === '检测完成' || state === '待测试'" @click="endTest"><el-icon><CircleCloseFilled /></el-icon>停止测试</button></div>
      </header>
      <div class="cell-grid">
        <article v-for="cell in cells" :key="cell.no" class="cell-card">
@@ -339,7 +341,7 @@
    <ConsoleChartPanel v-if="activeTab === 'step-chart'" :cells="cells" :test-name="profile.name" :battery-group="profile.batteryGroup" :duration-minutes="5" />
    <ConsoleProjectChartPanel v-if="activeTab === 'project-chart'" :cells="cells" />
<ConsoleDataListPanel v-if="activeTab === 'data-list' && backendDetail" :test-id="testId" :test-name="profile.name" :cells="cells" :can-import="state === '已结束'" :reception-status="backendDetail.receptionStatus" :bindings="backendDetail.batteries.map(item => ({ batteryNo: item.batteryNo, bindingId: item.binding?.bindingId, channelId: item.binding?.channelId, devid: item.binding?.devid, chlid: item.binding?.chlid }))" />
<ConsoleDataListPanel v-if="activeTab === 'data-list' && backendDetail" :test-id="testId" :test-name="profile.name" :cells="cells" :can-import="state === '检测完成'" :reception-status="backendDetail.receptionStatus" :bindings="backendDetail.batteries.map(item => ({ batteryNo: item.batteryNo, bindingId: item.binding?.bindingId, channelId: item.binding?.channelId, devid: item.binding?.devid, chlid: item.binding?.chlid }))" />
  </div>
</template>
@@ -353,4 +355,5 @@
.console-tabs{min-height:52px;display:flex;align-items:center;gap:10px;margin-bottom:14px;padding:0 4px;border-bottom:1px solid #d5e0e9}.console-tabs button{height:30px;min-width:88px;padding:0 16px;border:1px solid transparent;border-radius:16px;color:#52606d;background:#f5f7fa;font-size:12px;cursor:pointer;transition:.16s ease}.console-tabs button:hover{color:var(--blue);background:#edf5fd}.console-tabs button.active{color:#1677dd;background:#dff0ff;border-color:#c4e3ff;font-weight:600}.tab-content{min-width:0}.data-list-panel{min-width:0;border:1px solid #d8e1e9;background:#fff}.data-list-panel .panel-heading>small{color:#8795a3;font-size:10px}.data-table-wrap{width:100%;overflow-x:auto;padding:16px 18px 20px}.data-table-wrap table{min-width:1260px}.data-table-wrap td strong{color:#1268dc}.data-voltage{color:#7f35c5;font-weight:600}.data-temperature{color:#bf6100;font-weight:600}.console-layout{grid-template-columns:minmax(0,2.35fr) minmax(300px,1fr);gap:24px;min-width:0}.console-layout>main,.console-layout>.parameter-panel,.console-layout main>.panel{min-width:0;max-width:100%}.step-table-wrap,.channel-table-wrap{width:100%;max-width:100%;min-width:0;overflow-x:auto;overflow-y:hidden}.step-table-wrap table{min-width:720px}.channel-table-wrap table{width:1120px;min-width:1120px;max-width:none}.parameter-panel{position:relative;z-index:1;overflow:hidden}@media(max-width:1280px){.console-layout{grid-template-columns:minmax(0,1fr) 300px;gap:20px}}@media(max-width:1050px){.console-layout{grid-template-columns:minmax(0,1fr);gap:16px}.parameter-panel{grid-row:2}}@media(max-width:600px){.console-tabs{overflow-x:auto}.console-tabs button{flex:0 0 auto}.data-table-wrap{padding:12px}}
.device-state.离线{color:#b34f4f;background:#fff1f1;border-color:#e9bcbc}
.device-state.未知{color:#7b8794;background:#f2f4f6;border-color:#d8dde2}.mapping-list{display:grid;gap:10px;max-height:360px;overflow:auto;margin:0 16px 16px;padding-top:12px;border-top:1px solid #edf1f5}.mapping-row{display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:8px;padding:8px 10px;border:1px solid #d8e1e9;background:#f8fafc}.mapping-row strong{color:#52606d;font-size:12px}.mapping-row span{color:#71808d;font-size:11px}.mapping-row .compact-button{height:28px}.mapping-row :deep(.el-input-number){width:100%}.step-actions{display:flex;align-items:center;gap:8px}
.running-state.检测完成{color:#7b8794;background:#f2f4f6;border-color:#d8dde2}
</style>