| | |
| | | import ConsoleProjectChartPanel from '@/components/ConsoleProjectChartPanel.vue' |
| | | import ConsoleDataListPanel from '@/components/ConsoleDataListPanel.vue' |
| | | import { useTests } from '@/stores/tests' |
| | | import { getTestDetail, getTestLiveState, openTestLiveStream, startTestReception, type TestConfigurationDetail, type TestLiveState } from '@/api/tests' |
| | | import { getTestDetail, getTestLiveState, getTestReceptionIntervals, openTestLiveStream, pauseTestReception, resumeTestReception, startTestReception, type TestConfigurationDetail, type TestLiveState, type TestReceptionInterval } from '@/api/tests' |
| | | |
| | | type ConsoleState = '未开始接收' | '检测中' | '暂停' | '已结束' |
| | | type ReceptionState = '未开始接收' | '接收中' | '暂停接收' | '已结束接收' |
| | |
| | | let reconnectTimer: number | null = null |
| | | const runtimeAttach = ref(false) |
| | | const receptionState = ref<ReceptionState>('未开始接收') |
| | | const receptionIntervals = ref<TestReceptionInterval[]>([]) |
| | | const intervalError = ref('') |
| | | const testProfiles: Record<number, { name: string; owner: string; purpose: string; startedAt: string; batteryGroup: string; batteryCount: number; abnormal: boolean }> = { |
| | | 1024: { name: '高温循环性能测试', owner: '张工', purpose: '验证高温环境循环寿命', startedAt: '2026-07-10 14:40:00', batteryGroup: '高温循环 A 组', batteryCount: 8, abnormal: false }, |
| | | 1023: { name: '电芯容量一致性测试', owner: '李工', purpose: '筛查批次容量离散度', startedAt: '2026-07-10 11:30:00', batteryGroup: '容量一致性 A 组', batteryCount: 6, abnormal: true }, |
| | |
| | | { key: 'project-chart', label: '项目图表' }, |
| | | { key: 'data-list', label: '数据列表' }, |
| | | ] |
| | | |
| | | function intervalSourceLabel(interval: TestReceptionInterval) { |
| | | return interval.sourceType === 'CABINET_CHANNEL' |
| | | ? `检测柜通道 ${interval.sourceId}` |
| | | : `采集器 ${interval.sourceId}${interval.sourceChannelCode ? ` · ${interval.sourceChannelCode}` : ''}` |
| | | } |
| | | |
| | | function formatIntervalTime(value: string | null) { |
| | | return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '开放中' |
| | | } |
| | | |
| | | const timer = window.setInterval(() => { if (state.value === '检测中') elapsedSeconds.value += 1 }, 1000) |
| | | onBeforeUnmount(() => window.clearInterval(timer)) |
| | |
| | | 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 getTestReceptionIntervals(testId.value).then(value => { receptionIntervals.value = value; intervalError.value = '' }) |
| | | .catch(() => { intervalError.value = '接收区间刷新失败,请稍后重试' }) |
| | | void next |
| | | } |
| | | else if (event === 'test-status' && data && typeof data === 'object' && 'testStatus' in data && liveState.value) |
| | |
| | | try { |
| | | const detail = await getTestDetail(testId.value) |
| | | backendDetail.value = detail |
| | | try { receptionIntervals.value = await getTestReceptionIntervals(testId.value) } |
| | | catch { receptionIntervals.value = []; intervalError.value = '接收区间加载失败,请刷新重试' } |
| | | runtimeAttach.value = detail.runtimeAttach === true |
| | | if (detail.receptionStatus === 'NOT_STARTED') { |
| | | state.value = '未开始接收' |
| | |
| | | const battery = backendDetail.value?.batteries[index] |
| | | const binding = battery?.binding |
| | | const batteryNo = battery?.batteryNo || profile.value.batteryNos[index] |
| | | const batterySources = liveState.value?.sources.filter(item => item.batteryNo === 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 channels = computed(() => cells.value.map((cell, index) => { |
| | | const sources = liveState.value?.sources.filter(item => item.batteryNo === cell.no) || [] |
| | | 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 label = (status?: string) => ({ ONLINE: '在线', OFFLINE: '离线', UNKNOWN: '未知', WORKING: '运行', PAUSED: '暂停', STOPPED: '结束', FINISHED: '结束' } as Record<string, string>)[status || 'UNKNOWN'] || '异常' |
| | |
| | | temperature: cell.temperature, positive: cell.positive, negative: cell.negative } |
| | | })) |
| | | |
| | | function togglePause() { |
| | | async function togglePause() { |
| | | if (state.value === '已结束' || state.value === '未开始接收') return |
| | | ElMessage.info('该按钮仅用于数据接收动作,当前状态由后端接收区间事实驱动') |
| | | 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 ? '暂停接收' : '接收中' |
| | | try { receptionIntervals.value = await getTestReceptionIntervals(testId.value); intervalError.value = '' } |
| | | catch { intervalError.value = '接收区间刷新失败,请稍后重试'; ElMessage.warning('动作已生效,但接收区间暂时无法刷新') } |
| | | ElMessage.success(pausing ? '已暂停接收,实时快照继续更新且不纳入结果' : '已继续接收,已从新的观测边界打开区间') |
| | | } catch (error) { |
| | | if (error === 'cancel' || error === 'close') return |
| | | ElMessage.error(error instanceof Error ? error.message : (pausing ? '暂停接收失败' : '继续接收失败')) |
| | | } |
| | | } |
| | | |
| | | async function startReception() { |
| | |
| | | <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 class="test-state">测试状态:{{ testStatusLabel }}</span><span class="reception-state"><i></i>{{ receptionState }}</span><span class="live-connection" :class="liveConnection"><i></i>{{ liveConnection }}</span><span v-if="runtimeAttach" class="attach-state">运行中接入</span></div> |
| | | <div><strong>实时数据</strong><span class="cell-count">{{ cells.length }} 个电池</span><span class="running-state" :class="state"><i></i>{{ state }}</span><span class="test-state">测试状态:{{ testStatusLabel }}</span><span class="reception-state"><i></i>{{ receptionState }}</span><span class="live-connection" :class="liveConnection"><i></i>{{ liveConnection }}</span><span v-if="runtimeAttach" class="attach-state">运行中接入</span><span class="interval-count">接收区间 {{ receptionIntervals.length }} 段</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> |
| | |
| | | <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 v-if="receptionIntervals.length" class="interval-panel" aria-label="接收区间边界"> |
| | | <div class="interval-heading"><strong>接收区间边界</strong><span>实际观测边界 · 暂停期间不纳入结果</span></div> |
| | | <div class="interval-list"><div v-for="interval in receptionIntervals" :key="interval.intervalId"><span>{{ intervalSourceLabel(interval) }}</span><time>{{ formatIntervalTime(interval.startBoundaryAtUtc) }} - {{ formatIntervalTime(interval.endBoundaryAtUtc) }}</time><em>{{ interval.intervalStatus === 'OPEN' ? '开放中' : '已关闭' }}</em></div></div> |
| | | </section> |
| | | </section> |
| | | <p v-if="liveError" class="live-error" role="status">{{ liveError }},正在尝试恢复实时状态</p> |
| | | <p v-if="intervalError" class="live-error" role="status">{{ intervalError }}</p> |
| | | |
| | | <div class="console-layout"> |
| | | <main> |
| | |
| | | .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} |
| | | .interval-count{color:#71808d;font-size:10px;white-space:nowrap} |
| | | .interval-panel{margin:0 22px 18px;border-top:1px solid #d8e1e9}.interval-heading{display:flex;align-items:center;gap:10px;padding:12px 2px 8px;color:#26333f;font-size:12px}.interval-heading span{color:#8795a3;font-size:10px}.interval-list{display:grid;gap:6px}.interval-list>div{display:grid;grid-template-columns:minmax(170px,1fr) minmax(280px,2fr) auto;gap:12px;align-items:center;padding:8px 10px;background:#f8fafc;color:#52606d;font-size:11px}.interval-list time{font-family:'Cascadia Mono',Consolas,monospace;color:#71808d}.interval-list em{font-style:normal;color:#23865e}@media(max-width:680px){.interval-panel{margin:0 14px 14px}.interval-list>div{grid-template-columns:1fr;gap:4px}} |
| | | .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}} |