From 49a4afa4a4449b3b8254acb241ba57f93a1a3f4f Mon Sep 17 00:00:00 2001 From: caoqianming Date: Tue, 24 Mar 2026 13:43:57 +0800 Subject: [PATCH] feat(web): auto-toast on API errors with dismissible notifications Add showToast() utility in api.js and a matching toast stylesheet. apiFetch now automatically shows a toast for any 400+ response before re-throwing, so callers can still .catch() for additional handling. Toasts stack at the bottom-right, auto-dismiss after 4s, and support error/warning/success/info levels via a left-border colour accent. Co-Authored-By: Claude Sonnet 4.6 --- web/js/api.js | 52 ++++++++++++++++++++++++++++++++++++++++++++- web/styles.css | 57 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 1 deletion(-) diff --git a/web/js/api.js b/web/js/api.js index a6393d2..3abff0c 100644 --- a/web/js/api.js +++ b/web/js/api.js @@ -4,6 +4,54 @@ export function setStatus(text) { dom.statusText.textContent = text; } +// ── Toast ───────────────────────────────────────── + +function getContainer() { + let el = document.getElementById("toast-container"); + if (!el) { + el = document.createElement("div"); + el.id = "toast-container"; + document.body.appendChild(el); + } + return el; +} + +const ICONS = { error: "✕", warning: "!", success: "✓", info: "i" }; + +/** + * 显示 toast 通知。 + * @param {string} title 主要文字 + * @param {object} [opts] + * @param {string} [opts.message] 次要说明文字 + * @param {"error"|"warning"|"success"|"info"} [opts.level="error"] + * @param {number} [opts.duration=4000] 自动关闭毫秒数,0 表示不自动关闭 + */ +export function showToast(title, { message, level = "error", duration = 4000 } = {}) { + const container = getContainer(); + + const el = document.createElement("div"); + el.className = `toast ${level}`; + el.innerHTML = ` + ${ICONS[level] ?? "i"} +
+
${title}
+ ${message ? `
${message}
` : ""} +
`; + + const dismiss = () => { + el.classList.add("hiding"); + el.addEventListener("animationend", () => el.remove(), { once: true }); + }; + + el.addEventListener("click", dismiss); + container.appendChild(el); + + if (duration > 0) setTimeout(dismiss, duration); + return dismiss; +} + +// ── apiFetch ────────────────────────────────────── + export async function apiFetch(url, options = {}) { const response = await fetch(url, { headers: { "Content-Type": "application/json" }, @@ -11,7 +59,9 @@ export async function apiFetch(url, options = {}) { }); if (!response.ok) { - throw new Error((await response.text()) || response.statusText); + const text = (await response.text()) || response.statusText; + showToast(`请求失败 ${response.status}`, { message: text }); + throw new Error(text); } if (response.status === 204) { diff --git a/web/styles.css b/web/styles.css index 41abbf4..62b6583 100644 --- a/web/styles.css +++ b/web/styles.css @@ -1000,6 +1000,63 @@ button.danger:hover { background: var(--danger-hover); } color: var(--text); } +/* ── Toast ────────────────────────────────────────── */ + +#toast-container { + position: fixed; + bottom: 20px; + right: 20px; + display: flex; + flex-direction: column-reverse; + gap: 8px; + z-index: 9999; + pointer-events: none; +} + +.toast { + display: flex; + align-items: flex-start; + gap: 8px; + min-width: 240px; + max-width: 380px; + padding: 10px 14px; + background: var(--surface); + border: 1px solid var(--border); + border-left: 3px solid var(--text-3); + box-shadow: 0 4px 12px rgba(0,0,0,0.1); + font-size: 13px; + color: var(--text); + pointer-events: auto; + animation: toast-in 0.15s ease; + cursor: pointer; +} + +.toast.error { border-left-color: var(--danger); } +.toast.warning { border-left-color: var(--warning); } +.toast.success { border-left-color: var(--success); } + +.toast-icon { + flex-shrink: 0; + font-size: 14px; + line-height: 1.5; +} + +.toast-body { flex: 1; word-break: break-word; } +.toast-title { font-weight: 600; margin-bottom: 2px; } +.toast-title:only-child { margin-bottom: 0; } +.toast-message { color: var(--text-2); font-size: 12px; } + +.toast.hiding { animation: toast-out 0.15s 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); } +} + /* ── Scrollbar ────────────────────────────────────── */ ::-webkit-scrollbar { width: 5px; height: 5px; }