Files
smart_grid_iot/client/src/components/AlarmSirenLight.vue
T

372 lines
9.9 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>