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:
XiaoQing235
2026-08-27 22:21:08 +08:00
committed by GitHub
co-authored by shiyu
parent 4d907c7bfd
commit 0763d457d5
11 changed files with 486 additions and 88 deletions
+51
View File
@@ -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
View File
@@ -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
View File
@@ -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
+7 -25
View File
@@ -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):
+9 -25
View File
@@ -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,
}
+73
View File
@@ -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()
+89
View File
@@ -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
View File
@@ -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)}`;
+2
View File
@@ -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",
+2
View File
@@ -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": "请选择要分享的文件或目录",
+155 -18
View File
@@ -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>
); );