yeg
5 天以前 9dfc7d64b00a704b7e77a6000c1dcadd66b0d345
src/components/ConsoleDataListPanel.vue
@@ -1,97 +1,1113 @@
<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 { useRouter } from 'vue-router'
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 }> }>()
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 activeTab = ref<DataTab>('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 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 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)
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), testName: props.testName } }) }
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>
    <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
      ><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>