diff --git a/web/index.html b/web/index.html index 1eecd2c..05ea133 100644 --- a/web/index.html +++ b/web/index.html @@ -4,7 +4,7 @@ PLC Control - +
@@ -19,9 +19,10 @@
+
- + diff --git a/web/js/logs.js b/web/js/logs.js index ee0d5b5..55ad608 100644 --- a/web/js/logs.js +++ b/web/js/logs.js @@ -4,6 +4,7 @@ import { prependEvent } from "./events.js"; import { formatValue } from "./points.js"; import { state } from "./state.js"; import { renderUnits } from "./units.js"; +import { showToast } from "./toast.js"; function escapeHtml(text) { return text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); @@ -55,6 +56,8 @@ export function stopLogs() { } } +let _disconnectToast = null; + function setWsStatus(connected) { if (dom.wsDot) { dom.wsDot.className = `ws-dot ${connected ? "connected" : "disconnected"}`; @@ -62,6 +65,13 @@ function setWsStatus(connected) { if (dom.wsLabel) { 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() { diff --git a/web/js/toast.js b/web/js/toast.js new file mode 100644 index 0000000..2772063 --- /dev/null +++ b/web/js/toast.js @@ -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 }; +} diff --git a/web/styles.css b/web/styles.css index ebdc01f..a855cc6 100644 --- a/web/styles.css +++ b/web/styles.css @@ -1310,3 +1310,55 @@ button.danger:hover { background: var(--danger-hover); } .doc-view { padding: 0; } .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); } +}