统一测试三类数据字段并修正记录编号与时长
列表、导入预览、模板和下载按甲方表格统一列名及顺序,记录编号对齐修订身份。区分相对时长与绝对时间,工步循环单页导入使用实时辅助页可靠匹配时间,并验证编辑、单位及原表全量导入。
| | |
| | | stepType?: string |
| | | sequenceId: number |
| | | cabinetSequenceId?: number | null |
| | | relativeTimeSeconds?: number | string | null |
| | | recordedAt?: number | null |
| | | absoluteTime?: number | null |
| | | deviceTime?: number | null |
| | |
| | | import { consoleImportColumns, submitConsoleDataAction, type ConsoleDataType } from '@/api/consoleData' |
| | | import { formatBusinessTime as formatTime } from '@/utils/businessTime' |
| | | import { formatTestDisplayValue, toTestApiValue } from '@/utils/testDataUnits' |
| | | import { createConsoleImportWorkbook } from '@/utils/consoleDataImport' |
| | | import { parseTestDuration } from '@/utils/testDataDuration' |
| | | |
| | | type DataTab = 'realtime' | 'step' | 'cycle' |
| | | interface ConsoleCell { |
| | |
| | | actualStepLabel: string |
| | | sequenceId: number |
| | | recordedAt: string |
| | | relativeTimeSeconds: string |
| | | absoluteTime: string |
| | | serverReceivedAt: string |
| | | stepDuration: string |
| | |
| | | ] |
| | | : activeTab.value === 'step' |
| | | ? [ |
| | | { label: '工步时间', value: 'stepDuration' }, |
| | | { label: '工步时间(h:min:s.ms)', value: 'stepDuration' }, |
| | | { label: '容量(mAh)', value: 'capacity' }, |
| | | { label: '能量(mWh)', value: 'energy' }, |
| | | { label: '起始电压(V)', value: 'startVoltage' }, |
| | |
| | | ], |
| | | ) |
| | | function emptyRow(base: Partial<DataRow>): DataRow { |
| | | return { sourceIdentity: '-', collectorDeviceTime: '-', id: '', sourceType: 'CABINET', metricCode: '-', metricValue: '-', unit: '-', collectorCode: '-', channelCode: '-', recordNo: '', batteryNo: '', cycleNo: 0, stepNo: 0, stepName: '', actualStepLabel: '-', sequenceId: 0, recordedAt: '', absoluteTime: '', serverReceivedAt: '', stepDuration: '-', startVoltage: '-', endVoltage: '-', chargeMedianVoltage: '-', dischargeMedianVoltage: '-', voltage: '-', current: '-', capacity: '-', energy: '-', temperature: '-', positivePotential: '-', negativePotential: '-', extensionMeasurements: '-', chargeCapacity: '-', dischargeCapacity: '-', efficiency: '-', chargeEnergy: '-', dischargeEnergy: '-', ...base } |
| | | return { sourceIdentity: '-', collectorDeviceTime: '-', id: '', sourceType: 'CABINET', metricCode: '-', metricValue: '-', unit: '-', collectorCode: '-', channelCode: '-', recordNo: '', batteryNo: '', cycleNo: 0, stepNo: 0, stepName: '', actualStepLabel: '-', sequenceId: 0, relativeTimeSeconds: '-', recordedAt: '', absoluteTime: '', serverReceivedAt: '', stepDuration: '-', startVoltage: '-', endVoltage: '-', chargeMedianVoltage: '-', dischargeMedianVoltage: '-', voltage: '-', current: '-', capacity: '-', energy: '-', temperature: '-', positivePotential: '-', negativePotential: '-', extensionMeasurements: '-', chargeCapacity: '-', dischargeCapacity: '-', efficiency: '-', chargeEnergy: '-', dischargeEnergy: '-', ...base } |
| | | } |
| | | function mapRecord(record: PersistedTestRecord): DataRow { |
| | | const deviceTime = formatTime(record.deviceTime ?? record.recordedAt) |
| | |
| | | extensionMeasurements = Object.entries(parsed as Record<string, { value?: unknown; metricValue?: unknown; unit?: string; channelCode?: string }>).map(([code, value]) => `${code}${value?.channelCode ? ` (${value.channelCode})` : ''}=${value?.value ?? value?.metricValue ?? '-'}${value?.unit ? ` ${value.unit}` : ''}`).join('; ') || '-' |
| | | } catch { extensionMeasurements = String(record.extensionMeasurementsJson) } |
| | | } |
| | | return emptyRow({ sourceIdentity: record.sourceIdentity || '-', collectorDeviceTime: formatTime(record.collectorDeviceTime), id: identity, sourceType: record.sourceType || 'CABINET', metricCode: record.metricCode || '-', metricValue: extensionValue, unit: record.unit || '-', collectorCode: record.collectorCode || String(record.collectorId ?? '-'), channelCode: record.channelCode || '-', recordNo: record.recordNo, batteryNo: record.batteryNo || '-', cycleNo: Number(record.cycleNo || 0), stepNo: Number(record.stepNo || 0), stepName: collector ? '-' : (record.stepName || '-'), sequenceId: Number(record.sequenceId || record.cabinetSequenceId || 0), recordedAt: deviceTime, absoluteTime: deviceTime, serverReceivedAt: formatTime(record.serverReceivedAt), voltage: collector ? '-' : (record.voltage == null ? '-' : Number(record.voltage).toFixed(4)), current: collector ? '-' : formatTestDisplayValue('current', record.current), capacity: collector ? '-' : formatTestDisplayValue('capacity', record.capacity), energy: collector ? '-' : formatTestDisplayValue('energy', record.energy), temperature: snapshot ? (record.temperature == null ? '-' : Number(record.temperature).toFixed(4)) : extension('TEMPERATURE'), positivePotential: snapshot ? (record.positivePotential == null ? '-' : Number(record.positivePotential).toFixed(4)) : extension('POSITIVE_POTENTIAL'), negativePotential: snapshot ? (record.negativePotential == null ? '-' : Number(record.negativePotential).toFixed(4)) : extension('NEGATIVE_POTENTIAL'), extensionMeasurements }) |
| | | return emptyRow({ sourceIdentity: record.sourceIdentity || '-', collectorDeviceTime: formatTime(record.collectorDeviceTime), id: identity, sourceType: record.sourceType || 'CABINET', metricCode: record.metricCode || '-', metricValue: extensionValue, unit: record.unit || '-', collectorCode: record.collectorCode || String(record.collectorId ?? '-'), channelCode: record.channelCode || '-', recordNo: identity, batteryNo: record.batteryNo || '-', cycleNo: Number(record.cycleNo || 0), stepNo: Number(record.stepNo || 0), stepName: collector ? '-' : (record.stepName || '-'), sequenceId: Number(record.sequenceId || record.cabinetSequenceId || 0), recordedAt: deviceTime, relativeTimeSeconds: formatTestDisplayValue('relativeTimeSeconds', record.relativeTimeSeconds), absoluteTime: deviceTime, serverReceivedAt: formatTime(record.serverReceivedAt), voltage: collector ? '-' : (record.voltage == null ? '-' : Number(record.voltage).toFixed(4)), current: collector ? '-' : formatTestDisplayValue('current', record.current), capacity: collector ? '-' : formatTestDisplayValue('capacity', record.capacity), energy: collector ? '-' : formatTestDisplayValue('energy', record.energy), temperature: snapshot ? (record.temperature == null ? '-' : Number(record.temperature).toFixed(4)) : extension('TEMPERATURE'), positivePotential: snapshot ? (record.positivePotential == null ? '-' : Number(record.positivePotential).toFixed(4)) : extension('POSITIVE_POTENTIAL'), negativePotential: snapshot ? (record.negativePotential == null ? '-' : Number(record.negativePotential).toFixed(4)) : extension('NEGATIVE_POTENTIAL'), extensionMeasurements }) |
| | | } |
| | | function mapStep(record: PersistedTestStep): DataRow { |
| | | const startedAt = formatTime(record.recordedAt) |
| | | const identity = record.stableIdentity || `step:${[record.adapterId, record.devtype, record.deviceId, record.subdevid, record.channelId, record.cycleNo, record.stepNo].join(':')}` |
| | | return emptyRow({ id: identity, recordNo: record.recordNo, batteryNo: record.batteryNo, cycleNo: Number(record.cycleNo), stepNo: Number(record.stepNo), stepName: record.stepName || '-', actualStepLabel: record.actualStepLabel || `${record.stepNo} · ${record.stepName || '-'}`, sequenceId: Number(record.sequenceId || 0), recordedAt: startedAt, absoluteTime: formatTime(record.absoluteTime || record.recordedAt), serverReceivedAt: formatTime(record.serverReceivedAt), stepDuration: record.stepDuration == null ? '-' : String(record.stepDuration), capacity: formatTestDisplayValue('capacity', record.capacity), energy: formatTestDisplayValue('energy', record.energy), startVoltage: record.startVoltage == null ? '-' : Number(record.startVoltage).toFixed(4), endVoltage: record.endVoltage == null ? '-' : Number(record.endVoltage).toFixed(4), chargeMedianVoltage: record.chargeMedianVoltage == null ? '-' : Number(record.chargeMedianVoltage).toFixed(4), dischargeMedianVoltage: record.dischargeMedianVoltage == null ? '-' : Number(record.dischargeMedianVoltage).toFixed(4) }) |
| | | return emptyRow({ id: identity, recordNo: identity, batteryNo: record.batteryNo, cycleNo: Number(record.cycleNo), stepNo: Number(record.stepNo), stepName: record.stepName || '-', actualStepLabel: record.actualStepLabel || `${record.stepNo} · ${record.stepName || '-'}`, sequenceId: Number(record.sequenceId || 0), recordedAt: startedAt, absoluteTime: formatTime(record.absoluteTime || record.recordedAt), serverReceivedAt: formatTime(record.serverReceivedAt), stepDuration: formatTestDisplayValue('stepDuration', record.stepDuration), capacity: formatTestDisplayValue('capacity', record.capacity), energy: formatTestDisplayValue('energy', record.energy), startVoltage: record.startVoltage == null ? '-' : Number(record.startVoltage).toFixed(4), endVoltage: record.endVoltage == null ? '-' : Number(record.endVoltage).toFixed(4), chargeMedianVoltage: record.chargeMedianVoltage == null ? '-' : Number(record.chargeMedianVoltage).toFixed(4), dischargeMedianVoltage: record.dischargeMedianVoltage == null ? '-' : Number(record.dischargeMedianVoltage).toFixed(4) }) |
| | | } |
| | | function mapCycle(record: PersistedTestCycle): DataRow { |
| | | const startedAt = formatTime(record.recordedAt) |
| | | const identity = record.stableIdentity || `cycle:${[record.adapterId, record.devtype, record.deviceId, record.subdevid, record.channelId, record.cycleNo].join(':')}` |
| | | return emptyRow({ id: identity, recordNo: record.recordNo, batteryNo: record.batteryNo, cycleNo: Number(record.cycleNo), sequenceId: Number(record.sequenceId || 0), recordedAt: startedAt, absoluteTime: formatTime(record.absoluteTime || record.recordedAt), serverReceivedAt: formatTime(record.serverReceivedAt), chargeCapacity: formatTestDisplayValue('chargeCapacity', record.chargeCapacity), dischargeCapacity: formatTestDisplayValue('dischargeCapacity', record.dischargeCapacity), efficiency: record.efficiency == null ? '-' : Number(record.efficiency).toFixed(2), chargeEnergy: formatTestDisplayValue('chargeEnergy', record.chargeEnergy), dischargeEnergy: formatTestDisplayValue('dischargeEnergy', record.dischargeEnergy) }) |
| | | return emptyRow({ id: identity, recordNo: identity, batteryNo: record.batteryNo, cycleNo: Number(record.cycleNo), sequenceId: Number(record.sequenceId || 0), recordedAt: startedAt, absoluteTime: formatTime(record.absoluteTime || record.recordedAt), serverReceivedAt: formatTime(record.serverReceivedAt), chargeCapacity: formatTestDisplayValue('chargeCapacity', record.chargeCapacity), dischargeCapacity: formatTestDisplayValue('dischargeCapacity', record.dischargeCapacity), efficiency: record.efficiency == null ? '-' : Number(record.efficiency).toFixed(2), chargeEnergy: formatTestDisplayValue('chargeEnergy', record.chargeEnergy), dischargeEnergy: formatTestDisplayValue('dischargeEnergy', record.dischargeEnergy) }) |
| | | } |
| | | async function loadRecords() { |
| | | loading.value = true |
| | |
| | | router.push({ path: '/data-revision', query: { testId: String(props.testId), dataType: dataType.value } }) |
| | | } |
| | | function downloadTemplate() { |
| | | const worksheet = XLSX.utils.aoa_to_sheet([Object.values(consoleImportColumns[dataType.value])]) |
| | | const workbook = XLSX.utils.book_new() |
| | | XLSX.utils.book_append_sheet(workbook, worksheet, `${activeTab.value}模板`) |
| | | const workbook = createConsoleImportWorkbook(dataType.value) |
| | | XLSX.writeFile(workbook, `测试${activeTab.value}数据导入模板.xlsx`) |
| | | ElMessage.success('导入模板已下载') |
| | | } |
| | |
| | | /* 用户取消原因填写 */ |
| | | } |
| | | } |
| | | function isEditableNumericValue(value: unknown) { |
| | | function isEditableNumericValue(value: unknown, field: string) { |
| | | // “-”表示设备未提供该指标;允许保留缺失值,以便只修订本次实际填写的字段。 |
| | | const normalized = String(value ?? '').trim() |
| | | if (field === 'stepDuration') return normalized === '-' || (normalized !== '' && Number.isFinite(parseTestDuration(normalized))) |
| | | return normalized === '-' || (normalized !== '' && Number.isFinite(Number(normalized))) |
| | | } |
| | | async function submitBatchEdit() { |
| | |
| | | singleRow.value && |
| | | editorFieldOptions.value.some(field => { |
| | | const value = singleRow.value?.[field.value as keyof DataRow] |
| | | return !isEditableNumericValue(value) |
| | | return !isEditableNumericValue(value, field.value) |
| | | }) |
| | | ) { |
| | | ElMessage.warning('请填写完整、有效的数据') |
| | |
| | | .filter(item => row[item.value as keyof DataRow] !== before[item.value as keyof DataRow]) |
| | | .map(item => [item.value, row[item.value as keyof DataRow]])) |
| | | // 编辑框使用毫单位;提交字段和摘要都回到 API 单位,正式修订前值仍由后端读取。 |
| | | const apiValues = Object.fromEntries(Object.entries(values).map(([field, value]) => [field, toTestApiValue(field, String(value))])) |
| | | const apiValues = Object.fromEntries(Object.entries(values).map(([field, value]) => [field, String(value).trim() === '-' ? null : toTestApiValue(field, String(value))])) |
| | | const apiBefore = { ...before, ...Object.fromEntries(fields.map(item => [item.value, toTestApiValue(item.value, String(before[item.value as keyof DataRow]))])) } |
| | | return { identity: id, before: apiBefore, after: { ...apiBefore, ...apiValues }, override: { stableIdentity: id, fieldValues: JSON.stringify(apiValues), deleted: false } } |
| | | }) |
| | |
| | | ElMessage.warning('没有可下载的数据') |
| | | return |
| | | } |
| | | const headers = activeTab.value === 'cycle' ? ['记录编号', '电池', '循环序号', '充电容量(mAh)', '放电容量(mAh)', '充放电效率(%)', '充电能量(mWh)', '放电能量(mWh)'] : activeTab.value === 'step' ? ['记录编号', '电池', '循环序号', '工步序号', '工步名称', '工步时间(h:min:s.ms)', '容量(mAh)', '能量(mWh)', '起始电压(V)', '终止电压(V)', '充电中值电压(V)', '放电中值电压(V)'] : ['记录编号', '电池', '循环序号', '工步序号', '工步名称', '记录序号', '记录时间(h:min:s.ms)', '电压(V)', '电流(mA)', '容量(mAh)', '能量(mWh)', '绝对时间', '温度(℃)', '正电位(V)', '负电位(V)'] |
| | | 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.chargeMedianVoltage, row.dischargeMedianVoltage]) : filteredRows.value.map(row => [row.recordNo, row.batteryNo, row.cycleNo, row.stepNo, row.stepName, row.sequenceId, row.recordedAt, row.voltage, row.current, row.capacity, row.energy, row.absoluteTime, row.temperature, row.positivePotential, row.negativePotential]) |
| | | const columns = consoleImportColumns[dataType.value] |
| | | const headers = Object.values(columns) |
| | | const records = filteredRows.value.map(row => Object.keys(columns).map(field => row[field as keyof DataRow])) |
| | | 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') |
| | |
| | | <div class="table-wrap"> |
| | | <table> |
| | | <thead> |
| | | <tr v-if="activeTab === 'realtime'"> |
| | | <tr> |
| | | <th class="selection-column"> |
| | | <el-checkbox :model-value="allPageSelected" :indeterminate="somePageSelected" aria-label="选择本页" @change="(checked: string | number | boolean) => togglePageSelection(Boolean(checked))" /> |
| | | <el-checkbox v-if="activeTab === 'realtime'" :model-value="allPageSelected" :indeterminate="somePageSelected" aria-label="选择本页" @change="(checked: string | number | boolean) => togglePageSelection(Boolean(checked))" /> |
| | | </th> |
| | | <th>记录编号</th> |
| | | <th>电池</th> |
| | | <th>循环序号</th> |
| | | <th>工步序号</th> |
| | | <th>工步名称</th> |
| | | <th>记录序号</th> |
| | | <th>设备时间</th> |
| | | <th>绝对时间</th> |
| | | <th>温度(℃)</th> |
| | | <th>正电位(V)</th> |
| | | <th>负电位(V)</th> |
| | | <th>电压(V)</th> |
| | | <th>电流(mA)</th> |
| | | <th>容量(mAh)</th> |
| | | <th>能量(mWh)</th> |
| | | <th class="action-column">操作</th> |
| | | </tr> |
| | | <tr v-else-if="activeTab === 'step'"> |
| | | <th class="selection-column"></th> |
| | | <th>记录编号</th> |
| | | <th>电池</th> |
| | | <th>循环序号</th> |
| | | <th>工步名称</th> |
| | | <th>工步时间</th> |
| | | <th>容量(mAh)</th> |
| | | <th>能量(mWh)</th> |
| | | <th>起始电压(V)</th> |
| | | <th>终止电压(V)</th> |
| | | <th>充电中值电压(V)</th> |
| | | <th>放电中值电压(V)</th> |
| | | <th class="action-column">操作</th> |
| | | </tr> |
| | | <tr v-else> |
| | | <th class="selection-column"></th> |
| | | <th>记录编号</th> |
| | | <th>电池</th> |
| | | <th>循环序号</th> |
| | | <th>充电容量(mAh)</th> |
| | | <th>放电容量(mAh)</th> |
| | | <th>充放电效率(%)</th> |
| | | <th>充电能量(mWh)</th> |
| | | <th>放电能量(mWh)</th> |
| | | <th v-for="(label, field) in consoleImportColumns[dataType]" :key="field">{{ label }}</th> |
| | | <th class="action-column">操作</th> |
| | | </tr> |
| | | </thead> |
| | |
| | | <td>{{ row.stepNo }}</td> |
| | | <td>{{ row.stepName }}</td> |
| | | <td>{{ row.sequenceId }}</td> |
| | | <td>{{ row.recordedAt }}</td> |
| | | <td>{{ row.absoluteTime }}</td> |
| | | <td><el-input v-if="editing && draftRows[row.id]" v-model="draftRows[row.id].temperature" size="small" /><span v-else>{{ row.temperature }}</span></td> |
| | | <td><el-input v-if="editing && draftRows[row.id]" v-model="draftRows[row.id].positivePotential" size="small" /><span v-else>{{ row.positivePotential }}</span></td> |
| | | <td><el-input v-if="editing && draftRows[row.id]" v-model="draftRows[row.id].negativePotential" size="small" /><span v-else>{{ row.negativePotential }}</span></td> |
| | | <td>{{ row.relativeTimeSeconds }}</td> |
| | | <td> |
| | | <el-input v-if="editing && draftRows[row.id]" v-model="draftRows[row.id].voltage" size="small" /> |
| | | <span v-else>{{ row.voltage }}</span> |
| | |
| | | <el-input v-if="editing && draftRows[row.id]" v-model="draftRows[row.id].energy" size="small" /> |
| | | <span v-else>{{ row.energy }}</span> |
| | | </td> |
| | | <td>{{ row.absoluteTime }}</td> |
| | | <td><el-input v-if="editing && draftRows[row.id]" v-model="draftRows[row.id].temperature" size="small" /><span v-else>{{ row.temperature }}</span></td> |
| | | <td><el-input v-if="editing && draftRows[row.id]" v-model="draftRows[row.id].positivePotential" size="small" /><span v-else>{{ row.positivePotential }}</span></td> |
| | | <td><el-input v-if="editing && draftRows[row.id]" v-model="draftRows[row.id].negativePotential" size="small" /><span v-else>{{ row.negativePotential }}</span></td> |
| | | <td class="action-column"> |
| | | <div class="row-actions"><button type="button" @click="openRowEditor(row)">编辑</button><button class="delete-action" type="button" @click="deleteSelected([row.id])">删除</button></div> |
| | | </td> |
| | |
| | | <strong>{{ row.batteryNo }}</strong> |
| | | </td> |
| | | <td>{{ row.cycleNo }}</td> |
| | | <td>{{ row.stepNo }} · {{ row.stepName }}</td> |
| | | <td>{{ row.stepNo }}</td> |
| | | <td>{{ row.stepName }}</td> |
| | | <td> |
| | | <el-input v-if="editing && draftRows[row.id]" v-model="draftRows[row.id].stepDuration" size="small" /> |
| | | <span v-else>{{ row.stepDuration }}</span> |
| | |
| | | <dd>{{ singleRow.stepName }}</dd> |
| | | </div> |
| | | <div> |
| | | <dt>记录时间</dt> |
| | | <dt>绝对时间</dt> |
| | | <dd>{{ singleRow.recordedAt }}</dd> |
| | | </div> |
| | | <div> |
| | |
| | | min-width: 2100px; |
| | | } |
| | | .console-data-panel.realtime th:nth-child(8), |
| | | .console-data-panel.realtime th:nth-child(9) { |
| | | .console-data-panel.realtime th:nth-child(13) { |
| | | width: 185px; |
| | | } |
| | | .source-column small { |
| | |
| | | import * as XLSX from 'xlsx' |
| | | import type { ConsoleDataType } from '../api/consoleData' |
| | | import { parseTestDuration } from './testDataDuration.ts' |
| | | |
| | | export const consoleImportColumns = { |
| | | REALTIME: { recordNo: '记录编号', batteryNo: '电池号', recordedAt: '记录时间(h:min:s.ms)', cycleNo: '循环序号', stepNo: '工步序号', stepName: '工步名称', sequenceId: '记录序号', voltage: '电压(V)', current: '电流(mA)', capacity: '容量(mAh)', energy: '能量(mWh)', temperature: '温度(℃)', positivePotential: '正电位(V)', negativePotential: '负电位(V)' }, |
| | | STEP: { recordNo: '记录编号', batteryNo: '电池号', recordedAt: '设备时间', cycleNo: '循环序号', stepNo: '工步序号', stepName: '工步', stepDuration: '工步时间', capacity: '容量(mAh)', energy: '能量(mWh)', startVoltage: '起始电压(V)', endVoltage: '终止电压(V)', chargeMedianVoltage: '充电中值电压(V)', dischargeMedianVoltage: '放电中值电压(V)' }, |
| | | CYCLE: { recordNo: '记录编号', batteryNo: '电池号', recordedAt: '设备时间', cycleNo: '循环序号', chargeCapacity: '充电容量(mAh)', dischargeCapacity: '放电容量(mAh)', efficiency: '充放电效率(%)', chargeEnergy: '充电能量(mWh)', dischargeEnergy: '放电能量(mWh)' }, |
| | | REALTIME: { recordNo: '记录编号', batteryNo: '电池', cycleNo: '循环序号', stepNo: '工步序号', stepName: '工步名称', sequenceId: '记录序号', relativeTimeSeconds: '记录时间(h:min:s.ms)', voltage: '电压(V)', current: '电流(mA)', capacity: '容量(mAh)', energy: '能量(mWh)', recordedAt: '绝对时间', temperature: '温度(℃)', positivePotential: '正电位(V)', negativePotential: '负电位(V)' }, |
| | | STEP: { recordNo: '记录编号', batteryNo: '电池', cycleNo: '循环序号', stepNo: '工步序号', stepName: '工步名称', stepDuration: '工步时间(h:min:s.ms)', capacity: '容量(mAh)', energy: '能量(mWh)', startVoltage: '起始电压(V)', endVoltage: '终止电压(V)', chargeMedianVoltage: '充电中值电压(V)', dischargeMedianVoltage: '放电中值电压(V)' }, |
| | | CYCLE: { recordNo: '记录编号', batteryNo: '电池', cycleNo: '循环序号', chargeCapacity: '充电容量(mAh)', dischargeCapacity: '放电容量(mAh)', efficiency: '充放电效率(%)', chargeEnergy: '充电能量(mWh)', dischargeEnergy: '放电能量(mWh)' }, |
| | | } satisfies Record<ConsoleDataType, Record<string, string>> |
| | | |
| | | export interface ValidatedImportRow { |
| | |
| | | } |
| | | |
| | | /** 创建包含三个标准列头的空工作簿,不生成测量数据。 */ |
| | | export function createConsoleImportWorkbook(): XLSX.WorkBook { |
| | | export function createConsoleImportWorkbook(selectedType?: ConsoleDataType): XLSX.WorkBook { |
| | | const workbook = XLSX.utils.book_new() |
| | | for (const type of Object.keys(consoleSheetLabels) as ConsoleDataType[]) { |
| | | for (const type of selectedType ? [selectedType] : Object.keys(consoleSheetLabels) as ConsoleDataType[]) { |
| | | XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet([Object.values(consoleImportColumns[type])]), consoleSheetLabels[type]) |
| | | } |
| | | // 单页汇总没有绝对时间,辅助页只供匹配,不隐式导入实时数据。 |
| | | if (selectedType && selectedType !== 'REALTIME') XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet([Object.values(consoleImportColumns.REALTIME)]), 'record') |
| | | return workbook |
| | | } |
| | | |
| | |
| | | const table = XLSX.utils.sheet_to_json<unknown[]>(sheet, { header: 1, raw: true, defval: '', blankrows: true }) |
| | | const headers = (table[0] || []).map(value => String(value).trim()) |
| | | const party = name === sheetNames[type][2] |
| | | const legacyRealtime = type === 'REALTIME' && !headers.includes('绝对时间') && (headers.includes('设备时间') || headers.includes('记录时间(h:min:s.ms)')) |
| | | const legacySourceNumber = headers.includes('电池号') && (headers.includes('设备时间') || legacyRealtime) |
| | | const date1904 = !!workbook.Workbook?.WBProps?.date1904 |
| | | const endTimes = new Map<string, unknown>() |
| | | if (party && type !== 'REALTIME' && workbook.Sheets.record) { |
| | | const invalidEndTimes = new Set<string>() |
| | | const batteryFor = (row: Record<string, unknown>) => String(row['电池'] || row['电池号'] || selectedBattery || (batteries.length === 1 ? batteries[0] : '')).trim() |
| | | const timeKey = (row: Record<string, unknown>) => JSON.stringify([batteryFor(row), String(row['循环序号']), ...(type === 'STEP' ? [String(row['工步序号'])] : [])]) |
| | | const recordNames = workbook.SheetNames.filter(name => sheetNames.REALTIME.includes(name) && workbook.Sheets[name]) |
| | | if (type !== 'REALTIME' && recordNames.length > 1) throw new Error('存在多个实时辅助工作表,无法确定时间来源') |
| | | if (type !== 'REALTIME' && recordNames.length === 1) { |
| | | // 汇总表没有绝对时间,只借用匹配实时记录的末次时间,不导入其他 Sheet 的行。 |
| | | const records = XLSX.utils.sheet_to_json<Record<string, unknown>>(workbook.Sheets.record, { raw: true }) |
| | | const records = XLSX.utils.sheet_to_json<Record<string, unknown>>(workbook.Sheets[recordNames[0]!]!, { raw: true }) |
| | | for (const record of records) { |
| | | const key = type === 'STEP' ? `${record['循环序号']}:${record['工步序号']}` : String(record['循环序号']) |
| | | const key = timeKey(record) |
| | | const time = deviceTime(record['绝对时间'], date1904) |
| | | if (!Number.isFinite(time)) invalidEndTimes.add(key) |
| | | if (Number.isFinite(time) && (!endTimes.has(key) || time > deviceTime(endTimes.get(key), date1904))) endTimes.set(key, record['绝对时间']) |
| | | } |
| | | // 组内有未知时间就无法可靠确定末次记录,不能静默退到更早的有效行。 |
| | | for (const key of invalidEndTimes) endTimes.delete(key) |
| | | } |
| | | // 旧标准模板按原列头单位读取,避免对 A/Ah/Wh 再次缩放。 |
| | | const headerFor = (field: string, standardHeader: string) => { |
| | | if (field === 'recordedAt' && legacyRealtime) return headers.includes('设备时间') ? '设备时间' : '记录时间(h:min:s.ms)' |
| | | if (party) return partyHeaders[field] || standardHeader |
| | | if (field === 'batteryNo' && headers.includes('电池号')) return '电池号' |
| | | if (field === 'stepName' && headers.includes('工步')) return '工步' |
| | | if (field === 'stepDuration' && headers.includes('工步时间')) return '工步时间' |
| | | if (field === 'recordedAt' && headers.includes('设备时间')) return '设备时间' |
| | | const legacyHeader = standardHeader.replace(/\(m(A|Ah|Wh)\)/, '($1)') |
| | | return headers.includes(standardHeader) ? standardHeader : headers.includes(legacyHeader) ? legacyHeader : standardHeader |
| | | } |
| | | const columns: Record<string, string> = consoleImportColumns[type] |
| | | const columns: Record<string, string> = { ...consoleImportColumns[type], ...(type !== 'REALTIME' ? { recordedAt: '绝对时间' } : {}) } |
| | | const missing = Object.entries(columns).filter(([field, header]) => { |
| | | // 中值只读取对应列;缺列可为空,不借用旧峰值或谷值。 |
| | | if (party && ['recordNo', 'batteryNo', 'temperature', 'positivePotential', 'negativePotential', 'chargeMedianVoltage', 'dischargeMedianVoltage'].includes(field)) return false |
| | | if (party && type !== 'REALTIME' && field === 'recordedAt') return false |
| | | if (['recordNo', 'batteryNo'].includes(field)) return false |
| | | if (field === 'relativeTimeSeconds' && legacyRealtime) return false |
| | | if (party && ['temperature', 'positivePotential', 'negativePotential', 'chargeMedianVoltage', 'dischargeMedianVoltage'].includes(field)) return false |
| | | if (type !== 'REALTIME' && field === 'recordedAt') return false |
| | | return !headers.includes(headerFor(field, header)) |
| | | }).map(([field, header]) => headerFor(field, header)) |
| | | if (missing.length) throw new Error(`第1行,列「${missing.join('、')}」:模板字段缺失`) |
| | |
| | | for (const [field, standardHeader] of Object.entries(columns)) { |
| | | const header = headerFor(field, standardHeader) |
| | | let value: unknown = raw[header] |
| | | if (party && field === 'batteryNo') value = selectedBattery || (batteries.length === 1 ? batteries[0] : '') |
| | | if (party && field === 'recordNo') value = raw['记录编号'] || (type === 'REALTIME' ? raw['记录序号'] : type === 'STEP' ? `step:${raw['循环序号']}:${raw['工步序号']}` : `cycle:${raw['循环序号']}`) |
| | | if (party && type !== 'REALTIME' && field === 'recordedAt' && !value) value = endTimes.get(type === 'STEP' ? `${raw['循环序号']}:${raw['工步序号']}` : String(raw['循环序号'])) |
| | | if (field === 'stepDuration' && typeof value === 'string' && value.includes(':')) { |
| | | const duration = /^(\d+):([0-5]\d):([0-5]\d)(?:\.(\d{1,3}))?$/.exec(value.trim()) |
| | | value = duration ? Number(duration[1]) * 3600 + Number(duration[2]) * 60 + Number(duration[3]) + Number((duration[4] || '').padEnd(3, '0')) / 1000 : NaN |
| | | if (field === 'batteryNo') value = batteryFor(raw) |
| | | // 系统记录编号只供定位修订,导入不能拿它代替设备/文件的来源编号。 |
| | | if (field === 'recordNo') value = type === 'REALTIME' ? raw['记录序号'] : type === 'STEP' ? `step:${raw['循环序号']}:${raw['工步序号']}` : `cycle:${raw['循环序号']}` |
| | | // 旧标准模板的编号曾表示来源,保留其重复导入身份;新模板编号不参与去重。 |
| | | if (field === 'recordNo' && legacySourceNumber && raw['记录编号']) value = raw['记录编号'] |
| | | if (type !== 'REALTIME' && field === 'recordedAt' && !value) value = endTimes.get(timeKey(raw)) |
| | | if (field === 'stepDuration' || field === 'relativeTimeSeconds') { |
| | | const cell = sheet[XLSX.utils.encode_cell({ r: sourceRow - 1, c: headers.indexOf(header) })] |
| | | if (typeof value === 'number' && cell?.z && /[hs]/i.test(cell.z)) value = XLSX.utils.format_cell(cell) |
| | | value = field === 'relativeTimeSeconds' && legacyRealtime ? null : parseTestDuration(value) |
| | | } |
| | | const text = String(value ?? '').trim() |
| | | const fail = (reason: string) => errors.push(`第${sourceRow}行,列「${header}」:${reason}`) |
| New file |
| | |
| | | /** 将设备或文件的秒时长显示为累计小时,保留毫秒;缺失不补零。 */ |
| | | export function formatTestDuration(value: unknown): string { |
| | | if (value == null || value === '' || value === '-') return '-' |
| | | const seconds = Number(value) |
| | | if (!Number.isFinite(seconds) || seconds < 0) return '-' |
| | | const milliseconds = Math.round(seconds * 1000) |
| | | return `${Math.floor(milliseconds / 3600000)}:${String(Math.floor(milliseconds / 60000) % 60).padStart(2, '0')}:${String(Math.floor(milliseconds / 1000) % 60).padStart(2, '0')}.${String(milliseconds % 1000).padStart(3, '0')}` |
| | | } |
| | | |
| | | /** 解析累计时长;仅旧秒值输入允许纯数字,不将其解释为绝对时间。 */ |
| | | export function parseTestDuration(value: unknown): number | null { |
| | | const text = String(value ?? '').trim() |
| | | if (!text || text === '-') return null |
| | | const duration = /^(\d+):([0-5]\d):([0-5]\d)(?:\.(\d{1,3}))?$/.exec(text) |
| | | const seconds = duration ? Number(duration[1]) * 3600 + Number(duration[2]) * 60 + Number(duration[3]) + Number((duration[4] || '').padEnd(3, '0')) / 1000 : Number(text) |
| | | return Number.isFinite(seconds) && seconds >= 0 && Number.isSafeInteger(Math.round(seconds * 1000)) ? seconds : NaN |
| | | } |
| | |
| | | import { formatTestDuration, parseTestDuration } from './testDataDuration.ts' |
| | | |
| | | const milliFields = new Set([ |
| | | 'current', 'capacity', 'energy', |
| | | 'chargeCapacity', 'dischargeCapacity', 'chargeEnergy', 'dischargeEnergy', |
| | |
| | | |
| | | /** 将编辑输入转回 API 单位;空值及非法输入交由原有校验处理。 */ |
| | | export function toTestApiValue(field: string, value: string): string { |
| | | if (field === 'stepDuration') { |
| | | const seconds = parseTestDuration(value) |
| | | return seconds == null ? '-' : String(seconds) |
| | | } |
| | | if (!milliFields.has(field) || !value.trim() || !Number.isFinite(Number(value))) return value |
| | | return String(Number((Number(value) / 1000).toPrecision(15))) |
| | | } |
| | | |
| | | /** 格式化 API 测量值,先换算再舍入,保留零值与缺失的区别。 */ |
| | | export function formatTestDisplayValue(field: string, value: unknown): string { |
| | | if (field === 'stepDuration' || field === 'relativeTimeSeconds') return formatTestDuration(value) |
| | | if (value === null || value === undefined || String(value).trim() === '') return '-' |
| | | const number = Number(value) |
| | | return Number.isFinite(number) ? toTestDisplayValue(field, number).toFixed(4) : '-' |
| | |
| | | import { getTestDetail } from '@/api/tests' |
| | | import { formatBusinessTime } from '@/utils/businessTime' |
| | | import { toTestDisplayValue } from '@/utils/testDataUnits' |
| | | import { formatTestDuration } from '@/utils/testDataDuration' |
| | | |
| | | type ImportStage = 'upload' | 'preview' | 'result' |
| | | |
| | |
| | | XLSX.writeFile(createConsoleImportWorkbook(), '测试三 Sheet 数据导入模板.xlsx') |
| | | } |
| | | |
| | | function previewValue(item: ValidatedImportRow, field: string): string | number { |
| | | // 文件中的系统编号只作展示;真正入库时由系统分配,来源编号不占用此列。 |
| | | if (field === 'recordNo') return String(item.raw['记录编号'] || '-') |
| | | if (field === 'stepDuration' || field === 'relativeTimeSeconds') return formatTestDuration(item.data[field]) |
| | | if (field === 'recordedAt') return formatBusinessTime(item.data[field]) |
| | | const value = item.data[field] |
| | | return typeof value === 'number' ? toTestDisplayValue(field, value) : value ?? '-' |
| | | } |
| | | |
| | | function backToDataList() { |
| | | router.push({ path: `/test-list/${testId}/console`, query: { tab: 'data-list', dataType } }) |
| | | } |
| | |
| | | } |
| | | |
| | | try { |
| | | const workbook = XLSX.read(await file.arrayBuffer(), { type: 'array' }) |
| | | const workbook = XLSX.read(await file.arrayBuffer(), { type: 'array', cellNF: true }) |
| | | sheets.value = workbookMode ? parseConsoleImportWorkbook(workbook, [...knownBatteries.value], selectedBattery.value) : [] |
| | | const rows = workbookMode ? sheets.value.flatMap(sheet => sheet.rows) : parseConsoleImport(workbook, dataType, [...knownBatteries.value], selectedBattery.value) |
| | | if (!rows.length && !workbookMode) { |
| | |
| | | <table><thead><tr><th>工作表</th><th>行数</th><th>有效数</th><th>错误行数</th><th>工作表错误数</th></tr></thead><tbody><tr v-for="sheet in sheets" :key="sheet.type"><td>{{ consoleSheetLabels[sheet.type] }}</td><td>{{ sheet.rows.length }}</td><td>{{ sheet.rows.filter(row => !row.errors.length).length }}</td><td>{{ sheet.rows.filter(row => row.errors.length).length }}</td><td>{{ sheet.errors.length }}</td></tr></tbody></table> |
| | | <el-tabs v-model="previewType"><el-tab-pane v-for="sheet in sheets" :key="sheet.type" :name="sheet.type" :label="consoleSheetLabels[sheet.type]" /></el-tabs> |
| | | </div> |
| | | <div class="preview-table-wrap"><table><thead><tr><th>Excel行号</th><th v-for="(label, field) in consoleImportColumns[previewType]" :key="field">{{ label }}</th><th>校验结果</th></tr></thead><tbody><tr v-for="item in previewRows.slice(0, 12)" :key="item.sourceRow"><td>{{ item.sourceRow }}</td><td v-for="(label, field) in consoleImportColumns[previewType]" :key="label">{{ field === 'recordedAt' ? formatBusinessTime(item.data[field]) : typeof item.data[field] === 'number' ? toTestDisplayValue(field, Number(item.data[field])) : item.data[field] ?? '-' }}</td><td>{{ item.errors.length ? item.errors.join(';') : '通过' }}</td></tr></tbody></table></div> |
| | | <div class="preview-table-wrap"><table><thead><tr><th>Excel行号</th><th v-for="(label, field) in consoleImportColumns[previewType]" :key="field">{{ label }}</th><th>校验结果</th></tr></thead><tbody><tr v-for="item in previewRows.slice(0, 12)" :key="item.sourceRow"><td>{{ item.sourceRow }}</td><td v-for="(label, field) in consoleImportColumns[previewType]" :key="label">{{ previewValue(item, field) }}</td><td>{{ item.errors.length ? item.errors.join(';') : '通过' }}</td></tr></tbody></table></div> |
| | | <p v-if="previewRows.length > 12" class="preview-tip">当前预览前 12 条,共 {{ previewRows.length }} 条数据</p> |
| | | <el-alert v-if="allErrors.length" type="error" :closable="false" :title="workbookMode ? '三 Sheet 校验失败,整批尚未写入数据' : '本页校验失败,尚未写入数据'"><p v-for="(error, index) in allErrors.slice(0, 20)" :key="index">{{ error }}</p></el-alert> |
| | | <el-alert v-if="submitError" class="submit-error" type="error" :closable="false" title="导入未成功"><p>{{ submitError }}</p></el-alert> |
| | |
| | | const route = useRoute(); const router = useRouter(); const record = ref<DataRevisionAction | null>(null); const loading = ref(true); const loadError = ref('') |
| | | const dataTypeLabels = revisionDataTypeLabels; const operationLabels = revisionOperationLabels |
| | | // 后端修订摘要保留 A/Ah/Wh,仅展示时换算,不改写已保存的修订事实。 |
| | | const fieldLabels: Record<string, string> = { temperature: '温度(℃)', positivePotential: '正电位(V)', negativePotential: '负电位(V)', voltage: '电压(V)', current: '电流(mA)', capacity: '容量(mAh)', energy: '能量(mWh)', stepDuration: '工步时间(s)', startVoltage: '起始电压(V)', endVoltage: '终止电压(V)', chargeMedianVoltage: '充电中值电压(V)', dischargeMedianVoltage: '放电中值电压(V)', chargePeakVoltage: '充电峰值电压(V)', dischargeValleyVoltage: '放电谷值电压(V)', chargeCapacity: '充电容量(mAh)', dischargeCapacity: '放电容量(mAh)', efficiency: '效率(%)', chargeEnergy: '充电能量(mWh)', dischargeEnergy: '放电能量(mWh)' } |
| | | const fieldLabels: Record<string, string> = { temperature: '温度(℃)', positivePotential: '正电位(V)', negativePotential: '负电位(V)', voltage: '电压(V)', current: '电流(mA)', capacity: '容量(mAh)', energy: '能量(mWh)', stepDuration: '工步时间(h:min:s.ms)', startVoltage: '起始电压(V)', endVoltage: '终止电压(V)', chargeMedianVoltage: '充电中值电压(V)', dischargeMedianVoltage: '放电中值电压(V)', chargePeakVoltage: '充电峰值电压(V)', dischargeValleyVoltage: '放电谷值电压(V)', chargeCapacity: '充电容量(mAh)', dischargeCapacity: '放电容量(mAh)', efficiency: '充放电效率(%)', chargeEnergy: '充电能量(mWh)', dischargeEnergy: '放电能量(mWh)' } |
| | | function parseArray(value: string | null) { if (!value) return [] as Array<Record<string, unknown> | string>; try { const parsed = JSON.parse(value); return Array.isArray(parsed) ? parsed : [] } catch { return [] as Array<Record<string, unknown> | string> } } |
| | | // 字段变化只在编辑详情读取,导入和删除不解析或渲染逐条变化,审计原文仍由后端保存。 |
| | | const identities = computed(() => parseArray(record.value?.identitySummary || null).map(item => String(item))); const changeRows = computed(() => parseArray(record.value?.changesSummary || null).map((item, index) => { if (!item || typeof item !== 'object') return { identity: identities.value[index] || '-', changes: [] as Array<{ field: string; before: unknown; after: unknown }> }; const row = item as Record<string, unknown>; const before = row.before && typeof row.before === 'object' ? row.before as Record<string, unknown> : {}; const after = row.after && typeof row.after === 'object' ? row.after as Record<string, unknown> : row; const changes = Object.keys(after).filter(key => before[key] !== after[key] && fieldLabels[key]).map(key => ({ field: fieldLabels[key], before: formatTestDisplayValue(key, before[key]), after: formatTestDisplayValue(key, after[key]) })); return { identity: String(row.identity || row.recordNo || identities.value[index] || '-'), changes } })) |
| | |
| | | const source = fs.readFileSync(file, 'utf8'); |
| | | const script = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText; |
| | | const exports = {}; |
| | | new Function('exports', 'require', script)(exports, id => id.startsWith('.') ? load(path.resolve(path.dirname(file), id + '.ts')) : require(id)); |
| | | new Function('exports', 'require', script)(exports, id => id.startsWith('.') ? load(path.resolve(path.dirname(file), id.endsWith('.ts') ? id : id + '.ts')) : require(id)); |
| | | return exports; |
| | | }; |
| | |
| | | |
| | | // 拦截构建产物和 API 完成页面验收,不启动业务服务或连接共享数据库。 |
| | | const origin = 'http://single-sheet-import.test' |
| | | const output = '../.scratch/simulator-and-data-import-v1/single-sheet-browser' |
| | | const output = '../.scratch/console-excel-field-alignment/browser' |
| | | await mkdir(output, { recursive: true }) |
| | | const browser = await chromium.launch({ channel: 'chrome', headless: true }) |
| | | const calls = [] |
| | |
| | | |
| | | function standardFile(type, invalid = false) { |
| | | const fields = consoleImportColumns[type] |
| | | const row = { recordNo: '17', batteryNo: 'BAT-1', recordedAt: '2023-05-08 11:42:11', cycleNo: 1, stepNo: 1, stepName: '恒流放电', sequenceId: 17, capacity: 8.5122, temperature: 25, positivePotential: 1.5, negativePotential: -0.1, chargeMedianVoltage: 0, dischargeMedianVoltage: 1.1709 } |
| | | const row = { recordNo: '17', batteryNo: 'BAT-1', recordedAt: '2023-05-08 11:42:11', cycleNo: 1, stepNo: 1, stepName: '恒流放电', sequenceId: 17, relativeTimeSeconds: '1:02:32.125', stepDuration: '1:02:32.125', capacity: 8.5122, temperature: 25, positivePotential: 1.5, negativePotential: -0.1, chargeMedianVoltage: 0, dischargeMedianVoltage: 1.1709 } |
| | | const rows = [Object.values(fields), Object.keys(fields).map(field => row[field] ?? '')] |
| | | if (invalid) { |
| | | while (rows.length < 19) rows.push([]) |
| | | rows.push(Object.keys(fields).map(field => field === 'cycleNo' ? 0 : row[field] ?? '')) |
| | | } |
| | | const workbook = XLSX.utils.book_new() |
| | | XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet(rows), { REALTIME: '实时', STEP: '工步', CYCLE: '循环' }[type]) |
| | | const workbook = createConsoleImportWorkbook(type) |
| | | workbook.Sheets[workbook.SheetNames[0]] = XLSX.utils.aoa_to_sheet(rows) |
| | | if (type !== 'REALTIME') XLSX.utils.sheet_add_aoa(workbook.Sheets.record, [Object.keys(consoleImportColumns.REALTIME).map(field => row[field] ?? '')], { origin: 'A2' }) |
| | | return { name: '单页数据.xlsx', mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', buffer: XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }) } |
| | | } |
| | | |
| | |
| | | if (path.endsWith('/live-state')) { liveCalls.push(path); return route.fulfill({ json: { code: 200, data: { ...detail, sources: [], exceptions: [] } } }) } |
| | | if (/\/data\/(records|steps|cycles)$/.test(path)) { |
| | | const type = { records: 'REALTIME', steps: 'STEP', cycles: 'CYCLE' }[path.split('/').at(-1)] |
| | | const data = workbookRows?.[type]?.slice(0, 1).map(row => ({ ...row, recordId: 1, stepRecordId: 1, cycleRecordId: 1, stableIdentity: `${type.toLowerCase()}:1` })) || [] |
| | | const data = workbookRows?.[type]?.slice(0, 1).map(row => ({ ...row, sourceType: type === 'REALTIME' ? 'SNAPSHOT' : undefined, deviceTime: row.recordedAt, recordId: 1, stepRecordId: 1, cycleRecordId: 1, stableIdentity: `${type.toLowerCase()}:1` })) || [] |
| | | return route.fulfill({ json: { code: 200, data } }) |
| | | } |
| | | if (path.endsWith('/system/test/1')) return route.fulfill({ json: { code: 200, data: detail } }) |
| | |
| | | return route.fulfill({ body: await readFile('dist/index.html'), contentType: 'text/html' }) |
| | | }) |
| | | for (const type of ['REALTIME', 'STEP', 'CYCLE']) { |
| | | await page.setViewportSize({ width: 1440, height: 1000 }) |
| | | await page.goto(`${origin}/test-list/1/console?tab=data-list&dataType=${type}`) |
| | | const downloadEvent = page.waitForEvent('download') |
| | | await page.getByRole('button', { name: '下载模板', exact: true }).click() |
| | | const download = await downloadEvent |
| | | const workbook = XLSX.read(await readFile(await download.path())) |
| | | assert.equal(workbook.SheetNames.length, 1) |
| | | assert.equal(workbook.SheetNames.length, type === 'REALTIME' ? 1 : 2) |
| | | assert.deepEqual(XLSX.utils.sheet_to_json(workbook.Sheets[workbook.SheetNames[0]], { header: 1 })[0], Object.values(consoleImportColumns[type])) |
| | | await page.goto(`${origin}/test-list/1/console/data/import?dataType=${type}`) |
| | | await expect(page.locator('.status-notice')).toHaveCount(0) |
| | |
| | | await expect(page.locator('.table-wrap tbody')).toContainText('BAT-1') |
| | | await page.reload() |
| | | await expect(page.locator('.table-wrap tbody')).toContainText('BAT-1') |
| | | const expectedHeaders = ['记录编号', '电池', ...XLSX.utils.sheet_to_json(XLSX.readFile(process.env.PARTY_EXCEL || (await import('node:path')).resolve('../outputs/ticket03-excel/277#(5)(1)_补充三项测试值.xls')).Sheets[{ REALTIME: 'record', STEP: 'step', CYCLE: 'cycle' }[type]], { header: 1 })[0]] |
| | | assert.deepEqual((await page.locator('.table-wrap th').allTextContents()).map(value => value.trim()).slice(1, -1), expectedHeaders) |
| | | await expect(page.locator('.record-code').first()).toHaveText(type.toLowerCase() + ':1') |
| | | await page.screenshot({ path: `${output}/list-${type.toLowerCase()}-390.png`, fullPage: true }) |
| | | await page.setViewportSize({ width: 1440, height: 1000 }) |
| | | await page.screenshot({ path: `${output}/list-${type.toLowerCase()}.png`, fullPage: true, animations: 'disabled' }) |
| | | const downloadEvent = page.waitForEvent('download') |
| | | await page.getByRole('button', { name: '下载', exact: true }).click() |
| | | const csvText = await readFile(await (await downloadEvent).path(), 'utf8') |
| | | const csvRows = XLSX.utils.sheet_to_json(XLSX.read(csvText.replace(/^\uFEFF/, ''), { type: 'string', raw: true }).Sheets.Sheet1, { header: 1, raw: true }) |
| | | assert.deepEqual(csvRows[0], expectedHeaders) |
| | | assert.equal(csvRows[1][0], type.toLowerCase() + ':1') |
| | | if (type === 'REALTIME') { |
| | | assert.equal(csvRows[1][6], process.env.PARTY_EXCEL ? '0:00:00.000' : '1:02:32.125') |
| | | assert.match(csvRows[1][11], /^2023-05-08 11:42:11/) |
| | | } |
| | | if (type === 'STEP') { |
| | | assert.equal(csvRows[1][5], process.env.PARTY_EXCEL ? '1:02:32.000' : '1:02:32.125') |
| | | await expect(page.locator('.table-wrap thead')).toContainText('充电中值电压(V)') |
| | | await expect(page.locator('.table-wrap thead')).toContainText('放电中值电压(V)') |
| | | await expect(page.locator('.table-wrap tbody')).toContainText('0.0000') |
| | |
| | | assert.match(csv, /放电中值电压\(V\)/) |
| | | assert.match(csv, /"0\.0000","1\.1709"/) |
| | | assert.doesNotMatch(csv, /峰值|谷值/) |
| | | await page.locator('.row-actions').first().getByRole('button', { name: '编辑', exact: true }).click() |
| | | const durationInput = page.locator('.single-form-grid label').filter({ hasText: '工步时间(h:min:s.ms)' }).locator('input') |
| | | await expect(durationInput).toHaveValue(process.env.PARTY_EXCEL ? '1:02:32.000' : '1:02:32.125') |
| | | await durationInput.fill('1:02:33.250') |
| | | await page.locator('.remark-field textarea').fill('核对时长编辑与记录编号') |
| | | const edited = page.waitForResponse(response => response.url().endsWith('/console-data/actions')) |
| | | await page.getByRole('button', { name: '提交修改', exact: true }).click() |
| | | await edited |
| | | assert.equal(calls.at(-1).operationType, 'EDIT') |
| | | assert.deepEqual(JSON.parse(calls.at(-1).identitySummary), ['step:1']) |
| | | assert.deepEqual(JSON.parse(calls.at(-1).overrides[0].fieldValues), { stepDuration: '3753.25' }) |
| | | } |
| | | } |
| | | assert.deepEqual(errors, []) |
| | |
| | | import XLSX from 'xlsx' |
| | | import { parseConsoleImport, consoleImportColumns } from '../src/utils/consoleDataImport.ts' |
| | | import * as importUtils from '../src/utils/consoleDataImport.ts' |
| | | import { formatTestDuration, parseTestDuration } from '../src/utils/testDataDuration.ts' |
| | | |
| | | test('时长保留毫秒、累计小时和空值,非法时长不变成有效值', () => { |
| | | assert.equal(formatTestDuration(90062.125), '25:01:02.125') |
| | | assert.equal(parseTestDuration('25:01:02.125'), 90062.125) |
| | | assert.equal(formatTestDuration(0), '0:00:00.000') |
| | | assert.equal(formatTestDuration(null), '-') |
| | | assert.equal(parseTestDuration('-'), null) |
| | | for (const value of ['-1', '0:60:00.000', '2023-05-08 12:00:00', 'abc']) assert.ok(Number.isNaN(parseTestDuration(value))) |
| | | }) |
| | | |
| | | test('工步单页模板借同电池 record 时间,系统编号不充当来源编号', () => { |
| | | const workbook = importUtils.createConsoleImportWorkbook('STEP') |
| | | assert.deepEqual(workbook.SheetNames, ['工步', 'record']) |
| | | assert.deepEqual(XLSX.utils.sheet_to_json(workbook.Sheets['工步'], { header: 1 })[0], [ |
| | | '记录编号', '电池', '循环序号', '工步序号', '工步名称', '工步时间(h:min:s.ms)', '容量(mAh)', '能量(mWh)', |
| | | '起始电压(V)', '终止电压(V)', '充电中值电压(V)', '放电中值电压(V)', |
| | | ]) |
| | | XLSX.utils.sheet_add_aoa(workbook.Sheets['工步'], [['step:999', 'BAT-1', 1, 1, '恒流放电', '1:02:32.125']], { origin: 'A2' }) |
| | | const records = [{ batteryNo: 'BAT-1', recordedAt: '2023-05-08 11:42:11' }, { batteryNo: 'BAT-2', recordedAt: '2023-05-08 12:42:11' }] |
| | | XLSX.utils.sheet_add_aoa(workbook.Sheets.record, records.map(row => Object.keys(consoleImportColumns.REALTIME).map(key => ({ ...row, cycleNo: 1, stepNo: 1 })[key] ?? '')), { origin: 'A2' }) |
| | | const imported = parseConsoleImport(workbook, 'STEP', ['BAT-1', 'BAT-2']) |
| | | assert.equal(imported.length, 1) |
| | | assert.deepEqual(imported[0].errors, []) |
| | | assert.equal(imported[0].data.recordNo, 'step:1:1') |
| | | assert.equal(imported[0].data.recordedAt, 1683517331000) |
| | | assert.equal(imported[0].data.stepDuration, 3752.125) |
| | | workbook.Sheets.record.A2 = { t: 's', v: '' } |
| | | workbook.Sheets.record.B2 = { t: 's', v: 'BAT-2' } |
| | | assert.match(parseConsoleImport(workbook, 'STEP', ['BAT-1', 'BAT-2'])[0].errors.join(), /绝对.*无效/) |
| | | }) |
| | | |
| | | test('一键模板包含三个标准 Sheet,并逐 Sheet 预览与报告缺失表', () => { |
| | | const workbook = importUtils.createConsoleImportWorkbook() |
| | |
| | | assert.equal(rows[0].data.cycleNo, 1) |
| | | assert.equal(rows[0].data.stepNo, 1) |
| | | assert.equal(rows[0].data.recordedAt, 1683517331000) |
| | | assert.equal(rows[0].data.relativeTimeSeconds, 0) |
| | | assert.equal(rows[1].data.relativeTimeSeconds, 3752) |
| | | assert.equal(rows[0].data.deviceTimeRaw, '2023-05-08 11:42:11') |
| | | assert.equal(rows[0].data.current, -8.1651) |
| | | assert.equal(rows[1].data.capacity, 8.5122) |
| | |
| | | for (const field of ['temperature', 'positivePotential', 'negativePotential']) { |
| | | assert.equal(rows[0].data[field], null) |
| | | } |
| | | }) |
| | | |
| | | test('汇总时间匹配组中存在非法绝对时间时拒绝,不选择更早有效行代替', () => { |
| | | const workbook = partyWorkbook() |
| | | XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet([ |
| | | Object.values(consoleImportColumns.CYCLE), ['', 'BAT-1', 1, 0, 0, 0, 0, 0], |
| | | ]), '循环') |
| | | workbook.Sheets.record.J3 = { t: 's', v: '错误时间' } |
| | | assert.match(parseConsoleImport(workbook, 'CYCLE', ['BAT-1'])[0].errors.join(), /绝对.*无效/) |
| | | }) |
| | | |
| | | test('采集器三项测量只读取 Excel 已有值,缺列或空格不生成数值', () => { |
| | |
| | | }) |
| | | |
| | | test('多电池文件要求明确归属,保留空白行后的实际错误位置', () => { |
| | | assert.match(parseConsoleImport(partyWorkbook(), 'REALTIME', ['BAT-1', 'BAT-2'])[0].errors.join(), /电池号.*不能为空/) |
| | | assert.match(parseConsoleImport(partyWorkbook(), 'REALTIME', ['BAT-1', 'BAT-2'])[0].errors.join(), /电池.*不能为空/) |
| | | assert.equal(parseConsoleImport(partyWorkbook(), 'REALTIME', ['BAT-1', 'BAT-2'], 'BAT-2')[0].data.batteryNo, 'BAT-2') |
| | | const workbook = partyWorkbook() |
| | | XLSX.utils.sheet_add_aoa(workbook.Sheets.record, [[1, 1, '恒流放电', 19, '0:01:00.000', '不是数值', 0, 0, 0, '2023-05-08 11:44:11']], { origin: 'A20' }) |
| | |
| | | const values = { recordNo: '1', batteryNo: 'BAT-1', recordedAt: '2023-05-08 11:42:11', cycleNo: 1, stepNo: 1, stepName: '恒流放电', sequenceId: 1, current: legacy ? -8.1651 : -8165.1, capacity: legacy ? 8.5122 : 8512.2, energy: legacy ? 9.7178 : 9717.8, dischargeCapacity: legacy ? 8.5122 : 8512.2, dischargeEnergy: legacy ? 9.7178 : 9717.8 } |
| | | const book = XLSX.utils.book_new() |
| | | const headers = Object.values(fields).map(header => legacy ? header.replace(/\(m(A|Ah|Wh)\)/, '($1)') : header) |
| | | XLSX.utils.book_append_sheet(book, XLSX.utils.aoa_to_sheet([headers, Object.keys(fields).map(field => values[field] ?? '')]), '单页') |
| | | XLSX.utils.book_append_sheet(book, XLSX.utils.aoa_to_sheet([headers, Object.keys(fields).map(field => values[field] ?? '')]), { REALTIME: '实时', STEP: '工步', CYCLE: '循环' }[type]) |
| | | if (type !== 'REALTIME') XLSX.utils.book_append_sheet(book, XLSX.utils.aoa_to_sheet([ |
| | | ['电池', '循环序号', '工步序号', '绝对时间'], ['BAT-1', 1, 1, '2023-05-08 11:42:11'], |
| | | ]), 'record') |
| | | const row = parseConsoleImport(book, type, ['BAT-1'])[0] |
| | | assert.deepEqual(row.errors, []) |
| | | const field = type === 'CYCLE' ? 'dischargeCapacity' : 'capacity' |