src/api/tests.ts
@@ -1,7 +1,8 @@
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' | 'RUNNING_ABNORMAL' | 'COMPLETION_PENDING' | 'COMPLETED' | 'TERMINATED'
export type TestStatusCode = 'WAITING' | 'RUNNING' | 'PAUSED' | 'COMPLETED'
export type ReceptionStatusCode = 'NOT_STARTED' | 'RECEIVING' | 'PAUSED' | 'ENDED'
export interface TestListParams {
@@ -27,18 +28,20 @@
export interface TestBatteryPayload {
  relationId?: number
  testBatteryId?: number
  groupId: number
  groupName?: string | null
  batteryNo: string
  collectorId: number
  collectorName?: string | null
  binding: { bindingId?: number; channelId: number; expectedBarcode: string | null; claimStatus?: string; cabinetCode?: string | null; cabinetName?: string | null; displayNo?: string | null }
  binding: { bindingId?: number; channelId: number; expectedBarcode: string | null; claimStatus?: string; cabinetCode?: string | null; cabinetName?: string | null; displayNo?: string | null; devtype?: number | null; devid?: number | null; subdevid?: number | null; chlid?: number | null }
  collectorChannels: Array<{
    mappingId?: number
    measurementCode: string
    measurementName: string
    channelCode: string
  }>
  fields: Array<{ fieldId?: number; fieldName: string; fieldValue: string }>
  fields: Array<{ fieldId?: number; fieldDefinitionId?: number; fieldName: string; fieldType?: string; fieldValue: string }>
}
export interface TestThresholdPayload {
@@ -66,12 +69,17 @@
export interface TestConfigurationDetail extends TestConfigurationPayload {
  testId: number
  creatorId: number
  createTime?: number | null
  updateTime?: number | null
  creatorName: string | null
  principalName: string | null
  testTypeName: string | null
  testStatus: TestStatusCode
  receptionStatus: ReceptionStatusCode
  fieldSnapshots: Array<{
  startedAtUtc?: number | null
  endedAtUtc?: number | null
  /** 历史兼容字段;新接口不返回测试字段快照,字段从 batteries[].fields 读取。 */
  fieldSnapshots?: Array<{
    snapshotId: number
    fieldDefinitionId: number
    fieldName: string
@@ -79,6 +87,22 @@
    fieldValue: string | null
  }>
  runtimeAttach?: boolean
  logicStepMappings: TestLogicStepMapping[]
  consoleDetail?: TestConsoleDetail | null
}
export interface TestConsoleDetail {
  experimentName: string | null
  batteryFields: string[]
  specialFields: string[]
  fieldValues: Record<string, Record<string, string>>
}
export interface TestLogicStepMapping {
  logicalNo: number
  logicalName: string
  startStepNo: number
  endStepNo: number
}
export interface TestConfigurationOptions {
@@ -98,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
@@ -111,24 +135,21 @@
interface TestOptionsResponse extends ApiResponse { data: TestConfigurationOptions }
export interface TestListResult { total: number; rows: TestRecord[] }
const statusMap: Record<TestStatusCode, TestStatus> = {
  WAITING: '待测试', RUNNING: '检测中', PAUSED: '暂停', RUNNING_ABNORMAL: '检测中(异常)',
  COMPLETION_PENDING: '完成确认中', COMPLETED: '检测完成', TERMINATED: '已终止',
export const testStatusLabels: Record<string, TestStatus> = {
  WAITING: '待测试', RUNNING: '检测中', PAUSED: '暂停', COMPLETED: '检测完成',
  // 历史数据只作兼容展示,不会由新流程写入。
  RUNNING_ABNORMAL: '检测中', COMPLETION_PENDING: '检测完成', TERMINATED: '检测完成',
}
const receptionStatusMap: Record<ReceptionStatusCode, ReceptionStatus> = {
  NOT_STARTED: '未开始接收', RECEIVING: '接收中', PAUSED: '暂停接收', ENDED: '已结束接收',
}
const legacyStatusMap: Record<string, TestStatusCode> = {
  '0': 'WAITING', '1': 'RUNNING', '2': 'RUNNING_ABNORMAL', '3': 'COMPLETED',
}
function mapTest(item: TestDto): TestRecord {
  const status = item.testStatus ? statusMap[item.testStatus] : '未知'
  const status = item.testStatus ? testStatusLabels[item.testStatus] : '未知'
  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',
  }
}
@@ -137,7 +158,7 @@
  const searchParams = new URLSearchParams()
  Object.entries(params).forEach(([key, value]) => {
    if (value === undefined || value === '') return
    if (key === 'status') searchParams.set('testStatus', legacyStatusMap[String(value)] || String(value))
    if (key === 'status') searchParams.set('testStatus', String(value))
    else searchParams.set(key, String(value))
  })
  const result = await request<TestListResponse>(`/system/test/list?${searchParams.toString()}`)
@@ -164,39 +185,29 @@
  await request<ApiResponse>(`/system/test/${testIds.join(',')}`, { method: 'DELETE' })
}
/** 原子开始接收;后端确认全部来源后才会改变正式状态。 */
/** 建立测试接收边界;来源离线不阻断状态,只有成功观测数据才进入测试事实。 */
export async function startTestReception(testId: number): Promise<TestConfigurationDetail> {
  return (await request<TestDetailResponse>(`/system/test/${testId}/reception/start`, { method: 'POST' })).data
}
/** 暂停系统数据接收;设备实时观察继续进行。 */
/** 暂停系统数据请求;设备不接收控制命令。 */
export async function pauseTestReception(testId: number): Promise<TestConfigurationDetail> {
  return (await request<TestDetailResponse>(`/system/test/${testId}/reception/pause`, { method: 'POST' })).data
}
/** 继续系统数据接收;后端从新的成功观测边界打开区间。 */
/** 继续系统数据请求;恢复暂停后的实时请求。 */
export async function resumeTestReception(testId: number): Promise<TestConfigurationDetail> {
  return (await request<TestDetailResponse>(`/system/test/${testId}/reception/resume`, { method: 'POST' })).data
}
export interface TestReceptionInterval {
  intervalId: number
  testId: number
  bindingId: number | null
  sourceType: string
  sourceId: number
  sourceChannelCode: string | null
  actionAtUtc: string
  startBoundaryAtUtc: string
  endBoundaryAtUtc: string | null
  boundaryNote: string | null
  intervalStatus: 'OPEN' | 'CLOSED' | string
/** 人工停止测试并将本次接收标记为检测完成。 */
export async function stopTest(testId: number): Promise<TestConfigurationDetail> {
  return (await request<TestDetailResponse>(`/system/test/${testId}/stop`, { method: 'POST' })).data
}
interface TestReceptionIntervalsResponse extends ApiResponse { data: TestReceptionInterval[] }
export async function getTestReceptionIntervals(testId: number): Promise<TestReceptionInterval[]> {
  return (await request<TestReceptionIntervalsResponse>(`/system/test/${testId}/reception/intervals`)).data || []
/** 人工结束接收的独立动作接口;保留 stopTest 兼容旧页面调用。 */
export async function endTestReception(testId: number): Promise<TestConfigurationDetail> {
  return (await request<TestDetailResponse>(`/system/test/${testId}/reception/end`, { method: 'POST' })).data
}
export interface LiveSource {
@@ -207,8 +218,12 @@
  latestSnapshot: {
    source?: string
    unit?: string
    observedAt?: string
    observedAt?: number
    sampleIntervalMs?: number
    /** 通道工作状态;读库来源下为 UNOBSERVABLE,界面按“无法观测”展示。 */
    workStatus?: string
    /** 来源里最近一次采样行的设备时间(毫秒);没有采样行时为空。 */
    lastDataAt?: number | null
    measurements?: Array<Record<string, unknown>>
    units?: string[]
    [key: string]: unknown
@@ -219,20 +234,147 @@
  testId: number
  testStatus: TestStatusCode
  receptionStatus: ReceptionStatusCode
  updatedAt: string
  updatedAt: number
  sources: LiveSource[]
  exceptions: Array<Record<string, unknown>>
  testPlan?: BtsTestSnapshot | null
}
export interface BtsTestPlanRow {
  rowType: 'STEP' | 'CYCLE' | 'END' | string
  stepNo: number
  cyclePosition: number | null
  stepName: string
  durationSeconds: number
  voltageV: number | null
  executionStatus: string
  cycleCount: number | null
  recordCount: number | null
}
export interface BtsTestSnapshot {
  currentCycleNo: number
  currentStepNo: number
  plannedCycleCount: number
  rows: BtsTestPlanRow[]
}
export async function saveLogicStepMappings(testId: number, mappings: TestLogicStepMapping[]): Promise<TestConfigurationDetail> {
  return (await request<TestDetailResponse>(`/system/test/${testId}/logic-step-mappings`, { method: 'PUT', body: JSON.stringify(mappings) })).data
}
interface TestLiveStateResponse extends ApiResponse { data: TestLiveState }
/** 读取实时监控当前观测与提醒;不查询三子页持久化记录。 */
export async function getTestLiveState(testId: number): Promise<TestLiveState> {
  return (await request<TestLiveStateResponse>(`/system/test/${testId}/live-state`)).data
}
export async function saveTestConsoleDetail(testId: number, detail: TestConsoleDetail): Promise<TestConfigurationDetail> {
  return (await request<TestDetailResponse>(`/system/test/${testId}/console-detail`, { method: 'PUT', body: JSON.stringify(detail) })).data
}
export interface PersistedTestRecord {
  id: number | string
  stableIdentity?: string
  sampleId?: number
  sourceType?: 'CABINET' | 'COLLECTOR' | 'SNAPSHOT' | string
  metricCode?: string
  metricValue?: number | string | null
  unit?: string | null
  collectorId?: number
  collectorCode?: string
  channelCode?: string
  recordNo: string
  batteryNo: string
  cycleNo: number
  stepNo: number
  stepName: string
  stepType?: string
  sequenceId: number
  cabinetSequenceId?: number | null
  relativeTimeSeconds?: number | string | null
  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
  energy: number | string | null
  temperature?: 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
  adapterCode?: string
  devtype?: number
  subdevid?: number
}
export interface PersistedTestStep extends PersistedTestRecord {
  actualStepLabel: string
  stepDuration: number | string | null
  startVoltage: number | string | null
  endVoltage: number | string | null
  chargeMedianVoltage: number | string | null
  dischargeMedianVoltage: number | string | null
}
export interface PersistedTestCycle extends PersistedTestRecord {
  chargeCapacity: number | string | null
  dischargeCapacity: number | string | null
  efficiency: number | string | null
  chargeEnergy: number | string | null
  dischargeEnergy: number | string | null
  /** 来源类别:DEVICE 设备提供、AGGREGATED 本系统按工步层聚合、IMPORTED 授权文件导入。 */
  sourceKind?: string | null
}
// 三子页和图表查询持久化当前有效数据,不回放实时监控或触发预警判定。
async function getPersistedTestData<T>(testId: number, kind: 'records' | 'steps' | 'cycles'): Promise<T[]> {
  const result = await request<ApiResponse & { data?: T[] }>(`/system/test/${testId}/data/${kind}`)
  return Array.isArray(result.data) ? result.data : []
}
export function getPersistedTestRecords(testId: number): Promise<PersistedTestRecord[]> {
  return getPersistedTestData<PersistedTestRecord>(testId, 'records')
}
export function getPersistedTestSteps(testId: number): Promise<PersistedTestStep[]> {
  return getPersistedTestData<PersistedTestStep>(testId, 'steps')
}
export function getPersistedTestCycles(testId: number): Promise<PersistedTestCycle[]> {
  return getPersistedTestData<PersistedTestCycle>(testId, 'cycles')
}
export interface WarningExceptionRecord {
  exceptionId: number; testId: number; testName?: string; sourceType: string; sourceIdentity: string;
  exceptionType: 'THRESHOLD' | 'DEVICE_OFFLINE';
  metricCode: string; abnormalValue: number | null; unit: string; ruleSnapshot: string; severity: string;
  exceptionStatus: 'OPEN' | 'CLOSED'; firstOccurredAtUtc: number; lastOccurredAtUtc: number;
  occurrenceCount: number; handledByName?: string; handledAtUtc?: number | null; handlingNote?: string;
}
export async function getWarningExceptions(params: { testId?: number; status?: string } = {}): Promise<WarningExceptionRecord[]> {
  const query = new URLSearchParams(); if (params.testId) query.set('testId', String(params.testId)); if (params.status) query.set('status', params.status);
  const result = await request<ApiResponse & { rows?: WarningExceptionRecord[] }>(`/system/test-warning-exceptions/list?${query}`)
  return result.rows || []
}
export async function closeWarningException(exceptionId: number, handlingNote: string): Promise<WarningExceptionRecord> {
  const result = await request<ApiResponse & { data: WarningExceptionRecord }>(`/system/test-warning-exceptions/${exceptionId}/close`, { method: 'PUT', body: JSON.stringify({ handlingNote }) })
  return result.data
}
export type TestLiveEvent = 'snapshot' | 'device-status' | 'reception-status' | 'test-status' | 'exception' | 'heartbeat'
/** 使用 fetch 流读取鉴权 SSE;调用返回函数关闭连接,断线由页面决定是否重连。 */
/** 订阅实时监控观测与提醒(接收中后端约 5 秒观测);使用鉴权 SSE,调用返回函数关闭连接。 */
export function openTestLiveStream(testId: number, onEvent: (event: TestLiveEvent, data: unknown) => void,
  onError: (error: Error) => void): () => void {
  const controller = new AbortController()
@@ -243,7 +385,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 = ''