mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-07-03 05:12:17 +08:00
Compare commits
1 Commits
v2.2.2
...
codex/add-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c157f1573b |
118
.github/workflows/release-drafter.yml
vendored
118
.github/workflows/release-drafter.yml
vendored
@@ -1,9 +1,6 @@
|
|||||||
name: Release Drafter
|
name: Release Drafter
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -13,119 +10,8 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- id: drafter
|
- uses: release-drafter/release-drafter@v6
|
||||||
uses: release-drafter/release-drafter@v6
|
|
||||||
with:
|
with:
|
||||||
config-name: release-drafter.yml
|
config-name: release-drafter.yml
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Add direct commits
|
|
||||||
if: steps.drafter.outputs.id != ''
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
RELEASE_ID: ${{ steps.drafter.outputs.id }}
|
|
||||||
HEAD_SHA: ${{ github.sha }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
latest_tag="$(gh api "repos/${GITHUB_REPOSITORY}/releases/latest" --jq '.tag_name' 2>/dev/null || true)"
|
|
||||||
|
|
||||||
if [ -n "$latest_tag" ]; then
|
|
||||||
commits_json="$(gh api "repos/${GITHUB_REPOSITORY}/compare/${latest_tag}...${HEAD_SHA}" --jq '.commits')"
|
|
||||||
else
|
|
||||||
commits_json="$(gh api "repos/${GITHUB_REPOSITORY}/commits?sha=${HEAD_SHA}&per_page=100")"
|
|
||||||
fi
|
|
||||||
|
|
||||||
direct_commits="$(mktemp)"
|
|
||||||
printf '%s\n' "$commits_json" \
|
|
||||||
| jq -r '.[] | [.sha, (.commit.message | split("\n")[0]), (.author.login // .commit.author.name)] | @tsv' \
|
|
||||||
> "$direct_commits"
|
|
||||||
|
|
||||||
features=()
|
|
||||||
fixes=()
|
|
||||||
refactors=()
|
|
||||||
docs=()
|
|
||||||
maintenance=()
|
|
||||||
|
|
||||||
while IFS=$'\t' read -r sha subject author; do
|
|
||||||
if [[ -z "$sha" || "$subject" =~ ^Merge[[:space:]] ]]; then
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
prs="$(gh api \
|
|
||||||
-H "Accept: application/vnd.github+json" \
|
|
||||||
"repos/${GITHUB_REPOSITORY}/commits/${sha}/pulls" \
|
|
||||||
--jq 'length')"
|
|
||||||
|
|
||||||
if [ "$prs" -gt 0 ]; then
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
short_sha="${sha:0:7}"
|
|
||||||
line="- ${short_sha} ${subject} @${author}"
|
|
||||||
type="${subject%%:*}"
|
|
||||||
type="${type%%(*}"
|
|
||||||
type="${type%!}"
|
|
||||||
|
|
||||||
if [ "$type" = "feat" ]; then
|
|
||||||
features+=("$line")
|
|
||||||
elif [ "$type" = "fix" ]; then
|
|
||||||
fixes+=("$line")
|
|
||||||
elif [ "$type" = "refactor" ]; then
|
|
||||||
refactors+=("$line")
|
|
||||||
elif [ "$type" = "docs" ]; then
|
|
||||||
docs+=("$line")
|
|
||||||
elif [[ "$type" = "chore" || "$type" = "ci" || "$type" = "build" ]]; then
|
|
||||||
maintenance+=("$line")
|
|
||||||
fi
|
|
||||||
done < "$direct_commits"
|
|
||||||
|
|
||||||
direct_notes="$(mktemp)"
|
|
||||||
{
|
|
||||||
echo "## Direct Commits"
|
|
||||||
echo
|
|
||||||
|
|
||||||
if [ "${#features[@]}" -gt 0 ]; then
|
|
||||||
echo "### 🚀 Features"
|
|
||||||
printf '%s\n' "${features[@]}"
|
|
||||||
echo
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "${#fixes[@]}" -gt 0 ]; then
|
|
||||||
echo "### 🐛 Bug Fixes"
|
|
||||||
printf '%s\n' "${fixes[@]}"
|
|
||||||
echo
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "${#refactors[@]}" -gt 0 ]; then
|
|
||||||
echo "### 📦 Code Refactoring"
|
|
||||||
printf '%s\n' "${refactors[@]}"
|
|
||||||
echo
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "${#docs[@]}" -gt 0 ]; then
|
|
||||||
echo "### 📄 Documentation"
|
|
||||||
printf '%s\n' "${docs[@]}"
|
|
||||||
echo
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "${#maintenance[@]}" -gt 0 ]; then
|
|
||||||
echo "### 🧰 Maintenance"
|
|
||||||
printf '%s\n' "${maintenance[@]}"
|
|
||||||
echo
|
|
||||||
fi
|
|
||||||
} > "$direct_notes"
|
|
||||||
|
|
||||||
if [ "$(wc -l < "$direct_notes")" -le 2 ]; then
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
body="$(gh api "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" --jq '.body')"
|
|
||||||
body_without_direct="$(printf '%s\n' "$body" | sed '/^## Direct Commits$/,$d')"
|
|
||||||
new_body="$(printf '%s\n\n%s\n' "$body_without_direct" "$(cat "$direct_notes")")"
|
|
||||||
|
|
||||||
gh api \
|
|
||||||
--method PATCH \
|
|
||||||
"repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" \
|
|
||||||
-f body="$new_body"
|
|
||||||
@@ -9,25 +9,7 @@ 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, "pagination_mode": "paged"}
|
return {"items": items, "total": total, "page": page, "page_size": page_size, "pages": pages}
|
||||||
|
|
||||||
|
|
||||||
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):
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ from domain.backup import api as backup
|
|||||||
from domain.config import api as config
|
from domain.config import api as config
|
||||||
from domain.email import api as email
|
from domain.email import api as email
|
||||||
from domain.offline_downloads import api as offline_downloads
|
from domain.offline_downloads import api as offline_downloads
|
||||||
from domain.notices import api as notices
|
|
||||||
from domain.plugins import api as plugins
|
from domain.plugins import api as plugins
|
||||||
from domain.processors import api as processors
|
from domain.processors import api as processors
|
||||||
from domain.share import api as share
|
from domain.share import api as share
|
||||||
@@ -42,7 +41,6 @@ def include_routers(app: FastAPI):
|
|||||||
app.include_router(webdav_api.router)
|
app.include_router(webdav_api.router)
|
||||||
app.include_router(s3_api.router)
|
app.include_router(s3_api.router)
|
||||||
app.include_router(offline_downloads.router)
|
app.include_router(offline_downloads.router)
|
||||||
app.include_router(notices.router)
|
|
||||||
app.include_router(email.router)
|
app.include_router(email.router)
|
||||||
app.include_router(audit.router)
|
app.include_router(audit.router)
|
||||||
app.include_router(permission.router)
|
app.include_router(permission.router)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from typing import List, Dict, Protocol, runtime_checkable, Tuple, AsyncIterator, Any
|
from typing import List, Dict, Protocol, runtime_checkable, Tuple, AsyncIterator
|
||||||
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", cursor: str | None = None) -> Tuple[List[Dict], int] | Dict[str, Any]: ...
|
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 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,7 +4,6 @@ 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"
|
||||||
@@ -115,51 +114,65 @@ class OneDriveAdapter:
|
|||||||
"type": "dir" if is_dir else "file",
|
"type": "dir" if is_dir else "file",
|
||||||
}
|
}
|
||||||
|
|
||||||
async def list_dir(
|
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]:
|
||||||
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 不提供目录总数,使用 nextLink 游标分页。
|
由于 Graph API 不支持基于偏移($skip)的分页,此方法将获取所有项目,
|
||||||
: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: 排序顺序
|
||||||
:param cursor: Graph nextLink。
|
:return: 文件/目录列表和总数。
|
||||||
:return: 游标分页结果。
|
|
||||||
"""
|
"""
|
||||||
if cursor:
|
api_path = self._get_api_path(rel)
|
||||||
resp = await self._request("GET", full_url=cursor)
|
children_path = f"{api_path}:/children" if api_path else "/children"
|
||||||
else:
|
all_items = []
|
||||||
api_path = self._get_api_path(rel)
|
params = {"$top": 999}
|
||||||
children_path = f"{api_path}:/children" if api_path else "/children"
|
resp = await self._request("GET", api_path_segment=children_path, params=params)
|
||||||
resp = await self._request("GET", api_path_segment=children_path, params={"$top": page_size})
|
|
||||||
|
|
||||||
if resp.status_code == 404:
|
while True:
|
||||||
return cursor_page([], page_size, cursor=cursor)
|
if resp.status_code == 404 and not all_items:
|
||||||
resp.raise_for_status()
|
return [], 0
|
||||||
|
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
|
||||||
|
|
||||||
formatted_items = [self._format_item(item) for item in data.get("value", [])]
|
all_items.extend(data.get("value", []))
|
||||||
return cursor_page(
|
next_link = data.get("@odata.nextLink")
|
||||||
formatted_items,
|
|
||||||
page_size,
|
if not next_link:
|
||||||
cursor=cursor,
|
break
|
||||||
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:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
from typing import List, Dict, Tuple, AsyncIterator, Optional
|
from typing import List, Dict, Tuple, AsyncIterator, Optional
|
||||||
import asyncio
|
|
||||||
import base64
|
import base64
|
||||||
import io
|
import io
|
||||||
import os
|
import os
|
||||||
import struct
|
import struct
|
||||||
import time
|
|
||||||
from models import StorageAdapter
|
from models import StorageAdapter
|
||||||
from api.response import cursor_page
|
from telethon import TelegramClient, 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
|
||||||
from telethon.tl import types
|
from telethon.tl import types
|
||||||
@@ -54,9 +51,6 @@ CONFIG_SCHEMA = [
|
|||||||
class TelegramAdapter:
|
class TelegramAdapter:
|
||||||
"""Telegram 存储适配器 (使用用户 Session)"""
|
"""Telegram 存储适配器 (使用用户 Session)"""
|
||||||
native_video_thumbnail_only = True
|
native_video_thumbnail_only = True
|
||||||
_message_cache_ttl = 300
|
|
||||||
_message_cache_limit = 200
|
|
||||||
_download_chunk_size = 512 * 1024
|
|
||||||
|
|
||||||
def __init__(self, record: StorageAdapter):
|
def __init__(self, record: StorageAdapter):
|
||||||
self.record = record
|
self.record = record
|
||||||
@@ -89,12 +83,6 @@ class TelegramAdapter:
|
|||||||
if not all([self.api_id, self.api_hash, self.session_string, self.chat_id]):
|
if not all([self.api_id, self.api_hash, self.session_string, self.chat_id]):
|
||||||
raise ValueError("Telegram 适配器需要 api_id, api_hash, session_string 和 chat_id")
|
raise ValueError("Telegram 适配器需要 api_id, api_hash, session_string 和 chat_id")
|
||||||
|
|
||||||
self._client: TelegramClient | None = None
|
|
||||||
self._client_lock = asyncio.Lock()
|
|
||||||
self._download_lock = asyncio.Lock()
|
|
||||||
self._active_stream_message_id: int | None = None
|
|
||||||
self._message_cache: Dict[int, Tuple[float, object]] = {}
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _parse_legacy_session_string(value: str) -> StringSession:
|
def _parse_legacy_session_string(value: str) -> StringSession:
|
||||||
"""
|
"""
|
||||||
@@ -196,80 +184,6 @@ class TelegramAdapter:
|
|||||||
"""创建一个新的 TelegramClient 实例"""
|
"""创建一个新的 TelegramClient 实例"""
|
||||||
return TelegramClient(self._build_session(), self.api_id, self.api_hash, proxy=self.proxy)
|
return TelegramClient(self._build_session(), self.api_id, self.api_hash, proxy=self.proxy)
|
||||||
|
|
||||||
async def _get_connected_client(self) -> TelegramClient:
|
|
||||||
async with self._client_lock:
|
|
||||||
if self._client is None:
|
|
||||||
self._client = self._get_client()
|
|
||||||
if not self._client.is_connected():
|
|
||||||
await self._client.connect()
|
|
||||||
return self._client
|
|
||||||
|
|
||||||
async def _disconnect_shared_client(self):
|
|
||||||
if self._client and self._client.is_connected():
|
|
||||||
await self._client.disconnect()
|
|
||||||
|
|
||||||
def _clear_message_cache(self):
|
|
||||||
self._message_cache.clear()
|
|
||||||
|
|
||||||
async def _get_cached_message(self, message_id: int):
|
|
||||||
now = time.monotonic()
|
|
||||||
cached = self._message_cache.get(message_id)
|
|
||||||
if cached and cached[0] > now:
|
|
||||||
return cached[1]
|
|
||||||
|
|
||||||
client = await self._get_connected_client()
|
|
||||||
message = await client.get_messages(self.chat_id, ids=message_id)
|
|
||||||
if message:
|
|
||||||
if len(self._message_cache) >= self._message_cache_limit:
|
|
||||||
oldest_key = min(self._message_cache, key=lambda k: self._message_cache[k][0])
|
|
||||||
self._message_cache.pop(oldest_key, None)
|
|
||||||
self._message_cache[message_id] = (now + self._message_cache_ttl, message)
|
|
||||||
else:
|
|
||||||
self._message_cache.pop(message_id, None)
|
|
||||||
return message
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _get_message_media(message):
|
|
||||||
return message.document or message.video or message.photo
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _flood_wait_http_exception(exc: errors.FloodWaitError):
|
|
||||||
from fastapi import HTTPException
|
|
||||||
|
|
||||||
seconds = int(getattr(exc, "seconds", 0) or 0)
|
|
||||||
if seconds > 0:
|
|
||||||
return HTTPException(
|
|
||||||
status_code=429,
|
|
||||||
detail=f"Telegram 请求过于频繁,请等待 {seconds} 秒后重试",
|
|
||||||
headers={"Retry-After": str(seconds)},
|
|
||||||
)
|
|
||||||
return HTTPException(status_code=429, detail="Telegram 请求过于频繁,请稍后重试")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _get_message_file_size(message, media) -> int:
|
|
||||||
file_meta = message.file
|
|
||||||
size = file_meta.size if file_meta and file_meta.size is not None else None
|
|
||||||
if size is None:
|
|
||||||
if hasattr(media, "size") and media.size is not None:
|
|
||||||
size = media.size
|
|
||||||
elif message.photo and getattr(message.photo, "sizes", None):
|
|
||||||
photo_size = message.photo.sizes[-1]
|
|
||||||
size = getattr(photo_size, "size", 0) or 0
|
|
||||||
else:
|
|
||||||
size = 0
|
|
||||||
return int(size or 0)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _get_message_mime_type(message, media) -> str:
|
|
||||||
file_meta = message.file
|
|
||||||
if file_meta and getattr(file_meta, "mime_type", None):
|
|
||||||
return file_meta.mime_type
|
|
||||||
if hasattr(media, "mime_type") and media.mime_type:
|
|
||||||
return media.mime_type
|
|
||||||
if message.photo:
|
|
||||||
return "image/jpeg"
|
|
||||||
return "application/octet-stream"
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _parse_message_id(rel: str) -> int:
|
def _parse_message_id(rel: str) -> int:
|
||||||
try:
|
try:
|
||||||
@@ -281,144 +195,141 @@ 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(
|
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]:
|
||||||
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 cursor_page([], page_size, cursor=cursor)
|
return [], 0
|
||||||
|
|
||||||
client = self._get_client()
|
client = self._get_client()
|
||||||
entries = []
|
entries = []
|
||||||
next_cursor = None
|
|
||||||
try:
|
try:
|
||||||
await client.connect()
|
await client.connect()
|
||||||
offset_id = int(cursor) if cursor else 0
|
messages = await client.get_messages(self.chat_id, limit=200)
|
||||||
batch_limit = min(max(page_size, 50), 200)
|
for message in messages:
|
||||||
while len(entries) < page_size:
|
if not message:
|
||||||
messages = await client.get_messages(self.chat_id, limit=batch_limit, offset_id=offset_id)
|
continue
|
||||||
if not messages:
|
|
||||||
next_cursor = None
|
|
||||||
break
|
|
||||||
|
|
||||||
offset_id = messages[-1].id
|
media = message.document or message.video or message.photo
|
||||||
next_cursor = str(offset_id)
|
if not media:
|
||||||
for message in messages:
|
continue
|
||||||
if not message:
|
|
||||||
continue
|
|
||||||
|
|
||||||
media = message.document or message.video or message.photo
|
file_meta = message.file
|
||||||
if not media:
|
if not file_meta:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
file_meta = message.file
|
filename = file_meta.name
|
||||||
if not file_meta:
|
if not filename:
|
||||||
continue
|
if message.text and '.' in message.text and len(message.text) < 256 and '\n' not in message.text:
|
||||||
|
filename = message.text
|
||||||
|
else:
|
||||||
|
filename = f"unknown_{message.id}"
|
||||||
|
|
||||||
filename = file_meta.name
|
size = file_meta.size
|
||||||
if not filename:
|
if size is None:
|
||||||
if message.text and '.' in message.text and len(message.text) < 256 and '\n' not in message.text:
|
# 兼容缺失 size 的情况
|
||||||
filename = message.text
|
if hasattr(media, "size") and media.size is not None:
|
||||||
else:
|
size = media.size
|
||||||
filename = f"unknown_{message.id}"
|
elif message.photo and getattr(message.photo, "sizes", None):
|
||||||
|
photo_size = message.photo.sizes[-1]
|
||||||
|
size = getattr(photo_size, "size", 0) or 0
|
||||||
|
else:
|
||||||
|
size = 0
|
||||||
|
|
||||||
size = file_meta.size
|
entries.append({
|
||||||
if size is None:
|
"name": f"{message.id}_{filename}",
|
||||||
# 兼容缺失 size 的情况
|
"is_dir": False,
|
||||||
if hasattr(media, "size") and media.size is not None:
|
"size": size,
|
||||||
size = media.size
|
"mtime": int(message.date.timestamp()),
|
||||||
elif message.photo and getattr(message.photo, "sizes", None):
|
"type": "file",
|
||||||
photo_size = message.photo.sizes[-1]
|
"has_thumbnail": self._message_has_thumbnail(message),
|
||||||
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)
|
||||||
|
|
||||||
client = await self._get_connected_client()
|
client = self._get_client()
|
||||||
message = await self._get_cached_message(message_id)
|
|
||||||
if not message or not self._get_message_media(message):
|
|
||||||
raise FileNotFoundError(f"在频道 {self.chat_id} 中未找到消息ID为 {message_id} 的文件")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with self._download_lock:
|
await client.connect()
|
||||||
file_bytes = await client.download_media(message, file=bytes)
|
message = await client.get_messages(self.chat_id, ids=message_id)
|
||||||
return file_bytes
|
if not message or not (message.document or message.video or message.photo):
|
||||||
except errors.FloodWaitError as exc:
|
raise FileNotFoundError(f"在频道 {self.chat_id} 中未找到消息ID为 {message_id} 的文件")
|
||||||
await self._disconnect_shared_client()
|
|
||||||
raise self._flood_wait_http_exception(exc)
|
file_bytes = await client.download_media(message, file=bytes)
|
||||||
|
return file_bytes
|
||||||
|
finally:
|
||||||
|
if client.is_connected():
|
||||||
|
await client.disconnect()
|
||||||
|
|
||||||
async def read_file_range(self, root: str, rel: str, start: int, end: Optional[int] = None) -> bytes:
|
async def read_file_range(self, root: str, rel: str, start: int, end: Optional[int] = None) -> bytes:
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
message_id = self._parse_message_id(rel)
|
message_id = self._parse_message_id(rel)
|
||||||
client = await self._get_connected_client()
|
client = self._get_client()
|
||||||
message = await self._get_cached_message(message_id)
|
|
||||||
if not message:
|
|
||||||
raise FileNotFoundError(f"在频道 {self.chat_id} 中未找到消息ID为 {message_id} 的文件")
|
|
||||||
|
|
||||||
media = self._get_message_media(message)
|
|
||||||
if not media:
|
|
||||||
raise FileNotFoundError(f"在频道 {self.chat_id} 中未找到消息ID为 {message_id} 的文件")
|
|
||||||
|
|
||||||
file_size = self._get_message_file_size(message, media)
|
|
||||||
if file_size > 0:
|
|
||||||
if start >= file_size:
|
|
||||||
raise HTTPException(status_code=416, detail="Requested Range Not Satisfiable")
|
|
||||||
if end is None or end >= file_size:
|
|
||||||
end = file_size - 1
|
|
||||||
elif end is None:
|
|
||||||
end = start
|
|
||||||
|
|
||||||
if end < start:
|
|
||||||
raise HTTPException(status_code=416, detail="Requested Range Not Satisfiable")
|
|
||||||
|
|
||||||
limit = end - start + 1
|
|
||||||
data = bytearray()
|
|
||||||
try:
|
try:
|
||||||
async with self._download_lock:
|
await client.connect()
|
||||||
async for chunk in client.iter_download(
|
message = await client.get_messages(self.chat_id, ids=message_id)
|
||||||
media,
|
if not message:
|
||||||
offset=start,
|
raise FileNotFoundError(f"在频道 {self.chat_id} 中未找到消息ID为 {message_id} 的文件")
|
||||||
request_size=self._download_chunk_size,
|
|
||||||
chunk_size=self._download_chunk_size,
|
media = message.document or message.video or message.photo
|
||||||
file_size=file_size or None,
|
if not media:
|
||||||
):
|
raise FileNotFoundError(f"在频道 {self.chat_id} 中未找到消息ID为 {message_id} 的文件")
|
||||||
if not chunk:
|
|
||||||
continue
|
file_meta = message.file
|
||||||
need = limit - len(data)
|
file_size = file_meta.size if file_meta and file_meta.size is not None else getattr(media, "size", 0) or 0
|
||||||
if need <= 0:
|
if file_size > 0:
|
||||||
break
|
if start >= file_size:
|
||||||
data.extend(chunk[:need])
|
raise HTTPException(status_code=416, detail="Requested Range Not Satisfiable")
|
||||||
if len(data) >= limit:
|
if end is None or end >= file_size:
|
||||||
break
|
end = file_size - 1
|
||||||
|
elif end is None:
|
||||||
|
end = start
|
||||||
|
|
||||||
|
if end < start:
|
||||||
|
raise HTTPException(status_code=416, detail="Requested Range Not Satisfiable")
|
||||||
|
|
||||||
|
limit = end - start + 1
|
||||||
|
data = bytearray()
|
||||||
|
async for chunk in client.iter_download(media, offset=start):
|
||||||
|
if not chunk:
|
||||||
|
continue
|
||||||
|
need = limit - len(data)
|
||||||
|
if need <= 0:
|
||||||
|
break
|
||||||
|
data.extend(chunk[:need])
|
||||||
|
if len(data) >= limit:
|
||||||
|
break
|
||||||
return bytes(data)
|
return bytes(data)
|
||||||
except errors.FloodWaitError as exc:
|
finally:
|
||||||
await self._disconnect_shared_client()
|
if client.is_connected():
|
||||||
raise self._flood_wait_http_exception(exc)
|
await client.disconnect()
|
||||||
|
|
||||||
async def write_file(self, root: str, rel: str, data: bytes):
|
async def write_file(self, root: str, rel: str, data: bytes):
|
||||||
"""将字节数据作为文件上传"""
|
"""将字节数据作为文件上传"""
|
||||||
@@ -438,7 +349,6 @@ class TelegramAdapter:
|
|||||||
stored_name = file_meta.name
|
stored_name = file_meta.name
|
||||||
if getattr(message, "id", None) is not None:
|
if getattr(message, "id", None) is not None:
|
||||||
actual_rel = f"{message.id}_{stored_name}"
|
actual_rel = f"{message.id}_{stored_name}"
|
||||||
self._clear_message_cache()
|
|
||||||
return {"rel": actual_rel, "size": len(data)}
|
return {"rel": actual_rel, "size": len(data)}
|
||||||
finally:
|
finally:
|
||||||
if client.is_connected():
|
if client.is_connected():
|
||||||
@@ -468,7 +378,6 @@ class TelegramAdapter:
|
|||||||
stored_name = file_meta.name
|
stored_name = file_meta.name
|
||||||
if getattr(message, "id", None) is not None:
|
if getattr(message, "id", None) is not None:
|
||||||
actual_rel = f"{message.id}_{stored_name}"
|
actual_rel = f"{message.id}_{stored_name}"
|
||||||
self._clear_message_cache()
|
|
||||||
if file_meta and getattr(file_meta, "size", None):
|
if file_meta and getattr(file_meta, "size", None):
|
||||||
size = int(file_meta.size)
|
size = int(file_meta.size)
|
||||||
return {"rel": actual_rel, "size": size}
|
return {"rel": actual_rel, "size": size}
|
||||||
@@ -504,7 +413,6 @@ class TelegramAdapter:
|
|||||||
stored_name = file_meta.name
|
stored_name = file_meta.name
|
||||||
if getattr(message, "id", None) is not None:
|
if getattr(message, "id", None) is not None:
|
||||||
actual_rel = f"{message.id}_{stored_name}"
|
actual_rel = f"{message.id}_{stored_name}"
|
||||||
self._clear_message_cache()
|
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
if os.path.exists(temp_path):
|
if os.path.exists(temp_path):
|
||||||
@@ -517,7 +425,38 @@ class TelegramAdapter:
|
|||||||
raise NotImplementedError("Telegram 适配器不支持创建目录。")
|
raise NotImplementedError("Telegram 适配器不支持创建目录。")
|
||||||
|
|
||||||
async def get_thumbnail(self, root: str, rel: str, size: str = "medium"):
|
async def get_thumbnail(self, root: str, rel: str, size: str = "medium"):
|
||||||
return None
|
try:
|
||||||
|
message_id_str, _ = rel.split('_', 1)
|
||||||
|
message_id = int(message_id_str)
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
client = self._get_client()
|
||||||
|
try:
|
||||||
|
await client.connect()
|
||||||
|
message = await client.get_messages(self.chat_id, ids=message_id)
|
||||||
|
if not message:
|
||||||
|
return None
|
||||||
|
|
||||||
|
thumb = self._pick_photo_thumb(self._get_message_thumbs(message))
|
||||||
|
if not thumb:
|
||||||
|
return None
|
||||||
|
|
||||||
|
embedded = getattr(thumb, "bytes", None)
|
||||||
|
if embedded and isinstance(thumb, types.PhotoCachedSize):
|
||||||
|
return bytes(embedded)
|
||||||
|
if embedded and isinstance(thumb, types.PhotoStrippedSize):
|
||||||
|
return utils.stripped_photo_to_jpg(bytes(embedded))
|
||||||
|
|
||||||
|
result = await client.download_media(message, bytes, thumb=thumb)
|
||||||
|
if isinstance(result, (bytes, bytearray)):
|
||||||
|
return bytes(result)
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
if client.is_connected():
|
||||||
|
await client.disconnect()
|
||||||
|
|
||||||
async def delete(self, root: str, rel: str):
|
async def delete(self, root: str, rel: str):
|
||||||
"""删除一个文件 (即一条消息)"""
|
"""删除一个文件 (即一条消息)"""
|
||||||
@@ -533,12 +472,9 @@ class TelegramAdapter:
|
|||||||
result = await client.delete_messages(self.chat_id, [message_id])
|
result = await client.delete_messages(self.chat_id, [message_id])
|
||||||
if not result or not result[0].pts:
|
if not result or not result[0].pts:
|
||||||
raise FileNotFoundError(f"在 {self.chat_id} 中删除消息 {message_id} 失败,可能消息不存在或无权限")
|
raise FileNotFoundError(f"在 {self.chat_id} 中删除消息 {message_id} 失败,可能消息不存在或无权限")
|
||||||
self._message_cache.pop(message_id, None)
|
|
||||||
finally:
|
finally:
|
||||||
if client.is_connected():
|
if client.is_connected():
|
||||||
await client.disconnect()
|
await client.disconnect()
|
||||||
if self._client is client:
|
|
||||||
self._client = None
|
|
||||||
|
|
||||||
async def move(self, root: str, src_rel: str, dst_rel: str):
|
async def move(self, root: str, src_rel: str, dst_rel: str):
|
||||||
raise NotImplementedError("Telegram 适配器不支持移动。")
|
raise NotImplementedError("Telegram 适配器不支持移动。")
|
||||||
@@ -558,17 +494,38 @@ class TelegramAdapter:
|
|||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
raise HTTPException(status_code=400, detail=f"无效的文件路径格式: {rel}")
|
raise HTTPException(status_code=400, detail=f"无效的文件路径格式: {rel}")
|
||||||
|
|
||||||
|
client = self._get_client()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
client = await self._get_connected_client()
|
await client.connect()
|
||||||
message = await self._get_cached_message(message_id)
|
message = await client.get_messages(self.chat_id, ids=message_id)
|
||||||
if not message:
|
if not message:
|
||||||
raise FileNotFoundError(f"在频道 {self.chat_id} 中未找到消息ID为 {message_id} 的文件")
|
raise FileNotFoundError(f"在频道 {self.chat_id} 中未找到消息ID为 {message_id} 的文件")
|
||||||
media = self._get_message_media(message)
|
media = message.document or message.video or message.photo
|
||||||
if not media:
|
if not media:
|
||||||
raise FileNotFoundError(f"在频道 {self.chat_id} 中未找到消息ID为 {message_id} 的文件")
|
raise FileNotFoundError(f"在频道 {self.chat_id} 中未找到消息ID为 {message_id} 的文件")
|
||||||
|
|
||||||
file_size = self._get_message_file_size(message, media)
|
file_meta = message.file
|
||||||
mime_type = self._get_message_mime_type(message, media)
|
file_size = file_meta.size if file_meta and file_meta.size is not None else None
|
||||||
|
if file_size is None:
|
||||||
|
if hasattr(media, "size") and media.size is not None:
|
||||||
|
file_size = media.size
|
||||||
|
elif message.photo and getattr(message.photo, "sizes", None):
|
||||||
|
photo_size = message.photo.sizes[-1]
|
||||||
|
file_size = getattr(photo_size, "size", 0) or 0
|
||||||
|
else:
|
||||||
|
file_size = 0
|
||||||
|
|
||||||
|
mime_type = None
|
||||||
|
if file_meta and getattr(file_meta, "mime_type", None):
|
||||||
|
mime_type = file_meta.mime_type
|
||||||
|
if not mime_type:
|
||||||
|
if hasattr(media, "mime_type") and media.mime_type:
|
||||||
|
mime_type = media.mime_type
|
||||||
|
elif message.photo:
|
||||||
|
mime_type = "image/jpeg"
|
||||||
|
else:
|
||||||
|
mime_type = "application/octet-stream"
|
||||||
|
|
||||||
start = 0
|
start = 0
|
||||||
end = file_size - 1
|
end = file_size - 1
|
||||||
@@ -581,6 +538,8 @@ class TelegramAdapter:
|
|||||||
|
|
||||||
if file_size <= 0:
|
if file_size <= 0:
|
||||||
headers["Content-Length"] = "0"
|
headers["Content-Length"] = "0"
|
||||||
|
if client.is_connected():
|
||||||
|
await client.disconnect()
|
||||||
return StreamingResponse(iter(()), status_code=status, headers=headers)
|
return StreamingResponse(iter(()), status_code=status, headers=headers)
|
||||||
|
|
||||||
if range_header:
|
if range_header:
|
||||||
@@ -597,70 +556,37 @@ class TelegramAdapter:
|
|||||||
raise HTTPException(status_code=400, detail="Invalid Range header")
|
raise HTTPException(status_code=400, detail="Invalid Range header")
|
||||||
|
|
||||||
headers["Content-Length"] = str(end - start + 1)
|
headers["Content-Length"] = str(end - start + 1)
|
||||||
self._active_stream_message_id = message_id
|
|
||||||
|
|
||||||
async def iterator():
|
async def iterator():
|
||||||
downloaded = 0
|
|
||||||
try:
|
try:
|
||||||
limit = end - start + 1
|
limit = end - start + 1
|
||||||
if self._active_stream_message_id != message_id:
|
downloaded = 0
|
||||||
return
|
|
||||||
async with self._download_lock:
|
async for chunk in client.iter_download(media, offset=start):
|
||||||
async for chunk in client.iter_download(
|
if downloaded + len(chunk) > limit:
|
||||||
media,
|
yield chunk[:limit - downloaded]
|
||||||
offset=start,
|
break
|
||||||
request_size=self._download_chunk_size,
|
|
||||||
chunk_size=self._download_chunk_size,
|
|
||||||
file_size=file_size,
|
|
||||||
):
|
|
||||||
if self._active_stream_message_id != message_id:
|
|
||||||
return
|
|
||||||
if not chunk:
|
|
||||||
continue
|
|
||||||
remaining = limit - downloaded
|
|
||||||
if remaining <= 0:
|
|
||||||
break
|
|
||||||
data = chunk[:remaining]
|
|
||||||
downloaded += len(data)
|
|
||||||
yield data
|
|
||||||
if downloaded >= limit:
|
|
||||||
break
|
|
||||||
except errors.FloodWaitError as exc:
|
|
||||||
await self._disconnect_shared_client()
|
|
||||||
if downloaded == 0:
|
|
||||||
raise self._flood_wait_http_exception(exc)
|
|
||||||
seconds = int(getattr(exc, "seconds", 0) or 0)
|
|
||||||
print(f"Telegram streaming stopped by FloodWait after partial response, wait={seconds}s")
|
|
||||||
return
|
|
||||||
except Exception:
|
|
||||||
await self._disconnect_shared_client()
|
|
||||||
raise
|
|
||||||
|
|
||||||
agen = iterator()
|
|
||||||
try:
|
|
||||||
first_chunk = await agen.__anext__()
|
|
||||||
except StopAsyncIteration:
|
|
||||||
first_chunk = b""
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
|
|
||||||
async def response_iterator():
|
|
||||||
try:
|
|
||||||
if first_chunk:
|
|
||||||
yield first_chunk
|
|
||||||
async for chunk in agen:
|
|
||||||
yield chunk
|
yield chunk
|
||||||
|
downloaded += len(chunk)
|
||||||
|
if downloaded >= limit:
|
||||||
|
break
|
||||||
finally:
|
finally:
|
||||||
await agen.aclose()
|
if client.is_connected():
|
||||||
|
await client.disconnect()
|
||||||
|
|
||||||
return StreamingResponse(response_iterator(), status_code=status, headers=headers)
|
return StreamingResponse(iterator(), status_code=status, headers=headers)
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
if client.is_connected():
|
||||||
|
await client.disconnect()
|
||||||
raise
|
raise
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
|
if client.is_connected():
|
||||||
|
await client.disconnect()
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await self._disconnect_shared_client()
|
if client.is_connected():
|
||||||
|
await client.disconnect()
|
||||||
raise HTTPException(status_code=500, detail=f"Streaming failed: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Streaming failed: {str(e)}")
|
||||||
|
|
||||||
async def stat_file(self, root: str, rel: str):
|
async def stat_file(self, root: str, rel: str):
|
||||||
@@ -670,21 +596,36 @@ class TelegramAdapter:
|
|||||||
except (ValueError, IndexError):
|
except (ValueError, IndexError):
|
||||||
raise FileNotFoundError(f"无效的文件路径格式: {rel}")
|
raise FileNotFoundError(f"无效的文件路径格式: {rel}")
|
||||||
|
|
||||||
message = await self._get_cached_message(message_id)
|
client = self._get_client()
|
||||||
media = self._get_message_media(message) if message else None
|
try:
|
||||||
if not message or not media:
|
await client.connect()
|
||||||
raise FileNotFoundError(f"在频道 {self.chat_id} 中未找到消息ID为 {message_id} 的文件")
|
message = await client.get_messages(self.chat_id, ids=message_id)
|
||||||
|
media = message.document or message.video or message.photo
|
||||||
|
if not message or not media:
|
||||||
|
raise FileNotFoundError(f"在频道 {self.chat_id} 中未找到消息ID为 {message_id} 的文件")
|
||||||
|
|
||||||
size = self._get_message_file_size(message, media)
|
file_meta = message.file
|
||||||
|
size = file_meta.size if file_meta and file_meta.size is not None else None
|
||||||
|
if size is None:
|
||||||
|
if hasattr(media, "size") and media.size is not None:
|
||||||
|
size = media.size
|
||||||
|
elif message.photo and getattr(message.photo, "sizes", None):
|
||||||
|
photo_size = message.photo.sizes[-1]
|
||||||
|
size = getattr(photo_size, "size", 0) or 0
|
||||||
|
else:
|
||||||
|
size = 0
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"name": rel,
|
"name": rel,
|
||||||
"is_dir": False,
|
"is_dir": False,
|
||||||
"size": size,
|
"size": size,
|
||||||
"mtime": int(message.date.timestamp()),
|
"mtime": int(message.date.timestamp()),
|
||||||
"type": "file",
|
"type": "file",
|
||||||
"has_thumbnail": False,
|
"has_thumbnail": self._message_has_thumbnail(message),
|
||||||
}
|
}
|
||||||
|
finally:
|
||||||
|
if client.is_connected():
|
||||||
|
await client.disconnect()
|
||||||
|
|
||||||
def ADAPTER_FACTORY(rec: StorageAdapter) -> TelegramAdapter:
|
def ADAPTER_FACTORY(rec: StorageAdapter) -> TelegramAdapter:
|
||||||
return TelegramAdapter(rec)
|
return TelegramAdapter(rec)
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ PUBLIC_CONFIG_KEYS = [
|
|||||||
"THEME_BORDER_RADIUS",
|
"THEME_BORDER_RADIUS",
|
||||||
"THEME_CUSTOM_TOKENS",
|
"THEME_CUSTOM_TOKENS",
|
||||||
"THEME_CUSTOM_CSS",
|
"THEME_CUSTOM_CSS",
|
||||||
"DEFAULT_FILE_VIEW_MODE",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from models.database import Configuration, UserAccount
|
|||||||
|
|
||||||
load_dotenv(dotenv_path=".env")
|
load_dotenv(dotenv_path=".env")
|
||||||
|
|
||||||
VERSION = "v2.2.2"
|
VERSION = "v2.2.1"
|
||||||
|
|
||||||
|
|
||||||
class ConfigService:
|
class ConfigService:
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
from .service import NoticeService, notice_sync_service
|
|
||||||
|
|
||||||
__all__ = ["NoticeService", "notice_sync_service"]
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
from typing import Annotated
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
|
||||||
|
|
||||||
from api.response import success
|
|
||||||
from domain.auth import User, get_current_active_user
|
|
||||||
|
|
||||||
from .service import NoticeService
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/notices", tags=["notices"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
|
||||||
async def list_notices(
|
|
||||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
|
||||||
page: int = Query(1, ge=1),
|
|
||||||
):
|
|
||||||
data = await NoticeService.list_notices(page=page)
|
|
||||||
return data.model_dump()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/popup")
|
|
||||||
async def get_popup_notice(
|
|
||||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
|
||||||
):
|
|
||||||
item = await NoticeService.get_popup_notice()
|
|
||||||
return success(item.model_dump() if item else None)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{notice_id}/dismiss")
|
|
||||||
async def dismiss_popup_notice(
|
|
||||||
notice_id: int,
|
|
||||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
|
||||||
):
|
|
||||||
await NoticeService.dismiss_popup(notice_id)
|
|
||||||
return success()
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from domain.config import VERSION
|
|
||||||
from models.database import Notice
|
|
||||||
|
|
||||||
from .types import NoticeItem, NoticeListResponse
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
REMOTE_NOTICES_URL = "https://foxel.cc/api/notices"
|
|
||||||
SYNC_INTERVAL_SECONDS = 60 * 60 * 24
|
|
||||||
PAGE_SIZE = 20
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_version(version: str) -> str:
|
|
||||||
return (version or "").strip().removeprefix("v").removeprefix("V")
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_remote_time(value: Any) -> datetime:
|
|
||||||
if isinstance(value, (int, float)):
|
|
||||||
timestamp = float(value)
|
|
||||||
if timestamp > 10_000_000_000:
|
|
||||||
timestamp = timestamp / 1000
|
|
||||||
return datetime.fromtimestamp(timestamp, timezone.utc)
|
|
||||||
if isinstance(value, str):
|
|
||||||
text = value.strip()
|
|
||||||
if not text:
|
|
||||||
return datetime.now(timezone.utc)
|
|
||||||
try:
|
|
||||||
if text.isdigit():
|
|
||||||
return _parse_remote_time(int(text))
|
|
||||||
return datetime.fromisoformat(text.replace("Z", "+00:00"))
|
|
||||||
except ValueError:
|
|
||||||
return datetime.now(timezone.utc)
|
|
||||||
return datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
class NoticeService:
|
|
||||||
@classmethod
|
|
||||||
async def list_notices(cls, page: int = 1, page_size: int = PAGE_SIZE) -> NoticeListResponse:
|
|
||||||
page = max(1, page)
|
|
||||||
page_size = max(1, min(page_size, 100))
|
|
||||||
query = Notice.all().order_by("-created_at", "-id")
|
|
||||||
total = await query.count()
|
|
||||||
notices = await query.offset((page - 1) * page_size).limit(page_size)
|
|
||||||
return NoticeListResponse(
|
|
||||||
items=[cls._to_item(item) for item in notices],
|
|
||||||
page=page,
|
|
||||||
pageSize=page_size,
|
|
||||||
total=total,
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def get_popup_notice(cls) -> NoticeItem | None:
|
|
||||||
notice = await Notice.filter(is_popup=True, popup_dismissed=False).order_by("-created_at", "-id").first()
|
|
||||||
if not notice:
|
|
||||||
return None
|
|
||||||
return cls._to_item(notice)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def dismiss_popup(cls, notice_id: int) -> None:
|
|
||||||
await Notice.filter(id=notice_id).update(popup_dismissed=True, is_popup=False)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def sync_remote_notices(cls) -> None:
|
|
||||||
items = await cls._fetch_remote_notices()
|
|
||||||
if not items:
|
|
||||||
return
|
|
||||||
|
|
||||||
popup_remote_ids: list[int] = []
|
|
||||||
for raw in items:
|
|
||||||
remote_id = raw.get("id")
|
|
||||||
if remote_id is None:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
remote_id = int(remote_id)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
continue
|
|
||||||
|
|
||||||
is_popup = bool(raw.get("isPopup"))
|
|
||||||
if is_popup:
|
|
||||||
popup_remote_ids.append(remote_id)
|
|
||||||
|
|
||||||
notice = await Notice.get_or_none(remote_id=remote_id)
|
|
||||||
popup_dismissed = notice.popup_dismissed if notice else False
|
|
||||||
await Notice.update_or_create(
|
|
||||||
remote_id=remote_id,
|
|
||||||
defaults={
|
|
||||||
"title": str(raw.get("title") or "")[:255],
|
|
||||||
"content_md": str(raw.get("contentMd") or ""),
|
|
||||||
"is_popup": is_popup and not popup_dismissed,
|
|
||||||
"created_at": _parse_remote_time(raw.get("createdAt")),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
await cls._keep_only_latest_popup(popup_remote_ids)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def _keep_only_latest_popup(cls, popup_remote_ids: list[int]) -> None:
|
|
||||||
latest = await Notice.filter(remote_id__in=popup_remote_ids, popup_dismissed=False).order_by(
|
|
||||||
"-created_at", "-id"
|
|
||||||
).first()
|
|
||||||
if not latest:
|
|
||||||
return
|
|
||||||
await Notice.filter(is_popup=True).exclude(id=latest.id).update(is_popup=False)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def _fetch_remote_notices(cls) -> list[dict[str, Any]]:
|
|
||||||
results: list[dict[str, Any]] = []
|
|
||||||
page = 1
|
|
||||||
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
|
|
||||||
while True:
|
|
||||||
resp = await client.get(
|
|
||||||
REMOTE_NOTICES_URL,
|
|
||||||
params={"version": _normalize_version(VERSION), "page": page},
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
|
||||||
items = data.get("items") if isinstance(data, dict) else None
|
|
||||||
if not isinstance(items, list):
|
|
||||||
break
|
|
||||||
results.extend(item for item in items if isinstance(item, dict))
|
|
||||||
|
|
||||||
total = data.get("total", len(results)) if isinstance(data, dict) else len(results)
|
|
||||||
page_size = data.get("pageSize") or data.get("page_size") or len(items)
|
|
||||||
if not items or len(results) >= int(total or 0) or page_size <= 0:
|
|
||||||
break
|
|
||||||
page += 1
|
|
||||||
return results
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _to_item(notice: Notice) -> NoticeItem:
|
|
||||||
return NoticeItem(
|
|
||||||
id=notice.id,
|
|
||||||
title=notice.title,
|
|
||||||
contentMd=notice.content_md or "",
|
|
||||||
isPopup=notice.is_popup and not notice.popup_dismissed,
|
|
||||||
createdAt=int(notice.created_at.timestamp() * 1000),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class NoticeSyncService:
|
|
||||||
def __init__(self):
|
|
||||||
self._worker: asyncio.Task | None = None
|
|
||||||
self._stop_event = asyncio.Event()
|
|
||||||
|
|
||||||
async def start(self) -> None:
|
|
||||||
if self._worker and not self._worker.done():
|
|
||||||
return
|
|
||||||
self._stop_event.clear()
|
|
||||||
self._worker = asyncio.create_task(self._run_loop())
|
|
||||||
|
|
||||||
async def stop(self) -> None:
|
|
||||||
if not self._worker:
|
|
||||||
return
|
|
||||||
self._stop_event.set()
|
|
||||||
await self._worker
|
|
||||||
self._worker = None
|
|
||||||
|
|
||||||
async def _run_loop(self) -> None:
|
|
||||||
while not self._stop_event.is_set():
|
|
||||||
try:
|
|
||||||
await NoticeService.sync_remote_notices()
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Failed to sync notices")
|
|
||||||
try:
|
|
||||||
await asyncio.wait_for(self._stop_event.wait(), timeout=SYNC_INTERVAL_SECONDS)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
notice_sync_service = NoticeSyncService()
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
|
|
||||||
class NoticeItem(BaseModel):
|
|
||||||
id: int
|
|
||||||
title: str
|
|
||||||
contentMd: str
|
|
||||||
isPopup: bool
|
|
||||||
createdAt: int
|
|
||||||
|
|
||||||
|
|
||||||
class NoticeListResponse(BaseModel):
|
|
||||||
items: list[NoticeItem]
|
|
||||||
page: int
|
|
||||||
pageSize: int
|
|
||||||
total: int
|
|
||||||
@@ -183,10 +183,9 @@ 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, cursor
|
full_path, current_user.id, page_num, page_size, sort_by, sort_order
|
||||||
)
|
)
|
||||||
return success(data)
|
return success(data)
|
||||||
|
|
||||||
@@ -212,10 +211,9 @@ 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, cursor
|
"/", current_user.id, page_num, page_size, sort_by, sort_order
|
||||||
)
|
)
|
||||||
return success(data)
|
return success(data)
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ 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)
|
||||||
@@ -120,28 +119,12 @@ 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):
|
||||||
try:
|
adapter_entries_page, adapter_total = await list_dir(
|
||||||
parameters = inspect.signature(list_dir).parameters
|
effective_root, rel, page_num, page_size, sort_by, sort_order
|
||||||
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:
|
||||||
@@ -206,9 +189,6 @@ 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
|
||||||
@@ -316,14 +296,13 @@ 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, cursor)
|
result = await cls.list_virtual_dir(path, page_num, page_size, sort_by, sort_order)
|
||||||
items = result.get("items", [])
|
items = result.get("items", [])
|
||||||
if not items:
|
if not items:
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -275,30 +275,15 @@ 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,10 +26,9 @@ 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, cursor)
|
return await cls.list_virtual_dir(path, page_num, page_size, sort_by, sort_order)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def list_directory_with_permission(
|
async def list_directory_with_permission(
|
||||||
@@ -40,35 +39,19 @@ 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, cursor
|
full_path, user_id, page_num, page_size, sort_by, sort_order
|
||||||
)
|
)
|
||||||
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,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
3
main.py
3
main.py
@@ -23,7 +23,6 @@ import httpx
|
|||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from domain.tasks import task_queue_service, task_scheduler
|
from domain.tasks import task_queue_service, task_scheduler
|
||||||
from domain.role.service import RoleService
|
from domain.role.service import RoleService
|
||||||
from domain.notices import notice_sync_service
|
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
@@ -78,7 +77,6 @@ async def lifespan(app: FastAPI):
|
|||||||
from domain.plugins import init_plugins
|
from domain.plugins import init_plugins
|
||||||
await init_plugins(app)
|
await init_plugins(app)
|
||||||
await task_scheduler.start()
|
await task_scheduler.start()
|
||||||
await notice_sync_service.start()
|
|
||||||
|
|
||||||
# 在所有路由加载完成后,挂载静态文件服务(放在最后以避免覆盖 API 路由)
|
# 在所有路由加载完成后,挂载静态文件服务(放在最后以避免覆盖 API 路由)
|
||||||
app.mount("/", SPAStaticFiles(directory="web/dist", html=True, check_dir=False), name="static")
|
app.mount("/", SPAStaticFiles(directory="web/dist", html=True, check_dir=False), name="static")
|
||||||
@@ -87,7 +85,6 @@ async def lifespan(app: FastAPI):
|
|||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
await notice_sync_service.stop()
|
|
||||||
await task_scheduler.stop()
|
await task_scheduler.stop()
|
||||||
await task_queue_service.stop_worker()
|
await task_queue_service.stop_worker()
|
||||||
await close_db()
|
await close_db()
|
||||||
|
|||||||
@@ -247,20 +247,6 @@ class RecentFile(Model):
|
|||||||
unique_together = (("user", "path"),)
|
unique_together = (("user", "path"),)
|
||||||
|
|
||||||
|
|
||||||
class Notice(Model):
|
|
||||||
id = fields.IntField(pk=True)
|
|
||||||
remote_id = fields.IntField(unique=True, index=True)
|
|
||||||
title = fields.CharField(max_length=255)
|
|
||||||
content_md = fields.TextField(null=True)
|
|
||||||
is_popup = fields.BooleanField(default=False)
|
|
||||||
popup_dismissed = fields.BooleanField(default=False)
|
|
||||||
created_at = fields.DatetimeField()
|
|
||||||
updated_at = fields.DatetimeField(auto_now=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
table = "notices"
|
|
||||||
|
|
||||||
|
|
||||||
class Plugin(Model):
|
class Plugin(Model):
|
||||||
id = fields.IntField(pk=True)
|
id = fields.IntField(pk=True)
|
||||||
key = fields.CharField(max_length=100, unique=True) # 插件唯一标识
|
key = fields.CharField(max_length=100, unique=True) # 插件唯一标识
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import request from './client';
|
|
||||||
|
|
||||||
export interface NoticeItem {
|
export interface NoticeItem {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -16,17 +14,42 @@ export interface GetNoticesResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface GetNoticesParams {
|
export interface GetNoticesParams {
|
||||||
|
version: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FOXEL_CORE_BASE = 'https://foxel.cc';
|
||||||
|
|
||||||
|
function normalizeVersion(version: string) {
|
||||||
|
return (version || '').trim().replace(/^v/i, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractErrorMessage(data: any) {
|
||||||
|
if (!data) return '';
|
||||||
|
if (typeof data === 'string') return data;
|
||||||
|
if (typeof data.detail === 'string') return data.detail;
|
||||||
|
if (typeof data.code === 'string') return data.code;
|
||||||
|
if (typeof data.message === 'string') return data.message;
|
||||||
|
if (typeof data.msg === 'string') return data.msg;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
export const noticesApi = {
|
export const noticesApi = {
|
||||||
list: async (params: GetNoticesParams): Promise<GetNoticesResponse> => {
|
list: async (params: GetNoticesParams): Promise<GetNoticesResponse> => {
|
||||||
return await request<GetNoticesResponse>(`/notices?page=${params.page ?? 1}`);
|
const url = new URL('/api/notices', FOXEL_CORE_BASE);
|
||||||
},
|
url.searchParams.set('version', normalizeVersion(params.version));
|
||||||
getPopup: async (): Promise<NoticeItem | null> => {
|
url.searchParams.set('page', String(params.page ?? 1));
|
||||||
return await request<NoticeItem | null>('/notices/popup');
|
|
||||||
},
|
const resp = await fetch(url.href);
|
||||||
dismiss: async (id: number): Promise<void> => {
|
if (!resp.ok) {
|
||||||
await request(`/notices/${id}/dismiss`, { method: 'POST' });
|
let msg = resp.statusText || `Request failed: ${resp.status}`;
|
||||||
|
try {
|
||||||
|
const data = await resp.json();
|
||||||
|
msg = extractErrorMessage(data) || msg;
|
||||||
|
} catch { void 0; }
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
return await resp.json();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -13,14 +13,10 @@ export interface DirListing {
|
|||||||
path: string;
|
path: string;
|
||||||
entries: VfsEntry[];
|
entries: VfsEntry[];
|
||||||
pagination?: {
|
pagination?: {
|
||||||
mode?: 'paged' | 'cursor';
|
total: number;
|
||||||
|
page: number;
|
||||||
page_size: number;
|
page_size: number;
|
||||||
total?: number;
|
pages: number;
|
||||||
page?: number;
|
|
||||||
pages?: number;
|
|
||||||
cursor?: string | null;
|
|
||||||
next_cursor?: string | null;
|
|
||||||
has_next?: boolean;
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +47,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', cursor?: string | null) => {
|
list: (path: string, page: number = 1, pageSize: number = 50, sortBy: string = 'name', sortOrder: string = 'asc') => {
|
||||||
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({
|
||||||
@@ -60,7 +56,6 @@ 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) => {
|
||||||
|
|||||||
@@ -24,16 +24,6 @@ function getPluginStylePaths(plugin: PluginItem): string[] {
|
|||||||
return styles.filter((s) => typeof s === 'string' && s.trim().length > 0);
|
return styles.filter((s) => typeof s === 'string' && s.trim().length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function unloadPluginFrame(iframe: HTMLIFrameElement | null) {
|
|
||||||
if (!iframe) return;
|
|
||||||
try {
|
|
||||||
iframe.contentWindow?.postMessage({ type: 'foxel-plugin:unload' }, window.location.origin);
|
|
||||||
} catch {
|
|
||||||
void 0;
|
|
||||||
}
|
|
||||||
iframe.src = 'about:blank';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 插件宿主组件 - 文件打开模式
|
* 插件宿主组件 - 文件打开模式
|
||||||
* 使用 iframe 隔离渲染与样式,避免插件污染宿主 DOM/CSS。
|
* 使用 iframe 隔离渲染与样式,避免插件污染宿主 DOM/CSS。
|
||||||
@@ -76,10 +66,7 @@ export const PluginAppHost: React.FC<PluginAppHostProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('message', onMessage);
|
window.addEventListener('message', onMessage);
|
||||||
return () => {
|
return () => window.removeEventListener('message', onMessage);
|
||||||
window.removeEventListener('message', onMessage);
|
|
||||||
unloadPluginFrame(iframeRef.current);
|
|
||||||
};
|
|
||||||
}, [plugin.key]);
|
}, [plugin.key]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -131,10 +118,7 @@ export const PluginAppOpenHost: React.FC<PluginAppOpenHostProps> = ({ plugin, on
|
|||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('message', onMessage);
|
window.addEventListener('message', onMessage);
|
||||||
return () => {
|
return () => window.removeEventListener('message', onMessage);
|
||||||
window.removeEventListener('message', onMessage);
|
|
||||||
unloadPluginFrame(iframeRef.current);
|
|
||||||
};
|
|
||||||
}, [plugin.key]);
|
}, [plugin.key]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ import { useI18n } from '../i18n';
|
|||||||
|
|
||||||
export interface NoticesModalProps {
|
export interface NoticesModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
version: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
initialNotice?: NoticeItem | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const NoticesModal = memo(function NoticesModal({ open, onClose, initialNotice }: NoticesModalProps) {
|
const NoticesModal = memo(function NoticesModal({ open, version, onClose }: NoticesModalProps) {
|
||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [items, setItems] = useState<NoticeItem[]>([]);
|
const [items, setItems] = useState<NoticeItem[]>([]);
|
||||||
@@ -28,15 +28,12 @@ const NoticesModal = memo(function NoticesModal({ open, onClose, initialNotice }
|
|||||||
if (mode === 'replace') setLoading(true);
|
if (mode === 'replace') setLoading(true);
|
||||||
else setLoadingMore(true);
|
else setLoadingMore(true);
|
||||||
try {
|
try {
|
||||||
const resp = await noticesApi.list({ page: targetPage });
|
const resp = await noticesApi.list({ version, page: targetPage });
|
||||||
setPage(resp.page ?? targetPage);
|
setPage(resp.page ?? targetPage);
|
||||||
setTotal(resp.total ?? 0);
|
setTotal(resp.total ?? 0);
|
||||||
const nextItems = mode === 'replace' && initialNotice && !resp.items.some(item => item.id === initialNotice.id)
|
setItems(prev => mode === 'replace' ? resp.items : [...prev, ...resp.items]);
|
||||||
? [initialNotice, ...resp.items]
|
|
||||||
: resp.items;
|
|
||||||
setItems(prev => mode === 'replace' ? nextItems : [...prev, ...resp.items]);
|
|
||||||
if (mode === 'replace') {
|
if (mode === 'replace') {
|
||||||
setSelectedId(initialNotice?.id ?? resp.items[0]?.id ?? null);
|
setSelectedId(resp.items[0]?.id ?? null);
|
||||||
} else {
|
} else {
|
||||||
setSelectedId(prev => prev ?? resp.items[0]?.id ?? null);
|
setSelectedId(prev => prev ?? resp.items[0]?.id ?? null);
|
||||||
}
|
}
|
||||||
@@ -58,7 +55,7 @@ const NoticesModal = memo(function NoticesModal({ open, onClose, initialNotice }
|
|||||||
setSelectedId(null);
|
setSelectedId(null);
|
||||||
loadPage(1, 'replace');
|
loadPage(1, 'replace');
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [open, initialNotice?.id]);
|
}, [open, version]);
|
||||||
|
|
||||||
const formatTime = (ts: number) => {
|
const formatTime = (ts: number) => {
|
||||||
try {
|
try {
|
||||||
@@ -184,3 +181,4 @@ const NoticesModal = memo(function NoticesModal({ open, onClose, initialNotice }
|
|||||||
});
|
});
|
||||||
|
|
||||||
export default NoticesModal;
|
export default NoticesModal;
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,6 @@
|
|||||||
"English": "English",
|
"English": "English",
|
||||||
"Default Language": "Default Language",
|
"Default Language": "Default Language",
|
||||||
"Used when the user has not selected a language": "Used when the user has not selected a language",
|
"Used when the user has not selected a language": "Used when the user has not selected a language",
|
||||||
"Default File View Mode": "Default File View Mode",
|
|
||||||
"Full Name": "Full Name",
|
"Full Name": "Full Name",
|
||||||
"Email": "Email",
|
"Email": "Email",
|
||||||
"Change Password": "Change Password",
|
"Change Password": "Change Password",
|
||||||
@@ -240,20 +239,6 @@
|
|||||||
"Type": "Type",
|
"Type": "Type",
|
||||||
"Folder": "Folder",
|
"Folder": "Folder",
|
||||||
"File": "File",
|
"File": "File",
|
||||||
"Image": "Image",
|
|
||||||
"Video": "Video",
|
|
||||||
"Audio": "Audio",
|
|
||||||
"PDF": "PDF",
|
|
||||||
"Word": "Word",
|
|
||||||
"Spreadsheet": "Spreadsheet",
|
|
||||||
"Presentation": "Presentation",
|
|
||||||
"Archive": "Archive",
|
|
||||||
"Code": "Code",
|
|
||||||
"Markdown": "Markdown",
|
|
||||||
"Text": "Text",
|
|
||||||
"Font": "Font",
|
|
||||||
"Database": "Database",
|
|
||||||
"Config": "Config",
|
|
||||||
"Path": "Path",
|
"Path": "Path",
|
||||||
"Path copied to clipboard": "Path copied to clipboard",
|
"Path copied to clipboard": "Path copied to clipboard",
|
||||||
"Copy failed": "Copy failed",
|
"Copy failed": "Copy failed",
|
||||||
|
|||||||
@@ -57,7 +57,6 @@
|
|||||||
"English": "English",
|
"English": "English",
|
||||||
"Default Language": "默认语言",
|
"Default Language": "默认语言",
|
||||||
"Used when the user has not selected a language": "用户未手动选择语言时使用",
|
"Used when the user has not selected a language": "用户未手动选择语言时使用",
|
||||||
"Default File View Mode": "默认文件展示方式",
|
|
||||||
"Full Name": "昵称",
|
"Full Name": "昵称",
|
||||||
"Email": "邮箱",
|
"Email": "邮箱",
|
||||||
"Change Password": "修改密码",
|
"Change Password": "修改密码",
|
||||||
@@ -259,20 +258,6 @@
|
|||||||
"Type": "类型",
|
"Type": "类型",
|
||||||
"Folder": "文件夹",
|
"Folder": "文件夹",
|
||||||
"File": "文件",
|
"File": "文件",
|
||||||
"Image": "图片",
|
|
||||||
"Video": "视频",
|
|
||||||
"Audio": "音频",
|
|
||||||
"PDF": "PDF",
|
|
||||||
"Word": "Word 文档",
|
|
||||||
"Spreadsheet": "表格",
|
|
||||||
"Presentation": "演示文稿",
|
|
||||||
"Archive": "压缩包",
|
|
||||||
"Code": "代码",
|
|
||||||
"Markdown": "Markdown",
|
|
||||||
"Text": "文本",
|
|
||||||
"Font": "字体",
|
|
||||||
"Database": "数据库",
|
|
||||||
"Config": "配置",
|
|
||||||
"Path": "路径",
|
"Path": "路径",
|
||||||
"Path copied to clipboard": "路径已复制到剪贴板",
|
"Path copied to clipboard": "路径已复制到剪贴板",
|
||||||
"Copy failed": "复制失败",
|
"Copy failed": "复制失败",
|
||||||
@@ -790,6 +775,7 @@
|
|||||||
"Users": "用户",
|
"Users": "用户",
|
||||||
"Create User": "创建用户",
|
"Create User": "创建用户",
|
||||||
"Create Role": "创建角色",
|
"Create Role": "创建角色",
|
||||||
|
"Edit": "编辑",
|
||||||
"Submit": "提交",
|
"Submit": "提交",
|
||||||
"Super Admin": "超级管理员",
|
"Super Admin": "超级管理员",
|
||||||
"Disabled": "已禁用",
|
"Disabled": "已禁用",
|
||||||
@@ -810,11 +796,14 @@
|
|||||||
"Is Regex": "正则表达式",
|
"Is Regex": "正则表达式",
|
||||||
"Priority": "优先级",
|
"Priority": "优先级",
|
||||||
"Higher value = higher priority": "数值越大优先级越高",
|
"Higher value = higher priority": "数值越大优先级越高",
|
||||||
|
"Permissions": "权限",
|
||||||
"System Permissions": "系统权限",
|
"System Permissions": "系统权限",
|
||||||
"Download and preview files": "下载和预览文件",
|
"Download and preview files": "下载和预览文件",
|
||||||
"Upload and modify files": "上传和修改文件",
|
"Upload and modify files": "上传和修改文件",
|
||||||
"Delete files and folders": "删除文件和目录",
|
"Delete files and folders": "删除文件和目录",
|
||||||
"Create share links": "创建分享链接",
|
"Create share links": "创建分享链接",
|
||||||
|
"Share": "分享",
|
||||||
|
"Delete": "删除",
|
||||||
"permission.category.system": "系统",
|
"permission.category.system": "系统",
|
||||||
"permission.category.adapter": "存储适配器"
|
"permission.category.adapter": "存储适配器"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Layout, Button, Dropdown, theme, Flex, Avatar, Typography, Tooltip, Modal, QRCode } from 'antd';
|
import { Layout, Button, Dropdown, theme, Flex, Avatar, Typography, Tooltip, Modal, QRCode } from 'antd';
|
||||||
import { SearchOutlined, MenuUnfoldOutlined, LogoutOutlined, UserOutlined, RobotOutlined, BellOutlined, QrcodeOutlined } from '@ant-design/icons';
|
import { SearchOutlined, MenuUnfoldOutlined, LogoutOutlined, UserOutlined, RobotOutlined, BellOutlined, QrcodeOutlined } from '@ant-design/icons';
|
||||||
import { memo, useEffect, useMemo, useState } from 'react';
|
import { memo, useMemo, useState } from 'react';
|
||||||
import SearchDialog from './SearchDialog.tsx';
|
import SearchDialog from './SearchDialog.tsx';
|
||||||
import { authApi } from '../api/auth.ts';
|
import { authApi } from '../api/auth.ts';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
@@ -9,8 +9,8 @@ import LanguageSwitcher from '../components/LanguageSwitcher';
|
|||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import ProfileModal from '../components/ProfileModal';
|
import ProfileModal from '../components/ProfileModal';
|
||||||
import NoticesModal from '../components/NoticesModal';
|
import NoticesModal from '../components/NoticesModal';
|
||||||
|
import { useSystemStatus } from '../contexts/SystemContext';
|
||||||
import useResponsive from '../hooks/useResponsive';
|
import useResponsive from '../hooks/useResponsive';
|
||||||
import { noticesApi, type NoticeItem } from '../api/notices';
|
|
||||||
|
|
||||||
const { Header } = Layout;
|
const { Header } = Layout;
|
||||||
|
|
||||||
@@ -30,8 +30,7 @@ const TopHeader = memo(function TopHeader({ collapsed, onToggle, onOpenAiAgent,
|
|||||||
const [profileOpen, setProfileOpen] = useState(false);
|
const [profileOpen, setProfileOpen] = useState(false);
|
||||||
const [clientAuthOpen, setClientAuthOpen] = useState(false);
|
const [clientAuthOpen, setClientAuthOpen] = useState(false);
|
||||||
const [noticesOpen, setNoticesOpen] = useState(false);
|
const [noticesOpen, setNoticesOpen] = useState(false);
|
||||||
const [popupNotice, setPopupNotice] = useState<NoticeItem | null>(null);
|
const status = useSystemStatus();
|
||||||
const [popupMode, setPopupMode] = useState(false);
|
|
||||||
const { isMobile } = useResponsive();
|
const { isMobile } = useResponsive();
|
||||||
const clientAuthPayload = useMemo(() => JSON.stringify({
|
const clientAuthPayload = useMemo(() => JSON.stringify({
|
||||||
base_url: window.location.origin,
|
base_url: window.location.origin,
|
||||||
@@ -45,35 +44,6 @@ const TopHeader = memo(function TopHeader({ collapsed, onToggle, onOpenAiAgent,
|
|||||||
|
|
||||||
const openProfile = () => setProfileOpen(true);
|
const openProfile = () => setProfileOpen(true);
|
||||||
const openClientAuth = () => setClientAuthOpen(true);
|
const openClientAuth = () => setClientAuthOpen(true);
|
||||||
const openNotices = () => {
|
|
||||||
setPopupMode(false);
|
|
||||||
setNoticesOpen(true);
|
|
||||||
};
|
|
||||||
const closeNotices = async () => {
|
|
||||||
const shouldDismiss = popupMode && popupNotice;
|
|
||||||
setNoticesOpen(false);
|
|
||||||
setPopupMode(false);
|
|
||||||
if (shouldDismiss) {
|
|
||||||
try {
|
|
||||||
await noticesApi.dismiss(popupNotice.id);
|
|
||||||
setPopupNotice(null);
|
|
||||||
} catch { void 0; }
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
if (!authToken) return;
|
|
||||||
noticesApi.getPopup().then((notice) => {
|
|
||||||
if (cancelled || !notice) return;
|
|
||||||
setPopupNotice(notice);
|
|
||||||
setPopupMode(true);
|
|
||||||
setNoticesOpen(true);
|
|
||||||
}).catch(() => void 0);
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [authToken]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Header
|
<Header
|
||||||
@@ -114,7 +84,7 @@ const TopHeader = memo(function TopHeader({ collapsed, onToggle, onOpenAiAgent,
|
|||||||
type="text"
|
type="text"
|
||||||
icon={<BellOutlined />}
|
icon={<BellOutlined />}
|
||||||
aria-label={t('Notices')}
|
aria-label={t('Notices')}
|
||||||
onClick={openNotices}
|
onClick={() => setNoticesOpen(true)}
|
||||||
style={{ paddingInline: 8, height: 40 }}
|
style={{ paddingInline: 8, height: 40 }}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -163,7 +133,7 @@ const TopHeader = memo(function TopHeader({ collapsed, onToggle, onOpenAiAgent,
|
|||||||
<QRCode value={clientAuthPayload} size={220} />
|
<QRCode value={clientAuthPayload} size={220} />
|
||||||
</Flex>
|
</Flex>
|
||||||
</Modal>
|
</Modal>
|
||||||
<NoticesModal open={noticesOpen} onClose={closeNotices} initialNotice={popupMode ? popupNotice : null} />
|
<NoticesModal open={noticesOpen} onClose={() => setNoticesOpen(false)} version={status?.version || ''} />
|
||||||
</Flex>
|
</Flex>
|
||||||
</Header>
|
</Header>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 { Button, Space, theme, Pagination } from 'antd';
|
import { 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';
|
||||||
@@ -28,7 +28,6 @@ import { MoveCopyModal } from './components/Modals/MoveCopyModal';
|
|||||||
import { SearchResultsView } from './components/SearchResultsView';
|
import { SearchResultsView } from './components/SearchResultsView';
|
||||||
import type { ViewMode } from './types';
|
import type { ViewMode } from './types';
|
||||||
import { vfsApi, type VfsEntry } from '../../api/client';
|
import { vfsApi, type VfsEntry } from '../../api/client';
|
||||||
import { getPublicConfig } from '../../api/config';
|
|
||||||
import { LoadingSkeleton } from './components/LoadingSkeleton';
|
import { LoadingSkeleton } from './components/LoadingSkeleton';
|
||||||
import useResponsive from '../../hooks/useResponsive';
|
import useResponsive from '../../hooks/useResponsive';
|
||||||
|
|
||||||
@@ -43,7 +42,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, goCursorNext, goCursorPrev } = useFileExplorer(navKey);
|
const { path, entries, loading, pagination, processorTypes, sortBy, sortOrder, load, navigateTo, goUp, handlePaginationChange, refresh, handleSortChange } = 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();
|
||||||
@@ -107,21 +106,6 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
|||||||
load(routePath, 1, pagination.pageSize, sortBy, sortOrder);
|
load(routePath, 1, pagination.pageSize, sortBy, sortOrder);
|
||||||
}, [routePath, navKey, load, pagination.pageSize, sortBy, sortOrder]);
|
}, [routePath, navKey, load, pagination.pageSize, sortBy, sortOrder]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let mounted = true;
|
|
||||||
getPublicConfig()
|
|
||||||
.then((cfg) => {
|
|
||||||
if (!mounted || isMobile) return;
|
|
||||||
setViewMode(cfg.DEFAULT_FILE_VIEW_MODE === 'list' ? 'list' : 'grid');
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (mounted && !isMobile) setViewMode('grid');
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
mounted = false;
|
|
||||||
};
|
|
||||||
}, [isMobile]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isMobile && viewMode !== 'grid') {
|
if (isMobile && viewMode !== 'grid') {
|
||||||
setViewMode('grid');
|
setViewMode('grid');
|
||||||
@@ -221,10 +205,8 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
|||||||
}
|
}
|
||||||
return joined.startsWith('/') ? joined : `/${joined}`;
|
return joined.startsWith('/') ? joined : `/${joined}`;
|
||||||
}, [entryBasePath]);
|
}, [entryBasePath]);
|
||||||
const showFsPagination = !isSearching && pagination.mode === 'paged' && pagination.total > 0;
|
const showFsPagination = !isSearching && 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();
|
||||||
@@ -300,7 +282,6 @@ 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}
|
||||||
@@ -328,7 +309,7 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
|||||||
onChange={handleDirectoryInputChange}
|
onChange={handleDirectoryInputChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div style={{ flex: 1, overflow: 'auto', minHeight: 0, paddingBottom: shouldReserveAnyBottomBar ? '80px' : '0' }} onContextMenu={isMobile ? undefined : openBlankContextMenu}>
|
<div style={{ flex: 1, overflow: 'auto', minHeight: 0, paddingBottom: shouldReserveBottomBar ? '80px' : '0' }} onContextMenu={isMobile ? undefined : openBlankContextMenu}>
|
||||||
{isSearching ? (
|
{isSearching ? (
|
||||||
<SearchResultsView
|
<SearchResultsView
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
@@ -383,19 +364,6 @@ 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
|
||||||
|
|||||||
@@ -19,45 +19,6 @@ interface FileListViewProps {
|
|||||||
onContextMenu: (e: React.MouseEvent, entry: VfsEntry) => void;
|
onContextMenu: (e: React.MouseEvent, entry: VfsEntry) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileTypeGroups: Array<{ key: string; exts: string[] }> = [
|
|
||||||
{ key: 'Image', exts: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'tiff'] },
|
|
||||||
{ key: 'Video', exts: ['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'webm', 'm4v', '3gp'] },
|
|
||||||
{ key: 'Audio', exts: ['mp3', 'wav', 'flac', 'aac', 'ogg', 'wma', 'm4a'] },
|
|
||||||
{ key: 'PDF', exts: ['pdf'] },
|
|
||||||
{ key: 'Word', exts: ['doc', 'docx'] },
|
|
||||||
{ key: 'Spreadsheet', exts: ['xls', 'xlsx', 'csv'] },
|
|
||||||
{ key: 'Presentation', exts: ['ppt', 'pptx'] },
|
|
||||||
{ key: 'Archive', exts: ['zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz'] },
|
|
||||||
{ key: 'Code', exts: ['js', 'jsx', 'ts', 'tsx', 'vue', 'html', 'htm', 'css', 'scss', 'sass', 'less', 'json', 'xml', 'yaml', 'yml', 'py', 'java', 'cpp', 'cc', 'cxx', 'c', 'h', 'hpp', 'hxx', 'php', 'rb', 'go', 'rs', 'rust', 'swift', 'kt', 'scala', 'clj', 'cljs', 'cs', 'vb', 'fs', 'pl', 'pm', 'r', 'lua', 'dart', 'elm'] },
|
|
||||||
{ key: 'Markdown', exts: ['md', 'markdown'] },
|
|
||||||
{ key: 'Text', exts: ['txt', 'log', 'ini', 'cfg', 'conf', 'sh', 'bash', 'zsh', 'fish', 'ps1', 'bat', 'cmd', 'dockerfile', 'makefile', 'gradle', 'cmake', 'gitignore', 'gitattributes', 'editorconfig', 'prettierrc'] },
|
|
||||||
{ key: 'Font', exts: ['ttf', 'otf', 'woff', 'woff2', 'eot'] },
|
|
||||||
{ key: 'Database', exts: ['db', 'sqlite', 'sql'] },
|
|
||||||
{ key: 'Config', exts: ['env', 'config', 'properties', 'toml'] },
|
|
||||||
];
|
|
||||||
|
|
||||||
const formatFileSize = (size: number) => {
|
|
||||||
if (!Number.isFinite(size) || size < 0) return '-';
|
|
||||||
const units = ['B', 'KB', 'MB', 'GB'];
|
|
||||||
let value = size;
|
|
||||||
let unitIndex = 0;
|
|
||||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
|
||||||
value /= 1024;
|
|
||||||
unitIndex += 1;
|
|
||||||
}
|
|
||||||
if (unitIndex === 0) return `${value} ${units[unitIndex]}`;
|
|
||||||
return `${value.toFixed(2)} ${units[unitIndex]}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getFileTypeLabel = (entry: VfsEntry, t: (key: string) => string) => {
|
|
||||||
if (entry.type === 'mount') return t('Mount Point');
|
|
||||||
if (entry.is_dir) return t('Folder');
|
|
||||||
|
|
||||||
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
|
||||||
const group = fileTypeGroups.find(item => item.exts.includes(ext));
|
|
||||||
return t(group?.key || 'File');
|
|
||||||
};
|
|
||||||
|
|
||||||
export const FileListView: React.FC<FileListViewProps> = ({
|
export const FileListView: React.FC<FileListViewProps> = ({
|
||||||
entries,
|
entries,
|
||||||
selectedEntries,
|
selectedEntries,
|
||||||
@@ -102,8 +63,7 @@ export const FileListView: React.FC<FileListViewProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{ title: t('Type'), key: 'fileType', width: 110, render: (_: any, r: VfsEntry) => getFileTypeLabel(r, t) },
|
{ title: t('Size'), dataIndex: 'size', width: 100, render: (v: number, r: VfsEntry) => r.is_dir ? '-' : v },
|
||||||
{ title: t('Size'), dataIndex: 'size', width: 120, render: (v: number, r: VfsEntry) => r.is_dir ? '-' : formatFileSize(v) },
|
|
||||||
{ title: t('Modified Time'), dataIndex: 'mtime', width: 160, render: (v: number) => v ? new Date(v * 1000).toLocaleString() : '-' },
|
{ title: t('Modified Time'), dataIndex: 'mtime', width: 160, render: (v: number) => v ? new Date(v * 1000).toLocaleString() : '-' },
|
||||||
{
|
{
|
||||||
title: t('Actions'),
|
title: t('Actions'),
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ 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;
|
||||||
@@ -31,7 +30,6 @@ export const Header: React.FC<HeaderProps> = ({
|
|||||||
viewMode,
|
viewMode,
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
paginationMode = 'paged',
|
|
||||||
isMobile = false,
|
isMobile = false,
|
||||||
onGoUp,
|
onGoUp,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
@@ -84,7 +82,6 @@ export const Header: React.FC<HeaderProps> = ({
|
|||||||
setEditingPath(false);
|
setEditingPath(false);
|
||||||
setPathInputValue('');
|
setPathInputValue('');
|
||||||
};
|
};
|
||||||
const sortDisabled = paginationMode === 'cursor';
|
|
||||||
|
|
||||||
const renderBreadcrumb = () => {
|
const renderBreadcrumb = () => {
|
||||||
if (editingPath) {
|
if (editingPath) {
|
||||||
@@ -157,7 +154,6 @@ 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) },
|
||||||
@@ -168,7 +164,6 @@ 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'),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -235,7 +230,6 @@ 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={[
|
||||||
@@ -246,7 +240,6 @@ 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,14 +7,10 @@ type ExplorerSnapshot = {
|
|||||||
path: string;
|
path: string;
|
||||||
entries: VfsEntry[];
|
entries: VfsEntry[];
|
||||||
pagination?: {
|
pagination?: {
|
||||||
mode?: 'paged' | 'cursor';
|
total: number;
|
||||||
|
page: number;
|
||||||
page_size: number;
|
page_size: number;
|
||||||
total?: number;
|
pages: number;
|
||||||
page?: number;
|
|
||||||
pages?: number;
|
|
||||||
cursor?: string | null;
|
|
||||||
next_cursor?: string | null;
|
|
||||||
has_next?: boolean;
|
|
||||||
};
|
};
|
||||||
sortBy: string;
|
sortBy: string;
|
||||||
sortOrder: string;
|
sortOrder: string;
|
||||||
@@ -34,11 +30,6 @@ 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]}`,
|
||||||
@@ -47,29 +38,23 @@ 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, cursor?: string | null, cursorHistory: (string | null)[] = []) => {
|
const load = useCallback(async (p: string, page: number = 1, pageSize: number = 50, sb = sortBy, so = sortOrder) => {
|
||||||
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, cursor),
|
vfsApi.list(canonical === '/' ? '' : canonical, page, pageSize, sb, so),
|
||||||
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,
|
||||||
mode: pageMode,
|
current: res.pagination!.page,
|
||||||
current: res.pagination?.page || page,
|
pageSize: res.pagination!.page_size,
|
||||||
pageSize: res.pagination?.page_size || pageSize,
|
total: res.pagination!.total
|
||||||
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') {
|
||||||
@@ -109,31 +94,8 @@ 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(
|
load(path, pagination.current, pagination.pageSize, sortBy, sortOrder);
|
||||||
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) => {
|
||||||
@@ -155,8 +117,6 @@ export function useFileExplorer(navKey: string) {
|
|||||||
goUp,
|
goUp,
|
||||||
handlePaginationChange,
|
handlePaginationChange,
|
||||||
refresh,
|
refresh,
|
||||||
handleSortChange,
|
handleSortChange
|
||||||
goCursorNext,
|
|
||||||
goCursorPrev,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,30 +43,6 @@ const THEME_KEYS = {
|
|||||||
CSS: 'THEME_CUSTOM_CSS',
|
CSS: 'THEME_CUSTOM_CSS',
|
||||||
};
|
};
|
||||||
|
|
||||||
const CONFIG_DEFAULTS: Record<string, string> = {
|
|
||||||
...Object.fromEntries(APP_CONFIG_KEYS.map(({ key, default: def }) => [key, def ?? ''])),
|
|
||||||
APP_DEFAULT_LANGUAGE: 'zh',
|
|
||||||
AUTH_ALLOW_REGISTER: 'false',
|
|
||||||
AUTH_DEFAULT_REGISTER_ROLE_ID: '',
|
|
||||||
DEFAULT_FILE_VIEW_MODE: 'grid',
|
|
||||||
[THEME_KEYS.MODE]: 'light',
|
|
||||||
[THEME_KEYS.PRIMARY]: '#111111',
|
|
||||||
[THEME_KEYS.RADIUS]: '10',
|
|
||||||
[THEME_KEYS.TOKENS]: '',
|
|
||||||
[THEME_KEYS.CSS]: '',
|
|
||||||
WEBDAV_MAPPING_ENABLED: '1',
|
|
||||||
S3_MAPPING_ENABLED: '1',
|
|
||||||
S3_MAPPING_BUCKET: 'foxel',
|
|
||||||
S3_MAPPING_REGION: '',
|
|
||||||
S3_MAPPING_BASE_PATH: '/',
|
|
||||||
S3_MAPPING_ACCESS_KEY: '',
|
|
||||||
S3_MAPPING_SECRET_KEY: '',
|
|
||||||
EMAIL_CONFIG: '',
|
|
||||||
EMAIL_PASSWORD_RESET_TEMPLATE: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
const stringifyConfigValue = (value: unknown) => String(value ?? '');
|
|
||||||
|
|
||||||
export default function SystemSettingsPage({ tabKey, onTabNavigate }: SystemSettingsPageProps) {
|
export default function SystemSettingsPage({ tabKey, onTabNavigate }: SystemSettingsPageProps) {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [config, setConfigState] = useState<Record<string, string> | null>(null);
|
const [config, setConfigState] = useState<Record<string, string> | null>(null);
|
||||||
@@ -93,21 +69,16 @@ export default function SystemSettingsPage({ tabKey, onTabNavigate }: SystemSett
|
|||||||
const handleSave = async (values: Record<string, unknown>): Promise<boolean> => {
|
const handleSave = async (values: Record<string, unknown>): Promise<boolean> => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const currentConfig = config ?? {};
|
for (const [key, value] of Object.entries(values)) {
|
||||||
const changedValues = Object.fromEntries(
|
await setConfig(key, String(value ?? ''));
|
||||||
Object.entries(values)
|
|
||||||
.map(([key, value]) => [key, stringifyConfigValue(value)] as const)
|
|
||||||
.filter(([key, value]) => value !== (currentConfig[key] ?? CONFIG_DEFAULTS[key] ?? '')),
|
|
||||||
) as Record<string, string>;
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(changedValues)) {
|
|
||||||
await setConfig(key, value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
message.success(t('Saved successfully'));
|
message.success(t('Saved successfully'));
|
||||||
setConfigState((prev) => ({ ...(prev ?? {}), ...changedValues }));
|
const stringValues = Object.fromEntries(
|
||||||
|
Object.entries(values).map(([key, value]) => [key, String(value ?? '')]),
|
||||||
|
) as Record<string, string>;
|
||||||
|
setConfigState((prev) => ({ ...(prev ?? {}), ...stringValues }));
|
||||||
// trigger theme refresh if related keys changed
|
// trigger theme refresh if related keys changed
|
||||||
if (Object.keys(changedValues).some(k => Object.values(THEME_KEYS).includes(k))) {
|
if (Object.keys(values).some(k => Object.values(THEME_KEYS).includes(k))) {
|
||||||
await refreshTheme();
|
await refreshTheme();
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ export default function AppSettingsTab({
|
|||||||
return {
|
return {
|
||||||
...Object.fromEntries(configKeys.map(({ key, default: def }) => [key, config[key] ?? def ?? ''])),
|
...Object.fromEntries(configKeys.map(({ key, default: def }) => [key, config[key] ?? def ?? ''])),
|
||||||
APP_DEFAULT_LANGUAGE: normalizeLang(config.APP_DEFAULT_LANGUAGE, 'zh'),
|
APP_DEFAULT_LANGUAGE: normalizeLang(config.APP_DEFAULT_LANGUAGE, 'zh'),
|
||||||
DEFAULT_FILE_VIEW_MODE: config.DEFAULT_FILE_VIEW_MODE === 'list' ? 'list' : 'grid',
|
|
||||||
AUTH_ALLOW_REGISTER: allowRegister,
|
AUTH_ALLOW_REGISTER: allowRegister,
|
||||||
AUTH_DEFAULT_REGISTER_ROLE_ID: Number.isFinite(roleId) ? roleId : undefined,
|
AUTH_DEFAULT_REGISTER_ROLE_ID: Number.isFinite(roleId) ? roleId : undefined,
|
||||||
};
|
};
|
||||||
@@ -71,7 +70,6 @@ export default function AppSettingsTab({
|
|||||||
}
|
}
|
||||||
const defaultLanguage = normalizeLang(vals.APP_DEFAULT_LANGUAGE, 'zh');
|
const defaultLanguage = normalizeLang(vals.APP_DEFAULT_LANGUAGE, 'zh');
|
||||||
payload.APP_DEFAULT_LANGUAGE = defaultLanguage;
|
payload.APP_DEFAULT_LANGUAGE = defaultLanguage;
|
||||||
payload.DEFAULT_FILE_VIEW_MODE = vals.DEFAULT_FILE_VIEW_MODE === 'list' ? 'list' : 'grid';
|
|
||||||
const allow = !!vals.AUTH_ALLOW_REGISTER;
|
const allow = !!vals.AUTH_ALLOW_REGISTER;
|
||||||
payload.AUTH_ALLOW_REGISTER = allow ? 'true' : 'false';
|
payload.AUTH_ALLOW_REGISTER = allow ? 'true' : 'false';
|
||||||
if (allow) {
|
if (allow) {
|
||||||
@@ -105,19 +103,6 @@ export default function AppSettingsTab({
|
|||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
name="DEFAULT_FILE_VIEW_MODE"
|
|
||||||
label={t('Default File View Mode')}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
size="large"
|
|
||||||
options={[
|
|
||||||
{ value: 'grid', label: t('Grid') },
|
|
||||||
{ value: 'list', label: t('List') },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Divider titlePlacement="left">{t('Registration Settings')}</Divider>
|
<Divider titlePlacement="left">{t('Registration Settings')}</Divider>
|
||||||
|
|
||||||
<Alert
|
<Alert
|
||||||
|
|||||||
@@ -364,27 +364,12 @@ async function main() {
|
|||||||
|
|
||||||
await mountError();
|
await mountError();
|
||||||
|
|
||||||
const runCleanup = () => {
|
window.addEventListener('beforeunload', () => {
|
||||||
try {
|
try {
|
||||||
cleanup?.();
|
cleanup?.();
|
||||||
} catch {
|
} catch {
|
||||||
void 0;
|
void 0;
|
||||||
}
|
}
|
||||||
cleanup = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('message', (ev) => {
|
|
||||||
if (ev.origin !== window.location.origin) return;
|
|
||||||
if (ev.source !== window.parent) return;
|
|
||||||
const data = ev.data as any;
|
|
||||||
if (!data || typeof data !== 'object') return;
|
|
||||||
if (data.type !== 'foxel-plugin:unload') return;
|
|
||||||
runCleanup();
|
|
||||||
root.innerHTML = '';
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener('beforeunload', () => {
|
|
||||||
runCleanup();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user