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