mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-07-02 04:31:28 +08:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce74c2712b | ||
|
|
59d6c94a57 | ||
|
|
fd87dc3ce2 | ||
|
|
620ae17732 | ||
|
|
9b0dd13816 | ||
|
|
6a52fa3fd5 | ||
|
|
219999914c | ||
|
|
1a3d9d41ec | ||
|
|
27ad49d8ed | ||
|
|
e230bf6661 | ||
|
|
50fb0b4977 | ||
|
|
b50f19bcb4 | ||
|
|
3f3f192d53 | ||
|
|
83aaa7a052 | ||
|
|
a2638f077c | ||
|
|
81eed370a6 | ||
|
|
cce39f7b1c | ||
|
|
61c2897857 | ||
|
|
b15a9b68e1 | ||
|
|
1f762a9822 | ||
|
|
2974425bef | ||
|
|
9431d0459f | ||
|
|
24ce681c28 | ||
|
|
20bc1cfbb7 | ||
|
|
9a7a7a8b81 | ||
|
|
2f92fa353c | ||
|
|
86e81bf40c | ||
|
|
b3b5ae2eac | ||
|
|
cfcb28d0ac | ||
|
|
150f6a77fb | ||
|
|
62a1c5810d | ||
|
|
bfa8898931 |
22
.github/release-drafter.yml
vendored
Normal file
22
.github/release-drafter.yml
vendored
Normal 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
|
||||||
2
.github/workflows/docker.yml
vendored
2
.github/workflows/docker.yml
vendored
@@ -32,7 +32,7 @@ jobs:
|
|||||||
VERSION=${GITHUB_REF#refs/tags/}
|
VERSION=${GITHUB_REF#refs/tags/}
|
||||||
echo "DOCKER_TAGS=ghcr.io/${REPO_LC}:${VERSION},ghcr.io/${REPO_LC}:latest" >> $GITHUB_ENV
|
echo "DOCKER_TAGS=ghcr.io/${REPO_LC}:${VERSION},ghcr.io/${REPO_LC}:latest" >> $GITHUB_ENV
|
||||||
else
|
else
|
||||||
echo "DOCKER_TAGS=ghcr.io/${REPO_LC}:latest" >> $GITHUB_ENV
|
echo "DOCKER_TAGS=ghcr.io/${REPO_LC}:dev" >> $GITHUB_ENV
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Log in to GitHub Container Registry
|
- name: Log in to GitHub Container Registry
|
||||||
|
|||||||
17
.github/workflows/release-drafter.yml
vendored
Normal file
17
.github/workflows/release-drafter.yml
vendored
Normal 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 }}
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -5,5 +5,5 @@ __pycache__/
|
|||||||
.venv/
|
.venv/
|
||||||
.vscode/
|
.vscode/
|
||||||
data/
|
data/
|
||||||
|
migrate/
|
||||||
.env
|
.env
|
||||||
@@ -82,7 +82,24 @@
|
|||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **启动开发服务器**
|
4. **初始化环境**
|
||||||
|
|
||||||
|
在启动服务前,请进行以下准备:
|
||||||
|
|
||||||
|
- **创建数据目录**:
|
||||||
|
在项目根目录执行 `mkdir -p data/db`。这将创建用于存放数据库等文件的目录。
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> 请确保应用拥有对 `data/db` 目录的读写权限。
|
||||||
|
|
||||||
|
- **创建 `.env` 配置文件**:
|
||||||
|
在项目根目录创建名为 `.env` 的文件,并填入以下内容。这些密钥用于保障应用安全,您可以按需修改。
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
SECRET_KEY=EnsRhL9NFPxgFVc+7t96/y70DIOR+9SpntcIqQa90TU=
|
||||||
|
TEMP_LINK_SECRET_KEY=EnsRhL9NFPxgFVc+7t96/y70DIOR+9SpntcIqQa90TU=
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **启动开发服务器**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
||||||
|
|||||||
@@ -13,11 +13,13 @@ FROM python:3.13-slim
|
|||||||
|
|
||||||
WORKDIR /app
|
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 .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt && pip install gunicorn
|
RUN pip install --no-cache-dir -r requirements.txt && pip install gunicorn
|
||||||
|
|
||||||
|
RUN git clone https://github.com/DrizzleTime/FoxelUpgrade /app/migrate
|
||||||
|
|
||||||
COPY --from=frontend-builder /app/web/dist /app/web/dist
|
COPY --from=frontend-builder /app/web/dist /app/web/dist
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
65
README.md
65
README.md
@@ -1,8 +1,12 @@
|
|||||||
|
<div align="right">
|
||||||
|
<b>English</b> | <a href="./README_zh.md">简体中文</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
# Foxel
|
# Foxel
|
||||||
|
|
||||||
**一个面向个人和团队的、高度可扩展的私有云盘解决方案,支持 AI 语义搜索。**
|
**A highly extensible private cloud storage solution for individuals and teams, featuring AI-powered semantic search.**
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||
@@ -11,26 +15,31 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
<blockquote>
|
<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>
|
<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>
|
</blockquote>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## ✨ 核心功能
|
## 👀 Online Demo
|
||||||
|
|
||||||
- **统一文件管理**:集中管理分布于不同存储后端的文件。
|
> [https://demo.foxel.cc](https://demo.foxel.cc)
|
||||||
- **插件化存储后端**:采用可扩展的适配器模式,方便集成多种存储类型。
|
>
|
||||||
- **语义搜索**:支持自然语言描述搜索图片、文档等非结构化数据内容。
|
> Account/Password: `admin` / `admin`
|
||||||
- **内置文件预览**:可直接预览图片、视频、PDF、Office 文档及文本、代码文件,无需下载。
|
|
||||||
- **权限与分享**:支持公开或私密分享链接,便于文件共享。
|
|
||||||
- **任务处理中心**:支持异步任务处理,如文件索引和数据备份,不影响主应用运行。
|
|
||||||
|
|
||||||
## 🚀 快速开始
|
## ✨ 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. **创建数据目录**:
|
## 🚀 Quick Start
|
||||||
新建 `data` 文件夹用于持久化数据:
|
|
||||||
|
Using Docker Compose is the most recommended way to start Foxel.
|
||||||
|
|
||||||
|
1. **Create Data Directories**:
|
||||||
|
Create a `data` folder for persistent data:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p data/db
|
mkdir -p data/db
|
||||||
@@ -38,40 +47,40 @@ mkdir -p data/mount
|
|||||||
chmod 777 data/db data/mount
|
chmod 777 data/db data/mount
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **下载 Docker Compose 文件**:
|
2. **Download Docker Compose File**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -L -O https://github.com/DrizzleTime/Foxel/raw/main/compose.yaml
|
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
|
```bash
|
||||||
docker-compose up -d
|
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
87
README_zh.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<div align="right">
|
||||||
|
<a href="./README.md">English</a> | <b>简体中文</b>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
# Foxel
|
||||||
|
|
||||||
|
**一个面向个人和团队的、高度可扩展的私有云盘解决方案,支持 AI 语义搜索。**
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
<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**,我们会邀请你加入群聊。
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
from fastapi import FastAPI
|
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
|
||||||
|
|
||||||
|
|
||||||
def include_routers(app: FastAPI):
|
def include_routers(app: FastAPI):
|
||||||
app.include_router(adapters.router)
|
app.include_router(adapters.router)
|
||||||
app.include_router(virtual_fs.router)
|
app.include_router(virtual_fs.router)
|
||||||
app.include_router(search.router)
|
app.include_router(search.router)
|
||||||
app.include_router(mounts.router)
|
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
app.include_router(config.router)
|
app.include_router(config.router)
|
||||||
app.include_router(processors.router)
|
app.include_router(processors.router)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from fastapi import APIRouter, HTTPException, Depends
|
|||||||
from tortoise.transactions import in_transaction
|
from tortoise.transactions import in_transaction
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from models import StorageAdapter, Mount
|
from models import StorageAdapter
|
||||||
from schemas import AdapterCreate, AdapterOut
|
from schemas import AdapterCreate, AdapterOut
|
||||||
from services.auth import get_current_active_user, User
|
from services.auth import get_current_active_user, User
|
||||||
from services.adapters.registry import runtime_registry, get_config_schemas
|
from services.adapters.registry import runtime_registry, get_config_schemas
|
||||||
@@ -39,26 +39,21 @@ async def create_adapter(
|
|||||||
data: AdapterCreate,
|
data: AdapterCreate,
|
||||||
current_user: Annotated[User, Depends(get_current_active_user)]
|
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 = {
|
adapter_fields = {
|
||||||
"name": data.name,
|
"name": data.name,
|
||||||
"type": data.type,
|
"type": data.type,
|
||||||
"config": validate_and_normalize_config(data.type, data.config or {}),
|
"config": validate_and_normalize_config(data.type, data.config or {}),
|
||||||
"enabled": data.enabled,
|
"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)
|
rec = await StorageAdapter.create(**adapter_fields)
|
||||||
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()
|
await runtime_registry.refresh()
|
||||||
await LogService.action(
|
await LogService.action(
|
||||||
"route:adapters",
|
"route:adapters",
|
||||||
@@ -73,20 +68,8 @@ async def create_adapter(
|
|||||||
async def list_adapters(
|
async def list_adapters(
|
||||||
current_user: Annotated[User, Depends(get_current_active_user)]
|
current_user: Annotated[User, Depends(get_current_active_user)]
|
||||||
):
|
):
|
||||||
adapters = await StorageAdapter.all().prefetch_related("mounts")
|
adapters = await StorageAdapter.all()
|
||||||
out = []
|
out = [AdapterOut.model_validate(a) for a in adapters]
|
||||||
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)
|
|
||||||
return success(out)
|
return success(out)
|
||||||
|
|
||||||
|
|
||||||
@@ -109,13 +92,10 @@ async def get_adapter(
|
|||||||
adapter_id: int,
|
adapter_id: int,
|
||||||
current_user: Annotated[User, Depends(get_current_active_user)]
|
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:
|
if not rec:
|
||||||
raise HTTPException(404, detail="Not found")
|
raise HTTPException(404, detail="Not found")
|
||||||
mount = rec.mounts[0] if rec.mounts else None
|
return success(AdapterOut.model_validate(rec))
|
||||||
rec.mount_path = mount.path if mount else None
|
|
||||||
rec.sub_path = mount.sub_path if mount else None
|
|
||||||
return success(rec)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{adapter_id}")
|
@router.put("/{adapter_id}")
|
||||||
@@ -124,33 +104,23 @@ async def update_adapter(
|
|||||||
data: AdapterCreate,
|
data: AdapterCreate,
|
||||||
current_user: Annotated[User, Depends(get_current_active_user)]
|
current_user: Annotated[User, Depends(get_current_active_user)]
|
||||||
):
|
):
|
||||||
|
rec = await StorageAdapter.get_or_none(id=adapter_id)
|
||||||
rec = await StorageAdapter.get_or_none(id=adapter_id).prefetch_related("mounts")
|
|
||||||
if not rec:
|
if not rec:
|
||||||
raise HTTPException(404, detail="Not found")
|
raise HTTPException(404, detail="Not found")
|
||||||
norm_path = AdapterCreate.normalize_mount_path(data.mount_path)
|
|
||||||
existing = await Mount.get_or_none(path=norm_path)
|
norm_path = AdapterCreate.normalize_mount_path(data.path)
|
||||||
mount = rec.mounts[0] if rec.mounts else None
|
existing = await StorageAdapter.get_or_none(path=norm_path)
|
||||||
if existing and (not mount or existing.id != mount.id):
|
if existing and existing.id != adapter_id:
|
||||||
raise HTTPException(400, detail="Mount path already exists")
|
raise HTTPException(400, detail="Mount path already exists")
|
||||||
|
|
||||||
rec.name = data.name
|
rec.name = data.name
|
||||||
rec.type = data.type
|
rec.type = data.type
|
||||||
rec.config = validate_and_normalize_config(data.type, data.config or {})
|
rec.config = validate_and_normalize_config(data.type, data.config or {})
|
||||||
rec.enabled = data.enabled
|
rec.enabled = data.enabled
|
||||||
|
rec.path = norm_path
|
||||||
|
rec.sub_path = data.sub_path
|
||||||
await rec.save()
|
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.refresh()
|
||||||
await LogService.action(
|
await LogService.action(
|
||||||
"route:adapters",
|
"route:adapters",
|
||||||
|
|||||||
@@ -41,7 +41,9 @@ async def get_system_status():
|
|||||||
"version": VERSION,
|
"version": VERSION,
|
||||||
"title": await ConfigCenter.get("APP_NAME", "Foxel"),
|
"title": await ConfigCenter.get("APP_NAME", "Foxel"),
|
||||||
"logo": await ConfigCenter.get("APP_LOGO", "/logo.svg"),
|
"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)
|
return success(system_info)
|
||||||
|
|
||||||
|
|||||||
@@ -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})
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
from fastapi import APIRouter, Depends, Body
|
from fastapi import APIRouter, Depends, Body
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
from services.processors.registry import get_config_schemas
|
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 services.auth import get_current_active_user, User
|
||||||
from api.response import success
|
from api.response import success
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@@ -21,7 +21,7 @@ async def list_processors(
|
|||||||
"name": meta["name"],
|
"name": meta["name"],
|
||||||
"supported_exts": meta.get("supported_exts", []),
|
"supported_exts": meta.get("supported_exts", []),
|
||||||
"config_schema": meta["config_schema"],
|
"config_schema": meta["config_schema"],
|
||||||
"produces_file": meta.get("produces_file", False),
|
"produces_file": meta.get("produces_file", False),
|
||||||
})
|
})
|
||||||
return success(out)
|
return success(out)
|
||||||
|
|
||||||
@@ -40,5 +40,13 @@ async def process_file_with_processor(
|
|||||||
req: ProcessRequest = Body(...)
|
req: ProcessRequest = Body(...)
|
||||||
):
|
):
|
||||||
save_to = req.path if req.overwrite else req.save_to
|
save_to = req.path if req.overwrite else req.save_to
|
||||||
result = await process_file(req.path, req.processor_type, req.config, save_to)
|
task = await task_queue_service.add_task(
|
||||||
return success(result)
|
"process_file",
|
||||||
|
{
|
||||||
|
"path": req.path,
|
||||||
|
"processor_type": req.processor_type,
|
||||||
|
"config": req.config,
|
||||||
|
"save_to": save_to,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return success({"task_id": task.id})
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from schemas.tasks import AutomationTaskCreate, AutomationTaskUpdate
|
|||||||
from api.response import success
|
from api.response import success
|
||||||
from services.auth import get_current_active_user, User
|
from services.auth import get_current_active_user, User
|
||||||
from services.logging import LogService
|
from services.logging import LogService
|
||||||
|
from services.task_queue import task_queue_service
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/api/tasks",
|
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("/")
|
@router.post("/")
|
||||||
async def create_task(
|
async def create_task(
|
||||||
task_in: AutomationTaskCreate,
|
task_in: AutomationTaskCreate,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from services.virtual_fs import (
|
|||||||
from services.thumbnail import is_image_filename, get_or_create_thumb, is_raw_filename
|
from services.thumbnail import is_image_filename, get_or_create_thumb, is_raw_filename
|
||||||
from schemas import MkdirRequest, MoveRequest
|
from schemas import MkdirRequest, MoveRequest
|
||||||
from api.response import success
|
from api.response import success
|
||||||
|
from services.config import ConfigCenter
|
||||||
|
|
||||||
router = APIRouter(prefix='/api/fs', tags=["virtual-fs"])
|
router = APIRouter(prefix='/api/fs', tags=["virtual-fs"])
|
||||||
|
|
||||||
@@ -116,7 +117,7 @@ async def get_thumb(
|
|||||||
if not is_image_filename(rel):
|
if not is_image_filename(rel):
|
||||||
raise HTTPException(404, detail="Not an image")
|
raise HTTPException(404, detail="Not an image")
|
||||||
# type: ignore
|
# 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 = {
|
headers = {
|
||||||
'Cache-Control': 'public, max-age=3600',
|
'Cache-Control': 'public, max-age=3600',
|
||||||
'ETag': key,
|
'ETag': key,
|
||||||
@@ -151,7 +152,13 @@ async def get_temp_link(
|
|||||||
"""获取文件的临时公开访问令牌"""
|
"""获取文件的临时公开访问令牌"""
|
||||||
full_path = '/' + full_path if not full_path.startswith('/') else full_path
|
full_path = '/' + full_path if not full_path.startswith('/') else full_path
|
||||||
token = await generate_temp_link_token(full_path, expires_in=expires_in)
|
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}")
|
@router.get("/public/{token}")
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
|
python migrate/run.py
|
||||||
nginx -g 'daemon off;' &
|
nginx -g 'daemon off;' &
|
||||||
exec gunicorn -k uvicorn.workers.UvicornWorker -w 4 -b 0.0.0.0:8000 main:app
|
exec gunicorn -k uvicorn.workers.UvicornWorker -w 4 -b 0.0.0.0:8000 main:app
|
||||||
22
main.py
22
main.py
@@ -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 dotenv import load_dotenv
|
||||||
|
from services.task_queue import task_queue_service
|
||||||
|
|
||||||
load_dotenv()
|
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
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
await init_db()
|
await init_db()
|
||||||
await runtime_registry.refresh()
|
await runtime_registry.refresh()
|
||||||
|
await ConfigCenter.set("APP_VERSION", VERSION)
|
||||||
|
await task_queue_service.start_worker()
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
|
await task_queue_service.stop_worker()
|
||||||
await close_db()
|
await close_db()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
from .database import StorageAdapter, Mount
|
from .database import StorageAdapter
|
||||||
|
|
||||||
__all__ = ["StorageAdapter", "Mount"]
|
__all__ = ["StorageAdapter"]
|
||||||
|
|||||||
@@ -8,25 +8,13 @@ class StorageAdapter(Model):
|
|||||||
type = fields.CharField(max_length=30)
|
type = fields.CharField(max_length=30)
|
||||||
config = fields.JSONField()
|
config = fields.JSONField()
|
||||||
enabled = fields.BooleanField(default=True)
|
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:
|
class Meta:
|
||||||
table = "storage_adapters"
|
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):
|
class UserAccount(Model):
|
||||||
id = fields.IntField(pk=True)
|
id = fields.IntField(pk=True)
|
||||||
username = fields.CharField(max_length=50, unique=True)
|
username = fields.CharField(max_length=50, unique=True)
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ http {
|
|||||||
listen 80;
|
listen 80;
|
||||||
server_name _;
|
server_name _;
|
||||||
|
|
||||||
location ~ ^/(api|docs) {
|
location ~ ^/(api|docs|openapi\.json$) {
|
||||||
proxy_pass http://127.0.0.1:8000;
|
proxy_pass http://127.0.0.1:8000;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
|||||||
@@ -1,14 +1,27 @@
|
|||||||
|
aioboto3==15.1.0
|
||||||
|
aiobotocore==2.24.0
|
||||||
|
aiofiles==24.1.0
|
||||||
|
aiohappyeyeballs==2.6.1
|
||||||
|
aiohttp==3.12.15
|
||||||
|
aioitertools==0.12.0
|
||||||
|
aiosignal==1.4.0
|
||||||
aiosqlite==0.21.0
|
aiosqlite==0.21.0
|
||||||
annotated-types==0.7.0
|
annotated-types==0.7.0
|
||||||
anyio==4.10.0
|
anyio==4.10.0
|
||||||
|
asyncclick==8.2.2.2
|
||||||
|
attrs==25.3.0
|
||||||
bcrypt==4.3.0
|
bcrypt==4.3.0
|
||||||
|
boto3==1.39.11
|
||||||
|
botocore==1.39.11
|
||||||
certifi==2025.8.3
|
certifi==2025.8.3
|
||||||
click==8.2.1
|
click==8.2.1
|
||||||
|
dictdiffer==0.9.0
|
||||||
dnspython==2.7.0
|
dnspython==2.7.0
|
||||||
email_validator==2.2.0
|
email_validator==2.2.0
|
||||||
fastapi==0.116.1
|
fastapi==0.116.1
|
||||||
fastapi-cli==0.0.8
|
fastapi-cli==0.0.8
|
||||||
fastapi-cloud-cli==0.1.5
|
fastapi-cloud-cli==0.1.5
|
||||||
|
frozenlist==1.7.0
|
||||||
grpcio==1.74.0
|
grpcio==1.74.0
|
||||||
h11==0.16.0
|
h11==0.16.0
|
||||||
httpcore==1.0.9
|
httpcore==1.0.9
|
||||||
@@ -18,14 +31,17 @@ idna==3.10
|
|||||||
imageio==2.37.0
|
imageio==2.37.0
|
||||||
iso8601==2.1.0
|
iso8601==2.1.0
|
||||||
Jinja2==3.1.6
|
Jinja2==3.1.6
|
||||||
|
jmespath==1.0.1
|
||||||
markdown-it-py==4.0.0
|
markdown-it-py==4.0.0
|
||||||
MarkupSafe==3.0.2
|
MarkupSafe==3.0.2
|
||||||
mdurl==0.1.2
|
mdurl==0.1.2
|
||||||
milvus-lite==2.5.1
|
milvus-lite==2.5.1
|
||||||
|
multidict==6.6.4
|
||||||
numpy==2.3.2
|
numpy==2.3.2
|
||||||
pandas==2.3.1
|
pandas==2.3.1
|
||||||
passlib==1.7.4
|
passlib==1.7.4
|
||||||
pillow==11.3.0
|
pillow==11.3.0
|
||||||
|
propcache==0.3.2
|
||||||
protobuf==6.32.0
|
protobuf==6.32.0
|
||||||
pyaes==1.6.1
|
pyaes==1.6.1
|
||||||
pyasn1==0.6.1
|
pyasn1==0.6.1
|
||||||
@@ -46,6 +62,7 @@ rich==14.1.0
|
|||||||
rich-toolkit==0.15.0
|
rich-toolkit==0.15.0
|
||||||
rignore==0.6.4
|
rignore==0.6.4
|
||||||
rsa==4.9.1
|
rsa==4.9.1
|
||||||
|
s3transfer==0.13.1
|
||||||
sentry-sdk==2.35.0
|
sentry-sdk==2.35.0
|
||||||
setuptools==80.9.0
|
setuptools==80.9.0
|
||||||
shellingham==1.5.4
|
shellingham==1.5.4
|
||||||
@@ -65,3 +82,5 @@ uvicorn==0.35.0
|
|||||||
uvloop==0.21.0
|
uvloop==0.21.0
|
||||||
watchfiles==1.1.0
|
watchfiles==1.1.0
|
||||||
websockets==15.0.1
|
websockets==15.0.1
|
||||||
|
wrapt==1.17.3
|
||||||
|
yarl==1.20.1
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
from .adapters import AdapterCreate, AdapterOut
|
from .adapters import AdapterCreate, AdapterOut
|
||||||
from .mounts import MountCreate, MountOut
|
|
||||||
from .fs import MkdirRequest, MoveRequest
|
from .fs import MkdirRequest, MoveRequest
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AdapterCreate",
|
"AdapterCreate",
|
||||||
"AdapterOut",
|
"AdapterOut",
|
||||||
"MountCreate",
|
|
||||||
"MountOut",
|
|
||||||
"MkdirRequest",
|
"MkdirRequest",
|
||||||
"MoveRequest",
|
"MoveRequest",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
from typing import Dict, Optional
|
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
|
name: str
|
||||||
type: str = Field(pattern=r"^[a-zA-Z0-9_]+$")
|
type: str = Field(pattern=r"^[a-zA-Z0-9_]+$")
|
||||||
config: Dict = Field(default_factory=dict)
|
config: Dict = Field(default_factory=dict)
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
mount_path: str
|
path: str = None
|
||||||
sub_path: Optional[str] = None
|
sub_path: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AdapterCreate(AdapterBase):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def normalize_mount_path(p: str) -> str:
|
def normalize_mount_path(p: str) -> str:
|
||||||
p = p.strip()
|
p = p.strip()
|
||||||
@@ -18,15 +20,17 @@ class AdapterCreate(BaseModel):
|
|||||||
p = p.rstrip('/')
|
p = p.rstrip('/')
|
||||||
return p or '/'
|
return p or '/'
|
||||||
|
|
||||||
@validator("mount_path")
|
@field_validator("path")
|
||||||
def _v_mount(cls, v: str):
|
def _v_mount(cls, v: str):
|
||||||
if not v:
|
if not v:
|
||||||
raise ValueError("mount_path required")
|
raise ValueError("mount_path required")
|
||||||
return cls.normalize_mount_path(v)
|
return cls.normalize_mount_path(v)
|
||||||
|
|
||||||
|
|
||||||
class AdapterOut(AdapterCreate):
|
class AdapterOut(AdapterBase):
|
||||||
id: int
|
id: int
|
||||||
|
path: str = None
|
||||||
|
sub_path: Optional[str] = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|||||||
@@ -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
|
|
||||||
431
services/adapters/onedrive.py
Normal file
431
services/adapters/onedrive.py
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from typing import List, Dict, Tuple, AsyncIterator
|
||||||
|
import httpx
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from models import StorageAdapter
|
||||||
|
|
||||||
|
MS_GRAPH_URL = "https://graph.microsoft.com/v1.0"
|
||||||
|
MS_OAUTH_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/token"
|
||||||
|
|
||||||
|
|
||||||
|
class OneDriveAdapter:
|
||||||
|
"""OneDrive 存储适配器"""
|
||||||
|
|
||||||
|
def __init__(self, record: StorageAdapter):
|
||||||
|
self.record = record
|
||||||
|
cfg = record.config
|
||||||
|
self.client_id = cfg.get("client_id")
|
||||||
|
self.client_secret = cfg.get("client_secret")
|
||||||
|
self.refresh_token = cfg.get("refresh_token")
|
||||||
|
self.root = cfg.get("root", "/").strip("/")
|
||||||
|
|
||||||
|
if not all([self.client_id, self.client_secret, self.refresh_token]):
|
||||||
|
raise ValueError(
|
||||||
|
"OneDrive 适配器需要 client_id, client_secret, 和 refresh_token")
|
||||||
|
|
||||||
|
self._access_token: str | None = None
|
||||||
|
self._token_expiry: datetime | None = None
|
||||||
|
|
||||||
|
def get_effective_root(self, sub_path: str | None) -> str:
|
||||||
|
"""
|
||||||
|
获取有效根路径。
|
||||||
|
:param sub_path: 子路径。
|
||||||
|
:return: 完整的有效路径。
|
||||||
|
"""
|
||||||
|
if sub_path:
|
||||||
|
return f"/{self.root.strip('/')}/{sub_path.strip('/')}".strip()
|
||||||
|
return f"/{self.root.strip('/')}".strip()
|
||||||
|
|
||||||
|
def _get_api_path(self, rel_path: str) -> str:
|
||||||
|
"""
|
||||||
|
将用户可见的相对路径转换为 Graph API 路径段。
|
||||||
|
:param rel_path: 相对路径。
|
||||||
|
:return: Graph API 路径段。
|
||||||
|
"""
|
||||||
|
full_path = self.get_effective_root(rel_path).strip('/')
|
||||||
|
if not full_path:
|
||||||
|
return ""
|
||||||
|
return f":/{full_path}"
|
||||||
|
|
||||||
|
async def _get_access_token(self) -> str:
|
||||||
|
"""
|
||||||
|
获取或刷新 access token。
|
||||||
|
:return: access token。
|
||||||
|
"""
|
||||||
|
if self._access_token and self._token_expiry and datetime.now(timezone.utc) < self._token_expiry:
|
||||||
|
return self._access_token
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"client_id": self.client_id,
|
||||||
|
"client_secret": self.client_secret,
|
||||||
|
"refresh_token": self.refresh_token,
|
||||||
|
"grant_type": "refresh_token",
|
||||||
|
}
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.post(MS_OAUTH_URL, data=data)
|
||||||
|
resp.raise_for_status()
|
||||||
|
token_data = resp.json()
|
||||||
|
self._access_token = token_data["access_token"]
|
||||||
|
self._token_expiry = datetime.now(
|
||||||
|
timezone.utc) + timedelta(seconds=token_data["expires_in"] - 300)
|
||||||
|
return self._access_token
|
||||||
|
|
||||||
|
async def _request(self, method: str, api_path_segment: str | None = None, *, full_url: str | None = None, **kwargs):
|
||||||
|
"""
|
||||||
|
向 Microsoft Graph API 发送请求。
|
||||||
|
:param method: HTTP 方法。
|
||||||
|
:param api_path_segment: API 路径段 (与 full_url 互斥)。
|
||||||
|
:param full_url: 完整的请求 URL (与 api_path_segment 互斥)。
|
||||||
|
:param kwargs: 其他请求参数。
|
||||||
|
:return: 响应对象。
|
||||||
|
"""
|
||||||
|
if not ((api_path_segment is not None) ^ (full_url is not None)):
|
||||||
|
raise ValueError("必须提供 api_path_segment 或 full_url 中的一个,且仅一个")
|
||||||
|
|
||||||
|
token = await self._get_access_token()
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
if "headers" in kwargs:
|
||||||
|
headers.update(kwargs.pop("headers"))
|
||||||
|
|
||||||
|
url = full_url if full_url else f"{MS_GRAPH_URL}/me/drive/root{api_path_segment}"
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.request(method, url, headers=headers, **kwargs)
|
||||||
|
# 如果 token 过期 (401),刷新并重试一次
|
||||||
|
if resp.status_code == 401:
|
||||||
|
self._access_token = None # 强制刷新
|
||||||
|
token = await self._get_access_token()
|
||||||
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
|
resp = await client.request(method, url, headers=headers, **kwargs)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
def _format_item(self, item: Dict) -> Dict:
|
||||||
|
"""
|
||||||
|
将 Graph API 返回的 item 格式化为统一的格式。
|
||||||
|
:param item: Graph API 返回的 item 字典。
|
||||||
|
:return: 格式化后的字典。
|
||||||
|
"""
|
||||||
|
is_dir = "folder" in item
|
||||||
|
return {
|
||||||
|
"name": item["name"],
|
||||||
|
"is_dir": is_dir,
|
||||||
|
"size": 0 if is_dir else item.get("size", 0),
|
||||||
|
"mtime": int(datetime.fromisoformat(item["lastModifiedDateTime"].replace("Z", "+00:00")).timestamp()),
|
||||||
|
"type": "dir" if is_dir else "file",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def list_dir(self, root: str, rel: str, page_num: int = 1, page_size: int = 50) -> Tuple[List[Dict], int]:
|
||||||
|
"""
|
||||||
|
列出目录内容。
|
||||||
|
:param root: 根路径 (在此适配器中未使用,通过配置的 root 确定)。
|
||||||
|
:param rel: 相对路径。
|
||||||
|
:param page_num: 页码。
|
||||||
|
:param page_size: 每页大小。
|
||||||
|
:return: 文件/目录列表和总数。
|
||||||
|
"""
|
||||||
|
api_path = self._get_api_path(rel)
|
||||||
|
children_path = f"{api_path}:/children" if api_path else "/children"
|
||||||
|
|
||||||
|
# Graph API 的分页是基于 @odata.nextLink token 的。
|
||||||
|
# 为了支持自定义排序(文件夹在前),我们必须获取所有项目,
|
||||||
|
# 然后在内存中进行排序和分页。此版本通过处理分页链接来稳健地获取所有项目。
|
||||||
|
all_items = []
|
||||||
|
|
||||||
|
# 初始请求
|
||||||
|
resp = await self._request("GET", api_path_segment=children_path, params={"$top": 200})
|
||||||
|
|
||||||
|
while True:
|
||||||
|
if resp.status_code == 404 and not all_items:
|
||||||
|
return [], 0
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = resp.json()
|
||||||
|
except Exception as e:
|
||||||
|
raise IOError(f"解析 Graph API 响应失败: {e}") from e
|
||||||
|
|
||||||
|
all_items.extend(data.get("value", []))
|
||||||
|
next_link = data.get("@odata.nextLink")
|
||||||
|
|
||||||
|
if not next_link:
|
||||||
|
break
|
||||||
|
|
||||||
|
# 后续分页请求
|
||||||
|
resp = await self._request("GET", full_url=next_link)
|
||||||
|
|
||||||
|
formatted_items = [self._format_item(item) for item in all_items]
|
||||||
|
# 排序:文件夹在前,然后按名称排序
|
||||||
|
formatted_items.sort(key=lambda x: (
|
||||||
|
not x["is_dir"], x["name"].lower()))
|
||||||
|
|
||||||
|
total_count = len(formatted_items)
|
||||||
|
start_idx = (page_num - 1) * page_size
|
||||||
|
end_idx = start_idx + page_size
|
||||||
|
|
||||||
|
return formatted_items[start_idx:end_idx], total_count
|
||||||
|
|
||||||
|
async def read_file(self, root: str, rel: str) -> bytes:
|
||||||
|
"""
|
||||||
|
读取文件内容。
|
||||||
|
:param root: 根路径。
|
||||||
|
:param rel: 相对路径。
|
||||||
|
:return: 文件内容的字节流。
|
||||||
|
"""
|
||||||
|
api_path = self._get_api_path(rel)
|
||||||
|
if not api_path:
|
||||||
|
raise IsADirectoryError("不能将根目录作为文件读取")
|
||||||
|
|
||||||
|
resp = await self._request("GET", api_path_segment=f"{api_path}:/content")
|
||||||
|
if resp.status_code == 404:
|
||||||
|
raise FileNotFoundError(rel)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.content
|
||||||
|
|
||||||
|
async def write_file(self, root: str, rel: str, data: bytes):
|
||||||
|
"""
|
||||||
|
写入文件。
|
||||||
|
:param root: 根路径。
|
||||||
|
:param rel: 相对路径。
|
||||||
|
:param data: 文件内容的字节流。
|
||||||
|
"""
|
||||||
|
api_path = self._get_api_path(rel)
|
||||||
|
if not api_path:
|
||||||
|
raise ValueError("不能直接写入根路径")
|
||||||
|
resp = await self._request("PUT", api_path_segment=f"{api_path}:/content", content=data)
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
async def write_file_stream(self, root: str, rel: str, data_iter: AsyncIterator[bytes]):
|
||||||
|
"""
|
||||||
|
以流式方式写入文件。
|
||||||
|
:param root: 根路径。
|
||||||
|
:param rel: 相对路径。
|
||||||
|
:param data_iter: 文件内容的异步迭代器。
|
||||||
|
:return: 文件大小。
|
||||||
|
"""
|
||||||
|
api_path = self._get_api_path(rel)
|
||||||
|
if not api_path:
|
||||||
|
raise ValueError("不能直接写入根路径")
|
||||||
|
|
||||||
|
resp = await self._request("PUT", api_path_segment=f"{api_path}:/content", content=data_iter)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json().get("size", 0)
|
||||||
|
|
||||||
|
async def mkdir(self, root: str, rel: str):
|
||||||
|
"""
|
||||||
|
创建目录。
|
||||||
|
:param root: 根路径。
|
||||||
|
:param rel: 相对路径。
|
||||||
|
"""
|
||||||
|
parent_path_str, new_dir_name = rel.rstrip(
|
||||||
|
'/').rsplit('/', 1) if '/' in rel.rstrip('/') else ('', rel)
|
||||||
|
parent_api_path = self._get_api_path(parent_path_str)
|
||||||
|
|
||||||
|
children_path = f"{parent_api_path}:/children" if parent_api_path else "/children"
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"name": new_dir_name,
|
||||||
|
"folder": {},
|
||||||
|
"@microsoft.graph.conflictBehavior": "fail" # 如果已存在则失败
|
||||||
|
}
|
||||||
|
resp = await self._request("POST", api_path_segment=children_path, json=payload)
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
async def delete(self, root: str, rel: str):
|
||||||
|
"""
|
||||||
|
删除文件或目录。
|
||||||
|
:param root: 根路径。
|
||||||
|
:param rel: 相对路径。
|
||||||
|
"""
|
||||||
|
api_path = self._get_api_path(rel)
|
||||||
|
if not api_path:
|
||||||
|
raise ValueError("不能删除根目录")
|
||||||
|
|
||||||
|
resp = await self._request("DELETE", api_path_segment=api_path)
|
||||||
|
if resp.status_code not in (204, 404):
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
async def move(self, root: str, src_rel: str, dst_rel: str):
|
||||||
|
"""
|
||||||
|
移动或重命名文件/目录。
|
||||||
|
:param root: 根路径。
|
||||||
|
:param src_rel: 源相对路径。
|
||||||
|
:param dst_rel: 目标相对路径。
|
||||||
|
"""
|
||||||
|
src_api_path = self._get_api_path(src_rel)
|
||||||
|
if not src_api_path:
|
||||||
|
raise ValueError("不能移动根目录")
|
||||||
|
|
||||||
|
dst_parent_rel, dst_name = dst_rel.rstrip(
|
||||||
|
'/').rsplit('/', 1) if '/' in dst_rel.rstrip('/') else ('', dst_rel)
|
||||||
|
dst_parent_api_path = self._get_api_path(dst_parent_rel)
|
||||||
|
|
||||||
|
# 获取父项目的 ID
|
||||||
|
parent_resp = await self._request("GET", api_path_segment=dst_parent_api_path)
|
||||||
|
parent_resp.raise_for_status()
|
||||||
|
parent_id = parent_resp.json()["id"]
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"parentReference": {"id": parent_id},
|
||||||
|
"name": dst_name
|
||||||
|
}
|
||||||
|
resp = await self._request("PATCH", api_path_segment=src_api_path, json=payload)
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
async def rename(self, root: str, src_rel: str, dst_rel: str):
|
||||||
|
"""
|
||||||
|
重命名文件或目录。
|
||||||
|
在 Graph API 中,移动和重命名是同一个 PATCH 操作。
|
||||||
|
"""
|
||||||
|
await self.move(root, src_rel, dst_rel)
|
||||||
|
|
||||||
|
async def copy(self, root: str, src_rel: str, dst_rel: str, overwrite: bool = False):
|
||||||
|
"""
|
||||||
|
复制文件或目录。
|
||||||
|
:param root: 根路径。
|
||||||
|
:param src_rel: 源相对路径。
|
||||||
|
:param dst_rel: 目标相对路径。
|
||||||
|
:param overwrite: 是否覆盖 (在此 API 中未直接使用)。
|
||||||
|
"""
|
||||||
|
src_api_path = self._get_api_path(src_rel)
|
||||||
|
if not src_api_path:
|
||||||
|
raise ValueError("不能复制根目录")
|
||||||
|
|
||||||
|
dst_parent_rel, dst_name = dst_rel.rstrip(
|
||||||
|
'/').rsplit('/', 1) if '/' in dst_rel.rstrip('/') else ('', dst_rel)
|
||||||
|
dst_parent_api_path = self._get_api_path(dst_parent_rel)
|
||||||
|
|
||||||
|
parent_resp = await self._request("GET", api_path_segment=dst_parent_api_path)
|
||||||
|
parent_resp.raise_for_status()
|
||||||
|
parent_id = parent_resp.json()["id"]
|
||||||
|
|
||||||
|
payload = {"parentReference": {"id": parent_id}, "name": dst_name}
|
||||||
|
copy_path = f"{src_api_path}:/copy"
|
||||||
|
resp = await self._request("POST", api_path_segment=copy_path, json=payload)
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
async def stream_file(self, root: str, rel: str, range_header: str | None):
|
||||||
|
"""
|
||||||
|
流式传输文件(支持范围请求)。
|
||||||
|
:param root: 根路径。
|
||||||
|
:param rel: 相对路径。
|
||||||
|
:param range_header: HTTP Range 头。
|
||||||
|
:return: FastAPI StreamingResponse 对象。
|
||||||
|
"""
|
||||||
|
api_path = self._get_api_path(rel)
|
||||||
|
if not api_path:
|
||||||
|
raise IsADirectoryError("不能对目录进行流式传输")
|
||||||
|
|
||||||
|
resp = await self._request("GET", api_path_segment=api_path)
|
||||||
|
if resp.status_code == 404:
|
||||||
|
raise FileNotFoundError(rel)
|
||||||
|
resp.raise_for_status()
|
||||||
|
item_data = resp.json()
|
||||||
|
|
||||||
|
download_url = item_data.get("@microsoft.graph.downloadUrl")
|
||||||
|
if not download_url:
|
||||||
|
raise Exception("无法获取下载 URL")
|
||||||
|
|
||||||
|
file_size = item_data.get("size", 0)
|
||||||
|
content_type = item_data.get("file", {}).get(
|
||||||
|
"mimeType", "application/octet-stream")
|
||||||
|
|
||||||
|
start = 0
|
||||||
|
end = file_size - 1
|
||||||
|
status = 200
|
||||||
|
headers = {
|
||||||
|
"Accept-Ranges": "bytes",
|
||||||
|
"Content-Type": content_type,
|
||||||
|
"Content-Disposition": f"inline; filename=\"{item_data.get('name')}\""
|
||||||
|
}
|
||||||
|
|
||||||
|
if range_header and range_header.startswith("bytes="):
|
||||||
|
try:
|
||||||
|
part = range_header.removeprefix("bytes=")
|
||||||
|
s, e = part.split("-", 1)
|
||||||
|
if s.strip():
|
||||||
|
start = int(s)
|
||||||
|
if e.strip():
|
||||||
|
end = int(e)
|
||||||
|
if start >= file_size:
|
||||||
|
raise HTTPException(416, "Requested Range Not Satisfiable")
|
||||||
|
if end >= file_size:
|
||||||
|
end = file_size - 1
|
||||||
|
status = 206
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(400, "Invalid Range header")
|
||||||
|
|
||||||
|
headers["Content-Range"] = f"bytes {start}-{end}/{file_size}"
|
||||||
|
headers["Content-Length"] = str(end - start + 1)
|
||||||
|
else:
|
||||||
|
headers["Content-Length"] = str(file_size)
|
||||||
|
|
||||||
|
async def file_iterator():
|
||||||
|
nonlocal start, end
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
req_headers = {'Range': f'bytes={start}-{end}'}
|
||||||
|
async with client.stream("GET", download_url, headers=req_headers) as stream_resp:
|
||||||
|
stream_resp.raise_for_status()
|
||||||
|
async for chunk in stream_resp.aiter_bytes():
|
||||||
|
yield chunk
|
||||||
|
|
||||||
|
return StreamingResponse(file_iterator(), status_code=status, headers=headers, media_type=content_type)
|
||||||
|
|
||||||
|
async def get_thumbnail(self, root: str, rel: str, size: str = "medium"):
|
||||||
|
"""
|
||||||
|
获取文件的缩略图。
|
||||||
|
:param root: 根路径。
|
||||||
|
:param rel: 相对路径。
|
||||||
|
:param size: 缩略图大小 (large, medium, small)。
|
||||||
|
:return: 缩略图内容的字节流,或在不支持时返回 None。
|
||||||
|
"""
|
||||||
|
api_path = self._get_api_path(rel)
|
||||||
|
if not api_path:
|
||||||
|
return None
|
||||||
|
|
||||||
|
thumb_path = f"{api_path}:/thumbnails/0/{size}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = await self._request("GET", api_path_segment=thumb_path)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
thumb_data = resp.json()
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
thumb_resp = await client.get(thumb_data['url'])
|
||||||
|
thumb_resp.raise_for_status()
|
||||||
|
return thumb_resp.content
|
||||||
|
elif resp.status_code == 404:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
resp.raise_for_status()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def stat_file(self, root: str, rel: str):
|
||||||
|
"""
|
||||||
|
获取文件或目录的元数据。
|
||||||
|
:param root: 根路径。
|
||||||
|
:param rel: 相对路径。
|
||||||
|
:return: 格式化后的文件/目录信息。
|
||||||
|
"""
|
||||||
|
api_path = self._get_api_path(rel)
|
||||||
|
resp = await self._request("GET", api_path_segment=api_path)
|
||||||
|
if resp.status_code == 404:
|
||||||
|
raise FileNotFoundError(rel)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return self._format_item(resp.json())
|
||||||
|
|
||||||
|
|
||||||
|
ADAPTER_TYPE = "OneDrive"
|
||||||
|
|
||||||
|
CONFIG_SCHEMA = [
|
||||||
|
{"key": "client_id", "label": "Client ID", "type": "string", "required": True},
|
||||||
|
{"key": "client_secret", "label": "Client Secret",
|
||||||
|
"type": "password", "required": True},
|
||||||
|
{"key": "refresh_token", "label": "Refresh Token", "type": "password",
|
||||||
|
"required": True, "help_text": "可以通过运行 'python -m services.adapters.onedrive' 获取"},
|
||||||
|
{"key": "root", "label": "根目录 (Root Path)", "type": "string",
|
||||||
|
"required": False, "placeholder": "默认为根目录 /"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def ADAPTER_FACTORY(rec): return OneDriveAdapter(rec)
|
||||||
366
services/adapters/s3.py
Normal file
366
services/adapters/s3.py
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
import asyncio
|
||||||
|
import mimetypes
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Dict, Tuple, AsyncIterator
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
import aioboto3
|
||||||
|
from botocore.exceptions import ClientError
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from models import StorageAdapter
|
||||||
|
from services.logging import LogService
|
||||||
|
|
||||||
|
|
||||||
|
class S3Adapter:
|
||||||
|
"""S3 兼容对象存储适配器"""
|
||||||
|
|
||||||
|
def __init__(self, record: StorageAdapter):
|
||||||
|
self.record = record
|
||||||
|
cfg = record.config
|
||||||
|
self.bucket_name = cfg.get("bucket_name")
|
||||||
|
self.aws_access_key_id = cfg.get("access_key_id")
|
||||||
|
self.aws_secret_access_key = cfg.get("secret_access_key")
|
||||||
|
self.region_name = cfg.get("region_name")
|
||||||
|
self.endpoint_url = cfg.get("endpoint_url")
|
||||||
|
self.root = cfg.get("root", "").strip("/")
|
||||||
|
|
||||||
|
if not all([self.bucket_name, self.aws_access_key_id, self.aws_secret_access_key]):
|
||||||
|
raise ValueError(
|
||||||
|
"S3 适配器需要 bucket_name, access_key_id, 和 secret_access_key")
|
||||||
|
|
||||||
|
self.session = aioboto3.Session(
|
||||||
|
aws_access_key_id=self.aws_access_key_id,
|
||||||
|
aws_secret_access_key=self.aws_secret_access_key,
|
||||||
|
region_name=self.region_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_effective_root(self, sub_path: str | None) -> str:
|
||||||
|
"""获取 S3 中的有效根路径 (key prefix)"""
|
||||||
|
if sub_path:
|
||||||
|
return f"{self.root}/{sub_path.strip('/')}".strip("/")
|
||||||
|
return self.root
|
||||||
|
|
||||||
|
def _get_s3_key(self, rel_path: str) -> str:
|
||||||
|
"""将相对路径转换为 S3 key"""
|
||||||
|
rel_path = rel_path.strip("/")
|
||||||
|
if self.root:
|
||||||
|
return f"{self.root}/{rel_path}"
|
||||||
|
return rel_path
|
||||||
|
|
||||||
|
def _get_client(self):
|
||||||
|
return self.session.client("s3", endpoint_url=self.endpoint_url)
|
||||||
|
|
||||||
|
async def list_dir(self, root: str, rel: str, page_num: int = 1, page_size: int = 50) -> Tuple[List[Dict], int]:
|
||||||
|
prefix = self._get_s3_key(rel)
|
||||||
|
if prefix and not prefix.endswith("/"):
|
||||||
|
prefix += "/"
|
||||||
|
|
||||||
|
all_items = []
|
||||||
|
|
||||||
|
async with self._get_client() as s3:
|
||||||
|
paginator = s3.get_paginator("list_objects_v2")
|
||||||
|
async for result in paginator.paginate(Bucket=self.bucket_name, Prefix=prefix, Delimiter="/"):
|
||||||
|
# 添加子目录
|
||||||
|
for common_prefix in result.get("CommonPrefixes", []):
|
||||||
|
dir_name = common_prefix.get(
|
||||||
|
"Prefix").removeprefix(prefix).strip("/")
|
||||||
|
if dir_name:
|
||||||
|
all_items.append({
|
||||||
|
"name": dir_name,
|
||||||
|
"is_dir": True,
|
||||||
|
"size": 0,
|
||||||
|
"mtime": 0,
|
||||||
|
"type": "dir",
|
||||||
|
})
|
||||||
|
|
||||||
|
# 添加文件
|
||||||
|
for content in result.get("Contents", []):
|
||||||
|
file_key = content.get("Key")
|
||||||
|
if file_key == prefix: # 忽略目录本身
|
||||||
|
continue
|
||||||
|
file_name = file_key.removeprefix(prefix)
|
||||||
|
if file_name:
|
||||||
|
all_items.append({
|
||||||
|
"name": file_name,
|
||||||
|
"is_dir": False,
|
||||||
|
"size": content.get("Size", 0),
|
||||||
|
"mtime": int(content.get("LastModified", datetime.now()).timestamp()),
|
||||||
|
"type": "file",
|
||||||
|
})
|
||||||
|
|
||||||
|
# 在内存中排序和分页
|
||||||
|
all_items.sort(key=lambda x: (not x["is_dir"], x["name"].lower()))
|
||||||
|
total_count = len(all_items)
|
||||||
|
start_idx = (page_num - 1) * page_size
|
||||||
|
end_idx = start_idx + page_size
|
||||||
|
|
||||||
|
return all_items[start_idx:end_idx], total_count
|
||||||
|
|
||||||
|
async def read_file(self, root: str, rel: str) -> bytes:
|
||||||
|
key = self._get_s3_key(rel)
|
||||||
|
async with self._get_client() as s3:
|
||||||
|
try:
|
||||||
|
resp = await s3.get_object(Bucket=self.bucket_name, Key=key)
|
||||||
|
return await resp["Body"].read()
|
||||||
|
except ClientError as e:
|
||||||
|
if e.response["Error"]["Code"] == "NoSuchKey":
|
||||||
|
raise FileNotFoundError(rel)
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def write_file(self, root: str, rel: str, data: bytes):
|
||||||
|
key = self._get_s3_key(rel)
|
||||||
|
async with self._get_client() as s3:
|
||||||
|
await s3.put_object(Bucket=self.bucket_name, Key=key, Body=data)
|
||||||
|
await LogService.info(
|
||||||
|
"adapter:s3", f"Wrote file to {rel}",
|
||||||
|
details={"adapter_id": self.record.id,
|
||||||
|
"bucket": self.bucket_name, "key": key, "size": len(data)}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def write_file_stream(self, root: str, rel: str, data_iter: AsyncIterator[bytes]):
|
||||||
|
key = self._get_s3_key(rel)
|
||||||
|
MIN_PART_SIZE = 5 * 1024 * 1024
|
||||||
|
|
||||||
|
async with self._get_client() as s3:
|
||||||
|
mpu = await s3.create_multipart_upload(Bucket=self.bucket_name, Key=key)
|
||||||
|
upload_id = mpu['UploadId']
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
part_number = 1
|
||||||
|
total_size = 0
|
||||||
|
buffer = bytearray()
|
||||||
|
|
||||||
|
try:
|
||||||
|
async for chunk in data_iter:
|
||||||
|
if not chunk:
|
||||||
|
continue
|
||||||
|
buffer.extend(chunk)
|
||||||
|
|
||||||
|
while len(buffer) >= MIN_PART_SIZE:
|
||||||
|
part_data = buffer[:MIN_PART_SIZE]
|
||||||
|
del buffer[:MIN_PART_SIZE]
|
||||||
|
|
||||||
|
part = await s3.upload_part(
|
||||||
|
Bucket=self.bucket_name,
|
||||||
|
Key=key,
|
||||||
|
PartNumber=part_number,
|
||||||
|
UploadId=upload_id,
|
||||||
|
Body=part_data
|
||||||
|
)
|
||||||
|
|
||||||
|
parts.append({'PartNumber': part_number, 'ETag': part['ETag']})
|
||||||
|
total_size += len(part_data)
|
||||||
|
part_number += 1
|
||||||
|
|
||||||
|
if buffer:
|
||||||
|
part = await s3.upload_part(
|
||||||
|
Bucket=self.bucket_name,
|
||||||
|
Key=key,
|
||||||
|
PartNumber=part_number,
|
||||||
|
UploadId=upload_id,
|
||||||
|
Body=bytes(buffer)
|
||||||
|
)
|
||||||
|
parts.append({'PartNumber': part_number, 'ETag': part['ETag']})
|
||||||
|
total_size += len(buffer)
|
||||||
|
|
||||||
|
await s3.complete_multipart_upload(
|
||||||
|
Bucket=self.bucket_name,
|
||||||
|
Key=key,
|
||||||
|
UploadId=upload_id,
|
||||||
|
MultipartUpload={'Parts': parts}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
await s3.abort_multipart_upload(
|
||||||
|
Bucket=self.bucket_name,
|
||||||
|
Key=key,
|
||||||
|
UploadId=upload_id
|
||||||
|
)
|
||||||
|
raise IOError(f"S3 stream upload failed: {e}") from e
|
||||||
|
|
||||||
|
await LogService.info(
|
||||||
|
"adapter:s3", f"Wrote file stream to {rel}",
|
||||||
|
details={"adapter_id": self.record.id, "bucket": self.bucket_name, "key": key, "size": total_size}
|
||||||
|
)
|
||||||
|
return total_size
|
||||||
|
|
||||||
|
async def mkdir(self, root: str, rel: str):
|
||||||
|
key = self._get_s3_key(rel)
|
||||||
|
if not key.endswith("/"):
|
||||||
|
key += "/"
|
||||||
|
async with self._get_client() as s3:
|
||||||
|
await s3.put_object(Bucket=self.bucket_name, Key=key, Body=b"")
|
||||||
|
await LogService.info(
|
||||||
|
"adapter:s3", f"Created directory {rel}",
|
||||||
|
details={"adapter_id": self.record.id,
|
||||||
|
"bucket": self.bucket_name, "key": key}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def delete(self, root: str, rel: str):
|
||||||
|
key = self._get_s3_key(rel)
|
||||||
|
async with self._get_client() as s3:
|
||||||
|
is_dir_like = False
|
||||||
|
try:
|
||||||
|
head = await s3.head_object(Bucket=self.bucket_name, Key=key)
|
||||||
|
if head['ContentLength'] == 0 and key.endswith('/'):
|
||||||
|
is_dir_like = True
|
||||||
|
except ClientError as e:
|
||||||
|
if e.response['Error']['Code'] != '404':
|
||||||
|
raise
|
||||||
|
|
||||||
|
# 如果是目录,删除目录下的所有对象
|
||||||
|
if is_dir_like or not await self.stat_file(root, rel):
|
||||||
|
dir_key = key if key.endswith('/') else key + '/'
|
||||||
|
paginator = s3.get_paginator("list_objects_v2")
|
||||||
|
objects_to_delete = []
|
||||||
|
async for result in paginator.paginate(Bucket=self.bucket_name, Prefix=dir_key):
|
||||||
|
for content in result.get("Contents", []):
|
||||||
|
objects_to_delete.append({"Key": content["Key"]})
|
||||||
|
if objects_to_delete:
|
||||||
|
await s3.delete_objects(Bucket=self.bucket_name, Delete={"Objects": objects_to_delete})
|
||||||
|
# 如果是文件,直接删除
|
||||||
|
else:
|
||||||
|
await s3.delete_object(Bucket=self.bucket_name, Key=key)
|
||||||
|
|
||||||
|
await LogService.info(
|
||||||
|
"adapter:s3", f"Deleted {rel}",
|
||||||
|
details={"adapter_id": self.record.id,
|
||||||
|
"bucket": self.bucket_name, "key": key}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def move(self, root: str, src_rel: str, dst_rel: str):
|
||||||
|
await self.copy(root, src_rel, dst_rel, overwrite=True)
|
||||||
|
await self.delete(root, src_rel)
|
||||||
|
await LogService.info(
|
||||||
|
"adapter:s3", f"Moved {src_rel} to {dst_rel}",
|
||||||
|
details={"adapter_id": self.record.id, "bucket": self.bucket_name,
|
||||||
|
"src_key": self._get_s3_key(src_rel), "dst_key": self._get_s3_key(dst_rel)}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def rename(self, root: str, src_rel: str, dst_rel: str):
|
||||||
|
await self.move(root, src_rel, dst_rel)
|
||||||
|
|
||||||
|
async def copy(self, root: str, src_rel: str, dst_rel: str, overwrite: bool = False):
|
||||||
|
src_key = self._get_s3_key(src_rel)
|
||||||
|
dst_key = self._get_s3_key(dst_rel)
|
||||||
|
|
||||||
|
async with self._get_client() as s3:
|
||||||
|
if not overwrite:
|
||||||
|
try:
|
||||||
|
await s3.head_object(Bucket=self.bucket_name, Key=dst_key)
|
||||||
|
raise FileExistsError(dst_rel)
|
||||||
|
except ClientError as e:
|
||||||
|
if e.response["Error"]["Code"] != "404":
|
||||||
|
raise
|
||||||
|
|
||||||
|
copy_source = {"Bucket": self.bucket_name, "Key": src_key}
|
||||||
|
await s3.copy_object(CopySource=copy_source, Bucket=self.bucket_name, Key=dst_key)
|
||||||
|
await LogService.info(
|
||||||
|
"adapter:s3", f"Copied {src_rel} to {dst_rel}",
|
||||||
|
details={"adapter_id": self.record.id, "bucket": self.bucket_name,
|
||||||
|
"src_key": src_key, "dst_key": dst_key}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def stat_file(self, root: str, rel: str):
|
||||||
|
key = self._get_s3_key(rel)
|
||||||
|
async with self._get_client() as s3:
|
||||||
|
try:
|
||||||
|
head = await s3.head_object(Bucket=self.bucket_name, Key=key)
|
||||||
|
return {
|
||||||
|
"name": rel.split("/")[-1],
|
||||||
|
"is_dir": False,
|
||||||
|
"size": head["ContentLength"],
|
||||||
|
"mtime": int(head["LastModified"].timestamp()),
|
||||||
|
"type": "file",
|
||||||
|
}
|
||||||
|
except ClientError as e:
|
||||||
|
if e.response["Error"]["Code"] == "404":
|
||||||
|
# 检查是否为一个 "目录"
|
||||||
|
dir_key = key if key.endswith('/') else key + '/'
|
||||||
|
resp = await s3.list_objects_v2(Bucket=self.bucket_name, Prefix=dir_key, MaxKeys=1)
|
||||||
|
if resp.get('KeyCount', 0) > 0:
|
||||||
|
return {
|
||||||
|
"name": rel.split("/")[-1],
|
||||||
|
"is_dir": True,
|
||||||
|
"size": 0,
|
||||||
|
"mtime": 0,
|
||||||
|
"type": "dir",
|
||||||
|
}
|
||||||
|
raise FileNotFoundError(rel)
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def stream_file(self, root: str, rel: str, range_header: str | None):
|
||||||
|
key = self._get_s3_key(rel)
|
||||||
|
async with self._get_client() as s3:
|
||||||
|
try:
|
||||||
|
head = await s3.head_object(Bucket=self.bucket_name, Key=key)
|
||||||
|
file_size = head["ContentLength"]
|
||||||
|
content_type = head.get("ContentType", mimetypes.guess_type(key)[
|
||||||
|
0] or "application/octet-stream")
|
||||||
|
except ClientError as e:
|
||||||
|
if e.response["Error"]["Code"] == "404":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404, detail="File not found")
|
||||||
|
raise
|
||||||
|
|
||||||
|
start = 0
|
||||||
|
end = file_size - 1
|
||||||
|
status = 200
|
||||||
|
headers = {
|
||||||
|
"Accept-Ranges": "bytes",
|
||||||
|
"Content-Type": content_type,
|
||||||
|
"Content-Length": str(file_size),
|
||||||
|
"Content-Disposition": f"inline; filename=\"{quote(rel.split('/')[-1])}\""
|
||||||
|
}
|
||||||
|
|
||||||
|
if range_header:
|
||||||
|
range_val = range_header.strip().partition("=")[2]
|
||||||
|
s, _, e = range_val.partition("-")
|
||||||
|
try:
|
||||||
|
start = int(s) if s else 0
|
||||||
|
end = int(e) if e else file_size - 1
|
||||||
|
if start >= file_size or end >= file_size or start > end:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=416, detail="Requested Range Not Satisfiable")
|
||||||
|
status = 206
|
||||||
|
headers["Content-Length"] = str(end - start + 1)
|
||||||
|
headers["Content-Range"] = f"bytes {start}-{end}/{file_size}"
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail="Invalid Range header")
|
||||||
|
|
||||||
|
range_arg = f"bytes={start}-{end}"
|
||||||
|
|
||||||
|
async def iterator():
|
||||||
|
try:
|
||||||
|
resp = await s3.get_object(Bucket=self.bucket_name, Key=key, Range=range_arg)
|
||||||
|
body = resp["Body"]
|
||||||
|
while chunk := await body.read(65536):
|
||||||
|
yield chunk
|
||||||
|
except Exception as e:
|
||||||
|
LogService.error(
|
||||||
|
"adapter:s3", f"Error streaming file {key}: {e}")
|
||||||
|
|
||||||
|
return StreamingResponse(iterator(), status_code=status, headers=headers, media_type=content_type)
|
||||||
|
|
||||||
|
|
||||||
|
ADAPTER_TYPE = "S3"
|
||||||
|
|
||||||
|
CONFIG_SCHEMA = [
|
||||||
|
{"key": "bucket_name", "label": "Bucket 名称",
|
||||||
|
"type": "string", "required": True},
|
||||||
|
{"key": "access_key_id", "label": "Access Key ID",
|
||||||
|
"type": "string", "required": True},
|
||||||
|
{"key": "secret_access_key", "label": "Secret Access Key",
|
||||||
|
"type": "password", "required": True},
|
||||||
|
{"key": "region_name", "label": "区域 (Region)", "type": "string",
|
||||||
|
"required": False, "placeholder": "例如 us-east-1"},
|
||||||
|
{"key": "endpoint_url", "label": "Endpoint URL", "type": "string",
|
||||||
|
"required": False, "placeholder": "对于 S3 兼容存储, 例如 https://minio.example.com"},
|
||||||
|
{"key": "root", "label": "根路径 (Root Path)", "type": "string",
|
||||||
|
"required": False, "placeholder": "在 bucket 内的路径前缀"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def ADAPTER_FACTORY(rec): return S3Adapter(rec)
|
||||||
319
services/adapters/telegram.py
Normal file
319
services/adapters/telegram.py
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from typing import List, Dict, Tuple, AsyncIterator
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
from models import StorageAdapter
|
||||||
|
from telethon import TelegramClient
|
||||||
|
from telethon.sessions import StringSession
|
||||||
|
import socks
|
||||||
|
|
||||||
|
# 适配器类型标识
|
||||||
|
ADAPTER_TYPE = "Telegram"
|
||||||
|
|
||||||
|
# 适配器配置项定义
|
||||||
|
CONFIG_SCHEMA = [
|
||||||
|
{"key": "api_id", "label": "API ID", "type": "string", "required": True, "help_text": "从 my.telegram.org 获取"},
|
||||||
|
{"key": "api_hash", "label": "API Hash", "type": "password", "required": True, "help_text": "从 my.telegram.org 获取"},
|
||||||
|
{"key": "session_string", "label": "Session String", "type": "password", "required": True, "help_text": "通过 generate_session.py 生成"},
|
||||||
|
{"key": "chat_id", "label": "Chat ID", "type": "string", "required": True, "placeholder": "频道/群组的ID或用户名, 例如: -100123456789 或 'channel_username'"},
|
||||||
|
{"key": "proxy_protocol", "label": "代理协议", "type": "string", "required": False, "placeholder": "例如: socks5, http"},
|
||||||
|
{"key": "proxy_host", "label": "代理主机", "type": "string", "required": False, "placeholder": "例如: 127.0.0.1"},
|
||||||
|
{"key": "proxy_port", "label": "代理端口", "type": "number", "required": False, "placeholder": "例如: 1080"},
|
||||||
|
]
|
||||||
|
|
||||||
|
class TelegramAdapter:
|
||||||
|
"""Telegram 存储适配器 (使用用户 Session)"""
|
||||||
|
|
||||||
|
def __init__(self, record: StorageAdapter):
|
||||||
|
self.record = record
|
||||||
|
cfg = record.config
|
||||||
|
self.api_id = int(cfg.get("api_id"))
|
||||||
|
self.api_hash = cfg.get("api_hash")
|
||||||
|
self.session_string = cfg.get("session_string")
|
||||||
|
self.chat_id_str = cfg.get("chat_id")
|
||||||
|
|
||||||
|
# 代理设置
|
||||||
|
self.proxy_protocol = cfg.get("proxy_protocol")
|
||||||
|
self.proxy_host = cfg.get("proxy_host")
|
||||||
|
self.proxy_port = cfg.get("proxy_port")
|
||||||
|
|
||||||
|
self.proxy = None
|
||||||
|
if self.proxy_protocol and self.proxy_host and self.proxy_port:
|
||||||
|
proto_map = {
|
||||||
|
"socks5": socks.SOCKS5,
|
||||||
|
"http": socks.HTTP,
|
||||||
|
}
|
||||||
|
proxy_type = proto_map.get(self.proxy_protocol.lower())
|
||||||
|
if proxy_type:
|
||||||
|
self.proxy = (proxy_type, self.proxy_host, int(self.proxy_port))
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.chat_id = int(self.chat_id_str)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
self.chat_id = self.chat_id_str
|
||||||
|
|
||||||
|
if not all([self.api_id, self.api_hash, self.session_string, self.chat_id]):
|
||||||
|
raise ValueError("Telegram 适配器需要 api_id, api_hash, session_string 和 chat_id")
|
||||||
|
|
||||||
|
def _get_client(self) -> TelegramClient:
|
||||||
|
"""创建一个新的 TelegramClient 实例"""
|
||||||
|
return TelegramClient(StringSession(self.session_string), self.api_id, self.api_hash, proxy=self.proxy)
|
||||||
|
|
||||||
|
def get_effective_root(self, sub_path: str | None) -> str:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
async def list_dir(self, root: str, rel: str, page_num: int = 1, page_size: int = 50) -> Tuple[List[Dict], int]:
|
||||||
|
if rel:
|
||||||
|
return [], 0
|
||||||
|
|
||||||
|
client = self._get_client()
|
||||||
|
entries = []
|
||||||
|
try:
|
||||||
|
await client.connect()
|
||||||
|
messages = await client.get_messages(self.chat_id, limit=50)
|
||||||
|
for message in messages:
|
||||||
|
if not message:
|
||||||
|
continue
|
||||||
|
|
||||||
|
media = message.document or message.video or message.photo
|
||||||
|
if not media:
|
||||||
|
continue
|
||||||
|
|
||||||
|
filename = None
|
||||||
|
size = 0
|
||||||
|
|
||||||
|
if message.photo:
|
||||||
|
photo_size = message.photo.sizes[-1]
|
||||||
|
size = photo_size.size if hasattr(photo_size, 'size') else 0
|
||||||
|
filename = f"photo_{message.id}.jpg"
|
||||||
|
|
||||||
|
elif message.document or message.video:
|
||||||
|
size = media.size
|
||||||
|
if hasattr(media, 'attributes'):
|
||||||
|
for attr in media.attributes:
|
||||||
|
if hasattr(attr, 'file_name') and attr.file_name:
|
||||||
|
filename = attr.file_name
|
||||||
|
break
|
||||||
|
|
||||||
|
if not filename:
|
||||||
|
if message.text and '.' in message.text and len(message.text) < 256 and '\n' not in message.text:
|
||||||
|
filename = message.text
|
||||||
|
|
||||||
|
if not filename:
|
||||||
|
filename = f"unknown_{message.id}"
|
||||||
|
|
||||||
|
entries.append({
|
||||||
|
"name": f"{message.id}_{filename}",
|
||||||
|
"is_dir": False,
|
||||||
|
"size": size,
|
||||||
|
"mtime": int(message.date.timestamp()),
|
||||||
|
"type": "file",
|
||||||
|
})
|
||||||
|
finally:
|
||||||
|
if client.is_connected():
|
||||||
|
await client.disconnect()
|
||||||
|
|
||||||
|
return entries, len(entries)
|
||||||
|
|
||||||
|
async def read_file(self, root: str, rel: str) -> bytes:
|
||||||
|
try:
|
||||||
|
message_id_str, _ = rel.split('_', 1)
|
||||||
|
message_id = int(message_id_str)
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
raise FileNotFoundError(f"无效的文件路径格式: {rel}")
|
||||||
|
|
||||||
|
client = self._get_client()
|
||||||
|
try:
|
||||||
|
await client.connect()
|
||||||
|
message = await client.get_messages(self.chat_id, ids=message_id)
|
||||||
|
if not message or not (message.document or message.video or message.photo):
|
||||||
|
raise FileNotFoundError(f"在频道 {self.chat_id} 中未找到消息ID为 {message_id} 的文件")
|
||||||
|
|
||||||
|
file_bytes = await client.download_media(message, file=bytes)
|
||||||
|
return file_bytes
|
||||||
|
finally:
|
||||||
|
if client.is_connected():
|
||||||
|
await client.disconnect()
|
||||||
|
|
||||||
|
async def write_file(self, root: str, rel: str, data: bytes):
|
||||||
|
"""将字节数据作为文件上传"""
|
||||||
|
client = self._get_client()
|
||||||
|
file_like = io.BytesIO(data)
|
||||||
|
file_like.name = os.path.basename(rel) or "file"
|
||||||
|
|
||||||
|
try:
|
||||||
|
await client.connect()
|
||||||
|
await client.send_file(self.chat_id, file_like, caption=file_like.name)
|
||||||
|
finally:
|
||||||
|
if client.is_connected():
|
||||||
|
await client.disconnect()
|
||||||
|
|
||||||
|
async def write_file_stream(self, root: str, rel: str, data_iter: AsyncIterator[bytes]):
|
||||||
|
"""以流式方式上传文件"""
|
||||||
|
client = self._get_client()
|
||||||
|
filename = os.path.basename(rel) or "file"
|
||||||
|
import tempfile
|
||||||
|
temp_dir = tempfile.gettempdir()
|
||||||
|
temp_path = os.path.join(temp_dir, filename)
|
||||||
|
|
||||||
|
total_size = 0
|
||||||
|
try:
|
||||||
|
with open(temp_path, "wb") as f:
|
||||||
|
async for chunk in data_iter:
|
||||||
|
if chunk:
|
||||||
|
f.write(chunk)
|
||||||
|
total_size += len(chunk)
|
||||||
|
|
||||||
|
await client.connect()
|
||||||
|
await client.send_file(self.chat_id, temp_path, caption=filename)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if os.path.exists(temp_path):
|
||||||
|
os.remove(temp_path)
|
||||||
|
if client.is_connected():
|
||||||
|
await client.disconnect()
|
||||||
|
return total_size
|
||||||
|
|
||||||
|
async def mkdir(self, root: str, rel: str):
|
||||||
|
raise NotImplementedError("Telegram 适配器不支持创建目录。")
|
||||||
|
|
||||||
|
async def delete(self, root: str, rel: str):
|
||||||
|
"""删除一个文件 (即一条消息)"""
|
||||||
|
try:
|
||||||
|
message_id_str, _ = rel.split('_', 1)
|
||||||
|
message_id = int(message_id_str)
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
raise FileNotFoundError(f"无效的文件路径格式,无法解析消息ID: {rel}")
|
||||||
|
|
||||||
|
client = self._get_client()
|
||||||
|
try:
|
||||||
|
await client.connect()
|
||||||
|
result = await client.delete_messages(self.chat_id, [message_id])
|
||||||
|
if not result or not result[0].pts:
|
||||||
|
raise FileNotFoundError(f"在 {self.chat_id} 中删除消息 {message_id} 失败,可能消息不存在或无权限")
|
||||||
|
finally:
|
||||||
|
if client.is_connected():
|
||||||
|
await client.disconnect()
|
||||||
|
|
||||||
|
async def move(self, root: str, src_rel: str, dst_rel: str):
|
||||||
|
raise NotImplementedError("Telegram 适配器不支持移动。")
|
||||||
|
|
||||||
|
async def rename(self, root: str, src_rel: str, dst_rel: str):
|
||||||
|
raise NotImplementedError("Telegram 适配器不支持重命名。")
|
||||||
|
|
||||||
|
async def copy(self, root: str, src_rel: str, dst_rel: str, overwrite: bool = False):
|
||||||
|
raise NotImplementedError("Telegram 适配器不支持复制。")
|
||||||
|
|
||||||
|
async def stream_file(self, root: str, rel: str, range_header: str | None):
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
try:
|
||||||
|
message_id_str, _ = rel.split('_', 1)
|
||||||
|
message_id = int(message_id_str)
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
raise HTTPException(status_code=400, detail=f"无效的文件路径格式: {rel}")
|
||||||
|
|
||||||
|
client = self._get_client()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await client.connect()
|
||||||
|
message = await client.get_messages(self.chat_id, ids=message_id)
|
||||||
|
media = message.document or message.video or message.photo
|
||||||
|
if not message or not media:
|
||||||
|
raise FileNotFoundError(f"在频道 {self.chat_id} 中未找到消息ID为 {message_id} 的文件")
|
||||||
|
|
||||||
|
if message.photo:
|
||||||
|
photo_size = media.sizes[-1]
|
||||||
|
file_size = photo_size.size if hasattr(photo_size, 'size') else 0
|
||||||
|
mime_type = "image/jpeg"
|
||||||
|
else:
|
||||||
|
file_size = media.size
|
||||||
|
mime_type = media.mime_type or "application/octet-stream"
|
||||||
|
|
||||||
|
start = 0
|
||||||
|
end = file_size - 1
|
||||||
|
status = 200
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Accept-Ranges": "bytes",
|
||||||
|
"Content-Type": mime_type,
|
||||||
|
"Content-Length": str(file_size),
|
||||||
|
}
|
||||||
|
|
||||||
|
if range_header:
|
||||||
|
try:
|
||||||
|
range_val = range_header.strip().partition("=")[2]
|
||||||
|
s, _, e = range_val.partition("-")
|
||||||
|
start = int(s) if s else 0
|
||||||
|
end = int(e) if e else file_size - 1
|
||||||
|
if start >= file_size or end >= file_size or start > end:
|
||||||
|
raise HTTPException(status_code=416, detail="Requested Range Not Satisfiable")
|
||||||
|
status = 206
|
||||||
|
headers["Content-Length"] = str(end - start + 1)
|
||||||
|
headers["Content-Range"] = f"bytes {start}-{end}/{file_size}"
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid Range header")
|
||||||
|
|
||||||
|
async def iterator():
|
||||||
|
try:
|
||||||
|
limit = end - start + 1
|
||||||
|
downloaded = 0
|
||||||
|
|
||||||
|
async for chunk in client.iter_download(media, offset=start):
|
||||||
|
if downloaded + len(chunk) > limit:
|
||||||
|
yield chunk[:limit - downloaded]
|
||||||
|
break
|
||||||
|
yield chunk
|
||||||
|
downloaded += len(chunk)
|
||||||
|
if downloaded >= limit:
|
||||||
|
break
|
||||||
|
finally:
|
||||||
|
if client.is_connected():
|
||||||
|
await client.disconnect()
|
||||||
|
|
||||||
|
return StreamingResponse(iterator(), status_code=status, headers=headers)
|
||||||
|
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
if client.is_connected():
|
||||||
|
await client.disconnect()
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
if client.is_connected():
|
||||||
|
await client.disconnect()
|
||||||
|
raise HTTPException(status_code=500, detail=f"Streaming failed: {str(e)}")
|
||||||
|
|
||||||
|
async def stat_file(self, root: str, rel: str):
|
||||||
|
try:
|
||||||
|
message_id_str, filename = rel.split('_', 1)
|
||||||
|
message_id = int(message_id_str)
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
raise FileNotFoundError(f"无效的文件路径格式: {rel}")
|
||||||
|
|
||||||
|
client = self._get_client()
|
||||||
|
try:
|
||||||
|
await client.connect()
|
||||||
|
message = await client.get_messages(self.chat_id, ids=message_id)
|
||||||
|
media = message.document or message.video or message.photo
|
||||||
|
if not message or not media:
|
||||||
|
raise FileNotFoundError(f"在频道 {self.chat_id} 中未找到消息ID为 {message_id} 的文件")
|
||||||
|
|
||||||
|
if message.photo:
|
||||||
|
photo_size = media.sizes[-1]
|
||||||
|
size = photo_size.size if hasattr(photo_size, 'size') else 0
|
||||||
|
else:
|
||||||
|
size = media.size
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": rel,
|
||||||
|
"is_dir": False,
|
||||||
|
"size": size,
|
||||||
|
"mtime": int(message.date.timestamp()),
|
||||||
|
"type": "file",
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
if client.is_connected():
|
||||||
|
await client.disconnect()
|
||||||
|
|
||||||
|
def ADAPTER_FACTORY(rec: StorageAdapter) -> TelegramAdapter:
|
||||||
|
return TelegramAdapter(rec)
|
||||||
@@ -2,13 +2,14 @@ import httpx
|
|||||||
from typing import List
|
from typing import List
|
||||||
from services.config import ConfigCenter
|
from services.config import ConfigCenter
|
||||||
|
|
||||||
|
|
||||||
async def describe_image_base64(base64_image: str, detail: str = "high") -> str:
|
async def describe_image_base64(base64_image: str, detail: str = "high") -> str:
|
||||||
"""
|
"""
|
||||||
传入base64图片和文本提示,返回图片描述文本。
|
传入base64图片和文本提示,返回图片描述文本。
|
||||||
"""
|
"""
|
||||||
OAI_API_URL = await ConfigCenter.get("AI_API_URL", "https://api.siliconflow.cn/v1/chat/completions")
|
OAI_API_URL = await ConfigCenter.get("AI_VISION_API_URL")
|
||||||
VISION_MODEL = await ConfigCenter.get("AI_VISION_MODEL", "Qwen/Qwen2.5-VL-32B-Instruct")
|
VISION_MODEL = await ConfigCenter.get("AI_VISION_MODEL")
|
||||||
API_KEY = await ConfigCenter.get("AI_API_KEY", "")
|
API_KEY = await ConfigCenter.get("AI_VISION_API_KEY")
|
||||||
payload = {
|
payload = {
|
||||||
"model": VISION_MODEL,
|
"model": VISION_MODEL,
|
||||||
"messages": [
|
"messages": [
|
||||||
@@ -42,13 +43,14 @@ async def describe_image_base64(base64_image: str, detail: str = "high") -> str:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"请求失败: {str(e)}"
|
return f"请求失败: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
async def get_text_embedding(text: str) -> List[float]:
|
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")
|
OAI_API_URL = await ConfigCenter.get("AI_EMBED_API_URL")
|
||||||
EMBED_MODEL = await ConfigCenter.get("AI_EMBED_MODEL", "Qwen/Qwen3-Embedding-8B")
|
EMBED_MODEL = await ConfigCenter.get("AI_EMBED_MODEL")
|
||||||
API_KEY = await ConfigCenter.get("AI_API_KEY", "")
|
API_KEY = await ConfigCenter.get("AI_EMBED_API_KEY")
|
||||||
payload = {
|
payload = {
|
||||||
"model": EMBED_MODEL,
|
"model": EMBED_MODEL,
|
||||||
"input": text
|
"input": text
|
||||||
@@ -58,7 +60,11 @@ async def get_text_embedding(text: str) -> List[float]:
|
|||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
}
|
}
|
||||||
async with httpx.AsyncClient() as client:
|
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()
|
resp.raise_for_status()
|
||||||
result = resp.json()
|
result = resp.json()
|
||||||
return result["data"][0]["embedding"]
|
return result["data"][0]["embedding"]
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
from tortoise.transactions import in_transaction
|
from tortoise.transactions import in_transaction
|
||||||
from models.database import (
|
from models.database import (
|
||||||
StorageAdapter,
|
StorageAdapter,
|
||||||
Mount,
|
|
||||||
UserAccount,
|
UserAccount,
|
||||||
AutomationTask,
|
AutomationTask,
|
||||||
ShareLink,
|
ShareLink,
|
||||||
@@ -18,7 +17,6 @@ class BackupService:
|
|||||||
"""
|
"""
|
||||||
async with in_transaction() as conn:
|
async with in_transaction() as conn:
|
||||||
adapters = await StorageAdapter.all().values()
|
adapters = await StorageAdapter.all().values()
|
||||||
mounts = await Mount.all().values()
|
|
||||||
users = await UserAccount.all().values()
|
users = await UserAccount.all().values()
|
||||||
tasks = await AutomationTask.all().values()
|
tasks = await AutomationTask.all().values()
|
||||||
shares = await ShareLink.all().values()
|
shares = await ShareLink.all().values()
|
||||||
@@ -33,7 +31,6 @@ class BackupService:
|
|||||||
return {
|
return {
|
||||||
"version": VERSION,
|
"version": VERSION,
|
||||||
"storage_adapters": list(adapters),
|
"storage_adapters": list(adapters),
|
||||||
"mounts": list(mounts),
|
|
||||||
"user_accounts": list(users),
|
"user_accounts": list(users),
|
||||||
"automation_tasks": list(tasks),
|
"automation_tasks": list(tasks),
|
||||||
"share_links": list(shares),
|
"share_links": list(shares),
|
||||||
@@ -48,7 +45,6 @@ class BackupService:
|
|||||||
async with in_transaction() as conn:
|
async with in_transaction() as conn:
|
||||||
await ShareLink.all().using_db(conn).delete()
|
await ShareLink.all().using_db(conn).delete()
|
||||||
await AutomationTask.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 StorageAdapter.all().using_db(conn).delete()
|
||||||
await UserAccount.all().using_db(conn).delete()
|
await UserAccount.all().using_db(conn).delete()
|
||||||
await Configuration.all().using_db(conn).delete()
|
await Configuration.all().using_db(conn).delete()
|
||||||
@@ -71,12 +67,6 @@ class BackupService:
|
|||||||
using_db=conn
|
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"):
|
if data.get("automation_tasks"):
|
||||||
await AutomationTask.bulk_create(
|
await AutomationTask.bulk_create(
|
||||||
[AutomationTask(**t) for t in data["automation_tasks"]],
|
[AutomationTask(**t) for t in data["automation_tasks"]],
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from typing import Any, Optional, Dict
|
|||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from models.database import Configuration
|
from models.database import Configuration
|
||||||
load_dotenv(dotenv_path=".env")
|
load_dotenv(dotenv_path=".env")
|
||||||
VERSION = "v1.0.0"
|
VERSION = "v1.1.4"
|
||||||
|
|
||||||
class ConfigCenter:
|
class ConfigCenter:
|
||||||
_cache: Dict[str, Any] = {}
|
_cache: Dict[str, Any] = {}
|
||||||
|
|||||||
122
services/task_queue.py
Normal file
122
services/task_queue.py
Normal 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()
|
||||||
@@ -4,6 +4,9 @@ from models.database import AutomationTask
|
|||||||
from services.processors.registry import get as get_processor
|
from services.processors.registry import get as get_processor
|
||||||
from services.logging import LogService
|
from services.logging import LogService
|
||||||
|
|
||||||
|
from services.task_queue import task_queue_service
|
||||||
|
|
||||||
|
|
||||||
class TaskService:
|
class TaskService:
|
||||||
async def trigger_tasks(self, event: str, path: str):
|
async def trigger_tasks(self, event: str, path: str):
|
||||||
tasks = await AutomationTask.filter(event=event, enabled=True)
|
tasks = await AutomationTask.filter(event=event, enabled=True)
|
||||||
@@ -21,28 +24,12 @@ class TaskService:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
async def execute(self, task: AutomationTask, path: str):
|
async def execute(self, task: AutomationTask, path: str):
|
||||||
from services.virtual_fs import read_file, write_file
|
await task_queue_service.add_task(
|
||||||
|
"automation_task",
|
||||||
processor = get_processor(task.processor_type)
|
{
|
||||||
if not processor:
|
"task_id": task.id,
|
||||||
print(f"Processor {task.processor_type} not found for task {task.id}")
|
"path": path,
|
||||||
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}
|
|
||||||
)
|
|
||||||
|
|
||||||
task_service = TaskService()
|
task_service = TaskService()
|
||||||
@@ -5,7 +5,8 @@ from pathlib import Path
|
|||||||
from typing import Tuple
|
from typing import Tuple
|
||||||
from fastapi import HTTPException
|
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"}
|
RAW_EXT = {"arw", "cr2", "cr3", "nef", "rw2", "orf", "pef", "dng"}
|
||||||
MAX_SOURCE_SIZE = 200 * 1024 * 1024
|
MAX_SOURCE_SIZE = 200 * 1024 * 1024
|
||||||
CACHE_ROOT = Path('data/.thumb_cache')
|
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()
|
thumb = raw.extract_thumb()
|
||||||
except rawpy.LibRawNoThumbnailError:
|
except rawpy.LibRawNoThumbnailError:
|
||||||
thumb = None
|
thumb = None
|
||||||
|
|
||||||
if thumb is not None and thumb.format in [rawpy.ThumbFormat.JPEG, rawpy.ThumbFormat.BITMAP]:
|
if thumb is not None and thumb.format in [rawpy.ThumbFormat.JPEG, rawpy.ThumbFormat.BITMAP]:
|
||||||
im = Image.open(io.BytesIO(thumb.data))
|
im = Image.open(io.BytesIO(thumb.data))
|
||||||
else:
|
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)
|
im = Image.fromarray(rgb)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"rawpy processing failed: {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'):
|
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)
|
stat = await adapter.stat_file(root, rel)
|
||||||
if stat['size'] > MAX_SOURCE_SIZE:
|
if stat['size'] > MAX_SOURCE_SIZE:
|
||||||
raise HTTPException(400, detail="Image too large for thumbnail")
|
raise HTTPException(400, detail="Image too large for thumbnail")
|
||||||
|
|
||||||
key = _cache_key(adapter_id, rel, stat['size'], int(stat['mtime']), w, h, fit)
|
key = _cache_key(adapter_id, rel, stat['size'], int(
|
||||||
|
stat['mtime']), w, h, fit)
|
||||||
path = _cache_path(key)
|
path = _cache_path(key)
|
||||||
if path.exists():
|
if path.exists():
|
||||||
return path.read_bytes(), 'image/webp', key
|
return path.read_bytes(), 'image/webp', key
|
||||||
|
|
||||||
_ensure_cache_dir(path)
|
_ensure_cache_dir(path)
|
||||||
read_data = await adapter.read_file(root, rel)
|
thumb_bytes, mime = None, None
|
||||||
try:
|
|
||||||
thumb_bytes, mime = generate_thumb(read_data, w, h, fit, is_raw=is_raw_filename(rel))
|
get_thumb_impl = getattr(adapter, "get_thumbnail", None)
|
||||||
except Exception as e:
|
if callable(get_thumb_impl):
|
||||||
print(e)
|
size_str = "large" if w > 400 else "medium" if w > 100 else "small"
|
||||||
raise HTTPException(500, detail=f"Thumbnail generation failed: {e}")
|
native_thumb_bytes = await get_thumb_impl(root, rel, size_str)
|
||||||
path.write_bytes(thumb_bytes)
|
|
||||||
return thumb_bytes, mime, key
|
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")
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
from pathlib import Path
|
|
||||||
from typing import Dict, Tuple, Any, Union, AsyncIterator
|
from typing import Dict, Tuple, Any, Union, AsyncIterator
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
import mimetypes
|
import mimetypes
|
||||||
@@ -8,7 +7,7 @@ import hmac
|
|||||||
import hashlib
|
import hashlib
|
||||||
import base64
|
import base64
|
||||||
|
|
||||||
from models import Mount
|
from models import StorageAdapter
|
||||||
from .adapters.registry import runtime_registry
|
from .adapters.registry import runtime_registry
|
||||||
from api.response import page
|
from api.response import page
|
||||||
from .thumbnail import is_image_filename, is_raw_filename
|
from .thumbnail import is_image_filename, is_raw_filename
|
||||||
@@ -18,16 +17,16 @@ from services.logging import LogService
|
|||||||
from services.config import ConfigCenter
|
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
|
norm = path if path.startswith('/') else '/' + path
|
||||||
mounts = await Mount.filter(enabled=True)
|
adapters = await StorageAdapter.filter(enabled=True)
|
||||||
best = None
|
best = None
|
||||||
for m in mounts:
|
for a in adapters:
|
||||||
if norm == m.path or norm.startswith(m.path.rstrip('/') + '/'):
|
if norm == a.path or norm.startswith(a.path.rstrip('/') + '/'):
|
||||||
if (best is None) or len(m.path) > len(best.path):
|
if (best is None) or len(a.path) > len(best.path):
|
||||||
best = m
|
best = a
|
||||||
if not best:
|
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('/')
|
rel = norm[len(best.path):].lstrip('/')
|
||||||
return best, rel
|
return best, rel
|
||||||
|
|
||||||
@@ -35,16 +34,22 @@ async def resolve_mount(path: str) -> Tuple[Mount, str]:
|
|||||||
|
|
||||||
|
|
||||||
async def resolve_adapter_and_rel(path: 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
|
norm = path if path.startswith('/') else '/' + path
|
||||||
try:
|
try:
|
||||||
mount, rel = await resolve_mount(norm)
|
adapter_model, rel = await resolve_adapter_by_path(norm)
|
||||||
except HTTPException as e:
|
except HTTPException as e:
|
||||||
raise e
|
raise e
|
||||||
await mount.fetch_related("adapter")
|
adapter_instance = runtime_registry.get(adapter_model.id)
|
||||||
adapter_instance = runtime_registry.get(mount.adapter_id)
|
if not adapter_instance:
|
||||||
effective_root = adapter_instance.get_effective_root(mount.sub_path)
|
await runtime_registry.refresh()
|
||||||
return adapter_instance, mount, effective_root, rel
|
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):
|
async def _ensure_method(adapter: Any, method: str):
|
||||||
@@ -56,27 +61,35 @@ async def _ensure_method(adapter: Any, method: str):
|
|||||||
|
|
||||||
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) -> Dict:
|
||||||
norm = (path if path.startswith('/') else '/' + path).rstrip('/') or '/'
|
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 = []
|
child_mount_entries = []
|
||||||
norm_prefix = norm.rstrip('/')
|
norm_prefix = norm.rstrip('/')
|
||||||
for m in mounts:
|
for a in adapters:
|
||||||
if m.path == norm:
|
if a.path == norm:
|
||||||
continue
|
continue
|
||||||
if m.path.startswith(norm_prefix + '/'):
|
if a.path.startswith(norm_prefix + '/'):
|
||||||
tail = m.path[len(norm_prefix):].lstrip('/')
|
tail = a.path[len(norm_prefix):].lstrip('/')
|
||||||
if '/' not in tail:
|
if '/' not in tail:
|
||||||
child_mount_entries.append(tail)
|
child_mount_entries.append(tail)
|
||||||
child_mount_entries = sorted(set(child_mount_entries))
|
child_mount_entries = sorted(set(child_mount_entries))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
mount, rel = await resolve_mount(norm)
|
adapter_model, rel = await resolve_adapter_by_path(norm)
|
||||||
await mount.fetch_related("adapter")
|
adapter_instance = runtime_registry.get(adapter_model.id)
|
||||||
adapter = runtime_registry.get(mount.adapter_id)
|
if not adapter_instance:
|
||||||
effective_root = adapter.get_effective_root(mount.sub_path)
|
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:
|
except HTTPException:
|
||||||
mount = None
|
adapter_model = None
|
||||||
adapter = None
|
adapter_instance = None
|
||||||
effective_root = ''
|
effective_root = ''
|
||||||
rel = ''
|
rel = ''
|
||||||
|
|
||||||
@@ -84,8 +97,8 @@ async def list_virtual_dir(path: str, page_num: int = 1, page_size: int = 50) ->
|
|||||||
adapter_total = 0
|
adapter_total = 0
|
||||||
covered = set()
|
covered = set()
|
||||||
|
|
||||||
if mount and adapter:
|
if adapter_model and adapter_instance:
|
||||||
list_dir = await _ensure_method(adapter, "list_dir")
|
list_dir = await _ensure_method(adapter_instance, "list_dir")
|
||||||
try:
|
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)
|
||||||
except NotADirectoryError:
|
except NotADirectoryError:
|
||||||
@@ -119,18 +132,18 @@ async def list_virtual_dir(path: str, page_num: int = 1, page_size: int = 50) ->
|
|||||||
|
|
||||||
|
|
||||||
async def read_file(path: str) -> Union[bytes, Any]:
|
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 == '':
|
if rel.endswith('/') or rel == '':
|
||||||
raise HTTPException(400, detail="Path is a directory")
|
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)
|
return await read_func(root, rel)
|
||||||
|
|
||||||
|
|
||||||
async def write_file(path: str, data: bytes):
|
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('/'):
|
if rel.endswith('/'):
|
||||||
raise HTTPException(400, detail="Invalid file path")
|
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 write_func(root, rel, data)
|
||||||
await task_service.trigger_tasks("file_written", path)
|
await task_service.trigger_tasks("file_written", path)
|
||||||
await LogService.action(
|
await LogService.action(
|
||||||
@@ -139,10 +152,10 @@ async def write_file(path: str, data: bytes):
|
|||||||
|
|
||||||
|
|
||||||
async def write_file_stream(path: str, data_iter: AsyncIterator[bytes], overwrite: bool = True):
|
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('/'):
|
if rel.endswith('/'):
|
||||||
raise HTTPException(400, detail="Invalid file path")
|
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):
|
if not overwrite and callable(exists_func):
|
||||||
try:
|
try:
|
||||||
if await exists_func(root, rel):
|
if await exists_func(root, rel):
|
||||||
@@ -153,7 +166,7 @@ async def write_file_stream(path: str, data_iter: AsyncIterator[bytes], overwrit
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
size = 0
|
size = 0
|
||||||
stream_func = getattr(adapter, "write_file_stream", None)
|
stream_func = getattr(adapter_instance, "write_file_stream", None)
|
||||||
if callable(stream_func):
|
if callable(stream_func):
|
||||||
size = await stream_func(root, rel, data_iter)
|
size = await stream_func(root, rel, data_iter)
|
||||||
else:
|
else:
|
||||||
@@ -161,7 +174,7 @@ async def write_file_stream(path: str, data_iter: AsyncIterator[bytes], overwrit
|
|||||||
async for chunk in data_iter:
|
async for chunk in data_iter:
|
||||||
if chunk:
|
if chunk:
|
||||||
buf.extend(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))
|
await write_func(root, rel, bytes(buf))
|
||||||
size = len(buf)
|
size = len(buf)
|
||||||
|
|
||||||
@@ -175,35 +188,35 @@ async def write_file_stream(path: str, data_iter: AsyncIterator[bytes], overwrit
|
|||||||
|
|
||||||
|
|
||||||
async def make_dir(path: str):
|
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:
|
if not rel:
|
||||||
raise HTTPException(400, detail="Cannot create root")
|
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 mkdir_func(root, rel)
|
||||||
await LogService.action("virtual_fs", f"Created directory {path}", details={"path": path})
|
await LogService.action("virtual_fs", f"Created directory {path}", details={"path": path})
|
||||||
|
|
||||||
|
|
||||||
async def delete_path(path: str):
|
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:
|
if not rel:
|
||||||
raise HTTPException(400, detail="Cannot delete root")
|
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 delete_func(root, rel)
|
||||||
await task_service.trigger_tasks("file_deleted", path)
|
await task_service.trigger_tasks("file_deleted", path)
|
||||||
await LogService.action("virtual_fs", f"Deleted {path}", details={"path": 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):
|
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_s, adapter_model_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_d, adapter_model_d, root_d, rel_d = await resolve_adapter_and_rel(dst)
|
||||||
debug_info = {
|
debug_info = {
|
||||||
"src": src, "dst": dst,
|
"src": src, "dst": dst,
|
||||||
"rel_s": rel_s, "rel_d": rel_d,
|
"rel_s": rel_s, "rel_d": rel_d,
|
||||||
"root_s": root_s, "root_d": root_d,
|
"root_s": root_s, "root_d": root_d,
|
||||||
"overwrite": overwrite
|
"overwrite": overwrite
|
||||||
}
|
}
|
||||||
if mount_s.id != mount_d.id:
|
if adapter_model_s.id != adapter_model_d.id:
|
||||||
raise HTTPException(400, detail="Cross-mount move not supported")
|
raise HTTPException(400, detail="Cross-adapter move not supported")
|
||||||
if not rel_s:
|
if not rel_s:
|
||||||
raise HTTPException(400, detail="Cannot move or rename mount root")
|
raise HTTPException(400, detail="Cannot move or rename mount root")
|
||||||
if not rel_d:
|
if not rel_d:
|
||||||
@@ -266,16 +279,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):
|
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_s, adapter_model_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_d, adapter_model_d, root_d, rel_d = await resolve_adapter_and_rel(dst)
|
||||||
debug_info = {
|
debug_info = {
|
||||||
"src": src, "dst": dst,
|
"src": src, "dst": dst,
|
||||||
"rel_s": rel_s, "rel_d": rel_d,
|
"rel_s": rel_s, "rel_d": rel_d,
|
||||||
"root_s": root_s, "root_d": root_d,
|
"root_s": root_s, "root_d": root_d,
|
||||||
"overwrite": overwrite
|
"overwrite": overwrite
|
||||||
}
|
}
|
||||||
if mount_s.id != mount_d.id:
|
if adapter_model_s.id != adapter_model_d.id:
|
||||||
raise HTTPException(400, detail="Cross-mount rename not supported")
|
raise HTTPException(400, detail="Cross-adapter rename not supported")
|
||||||
if not rel_s:
|
if not rel_s:
|
||||||
raise HTTPException(400, detail="Cannot rename mount root")
|
raise HTTPException(400, detail="Cannot rename mount root")
|
||||||
if not rel_d:
|
if not rel_d:
|
||||||
@@ -338,7 +351,7 @@ async def rename_path(src: str, dst: str, overwrite: bool = False, return_debug:
|
|||||||
|
|
||||||
|
|
||||||
async def stream_file(path: str, range_header: str | None):
|
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('/'):
|
if not rel or rel.endswith('/'):
|
||||||
raise HTTPException(400, detail="Path is a directory")
|
raise HTTPException(400, detail="Path is a directory")
|
||||||
if is_raw_filename(rel):
|
if is_raw_filename(rel):
|
||||||
@@ -371,7 +384,7 @@ async def stream_file(path: str, range_header: str | None):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(500, detail=f"RAW file processing failed: {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):
|
if callable(stream_impl):
|
||||||
return await stream_impl(root, rel, range_header)
|
return await stream_impl(root, rel, range_header)
|
||||||
data = await read_file(path)
|
data = await read_file(path)
|
||||||
@@ -380,24 +393,24 @@ async def stream_file(path: str, range_header: str | None):
|
|||||||
|
|
||||||
|
|
||||||
async def stat_file(path: str):
|
async def stat_file(path: str):
|
||||||
adapter, _mount, root, rel = await resolve_adapter_and_rel(path)
|
adapter_instance, _, root, rel = await resolve_adapter_and_rel(path)
|
||||||
stat_func = getattr(adapter, "stat_file", None)
|
stat_func = getattr(adapter_instance, "stat_file", None)
|
||||||
if not callable(stat_func):
|
if not callable(stat_func):
|
||||||
raise HTTPException(501, detail="Adapter does not implement stat_file")
|
raise HTTPException(501, detail="Adapter does not implement stat_file")
|
||||||
return await stat_func(root, rel)
|
return await stat_func(root, rel)
|
||||||
|
|
||||||
|
|
||||||
async def copy_path(src: str, dst: str, overwrite: bool = False, return_debug: bool = True):
|
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_s, adapter_model_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_d, adapter_model_d, root_d, rel_d = await resolve_adapter_and_rel(dst)
|
||||||
debug_info = {
|
debug_info = {
|
||||||
"src": src, "dst": dst,
|
"src": src, "dst": dst,
|
||||||
"rel_s": rel_s, "rel_d": rel_d,
|
"rel_s": rel_s, "rel_d": rel_d,
|
||||||
"root_s": root_s, "root_d": root_d,
|
"root_s": root_s, "root_d": root_d,
|
||||||
"overwrite": overwrite
|
"overwrite": overwrite
|
||||||
}
|
}
|
||||||
if mount_s.id != mount_d.id:
|
if adapter_model_s.id != adapter_model_d.id:
|
||||||
raise HTTPException(400, detail="Cross-mount copy not supported")
|
raise HTTPException(400, detail="Cross-adapter copy not supported")
|
||||||
if not rel_s:
|
if not rel_s:
|
||||||
raise HTTPException(400, detail="Cannot copy mount root")
|
raise HTTPException(400, detail="Cannot copy mount root")
|
||||||
if not rel_d:
|
if not rel_d:
|
||||||
|
|||||||
367
setup/foxel.sh
Normal file
367
setup/foxel.sh
Normal 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
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
"@ant-design/v5-patch-for-react-19": "^1.0.3",
|
"@ant-design/v5-patch-for-react-19": "^1.0.3",
|
||||||
"@uiw/react-md-editor": "^4.0.8",
|
"@uiw/react-md-editor": "^4.0.8",
|
||||||
"antd": "^5.27.0",
|
"antd": "^5.27.0",
|
||||||
|
"artplayer": "^5.2.5",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
@@ -316,6 +317,8 @@
|
|||||||
|
|
||||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
"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=="],
|
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
|
||||||
|
|
||||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"@ant-design/v5-patch-for-react-19": "^1.0.3",
|
"@ant-design/v5-patch-for-react-19": "^1.0.3",
|
||||||
"@uiw/react-md-editor": "^4.0.8",
|
"@uiw/react-md-editor": "^4.0.8",
|
||||||
"antd": "^5.27.0",
|
"antd": "^5.27.0",
|
||||||
|
"artplayer": "^5.2.5",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export interface AdapterItem {
|
|||||||
type: string;
|
type: string;
|
||||||
config: any;
|
config: any;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
mount_path?: string | null;
|
path?: string | null;
|
||||||
sub_path?: string | null;
|
sub_path?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export interface SystemStatus {
|
|||||||
title: string;
|
title: string;
|
||||||
logo: string;
|
logo: string;
|
||||||
is_initialized: boolean;
|
is_initialized: boolean;
|
||||||
|
app_domain?: string;
|
||||||
|
file_domain?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function status() {
|
export async function status() {
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export const vfsApi = {
|
|||||||
streamUrl: (path: string) => `${API_BASE_URL}/fs/stream/${encodeURI(path.replace(/^\/+/, ''))}`,
|
streamUrl: (path: string) => `${API_BASE_URL}/fs/stream/${encodeURI(path.replace(/^\/+/, ''))}`,
|
||||||
stat: (path: string) => request(`/fs/stat/${encodeURI(path.replace(/^\/+/, ''))}`),
|
stat: (path: string) => request(`/fs/stat/${encodeURI(path.replace(/^\/+/, ''))}`),
|
||||||
getTempLinkToken: (path: string, expiresIn: number = 3600) =>
|
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}`,
|
getTempPublicUrl: (token: string) => `${API_BASE_URL}/fs/public/${token}`,
|
||||||
uploadStream: (fullPath: string, file: File, overwrite: boolean = true, onProgress?: (loaded: number, total: number) => void) => {
|
uploadStream: (fullPath: string, file: File, overwrite: boolean = true, onProgress?: (loaded: number, total: number) => void) => {
|
||||||
const enc = encodeURI(fullPath.replace(/^\/+/, ''));
|
const enc = encodeURI(fullPath.replace(/^\/+/, ''));
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import React, { useEffect, useState } from 'react';
|
|||||||
import { vfsApi } from '../../api/client';
|
import { vfsApi } from '../../api/client';
|
||||||
import type { AppComponentProps } from '../types';
|
import type { AppComponentProps } from '../types';
|
||||||
import { Spin, Result, Button } from 'antd';
|
import { Spin, Result, Button } from 'antd';
|
||||||
|
import { useSystemStatus } from '../../contexts/SystemContext';
|
||||||
|
|
||||||
export const OfficeViewerApp: React.FC<AppComponentProps> = ({ filePath, onRequestClose }) => {
|
export const OfficeViewerApp: React.FC<AppComponentProps> = ({ filePath, onRequestClose }) => {
|
||||||
|
const systemStatus = useSystemStatus();
|
||||||
const [url, setUrl] = useState<string>();
|
const [url, setUrl] = useState<string>();
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [err, setErr] = useState<string>();
|
const [err, setErr] = useState<string>();
|
||||||
@@ -17,8 +19,8 @@ export const OfficeViewerApp: React.FC<AppComponentProps> = ({ filePath, onReque
|
|||||||
vfsApi.getTempLinkToken(filePath.replace(/^\/+/, ''))
|
vfsApi.getTempLinkToken(filePath.replace(/^\/+/, ''))
|
||||||
.then(res => {
|
.then(res => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
// 注意:vfsApi.getTempPublicUrl 返回的是相对路径,我们需要构建完整的 URL
|
const baseUrl = systemStatus?.file_domain || window.location.origin;
|
||||||
const fullUrl = new URL(vfsApi.getTempPublicUrl(res.token), window.location.origin).href;
|
const fullUrl = new URL(res.url, baseUrl).href;
|
||||||
const officeUrl = `https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(fullUrl)}`;
|
const officeUrl = `https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(fullUrl)}`;
|
||||||
setUrl(officeUrl);
|
setUrl(officeUrl);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 { vfsApi } from '../../api/client';
|
||||||
import type { AppComponentProps } from '../types';
|
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 }) => {
|
export const VideoPlayerApp: React.FC<AppComponentProps> = ({ filePath }) => {
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const artRef = useRef<HTMLDivElement | null>(null);
|
||||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
const artInstance = useRef<Artplayer | 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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
//
|
||||||
const safePath = filePath.replace(/^\/+/, '').split('#').map((seg, idx) => idx === 0 ? seg : encodeURIComponent('#') + seg).join('');
|
const safePath = filePath.replace(/^\/+/, '').split('#').map((seg, idx) => idx === 0 ? seg : encodeURIComponent('#') + seg).join('');
|
||||||
const u = vfsApi.streamUrl(safePath);
|
const videoUrl = vfsApi.streamUrl(safePath);
|
||||||
setUrl(u);
|
|
||||||
setErr(undefined);
|
|
||||||
setLoading(true);
|
|
||||||
}, [filePath, retryKey]);
|
|
||||||
|
|
||||||
// 处理视频事件
|
if (artRef.current) {
|
||||||
useEffect(() => {
|
artInstance.current = new Artplayer({
|
||||||
const video = videoRef.current;
|
container: artRef.current,
|
||||||
if (!video || !url) return;
|
url: videoUrl,
|
||||||
|
autoplay: true,
|
||||||
const onLoadedMetadata = () => {
|
fullscreen: true,
|
||||||
if (isMountedRef.current) {
|
fullscreenWeb: true,
|
||||||
setDuration(video.duration);
|
pip: true,
|
||||||
}
|
setting: true,
|
||||||
};
|
playbackRate: true,
|
||||||
|
});
|
||||||
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);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
video.removeEventListener('loadedmetadata', onLoadedMetadata);
|
if (artInstance.current) {
|
||||||
video.removeEventListener('timeupdate', onTimeUpdate);
|
artInstance.current.destroy();
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}, 3000);
|
};
|
||||||
};
|
}, [filePath]);
|
||||||
|
|
||||||
const handleMouseMove = () => {
|
|
||||||
resetControlsTimer();
|
|
||||||
};
|
|
||||||
|
|
||||||
const retry = () => setRetryKey(k => k + 1);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', background: '#000' }}
|
ref={artRef}
|
||||||
ref={containerRef}
|
style={{
|
||||||
onMouseMove={handleMouseMove}
|
width: '100%',
|
||||||
>
|
height: '100%',
|
||||||
<div style={{ flex: 1, position: 'relative', backgroundColor: '#000', overflow: 'hidden' }}>
|
backgroundColor: '#000'
|
||||||
{/* 视频元素 */}
|
}}
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 { navGroups } from './nav.ts';
|
||||||
import type { NavItem, NavGroup } from './nav.ts';
|
import type { NavItem, NavGroup } from './nav.ts';
|
||||||
import { memo, useEffect, useState } from 'react';
|
import { memo, useEffect, useState } from 'react';
|
||||||
import { useSystemStatus } from '../contexts/SystemContext.tsx';
|
import { useSystemStatus } from '../contexts/SystemContext.tsx';
|
||||||
import {
|
import {
|
||||||
CheckCircleOutlined,
|
CheckCircleOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
GithubOutlined,
|
GithubOutlined,
|
||||||
MenuFoldOutlined,
|
MenuFoldOutlined,
|
||||||
SendOutlined,
|
SendOutlined,
|
||||||
@@ -154,8 +155,8 @@ const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle
|
|||||||
}} onClick={showVersionModal}>
|
}} onClick={showVersionModal}>
|
||||||
{hasUpdate ? (
|
{hasUpdate ? (
|
||||||
<Tooltip title={`发现新版本: ${latestVersion?.version}`} placement={collapsed ? 'right' : 'top'}>
|
<Tooltip title={`发现新版本: ${latestVersion?.version}`} placement={collapsed ? 'right' : 'top'}>
|
||||||
<a href="https://github.com/DrizzleTime/Foxel/releases" target="_blank" rel="noopener noreferrer"
|
<a rel="noopener noreferrer"
|
||||||
style={{ textDecoration: 'none' }}>
|
style={{ textDecoration: 'none' }}>
|
||||||
{collapsed ? (
|
{collapsed ? (
|
||||||
<Tag icon={<WarningOutlined />} color="warning" style={{ marginInlineEnd: 0 }} />
|
<Tag icon={<WarningOutlined />} color="warning" style={{ marginInlineEnd: 0 }} />
|
||||||
) : (
|
) : (
|
||||||
@@ -199,8 +200,14 @@ const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle
|
|||||||
href="https://t.me/+thDsBfyqJxZkNTU1"
|
href="https://t.me/+thDsBfyqJxZkNTU1"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
/>
|
/>
|
||||||
|
<Button
|
||||||
|
shape="circle"
|
||||||
|
icon={<FileTextOutlined />}
|
||||||
|
href="https://foxel.cc"
|
||||||
|
target="_blank"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</Sider>
|
</Sider>
|
||||||
<Modal
|
<Modal
|
||||||
@@ -225,13 +232,72 @@ const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle
|
|||||||
onCancel={() => setIsVersionModalOpen(false)}
|
onCancel={() => setIsVersionModalOpen(false)}
|
||||||
title="版本信息"
|
title="版本信息"
|
||||||
footer={null}
|
footer={null}
|
||||||
|
width={600}
|
||||||
>
|
>
|
||||||
<div>
|
<div style={{ paddingTop: 12 }}>
|
||||||
<p>当前版本: {status?.version}</p>
|
{latestVersion ? (
|
||||||
{latestVersion && (
|
<>
|
||||||
<div>
|
<Descriptions bordered column={1} size="small">
|
||||||
<p>最新版本: {latestVersion.version}</p>
|
<Descriptions.Item label="当前版本">
|
||||||
<ReactMarkdown>{latestVersion.body}</ReactMarkdown>
|
<Tag>{status?.version}</Tag>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="最新版本">
|
||||||
|
<Tag color={hasUpdate ? 'orange' : 'green'}>{latestVersion.version}</Tag>
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
|
||||||
|
{hasUpdate && (
|
||||||
|
<Alert
|
||||||
|
message={<span style={{ color: token.colorText }}>{`发现新版本: ${latestVersion.version}`}</span>}
|
||||||
|
description={<span style={{ color: token.colorTextSecondary }}>建议尽快更新到最新版本,以获得新功能和安全修复。</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 />}
|
||||||
|
>
|
||||||
|
前往发布页面
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Divider orientation="left" plain>更新日志</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 }}>正在获取最新版本信息...</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,17 +1,8 @@
|
|||||||
import { memo, useState, useEffect, useCallback } from 'react';
|
import { memo, useState, useEffect, useCallback } from 'react';
|
||||||
import { Table, Button, Space, Drawer, Form, Input, Switch, message, Typography, Popconfirm, Select } from 'antd';
|
import { Table, Button, Space, Drawer, Form, Input, Switch, message, Typography, Popconfirm, Select } from 'antd';
|
||||||
import PageCard from '../components/PageCard';
|
import PageCard from '../components/PageCard';
|
||||||
import { adaptersApi } from '../api/client';
|
import { adaptersApi, type AdapterItem } from '../api/client';
|
||||||
|
|
||||||
interface AdapterItem {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
type: string;
|
|
||||||
config: any;
|
|
||||||
enabled: boolean;
|
|
||||||
mount_path?: string | null;
|
|
||||||
sub_path?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AdapterTypeField {
|
interface AdapterTypeField {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -65,7 +56,7 @@ const AdaptersPage = memo(function AdaptersPage() {
|
|||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
name: '',
|
name: '',
|
||||||
type: defaultType,
|
type: defaultType,
|
||||||
mount_path: '/',
|
path: '/',
|
||||||
sub_path: '',
|
sub_path: '',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
config: cfgDefaults
|
config: cfgDefaults
|
||||||
@@ -79,7 +70,7 @@ const AdaptersPage = memo(function AdaptersPage() {
|
|||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
name: rec.name,
|
name: rec.name,
|
||||||
type: rec.type,
|
type: rec.type,
|
||||||
mount_path: rec.mount_path || '/',
|
path: rec.path || '/',
|
||||||
sub_path: rec.sub_path || '',
|
sub_path: rec.sub_path || '',
|
||||||
enabled: rec.enabled,
|
enabled: rec.enabled,
|
||||||
config: rec.config || {}
|
config: rec.config || {}
|
||||||
@@ -105,7 +96,7 @@ const AdaptersPage = memo(function AdaptersPage() {
|
|||||||
const body = {
|
const body = {
|
||||||
name: values.name.trim(),
|
name: values.name.trim(),
|
||||||
type: values.type,
|
type: values.type,
|
||||||
mount_path: values.mount_path || '/',
|
path: values.path || '/',
|
||||||
sub_path: values.sub_path?.trim() || null,
|
sub_path: values.sub_path?.trim() || null,
|
||||||
enabled: values.enabled,
|
enabled: values.enabled,
|
||||||
config: cfg
|
config: cfg
|
||||||
@@ -155,7 +146,7 @@ const AdaptersPage = memo(function AdaptersPage() {
|
|||||||
const columns = [
|
const columns = [
|
||||||
{ title: '名称', dataIndex: 'name' },
|
{ title: '名称', dataIndex: 'name' },
|
||||||
{ title: '类型', dataIndex: 'type', width: 100 },
|
{ title: '类型', dataIndex: 'type', width: 100 },
|
||||||
{ title: '挂载路径', dataIndex: 'mount_path', width: 140, render: (v: string) => v || '-' },
|
{ title: '挂载路径', dataIndex: 'path', width: 140, render: (v: string) => v || '-' },
|
||||||
{ title: '子路径', dataIndex: 'sub_path', width: 140, render: (v: string) => v || '-' },
|
{ title: '子路径', dataIndex: 'sub_path', width: 140, render: (v: string) => v || '-' },
|
||||||
{
|
{
|
||||||
title: '启用',
|
title: '启用',
|
||||||
@@ -251,7 +242,6 @@ const AdaptersPage = memo(function AdaptersPage() {
|
|||||||
placeholder="选择适配器类型"
|
placeholder="选择适配器类型"
|
||||||
options={availableTypes.map(t => ({ value: t.type, label: `${t.name} (${t.type})` }))}
|
options={availableTypes.map(t => ({ value: t.type, label: `${t.name} (${t.type})` }))}
|
||||||
onChange={() => {
|
onChange={() => {
|
||||||
// 切换类型时刷新默认 config
|
|
||||||
const t = availableTypes.find(v => v.type === form.getFieldValue('type'));
|
const t = availableTypes.find(v => v.type === form.getFieldValue('type'));
|
||||||
const cfgDefaults: Record<string, any> = {};
|
const cfgDefaults: Record<string, any> = {};
|
||||||
t?.config_schema.forEach(f => {
|
t?.config_schema.forEach(f => {
|
||||||
@@ -261,7 +251,7 @@ const AdaptersPage = memo(function AdaptersPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="mount_path" label="挂载路径" rules={[{ required: true, message: '请输入挂载路径' }]}>
|
<Form.Item name="path" label="挂载路径" rules={[{ required: true, message: '请输入挂载路径' }]}>
|
||||||
<Input placeholder="/或/drive" />
|
<Input placeholder="/或/drive" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="sub_path" label="子路径(可选)">
|
<Form.Item name="sub_path" label="子路径(可选)">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useEffect, useState } from 'react';
|
import { memo, useEffect, useRef, useState } from 'react';
|
||||||
import { useParams } from 'react-router';
|
import { useParams } from 'react-router';
|
||||||
import { theme, Pagination } from 'antd';
|
import { theme, Pagination } from 'antd';
|
||||||
import { AppWindowsLayer } from '../../apps/AppWindowsLayer';
|
import { AppWindowsLayer } from '../../apps/AppWindowsLayer';
|
||||||
@@ -15,6 +15,7 @@ import { GridView } from './components/GridView';
|
|||||||
import { FileListView } from './components/FileListView';
|
import { FileListView } from './components/FileListView';
|
||||||
import { EmptyState } from './components/EmptyState';
|
import { EmptyState } from './components/EmptyState';
|
||||||
import { ContextMenu } from './components/ContextMenu';
|
import { ContextMenu } from './components/ContextMenu';
|
||||||
|
import { DropzoneOverlay } from './components/DropzoneOverlay';
|
||||||
import { CreateDirModal } from './components/Modals/CreateDirModal';
|
import { CreateDirModal } from './components/Modals/CreateDirModal';
|
||||||
import { RenameModal } from './components/Modals/RenameModal';
|
import { RenameModal } from './components/Modals/RenameModal';
|
||||||
import { ProcessorModal } from './components/Modals/ProcessorModal';
|
import { ProcessorModal } from './components/Modals/ProcessorModal';
|
||||||
@@ -29,6 +30,8 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
|||||||
const { navKey = 'files', '*': restPath = '' } = useParams();
|
const { navKey = 'files', '*': restPath = '' } = useParams();
|
||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('grid');
|
const [viewMode, setViewMode] = useState<ViewMode>('grid');
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const dragCounter = useRef(0);
|
||||||
|
|
||||||
// --- Hooks ---
|
// --- Hooks ---
|
||||||
const { path, entries, loading, pagination, processorTypes, load, navigateTo, goUp, handlePaginationChange, refresh } = useFileExplorer(navKey);
|
const { path, entries, loading, pagination, processorTypes, load, navigateTo, goUp, handlePaginationChange, refresh } = useFileExplorer(navKey);
|
||||||
@@ -37,6 +40,7 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
|||||||
const { appWindows, openFileWithDefaultApp, confirmOpenWithApp, closeWindow, toggleMax, bringToFront, updateWindow } = useAppWindows(path);
|
const { appWindows, openFileWithDefaultApp, confirmOpenWithApp, closeWindow, toggleMax, bringToFront, updateWindow } = useAppWindows(path);
|
||||||
const { ctxMenu, blankCtxMenu, openContextMenu, openBlankContextMenu, closeContextMenus } = useContextMenu();
|
const { ctxMenu, blankCtxMenu, openContextMenu, openBlankContextMenu, closeContextMenus } = useContextMenu();
|
||||||
const uploader = useUploader(path, refresh);
|
const uploader = useUploader(path, refresh);
|
||||||
|
const { handleFileDrop } = uploader;
|
||||||
const processorHook = useProcessor({ path, processorTypes, refresh });
|
const processorHook = useProcessor({ path, processorTypes, refresh });
|
||||||
const { thumbs } = useThumbnails(entries, path);
|
const { thumbs } = useThumbnails(entries, path);
|
||||||
|
|
||||||
@@ -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 (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -91,6 +126,10 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
|||||||
position: 'relative'
|
position: 'relative'
|
||||||
}}
|
}}
|
||||||
onClick={closeContextMenus}
|
onClick={closeContextMenus}
|
||||||
|
onDragEnter={handleDragEnter}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDrop={handleDrop}
|
||||||
>
|
>
|
||||||
<Header
|
<Header
|
||||||
navKey={navKey}
|
navKey={navKey}
|
||||||
@@ -109,7 +148,7 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
|||||||
|
|
||||||
<div style={{ flex: 1, overflow: 'auto', paddingBottom: pagination.total > 0 ? '80px' : '0' }} onContextMenu={openBlankContextMenu}>
|
<div style={{ flex: 1, overflow: 'auto', paddingBottom: pagination.total > 0 ? '80px' : '0' }} onContextMenu={openBlankContextMenu}>
|
||||||
{loading && entries.length === 0 ? (
|
{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' ? (
|
) : viewMode === 'grid' ? (
|
||||||
<GridView
|
<GridView
|
||||||
entries={entries}
|
entries={entries}
|
||||||
@@ -121,8 +160,6 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
|||||||
onSelectRange={handleSelectRange}
|
onSelectRange={handleSelectRange}
|
||||||
onOpen={handleOpenEntry}
|
onOpen={handleOpenEntry}
|
||||||
onContextMenu={openContextMenu}
|
onContextMenu={openContextMenu}
|
||||||
onCreateDir={() => setCreatingDir(true)}
|
|
||||||
onGoUp={goUp}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<FileListView
|
<FileListView
|
||||||
@@ -214,6 +251,7 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
|||||||
onStartUpload={uploader.startUpload}
|
onStartUpload={uploader.startUpload}
|
||||||
/>
|
/>
|
||||||
<AppWindowsLayer windows={appWindows} onClose={closeWindow} onToggleMax={toggleMax} onBringToFront={bringToFront} onUpdateWindow={updateWindow} />
|
<AppWindowsLayer windows={appWindows} onClose={closeWindow} onToggleMax={toggleMax} onBringToFront={bringToFront} onUpdateWindow={updateWindow} />
|
||||||
|
<DropzoneOverlay visible={isDragging} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,14 +1,12 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Button, Space, Typography, theme } from 'antd';
|
import { Typography, theme } from 'antd';
|
||||||
import { PlusOutlined, CloudUploadOutlined, ArrowUpOutlined, FolderOpenOutlined } from '@ant-design/icons';
|
import { FolderOpenOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isRoot: boolean;
|
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 { token } = theme.useToken();
|
||||||
return (
|
return (
|
||||||
<div style={{ display:'flex', flexDirection:'column', alignItems:'center', justifyContent:'center', padding:isRoot? '80px 40px':'60px 40px', minHeight: isRoot? '400px':'300px', color: token.colorTextSecondary }}>
|
<div style={{ display:'flex', flexDirection:'column', alignItems:'center', justifyContent:'center', padding:isRoot? '80px 40px':'60px 40px', minHeight: isRoot? '400px':'300px', color: token.colorTextSecondary }}>
|
||||||
@@ -19,15 +17,6 @@ export const EmptyState: React.FC<Props> = ({ isRoot, onCreateDir, onGoUp }) =>
|
|||||||
<Typography.Text style={{ color: token.colorTextTertiary, marginBottom:24, textAlign:'center', maxWidth:300, lineHeight:1.5 }}>
|
<Typography.Text style={{ color: token.colorTextTertiary, marginBottom:24, textAlign:'center', maxWidth:300, lineHeight:1.5 }}>
|
||||||
{isRoot ? '开始上传文件或创建新目录来组织您的内容' : '您可以在此目录中创建新的文件夹或上传文件'}
|
{isRoot ? '开始上传文件或创建新目录来组织您的内容' : '您可以在此目录中创建新的文件夹或上传文件'}
|
||||||
</Typography.Text>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,20 +7,14 @@ import { EmptyState } from './EmptyState';
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
entries: VfsEntry[];
|
entries: VfsEntry[];
|
||||||
thumbs: Record<string,string>;
|
thumbs: Record<string, string>;
|
||||||
// ...existing code...
|
|
||||||
// selected was single entry before; now use selectedEntries for multi-select
|
|
||||||
selectedEntries: string[];
|
selectedEntries: string[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
path: string;
|
path: string;
|
||||||
// onSelect: clicked entry, additive indicates Ctrl/Cmd click to toggle
|
|
||||||
onSelect: (e: VfsEntry, additive?: boolean) => void;
|
onSelect: (e: VfsEntry, additive?: boolean) => void;
|
||||||
// onSelectRange: called when marquee/selecting multiple by box
|
|
||||||
onSelectRange: (names: string[]) => void;
|
onSelectRange: (names: string[]) => void;
|
||||||
onOpen: (e: VfsEntry) => void;
|
onOpen: (e: VfsEntry) => void;
|
||||||
onContextMenu: (e: React.MouseEvent, entry: VfsEntry) => void;
|
onContextMenu: (e: React.MouseEvent, entry: VfsEntry) => void;
|
||||||
onCreateDir: () => void;
|
|
||||||
onGoUp: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatSize = (size: number) => {
|
const formatSize = (size: number) => {
|
||||||
@@ -30,14 +24,12 @@ const formatSize = (size: number) => {
|
|||||||
return (size / 1024 / 1024 / 1024).toFixed(1) + ' GB';
|
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();
|
const { token } = theme.useToken();
|
||||||
|
|
||||||
// refs for marquee selection
|
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const itemRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
const itemRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||||
const startRef = useRef<{x:number,y: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 [rect, setRect] = useState<{ left: number, top: number, width: number, height: number } | null>(null);
|
||||||
const [selecting, setSelecting] = useState(false);
|
const [selecting, setSelecting] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -52,12 +44,11 @@ export const GridView: React.FC<Props> = ({ entries, thumbs, selectedEntries, lo
|
|||||||
const height = Math.abs(cy - s.y);
|
const height = Math.abs(cy - s.y);
|
||||||
setRect({ left, top, width, height });
|
setRect({ left, top, width, height });
|
||||||
};
|
};
|
||||||
const onUp = () => { // 不需要 MouseEvent 参数,避免未使用警告
|
const onUp = () => {
|
||||||
if (!startRef.current) return;
|
if (!startRef.current) return;
|
||||||
setSelecting(false);
|
setSelecting(false);
|
||||||
const r = rect;
|
const r = rect;
|
||||||
if (r) {
|
if (r) {
|
||||||
// compute intersecting items
|
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (container) {
|
if (container) {
|
||||||
const sel: string[] = [];
|
const sel: string[] = [];
|
||||||
@@ -89,17 +80,14 @@ export const GridView: React.FC<Props> = ({ entries, thumbs, selectedEntries, lo
|
|||||||
}, [selecting, rect, entries, onSelectRange]);
|
}, [selecting, rect, entries, onSelectRange]);
|
||||||
|
|
||||||
const handleMouseDown = (e: React.MouseEvent) => {
|
const handleMouseDown = (e: React.MouseEvent) => {
|
||||||
// only left button and not on an item actionable element
|
|
||||||
if (e.button !== 0) return;
|
if (e.button !== 0) return;
|
||||||
// start marquee if click on empty space inside container
|
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
if (target.closest('.fx-grid-item')) {
|
if (target.closest('.fx-grid-item')) {
|
||||||
return; // clicks on item handled separately
|
return;
|
||||||
}
|
}
|
||||||
startRef.current = { x: e.clientX, y: e.clientY };
|
startRef.current = { x: e.clientX, y: e.clientY };
|
||||||
setSelecting(true);
|
setSelecting(true);
|
||||||
setRect({ left: e.clientX, top: e.clientY, width: 0, height: 0 });
|
setRect({ left: e.clientX, top: e.clientY, width: 0, height: 0 });
|
||||||
// prevent text selection
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -108,29 +96,28 @@ export const GridView: React.FC<Props> = ({ entries, thumbs, selectedEntries, lo
|
|||||||
{entries.map(ent => {
|
{entries.map(ent => {
|
||||||
const isImg = thumbs[ent.name];
|
const isImg = thumbs[ent.name];
|
||||||
const ext = ent.name.split('.').pop()?.toLowerCase();
|
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);
|
const isSelected = selectedEntries.includes(ent.name);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={ent.name}
|
key={ent.name}
|
||||||
ref={(el) => { itemRefs.current[ent.name] = el; }} // 确保函数不返回值,匹配 Ref 类型
|
ref={(el) => { itemRefs.current[ent.name] = el; }}
|
||||||
className={['fx-grid-item', isSelected ? 'selected' : '', ent.is_dir? 'dir':'file'].join(' ')}
|
className={['fx-grid-item', isSelected ? 'selected' : '', ent.is_dir ? 'dir' : 'file'].join(' ')}
|
||||||
onClick={(ev) => {
|
onClick={(ev) => {
|
||||||
// click selection: support ctrl/cmd to toggle
|
|
||||||
const additive = ev.ctrlKey || ev.metaKey;
|
const additive = ev.ctrlKey || ev.metaKey;
|
||||||
onSelect(ent, additive);
|
onSelect(ent, additive);
|
||||||
}}
|
}}
|
||||||
onDoubleClick={() => onOpen(ent)}
|
onDoubleClick={() => onOpen(ent)}
|
||||||
onContextMenu={(e)=> onContextMenu(e, ent)}
|
onContextMenu={(e) => onContextMenu(e, ent)}
|
||||||
style={{ userSelect:'none' }}
|
style={{ userSelect: 'none' }}
|
||||||
>
|
>
|
||||||
<div className="thumb" style={{ background: ent.is_dir ? 'linear-gradient(#fafafa,#f2f2f2)' : '#fff' }}>
|
<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 && <FolderFilled style={{ fontSize: 32, color: token.colorPrimary }} />}
|
||||||
{!ent.is_dir && (isImg ? <img src={isImg} alt={ent.name} style={{ maxWidth:'100%', maxHeight:'100%'}} /> : isPictureType ? <PictureOutlined style={{ fontSize:32, color:'#8c8c8c' }} /> : getFileIcon(ent.name,32))}
|
{!ent.is_dir && (isImg ? <img src={isImg} alt={ent.name} style={{ maxWidth: '100%', maxHeight: '100%' }} /> : isPictureType ? <PictureOutlined style={{ fontSize: 32, color: '#8c8c8c' }} /> : getFileIcon(ent.name, 32))}
|
||||||
{ent.type === 'mount' && <span className="badge">M</span>}
|
{ent.type === 'mount' && <span className="badge">M</span>}
|
||||||
</div>
|
</div>
|
||||||
<Tooltip title={ent.name}><div className="name ellipsis" style={{ userSelect:'none' }}>{ent.name}</div></Tooltip>
|
<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 className="meta ellipsis" style={{ fontSize: 11, color: token.colorTextSecondary, userSelect: 'none' }}>{ent.is_dir ? '目录' : formatSize(ent.size)}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -148,8 +135,8 @@ export const GridView: React.FC<Props> = ({ entries, thumbs, selectedEntries, lo
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{loading && <div style={{ width:'100%', textAlign:'center', padding:40 }}><Spin /></div>}
|
{loading && <div style={{ width: '100%', textAlign: 'center', padding: 40 }}><Spin /></div>}
|
||||||
{!loading && entries.length === 0 && <EmptyState isRoot={path==='/' } onCreateDir={onCreateDir} onGoUp={onGoUp} />}
|
{!loading && entries.length === 0 && <EmptyState isRoot={path === '/'} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { memo, useState, useEffect } from 'react';
|
import { memo, useState, useEffect } from 'react';
|
||||||
import { Modal, Radio, message, Button, Typography, Input } from 'antd';
|
import { Modal, Radio, message, Button, Typography, Input, Space } from 'antd';
|
||||||
import { CopyOutlined } from '@ant-design/icons';
|
import { CopyOutlined, FileMarkdownOutlined } from '@ant-design/icons';
|
||||||
import type { VfsEntry } from '../../../../api/client';
|
import type { VfsEntry } from '../../../../api/client';
|
||||||
import { vfsApi } from '../../../../api/client';
|
import { vfsApi } from '../../../../api/client';
|
||||||
|
|
||||||
@@ -11,6 +11,21 @@ interface DirectLinkModalProps {
|
|||||||
onCancel: () => void;
|
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 ``;
|
||||||
|
} else {
|
||||||
|
return `[${fileName}](${url})`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const DirectLinkModal = memo(function DirectLinkModal({ entry, path, open, onCancel }: DirectLinkModalProps) {
|
export const DirectLinkModal = memo(function DirectLinkModal({ entry, path, open, onCancel }: DirectLinkModalProps) {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [expiresIn, setExpiresIn] = useState(3600);
|
const [expiresIn, setExpiresIn] = useState(3600);
|
||||||
@@ -29,8 +44,7 @@ export const DirectLinkModal = memo(function DirectLinkModal({ entry, path, open
|
|||||||
try {
|
try {
|
||||||
const fullPath = (path === '/' ? '' : path) + '/' + entry.name;
|
const fullPath = (path === '/' ? '' : path) + '/' + entry.name;
|
||||||
const res = await vfsApi.getTempLinkToken(fullPath, expiresIn);
|
const res = await vfsApi.getTempLinkToken(fullPath, expiresIn);
|
||||||
const tempLink = `${window.location.origin}/api/fs/public/${res.token}`;
|
setLink(res.url);
|
||||||
setLink(tempLink);
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
message.error(e.message || '生成链接失败');
|
message.error(e.message || '生成链接失败');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -42,6 +56,13 @@ export const DirectLinkModal = memo(function DirectLinkModal({ entry, path, open
|
|||||||
navigator.clipboard.writeText(text);
|
navigator.clipboard.writeText(text);
|
||||||
message.success('已复制到剪贴板');
|
message.success('已复制到剪贴板');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCopyMarkdown = () => {
|
||||||
|
if (!entry || !link) return;
|
||||||
|
const markdownText = generateMarkdownLink(entry.name, link);
|
||||||
|
navigator.clipboard.writeText(markdownText);
|
||||||
|
message.success('Markdown 格式已复制到剪贴板');
|
||||||
|
};
|
||||||
|
|
||||||
const handleExpiresChange = (e: any) => {
|
const handleExpiresChange = (e: any) => {
|
||||||
setExpiresIn(e.target.value);
|
setExpiresIn(e.target.value);
|
||||||
@@ -70,9 +91,14 @@ export const DirectLinkModal = memo(function DirectLinkModal({ entry, path, open
|
|||||||
|
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
<Input readOnly value={link} disabled={loading} placeholder={loading ? "正在生成链接..." : "链接将显示在这里"} />
|
<Input readOnly value={link} disabled={loading} placeholder={loading ? "正在生成链接..." : "链接将显示在这里"} />
|
||||||
<Button icon={<CopyOutlined />} onClick={() => handleCopy(link)} disabled={!link || loading}>
|
<Space.Compact>
|
||||||
复制
|
<Button icon={<CopyOutlined />} onClick={() => handleCopy(link)} disabled={!link || loading}>
|
||||||
</Button>
|
复制
|
||||||
|
</Button>
|
||||||
|
<Button icon={<FileMarkdownOutlined />} onClick={handleCopyMarkdown} disabled={!link || loading}>
|
||||||
|
复制 Markdown
|
||||||
|
</Button>
|
||||||
|
</Space.Compact>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Modal, Form, Input, Radio, InputNumber, message, Button, Typography } f
|
|||||||
import { CopyOutlined } from '@ant-design/icons';
|
import { CopyOutlined } from '@ant-design/icons';
|
||||||
import type { VfsEntry, ShareInfoWithPassword } from '../../../../api/client';
|
import type { VfsEntry, ShareInfoWithPassword } from '../../../../api/client';
|
||||||
import { shareApi } from '../../../../api/share';
|
import { shareApi } from '../../../../api/share';
|
||||||
|
import { useSystemStatus } from '../../../../contexts/SystemContext';
|
||||||
|
|
||||||
interface ShareModalProps {
|
interface ShareModalProps {
|
||||||
entries: VfsEntry[];
|
entries: VfsEntry[];
|
||||||
@@ -13,6 +14,7 @@ interface ShareModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ShareModal = memo(function ShareModal({ entries, path, open, onOk, onCancel }: ShareModalProps) {
|
export const ShareModal = memo(function ShareModal({ entries, path, open, onOk, onCancel }: ShareModalProps) {
|
||||||
|
const systemStatus = useSystemStatus();
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [accessType, setAccessType] = useState('public');
|
const [accessType, setAccessType] = useState('public');
|
||||||
@@ -66,7 +68,8 @@ export const ShareModal = memo(function ShareModal({ entries, path, open, onOk,
|
|||||||
message.success('已复制到剪贴板');
|
message.success('已复制到剪贴板');
|
||||||
};
|
};
|
||||||
|
|
||||||
const shareUrl = createdShare ? `${window.location.origin}/share/${createdShare.token}` : '';
|
const baseUrl = systemStatus?.app_domain || window.location.origin;
|
||||||
|
const shareUrl = createdShare ? new URL(`/share/${createdShare.token}`, baseUrl).href : '';
|
||||||
|
|
||||||
const renderForm = () => (
|
const renderForm = () => (
|
||||||
<Form form={form} layout="vertical" initialValues={{ name: defaultName, accessType: 'public', expiresInDays: 7 }}>
|
<Form form={form} layout="vertical" initialValues={{ name: defaultName, accessType: 'public', expiresInDays: 7 }}>
|
||||||
|
|||||||
@@ -39,13 +39,25 @@ export function useUploader(path: string, onUploadComplete: () => void) {
|
|||||||
}));
|
}));
|
||||||
setFiles(newFiles);
|
setFiles(newFiles);
|
||||||
setIsModalVisible(true);
|
setIsModalVisible(true);
|
||||||
// reset file input
|
|
||||||
if (fileInputRef.current) {
|
if (fileInputRef.current) {
|
||||||
fileInputRef.current.value = '';
|
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 () => {
|
const startUpload = useCallback(async () => {
|
||||||
if (files.length === 0) {
|
if (files.length === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -66,7 +78,7 @@ export function useUploader(path: string, onUploadComplete: () => void) {
|
|||||||
setFiles(prev => prev.map(f => f.id === uploadFile.id ? { ...f, progress } : f));
|
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);
|
const permanentLink = vfsApi.getTempPublicUrl(link.token);
|
||||||
|
|
||||||
setFiles(prev => prev.map(f => f.id === uploadFile.id ? { ...f, status: 'success', progress: 100, permanentLink } : f));
|
setFiles(prev => prev.map(f => f.id === uploadFile.id ? { ...f, status: 'success', progress: 100, permanentLink } : f));
|
||||||
@@ -86,6 +98,7 @@ export function useUploader(path: string, onUploadComplete: () => void) {
|
|||||||
openModal,
|
openModal,
|
||||||
closeModal,
|
closeModal,
|
||||||
handleFileChange,
|
handleFileChange,
|
||||||
|
handleFileDrop,
|
||||||
startUpload,
|
startUpload,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
import { memo, useState, useEffect, useCallback } from 'react';
|
|
||||||
import { useParams } from 'react-router';
|
|
||||||
import { Card, message, Spin, List, Typography, Button, Empty, Breadcrumb, Input, Form } from 'antd';
|
|
||||||
import { FileOutlined, FolderOutlined, DownloadOutlined } from '@ant-design/icons';
|
|
||||||
import { shareApi, type ShareInfo } from '../api/share';
|
|
||||||
import { type VfsEntry } from '../api/vfs';
|
|
||||||
import { format, parseISO } from 'date-fns';
|
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
|
||||||
|
|
||||||
const PublicSharePage = memo(function PublicSharePage() {
|
|
||||||
const { token } = useParams<{ token: string }>();
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [shareInfo, setShareInfo] = useState<ShareInfo | null>(null);
|
|
||||||
const [entries, setEntries] = useState<VfsEntry[]>([]);
|
|
||||||
const [currentPath, setCurrentPath] = useState('/');
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [verified, setVerified] = useState(false);
|
|
||||||
|
|
||||||
const loadData = useCallback(async (p: string, pwd?: string) => {
|
|
||||||
if (!token) return;
|
|
||||||
setLoading(true);
|
|
||||||
setError('');
|
|
||||||
try {
|
|
||||||
let info = shareInfo;
|
|
||||||
if (!info) {
|
|
||||||
info = await shareApi.get(token);
|
|
||||||
setShareInfo(info);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (info?.access_type === 'password' && !verified) {
|
|
||||||
// Do not load files until password is verified
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentPassword = pwd || password;
|
|
||||||
const listing = await shareApi.listDir(token, p, currentPassword);
|
|
||||||
setEntries(listing.entries || []);
|
|
||||||
setCurrentPath(p);
|
|
||||||
} catch (e: any) {
|
|
||||||
setError(e.message || '加载分享失败');
|
|
||||||
if (e.message === '需要密码') {
|
|
||||||
setVerified(false);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [token, shareInfo, password, verified]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadData(currentPath);
|
|
||||||
}, [loadData, currentPath]);
|
|
||||||
|
|
||||||
const handleEntryClick = (entry: VfsEntry) => {
|
|
||||||
if (entry.is_dir) {
|
|
||||||
const newPath = (currentPath === '/' ? '' : currentPath) + '/' + entry.name;
|
|
||||||
loadData(newPath);
|
|
||||||
} else {
|
|
||||||
// Preview logic can be added here
|
|
||||||
message.info('暂不支持预览');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBreadcrumbClick = (path: string) => {
|
|
||||||
loadData(path);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderBreadcrumb = () => {
|
|
||||||
const parts = currentPath.split('/').filter(Boolean);
|
|
||||||
const items = [{ title: '全部文件', path: '/' }];
|
|
||||||
parts.forEach((part, i) => {
|
|
||||||
const path = '/' + parts.slice(0, i + 1).join('/');
|
|
||||||
items.push({ title: part, path });
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
<Breadcrumb>
|
|
||||||
{items.map((item, i) => (
|
|
||||||
<Breadcrumb.Item key={i}>
|
|
||||||
{i === items.length - 1 ? (
|
|
||||||
<span>{item.title}</span>
|
|
||||||
) : (
|
|
||||||
<a onClick={() => handleBreadcrumbClick(item.path)}>{item.title}</a>
|
|
||||||
)}
|
|
||||||
</Breadcrumb.Item>
|
|
||||||
))}
|
|
||||||
</Breadcrumb>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePasswordSubmit = async (values: { password_input: string }) => {
|
|
||||||
if (!token) return;
|
|
||||||
try {
|
|
||||||
await shareApi.verifyPassword(token, values.password_input);
|
|
||||||
setPassword(values.password_input);
|
|
||||||
setVerified(true);
|
|
||||||
setError('');
|
|
||||||
loadData(currentPath, values.password_input);
|
|
||||||
} catch (e: any) {
|
|
||||||
message.error(e.message || '密码错误');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading && !shareInfo) {
|
|
||||||
return <div style={{ textAlign: 'center', padding: 50 }}><Spin size="large" /></div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error && !error.includes('需要密码')) {
|
|
||||||
return <div style={{ textAlign: 'center', padding: 50 }}><Empty description={error} /></div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shareInfo?.access_type === 'password' && !verified) {
|
|
||||||
return (
|
|
||||||
<div style={{ padding: '24px', maxWidth: 400, margin: '100px auto' }}>
|
|
||||||
<Card title="需要密码">
|
|
||||||
<Form onFinish={handlePasswordSubmit}>
|
|
||||||
<Form.Item name="password_input" rules={[{ required: true, message: '请输入密码' }]}>
|
|
||||||
<Input.Password placeholder="请输入密码" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item>
|
|
||||||
<Button type="primary" htmlType="submit" block>
|
|
||||||
确认
|
|
||||||
</Button>
|
|
||||||
</Form.Item>
|
|
||||||
</Form>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ padding: '24px', maxWidth: 960, margin: 'auto' }}>
|
|
||||||
<Card>
|
|
||||||
<Title level={4}>{shareInfo?.name}</Title>
|
|
||||||
<Text type="secondary">
|
|
||||||
创建于 {shareInfo && format(parseISO(shareInfo.created_at), 'yyyy-MM-dd')}
|
|
||||||
{shareInfo?.expires_at && `,将于 ${format(parseISO(shareInfo.expires_at), 'yyyy-MM-dd')} 过期`}
|
|
||||||
</Text>
|
|
||||||
<div style={{ margin: '16px 0' }}>
|
|
||||||
{renderBreadcrumb()}
|
|
||||||
</div>
|
|
||||||
<List
|
|
||||||
loading={loading}
|
|
||||||
dataSource={entries}
|
|
||||||
renderItem={item => (
|
|
||||||
<List.Item
|
|
||||||
actions={[
|
|
||||||
!item.is_dir ? <Button type="text" icon={<DownloadOutlined />} href={shareApi.downloadUrl(token!, (currentPath === '/' ? '' : currentPath) + '/' + item.name, password)} download /> : null
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<List.Item.Meta
|
|
||||||
avatar={item.is_dir ? <FolderOutlined /> : <FileOutlined />}
|
|
||||||
title={<a onClick={() => handleEntryClick(item)}>{item.name}</a>}
|
|
||||||
description={!item.is_dir ? `${(item.size / 1024).toFixed(2)} KB` : ''}
|
|
||||||
/>
|
|
||||||
</List.Item>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default PublicSharePage;
|
|
||||||
110
web/src/pages/PublicSharePage/DirectoryViewer.tsx
Normal file
110
web/src/pages/PublicSharePage/DirectoryViewer.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { memo, useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Card, message, List, Typography, Button, Empty, Breadcrumb } from 'antd';
|
||||||
|
import { FileOutlined, FolderOutlined, DownloadOutlined } from '@ant-design/icons';
|
||||||
|
import { shareApi, type ShareInfo } from '../../api/share';
|
||||||
|
import { type VfsEntry } from '../../api/vfs';
|
||||||
|
import { format, parseISO } from 'date-fns';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
interface DirectoryViewerProps {
|
||||||
|
token: string;
|
||||||
|
shareInfo: ShareInfo;
|
||||||
|
password?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DirectoryViewer = memo(function DirectoryViewer({ token, shareInfo, password }: DirectoryViewerProps) {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [entries, setEntries] = useState<VfsEntry[]>([]);
|
||||||
|
const [currentPath, setCurrentPath] = useState('/');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const loadData = useCallback(async (p: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const listing = await shareApi.listDir(token, p, password);
|
||||||
|
setEntries(listing.entries || []);
|
||||||
|
setCurrentPath(p);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message || '加载分享失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [token, password]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData(currentPath);
|
||||||
|
}, [loadData, currentPath]);
|
||||||
|
|
||||||
|
const handleEntryClick = (entry: VfsEntry) => {
|
||||||
|
if (entry.is_dir) {
|
||||||
|
const newPath = (currentPath === '/' ? '' : currentPath) + '/' + entry.name;
|
||||||
|
loadData(newPath);
|
||||||
|
} else {
|
||||||
|
message.info('暂不支持预览');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBreadcrumbClick = (path: string) => {
|
||||||
|
loadData(path);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderBreadcrumb = () => {
|
||||||
|
const parts = currentPath.split('/').filter(Boolean);
|
||||||
|
const items = [{ title: '全部文件', path: '/' }];
|
||||||
|
parts.forEach((part, i) => {
|
||||||
|
const path = '/' + parts.slice(0, i + 1).join('/');
|
||||||
|
items.push({ title: part, path });
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<Breadcrumb>
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<Breadcrumb.Item key={i}>
|
||||||
|
{i === items.length - 1 ? (
|
||||||
|
<span>{item.title}</span>
|
||||||
|
) : (
|
||||||
|
<a onClick={() => handleBreadcrumbClick(item.path)}>{item.title}</a>
|
||||||
|
)}
|
||||||
|
</Breadcrumb.Item>
|
||||||
|
))}
|
||||||
|
</Breadcrumb>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <div style={{ textAlign: 'center', padding: 50 }}><Empty description={error} /></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '24px', maxWidth: 960, margin: 'auto' }}>
|
||||||
|
<Card>
|
||||||
|
<Title level={4}>{shareInfo?.name}</Title>
|
||||||
|
<Text type="secondary">
|
||||||
|
创建于 {shareInfo && format(parseISO(shareInfo.created_at), 'yyyy-MM-dd')}
|
||||||
|
{shareInfo?.expires_at && `,将于 ${format(parseISO(shareInfo.expires_at), 'yyyy-MM-dd')} 过期`}
|
||||||
|
</Text>
|
||||||
|
<div style={{ margin: '16px 0' }}>
|
||||||
|
{renderBreadcrumb()}
|
||||||
|
</div>
|
||||||
|
<List
|
||||||
|
loading={loading}
|
||||||
|
dataSource={entries}
|
||||||
|
renderItem={item => (
|
||||||
|
<List.Item
|
||||||
|
actions={[
|
||||||
|
!item.is_dir ? <Button type="text" icon={<DownloadOutlined />} href={shareApi.downloadUrl(token!, (currentPath === '/' ? '' : currentPath) + '/' + item.name, password)} download /> : null
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<List.Item.Meta
|
||||||
|
avatar={item.is_dir ? <FolderOutlined /> : <FileOutlined />}
|
||||||
|
title={<a onClick={() => handleEntryClick(item)}>{item.name}</a>}
|
||||||
|
description={!item.is_dir ? `${(item.size / 1024).toFixed(2)} KB` : ''}
|
||||||
|
/>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
102
web/src/pages/PublicSharePage/FileViewer.tsx
Normal file
102
web/src/pages/PublicSharePage/FileViewer.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { memo, useState, useEffect } from 'react';
|
||||||
|
import { Card, Spin, Button, Typography, Empty } from 'antd';
|
||||||
|
import { DownloadOutlined } from '@ant-design/icons';
|
||||||
|
import { shareApi, type ShareInfo } from '../../api/share';
|
||||||
|
import { type VfsEntry } from '../../api/vfs';
|
||||||
|
import { format, parseISO } from 'date-fns';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
interface FileViewerProps {
|
||||||
|
token: string;
|
||||||
|
shareInfo: ShareInfo;
|
||||||
|
entry: VfsEntry;
|
||||||
|
password?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FileViewer = memo(function FileViewer({ token, shareInfo, entry, password }: FileViewerProps) {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [content, setContent] = useState<string>('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadFileContent = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const url = shareApi.downloadUrl(token, entry.name, password);
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('无法加载文件');
|
||||||
|
}
|
||||||
|
const text = await response.text();
|
||||||
|
setContent(text);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message || '加载文件失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (entry.name.endsWith('.md')) {
|
||||||
|
loadFileContent();
|
||||||
|
} else {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [token, entry.name, password]);
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
if (loading) {
|
||||||
|
return <div style={{ textAlign: 'center', padding: 50 }}><Spin /></div>;
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
return <Empty description={error} />;
|
||||||
|
}
|
||||||
|
if (entry.name.endsWith('.md')) {
|
||||||
|
return <ReactMarkdown>{content}</ReactMarkdown>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Empty
|
||||||
|
description={
|
||||||
|
<div>
|
||||||
|
<p>暂不支持在线预览此类型文件</p>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<DownloadOutlined />}
|
||||||
|
href={shareApi.downloadUrl(token, entry.name, password)}
|
||||||
|
download
|
||||||
|
>
|
||||||
|
下载文件
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '24px', maxWidth: 960, margin: 'auto' }}>
|
||||||
|
<Card>
|
||||||
|
<Title level={4}>{entry.name}</Title>
|
||||||
|
<Text type="secondary">
|
||||||
|
创建于 {shareInfo && format(parseISO(shareInfo.created_at), 'yyyy-MM-dd')}
|
||||||
|
{shareInfo?.expires_at && `,将于 ${format(parseISO(shareInfo.expires_at), 'yyyy-MM-dd')} 过期`}
|
||||||
|
</Text>
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<Button
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
icon={<DownloadOutlined />}
|
||||||
|
href={shareApi.downloadUrl(token, entry.name, password)}
|
||||||
|
download
|
||||||
|
>
|
||||||
|
下载
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Card>
|
||||||
|
{renderContent()}
|
||||||
|
</Card>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
109
web/src/pages/PublicSharePage/index.tsx
Normal file
109
web/src/pages/PublicSharePage/index.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { memo, useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useParams } from 'react-router';
|
||||||
|
import { Card, message, Spin, Button, Empty, Input, Form } from 'antd';
|
||||||
|
import { shareApi, type ShareInfo } from '../../api/share';
|
||||||
|
import { type VfsEntry } from '../../api/vfs';
|
||||||
|
import { DirectoryViewer } from './DirectoryViewer';
|
||||||
|
import { FileViewer } from './FileViewer';
|
||||||
|
|
||||||
|
const PublicSharePage = memo(function PublicSharePage() {
|
||||||
|
const { token } = useParams<{ token: string }>();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [shareInfo, setShareInfo] = useState<ShareInfo | null>(null);
|
||||||
|
const [entry, setEntry] = useState<VfsEntry | null>(null);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [verified, setVerified] = useState(false);
|
||||||
|
|
||||||
|
const loadData = useCallback(async (pwd?: string) => {
|
||||||
|
if (!token) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
let info = shareInfo;
|
||||||
|
if (!info) {
|
||||||
|
info = await shareApi.get(token);
|
||||||
|
setShareInfo(info);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (info?.access_type === 'password' && !verified) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPassword = pwd || password;
|
||||||
|
|
||||||
|
if (info.paths.length === 1) {
|
||||||
|
const listing = await shareApi.listDir(token, '/', currentPassword);
|
||||||
|
if (listing.entries.length === 1) {
|
||||||
|
const singleEntry = listing.entries[0];
|
||||||
|
setEntry(singleEntry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message || '加载分享失败');
|
||||||
|
if (e.message === '需要密码') {
|
||||||
|
setVerified(false);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [token, shareInfo, password, verified]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
const handlePasswordSubmit = async (values: { password_input: string }) => {
|
||||||
|
if (!token) return;
|
||||||
|
try {
|
||||||
|
await shareApi.verifyPassword(token, values.password_input);
|
||||||
|
setPassword(values.password_input);
|
||||||
|
setVerified(true);
|
||||||
|
setError('');
|
||||||
|
loadData(values.password_input);
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e.message || '密码错误');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading && !shareInfo) {
|
||||||
|
return <div style={{ textAlign: 'center', padding: 50 }}><Spin size="large" /></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !error.includes('需要密码')) {
|
||||||
|
return <div style={{ textAlign: 'center', padding: 50 }}><Empty description={error} /></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shareInfo?.access_type === 'password' && !verified) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '24px', maxWidth: 400, margin: '100px auto' }}>
|
||||||
|
<Card title="需要密码">
|
||||||
|
<Form onFinish={handlePasswordSubmit}>
|
||||||
|
<Form.Item name="password_input" rules={[{ required: true, message: '请输入密码' }]}>
|
||||||
|
<Input.Password placeholder="请输入密码" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit" block>
|
||||||
|
确认
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shareInfo) {
|
||||||
|
return <div style={{ textAlign: 'center', padding: 50 }}><Empty description="无法加载分享信息" /></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry && !entry.is_dir) {
|
||||||
|
return <FileViewer token={token!} shareInfo={shareInfo} entry={entry} password={password} />;
|
||||||
|
} else {
|
||||||
|
return <DirectoryViewer token={token!} shareInfo={shareInfo} password={password} />;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default PublicSharePage;
|
||||||
@@ -26,7 +26,7 @@ const SetupPage = () => {
|
|||||||
root: values.root_dir
|
root: values.root_dir
|
||||||
},
|
},
|
||||||
sub_path: null,
|
sub_path: null,
|
||||||
mount_path: values.mount_path,
|
path: values.path,
|
||||||
enabled: true
|
enabled: true
|
||||||
});
|
});
|
||||||
window.location.href = '/';
|
window.location.href = '/';
|
||||||
@@ -41,7 +41,7 @@ const SetupPage = () => {
|
|||||||
|
|
||||||
const stepFields = [
|
const stepFields = [
|
||||||
['db_driver', 'vector_db_driver'],
|
['db_driver', 'vector_db_driver'],
|
||||||
['adapter_name', 'adapter_type', 'mount_path', 'root_dir'],
|
['adapter_name', 'adapter_type', 'path', 'root_dir'],
|
||||||
['username', 'full_name', 'email', 'password', 'confirm'],
|
['username', 'full_name', 'email', 'password', 'confirm'],
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -105,7 +105,7 @@ const SetupPage = () => {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="挂载路径"
|
label="挂载路径"
|
||||||
name="mount_path"
|
name="path"
|
||||||
initialValue="/local"
|
initialValue="/local"
|
||||||
rules={[{ required: true, message: '请输入挂载路径!' }]}
|
rules={[{ required: true, message: '请输入挂载路径!' }]}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import PageCard from '../components/PageCard';
|
|||||||
import { shareApi, type ShareInfo } from '../api/share';
|
import { shareApi, type ShareInfo } from '../api/share';
|
||||||
import { format, parseISO } from 'date-fns';
|
import { format, parseISO } from 'date-fns';
|
||||||
import { LinkOutlined, CopyOutlined, DeleteOutlined } from '@ant-design/icons';
|
import { LinkOutlined, CopyOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||||
|
import { useSystemStatus } from '../contexts/SystemContext';
|
||||||
|
|
||||||
const SharePage = memo(function SharePage() {
|
const SharePage = memo(function SharePage() {
|
||||||
|
const systemStatus = useSystemStatus();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [data, setData] = useState<ShareInfo[]>([]);
|
const [data, setData] = useState<ShareInfo[]>([]);
|
||||||
|
|
||||||
@@ -24,7 +26,8 @@ const SharePage = memo(function SharePage() {
|
|||||||
useEffect(() => { fetchList(); }, [fetchList]);
|
useEffect(() => { fetchList(); }, [fetchList]);
|
||||||
|
|
||||||
const doCopy = (rec: ShareInfo) => {
|
const doCopy = (rec: ShareInfo) => {
|
||||||
const shareUrl = `${window.location.origin}/share/${rec.token}`;
|
const baseUrl = systemStatus?.app_domain || window.location.origin;
|
||||||
|
const shareUrl = new URL(`/share/${rec.token}`, baseUrl).href;
|
||||||
navigator.clipboard.writeText(shareUrl);
|
navigator.clipboard.writeText(shareUrl);
|
||||||
message.success('链接已复制');
|
message.success('链接已复制');
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,26 +1,33 @@
|
|||||||
import { Form, Input, Button, message, Tabs, Space } from 'antd';
|
import { Form, Input, Button, message, Tabs, Space, Card } from 'antd';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import PageCard from '../../components/PageCard';
|
import PageCard from '../../components/PageCard';
|
||||||
import { getAllConfig, setConfig } from '../../api/config';
|
import { getAllConfig, setConfig } from '../../api/config';
|
||||||
import { API_BASE_URL } from '../../api/client';
|
|
||||||
import { AppstoreOutlined, RobotOutlined } from '@ant-design/icons';
|
import { AppstoreOutlined, RobotOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
const APP_CONFIG_KEYS = [
|
const APP_CONFIG_KEYS: {key: string, label: string, default?: string}[] = [
|
||||||
{ key: 'APP_NAME', label: '应用名称' },
|
{ key: 'APP_NAME', label: '应用名称' },
|
||||||
{ key: 'APP_LOGO', label: 'LOGO地址' },
|
{ key: 'APP_LOGO', label: 'LOGO地址' },
|
||||||
{ key: 'SERVER_URL', label: '服务端URL', default: API_BASE_URL },
|
{ key: 'APP_DOMAIN', label: '应用域名' },
|
||||||
|
{ key: 'FILE_DOMAIN', label: '文件域名' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const AI_CONFIG_KEYS = [
|
const VISION_CONFIG_KEYS = [
|
||||||
{ key: 'AI_API_URL', label: 'AI API地址' },
|
{ key: 'AI_VISION_API_URL', label: '视觉模型 API 地址' },
|
||||||
{ key: 'AI_VISION_MODEL', label: '视觉模型' },
|
{ key: 'AI_VISION_MODEL', label: '视觉模型', default: 'Qwen/Qwen2.5-VL-32B-Instruct' },
|
||||||
{ key: 'AI_EMBED_MODEL', label: '嵌入模型' },
|
{ key: 'AI_VISION_API_KEY', label: '视觉模型 API Key' },
|
||||||
{ key: 'AI_API_KEY', label: 'API Key' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const EMBED_CONFIG_KEYS = [
|
||||||
|
{ key: 'AI_EMBED_API_URL', label: '嵌入模型 API 地址' },
|
||||||
|
{ key: 'AI_EMBED_MODEL', label: '嵌入模型', default: 'Qwen/Qwen3-Embedding-8B' },
|
||||||
|
{ key: 'AI_EMBED_API_KEY', label: '嵌入模型 API Key' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ALL_AI_KEYS = [...VISION_CONFIG_KEYS, ...EMBED_CONFIG_KEYS];
|
||||||
|
|
||||||
export default function SystemSettingsPage() {
|
export default function SystemSettingsPage() {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [config, setConfigState] = useState<Record<string, string> | null>(null);
|
const [config, setConfigState] = useState<Record<string, string> | null>(null);
|
||||||
const [activeTab, setActiveTab] = useState('app');
|
const [activeTab, setActiveTab] = useState('app');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -41,14 +48,13 @@ export default function SystemSettingsPage() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 加载中时不渲染表单
|
|
||||||
if (!config) {
|
if (!config) {
|
||||||
return <PageCard title='系统设置'><div>加载中...</div></PageCard>;
|
return <PageCard title='系统设置'><div>加载中...</div></PageCard>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageCard
|
<PageCard
|
||||||
title='系统设置'
|
title='系统设置'
|
||||||
>
|
>
|
||||||
<Space direction="vertical" style={{ width: '100%' }} size={32}>
|
<Space direction="vertical" style={{ width: '100%' }} size={32}>
|
||||||
<Tabs
|
<Tabs
|
||||||
@@ -73,7 +79,7 @@ export default function SystemSettingsPage() {
|
|||||||
}}
|
}}
|
||||||
onFinish={handleSave}
|
onFinish={handleSave}
|
||||||
style={{ marginTop: 24 }}
|
style={{ marginTop: 24 }}
|
||||||
key={JSON.stringify(config)}
|
key={JSON.stringify(config)}
|
||||||
>
|
>
|
||||||
{APP_CONFIG_KEYS.map(({ key, label }) => (
|
{APP_CONFIG_KEYS.map(({ key, label }) => (
|
||||||
<Form.Item key={key} name={key} label={label}>
|
<Form.Item key={key} name={key} label={label}>
|
||||||
@@ -100,18 +106,27 @@ export default function SystemSettingsPage() {
|
|||||||
<Form
|
<Form
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
initialValues={{
|
initialValues={{
|
||||||
...Object.fromEntries(AI_CONFIG_KEYS.map(({ key }) => [key, config[key] ?? ''])),
|
...Object.fromEntries(ALL_AI_KEYS.map(({ key, default: def }) => [key, config[key] ?? def ?? ''])),
|
||||||
}}
|
}}
|
||||||
onFinish={handleSave}
|
onFinish={handleSave}
|
||||||
style={{ marginTop: 24 }}
|
style={{ marginTop: 24 }}
|
||||||
key={JSON.stringify(config)} // 强制表单重置
|
key={JSON.stringify(config)}
|
||||||
>
|
>
|
||||||
{AI_CONFIG_KEYS.map(({ key, label }) => (
|
<Card title="视觉模型" style={{ marginBottom: 24 }}>
|
||||||
<Form.Item key={key} name={key} label={label}>
|
{VISION_CONFIG_KEYS.map(({ key, label }) => (
|
||||||
<Input size="large" />
|
<Form.Item key={key} name={key} label={label}>
|
||||||
</Form.Item>
|
<Input size="large" />
|
||||||
))}
|
</Form.Item>
|
||||||
<Form.Item>
|
))}
|
||||||
|
</Card>
|
||||||
|
<Card title="嵌入模型">
|
||||||
|
{EMBED_CONFIG_KEYS.map(({ key, label }) => (
|
||||||
|
<Form.Item key={key} name={key} label={label}>
|
||||||
|
<Input size="large" />
|
||||||
|
</Form.Item>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
<Form.Item style={{ marginTop: 24 }}>
|
||||||
<Button type="primary" htmlType="submit" loading={loading} block>
|
<Button type="primary" htmlType="submit" loading={loading} block>
|
||||||
保存
|
保存
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { RouteObject } from 'react-router';
|
|||||||
import LayoutShell from './LayoutShell.tsx';
|
import LayoutShell from './LayoutShell.tsx';
|
||||||
import LoginPage from '../pages/LoginPage.tsx';
|
import LoginPage from '../pages/LoginPage.tsx';
|
||||||
import SetupPage from '../pages/SetupPage.tsx';
|
import SetupPage from '../pages/SetupPage.tsx';
|
||||||
import PublicSharePage from '../pages/PublicSharePage.tsx';
|
import PublicSharePage from '../pages/PublicSharePage';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import type { JSX } from 'react';
|
import type { JSX } from 'react';
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { ThemeConfig } from 'antd/es/config-provider/context';
|
|||||||
export const foxelTheme: ThemeConfig = {
|
export const foxelTheme: ThemeConfig = {
|
||||||
algorithm: [theme.defaultAlgorithm, theme.compactAlgorithm],
|
algorithm: [theme.defaultAlgorithm, theme.compactAlgorithm],
|
||||||
token: {
|
token: {
|
||||||
|
colorInfoBg: '#efefef',
|
||||||
colorPrimary: '#111',
|
colorPrimary: '#111',
|
||||||
colorInfo: '#111',
|
colorInfo: '#111',
|
||||||
colorText: '#111',
|
colorText: '#111',
|
||||||
@@ -37,7 +38,7 @@ export const foxelTheme: ThemeConfig = {
|
|||||||
},
|
},
|
||||||
Card: {
|
Card: {
|
||||||
borderRadiusLG: 16,
|
borderRadiusLG: 16,
|
||||||
padding: 16
|
padding: 16
|
||||||
},
|
},
|
||||||
Input: { borderRadius: 8 },
|
Input: { borderRadius: 8 },
|
||||||
Dropdown: { controlItemBgHover: '#f2f2f2' },
|
Dropdown: { controlItemBgHover: '#f2f2f2' },
|
||||||
|
|||||||
Reference in New Issue
Block a user