yeg
2026-08-31 35789d2f15c426bafa876806487034f4c5ddc3eb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<script setup lang="ts">
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'
 
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</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>
  </section>
</template>
<style scoped>
.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>