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:
@@ -8,11 +8,9 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
@@ -32,15 +30,24 @@ class DeleteResult(BaseModel):
|
|||||||
deleted: bool
|
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(
|
@router.get(
|
||||||
"",
|
"",
|
||||||
response_model=FileListResponse,
|
response_model=FileListResponse,
|
||||||
summary="列出已上传文件(需鉴权)",
|
summary="列出已上传文件(需鉴权)",
|
||||||
description="供文件浏览页使用;字段同公开 /api/files,但要求 docs Basic Auth。",
|
description="供文件浏览页使用;字段同公开 /api/files,但要求 docs Basic Auth。支持分页。",
|
||||||
)
|
)
|
||||||
def list_files(
|
def list_files(
|
||||||
limit: int = 100,
|
limit: int = Query(100, ge=1, le=10000, description="每页条数,1-10000"),
|
||||||
offset: int = 0,
|
offset: int = Query(0, ge=0, description="偏移量"),
|
||||||
service: UploadService = Depends(_service),
|
service: UploadService = Depends(_service),
|
||||||
_: str = Depends(require_docs_auth),
|
_: str = Depends(require_docs_auth),
|
||||||
) -> FileListResponse:
|
) -> FileListResponse:
|
||||||
@@ -48,6 +55,30 @@ def list_files(
|
|||||||
return FileListResponse(total=total, items=items)
|
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(
|
@router.get(
|
||||||
"/{file_id}",
|
"/{file_id}",
|
||||||
response_model=UploadedFileOut,
|
response_model=UploadedFileOut,
|
||||||
@@ -97,15 +128,4 @@ def delete_file(
|
|||||||
service: UploadService = Depends(_service),
|
service: UploadService = Depends(_service),
|
||||||
_: str = Depends(require_docs_auth),
|
_: str = Depends(require_docs_auth),
|
||||||
) -> DeleteResult:
|
) -> DeleteResult:
|
||||||
out, path = service.get_out_with_disk_path(file_id)
|
return DeleteResult(deleted=service.delete_file(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)
|
|
||||||
|
|||||||
@@ -92,6 +92,24 @@ class UploadService:
|
|||||||
path = (self.upload_root / row.storage_path).resolve()
|
path = (self.upload_root / row.storage_path).resolve()
|
||||||
return out, path
|
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 复用) ----------------
|
# ---------------- 共享 helper(供本类与 ChunkUploadService 复用) ----------------
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
/* 文件浏览页专属样式。表格、行内操作、sha 截断。 */
|
/* 文件浏览页专属样式。表格、复选框列、行内操作、sha 截断、批量栏、分页器。 */
|
||||||
.files-table th.col-name { min-width: 30%; }
|
.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-size { width: 9em; }
|
||||||
.files-table th.col-src { width: 6em; }
|
.files-table th.col-src { width: 6em; }
|
||||||
.files-table th.col-time { width: 11em; }
|
.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 .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-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 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 { cursor: pointer; }
|
||||||
.sha-short:hover { color: var(--primary); }
|
.sha-short:hover { color: var(--primary); }
|
||||||
.row-removed { opacity: 0; transition: opacity 0.25s; }
|
.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) {
|
@media (max-width: 640px) {
|
||||||
.files-table th, .files-table td { padding: 0.5em 0.4em; }
|
.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-src, .files-table td.col-src { display: none; }
|
||||||
.files-table th.col-time, .files-table td.col-time { font-size: 0.78em; }
|
.files-table th.col-time, .files-table td.col-time { font-size: 0.78em; }
|
||||||
|
.batchbar { font-size: 0.85em; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,17 +10,35 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<h1>文件浏览</h1>
|
<h1>文件浏览</h1>
|
||||||
<p class="sub">查看已上传的文件、下载或删除。删除后不再显示。</p>
|
<p class="sub">查看已上传的文件、下载或删除。支持多选与分页。删除后不再显示。</p>
|
||||||
|
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<button class="btn primary" id="refresh">刷新</button>
|
<button class="btn primary" id="refresh">刷新</button>
|
||||||
<span class="muted" id="count"></span>
|
<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>
|
||||||
|
|
||||||
<div id="list">
|
<div id="list">
|
||||||
<div class="skel">加载中…</div>
|
<div class="skel">加载中…</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="pager" class="pager hidden"></div>
|
||||||
|
|
||||||
<p class="foot"><a class="link" href="/upload">上传文件</a> · zikai file service</p>
|
<p class="foot"><a class="link" href="/upload">上传文件</a> · zikai file service</p>
|
||||||
</div>
|
</div>
|
||||||
<script src="/static/common.js"></script>
|
<script src="/static/common.js"></script>
|
||||||
|
|||||||
@@ -1,36 +1,89 @@
|
|||||||
/* 文件浏览页:拉取 /api/admin/files、渲染表格、下载/删除/复制 sha。
|
/* 文件浏览页:分页拉取 /api/admin/files、复选框多选 + 全选、批量下载/删除、复制 sha。
|
||||||
每次进入或删除后重新拉取,前端不缓存列表,保证已删除文件不显示。 */
|
state.items 缓存当前页数据;切换页/页大小重新拉取;删除后若当前页空则回退一页。 */
|
||||||
(function () {
|
(function () {
|
||||||
"use strict";
|
"use strict";
|
||||||
const { el, toast, copyText, fmtBytes, fmtTime, api } = window.ZK;
|
const { el, toast, copyText, fmtBytes, fmtTime, api } = window.ZK;
|
||||||
const listEl = document.getElementById("list");
|
const listEl = document.getElementById("list");
|
||||||
const countEl = document.getElementById("count");
|
const countEl = document.getElementById("count");
|
||||||
const refreshBtn = document.getElementById("refresh");
|
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>';
|
listEl.innerHTML = '<div class="skel">加载中…</div>';
|
||||||
countEl.textContent = "";
|
countEl.textContent = "";
|
||||||
|
pagerEl.classList.add("hidden");
|
||||||
try {
|
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);
|
if (!res.ok) throw new Error("HTTP " + res.status);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
render(body.items || []);
|
state.items = body.items || [];
|
||||||
countEl.textContent = `共 ${body.total} 个文件`;
|
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) {
|
} catch (e) {
|
||||||
listEl.innerHTML = '<div class="empty">加载失败:' + (e.message || e) + "</div>";
|
listEl.innerHTML = '<div class="empty">加载失败:' + (e.message || e) + "</div>";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function render(items) {
|
function render() {
|
||||||
if (!items.length) {
|
if (!state.items.length) {
|
||||||
listEl.innerHTML = '<div class="empty">还没有文件。去 <a class="link" href="/upload">上传</a> 一个吧。</div>';
|
listEl.innerHTML = '<div class="empty">还没有文件。去 <a class="link" href="/upload">上传</a> 一个吧。</div>';
|
||||||
|
renderSelection();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const allChecked = state.items.length > 0 && state.items.every((f) => state.selected.has(f.id));
|
||||||
const table = el("table", { class: "files-table" });
|
const table = el("table", { class: "files-table" });
|
||||||
const thead = el("thead", null,
|
const thead = el("thead", null,
|
||||||
el("tr", 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-name" }, "文件名 / SHA-256"),
|
||||||
el("th", { class: "col-size" }, "大小"),
|
el("th", { class: "col-size" }, "大小"),
|
||||||
el("th", { class: "col-src" }, "来源"),
|
el("th", { class: "col-src" }, "来源"),
|
||||||
@@ -39,19 +92,22 @@
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
const tbody = el("tbody", null);
|
const tbody = el("tbody", null);
|
||||||
for (const f of items) {
|
for (const f of state.items) {
|
||||||
const row = el("tr", null,
|
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("td", { class: "col-name" },
|
||||||
el("div", { class: "fname" }, f.original_filename),
|
el("div", { class: "fname" }, f.original_filename),
|
||||||
el("span", { class: "sha mono sha-short", title: "点击复制完整 SHA-256" },
|
el("span", { class: "sha mono sha-short", title: "点击复制完整 SHA-256" }, shortSha(f.sha256))
|
||||||
shortSha(f.sha256))
|
|
||||||
),
|
),
|
||||||
el("td", { class: "col-size mono" }, fmtBytes(f.size_bytes)),
|
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-src" }, el("span", { class: "tag" }, f.source || "-")),
|
||||||
el("td", { class: "col-time muted" }, fmtTime(f.uploaded_at)),
|
el("td", { class: "col-time muted" }, fmtTime(f.uploaded_at)),
|
||||||
el("td", { class: "col-act" },
|
el("td", { class: "col-act" },
|
||||||
el("a", { class: "btn primary", href: `/api/admin/files/${f.id}/download`, download: "" }, "下载"),
|
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");
|
const shaNode = row.querySelector(".sha-short");
|
||||||
@@ -59,12 +115,74 @@
|
|||||||
const ok = await copyText(f.sha256);
|
const ok = await copyText(f.sha256);
|
||||||
toast(ok ? "已复制 SHA-256" : "复制失败");
|
toast(ok ? "已复制 SHA-256" : "复制失败");
|
||||||
});
|
});
|
||||||
|
const cb = row.querySelector(".row-sel");
|
||||||
|
cb.addEventListener("change", () => toggleSelect(f.id, cb.checked));
|
||||||
tbody.appendChild(row);
|
tbody.appendChild(row);
|
||||||
}
|
}
|
||||||
table.appendChild(thead);
|
table.appendChild(thead);
|
||||||
table.appendChild(tbody);
|
table.appendChild(tbody);
|
||||||
listEl.innerHTML = "";
|
listEl.innerHTML = "";
|
||||||
listEl.appendChild(table);
|
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) {
|
function shortSha(sha) {
|
||||||
@@ -72,23 +190,63 @@
|
|||||||
return sha.length > 16 ? sha.slice(0, 12) + "…" + sha.slice(-4) : sha;
|
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;
|
if (!confirm(`确定删除「${f.original_filename}」?\n此操作不可恢复,将同时删除磁盘文件。`)) return;
|
||||||
try {
|
try {
|
||||||
const res = await api(`/api/admin/files/${f.id}`, { method: "DELETE" });
|
const res = await api(`/api/admin/files/${f.id}`, { method: "DELETE" });
|
||||||
if (!res.ok) throw new Error("HTTP " + res.status);
|
if (!res.ok) throw new Error("HTTP " + res.status);
|
||||||
const body = await res.json();
|
state.selected.delete(f.id);
|
||||||
if (!body.deleted) { toast("文件已不存在"); }
|
// 删除后重新加载当前页(后续行会自动上移补位)
|
||||||
row.classList.add("row-removed");
|
await load(state.page);
|
||||||
setTimeout(() => row.remove(), 250);
|
|
||||||
toast("已删除");
|
toast("已删除");
|
||||||
// 更新计数
|
|
||||||
const m = (countEl.textContent || "").match(/(\d+)/);
|
|
||||||
if (m) countEl.textContent = `共 ${Math.max(0, Number(m[1]) - 1)} 个文件`;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast("删除失败:" + (e.message || e), "err");
|
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);
|
||||||
})();
|
})();
|
||||||
|
|||||||
Reference in New Issue
Block a user