mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-06-08 17:09:59 +08:00
feat: implement cursor-based pagination across various components and APIs
This commit is contained in:
@@ -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):
|
||||||
|
|||||||
@@ -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]): ...
|
||||||
|
|||||||
@@ -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:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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')}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user