refactor(web): unify toast — remove duplicate, reuse api.js showToast

- Deleted redundant toast.js (was duplicating api.js's toast with conflicting CSS)
- Extended api.js showToast: returns {dismiss}, supports shake option, guards
  against double-dismiss
- Removed duplicate #toastContainer CSS block from styles.css; added shake
  animation to existing toast CSS
- logs.js: import showToast from api.js; WS disconnect shows persistent error
  toast with shake, reconnect shows success toast

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-03-25 13:11:23 +08:00
parent 69ae0b05b7
commit b7d55fed81
5 changed files with 25 additions and 90 deletions

View File

@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PLC Control</title>
<link rel="stylesheet" href="/ui/styles.css?v=20260325e" />
<link rel="stylesheet" href="/ui/styles.css?v=20260325f" />
</head>
<body>
<div data-partial="/ui/html/topbar.html"></div>
@ -19,10 +19,9 @@
<div data-partial="/ui/html/chart-panel.html"></div>
</main>
<div id="toastContainer"></div>
<div data-partial="/ui/html/modals.html"></div>
<div data-partial="/ui/html/api-doc-drawer.html"></div>
<script type="module" src="/ui/js/index.js?v=20260325e"></script>
<script type="module" src="/ui/js/index.js?v=20260325f"></script>
</body>
</html>

View File

@ -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 = `
<span class="toast-icon">${ICONS[level] ?? "i"}</span>
<div class="toast-body">
@ -39,6 +41,7 @@ export function showToast(title, { message, level = "error", duration = 4000 } =
</div>`;
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 ──────────────────────────────────────

View File

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

View File

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

View File

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