Compare commits

..

23 Commits

Author SHA1 Message Date
shiyu
6a52fa3fd5 chore: Update version to v1.1.3 2025-08-31 19:52:30 +08:00
shiyu
219999914c docs: Update development environment initialization steps 2025-08-31 19:27:39 +08:00
shiyu
1a3d9d41ec feat: Update Telegram adapter to support uploads 2025-08-31 18:22:46 +08:00
shiyu
27ad49d8ed docs: Format badge display in README file 2025-08-31 12:56:15 +08:00
shiyu
e230bf6661 docs: Add English README file 2025-08-31 12:53:02 +08:00
shiyu
50fb0b4977 feat: Implement task queue service 2025-08-31 12:48:20 +08:00
shiyu
b50f19bcb4 feat: Add application domain and file domain configuration 2025-08-31 12:38:21 +08:00
shiyu
3f3f192d53 feat: Update version to v1.1.2 2025-08-30 15:39:05 +08:00
shiyu
83aaa7a052 feat: Add Artplayer as video player 2025-08-30 11:34:36 +08:00
shiyu
a2638f077c feat: Add Telegram storage adapter implementation 2025-08-30 11:16:35 +08:00
shiyu
81eed370a6 feat: Update AI configuration items 2025-08-29 18:41:57 +08:00
shiyu
cce39f7b1c feat: Add link button to access documentation page 2025-08-29 15:59:14 +08:00
shiyu
61c2897857 feat: Update requirements.txt 2025-08-29 13:35:44 +08:00
shiyu
b15a9b68e1 feat: Update permissions for release drafter workflow 2025-08-29 13:23:10 +08:00
shiyu
1f762a9822 feat: Add release drafter configuration for automated release notes 2025-08-29 13:19:33 +08:00
shiyu
2974425bef feat: Update version to v1.1.1 2025-08-29 13:14:25 +08:00
shiyu
9431d0459f refactor: Remove unused props from GridView component and clean up related code 2025-08-29 13:00:24 +08:00
shiyu
24ce681c28 refactor: Simplify EmptyState component 2025-08-29 12:55:53 +08:00
shiyu
20bc1cfbb7 feat: Implement S3Adapter for S3 compatible object storage with file operations 2025-08-29 12:50:51 +08:00
shiyu
9a7a7a8b81 fix: Improve adapter instance retrieval with refresh logic in resolve_adapter_and_rel and list_virtual_dir 2025-08-29 12:38:11 +08:00
shiyu
2f92fa353c feat: Add OneDrive storage adapter with support for file operations and thumbnail retrieval 2025-08-29 12:08:05 +08:00
shiyu
86e81bf40c refactor: Rename 'mount_path' to 'path' in adapter schemas and related components 2025-08-28 17:00:12 +08:00
shiyu
b3b5ae2eac fix: Correct Docker tag assignment for non-tagged pushes 2025-08-28 13:33:07 +08:00
41 changed files with 1712 additions and 587 deletions

22
.github/release-drafter.yml vendored Normal file
View File

@@ -0,0 +1,22 @@
name-template: 'v$RESOLVED_VERSION'
tag-template: 'v$RESOLVED_VERSION'
categories:
- title: '🚀 Features'
labels:
- 'feat'
- title: '🐛 Bug Fixes'
labels:
- 'fix'
- title: '📦 Code Refactoring'
labels:
- 'refactor'
- title: '📄 Documentation'
labels:
- 'docs'
- title: '🧰 Maintenance'
label: 'chore'
change-template: '- $TITLE @$AUTHOR (#$NUMBER)'
template: |
## Changes
$CHANGES

View File

@@ -32,7 +32,7 @@ jobs:
VERSION=${GITHUB_REF#refs/tags/}
echo "DOCKER_TAGS=ghcr.io/${REPO_LC}:${VERSION},ghcr.io/${REPO_LC}:latest" >> $GITHUB_ENV
else
echo "DOCKER_TAGS=ghcr.io/${REPO_LC}:latest" >> $GITHUB_ENV
echo "DOCKER_TAGS=ghcr.io/${REPO_LC}:dev" >> $GITHUB_ENV
fi
- name: Log in to GitHub Container Registry

17
.github/workflows/release-drafter.yml vendored Normal file
View File

@@ -0,0 +1,17 @@
name: Release Drafter
on:
workflow_dispatch:
jobs:
update_release_draft:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: release-drafter/release-drafter@v5
with:
config-name: release-drafter.yml
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -82,7 +82,24 @@
pip install -r requirements.txt
```
4. **启动开发服务器**
4. **初始化环境**
在启动服务前,请进行以下准备:
- **创建数据目录**:
在项目根目录执行 `mkdir -p data/db`。这将创建用于存放数据库等文件的目录。
> [!IMPORTANT]
> 请确保应用拥有对 `data/db` 目录的读写权限。
- **创建 `.env` 配置文件**:
在项目根目录创建名为 `.env` 的文件,并填入以下内容。这些密钥用于保障应用安全,您可以按需修改。
```dotenv
SECRET_KEY=EnsRhL9NFPxgFVc+7t96/y70DIOR+9SpntcIqQa90TU=
TEMP_LINK_SECRET_KEY=EnsRhL9NFPxgFVc+7t96/y70DIOR+9SpntcIqQa90TU=
```
5. **启动开发服务器**
```bash
uvicorn main:app --reload --host 0.0.0.0 --port 8000

View File

@@ -1,8 +1,12 @@
<div align="right">
<b>English</b> | <a href="./README_zh.md">简体中文</a>
</div>
<div align="center">
# Foxel
**一个面向个人和团队的、高度可扩展的私有云盘解决方案,支持 AI 语义搜索。**
**A highly extensible private cloud storage solution for individuals and teams, featuring AI-powered semantic search.**
![Python Version](https://img.shields.io/badge/Python-3.13+-blue.svg)
![React](https://img.shields.io/badge/React-19.0-blue.svg)
@@ -11,32 +15,31 @@
---
<blockquote>
<em><strong>数据之洋浩瀚无涯,当以洞察之目引航,然其脉络深隐,非表象所能尽窥。</strong></em><br>
<em><strong>The ocean of data is boundless, let the eye of insight guide the voyage, yet its intricate connections lie deep, not fully discernible from the surface.</strong></em>
</blockquote>
</div>
## 👀 在线体验
## 👀 Online Demo
> [https://demo.foxel.cc](https://demo.foxel.cc)
>
> 账号/密码:`admin` / `admin`
> Account/Password: `admin` / `admin`
## ✨ 核心功能
## ✨ Core Features
- **统一文件管理**:集中管理分布于不同存储后端的文件。
- **插件化存储后端**:采用可扩展的适配器模式,方便集成多种存储类型。
- **语义搜索**:支持自然语言描述搜索图片、文档等非结构化数据内容。
- **内置文件预览**可直接预览图片、视频、PDF、Office 文档及文本、代码文件,无需下载。
- **权限与分享**:支持公开或私密分享链接,便于文件共享。
- **任务处理中心**:支持异步任务处理,如文件索引和数据备份,不影响主应用运行。
- **Unified File Management**: Centralize management of files distributed across different storage backends.
- **Pluggable Storage Backends**: Utilizes an extensible adapter pattern to easily integrate various storage types.
- **Semantic Search**: Supports natural language search for content within unstructured data like images and documents.
- **Built-in File Preview**: Preview images, videos, PDFs, Office documents, text, and code files directly without downloading.
- **Permissions and Sharing**: Supports public or private sharing links for easy file distribution.
- **Task Processing Center**: Supports asynchronous task processing, such as file indexing and data backups, without impacting the main application.
## 🚀 快速开始
## 🚀 Quick Start
使用 Docker Compose 是启动 Foxel 最推荐的方式。
Using Docker Compose is the most recommended way to start Foxel.
1. **创建数据目录**:
新建 `data` 文件夹用于持久化数据:
1. **Create Data Directories**:
Create a `data` folder for persistent data:
```bash
mkdir -p data/db
@@ -44,40 +47,40 @@ mkdir -p data/mount
chmod 777 data/db data/mount
```
2. **下载 Docker Compose 文件**
2. **Download Docker Compose File**:
```bash
curl -L -O https://github.com/DrizzleTime/Foxel/raw/main/compose.yaml
```
下载完成后,**强烈建议**修改 `compose.yaml` 文件中的环境变量以确保安全:
After downloading, it is **strongly recommended** to modify the environment variables in the `compose.yaml` file to ensure security:
- 修改 `SECRET_KEY` `TEMP_LINK_SECRET_KEY`:将默认的密钥替换为随机生成的强密钥
- Modify `SECRET_KEY` and `TEMP_LINK_SECRET_KEY`: Replace the default keys with randomly generated strong keys.
3. **启动服务**:
3. **Start the Services**:
```bash
docker-compose up -d
```
4. **访问应用**:
4. **Access the Application**:
服务启动后,在浏览器中打开页面。
Once the services are running, open the page in your browser.
> 首次启动,请根据引导页面完成管理员账号的初始化设置。
> On the first launch, please follow the setup guide to initialize the administrator account.
## 🤝 如何贡献
## 🤝 How to Contribute
我们非常欢迎来自社区的贡献!无论是提交 Bug、建议新功能还是直接贡献代码。
We welcome contributions from the community! Whether it's submitting bugs, suggesting new features, or contributing code directly.
在开始之前,请先阅读我们的 [`CONTRIBUTING.md`](CONTRIBUTING.md) 文件,它会指导你如何设置开发环境以及提交流程。
Before you start, please read our [`CONTRIBUTING.md`](CONTRIBUTING.md) file, which will guide you on how to set up your development environment and the submission process.
## 🌐 社区
## 🌐 Community
加入我们的交流社区:[Telegram 群组](https://t.me/+thDsBfyqJxZkNTU1),与开发者和用户一起讨论!
Join our community on [Telegram](https://t.me/+thDsBfyqJxZkNTU1) to discuss with developers and other users!
你也可以加入我们的微信群,获取更多实时交流与支持。请扫描下方二维码加入:
You can also join our WeChat group for more real-time communication and support. Please scan the QR code below to join:
<img src="https://foxel.cc/image/wechat.png" alt="微信群二维码" width="180">
<img src="https://foxel.cc/image/wechat.png" alt="WeChat Group QR Code" width="180">
> 如果二维码失效,请添加微信号 **drizzle2001**,我们会邀请你加入群聊。
> If the QR code is invalid, please add WeChat ID **drizzle2001**, and we will invite you to the group.

87
README_zh.md Normal file
View File

@@ -0,0 +1,87 @@
<div align="right">
<a href="./README.md">English</a> | <b>简体中文</b>
</div>
<div align="center">
# Foxel
**一个面向个人和团队的、高度可扩展的私有云盘解决方案,支持 AI 语义搜索。**
![Python Version](https://img.shields.io/badge/Python-3.13+-blue.svg)
![React](https://img.shields.io/badge/React-19.0-blue.svg)
![License](https://img.shields.io/badge/license-MIT-green.svg)
![GitHub stars](https://img.shields.io/github/stars/DrizzleTime/foxel?style=social)
---
<blockquote>
<em><strong>数据之洋浩瀚无涯,当以洞察之目引航,然其脉络深隐,非表象所能尽窥。</strong></em><br>
<em><strong>The ocean of data is boundless, let the eye of insight guide the voyage, yet its intricate connections lie deep, not fully discernible from the surface.</strong></em>
</blockquote>
</div>
## 👀 在线体验
> [https://demo.foxel.cc](https://demo.foxel.cc)
>
> 账号/密码:`admin` / `admin`
## ✨ 核心功能
- **统一文件管理**:集中管理分布于不同存储后端的文件。
- **插件化存储后端**:采用可扩展的适配器模式,方便集成多种存储类型。
- **语义搜索**:支持自然语言描述搜索图片、文档等非结构化数据内容。
- **内置文件预览**可直接预览图片、视频、PDF、Office 文档及文本、代码文件,无需下载。
- **权限与分享**:支持公开或私密分享链接,便于文件共享。
- **任务处理中心**:支持异步任务处理,如文件索引和数据备份,不影响主应用运行。
## 🚀 快速开始
使用 Docker Compose 是启动 Foxel 最推荐的方式。
1. **创建数据目录**:
新建 `data` 文件夹用于持久化数据:
```bash
mkdir -p data/db
mkdir -p data/mount
chmod 777 data/db data/mount
```
2. **下载 Docker Compose 文件**
```bash
curl -L -O https://github.com/DrizzleTime/Foxel/raw/main/compose.yaml
```
下载完成后,**强烈建议**修改 `compose.yaml` 文件中的环境变量以确保安全:
- 修改 `SECRET_KEY` 和 `TEMP_LINK_SECRET_KEY`:将默认的密钥替换为随机生成的强密钥
3. **启动服务**:
```bash
docker-compose up -d
```
4. **访问应用**:
服务启动后,在浏览器中打开页面。
> 首次启动,请根据引导页面完成管理员账号的初始化设置。
## 🤝 如何贡献
我们非常欢迎来自社区的贡献!无论是提交 Bug、建议新功能还是直接贡献代码。
在开始之前,请先阅读我们的 [`CONTRIBUTING.md`](CONTRIBUTING.md) 文件,它会指导你如何设置开发环境以及提交流程。
## 🌐 社区
加入我们的交流社区:[Telegram 群组](https://t.me/+thDsBfyqJxZkNTU1),与开发者和用户一起讨论!
你也可以加入我们的微信群,获取更多实时交流与支持。请扫描下方二维码加入:
<img src="https://foxel.cc/image/wechat.png" alt="微信群二维码" width="180">
> 如果二维码失效,请添加微信号 **drizzle2001**,我们会邀请你加入群聊。

View File

@@ -39,7 +39,7 @@ async def create_adapter(
data: AdapterCreate,
current_user: Annotated[User, Depends(get_current_active_user)]
):
norm_path = AdapterCreate.normalize_mount_path(data.mount_path)
norm_path = AdapterCreate.normalize_mount_path(data.path)
exists = await StorageAdapter.get_or_none(path=norm_path)
if exists:
raise HTTPException(400, detail="Mount path already exists")
@@ -108,7 +108,7 @@ async def update_adapter(
if not rec:
raise HTTPException(404, detail="Not found")
norm_path = AdapterCreate.normalize_mount_path(data.mount_path)
norm_path = AdapterCreate.normalize_mount_path(data.path)
existing = await StorageAdapter.get_or_none(path=norm_path)
if existing and existing.id != adapter_id:
raise HTTPException(400, detail="Mount path already exists")

View File

@@ -41,7 +41,9 @@ async def get_system_status():
"version": VERSION,
"title": await ConfigCenter.get("APP_NAME", "Foxel"),
"logo": await ConfigCenter.get("APP_LOGO", "/logo.svg"),
"is_initialized": await has_users()
"is_initialized": await has_users(),
"app_domain": await ConfigCenter.get("APP_DOMAIN"),
"file_domain": await ConfigCenter.get("FILE_DOMAIN"),
}
return success(system_info)

View File

@@ -1,7 +1,7 @@
from fastapi import APIRouter, Depends, Body
from typing import Annotated
from services.processors.registry import get_config_schemas
from services.virtual_fs import process_file
from services.task_queue import task_queue_service
from services.auth import get_current_active_user, User
from api.response import success
from pydantic import BaseModel
@@ -21,7 +21,7 @@ async def list_processors(
"name": meta["name"],
"supported_exts": meta.get("supported_exts", []),
"config_schema": meta["config_schema"],
"produces_file": meta.get("produces_file", False),
"produces_file": meta.get("produces_file", False),
})
return success(out)
@@ -40,5 +40,13 @@ async def process_file_with_processor(
req: ProcessRequest = Body(...)
):
save_to = req.path if req.overwrite else req.save_to
result = await process_file(req.path, req.processor_type, req.config, save_to)
return success(result)
task = await task_queue_service.add_task(
"process_file",
{
"path": req.path,
"processor_type": req.processor_type,
"config": req.config,
"save_to": save_to,
},
)
return success({"task_id": task.id})

View File

@@ -6,6 +6,7 @@ from schemas.tasks import AutomationTaskCreate, AutomationTaskUpdate
from api.response import success
from services.auth import get_current_active_user, User
from services.logging import LogService
from services.task_queue import task_queue_service
router = APIRouter(
prefix="/api/tasks",
@@ -15,6 +16,25 @@ router = APIRouter(
)
@router.get("/queue")
async def get_task_queue_status(
current_user: Annotated[User, Depends(get_current_active_user)],
):
tasks = task_queue_service.get_all_tasks()
return success([task.dict() for task in tasks])
@router.get("/queue/{task_id}")
async def get_task_status(
task_id: str,
current_user: Annotated[User, Depends(get_current_active_user)],
):
task = task_queue_service.get_task(task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
return success(task.dict())
@router.post("/")
async def create_task(
task_in: AutomationTaskCreate,

View File

@@ -19,6 +19,7 @@ from services.virtual_fs import (
from services.thumbnail import is_image_filename, get_or_create_thumb, is_raw_filename
from schemas import MkdirRequest, MoveRequest
from api.response import success
from services.config import ConfigCenter
router = APIRouter(prefix='/api/fs', tags=["virtual-fs"])
@@ -151,7 +152,13 @@ async def get_temp_link(
"""获取文件的临时公开访问令牌"""
full_path = '/' + full_path if not full_path.startswith('/') else full_path
token = await generate_temp_link_token(full_path, expires_in=expires_in)
return success({"token": token, "path": full_path})
file_domain = await ConfigCenter.get("FILE_DOMAIN")
if file_domain:
file_domain = file_domain.rstrip('/')
url = f"{file_domain}/api/fs/public/{token}"
else:
url = f"/api/fs/public/{token}"
return success({"token": token, "path": full_path, "url": url})
@router.get("/public/{token}")

View File

@@ -8,6 +8,7 @@ from fastapi import FastAPI
from services.middleware.logging_middleware import LoggingMiddleware
from services.middleware.exception_handler import global_exception_handler
from dotenv import load_dotenv
from services.task_queue import task_queue_service
load_dotenv()
@@ -17,9 +18,11 @@ async def lifespan(app: FastAPI):
await init_db()
await runtime_registry.refresh()
await ConfigCenter.set("APP_VERSION", VERSION)
await task_queue_service.start_worker()
try:
yield
finally:
await task_queue_service.stop_worker()
await close_db()

View File

@@ -1,14 +1,27 @@
aioboto3==15.1.0
aiobotocore==2.24.0
aiofiles==24.1.0
aiohappyeyeballs==2.6.1
aiohttp==3.12.15
aioitertools==0.12.0
aiosignal==1.4.0
aiosqlite==0.21.0
annotated-types==0.7.0
anyio==4.10.0
asyncclick==8.2.2.2
attrs==25.3.0
bcrypt==4.3.0
boto3==1.39.11
botocore==1.39.11
certifi==2025.8.3
click==8.2.1
dictdiffer==0.9.0
dnspython==2.7.0
email_validator==2.2.0
fastapi==0.116.1
fastapi-cli==0.0.8
fastapi-cloud-cli==0.1.5
frozenlist==1.7.0
grpcio==1.74.0
h11==0.16.0
httpcore==1.0.9
@@ -18,14 +31,17 @@ idna==3.10
imageio==2.37.0
iso8601==2.1.0
Jinja2==3.1.6
jmespath==1.0.1
markdown-it-py==4.0.0
MarkupSafe==3.0.2
mdurl==0.1.2
milvus-lite==2.5.1
multidict==6.6.4
numpy==2.3.2
pandas==2.3.1
passlib==1.7.4
pillow==11.3.0
propcache==0.3.2
protobuf==6.32.0
pyaes==1.6.1
pyasn1==0.6.1
@@ -46,6 +62,7 @@ rich==14.1.0
rich-toolkit==0.15.0
rignore==0.6.4
rsa==4.9.1
s3transfer==0.13.1
sentry-sdk==2.35.0
setuptools==80.9.0
shellingham==1.5.4
@@ -65,3 +82,5 @@ uvicorn==0.35.0
uvloop==0.21.0
watchfiles==1.1.0
websockets==15.0.1
wrapt==1.17.3
yarl==1.20.1

View File

@@ -1,5 +1,5 @@
from typing import Dict, Optional
from pydantic import BaseModel, Field, validator
from pydantic import BaseModel, Field, field_validator
class AdapterBase(BaseModel):
@@ -7,12 +7,11 @@ class AdapterBase(BaseModel):
type: str = Field(pattern=r"^[a-zA-Z0-9_]+$")
config: Dict = Field(default_factory=dict)
enabled: bool = True
path: str = None
sub_path: Optional[str] = None
class AdapterCreate(AdapterBase):
mount_path: str
@staticmethod
def normalize_mount_path(p: str) -> str:
p = p.strip()
@@ -21,7 +20,7 @@ class AdapterCreate(AdapterBase):
p = p.rstrip('/')
return p or '/'
@validator("mount_path")
@field_validator("path")
def _v_mount(cls, v: str):
if not v:
raise ValueError("mount_path required")
@@ -30,7 +29,8 @@ class AdapterCreate(AdapterBase):
class AdapterOut(AdapterBase):
id: int
mount_path: str = Field(alias='path')
path: str = None
sub_path: Optional[str] = None
class Config:
from_attributes = True

View File

@@ -0,0 +1,431 @@
from __future__ import annotations
from datetime import datetime, timezone, timedelta
from typing import List, Dict, Tuple, AsyncIterator
import httpx
from fastapi.responses import StreamingResponse
from fastapi import HTTPException
from models import StorageAdapter
MS_GRAPH_URL = "https://graph.microsoft.com/v1.0"
MS_OAUTH_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/token"
class OneDriveAdapter:
"""OneDrive 存储适配器"""
def __init__(self, record: StorageAdapter):
self.record = record
cfg = record.config
self.client_id = cfg.get("client_id")
self.client_secret = cfg.get("client_secret")
self.refresh_token = cfg.get("refresh_token")
self.root = cfg.get("root", "/").strip("/")
if not all([self.client_id, self.client_secret, self.refresh_token]):
raise ValueError(
"OneDrive 适配器需要 client_id, client_secret, 和 refresh_token")
self._access_token: str | None = None
self._token_expiry: datetime | None = None
def get_effective_root(self, sub_path: str | None) -> str:
"""
获取有效根路径。
:param sub_path: 子路径。
:return: 完整的有效路径。
"""
if sub_path:
return f"/{self.root.strip('/')}/{sub_path.strip('/')}".strip()
return f"/{self.root.strip('/')}".strip()
def _get_api_path(self, rel_path: str) -> str:
"""
将用户可见的相对路径转换为 Graph API 路径段。
:param rel_path: 相对路径。
:return: Graph API 路径段。
"""
full_path = self.get_effective_root(rel_path).strip('/')
if not full_path:
return ""
return f":/{full_path}"
async def _get_access_token(self) -> str:
"""
获取或刷新 access token。
:return: access token。
"""
if self._access_token and self._token_expiry and datetime.now(timezone.utc) < self._token_expiry:
return self._access_token
data = {
"client_id": self.client_id,
"client_secret": self.client_secret,
"refresh_token": self.refresh_token,
"grant_type": "refresh_token",
}
async with httpx.AsyncClient() as client:
resp = await client.post(MS_OAUTH_URL, data=data)
resp.raise_for_status()
token_data = resp.json()
self._access_token = token_data["access_token"]
self._token_expiry = datetime.now(
timezone.utc) + timedelta(seconds=token_data["expires_in"] - 300)
return self._access_token
async def _request(self, method: str, api_path_segment: str | None = None, *, full_url: str | None = None, **kwargs):
"""
向 Microsoft Graph API 发送请求。
:param method: HTTP 方法。
:param api_path_segment: API 路径段 (与 full_url 互斥)。
:param full_url: 完整的请求 URL (与 api_path_segment 互斥)。
:param kwargs: 其他请求参数。
:return: 响应对象。
"""
if not ((api_path_segment is not None) ^ (full_url is not None)):
raise ValueError("必须提供 api_path_segment 或 full_url 中的一个,且仅一个")
token = await self._get_access_token()
headers = {"Authorization": f"Bearer {token}"}
if "headers" in kwargs:
headers.update(kwargs.pop("headers"))
url = full_url if full_url else f"{MS_GRAPH_URL}/me/drive/root{api_path_segment}"
async with httpx.AsyncClient() as client:
resp = await client.request(method, url, headers=headers, **kwargs)
# 如果 token 过期 (401),刷新并重试一次
if resp.status_code == 401:
self._access_token = None # 强制刷新
token = await self._get_access_token()
headers["Authorization"] = f"Bearer {token}"
resp = await client.request(method, url, headers=headers, **kwargs)
return resp
def _format_item(self, item: Dict) -> Dict:
"""
将 Graph API 返回的 item 格式化为统一的格式。
:param item: Graph API 返回的 item 字典。
:return: 格式化后的字典。
"""
is_dir = "folder" in item
return {
"name": item["name"],
"is_dir": is_dir,
"size": 0 if is_dir else item.get("size", 0),
"mtime": int(datetime.fromisoformat(item["lastModifiedDateTime"].replace("Z", "+00:00")).timestamp()),
"type": "dir" if is_dir else "file",
}
async def list_dir(self, root: str, rel: str, page_num: int = 1, page_size: int = 50) -> Tuple[List[Dict], int]:
"""
列出目录内容。
:param root: 根路径 (在此适配器中未使用,通过配置的 root 确定)。
:param rel: 相对路径。
:param page_num: 页码。
:param page_size: 每页大小。
:return: 文件/目录列表和总数。
"""
api_path = self._get_api_path(rel)
children_path = f"{api_path}:/children" if api_path else "/children"
# Graph API 的分页是基于 @odata.nextLink token 的。
# 为了支持自定义排序(文件夹在前),我们必须获取所有项目,
# 然后在内存中进行排序和分页。此版本通过处理分页链接来稳健地获取所有项目。
all_items = []
# 初始请求
resp = await self._request("GET", api_path_segment=children_path, params={"$top": 200})
while True:
if resp.status_code == 404 and not all_items:
return [], 0
resp.raise_for_status()
try:
data = resp.json()
except Exception as e:
raise IOError(f"解析 Graph API 响应失败: {e}") from e
all_items.extend(data.get("value", []))
next_link = data.get("@odata.nextLink")
if not next_link:
break
# 后续分页请求
resp = await self._request("GET", full_url=next_link)
formatted_items = [self._format_item(item) for item in all_items]
# 排序:文件夹在前,然后按名称排序
formatted_items.sort(key=lambda x: (
not x["is_dir"], x["name"].lower()))
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:
"""
读取文件内容。
:param root: 根路径。
:param rel: 相对路径。
:return: 文件内容的字节流。
"""
api_path = self._get_api_path(rel)
if not api_path:
raise IsADirectoryError("不能将根目录作为文件读取")
resp = await self._request("GET", api_path_segment=f"{api_path}:/content")
if resp.status_code == 404:
raise FileNotFoundError(rel)
resp.raise_for_status()
return resp.content
async def write_file(self, root: str, rel: str, data: bytes):
"""
写入文件。
:param root: 根路径。
:param rel: 相对路径。
:param data: 文件内容的字节流。
"""
api_path = self._get_api_path(rel)
if not api_path:
raise ValueError("不能直接写入根路径")
resp = await self._request("PUT", api_path_segment=f"{api_path}:/content", content=data)
resp.raise_for_status()
async def write_file_stream(self, root: str, rel: str, data_iter: AsyncIterator[bytes]):
"""
以流式方式写入文件。
:param root: 根路径。
:param rel: 相对路径。
:param data_iter: 文件内容的异步迭代器。
:return: 文件大小。
"""
api_path = self._get_api_path(rel)
if not api_path:
raise ValueError("不能直接写入根路径")
resp = await self._request("PUT", api_path_segment=f"{api_path}:/content", content=data_iter)
resp.raise_for_status()
return resp.json().get("size", 0)
async def mkdir(self, root: str, rel: str):
"""
创建目录。
:param root: 根路径。
:param rel: 相对路径。
"""
parent_path_str, new_dir_name = rel.rstrip(
'/').rsplit('/', 1) if '/' in rel.rstrip('/') else ('', rel)
parent_api_path = self._get_api_path(parent_path_str)
children_path = f"{parent_api_path}:/children" if parent_api_path else "/children"
payload = {
"name": new_dir_name,
"folder": {},
"@microsoft.graph.conflictBehavior": "fail" # 如果已存在则失败
}
resp = await self._request("POST", api_path_segment=children_path, json=payload)
resp.raise_for_status()
async def delete(self, root: str, rel: str):
"""
删除文件或目录。
:param root: 根路径。
:param rel: 相对路径。
"""
api_path = self._get_api_path(rel)
if not api_path:
raise ValueError("不能删除根目录")
resp = await self._request("DELETE", api_path_segment=api_path)
if resp.status_code not in (204, 404):
resp.raise_for_status()
async def move(self, root: str, src_rel: str, dst_rel: str):
"""
移动或重命名文件/目录。
:param root: 根路径。
:param src_rel: 源相对路径。
:param dst_rel: 目标相对路径。
"""
src_api_path = self._get_api_path(src_rel)
if not src_api_path:
raise ValueError("不能移动根目录")
dst_parent_rel, dst_name = dst_rel.rstrip(
'/').rsplit('/', 1) if '/' in dst_rel.rstrip('/') else ('', dst_rel)
dst_parent_api_path = self._get_api_path(dst_parent_rel)
# 获取父项目的 ID
parent_resp = await self._request("GET", api_path_segment=dst_parent_api_path)
parent_resp.raise_for_status()
parent_id = parent_resp.json()["id"]
payload = {
"parentReference": {"id": parent_id},
"name": dst_name
}
resp = await self._request("PATCH", api_path_segment=src_api_path, json=payload)
resp.raise_for_status()
async def rename(self, root: str, src_rel: str, dst_rel: str):
"""
重命名文件或目录。
在 Graph API 中,移动和重命名是同一个 PATCH 操作。
"""
await self.move(root, src_rel, dst_rel)
async def copy(self, root: str, src_rel: str, dst_rel: str, overwrite: bool = False):
"""
复制文件或目录。
:param root: 根路径。
:param src_rel: 源相对路径。
:param dst_rel: 目标相对路径。
:param overwrite: 是否覆盖 (在此 API 中未直接使用)。
"""
src_api_path = self._get_api_path(src_rel)
if not src_api_path:
raise ValueError("不能复制根目录")
dst_parent_rel, dst_name = dst_rel.rstrip(
'/').rsplit('/', 1) if '/' in dst_rel.rstrip('/') else ('', dst_rel)
dst_parent_api_path = self._get_api_path(dst_parent_rel)
parent_resp = await self._request("GET", api_path_segment=dst_parent_api_path)
parent_resp.raise_for_status()
parent_id = parent_resp.json()["id"]
payload = {"parentReference": {"id": parent_id}, "name": dst_name}
copy_path = f"{src_api_path}:/copy"
resp = await self._request("POST", api_path_segment=copy_path, json=payload)
resp.raise_for_status()
async def stream_file(self, root: str, rel: str, range_header: str | None):
"""
流式传输文件(支持范围请求)。
:param root: 根路径。
:param rel: 相对路径。
:param range_header: HTTP Range 头。
:return: FastAPI StreamingResponse 对象。
"""
api_path = self._get_api_path(rel)
if not api_path:
raise IsADirectoryError("不能对目录进行流式传输")
resp = await self._request("GET", api_path_segment=api_path)
if resp.status_code == 404:
raise FileNotFoundError(rel)
resp.raise_for_status()
item_data = resp.json()
download_url = item_data.get("@microsoft.graph.downloadUrl")
if not download_url:
raise Exception("无法获取下载 URL")
file_size = item_data.get("size", 0)
content_type = item_data.get("file", {}).get(
"mimeType", "application/octet-stream")
start = 0
end = file_size - 1
status = 200
headers = {
"Accept-Ranges": "bytes",
"Content-Type": content_type,
"Content-Disposition": f"inline; filename=\"{item_data.get('name')}\""
}
if range_header and range_header.startswith("bytes="):
try:
part = range_header.removeprefix("bytes=")
s, e = part.split("-", 1)
if s.strip():
start = int(s)
if e.strip():
end = int(e)
if start >= file_size:
raise HTTPException(416, "Requested Range Not Satisfiable")
if end >= file_size:
end = file_size - 1
status = 206
except ValueError:
raise HTTPException(400, "Invalid Range header")
headers["Content-Range"] = f"bytes {start}-{end}/{file_size}"
headers["Content-Length"] = str(end - start + 1)
else:
headers["Content-Length"] = str(file_size)
async def file_iterator():
nonlocal start, end
async with httpx.AsyncClient() as client:
req_headers = {'Range': f'bytes={start}-{end}'}
async with client.stream("GET", download_url, headers=req_headers) as stream_resp:
stream_resp.raise_for_status()
async for chunk in stream_resp.aiter_bytes():
yield chunk
return StreamingResponse(file_iterator(), status_code=status, headers=headers, media_type=content_type)
async def get_thumbnail(self, root: str, rel: str, size: str = "medium"):
"""
获取文件的缩略图。
:param root: 根路径。
:param rel: 相对路径。
:param size: 缩略图大小 (large, medium, small)。
:return: 缩略图内容的字节流,或在不支持时返回 None。
"""
api_path = self._get_api_path(rel)
if not api_path:
return None
thumb_path = f"{api_path}:/thumbnails/0/{size}"
try:
resp = await self._request("GET", api_path_segment=thumb_path)
if resp.status_code == 200:
thumb_data = resp.json()
async with httpx.AsyncClient() as client:
thumb_resp = await client.get(thumb_data['url'])
thumb_resp.raise_for_status()
return thumb_resp.content
elif resp.status_code == 404:
return None
else:
resp.raise_for_status()
except Exception:
return None
async def stat_file(self, root: str, rel: str):
"""
获取文件或目录的元数据。
:param root: 根路径。
:param rel: 相对路径。
:return: 格式化后的文件/目录信息。
"""
api_path = self._get_api_path(rel)
resp = await self._request("GET", api_path_segment=api_path)
if resp.status_code == 404:
raise FileNotFoundError(rel)
resp.raise_for_status()
return self._format_item(resp.json())
ADAPTER_TYPE = "OneDrive"
CONFIG_SCHEMA = [
{"key": "client_id", "label": "Client ID", "type": "string", "required": True},
{"key": "client_secret", "label": "Client Secret",
"type": "password", "required": True},
{"key": "refresh_token", "label": "Refresh Token", "type": "password",
"required": True, "help_text": "可以通过运行 'python -m services.adapters.onedrive' 获取"},
{"key": "root", "label": "根目录 (Root Path)", "type": "string",
"required": False, "placeholder": "默认为根目录 /"},
]
def ADAPTER_FACTORY(rec): return OneDriveAdapter(rec)

366
services/adapters/s3.py Normal file
View File

@@ -0,0 +1,366 @@
from __future__ import annotations
import asyncio
import mimetypes
from datetime import datetime
from typing import List, Dict, Tuple, AsyncIterator
from urllib.parse import quote
import aioboto3
from botocore.exceptions import ClientError
from fastapi import HTTPException
from fastapi.responses import StreamingResponse
from models import StorageAdapter
from services.logging import LogService
class S3Adapter:
"""S3 兼容对象存储适配器"""
def __init__(self, record: StorageAdapter):
self.record = record
cfg = record.config
self.bucket_name = cfg.get("bucket_name")
self.aws_access_key_id = cfg.get("access_key_id")
self.aws_secret_access_key = cfg.get("secret_access_key")
self.region_name = cfg.get("region_name")
self.endpoint_url = cfg.get("endpoint_url")
self.root = cfg.get("root", "").strip("/")
if not all([self.bucket_name, self.aws_access_key_id, self.aws_secret_access_key]):
raise ValueError(
"S3 适配器需要 bucket_name, access_key_id, 和 secret_access_key")
self.session = aioboto3.Session(
aws_access_key_id=self.aws_access_key_id,
aws_secret_access_key=self.aws_secret_access_key,
region_name=self.region_name,
)
def get_effective_root(self, sub_path: str | None) -> str:
"""获取 S3 中的有效根路径 (key prefix)"""
if sub_path:
return f"{self.root}/{sub_path.strip('/')}".strip("/")
return self.root
def _get_s3_key(self, rel_path: str) -> str:
"""将相对路径转换为 S3 key"""
rel_path = rel_path.strip("/")
if self.root:
return f"{self.root}/{rel_path}"
return rel_path
def _get_client(self):
return self.session.client("s3", endpoint_url=self.endpoint_url)
async def list_dir(self, root: str, rel: str, page_num: int = 1, page_size: int = 50) -> Tuple[List[Dict], int]:
prefix = self._get_s3_key(rel)
if prefix and not prefix.endswith("/"):
prefix += "/"
all_items = []
async with self._get_client() as s3:
paginator = s3.get_paginator("list_objects_v2")
async for result in paginator.paginate(Bucket=self.bucket_name, Prefix=prefix, Delimiter="/"):
# 添加子目录
for common_prefix in result.get("CommonPrefixes", []):
dir_name = common_prefix.get(
"Prefix").removeprefix(prefix).strip("/")
if dir_name:
all_items.append({
"name": dir_name,
"is_dir": True,
"size": 0,
"mtime": 0,
"type": "dir",
})
# 添加文件
for content in result.get("Contents", []):
file_key = content.get("Key")
if file_key == prefix: # 忽略目录本身
continue
file_name = file_key.removeprefix(prefix)
if file_name:
all_items.append({
"name": file_name,
"is_dir": False,
"size": content.get("Size", 0),
"mtime": int(content.get("LastModified", datetime.now()).timestamp()),
"type": "file",
})
# 在内存中排序和分页
all_items.sort(key=lambda x: (not x["is_dir"], x["name"].lower()))
total_count = len(all_items)
start_idx = (page_num - 1) * page_size
end_idx = start_idx + page_size
return all_items[start_idx:end_idx], total_count
async def read_file(self, root: str, rel: str) -> bytes:
key = self._get_s3_key(rel)
async with self._get_client() as s3:
try:
resp = await s3.get_object(Bucket=self.bucket_name, Key=key)
return await resp["Body"].read()
except ClientError as e:
if e.response["Error"]["Code"] == "NoSuchKey":
raise FileNotFoundError(rel)
raise
async def write_file(self, root: str, rel: str, data: bytes):
key = self._get_s3_key(rel)
async with self._get_client() as s3:
await s3.put_object(Bucket=self.bucket_name, Key=key, Body=data)
await LogService.info(
"adapter:s3", f"Wrote file to {rel}",
details={"adapter_id": self.record.id,
"bucket": self.bucket_name, "key": key, "size": len(data)}
)
async def write_file_stream(self, root: str, rel: str, data_iter: AsyncIterator[bytes]):
key = self._get_s3_key(rel)
MIN_PART_SIZE = 5 * 1024 * 1024
async with self._get_client() as s3:
mpu = await s3.create_multipart_upload(Bucket=self.bucket_name, Key=key)
upload_id = mpu['UploadId']
parts = []
part_number = 1
total_size = 0
buffer = bytearray()
try:
async for chunk in data_iter:
if not chunk:
continue
buffer.extend(chunk)
while len(buffer) >= MIN_PART_SIZE:
part_data = buffer[:MIN_PART_SIZE]
del buffer[:MIN_PART_SIZE]
part = await s3.upload_part(
Bucket=self.bucket_name,
Key=key,
PartNumber=part_number,
UploadId=upload_id,
Body=part_data
)
parts.append({'PartNumber': part_number, 'ETag': part['ETag']})
total_size += len(part_data)
part_number += 1
if buffer:
part = await s3.upload_part(
Bucket=self.bucket_name,
Key=key,
PartNumber=part_number,
UploadId=upload_id,
Body=bytes(buffer)
)
parts.append({'PartNumber': part_number, 'ETag': part['ETag']})
total_size += len(buffer)
await s3.complete_multipart_upload(
Bucket=self.bucket_name,
Key=key,
UploadId=upload_id,
MultipartUpload={'Parts': parts}
)
except Exception as e:
await s3.abort_multipart_upload(
Bucket=self.bucket_name,
Key=key,
UploadId=upload_id
)
raise IOError(f"S3 stream upload failed: {e}") from e
await LogService.info(
"adapter:s3", f"Wrote file stream to {rel}",
details={"adapter_id": self.record.id, "bucket": self.bucket_name, "key": key, "size": total_size}
)
return total_size
async def mkdir(self, root: str, rel: str):
key = self._get_s3_key(rel)
if not key.endswith("/"):
key += "/"
async with self._get_client() as s3:
await s3.put_object(Bucket=self.bucket_name, Key=key, Body=b"")
await LogService.info(
"adapter:s3", f"Created directory {rel}",
details={"adapter_id": self.record.id,
"bucket": self.bucket_name, "key": key}
)
async def delete(self, root: str, rel: str):
key = self._get_s3_key(rel)
async with self._get_client() as s3:
is_dir_like = False
try:
head = await s3.head_object(Bucket=self.bucket_name, Key=key)
if head['ContentLength'] == 0 and key.endswith('/'):
is_dir_like = True
except ClientError as e:
if e.response['Error']['Code'] != '404':
raise
# 如果是目录,删除目录下的所有对象
if is_dir_like or not await self.stat_file(root, rel):
dir_key = key if key.endswith('/') else key + '/'
paginator = s3.get_paginator("list_objects_v2")
objects_to_delete = []
async for result in paginator.paginate(Bucket=self.bucket_name, Prefix=dir_key):
for content in result.get("Contents", []):
objects_to_delete.append({"Key": content["Key"]})
if objects_to_delete:
await s3.delete_objects(Bucket=self.bucket_name, Delete={"Objects": objects_to_delete})
# 如果是文件,直接删除
else:
await s3.delete_object(Bucket=self.bucket_name, Key=key)
await LogService.info(
"adapter:s3", f"Deleted {rel}",
details={"adapter_id": self.record.id,
"bucket": self.bucket_name, "key": key}
)
async def move(self, root: str, src_rel: str, dst_rel: str):
await self.copy(root, src_rel, dst_rel, overwrite=True)
await self.delete(root, src_rel)
await LogService.info(
"adapter:s3", f"Moved {src_rel} to {dst_rel}",
details={"adapter_id": self.record.id, "bucket": self.bucket_name,
"src_key": self._get_s3_key(src_rel), "dst_key": self._get_s3_key(dst_rel)}
)
async def rename(self, root: str, src_rel: str, dst_rel: str):
await self.move(root, src_rel, dst_rel)
async def copy(self, root: str, src_rel: str, dst_rel: str, overwrite: bool = False):
src_key = self._get_s3_key(src_rel)
dst_key = self._get_s3_key(dst_rel)
async with self._get_client() as s3:
if not overwrite:
try:
await s3.head_object(Bucket=self.bucket_name, Key=dst_key)
raise FileExistsError(dst_rel)
except ClientError as e:
if e.response["Error"]["Code"] != "404":
raise
copy_source = {"Bucket": self.bucket_name, "Key": src_key}
await s3.copy_object(CopySource=copy_source, Bucket=self.bucket_name, Key=dst_key)
await LogService.info(
"adapter:s3", f"Copied {src_rel} to {dst_rel}",
details={"adapter_id": self.record.id, "bucket": self.bucket_name,
"src_key": src_key, "dst_key": dst_key}
)
async def stat_file(self, root: str, rel: str):
key = self._get_s3_key(rel)
async with self._get_client() as s3:
try:
head = await s3.head_object(Bucket=self.bucket_name, Key=key)
return {
"name": rel.split("/")[-1],
"is_dir": False,
"size": head["ContentLength"],
"mtime": int(head["LastModified"].timestamp()),
"type": "file",
}
except ClientError as e:
if e.response["Error"]["Code"] == "404":
# 检查是否为一个 "目录"
dir_key = key if key.endswith('/') else key + '/'
resp = await s3.list_objects_v2(Bucket=self.bucket_name, Prefix=dir_key, MaxKeys=1)
if resp.get('KeyCount', 0) > 0:
return {
"name": rel.split("/")[-1],
"is_dir": True,
"size": 0,
"mtime": 0,
"type": "dir",
}
raise FileNotFoundError(rel)
raise
async def stream_file(self, root: str, rel: str, range_header: str | None):
key = self._get_s3_key(rel)
async with self._get_client() as s3:
try:
head = await s3.head_object(Bucket=self.bucket_name, Key=key)
file_size = head["ContentLength"]
content_type = head.get("ContentType", mimetypes.guess_type(key)[
0] or "application/octet-stream")
except ClientError as e:
if e.response["Error"]["Code"] == "404":
raise HTTPException(
status_code=404, detail="File not found")
raise
start = 0
end = file_size - 1
status = 200
headers = {
"Accept-Ranges": "bytes",
"Content-Type": content_type,
"Content-Length": str(file_size),
"Content-Disposition": f"inline; filename=\"{quote(rel.split('/')[-1])}\""
}
if range_header:
range_val = range_header.strip().partition("=")[2]
s, _, e = range_val.partition("-")
try:
start = int(s) if s else 0
end = int(e) if e else file_size - 1
if start >= file_size or end >= file_size or start > end:
raise HTTPException(
status_code=416, detail="Requested Range Not Satisfiable")
status = 206
headers["Content-Length"] = str(end - start + 1)
headers["Content-Range"] = f"bytes {start}-{end}/{file_size}"
except ValueError:
raise HTTPException(
status_code=400, detail="Invalid Range header")
range_arg = f"bytes={start}-{end}"
async def iterator():
try:
resp = await s3.get_object(Bucket=self.bucket_name, Key=key, Range=range_arg)
body = resp["Body"]
while chunk := await body.read(65536):
yield chunk
except Exception as e:
LogService.error(
"adapter:s3", f"Error streaming file {key}: {e}")
return StreamingResponse(iterator(), status_code=status, headers=headers, media_type=content_type)
ADAPTER_TYPE = "S3"
CONFIG_SCHEMA = [
{"key": "bucket_name", "label": "Bucket 名称",
"type": "string", "required": True},
{"key": "access_key_id", "label": "Access Key ID",
"type": "string", "required": True},
{"key": "secret_access_key", "label": "Secret Access Key",
"type": "password", "required": True},
{"key": "region_name", "label": "区域 (Region)", "type": "string",
"required": False, "placeholder": "例如 us-east-1"},
{"key": "endpoint_url", "label": "Endpoint URL", "type": "string",
"required": False, "placeholder": "对于 S3 兼容存储, 例如 https://minio.example.com"},
{"key": "root", "label": "根路径 (Root Path)", "type": "string",
"required": False, "placeholder": "在 bucket 内的路径前缀"},
]
def ADAPTER_FACTORY(rec): return S3Adapter(rec)

View File

@@ -0,0 +1,319 @@
from __future__ import annotations
from typing import List, Dict, Tuple, AsyncIterator
import io
import os
from models import StorageAdapter
from telethon import TelegramClient
from telethon.sessions import StringSession
import socks
# 适配器类型标识
ADAPTER_TYPE = "Telegram"
# 适配器配置项定义
CONFIG_SCHEMA = [
{"key": "api_id", "label": "API ID", "type": "string", "required": True, "help_text": "从 my.telegram.org 获取"},
{"key": "api_hash", "label": "API Hash", "type": "password", "required": True, "help_text": "从 my.telegram.org 获取"},
{"key": "session_string", "label": "Session String", "type": "password", "required": True, "help_text": "通过 generate_session.py 生成"},
{"key": "chat_id", "label": "Chat ID", "type": "string", "required": True, "placeholder": "频道/群组的ID或用户名, 例如: -100123456789 或 'channel_username'"},
{"key": "proxy_protocol", "label": "代理协议", "type": "string", "required": False, "placeholder": "例如: socks5, http"},
{"key": "proxy_host", "label": "代理主机", "type": "string", "required": False, "placeholder": "例如: 127.0.0.1"},
{"key": "proxy_port", "label": "代理端口", "type": "number", "required": False, "placeholder": "例如: 1080"},
]
class TelegramAdapter:
"""Telegram 存储适配器 (使用用户 Session)"""
def __init__(self, record: StorageAdapter):
self.record = record
cfg = record.config
self.api_id = int(cfg.get("api_id"))
self.api_hash = cfg.get("api_hash")
self.session_string = cfg.get("session_string")
self.chat_id_str = cfg.get("chat_id")
# 代理设置
self.proxy_protocol = cfg.get("proxy_protocol")
self.proxy_host = cfg.get("proxy_host")
self.proxy_port = cfg.get("proxy_port")
self.proxy = None
if self.proxy_protocol and self.proxy_host and self.proxy_port:
proto_map = {
"socks5": socks.SOCKS5,
"http": socks.HTTP,
}
proxy_type = proto_map.get(self.proxy_protocol.lower())
if proxy_type:
self.proxy = (proxy_type, self.proxy_host, int(self.proxy_port))
try:
self.chat_id = int(self.chat_id_str)
except (ValueError, TypeError):
self.chat_id = self.chat_id_str
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")
def _get_client(self) -> TelegramClient:
"""创建一个新的 TelegramClient 实例"""
return TelegramClient(StringSession(self.session_string), self.api_id, self.api_hash, proxy=self.proxy)
def get_effective_root(self, sub_path: str | None) -> str:
return ""
async def list_dir(self, root: str, rel: str, page_num: int = 1, page_size: int = 50) -> Tuple[List[Dict], int]:
if rel:
return [], 0
client = self._get_client()
entries = []
try:
await client.connect()
messages = await client.get_messages(self.chat_id, limit=50)
for message in messages:
if not message:
continue
media = message.document or message.video or message.photo
if not media:
continue
filename = None
size = 0
if message.photo:
photo_size = message.photo.sizes[-1]
size = photo_size.size if hasattr(photo_size, 'size') else 0
filename = f"photo_{message.id}.jpg"
elif message.document or message.video:
size = media.size
if hasattr(media, 'attributes'):
for attr in media.attributes:
if hasattr(attr, 'file_name') and attr.file_name:
filename = attr.file_name
break
if not filename:
if message.text and '.' in message.text and len(message.text) < 256 and '\n' not in message.text:
filename = message.text
if not filename:
filename = f"unknown_{message.id}"
entries.append({
"name": f"{message.id}_{filename}",
"is_dir": False,
"size": size,
"mtime": int(message.date.timestamp()),
"type": "file",
})
finally:
if client.is_connected():
await client.disconnect()
return entries, len(entries)
async def read_file(self, root: str, rel: str) -> bytes:
try:
message_id_str, _ = rel.split('_', 1)
message_id = int(message_id_str)
except (ValueError, IndexError):
raise FileNotFoundError(f"无效的文件路径格式: {rel}")
client = self._get_client()
try:
await client.connect()
message = await client.get_messages(self.chat_id, ids=message_id)
if not message or not (message.document or message.video or message.photo):
raise FileNotFoundError(f"在频道 {self.chat_id} 中未找到消息ID为 {message_id} 的文件")
file_bytes = await client.download_media(message, file=bytes)
return file_bytes
finally:
if client.is_connected():
await client.disconnect()
async def write_file(self, root: str, rel: str, data: bytes):
"""将字节数据作为文件上传"""
client = self._get_client()
file_like = io.BytesIO(data)
file_like.name = os.path.basename(rel) or "file"
try:
await client.connect()
await client.send_file(self.chat_id, file_like, caption=file_like.name)
finally:
if client.is_connected():
await client.disconnect()
async def write_file_stream(self, root: str, rel: str, data_iter: AsyncIterator[bytes]):
"""以流式方式上传文件"""
client = self._get_client()
filename = os.path.basename(rel) or "file"
import tempfile
temp_dir = tempfile.gettempdir()
temp_path = os.path.join(temp_dir, filename)
total_size = 0
try:
with open(temp_path, "wb") as f:
async for chunk in data_iter:
if chunk:
f.write(chunk)
total_size += len(chunk)
await client.connect()
await client.send_file(self.chat_id, temp_path, caption=filename)
finally:
if os.path.exists(temp_path):
os.remove(temp_path)
if client.is_connected():
await client.disconnect()
return total_size
async def mkdir(self, root: str, rel: str):
raise NotImplementedError("Telegram 适配器不支持创建目录。")
async def delete(self, root: str, rel: str):
"""删除一个文件 (即一条消息)"""
try:
message_id_str, _ = rel.split('_', 1)
message_id = int(message_id_str)
except (ValueError, IndexError):
raise FileNotFoundError(f"无效的文件路径格式无法解析消息ID: {rel}")
client = self._get_client()
try:
await client.connect()
result = await client.delete_messages(self.chat_id, [message_id])
if not result or not result[0].pts:
raise FileNotFoundError(f"{self.chat_id} 中删除消息 {message_id} 失败,可能消息不存在或无权限")
finally:
if client.is_connected():
await client.disconnect()
async def move(self, root: str, src_rel: str, dst_rel: str):
raise NotImplementedError("Telegram 适配器不支持移动。")
async def rename(self, root: str, src_rel: str, dst_rel: str):
raise NotImplementedError("Telegram 适配器不支持重命名。")
async def copy(self, root: str, src_rel: str, dst_rel: str, overwrite: bool = False):
raise NotImplementedError("Telegram 适配器不支持复制。")
async def stream_file(self, root: str, rel: str, range_header: str | None):
from fastapi.responses import StreamingResponse
from fastapi import HTTPException
try:
message_id_str, _ = rel.split('_', 1)
message_id = int(message_id_str)
except (ValueError, IndexError):
raise HTTPException(status_code=400, detail=f"无效的文件路径格式: {rel}")
client = self._get_client()
try:
await client.connect()
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} 的文件")
if message.photo:
photo_size = media.sizes[-1]
file_size = photo_size.size if hasattr(photo_size, 'size') else 0
mime_type = "image/jpeg"
else:
file_size = media.size
mime_type = media.mime_type or "application/octet-stream"
start = 0
end = file_size - 1
status = 200
headers = {
"Accept-Ranges": "bytes",
"Content-Type": mime_type,
"Content-Length": str(file_size),
}
if range_header:
try:
range_val = range_header.strip().partition("=")[2]
s, _, e = range_val.partition("-")
start = int(s) if s else 0
end = int(e) if e else file_size - 1
if start >= file_size or end >= file_size or start > end:
raise HTTPException(status_code=416, detail="Requested Range Not Satisfiable")
status = 206
headers["Content-Length"] = str(end - start + 1)
headers["Content-Range"] = f"bytes {start}-{end}/{file_size}"
except ValueError:
raise HTTPException(status_code=400, detail="Invalid Range header")
async def iterator():
try:
limit = end - start + 1
downloaded = 0
async for chunk in client.iter_download(media, offset=start):
if downloaded + len(chunk) > limit:
yield chunk[:limit - downloaded]
break
yield chunk
downloaded += len(chunk)
if downloaded >= limit:
break
finally:
if client.is_connected():
await client.disconnect()
return StreamingResponse(iterator(), status_code=status, headers=headers)
except FileNotFoundError as e:
if client.is_connected():
await client.disconnect()
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
if client.is_connected():
await client.disconnect()
raise HTTPException(status_code=500, detail=f"Streaming failed: {str(e)}")
async def stat_file(self, root: str, rel: str):
try:
message_id_str, filename = rel.split('_', 1)
message_id = int(message_id_str)
except (ValueError, IndexError):
raise FileNotFoundError(f"无效的文件路径格式: {rel}")
client = self._get_client()
try:
await client.connect()
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} 的文件")
if message.photo:
photo_size = media.sizes[-1]
size = photo_size.size if hasattr(photo_size, 'size') else 0
else:
size = media.size
return {
"name": rel,
"is_dir": False,
"size": size,
"mtime": int(message.date.timestamp()),
"type": "file",
}
finally:
if client.is_connected():
await client.disconnect()
def ADAPTER_FACTORY(rec: StorageAdapter) -> TelegramAdapter:
return TelegramAdapter(rec)

View File

@@ -2,13 +2,14 @@ import httpx
from typing import List
from services.config import ConfigCenter
async def describe_image_base64(base64_image: str, detail: str = "high") -> str:
"""
传入base64图片和文本提示返回图片描述文本。
"""
OAI_API_URL = await ConfigCenter.get("AI_API_URL", "https://api.siliconflow.cn/v1/chat/completions")
VISION_MODEL = await ConfigCenter.get("AI_VISION_MODEL", "Qwen/Qwen2.5-VL-32B-Instruct")
API_KEY = await ConfigCenter.get("AI_API_KEY", "")
OAI_API_URL = await ConfigCenter.get("AI_VISION_API_URL")
VISION_MODEL = await ConfigCenter.get("AI_VISION_MODEL")
API_KEY = await ConfigCenter.get("AI_VISION_API_KEY")
payload = {
"model": VISION_MODEL,
"messages": [
@@ -42,13 +43,14 @@ async def describe_image_base64(base64_image: str, detail: str = "high") -> str:
except Exception as e:
return f"请求失败: {str(e)}"
async def get_text_embedding(text: str) -> List[float]:
"""
传入文本,返回嵌入向量。
"""
OAI_API_URL = await ConfigCenter.get("AI_API_URL", "https://api.siliconflow.cn/v1/chat/completions")
EMBED_MODEL = await ConfigCenter.get("AI_EMBED_MODEL", "Qwen/Qwen3-Embedding-8B")
API_KEY = await ConfigCenter.get("AI_API_KEY", "")
OAI_API_URL = await ConfigCenter.get("AI_EMBED_API_URL")
EMBED_MODEL = await ConfigCenter.get("AI_EMBED_MODEL")
API_KEY = await ConfigCenter.get("AI_EMBED_API_KEY")
payload = {
"model": EMBED_MODEL,
"input": text
@@ -58,7 +60,11 @@ async def get_text_embedding(text: str) -> List[float]:
"Content-Type": "application/json"
}
async with httpx.AsyncClient() as client:
resp = await client.post(OAI_API_URL.replace("chat/completions", "embeddings"), headers=headers, json=payload)
if OAI_API_URL.endswith("chat/completions"):
url = OAI_API_URL.replace("chat/completions", "embeddings")
else:
url = OAI_API_URL
resp = await client.post(url, headers=headers, json=payload)
resp.raise_for_status()
result = resp.json()
return result["data"][0]["embedding"]

View File

@@ -4,7 +4,7 @@ from typing import Any, Optional, Dict
from dotenv import load_dotenv
from models.database import Configuration
load_dotenv(dotenv_path=".env")
VERSION = "v1.1.0"
VERSION = "v1.1.3"
class ConfigCenter:
_cache: Dict[str, Any] = {}

122
services/task_queue.py Normal file
View File

@@ -0,0 +1,122 @@
import asyncio
from typing import Dict, Any
from pydantic import BaseModel, Field
import uuid
from services.logging import LogService
from enum import Enum
class TaskStatus(str, Enum):
PENDING = "pending"
RUNNING = "running"
SUCCESS = "success"
FAILED = "failed"
class Task(BaseModel):
id: str = Field(default_factory=lambda: uuid.uuid4().hex)
name: str
status: TaskStatus = TaskStatus.PENDING
result: Any = None
error: str | None = None
task_info: Dict[str, Any] = {}
class TaskQueueService:
def __init__(self):
self._queue = asyncio.Queue()
self._tasks: Dict[str, Task] = {}
self._worker_task: asyncio.Task | None = None
async def add_task(self, name: str, task_info: Dict[str, Any]) -> Task:
task = Task(name=name, task_info=task_info)
self._tasks[task.id] = task
await self._queue.put(task)
await LogService.info("task_queue", f"Task {name} ({task.id}) enqueued", {"task_id": task.id, "name": name})
return task
def get_task(self, task_id: str) -> Task | None:
return self._tasks.get(task_id)
def get_all_tasks(self) -> list[Task]:
return list(self._tasks.values())
async def _execute_task(self, task: Task):
from services.virtual_fs import process_file
task.status = TaskStatus.RUNNING
await LogService.info("task_queue", f"Task {task.name} ({task.id}) started", {"task_id": task.id, "name": task.name})
try:
if task.name == "process_file":
params = task.task_info
result = await process_file(
path=params["path"],
processor_type=params["processor_type"],
config=params["config"],
save_to=params["save_to"]
)
task.result = result
elif task.name == "automation_task":
from models.database import AutomationTask
from services.processors.registry import get as get_processor
from services.virtual_fs import read_file, write_file
params = task.task_info
auto_task = await AutomationTask.get(id=params["task_id"])
path = params["path"]
processor = get_processor(auto_task.processor_type)
if not processor:
raise ValueError(f"Processor {auto_task.processor_type} not found for task {auto_task.id}")
file_content = await read_file(path)
result = await processor.process(file_content, path, auto_task.processor_config)
save_to = auto_task.processor_config.get("save_to")
if save_to and getattr(processor, "produces_file", False):
await write_file(save_to, result)
task.result = "Automation task completed"
else:
raise ValueError(f"Unknown task name: {task.name}")
task.status = TaskStatus.SUCCESS
await LogService.info("task_queue", f"Task {task.name} ({task.id}) succeeded", {"task_id": task.id, "name": task.name})
except Exception as e:
task.status = TaskStatus.FAILED
task.error = str(e)
await LogService.error("task_queue", f"Task {task.name} ({task.id}) failed: {e}", {"task_id": task.id, "name": task.name})
async def worker(self):
await LogService.info("task_queue", "Task worker started")
while True:
try:
task = await self._queue.get()
await self._execute_task(task)
except asyncio.CancelledError:
await LogService.info("task_queue", "Task worker stopped")
break
except Exception as e:
await LogService.error("task_queue", f"Error in task worker: {e}", exc_info=True)
finally:
self._queue.task_done()
async def start_worker(self):
if self._worker_task is None or self._worker_task.done():
self._worker_task = asyncio.create_task(self.worker())
await LogService.info("task_queue", "Task worker created.")
async def stop_worker(self):
if self._worker_task and not self._worker_task.done():
self._worker_task.cancel()
try:
await self._worker_task
except asyncio.CancelledError:
pass
finally:
self._worker_task = None
await LogService.info("task_queue", "Task worker has been stopped.")
task_queue_service = TaskQueueService()

View File

@@ -4,6 +4,9 @@ from models.database import AutomationTask
from services.processors.registry import get as get_processor
from services.logging import LogService
from services.task_queue import task_queue_service
class TaskService:
async def trigger_tasks(self, event: str, path: str):
tasks = await AutomationTask.filter(event=event, enabled=True)
@@ -21,28 +24,12 @@ class TaskService:
return True
async def execute(self, task: AutomationTask, path: str):
from services.virtual_fs import read_file, write_file
processor = get_processor(task.processor_type)
if not processor:
print(f"Processor {task.processor_type} not found for task {task.id}")
return
try:
file_content = await read_file(path)
result = await processor.process(file_content, path, task.processor_config)
save_to = task.processor_config.get("save_to")
if save_to and getattr(processor, "produces_file", False):
await write_file(save_to, result)
except Exception as e:
error_message = f"Error executing task {task.id} for path {path}: {e}"
print(error_message)
await LogService.error(
source=f"task_executor:{task.id}",
message=error_message,
details={"task_name": task.name, "event": task.event, "path": path, "processor": task.processor_type}
)
await task_queue_service.add_task(
"automation_task",
{
"task_id": task.id,
"path": path,
},
)
task_service = TaskService()

View File

@@ -5,7 +5,8 @@ from pathlib import Path
from typing import Tuple
from fastapi import HTTPException
ALLOWED_EXT = {"jpg", "jpeg", "png", "webp", "gif", "bmp", "tiff", "arw", "cr2", "cr3", "nef", "rw2", "orf", "pef", "dng"}
ALLOWED_EXT = {"jpg", "jpeg", "png", "webp", "gif", "bmp",
"tiff", "arw", "cr2", "cr3", "nef", "rw2", "orf", "pef", "dng"}
RAW_EXT = {"arw", "cr2", "cr3", "nef", "rw2", "orf", "pef", "dng"}
MAX_SOURCE_SIZE = 200 * 1024 * 1024
CACHE_ROOT = Path('data/.thumb_cache')
@@ -49,11 +50,12 @@ def generate_thumb(data: bytes, w: int, h: int, fit: str, is_raw: bool = False)
thumb = raw.extract_thumb()
except rawpy.LibRawNoThumbnailError:
thumb = None
if thumb is not None and thumb.format in [rawpy.ThumbFormat.JPEG, rawpy.ThumbFormat.BITMAP]:
im = Image.open(io.BytesIO(thumb.data))
else:
rgb = raw.postprocess(use_camera_wb=False, use_auto_wb=True, output_bps=8)
rgb = raw.postprocess(
use_camera_wb=False, use_auto_wb=True, output_bps=8)
im = Image.fromarray(rgb)
except Exception as e:
print(f"rawpy processing failed: {e}")
@@ -87,18 +89,48 @@ def generate_thumb(data: bytes, w: int, h: int, fit: str, is_raw: bool = False)
async def get_or_create_thumb(adapter, adapter_id: int, root: str, rel: str, w: int, h: int, fit: str = 'cover'):
stat = await adapter.stat_file(root, rel)
if stat['size'] > MAX_SOURCE_SIZE:
raise HTTPException(400, detail="Image too large for thumbnail")
key = _cache_key(adapter_id, rel, stat['size'], int(stat['mtime']), w, h, fit)
raise HTTPException(400, detail="Image too large for thumbnail")
key = _cache_key(adapter_id, rel, stat['size'], int(
stat['mtime']), w, h, fit)
path = _cache_path(key)
if path.exists():
return path.read_bytes(), 'image/webp', key
_ensure_cache_dir(path)
read_data = await adapter.read_file(root, rel)
try:
thumb_bytes, mime = generate_thumb(read_data, w, h, fit, is_raw=is_raw_filename(rel))
except Exception as e:
print(e)
raise HTTPException(500, detail=f"Thumbnail generation failed: {e}")
path.write_bytes(thumb_bytes)
return thumb_bytes, mime, key
thumb_bytes, mime = None, None
get_thumb_impl = getattr(adapter, "get_thumbnail", None)
if callable(get_thumb_impl):
size_str = "large" if w > 400 else "medium" if w > 100 else "small"
native_thumb_bytes = await get_thumb_impl(root, rel, size_str)
if native_thumb_bytes:
try:
from PIL import Image
im = Image.open(io.BytesIO(native_thumb_bytes))
buf = io.BytesIO()
im.save(buf, 'WEBP', quality=85)
thumb_bytes = buf.getvalue()
mime = 'image/webp'
except Exception as e:
print(
f"Failed to convert native thumbnail to WebP: {e}, falling back.")
thumb_bytes, mime = None, None
if not thumb_bytes:
read_data = await adapter.read_file(root, rel)
try:
thumb_bytes, mime = generate_thumb(
read_data, w, h, fit, is_raw=is_raw_filename(rel))
except Exception as e:
print(e)
raise HTTPException(
500, detail=f"Thumbnail generation failed: {e}")
if thumb_bytes:
path.write_bytes(thumb_bytes)
return thumb_bytes, mime, key
raise HTTPException(
500, detail="Failed to generate thumbnail by any means")

View File

@@ -42,7 +42,12 @@ async def resolve_adapter_and_rel(path: str):
raise e
adapter_instance = runtime_registry.get(adapter_model.id)
if not adapter_instance:
raise HTTPException(404, detail=f"Adapter instance not found for ID {adapter_model.id}")
await runtime_registry.refresh()
adapter_instance = runtime_registry.get(adapter_model.id)
if not adapter_instance:
raise HTTPException(
404, detail=f"Adapter instance for ID {adapter_model.id} not found or failed to load."
)
effective_root = adapter_instance.get_effective_root(adapter_model.sub_path)
return adapter_instance, adapter_model, effective_root, rel
@@ -72,7 +77,16 @@ async def list_virtual_dir(path: str, page_num: int = 1, page_size: int = 50) ->
try:
adapter_model, rel = await resolve_adapter_by_path(norm)
adapter_instance = runtime_registry.get(adapter_model.id)
effective_root = adapter_instance.get_effective_root(adapter_model.sub_path)
if not adapter_instance:
await runtime_registry.refresh()
adapter_instance = runtime_registry.get(adapter_model.id)
if adapter_instance:
effective_root = adapter_instance.get_effective_root(adapter_model.sub_path)
else:
adapter_model = None
effective_root = ""
rel = ""
except HTTPException:
adapter_model = None
adapter_instance = None

View File

@@ -8,6 +8,7 @@
"@ant-design/v5-patch-for-react-19": "^1.0.3",
"@uiw/react-md-editor": "^4.0.8",
"antd": "^5.27.0",
"artplayer": "^5.2.5",
"date-fns": "^4.1.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
@@ -316,6 +317,8 @@
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"artplayer": ["artplayer@5.2.5", "", { "dependencies": { "option-validator": "^2.0.6" } }, "sha512-Ogym5rvkAJ4VLncM4Apl3TJ/a/ozM3csvY4IKuuMR++hUmEZgj/HaGsNonwx8r56nsqiZYE7O4vS1HFZl+NBSg=="],
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
@@ -532,6 +535,8 @@
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
"kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="],
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
@@ -646,6 +651,8 @@
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
"option-validator": ["option-validator@2.0.6", "", { "dependencies": { "kind-of": "^6.0.3" } }, "sha512-tmZDan2LRIRQyhUGvkff68/O0R8UmF+Btmiiz0SmSw2ng3CfPZB9wJlIjHpe/MKUZqyIZkVIXCrwr1tIN+0Dzg=="],
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],

View File

@@ -14,6 +14,7 @@
"@ant-design/v5-patch-for-react-19": "^1.0.3",
"@uiw/react-md-editor": "^4.0.8",
"antd": "^5.27.0",
"artplayer": "^5.2.5",
"date-fns": "^4.1.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",

View File

@@ -6,7 +6,7 @@ export interface AdapterItem {
type: string;
config: any;
enabled: boolean;
mount_path?: string | null;
path?: string | null;
sub_path?: string | null;
}

View File

@@ -20,6 +20,8 @@ export interface SystemStatus {
title: string;
logo: string;
is_initialized: boolean;
app_domain?: string;
file_domain?: string;
}
export async function status() {

View File

@@ -51,7 +51,7 @@ export const vfsApi = {
streamUrl: (path: string) => `${API_BASE_URL}/fs/stream/${encodeURI(path.replace(/^\/+/, ''))}`,
stat: (path: string) => request(`/fs/stat/${encodeURI(path.replace(/^\/+/, ''))}`),
getTempLinkToken: (path: string, expiresIn: number = 3600) =>
request<{token: string}>(`/fs/temp-link/${encodeURI(path.replace(/^\/+/, ''))}?expires_in=${expiresIn}`),
request<{token: string, path: string, url: string}>(`/fs/temp-link/${encodeURI(path.replace(/^\/+/, ''))}?expires_in=${expiresIn}`),
getTempPublicUrl: (token: string) => `${API_BASE_URL}/fs/public/${token}`,
uploadStream: (fullPath: string, file: File, overwrite: boolean = true, onProgress?: (loaded: number, total: number) => void) => {
const enc = encodeURI(fullPath.replace(/^\/+/, ''));

View File

@@ -2,8 +2,10 @@ import React, { useEffect, useState } from 'react';
import { vfsApi } from '../../api/client';
import type { AppComponentProps } from '../types';
import { Spin, Result, Button } from 'antd';
import { useSystemStatus } from '../../contexts/SystemContext';
export const OfficeViewerApp: React.FC<AppComponentProps> = ({ filePath, onRequestClose }) => {
const systemStatus = useSystemStatus();
const [url, setUrl] = useState<string>();
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string>();
@@ -17,8 +19,8 @@ export const OfficeViewerApp: React.FC<AppComponentProps> = ({ filePath, onReque
vfsApi.getTempLinkToken(filePath.replace(/^\/+/, ''))
.then(res => {
if (cancelled) return;
// 注意vfsApi.getTempPublicUrl 返回的是相对路径,我们需要构建完整的 URL
const fullUrl = new URL(vfsApi.getTempPublicUrl(res.token), window.location.origin).href;
const baseUrl = systemStatus?.file_domain || window.location.origin;
const fullUrl = new URL(res.url, baseUrl).href;
const officeUrl = `https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(fullUrl)}`;
setUrl(officeUrl);
})

View File

@@ -1,408 +1,46 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { useEffect, useRef } from 'react';
import Artplayer from 'artplayer';
import { vfsApi } from '../../api/client';
import type { AppComponentProps } from '../types';
import { Spin, Button } from 'antd';
import {
PauseOutlined,
CaretRightOutlined,
SoundOutlined,
FullscreenOutlined,
ReloadOutlined
} from '@ant-design/icons';
export const VideoPlayerApp: React.FC<AppComponentProps> = ({ filePath }) => {
const containerRef = useRef<HTMLDivElement | null>(null);
const videoRef = useRef<HTMLVideoElement | null>(null);
const progressBarRef = useRef<HTMLDivElement | null>(null);
const progressRef = useRef<HTMLDivElement | null>(null);
const isMountedRef = useRef(true);
const [isPlaying, setIsPlaying] = useState(false);
const [volume, setVolume] = useState(0.7);
const [isMuted, setIsMuted] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string>();
const [url, setUrl] = useState<string>();
const [showControls, setShowControls] = useState(true);
const [retryKey, setRetryKey] = useState(0);
const controlsTimerRef = useRef<number | undefined>(undefined);
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
if (controlsTimerRef.current) {
window.clearTimeout(controlsTimerRef.current);
}
};
}, []);
const artRef = useRef<HTMLDivElement | null>(null);
const artInstance = useRef<Artplayer | null>(null);
useEffect(() => {
//
const safePath = filePath.replace(/^\/+/, '').split('#').map((seg, idx) => idx === 0 ? seg : encodeURIComponent('#') + seg).join('');
const u = vfsApi.streamUrl(safePath);
setUrl(u);
setErr(undefined);
setLoading(true);
}, [filePath, retryKey]);
const videoUrl = vfsApi.streamUrl(safePath);
// 处理视频事件
useEffect(() => {
const video = videoRef.current;
if (!video || !url) return;
const onLoadedMetadata = () => {
if (isMountedRef.current) {
setDuration(video.duration);
}
};
const onTimeUpdate = () => {
if (isMountedRef.current) {
setCurrentTime(video.currentTime);
updateProgressBar();
}
};
const onCanPlay = () => {
if (isMountedRef.current) {
setLoading(false);
}
};
const onEnded = () => {
if (isMountedRef.current) {
setIsPlaying(false);
}
};
const onError = () => {
if (isMountedRef.current) {
setLoading(false);
setErr('视频加载失败');
}
};
const onPlay = () => {
if (isMountedRef.current) {
setIsPlaying(true);
}
};
const onPause = () => {
if (isMountedRef.current) {
setIsPlaying(false);
}
};
const onProgress = () => {
// 监听缓冲进度
if (video.buffered.length > 0) {
const bufferedEnd = video.buffered.end(video.buffered.length - 1);
if (progressBarRef.current) {
const bufferProgress = bufferedEnd / video.duration * 100;
progressBarRef.current.style.setProperty('--buffer-width', `${bufferProgress}%`);
}
}
};
video.addEventListener('loadedmetadata', onLoadedMetadata);
video.addEventListener('timeupdate', onTimeUpdate);
video.addEventListener('canplay', onCanPlay);
video.addEventListener('ended', onEnded);
video.addEventListener('error', onError);
video.addEventListener('play', onPlay);
video.addEventListener('pause', onPause);
video.addEventListener('progress', onProgress);
if (artRef.current) {
artInstance.current = new Artplayer({
container: artRef.current,
url: videoUrl,
autoplay: true,
fullscreen: true,
fullscreenWeb: true,
pip: true,
setting: true,
playbackRate: true,
});
}
return () => {
video.removeEventListener('loadedmetadata', onLoadedMetadata);
video.removeEventListener('timeupdate', onTimeUpdate);
video.removeEventListener('canplay', onCanPlay);
video.removeEventListener('ended', onEnded);
video.removeEventListener('error', onError);
video.removeEventListener('play', onPlay);
video.removeEventListener('pause', onPause);
video.removeEventListener('progress', onProgress);
};
}, [url]);
// 处理进度条更新
const updateProgressBar = () => {
const video = videoRef.current;
const progress = progressRef.current;
if (video && progress && duration > 0) {
const percentage = (video.currentTime / duration) * 100;
progress.style.width = `${percentage}%`;
}
};
// 处理进度条点击
const handleProgressBarClick = (e: React.MouseEvent<HTMLDivElement>) => {
const progressBar = progressBarRef.current;
const video = videoRef.current;
if (progressBar && video) {
const rect = progressBar.getBoundingClientRect();
const clickPosition = e.clientX - rect.left;
const percentage = clickPosition / rect.width;
const newTime = percentage * duration;
video.currentTime = newTime;
setCurrentTime(newTime);
}
};
// 播放/暂停
const togglePlay = () => {
const video = videoRef.current;
if (!video) return;
if (isPlaying) {
video.pause();
} else {
video.play().catch(error => {
console.error('播放失败:', error);
setErr('播放失败');
});
}
};
// 全屏
const toggleFullscreen = () => {
const container = containerRef.current;
if (!container) return;
if (!document.fullscreenElement) {
container.requestFullscreen().catch(err => {
console.error('全屏失败:', err);
});
} else {
document.exitFullscreen();
}
};
// 音量控制
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newVolume = parseFloat(e.target.value);
setVolume(newVolume);
const video = videoRef.current;
if (video) {
video.volume = newVolume;
setIsMuted(newVolume === 0);
}
};
// 静音切换
const toggleMute = () => {
const video = videoRef.current;
if (!video) return;
const newMuted = !isMuted;
setIsMuted(newMuted);
video.muted = newMuted;
};
// 格式化时间显示
const formatTime = (seconds: number): string => {
if (isNaN(seconds)) return '00:00';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
// 控制栏自动隐藏
const resetControlsTimer = () => {
if (controlsTimerRef.current) {
window.clearTimeout(controlsTimerRef.current);
}
setShowControls(true);
controlsTimerRef.current = window.setTimeout(() => {
if (isPlaying && isMountedRef.current) {
setShowControls(false);
if (artInstance.current) {
artInstance.current.destroy();
}
}, 3000);
};
const handleMouseMove = () => {
resetControlsTimer();
};
const retry = () => setRetryKey(k => k + 1);
};
}, [filePath]);
return (
<div
style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', background: '#000' }}
ref={containerRef}
onMouseMove={handleMouseMove}
>
<div style={{ flex: 1, position: 'relative', backgroundColor: '#000', overflow: 'hidden' }}>
{/* 视频元素 */}
<video
ref={videoRef}
style={{
width: '100%',
height: '100%',
objectFit: 'contain'
}}
src={url}
controlsList="nodownload"
crossOrigin="anonymous"
preload="metadata"
onClick={togglePlay}
/>
{/* 加载指示器 */}
{loading && !err && (
<div style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.35)', gap: 12 }}>
<Spin />
<span style={{ fontSize: 12, color: '#aaa' }}>...</span>
</div>
)}
{/* 错误显示 */}
{err && (
<div style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.5)', gap: 12 }}>
<span style={{ color: '#ff4d4f', fontSize: 13 }}>{err}</span>
<Button icon={<ReloadOutlined />} size="small" onClick={retry}></Button>
</div>
)}
{/* 控制栏 */}
{showControls && (
<div
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
background: 'linear-gradient(transparent, rgba(0,0,0,0.7))',
padding: '30px 15px 10px',
transition: 'opacity 0.3s',
opacity: showControls ? 1 : 0,
display: 'flex',
flexDirection: 'column',
gap: '8px'
}}
>
{/* 进度条 */}
<div
ref={progressBarRef}
onClick={handleProgressBarClick}
style={{
height: '4px',
backgroundColor: 'rgba(255,255,255,0.2)',
cursor: 'pointer',
position: 'relative',
borderRadius: '2px',
'--buffer-width': '0%'
} as React.CSSProperties}
>
<div
style={{
position: 'absolute',
height: '100%',
width: 'var(--buffer-width)',
backgroundColor: 'rgba(255,255,255,0.4)',
borderRadius: '2px'
}}
/>
<div
ref={progressRef}
style={{
height: '100%',
width: '0%',
backgroundColor: '#1890ff',
position: 'relative',
borderRadius: '2px',
}}
>
<div
style={{
position: 'absolute',
right: '-6px',
top: '-4px',
width: '12px',
height: '12px',
borderRadius: '50%',
backgroundColor: '#1890ff',
}}
/>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<Button
type="text"
icon={isPlaying ? <PauseOutlined /> : <CaretRightOutlined />}
onClick={togglePlay}
style={{ color: '#fff' }}
/>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', width: '100px' }}>
<Button
type="text"
icon={<SoundOutlined />}
onClick={toggleMute}
style={{ color: isMuted ? '#888' : '#fff' }}
/>
<input
type="range"
min="0"
max="1"
step="0.01"
value={isMuted ? 0 : volume}
onChange={handleVolumeChange}
style={{ width: '60px' }}
/>
</div>
<div style={{ color: '#fff', fontSize: '12px' }}>
{formatTime(currentTime)} / {formatTime(duration)}
</div>
</div>
<div>
<Button
type="text"
icon={<FullscreenOutlined />}
onClick={toggleFullscreen}
style={{ color: '#fff' }}
/>
</div>
</div>
</div>
)}
{!isPlaying && !loading && !err && (
<div
onClick={togglePlay}
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: '60px',
height: '60px',
borderRadius: '50%',
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer'
}}
>
<CaretRightOutlined style={{ fontSize: '24px', color: '#fff' }} />
</div>
)}
</div>
</div>
ref={artRef}
style={{
width: '100%',
height: '100%',
backgroundColor: '#000'
}}
/>
);
};

View File

@@ -5,6 +5,7 @@ import { memo, useEffect, useState } from 'react';
import { useSystemStatus } from '../contexts/SystemContext.tsx';
import {
CheckCircleOutlined,
FileTextOutlined,
GithubOutlined,
MenuFoldOutlined,
SendOutlined,
@@ -154,8 +155,8 @@ const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle
}} onClick={showVersionModal}>
{hasUpdate ? (
<Tooltip title={`发现新版本: ${latestVersion?.version}`} placement={collapsed ? 'right' : 'top'}>
<a href="https://github.com/DrizzleTime/Foxel/releases" target="_blank" rel="noopener noreferrer"
style={{ textDecoration: 'none' }}>
<a rel="noopener noreferrer"
style={{ textDecoration: 'none' }}>
{collapsed ? (
<Tag icon={<WarningOutlined />} color="warning" style={{ marginInlineEnd: 0 }} />
) : (
@@ -199,8 +200,14 @@ const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle
href="https://t.me/+thDsBfyqJxZkNTU1"
target="_blank"
/>
<Button
shape="circle"
icon={<FileTextOutlined />}
href="https://foxel.cc"
target="_blank"
/>
</div>
</div>
</Sider>
<Modal
@@ -241,11 +248,11 @@ const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle
{hasUpdate && (
<Alert
message={`发现新版本: ${latestVersion.version}`}
description="建议尽快更新到最新版本,以获得新功能和安全修复。"
message={<span style={{ color: token.colorText }}>{`发现新版本: ${latestVersion.version}`}</span>}
description={<span style={{ color: token.colorTextSecondary }}></span>}
type="info"
showIcon
style={{ marginTop: 24, marginBottom: 24 }}
style={{ marginTop: 24, marginBottom: 24, background: token.colorInfoBg, borderColor: token.colorInfoBorder }}
action={
<Button
size="small"
@@ -276,7 +283,8 @@ const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle
borderBottom: `1px solid ${token.colorBorderSecondary}`,
paddingBottom: 8,
marginTop: 24,
marginBottom: 16
marginBottom: 16,
color: token.colorTextHeading
}} {...props} />,
ul: ({ ...props }) => <ul style={{ paddingLeft: 20 }} {...props} />,
li: ({ ...props }) => <li style={{ marginBottom: 8 }} {...props} />,

View File

@@ -1,17 +1,8 @@
import { memo, useState, useEffect, useCallback } from 'react';
import { Table, Button, Space, Drawer, Form, Input, Switch, message, Typography, Popconfirm, Select } from 'antd';
import PageCard from '../components/PageCard';
import { adaptersApi } from '../api/client';
import { adaptersApi, type AdapterItem } from '../api/client';
interface AdapterItem {
id: number;
name: string;
type: string;
config: any;
enabled: boolean;
mount_path?: string | null;
sub_path?: string | null;
}
interface AdapterTypeField {
key: string;
@@ -65,7 +56,7 @@ const AdaptersPage = memo(function AdaptersPage() {
form.setFieldsValue({
name: '',
type: defaultType,
mount_path: '/',
path: '/',
sub_path: '',
enabled: true,
config: cfgDefaults
@@ -79,7 +70,7 @@ const AdaptersPage = memo(function AdaptersPage() {
form.setFieldsValue({
name: rec.name,
type: rec.type,
mount_path: rec.mount_path || '/',
path: rec.path || '/',
sub_path: rec.sub_path || '',
enabled: rec.enabled,
config: rec.config || {}
@@ -105,7 +96,7 @@ const AdaptersPage = memo(function AdaptersPage() {
const body = {
name: values.name.trim(),
type: values.type,
mount_path: values.mount_path || '/',
path: values.path || '/',
sub_path: values.sub_path?.trim() || null,
enabled: values.enabled,
config: cfg
@@ -155,7 +146,7 @@ const AdaptersPage = memo(function AdaptersPage() {
const columns = [
{ title: '名称', dataIndex: 'name' },
{ title: '类型', dataIndex: 'type', width: 100 },
{ title: '挂载路径', dataIndex: 'mount_path', width: 140, render: (v: string) => v || '-' },
{ title: '挂载路径', dataIndex: 'path', width: 140, render: (v: string) => v || '-' },
{ title: '子路径', dataIndex: 'sub_path', width: 140, render: (v: string) => v || '-' },
{
title: '启用',
@@ -251,7 +242,6 @@ const AdaptersPage = memo(function AdaptersPage() {
placeholder="选择适配器类型"
options={availableTypes.map(t => ({ value: t.type, label: `${t.name} (${t.type})` }))}
onChange={() => {
// 切换类型时刷新默认 config
const t = availableTypes.find(v => v.type === form.getFieldValue('type'));
const cfgDefaults: Record<string, any> = {};
t?.config_schema.forEach(f => {
@@ -261,7 +251,7 @@ const AdaptersPage = memo(function AdaptersPage() {
}}
/>
</Form.Item>
<Form.Item name="mount_path" label="挂载路径" rules={[{ required: true, message: '请输入挂载路径' }]}>
<Form.Item name="path" label="挂载路径" rules={[{ required: true, message: '请输入挂载路径' }]}>
<Input placeholder="/或/drive" />
</Form.Item>
<Form.Item name="sub_path" label="子路径(可选)">

View File

@@ -109,7 +109,7 @@ const FileExplorerPage = memo(function FileExplorerPage() {
<div style={{ flex: 1, overflow: 'auto', paddingBottom: pagination.total > 0 ? '80px' : '0' }} onContextMenu={openBlankContextMenu}>
{loading && entries.length === 0 ? (
<div style={{ textAlign: 'center', padding: 40 }}><EmptyState isRoot={path === '/'} onCreateDir={() => setCreatingDir(true)} onGoUp={goUp} /></div>
<div style={{ textAlign: 'center', padding: 40 }}><EmptyState isRoot={path === '/'} /></div>
) : viewMode === 'grid' ? (
<GridView
entries={entries}
@@ -121,8 +121,6 @@ const FileExplorerPage = memo(function FileExplorerPage() {
onSelectRange={handleSelectRange}
onOpen={handleOpenEntry}
onContextMenu={openContextMenu}
onCreateDir={() => setCreatingDir(true)}
onGoUp={goUp}
/>
) : (
<FileListView

View File

@@ -1,14 +1,12 @@
import React from 'react';
import { Button, Space, Typography, theme } from 'antd';
import { PlusOutlined, CloudUploadOutlined, ArrowUpOutlined, FolderOpenOutlined } from '@ant-design/icons';
import { Typography, theme } from 'antd';
import { FolderOpenOutlined } from '@ant-design/icons';
interface Props {
isRoot: boolean;
onCreateDir: () => void;
onGoUp: () => void;
}
export const EmptyState: React.FC<Props> = ({ isRoot, onCreateDir, onGoUp }) => {
export const EmptyState: React.FC<Props> = ({ isRoot }) => {
const { token } = theme.useToken();
return (
<div style={{ display:'flex', flexDirection:'column', alignItems:'center', justifyContent:'center', padding:isRoot? '80px 40px':'60px 40px', minHeight: isRoot? '400px':'300px', color: token.colorTextSecondary }}>
@@ -19,15 +17,6 @@ export const EmptyState: React.FC<Props> = ({ isRoot, onCreateDir, onGoUp }) =>
<Typography.Text style={{ color: token.colorTextTertiary, marginBottom:24, textAlign:'center', maxWidth:300, lineHeight:1.5 }}>
{isRoot ? '开始上传文件或创建新目录来组织您的内容' : '您可以在此目录中创建新的文件夹或上传文件'}
</Typography.Text>
<Space size={12}>
<Button type="primary" icon={<PlusOutlined />} onClick={onCreateDir}></Button>
<Button icon={<CloudUploadOutlined />} disabled></Button>
</Space>
{!isRoot && (
<div style={{ marginTop:16 }}>
<Button type="link" size="small" icon={<ArrowUpOutlined />} onClick={onGoUp} style={{ color: token.colorTextTertiary }}></Button>
</div>
)}
</div>
);
};

View File

@@ -7,20 +7,14 @@ import { EmptyState } from './EmptyState';
interface Props {
entries: VfsEntry[];
thumbs: Record<string,string>;
// ...existing code...
// selected was single entry before; now use selectedEntries for multi-select
thumbs: Record<string, string>;
selectedEntries: string[];
loading: boolean;
path: string;
// onSelect: clicked entry, additive indicates Ctrl/Cmd click to toggle
onSelect: (e: VfsEntry, additive?: boolean) => void;
// onSelectRange: called when marquee/selecting multiple by box
onSelectRange: (names: string[]) => void;
onOpen: (e: VfsEntry) => void;
onContextMenu: (e: React.MouseEvent, entry: VfsEntry) => void;
onCreateDir: () => void;
onGoUp: () => void;
}
const formatSize = (size: number) => {
@@ -30,14 +24,12 @@ const formatSize = (size: number) => {
return (size / 1024 / 1024 / 1024).toFixed(1) + ' GB';
};
export const GridView: React.FC<Props> = ({ entries, thumbs, selectedEntries, loading, path, onSelect, onSelectRange, onOpen, onContextMenu, onCreateDir, onGoUp }) => {
export const GridView: React.FC<Props> = ({ entries, thumbs, selectedEntries, loading, path, onSelect, onSelectRange, onOpen, onContextMenu }) => {
const { token } = theme.useToken();
// refs for marquee selection
const containerRef = useRef<HTMLDivElement | null>(null);
const itemRefs = useRef<Record<string, HTMLDivElement | null>>({});
const startRef = useRef<{x:number,y:number} | null>(null);
const [rect, setRect] = useState<{left:number,top:number,width:number,height:number} | null>(null);
const startRef = useRef<{ x: number, y: number } | null>(null);
const [rect, setRect] = useState<{ left: number, top: number, width: number, height: number } | null>(null);
const [selecting, setSelecting] = useState(false);
useEffect(() => {
@@ -52,12 +44,11 @@ export const GridView: React.FC<Props> = ({ entries, thumbs, selectedEntries, lo
const height = Math.abs(cy - s.y);
setRect({ left, top, width, height });
};
const onUp = () => { // 不需要 MouseEvent 参数,避免未使用警告
const onUp = () => {
if (!startRef.current) return;
setSelecting(false);
const r = rect;
if (r) {
// compute intersecting items
const container = containerRef.current;
if (container) {
const sel: string[] = [];
@@ -89,17 +80,14 @@ export const GridView: React.FC<Props> = ({ entries, thumbs, selectedEntries, lo
}, [selecting, rect, entries, onSelectRange]);
const handleMouseDown = (e: React.MouseEvent) => {
// only left button and not on an item actionable element
if (e.button !== 0) return;
// start marquee if click on empty space inside container
const target = e.target as HTMLElement;
if (target.closest('.fx-grid-item')) {
return; // clicks on item handled separately
return;
}
startRef.current = { x: e.clientX, y: e.clientY };
setSelecting(true);
setRect({ left: e.clientX, top: e.clientY, width: 0, height: 0 });
// prevent text selection
e.preventDefault();
};
@@ -108,29 +96,28 @@ export const GridView: React.FC<Props> = ({ entries, thumbs, selectedEntries, lo
{entries.map(ent => {
const isImg = thumbs[ent.name];
const ext = ent.name.split('.').pop()?.toLowerCase();
const isPictureType = ['png','jpg','jpeg','gif','webp','svg'].includes(ext || '');
const isPictureType = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext || '');
const isSelected = selectedEntries.includes(ent.name);
return (
<div
key={ent.name}
ref={(el) => { itemRefs.current[ent.name] = el; }} // 确保函数不返回值,匹配 Ref 类型
className={['fx-grid-item', isSelected ? 'selected' : '', ent.is_dir? 'dir':'file'].join(' ')}
ref={(el) => { itemRefs.current[ent.name] = el; }}
className={['fx-grid-item', isSelected ? 'selected' : '', ent.is_dir ? 'dir' : 'file'].join(' ')}
onClick={(ev) => {
// click selection: support ctrl/cmd to toggle
const additive = ev.ctrlKey || ev.metaKey;
onSelect(ent, additive);
}}
onDoubleClick={() => onOpen(ent)}
onContextMenu={(e)=> onContextMenu(e, ent)}
style={{ userSelect:'none' }}
onContextMenu={(e) => onContextMenu(e, ent)}
style={{ userSelect: 'none' }}
>
<div className="thumb" style={{ background: ent.is_dir ? 'linear-gradient(#fafafa,#f2f2f2)' : '#fff' }}>
{ent.is_dir && <FolderFilled style={{ fontSize:32, color: token.colorPrimary }} />}
{!ent.is_dir && (isImg ? <img src={isImg} alt={ent.name} style={{ maxWidth:'100%', maxHeight:'100%'}} /> : isPictureType ? <PictureOutlined style={{ fontSize:32, color:'#8c8c8c' }} /> : getFileIcon(ent.name,32))}
{ent.is_dir && <FolderFilled style={{ fontSize: 32, color: token.colorPrimary }} />}
{!ent.is_dir && (isImg ? <img src={isImg} alt={ent.name} style={{ maxWidth: '100%', maxHeight: '100%' }} /> : isPictureType ? <PictureOutlined style={{ fontSize: 32, color: '#8c8c8c' }} /> : getFileIcon(ent.name, 32))}
{ent.type === 'mount' && <span className="badge">M</span>}
</div>
<Tooltip title={ent.name}><div className="name ellipsis" style={{ userSelect:'none' }}>{ent.name}</div></Tooltip>
<div className="meta ellipsis" style={{ fontSize:11, color: token.colorTextSecondary, userSelect:'none' }}>{ent.is_dir ? '目录' : formatSize(ent.size)}</div>
<Tooltip title={ent.name}><div className="name ellipsis" style={{ userSelect: 'none' }}>{ent.name}</div></Tooltip>
<div className="meta ellipsis" style={{ fontSize: 11, color: token.colorTextSecondary, userSelect: 'none' }}>{ent.is_dir ? '目录' : formatSize(ent.size)}</div>
</div>
)
})}
@@ -148,8 +135,8 @@ export const GridView: React.FC<Props> = ({ entries, thumbs, selectedEntries, lo
}}
/>
)}
{loading && <div style={{ width:'100%', textAlign:'center', padding:40 }}><Spin /></div>}
{!loading && entries.length === 0 && <EmptyState isRoot={path==='/' } onCreateDir={onCreateDir} onGoUp={onGoUp} />}
{loading && <div style={{ width: '100%', textAlign: 'center', padding: 40 }}><Spin /></div>}
{!loading && entries.length === 0 && <EmptyState isRoot={path === '/'} />}
</div>
);
};

View File

@@ -29,8 +29,7 @@ export const DirectLinkModal = memo(function DirectLinkModal({ entry, path, open
try {
const fullPath = (path === '/' ? '' : path) + '/' + entry.name;
const res = await vfsApi.getTempLinkToken(fullPath, expiresIn);
const tempLink = `${window.location.origin}/api/fs/public/${res.token}`;
setLink(tempLink);
setLink(res.url);
} catch (e: any) {
message.error(e.message || '生成链接失败');
} finally {

View File

@@ -3,6 +3,7 @@ import { Modal, Form, Input, Radio, InputNumber, message, Button, Typography } f
import { CopyOutlined } from '@ant-design/icons';
import type { VfsEntry, ShareInfoWithPassword } from '../../../../api/client';
import { shareApi } from '../../../../api/share';
import { useSystemStatus } from '../../../../contexts/SystemContext';
interface ShareModalProps {
entries: VfsEntry[];
@@ -13,6 +14,7 @@ interface ShareModalProps {
}
export const ShareModal = memo(function ShareModal({ entries, path, open, onOk, onCancel }: ShareModalProps) {
const systemStatus = useSystemStatus();
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [accessType, setAccessType] = useState('public');
@@ -66,7 +68,8 @@ export const ShareModal = memo(function ShareModal({ entries, path, open, onOk,
message.success('已复制到剪贴板');
};
const shareUrl = createdShare ? `${window.location.origin}/share/${createdShare.token}` : '';
const baseUrl = systemStatus?.app_domain || window.location.origin;
const shareUrl = createdShare ? new URL(`/share/${createdShare.token}`, baseUrl).href : '';
const renderForm = () => (
<Form form={form} layout="vertical" initialValues={{ name: defaultName, accessType: 'public', expiresInDays: 7 }}>

View File

@@ -26,7 +26,7 @@ const SetupPage = () => {
root: values.root_dir
},
sub_path: null,
mount_path: values.mount_path,
path: values.path,
enabled: true
});
window.location.href = '/';
@@ -41,7 +41,7 @@ const SetupPage = () => {
const stepFields = [
['db_driver', 'vector_db_driver'],
['adapter_name', 'adapter_type', 'mount_path', 'root_dir'],
['adapter_name', 'adapter_type', 'path', 'root_dir'],
['username', 'full_name', 'email', 'password', 'confirm'],
]
@@ -105,7 +105,7 @@ const SetupPage = () => {
</Form.Item>
<Form.Item
label="挂载路径"
name="mount_path"
name="path"
initialValue="/local"
rules={[{ required: true, message: '请输入挂载路径!' }]}
>

View File

@@ -4,8 +4,10 @@ import PageCard from '../components/PageCard';
import { shareApi, type ShareInfo } from '../api/share';
import { format, parseISO } from 'date-fns';
import { LinkOutlined, CopyOutlined, DeleteOutlined } from '@ant-design/icons';
import { useSystemStatus } from '../contexts/SystemContext';
const SharePage = memo(function SharePage() {
const systemStatus = useSystemStatus();
const [loading, setLoading] = useState(false);
const [data, setData] = useState<ShareInfo[]>([]);
@@ -24,7 +26,8 @@ const SharePage = memo(function SharePage() {
useEffect(() => { fetchList(); }, [fetchList]);
const doCopy = (rec: ShareInfo) => {
const shareUrl = `${window.location.origin}/share/${rec.token}`;
const baseUrl = systemStatus?.app_domain || window.location.origin;
const shareUrl = new URL(`/share/${rec.token}`, baseUrl).href;
navigator.clipboard.writeText(shareUrl);
message.success('链接已复制');
};

View File

@@ -1,26 +1,33 @@
import { Form, Input, Button, message, Tabs, Space } from 'antd';
import { Form, Input, Button, message, Tabs, Space, Card } from 'antd';
import { useEffect, useState } from 'react';
import PageCard from '../../components/PageCard';
import { getAllConfig, setConfig } from '../../api/config';
import { API_BASE_URL } from '../../api/client';
import { AppstoreOutlined, RobotOutlined } from '@ant-design/icons';
const APP_CONFIG_KEYS = [
const APP_CONFIG_KEYS: {key: string, label: string, default?: string}[] = [
{ key: 'APP_NAME', label: '应用名称' },
{ key: 'APP_LOGO', label: 'LOGO地址' },
{ key: 'SERVER_URL', label: '服务端URL', default: API_BASE_URL },
{ key: 'APP_DOMAIN', label: '应用域名' },
{ key: 'FILE_DOMAIN', label: '文件域名' },
];
const AI_CONFIG_KEYS = [
{ key: 'AI_API_URL', label: 'AI API地址' },
{ key: 'AI_VISION_MODEL', label: '视觉模型' },
{ key: 'AI_EMBED_MODEL', label: '嵌入模型' },
{ key: 'AI_API_KEY', label: 'API Key' },
const VISION_CONFIG_KEYS = [
{ key: 'AI_VISION_API_URL', label: '视觉模型 API 地址' },
{ key: 'AI_VISION_MODEL', label: '视觉模型', default: 'Qwen/Qwen2.5-VL-32B-Instruct' },
{ key: 'AI_VISION_API_KEY', label: '视觉模型 API Key' },
];
const EMBED_CONFIG_KEYS = [
{ key: 'AI_EMBED_API_URL', label: '嵌入模型 API 地址' },
{ key: 'AI_EMBED_MODEL', label: '嵌入模型', default: 'Qwen/Qwen3-Embedding-8B' },
{ key: 'AI_EMBED_API_KEY', label: '嵌入模型 API Key' },
];
const ALL_AI_KEYS = [...VISION_CONFIG_KEYS, ...EMBED_CONFIG_KEYS];
export default function SystemSettingsPage() {
const [loading, setLoading] = useState(false);
const [config, setConfigState] = useState<Record<string, string> | null>(null);
const [config, setConfigState] = useState<Record<string, string> | null>(null);
const [activeTab, setActiveTab] = useState('app');
useEffect(() => {
@@ -41,14 +48,13 @@ export default function SystemSettingsPage() {
setLoading(false);
};
// 加载中时不渲染表单
if (!config) {
return <PageCard title='系统设置'><div>...</div></PageCard>;
}
return (
<PageCard
title='系统设置'
title='系统设置'
>
<Space direction="vertical" style={{ width: '100%' }} size={32}>
<Tabs
@@ -73,7 +79,7 @@ export default function SystemSettingsPage() {
}}
onFinish={handleSave}
style={{ marginTop: 24 }}
key={JSON.stringify(config)}
key={JSON.stringify(config)}
>
{APP_CONFIG_KEYS.map(({ key, label }) => (
<Form.Item key={key} name={key} label={label}>
@@ -100,18 +106,27 @@ export default function SystemSettingsPage() {
<Form
layout="vertical"
initialValues={{
...Object.fromEntries(AI_CONFIG_KEYS.map(({ key }) => [key, config[key] ?? ''])),
...Object.fromEntries(ALL_AI_KEYS.map(({ key, default: def }) => [key, config[key] ?? def ?? ''])),
}}
onFinish={handleSave}
style={{ marginTop: 24 }}
key={JSON.stringify(config)} // 强制表单重置
key={JSON.stringify(config)}
>
{AI_CONFIG_KEYS.map(({ key, label }) => (
<Form.Item key={key} name={key} label={label}>
<Input size="large" />
</Form.Item>
))}
<Form.Item>
<Card title="视觉模型" style={{ marginBottom: 24 }}>
{VISION_CONFIG_KEYS.map(({ key, label }) => (
<Form.Item key={key} name={key} label={label}>
<Input size="large" />
</Form.Item>
))}
</Card>
<Card title="嵌入模型">
{EMBED_CONFIG_KEYS.map(({ key, label }) => (
<Form.Item key={key} name={key} label={label}>
<Input size="large" />
</Form.Item>
))}
</Card>
<Form.Item style={{ marginTop: 24 }}>
<Button type="primary" htmlType="submit" loading={loading} block>
</Button>

View File

@@ -4,6 +4,7 @@ import type { ThemeConfig } from 'antd/es/config-provider/context';
export const foxelTheme: ThemeConfig = {
algorithm: [theme.defaultAlgorithm, theme.compactAlgorithm],
token: {
colorInfoBg: '#efefef',
colorPrimary: '#111',
colorInfo: '#111',
colorText: '#111',
@@ -37,7 +38,7 @@ export const foxelTheme: ThemeConfig = {
},
Card: {
borderRadiusLG: 16,
padding: 16
padding: 16
},
Input: { borderRadius: 8 },
Dropdown: { controlItemBgHover: '#f2f2f2' },