| | |
| | | |
| | | ## 测试与监测界面 |
| | | |
| | | - 正式测试状态只展示:`待测试`、`检测中`、`暂停`、`检测中(异常)`、`检测完成`、`已终止`。“完成确认中”是后端内部阶段,前端可用进度文案表达但不新增正式测试状态。 |
| | | - 正式测试状态只展示:`待测试`、`检测中`、`暂停`、`检测完成`。`完成确认中`、`检测中(异常)`和`已终止`不属于新流程状态,历史值仅作兼容展示。 |
| | | - 设备状态和接收状态单独并列展示。接收状态使用:`未开始接收`、`接收中`、`暂停接收`、`已结束接收`;不要把设备、接收和测试三套状态合并到一个字段或筛选项。 |
| | | - 开始、暂停、继续和停止测试的页面动作只改变系统数据请求,不表示控制 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 布局与组件习惯,确保桌面和常用窄屏下按钮、状态文本和表格操作不重叠。 |
| | | |
| | |
| | | export interface TestBatteryPayload { |
| | | relationId?: number |
| | | groupId: number |
| | | groupName?: string | null |
| | | batteryNo: string |
| | | collectorId: number |
| | | collectorName?: string | null |
| | |
| | | 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 |
| | |
| | | sequenceId: number |
| | | recordedAt: string |
| | | absoluteTime: string |
| | | deviceTime?: string |
| | | serverReceivedAt?: string |
| | | voltage: number | string | null |
| | | current: number | string | null |
| | | capacity: number | string | null |
| | |
| | | sequenceId: number |
| | | recordedAt: string |
| | | absoluteTime: string |
| | | serverReceivedAt: string |
| | | stepDuration: string |
| | | startVoltage: string |
| | | endVoltage: string |
| | |
| | | { label: '放电能量(Wh)', value: 'dischargeEnergy' }, |
| | | ], |
| | | ) |
| | | function formatTime(value: string) { |
| | | function formatTime(value?: string | null) { |
| | | return value |
| | | ? value |
| | | .replace('T', ' ') |
| | |
| | | : '-' |
| | | } |
| | | 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) |
| | |
| | | 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()]) |
| | |
| | | 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') |
| | |
| | | <th>实际工步号</th> |
| | | <th>实际工步名称</th> |
| | | <th>记录序号</th> |
| | | <th>记录时间</th> |
| | | <th>绝对时间</th> |
| | | <th>设备时间</th> |
| | | <th>服务器接收时间</th> |
| | | <th>电压(V)</th> |
| | | <th>电流(A)</th> |
| | | <th>容量(Ah)</th> |
| | |
| | | <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> |
| | |
| | | <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)" |
| | |
| | | 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<'连接中' | '已连接' | '已断开' | '权限不足' | '空数据'>('连接中') |
| | |
| | | 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[]>([]) |
| | |
| | | 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 = '暂停接收' |
| | |
| | | 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 = '已断开' |
| | |
| | | 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') |
| | |
| | | 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) || '-', |
| | |
| | | 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) || [] |
| | |
| | | })) |
| | | |
| | | async function togglePause() { |
| | | if (state.value === '已结束' || state.value === '未开始接收') return |
| | | if (state.value === '检测完成' || state.value === '待测试') return |
| | | const pausing = state.value !== '暂停' |
| | | try { |
| | | await ElMessageBox.confirm( |
| | |
| | | } |
| | | |
| | | async function startReception() { |
| | | if (state.value !== '未开始接收') return |
| | | if (state.value !== '待测试') return |
| | | try { |
| | | backendDetail.value = await startTestReception(testId.value) |
| | | runtimeAttach.value = backendDetail.value.runtimeAttach === true |
| | |
| | | } |
| | | |
| | | 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 |
| | |
| | | <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"> |
| | |
| | | <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> |
| | |
| | | .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> |