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))