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:
wk authored and trae-solo-agent committed 2026-10-24 18:40:05 +08:00
1 parent 7ff2405634
commit 2d55947762
13 files changed
+676 -115

No files matched your search

+6
View File
@@ -32,6 +32,8 @@ export interface DeviceAlarmSummary {
todayCount: number;
gradeOneCount: number;
undispatchedCount: number;
// 活跃严重报警 id(grade=1 且未派单完成),不受分页筛选影响,供前端全局警灯/警笛触发
activeSevereIds: number[];
}
export interface DeviceAlarmListResult {
@@ -72,6 +74,10 @@ export const deviceAlarmApi = {
dispatch: (id: number, body: DeviceAlarmDispatchParams) =>
request.post<DeviceAlarmDispatchResult>(`/device-alarms/${id}/dispatch`, body),
// 活跃严重报警 id 轻量接口(供全局悬浮警灯轮询)
activeSevere: () =>
request.get<{ activeSevereIds: number[] }>('/device-alarms/active-severe'),
};
// ==================== 报警派单台账(Task7 使用) ====================
+371
View File
@@ -0,0 +1,371 @@
<template>
<!-- 全局悬浮立体警灯(活跃严重报警指示,登录后所有页面可见;点击跳转报警监控页,可拖拽调整位置) -->
<div
ref="sirenRef"
class="floating-siren"
:class="{ dragging: isDragging }"
@mousedown="onMouseDown"
>
<a-tooltip :title="activeSevereIds.length ? `存在 ${activeSevereIds.length} 条未派单严重报警,点击查看` : '无未派单严重报警'">
<div
class="siren-light"
:class="{ 'is-on': activeSevereIds.length > 0 }"
>
<span class="siren-dome"></span>
<span class="siren-beam"></span>
<span class="siren-bulb bulb-red"></span>
<span class="siren-bulb bulb-blue"></span>
</div>
</a-tooltip>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { deviceAlarmApi } from '@/api/alarm';
const router = useRouter();
const route = useRoute();
// 警灯容器 ref(用于读取尺寸与设置/保存位置)
const sirenRef = ref<HTMLDivElement | null>(null);
// 拖动中标志(用于 grabbing 光标样式)
const isDragging = ref(false);
// 当前活跃严重报警 id(grade=1 且无已完成派单),非空时警灯持续旋转显示
const activeSevereIds = ref<number[]>([]);
// 已即时触发过警笛的严重报警 id(用于去重,避免重复提醒)
const notifiedIds = ref<Set<number>>(new Set());
let audioCtx: AudioContext | null = null;
// 拖动状态:起始鼠标坐标、容器起始位置、是否已发生拖动(位移 >5px)
let dragging = false;
let moved = false;
let startX = 0;
let startY = 0;
let originLeft = 0;
let originTop = 0;
const POS_KEY = 'alarm-siren-pos';
// 扫描活跃严重报警 id:新出现且未提醒过的即时触发警笛
function scanActiveSevere(ids: number[]) {
const fresh = ids.filter((id) => !notifiedIds.value.has(id));
if (!fresh.length) return;
fresh.forEach((id) => notifiedIds.value.add(id));
playBeep();
}
// 轮询拉取活跃严重报警 id(轻量接口,不受分页/筛选影响)
async function fetchActiveSevere() {
try {
const res = await deviceAlarmApi.activeSevere();
const ids = (res.data?.activeSevereIds || []).map(Number);
activeSevereIds.value = ids;
scanActiveSevere(ids);
} catch {
// 拦截器已提示,静默忽略
}
}
// 警灯点击(未拖动时由 mouseup 触发):非本页时跳转到设备报警监控页
function goAlarmPage() {
if (route.path !== '/device/alarm') {
router.push('/device/alarm');
}
}
// 拖动开始:记录起始鼠标坐标与容器当前 left/top
function onMouseDown(e: MouseEvent) {
if (e.button !== 0) return;
const el = sirenRef.value;
if (!el) return;
dragging = true;
moved = false;
startX = e.clientX;
startY = e.clientY;
originLeft = el.offsetLeft;
originTop = el.offsetTop;
isDragging.value = true;
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
}
// 拖动中:容器跟随鼠标移动(起始位置 + 当前位移),位移超过 5px 视为拖动;限制不越出视口
function onMouseMove(e: MouseEvent) {
if (!dragging) return;
const el = sirenRef.value;
if (!el) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
if (!moved && (Math.abs(dx) > 5 || Math.abs(dy) > 5)) moved = true;
if (!moved) return;
const maxLeft = window.innerWidth - el.offsetWidth;
const maxTop = window.innerHeight - el.offsetHeight;
el.style.left = `${Math.min(Math.max(originLeft + dx, 0), maxLeft)}px`;
el.style.top = `${Math.min(Math.max(originTop + dy, 0), maxTop)}px`;
}
// 拖动结束:移除监听;未拖动(位移 ≤5px)视为点击跳转,已拖动则保存位置到 localStorage
function onMouseUp() {
if (!dragging) return;
dragging = false;
isDragging.value = false;
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
const el = sirenRef.value;
if (moved) {
if (!el) return;
try {
localStorage.setItem(POS_KEY, JSON.stringify({ left: el.offsetLeft, top: el.offsetTop }));
} catch {
// 存储失败静默忽略
}
return;
}
goAlarmPage();
}
// 恢复警灯位置:优先读取本地记录(数值合法且不越界则应用),无记录/解析失败用默认右上角
function restorePosition() {
const el = sirenRef.value;
if (!el) return;
let left = window.innerWidth - el.offsetWidth - 24;
let top = 120;
try {
const raw = localStorage.getItem(POS_KEY);
if (raw) {
const saved = JSON.parse(raw);
const maxLeft = window.innerWidth - el.offsetWidth;
const maxTop = window.innerHeight - el.offsetHeight;
if (
typeof saved?.left === 'number' &&
typeof saved?.top === 'number' &&
Number.isFinite(saved.left) &&
Number.isFinite(saved.top) &&
saved.left >= 0 &&
saved.left <= maxLeft &&
saved.top >= 0 &&
saved.top <= maxTop
) {
left = saved.left;
top = saved.top;
}
}
} catch {
// 解析失败走默认右上角
}
el.style.left = `${left}px`;
el.style.top = `${top}px`;
}
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(() => playSiren(ctx)).catch(() => { /* 静默降级 */ });
} else {
playSiren(ctx);
}
} catch {
// 静默降级:不阻塞页面
}
}
// 警笛合成:700Hz↔900Hz 高低音交替 3 轮(每轮 0.4s 切换频率,共约 2.4s),gain 包络防爆音
function playSiren(ctx: AudioContext) {
const now = ctx.currentTime;
const FREQS = [700, 900];
const ROUNDS = 3;
for (let i = 0; i < ROUNDS * 2; i++) {
const start = now + i * 0.4;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = FREQS[i % 2];
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.4);
}
}
// 轮询(30 秒)与警笛重复提醒(3 分钟)
let pollTimer: number | undefined;
let sirenTimer: number | undefined;
onMounted(() => {
restorePosition();
fetchActiveSevere();
pollTimer = window.setInterval(fetchActiveSevere, 30000);
// 存在活跃严重报警时每 3 分钟重复播放一次警笛提醒
sirenTimer = window.setInterval(() => {
if (activeSevereIds.value.length > 0) playBeep();
}, 180000);
});
onUnmounted(() => {
if (pollTimer) window.clearInterval(pollTimer);
if (sirenTimer) window.clearInterval(sirenTimer);
audioCtx?.close().catch(() => {});
});
</script>
<style scoped>
/* 悬浮立体警灯(活跃严重报警指示),全局固定定位覆盖其他页面内容 */
.floating-siren {
position: fixed;
left: 0;
top: 120px;
z-index: 1000;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
cursor: grab;
}
/* 拖动中的光标样式(含警灯主体,覆盖其 pointer 光标) */
.floating-siren.dragging,
.floating-siren.dragging .siren-light {
cursor: grabbing;
}
/* 警灯主体:半球灯罩 + 旋转光束 + 红蓝双灯芯 */
.siren-light {
position: relative;
width: 56px;
height: 56px;
border-radius: 50%;
background:
radial-gradient(circle at 32% 24%, rgba(255, 255, 255, 0.9), rgba(255, 255, 255, 0.08) 40%, transparent 48%),
linear-gradient(160deg, #334155 0%, #0f172a 78%);
box-shadow:
inset 0 2px 6px rgba(255, 255, 255, 0.28),
inset 0 -6px 12px rgba(0, 0, 0, 0.55),
0 10px 22px rgba(15, 23, 42, 0.35);
cursor: pointer;
overflow: hidden;
user-select: none;
transition: box-shadow 0.3s ease;
}
.siren-light:hover {
box-shadow:
inset 0 2px 6px rgba(255, 255, 255, 0.3),
inset 0 -6px 12px rgba(0, 0, 0, 0.55),
0 12px 26px rgba(15, 23, 42, 0.45);
}
/* 灯罩高光:模拟玻璃半球顶部反光 */
.siren-dome {
position: absolute;
inset: 0;
border-radius: 50%;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.35) 0%, transparent 40%);
pointer-events: none;
z-index: 2;
}
/* 旋转光束:红蓝扇形交替扫光,模拟警灯旋转 */
.siren-beam {
position: absolute;
inset: -10px;
border-radius: 50%;
background: conic-gradient(
from 0deg,
rgba(255, 77, 79, 0.5) 0deg 45deg,
transparent 45deg 135deg,
rgba(22, 119, 255, 0.5) 135deg 180deg,
transparent 180deg 360deg
);
opacity: 0;
transition: opacity 0.3s ease;
}
/* 红蓝双灯芯 */
.siren-bulb {
position: absolute;
top: 50%;
width: 15px;
height: 15px;
border-radius: 50%;
transform: translateY(-50%);
opacity: 0.3;
z-index: 1;
}
.bulb-red {
left: 9px;
background: radial-gradient(circle at 35% 30%, #fff, #ff4d4f 55%, #a8071a);
}
.bulb-blue {
right: 9px;
background: radial-gradient(circle at 35% 30%, #fff, #1677ff 55%, #003eb3);
}
/* 激活状态:红蓝交替闪烁 + 光束旋转 + 整体内发光 */
.siren-light.is-on {
box-shadow:
inset 0 2px 6px rgba(255, 255, 255, 0.3),
inset 0 -6px 12px rgba(0, 0, 0, 0.5),
0 0 16px rgba(255, 77, 79, 0.55),
0 10px 22px rgba(15, 23, 42, 0.4);
}
.siren-light.is-on .siren-beam {
opacity: 0.9;
animation: siren-rotate 1.2s linear infinite;
}
.siren-light.is-on .bulb-red {
animation: siren-blink-red 1s ease-in-out infinite;
}
.siren-light.is-on .bulb-blue {
animation: siren-blink-blue 1s ease-in-out infinite;
}
@keyframes siren-rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes siren-blink-red {
0%,
45% {
opacity: 1;
box-shadow: 0 0 12px 3px rgba(255, 77, 79, 0.9);
}
55%,
100% {
opacity: 0.15;
box-shadow: none;
}
}
@keyframes siren-blink-blue {
0%,
45% {
opacity: 0.15;
box-shadow: none;
}
55%,
100% {
opacity: 1;
box-shadow: 0 0 12px 3px rgba(22, 119, 255, 0.9);
}
}
</style>
+4
View File
@@ -125,6 +125,9 @@
</a-spin>
</div>
</a-drawer>
<!-- 全局悬浮警灯(登录后所有页面共享,活跃严重报警提示) -->
<AlarmSirenLight />
</a-layout>
</template>
@@ -157,6 +160,7 @@ import {
} from '@ant-design/icons-vue'
import { usePermissionStore, type MenuNode } from '@/stores/permission'
import { noticeApi, type NoticeInfo } from '@/api/notice'
import AlarmSirenLight from '@/components/AlarmSirenLight.vue'
const router = useRouter()
const route = useRoute()
+1 -114
View File
@@ -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;