diff --git a/web/index.html b/web/index.html index 05ea133..0feecfe 100644 --- a/web/index.html +++ b/web/index.html @@ -4,7 +4,7 @@ PLC Control - +
@@ -19,10 +19,9 @@
-
- + diff --git a/web/js/api.js b/web/js/api.js index 3abff0c..8d1c7bf 100644 --- a/web/js/api.js +++ b/web/js/api.js @@ -22,15 +22,17 @@ const ICONS = { error: "✕", warning: "!", success: "✓", info: "i" }; * 显示 toast 通知。 * @param {string} title 主要文字 * @param {object} [opts] - * @param {string} [opts.message] 次要说明文字 + * @param {string} [opts.message] 次要说明文字 * @param {"error"|"warning"|"success"|"info"} [opts.level="error"] * @param {number} [opts.duration=4000] 自动关闭毫秒数,0 表示不自动关闭 + * @param {boolean} [opts.shake=false] 出现时加抖动动画 + * @returns {{ dismiss: () => void }} */ -export function showToast(title, { message, level = "error", duration = 4000 } = {}) { +export function showToast(title, { message, level = "error", duration = 4000, shake = false } = {}) { const container = getContainer(); const el = document.createElement("div"); - el.className = `toast ${level}`; + el.className = `toast ${level}${shake ? " shake" : ""}`; el.innerHTML = ` ${ICONS[level] ?? "i"}
@@ -39,6 +41,7 @@ export function showToast(title, { message, level = "error", duration = 4000 } =
`; const dismiss = () => { + if (!el.parentNode) return; el.classList.add("hiding"); el.addEventListener("animationend", () => el.remove(), { once: true }); }; @@ -47,7 +50,7 @@ export function showToast(title, { message, level = "error", duration = 4000 } = container.appendChild(el); if (duration > 0) setTimeout(dismiss, duration); - return dismiss; + return { dismiss }; } // ── apiFetch ────────────────────────────────────── diff --git a/web/js/logs.js b/web/js/logs.js index 55ad608..ed96c38 100644 --- a/web/js/logs.js +++ b/web/js/logs.js @@ -4,7 +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"; +import { showToast } from "./api.js"; function escapeHtml(text) { return text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); @@ -66,11 +66,16 @@ function setWsStatus(connected) { dom.wsLabel.textContent = connected ? "已连接" : "连接断开,重连中…"; } if (!connected && !_disconnectToast) { - _disconnectToast = showToast("后端连接断开,正在重连…", "error", 0); + _disconnectToast = showToast("后端连接断开", { + message: "正在重连,请稍候…", + level: "error", + duration: 0, + shake: true, + }); } else if (connected && _disconnectToast) { _disconnectToast.dismiss(); _disconnectToast = null; - showToast("连接已恢复", "success", 3000); + showToast("连接已恢复", { level: "success", duration: 3000 }); } } diff --git a/web/js/toast.js b/web/js/toast.js deleted file mode 100644 index 2772063..0000000 --- a/web/js/toast.js +++ /dev/null @@ -1,30 +0,0 @@ -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 a855cc6..e9748c7 100644 --- a/web/styles.css +++ b/web/styles.css @@ -1270,6 +1270,7 @@ button.danger:hover { background: var(--danger-hover); } .toast-message { color: var(--text-2); font-size: 12px; } .toast.hiding { animation: toast-out 0.15s ease forwards; } +.toast.shake { animation: toast-shake 0.4s ease; } @keyframes toast-in { from { opacity: 0; transform: translateY(8px); } @@ -1279,6 +1280,13 @@ button.danger:hover { background: var(--danger-hover); } 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); } +} /* ── Scrollbar ────────────────────────────────────── */ @@ -1311,54 +1319,4 @@ button.danger:hover { background: var(--danger-hover); } .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); } -}