From 3721379cf11a47d36dab3c37f20766ab007cc3cd Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期四, 08 十月 2026 13:20:48 +0800
Subject: [PATCH] 实时数据列表新增来源定位列

---
 src/components/ConsoleDataListPanel.vue | 1145 +++++++++++++++++++++++++++++++++++++++++++++++++++++---
 1 files changed, 1,078 insertions(+), 67 deletions(-)

diff --git a/src/components/ConsoleDataListPanel.vue b/src/components/ConsoleDataListPanel.vue
index e9d5384..74a7f33 100644
--- a/src/components/ConsoleDataListPanel.vue
+++ b/src/components/ConsoleDataListPanel.vue
@@ -1,98 +1,1109 @@
 <script setup lang="ts">
-import { computed, onMounted, reactive, ref } from 'vue'
+import { computed, onMounted, reactive, ref, watch } from 'vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
 import { useRoute, useRouter } from 'vue-router'
 import * as XLSX from 'xlsx'
 import { Download, EditPen, Refresh, Search, UploadFilled, Clock, Check } from '@element-plus/icons-vue'
-import { getBtsCycles, getBtsRecords, getBtsSteps, type BtsCycleRecord, type BtsRecord, type BtsStepRecord } from '@/api/tests'
-import { getConsoleDataOverrides, submitConsoleDataAction, type ConsoleDataType } from '@/api/consoleData'
+import { getPersistedTestCycles, getPersistedTestRecords, getPersistedTestSteps, type PersistedTestCycle, type PersistedTestRecord, type PersistedTestStep } from '@/api/tests'
+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 { no: string }
-interface FilterState { batteryNo: string; dateRange: [string, string] | null; cycleNo: string; stepNo: string }
-interface DataRow { id: string; recordNo: string; batteryNo: string; cycleNo: number; stepNo: number; stepName: string; sequenceId: number; recordedAt: string; absoluteTime: string; stepDuration: string; startVoltage: string; endVoltage: string; chargePeakVoltage: string; dischargeValleyVoltage: string; voltage: string; current: string; capacity: string; energy: string; chargeCapacity: string; dischargeCapacity: string; efficiency: string; chargeEnergy: string; dischargeEnergy: string }
-const props = defineProps<{ testId: number; testName: string; cells: ConsoleCell[]; canImport?: boolean; bindings?: Array<{ batteryNo: string; devid?: number | null; chlid?: number | null }> }>()
-const route = useRoute(); const router = useRouter()
+interface ConsoleCell {
+  no: string
+}
+interface FilterState {
+  batteryNo: string
+  dateRange: [string, string] | null
+  cycleNo: string
+  stepNo: string
+}
+interface DataRow {
+  id: string
+  sourceType: string
+  metricCode: string
+  metricValue: string
+  unit: string
+  collectorCode: string
+  channelCode: string
+  recordNo: string
+  batteryNo: string
+  cycleNo: number
+  stepNo: number
+  stepName: string
+  actualStepLabel: string
+  sequenceId: number
+  recordedAt: string
+  relativeTimeSeconds: string
+  absoluteTime: string
+  serverReceivedAt: string
+  stepDuration: string
+  startVoltage: string
+  endVoltage: string
+  chargeMedianVoltage: string
+  dischargeMedianVoltage: string
+  voltage: string
+  current: string
+  capacity: string
+  energy: string
+  temperature: string
+  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()
+const router = useRouter()
 const requestedDataType = String(route.query.dataType || 'REALTIME')
-const activeTab = ref<DataTab>(requestedDataType === 'STEP' ? 'step' : requestedDataType === 'CYCLE' ? 'cycle' : 'realtime'); const loading = ref(false); const loadError = ref(''); const rows = ref<DataRow[]>([]); const stepRows = ref<DataRow[]>([]); const cycleRows = ref<DataRow[]>([])
-const dataTabs = [{ key: 'realtime', label: '实时' }, { key: 'step', label: '工步' }, { key: 'cycle', label: '循环' }] as const
-const filters = reactive<FilterState>({ batteryNo: '', dateRange: null, cycleNo: '', stepNo: '' }); const appliedFilters = ref<FilterState>({ batteryNo: '', dateRange: null, cycleNo: '', stepNo: '' }); const currentPage = ref(1); const pageSize = ref(20)
-const selectedIds = ref<Set<string>>(new Set()); const editorVisible = ref(false); const editing = ref(false); const singleEditing = ref(false); const draftRows = ref<Record<string, DataRow>>({}); const editForm = reactive({ field: 'voltage' as keyof DataRow, value: '', remark: '' })
-const dataType = computed<ConsoleDataType>(() => activeTab.value === 'step' ? 'STEP' : activeTab.value === 'cycle' ? 'CYCLE' : 'REALTIME')
-const fieldOptions = computed(() => activeTab.value === 'realtime'
-  ? [{ label: '电压(V)', value: 'voltage' }, { label: '电流(A)', value: 'current' }, { label: '容量(Ah)', value: 'capacity' }, { label: '能量(Wh)', value: 'energy' }]
-  : activeTab.value === 'step'
-    ? [{ label: '工步时间', value: 'stepDuration' }, { label: '容量(Ah)', value: 'capacity' }, { label: '能量(Wh)', value: 'energy' }, { label: '起始电压(V)', value: 'startVoltage' }, { label: '终止电压(V)', value: 'endVoltage' }, { label: '充电峰值电压(V)', value: 'chargePeakVoltage' }, { label: '放电谷值电压(V)', value: 'dischargeValleyVoltage' }]
-    : [{ label: '充电容量(Ah)', value: 'chargeCapacity' }, { label: '放电容量(Ah)', value: 'dischargeCapacity' }, { label: '效率(%)', value: 'efficiency' }, { label: '充电能量(Wh)', value: 'chargeEnergy' }, { label: '放电能量(Wh)', value: 'dischargeEnergy' }])
-function formatTime(value: string) { return value ? value.replace('T', ' ').replace('Z', '').replace(/\.\d{3,6}$/, '') : '-' }
-function emptyRow(base: Partial<DataRow>): DataRow { return { id: '', recordNo: '', batteryNo: '', cycleNo: 0, stepNo: 0, stepName: '', sequenceId: 0, recordedAt: '', absoluteTime: '', stepDuration: '-', startVoltage: '-', endVoltage: '-', chargePeakVoltage: '-', dischargeValleyVoltage: '-', voltage: '-', current: '-', capacity: '-', energy: '-', chargeCapacity: '-', dischargeCapacity: '-', efficiency: '-', chargeEnergy: '-', dischargeEnergy: '-', ...base } }
-function mapRecord(record: BtsRecord, batteryNo: string, deviceId: number, channelId: number): DataRow { const timestamp = formatTime(record.deviceTime); return emptyRow({ id: `${deviceId}:${channelId}:${record.sequenceId}`, recordNo: `RD-${deviceId}-${channelId}-${String(record.sequenceId).padStart(6, '0')}`, batteryNo: batteryNo || '-', cycleNo: record.cycleNo, stepNo: record.stepNo, stepName: record.stepName, sequenceId: record.sequenceId, recordedAt: timestamp, absoluteTime: timestamp, voltage: Number(record.voltageV).toFixed(4), current: Number(record.currentA).toFixed(4), capacity: Number(record.capacityAh).toFixed(4), energy: Number(record.energyWh).toFixed(4) }) }
-function mapStep(record: BtsStepRecord, batteryNo: string, deviceId: number, channelId: number): DataRow { const startedAt = formatTime(record.startedAt); return emptyRow({ id: `step:${deviceId}:${channelId}:${record.cycleNo}:${record.stepNo}`, recordNo: `STEP-${deviceId}-${channelId}-${record.cycleNo}-${record.stepNo}`, batteryNo, cycleNo: record.cycleNo, stepNo: record.stepNo, stepName: record.stepName, sequenceId: record.recordCount, recordedAt: startedAt, absoluteTime: startedAt, stepDuration: String(record.durationSeconds), capacity: Number(record.capacityAh).toFixed(4), energy: Number(record.energyWh).toFixed(4), startVoltage: Number(record.startVoltageV).toFixed(4), endVoltage: Number(record.endVoltageV).toFixed(4), chargePeakVoltage: record.chargePeakVoltageV == null ? '-' : Number(record.chargePeakVoltageV).toFixed(4), dischargeValleyVoltage: record.dischargeValleyVoltageV == null ? '-' : Number(record.dischargeValleyVoltageV).toFixed(4) }) }
-function mapCycle(record: BtsCycleRecord, batteryNo: string, deviceId: number, channelId: number): DataRow { const startedAt = formatTime(record.startedAt); return emptyRow({ id: `cycle:${deviceId}:${channelId}:${record.cycleNo}`, recordNo: record.recordNo, batteryNo, cycleNo: record.cycleNo, sequenceId: record.recordCount, recordedAt: startedAt, absoluteTime: startedAt, chargeCapacity: Number(record.chargeCapacityAh).toFixed(4), dischargeCapacity: Number(record.dischargeCapacityAh).toFixed(4), efficiency: Number(record.efficiency).toFixed(2), chargeEnergy: Number(record.chargeEnergyWh).toFixed(4), dischargeEnergy: Number(record.dischargeEnergyWh).toFixed(4) }) }
-function applyOverrides(list: DataRow[], overrides: Awaited<ReturnType<typeof getConsoleDataOverrides>>) { const byIdentity = new Map(overrides.map(item => [item.stableIdentity, item])); const known = new Set(list.map(row => row.id)); const result = list.flatMap(row => { const override = byIdentity.get(row.id); if (!override) return [row]; if (override.deleted) return []; try { return [{ ...row, ...(override.fieldValues ? JSON.parse(override.fieldValues) : {}) }] } catch { return [row] } }); overrides.filter(item => !known.has(item.stableIdentity) && !item.deleted && item.fieldValues).forEach(item => { try { result.push(emptyRow({ ...(JSON.parse(item.fieldValues || '{}')), id: item.stableIdentity, recordNo: item.stableIdentity })) } catch { /* 忽略无法解析的历史覆盖 */ } }); return result }
-async function loadRecords() { loading.value = true; loadError.value = ''; try { const bindings = (props.bindings || []).filter(item => Number.isFinite(item.devid) && Number.isFinite(item.chlid)); const batches = await Promise.all(bindings.map(async binding => { const deviceId = Number(binding.devid); const channelId = Number(binding.chlid); const [records, steps, cycles] = await Promise.all([getBtsRecords(deviceId, channelId), getBtsSteps(deviceId, channelId), getBtsCycles(deviceId, channelId)]); return { records: records.map(item => mapRecord(item, binding.batteryNo, deviceId, channelId)), steps: steps.map(item => mapStep(item, binding.batteryNo, deviceId, channelId)), cycles: cycles.map(item => mapCycle(item, binding.batteryNo, deviceId, channelId)) } })); const overrides = await Promise.all((['REALTIME', 'STEP', 'CYCLE'] as ConsoleDataType[]).map(type => getConsoleDataOverrides(props.testId, type))); rows.value = applyOverrides(batches.flatMap(item => item.records), overrides[0]).sort((a, b) => b.absoluteTime.localeCompare(a.absoluteTime) || b.sequenceId - a.sequenceId); stepRows.value = applyOverrides(batches.flatMap(item => item.steps), overrides[1]); cycleRows.value = applyOverrides(batches.flatMap(item => item.cycles), overrides[2]) } catch (error) { rows.value = []; stepRows.value = []; cycleRows.value = []; loadError.value = error instanceof Error ? error.message : '检测柜记录加载失败' } finally { loading.value = false } }
-const sourceRows = computed(() => activeTab.value === 'step' ? stepRows.value : activeTab.value === 'cycle' ? cycleRows.value : rows.value)
+const activeTab = ref<DataTab>(requestedDataType === 'STEP' ? 'step' : requestedDataType === 'CYCLE' ? 'cycle' : 'realtime')
+const loading = ref(false)
+const loadError = ref('')
+const rows = ref<DataRow[]>([])
+const stepRows = ref<DataRow[]>([])
+const cycleRows = ref<DataRow[]>([])
+const dataTabs = [
+  { key: 'realtime', label: '实时' },
+  { 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))
+const filters = reactive<FilterState>({ batteryNo: '', dateRange: null, cycleNo: '', stepNo: '' })
+const appliedFilters = ref<FilterState>({ batteryNo: '', dateRange: null, cycleNo: '', stepNo: '' })
+const currentPage = ref(1)
+const pageSize = ref(20)
+const selectedIds = ref<Set<string>>(new Set())
+const editorVisible = ref(false)
+const editing = ref(false)
+const singleEditing = ref(false)
+const draftRows = ref<Record<string, DataRow>>({})
+const editForm = reactive({ field: 'voltage' as keyof DataRow, value: '', remark: '' })
+const dataType = computed<ConsoleDataType>(() => (activeTab.value === 'step' ? 'STEP' : activeTab.value === 'cycle' ? 'CYCLE' : 'REALTIME'))
+const fieldOptions = computed(() =>
+  activeTab.value === 'realtime'
+      ? [
+          { label: '电压(V)', value: 'voltage' },
+          { 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: '工步时间(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: 'chargeMedianVoltage' },
+          { label: '放电中值电压(V)', value: 'dischargeMedianVoltage' },
+        ]
+      : [
+          { label: '充电容量(mAh)', value: 'chargeCapacity' },
+          { label: '放电容量(mAh)', value: 'dischargeCapacity' },
+          { label: '效率(%)', value: 'efficiency' },
+          { label: '充电能量(mWh)', value: 'chargeEnergy' },
+          { label: '放电能量(mWh)', value: 'dischargeEnergy' },
+        ],
+)
+function emptyRow(base: Partial<DataRow>): DataRow {
+  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 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'
+  const metricCode = String(record.metricCode || '').toUpperCase()
+  const extensionValue = record.metricValue == null ? '-' : String(record.metricValue)
+  const extension = (name: string) => collector && metricCode.includes(name) ? extensionValue : '-'
+  let extensionMeasurements = '-'
+  if (snapshot && record.extensionMeasurementsJson) {
+    try {
+      const parsed = typeof record.extensionMeasurementsJson === 'string' ? JSON.parse(record.extensionMeasurementsJson) : record.extensionMeasurementsJson
+      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 || '-', 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: 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: 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 {
+    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
+  }
+}
+// 数据列表只展示后端持久化的 record、step、cycle 事实;逻辑工步合并留给统计图表等派生视图。
+const sourceRows = computed(() => (activeTab.value === 'step' ? stepRows.value : activeTab.value === 'cycle' ? cycleRows.value : rows.value))
 const singleRow = computed(() => Object.values(draftRows.value)[0] || null)
-const batteryOptions = computed(() => [...new Set(sourceRows.value.map(row => row.batteryNo).filter(Boolean))]); const cycleOptions = computed(() => [...new Set(sourceRows.value.map(row => row.cycleNo))].sort((a, b) => a - b)); 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(() => sourceRows.value.filter(row => { const query = appliedFilters.value; if (query.batteryNo && row.batteryNo !== query.batteryNo) return false; if (query.cycleNo && row.cycleNo !== Number(query.cycleNo)) return false; if (activeTab.value !== 'cycle' && query.stepNo && row.stepNo !== Number(query.stepNo)) return false; if (query.dateRange) { const date = row.absoluteTime.slice(0, 10); if (date < query.dateRange[0] || date > query.dateRange[1]) return false } return true }))
+const editorFieldOptions = computed(() => singleRow.value?.sourceType === 'COLLECTOR'
+  ? [{ 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(() =>
+  sourceRows.value.filter(row => {
+    const query = appliedFilters.value
+    if (query.batteryNo && row.batteryNo !== query.batteryNo) return false
+    if (query.cycleNo && row.cycleNo !== Number(query.cycleNo)) return false
+    if (activeTab.value !== 'cycle' && query.stepNo && row.stepNo !== Number(query.stepNo)) return false
+    if (query.dateRange) {
+      const date = row.absoluteTime.slice(0, 10)
+      if (date < query.dateRange[0] || date > query.dateRange[1]) return false
+    }
+    return true
+  }),
+)
 const pagedRows = computed(() => filteredRows.value.slice((currentPage.value - 1) * pageSize.value, currentPage.value * pageSize.value))
-function applySearch() { appliedFilters.value = { batteryNo: filters.batteryNo, dateRange: filters.dateRange ? [...filters.dateRange] as [string, string] : null, cycleNo: filters.cycleNo, stepNo: filters.stepNo }; currentPage.value = 1 }
-function resetSearch() { Object.assign(filters, { batteryNo: '', dateRange: null, cycleNo: '', stepNo: '' }); Object.assign(appliedFilters.value, { batteryNo: '', dateRange: null, cycleNo: '', stepNo: '' }); currentPage.value = 1 }
-function toggleRowSelection(id: string, checked: boolean) { const next = new Set(selectedIds.value); checked ? next.add(id) : next.delete(id); selectedIds.value = next }
-function togglePageSelection(checked: boolean) { const next = new Set(selectedIds.value); pagedRows.value.forEach(row => checked ? next.add(row.id) : next.delete(row.id)); selectedIds.value = next }
+function applySearch() {
+  appliedFilters.value = { batteryNo: filters.batteryNo, dateRange: filters.dateRange ? ([...filters.dateRange] as [string, string]) : null, cycleNo: filters.cycleNo, stepNo: filters.stepNo }
+  currentPage.value = 1
+}
+function resetSearch() {
+  Object.assign(filters, { batteryNo: '', dateRange: null, cycleNo: '', stepNo: '' })
+  Object.assign(appliedFilters.value, { batteryNo: '', dateRange: null, cycleNo: '', stepNo: '' })
+  currentPage.value = 1
+}
+function toggleRowSelection(id: string, checked: boolean) {
+  const next = new Set(selectedIds.value)
+  checked ? next.add(id) : next.delete(id)
+  selectedIds.value = next
+}
+function togglePageSelection(checked: boolean) {
+  const next = new Set(selectedIds.value)
+  pagedRows.value.forEach(row => (checked ? next.add(row.id) : next.delete(row.id)))
+  selectedIds.value = next
+}
 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 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 downloadTemplate() { const worksheet = XLSX.utils.aoa_to_sheet([templateHeaders()]); const workbook = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(workbook, worksheet, `${activeTab.value}模板`); XLSX.writeFile(workbook, `测试${activeTab.value}数据导入模板.xlsx`); ElMessage.success('导入模板已下载') }
-function openBatchEditor() { singleEditing.value = false; editing.value = true; draftRows.value = Object.fromEntries(sourceRows.value.map(row => [row.id, { ...row }])) }
-function openRowEditor(row: DataRow) { singleEditing.value = true; editing.value = false; draftRows.value = { [row.id]: { ...row } }; selectedIds.value = new Set([row.id]); editForm.field = String(fieldOptions.value[0]?.value || 'voltage') as keyof DataRow; editForm.value = String(row[editForm.field] ?? ''); editForm.remark = ''; editorVisible.value = true }
-async function saveBatchEdit() { if (!Object.keys(draftRows.value).length) return; try { const result = await ElMessageBox.prompt('请输入本次修改原因', '提交数据修改', { inputPlaceholder: '必填,将写入数据修订记录', inputValidator: value => value.trim() ? true : '修改原因不能为空' }); editForm.remark = result.value.trim(); await submitBatchEdit() } catch { /* 用户取消原因填写 */ } }
-async function submitBatchEdit() { if (singleEditing.value && singleRow.value && fieldOptions.value.some(field => { const value = singleRow.value?.[field.value as keyof DataRow]; return !String(value ?? '').trim() || !Number.isFinite(Number(value)) })) { ElMessage.warning('请填写完整、有效的数据'); return } if (!editForm.remark.trim()) { ElMessage.warning('请填写修改原因'); return }; const originals = new Map(sourceRows.value.map(row => [row.id, row])); const changed = Object.values(draftRows.value).filter(row => JSON.stringify(originals.get(row.id)) !== JSON.stringify(row)); if (!changed.length) { ElMessage.warning('没有检测到修改'); return }; try { await submitConsoleDataAction(props.testId, { dataType: dataType.value, operationType: 'EDIT', scopeSummary: `修改${changed.length}条${activeTab.value}数据`, identitySummary: JSON.stringify(changed.map(row => row.id)), changesSummary: JSON.stringify(changed.map(row => ({ identity: row.id, before: originals.get(row.id), after: row }))), reason: editForm.remark.trim(), overrides: changed.map(row => ({ stableIdentity: row.id, fieldValues: JSON.stringify(Object.fromEntries(fieldOptions.value.map(item => [item.value, row[item.value as keyof DataRow]]))), deleted: false })) }); editorVisible.value = false; cancelBatchEdit(); await loadRecords(); ElMessage.success(`已提交 ${changed.length} 条数据修改`) } catch (error) { ElMessage.error(error instanceof Error ? error.message : '数据修改提交失败') } }
-function cancelBatchEdit() { editing.value = false; singleEditing.value = false; draftRows.value = {}; selectedIds.value = new Set() }
-async function deleteSelected(requestedIds?: string[] | Event) { const ids = Array.isArray(requestedIds) && requestedIds.length ? requestedIds : [...selectedIds.value]; if (!ids.length) { ElMessage.warning('请先勾选需要删除的数据'); return }; let result: { value: string }; try { 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(ids), changesSummary: null, reason: result.value.trim(), overrides: ids.map(stableIdentity => ({ stableIdentity, fieldValues: null, deleted: true })) }); const target = sourceRows.value; const kept = target.filter(row => !ids.includes(row.id)); if (activeTab.value === 'realtime') rows.value = kept; else if (activeTab.value === 'step') stepRows.value = kept; else cycleRows.value = kept; selectedIds.value = new Set(); ElMessage.success(`已物理删除 ${ids.length} 条数据`) } catch (error) { ElMessage.error(error instanceof Error ? error.message : '数据删除失败') } }
+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 downloadTemplate() {
+  const workbook = createConsoleImportWorkbook(dataType.value)
+  XLSX.writeFile(workbook, `测试${activeTab.value}数据导入模板.xlsx`)
+  ElMessage.success('导入模板已下载')
+}
+function openBatchEditor() {
+  singleEditing.value = false
+  editing.value = true
+  draftRows.value = Object.fromEntries(sourceRows.value.map(row => [row.id, { ...row }]))
+}
+function openRowEditor(row: DataRow) {
+  singleEditing.value = true
+  editing.value = false
+  draftRows.value = { [row.id]: { ...row } }
+  selectedIds.value = new Set([row.id])
+  editForm.field = String(editorFieldOptions.value[0]?.value || 'voltage') as keyof DataRow
+  editForm.value = String(row[editForm.field] ?? '')
+  editForm.remark = ''
+  editorVisible.value = true
+}
+async function saveBatchEdit() {
+  if (!Object.keys(draftRows.value).length) return
+  try {
+    const result = await ElMessageBox.prompt('请输入本次修改原因', '提交数据修改', { inputPlaceholder: '必填,将写入数据修订记录', inputValidator: value => (value.trim() ? true : '修改原因不能为空') })
+    editForm.remark = result.value.trim()
+    await submitBatchEdit()
+  } catch {
+    /* 用户取消原因填写 */
+  }
+}
+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 !isEditableNumericValue(value, field.value)
+    })
+  ) {
+    ElMessage.warning('请填写完整、有效的数据')
+    return
+  }
+  if (!editForm.remark.trim()) {
+    ElMessage.warning('请填写修改原因')
+    return
+  }
+  const originals = new Map(sourceRows.value.map(row => [row.id, row]))
+  const changed = Object.values(draftRows.value).filter(row => JSON.stringify(originals.get(row.id)) !== JSON.stringify(row))
+  if (!changed.length) {
+    ElMessage.warning('没有检测到修改')
+    return
+  }
+  try {
+    const editItems = changed.flatMap(row => {
+      const ids = [row.id]
+      return ids.map(id => {
+        const before = originals.get(id)!
+        const fields = row.sourceType === 'COLLECTOR' ? editorFieldOptions.value : fieldOptions.value
+        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) })
+    editorVisible.value = false
+    cancelBatchEdit()
+    await loadRecords()
+    ElMessage.success(`已提交 ${changed.length} 条数据修改`)
+  } catch (error) {
+    ElMessage.error(error instanceof Error ? error.message : '数据修改提交失败')
+  }
+}
+function cancelBatchEdit() {
+  editing.value = false
+  singleEditing.value = false
+  draftRows.value = {}
+  selectedIds.value = new Set()
+}
+async function deleteSelected(requestedIds?: string[] | Event) {
+  const ids = Array.isArray(requestedIds) && requestedIds.length ? requestedIds : [...selectedIds.value]
+  if (!ids.length) {
+    ElMessage.warning('请先勾选需要删除的数据')
+    return
+  }
+  const physicalIds = ids
+  let result: { value: string }
+  try {
+    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() })
+    await loadRecords()
+    selectedIds.value = new Set()
+    ElMessage.success(`已从当前有效视图移除 ${ids.length} 条数据`)
+  } catch (error) {
+    ElMessage.error(error instanceof Error ? error.message : '数据删除失败')
+  }
+}
 function changeTab(tab: DataTab) {
   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()
 }
-function escapeCsv(value: unknown) { return `"${String(value ?? '').replaceAll('"', '""')}"` }
-function downloadCsv() { if (!filteredRows.value.length) { ElMessage.warning('没有可下载的数据'); return } const headers = activeTab.value === 'cycle' ? ['记录编号', '电池', '循环号', '充电容量(Ah)', '放电容量(Ah)', '充放电效率(%)', '充电能量(Wh)', '放电能量(Wh)'] : ['记录编号', '电池', '循环号', '实际工步号', '实际工步名称', '记录序号', '记录时间', '绝对时间', '电压(V)', '电流(A)', '容量(Ah)', '能量(Wh)']; const records = activeTab.value === 'cycle' ? filteredRows.value.map(row => [row.recordNo, row.batteryNo, row.cycleNo, row.chargeCapacity, row.dischargeCapacity, row.efficiency, row.chargeEnergy, row.dischargeEnergy]) : filteredRows.value.map(row => [row.recordNo, row.batteryNo, row.cycleNo, row.stepNo, row.stepName, row.sequenceId, row.recordedAt, row.absoluteTime, row.voltage, row.current, row.capacity, row.energy]); const blob = new Blob([`\uFEFF${[headers, ...records].map(item => item.map(escapeCsv).join(',')).join('\n')}`], { type: 'text/csv;charset=utf-8' }); const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = `${props.testName}-${activeTab.value}.csv`; anchor.click(); URL.revokeObjectURL(url) }
-// 数据列表进入时加载一次,后续由工具栏“刷新”按钮显式触发,避免实时状态重渲染引起重复请求。
+function escapeCsv(value: unknown) {
+  return `"${String(value ?? '').replaceAll('"', '""')}"`
+}
+async function downloadCsv() {
+  if (!await loadRecords()) return
+  if (!filteredRows.value.length) {
+    ElMessage.warning('没有可下载的数据')
+    return
+  }
+  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')
+  anchor.href = url
+  anchor.download = `${props.testName}-${activeTab.value}.csv`
+  anchor.click()
+  URL.revokeObjectURL(url)
+}
+// 进入页面及接收状态变化时同步;操作后的刷新由各入口触发,不随每次实时快照重复查询。
 onMounted(loadRecords)
+watch(
+  () => props.receptionStatus,
+  (next, previous) => {
+    if (next !== previous) void loadRecords()
+  },
+)
 </script>
 <template>
-  <section class="console-data-panel">
-    <header class="section-heading"><div><i></i><strong>数据列表</strong><span>TEST DATA</span></div><nav class="data-tabs" aria-label="数据列表子页"><button v-for="tab in dataTabs" :key="tab.key" type="button" :class="{ active: activeTab === tab.key }" :aria-current="activeTab === tab.key ? 'page' : undefined" @click="changeTab(tab.key)">{{ tab.label }}</button></nav><small>{{ loading ? '加载中...' : `共 ${filteredRows.length} 条` }}</small></header>
-    <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><label><span>设备时间</span><el-date-picker v-model="filters.dateRange" type="daterange" value-format="YYYY-MM-DD" start-placeholder="开始日期" end-placeholder="结束日期" range-separator="至" /></label><label><span>循环</span><el-select v-model="filters.cycleNo" clearable placeholder="全部"><el-option label="全部" value="" /><el-option v-for="item in cycleOptions" :key="item" :label="`循环 ${item}`" :value="String(item)" /></el-select></label><label><span>实际工步</span><el-select v-model="filters.stepNo" clearable placeholder="全部"><el-option label="全部" value="" /><el-option v-for="item in stepOptions" :key="item[0]" :label="`${item[0]}-${item[1]}`" :value="String(item[0])" /></el-select></label><div class="filter-actions"><button class="primary-button" type="button" @click="applySearch"><el-icon><Search /></el-icon>查询</button><button class="secondary-button" type="button" @click="resetSearch"><el-icon><Refresh /></el-icon>重置</button></div></div>
-    <div class="data-toolbar"><div class="toolbar-group"><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" @click="goToRevisions"><el-icon><Clock /></el-icon>修订记录</button><button v-if="!editing" class="primary-button" type="button" @click="openBatchEditor"><el-icon><EditPen /></el-icon>批量修改</button><template v-else><button class="primary-button" type="button" @click="saveBatchEdit"><el-icon><Check /></el-icon>保存修改</button><button class="secondary-button" type="button" @click="cancelBatchEdit">取消</button></template><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><div class="toolbar-group"><button class="secondary-button" type="button" :disabled="loading || editing" @click="loadRecords"><el-icon><Refresh /></el-icon>刷新</button><button class="secondary-button" type="button" :disabled="editing" @click="downloadCsv"><el-icon><Download /></el-icon>下载</button></div></div>
-    <div class="table-wrap"><table><thead><tr v-if="activeTab === 'realtime'"><th class="selection-column"><el-checkbox :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>电压(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>结束时间</th><th>容量(Ah)</th><th>能量(Wh)</th><th class="action-column">操作</th></tr><tr v-else><th class="selection-column"></th><th>循环记录</th><th>电池</th><th>循环号</th><th>充电容量(Ah)</th><th>放电容量(Ah)</th><th>充放电效率(%)</th><th>充电能量(Wh)</th><th>放电能量(Wh)</th><th class="action-column">操作</th></tr></thead><tbody><template v-for="row in pagedRows" :key="row.id"><tr v-if="activeTab === 'realtime'"><td class="selection-column"><el-checkbox :model-value="selectedIds.has(row.id)" :aria-label="`选择 ${row.recordNo}`" @change="(checked: string | number | boolean) => toggleRowSelection(row.id, Boolean(checked))" /></td><td class="record-code">{{ row.recordNo }}</td><td><strong>{{ row.batteryNo }}</strong></td><td>{{ row.cycleNo }}</td><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].voltage" size="small" /><span v-else>{{ row.voltage }}</span></td><td><el-input v-if="editing && draftRows[row.id]" v-model="draftRows[row.id].current" size="small" /><span v-else>{{ row.current }}</span></td><td><el-input v-if="editing && draftRows[row.id]" v-model="draftRows[row.id].capacity" size="small" /><span v-else>{{ row.capacity }}</span></td><td><el-input v-if="editing && draftRows[row.id]" v-model="draftRows[row.id].energy" size="small" /><span v-else>{{ row.energy }}</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></tr><tr v-else-if="activeTab === 'step'"><td class="selection-column"><el-checkbox :model-value="selectedIds.has(row.id)" :aria-label="`选择 ${row.recordNo}`" @change="(checked: string | number | boolean) => toggleRowSelection(row.id, Boolean(checked))" /></td><td class="record-code">{{ row.recordNo }}</td><td><strong>{{ row.batteryNo }}</strong></td><td>{{ row.cycleNo }}</td><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].capacity" size="small" /><span v-else>{{ row.capacity }}</span></td><td><el-input v-if="editing && draftRows[row.id]" v-model="draftRows[row.id].energy" size="small" /><span v-else>{{ row.energy }}</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></tr><tr v-else><td class="selection-column"><el-checkbox :model-value="selectedIds.has(row.id)" :aria-label="`选择 ${row.recordNo}`" @change="(checked: string | number | boolean) => toggleRowSelection(row.id, Boolean(checked))" /></td><td class="record-code">{{ row.recordNo }}</td><td><strong>{{ row.batteryNo }}</strong></td><td>{{ row.cycleNo }}</td><td><el-input v-if="editing && draftRows[row.id]" v-model="draftRows[row.id].chargeCapacity" size="small" /><span v-else>{{ row.chargeCapacity }}</span></td><td><el-input v-if="editing && draftRows[row.id]" v-model="draftRows[row.id].dischargeCapacity" size="small" /><span v-else>{{ row.dischargeCapacity }}</span></td><td><el-input v-if="editing && draftRows[row.id]" v-model="draftRows[row.id].efficiency" size="small" /><span v-else>{{ row.efficiency }}</span></td><td><el-input v-if="editing && draftRows[row.id]" v-model="draftRows[row.id].chargeEnergy" size="small" /><span v-else>{{ row.chargeEnergy }}</span></td><td><el-input v-if="editing && draftRows[row.id]" v-model="draftRows[row.id].dischargeEnergy" size="small" /><span v-else>{{ row.dischargeEnergy }}</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></tr></template><tr v-if="!loading && !pagedRows.length"><td :colspan="activeTab === 'realtime' ? 14 : activeTab === 'step' ? 12 : 10" class="empty-cell">{{ loadError || '暂无检测柜记录' }}</td></tr></tbody></table></div>
-    <footer class="pagination-bar"><span>显示 {{ pagedRows.length }} 条,共 {{ filteredRows.length }} 条</span><el-pagination v-model:current-page="currentPage" v-model:page-size="pageSize" :page-sizes="[20, 50, 100]" :total="filteredRows.length" layout="sizes, prev, pager, next" background /></footer>
+  <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="数据列表子页">
+        <button v-for="tab in dataTabs" :key="tab.key" type="button" :class="{ active: activeTab === tab.key }" :aria-current="activeTab === tab.key ? 'page' : undefined" @click="changeTab(tab.key)">
+          {{ tab.label }}
+        </button>
+      </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>
+    <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
+      ><label><span>设备时间</span><el-date-picker v-model="filters.dateRange" type="daterange" value-format="YYYY-MM-DD" start-placeholder="开始日期" end-placeholder="结束日期" range-separator="至" /></label
+      ><label
+        ><span>循环</span><el-select v-model="filters.cycleNo" clearable placeholder="全部"><el-option label="全部" value="" /><el-option v-for="item in cycleOptions" :key="item" :label="`循环 ${item}`" :value="String(item)" /></el-select></label
+      ><label
+        ><span>工步</span><el-select v-model="filters.stepNo" clearable placeholder="全部"><el-option label="全部" value="" /><el-option v-for="item in stepOptions" :key="item[0]" :label="`${item[0]}-${item[1]}`" :value="String(item[0])" /></el-select
+      ></label>
+      <div class="filter-actions">
+        <button class="primary-button" type="button" @click="applySearch">
+          <el-icon><Search /></el-icon>查询</button
+        ><button class="secondary-button" type="button" @click="resetSearch">
+          <el-icon><Refresh /></el-icon>重置
+        </button>
+      </div>
+    </div>
+    <div class="data-toolbar">
+      <div class="toolbar-group">
+        <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">
+          <el-icon><EditPen /></el-icon>批量修改</button
+        ><template v-else
+          ><button class="primary-button" type="button" @click="saveBatchEdit">
+            <el-icon><Check /></el-icon>保存修改</button
+          ><button class="secondary-button" type="button" @click="cancelBatchEdit">取消</button></template
+        ><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' ? '实时采样记录 · 已持久化 · 当前有效值' : `${activeTab === 'step' ? '工步' : '循环'}记录 · 已持久化 · 当前有效值` }}</span>
+      <div class="toolbar-group">
+        <button class="secondary-button" type="button" :disabled="loading || editing" @click="loadRecords">
+          <el-icon><Refresh /></el-icon>刷新</button
+        ><button class="secondary-button" type="button" :disabled="editing" @click="downloadCsv">
+          <el-icon><Download /></el-icon>下载
+        </button>
+      </div>
+    </div>
+    <div class="table-wrap">
+      <table>
+        <thead>
+          <tr>
+            <th class="selection-column">
+              <el-checkbox v-if="activeTab === 'realtime'" :model-value="allPageSelected" :indeterminate="somePageSelected" aria-label="选择本页" @change="(checked: string | number | boolean) => togglePageSelection(Boolean(checked))" />
+            </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>
+        <tbody>
+          <template v-for="row in pagedRows" :key="row.id"
+            ><tr v-if="activeTab === 'realtime'">
+              <td class="selection-column">
+                <el-checkbox :model-value="selectedIds.has(row.id)" aria-label="选择记录" @change="(checked: string | number | boolean) => toggleRowSelection(row.id, Boolean(checked))" />
+              </td>
+              <td class="record-code">{{ row.recordNo }}</td>
+              <td>
+                <strong>{{ row.batteryNo }}</strong>
+              </td>
+              <td>{{ row.cycleNo }}</td>
+              <td>{{ row.stepNo }}</td>
+              <td>{{ row.stepName }}</td>
+              <td>{{ row.sequenceId }}</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>
+              </td>
+              <td>
+                <el-input v-if="editing && draftRows[row.id]" v-model="draftRows[row.id].current" size="small" />
+                <span v-else>{{ row.current }}</span>
+              </td>
+              <td>
+                <el-input v-if="editing && draftRows[row.id]" v-model="draftRows[row.id].capacity" size="small" />
+                <span v-else>{{ row.capacity }}</span>
+              </td>
+              <td>
+                <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>
+            </tr>
+            <tr v-else-if="activeTab === 'step'">
+              <td class="selection-column">
+                <el-checkbox :model-value="selectedIds.has(row.id)" aria-label="选择记录" @change="(checked: string | number | boolean) => toggleRowSelection(row.id, Boolean(checked))" />
+              </td>
+              <td class="record-code">{{ row.recordNo }}</td>
+              <td>
+                <strong>{{ row.batteryNo }}</strong>
+              </td>
+              <td>{{ row.cycleNo }}</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>
+              </td>
+              <td>
+                <el-input v-if="editing && draftRows[row.id]" v-model="draftRows[row.id].capacity" size="small" />
+                <span v-else>{{ row.capacity }}</span>
+              </td>
+              <td>
+                <el-input v-if="editing && draftRows[row.id]" v-model="draftRows[row.id].energy" size="small" />
+                <span v-else>{{ row.energy }}</span>
+              </td>
+              <td>
+                <el-input v-if="editing && draftRows[row.id]" v-model="draftRows[row.id].startVoltage" size="small" />
+                <span v-else>{{ row.startVoltage }}</span>
+              </td>
+              <td>
+                <el-input v-if="editing && draftRows[row.id]" v-model="draftRows[row.id].endVoltage" size="small" />
+                <span v-else>{{ row.endVoltage }}</span>
+              </td>
+              <td>
+                <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].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>
+              </td>
+            </tr>
+            <tr v-else>
+              <td class="selection-column">
+                <el-checkbox :model-value="selectedIds.has(row.id)" aria-label="选择记录" @change="(checked: string | number | boolean) => toggleRowSelection(row.id, Boolean(checked))" />
+              </td>
+              <td class="record-code">{{ row.recordNo }}</td>
+              <td>
+                <strong>{{ row.batteryNo }}</strong>
+              </td>
+              <td>{{ row.cycleNo }}</td>
+              <td>
+                <el-input v-if="editing && draftRows[row.id]" v-model="draftRows[row.id].chargeCapacity" size="small" />
+                <span v-else>{{ row.chargeCapacity }}</span>
+              </td>
+              <td>
+                <el-input v-if="editing && draftRows[row.id]" v-model="draftRows[row.id].dischargeCapacity" size="small" />
+                <span v-else>{{ row.dischargeCapacity }}</span>
+              </td>
+              <td>
+                <el-input v-if="editing && draftRows[row.id]" v-model="draftRows[row.id].efficiency" size="small" />
+                <span v-else>{{ row.efficiency }}</span>
+              </td>
+              <td>
+                <el-input v-if="editing && draftRows[row.id]" v-model="draftRows[row.id].chargeEnergy" size="small" />
+                <span v-else>{{ row.chargeEnergy }}</span>
+              </td>
+              <td>
+                <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' ? 18 : activeTab === 'step' ? 14 : 11" class="empty-cell">
+              {{ loadError || '暂无检测柜记录' }}
+            </td>
+          </tr>
+        </tbody>
+      </table>
+    </div>
+    <footer class="pagination-bar">
+      <span>显示 {{ pagedRows.length }} 条,共 {{ filteredRows.length }} 条</span><el-pagination v-model:current-page="currentPage" v-model:page-size="pageSize" :page-sizes="[20, 50, 100]" :total="filteredRows.length" layout="sizes, prev, pager, next" background />
+    </footer>
     <el-dialog v-model="editorVisible" title="编辑数据" width="860px" align-center>
       <template v-if="singleRow">
-        <dl class="single-step-summary"><div><dt>记录编号</dt><dd>{{ singleRow.recordNo }}</dd></div><div><dt>电池号</dt><dd>{{ singleRow.batteryNo }}</dd></div><div><dt>循环号</dt><dd>{{ singleRow.cycleNo }}</dd></div><div><dt>工步号</dt><dd>{{ singleRow.stepNo }}</dd></div><div><dt>工步名称</dt><dd>{{ singleRow.stepName }}</dd></div><div><dt>记录时间</dt><dd>{{ singleRow.recordedAt }}</dd></div></dl>
-        <div class="single-form-grid"><label v-for="field in fieldOptions" :key="String(field.value)"><span>{{ field.label }}</span><el-input v-model="singleRow[field.value as keyof DataRow]" inputmode="decimal" /></label><label class="remark-field"><span>修改原因</span><el-input v-model="editForm.remark" type="textarea" :rows="3" maxlength="120" show-word-limit placeholder="请输入数据修改原因,提交后将写入修订记录" /></label></div>
+        <dl class="single-step-summary">
+          <div>
+            <dt>记录编号</dt>
+            <dd>{{ singleRow.recordNo }}</dd>
+          </div>
+          <div>
+            <dt>电池号</dt>
+            <dd>{{ singleRow.batteryNo }}</dd>
+          </div>
+          <div>
+            <dt>循环号</dt>
+            <dd>{{ singleRow.cycleNo }}</dd>
+          </div>
+          <div>
+            <dt>工步号</dt>
+            <dd>{{ singleRow.stepNo }}</dd>
+          </div>
+          <div>
+            <dt>工步名称</dt>
+            <dd>{{ singleRow.stepName }}</dd>
+          </div>
+          <div>
+            <dt>绝对时间</dt>
+            <dd>{{ singleRow.recordedAt }}</dd>
+          </div>
+          <div>
+            <dt>服务器接收</dt>
+            <dd>{{ singleRow.serverReceivedAt }}</dd>
+          </div>
+        </dl>
+        <div class="single-form-grid">
+          <label v-for="field in editorFieldOptions" :key="String(field.value)"
+            ><span>{{ field.label }}</span
+            ><el-input v-model="singleRow[field.value as keyof DataRow]" inputmode="decimal" /></label
+          ><label class="remark-field"><span>修改原因</span><el-input v-model="editForm.remark" type="textarea" :rows="3" maxlength="120" show-word-limit placeholder="请输入数据修改原因,提交后将写入修订记录" /></label>
+        </div>
       </template>
-      <template #footer><button class="secondary-button" type="button" @click="editorVisible = false">取消</button><button class="primary-button" type="button" @click="submitBatchEdit"><el-icon><Check /></el-icon>提交修改</button></template>
+      <template #footer
+        ><button class="secondary-button" type="button" @click="editorVisible = false">取消</button
+        ><button class="primary-button" type="button" @click="submitBatchEdit">
+          <el-icon><Check /></el-icon>提交修改
+        </button></template
+      >
     </el-dialog>
   </section>
 </template>
 <style scoped>
-.danger-button{height:34px;padding:0 13px;border:1px solid #ee6666;border-radius:3px;color:#fff;background:#ee6666;cursor:pointer}.danger-button:disabled{opacity:.5;cursor:not-allowed}
-.console-data-panel{min-width:0;border:1px solid #d8e1e9;background:#fff}.section-heading{position:relative;height:60px;display:flex;align-items:center;justify-content:space-between;padding:0 16px;border-bottom:1px solid var(--line);background:var(--surface-soft)}.section-heading>div{display:flex;align-items:center;gap:8px}.section-heading i{width:3px;height:16px;background:#2b78a3}.section-heading strong{color:var(--ink);font-size:14px}.section-heading span{color:#81909d;font-size:10px}.section-heading small{color:#647380;font-size:11px}.data-tabs{position:absolute;top:50%;left:50%;display:flex;justify-content:center;gap:8px;padding:0;border:0;background:transparent;transform:translate(-50%,-50%)}.data-tabs button{height:32px;min-width:78px;padding:0 18px;border:1px solid #cbd8e5;border-radius:4px;color:#52606d;background:#fff;box-shadow:0 1px 4px rgba(40,75,105,.08);font-size:13px;font-weight:600;cursor:pointer;transition:color .16s ease,background .16s ease,border-color .16s ease,box-shadow .16s ease}.data-tabs button:hover{color:#1677dd;border-color:#8ec4f4;box-shadow:0 3px 8px rgba(36,128,220,.14)}.data-tabs button.active{color:#fff;background:#1687ed;border-color:#1677dd;box-shadow:0 3px 8px rgba(26,112,220,.24)}.filter-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px 18px;padding:16px}.filter-grid label{min-width:0;display:grid;grid-template-columns:68px minmax(0,1fr);align-items:center;gap:8px}.filter-grid label>span{color:#52606d;font-size:12px}.filter-grid :deep(.el-select),.filter-grid :deep(.el-date-editor){width:100%}.filter-actions{grid-column:-2/-1;display:flex;justify-content:flex-end;gap:8px}.primary-button,.secondary-button{height:34px;display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:0 13px;border:1px solid transparent;border-radius:3px;font-size:13px;cursor:pointer;white-space:nowrap}.primary-button{color:#fff;background:var(--blue)}.secondary-button{color:#52606d;background:#fff;border-color:var(--line-strong)}.secondary-button:disabled{opacity:.5;cursor:not-allowed}.data-toolbar{min-height:52px;display:flex;align-items:center;justify-content:space-between;gap:12px;margin:0 16px;border-top:1px solid #d8e1e9;color:#7b8794;font-size:11px}.data-toolbar>div{display:flex;gap:8px}.load-error{color:#b34f4f}.table-wrap{width:100%;overflow-x:auto}table{width:100%;min-width:1260px;table-layout:fixed;border-collapse:collapse;color:#52606d;font-size:12px}th,td{height:44px;padding:0 10px;border-right:1px solid #dde7f0;border-bottom:1px solid #dde7f0;text-align:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}th{height:40px;color:#52606d;background:#eaf4ff;border-color:#c7e0ff;font-weight:600}.record-code{font-family:'Cascadia Mono',Consolas,monospace;font-size:11px}.empty-cell{height:150px;color:#8795a3}.pagination-bar{min-height:58px;display:flex;align-items:center;justify-content:space-between;gap:16px;padding:8px 14px 8px 16px}.pagination-bar>span{color:#7b8794;font-size:11px}.pagination-bar :deep(.el-pagination){--el-pagination-button-bg-color:#edf4fa;--el-pagination-hover-color:var(--blue)}@media(max-width:900px){.filter-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.data-toolbar{align-items:flex-start;flex-direction:column;padding:10px 0}}@media(max-width:600px){.filter-grid{grid-template-columns:1fr;padding:14px 12px}.filter-actions{grid-column:auto}.filter-actions button{flex:1}.data-tabs{position:static;transform:none;gap:6px;padding:0 8px}.data-tabs button{min-width:0;flex:1;padding:0 12px}.data-toolbar{margin:0 12px}.pagination-bar{align-items:flex-start;flex-direction:column;overflow-x:auto}}
-.toolbar-group{display:flex;align-items:center;gap:8px}.selection-column{width:46px;padding:0}.edit-form{display:grid;gap:14px}.edit-form label{display:grid;grid-template-columns:82px minmax(0,1fr);align-items:center;gap:10px}.edit-form label>span{color:#52606d;font-size:12px}.edit-form :deep(.el-select){width:100%}
-.inline-editor,.row-actions-panel{margin:14px 16px;border:1px solid #cbddeb;background:#f8fbfe}.inline-editor>header{display:flex;align-items:center;gap:12px;padding:12px 14px;border-bottom:1px solid #d8e5ef}.inline-editor>header strong{color:#26333f;font-size:13px}.inline-editor>header span{color:#7b8794;font-size:11px}.inline-editor-row{display:flex;align-items:center;gap:10px;overflow-x:auto;padding:10px 12px;border-bottom:1px solid #e2ebf2;white-space:nowrap}.inline-editor-row>span{min-width:72px}.inline-editor-row label{display:grid;gap:4px;min-width:112px}.inline-editor-row label small{color:#71808d;font-size:10px}.inline-editor-row :deep(.el-input){width:112px}.row-actions-panel{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:8px;padding:10px 12px}.row-actions-panel>div{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:8px 10px;background:#fff;border:1px solid #e2ebf2}.row-action{padding:2px 6px;border:0;color:var(--blue);background:transparent;cursor:pointer;font-size:12px}.danger-text{color:#d94b4b}
-.action-column{position:sticky;right:0;z-index:2;width:112px;background:#fff;overflow:visible;box-shadow:inset 1px 0 0 #b4c7d9,-8px 0 14px rgba(23,49,74,.1)}th.action-column{z-index:3;background:#eaf4ff}.row-actions{display:flex;align-items:center;justify-content:center;gap:12px}.row-actions button{border:0;color:var(--blue);background:transparent;font-size:12px;cursor:pointer}.row-actions .delete-action{color:#ad4a4a}
-.single-step-summary{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px 22px;margin:0;padding:4px 0 20px;border-bottom:1px solid #d8e1e9}.single-step-summary>div{display:flex;align-items:center;gap:10px;min-width:0}.single-step-summary dt{color:#71808d;font-size:12px;white-space:nowrap}.single-step-summary dd{min-width:0;color:#405261;font-size:12px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.single-form-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px 24px;padding-top:22px}.single-form-grid label{display:grid;gap:8px}.single-form-grid label>span{color:#52606d;font-size:12px;font-weight:500}.single-form-grid .remark-field{grid-column:1/-1}.single-form-grid :deep(.el-input__wrapper),.single-form-grid :deep(.el-textarea__inner){border-radius:2px;background:#f6f8fa;box-shadow:0 0 0 1px #e0e7ed inset}@media(max-width:760px){.single-step-summary,.single-form-grid{grid-template-columns:1fr}}
-/* 三个数据子页使用连续分段控件,保持当前页在标题栏中央突出显示。 */
-.data-tabs{width:228px;gap:0;padding:0;border:1px solid #2188f5;border-radius:5px;overflow:hidden;background:#fff;box-shadow:none}
-.data-tabs button{flex:1;min-width:0;height:30px;padding:0 8px;border:0;border-radius:0;color:#2188f5;background:#fff;box-shadow:none;font-size:13px;font-weight:500}
-.data-tabs button+button{border-left:1px solid #2188f5}
-.data-tabs button:hover{color:#1677dd;background:#eef7ff;box-shadow:none;transform:none}
-.data-tabs button.active{color:#fff;background:#2188f5;border-color:transparent;box-shadow:none}
-@media(max-width:600px){.data-tabs{width:min(228px,calc(100vw - 32px))}.data-tabs button{padding:0 6px;font-size:13px}}
+.danger-button {
+  height: 34px;
+  padding: 0 13px;
+  border: 1px solid #ee6666;
+  border-radius: 3px;
+  color: #fff;
+  background: #ee6666;
+  cursor: pointer;
+}
+.danger-button:disabled {
+  opacity: 0.5;
+  cursor: not-allowed;
+}
+.console-data-panel {
+  min-width: 0;
+  border: 1px solid #d8e1e9;
+  background: #fff;
+}
+.section-heading {
+  position: relative;
+  height: 60px;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 0 16px;
+  border-bottom: 1px solid var(--line);
+  background: var(--surface-soft);
+}
+.section-heading > div {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+.section-heading i {
+  width: 3px;
+  height: 16px;
+  background: #2b78a3;
+}
+.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;
+  font-size: 10px;
+}
+.section-heading small {
+  color: #647380;
+  font-size: 11px;
+}
+.data-tabs {
+  position: absolute;
+  top: 50%;
+  left: 50%;
+  display: flex;
+  justify-content: center;
+  gap: 8px;
+  padding: 0;
+  border: 0;
+  background: transparent;
+  transform: translate(-50%, -50%);
+}
+.data-tabs button {
+  height: 32px;
+  min-width: 78px;
+  padding: 0 18px;
+  border: 1px solid #cbd8e5;
+  border-radius: 4px;
+  color: #52606d;
+  background: #fff;
+  box-shadow: 0 1px 4px rgba(40, 75, 105, 0.08);
+  font-size: 13px;
+  font-weight: 600;
+  cursor: pointer;
+  transition:
+    color 0.16s ease,
+    background 0.16s ease,
+    border-color 0.16s ease,
+    box-shadow 0.16s ease;
+}
+.data-tabs button:hover {
+  color: #1677dd;
+  border-color: #8ec4f4;
+  box-shadow: 0 3px 8px rgba(36, 128, 220, 0.14);
+}
+.data-tabs button.active {
+  color: #fff;
+  background: #1687ed;
+  border-color: #1677dd;
+  box-shadow: 0 3px 8px rgba(26, 112, 220, 0.24);
+}
+.filter-grid {
+  display: grid;
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+  gap: 14px 18px;
+  padding: 16px;
+}
+.filter-grid label {
+  min-width: 0;
+  display: grid;
+  grid-template-columns: 68px minmax(0, 1fr);
+  align-items: center;
+  gap: 8px;
+}
+.filter-grid label > span {
+  color: #52606d;
+  font-size: 12px;
+}
+.filter-grid :deep(.el-select),
+.filter-grid :deep(.el-date-editor) {
+  width: 100%;
+}
+.filter-actions {
+  grid-column: -2/-1;
+  display: flex;
+  justify-content: flex-end;
+  gap: 8px;
+}
+.primary-button,
+.secondary-button {
+  height: 34px;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  gap: 6px;
+  padding: 0 13px;
+  border: 1px solid transparent;
+  border-radius: 3px;
+  font-size: 13px;
+  cursor: pointer;
+  white-space: nowrap;
+}
+.primary-button {
+  color: #fff;
+  background: var(--blue);
+}
+.secondary-button {
+  color: #52606d;
+  background: #fff;
+  border-color: var(--line-strong);
+}
+.secondary-button:disabled {
+  opacity: 0.5;
+  cursor: not-allowed;
+}
+.data-toolbar {
+  min-height: 52px;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+  margin: 0 16px;
+  border-top: 1px solid #d8e1e9;
+  color: #7b8794;
+  font-size: 11px;
+}
+.data-toolbar > div {
+  display: flex;
+  flex-wrap: wrap;
+  max-width: 100%;
+  gap: 8px;
+}
+.data-toolbar button {
+  white-space: nowrap;
+}
+.load-error {
+  color: #b34f4f;
+}
+.table-wrap {
+  width: 100%;
+  overflow-x: auto;
+}
+table {
+  width: 100%;
+  min-width: 1260px;
+  table-layout: fixed;
+  border-collapse: collapse;
+  color: #52606d;
+  font-size: 12px;
+}
+th,
+td {
+  height: 44px;
+  padding: 0 10px;
+  border-right: 1px solid #dde7f0;
+  border-bottom: 1px solid #dde7f0;
+  text-align: center;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+th {
+  height: 40px;
+  color: #52606d;
+  background: #eaf4ff;
+  border-color: #c7e0ff;
+  font-weight: 600;
+}
+.record-code {
+  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;
+}
+.pagination-bar {
+  min-height: 58px;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 16px;
+  padding: 8px 14px 8px 16px;
+}
+.pagination-bar > span {
+  color: #7b8794;
+  font-size: 11px;
+}
+.pagination-bar :deep(.el-pagination) {
+  --el-pagination-button-bg-color: #edf4fa;
+  --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));
+  }
+  .data-toolbar {
+    align-items: flex-start;
+    flex-direction: column;
+    padding: 10px 0;
+  }
+}
+@media (max-width: 600px) {
+  .filter-grid {
+    grid-template-columns: 1fr;
+    padding: 14px 12px;
+  }
+  .filter-actions {
+    grid-column: auto;
+  }
+  .filter-actions button {
+    flex: 1;
+  }
+  .data-tabs {
+    position: static;
+    transform: none;
+    gap: 6px;
+    padding: 0 8px;
+  }
+  .data-tabs button {
+    min-width: 0;
+    flex: 1;
+    padding: 0 12px;
+  }
+  .data-toolbar {
+    margin: 0 12px;
+  }
+  .pagination-bar {
+    align-items: flex-start;
+    flex-direction: column;
+    overflow-x: auto;
+  }
+}
+.toolbar-group {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+.selection-column {
+  width: 46px;
+  padding: 0;
+}
+.edit-form {
+  display: grid;
+  gap: 14px;
+}
+.edit-form label {
+  display: grid;
+  grid-template-columns: 82px minmax(0, 1fr);
+  align-items: center;
+  gap: 10px;
+}
+.edit-form label > span {
+  color: #52606d;
+  font-size: 12px;
+}
+.edit-form :deep(.el-select) {
+  width: 100%;
+}
+.danger-text {
+  color: #d94b4b;
+}
+.action-column {
+  position: sticky;
+  right: 0;
+  z-index: 2;
+  width: 112px;
+  background: #fff;
+  overflow: visible;
+  box-shadow:
+    inset 1px 0 0 #b4c7d9,
+    -8px 0 14px rgba(23, 49, 74, 0.1);
+}
+th.action-column {
+  z-index: 3;
+  background: #eaf4ff;
+}
+.row-actions {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 12px;
+}
+.row-actions button {
+  border: 0;
+  color: var(--blue);
+  background: transparent;
+  font-size: 12px;
+  cursor: pointer;
+}
+.row-actions .delete-action {
+  color: #ad4a4a;
+}
+.single-step-summary {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  gap: 14px 22px;
+  margin: 0;
+  padding: 4px 0 20px;
+  border-bottom: 1px solid #d8e1e9;
+}
+.single-step-summary > div {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  min-width: 0;
+}
+.single-step-summary dt {
+  color: #71808d;
+  font-size: 12px;
+  white-space: nowrap;
+}
+.single-step-summary dd {
+  min-width: 0;
+  color: #405261;
+  font-size: 12px;
+  font-weight: 600;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.single-form-grid {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  gap: 18px 24px;
+  padding-top: 22px;
+}
+.single-form-grid label {
+  display: grid;
+  gap: 8px;
+}
+.single-form-grid label > span {
+  color: #52606d;
+  font-size: 12px;
+  font-weight: 500;
+}
+.single-form-grid .remark-field {
+  grid-column: 1/-1;
+}
+.single-form-grid :deep(.el-input__wrapper),
+.single-form-grid :deep(.el-textarea__inner) {
+  border-radius: 2px;
+  background: #f6f8fa;
+  box-shadow: 0 0 0 1px #e0e7ed inset;
+}
+@media (max-width: 760px) {
+  .single-step-summary,
+  .single-form-grid {
+    grid-template-columns: 1fr;
+  }
+}
+/* 数据子页沿用连续分段控件;窄屏另起一行,避免覆盖标题。 */
+.data-tabs {
+  width: 228px;
+  gap: 0;
+  padding: 0;
+  border: 1px solid #2188f5;
+  border-radius: 5px;
+  overflow: hidden;
+  background: #fff;
+  box-shadow: none;
+}
+.data-tabs button {
+  flex: 1;
+  min-width: 0;
+  height: 30px;
+  padding: 0 8px;
+  border: 0;
+  border-radius: 0;
+  color: #2188f5;
+  background: #fff;
+  box-shadow: none;
+  font-size: 13px;
+  font-weight: 500;
+}
+.data-tabs button + button {
+  border-left: 1px solid #2188f5;
+}
+.data-tabs button:hover {
+  color: #1677dd;
+  background: #eef7ff;
+  box-shadow: none;
+  transform: none;
+}
+.data-tabs button.active {
+  color: #fff;
+  background: #2188f5;
+  border-color: transparent;
+  box-shadow: none;
+}
+@media (max-width: 600px) {
+  .data-tabs {
+    width: min(228px, calc(100vw - 32px));
+  }
+  .data-tabs button {
+    padding: 0 6px;
+    font-size: 13px;
+  }
+}
 </style>

--
Gitblit v1.7.1