yeg
2026-09-09 b226aa0db7fbecb8fff92476537adc0ca3e16a4f
统一测试三类数据字段并修正记录编号与时长

列表、导入预览、模板和下载按甲方表格统一列名及顺序,记录编号对齐修订身份。区分相对时长与绝对时间,工步循环单页导入使用实时辅助页可靠匹配时间,并验证编辑、单位及原表全量导入。
1个文件已添加
10个文件已修改
284 ■■■■■ 已修改文件
src/api/tests.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/ConsoleDataListPanel.vue 93 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/consoleDataImport.ts 57 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/testDataDuration.ts 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/testDataUnits.ts 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ConsoleDataImport.vue 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/DataRevisionDetail.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tests/load-typescript.cjs 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tests/test-console-data-import-browser.mjs 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tests/test-console-data-import.test.mjs 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tests/test-console-display-units.test.mjs 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/tests.ts
@@ -353,6 +353,7 @@
  stepType?: string
  sequenceId: number
  cabinetSequenceId?: number | null
  relativeTimeSeconds?: number | string | null
  recordedAt?: number | null
  absoluteTime?: number | null
  deviceTime?: number | null
src/components/ConsoleDataListPanel.vue
@@ -8,6 +8,8 @@
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 {
@@ -35,6 +37,7 @@
  actualStepLabel: string
  sequenceId: number
  recordedAt: string
  relativeTimeSeconds: string
  absoluteTime: string
  serverReceivedAt: string
  stepDuration: string
@@ -97,7 +100,7 @@
        ]
    : 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' },
@@ -114,7 +117,7 @@
        ],
)
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)
@@ -131,17 +134,17 @@
      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
@@ -215,9 +218,7 @@
  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('导入模板已下载')
}
@@ -246,9 +247,10 @@
    /* 用户取消原因填写 */
  }
}
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() {
@@ -257,7 +259,7 @@
    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('请填写完整、有效的数据')
@@ -283,7 +285,7 @@
          .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 } }
      })
@@ -344,8 +346,9 @@
    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')
@@ -422,52 +425,11 @@
    <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>
@@ -485,11 +447,7 @@
              <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>
@@ -506,6 +464,10 @@
                <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>
@@ -519,7 +481,8 @@
                <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>
@@ -621,7 +584,7 @@
            <dd>{{ singleRow.stepName }}</dd>
          </div>
          <div>
            <dt>记录时间</dt>
            <dt>绝对时间</dt>
            <dd>{{ singleRow.recordedAt }}</dd>
          </div>
          <div>
@@ -857,7 +820,7 @@
  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 {
src/utils/consoleDataImport.ts
@@ -1,10 +1,11 @@
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 {
@@ -23,11 +24,13 @@
}
/** 创建包含三个标准列头的空工作簿,不生成测量数据。 */
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
}
@@ -81,29 +84,45 @@
  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('、')}」:模板字段缺失`)
@@ -116,12 +135,16 @@
    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}`)
src/utils/testDataDuration.ts
New file
@@ -0,0 +1,17 @@
/** 将设备或文件的秒时长显示为累计小时,保留毫秒;缺失不补零。 */
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
}
src/utils/testDataUnits.ts
@@ -1,3 +1,5 @@
import { formatTestDuration, parseTestDuration } from './testDataDuration.ts'
const milliFields = new Set([
  'current', 'capacity', 'energy',
  'chargeCapacity', 'dischargeCapacity', 'chargeEnergy', 'dischargeEnergy',
@@ -10,12 +12,17 @@
/** 将编辑输入转回 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) : '-'
src/views/ConsoleDataImport.vue
@@ -8,6 +8,7 @@
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'
@@ -45,6 +46,15 @@
  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 } })
}
@@ -72,7 +82,7 @@
  }
  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) {
@@ -173,7 +183,7 @@
          <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>
src/views/DataRevisionDetail.vue
@@ -6,7 +6,7 @@
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 } }))
tests/load-typescript.cjs
@@ -7,6 +7,6 @@
  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;
};
tests/test-console-data-import-browser.mjs
@@ -6,7 +6,7 @@
// 拦截构建产物和 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 = []
@@ -18,14 +18,15 @@
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' }) }
}
@@ -46,7 +47,7 @@
    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 } })
@@ -54,12 +55,13 @@
    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)
@@ -171,7 +173,24 @@
    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')
@@ -183,6 +202,17 @@
      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, [])
tests/test-console-data-import.test.mjs
@@ -3,6 +3,37 @@
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()
@@ -41,6 +72,8 @@
  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)
@@ -48,6 +81,15 @@
  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 已有值,缺列或空格不生成数值', () => {
@@ -82,7 +124,7 @@
})
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' })
tests/test-console-display-units.test.mjs
@@ -51,7 +51,10 @@
      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'