/* ==========================================================================
components.js — 共享交互组件(toast / modal / 图标 / 格式化)
========================================================================== */
/* ------------------------------------------------------------ 内联图标 */
const ICONS = {
images:
'',
history:
'',
plug:
'',
boxes:
'',
fileText:
'',
plus: '',
trash:
'',
search:
'',
refresh:
'',
check: '',
checkCircle:
'',
x: '',
chevronLeft: '',
chevronRight: '',
chevronDown: '',
eye: '',
eyeOff:
'',
copy:
'',
key: '',
alert:
'',
info: '',
loader: '',
download:
'',
externalLink:
'',
image:
'',
filter: '',
code: '',
sparkles:
'',
database:
'',
clock: '',
zap: '',
settings:
'',
};
function icon(name, size = 16) {
const body = ICONS[name] || ICONS.info;
return ``;
}
/* --------------------------------------------------------------- Toast */
function toast(msg, type = "ok") {
let host = document.getElementById("toast-host");
if (!host) {
host = document.createElement("div");
host.id = "toast-host";
document.body.appendChild(host);
}
const ic = type === "err" ? "alert" : type === "info" ? "info" : "checkCircle";
const el = document.createElement("div");
el.className = "toast" + (type === "err" ? " err" : type === "info" ? " info" : "");
el.innerHTML = `${icon(ic, 16)}${esc(msg)}`;
host.appendChild(el);
setTimeout(() => {
el.style.transition = "opacity .25s ease, transform .25s ease";
el.style.opacity = "0";
el.style.transform = "translateY(6px)";
setTimeout(() => el.remove(), 260);
}, 2600);
}
/* --------------------------------------------------------------- Modal */
function openModal(id) {
const m = document.getElementById(id);
if (m) m.classList.add("open");
}
function closeModal(id) {
const m = document.getElementById(id);
if (m) m.classList.remove("open");
}
function paintIcons(root = document) {
root.querySelectorAll("[data-icon]").forEach((el) => {
el.outerHTML = icon(el.getAttribute("data-icon"), 16);
});
}
async function runTask(task, button) {
if (button && button.disabled) return;
if (button) { button.disabled = true; button.classList.add("is-loading"); }
try {
return await task();
} catch (err) {
toast(err.message || "操作失败,请重试", "err");
} finally {
if (button) { button.disabled = false; button.classList.remove("is-loading"); }
}
}
document.addEventListener("click", (e) => {
if (e.target.classList && e.target.classList.contains("modal-mask")) {
e.target.classList.remove("open");
}
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
document.querySelectorAll(".modal-mask.open").forEach((m) => m.classList.remove("open"));
document.querySelectorAll(".lightbox.open").forEach((m) => m.classList.remove("open"));
}
});
/* ------------------------------------------------------------ 格式化 */
function fmtBytes(n) {
if (!n && n !== 0) return "—";
if (n < 1024) return n + " B";
if (n < 1048576) return (n / 1024).toFixed(1) + " KB";
return (n / 1048576).toFixed(2) + " MB";
}
function fmtDuration(ms) {
if (ms == null) return "—";
return ms >= 1000 ? (ms / 1000).toFixed(1) + " s" : ms + " ms";
}
function esc(s) {
return String(s == null ? "" : s).replace(/[&<>"']/g, (c) =>
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])
);
}
function copyText(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(
() => toast("已复制到剪贴板"),
() => toast("复制失败,请手动选择", "err")
);
return;
}
const ta = document.createElement("textarea");
ta.value = text;
document.body.appendChild(ta);
ta.select();
try {
document.execCommand("copy");
toast("已复制到剪贴板");
} catch (err) {
toast("复制失败,请手动选择", "err");
}
ta.remove();
}
/* --------------------------------------------------- Markdown 极简渲染 */
function renderMarkdown(md) {
const lines = String(md).split("\n");
let out = "";
let inCode = false;
let inList = false;
let inTable = false;
const inline = (s) =>
esc(s)
.replace(/`([^`]+)`/g, "$1")
.replace(/\*\*([^*]+)\*\*/g, "$1")
.replace(/(^|[^*])\*([^*]+)\*/g, "$1$2")
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1');
const closeList = () => { if (inList) { out += ""; inList = false; } };
const closeTable = () => { if (inTable) { out += ""; inTable = false; } };
for (const raw of lines) {
const line = raw.replace(/\s+$/, "");
if (line.trim().startsWith("```")) {
closeList(); closeTable();
out += inCode ? "" : "
";
inCode = !inCode;
continue;
}
if (inCode) { out += esc(raw) + "\n"; continue; }
if (!line.trim()) { closeList(); closeTable(); continue; }
const h = line.match(/^(#{1,4})\s+(.*)$/);
if (h) {
closeList(); closeTable();
const lv = h[1].length;
out += `${inline(h[2])}`;
continue;
}
if (/^\s*\|/.test(line)) {
const cells = line.split("|").slice(1, -1).map((c) => c.trim());
if (/^[\s|:-]+$/.test(line)) continue;
if (!inTable) {
closeList();
out += "" + cells.map((c) => `| ${inline(c)} | `).join("") + "
";
inTable = true;
} else {
out += "" + cells.map((c) => `| ${inline(c)} | `).join("") + "
";
}
continue;
}
closeTable();
const li = line.match(/^\s*[-*]\s+(.*)$/);
if (li) {
if (!inList) { out += ""; inList = true; }
out += `- ${inline(li[1])}
`;
continue;
}
closeList();
const bq = line.match(/^>\s?(.*)$/);
if (bq) { out += `${inline(bq[1])}
`; continue; }
out += `${inline(line)}
`;
}
closeList(); closeTable();
if (inCode) out += "";
return out;
}
/* --------------------------------------------------- 激活导航项(CSS 兜底) */
document.addEventListener("DOMContentLoaded", () => {
const page = document.body.getAttribute("data-page");
if (!page) return;
const item = document.querySelector(`.app-nav a[data-nav="${page}"]`);
if (item) item.setAttribute("aria-current", "page");
});