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
|
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
|
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
|
chargePeakVoltage: number | string | null
|
dischargeValleyVoltage: 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
|
}
|
|
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;
|
metricCode: string; abnormalValue: number; unit: string; ruleSnapshot: string; severity: string;
|
exceptionStatus: 'OPEN' | 'CLOSED'; firstOccurredAtUtc: string; lastOccurredAtUtc: string;
|
occurrenceCount: number; handledByName?: string; handledAtUtc?: string; 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;调用返回函数关闭连接,断线由页面决定是否重连。 */
|
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()
|
}
|