372 lines
9.9 KiB
Vue
372 lines
9.9 KiB
Vue
<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>
|