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):
return {"code": code, "msg": msg, "data": data}
+16 -9
View File
@@ -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
View File
@@ -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
+7 -25
View File
@@ -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):
+9 -25
View File
@@ -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,
)
+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' }),
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)}`;
+2
View File
@@ -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",
+2
View File
@@ -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": "请选择要分享的文件或目录",
+155 -18
View File
@@ -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>
);