yeg
2026-08-31 35789d2f15c426bafa876806487034f4c5ddc3eb
实现Ticket22控制台数据三子页

接入检测柜 record、step、cycle 数据并支持实时页筛选分页和刷新恢复。移除数据列表前端固定造数及业务状态依赖。
3个文件已修改
416 ■■■■■ 已修改文件
src/api/tests.ts 58 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/ConsoleDataListPanel.vue 356 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/TestConsole.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/tests.ts
@@ -31,7 +31,7 @@
  batteryNo: string
  collectorId: number
  collectorName?: string | null
  binding: { bindingId?: number; channelId: number; expectedBarcode: string | null; claimStatus?: string; cabinetCode?: string | null; cabinetName?: string | null; displayNo?: string | null }
  binding: { bindingId?: number; channelId: number; expectedBarcode: string | null; claimStatus?: string; cabinetCode?: string | null; cabinetName?: string | null; displayNo?: string | null; devtype?: number | null; devid?: number | null; subdevid?: number | null; chlid?: number | null }
  collectorChannels: Array<{
    mappingId?: number
    measurementCode: string
@@ -260,6 +260,62 @@
  return (await request<TestIntegrityResponse>(`/system/test/${testId}/integrity`)).data
}
export interface BtsRecord {
  sequenceId: number
  deviceTime: string
  currentA: number
  voltageV: number
  capacityAh: number
  energyWh: number
  cycleNo: number
  stepNo: number
  stepName: string
  stepType: string
}
export interface BtsStepRecord {
  cycleNo: number
  stepNo: number
  stepName: string
  stepType: string
  recordCount: number
  startedAt: string
  endedAt: string
  capacityAh: number
  energyWh: number
}
export interface BtsCycleRecord {
  cycleNo: number
  recordCount: number
  startedAt: string
  endedAt: string
  chargeCapacityAh: number
  dischargeCapacityAh: number
  chargeEnergyWh: number
  dischargeEnergyWh: number
}
async function getSimulatorChannelData<T>(deviceId: number, channelId: number, kind: 'records' | 'steps' | 'cycles'): Promise<T[]> {
  const token = getAuthToken()
  const response = await fetch(`${import.meta.env.VITE_API_BASE_URL || '/dev-api'}/dev/bts-simulator/devices/${deviceId}/channels/${channelId}/${kind}`, { headers: token ? { Authorization: `Bearer ${token}` } : undefined })
  if (!response.ok) throw new Error(`检测柜${kind}数据加载失败(${response.status})`)
  const result = await response.json() as T[]
  return Array.isArray(result) ? result : []
}
export function getBtsRecords(deviceId: number, channelId: number): Promise<BtsRecord[]> {
  return getSimulatorChannelData<BtsRecord>(deviceId, channelId, 'records')
}
export function getBtsSteps(deviceId: number, channelId: number): Promise<BtsStepRecord[]> {
  return getSimulatorChannelData<BtsStepRecord>(deviceId, channelId, 'steps')
}
export function getBtsCycles(deviceId: number, channelId: number): Promise<BtsCycleRecord[]> {
  return getSimulatorChannelData<BtsCycleRecord>(deviceId, channelId, 'cycles')
}
export type TestLiveEvent = 'snapshot' | 'device-status' | 'reception-status' | 'test-status' | 'exception' | 'integrity' | 'heartbeat'
/** 使用 fetch 流读取鉴权 SSE;调用返回函数关闭连接,断线由页面决定是否重连。 */
src/components/ConsoleDataListPanel.vue
@@ -1,334 +1,42 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router'
import * as XLSX from 'xlsx'
import { dataRevisionRecords, type RevisionOperation } from '@/stores/dataRevisions'
import { consoleDataRowsByTest, type ConsoleDataRow as DataRow } from '@/stores/consoleTestData'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Download, Refresh, Search } from '@element-plus/icons-vue'
import { getBtsCycles, getBtsRecords, getBtsSteps, type BtsCycleRecord, type BtsRecord, type BtsStepRecord } from '@/api/tests'
interface ConsoleCell {
  no: string
  voltage: string
  temperature: string
  positive: string
  negative: string
  capacity: string
  current: string
}
interface FilterState {
  batteryNo: string
  dateRange: [string, string] | null
  cycleNo: string
  stepNo: string
}
type EditableField = 'voltage' | 'current' | 'capacity' | 'energy' | 'temperature' | 'positivePotential' | 'negativePotential'
const props = defineProps<{
  testId: number
  testName: string
  startedAt: string
  cells: ConsoleCell[]
  canImport: boolean
}>()
const router = useRouter()
const stepNames = ['恒流充电', '搁置', '恒流放电', '搁置', '恒流充电']
const fieldOptions: Array<{ value: EditableField; label: string; unit: string }> = [
  { value: 'voltage', label: '电压', unit: 'V' },
  { value: 'current', label: '电流', unit: 'mA' },
  { value: 'capacity', label: '容量', unit: 'mAh' },
  { value: 'energy', label: '能量', unit: 'mWh' },
  { value: 'temperature', label: '温度', unit: '℃' },
  { value: 'positivePotential', label: '正电位', unit: 'V' },
  { value: 'negativePotential', label: '负电位', unit: 'V' },
]
function formatDate(date: Date) {
  const pad = (value: number) => String(value).padStart(2, '0')
  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
function buildRows(): DataRow[] {
  const start = new Date(props.startedAt.replace(' ', 'T')).getTime()
  return Array.from({ length: 658 }, (_, index) => {
    const cell = props.cells[index % props.cells.length]
    const stepIndex = index % stepNames.length
    const point = Math.floor(index / Math.max(props.cells.length, 1))
    const voltage = Number(cell?.voltage || 0) + Math.sin(index / 17) * 0.018
    const capacity = Number(cell?.capacity || 0) + point * 0.82
    return {
      id: index + 1,
      recordNo: `RD-${String(props.testId).padStart(4, '0')}-${String(index + 1).padStart(6, '0')}`,
      batteryNo: cell?.no || '-',
      recordedAt: formatDate(new Date(start + index * 60_000)),
      stepDuration: `00:${String(point % 60).padStart(2, '0')}:${String(index % 60).padStart(2, '0')}.000`,
      cycleNo: Math.floor(index / (stepNames.length * Math.max(props.cells.length, 1))) + 1,
      stepNo: stepIndex + 1,
      stepName: stepNames[stepIndex],
      voltage: voltage.toFixed(4),
      current: (Number(cell?.current || 0) + Math.cos(index / 13) * 8).toFixed(1),
      capacity: capacity.toFixed(1),
      energy: (capacity * voltage).toFixed(1),
      temperature: (Number(cell?.temperature || 0) + Math.sin(index / 23) * 0.5).toFixed(1),
      positivePotential: (Number(cell?.positive || 0) + Math.sin(index / 19) * 0.006).toFixed(4),
      negativePotential: (Number(cell?.negative || 0) + Math.cos(index / 21) * 0.006).toFixed(4),
    }
  })
}
if (!consoleDataRowsByTest[props.testId]) consoleDataRowsByTest[props.testId] = buildRows()
const rows = computed<DataRow[]>({
  get: () => consoleDataRowsByTest[props.testId] || [],
  set: value => { consoleDataRowsByTest[props.testId] = value },
})
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<number>>(new Set())
const editorVisible = ref(false)
const editForm = reactive<{ field: EditableField; value: string; remark: string }>({ field: 'voltage', value: '', remark: '' })
const batteryOptions = computed(() => [...new Set(props.cells.map(cell => cell.no))])
const cycleOptions = computed(() => [...new Set(rows.value.map(row => row.cycleNo))].sort((a, b) => a - b))
const filteredRows = computed(() => rows.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 (query.stepNo && row.stepNo !== Number(query.stepNo)) return false
  if (query.dateRange) {
    const date = row.recordedAt.slice(0, 10)
    if (date < query.dateRange[0] || date > query.dateRange[1]) return false
  }
  return true
}))
const pagedRows = computed(() => {
  const start = (currentPage.value - 1) * pageSize.value
  return filteredRows.value.slice(start, start + pageSize.value)
})
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 applySearch() {
  appliedFilters.value = {
    batteryNo: filters.batteryNo,
    dateRange: filters.dateRange ? [...filters.dateRange] as [string, string] : null,
    cycleNo: filters.cycleNo,
    stepNo: filters.stepNo,
  }
  currentPage.value = 1
  selectedIds.value = new Set()
  ElMessage.success(`已找到 ${filteredRows.value.length} 条数据`)
}
function resetSearch() {
  filters.batteryNo = ''
  filters.dateRange = null
  filters.cycleNo = ''
  filters.stepNo = ''
  appliedFilters.value = { batteryNo: '', dateRange: null, cycleNo: '', stepNo: '' }
  currentPage.value = 1
  selectedIds.value = new Set()
}
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 toggleRowSelection(id: number, checked: boolean) {
  const next = new Set(selectedIds.value)
  checked ? next.add(id) : next.delete(id)
  selectedIds.value = next
}
function openSingleEditor(row: DataRow) {
  router.push({
    path: `/test-list/${props.testId}/console/data/${row.id}/edit`,
    query: { testName: props.testName, completed: props.canImport ? '1' : '0' },
  })
}
function openBatchEditor() {
  if (!selectedIds.value.size) {
    ElMessage.warning('请先勾选需要批量修改的数据')
    return
  }
  editForm.field = 'voltage'
  editForm.value = ''
  editForm.remark = ''
  editorVisible.value = true
}
function revisionValues(row: DataRow) {
  return {
    voltage: row.voltage,
    current: row.current,
    capacity: row.capacity,
    energy: row.energy,
    temperature: row.temperature,
    positivePotential: row.positivePotential,
    negativePotential: row.negativePotential,
  }
}
function recordRevision(row: DataRow, operation: RevisionOperation, remark: string, revisedRow: DataRow | null) {
  const now = formatDate(new Date())
  dataRevisionRecords.unshift({
    id: `DR-${Date.now()}-${row.id}`,
    testId: props.testId,
    testName: props.testName,
    testedAt: row.recordedAt,
    generatedAt: row.recordedAt,
    modifiedAt: now,
    modifier: '管理员',
    remark,
    operation,
    stepName: row.stepName,
    cycleNo: row.cycleNo,
    stepNo: row.stepNo,
    stepDuration: row.stepDuration,
    absoluteTime: row.recordedAt,
    originalData: revisionValues(row),
    revisedData: revisedRow ? revisionValues(revisedRow) : null,
  })
}
function saveEdit() {
  if (!editForm.value.trim()) {
    ElMessage.warning('请输入修改后的数值')
    return
  }
  if (!Number.isFinite(Number(editForm.value))) {
    ElMessage.warning('修改值必须是有效数字')
    return
  }
  if (!editForm.remark.trim()) {
    ElMessage.warning('请填写修改原因')
    return
  }
  const targetIds = selectedIds.value
  rows.value = rows.value.map(row => {
    if (!targetIds.has(row.id)) return row
    const revised = { ...row, [editForm.field]: editForm.value.trim() }
    recordRevision(row, '修改', editForm.remark.trim(), revised)
    return revised
  })
  editorVisible.value = false
  ElMessage.success(`已修改 ${targetIds.size} 条数据并生成修订记录`)
}
async function removeRow(row: DataRow) {
  try {
    const { value } = await ElMessageBox.prompt('删除后数据不可恢复,请填写删除原因。原始数据和原因会保留在修订记录中。', '删除数据', {
      confirmButtonText: '确认删除', cancelButtonText: '取消', type: 'warning', confirmButtonClass: 'danger-confirm',
      inputPlaceholder: '请输入删除原因', inputValidator: input => Boolean(input.trim()) || '删除原因不能为空',
    })
    recordRevision(row, '删除', value.trim(), null)
    rows.value = rows.value.filter(item => item.id !== row.id)
    const next = new Set(selectedIds.value)
    next.delete(row.id)
    selectedIds.value = next
    if (pagedRows.value.length === 0 && currentPage.value > 1) currentPage.value -= 1
    ElMessage.success('数据已删除,修订记录已保留')
  } catch { /* 用户取消 */ }
}
function escapeCsv(value: string | number) {
  return `"${String(value).replaceAll('"', '""')}"`
}
function downloadCsv(source: DataRow[], filename: string) {
  if (!source.length) {
    ElMessage.warning('没有可下载的数据')
    return
  }
  const headers = ['记录编号', '电池号', '记录时间', '工步时间', '循环序号', '工步序号', '工步名称', '电压(V)', '电流(mA)', '容量(mAh)', '能量(mWh)', '温度(℃)', '正电位(V)', '负电位(V)']
  const records = source.map(row => [row.recordNo, row.batteryNo, row.recordedAt, row.stepDuration, row.cycleNo, row.stepNo, row.stepName, row.voltage, row.current, row.capacity, row.energy, row.temperature, row.positivePotential, row.negativePotential])
  const content = `\uFEFF${[headers, ...records].map(record => record.map(escapeCsv).join(',')).join('\n')}`
  const url = URL.createObjectURL(new Blob([content], { type: 'text/csv;charset=utf-8' }))
  const anchor = document.createElement('a')
  anchor.href = url
  anchor.download = filename
  anchor.click()
  window.setTimeout(() => URL.revokeObjectURL(url), 1000)
  ElMessage.success(`已下载 ${source.length} 条数据`)
}
function downloadSelected() {
  downloadCsv(rows.value.filter(row => selectedIds.value.has(row.id)), `${props.testName}_选中数据.csv`)
}
function downloadTemplate() {
  const headers = [['记录编号', '电池号', '记录时间', '工步时间', '循环序号', '工步序号', '工步名称', '电压(V)', '电流(mA)', '容量(mAh)', '能量(mWh)', '温度(℃)', '正电位(V)', '负电位(V)']]
  const worksheet = XLSX.utils.aoa_to_sheet(headers)
  worksheet['!cols'] = headers[0].map(header => ({ wch: Math.max(header.length + 4, 12) }))
  const workbook = XLSX.utils.book_new()
  XLSX.utils.book_append_sheet(workbook, worksheet, '测试数据')
  XLSX.writeFile(workbook, '测试数据导入模板.xlsx')
  ElMessage.success('导入模板已下载')
}
function goToImport() {
  router.push({
    path: `/test-list/${props.testId}/console/data/import`,
    query: { testName: props.testName, completed: props.canImport ? '1' : '0' },
  })
}
function goToRevisions() {
  router.push({ path: '/data-revision', query: { testId: String(props.testId), testName: props.testName } })
}
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; voltage: string; current: string; capacity: string; energy: string }
const props = defineProps<{ testId: number; testName: string; cells: ConsoleCell[]; bindings?: Array<{ batteryNo: string; devid?: number | null; chlid?: number | null }> }>()
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 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)
function formatTime(value: string) { return value ? value.replace('T', ' ').replace('Z', '').replace(/\.\d{3,6}$/, '') : '-' }
function mapRecord(record: BtsRecord, batteryNo: string, deviceId: number, channelId: number): DataRow { const timestamp = formatTime(record.deviceTime); return { 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 { 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, voltage: '-', current: '-', capacity: Number(record.capacityAh).toFixed(4), energy: Number(record.energyWh).toFixed(4) } }
function mapCycle(record: BtsCycleRecord, batteryNo: string, deviceId: number, channelId: number): DataRow { const startedAt = formatTime(record.startedAt); return { id: `cycle:${deviceId}:${channelId}:${record.cycleNo}`, recordNo: `CYCLE-${deviceId}-${channelId}-${record.cycleNo}`, batteryNo, cycleNo: record.cycleNo, stepNo: 0, stepName: '循环统计', sequenceId: record.recordCount, recordedAt: startedAt, absoluteTime: startedAt, voltage: '-', current: '-', capacity: Number(record.chargeCapacityAh || record.dischargeCapacityAh).toFixed(4), energy: Number(record.chargeEnergyWh || record.dischargeEnergyWh).toFixed(4) } }
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)) } })); rows.value = batches.flatMap(item => item.records).sort((a, b) => b.absoluteTime.localeCompare(a.absoluteTime) || b.sequenceId - a.sequenceId); stepRows.value = batches.flatMap(item => item.steps); cycleRows.value = batches.flatMap(item => item.cycles) } 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 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 (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 changeTab(tab: DataTab) { activeTab.value = tab; currentPage.value = 1 }
function escapeCsv(value: unknown) { return `"${String(value ?? '').replaceAll('"', '""')}"` }
function downloadCsv() { if (!filteredRows.value.length) { ElMessage.warning('没有可下载的数据'); return } const headers = ['记录编号', '电池', '循环号', '实际工步号', '实际工步名称', '记录序号', '记录时间', '绝对时间', '电压(V)', '电流(A)', '容量(Ah)', '能量(Wh)']; const records = 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) }
onMounted(loadRecords); watch(() => props.bindings, loadRecords, { deep: true })
</script>
<template>
  <section class="console-data-panel">
    <header class="section-heading"><div><i></i><strong>查询表格</strong><span>TEST DATA QUERY</span></div><small>共 {{ 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="battery in batteryOptions" :key="battery" :label="battery" :value="battery" /></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="至" unlink-panels /></label>
      <label><span>循环</span><el-select v-model="filters.cycleNo" clearable placeholder="全部"><el-option label="全部" value="" /><el-option v-for="cycle in cycleOptions" :key="cycle" :label="`循环 ${cycle}`" :value="String(cycle)" /></el-select></label>
      <label><span>工步</span><el-select v-model="filters.stepNo" clearable placeholder="全部"><el-option label="全部" value="" /><el-option v-for="(name, index) in stepNames" :key="index" :label="`${index + 1}-${name}`" :value="String(index + 1)" /></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><RefreshLeft /></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 class="primary-button" type="button" @click="openBatchEditor"><el-icon><EditPen /></el-icon>批量修改</button>
      </div>
      <div class="toolbar-group">
        <button class="secondary-button" type="button" @click="downloadCsv(filteredRows, `${testName}_全部数据.csv`)"><el-icon><Download /></el-icon>下载全部</button>
        <button class="secondary-button" type="button" @click="downloadSelected"><el-icon><Download /></el-icon>下载选中</button>
      </div>
    </div>
    <div class="table-wrap">
      <table>
        <thead><tr><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>工步时间(h:min:s.ms)</th><th>循环序号</th><th>工步序号</th><th>工步名称</th><th>电压(V)</th><th>电流(mA)</th><th>容量(mAh)</th><th>能量(mWh)</th><th>温度(℃)</th><th>正电位(V)</th><th>负电位(V)</th><th class="action-column">操作</th></tr></thead>
        <tbody>
          <tr v-for="row in pagedRows" :key="row.id">
            <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.recordedAt }}</td><td>{{ row.stepDuration }}</td><td>{{ row.cycleNo }}</td><td>{{ row.stepNo }}</td><td>{{ row.stepName }}</td><td>{{ row.voltage }}</td><td>{{ row.current }}</td><td>{{ row.capacity }}</td><td>{{ row.energy }}</td><td>{{ row.temperature }}</td><td>{{ row.positivePotential }}</td><td>{{ row.negativePotential }}</td>
            <td class="action-column"><div class="row-actions"><button type="button" @click="openSingleEditor(row)">编辑</button><button class="delete-action" type="button" @click="removeRow(row)">删除</button></div></td>
          </tr>
          <tr v-if="!pagedRows.length"><td colspan="16" class="empty-cell">暂无符合条件的测试数据</td></tr>
        </tbody>
      </table>
    </div>
    <header class="section-heading"><div><i></i><strong>数据列表</strong><span>TEST DATA</span></div><small>{{ loading ? '加载中...' : `共 ${filteredRows.length} 条` }}</small></header>
    <nav class="data-tabs" aria-label="数据列表子页"><button v-for="tab in [{ key: 'realtime', label: '实时' }, { key: 'step', label: '工步' }, { key: 'cycle', label: '循环' }]" :key="tab.key" type="button" :class="{ active: activeTab === tab.key }" @click="changeTab(tab.key as DataTab)">{{ tab.label }}</button></nav>
    <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"><span v-if="loadError" class="load-error">{{ loadError }}</span><span v-else>仅展示检测柜实际返回的 {{ activeTab === 'realtime' ? 'record' : activeTab === 'step' ? 'step' : 'cycle' }} 数据</span><div><button class="secondary-button" type="button" :disabled="loading" @click="loadRecords"><el-icon><Refresh /></el-icon>刷新</button><button class="secondary-button" type="button" @click="downloadCsv"><el-icon><Download /></el-icon>下载</button></div></div>
    <div class="table-wrap"><table><thead><tr v-if="activeTab === 'realtime'"><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></tr><tr v-else-if="activeTab === 'step'"><th>工步记录</th><th>电池</th><th>循环号</th><th>实际工步号</th><th>实际工步名称</th><th>记录数量</th><th>开始时间</th><th>结束时间</th><th>容量(Ah)</th><th>能量(Wh)</th></tr><tr v-else><th>循环记录</th><th>电池</th><th>循环号</th><th>记录数量</th><th>开始时间</th><th>结束时间</th><th>充电容量(Ah)</th><th>放电容量(Ah)</th><th>充电能量(Wh)</th><th>放电能量(Wh)</th></tr></thead><tbody><template v-for="row in pagedRows" :key="row.id"><tr v-if="activeTab === 'realtime'"><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>{{ row.voltage }}</td><td>{{ row.current }}</td><td>{{ row.capacity }}</td><td>{{ row.energy }}</td></tr><tr v-else-if="activeTab === 'step'"><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>{{ row.capacity }}</td><td>{{ row.energy }}</td></tr><tr v-else><td class="record-code">{{ row.recordNo }}</td><td><strong>{{ row.batteryNo }}</strong></td><td>{{ row.cycleNo }}</td><td>{{ row.sequenceId }}</td><td>{{ row.recordedAt }}</td><td>{{ row.absoluteTime }}</td><td>{{ row.capacity }}</td><td>-</td><td>{{ row.energy }}</td><td>-</td></tr></template><tr v-if="!loading && !pagedRows.length"><td :colspan="activeTab === 'realtime' ? 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>
    <el-dialog v-model="editorVisible" :title="`批量修改(${selectedIds.size} 条)`" width="520px" align-center>
      <div class="edit-form"><label><span>修改字段</span><el-select v-model="editForm.field"><el-option v-for="field in fieldOptions" :key="field.value" :label="`${field.label}(${field.unit})`" :value="field.value" /></el-select></label><label><span>修改值</span><el-input v-model="editForm.value" placeholder="请输入修改后的数值" /></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 #footer><button class="secondary-button" type="button" @click="editorVisible=false">取消</button><button class="primary-button" type="button" @click="saveEdit"><el-icon><Check /></el-icon>确认修改</button></template>
    </el-dialog>
  </section>
</template>
<style scoped>
.console-data-panel{min-width:0;border:1px solid #d8e1e9;background:#fff}.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;font-weight:500;cursor:pointer;white-space:nowrap}.primary-button{color:#fff;background:var(--blue);border-color:var(--blue)}.primary-button:hover{background:#176fcb}.secondary-button{color:#52606d;background:#fff;border-color:var(--line-strong)}.secondary-button:hover{color:var(--blue);border-color:#9dc8f5}.section-heading{height:46px;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}.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;font-weight:500}.filter-grid :deep(.el-select),.filter-grid :deep(.el-date-editor){width:100%}.filter-grid :deep(.el-input__wrapper),.filter-grid :deep(.el-select__wrapper){min-height:34px;border-radius:3px;background:#f8fafc;box-shadow:0 0 0 1px #d8e1e9 inset}.filter-actions{grid-column:-2/-1;display:flex;justify-content:flex-end;gap:8px}.data-toolbar{min-height:58px;display:flex;align-items:center;justify-content:space-between;gap:14px;margin:0 16px;padding:10px 0;border-top:1px solid #d8e1e9}.toolbar-group{display:flex;align-items:center;gap:8px}.file-input{display:none}.table-wrap{width:100%;overflow-x:auto}table{width:100%;min-width:1820px;table-layout:fixed;border-collapse:collapse;color:#52606d;font-size:12px}th,td{height:46px;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}th:nth-child(2){width:158px}th:nth-child(3){width:110px}th:nth-child(4){width:158px}th:nth-child(5){width:150px}th:nth-child(6),th:nth-child(7){width:78px}th:nth-child(8){width:100px}th:nth-child(n+9):nth-child(-n+15){width:96px}.selection-column{width:46px;padding:0}.record-code{color:#647380;font-family:'Cascadia Mono',Consolas,monospace;font-size:11px}td strong{color:#26333f}.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}tbody tr:hover td{background:#f7fbff}tbody tr:hover td.action-column{background:#f7fbff}.row-actions{display:flex;align-items:center;justify-content:center;gap:14px}.row-actions button{border:0;color:var(--blue);background:transparent;font-size:12px;cursor:pointer}.row-actions .delete-action{color:#ad4a4a}.empty-cell{height:150px;color:#8795a3;text-align:center}.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)}.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 .remark-field{align-items:start}.edit-form :deep(.el-select){width:100%}@media(max-width:1250px){.filter-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.filter-actions{grid-column:-2/-1}}@media(max-width:900px){.filter-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.data-toolbar{align-items:flex-start;flex-direction:column}.toolbar-group{flex-wrap:wrap}}@media(max-width:600px){.filter-grid{grid-template-columns:1fr;padding:14px 12px}.filter-actions{grid-column:auto}.filter-actions button{flex:1}.data-toolbar{margin:0 12px}.toolbar-group{width:100%}.toolbar-group button{flex:1;padding:0 8px}.pagination-bar{align-items:flex-start;flex-direction:column;overflow-x:auto}.section-heading span{display:none}}
.console-data-panel{min-width:0;border:1px solid #d8e1e9;background:#fff}.section-heading{height:46px;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{display:flex;gap:8px;padding:12px 16px 0;border-bottom:1px solid #d8e1e9}.data-tabs button{height:32px;padding:0 18px;border:1px solid transparent;border-bottom:2px solid transparent;color:#52606d;background:#fff;font-size:12px;cursor:pointer}.data-tabs button.active{color:#1677dd;border-bottom-color:#1677dd;font-weight:600}.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{overflow-x:auto}.data-toolbar{margin:0 12px}.pagination-bar{align-items:flex-start;flex-direction:column;overflow-x:auto}}
</style>
src/views/TestConsole.vue
@@ -296,7 +296,7 @@
    <ConsoleChartPanel v-if="activeTab === 'step-chart'" :cells="cells" :test-name="profile.name" :battery-group="profile.batteryGroup" :duration-minutes="5" />
    <ConsoleProjectChartPanel v-if="activeTab === 'project-chart'" :cells="cells" />
    <ConsoleDataListPanel v-if="activeTab === 'data-list'" :test-id="testId" :test-name="profile.name" :started-at="profile.startedAt" :cells="cells" :can-import="state === '已结束'" />
    <ConsoleDataListPanel v-if="activeTab === 'data-list'" :test-id="testId" :test-name="profile.name" :cells="cells" :bindings="backendDetail?.batteries.map(item => ({ batteryNo: item.batteryNo, devid: item.binding?.devid, chlid: item.binding?.chlid }))" />
    <el-dialog v-model="stepDialogVisible" title="工步设置" width="620px" align-center><div class="step-dialog-content"><p>当前执行工步</p><strong>工步 3 · 恒流充电</strong><dl><div><dt>目标电压</dt><dd>2.4000 V</dd></div><div><dt>循环进度</dt><dd>36 / 60</dd></div><div><dt>执行时间</dt><dd>{{ elapsedText }}</dd></div></dl></div><template #footer><button class="secondary-button" type="button" @click="stepDialogVisible=false">取消</button><button class="primary-button" type="button" @click="stepDialogVisible=false; ElMessage.success('工步设置已保存')"><el-icon><Check /></el-icon>确认</button></template></el-dialog>
  </div>