feat: implement cursor-based pagination across various components and APIs

This commit is contained in:
shiyu
2026-05-10 00:36:41 +08:00
parent 56b48b28a1
commit f89292e451
12 changed files with 275 additions and 148 deletions

View File

@@ -9,7 +9,25 @@ def success(data: Any = None, msg: str = "ok", code: int = 0):
def page(items: list[Any], total: int, page: int, page_size: int): def page(items: list[Any], total: int, page: int, page_size: int):
"""统一分页数据结构。""" """统一分页数据结构。"""
pages = (total + page_size - 1) // page_size if page_size else 0 pages = (total + page_size - 1) // page_size if page_size else 0
return {"items": items, "total": total, "page": page, "page_size": page_size, "pages": pages} return {"items": items, "total": total, "page": page, "page_size": page_size, "pages": pages, "pagination_mode": "paged"}
def cursor_page(
items: list[Any],
page_size: int,
*,
cursor: str | None = None,
next_cursor: str | None = None,
):
"""无总数游标分页结构。"""
return {
"items": items,
"page_size": page_size,
"pagination_mode": "cursor",
"cursor": cursor,
"next_cursor": next_cursor,
"has_next": bool(next_cursor),
}
def error(msg: str, code: int = 1, data: Optional[Any] = None): def error(msg: str, code: int = 1, data: Optional[Any] = None):

View File

@@ -1,4 +1,4 @@
from typing import List, Dict, Protocol, runtime_checkable, Tuple, AsyncIterator from typing import List, Dict, Protocol, runtime_checkable, Tuple, AsyncIterator, Any
from models import StorageAdapter from models import StorageAdapter
# 约定:任意新适配器模块需定义: # 约定:任意新适配器模块需定义:
@@ -9,7 +9,7 @@ from models import StorageAdapter
@runtime_checkable @runtime_checkable
class BaseAdapter(Protocol): class BaseAdapter(Protocol):
record: StorageAdapter record: StorageAdapter
async def list_dir(self, root: str, rel: str, page_num: int = 1, page_size: int = 50, sort_by: str = "name", sort_order: str = "asc") -> Tuple[List[Dict], int]: ... async def list_dir(self, root: str, rel: str, page_num: int = 1, page_size: int = 50, sort_by: str = "name", sort_order: str = "asc", cursor: str | None = None) -> Tuple[List[Dict], int] | Dict[str, Any]: ...
async def read_file(self, root: str, rel: str) -> bytes: ... async def read_file(self, root: str, rel: str) -> bytes: ...
async def write_file(self, root: str, rel: str, data: bytes): ... async def write_file(self, root: str, rel: str, data: bytes): ...
async def write_file_stream(self, root: str, rel: str, data_iter: AsyncIterator[bytes]): ... async def write_file_stream(self, root: str, rel: str, data_iter: AsyncIterator[bytes]): ...

View File

@@ -4,6 +4,7 @@ import httpx
from fastapi.responses import StreamingResponse, Response from fastapi.responses import StreamingResponse, Response
from fastapi import HTTPException from fastapi import HTTPException
from models import StorageAdapter from models import StorageAdapter
from api.response import cursor_page
MS_GRAPH_URL = "https://graph.microsoft.com/v1.0" MS_GRAPH_URL = "https://graph.microsoft.com/v1.0"
MS_OAUTH_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/token" MS_OAUTH_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/token"
@@ -114,65 +115,51 @@ class OneDriveAdapter:
"type": "dir" if is_dir else "file", "type": "dir" if is_dir else "file",
} }
async def list_dir(self, root: str, rel: str, page_num: int = 1, page_size: int = 50, sort_by: str = "name", sort_order: str = "asc") -> Tuple[List[Dict], int]: async def list_dir(
self,
root: str,
rel: str,
page_num: int = 1,
page_size: int = 50,
sort_by: str = "name",
sort_order: str = "asc",
cursor: str | None = None,
):
""" """
列出目录内容。 列出目录内容。
由于 Graph API 不支持基于偏移($skip)的分页,此方法将获取所有项目, Graph API 不提供目录总数,使用 nextLink 游标分页。
:param root: 根路径 (在此适配器中未使用,通过配置的 root 确定)。 :param root: 根路径 (在此适配器中未使用,通过配置的 root 确定)。
:param rel: 相对路径。 :param rel: 相对路径。
:param page_num: 页码。 :param page_num: 页码。
:param page_size: 每页大小。 :param page_size: 每页大小。
:param sort_by: 排序字段 :param sort_by: 排序字段
:param sort_order: 排序顺序 :param sort_order: 排序顺序
:return: 文件/目录列表和总数 :param cursor: Graph nextLink
:return: 游标分页结果。
""" """
api_path = self._get_api_path(rel) if cursor:
children_path = f"{api_path}:/children" if api_path else "/children" resp = await self._request("GET", full_url=cursor)
all_items = [] else:
params = {"$top": 999} api_path = self._get_api_path(rel)
resp = await self._request("GET", api_path_segment=children_path, params=params) children_path = f"{api_path}:/children" if api_path else "/children"
resp = await self._request("GET", api_path_segment=children_path, params={"$top": page_size})
while True: if resp.status_code == 404:
if resp.status_code == 404 and not all_items: return cursor_page([], page_size, cursor=cursor)
return [], 0 resp.raise_for_status()
resp.raise_for_status()
try: try:
data = resp.json() data = resp.json()
except Exception as e: except Exception as e:
raise IOError(f"解析 Graph API 响应失败: {e}") from e raise IOError(f"解析 Graph API 响应失败: {e}") from e
all_items.extend(data.get("value", [])) formatted_items = [self._format_item(item) for item in data.get("value", [])]
next_link = data.get("@odata.nextLink") return cursor_page(
formatted_items,
if not next_link: page_size,
break cursor=cursor,
next_cursor=data.get("@odata.nextLink"),
resp = await self._request("GET", full_url=next_link) )
formatted_items = [self._format_item(item) for item in all_items]
# 排序
reverse = sort_order.lower() == "desc"
def get_sort_key(item):
key = (not item["is_dir"],)
sort_field = sort_by.lower()
if sort_field == "name":
key += (item["name"].lower(),)
elif sort_field == "size":
key += (item["size"],)
elif sort_field == "mtime":
key += (item["mtime"],)
else:
key += (item["name"].lower(),)
return key
formatted_items.sort(key=get_sort_key, reverse=reverse)
total_count = len(formatted_items)
start_idx = (page_num - 1) * page_size
end_idx = start_idx + page_size
return formatted_items[start_idx:end_idx], total_count
async def read_file(self, root: str, rel: str) -> bytes: async def read_file(self, root: str, rel: str) -> bytes:
""" """

View File

@@ -6,6 +6,7 @@ import os
import struct import struct
import time import time
from models import StorageAdapter from models import StorageAdapter
from api.response import cursor_page
from telethon import TelegramClient, errors, utils from telethon import TelegramClient, errors, utils
from telethon.crypto import AuthKey from telethon.crypto import AuthKey
from telethon.sessions import StringSession from telethon.sessions import StringSession
@@ -280,81 +281,79 @@ class TelegramAdapter:
def get_effective_root(self, sub_path: str | None) -> str: def get_effective_root(self, sub_path: str | None) -> str:
return "" return ""
async def list_dir(self, root: str, rel: str, page_num: int = 1, page_size: int = 50, sort_by: str = "name", sort_order: str = "asc") -> Tuple[List[Dict], int]: async def list_dir(
self,
root: str,
rel: str,
page_num: int = 1,
page_size: int = 50,
sort_by: str = "name",
sort_order: str = "asc",
cursor: str | None = None,
):
if rel: if rel:
return [], 0 return cursor_page([], page_size, cursor=cursor)
client = self._get_client() client = self._get_client()
entries = [] entries = []
next_cursor = None
try: try:
await client.connect() await client.connect()
messages = await client.get_messages(self.chat_id, limit=200) offset_id = int(cursor) if cursor else 0
for message in messages: batch_limit = min(max(page_size, 50), 200)
if not message: while len(entries) < page_size:
continue messages = await client.get_messages(self.chat_id, limit=batch_limit, offset_id=offset_id)
if not messages:
next_cursor = None
break
media = message.document or message.video or message.photo offset_id = messages[-1].id
if not media: next_cursor = str(offset_id)
continue for message in messages:
if not message:
continue
file_meta = message.file media = message.document or message.video or message.photo
if not file_meta: if not media:
continue continue
filename = file_meta.name file_meta = message.file
if not filename: if not file_meta:
if message.text and '.' in message.text and len(message.text) < 256 and '\n' not in message.text: continue
filename = message.text
else:
filename = f"unknown_{message.id}"
size = file_meta.size filename = file_meta.name
if size is None: if not filename:
# 兼容缺失 size 的情况 if message.text and '.' in message.text and len(message.text) < 256 and '\n' not in message.text:
if hasattr(media, "size") and media.size is not None: filename = message.text
size = media.size else:
elif message.photo and getattr(message.photo, "sizes", None): filename = f"unknown_{message.id}"
photo_size = message.photo.sizes[-1]
size = getattr(photo_size, "size", 0) or 0
else:
size = 0
entries.append({ size = file_meta.size
"name": f"{message.id}_{filename}", if size is None:
"is_dir": False, # 兼容缺失 size 的情况
"size": size, if hasattr(media, "size") and media.size is not None:
"mtime": int(message.date.timestamp()), size = media.size
"type": "file", elif message.photo and getattr(message.photo, "sizes", None):
"has_thumbnail": False, photo_size = message.photo.sizes[-1]
}) size = getattr(photo_size, "size", 0) or 0
else:
size = 0
entries.append({
"name": f"{message.id}_{filename}",
"is_dir": False,
"size": size,
"mtime": int(message.date.timestamp()),
"type": "file",
"has_thumbnail": False,
})
if len(entries) >= page_size:
break
finally: finally:
if client.is_connected(): if client.is_connected():
await client.disconnect() await client.disconnect()
# 排序 return cursor_page(entries, page_size, cursor=cursor, next_cursor=next_cursor)
reverse = sort_order.lower() == "desc"
def get_sort_key(item):
key = (not item["is_dir"],)
sort_field = sort_by.lower()
if sort_field == "name":
key += (item["name"].lower(),)
elif sort_field == "size":
key += (item["size"],)
elif sort_field == "mtime":
key += (item["mtime"],)
else:
key += (item["name"].lower(),)
return key
entries.sort(key=get_sort_key, reverse=reverse)
total_count = len(entries)
# 分页
start_idx = (page_num - 1) * page_size
end_idx = start_idx + page_size
page_entries = entries[start_idx:end_idx]
return page_entries, total_count
async def read_file(self, root: str, rel: str) -> bytes: async def read_file(self, root: str, rel: str) -> bytes:
message_id = self._parse_message_id(rel) message_id = self._parse_message_id(rel)

View File

@@ -183,9 +183,10 @@ async def browse_fs(
page_size: int = Query(50, ge=1, le=500, description="每页条数"), page_size: int = Query(50, ge=1, le=500, description="每页条数"),
sort_by: str = Query("name", description="按字段排序: name, size, mtime"), sort_by: str = Query("name", description="按字段排序: name, size, mtime"),
sort_order: str = Query("asc", description="排序顺序: asc, desc"), sort_order: str = Query("asc", description="排序顺序: asc, desc"),
cursor: str | None = Query(None, description="游标分页位置"),
): ):
data = await VirtualFSService.list_directory_with_permission( data = await VirtualFSService.list_directory_with_permission(
full_path, current_user.id, page_num, page_size, sort_by, sort_order full_path, current_user.id, page_num, page_size, sort_by, sort_order, cursor
) )
return success(data) return success(data)
@@ -211,9 +212,10 @@ async def root_listing(
page_size: int = Query(50, ge=1, le=500, description="每页条数"), page_size: int = Query(50, ge=1, le=500, description="每页条数"),
sort_by: str = Query("name", description="按字段排序: name, size, mtime"), sort_by: str = Query("name", description="按字段排序: name, size, mtime"),
sort_order: str = Query("asc", description="排序顺序: asc, desc"), sort_order: str = Query("asc", description="排序顺序: asc, desc"),
cursor: str | None = Query(None, description="游标分页位置"),
): ):
# 根目录不需要权限检查,但需要过滤无权限的子目录 # 根目录不需要权限检查,但需要过滤无权限的子目录
data = await VirtualFSService.list_directory_with_permission( data = await VirtualFSService.list_directory_with_permission(
"/", current_user.id, page_num, page_size, sort_by, sort_order "/", current_user.id, page_num, page_size, sort_by, sort_order, cursor
) )
return success(data) return success(data)

View File

@@ -57,6 +57,7 @@ class VirtualFSListingMixin(VirtualFSResolverMixin):
page_size: int = 50, page_size: int = 50,
sort_by: str = "name", sort_by: str = "name",
sort_order: str = "asc", sort_order: str = "asc",
cursor: str | None = None,
) -> Dict: ) -> Dict:
norm = cls._normalize_path(path).rstrip("/") or "/" norm = cls._normalize_path(path).rstrip("/") or "/"
adapters = await StorageAdapter.filter(enabled=True) adapters = await StorageAdapter.filter(enabled=True)
@@ -119,12 +120,28 @@ class VirtualFSListingMixin(VirtualFSResolverMixin):
adapter_entries_for_merge: List[Dict] = [] adapter_entries_for_merge: List[Dict] = []
adapter_entries_page: List[Dict] | None = None adapter_entries_page: List[Dict] | None = None
adapter_total: int | None = None adapter_total: int | None = None
adapter_listing: Dict[str, Any] | None = None
if adapter_model and adapter_instance: if adapter_model and adapter_instance:
list_dir = getattr(adapter_instance, "list_dir", None) list_dir = getattr(adapter_instance, "list_dir", None)
if callable(list_dir): if callable(list_dir):
adapter_entries_page, adapter_total = await list_dir( try:
effective_root, rel, page_num, page_size, sort_by, sort_order parameters = inspect.signature(list_dir).parameters
) except (TypeError, ValueError):
parameters = {}
if "cursor" in parameters:
raw_listing = await list_dir(
effective_root, rel, page_num, page_size, sort_by, sort_order, cursor=cursor
)
else:
raw_listing = await list_dir(
effective_root, rel, page_num, page_size, sort_by, sort_order
)
if isinstance(raw_listing, dict):
adapter_listing = raw_listing
adapter_entries_page = raw_listing.get("items", [])
adapter_total = raw_listing.get("total")
else:
adapter_entries_page, adapter_total = raw_listing
if rel: if rel:
parent_rel = cls._parent_rel(rel) parent_rel = cls._parent_rel(rel)
if rel: if rel:
@@ -189,6 +206,9 @@ class VirtualFSListingMixin(VirtualFSResolverMixin):
annotate_entry_list = adapter_entries_page or [] annotate_entry_list = adapter_entries_page or []
for ent in annotate_entry_list: for ent in annotate_entry_list:
annotate_entry(ent) annotate_entry(ent)
if adapter_listing and adapter_listing.get("pagination_mode") == "cursor":
adapter_listing["items"] = annotate_entry_list
return adapter_listing
return page(adapter_entries_page, adapter_total, page_num, page_size) return page(adapter_entries_page, adapter_total, page_num, page_size)
@classmethod @classmethod
@@ -296,13 +316,14 @@ class VirtualFSListingMixin(VirtualFSResolverMixin):
page_size: int = 50, page_size: int = 50,
sort_by: str = "name", sort_by: str = "name",
sort_order: str = "asc", sort_order: str = "asc",
cursor: str | None = None,
) -> Dict: ) -> Dict:
""" """
带权限过滤的目录列表 带权限过滤的目录列表
过滤掉用户没有读取权限的条目 过滤掉用户没有读取权限的条目
""" """
result = await cls.list_virtual_dir(path, page_num, page_size, sort_by, sort_order) result = await cls.list_virtual_dir(path, page_num, page_size, sort_by, sort_order, cursor)
items = result.get("items", []) items = result.get("items", [])
if not items: if not items:
return result return result

View File

@@ -275,15 +275,30 @@ 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 = {
"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 { return {
"path": full_path, "path": full_path,
"entries": result["items"], "entries": result["items"],
"pagination": { "pagination": pagination,
"total": result["total"],
"page": result["page"],
"page_size": result["page_size"],
"pages": result["pages"],
},
} }
@classmethod @classmethod

View File

@@ -26,9 +26,10 @@ class VirtualFSService(
page_size: int = 50, page_size: int = 50,
sort_by: str = "name", sort_by: str = "name",
sort_order: str = "asc", sort_order: str = "asc",
cursor: str | None = None,
): ):
"""列出目录内容""" """列出目录内容"""
return await cls.list_virtual_dir(path, page_num, page_size, sort_by, sort_order) return await cls.list_virtual_dir(path, page_num, page_size, sort_by, sort_order, cursor)
@classmethod @classmethod
async def list_directory_with_permission( async def list_directory_with_permission(
@@ -39,19 +40,35 @@ class VirtualFSService(
page_size: int = 50, page_size: int = 50,
sort_by: str = "name", sort_by: str = "name",
sort_order: str = "asc", sort_order: str = "asc",
cursor: str | None = None,
): ):
"""列出目录内容(带权限过滤)""" """列出目录内容(带权限过滤)"""
full_path = cls._normalize_path(path).rstrip("/") or "/" full_path = cls._normalize_path(path).rstrip("/") or "/"
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 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 { return {
"path": full_path, "path": full_path,
"entries": result.get("items", []) if isinstance(result, dict) else [], "entries": result.get("items", []) if isinstance(result, dict) else [],
"pagination": { "pagination": pagination,
"total": result.get("total", 0) if isinstance(result, dict) else 0,
"page": result.get("page", page_num) if isinstance(result, dict) else page_num,
"page_size": result.get("page_size", page_size) if isinstance(result, dict) else page_size,
"pages": result.get("pages", 0) if isinstance(result, dict) else 0,
},
} }

View File

@@ -13,10 +13,14 @@ export interface DirListing {
path: string; path: string;
entries: VfsEntry[]; entries: VfsEntry[];
pagination?: { pagination?: {
total: number; mode?: 'paged' | 'cursor';
page: number;
page_size: number; page_size: number;
pages: number; total?: number;
page?: number;
pages?: number;
cursor?: string | null;
next_cursor?: string | null;
has_next?: boolean;
}; };
} }
@@ -47,7 +51,7 @@ export interface SearchResponse {
} }
export const vfsApi = { export const vfsApi = {
list: (path: string, page: number = 1, pageSize: number = 50, sortBy: string = 'name', sortOrder: string = 'asc') => { list: (path: string, page: number = 1, pageSize: number = 50, sortBy: string = 'name', sortOrder: string = 'asc', cursor?: string | null) => {
const cleaned = path.replace(/\\/g, '/'); const cleaned = path.replace(/\\/g, '/');
const trimmed = cleaned === '/' ? '' : cleaned.replace(/^\/+/, ''); const trimmed = cleaned === '/' ? '' : cleaned.replace(/^\/+/, '');
const params = new URLSearchParams({ const params = new URLSearchParams({
@@ -56,6 +60,7 @@ export const vfsApi = {
sort_by: sortBy, sort_by: sortBy,
sort_order: sortOrder sort_order: sortOrder
}); });
if (cursor) params.set('cursor', cursor);
return request<DirListing>(`/fs/${encodeURI(trimmed)}?${params}`); return request<DirListing>(`/fs/${encodeURI(trimmed)}?${params}`);
}, },
readFile: async (path: string) => { readFile: async (path: string) => {

View File

@@ -1,6 +1,6 @@
import { memo, useCallback, useEffect, useRef, useState } from 'react'; import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
import { theme, Pagination } from 'antd'; import { Button, Space, theme, Pagination } from 'antd';
import { useFileExplorer } from './hooks/useFileExplorer'; import { useFileExplorer } from './hooks/useFileExplorer';
import { useFileSelection } from './hooks/useFileSelection'; import { useFileSelection } from './hooks/useFileSelection';
import { useFileActions } from './hooks/useFileActions.tsx'; import { useFileActions } from './hooks/useFileActions.tsx';
@@ -43,7 +43,7 @@ const FileExplorerPage = memo(function FileExplorerPage() {
const skeletonTimerRef = useRef<number | null>(null); const skeletonTimerRef = useRef<number | null>(null);
// --- Hooks --- // --- Hooks ---
const { path, entries, loading, pagination, processorTypes, sortBy, sortOrder, load, navigateTo, goUp, handlePaginationChange, refresh, handleSortChange } = useFileExplorer(navKey); const { path, entries, loading, pagination, processorTypes, sortBy, sortOrder, load, navigateTo, goUp, handlePaginationChange, refresh, handleSortChange, goCursorNext, goCursorPrev } = useFileExplorer(navKey);
const { selectedEntries, handleSelect, handleSelectRange, clearSelection, setSelectedEntries } = useFileSelection(); const { selectedEntries, handleSelect, handleSelectRange, clearSelection, setSelectedEntries } = useFileSelection();
const { openFileWithDefaultApp, confirmOpenWithApp } = useAppWindows(); const { openFileWithDefaultApp, confirmOpenWithApp } = useAppWindows();
const { ctxMenu, blankCtxMenu, openContextMenu, openBlankContextMenu, openContextMenuAt, closeContextMenus } = useContextMenu(); const { ctxMenu, blankCtxMenu, openContextMenu, openBlankContextMenu, openContextMenuAt, closeContextMenus } = useContextMenu();
@@ -221,8 +221,10 @@ const FileExplorerPage = memo(function FileExplorerPage() {
} }
return joined.startsWith('/') ? joined : `/${joined}`; return joined.startsWith('/') ? joined : `/${joined}`;
}, [entryBasePath]); }, [entryBasePath]);
const showFsPagination = !isSearching && pagination.total > 0; const showFsPagination = !isSearching && pagination.mode === 'paged' && pagination.total > 0;
const showCursorPagination = !isSearching && pagination.mode === 'cursor' && (pagination.cursorHistory.length > 0 || pagination.hasNext);
const shouldReserveBottomBar = showSearchPagination || showFsPagination; const shouldReserveBottomBar = showSearchPagination || showFsPagination;
const shouldReserveAnyBottomBar = shouldReserveBottomBar || showCursorPagination;
const handleDragEnter = (e: React.DragEvent) => { const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
@@ -298,6 +300,7 @@ const FileExplorerPage = memo(function FileExplorerPage() {
viewMode={viewMode} viewMode={viewMode}
sortBy={sortBy} sortBy={sortBy}
sortOrder={sortOrder} sortOrder={sortOrder}
paginationMode={pagination.mode}
isMobile={isMobile} isMobile={isMobile}
onGoUp={goUp} onGoUp={goUp}
onNavigate={navigateTo} onNavigate={navigateTo}
@@ -325,7 +328,7 @@ const FileExplorerPage = memo(function FileExplorerPage() {
onChange={handleDirectoryInputChange} onChange={handleDirectoryInputChange}
/> />
<div style={{ flex: 1, overflow: 'auto', minHeight: 0, paddingBottom: shouldReserveBottomBar ? '80px' : '0' }} onContextMenu={isMobile ? undefined : openBlankContextMenu}> <div style={{ flex: 1, overflow: 'auto', minHeight: 0, paddingBottom: shouldReserveAnyBottomBar ? '80px' : '0' }} onContextMenu={isMobile ? undefined : openBlankContextMenu}>
{isSearching ? ( {isSearching ? (
<SearchResultsView <SearchResultsView
viewMode={viewMode} viewMode={viewMode}
@@ -380,6 +383,19 @@ const FileExplorerPage = memo(function FileExplorerPage() {
</div> </div>
)} )}
{showCursorPagination && (
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, padding: '12px 16px', background: token.colorBgContainer, borderTop: `1px solid ${token.colorBorderSecondary}`, textAlign: 'center', zIndex: 10 }}>
<Space>
<Button size="small" onClick={goCursorPrev} disabled={pagination.cursorHistory.length === 0 || loading}>
</Button>
<Button size="small" type="primary" onClick={goCursorNext} disabled={!pagination.hasNext || loading}>
</Button>
</Space>
</div>
)}
{showSearchPagination && ( {showSearchPagination && (
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, padding: '12px 16px', background: token.colorBgContainer, borderTop: `1px solid ${token.colorBorderSecondary}`, textAlign: 'center', zIndex: 10 }}> <div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, padding: '12px 16px', background: token.colorBgContainer, borderTop: `1px solid ${token.colorBorderSecondary}`, textAlign: 'center', zIndex: 10 }}>
<Pagination <Pagination

View File

@@ -12,6 +12,7 @@ interface HeaderProps {
viewMode: ViewMode; viewMode: ViewMode;
sortBy: string; sortBy: string;
sortOrder: string; sortOrder: string;
paginationMode?: 'paged' | 'cursor';
isMobile?: boolean; isMobile?: boolean;
onGoUp: () => void; onGoUp: () => void;
onNavigate: (path: string) => void; onNavigate: (path: string) => void;
@@ -30,6 +31,7 @@ export const Header: React.FC<HeaderProps> = ({
viewMode, viewMode,
sortBy, sortBy,
sortOrder, sortOrder,
paginationMode = 'paged',
isMobile = false, isMobile = false,
onGoUp, onGoUp,
onNavigate, onNavigate,
@@ -82,6 +84,7 @@ export const Header: React.FC<HeaderProps> = ({
setEditingPath(false); setEditingPath(false);
setPathInputValue(''); setPathInputValue('');
}; };
const sortDisabled = paginationMode === 'cursor';
const renderBreadcrumb = () => { const renderBreadcrumb = () => {
if (editingPath) { if (editingPath) {
@@ -154,6 +157,7 @@ export const Header: React.FC<HeaderProps> = ({
{ {
key: 'sort', key: 'sort',
label: t('Sort By') + `: ${t(sortBy === 'mtime' ? 'Modified Time' : sortBy === 'size' ? 'Size' : 'Name')}`, label: t('Sort By') + `: ${t(sortBy === 'mtime' ? 'Modified Time' : sortBy === 'size' ? 'Size' : 'Name')}`,
disabled: sortDisabled,
children: [ children: [
{ key: 'sort-name', label: t('Name'), onClick: () => onSortChange('name', sortOrder) }, { key: 'sort-name', label: t('Name'), onClick: () => onSortChange('name', sortOrder) },
{ key: 'sort-size', label: t('Size'), onClick: () => onSortChange('size', sortOrder) }, { key: 'sort-size', label: t('Size'), onClick: () => onSortChange('size', sortOrder) },
@@ -164,6 +168,7 @@ export const Header: React.FC<HeaderProps> = ({
key: 'sort-order', key: 'sort-order',
label: sortOrder === 'asc' ? t('Ascending') : t('Descending'), label: sortOrder === 'asc' ? t('Ascending') : t('Descending'),
icon: sortOrder === 'asc' ? <ArrowUpOutlined /> : <ArrowDownOutlined />, icon: sortOrder === 'asc' ? <ArrowUpOutlined /> : <ArrowDownOutlined />,
disabled: sortDisabled,
onClick: () => onSortChange(sortBy, sortOrder === 'asc' ? 'desc' : 'asc'), onClick: () => onSortChange(sortBy, sortOrder === 'asc' ? 'desc' : 'asc'),
}, },
]; ];
@@ -230,6 +235,7 @@ export const Header: React.FC<HeaderProps> = ({
<Select <Select
size="small" size="small"
value={sortBy} value={sortBy}
disabled={sortDisabled}
onChange={(val) => onSortChange(val, sortOrder)} onChange={(val) => onSortChange(val, sortOrder)}
style={{ width: 112 }} style={{ width: 112 }}
options={[ options={[
@@ -240,6 +246,7 @@ export const Header: React.FC<HeaderProps> = ({
/> />
<Button <Button
size="small" size="small"
disabled={sortDisabled}
icon={sortOrder === 'asc' ? <ArrowUpOutlined /> : <ArrowDownOutlined />} icon={sortOrder === 'asc' ? <ArrowUpOutlined /> : <ArrowDownOutlined />}
onClick={() => onSortChange(sortBy, sortOrder === 'asc' ? 'desc' : 'asc')} onClick={() => onSortChange(sortBy, sortOrder === 'asc' ? 'desc' : 'asc')}
/> />

View File

@@ -7,10 +7,14 @@ type ExplorerSnapshot = {
path: string; path: string;
entries: VfsEntry[]; entries: VfsEntry[];
pagination?: { pagination?: {
total: number; mode?: 'paged' | 'cursor';
page: number;
page_size: number; page_size: number;
pages: number; total?: number;
page?: number;
pages?: number;
cursor?: string | null;
next_cursor?: string | null;
has_next?: boolean;
}; };
sortBy: string; sortBy: string;
sortOrder: string; sortOrder: string;
@@ -30,6 +34,11 @@ export function useFileExplorer(navKey: string) {
current: 1, current: 1,
pageSize: 50, pageSize: 50,
total: 0, total: 0,
mode: 'paged' as 'paged' | 'cursor',
cursor: null as string | null,
nextCursor: null as string | null,
cursorHistory: [] as (string | null)[],
hasNext: false,
showSizeChanger: true, showSizeChanger: true,
showQuickJumper: true, showQuickJumper: true,
showTotal: (total: number, range: [number, number]) => `${total} ${'items'} ${range[0]}-${range[1]}`, showTotal: (total: number, range: [number, number]) => `${total} ${'items'} ${range[0]}-${range[1]}`,
@@ -38,23 +47,29 @@ export function useFileExplorer(navKey: string) {
const [sortBy, setSortBy] = useState('name'); const [sortBy, setSortBy] = useState('name');
const [sortOrder, setSortOrder] = useState('asc'); const [sortOrder, setSortOrder] = useState('asc');
const load = useCallback(async (p: string, page: number = 1, pageSize: number = 50, sb = sortBy, so = sortOrder) => { const load = useCallback(async (p: string, page: number = 1, pageSize: number = 50, sb = sortBy, so = sortOrder, cursor?: string | null, cursorHistory: (string | null)[] = []) => {
const canonical = p === '' ? '/' : (p.startsWith('/') ? p : '/' + p); const canonical = p === '' ? '/' : (p.startsWith('/') ? p : '/' + p);
setLoading(true); setLoading(true);
try { try {
// Load entries and processor types concurrently // Load entries and processor types concurrently
const [res, processors] = await Promise.all([ const [res, processors] = await Promise.all([
vfsApi.list(canonical === '/' ? '' : canonical, page, pageSize, sb, so), vfsApi.list(canonical === '/' ? '' : canonical, page, pageSize, sb, so, cursor),
processorsApi.list() processorsApi.list()
]); ]);
setEntries(res.entries); setEntries(res.entries);
const resolvedPath = res.path || canonical; const resolvedPath = res.path || canonical;
setPath(resolvedPath); setPath(resolvedPath);
const pageMode = res.pagination?.mode || 'paged';
setPagination(prev => ({ setPagination(prev => ({
...prev, ...prev,
current: res.pagination!.page, mode: pageMode,
pageSize: res.pagination!.page_size, current: res.pagination?.page || page,
total: res.pagination!.total pageSize: res.pagination?.page_size || pageSize,
total: res.pagination?.total || 0,
cursor: res.pagination?.cursor || null,
nextCursor: res.pagination?.next_cursor || null,
hasNext: Boolean(res.pagination?.has_next),
cursorHistory: pageMode === 'cursor' ? cursorHistory : [],
})); }));
setProcessorTypes(processors); setProcessorTypes(processors);
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
@@ -94,8 +109,31 @@ export function useFileExplorer(navKey: string) {
load(path, page, pageSize, sortBy, sortOrder); load(path, page, pageSize, sortBy, sortOrder);
}; };
const goCursorNext = () => {
if (!pagination.nextCursor) return;
load(path, 1, pagination.pageSize, sortBy, sortOrder, pagination.nextCursor, [
...pagination.cursorHistory,
pagination.cursor,
]);
};
const goCursorPrev = () => {
if (pagination.cursorHistory.length === 0) return;
const nextHistory = pagination.cursorHistory.slice(0, -1);
const prevCursor = pagination.cursorHistory[pagination.cursorHistory.length - 1];
load(path, 1, pagination.pageSize, sortBy, sortOrder, prevCursor, nextHistory);
};
const refresh = () => { const refresh = () => {
load(path, pagination.current, pagination.pageSize, sortBy, sortOrder); load(
path,
pagination.current,
pagination.pageSize,
sortBy,
sortOrder,
pagination.mode === 'cursor' ? pagination.cursor : null,
pagination.mode === 'cursor' ? pagination.cursorHistory : [],
);
} }
const handleSortChange = (sb: string, so: string) => { const handleSortChange = (sb: string, so: string) => {
@@ -117,6 +155,8 @@ export function useFileExplorer(navKey: string) {
goUp, goUp,
handlePaginationChange, handlePaginationChange,
refresh, refresh,
handleSortChange handleSortChange,
goCursorNext,
goCursorPrev,
}; };
} }