对齐测试控制台数据与实时状态
统一毫秒时间展示,补充扩展测量来源,修正三类模板导入及刷新后事实恢复。增加无需服务启动的浏览器契约验证。
| | |
| | | export type ConsoleDataType = 'REALTIME' | 'STEP' | 'CYCLE' |
| | | export type ConsoleDataOperation = 'EDIT' | 'IMPORT' | 'DELETE' |
| | | |
| | | export const consoleImportColumns = { |
| | | REALTIME: { recordNo: '记录编号', batteryNo: '电池号', recordedAt: '设备时间', cycleNo: '循环序号', stepNo: '工步序号', stepName: '工步名称', sequenceId: '记录序号', voltage: '电压(V)', current: '电流(A)', capacity: '容量(Ah)', energy: '能量(Wh)', temperature: '温度(℃)', positivePotential: '正电位(V)', negativePotential: '负电位(V)' }, |
| | | STEP: { recordNo: '记录编号', batteryNo: '电池号', recordedAt: '设备时间', cycleNo: '循环序号', stepNo: '工步序号', stepName: '工步', stepDuration: '工步时间', capacity: '容量(Ah)', energy: '能量(Wh)', startVoltage: '起始电压(V)', endVoltage: '终止电压(V)', chargePeakVoltage: '充电峰值电压(V)', dischargeValleyVoltage: '放电谷值电压(V)' }, |
| | | CYCLE: { recordNo: '记录编号', batteryNo: '电池号', recordedAt: '设备时间', cycleNo: '循环序号', chargeCapacity: '充电容量(Ah)', dischargeCapacity: '放电容量(Ah)', efficiency: '充放电效率(%)', chargeEnergy: '充电能量(Wh)', dischargeEnergy: '放电能量(Wh)' }, |
| | | } satisfies Record<ConsoleDataType, Record<string, string>> |
| | | |
| | | export interface ConsoleDataOverride { |
| | | overrideId: number |
| | | testId: number |
| | |
| | | import { request, type ApiResponse } from './request' |
| | | import { formatBusinessTime } from '@/utils/businessTime' |
| | | |
| | | export type RevisionDataType = 'REALTIME' | 'STEP' | 'CYCLE' |
| | | export type RevisionOperationType = 'EDIT' | 'IMPORT' | 'DELETE' |
| | | |
| | | export const revisionDataTypeLabels: Record<RevisionDataType, string> = { REALTIME: '实时', STEP: '工步', CYCLE: '循环' } |
| | | export const revisionOperationLabels: Record<RevisionOperationType, string> = { EDIT: '编辑', IMPORT: '导入', DELETE: '删除' } |
| | | export function formatRevisionTime(value: string | null | undefined) { return value ? new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'medium' }).format(new Date(value)) : '-' } |
| | | export const formatRevisionTime = formatBusinessTime |
| | | export function formatRevisionScope(value: string) { return value.replace(/^按格(?=修改)/, '') } |
| | | |
| | | export interface DataRevisionAction { |
| | |
| | | reason: string |
| | | operatorId: number | null |
| | | operatorName: string |
| | | createdAtUtc: string |
| | | createdAtUtc: number |
| | | } |
| | | |
| | | interface RevisionListResponse extends ApiResponse { total: number; rows: DataRevisionAction[] } |
| | |
| | | import type { ReceptionStatus, TestRecord, TestStatus } from '@/stores/tests' |
| | | import { getAuthToken, request, type ApiResponse } from './request' |
| | | import { formatBusinessTime } from '@/utils/businessTime' |
| | | |
| | | export type TestStatusCode = 'WAITING' | 'RUNNING' | 'PAUSED' | 'COMPLETED' |
| | | export type ReceptionStatusCode = 'NOT_STARTED' | 'RECEIVING' | 'PAUSED' | 'ENDED' |
| | |
| | | export interface TestConfigurationDetail extends TestConfigurationPayload { |
| | | testId: number |
| | | creatorId: number |
| | | createTime?: string | null |
| | | updateTime?: string | null |
| | | createTime?: number | null |
| | | updateTime?: number | null |
| | | creatorName: string | null |
| | | principalName: string | null |
| | | testTypeName: string | null |
| | | testStatus: TestStatusCode |
| | | receptionStatus: ReceptionStatusCode |
| | | startedAtUtc?: string | null |
| | | endedAtUtc?: string | null |
| | | startedAtUtc?: number | null |
| | | endedAtUtc?: number | null |
| | | /** 历史兼容字段;新接口不返回测试字段快照,字段从 batteries[].fields 读取。 */ |
| | | fieldSnapshots?: Array<{ |
| | | snapshotId: number |
| | |
| | | testStatus: TestStatusCode | null |
| | | receptionStatus: ReceptionStatusCode | null |
| | | batteryNo: string | null |
| | | createTime: string | null |
| | | startedAtUtc: string | null |
| | | endedAtUtc: string | null |
| | | createTime: number | null |
| | | startedAtUtc: number | null |
| | | endedAtUtc: number | null |
| | | runtimeAttach?: boolean | null |
| | | remark: string | null |
| | | batteryCount: number | null |
| | |
| | | const receptionStatus = item.receptionStatus ? receptionStatusMap[item.receptionStatus] : '未知' |
| | | return { |
| | | id: item.testId, name: item.testName || '-', type: item.testTypeName || '-', batteryNo: item.batteryNo || '-', batteryGroup: '-', |
| | | batteryCount: Number(item.batteryCount) || 0, createdAt: item.createTime || '-', startedAt: item.startedAtUtc || '-', |
| | | endedAt: item.endedAtUtc || '-', purpose: item.testPurpose || '-', owner: item.principalName || '-', |
| | | batteryCount: Number(item.batteryCount) || 0, createdAt: formatBusinessTime(item.createTime), startedAt: formatBusinessTime(item.startedAtUtc), |
| | | endedAt: formatBusinessTime(item.endedAtUtc), purpose: item.testPurpose || '-', owner: item.principalName || '-', |
| | | remark: item.remark || '-', status, receptionStatus, editable: item.receptionStatus === 'NOT_STARTED', |
| | | } |
| | | } |
| | |
| | | latestSnapshot: { |
| | | source?: string |
| | | unit?: string |
| | | observedAt?: string |
| | | observedAt?: number |
| | | sampleIntervalMs?: number |
| | | measurements?: Array<Record<string, unknown>> |
| | | units?: string[] |
| | |
| | | testId: number |
| | | testStatus: TestStatusCode |
| | | receptionStatus: ReceptionStatusCode |
| | | updatedAt: string |
| | | updatedAt: number |
| | | sources: LiveSource[] |
| | | exceptions: Array<Record<string, unknown>> |
| | | testPlan?: BtsTestSnapshot | null |
| | |
| | | stepType?: string |
| | | sequenceId: number |
| | | cabinetSequenceId?: number | null |
| | | recordedAt: string |
| | | absoluteTime: string |
| | | deviceTime?: string |
| | | serverReceivedAt?: string |
| | | recordedAt?: number | null |
| | | absoluteTime?: number | null |
| | | deviceTime?: number | null |
| | | collectorDeviceTime?: number | null |
| | | captureSlot?: number | null |
| | | serverReceivedAt?: number | null |
| | | voltage: number | string | null |
| | | current: number | string | null |
| | | capacity: number | string | null |
| | |
| | | positivePotential?: number | string | null |
| | | negativePotential?: number | string | null |
| | | extensionMeasurementsJson?: string | Record<string, unknown> | null |
| | | sourceIdentity?: string |
| | | sourceRecordId?: string |
| | | deviceId: number |
| | | channelId: number |
| | | adapterId?: number |
| | |
| | | headers: token ? { Authorization: `Bearer ${token}`, Accept: 'text/event-stream' } : { Accept: 'text/event-stream' }, |
| | | signal: controller.signal, |
| | | }) |
| | | if (!response.ok || !response.body) throw new Error(response.status === 401 ? '实时流权限不足' : `实时流连接失败(${response.status})`) |
| | | if (!response.ok || !response.body) throw new Error([401, 403].includes(response.status) ? '实时流权限不足' : `实时流连接失败(${response.status})`) |
| | | if (!response.headers.get('content-type')?.includes('text/event-stream')) throw new Error('实时流响应格式错误') |
| | | const reader = response.body.getReader() |
| | | const decoder = new TextDecoder() |
| | | let buffer = '' |
| | |
| | | const hasSelectedRecords = computed(() => selectedRecords().length > 0) |
| | | |
| | | function recordTime(record: PersistedTestRecord) { |
| | | const value = record.deviceTime || record.recordedAt |
| | | const parsed = Date.parse(value || '') |
| | | return Number.isFinite(parsed) ? parsed : null |
| | | const value = record.deviceTime ?? record.recordedAt |
| | | return typeof value === 'number' && Number.isFinite(value) ? value : null |
| | | } |
| | | |
| | | function chartOption(metric: CompareMetricDefinition) { |
| | |
| | | import * as XLSX from 'xlsx' |
| | | import { Download, EditPen, Refresh, Search, UploadFilled, Clock, Check } from '@element-plus/icons-vue' |
| | | import { getPersistedTestCycles, getPersistedTestRecords, getPersistedTestSteps, type PersistedTestCycle, type PersistedTestRecord, type PersistedTestStep } from '@/api/tests' |
| | | import { submitConsoleDataAction, type ConsoleDataType } from '@/api/consoleData' |
| | | import { consoleImportColumns, submitConsoleDataAction, type ConsoleDataType } from '@/api/consoleData' |
| | | import { formatBusinessTime as formatTime } from '@/utils/businessTime' |
| | | |
| | | type DataTab = 'realtime' | 'step' | 'cycle' |
| | | interface ConsoleCell { |
| | |
| | | positivePotential: string |
| | | negativePotential: string |
| | | extensionMeasurements: string |
| | | sourceIdentity: string |
| | | collectorDeviceTime: string |
| | | chargeCapacity: string |
| | | dischargeCapacity: string |
| | | efficiency: string |
| | |
| | | { label: '放电能量(Wh)', value: 'dischargeEnergy' }, |
| | | ], |
| | | ) |
| | | function formatTime(value?: string | null) { |
| | | return value |
| | | ? value |
| | | .replace('T', ' ') |
| | | .replace('Z', '') |
| | | .replace(/\.\d{3,6}$/, '') |
| | | : '-' |
| | | } |
| | | function emptyRow(base: Partial<DataRow>): DataRow { |
| | | return { id: '', sourceType: 'CABINET', metricCode: '-', metricValue: '-', unit: '-', collectorCode: '-', channelCode: '-', recordNo: '', batteryNo: '', cycleNo: 0, stepNo: 0, stepName: '', actualStepLabel: '-', sequenceId: 0, recordedAt: '', absoluteTime: '', serverReceivedAt: '', stepDuration: '-', startVoltage: '-', endVoltage: '-', chargePeakVoltage: '-', dischargeValleyVoltage: '-', voltage: '-', current: '-', capacity: '-', energy: '-', temperature: '-', positivePotential: '-', negativePotential: '-', extensionMeasurements: '-', chargeCapacity: '-', dischargeCapacity: '-', efficiency: '-', chargeEnergy: '-', dischargeEnergy: '-', ...base } |
| | | return { sourceIdentity: '-', collectorDeviceTime: '-', id: '', sourceType: 'CABINET', metricCode: '-', metricValue: '-', unit: '-', collectorCode: '-', channelCode: '-', recordNo: '', batteryNo: '', cycleNo: 0, stepNo: 0, stepName: '', actualStepLabel: '-', sequenceId: 0, recordedAt: '', absoluteTime: '', serverReceivedAt: '', stepDuration: '-', startVoltage: '-', endVoltage: '-', chargePeakVoltage: '-', dischargeValleyVoltage: '-', voltage: '-', current: '-', capacity: '-', energy: '-', temperature: '-', positivePotential: '-', negativePotential: '-', extensionMeasurements: '-', chargeCapacity: '-', dischargeCapacity: '-', efficiency: '-', chargeEnergy: '-', dischargeEnergy: '-', ...base } |
| | | } |
| | | function mapRecord(record: PersistedTestRecord): DataRow { |
| | | const deviceTime = formatTime(record.deviceTime || record.recordedAt) |
| | | 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' |
| | |
| | | if (snapshot && record.extensionMeasurementsJson) { |
| | | try { |
| | | const parsed = typeof record.extensionMeasurementsJson === 'string' ? JSON.parse(record.extensionMeasurementsJson) : record.extensionMeasurementsJson |
| | | extensionMeasurements = Object.entries(parsed as Record<string, any>).map(([code, value]) => `${code}=${value?.value ?? value?.metricValue ?? '-'}${value?.unit ? ` ${value.unit}` : ''}`).join('; ') || '-' |
| | | 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({ id: identity, sourceType: record.sourceType || 'CABINET', metricCode: record.metricCode || '-', metricValue: extensionValue, unit: record.unit || '-', collectorCode: record.collectorCode || '-', channelCode: record.channelCode || '-', recordNo: record.recordNo, 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, absoluteTime: deviceTime, serverReceivedAt: formatTime(record.serverReceivedAt), voltage: collector ? '-' : (record.voltage == null ? '-' : Number(record.voltage).toFixed(4)), current: collector ? '-' : (record.current == null ? '-' : Number(record.current).toFixed(4)), capacity: collector ? '-' : (record.capacity == null ? '-' : Number(record.capacity).toFixed(4)), energy: collector ? '-' : (record.energy == null ? '-' : Number(record.energy).toFixed(4)), 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 }) |
| | | return emptyRow({ sourceIdentity: record.sourceIdentity || '-', 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: record.recordNo, 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, absoluteTime: deviceTime, serverReceivedAt: formatTime(record.serverReceivedAt), voltage: collector ? '-' : (record.voltage == null ? '-' : Number(record.voltage).toFixed(4)), current: collector ? '-' : (record.current == null ? '-' : Number(record.current).toFixed(4)), capacity: collector ? '-' : (record.capacity == null ? '-' : Number(record.capacity).toFixed(4)), energy: collector ? '-' : (record.energy == null ? '-' : Number(record.energy).toFixed(4)), 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) |
| | |
| | | loading.value = true |
| | | loadError.value = '' |
| | | try { |
| | | if (props.receptionStatus === 'NOT_STARTED') { |
| | | rows.value = [] |
| | | stepRows.value = [] |
| | | cycleRows.value = [] |
| | | return |
| | | } |
| | | 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 |
| | | } |
| | |
| | | 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 realtimeTemplateHeaders() { |
| | | return [...templateHeaders(), '温度(℃)', '正电位(V)', '负电位(V)'] |
| | | } |
| | | function downloadTemplate() { |
| | | const worksheet = XLSX.utils.aoa_to_sheet([activeTab.value === 'realtime' ? realtimeTemplateHeaders() : templateHeaders()]) |
| | | const worksheet = XLSX.utils.aoa_to_sheet([Object.values(consoleImportColumns[dataType.value])]) |
| | | const workbook = XLSX.utils.book_new() |
| | | XLSX.utils.book_append_sheet(workbook, worksheet, `${activeTab.value}模板`) |
| | | XLSX.writeFile(workbook, `测试${activeTab.value}数据导入模板.xlsx`) |
| | |
| | | } |
| | | 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() }) |
| | | if (activeTab.value === 'realtime') rows.value = rows.value.filter(row => !ids.includes(row.id)) |
| | | else if (activeTab.value === 'step') stepRows.value = stepRows.value.filter(row => !physicalIds.includes(row.id)) |
| | | else cycleRows.value = cycleRows.value.filter(row => !ids.includes(row.id)) |
| | | await loadRecords() |
| | | selectedIds.value = new Set() |
| | | ElMessage.success(`已从当前有效视图移除 ${ids.length} 条数据`) |
| | | } catch (error) { |
| | |
| | | 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() { |
| | | async function downloadCsv() { |
| | | if (!await loadRecords()) return |
| | | if (!filteredRows.value.length) { |
| | | ElMessage.warning('没有可下载的数据') |
| | | return |
| | |
| | | <td>{{ row.sequenceId }}</td> |
| | | <td>{{ row.recordedAt }}</td> |
| | | <td>{{ row.serverReceivedAt }}</td> |
| | | <td>{{ row.sourceType === 'COLLECTOR' ? `采集器 ${row.collectorCode}` : row.sourceType === 'SNAPSHOT' ? '检测柜 + 采集器' : '检测柜' }}<small v-if="row.sourceType === 'COLLECTOR'">{{ row.channelCode }}</small></td> |
| | | <td :title="row.sourceIdentity">{{ row.sourceType === 'COLLECTOR' ? `采集器 ${row.collectorCode}` : row.sourceType === 'SNAPSHOT' ? '检测柜 + 采集器' : '检测柜' }}<small>{{ row.sourceIdentity }}</small><small v-if="row.sourceType === 'SNAPSHOT'">采集器 {{ row.collectorCode }} · {{ row.collectorDeviceTime }}</small><small v-if="row.sourceType === 'COLLECTOR'">{{ row.channelCode }}</small><small v-if="row.extensionMeasurements !== '-'">{{ row.extensionMeasurements }}</small></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> |
| New file |
| | |
| | | /** 业务接口只接受毫秒时间戳;缺失或非法时间不伪装为当前时间。 */ |
| | | export function businessTime(value: unknown): number | null { |
| | | return typeof value === 'number' && Number.isFinite(value) && Number.isFinite(new Date(value).getTime()) ? value : null |
| | | } |
| | | |
| | | /** 将业务时间按浏览器本地时区展示,保留毫秒精度。 */ |
| | | export function formatBusinessTime(value: unknown): string { |
| | | const time = businessTime(value) |
| | | if (time === null) return '-' |
| | | const date = new Date(time) |
| | | const pad = (part: number, length = 2) => String(part).padStart(length, '0') |
| | | return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}` |
| | | } |
| | |
| | | import { ElMessage, ElMessageBox } from 'element-plus' |
| | | import { useRoute, useRouter } from 'vue-router' |
| | | import * as XLSX from 'xlsx' |
| | | import type { ConsoleDataRow } from '@/stores/consoleTestData' |
| | | import { submitConsoleDataAction, type ConsoleDataType } from '@/api/consoleData' |
| | | import { consoleImportColumns, submitConsoleDataAction, type ConsoleDataType } from '@/api/consoleData' |
| | | import { getTestDetail } from '@/api/tests' |
| | | import { formatBusinessTime } from '@/utils/businessTime' |
| | | |
| | | type ImportStage = 'upload' | 'preview' | 'result' |
| | | type RawExcelRow = Record<string, unknown> |
| | |
| | | sourceRow: number |
| | | raw: RawExcelRow |
| | | errors: string[] |
| | | data: Omit<ConsoleDataRow, 'id'> |
| | | data: Record<string, string | number | null> |
| | | } |
| | | |
| | | const route = useRoute() |
| | |
| | | const stage = ref<ImportStage>('upload') |
| | | const dragging = ref(false) |
| | | |
| | | const requiredHeaders = dataType === 'CYCLE' ? ['记录编号', '电池号', '循环序号', '充电容量(Ah)', '放电容量(Ah)', '充放电效率(%)', '充电能量(Wh)', '放电能量(Wh)'] : dataType === 'STEP' ? ['记录编号', '电池号', '循环序号', '工步序号', '工步', '工步时间', '容量(Ah)', '能量(Wh)', '起始电压(V)', '终止电压(V)', '充电峰值电压(V)', '放电谷值电压(V)'] : ['记录编号', '电池号', '记录时间', '循环序号', '工步序号', '工步名称', '记录序号', '电压(V)', '电流(A)', '容量(Ah)', '能量(Wh)'] |
| | | const numericHeaders = requiredHeaders.filter(header => !['记录编号', '电池号', '记录时间', '工步', '工步名称', '工步时间'].includes(header)) |
| | | const columns = consoleImportColumns[dataType] |
| | | const requiredHeaders = Object.values(columns) |
| | | const validRows = computed(() => validatedRows.value.filter(row => row.errors.length === 0)) |
| | | const failedRows = computed(() => validatedRows.value.filter(row => row.errors.length > 0)) |
| | | const knownBatteries = ref<Set<string>>(new Set()) |
| | | |
| | | function backToDataList() { |
| | | router.push({ path: `/test-list/${testId}/console`, query: { tab: 'data-list', state: canImport.value ? 'ended' : 'running' } }) |
| | | router.push({ path: `/test-list/${testId}/console`, query: { tab: 'data-list', dataType } }) |
| | | } |
| | | |
| | | onMounted(async () => { |
| | |
| | | return String(row[key] ?? '').trim() |
| | | } |
| | | |
| | | function normalizeImportRow(row: RawExcelRow): RawExcelRow { |
| | | if (dataType === 'REALTIME') return { ...row, '工步时间': row['工步时间'] || '-', '电流(A)': row['电流(A)'], '容量(Ah)': row['容量(Ah)'], '能量(Wh)': row['能量(Wh)'], '温度(℃)': row['温度(℃)'] || '0', '正电位(V)': row['正电位(V)'] || '0', '负电位(V)': row['负电位(V)'] || '0' } |
| | | if (dataType === 'STEP') return { ...row, '记录时间': row['工步时间'], '电流(A)': '0', '容量(Ah)': row['容量(Ah)'], '能量(Wh)': row['能量(Wh)'], '温度(℃)': '0', '正电位(V)': '0', '负电位(V)': '0' } |
| | | return { ...row, '记录时间': '-', '工步时间': '-', '工步名称': '循环统计', '工步序号': '1', '电压(V)': '0', '电流(A)': '0', '容量(Ah)': row['充电容量(Ah)'], '能量(Wh)': row['充电能量(Wh)'], '温度(℃)': '0', '正电位(V)': '0', '负电位(V)': '0' } |
| | | } |
| | | |
| | | function validateRow(raw: RawExcelRow, sourceRow: number): ValidatedRow { |
| | | const errors: string[] = [] |
| | | requiredHeaders.forEach(header => { |
| | | if (!textValue(raw, header)) errors.push(`${header}不能为空`) |
| | | }) |
| | | numericHeaders.forEach(header => { |
| | | const data: ValidatedRow['data'] = { sourceType: dataType === 'REALTIME' ? 'SNAPSHOT' : 'CABINET' } |
| | | Object.entries(columns).forEach(([field, header]) => { |
| | | const value = textValue(raw, header) |
| | | if (value && !Number.isFinite(Number(value))) errors.push(`${header}必须是数字`) |
| | | if (['recordNo', 'batteryNo', 'stepName'].includes(field)) { |
| | | data[field] = value |
| | | if (!value) errors.push(`${header}不能为空`) |
| | | } else if (field === 'recordedAt') { |
| | | // Excel 文本按浏览器本地时间解释,提交边界只发送毫秒数。 |
| | | const parsed = /^\d{13}$/.test(value) ? Number(value) : new Date(value).getTime() |
| | | if (!value || !Number.isFinite(parsed)) errors.push('设备时间无效') |
| | | data.recordedAt = Number.isFinite(parsed) ? parsed : null |
| | | data.deviceTime = data.recordedAt |
| | | data.absoluteTime = data.recordedAt |
| | | } else if (['cycleNo', 'stepNo', 'sequenceId'].includes(field)) { |
| | | data[field] = Number(value) |
| | | if (!value || !Number.isInteger(data[field]) || Number(data[field]) < 1) errors.push(`${header}必须是正整数`) |
| | | } else { |
| | | data[field] = !value || value === '-' ? null : Number(value) |
| | | if (data[field] !== null && !Number.isFinite(data[field])) errors.push(`${header}必须是数字`) |
| | | } |
| | | }) |
| | | const batteryNo = textValue(raw, '电池号') |
| | | if (batteryNo && knownBatteries.value.size && !knownBatteries.value.has(batteryNo)) errors.push('电池号不属于当前测试') |
| | | const cycleNo = Number(textValue(raw, '循环序号')) |
| | | const stepNo = Number(textValue(raw, '工步序号')) |
| | | if (!Number.isInteger(cycleNo) || cycleNo < 1) errors.push('循环序号必须是正整数') |
| | | if (!Number.isInteger(stepNo) || stepNo < 1) errors.push('工步序号必须是正整数') |
| | | |
| | | return { |
| | | sourceRow, |
| | | raw, |
| | | errors: [...new Set(errors)], |
| | | data: { |
| | | recordNo: textValue(raw, '记录编号'), |
| | | batteryNo, |
| | | recordedAt: textValue(raw, '记录时间'), |
| | | stepDuration: textValue(raw, '工步时间'), |
| | | cycleNo: cycleNo || 1, |
| | | stepNo: stepNo || 1, |
| | | stepName: textValue(raw, dataType === 'STEP' ? '工步' : '工步名称'), |
| | | voltage: textValue(raw, '电压(V)'), |
| | | current: textValue(raw, '电流(A)'), |
| | | capacity: textValue(raw, '容量(Ah)'), |
| | | energy: textValue(raw, '能量(Wh)'), |
| | | temperature: textValue(raw, '温度(℃)'), |
| | | positivePotential: textValue(raw, '正电位(V)'), |
| | | negativePotential: textValue(raw, '负电位(V)'), |
| | | }, |
| | | } |
| | | return { sourceRow, raw, errors: [...new Set(errors)], data } |
| | | } |
| | | |
| | | async function processFile(file: File) { |
| | |
| | | selectedFile.value = file |
| | | const seen = new Set<string>() |
| | | validatedRows.value = rawRows.map((row, index) => { |
| | | const normalized = normalizeImportRow(row) |
| | | const validated = validateRow(normalized, index + 2) |
| | | const identity = validated.data.recordNo |
| | | const validated = validateRow(row, index + 2) |
| | | const identity = String(validated.data.recordNo || '') |
| | | if (identity && seen.has(identity)) validated.errors.push('本批次存在重复稳定身份') |
| | | if (identity) seen.add(identity) |
| | | return validated |
| | |
| | | } |
| | | |
| | | async function confirmImport() { |
| | | if (!validRows.value.length) { ElMessage.warning('没有可导入的有效数据'); return } |
| | | if (!canImport.value) { |
| | | ElMessage.warning('测试完成后才可以导入数据') |
| | | return |
| | |
| | | try { const result = await ElMessageBox.prompt('请输入本次导入原因', '提交导入', { inputPlaceholder: '必填,将写入数据修订记录', inputValidator: value => value.trim() ? true : '导入原因不能为空' }); reason = result.value.trim() } catch { return } |
| | | const imported = validRows.value.map(item => ({ ...item.data })) |
| | | try { |
| | | await submitConsoleDataAction(testId, { dataType, operationType: 'IMPORT', scopeSummary: `导入${validRows.value.length}条${dataType}数据`, identitySummary: JSON.stringify(imported.map(row => row.recordNo)), changesSummary: JSON.stringify(imported), reason, overrides: imported.map(row => ({ stableIdentity: row.recordNo, fieldValues: JSON.stringify(row), deleted: false })) }) |
| | | await submitConsoleDataAction(testId, { dataType, operationType: 'IMPORT', scopeSummary: `导入${validRows.value.length}条${dataType}数据`, identitySummary: JSON.stringify(imported.map(row => row.recordNo)), changesSummary: JSON.stringify(imported), reason, overrides: imported.map(row => ({ stableIdentity: String(row.recordNo), fieldValues: JSON.stringify(row), deleted: false })) }) |
| | | } catch (error) { ElMessage.error(error instanceof Error ? error.message : '导入提交失败'); return } |
| | | stage.value = 'result' |
| | | } |
| | |
| | | <div v-else-if="stage === 'preview'" class="preview-stage"> |
| | | <div class="file-summary"><div><el-icon><Document /></el-icon><span><strong>{{ selectedFile?.name }}</strong><small>{{ ((selectedFile?.size || 0) / 1024).toFixed(1) }} KB · 已完成字段校验</small></span></div><button type="button" @click="resetUpload">重新选择</button></div> |
| | | <div class="validation-summary"><div><span>总数据</span><strong>{{ validatedRows.length }}</strong></div><div class="success"><span>校验通过</span><strong>{{ validRows.length }}</strong></div><div class="failed"><span>校验失败</span><strong>{{ failedRows.length }}</strong></div></div> |
| | | <div class="preview-table-wrap"><table><thead><tr><th>Excel行号</th><th>电池号</th><th>记录时间</th><th>循环</th><th>工步</th><th>工步名称</th><th>电压(V)</th><th>容量(Ah)</th><th>校验结果</th></tr></thead><tbody><tr v-for="item in validatedRows.slice(0, 12)" :key="item.sourceRow"><td>{{ item.sourceRow }}</td><td>{{ item.data.batteryNo }}</td><td>{{ item.data.recordedAt }}</td><td>{{ item.data.cycleNo }}</td><td>{{ item.data.stepNo }}</td><td>{{ item.data.stepName }}</td><td>{{ item.data.voltage }}</td><td>{{ item.data.capacity }}</td><td><span class="validation-state" :class="item.errors.length ? 'failed' : 'success'">{{ item.errors.length ? item.errors.join(';') : '通过' }}</span></td></tr></tbody></table></div> |
| | | <div class="preview-table-wrap"><table><thead><tr><th>Excel行号</th><th>电池号</th><th>记录时间</th><th>循环</th><th>工步</th><th>工步名称</th><th>电压(V)</th><th>容量(Ah)</th><th>校验结果</th></tr></thead><tbody><tr v-for="item in validatedRows.slice(0, 12)" :key="item.sourceRow"><td>{{ item.sourceRow }}</td><td>{{ item.data.batteryNo }}</td><td>{{ formatBusinessTime(item.data.recordedAt) }}</td><td>{{ item.data.cycleNo }}</td><td>{{ item.data.stepNo }}</td><td>{{ item.data.stepName }}</td><td>{{ item.data.voltage }}</td><td>{{ item.data.capacity }}</td><td><span class="validation-state" :class="item.errors.length ? 'failed' : 'success'">{{ item.errors.length ? item.errors.join(';') : '通过' }}</span></td></tr></tbody></table></div> |
| | | <p v-if="validatedRows.length > 12" class="preview-tip">当前预览前 12 条,共 {{ validatedRows.length }} 条数据</p> |
| | | <div class="stage-actions"><button class="primary-button" type="button" :disabled="!canImport" @click="confirmImport"><el-icon><Check /></el-icon>确认导入</button><button class="secondary-button" type="button" @click="resetUpload">返回上传</button></div> |
| | | </div> |
| | |
| | | import ConsoleDataListPanel from '@/components/ConsoleDataListPanel.vue' |
| | | import { endTestReception, getPersistedTestCycles, getPersistedTestRecords, getPersistedTestSteps, getTestDetail, getTestLiveState, openTestLiveStream, pauseTestReception, resumeTestReception, saveLogicStepMappings, startTestReception, type PersistedTestCycle, type PersistedTestRecord, type PersistedTestStep, type TestConfigurationDetail, type TestLiveState, type TestLogicStepMapping } from '@/api/tests' |
| | | import { getBatteryGroupDetail } from '@/api/batteryGroups' |
| | | import { businessTime, formatBusinessTime as formatDateTime } from '@/utils/businessTime' |
| | | |
| | | type ConsoleState = '待测试' | '检测中' | '暂停' | '检测完成' |
| | | type ReceptionState = '未开始接收' | '接收中' | '暂停接收' | '已结束接收' |
| | |
| | | const testStatusLabel = ref('待测试') |
| | | let closeLiveStream: (() => void) | null = null |
| | | let reconnectTimer: number | null = null |
| | | let disposed = false |
| | | let stopPending = false |
| | | const runtimeAttach = ref(false) |
| | | const receptionState = ref<ReceptionState>('未开始接收') |
| | |
| | | const projectSteps = ref<PersistedTestStep[]>([]) |
| | | const projectCycles = ref<PersistedTestCycle[]>([]) |
| | | const projectCapacityFactor = ref('') |
| | | function formatDateTime(value?: string | null) { |
| | | return value ? value.replace('T', ' ').replace(/Z$/, '').replace(/\.\d{3,6}$/, '') : '-' |
| | | } |
| | | |
| | | // 控制台的柜状态和采集器状态只表达连接事实,设备工作状态由设备事实另行保留。 |
| | | function connectionStatusLabel(status?: string) { |
| | | return status === 'ONLINE' ? '在线' : '离线' |
| | | return status === 'ONLINE' ? '在线' : status === 'OFFLINE' ? '离线' : '-' |
| | | } |
| | | |
| | | function exceptionTitle(item: Record<string, unknown>) { |
| | |
| | | const stepDraft = ref<TestLogicStepMapping[]>([]) |
| | | const selectedPlanSteps = ref<number[]>([]) |
| | | const stepEditing = ref(false) |
| | | const activeTab = ref<ConsoleTab>(route.query.tab === 'data-list' ? 'data-list' : 'realtime') |
| | | const activeTab = ref<ConsoleTab>(['realtime', 'step-chart', 'project-chart', 'data-list'].includes(String(route.query.tab)) ? route.query.tab as ConsoleTab : 'realtime') |
| | | const consoleTabs: Array<{ key: ConsoleTab; label: string }> = [ |
| | | { key: 'realtime', label: '实时数据' }, |
| | | { key: 'step-chart', label: '工步图表' }, |
| | |
| | | const timer = window.setInterval(() => { if (state.value === '检测中') elapsedSeconds.value += 1 }, 1000) |
| | | onBeforeUnmount(() => window.clearInterval(timer)) |
| | | |
| | | function shouldShowLiveConnectionError() { |
| | | return state.value === '检测中' && receptionState.value === '接收中' |
| | | } |
| | | |
| | | function applyLiveState(next: TestLiveState) { |
| | | if (disposed || next.testId !== testId.value) return |
| | | if (stopPending && next.receptionStatus !== 'ENDED') return |
| | | liveState.value = next |
| | | liveConnection.value = next.sources.some(source => Boolean(source.latestSnapshot?.observedAt) |
| | |
| | | } |
| | | |
| | | function scheduleLiveReconnect() { |
| | | if (reconnectTimer !== null) return |
| | | if (disposed || reconnectTimer !== null) return |
| | | reconnectTimer = window.setTimeout(async () => { |
| | | reconnectTimer = null |
| | | try { applyLiveState(await getTestLiveState(testId.value)); connectLiveStream() } |
| | | catch { liveConnection.value = '已断开'; scheduleLiveReconnect() } |
| | | try { applyLiveState(await getTestLiveState(testId.value)); if (!disposed) connectLiveStream() } |
| | | catch (error) { handleLiveError(error instanceof Error ? error : new Error('实时状态加载失败')) } |
| | | }, 2000) |
| | | } |
| | | |
| | | function connectLiveStream() { |
| | | if (disposed) return |
| | | closeLiveStream?.() |
| | | closeLiveStream = openTestLiveStream(testId.value, (event, data) => { |
| | | if (event === 'snapshot' && data && typeof data === 'object') applyLiveState(data as TestLiveState) |
| | | else if (event === 'device-status' && Array.isArray(data) && liveState.value) liveState.value = { ...liveState.value, sources: data as TestLiveState['sources'] } |
| | | else if (event === 'reception-status' && data && typeof data === 'object' && 'receptionStatus' in data) { |
| | | const next = { ...(liveState.value || {}), ...(data as object) } as TestLiveState |
| | | if (liveState.value) applyLiveState({ ...liveState.value, receptionStatus: (data as { receptionStatus: TestLiveState['receptionStatus'] }).receptionStatus }) |
| | | void next |
| | | } |
| | | else if (event === 'test-status' && data && typeof data === 'object' && 'testStatus' in data && liveState.value) |
| | | applyLiveState({ ...liveState.value, testStatus: (data as { testStatus: TestLiveState['testStatus'] }).testStatus }) |
| | |
| | | // 来源异常和测试预警由下方告警面板展示,不能把正常暂停/完成误报成连接错误。 |
| | | liveError.value = '' |
| | | } |
| | | }, error => { |
| | | }, handleLiveError) |
| | | } |
| | | |
| | | function handleLiveError(error: Error) { |
| | | if (disposed) return |
| | | closeLiveStream?.() |
| | | liveConnection.value = error.message.includes('权限') ? '权限不足' : '已断开' |
| | | liveError.value = shouldShowLiveConnectionError() ? error.message : '' |
| | | liveError.value = error.message |
| | | // 断线时不能把最后一份测量继续当作实时值;恢复后由服务端完整快照替换。 |
| | | if (liveState.value) liveState.value = { ...liveState.value, sources: [], testPlan: null } |
| | | scheduleLiveReconnect() |
| | | }) |
| | | } |
| | | |
| | | onMounted(async () => { |
| | | try { |
| | | const detail = await getTestDetail(testId.value) |
| | | if (disposed) return |
| | | backendDetail.value = detail |
| | | stepSettings.value = detail.logicStepMappings || [] |
| | | runtimeAttach.value = detail.runtimeAttach === true |
| | |
| | | receptionState.value = '接收中' |
| | | } |
| | | if (detail.startedAtUtc) { |
| | | const started = Date.parse(detail.startedAtUtc) |
| | | if (Number.isFinite(started)) elapsedSeconds.value = Math.max(0, Math.floor((Date.now() - started) / 1000)) |
| | | const started = businessTime(detail.startedAtUtc) |
| | | if (started !== null) elapsedSeconds.value = Math.max(0, Math.floor(((businessTime(detail.endedAtUtc) ?? Date.now()) - started) / 1000)) |
| | | } |
| | | try { applyLiveState(await getTestLiveState(testId.value)) } catch (error) { |
| | | liveConnection.value = '已断开' |
| | | liveError.value = shouldShowLiveConnectionError() |
| | | ? (error instanceof Error ? error.message : '实时状态加载失败') : '' |
| | | liveError.value = error instanceof Error ? error.message : '实时状态加载失败' |
| | | } |
| | | connectLiveStream() |
| | | if (activeTab.value === 'step-chart') await loadRealtimeRecords() |
| | | if (activeTab.value === 'project-chart') await loadProjectChartData() |
| | | } catch (error) { |
| | | ElMessage.error(error instanceof Error ? error.message : '测试状态加载失败') |
| | | } |
| | | }) |
| | | onBeforeUnmount(() => { closeLiveStream?.(); if (reconnectTimer !== null) window.clearTimeout(reconnectTimer) }) |
| | | onBeforeUnmount(() => { disposed = true; closeLiveStream?.(); if (reconnectTimer !== null) window.clearTimeout(reconnectTimer) }) |
| | | |
| | | async function loadRealtimeRecords() { |
| | | try { realtimeRecords.value = await getPersistedTestRecords(testId.value) } |
| | |
| | | } |
| | | |
| | | watch(activeTab, tab => { |
| | | void router.replace({ query: { ...route.query, tab } }) |
| | | if (tab === 'step-chart') void loadRealtimeRecords() |
| | | if (tab === 'project-chart') void loadProjectChartData() |
| | | }) |
| | |
| | | return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}.${String(ms).padStart(3, '0')}` |
| | | } |
| | | function snapshotNumber(key: string) { |
| | | const snapshots = liveState.value?.sources.map(source => source.latestSnapshot as Record<string, unknown>) || [] |
| | | const values = snapshots.map(snapshot => snapshot[key]).filter(value => Number.isFinite(Number(value))).map(Number) |
| | | return values.length ? Math.max(...values) : undefined |
| | | const source = liveState.value?.sources.find(item => item.sourceType === 'CABINET_CHANNEL' && item.deviceStatus === 'ONLINE') |
| | | const value = source?.latestSnapshot?.[key] |
| | | return typeof value === 'number' && Number.isFinite(value) ? value : undefined |
| | | } |
| | | const currentCycleNo = computed(() => liveState.value?.testPlan?.currentCycleNo |
| | | || snapshotNumber('cycleNo') || snapshotNumber('cycle') || 0) |
| | | ?? snapshotNumber('cycleNo') ?? 0) |
| | | const currentStepNo = computed(() => liveState.value?.testPlan?.currentStepNo |
| | | || snapshotNumber('stepNo') || snapshotNumber('step') || 0) |
| | | ?? snapshotNumber('stepNo') ?? 0) |
| | | const plannedCycleCount = computed(() => liveState.value?.testPlan?.plannedCycleCount || 0) |
| | | const detectorPlanRows = computed(() => liveState.value?.testPlan?.rows || []) |
| | | const actualPlanRows = computed(() => detectorPlanRows.value.filter(row => row.rowType === 'STEP').map((row, index) => ({ ...row, cyclePosition: row.cyclePosition || index + 1 }))) |
| | |
| | | if (state.value === '检测完成') return |
| | | try { |
| | | await ElMessageBox.confirm('停止测试后将不可恢复,系统会停止请求本测试的数据,但不会向设备发送停止命令,是否继续?', '确认停止测试', { confirmButtonText: '停止测试', cancelButtonText: '取消', type: 'warning', confirmButtonClass: 'danger-confirm' }) |
| | | const previousState = state.value |
| | | const previousReceptionState = receptionState.value |
| | | const previousLiveState = liveState.value |
| | | // 停止是不可恢复动作,先更新本地视图,避免等待后端配置重载和采样事务提交。 |
| | | stopPending = true |
| | | state.value = '检测完成' |
| | | receptionState.value = '已结束接收' |
| | | liveError.value = '' |
| | | testStatusLabel.value = '检测完成' |
| | | if (liveState.value) |
| | | liveState.value = { ...liveState.value, testStatus: 'COMPLETED', receptionStatus: 'ENDED', exceptions: [] } |
| | | try { |
| | | backendDetail.value = await endTestReception(testId.value) |
| | | stopPending = false |
| | | } catch (error) { |
| | | stopPending = false |
| | | state.value = previousState |
| | | receptionState.value = previousReceptionState |
| | | testStatusLabel.value = previousState |
| | | liveState.value = previousLiveState |
| | | throw error |
| | | } |
| | | state.value = '检测完成' |
| | | receptionState.value = '已结束接收' |
| | | testStatusLabel.value = '检测完成' |
| | | if (liveState.value) liveState.value = { ...liveState.value, testStatus: 'COMPLETED', receptionStatus: 'ENDED', sources: liveState.value.sources.map(source => ({ ...source, latestSnapshot: {} })), exceptions: [] } |
| | | } finally { stopPending = false } |
| | | ElMessage.success('测试已停止,系统不再请求数据') |
| | | } catch (error) { |
| | | if (error === 'cancel' || error === 'close') return |
| | |
| | | </header> |
| | | <div class="cell-grid"> |
| | | <article v-for="cell in cells" :key="cell.no" class="cell-card"> |
| | | <div class="cell-summary"><strong>{{ cell.no }}</strong><span>{{ profile.batteryGroup }}</span><p>{{ cell.cabinet }} · {{ cell.channel }}</p><small>检测柜来源 · 单位:{{ cell.unit }} · 采样时间:{{ cell.observedAt || '-' }}</small><small>采集器来源 · 状态:{{ cell.collectorStatus }} · 单位:{{ cell.collectorUnit }} · 采样时间:{{ cell.collectorObservedAt || '-' }}</small><small v-if="receptionState === '暂停接收'">暂停期间停止请求,当前快照不纳入结果</small><small>工步进行时间:{{ elapsedText }}</small></div> |
| | | <div class="cell-summary"><strong>{{ cell.no }}</strong><span>{{ profile.batteryGroup }}</span><p>{{ cell.cabinet }} · {{ cell.channel }}</p><small>检测柜来源 · 单位:{{ cell.unit }} · 采样时间:{{ formatDateTime(cell.observedAt) }}</small><small>采集器来源 · 状态:{{ cell.collectorStatus }} · 单位:{{ cell.collectorUnit }} · 采样时间:{{ formatDateTime(cell.collectorObservedAt) }}</small><small v-if="receptionState === '暂停接收'">暂停期间停止请求,当前快照不纳入结果</small><small>工步进行时间:{{ elapsedText }}</small></div> |
| | | <dl class="metric-list"><div><dt>电压(V)</dt><dd class="voltage">{{ cell.voltage }}</dd></div><div><dt>温度(℃)</dt><dd class="temperature">{{ cell.temperature }}</dd></div><div><dt>正电位</dt><dd class="positive">{{ cell.positive }}</dd></div><div><dt>负电位</dt><dd class="negative">{{ cell.negative }}</dd></div><div><dt>容量(Ah)</dt><dd class="capacity">{{ cell.capacity }}</dd></div><div><dt>电流(A)</dt><dd class="current">{{ cell.current }}</dd></div></dl> |
| | | </article> |
| | | </div> |
| | |
| | | const store = await readFile(new URL('../src/stores/tests.ts', import.meta.url), 'utf8') |
| | | |
| | | assert.match(view, /function connectionStatusLabel\(status\?: string\)/) |
| | | assert.match(view, /status === 'ONLINE' \? '在线' : '离线'/) |
| | | assert.match(view, /status === 'ONLINE' \? '在线' : status === 'OFFLINE' \? '离线' : '-'/) |
| | | assert.match(view, /receptionState\.value !== '接收中'/) |
| | | assert.match(view, /liveState\.value = \{ \.\.\.liveState\.value, testStatus: 'COMPLETED', receptionStatus: 'ENDED', exceptions: \[\] \}/) |
| | | assert.match(view, /liveState\.value = \{ \.\.\.liveState\.value, testStatus: 'COMPLETED', receptionStatus: 'ENDED',[^\r\n]+exceptions: \[\] \}/) |
| | | assert.doesNotMatch(view, /STOPPED: '结束'/) |
| | | assert.doesNotMatch(view, /FINISHED: '结束'/) |
| | | assert.match(view, /function exceptionTitle\(item: Record<string, unknown>\)/) |
| | |
| | | |
| | | const view = await readFile(new URL('../src/views/TestConsole.vue', import.meta.url), 'utf8') |
| | | |
| | | assert.match(view, /function shouldShowLiveConnectionError\(\)/) |
| | | assert.match(view, /state\.value === '检测中' && receptionState\.value === '接收中'/) |
| | | assert.match(view, /liveError\.value = shouldShowLiveConnectionError\(\) \? error\.message : ''/) |
| | | assert.match(view, /function handleLiveError\(error: Error\)/) |
| | | assert.match(view, /liveError\.value = error\.message/) |
| | | assert.match(view, /sources: \[\], testPlan: null/) |
| | | assert.match(view, /receptionState\.value = pausing \? '暂停接收' : '接收中'[\s\S]{0,180}liveError\.value = ''/) |
| | | assert.match(view, /receptionState\.value = '已结束接收'[\s\S]{0,220}liveError\.value = ''/) |
| | | assert.match(view, /await endTestReception\(testId\.value\)[\s\S]{0,100}state\.value = '检测完成'/) |
| | | |
| | | console.log('测试控制台暂停/停止生命周期契约通过') |
| New file |
| | |
| | | import assert from 'node:assert/strict' |
| | | import { readFile, mkdir } from 'node:fs/promises' |
| | | import { chromium, expect } from '@playwright/test' |
| | | import * as XLSX from 'xlsx' |
| | | |
| | | // 从生产构建拦截静态资源和 REST/SSE,无需启动开发或业务服务。 |
| | | const browser = await chromium.launch({ channel: 'chrome', headless: true }) |
| | | const context = await browser.newContext({ timezoneId: 'Asia/Hong_Kong', viewport: { width: 1440, height: 1000 }, acceptDownloads: true }) |
| | | const page = await context.newPage() |
| | | const errors = [] |
| | | page.on('pageerror', error => errors.push(error.message)) |
| | | const time = 1788588000123 |
| | | const detail = { testId: 42, testName: 'Ticket11', testStatus: 'COMPLETED', receptionStatus: 'ENDED', startedAtUtc: time, endedAtUtc: time + 60000, createTime: time, batteries: [{ batteryNo: 'B01', collectorId: 7, binding: { channelId: 1, cabinetName: '检测柜一', displayNo: '1' }, collectorChannels: [], fields: [] }], logicStepMappings: [], thresholds: [] } |
| | | const data = { |
| | | records: [{ id: 'realtime:1', stableIdentity: 'realtime:1', recordNo: 'RT-1', sourceType: 'SNAPSHOT', batteryNo: 'B01', cycleNo: 2, stepNo: 3, sequenceId: 10, stepName: '恒流放电', deviceTime: time, collectorDeviceTime: time + 500, serverReceivedAt: time + 1000, voltage: 3.7, current: 1.2, capacity: 2.5, energy: 9.25, temperature: 26, positivePotential: 0.5, negativePotential: null, sourceIdentity: 'adapter-a:1:2:3:4', collectorId: 7, extensionMeasurementsJson: '{"HUMIDITY":{"value":41,"unit":"%","channelCode":"S-04"}}' }], |
| | | steps: [{ id: 'step:1', stableIdentity: 'step:1', recordNo: 'STEP-1', batteryNo: 'B01', cycleNo: 2, stepNo: 3, stepName: '恒流放电', recordedAt: time, serverReceivedAt: time + 1000, capacity: 2.5 }], |
| | | cycles: [{ id: 'cycle:1', stableIdentity: 'cycle:1', recordNo: 'CYCLE-1', batteryNo: 'B01', cycleNo: 2, recordedAt: time, serverReceivedAt: time + 1000, chargeCapacity: 3, dischargeCapacity: 2.5, efficiency: 83.33 }], |
| | | } |
| | | const live = { ...detail, updatedAt: time, sources: [], exceptions: [], testPlan: { currentCycleNo: 2, currentStepNo: 3, plannedCycleCount: 5, rows: [{ rowType: 'STEP', stepNo: 3, cyclePosition: 1, stepName: '设备计划放电', durationSeconds: 60, voltageV: 3.2, executionStatus: '进行中' }] } } |
| | | let liveReads = 0 |
| | | let unavailable = false |
| | | const requests = [] |
| | | await context.addInitScript(() => localStorage.setItem('battery_detection_auth_token', 'ticket11-token')) |
| | | await page.route('http://ticket11.test/**', async route => { |
| | | const url = new URL(route.request().url()) |
| | | if (url.pathname.startsWith('/dev-api/')) { |
| | | requests.push(url.pathname) |
| | | if (unavailable && /\/live(?:-state)?$/.test(url.pathname)) return route.fulfill({ status: 503, json: { code: 503, msg: '后端暂时不可用' } }) |
| | | let result = detail |
| | | if (url.pathname.endsWith('/live-state')) { liveReads++; result = live } |
| | | else if (url.pathname.endsWith('/live')) { |
| | | await new Promise(resolve => setTimeout(resolve, 1500)) |
| | | return route.fulfill({ contentType: 'text/event-stream', body: `event: snapshot\ndata: ${JSON.stringify(live)}\n\n` }) |
| | | } |
| | | else if (url.pathname.includes('/data/')) result = data[url.pathname.split('/').at(-1)] || [] |
| | | else if (url.pathname.endsWith('/console-data/actions')) { |
| | | const action = route.request().postDataJSON() |
| | | const kind = { REALTIME: 'records', STEP: 'steps', CYCLE: 'cycles' }[action.dataType] |
| | | if (action.operationType === 'DELETE') data[kind] = data[kind].filter(row => !JSON.parse(action.identitySummary).includes(row.stableIdentity)) |
| | | else for (const item of action.overrides || []) { |
| | | const row = data[kind].find(row => row.stableIdentity === item.stableIdentity) |
| | | if (row) Object.assign(row, JSON.parse(item.fieldValues)) |
| | | else data[kind].push({ ...JSON.parse(item.fieldValues), stableIdentity: item.stableIdentity }) |
| | | } |
| | | } |
| | | return route.fulfill({ json: { code: 200, data: result } }) |
| | | } |
| | | const asset = url.pathname.startsWith('/assets/') ? url.pathname.slice(1) : 'index.html' |
| | | const contentType = asset.endsWith('.js') ? 'text/javascript' : asset.endsWith('.css') ? 'text/css' : 'text/html' |
| | | return route.fulfill({ contentType, body: await readFile(new URL(`../dist/${asset}`, import.meta.url)) }) |
| | | }) |
| | | try { |
| | | await page.goto('http://ticket11.test/test-list/42/console?tab=data-list') |
| | | const panel = page.locator('.console-data-panel') |
| | | await page.getByText('RT-1', { exact: true }).waitFor() |
| | | assert.match(await page.locator('body').innerText(), /2026-09-05 14:00:00\.123/) |
| | | assert.match(await page.locator('body').innerText(), /HUMIDITY.*41.*%/) |
| | | assert.match(await page.locator('body').innerText(), /S-04/) |
| | | assert.match(await page.locator('body').innerText(), /adapter-a:1:2:3:4/) |
| | | await panel.getByRole('button', { name: '编辑', exact: true }).click() |
| | | const dialog = page.getByRole('dialog') |
| | | await dialog.locator('label').filter({ hasText: '电压(V)' }).locator('input').fill('4.1256') |
| | | await dialog.locator('textarea').fill('核对电压') |
| | | await dialog.getByRole('button', { name: '提交修改', exact: true }).click() |
| | | await page.getByText('4.1256', { exact: true }).waitFor() |
| | | await page.reload() |
| | | await page.getByText('4.1256', { exact: true }).waitFor() |
| | | data.records[0].voltage = 4.3333 |
| | | const exported = page.waitForEvent('download') |
| | | await panel.getByRole('button', { name: '下载', exact: true }).click() |
| | | assert.match(await readFile(await (await exported).path(), 'utf8'), /4.3333/) |
| | | |
| | | // 实际下载模板再上传,覆盖三种导入字段映射及返回子页。 |
| | | for (const [kind, tab, type] of [['records', '实时', 'REALTIME'], ['steps', '工步', 'STEP'], ['cycles', '循环', 'CYCLE']]) { |
| | | await panel.getByRole('button', { name: tab, exact: true }).click() |
| | | const templateDownload = page.waitForEvent('download') |
| | | await panel.getByRole('button', { name: '下载模板', exact: true }).click() |
| | | const template = XLSX.read(await readFile(await (await templateDownload).path())) |
| | | const sheet = template.Sheets[template.SheetNames[0]] |
| | | const headers = XLSX.utils.sheet_to_json(sheet, { header: 1 })[0] |
| | | const values = headers.map(header => ({ '记录编号': `IMPORT-${type}`, '电池号': 'B01', '设备时间': '2026-09-05 14:00:00.123', '循环序号': 2, '工步序号': 3, '记录序号': 11, '工步名称': '导入工步', '工步': '导入工步', '温度(℃)': '-', '负电位(V)': '-' }[header] ?? 1.2345)) |
| | | XLSX.utils.sheet_add_aoa(sheet, [values], { origin: 'A2' }) |
| | | await panel.getByRole('button', { name: '导入', exact: true }).click() |
| | | await page.locator('input[type=file]').setInputFiles({ name: 'ticket11.xlsx', mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', buffer: XLSX.write(template, { type: 'buffer', bookType: 'xlsx' }) }) |
| | | await page.getByText('通过', { exact: true }).waitFor() |
| | | await page.getByRole('button', { name: '确认导入', exact: true }).click() |
| | | await page.locator('.el-message-box input').fill('导入核对') |
| | | await page.locator('.el-message-box').getByRole('button', { name: '确定', exact: true }).click() |
| | | await page.getByText('导入完成', { exact: true }).waitFor() |
| | | const imported = data[kind].find(row => row.recordNo === `IMPORT-${type}`) |
| | | assert.equal(imported.recordedAt, time) |
| | | if (kind === 'records') assert.equal(imported.temperature, null) |
| | | if (kind === 'cycles') assert.equal(imported.dischargeCapacity, 1.2345) |
| | | if (kind === 'steps') assert.equal(imported.endVoltage, 1.2345) |
| | | await page.getByRole('button', { name: '返回数据列表', exact: true }).first().click() |
| | | await page.getByText(`IMPORT-${type}`, { exact: true }).waitFor() |
| | | await page.reload() |
| | | await page.getByText(`IMPORT-${type}`, { exact: true }).waitFor() |
| | | } |
| | | await panel.getByRole('button', { name: '实时', exact: true }).click() |
| | | const original = page.getByRole('row').filter({ has: page.getByText('RT-1', { exact: true }) }) |
| | | await original.getByRole('button', { name: '删除', exact: true }).click() |
| | | await page.locator('.el-message-box input').fill('移除错误数据') |
| | | await page.locator('.el-message-box').getByRole('button', { name: '确定', exact: true }).click() |
| | | await original.waitFor({ state: 'detached' }) |
| | | await page.reload() |
| | | await page.getByText('IMPORT-REALTIME', { exact: true }).waitFor() |
| | | assert.equal(await page.getByText('RT-1', { exact: true }).count(), 0) |
| | | await page.getByRole('button', { name: '工步', exact: true }).click() |
| | | await page.getByText('STEP-1', { exact: true }).waitFor() |
| | | await page.getByRole('button', { name: '循环', exact: true }).click() |
| | | await page.getByText('CYCLE-1', { exact: true }).waitFor() |
| | | await page.reload() |
| | | await page.getByText('CYCLE-1', { exact: true }).waitFor() |
| | | await page.getByRole('button', { name: '实时数据', exact: true }).click() |
| | | await page.getByText('设备计划放电', { exact: true }).waitFor() |
| | | assert.match(await page.locator('.project-meta').innerText(), /2 \/ 计划 5/) |
| | | const before = liveReads |
| | | await page.waitForFunction(() => document.querySelector('.live-error') !== null) |
| | | await new Promise(resolve => setTimeout(resolve, 2400)) |
| | | assert.ok(liveReads > before, 'SSE 断开后重新读取后端快照') |
| | | Object.assign(detail, { testStatus: 'RUNNING', receptionStatus: 'RECEIVING' }) |
| | | Object.assign(live, { testStatus: 'RUNNING', receptionStatus: 'RECEIVING', sources: [{ sourceType: 'CABINET_CHANNEL', batteryNo: 'B01', sourceId: 1, deviceStatus: 'ONLINE', latestSnapshot: { voltageV: 3.8123, observedAt: time, units: ['V'] } }] }) |
| | | await page.reload() |
| | | await expect(page.locator('.metric-list .voltage')).toHaveText('3.8123') |
| | | await expect(page.locator('.cell-summary')).toContainText('2026-09-05 14:00:00.123') |
| | | unavailable = true |
| | | await expect(page.locator('.live-error')).toBeVisible() |
| | | await expect(page.locator('.metric-list .voltage')).toHaveText('-') |
| | | live.sources[0].latestSnapshot.voltageV = 4.7123 |
| | | unavailable = false |
| | | await expect(page.locator('.metric-list .voltage')).toHaveText('4.7123', { timeout: 10000 }) |
| | | Object.assign(detail, { testStatus: 'PAUSED', receptionStatus: 'PAUSED' }) |
| | | Object.assign(live, { testStatus: 'PAUSED', receptionStatus: 'PAUSED' }) |
| | | await page.reload() |
| | | await expect(page.locator('.reception-state')).toHaveText('暂停接收') |
| | | await expect(page.locator('.metric-list .voltage')).toHaveText('-') |
| | | assert.ok(requests.every(url => !url.includes('simulator') && !url.includes('overrides'))) |
| | | assert.deepEqual(errors, []) |
| | | await mkdir(new URL('../test-results/ticket11/', import.meta.url), { recursive: true }) |
| | | await page.screenshot({ path: new URL('../test-results/ticket11/desktop.png', import.meta.url).pathname.replace(/^\/(\w:)/, '$1'), fullPage: true }) |
| | | await page.getByRole('button', { name: '数据列表', exact: true }).click() |
| | | await panel.getByRole('button', { name: '实时', exact: true }).click() |
| | | await page.getByText('IMPORT-REALTIME', { exact: true }).waitFor() |
| | | await page.screenshot({ path: new URL('../test-results/ticket11/data-desktop.png', import.meta.url).pathname.replace(/^\/(\w:)/, '$1'), fullPage: true }) |
| | | await page.setViewportSize({ width: 760, height: 900 }) |
| | | await page.screenshot({ path: new URL('../test-results/ticket11/data-narrow.png', import.meta.url).pathname.replace(/^\/(\w:)/, '$1'), fullPage: true }) |
| | | console.log('Ticket11 浏览器契约通过') |
| | | } finally { |
| | | await browser.close() |
| | | } |