| | |
| | | <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> |