From 9dfc7d64b00a704b7e77a6000c1dcadd66b0d345 Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期五, 09 十月 2026 15:52:09 +0800
Subject: [PATCH] 工步名称的说明同时覆盖实时页

---
 src/components/ConsoleDataListPanel.vue |  287 +++++++++++++++++++++++++++++++-------------------------
 1 files changed, 158 insertions(+), 129 deletions(-)

diff --git a/src/components/ConsoleDataListPanel.vue b/src/components/ConsoleDataListPanel.vue
index 85c77e4..f5b488e 100644
--- a/src/components/ConsoleDataListPanel.vue
+++ b/src/components/ConsoleDataListPanel.vue
@@ -5,7 +5,11 @@
 import * as XLSX from 'xlsx'
 import { Download, EditPen, Refresh, Search, UploadFilled, Clock, Check } from '@element-plus/icons-vue'
 import { getPersistedTestCycles, getPersistedTestRecords, getPersistedTestSteps, type PersistedTestCycle, type PersistedTestRecord, type PersistedTestStep } from '@/api/tests'
-import { submitConsoleDataAction, type ConsoleDataType } from '@/api/consoleData'
+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 {
@@ -33,13 +37,14 @@
   actualStepLabel: string
   sequenceId: number
   recordedAt: string
+  relativeTimeSeconds: string
   absoluteTime: string
   serverReceivedAt: string
   stepDuration: string
   startVoltage: string
   endVoltage: string
-  chargePeakVoltage: string
-  dischargeValleyVoltage: string
+  chargeMedianVoltage: string
+  dischargeMedianVoltage: string
   voltage: string
   current: string
   capacity: string
@@ -48,11 +53,15 @@
   positivePotential: string
   negativePotential: string
   extensionMeasurements: string
+  sourceIdentity: string
+  sourceRecordId: string
+  collectorDeviceTime: string
   chargeCapacity: string
   dischargeCapacity: string
   efficiency: string
   chargeEnergy: string
   dischargeEnergy: string
+  sourceKind: string
 }
 const props = defineProps<{ testId: number; testName: string; cells: ConsoleCell[]; canImport?: boolean; receptionStatus?: string; bindings?: Array<{ batteryNo: string; bindingId?: number | null; channelId?: number | null; devid?: number | null; chlid?: number | null }>}>()
 const route = useRoute()
@@ -69,6 +78,14 @@
   { key: 'step', label: '工步' },
   { key: 'cycle', label: '循环' },
 ] as const
+// 循环统计的来源类别:设备与文件给的值原样展示,推导值必须说明它是聚合出来的。
+const cycleSourceLabels: Record<string, string> = { DEVICE: '设备提供', AGGREGATED: '工步层聚合', IMPORTED: '文件导入' }
+const CYCLE_SOURCE_UNKNOWN = '未标注'
+const cycleSourceTitle = '「工步层聚合」表示设备没有提供循环汇总行,该循环的容量、能量与效率由本系统按工步层同方向工步求和得出;推导结果可能带有设备工步行里的偏差。'
+const hasAggregatedCycles = computed(() => cycleRows.value.some(row => row.sourceKind === cycleSourceLabels.AGGREGATED))
+// 本来源的工步方案里只有「步_N」标号,没有描述性名称:那里的工步名称是本系统按电流符号判定的方向(充电/放电/搁置)。
+const derivedStepNames = ['充电', '放电', '搁置']
+const hasDerivedStepNames = computed(() => [...rows.value, ...stepRows.value].some(row => derivedStepNames.includes(String(row.stepName || '').trim())))
 const filters = reactive<FilterState>({ batteryNo: '', dateRange: null, cycleNo: '', stepNo: '' })
 const appliedFilters = ref<FilterState>({ batteryNo: '', dateRange: null, cycleNo: '', stepNo: '' })
 const currentPage = ref(1)
@@ -84,44 +101,36 @@
   activeTab.value === 'realtime'
       ? [
           { label: '电压(V)', value: 'voltage' },
-          { label: '电流(A)', value: 'current' },
-          { label: '容量(Ah)', value: 'capacity' },
-          { label: '能量(Wh)', value: 'energy' },
+          { label: '电流(mA)', value: 'current' },
+          { label: '容量(mAh)', value: 'capacity' },
+          { label: '能量(mWh)', value: 'energy' },
           { label: '温度(℃)', value: 'temperature' },
           { label: '正电位(V)', value: 'positivePotential' },
           { label: '负电位(V)', value: 'negativePotential' },
         ]
     : activeTab.value === 'step'
       ? [
-          { label: '工步时间', value: 'stepDuration' },
-          { label: '容量(Ah)', value: 'capacity' },
-          { label: '能量(Wh)', value: 'energy' },
+          { label: '工步时间(h:min:s.ms)', value: 'stepDuration' },
+          { label: '容量(mAh)', value: 'capacity' },
+          { label: '能量(mWh)', value: 'energy' },
           { label: '起始电压(V)', value: 'startVoltage' },
           { label: '终止电压(V)', value: 'endVoltage' },
-          { label: '充电峰值电压(V)', value: 'chargePeakVoltage' },
-          { label: '放电谷值电压(V)', value: 'dischargeValleyVoltage' },
+          { label: '充电中值电压(V)', value: 'chargeMedianVoltage' },
+          { label: '放电中值电压(V)', value: 'dischargeMedianVoltage' },
         ]
       : [
-          { label: '充电容量(Ah)', value: 'chargeCapacity' },
-          { label: '放电容量(Ah)', value: 'dischargeCapacity' },
+          { label: '充电容量(mAh)', value: 'chargeCapacity' },
+          { label: '放电容量(mAh)', value: 'dischargeCapacity' },
           { label: '效率(%)', value: 'efficiency' },
-          { label: '充电能量(Wh)', value: 'chargeEnergy' },
-          { label: '放电能量(Wh)', value: 'dischargeEnergy' },
+          { label: '充电能量(mWh)', value: 'chargeEnergy' },
+          { label: '放电能量(mWh)', value: 'dischargeEnergy' },
         ],
 )
-function formatTime(value?: string | null) {
-  return value
-    ? value
-        .replace('T', ' ')
-        .replace('Z', '')
-        .replace(/\.\d{3,6}$/, '')
-    : '-'
-}
 function emptyRow(base: Partial<DataRow>): DataRow {
-  return { id: '', sourceType: 'CABINET', metricCode: '-', metricValue: '-', unit: '-', collectorCode: '-', channelCode: '-', recordNo: '', batteryNo: '', cycleNo: 0, stepNo: 0, stepName: '', actualStepLabel: '-', sequenceId: 0, recordedAt: '', absoluteTime: '', serverReceivedAt: '', stepDuration: '-', startVoltage: '-', endVoltage: '-', chargePeakVoltage: '-', dischargeValleyVoltage: '-', voltage: '-', current: '-', capacity: '-', energy: '-', temperature: '-', positivePotential: '-', negativePotential: '-', extensionMeasurements: '-', chargeCapacity: '-', dischargeCapacity: '-', efficiency: '-', chargeEnergy: '-', dischargeEnergy: '-', ...base }
+  return { sourceIdentity: '-', sourceRecordId: '-', 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: '-', sourceKind: '-', ...base }
 }
 function mapRecord(record: PersistedTestRecord): DataRow {
-  const deviceTime = formatTime(record.deviceTime || record.recordedAt)
+  const deviceTime = formatTime(record.deviceTime ?? record.recordedAt)
   const identity = record.stableIdentity || [record.adapterId, record.devtype, record.deviceId, record.subdevid, record.channelId, record.sequenceId].join(':')
   const collector = record.sourceType === 'COLLECTOR'
   const snapshot = record.sourceType === 'SNAPSHOT'
@@ -132,41 +141,38 @@
   if (snapshot && record.extensionMeasurementsJson) {
     try {
       const parsed = typeof record.extensionMeasurementsJson === 'string' ? JSON.parse(record.extensionMeasurementsJson) : record.extensionMeasurementsJson
-      extensionMeasurements = Object.entries(parsed as Record<string, any>).map(([code, value]) => `${code}=${value?.value ?? value?.metricValue ?? '-'}${value?.unit ? ` ${value.unit}` : ''}`).join('; ') || '-'
+      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({ id: identity, sourceType: record.sourceType || 'CABINET', metricCode: record.metricCode || '-', metricValue: extensionValue, unit: record.unit || '-', collectorCode: record.collectorCode || '-', 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 ? '-' : (record.current == null ? '-' : Number(record.current).toFixed(4)), capacity: collector ? '-' : (record.capacity == null ? '-' : Number(record.capacity).toFixed(4)), energy: collector ? '-' : (record.energy == null ? '-' : Number(record.energy).toFixed(4)), 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 || '-', sourceRecordId: record.sourceRecordId || '-', 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: record.capacity == null ? '-' : Number(record.capacity).toFixed(4), energy: record.energy == null ? '-' : Number(record.energy).toFixed(4), startVoltage: record.startVoltage == null ? '-' : Number(record.startVoltage).toFixed(4), endVoltage: record.endVoltage == null ? '-' : Number(record.endVoltage).toFixed(4), chargePeakVoltage: record.chargePeakVoltage == null ? '-' : Number(record.chargePeakVoltage).toFixed(4), dischargeValleyVoltage: record.dischargeValleyVoltage == null ? '-' : Number(record.dischargeValleyVoltage).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: record.chargeCapacity == null ? '-' : Number(record.chargeCapacity).toFixed(4), dischargeCapacity: record.dischargeCapacity == null ? '-' : Number(record.dischargeCapacity).toFixed(4), efficiency: record.efficiency == null ? '-' : Number(record.efficiency).toFixed(2), chargeEnergy: record.chargeEnergy == null ? '-' : Number(record.chargeEnergy).toFixed(4), dischargeEnergy: record.dischargeEnergy == null ? '-' : Number(record.dischargeEnergy).toFixed(4) })
+  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), sourceKind: cycleSourceLabels[String(record.sourceKind || 'DEVICE')] || CYCLE_SOURCE_UNKNOWN })
 }
 async function loadRecords() {
   loading.value = true
   loadError.value = ''
   try {
-    if (props.receptionStatus === 'NOT_STARTED') {
-      rows.value = []
-      stepRows.value = []
-      cycleRows.value = []
-      return
-    }
     const [records, steps, cycles] = await Promise.all([getPersistedTestRecords(props.testId), getPersistedTestSteps(props.testId), getPersistedTestCycles(props.testId)])
     // 后端查询接口已经返回当前有效数据,前端不再读取覆盖表或拼接伪造行。
     rows.value = records.map(mapRecord).sort((a, b) => b.absoluteTime.localeCompare(a.absoluteTime) || b.sequenceId - a.sequenceId)
     stepRows.value = steps.map(mapStep)
     cycleRows.value = cycles.map(mapCycle)
+    currentPage.value = Math.min(currentPage.value, Math.max(1, Math.ceil(filteredRows.value.length / pageSize.value)))
+    return true
   } catch (error) {
     rows.value = []
     stepRows.value = []
     cycleRows.value = []
     loadError.value = error instanceof Error ? error.message : '测试数据加载失败'
+    return false
   } finally {
     loading.value = false
   }
@@ -178,6 +184,11 @@
   ? [{ label: '扩展值', value: 'metricValue' as keyof DataRow }]
   : fieldOptions.value)
 const batteryOptions = computed(() => [...new Set(sourceRows.value.map(row => row.batteryNo).filter(Boolean))])
+// 最近数据时间取本测试已接收实时记录里最新的设备绝对时间,与展示刷新节奏无关。
+const latestRealtimeDataAt = computed(() => {
+  const times = rows.value.map(row => row.absoluteTime).filter(value => value && value !== '-')
+  return times.length ? times.reduce((latest, current) => (current > latest ? current : latest)) : '-'
+})
 const cycleOptions = computed(() => [...new Set(sourceRows.value.map(row => row.cycleNo))].sort((a, b) => a - b))
 const stepOptions = computed(() => [...new Map(sourceRows.value.map(row => [row.stepNo, row.stepName])).entries()].filter(item => item[0] > 0).sort((a, b) => a[0] - b[0]))
 const filteredRows = computed(() =>
@@ -215,22 +226,14 @@
 }
 const allPageSelected = computed(() => pagedRows.value.length > 0 && pagedRows.value.every(row => selectedIds.value.has(row.id)))
 const somePageSelected = computed(() => pagedRows.value.some(row => selectedIds.value.has(row.id)) && !allPageSelected.value)
-function goToImport() {
-  router.push({ path: `/test-list/${props.testId}/console/data/import`, query: { testName: props.testName, completed: props.canImport ? '1' : '0', dataType: dataType.value } })
+function goToImport(workbook = false) {
+  router.push({ path: `/test-list/${props.testId}/console/data/import`, query: { testName: props.testName, completed: props.canImport ? '1' : '0', dataType: dataType.value, ...(workbook ? { mode: 'workbook' } : {}) } })
 }
 function goToRevisions() {
   router.push({ path: '/data-revision', query: { testId: String(props.testId), dataType: dataType.value } })
 }
-function templateHeaders() {
-  return activeTab.value === 'cycle' ? ['记录编号', '电池号', '循环序号', '充放电容量(Ah)', '效率(%)', '能量(Wh)'] : activeTab.value === 'step' ? ['记录编号', '电池号', '循环序号', '工步序号', '工步', '工步时间', '容量(Ah)', '能量(Wh)', '起止电压(V)', '充电峰值电压(V)', '放电谷值电压(V)'] : ['记录编号', '电池号', '设备时间', '循环序号', '工步序号', '工步名称', '记录序号', '服务器接收时间', '电压(V)', '电流(A)', '容量(Ah)', '能量(Wh)']
-}
-function realtimeTemplateHeaders() {
-  return [...templateHeaders(), '温度(℃)', '正电位(V)', '负电位(V)']
-}
 function downloadTemplate() {
-  const worksheet = XLSX.utils.aoa_to_sheet([activeTab.value === 'realtime' ? realtimeTemplateHeaders() : templateHeaders()])
-  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('导入模板已下载')
 }
@@ -259,13 +262,19 @@
     /* 用户取消原因填写 */
   }
 }
+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() {
   if (
     singleEditing.value &&
     singleRow.value &&
     editorFieldOptions.value.some(field => {
       const value = singleRow.value?.[field.value as keyof DataRow]
-      return !String(value ?? '').trim() || !Number.isFinite(Number(value))
+      return !isEditableNumericValue(value, field.value)
     })
   ) {
     ElMessage.warning('请填写完整、有效的数据')
@@ -287,9 +296,13 @@
       return ids.map(id => {
         const before = originals.get(id)!
         const fields = row.sourceType === 'COLLECTOR' ? editorFieldOptions.value : fieldOptions.value
-        const values = Object.fromEntries(fields.map(item => [item.value, row[item.value as keyof DataRow]]))
-        const after = { ...before, ...values }
-        return { identity: id, before, after, override: { stableIdentity: id, fieldValues: JSON.stringify(values), deleted: false } }
+        const values = Object.fromEntries(fields
+          .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, 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 } }
       })
     })
     await submitConsoleDataAction(props.testId, { dataType: dataType.value, operationType: 'EDIT', scopeSummary: `按格修改${changed.length}条${activeTab.value}数据`, identitySummary: JSON.stringify(editItems.map(item => item.identity)), changesSummary: JSON.stringify(editItems.map(item => ({ identity: item.identity, before: item.before, after: item.after }))), reason: editForm.remark.trim(), overrides: editItems.map(item => item.override) })
@@ -316,15 +329,13 @@
   const physicalIds = ids
   let result: { value: string }
   try {
-    result = await ElMessageBox.prompt(`确认从当前有效视图移除 ${ids.length} 条${activeTab.value}数据?原始设备数据仍会保留。`, '移除数据', { inputPlaceholder: '请输入移除原因', inputValidator: value => (value.trim() ? true : '移除原因不能为空') })
+    result = await ElMessageBox.prompt(`确认物理删除 ${ids.length} 条${activeTab.value}数据?删除前内容会写入数据修订记录。`, '物理删除数据', { inputPlaceholder: '请输入删除原因', inputValidator: value => (value.trim() ? true : '删除原因不能为空') })
   } catch {
     return
   }
   try {
-    await submitConsoleDataAction(props.testId, { dataType: dataType.value, operationType: 'DELETE', scopeSummary: `从当前有效视图移除${ids.length}条${activeTab.value}数据`, identitySummary: JSON.stringify(physicalIds), changesSummary: null, reason: result.value.trim(), overrides: physicalIds.map(stableIdentity => ({ stableIdentity, fieldValues: null, deleted: true })) })
-    if (activeTab.value === 'realtime') rows.value = rows.value.filter(row => !ids.includes(row.id))
-    else if (activeTab.value === 'step') stepRows.value = stepRows.value.filter(row => !physicalIds.includes(row.id))
-    else cycleRows.value = cycleRows.value.filter(row => !ids.includes(row.id))
+    await submitConsoleDataAction(props.testId, { dataType: dataType.value, operationType: 'DELETE', scopeSummary: `物理删除${ids.length}条${activeTab.value}数据`, identitySummary: JSON.stringify(physicalIds), changesSummary: null, reason: result.value.trim() })
+    await loadRecords()
     selectedIds.value = new Set()
     ElMessage.success(`已从当前有效视图移除 ${ids.length} 条数据`)
   } catch (error) {
@@ -335,6 +346,8 @@
   if (activeTab.value === tab) return
   if (editing.value) cancelBatchEdit()
   activeTab.value = tab
+  selectedIds.value = new Set()
+  void router.replace({ query: { ...route.query, tab: 'data-list', dataType: dataType.value } })
   currentPage.value = 1
   // 切换数据子页时主动同步一次,除此之外不进行后台轮询。
   void loadRecords()
@@ -342,13 +355,19 @@
 function escapeCsv(value: unknown) {
   return `"${String(value ?? '').replaceAll('"', '""')}"`
 }
-function downloadCsv() {
+async function downloadCsv() {
+  if (!await loadRecords()) return
   if (!filteredRows.value.length) {
     ElMessage.warning('没有可下载的数据')
     return
   }
-  const headers = activeTab.value === 'cycle' ? ['记录编号', '电池', '循环号', '充电容量(Ah)', '放电容量(Ah)', '充放电效率(%)', '充电能量(Wh)', '放电能量(Wh)'] : activeTab.value === 'step' ? ['记录编号', '电池', '循环号', '工步序号', '工步', '工步时间', '容量(Ah)', '能量(Wh)', '起始电压(V)', '终止电压(V)', '充电峰值(V)', '放电谷值(V)'] : ['记录编号', '电池', '循环号', '实际工步号', '实际工步名称', '记录序号', '设备时间', '服务器接收时间', '电压(V)', '电流(A)', '容量(Ah)', '能量(Wh)', '温度(℃)', '正电位(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.chargePeakVoltage, row.dischargeValleyVoltage]) : filteredRows.value.map(row => [row.recordNo, row.batteryNo, row.cycleNo, row.stepNo, row.stepName, row.sequenceId, row.recordedAt, row.serverReceivedAt, row.voltage, row.current, row.capacity, row.energy, row.temperature, row.positivePotential, row.negativePotential])
+  const columns = consoleImportColumns[dataType.value]
+  // 导出与界面一致地带上来源列:循环的推导值不能在被导出后失去"聚合来源"这个事实;
+  // 实时的"来源定位"是设备侧自己的对照串(如厂商库名与记录号),导出后仍能拿它跟厂商软件核对。
+  const extraColumn = activeTab.value === 'cycle' ? { field: 'sourceKind' as const, label: '数据来源' }
+    : activeTab.value === 'realtime' ? { field: 'sourceRecordId' as const, label: '来源定位' } : null
+  const headers = [...Object.values(columns), ...(extraColumn ? [extraColumn.label] : [])]
+  const records = filteredRows.value.map(row => [...Object.keys(columns).map(field => row[field as keyof DataRow]), ...(extraColumn ? [row[extraColumn.field]] : [])])
   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')
@@ -357,7 +376,7 @@
   anchor.click()
   URL.revokeObjectURL(url)
 }
-// 数据列表进入时加载一次,后续由工具栏“刷新”按钮显式触发,避免实时状态重渲染引起重复请求。
+// 进入页面及接收状态变化时同步;操作后的刷新由各入口触发,不随每次实时快照重复查询。
 onMounted(loadRecords)
 watch(
   () => props.receptionStatus,
@@ -367,7 +386,7 @@
 )
 </script>
 <template>
-  <section class="console-data-panel">
+  <section class="console-data-panel" :class="activeTab">
     <header class="section-heading">
       <div><i></i><strong>数据列表</strong><span>TEST DATA</span></div>
       <nav class="data-tabs" aria-label="数据列表子页">
@@ -377,6 +396,9 @@
       </nav>
       <small>{{ loading ? '加载中...' : `共 ${filteredRows.length} 条` }}</small>
     </header>
+    <p v-if="activeTab === 'realtime'" class="realtime-hint">最近数据时间:{{ latestRealtimeDataAt }} · 设备约 60 秒产生一行新数据,展示刷新比它快是正常的</p>
+    <p v-if="activeTab === 'cycle' && hasAggregatedCycles" class="realtime-hint">来源标注「工步层聚合」表示设备没有提供循环汇总行,该循环由本系统按工步层同方向工步求和得出,不是设备事实;界面与导出都带这一列。</p>
+    <p v-if="(activeTab === 'realtime' || activeTab === 'step') && hasDerivedStepNames" class="realtime-hint">工步名称「充电 / 放电 / 搁置」是本系统按电流符号判定的方向,不是设备给的名称:该来源的工步方案里只有「步_N」标号与操作类型,没有描述性名称。</p>
     <div class="filter-grid">
       <label
         ><span>电池</span><el-select v-model="filters.batteryNo" clearable placeholder="全部"><el-option label="全部" value="" /><el-option v-for="item in batteryOptions" :key="item" :label="item" :value="item" /></el-select></label
@@ -396,10 +418,12 @@
     </div>
     <div class="data-toolbar">
       <div class="toolbar-group">
-        <button class="primary-button" type="button" @click="goToImport">
+        <button class="primary-button" type="button" @click="goToImport()">
           <el-icon><UploadFilled /></el-icon>导入</button
         ><button class="primary-button" type="button" @click="downloadTemplate">
           <el-icon><Download /></el-icon>下载模板</button
+        ><button class="primary-button" type="button" :disabled="!canImport || editing" @click="goToImport(true)">
+          <el-icon><UploadFilled /></el-icon>三 Sheet 一键导入</button
         ><button class="primary-button" type="button" @click="goToRevisions">
           <el-icon><Clock /></el-icon>修订记录</button
         ><button v-if="!editing" class="primary-button" type="button" @click="openBatchEditor">
@@ -411,7 +435,7 @@
         ><button class="danger-button" type="button" :disabled="editing" @click="deleteSelected">删除</button>
       </div>
       <span v-if="loadError" class="load-error">{{ loadError }}</span
-      ><span v-else>{{ editing ? '编辑模式:仅允许修改当前页规定字段,保存后填写原因并一次提交' : `仅展示当前测试已接收的 ${activeTab === 'realtime' ? 'record' : activeTab === 'step' ? 'step' : 'cycle'} 数据` }}</span>
+      ><span v-else>{{ editing ? '编辑模式:仅允许修改当前页规定字段,保存后填写原因并一次提交' : activeTab === 'realtime' ? '实时采样记录 · 已持久化 · 当前有效值' : `${activeTab === 'step' ? '工步' : '循环'}记录 · 已持久化 · 当前有效值` }}</span>
       <div class="toolbar-group">
         <button class="secondary-button" type="button" :disabled="loading || editing" @click="loadRecords">
           <el-icon><Refresh /></el-icon>刷新</button
@@ -423,57 +447,13 @@
     <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>温度(℃)</th>
-            <th>正电位(V)</th>
-            <th>负电位(V)</th>
-            <th>电压(V)</th>
-            <th>电流(A)</th>
-            <th>容量(Ah)</th>
-            <th>能量(Wh)</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>服务器接收时间</th>
-            <th>工步时间</th>
-            <th>容量(Ah)</th>
-            <th>能量(Wh)</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>设备时间</th>
-            <th>服务器接收时间</th>
-            <th>充电容量(Ah)</th>
-            <th>放电容量(Ah)</th>
-            <th>充放电效率(%)</th>
-            <th>充电能量(Wh)</th>
-            <th>放电能量(Wh)</th>
+            <th v-for="(label, field) in consoleImportColumns[dataType]" :key="field">{{ label }}</th>
+            <th v-if="activeTab === 'cycle'" :title="cycleSourceTitle">数据来源</th>
+            <th v-if="activeTab === 'realtime'" title="设备侧的对照串(厂商库名与记录号等),用于跟厂商软件核对">来源定位</th>
             <th class="action-column">操作</th>
           </tr>
         </thead>
@@ -491,12 +471,7 @@
               <td>{{ row.stepNo }}</td>
               <td>{{ row.stepName }}</td>
               <td>{{ row.sequenceId }}</td>
-              <td>{{ row.recordedAt }}</td>
-              <td>{{ row.serverReceivedAt }}</td>
-              <td>{{ row.sourceType === 'COLLECTOR' ? `采集器 ${row.collectorCode}` : row.sourceType === 'SNAPSHOT' ? '检测柜 + 采集器' : '检测柜' }}<small v-if="row.sourceType === 'COLLECTOR'">{{ row.channelCode }}</small></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>
@@ -513,6 +488,11 @@
                 <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="source-cell" :title="row.sourceRecordId">{{ row.sourceRecordId }}</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>
@@ -526,9 +506,8 @@
                 <strong>{{ row.batteryNo }}</strong>
               </td>
               <td>{{ row.cycleNo }}</td>
-              <td>{{ row.stepNo }} · {{ row.stepName }}</td>
-              <td>{{ row.recordedAt }}</td>
-              <td>{{ row.serverReceivedAt }}</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>
@@ -550,12 +529,12 @@
                 <span v-else>{{ row.endVoltage }}</span>
               </td>
               <td>
-                <el-input v-if="editing && draftRows[row.id]" v-model="draftRows[row.id].chargePeakVoltage" size="small" />
-                <span v-else>{{ row.chargePeakVoltage }}</span>
+                <el-input v-if="editing && draftRows[row.id]" v-model="draftRows[row.id].chargeMedianVoltage" size="small" />
+                <span v-else>{{ row.chargeMedianVoltage }}</span>
               </td>
               <td>
-                <el-input v-if="editing && draftRows[row.id]" v-model="draftRows[row.id].dischargeValleyVoltage" size="small" />
-                <span v-else>{{ row.dischargeValleyVoltage }}</span>
+                <el-input v-if="editing && draftRows[row.id]" v-model="draftRows[row.id].dischargeMedianVoltage" size="small" />
+                <span v-else>{{ row.dischargeMedianVoltage }}</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>
@@ -570,8 +549,6 @@
                 <strong>{{ row.batteryNo }}</strong>
               </td>
               <td>{{ row.cycleNo }}</td>
-              <td>{{ row.recordedAt }}</td>
-              <td>{{ row.serverReceivedAt }}</td>
               <td>
                 <el-input v-if="editing && draftRows[row.id]" v-model="draftRows[row.id].chargeCapacity" size="small" />
                 <span v-else>{{ row.chargeCapacity }}</span>
@@ -592,13 +569,14 @@
                 <el-input v-if="editing && draftRows[row.id]" v-model="draftRows[row.id].dischargeEnergy" size="small" />
                 <span v-else>{{ row.dischargeEnergy }}</span>
               </td>
+              <td :title="cycleSourceTitle">{{ row.sourceKind }}</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>
             </tr></template
           >
           <tr v-if="!loading && !pagedRows.length">
-            <td :colspan="activeTab === 'realtime' ? 19 : activeTab === 'step' ? 15 : 12" class="empty-cell">
+            <td :colspan="activeTab === 'realtime' ? 18 : activeTab === 'step' ? 14 : 11" class="empty-cell">
               {{ loadError || '暂无检测柜记录' }}
             </td>
           </tr>
@@ -632,7 +610,7 @@
             <dd>{{ singleRow.stepName }}</dd>
           </div>
           <div>
-            <dt>记录时间</dt>
+            <dt>绝对时间</dt>
             <dd>{{ singleRow.recordedAt }}</dd>
           </div>
           <div>
@@ -698,6 +676,14 @@
 .section-heading strong {
   color: var(--ink);
   font-size: 14px;
+}
+.realtime-hint {
+  margin: 0;
+  padding: 9px 16px;
+  border-bottom: 1px solid var(--line);
+  color: #71808d;
+  background: #f8fbfe;
+  font-size: 11px;
 }
 .section-heading span {
   color: #81909d;
@@ -815,7 +801,12 @@
 }
 .data-toolbar > div {
   display: flex;
+  flex-wrap: wrap;
+  max-width: 100%;
   gap: 8px;
+}
+.data-toolbar button {
+  white-space: nowrap;
 }
 .load-error {
   color: #b34f4f;
@@ -854,6 +845,30 @@
   font-family: 'Cascadia Mono', Consolas, monospace;
   font-size: 11px;
 }
+.source-column {
+  width: 280px;
+  white-space: normal;
+  overflow-wrap: anywhere;
+}
+/* 来源定位是设备侧的对照串(可能较长),窄列里允许换行显示 */
+.source-cell {
+  width: 220px;
+  font-family: 'Cascadia Mono', Consolas, monospace;
+  font-size: 11px;
+  white-space: normal;
+  overflow-wrap: anywhere;
+}
+.console-data-panel.realtime table {
+  min-width: 2100px;
+}
+.console-data-panel.realtime th:nth-child(8),
+.console-data-panel.realtime th:nth-child(13) {
+  width: 185px;
+}
+.source-column small {
+  display: block;
+  line-height: 1.6;
+}
 .empty-cell {
   height: 150px;
   color: #8795a3;
@@ -875,6 +890,20 @@
   --el-pagination-hover-color: var(--blue);
 }
 @media (max-width: 900px) {
+  .section-heading {
+    height: auto;
+    min-height: 90px;
+    flex-wrap: wrap;
+    gap: 8px;
+    padding-top: 10px;
+    padding-bottom: 10px;
+  }
+  .data-tabs {
+    position: static;
+    order: 3;
+    flex-basis: 100%;
+    transform: none;
+  }
   .filter-grid {
     grid-template-columns: repeat(2, minmax(0, 1fr));
   }
@@ -1033,7 +1062,7 @@
     grid-template-columns: 1fr;
   }
 }
-/* 三个数据子页使用连续分段控件,保持当前页在标题栏中央突出显示。 */
+/* 数据子页沿用连续分段控件;窄屏另起一行,避免覆盖标题。 */
 .data-tabs {
   width: 228px;
   gap: 0;

--
Gitblit v1.7.1