feat(alarm): 设备报警监控报警提示升级与警灯交互优化 - 报警提示升级:后端新增报警派单完成接口 POST /api/worker-app/alarm-dispatches/:id/complete(供共享电工APP确认完成,归属校验403/不存在404/已完成幂等,status置1并写completed_at);GET /api/device-alarms 的 summary 由 severeIds 改为 activeSevereIds(grade=1 且无已完成派单的报警id,倒序100条,派单完成自动移出即报警取消)并新增轻量接口 GET /api/device-alarms/active-severe;前端新建全局悬浮立体警灯组件 AlarmSirenLight(红蓝双色3D警灯,挂载主布局所有页面可见,点击跳转报警监控页,30秒轮询activeSevereIds新id即时响+每3分钟重复响,Web Audio合成警笛700/900Hz交替,浏览器自动播放静默降级);index.vue 移除原10秒指示灯与880Hz beep、瘦身为纯列表页;警灯交互优化:警灯可鼠标拖动(位移>5px判为拖动不触发跳转)、位置经localStorage跨刷新记忆(默认右上角)、取消「重置报警提示」按钮;后端tsc与前端vue-tsc编译通过,接口实测派单完成排除逻辑生效,浏览器端到端验证通过(全局显示、点击跳转、拖动、刷新记忆、重置按钮无残留)
This commit is contained in:
1 parent
7ff2405634
commit
2d55947762
13 files changed
+676
-115
No files matched your search
@@ -19,10 +19,6 @@
|
||||
<p class="desc">实时监控接入线路的设备报警与事件,支持按客户、等级检索并快速发起报警派单。</p>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="alarm-light" :class="{ active: alarmLight }" title="严重报警指示">
|
||||
<span class="alarm-dot"></span>
|
||||
<span>严重报警</span>
|
||||
</div>
|
||||
<a-button size="small" @click="handleShowSubStatus">
|
||||
<template #icon><ApiOutlined /></template>
|
||||
订阅状态
|
||||
@@ -357,66 +353,6 @@ async function fetchWorkerOptions() {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 严重报警灯 + 声音 ====================
|
||||
const notifiedSevereIds = ref<Set<number>>(new Set());
|
||||
const alarmLight = ref(false);
|
||||
let alarmLightTimer: number | undefined;
|
||||
let audioCtx: AudioContext | null = null;
|
||||
|
||||
function scanSevereAlarms(list: DeviceAlarmRow[]) {
|
||||
const fresh = list.filter((r) => r.alarmGrade === 1 && !notifiedSevereIds.value.has(r.id));
|
||||
if (!fresh.length) return;
|
||||
fresh.forEach((r) => notifiedSevereIds.value.add(r.id));
|
||||
triggerAlarmAlert();
|
||||
}
|
||||
|
||||
function triggerAlarmAlert() {
|
||||
// 报警灯:点亮并保持 10 秒
|
||||
alarmLight.value = true;
|
||||
if (alarmLightTimer) window.clearTimeout(alarmLightTimer);
|
||||
alarmLightTimer = window.setTimeout(() => {
|
||||
alarmLight.value = false;
|
||||
}, 10000);
|
||||
// 声音:Web Audio 生成 880Hz 蜂鸣(3 次,每次 0.2s,间隔 0.1s)
|
||||
playBeep();
|
||||
}
|
||||
|
||||
function playBeep() {
|
||||
try {
|
||||
const Ctx = window.AudioContext || (window as any).webkitAudioContext;
|
||||
if (!Ctx) return;
|
||||
if (!audioCtx) audioCtx = new Ctx();
|
||||
const ctx = audioCtx;
|
||||
// 浏览器自动播放策略:处于 suspended 时尝试 resume,失败则静默降级只亮灯
|
||||
if (ctx.state === 'suspended') {
|
||||
ctx.resume().then(() => doBeep(ctx)).catch(() => { /* 静默降级 */ });
|
||||
} else {
|
||||
doBeep(ctx);
|
||||
}
|
||||
} catch {
|
||||
// 静默降级:只亮灯,不阻塞
|
||||
}
|
||||
}
|
||||
|
||||
function doBeep(ctx: AudioContext) {
|
||||
const now = ctx.currentTime;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const start = now + i * 0.3;
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.type = 'sine';
|
||||
osc.frequency.value = 880;
|
||||
gain.gain.setValueAtTime(0.0001, start);
|
||||
gain.gain.exponentialRampToValueAtTime(0.3, start + 0.02);
|
||||
gain.gain.setValueAtTime(0.3, start + 0.15);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, start + 0.2);
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
osc.start(start);
|
||||
osc.stop(start + 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 订阅状态 ====================
|
||||
const subStatusVisible = ref(false);
|
||||
const subStatusLoading = ref(false);
|
||||
@@ -449,7 +385,6 @@ async function fetchList() {
|
||||
summary.gradeOneCount = s.gradeOneCount ?? 0;
|
||||
summary.undispatchedCount = s.undispatchedCount ?? 0;
|
||||
}
|
||||
scanSevereAlarms(dataSource.value);
|
||||
} catch {
|
||||
// 拦截器已提示
|
||||
} finally {
|
||||
@@ -472,7 +407,7 @@ async function fetchCustomerOptions() {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 轮询(30 秒) ====================
|
||||
// ==================== 轮询(30 秒,仅刷新列表) ====================
|
||||
let pollTimer: number | undefined;
|
||||
|
||||
function startPolling() {
|
||||
@@ -490,8 +425,6 @@ onMounted(() => {
|
||||
|
||||
onUnmounted(() => {
|
||||
if (pollTimer) window.clearInterval(pollTimer);
|
||||
if (alarmLightTimer) window.clearTimeout(alarmLightTimer);
|
||||
audioCtx?.close().catch(() => {});
|
||||
});
|
||||
|
||||
// ==================== 筛选与分页 ====================
|
||||
@@ -663,52 +596,6 @@ function formatTime(val?: string) {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* 严重报警灯 */
|
||||
.alarm-light {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 9999px;
|
||||
background: #f1f5f9;
|
||||
border: 1px solid #e2e8f0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.alarm-light .alarm-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #cbd5e1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.alarm-light.active {
|
||||
background: #fef2f2;
|
||||
border-color: #fecaca;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.alarm-light.active .alarm-dot {
|
||||
background: #dc2626;
|
||||
animation: alarm-blink 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes alarm-blink {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
box-shadow: 0 0 0 0 rgba(220, 38, 38, 0.5);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
box-shadow: 0 0 0 6px rgba(220, 38, 38, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 统计卡片 */
|
||||
.kpi-section {
|
||||
flex-shrink: 0;
|
||||
|
||||
Reference in new issue
Block a user