/* 文件浏览页:分页拉取 /api/admin/files、复选框多选 + 全选、批量下载/删除、复制 sha。 state.items 缓存当前页数据;切换页/页大小重新拉取;删除后若当前页空则回退一页。 */ (function () { "use strict"; const { el, toast, copyText, fmtBytes, fmtTime, api } = window.ZK; const listEl = document.getElementById("list"); const countEl = document.getElementById("count"); const refreshBtn = document.getElementById("refresh"); const pageSizeSel = document.getElementById("pageSize"); const batchbar = document.getElementById("batchbar"); const selCountEl = document.getElementById("selCount"); const batchDownloadBtn = document.getElementById("batchDownload"); const batchDeleteBtn = document.getElementById("batchDelete"); const clearSelBtn = document.getElementById("clearSel"); const pagerEl = document.getElementById("pager"); // 分页状态 const state = { page: 1, // 从 1 开始 pageSize: 100, // 数字或 "all" total: 0, items: [], selected: new Set(), // 选中的 file id }; refreshBtn.addEventListener("click", () => load(state.page)); pageSizeSel.addEventListener("change", () => { const v = pageSizeSel.value; state.pageSize = v === "all" ? "all" : Number(v); state.page = 1; load(1); }); batchDownloadBtn.addEventListener("click", batchDownload); batchDeleteBtn.addEventListener("click", batchDelete); clearSelBtn.addEventListener("click", () => { state.selected.clear(); renderSelection(); }); function effectiveLimit() { return state.pageSize === "all" ? 10000 : state.pageSize; } function totalPages() { if (state.pageSize === "all") return 1; return Math.max(1, Math.ceil(state.total / state.pageSize)); } async function load(page) { state.page = Math.max(1, page); const limit = effectiveLimit(); const offset = (state.page - 1) * (state.pageSize === "all" ? 0 : state.pageSize); listEl.innerHTML = '
加载中…
'; countEl.textContent = ""; pagerEl.classList.add("hidden"); try { const res = await api(`/api/admin/files?limit=${limit}&offset=${offset}`); if (!res.ok) throw new Error("HTTP " + res.status); const body = await res.json(); state.items = body.items || []; state.total = body.total || 0; // 删除后当前页可能空且不是首页:回退一页 if (state.items.length === 0 && state.page > 1) { load(state.page - 1); return; } countEl.textContent = `共 ${state.total} 个文件`; render(); renderPager(); } catch (e) { listEl.innerHTML = '
加载失败:' + (e.message || e) + "
"; } } function render() { if (!state.items.length) { listEl.innerHTML = '
还没有文件。去 上传 一个吧。
'; renderSelection(); return; } const allChecked = state.items.length > 0 && state.items.every((f) => state.selected.has(f.id)); const table = el("table", { class: "files-table" }); const thead = el("thead", null, el("tr", null, el("th", { class: "col-sel" }, el("input", { type: "checkbox", id: "selectAll", checked: allChecked }) ), el("th", { class: "col-name" }, "文件名 / SHA-256"), el("th", { class: "col-size" }, "大小"), el("th", { class: "col-src" }, "来源"), el("th", { class: "col-time" }, "上传时间"), el("th", { class: "col-act" }, "操作") ) ); const tbody = el("tbody", null); for (const f of state.items) { const checked = state.selected.has(f.id); const row = el("tr", { class: checked ? "sel" : "", dataset: { id: f.id } }, el("td", { class: "col-sel" }, el("input", { type: "checkbox", class: "row-sel", checked, dataset: { id: f.id } }) ), el("td", { class: "col-name" }, el("div", { class: "fname" }, f.original_filename), el("span", { class: "sha mono sha-short", title: "点击复制完整 SHA-256" }, shortSha(f.sha256)) ), el("td", { class: "col-size mono" }, fmtBytes(f.size_bytes)), el("td", { class: "col-src" }, el("span", { class: "tag" }, f.source || "-")), el("td", { class: "col-time muted" }, fmtTime(f.uploaded_at)), el("td", { class: "col-act" }, el("a", { class: "btn primary", href: `/api/admin/files/${f.id}/download`, download: "" }, "下载"), el("button", { class: "btn danger", onclick: () => removeOne(f) }, "删除") ) ); const shaNode = row.querySelector(".sha-short"); shaNode.addEventListener("click", async () => { const ok = await copyText(f.sha256); toast(ok ? "已复制 SHA-256" : "复制失败"); }); const cb = row.querySelector(".row-sel"); cb.addEventListener("change", () => toggleSelect(f.id, cb.checked)); tbody.appendChild(row); } table.appendChild(thead); table.appendChild(tbody); listEl.innerHTML = ""; listEl.appendChild(table); document.getElementById("selectAll").addEventListener("change", (e) => { const on = e.target.checked; for (const f of state.items) { if (on) state.selected.add(f.id); else state.selected.delete(f.id); } render(); renderSelection(); }); renderSelection(); } function toggleSelect(id, on) { if (on) state.selected.add(id); else state.selected.delete(id); render(); renderSelection(); } function renderSelection() { const n = state.selected.size; selCountEl.textContent = n; batchbar.classList.toggle("hidden", n === 0); // 更新全选框状态(不重新渲染表格,只改 checkbox) const sa = document.getElementById("selectAll"); if (sa) { sa.checked = state.items.length > 0 && state.items.every((f) => state.selected.has(f.id)); } } function renderPager() { if (state.pageSize === "all" || totalPages() <= 1) { pagerEl.classList.add("hidden"); return; } pagerEl.classList.remove("hidden"); pagerEl.innerHTML = ""; const tp = totalPages(); const cur = state.page; const mk = (label, page, opts = {}) => { const b = el("button", { class: "btn" + (page === cur && !opts.ellipsis ? " is-current" : ""), onclick: () => load(page), }, String(label)); if (opts.disabled) b.disabled = true; return b; }; pagerEl.appendChild(mk("‹", cur - 1, { disabled: cur <= 1 })); // 页码:始终显示首页、末页、当前页前后各 1 页,其余用省略号 const pages = new Set([1, tp, cur, cur - 1, cur + 1]); const sorted = [...pages].filter((p) => p >= 1 && p <= tp).sort((a, b) => a - b); let last = 0; for (const p of sorted) { if (p - last > 1) pagerEl.appendChild(el("span", { class: "pager-ellipsis" }, "…")); pagerEl.appendChild(mk(p, p)); last = p; } pagerEl.appendChild(mk("›", cur + 1, { disabled: cur >= tp })); pagerEl.appendChild(el("span", { class: "pager-info" }, `第 ${cur}/${tp} 页`)); } function shortSha(sha) { if (!sha) return "-"; return sha.length > 16 ? sha.slice(0, 12) + "…" + sha.slice(-4) : sha; } async function removeOne(f) { if (!confirm(`确定删除「${f.original_filename}」?\n此操作不可恢复,将同时删除磁盘文件。`)) return; try { const res = await api(`/api/admin/files/${f.id}`, { method: "DELETE" }); if (!res.ok) throw new Error("HTTP " + res.status); state.selected.delete(f.id); // 删除后重新加载当前页(后续行会自动上移补位) await load(state.page); toast("已删除"); } catch (e) { toast("删除失败:" + (e.message || e), "err"); } } async function batchDelete() { const ids = [...state.selected]; if (!ids.length) return; if (!confirm(`确定删除选中的 ${ids.length} 个文件?\n此操作不可恢复,将同时删除磁盘文件。`)) return; batchDeleteBtn.disabled = true; try { const res = await api("/api/admin/files/batch-delete", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ids }), }); if (!res.ok) throw new Error("HTTP " + res.status); const body = await res.json(); state.selected.clear(); await load(state.page); const miss = body.not_found && body.not_found.length; toast(`已删除 ${body.deleted} 个` + (miss ? `,${miss} 个未找到` : "")); } catch (e) { toast("批量删除失败:" + (e.message || e), "err"); } finally { batchDeleteBtn.disabled = false; } } async function batchDownload() { const ids = [...state.selected]; if (!ids.length) return; if (ids.length > 20) { if (!confirm(`将下载 ${ids.length} 个文件,浏览器可能弹出多个下载提示。继续?`)) return; } // 逐个触发下载:同源 Basic Auth 凭据由浏览器自动带上 for (const id of ids) { const a = document.createElement("a"); a.href = `/api/admin/files/${id}/download`; a.download = ""; document.body.appendChild(a); a.click(); a.remove(); // 间隔 150ms,避免浏览器拦截并发下载 await new Promise((r) => setTimeout(r, 150)); } toast(`已触发 ${ids.length} 个下载`); } load(1); })();