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 <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-03-24 13:43:57 +08:00
parent f7dc39a70a
commit 49a4afa4a4
2 changed files with 108 additions and 1 deletions

View File

@ -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 = `
<span class="toast-icon">${ICONS[level] ?? "i"}</span>
<div class="toast-body">
<div class="toast-title">${title}</div>
${message ? `<div class="toast-message">${message}</div>` : ""}
</div>`;
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) {

View File

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