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