| | |
| | | import { useRoute, useRouter } from 'vue-router' |
| | | import * as XLSX from 'xlsx' |
| | | import { Download, EditPen, Refresh, Search, UploadFilled, Clock, Check } from '@element-plus/icons-vue' |
| | | import { getPersistedTestCycles, getPersistedTestRecords, getPersistedTestSteps, type PersistedTestCycle, type PersistedTestRecord, type PersistedTestStep, type TestLogicStepMapping, type TestPlanStepPayload } from '@/api/tests' |
| | | import { getPersistedTestCycles, getPersistedTestRecords, getPersistedTestSteps, type PersistedTestCycle, type PersistedTestRecord, type PersistedTestStep } from '@/api/tests' |
| | | import { getConsoleDataOverrides, submitConsoleDataAction, type ConsoleDataType } from '@/api/consoleData' |
| | | |
| | | type DataTab = 'realtime' | 'step' | 'cycle' |
| | |
| | | } |
| | | interface DataRow { |
| | | id: string |
| | | sourceIds?: string[] |
| | | cyclePosition?: number |
| | | recordNo: string |
| | | batteryNo: string |
| | | cycleNo: number |
| | |
| | | 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; receptionStatus?: string; bindings?: Array<{ batteryNo: string; bindingId?: number | null; channelId?: number | null; devid?: number | null; chlid?: number | null }>; receptionIntervals?: ReceptionInterval[]; logicStepMappings?: TestLogicStepMapping[]; planSteps?: Array<Pick<TestPlanStepPayload, 'cycleNo' | 'stepNo' | 'sortOrder' | 'stepType'>> }>() |
| | | const props = defineProps<{ testId: number; testName: string; cells: ConsoleCell[]; canImport?: boolean; receptionStatus?: string; bindings?: Array<{ batteryNo: string; bindingId?: number | null; channelId?: number | null; devid?: number | null; chlid?: number | null }>}>() |
| | | const route = useRoute() |
| | | const router = useRouter() |
| | | const requestedDataType = String(route.query.dataType || 'REALTIME') |
| | |
| | | loading.value = false |
| | | } |
| | | } |
| | | function mappingForPosition(position: number) { |
| | | return (props.logicStepMappings || []).find(item => position >= item.startStepNo && position <= item.endStepNo) |
| | | } |
| | | const plannedStepPositions = computed(() => { |
| | | const byCycleAndStep = new Map<string, number>() |
| | | const byStep = new Map<number, number>() |
| | | const counters = new Map<number, number>() |
| | | const ordered = [...(props.planSteps || [])].filter(item => item.stepType !== 'CYCLE' && item.stepType !== 'END').sort((a, b) => a.sortOrder - b.sortOrder) |
| | | ordered.forEach(item => { |
| | | const position = (counters.get(item.cycleNo) || 0) + 1 |
| | | counters.set(item.cycleNo, position) |
| | | byCycleAndStep.set(`${item.cycleNo}:${item.stepNo}`, position) |
| | | if (!byStep.has(item.stepNo)) byStep.set(item.stepNo, position) |
| | | }) |
| | | return { byCycleAndStep, byStep } |
| | | }) |
| | | const observedStepPositions = computed(() => { |
| | | const byCycleAndStep = new Map<string, number>() |
| | | const stepsByBatteryAndCycle = new Map<string, number[]>() |
| | | stepRows.value.forEach(row => { |
| | | const cycleKey = `${row.batteryNo}:${row.cycleNo}` |
| | | const steps = stepsByBatteryAndCycle.get(cycleKey) || [] |
| | | if (!steps.includes(row.stepNo)) steps.push(row.stepNo) |
| | | stepsByBatteryAndCycle.set(cycleKey, steps) |
| | | }) |
| | | stepsByBatteryAndCycle.forEach((steps, cycleKey) => { |
| | | steps.sort((a, b) => a - b).forEach((stepNo, index) => byCycleAndStep.set(`${cycleKey}:${stepNo}`, index + 1)) |
| | | }) |
| | | return byCycleAndStep |
| | | }) |
| | | function cyclePositionFor(row: DataRow) { |
| | | if (row.cyclePosition) return row.cyclePosition |
| | | const planned = plannedStepPositions.value.byCycleAndStep.get(`${row.cycleNo}:${row.stepNo}`) |
| | | if (planned !== undefined) return planned |
| | | const observed = observedStepPositions.value.get(`${row.batteryNo}:${row.cycleNo}:${row.stepNo}`) |
| | | if (observed !== undefined) return observed |
| | | return plannedStepPositions.value.byStep.get(row.stepNo) || row.stepNo |
| | | } |
| | | const mappedStepRows = computed(() => { |
| | | const groups = new Map<string, DataRow[]>() |
| | | // 位置来自设备 step 汇总事实,不能根据接收窗口内实际返回的行重新压缩,否则缺失工步会使合并范围漂移。 |
| | | stepRows.value.forEach(row => { |
| | | const position = cyclePositionFor(row) |
| | | const mapping = mappingForPosition(position) |
| | | const logicalName = mapping?.logicalName || row.stepName |
| | | const key = `${row.batteryNo}:${row.cycleNo}:${mapping ? `mapping:${mapping.startStepNo}-${mapping.endStepNo}` : `position:${position}`}` |
| | | const group = groups.get(key) || [] |
| | | group.push({ ...row, stepName: logicalName }) |
| | | groups.set(key, 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)) |
| | | const sumValue = (field: keyof DataRow) => numberValues(field).reduce((sum, value) => sum + value, 0) |
| | | const peaks = numberValues('chargePeakVoltage') |
| | | const valleys = numberValues('dischargeValleyVoltage') |
| | | return { ...last, id: `logical-step:${first.batteryNo}:${first.cycleNo}:${first.stepNo}`, sourceIds: group.map(item => item.id), stepName: first.stepName, actualStepLabel: group.length === 1 ? first.actualStepLabel : `${group.map(item => item.actualStepLabel.split(' · ')[0]).join('-')}`, stepDuration: String(group.reduce((sum, item) => sum + Number(item.stepDuration || 0), 0)), capacity: sumValue('capacity').toFixed(4), energy: sumValue('energy').toFixed(4), 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.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)) |
| | | // 数据列表只展示后端持久化的 record、step、cycle 事实;逻辑工步合并留给统计图表等派生视图。 |
| | | const sourceRows = computed(() => (activeTab.value === 'step' ? stepRows.value : activeTab.value === 'cycle' ? cycleRows.value : rows.value)) |
| | | const singleRow = computed(() => Object.values(draftRows.value)[0] || null) |
| | | const batteryOptions = computed(() => [...new Set(sourceRows.value.map(row => row.batteryNo).filter(Boolean))]) |
| | | const cycleOptions = computed(() => [...new Set(sourceRows.value.map(row => row.cycleNo))].sort((a, b) => a - b)) |
| | |
| | | } |
| | | try { |
| | | const editItems = changed.flatMap(row => { |
| | | const ids = dataType.value === 'STEP' && row.sourceIds?.length ? row.sourceIds : [row.id] |
| | | const ids = [row.id] |
| | | return ids.map(id => { |
| | | const before = dataType.value === 'STEP' ? stepRows.value.find(item => item.id === id) || row : row |
| | | const before = row |
| | | const after = { ...before, ...Object.fromEntries(fieldOptions.value.map(item => [item.value, row[item.value as keyof DataRow]])) } |
| | | return { identity: id, before, after, override: { stableIdentity: id, fieldValues: JSON.stringify(Object.fromEntries(fieldOptions.value.map(item => [item.value, row[item.value as keyof DataRow]]))), deleted: false } } |
| | | }) |
| | |
| | | ElMessage.warning('请先勾选需要删除的数据') |
| | | return |
| | | } |
| | | const displayRows = sourceRows.value.filter(row => ids.includes(row.id)) |
| | | const physicalIds = dataType.value === 'STEP' ? displayRows.flatMap(row => (row.sourceIds?.length ? row.sourceIds : [row.id])) : ids |
| | | const physicalIds = ids |
| | | let result: { value: string } |
| | | try { |
| | | result = await ElMessageBox.prompt(`确认物理删除 ${ids.length} 条${activeTab.value}数据?删除后不可恢复。`, '删除确认', { inputPlaceholder: '请输入删除原因', inputValidator: value => (value.trim() ? true : '删除原因不能为空') }) |