yeg
2026-09-05 7835baa4f22d5ff39b95f5cb05aefc9d3ceb67ae
对齐测试控制台数据与实时状态

统一毫秒时间展示,补充扩展测量来源,修正三类模板导入及刷新后事实恢复。增加无需服务启动的浏览器契约验证。
2个文件已添加
9个文件已修改
441 ■■■■■ 已修改文件
src/api/consoleData.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/dataRevisions.ts 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/tests.ts 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/ConsoleChartPanel.vue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/ConsoleDataListPanel.vue 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/businessTime.ts 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ConsoleDataImport.vue 79 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/TestConsole.vue 83 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tests/test-console-alarm-and-status.contract.test.mjs 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tests/test-console-live-lifecycle.contract.test.mjs 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tests/test-ticket11-browser.mjs 151 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/consoleData.ts
@@ -3,6 +3,12 @@
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
src/api/dataRevisions.ts
@@ -1,11 +1,12 @@
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 {
@@ -21,7 +22,7 @@
  reason: string
  operatorId: number | null
  operatorName: string
  createdAtUtc: string
  createdAtUtc: number
}
interface RevisionListResponse extends ApiResponse { total: number; rows: DataRevisionAction[] }
src/api/tests.ts
@@ -1,5 +1,6 @@
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'
@@ -68,15 +69,15 @@
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
@@ -121,9 +122,9 @@
  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
@@ -147,8 +148,8 @@
  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',
  }
}
@@ -217,7 +218,7 @@
  latestSnapshot: {
    source?: string
    unit?: string
    observedAt?: string
    observedAt?: number
    sampleIntervalMs?: number
    measurements?: Array<Record<string, unknown>>
    units?: string[]
@@ -229,7 +230,7 @@
  testId: number
  testStatus: TestStatusCode
  receptionStatus: ReceptionStatusCode
  updatedAt: string
  updatedAt: number
  sources: LiveSource[]
  exceptions: Array<Record<string, unknown>>
  testPlan?: BtsTestSnapshot | null
@@ -351,10 +352,12 @@
  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
@@ -363,6 +366,8 @@
  positivePotential?: number | string | null
  negativePotential?: number | string | null
  extensionMeasurementsJson?: string | Record<string, unknown> | null
  sourceIdentity?: string
  sourceRecordId?: string
  deviceId: number
  channelId: number
  adapterId?: number
@@ -434,7 +439,8 @@
        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 = ''
src/components/ConsoleChartPanel.vue
@@ -393,9 +393,8 @@
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) {
src/components/ConsoleDataListPanel.vue
@@ -5,7 +5,8 @@
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 {
@@ -48,6 +49,8 @@
  positivePotential: string
  negativePotential: string
  extensionMeasurements: string
  sourceIdentity: string
  collectorDeviceTime: string
  chargeCapacity: string
  dischargeCapacity: string
  efficiency: string
@@ -109,19 +112,11 @@
          { 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'
@@ -132,10 +127,10 @@
  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)
@@ -151,22 +146,19 @@
  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
  }
@@ -221,14 +213,8 @@
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`)
@@ -329,9 +315,7 @@
  }
  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) {
@@ -342,6 +326,8 @@
  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()
@@ -349,7 +335,8 @@
function escapeCsv(value: unknown) {
  return `"${String(value ?? '').replaceAll('"', '""')}"`
}
function downloadCsv() {
async function downloadCsv() {
  if (!await loadRecords()) return
  if (!filteredRows.value.length) {
    ElMessage.warning('没有可下载的数据')
    return
@@ -500,7 +487,7 @@
              <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>
src/utils/businessTime.ts
New file
@@ -0,0 +1,13 @@
/** 业务接口只接受毫秒时间戳;缺失或非法时间不伪装为当前时间。 */
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)}`
}
src/views/ConsoleDataImport.vue
@@ -3,9 +3,9 @@
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>
@@ -14,7 +14,7 @@
  sourceRow: number
  raw: RawExcelRow
  errors: string[]
  data: Omit<ConsoleDataRow, 'id'>
  data: Record<string, string | number | null>
}
const route = useRoute()
@@ -29,14 +29,14 @@
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 () => {
@@ -53,49 +53,32 @@
  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) {
@@ -128,9 +111,8 @@
    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
@@ -161,6 +143,7 @@
}
async function confirmImport() {
  if (!validRows.value.length) { ElMessage.warning('没有可导入的有效数据'); return }
  if (!canImport.value) {
    ElMessage.warning('测试完成后才可以导入数据')
    return
@@ -169,7 +152,7 @@
  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'
}
@@ -206,7 +189,7 @@
      <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>
src/views/TestConsole.vue
@@ -7,6 +7,7 @@
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 = '未开始接收' | '接收中' | '暂停接收' | '已结束接收'
@@ -22,6 +23,7 @@
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>('未开始接收')
@@ -29,13 +31,10 @@
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>) {
@@ -74,7 +73,7 @@
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: '工步图表' },
@@ -85,11 +84,8 @@
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)
@@ -108,23 +104,22 @@
}
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 })
@@ -134,16 +129,23 @@
      // 来源异常和测试预警由下方告警面板展示,不能把正常暂停/完成误报成连接错误。
      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
@@ -161,21 +163,21 @@
      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) }
@@ -183,6 +185,7 @@
}
watch(activeTab, tab => {
  void router.replace({ query: { ...route.query, tab } })
  if (tab === 'step-chart') void loadRealtimeRecords()
  if (tab === 'project-chart') void loadProjectChartData()
})
@@ -326,14 +329,14 @@
  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 })))
@@ -468,28 +471,14 @@
  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
@@ -513,7 +502,7 @@
      </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>
tests/test-console-alarm-and-status.contract.test.mjs
@@ -7,9 +7,9 @@
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>\)/)
tests/test-console-live-lifecycle.contract.test.mjs
@@ -3,10 +3,10 @@
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('测试控制台暂停/停止生命周期契约通过')
tests/test-ticket11-browser.mjs
New file
@@ -0,0 +1,151 @@
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()
}