feat(web): add toast notifications + WS disconnect alert
- New toast.js module: showToast(message, type, duration) - types: error / warning / success / info - duration=0 → persistent until dismissed or manually closed - click to dismiss early - shake animation on appear - WS disconnect: persistent red error toast "后端连接断开,正在重连…" - WS reconnect: dismisses disconnect toast, shows green "连接已恢复" for 3s Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
757d6f9a3a
commit
69ae0b05b7
|
|
@ -4,7 +4,7 @@
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>PLC Control</title>
|
<title>PLC Control</title>
|
||||||
<link rel="stylesheet" href="/ui/styles.css?v=20260325d" />
|
<link rel="stylesheet" href="/ui/styles.css?v=20260325e" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div data-partial="/ui/html/topbar.html"></div>
|
<div data-partial="/ui/html/topbar.html"></div>
|
||||||
|
|
@ -19,9 +19,10 @@
|
||||||
<div data-partial="/ui/html/chart-panel.html"></div>
|
<div data-partial="/ui/html/chart-panel.html"></div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<div id="toastContainer"></div>
|
||||||
<div data-partial="/ui/html/modals.html"></div>
|
<div data-partial="/ui/html/modals.html"></div>
|
||||||
<div data-partial="/ui/html/api-doc-drawer.html"></div>
|
<div data-partial="/ui/html/api-doc-drawer.html"></div>
|
||||||
|
|
||||||
<script type="module" src="/ui/js/index.js?v=20260325d"></script>
|
<script type="module" src="/ui/js/index.js?v=20260325e"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { prependEvent } from "./events.js";
|
||||||
import { formatValue } from "./points.js";
|
import { formatValue } from "./points.js";
|
||||||
import { state } from "./state.js";
|
import { state } from "./state.js";
|
||||||
import { renderUnits } from "./units.js";
|
import { renderUnits } from "./units.js";
|
||||||
|
import { showToast } from "./toast.js";
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
return text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
return text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
||||||
|
|
@ -55,6 +56,8 @@ export function stopLogs() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let _disconnectToast = null;
|
||||||
|
|
||||||
function setWsStatus(connected) {
|
function setWsStatus(connected) {
|
||||||
if (dom.wsDot) {
|
if (dom.wsDot) {
|
||||||
dom.wsDot.className = `ws-dot ${connected ? "connected" : "disconnected"}`;
|
dom.wsDot.className = `ws-dot ${connected ? "connected" : "disconnected"}`;
|
||||||
|
|
@ -62,6 +65,13 @@ function setWsStatus(connected) {
|
||||||
if (dom.wsLabel) {
|
if (dom.wsLabel) {
|
||||||
dom.wsLabel.textContent = connected ? "已连接" : "连接断开,重连中…";
|
dom.wsLabel.textContent = connected ? "已连接" : "连接断开,重连中…";
|
||||||
}
|
}
|
||||||
|
if (!connected && !_disconnectToast) {
|
||||||
|
_disconnectToast = showToast("后端连接断开,正在重连…", "error", 0);
|
||||||
|
} else if (connected && _disconnectToast) {
|
||||||
|
_disconnectToast.dismiss();
|
||||||
|
_disconnectToast = null;
|
||||||
|
showToast("连接已恢复", "success", 3000);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startPointSocket() {
|
export function startPointSocket() {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
const container = document.getElementById("toastContainer");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a toast notification.
|
||||||
|
* @param {string} message
|
||||||
|
* @param {"error"|"warning"|"success"|"info"} type
|
||||||
|
* @param {number} duration ms before auto-dismiss (0 = persistent until manually dismissed)
|
||||||
|
* @returns {{ dismiss: () => void }} call dismiss() to remove early
|
||||||
|
*/
|
||||||
|
export function showToast(message, type = "info", duration = 4000) {
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.className = `toast ${type} shake`;
|
||||||
|
el.textContent = message;
|
||||||
|
container.appendChild(el);
|
||||||
|
|
||||||
|
function dismiss() {
|
||||||
|
if (!el.parentNode) return;
|
||||||
|
el.classList.add("leaving");
|
||||||
|
el.addEventListener("animationend", () => el.remove(), { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
let timer = duration > 0 ? setTimeout(dismiss, duration) : null;
|
||||||
|
|
||||||
|
el.addEventListener("click", () => {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
dismiss();
|
||||||
|
});
|
||||||
|
|
||||||
|
return { dismiss };
|
||||||
|
}
|
||||||
|
|
@ -1310,3 +1310,55 @@ button.danger:hover { background: var(--danger-hover); }
|
||||||
.doc-view { padding: 0; }
|
.doc-view { padding: 0; }
|
||||||
.doc-card { border-left: none; border-right: none; }
|
.doc-card { border-left: none; border-right: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Toast notifications ─────────────────────────── */
|
||||||
|
|
||||||
|
#toastContainer {
|
||||||
|
position: fixed;
|
||||||
|
top: calc(var(--topbar-h) + 8px);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.toast {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
animation: toast-in 0.2s ease forwards;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.toast.error { background: var(--danger); }
|
||||||
|
.toast.warning { background: var(--warning); }
|
||||||
|
.toast.success { background: var(--success); }
|
||||||
|
.toast.info { background: var(--accent); }
|
||||||
|
.toast.shake { animation: toast-in 0.2s ease forwards, toast-shake 0.4s ease 0.2s; }
|
||||||
|
.toast.leaving { animation: toast-out 0.2s ease forwards; }
|
||||||
|
|
||||||
|
@keyframes toast-in {
|
||||||
|
from { opacity: 0; transform: translateY(-8px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
@keyframes toast-out {
|
||||||
|
from { opacity: 1; transform: translateY(0); }
|
||||||
|
to { opacity: 0; transform: translateY(-8px); }
|
||||||
|
}
|
||||||
|
@keyframes toast-shake {
|
||||||
|
0%, 100% { transform: translateX(0); }
|
||||||
|
20% { transform: translateX(-5px); }
|
||||||
|
40% { transform: translateX(5px); }
|
||||||
|
60% { transform: translateX(-4px); }
|
||||||
|
80% { transform: translateX(4px); }
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue