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:
caoqianming 2026-03-25 13:08:14 +08:00
parent 757d6f9a3a
commit 69ae0b05b7
4 changed files with 95 additions and 2 deletions

View File

@ -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>

View File

@ -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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;"); return text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
@ -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() {

30
web/js/toast.js Normal file
View File

@ -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 };
}

View File

@ -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); }
}