<script setup lang="ts">
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { useRoute, useRouter } from 'vue-router'
|
import ConsoleChartPanel from '@/components/ConsoleChartPanel.vue'
|
import ConsoleProjectChartPanel from '@/components/ConsoleProjectChartPanel.vue'
|
import ConsoleDataListPanel from '@/components/ConsoleDataListPanel.vue'
|
import { endTestReception, getPersistedTestRecords, getTestDetail, getTestLiveState, openTestLiveStream, pauseTestReception, resumeTestReception, saveLogicStepMappings, startTestReception, type PersistedTestRecord, type TestConfigurationDetail, type TestLiveState, type TestLogicStepMapping } from '@/api/tests'
|
|
type ConsoleState = '待测试' | '检测中' | '暂停' | '检测完成'
|
type ReceptionState = '未开始接收' | '接收中' | '暂停接收' | '已结束接收'
|
type ConsoleTab = 'realtime' | 'step-chart' | 'project-chart' | 'data-list'
|
|
const route = useRoute()
|
const router = useRouter()
|
const testId = computed(() => Number(route.params.id))
|
const backendDetail = ref<TestConfigurationDetail | null>(null)
|
const liveState = ref<TestLiveState | null>(null)
|
const liveConnection = ref<'连接中' | '已连接' | '已断开' | '权限不足' | '空数据'>('连接中')
|
const liveError = ref('')
|
const testStatusLabel = ref('待测试')
|
let closeLiveStream: (() => void) | null = null
|
let reconnectTimer: number | null = null
|
const runtimeAttach = ref(false)
|
const receptionState = ref<ReceptionState>('未开始接收')
|
const realtimeRecords = ref<PersistedTestRecord[]>([])
|
function formatDateTime(value?: string | null) {
|
return value ? value.replace('T', ' ').replace(/Z$/, '').replace(/\.\d{3,6}$/, '') : '-'
|
}
|
const profile = computed(() => {
|
const detail = backendDetail.value
|
const batteries = detail?.batteries || []
|
return {
|
name: detail?.testName || '-',
|
owner: detail?.principalName || '-',
|
purpose: detail?.testPurpose || '-',
|
startedAt: formatDateTime(detail?.startedAtUtc),
|
batteryGroup: batteries[0]?.groupName || '-',
|
batteryCount: batteries.length,
|
}
|
})
|
const state = ref<ConsoleState>('待测试')
|
const elapsedSeconds = ref(0)
|
const stepSettings = ref<TestLogicStepMapping[]>([])
|
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 consoleTabs: Array<{ key: ConsoleTab; label: string }> = [
|
{ key: 'realtime', label: '实时数据' },
|
{ key: 'step-chart', label: '工步图表' },
|
{ key: 'project-chart', label: '项目图表' },
|
{ key: 'data-list', label: '数据列表' },
|
]
|
|
const timer = window.setInterval(() => { if (state.value === '检测中') elapsedSeconds.value += 1 }, 1000)
|
onBeforeUnmount(() => window.clearInterval(timer))
|
|
function applyLiveState(next: TestLiveState) {
|
liveState.value = next
|
liveConnection.value = next.sources.some(source => Boolean(source.latestSnapshot?.observedAt)
|
|| Boolean(source.latestSnapshot?.measurements?.length)) ? '已连接' : '空数据'
|
liveError.value = ''
|
const nextState = ({ WAITING: '待测试', RUNNING: '检测中', PAUSED: '暂停', RUNNING_ABNORMAL: '检测中', COMPLETION_PENDING: '检测完成', COMPLETED: '检测完成', TERMINATED: '检测完成' } as Record<string, ConsoleState | undefined>)[next.testStatus] || '待测试'
|
state.value = nextState
|
testStatusLabel.value = nextState
|
const mapped = next.receptionStatus
|
if (mapped === 'NOT_STARTED') receptionState.value = '未开始接收'
|
else if (mapped === 'PAUSED') receptionState.value = '暂停接收'
|
else if (mapped === 'ENDED') receptionState.value = '已结束接收'
|
else receptionState.value = '接收中'
|
}
|
|
function scheduleLiveReconnect() {
|
if (reconnectTimer !== null) return
|
reconnectTimer = window.setTimeout(async () => {
|
reconnectTimer = null
|
try { applyLiveState(await getTestLiveState(testId.value)); connectLiveStream() }
|
catch { liveConnection.value = '已断开'; scheduleLiveReconnect() }
|
}, 2000)
|
}
|
|
function connectLiveStream() {
|
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 })
|
else if (event === 'exception' && Array.isArray(data) && liveState.value) {
|
const current = (data as TestLiveState['exceptions']).filter(item => !item.status || item.status === 'OPEN')
|
liveState.value = { ...liveState.value, exceptions: current }
|
liveError.value = current.length ? '检测来源存在异常' : ''
|
}
|
}, error => {
|
liveConnection.value = error.message.includes('权限') ? '权限不足' : '已断开'
|
liveError.value = error.message
|
scheduleLiveReconnect()
|
})
|
}
|
|
onMounted(async () => {
|
try {
|
const detail = await getTestDetail(testId.value)
|
backendDetail.value = detail
|
stepSettings.value = detail.logicStepMappings || []
|
runtimeAttach.value = detail.runtimeAttach === true
|
if (detail.receptionStatus === 'NOT_STARTED') {
|
state.value = '待测试'
|
receptionState.value = '未开始接收'
|
} else if (detail.receptionStatus === 'PAUSED') {
|
state.value = '暂停'
|
receptionState.value = '暂停接收'
|
} else if (detail.receptionStatus === 'ENDED') {
|
state.value = '检测完成'
|
receptionState.value = '已结束接收'
|
} else {
|
state.value = '检测中'
|
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))
|
}
|
try { applyLiveState(await getTestLiveState(testId.value)) } catch (error) {
|
liveConnection.value = '已断开'
|
liveError.value = error instanceof Error ? error.message : '实时状态加载失败'
|
}
|
connectLiveStream()
|
} catch (error) {
|
ElMessage.error(error instanceof Error ? error.message : '测试状态加载失败')
|
}
|
})
|
onBeforeUnmount(() => { closeLiveStream?.(); if (reconnectTimer !== null) window.clearTimeout(reconnectTimer) })
|
|
async function loadRealtimeRecords() {
|
try { realtimeRecords.value = await getPersistedTestRecords(testId.value) }
|
catch (error) { ElMessage.error(error instanceof Error ? error.message : '实时数据加载失败') }
|
}
|
|
watch(activeTab, tab => {
|
if (tab === 'step-chart') void loadRealtimeRecords()
|
})
|
|
const elapsedText = computed(() => {
|
const hours = Math.floor(elapsedSeconds.value / 3600)
|
const minutes = Math.floor((elapsedSeconds.value % 3600) / 60)
|
const seconds = elapsedSeconds.value % 60
|
return [hours, minutes, seconds].map(value => String(value).padStart(2, '0')).join(':')
|
})
|
|
const cells = computed(() => (backendDetail.value?.batteries || []).map((battery) => {
|
const binding = battery?.binding
|
const batteryNo = battery.batteryNo
|
const batterySources = liveState.value?.sources?.filter(item => item.batteryNo === batteryNo) || []
|
const source = batterySources.find(item => item.sourceType === 'CABINET_CHANNEL') || batterySources[0]
|
const cabinetSource = batterySources.find(item => item.sourceType === 'CABINET_CHANNEL')
|
const collectorSource = batterySources.find(item => item.sourceType === 'COLLECTOR')
|
const metric = (codes: string[], preferred = cabinetSource, snapshotField?: string) => {
|
if (receptionState.value !== '接收中') return undefined
|
const snapshot = preferred?.latestSnapshot
|
const item = (snapshot?.measurements || []).find(value => codes.includes(String(value.metricCode)))
|
if (item && item.value !== undefined && item.value !== null) return String(item.value)
|
const directValue = snapshotField ? snapshot?.[snapshotField] : undefined
|
return directValue !== undefined && directValue !== null ? String(directValue) : undefined
|
}
|
// 采集器一个设备可服务多个电池号,实时值必须同时匹配绑定指标和 S 通道。
|
const collectorMetric = (codes: string[]) => {
|
if (receptionState.value !== '接收中') return undefined
|
const mappings = battery.collectorChannels || []
|
const snapshot = collectorSource?.latestSnapshot
|
const match = (snapshot?.measurements || []).find(item => {
|
const code = String(item.metricCode || '')
|
return codes.includes(code) && mappings.some(mapping => measurementAliases(mapping.measurementCode).includes(code)
|
&& mapping.channelCode === String(item.channelCode || ''))
|
})
|
return match?.value === undefined || match?.value === null ? undefined : String(match.value)
|
}
|
return {
|
no: batteryNo,
|
cabinet: binding?.cabinetName || binding?.cabinetCode || '-',
|
channel: binding?.displayNo ? `通道 ${binding.displayNo}` : '-',
|
voltage: metric(['VOLTAGE', 'voltage'], cabinetSource, 'voltageV') || '-',
|
temperature: collectorMetric(['TEMPERATURE', 'temperature', 'PRIMARY_TEMPERATURE']) || '-',
|
positive: collectorMetric(['POSITIVE_POTENTIAL', 'PRIMARY_POSITIVE_POTENTIAL', 'positive_potential']) || '-',
|
negative: collectorMetric(['NEGATIVE_POTENTIAL', 'PRIMARY_NEGATIVE_POTENTIAL', 'negative_potential']) || '-',
|
capacity: metric(['CAPACITY', 'capacity'], cabinetSource, 'capacityAh') || '-',
|
current: metric(['CURRENT', 'current'], cabinetSource, 'currentA') || '-',
|
source: source?.latestSnapshot?.source || (source?.sourceType === 'COLLECTOR' ? '采集器' : '检测柜'),
|
unit: source?.latestSnapshot?.units?.join('/') || '-',
|
observedAt: source?.latestSnapshot?.observedAt,
|
collectorObservedAt: collectorSource?.latestSnapshot?.observedAt,
|
collectorUnit: collectorSource?.latestSnapshot?.units?.join('/') || '-',
|
collectorStatus: ({ ONLINE: '在线', OFFLINE: '离线', UNKNOWN: '未知', WORKING: '运行', PAUSED: '暂停', STOPPED: '结束', FINISHED: '结束' } as Record<string, string>)[collectorSource?.deviceStatus || 'UNKNOWN'] || '异常',
|
deviceStatus: ({ ONLINE: '在线', OFFLINE: '离线', UNKNOWN: '未知', WORKING: '运行', PAUSED: '暂停', STOPPED: '结束', FINISHED: '结束' } as Record<string, string>)[source?.deviceStatus || 'UNKNOWN'] || '异常',
|
}
|
}))
|
|
// 实时告警只展示后端当前仍开放的异常,异常事实由后端状态和消息说明。
|
const currentExceptions = computed(() => (liveState.value?.exceptions || [])
|
.filter(item => item.sourceType !== 'COLLECTOR' || !item.status || item.status === 'OPEN'))
|
|
function openStepSettings() { stepDraft.value = stepSettings.value.map(item => ({ ...item })); selectedPlanSteps.value = []; stepEditing.value = true }
|
function togglePlanStep(position: number, checked: string | number | boolean) { const next = new Set(selectedPlanSteps.value); checked ? next.add(position) : next.delete(position); selectedPlanSteps.value = [...next].sort((a, b) => a - b) }
|
async function persistStepMappings(mappings: TestLogicStepMapping[]) {
|
const detail = await saveLogicStepMappings(testId.value, mappings)
|
stepSettings.value = detail.logicStepMappings || []
|
backendDetail.value = detail
|
stepDraft.value = stepSettings.value.map(item => ({ ...item }))
|
}
|
async function addStepMapping() {
|
const positions = [...selectedPlanSteps.value].sort((a, b) => a - b)
|
if (positions.length < 2) { ElMessage.warning('请先勾选至少两个连续工步'); return }
|
const contiguous = positions.every((value, index) => index === 0 || value === positions[index - 1] + 1)
|
if (!contiguous) { ElMessage.warning('只能合并连续工步'); return }
|
if (stepDraft.value.some(item => positions[0] <= item.endStepNo && positions[positions.length - 1] >= item.startStepNo)) {
|
ElMessage.warning('选中的工步已在其他合并组中'); return
|
}
|
try {
|
const result = await ElMessageBox.prompt('请输入逻辑工步名称', '合并工步', {
|
inputPlaceholder: '例如:充电阶段', inputValidator: value => value.trim() ? true : '逻辑工步名称不能为空',
|
})
|
const previous = stepDraft.value.map(item => ({ ...item }))
|
const next = stepDraft.value.length + 1
|
stepDraft.value.push({ logicalNo: next, logicalName: result.value.trim(), startStepNo: positions[0], endStepNo: positions[positions.length - 1] })
|
normalizeStepDraft()
|
try {
|
await persistStepMappings(stepDraft.value)
|
selectedPlanSteps.value = []
|
ElMessage.success('工步合并规则已保存')
|
} catch (error) {
|
stepDraft.value = previous
|
ElMessage.error(error instanceof Error ? error.message : '工步合并保存失败')
|
}
|
} catch { /* 用户取消命名或确认 */ }
|
}
|
function normalizeStepDraft() { stepDraft.value.sort((a, b) => a.startStepNo - b.startStepNo); stepDraft.value.forEach((item, i) => { item.logicalNo = i + 1 }) }
|
async function removeStepMapping(index: number) {
|
try {
|
await ElMessageBox.confirm('解除后将立即保存该变更,确定解除合并吗?', '解除合并', {
|
confirmButtonText: '解除合并', cancelButtonText: '取消', type: 'warning',
|
})
|
const previous = stepDraft.value.map(item => ({ ...item }))
|
stepDraft.value.splice(index, 1)
|
normalizeStepDraft()
|
try {
|
await persistStepMappings(stepDraft.value)
|
ElMessage.success('合并规则已解除')
|
} catch (error) {
|
stepDraft.value = previous
|
ElMessage.error(error instanceof Error ? error.message : '解除合并保存失败')
|
}
|
} catch { /* 用户取消解除 */ }
|
}
|
function cancelStepSettings() { stepEditing.value = false; selectedPlanSteps.value = [] }
|
|
interface PlanDisplayStep { no: number; name: string; time: string; voltage: string; state: '进行中' | '已完成' | '待执行' }
|
function formatDuration(seconds: number) {
|
const millis = Math.round(Number(seconds || 0) * 1000)
|
const h = Math.floor(millis / 3600000); const m = Math.floor((millis % 3600000) / 60000); const s = Math.floor((millis % 60000) / 1000); const ms = millis % 1000
|
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 currentCycleNo = computed(() => liveState.value?.testPlan?.currentCycleNo
|
|| snapshotNumber('cycleNo') || snapshotNumber('cycle') || 0)
|
const currentStepNo = computed(() => liveState.value?.testPlan?.currentStepNo
|
|| snapshotNumber('stepNo') || snapshotNumber('step') || 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 })))
|
const planRows = computed<PlanDisplayStep[]>(() => {
|
const physicalSteps = actualPlanRows.value
|
const consumed = new Set<string>()
|
const displayRows: PlanDisplayStep[] = []
|
detectorPlanRows.value.forEach(row => {
|
if (row.rowType !== 'STEP') {
|
displayRows.push({ no: row.stepNo, name: row.stepName, time: row.rowType === 'CYCLE' ? `起始工步 ${row.stepNo || '-'}` : '-', voltage: row.rowType === 'CYCLE' ? `当前循环 ${row.cycleCount || '-'}` : '-', state: (row.executionStatus || '待执行') as PlanDisplayStep['state'] })
|
return
|
}
|
const physical = physicalSteps.find(item => item.stepNo === row.stepNo && item.stepName === row.stepName) || physicalSteps.find(item => item.stepNo === row.stepNo)
|
const position = physical?.cyclePosition || row.stepNo
|
const mapping = stepSettings.value.find(item => position >= item.startStepNo && position <= item.endStepNo)
|
const key = mapping ? `mapping:${mapping.startStepNo}-${mapping.endStepNo}` : `position:${position}`
|
if (consumed.has(key)) return
|
const grouped = mapping ? physicalSteps.filter(item => { const itemMapping = stepSettings.value.find(candidate => item.cyclePosition >= candidate.startStepNo && item.cyclePosition <= candidate.endStepNo); return itemMapping?.startStepNo === mapping.startStepNo && itemMapping.endStepNo === mapping.endStepNo }) : [physical || row]
|
grouped.forEach(item => consumed.add(String(item.cyclePosition)))
|
consumed.add(key)
|
const state = grouped.some(item => item.executionStatus === '进行中') ? '进行中' : grouped.every(item => item.executionStatus === '已完成') ? '已完成' : '待执行'
|
displayRows.push({ no: mapping?.logicalNo || row.stepNo, name: mapping?.logicalName || row.stepName, time: formatDuration(grouped.reduce((sum, item) => sum + Number(item.durationSeconds || 0), 0)), voltage: grouped[grouped.length - 1].voltageV == null ? '-' : `${Number(grouped[grouped.length - 1].voltageV).toFixed(4)} V`, state })
|
})
|
return displayRows
|
})
|
|
const parameters = computed(() => {
|
const detail = backendDetail.value
|
if (!detail) return [] as Array<[string, string]>
|
const thresholds = (detail.thresholds || []).map(item => `${item.metricCode}: ${item.minValue ?? '-'} ~ ${item.maxValue ?? '-'} ${item.unit}`).join(';')
|
const sharedFields = (detail.fieldSnapshots || []).map(item => `${item.fieldName}: ${item.fieldValue ?? '-'}`).join(';')
|
return [
|
['测试名称', detail.testName || '-'], ['创建者', detail.creatorName || '-'], ['负责人', detail.principalName || '-'],
|
['测试类型', detail.testTypeName || '-'], ['测试目的', detail.testPurpose || '-'], ['创建时间', formatDateTime(detail.createTime)],
|
['开始接收', formatDateTime(detail.startedAtUtc)], ['监测超时', detail.monitorTimeoutMinutes == null ? '-' : `${detail.monitorTimeoutMinutes} 分钟`],
|
['预警阈值', thresholds || '-'], ['共享字段', sharedFields || '-'], ['备注', detail.remark || '-'],
|
] as Array<[string, string]>
|
})
|
|
interface CollectorColumn {
|
key: string
|
label: string
|
measurementCode: string
|
}
|
|
function measurementAliases(measurementCode: string) {
|
if (measurementCode.includes('TEMPERATURE')) return [measurementCode, 'temperature', 'TEMPERATURE']
|
if (measurementCode.includes('POSITIVE_POTENTIAL')) return [measurementCode, 'positive_potential', 'POSITIVE_POTENTIAL']
|
if (measurementCode.includes('NEGATIVE_POTENTIAL')) return [measurementCode, 'negative_potential', 'NEGATIVE_POTENTIAL']
|
return [measurementCode]
|
}
|
|
const collectorColumns = computed<CollectorColumn[]>(() => {
|
const columns = new Map<string, CollectorColumn>()
|
for (const battery of backendDetail.value?.batteries || []) {
|
for (const mapping of battery.collectorChannels || []) {
|
const measurementCode = String(mapping.measurementCode || '').trim()
|
if (!measurementCode || columns.has(measurementCode)) continue
|
columns.set(measurementCode, {
|
key: measurementCode,
|
label: mapping.measurementName || measurementCode,
|
measurementCode,
|
})
|
}
|
}
|
return [...columns.values()]
|
})
|
|
const channels = computed(() => cells.value.map((cell) => {
|
const sources = liveState.value?.sources?.filter(item => item.batteryNo === cell.no) || []
|
const cabinet = sources.find(item => item.sourceType === 'CABINET_CHANNEL')
|
const collector = sources.find(item => item.sourceType === 'COLLECTOR')
|
const battery = backendDetail.value?.batteries?.find(item => item.batteryNo === cell.no)
|
const label = (status?: string) => ({ ONLINE: '在线', OFFLINE: '离线', UNKNOWN: '未知', WORKING: '运行', PAUSED: '暂停', STOPPED: '结束', FINISHED: '结束' } as Record<string, string>)[status || 'UNKNOWN'] || '异常'
|
const measurements: Record<string, string> = {}
|
if (receptionState.value === '接收中') {
|
const sourceMeasurements = collector?.latestSnapshot?.measurements || []
|
for (const mapping of battery?.collectorChannels || []) {
|
const measurementCode = String(mapping.measurementCode || '')
|
if (!measurementCode) continue
|
const measurement = sourceMeasurements.find(item => measurementAliases(measurementCode).includes(String(item.metricCode || ''))
|
&& String(item.channelCode || '') === String(mapping.channelCode || ''))
|
if (measurement?.value !== undefined && measurement?.value !== null) measurements[measurementCode] = String(measurement.value)
|
}
|
}
|
return { no: cell.no, cabinet: cell.cabinet, cabinetState: label(cabinet?.deviceStatus), channel: cell.channel,
|
collector: battery?.collectorName || '-', collectorState: label(collector?.deviceStatus), measurements }
|
}))
|
|
async function togglePause() {
|
if (state.value === '检测完成' || state.value === '待测试') return
|
const pausing = state.value !== '暂停'
|
try {
|
await ElMessageBox.confirm(
|
pausing
|
? '暂停接收将停止系统向检测柜和采集器请求新数据,但设备仍继续运行,不会向设备发送暂停命令,是否继续?'
|
: '继续接收将恢复系统数据请求,不会追认暂停期间数据,也不会向设备发送继续命令,是否继续?',
|
pausing ? '确认暂停接收' : '确认继续接收',
|
{ confirmButtonText: pausing ? '暂停接收' : '继续接收', cancelButtonText: '取消', type: 'warning' },
|
)
|
backendDetail.value = pausing
|
? await pauseTestReception(testId.value)
|
: await resumeTestReception(testId.value)
|
state.value = pausing ? '暂停' : '检测中'
|
receptionState.value = pausing ? '暂停接收' : '接收中'
|
ElMessage.success(pausing ? '已暂停接收,系统已停止请求新数据' : '已继续接收,系统已恢复数据请求')
|
} catch (error) {
|
if (error === 'cancel' || error === 'close') return
|
ElMessage.error(error instanceof Error ? error.message : (pausing ? '暂停接收失败' : '继续接收失败'))
|
}
|
}
|
|
async function startReception() {
|
if (state.value !== '待测试') return
|
try {
|
backendDetail.value = await startTestReception(testId.value)
|
runtimeAttach.value = backendDetail.value.runtimeAttach === true
|
state.value = '检测中'
|
receptionState.value = '接收中'
|
ElMessage.success('已开始接收,未向设备发送控制命令')
|
} catch (error) {
|
ElMessage.error(error instanceof Error ? error.message : '开始接收失败')
|
}
|
}
|
|
async function endTest() {
|
if (state.value === '检测完成') return
|
try {
|
await ElMessageBox.confirm('停止测试后将不可恢复,系统会停止请求本测试的数据,但不会向设备发送停止命令,是否继续?', '确认停止测试', { confirmButtonText: '停止测试', cancelButtonText: '取消', type: 'warning', confirmButtonClass: 'danger-confirm' })
|
backendDetail.value = await endTestReception(testId.value)
|
state.value = '检测完成'
|
receptionState.value = '已结束接收'
|
testStatusLabel.value = ({ WAITING: '待测试', RUNNING: '检测中', PAUSED: '暂停', RUNNING_ABNORMAL: '检测中', COMPLETION_PENDING: '检测完成', COMPLETED: '检测完成', TERMINATED: '检测完成' } as Record<string, string>)[backendDetail.value.testStatus || 'COMPLETED'] || '检测完成'
|
ElMessage.success('测试已停止,系统不再请求数据')
|
} catch (error) {
|
if (error === 'cancel' || error === 'close') return
|
ElMessage.error(error instanceof Error ? error.message : '停止测试失败')
|
}
|
}
|
</script>
|
|
<template>
|
<div class="console-page">
|
<div class="page-tools"><button class="back-button" type="button" @click="router.push('/test-list')"><el-icon><ArrowLeft /></el-icon>返回测试列表</button><span>TEST CONSOLE · #{{ testId }}</span></div>
|
|
<nav class="console-tabs" aria-label="控制台页面导航"><button v-for="tab in consoleTabs" :key="tab.key" type="button" :class="{ active: activeTab === tab.key }" @click="activeTab = tab.key">{{ tab.label }}</button></nav>
|
|
<div v-show="activeTab === 'realtime'" class="tab-content">
|
<section class="realtime-panel">
|
<header class="realtime-header">
|
<div><strong>实时数据</strong><span class="cell-count">{{ cells.length }} 个电池</span><span class="running-state" :class="state"><i></i>{{ state }}</span><span v-if="runtimeAttach" class="attach-state">运行中接入</span></div>
|
<dl><div><dt>测试开始时间</dt><dd>{{ profile.startedAt }}</dd></div><div><dt>测试已进行时间</dt><dd>{{ elapsedText }}</dd></div></dl>
|
<div class="test-actions"><button v-if="state === '待测试'" class="primary-button" type="button" @click="startReception"><el-icon><VideoPlay /></el-icon>开始接收</button><button v-else class="primary-button" type="button" :disabled="state === '检测完成'" @click="togglePause"><el-icon><VideoPause v-if="state !== '暂停'" /><VideoPlay v-else /></el-icon>{{ state === '暂停' ? '继续接收' : '暂停接收' }}</button><button class="danger-button" type="button" :disabled="state === '检测完成' || state === '待测试'" @click="endTest"><el-icon><CircleCloseFilled /></el-icon>停止测试</button></div>
|
</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>
|
<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>
|
</section>
|
<p v-if="liveError" class="live-error" role="status">{{ liveError }},正在尝试恢复实时状态</p>
|
|
<div class="console-layout">
|
<main>
|
<section class="panel step-panel"><header class="panel-heading"><div><i></i><strong>项目参数</strong><span>STEP PARAMETERS</span></div><div class="step-actions"><template v-if="stepEditing"><button class="primary-button compact-button" type="button" @click="addStepMapping"><el-icon><Plus /></el-icon>合并选中</button><button class="secondary-button compact-button" type="button" @click="cancelStepSettings">取消</button></template><button v-else class="primary-button compact-button" type="button" @click="openStepSettings"><el-icon><Setting /></el-icon>工步设置</button></div></header><div class="project-meta"><span>测试项目 <strong>{{ profile.purpose }}</strong></span><span>当前循环 <strong>{{ currentCycleNo || '-' }} / 计划 {{ plannedCycleCount || '-' }}</strong></span><span>当前工步 <strong>{{ currentStepNo || '-' }}</strong></span><span>执行状态 <strong>{{ testStatusLabel }}</strong></span></div><div class="step-table-wrap"><table><thead><tr><th v-if="stepEditing">选择</th><th>工步号</th><th>工步名称</th><th>时间(h:min:s.ms)</th><th>电压(V)</th><th>执行状态</th></tr></thead><tbody><template v-if="stepEditing"><tr v-for="step in actualPlanRows" :key="`edit-${step.cyclePosition}`" :class="{ active: step.executionStatus === '进行中' }"><td><el-checkbox :model-value="selectedPlanSteps.includes(step.cyclePosition)" @change="(checked: string | number | boolean) => togglePlanStep(step.cyclePosition, checked)" /></td><td>{{ step.stepNo }}</td><td><strong>{{ step.stepName }}</strong></td><td>{{ formatDuration(step.durationSeconds) }}</td><td>{{ step.voltageV == null ? '-' : `${Number(step.voltageV).toFixed(4)} V` }}</td><td><span class="step-state" :class="step.executionStatus">{{ step.executionStatus }}</span></td></tr></template><template v-else><tr v-for="(step, index) in planRows" :key="`${index}-${step.no}-${step.name}`" :class="{ active: step.state === '进行中' }"><td>{{ step.no }}</td><td><strong>{{ step.name }}</strong></td><td>{{ step.time }}</td><td>{{ step.voltage }}</td><td><span class="step-state" :class="step.state">{{ step.state }}</span></td></tr></template><tr v-if="!planRows.length && !actualPlanRows.length"><td :colspan="stepEditing ? 6 : 5" class="empty-cell">暂无后端检测柜工步方案</td></tr></tbody></table></div><div v-if="stepEditing && stepDraft.length" class="mapping-list"><div v-for="(item, index) in stepDraft" :key="index" class="mapping-row"><strong>逻辑工步 {{ item.logicalNo }}</strong><span>第 {{ item.startStepNo }} 至 {{ item.endStepNo }} 个工步</span><button class="secondary-button compact-button" type="button" aria-label="解除合并" @click="removeStepMapping(index)"><el-icon><Delete /></el-icon>解除合并</button></div></div></section>
|
<section class="panel alarm-panel"><header class="panel-heading"><div><i></i><strong>异常警告</strong><span>ALARM</span></div></header><div v-if="currentExceptions.length" class="alarm-list"><div v-for="(item, index) in currentExceptions" :key="`${item.sourceId || item.sourceType}-${item.channelCode || index}-${index}`"><el-icon><WarningFilled /></el-icon><span><strong>{{ item.sourceType === 'COLLECTOR' ? `采集器${item.collectorName || item.collectorCode || `#${item.sourceId}`}${item.status === 'OPEN' ? '离线' : '异常已处理'} · ${item.channelCode || '来源'}` : (item.message || '数据来源当前不可观测') }}</strong><small>{{ item.measurementName ? `${item.measurementName}受影响 · ` : '' }}{{ item.message || (item.status === 'OPEN' ? '当前异常' : '异常已处理') }}</small></span></div></div><div v-else class="alarm-empty"><el-icon><CircleCheckFilled /></el-icon>暂无异常</div></section>
|
<section class="panel channel-panel"><header class="panel-heading"><div><i></i><strong>通道</strong><span>CHANNEL STATUS</span></div></header><div class="channel-table-wrap"><table :style="{ minWidth: `${1120 + collectorColumns.length * 120}px` }"><thead><tr><th>电池号</th><th>测试柜</th><th>柜状态</th><th>检测通道</th><th>采集器</th><th>采集器状态</th><th v-for="column in collectorColumns" :key="column.key">{{ column.label }}</th></tr></thead><tbody><tr v-for="item in channels" :key="item.no"><td><strong>{{ item.no }}</strong></td><td>{{ item.cabinet }}</td><td><span class="device-state" :class="item.cabinetState">{{ item.cabinetState }}</span></td><td>{{ item.channel }}</td><td>{{ item.collector }}</td><td><span class="device-state" :class="{ 在线: item.collectorState === '在线', 异常: item.collectorState !== '在线' }">{{ item.collectorState }}</span></td><td v-for="column in collectorColumns" :key="column.key">{{ item.measurements[column.measurementCode] || '-' }}</td></tr><tr v-if="!channels.length"><td :colspan="6 + collectorColumns.length" class="empty-cell">暂无通道配置</td></tr></tbody></table></div></section>
|
</main>
|
<aside class="parameter-panel"><header class="panel-heading"><div><i></i><strong>测试参数</strong><span>TEST SETTINGS</span></div></header><dl><div v-for="item in parameters" :key="item[0]"><dt>{{ item[0] }}</dt><dd>{{ item[1] }}</dd></div></dl></aside>
|
</div>
|
</div>
|
|
<ConsoleChartPanel v-if="activeTab === 'step-chart'" :test-id="testId" :cells="cells" :records="realtimeRecords" :step-plan-rows="detectorPlanRows" :logic-step-mappings="stepSettings" :console-detail="backendDetail?.consoleDetail" :test-name="profile.name" :battery-group="profile.batteryGroup" />
|
<ConsoleProjectChartPanel v-if="activeTab === 'project-chart'" :cells="cells" />
|
|
<ConsoleDataListPanel v-if="activeTab === 'data-list' && backendDetail" :test-id="testId" :test-name="profile.name" :cells="cells" :can-import="state === '检测完成'" :reception-status="backendDetail.receptionStatus" :bindings="backendDetail.batteries.map(item => ({ batteryNo: item.batteryNo, bindingId: item.binding?.bindingId, channelId: item.binding?.channelId, devid: item.binding?.devid, chlid: item.binding?.chlid }))" />
|
|
</div>
|
</template>
|
|
<style scoped>
|
.console-page{width:100%;padding-bottom:24px}.page-tools{height:34px;display:flex;align-items:center;justify-content:space-between}.page-tools>span{color:#7f8d99;font-size:10px;font-weight:600}.back-button{display:inline-flex;align-items:center;gap:6px;border:0;color:#52606d;background:transparent;font-size:13px;cursor:pointer}.back-button:hover{color:var(--blue)}.primary-button,.secondary-button,.danger-button{height:34px;display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:0 13px;border:1px solid transparent;border-radius:3px;font-size:13px;font-weight:500;cursor:pointer;white-space:nowrap}.primary-button{color:#fff;background:var(--blue);border-color:var(--blue)}.secondary-button{color:#52606d;background:#fff;border-color:var(--line-strong)}.danger-button{color:#fff;background:#ee6666;border-color:#ee6666}.primary-button:disabled,.danger-button:disabled{opacity:.5;cursor:not-allowed}.realtime-panel,.panel,.parameter-panel{border:1px solid #d8e1e9;background:#fff}.realtime-panel{margin-bottom:16px}.realtime-header{min-height:96px;display:grid;grid-template-columns:minmax(190px,1fr) minmax(360px,2fr) auto;align-items:center;gap:24px;padding:16px 20px;border-bottom:1px solid var(--line)}.realtime-header>div:first-child{display:flex;align-items:center;gap:12px}.realtime-header strong{font-size:15px}.cell-count{color:#71808d;font-size:11px}.running-state{display:inline-flex;align-items:center;gap:6px;padding:4px 7px;border:1px solid;font-size:11px}.running-state i{width:5px;height:5px;border-radius:50%;background:currentColor}.running-state.检测中{color:#23865e;background:#edf9f3;border-color:#b8dfcd}.running-state.暂停{color:#b06a18;background:#fff8e9;border-color:#efd59e}.running-state.已结束{color:#7b8794;background:#f2f4f6;border-color:#d8dde2}.running-state.未开始接收{color:#7b8794;background:#f2f4f6;border-color:#d8dde2}.realtime-header dl{display:flex;gap:44px}.realtime-header dl>div{display:flex;align-items:center;gap:16px}.realtime-header dt{color:#52606d;font-size:12px}.realtime-header dd{color:#7b8794;font-family:'Cascadia Mono',Consolas,monospace;font-size:12px;font-weight:600}.test-actions{display:flex;gap:10px}.cell-grid{display:grid;grid-template-columns:repeat(4,minmax(190px,1fr));gap:24px;padding:22px 36px}.cell-card{min-width:0;border:1px solid #bec9d3;border-radius:6px;background:#f5f7fa;overflow:hidden}.cell-summary{display:flex;flex-direction:column;gap:7px;padding:16px 18px 12px;border-bottom:1px solid #c8d1d9}.cell-summary strong{color:#1268dc;font-size:13px}.cell-summary span{color:#37688e;font-size:12px;font-weight:600}.cell-summary p,.cell-summary small{color:#71808d;font-size:10px}.metric-list{display:grid;gap:12px;padding:14px 18px 16px}.metric-list>div{display:flex;align-items:center;justify-content:space-between}.metric-list dt{color:#52606d;font-size:10px}.metric-list dd{font-size:17px;font-weight:700}.metric-list .voltage{color:#7f35c5}.metric-list .temperature{color:#bf6100}.metric-list .positive{color:#e99f9f}.metric-list .negative{color:#176ee8}.metric-list .capacity{color:#f59a2a}.metric-list .current{color:#21628c}.console-layout{display:grid;grid-template-columns:minmax(0,2.35fr) minmax(270px,1fr);gap:16px;align-items:start}.console-layout main{display:grid;gap:16px}.panel-heading{height:48px;display:flex;align-items:center;justify-content:space-between;padding:0 16px;border-bottom:1px solid var(--line);background:#fbfdff}.panel-heading>div{display:flex;align-items:center;gap:8px}.panel-heading>div>i{width:3px;height:16px;background:#2b78a3}.panel-heading strong{color:#26333f;font-size:14px}.panel-heading span{color:#81909d;font-size:10px}.compact-button{height:30px;font-size:12px}.project-meta{display:flex;gap:32px;padding:14px 16px 0;color:#71808d;font-size:11px}.project-meta strong{margin-left:8px;color:#31404d}.step-table-wrap,.channel-table-wrap{overflow-x:auto;padding:14px 16px 18px}table{width:100%;border-collapse:collapse;color:#52606d;font-size:12px}th,td{height:38px;padding:0 10px;border:1px solid #d5e0e9;text-align:center;white-space:nowrap}th{color:#52606d;background:#eaf4ff;font-weight:600}.step-table tr.active td{background:#eef7ff}.step-table tr.active td:first-child{border-left:3px solid #2188f5}.step-state{font-size:11px}.step-state.进行中{color:#1677dd}.step-state.已完成{color:#23865e}.step-state.待执行{color:#8795a3}.parameter-panel dl{display:grid;gap:0;padding:12px 18px 18px}.parameter-panel dl>div{display:grid;grid-template-columns:88px minmax(0,1fr);gap:10px;padding:9px 0;border-bottom:1px solid #edf1f5}.parameter-panel dl>div:last-child{border-bottom:0}.parameter-panel dt{color:#71808d;font-size:11px}.parameter-panel dd{min-width:0;color:#52606d;font-size:11px;overflow-wrap:anywhere}.alarm-empty{height:68px;display:flex;align-items:center;gap:8px;margin:14px 16px;padding:0 16px;color:#71808d;background:#f4f7fa;font-size:12px}.alarm-empty .el-icon{color:#23865e;font-size:16px}.alarm-list{display:grid;gap:8px;padding:14px 16px}.alarm-list>div{display:flex;align-items:center;gap:10px;padding:10px 12px;border:1px solid #f0c8a3;background:#fff7ef;color:#b35a25}.alarm-list span,.alarm-list strong,.alarm-list small{display:block}.alarm-list strong{font-size:12px}.alarm-list small{margin-top:3px;color:#8c6849;font-size:10px}.channel-table-wrap table{min-width:1120px}.device-state{display:inline-flex;padding:3px 7px;border:1px solid;font-size:10px}.device-state.在线{color:#23865e;background:#edf9f3;border-color:#b8dfcd}.device-state.异常{color:#b34f4f;background:#fff1f1;border-color:#e9bcbc}.step-dialog-content{display:grid;gap:12px}.step-dialog-content>p{color:#71808d;font-size:12px}.step-dialog-content>strong{color:#26333f}.step-dialog-content dl{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}.step-dialog-content dl>div{padding:12px;border:1px solid #d8e1e9;background:#f8fafc}.step-dialog-content dt{color:#8795a3;font-size:10px}.step-dialog-content dd{margin-top:5px;color:#405261;font-size:12px}@media(max-width:1280px){.cell-grid{gap:16px;padding:20px}.console-layout{grid-template-columns:minmax(0,1fr) 280px}}@media(max-width:1050px){.cell-grid{grid-template-columns:repeat(2,minmax(210px,1fr))}.realtime-header{grid-template-columns:1fr auto}.realtime-header dl{grid-column:1/-1;grid-row:2}.console-layout{grid-template-columns:1fr}.parameter-panel{grid-row:2}}@media(max-width:680px){.realtime-header{grid-template-columns:1fr}.realtime-header dl{grid-column:auto;grid-row:auto;align-items:flex-start;flex-direction:column;gap:10px}.test-actions{justify-content:stretch}.test-actions button{flex:1}.cell-grid{grid-template-columns:1fr;padding:14px}.project-meta{flex-direction:column;gap:8px}.step-dialog-content dl{grid-template-columns:1fr}}
|
|
.reception-state{padding:4px 7px;border:1px solid #b9decf;color:#23865e;background:#edf9f3;font-size:11px}.test-state{padding:4px 7px;border:1px solid #d8dde2;color:#52606d;background:#f5f7fa;font-size:11px}.attach-state{padding:4px 7px;border:1px solid #bdd8f4;color:#1677dd;background:#edf6ff;font-size:11px}
|
.live-connection{display:inline-flex;align-items:center;gap:5px;padding:4px 7px;border:1px solid #cbd5df;color:#52606d;background:#f5f7fa;font-size:11px}.live-connection i{width:5px;height:5px;border-radius:50%;background:currentColor}.live-connection.已连接{color:#23865e;background:#edf9f3;border-color:#b8dfcd}.live-connection.权限不足{color:#b34f4f;background:#fff1f1;border-color:#e9bcbc}.live-error{margin:8px 20px 14px;padding:9px 12px;border:1px solid #e9bcbc;color:#a94444;background:#fff5f5;font-size:12px}
|
/* 双栏收缩边界:宽表只能在所属面板内横向滚动,不能撑开主网格。 */
|
.console-tabs{min-height:52px;display:flex;align-items:center;gap:10px;margin-bottom:14px;padding:0 4px;border-bottom:1px solid #d5e0e9}.console-tabs button{height:30px;min-width:88px;padding:0 16px;border:1px solid transparent;border-radius:16px;color:#52606d;background:#f5f7fa;font-size:12px;cursor:pointer;transition:.16s ease}.console-tabs button:hover{color:var(--blue);background:#edf5fd}.console-tabs button.active{color:#1677dd;background:#dff0ff;border-color:#c4e3ff;font-weight:600}.tab-content{min-width:0}.data-list-panel{min-width:0;border:1px solid #d8e1e9;background:#fff}.data-list-panel .panel-heading>small{color:#8795a3;font-size:10px}.data-table-wrap{width:100%;overflow-x:auto;padding:16px 18px 20px}.data-table-wrap table{min-width:1260px}.data-table-wrap td strong{color:#1268dc}.data-voltage{color:#7f35c5;font-weight:600}.data-temperature{color:#bf6100;font-weight:600}.console-layout{grid-template-columns:minmax(0,2.35fr) minmax(300px,1fr);gap:24px;min-width:0}.console-layout>main,.console-layout>.parameter-panel,.console-layout main>.panel{min-width:0;max-width:100%}.step-table-wrap,.channel-table-wrap{width:100%;max-width:100%;min-width:0;overflow-x:auto;overflow-y:hidden}.step-table-wrap table{min-width:720px}.channel-table-wrap table{width:1120px;min-width:1120px;max-width:none}.parameter-panel{position:relative;z-index:1;overflow:hidden}@media(max-width:1280px){.console-layout{grid-template-columns:minmax(0,1fr) 300px;gap:20px}}@media(max-width:1050px){.console-layout{grid-template-columns:minmax(0,1fr);gap:16px}.parameter-panel{grid-row:2}}@media(max-width:600px){.console-tabs{overflow-x:auto}.console-tabs button{flex:0 0 auto}.data-table-wrap{padding:12px}}
|
.device-state.离线{color:#b34f4f;background:#fff1f1;border-color:#e9bcbc}
|
.device-state.未知{color:#7b8794;background:#f2f4f6;border-color:#d8dde2}.mapping-list{display:grid;gap:10px;max-height:360px;overflow:auto;margin:0 16px 16px;padding-top:12px;border-top:1px solid #edf1f5}.mapping-row{display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:8px;padding:8px 10px;border:1px solid #d8e1e9;background:#f8fafc}.mapping-row strong{color:#52606d;font-size:12px}.mapping-row span{color:#71808d;font-size:11px}.mapping-row .compact-button{height:28px}.mapping-row :deep(.el-input-number){width:100%}.step-actions{display:flex;align-items:center;gap:8px}
|
.running-state.检测完成{color:#7b8794;background:#f2f4f6;border-color:#d8dde2}
|
</style>
|