Compare commits

...

53 Commits

Author SHA1 Message Date
shiyu
2b8cfce8f2 chore: update version to v1.2.1 2025-09-09 16:56:26 +08:00
shiyu
db453ef09b feat: add i18n with language switcher and English/Chinese translations 2025-09-09 16:50:43 +08:00
shiyu
59c017a05b fix: URL format when generating links 2025-09-09 11:59:01 +08:00
shiyu
d42c6b5cee feat: Support more video formats 2025-09-08 19:15:09 +08:00
shiyu
9e69eb3e20 chore: update version to v1.2.0 2025-09-08 16:53:56 +08:00
shiyu
6e7225ac40 feat: implement Quark adapter 2025-09-08 16:51:09 +08:00
shiyu
d41b72d0ce feat: Add theme and dark mode 2025-09-08 15:20:49 +08:00
shiyu
f40ff4d751 feat: Add App Center plugin functionality 2025-09-08 12:28:37 +08:00
shiyu
280bedcf1a chore: Update version to v1.1.6 2025-09-07 17:00:25 +08:00
shiyu
b03f2619ca feat: Add vector database clearing 2025-09-07 16:48:14 +08:00
Kuenpan Foo
72403d5861 feat: Support Docker for ARM architecture(#35) 2025-09-07 16:46:18 +08:00
ShiYu
dffcdb7a8b feat: Add video playback and image preview support to share page 2025-09-07 11:05:10 +08:00
shiyu
19c4394f3d feat: Add queue management functionality to TasksPage 2025-09-06 19:44:00 +08:00
时雨
3fd48da2b4 fix: Remove uv sync command from Dockerfile to streamline installation (#33) 2025-09-06 16:55:28 +08:00
shiyu
c759b36aba fix: Remove --system flag from uv sync command in Dockerfile 2025-09-06 16:29:27 +08:00
shiyu
99a6acd54a feat: Update Dockerfile to use uv for package management 2025-09-06 16:27:30 +08:00
shiyu
20f6b5c210 chore: Update version to v1.1.5 2025-09-06 16:17:12 +08:00
shiyu
74ffc0bb30 feat: Add sorting functionality to the virtual file system and adapter list methods 2025-09-06 16:15:24 +08:00
shiyu
57919aa7ae feat: Add httpx.AsyncClient timeout settings 2025-09-06 15:27:25 +08:00
shiyu
5126dae411 feat: Migrate to uv for environment management 2025-09-06 14:11:15 +08:00
shiyu
2a78d809af feat: Implement upsert and remove methods in RuntimeRegistry for adapter management 2025-09-05 13:36:12 +08:00
shiyu
ce74c2712b chore: Update version to v1.1.4 2025-09-04 11:00:39 +08:00
Copilot
59d6c94a57 feat: Add Markdown direct link feature to DirectLinkModal (#28)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: DrizzleTime <169802108+DrizzleTime@users.noreply.github.com>
Co-authored-by: 时雨 <im@shiyu.dev>
2025-09-04 10:38:37 +08:00
Zhang Jian
fd87dc3ce2 feat: Add auto setup Foxel sh (#26) 2025-09-03 09:56:14 +08:00
shiyu
620ae17732 feat: Update Nginx configuration to include openapi.json in location block 2025-09-02 14:24:47 +08:00
shiyu
9b0dd13816 feat: Add file drag-and-drop functionality #25 2025-09-01 13:38:51 +08:00
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
shiyu
cfcb28d0ac feat: Update application version to v1.1.0 2025-08-28 13:25:43 +08:00
shiyu
150f6a77fb refactor: Refactor public sharing page 2025-08-28 13:22:46 +08:00
shiyu
62a1c5810d feat: Refactor storage adapter and mount handling; migrate mounts to storage adapters; enhance SideNav; implement database migration scripts 2025-08-28 12:59:24 +08:00
shiyu
bfa8898931 docs: Add online experience section 2025-08-27 20:37:10 +08:00
120 changed files with 8043 additions and 1600 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
@@ -42,10 +42,10 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
- name: Build and push Docker image (multi arch)
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ env.DOCKER_TAGS }}

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 }}

5
.gitignore vendored
View File

@@ -5,5 +5,6 @@ __pycache__/
.venv/
.vscode/
data/
.env
migrate/
.env
AGENTS.md

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.13

View File

@@ -70,8 +70,10 @@
2. **创建并激活 Python 虚拟环境**
我们推荐使用 `uv` 来管理虚拟环境,以获得最佳性能。
```bash
python3 -m venv .venv
uv venv
source .venv/bin/activate
# On Windows: .venv\Scripts\activate
```
@@ -79,10 +81,27 @@
3. **安装依赖**
```bash
pip install -r requirements.txt
uv sync
```
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

@@ -13,10 +13,13 @@ FROM python:3.13-slim
WORKDIR /app
RUN apt-get update && apt-get install -y nginx && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y nginx git && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt && pip install gunicorn
RUN pip install uv
COPY pyproject.toml uv.lock ./
RUN uv pip install --system . gunicorn
RUN git clone https://github.com/DrizzleTime/FoxelUpgrade /app/migrate
COPY --from=frontend-builder /app/web/dist /app/web/dist

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,26 +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
- **统一文件管理**:集中管理分布于不同存储后端的文件。
- **插件化存储后端**:采用可扩展的适配器模式,方便集成多种存储类型。
- **语义搜索**:支持自然语言描述搜索图片、文档等非结构化数据内容。
- **内置文件预览**可直接预览图片、视频、PDF、Office 文档及文本、代码文件,无需下载。
- **权限与分享**:支持公开或私密分享链接,便于文件共享。
- **任务处理中心**:支持异步任务处理,如文件索引和数据备份,不影响主应用运行。
> [https://demo.foxel.cc](https://demo.foxel.cc)
>
> Account/Password: `admin` / `admin`
## 🚀 快速开始
## ✨ Core Features
使用 Docker Compose 是启动 Foxel 最推荐的方式。
- **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.
1. **创建数据目录**:
新建 `data` 文件夹用于持久化数据:
## 🚀 Quick Start
Using Docker Compose is the most recommended way to start Foxel.
1. **Create Data Directories**:
Create a `data` folder for persistent data:
```bash
mkdir -p data/db
@@ -38,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

@@ -1,13 +1,13 @@
from fastapi import FastAPI
from .routes import adapters, virtual_fs, mounts, auth, config, processors, tasks, logs, share, backup, search
from .routes import adapters, virtual_fs, auth, config, processors, tasks, logs, share, backup, search, vector_db
from .routes import plugins
def include_routers(app: FastAPI):
app.include_router(adapters.router)
app.include_router(virtual_fs.router)
app.include_router(search.router)
app.include_router(mounts.router)
app.include_router(auth.router)
app.include_router(config.router)
app.include_router(processors.router)
@@ -15,4 +15,6 @@ def include_routers(app: FastAPI):
app.include_router(logs.router)
app.include_router(share.router)
app.include_router(share.public_router)
app.include_router(backup.router)
app.include_router(backup.router)
app.include_router(vector_db.router)
app.include_router(plugins.router)

View File

@@ -2,7 +2,7 @@ from fastapi import APIRouter, HTTPException, Depends
from tortoise.transactions import in_transaction
from typing import Annotated
from models import StorageAdapter, Mount
from models import StorageAdapter
from schemas import AdapterCreate, AdapterOut
from services.auth import get_current_active_user, User
from services.adapters.registry import runtime_registry, get_config_schemas
@@ -39,27 +39,22 @@ async def create_adapter(
data: AdapterCreate,
current_user: Annotated[User, Depends(get_current_active_user)]
):
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")
adapter_fields = {
"name": data.name,
"type": data.type,
"config": validate_and_normalize_config(data.type, data.config or {}),
"enabled": data.enabled,
"path": norm_path,
"sub_path": data.sub_path,
}
norm_path = AdapterCreate.normalize_mount_path(data.mount_path)
exists = await Mount.get_or_none(path=norm_path)
if exists:
raise HTTPException(400, detail="Mount path already exists")
async with in_transaction():
rec = await StorageAdapter.create(**adapter_fields)
await Mount.create(
path=norm_path,
sub_path=data.sub_path,
adapter=rec,
enabled=True,
)
rec.mount_path = norm_path
rec.sub_path = data.sub_path
await runtime_registry.refresh()
rec = await StorageAdapter.create(**adapter_fields)
await runtime_registry.upsert(rec)
await LogService.action(
"route:adapters",
f"Created adapter {rec.name}",
@@ -73,20 +68,8 @@ async def create_adapter(
async def list_adapters(
current_user: Annotated[User, Depends(get_current_active_user)]
):
adapters = await StorageAdapter.all().prefetch_related("mounts")
out = []
for a in adapters:
mount = a.mounts[0] if a.mounts else None
item = AdapterOut(
name=a.name,
type=a.type,
config=a.config,
enabled=a.enabled,
id=a.id,
mount_path=mount.path if mount else None,
sub_path=mount.sub_path if mount else None
)
out.append(item)
adapters = await StorageAdapter.all()
out = [AdapterOut.model_validate(a) for a in adapters]
return success(out)
@@ -109,13 +92,10 @@ async def get_adapter(
adapter_id: int,
current_user: Annotated[User, Depends(get_current_active_user)]
):
rec = await StorageAdapter.get_or_none(id=adapter_id).prefetch_related("mounts")
rec = await StorageAdapter.get_or_none(id=adapter_id)
if not rec:
raise HTTPException(404, detail="Not found")
mount = rec.mounts[0] if rec.mounts else None
rec.mount_path = mount.path if mount else None
rec.sub_path = mount.sub_path if mount else None
return success(rec)
return success(AdapterOut.model_validate(rec))
@router.put("/{adapter_id}")
@@ -124,34 +104,24 @@ async def update_adapter(
data: AdapterCreate,
current_user: Annotated[User, Depends(get_current_active_user)]
):
rec = await StorageAdapter.get_or_none(id=adapter_id).prefetch_related("mounts")
rec = await StorageAdapter.get_or_none(id=adapter_id)
if not rec:
raise HTTPException(404, detail="Not found")
norm_path = AdapterCreate.normalize_mount_path(data.mount_path)
existing = await Mount.get_or_none(path=norm_path)
mount = rec.mounts[0] if rec.mounts else None
if existing and (not mount or existing.id != mount.id):
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")
rec.name = data.name
rec.type = data.type
rec.config = validate_and_normalize_config(data.type, data.config or {})
rec.enabled = data.enabled
rec.path = norm_path
rec.sub_path = data.sub_path
await rec.save()
if mount:
mount.path = norm_path
mount.sub_path = data.sub_path
await mount.save()
else:
mount = await Mount.create(
path=norm_path,
sub_path=data.sub_path,
adapter=rec,
enabled=True,
)
rec.mount_path = mount.path
rec.sub_path = mount.sub_path
await runtime_registry.refresh()
await runtime_registry.upsert(rec)
await LogService.action(
"route:adapters",
f"Updated adapter {rec.name}",
@@ -169,7 +139,7 @@ async def delete_adapter(
deleted = await StorageAdapter.filter(id=adapter_id).delete()
if not deleted:
raise HTTPException(404, detail="Not found")
await runtime_registry.refresh()
runtime_registry.remove(adapter_id)
await LogService.action(
"route:adapters",
f"Deleted adapter {adapter_id}",

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,84 +0,0 @@
from fastapi import APIRouter, HTTPException, Depends
from typing import Annotated
from models import StorageAdapter, Mount
from schemas import MountCreate, MountOut
from api.response import success
from services.auth import get_current_active_user, User
from services.logging import LogService
router = APIRouter(prefix="/api/mounts", tags=["mounts"])
@router.post("")
async def create_mount(
data: MountCreate,
current_user: Annotated[User, Depends(get_current_active_user)],
):
adapter = await StorageAdapter.get_or_none(id=data.adapter_id)
if not adapter:
raise HTTPException(400, detail="Adapter not found")
rec = await Mount.create(
path=MountCreate.normalize(data.path),
adapter=adapter,
sub_path=data.sub_path,
enabled=data.enabled,
)
await LogService.action(
"route:mounts",
f"Created mount {rec.path}",
details=data.model_dump(),
user_id=current_user.id if hasattr(current_user, "id") else None,
)
return success(rec)
@router.get("")
async def list_mounts(
current_user: Annotated[User, Depends(get_current_active_user)],
):
recs = await Mount.all()
return success(recs)
@router.put("/{mount_id}")
async def update_mount(
mount_id: int,
data: MountCreate,
current_user: Annotated[User, Depends(get_current_active_user)],
):
rec = await Mount.get_or_none(id=mount_id)
if not rec:
raise HTTPException(404, detail="Not found")
adapter = await StorageAdapter.get_or_none(id=data.adapter_id)
if not adapter:
raise HTTPException(400, detail="Adapter not found")
rec.path = MountCreate.normalize(data.path)
rec.adapter = adapter
rec.sub_path = data.sub_path
rec.enabled = data.enabled
await rec.save()
await LogService.action(
"route:mounts",
f"Updated mount {rec.path}",
details=data.model_dump(),
user_id=current_user.id if hasattr(current_user, "id") else None,
)
return success(rec)
@router.delete("/{mount_id}")
async def delete_mount(
mount_id: int,
current_user: Annotated[User, Depends(get_current_active_user)],
):
deleted = await Mount.filter(id=mount_id).delete()
if not deleted:
raise HTTPException(404, detail="Not found")
await LogService.action(
"route:mounts",
f"Deleted mount {mount_id}",
details={"mount_id": mount_id},
user_id=current_user.id if hasattr(current_user, "id") else None,
)
return success({"deleted": True})

73
api/routes/plugins.py Normal file
View File

@@ -0,0 +1,73 @@
from typing import List, Any, Dict
from fastapi import APIRouter, HTTPException, Body
from models import database
from schemas import PluginCreate, PluginOut
router = APIRouter(prefix="/api/plugins", tags=["plugins"])
@router.post("", response_model=PluginOut)
async def create_plugin(payload: PluginCreate):
rec = await database.Plugin.create(
url=payload.url,
enabled=payload.enabled,
)
return PluginOut.model_validate(rec)
@router.get("", response_model=List[PluginOut])
async def list_plugins():
rows = await database.Plugin.all().order_by("-id")
return [PluginOut.model_validate(r) for r in rows]
@router.delete("/{plugin_id}")
async def delete_plugin(plugin_id: int):
rec = await database.Plugin.get_or_none(id=plugin_id)
if not rec:
raise HTTPException(status_code=404, detail="Plugin not found")
await rec.delete()
return {"code": 0, "msg": "ok"}
@router.put("/{plugin_id}", response_model=PluginOut)
async def update_plugin(plugin_id: int, payload: PluginCreate):
rec = await database.Plugin.get_or_none(id=plugin_id)
if not rec:
raise HTTPException(status_code=404, detail="Plugin not found")
rec.url = payload.url
rec.enabled = payload.enabled
await rec.save()
return PluginOut.model_validate(rec)
@router.post("/{plugin_id}/metadata", response_model=PluginOut)
async def update_manifest(plugin_id: int, manifest: Dict[str, Any] = Body(...)):
rec = await database.Plugin.get_or_none(id=plugin_id)
if not rec:
raise HTTPException(status_code=404, detail="Plugin not found")
key_map = {
'key': 'key',
'name': 'name',
'version': 'version',
'supported_exts': 'supported_exts',
'supportedExts': 'supported_exts',
'default_bounds': 'default_bounds',
'defaultBounds': 'default_bounds',
'default_maximized': 'default_maximized',
'defaultMaximized': 'default_maximized',
'icon': 'icon',
'description': 'description',
'author': 'author',
'website': 'website',
'github': 'github',
}
for k, v in list(manifest.items()):
if v is None:
continue
attr = key_map.get(k)
if not attr:
continue
setattr(rec, attr, v)
await rec.save()
return PluginOut.model_validate(rec)

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,

19
api/routes/vector_db.py Normal file
View File

@@ -0,0 +1,19 @@
from fastapi import APIRouter, Depends, HTTPException
from services.auth import get_current_active_user
from models.database import UserAccount
from services.vector_db import VectorDBService
from api.response import success
router = APIRouter(prefix="/api/vector-db", tags=["vector-db"])
@router.post("/clear-all", summary="清空向量数据库")
async def clear_vector_db(user: UserAccount = Depends(get_current_active_user)):
if user.username != 'admin':
raise HTTPException(status_code=403, detail="仅管理员可操作")
try:
service = VectorDBService()
service.clear_all_data()
return success(msg="向量数据库已清空")
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

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"])
@@ -116,7 +117,7 @@ async def get_thumb(
if not is_image_filename(rel):
raise HTTPException(404, detail="Not an image")
# type: ignore
data, mime, key = await get_or_create_thumb(adapter, mount.adapter_id, root, rel, w, h, fit)
data, mime, key = await get_or_create_thumb(adapter, mount.id, root, rel, w, h, fit)
headers = {
'Cache-Control': 'public, max-age=3600',
'ETag': key,
@@ -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}")
@@ -299,10 +306,12 @@ async def browse_fs(
current_user: Annotated[User, Depends(get_current_active_user)],
full_path: str,
page_num: int = Query(1, alias="page", ge=1, description="页码"),
page_size: int = Query(50, ge=1, le=500, description="每页条数")
page_size: int = Query(50, ge=1, le=500, description="每页条数"),
sort_by: str = Query("name", description="按字段排序: name, size, mtime"),
sort_order: str = Query("asc", description="排序顺序: asc, desc")
):
full_path = '/' + full_path if not full_path.startswith('/') else full_path
result = await list_virtual_dir(full_path, page_num, page_size)
result = await list_virtual_dir(full_path, page_num, page_size, sort_by, sort_order)
return success({
"path": full_path,
"entries": result["items"],
@@ -329,6 +338,18 @@ async def api_delete(
async def root_listing(
current_user: Annotated[User, Depends(get_current_active_user)],
page_num: int = Query(1, alias="page", ge=1, description="页码"),
page_size: int = Query(50, ge=1, le=500, description="每页条数")
page_size: int = Query(50, ge=1, le=500, description="每页条数"),
sort_by: str = Query("name", description="按字段排序: name, size, mtime"),
sort_order: str = Query("asc", description="排序顺序: asc, desc")
):
return await browse_fs("", page_num, page_size)
result = await list_virtual_dir("/", page_num, page_size, sort_by, sort_order)
return success({
"path": "/",
"entries": result["items"],
"pagination": {
"total": result["total"],
"page": result["page"],
"page_size": result["page_size"],
"pages": result["pages"]
}
})

View File

@@ -1,7 +1,7 @@
services:
foxel:
image: ghcr.io/drizzletime/foxel:latest
#image: ghcr.nju.edu.cn/drizzletime/foxel:latest #国内用户可以用此镜像命令
#image: ghcr.nju.edu.cn/drizzletime/foxel:latest # 国内用户可以用此镜像命令
container_name: foxel
restart: unless-stopped
ports:

View File

@@ -1,4 +1,5 @@
#!/bin/bash
set -e
python migrate/run.py
nginx -g 'daemon off;' &
exec gunicorn -k uvicorn.workers.UvicornWorker -w 4 -b 0.0.0.0:8000 main:app

22
main.py
View File

@@ -1,24 +1,28 @@
from services.config import VERSION, ConfigCenter
from services.adapters.registry import runtime_registry
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
from db.session import close_db, init_db
from api.routers import include_routers
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()
from services.middleware.exception_handler import global_exception_handler
from services.middleware.logging_middleware import LoggingMiddleware
from fastapi import FastAPI, Request
from api.routers import include_routers
from db.session import close_db, init_db
from contextlib import asynccontextmanager
from fastapi.middleware.cors import CORSMiddleware
from services.adapters.registry import runtime_registry
@asynccontextmanager
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,3 +1,3 @@
from .database import StorageAdapter, Mount
from .database import StorageAdapter
__all__ = ["StorageAdapter", "Mount"]
__all__ = ["StorageAdapter"]

View File

@@ -8,25 +8,13 @@ class StorageAdapter(Model):
type = fields.CharField(max_length=30)
config = fields.JSONField()
enabled = fields.BooleanField(default=True)
mounts: fields.ReverseRelation["Mount"]
path = fields.CharField(max_length=255, unique=True)
sub_path = fields.CharField(max_length=1024, null=True)
class Meta:
table = "storage_adapters"
class Mount(Model):
id = fields.IntField(pk=True)
path = fields.CharField(max_length=255, unique=True)
sub_path = fields.CharField(max_length=1024, null=True)
adapter: fields.ForeignKeyRelation[StorageAdapter] = fields.ForeignKeyField(
"models.StorageAdapter", related_name="mounts", on_delete=fields.CASCADE
)
enabled = fields.BooleanField(default=True)
class Meta:
table = "mounts"
class UserAccount(Model):
id = fields.IntField(pk=True)
username = fields.CharField(max_length=50, unique=True)
@@ -93,3 +81,29 @@ class ShareLink(Model):
class Meta:
table = "share_links"
class Plugin(Model):
id = fields.IntField(pk=True)
url = fields.CharField(max_length=2048)
enabled = fields.BooleanField(default=True)
key = fields.CharField(max_length=100, null=True)
name = fields.CharField(max_length=255, null=True)
version = fields.CharField(max_length=50, null=True)
supported_exts = fields.JSONField(null=True)
default_bounds = fields.JSONField(null=True)
default_maximized = fields.BooleanField(null=True)
icon = fields.CharField(max_length=2048, null=True)
description = fields.TextField(null=True)
author = fields.CharField(max_length=255, null=True)
website = fields.CharField(max_length=2048, null=True)
github = fields.CharField(max_length=2048, null=True)
created_at = fields.DatetimeField(auto_now_add=True)
updated_at = fields.DatetimeField(auto_now=True)
class Meta:
table = "plugins"

View File

@@ -28,7 +28,7 @@ http {
listen 80;
server_name _;
location ~ ^/(api|docs) {
location ~ ^/(api|docs|openapi\.json$) {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;

94
pyproject.toml Normal file
View File

@@ -0,0 +1,94 @@
[project]
name = "foxel"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"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",
"httptools==0.6.4",
"httpx==0.28.1",
"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",
"pydantic==2.11.7",
"pydantic-core==2.33.2",
"pygments==2.19.2",
"pyjwt==2.10.1",
"pymilvus==2.6.0",
"pypika-tortoise==0.6.1",
"pysocks==1.7.1",
"python-dateutil==2.9.0.post0",
"python-dotenv==1.1.1",
"python-multipart==0.0.20",
"pytz==2025.2",
"pyyaml==6.0.2",
"rawpy==0.25.1",
"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",
"six==1.17.0",
"sniffio==1.3.1",
"starlette==0.47.2",
"telethon==1.40.0",
"tortoise-orm==0.25.1",
"tqdm==4.67.1",
"typer==0.16.0",
"typing-extensions==4.14.1",
"typing-inspection==0.4.1",
"tzdata==2025.2",
"ujson==5.10.0",
"urllib3==2.5.0",
"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,67 +0,0 @@
aiosqlite==0.21.0
annotated-types==0.7.0
anyio==4.10.0
bcrypt==4.3.0
certifi==2025.8.3
click==8.2.1
dnspython==2.7.0
email_validator==2.2.0
fastapi==0.116.1
fastapi-cli==0.0.8
fastapi-cloud-cli==0.1.5
grpcio==1.74.0
h11==0.16.0
httpcore==1.0.9
httptools==0.6.4
httpx==0.28.1
idna==3.10
imageio==2.37.0
iso8601==2.1.0
Jinja2==3.1.6
markdown-it-py==4.0.0
MarkupSafe==3.0.2
mdurl==0.1.2
milvus-lite==2.5.1
numpy==2.3.2
pandas==2.3.1
passlib==1.7.4
pillow==11.3.0
protobuf==6.32.0
pyaes==1.6.1
pyasn1==0.6.1
pydantic==2.11.7
pydantic_core==2.33.2
Pygments==2.19.2
PyJWT==2.10.1
pymilvus==2.6.0
pypika-tortoise==0.6.1
PySocks==1.7.1
python-dateutil==2.9.0.post0
python-dotenv==1.1.1
python-multipart==0.0.20
pytz==2025.2
PyYAML==6.0.2
rawpy==0.25.1
rich==14.1.0
rich-toolkit==0.15.0
rignore==0.6.4
rsa==4.9.1
sentry-sdk==2.35.0
setuptools==80.9.0
shellingham==1.5.4
six==1.17.0
sniffio==1.3.1
starlette==0.47.2
Telethon==1.40.0
tortoise-orm==0.25.1
tqdm==4.67.1
typer==0.16.0
typing-inspection==0.4.1
typing_extensions==4.14.1
tzdata==2025.2
ujson==5.10.0
urllib3==2.5.0
uvicorn==0.35.0
uvloop==0.21.0
watchfiles==1.1.0
websockets==15.0.1

View File

@@ -1,12 +1,12 @@
from schemas.plugins import PluginCreate,PluginOut
from .adapters import AdapterCreate, AdapterOut
from .mounts import MountCreate, MountOut
from .fs import MkdirRequest, MoveRequest
__all__ = [
"PluginOut"
"PluginCreate"
"AdapterCreate",
"AdapterOut",
"MountCreate",
"MountOut",
"MkdirRequest",
"MoveRequest",
]

View File

@@ -1,15 +1,17 @@
from typing import Dict, Optional
from pydantic import BaseModel, Field, validator
from pydantic import BaseModel, Field, field_validator
class AdapterCreate(BaseModel):
class AdapterBase(BaseModel):
name: str
type: str = Field(pattern=r"^[a-zA-Z0-9_]+$")
config: Dict = Field(default_factory=dict)
enabled: bool = True
mount_path: str
sub_path: Optional[str] = None
path: str = None
sub_path: Optional[str] = None
class AdapterCreate(AdapterBase):
@staticmethod
def normalize_mount_path(p: str) -> str:
p = p.strip()
@@ -18,15 +20,17 @@ class AdapterCreate(BaseModel):
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")
return cls.normalize_mount_path(v)
class AdapterOut(AdapterCreate):
class AdapterOut(AdapterBase):
id: int
path: str = None
sub_path: Optional[str] = None
class Config:
from_attributes = True

View File

@@ -1,23 +0,0 @@
from typing import Optional
from pydantic import BaseModel
class MountCreate(BaseModel):
path: str
adapter_id: int
sub_path: Optional[str] = None
enabled: bool = True
@staticmethod
def normalize(path: str) -> str:
return (path if path.startswith('/') else '/' + path).rstrip('/') or '/'
def model_post_init(self, __context):
self.path = self.normalize(self.path)
class MountOut(MountCreate):
id: int
class Config:
from_attributes = True

27
schemas/plugins.py Normal file
View File

@@ -0,0 +1,27 @@
from typing import List, Optional, Dict, Any
from pydantic import BaseModel, Field
class PluginCreate(BaseModel):
url: str = Field(min_length=1)
enabled: bool = True
class PluginOut(BaseModel):
id: int
url: str
enabled: bool
key: Optional[str]
name: Optional[str]
version: Optional[str]
supported_exts: Optional[List[str]]
default_bounds: Optional[Dict[str, Any]]
default_maximized: Optional[bool]
icon: Optional[str]
description: Optional[str]
author: Optional[str]
website: Optional[str]
github: Optional[str]
class Config:
from_attributes = True

View File

@@ -10,7 +10,7 @@ from models import StorageAdapter
@runtime_checkable
class BaseAdapter(Protocol):
record: StorageAdapter
async def list_dir(self, root: str, rel: str, page_num: int = 1, page_size: int = 50) -> Tuple[List[Dict], int]: ...
async def list_dir(self, root: str, rel: str, page_num: int = 1, page_size: int = 50, sort_by: str = "name", sort_order: str = "asc") -> Tuple[List[Dict], int]: ...
async def read_file(self, root: str, rel: str) -> bytes: ...
async def write_file(self, root: str, rel: str, data: bytes): ...
async def write_file_stream(self, root: str, rel: str, data_iter: AsyncIterator[bytes]): ...

View File

@@ -46,25 +46,18 @@ class LocalAdapter:
return str(Path(root) / sub_path)
return root
async def list_dir(self, root: str, rel: str, page_num: int = 1, page_size: int = 50) -> Tuple[List[Dict], int]:
async def list_dir(self, root: str, rel: str, page_num: int = 1, page_size: int = 50, sort_by: str = "name", sort_order: str = "asc") -> Tuple[List[Dict], int]:
rel = rel.strip('/')
base = _safe_join(root, rel) if rel else Path(root)
if not base.exists():
return [], 0
if not base.is_dir():
raise NotADirectoryError(rel)
# 获取所有文件名并排序
all_names = await asyncio.to_thread(lambda: sorted(os.listdir(base), key=str.lower))
total_count = len(all_names)
# 计算分页范围
start_idx = (page_num - 1) * page_size
end_idx = start_idx + page_size
page_names = all_names[start_idx:end_idx]
all_names = await asyncio.to_thread(os.listdir, base)
entries = []
for name in page_names:
for name in all_names:
fp = base / name
try:
st = await asyncio.to_thread(fp.stat)
@@ -79,10 +72,35 @@ class LocalAdapter:
"mode": stat.S_IMODE(st.st_mode),
"type": "dir" if is_dir else "file",
})
# 排序
reverse = sort_order.lower() == "desc"
# 按目录优先排序
entries.sort(key=lambda x: (not x["is_dir"], x["name"].lower()))
return entries, total_count
def get_sort_key(item):
# 基础排序键,目录优先
key = (not item["is_dir"],)
sort_field = sort_by.lower()
if sort_field == "name":
key += (item["name"].lower(),)
elif sort_field == "size":
key += (item["size"],)
elif sort_field == "mtime":
key += (item["mtime"],)
else: # 默认按名称
key += (item["name"].lower(),)
return key
entries.sort(key=get_sort_key, reverse=reverse)
total_count = len(entries)
# 分页
start_idx = (page_num - 1) * page_size
end_idx = start_idx + page_size
page_entries = entries[start_idx:end_idx]
return page_entries, total_count
async def read_file(self, root: str, rel: str) -> bytes:
fp = _safe_join(root, rel)

View File

@@ -0,0 +1,440 @@
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(timeout=20.0) 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(timeout=60.0) as client:
resp = await client.request(method, url, headers=headers, **kwargs)
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, sort_by: str = "name", sort_order: str = "asc") -> Tuple[List[Dict], int]:
"""
列出目录内容。
由于 Graph API 不支持基于偏移($skip)的分页,此方法将获取所有项目,
:param root: 根路径 (在此适配器中未使用,通过配置的 root 确定)。
:param rel: 相对路径。
:param page_num: 页码。
:param page_size: 每页大小。
:param sort_by: 排序字段
:param sort_order: 排序顺序
:return: 文件/目录列表和总数。
"""
api_path = self._get_api_path(rel)
children_path = f"{api_path}:/children" if api_path else "/children"
all_items = []
params = {"$top": 999}
resp = await self._request("GET", api_path_segment=children_path, params=params)
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]
# 排序
reverse = sort_order.lower() == "desc"
def get_sort_key(item):
key = (not item["is_dir"],)
sort_field = sort_by.lower()
if sort_field == "name":
key += (item["name"].lower(),)
elif sort_field == "size":
key += (item["size"],)
elif sort_field == "mtime":
key += (item["mtime"],)
else:
key += (item["name"].lower(),)
return key
formatted_items.sort(key=get_sort_key, reverse=reverse)
total_count = len(formatted_items)
start_idx = (page_num - 1) * page_size
end_idx = start_idx + page_size
return formatted_items[start_idx:end_idx], total_count
async def read_file(self, root: str, rel: str) -> bytes:
"""
读取文件内容。
: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(timeout=60.0) 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(timeout=30.0) 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)

724
services/adapters/quark.py Normal file
View File

@@ -0,0 +1,724 @@
from __future__ import annotations
import asyncio
import base64
import hashlib
import mimetypes
import os
import time
from typing import Dict, List, Tuple, Optional, AsyncIterator, Any
import httpx
from fastapi import HTTPException
from fastapi.responses import StreamingResponse
from models import StorageAdapter
from .base import BaseAdapter
# Quark 普通(UC)接口
API_BASE = "https://drive.quark.cn/1/clouddrive"
REFERER = "https://pan.quark.cn"
PR = "ucpro"
class QuarkAdapter:
"""夸克网盘Cookie 模式)
- 使用浏览器导出的 Cookie 进行鉴权
- 通过 Quark/UC 的 clouddrive 接口实现:列目录、读写、分片上传、基础操作
- 根 FID 固定为 "0";路径解析通过名称遍历
"""
def __init__(self, record: StorageAdapter):
self.record = record
cfg = record.config or {}
self.cookie: str = cfg.get("cookie") or cfg.get("Cookie")
self.root_fid: str = cfg.get("root_fid", "0")
self.use_transcoding_address: bool = bool(cfg.get("use_transcoding_address", False))
self.only_list_video_file: bool = bool(cfg.get("only_list_video_file", False))
if not self.cookie:
raise ValueError("Quark 适配器需要 cookie 配置")
# 运行期缓存
self._dir_fid_cache: Dict[str, str] = {f"{self.root_fid}:": self.root_fid}
self._children_cache: Dict[str, List[Dict[str, Any]]] = {}
# UA 与超时
self._ua = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) quark-cloud-drive/2.5.20 Chrome/100.0.4896.160 "
"Electron/18.3.5.4-b478491100 Safari/537.36 Channel/pckk_other_ch"
)
self._timeout = 30.0
# -----------------
# 工具与通用请求
# -----------------
def get_effective_root(self, sub_path: str | None) -> str:
return self.root_fid
async def _request(
self,
method: str,
pathname: str,
*,
json: Any | None = None,
params: Dict[str, str] | None = None,
) -> Any:
headers = {
"Cookie": self._safe_cookie(self.cookie),
"Accept": "application/json, text/plain, */*",
"Referer": REFERER,
"User-Agent": self._ua,
}
query = {"pr": PR, "fr": "pc"}
if params:
query.update(params)
url = f"{API_BASE}{pathname}"
async with httpx.AsyncClient(timeout=self._timeout) as client:
resp = await client.request(method, url, headers=headers, params=query, json=json)
# 更新运行期 cookie若返回 __puus/__pus
try:
for key in ("__puus", "__pus"):
v = resp.cookies.get(key)
if v:
# 简单替换/追加到 self.cookie
self._set_cookie_kv(key, v)
except Exception:
pass
# 解析业务状态
data = None
try:
data = resp.json()
except Exception:
resp.raise_for_status()
return resp
status = data.get("status")
code = data.get("code")
msg = data.get("message") or ""
if (status is not None and status >= 400) or (code is not None and code != 0):
raise HTTPException(502, detail=f"Quark error status={status} code={code} msg={msg}")
return data
def _set_cookie_kv(self, key: str, value: str):
# 将指定键值写入 self.cookie粗略字符串处理
parts = [p.strip() for p in (self.cookie or "").replace("\r", "").replace("\n", "").split(";") if p.strip()]
found = False
for i, p in enumerate(parts):
if p.startswith(key + "="):
parts[i] = f"{key}={value}"
found = True
break
if not found:
parts.append(f"{key}={value}")
self.cookie = "; ".join(parts)
def _sanitize_cookie(self, cookie: str) -> str:
if not cookie:
return ""
# 去除换行与前后空白
cookie = cookie.replace("\r", "").replace("\n", "").strip()
# 统一分号分隔并去除多余空格/空段
parts = [p.strip() for p in cookie.split(";") if p.strip()]
return "; ".join(parts)
def _safe_cookie(self, cookie: str) -> str:
s = self._sanitize_cookie(cookie)
# 仅保留可见 ASCII (0x20-0x7E)
s = "".join(ch for ch in s if 32 <= ord(ch) <= 126)
return s
# -----------------
# 列表与路径解析
# -----------------
def _map_file_item(self, it: Dict[str, Any]) -> Dict[str, Any]:
# Quark/UC 列表项file=true 表示文件false 表示目录
is_dir = not bool(it.get("file", False))
updated_at_ms = int(it.get("updated_at", 0) or 0)
name = it.get("file_name") or it.get("filename") or it.get("name")
return {
"fid": it.get("fid"),
"name": name,
"is_dir": is_dir,
"size": 0 if is_dir else int(it.get("size", 0) or 0),
"mtime": updated_at_ms // 1000 if updated_at_ms else 0,
"type": "dir" if is_dir else "file",
}
async def _list_children(self, parent_fid: str) -> List[Dict[str, Any]]:
if parent_fid in self._children_cache:
return self._children_cache[parent_fid]
files: List[Dict[str, Any]] = []
page = 1
size = 100
total = None
while True:
qp = {"pdir_fid": parent_fid, "_size": str(size), "_page": str(page), "_fetch_total": "1"}
data = await self._request("GET", "/file/sort", params=qp)
d = (data or {}).get("data", {})
meta = (data or {}).get("metadata", {})
page_files = d.get("list", [])
files.extend(page_files)
if total is None:
total = meta.get("_total") or meta.get("total") or 0
if page * size >= int(total):
break
page += 1
mapped = [self._map_file_item(x) for x in files if (not self.only_list_video_file) or (not x.get("file")) or (x.get("category") == 1)]
self._children_cache[parent_fid] = mapped
return mapped
def _dir_cache_key(self, base_fid: str, rel: str) -> str:
return f"{base_fid}:{rel.strip('/')}"
async def _resolve_dir_fid_from(self, base_fid: str, rel: str) -> str:
key = rel.strip("/")
cache_key = self._dir_cache_key(base_fid, key)
if cache_key in self._dir_fid_cache:
return self._dir_fid_cache[cache_key]
if key == "":
self._dir_fid_cache[cache_key] = base_fid
return base_fid
parent_fid = base_fid
path_so_far = []
for seg in key.split("/"):
if seg == "":
continue
path_so_far.append(seg)
cache_key = self._dir_cache_key(base_fid, "/".join(path_so_far))
cached = self._dir_fid_cache.get(cache_key)
if cached:
parent_fid = cached
continue
children = await self._list_children(parent_fid)
found = next((c for c in children if c["is_dir"] and c["name"] == seg), None)
if not found:
raise FileNotFoundError(f"Directory not found: {seg}")
parent_fid = found["fid"]
self._dir_fid_cache[cache_key] = parent_fid
return parent_fid
async def _find_child(self, parent_fid: str, name: str) -> Optional[Dict[str, Any]]:
children = await self._list_children(parent_fid)
for it in children:
if it["name"] == name:
return it
return None
def _invalidate_children_cache(self, parent_fid: str):
if parent_fid in self._children_cache:
try:
del self._children_cache[parent_fid]
except Exception:
pass
# -----------------
# 目录与文件列表
# -----------------
async def list_dir(
self,
root: str,
rel: str,
page_num: int = 1,
page_size: int = 50,
sort_by: str = "name",
sort_order: str = "asc",
) -> Tuple[List[Dict], int]:
base_fid = root or self.root_fid
fid = await self._resolve_dir_fid_from(base_fid, rel)
items = await self._list_children(fid)
# 排序,目录优先
reverse = sort_order.lower() == "desc"
def get_sort_key(item):
key = (not item["is_dir"],)
sf = sort_by.lower()
if sf == "name":
key += (item["name"].lower(),)
elif sf == "size":
key += (item["size"],)
elif sf == "mtime":
key += (item["mtime"],)
else:
key += (item["name"].lower(),)
return key
items.sort(key=get_sort_key, reverse=reverse)
total = len(items)
start = (page_num - 1) * page_size
end = start + page_size
return items[start:end], total
# -----------------
# 下载与流式下载
# -----------------
async def _get_download_url(self, fid: str) -> str:
data = await self._request("POST", "/file/download", json={"fids": [fid]})
arr = (data or {}).get("data", [])
if not arr:
raise HTTPException(502, detail="No download data returned by Quark")
url = arr[0].get("download_url") or arr[0].get("DownloadUrl")
if not url:
raise HTTPException(502, detail="No download_url returned by Quark")
return url
async def _get_transcoding_url(self, fid: str) -> Optional[str]:
try:
payload = {"fid": fid, "resolutions": "low,normal,high,super,2k,4k", "supports": "fmp4_av,m3u8,dolby_vision"}
data = await self._request("POST", "/file/v2/play/project", json=payload)
lst = (data or {}).get("data", {}).get("video_list", [])
for item in lst:
vi = item.get("video_info") or {}
url = vi.get("url")
if url:
return url
except Exception:
return None
return None
def _is_video_name(self, name: str) -> bool:
mime, _ = mimetypes.guess_type(name)
return bool(mime and mime.startswith("video/"))
def _download_headers(self) -> Dict[str, str]:
return {"Cookie": self._safe_cookie(self.cookie), "User-Agent": self._ua, "Referer": REFERER}
async def read_file(self, root: str, rel: str) -> bytes:
if not rel or rel.endswith("/"):
raise IsADirectoryError("Path is a directory")
parent = rel.rsplit("/", 1)[0] if "/" in rel else ""
name = rel.rsplit("/", 1)[-1]
base_fid = root or self.root_fid
parent_fid = await self._resolve_dir_fid_from(base_fid, parent)
it = await self._find_child(parent_fid, name)
if not it or it["is_dir"]:
raise FileNotFoundError(rel)
url = await self._get_download_url(it["fid"])
headers = self._download_headers()
async with httpx.AsyncClient(timeout=None, follow_redirects=True) as client:
resp = await client.get(url, headers=headers)
if resp.status_code == 404:
raise FileNotFoundError(rel)
resp.raise_for_status()
return resp.content
async def stream_file(self, root: str, rel: str, range_header: str | None):
if not rel or rel.endswith("/"):
raise IsADirectoryError("Path is a directory")
parent = rel.rsplit("/", 1)[0] if "/" in rel else ""
name = rel.rsplit("/", 1)[-1]
base_fid = root or self.root_fid
parent_fid = await self._resolve_dir_fid_from(base_fid, parent)
it = await self._find_child(parent_fid, name)
if not it or it["is_dir"]:
raise FileNotFoundError(rel)
url = await self._get_download_url(it["fid"])
if self.use_transcoding_address and self._is_video_name(name):
tr = await self._get_transcoding_url(it["fid"])
if tr:
url = tr
dl_headers = self._download_headers()
# 预获取大小/是否支持范围
total_size: Optional[int] = None
async with httpx.AsyncClient(timeout=self._timeout, follow_redirects=True) as client:
try:
head_resp = await client.head(url, headers=dl_headers)
if head_resp.status_code == 200:
cl = head_resp.headers.get("Content-Length")
if cl and cl.isdigit():
total_size = int(cl)
except Exception:
pass
mime, _ = mimetypes.guess_type(rel)
content_type = mime or "application/octet-stream"
# 解析 Range
start = 0
end: Optional[int] = None
status_code = 200
if range_header and range_header.startswith("bytes="):
status_code = 206
part = range_header.split("=", 1)[1]
s, e = part.split("-", 1)
if s.strip():
start = int(s)
if e.strip():
end = int(e)
if total_size is not None and end is None and status_code == 206:
end = total_size - 1
if end is not None and total_size is not None and end >= total_size:
end = total_size - 1
if total_size is not None and start >= total_size:
raise HTTPException(416, detail="Requested Range Not Satisfiable")
resp_headers: Dict[str, str] = {"Accept-Ranges": "bytes", "Content-Type": content_type}
if status_code == 206 and total_size is not None and end is not None:
resp_headers["Content-Range"] = f"bytes {start}-{end}/{total_size}"
resp_headers["Content-Length"] = str(end - start + 1)
elif total_size is not None:
resp_headers["Content-Length"] = str(total_size)
async def iterator():
headers = dict(dl_headers)
if status_code == 206 and end is not None:
headers["Range"] = f"bytes={start}-{end}"
async with httpx.AsyncClient(timeout=None, follow_redirects=True) as client:
async with client.stream("GET", url, headers=headers) as resp:
if resp.status_code in (404, 416):
await resp.aclose()
raise HTTPException(resp.status_code, detail="Upstream not available")
async for chunk in resp.aiter_bytes():
if chunk:
yield chunk
return StreamingResponse(iterator(), status_code=status_code, headers=resp_headers, media_type=content_type)
# -----------------
# 上传(大文件分片)
# -----------------
@staticmethod
def _md5_hex(b: bytes) -> str:
return hashlib.md5(b).hexdigest()
@staticmethod
def _sha1_hex(b: bytes) -> str:
return hashlib.sha1(b).hexdigest()
def _guess_mime(self, name: str) -> str:
mime, _ = mimetypes.guess_type(name)
return mime or "application/octet-stream"
async def _upload_pre(self, filename: str, size: int, parent_fid: str) -> Dict[str, Any]:
now_ms = int(time.time() * 1000)
body = {
"ccp_hash_update": True,
"dir_name": "",
"file_name": filename,
"format_type": self._guess_mime(filename),
"l_created_at": now_ms,
"l_updated_at": now_ms,
"pdir_fid": parent_fid,
"size": size,
}
data = await self._request("POST", "/file/upload/pre", json=body)
return data
async def write_file(self, root: str, rel: str, data: bytes):
async def gen():
yield data
return await self.write_file_stream(root, rel, gen())
async def write_file_stream(self, root: str, rel: str, data_iter: AsyncIterator[bytes]):
if not rel or rel.endswith("/"):
raise HTTPException(400, detail="Invalid file path")
parent = rel.rsplit("/", 1)[0] if "/" in rel else ""
name = rel.rsplit("/", 1)[-1]
base_fid = root or self.root_fid
parent_fid = await self._resolve_dir_fid_from(base_fid, parent)
# 将数据落盘到临时文件,同时计算 MD5/SHA1
import tempfile
md5 = hashlib.md5()
sha1 = hashlib.sha1()
total = 0
with tempfile.NamedTemporaryFile(delete=False) as tf:
tmp_path = tf.name
try:
async for chunk in data_iter:
if not chunk:
continue
total += len(chunk)
md5.update(chunk)
sha1.update(chunk)
tf.write(chunk)
finally:
tf.flush()
md5_hex = md5.hexdigest()
sha1_hex = sha1.hexdigest()
# 预上传,拿到上传信息
pre_resp = await self._upload_pre(name, total, parent_fid)
pre_data = pre_resp.get("data", {})
# hash 秒传
hash_body = {"md5": md5_hex, "sha1": sha1_hex, "task_id": pre_data.get("task_id")}
hash_resp = await self._request("POST", "/file/update/hash", json=hash_body)
if (hash_resp.get("data") or {}).get("finish") is True:
try:
os.unlink(tmp_path)
except Exception:
pass
# 刷新父目录缓存
self._invalidate_children_cache(parent_fid)
return total
# 分片上传
part_size = int((pre_resp.get("metadata") or {}).get("part_size") or 0)
if part_size <= 0:
raise HTTPException(502, detail="Invalid part_size from Quark")
bucket = pre_data.get("bucket")
obj_key = pre_data.get("obj_key")
upload_id = pre_data.get("upload_id")
upload_url = pre_data.get("upload_url")
if not (bucket and obj_key and upload_id and upload_url):
raise HTTPException(502, detail="Upload pre missing fields")
# 计算 host 与基础 URL
try:
upload_host = upload_url.split("://", 1)[1]
except Exception:
upload_host = upload_url
base_url = f"https://{bucket}.{upload_host}/{obj_key}"
# 分片循环
etags: List[str] = []
oss_ua = "aliyun-sdk-js/6.6.1 Chrome 98.0.4758.80 on Windows 10 64-bit"
async with httpx.AsyncClient(timeout=None, follow_redirects=True) as client:
with open(tmp_path, "rb") as rf:
part_number = 1
left = total
while left > 0:
sz = min(part_size, left)
data_bytes = rf.read(sz)
if len(data_bytes) != sz:
raise IOError("Failed to read part bytes")
now_str = time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime())
# 申请签名
auth_meta = (
"PUT\n\n"
f"{self._guess_mime(name)}\n"
f"{now_str}\n"
f"x-oss-date:{now_str}\n"
f"x-oss-user-agent:{oss_ua}\n"
f"/{bucket}/{obj_key}?partNumber={part_number}&uploadId={upload_id}"
)
auth_req_body = {"auth_info": pre_data.get("auth_info"), "auth_meta": auth_meta, "task_id": pre_data.get("task_id")}
auth_resp = await self._request("POST", "/file/upload/auth", json=auth_req_body)
auth_key = (auth_resp.get("data") or {}).get("auth_key")
if not auth_key:
raise HTTPException(502, detail="upload/auth missing auth_key")
put_headers = {
"Authorization": auth_key,
"Content-Type": self._guess_mime(name),
"Referer": REFERER + "/",
"x-oss-date": now_str,
"x-oss-user-agent": oss_ua,
}
put_url = f"{base_url}?partNumber={part_number}&uploadId={upload_id}"
put_resp = await client.put(put_url, headers=put_headers, content=data_bytes)
if put_resp.status_code != 200:
raise HTTPException(502, detail=f"Upload part failed status={put_resp.status_code} text={put_resp.text}")
etag = put_resp.headers.get("Etag", "")
etags.append(etag)
left -= sz
part_number += 1
# 组合 commit xml
parts_xml = [f"<Part>\n<PartNumber>{i+1}</PartNumber>\n<ETag>{etags[i]}</ETag>\n</Part>\n" for i in range(len(etags))]
body_xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<CompleteMultipartUpload>\n" + "".join(parts_xml) + "</CompleteMultipartUpload>"
content_md5 = base64.b64encode(hashlib.md5(body_xml.encode("utf-8")).digest()).decode("ascii")
callback = pre_data.get("callback") or {}
try:
import json as _json
callback_b64 = base64.b64encode(_json.dumps(callback).encode("utf-8")).decode("ascii")
except Exception:
callback_b64 = ""
now_str = time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime())
auth_meta_commit = (
"POST\n"
f"{content_md5}\n"
"application/xml\n"
f"{now_str}\n"
f"x-oss-callback:{callback_b64}\n"
f"x-oss-date:{now_str}\n"
f"x-oss-user-agent:{oss_ua}\n"
f"/{bucket}/{obj_key}?uploadId={upload_id}"
)
auth_commit_resp = await self._request("POST", "/file/upload/auth", json={"auth_info": pre_data.get("auth_info"), "auth_meta": auth_meta_commit, "task_id": pre_data.get("task_id")})
auth_key_commit = (auth_commit_resp.get("data") or {}).get("auth_key")
if not auth_key_commit:
raise HTTPException(502, detail="upload/auth(commit) missing auth_key")
async with httpx.AsyncClient(timeout=None, follow_redirects=True) as client:
commit_headers = {
"Authorization": auth_key_commit,
"Content-MD5": content_md5,
"Content-Type": "application/xml",
"Referer": REFERER + "/",
"x-oss-callback": callback_b64,
"x-oss-date": now_str,
"x-oss-user-agent": oss_ua,
}
commit_url = f"{base_url}?uploadId={upload_id}"
r = await client.post(commit_url, headers=commit_headers, content=body_xml.encode("utf-8"))
if r.status_code != 200:
raise HTTPException(502, detail=f"Upload commit failed status={r.status_code} text={r.text}")
# finish
await self._request("POST", "/file/upload/finish", json={"obj_key": obj_key, "task_id": pre_data.get("task_id")})
# 端合并存在轻微延迟,等待再刷新缓存
try:
await asyncio.sleep(1.0)
except Exception:
pass
try:
os.unlink(tmp_path)
except Exception:
pass
# 失效父目录缓存,确保后续列表可见
self._invalidate_children_cache(parent_fid)
return total
# -----------------
# 基本文件操作
# -----------------
async def mkdir(self, root: str, rel: str):
if not rel or rel == "/":
raise HTTPException(400, detail="Cannot create root")
parent = rel.rstrip("/")
parent_rel, name = (parent.rsplit("/", 1) if "/" in parent else ("", parent))
if not name:
raise HTTPException(400, detail="Invalid directory name")
pdir = await self._resolve_dir_fid_from(root or self.root_fid, parent_rel)
await self._request("POST", "/file", json={"dir_init_lock": False, "dir_path": "", "file_name": name, "pdir_fid": pdir})
self._invalidate_children_cache(pdir)
async def delete(self, root: str, rel: str):
# 解析对象 fid + 父目录,用于失效缓存
base_fid = root or self.root_fid
if rel == "" or rel.endswith("/"):
parent_rel = rel.rstrip("/")
target_fid = await self._resolve_dir_fid_from(base_fid, parent_rel)
parent_of_target = await self._resolve_dir_fid_from(base_fid, (parent_rel.rsplit("/", 1)[0] if "/" in parent_rel else ""))
else:
parent_rel, name = (rel.rsplit("/", 1) if "/" in rel else ("", rel))
parent_of_target = await self._resolve_dir_fid_from(base_fid, parent_rel)
it = await self._find_child(parent_of_target, name)
if not it:
return
target_fid = it["fid"]
await self._request("POST", "/file/delete", json={"action_type": 1, "exclude_fids": [], "filelist": [target_fid]})
self._invalidate_children_cache(parent_of_target)
async def move(self, root: str, src_rel: str, dst_rel: str):
# 支持跨目录与重命名:先移动到父目录,后重命名(若需要)
src_parent_rel, src_name = (src_rel.rsplit("/", 1) if "/" in src_rel else ("", src_rel))
dst_parent_rel, dst_name = (dst_rel.rsplit("/", 1) if "/" in dst_rel else ("", dst_rel))
base_fid = root or self.root_fid
src_parent_fid = await self._resolve_dir_fid_from(base_fid, src_parent_rel)
obj = await self._find_child(src_parent_fid, src_name)
if not obj:
raise FileNotFoundError(src_rel)
dst_parent_fid = await self._resolve_dir_fid_from(base_fid, dst_parent_rel)
if src_parent_fid != dst_parent_fid:
await self._request("POST", "/file/move", json={"action_type": 1, "exclude_fids": [], "filelist": [obj["fid"]], "to_pdir_fid": dst_parent_fid})
self._invalidate_children_cache(src_parent_fid)
self._invalidate_children_cache(dst_parent_fid)
if obj["name"] != dst_name:
await self._request("POST", "/file/rename", json={"fid": obj["fid"], "file_name": dst_name})
self._invalidate_children_cache(dst_parent_fid)
async def rename(self, root: str, src_rel: str, dst_rel: str):
src_parent_rel, src_name = (src_rel.rsplit("/", 1) if "/" in src_rel else ("", src_rel))
base_fid = root or self.root_fid
src_parent_fid = await self._resolve_dir_fid_from(base_fid, src_parent_rel)
obj = await self._find_child(src_parent_fid, src_name)
if not obj:
raise FileNotFoundError(src_rel)
dst_name = dst_rel.rsplit("/", 1)[-1]
await self._request("POST", "/file/rename", json={"fid": obj["fid"], "file_name": dst_name})
self._invalidate_children_cache(src_parent_fid)
async def copy(self, root: str, src_rel: str, dst_rel: str, overwrite: bool = False):
raise NotImplementedError("QuarkOpen does not support copy via open API")
# -----------------
# STAT / EXISTS / 辅助
# -----------------
async def stat_file(self, root: str, rel: str):
# 通过父目录列表获取元数据
base_fid = root or self.root_fid
if rel == "" or rel.endswith("/"):
# 目录
fid = await self._resolve_dir_fid_from(base_fid, rel.rstrip("/"))
return {"name": rel.rstrip("/").split("/")[-1] if rel else "", "is_dir": True, "size": 0, "mtime": 0, "type": "dir", "fid": fid}
parent_rel, name = (rel.rsplit("/", 1) if "/" in rel else ("", rel))
parent_fid = await self._resolve_dir_fid_from(base_fid, parent_rel)
it = await self._find_child(parent_fid, name)
if not it:
raise FileNotFoundError(rel)
return it
async def exists(self, root: str, rel: str) -> bool:
try:
base_fid = root or self.root_fid
if rel == "" or rel.endswith("/"):
await self._resolve_dir_fid_from(base_fid, rel.rstrip("/"))
return True
parent_rel, name = (rel.rsplit("/", 1) if "/" in rel else ("", rel))
parent_fid = await self._resolve_dir_fid_from(base_fid, parent_rel)
it = await self._find_child(parent_fid, name)
return it is not None
except FileNotFoundError:
return False
async def stat_path(self, root: str, rel: str):
# 用于 move/copy 前的预检查调试
try:
base_fid = root or self.root_fid
if rel == "" or rel.endswith("/"):
fid = await self._resolve_dir_fid_from(base_fid, rel.rstrip("/"))
return {"exists": True, "is_dir": True, "path": rel, "fid": fid}
parent_rel, name = (rel.rsplit("/", 1) if "/" in rel else ("", rel))
parent_fid = await self._resolve_dir_fid_from(base_fid, parent_rel)
it = await self._find_child(parent_fid, name)
if it:
return {"exists": True, "is_dir": it["is_dir"], "path": rel, "fid": it["fid"]}
return {"exists": False, "is_dir": None, "path": rel}
except FileNotFoundError:
return {"exists": False, "is_dir": None, "path": rel}
async def _resolve_target_fid(self, rel: str, *, base_fid: Optional[str] = None) -> str:
base = base_fid or self.root_fid
if rel == "" or rel.endswith("/"):
return await self._resolve_dir_fid_from(base, rel.rstrip("/"))
parent_rel, name = (rel.rsplit("/", 1) if "/" in rel else ("", rel))
parent_fid = await self._resolve_dir_fid_from(base, parent_rel)
it = await self._find_child(parent_fid, name)
if not it:
raise FileNotFoundError(rel)
return it["fid"]
ADAPTER_TYPE = "Quark"
CONFIG_SCHEMA = [
{"key": "cookie", "label": "Cookie", "type": "password", "required": True, "placeholder": "从 pan.quark.cn 复制"},
{"key": "root_fid", "label": "根 FID", "type": "string", "required": False, "default": "0"},
{"key": "use_transcoding_address", "label": "视频转码直链", "type": "checkbox", "required": False, "default": False},
{"key": "only_list_video_file", "label": "仅列出视频文件", "type": "checkbox", "required": False, "default": False},
]
def ADAPTER_FACTORY(rec: StorageAdapter) -> BaseAdapter:
return QuarkAdapter(rec)

View File

@@ -78,6 +78,31 @@ class RuntimeRegistry:
def snapshot(self) -> Dict[int, BaseAdapter]:
return dict(self._instances)
def remove(self, adapter_id: int):
"""从缓存中移除一个适配器实例"""
if adapter_id in self._instances:
del self._instances[adapter_id]
async def upsert(self, rec: StorageAdapter):
"""新增或更新一个适配器实例"""
if not rec.enabled:
self.remove(rec.id)
return
factory = TYPE_MAP.get(rec.type)
if not factory:
discover_adapters()
factory = TYPE_MAP.get(rec.type)
if not factory:
return
try:
instance = factory(rec)
self._instances[rec.id] = instance
except Exception:
self.remove(rec.id)
pass
runtime_registry = RuntimeRegistry()
discover_adapters()

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

@@ -0,0 +1,380 @@
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, sort_by: str = "name", sort_order: str = "asc") -> 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",
})
# 在内存中排序和分页
reverse = sort_order.lower() == "desc"
def get_sort_key(item):
key = (not item["is_dir"],)
sort_field = sort_by.lower()
if sort_field == "name":
key += (item["name"].lower(),)
elif sort_field == "size":
key += (item["size"],)
elif sort_field == "mtime":
key += (item["mtime"],)
else:
key += (item["name"].lower(),)
return key
all_items.sort(key=get_sort_key, reverse=reverse)
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,342 @@
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, sort_by: str = "name", sort_order: str = "asc") -> 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=200)
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()
# 排序
reverse = sort_order.lower() == "desc"
def get_sort_key(item):
key = (not item["is_dir"],)
sort_field = sort_by.lower()
if sort_field == "name":
key += (item["name"].lower(),)
elif sort_field == "size":
key += (item["size"],)
elif sort_field == "mtime":
key += (item["mtime"],)
else:
key += (item["name"].lower(),)
return key
entries.sort(key=get_sort_key, reverse=reverse)
total_count = len(entries)
# 分页
start_idx = (page_num - 1) * page_size
end_idx = start_idx + page_size
page_entries = entries[start_idx:end_idx]
return page_entries, total_count
async def read_file(self, root: str, rel: str) -> bytes:
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

@@ -39,7 +39,7 @@ class WebDAVAdapter:
rel = rel.strip('/')
return self.base_url if not rel else urljoin(self.base_url, quote(rel) + ('/' if rel.endswith('/') else ''))
async def list_dir(self, root: str, rel: str, page_num: int = 1, page_size: int = 50) -> Tuple[List[Dict], int]:
async def list_dir(self, root: str, rel: str, page_num: int = 1, page_size: int = 50, sort_by: str = "name", sort_order: str = "asc") -> Tuple[List[Dict], int]:
raw_url = self._build_url(rel)
url = raw_url if raw_url.endswith('/') else raw_url + '/'
depth = "1"
@@ -92,16 +92,39 @@ class WebDAVAdapter:
"d:collection", NS) is not None if rt_el is not None else href_path.endswith('/')
size = int(
size_el.text) if size_el is not None and size_el.text and size_el.text.isdigit() else 0
from email.utils import parsedate_to_datetime
mtime = 0
if lm_el is not None and lm_el.text:
try:
mtime = int(parsedate_to_datetime(lm_el.text).timestamp())
except Exception:
mtime = 0
all_entries.append({
"name": name,
"is_dir": is_dir,
"size": 0 if is_dir else size,
"mtime": 0,
"mtime": mtime,
"type": "dir" if is_dir else "file",
})
# 排序所有条目
all_entries.sort(key=lambda x: (not x["is_dir"], x["name"].lower()))
reverse = sort_order.lower() == "desc"
def get_sort_key(item):
key = (not item["is_dir"],)
sort_field = sort_by.lower()
if sort_field == "name":
key += (item["name"].lower(),)
elif sort_field == "size":
key += (item["size"],)
elif sort_field == "mtime":
key += (item["mtime"],)
else:
key += (item["name"].lower(),)
return key
all_entries.sort(key=get_sort_key, reverse=reverse)
total_count = len(all_entries)
# 应用分页

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

@@ -1,7 +1,6 @@
from tortoise.transactions import in_transaction
from models.database import (
StorageAdapter,
Mount,
UserAccount,
AutomationTask,
ShareLink,
@@ -18,7 +17,6 @@ class BackupService:
"""
async with in_transaction() as conn:
adapters = await StorageAdapter.all().values()
mounts = await Mount.all().values()
users = await UserAccount.all().values()
tasks = await AutomationTask.all().values()
shares = await ShareLink.all().values()
@@ -33,7 +31,6 @@ class BackupService:
return {
"version": VERSION,
"storage_adapters": list(adapters),
"mounts": list(mounts),
"user_accounts": list(users),
"automation_tasks": list(tasks),
"share_links": list(shares),
@@ -48,7 +45,6 @@ class BackupService:
async with in_transaction() as conn:
await ShareLink.all().using_db(conn).delete()
await AutomationTask.all().using_db(conn).delete()
await Mount.all().using_db(conn).delete()
await StorageAdapter.all().using_db(conn).delete()
await UserAccount.all().using_db(conn).delete()
await Configuration.all().using_db(conn).delete()
@@ -71,12 +67,6 @@ class BackupService:
using_db=conn
)
if data.get("mounts"):
await Mount.bulk_create(
[Mount(**m) for m in data["mounts"]],
using_db=conn
)
if data.get("automation_tasks"):
await AutomationTask.bulk_create(
[AutomationTask(**t) for t in data["automation_tasks"]],

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.0.0"
VERSION = "v1.2.1"
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

@@ -75,3 +75,9 @@ class VectorDBService:
output_fields=["path"]
)
return [[{'id': r['path'], 'distance': 1.0, 'entity': {'path': r['path']}} for r in results]]
def clear_all_data(self):
"""清空所有集合的内容"""
collections = self.client.list_collections()
for collection_name in collections:
self.client.drop_collection(collection_name)

View File

@@ -1,4 +1,3 @@
from pathlib import Path
from typing import Dict, Tuple, Any, Union, AsyncIterator
from fastapi import HTTPException
import mimetypes
@@ -8,7 +7,7 @@ import hmac
import hashlib
import base64
from models import Mount
from models import StorageAdapter
from .adapters.registry import runtime_registry
from api.response import page
from .thumbnail import is_image_filename, is_raw_filename
@@ -18,16 +17,16 @@ from services.logging import LogService
from services.config import ConfigCenter
async def resolve_mount(path: str) -> Tuple[Mount, str]:
async def resolve_adapter_by_path(path: str) -> Tuple[StorageAdapter, str]:
norm = path if path.startswith('/') else '/' + path
mounts = await Mount.filter(enabled=True)
adapters = await StorageAdapter.filter(enabled=True)
best = None
for m in mounts:
if norm == m.path or norm.startswith(m.path.rstrip('/') + '/'):
if (best is None) or len(m.path) > len(best.path):
best = m
for a in adapters:
if norm == a.path or norm.startswith(a.path.rstrip('/') + '/'):
if (best is None) or len(a.path) > len(best.path):
best = a
if not best:
raise HTTPException(404, detail="No mount for path")
raise HTTPException(404, detail="No storage adapter for path")
rel = norm[len(best.path):].lstrip('/')
return best, rel
@@ -35,16 +34,22 @@ async def resolve_mount(path: str) -> Tuple[Mount, str]:
async def resolve_adapter_and_rel(path: str):
"""返回 (adapter_instance, mount, effective_root, rel_path)."""
"""返回 (adapter_instance, adapter_model, effective_root, rel_path)."""
norm = path if path.startswith('/') else '/' + path
try:
mount, rel = await resolve_mount(norm)
adapter_model, rel = await resolve_adapter_by_path(norm)
except HTTPException as e:
raise e
await mount.fetch_related("adapter")
adapter_instance = runtime_registry.get(mount.adapter_id)
effective_root = adapter_instance.get_effective_root(mount.sub_path)
return adapter_instance, mount, effective_root, rel
adapter_instance = runtime_registry.get(adapter_model.id)
if not adapter_instance:
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
async def _ensure_method(adapter: Any, method: str):
@@ -54,29 +59,37 @@ async def _ensure_method(adapter: Any, method: str):
return func
async def list_virtual_dir(path: str, page_num: int = 1, page_size: int = 50) -> Dict:
async def list_virtual_dir(path: str, page_num: int = 1, page_size: int = 50, sort_by: str = "name", sort_order: str = "asc") -> Dict:
norm = (path if path.startswith('/') else '/' + path).rstrip('/') or '/'
mounts = await Mount.filter(enabled=True).prefetch_related("adapter")
adapters = await StorageAdapter.filter(enabled=True)
child_mount_entries = []
norm_prefix = norm.rstrip('/')
for m in mounts:
if m.path == norm:
for a in adapters:
if a.path == norm:
continue
if m.path.startswith(norm_prefix + '/'):
tail = m.path[len(norm_prefix):].lstrip('/')
if a.path.startswith(norm_prefix + '/'):
tail = a.path[len(norm_prefix):].lstrip('/')
if '/' not in tail:
child_mount_entries.append(tail)
child_mount_entries = sorted(set(child_mount_entries))
try:
mount, rel = await resolve_mount(norm)
await mount.fetch_related("adapter")
adapter = runtime_registry.get(mount.adapter_id)
effective_root = adapter.get_effective_root(mount.sub_path)
adapter_model, rel = await resolve_adapter_by_path(norm)
adapter_instance = runtime_registry.get(adapter_model.id)
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:
mount = None
adapter = None
adapter_model = None
adapter_instance = None
effective_root = ''
rel = ''
@@ -84,10 +97,10 @@ async def list_virtual_dir(path: str, page_num: int = 1, page_size: int = 50) ->
adapter_total = 0
covered = set()
if mount and adapter:
list_dir = await _ensure_method(adapter, "list_dir")
if adapter_model and adapter_instance:
list_dir = await _ensure_method(adapter_instance, "list_dir")
try:
adapter_entries, adapter_total = await list_dir(effective_root, rel, page_num, page_size)
adapter_entries, adapter_total = await list_dir(effective_root, rel, page_num, page_size, sort_by, sort_order)
except NotADirectoryError:
raise HTTPException(400, detail="Not a directory")
@@ -105,32 +118,47 @@ async def list_virtual_dir(path: str, page_num: int = 1, page_size: int = 50) ->
ent['is_image'] = is_image_filename(ent['name'])
else:
ent['is_image'] = False
all_entries = adapter_entries + mount_entries
all_entries.sort(key=lambda x: (not x.get("is_dir"), x["name"].lower()))
total_entries = adapter_total + len(mount_entries)
if mount_entries:
reverse = sort_order.lower() == "desc"
def get_sort_key(item):
key = (not item.get("is_dir"),)
sort_field = sort_by.lower()
if sort_field == "name":
key += (item["name"].lower(),)
elif sort_field == "size":
key += (item.get("size", 0),)
elif sort_field == "mtime":
key += (item.get("mtime", 0),)
else:
key += (item["name"].lower(),)
return key
all_entries.sort(key=get_sort_key, reverse=reverse)
total_entries = adapter_total + len(mount_entries)
start_idx = (page_num - 1) * page_size
end_idx = start_idx + page_size
page_entries = all_entries[start_idx:end_idx]
return page(page_entries, total_entries, page_num, page_size)
else:
return page(adapter_entries, adapter_total, page_num, page_size)
return page(adapter_entries, adapter_total, page_num, page_size)
async def read_file(path: str) -> Union[bytes, Any]:
adapter, _mount, root, rel = await resolve_adapter_and_rel(path)
adapter_instance, _, root, rel = await resolve_adapter_and_rel(path)
if rel.endswith('/') or rel == '':
raise HTTPException(400, detail="Path is a directory")
read_func = await _ensure_method(adapter, "read_file")
read_func = await _ensure_method(adapter_instance, "read_file")
return await read_func(root, rel)
async def write_file(path: str, data: bytes):
adapter, _mount, root, rel = await resolve_adapter_and_rel(path)
adapter_instance, _, root, rel = await resolve_adapter_and_rel(path)
if rel.endswith('/'):
raise HTTPException(400, detail="Invalid file path")
write_func = await _ensure_method(adapter, "write_file")
write_func = await _ensure_method(adapter_instance, "write_file")
await write_func(root, rel, data)
await task_service.trigger_tasks("file_written", path)
await LogService.action(
@@ -139,10 +167,10 @@ async def write_file(path: str, data: bytes):
async def write_file_stream(path: str, data_iter: AsyncIterator[bytes], overwrite: bool = True):
adapter, _mount, root, rel = await resolve_adapter_and_rel(path)
adapter_instance, _, root, rel = await resolve_adapter_and_rel(path)
if rel.endswith('/'):
raise HTTPException(400, detail="Invalid file path")
exists_func = getattr(adapter, "exists", None)
exists_func = getattr(adapter_instance, "exists", None)
if not overwrite and callable(exists_func):
try:
if await exists_func(root, rel):
@@ -153,7 +181,7 @@ async def write_file_stream(path: str, data_iter: AsyncIterator[bytes], overwrit
pass
size = 0
stream_func = getattr(adapter, "write_file_stream", None)
stream_func = getattr(adapter_instance, "write_file_stream", None)
if callable(stream_func):
size = await stream_func(root, rel, data_iter)
else:
@@ -161,7 +189,7 @@ async def write_file_stream(path: str, data_iter: AsyncIterator[bytes], overwrit
async for chunk in data_iter:
if chunk:
buf.extend(chunk)
write_func = await _ensure_method(adapter, "write_file")
write_func = await _ensure_method(adapter_instance, "write_file")
await write_func(root, rel, bytes(buf))
size = len(buf)
@@ -175,35 +203,35 @@ async def write_file_stream(path: str, data_iter: AsyncIterator[bytes], overwrit
async def make_dir(path: str):
adapter, _mount, root, rel = await resolve_adapter_and_rel(path)
adapter_instance, _, root, rel = await resolve_adapter_and_rel(path)
if not rel:
raise HTTPException(400, detail="Cannot create root")
mkdir_func = await _ensure_method(adapter, "mkdir")
mkdir_func = await _ensure_method(adapter_instance, "mkdir")
await mkdir_func(root, rel)
await LogService.action("virtual_fs", f"Created directory {path}", details={"path": path})
async def delete_path(path: str):
adapter, _mount, root, rel = await resolve_adapter_and_rel(path)
adapter_instance, _, root, rel = await resolve_adapter_and_rel(path)
if not rel:
raise HTTPException(400, detail="Cannot delete root")
delete_func = await _ensure_method(adapter, "delete")
delete_func = await _ensure_method(adapter_instance, "delete")
await delete_func(root, rel)
await task_service.trigger_tasks("file_deleted", path)
await LogService.action("virtual_fs", f"Deleted {path}", details={"path": path})
async def move_path(src: str, dst: str, overwrite: bool = False, return_debug: bool = True):
adapter_s, mount_s, root_s, rel_s = await resolve_adapter_and_rel(src)
adapter_d, mount_d, root_d, rel_d = await resolve_adapter_and_rel(dst)
adapter_s, adapter_model_s, root_s, rel_s = await resolve_adapter_and_rel(src)
adapter_d, adapter_model_d, root_d, rel_d = await resolve_adapter_and_rel(dst)
debug_info = {
"src": src, "dst": dst,
"rel_s": rel_s, "rel_d": rel_d,
"root_s": root_s, "root_d": root_d,
"overwrite": overwrite
}
if mount_s.id != mount_d.id:
raise HTTPException(400, detail="Cross-mount move not supported")
if adapter_model_s.id != adapter_model_d.id:
raise HTTPException(400, detail="Cross-adapter move not supported")
if not rel_s:
raise HTTPException(400, detail="Cannot move or rename mount root")
if not rel_d:
@@ -266,16 +294,16 @@ async def move_path(src: str, dst: str, overwrite: bool = False, return_debug: b
async def rename_path(src: str, dst: str, overwrite: bool = False, return_debug: bool = True):
adapter_s, mount_s, root_s, rel_s = await resolve_adapter_and_rel(src)
adapter_d, mount_d, root_d, rel_d = await resolve_adapter_and_rel(dst)
adapter_s, adapter_model_s, root_s, rel_s = await resolve_adapter_and_rel(src)
adapter_d, adapter_model_d, root_d, rel_d = await resolve_adapter_and_rel(dst)
debug_info = {
"src": src, "dst": dst,
"rel_s": rel_s, "rel_d": rel_d,
"root_s": root_s, "root_d": root_d,
"overwrite": overwrite
}
if mount_s.id != mount_d.id:
raise HTTPException(400, detail="Cross-mount rename not supported")
if adapter_model_s.id != adapter_model_d.id:
raise HTTPException(400, detail="Cross-adapter rename not supported")
if not rel_s:
raise HTTPException(400, detail="Cannot rename mount root")
if not rel_d:
@@ -338,7 +366,7 @@ async def rename_path(src: str, dst: str, overwrite: bool = False, return_debug:
async def stream_file(path: str, range_header: str | None):
adapter, mount, root, rel = await resolve_adapter_and_rel(path)
adapter_instance, _, root, rel = await resolve_adapter_and_rel(path)
if not rel or rel.endswith('/'):
raise HTTPException(400, detail="Path is a directory")
if is_raw_filename(rel):
@@ -371,7 +399,7 @@ async def stream_file(path: str, range_header: str | None):
except Exception as e:
raise HTTPException(500, detail=f"RAW file processing failed: {e}")
stream_impl = getattr(adapter, "stream_file", None)
stream_impl = getattr(adapter_instance, "stream_file", None)
if callable(stream_impl):
return await stream_impl(root, rel, range_header)
data = await read_file(path)
@@ -380,24 +408,24 @@ async def stream_file(path: str, range_header: str | None):
async def stat_file(path: str):
adapter, _mount, root, rel = await resolve_adapter_and_rel(path)
stat_func = getattr(adapter, "stat_file", None)
adapter_instance, _, root, rel = await resolve_adapter_and_rel(path)
stat_func = getattr(adapter_instance, "stat_file", None)
if not callable(stat_func):
raise HTTPException(501, detail="Adapter does not implement stat_file")
return await stat_func(root, rel)
async def copy_path(src: str, dst: str, overwrite: bool = False, return_debug: bool = True):
adapter_s, mount_s, root_s, rel_s = await resolve_adapter_and_rel(src)
adapter_d, mount_d, root_d, rel_d = await resolve_adapter_and_rel(dst)
adapter_s, adapter_model_s, root_s, rel_s = await resolve_adapter_and_rel(src)
adapter_d, adapter_model_d, root_d, rel_d = await resolve_adapter_and_rel(dst)
debug_info = {
"src": src, "dst": dst,
"rel_s": rel_s, "rel_d": rel_d,
"root_s": root_s, "root_d": root_d,
"overwrite": overwrite
}
if mount_s.id != mount_d.id:
raise HTTPException(400, detail="Cross-mount copy not supported")
if adapter_model_s.id != adapter_model_d.id:
raise HTTPException(400, detail="Cross-adapter copy not supported")
if not rel_s:
raise HTTPException(400, detail="Cannot copy mount root")
if not rel_d:

367
setup/foxel.sh Normal file
View File

@@ -0,0 +1,367 @@
#!/bin/bash
#================================================================================
# Foxel 一键部署与更新脚本
#
# 作者: maxage
# 版本: 1.7 (增加下载镜像, 解决网络问题)
# 描述: 此脚本用于自动化安装、配置和管理 Foxel 项目 (使用 Docker Compose)。
# - 智能检测现有安装,提供安装向导和管理菜单两种模式。
# - 自动检测并安装依赖。
# - 为国内用户提供镜像源切换选项。
#
# 一键运行命令:
# bash <(curl -sL "https://raw.githubusercontent.com/DrizzleTime/Foxel/main/setup/foxel.sh?_=$(date +%s)")
#================================================================================
# --- 消息打印函数 ---
info() {
echo "[信息] $1"
}
warn() {
echo "[警告] $1"
}
error() {
echo "[错误] $1"
}
# --- 基础函数 ---
command_exists() {
command -v "$1" &> /dev/null
}
confirm_action() {
local prompt_message="$1"
printf "%s" "${prompt_message} (y/n): "
read confirmation
if [[ "$confirmation" =~ ^[Yy]$ ]]; then
return 0 # Yes
else
return 1 # No
fi
}
# --- IP地址检测函数 (只输出IP) ---
get_public_ipv4() {
curl -4 -s --max-time 2 https://api.ipify.org || \
curl -4 -s --max-time 2 https://ifconfig.me/ip || \
curl -4 -s --max-time 2 https://icanhazip.com
}
get_public_ipv6() {
curl -6 -s --max-time 2 https://api64.ipify.org || \
curl -6 -s --max-time 2 https://ifconfig.co
}
get_private_ip() {
# 尝试多种方法获取最主要的内网IPv4地址
ip -4 route get 1.1.1.1 2>/dev/null | awk -F"src " 'NR==1{print $2}' | awk '{print $1}' || \
hostname -I 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i ~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/) {print $i; exit}}' || \
ip -4 addr 2>/dev/null | grep -oP '(?<=inet\s)\d+(\.\d+){3}' | grep -v '127.0.0.1' | head -n 1
}
# --- 依赖与环境检查 ---
check_and_install_dependencies() {
info "正在检查所需依赖..."
declare -A deps=( [curl]="curl" [openssl]="openssl" [ss]="iproute2" )
local missing_deps=()
for cmd in "${!deps[@]}"; do
if ! command_exists "$cmd"; then
missing_deps+=("${deps[$cmd]}")
fi
done
if [ ${#missing_deps[@]} -gt 0 ]; then
warn "检测到以下依赖项缺失: ${missing_deps[*]}"
if confirm_action "是否尝试自动安装它们?"; then
local pm_cmd=""
if command_exists apt-get; then pm_cmd="sudo apt-get update && sudo apt-get install -y";
elif command_exists yum; then pm_cmd="sudo yum install -y";
elif command_exists dnf; then pm_cmd="sudo dnf install -y";
else error "未检测到 apt, yum 或 dnf。请手动安装: ${missing_deps[*]}"; exit 1; fi
info "即将使用命令安装: '$pm_cmd ${missing_deps[*]}'"
$pm_cmd "${missing_deps[@]}"
for cmd in "${!deps[@]}"; do
if ! command_exists "$cmd"; then error "依赖 '${deps[$cmd]}' 自动安装失败。"; exit 1; fi
done
info "依赖已成功安装。"
else
error "用户取消了安装。请先手动安装依赖: ${missing_deps[*]}"; exit 1
fi
else
info "所有基础依赖均已满足。"
fi
}
initialize_environment() {
check_and_install_dependencies
if ! command_exists docker; then
error "未找到 Docker。请参照官方文档安装: https://docs.docker.com/engine/install/"; exit 1;
fi
if ! docker info &> /dev/null; then error "Docker deamon 未在运行。请先启动 Docker。"; exit 1; fi
info "Docker 环境检测通过。"
if command_exists docker-compose; then COMPOSE_CMD="docker-compose";
elif docker compose version &> /dev/null; then COMPOSE_CMD="docker compose";
else error "未找到 Docker Compose。请安装 Docker Compose v1 或 v2。"; exit 1; fi
info "检测到 Docker Compose 命令: $COMPOSE_CMD"
}
# --- 新安装流程 ---
install_new_foxel() {
info "--- 开始 Foxel 全新安装 ---"
local install_path
while true; do
read -p "请输入您想在哪里创建 Foxel 的数据目录 (例如: /opt/docker): " install_path
if [[ -z "$install_path" ]]; then warn "输入不能为空,请重新输入。"; continue; fi
if [ ! -d "$install_path" ]; then
if confirm_action "目录 '$install_path' 不存在。您想现在创建它吗?"; then
mkdir -p "$install_path"
if [ $? -eq 0 ]; then info "目录 '$install_path' 创建成功。"; break;
else error "创建目录 '$install_path' 失败。"; fi
else info "操作已取消。"; fi
else info "将使用已存在的目录 '$install_path'。"; break; fi
done
echo
local foxel_dir="$install_path/Foxel"
info "将在 '$foxel_dir' 目录中创建所需文件..."
mkdir -p "$foxel_dir/data/"{db,mount} && chmod 777 "$foxel_dir/data/"{db,mount}
if [ $? -ne 0 ]; then error "创建或设置子目录权限失败。"; exit 1; fi
cd "$foxel_dir" || exit
info "正在下载 'compose.yaml'..."
local COMPOSE_MIRROR_URL="https://ghproxy.com/https://raw.githubusercontent.com/DrizzleTime/Foxel/main/compose.yaml"
local COMPOSE_OFFICIAL_URL="https://raw.githubusercontent.com/DrizzleTime/Foxel/main/compose.yaml"
if ! curl -L -o compose.yaml "$COMPOSE_MIRROR_URL"; then
warn "镜像源下载失败,正在尝试从官方源下载..."
if ! curl -L -o compose.yaml "$COMPOSE_OFFICIAL_URL"; then
error "下载 'compose.yaml' 失败。请检查您的网络连接。"; exit 1;
fi
fi
info "'compose.yaml' 下载成功。"
echo
if confirm_action "您的服务器是否位于中国大陆(以便为您选择更快的镜像源)?"; then
info "正在切换到国内镜像源..."
sed -i 's|^\( *\)image: ghcr.io/drizzletime/foxel:latest|\1#image: ghcr.io/drizzletime/foxel:latest|' compose.yaml
sed -i 's|^\( *\)#image: ghcr.nju.edu.cn/drizzletime/foxel:latest|\1image: ghcr.nju.edu.cn/drizzletime/foxel:latest|' compose.yaml
info "已成功切换到 ghcr.nju.edu.cn 镜像源。"
else
info "将使用默认的 ghcr.io 官方镜像源。"
fi
echo
local new_port
while true; do
read -p "请输入新的对外端口 (或直接按回车使用默认的 8088): " new_port
if [[ -z "$new_port" ]]; then
new_port="8088"
info "将使用默认端口 8088。"
break
fi
if ! [[ "$new_port" =~ ^[0-9]+$ ]] || [ "$new_port" -lt 1 ] || [ "$new_port" -gt 65535 ]; then
warn "输入无效。请输入 1-65535 之间的数字。"
continue
fi
if ss -tuln | grep -q ":${new_port}\b"; then
warn "端口 $new_port 已被占用,请换一个。"
else
sed -i "s/\"8088:80\"/\"$new_port:80\"/" compose.yaml
info "端口已成功修改为 $new_port"
break
fi
done
echo
if ! confirm_action "是否需要生成新的随机密钥 (推荐)(选择 'n' 将使用默认值)"; then
info "将使用 'compose.yaml' 文件中的默认密钥。"
else
info "正在生成新的随机密钥..."
sed -i "s|SECRET_KEY=.*|SECRET_KEY=$(openssl rand -base64 32)|" compose.yaml
sed -i "s|TEMP_LINK_SECRET_KEY=.*|TEMP_LINK_SECRET_KEY=$(openssl rand -base64 32)|" compose.yaml
info "新的密钥已成功生成并替换。"
fi
echo
if confirm_action "所有配置已准备就绪!您想现在启动 Foxel 项目吗?"; then
info "正在启动 Foxel 服务... 这可能需要一些时间来拉取镜像。"
$COMPOSE_CMD pull && $COMPOSE_CMD up -d
if [ $? -eq 0 ]; then
info "Foxel 部署成功!"
info "-------------------------------------------------"
info "正在检测服务器IP地址请稍候..."
# 先捕获所有IP地址
local public_ipv4=$(get_public_ipv4 2>/dev/null)
local public_ipv6=$(get_public_ipv6 2>/dev/null)
local private_ip=$(get_private_ip 2>/dev/null)
local final_port=$new_port
local ip_found=false
echo
info "部署完成!您可以通过以下地址访问 Foxel:"
if [[ -n "$private_ip" ]]; then
echo " - 局域网地址: http://${private_ip}:${final_port}"
ip_found=true
fi
if [[ -n "$public_ipv4" ]]; then
echo " - 公网地址 (IPv4): http://${public_ipv4}:${final_port}"
ip_found=true
fi
if [[ -n "$public_ipv6" ]]; then
# 正确格式化IPv6地址
echo " - 公网地址 (IPv6): http://[${public_ipv6}]:${final_port}"
ip_found=true
fi
if ! $ip_found; then
warn "未能自动检测到服务器IP地址。"
echo " 请手动使用 http://[您的服务器IP]:${final_port} 访问它。"
fi
echo "-------------------------------------------------"
else
error "启动 Foxel 失败。请运行 'cd $foxel_dir && $COMPOSE_CMD logs' 查看日志。"
fi
else
info "操作已取消。您可以稍后进入 '$foxel_dir' 并手动运行 '$COMPOSE_CMD up -d'。"
fi
}
# --- 现有安装管理 ---
get_foxel_install_dir() {
local data_path
data_path=$(docker inspect foxel --format='{{range .Mounts}}{{if eq .Destination "/app/data"}}{{.Source}}{{end}}{{end}}')
if [[ -n "$data_path" ]]; then
echo "$(dirname "$data_path")"
fi
}
service_menu() {
while true; do
echo
echo "--- 服务管理 ---"
echo "1. 启动 Foxel"
echo "2. 停止 Foxel"
echo "3. 重启 Foxel"
echo "4. 查看日志"
echo "5. 返回上级菜单"
read -p "请选择操作 [1-5]: " service_choice
case $service_choice in
1) info "正在启动..."; $COMPOSE_CMD up -d ;;
2) info "正在停止..."; $COMPOSE_CMD stop ;;
3) info "正在重启..."; $COMPOSE_CMD restart ;;
4) info "正在显示日志 (按 Ctrl+C 退出)..."; $COMPOSE_CMD logs -f ;;
5) break ;;
*) warn "无效输入。" ;;
esac
done
}
manage_existing_installation() {
info "检测到 Foxel 已安装。"
local foxel_dir
foxel_dir=$(get_foxel_install_dir)
if [[ -z "$foxel_dir" || ! -f "$foxel_dir/compose.yaml" ]]; then
error "无法自动定位 Foxel 的 compose.yaml 文件。"
read -p "请手动输入 Foxel 的安装目录 (包含 compose.yaml 的目录): " foxel_dir
if [[ ! -f "$foxel_dir/compose.yaml" ]]; then error "在指定目录中未找到 compose.yaml。退出。"; exit 1; fi
fi
info "Foxel 安装目录位于: $foxel_dir"
cd "$foxel_dir" || exit 1
while true; do
echo
echo "--- Foxel 管理菜单 ---"
echo "1. 更新"
echo "2. 卸载"
echo "3. 重新安装"
echo "4. 服务管理 (启动/停止/重启/日志)"
echo "5. 退出"
read -p "请选择操作 [1-5]: " choice
case $choice in
1) # 更新
warn "更新前,强烈建议您备份 '$foxel_dir/data' 目录!"
if confirm_action "您确定要继续更新吗?"; then
info "正在拉取最新镜像..."
$COMPOSE_CMD pull
info "正在使用新镜像重新部署..."
$COMPOSE_CMD up -d
if [ $? -eq 0 ]; then info "Foxel 更新成功!"; else error "更新失败!"; fi
else info "更新操作已取消。"; fi
;;
2) # 卸载
warn "这将停止并删除 Foxel 容器及相关网络!"
warn "强烈建议您先备份 '$foxel_dir/data' 目录!"
if confirm_action "您确定要继续卸载吗?"; then
info "正在停止并移除容器..."
$COMPOSE_CMD down
if confirm_action "是否要删除所有数据卷(这将删除数据库等所有数据)?"; then
$COMPOSE_CMD down -v
info "数据卷已删除。"
fi
if confirm_action "是否要删除整个 Foxel 安装目录 '$foxel_dir'"; then
rm -rf "$foxel_dir"
info "安装目录已删除。"
fi
info "Foxel 卸载完成。"
exit 0
else info "卸载操作已取消。"; fi
;;
3) # 重新安装
warn "重新安装将完全删除当前的 Foxel 实例(包括数据),然后进入全新安装流程。"
warn "在继续之前,请务必备份好您的重要数据!"
if confirm_action "您确定要重新安装吗?"; then
info "正在执行卸载..."
$COMPOSE_CMD down -v && rm -rf "$foxel_dir"
info "旧实例已彻底移除。"
install_new_foxel
exit 0
else info "重新安装操作已取消。"; fi
;;
4) # 服务管理
service_menu
;;
5) # 退出
break
;;
*)
warn "无效输入。"
;;
esac
done
}
# --- 主函数 ---
main() {
clear
local SCRIPT_VERSION="1.7"
echo "================================================="
info "欢迎使用 Foxel 一键安装与管理脚本 (版本: ${SCRIPT_VERSION})"
echo "================================================="
echo
initialize_environment
echo
if docker ps -a -q -f "name=^/foxel$" | grep -q .; then
manage_existing_installation
else
install_new_foxel
fi
echo
info "脚本执行完毕。"
}
# --- 脚本入口 ---
main

1650
uv.lock generated Normal file

File diff suppressed because it is too large Load Diff

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

@@ -1,13 +1,20 @@
<!doctype html>
<html lang="en">
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Foxel</title>
<link rel='stylesheet'
href='https://chinese-fonts-cdn.deno.dev/packages/maple-mono-cn/dist/MapleMono-CN-Regular/result.css' />
</head>
<body>
<style>
* {
font-family: 'Maple Mono CN';
}
</style>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>

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

@@ -4,11 +4,15 @@ import { AuthProvider } from './contexts/AuthContext.tsx';
import { status as getStatus } from './api/config.ts';
import type { SystemStatus } from './api/config.ts';
import { SystemContext } from './contexts/SystemContext.tsx';
import { Spin } from 'antd';
import { ThemeProvider } from './contexts/ThemeContext.tsx';
import { Spin, ConfigProvider } from 'antd';
import { Routes, Route, Navigate } from 'react-router';
import SetupPage from './pages/SetupPage.tsx';
import { I18nProvider, useI18n } from './i18n';
import zhCN from 'antd/locale/zh_CN';
import enUS from 'antd/locale/en_US';
function App() {
function AppInner() {
const [status, setStatus] = useState<SystemStatus | null>(null);
useEffect(() => {
async function checkInitialization() {
@@ -35,20 +39,33 @@ function App() {
);
}
const { lang } = useI18n();
const locale = lang === 'zh' ? zhCN : enUS;
return (
<SystemContext.Provider value={status}>
<AuthProvider>
{!status.is_initialized ? (
<Routes>
<Route path="/setup" element={<SetupPage />} />
<Route path="*" element={<Navigate to="/setup" replace />} />
</Routes>
) : (
<AppRouter />
)}
</AuthProvider>
</SystemContext.Provider>
<ConfigProvider locale={locale}>
<SystemContext.Provider value={status}>
<AuthProvider>
<ThemeProvider>
{!status.is_initialized ? (
<Routes>
<Route path="/setup" element={<SetupPage />} />
<Route path="*" element={<Navigate to="/setup" replace />} />
</Routes>
) : (
<AppRouter />
)}
</ThemeProvider>
</AuthProvider>
</SystemContext.Provider>
</ConfigProvider>
);
}
export default App;
export default function App() {
return (
<I18nProvider>
<AppInner />
</I18nProvider>
);
}

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() {

46
web/src/api/plugins.ts Normal file
View File

@@ -0,0 +1,46 @@
import request from './client';
export interface PluginItem {
id: number;
url: string;
enabled: boolean;
key?: string | null;
name?: string | null;
version?: string | null;
supported_exts?: string[] | null;
default_bounds?: Record<string, any> | null;
default_maximized?: boolean | null;
icon?: string | null;
description?: string | null;
author?: string | null;
website?: string | null;
github?: string | null;
}
export interface PluginCreate {
url: string;
enabled?: boolean;
}
export interface PluginManifestUpdate {
key?: string;
name?: string;
version?: string;
supported_exts?: string[];
default_bounds?: Record<string, any>;
default_maximized?: boolean;
icon?: string;
description?: string;
author?: string;
website?: string;
github?: string;
}
export const pluginsApi = {
list: () => request<PluginItem[]>(`/plugins`),
create: (payload: PluginCreate) => request<PluginItem>(`/plugins`, { method: 'POST', json: payload }),
remove: (id: number) => request(`/plugins/${id}`, { method: 'DELETE' }),
update: (id: number, payload: PluginCreate) => request<PluginItem>(`/plugins/${id}`, { method: 'PUT', json: payload }),
updateManifest: (id: number, payload: PluginManifestUpdate) => request<PluginItem>(`/plugins/${id}/metadata`, { method: 'POST', json: payload }),
};

View File

@@ -14,9 +14,19 @@ export interface AutomationTask {
export type AutomationTaskCreate = Omit<AutomationTask, 'id'>;
export type AutomationTaskUpdate = Partial<AutomationTaskCreate>;
export interface QueuedTask {
id: string;
name: string;
status: 'pending' | 'running' | 'success' | 'failed';
result?: any;
error?: string;
task_info: Record<string, any>;
}
export const tasksApi = {
list: () => request<AutomationTask[]>('/tasks/'),
create: (payload: AutomationTaskCreate) => request<AutomationTask>('/tasks/', { method: 'POST', json: payload }),
update: (id: number, payload: AutomationTaskUpdate) => request<AutomationTask>(`/tasks/${id}`, { method: 'PUT', json: payload }),
remove: (id: number) => request<void>(`/tasks/${id}`, { method: 'DELETE' }),
getQueue: () => request<QueuedTask[]>('/tasks/queue'),
};

5
web/src/api/vectorDB.ts Normal file
View File

@@ -0,0 +1,5 @@
import client from './client';
export const vectorDBApi = {
clearAll: () => client('/vector-db/clear-all', { method: 'POST' }),
};

View File

@@ -27,12 +27,14 @@ export interface SearchResultItem {
}
export const vfsApi = {
list: (path: string, page: number = 1, pageSize: number = 50) => {
list: (path: string, page: number = 1, pageSize: number = 50, sortBy: string = 'name', sortOrder: string = 'asc') => {
const cleaned = path.replace(/\\/g, '/');
const trimmed = cleaned === '/' ? '' : cleaned.replace(/^\/+/, '');
const params = new URLSearchParams({
page: page.toString(),
page_size: pageSize.toString()
page_size: pageSize.toString(),
sort_by: sortBy,
sort_order: sortOrder
});
return request<DirListing>(`/fs/${encodeURI(trimmed)}?${params}`);
},
@@ -51,7 +53,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

@@ -243,8 +243,8 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
left: w.maximized ? 0 : w.x,
width: w.maximized ? '100vw' : w.width,
height: w.maximized ? '100vh' : w.height,
background: 'rgba(240, 242, 245, 0.7)', // Semi-transparent background
border: '1px solid rgba(255, 255, 255, 0.18)',
background: 'var(--ant-color-bg-elevated, var(--ant-color-bg-container))',
border: '1px solid var(--ant-color-border-secondary, rgba(255,255,255,0.18))',
borderRadius: w.maximized ? 0 : 12,
boxShadow: w.maximized
? 'none'
@@ -254,7 +254,7 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
backdropFilter: 'blur(20px) saturate(180%)', // Enhanced blur effect
backdropFilter: 'blur(12px) saturate(150%)',
zIndex: 3000 + idx,
willChange: 'left,top,width,height',
transition: interacting ? 'none' : 'top .15s,left .15s,width .15s,height .15s,box-shadow .25s'
@@ -269,9 +269,9 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 12px',
background: 'rgba(0, 0, 0, 0.25)', // Lighter, transparent title bar
borderBottom: '1px solid rgba(255, 255, 255, 0.1)',
color: '#333', // Darker text for readability
background: 'var(--ant-color-fill-secondary, rgba(0,0,0,0.25))',
borderBottom: '1px solid var(--ant-color-border-secondary, rgba(255,255,255,0.1))',
color: 'var(--ant-color-text, #333)',
fontSize: 13,
fontWeight: 600,
letterSpacing: .2,
@@ -298,7 +298,7 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
icon={w.maximized ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
onClick={() => onToggleMax(w.id)}
style={{
color: '#555',
color: 'var(--ant-color-text-secondary, #555)',
width: 30,
height: 30,
display: 'flex',
@@ -314,7 +314,7 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
icon={<CloseOutlined />}
onClick={() => onClose(w.id)}
style={{
color: '#ff4d4f',
color: 'var(--ant-color-error, #ff4d4f)',
width: 30,
height: 30,
display: 'flex',

View File

@@ -177,7 +177,7 @@ export const ImageViewerApp: React.FC<AppComponentProps> = ({ filePath, entry, o
if (err) {
return (
<div style={{
color: '#f5222d',
color: 'var(--ant-color-error, #f5222d)',
padding: 16,
background: 'rgba(20,20,20,0.8)',
backdropFilter: 'blur(24px)'

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);
})
@@ -58,7 +60,7 @@ export const OfficeViewerApp: React.FC<AppComponentProps> = ({ filePath, onReque
}
return (
<div style={{ width: '100%', height: '100%', background: '#fff' }}>
<div style={{ width: '100%', height: '100%', background: 'var(--ant-color-bg-container, #fff)' }}>
{url ? (
<iframe
src={url}
@@ -77,4 +79,4 @@ export const OfficeViewerApp: React.FC<AppComponentProps> = ({ filePath, onReque
)}
</div>
);
};
};

View File

@@ -0,0 +1,59 @@
import React, { useRef, useState } from 'react';
import type { AppComponentProps } from '../types';
import { vfsApi } from '../../api/vfs';
import { loadPluginFromUrl, ensureManifest, type RegisteredPlugin } from '../../plugins/runtime';
import type { PluginItem } from '../../api/plugins';
import { useAsyncSafeEffect } from '../../hooks/useAsyncSafeEffect';
import { useI18n } from '../../i18n';
export interface PluginAppHostProps extends AppComponentProps {
plugin: PluginItem;
}
export const PluginAppHost: React.FC<PluginAppHostProps> = ({ plugin, filePath, entry, onRequestClose }) => {
const containerRef = useRef<HTMLDivElement>(null);
const [error, setError] = useState<string | null>(null);
const onCloseRef = useRef(onRequestClose);
onCloseRef.current = onRequestClose;
const { t } = useI18n();
const pluginRef = useRef<RegisteredPlugin | null>(null);
useAsyncSafeEffect(
async ({ isDisposed }) => {
try {
const p = await loadPluginFromUrl(plugin.url);
if (isDisposed()) return;
pluginRef.current = p;
await ensureManifest(plugin.id, p);
if (isDisposed()) return;
const token = await vfsApi.getTempLinkToken(filePath);
if (isDisposed()) return;
const downloadUrl = vfsApi.getTempPublicUrl(token.token);
if (isDisposed() || !containerRef.current) return;
await p.mount(containerRef.current, {
filePath,
entry,
urls: { downloadUrl },
host: { close: () => onCloseRef.current() },
});
} catch (e: any) {
if (!isDisposed()) setError(e?.message || t('Plugin run failed'));
}
},
[plugin.id, plugin.url, filePath],
() => {
try {
if (pluginRef.current?.unmount && containerRef.current) {
pluginRef.current.unmount(containerRef.current);
}
} catch {}
},
);
if (error) {
return <div style={{ padding: 12, color: 'red' }}>{t('Plugin Error')}: {error}</div>;
}
return <div ref={containerRef} style={{ width: '100%', height: '100%', overflow: 'auto' }} />;
};

View File

@@ -64,19 +64,19 @@ export const TextEditorApp: React.FC<AppComponentProps> = ({ filePath, entry, on
}, [handleSave]);
return (
<Layout style={{ height: '100%', background: '#ffffff' }}>
<Layout style={{ height: '100%', background: 'var(--ant-color-bg-container, #ffffff)' }}>
<Header
style={{
background: '#f0f2f5',
background: 'var(--ant-color-bg-layout, #f0f2f5)',
padding: '0 16px',
height: 40,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
borderBottom: '1px solid #d9d9d9'
borderBottom: '1px solid var(--ant-color-border-secondary, #d9d9d9)'
}}
>
<span style={{ color: 'rgba(0, 0, 0, 0.88)' }}>
<span style={{ color: 'var(--ant-color-text, rgba(0,0,0,0.88))' }}>
{entry.name} {isDirty && '*'}
</span>
<Space>
@@ -101,4 +101,4 @@ export const TextEditorApp: React.FC<AppComponentProps> = ({ filePath, entry, on
</Content>
</Layout>
);
};
};

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

@@ -7,7 +7,7 @@ export const descriptor: AppDescriptor = {
supported: (entry) => {
if (entry.is_dir) return false;
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
return ['mp4','webm','ogg','m4v','mov'].includes(ext);
return ['mp4','webm','ogg','m4v','mov','mkv','avi','wmv','flv','3gp'].includes(ext);
},
component: VideoPlayerApp,
default: true,

View File

@@ -1,9 +1,11 @@
import type { VfsEntry } from '../api/client';
import type { AppDescriptor } from './types';
import React from 'react';
import { pluginsApi, type PluginItem } from '../api/plugins';
import { PluginAppHost } from './PluginHost';
const apps: AppDescriptor[] = [];
// 使用 import.meta.glob 动态导入所有应用
// vite-glob-ignore
const appModules = import.meta.glob('./*/index.ts');
async function loadApps() {
@@ -16,11 +18,34 @@ async function loadApps() {
}
}
}
try {
const items = await pluginsApi.list();
items.filter(p => p.enabled !== false).forEach((p) => registerPluginAsApp(p));
} catch (e) {
}
}
// 立即加载并注册所有应用
loadApps();
function registerPluginAsApp(p: PluginItem) {
const key = 'plugin:' + p.id;
if (apps.find(a => a.key === key)) return;
const supported = (entry: VfsEntry) => {
if (entry.is_dir) return false;
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
if (!p.supported_exts || p.supported_exts.length === 0) return true;
return p.supported_exts.includes(ext);
};
apps.push({
key,
name: p.name || `插件 ${p.id}`,
supported,
component: (props: any) => React.createElement(PluginAppHost, { plugin: p, ...props }),
default: false,
defaultBounds: p.default_bounds || undefined,
defaultMaximized: p.default_maximized || undefined,
});
}
loadApps();
export function getAppsForEntry(entry: VfsEntry): AppDescriptor[] {
return apps.filter(a => a.supported(entry));
@@ -43,3 +68,27 @@ export function getDefaultAppForEntry(entry: VfsEntry): AppDescriptor | undefine
export type { AppDescriptor };
export type { AppComponentProps } from './types';
export async function reloadPluginApps() {
try {
const items = await pluginsApi.list();
const keepKeys = new Set(items.filter(p => p.enabled !== false).map(p => 'plugin:' + p.id));
for (let i = apps.length - 1; i >= 0; i--) {
const a = apps[i];
if (a.key.startsWith('plugin:') && !keepKeys.has(a.key)) {
apps.splice(i, 1);
}
}
items.filter(p => p.enabled !== false).forEach(p => {
const key = 'plugin:' + p.id;
const existing = apps.find(a => a.key === key);
if (!existing) {
registerPluginAsApp(p);
} else {
existing.name = p.name || `插件 ${p.id}`;
existing.defaultBounds = p.default_bounds || undefined;
existing.defaultMaximized = p.default_maximized || undefined;
}
});
} catch { }
}

View File

@@ -0,0 +1,20 @@
import { Dropdown, Button } from 'antd';
import { GlobalOutlined, CheckOutlined } from '@ant-design/icons';
import { memo } from 'react';
import { useI18n } from '../i18n';
const LanguageSwitcher = memo(function LanguageSwitcher() {
const { lang, setLang, t } = useI18n();
const items = [
{ key: 'zh', label: t('Chinese'), icon: lang === 'zh' ? <CheckOutlined /> : undefined, onClick: () => setLang('zh') },
{ key: 'en', label: t('English'), icon: lang === 'en' ? <CheckOutlined /> : undefined, onClick: () => setLang('en') },
];
return (
<Dropdown menu={{ items }} trigger={['click']}>
<Button icon={<GlobalOutlined />}>{t('Language')}</Button>
</Dropdown>
);
});
export default LanguageSwitcher;

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { Form, Input, Select, Typography } from 'antd';
import type { ProcessorTypeMeta } from '../api/processors';
import { useI18n } from '../i18n';
interface ProcessorConfigFormProps {
processorMeta: ProcessorTypeMeta | undefined;
@@ -9,17 +10,18 @@ interface ProcessorConfigFormProps {
}
export const ProcessorConfigForm: React.FC<ProcessorConfigFormProps> = ({ processorMeta, configPath }) => {
const { t } = useI18n();
if (!processorMeta) {
return <Typography.Text type="secondary"></Typography.Text>;
return <Typography.Text type="secondary">{t('Please select a processor')}</Typography.Text>;
}
if (!processorMeta.config_schema?.length) {
return <Typography.Text type="secondary"></Typography.Text>;
return <Typography.Text type="secondary">{t('No config fields')}</Typography.Text>;
}
return (
<>
{processorMeta.config_schema.map(field => {
const rules = field.required ? [{ required: true, message: `请输入${field.label}` }] : [];
const rules = field.required ? [{ required: true, message: t('Please input {label}', { label: field.label }) }] : [];
let inputNode: React.ReactNode;
switch (field.type) {
@@ -31,7 +33,7 @@ export const ProcessorConfigForm: React.FC<ProcessorConfigFormProps> = ({ proces
break;
case 'select':
inputNode = (
<Select placeholder={field.placeholder || '请选择'}>
<Select placeholder={field.placeholder || t('Please select')}>
{field.options?.map((opt: any) => (
<Select.Option key={String(opt.value)} value={opt.value}>
{opt.label}
@@ -48,7 +50,7 @@ export const ProcessorConfigForm: React.FC<ProcessorConfigFormProps> = ({ proces
<Form.Item
key={field.key}
name={[...configPath, field.key]}
label={field.label}
label={t(field.label)}
rules={rules}
initialValue={field.default}
>
@@ -58,4 +60,4 @@ export const ProcessorConfigForm: React.FC<ProcessorConfigFormProps> = ({ proces
})}
</>
);
};
};

View File

@@ -0,0 +1,185 @@
import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { ConfigProvider, theme as antdTheme } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import type { ThemeConfig } from 'antd/es/config-provider/context';
import { getAllConfig } from '../api/config';
import { useAuth } from './AuthContext';
import baseTheme from '../theme';
type ThemeMode = 'light' | 'dark' | 'system';
interface ThemeState {
mode: ThemeMode;
primaryColor?: string | null;
borderRadius?: number | null;
customTokens?: Record<string, any> | null;
customCSS?: string | null;
}
interface ThemeContextType {
refreshTheme: () => Promise<void>;
previewTheme: (patch: Partial<ThemeState>) => void;
mode: ThemeMode;
resolvedMode: ThemeMode;
}
const Ctx = createContext<ThemeContextType>({} as any);
const CONFIG_KEYS = {
MODE: 'THEME_MODE',
PRIMARY: 'THEME_PRIMARY_COLOR',
RADIUS: 'THEME_BORDER_RADIUS',
TOKENS: 'THEME_CUSTOM_TOKENS',
CSS: 'THEME_CUSTOM_CSS',
};
function parseJSON<T = any>(text: string | null | undefined): T | null {
if (!text) return null;
try {
return JSON.parse(text) as T;
} catch {
return null;
}
}
function useSystemDarkPreferred() {
const [isDark, setIsDark] = useState<boolean>(
typeof window !== 'undefined' && window.matchMedia
? window.matchMedia('(prefers-color-scheme: dark)').matches
: false
);
useEffect(() => {
if (!window.matchMedia) return;
const mql = window.matchMedia('(prefers-color-scheme: dark)');
const handler = (e: MediaQueryListEvent) => setIsDark(e.matches);
mql.addEventListener?.('change', handler);
return () => mql.removeEventListener?.('change', handler);
}, []);
return isDark;
}
function buildThemeConfig(state: ThemeState, systemDark: boolean): ThemeConfig {
const resolvedMode: ThemeMode = state.mode === 'system' ? (systemDark ? 'dark' : 'light') : state.mode;
const algorithm = resolvedMode === 'dark'
? [antdTheme.darkAlgorithm, antdTheme.compactAlgorithm]
: [antdTheme.defaultAlgorithm, antdTheme.compactAlgorithm];
const safeBaseTokens: Record<string, any> = resolvedMode === 'dark'
? {
borderRadius: baseTheme.token?.borderRadius,
fontSize: baseTheme.token?.fontSize,
controlHeight: baseTheme.token?.controlHeight,
boxShadow: baseTheme.token?.boxShadow,
}
: { ...(baseTheme.token as any) };
const token = {
...safeBaseTokens,
...(state.primaryColor ? { colorPrimary: state.primaryColor } : {}),
...(state.borderRadius != null ? { borderRadius: state.borderRadius } : {}),
...(state.customTokens || {}),
} as any;
const baseComponents = { ...(baseTheme.components as any) };
if (resolvedMode === 'dark' && baseComponents) {
if (baseComponents.Menu) {
const { itemHoverColor, itemHoverBg, itemSelectedBg, itemSelectedColor, ...rest } = baseComponents.Menu;
baseComponents.Menu = rest;
}
if (baseComponents.Dropdown) {
const { controlItemBgHover, ...rest } = baseComponents.Dropdown;
baseComponents.Dropdown = rest;
}
if (baseComponents.Table) {
const { headerBg, rowHoverBg, ...rest } = baseComponents.Table;
baseComponents.Table = rest;
}
}
return { algorithm, token, components: baseComponents } satisfies ThemeConfig;
}
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const { isAuthenticated } = useAuth();
const systemDark = useSystemDarkPreferred();
const [state, setState] = useState<ThemeState>({ mode: 'light' });
const styleTagRef = useRef<HTMLStyleElement | null>(null);
const ensureStyleTag = () => {
if (styleTagRef.current) return styleTagRef.current;
let styleEl = document.getElementById('foxel-custom-css') as HTMLStyleElement | null;
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = 'foxel-custom-css';
document.head.appendChild(styleEl);
}
styleTagRef.current = styleEl;
return styleEl;
};
const applyCustomCSS = (cssText: string | null | undefined) => {
const el = ensureStyleTag();
el.textContent = cssText || '';
};
const applyHtmlDataTheme = (mode: ThemeMode) => {
const finalMode = mode === 'system' ? (systemDark ? 'dark' : 'light') : mode;
document.documentElement.setAttribute('data-theme', finalMode);
};
const refreshTheme = async () => {
if (!isAuthenticated) {
applyHtmlDataTheme(state.mode || 'light');
applyCustomCSS(state.customCSS || '');
return;
}
try {
const cfg = await getAllConfig();
const mode = (cfg[CONFIG_KEYS.MODE] as ThemeMode) || 'light';
const primary = (cfg[CONFIG_KEYS.PRIMARY] as string) || null;
const radiusStr = cfg[CONFIG_KEYS.RADIUS];
const radius = radiusStr != null ? Number(radiusStr) : null;
const customTokens = parseJSON<Record<string, any>>(cfg[CONFIG_KEYS.TOKENS]);
const customCSS = (cfg[CONFIG_KEYS.CSS] as string) || '';
setState({ mode, primaryColor: primary, borderRadius: radius, customTokens, customCSS });
applyHtmlDataTheme(mode);
applyCustomCSS(customCSS);
} catch (e) {
applyHtmlDataTheme('light');
applyCustomCSS('');
}
};
const previewTheme = (patch: Partial<ThemeState>) => {
const next: ThemeState = { ...state, ...patch };
setState(next);
applyHtmlDataTheme(next.mode || 'light');
applyCustomCSS(next.customCSS || '');
};
useEffect(() => {
refreshTheme();
}, [isAuthenticated, systemDark]);
const themeConfig = useMemo(() => buildThemeConfig(state, systemDark), [state, systemDark]);
const resolvedMode: ThemeMode = useMemo(() => (state.mode === 'system' ? (systemDark ? 'dark' : 'light') : state.mode), [state.mode, systemDark]);
const ctxValue = useMemo<ThemeContextType>(() => ({
refreshTheme,
previewTheme,
mode: state.mode,
resolvedMode,
}), [state.mode, resolvedMode]);
return (
<Ctx.Provider value={ctxValue}>
<ConfigProvider theme={{ ...themeConfig, cssVar: true }} locale={zhCN}>
{children}
</ConfigProvider>
</Ctx.Provider>
);
}
export function useTheme() {
return useContext(Ctx);
}

View File

@@ -1,41 +1,42 @@
html,body,#root { height: 100%; }
body { font-family: system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,'Noto Sans',sans-serif; background:#f9f9f9; }
body { font-family: system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,'Noto Sans',sans-serif; background: var(--ant-color-bg-layout, #f9f9f9); }
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #d9d9d9; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #bfbfbf; }
::-webkit-scrollbar-thumb { background: var(--ant-color-fill-tertiary, #d9d9d9); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: var(--ant-color-fill-secondary, #bfbfbf); }
.fx-surface { background:#fff; border:1px solid #eaeaea; border-radius:12px; }
.fx-card { background:linear-gradient(#fff,#fafafa); border:1px solid #eaeaea; border-radius:14px; box-shadow:0 1px 2px rgba(0,0,0,.04),0 4px 10px -2px rgba(0,0,0,.03); }
.fx-fade-text { color:#555; }
.fx-surface { background: var(--ant-color-bg-container, #fff); border:1px solid var(--ant-color-border, #eaeaea); border-radius:12px; }
.fx-card { background: var(--ant-color-bg-container, #fff); border:1px solid var(--ant-color-border, #eaeaea); border-radius:14px; box-shadow:0 1px 2px rgba(0,0,0,.04),0 4px 10px -2px rgba(0,0,0,.03); }
.fx-fade-text { color: var(--ant-color-text-secondary, #555); }
.fx-quiet-btn.ant-btn-text:not(:hover) { color:#666; }
.fx-quiet-btn.ant-btn-text:not(:hover) { color: var(--ant-color-text-tertiary, #666); }
.ant-layout { background:#f9f9f9; }
/* 使用 antd 默认布局背景 */
.ant-layout { background: transparent; }
/* Menu compact spacing adjustments */
.ant-menu-inline .ant-menu-item { margin-block:2px; }
/* Sidebar high-contrast selection */
.sider-menu .ant-menu-item-selected {
background:#111 !important;
background: var(--ant-color-primary, #111) !important;
color:#fff !important;
}
.sider-menu .ant-menu-item-selected .ant-menu-item-icon,
.sider-menu .ant-menu-item-selected .anticon { color:#fff !important; }
.sider-menu .ant-menu-item:not(.ant-menu-item-selected):hover { background:#f2f2f2; }
.sider-menu .ant-menu-item:not(.ant-menu-item-selected):hover { background: var(--ant-color-fill-tertiary, #f2f2f2); }
.row-selected td { background: rgba(24,144,255,0.12) !important; }
.row-selected:hover td { background: rgba(24,144,255,0.2) !important; }
.fx-grid { display:flex; flex-wrap:wrap; gap:20px; }
.fx-grid-item { width:160px; cursor:pointer; border-radius:14px; padding:12px 12px 10px; background:#f5f5f5; position:relative; display:flex; flex-direction:column; align-items:stretch; gap:6px; transition:.18s box-shadow,.18s background; }
.fx-grid-item.dir { background:#f3f3f3; }
.fx-grid-item.selected { box-shadow:0 0 0 2px var(--ant-color-primary); background:#acc0c0; }
.fx-grid-item:hover { background:#d2d1d1a7; box-shadow:0 1px 4px rgba(0,0,0,.06); }
.fx-grid-item .thumb { height:120px; border-radius:10px; background:#fff; display:flex; align-items:center; justify-content:center; overflow:hidden; position:relative; box-shadow: inset 0 0 0 1px #eee; }
.fx-grid-item { width:160px; cursor:pointer; border-radius:14px; padding:12px 12px 10px; background: var(--ant-color-fill-tertiary, #f5f5f5); position:relative; display:flex; flex-direction:column; align-items:stretch; gap:6px; transition:.18s box-shadow,.18s background; }
.fx-grid-item.dir { background: var(--ant-color-fill-secondary, #f3f3f3); }
.fx-grid-item.selected { box-shadow:0 0 0 2px var(--ant-color-primary); background: var(--ant-color-primary-bg, #e6f4ff); }
.fx-grid-item:hover { background: var(--ant-color-fill, #ededed); box-shadow:0 1px 4px rgba(0,0,0,.06); }
.fx-grid-item .thumb { height:120px; border-radius:10px; background: var(--ant-color-bg-container, #fff); display:flex; align-items:center; justify-content:center; overflow:hidden; position:relative; box-shadow: inset 0 0 0 1px var(--ant-color-border-secondary, #eee); }
.fx-grid-item .thumb img { width:100%; height:100%; object-fit:cover; }
.fx-grid-item .thumb .badge { position:absolute; top:6px; left:6px; background:#111; color:#fff; font-size:10px; padding:2px 4px; border-radius:6px; line-height:1; letter-spacing:.5px; }
.fx-grid-item .thumb .badge { position:absolute; top:6px; left:6px; background: var(--ant-color-primary, #111); color:#fff; font-size:10px; padding:2px 4px; border-radius:6px; line-height:1; letter-spacing:.5px; }
.fx-grid-item .name { font-weight:600; font-size:13px; }
.ellipsis { overflow:hidden; white-space:nowrap; text-overflow:ellipsis; }

View File

@@ -0,0 +1,35 @@
import React, { useEffect } from 'react';
export interface AsyncEffectCtx {
isDisposed: () => boolean;
signal: AbortSignal;
}
export function useAsyncSafeEffect(
effect: (ctx: AsyncEffectCtx) => void | Promise<void>,
deps: React.DependencyList,
cleanup?: (ctx: AsyncEffectCtx) => void,
) {
useEffect(() => {
let disposed = false;
const ac = new AbortController();
const ctx: AsyncEffectCtx = {
isDisposed: () => disposed,
signal: ac.signal,
};
Promise.resolve(effect(ctx)).catch(() => {
// 故意忽略 effect 内部抛出的异常,交由调用方处理
});
return () => {
disposed = true;
try {
cleanup?.(ctx);
} finally {
ac.abort();
}
};
}, deps);
}

58
web/src/i18n/index.tsx Normal file
View File

@@ -0,0 +1,58 @@
import { createContext, useContext, useMemo, useState, useEffect } from 'react';
import type { PropsWithChildren } from 'react';
import { zh } from './locales/zh';
import { en } from './locales/en';
type Lang = 'zh' | 'en';
type Dict = Record<string, string>;
const dicts: Record<Lang, Dict> = {
zh,
en,
};
export interface I18nContextValue {
lang: Lang;
setLang: (lang: Lang) => void;
t: (key: string, params?: Record<string, string | number>) => string;
}
const I18nContext = createContext<I18nContextValue | null>(null);
function interpolate(template: string, params?: Record<string, string | number>): string {
if (!params) return template;
return template.replace(/\{(\w+)\}/g, (_, k) => String(params[k] ?? `{${k}}`));
}
export function I18nProvider({ children }: PropsWithChildren) {
const [lang, setLangState] = useState<Lang>(() => (localStorage.getItem('lang') as Lang) || 'zh');
const setLang = (l: Lang) => {
setLangState(l);
localStorage.setItem('lang', l);
};
useEffect(() => {
document.documentElement.lang = lang;
}, [lang]);
const t = (key: string, params?: Record<string, string | number>) => {
const dict = dicts[lang] || {};
const raw = dict[key] ?? key; // fallback to key (English)
return interpolate(raw, params);
};
const value = useMemo<I18nContextValue>(() => ({ lang, setLang, t }), [lang]);
return (
<I18nContext.Provider value={value}>
{children}
</I18nContext.Provider>
);
}
export function useI18n() {
const ctx = useContext(I18nContext);
if (!ctx) throw new Error('useI18n must be used within I18nProvider');
return ctx;
}

365
web/src/i18n/locales/en.ts Normal file
View File

@@ -0,0 +1,365 @@
export const en = {
// General
'All Files': 'All Files',
'Manage': 'Manage',
// 'System' defined above for navigation
'Follow System': 'System',
'Automation': 'Automation',
'My Shares': 'My Shares',
'Offline Downloads': 'Offline Downloads',
'Adapters': 'Adapters',
'Plugins': 'App Center',
'System Settings': 'System Settings',
'Backup & Restore': 'Backup & Restore',
'System Logs': 'System Logs',
// Top header
'Search files / tags / types': 'Search files / tags / types',
'Log Out': 'Log Out',
'Admin': 'Admin',
'Language': 'Language',
'Chinese': '中文',
'English': 'English',
// Auth / Login
'Welcome Back': 'Welcome Back',
'Sign in to your Foxel account': 'Sign in to your Foxel account',
'Username / Email': 'Username / Email',
'Password': 'Password',
'Sign In': 'Sign In',
'Please enter username and password': 'Please enter username and password',
'Login failed': 'Login failed',
'Your next-generation file manager': 'Your next-generation file manager',
'Cross-platform sync, access anywhere': 'Cross-platform sync, access anywhere',
'AI-powered search for quick find': 'AI-powered search for quick find',
'Flexible sharing and collaboration': 'Flexible sharing and collaboration',
'Powerful automation to simplify tasks': 'Powerful automation to simplify tasks',
'Join our community:': 'Join our community:',
// Share page
'Refresh': 'Refresh',
'Copy': 'Copy',
// 'Cancel' already defined above
'Copied link': 'Link copied',
'Share canceled': 'Share canceled',
'Cancel failed': 'Cancel failed',
'Load failed': 'Load failed',
'Are you sure to cancel share?': 'Are you sure to cancel share?',
'Share Name': 'Share Name',
'Share Content': 'Share Content',
'Created At': 'Created At',
'Expires At': 'Expires At',
'Forever': 'Forever',
'Access': 'Access',
'Public': 'Public',
'By Password': 'By Password',
// Public share page
'Password Required': 'Password Required',
'Please enter password': 'Please enter password',
'Confirm': 'Confirm',
'Unable to load share info': 'Unable to load share info',
'Share load failed': 'Failed to load share',
'Wrong password': 'Wrong password',
'Root': 'All Files',
'Created on {date}': 'Created on {date}',
'Expires on {date}': 'Expires on {date}',
'Download File': 'Download File',
'Preview not supported for this file type': 'Preview not supported for this file type',
'Back': 'Back',
'Download': 'Download',
// Offline download
'No offline download tasks': 'No offline download tasks',
// Header/File Explorer
'Home': 'Home',
'File Manager': 'File Manager',
'New Folder': 'New Folder',
'Upload': 'Upload',
'Name': 'Name',
'Size': 'Size',
'Modified Time': 'Modified Time',
'Grid': 'Grid',
'List': 'List',
'Mount Point': 'Mount Point',
// Context menu
'Upload File': 'Upload File',
'Open': 'Open',
'Open With': 'Open With',
'Default': 'Default',
'Rename': 'Rename',
'Delete': 'Delete',
'Details': 'Details',
'Get Direct Link': 'Get Direct Link',
// Side nav modals
'Join Community': 'Join Community',
'Scan to join WeChat group': 'Scan to join WeChat group',
'If QR expires, add drizzle2001 to join': 'If QR expires, add drizzle2001 to join',
'Version Info': 'Version Info',
'Current Version': 'Current Version',
'Latest Version': 'Latest Version',
'New version found: {version}': 'New version found: {version}',
'Please update to the latest for features and fixes': 'Please update to the latest for features and fixes',
'Open Releases': 'Open Releases',
'Changelog': 'Changelog',
'Fetching latest version...': 'Fetching latest version...',
'Update available': 'Update available',
'You are on the latest: {version}': 'You are on the latest: {version}',
'Up to date': 'Up to date',
// Share modal
'Share {count} items': 'Share {count} items',
'Share link created': 'Share link created',
'Create failed': 'Create failed',
'Copied to clipboard': 'Copied to clipboard',
'Expiration (days)': 'Expiration (days)',
'Set 0 or negative for forever': 'Set 0 or negative for forever',
'Share link created successfully!': 'Share link created successfully!',
'Share Link': 'Share Link',
'Share created': 'Share created',
'Create Share': 'Create Share',
'Done': 'Done',
'Create': 'Create',
// Direct link modal
'Failed to generate link': 'Failed to generate link',
'Markdown copied to clipboard': 'Markdown copied to clipboard',
'Generate a direct link for {name}': 'Generate a direct link for {name}',
'1 hour': '1 hour',
'1 day': '1 day',
'7 days': '7 days',
'Generating link...': 'Generating link...',
'Link will appear here': 'Link will appear here',
'Copy Markdown': 'Copy Markdown',
'Close': 'Close',
// File detail
'Camera Make': 'Camera Make',
'Camera Model': 'Camera Model',
'Capture Time': 'Capture Time',
'X Resolution': 'X Resolution',
'Y Resolution': 'Y Resolution',
'Exposure Time': 'Exposure Time',
'Aperture': 'Aperture',
'Focal Length': 'Focal Length',
'Width': 'Width',
'Height': 'Height',
'No common EXIF info': 'No common EXIF info',
'Bytes': 'Bytes',
'File Properties': 'File Properties',
'Loading file info...': 'Loading file info...',
'Basic Info': 'Basic Info',
'Type': 'Type',
'Folder': 'Folder',
'File': 'File',
'Path': 'Path',
'Path copied to clipboard': 'Path copied to clipboard',
'Copy failed': 'Copy failed',
'Permissions': 'Permissions',
'EXIF Info': 'EXIF Info',
// Search dialog
'Smart Search': 'Smart Search',
'Name Search': 'Name Search',
'Search Results': 'Search Results',
'No files found': 'No files found',
'Relevance': 'Relevance',
// System settings
'Saved successfully': 'Saved successfully',
'Save failed': 'Save failed',
'Loading...': 'Loading...',
'Appearance Settings': 'Appearance Settings',
'Theme': 'Theme',
'Theme Mode': 'Theme Mode',
'Light': 'Light',
'Dark': 'Dark',
// 'Follow System' used for theme mode
'Primary Color': 'Primary Color',
'Border Radius': 'Border Radius',
'Advanced': 'Advanced',
'Override AntD Tokens (JSON)': 'Override AntD Tokens (JSON)',
'e.g. {"colorText": "#222"}': 'e.g. {"colorText": "#222"}',
'Custom CSS': 'Custom CSS',
'Save': 'Save',
'App Settings': 'App Settings',
'AI Settings': 'AI Settings',
'Vision Model': 'Vision Model',
'Embedding Model': 'Embedding Model',
'Vector Database': 'Vector Database',
'Vector Database Settings': 'Vector Database Settings',
'Database Type': 'Database Type',
'Confirm clear vector database?': 'Confirm clear vector database?',
'This will delete all collections irreversibly.': 'This will delete all collections irreversibly.',
'Confirm Clear': 'Confirm Clear',
// 'Cancel' defined above
'Vector database cleared': 'Vector database cleared',
'Clear failed': 'Clear failed',
'Clear Vector DB': 'Clear Vector DB',
'App Name': 'App Name',
'Logo URL': 'Logo URL',
'App Domain': 'App Domain',
'File Domain': 'File Domain',
'Vision API URL': 'Vision API URL',
'Vision API Key': 'Vision API Key',
'Embedding API URL': 'Embedding API URL',
'Embedding API Key': 'Embedding API Key',
// Adapters
'Missing required config:': 'Missing required config:',
'Updated successfully': 'Updated successfully',
'Created successfully': 'Created successfully',
'Operation failed': 'Operation failed',
'Deleted': 'Deleted',
'Delete failed': 'Delete failed',
'Status updated': 'Status updated',
'Update failed': 'Update failed',
'Mount Path': 'Mount Path',
'Sub Path': 'Sub Path',
'Sub Path (optional)': 'Sub Path (optional)',
'Sub directory inside adapter': 'Sub directory inside adapter',
'Enabled': 'Enabled',
'Actions': 'Actions',
'Edit': 'Edit',
'Confirm delete?': 'Confirm delete?',
'No config fields': 'No config fields',
'Please input {label}': 'Please input {label}',
'Storage Adapters': 'Storage Adapters',
'Create Adapter': 'Create Adapter',
'Unique name': 'Unique name',
'Select adapter type': 'Select adapter type',
'/ or /drive': '/ or /drive',
'Adapter Config': 'Adapter Config',
// Tasks
'Automation Tasks': 'Automation Tasks',
'Running Tasks': 'Running Tasks',
'Create Task': 'Create Task',
'Edit Task': 'Edit Task',
'Create Automation Task': 'Create Automation Task',
'Task Name': 'Task Name',
'Trigger Event': 'Trigger Event',
'File Written': 'File Written',
'File Deleted': 'File Deleted',
'Matching Rules': 'Matching Rules',
'Path Prefix (optional)': 'Path Prefix (optional)',
'Filename Regex (optional)': 'Filename Regex (optional)',
'Action': 'Action',
'Current Task Queue': 'Current Task Queue',
'Params': 'Params',
'Status': 'Status',
// Logs
'Confirm clear logs?': 'Confirm clear logs?',
'This will delete logs in selected range irreversibly.': 'This will delete logs in selected range irreversibly.',
'Cleared {count} logs': 'Cleared {count} logs',
'Time': 'Time',
'Level': 'Level',
'Source': 'Source',
'Message': 'Message',
'Search source': 'Search source',
'Clear': 'Clear',
'Log Details': 'Log Details',
// Backup
'Export started, check your downloads.': 'Export started, check your downloads.',
'Export failed': 'Export failed',
'Confirm import backup?': 'Confirm import backup?',
'Are you sure to import from this file?': 'Are you sure to import from this file?',
'Warning: This will overwrite all data including users (with passwords), settings, storages and tasks. Irreversible!': 'Warning: This will overwrite all data including users (with passwords), settings, storages and tasks. Irreversible!',
'Confirm Import': 'Confirm Import',
'Import succeeded! The page will refresh.': 'Import succeeded! The page will refresh.',
'Import failed': 'Import failed',
'Export': 'Export',
'Import': 'Import',
'Export all data (adapters, users, tasks, shares) into a JSON file.': 'Export all data (adapters, users, tasks, shares) into a JSON file.',
'Keep your backup file safe.': 'Keep your backup file safe.',
'Export Backup': 'Export Backup',
'Restore data from a previously exported JSON file.': 'Restore data from a previously exported JSON file.',
'Warning: This will clear and overwrite existing data.': 'Warning: This will clear and overwrite existing data.',
'Choose File and Restore': 'Choose File and Restore',
// Empty state
'No files yet here': 'No files yet here',
'This folder is empty': 'This folder is empty',
'Start uploading files or create folders to organize your content': 'Start uploading files or create folders to organize your content',
'You can create folders or upload files here': 'You can create folders or upload files here',
// File actions
'Please input name': 'Please input name',
'Confirm delete {name}?': 'Confirm delete {name}?',
'items': 'items',
'Downloading folders is not supported': 'Downloading folders is not supported',
'Download failed': 'Download failed',
'Please select files or folders to share': 'Please select files or folders to share',
'Direct links for folders are not supported': 'Direct links for folders are not supported',
// Processor flow
'Processing finished': 'Processing finished',
'Processing failed': 'Processing failed',
// Plugins page
'Installed successfully': 'Installed successfully',
'Plugin': 'Plugin',
'Open Link': 'Open Link',
'Link copied': 'Link copied',
'Copy Link': 'Copy Link',
'Confirm delete this plugin?': 'Confirm delete this plugin?',
'Author': 'Author',
'Website': 'Website',
'Install App': 'Install App',
'Search name/author/url/extension': 'Search name/author/url/extension',
'No plugins': 'No plugins',
'Install': 'Install',
'App URL': 'App URL',
'Please input a valid URL': 'Please input a valid URL',
// Setup page
'Initialization succeeded! Logging you in...': 'Initialization succeeded! Logging you in...',
'Initialization failed, please try later': 'Initialization failed, please try later',
'Database Setup': 'Database Setup',
'Choose database driver': 'Choose database driver',
'Select database and vector database for system data': 'Select database and vector database for system data',
'Database Driver': 'Database Driver',
'Vector DB Driver': 'Vector DB Driver',
'Initialize Mount': 'Initialize Mount',
'Configure initial storage': 'Configure initial storage',
'Create the first storage mount for your files': 'Create the first storage mount for your files',
'Mount Name': 'Mount Name',
'Local Storage': 'Local Storage',
'Please input mount name!': 'Please input mount name!',
'Storage Type': 'Storage Type',
'Please input mount path!': 'Please input mount path!',
'Root Directory': 'Root Directory',
'Please input root directory!': 'Please input root directory!',
'e.g., data/ or /var/foxel/data': 'e.g., data/ or /var/foxel/data',
'Create Admin': 'Create Admin',
'Create admin account': 'Create admin account',
'This is the first account with full permissions': 'This is the first account with full permissions',
'Username': 'Username',
'Full Name': 'Full Name',
'Email': 'Email',
'Please input a valid email!': 'Please input a valid email!',
'Confirm Password': 'Confirm Password',
'Please confirm your password!': 'Please confirm your password!',
'Passwords do not match!': 'Passwords do not match!',
'System Initialization': 'System Initialization',
'Previous': 'Previous',
'Next': 'Next',
'Finish Initialization': 'Finish Initialization',
// Plugin host
'Plugin run failed': 'Plugin run failed',
'Plugin Error': 'Plugin Error',
'Cannot open file: no available app': 'Cannot open file: no available app',
'Error': 'Error',
'App "{key}" not found.': 'App "{key}" not found.',
'Open with {app}': 'Open with {app}',
'Set as default for .{ext}': 'Set as default for .{ext}',
'Advanced tokens must be valid JSON': 'Advanced tokens must be valid JSON',
} as const;
export type EnKeys = keyof typeof en;

367
web/src/i18n/locales/zh.ts Normal file
View File

@@ -0,0 +1,367 @@
import { en } from './en';
// Start from English defaults, then override with Chinese translations we have.
export const zh = {
...en,
// General
'All Files': '全部文件',
'Manage': '管理',
'System': '系统',
'Automation': '自动化',
'My Shares': '我的分享',
'Offline Downloads': '离线下载',
'Adapters': '存储挂载',
'Plugins': '应用中心',
'System Settings': '系统设置',
'Backup & Restore': '备份恢复',
'System Logs': '系统日志',
// Top header
'Search files / tags / types': '搜索文件 / 标签 / 类型',
'Log Out': '退出登录',
'Admin': '管理员',
'Language': '语言',
'Chinese': '中文',
'English': '英文',
// Auth / Login
'Welcome Back': '欢迎回来',
'Sign in to your Foxel account': '登录到您的 Foxel 账户',
'Username / Email': '用户名/邮箱',
'Password': '密码',
'Sign In': '登录',
'Please enter username and password': '请输入用户名与密码',
'Login failed': '登录失败',
'Your next-generation file manager': '您的下一代文件管理系统',
'Cross-platform sync, access anywhere': '跨平台同步,随时随地访问',
'AI-powered search for quick find': 'AI 驱动的智能搜索,快速定位文件',
'Flexible sharing and collaboration': '灵活的分享与协作,提升团队效率',
'Powerful automation to simplify tasks': '强大的自动化工作流,简化繁琐任务',
'Join our community:': '加入我们的社区:',
// Share page
'Refresh': '刷新',
'Copy': '复制',
'Cancel': '取消',
'Copied link': '链接已复制',
'Share canceled': '分享已取消',
'Cancel failed': '取消失败',
'Load failed': '加载失败',
'Are you sure to cancel share?': '确认取消分享?',
'Share Name': '分享名称',
'Share Content': '分享内容',
'Created At': '创建时间',
'Expires At': '过期时间',
'Forever': '永久有效',
'Access': '访问',
'Public': '公开',
'By Password': '密码',
// Public share page
'Password Required': '需要密码',
'Please enter password': '请输入密码',
'Confirm': '确认',
'Unable to load share info': '无法加载分享信息',
'Share load failed': '加载分享失败',
'Wrong password': '密码错误',
'Root': '全部文件',
'Created on {date}': '创建于 {date}',
'Expires on {date}': '将于 {date} 过期',
'Download File': '下载文件',
'Preview not supported for this file type': '暂不支持在线预览此类型文件',
'Back': '返回',
'Download': '下载',
// Header/File Explorer
'Home': '主页',
'File Manager': '文件管理',
'New Folder': '新建目录',
'Upload': '上传',
'Name': '名称',
'Size': '大小',
'Modified Time': '修改时间',
'Grid': '网格',
'List': '列表',
'Mount Point': '挂载点',
// Context menu
'Upload File': '上传文件',
'Open': '打开',
'Open With': '打开方式',
'Default': '默认',
'Rename': '重命名',
'Delete': '删除',
'Details': '详情',
'Get Direct Link': '获取直链',
// Side nav modals
'Join Community': '加入社区',
'Scan to join WeChat group': '微信扫码加入交流群',
'If QR expires, add drizzle2001 to join': '如二维码失效,请添加 drizzle2001 拉群',
'Version Info': '版本信息',
'Current Version': '当前版本',
'Latest Version': '最新版本',
'New version found: {version}': '发现新版本: {version}',
'Please update to the latest for features and fixes': '建议尽快更新到最新版本,以获得新功能和安全修复。',
'Open Releases': '前往发布页面',
'Changelog': '更新日志',
'Fetching latest version...': '正在获取最新版本信息...',
'Update available': '有更新',
'You are on the latest: {version}': '当前为最新版: {version}',
'Up to date': '已是最新版',
// Share modal
'Share {count} items': '分享 {count} 个项目',
'Share link created': '分享链接已创建',
'Create failed': '创建失败',
'Copied to clipboard': '已复制到剪贴板',
'Expiration (days)': '有效期 (天)',
'Set 0 or negative for forever': '设置为 0 或负数表示永久有效',
'Share link created successfully!': '分享链接已成功创建!',
'Share Link': '分享链接',
'Share created': '分享创建成功',
'Create Share': '创建分享',
'Done': '完成',
'Create': '创建',
// Direct link modal
'Failed to generate link': '生成链接失败',
'Markdown copied to clipboard': 'Markdown 格式已复制到剪贴板',
'Generate a direct link for {name}': '为 {name} 生成一个直接访问链接。',
'1 hour': '1 小时',
'1 day': '1 天',
'7 days': '7 天',
'Generating link...': '正在生成链接...',
'Link will appear here': '链接将显示在这里',
'Copy Markdown': '复制 Markdown',
'Close': '关闭',
// File detail
'Camera Make': '设备品牌',
'Camera Model': '设备型号',
'Capture Time': '拍摄时间',
'X Resolution': '水平分辨率',
'Y Resolution': '垂直分辨率',
'Exposure Time': '曝光时间',
'Aperture': '光圈值',
'Focal Length': '焦距',
'Width': '宽度',
'Height': '高度',
'No common EXIF info': '无常见EXIF信息',
'Bytes': '字节',
'File Properties': '文件属性',
'Loading file info...': '加载文件信息...',
'Basic Info': '基本信息',
'Type': '类型',
'Folder': '文件夹',
'File': '文件',
'Path': '路径',
'Path copied to clipboard': '路径已复制到剪贴板',
'Copy failed': '复制失败',
'Permissions': '权限',
'EXIF Info': 'EXIF信息',
// Search dialog
'Smart Search': '智能搜索',
'Name Search': '名称搜索',
'Search Results': '搜索结果',
'No files found': '未找到相关文件',
'Relevance': '相关度',
// System settings
'Saved successfully': '保存成功',
'Save failed': '保存失败',
'Loading...': '加载中...',
'Appearance Settings': '外观设置',
'Theme': '主题',
'Theme Mode': '主题模式',
'Light': '亮色',
'Dark': '暗色',
// 'Follow System' used for theme mode
'Follow System': '跟随系统',
'Primary Color': '主色',
'Border Radius': '圆角',
'Advanced': '高级',
'Override AntD Tokens (JSON)': '覆盖 AntD TokenJSON',
'e.g. {"colorText": "#222"}': '例如:{"colorText": "#222"}',
'Custom CSS': '自定义 CSS',
'Save': '保存',
'App Settings': '应用设置',
'AI Settings': 'AI设置',
'Vision Model': '视觉模型',
'Embedding Model': '嵌入模型',
'Vector Database': '向量数据库',
'Vector Database Settings': '向量数据库设置',
'Database Type': '数据库类型',
'Confirm clear vector database?': '确认清空向量数据库?',
'This will delete all collections irreversibly.': '此操作将删除所有集合中的所有数据,且不可逆。',
'Confirm Clear': '确认清空',
// 'Cancel' defined above
'Vector database cleared': '向量数据库已清空',
'Clear failed': '清空失败',
'Clear Vector DB': '清空向量库',
'App Name': '应用名称',
'Logo URL': 'LOGO地址',
'App Domain': '应用域名',
'File Domain': '文件域名',
'Vision API URL': '视觉模型 API 地址',
'Vision API Key': '视觉模型 API Key',
'Embedding API URL': '嵌入模型 API 地址',
'Embedding API Key': '嵌入模型 API Key',
// Adapters
'Missing required config:': '缺少必填配置:',
'Updated successfully': '更新成功',
'Created successfully': '创建成功',
'Operation failed': '操作失败',
'Deleted': '已删除',
'Delete failed': '删除失败',
'Status updated': '状态已更新',
'Update failed': '更新失败',
'Mount Path': '挂载路径',
'Sub Path': '子路径',
'Sub Path (optional)': '子路径(可选)',
'Sub directory inside adapter': '适配器内部子目录',
'Enabled': '启用',
'Actions': '操作',
'Edit': '编辑',
'Confirm delete?': '确认删除?',
'No config fields': '无配置项',
'Please input {label}': '请输入{label}',
'Storage Adapters': '存储适配器',
'Create Adapter': '新建适配器',
'Unique name': '唯一名称',
'Select adapter type': '选择适配器类型',
'/ or /drive': '/或/drive',
'Adapter Config': '适配器配置',
// Tasks
'Automation Tasks': '自动化任务',
'Running Tasks': '运行中的任务',
'Create Task': '新建任务',
'Edit Task': '编辑任务',
'Create Automation Task': '新建自动化任务',
'Task Name': '任务名称',
'Trigger Event': '触发事件',
'File Written': '文件写入',
'File Deleted': '文件删除',
'Matching Rules': '匹配规则',
'Path Prefix (optional)': '路径前缀 (可选)',
'Filename Regex (optional)': '文件名正则 (可选)',
'Action': '执行动作',
'Current Task Queue': '当前任务队列',
'Params': '参数',
'Status': '状态',
// Logs
'Confirm clear logs?': '确认清理日志?',
'This will delete logs in selected range irreversibly.': '该操作将删除选定时间范围内的所有日志,且不可恢复。',
'Cleared {count} logs': '成功清理 {count} 条日志',
'Time': '时间',
'Level': '级别',
'Source': '来源',
'Message': '消息',
'Search source': '搜索来源',
'Clear': '清理',
'Log Details': '日志详情',
// Backup
'Export started, check your downloads.': '导出已开始,请检查您的下载。',
'Export failed': '导出失败',
'Confirm import backup?': '确认导入备份?',
'Are you sure to import from this file?': '您确定要从此文件导入数据吗?',
'Warning: This will overwrite all data including users (with passwords), settings, storages and tasks. Irreversible!': '警告:此操作将覆盖当前数据库中的所有现有数据,包括用户(含密码)、设置、存储和任务。此操作不可逆!',
'Confirm Import': '确认导入',
'Import succeeded! The page will refresh.': '导入成功!页面将刷新。',
'Import failed': '导入失败',
'Export': '导出',
'Import': '恢复',
'Export all data (adapters, users, tasks, shares) into a JSON file.': '点击按钮将所有数据(包括存储、用户、自动化任务和分享)导出为一个 JSON 文件。',
'Keep your backup file safe.': '请妥善保管您的备份文件。',
'Export Backup': '导出备份',
'Restore data from a previously exported JSON file.': '从之前导出的JSON文件恢复数据。',
'Warning: This will clear and overwrite existing data.': '警告:此操作将清除并覆盖现有数据。',
'Choose File and Restore': '选择文件并恢复',
// Empty state
'No files yet here': '这里还没有任何文件',
'This folder is empty': '此目录为空',
'Start uploading files or create folders to organize your content': '开始上传文件或创建新目录来组织您的内容',
'You can create folders or upload files here': '您可以在此目录中创建新的文件夹或上传文件',
// File actions
'Please input name': '请输入名称',
'Confirm delete {name}?': '确认删除 {name} ?',
'items': '项',
'Downloading folders is not supported': '暂不支持下载目录',
'Download failed': '下载失败',
'Please select files or folders to share': '请选择要分享的文件或目录',
'Direct links for folders are not supported': '不支持获取目录的直链',
// Processor flow
'Processing finished': '处理完成',
'Processing failed': '处理失败',
// Plugins page
'Installed successfully': '安装成功',
'Plugin': '插件',
'Open Link': '打开链接',
'Link copied': '已复制链接',
'Copy Link': '复制链接',
'Confirm delete this plugin?': '确认删除该插件?',
'Author': '作者',
'Website': '官网',
'Install App': '安装应用',
'Search name/author/url/extension': '搜索 名称/作者/链接/扩展名',
'No plugins': '暂无插件',
'Install': '安装',
'App URL': '应用链接',
'Please input a valid URL': '请输入合法的 URL',
// Setup page
'Initialization succeeded! Logging you in...': '初始化成功!正在为您登录,请不要刷新。',
'Initialization failed, please try later': '初始化失败,请稍后重试',
'Database Setup': '数据库设置',
'Choose database driver': '选择数据库驱动',
'Select database and vector database for system data': '选择用于存储系统数据的数据库和向量数据库。',
'Database Driver': '数据库驱动',
'Vector DB Driver': '向量数据库驱动',
'Initialize Mount': '初始化挂载',
'Configure initial storage': '配置初始存储',
'Create the first storage mount for your files': '为您的文件创建第一个存储挂载点。',
'Mount Name': '挂载名称',
'Local Storage': '本地存储',
'Please input mount name!': '请输入挂载名称!',
'Storage Type': '存储类型',
'Please input mount path!': '请输入挂载路径!',
'Root Directory': '根目录',
'Please input root directory!': '请输入根目录!',
'e.g., data/ or /var/foxel/data': '例如: data/ 或 /var/foxel/data',
'Create Admin': '创建管理员',
'Create admin account': '创建管理员账户',
'This is the first account with full permissions': '这是系统的第一个账户,将拥有最高权限。',
'Username': '用户名',
'Full Name': '昵称',
'Email': '邮箱',
'Please input a valid email!': '请输入有效的邮箱地址!',
'Confirm Password': '确认密码',
'Please confirm your password!': '请确认您的密码!',
'Passwords do not match!': '两次输入的密码不一致!',
'System Initialization': '系统初始化',
'Previous': '上一步',
'Next': '下一步',
'Finish Initialization': '完成初始化',
// Plugin host
'Plugin run failed': '插件运行失败',
'Plugin Error': '插件错误',
'Cannot open file: no available app': '无法打开该文件:没有可用的应用',
'Error': '错误',
'App "{key}" not found.': '应用 "{key}" 不存在。',
'Open with {app}': '使用 {app} 打开',
'Set as default for .{ext}': '设为该类型(.{ext})默认应用',
'Advanced tokens must be valid JSON': '高级 Token 需为合法 JSON',
} as const;
export type ZhKeys = keyof typeof zh;

View File

@@ -2,6 +2,7 @@ import { Modal, Input, List, Divider, Spin, Select, Space } from 'antd';
import { SearchOutlined, FileTextOutlined } from '@ant-design/icons';
import React, { useState } from 'react';
import { vfsApi, type SearchResultItem } from '../api/vfs';
import { useI18n } from '../i18n';
import { useNavigate } from 'react-router';
@@ -10,9 +11,9 @@ interface SearchDialogProps {
onClose: () => void;
}
const SEARCH_MODES = [
{ label: '智能搜索', value: 'vector' },
{ label: '名称搜索', value: 'filename' },
const SEARCH_MODES = (t: (k: string)=>string) => [
{ label: t('Smart Search'), value: 'vector' },
{ label: t('Name Search'), value: 'filename' },
];
const SearchDialog: React.FC<SearchDialogProps> = ({ open, onClose }) => {
@@ -21,6 +22,7 @@ const SearchDialog: React.FC<SearchDialogProps> = ({ open, onClose }) => {
const [results, setResults] = useState<SearchResultItem[]>([]);
const [searched, setSearched] = useState(false);
const [searchMode, setSearchMode] = useState<'vector' | 'filename'>('vector');
const { t } = useI18n();
const navigate = useNavigate();
const handleSearch = async () => {
@@ -48,7 +50,7 @@ const SearchDialog: React.FC<SearchDialogProps> = ({ open, onClose }) => {
>
<Space.Compact style={{ marginBottom: 0, width: '100%' }}>
<Select
options={SEARCH_MODES}
options={SEARCH_MODES(t)}
value={searchMode}
onChange={v => setSearchMode(v as 'vector' | 'filename')}
style={{
@@ -67,7 +69,7 @@ const SearchDialog: React.FC<SearchDialogProps> = ({ open, onClose }) => {
<Input
allowClear
prefix={<SearchOutlined />}
placeholder="搜索文件 / 标签 / 类型"
placeholder={t('Search files / tags / types')}
value={search}
onChange={e => setSearch(e.target.value)}
style={{
@@ -84,14 +86,14 @@ const SearchDialog: React.FC<SearchDialogProps> = ({ open, onClose }) => {
</Space.Compact>
{searched && (
<>
<Divider style={{ margin: '12px 0' }}></Divider>
<Divider style={{ margin: '12px 0' }}>{t('Search Results')}</Divider>
{loading ? (
<Spin />
) : (
<List
itemLayout="horizontal"
dataSource={results}
locale={{ emptyText: '未找到相关文件' }}
locale={{ emptyText: t('No files found') }}
renderItem={item => {
const fullPath = item.path || '';
const trimmed = fullPath.replace(/\/+$/, '');
@@ -112,7 +114,7 @@ const SearchDialog: React.FC<SearchDialogProps> = ({ open, onClose }) => {
{fullPath}
</a>
}
description={`相关度: ${item.score.toFixed(2)}`}
description={`${t('Relevance')}: ${item.score.toFixed(2)}`}
/>
</List.Item>
);

View File

@@ -1,10 +1,11 @@
import { Layout, Menu, theme, Button, Modal, Tag, Tooltip } from 'antd';
import { Layout, Menu, theme, Button, Modal, Tag, Tooltip, Descriptions, Alert, Divider, Spin } from 'antd';
import { navGroups } from './nav.ts';
import type { NavItem, NavGroup } from './nav.ts';
import { memo, useEffect, useState } from 'react';
import { useSystemStatus } from '../contexts/SystemContext.tsx';
import {
CheckCircleOutlined,
FileTextOutlined,
GithubOutlined,
MenuFoldOutlined,
SendOutlined,
@@ -14,6 +15,8 @@ import {
import '../styles/sider-menu.css';
import { getLatestVersion } from '../api/config.ts';
import ReactMarkdown from 'react-markdown';
import { useTheme } from '../contexts/ThemeContext';
import { useI18n } from '../i18n';
const { Sider } = Layout;
export interface SideNavProps {
@@ -26,6 +29,8 @@ export interface SideNavProps {
const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle }: SideNavProps) {
const status = useSystemStatus();
const { token } = theme.useToken();
const { resolvedMode } = useTheme();
const { t } = useI18n();
const [isModalOpen, setIsModalOpen] = useState(false);
const [isVersionModalOpen, setIsVersionModalOpen] = useState(false);
const [latestVersion, setLatestVersion] = useState<{
@@ -84,10 +89,16 @@ const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle
height: 24,
objectFit: 'contain',
marginRight: collapsed ? 0 : 8,
...(status?.logo?.endsWith('.svg') && { filter: 'brightness(0) saturate(100%)' })
...(resolvedMode === 'dark'
? { filter: 'brightness(0) invert(1)' }
: (status?.logo?.endsWith('.svg') ? { filter: 'brightness(0) saturate(100%)' } : {}))
}}
/>
{!collapsed && <span style={{ fontWeight: 700 }}>{status?.title}</span>}
{!collapsed && (
<span style={{ fontWeight: 700, color: resolvedMode === 'dark' ? '#fff' : token.colorText }}>
{status?.title}
</span>
)}
</div>
{/* 展开时显示收缩按钮 */}
{!collapsed && (
@@ -113,7 +124,7 @@ const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle
color: token.colorTextTertiary,
textTransform: 'uppercase'
}}
>{group.title}</div>
>{t(group.title)}</div>
)}
<Menu
mode="inline"
@@ -121,7 +132,7 @@ const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle
inlineIndent={12}
selectedKeys={[activeKey]}
onClick={(e) => onChange(e.key)}
items={group.children.map((i: NavItem) => ({ key: i.key, icon: i.icon, label: i.label }))}
items={group.children.map((i: NavItem) => ({ key: i.key, icon: i.icon, label: t(i.label) }))}
style={{ borderInline: 'none', background: 'transparent' }}
className="sider-menu-group foxel-sider-menu"
/>
@@ -153,26 +164,26 @@ const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle
cursor: 'pointer'
}} 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' }}>
<Tooltip title={t('New version found: {version}', { version: latestVersion?.version || '' })} placement={collapsed ? 'right' : 'top'}>
<a rel="noopener noreferrer"
style={{ textDecoration: 'none' }}>
{collapsed ? (
<Tag icon={<WarningOutlined />} color="warning" style={{ marginInlineEnd: 0 }} />
) : (
<Tag icon={<WarningOutlined />} color="warning">
{status?.version} - [{latestVersion?.version}]
{status?.version} - {t('Update available')} [{latestVersion?.version}]
</Tag>
)}
</a>
</Tooltip>
) : (
latestVersion ? (
<Tooltip title={`当前为最新版: ${status?.version}`} placement={collapsed ? 'right' : 'top'}>
<Tooltip title={t('You are on the latest: {version}', { version: status?.version || '' })} placement={collapsed ? 'right' : 'top'}>
{collapsed ? (
<Tag icon={<CheckCircleOutlined />} color="success" style={{ marginInlineEnd: 0 }} />
) : (
<Tag icon={<CheckCircleOutlined />} color="success">
{t('Up to date')}
</Tag>
)}
</Tooltip>
@@ -199,39 +210,104 @@ 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
open={isModalOpen}
onCancel={() => setIsModalOpen(false)}
title="加入社区"
title={t('Join Community')}
footer={null}
width={320}
>
<div style={{ textAlign: 'center', padding: '12px 0' }}>
<img src="https://foxel.cc/image/wechat.png" width={200} alt="wechat" />
<div style={{ marginTop: 12, color: token.colorTextSecondary }}>
{t('Scan to join WeChat group')}
</div>
<div style={{ marginTop: 8, fontSize: 12, color: token.colorTextTertiary }}>
drizzle2001
{t('If QR expires, add drizzle2001 to join')}
</div>
</div>
</Modal>
<Modal
open={isVersionModalOpen}
onCancel={() => setIsVersionModalOpen(false)}
title="版本信息"
title={t('Version Info')}
footer={null}
width={600}
>
<div>
<p>: {status?.version}</p>
{latestVersion && (
<div>
<p>: {latestVersion.version}</p>
<ReactMarkdown>{latestVersion.body}</ReactMarkdown>
<div style={{ paddingTop: 12 }}>
{latestVersion ? (
<>
<Descriptions bordered column={1} size="small">
<Descriptions.Item label={t('Current Version')}>
<Tag>{status?.version}</Tag>
</Descriptions.Item>
<Descriptions.Item label={t('Latest Version')}>
<Tag color={hasUpdate ? 'orange' : 'green'}>{latestVersion.version}</Tag>
</Descriptions.Item>
</Descriptions>
{hasUpdate && (
<Alert
message={<span style={{ color: token.colorText }}>{t('New version found: {version}', { version: latestVersion.version })}</span>}
description={<span style={{ color: token.colorTextSecondary }}>{t('Please update to the latest for features and fixes')}</span>}
type="info"
showIcon
style={{ marginTop: 24, marginBottom: 24, background: token.colorInfoBg, borderColor: token.colorInfoBorder }}
action={
<Button
size="small"
type="primary"
href="https://github.com/DrizzleTime/Foxel/releases"
target="_blank"
icon={<GithubOutlined />}
>
{t('Open Releases')}
</Button>
}
/>
)}
<Divider orientation="left" plain>{t('Changelog')}</Divider>
<div style={{
maxHeight: '40vh',
overflowY: 'auto',
padding: '8px 16px',
background: token.colorFillAlter,
borderRadius: token.borderRadiusLG,
border: `1px solid ${token.colorBorderSecondary}`
}}>
<ReactMarkdown
components={{
h3: ({ ...props }) => <h3 style={{
fontSize: 16,
borderBottom: `1px solid ${token.colorBorderSecondary}`,
paddingBottom: 8,
marginTop: 24,
marginBottom: 16,
color: token.colorTextHeading
}} {...props} />,
ul: ({ ...props }) => <ul style={{ paddingLeft: 20 }} {...props} />,
li: ({ ...props }) => <li style={{ marginBottom: 8 }} {...props} />,
p: ({ ...props }) => <p style={{ marginBottom: 8 }} {...props} />,
a: ({ ...props }) => <a {...props} target="_blank" rel="noopener noreferrer" />
}}
>{latestVersion.body}</ReactMarkdown>
</div>
</>
) : (
<div style={{ textAlign: 'center', padding: '40px 0', color: token.colorTextSecondary }}>
<Spin size="large" />
<p style={{ marginTop: 16 }}>{t('Fetching latest version...')}</p>
</div>
)}
</div>

View File

@@ -4,6 +4,8 @@ import { memo, useState } from 'react';
import SearchDialog from './SearchDialog.tsx';
import { authApi } from '../api/auth.ts';
import { useNavigate } from 'react-router';
import { useI18n } from '../i18n';
import LanguageSwitcher from '../components/LanguageSwitcher';
const { Header } = Layout;
@@ -16,6 +18,7 @@ const TopHeader = memo(function TopHeader({ collapsed, onToggle }: TopHeaderProp
const { token } = theme.useToken();
const [searchOpen, setSearchOpen] = useState(false);
const navigate = useNavigate();
const { t } = useI18n();
const handleLogout = () => {
authApi.logout();
@@ -37,18 +40,19 @@ const TopHeader = memo(function TopHeader({ collapsed, onToggle }: TopHeaderProp
style={{ maxWidth: 420 }}
onClick={() => setSearchOpen(true)}
>
/ /
{t('Search files / tags / types')}
</Button>
<SearchDialog open={searchOpen} onClose={() => setSearchOpen(false)} />
<Flex style={{ marginLeft: 'auto' }} align="center" gap={12}>
<LanguageSwitcher />
<Dropdown
menu={{
items: [
{ key: 'logout', label: '退出登录', icon: <LogoutOutlined />, onClick: handleLogout }
{ key: 'logout', label: t('Log Out'), icon: <LogoutOutlined />, onClick: handleLogout }
]
}}
>
<Button icon={<UserOutlined />}></Button>
<Button icon={<UserOutlined />}>{t('Admin')}</Button>
</Dropdown>
</Flex>
</Header>

View File

@@ -8,6 +8,7 @@ import {
RobotOutlined,
BugOutlined,
DatabaseOutlined,
AppstoreOutlined,
} from '@ant-design/icons';
import type { ReactNode } from 'react';
@@ -19,26 +20,27 @@ export const navGroups: NavGroup[] = [
key: 'library',
title: '',
children: [
{ key: 'files', icon: React.createElement(FolderOpenOutlined), label: '全部文件' },
{ key: 'files', icon: React.createElement(FolderOpenOutlined), label: 'All Files' },
]
},
{
key: 'manage',
title: '管理',
title: 'Manage',
children: [
{ key: 'tasks', icon: React.createElement(RobotOutlined), label: '自动化' },
{ key: 'share', icon: React.createElement(ShareAltOutlined), label: '我的分享' },
{ key: 'offline', icon: React.createElement(CloudDownloadOutlined), label: '离线下载' },
{ key: 'adapters', icon: React.createElement(ApiOutlined), label: '存储挂载' },
{ key: 'tasks', icon: React.createElement(RobotOutlined), label: 'Automation' },
{ key: 'share', icon: React.createElement(ShareAltOutlined), label: 'My Shares' },
{ key: 'offline', icon: React.createElement(CloudDownloadOutlined), label: 'Offline Downloads' },
{ key: 'adapters', icon: React.createElement(ApiOutlined), label: 'Adapters' },
{ key: 'plugins', icon: React.createElement(AppstoreOutlined), label: 'Plugins' },
]
},
{
key: 'system',
title: '系统',
title: 'System',
children: [
{ key: 'settings', icon: React.createElement(SettingOutlined), label: '系统设置' },
{ key: 'backup', icon: React.createElement(DatabaseOutlined), label: '备份恢复' },
{ key: 'logs', icon: React.createElement(BugOutlined), label: '系统日志' }
{ key: 'settings', icon: React.createElement(SettingOutlined), label: 'System Settings' },
{ key: 'backup', icon: React.createElement(DatabaseOutlined), label: 'Backup & Restore' },
{ key: 'logs', icon: React.createElement(BugOutlined), label: 'System Logs' }
]
}
];

View File

@@ -1,17 +1,12 @@
import { createRoot } from 'react-dom/client';
import App from './App.tsx';
import '@ant-design/v5-patch-for-react-19';
import { ConfigProvider } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import 'antd/dist/reset.css';
import foxelTheme from './theme';
import './global.css';
import { BrowserRouter } from 'react-router';
createRoot(document.getElementById('root')!).render(
<ConfigProvider locale={zhCN} theme={foxelTheme}>
<BrowserRouter>
<App />
</BrowserRouter>
</ConfigProvider>
<BrowserRouter>
<App />
</BrowserRouter>
);

View File

@@ -1,17 +1,9 @@
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';
import { useI18n } from '../i18n';
interface AdapterItem {
id: number;
name: string;
type: string;
config: any;
enabled: boolean;
mount_path?: string | null;
sub_path?: string | null;
}
interface AdapterTypeField {
key: string;
@@ -34,6 +26,7 @@ const AdaptersPage = memo(function AdaptersPage() {
const [editing, setEditing] = useState<AdapterItem | null>(null);
const [form] = Form.useForm();
const [availableTypes, setAvailableTypes] = useState<AdapterTypeMeta[]>([]);
const { t } = useI18n();
const fetchList = useCallback(async () => {
setLoading(true);
@@ -45,7 +38,7 @@ const AdaptersPage = memo(function AdaptersPage() {
setData(list);
setAvailableTypes(types);
} catch (e: any) {
message.error(e.message || '加载失败');
message.error(e.message || t('Load failed'));
} finally {
setLoading(false);
}
@@ -65,7 +58,7 @@ const AdaptersPage = memo(function AdaptersPage() {
form.setFieldsValue({
name: '',
type: defaultType,
mount_path: '/',
path: '/',
sub_path: '',
enabled: true,
config: cfgDefaults
@@ -79,7 +72,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 || {}
@@ -99,13 +92,13 @@ const AdaptersPage = memo(function AdaptersPage() {
}
});
if (miss.length) {
message.error('缺少必填配置: ' + miss.join(', '));
message.error(t('Missing required config:') + ' ' + miss.join(', '));
return;
}
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
@@ -113,17 +106,17 @@ const AdaptersPage = memo(function AdaptersPage() {
setLoading(true);
if (editing) {
await adaptersApi.update(editing.id, body as any);
message.success('更新成功');
message.success(t('Updated successfully'));
} else {
await adaptersApi.create(body as any);
message.success('创建成功');
message.success(t('Created successfully'));
}
setOpen(false);
setEditing(null);
fetchList();
} catch (e: any) {
if (e?.errorFields) return; // 表单校验
message.error(e.message || '操作失败');
message.error(e.message || t('Operation failed'));
} finally {
setLoading(false);
}
@@ -132,10 +125,10 @@ const AdaptersPage = memo(function AdaptersPage() {
const doDelete = async (rec: AdapterItem) => {
try {
await adaptersApi.remove(rec.id);
message.success('已删除');
message.success(t('Deleted'));
fetchList();
} catch (e: any) {
message.error(e.message || '删除失败');
message.error(e.message || t('Delete failed'));
}
};
@@ -143,22 +136,22 @@ const AdaptersPage = memo(function AdaptersPage() {
try {
setLoading(true);
await adaptersApi.update(rec.id, { ...rec, enabled: checked });
message.success('状态已更新');
message.success(t('Status updated'));
fetchList();
} catch (e: any) {
message.error(e.message || '更新失败');
message.error(e.message || t('Update failed'));
} finally {
setLoading(false);
}
};
const columns = [
{ title: '名称', dataIndex: 'name' },
{ title: '类型', dataIndex: 'type', width: 100 },
{ title: '挂载路径', dataIndex: 'mount_path', width: 140, render: (v: string) => v || '-' },
{ title: '子路径', dataIndex: 'sub_path', width: 140, render: (v: string) => v || '-' },
{ title: t('Name'), dataIndex: 'name' },
{ title: t('Type'), dataIndex: 'type', width: 100 },
{ title: t('Mount Path'), dataIndex: 'path', width: 140, render: (v: string) => v || '-' },
{ title: t('Sub Path'), dataIndex: 'sub_path', width: 140, render: (v: string) => v || '-' },
{
title: '启用',
title: t('Enabled'),
dataIndex: 'enabled',
width: 80,
render: (v: boolean, rec: AdapterItem) => (
@@ -171,13 +164,13 @@ const AdaptersPage = memo(function AdaptersPage() {
)
},
{
title: '操作',
title: t('Actions'),
width: 160,
render: (_: any, rec: AdapterItem) => (
<Space size="small">
<Button size="small" onClick={() => openEdit(rec)}></Button>
<Popconfirm title="确认删除?" onConfirm={() => doDelete(rec)}>
<Button size="small" danger></Button>
<Button size="small" onClick={() => openEdit(rec)}>{t('Edit')}</Button>
<Popconfirm title={t('Confirm delete?')} onConfirm={() => doDelete(rec)}>
<Button size="small" danger>{t('Delete')}</Button>
</Popconfirm>
</Space>
)
@@ -188,9 +181,9 @@ const AdaptersPage = memo(function AdaptersPage() {
const currentTypeMeta = availableTypes.find(t => t.type === selectedType);
function renderConfigFields() {
if (!currentTypeMeta) return <Typography.Text type="secondary"></Typography.Text>;
if (!currentTypeMeta) return <Typography.Text type="secondary">{t('No config fields')}</Typography.Text>;
return currentTypeMeta.config_schema.map(field => {
const rules = field.required ? [{ required: true, message: `请输入${field.label}` }] : [];
const rules = field.required ? [{ required: true, message: t('Please input {label}', { label: field.label }) }] : [];
let inputNode: any = <Input placeholder={field.placeholder} />;
if (field.type === 'password') inputNode = <Input.Password placeholder={field.placeholder} />;
if (field.type === 'number') inputNode = <Input type="number" placeholder={field.placeholder} />;
@@ -198,7 +191,7 @@ const AdaptersPage = memo(function AdaptersPage() {
<Form.Item
key={field.key}
name={['config', field.key]}
label={field.label}
label={t(field.label)}
rules={rules}
>
{inputNode}
@@ -209,11 +202,11 @@ const AdaptersPage = memo(function AdaptersPage() {
return (
<PageCard
title="存储适配器"
title={t('Storage Adapters')}
extra={
<Space>
<Button onClick={fetchList} loading={loading}></Button>
<Button type="primary" onClick={openCreate}></Button>
<Button onClick={fetchList} loading={loading}>{t('Refresh')}</Button>
<Button type="primary" onClick={openCreate}>{t('Create Adapter')}</Button>
</Space>
}
>
@@ -226,15 +219,15 @@ const AdaptersPage = memo(function AdaptersPage() {
style={{ marginBottom: 0 }}
/>
<Drawer
title={editing ? `编辑: ${editing.name}` : '新建适配器'}
title={editing ? `${t('Edit')}: ${editing.name}` : t('Create Adapter')}
width={480}
open={open}
onClose={() => { setOpen(false); setEditing(null); }}
destroyOnClose
extra={
<Space>
<Button onClick={() => { setOpen(false); setEditing(null); }}></Button>
<Button type="primary" onClick={submit} loading={loading}></Button>
<Button onClick={() => { setOpen(false); setEditing(null); }}>{t('Cancel')}</Button>
<Button type="primary" onClick={submit} loading={loading}>{t('Submit')}</Button>
</Space>
}
>
@@ -243,15 +236,14 @@ const AdaptersPage = memo(function AdaptersPage() {
layout="vertical"
initialValues={{ enabled: true }}
>
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入名称' }]}>
<Input placeholder="唯一名称" />
<Form.Item name="name" label={t('Name')} rules={[{ required: true, message: t('Please input {label}', { label: t('Name') }) }]}>
<Input placeholder={t('Unique name')} />
</Form.Item>
<Form.Item name="type" label="类型" rules={[{ required: true }]}>
<Form.Item name="type" label={t('Type')} rules={[{ required: true }]}>
<Select
placeholder="选择适配器类型"
placeholder={t('Select adapter type')}
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,16 +253,16 @@ const AdaptersPage = memo(function AdaptersPage() {
}}
/>
</Form.Item>
<Form.Item name="mount_path" label="挂载路径" rules={[{ required: true, message: '请输入挂载路径' }]}>
<Input placeholder="/或/drive" />
<Form.Item name="path" label={t('Mount Path')} rules={[{ required: true, message: t('Please input {label}', { label: t('Mount Path') }) }]}>
<Input placeholder={t('/ or /drive')} />
</Form.Item>
<Form.Item name="sub_path" label="子路径(可选)">
<Input placeholder="适配器内部子目录" />
<Form.Item name="sub_path" label={t('Sub Path (optional)')}>
<Input placeholder={t('Sub directory inside adapter')} />
</Form.Item>
<Form.Item name="enabled" label="启用" valuePropName="checked">
<Form.Item name="enabled" label={t('Enabled')} valuePropName="checked">
<Switch />
</Form.Item>
<Typography.Title level={5} style={{ marginTop: 8, fontSize: 14 }}></Typography.Title>
<Typography.Title level={5} style={{ marginTop: 8, fontSize: 14 }}>{t('Adapter Config')}</Typography.Title>
{renderConfigFields()}
</Form>
</Drawer>

View File

@@ -1,4 +1,4 @@
import { memo, useEffect, useState } from 'react';
import { memo, useEffect, useRef, useState } from 'react';
import { useParams } from 'react-router';
import { theme, Pagination } from 'antd';
import { AppWindowsLayer } from '../../apps/AppWindowsLayer';
@@ -15,6 +15,7 @@ import { GridView } from './components/GridView';
import { FileListView } from './components/FileListView';
import { EmptyState } from './components/EmptyState';
import { ContextMenu } from './components/ContextMenu';
import { DropzoneOverlay } from './components/DropzoneOverlay';
import { CreateDirModal } from './components/Modals/CreateDirModal';
import { RenameModal } from './components/Modals/RenameModal';
import { ProcessorModal } from './components/Modals/ProcessorModal';
@@ -29,14 +30,17 @@ const FileExplorerPage = memo(function FileExplorerPage() {
const { navKey = 'files', '*': restPath = '' } = useParams();
const { token } = theme.useToken();
const [viewMode, setViewMode] = useState<ViewMode>('grid');
const [isDragging, setIsDragging] = useState(false);
const dragCounter = useRef(0);
// --- Hooks ---
const { path, entries, loading, pagination, processorTypes, load, navigateTo, goUp, handlePaginationChange, refresh } = useFileExplorer(navKey);
const { path, entries, loading, pagination, processorTypes, sortBy, sortOrder, load, navigateTo, goUp, handlePaginationChange, refresh, handleSortChange } = useFileExplorer(navKey);
const { selectedEntries, handleSelect, handleSelectRange, clearSelection, setSelectedEntries } = useFileSelection();
const { doCreateDir, doDelete, doRename, doDownload, doShare, doGetDirectLink } = useFileActions({ path, refresh, clearSelection, onShare: (entries) => setSharingEntries(entries), onGetDirectLink: (entry) => setDirectLinkEntry(entry) });
const { appWindows, openFileWithDefaultApp, confirmOpenWithApp, closeWindow, toggleMax, bringToFront, updateWindow } = useAppWindows(path);
const { ctxMenu, blankCtxMenu, openContextMenu, openBlankContextMenu, closeContextMenus } = useContextMenu();
const uploader = useUploader(path, refresh);
const { handleFileDrop } = uploader;
const processorHook = useProcessor({ path, processorTypes, refresh });
const { thumbs } = useThumbnails(entries, path);
@@ -52,8 +56,8 @@ const FileExplorerPage = memo(function FileExplorerPage() {
// --- Effects ---
useEffect(() => {
const routeP = '/' + (restPath || '').replace(/^\/+/, '');
load(routeP, 1, pagination.pageSize);
}, [restPath, navKey, load, pagination.pageSize]);
load(routeP, 1, pagination.pageSize, sortBy, sortOrder);
}, [restPath, navKey, load, pagination.pageSize, sortBy, sortOrder]);
// --- Handlers ---
const handleOpenEntry = (entry: VfsEntry) => {
@@ -79,6 +83,37 @@ const FileExplorerPage = memo(function FileExplorerPage() {
}
};
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounter.current++;
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
setIsDragging(true);
}
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounter.current--;
if (dragCounter.current === 0) {
setIsDragging(false);
}
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
dragCounter.current = 0;
handleFileDrop(e.dataTransfer.files);
};
return (
<div
style={{
@@ -91,25 +126,32 @@ const FileExplorerPage = memo(function FileExplorerPage() {
position: 'relative'
}}
onClick={closeContextMenus}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
<Header
navKey={navKey}
path={path}
loading={loading}
viewMode={viewMode}
sortBy={sortBy}
sortOrder={sortOrder}
onGoUp={goUp}
onNavigate={navigateTo}
onRefresh={refresh}
onCreateDir={() => setCreatingDir(true)}
onUpload={uploader.openModal}
onSetViewMode={setViewMode}
onSortChange={handleSortChange}
/>
<input ref={uploader.fileInputRef} type="file" style={{ display: 'none' }} multiple onChange={uploader.handleFileChange} />
<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 +163,6 @@ const FileExplorerPage = memo(function FileExplorerPage() {
onSelectRange={handleSelectRange}
onOpen={handleOpenEntry}
onContextMenu={openContextMenu}
onCreateDir={() => setCreatingDir(true)}
onGoUp={goUp}
/>
) : (
<FileListView
@@ -214,6 +254,7 @@ const FileExplorerPage = memo(function FileExplorerPage() {
onStartUpload={uploader.startUpload}
/>
<AppWindowsLayer windows={appWindows} onClose={closeWindow} onToggleMax={toggleMax} onBringToFront={bringToFront} onUpdateWindow={updateWindow} />
<DropzoneOverlay visible={isDragging} />
</div>
);
});

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { Menu, theme } from 'antd';
import type { VfsEntry } from '../../../api/client';
import { getAppsForEntry, getDefaultAppForEntry } from '../../../apps/registry';
import { useI18n } from '../../../i18n';
import {
FolderFilled, AppstoreOutlined, AppstoreAddOutlined, DownloadOutlined,
EditOutlined, DeleteOutlined, InfoCircleOutlined, UploadOutlined, PlusOutlined, ShareAltOutlined, LinkOutlined
@@ -30,13 +31,14 @@ interface ContextMenuProps {
export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
const { token } = theme.useToken();
const { t } = useI18n();
const { x, y, entry, entries, selectedEntries, processorTypes, onClose, ...actions } = props;
const getContextMenuItems = () => {
if (!entry) { // Blank context menu
return [
{ key: 'upload', label: '上传文件', icon: <UploadOutlined />, onClick: actions.onUpload },
{ key: 'mkdir', label: '新建目录', icon: <PlusOutlined />, onClick: actions.onCreateDir },
{ key: 'upload', label: t('Upload File'), icon: <UploadOutlined />, onClick: actions.onUpload },
{ key: 'mkdir', label: t('New Folder'), icon: <PlusOutlined />, onClick: actions.onCreateDir },
];
}
@@ -61,56 +63,56 @@ export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
return [
(entry.is_dir || apps.length > 0) ? {
key: 'open',
label: defaultApp ? `打开 (${defaultApp.name})` : '打开',
label: defaultApp ? `${t('Open')} (${defaultApp.name})` : t('Open'),
icon: <FolderFilled />,
onClick: () => actions.onOpen(entry),
} : null,
!entry.is_dir && apps.length > 0 ? {
key: 'openWith',
label: '打开方式',
label: t('Open With'),
icon: <AppstoreOutlined />,
children: apps.map(a => ({
key: 'openWith-' + a.key,
label: a.name + (a.key === defaultApp?.key ? ' (默认)' : ''),
label: a.name + (a.key === defaultApp?.key ? ` (${t('Default')})` : ''),
onClick: () => actions.onOpenWith(entry, a.key),
})),
} : null,
!entry.is_dir && processorSubMenu.length > 0 ? {
key: 'process',
label: '处理器',
label: t('Processor'),
icon: <AppstoreAddOutlined />,
children: processorSubMenu,
} : null,
{
key: 'share',
label: '分享',
label: t('Share'),
icon: <ShareAltOutlined />,
onClick: () => actions.onShare(targetEntries),
},
{
key: 'directLink',
label: '获取直链',
label: t('Get Direct Link'),
icon: <LinkOutlined />,
disabled: targetEntries.length !== 1 || targetEntries[0].is_dir,
onClick: () => actions.onGetDirectLink(targetEntries[0]),
},
{
key: 'download',
label: '下载',
label: t('Download'),
icon: <DownloadOutlined />,
disabled: targetEntries.some(t => t.is_dir) || targetEntries.length > 1,
onClick: () => actions.onDownload(targetEntries[0]),
},
{
key: 'rename',
label: '重命名',
label: t('Rename'),
icon: <EditOutlined />,
disabled: targetEntries.length !== 1 || targetEntries[0].type === 'mount',
onClick: () => actions.onRename(targetEntries[0]),
},
{
key: 'delete',
label: '删除',
label: t('Delete'),
icon: <DeleteOutlined />,
danger: true,
disabled: targetEntries.some(t => t.type === 'mount'),
@@ -118,7 +120,7 @@ export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
},
{
key: 'detail',
label: '详情',
label: t('Details'),
icon: <InfoCircleOutlined />,
onClick: () => actions.onDetail(entry),
},
@@ -148,4 +150,4 @@ export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
/>
</div>
);
};
};

View File

@@ -0,0 +1,39 @@
import { memo } from 'react';
import { theme } from 'antd';
interface DropzoneOverlayProps {
visible: boolean;
}
export const DropzoneOverlay = memo(function DropzoneOverlay({ visible }: DropzoneOverlayProps) {
const { token } = theme.useToken();
if (!visible) {
return null;
}
return (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 100,
borderColor: token.colorPrimary,
borderStyle: 'dashed',
borderWidth: 4,
borderRadius: token.borderRadius,
}}
>
<div style={{ color: 'white', fontSize: 24, fontWeight: 'bold' }}>
</div>
</div>
);
});

View File

@@ -1,33 +1,24 @@
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';
import { useI18n } from '../../../i18n';
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();
const { t } = useI18n();
return (
<div style={{ display:'flex', flexDirection:'column', alignItems:'center', justifyContent:'center', padding:isRoot? '80px 40px':'60px 40px', minHeight: isRoot? '400px':'300px', color: token.colorTextSecondary }}>
<FolderOpenOutlined style={{ fontSize:64, color: token.colorTextQuaternary, marginBottom:16 }} />
<Typography.Title level={4} style={{ color: token.colorTextSecondary, marginBottom:8, fontWeight:400 }}>
{isRoot ? '这里还没有任何文件' : '此目录为空'}
{isRoot ? t('No files yet here') : t('This folder is empty')}
</Typography.Title>
<Typography.Text style={{ color: token.colorTextTertiary, marginBottom:24, textAlign:'center', maxWidth:300, lineHeight:1.5 }}>
{isRoot ? '开始上传文件或创建新目录来组织您的内容' : '您可以在此目录中创建新的文件夹或上传文件'}
{isRoot ? t('Start uploading files or create folders to organize your content') : t('You can create folders or upload files here')}
</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

@@ -1,6 +1,7 @@
import React from 'react';
import { Modal, Typography, Spin, theme, Card, Descriptions, Divider, Badge, Space, message } from 'antd';
import { FileOutlined, FolderOutlined, CameraOutlined, InfoCircleOutlined } from '@ant-design/icons';
import { useI18n } from '../../../i18n';
import type { VfsEntry } from '../../../api/client';
interface Props {
@@ -10,21 +11,24 @@ interface Props {
onClose: () => void;
}
const exifFieldMap: Record<string, { label: string; format?: (v: any) => string }> = {
'271': { label: '设备品牌' },
'272': { label: '设备型号' },
'306': { label: '拍摄时间' },
'282': { label: '水平分辨率', format: v => `${v} dpi` },
'283': { label: '垂直分辨率', format: v => `${v} dpi` },
'33434': { label: '曝光时间', format: v => `${v} ` },
'33437': { label: '光圈值', format: v => `f/${v}` },
'34855': { label: 'ISO' },
'37377': { label: '焦距', format: v => `${v} mm` },
'40962': { label: '宽度', format: v => `${v} px` },
'40963': { label: '高度', format: v => `${v} px` },
};
function getExifFieldMap(t: (k: string)=>string): Record<string, { label: string; format?: (v: any) => string }> {
return {
'271': { label: t('Camera Make') },
'272': { label: t('Camera Model') },
'306': { label: t('Capture Time') },
'282': { label: t('X Resolution'), format: v => `${v} dpi` },
'283': { label: t('Y Resolution'), format: v => `${v} dpi` },
'33434': { label: t('Exposure Time'), format: v => `${v} s` },
'33437': { label: t('Aperture'), format: v => `f/${v}` },
'34855': { label: 'ISO' },
'37377': { label: t('Focal Length'), format: v => `${v} mm` },
'40962': { label: t('Width'), format: v => `${v} px` },
'40963': { label: t('Height'), format: v => `${v} px` },
};
}
function renderExif(exif: Record<string, any>) {
function renderExif(exif: Record<string, any>, t: (k: string)=>string) {
const exifFieldMap = getExifFieldMap(t);
const items = Object.entries(exifFieldMap)
.filter(([key]) => exif[key] !== undefined)
.map(([key, { label, format }]) => ({
@@ -35,9 +39,9 @@ function renderExif(exif: Record<string, any>) {
if (items.length === 0) {
return (
<div style={{ textAlign: 'center', padding: 24, color: '#999' }}>
<div style={{ textAlign: 'center', padding: 24, color: 'var(--ant-color-text-tertiary, #999)' }}>
<InfoCircleOutlined style={{ fontSize: 20, marginBottom: 8 }} />
<div>EXIF信息</div>
<div>{t('No common EXIF info')}</div>
</div>
);
}
@@ -49,19 +53,19 @@ function renderExif(exif: Record<string, any>) {
bordered
items={items.map(item => ({
key: item.key,
label: <span style={{ fontWeight: 500, color: '#595959' }}>{item.label}</span>,
children: <span style={{ color: '#262626' }}>{item.value}</span>
label: <span style={{ fontWeight: 500, color: 'var(--ant-color-text-secondary, #595959)' }}>{item.label}</span>,
children: <span style={{ color: 'var(--ant-color-text, #262626)' }}>{item.value}</span>
}))}
contentStyle={{ padding: '8px 12px' }}
labelStyle={{ padding: '8px 12px', backgroundColor: '#fafafa', width: '30%' }}
labelStyle={{ padding: '8px 12px', backgroundColor: 'var(--ant-color-fill-tertiary, #fafafa)', width: '30%' }}
/>
);
}
function formatFileSize(size: number | string): string {
function formatFileSize(size: number | string, t: (k: string)=>string): string {
if (typeof size !== 'number') return String(size);
const units = ['字节', 'KB', 'MB', 'GB'];
const units = [t('Bytes'), 'KB', 'MB', 'GB'];
let index = 0;
let fileSize = size;
@@ -75,13 +79,14 @@ function formatFileSize(size: number | string): string {
export const FileDetailModal: React.FC<Props> = ({ entry, loading, data, onClose }) => {
const { token } = theme.useToken();
const { t } = useI18n();
return (
<Modal
title={
<Space>
<InfoCircleOutlined style={{ color: token.colorPrimary }} />
<span></span>
<span>{t('File Properties')}</span>
{entry && (
<Typography.Text type="secondary" style={{ fontSize: 14 }}>
- {entry.name}
@@ -100,7 +105,7 @@ export const FileDetailModal: React.FC<Props> = ({ entry, loading, data, onClose
{loading ? (
<div style={{ textAlign: 'center', padding: 48 }}>
<Spin size="large" />
<div style={{ marginTop: 16, color: token.colorTextSecondary }}>...</div>
<div style={{ marginTop: 16, color: token.colorTextSecondary }}>{t('Loading file info...')}</div>
</div>
) : data ? (
data.error ? (
@@ -118,7 +123,7 @@ export const FileDetailModal: React.FC<Props> = ({ entry, loading, data, onClose
title={
<Space>
{data.is_dir ? <FolderOutlined /> : <FileOutlined />}
{t('Basic Info')}
</Space>
}
style={{ borderRadius: 8, height: 'fit-content' }}
@@ -129,36 +134,36 @@ export const FileDetailModal: React.FC<Props> = ({ entry, loading, data, onClose
items={[
{
key: 'name',
label: '名称',
label: t('Name'),
children: <Typography.Text strong>{data.name}</Typography.Text>
},
{
key: 'type',
label: '类型',
label: t('Type'),
children: (
<Badge
status={data.is_dir ? 'processing' : 'default'}
text={data.type || (data.is_dir ? '文件夹' : '文件')}
text={data.type || (data.is_dir ? t('Folder') : t('File'))}
/>
)
},
{
key: 'size',
label: '大小',
children: formatFileSize(data.size)
label: t('Size'),
children: formatFileSize(data.size, t)
},
{
key: 'mtime',
label: '修改时间',
label: t('Modified Time'),
children: data.mtime ? (
typeof data.mtime === 'number'
? new Date(data.mtime * 1000).toLocaleString('zh-CN')
? new Date(data.mtime * 1000).toLocaleString()
: data.mtime
) : '-'
},
{
key: 'path',
label: '路径',
label: t('Path'),
children: (
<Typography.Text style={{ display: 'block', marginTop: 4 }}>
<a
@@ -168,9 +173,9 @@ export const FileDetailModal: React.FC<Props> = ({ entry, loading, data, onClose
try {
if (navigator.clipboard) {
navigator.clipboard.writeText(data.path).then(() => {
message.success('路径已复制到剪贴板');
message.success(t('Path copied to clipboard'));
}).catch(() => {
message.error('复制失败');
message.error(t('Copy failed'));
});
} else {
const textarea = document.createElement('textarea');
@@ -179,10 +184,10 @@ export const FileDetailModal: React.FC<Props> = ({ entry, loading, data, onClose
textarea.select();
const ok = document.execCommand('copy');
document.body.removeChild(textarea);
message[ok ? 'success' : 'error'](ok ? '路径已复制到剪贴板' : '复制失败');
message[ok ? 'success' : 'error'](ok ? t('Path copied to clipboard') : t('Copy failed'));
}
} catch {
message.error('复制失败');
message.error(t('Copy failed'));
}
}}
style={{
@@ -214,7 +219,7 @@ export const FileDetailModal: React.FC<Props> = ({ entry, loading, data, onClose
<>
<Divider style={{ margin: '12px 0' }} />
<div>
<span style={{ fontWeight: 500, color: token.colorTextSecondary }}></span>
<span style={{ fontWeight: 500, color: token.colorTextSecondary }}>{t('Permissions')}</span>
<Typography.Text code>{data.mode.toString(8)}</Typography.Text>
</div>
</>
@@ -230,12 +235,12 @@ export const FileDetailModal: React.FC<Props> = ({ entry, loading, data, onClose
title={
<Space>
<CameraOutlined />
EXIF信息
{t('EXIF Info')}
</Space>
}
style={{ borderRadius: 8, height: 'fit-content' }}
>
{renderExif(data.exif)}
{renderExif(data.exif, t)}
</Card>
</div>
)}

View File

@@ -17,11 +17,25 @@ import {
FontSizeOutlined,
} from '@ant-design/icons';
export const getFileIcon = (fileName: string, size: number = 16) => {
const lightenColor = (hex: string, amount: number) => {
const s = hex.replace('#', '');
const n = s.length === 3 ? s.split('').map(c => c + c).join('') : s;
const num = parseInt(n, 16);
if (Number.isNaN(num) || n.length !== 6) return hex;
const r = (num >> 16) & 255;
const g = (num >> 8) & 255;
const b = num & 255;
const mix = (c: number) => Math.round(c + (255 - c) * amount);
const toHex = (v: number) => v.toString(16).padStart(2, '0');
return `#${toHex(mix(r))}${toHex(mix(g))}${toHex(mix(b))}`;
};
export const getFileIcon = (fileName: string, size: number = 16, resolvedMode: 'light' | 'dark' | 'system' = 'light') => {
const ext = fileName.split('.').pop()?.toLowerCase() || '';
const iconStyle: React.CSSProperties = { fontSize: size, marginRight: size === 16 ? 6 : 0 };
const make = (node: React.ReactNode, color: string) => React.cloneElement(node as any, { style: { ...iconStyle, color } });
const adj = (color: string) => (resolvedMode === 'dark' ? lightenColor(color, 0.3) : color);
const make = (node: React.ReactNode, color: string) => React.cloneElement(node as any, { style: { ...iconStyle, color: adj(color) } });
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'tiff'].includes(ext)) return make(<FileImageOutlined />, '#52c41a');
if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'webm', 'm4v', '3gp'].includes(ext)) return make(<VideoCameraOutlined />, '#fa541c');

View File

@@ -4,6 +4,8 @@ import { FolderFilled, MoreOutlined, EditOutlined, DeleteOutlined, AppstoreOutli
import type { VfsEntry } from '../../../api/client';
import { getFileIcon } from './FileIcons';
import { getAppsForEntry, getDefaultAppForEntry } from '../../../apps/registry';
import { useTheme } from '../../../contexts/ThemeContext';
import { useI18n } from '../../../i18n';
interface FileListViewProps {
entries: VfsEntry[];
@@ -31,28 +33,42 @@ export const FileListView: React.FC<FileListViewProps> = ({
onContextMenu,
}) => {
const { token } = theme.useToken();
const { resolvedMode } = useTheme();
const { t } = useI18n();
const lightenColor = (hex: string, amount: number) => {
const s = hex.replace('#', '');
const n = s.length === 3 ? s.split('').map(c => c + c).join('') : s;
const num = parseInt(n, 16);
if (Number.isNaN(num) || n.length !== 6) return hex;
const r = (num >> 16) & 255;
const g = (num >> 8) & 255;
const b = num & 255;
const mix = (c: number) => Math.round(c + (255 - c) * amount);
const toHex = (v: number) => v.toString(16).padStart(2, '0');
return `#${toHex(mix(r))}${toHex(mix(g))}${toHex(mix(b))}`;
};
const columns = [
{
title: '名称',
title: t('Name'),
dataIndex: 'name',
key: 'name',
render: (_: any, r: VfsEntry) => (
<span style={{ cursor: 'pointer', userSelect: 'none' }} onDoubleClick={() => onOpen(r)}>
{r.is_dir ? (
<FolderFilled style={{ color: token.colorPrimary, marginRight: 6 }} />
<FolderFilled style={{ color: resolvedMode === 'dark' ? lightenColor(String(token.colorPrimary || '#111111'), 0.72) : token.colorPrimary, marginRight: 6 }} />
) : (
getFileIcon(r.name, 16)
getFileIcon(r.name, 16, resolvedMode)
)}
{r.name}
{r.type === 'mount' && <Tooltip title="挂载点"><span style={{ marginLeft: 6, fontSize: 10, padding: '0 4px', border: `1px solid ${token.colorBorderSecondary}`, borderRadius: 4 }}>MOUNT</span></Tooltip>}
{r.type === 'mount' && <Tooltip title={t('Mount Point')}><span style={{ marginLeft: 6, fontSize: 10, padding: '0 4px', border: `1px solid ${token.colorBorderSecondary}`, borderRadius: 4 }}>MOUNT</span></Tooltip>}
</span>
)
},
{ title: '大小', dataIndex: 'size', width: 100, render: (v: number, r: VfsEntry) => r.is_dir ? '-' : v },
{ title: '修改时间', dataIndex: 'mtime', width: 160, render: (v: number) => v ? new Date(v * 1000).toLocaleString() : '-' },
{ title: t('Size'), dataIndex: 'size', width: 100, render: (v: number, r: VfsEntry) => r.is_dir ? '-' : v },
{ title: t('Modified Time'), dataIndex: 'mtime', width: 160, render: (v: number) => v ? new Date(v * 1000).toLocaleString() : '-' },
{
title: '操作',
title: t('Actions'),
key: 'actions',
width: 110,
render: (_: any, r: VfsEntry) => {
@@ -62,19 +78,19 @@ export const FileListView: React.FC<FileListViewProps> = ({
<Dropdown
menu={{
items: [
(r.is_dir || apps.length > 0) ? { key: 'open', label: defaultApp ? `打开(${defaultApp.name})` : '打开', icon: <FolderOpenOutlined />, onClick: () => onOpen(r) } : null,
(r.is_dir || apps.length > 0) ? { key: 'open', label: defaultApp ? `${t('Open')}(${defaultApp.name})` : t('Open'), icon: <FolderOpenOutlined />, onClick: () => onOpen(r) } : null,
!r.is_dir && apps.length > 0 ? {
key: 'openWith',
label: '打开方式',
label: t('Open With'),
icon: <AppstoreOutlined />,
children: apps.map(a => ({
key: 'openWith-' + a.key,
label: a.name + (a.key === defaultApp?.key ? ' (默认)' : ''),
label: a.name + (a.key === defaultApp?.key ? ` (${t('Default')})` : ''),
onClick: () => onOpenWith(r, a.key)
}))
} : null,
{ key: 'rename', label: '重命名', icon: <EditOutlined />, disabled: r.type === 'mount', onClick: () => onRename(r) },
{ key: 'delete', label: '删除', icon: <DeleteOutlined />, danger: true, disabled: r.type === 'mount', onClick: () => onDelete(r) }
{ key: 'rename', label: t('Rename'), icon: <EditOutlined />, disabled: r.type === 'mount', onClick: () => onRename(r) },
{ key: 'delete', label: t('Delete'), icon: <DeleteOutlined />, danger: true, disabled: r.type === 'mount', onClick: () => onDelete(r) }
].filter(Boolean) as any[]
}}
>
@@ -105,4 +121,4 @@ export const FileListView: React.FC<FileListViewProps> = ({
}}
/>
);
};
};

View File

@@ -4,23 +4,18 @@ import { FolderFilled, PictureOutlined } from '@ant-design/icons';
import type { VfsEntry } from '../../../api/client';
import { getFileIcon } from './FileIcons';
import { EmptyState } from './EmptyState';
import { useTheme } from '../../../contexts/ThemeContext';
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 +25,34 @@ 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 { resolvedMode } = useTheme();
const lightenColor = (hex: string, amount: number) => {
const parseHex = (h: string) => {
const s = h.replace('#', '');
const n = s.length === 3 ? s.split('').map(c => c + c).join('') : s;
const num = parseInt(n, 16);
if (Number.isNaN(num) || n.length !== 6) return null;
return {
r: (num >> 16) & 255,
g: (num >> 8) & 255,
b: num & 255,
};
};
const rgb = parseHex(hex);
if (!rgb) return hex;
const mix = (c: number) => Math.round(c + (255 - c) * amount);
const r = mix(rgb.r);
const g = mix(rgb.g);
const b = mix(rgb.b);
const toHex = (v: number) => v.toString(16).padStart(2, '0');
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
};
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 +67,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 +103,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 +119,43 @@ 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))}
<div className="thumb" style={{ background: 'var(--ant-color-bg-container, #fff)' }}>
{ent.is_dir && (
<FolderFilled
style={{
fontSize: 32,
color: resolvedMode === 'dark' ? lightenColor(String(token.colorPrimary || '#111111'), 0.72) : token.colorPrimary,
}}
/>
)}
{!ent.is_dir && (
isImg ? (
<img src={isImg} alt={ent.name} style={{ maxWidth: '100%', maxHeight: '100%' }} />
) : isPictureType ? (
<PictureOutlined style={{ fontSize: 32, color: resolvedMode === 'dark' ? lightenColor(String(token.colorPrimary || '#111111'), 0.72) : 'var(--ant-color-text-tertiary, #8c8c8c)' }} />
) : (
getFileIcon(ent.name, 32, resolvedMode)
)
)}
{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>
)
})}
@@ -142,14 +167,14 @@ export const GridView: React.FC<Props> = ({ entries, thumbs, selectedEntries, lo
top: rect.top,
width: rect.width,
height: rect.height,
border: '1px dashed rgba(0,0,0,0.4)',
background: 'rgba(0, 120, 212, 0.08)',
border: '1px dashed var(--ant-color-border, rgba(0,0,0,0.4))',
background: 'var(--ant-color-primary-bg, rgba(0, 120, 212, 0.08))',
zIndex: 999
}}
/>
)}
{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

@@ -1,6 +1,8 @@
import React, { useState } from 'react';
import { Flex, Typography, Divider, Button, Space, Tooltip, Segmented, Breadcrumb, Input, theme } from 'antd';
import { ArrowUpOutlined, ReloadOutlined, PlusOutlined, UploadOutlined, AppstoreOutlined, UnorderedListOutlined } from '@ant-design/icons';
import { ArrowUpOutlined, ArrowDownOutlined, ReloadOutlined, PlusOutlined, UploadOutlined, AppstoreOutlined, UnorderedListOutlined } from '@ant-design/icons';
import { Select } from 'antd';
import { useI18n } from '../../../i18n';
import type { ViewMode } from '../types';
interface HeaderProps {
@@ -8,26 +10,33 @@ interface HeaderProps {
path: string;
loading: boolean;
viewMode: ViewMode;
sortBy: string;
sortOrder: string;
onGoUp: () => void;
onNavigate: (path: string) => void;
onRefresh: () => void;
onCreateDir: () => void;
onUpload: () => void;
onSetViewMode: (mode: ViewMode) => void;
onSortChange: (sortBy: string, sortOrder: string) => void;
}
export const Header: React.FC<HeaderProps> = ({
path,
loading,
viewMode,
sortBy,
sortOrder,
onGoUp,
onNavigate,
onRefresh,
onCreateDir,
onUpload,
onSetViewMode,
onSortChange,
}) => {
const { token } = theme.useToken();
const { t } = useI18n();
const [editingPath, setEditingPath] = useState(false);
const [pathInputValue, setPathInputValue] = useState('');
@@ -66,7 +75,7 @@ export const Header: React.FC<HeaderProps> = ({
}
const breadcrumbItems = [
{ key: 'root', title: <span style={{ cursor: 'pointer' }} onClick={() => onNavigate('/')}>Home</span> },
{ key: 'root', title: <span style={{ cursor: 'pointer' }} onClick={() => onNavigate('/')}>{t('Home')}</span> },
...path.split('/').filter(Boolean).map((segment, index, arr) => {
const segmentPath = '/' + arr.slice(0, index + 1).join('/');
return {
@@ -92,24 +101,40 @@ export const Header: React.FC<HeaderProps> = ({
<Flex align="center" justify="space-between" style={{ padding: '10px 16px', borderBottom: `1px solid ${token.colorBorderSecondary}`, gap: 12 }}>
<Flex align="center" gap={8} style={{ flexWrap: 'wrap', flex: 1, overflow: 'hidden' }}>
<Button size="small" icon={<ArrowUpOutlined />} onClick={onGoUp} disabled={path === '/'} />
<Typography.Text strong></Typography.Text>
<Typography.Text strong>{t('File Manager')}</Typography.Text>
<Divider type="vertical" />
{renderBreadcrumb()}
</Flex>
<Space size={8} wrap>
<Button size="small" icon={<ReloadOutlined />} onClick={onRefresh} loading={loading}></Button>
<Button size="small" icon={<PlusOutlined />} onClick={onCreateDir}></Button>
<Button size="small" icon={<UploadOutlined />} onClick={onUpload}></Button>
<Button size="small" icon={<ReloadOutlined />} onClick={onRefresh} loading={loading}>{t('Refresh')}</Button>
<Button size="small" icon={<PlusOutlined />} onClick={onCreateDir}>{t('New Folder')}</Button>
<Button size="small" icon={<UploadOutlined />} onClick={onUpload}>{t('Upload')}</Button>
<Select
size="small"
value={sortBy}
onChange={(val) => onSortChange(val, sortOrder)}
style={{ width: 80 }}
options={[
{ value: 'name', label: t('Name') },
{ value: 'size', label: t('Size') },
{ value: 'mtime', label: t('Modified Time') },
]}
/>
<Button
size="small"
icon={sortOrder === 'asc' ? <ArrowUpOutlined /> : <ArrowDownOutlined />}
onClick={() => onSortChange(sortBy, sortOrder === 'asc' ? 'desc' : 'asc')}
/>
<Segmented
size="small"
value={viewMode}
onChange={v => onSetViewMode(v as any)}
options={[
{ label: <Tooltip title="网格"><AppstoreOutlined /></Tooltip>, value: 'grid' },
{ label: <Tooltip title="列表"><UnorderedListOutlined /></Tooltip>, value: 'list' }
{ label: <Tooltip title={t('Grid')}><AppstoreOutlined /></Tooltip>, value: 'grid' },
{ label: <Tooltip title={t('List')}><UnorderedListOutlined /></Tooltip>, value: 'list' }
]}
/>
</Space>
</Flex>
);
};
};

View File

@@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react';
import { Modal, Input } from 'antd';
import { useI18n } from '../../../../i18n';
interface CreateDirModalProps {
open: boolean;
@@ -9,6 +10,7 @@ interface CreateDirModalProps {
export const CreateDirModal: React.FC<CreateDirModalProps> = ({ open, onOk, onCancel }) => {
const [name, setName] = useState('');
const { t } = useI18n();
useEffect(() => {
if (open) {
@@ -22,7 +24,7 @@ export const CreateDirModal: React.FC<CreateDirModalProps> = ({ open, onOk, onCa
return (
<Modal
title="新建目录"
title={t('New Folder')}
open={open}
onOk={handleOk}
onCancel={onCancel}
@@ -30,7 +32,7 @@ export const CreateDirModal: React.FC<CreateDirModalProps> = ({ open, onOk, onCa
destroyOnClose
>
<Input
placeholder="目录名称"
placeholder={t('Folder Name')}
value={name}
onChange={e => setName(e.target.value)}
onPressEnter={handleOk}
@@ -38,4 +40,4 @@ export const CreateDirModal: React.FC<CreateDirModalProps> = ({ open, onOk, onCa
/>
</Modal>
);
};
};

View File

@@ -1,8 +1,9 @@
import { memo, useState, useEffect } from 'react';
import { Modal, Radio, message, Button, Typography, Input } from 'antd';
import { CopyOutlined } from '@ant-design/icons';
import { Modal, Radio, message, Button, Typography, Input, Space } from 'antd';
import { CopyOutlined, FileMarkdownOutlined } from '@ant-design/icons';
import type { VfsEntry } from '../../../../api/client';
import { vfsApi } from '../../../../api/client';
import { useI18n } from '../../../../i18n';
interface DirectLinkModalProps {
entry: VfsEntry | null;
@@ -11,10 +12,26 @@ interface DirectLinkModalProps {
onCancel: () => void;
}
// Helper function to check if a file is an image
const isImageFile = (fileName: string): boolean => {
const ext = fileName.split('.').pop()?.toLowerCase() || '';
return ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'tiff'].includes(ext);
};
// Helper function to generate Markdown formatted link
const generateMarkdownLink = (fileName: string, url: string): string => {
if (isImageFile(fileName)) {
return `![${fileName}](${url})`;
} else {
return `[${fileName}](${url})`;
}
};
export const DirectLinkModal = memo(function DirectLinkModal({ entry, path, open, onCancel }: DirectLinkModalProps) {
const [loading, setLoading] = useState(false);
const [expiresIn, setExpiresIn] = useState(3600);
const [link, setLink] = useState('');
const { t } = useI18n();
useEffect(() => {
if (open && entry) {
@@ -29,10 +46,14 @@ 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);
let url = res.url;
if (url && !url.startsWith('http://') && !url.startsWith('https://')) {
const origin = window.location.origin;
url = url.startsWith('/') ? origin + url : origin + '/' + url;
}
setLink(url);
} catch (e: any) {
message.error(e.message || '生成链接失败');
message.error(e.message || t('Failed to generate link'));
} finally {
setLoading(false);
}
@@ -40,40 +61,52 @@ export const DirectLinkModal = memo(function DirectLinkModal({ entry, path, open
const handleCopy = (text: string) => {
navigator.clipboard.writeText(text);
message.success('已复制到剪贴板');
message.success(t('Copied to clipboard'));
};
const handleCopyMarkdown = () => {
if (!entry || !link) return;
const markdownText = generateMarkdownLink(entry.name, link);
navigator.clipboard.writeText(markdownText);
message.success(t('Markdown copied to clipboard'));
};
const handleExpiresChange = (e: any) => {
setExpiresIn(e.target.value);
};
return (
<Modal
title="获取直链"
title={t('Get Direct Link')}
open={open}
onCancel={onCancel}
footer={[
<Button key="back" onClick={onCancel}>
{t('Close')}
</Button>,
]}
>
<Typography.Paragraph>
<strong>{entry?.name}</strong> 访
{t('Generate a direct link for {name}', { name: entry?.name || '' })}
</Typography.Paragraph>
<Radio.Group value={expiresIn} onChange={handleExpiresChange} style={{ marginBottom: 16 }}>
<Radio.Button value={3600}>1 </Radio.Button>
<Radio.Button value={86400}>1 </Radio.Button>
<Radio.Button value={604800}>7 </Radio.Button>
<Radio.Button value={0}></Radio.Button>
<Radio.Button value={3600}>{t('1 hour')}</Radio.Button>
<Radio.Button value={86400}>{t('1 day')}</Radio.Button>
<Radio.Button value={604800}>{t('7 days')}</Radio.Button>
<Radio.Button value={0}>{t('Forever')}</Radio.Button>
</Radio.Group>
<div style={{ display: 'flex', gap: 8 }}>
<Input readOnly value={link} disabled={loading} placeholder={loading ? "正在生成链接..." : "链接将显示在这里"} />
<Button icon={<CopyOutlined />} onClick={() => handleCopy(link)} disabled={!link || loading}>
</Button>
<Input readOnly value={link} disabled={loading} placeholder={loading ? t('Generating link...') : t('Link will appear here')} />
<Space.Compact>
<Button icon={<CopyOutlined />} onClick={() => handleCopy(link)} disabled={!link || loading}>
{t('Copy')}
</Button>
<Button icon={<FileMarkdownOutlined />} onClick={handleCopyMarkdown} disabled={!link || loading}>
{t('Copy Markdown')}
</Button>
</Space.Compact>
</div>
</Modal>
);
});
});

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { Modal, Form, Select, Input, Checkbox } from 'antd';
import { useI18n } from '../../../../i18n';
import type { VfsEntry } from '../../../../api/client';
import type { ProcessorTypeMeta } from '../../../../api/processors';
import { ProcessorConfigForm } from '../../../../components/ProcessorConfigForm';
@@ -28,6 +29,7 @@ export const ProcessorModal: React.FC<ProcessorModalProps> = (props) => {
onConfigChange, onSavingPathChange, onOverwriteChange
} = props;
const [form] = Form.useForm();
const { t } = useI18n();
const selectedProcessorMeta = processorTypes.find(pt => pt.type === selectedProcessor);
@@ -51,7 +53,7 @@ export const ProcessorModal: React.FC<ProcessorModalProps> = (props) => {
return (
<Modal
title={`使用处理器处理文件${entry ? `: ${entry.name}` : ''}`}
title={t('Process file with processor') + (entry ? `: ${entry.name}` : '')}
open={visible}
onCancel={onCancel}
onOk={onOk}
@@ -59,11 +61,11 @@ export const ProcessorModal: React.FC<ProcessorModalProps> = (props) => {
destroyOnClose
>
<Form form={form} layout="vertical" onValuesChange={handleFormValuesChange}>
<Form.Item name="processor_type" label="处理器" required>
<Form.Item name="processor_type" label={t('Processor')} required>
<Select
onChange={onSelectedProcessorChange}
options={processorTypes.map(pt => ({ value: pt.type, label: pt.name }))}
placeholder="请选择处理器"
placeholder={t('Select a processor')}
/>
</Form.Item>
<ProcessorConfigForm
@@ -75,15 +77,15 @@ export const ProcessorModal: React.FC<ProcessorModalProps> = (props) => {
<>
<Form.Item>
<Checkbox checked={overwrite} onChange={e => onOverwriteChange(e.target.checked)}>
{t('Overwrite original file')}
</Checkbox>
</Form.Item>
{!overwrite && (
<Form.Item label="保存为新文件">
<Form.Item label={t('Save as new file')}>
<Input
value={savingPath}
onChange={e => onSavingPathChange(e.target.value)}
placeholder="如 /newfile.jpg不填则仅返回处理结果"
placeholder={t('e.g. /newfile.jpg, leave blank to only return result')}
/>
</Form.Item>
)}
@@ -92,4 +94,4 @@ export const ProcessorModal: React.FC<ProcessorModalProps> = (props) => {
</Form>
</Modal>
);
};
};

View File

@@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react';
import { Modal, Input } from 'antd';
import { useI18n } from '../../../../i18n';
import type { VfsEntry } from '../../../../api/client';
interface RenameModalProps {
@@ -10,6 +11,7 @@ interface RenameModalProps {
export const RenameModal: React.FC<RenameModalProps> = ({ entry, onOk, onCancel }) => {
const [name, setName] = useState('');
const { t } = useI18n();
useEffect(() => {
if (entry) {
@@ -25,7 +27,7 @@ export const RenameModal: React.FC<RenameModalProps> = ({ entry, onOk, onCancel
return (
<Modal
title="重命名"
title={t('Rename')}
open={!!entry}
onOk={handleOk}
onCancel={onCancel}
@@ -33,7 +35,7 @@ export const RenameModal: React.FC<RenameModalProps> = ({ entry, onOk, onCancel
destroyOnClose
>
<Input
placeholder="新的名称"
placeholder={t('New Name')}
value={name}
onChange={e => setName(e.target.value)}
onPressEnter={handleOk}
@@ -41,4 +43,4 @@ export const RenameModal: React.FC<RenameModalProps> = ({ entry, onOk, onCancel
/>
</Modal>
);
};
};

View File

@@ -3,6 +3,8 @@ 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';
import { useI18n } from '../../../../i18n';
interface ShareModalProps {
entries: VfsEntry[];
@@ -13,13 +15,15 @@ interface ShareModalProps {
}
export const ShareModal = memo(function ShareModal({ entries, path, open, onOk, onCancel }: ShareModalProps) {
const systemStatus = useSystemStatus();
const { t } = useI18n();
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [accessType, setAccessType] = useState('public');
const [createdShare, setCreatedShare] = useState<ShareInfoWithPassword | null>(null);
const defaultName = entries.length > 1
? `分享 ${entries.length} 个项目`
? t('Share {count} items', { count: entries.length.toString() })
: (entries.length === 1 ? entries[0].name : '');
useEffect(() => {
@@ -52,10 +56,10 @@ export const ShareModal = memo(function ShareModal({ entries, path, open, onOk,
password: values.password,
expires_in_days: values.expiresInDays,
});
message.success('分享链接已创建');
message.success(t('Share link created'));
setCreatedShare(result);
} catch (e: any) {
message.error(e.message || '创建失败');
message.error(e.message || t('Create failed'));
} finally {
setLoading(false);
}
@@ -63,28 +67,29 @@ export const ShareModal = memo(function ShareModal({ entries, path, open, onOk,
const handleCopy = (text: string) => {
navigator.clipboard.writeText(text);
message.success('已复制到剪贴板');
message.success(t('Copied to clipboard'));
};
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 }}>
<Form.Item name="name" label="分享名称" rules={[{ required: true }]} >
<Form.Item name="name" label={t('Share Name')} rules={[{ required: true }]} >
<Input />
</Form.Item>
<Form.Item name="accessType" label="访问权限">
<Form.Item name="accessType" label={t('Access')}>
<Radio.Group onChange={(e) => setAccessType(e.target.value)}>
<Radio value="public"></Radio>
<Radio value="password">访</Radio>
<Radio value="public">{t('Public')}</Radio>
<Radio value="password">{t('By Password')}</Radio>
</Radio.Group>
</Form.Item>
{accessType === 'password' && (
<Form.Item name="password" label="访问密码" rules={[{ required: true, message: '请输入密码' }]} >
<Form.Item name="password" label={t('Please enter password')} rules={[{ required: true, message: t('Please enter password') }]} >
<Input.Password />
</Form.Item>
)}
<Form.Item name="expiresInDays" label="有效期 (天)" help="设置为 0 或负数表示永久有效">
<Form.Item name="expiresInDays" label={t('Expiration (days)')} help={t('Set 0 or negative for forever')}>
<InputNumber min={-1} style={{ width: '100%' }} />
</Form.Item>
</Form>
@@ -92,44 +97,44 @@ export const ShareModal = memo(function ShareModal({ entries, path, open, onOk,
const renderSuccess = () => (
<div>
<Typography.Paragraph></Typography.Paragraph>
<Typography.Paragraph>{t('Share link created successfully!')}</Typography.Paragraph>
<Form layout="vertical">
<Form.Item label="分享链接">
<Form.Item label={t('Share Link')}>
<div style={{ display: 'flex', gap: 8 }}>
<Input readOnly value={shareUrl} style={{ flex: 1 }} />
<Button icon={<CopyOutlined />} onClick={() => handleCopy(shareUrl)}>
{t('Copy')}
</Button>
</div>
</Form.Item>
{createdShare?.password && (
<Form.Item label="访问密码">
<Form.Item label={t('Password')}>
<div style={{ display: 'flex', gap: 8 }}>
<Input readOnly value={createdShare.password} style={{ flex: 1 }} />
<Button icon={<CopyOutlined />} onClick={() => handleCopy(createdShare.password!)}>
{t('Copy')}
</Button>
</div>
</Form.Item>
)}
</Form>
<Typography.Text type="secondary">
: {createdShare?.expires_at ? new Date(createdShare.expires_at).toLocaleString() : '永久有效'}
{t('Expires At')}: {createdShare?.expires_at ? new Date(createdShare.expires_at).toLocaleString() : t('Forever')}
</Typography.Text>
</div>
);
return (
<Modal
title={createdShare ? "分享创建成功" : "创建分享"}
title={createdShare ? t('Share created') : t('Create Share')}
open={open}
onOk={createdShare ? onOk : handleOk}
onCancel={onCancel}
confirmLoading={loading}
destroyOnHidden
okText={createdShare ? "完成" : "创建"}
okText={createdShare ? t('Done') : t('Create')}
>
{createdShare ? renderSuccess() : renderForm()}
</Modal>
);
});
});

View File

@@ -2,6 +2,7 @@ import React, { useEffect } from 'react';
import { Modal, Button, List, Progress, Typography, message, Flex } from 'antd';
import { CopyOutlined, CheckCircleFilled, CloseCircleFilled } from '@ant-design/icons';
import type { UploadFile } from '../../hooks/useUploader';
import { useI18n } from '../../../../i18n';
interface UploadModalProps {
visible: boolean;
@@ -11,6 +12,7 @@ interface UploadModalProps {
}
const UploadModal: React.FC<UploadModalProps> = ({ visible, files, onClose, onStartUpload }) => {
const { t } = useI18n();
const allSuccess = files.every(f => f.status === 'success');
@@ -22,7 +24,7 @@ const UploadModal: React.FC<UploadModalProps> = ({ visible, files, onClose, onSt
const handleCopy = (text: string) => {
navigator.clipboard.writeText(text);
message.success('链接已复制到剪贴板');
message.success(t('Copied to clipboard'));
};
const renderStatus = (file: UploadFile) => {
@@ -32,32 +34,32 @@ const UploadModal: React.FC<UploadModalProps> = ({ visible, files, onClose, onSt
case 'success':
return (
<Flex align="center" gap={8}>
<CheckCircleFilled style={{ color: '#52c41a' }} />
<Typography.Text type="secondary" style={{ verticalAlign: 'middle' }}></Typography.Text>
<CheckCircleFilled style={{ color: 'var(--ant-color-success, #52c41a)' }} />
<Typography.Text type="secondary" style={{ verticalAlign: 'middle' }}>{t('Upload succeeded')}</Typography.Text>
<Button icon={<CopyOutlined />} size="small" onClick={() => handleCopy(file.permanentLink!)} type="text" />
</Flex>
);
case 'error':
return (
<Flex align="center" gap={8}>
<CloseCircleFilled style={{ color: '#ff4d4f' }} />
<Typography.Text type="danger" title={file.error}></Typography.Text>
<CloseCircleFilled style={{ color: 'var(--ant-color-error, #ff4d4f)' }} />
<Typography.Text type="danger" title={file.error}>{t('Upload failed')}</Typography.Text>
</Flex>
);
default:
return <Typography.Text type="secondary"></Typography.Text>;
return <Typography.Text type="secondary">{t('Waiting to upload')}</Typography.Text>;
}
};
return (
<Modal
open={visible}
title="上传文件"
title={t('Upload File')}
width={600}
onCancel={onClose}
footer={[
<Button key="close" onClick={onClose} disabled={!allSuccess && files.some(f => f.status === 'uploading')}>
{allSuccess ? '关闭' : '完成'}
{allSuccess ? t('Close') : t('Done')}
</Button>,
]}
>
@@ -71,7 +73,7 @@ const UploadModal: React.FC<UploadModalProps> = ({ visible, files, onClose, onSt
borderRadius: 8,
transition: 'background-color 0.2s',
}}
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = '#f0f0f0'; }}
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = 'var(--ant-color-fill-tertiary, #f0f0f0)'; }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent'; }}
>
<Flex justify="space-between" align="center" style={{ width: '100%' }}>
@@ -89,4 +91,4 @@ const UploadModal: React.FC<UploadModalProps> = ({ visible, files, onClose, onSt
);
};
export default UploadModal;
export default UploadModal;

View File

@@ -1,11 +1,13 @@
import { useState, useCallback } from 'react';
import { Modal, Checkbox } from 'antd';
import { useI18n } from '../../../i18n';
import type { VfsEntry } from '../../../api/client';
import type { AppDescriptor } from '../../../apps/registry';
import type { AppWindow } from '../types';
import { getAppsForEntry, getDefaultAppForEntry, getAppByKey } from '../../../apps/registry';
export function useAppWindows(path: string) {
const { t } = useI18n();
const [appWindows, setAppWindows] = useState<AppWindow[]>([]);
const openWithApp = useCallback((entry: VfsEntry, app: AppDescriptor) => {
@@ -40,7 +42,7 @@ export function useAppWindows(path: string) {
const openFileWithDefaultApp = useCallback((entry: VfsEntry) => {
const apps = getAppsForEntry(entry);
if (!apps.length) {
Modal.error({ title: '无法打开该文件:没有可用的应用' });
Modal.error({ title: t('Cannot open file: no available app') });
return;
}
const defaultApp = getDefaultAppForEntry(entry) || apps[0];
@@ -50,17 +52,17 @@ export function useAppWindows(path: string) {
const confirmOpenWithApp = useCallback((entry: VfsEntry, appKey: string) => {
const app = getAppByKey(appKey);
if (!app) {
Modal.error({ title: '错误', content: `应用 "${appKey}" 不存在。` });
Modal.error({ title: t('Error'), content: t('App "{key}" not found.', { key: appKey }) });
return;
}
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
let setDefault = false;
Modal.confirm({
title: `使用 ${app.name} 打开`,
title: t('Open with {app}', { app: app.name }),
content: (
<div>
<div style={{ marginBottom: 8 }}>: {entry.name}</div>
<Checkbox onChange={e => setDefault = e.target.checked}>(.{ext})</Checkbox>
<div style={{ marginBottom: 8 }}>{t('File')}: {entry.name}</div>
<Checkbox onChange={e => setDefault = e.target.checked}>{t('Set as default for .{ext}', { ext })}</Checkbox>
</div>
),
onOk: () => {
@@ -92,4 +94,4 @@ export function useAppWindows(path: string) {
bringToFront,
updateWindow,
};
}
}

View File

@@ -1,5 +1,6 @@
import { useCallback } from 'react';
import { message, Modal } from 'antd';
import { useI18n } from '../../../i18n';
import { vfsApi, type VfsEntry } from '../../../api/client';
interface FileActionsParams {
@@ -11,9 +12,10 @@ interface FileActionsParams {
}
export function useFileActions({ path, refresh, clearSelection, onShare, onGetDirectLink }: FileActionsParams) {
const { t } = useI18n();
const doCreateDir = useCallback(async (name: string) => {
if (!name.trim()) {
message.warning('请输入名称');
message.warning(t('Please input name'));
return;
}
try {
@@ -26,8 +28,8 @@ export function useFileActions({ path, refresh, clearSelection, onShare, onGetDi
const doDelete = useCallback(async (entries: VfsEntry[]) => {
Modal.confirm({
title: `确认删除 ${entries.length > 1 ? `${entries.length} ` : entries[0].name} ?`,
content: entries.length > 1 ? <div style={{ maxHeight: 180, overflow: 'auto' }}>{entries.map(it => <div key={it.name}>{it.name}{it.type === 'mount' && ' (挂载点)'}</div>)}</div> : null,
title: t('Confirm delete {name}?', { name: entries.length > 1 ? `${entries.length} ${t('items')}` : entries[0].name }),
content: entries.length > 1 ? <div style={{ maxHeight: 180, overflow: 'auto' }}>{entries.map(it => <div key={it.name}>{it.name}{it.type === 'mount' && ` (${t('Mount Point')})`}</div>)}</div> : null,
onOk: async () => {
try {
await Promise.all(entries.map(it => vfsApi.deletePath((path === '/' ? '' : path) + '/' + it.name)));
@@ -57,7 +59,7 @@ export function useFileActions({ path, refresh, clearSelection, onShare, onGetDi
const doDownload = useCallback(async (entry: VfsEntry) => {
if (entry.is_dir) {
message.warning('暂不支持下载目录');
message.warning(t('Downloading folders is not supported'));
return;
}
try {
@@ -72,13 +74,13 @@ export function useFileActions({ path, refresh, clearSelection, onShare, onGetDi
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (e: any) {
message.error(e.message || '下载失败');
message.error(e.message || t('Download failed'));
}
}, [path]);
const doShare = useCallback((entries: VfsEntry[]) => {
if (entries.length === 0) {
message.warning('请选择要分享的文件或目录');
message.warning(t('Please select files or folders to share'));
return;
}
onShare(entries);
@@ -86,7 +88,7 @@ export function useFileActions({ path, refresh, clearSelection, onShare, onGetDi
const doGetDirectLink = useCallback((entry: VfsEntry) => {
if (entry.is_dir) {
message.warning('不支持获取目录的直链');
message.warning(t('Direct links for folders are not supported'));
return;
}
onGetDirectLink(entry);
@@ -100,4 +102,4 @@ export function useFileActions({ path, refresh, clearSelection, onShare, onGetDi
doShare,
doGetDirectLink,
};
}
}

View File

@@ -18,17 +18,19 @@ export function useFileExplorer(navKey: string) {
total: 0,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total: number, range: [number, number]) => `${total} 项,第 ${range[0]}-${range[1]}`,
showTotal: (total: number, range: [number, number]) => `${total} ${'items'} ${range[0]}-${range[1]}`,
pageSizeOptions: ['20', '50', '100', '200']
});
const [sortBy, setSortBy] = useState('name');
const [sortOrder, setSortOrder] = useState('asc');
const load = useCallback(async (p: string, page: number = 1, pageSize: number = 50) => {
const load = useCallback(async (p: string, page: number = 1, pageSize: number = 50, sb = sortBy, so = sortOrder) => {
const canonical = p === '' ? '/' : (p.startsWith('/') ? p : '/' + p);
setLoading(true);
try {
// Load entries and processor types concurrently
const [res, processors] = await Promise.all([
vfsApi.list(canonical === '/' ? '' : canonical, page, pageSize),
vfsApi.list(canonical === '/' ? '' : canonical, page, pageSize, sb, so),
processorsApi.list()
]);
setEntries(res.entries);
@@ -41,11 +43,11 @@ export function useFileExplorer(navKey: string) {
}));
setProcessorTypes(processors);
} catch (e: any) {
message.error(e.message || '加载失败');
message.error(e.message || 'Load failed');
} finally {
setLoading(false);
}
}, []);
}, [sortBy, sortOrder]);
const navigateTo = useCallback((p: string) => {
const canonical = p === '' || p === '/' ? '/' : (p.startsWith('/') ? p : '/' + p);
@@ -60,23 +62,32 @@ export function useFileExplorer(navKey: string) {
}, [path, navigateTo]);
const handlePaginationChange = (page: number, pageSize: number) => {
load(path, page, pageSize);
load(path, page, pageSize, sortBy, sortOrder);
};
const refresh = () => {
load(path, pagination.current, pagination.pageSize);
load(path, pagination.current, pagination.pageSize, sortBy, sortOrder);
}
const handleSortChange = (sb: string, so: string) => {
setSortBy(sb);
setSortOrder(so);
load(path, 1, pagination.pageSize, sb, so);
};
return {
path,
entries,
loading,
pagination,
processorTypes,
sortBy,
sortOrder,
load,
navigateTo,
goUp,
handlePaginationChange,
refresh,
handleSortChange
};
}
}

View File

@@ -1,5 +1,6 @@
import { useState, useCallback } from 'react';
import { message } from 'antd';
import { useI18n } from '../../../i18n';
import { processorsApi, type ProcessorTypeMeta } from '../../../api/processors';
import type { VfsEntry } from '../../../api/client';
@@ -10,6 +11,7 @@ interface ProcessorParams {
}
export function useProcessor({ path, processorTypes, refresh }: ProcessorParams) {
const { t } = useI18n();
const [modal, setModal] = useState<{ entry: VfsEntry | null; visible: boolean }>({ entry: null, visible: false });
const [selectedProcessor, setSelectedProcessor] = useState<string>('');
const [config, setConfig] = useState<any>({});
@@ -48,11 +50,11 @@ export function useProcessor({ path, processorTypes, refresh }: ProcessorParams)
};
await processorsApi.process(params);
message.success('处理完成');
message.success(t('Processing finished'));
setModal({ entry: null, visible: false });
if (overwrite || savingPath) refresh();
} catch (e: any) {
message.error(e.message || '处理失败');
message.error(e.message || t('Processing failed'));
} finally {
setLoading(false);
}
@@ -100,4 +102,4 @@ export function useProcessor({ path, processorTypes, refresh }: ProcessorParams)
setProcessorSavingPath: setSavingPath,
setProcessorOverwrite: setOverwrite,
};
}
}

View File

@@ -39,13 +39,25 @@ export function useUploader(path: string, onUploadComplete: () => void) {
}));
setFiles(newFiles);
setIsModalVisible(true);
// reset file input
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
};
const handleFileDrop = (droppedFiles: FileList) => {
if (droppedFiles && droppedFiles.length > 0) {
const newFiles: UploadFile[] = Array.from(droppedFiles).map(file => ({
id: `${file.name}-${Date.now()}`,
file,
status: 'pending',
progress: 0,
}));
setFiles(newFiles);
setIsModalVisible(true);
}
};
const startUpload = useCallback(async () => {
if (files.length === 0) {
return;
@@ -66,13 +78,13 @@ export function useUploader(path: string, onUploadComplete: () => void) {
setFiles(prev => prev.map(f => f.id === uploadFile.id ? { ...f, progress } : f));
});
const link = await vfsApi.getTempLinkToken(dest, 60 * 60 * 24 * 365 * 10); // 10 years
const link = await vfsApi.getTempLinkToken(dest, 60 * 60 * 24 * 365 * 10);
const permanentLink = vfsApi.getTempPublicUrl(link.token);
setFiles(prev => prev.map(f => f.id === uploadFile.id ? { ...f, status: 'success', progress: 100, permanentLink } : f));
} catch (e: any) {
setFiles(prev => prev.map(f => f.id === uploadFile.id ? { ...f, status: 'error', error: e.message } : f));
message.error(`上传失败: ${uploadFile.file.name} - ${e.message}`);
message.error(`Upload failed: ${uploadFile.file.name} - ${e.message}`);
}
}
@@ -86,6 +98,7 @@ export function useUploader(path: string, onUploadComplete: () => void) {
openModal,
closeModal,
handleFileChange,
handleFileDrop,
startUpload,
};
}
}

Some files were not shown because too many files have changed in this diff Show More