mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-09-04 23:29:00 +08:00
feat: paginate public share directories (#132)
* feat: paginate public share directories * feat: add cursor pagination to public share listings Unify share and VFS directory payloads, pass cursor through the share API, and add prev/next controls plus tests for cursor-mode adapters. --------- Co-authored-by: shiyu <im@shiyu.dev>
This commit is contained in:
@@ -30,5 +30,56 @@ def cursor_page(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def listing_pagination(
|
||||||
|
result: Any,
|
||||||
|
*,
|
||||||
|
page_num: int = 1,
|
||||||
|
page_size: int = 50,
|
||||||
|
cursor: str | None = None,
|
||||||
|
) -> tuple[list[Any], dict[str, Any]]:
|
||||||
|
"""把 list_virtual_dir / page / cursor_page 结果规范成对外 pagination。"""
|
||||||
|
data = result if isinstance(result, dict) else {}
|
||||||
|
items = data.get("items") or []
|
||||||
|
mode = data.get("pagination_mode") or "paged"
|
||||||
|
pagination: dict[str, Any] = {
|
||||||
|
"mode": mode,
|
||||||
|
"page_size": data.get("page_size", page_size),
|
||||||
|
}
|
||||||
|
if mode == "cursor":
|
||||||
|
pagination.update(
|
||||||
|
{
|
||||||
|
"cursor": data.get("cursor", cursor),
|
||||||
|
"next_cursor": data.get("next_cursor"),
|
||||||
|
"has_next": bool(data.get("has_next")),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
total = data.get("total")
|
||||||
|
pages = data.get("pages")
|
||||||
|
pagination.update(
|
||||||
|
{
|
||||||
|
"total": 0 if total is None else total,
|
||||||
|
"page": data.get("page", page_num),
|
||||||
|
"pages": 0 if pages is None else pages,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return items, pagination
|
||||||
|
|
||||||
|
|
||||||
|
def dir_listing(
|
||||||
|
result: Any,
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
page_num: int = 1,
|
||||||
|
page_size: int = 50,
|
||||||
|
cursor: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""对外目录列表载荷:path / entries / pagination。"""
|
||||||
|
items, pagination = listing_pagination(
|
||||||
|
result, page_num=page_num, page_size=page_size, cursor=cursor
|
||||||
|
)
|
||||||
|
return {"path": path, "entries": items, "pagination": pagination}
|
||||||
|
|
||||||
|
|
||||||
def error(msg: str, code: int = 1, data: Optional[Any] = None):
|
def error(msg: str, code: int = 1, data: Optional[Any] = None):
|
||||||
return {"code": code, "msg": msg, "data": data}
|
return {"code": code, "msg": msg, "data": data}
|
||||||
|
|||||||
+16
-9
@@ -1,6 +1,6 @@
|
|||||||
from typing import Annotated, List, Optional
|
from typing import Annotated, List, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Request
|
from fastapi import APIRouter, Depends, Query, Request
|
||||||
|
|
||||||
from api.response import success
|
from api.response import success
|
||||||
from domain.audit import AuditAction, audit
|
from domain.audit import AuditAction, audit
|
||||||
@@ -95,20 +95,27 @@ async def verify_password(request: Request, token: str, payload: SharePassword):
|
|||||||
@public_router.get("/{token}/ls")
|
@public_router.get("/{token}/ls")
|
||||||
@audit(action=AuditAction.SHARE, description="浏览分享内容")
|
@audit(action=AuditAction.SHARE, description="浏览分享内容")
|
||||||
async def list_share_content(
|
async def list_share_content(
|
||||||
request: Request, token: str, path: str = "/", password: Optional[str] = None
|
request: Request,
|
||||||
|
token: str,
|
||||||
|
path: str = "/",
|
||||||
|
password: Optional[str] = None,
|
||||||
|
page_num: int = Query(1, alias="page", ge=1, description="页码"),
|
||||||
|
page_size: int = Query(50, ge=1, le=500, description="每页条数"),
|
||||||
|
cursor: str | None = Query(None, description="游标分页位置"),
|
||||||
):
|
):
|
||||||
share = await ShareService.ensure_share_access(token, password)
|
share = await ShareService.ensure_share_access(token, password)
|
||||||
content = await ShareService.get_shared_item_details(share, path)
|
content = await ShareService.get_shared_item_details(
|
||||||
|
share=share,
|
||||||
|
sub_path=path,
|
||||||
|
page_num=page_num,
|
||||||
|
page_size=page_size,
|
||||||
|
cursor=cursor,
|
||||||
|
)
|
||||||
return success(
|
return success(
|
||||||
{
|
{
|
||||||
"path": path,
|
"path": path,
|
||||||
"entries": content.get("items", []),
|
"entries": content.get("items", []),
|
||||||
"pagination": {
|
"pagination": content.get("pagination"),
|
||||||
"total": content.get("total", 0),
|
|
||||||
"page": content.get("page", 1),
|
|
||||||
"page_size": content.get("page_size", 1),
|
|
||||||
"pages": content.get("pages", 1),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+58
-8
@@ -7,6 +7,7 @@ import bcrypt
|
|||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
|
|
||||||
|
from api.response import listing_pagination
|
||||||
from domain.virtual_fs import VirtualFSService
|
from domain.virtual_fs import VirtualFSService
|
||||||
from models.database import ShareLink, UserAccount
|
from models.database import ShareLink, UserAccount
|
||||||
|
|
||||||
@@ -114,7 +115,53 @@ class ShareService:
|
|||||||
return deleted_count
|
return deleted_count
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_shared_item_details(cls, share: ShareLink, sub_path: str = ""):
|
async def _list_shared_virtual_dir(
|
||||||
|
cls,
|
||||||
|
vfs_path: str,
|
||||||
|
page_num: int,
|
||||||
|
page_size: int,
|
||||||
|
cursor: str | None,
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
result = await VirtualFSService.list_virtual_dir(
|
||||||
|
vfs_path,
|
||||||
|
page_num,
|
||||||
|
page_size,
|
||||||
|
cursor=cursor,
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise HTTPException(status_code=404, detail="目录未找到")
|
||||||
|
except HTTPException as e:
|
||||||
|
if e.status_code == 404:
|
||||||
|
raise HTTPException(status_code=404, detail="目录未找到") from e
|
||||||
|
raise
|
||||||
|
items, pagination = listing_pagination(
|
||||||
|
result, page_num=page_num, page_size=page_size, cursor=cursor
|
||||||
|
)
|
||||||
|
return {"items": items, "pagination": pagination}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _single_file_listing(cls, stat: dict, page_num: int, page_size: int):
|
||||||
|
return {
|
||||||
|
"items": [stat] if page_num == 1 else [],
|
||||||
|
"pagination": {
|
||||||
|
"mode": "paged",
|
||||||
|
"page_size": page_size,
|
||||||
|
"total": 1,
|
||||||
|
"page": page_num,
|
||||||
|
"pages": 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_shared_item_details(
|
||||||
|
cls,
|
||||||
|
share: ShareLink,
|
||||||
|
sub_path: str = "",
|
||||||
|
page_num: int = 1,
|
||||||
|
page_size: int = 50,
|
||||||
|
cursor: str | None = None,
|
||||||
|
):
|
||||||
if not share.paths:
|
if not share.paths:
|
||||||
raise HTTPException(status_code=404, detail="分享内容为空")
|
raise HTTPException(status_code=404, detail="分享内容为空")
|
||||||
|
|
||||||
@@ -124,21 +171,24 @@ class ShareService:
|
|||||||
full_path = f"{base_shared_path.rstrip('/')}/{sub_path.lstrip('/')}".rstrip("/")
|
full_path = f"{base_shared_path.rstrip('/')}/{sub_path.lstrip('/')}".rstrip("/")
|
||||||
if not full_path.startswith(base_shared_path):
|
if not full_path.startswith(base_shared_path):
|
||||||
raise HTTPException(status_code=403, detail="无权访问此路径")
|
raise HTTPException(status_code=403, detail="无权访问此路径")
|
||||||
try:
|
return await cls._list_shared_virtual_dir(
|
||||||
return await VirtualFSService.list_virtual_dir(full_path)
|
full_path, page_num, page_size, cursor
|
||||||
except FileNotFoundError:
|
)
|
||||||
raise HTTPException(status_code=404, detail="目录未找到")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
stat = await VirtualFSService.stat_file(base_shared_path)
|
stat = await VirtualFSService.stat_file(base_shared_path)
|
||||||
if stat.get("is_dir"):
|
if stat.get("is_dir"):
|
||||||
return await VirtualFSService.list_virtual_dir(base_shared_path)
|
return await cls._list_shared_virtual_dir(
|
||||||
|
base_shared_path, page_num, page_size, cursor
|
||||||
|
)
|
||||||
|
|
||||||
stat["name"] = base_shared_path.split("/")[-1]
|
stat["name"] = base_shared_path.split("/")[-1]
|
||||||
return {"items": [stat], "total": 1, "page": 1, "page_size": 1, "pages": 1}
|
return cls._single_file_listing(stat, page_num, page_size)
|
||||||
except HTTPException as e:
|
except HTTPException as e:
|
||||||
if "Path is a directory" in str(e.detail) or "Not a file" in str(e.detail):
|
if "Path is a directory" in str(e.detail) or "Not a file" in str(e.detail):
|
||||||
return await VirtualFSService.list_virtual_dir(base_shared_path)
|
return await cls._list_shared_virtual_dir(
|
||||||
|
base_shared_path, page_num, page_size, cursor
|
||||||
|
)
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from urllib.parse import quote
|
|||||||
from fastapi import HTTPException, Request, UploadFile
|
from fastapi import HTTPException, Request, UploadFile
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
|
|
||||||
|
from api.response import dir_listing
|
||||||
from domain.config import ConfigService
|
from domain.config import ConfigService
|
||||||
from domain.tasks import TaskService
|
from domain.tasks import TaskService
|
||||||
from .thumbnail import (
|
from .thumbnail import (
|
||||||
@@ -293,31 +294,12 @@ class VirtualFSRouteMixin(VirtualFSTempLinkMixin):
|
|||||||
async def list_directory(cls, full_path: str, page_num: int, page_size: int, sort_by: str, sort_order: str):
|
async def list_directory(cls, full_path: str, page_num: int, page_size: int, sort_by: str, sort_order: str):
|
||||||
full_path = cls._normalize_path(full_path)
|
full_path = cls._normalize_path(full_path)
|
||||||
result = await cls.list_virtual_dir(full_path, page_num, page_size, sort_by, sort_order)
|
result = await cls.list_virtual_dir(full_path, page_num, page_size, sort_by, sort_order)
|
||||||
pagination = {
|
return dir_listing(
|
||||||
"mode": result.get("pagination_mode", "paged"),
|
result,
|
||||||
"page_size": result.get("page_size", page_size),
|
path=full_path,
|
||||||
}
|
page_num=page_num,
|
||||||
if pagination["mode"] == "cursor":
|
page_size=page_size,
|
||||||
pagination.update(
|
)
|
||||||
{
|
|
||||||
"cursor": result.get("cursor"),
|
|
||||||
"next_cursor": result.get("next_cursor"),
|
|
||||||
"has_next": bool(result.get("has_next")),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
pagination.update(
|
|
||||||
{
|
|
||||||
"total": result["total"],
|
|
||||||
"page": result["page"],
|
|
||||||
"pages": result["pages"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"path": full_path,
|
|
||||||
"entries": result["items"],
|
|
||||||
"pagination": pagination,
|
|
||||||
}
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def delete(cls, full_path: str):
|
async def delete(cls, full_path: str):
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from api.response import dir_listing
|
||||||
|
|
||||||
from .common import VirtualFSCommonMixin
|
from .common import VirtualFSCommonMixin
|
||||||
from .resolver import VirtualFSResolverMixin
|
from .resolver import VirtualFSResolverMixin
|
||||||
from .listing import VirtualFSListingMixin
|
from .listing import VirtualFSListingMixin
|
||||||
@@ -47,28 +49,10 @@ class VirtualFSService(
|
|||||||
result = await cls.list_virtual_dir_with_permission(
|
result = await cls.list_virtual_dir_with_permission(
|
||||||
full_path, user_id, page_num, page_size, sort_by, sort_order, cursor
|
full_path, user_id, page_num, page_size, sort_by, sort_order, cursor
|
||||||
)
|
)
|
||||||
pagination = {
|
return dir_listing(
|
||||||
"mode": result.get("pagination_mode", "paged") if isinstance(result, dict) else "paged",
|
result,
|
||||||
"page_size": result.get("page_size", page_size) if isinstance(result, dict) else page_size,
|
path=full_path,
|
||||||
}
|
page_num=page_num,
|
||||||
if pagination["mode"] == "cursor":
|
page_size=page_size,
|
||||||
pagination.update(
|
cursor=cursor,
|
||||||
{
|
)
|
||||||
"cursor": result.get("cursor") if isinstance(result, dict) else cursor,
|
|
||||||
"next_cursor": result.get("next_cursor") if isinstance(result, dict) else None,
|
|
||||||
"has_next": bool(result.get("has_next")) if isinstance(result, dict) else False,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
pagination.update(
|
|
||||||
{
|
|
||||||
"total": result.get("total", 0) if isinstance(result, dict) else 0,
|
|
||||||
"page": result.get("page", page_num) if isinstance(result, dict) else page_num,
|
|
||||||
"pages": result.get("pages", 0) if isinstance(result, dict) else 0,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"path": full_path,
|
|
||||||
"entries": result.get("items", []) if isinstance(result, dict) else [],
|
|
||||||
"pagination": pagination,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from api.response import cursor_page, dir_listing, listing_pagination, page
|
||||||
|
|
||||||
|
|
||||||
|
class ListingPaginationTests(unittest.TestCase):
|
||||||
|
def test_paged_result_keeps_totals(self):
|
||||||
|
result = page([{"name": "a"}], total=120, page=2, page_size=50)
|
||||||
|
items, pagination = listing_pagination(result, page_num=2, page_size=50)
|
||||||
|
self.assertEqual(len(items), 1)
|
||||||
|
self.assertEqual(
|
||||||
|
pagination,
|
||||||
|
{
|
||||||
|
"mode": "paged",
|
||||||
|
"page_size": 50,
|
||||||
|
"total": 120,
|
||||||
|
"page": 2,
|
||||||
|
"pages": 3,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_cursor_result_keeps_cursors(self):
|
||||||
|
result = cursor_page(
|
||||||
|
[{"name": "tg"}],
|
||||||
|
page_size=50,
|
||||||
|
cursor="10",
|
||||||
|
next_cursor="20",
|
||||||
|
)
|
||||||
|
items, pagination = listing_pagination(result, page_num=2, page_size=50, cursor="10")
|
||||||
|
self.assertEqual(items, [{"name": "tg"}])
|
||||||
|
self.assertEqual(
|
||||||
|
pagination,
|
||||||
|
{
|
||||||
|
"mode": "cursor",
|
||||||
|
"page_size": 50,
|
||||||
|
"cursor": "10",
|
||||||
|
"next_cursor": "20",
|
||||||
|
"has_next": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertNotIn("total", pagination)
|
||||||
|
self.assertNotIn("page", pagination)
|
||||||
|
|
||||||
|
def test_missing_total_does_not_fake_page_count(self):
|
||||||
|
items, pagination = listing_pagination(
|
||||||
|
{"items": [{"name": "a"}] * 50, "page_size": 50},
|
||||||
|
page_num=1,
|
||||||
|
page_size=50,
|
||||||
|
)
|
||||||
|
self.assertEqual(len(items), 50)
|
||||||
|
self.assertEqual(pagination["mode"], "paged")
|
||||||
|
self.assertEqual(pagination["total"], 0)
|
||||||
|
self.assertEqual(pagination["pages"], 0)
|
||||||
|
|
||||||
|
def test_dir_listing_wraps_path_and_entries(self):
|
||||||
|
result = page([{"name": "a"}], total=1, page=1, page_size=50)
|
||||||
|
payload = dir_listing(result, path="/share/docs", page_num=1, page_size=50)
|
||||||
|
self.assertEqual(payload["path"], "/share/docs")
|
||||||
|
self.assertEqual(payload["entries"], [{"name": "a"}])
|
||||||
|
self.assertEqual(payload["pagination"]["mode"], "paged")
|
||||||
|
self.assertEqual(payload["pagination"]["total"], 1)
|
||||||
|
|
||||||
|
def test_non_dict_result_is_empty_paged(self):
|
||||||
|
items, pagination = listing_pagination(None, page_num=3, page_size=20)
|
||||||
|
self.assertEqual(items, [])
|
||||||
|
self.assertEqual(pagination["mode"], "paged")
|
||||||
|
self.assertEqual(pagination["total"], 0)
|
||||||
|
self.assertEqual(pagination["page"], 3)
|
||||||
|
self.assertEqual(pagination["page_size"], 20)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from api.response import cursor_page, page
|
||||||
|
from domain.share.service import ShareService
|
||||||
|
|
||||||
|
|
||||||
|
class ShareSingleFileListingTests(unittest.TestCase):
|
||||||
|
def test_first_page_returns_file(self):
|
||||||
|
stat = {"name": "notes.md", "is_dir": False, "size": 12}
|
||||||
|
result = ShareService._single_file_listing(stat, 1, 50)
|
||||||
|
self.assertEqual(result["items"], [stat])
|
||||||
|
self.assertEqual(result["pagination"]["mode"], "paged")
|
||||||
|
self.assertEqual(result["pagination"]["total"], 1)
|
||||||
|
self.assertEqual(result["pagination"]["page"], 1)
|
||||||
|
self.assertEqual(result["pagination"]["pages"], 1)
|
||||||
|
|
||||||
|
def test_later_page_is_empty(self):
|
||||||
|
stat = {"name": "notes.md", "is_dir": False}
|
||||||
|
result = ShareService._single_file_listing(stat, 2, 50)
|
||||||
|
self.assertEqual(result["items"], [])
|
||||||
|
self.assertEqual(result["pagination"]["total"], 1)
|
||||||
|
self.assertEqual(result["pagination"]["page"], 2)
|
||||||
|
self.assertEqual(result["pagination"]["pages"], 1)
|
||||||
|
|
||||||
|
|
||||||
|
class ShareDirectoryListingTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_directory_maps_offset_pagination(self):
|
||||||
|
share = SimpleNamespace(paths=["/photos"])
|
||||||
|
listing = page([{"name": "a.jpg"}, {"name": "b.jpg"}], total=90, page=2, page_size=20)
|
||||||
|
with patch.object(ShareService, "_list_shared_virtual_dir", new_callable=AsyncMock) as listed:
|
||||||
|
with patch("domain.share.service.VirtualFSService.stat_file", new_callable=AsyncMock) as stat_file:
|
||||||
|
stat_file.return_value = {"is_dir": True, "name": "photos"}
|
||||||
|
listed.return_value = {"items": listing["items"], "pagination": {"mode": "paged", "total": 90}}
|
||||||
|
result = await ShareService.get_shared_item_details(
|
||||||
|
share, "/", page_num=2, page_size=20
|
||||||
|
)
|
||||||
|
listed.assert_awaited_once_with("/photos", 2, 20, None)
|
||||||
|
self.assertEqual(result["pagination"]["total"], 90)
|
||||||
|
|
||||||
|
async def test_directory_passes_cursor_to_vfs(self):
|
||||||
|
share = SimpleNamespace(paths=["/tg"])
|
||||||
|
vfs_result = cursor_page(
|
||||||
|
[{"name": "1_clip.mp4"}],
|
||||||
|
page_size=50,
|
||||||
|
cursor="10",
|
||||||
|
next_cursor="20",
|
||||||
|
)
|
||||||
|
with patch("domain.share.service.VirtualFSService.stat_file", new_callable=AsyncMock) as stat_file:
|
||||||
|
with patch(
|
||||||
|
"domain.share.service.VirtualFSService.list_virtual_dir",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
) as list_dir:
|
||||||
|
stat_file.return_value = {"is_dir": True, "name": "tg"}
|
||||||
|
list_dir.return_value = vfs_result
|
||||||
|
result = await ShareService.get_shared_item_details(
|
||||||
|
share, "/", page_num=1, page_size=50, cursor="10"
|
||||||
|
)
|
||||||
|
list_dir.assert_awaited_once_with("/tg", 1, 50, cursor="10")
|
||||||
|
self.assertEqual(result["items"], [{"name": "1_clip.mp4"}])
|
||||||
|
self.assertEqual(result["pagination"]["mode"], "cursor")
|
||||||
|
self.assertEqual(result["pagination"]["next_cursor"], "20")
|
||||||
|
self.assertTrue(result["pagination"]["has_next"])
|
||||||
|
self.assertNotIn("total", result["pagination"])
|
||||||
|
|
||||||
|
async def test_missing_directory_http_404_is_normalized(self):
|
||||||
|
share = SimpleNamespace(paths=["/photos"])
|
||||||
|
with patch(
|
||||||
|
"domain.share.service.VirtualFSService.list_virtual_dir",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
) as list_dir:
|
||||||
|
list_dir.side_effect = HTTPException(status_code=404, detail="Path not found")
|
||||||
|
with self.assertRaises(HTTPException) as ctx:
|
||||||
|
await ShareService.get_shared_item_details(share, "/missing")
|
||||||
|
self.assertEqual(ctx.exception.status_code, 404)
|
||||||
|
self.assertEqual(ctx.exception.detail, "目录未找到")
|
||||||
|
|
||||||
|
async def test_empty_share_is_404(self):
|
||||||
|
share = SimpleNamespace(paths=[])
|
||||||
|
with self.assertRaises(HTTPException) as ctx:
|
||||||
|
await ShareService.get_shared_item_details(share)
|
||||||
|
self.assertEqual(ctx.exception.status_code, 404)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+24
-3
@@ -34,12 +34,33 @@ export const shareApi = {
|
|||||||
clearExpired: () => request<ClearExpiredResult>(`/shares/expired`, { method: 'DELETE' }),
|
clearExpired: () => request<ClearExpiredResult>(`/shares/expired`, { method: 'DELETE' }),
|
||||||
get: (token: string) => request<ShareInfo>(`/s/${token}`),
|
get: (token: string) => request<ShareInfo>(`/s/${token}`),
|
||||||
verifyPassword: (token: string, password: string) => request<void>(`/s/${token}/verify`, { method: 'POST', json: { password } }),
|
verifyPassword: (token: string, password: string) => request<void>(`/s/${token}/verify`, { method: 'POST', json: { password } }),
|
||||||
listDir: (token: string, path: string = '/', password?: string) => {
|
listDir: (
|
||||||
const params: Record<string, string> = { path };
|
token: string,
|
||||||
|
path: string = '/',
|
||||||
|
password?: string,
|
||||||
|
options?: {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
cursor?: string | null;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
const page = options?.page ?? 1;
|
||||||
|
const pageSize = options?.pageSize ?? 50;
|
||||||
|
const params: Record<string, string> = {
|
||||||
|
path,
|
||||||
|
page: String(page),
|
||||||
|
page_size: String(pageSize),
|
||||||
|
};
|
||||||
if (password) {
|
if (password) {
|
||||||
params.password = password;
|
params.password = password;
|
||||||
}
|
}
|
||||||
return request<DirListing>(`/s/${token}/ls?${new URLSearchParams(params)}`);
|
if (options?.cursor) {
|
||||||
|
params.cursor = options.cursor;
|
||||||
|
}
|
||||||
|
return request<DirListing>(`/s/${token}/ls?${new URLSearchParams(params)}`, {
|
||||||
|
signal: options?.signal,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
downloadUrl: (token: string, path: string, password?: string) => {
|
downloadUrl: (token: string, path: string, password?: string) => {
|
||||||
const url = `${API_BASE_URL}/s/${token}/download?path=${encodeURIComponent(path)}`;
|
const url = `${API_BASE_URL}/s/${token}/download?path=${encodeURIComponent(path)}`;
|
||||||
|
|||||||
@@ -636,6 +636,8 @@
|
|||||||
"Please input name": "Please input name",
|
"Please input name": "Please input name",
|
||||||
"Confirm delete {name}?": "Confirm delete {name}?",
|
"Confirm delete {name}?": "Confirm delete {name}?",
|
||||||
"items": "items",
|
"items": "items",
|
||||||
|
"Previous page": "Previous page",
|
||||||
|
"Next page": "Next page",
|
||||||
"Downloading folders is not supported": "Downloading folders is not supported",
|
"Downloading folders is not supported": "Downloading folders is not supported",
|
||||||
"Download failed": "Download failed",
|
"Download failed": "Download failed",
|
||||||
"Please select files or folders to share": "Please select files or folders to share",
|
"Please select files or folders to share": "Please select files or folders to share",
|
||||||
|
|||||||
@@ -629,6 +629,8 @@
|
|||||||
"Please input name": "请输入名称",
|
"Please input name": "请输入名称",
|
||||||
"Confirm delete {name}?": "确认删除 {name} ?",
|
"Confirm delete {name}?": "确认删除 {name} ?",
|
||||||
"items": "项",
|
"items": "项",
|
||||||
|
"Previous page": "上一页",
|
||||||
|
"Next page": "下一页",
|
||||||
"Downloading folders is not supported": "暂不支持下载目录",
|
"Downloading folders is not supported": "暂不支持下载目录",
|
||||||
"Download failed": "下载失败",
|
"Download failed": "下载失败",
|
||||||
"Please select files or folders to share": "请选择要分享的文件或目录",
|
"Please select files or folders to share": "请选择要分享的文件或目录",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { memo, useState, useEffect, useCallback } from 'react';
|
import { memo, useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { Card, List, Typography, Button, Empty, Breadcrumb } from 'antd';
|
import { Card, List, Typography, Button, Empty, Breadcrumb, Pagination, Space } from 'antd';
|
||||||
import { FileOutlined, FolderOutlined, DownloadOutlined } from '@ant-design/icons';
|
import { FileOutlined, FolderOutlined, DownloadOutlined } from '@ant-design/icons';
|
||||||
import { shareApi, type ShareInfo } from '../../api/share';
|
import { shareApi, type ShareInfo } from '../../api/share';
|
||||||
import { type VfsEntry } from '../../api/vfs';
|
import { type VfsEntry } from '../../api/vfs';
|
||||||
@@ -15,42 +15,151 @@ interface DirectoryViewerProps {
|
|||||||
onFileClick: (entry: VfsEntry, path: string) => void;
|
onFileClick: (entry: VfsEntry, path: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PAGE_SIZE = 50;
|
||||||
|
|
||||||
|
type SharePaginationState = {
|
||||||
|
mode: 'paged' | 'cursor';
|
||||||
|
current: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
cursor: string | null;
|
||||||
|
nextCursor: string | null;
|
||||||
|
hasNext: boolean;
|
||||||
|
cursorHistory: (string | null)[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const INITIAL_PAGINATION: SharePaginationState = {
|
||||||
|
mode: 'paged',
|
||||||
|
current: 1,
|
||||||
|
pageSize: DEFAULT_PAGE_SIZE,
|
||||||
|
total: 0,
|
||||||
|
cursor: null,
|
||||||
|
nextCursor: null,
|
||||||
|
hasNext: false,
|
||||||
|
cursorHistory: [],
|
||||||
|
};
|
||||||
|
|
||||||
export const DirectoryViewer = memo(function DirectoryViewer({ token, shareInfo, password, onFileClick }: DirectoryViewerProps) {
|
export const DirectoryViewer = memo(function DirectoryViewer({ token, shareInfo, password, onFileClick }: DirectoryViewerProps) {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [entries, setEntries] = useState<VfsEntry[]>([]);
|
const [entries, setEntries] = useState<VfsEntry[]>([]);
|
||||||
const [currentPath, setCurrentPath] = useState('/');
|
const [currentPath, setCurrentPath] = useState('/');
|
||||||
|
const [pagination, setPagination] = useState<SharePaginationState>(INITIAL_PAGINATION);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const tRef = useRef(t);
|
||||||
|
tRef.current = t;
|
||||||
|
const paginationRef = useRef(pagination);
|
||||||
|
paginationRef.current = pagination;
|
||||||
|
const loadGenRef = useRef(0);
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
const loadData = useCallback(async (p: string) => {
|
const loadData = useCallback(async (opts: {
|
||||||
setLoading(true);
|
path: string;
|
||||||
setError('');
|
page?: number;
|
||||||
try {
|
pageSize?: number;
|
||||||
const listing = await shareApi.listDir(token, p, password);
|
cursor?: string | null;
|
||||||
setEntries(listing.entries || []);
|
cursorHistory?: (string | null)[];
|
||||||
setCurrentPath(p);
|
}) => {
|
||||||
} catch (e: any) {
|
const page = opts.page ?? 1;
|
||||||
setError(e.message || t('Share load failed'));
|
const pageSize = opts.pageSize ?? paginationRef.current.pageSize;
|
||||||
} finally {
|
const cursor = opts.cursor ?? null;
|
||||||
setLoading(false);
|
const cursorHistory = opts.cursorHistory ?? [];
|
||||||
}
|
const gen = ++loadGenRef.current;
|
||||||
}, [password, t, token]);
|
abortRef.current?.abort();
|
||||||
|
const ac = new AbortController();
|
||||||
|
abortRef.current = ac;
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const listing = await shareApi.listDir(token, opts.path, password, {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
cursor,
|
||||||
|
signal: ac.signal,
|
||||||
|
});
|
||||||
|
if (gen !== loadGenRef.current) return;
|
||||||
|
const listingPagination = listing.pagination;
|
||||||
|
const pageMode = listingPagination?.mode === 'cursor' ? 'cursor' : 'paged';
|
||||||
|
setEntries(listing.entries || []);
|
||||||
|
setPagination({
|
||||||
|
mode: pageMode,
|
||||||
|
current: listingPagination?.page ?? page,
|
||||||
|
pageSize: listingPagination?.page_size ?? pageSize,
|
||||||
|
total: listingPagination?.total ?? 0,
|
||||||
|
cursor: listingPagination?.cursor ?? null,
|
||||||
|
nextCursor: listingPagination?.next_cursor ?? null,
|
||||||
|
hasNext: Boolean(listingPagination?.has_next),
|
||||||
|
cursorHistory: pageMode === 'cursor' ? cursorHistory : [],
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
if (gen !== loadGenRef.current) return;
|
||||||
|
if (e?.name === 'AbortError') return;
|
||||||
|
setError(e.message || tRef.current('Share load failed'));
|
||||||
|
} finally {
|
||||||
|
if (gen === loadGenRef.current) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [password, token]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData(currentPath);
|
loadData({ path: currentPath, page: 1 });
|
||||||
|
return () => {
|
||||||
|
abortRef.current?.abort();
|
||||||
|
};
|
||||||
}, [loadData, currentPath]);
|
}, [loadData, currentPath]);
|
||||||
|
|
||||||
|
const goToPath = (path: string) => {
|
||||||
|
setPagination(prev => ({
|
||||||
|
...prev,
|
||||||
|
current: 1,
|
||||||
|
cursor: null,
|
||||||
|
nextCursor: null,
|
||||||
|
hasNext: false,
|
||||||
|
cursorHistory: [],
|
||||||
|
}));
|
||||||
|
setCurrentPath(path);
|
||||||
|
};
|
||||||
|
|
||||||
const handleEntryClick = (entry: VfsEntry) => {
|
const handleEntryClick = (entry: VfsEntry) => {
|
||||||
const newPath = (currentPath === '/' ? '' : currentPath) + '/' + entry.name;
|
const newPath = (currentPath === '/' ? '' : currentPath) + '/' + entry.name;
|
||||||
if (entry.is_dir) {
|
if (entry.is_dir) {
|
||||||
loadData(newPath);
|
goToPath(newPath);
|
||||||
} else {
|
} else {
|
||||||
onFileClick(entry, newPath);
|
onFileClick(entry, newPath);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBreadcrumbClick = (path: string) => {
|
const handleBreadcrumbClick = (path: string) => {
|
||||||
loadData(path);
|
goToPath(path);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePageChange = (page: number, pageSize: number) => {
|
||||||
|
loadData({ path: currentPath, page, pageSize });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCursorNext = () => {
|
||||||
|
if (!pagination.nextCursor) return;
|
||||||
|
loadData({
|
||||||
|
path: currentPath,
|
||||||
|
page: 1,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
cursor: pagination.nextCursor,
|
||||||
|
cursorHistory: [...pagination.cursorHistory, pagination.cursor],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCursorPrev = () => {
|
||||||
|
if (pagination.cursorHistory.length === 0) return;
|
||||||
|
const nextHistory = pagination.cursorHistory.slice(0, -1);
|
||||||
|
const prevCursor = pagination.cursorHistory[pagination.cursorHistory.length - 1];
|
||||||
|
loadData({
|
||||||
|
path: currentPath,
|
||||||
|
page: 1,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
cursor: prevCursor,
|
||||||
|
cursorHistory: nextHistory,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderBreadcrumb = () => {
|
const renderBreadcrumb = () => {
|
||||||
@@ -75,6 +184,9 @@ export const DirectoryViewer = memo(function DirectoryViewer({ token, shareInfo,
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const showPagedPagination = pagination.mode === 'paged' && pagination.total > 0;
|
||||||
|
const showCursorPagination = pagination.mode === 'cursor' && (pagination.cursorHistory.length > 0 || pagination.hasNext);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return <div style={{ textAlign: 'center', padding: 50 }}><Empty description={error} /></div>;
|
return <div style={{ textAlign: 'center', padding: 50 }}><Empty description={error} /></div>;
|
||||||
}
|
}
|
||||||
@@ -112,6 +224,31 @@ export const DirectoryViewer = memo(function DirectoryViewer({ token, shareInfo,
|
|||||||
</List.Item>
|
</List.Item>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
{showPagedPagination ? (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', marginTop: 16 }}>
|
||||||
|
<Pagination
|
||||||
|
current={pagination.current}
|
||||||
|
pageSize={pagination.pageSize}
|
||||||
|
total={pagination.total}
|
||||||
|
showSizeChanger
|
||||||
|
pageSizeOptions={['20', '50', '100', '200']}
|
||||||
|
showTotal={(total, range) => `${total} ${t('items')} ${range[0]}-${range[1]}`}
|
||||||
|
onChange={handlePageChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{showCursorPagination ? (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', marginTop: 16 }}>
|
||||||
|
<Space>
|
||||||
|
<Button size="small" onClick={handleCursorPrev} disabled={pagination.cursorHistory.length === 0 || loading}>
|
||||||
|
{t('Previous page')}
|
||||||
|
</Button>
|
||||||
|
<Button size="small" type="primary" onClick={handleCursorNext} disabled={!pagination.hasNext || loading}>
|
||||||
|
{t('Next page')}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user