mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-08-30 04:26:55 +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):
|
||||
return {"code": code, "msg": msg, "data": data}
|
||||
|
||||
+16
-9
@@ -1,6 +1,6 @@
|
||||
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 domain.audit import AuditAction, audit
|
||||
@@ -95,20 +95,27 @@ async def verify_password(request: Request, token: str, payload: SharePassword):
|
||||
@public_router.get("/{token}/ls")
|
||||
@audit(action=AuditAction.SHARE, description="浏览分享内容")
|
||||
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)
|
||||
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(
|
||||
{
|
||||
"path": path,
|
||||
"entries": content.get("items", []),
|
||||
"pagination": {
|
||||
"total": content.get("total", 0),
|
||||
"page": content.get("page", 1),
|
||||
"page_size": content.get("page_size", 1),
|
||||
"pages": content.get("pages", 1),
|
||||
},
|
||||
"pagination": content.get("pagination"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
+58
-8
@@ -7,6 +7,7 @@ import bcrypt
|
||||
from fastapi import HTTPException, status
|
||||
from fastapi.responses import Response
|
||||
|
||||
from api.response import listing_pagination
|
||||
from domain.virtual_fs import VirtualFSService
|
||||
from models.database import ShareLink, UserAccount
|
||||
|
||||
@@ -114,7 +115,53 @@ class ShareService:
|
||||
return deleted_count
|
||||
|
||||
@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:
|
||||
raise HTTPException(status_code=404, detail="分享内容为空")
|
||||
|
||||
@@ -124,21 +171,24 @@ class ShareService:
|
||||
full_path = f"{base_shared_path.rstrip('/')}/{sub_path.lstrip('/')}".rstrip("/")
|
||||
if not full_path.startswith(base_shared_path):
|
||||
raise HTTPException(status_code=403, detail="无权访问此路径")
|
||||
try:
|
||||
return await VirtualFSService.list_virtual_dir(full_path)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="目录未找到")
|
||||
return await cls._list_shared_virtual_dir(
|
||||
full_path, page_num, page_size, cursor
|
||||
)
|
||||
|
||||
try:
|
||||
stat = await VirtualFSService.stat_file(base_shared_path)
|
||||
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]
|
||||
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:
|
||||
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
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -5,6 +5,7 @@ from urllib.parse import quote
|
||||
from fastapi import HTTPException, Request, UploadFile
|
||||
from fastapi.responses import Response
|
||||
|
||||
from api.response import dir_listing
|
||||
from domain.config import ConfigService
|
||||
from domain.tasks import TaskService
|
||||
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):
|
||||
full_path = cls._normalize_path(full_path)
|
||||
result = await cls.list_virtual_dir(full_path, page_num, page_size, sort_by, sort_order)
|
||||
pagination = {
|
||||
"mode": result.get("pagination_mode", "paged"),
|
||||
"page_size": result.get("page_size", page_size),
|
||||
}
|
||||
if pagination["mode"] == "cursor":
|
||||
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,
|
||||
}
|
||||
return dir_listing(
|
||||
result,
|
||||
path=full_path,
|
||||
page_num=page_num,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def delete(cls, full_path: str):
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from api.response import dir_listing
|
||||
|
||||
from .common import VirtualFSCommonMixin
|
||||
from .resolver import VirtualFSResolverMixin
|
||||
from .listing import VirtualFSListingMixin
|
||||
@@ -47,28 +49,10 @@ class VirtualFSService(
|
||||
result = await cls.list_virtual_dir_with_permission(
|
||||
full_path, user_id, page_num, page_size, sort_by, sort_order, cursor
|
||||
)
|
||||
pagination = {
|
||||
"mode": result.get("pagination_mode", "paged") if isinstance(result, dict) else "paged",
|
||||
"page_size": result.get("page_size", page_size) if isinstance(result, dict) else page_size,
|
||||
}
|
||||
if pagination["mode"] == "cursor":
|
||||
pagination.update(
|
||||
{
|
||||
"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,
|
||||
}
|
||||
return dir_listing(
|
||||
result,
|
||||
path=full_path,
|
||||
page_num=page_num,
|
||||
page_size=page_size,
|
||||
cursor=cursor,
|
||||
)
|
||||
|
||||
@@ -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' }),
|
||||
get: (token: string) => request<ShareInfo>(`/s/${token}`),
|
||||
verifyPassword: (token: string, password: string) => request<void>(`/s/${token}/verify`, { method: 'POST', json: { password } }),
|
||||
listDir: (token: string, path: string = '/', password?: string) => {
|
||||
const params: Record<string, string> = { path };
|
||||
listDir: (
|
||||
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) {
|
||||
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) => {
|
||||
const url = `${API_BASE_URL}/s/${token}/download?path=${encodeURIComponent(path)}`;
|
||||
|
||||
@@ -636,6 +636,8 @@
|
||||
"Please input name": "Please input name",
|
||||
"Confirm delete {name}?": "Confirm delete {name}?",
|
||||
"items": "items",
|
||||
"Previous page": "Previous page",
|
||||
"Next 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",
|
||||
|
||||
@@ -629,6 +629,8 @@
|
||||
"Please input name": "请输入名称",
|
||||
"Confirm delete {name}?": "确认删除 {name} ?",
|
||||
"items": "项",
|
||||
"Previous page": "上一页",
|
||||
"Next page": "下一页",
|
||||
"Downloading folders is not supported": "暂不支持下载目录",
|
||||
"Download failed": "下载失败",
|
||||
"Please select files or folders to share": "请选择要分享的文件或目录",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { memo, useState, useEffect, useCallback } from 'react';
|
||||
import { Card, List, Typography, Button, Empty, Breadcrumb } from 'antd';
|
||||
import { memo, useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Card, List, Typography, Button, Empty, Breadcrumb, Pagination, Space } from 'antd';
|
||||
import { FileOutlined, FolderOutlined, DownloadOutlined } from '@ant-design/icons';
|
||||
import { shareApi, type ShareInfo } from '../../api/share';
|
||||
import { type VfsEntry } from '../../api/vfs';
|
||||
@@ -15,42 +15,151 @@ interface DirectoryViewerProps {
|
||||
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) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [entries, setEntries] = useState<VfsEntry[]>([]);
|
||||
const [currentPath, setCurrentPath] = useState('/');
|
||||
const [pagination, setPagination] = useState<SharePaginationState>(INITIAL_PAGINATION);
|
||||
const [error, setError] = useState('');
|
||||
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) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const listing = await shareApi.listDir(token, p, password);
|
||||
setEntries(listing.entries || []);
|
||||
setCurrentPath(p);
|
||||
} catch (e: any) {
|
||||
setError(e.message || t('Share load failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [password, t, token]);
|
||||
const loadData = useCallback(async (opts: {
|
||||
path: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
cursor?: string | null;
|
||||
cursorHistory?: (string | null)[];
|
||||
}) => {
|
||||
const page = opts.page ?? 1;
|
||||
const pageSize = opts.pageSize ?? paginationRef.current.pageSize;
|
||||
const cursor = opts.cursor ?? null;
|
||||
const cursorHistory = opts.cursorHistory ?? [];
|
||||
const gen = ++loadGenRef.current;
|
||||
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(() => {
|
||||
loadData(currentPath);
|
||||
loadData({ path: currentPath, page: 1 });
|
||||
return () => {
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, [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 newPath = (currentPath === '/' ? '' : currentPath) + '/' + entry.name;
|
||||
if (entry.is_dir) {
|
||||
loadData(newPath);
|
||||
goToPath(newPath);
|
||||
} else {
|
||||
onFileClick(entry, newPath);
|
||||
}
|
||||
};
|
||||
|
||||
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 = () => {
|
||||
@@ -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) {
|
||||
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>
|
||||
)}
|
||||
/>
|
||||
{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>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user