7635412b7b
- 작업자 폰(/mobile)을 SCADA 데모와 ws 로 연결, 알람 발생 시 풀스크린 푸시
· v5 솔리드+글로우 톤, 진동/Web Audio 비프/Wake Lock/auto reconnect
· 시연 안전망: ?test=1 자동 발동, 우상단 hidden 트리거
- backend: com.erp.alarm 신규 패키지 (WebSocketConfig + Handshake + Handler + Controller)
· JWT 토큰 핸드셰이크 검증, userId 기반 채널 매핑 (멀티 디바이스 지원)
· spring-boot-starter-websocket 의존성 추가
· path 를 /api/demo/* 안에 두어 Traefik 라우트 추가 불필요 + 정식 알람과 분리
- SCADA scenario.js 의 emergency 시퀀스(2700ms)에 fetch('/api/demo/alarm/trigger') 배선
· /scada?worker=<user_id> query 로 target user 지정 (iframe src 로 전달)
- 운영 시연 URL: siflex.invyone.com/mobile (siflex_user) ↔ /scada?worker=siflex_user
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
265 lines
9.3 KiB
TypeScript
265 lines
9.3 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { useAuth } from "@/hooks/useAuth";
|
|
import { TokenManager } from "@/lib/auth/tokenManager";
|
|
import "@/styles/mobile-alarm.css";
|
|
|
|
type Severity = "CRITICAL" | "WARNING" | "INFO";
|
|
|
|
type AlarmPayload = {
|
|
code: string;
|
|
severity: Severity;
|
|
title: string;
|
|
message: string;
|
|
location: string;
|
|
comp?: string;
|
|
ts: number;
|
|
};
|
|
|
|
const MOCK_ALARM: AlarmPayload = {
|
|
code: "P-IN-HH",
|
|
severity: "CRITICAL",
|
|
title: "BW-A1 펌프 과압 / 누설 의심",
|
|
message:
|
|
"BW-A1 토출 압력이 정상 운전 범위(4.0~5.0 bar)를 초과했습니다.\n현장 점검이 즉시 필요합니다.",
|
|
location: "펌프룸 A · BW-A1 (원수 취수펌프 #1)",
|
|
comp: "bw-a1",
|
|
ts: 0,
|
|
};
|
|
|
|
const RECONNECT_DELAY_MS = 3000;
|
|
|
|
// Wake Lock API — 표준이지만 일부 브라우저에서 미지원이라 옵셔널 체이닝.
|
|
type WakeLockSentinel = { release: () => Promise<void> } & EventTarget;
|
|
type WakeLockNavigator = Navigator & {
|
|
wakeLock?: { request: (type: "screen") => Promise<WakeLockSentinel> };
|
|
};
|
|
|
|
export function MobileAlarmClient() {
|
|
const { user } = useAuth();
|
|
const [mode, setMode] = useState<"idle" | "alarm">("idle");
|
|
const [alarm, setAlarm] = useState<AlarmPayload | null>(null);
|
|
const [clock, setClock] = useState("--:--:--");
|
|
const [connected, setConnected] = useState(false);
|
|
|
|
const wakeLockRef = useRef<WakeLockSentinel | null>(null);
|
|
const audioCtxRef = useRef<AudioContext | null>(null);
|
|
|
|
// ─── 시계 ────────────────────────────────────────────────────────
|
|
useEffect(() => {
|
|
const tick = () => {
|
|
const d = new Date();
|
|
const hh = String(d.getHours()).padStart(2, "0");
|
|
const mm = String(d.getMinutes()).padStart(2, "0");
|
|
const ss = String(d.getSeconds()).padStart(2, "0");
|
|
setClock(`${hh}:${mm}:${ss}`);
|
|
};
|
|
tick();
|
|
const t = setInterval(tick, 1000);
|
|
return () => clearInterval(t);
|
|
}, []);
|
|
|
|
// ─── 알람음 (Web Audio API 비프, 외부 파일 의존성 없음) ───────────
|
|
const playAlarmBeep = useCallback(() => {
|
|
try {
|
|
let ctx = audioCtxRef.current;
|
|
if (!ctx) {
|
|
const Ctor = (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext);
|
|
ctx = new Ctor();
|
|
audioCtxRef.current = ctx;
|
|
}
|
|
// iOS 등은 사용자 제스처 없이 시작된 ctx 가 suspended 상태일 수 있음
|
|
if (ctx.state === "suspended") void ctx.resume();
|
|
const now = ctx.currentTime;
|
|
// 3회 반복 비프 (650Hz / 0.18s on, 0.12s off)
|
|
for (let i = 0; i < 3; i++) {
|
|
const osc = ctx.createOscillator();
|
|
const gain = ctx.createGain();
|
|
osc.type = "square";
|
|
osc.frequency.value = 650;
|
|
const start = now + i * 0.3;
|
|
const stop = start + 0.18;
|
|
gain.gain.setValueAtTime(0.0001, start);
|
|
gain.gain.exponentialRampToValueAtTime(0.35, start + 0.01);
|
|
gain.gain.exponentialRampToValueAtTime(0.0001, stop);
|
|
osc.connect(gain).connect(ctx.destination);
|
|
osc.start(start);
|
|
osc.stop(stop + 0.02);
|
|
}
|
|
} catch {
|
|
// 오디오 실패해도 알람 표시는 정상 동작 — silent fail
|
|
}
|
|
}, []);
|
|
|
|
// ─── 알람 표시 ───────────────────────────────────────────────────
|
|
const showAlarm = useCallback(
|
|
(payload: AlarmPayload) => {
|
|
setAlarm({ ...payload, ts: payload.ts || Date.now() });
|
|
setMode("alarm");
|
|
if (typeof navigator !== "undefined" && navigator.vibrate) {
|
|
navigator.vibrate([400, 120, 400, 120, 800]);
|
|
}
|
|
playAlarmBeep();
|
|
},
|
|
[playAlarmBeep],
|
|
);
|
|
|
|
const ackAlarm = useCallback(() => {
|
|
setMode("idle");
|
|
setAlarm(null);
|
|
if (typeof navigator !== "undefined" && navigator.vibrate) {
|
|
navigator.vibrate(0);
|
|
}
|
|
}, []);
|
|
|
|
// ─── Wake Lock (화면 잠금 회피) ───────────────────────────────────
|
|
useEffect(() => {
|
|
const nav = navigator as WakeLockNavigator;
|
|
if (!nav.wakeLock) return;
|
|
|
|
const acquire = async () => {
|
|
try {
|
|
if (wakeLockRef.current) return;
|
|
wakeLockRef.current = await nav.wakeLock!.request("screen");
|
|
wakeLockRef.current.addEventListener("release", () => {
|
|
wakeLockRef.current = null;
|
|
});
|
|
} catch {
|
|
// 사용자 권한 없거나 미지원 — 무시
|
|
}
|
|
};
|
|
|
|
void acquire();
|
|
|
|
// 탭 복귀 시 lock 이 풀려있을 수 있으므로 재획득
|
|
const onVisibility = () => {
|
|
if (document.visibilityState === "visible") void acquire();
|
|
};
|
|
document.addEventListener("visibilitychange", onVisibility);
|
|
|
|
return () => {
|
|
document.removeEventListener("visibilitychange", onVisibility);
|
|
void wakeLockRef.current?.release().catch(() => {});
|
|
wakeLockRef.current = null;
|
|
};
|
|
}, []);
|
|
|
|
// ─── WebSocket 연결 (auto reconnect) ─────────────────────────────
|
|
useEffect(() => {
|
|
if (typeof window === "undefined") return;
|
|
const token = TokenManager.getToken();
|
|
if (!token) return;
|
|
|
|
const proto = window.location.protocol === "https:" ? "wss" : "ws";
|
|
// /api/demo/* 는 시연 전용 path. 운영 Traefik 의 PathPrefix(`/api`) 라우트로 backend 까지 도달.
|
|
const url = `${proto}://${window.location.host}/api/demo/ws/alarm?token=${encodeURIComponent(token)}`;
|
|
|
|
let ws: WebSocket | null = null;
|
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
let cancelled = false;
|
|
|
|
const connect = () => {
|
|
if (cancelled) return;
|
|
ws = new WebSocket(url);
|
|
|
|
ws.onopen = () => setConnected(true);
|
|
ws.onclose = () => {
|
|
setConnected(false);
|
|
if (!cancelled) {
|
|
reconnectTimer = setTimeout(connect, RECONNECT_DELAY_MS);
|
|
}
|
|
};
|
|
ws.onerror = () => {
|
|
// close 이벤트가 곧 따라옴 — 거기서 재연결 처리
|
|
};
|
|
ws.onmessage = (e) => {
|
|
try {
|
|
const data = JSON.parse(e.data);
|
|
if (data?.type === "alarm" && data?.payload) {
|
|
showAlarm(data.payload as AlarmPayload);
|
|
}
|
|
} catch {
|
|
// 파싱 실패는 silent
|
|
}
|
|
};
|
|
};
|
|
|
|
connect();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
if (ws && ws.readyState <= 1) ws.close();
|
|
};
|
|
}, [showAlarm]);
|
|
|
|
// ─── 시연 안전망 — ?test=1 자동 발동 ──────────────────────────────
|
|
useEffect(() => {
|
|
if (typeof window === "undefined") return;
|
|
const params = new URLSearchParams(window.location.search);
|
|
if (params.get("test") === "1") {
|
|
const t = setTimeout(() => showAlarm(MOCK_ALARM), 800);
|
|
return () => clearTimeout(t);
|
|
}
|
|
}, [showAlarm]);
|
|
|
|
return (
|
|
<div className={`m-shell ${mode === "alarm" ? "m-shell-alarm" : ""}`}>
|
|
<header className="m-head">
|
|
<span className="m-brand">🛡 INVYONE EHS</span>
|
|
<span className={`m-conn ${connected ? "ok" : "off"}`}>
|
|
<span className="m-dot" />
|
|
{connected ? "실시간 연결됨" : "오프라인 (재연결 중...)"}
|
|
</span>
|
|
</header>
|
|
|
|
{mode === "idle" && (
|
|
<main className="m-idle">
|
|
<div className="m-glow-orb" />
|
|
<div className="m-clock">{clock}</div>
|
|
<div className="m-user">{user?.user_name ?? "작업자"}</div>
|
|
{user?.dept_name && <div className="m-user-dept">{user.dept_name}</div>}
|
|
<div className="m-idle-hint">
|
|
설비 알람이 발생하면 진동과 함께
|
|
<br />이 화면에 풀스크린으로 표시됩니다
|
|
</div>
|
|
</main>
|
|
)}
|
|
|
|
{mode === "alarm" && alarm && (
|
|
<main className="m-alarm">
|
|
<div className="m-alarm-bg" />
|
|
<div className="m-alarm-card">
|
|
<div className="m-alarm-sev">{alarm.severity}</div>
|
|
<div className="m-alarm-icon">⚠</div>
|
|
<div className="m-alarm-code">{alarm.code}</div>
|
|
<div className="m-alarm-title">{alarm.title}</div>
|
|
<div className="m-alarm-loc">📍 {alarm.location}</div>
|
|
<div className="m-alarm-msg">{alarm.message}</div>
|
|
<div className="m-alarm-actions">
|
|
<button className="m-btn-ack" onClick={ackAlarm}>
|
|
✓ 확인 (ACK) — 현장 출동
|
|
</button>
|
|
<button className="m-btn-skip" onClick={ackAlarm}>
|
|
나중에
|
|
</button>
|
|
</div>
|
|
<div className="m-alarm-time">
|
|
발생: {new Date(alarm.ts).toLocaleTimeString("ko-KR")}
|
|
</div>
|
|
</div>
|
|
</main>
|
|
)}
|
|
|
|
{/* 시연 안전망 — 우상단 점 클릭으로 mock 알람 트리거 */}
|
|
<button
|
|
className="m-test-trigger"
|
|
onClick={() => (mode === "idle" ? showAlarm(MOCK_ALARM) : ackAlarm())}
|
|
aria-label="test trigger"
|
|
title="test trigger"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|