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:
parent
f7dc39a70a
commit
49a4afa4a4
|
|
@ -4,6 +4,54 @@ export function setStatus(text) {
|
||||||
dom.statusText.textContent = 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 = {}) {
|
export async function apiFetch(url, options = {}) {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
|
@ -11,7 +59,9 @@ export async function apiFetch(url, options = {}) {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
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) {
|
if (response.status === 204) {
|
||||||
|
|
|
||||||
|
|
@ -1000,6 +1000,63 @@ button.danger:hover { background: var(--danger-hover); }
|
||||||
color: var(--text);
|
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 ────────────────────────────────────── */
|
/* ── Scrollbar ────────────────────────────────────── */
|
||||||
|
|
||||||
::-webkit-scrollbar { width: 5px; height: 5px; }
|
::-webkit-scrollbar { width: 5px; height: 5px; }
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue