yeg
2026-09-05 61a1025d7d30f694ed0a52c59b97b7a38e571f54
修复控制台实时阈值告警展示并补充浏览器回归

识别当前阈值异常并显示测量值、预警范围和来源通道。验证超温恢复、异常处理独立、暂停结束与窄屏布局,等待实时流建立后再推送回归事件。
1个文件已添加
2个文件已修改
133 ■■■■■ 已修改文件
src/views/TestConsole.vue 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tests/test-offline-exceptions-browser.mjs 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tests/test-threshold-exceptions-browser.mjs 117 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/TestConsole.vue
@@ -42,10 +42,11 @@
    const source = item.sourceType === 'COLLECTOR' ? '采集器' : '检测柜通道'
    return `${source}离线 · ${item.sourceIdentity || item.sourceId || '-'}`
  }
  if (item.exceptionId !== undefined && item.exceptionId !== null) {
  if (item.exceptionType === 'THRESHOLD' || (item.exceptionId !== undefined && item.exceptionId !== null)) {
    const source = item.sourceType === 'COLLECTOR' ? '采集器' : '检测柜'
    const field = String(item.metricCode || item.measurementName || '字段')
    return `${source} · ${field}超限`
    const code = String(item.metricCode || item.measurementName || '字段')
    const field = ({ TEMPERATURE: '温度', VOLTAGE: '电压', CURRENT: '电流', CAPACITY: '容量', ENERGY: '能量', POSITIVE_POTENTIAL: '正电位', NEGATIVE_POTENTIAL: '负电位' } as Record<string, string>)[code.toUpperCase()] || code
    return `${source} · ${field}超限${item.sourceIdentity ? ` · ${item.sourceIdentity}` : ''}`
  }
  if (item.sourceType === 'COLLECTOR') {
    const name = item.collectorName || item.collectorCode || `#${item.sourceId || '-'}`
@@ -56,6 +57,11 @@
function exceptionDetail(item: Record<string, unknown>) {
  if (item.exceptionType === 'DEVICE_OFFLINE') return String(item.message || '接收期间设备离线')
  if (item.exceptionType === 'THRESHOLD') {
    const range = item.minValue != null && item.maxValue != null ? `${item.minValue} ~ ${item.maxValue}`
      : item.minValue != null ? `≥ ${item.minValue}` : `≤ ${item.maxValue}`
    return `当前值:${item.value ?? '-'} ${item.unit || ''} · 预警阈值:${range} ${item.unit || ''}`
  }
  if (item.exceptionId !== undefined && item.exceptionId !== null)
    return `异常值:${item.value ?? '-'} ${item.unit || ''} · 测试预警异常`
  return String(item.message || (item.status === 'OPEN' ? '当前异常' : '异常已处理'))
@@ -270,7 +276,7 @@
  }
}))
// 离线提醒由后端当前设备事实生成,与异常管理的处理状态无关。
// 实时提醒由后端当前设备和测量事实生成,与异常管理的处理状态无关。
const currentExceptions = computed(() => (liveState.value?.exceptions || [])
  .filter(item => receptionState.value === '接收中'
    && (!item.status || item.status === 'OPEN')))
tests/test-offline-exceptions-browser.mjs
@@ -83,6 +83,7 @@
    else await expect(alarm).toContainText('暂无异常')
  }
  deviceStatus = 'ONLINE'
  await page.waitForFunction(() => typeof window.emitOfflineTestEvent === 'function')
  await page.evaluate(state => {
    window.emitOfflineTestEvent('device-status', state.sources)
    window.emitOfflineTestEvent('exception', state.exceptions)
@@ -109,6 +110,7 @@
    await page.goto(`${origin}/test-list/1/console`)
    await expect(alarm).toContainText('暂无异常')
    deviceStatus = 'OFFLINE'
    await page.waitForFunction(() => typeof window.emitOfflineTestEvent === 'function')
    await page.evaluate(state => window.emitOfflineTestEvent('snapshot', state), live())
    await expect(alarm.locator('.alarm-list > div')).toHaveCount(2)
    await expect(alarm).toContainText('检测柜通道离线')
tests/test-threshold-exceptions-browser.mjs
New file
@@ -0,0 +1,117 @@
import assert from 'node:assert/strict'
import { readFile, mkdir } from 'node:fs/promises'
import { chromium, expect } from '@playwright/test'
// 使用真实构建产物和可推送实时流,不修改共享测试或设备数据。
const origin = 'http://threshold-exceptions.test'
const output = '../.scratch/simulator-and-data-import-v1/threshold-exceptions-browser'
await mkdir(output, { recursive: true })
const browser = await chromium.launch({ channel: 'chrome', headless: true })
const now = Date.UTC(2026, 8, 5, 12, 0, 0)
let receptionStatus = 'RECEIVING'
let temperature = 38
const threshold = { thresholdId: 1, metricCode: 'TEMPERATURE', minValue: 8, maxValue: 33, severity: 'WARNING' }
const warning = { exceptionId: 1, testId: 1, testName: '温度预警验收', exceptionType: 'THRESHOLD',
  thresholdId: 1, sourceType: 'COLLECTOR', sourceIdentity: '9:S-01', metricCode: 'TEMPERATURE',
  abnormalValue: 38, unit: '℃', ruleSnapshot: JSON.stringify(threshold), exceptionStatus: 'OPEN',
  firstOccurredAtUtc: now, lastOccurredAtUtc: now, occurrenceCount: 1 }
const detail = () => ({ testId: 1, testName: '温度预警验收',
  testStatus: receptionStatus === 'RECEIVING' ? 'RUNNING' : receptionStatus === 'ENDED' ? 'COMPLETED' : 'PAUSED',
  receptionStatus, creatorName: '若依', principalName: '若依', testTypeName: '性能测试', testPurpose: '温度观测',
  startedAtUtc: now, createTime: now, thresholds: [threshold], planSteps: [], logicStepMappings: [],
  batteries: [{ testBatteryId: 1, batteryNo: 'B-01', groupName: '演示电池组', collectorId: 9, collectorName: '演示采集器', fields: [],
    collectorChannels: [{ channelCode: 'S-01', measurementCode: 'PRIMARY_TEMPERATURE' }],
    binding: { channelId: 1, cabinetId: 1, cabinetName: '演示检测柜', adapterCode: 'dev-sim', devtype: 22, devid: 1, subdevid: 1, chlid: 1 } }],
})
const live = () => ({ testId: 1, testStatus: detail().testStatus, receptionStatus, serverTime: now, testPlan: null,
  sources: [{ sourceType: 'COLLECTOR', sourceId: 9, batteryNo: 'B-01', deviceStatus: 'ONLINE',
    latestSnapshot: receptionStatus === 'RECEIVING' ? { observedAt: now, measurements: [
      { channelCode: 'S-01', metricCode: 'TEMPERATURE', value: temperature, unit: '℃', deviceTime: now, serverReceivedAt: now },
    ] } : {} }],
  exceptions: receptionStatus === 'RECEIVING' && temperature > 33 ? [{
    exceptionType: 'THRESHOLD', thresholdId: 1, sourceType: 'COLLECTOR', sourceIdentity: '9:S-01',
    metricCode: 'TEMPERATURE', value: temperature, unit: '℃', minValue: 8, maxValue: 33,
    status: 'OPEN', message: '当前测量超出预警阈值',
  }] : [],
})
try {
  const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } })
  const errors = []
  page.on('pageerror', error => errors.push(error.message))
  await page.addInitScript(() => {
    localStorage.setItem('battery_detection_auth_token', 'browser-test')
    const fetch = window.fetch.bind(window)
    window.fetch = (input, init) => {
      if (!String(input).endsWith('/test/1/live')) return fetch(input, init)
      return Promise.resolve(new Response(new ReadableStream({ start(controller) {
        window.emitThresholdTestEvent = (event, data) => controller.enqueue(new TextEncoder().encode(
          `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`))
      } }), { headers: { 'Content-Type': 'text/event-stream' } }))
    }
  })
  await page.route(`${origin}/**`, async route => {
    const path = new URL(route.request().url()).pathname
    if (path.startsWith('/assets/')) return route.fulfill({ body: await readFile(`dist${path}`), contentType: path.endsWith('.css') ? 'text/css' : 'application/javascript' })
    if (path.includes('/system/test-warning-exceptions/')) {
      if (path.endsWith('/close')) {
        Object.assign(warning, { exceptionStatus: 'CLOSED', handledAtUtc: now, handledByName: '若依', handlingNote: route.request().postDataJSON().handlingNote })
        return route.fulfill({ json: { code: 200, data: warning } })
      }
      return route.fulfill({ json: { code: 200, rows: [warning], total: 1 } })
    }
    if (path.includes('/system/test/1')) return route.fulfill({ json: { code: 200, data: path.endsWith('/live-state') ? live() : detail() } })
    if (path.endsWith('/getInfo')) return route.fulfill({ json: { code: 200, user: { userName: 'admin' }, roles: ['admin'] } })
    return route.fulfill({ contentType: 'text/html', body: await readFile('dist/index.html', 'utf8') })
  })
  await page.goto(`${origin}/test-list/1/console`)
  const alarm = page.locator('.alarm-panel')
  const emitSnapshot = async () => {
    await page.waitForFunction(() => typeof window.emitThresholdTestEvent === 'function')
    await page.evaluate(state => window.emitThresholdTestEvent('snapshot', state), live())
  }
  await expect(alarm).toContainText('温度超限 · 9:S-01')
  await expect(alarm).toContainText('当前值:38 ℃')
  await expect(alarm).toContainText('预警阈值:8 ~ 33 ℃')
  await expect(alarm).not.toContainText('离线')
  await expect(alarm).not.toContainText('待处理')
  await alarm.screenshot({ path: `${output}/console-alarm.png` })
  await page.setViewportSize({ width: 390, height: 844 })
  await expect(alarm).toContainText('当前值:38 ℃')
  assert.equal(await alarm.evaluate(element => element.scrollWidth > element.clientWidth), false)
  await alarm.screenshot({ path: `${output}/console-alarm-390.png` })
  await page.setViewportSize({ width: 1440, height: 1000 })
  temperature = 25
  await emitSnapshot()
  await expect(alarm).toContainText('暂无异常')
  assert.equal(warning.exceptionStatus, 'OPEN')
  await page.goto(`${origin}/exception`)
  const table = page.locator('.exception-page table')
  await expect(table.locator('tbody tr')).toHaveCount(1)
  await expect(table).toContainText('待处理')
  await expect(table).toContainText('38')
  await table.getByRole('button', { name: '处理', exact: true }).click()
  await page.getByRole('textbox').last().fill('已检查温度测量与阈值配置')
  await page.getByRole('button', { name: '完成处理' }).click()
  await expect(table).toContainText('已处理')
  await page.reload()
  await expect(table).toContainText('已处理')
  await page.screenshot({ path: `${output}/exception-management.png`, fullPage: true, animations: 'disabled' })
  await page.goto(`${origin}/test-list/1/console`)
  await expect(alarm).toContainText('暂无异常')
  temperature = 38
  await emitSnapshot()
  await expect(alarm).toContainText('温度超限')
  assert.equal(warning.exceptionStatus, 'CLOSED')
  for (const status of ['PAUSED', 'ENDED', 'RECEIVING']) {
    receptionStatus = status
    await emitSnapshot()
    if (status === 'RECEIVING') await expect(alarm).toContainText('当前值:38 ℃')
    else await expect(alarm).toContainText('暂无异常')
  }
  await page.reload()
  await expect(alarm).toContainText('温度超限')
  assert.deepEqual(errors, [])
  console.log('温度预警浏览器验证通过:38℃超限、实时恢复、历史处理独立、暂停和结束隐藏、刷新及桌面和窄屏布局。')
} finally {
  await browser.close()
}