修复未开始测试显示历史数据
数据列表按测试接收状态阻止未开始测试读取设备历史,并保持循环内工步映射与实时方案展示。
| | |
| | | export interface BtsTestPlanRow { |
| | | rowType: 'STEP' | 'CYCLE' | 'END' | string |
| | | stepNo: number |
| | | cyclePosition: number | null |
| | | stepName: string |
| | | durationSeconds: number |
| | | voltageV: number | null |
| | |
| | | export interface BtsStepRecord { |
| | | cycleNo: number |
| | | stepNo: number |
| | | cyclePosition: number |
| | | stepName: string |
| | | stepType: string |
| | | recordCount: number |
| | |
| | | <script setup lang="ts"> |
| | | import { computed, onMounted, reactive, ref } from 'vue' |
| | | import { computed, onMounted, reactive, ref, watch } from 'vue' |
| | | import { ElMessage, ElMessageBox } from 'element-plus' |
| | | import { useRoute, useRouter } from 'vue-router' |
| | | import * as XLSX from 'xlsx' |
| | |
| | | type DataTab = 'realtime' | 'step' | 'cycle' |
| | | interface ConsoleCell { no: string } |
| | | interface FilterState { batteryNo: string; dateRange: [string, string] | null; cycleNo: string; stepNo: string } |
| | | interface DataRow { id: string; sourceIds?: string[]; recordNo: string; batteryNo: string; cycleNo: number; stepNo: number; stepName: string; actualStepLabel: string; sequenceId: number; recordedAt: string; absoluteTime: string; stepDuration: string; startVoltage: string; endVoltage: string; chargePeakVoltage: string; dischargeValleyVoltage: string; voltage: string; current: string; capacity: string; energy: string; chargeCapacity: string; dischargeCapacity: string; efficiency: string; chargeEnergy: string; dischargeEnergy: string } |
| | | interface DataRow { id: string; sourceIds?: string[]; cyclePosition?: number; recordNo: string; batteryNo: string; cycleNo: number; stepNo: number; stepName: string; actualStepLabel: string; sequenceId: number; recordedAt: string; absoluteTime: string; stepDuration: string; startVoltage: string; endVoltage: string; chargePeakVoltage: string; dischargeValleyVoltage: string; voltage: string; current: string; capacity: string; energy: string; chargeCapacity: string; dischargeCapacity: string; efficiency: string; chargeEnergy: string; dischargeEnergy: string } |
| | | type ReceptionInterval = { bindingId: number | null; sourceType: string; sourceId: number; startBoundaryAtUtc: string; endBoundaryAtUtc?: string | null } |
| | | const props = defineProps<{ testId: number; testName: string; cells: ConsoleCell[]; canImport?: boolean; bindings?: Array<{ batteryNo: string; bindingId?: number | null; devid?: number | null; chlid?: number | null }>; receptionIntervals?: ReceptionInterval[]; logicStepMappings?: TestLogicStepMapping[] }>() |
| | | const props = defineProps<{ testId: number; testName: string; cells: ConsoleCell[]; canImport?: boolean; receptionStatus?: string; bindings?: Array<{ batteryNo: string; bindingId?: number | null; devid?: number | null; chlid?: number | null }>; receptionIntervals?: ReceptionInterval[]; logicStepMappings?: TestLogicStepMapping[] }>() |
| | | const route = useRoute(); const router = useRouter() |
| | | const requestedDataType = String(route.query.dataType || 'REALTIME') |
| | | const activeTab = ref<DataTab>(requestedDataType === 'STEP' ? 'step' : requestedDataType === 'CYCLE' ? 'cycle' : 'realtime'); const loading = ref(false); const loadError = ref(''); const rows = ref<DataRow[]>([]); const stepRows = ref<DataRow[]>([]); const cycleRows = ref<DataRow[]>([]) |
| | |
| | | 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 } } |
| | | function inReceptionWindow(bindingId: number | null | undefined, sourceId: number, time: string) { const intervals = props.receptionIntervals || []; if (!intervals.length) return true; const instant = Date.parse(time); if (!Number.isFinite(instant)) return false; return intervals.some(item => item.bindingId === bindingId && item.sourceType === 'CABINET_CHANNEL' && item.sourceId === sourceId && instant >= Date.parse(item.startBoundaryAtUtc) && (!item.endBoundaryAtUtc || instant < Date.parse(item.endBoundaryAtUtc))) } |
| | | function mapRecord(record: BtsRecord, batteryNo: string, deviceId: number, channelId: number): DataRow { const timestamp = formatTime(record.deviceTime); return emptyRow({ id: `${deviceId}:${channelId}:${record.sequenceId}`, recordNo: `RD-${deviceId}-${channelId}-${String(record.sequenceId).padStart(6, '0')}`, batteryNo: batteryNo || '-', cycleNo: record.cycleNo, stepNo: record.stepNo, stepName: record.stepName, sequenceId: record.sequenceId, recordedAt: timestamp, absoluteTime: timestamp, voltage: Number(record.voltageV).toFixed(4), current: Number(record.currentA).toFixed(4), capacity: Number(record.capacityAh).toFixed(4), energy: Number(record.energyWh).toFixed(4) }) } |
| | | function mapStep(record: BtsStepRecord, batteryNo: string, deviceId: number, channelId: number): DataRow { const startedAt = formatTime(record.startedAt); return emptyRow({ id: `step:${deviceId}:${channelId}:${record.cycleNo}:${record.stepNo}`, recordNo: `STEP-${deviceId}-${channelId}-${record.cycleNo}-${record.stepNo}`, batteryNo, cycleNo: record.cycleNo, stepNo: record.stepNo, stepName: record.stepName, actualStepLabel: `${record.stepNo} · ${record.stepName}`, sequenceId: record.recordCount, recordedAt: startedAt, absoluteTime: startedAt, stepDuration: String(record.durationSeconds), capacity: Number(record.capacityAh).toFixed(4), energy: Number(record.energyWh).toFixed(4), startVoltage: Number(record.startVoltageV).toFixed(4), endVoltage: Number(record.endVoltageV).toFixed(4), chargePeakVoltage: record.chargePeakVoltageV == null ? '-' : Number(record.chargePeakVoltageV).toFixed(4), dischargeValleyVoltage: record.dischargeValleyVoltageV == null ? '-' : Number(record.dischargeValleyVoltageV).toFixed(4) }) } |
| | | function mapStep(record: BtsStepRecord, batteryNo: string, deviceId: number, channelId: number): DataRow { const startedAt = formatTime(record.startedAt); return emptyRow({ id: `step:${deviceId}:${channelId}:${record.cycleNo}:${record.stepNo}`, recordNo: `STEP-${deviceId}-${channelId}-${record.cycleNo}-${record.stepNo}`, batteryNo, cycleNo: record.cycleNo, cyclePosition: record.cyclePosition, stepNo: record.stepNo, stepName: record.stepName, actualStepLabel: `${record.stepNo} · ${record.stepName}`, sequenceId: record.recordCount, recordedAt: startedAt, absoluteTime: startedAt, stepDuration: String(record.durationSeconds), capacity: Number(record.capacityAh).toFixed(4), energy: Number(record.energyWh).toFixed(4), startVoltage: Number(record.startVoltageV).toFixed(4), endVoltage: Number(record.endVoltageV).toFixed(4), chargePeakVoltage: record.chargePeakVoltageV == null ? '-' : Number(record.chargePeakVoltageV).toFixed(4), dischargeValleyVoltage: record.dischargeValleyVoltageV == null ? '-' : Number(record.dischargeValleyVoltageV).toFixed(4) }) } |
| | | function mapCycle(record: BtsCycleRecord, batteryNo: string, deviceId: number, channelId: number): DataRow { const startedAt = formatTime(record.startedAt); return emptyRow({ id: `cycle:${deviceId}:${channelId}:${record.cycleNo}`, recordNo: record.recordNo, batteryNo, cycleNo: record.cycleNo, sequenceId: record.recordCount, recordedAt: startedAt, absoluteTime: startedAt, chargeCapacity: Number(record.chargeCapacityAh).toFixed(4), dischargeCapacity: Number(record.dischargeCapacityAh).toFixed(4), efficiency: Number(record.efficiency).toFixed(2), chargeEnergy: Number(record.chargeEnergyWh).toFixed(4), dischargeEnergy: Number(record.dischargeEnergyWh).toFixed(4) }) } |
| | | function applyOverrides(list: DataRow[], overrides: Awaited<ReturnType<typeof getConsoleDataOverrides>>) { const byIdentity = new Map(overrides.map(item => [item.stableIdentity, item])); const known = new Set(list.map(row => row.id)); const result = list.flatMap(row => { const override = byIdentity.get(row.id); if (!override) return [row]; if (override.deleted) return []; try { return [{ ...row, ...(override.fieldValues ? JSON.parse(override.fieldValues) : {}) }] } catch { return [row] } }); overrides.filter(item => !known.has(item.stableIdentity) && !item.deleted && item.fieldValues).forEach(item => { try { result.push(emptyRow({ ...(JSON.parse(item.fieldValues || '{}')), id: item.stableIdentity, recordNo: item.stableIdentity })) } catch { /* 忽略无法解析的历史覆盖 */ } }); return result } |
| | | async function loadRecords() { loading.value = true; loadError.value = ''; try { const bindings = (props.bindings || []).filter(item => Number.isFinite(item.devid) && Number.isFinite(item.chlid)); const batches = await Promise.all(bindings.map(async binding => { const deviceId = Number(binding.devid); const channelId = Number(binding.chlid); const [records, steps, cycles] = await Promise.all([getBtsRecords(deviceId, channelId), getBtsSteps(deviceId, channelId), getBtsCycles(deviceId, channelId)]); return { records: records.filter(item => inReceptionWindow(binding.bindingId, channelId, item.deviceTime)).map(item => mapRecord(item, binding.batteryNo, deviceId, channelId)), steps: steps.filter(item => inReceptionWindow(binding.bindingId, channelId, item.startedAt)).map(item => mapStep(item, binding.batteryNo, deviceId, channelId)), cycles: cycles.filter(item => inReceptionWindow(binding.bindingId, channelId, item.startedAt)).map(item => mapCycle(item, binding.batteryNo, deviceId, channelId)) } })); const overrides = await Promise.all((['REALTIME', 'STEP', 'CYCLE'] as ConsoleDataType[]).map(type => getConsoleDataOverrides(props.testId, type))); rows.value = applyOverrides(batches.flatMap(item => item.records), overrides[0]).sort((a, b) => b.absoluteTime.localeCompare(a.absoluteTime) || b.sequenceId - a.sequenceId); stepRows.value = applyOverrides(batches.flatMap(item => item.steps), overrides[1]); cycleRows.value = applyOverrides(batches.flatMap(item => item.cycles), overrides[2]) } catch (error) { rows.value = []; stepRows.value = []; cycleRows.value = []; loadError.value = error instanceof Error ? error.message : '检测柜记录加载失败' } finally { loading.value = false } } |
| | | function mappingFor(stepNo: number) { return (props.logicStepMappings || []).find(item => stepNo >= item.startStepNo && stepNo <= item.endStepNo) } |
| | | async function loadRecords() { loading.value = true; loadError.value = ''; try { if (props.receptionStatus === 'NOT_STARTED') { rows.value = []; stepRows.value = []; cycleRows.value = []; return } const bindings = (props.bindings || []).filter(item => Number.isFinite(item.devid) && Number.isFinite(item.chlid)); const batches = await Promise.all(bindings.map(async binding => { const deviceId = Number(binding.devid); const channelId = Number(binding.chlid); const [records, steps, cycles] = await Promise.all([getBtsRecords(deviceId, channelId), getBtsSteps(deviceId, channelId), getBtsCycles(deviceId, channelId)]); return { records: records.filter(item => inReceptionWindow(binding.bindingId, channelId, item.deviceTime)).map(item => mapRecord(item, binding.batteryNo, deviceId, channelId)), steps: steps.filter(item => inReceptionWindow(binding.bindingId, channelId, item.startedAt)).map(item => mapStep(item, binding.batteryNo, deviceId, channelId)), cycles: cycles.filter(item => inReceptionWindow(binding.bindingId, channelId, item.startedAt)).map(item => mapCycle(item, binding.batteryNo, deviceId, channelId)) } })); const overrides = await Promise.all((['REALTIME', 'STEP', 'CYCLE'] as ConsoleDataType[]).map(type => getConsoleDataOverrides(props.testId, type))); rows.value = applyOverrides(batches.flatMap(item => item.records), overrides[0]).sort((a, b) => b.absoluteTime.localeCompare(a.absoluteTime) || b.sequenceId - a.sequenceId); stepRows.value = applyOverrides(batches.flatMap(item => item.steps), overrides[1]); cycleRows.value = applyOverrides(batches.flatMap(item => item.cycles), overrides[2]) } catch (error) { rows.value = []; stepRows.value = []; cycleRows.value = []; loadError.value = error instanceof Error ? error.message : '检测柜记录加载失败' } finally { loading.value = false } } |
| | | function mappingForPosition(position: number) { return (props.logicStepMappings || []).find(item => position >= item.startStepNo && position <= item.endStepNo) } |
| | | const mappedStepRows = computed(() => { |
| | | const groups = new Map<string, DataRow[]>() |
| | | // 位置来自设备 step 汇总事实,不能根据接收窗口内实际返回的行重新压缩,否则缺失工步会使合并范围漂移。 |
| | | stepRows.value.forEach(row => { |
| | | const mapping = mappingFor(row.stepNo) |
| | | const logicalNo = mapping?.logicalNo || row.stepNo |
| | | const position = row.cyclePosition || row.stepNo |
| | | const mapping = mappingForPosition(position) |
| | | const logicalName = mapping?.logicalName || row.stepName |
| | | const key = `${row.batteryNo}:${row.cycleNo}:${logicalNo}` |
| | | const key = `${row.batteryNo}:${row.cycleNo}:${mapping ? `mapping:${mapping.startStepNo}-${mapping.endStepNo}` : `position:${position}`}` |
| | | const group = groups.get(key) || [] |
| | | group.push({ ...row, stepName: logicalName, id: `${row.id}|${logicalNo}`, recordNo: `STEP-${row.batteryNo}-${row.cycleNo}-${logicalNo}`, stepNo: logicalNo }) |
| | | group.push({ ...row, stepName: logicalName }) |
| | | groups.set(key, group) |
| | | }) |
| | | return [...groups.values()].map(group => { |
| | | const aggregated = [...groups.values()].map(group => { |
| | | group.sort((a, b) => a.sequenceId - b.sequenceId) |
| | | const first = group[0]; const last = group[group.length - 1] |
| | | const numberValues = (field: keyof DataRow) => group.map(item => Number(item[field])).filter(value => Number.isFinite(value)) |
| | |
| | | startVoltage: first.startVoltage, endVoltage: last.endVoltage, |
| | | chargePeakVoltage: peaks.length ? Math.max(...peaks).toFixed(4) : '-', |
| | | dischargeValleyVoltage: valleys.length ? Math.min(...valleys).toFixed(4) : '-', } |
| | | }).sort((a, b) => a.batteryNo.localeCompare(b.batteryNo) || a.cycleNo - b.cycleNo || a.stepNo - b.stepNo) |
| | | }).sort((a, b) => a.batteryNo.localeCompare(b.batteryNo) || a.cycleNo - b.cycleNo || (a.cyclePosition || a.stepNo) - (b.cyclePosition || b.stepNo)) |
| | | const logicalNos = new Map<string, number>() |
| | | return aggregated.map(row => { const key = `${row.batteryNo}:${row.cycleNo}`; const logicalNo = (logicalNos.get(key) || 0) + 1; logicalNos.set(key, logicalNo); return { ...row, stepNo: logicalNo, recordNo: `STEP-${row.batteryNo}-${row.cycleNo}-${logicalNo}` } }) |
| | | }) |
| | | const sourceRows = computed(() => activeTab.value === 'step' ? mappedStepRows.value : activeTab.value === 'cycle' ? cycleRows.value : rows.value) |
| | | const singleRow = computed(() => Object.values(draftRows.value)[0] || null) |
| | |
| | | function downloadCsv() { if (!filteredRows.value.length) { ElMessage.warning('没有可下载的数据'); return } const headers = activeTab.value === 'cycle' ? ['记录编号', '电池', '循环号', '充电容量(Ah)', '放电容量(Ah)', '充放电效率(%)', '充电能量(Wh)', '放电能量(Wh)'] : ['记录编号', '电池', '循环号', '实际工步号', '实际工步名称', '记录序号', '记录时间', '绝对时间', '电压(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]) : 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 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'); anchor.href = url; anchor.download = `${props.testName}-${activeTab.value}.csv`; anchor.click(); URL.revokeObjectURL(url) } |
| | | // 数据列表进入时加载一次,后续由工具栏“刷新”按钮显式触发,避免实时状态重渲染引起重复请求。 |
| | | onMounted(loadRecords) |
| | | watch(() => props.receptionStatus, (next, previous) => { if (next !== previous) void loadRecords() }) |
| | | </script> |
| | | <template> |
| | | <section class="console-data-panel"> |
| | |
| | | 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 stepDialogVisible = ref(false) |
| | | const stepSettings = ref<TestLogicStepMapping[]>([]) |
| | | const stepDraft = ref<TestLogicStepMapping[]>([]) |
| | | const selectedPlanSteps = ref<number[]>([]) |
| | | const stepEditing = ref(false) |
| | | const activeTab = ref<ConsoleTab>(route.query.tab === 'data-list' ? 'data-list' : 'realtime') |
| | | const consoleTabs: Array<{ key: ConsoleTab; label: string }> = [ |
| | | { key: 'realtime', label: '实时数据' }, |
| | |
| | | const currentExceptions = computed(() => (liveState.value?.exceptions || []) |
| | | .filter(item => item.sourceType !== 'COLLECTOR' || !item.status || item.status === 'OPEN')) |
| | | |
| | | function openStepSettings() { stepDraft.value = stepSettings.value.map(item => ({ ...item })) ; stepDialogVisible.value = true } |
| | | function addStepMapping() { const next = stepDraft.value.length + 1; const start = stepDraft.value.length ? Math.max(...stepDraft.value.map(item => item.endStepNo)) + 1 : 1; stepDraft.value.push({ logicalNo: next, logicalName: `逻辑工步 ${next}`, startStepNo: start, endStepNo: start }) } |
| | | function removeStepMapping(index: number) { stepDraft.value.splice(index, 1); stepDraft.value.forEach((item, i) => { item.logicalNo = i + 1 }) } |
| | | function openStepSettings() { stepDraft.value = stepSettings.value.map(item => ({ ...item })); selectedPlanSteps.value = []; stepEditing.value = true } |
| | | function togglePlanStep(position: number, checked: string | number | boolean) { const next = new Set(selectedPlanSteps.value); checked ? next.add(position) : next.delete(position); selectedPlanSteps.value = [...next].sort((a, b) => a - b) } |
| | | async function addStepMapping() { const positions = [...selectedPlanSteps.value].sort((a, b) => a - b); if (positions.length < 2) { ElMessage.warning('请先勾选至少两个连续工步'); return }; const contiguous = positions.every((value, index) => index === 0 || value === positions[index - 1] + 1); if (!contiguous) { ElMessage.warning('只能合并连续工步'); return }; if (stepDraft.value.some(item => positions[0] <= item.endStepNo && positions[positions.length - 1] >= item.startStepNo)) { ElMessage.warning('选中的工步已在其他合并组中'); return }; try { const result = await ElMessageBox.prompt('请输入逻辑工步名称', '合并工步', { inputPlaceholder: '例如:充电阶段', inputValidator: value => value.trim() ? true : '逻辑工步名称不能为空' }); const next = stepDraft.value.length + 1; stepDraft.value.push({ logicalNo: next, logicalName: result.value.trim(), startStepNo: positions[0], endStepNo: positions[positions.length - 1] }); selectedPlanSteps.value = [] } catch { /* 用户取消命名 */ } } |
| | | function normalizeStepDraft() { stepDraft.value.sort((a, b) => a.startStepNo - b.startStepNo); stepDraft.value.forEach((item, i) => { item.logicalNo = i + 1 }) } |
| | | function removeStepMapping(index: number) { stepDraft.value.splice(index, 1); normalizeStepDraft() } |
| | | async function saveSteps() { |
| | | try { const detail = await saveLogicStepMappings(testId.value, stepDraft.value); stepSettings.value = detail.logicStepMappings || []; backendDetail.value = detail; stepDialogVisible.value = false; ElMessage.success('工步合并规则已保存') } |
| | | try { normalizeStepDraft(); const detail = await saveLogicStepMappings(testId.value, stepDraft.value); stepSettings.value = detail.logicStepMappings || []; backendDetail.value = detail; stepEditing.value = false; selectedPlanSteps.value = []; ElMessage.success('工步合并规则已保存') } |
| | | catch (error) { ElMessage.error(error instanceof Error ? error.message : '工步设置保存失败') } |
| | | } |
| | | function cancelStepSettings() { stepEditing.value = false; selectedPlanSteps.value = [] } |
| | | |
| | | interface PlanDisplayStep { no: number; name: string; time: string; voltage: string; state: '进行中' | '已完成' | '待执行' } |
| | | function formatDuration(seconds: number) { |
| | |
| | | const currentStepNo = computed(() => liveState.value?.testPlan?.currentStepNo |
| | | || snapshotNumber('stepNo') || snapshotNumber('step') || 0) |
| | | const plannedCycleCount = computed(() => liveState.value?.testPlan?.plannedCycleCount || 0) |
| | | const planRows = computed<PlanDisplayStep[]>(() => { |
| | | const detectorRows = liveState.value?.testPlan?.rows |
| | | if (detectorRows?.length) return detectorRows.map(row => ({ |
| | | no: row.stepNo, |
| | | name: row.stepName, |
| | | time: row.rowType === 'CYCLE' ? `起始工步 ${row.stepNo || '-'}` : row.rowType === 'END' ? '-' : formatDuration(row.durationSeconds), |
| | | voltage: row.rowType === 'CYCLE' ? `当前循环 ${row.cycleCount || '-'}` : row.voltageV == null ? '-' : `${Number(row.voltageV).toFixed(4)} V`, |
| | | state: (row.executionStatus || '待执行') as PlanDisplayStep['state'], |
| | | })) |
| | | return [] |
| | | }) |
| | | const detectorPlanRows = computed(() => liveState.value?.testPlan?.rows || []) |
| | | const actualPlanRows = computed(() => detectorPlanRows.value.filter(row => row.rowType === 'STEP').map((row, index) => ({ ...row, cyclePosition: row.cyclePosition || index + 1 }))) |
| | | const planRows = computed<PlanDisplayStep[]>(() => detectorPlanRows.value.map(row => row.rowType === 'STEP' |
| | | ? { no: row.stepNo, name: row.stepName, time: formatDuration(row.durationSeconds), voltage: row.voltageV == null ? '-' : `${Number(row.voltageV).toFixed(4)} V`, state: row.executionStatus as PlanDisplayStep['state'] } |
| | | : { no: row.stepNo, name: row.stepName, time: row.rowType === 'CYCLE' ? `起始工步 ${row.stepNo || '-'}` : '-', voltage: row.rowType === 'CYCLE' ? `当前循环 ${row.cycleCount || '-'}` : '-', state: (row.executionStatus || '待执行') as PlanDisplayStep['state'] })) |
| | | |
| | | const parameters = [ |
| | | ['测试名称', profile.value.name], ['创建者', '陈师傅'], ['负责人', profile.value.owner], ['测试项目', profile.value.purpose], |
| | |
| | | |
| | | <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="openStepSettings"><el-icon><Setting /></el-icon>工步设置</button></header><div class="project-meta"><span>测试项目 <strong>{{ profile.purpose }}</strong></span><span>当前循环 <strong>{{ currentCycleNo || '-' }} / 计划 {{ plannedCycleCount || '-' }}</strong></span><span>当前工步 <strong>{{ currentStepNo || '-' }}</strong></span><span>执行状态 <strong>{{ testStatusLabel }}</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, index) in planRows" :key="`${index}-${step.no}-${step.name}`" :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><tr v-if="!planRows.length"><td colspan="5" class="empty-cell">暂无后端检测柜工步方案</td></tr></tbody></table></div></section> |
| | | <section class="panel step-panel"><header class="panel-heading"><div><i></i><strong>项目参数</strong><span>STEP PARAMETERS</span></div><div class="step-actions"><template v-if="stepEditing"><button class="primary-button compact-button" type="button" @click="addStepMapping"><el-icon><Plus /></el-icon>合并选中</button><button class="primary-button compact-button" type="button" @click="saveSteps"><el-icon><Check /></el-icon>保存</button><button class="secondary-button compact-button" type="button" @click="cancelStepSettings">取消</button></template><button v-else class="primary-button compact-button" type="button" @click="openStepSettings"><el-icon><Setting /></el-icon>工步设置</button></div></header><div class="project-meta"><span>测试项目 <strong>{{ profile.purpose }}</strong></span><span>当前循环 <strong>{{ currentCycleNo || '-' }} / 计划 {{ plannedCycleCount || '-' }}</strong></span><span>当前工步 <strong>{{ currentStepNo || '-' }}</strong></span><span>执行状态 <strong>{{ testStatusLabel }}</strong></span></div><div class="step-table-wrap"><table><thead><tr><th v-if="stepEditing">选择</th><th>工步号</th><th>工步名称</th><th>时间(h:min:s.ms)</th><th>电压(V)</th><th>执行状态</th></tr></thead><tbody><template v-if="stepEditing"><tr v-for="step in actualPlanRows" :key="`edit-${step.cyclePosition}`" :class="{ active: step.executionStatus === '进行中' }"><td><el-checkbox :model-value="selectedPlanSteps.includes(step.cyclePosition)" @change="(checked: string | number | boolean) => togglePlanStep(step.cyclePosition, checked)" /></td><td>{{ step.stepNo }}</td><td><strong>{{ step.stepName }}</strong></td><td>{{ formatDuration(step.durationSeconds) }}</td><td>{{ step.voltageV == null ? '-' : `${Number(step.voltageV).toFixed(4)} V` }}</td><td><span class="step-state" :class="step.executionStatus">{{ step.executionStatus }}</span></td></tr></template><template v-else><tr v-for="(step, index) in planRows" :key="`${index}-${step.no}-${step.name}`" :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></template><tr v-if="!planRows.length && !actualPlanRows.length"><td :colspan="stepEditing ? 6 : 5" class="empty-cell">暂无后端检测柜工步方案</td></tr></tbody></table></div><div v-if="stepEditing && stepDraft.length" class="mapping-list"><div v-for="(item, index) in stepDraft" :key="index" class="mapping-row"><strong>逻辑工步 {{ item.logicalNo }}</strong><span>第 {{ item.startStepNo }} 至 {{ item.endStepNo }} 个工步</span><button class="secondary-button compact-button" type="button" aria-label="解除合并" @click="removeStepMapping(index)"><el-icon><Delete /></el-icon>解除合并</button></div></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 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> |
| | |
| | | <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 === '已结束'" :bindings="backendDetail.batteries.map(item => ({ batteryNo: item.batteryNo, bindingId: item.binding?.bindingId, devid: item.binding?.devid, chlid: item.binding?.chlid }))" :reception-intervals="backendDetail.receptionIntervals" :logic-step-mappings="backendDetail.logicStepMappings" /> |
| | | <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, devid: item.binding?.devid, chlid: item.binding?.chlid }))" :reception-intervals="backendDetail.receptionIntervals" :logic-step-mappings="backendDetail.logicStepMappings" /> |
| | | |
| | | <el-dialog v-model="stepDialogVisible" title="工步设置" width="760px" align-center><div class="step-dialog-content"><p>将连续实际工步合并为逻辑工步;循环和结束统计行不参与,原始检测柜数据保持不变。</p><div class="mapping-list"><div v-for="(item, index) in stepDraft" :key="index" class="mapping-row"><strong>逻辑工步 {{ item.logicalNo }}</strong><el-input v-model="item.logicalName" placeholder="逻辑工步名称" /><el-input-number v-model="item.startStepNo" :min="1" controls-position="right" /><span>至</span><el-input-number v-model="item.endStepNo" :min="1" controls-position="right" /><button class="secondary-button" type="button" aria-label="取消合并" @click="removeStepMapping(index)"><el-icon><Delete /></el-icon></button></div></div><button class="secondary-button" type="button" @click="addStepMapping"><el-icon><Plus /></el-icon>新增合并组</button></div><template #footer><button class="secondary-button" type="button" @click="stepDialogVisible=false">取消</button><button class="primary-button" type="button" @click="saveSteps"><el-icon><Check /></el-icon>保存规则</button></template></el-dialog> |
| | | </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}.mapping-row{display:grid;grid-template-columns:100px minmax(180px,1fr) 110px 20px 110px 40px;align-items:center;gap:8px}.mapping-row strong{color:#52606d;font-size:12px}.mapping-row :deep(.el-input-number){width:100%} |
| | | .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} |
| | | </style> |
| | |
| | | |
| | | const view = await readFile(new URL('../src/views/TestConsole.vue', import.meta.url), 'utf8') |
| | | const dataList = await readFile(new URL('../src/components/ConsoleDataListPanel.vue', import.meta.url), 'utf8') |
| | | const liveService = await readFile(new URL('../../BatteryDetection/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TTestLiveServiceImpl.java', import.meta.url), 'utf8') |
| | | |
| | | assert.match(view, /<strong>项目参数<\/strong><span>STEP PARAMETERS<\/span>/) |
| | | assert.match(view, /testPlan\?\.rows/) |
| | |
| | | assert.doesNotMatch(view, /<strong>工步数据<\/strong><span>STEP SUMMARY<\/span>/) |
| | | assert.doesNotMatch(view, /visibleStepRows/) |
| | | assert.match(view, /:logic-step-mappings="backendDetail\.logicStepMappings"/) |
| | | assert.match(dataList, /mappingFor\(stepNo: number\)/) |
| | | assert.match(view, /selectedPlanSteps/) |
| | | assert.match(view, /合并选中/) |
| | | assert.match(view, /解除合并/) |
| | | assert.match(view, /startStepNo: positions\[0\], endStepNo: positions\[positions\.length - 1\]/) |
| | | assert.match(dataList, /mappingForPosition\(position: number\)/) |
| | | assert.match(dataList, /逻辑工步/) |
| | | assert.match(dataList, /起始电压\(V\)/) |
| | | assert.match(dataList, /放电谷值\(V\)/) |
| | | assert.match(dataList, /sourceIds: group\.map\(item => item\.id\)/) |
| | | assert.match(dataList, /receptionIntervals/) |
| | | assert.match(dataList, /physicalIds/) |
| | | assert.match(dataList, /props\.receptionStatus === 'NOT_STARTED'/) |
| | | assert.match(liveService, /simulator\.btsTest\(battery\.getBinding\(\)\.getDevid\(\), battery\.getBinding\(\)\.getChlid\(\), 1\)/) |
| | | assert.match(await readFile(new URL('../../BatteryDetection/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsDevelopmentSimulator.java', import.meta.url), 'utf8'), /Integer cyclePosition/) |
| | | |
| | | console.log('Ticket23 实时工步方案与数据列表工步映射回归契约通过') |