yeg
2026-08-30 d80b338eb822455f230cb0888513147d50965904
接入双状态实时控制台

通过鉴权 fetch SSE 恢复测试状态、来源快照和断线状态,明确展示设备与接收状态及测量单位。
1个文件已添加
4个文件已修改
216 ■■■■■ 已修改文件
package.json 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/request.ts 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/tests.ts 75 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/TestConsole.vue 115 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tests/test-console-live.contract.test.mjs 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
package.json
@@ -11,7 +11,8 @@
    "test:e2e:ticket05": "playwright test tests/e2e/shared-fields-and-battery-groups.spec.ts",
    "test:e2e:ticket06": "playwright test tests/e2e/collector-sampling.spec.ts",
    "test:e2e:ticket07": "playwright test tests/e2e/test-required-device-bindings.spec.ts",
    "test:e2e:ticket08": "playwright test tests/e2e/running-start-and-reception-boundaries.spec.ts"
    "test:e2e:ticket08": "playwright test tests/e2e/running-start-and-reception-boundaries.spec.ts",
    "test:contract:ticket09": "node tests/test-console-live.contract.test.mjs"
  },
  "dependencies": {
    "@element-plus/icons-vue": "^2.3.2",
src/api/request.ts
@@ -18,6 +18,11 @@
const AUTH_TOKEN_KEY = 'battery_detection_auth_token'
const AUTH_USER_KEY = 'battery_detection_auth_user'
/** SSE 等非 JSON 请求复用同一会话令牌;令牌只放请求头。 */
export function getAuthToken(): string | null {
  return localStorage.getItem(AUTH_TOKEN_KEY)
}
type UnauthorizedHandler = () => void | Promise<void>
let unauthorizedHandler: UnauthorizedHandler | undefined
@@ -48,7 +53,7 @@
export async function request<T extends ApiResponse>(path: string, init: RequestInit = {}): Promise<T> {
  const headers = new Headers(init.headers)
  const token = localStorage.getItem(AUTH_TOKEN_KEY)
  const token = getAuthToken()
  if (init.body && !headers.has('Content-Type')) {
    headers.set('Content-Type', 'application/json')
src/api/tests.ts
@@ -1,5 +1,5 @@
import type { ReceptionStatus, TestRecord, TestStatus } from '@/stores/tests'
import { request, type ApiResponse } from './request'
import { getAuthToken, request, type ApiResponse } from './request'
export type TestStatusCode = 'WAITING' | 'RUNNING' | 'PAUSED' | 'RUNNING_ABNORMAL' | 'COMPLETION_PENDING' | 'COMPLETED' | 'TERMINATED'
export type ReceptionStatusCode = 'NOT_STARTED' | 'RECEIVING' | 'PAUSED' | 'ENDED'
@@ -168,3 +168,76 @@
export async function startTestReception(testId: number): Promise<TestConfigurationDetail> {
  return (await request<TestDetailResponse>(`/system/test/${testId}/reception/start`, { method: 'POST' })).data
}
export interface LiveSource {
  sourceType: 'CABINET_CHANNEL' | 'COLLECTOR' | string
  sourceId: number
  batteryNo: string
  deviceStatus: string
  latestSnapshot: {
    source?: string
    unit?: string
    observedAt?: string
    sampleIntervalMs?: number
    measurements?: Array<Record<string, unknown>>
    units?: string[]
    [key: string]: unknown
  }
}
export interface TestLiveState {
  testId: number
  testStatus: TestStatusCode
  receptionStatus: ReceptionStatusCode
  updatedAt: string
  sources: LiveSource[]
  exceptions: Array<Record<string, unknown>>
}
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 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(response.status === 401 ? '实时流权限不足' : `实时流连接失败(${response.status})`)
      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()
}
src/views/TestConsole.vue
@@ -6,7 +6,7 @@
import ConsoleProjectChartPanel from '@/components/ConsoleProjectChartPanel.vue'
import ConsoleDataListPanel from '@/components/ConsoleDataListPanel.vue'
import { useTests } from '@/stores/tests'
import { getTestDetail, startTestReception, type TestConfigurationDetail } from '@/api/tests'
import { getTestDetail, getTestLiveState, openTestLiveStream, startTestReception, type TestConfigurationDetail, type TestLiveState } from '@/api/tests'
type ConsoleState = '未开始接收' | '检测中' | '暂停' | '已结束'
type ReceptionState = '未开始接收' | '接收中' | '暂停接收' | '已结束接收'
@@ -14,9 +14,15 @@
const route = useRoute()
const router = useRouter()
const { records: testRecords, updateTestStatus } = useTests()
const { records: testRecords } = useTests()
const testId = computed(() => Number(route.params.id) || 1024)
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 testProfiles: Record<number, { name: string; owner: string; purpose: string; startedAt: string; batteryGroup: string; batteryCount: number; abnormal: boolean }> = {
@@ -55,6 +61,48 @@
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 = ''
  testStatusLabel.value = ({ WAITING: '待测试', RUNNING: '检测中', PAUSED: '暂停', RUNNING_ABNORMAL: '检测中(异常)', COMPLETION_PENDING: '完成确认中', COMPLETED: '检测完成', TERMINATED: '已终止' } as Record<string, string>)[next.testStatus] || '未知'
  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) && data.length) liveError.value = '检测来源存在异常'
  }, error => {
    liveConnection.value = error.message.includes('权限') ? '权限不足' : '已断开'
    liveError.value = error.message
    scheduleLiveReconnect()
  })
}
onMounted(async () => {
  try {
    const detail = await getTestDetail(testId.value)
@@ -73,10 +121,16 @@
      state.value = '检测中'
      receptionState.value = '接收中'
    }
    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) })
const elapsedText = computed(() => {
  const hours = Math.floor(elapsedSeconds.value / 3600)
@@ -88,16 +142,32 @@
const cells = computed(() => Array.from({ length: profile.value.batteryCount }, (_, index) => {
  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 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) => {
    const item = (preferred?.latestSnapshot?.measurements || []).find(value => codes.includes(String(value.metricCode)))
    return item && item.value !== undefined ? String(item.value) : undefined
  }
  return {
    no: battery?.batteryNo || profile.value.batteryNos[index] || `SP26-${214 + index}`,
    cabinet: binding?.cabinetName || binding?.cabinetCode || `检测柜 A-${String(Math.floor(index / 4) + 1).padStart(2, '0')}`,
    channel: binding?.displayNo ? `通道 ${binding.displayNo}` : `通道 ${String(index + 1).padStart(2, '0')}`,
    voltage: (1.2952 + (index % 4) * 0.0011).toFixed(4),
    temperature: (32.6 + (index % 5) * 0.15).toFixed(1),
    positive: (1.2939 + (index % 4) * 0.001).toFixed(4),
    negative: (1.2946 + (index % 4) * 0.0009).toFixed(4),
    capacity: (1495.8 + (index % 6) * 0.48).toFixed(1),
    current: (-8170.6 + (index % 5) * 1.7).toFixed(1),
    voltage: metric(['VOLTAGE', 'voltage']) || '-',
    temperature: metric(['TEMPERATURE', 'temperature', 'PRIMARY_TEMPERATURE'], collectorSource) || '-',
    positive: metric(['POSITIVE_POTENTIAL', 'PRIMARY_POSITIVE_POTENTIAL'], collectorSource) || '-',
    negative: metric(['NEGATIVE_POTENTIAL', 'PRIMARY_NEGATIVE_POTENTIAL'], collectorSource) || '-',
    capacity: metric(['CAPACITY', 'capacity']) || '-',
    current: metric(['CURRENT', 'current']) || '-',
    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'] || '异常',
  }
}))
@@ -118,13 +188,19 @@
  ['测试目的', profile.value.purpose], ['备注', '自动采集,异常时通知负责人'],
]
const channels = computed(() => cells.value.map((cell, index) => ({ no: cell.no, cabinet: cell.cabinet, cabinetState: index === 2 && profile.value.abnormal ? '异常' : '在线', channel: cell.channel, collector: backendDetail.value?.batteries[index]?.collectorName || `采集器 COL-${String(Math.floor(index / 4) + 1).padStart(2, '0')}`, collectorState: '在线', temperature: `温度通道 ${index + 1}`, positive: `正电位通道 ${index + 1}`, negative: `负电位通道 ${index + 1}` })))
const channels = computed(() => cells.value.map((cell, index) => {
  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'] || '异常'
  return { no: cell.no, cabinet: cell.cabinet, cabinetState: label(cabinet?.deviceStatus), channel: cell.channel,
    collector: backendDetail.value?.batteries[index]?.collectorName || '-', collectorState: label(collector?.deviceStatus),
    temperature: cell.temperature, positive: cell.positive, negative: cell.negative }
}))
function togglePause() {
  if (state.value === '已结束' || state.value === '未开始接收') return
  state.value = state.value === '暂停' ? '检测中' : '暂停'
  updateTestStatus(testId.value, state.value === '暂停' ? '暂停' : '检测中')
  ElMessage.success(state.value === '暂停' ? '测试已暂停' : '测试已继续')
  ElMessage.info('该按钮仅用于数据接收动作,当前状态由后端接收区间事实驱动')
}
async function startReception() {
@@ -134,7 +210,6 @@
    runtimeAttach.value = backendDetail.value.runtimeAttach === true
    state.value = '检测中'
    receptionState.value = '接收中'
    updateTestStatus(testId.value, '检测中')
    ElMessage.success('已开始接收,未向设备发送控制命令')
  } catch (error) {
    ElMessage.error(error instanceof Error ? error.message : '开始接收失败')
@@ -145,9 +220,7 @@
  if (state.value === '已结束') return
  try {
    await ElMessageBox.confirm('结束接收将关闭本测试的数据接收区间,不会停止设备,是否继续?', '确认结束接收', { confirmButtonText: '结束接收', cancelButtonText: '取消', type: 'warning', confirmButtonClass: 'danger-confirm' })
    state.value = '已结束'
    updateTestStatus(testId.value, '检测完成')
    ElMessage.success('测试已结束')
    ElMessage.info('结束接收动作由后端接收区间接口处理,当前版本仅展示实时事实')
  } catch { /* 用户取消 */ }
}
</script>
@@ -161,17 +234,18 @@
    <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="reception-state"><i></i>{{ receptionState }}</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></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>工步开始时间:2026/07/10 14:40:12</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>容量</dt><dd class="capacity">{{ cell.capacity }}</dd></div><div><dt>电流(mA)</dt><dd class="current">{{ cell.current }}</dd></div></dl>
          <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>
@@ -195,7 +269,8 @@
<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}.attach-state{padding:4px 7px;border:1px solid #bdd8f4;color:#1677dd;background:#edf6ff;font-size:11px}
.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}}
</style>
tests/test-console-live.contract.test.mjs
New file
@@ -0,0 +1,16 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
const api = await readFile(new URL('../src/api/tests.ts', import.meta.url), 'utf8')
const view = await readFile(new URL('../src/views/TestConsole.vue', import.meta.url), 'utf8')
assert.match(api, /text\/event-stream/)
assert.match(api, /Authorization: `Bearer \$\{token\}`/)
assert.match(api, /\/live-state/)
assert.match(api, /\/live`/)
assert.doesNotMatch(api, /new EventSource/)
assert.match(view, /getTestLiveState/)
assert.match(view, /openTestLiveStream/)
assert.match(view, /liveConnection/)
assert.match(view, /不纳入结果/)
assert.doesNotMatch(view, /localStorage\.(setItem|getItem)\([^)]*test/i)
console.log('Ticket09 前端实时控制台契约通过')