diff --git a/app/controllers/file_admin_controller.py b/app/controllers/file_admin_controller.py index 113c32c..cb0bae5 100644 --- a/app/controllers/file_admin_controller.py +++ b/app/controllers/file_admin_controller.py @@ -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)) diff --git a/app/services/upload_service.py b/app/services/upload_service.py index 05ffaf2..0fb067c 100644 --- a/app/services/upload_service.py +++ b/app/services/upload_service.py @@ -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 diff --git a/static/file_browser.css b/static/file_browser.css index 0323823..8565aba 100644 --- a/static/file_browser.css +++ b/static/file_browser.css @@ -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; } } diff --git a/static/file_browser.html b/static/file_browser.html index 1f19d0b..9757dc4 100644 --- a/static/file_browser.html +++ b/static/file_browser.html @@ -10,17 +10,35 @@

文件浏览

-

查看已上传的文件、下载或删除。删除后不再显示。

+

查看已上传的文件、下载或删除。支持多选与分页。删除后不再显示。

+ + +
+ +
加载中…
+ +

上传文件 · zikai file service

diff --git a/static/file_browser.js b/static/file_browser.js index 6916ec9..0ae065d 100644 --- a/static/file_browser.js +++ b/static/file_browser.js @@ -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 = '
加载中…
'; 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 = '
加载失败:' + (e.message || e) + "
"; } } - function render(items) { - if (!items.length) { + 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" }, "来源"), @@ -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); })();