yeg
2026-09-28 be3757f2433803cb3bfc4c47b54cd8cdff8575e5
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
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 TestListParams {
  pageNum: number
  pageSize: number
  testName?: string
  testTypeId?: number
  batteryNo?: string
  status?: string
  beginCreateTime?: string
  endCreateTime?: string
}
 
export interface TestPlanStepPayload {
  planStepId?: number
  cycleNo: number
  stepNo: number
  stepType: string
  stepName: string
  parameters: Record<string, unknown>
  sortOrder: number
}
 
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; 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; fieldDefinitionId?: number; fieldName: string; fieldType?: string; fieldValue: string }>
}
 
export interface TestThresholdPayload {
  thresholdId?: number
  metricCode: string
  minValue: number | null
  maxValue: number | null
  unit: 'A' | 'V' | 'Ah' | 'Wh' | '℃'
  severity: string
}
 
export interface TestConfigurationPayload {
  testId?: number
  testName: string
  principalId: number
  testTypeId: number
  testPurpose: string
  monitorTimeoutMinutes: number
  remark: string | null
  batteries: TestBatteryPayload[]
  planSteps: TestPlanStepPayload[]
  thresholds: TestThresholdPayload[]
}
 
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
  startedAtUtc?: number | null
  endedAtUtc?: number | null
  /** 历史兼容字段;新接口不返回测试字段快照,字段从 batteries[].fields 读取。 */
  fieldSnapshots?: Array<{
    snapshotId: number
    fieldDefinitionId: number
    fieldName: string
    fieldType: string
    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 {
  users: Array<{ userId: number; userName: string; nickName: string | null }>
  testTypes: Array<{ testTypeId: number; testTypeName: string }>
  batteries: Array<{ relationId: number; groupId: number; groupName: string; batteryNo: string }>
  channels: Array<{ channelId: number; cabinetId: number; cabinetCode: string; cabinetName: string; adapterCode: string; devtype: number; devid: number; subdevid: number; chlid: number; displayNo: string | null }>
  collectors: Array<{ collectorId: number; collectorCode: string; collectorName: string; channelCount: number }>
}
 
interface TestDto {
  testId: number
  testName: string | null
  testTypeName: string | null
  principalName: string | null
  testPurpose: string | null
  testStatus: TestStatusCode | null
  receptionStatus: ReceptionStatusCode | null
  batteryNo: string | null
  createTime: number | null
  startedAtUtc: number | null
  endedAtUtc: number | null
  runtimeAttach?: boolean | null
  remark: string | null
  batteryCount: number | null
}
 
interface TestListResponse extends ApiResponse { total: number; rows: TestDto[] }
interface TestDetailResponse extends ApiResponse { data: TestConfigurationDetail }
interface TestOptionsResponse extends ApiResponse { data: TestConfigurationOptions }
export interface TestListResult { total: number; rows: TestRecord[] }
 
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: '已结束接收',
}
function mapTest(item: TestDto): TestRecord {
  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: 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',
  }
}
 
export async function getTestList(params: TestListParams): Promise<TestListResult> {
  const searchParams = new URLSearchParams()
  Object.entries(params).forEach(([key, value]) => {
    if (value === undefined || value === '') return
    if (key === 'status') searchParams.set('testStatus', String(value))
    else searchParams.set(key, String(value))
  })
  const result = await request<TestListResponse>(`/system/test/list?${searchParams.toString()}`)
  return { total: Number(result.total) || 0, rows: (result.rows || []).map(mapTest) }
}
 
export async function getTestConfigurationOptions(): Promise<TestConfigurationOptions> {
  return (await request<TestOptionsResponse>('/system/test/options')).data
}
 
export async function getTestDetail(testId: number): Promise<TestConfigurationDetail> {
  return (await request<TestDetailResponse>(`/system/test/${testId}`)).data
}
 
export async function createTest(payload: TestConfigurationPayload): Promise<TestConfigurationDetail> {
  return (await request<TestDetailResponse>('/system/test', { method: 'POST', body: JSON.stringify(payload) })).data
}
 
export async function updateTest(payload: TestConfigurationPayload & { testId: number }): Promise<TestConfigurationDetail> {
  return (await request<TestDetailResponse>('/system/test', { method: 'PUT', body: JSON.stringify(payload) })).data
}
 
export async function deleteTests(testIds: number[]): Promise<void> {
  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 async function stopTest(testId: number): Promise<TestConfigurationDetail> {
  return (await request<TestDetailResponse>(`/system/test/${testId}/stop`, { method: 'POST' })).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 {
  sourceType: 'CABINET_CHANNEL' | 'COLLECTOR' | string
  sourceId: number
  batteryNo: string
  deviceStatus: string
  latestSnapshot: {
    source?: string
    unit?: string
    observedAt?: number
    sampleIntervalMs?: number
    /** 通道工作状态;读库来源下为 UNOBSERVABLE,界面按“无法观测”展示。 */
    workStatus?: string
    /** 来源里最近一次采样行的设备时间(毫秒);没有采样行时为空。 */
    lastDataAt?: number | null
    measurements?: Array<Record<string, unknown>>
    units?: string[]
    [key: string]: unknown
  }
}
 
export interface TestLiveState {
  testId: number
  testStatus: TestStatusCode
  receptionStatus: ReceptionStatusCode
  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 interface BtsRecord {
  sequenceId: number
  deviceTime: string
  currentA: number
  voltageV: number
  capacityAh: number
  energyWh: number
  cycleNo: number
  stepNo: number
  stepName: string
  stepType: string
}
 
export interface BtsStepRecord {
  cycleNo: number
  stepNo: number
  cyclePosition: number
  stepName: string
  stepType: string
  recordCount: number
  startedAt: string
  endedAt: string
  capacityAh: number
  energyWh: number
  durationSeconds: number
  startVoltageV: number
  endVoltageV: number
  chargePeakVoltageV: number
  dischargeValleyVoltageV: number
}
 
export interface BtsCycleRecord {
  recordNo: string
  cycleNo: number
  recordCount: number
  startedAt: string
  endedAt: string
  chargeCapacityAh: number
  dischargeCapacityAh: number
  chargeEnergyWh: number
  dischargeEnergyWh: number
  efficiency: number
}
 
async function getSimulatorChannelData<T>(deviceId: number, channelId: number, kind: 'records' | 'steps' | 'cycles'): Promise<T[]> {
  const token = getAuthToken()
  const response = await fetch(`${import.meta.env.VITE_API_BASE_URL || '/dev-api'}/dev/bts-simulator/devices/${deviceId}/channels/${channelId}/${kind}`, { headers: token ? { Authorization: `Bearer ${token}` } : undefined })
  if (!response.ok) throw new Error(`检测柜${kind}数据加载失败(${response.status})`)
  const result = await response.json() as T[]
  return Array.isArray(result) ? result : []
}
 
export function getBtsRecords(deviceId: number, channelId: number): Promise<BtsRecord[]> {
  return getSimulatorChannelData<BtsRecord>(deviceId, channelId, 'records')
}
 
export function getBtsSteps(deviceId: number, channelId: number): Promise<BtsStepRecord[]> {
  return getSimulatorChannelData<BtsStepRecord>(deviceId, channelId, 'steps')
}
 
export function getBtsCycles(deviceId: number, channelId: number): Promise<BtsCycleRecord[]> {
  return getSimulatorChannelData<BtsCycleRecord>(deviceId, channelId, 'cycles')
}
 
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'
 
/** 订阅实时监控观测与提醒(接收中后端约 5 秒观测);使用鉴权 SSE,调用返回函数关闭连接。 */
export function openTestLiveStream(testId: number, onEvent: (event: TestLiveEvent, data: unknown) => void,
  onError: (error: Error) => void): () => void {
  const controller = new AbortController()
  const token = getAuthToken()
  void (async () => {
    try {
      const response = await fetch(`${import.meta.env.VITE_API_BASE_URL || '/dev-api'}/system/test/${testId}/live`, {
        headers: token ? { Authorization: `Bearer ${token}`, Accept: 'text/event-stream' } : { Accept: 'text/event-stream' },
        signal: controller.signal,
      })
      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 = ''
      let eventName: TestLiveEvent = 'snapshot'
      let data = ''
      while (true) {
        const chunk = await reader.read()
        if (chunk.done) throw new Error('实时流已断开')
        buffer += decoder.decode(chunk.value, { stream: true })
        const lines = buffer.split(/\r?\n/)
        buffer = lines.pop() || ''
        for (const line of lines) {
          if (line.startsWith('event:')) eventName = line.slice(6).trim() as TestLiveEvent
          else if (line.startsWith('data:')) data += line.slice(5).trim()
          else if (line === '' && data) {
            try { onEvent(eventName, JSON.parse(data)) } catch { onError(new Error('实时流数据格式错误')) }
            eventName = 'snapshot'
            data = ''
          }
        }
      }
    } catch (error) {
      if (!controller.signal.aborted) onError(error instanceof Error ? error : new Error('实时流连接失败'))
    }
  })()
  return () => controller.abort()
}