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