/* ===================================================================== * 通用工具(utils.js) * DOM 查询 / HTML 转义 / URL 白名单 / 日期格式化 / Toast / 自定义弹窗 * ===================================================================== */ export function $(selector, root = document) { return root.querySelector(selector); } export function escapeHtml(value) { return String(value ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'", }[c])); } // URL 协议白名单:仅允许 http/https、站内相对路径与锚点, // 禁止 javascript: / data: / vbscript: 等危险协议,防止 XSS。 export function safeUrl(url, type = "link") { const value = String(url ?? "").trim(); if (!value) return ""; if (/^(https?:)?\/\//i.test(value)) return value; if (value.startsWith("/") || value.startsWith("#")) return value; if (type === "link" && value.startsWith("mailto:")) return value; return ""; } export function parseDate(value) { if (!value) return null; let text = String(value).replace(" ", "T"); // 后端时间为无时区 UTC,补 Z 后按 UTC 解析并转为本地时区显示 if (!/[zZ]|[+-]\d{2}:\d{2}$/.test(text)) text += "Z"; const d = new Date(text); return Number.isNaN(d.getTime()) ? null : d; } export function formatDate(value) { const d = parseDate(value); if (!d) return ""; const p = (n) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`; } /* ---------------- 通知(自定义 Snackbar,不使用 alert) ---------------- */ export function showToast(message, type = "info") { const el = document.createElement("div"); el.className = `toast toast-${type}`; el.textContent = message; $("#toast-root").appendChild(el); requestAnimationFrame(() => el.classList.add("show")); setTimeout(() => { el.classList.remove("show"); setTimeout(() => el.remove(), 300); }, 3200); } /* ---------------- 自定义弹窗 ---------------- */ export function openModal({ title, content }) { const overlay = document.createElement("div"); overlay.className = "modal-overlay"; overlay.innerHTML = `