yeg
2026-08-31 35789d2f15c426bafa876806487034f4c5ddc3eb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
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, startTestReception, type TestConfigurationDetail, type TestLiveState, type TestIntegrityIssue } from '@/api/tests'
 
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 backendDetail = ref<TestConfigurationDetail | null>(null)
const liveState = ref<TestLiveState | null>(null)
const liveConnection = ref<'连接中' | '已连接' | '已断开' | '权限不足' | '空数据'>('连接中')
const liveError = ref('')
const testStatusLabel = ref('待测试')
let closeLiveStream: (() => void) | null = null
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 },
}
const currentTest = computed(() => testRecords.value.find(item => item.id === testId.value))
const profile = computed(() => {
  const record = currentTest.value
  const fallback = testProfiles[testId.value]
  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,
  }
})
const initialStatus = currentTest.value?.status
const state = ref<ConsoleState>(initialStatus === '检测完成' || route.query.state === 'ended' ? '已结束' : initialStatus === '暂停' ? '暂停' : initialStatus === '待测试' ? '未开始接收' : '检测中')
const elapsedSeconds = ref(42 * 60 + 25)
const stepDialogVisible = ref(false)
const activeTab = ref<ConsoleTab>(route.query.tab === 'data-list' ? 'data-list' : 'realtime')
const consoleTabs: Array<{ key: ConsoleTab; label: string }> = [
  { key: 'realtime', label: '实时数据' },
  { key: 'step-chart', label: '工步图表' },
  { key: 'project-chart', label: '项目图表' },
  { key: 'data-list', label: '数据列表' },
]
 
const timer = window.setInterval(() => { if (state.value === '检测中') elapsedSeconds.value += 1 }, 1000)
onBeforeUnmount(() => window.clearInterval(timer))
 
function applyLiveState(next: TestLiveState) {
  liveState.value = next
  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 mapped = next.receptionStatus
  if (mapped === 'NOT_STARTED') receptionState.value = '未开始接收'
  else if (mapped === 'PAUSED') receptionState.value = '暂停接收'
  else if (mapped === 'ENDED') receptionState.value = '已结束接收'
  else receptionState.value = '接收中'
}
 
function scheduleLiveReconnect() {
  if (reconnectTimer !== null) return
  reconnectTimer = window.setTimeout(async () => {
    reconnectTimer = null
    try { applyLiveState(await getTestLiveState(testId.value)); connectLiveStream() }
    catch { liveConnection.value = '已断开'; scheduleLiveReconnect() }
  }, 2000)
}
 
function connectLiveStream() {
  closeLiveStream?.()
  closeLiveStream = openTestLiveStream(testId.value, (event, data) => {
    if (event === 'snapshot' && data && typeof data === 'object') applyLiveState(data as TestLiveState)
    else if (event === 'device-status' && Array.isArray(data) && liveState.value) liveState.value = { ...liveState.value, sources: data as TestLiveState['sources'] }
    else if (event === 'reception-status' && data && typeof data === 'object' && 'receptionStatus' in data) {
      const next = { ...(liveState.value || {}), ...(data as object) } as TestLiveState
      if (liveState.value) applyLiveState({ ...liveState.value, receptionStatus: (data as { receptionStatus: TestLiveState['receptionStatus'] }).receptionStatus })
      void next
    }
    else if (event === 'test-status' && data && typeof data === 'object' && 'testStatus' in data && liveState.value)
      applyLiveState({ ...liveState.value, testStatus: (data as { testStatus: TestLiveState['testStatus'] }).testStatus })
    else if (event === 'exception' && Array.isArray(data) && liveState.value) {
      const current = (data as TestLiveState['exceptions']).filter(item => !item.status || item.status === 'OPEN')
      liveState.value = { ...liveState.value, exceptions: current }
      liveError.value = current.length ? '检测来源存在异常' : ''
    }
    else if (event === 'integrity' && Array.isArray(data) && liveState.value)
      liveState.value = { ...liveState.value, integrityIssues: data as TestIntegrityIssue[] }
  }, error => {
    liveConnection.value = error.message.includes('权限') ? '权限不足' : '已断开'
    liveError.value = error.message
    scheduleLiveReconnect()
  })
}
 
onMounted(async () => {
  try {
    const detail = await getTestDetail(testId.value)
    backendDetail.value = detail
    runtimeAttach.value = detail.runtimeAttach === true
    if (detail.receptionStatus === 'NOT_STARTED') {
      state.value = '未开始接收'
      receptionState.value = '未开始接收'
    } else if (detail.receptionStatus === 'PAUSED') {
      state.value = '暂停'
      receptionState.value = '暂停接收'
    } else if (detail.receptionStatus === 'ENDED') {
      state.value = '已结束'
      receptionState.value = '已结束接收'
    } else {
      state.value = '检测中'
      receptionState.value = '接收中'
    }
    try { applyLiveState(await getTestLiveState(testId.value)) } catch (error) {
      liveConnection.value = '已断开'
      liveError.value = error instanceof Error ? error.message : '实时状态加载失败'
    }
    connectLiveStream()
  } catch (error) {
    ElMessage.error(error instanceof Error ? error.message : '测试状态加载失败')
  }
})
onBeforeUnmount(() => { closeLiveStream?.(); if (reconnectTimer !== null) window.clearTimeout(reconnectTimer) })
 
const elapsedText = computed(() => {
  const hours = Math.floor(elapsedSeconds.value / 3600)
  const minutes = Math.floor((elapsedSeconds.value % 3600) / 60)
  const seconds = elapsedSeconds.value % 60
  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 binding = battery?.binding
  const batteryNo = battery?.batteryNo || profile.value.batteryNos[index]
  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')
  const collectorSource = batterySources.find(item => item.sourceType === 'COLLECTOR')
  const metric = (codes: string[], preferred = cabinetSource) => {
    const item = (preferred?.latestSnapshot?.measurements || []).find(value => codes.includes(String(value.metricCode)))
    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')}`,
    voltage: metric(['VOLTAGE', 'voltage']) || '-',
    temperature: metric(['TEMPERATURE', 'temperature', 'PRIMARY_TEMPERATURE'], collectorSource) || '-',
    positive: metric(['POSITIVE_POTENTIAL', 'PRIMARY_POSITIVE_POTENTIAL'], collectorSource) || '-',
    negative: metric(['NEGATIVE_POTENTIAL', 'PRIMARY_NEGATIVE_POTENTIAL'], collectorSource) || '-',
    capacity: metric(['CAPACITY', 'capacity']) || '-',
    current: metric(['CURRENT', 'current']) || '-',
    source: source?.latestSnapshot?.source || (source?.sourceType === 'COLLECTOR' ? '采集器' : '检测柜'),
    unit: source?.latestSnapshot?.units?.join('/') || '-',
    observedAt: source?.latestSnapshot?.observedAt,
    collectorObservedAt: collectorSource?.latestSnapshot?.observedAt,
    collectorUnit: collectorSource?.latestSnapshot?.units?.join('/') || '-',
    collectorStatus: ({ ONLINE: '在线', OFFLINE: '离线', UNKNOWN: '未知', WORKING: '运行', PAUSED: '暂停', STOPPED: '结束', FINISHED: '结束' } as Record<string, string>)[collectorSource?.deviceStatus || 'UNKNOWN'] || '异常',
    deviceStatus: ({ ONLINE: '在线', OFFLINE: '离线', UNKNOWN: '未知', WORKING: '运行', PAUSED: '暂停', STOPPED: '结束', FINISHED: '结束' } as Record<string, string>)[source?.deviceStatus || 'UNKNOWN'] || '异常',
  }
}))
 
// 实时告警只展示后端当前仍开放的异常,关闭区间保留在历史记录中追溯。
const currentExceptions = computed(() => (liveState.value?.exceptions || [])
  .filter(item => item.sourceType !== 'COLLECTOR' || !item.status || item.status === 'OPEN'))
 
const steps = [
  { no: 1, name: '恒流充电', time: '-', voltage: '0.9000 V', state: '已完成' },
  { no: 2, name: '搁置', time: '00:05:00', voltage: '-', state: '已完成' },
  { no: 3, name: '恒流充电', time: '00:00:00', voltage: '2.4000 V', state: '进行中' },
  { no: 4, name: '搁置', time: '00:05:00', voltage: '-', state: '待执行' },
  { no: 5, name: '恒流放电', time: '-', voltage: '0.9000 V', state: '待执行' },
  { no: 6, name: '循环', time: '起始工步 2', voltage: '循环次数 32', state: '待执行' },
  { no: 7, name: '结束', time: '-', voltage: '-', state: '待执行' },
]
 
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 channels = computed(() => cells.value.map((cell, index) => {
  const sources = liveState.value?.sources?.filter(item => item.batteryNo === cell.no) || []
  const cabinet = sources.find(item => item.sourceType === 'CABINET_CHANNEL')
  const collector = sources.find(item => item.sourceType === 'COLLECTOR')
  const label = (status?: string) => ({ ONLINE: '在线', OFFLINE: '离线', UNKNOWN: '未知', WORKING: '运行', PAUSED: '暂停', STOPPED: '结束', FINISHED: '结束' } as Record<string, string>)[status || 'UNKNOWN'] || '异常'
  return { no: cell.no, cabinet: cell.cabinet, cabinetState: label(cabinet?.deviceStatus), channel: cell.channel,
    collector: backendDetail.value?.batteries[index]?.collectorName || '-', collectorState: label(collector?.deviceStatus),
    temperature: cell.temperature, positive: cell.positive, negative: cell.negative }
}))
 
async function togglePause() {
  if (state.value === '已结束' || state.value === '未开始接收') return
  const pausing = state.value !== '暂停'
  try {
    await ElMessageBox.confirm(
      pausing
        ? '暂停接收将停止系统向检测柜和采集器请求新数据,但设备仍继续运行,不会向设备发送暂停命令,是否继续?'
        : '继续接收将恢复系统数据请求,不会追认暂停期间数据,也不会向设备发送继续命令,是否继续?',
      pausing ? '确认暂停接收' : '确认继续接收',
      { confirmButtonText: pausing ? '暂停接收' : '继续接收', cancelButtonText: '取消', type: 'warning' },
    )
    backendDetail.value = pausing
      ? await pauseTestReception(testId.value)
      : await resumeTestReception(testId.value)
    state.value = pausing ? '暂停' : '检测中'
    receptionState.value = pausing ? '暂停接收' : '接收中'
    ElMessage.success(pausing ? '已暂停接收,系统已停止请求新数据' : '已继续接收,系统已恢复数据请求')
  } catch (error) {
    if (error === 'cancel' || error === 'close') return
    ElMessage.error(error instanceof Error ? error.message : (pausing ? '暂停接收失败' : '继续接收失败'))
  }
}
 
async function startReception() {
  if (state.value !== '未开始接收') return
  try {
    backendDetail.value = await startTestReception(testId.value)
    runtimeAttach.value = backendDetail.value.runtimeAttach === true
    state.value = '检测中'
    receptionState.value = '接收中'
    ElMessage.success('已开始接收,未向设备发送控制命令')
  } catch (error) {
    ElMessage.error(error instanceof Error ? error.message : '开始接收失败')
  }
}
 
async function endTest() {
  if (state.value === '已结束') return
  try {
    await ElMessageBox.confirm('停止测试后将不可恢复,系统会停止请求本测试的数据,但不会向设备发送停止命令,是否继续?', '确认停止测试', { confirmButtonText: '停止测试', cancelButtonText: '取消', type: 'warning', confirmButtonClass: 'danger-confirm' })
    backendDetail.value = await endTestReception(testId.value)
    state.value = '已结束'
    receptionState.value = '已结束接收'
    testStatusLabel.value = ({ WAITING: '待测试', RUNNING: '检测中', PAUSED: '暂停', RUNNING_ABNORMAL: '检测中(异常)', COMPLETION_PENDING: '完成确认中', COMPLETED: '检测完成', TERMINATED: '已终止' } as Record<string, string>)[backendDetail.value.testStatus || 'COMPLETION_PENDING'] || '完成确认中'
    ElMessage.success('测试已停止,系统不再请求数据')
  } catch (error) {
    if (error === 'cancel' || error === 'close') return
    ElMessage.error(error instanceof Error ? error.message : '停止测试失败')
  }
}
</script>
 
<template>
  <div class="console-page">
    <div class="page-tools"><button class="back-button" type="button" @click="router.push('/test-list')"><el-icon><ArrowLeft /></el-icon>返回测试列表</button><span>TEST CONSOLE · #{{ testId }}</span></div>
 
    <nav class="console-tabs" aria-label="控制台页面导航"><button v-for="tab in consoleTabs" :key="tab.key" type="button" :class="{ active: activeTab === tab.key }" @click="activeTab = tab.key">{{ tab.label }}</button></nav>
 
    <div v-show="activeTab === 'realtime'" class="tab-content">
    <section class="realtime-panel">
      <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>
      </header>
      <div class="cell-grid">
        <article v-for="cell in cells" :key="cell.no" class="cell-card">
          <div class="cell-summary"><strong>{{ cell.no }}</strong><span>{{ profile.batteryGroup }}</span><p>{{ cell.cabinet }} · {{ cell.channel }}</p><small>检测柜来源 · 单位:{{ cell.unit }} · 采样时间:{{ cell.observedAt || '-' }}</small><small>采集器来源 · 状态:{{ cell.collectorStatus }} · 单位:{{ cell.collectorUnit }} · 采样时间:{{ cell.collectorObservedAt || '-' }}</small><small v-if="receptionState === '暂停接收'">暂停期间停止请求,当前快照不纳入结果</small><small>工步进行时间:{{ elapsedText }}</small></div>
          <dl class="metric-list"><div><dt>电压(V)</dt><dd class="voltage">{{ cell.voltage }}</dd></div><div><dt>温度(℃)</dt><dd class="temperature">{{ cell.temperature }}</dd></div><div><dt>正电位</dt><dd class="positive">{{ cell.positive }}</dd></div><div><dt>负电位</dt><dd class="negative">{{ cell.negative }}</dd></div><div><dt>容量(Ah)</dt><dd class="capacity">{{ cell.capacity }}</dd></div><div><dt>电流(A)</dt><dd class="current">{{ cell.current }}</dd></div></dl>
        </article>
      </div>
    </section>
    <p v-if="liveError" class="live-error" role="status">{{ liveError }},正在尝试恢复实时状态</p>
 
    <div class="console-layout">
      <main>
        <section class="panel step-panel"><header class="panel-heading"><div><i></i><strong>项目参数</strong><span>STEP PARAMETERS</span></div><button class="primary-button compact-button" type="button" @click="stepDialogVisible=true"><el-icon><Setting /></el-icon>工步设置</button></header><div class="project-meta"><span>测试项目 <strong>{{ profile.purpose }}</strong></span><span>当前循环 <strong>循环 36 / 计划 60</strong></span></div><div class="step-table-wrap"><table><thead><tr><th>工步号</th><th>工步名称</th><th>时间(h:min:s.ms)</th><th>电压(V)</th><th>状态</th></tr></thead><tbody><tr v-for="step in steps" :key="step.no" :class="{ active: step.state === '进行中' }"><td>{{ step.no }}</td><td><strong>{{ step.name }}</strong></td><td>{{ step.time }}</td><td>{{ step.voltage }}</td><td><span class="step-state" :class="step.state">{{ step.state }}</span></td></tr></tbody></table></div></section>
        <section class="panel alarm-panel"><header class="panel-heading"><div><i></i><strong>异常警告</strong><span>ALARM</span></div></header><div v-if="currentExceptions.length" class="alarm-list"><div v-for="(item, index) in currentExceptions" :key="`${item.sourceId || item.sourceType}-${item.channelCode || index}-${index}`"><el-icon><WarningFilled /></el-icon><span><strong>{{ item.sourceType === 'COLLECTOR' ? `采集器${item.collectorName || item.collectorCode || `#${item.sourceId}`}${item.status === 'OPEN' ? '离线' : '断线已恢复'} · ${item.channelCode || '来源'}` : (item.message || '数据来源当前不可观测') }}</strong><small>{{ item.measurementName ? `${item.measurementName}受影响 · ` : '' }}{{ item.status === 'OPEN' ? '缺失区间开放' : (item.message || '当前异常') }} · {{ item.missingFrom || item.openedAt || '-' }}<template v-if="item.missingTo"> 至 {{ item.missingTo }}</template></small></span></div></div><div v-else class="alarm-empty"><el-icon><CircleCheckFilled /></el-icon>暂无异常</div></section>
        <section class="panel integrity-panel"><header class="panel-heading"><div><i></i><strong>完整性检查</strong><span>INTERVAL INTEGRITY</span></div><span :class="(liveState?.integrityIssues || []).some(item => item.issueStatus === 'OPEN') ? 'integrity-open' : 'integrity-ok'">{{ (liveState?.integrityIssues || []).some(item => item.issueStatus === 'OPEN') ? '完成确认中' : '已通过' }}</span></header><div v-if="(liveState?.integrityIssues || []).length" class="integrity-list"><div v-for="item in (liveState?.integrityIssues || [])" :key="item.issueId" :class="item.issueStatus === 'OPEN' ? 'open' : 'resolved'"><strong>{{ item.sourceType === 'COLLECTOR_CHANNEL' ? '采集器' : '检测柜' }}<template v-if="item.intervalId"> · 区间 #{{ item.intervalId }}</template></strong><span>{{ item.issueType === 'SEQUENCE_GAP' ? `序号缺口 ${item.missingFromSequence} - ${item.missingToSequence}` : item.issueType }}</span><small>{{ item.issueStatus === 'OPEN' ? '待补采或处理' : '已恢复' }} · 重试 {{ item.retryCount }} 次</small></div></div><div v-else class="integrity-empty">所有接收区间序号连续,时间间隔仅作辅助指标</div></section>
        <section class="panel channel-panel"><header class="panel-heading"><div><i></i><strong>通道</strong><span>CHANNEL STATUS</span></div></header><div class="channel-table-wrap"><table><thead><tr><th>电池号</th><th>测试柜</th><th>柜状态</th><th>检测通道</th><th>采集器</th><th>采集器状态</th><th>温度</th><th>正电位</th><th>负电位</th></tr></thead><tbody><tr v-for="item in channels" :key="item.no"><td><strong>{{ item.no }}</strong></td><td>{{ item.cabinet }}</td><td><span class="device-state" :class="item.cabinetState">{{ item.cabinetState }}</span></td><td>{{ item.channel }}</td><td>{{ item.collector }}</td><td><span class="device-state" :class="{ 在线: item.collectorState === '在线', 异常: item.collectorState !== '在线' }">{{ item.collectorState }}</span></td><td>{{ item.temperature }}</td><td>{{ item.positive }}</td><td>{{ item.negative }}</td></tr></tbody></table></div></section>
      </main>
      <aside class="parameter-panel"><header class="panel-heading"><div><i></i><strong>测试参数</strong><span>TEST SETTINGS</span></div></header><dl><div v-for="item in parameters" :key="item[0]"><dt>{{ item[0] }}</dt><dd>{{ item[1] }}</dd></div></dl></aside>
    </div>
    </div>
 
    <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'" :test-id="testId" :test-name="profile.name" :cells="cells" :bindings="backendDetail?.batteries.map(item => ({ batteryNo: item.batteryNo, devid: item.binding?.devid, chlid: item.binding?.chlid }))" />
 
    <el-dialog v-model="stepDialogVisible" title="工步设置" width="620px" align-center><div class="step-dialog-content"><p>当前执行工步</p><strong>工步 3 · 恒流充电</strong><dl><div><dt>目标电压</dt><dd>2.4000 V</dd></div><div><dt>循环进度</dt><dd>36 / 60</dd></div><div><dt>执行时间</dt><dd>{{ elapsedText }}</dd></div></dl></div><template #footer><button class="secondary-button" type="button" @click="stepDialogVisible=false">取消</button><button class="primary-button" type="button" @click="stepDialogVisible=false; ElMessage.success('工步设置已保存')"><el-icon><Check /></el-icon>确认</button></template></el-dialog>
  </div>
</template>
 
<style scoped>
.console-page{width:100%;padding-bottom:24px}.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)}.primary-button,.secondary-button,.danger-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)}.secondary-button{color:#52606d;background:#fff;border-color:var(--line-strong)}.danger-button{color:#fff;background:#ee6666;border-color:#ee6666}.primary-button:disabled,.danger-button:disabled{opacity:.5;cursor:not-allowed}.realtime-panel,.panel,.parameter-panel{border:1px solid #d8e1e9;background:#fff}.realtime-panel{margin-bottom:16px}.realtime-header{min-height:96px;display:grid;grid-template-columns:minmax(190px,1fr) minmax(360px,2fr) auto;align-items:center;gap:24px;padding:16px 20px;border-bottom:1px solid var(--line)}.realtime-header>div:first-child{display:flex;align-items:center;gap:12px}.realtime-header strong{font-size:15px}.cell-count{color:#71808d;font-size:11px}.running-state{display:inline-flex;align-items:center;gap:6px;padding:4px 7px;border:1px solid;font-size:11px}.running-state i{width:5px;height:5px;border-radius:50%;background:currentColor}.running-state.检测中{color:#23865e;background:#edf9f3;border-color:#b8dfcd}.running-state.暂停{color:#b06a18;background:#fff8e9;border-color:#efd59e}.running-state.已结束{color:#7b8794;background:#f2f4f6;border-color:#d8dde2}.running-state.未开始接收{color:#7b8794;background:#f2f4f6;border-color:#d8dde2}.realtime-header dl{display:flex;gap:44px}.realtime-header dl>div{display:flex;align-items:center;gap:16px}.realtime-header dt{color:#52606d;font-size:12px}.realtime-header dd{color:#7b8794;font-family:'Cascadia Mono',Consolas,monospace;font-size:12px;font-weight:600}.test-actions{display:flex;gap:10px}.cell-grid{display:grid;grid-template-columns:repeat(4,minmax(190px,1fr));gap:24px;padding:22px 36px}.cell-card{min-width:0;border:1px solid #bec9d3;border-radius:6px;background:#f5f7fa;overflow:hidden}.cell-summary{display:flex;flex-direction:column;gap:7px;padding:16px 18px 12px;border-bottom:1px solid #c8d1d9}.cell-summary strong{color:#1268dc;font-size:13px}.cell-summary span{color:#37688e;font-size:12px;font-weight:600}.cell-summary p,.cell-summary small{color:#71808d;font-size:10px}.metric-list{display:grid;gap:12px;padding:14px 18px 16px}.metric-list>div{display:flex;align-items:center;justify-content:space-between}.metric-list dt{color:#52606d;font-size:10px}.metric-list dd{font-size:17px;font-weight:700}.metric-list .voltage{color:#7f35c5}.metric-list .temperature{color:#bf6100}.metric-list .positive{color:#e99f9f}.metric-list .negative{color:#176ee8}.metric-list .capacity{color:#f59a2a}.metric-list .current{color:#21628c}.console-layout{display:grid;grid-template-columns:minmax(0,2.35fr) minmax(270px,1fr);gap:16px;align-items:start}.console-layout main{display:grid;gap:16px}.panel-heading{height:48px;display:flex;align-items:center;justify-content:space-between;padding:0 16px;border-bottom:1px solid var(--line);background:#fbfdff}.panel-heading>div{display:flex;align-items:center;gap:8px}.panel-heading>div>i{width:3px;height:16px;background:#2b78a3}.panel-heading strong{color:#26333f;font-size:14px}.panel-heading span{color:#81909d;font-size:10px}.compact-button{height:30px;font-size:12px}.project-meta{display:flex;gap:32px;padding:14px 16px 0;color:#71808d;font-size:11px}.project-meta strong{margin-left:8px;color:#31404d}.step-table-wrap,.channel-table-wrap{overflow-x:auto;padding:14px 16px 18px}table{width:100%;border-collapse:collapse;color:#52606d;font-size:12px}th,td{height:38px;padding:0 10px;border:1px solid #d5e0e9;text-align:center;white-space:nowrap}th{color:#52606d;background:#eaf4ff;font-weight:600}.step-table tr.active td{background:#eef7ff}.step-table tr.active td:first-child{border-left:3px solid #2188f5}.step-state{font-size:11px}.step-state.进行中{color:#1677dd}.step-state.已完成{color:#23865e}.step-state.待执行{color:#8795a3}.parameter-panel dl{display:grid;gap:0;padding:12px 18px 18px}.parameter-panel dl>div{display:grid;grid-template-columns:88px minmax(0,1fr);gap:10px;padding:9px 0;border-bottom:1px solid #edf1f5}.parameter-panel dl>div:last-child{border-bottom:0}.parameter-panel dt{color:#71808d;font-size:11px}.parameter-panel dd{min-width:0;color:#52606d;font-size:11px;overflow-wrap:anywhere}.alarm-empty{height:68px;display:flex;align-items:center;gap:8px;margin:14px 16px;padding:0 16px;color:#71808d;background:#f4f7fa;font-size:12px}.alarm-empty .el-icon{color:#23865e;font-size:16px}.alarm-list{display:grid;gap:8px;padding:14px 16px}.alarm-list>div{display:flex;align-items:center;gap:10px;padding:10px 12px;border:1px solid #f0c8a3;background:#fff7ef;color:#b35a25}.alarm-list span,.alarm-list strong,.alarm-list small{display:block}.alarm-list strong{font-size:12px}.alarm-list small{margin-top:3px;color:#8c6849;font-size:10px}.channel-table-wrap table{min-width:1120px}.device-state{display:inline-flex;padding:3px 7px;border:1px solid;font-size:10px}.device-state.在线{color:#23865e;background:#edf9f3;border-color:#b8dfcd}.device-state.异常{color:#b34f4f;background:#fff1f1;border-color:#e9bcbc}.step-dialog-content{display:grid;gap:12px}.step-dialog-content>p{color:#71808d;font-size:12px}.step-dialog-content>strong{color:#26333f}.step-dialog-content dl{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}.step-dialog-content dl>div{padding:12px;border:1px solid #d8e1e9;background:#f8fafc}.step-dialog-content dt{color:#8795a3;font-size:10px}.step-dialog-content dd{margin-top:5px;color:#405261;font-size:12px}@media(max-width:1280px){.cell-grid{gap:16px;padding:20px}.console-layout{grid-template-columns:minmax(0,1fr) 280px}}@media(max-width:1050px){.cell-grid{grid-template-columns:repeat(2,minmax(210px,1fr))}.realtime-header{grid-template-columns:1fr auto}.realtime-header dl{grid-column:1/-1;grid-row:2}.console-layout{grid-template-columns:1fr}.parameter-panel{grid-row:2}}@media(max-width:680px){.realtime-header{grid-template-columns:1fr}.realtime-header dl{grid-column:auto;grid-row:auto;align-items:flex-start;flex-direction:column;gap:10px}.test-actions{justify-content:stretch}.test-actions button{flex:1}.cell-grid{grid-template-columns:1fr;padding:14px}.project-meta{flex-direction:column;gap:8px}.step-dialog-content dl{grid-template-columns:1fr}}
 
.reception-state{padding:4px 7px;border:1px solid #b9decf;color:#23865e;background:#edf9f3;font-size:11px}.test-state{padding:4px 7px;border:1px solid #d8dde2;color:#52606d;background:#f5f7fa;font-size:11px}.attach-state{padding:4px 7px;border:1px solid #bdd8f4;color:#1677dd;background:#edf6ff;font-size:11px}
.live-connection{display:inline-flex;align-items:center;gap:5px;padding:4px 7px;border:1px solid #cbd5df;color:#52606d;background:#f5f7fa;font-size:11px}.live-connection i{width:5px;height:5px;border-radius:50%;background:currentColor}.live-connection.已连接{color:#23865e;background:#edf9f3;border-color:#b8dfcd}.live-connection.权限不足{color:#b34f4f;background:#fff1f1;border-color:#e9bcbc}.live-error{margin:8px 20px 14px;padding:9px 12px;border:1px solid #e9bcbc;color:#a94444;background:#fff5f5;font-size:12px}
/* 双栏收缩边界:宽表只能在所属面板内横向滚动,不能撑开主网格。 */
.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}
.integrity-panel .panel-heading>span{font-size:11px}.integrity-open{color:#b34f4f}.integrity-ok{color:#23865e}.integrity-list{display:grid;gap:8px;padding:14px 16px}.integrity-list>div{display:grid;grid-template-columns:1.2fr 1fr auto;align-items:center;gap:10px;padding:9px 12px;border:1px solid #e5c3a4;background:#fff8f1;font-size:11px}.integrity-list>div.resolved{border-color:#b8dfcd;background:#f2fbf6}.integrity-list span{color:#52606d}.integrity-list small{color:#8795a3}.integrity-empty{padding:16px;color:#71808d;font-size:11px}
</style>