Files
zTools2/static/file_browser.js
zikai 41580a9025 feat: 文件浏览页支持多选、批量下载/删除与分页
- 后端:
  - UploadService.delete_file 下沉删除逻辑(DB 行 + 磁盘文件),单删/批删复用。
  - POST /api/admin/files/batch-delete:批量硬删除,返回 deleted 数与 not_found 列表。
  - list 接口加 Query 约束(limit 1-10000、offset>=0),非法返回 422。
- 前端(file_browser):
  - 复选框列 + 全选/反选,选中行高亮,批量操作栏(已选计数/下载/删除/取消)。
  - 分页控件:每页 20/50/100/全部 切换,页码导航(首页/末页/当前±1/省略号)。
  - 批量下载逐个触发(间隔 150ms 防浏览器拦截);批量删除走 batch-delete 接口。
  - 删除后重载当前页,当前页空则回退一页。
2026-07-21 14:37:13 +00:00

253 lines
9.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 文件浏览页:分页拉取 /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 = '<div class="skel">加载中…</div>';
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 = '<div class="empty">加载失败:' + (e.message || e) + "</div>";
}
}
function render() {
if (!state.items.length) {
listEl.innerHTML = '<div class="empty">还没有文件。去 <a class="link" href="/upload">上传</a> 一个吧。</div>';
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);
})();