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 接口。
  - 删除后重载当前页,当前页空则回退一页。
This commit is contained in:
zikai
2026-07-21 14:37:13 +00:00
parent fffba79022
commit 41580a9025
5 changed files with 292 additions and 45 deletions

View File

@@ -8,11 +8,9 @@
from __future__ import annotations
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import FileResponse
from pydantic import BaseModel
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from ..database import get_db
@@ -32,15 +30,24 @@ class DeleteResult(BaseModel):
deleted: bool
class BatchDeleteRequest(BaseModel):
ids: list[int] = Field(..., description="要删除的文件 id 列表")
class BatchDeleteResult(BaseModel):
deleted: int = Field(..., description="实际删除的条数")
not_found: list[int] = Field(default_factory=list, description="未找到的 id")
@router.get(
"",
response_model=FileListResponse,
summary="列出已上传文件(需鉴权)",
description="供文件浏览页使用;字段同公开 /api/files但要求 docs Basic Auth。",
description="供文件浏览页使用;字段同公开 /api/files但要求 docs Basic Auth。支持分页。",
)
def list_files(
limit: int = 100,
offset: int = 0,
limit: int = Query(100, ge=1, le=10000, description="每页条数1-10000"),
offset: int = Query(0, ge=0, description="偏移量"),
service: UploadService = Depends(_service),
_: str = Depends(require_docs_auth),
) -> FileListResponse:
@@ -48,6 +55,30 @@ def list_files(
return FileListResponse(total=total, items=items)
@router.post(
"/batch-delete",
response_model=BatchDeleteResult,
summary="批量硬删除文件(需鉴权)",
description="一次删除多个文件;返回实际删除条数与未命中的 id 列表。",
)
def batch_delete_files(
body: BatchDeleteRequest,
service: UploadService = Depends(_service),
_: str = Depends(require_docs_auth),
) -> BatchDeleteResult:
if not body.ids:
raise HTTPException(400, "ids 不能为空")
deleted = 0
not_found: list[int] = []
for fid in body.ids:
ok = service.delete_file(fid)
if ok:
deleted += 1
else:
not_found.append(fid)
return BatchDeleteResult(deleted=deleted, not_found=not_found)
@router.get(
"/{file_id}",
response_model=UploadedFileOut,
@@ -97,15 +128,4 @@ def delete_file(
service: UploadService = Depends(_service),
_: str = Depends(require_docs_auth),
) -> DeleteResult:
out, path = service.get_out_with_disk_path(file_id)
if out is None:
return DeleteResult(deleted=False)
# 先删磁盘文件,再删 DB 行;磁盘文件缺失不阻断 DB 清理
if path is not None:
try:
Path(path).unlink(missing_ok=True)
except Exception:
# 即使磁盘删除失败也继续清 DB 行,保证列表不再显示
pass
service.dao.delete(file_id)
return DeleteResult(deleted=True)
return DeleteResult(deleted=service.delete_file(file_id))

View File

@@ -92,6 +92,24 @@ class UploadService:
path = (self.upload_root / row.storage_path).resolve()
return out, path
def delete_file(self, file_id: int) -> bool:
"""硬删除:删磁盘文件 + 删 DB 行。磁盘缺失不阻断 DB 清理。返回是否命中。
供单删/批删复用,保证删除语义一致。
"""
_, path = self.get_out_with_disk_path(file_id)
row = self.dao.get_by_id(file_id)
if row is None:
return False
if path is not None:
try:
Path(path).unlink(missing_ok=True)
except Exception:
# 即使磁盘删除失败也继续清 DB 行,保证列表不再显示
pass
self.dao.delete(file_id)
return True
# ---------------- 共享 helper供本类与 ChunkUploadService 复用) ----------------
@staticmethod

View File

@@ -1,5 +1,7 @@
/* 文件浏览页专属样式。表格、行内操作、sha 截断。 */
.files-table th.col-name { min-width: 30%; }
/* 文件浏览页专属样式。表格、复选框列、行内操作、sha 截断、批量栏、分页器。 */
.files-table th.col-sel { width: 2.4em; text-align: center; }
.files-table td.col-sel { text-align: center; }
.files-table th.col-name { min-width: 28%; }
.files-table th.col-size { width: 9em; }
.files-table th.col-src { width: 6em; }
.files-table th.col-time { width: 11em; }
@@ -8,11 +10,42 @@
.files-table td.col-name .fname { font-weight: 600; word-break: break-all; }
.files-table td.col-name .sha { display: block; margin-top: 0.15em; color: var(--text-dim); }
.files-table td.col-act .btn { padding: 0.3em 0.8em; font-size: 0.85em; margin-left: 0.3em; }
.files-table tbody tr.sel { background: var(--primary-soft); }
.files-table tbody tr.sel:hover td { background: var(--primary-soft); }
.files-table input[type="checkbox"] { width: 16px; height: 16px; cursor: pointer; accent-color: var(--primary); }
.sha-short { cursor: pointer; }
.sha-short:hover { color: var(--primary); }
.row-removed { opacity: 0; transition: opacity 0.25s; }
.toolbar-spacer { flex: 1; }
.page-size { display: inline-flex; align-items: center; gap: 0.4em; font-size: 0.88em; color: var(--text-dim); }
.page-size select {
padding: 0.25em 0.5em; border: 1px solid var(--border); border-radius: 6px;
background: var(--surface); color: var(--text); font-size: 0.88em; cursor: pointer;
}
.batchbar {
display: flex; align-items: center; gap: 0.6em; flex-wrap: wrap;
padding: 0.6em 0.9em; margin: 0 0 0.8em;
background: var(--primary-soft); border: 1px solid var(--primary);
border-radius: var(--radius);
}
.batchbar.hidden { display: none; }
.batchbar-info { font-size: 0.9em; color: var(--text); }
.batchbar-info b { color: var(--primary); }
.batchbar .btn { padding: 0.35em 0.9em; font-size: 0.85em; }
.pager { display: flex; align-items: center; gap: 0.4em; justify-content: center; margin: 1.5em 0 0; flex-wrap: wrap; }
.pager.hidden { display: none; }
.pager .btn { padding: 0.3em 0.8em; font-size: 0.85em; min-width: 2.4em; }
.pager .btn.is-current { background: var(--primary); border-color: var(--primary); color: #fff; }
.pager .btn[disabled] { opacity: 0.4; cursor: not-allowed; }
.pager .pager-ellipsis { padding: 0 0.3em; color: var(--text-dim); }
.pager .pager-info { font-size: 0.82em; color: var(--text-dim); margin-left: 0.6em; }
@media (max-width: 640px) {
.files-table th, .files-table td { padding: 0.5em 0.4em; }
.files-table th.col-src, .files-table td.col-src { display: none; }
.files-table th.col-time, .files-table td.col-time { font-size: 0.78em; }
.batchbar { font-size: 0.85em; }
}

View File

@@ -10,17 +10,35 @@
<body>
<div class="wrap">
<h1>文件浏览</h1>
<p class="sub">查看已上传的文件、下载或删除。删除后不再显示。</p>
<p class="sub">查看已上传的文件、下载或删除。支持多选与分页。删除后不再显示。</p>
<div class="toolbar">
<button class="btn primary" id="refresh">刷新</button>
<span class="muted" id="count"></span>
<span class="toolbar-spacer"></span>
<label class="page-size">每页
<select id="pageSize">
<option value="20">20</option>
<option value="50">50</option>
<option value="100" selected>100</option>
<option value="all">全部</option>
</select>
</label>
</div>
<div id="batchbar" class="batchbar hidden">
<span class="batchbar-info">已选 <b id="selCount">0</b></span>
<button class="btn primary" id="batchDownload">批量下载</button>
<button class="btn danger" id="batchDelete">批量删除</button>
<button class="btn ghost" id="clearSel">取消选择</button>
</div>
<div id="list">
<div class="skel">加载中…</div>
</div>
<div id="pager" class="pager hidden"></div>
<p class="foot"><a class="link" href="/upload">上传文件</a> · zikai file service</p>
</div>
<script src="/static/common.js"></script>

View File

@@ -1,36 +1,89 @@
/* 文件浏览页:拉取 /api/admin/files、渲染表格、下载/删除/复制 sha。
每次进入或删除后重新拉取,前端不缓存列表,保证已删除文件不显示。 */
/* 文件浏览页:分页拉取 /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");
refreshBtn.addEventListener("click", load);
// 分页状态
const state = {
page: 1, // 从 1 开始
pageSize: 100, // 数字或 "all"
total: 0,
items: [],
selected: new Set(), // 选中的 file id
};
async function load() {
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=500&offset=0");
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();
render(body.items || []);
countEl.textContent = `${body.total} 个文件`;
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(items) {
if (!items.length) {
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" }, "来源"),
@@ -39,19 +92,22 @@
)
);
const tbody = el("tbody", null);
for (const f of items) {
const row = el("tr", 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("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: () => remove(f, row) }, "删除")
el("button", { class: "btn danger", onclick: () => removeOne(f) }, "删除")
)
);
const shaNode = row.querySelector(".sha-short");
@@ -59,12 +115,74 @@
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) {
@@ -72,23 +190,63 @@
return sha.length > 16 ? sha.slice(0, 12) + "…" + sha.slice(-4) : sha;
}
async function remove(f, row) {
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);
const body = await res.json();
if (!body.deleted) { toast("文件已不存在"); }
row.classList.add("row-removed");
setTimeout(() => row.remove(), 250);
state.selected.delete(f.id);
// 删除后重新加载当前页(后续行会自动上移补位)
await load(state.page);
toast("已删除");
// 更新计数
const m = (countEl.textContent || "").match(/(\d+)/);
if (m) countEl.textContent = `${Math.max(0, Number(m[1]) - 1)} 个文件`;
} catch (e) {
toast("删除失败:" + (e.message || e), "err");
}
}
load();
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);
})();