展示接收中设备离线告警及异常管理记录
区分设备离线和字段超限,兼容空测量值并修复异常时间显示;补充接收状态、刷新和异常处理的浏览器验证。
| | |
| | | |
| | | export interface WarningExceptionRecord { |
| | | exceptionId: number; testId: number; testName?: string; sourceType: string; sourceIdentity: string; |
| | | metricCode: string; abnormalValue: number; unit: string; ruleSnapshot: string; severity: string; |
| | | exceptionStatus: 'OPEN' | 'CLOSED'; firstOccurredAtUtc: string; lastOccurredAtUtc: string; |
| | | occurrenceCount: number; handledByName?: string; handledAtUtc?: string; handlingNote?: string; |
| | | exceptionType: 'THRESHOLD' | 'DEVICE_OFFLINE'; |
| | | metricCode: string; abnormalValue: number | null; unit: string; ruleSnapshot: string; severity: string; |
| | | exceptionStatus: 'OPEN' | 'CLOSED'; firstOccurredAtUtc: number; lastOccurredAtUtc: number; |
| | | occurrenceCount: number; handledByName?: string; handledAtUtc?: number | null; handlingNote?: string; |
| | | } |
| | | export async function getWarningExceptions(params: { testId?: number; status?: string } = {}): Promise<WarningExceptionRecord[]> { |
| | | const query = new URLSearchParams(); if (params.testId) query.set('testId', String(params.testId)); if (params.status) query.set('status', params.status); |
| | |
| | | import { ref } from 'vue' |
| | | import { closeWarningException, getWarningExceptions, type WarningExceptionRecord } from '@/api/tests' |
| | | import { formatBusinessTime } from '@/utils/businessTime' |
| | | |
| | | export type ExceptionStatus = '待处理' | '已处理' |
| | | |
| | |
| | | const records = ref<ExceptionRecord[]>([]) |
| | | |
| | | function mapRecord(item: WarningExceptionRecord): ExceptionRecord { |
| | | const offline = item.exceptionType === 'DEVICE_OFFLINE' |
| | | let normalValue = '-' |
| | | try { const rule = JSON.parse(item.ruleSnapshot || '{}'); normalValue = rule.maxValue != null ? `≤ ${rule.maxValue} ${item.unit}` : rule.minValue != null ? `≥ ${rule.minValue} ${item.unit}` : '-' } catch { /* 快照不可解析时仍展示异常事实 */ } |
| | | return { id: item.exceptionId, testId: item.testId, testName: item.testName || `测试 #${item.testId}`, testTime: item.firstOccurredAtUtc, source: `${item.sourceType}${item.sourceIdentity ? ` · ${item.sourceIdentity}` : ''}`, exceptionItem: item.metricCode, abnormalValue: `${item.abnormalValue} ${item.unit}`, normalValue, generatedAt: item.lastOccurredAtUtc, processor: item.handledByName || '', handledAt: item.handledAtUtc || '-', status: item.exceptionStatus === 'OPEN' ? '待处理' : '已处理', handleRemark: item.handlingNote || '' } |
| | | const sourceLabel = ({ CABINET: '检测柜', CABINET_CHANNEL: '检测柜通道', COLLECTOR: '采集器' } as Record<string, string>)[item.sourceType] || item.sourceType |
| | | return { |
| | | id: item.exceptionId, testId: item.testId, testName: item.testName || `测试 #${item.testId}`, |
| | | testTime: formatBusinessTime(item.firstOccurredAtUtc), |
| | | source: `${sourceLabel}${item.sourceIdentity ? ` · ${item.sourceIdentity}` : ''}`, |
| | | exceptionItem: offline ? '设备离线' : item.metricCode, |
| | | abnormalValue: offline ? '离线' : item.abnormalValue == null ? '-' : `${item.abnormalValue} ${item.unit}`, |
| | | normalValue: offline ? '在线' : normalValue, |
| | | generatedAt: formatBusinessTime(item.lastOccurredAtUtc), processor: item.handledByName || '', |
| | | handledAt: formatBusinessTime(item.handledAtUtc), status: item.exceptionStatus === 'OPEN' ? '待处理' : '已处理', |
| | | handleRemark: item.handlingNote || '', |
| | | } |
| | | } |
| | | |
| | | export function useExceptions() { |
| | |
| | | <td class="id-cell">#{{ item.id }}</td> |
| | | <td class="code-cell">#{{ item.testId }}</td> |
| | | <td><strong>{{ item.testName }}</strong></td> |
| | | <td>{{ item.testTime }}</td><td>{{ item.source }}</td> |
| | | <td>{{ item.testTime }}</td><td :title="item.source">{{ item.source }}</td> |
| | | <td>{{ item.exceptionItem }}</td> |
| | | <td class="abnormal-value">{{ item.abnormalValue }}</td> |
| | | <td>{{ item.normalValue }}</td> |
| | |
| | | } |
| | | |
| | | function exceptionTitle(item: Record<string, unknown>) { |
| | | if (item.exceptionType === 'DEVICE_OFFLINE') { |
| | | const source = item.sourceType === 'COLLECTOR' ? '采集器' : '检测柜通道' |
| | | return `${source}离线${item.exceptionId ? '(待处理)' : ''} · ${item.sourceIdentity || item.sourceId || '-'}` |
| | | } |
| | | if (item.exceptionId !== undefined && item.exceptionId !== null) { |
| | | const source = item.sourceType === 'COLLECTOR' ? '采集器' : '检测柜' |
| | | const field = String(item.metricCode || item.measurementName || '字段') |
| | |
| | | } |
| | | |
| | | function exceptionDetail(item: Record<string, unknown>) { |
| | | if (item.exceptionType === 'DEVICE_OFFLINE') return String(item.message || '接收期间设备离线') |
| | | if (item.exceptionId !== undefined && item.exceptionId !== null) |
| | | return `异常值:${item.value ?? '-'} ${item.unit || ''} · 测试预警异常` |
| | | return String(item.message || (item.status === 'OPEN' ? '当前异常' : '异常已处理')) |
| | |
| | | // 实时告警只展示后端当前仍开放的异常,异常事实由后端状态和消息说明。 |
| | | const currentExceptions = computed(() => (liveState.value?.exceptions || []) |
| | | .filter(item => receptionState.value === '接收中' |
| | | && (item.sourceType !== 'COLLECTOR' || !item.status || item.status === 'OPEN'))) |
| | | && (!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) } |
| | |
| | | </template> |
| | | |
| | | <style scoped> |
| | | .alarm-list > div > span { min-width: 0; overflow-wrap: anywhere; } |
| | | .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} |
| New file |
| | |
| | | import assert from 'node:assert/strict' |
| | | import { readFile, mkdir } from 'node:fs/promises' |
| | | import { chromium, expect } from '@playwright/test' |
| | | |
| | | // 通过真实构建产物检查告警,API 拦截不会修改开发测试或设备。 |
| | | const origin = 'http://offline-exceptions.test' |
| | | const output = '../.scratch/simulator-and-data-import-v1/offline-exceptions-browser' |
| | | await mkdir(output, { recursive: true }) |
| | | const browser = await chromium.launch({ channel: 'chrome', headless: true }) |
| | | const now = Date.UTC(2026, 8, 5, 11, 0, 0) |
| | | let receptionStatus = 'RECEIVING' |
| | | const warnings = ['CABINET_CHANNEL', 'COLLECTOR'].map((sourceType, index) => ({ |
| | | exceptionId: index + 1, testId: 1, testName: '离线告警验收', exceptionType: 'DEVICE_OFFLINE', |
| | | sourceType, sourceIdentity: index ? '9' : 'dev-sim:22:1:1:1', metricCode: 'DEVICE_ONLINE', |
| | | abnormalValue: null, unit: '', ruleSnapshot: '{"expectedStatus":"ONLINE"}', 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: [], planSteps: [], logicStepMappings: [], |
| | | batteries: [{ testBatteryId: 1, batteryNo: 'B-01', groupName: '演示电池组', collectorId: 9, collectorName: '演示采集器', fields: [], collectorChannels: [], |
| | | 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: ['CABINET_CHANNEL', 'COLLECTOR'].map(sourceType => ({ sourceType, sourceId: sourceType === 'COLLECTOR' ? 9 : 1, batteryNo: 'B-01', deviceStatus: 'OFFLINE', latestSnapshot: {} })), |
| | | exceptions: receptionStatus === 'RECEIVING' ? warnings.filter(w => w.exceptionStatus === 'OPEN').map(w => ({ ...w, status: w.exceptionStatus, value: null, 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')) |
| | | 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.endsWith('/test/1/live')) return route.fulfill({ contentType: 'text/event-stream', body: `event: snapshot\ndata: ${JSON.stringify(live())}\n\n` }) |
| | | if (path.includes('/system/test-warning-exceptions/')) { |
| | | if (path.endsWith('/close')) { |
| | | const id = Number(path.split('/').at(-2)) |
| | | const record = warnings.find(w => w.exceptionId === id) |
| | | Object.assign(record, { exceptionStatus: 'CLOSED', handledAtUtc: now, handledByName: '若依', handlingNote: route.request().postDataJSON().handlingNote }) |
| | | return route.fulfill({ json: { code: 200, data: record } }) |
| | | } |
| | | return route.fulfill({ json: { code: 200, rows: warnings, total: warnings.length } }) |
| | | } |
| | | 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') |
| | | await expect(alarm).toContainText('检测柜通道离线') |
| | | await expect(alarm).toContainText('采集器离线') |
| | | 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('采集器离线') |
| | | 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 }) |
| | | for (const status of ['PAUSED', 'ENDED', 'RECEIVING']) { |
| | | receptionStatus = status |
| | | await page.reload() |
| | | if (status === 'RECEIVING') await expect(alarm).toContainText('采集器离线') |
| | | else await expect(alarm).toContainText('暂无异常') |
| | | } |
| | | if (process.argv[2] !== 'console') { |
| | | await page.goto(`${origin}/exception`) |
| | | const table = page.locator('.exception-page table') |
| | | await expect(table.locator('tbody tr')).toHaveCount(2) |
| | | await expect(table).toContainText('设备离线') |
| | | await expect(table).toContainText('2026-09-05') |
| | | await expect(table).not.toContainText('null') |
| | | await table.locator('tbody tr').first().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' }) |
| | | } |
| | | assert.deepEqual(errors, []) |
| | | console.log('离线告警浏览器验证通过:接收中告警、暂停和结束隐藏、刷新及异常记录处理。') |
| | | } finally { |
| | | await browser.close() |
| | | } |