mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-05-09 07:32:42 +08:00
Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b8cfce8f2 | ||
|
|
db453ef09b | ||
|
|
59c017a05b | ||
|
|
d42c6b5cee | ||
|
|
9e69eb3e20 | ||
|
|
6e7225ac40 | ||
|
|
d41b72d0ce | ||
|
|
f40ff4d751 | ||
|
|
280bedcf1a | ||
|
|
b03f2619ca | ||
|
|
72403d5861 | ||
|
|
dffcdb7a8b | ||
|
|
19c4394f3d | ||
|
|
3fd48da2b4 | ||
|
|
c759b36aba | ||
|
|
99a6acd54a | ||
|
|
20f6b5c210 | ||
|
|
74ffc0bb30 | ||
|
|
57919aa7ae | ||
|
|
5126dae411 | ||
|
|
2a78d809af | ||
|
|
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
|
||||
6
.github/workflows/docker.yml
vendored
6
.github/workflows/docker.yml
vendored
@@ -32,7 +32,7 @@ jobs:
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
echo "DOCKER_TAGS=ghcr.io/${REPO_LC}:${VERSION},ghcr.io/${REPO_LC}:latest" >> $GITHUB_ENV
|
||||
else
|
||||
echo "DOCKER_TAGS=ghcr.io/${REPO_LC}:latest" >> $GITHUB_ENV
|
||||
echo "DOCKER_TAGS=ghcr.io/${REPO_LC}:dev" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
@@ -42,10 +42,10 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
- name: Build and push Docker image (multi arch)
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ env.DOCKER_TAGS }}
|
||||
17
.github/workflows/release-drafter.yml
vendored
Normal file
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 }}
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -5,5 +5,6 @@ __pycache__/
|
||||
.venv/
|
||||
.vscode/
|
||||
data/
|
||||
|
||||
.env
|
||||
migrate/
|
||||
.env
|
||||
AGENTS.md
|
||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.13
|
||||
@@ -70,8 +70,10 @@
|
||||
|
||||
2. **创建并激活 Python 虚拟环境**
|
||||
|
||||
我们推荐使用 `uv` 来管理虚拟环境,以获得最佳性能。
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
uv venv
|
||||
source .venv/bin/activate
|
||||
# On Windows: .venv\Scripts\activate
|
||||
```
|
||||
@@ -79,10 +81,27 @@
|
||||
3. **安装依赖**
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
uv sync
|
||||
```
|
||||
|
||||
4. **启动开发服务器**
|
||||
4. **初始化环境**
|
||||
|
||||
在启动服务前,请进行以下准备:
|
||||
|
||||
- **创建数据目录**:
|
||||
在项目根目录执行 `mkdir -p data/db`。这将创建用于存放数据库等文件的目录。
|
||||
> [!IMPORTANT]
|
||||
> 请确保应用拥有对 `data/db` 目录的读写权限。
|
||||
|
||||
- **创建 `.env` 配置文件**:
|
||||
在项目根目录创建名为 `.env` 的文件,并填入以下内容。这些密钥用于保障应用安全,您可以按需修改。
|
||||
|
||||
```dotenv
|
||||
SECRET_KEY=EnsRhL9NFPxgFVc+7t96/y70DIOR+9SpntcIqQa90TU=
|
||||
TEMP_LINK_SECRET_KEY=EnsRhL9NFPxgFVc+7t96/y70DIOR+9SpntcIqQa90TU=
|
||||
```
|
||||
|
||||
5. **启动开发服务器**
|
||||
|
||||
```bash
|
||||
uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
||||
|
||||
@@ -13,10 +13,13 @@ FROM python:3.13-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y nginx && rm -rf /var/lib/apt/lists/*
|
||||
RUN apt-get update && apt-get install -y nginx git && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt && pip install gunicorn
|
||||
RUN pip install uv
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN uv pip install --system . gunicorn
|
||||
|
||||
RUN git clone https://github.com/DrizzleTime/FoxelUpgrade /app/migrate
|
||||
|
||||
COPY --from=frontend-builder /app/web/dist /app/web/dist
|
||||
|
||||
|
||||
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">
|
||||
|
||||
# Foxel
|
||||
|
||||
**一个面向个人和团队的、高度可扩展的私有云盘解决方案,支持 AI 语义搜索。**
|
||||
**A highly extensible private cloud storage solution for individuals and teams, featuring AI-powered semantic search.**
|
||||
|
||||

|
||||

|
||||
@@ -11,26 +15,31 @@
|
||||
|
||||
---
|
||||
<blockquote>
|
||||
<em><strong>数据之洋浩瀚无涯,当以洞察之目引航,然其脉络深隐,非表象所能尽窥。</strong></em><br>
|
||||
<em><strong>The ocean of data is boundless, let the eye of insight guide the voyage, yet its intricate connections lie deep, not fully discernible from the surface.</strong></em>
|
||||
</blockquote>
|
||||
</div>
|
||||
|
||||
## ✨ 核心功能
|
||||
## 👀 Online Demo
|
||||
|
||||
- **统一文件管理**:集中管理分布于不同存储后端的文件。
|
||||
- **插件化存储后端**:采用可扩展的适配器模式,方便集成多种存储类型。
|
||||
- **语义搜索**:支持自然语言描述搜索图片、文档等非结构化数据内容。
|
||||
- **内置文件预览**:可直接预览图片、视频、PDF、Office 文档及文本、代码文件,无需下载。
|
||||
- **权限与分享**:支持公开或私密分享链接,便于文件共享。
|
||||
- **任务处理中心**:支持异步任务处理,如文件索引和数据备份,不影响主应用运行。
|
||||
> [https://demo.foxel.cc](https://demo.foxel.cc)
|
||||
>
|
||||
> Account/Password: `admin` / `admin`
|
||||
|
||||
## 🚀 快速开始
|
||||
## ✨ Core Features
|
||||
|
||||
使用 Docker Compose 是启动 Foxel 最推荐的方式。
|
||||
- **Unified File Management**: Centralize management of files distributed across different storage backends.
|
||||
- **Pluggable Storage Backends**: Utilizes an extensible adapter pattern to easily integrate various storage types.
|
||||
- **Semantic Search**: Supports natural language search for content within unstructured data like images and documents.
|
||||
- **Built-in File Preview**: Preview images, videos, PDFs, Office documents, text, and code files directly without downloading.
|
||||
- **Permissions and Sharing**: Supports public or private sharing links for easy file distribution.
|
||||
- **Task Processing Center**: Supports asynchronous task processing, such as file indexing and data backups, without impacting the main application.
|
||||
|
||||
1. **创建数据目录**:
|
||||
新建 `data` 文件夹用于持久化数据:
|
||||
## 🚀 Quick Start
|
||||
|
||||
Using Docker Compose is the most recommended way to start Foxel.
|
||||
|
||||
1. **Create Data Directories**:
|
||||
Create a `data` folder for persistent data:
|
||||
|
||||
```bash
|
||||
mkdir -p data/db
|
||||
@@ -38,40 +47,40 @@ mkdir -p data/mount
|
||||
chmod 777 data/db data/mount
|
||||
```
|
||||
|
||||
2. **下载 Docker Compose 文件**:
|
||||
2. **Download Docker Compose File**:
|
||||
|
||||
```bash
|
||||
curl -L -O https://github.com/DrizzleTime/Foxel/raw/main/compose.yaml
|
||||
```
|
||||
|
||||
下载完成后,**强烈建议**修改 `compose.yaml` 文件中的环境变量以确保安全:
|
||||
After downloading, it is **strongly recommended** to modify the environment variables in the `compose.yaml` file to ensure security:
|
||||
|
||||
- 修改 `SECRET_KEY` 和 `TEMP_LINK_SECRET_KEY`:将默认的密钥替换为随机生成的强密钥
|
||||
- Modify `SECRET_KEY` and `TEMP_LINK_SECRET_KEY`: Replace the default keys with randomly generated strong keys.
|
||||
|
||||
3. **启动服务**:
|
||||
3. **Start the Services**:
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
4. **访问应用**:
|
||||
4. **Access the Application**:
|
||||
|
||||
服务启动后,在浏览器中打开页面。
|
||||
Once the services are running, open the page in your browser.
|
||||
|
||||
> 首次启动,请根据引导页面完成管理员账号的初始化设置。
|
||||
> On the first launch, please follow the setup guide to initialize the administrator account.
|
||||
|
||||
## 🤝 如何贡献
|
||||
## 🤝 How to Contribute
|
||||
|
||||
我们非常欢迎来自社区的贡献!无论是提交 Bug、建议新功能还是直接贡献代码。
|
||||
We welcome contributions from the community! Whether it's submitting bugs, suggesting new features, or contributing code directly.
|
||||
|
||||
在开始之前,请先阅读我们的 [`CONTRIBUTING.md`](CONTRIBUTING.md) 文件,它会指导你如何设置开发环境以及提交流程。
|
||||
Before you start, please read our [`CONTRIBUTING.md`](CONTRIBUTING.md) file, which will guide you on how to set up your development environment and the submission process.
|
||||
|
||||
## 🌐 社区
|
||||
## 🌐 Community
|
||||
|
||||
加入我们的交流社区:[Telegram 群组](https://t.me/+thDsBfyqJxZkNTU1),与开发者和用户一起讨论!
|
||||
Join our community on [Telegram](https://t.me/+thDsBfyqJxZkNTU1) to discuss with developers and other users!
|
||||
|
||||
你也可以加入我们的微信群,获取更多实时交流与支持。请扫描下方二维码加入:
|
||||
You can also join our WeChat group for more real-time communication and support. Please scan the QR code below to join:
|
||||
|
||||
<img src="https://foxel.cc/image/wechat.png" alt="微信群二维码" width="180">
|
||||
<img src="https://foxel.cc/image/wechat.png" alt="WeChat Group QR Code" width="180">
|
||||
|
||||
> 如果二维码失效,请添加微信号 **drizzle2001**,我们会邀请你加入群聊。
|
||||
> If the QR code is invalid, please add WeChat ID **drizzle2001**, and we will invite you to the group.
|
||||
|
||||
87
README_zh.md
Normal file
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,13 @@
|
||||
from fastapi import FastAPI
|
||||
|
||||
from .routes import adapters, virtual_fs, mounts, auth, config, processors, tasks, logs, share, backup, search
|
||||
from .routes import adapters, virtual_fs, auth, config, processors, tasks, logs, share, backup, search, vector_db
|
||||
from .routes import plugins
|
||||
|
||||
|
||||
def include_routers(app: FastAPI):
|
||||
app.include_router(adapters.router)
|
||||
app.include_router(virtual_fs.router)
|
||||
app.include_router(search.router)
|
||||
app.include_router(mounts.router)
|
||||
app.include_router(auth.router)
|
||||
app.include_router(config.router)
|
||||
app.include_router(processors.router)
|
||||
@@ -15,4 +15,6 @@ def include_routers(app: FastAPI):
|
||||
app.include_router(logs.router)
|
||||
app.include_router(share.router)
|
||||
app.include_router(share.public_router)
|
||||
app.include_router(backup.router)
|
||||
app.include_router(backup.router)
|
||||
app.include_router(vector_db.router)
|
||||
app.include_router(plugins.router)
|
||||
@@ -2,7 +2,7 @@ from fastapi import APIRouter, HTTPException, Depends
|
||||
from tortoise.transactions import in_transaction
|
||||
from typing import Annotated
|
||||
|
||||
from models import StorageAdapter, Mount
|
||||
from models import StorageAdapter
|
||||
from schemas import AdapterCreate, AdapterOut
|
||||
from services.auth import get_current_active_user, User
|
||||
from services.adapters.registry import runtime_registry, get_config_schemas
|
||||
@@ -39,27 +39,22 @@ async def create_adapter(
|
||||
data: AdapterCreate,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)]
|
||||
):
|
||||
norm_path = AdapterCreate.normalize_mount_path(data.path)
|
||||
exists = await StorageAdapter.get_or_none(path=norm_path)
|
||||
if exists:
|
||||
raise HTTPException(400, detail="Mount path already exists")
|
||||
|
||||
adapter_fields = {
|
||||
"name": data.name,
|
||||
"type": data.type,
|
||||
"config": validate_and_normalize_config(data.type, data.config or {}),
|
||||
"enabled": data.enabled,
|
||||
"path": norm_path,
|
||||
"sub_path": data.sub_path,
|
||||
}
|
||||
norm_path = AdapterCreate.normalize_mount_path(data.mount_path)
|
||||
exists = await Mount.get_or_none(path=norm_path)
|
||||
if exists:
|
||||
raise HTTPException(400, detail="Mount path already exists")
|
||||
async with in_transaction():
|
||||
rec = await StorageAdapter.create(**adapter_fields)
|
||||
await Mount.create(
|
||||
path=norm_path,
|
||||
sub_path=data.sub_path,
|
||||
adapter=rec,
|
||||
enabled=True,
|
||||
)
|
||||
rec.mount_path = norm_path
|
||||
rec.sub_path = data.sub_path
|
||||
await runtime_registry.refresh()
|
||||
|
||||
rec = await StorageAdapter.create(**adapter_fields)
|
||||
await runtime_registry.upsert(rec)
|
||||
await LogService.action(
|
||||
"route:adapters",
|
||||
f"Created adapter {rec.name}",
|
||||
@@ -73,20 +68,8 @@ async def create_adapter(
|
||||
async def list_adapters(
|
||||
current_user: Annotated[User, Depends(get_current_active_user)]
|
||||
):
|
||||
adapters = await StorageAdapter.all().prefetch_related("mounts")
|
||||
out = []
|
||||
for a in adapters:
|
||||
mount = a.mounts[0] if a.mounts else None
|
||||
item = AdapterOut(
|
||||
name=a.name,
|
||||
type=a.type,
|
||||
config=a.config,
|
||||
enabled=a.enabled,
|
||||
id=a.id,
|
||||
mount_path=mount.path if mount else None,
|
||||
sub_path=mount.sub_path if mount else None
|
||||
)
|
||||
out.append(item)
|
||||
adapters = await StorageAdapter.all()
|
||||
out = [AdapterOut.model_validate(a) for a in adapters]
|
||||
return success(out)
|
||||
|
||||
|
||||
@@ -109,13 +92,10 @@ async def get_adapter(
|
||||
adapter_id: int,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)]
|
||||
):
|
||||
rec = await StorageAdapter.get_or_none(id=adapter_id).prefetch_related("mounts")
|
||||
rec = await StorageAdapter.get_or_none(id=adapter_id)
|
||||
if not rec:
|
||||
raise HTTPException(404, detail="Not found")
|
||||
mount = rec.mounts[0] if rec.mounts else None
|
||||
rec.mount_path = mount.path if mount else None
|
||||
rec.sub_path = mount.sub_path if mount else None
|
||||
return success(rec)
|
||||
return success(AdapterOut.model_validate(rec))
|
||||
|
||||
|
||||
@router.put("/{adapter_id}")
|
||||
@@ -124,34 +104,24 @@ async def update_adapter(
|
||||
data: AdapterCreate,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)]
|
||||
):
|
||||
|
||||
rec = await StorageAdapter.get_or_none(id=adapter_id).prefetch_related("mounts")
|
||||
rec = await StorageAdapter.get_or_none(id=adapter_id)
|
||||
if not rec:
|
||||
raise HTTPException(404, detail="Not found")
|
||||
norm_path = AdapterCreate.normalize_mount_path(data.mount_path)
|
||||
existing = await Mount.get_or_none(path=norm_path)
|
||||
mount = rec.mounts[0] if rec.mounts else None
|
||||
if existing and (not mount or existing.id != mount.id):
|
||||
|
||||
norm_path = AdapterCreate.normalize_mount_path(data.path)
|
||||
existing = await StorageAdapter.get_or_none(path=norm_path)
|
||||
if existing and existing.id != adapter_id:
|
||||
raise HTTPException(400, detail="Mount path already exists")
|
||||
|
||||
rec.name = data.name
|
||||
rec.type = data.type
|
||||
rec.config = validate_and_normalize_config(data.type, data.config or {})
|
||||
rec.enabled = data.enabled
|
||||
rec.path = norm_path
|
||||
rec.sub_path = data.sub_path
|
||||
await rec.save()
|
||||
if mount:
|
||||
mount.path = norm_path
|
||||
mount.sub_path = data.sub_path
|
||||
await mount.save()
|
||||
else:
|
||||
mount = await Mount.create(
|
||||
path=norm_path,
|
||||
sub_path=data.sub_path,
|
||||
adapter=rec,
|
||||
enabled=True,
|
||||
)
|
||||
rec.mount_path = mount.path
|
||||
rec.sub_path = mount.sub_path
|
||||
await runtime_registry.refresh()
|
||||
|
||||
await runtime_registry.upsert(rec)
|
||||
await LogService.action(
|
||||
"route:adapters",
|
||||
f"Updated adapter {rec.name}",
|
||||
@@ -169,7 +139,7 @@ async def delete_adapter(
|
||||
deleted = await StorageAdapter.filter(id=adapter_id).delete()
|
||||
if not deleted:
|
||||
raise HTTPException(404, detail="Not found")
|
||||
await runtime_registry.refresh()
|
||||
runtime_registry.remove(adapter_id)
|
||||
await LogService.action(
|
||||
"route:adapters",
|
||||
f"Deleted adapter {adapter_id}",
|
||||
|
||||
@@ -41,7 +41,9 @@ async def get_system_status():
|
||||
"version": VERSION,
|
||||
"title": await ConfigCenter.get("APP_NAME", "Foxel"),
|
||||
"logo": await ConfigCenter.get("APP_LOGO", "/logo.svg"),
|
||||
"is_initialized": await has_users()
|
||||
"is_initialized": await has_users(),
|
||||
"app_domain": await ConfigCenter.get("APP_DOMAIN"),
|
||||
"file_domain": await ConfigCenter.get("FILE_DOMAIN"),
|
||||
}
|
||||
return success(system_info)
|
||||
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from typing import Annotated
|
||||
|
||||
from models import StorageAdapter, Mount
|
||||
from schemas import MountCreate, MountOut
|
||||
from api.response import success
|
||||
from services.auth import get_current_active_user, User
|
||||
from services.logging import LogService
|
||||
|
||||
router = APIRouter(prefix="/api/mounts", tags=["mounts"])
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_mount(
|
||||
data: MountCreate,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
):
|
||||
adapter = await StorageAdapter.get_or_none(id=data.adapter_id)
|
||||
if not adapter:
|
||||
raise HTTPException(400, detail="Adapter not found")
|
||||
rec = await Mount.create(
|
||||
path=MountCreate.normalize(data.path),
|
||||
adapter=adapter,
|
||||
sub_path=data.sub_path,
|
||||
enabled=data.enabled,
|
||||
)
|
||||
await LogService.action(
|
||||
"route:mounts",
|
||||
f"Created mount {rec.path}",
|
||||
details=data.model_dump(),
|
||||
user_id=current_user.id if hasattr(current_user, "id") else None,
|
||||
)
|
||||
return success(rec)
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_mounts(
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
):
|
||||
recs = await Mount.all()
|
||||
return success(recs)
|
||||
|
||||
|
||||
@router.put("/{mount_id}")
|
||||
async def update_mount(
|
||||
mount_id: int,
|
||||
data: MountCreate,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
):
|
||||
rec = await Mount.get_or_none(id=mount_id)
|
||||
if not rec:
|
||||
raise HTTPException(404, detail="Not found")
|
||||
adapter = await StorageAdapter.get_or_none(id=data.adapter_id)
|
||||
if not adapter:
|
||||
raise HTTPException(400, detail="Adapter not found")
|
||||
rec.path = MountCreate.normalize(data.path)
|
||||
rec.adapter = adapter
|
||||
rec.sub_path = data.sub_path
|
||||
rec.enabled = data.enabled
|
||||
await rec.save()
|
||||
await LogService.action(
|
||||
"route:mounts",
|
||||
f"Updated mount {rec.path}",
|
||||
details=data.model_dump(),
|
||||
user_id=current_user.id if hasattr(current_user, "id") else None,
|
||||
)
|
||||
return success(rec)
|
||||
|
||||
|
||||
@router.delete("/{mount_id}")
|
||||
async def delete_mount(
|
||||
mount_id: int,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
):
|
||||
deleted = await Mount.filter(id=mount_id).delete()
|
||||
if not deleted:
|
||||
raise HTTPException(404, detail="Not found")
|
||||
await LogService.action(
|
||||
"route:mounts",
|
||||
f"Deleted mount {mount_id}",
|
||||
details={"mount_id": mount_id},
|
||||
user_id=current_user.id if hasattr(current_user, "id") else None,
|
||||
)
|
||||
return success({"deleted": True})
|
||||
73
api/routes/plugins.py
Normal file
73
api/routes/plugins.py
Normal file
@@ -0,0 +1,73 @@
|
||||
from typing import List, Any, Dict
|
||||
from fastapi import APIRouter, HTTPException, Body
|
||||
from models import database
|
||||
from schemas import PluginCreate, PluginOut
|
||||
|
||||
router = APIRouter(prefix="/api/plugins", tags=["plugins"])
|
||||
|
||||
|
||||
@router.post("", response_model=PluginOut)
|
||||
async def create_plugin(payload: PluginCreate):
|
||||
rec = await database.Plugin.create(
|
||||
url=payload.url,
|
||||
enabled=payload.enabled,
|
||||
)
|
||||
return PluginOut.model_validate(rec)
|
||||
|
||||
|
||||
@router.get("", response_model=List[PluginOut])
|
||||
async def list_plugins():
|
||||
rows = await database.Plugin.all().order_by("-id")
|
||||
return [PluginOut.model_validate(r) for r in rows]
|
||||
|
||||
|
||||
@router.delete("/{plugin_id}")
|
||||
async def delete_plugin(plugin_id: int):
|
||||
rec = await database.Plugin.get_or_none(id=plugin_id)
|
||||
if not rec:
|
||||
raise HTTPException(status_code=404, detail="Plugin not found")
|
||||
await rec.delete()
|
||||
return {"code": 0, "msg": "ok"}
|
||||
|
||||
|
||||
@router.put("/{plugin_id}", response_model=PluginOut)
|
||||
async def update_plugin(plugin_id: int, payload: PluginCreate):
|
||||
rec = await database.Plugin.get_or_none(id=plugin_id)
|
||||
if not rec:
|
||||
raise HTTPException(status_code=404, detail="Plugin not found")
|
||||
rec.url = payload.url
|
||||
rec.enabled = payload.enabled
|
||||
await rec.save()
|
||||
return PluginOut.model_validate(rec)
|
||||
|
||||
|
||||
@router.post("/{plugin_id}/metadata", response_model=PluginOut)
|
||||
async def update_manifest(plugin_id: int, manifest: Dict[str, Any] = Body(...)):
|
||||
rec = await database.Plugin.get_or_none(id=plugin_id)
|
||||
if not rec:
|
||||
raise HTTPException(status_code=404, detail="Plugin not found")
|
||||
key_map = {
|
||||
'key': 'key',
|
||||
'name': 'name',
|
||||
'version': 'version',
|
||||
'supported_exts': 'supported_exts',
|
||||
'supportedExts': 'supported_exts',
|
||||
'default_bounds': 'default_bounds',
|
||||
'defaultBounds': 'default_bounds',
|
||||
'default_maximized': 'default_maximized',
|
||||
'defaultMaximized': 'default_maximized',
|
||||
'icon': 'icon',
|
||||
'description': 'description',
|
||||
'author': 'author',
|
||||
'website': 'website',
|
||||
'github': 'github',
|
||||
}
|
||||
for k, v in list(manifest.items()):
|
||||
if v is None:
|
||||
continue
|
||||
attr = key_map.get(k)
|
||||
if not attr:
|
||||
continue
|
||||
setattr(rec, attr, v)
|
||||
await rec.save()
|
||||
return PluginOut.model_validate(rec)
|
||||
@@ -1,7 +1,7 @@
|
||||
from fastapi import APIRouter, Depends, Body
|
||||
from typing import Annotated
|
||||
from services.processors.registry import get_config_schemas
|
||||
from services.virtual_fs import process_file
|
||||
from services.task_queue import task_queue_service
|
||||
from services.auth import get_current_active_user, User
|
||||
from api.response import success
|
||||
from pydantic import BaseModel
|
||||
@@ -21,7 +21,7 @@ async def list_processors(
|
||||
"name": meta["name"],
|
||||
"supported_exts": meta.get("supported_exts", []),
|
||||
"config_schema": meta["config_schema"],
|
||||
"produces_file": meta.get("produces_file", False),
|
||||
"produces_file": meta.get("produces_file", False),
|
||||
})
|
||||
return success(out)
|
||||
|
||||
@@ -40,5 +40,13 @@ async def process_file_with_processor(
|
||||
req: ProcessRequest = Body(...)
|
||||
):
|
||||
save_to = req.path if req.overwrite else req.save_to
|
||||
result = await process_file(req.path, req.processor_type, req.config, save_to)
|
||||
return success(result)
|
||||
task = await task_queue_service.add_task(
|
||||
"process_file",
|
||||
{
|
||||
"path": req.path,
|
||||
"processor_type": req.processor_type,
|
||||
"config": req.config,
|
||||
"save_to": save_to,
|
||||
},
|
||||
)
|
||||
return success({"task_id": task.id})
|
||||
|
||||
@@ -6,6 +6,7 @@ from schemas.tasks import AutomationTaskCreate, AutomationTaskUpdate
|
||||
from api.response import success
|
||||
from services.auth import get_current_active_user, User
|
||||
from services.logging import LogService
|
||||
from services.task_queue import task_queue_service
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/tasks",
|
||||
@@ -15,6 +16,25 @@ router = APIRouter(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/queue")
|
||||
async def get_task_queue_status(
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
):
|
||||
tasks = task_queue_service.get_all_tasks()
|
||||
return success([task.dict() for task in tasks])
|
||||
|
||||
|
||||
@router.get("/queue/{task_id}")
|
||||
async def get_task_status(
|
||||
task_id: str,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
):
|
||||
task = task_queue_service.get_task(task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
return success(task.dict())
|
||||
|
||||
|
||||
@router.post("/")
|
||||
async def create_task(
|
||||
task_in: AutomationTaskCreate,
|
||||
|
||||
19
api/routes/vector_db.py
Normal file
19
api/routes/vector_db.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from services.auth import get_current_active_user
|
||||
from models.database import UserAccount
|
||||
from services.vector_db import VectorDBService
|
||||
from api.response import success
|
||||
|
||||
router = APIRouter(prefix="/api/vector-db", tags=["vector-db"])
|
||||
|
||||
|
||||
@router.post("/clear-all", summary="清空向量数据库")
|
||||
async def clear_vector_db(user: UserAccount = Depends(get_current_active_user)):
|
||||
if user.username != 'admin':
|
||||
raise HTTPException(status_code=403, detail="仅管理员可操作")
|
||||
try:
|
||||
service = VectorDBService()
|
||||
service.clear_all_data()
|
||||
return success(msg="向量数据库已清空")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -19,6 +19,7 @@ from services.virtual_fs import (
|
||||
from services.thumbnail import is_image_filename, get_or_create_thumb, is_raw_filename
|
||||
from schemas import MkdirRequest, MoveRequest
|
||||
from api.response import success
|
||||
from services.config import ConfigCenter
|
||||
|
||||
router = APIRouter(prefix='/api/fs', tags=["virtual-fs"])
|
||||
|
||||
@@ -116,7 +117,7 @@ async def get_thumb(
|
||||
if not is_image_filename(rel):
|
||||
raise HTTPException(404, detail="Not an image")
|
||||
# type: ignore
|
||||
data, mime, key = await get_or_create_thumb(adapter, mount.adapter_id, root, rel, w, h, fit)
|
||||
data, mime, key = await get_or_create_thumb(adapter, mount.id, root, rel, w, h, fit)
|
||||
headers = {
|
||||
'Cache-Control': 'public, max-age=3600',
|
||||
'ETag': key,
|
||||
@@ -151,7 +152,13 @@ async def get_temp_link(
|
||||
"""获取文件的临时公开访问令牌"""
|
||||
full_path = '/' + full_path if not full_path.startswith('/') else full_path
|
||||
token = await generate_temp_link_token(full_path, expires_in=expires_in)
|
||||
return success({"token": token, "path": full_path})
|
||||
file_domain = await ConfigCenter.get("FILE_DOMAIN")
|
||||
if file_domain:
|
||||
file_domain = file_domain.rstrip('/')
|
||||
url = f"{file_domain}/api/fs/public/{token}"
|
||||
else:
|
||||
url = f"/api/fs/public/{token}"
|
||||
return success({"token": token, "path": full_path, "url": url})
|
||||
|
||||
|
||||
@router.get("/public/{token}")
|
||||
@@ -299,10 +306,12 @@ async def browse_fs(
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
full_path: str,
|
||||
page_num: int = Query(1, alias="page", ge=1, description="页码"),
|
||||
page_size: int = Query(50, ge=1, le=500, description="每页条数")
|
||||
page_size: int = Query(50, ge=1, le=500, description="每页条数"),
|
||||
sort_by: str = Query("name", description="按字段排序: name, size, mtime"),
|
||||
sort_order: str = Query("asc", description="排序顺序: asc, desc")
|
||||
):
|
||||
full_path = '/' + full_path if not full_path.startswith('/') else full_path
|
||||
result = await list_virtual_dir(full_path, page_num, page_size)
|
||||
result = await list_virtual_dir(full_path, page_num, page_size, sort_by, sort_order)
|
||||
return success({
|
||||
"path": full_path,
|
||||
"entries": result["items"],
|
||||
@@ -329,6 +338,18 @@ async def api_delete(
|
||||
async def root_listing(
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
page_num: int = Query(1, alias="page", ge=1, description="页码"),
|
||||
page_size: int = Query(50, ge=1, le=500, description="每页条数")
|
||||
page_size: int = Query(50, ge=1, le=500, description="每页条数"),
|
||||
sort_by: str = Query("name", description="按字段排序: name, size, mtime"),
|
||||
sort_order: str = Query("asc", description="排序顺序: asc, desc")
|
||||
):
|
||||
return await browse_fs("", page_num, page_size)
|
||||
result = await list_virtual_dir("/", page_num, page_size, sort_by, sort_order)
|
||||
return success({
|
||||
"path": "/",
|
||||
"entries": result["items"],
|
||||
"pagination": {
|
||||
"total": result["total"],
|
||||
"page": result["page"],
|
||||
"page_size": result["page_size"],
|
||||
"pages": result["pages"]
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
services:
|
||||
foxel:
|
||||
image: ghcr.io/drizzletime/foxel:latest
|
||||
#image: ghcr.nju.edu.cn/drizzletime/foxel:latest #国内用户可以用此镜像命令
|
||||
#image: ghcr.nju.edu.cn/drizzletime/foxel:latest # 国内用户可以用此镜像命令
|
||||
container_name: foxel
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
python migrate/run.py
|
||||
nginx -g 'daemon off;' &
|
||||
exec gunicorn -k uvicorn.workers.UvicornWorker -w 4 -b 0.0.0.0:8000 main:app
|
||||
22
main.py
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 services.task_queue import task_queue_service
|
||||
|
||||
load_dotenv()
|
||||
|
||||
from services.middleware.exception_handler import global_exception_handler
|
||||
from services.middleware.logging_middleware import LoggingMiddleware
|
||||
from fastapi import FastAPI, Request
|
||||
from api.routers import include_routers
|
||||
from db.session import close_db, init_db
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from services.adapters.registry import runtime_registry
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
await init_db()
|
||||
await runtime_registry.refresh()
|
||||
await ConfigCenter.set("APP_VERSION", VERSION)
|
||||
await task_queue_service.start_worker()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await task_queue_service.stop_worker()
|
||||
await close_db()
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
config = fields.JSONField()
|
||||
enabled = fields.BooleanField(default=True)
|
||||
mounts: fields.ReverseRelation["Mount"]
|
||||
path = fields.CharField(max_length=255, unique=True)
|
||||
sub_path = fields.CharField(max_length=1024, null=True)
|
||||
|
||||
class Meta:
|
||||
table = "storage_adapters"
|
||||
|
||||
|
||||
class Mount(Model):
|
||||
id = fields.IntField(pk=True)
|
||||
path = fields.CharField(max_length=255, unique=True)
|
||||
sub_path = fields.CharField(max_length=1024, null=True)
|
||||
adapter: fields.ForeignKeyRelation[StorageAdapter] = fields.ForeignKeyField(
|
||||
"models.StorageAdapter", related_name="mounts", on_delete=fields.CASCADE
|
||||
)
|
||||
enabled = fields.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
table = "mounts"
|
||||
|
||||
|
||||
class UserAccount(Model):
|
||||
id = fields.IntField(pk=True)
|
||||
username = fields.CharField(max_length=50, unique=True)
|
||||
@@ -93,3 +81,29 @@ class ShareLink(Model):
|
||||
|
||||
class Meta:
|
||||
table = "share_links"
|
||||
|
||||
|
||||
class Plugin(Model):
|
||||
id = fields.IntField(pk=True)
|
||||
url = fields.CharField(max_length=2048)
|
||||
enabled = fields.BooleanField(default=True)
|
||||
|
||||
key = fields.CharField(max_length=100, null=True)
|
||||
name = fields.CharField(max_length=255, null=True)
|
||||
version = fields.CharField(max_length=50, null=True)
|
||||
supported_exts = fields.JSONField(null=True)
|
||||
|
||||
default_bounds = fields.JSONField(null=True)
|
||||
default_maximized = fields.BooleanField(null=True)
|
||||
|
||||
icon = fields.CharField(max_length=2048, null=True)
|
||||
description = fields.TextField(null=True)
|
||||
author = fields.CharField(max_length=255, null=True)
|
||||
website = fields.CharField(max_length=2048, null=True)
|
||||
github = fields.CharField(max_length=2048, null=True)
|
||||
|
||||
created_at = fields.DatetimeField(auto_now_add=True)
|
||||
updated_at = fields.DatetimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
table = "plugins"
|
||||
|
||||
@@ -28,7 +28,7 @@ http {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
location ~ ^/(api|docs) {
|
||||
location ~ ^/(api|docs|openapi\.json$) {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
94
pyproject.toml
Normal file
94
pyproject.toml
Normal file
@@ -0,0 +1,94 @@
|
||||
[project]
|
||||
name = "foxel"
|
||||
version = "0.1.0"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"aioboto3==15.1.0",
|
||||
"aiobotocore==2.24.0",
|
||||
"aiofiles==24.1.0",
|
||||
"aiohappyeyeballs==2.6.1",
|
||||
"aiohttp==3.12.15",
|
||||
"aioitertools==0.12.0",
|
||||
"aiosignal==1.4.0",
|
||||
"aiosqlite==0.21.0",
|
||||
"annotated-types==0.7.0",
|
||||
"anyio==4.10.0",
|
||||
"asyncclick==8.2.2.2",
|
||||
"attrs==25.3.0",
|
||||
"bcrypt==4.3.0",
|
||||
"boto3==1.39.11",
|
||||
"botocore==1.39.11",
|
||||
"certifi==2025.8.3",
|
||||
"click==8.2.1",
|
||||
"dictdiffer==0.9.0",
|
||||
"dnspython==2.7.0",
|
||||
"email-validator==2.2.0",
|
||||
"fastapi==0.116.1",
|
||||
"fastapi-cli==0.0.8",
|
||||
"fastapi-cloud-cli==0.1.5",
|
||||
"frozenlist==1.7.0",
|
||||
"grpcio==1.74.0",
|
||||
"h11==0.16.0",
|
||||
"httpcore==1.0.9",
|
||||
"httptools==0.6.4",
|
||||
"httpx==0.28.1",
|
||||
"idna==3.10",
|
||||
"imageio==2.37.0",
|
||||
"iso8601==2.1.0",
|
||||
"jinja2==3.1.6",
|
||||
"jmespath==1.0.1",
|
||||
"markdown-it-py==4.0.0",
|
||||
"markupsafe==3.0.2",
|
||||
"mdurl==0.1.2",
|
||||
"milvus-lite==2.5.1",
|
||||
"multidict==6.6.4",
|
||||
"numpy==2.3.2",
|
||||
"pandas==2.3.1",
|
||||
"passlib==1.7.4",
|
||||
"pillow==11.3.0",
|
||||
"propcache==0.3.2",
|
||||
"protobuf==6.32.0",
|
||||
"pyaes==1.6.1",
|
||||
"pyasn1==0.6.1",
|
||||
"pydantic==2.11.7",
|
||||
"pydantic-core==2.33.2",
|
||||
"pygments==2.19.2",
|
||||
"pyjwt==2.10.1",
|
||||
"pymilvus==2.6.0",
|
||||
"pypika-tortoise==0.6.1",
|
||||
"pysocks==1.7.1",
|
||||
"python-dateutil==2.9.0.post0",
|
||||
"python-dotenv==1.1.1",
|
||||
"python-multipart==0.0.20",
|
||||
"pytz==2025.2",
|
||||
"pyyaml==6.0.2",
|
||||
"rawpy==0.25.1",
|
||||
"rich==14.1.0",
|
||||
"rich-toolkit==0.15.0",
|
||||
"rignore==0.6.4",
|
||||
"rsa==4.9.1",
|
||||
"s3transfer==0.13.1",
|
||||
"sentry-sdk==2.35.0",
|
||||
"setuptools==80.9.0",
|
||||
"shellingham==1.5.4",
|
||||
"six==1.17.0",
|
||||
"sniffio==1.3.1",
|
||||
"starlette==0.47.2",
|
||||
"telethon==1.40.0",
|
||||
"tortoise-orm==0.25.1",
|
||||
"tqdm==4.67.1",
|
||||
"typer==0.16.0",
|
||||
"typing-extensions==4.14.1",
|
||||
"typing-inspection==0.4.1",
|
||||
"tzdata==2025.2",
|
||||
"ujson==5.10.0",
|
||||
"urllib3==2.5.0",
|
||||
"uvicorn==0.35.0",
|
||||
"uvloop==0.21.0",
|
||||
"watchfiles==1.1.0",
|
||||
"websockets==15.0.1",
|
||||
"wrapt==1.17.3",
|
||||
"yarl==1.20.1",
|
||||
]
|
||||
@@ -1,67 +0,0 @@
|
||||
aiosqlite==0.21.0
|
||||
annotated-types==0.7.0
|
||||
anyio==4.10.0
|
||||
bcrypt==4.3.0
|
||||
certifi==2025.8.3
|
||||
click==8.2.1
|
||||
dnspython==2.7.0
|
||||
email_validator==2.2.0
|
||||
fastapi==0.116.1
|
||||
fastapi-cli==0.0.8
|
||||
fastapi-cloud-cli==0.1.5
|
||||
grpcio==1.74.0
|
||||
h11==0.16.0
|
||||
httpcore==1.0.9
|
||||
httptools==0.6.4
|
||||
httpx==0.28.1
|
||||
idna==3.10
|
||||
imageio==2.37.0
|
||||
iso8601==2.1.0
|
||||
Jinja2==3.1.6
|
||||
markdown-it-py==4.0.0
|
||||
MarkupSafe==3.0.2
|
||||
mdurl==0.1.2
|
||||
milvus-lite==2.5.1
|
||||
numpy==2.3.2
|
||||
pandas==2.3.1
|
||||
passlib==1.7.4
|
||||
pillow==11.3.0
|
||||
protobuf==6.32.0
|
||||
pyaes==1.6.1
|
||||
pyasn1==0.6.1
|
||||
pydantic==2.11.7
|
||||
pydantic_core==2.33.2
|
||||
Pygments==2.19.2
|
||||
PyJWT==2.10.1
|
||||
pymilvus==2.6.0
|
||||
pypika-tortoise==0.6.1
|
||||
PySocks==1.7.1
|
||||
python-dateutil==2.9.0.post0
|
||||
python-dotenv==1.1.1
|
||||
python-multipart==0.0.20
|
||||
pytz==2025.2
|
||||
PyYAML==6.0.2
|
||||
rawpy==0.25.1
|
||||
rich==14.1.0
|
||||
rich-toolkit==0.15.0
|
||||
rignore==0.6.4
|
||||
rsa==4.9.1
|
||||
sentry-sdk==2.35.0
|
||||
setuptools==80.9.0
|
||||
shellingham==1.5.4
|
||||
six==1.17.0
|
||||
sniffio==1.3.1
|
||||
starlette==0.47.2
|
||||
Telethon==1.40.0
|
||||
tortoise-orm==0.25.1
|
||||
tqdm==4.67.1
|
||||
typer==0.16.0
|
||||
typing-inspection==0.4.1
|
||||
typing_extensions==4.14.1
|
||||
tzdata==2025.2
|
||||
ujson==5.10.0
|
||||
urllib3==2.5.0
|
||||
uvicorn==0.35.0
|
||||
uvloop==0.21.0
|
||||
watchfiles==1.1.0
|
||||
websockets==15.0.1
|
||||
@@ -1,12 +1,12 @@
|
||||
from schemas.plugins import PluginCreate,PluginOut
|
||||
from .adapters import AdapterCreate, AdapterOut
|
||||
from .mounts import MountCreate, MountOut
|
||||
from .fs import MkdirRequest, MoveRequest
|
||||
|
||||
__all__ = [
|
||||
"PluginOut"
|
||||
"PluginCreate"
|
||||
"AdapterCreate",
|
||||
"AdapterOut",
|
||||
"MountCreate",
|
||||
"MountOut",
|
||||
"MkdirRequest",
|
||||
"MoveRequest",
|
||||
]
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
from typing import Dict, Optional
|
||||
from pydantic import BaseModel, Field, validator
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class AdapterCreate(BaseModel):
|
||||
class AdapterBase(BaseModel):
|
||||
name: str
|
||||
type: str = Field(pattern=r"^[a-zA-Z0-9_]+$")
|
||||
config: Dict = Field(default_factory=dict)
|
||||
enabled: bool = True
|
||||
mount_path: str
|
||||
sub_path: Optional[str] = None
|
||||
path: str = None
|
||||
sub_path: Optional[str] = None
|
||||
|
||||
|
||||
class AdapterCreate(AdapterBase):
|
||||
@staticmethod
|
||||
def normalize_mount_path(p: str) -> str:
|
||||
p = p.strip()
|
||||
@@ -18,15 +20,17 @@ class AdapterCreate(BaseModel):
|
||||
p = p.rstrip('/')
|
||||
return p or '/'
|
||||
|
||||
@validator("mount_path")
|
||||
@field_validator("path")
|
||||
def _v_mount(cls, v: str):
|
||||
if not v:
|
||||
raise ValueError("mount_path required")
|
||||
return cls.normalize_mount_path(v)
|
||||
|
||||
|
||||
class AdapterOut(AdapterCreate):
|
||||
class AdapterOut(AdapterBase):
|
||||
id: int
|
||||
path: str = None
|
||||
sub_path: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class MountCreate(BaseModel):
|
||||
path: str
|
||||
adapter_id: int
|
||||
sub_path: Optional[str] = None
|
||||
enabled: bool = True
|
||||
|
||||
@staticmethod
|
||||
def normalize(path: str) -> str:
|
||||
return (path if path.startswith('/') else '/' + path).rstrip('/') or '/'
|
||||
|
||||
def model_post_init(self, __context):
|
||||
self.path = self.normalize(self.path)
|
||||
|
||||
|
||||
class MountOut(MountCreate):
|
||||
id: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
27
schemas/plugins.py
Normal file
27
schemas/plugins.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from typing import List, Optional, Dict, Any
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PluginCreate(BaseModel):
|
||||
url: str = Field(min_length=1)
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class PluginOut(BaseModel):
|
||||
id: int
|
||||
url: str
|
||||
enabled: bool
|
||||
key: Optional[str]
|
||||
name: Optional[str]
|
||||
version: Optional[str]
|
||||
supported_exts: Optional[List[str]]
|
||||
default_bounds: Optional[Dict[str, Any]]
|
||||
default_maximized: Optional[bool]
|
||||
icon: Optional[str]
|
||||
description: Optional[str]
|
||||
author: Optional[str]
|
||||
website: Optional[str]
|
||||
github: Optional[str]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -10,7 +10,7 @@ from models import StorageAdapter
|
||||
@runtime_checkable
|
||||
class BaseAdapter(Protocol):
|
||||
record: StorageAdapter
|
||||
async def list_dir(self, root: str, rel: str, page_num: int = 1, page_size: int = 50) -> Tuple[List[Dict], int]: ...
|
||||
async def list_dir(self, root: str, rel: str, page_num: int = 1, page_size: int = 50, sort_by: str = "name", sort_order: str = "asc") -> Tuple[List[Dict], int]: ...
|
||||
async def read_file(self, root: str, rel: str) -> bytes: ...
|
||||
async def write_file(self, root: str, rel: str, data: bytes): ...
|
||||
async def write_file_stream(self, root: str, rel: str, data_iter: AsyncIterator[bytes]): ...
|
||||
|
||||
@@ -46,25 +46,18 @@ class LocalAdapter:
|
||||
return str(Path(root) / sub_path)
|
||||
return root
|
||||
|
||||
async def list_dir(self, root: str, rel: str, page_num: int = 1, page_size: int = 50) -> Tuple[List[Dict], int]:
|
||||
async def list_dir(self, root: str, rel: str, page_num: int = 1, page_size: int = 50, sort_by: str = "name", sort_order: str = "asc") -> Tuple[List[Dict], int]:
|
||||
rel = rel.strip('/')
|
||||
base = _safe_join(root, rel) if rel else Path(root)
|
||||
if not base.exists():
|
||||
return [], 0
|
||||
if not base.is_dir():
|
||||
raise NotADirectoryError(rel)
|
||||
|
||||
# 获取所有文件名并排序
|
||||
all_names = await asyncio.to_thread(lambda: sorted(os.listdir(base), key=str.lower))
|
||||
total_count = len(all_names)
|
||||
|
||||
# 计算分页范围
|
||||
start_idx = (page_num - 1) * page_size
|
||||
end_idx = start_idx + page_size
|
||||
page_names = all_names[start_idx:end_idx]
|
||||
|
||||
all_names = await asyncio.to_thread(os.listdir, base)
|
||||
|
||||
entries = []
|
||||
for name in page_names:
|
||||
for name in all_names:
|
||||
fp = base / name
|
||||
try:
|
||||
st = await asyncio.to_thread(fp.stat)
|
||||
@@ -79,10 +72,35 @@ class LocalAdapter:
|
||||
"mode": stat.S_IMODE(st.st_mode),
|
||||
"type": "dir" if is_dir else "file",
|
||||
})
|
||||
|
||||
# 排序
|
||||
reverse = sort_order.lower() == "desc"
|
||||
|
||||
# 按目录优先排序
|
||||
entries.sort(key=lambda x: (not x["is_dir"], x["name"].lower()))
|
||||
return entries, total_count
|
||||
def get_sort_key(item):
|
||||
# 基础排序键,目录优先
|
||||
key = (not item["is_dir"],)
|
||||
sort_field = sort_by.lower()
|
||||
|
||||
if sort_field == "name":
|
||||
key += (item["name"].lower(),)
|
||||
elif sort_field == "size":
|
||||
key += (item["size"],)
|
||||
elif sort_field == "mtime":
|
||||
key += (item["mtime"],)
|
||||
else: # 默认按名称
|
||||
key += (item["name"].lower(),)
|
||||
return key
|
||||
|
||||
entries.sort(key=get_sort_key, reverse=reverse)
|
||||
|
||||
total_count = len(entries)
|
||||
|
||||
# 分页
|
||||
start_idx = (page_num - 1) * page_size
|
||||
end_idx = start_idx + page_size
|
||||
page_entries = entries[start_idx:end_idx]
|
||||
|
||||
return page_entries, total_count
|
||||
|
||||
async def read_file(self, root: str, rel: str) -> bytes:
|
||||
fp = _safe_join(root, rel)
|
||||
|
||||
440
services/adapters/onedrive.py
Normal file
440
services/adapters/onedrive.py
Normal file
@@ -0,0 +1,440 @@
|
||||
from __future__ import annotations
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import List, Dict, Tuple, AsyncIterator
|
||||
import httpx
|
||||
from fastapi.responses import StreamingResponse
|
||||
from fastapi import HTTPException
|
||||
from models import StorageAdapter
|
||||
|
||||
MS_GRAPH_URL = "https://graph.microsoft.com/v1.0"
|
||||
MS_OAUTH_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/token"
|
||||
|
||||
|
||||
class OneDriveAdapter:
|
||||
"""OneDrive 存储适配器"""
|
||||
|
||||
def __init__(self, record: StorageAdapter):
|
||||
self.record = record
|
||||
cfg = record.config
|
||||
self.client_id = cfg.get("client_id")
|
||||
self.client_secret = cfg.get("client_secret")
|
||||
self.refresh_token = cfg.get("refresh_token")
|
||||
self.root = cfg.get("root", "/").strip("/")
|
||||
|
||||
if not all([self.client_id, self.client_secret, self.refresh_token]):
|
||||
raise ValueError(
|
||||
"OneDrive 适配器需要 client_id, client_secret, 和 refresh_token")
|
||||
|
||||
self._access_token: str | None = None
|
||||
self._token_expiry: datetime | None = None
|
||||
|
||||
def get_effective_root(self, sub_path: str | None) -> str:
|
||||
"""
|
||||
获取有效根路径。
|
||||
:param sub_path: 子路径。
|
||||
:return: 完整的有效路径。
|
||||
"""
|
||||
if sub_path:
|
||||
return f"/{self.root.strip('/')}/{sub_path.strip('/')}".strip()
|
||||
return f"/{self.root.strip('/')}".strip()
|
||||
|
||||
def _get_api_path(self, rel_path: str) -> str:
|
||||
"""
|
||||
将用户可见的相对路径转换为 Graph API 路径段。
|
||||
:param rel_path: 相对路径。
|
||||
:return: Graph API 路径段。
|
||||
"""
|
||||
full_path = self.get_effective_root(rel_path).strip('/')
|
||||
if not full_path:
|
||||
return ""
|
||||
return f":/{full_path}"
|
||||
|
||||
async def _get_access_token(self) -> str:
|
||||
"""
|
||||
获取或刷新 access token。
|
||||
:return: access token。
|
||||
"""
|
||||
if self._access_token and self._token_expiry and datetime.now(timezone.utc) < self._token_expiry:
|
||||
return self._access_token
|
||||
|
||||
data = {
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
"refresh_token": self.refresh_token,
|
||||
"grant_type": "refresh_token",
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=20.0) as client:
|
||||
resp = await client.post(MS_OAUTH_URL, data=data)
|
||||
resp.raise_for_status()
|
||||
token_data = resp.json()
|
||||
self._access_token = token_data["access_token"]
|
||||
self._token_expiry = datetime.now(
|
||||
timezone.utc) + timedelta(seconds=token_data["expires_in"] - 300)
|
||||
return self._access_token
|
||||
|
||||
async def _request(self, method: str, api_path_segment: str | None = None, *, full_url: str | None = None, **kwargs):
|
||||
"""
|
||||
向 Microsoft Graph API 发送请求。
|
||||
:param method: HTTP 方法。
|
||||
:param api_path_segment: API 路径段 (与 full_url 互斥)。
|
||||
:param full_url: 完整的请求 URL (与 api_path_segment 互斥)。
|
||||
:param kwargs: 其他请求参数。
|
||||
:return: 响应对象。
|
||||
"""
|
||||
if not ((api_path_segment is not None) ^ (full_url is not None)):
|
||||
raise ValueError("必须提供 api_path_segment 或 full_url 中的一个,且仅一个")
|
||||
|
||||
token = await self._get_access_token()
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
if "headers" in kwargs:
|
||||
headers.update(kwargs.pop("headers"))
|
||||
|
||||
url = full_url if full_url else f"{MS_GRAPH_URL}/me/drive/root{api_path_segment}"
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
resp = await client.request(method, url, headers=headers, **kwargs)
|
||||
if resp.status_code == 401:
|
||||
self._access_token = None
|
||||
token = await self._get_access_token()
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
resp = await client.request(method, url, headers=headers, **kwargs)
|
||||
return resp
|
||||
|
||||
def _format_item(self, item: Dict) -> Dict:
|
||||
"""
|
||||
将 Graph API 返回的 item 格式化为统一的格式。
|
||||
:param item: Graph API 返回的 item 字典。
|
||||
:return: 格式化后的字典。
|
||||
"""
|
||||
is_dir = "folder" in item
|
||||
return {
|
||||
"name": item["name"],
|
||||
"is_dir": is_dir,
|
||||
"size": 0 if is_dir else item.get("size", 0),
|
||||
"mtime": int(datetime.fromisoformat(item["lastModifiedDateTime"].replace("Z", "+00:00")).timestamp()),
|
||||
"type": "dir" if is_dir else "file",
|
||||
}
|
||||
|
||||
async def list_dir(self, root: str, rel: str, page_num: int = 1, page_size: int = 50, sort_by: str = "name", sort_order: str = "asc") -> Tuple[List[Dict], int]:
|
||||
"""
|
||||
列出目录内容。
|
||||
由于 Graph API 不支持基于偏移($skip)的分页,此方法将获取所有项目,
|
||||
:param root: 根路径 (在此适配器中未使用,通过配置的 root 确定)。
|
||||
:param rel: 相对路径。
|
||||
:param page_num: 页码。
|
||||
:param page_size: 每页大小。
|
||||
:param sort_by: 排序字段
|
||||
:param sort_order: 排序顺序
|
||||
:return: 文件/目录列表和总数。
|
||||
"""
|
||||
api_path = self._get_api_path(rel)
|
||||
children_path = f"{api_path}:/children" if api_path else "/children"
|
||||
all_items = []
|
||||
params = {"$top": 999}
|
||||
resp = await self._request("GET", api_path_segment=children_path, params=params)
|
||||
|
||||
while True:
|
||||
if resp.status_code == 404 and not all_items:
|
||||
return [], 0
|
||||
resp.raise_for_status()
|
||||
|
||||
try:
|
||||
data = resp.json()
|
||||
except Exception as e:
|
||||
raise IOError(f"解析 Graph API 响应失败: {e}") from e
|
||||
|
||||
all_items.extend(data.get("value", []))
|
||||
next_link = data.get("@odata.nextLink")
|
||||
|
||||
if not next_link:
|
||||
break
|
||||
|
||||
resp = await self._request("GET", full_url=next_link)
|
||||
|
||||
formatted_items = [self._format_item(item) for item in all_items]
|
||||
|
||||
# 排序
|
||||
reverse = sort_order.lower() == "desc"
|
||||
def get_sort_key(item):
|
||||
key = (not item["is_dir"],)
|
||||
sort_field = sort_by.lower()
|
||||
if sort_field == "name":
|
||||
key += (item["name"].lower(),)
|
||||
elif sort_field == "size":
|
||||
key += (item["size"],)
|
||||
elif sort_field == "mtime":
|
||||
key += (item["mtime"],)
|
||||
else:
|
||||
key += (item["name"].lower(),)
|
||||
return key
|
||||
formatted_items.sort(key=get_sort_key, reverse=reverse)
|
||||
|
||||
total_count = len(formatted_items)
|
||||
start_idx = (page_num - 1) * page_size
|
||||
end_idx = start_idx + page_size
|
||||
|
||||
return formatted_items[start_idx:end_idx], total_count
|
||||
|
||||
async def read_file(self, root: str, rel: str) -> bytes:
|
||||
"""
|
||||
读取文件内容。
|
||||
:param root: 根路径。
|
||||
:param rel: 相对路径。
|
||||
:return: 文件内容的字节流。
|
||||
"""
|
||||
api_path = self._get_api_path(rel)
|
||||
if not api_path:
|
||||
raise IsADirectoryError("不能将根目录作为文件读取")
|
||||
|
||||
resp = await self._request("GET", api_path_segment=f"{api_path}:/content")
|
||||
if resp.status_code == 404:
|
||||
raise FileNotFoundError(rel)
|
||||
resp.raise_for_status()
|
||||
return resp.content
|
||||
|
||||
async def write_file(self, root: str, rel: str, data: bytes):
|
||||
"""
|
||||
写入文件。
|
||||
:param root: 根路径。
|
||||
:param rel: 相对路径。
|
||||
:param data: 文件内容的字节流。
|
||||
"""
|
||||
api_path = self._get_api_path(rel)
|
||||
if not api_path:
|
||||
raise ValueError("不能直接写入根路径")
|
||||
resp = await self._request("PUT", api_path_segment=f"{api_path}:/content", content=data)
|
||||
resp.raise_for_status()
|
||||
|
||||
async def write_file_stream(self, root: str, rel: str, data_iter: AsyncIterator[bytes]):
|
||||
"""
|
||||
以流式方式写入文件。
|
||||
:param root: 根路径。
|
||||
:param rel: 相对路径。
|
||||
:param data_iter: 文件内容的异步迭代器。
|
||||
:return: 文件大小。
|
||||
"""
|
||||
api_path = self._get_api_path(rel)
|
||||
if not api_path:
|
||||
raise ValueError("不能直接写入根路径")
|
||||
|
||||
resp = await self._request("PUT", api_path_segment=f"{api_path}:/content", content=data_iter)
|
||||
resp.raise_for_status()
|
||||
return resp.json().get("size", 0)
|
||||
|
||||
async def mkdir(self, root: str, rel: str):
|
||||
"""
|
||||
创建目录。
|
||||
:param root: 根路径。
|
||||
:param rel: 相对路径。
|
||||
"""
|
||||
parent_path_str, new_dir_name = rel.rstrip(
|
||||
'/').rsplit('/', 1) if '/' in rel.rstrip('/') else ('', rel)
|
||||
parent_api_path = self._get_api_path(parent_path_str)
|
||||
|
||||
children_path = f"{parent_api_path}:/children" if parent_api_path else "/children"
|
||||
|
||||
payload = {
|
||||
"name": new_dir_name,
|
||||
"folder": {},
|
||||
"@microsoft.graph.conflictBehavior": "fail" # 如果已存在则失败
|
||||
}
|
||||
resp = await self._request("POST", api_path_segment=children_path, json=payload)
|
||||
resp.raise_for_status()
|
||||
|
||||
async def delete(self, root: str, rel: str):
|
||||
"""
|
||||
删除文件或目录。
|
||||
:param root: 根路径。
|
||||
:param rel: 相对路径。
|
||||
"""
|
||||
api_path = self._get_api_path(rel)
|
||||
if not api_path:
|
||||
raise ValueError("不能删除根目录")
|
||||
|
||||
resp = await self._request("DELETE", api_path_segment=api_path)
|
||||
if resp.status_code not in (204, 404):
|
||||
resp.raise_for_status()
|
||||
|
||||
async def move(self, root: str, src_rel: str, dst_rel: str):
|
||||
"""
|
||||
移动或重命名文件/目录。
|
||||
:param root: 根路径。
|
||||
:param src_rel: 源相对路径。
|
||||
:param dst_rel: 目标相对路径。
|
||||
"""
|
||||
src_api_path = self._get_api_path(src_rel)
|
||||
if not src_api_path:
|
||||
raise ValueError("不能移动根目录")
|
||||
|
||||
dst_parent_rel, dst_name = dst_rel.rstrip(
|
||||
'/').rsplit('/', 1) if '/' in dst_rel.rstrip('/') else ('', dst_rel)
|
||||
dst_parent_api_path = self._get_api_path(dst_parent_rel)
|
||||
|
||||
# 获取父项目的 ID
|
||||
parent_resp = await self._request("GET", api_path_segment=dst_parent_api_path)
|
||||
parent_resp.raise_for_status()
|
||||
parent_id = parent_resp.json()["id"]
|
||||
|
||||
payload = {
|
||||
"parentReference": {"id": parent_id},
|
||||
"name": dst_name
|
||||
}
|
||||
resp = await self._request("PATCH", api_path_segment=src_api_path, json=payload)
|
||||
resp.raise_for_status()
|
||||
|
||||
async def rename(self, root: str, src_rel: str, dst_rel: str):
|
||||
"""
|
||||
重命名文件或目录。
|
||||
在 Graph API 中,移动和重命名是同一个 PATCH 操作。
|
||||
"""
|
||||
await self.move(root, src_rel, dst_rel)
|
||||
|
||||
async def copy(self, root: str, src_rel: str, dst_rel: str, overwrite: bool = False):
|
||||
"""
|
||||
复制文件或目录。
|
||||
:param root: 根路径。
|
||||
:param src_rel: 源相对路径。
|
||||
:param dst_rel: 目标相对路径。
|
||||
:param overwrite: 是否覆盖 (在此 API 中未直接使用)。
|
||||
"""
|
||||
src_api_path = self._get_api_path(src_rel)
|
||||
if not src_api_path:
|
||||
raise ValueError("不能复制根目录")
|
||||
|
||||
dst_parent_rel, dst_name = dst_rel.rstrip(
|
||||
'/').rsplit('/', 1) if '/' in dst_rel.rstrip('/') else ('', dst_rel)
|
||||
dst_parent_api_path = self._get_api_path(dst_parent_rel)
|
||||
|
||||
parent_resp = await self._request("GET", api_path_segment=dst_parent_api_path)
|
||||
parent_resp.raise_for_status()
|
||||
parent_id = parent_resp.json()["id"]
|
||||
|
||||
payload = {"parentReference": {"id": parent_id}, "name": dst_name}
|
||||
copy_path = f"{src_api_path}:/copy"
|
||||
resp = await self._request("POST", api_path_segment=copy_path, json=payload)
|
||||
resp.raise_for_status()
|
||||
|
||||
async def stream_file(self, root: str, rel: str, range_header: str | None):
|
||||
"""
|
||||
流式传输文件(支持范围请求)。
|
||||
:param root: 根路径。
|
||||
:param rel: 相对路径。
|
||||
:param range_header: HTTP Range 头。
|
||||
:return: FastAPI StreamingResponse 对象。
|
||||
"""
|
||||
api_path = self._get_api_path(rel)
|
||||
if not api_path:
|
||||
raise IsADirectoryError("不能对目录进行流式传输")
|
||||
|
||||
resp = await self._request("GET", api_path_segment=api_path)
|
||||
if resp.status_code == 404:
|
||||
raise FileNotFoundError(rel)
|
||||
resp.raise_for_status()
|
||||
item_data = resp.json()
|
||||
|
||||
download_url = item_data.get("@microsoft.graph.downloadUrl")
|
||||
if not download_url:
|
||||
raise Exception("无法获取下载 URL")
|
||||
|
||||
file_size = item_data.get("size", 0)
|
||||
content_type = item_data.get("file", {}).get(
|
||||
"mimeType", "application/octet-stream")
|
||||
|
||||
start = 0
|
||||
end = file_size - 1
|
||||
status = 200
|
||||
headers = {
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Type": content_type,
|
||||
"Content-Disposition": f"inline; filename=\"{item_data.get('name')}\""
|
||||
}
|
||||
|
||||
if range_header and range_header.startswith("bytes="):
|
||||
try:
|
||||
part = range_header.removeprefix("bytes=")
|
||||
s, e = part.split("-", 1)
|
||||
if s.strip():
|
||||
start = int(s)
|
||||
if e.strip():
|
||||
end = int(e)
|
||||
if start >= file_size:
|
||||
raise HTTPException(416, "Requested Range Not Satisfiable")
|
||||
if end >= file_size:
|
||||
end = file_size - 1
|
||||
status = 206
|
||||
except ValueError:
|
||||
raise HTTPException(400, "Invalid Range header")
|
||||
|
||||
headers["Content-Range"] = f"bytes {start}-{end}/{file_size}"
|
||||
headers["Content-Length"] = str(end - start + 1)
|
||||
else:
|
||||
headers["Content-Length"] = str(file_size)
|
||||
|
||||
async def file_iterator():
|
||||
nonlocal start, end
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
req_headers = {'Range': f'bytes={start}-{end}'}
|
||||
async with client.stream("GET", download_url, headers=req_headers) as stream_resp:
|
||||
stream_resp.raise_for_status()
|
||||
async for chunk in stream_resp.aiter_bytes():
|
||||
yield chunk
|
||||
|
||||
return StreamingResponse(file_iterator(), status_code=status, headers=headers, media_type=content_type)
|
||||
|
||||
async def get_thumbnail(self, root: str, rel: str, size: str = "medium"):
|
||||
"""
|
||||
获取文件的缩略图。
|
||||
:param root: 根路径。
|
||||
:param rel: 相对路径。
|
||||
:param size: 缩略图大小 (large, medium, small)。
|
||||
:return: 缩略图内容的字节流,或在不支持时返回 None。
|
||||
"""
|
||||
api_path = self._get_api_path(rel)
|
||||
if not api_path:
|
||||
return None
|
||||
|
||||
thumb_path = f"{api_path}:/thumbnails/0/{size}"
|
||||
|
||||
try:
|
||||
resp = await self._request("GET", api_path_segment=thumb_path)
|
||||
if resp.status_code == 200:
|
||||
thumb_data = resp.json()
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
thumb_resp = await client.get(thumb_data['url'])
|
||||
thumb_resp.raise_for_status()
|
||||
return thumb_resp.content
|
||||
elif resp.status_code == 404:
|
||||
return None
|
||||
else:
|
||||
resp.raise_for_status()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def stat_file(self, root: str, rel: str):
|
||||
"""
|
||||
获取文件或目录的元数据。
|
||||
:param root: 根路径。
|
||||
:param rel: 相对路径。
|
||||
:return: 格式化后的文件/目录信息。
|
||||
"""
|
||||
api_path = self._get_api_path(rel)
|
||||
resp = await self._request("GET", api_path_segment=api_path)
|
||||
if resp.status_code == 404:
|
||||
raise FileNotFoundError(rel)
|
||||
resp.raise_for_status()
|
||||
return self._format_item(resp.json())
|
||||
|
||||
|
||||
ADAPTER_TYPE = "OneDrive"
|
||||
|
||||
CONFIG_SCHEMA = [
|
||||
{"key": "client_id", "label": "Client ID", "type": "string", "required": True},
|
||||
{"key": "client_secret", "label": "Client Secret",
|
||||
"type": "password", "required": True},
|
||||
{"key": "refresh_token", "label": "Refresh Token", "type": "password",
|
||||
"required": True, "help_text": "可以通过运行 'python -m services.adapters.onedrive' 获取"},
|
||||
{"key": "root", "label": "根目录 (Root Path)", "type": "string",
|
||||
"required": False, "placeholder": "默认为根目录 /"},
|
||||
]
|
||||
|
||||
|
||||
def ADAPTER_FACTORY(rec): return OneDriveAdapter(rec)
|
||||
724
services/adapters/quark.py
Normal file
724
services/adapters/quark.py
Normal file
@@ -0,0 +1,724 @@
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import mimetypes
|
||||
import os
|
||||
import time
|
||||
from typing import Dict, List, Tuple, Optional, AsyncIterator, Any
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from models import StorageAdapter
|
||||
from .base import BaseAdapter
|
||||
|
||||
|
||||
# Quark 普通(UC)接口
|
||||
API_BASE = "https://drive.quark.cn/1/clouddrive"
|
||||
REFERER = "https://pan.quark.cn"
|
||||
PR = "ucpro"
|
||||
|
||||
|
||||
class QuarkAdapter:
|
||||
"""夸克网盘(Cookie 模式)
|
||||
|
||||
- 使用浏览器导出的 Cookie 进行鉴权
|
||||
- 通过 Quark/UC 的 clouddrive 接口实现:列目录、读写、分片上传、基础操作
|
||||
- 根 FID 固定为 "0";路径解析通过名称遍历
|
||||
"""
|
||||
|
||||
def __init__(self, record: StorageAdapter):
|
||||
self.record = record
|
||||
cfg = record.config or {}
|
||||
self.cookie: str = cfg.get("cookie") or cfg.get("Cookie")
|
||||
self.root_fid: str = cfg.get("root_fid", "0")
|
||||
self.use_transcoding_address: bool = bool(cfg.get("use_transcoding_address", False))
|
||||
self.only_list_video_file: bool = bool(cfg.get("only_list_video_file", False))
|
||||
|
||||
if not self.cookie:
|
||||
raise ValueError("Quark 适配器需要 cookie 配置")
|
||||
|
||||
# 运行期缓存
|
||||
self._dir_fid_cache: Dict[str, str] = {f"{self.root_fid}:": self.root_fid}
|
||||
self._children_cache: Dict[str, List[Dict[str, Any]]] = {}
|
||||
|
||||
# UA 与超时
|
||||
self._ua = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) quark-cloud-drive/2.5.20 Chrome/100.0.4896.160 "
|
||||
"Electron/18.3.5.4-b478491100 Safari/537.36 Channel/pckk_other_ch"
|
||||
)
|
||||
self._timeout = 30.0
|
||||
|
||||
# -----------------
|
||||
# 工具与通用请求
|
||||
# -----------------
|
||||
def get_effective_root(self, sub_path: str | None) -> str:
|
||||
return self.root_fid
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
pathname: str,
|
||||
*,
|
||||
json: Any | None = None,
|
||||
params: Dict[str, str] | None = None,
|
||||
) -> Any:
|
||||
headers = {
|
||||
"Cookie": self._safe_cookie(self.cookie),
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Referer": REFERER,
|
||||
"User-Agent": self._ua,
|
||||
}
|
||||
query = {"pr": PR, "fr": "pc"}
|
||||
if params:
|
||||
query.update(params)
|
||||
url = f"{API_BASE}{pathname}"
|
||||
|
||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||
resp = await client.request(method, url, headers=headers, params=query, json=json)
|
||||
# 更新运行期 cookie(若返回 __puus/__pus)
|
||||
try:
|
||||
for key in ("__puus", "__pus"):
|
||||
v = resp.cookies.get(key)
|
||||
if v:
|
||||
# 简单替换/追加到 self.cookie
|
||||
self._set_cookie_kv(key, v)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 解析业务状态
|
||||
data = None
|
||||
try:
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
resp.raise_for_status()
|
||||
return resp
|
||||
status = data.get("status")
|
||||
code = data.get("code")
|
||||
msg = data.get("message") or ""
|
||||
if (status is not None and status >= 400) or (code is not None and code != 0):
|
||||
raise HTTPException(502, detail=f"Quark error status={status} code={code} msg={msg}")
|
||||
return data
|
||||
|
||||
def _set_cookie_kv(self, key: str, value: str):
|
||||
# 将指定键值写入 self.cookie(粗略字符串处理)
|
||||
parts = [p.strip() for p in (self.cookie or "").replace("\r", "").replace("\n", "").split(";") if p.strip()]
|
||||
found = False
|
||||
for i, p in enumerate(parts):
|
||||
if p.startswith(key + "="):
|
||||
parts[i] = f"{key}={value}"
|
||||
found = True
|
||||
break
|
||||
if not found:
|
||||
parts.append(f"{key}={value}")
|
||||
self.cookie = "; ".join(parts)
|
||||
|
||||
def _sanitize_cookie(self, cookie: str) -> str:
|
||||
if not cookie:
|
||||
return ""
|
||||
# 去除换行与前后空白
|
||||
cookie = cookie.replace("\r", "").replace("\n", "").strip()
|
||||
# 统一分号分隔并去除多余空格/空段
|
||||
parts = [p.strip() for p in cookie.split(";") if p.strip()]
|
||||
return "; ".join(parts)
|
||||
|
||||
def _safe_cookie(self, cookie: str) -> str:
|
||||
s = self._sanitize_cookie(cookie)
|
||||
# 仅保留可见 ASCII (0x20-0x7E)
|
||||
s = "".join(ch for ch in s if 32 <= ord(ch) <= 126)
|
||||
return s
|
||||
|
||||
# -----------------
|
||||
# 列表与路径解析
|
||||
# -----------------
|
||||
def _map_file_item(self, it: Dict[str, Any]) -> Dict[str, Any]:
|
||||
# Quark/UC 列表项:file=true 表示文件;false 表示目录
|
||||
is_dir = not bool(it.get("file", False))
|
||||
updated_at_ms = int(it.get("updated_at", 0) or 0)
|
||||
name = it.get("file_name") or it.get("filename") or it.get("name")
|
||||
return {
|
||||
"fid": it.get("fid"),
|
||||
"name": name,
|
||||
"is_dir": is_dir,
|
||||
"size": 0 if is_dir else int(it.get("size", 0) or 0),
|
||||
"mtime": updated_at_ms // 1000 if updated_at_ms else 0,
|
||||
"type": "dir" if is_dir else "file",
|
||||
}
|
||||
|
||||
async def _list_children(self, parent_fid: str) -> List[Dict[str, Any]]:
|
||||
if parent_fid in self._children_cache:
|
||||
return self._children_cache[parent_fid]
|
||||
|
||||
files: List[Dict[str, Any]] = []
|
||||
page = 1
|
||||
size = 100
|
||||
total = None
|
||||
while True:
|
||||
qp = {"pdir_fid": parent_fid, "_size": str(size), "_page": str(page), "_fetch_total": "1"}
|
||||
data = await self._request("GET", "/file/sort", params=qp)
|
||||
d = (data or {}).get("data", {})
|
||||
meta = (data or {}).get("metadata", {})
|
||||
page_files = d.get("list", [])
|
||||
files.extend(page_files)
|
||||
if total is None:
|
||||
total = meta.get("_total") or meta.get("total") or 0
|
||||
if page * size >= int(total):
|
||||
break
|
||||
page += 1
|
||||
|
||||
mapped = [self._map_file_item(x) for x in files if (not self.only_list_video_file) or (not x.get("file")) or (x.get("category") == 1)]
|
||||
self._children_cache[parent_fid] = mapped
|
||||
return mapped
|
||||
|
||||
def _dir_cache_key(self, base_fid: str, rel: str) -> str:
|
||||
return f"{base_fid}:{rel.strip('/')}"
|
||||
|
||||
async def _resolve_dir_fid_from(self, base_fid: str, rel: str) -> str:
|
||||
key = rel.strip("/")
|
||||
cache_key = self._dir_cache_key(base_fid, key)
|
||||
if cache_key in self._dir_fid_cache:
|
||||
return self._dir_fid_cache[cache_key]
|
||||
if key == "":
|
||||
self._dir_fid_cache[cache_key] = base_fid
|
||||
return base_fid
|
||||
|
||||
parent_fid = base_fid
|
||||
path_so_far = []
|
||||
for seg in key.split("/"):
|
||||
if seg == "":
|
||||
continue
|
||||
path_so_far.append(seg)
|
||||
cache_key = self._dir_cache_key(base_fid, "/".join(path_so_far))
|
||||
cached = self._dir_fid_cache.get(cache_key)
|
||||
if cached:
|
||||
parent_fid = cached
|
||||
continue
|
||||
children = await self._list_children(parent_fid)
|
||||
found = next((c for c in children if c["is_dir"] and c["name"] == seg), None)
|
||||
if not found:
|
||||
raise FileNotFoundError(f"Directory not found: {seg}")
|
||||
parent_fid = found["fid"]
|
||||
self._dir_fid_cache[cache_key] = parent_fid
|
||||
|
||||
return parent_fid
|
||||
|
||||
async def _find_child(self, parent_fid: str, name: str) -> Optional[Dict[str, Any]]:
|
||||
children = await self._list_children(parent_fid)
|
||||
for it in children:
|
||||
if it["name"] == name:
|
||||
return it
|
||||
return None
|
||||
|
||||
def _invalidate_children_cache(self, parent_fid: str):
|
||||
if parent_fid in self._children_cache:
|
||||
try:
|
||||
del self._children_cache[parent_fid]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# -----------------
|
||||
# 目录与文件列表
|
||||
# -----------------
|
||||
async def list_dir(
|
||||
self,
|
||||
root: str,
|
||||
rel: str,
|
||||
page_num: int = 1,
|
||||
page_size: int = 50,
|
||||
sort_by: str = "name",
|
||||
sort_order: str = "asc",
|
||||
) -> Tuple[List[Dict], int]:
|
||||
base_fid = root or self.root_fid
|
||||
fid = await self._resolve_dir_fid_from(base_fid, rel)
|
||||
items = await self._list_children(fid)
|
||||
|
||||
# 排序,目录优先
|
||||
reverse = sort_order.lower() == "desc"
|
||||
|
||||
def get_sort_key(item):
|
||||
key = (not item["is_dir"],)
|
||||
sf = sort_by.lower()
|
||||
if sf == "name":
|
||||
key += (item["name"].lower(),)
|
||||
elif sf == "size":
|
||||
key += (item["size"],)
|
||||
elif sf == "mtime":
|
||||
key += (item["mtime"],)
|
||||
else:
|
||||
key += (item["name"].lower(),)
|
||||
return key
|
||||
|
||||
items.sort(key=get_sort_key, reverse=reverse)
|
||||
total = len(items)
|
||||
start = (page_num - 1) * page_size
|
||||
end = start + page_size
|
||||
return items[start:end], total
|
||||
|
||||
# -----------------
|
||||
# 下载与流式下载
|
||||
# -----------------
|
||||
async def _get_download_url(self, fid: str) -> str:
|
||||
data = await self._request("POST", "/file/download", json={"fids": [fid]})
|
||||
arr = (data or {}).get("data", [])
|
||||
if not arr:
|
||||
raise HTTPException(502, detail="No download data returned by Quark")
|
||||
url = arr[0].get("download_url") or arr[0].get("DownloadUrl")
|
||||
if not url:
|
||||
raise HTTPException(502, detail="No download_url returned by Quark")
|
||||
return url
|
||||
|
||||
async def _get_transcoding_url(self, fid: str) -> Optional[str]:
|
||||
try:
|
||||
payload = {"fid": fid, "resolutions": "low,normal,high,super,2k,4k", "supports": "fmp4_av,m3u8,dolby_vision"}
|
||||
data = await self._request("POST", "/file/v2/play/project", json=payload)
|
||||
lst = (data or {}).get("data", {}).get("video_list", [])
|
||||
for item in lst:
|
||||
vi = item.get("video_info") or {}
|
||||
url = vi.get("url")
|
||||
if url:
|
||||
return url
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
def _is_video_name(self, name: str) -> bool:
|
||||
mime, _ = mimetypes.guess_type(name)
|
||||
return bool(mime and mime.startswith("video/"))
|
||||
|
||||
def _download_headers(self) -> Dict[str, str]:
|
||||
return {"Cookie": self._safe_cookie(self.cookie), "User-Agent": self._ua, "Referer": REFERER}
|
||||
|
||||
async def read_file(self, root: str, rel: str) -> bytes:
|
||||
if not rel or rel.endswith("/"):
|
||||
raise IsADirectoryError("Path is a directory")
|
||||
parent = rel.rsplit("/", 1)[0] if "/" in rel else ""
|
||||
name = rel.rsplit("/", 1)[-1]
|
||||
base_fid = root or self.root_fid
|
||||
parent_fid = await self._resolve_dir_fid_from(base_fid, parent)
|
||||
it = await self._find_child(parent_fid, name)
|
||||
if not it or it["is_dir"]:
|
||||
raise FileNotFoundError(rel)
|
||||
url = await self._get_download_url(it["fid"])
|
||||
headers = self._download_headers()
|
||||
async with httpx.AsyncClient(timeout=None, follow_redirects=True) as client:
|
||||
resp = await client.get(url, headers=headers)
|
||||
if resp.status_code == 404:
|
||||
raise FileNotFoundError(rel)
|
||||
resp.raise_for_status()
|
||||
return resp.content
|
||||
|
||||
async def stream_file(self, root: str, rel: str, range_header: str | None):
|
||||
if not rel or rel.endswith("/"):
|
||||
raise IsADirectoryError("Path is a directory")
|
||||
parent = rel.rsplit("/", 1)[0] if "/" in rel else ""
|
||||
name = rel.rsplit("/", 1)[-1]
|
||||
base_fid = root or self.root_fid
|
||||
parent_fid = await self._resolve_dir_fid_from(base_fid, parent)
|
||||
it = await self._find_child(parent_fid, name)
|
||||
if not it or it["is_dir"]:
|
||||
raise FileNotFoundError(rel)
|
||||
url = await self._get_download_url(it["fid"])
|
||||
if self.use_transcoding_address and self._is_video_name(name):
|
||||
tr = await self._get_transcoding_url(it["fid"])
|
||||
if tr:
|
||||
url = tr
|
||||
dl_headers = self._download_headers()
|
||||
|
||||
# 预获取大小/是否支持范围
|
||||
total_size: Optional[int] = None
|
||||
async with httpx.AsyncClient(timeout=self._timeout, follow_redirects=True) as client:
|
||||
try:
|
||||
head_resp = await client.head(url, headers=dl_headers)
|
||||
if head_resp.status_code == 200:
|
||||
cl = head_resp.headers.get("Content-Length")
|
||||
if cl and cl.isdigit():
|
||||
total_size = int(cl)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
mime, _ = mimetypes.guess_type(rel)
|
||||
content_type = mime or "application/octet-stream"
|
||||
|
||||
# 解析 Range
|
||||
start = 0
|
||||
end: Optional[int] = None
|
||||
status_code = 200
|
||||
if range_header and range_header.startswith("bytes="):
|
||||
status_code = 206
|
||||
part = range_header.split("=", 1)[1]
|
||||
s, e = part.split("-", 1)
|
||||
if s.strip():
|
||||
start = int(s)
|
||||
if e.strip():
|
||||
end = int(e)
|
||||
|
||||
if total_size is not None and end is None and status_code == 206:
|
||||
end = total_size - 1
|
||||
if end is not None and total_size is not None and end >= total_size:
|
||||
end = total_size - 1
|
||||
if total_size is not None and start >= total_size:
|
||||
raise HTTPException(416, detail="Requested Range Not Satisfiable")
|
||||
|
||||
resp_headers: Dict[str, str] = {"Accept-Ranges": "bytes", "Content-Type": content_type}
|
||||
if status_code == 206 and total_size is not None and end is not None:
|
||||
resp_headers["Content-Range"] = f"bytes {start}-{end}/{total_size}"
|
||||
resp_headers["Content-Length"] = str(end - start + 1)
|
||||
elif total_size is not None:
|
||||
resp_headers["Content-Length"] = str(total_size)
|
||||
|
||||
async def iterator():
|
||||
headers = dict(dl_headers)
|
||||
if status_code == 206 and end is not None:
|
||||
headers["Range"] = f"bytes={start}-{end}"
|
||||
async with httpx.AsyncClient(timeout=None, follow_redirects=True) as client:
|
||||
async with client.stream("GET", url, headers=headers) as resp:
|
||||
if resp.status_code in (404, 416):
|
||||
await resp.aclose()
|
||||
raise HTTPException(resp.status_code, detail="Upstream not available")
|
||||
async for chunk in resp.aiter_bytes():
|
||||
if chunk:
|
||||
yield chunk
|
||||
|
||||
return StreamingResponse(iterator(), status_code=status_code, headers=resp_headers, media_type=content_type)
|
||||
|
||||
# -----------------
|
||||
# 上传(大文件分片)
|
||||
# -----------------
|
||||
@staticmethod
|
||||
def _md5_hex(b: bytes) -> str:
|
||||
return hashlib.md5(b).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def _sha1_hex(b: bytes) -> str:
|
||||
return hashlib.sha1(b).hexdigest()
|
||||
|
||||
def _guess_mime(self, name: str) -> str:
|
||||
mime, _ = mimetypes.guess_type(name)
|
||||
return mime or "application/octet-stream"
|
||||
|
||||
async def _upload_pre(self, filename: str, size: int, parent_fid: str) -> Dict[str, Any]:
|
||||
now_ms = int(time.time() * 1000)
|
||||
body = {
|
||||
"ccp_hash_update": True,
|
||||
"dir_name": "",
|
||||
"file_name": filename,
|
||||
"format_type": self._guess_mime(filename),
|
||||
"l_created_at": now_ms,
|
||||
"l_updated_at": now_ms,
|
||||
"pdir_fid": parent_fid,
|
||||
"size": size,
|
||||
}
|
||||
data = await self._request("POST", "/file/upload/pre", json=body)
|
||||
return data
|
||||
|
||||
async def write_file(self, root: str, rel: str, data: bytes):
|
||||
async def gen():
|
||||
yield data
|
||||
return await self.write_file_stream(root, rel, gen())
|
||||
|
||||
async def write_file_stream(self, root: str, rel: str, data_iter: AsyncIterator[bytes]):
|
||||
if not rel or rel.endswith("/"):
|
||||
raise HTTPException(400, detail="Invalid file path")
|
||||
|
||||
parent = rel.rsplit("/", 1)[0] if "/" in rel else ""
|
||||
name = rel.rsplit("/", 1)[-1]
|
||||
base_fid = root or self.root_fid
|
||||
parent_fid = await self._resolve_dir_fid_from(base_fid, parent)
|
||||
|
||||
# 将数据落盘到临时文件,同时计算 MD5/SHA1
|
||||
import tempfile
|
||||
|
||||
md5 = hashlib.md5()
|
||||
sha1 = hashlib.sha1()
|
||||
total = 0
|
||||
with tempfile.NamedTemporaryFile(delete=False) as tf:
|
||||
tmp_path = tf.name
|
||||
try:
|
||||
async for chunk in data_iter:
|
||||
if not chunk:
|
||||
continue
|
||||
total += len(chunk)
|
||||
md5.update(chunk)
|
||||
sha1.update(chunk)
|
||||
tf.write(chunk)
|
||||
finally:
|
||||
tf.flush()
|
||||
|
||||
md5_hex = md5.hexdigest()
|
||||
sha1_hex = sha1.hexdigest()
|
||||
|
||||
# 预上传,拿到上传信息
|
||||
pre_resp = await self._upload_pre(name, total, parent_fid)
|
||||
pre_data = pre_resp.get("data", {})
|
||||
|
||||
# hash 秒传
|
||||
hash_body = {"md5": md5_hex, "sha1": sha1_hex, "task_id": pre_data.get("task_id")}
|
||||
hash_resp = await self._request("POST", "/file/update/hash", json=hash_body)
|
||||
if (hash_resp.get("data") or {}).get("finish") is True:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except Exception:
|
||||
pass
|
||||
# 刷新父目录缓存
|
||||
self._invalidate_children_cache(parent_fid)
|
||||
return total
|
||||
|
||||
# 分片上传
|
||||
part_size = int((pre_resp.get("metadata") or {}).get("part_size") or 0)
|
||||
if part_size <= 0:
|
||||
raise HTTPException(502, detail="Invalid part_size from Quark")
|
||||
|
||||
bucket = pre_data.get("bucket")
|
||||
obj_key = pre_data.get("obj_key")
|
||||
upload_id = pre_data.get("upload_id")
|
||||
upload_url = pre_data.get("upload_url")
|
||||
if not (bucket and obj_key and upload_id and upload_url):
|
||||
raise HTTPException(502, detail="Upload pre missing fields")
|
||||
|
||||
# 计算 host 与基础 URL
|
||||
try:
|
||||
upload_host = upload_url.split("://", 1)[1]
|
||||
except Exception:
|
||||
upload_host = upload_url
|
||||
base_url = f"https://{bucket}.{upload_host}/{obj_key}"
|
||||
|
||||
# 分片循环
|
||||
etags: List[str] = []
|
||||
oss_ua = "aliyun-sdk-js/6.6.1 Chrome 98.0.4758.80 on Windows 10 64-bit"
|
||||
async with httpx.AsyncClient(timeout=None, follow_redirects=True) as client:
|
||||
with open(tmp_path, "rb") as rf:
|
||||
part_number = 1
|
||||
left = total
|
||||
while left > 0:
|
||||
sz = min(part_size, left)
|
||||
data_bytes = rf.read(sz)
|
||||
if len(data_bytes) != sz:
|
||||
raise IOError("Failed to read part bytes")
|
||||
now_str = time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime())
|
||||
# 申请签名
|
||||
auth_meta = (
|
||||
"PUT\n\n"
|
||||
f"{self._guess_mime(name)}\n"
|
||||
f"{now_str}\n"
|
||||
f"x-oss-date:{now_str}\n"
|
||||
f"x-oss-user-agent:{oss_ua}\n"
|
||||
f"/{bucket}/{obj_key}?partNumber={part_number}&uploadId={upload_id}"
|
||||
)
|
||||
auth_req_body = {"auth_info": pre_data.get("auth_info"), "auth_meta": auth_meta, "task_id": pre_data.get("task_id")}
|
||||
auth_resp = await self._request("POST", "/file/upload/auth", json=auth_req_body)
|
||||
auth_key = (auth_resp.get("data") or {}).get("auth_key")
|
||||
if not auth_key:
|
||||
raise HTTPException(502, detail="upload/auth missing auth_key")
|
||||
|
||||
put_headers = {
|
||||
"Authorization": auth_key,
|
||||
"Content-Type": self._guess_mime(name),
|
||||
"Referer": REFERER + "/",
|
||||
"x-oss-date": now_str,
|
||||
"x-oss-user-agent": oss_ua,
|
||||
}
|
||||
put_url = f"{base_url}?partNumber={part_number}&uploadId={upload_id}"
|
||||
put_resp = await client.put(put_url, headers=put_headers, content=data_bytes)
|
||||
if put_resp.status_code != 200:
|
||||
raise HTTPException(502, detail=f"Upload part failed status={put_resp.status_code} text={put_resp.text}")
|
||||
etag = put_resp.headers.get("Etag", "")
|
||||
etags.append(etag)
|
||||
left -= sz
|
||||
part_number += 1
|
||||
|
||||
# 组合 commit xml
|
||||
parts_xml = [f"<Part>\n<PartNumber>{i+1}</PartNumber>\n<ETag>{etags[i]}</ETag>\n</Part>\n" for i in range(len(etags))]
|
||||
body_xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<CompleteMultipartUpload>\n" + "".join(parts_xml) + "</CompleteMultipartUpload>"
|
||||
content_md5 = base64.b64encode(hashlib.md5(body_xml.encode("utf-8")).digest()).decode("ascii")
|
||||
callback = pre_data.get("callback") or {}
|
||||
try:
|
||||
import json as _json
|
||||
callback_b64 = base64.b64encode(_json.dumps(callback).encode("utf-8")).decode("ascii")
|
||||
except Exception:
|
||||
callback_b64 = ""
|
||||
|
||||
now_str = time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime())
|
||||
auth_meta_commit = (
|
||||
"POST\n"
|
||||
f"{content_md5}\n"
|
||||
"application/xml\n"
|
||||
f"{now_str}\n"
|
||||
f"x-oss-callback:{callback_b64}\n"
|
||||
f"x-oss-date:{now_str}\n"
|
||||
f"x-oss-user-agent:{oss_ua}\n"
|
||||
f"/{bucket}/{obj_key}?uploadId={upload_id}"
|
||||
)
|
||||
auth_commit_resp = await self._request("POST", "/file/upload/auth", json={"auth_info": pre_data.get("auth_info"), "auth_meta": auth_meta_commit, "task_id": pre_data.get("task_id")})
|
||||
auth_key_commit = (auth_commit_resp.get("data") or {}).get("auth_key")
|
||||
if not auth_key_commit:
|
||||
raise HTTPException(502, detail="upload/auth(commit) missing auth_key")
|
||||
|
||||
async with httpx.AsyncClient(timeout=None, follow_redirects=True) as client:
|
||||
commit_headers = {
|
||||
"Authorization": auth_key_commit,
|
||||
"Content-MD5": content_md5,
|
||||
"Content-Type": "application/xml",
|
||||
"Referer": REFERER + "/",
|
||||
"x-oss-callback": callback_b64,
|
||||
"x-oss-date": now_str,
|
||||
"x-oss-user-agent": oss_ua,
|
||||
}
|
||||
commit_url = f"{base_url}?uploadId={upload_id}"
|
||||
r = await client.post(commit_url, headers=commit_headers, content=body_xml.encode("utf-8"))
|
||||
if r.status_code != 200:
|
||||
raise HTTPException(502, detail=f"Upload commit failed status={r.status_code} text={r.text}")
|
||||
|
||||
# finish
|
||||
await self._request("POST", "/file/upload/finish", json={"obj_key": obj_key, "task_id": pre_data.get("task_id")})
|
||||
# 端合并存在轻微延迟,等待再刷新缓存
|
||||
try:
|
||||
await asyncio.sleep(1.0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except Exception:
|
||||
pass
|
||||
# 失效父目录缓存,确保后续列表可见
|
||||
self._invalidate_children_cache(parent_fid)
|
||||
return total
|
||||
|
||||
# -----------------
|
||||
# 基本文件操作
|
||||
# -----------------
|
||||
async def mkdir(self, root: str, rel: str):
|
||||
if not rel or rel == "/":
|
||||
raise HTTPException(400, detail="Cannot create root")
|
||||
parent = rel.rstrip("/")
|
||||
parent_rel, name = (parent.rsplit("/", 1) if "/" in parent else ("", parent))
|
||||
if not name:
|
||||
raise HTTPException(400, detail="Invalid directory name")
|
||||
pdir = await self._resolve_dir_fid_from(root or self.root_fid, parent_rel)
|
||||
await self._request("POST", "/file", json={"dir_init_lock": False, "dir_path": "", "file_name": name, "pdir_fid": pdir})
|
||||
self._invalidate_children_cache(pdir)
|
||||
|
||||
async def delete(self, root: str, rel: str):
|
||||
# 解析对象 fid + 父目录,用于失效缓存
|
||||
base_fid = root or self.root_fid
|
||||
if rel == "" or rel.endswith("/"):
|
||||
parent_rel = rel.rstrip("/")
|
||||
target_fid = await self._resolve_dir_fid_from(base_fid, parent_rel)
|
||||
parent_of_target = await self._resolve_dir_fid_from(base_fid, (parent_rel.rsplit("/", 1)[0] if "/" in parent_rel else ""))
|
||||
else:
|
||||
parent_rel, name = (rel.rsplit("/", 1) if "/" in rel else ("", rel))
|
||||
parent_of_target = await self._resolve_dir_fid_from(base_fid, parent_rel)
|
||||
it = await self._find_child(parent_of_target, name)
|
||||
if not it:
|
||||
return
|
||||
target_fid = it["fid"]
|
||||
await self._request("POST", "/file/delete", json={"action_type": 1, "exclude_fids": [], "filelist": [target_fid]})
|
||||
self._invalidate_children_cache(parent_of_target)
|
||||
|
||||
async def move(self, root: str, src_rel: str, dst_rel: str):
|
||||
# 支持跨目录与重命名:先移动到父目录,后重命名(若需要)
|
||||
src_parent_rel, src_name = (src_rel.rsplit("/", 1) if "/" in src_rel else ("", src_rel))
|
||||
dst_parent_rel, dst_name = (dst_rel.rsplit("/", 1) if "/" in dst_rel else ("", dst_rel))
|
||||
|
||||
base_fid = root or self.root_fid
|
||||
src_parent_fid = await self._resolve_dir_fid_from(base_fid, src_parent_rel)
|
||||
obj = await self._find_child(src_parent_fid, src_name)
|
||||
if not obj:
|
||||
raise FileNotFoundError(src_rel)
|
||||
dst_parent_fid = await self._resolve_dir_fid_from(base_fid, dst_parent_rel)
|
||||
|
||||
if src_parent_fid != dst_parent_fid:
|
||||
await self._request("POST", "/file/move", json={"action_type": 1, "exclude_fids": [], "filelist": [obj["fid"]], "to_pdir_fid": dst_parent_fid})
|
||||
self._invalidate_children_cache(src_parent_fid)
|
||||
self._invalidate_children_cache(dst_parent_fid)
|
||||
|
||||
if obj["name"] != dst_name:
|
||||
await self._request("POST", "/file/rename", json={"fid": obj["fid"], "file_name": dst_name})
|
||||
self._invalidate_children_cache(dst_parent_fid)
|
||||
|
||||
async def rename(self, root: str, src_rel: str, dst_rel: str):
|
||||
src_parent_rel, src_name = (src_rel.rsplit("/", 1) if "/" in src_rel else ("", src_rel))
|
||||
base_fid = root or self.root_fid
|
||||
src_parent_fid = await self._resolve_dir_fid_from(base_fid, src_parent_rel)
|
||||
obj = await self._find_child(src_parent_fid, src_name)
|
||||
if not obj:
|
||||
raise FileNotFoundError(src_rel)
|
||||
dst_name = dst_rel.rsplit("/", 1)[-1]
|
||||
await self._request("POST", "/file/rename", json={"fid": obj["fid"], "file_name": dst_name})
|
||||
self._invalidate_children_cache(src_parent_fid)
|
||||
|
||||
async def copy(self, root: str, src_rel: str, dst_rel: str, overwrite: bool = False):
|
||||
raise NotImplementedError("QuarkOpen does not support copy via open API")
|
||||
|
||||
# -----------------
|
||||
# STAT / EXISTS / 辅助
|
||||
# -----------------
|
||||
async def stat_file(self, root: str, rel: str):
|
||||
# 通过父目录列表获取元数据
|
||||
base_fid = root or self.root_fid
|
||||
if rel == "" or rel.endswith("/"):
|
||||
# 目录
|
||||
fid = await self._resolve_dir_fid_from(base_fid, rel.rstrip("/"))
|
||||
return {"name": rel.rstrip("/").split("/")[-1] if rel else "", "is_dir": True, "size": 0, "mtime": 0, "type": "dir", "fid": fid}
|
||||
parent_rel, name = (rel.rsplit("/", 1) if "/" in rel else ("", rel))
|
||||
parent_fid = await self._resolve_dir_fid_from(base_fid, parent_rel)
|
||||
it = await self._find_child(parent_fid, name)
|
||||
if not it:
|
||||
raise FileNotFoundError(rel)
|
||||
return it
|
||||
|
||||
async def exists(self, root: str, rel: str) -> bool:
|
||||
try:
|
||||
base_fid = root or self.root_fid
|
||||
if rel == "" or rel.endswith("/"):
|
||||
await self._resolve_dir_fid_from(base_fid, rel.rstrip("/"))
|
||||
return True
|
||||
parent_rel, name = (rel.rsplit("/", 1) if "/" in rel else ("", rel))
|
||||
parent_fid = await self._resolve_dir_fid_from(base_fid, parent_rel)
|
||||
it = await self._find_child(parent_fid, name)
|
||||
return it is not None
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
async def stat_path(self, root: str, rel: str):
|
||||
# 用于 move/copy 前的预检查调试
|
||||
try:
|
||||
base_fid = root or self.root_fid
|
||||
if rel == "" or rel.endswith("/"):
|
||||
fid = await self._resolve_dir_fid_from(base_fid, rel.rstrip("/"))
|
||||
return {"exists": True, "is_dir": True, "path": rel, "fid": fid}
|
||||
parent_rel, name = (rel.rsplit("/", 1) if "/" in rel else ("", rel))
|
||||
parent_fid = await self._resolve_dir_fid_from(base_fid, parent_rel)
|
||||
it = await self._find_child(parent_fid, name)
|
||||
if it:
|
||||
return {"exists": True, "is_dir": it["is_dir"], "path": rel, "fid": it["fid"]}
|
||||
return {"exists": False, "is_dir": None, "path": rel}
|
||||
except FileNotFoundError:
|
||||
return {"exists": False, "is_dir": None, "path": rel}
|
||||
|
||||
async def _resolve_target_fid(self, rel: str, *, base_fid: Optional[str] = None) -> str:
|
||||
base = base_fid or self.root_fid
|
||||
if rel == "" or rel.endswith("/"):
|
||||
return await self._resolve_dir_fid_from(base, rel.rstrip("/"))
|
||||
parent_rel, name = (rel.rsplit("/", 1) if "/" in rel else ("", rel))
|
||||
parent_fid = await self._resolve_dir_fid_from(base, parent_rel)
|
||||
it = await self._find_child(parent_fid, name)
|
||||
if not it:
|
||||
raise FileNotFoundError(rel)
|
||||
return it["fid"]
|
||||
|
||||
|
||||
ADAPTER_TYPE = "Quark"
|
||||
|
||||
CONFIG_SCHEMA = [
|
||||
{"key": "cookie", "label": "Cookie", "type": "password", "required": True, "placeholder": "从 pan.quark.cn 复制"},
|
||||
{"key": "root_fid", "label": "根 FID", "type": "string", "required": False, "default": "0"},
|
||||
{"key": "use_transcoding_address", "label": "视频转码直链", "type": "checkbox", "required": False, "default": False},
|
||||
{"key": "only_list_video_file", "label": "仅列出视频文件", "type": "checkbox", "required": False, "default": False},
|
||||
]
|
||||
|
||||
def ADAPTER_FACTORY(rec: StorageAdapter) -> BaseAdapter:
|
||||
return QuarkAdapter(rec)
|
||||
@@ -78,6 +78,31 @@ class RuntimeRegistry:
|
||||
def snapshot(self) -> Dict[int, BaseAdapter]:
|
||||
return dict(self._instances)
|
||||
|
||||
def remove(self, adapter_id: int):
|
||||
"""从缓存中移除一个适配器实例"""
|
||||
if adapter_id in self._instances:
|
||||
del self._instances[adapter_id]
|
||||
|
||||
async def upsert(self, rec: StorageAdapter):
|
||||
"""新增或更新一个适配器实例"""
|
||||
if not rec.enabled:
|
||||
self.remove(rec.id)
|
||||
return
|
||||
|
||||
factory = TYPE_MAP.get(rec.type)
|
||||
if not factory:
|
||||
discover_adapters()
|
||||
factory = TYPE_MAP.get(rec.type)
|
||||
if not factory:
|
||||
return
|
||||
|
||||
try:
|
||||
instance = factory(rec)
|
||||
self._instances[rec.id] = instance
|
||||
except Exception:
|
||||
self.remove(rec.id)
|
||||
pass
|
||||
|
||||
|
||||
runtime_registry = RuntimeRegistry()
|
||||
discover_adapters()
|
||||
|
||||
380
services/adapters/s3.py
Normal file
380
services/adapters/s3.py
Normal file
@@ -0,0 +1,380 @@
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import mimetypes
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Tuple, AsyncIterator
|
||||
from urllib.parse import quote
|
||||
|
||||
import aioboto3
|
||||
from botocore.exceptions import ClientError
|
||||
from fastapi import HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from models import StorageAdapter
|
||||
from services.logging import LogService
|
||||
|
||||
|
||||
class S3Adapter:
|
||||
"""S3 兼容对象存储适配器"""
|
||||
|
||||
def __init__(self, record: StorageAdapter):
|
||||
self.record = record
|
||||
cfg = record.config
|
||||
self.bucket_name = cfg.get("bucket_name")
|
||||
self.aws_access_key_id = cfg.get("access_key_id")
|
||||
self.aws_secret_access_key = cfg.get("secret_access_key")
|
||||
self.region_name = cfg.get("region_name")
|
||||
self.endpoint_url = cfg.get("endpoint_url")
|
||||
self.root = cfg.get("root", "").strip("/")
|
||||
|
||||
if not all([self.bucket_name, self.aws_access_key_id, self.aws_secret_access_key]):
|
||||
raise ValueError(
|
||||
"S3 适配器需要 bucket_name, access_key_id, 和 secret_access_key")
|
||||
|
||||
self.session = aioboto3.Session(
|
||||
aws_access_key_id=self.aws_access_key_id,
|
||||
aws_secret_access_key=self.aws_secret_access_key,
|
||||
region_name=self.region_name,
|
||||
)
|
||||
|
||||
def get_effective_root(self, sub_path: str | None) -> str:
|
||||
"""获取 S3 中的有效根路径 (key prefix)"""
|
||||
if sub_path:
|
||||
return f"{self.root}/{sub_path.strip('/')}".strip("/")
|
||||
return self.root
|
||||
|
||||
def _get_s3_key(self, rel_path: str) -> str:
|
||||
"""将相对路径转换为 S3 key"""
|
||||
rel_path = rel_path.strip("/")
|
||||
if self.root:
|
||||
return f"{self.root}/{rel_path}"
|
||||
return rel_path
|
||||
|
||||
def _get_client(self):
|
||||
return self.session.client("s3", endpoint_url=self.endpoint_url)
|
||||
|
||||
async def list_dir(self, root: str, rel: str, page_num: int = 1, page_size: int = 50, sort_by: str = "name", sort_order: str = "asc") -> Tuple[List[Dict], int]:
|
||||
prefix = self._get_s3_key(rel)
|
||||
if prefix and not prefix.endswith("/"):
|
||||
prefix += "/"
|
||||
|
||||
all_items = []
|
||||
|
||||
async with self._get_client() as s3:
|
||||
paginator = s3.get_paginator("list_objects_v2")
|
||||
async for result in paginator.paginate(Bucket=self.bucket_name, Prefix=prefix, Delimiter="/"):
|
||||
# 添加子目录
|
||||
for common_prefix in result.get("CommonPrefixes", []):
|
||||
dir_name = common_prefix.get(
|
||||
"Prefix").removeprefix(prefix).strip("/")
|
||||
if dir_name:
|
||||
all_items.append({
|
||||
"name": dir_name,
|
||||
"is_dir": True,
|
||||
"size": 0,
|
||||
"mtime": 0,
|
||||
"type": "dir",
|
||||
})
|
||||
|
||||
# 添加文件
|
||||
for content in result.get("Contents", []):
|
||||
file_key = content.get("Key")
|
||||
if file_key == prefix: # 忽略目录本身
|
||||
continue
|
||||
file_name = file_key.removeprefix(prefix)
|
||||
if file_name:
|
||||
all_items.append({
|
||||
"name": file_name,
|
||||
"is_dir": False,
|
||||
"size": content.get("Size", 0),
|
||||
"mtime": int(content.get("LastModified", datetime.now()).timestamp()),
|
||||
"type": "file",
|
||||
})
|
||||
|
||||
# 在内存中排序和分页
|
||||
reverse = sort_order.lower() == "desc"
|
||||
def get_sort_key(item):
|
||||
key = (not item["is_dir"],)
|
||||
sort_field = sort_by.lower()
|
||||
if sort_field == "name":
|
||||
key += (item["name"].lower(),)
|
||||
elif sort_field == "size":
|
||||
key += (item["size"],)
|
||||
elif sort_field == "mtime":
|
||||
key += (item["mtime"],)
|
||||
else:
|
||||
key += (item["name"].lower(),)
|
||||
return key
|
||||
all_items.sort(key=get_sort_key, reverse=reverse)
|
||||
|
||||
total_count = len(all_items)
|
||||
start_idx = (page_num - 1) * page_size
|
||||
end_idx = start_idx + page_size
|
||||
|
||||
return all_items[start_idx:end_idx], total_count
|
||||
|
||||
async def read_file(self, root: str, rel: str) -> bytes:
|
||||
key = self._get_s3_key(rel)
|
||||
async with self._get_client() as s3:
|
||||
try:
|
||||
resp = await s3.get_object(Bucket=self.bucket_name, Key=key)
|
||||
return await resp["Body"].read()
|
||||
except ClientError as e:
|
||||
if e.response["Error"]["Code"] == "NoSuchKey":
|
||||
raise FileNotFoundError(rel)
|
||||
raise
|
||||
|
||||
async def write_file(self, root: str, rel: str, data: bytes):
|
||||
key = self._get_s3_key(rel)
|
||||
async with self._get_client() as s3:
|
||||
await s3.put_object(Bucket=self.bucket_name, Key=key, Body=data)
|
||||
await LogService.info(
|
||||
"adapter:s3", f"Wrote file to {rel}",
|
||||
details={"adapter_id": self.record.id,
|
||||
"bucket": self.bucket_name, "key": key, "size": len(data)}
|
||||
)
|
||||
|
||||
async def write_file_stream(self, root: str, rel: str, data_iter: AsyncIterator[bytes]):
|
||||
key = self._get_s3_key(rel)
|
||||
MIN_PART_SIZE = 5 * 1024 * 1024
|
||||
|
||||
async with self._get_client() as s3:
|
||||
mpu = await s3.create_multipart_upload(Bucket=self.bucket_name, Key=key)
|
||||
upload_id = mpu['UploadId']
|
||||
|
||||
parts = []
|
||||
part_number = 1
|
||||
total_size = 0
|
||||
buffer = bytearray()
|
||||
|
||||
try:
|
||||
async for chunk in data_iter:
|
||||
if not chunk:
|
||||
continue
|
||||
buffer.extend(chunk)
|
||||
|
||||
while len(buffer) >= MIN_PART_SIZE:
|
||||
part_data = buffer[:MIN_PART_SIZE]
|
||||
del buffer[:MIN_PART_SIZE]
|
||||
|
||||
part = await s3.upload_part(
|
||||
Bucket=self.bucket_name,
|
||||
Key=key,
|
||||
PartNumber=part_number,
|
||||
UploadId=upload_id,
|
||||
Body=part_data
|
||||
)
|
||||
|
||||
parts.append({'PartNumber': part_number, 'ETag': part['ETag']})
|
||||
total_size += len(part_data)
|
||||
part_number += 1
|
||||
|
||||
if buffer:
|
||||
part = await s3.upload_part(
|
||||
Bucket=self.bucket_name,
|
||||
Key=key,
|
||||
PartNumber=part_number,
|
||||
UploadId=upload_id,
|
||||
Body=bytes(buffer)
|
||||
)
|
||||
parts.append({'PartNumber': part_number, 'ETag': part['ETag']})
|
||||
total_size += len(buffer)
|
||||
|
||||
await s3.complete_multipart_upload(
|
||||
Bucket=self.bucket_name,
|
||||
Key=key,
|
||||
UploadId=upload_id,
|
||||
MultipartUpload={'Parts': parts}
|
||||
)
|
||||
except Exception as e:
|
||||
await s3.abort_multipart_upload(
|
||||
Bucket=self.bucket_name,
|
||||
Key=key,
|
||||
UploadId=upload_id
|
||||
)
|
||||
raise IOError(f"S3 stream upload failed: {e}") from e
|
||||
|
||||
await LogService.info(
|
||||
"adapter:s3", f"Wrote file stream to {rel}",
|
||||
details={"adapter_id": self.record.id, "bucket": self.bucket_name, "key": key, "size": total_size}
|
||||
)
|
||||
return total_size
|
||||
|
||||
async def mkdir(self, root: str, rel: str):
|
||||
key = self._get_s3_key(rel)
|
||||
if not key.endswith("/"):
|
||||
key += "/"
|
||||
async with self._get_client() as s3:
|
||||
await s3.put_object(Bucket=self.bucket_name, Key=key, Body=b"")
|
||||
await LogService.info(
|
||||
"adapter:s3", f"Created directory {rel}",
|
||||
details={"adapter_id": self.record.id,
|
||||
"bucket": self.bucket_name, "key": key}
|
||||
)
|
||||
|
||||
async def delete(self, root: str, rel: str):
|
||||
key = self._get_s3_key(rel)
|
||||
async with self._get_client() as s3:
|
||||
is_dir_like = False
|
||||
try:
|
||||
head = await s3.head_object(Bucket=self.bucket_name, Key=key)
|
||||
if head['ContentLength'] == 0 and key.endswith('/'):
|
||||
is_dir_like = True
|
||||
except ClientError as e:
|
||||
if e.response['Error']['Code'] != '404':
|
||||
raise
|
||||
|
||||
# 如果是目录,删除目录下的所有对象
|
||||
if is_dir_like or not await self.stat_file(root, rel):
|
||||
dir_key = key if key.endswith('/') else key + '/'
|
||||
paginator = s3.get_paginator("list_objects_v2")
|
||||
objects_to_delete = []
|
||||
async for result in paginator.paginate(Bucket=self.bucket_name, Prefix=dir_key):
|
||||
for content in result.get("Contents", []):
|
||||
objects_to_delete.append({"Key": content["Key"]})
|
||||
if objects_to_delete:
|
||||
await s3.delete_objects(Bucket=self.bucket_name, Delete={"Objects": objects_to_delete})
|
||||
# 如果是文件,直接删除
|
||||
else:
|
||||
await s3.delete_object(Bucket=self.bucket_name, Key=key)
|
||||
|
||||
await LogService.info(
|
||||
"adapter:s3", f"Deleted {rel}",
|
||||
details={"adapter_id": self.record.id,
|
||||
"bucket": self.bucket_name, "key": key}
|
||||
)
|
||||
|
||||
async def move(self, root: str, src_rel: str, dst_rel: str):
|
||||
await self.copy(root, src_rel, dst_rel, overwrite=True)
|
||||
await self.delete(root, src_rel)
|
||||
await LogService.info(
|
||||
"adapter:s3", f"Moved {src_rel} to {dst_rel}",
|
||||
details={"adapter_id": self.record.id, "bucket": self.bucket_name,
|
||||
"src_key": self._get_s3_key(src_rel), "dst_key": self._get_s3_key(dst_rel)}
|
||||
)
|
||||
|
||||
async def rename(self, root: str, src_rel: str, dst_rel: str):
|
||||
await self.move(root, src_rel, dst_rel)
|
||||
|
||||
async def copy(self, root: str, src_rel: str, dst_rel: str, overwrite: bool = False):
|
||||
src_key = self._get_s3_key(src_rel)
|
||||
dst_key = self._get_s3_key(dst_rel)
|
||||
|
||||
async with self._get_client() as s3:
|
||||
if not overwrite:
|
||||
try:
|
||||
await s3.head_object(Bucket=self.bucket_name, Key=dst_key)
|
||||
raise FileExistsError(dst_rel)
|
||||
except ClientError as e:
|
||||
if e.response["Error"]["Code"] != "404":
|
||||
raise
|
||||
|
||||
copy_source = {"Bucket": self.bucket_name, "Key": src_key}
|
||||
await s3.copy_object(CopySource=copy_source, Bucket=self.bucket_name, Key=dst_key)
|
||||
await LogService.info(
|
||||
"adapter:s3", f"Copied {src_rel} to {dst_rel}",
|
||||
details={"adapter_id": self.record.id, "bucket": self.bucket_name,
|
||||
"src_key": src_key, "dst_key": dst_key}
|
||||
)
|
||||
|
||||
async def stat_file(self, root: str, rel: str):
|
||||
key = self._get_s3_key(rel)
|
||||
async with self._get_client() as s3:
|
||||
try:
|
||||
head = await s3.head_object(Bucket=self.bucket_name, Key=key)
|
||||
return {
|
||||
"name": rel.split("/")[-1],
|
||||
"is_dir": False,
|
||||
"size": head["ContentLength"],
|
||||
"mtime": int(head["LastModified"].timestamp()),
|
||||
"type": "file",
|
||||
}
|
||||
except ClientError as e:
|
||||
if e.response["Error"]["Code"] == "404":
|
||||
# 检查是否为一个 "目录"
|
||||
dir_key = key if key.endswith('/') else key + '/'
|
||||
resp = await s3.list_objects_v2(Bucket=self.bucket_name, Prefix=dir_key, MaxKeys=1)
|
||||
if resp.get('KeyCount', 0) > 0:
|
||||
return {
|
||||
"name": rel.split("/")[-1],
|
||||
"is_dir": True,
|
||||
"size": 0,
|
||||
"mtime": 0,
|
||||
"type": "dir",
|
||||
}
|
||||
raise FileNotFoundError(rel)
|
||||
raise
|
||||
|
||||
async def stream_file(self, root: str, rel: str, range_header: str | None):
|
||||
key = self._get_s3_key(rel)
|
||||
async with self._get_client() as s3:
|
||||
try:
|
||||
head = await s3.head_object(Bucket=self.bucket_name, Key=key)
|
||||
file_size = head["ContentLength"]
|
||||
content_type = head.get("ContentType", mimetypes.guess_type(key)[
|
||||
0] or "application/octet-stream")
|
||||
except ClientError as e:
|
||||
if e.response["Error"]["Code"] == "404":
|
||||
raise HTTPException(
|
||||
status_code=404, detail="File not found")
|
||||
raise
|
||||
|
||||
start = 0
|
||||
end = file_size - 1
|
||||
status = 200
|
||||
headers = {
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Type": content_type,
|
||||
"Content-Length": str(file_size),
|
||||
"Content-Disposition": f"inline; filename=\"{quote(rel.split('/')[-1])}\""
|
||||
}
|
||||
|
||||
if range_header:
|
||||
range_val = range_header.strip().partition("=")[2]
|
||||
s, _, e = range_val.partition("-")
|
||||
try:
|
||||
start = int(s) if s else 0
|
||||
end = int(e) if e else file_size - 1
|
||||
if start >= file_size or end >= file_size or start > end:
|
||||
raise HTTPException(
|
||||
status_code=416, detail="Requested Range Not Satisfiable")
|
||||
status = 206
|
||||
headers["Content-Length"] = str(end - start + 1)
|
||||
headers["Content-Range"] = f"bytes {start}-{end}/{file_size}"
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Invalid Range header")
|
||||
|
||||
range_arg = f"bytes={start}-{end}"
|
||||
|
||||
async def iterator():
|
||||
try:
|
||||
resp = await s3.get_object(Bucket=self.bucket_name, Key=key, Range=range_arg)
|
||||
body = resp["Body"]
|
||||
while chunk := await body.read(65536):
|
||||
yield chunk
|
||||
except Exception as e:
|
||||
LogService.error(
|
||||
"adapter:s3", f"Error streaming file {key}: {e}")
|
||||
|
||||
return StreamingResponse(iterator(), status_code=status, headers=headers, media_type=content_type)
|
||||
|
||||
|
||||
ADAPTER_TYPE = "S3"
|
||||
|
||||
CONFIG_SCHEMA = [
|
||||
{"key": "bucket_name", "label": "Bucket 名称",
|
||||
"type": "string", "required": True},
|
||||
{"key": "access_key_id", "label": "Access Key ID",
|
||||
"type": "string", "required": True},
|
||||
{"key": "secret_access_key", "label": "Secret Access Key",
|
||||
"type": "password", "required": True},
|
||||
{"key": "region_name", "label": "区域 (Region)", "type": "string",
|
||||
"required": False, "placeholder": "例如 us-east-1"},
|
||||
{"key": "endpoint_url", "label": "Endpoint URL", "type": "string",
|
||||
"required": False, "placeholder": "对于 S3 兼容存储, 例如 https://minio.example.com"},
|
||||
{"key": "root", "label": "根路径 (Root Path)", "type": "string",
|
||||
"required": False, "placeholder": "在 bucket 内的路径前缀"},
|
||||
]
|
||||
|
||||
|
||||
def ADAPTER_FACTORY(rec): return S3Adapter(rec)
|
||||
342
services/adapters/telegram.py
Normal file
342
services/adapters/telegram.py
Normal file
@@ -0,0 +1,342 @@
|
||||
from __future__ import annotations
|
||||
from typing import List, Dict, Tuple, AsyncIterator
|
||||
import io
|
||||
import os
|
||||
from models import StorageAdapter
|
||||
from telethon import TelegramClient
|
||||
from telethon.sessions import StringSession
|
||||
import socks
|
||||
|
||||
# 适配器类型标识
|
||||
ADAPTER_TYPE = "Telegram"
|
||||
|
||||
# 适配器配置项定义
|
||||
CONFIG_SCHEMA = [
|
||||
{"key": "api_id", "label": "API ID", "type": "string", "required": True, "help_text": "从 my.telegram.org 获取"},
|
||||
{"key": "api_hash", "label": "API Hash", "type": "password", "required": True, "help_text": "从 my.telegram.org 获取"},
|
||||
{"key": "session_string", "label": "Session String", "type": "password", "required": True, "help_text": "通过 generate_session.py 生成"},
|
||||
{"key": "chat_id", "label": "Chat ID", "type": "string", "required": True, "placeholder": "频道/群组的ID或用户名, 例如: -100123456789 或 'channel_username'"},
|
||||
{"key": "proxy_protocol", "label": "代理协议", "type": "string", "required": False, "placeholder": "例如: socks5, http"},
|
||||
{"key": "proxy_host", "label": "代理主机", "type": "string", "required": False, "placeholder": "例如: 127.0.0.1"},
|
||||
{"key": "proxy_port", "label": "代理端口", "type": "number", "required": False, "placeholder": "例如: 1080"},
|
||||
]
|
||||
|
||||
class TelegramAdapter:
|
||||
"""Telegram 存储适配器 (使用用户 Session)"""
|
||||
|
||||
def __init__(self, record: StorageAdapter):
|
||||
self.record = record
|
||||
cfg = record.config
|
||||
self.api_id = int(cfg.get("api_id"))
|
||||
self.api_hash = cfg.get("api_hash")
|
||||
self.session_string = cfg.get("session_string")
|
||||
self.chat_id_str = cfg.get("chat_id")
|
||||
|
||||
# 代理设置
|
||||
self.proxy_protocol = cfg.get("proxy_protocol")
|
||||
self.proxy_host = cfg.get("proxy_host")
|
||||
self.proxy_port = cfg.get("proxy_port")
|
||||
|
||||
self.proxy = None
|
||||
if self.proxy_protocol and self.proxy_host and self.proxy_port:
|
||||
proto_map = {
|
||||
"socks5": socks.SOCKS5,
|
||||
"http": socks.HTTP,
|
||||
}
|
||||
proxy_type = proto_map.get(self.proxy_protocol.lower())
|
||||
if proxy_type:
|
||||
self.proxy = (proxy_type, self.proxy_host, int(self.proxy_port))
|
||||
|
||||
try:
|
||||
self.chat_id = int(self.chat_id_str)
|
||||
except (ValueError, TypeError):
|
||||
self.chat_id = self.chat_id_str
|
||||
|
||||
if not all([self.api_id, self.api_hash, self.session_string, self.chat_id]):
|
||||
raise ValueError("Telegram 适配器需要 api_id, api_hash, session_string 和 chat_id")
|
||||
|
||||
def _get_client(self) -> TelegramClient:
|
||||
"""创建一个新的 TelegramClient 实例"""
|
||||
return TelegramClient(StringSession(self.session_string), self.api_id, self.api_hash, proxy=self.proxy)
|
||||
|
||||
def get_effective_root(self, sub_path: str | None) -> str:
|
||||
return ""
|
||||
|
||||
async def list_dir(self, root: str, rel: str, page_num: int = 1, page_size: int = 50, sort_by: str = "name", sort_order: str = "asc") -> Tuple[List[Dict], int]:
|
||||
if rel:
|
||||
return [], 0
|
||||
|
||||
client = self._get_client()
|
||||
entries = []
|
||||
try:
|
||||
await client.connect()
|
||||
messages = await client.get_messages(self.chat_id, limit=200)
|
||||
for message in messages:
|
||||
if not message:
|
||||
continue
|
||||
|
||||
media = message.document or message.video or message.photo
|
||||
if not media:
|
||||
continue
|
||||
|
||||
filename = None
|
||||
size = 0
|
||||
|
||||
if message.photo:
|
||||
photo_size = message.photo.sizes[-1]
|
||||
size = photo_size.size if hasattr(photo_size, 'size') else 0
|
||||
filename = f"photo_{message.id}.jpg"
|
||||
|
||||
elif message.document or message.video:
|
||||
size = media.size
|
||||
if hasattr(media, 'attributes'):
|
||||
for attr in media.attributes:
|
||||
if hasattr(attr, 'file_name') and attr.file_name:
|
||||
filename = attr.file_name
|
||||
break
|
||||
|
||||
if not filename:
|
||||
if message.text and '.' in message.text and len(message.text) < 256 and '\n' not in message.text:
|
||||
filename = message.text
|
||||
|
||||
if not filename:
|
||||
filename = f"unknown_{message.id}"
|
||||
|
||||
entries.append({
|
||||
"name": f"{message.id}_{filename}",
|
||||
"is_dir": False,
|
||||
"size": size,
|
||||
"mtime": int(message.date.timestamp()),
|
||||
"type": "file",
|
||||
})
|
||||
finally:
|
||||
if client.is_connected():
|
||||
await client.disconnect()
|
||||
|
||||
# 排序
|
||||
reverse = sort_order.lower() == "desc"
|
||||
def get_sort_key(item):
|
||||
key = (not item["is_dir"],)
|
||||
sort_field = sort_by.lower()
|
||||
if sort_field == "name":
|
||||
key += (item["name"].lower(),)
|
||||
elif sort_field == "size":
|
||||
key += (item["size"],)
|
||||
elif sort_field == "mtime":
|
||||
key += (item["mtime"],)
|
||||
else:
|
||||
key += (item["name"].lower(),)
|
||||
return key
|
||||
entries.sort(key=get_sort_key, reverse=reverse)
|
||||
|
||||
total_count = len(entries)
|
||||
|
||||
# 分页
|
||||
start_idx = (page_num - 1) * page_size
|
||||
end_idx = start_idx + page_size
|
||||
page_entries = entries[start_idx:end_idx]
|
||||
|
||||
return page_entries, total_count
|
||||
|
||||
async def read_file(self, root: str, rel: str) -> bytes:
|
||||
try:
|
||||
message_id_str, _ = rel.split('_', 1)
|
||||
message_id = int(message_id_str)
|
||||
except (ValueError, IndexError):
|
||||
raise FileNotFoundError(f"无效的文件路径格式: {rel}")
|
||||
|
||||
client = self._get_client()
|
||||
try:
|
||||
await client.connect()
|
||||
message = await client.get_messages(self.chat_id, ids=message_id)
|
||||
if not message or not (message.document or message.video or message.photo):
|
||||
raise FileNotFoundError(f"在频道 {self.chat_id} 中未找到消息ID为 {message_id} 的文件")
|
||||
|
||||
file_bytes = await client.download_media(message, file=bytes)
|
||||
return file_bytes
|
||||
finally:
|
||||
if client.is_connected():
|
||||
await client.disconnect()
|
||||
|
||||
async def write_file(self, root: str, rel: str, data: bytes):
|
||||
"""将字节数据作为文件上传"""
|
||||
client = self._get_client()
|
||||
file_like = io.BytesIO(data)
|
||||
file_like.name = os.path.basename(rel) or "file"
|
||||
|
||||
try:
|
||||
await client.connect()
|
||||
await client.send_file(self.chat_id, file_like, caption=file_like.name)
|
||||
finally:
|
||||
if client.is_connected():
|
||||
await client.disconnect()
|
||||
|
||||
async def write_file_stream(self, root: str, rel: str, data_iter: AsyncIterator[bytes]):
|
||||
"""以流式方式上传文件"""
|
||||
client = self._get_client()
|
||||
filename = os.path.basename(rel) or "file"
|
||||
import tempfile
|
||||
temp_dir = tempfile.gettempdir()
|
||||
temp_path = os.path.join(temp_dir, filename)
|
||||
|
||||
total_size = 0
|
||||
try:
|
||||
with open(temp_path, "wb") as f:
|
||||
async for chunk in data_iter:
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
total_size += len(chunk)
|
||||
|
||||
await client.connect()
|
||||
await client.send_file(self.chat_id, temp_path, caption=filename)
|
||||
|
||||
finally:
|
||||
if os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
if client.is_connected():
|
||||
await client.disconnect()
|
||||
return total_size
|
||||
|
||||
async def mkdir(self, root: str, rel: str):
|
||||
raise NotImplementedError("Telegram 适配器不支持创建目录。")
|
||||
|
||||
async def delete(self, root: str, rel: str):
|
||||
"""删除一个文件 (即一条消息)"""
|
||||
try:
|
||||
message_id_str, _ = rel.split('_', 1)
|
||||
message_id = int(message_id_str)
|
||||
except (ValueError, IndexError):
|
||||
raise FileNotFoundError(f"无效的文件路径格式,无法解析消息ID: {rel}")
|
||||
|
||||
client = self._get_client()
|
||||
try:
|
||||
await client.connect()
|
||||
result = await client.delete_messages(self.chat_id, [message_id])
|
||||
if not result or not result[0].pts:
|
||||
raise FileNotFoundError(f"在 {self.chat_id} 中删除消息 {message_id} 失败,可能消息不存在或无权限")
|
||||
finally:
|
||||
if client.is_connected():
|
||||
await client.disconnect()
|
||||
|
||||
async def move(self, root: str, src_rel: str, dst_rel: str):
|
||||
raise NotImplementedError("Telegram 适配器不支持移动。")
|
||||
|
||||
async def rename(self, root: str, src_rel: str, dst_rel: str):
|
||||
raise NotImplementedError("Telegram 适配器不支持重命名。")
|
||||
|
||||
async def copy(self, root: str, src_rel: str, dst_rel: str, overwrite: bool = False):
|
||||
raise NotImplementedError("Telegram 适配器不支持复制。")
|
||||
|
||||
async def stream_file(self, root: str, rel: str, range_header: str | None):
|
||||
from fastapi.responses import StreamingResponse
|
||||
from fastapi import HTTPException
|
||||
|
||||
try:
|
||||
message_id_str, _ = rel.split('_', 1)
|
||||
message_id = int(message_id_str)
|
||||
except (ValueError, IndexError):
|
||||
raise HTTPException(status_code=400, detail=f"无效的文件路径格式: {rel}")
|
||||
|
||||
client = self._get_client()
|
||||
|
||||
try:
|
||||
await client.connect()
|
||||
message = await client.get_messages(self.chat_id, ids=message_id)
|
||||
media = message.document or message.video or message.photo
|
||||
if not message or not media:
|
||||
raise FileNotFoundError(f"在频道 {self.chat_id} 中未找到消息ID为 {message_id} 的文件")
|
||||
|
||||
if message.photo:
|
||||
photo_size = media.sizes[-1]
|
||||
file_size = photo_size.size if hasattr(photo_size, 'size') else 0
|
||||
mime_type = "image/jpeg"
|
||||
else:
|
||||
file_size = media.size
|
||||
mime_type = media.mime_type or "application/octet-stream"
|
||||
|
||||
start = 0
|
||||
end = file_size - 1
|
||||
status = 200
|
||||
|
||||
headers = {
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Type": mime_type,
|
||||
"Content-Length": str(file_size),
|
||||
}
|
||||
|
||||
if range_header:
|
||||
try:
|
||||
range_val = range_header.strip().partition("=")[2]
|
||||
s, _, e = range_val.partition("-")
|
||||
start = int(s) if s else 0
|
||||
end = int(e) if e else file_size - 1
|
||||
if start >= file_size or end >= file_size or start > end:
|
||||
raise HTTPException(status_code=416, detail="Requested Range Not Satisfiable")
|
||||
status = 206
|
||||
headers["Content-Length"] = str(end - start + 1)
|
||||
headers["Content-Range"] = f"bytes {start}-{end}/{file_size}"
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid Range header")
|
||||
|
||||
async def iterator():
|
||||
try:
|
||||
limit = end - start + 1
|
||||
downloaded = 0
|
||||
|
||||
async for chunk in client.iter_download(media, offset=start):
|
||||
if downloaded + len(chunk) > limit:
|
||||
yield chunk[:limit - downloaded]
|
||||
break
|
||||
yield chunk
|
||||
downloaded += len(chunk)
|
||||
if downloaded >= limit:
|
||||
break
|
||||
finally:
|
||||
if client.is_connected():
|
||||
await client.disconnect()
|
||||
|
||||
return StreamingResponse(iterator(), status_code=status, headers=headers)
|
||||
|
||||
except FileNotFoundError as e:
|
||||
if client.is_connected():
|
||||
await client.disconnect()
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
if client.is_connected():
|
||||
await client.disconnect()
|
||||
raise HTTPException(status_code=500, detail=f"Streaming failed: {str(e)}")
|
||||
|
||||
async def stat_file(self, root: str, rel: str):
|
||||
try:
|
||||
message_id_str, filename = rel.split('_', 1)
|
||||
message_id = int(message_id_str)
|
||||
except (ValueError, IndexError):
|
||||
raise FileNotFoundError(f"无效的文件路径格式: {rel}")
|
||||
|
||||
client = self._get_client()
|
||||
try:
|
||||
await client.connect()
|
||||
message = await client.get_messages(self.chat_id, ids=message_id)
|
||||
media = message.document or message.video or message.photo
|
||||
if not message or not media:
|
||||
raise FileNotFoundError(f"在频道 {self.chat_id} 中未找到消息ID为 {message_id} 的文件")
|
||||
|
||||
if message.photo:
|
||||
photo_size = media.sizes[-1]
|
||||
size = photo_size.size if hasattr(photo_size, 'size') else 0
|
||||
else:
|
||||
size = media.size
|
||||
|
||||
return {
|
||||
"name": rel,
|
||||
"is_dir": False,
|
||||
"size": size,
|
||||
"mtime": int(message.date.timestamp()),
|
||||
"type": "file",
|
||||
}
|
||||
finally:
|
||||
if client.is_connected():
|
||||
await client.disconnect()
|
||||
|
||||
def ADAPTER_FACTORY(rec: StorageAdapter) -> TelegramAdapter:
|
||||
return TelegramAdapter(rec)
|
||||
@@ -39,7 +39,7 @@ class WebDAVAdapter:
|
||||
rel = rel.strip('/')
|
||||
return self.base_url if not rel else urljoin(self.base_url, quote(rel) + ('/' if rel.endswith('/') else ''))
|
||||
|
||||
async def list_dir(self, root: str, rel: str, page_num: int = 1, page_size: int = 50) -> Tuple[List[Dict], int]:
|
||||
async def list_dir(self, root: str, rel: str, page_num: int = 1, page_size: int = 50, sort_by: str = "name", sort_order: str = "asc") -> Tuple[List[Dict], int]:
|
||||
raw_url = self._build_url(rel)
|
||||
url = raw_url if raw_url.endswith('/') else raw_url + '/'
|
||||
depth = "1"
|
||||
@@ -92,16 +92,39 @@ class WebDAVAdapter:
|
||||
"d:collection", NS) is not None if rt_el is not None else href_path.endswith('/')
|
||||
size = int(
|
||||
size_el.text) if size_el is not None and size_el.text and size_el.text.isdigit() else 0
|
||||
|
||||
from email.utils import parsedate_to_datetime
|
||||
mtime = 0
|
||||
if lm_el is not None and lm_el.text:
|
||||
try:
|
||||
mtime = int(parsedate_to_datetime(lm_el.text).timestamp())
|
||||
except Exception:
|
||||
mtime = 0
|
||||
|
||||
all_entries.append({
|
||||
"name": name,
|
||||
"is_dir": is_dir,
|
||||
"size": 0 if is_dir else size,
|
||||
"mtime": 0,
|
||||
"mtime": mtime,
|
||||
"type": "dir" if is_dir else "file",
|
||||
})
|
||||
|
||||
# 排序所有条目
|
||||
all_entries.sort(key=lambda x: (not x["is_dir"], x["name"].lower()))
|
||||
reverse = sort_order.lower() == "desc"
|
||||
def get_sort_key(item):
|
||||
key = (not item["is_dir"],)
|
||||
sort_field = sort_by.lower()
|
||||
if sort_field == "name":
|
||||
key += (item["name"].lower(),)
|
||||
elif sort_field == "size":
|
||||
key += (item["size"],)
|
||||
elif sort_field == "mtime":
|
||||
key += (item["mtime"],)
|
||||
else:
|
||||
key += (item["name"].lower(),)
|
||||
return key
|
||||
all_entries.sort(key=get_sort_key, reverse=reverse)
|
||||
|
||||
total_count = len(all_entries)
|
||||
|
||||
# 应用分页
|
||||
|
||||
@@ -2,13 +2,14 @@ import httpx
|
||||
from typing import List
|
||||
from services.config import ConfigCenter
|
||||
|
||||
|
||||
async def describe_image_base64(base64_image: str, detail: str = "high") -> str:
|
||||
"""
|
||||
传入base64图片和文本提示,返回图片描述文本。
|
||||
"""
|
||||
OAI_API_URL = await ConfigCenter.get("AI_API_URL", "https://api.siliconflow.cn/v1/chat/completions")
|
||||
VISION_MODEL = await ConfigCenter.get("AI_VISION_MODEL", "Qwen/Qwen2.5-VL-32B-Instruct")
|
||||
API_KEY = await ConfigCenter.get("AI_API_KEY", "")
|
||||
OAI_API_URL = await ConfigCenter.get("AI_VISION_API_URL")
|
||||
VISION_MODEL = await ConfigCenter.get("AI_VISION_MODEL")
|
||||
API_KEY = await ConfigCenter.get("AI_VISION_API_KEY")
|
||||
payload = {
|
||||
"model": VISION_MODEL,
|
||||
"messages": [
|
||||
@@ -42,13 +43,14 @@ async def describe_image_base64(base64_image: str, detail: str = "high") -> str:
|
||||
except Exception as e:
|
||||
return f"请求失败: {str(e)}"
|
||||
|
||||
|
||||
async def get_text_embedding(text: str) -> List[float]:
|
||||
"""
|
||||
传入文本,返回嵌入向量。
|
||||
"""
|
||||
OAI_API_URL = await ConfigCenter.get("AI_API_URL", "https://api.siliconflow.cn/v1/chat/completions")
|
||||
EMBED_MODEL = await ConfigCenter.get("AI_EMBED_MODEL", "Qwen/Qwen3-Embedding-8B")
|
||||
API_KEY = await ConfigCenter.get("AI_API_KEY", "")
|
||||
OAI_API_URL = await ConfigCenter.get("AI_EMBED_API_URL")
|
||||
EMBED_MODEL = await ConfigCenter.get("AI_EMBED_MODEL")
|
||||
API_KEY = await ConfigCenter.get("AI_EMBED_API_KEY")
|
||||
payload = {
|
||||
"model": EMBED_MODEL,
|
||||
"input": text
|
||||
@@ -58,7 +60,11 @@ async def get_text_embedding(text: str) -> List[float]:
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(OAI_API_URL.replace("chat/completions", "embeddings"), headers=headers, json=payload)
|
||||
if OAI_API_URL.endswith("chat/completions"):
|
||||
url = OAI_API_URL.replace("chat/completions", "embeddings")
|
||||
else:
|
||||
url = OAI_API_URL
|
||||
resp = await client.post(url, headers=headers, json=payload)
|
||||
resp.raise_for_status()
|
||||
result = resp.json()
|
||||
return result["data"][0]["embedding"]
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from tortoise.transactions import in_transaction
|
||||
from models.database import (
|
||||
StorageAdapter,
|
||||
Mount,
|
||||
UserAccount,
|
||||
AutomationTask,
|
||||
ShareLink,
|
||||
@@ -18,7 +17,6 @@ class BackupService:
|
||||
"""
|
||||
async with in_transaction() as conn:
|
||||
adapters = await StorageAdapter.all().values()
|
||||
mounts = await Mount.all().values()
|
||||
users = await UserAccount.all().values()
|
||||
tasks = await AutomationTask.all().values()
|
||||
shares = await ShareLink.all().values()
|
||||
@@ -33,7 +31,6 @@ class BackupService:
|
||||
return {
|
||||
"version": VERSION,
|
||||
"storage_adapters": list(adapters),
|
||||
"mounts": list(mounts),
|
||||
"user_accounts": list(users),
|
||||
"automation_tasks": list(tasks),
|
||||
"share_links": list(shares),
|
||||
@@ -48,7 +45,6 @@ class BackupService:
|
||||
async with in_transaction() as conn:
|
||||
await ShareLink.all().using_db(conn).delete()
|
||||
await AutomationTask.all().using_db(conn).delete()
|
||||
await Mount.all().using_db(conn).delete()
|
||||
await StorageAdapter.all().using_db(conn).delete()
|
||||
await UserAccount.all().using_db(conn).delete()
|
||||
await Configuration.all().using_db(conn).delete()
|
||||
@@ -71,12 +67,6 @@ class BackupService:
|
||||
using_db=conn
|
||||
)
|
||||
|
||||
if data.get("mounts"):
|
||||
await Mount.bulk_create(
|
||||
[Mount(**m) for m in data["mounts"]],
|
||||
using_db=conn
|
||||
)
|
||||
|
||||
if data.get("automation_tasks"):
|
||||
await AutomationTask.bulk_create(
|
||||
[AutomationTask(**t) for t in data["automation_tasks"]],
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import Any, Optional, Dict
|
||||
from dotenv import load_dotenv
|
||||
from models.database import Configuration
|
||||
load_dotenv(dotenv_path=".env")
|
||||
VERSION = "v1.0.0"
|
||||
VERSION = "v1.2.1"
|
||||
|
||||
class ConfigCenter:
|
||||
_cache: Dict[str, Any] = {}
|
||||
|
||||
122
services/task_queue.py
Normal file
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.logging import LogService
|
||||
|
||||
from services.task_queue import task_queue_service
|
||||
|
||||
|
||||
class TaskService:
|
||||
async def trigger_tasks(self, event: str, path: str):
|
||||
tasks = await AutomationTask.filter(event=event, enabled=True)
|
||||
@@ -21,28 +24,12 @@ class TaskService:
|
||||
return True
|
||||
|
||||
async def execute(self, task: AutomationTask, path: str):
|
||||
from services.virtual_fs import read_file, write_file
|
||||
|
||||
processor = get_processor(task.processor_type)
|
||||
if not processor:
|
||||
print(f"Processor {task.processor_type} not found for task {task.id}")
|
||||
return
|
||||
|
||||
try:
|
||||
file_content = await read_file(path)
|
||||
result = await processor.process(file_content, path, task.processor_config)
|
||||
|
||||
save_to = task.processor_config.get("save_to")
|
||||
if save_to and getattr(processor, "produces_file", False):
|
||||
await write_file(save_to, result)
|
||||
|
||||
except Exception as e:
|
||||
error_message = f"Error executing task {task.id} for path {path}: {e}"
|
||||
print(error_message)
|
||||
await LogService.error(
|
||||
source=f"task_executor:{task.id}",
|
||||
message=error_message,
|
||||
details={"task_name": task.name, "event": task.event, "path": path, "processor": task.processor_type}
|
||||
)
|
||||
await task_queue_service.add_task(
|
||||
"automation_task",
|
||||
{
|
||||
"task_id": task.id,
|
||||
"path": path,
|
||||
},
|
||||
)
|
||||
|
||||
task_service = TaskService()
|
||||
@@ -5,7 +5,8 @@ from pathlib import Path
|
||||
from typing import Tuple
|
||||
from fastapi import HTTPException
|
||||
|
||||
ALLOWED_EXT = {"jpg", "jpeg", "png", "webp", "gif", "bmp", "tiff", "arw", "cr2", "cr3", "nef", "rw2", "orf", "pef", "dng"}
|
||||
ALLOWED_EXT = {"jpg", "jpeg", "png", "webp", "gif", "bmp",
|
||||
"tiff", "arw", "cr2", "cr3", "nef", "rw2", "orf", "pef", "dng"}
|
||||
RAW_EXT = {"arw", "cr2", "cr3", "nef", "rw2", "orf", "pef", "dng"}
|
||||
MAX_SOURCE_SIZE = 200 * 1024 * 1024
|
||||
CACHE_ROOT = Path('data/.thumb_cache')
|
||||
@@ -49,11 +50,12 @@ def generate_thumb(data: bytes, w: int, h: int, fit: str, is_raw: bool = False)
|
||||
thumb = raw.extract_thumb()
|
||||
except rawpy.LibRawNoThumbnailError:
|
||||
thumb = None
|
||||
|
||||
|
||||
if thumb is not None and thumb.format in [rawpy.ThumbFormat.JPEG, rawpy.ThumbFormat.BITMAP]:
|
||||
im = Image.open(io.BytesIO(thumb.data))
|
||||
else:
|
||||
rgb = raw.postprocess(use_camera_wb=False, use_auto_wb=True, output_bps=8)
|
||||
rgb = raw.postprocess(
|
||||
use_camera_wb=False, use_auto_wb=True, output_bps=8)
|
||||
im = Image.fromarray(rgb)
|
||||
except Exception as e:
|
||||
print(f"rawpy processing failed: {e}")
|
||||
@@ -87,18 +89,48 @@ def generate_thumb(data: bytes, w: int, h: int, fit: str, is_raw: bool = False)
|
||||
async def get_or_create_thumb(adapter, adapter_id: int, root: str, rel: str, w: int, h: int, fit: str = 'cover'):
|
||||
stat = await adapter.stat_file(root, rel)
|
||||
if stat['size'] > MAX_SOURCE_SIZE:
|
||||
raise HTTPException(400, detail="Image too large for thumbnail")
|
||||
|
||||
key = _cache_key(adapter_id, rel, stat['size'], int(stat['mtime']), w, h, fit)
|
||||
raise HTTPException(400, detail="Image too large for thumbnail")
|
||||
|
||||
key = _cache_key(adapter_id, rel, stat['size'], int(
|
||||
stat['mtime']), w, h, fit)
|
||||
path = _cache_path(key)
|
||||
if path.exists():
|
||||
return path.read_bytes(), 'image/webp', key
|
||||
|
||||
_ensure_cache_dir(path)
|
||||
read_data = await adapter.read_file(root, rel)
|
||||
try:
|
||||
thumb_bytes, mime = generate_thumb(read_data, w, h, fit, is_raw=is_raw_filename(rel))
|
||||
except Exception as e:
|
||||
print(e)
|
||||
raise HTTPException(500, detail=f"Thumbnail generation failed: {e}")
|
||||
path.write_bytes(thumb_bytes)
|
||||
return thumb_bytes, mime, key
|
||||
thumb_bytes, mime = None, None
|
||||
|
||||
get_thumb_impl = getattr(adapter, "get_thumbnail", None)
|
||||
if callable(get_thumb_impl):
|
||||
size_str = "large" if w > 400 else "medium" if w > 100 else "small"
|
||||
native_thumb_bytes = await get_thumb_impl(root, rel, size_str)
|
||||
|
||||
if native_thumb_bytes:
|
||||
try:
|
||||
from PIL import Image
|
||||
im = Image.open(io.BytesIO(native_thumb_bytes))
|
||||
buf = io.BytesIO()
|
||||
im.save(buf, 'WEBP', quality=85)
|
||||
thumb_bytes = buf.getvalue()
|
||||
mime = 'image/webp'
|
||||
except Exception as e:
|
||||
print(
|
||||
f"Failed to convert native thumbnail to WebP: {e}, falling back.")
|
||||
thumb_bytes, mime = None, None
|
||||
|
||||
if not thumb_bytes:
|
||||
read_data = await adapter.read_file(root, rel)
|
||||
try:
|
||||
thumb_bytes, mime = generate_thumb(
|
||||
read_data, w, h, fit, is_raw=is_raw_filename(rel))
|
||||
except Exception as e:
|
||||
print(e)
|
||||
raise HTTPException(
|
||||
500, detail=f"Thumbnail generation failed: {e}")
|
||||
|
||||
if thumb_bytes:
|
||||
path.write_bytes(thumb_bytes)
|
||||
return thumb_bytes, mime, key
|
||||
|
||||
raise HTTPException(
|
||||
500, detail="Failed to generate thumbnail by any means")
|
||||
|
||||
@@ -75,3 +75,9 @@ class VectorDBService:
|
||||
output_fields=["path"]
|
||||
)
|
||||
return [[{'id': r['path'], 'distance': 1.0, 'entity': {'path': r['path']}} for r in results]]
|
||||
|
||||
def clear_all_data(self):
|
||||
"""清空所有集合的内容"""
|
||||
collections = self.client.list_collections()
|
||||
for collection_name in collections:
|
||||
self.client.drop_collection(collection_name)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from pathlib import Path
|
||||
from typing import Dict, Tuple, Any, Union, AsyncIterator
|
||||
from fastapi import HTTPException
|
||||
import mimetypes
|
||||
@@ -8,7 +7,7 @@ import hmac
|
||||
import hashlib
|
||||
import base64
|
||||
|
||||
from models import Mount
|
||||
from models import StorageAdapter
|
||||
from .adapters.registry import runtime_registry
|
||||
from api.response import page
|
||||
from .thumbnail import is_image_filename, is_raw_filename
|
||||
@@ -18,16 +17,16 @@ from services.logging import LogService
|
||||
from services.config import ConfigCenter
|
||||
|
||||
|
||||
async def resolve_mount(path: str) -> Tuple[Mount, str]:
|
||||
async def resolve_adapter_by_path(path: str) -> Tuple[StorageAdapter, str]:
|
||||
norm = path if path.startswith('/') else '/' + path
|
||||
mounts = await Mount.filter(enabled=True)
|
||||
adapters = await StorageAdapter.filter(enabled=True)
|
||||
best = None
|
||||
for m in mounts:
|
||||
if norm == m.path or norm.startswith(m.path.rstrip('/') + '/'):
|
||||
if (best is None) or len(m.path) > len(best.path):
|
||||
best = m
|
||||
for a in adapters:
|
||||
if norm == a.path or norm.startswith(a.path.rstrip('/') + '/'):
|
||||
if (best is None) or len(a.path) > len(best.path):
|
||||
best = a
|
||||
if not best:
|
||||
raise HTTPException(404, detail="No mount for path")
|
||||
raise HTTPException(404, detail="No storage adapter for path")
|
||||
rel = norm[len(best.path):].lstrip('/')
|
||||
return best, rel
|
||||
|
||||
@@ -35,16 +34,22 @@ async def resolve_mount(path: str) -> Tuple[Mount, str]:
|
||||
|
||||
|
||||
async def resolve_adapter_and_rel(path: str):
|
||||
"""返回 (adapter_instance, mount, effective_root, rel_path)."""
|
||||
"""返回 (adapter_instance, adapter_model, effective_root, rel_path)."""
|
||||
norm = path if path.startswith('/') else '/' + path
|
||||
try:
|
||||
mount, rel = await resolve_mount(norm)
|
||||
adapter_model, rel = await resolve_adapter_by_path(norm)
|
||||
except HTTPException as e:
|
||||
raise e
|
||||
await mount.fetch_related("adapter")
|
||||
adapter_instance = runtime_registry.get(mount.adapter_id)
|
||||
effective_root = adapter_instance.get_effective_root(mount.sub_path)
|
||||
return adapter_instance, mount, effective_root, rel
|
||||
adapter_instance = runtime_registry.get(adapter_model.id)
|
||||
if not adapter_instance:
|
||||
await runtime_registry.refresh()
|
||||
adapter_instance = runtime_registry.get(adapter_model.id)
|
||||
if not adapter_instance:
|
||||
raise HTTPException(
|
||||
404, detail=f"Adapter instance for ID {adapter_model.id} not found or failed to load."
|
||||
)
|
||||
effective_root = adapter_instance.get_effective_root(adapter_model.sub_path)
|
||||
return adapter_instance, adapter_model, effective_root, rel
|
||||
|
||||
|
||||
async def _ensure_method(adapter: Any, method: str):
|
||||
@@ -54,29 +59,37 @@ async def _ensure_method(adapter: Any, method: str):
|
||||
return func
|
||||
|
||||
|
||||
async def list_virtual_dir(path: str, page_num: int = 1, page_size: int = 50) -> Dict:
|
||||
async def list_virtual_dir(path: str, page_num: int = 1, page_size: int = 50, sort_by: str = "name", sort_order: str = "asc") -> Dict:
|
||||
norm = (path if path.startswith('/') else '/' + path).rstrip('/') or '/'
|
||||
mounts = await Mount.filter(enabled=True).prefetch_related("adapter")
|
||||
adapters = await StorageAdapter.filter(enabled=True)
|
||||
|
||||
child_mount_entries = []
|
||||
norm_prefix = norm.rstrip('/')
|
||||
for m in mounts:
|
||||
if m.path == norm:
|
||||
for a in adapters:
|
||||
if a.path == norm:
|
||||
continue
|
||||
if m.path.startswith(norm_prefix + '/'):
|
||||
tail = m.path[len(norm_prefix):].lstrip('/')
|
||||
if a.path.startswith(norm_prefix + '/'):
|
||||
tail = a.path[len(norm_prefix):].lstrip('/')
|
||||
if '/' not in tail:
|
||||
child_mount_entries.append(tail)
|
||||
child_mount_entries = sorted(set(child_mount_entries))
|
||||
|
||||
try:
|
||||
mount, rel = await resolve_mount(norm)
|
||||
await mount.fetch_related("adapter")
|
||||
adapter = runtime_registry.get(mount.adapter_id)
|
||||
effective_root = adapter.get_effective_root(mount.sub_path)
|
||||
adapter_model, rel = await resolve_adapter_by_path(norm)
|
||||
adapter_instance = runtime_registry.get(adapter_model.id)
|
||||
if not adapter_instance:
|
||||
await runtime_registry.refresh()
|
||||
adapter_instance = runtime_registry.get(adapter_model.id)
|
||||
|
||||
if adapter_instance:
|
||||
effective_root = adapter_instance.get_effective_root(adapter_model.sub_path)
|
||||
else:
|
||||
adapter_model = None
|
||||
effective_root = ""
|
||||
rel = ""
|
||||
except HTTPException:
|
||||
mount = None
|
||||
adapter = None
|
||||
adapter_model = None
|
||||
adapter_instance = None
|
||||
effective_root = ''
|
||||
rel = ''
|
||||
|
||||
@@ -84,10 +97,10 @@ async def list_virtual_dir(path: str, page_num: int = 1, page_size: int = 50) ->
|
||||
adapter_total = 0
|
||||
covered = set()
|
||||
|
||||
if mount and adapter:
|
||||
list_dir = await _ensure_method(adapter, "list_dir")
|
||||
if adapter_model and adapter_instance:
|
||||
list_dir = await _ensure_method(adapter_instance, "list_dir")
|
||||
try:
|
||||
adapter_entries, adapter_total = await list_dir(effective_root, rel, page_num, page_size)
|
||||
adapter_entries, adapter_total = await list_dir(effective_root, rel, page_num, page_size, sort_by, sort_order)
|
||||
except NotADirectoryError:
|
||||
raise HTTPException(400, detail="Not a directory")
|
||||
|
||||
@@ -105,32 +118,47 @@ async def list_virtual_dir(path: str, page_num: int = 1, page_size: int = 50) ->
|
||||
ent['is_image'] = is_image_filename(ent['name'])
|
||||
else:
|
||||
ent['is_image'] = False
|
||||
|
||||
all_entries = adapter_entries + mount_entries
|
||||
all_entries.sort(key=lambda x: (not x.get("is_dir"), x["name"].lower()))
|
||||
total_entries = adapter_total + len(mount_entries)
|
||||
|
||||
if mount_entries:
|
||||
reverse = sort_order.lower() == "desc"
|
||||
def get_sort_key(item):
|
||||
key = (not item.get("is_dir"),)
|
||||
sort_field = sort_by.lower()
|
||||
if sort_field == "name":
|
||||
key += (item["name"].lower(),)
|
||||
elif sort_field == "size":
|
||||
key += (item.get("size", 0),)
|
||||
elif sort_field == "mtime":
|
||||
key += (item.get("mtime", 0),)
|
||||
else:
|
||||
key += (item["name"].lower(),)
|
||||
return key
|
||||
all_entries.sort(key=get_sort_key, reverse=reverse)
|
||||
|
||||
total_entries = adapter_total + len(mount_entries)
|
||||
start_idx = (page_num - 1) * page_size
|
||||
end_idx = start_idx + page_size
|
||||
page_entries = all_entries[start_idx:end_idx]
|
||||
|
||||
return page(page_entries, total_entries, page_num, page_size)
|
||||
else:
|
||||
return page(adapter_entries, adapter_total, page_num, page_size)
|
||||
|
||||
return page(adapter_entries, adapter_total, page_num, page_size)
|
||||
|
||||
|
||||
async def read_file(path: str) -> Union[bytes, Any]:
|
||||
adapter, _mount, root, rel = await resolve_adapter_and_rel(path)
|
||||
adapter_instance, _, root, rel = await resolve_adapter_and_rel(path)
|
||||
if rel.endswith('/') or rel == '':
|
||||
raise HTTPException(400, detail="Path is a directory")
|
||||
read_func = await _ensure_method(adapter, "read_file")
|
||||
read_func = await _ensure_method(adapter_instance, "read_file")
|
||||
return await read_func(root, rel)
|
||||
|
||||
|
||||
async def write_file(path: str, data: bytes):
|
||||
adapter, _mount, root, rel = await resolve_adapter_and_rel(path)
|
||||
adapter_instance, _, root, rel = await resolve_adapter_and_rel(path)
|
||||
if rel.endswith('/'):
|
||||
raise HTTPException(400, detail="Invalid file path")
|
||||
write_func = await _ensure_method(adapter, "write_file")
|
||||
write_func = await _ensure_method(adapter_instance, "write_file")
|
||||
await write_func(root, rel, data)
|
||||
await task_service.trigger_tasks("file_written", path)
|
||||
await LogService.action(
|
||||
@@ -139,10 +167,10 @@ async def write_file(path: str, data: bytes):
|
||||
|
||||
|
||||
async def write_file_stream(path: str, data_iter: AsyncIterator[bytes], overwrite: bool = True):
|
||||
adapter, _mount, root, rel = await resolve_adapter_and_rel(path)
|
||||
adapter_instance, _, root, rel = await resolve_adapter_and_rel(path)
|
||||
if rel.endswith('/'):
|
||||
raise HTTPException(400, detail="Invalid file path")
|
||||
exists_func = getattr(adapter, "exists", None)
|
||||
exists_func = getattr(adapter_instance, "exists", None)
|
||||
if not overwrite and callable(exists_func):
|
||||
try:
|
||||
if await exists_func(root, rel):
|
||||
@@ -153,7 +181,7 @@ async def write_file_stream(path: str, data_iter: AsyncIterator[bytes], overwrit
|
||||
pass
|
||||
|
||||
size = 0
|
||||
stream_func = getattr(adapter, "write_file_stream", None)
|
||||
stream_func = getattr(adapter_instance, "write_file_stream", None)
|
||||
if callable(stream_func):
|
||||
size = await stream_func(root, rel, data_iter)
|
||||
else:
|
||||
@@ -161,7 +189,7 @@ async def write_file_stream(path: str, data_iter: AsyncIterator[bytes], overwrit
|
||||
async for chunk in data_iter:
|
||||
if chunk:
|
||||
buf.extend(chunk)
|
||||
write_func = await _ensure_method(adapter, "write_file")
|
||||
write_func = await _ensure_method(adapter_instance, "write_file")
|
||||
await write_func(root, rel, bytes(buf))
|
||||
size = len(buf)
|
||||
|
||||
@@ -175,35 +203,35 @@ async def write_file_stream(path: str, data_iter: AsyncIterator[bytes], overwrit
|
||||
|
||||
|
||||
async def make_dir(path: str):
|
||||
adapter, _mount, root, rel = await resolve_adapter_and_rel(path)
|
||||
adapter_instance, _, root, rel = await resolve_adapter_and_rel(path)
|
||||
if not rel:
|
||||
raise HTTPException(400, detail="Cannot create root")
|
||||
mkdir_func = await _ensure_method(adapter, "mkdir")
|
||||
mkdir_func = await _ensure_method(adapter_instance, "mkdir")
|
||||
await mkdir_func(root, rel)
|
||||
await LogService.action("virtual_fs", f"Created directory {path}", details={"path": path})
|
||||
|
||||
|
||||
async def delete_path(path: str):
|
||||
adapter, _mount, root, rel = await resolve_adapter_and_rel(path)
|
||||
adapter_instance, _, root, rel = await resolve_adapter_and_rel(path)
|
||||
if not rel:
|
||||
raise HTTPException(400, detail="Cannot delete root")
|
||||
delete_func = await _ensure_method(adapter, "delete")
|
||||
delete_func = await _ensure_method(adapter_instance, "delete")
|
||||
await delete_func(root, rel)
|
||||
await task_service.trigger_tasks("file_deleted", path)
|
||||
await LogService.action("virtual_fs", f"Deleted {path}", details={"path": path})
|
||||
|
||||
|
||||
async def move_path(src: str, dst: str, overwrite: bool = False, return_debug: bool = True):
|
||||
adapter_s, mount_s, root_s, rel_s = await resolve_adapter_and_rel(src)
|
||||
adapter_d, mount_d, root_d, rel_d = await resolve_adapter_and_rel(dst)
|
||||
adapter_s, adapter_model_s, root_s, rel_s = await resolve_adapter_and_rel(src)
|
||||
adapter_d, adapter_model_d, root_d, rel_d = await resolve_adapter_and_rel(dst)
|
||||
debug_info = {
|
||||
"src": src, "dst": dst,
|
||||
"rel_s": rel_s, "rel_d": rel_d,
|
||||
"root_s": root_s, "root_d": root_d,
|
||||
"overwrite": overwrite
|
||||
}
|
||||
if mount_s.id != mount_d.id:
|
||||
raise HTTPException(400, detail="Cross-mount move not supported")
|
||||
if adapter_model_s.id != adapter_model_d.id:
|
||||
raise HTTPException(400, detail="Cross-adapter move not supported")
|
||||
if not rel_s:
|
||||
raise HTTPException(400, detail="Cannot move or rename mount root")
|
||||
if not rel_d:
|
||||
@@ -266,16 +294,16 @@ async def move_path(src: str, dst: str, overwrite: bool = False, return_debug: b
|
||||
|
||||
|
||||
async def rename_path(src: str, dst: str, overwrite: bool = False, return_debug: bool = True):
|
||||
adapter_s, mount_s, root_s, rel_s = await resolve_adapter_and_rel(src)
|
||||
adapter_d, mount_d, root_d, rel_d = await resolve_adapter_and_rel(dst)
|
||||
adapter_s, adapter_model_s, root_s, rel_s = await resolve_adapter_and_rel(src)
|
||||
adapter_d, adapter_model_d, root_d, rel_d = await resolve_adapter_and_rel(dst)
|
||||
debug_info = {
|
||||
"src": src, "dst": dst,
|
||||
"rel_s": rel_s, "rel_d": rel_d,
|
||||
"root_s": root_s, "root_d": root_d,
|
||||
"overwrite": overwrite
|
||||
}
|
||||
if mount_s.id != mount_d.id:
|
||||
raise HTTPException(400, detail="Cross-mount rename not supported")
|
||||
if adapter_model_s.id != adapter_model_d.id:
|
||||
raise HTTPException(400, detail="Cross-adapter rename not supported")
|
||||
if not rel_s:
|
||||
raise HTTPException(400, detail="Cannot rename mount root")
|
||||
if not rel_d:
|
||||
@@ -338,7 +366,7 @@ async def rename_path(src: str, dst: str, overwrite: bool = False, return_debug:
|
||||
|
||||
|
||||
async def stream_file(path: str, range_header: str | None):
|
||||
adapter, mount, root, rel = await resolve_adapter_and_rel(path)
|
||||
adapter_instance, _, root, rel = await resolve_adapter_and_rel(path)
|
||||
if not rel or rel.endswith('/'):
|
||||
raise HTTPException(400, detail="Path is a directory")
|
||||
if is_raw_filename(rel):
|
||||
@@ -371,7 +399,7 @@ async def stream_file(path: str, range_header: str | None):
|
||||
except Exception as e:
|
||||
raise HTTPException(500, detail=f"RAW file processing failed: {e}")
|
||||
|
||||
stream_impl = getattr(adapter, "stream_file", None)
|
||||
stream_impl = getattr(adapter_instance, "stream_file", None)
|
||||
if callable(stream_impl):
|
||||
return await stream_impl(root, rel, range_header)
|
||||
data = await read_file(path)
|
||||
@@ -380,24 +408,24 @@ async def stream_file(path: str, range_header: str | None):
|
||||
|
||||
|
||||
async def stat_file(path: str):
|
||||
adapter, _mount, root, rel = await resolve_adapter_and_rel(path)
|
||||
stat_func = getattr(adapter, "stat_file", None)
|
||||
adapter_instance, _, root, rel = await resolve_adapter_and_rel(path)
|
||||
stat_func = getattr(adapter_instance, "stat_file", None)
|
||||
if not callable(stat_func):
|
||||
raise HTTPException(501, detail="Adapter does not implement stat_file")
|
||||
return await stat_func(root, rel)
|
||||
|
||||
|
||||
async def copy_path(src: str, dst: str, overwrite: bool = False, return_debug: bool = True):
|
||||
adapter_s, mount_s, root_s, rel_s = await resolve_adapter_and_rel(src)
|
||||
adapter_d, mount_d, root_d, rel_d = await resolve_adapter_and_rel(dst)
|
||||
adapter_s, adapter_model_s, root_s, rel_s = await resolve_adapter_and_rel(src)
|
||||
adapter_d, adapter_model_d, root_d, rel_d = await resolve_adapter_and_rel(dst)
|
||||
debug_info = {
|
||||
"src": src, "dst": dst,
|
||||
"rel_s": rel_s, "rel_d": rel_d,
|
||||
"root_s": root_s, "root_d": root_d,
|
||||
"overwrite": overwrite
|
||||
}
|
||||
if mount_s.id != mount_d.id:
|
||||
raise HTTPException(400, detail="Cross-mount copy not supported")
|
||||
if adapter_model_s.id != adapter_model_d.id:
|
||||
raise HTTPException(400, detail="Cross-adapter copy not supported")
|
||||
if not rel_s:
|
||||
raise HTTPException(400, detail="Cannot copy mount root")
|
||||
if not rel_d:
|
||||
|
||||
367
setup/foxel.sh
Normal file
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",
|
||||
"@uiw/react-md-editor": "^4.0.8",
|
||||
"antd": "^5.27.0",
|
||||
"artplayer": "^5.2.5",
|
||||
"date-fns": "^4.1.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
@@ -316,6 +317,8 @@
|
||||
|
||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"artplayer": ["artplayer@5.2.5", "", { "dependencies": { "option-validator": "^2.0.6" } }, "sha512-Ogym5rvkAJ4VLncM4Apl3TJ/a/ozM3csvY4IKuuMR++hUmEZgj/HaGsNonwx8r56nsqiZYE7O4vS1HFZl+NBSg=="],
|
||||
|
||||
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
|
||||
|
||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
@@ -532,6 +535,8 @@
|
||||
|
||||
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
||||
|
||||
"kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="],
|
||||
|
||||
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
|
||||
|
||||
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||
@@ -646,6 +651,8 @@
|
||||
|
||||
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
|
||||
|
||||
"option-validator": ["option-validator@2.0.6", "", { "dependencies": { "kind-of": "^6.0.3" } }, "sha512-tmZDan2LRIRQyhUGvkff68/O0R8UmF+Btmiiz0SmSw2ng3CfPZB9wJlIjHpe/MKUZqyIZkVIXCrwr1tIN+0Dzg=="],
|
||||
|
||||
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
|
||||
|
||||
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Foxel</title>
|
||||
<link rel='stylesheet'
|
||||
href='https://chinese-fonts-cdn.deno.dev/packages/maple-mono-cn/dist/MapleMono-CN-Regular/result.css' />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<style>
|
||||
* {
|
||||
font-family: 'Maple Mono CN';
|
||||
}
|
||||
</style>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"@ant-design/v5-patch-for-react-19": "^1.0.3",
|
||||
"@uiw/react-md-editor": "^4.0.8",
|
||||
"antd": "^5.27.0",
|
||||
"artplayer": "^5.2.5",
|
||||
"date-fns": "^4.1.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
|
||||
@@ -4,11 +4,15 @@ import { AuthProvider } from './contexts/AuthContext.tsx';
|
||||
import { status as getStatus } from './api/config.ts';
|
||||
import type { SystemStatus } from './api/config.ts';
|
||||
import { SystemContext } from './contexts/SystemContext.tsx';
|
||||
import { Spin } from 'antd';
|
||||
import { ThemeProvider } from './contexts/ThemeContext.tsx';
|
||||
import { Spin, ConfigProvider } from 'antd';
|
||||
import { Routes, Route, Navigate } from 'react-router';
|
||||
import SetupPage from './pages/SetupPage.tsx';
|
||||
import { I18nProvider, useI18n } from './i18n';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import enUS from 'antd/locale/en_US';
|
||||
|
||||
function App() {
|
||||
function AppInner() {
|
||||
const [status, setStatus] = useState<SystemStatus | null>(null);
|
||||
useEffect(() => {
|
||||
async function checkInitialization() {
|
||||
@@ -35,20 +39,33 @@ function App() {
|
||||
);
|
||||
}
|
||||
|
||||
const { lang } = useI18n();
|
||||
const locale = lang === 'zh' ? zhCN : enUS;
|
||||
|
||||
return (
|
||||
<SystemContext.Provider value={status}>
|
||||
<AuthProvider>
|
||||
{!status.is_initialized ? (
|
||||
<Routes>
|
||||
<Route path="/setup" element={<SetupPage />} />
|
||||
<Route path="*" element={<Navigate to="/setup" replace />} />
|
||||
</Routes>
|
||||
) : (
|
||||
<AppRouter />
|
||||
)}
|
||||
</AuthProvider>
|
||||
</SystemContext.Provider>
|
||||
<ConfigProvider locale={locale}>
|
||||
<SystemContext.Provider value={status}>
|
||||
<AuthProvider>
|
||||
<ThemeProvider>
|
||||
{!status.is_initialized ? (
|
||||
<Routes>
|
||||
<Route path="/setup" element={<SetupPage />} />
|
||||
<Route path="*" element={<Navigate to="/setup" replace />} />
|
||||
</Routes>
|
||||
) : (
|
||||
<AppRouter />
|
||||
)}
|
||||
</ThemeProvider>
|
||||
</AuthProvider>
|
||||
</SystemContext.Provider>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
export default function App() {
|
||||
return (
|
||||
<I18nProvider>
|
||||
<AppInner />
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ export interface AdapterItem {
|
||||
type: string;
|
||||
config: any;
|
||||
enabled: boolean;
|
||||
mount_path?: string | null;
|
||||
path?: string | null;
|
||||
sub_path?: string | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ export interface SystemStatus {
|
||||
title: string;
|
||||
logo: string;
|
||||
is_initialized: boolean;
|
||||
app_domain?: string;
|
||||
file_domain?: string;
|
||||
}
|
||||
|
||||
export async function status() {
|
||||
|
||||
46
web/src/api/plugins.ts
Normal file
46
web/src/api/plugins.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import request from './client';
|
||||
|
||||
export interface PluginItem {
|
||||
id: number;
|
||||
url: string;
|
||||
enabled: boolean;
|
||||
key?: string | null;
|
||||
name?: string | null;
|
||||
version?: string | null;
|
||||
supported_exts?: string[] | null;
|
||||
default_bounds?: Record<string, any> | null;
|
||||
default_maximized?: boolean | null;
|
||||
icon?: string | null;
|
||||
description?: string | null;
|
||||
author?: string | null;
|
||||
website?: string | null;
|
||||
github?: string | null;
|
||||
}
|
||||
|
||||
export interface PluginCreate {
|
||||
url: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface PluginManifestUpdate {
|
||||
key?: string;
|
||||
name?: string;
|
||||
version?: string;
|
||||
supported_exts?: string[];
|
||||
default_bounds?: Record<string, any>;
|
||||
default_maximized?: boolean;
|
||||
icon?: string;
|
||||
description?: string;
|
||||
author?: string;
|
||||
website?: string;
|
||||
github?: string;
|
||||
}
|
||||
|
||||
export const pluginsApi = {
|
||||
list: () => request<PluginItem[]>(`/plugins`),
|
||||
create: (payload: PluginCreate) => request<PluginItem>(`/plugins`, { method: 'POST', json: payload }),
|
||||
remove: (id: number) => request(`/plugins/${id}`, { method: 'DELETE' }),
|
||||
update: (id: number, payload: PluginCreate) => request<PluginItem>(`/plugins/${id}`, { method: 'PUT', json: payload }),
|
||||
updateManifest: (id: number, payload: PluginManifestUpdate) => request<PluginItem>(`/plugins/${id}/metadata`, { method: 'POST', json: payload }),
|
||||
};
|
||||
|
||||
@@ -14,9 +14,19 @@ export interface AutomationTask {
|
||||
export type AutomationTaskCreate = Omit<AutomationTask, 'id'>;
|
||||
export type AutomationTaskUpdate = Partial<AutomationTaskCreate>;
|
||||
|
||||
export interface QueuedTask {
|
||||
id: string;
|
||||
name: string;
|
||||
status: 'pending' | 'running' | 'success' | 'failed';
|
||||
result?: any;
|
||||
error?: string;
|
||||
task_info: Record<string, any>;
|
||||
}
|
||||
|
||||
export const tasksApi = {
|
||||
list: () => request<AutomationTask[]>('/tasks/'),
|
||||
create: (payload: AutomationTaskCreate) => request<AutomationTask>('/tasks/', { method: 'POST', json: payload }),
|
||||
update: (id: number, payload: AutomationTaskUpdate) => request<AutomationTask>(`/tasks/${id}`, { method: 'PUT', json: payload }),
|
||||
remove: (id: number) => request<void>(`/tasks/${id}`, { method: 'DELETE' }),
|
||||
getQueue: () => request<QueuedTask[]>('/tasks/queue'),
|
||||
};
|
||||
5
web/src/api/vectorDB.ts
Normal file
5
web/src/api/vectorDB.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import client from './client';
|
||||
|
||||
export const vectorDBApi = {
|
||||
clearAll: () => client('/vector-db/clear-all', { method: 'POST' }),
|
||||
};
|
||||
@@ -27,12 +27,14 @@ export interface SearchResultItem {
|
||||
}
|
||||
|
||||
export const vfsApi = {
|
||||
list: (path: string, page: number = 1, pageSize: number = 50) => {
|
||||
list: (path: string, page: number = 1, pageSize: number = 50, sortBy: string = 'name', sortOrder: string = 'asc') => {
|
||||
const cleaned = path.replace(/\\/g, '/');
|
||||
const trimmed = cleaned === '/' ? '' : cleaned.replace(/^\/+/, '');
|
||||
const params = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
page_size: pageSize.toString()
|
||||
page_size: pageSize.toString(),
|
||||
sort_by: sortBy,
|
||||
sort_order: sortOrder
|
||||
});
|
||||
return request<DirListing>(`/fs/${encodeURI(trimmed)}?${params}`);
|
||||
},
|
||||
@@ -51,7 +53,7 @@ export const vfsApi = {
|
||||
streamUrl: (path: string) => `${API_BASE_URL}/fs/stream/${encodeURI(path.replace(/^\/+/, ''))}`,
|
||||
stat: (path: string) => request(`/fs/stat/${encodeURI(path.replace(/^\/+/, ''))}`),
|
||||
getTempLinkToken: (path: string, expiresIn: number = 3600) =>
|
||||
request<{token: string}>(`/fs/temp-link/${encodeURI(path.replace(/^\/+/, ''))}?expires_in=${expiresIn}`),
|
||||
request<{token: string, path: string, url: string}>(`/fs/temp-link/${encodeURI(path.replace(/^\/+/, ''))}?expires_in=${expiresIn}`),
|
||||
getTempPublicUrl: (token: string) => `${API_BASE_URL}/fs/public/${token}`,
|
||||
uploadStream: (fullPath: string, file: File, overwrite: boolean = true, onProgress?: (loaded: number, total: number) => void) => {
|
||||
const enc = encodeURI(fullPath.replace(/^\/+/, ''));
|
||||
|
||||
@@ -243,8 +243,8 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
|
||||
left: w.maximized ? 0 : w.x,
|
||||
width: w.maximized ? '100vw' : w.width,
|
||||
height: w.maximized ? '100vh' : w.height,
|
||||
background: 'rgba(240, 242, 245, 0.7)', // Semi-transparent background
|
||||
border: '1px solid rgba(255, 255, 255, 0.18)',
|
||||
background: 'var(--ant-color-bg-elevated, var(--ant-color-bg-container))',
|
||||
border: '1px solid var(--ant-color-border-secondary, rgba(255,255,255,0.18))',
|
||||
borderRadius: w.maximized ? 0 : 12,
|
||||
boxShadow: w.maximized
|
||||
? 'none'
|
||||
@@ -254,7 +254,7 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
backdropFilter: 'blur(20px) saturate(180%)', // Enhanced blur effect
|
||||
backdropFilter: 'blur(12px) saturate(150%)',
|
||||
zIndex: 3000 + idx,
|
||||
willChange: 'left,top,width,height',
|
||||
transition: interacting ? 'none' : 'top .15s,left .15s,width .15s,height .15s,box-shadow .25s'
|
||||
@@ -269,9 +269,9 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '0 12px',
|
||||
background: 'rgba(0, 0, 0, 0.25)', // Lighter, transparent title bar
|
||||
borderBottom: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
color: '#333', // Darker text for readability
|
||||
background: 'var(--ant-color-fill-secondary, rgba(0,0,0,0.25))',
|
||||
borderBottom: '1px solid var(--ant-color-border-secondary, rgba(255,255,255,0.1))',
|
||||
color: 'var(--ant-color-text, #333)',
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
letterSpacing: .2,
|
||||
@@ -298,7 +298,7 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
|
||||
icon={w.maximized ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
|
||||
onClick={() => onToggleMax(w.id)}
|
||||
style={{
|
||||
color: '#555',
|
||||
color: 'var(--ant-color-text-secondary, #555)',
|
||||
width: 30,
|
||||
height: 30,
|
||||
display: 'flex',
|
||||
@@ -314,7 +314,7 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
|
||||
icon={<CloseOutlined />}
|
||||
onClick={() => onClose(w.id)}
|
||||
style={{
|
||||
color: '#ff4d4f',
|
||||
color: 'var(--ant-color-error, #ff4d4f)',
|
||||
width: 30,
|
||||
height: 30,
|
||||
display: 'flex',
|
||||
|
||||
@@ -177,7 +177,7 @@ export const ImageViewerApp: React.FC<AppComponentProps> = ({ filePath, entry, o
|
||||
if (err) {
|
||||
return (
|
||||
<div style={{
|
||||
color: '#f5222d',
|
||||
color: 'var(--ant-color-error, #f5222d)',
|
||||
padding: 16,
|
||||
background: 'rgba(20,20,20,0.8)',
|
||||
backdropFilter: 'blur(24px)'
|
||||
|
||||
@@ -2,8 +2,10 @@ import React, { useEffect, useState } from 'react';
|
||||
import { vfsApi } from '../../api/client';
|
||||
import type { AppComponentProps } from '../types';
|
||||
import { Spin, Result, Button } from 'antd';
|
||||
import { useSystemStatus } from '../../contexts/SystemContext';
|
||||
|
||||
export const OfficeViewerApp: React.FC<AppComponentProps> = ({ filePath, onRequestClose }) => {
|
||||
const systemStatus = useSystemStatus();
|
||||
const [url, setUrl] = useState<string>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState<string>();
|
||||
@@ -17,8 +19,8 @@ export const OfficeViewerApp: React.FC<AppComponentProps> = ({ filePath, onReque
|
||||
vfsApi.getTempLinkToken(filePath.replace(/^\/+/, ''))
|
||||
.then(res => {
|
||||
if (cancelled) return;
|
||||
// 注意:vfsApi.getTempPublicUrl 返回的是相对路径,我们需要构建完整的 URL
|
||||
const fullUrl = new URL(vfsApi.getTempPublicUrl(res.token), window.location.origin).href;
|
||||
const baseUrl = systemStatus?.file_domain || window.location.origin;
|
||||
const fullUrl = new URL(res.url, baseUrl).href;
|
||||
const officeUrl = `https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(fullUrl)}`;
|
||||
setUrl(officeUrl);
|
||||
})
|
||||
@@ -58,7 +60,7 @@ export const OfficeViewerApp: React.FC<AppComponentProps> = ({ filePath, onReque
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', background: '#fff' }}>
|
||||
<div style={{ width: '100%', height: '100%', background: 'var(--ant-color-bg-container, #fff)' }}>
|
||||
{url ? (
|
||||
<iframe
|
||||
src={url}
|
||||
@@ -77,4 +79,4 @@ export const OfficeViewerApp: React.FC<AppComponentProps> = ({ filePath, onReque
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
59
web/src/apps/PluginHost/index.tsx
Normal file
59
web/src/apps/PluginHost/index.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import type { AppComponentProps } from '../types';
|
||||
import { vfsApi } from '../../api/vfs';
|
||||
import { loadPluginFromUrl, ensureManifest, type RegisteredPlugin } from '../../plugins/runtime';
|
||||
import type { PluginItem } from '../../api/plugins';
|
||||
import { useAsyncSafeEffect } from '../../hooks/useAsyncSafeEffect';
|
||||
import { useI18n } from '../../i18n';
|
||||
|
||||
export interface PluginAppHostProps extends AppComponentProps {
|
||||
plugin: PluginItem;
|
||||
}
|
||||
|
||||
export const PluginAppHost: React.FC<PluginAppHostProps> = ({ plugin, filePath, entry, onRequestClose }) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const onCloseRef = useRef(onRequestClose);
|
||||
onCloseRef.current = onRequestClose;
|
||||
const { t } = useI18n();
|
||||
|
||||
const pluginRef = useRef<RegisteredPlugin | null>(null);
|
||||
|
||||
useAsyncSafeEffect(
|
||||
async ({ isDisposed }) => {
|
||||
try {
|
||||
const p = await loadPluginFromUrl(plugin.url);
|
||||
if (isDisposed()) return;
|
||||
pluginRef.current = p;
|
||||
await ensureManifest(plugin.id, p);
|
||||
if (isDisposed()) return;
|
||||
const token = await vfsApi.getTempLinkToken(filePath);
|
||||
if (isDisposed()) return;
|
||||
const downloadUrl = vfsApi.getTempPublicUrl(token.token);
|
||||
if (isDisposed() || !containerRef.current) return;
|
||||
await p.mount(containerRef.current, {
|
||||
filePath,
|
||||
entry,
|
||||
urls: { downloadUrl },
|
||||
host: { close: () => onCloseRef.current() },
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (!isDisposed()) setError(e?.message || t('Plugin run failed'));
|
||||
}
|
||||
},
|
||||
[plugin.id, plugin.url, filePath],
|
||||
() => {
|
||||
try {
|
||||
if (pluginRef.current?.unmount && containerRef.current) {
|
||||
pluginRef.current.unmount(containerRef.current);
|
||||
}
|
||||
} catch {}
|
||||
},
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return <div style={{ padding: 12, color: 'red' }}>{t('Plugin Error')}: {error}</div>;
|
||||
}
|
||||
|
||||
return <div ref={containerRef} style={{ width: '100%', height: '100%', overflow: 'auto' }} />;
|
||||
};
|
||||
@@ -64,19 +64,19 @@ export const TextEditorApp: React.FC<AppComponentProps> = ({ filePath, entry, on
|
||||
}, [handleSave]);
|
||||
|
||||
return (
|
||||
<Layout style={{ height: '100%', background: '#ffffff' }}>
|
||||
<Layout style={{ height: '100%', background: 'var(--ant-color-bg-container, #ffffff)' }}>
|
||||
<Header
|
||||
style={{
|
||||
background: '#f0f2f5',
|
||||
background: 'var(--ant-color-bg-layout, #f0f2f5)',
|
||||
padding: '0 16px',
|
||||
height: 40,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
borderBottom: '1px solid #d9d9d9'
|
||||
borderBottom: '1px solid var(--ant-color-border-secondary, #d9d9d9)'
|
||||
}}
|
||||
>
|
||||
<span style={{ color: 'rgba(0, 0, 0, 0.88)' }}>
|
||||
<span style={{ color: 'var(--ant-color-text, rgba(0,0,0,0.88))' }}>
|
||||
{entry.name} {isDirty && '*'}
|
||||
</span>
|
||||
<Space>
|
||||
@@ -101,4 +101,4 @@ export const TextEditorApp: React.FC<AppComponentProps> = ({ filePath, entry, on
|
||||
</Content>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,408 +1,46 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import Artplayer from 'artplayer';
|
||||
import { vfsApi } from '../../api/client';
|
||||
import type { AppComponentProps } from '../types';
|
||||
import { Spin, Button } from 'antd';
|
||||
import {
|
||||
PauseOutlined,
|
||||
CaretRightOutlined,
|
||||
SoundOutlined,
|
||||
FullscreenOutlined,
|
||||
ReloadOutlined
|
||||
} from '@ant-design/icons';
|
||||
|
||||
|
||||
export const VideoPlayerApp: React.FC<AppComponentProps> = ({ filePath }) => {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const progressBarRef = useRef<HTMLDivElement | null>(null);
|
||||
const progressRef = useRef<HTMLDivElement | null>(null);
|
||||
const isMountedRef = useRef(true);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [volume, setVolume] = useState(0.7);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState<string>();
|
||||
const [url, setUrl] = useState<string>();
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
const [retryKey, setRetryKey] = useState(0);
|
||||
const controlsTimerRef = useRef<number | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
if (controlsTimerRef.current) {
|
||||
window.clearTimeout(controlsTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
const artRef = useRef<HTMLDivElement | null>(null);
|
||||
const artInstance = useRef<Artplayer | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
//
|
||||
const safePath = filePath.replace(/^\/+/, '').split('#').map((seg, idx) => idx === 0 ? seg : encodeURIComponent('#') + seg).join('');
|
||||
const u = vfsApi.streamUrl(safePath);
|
||||
setUrl(u);
|
||||
setErr(undefined);
|
||||
setLoading(true);
|
||||
}, [filePath, retryKey]);
|
||||
const videoUrl = vfsApi.streamUrl(safePath);
|
||||
|
||||
// 处理视频事件
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video || !url) return;
|
||||
|
||||
const onLoadedMetadata = () => {
|
||||
if (isMountedRef.current) {
|
||||
setDuration(video.duration);
|
||||
}
|
||||
};
|
||||
|
||||
const onTimeUpdate = () => {
|
||||
if (isMountedRef.current) {
|
||||
setCurrentTime(video.currentTime);
|
||||
updateProgressBar();
|
||||
}
|
||||
};
|
||||
|
||||
const onCanPlay = () => {
|
||||
if (isMountedRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onEnded = () => {
|
||||
if (isMountedRef.current) {
|
||||
setIsPlaying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onError = () => {
|
||||
if (isMountedRef.current) {
|
||||
setLoading(false);
|
||||
setErr('视频加载失败');
|
||||
}
|
||||
};
|
||||
|
||||
const onPlay = () => {
|
||||
if (isMountedRef.current) {
|
||||
setIsPlaying(true);
|
||||
}
|
||||
};
|
||||
|
||||
const onPause = () => {
|
||||
if (isMountedRef.current) {
|
||||
setIsPlaying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onProgress = () => {
|
||||
// 监听缓冲进度
|
||||
if (video.buffered.length > 0) {
|
||||
const bufferedEnd = video.buffered.end(video.buffered.length - 1);
|
||||
if (progressBarRef.current) {
|
||||
const bufferProgress = bufferedEnd / video.duration * 100;
|
||||
progressBarRef.current.style.setProperty('--buffer-width', `${bufferProgress}%`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
video.addEventListener('loadedmetadata', onLoadedMetadata);
|
||||
video.addEventListener('timeupdate', onTimeUpdate);
|
||||
video.addEventListener('canplay', onCanPlay);
|
||||
video.addEventListener('ended', onEnded);
|
||||
video.addEventListener('error', onError);
|
||||
video.addEventListener('play', onPlay);
|
||||
video.addEventListener('pause', onPause);
|
||||
video.addEventListener('progress', onProgress);
|
||||
if (artRef.current) {
|
||||
artInstance.current = new Artplayer({
|
||||
container: artRef.current,
|
||||
url: videoUrl,
|
||||
autoplay: true,
|
||||
fullscreen: true,
|
||||
fullscreenWeb: true,
|
||||
pip: true,
|
||||
setting: true,
|
||||
playbackRate: true,
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
video.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||
video.removeEventListener('timeupdate', onTimeUpdate);
|
||||
video.removeEventListener('canplay', onCanPlay);
|
||||
video.removeEventListener('ended', onEnded);
|
||||
video.removeEventListener('error', onError);
|
||||
video.removeEventListener('play', onPlay);
|
||||
video.removeEventListener('pause', onPause);
|
||||
video.removeEventListener('progress', onProgress);
|
||||
};
|
||||
}, [url]);
|
||||
|
||||
// 处理进度条更新
|
||||
const updateProgressBar = () => {
|
||||
const video = videoRef.current;
|
||||
const progress = progressRef.current;
|
||||
|
||||
if (video && progress && duration > 0) {
|
||||
const percentage = (video.currentTime / duration) * 100;
|
||||
progress.style.width = `${percentage}%`;
|
||||
}
|
||||
};
|
||||
|
||||
// 处理进度条点击
|
||||
const handleProgressBarClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const progressBar = progressBarRef.current;
|
||||
const video = videoRef.current;
|
||||
|
||||
if (progressBar && video) {
|
||||
const rect = progressBar.getBoundingClientRect();
|
||||
const clickPosition = e.clientX - rect.left;
|
||||
const percentage = clickPosition / rect.width;
|
||||
const newTime = percentage * duration;
|
||||
|
||||
video.currentTime = newTime;
|
||||
setCurrentTime(newTime);
|
||||
}
|
||||
};
|
||||
|
||||
// 播放/暂停
|
||||
const togglePlay = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
if (isPlaying) {
|
||||
video.pause();
|
||||
} else {
|
||||
video.play().catch(error => {
|
||||
console.error('播放失败:', error);
|
||||
setErr('播放失败');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 全屏
|
||||
const toggleFullscreen = () => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
if (!document.fullscreenElement) {
|
||||
container.requestFullscreen().catch(err => {
|
||||
console.error('全屏失败:', err);
|
||||
});
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
};
|
||||
|
||||
// 音量控制
|
||||
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newVolume = parseFloat(e.target.value);
|
||||
setVolume(newVolume);
|
||||
|
||||
const video = videoRef.current;
|
||||
if (video) {
|
||||
video.volume = newVolume;
|
||||
setIsMuted(newVolume === 0);
|
||||
}
|
||||
};
|
||||
|
||||
// 静音切换
|
||||
const toggleMute = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
const newMuted = !isMuted;
|
||||
setIsMuted(newMuted);
|
||||
video.muted = newMuted;
|
||||
};
|
||||
|
||||
// 格式化时间显示
|
||||
const formatTime = (seconds: number): string => {
|
||||
if (isNaN(seconds)) return '00:00';
|
||||
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// 控制栏自动隐藏
|
||||
const resetControlsTimer = () => {
|
||||
if (controlsTimerRef.current) {
|
||||
window.clearTimeout(controlsTimerRef.current);
|
||||
}
|
||||
|
||||
setShowControls(true);
|
||||
|
||||
controlsTimerRef.current = window.setTimeout(() => {
|
||||
if (isPlaying && isMountedRef.current) {
|
||||
setShowControls(false);
|
||||
if (artInstance.current) {
|
||||
artInstance.current.destroy();
|
||||
}
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const handleMouseMove = () => {
|
||||
resetControlsTimer();
|
||||
};
|
||||
|
||||
const retry = () => setRetryKey(k => k + 1);
|
||||
};
|
||||
}, [filePath]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', background: '#000' }}
|
||||
ref={containerRef}
|
||||
onMouseMove={handleMouseMove}
|
||||
>
|
||||
<div style={{ flex: 1, position: 'relative', backgroundColor: '#000', overflow: 'hidden' }}>
|
||||
{/* 视频元素 */}
|
||||
<video
|
||||
ref={videoRef}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain'
|
||||
}}
|
||||
src={url}
|
||||
controlsList="nodownload"
|
||||
crossOrigin="anonymous"
|
||||
preload="metadata"
|
||||
onClick={togglePlay}
|
||||
/>
|
||||
|
||||
{/* 加载指示器 */}
|
||||
{loading && !err && (
|
||||
<div style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.35)', gap: 12 }}>
|
||||
<Spin />
|
||||
<span style={{ fontSize: 12, color: '#aaa' }}>正在缓冲...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 错误显示 */}
|
||||
{err && (
|
||||
<div style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.5)', gap: 12 }}>
|
||||
<span style={{ color: '#ff4d4f', fontSize: 13 }}>{err}</span>
|
||||
<Button icon={<ReloadOutlined />} size="small" onClick={retry}>重试</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 控制栏 */}
|
||||
{showControls && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: 'linear-gradient(transparent, rgba(0,0,0,0.7))',
|
||||
padding: '30px 15px 10px',
|
||||
transition: 'opacity 0.3s',
|
||||
opacity: showControls ? 1 : 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px'
|
||||
}}
|
||||
>
|
||||
{/* 进度条 */}
|
||||
<div
|
||||
ref={progressBarRef}
|
||||
onClick={handleProgressBarClick}
|
||||
style={{
|
||||
height: '4px',
|
||||
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||
cursor: 'pointer',
|
||||
position: 'relative',
|
||||
borderRadius: '2px',
|
||||
'--buffer-width': '0%'
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
height: '100%',
|
||||
width: 'var(--buffer-width)',
|
||||
backgroundColor: 'rgba(255,255,255,0.4)',
|
||||
borderRadius: '2px'
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
ref={progressRef}
|
||||
style={{
|
||||
height: '100%',
|
||||
width: '0%',
|
||||
backgroundColor: '#1890ff',
|
||||
position: 'relative',
|
||||
borderRadius: '2px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '-6px',
|
||||
top: '-4px',
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: '#1890ff',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={isPlaying ? <PauseOutlined /> : <CaretRightOutlined />}
|
||||
onClick={togglePlay}
|
||||
style={{ color: '#fff' }}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', width: '100px' }}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<SoundOutlined />}
|
||||
onClick={toggleMute}
|
||||
style={{ color: isMuted ? '#888' : '#fff' }}
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={isMuted ? 0 : volume}
|
||||
onChange={handleVolumeChange}
|
||||
style={{ width: '60px' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ color: '#fff', fontSize: '12px' }}>
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<FullscreenOutlined />}
|
||||
onClick={toggleFullscreen}
|
||||
style={{ color: '#fff' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isPlaying && !loading && !err && (
|
||||
<div
|
||||
onClick={togglePlay}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: '60px',
|
||||
height: '60px',
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
<CaretRightOutlined style={{ fontSize: '24px', color: '#fff' }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
ref={artRef}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: '#000'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ export const descriptor: AppDescriptor = {
|
||||
supported: (entry) => {
|
||||
if (entry.is_dir) return false;
|
||||
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
||||
return ['mp4','webm','ogg','m4v','mov'].includes(ext);
|
||||
return ['mp4','webm','ogg','m4v','mov','mkv','avi','wmv','flv','3gp'].includes(ext);
|
||||
},
|
||||
component: VideoPlayerApp,
|
||||
default: true,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { VfsEntry } from '../api/client';
|
||||
import type { AppDescriptor } from './types';
|
||||
import React from 'react';
|
||||
import { pluginsApi, type PluginItem } from '../api/plugins';
|
||||
import { PluginAppHost } from './PluginHost';
|
||||
const apps: AppDescriptor[] = [];
|
||||
|
||||
// 使用 import.meta.glob 动态导入所有应用
|
||||
// vite-glob-ignore
|
||||
const appModules = import.meta.glob('./*/index.ts');
|
||||
|
||||
async function loadApps() {
|
||||
@@ -16,11 +18,34 @@ async function loadApps() {
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
const items = await pluginsApi.list();
|
||||
items.filter(p => p.enabled !== false).forEach((p) => registerPluginAsApp(p));
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
|
||||
// 立即加载并注册所有应用
|
||||
loadApps();
|
||||
function registerPluginAsApp(p: PluginItem) {
|
||||
const key = 'plugin:' + p.id;
|
||||
if (apps.find(a => a.key === key)) return;
|
||||
const supported = (entry: VfsEntry) => {
|
||||
if (entry.is_dir) return false;
|
||||
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
||||
if (!p.supported_exts || p.supported_exts.length === 0) return true;
|
||||
return p.supported_exts.includes(ext);
|
||||
};
|
||||
apps.push({
|
||||
key,
|
||||
name: p.name || `插件 ${p.id}`,
|
||||
supported,
|
||||
component: (props: any) => React.createElement(PluginAppHost, { plugin: p, ...props }),
|
||||
default: false,
|
||||
defaultBounds: p.default_bounds || undefined,
|
||||
defaultMaximized: p.default_maximized || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
loadApps();
|
||||
|
||||
export function getAppsForEntry(entry: VfsEntry): AppDescriptor[] {
|
||||
return apps.filter(a => a.supported(entry));
|
||||
@@ -43,3 +68,27 @@ export function getDefaultAppForEntry(entry: VfsEntry): AppDescriptor | undefine
|
||||
|
||||
export type { AppDescriptor };
|
||||
export type { AppComponentProps } from './types';
|
||||
|
||||
export async function reloadPluginApps() {
|
||||
try {
|
||||
const items = await pluginsApi.list();
|
||||
const keepKeys = new Set(items.filter(p => p.enabled !== false).map(p => 'plugin:' + p.id));
|
||||
for (let i = apps.length - 1; i >= 0; i--) {
|
||||
const a = apps[i];
|
||||
if (a.key.startsWith('plugin:') && !keepKeys.has(a.key)) {
|
||||
apps.splice(i, 1);
|
||||
}
|
||||
}
|
||||
items.filter(p => p.enabled !== false).forEach(p => {
|
||||
const key = 'plugin:' + p.id;
|
||||
const existing = apps.find(a => a.key === key);
|
||||
if (!existing) {
|
||||
registerPluginAsApp(p);
|
||||
} else {
|
||||
existing.name = p.name || `插件 ${p.id}`;
|
||||
existing.defaultBounds = p.default_bounds || undefined;
|
||||
existing.defaultMaximized = p.default_maximized || undefined;
|
||||
}
|
||||
});
|
||||
} catch { }
|
||||
}
|
||||
|
||||
20
web/src/components/LanguageSwitcher.tsx
Normal file
20
web/src/components/LanguageSwitcher.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Dropdown, Button } from 'antd';
|
||||
import { GlobalOutlined, CheckOutlined } from '@ant-design/icons';
|
||||
import { memo } from 'react';
|
||||
import { useI18n } from '../i18n';
|
||||
|
||||
const LanguageSwitcher = memo(function LanguageSwitcher() {
|
||||
const { lang, setLang, t } = useI18n();
|
||||
const items = [
|
||||
{ key: 'zh', label: t('Chinese'), icon: lang === 'zh' ? <CheckOutlined /> : undefined, onClick: () => setLang('zh') },
|
||||
{ key: 'en', label: t('English'), icon: lang === 'en' ? <CheckOutlined /> : undefined, onClick: () => setLang('en') },
|
||||
];
|
||||
return (
|
||||
<Dropdown menu={{ items }} trigger={['click']}>
|
||||
<Button icon={<GlobalOutlined />}>{t('Language')}</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
});
|
||||
|
||||
export default LanguageSwitcher;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Form, Input, Select, Typography } from 'antd';
|
||||
import type { ProcessorTypeMeta } from '../api/processors';
|
||||
import { useI18n } from '../i18n';
|
||||
|
||||
interface ProcessorConfigFormProps {
|
||||
processorMeta: ProcessorTypeMeta | undefined;
|
||||
@@ -9,17 +10,18 @@ interface ProcessorConfigFormProps {
|
||||
}
|
||||
|
||||
export const ProcessorConfigForm: React.FC<ProcessorConfigFormProps> = ({ processorMeta, configPath }) => {
|
||||
const { t } = useI18n();
|
||||
if (!processorMeta) {
|
||||
return <Typography.Text type="secondary">请先选择处理器</Typography.Text>;
|
||||
return <Typography.Text type="secondary">{t('Please select a processor')}</Typography.Text>;
|
||||
}
|
||||
if (!processorMeta.config_schema?.length) {
|
||||
return <Typography.Text type="secondary">该处理器无配置项</Typography.Text>;
|
||||
return <Typography.Text type="secondary">{t('No config fields')}</Typography.Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{processorMeta.config_schema.map(field => {
|
||||
const rules = field.required ? [{ required: true, message: `请输入${field.label}` }] : [];
|
||||
const rules = field.required ? [{ required: true, message: t('Please input {label}', { label: field.label }) }] : [];
|
||||
let inputNode: React.ReactNode;
|
||||
|
||||
switch (field.type) {
|
||||
@@ -31,7 +33,7 @@ export const ProcessorConfigForm: React.FC<ProcessorConfigFormProps> = ({ proces
|
||||
break;
|
||||
case 'select':
|
||||
inputNode = (
|
||||
<Select placeholder={field.placeholder || '请选择'}>
|
||||
<Select placeholder={field.placeholder || t('Please select')}>
|
||||
{field.options?.map((opt: any) => (
|
||||
<Select.Option key={String(opt.value)} value={opt.value}>
|
||||
{opt.label}
|
||||
@@ -48,7 +50,7 @@ export const ProcessorConfigForm: React.FC<ProcessorConfigFormProps> = ({ proces
|
||||
<Form.Item
|
||||
key={field.key}
|
||||
name={[...configPath, field.key]}
|
||||
label={field.label}
|
||||
label={t(field.label)}
|
||||
rules={rules}
|
||||
initialValue={field.default}
|
||||
>
|
||||
@@ -58,4 +60,4 @@ export const ProcessorConfigForm: React.FC<ProcessorConfigFormProps> = ({ proces
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
185
web/src/contexts/ThemeContext.tsx
Normal file
185
web/src/contexts/ThemeContext.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ConfigProvider, theme as antdTheme } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import type { ThemeConfig } from 'antd/es/config-provider/context';
|
||||
import { getAllConfig } from '../api/config';
|
||||
import { useAuth } from './AuthContext';
|
||||
import baseTheme from '../theme';
|
||||
|
||||
type ThemeMode = 'light' | 'dark' | 'system';
|
||||
|
||||
interface ThemeState {
|
||||
mode: ThemeMode;
|
||||
primaryColor?: string | null;
|
||||
borderRadius?: number | null;
|
||||
customTokens?: Record<string, any> | null;
|
||||
customCSS?: string | null;
|
||||
}
|
||||
|
||||
interface ThemeContextType {
|
||||
refreshTheme: () => Promise<void>;
|
||||
previewTheme: (patch: Partial<ThemeState>) => void;
|
||||
mode: ThemeMode;
|
||||
resolvedMode: ThemeMode;
|
||||
}
|
||||
|
||||
const Ctx = createContext<ThemeContextType>({} as any);
|
||||
|
||||
const CONFIG_KEYS = {
|
||||
MODE: 'THEME_MODE',
|
||||
PRIMARY: 'THEME_PRIMARY_COLOR',
|
||||
RADIUS: 'THEME_BORDER_RADIUS',
|
||||
TOKENS: 'THEME_CUSTOM_TOKENS',
|
||||
CSS: 'THEME_CUSTOM_CSS',
|
||||
};
|
||||
|
||||
function parseJSON<T = any>(text: string | null | undefined): T | null {
|
||||
if (!text) return null;
|
||||
try {
|
||||
return JSON.parse(text) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function useSystemDarkPreferred() {
|
||||
const [isDark, setIsDark] = useState<boolean>(
|
||||
typeof window !== 'undefined' && window.matchMedia
|
||||
? window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
: false
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!window.matchMedia) return;
|
||||
const mql = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handler = (e: MediaQueryListEvent) => setIsDark(e.matches);
|
||||
mql.addEventListener?.('change', handler);
|
||||
return () => mql.removeEventListener?.('change', handler);
|
||||
}, []);
|
||||
return isDark;
|
||||
}
|
||||
|
||||
function buildThemeConfig(state: ThemeState, systemDark: boolean): ThemeConfig {
|
||||
const resolvedMode: ThemeMode = state.mode === 'system' ? (systemDark ? 'dark' : 'light') : state.mode;
|
||||
const algorithm = resolvedMode === 'dark'
|
||||
? [antdTheme.darkAlgorithm, antdTheme.compactAlgorithm]
|
||||
: [antdTheme.defaultAlgorithm, antdTheme.compactAlgorithm];
|
||||
|
||||
const safeBaseTokens: Record<string, any> = resolvedMode === 'dark'
|
||||
? {
|
||||
borderRadius: baseTheme.token?.borderRadius,
|
||||
fontSize: baseTheme.token?.fontSize,
|
||||
controlHeight: baseTheme.token?.controlHeight,
|
||||
boxShadow: baseTheme.token?.boxShadow,
|
||||
}
|
||||
: { ...(baseTheme.token as any) };
|
||||
|
||||
const token = {
|
||||
...safeBaseTokens,
|
||||
...(state.primaryColor ? { colorPrimary: state.primaryColor } : {}),
|
||||
...(state.borderRadius != null ? { borderRadius: state.borderRadius } : {}),
|
||||
...(state.customTokens || {}),
|
||||
} as any;
|
||||
|
||||
const baseComponents = { ...(baseTheme.components as any) };
|
||||
if (resolvedMode === 'dark' && baseComponents) {
|
||||
if (baseComponents.Menu) {
|
||||
const { itemHoverColor, itemHoverBg, itemSelectedBg, itemSelectedColor, ...rest } = baseComponents.Menu;
|
||||
baseComponents.Menu = rest;
|
||||
}
|
||||
if (baseComponents.Dropdown) {
|
||||
const { controlItemBgHover, ...rest } = baseComponents.Dropdown;
|
||||
baseComponents.Dropdown = rest;
|
||||
}
|
||||
if (baseComponents.Table) {
|
||||
const { headerBg, rowHoverBg, ...rest } = baseComponents.Table;
|
||||
baseComponents.Table = rest;
|
||||
}
|
||||
}
|
||||
|
||||
return { algorithm, token, components: baseComponents } satisfies ThemeConfig;
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const systemDark = useSystemDarkPreferred();
|
||||
const [state, setState] = useState<ThemeState>({ mode: 'light' });
|
||||
const styleTagRef = useRef<HTMLStyleElement | null>(null);
|
||||
|
||||
const ensureStyleTag = () => {
|
||||
if (styleTagRef.current) return styleTagRef.current;
|
||||
let styleEl = document.getElementById('foxel-custom-css') as HTMLStyleElement | null;
|
||||
if (!styleEl) {
|
||||
styleEl = document.createElement('style');
|
||||
styleEl.id = 'foxel-custom-css';
|
||||
document.head.appendChild(styleEl);
|
||||
}
|
||||
styleTagRef.current = styleEl;
|
||||
return styleEl;
|
||||
};
|
||||
|
||||
const applyCustomCSS = (cssText: string | null | undefined) => {
|
||||
const el = ensureStyleTag();
|
||||
el.textContent = cssText || '';
|
||||
};
|
||||
|
||||
const applyHtmlDataTheme = (mode: ThemeMode) => {
|
||||
const finalMode = mode === 'system' ? (systemDark ? 'dark' : 'light') : mode;
|
||||
document.documentElement.setAttribute('data-theme', finalMode);
|
||||
};
|
||||
|
||||
const refreshTheme = async () => {
|
||||
if (!isAuthenticated) {
|
||||
applyHtmlDataTheme(state.mode || 'light');
|
||||
applyCustomCSS(state.customCSS || '');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const cfg = await getAllConfig();
|
||||
const mode = (cfg[CONFIG_KEYS.MODE] as ThemeMode) || 'light';
|
||||
const primary = (cfg[CONFIG_KEYS.PRIMARY] as string) || null;
|
||||
const radiusStr = cfg[CONFIG_KEYS.RADIUS];
|
||||
const radius = radiusStr != null ? Number(radiusStr) : null;
|
||||
const customTokens = parseJSON<Record<string, any>>(cfg[CONFIG_KEYS.TOKENS]);
|
||||
const customCSS = (cfg[CONFIG_KEYS.CSS] as string) || '';
|
||||
setState({ mode, primaryColor: primary, borderRadius: radius, customTokens, customCSS });
|
||||
applyHtmlDataTheme(mode);
|
||||
applyCustomCSS(customCSS);
|
||||
} catch (e) {
|
||||
applyHtmlDataTheme('light');
|
||||
applyCustomCSS('');
|
||||
}
|
||||
};
|
||||
|
||||
const previewTheme = (patch: Partial<ThemeState>) => {
|
||||
const next: ThemeState = { ...state, ...patch };
|
||||
setState(next);
|
||||
applyHtmlDataTheme(next.mode || 'light');
|
||||
applyCustomCSS(next.customCSS || '');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refreshTheme();
|
||||
}, [isAuthenticated, systemDark]);
|
||||
|
||||
const themeConfig = useMemo(() => buildThemeConfig(state, systemDark), [state, systemDark]);
|
||||
const resolvedMode: ThemeMode = useMemo(() => (state.mode === 'system' ? (systemDark ? 'dark' : 'light') : state.mode), [state.mode, systemDark]);
|
||||
|
||||
const ctxValue = useMemo<ThemeContextType>(() => ({
|
||||
refreshTheme,
|
||||
previewTheme,
|
||||
mode: state.mode,
|
||||
resolvedMode,
|
||||
}), [state.mode, resolvedMode]);
|
||||
|
||||
return (
|
||||
<Ctx.Provider value={ctxValue}>
|
||||
<ConfigProvider theme={{ ...themeConfig, cssVar: true }} locale={zhCN}>
|
||||
{children}
|
||||
</ConfigProvider>
|
||||
</Ctx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
return useContext(Ctx);
|
||||
}
|
||||
@@ -1,41 +1,42 @@
|
||||
html,body,#root { height: 100%; }
|
||||
body { font-family: system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,'Noto Sans',sans-serif; background:#f9f9f9; }
|
||||
body { font-family: system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,'Noto Sans',sans-serif; background: var(--ant-color-bg-layout, #f9f9f9); }
|
||||
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: #d9d9d9; border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #bfbfbf; }
|
||||
::-webkit-scrollbar-thumb { background: var(--ant-color-fill-tertiary, #d9d9d9); border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--ant-color-fill-secondary, #bfbfbf); }
|
||||
|
||||
.fx-surface { background:#fff; border:1px solid #eaeaea; border-radius:12px; }
|
||||
.fx-card { background:linear-gradient(#fff,#fafafa); border:1px solid #eaeaea; border-radius:14px; box-shadow:0 1px 2px rgba(0,0,0,.04),0 4px 10px -2px rgba(0,0,0,.03); }
|
||||
.fx-fade-text { color:#555; }
|
||||
.fx-surface { background: var(--ant-color-bg-container, #fff); border:1px solid var(--ant-color-border, #eaeaea); border-radius:12px; }
|
||||
.fx-card { background: var(--ant-color-bg-container, #fff); border:1px solid var(--ant-color-border, #eaeaea); border-radius:14px; box-shadow:0 1px 2px rgba(0,0,0,.04),0 4px 10px -2px rgba(0,0,0,.03); }
|
||||
.fx-fade-text { color: var(--ant-color-text-secondary, #555); }
|
||||
|
||||
.fx-quiet-btn.ant-btn-text:not(:hover) { color:#666; }
|
||||
.fx-quiet-btn.ant-btn-text:not(:hover) { color: var(--ant-color-text-tertiary, #666); }
|
||||
|
||||
.ant-layout { background:#f9f9f9; }
|
||||
/* 使用 antd 默认布局背景 */
|
||||
.ant-layout { background: transparent; }
|
||||
|
||||
/* Menu compact spacing adjustments */
|
||||
.ant-menu-inline .ant-menu-item { margin-block:2px; }
|
||||
|
||||
/* Sidebar high-contrast selection */
|
||||
.sider-menu .ant-menu-item-selected {
|
||||
background:#111 !important;
|
||||
background: var(--ant-color-primary, #111) !important;
|
||||
color:#fff !important;
|
||||
}
|
||||
.sider-menu .ant-menu-item-selected .ant-menu-item-icon,
|
||||
.sider-menu .ant-menu-item-selected .anticon { color:#fff !important; }
|
||||
.sider-menu .ant-menu-item:not(.ant-menu-item-selected):hover { background:#f2f2f2; }
|
||||
.sider-menu .ant-menu-item:not(.ant-menu-item-selected):hover { background: var(--ant-color-fill-tertiary, #f2f2f2); }
|
||||
|
||||
.row-selected td { background: rgba(24,144,255,0.12) !important; }
|
||||
.row-selected:hover td { background: rgba(24,144,255,0.2) !important; }
|
||||
|
||||
.fx-grid { display:flex; flex-wrap:wrap; gap:20px; }
|
||||
.fx-grid-item { width:160px; cursor:pointer; border-radius:14px; padding:12px 12px 10px; background:#f5f5f5; position:relative; display:flex; flex-direction:column; align-items:stretch; gap:6px; transition:.18s box-shadow,.18s background; }
|
||||
.fx-grid-item.dir { background:#f3f3f3; }
|
||||
.fx-grid-item.selected { box-shadow:0 0 0 2px var(--ant-color-primary); background:#acc0c0; }
|
||||
.fx-grid-item:hover { background:#d2d1d1a7; box-shadow:0 1px 4px rgba(0,0,0,.06); }
|
||||
.fx-grid-item .thumb { height:120px; border-radius:10px; background:#fff; display:flex; align-items:center; justify-content:center; overflow:hidden; position:relative; box-shadow: inset 0 0 0 1px #eee; }
|
||||
.fx-grid-item { width:160px; cursor:pointer; border-radius:14px; padding:12px 12px 10px; background: var(--ant-color-fill-tertiary, #f5f5f5); position:relative; display:flex; flex-direction:column; align-items:stretch; gap:6px; transition:.18s box-shadow,.18s background; }
|
||||
.fx-grid-item.dir { background: var(--ant-color-fill-secondary, #f3f3f3); }
|
||||
.fx-grid-item.selected { box-shadow:0 0 0 2px var(--ant-color-primary); background: var(--ant-color-primary-bg, #e6f4ff); }
|
||||
.fx-grid-item:hover { background: var(--ant-color-fill, #ededed); box-shadow:0 1px 4px rgba(0,0,0,.06); }
|
||||
.fx-grid-item .thumb { height:120px; border-radius:10px; background: var(--ant-color-bg-container, #fff); display:flex; align-items:center; justify-content:center; overflow:hidden; position:relative; box-shadow: inset 0 0 0 1px var(--ant-color-border-secondary, #eee); }
|
||||
.fx-grid-item .thumb img { width:100%; height:100%; object-fit:cover; }
|
||||
.fx-grid-item .thumb .badge { position:absolute; top:6px; left:6px; background:#111; color:#fff; font-size:10px; padding:2px 4px; border-radius:6px; line-height:1; letter-spacing:.5px; }
|
||||
.fx-grid-item .thumb .badge { position:absolute; top:6px; left:6px; background: var(--ant-color-primary, #111); color:#fff; font-size:10px; padding:2px 4px; border-radius:6px; line-height:1; letter-spacing:.5px; }
|
||||
.fx-grid-item .name { font-weight:600; font-size:13px; }
|
||||
.ellipsis { overflow:hidden; white-space:nowrap; text-overflow:ellipsis; }
|
||||
|
||||
35
web/src/hooks/useAsyncSafeEffect.ts
Normal file
35
web/src/hooks/useAsyncSafeEffect.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
export interface AsyncEffectCtx {
|
||||
isDisposed: () => boolean;
|
||||
signal: AbortSignal;
|
||||
}
|
||||
|
||||
export function useAsyncSafeEffect(
|
||||
effect: (ctx: AsyncEffectCtx) => void | Promise<void>,
|
||||
deps: React.DependencyList,
|
||||
cleanup?: (ctx: AsyncEffectCtx) => void,
|
||||
) {
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
const ac = new AbortController();
|
||||
const ctx: AsyncEffectCtx = {
|
||||
isDisposed: () => disposed,
|
||||
signal: ac.signal,
|
||||
};
|
||||
|
||||
Promise.resolve(effect(ctx)).catch(() => {
|
||||
// 故意忽略 effect 内部抛出的异常,交由调用方处理
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
try {
|
||||
cleanup?.(ctx);
|
||||
} finally {
|
||||
ac.abort();
|
||||
}
|
||||
};
|
||||
}, deps);
|
||||
}
|
||||
|
||||
58
web/src/i18n/index.tsx
Normal file
58
web/src/i18n/index.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { createContext, useContext, useMemo, useState, useEffect } from 'react';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import { zh } from './locales/zh';
|
||||
import { en } from './locales/en';
|
||||
|
||||
type Lang = 'zh' | 'en';
|
||||
type Dict = Record<string, string>;
|
||||
|
||||
const dicts: Record<Lang, Dict> = {
|
||||
zh,
|
||||
en,
|
||||
};
|
||||
|
||||
export interface I18nContextValue {
|
||||
lang: Lang;
|
||||
setLang: (lang: Lang) => void;
|
||||
t: (key: string, params?: Record<string, string | number>) => string;
|
||||
}
|
||||
|
||||
const I18nContext = createContext<I18nContextValue | null>(null);
|
||||
|
||||
function interpolate(template: string, params?: Record<string, string | number>): string {
|
||||
if (!params) return template;
|
||||
return template.replace(/\{(\w+)\}/g, (_, k) => String(params[k] ?? `{${k}}`));
|
||||
}
|
||||
|
||||
export function I18nProvider({ children }: PropsWithChildren) {
|
||||
const [lang, setLangState] = useState<Lang>(() => (localStorage.getItem('lang') as Lang) || 'zh');
|
||||
|
||||
const setLang = (l: Lang) => {
|
||||
setLangState(l);
|
||||
localStorage.setItem('lang', l);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = lang;
|
||||
}, [lang]);
|
||||
|
||||
const t = (key: string, params?: Record<string, string | number>) => {
|
||||
const dict = dicts[lang] || {};
|
||||
const raw = dict[key] ?? key; // fallback to key (English)
|
||||
return interpolate(raw, params);
|
||||
};
|
||||
|
||||
const value = useMemo<I18nContextValue>(() => ({ lang, setLang, t }), [lang]);
|
||||
|
||||
return (
|
||||
<I18nContext.Provider value={value}>
|
||||
{children}
|
||||
</I18nContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useI18n() {
|
||||
const ctx = useContext(I18nContext);
|
||||
if (!ctx) throw new Error('useI18n must be used within I18nProvider');
|
||||
return ctx;
|
||||
}
|
||||
365
web/src/i18n/locales/en.ts
Normal file
365
web/src/i18n/locales/en.ts
Normal file
@@ -0,0 +1,365 @@
|
||||
export const en = {
|
||||
// General
|
||||
'All Files': 'All Files',
|
||||
'Manage': 'Manage',
|
||||
// 'System' defined above for navigation
|
||||
'Follow System': 'System',
|
||||
'Automation': 'Automation',
|
||||
'My Shares': 'My Shares',
|
||||
'Offline Downloads': 'Offline Downloads',
|
||||
'Adapters': 'Adapters',
|
||||
'Plugins': 'App Center',
|
||||
'System Settings': 'System Settings',
|
||||
'Backup & Restore': 'Backup & Restore',
|
||||
'System Logs': 'System Logs',
|
||||
|
||||
// Top header
|
||||
'Search files / tags / types': 'Search files / tags / types',
|
||||
'Log Out': 'Log Out',
|
||||
'Admin': 'Admin',
|
||||
'Language': 'Language',
|
||||
'Chinese': '中文',
|
||||
'English': 'English',
|
||||
|
||||
// Auth / Login
|
||||
'Welcome Back': 'Welcome Back',
|
||||
'Sign in to your Foxel account': 'Sign in to your Foxel account',
|
||||
'Username / Email': 'Username / Email',
|
||||
'Password': 'Password',
|
||||
'Sign In': 'Sign In',
|
||||
'Please enter username and password': 'Please enter username and password',
|
||||
'Login failed': 'Login failed',
|
||||
'Your next-generation file manager': 'Your next-generation file manager',
|
||||
'Cross-platform sync, access anywhere': 'Cross-platform sync, access anywhere',
|
||||
'AI-powered search for quick find': 'AI-powered search for quick find',
|
||||
'Flexible sharing and collaboration': 'Flexible sharing and collaboration',
|
||||
'Powerful automation to simplify tasks': 'Powerful automation to simplify tasks',
|
||||
'Join our community:': 'Join our community:',
|
||||
|
||||
// Share page
|
||||
'Refresh': 'Refresh',
|
||||
'Copy': 'Copy',
|
||||
// 'Cancel' already defined above
|
||||
'Copied link': 'Link copied',
|
||||
'Share canceled': 'Share canceled',
|
||||
'Cancel failed': 'Cancel failed',
|
||||
'Load failed': 'Load failed',
|
||||
'Are you sure to cancel share?': 'Are you sure to cancel share?',
|
||||
|
||||
'Share Name': 'Share Name',
|
||||
'Share Content': 'Share Content',
|
||||
'Created At': 'Created At',
|
||||
'Expires At': 'Expires At',
|
||||
'Forever': 'Forever',
|
||||
'Access': 'Access',
|
||||
'Public': 'Public',
|
||||
'By Password': 'By Password',
|
||||
|
||||
// Public share page
|
||||
'Password Required': 'Password Required',
|
||||
'Please enter password': 'Please enter password',
|
||||
'Confirm': 'Confirm',
|
||||
'Unable to load share info': 'Unable to load share info',
|
||||
'Share load failed': 'Failed to load share',
|
||||
'Wrong password': 'Wrong password',
|
||||
'Root': 'All Files',
|
||||
'Created on {date}': 'Created on {date}',
|
||||
'Expires on {date}': 'Expires on {date}',
|
||||
'Download File': 'Download File',
|
||||
'Preview not supported for this file type': 'Preview not supported for this file type',
|
||||
'Back': 'Back',
|
||||
'Download': 'Download',
|
||||
|
||||
// Offline download
|
||||
'No offline download tasks': 'No offline download tasks',
|
||||
// Header/File Explorer
|
||||
'Home': 'Home',
|
||||
'File Manager': 'File Manager',
|
||||
'New Folder': 'New Folder',
|
||||
'Upload': 'Upload',
|
||||
'Name': 'Name',
|
||||
'Size': 'Size',
|
||||
'Modified Time': 'Modified Time',
|
||||
'Grid': 'Grid',
|
||||
'List': 'List',
|
||||
'Mount Point': 'Mount Point',
|
||||
|
||||
// Context menu
|
||||
'Upload File': 'Upload File',
|
||||
'Open': 'Open',
|
||||
'Open With': 'Open With',
|
||||
'Default': 'Default',
|
||||
'Rename': 'Rename',
|
||||
'Delete': 'Delete',
|
||||
'Details': 'Details',
|
||||
'Get Direct Link': 'Get Direct Link',
|
||||
|
||||
// Side nav modals
|
||||
'Join Community': 'Join Community',
|
||||
'Scan to join WeChat group': 'Scan to join WeChat group',
|
||||
'If QR expires, add drizzle2001 to join': 'If QR expires, add drizzle2001 to join',
|
||||
'Version Info': 'Version Info',
|
||||
'Current Version': 'Current Version',
|
||||
'Latest Version': 'Latest Version',
|
||||
'New version found: {version}': 'New version found: {version}',
|
||||
'Please update to the latest for features and fixes': 'Please update to the latest for features and fixes',
|
||||
'Open Releases': 'Open Releases',
|
||||
'Changelog': 'Changelog',
|
||||
'Fetching latest version...': 'Fetching latest version...',
|
||||
'Update available': 'Update available',
|
||||
'You are on the latest: {version}': 'You are on the latest: {version}',
|
||||
'Up to date': 'Up to date',
|
||||
|
||||
// Share modal
|
||||
'Share {count} items': 'Share {count} items',
|
||||
'Share link created': 'Share link created',
|
||||
'Create failed': 'Create failed',
|
||||
'Copied to clipboard': 'Copied to clipboard',
|
||||
'Expiration (days)': 'Expiration (days)',
|
||||
'Set 0 or negative for forever': 'Set 0 or negative for forever',
|
||||
'Share link created successfully!': 'Share link created successfully!',
|
||||
'Share Link': 'Share Link',
|
||||
'Share created': 'Share created',
|
||||
'Create Share': 'Create Share',
|
||||
'Done': 'Done',
|
||||
'Create': 'Create',
|
||||
|
||||
// Direct link modal
|
||||
'Failed to generate link': 'Failed to generate link',
|
||||
'Markdown copied to clipboard': 'Markdown copied to clipboard',
|
||||
'Generate a direct link for {name}': 'Generate a direct link for {name}',
|
||||
'1 hour': '1 hour',
|
||||
'1 day': '1 day',
|
||||
'7 days': '7 days',
|
||||
'Generating link...': 'Generating link...',
|
||||
'Link will appear here': 'Link will appear here',
|
||||
'Copy Markdown': 'Copy Markdown',
|
||||
'Close': 'Close',
|
||||
|
||||
// File detail
|
||||
'Camera Make': 'Camera Make',
|
||||
'Camera Model': 'Camera Model',
|
||||
'Capture Time': 'Capture Time',
|
||||
'X Resolution': 'X Resolution',
|
||||
'Y Resolution': 'Y Resolution',
|
||||
'Exposure Time': 'Exposure Time',
|
||||
'Aperture': 'Aperture',
|
||||
'Focal Length': 'Focal Length',
|
||||
'Width': 'Width',
|
||||
'Height': 'Height',
|
||||
'No common EXIF info': 'No common EXIF info',
|
||||
'Bytes': 'Bytes',
|
||||
'File Properties': 'File Properties',
|
||||
'Loading file info...': 'Loading file info...',
|
||||
'Basic Info': 'Basic Info',
|
||||
'Type': 'Type',
|
||||
'Folder': 'Folder',
|
||||
'File': 'File',
|
||||
'Path': 'Path',
|
||||
'Path copied to clipboard': 'Path copied to clipboard',
|
||||
'Copy failed': 'Copy failed',
|
||||
'Permissions': 'Permissions',
|
||||
'EXIF Info': 'EXIF Info',
|
||||
|
||||
// Search dialog
|
||||
'Smart Search': 'Smart Search',
|
||||
'Name Search': 'Name Search',
|
||||
'Search Results': 'Search Results',
|
||||
'No files found': 'No files found',
|
||||
'Relevance': 'Relevance',
|
||||
|
||||
// System settings
|
||||
'Saved successfully': 'Saved successfully',
|
||||
'Save failed': 'Save failed',
|
||||
'Loading...': 'Loading...',
|
||||
'Appearance Settings': 'Appearance Settings',
|
||||
'Theme': 'Theme',
|
||||
'Theme Mode': 'Theme Mode',
|
||||
'Light': 'Light',
|
||||
'Dark': 'Dark',
|
||||
// 'Follow System' used for theme mode
|
||||
'Primary Color': 'Primary Color',
|
||||
'Border Radius': 'Border Radius',
|
||||
'Advanced': 'Advanced',
|
||||
'Override AntD Tokens (JSON)': 'Override AntD Tokens (JSON)',
|
||||
'e.g. {"colorText": "#222"}': 'e.g. {"colorText": "#222"}',
|
||||
'Custom CSS': 'Custom CSS',
|
||||
'Save': 'Save',
|
||||
'App Settings': 'App Settings',
|
||||
'AI Settings': 'AI Settings',
|
||||
'Vision Model': 'Vision Model',
|
||||
'Embedding Model': 'Embedding Model',
|
||||
'Vector Database': 'Vector Database',
|
||||
'Vector Database Settings': 'Vector Database Settings',
|
||||
'Database Type': 'Database Type',
|
||||
'Confirm clear vector database?': 'Confirm clear vector database?',
|
||||
'This will delete all collections irreversibly.': 'This will delete all collections irreversibly.',
|
||||
'Confirm Clear': 'Confirm Clear',
|
||||
// 'Cancel' defined above
|
||||
'Vector database cleared': 'Vector database cleared',
|
||||
'Clear failed': 'Clear failed',
|
||||
'Clear Vector DB': 'Clear Vector DB',
|
||||
'App Name': 'App Name',
|
||||
'Logo URL': 'Logo URL',
|
||||
'App Domain': 'App Domain',
|
||||
'File Domain': 'File Domain',
|
||||
'Vision API URL': 'Vision API URL',
|
||||
'Vision API Key': 'Vision API Key',
|
||||
'Embedding API URL': 'Embedding API URL',
|
||||
'Embedding API Key': 'Embedding API Key',
|
||||
|
||||
// Adapters
|
||||
'Missing required config:': 'Missing required config:',
|
||||
'Updated successfully': 'Updated successfully',
|
||||
'Created successfully': 'Created successfully',
|
||||
'Operation failed': 'Operation failed',
|
||||
'Deleted': 'Deleted',
|
||||
'Delete failed': 'Delete failed',
|
||||
'Status updated': 'Status updated',
|
||||
'Update failed': 'Update failed',
|
||||
'Mount Path': 'Mount Path',
|
||||
'Sub Path': 'Sub Path',
|
||||
'Sub Path (optional)': 'Sub Path (optional)',
|
||||
'Sub directory inside adapter': 'Sub directory inside adapter',
|
||||
'Enabled': 'Enabled',
|
||||
'Actions': 'Actions',
|
||||
'Edit': 'Edit',
|
||||
'Confirm delete?': 'Confirm delete?',
|
||||
'No config fields': 'No config fields',
|
||||
'Please input {label}': 'Please input {label}',
|
||||
'Storage Adapters': 'Storage Adapters',
|
||||
'Create Adapter': 'Create Adapter',
|
||||
'Unique name': 'Unique name',
|
||||
'Select adapter type': 'Select adapter type',
|
||||
'/ or /drive': '/ or /drive',
|
||||
'Adapter Config': 'Adapter Config',
|
||||
|
||||
// Tasks
|
||||
'Automation Tasks': 'Automation Tasks',
|
||||
'Running Tasks': 'Running Tasks',
|
||||
'Create Task': 'Create Task',
|
||||
'Edit Task': 'Edit Task',
|
||||
'Create Automation Task': 'Create Automation Task',
|
||||
'Task Name': 'Task Name',
|
||||
'Trigger Event': 'Trigger Event',
|
||||
'File Written': 'File Written',
|
||||
'File Deleted': 'File Deleted',
|
||||
'Matching Rules': 'Matching Rules',
|
||||
'Path Prefix (optional)': 'Path Prefix (optional)',
|
||||
'Filename Regex (optional)': 'Filename Regex (optional)',
|
||||
'Action': 'Action',
|
||||
'Current Task Queue': 'Current Task Queue',
|
||||
'Params': 'Params',
|
||||
'Status': 'Status',
|
||||
|
||||
// Logs
|
||||
'Confirm clear logs?': 'Confirm clear logs?',
|
||||
'This will delete logs in selected range irreversibly.': 'This will delete logs in selected range irreversibly.',
|
||||
'Cleared {count} logs': 'Cleared {count} logs',
|
||||
'Time': 'Time',
|
||||
'Level': 'Level',
|
||||
'Source': 'Source',
|
||||
'Message': 'Message',
|
||||
'Search source': 'Search source',
|
||||
'Clear': 'Clear',
|
||||
'Log Details': 'Log Details',
|
||||
|
||||
// Backup
|
||||
'Export started, check your downloads.': 'Export started, check your downloads.',
|
||||
'Export failed': 'Export failed',
|
||||
'Confirm import backup?': 'Confirm import backup?',
|
||||
'Are you sure to import from this file?': 'Are you sure to import from this file?',
|
||||
'Warning: This will overwrite all data including users (with passwords), settings, storages and tasks. Irreversible!': 'Warning: This will overwrite all data including users (with passwords), settings, storages and tasks. Irreversible!',
|
||||
'Confirm Import': 'Confirm Import',
|
||||
'Import succeeded! The page will refresh.': 'Import succeeded! The page will refresh.',
|
||||
'Import failed': 'Import failed',
|
||||
'Export': 'Export',
|
||||
'Import': 'Import',
|
||||
'Export all data (adapters, users, tasks, shares) into a JSON file.': 'Export all data (adapters, users, tasks, shares) into a JSON file.',
|
||||
'Keep your backup file safe.': 'Keep your backup file safe.',
|
||||
'Export Backup': 'Export Backup',
|
||||
'Restore data from a previously exported JSON file.': 'Restore data from a previously exported JSON file.',
|
||||
'Warning: This will clear and overwrite existing data.': 'Warning: This will clear and overwrite existing data.',
|
||||
'Choose File and Restore': 'Choose File and Restore',
|
||||
|
||||
// Empty state
|
||||
'No files yet here': 'No files yet here',
|
||||
'This folder is empty': 'This folder is empty',
|
||||
'Start uploading files or create folders to organize your content': 'Start uploading files or create folders to organize your content',
|
||||
'You can create folders or upload files here': 'You can create folders or upload files here',
|
||||
|
||||
// File actions
|
||||
'Please input name': 'Please input name',
|
||||
'Confirm delete {name}?': 'Confirm delete {name}?',
|
||||
'items': 'items',
|
||||
'Downloading folders is not supported': 'Downloading folders is not supported',
|
||||
'Download failed': 'Download failed',
|
||||
'Please select files or folders to share': 'Please select files or folders to share',
|
||||
'Direct links for folders are not supported': 'Direct links for folders are not supported',
|
||||
|
||||
// Processor flow
|
||||
'Processing finished': 'Processing finished',
|
||||
'Processing failed': 'Processing failed',
|
||||
|
||||
// Plugins page
|
||||
'Installed successfully': 'Installed successfully',
|
||||
'Plugin': 'Plugin',
|
||||
'Open Link': 'Open Link',
|
||||
'Link copied': 'Link copied',
|
||||
'Copy Link': 'Copy Link',
|
||||
'Confirm delete this plugin?': 'Confirm delete this plugin?',
|
||||
'Author': 'Author',
|
||||
'Website': 'Website',
|
||||
'Install App': 'Install App',
|
||||
'Search name/author/url/extension': 'Search name/author/url/extension',
|
||||
'No plugins': 'No plugins',
|
||||
'Install': 'Install',
|
||||
'App URL': 'App URL',
|
||||
'Please input a valid URL': 'Please input a valid URL',
|
||||
|
||||
// Setup page
|
||||
'Initialization succeeded! Logging you in...': 'Initialization succeeded! Logging you in...',
|
||||
'Initialization failed, please try later': 'Initialization failed, please try later',
|
||||
'Database Setup': 'Database Setup',
|
||||
'Choose database driver': 'Choose database driver',
|
||||
'Select database and vector database for system data': 'Select database and vector database for system data',
|
||||
'Database Driver': 'Database Driver',
|
||||
'Vector DB Driver': 'Vector DB Driver',
|
||||
'Initialize Mount': 'Initialize Mount',
|
||||
'Configure initial storage': 'Configure initial storage',
|
||||
'Create the first storage mount for your files': 'Create the first storage mount for your files',
|
||||
'Mount Name': 'Mount Name',
|
||||
'Local Storage': 'Local Storage',
|
||||
'Please input mount name!': 'Please input mount name!',
|
||||
'Storage Type': 'Storage Type',
|
||||
'Please input mount path!': 'Please input mount path!',
|
||||
'Root Directory': 'Root Directory',
|
||||
'Please input root directory!': 'Please input root directory!',
|
||||
'e.g., data/ or /var/foxel/data': 'e.g., data/ or /var/foxel/data',
|
||||
'Create Admin': 'Create Admin',
|
||||
'Create admin account': 'Create admin account',
|
||||
'This is the first account with full permissions': 'This is the first account with full permissions',
|
||||
'Username': 'Username',
|
||||
'Full Name': 'Full Name',
|
||||
'Email': 'Email',
|
||||
'Please input a valid email!': 'Please input a valid email!',
|
||||
'Confirm Password': 'Confirm Password',
|
||||
'Please confirm your password!': 'Please confirm your password!',
|
||||
'Passwords do not match!': 'Passwords do not match!',
|
||||
'System Initialization': 'System Initialization',
|
||||
'Previous': 'Previous',
|
||||
'Next': 'Next',
|
||||
'Finish Initialization': 'Finish Initialization',
|
||||
|
||||
// Plugin host
|
||||
'Plugin run failed': 'Plugin run failed',
|
||||
'Plugin Error': 'Plugin Error',
|
||||
'Cannot open file: no available app': 'Cannot open file: no available app',
|
||||
'Error': 'Error',
|
||||
'App "{key}" not found.': 'App "{key}" not found.',
|
||||
'Open with {app}': 'Open with {app}',
|
||||
'Set as default for .{ext}': 'Set as default for .{ext}',
|
||||
'Advanced tokens must be valid JSON': 'Advanced tokens must be valid JSON',
|
||||
} as const;
|
||||
|
||||
export type EnKeys = keyof typeof en;
|
||||
367
web/src/i18n/locales/zh.ts
Normal file
367
web/src/i18n/locales/zh.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
import { en } from './en';
|
||||
|
||||
// Start from English defaults, then override with Chinese translations we have.
|
||||
export const zh = {
|
||||
...en,
|
||||
|
||||
// General
|
||||
'All Files': '全部文件',
|
||||
'Manage': '管理',
|
||||
'System': '系统',
|
||||
'Automation': '自动化',
|
||||
'My Shares': '我的分享',
|
||||
'Offline Downloads': '离线下载',
|
||||
'Adapters': '存储挂载',
|
||||
'Plugins': '应用中心',
|
||||
'System Settings': '系统设置',
|
||||
'Backup & Restore': '备份恢复',
|
||||
'System Logs': '系统日志',
|
||||
|
||||
// Top header
|
||||
'Search files / tags / types': '搜索文件 / 标签 / 类型',
|
||||
'Log Out': '退出登录',
|
||||
'Admin': '管理员',
|
||||
'Language': '语言',
|
||||
'Chinese': '中文',
|
||||
'English': '英文',
|
||||
|
||||
// Auth / Login
|
||||
'Welcome Back': '欢迎回来',
|
||||
'Sign in to your Foxel account': '登录到您的 Foxel 账户',
|
||||
'Username / Email': '用户名/邮箱',
|
||||
'Password': '密码',
|
||||
'Sign In': '登录',
|
||||
'Please enter username and password': '请输入用户名与密码',
|
||||
'Login failed': '登录失败',
|
||||
'Your next-generation file manager': '您的下一代文件管理系统',
|
||||
'Cross-platform sync, access anywhere': '跨平台同步,随时随地访问',
|
||||
'AI-powered search for quick find': 'AI 驱动的智能搜索,快速定位文件',
|
||||
'Flexible sharing and collaboration': '灵活的分享与协作,提升团队效率',
|
||||
'Powerful automation to simplify tasks': '强大的自动化工作流,简化繁琐任务',
|
||||
'Join our community:': '加入我们的社区:',
|
||||
|
||||
// Share page
|
||||
'Refresh': '刷新',
|
||||
'Copy': '复制',
|
||||
'Cancel': '取消',
|
||||
'Copied link': '链接已复制',
|
||||
'Share canceled': '分享已取消',
|
||||
'Cancel failed': '取消失败',
|
||||
'Load failed': '加载失败',
|
||||
'Are you sure to cancel share?': '确认取消分享?',
|
||||
'Share Name': '分享名称',
|
||||
'Share Content': '分享内容',
|
||||
'Created At': '创建时间',
|
||||
'Expires At': '过期时间',
|
||||
'Forever': '永久有效',
|
||||
'Access': '访问',
|
||||
'Public': '公开',
|
||||
'By Password': '密码',
|
||||
|
||||
// Public share page
|
||||
'Password Required': '需要密码',
|
||||
'Please enter password': '请输入密码',
|
||||
'Confirm': '确认',
|
||||
'Unable to load share info': '无法加载分享信息',
|
||||
'Share load failed': '加载分享失败',
|
||||
'Wrong password': '密码错误',
|
||||
'Root': '全部文件',
|
||||
'Created on {date}': '创建于 {date}',
|
||||
'Expires on {date}': '将于 {date} 过期',
|
||||
'Download File': '下载文件',
|
||||
'Preview not supported for this file type': '暂不支持在线预览此类型文件',
|
||||
'Back': '返回',
|
||||
'Download': '下载',
|
||||
|
||||
// Header/File Explorer
|
||||
'Home': '主页',
|
||||
'File Manager': '文件管理',
|
||||
'New Folder': '新建目录',
|
||||
'Upload': '上传',
|
||||
'Name': '名称',
|
||||
'Size': '大小',
|
||||
'Modified Time': '修改时间',
|
||||
'Grid': '网格',
|
||||
'List': '列表',
|
||||
'Mount Point': '挂载点',
|
||||
|
||||
// Context menu
|
||||
'Upload File': '上传文件',
|
||||
'Open': '打开',
|
||||
'Open With': '打开方式',
|
||||
'Default': '默认',
|
||||
'Rename': '重命名',
|
||||
'Delete': '删除',
|
||||
'Details': '详情',
|
||||
'Get Direct Link': '获取直链',
|
||||
|
||||
// Side nav modals
|
||||
'Join Community': '加入社区',
|
||||
'Scan to join WeChat group': '微信扫码加入交流群',
|
||||
'If QR expires, add drizzle2001 to join': '如二维码失效,请添加 drizzle2001 拉群',
|
||||
'Version Info': '版本信息',
|
||||
'Current Version': '当前版本',
|
||||
'Latest Version': '最新版本',
|
||||
'New version found: {version}': '发现新版本: {version}',
|
||||
'Please update to the latest for features and fixes': '建议尽快更新到最新版本,以获得新功能和安全修复。',
|
||||
'Open Releases': '前往发布页面',
|
||||
'Changelog': '更新日志',
|
||||
'Fetching latest version...': '正在获取最新版本信息...',
|
||||
'Update available': '有更新',
|
||||
'You are on the latest: {version}': '当前为最新版: {version}',
|
||||
'Up to date': '已是最新版',
|
||||
|
||||
// Share modal
|
||||
'Share {count} items': '分享 {count} 个项目',
|
||||
'Share link created': '分享链接已创建',
|
||||
'Create failed': '创建失败',
|
||||
'Copied to clipboard': '已复制到剪贴板',
|
||||
'Expiration (days)': '有效期 (天)',
|
||||
'Set 0 or negative for forever': '设置为 0 或负数表示永久有效',
|
||||
'Share link created successfully!': '分享链接已成功创建!',
|
||||
'Share Link': '分享链接',
|
||||
'Share created': '分享创建成功',
|
||||
'Create Share': '创建分享',
|
||||
'Done': '完成',
|
||||
'Create': '创建',
|
||||
|
||||
// Direct link modal
|
||||
'Failed to generate link': '生成链接失败',
|
||||
'Markdown copied to clipboard': 'Markdown 格式已复制到剪贴板',
|
||||
'Generate a direct link for {name}': '为 {name} 生成一个直接访问链接。',
|
||||
'1 hour': '1 小时',
|
||||
'1 day': '1 天',
|
||||
'7 days': '7 天',
|
||||
'Generating link...': '正在生成链接...',
|
||||
'Link will appear here': '链接将显示在这里',
|
||||
'Copy Markdown': '复制 Markdown',
|
||||
'Close': '关闭',
|
||||
|
||||
// File detail
|
||||
'Camera Make': '设备品牌',
|
||||
'Camera Model': '设备型号',
|
||||
'Capture Time': '拍摄时间',
|
||||
'X Resolution': '水平分辨率',
|
||||
'Y Resolution': '垂直分辨率',
|
||||
'Exposure Time': '曝光时间',
|
||||
'Aperture': '光圈值',
|
||||
'Focal Length': '焦距',
|
||||
'Width': '宽度',
|
||||
'Height': '高度',
|
||||
'No common EXIF info': '无常见EXIF信息',
|
||||
'Bytes': '字节',
|
||||
'File Properties': '文件属性',
|
||||
'Loading file info...': '加载文件信息...',
|
||||
'Basic Info': '基本信息',
|
||||
'Type': '类型',
|
||||
'Folder': '文件夹',
|
||||
'File': '文件',
|
||||
'Path': '路径',
|
||||
'Path copied to clipboard': '路径已复制到剪贴板',
|
||||
'Copy failed': '复制失败',
|
||||
'Permissions': '权限',
|
||||
'EXIF Info': 'EXIF信息',
|
||||
|
||||
// Search dialog
|
||||
'Smart Search': '智能搜索',
|
||||
'Name Search': '名称搜索',
|
||||
'Search Results': '搜索结果',
|
||||
'No files found': '未找到相关文件',
|
||||
'Relevance': '相关度',
|
||||
|
||||
// System settings
|
||||
'Saved successfully': '保存成功',
|
||||
'Save failed': '保存失败',
|
||||
'Loading...': '加载中...',
|
||||
'Appearance Settings': '外观设置',
|
||||
'Theme': '主题',
|
||||
'Theme Mode': '主题模式',
|
||||
'Light': '亮色',
|
||||
'Dark': '暗色',
|
||||
// 'Follow System' used for theme mode
|
||||
'Follow System': '跟随系统',
|
||||
'Primary Color': '主色',
|
||||
'Border Radius': '圆角',
|
||||
'Advanced': '高级',
|
||||
'Override AntD Tokens (JSON)': '覆盖 AntD Token(JSON)',
|
||||
'e.g. {"colorText": "#222"}': '例如:{"colorText": "#222"}',
|
||||
'Custom CSS': '自定义 CSS',
|
||||
'Save': '保存',
|
||||
'App Settings': '应用设置',
|
||||
'AI Settings': 'AI设置',
|
||||
'Vision Model': '视觉模型',
|
||||
'Embedding Model': '嵌入模型',
|
||||
'Vector Database': '向量数据库',
|
||||
'Vector Database Settings': '向量数据库设置',
|
||||
'Database Type': '数据库类型',
|
||||
'Confirm clear vector database?': '确认清空向量数据库?',
|
||||
'This will delete all collections irreversibly.': '此操作将删除所有集合中的所有数据,且不可逆。',
|
||||
'Confirm Clear': '确认清空',
|
||||
// 'Cancel' defined above
|
||||
'Vector database cleared': '向量数据库已清空',
|
||||
'Clear failed': '清空失败',
|
||||
'Clear Vector DB': '清空向量库',
|
||||
'App Name': '应用名称',
|
||||
'Logo URL': 'LOGO地址',
|
||||
'App Domain': '应用域名',
|
||||
'File Domain': '文件域名',
|
||||
'Vision API URL': '视觉模型 API 地址',
|
||||
'Vision API Key': '视觉模型 API Key',
|
||||
'Embedding API URL': '嵌入模型 API 地址',
|
||||
'Embedding API Key': '嵌入模型 API Key',
|
||||
|
||||
// Adapters
|
||||
'Missing required config:': '缺少必填配置:',
|
||||
'Updated successfully': '更新成功',
|
||||
'Created successfully': '创建成功',
|
||||
'Operation failed': '操作失败',
|
||||
'Deleted': '已删除',
|
||||
'Delete failed': '删除失败',
|
||||
'Status updated': '状态已更新',
|
||||
'Update failed': '更新失败',
|
||||
'Mount Path': '挂载路径',
|
||||
'Sub Path': '子路径',
|
||||
'Sub Path (optional)': '子路径(可选)',
|
||||
'Sub directory inside adapter': '适配器内部子目录',
|
||||
'Enabled': '启用',
|
||||
'Actions': '操作',
|
||||
'Edit': '编辑',
|
||||
'Confirm delete?': '确认删除?',
|
||||
'No config fields': '无配置项',
|
||||
'Please input {label}': '请输入{label}',
|
||||
'Storage Adapters': '存储适配器',
|
||||
'Create Adapter': '新建适配器',
|
||||
'Unique name': '唯一名称',
|
||||
'Select adapter type': '选择适配器类型',
|
||||
'/ or /drive': '/或/drive',
|
||||
'Adapter Config': '适配器配置',
|
||||
|
||||
// Tasks
|
||||
'Automation Tasks': '自动化任务',
|
||||
'Running Tasks': '运行中的任务',
|
||||
'Create Task': '新建任务',
|
||||
'Edit Task': '编辑任务',
|
||||
'Create Automation Task': '新建自动化任务',
|
||||
'Task Name': '任务名称',
|
||||
'Trigger Event': '触发事件',
|
||||
'File Written': '文件写入',
|
||||
'File Deleted': '文件删除',
|
||||
'Matching Rules': '匹配规则',
|
||||
'Path Prefix (optional)': '路径前缀 (可选)',
|
||||
'Filename Regex (optional)': '文件名正则 (可选)',
|
||||
'Action': '执行动作',
|
||||
'Current Task Queue': '当前任务队列',
|
||||
'Params': '参数',
|
||||
'Status': '状态',
|
||||
|
||||
// Logs
|
||||
'Confirm clear logs?': '确认清理日志?',
|
||||
'This will delete logs in selected range irreversibly.': '该操作将删除选定时间范围内的所有日志,且不可恢复。',
|
||||
'Cleared {count} logs': '成功清理 {count} 条日志',
|
||||
'Time': '时间',
|
||||
'Level': '级别',
|
||||
'Source': '来源',
|
||||
'Message': '消息',
|
||||
'Search source': '搜索来源',
|
||||
'Clear': '清理',
|
||||
'Log Details': '日志详情',
|
||||
|
||||
// Backup
|
||||
'Export started, check your downloads.': '导出已开始,请检查您的下载。',
|
||||
'Export failed': '导出失败',
|
||||
'Confirm import backup?': '确认导入备份?',
|
||||
'Are you sure to import from this file?': '您确定要从此文件导入数据吗?',
|
||||
'Warning: This will overwrite all data including users (with passwords), settings, storages and tasks. Irreversible!': '警告:此操作将覆盖当前数据库中的所有现有数据,包括用户(含密码)、设置、存储和任务。此操作不可逆!',
|
||||
'Confirm Import': '确认导入',
|
||||
'Import succeeded! The page will refresh.': '导入成功!页面将刷新。',
|
||||
'Import failed': '导入失败',
|
||||
'Export': '导出',
|
||||
'Import': '恢复',
|
||||
'Export all data (adapters, users, tasks, shares) into a JSON file.': '点击按钮将所有数据(包括存储、用户、自动化任务和分享)导出为一个 JSON 文件。',
|
||||
'Keep your backup file safe.': '请妥善保管您的备份文件。',
|
||||
'Export Backup': '导出备份',
|
||||
'Restore data from a previously exported JSON file.': '从之前导出的JSON文件恢复数据。',
|
||||
'Warning: This will clear and overwrite existing data.': '警告:此操作将清除并覆盖现有数据。',
|
||||
'Choose File and Restore': '选择文件并恢复',
|
||||
|
||||
// Empty state
|
||||
'No files yet here': '这里还没有任何文件',
|
||||
'This folder is empty': '此目录为空',
|
||||
'Start uploading files or create folders to organize your content': '开始上传文件或创建新目录来组织您的内容',
|
||||
'You can create folders or upload files here': '您可以在此目录中创建新的文件夹或上传文件',
|
||||
|
||||
// File actions
|
||||
'Please input name': '请输入名称',
|
||||
'Confirm delete {name}?': '确认删除 {name} ?',
|
||||
'items': '项',
|
||||
'Downloading folders is not supported': '暂不支持下载目录',
|
||||
'Download failed': '下载失败',
|
||||
'Please select files or folders to share': '请选择要分享的文件或目录',
|
||||
'Direct links for folders are not supported': '不支持获取目录的直链',
|
||||
|
||||
// Processor flow
|
||||
'Processing finished': '处理完成',
|
||||
'Processing failed': '处理失败',
|
||||
|
||||
// Plugins page
|
||||
'Installed successfully': '安装成功',
|
||||
'Plugin': '插件',
|
||||
'Open Link': '打开链接',
|
||||
'Link copied': '已复制链接',
|
||||
'Copy Link': '复制链接',
|
||||
'Confirm delete this plugin?': '确认删除该插件?',
|
||||
'Author': '作者',
|
||||
'Website': '官网',
|
||||
'Install App': '安装应用',
|
||||
'Search name/author/url/extension': '搜索 名称/作者/链接/扩展名',
|
||||
'No plugins': '暂无插件',
|
||||
'Install': '安装',
|
||||
'App URL': '应用链接',
|
||||
'Please input a valid URL': '请输入合法的 URL',
|
||||
|
||||
// Setup page
|
||||
'Initialization succeeded! Logging you in...': '初始化成功!正在为您登录,请不要刷新。',
|
||||
'Initialization failed, please try later': '初始化失败,请稍后重试',
|
||||
'Database Setup': '数据库设置',
|
||||
'Choose database driver': '选择数据库驱动',
|
||||
'Select database and vector database for system data': '选择用于存储系统数据的数据库和向量数据库。',
|
||||
'Database Driver': '数据库驱动',
|
||||
'Vector DB Driver': '向量数据库驱动',
|
||||
'Initialize Mount': '初始化挂载',
|
||||
'Configure initial storage': '配置初始存储',
|
||||
'Create the first storage mount for your files': '为您的文件创建第一个存储挂载点。',
|
||||
'Mount Name': '挂载名称',
|
||||
'Local Storage': '本地存储',
|
||||
'Please input mount name!': '请输入挂载名称!',
|
||||
'Storage Type': '存储类型',
|
||||
'Please input mount path!': '请输入挂载路径!',
|
||||
'Root Directory': '根目录',
|
||||
'Please input root directory!': '请输入根目录!',
|
||||
'e.g., data/ or /var/foxel/data': '例如: data/ 或 /var/foxel/data',
|
||||
'Create Admin': '创建管理员',
|
||||
'Create admin account': '创建管理员账户',
|
||||
'This is the first account with full permissions': '这是系统的第一个账户,将拥有最高权限。',
|
||||
'Username': '用户名',
|
||||
'Full Name': '昵称',
|
||||
'Email': '邮箱',
|
||||
'Please input a valid email!': '请输入有效的邮箱地址!',
|
||||
'Confirm Password': '确认密码',
|
||||
'Please confirm your password!': '请确认您的密码!',
|
||||
'Passwords do not match!': '两次输入的密码不一致!',
|
||||
'System Initialization': '系统初始化',
|
||||
'Previous': '上一步',
|
||||
'Next': '下一步',
|
||||
'Finish Initialization': '完成初始化',
|
||||
|
||||
// Plugin host
|
||||
'Plugin run failed': '插件运行失败',
|
||||
'Plugin Error': '插件错误',
|
||||
'Cannot open file: no available app': '无法打开该文件:没有可用的应用',
|
||||
'Error': '错误',
|
||||
'App "{key}" not found.': '应用 "{key}" 不存在。',
|
||||
'Open with {app}': '使用 {app} 打开',
|
||||
'Set as default for .{ext}': '设为该类型(.{ext})默认应用',
|
||||
'Advanced tokens must be valid JSON': '高级 Token 需为合法 JSON',
|
||||
} as const;
|
||||
|
||||
export type ZhKeys = keyof typeof zh;
|
||||
@@ -2,6 +2,7 @@ import { Modal, Input, List, Divider, Spin, Select, Space } from 'antd';
|
||||
import { SearchOutlined, FileTextOutlined } from '@ant-design/icons';
|
||||
import React, { useState } from 'react';
|
||||
import { vfsApi, type SearchResultItem } from '../api/vfs';
|
||||
import { useI18n } from '../i18n';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
|
||||
@@ -10,9 +11,9 @@ interface SearchDialogProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const SEARCH_MODES = [
|
||||
{ label: '智能搜索', value: 'vector' },
|
||||
{ label: '名称搜索', value: 'filename' },
|
||||
const SEARCH_MODES = (t: (k: string)=>string) => [
|
||||
{ label: t('Smart Search'), value: 'vector' },
|
||||
{ label: t('Name Search'), value: 'filename' },
|
||||
];
|
||||
|
||||
const SearchDialog: React.FC<SearchDialogProps> = ({ open, onClose }) => {
|
||||
@@ -21,6 +22,7 @@ const SearchDialog: React.FC<SearchDialogProps> = ({ open, onClose }) => {
|
||||
const [results, setResults] = useState<SearchResultItem[]>([]);
|
||||
const [searched, setSearched] = useState(false);
|
||||
const [searchMode, setSearchMode] = useState<'vector' | 'filename'>('vector');
|
||||
const { t } = useI18n();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSearch = async () => {
|
||||
@@ -48,7 +50,7 @@ const SearchDialog: React.FC<SearchDialogProps> = ({ open, onClose }) => {
|
||||
>
|
||||
<Space.Compact style={{ marginBottom: 0, width: '100%' }}>
|
||||
<Select
|
||||
options={SEARCH_MODES}
|
||||
options={SEARCH_MODES(t)}
|
||||
value={searchMode}
|
||||
onChange={v => setSearchMode(v as 'vector' | 'filename')}
|
||||
style={{
|
||||
@@ -67,7 +69,7 @@ const SearchDialog: React.FC<SearchDialogProps> = ({ open, onClose }) => {
|
||||
<Input
|
||||
allowClear
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="搜索文件 / 标签 / 类型"
|
||||
placeholder={t('Search files / tags / types')}
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
style={{
|
||||
@@ -84,14 +86,14 @@ const SearchDialog: React.FC<SearchDialogProps> = ({ open, onClose }) => {
|
||||
</Space.Compact>
|
||||
{searched && (
|
||||
<>
|
||||
<Divider style={{ margin: '12px 0' }}>搜索结果</Divider>
|
||||
<Divider style={{ margin: '12px 0' }}>{t('Search Results')}</Divider>
|
||||
{loading ? (
|
||||
<Spin />
|
||||
) : (
|
||||
<List
|
||||
itemLayout="horizontal"
|
||||
dataSource={results}
|
||||
locale={{ emptyText: '未找到相关文件' }}
|
||||
locale={{ emptyText: t('No files found') }}
|
||||
renderItem={item => {
|
||||
const fullPath = item.path || '';
|
||||
const trimmed = fullPath.replace(/\/+$/, '');
|
||||
@@ -112,7 +114,7 @@ const SearchDialog: React.FC<SearchDialogProps> = ({ open, onClose }) => {
|
||||
{fullPath}
|
||||
</a>
|
||||
}
|
||||
description={`相关度: ${item.score.toFixed(2)}`}
|
||||
description={`${t('Relevance')}: ${item.score.toFixed(2)}`}
|
||||
/>
|
||||
</List.Item>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Layout, Menu, theme, Button, Modal, Tag, Tooltip } from 'antd';
|
||||
import { Layout, Menu, theme, Button, Modal, Tag, Tooltip, Descriptions, Alert, Divider, Spin } from 'antd';
|
||||
import { navGroups } from './nav.ts';
|
||||
import type { NavItem, NavGroup } from './nav.ts';
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
import { useSystemStatus } from '../contexts/SystemContext.tsx';
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
FileTextOutlined,
|
||||
GithubOutlined,
|
||||
MenuFoldOutlined,
|
||||
SendOutlined,
|
||||
@@ -14,6 +15,8 @@ import {
|
||||
import '../styles/sider-menu.css';
|
||||
import { getLatestVersion } from '../api/config.ts';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { useI18n } from '../i18n';
|
||||
const { Sider } = Layout;
|
||||
|
||||
export interface SideNavProps {
|
||||
@@ -26,6 +29,8 @@ export interface SideNavProps {
|
||||
const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle }: SideNavProps) {
|
||||
const status = useSystemStatus();
|
||||
const { token } = theme.useToken();
|
||||
const { resolvedMode } = useTheme();
|
||||
const { t } = useI18n();
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isVersionModalOpen, setIsVersionModalOpen] = useState(false);
|
||||
const [latestVersion, setLatestVersion] = useState<{
|
||||
@@ -84,10 +89,16 @@ const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle
|
||||
height: 24,
|
||||
objectFit: 'contain',
|
||||
marginRight: collapsed ? 0 : 8,
|
||||
...(status?.logo?.endsWith('.svg') && { filter: 'brightness(0) saturate(100%)' })
|
||||
...(resolvedMode === 'dark'
|
||||
? { filter: 'brightness(0) invert(1)' }
|
||||
: (status?.logo?.endsWith('.svg') ? { filter: 'brightness(0) saturate(100%)' } : {}))
|
||||
}}
|
||||
/>
|
||||
{!collapsed && <span style={{ fontWeight: 700 }}>{status?.title}</span>}
|
||||
{!collapsed && (
|
||||
<span style={{ fontWeight: 700, color: resolvedMode === 'dark' ? '#fff' : token.colorText }}>
|
||||
{status?.title}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* 展开时显示收缩按钮 */}
|
||||
{!collapsed && (
|
||||
@@ -113,7 +124,7 @@ const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle
|
||||
color: token.colorTextTertiary,
|
||||
textTransform: 'uppercase'
|
||||
}}
|
||||
>{group.title}</div>
|
||||
>{t(group.title)}</div>
|
||||
)}
|
||||
<Menu
|
||||
mode="inline"
|
||||
@@ -121,7 +132,7 @@ const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle
|
||||
inlineIndent={12}
|
||||
selectedKeys={[activeKey]}
|
||||
onClick={(e) => onChange(e.key)}
|
||||
items={group.children.map((i: NavItem) => ({ key: i.key, icon: i.icon, label: i.label }))}
|
||||
items={group.children.map((i: NavItem) => ({ key: i.key, icon: i.icon, label: t(i.label) }))}
|
||||
style={{ borderInline: 'none', background: 'transparent' }}
|
||||
className="sider-menu-group foxel-sider-menu"
|
||||
/>
|
||||
@@ -153,26 +164,26 @@ const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle
|
||||
cursor: 'pointer'
|
||||
}} onClick={showVersionModal}>
|
||||
{hasUpdate ? (
|
||||
<Tooltip title={`发现新版本: ${latestVersion?.version}`} placement={collapsed ? 'right' : 'top'}>
|
||||
<a href="https://github.com/DrizzleTime/Foxel/releases" target="_blank" rel="noopener noreferrer"
|
||||
style={{ textDecoration: 'none' }}>
|
||||
<Tooltip title={t('New version found: {version}', { version: latestVersion?.version || '' })} placement={collapsed ? 'right' : 'top'}>
|
||||
<a rel="noopener noreferrer"
|
||||
style={{ textDecoration: 'none' }}>
|
||||
{collapsed ? (
|
||||
<Tag icon={<WarningOutlined />} color="warning" style={{ marginInlineEnd: 0 }} />
|
||||
) : (
|
||||
<Tag icon={<WarningOutlined />} color="warning">
|
||||
{status?.version} - 有更新 [{latestVersion?.version}]
|
||||
{status?.version} - {t('Update available')} [{latestVersion?.version}]
|
||||
</Tag>
|
||||
)}
|
||||
</a>
|
||||
</Tooltip>
|
||||
) : (
|
||||
latestVersion ? (
|
||||
<Tooltip title={`当前为最新版: ${status?.version}`} placement={collapsed ? 'right' : 'top'}>
|
||||
<Tooltip title={t('You are on the latest: {version}', { version: status?.version || '' })} placement={collapsed ? 'right' : 'top'}>
|
||||
{collapsed ? (
|
||||
<Tag icon={<CheckCircleOutlined />} color="success" style={{ marginInlineEnd: 0 }} />
|
||||
) : (
|
||||
<Tag icon={<CheckCircleOutlined />} color="success">
|
||||
已是最新版
|
||||
{t('Up to date')}
|
||||
</Tag>
|
||||
)}
|
||||
</Tooltip>
|
||||
@@ -199,39 +210,104 @@ const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle
|
||||
href="https://t.me/+thDsBfyqJxZkNTU1"
|
||||
target="_blank"
|
||||
/>
|
||||
<Button
|
||||
shape="circle"
|
||||
icon={<FileTextOutlined />}
|
||||
href="https://foxel.cc"
|
||||
target="_blank"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</Sider>
|
||||
<Modal
|
||||
open={isModalOpen}
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
title="加入社区"
|
||||
title={t('Join Community')}
|
||||
footer={null}
|
||||
width={320}
|
||||
>
|
||||
<div style={{ textAlign: 'center', padding: '12px 0' }}>
|
||||
<img src="https://foxel.cc/image/wechat.png" width={200} alt="wechat" />
|
||||
<div style={{ marginTop: 12, color: token.colorTextSecondary }}>
|
||||
微信扫码加入交流群
|
||||
{t('Scan to join WeChat group')}
|
||||
</div>
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: token.colorTextTertiary }}>
|
||||
如二维码失效,请添加 drizzle2001 拉群
|
||||
{t('If QR expires, add drizzle2001 to join')}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<Modal
|
||||
open={isVersionModalOpen}
|
||||
onCancel={() => setIsVersionModalOpen(false)}
|
||||
title="版本信息"
|
||||
title={t('Version Info')}
|
||||
footer={null}
|
||||
width={600}
|
||||
>
|
||||
<div>
|
||||
<p>当前版本: {status?.version}</p>
|
||||
{latestVersion && (
|
||||
<div>
|
||||
<p>最新版本: {latestVersion.version}</p>
|
||||
<ReactMarkdown>{latestVersion.body}</ReactMarkdown>
|
||||
<div style={{ paddingTop: 12 }}>
|
||||
{latestVersion ? (
|
||||
<>
|
||||
<Descriptions bordered column={1} size="small">
|
||||
<Descriptions.Item label={t('Current Version')}>
|
||||
<Tag>{status?.version}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('Latest Version')}>
|
||||
<Tag color={hasUpdate ? 'orange' : 'green'}>{latestVersion.version}</Tag>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
{hasUpdate && (
|
||||
<Alert
|
||||
message={<span style={{ color: token.colorText }}>{t('New version found: {version}', { version: latestVersion.version })}</span>}
|
||||
description={<span style={{ color: token.colorTextSecondary }}>{t('Please update to the latest for features and fixes')}</span>}
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginTop: 24, marginBottom: 24, background: token.colorInfoBg, borderColor: token.colorInfoBorder }}
|
||||
action={
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
href="https://github.com/DrizzleTime/Foxel/releases"
|
||||
target="_blank"
|
||||
icon={<GithubOutlined />}
|
||||
>
|
||||
{t('Open Releases')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Divider orientation="left" plain>{t('Changelog')}</Divider>
|
||||
<div style={{
|
||||
maxHeight: '40vh',
|
||||
overflowY: 'auto',
|
||||
padding: '8px 16px',
|
||||
background: token.colorFillAlter,
|
||||
borderRadius: token.borderRadiusLG,
|
||||
border: `1px solid ${token.colorBorderSecondary}`
|
||||
}}>
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
h3: ({ ...props }) => <h3 style={{
|
||||
fontSize: 16,
|
||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||
paddingBottom: 8,
|
||||
marginTop: 24,
|
||||
marginBottom: 16,
|
||||
color: token.colorTextHeading
|
||||
}} {...props} />,
|
||||
ul: ({ ...props }) => <ul style={{ paddingLeft: 20 }} {...props} />,
|
||||
li: ({ ...props }) => <li style={{ marginBottom: 8 }} {...props} />,
|
||||
p: ({ ...props }) => <p style={{ marginBottom: 8 }} {...props} />,
|
||||
a: ({ ...props }) => <a {...props} target="_blank" rel="noopener noreferrer" />
|
||||
}}
|
||||
>{latestVersion.body}</ReactMarkdown>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: '40px 0', color: token.colorTextSecondary }}>
|
||||
<Spin size="large" />
|
||||
<p style={{ marginTop: 16 }}>{t('Fetching latest version...')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,8 @@ import { memo, useState } from 'react';
|
||||
import SearchDialog from './SearchDialog.tsx';
|
||||
import { authApi } from '../api/auth.ts';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useI18n } from '../i18n';
|
||||
import LanguageSwitcher from '../components/LanguageSwitcher';
|
||||
|
||||
const { Header } = Layout;
|
||||
|
||||
@@ -16,6 +18,7 @@ const TopHeader = memo(function TopHeader({ collapsed, onToggle }: TopHeaderProp
|
||||
const { token } = theme.useToken();
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const { t } = useI18n();
|
||||
|
||||
const handleLogout = () => {
|
||||
authApi.logout();
|
||||
@@ -37,18 +40,19 @@ const TopHeader = memo(function TopHeader({ collapsed, onToggle }: TopHeaderProp
|
||||
style={{ maxWidth: 420 }}
|
||||
onClick={() => setSearchOpen(true)}
|
||||
>
|
||||
搜索文件 / 标签 / 类型
|
||||
{t('Search files / tags / types')}
|
||||
</Button>
|
||||
<SearchDialog open={searchOpen} onClose={() => setSearchOpen(false)} />
|
||||
<Flex style={{ marginLeft: 'auto' }} align="center" gap={12}>
|
||||
<LanguageSwitcher />
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'logout', label: '退出登录', icon: <LogoutOutlined />, onClick: handleLogout }
|
||||
{ key: 'logout', label: t('Log Out'), icon: <LogoutOutlined />, onClick: handleLogout }
|
||||
]
|
||||
}}
|
||||
>
|
||||
<Button icon={<UserOutlined />}>管理员</Button>
|
||||
<Button icon={<UserOutlined />}>{t('Admin')}</Button>
|
||||
</Dropdown>
|
||||
</Flex>
|
||||
</Header>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
RobotOutlined,
|
||||
BugOutlined,
|
||||
DatabaseOutlined,
|
||||
AppstoreOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
@@ -19,26 +20,27 @@ export const navGroups: NavGroup[] = [
|
||||
key: 'library',
|
||||
title: '',
|
||||
children: [
|
||||
{ key: 'files', icon: React.createElement(FolderOpenOutlined), label: '全部文件' },
|
||||
{ key: 'files', icon: React.createElement(FolderOpenOutlined), label: 'All Files' },
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'manage',
|
||||
title: '管理',
|
||||
title: 'Manage',
|
||||
children: [
|
||||
{ key: 'tasks', icon: React.createElement(RobotOutlined), label: '自动化' },
|
||||
{ key: 'share', icon: React.createElement(ShareAltOutlined), label: '我的分享' },
|
||||
{ key: 'offline', icon: React.createElement(CloudDownloadOutlined), label: '离线下载' },
|
||||
{ key: 'adapters', icon: React.createElement(ApiOutlined), label: '存储挂载' },
|
||||
{ key: 'tasks', icon: React.createElement(RobotOutlined), label: 'Automation' },
|
||||
{ key: 'share', icon: React.createElement(ShareAltOutlined), label: 'My Shares' },
|
||||
{ key: 'offline', icon: React.createElement(CloudDownloadOutlined), label: 'Offline Downloads' },
|
||||
{ key: 'adapters', icon: React.createElement(ApiOutlined), label: 'Adapters' },
|
||||
{ key: 'plugins', icon: React.createElement(AppstoreOutlined), label: 'Plugins' },
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'system',
|
||||
title: '系统',
|
||||
title: 'System',
|
||||
children: [
|
||||
{ key: 'settings', icon: React.createElement(SettingOutlined), label: '系统设置' },
|
||||
{ key: 'backup', icon: React.createElement(DatabaseOutlined), label: '备份恢复' },
|
||||
{ key: 'logs', icon: React.createElement(BugOutlined), label: '系统日志' }
|
||||
{ key: 'settings', icon: React.createElement(SettingOutlined), label: 'System Settings' },
|
||||
{ key: 'backup', icon: React.createElement(DatabaseOutlined), label: 'Backup & Restore' },
|
||||
{ key: 'logs', icon: React.createElement(BugOutlined), label: 'System Logs' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
import '@ant-design/v5-patch-for-react-19';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import 'antd/dist/reset.css';
|
||||
import foxelTheme from './theme';
|
||||
import './global.css';
|
||||
import { BrowserRouter } from 'react-router';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<ConfigProvider locale={zhCN} theme={foxelTheme}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</ConfigProvider>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
import { memo, useState, useEffect, useCallback } from 'react';
|
||||
import { Table, Button, Space, Drawer, Form, Input, Switch, message, Typography, Popconfirm, Select } from 'antd';
|
||||
import PageCard from '../components/PageCard';
|
||||
import { adaptersApi } from '../api/client';
|
||||
import { adaptersApi, type AdapterItem } from '../api/client';
|
||||
import { useI18n } from '../i18n';
|
||||
|
||||
interface AdapterItem {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
config: any;
|
||||
enabled: boolean;
|
||||
mount_path?: string | null;
|
||||
sub_path?: string | null;
|
||||
}
|
||||
|
||||
interface AdapterTypeField {
|
||||
key: string;
|
||||
@@ -34,6 +26,7 @@ const AdaptersPage = memo(function AdaptersPage() {
|
||||
const [editing, setEditing] = useState<AdapterItem | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [availableTypes, setAvailableTypes] = useState<AdapterTypeMeta[]>([]);
|
||||
const { t } = useI18n();
|
||||
|
||||
const fetchList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -45,7 +38,7 @@ const AdaptersPage = memo(function AdaptersPage() {
|
||||
setData(list);
|
||||
setAvailableTypes(types);
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '加载失败');
|
||||
message.error(e.message || t('Load failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -65,7 +58,7 @@ const AdaptersPage = memo(function AdaptersPage() {
|
||||
form.setFieldsValue({
|
||||
name: '',
|
||||
type: defaultType,
|
||||
mount_path: '/',
|
||||
path: '/',
|
||||
sub_path: '',
|
||||
enabled: true,
|
||||
config: cfgDefaults
|
||||
@@ -79,7 +72,7 @@ const AdaptersPage = memo(function AdaptersPage() {
|
||||
form.setFieldsValue({
|
||||
name: rec.name,
|
||||
type: rec.type,
|
||||
mount_path: rec.mount_path || '/',
|
||||
path: rec.path || '/',
|
||||
sub_path: rec.sub_path || '',
|
||||
enabled: rec.enabled,
|
||||
config: rec.config || {}
|
||||
@@ -99,13 +92,13 @@ const AdaptersPage = memo(function AdaptersPage() {
|
||||
}
|
||||
});
|
||||
if (miss.length) {
|
||||
message.error('缺少必填配置: ' + miss.join(', '));
|
||||
message.error(t('Missing required config:') + ' ' + miss.join(', '));
|
||||
return;
|
||||
}
|
||||
const body = {
|
||||
name: values.name.trim(),
|
||||
type: values.type,
|
||||
mount_path: values.mount_path || '/',
|
||||
path: values.path || '/',
|
||||
sub_path: values.sub_path?.trim() || null,
|
||||
enabled: values.enabled,
|
||||
config: cfg
|
||||
@@ -113,17 +106,17 @@ const AdaptersPage = memo(function AdaptersPage() {
|
||||
setLoading(true);
|
||||
if (editing) {
|
||||
await adaptersApi.update(editing.id, body as any);
|
||||
message.success('更新成功');
|
||||
message.success(t('Updated successfully'));
|
||||
} else {
|
||||
await adaptersApi.create(body as any);
|
||||
message.success('创建成功');
|
||||
message.success(t('Created successfully'));
|
||||
}
|
||||
setOpen(false);
|
||||
setEditing(null);
|
||||
fetchList();
|
||||
} catch (e: any) {
|
||||
if (e?.errorFields) return; // 表单校验
|
||||
message.error(e.message || '操作失败');
|
||||
message.error(e.message || t('Operation failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -132,10 +125,10 @@ const AdaptersPage = memo(function AdaptersPage() {
|
||||
const doDelete = async (rec: AdapterItem) => {
|
||||
try {
|
||||
await adaptersApi.remove(rec.id);
|
||||
message.success('已删除');
|
||||
message.success(t('Deleted'));
|
||||
fetchList();
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '删除失败');
|
||||
message.error(e.message || t('Delete failed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -143,22 +136,22 @@ const AdaptersPage = memo(function AdaptersPage() {
|
||||
try {
|
||||
setLoading(true);
|
||||
await adaptersApi.update(rec.id, { ...rec, enabled: checked });
|
||||
message.success('状态已更新');
|
||||
message.success(t('Status updated'));
|
||||
fetchList();
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '更新失败');
|
||||
message.error(e.message || t('Update failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '名称', dataIndex: 'name' },
|
||||
{ title: '类型', dataIndex: 'type', width: 100 },
|
||||
{ title: '挂载路径', dataIndex: 'mount_path', width: 140, render: (v: string) => v || '-' },
|
||||
{ title: '子路径', dataIndex: 'sub_path', width: 140, render: (v: string) => v || '-' },
|
||||
{ title: t('Name'), dataIndex: 'name' },
|
||||
{ title: t('Type'), dataIndex: 'type', width: 100 },
|
||||
{ title: t('Mount Path'), dataIndex: 'path', width: 140, render: (v: string) => v || '-' },
|
||||
{ title: t('Sub Path'), dataIndex: 'sub_path', width: 140, render: (v: string) => v || '-' },
|
||||
{
|
||||
title: '启用',
|
||||
title: t('Enabled'),
|
||||
dataIndex: 'enabled',
|
||||
width: 80,
|
||||
render: (v: boolean, rec: AdapterItem) => (
|
||||
@@ -171,13 +164,13 @@ const AdaptersPage = memo(function AdaptersPage() {
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
title: t('Actions'),
|
||||
width: 160,
|
||||
render: (_: any, rec: AdapterItem) => (
|
||||
<Space size="small">
|
||||
<Button size="small" onClick={() => openEdit(rec)}>编辑</Button>
|
||||
<Popconfirm title="确认删除?" onConfirm={() => doDelete(rec)}>
|
||||
<Button size="small" danger>删除</Button>
|
||||
<Button size="small" onClick={() => openEdit(rec)}>{t('Edit')}</Button>
|
||||
<Popconfirm title={t('Confirm delete?')} onConfirm={() => doDelete(rec)}>
|
||||
<Button size="small" danger>{t('Delete')}</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)
|
||||
@@ -188,9 +181,9 @@ const AdaptersPage = memo(function AdaptersPage() {
|
||||
const currentTypeMeta = availableTypes.find(t => t.type === selectedType);
|
||||
|
||||
function renderConfigFields() {
|
||||
if (!currentTypeMeta) return <Typography.Text type="secondary">无配置项</Typography.Text>;
|
||||
if (!currentTypeMeta) return <Typography.Text type="secondary">{t('No config fields')}</Typography.Text>;
|
||||
return currentTypeMeta.config_schema.map(field => {
|
||||
const rules = field.required ? [{ required: true, message: `请输入${field.label}` }] : [];
|
||||
const rules = field.required ? [{ required: true, message: t('Please input {label}', { label: field.label }) }] : [];
|
||||
let inputNode: any = <Input placeholder={field.placeholder} />;
|
||||
if (field.type === 'password') inputNode = <Input.Password placeholder={field.placeholder} />;
|
||||
if (field.type === 'number') inputNode = <Input type="number" placeholder={field.placeholder} />;
|
||||
@@ -198,7 +191,7 @@ const AdaptersPage = memo(function AdaptersPage() {
|
||||
<Form.Item
|
||||
key={field.key}
|
||||
name={['config', field.key]}
|
||||
label={field.label}
|
||||
label={t(field.label)}
|
||||
rules={rules}
|
||||
>
|
||||
{inputNode}
|
||||
@@ -209,11 +202,11 @@ const AdaptersPage = memo(function AdaptersPage() {
|
||||
|
||||
return (
|
||||
<PageCard
|
||||
title="存储适配器"
|
||||
title={t('Storage Adapters')}
|
||||
extra={
|
||||
<Space>
|
||||
<Button onClick={fetchList} loading={loading}>刷新</Button>
|
||||
<Button type="primary" onClick={openCreate}>新建适配器</Button>
|
||||
<Button onClick={fetchList} loading={loading}>{t('Refresh')}</Button>
|
||||
<Button type="primary" onClick={openCreate}>{t('Create Adapter')}</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
@@ -226,15 +219,15 @@ const AdaptersPage = memo(function AdaptersPage() {
|
||||
style={{ marginBottom: 0 }}
|
||||
/>
|
||||
<Drawer
|
||||
title={editing ? `编辑: ${editing.name}` : '新建适配器'}
|
||||
title={editing ? `${t('Edit')}: ${editing.name}` : t('Create Adapter')}
|
||||
width={480}
|
||||
open={open}
|
||||
onClose={() => { setOpen(false); setEditing(null); }}
|
||||
destroyOnClose
|
||||
extra={
|
||||
<Space>
|
||||
<Button onClick={() => { setOpen(false); setEditing(null); }}>取消</Button>
|
||||
<Button type="primary" onClick={submit} loading={loading}>提交</Button>
|
||||
<Button onClick={() => { setOpen(false); setEditing(null); }}>{t('Cancel')}</Button>
|
||||
<Button type="primary" onClick={submit} loading={loading}>{t('Submit')}</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
@@ -243,15 +236,14 @@ const AdaptersPage = memo(function AdaptersPage() {
|
||||
layout="vertical"
|
||||
initialValues={{ enabled: true }}
|
||||
>
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入名称' }]}>
|
||||
<Input placeholder="唯一名称" />
|
||||
<Form.Item name="name" label={t('Name')} rules={[{ required: true, message: t('Please input {label}', { label: t('Name') }) }]}>
|
||||
<Input placeholder={t('Unique name')} />
|
||||
</Form.Item>
|
||||
<Form.Item name="type" label="类型" rules={[{ required: true }]}>
|
||||
<Form.Item name="type" label={t('Type')} rules={[{ required: true }]}>
|
||||
<Select
|
||||
placeholder="选择适配器类型"
|
||||
placeholder={t('Select adapter type')}
|
||||
options={availableTypes.map(t => ({ value: t.type, label: `${t.name} (${t.type})` }))}
|
||||
onChange={() => {
|
||||
// 切换类型时刷新默认 config
|
||||
const t = availableTypes.find(v => v.type === form.getFieldValue('type'));
|
||||
const cfgDefaults: Record<string, any> = {};
|
||||
t?.config_schema.forEach(f => {
|
||||
@@ -261,16 +253,16 @@ const AdaptersPage = memo(function AdaptersPage() {
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="mount_path" label="挂载路径" rules={[{ required: true, message: '请输入挂载路径' }]}>
|
||||
<Input placeholder="/或/drive" />
|
||||
<Form.Item name="path" label={t('Mount Path')} rules={[{ required: true, message: t('Please input {label}', { label: t('Mount Path') }) }]}>
|
||||
<Input placeholder={t('/ or /drive')} />
|
||||
</Form.Item>
|
||||
<Form.Item name="sub_path" label="子路径(可选)">
|
||||
<Input placeholder="适配器内部子目录" />
|
||||
<Form.Item name="sub_path" label={t('Sub Path (optional)')}>
|
||||
<Input placeholder={t('Sub directory inside adapter')} />
|
||||
</Form.Item>
|
||||
<Form.Item name="enabled" label="启用" valuePropName="checked">
|
||||
<Form.Item name="enabled" label={t('Enabled')} valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Typography.Title level={5} style={{ marginTop: 8, fontSize: 14 }}>适配器配置</Typography.Title>
|
||||
<Typography.Title level={5} style={{ marginTop: 8, fontSize: 14 }}>{t('Adapter Config')}</Typography.Title>
|
||||
{renderConfigFields()}
|
||||
</Form>
|
||||
</Drawer>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
import { memo, useEffect, useRef, useState } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import { theme, Pagination } from 'antd';
|
||||
import { AppWindowsLayer } from '../../apps/AppWindowsLayer';
|
||||
@@ -15,6 +15,7 @@ import { GridView } from './components/GridView';
|
||||
import { FileListView } from './components/FileListView';
|
||||
import { EmptyState } from './components/EmptyState';
|
||||
import { ContextMenu } from './components/ContextMenu';
|
||||
import { DropzoneOverlay } from './components/DropzoneOverlay';
|
||||
import { CreateDirModal } from './components/Modals/CreateDirModal';
|
||||
import { RenameModal } from './components/Modals/RenameModal';
|
||||
import { ProcessorModal } from './components/Modals/ProcessorModal';
|
||||
@@ -29,14 +30,17 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
const { navKey = 'files', '*': restPath = '' } = useParams();
|
||||
const { token } = theme.useToken();
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('grid');
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const dragCounter = useRef(0);
|
||||
|
||||
// --- Hooks ---
|
||||
const { path, entries, loading, pagination, processorTypes, load, navigateTo, goUp, handlePaginationChange, refresh } = useFileExplorer(navKey);
|
||||
const { path, entries, loading, pagination, processorTypes, sortBy, sortOrder, load, navigateTo, goUp, handlePaginationChange, refresh, handleSortChange } = useFileExplorer(navKey);
|
||||
const { selectedEntries, handleSelect, handleSelectRange, clearSelection, setSelectedEntries } = useFileSelection();
|
||||
const { doCreateDir, doDelete, doRename, doDownload, doShare, doGetDirectLink } = useFileActions({ path, refresh, clearSelection, onShare: (entries) => setSharingEntries(entries), onGetDirectLink: (entry) => setDirectLinkEntry(entry) });
|
||||
const { appWindows, openFileWithDefaultApp, confirmOpenWithApp, closeWindow, toggleMax, bringToFront, updateWindow } = useAppWindows(path);
|
||||
const { ctxMenu, blankCtxMenu, openContextMenu, openBlankContextMenu, closeContextMenus } = useContextMenu();
|
||||
const uploader = useUploader(path, refresh);
|
||||
const { handleFileDrop } = uploader;
|
||||
const processorHook = useProcessor({ path, processorTypes, refresh });
|
||||
const { thumbs } = useThumbnails(entries, path);
|
||||
|
||||
@@ -52,8 +56,8 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
// --- Effects ---
|
||||
useEffect(() => {
|
||||
const routeP = '/' + (restPath || '').replace(/^\/+/, '');
|
||||
load(routeP, 1, pagination.pageSize);
|
||||
}, [restPath, navKey, load, pagination.pageSize]);
|
||||
load(routeP, 1, pagination.pageSize, sortBy, sortOrder);
|
||||
}, [restPath, navKey, load, pagination.pageSize, sortBy, sortOrder]);
|
||||
|
||||
// --- Handlers ---
|
||||
const handleOpenEntry = (entry: VfsEntry) => {
|
||||
@@ -79,6 +83,37 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnter = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragCounter.current++;
|
||||
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
|
||||
setIsDragging(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragCounter.current--;
|
||||
if (dragCounter.current === 0) {
|
||||
setIsDragging(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
dragCounter.current = 0;
|
||||
handleFileDrop(e.dataTransfer.files);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -91,25 +126,32 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
position: 'relative'
|
||||
}}
|
||||
onClick={closeContextMenus}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<Header
|
||||
navKey={navKey}
|
||||
path={path}
|
||||
loading={loading}
|
||||
viewMode={viewMode}
|
||||
sortBy={sortBy}
|
||||
sortOrder={sortOrder}
|
||||
onGoUp={goUp}
|
||||
onNavigate={navigateTo}
|
||||
onRefresh={refresh}
|
||||
onCreateDir={() => setCreatingDir(true)}
|
||||
onUpload={uploader.openModal}
|
||||
onSetViewMode={setViewMode}
|
||||
onSortChange={handleSortChange}
|
||||
/>
|
||||
|
||||
<input ref={uploader.fileInputRef} type="file" style={{ display: 'none' }} multiple onChange={uploader.handleFileChange} />
|
||||
|
||||
<div style={{ flex: 1, overflow: 'auto', paddingBottom: pagination.total > 0 ? '80px' : '0' }} onContextMenu={openBlankContextMenu}>
|
||||
{loading && entries.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: 40 }}><EmptyState isRoot={path === '/'} onCreateDir={() => setCreatingDir(true)} onGoUp={goUp} /></div>
|
||||
<div style={{ textAlign: 'center', padding: 40 }}><EmptyState isRoot={path === '/'} /></div>
|
||||
) : viewMode === 'grid' ? (
|
||||
<GridView
|
||||
entries={entries}
|
||||
@@ -121,8 +163,6 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
onSelectRange={handleSelectRange}
|
||||
onOpen={handleOpenEntry}
|
||||
onContextMenu={openContextMenu}
|
||||
onCreateDir={() => setCreatingDir(true)}
|
||||
onGoUp={goUp}
|
||||
/>
|
||||
) : (
|
||||
<FileListView
|
||||
@@ -214,6 +254,7 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
onStartUpload={uploader.startUpload}
|
||||
/>
|
||||
<AppWindowsLayer windows={appWindows} onClose={closeWindow} onToggleMax={toggleMax} onBringToFront={bringToFront} onUpdateWindow={updateWindow} />
|
||||
<DropzoneOverlay visible={isDragging} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { Menu, theme } from 'antd';
|
||||
import type { VfsEntry } from '../../../api/client';
|
||||
import { getAppsForEntry, getDefaultAppForEntry } from '../../../apps/registry';
|
||||
import { useI18n } from '../../../i18n';
|
||||
import {
|
||||
FolderFilled, AppstoreOutlined, AppstoreAddOutlined, DownloadOutlined,
|
||||
EditOutlined, DeleteOutlined, InfoCircleOutlined, UploadOutlined, PlusOutlined, ShareAltOutlined, LinkOutlined
|
||||
@@ -30,13 +31,14 @@ interface ContextMenuProps {
|
||||
|
||||
export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
|
||||
const { token } = theme.useToken();
|
||||
const { t } = useI18n();
|
||||
const { x, y, entry, entries, selectedEntries, processorTypes, onClose, ...actions } = props;
|
||||
|
||||
const getContextMenuItems = () => {
|
||||
if (!entry) { // Blank context menu
|
||||
return [
|
||||
{ key: 'upload', label: '上传文件', icon: <UploadOutlined />, onClick: actions.onUpload },
|
||||
{ key: 'mkdir', label: '新建目录', icon: <PlusOutlined />, onClick: actions.onCreateDir },
|
||||
{ key: 'upload', label: t('Upload File'), icon: <UploadOutlined />, onClick: actions.onUpload },
|
||||
{ key: 'mkdir', label: t('New Folder'), icon: <PlusOutlined />, onClick: actions.onCreateDir },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -61,56 +63,56 @@ export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
|
||||
return [
|
||||
(entry.is_dir || apps.length > 0) ? {
|
||||
key: 'open',
|
||||
label: defaultApp ? `打开 (${defaultApp.name})` : '打开',
|
||||
label: defaultApp ? `${t('Open')} (${defaultApp.name})` : t('Open'),
|
||||
icon: <FolderFilled />,
|
||||
onClick: () => actions.onOpen(entry),
|
||||
} : null,
|
||||
!entry.is_dir && apps.length > 0 ? {
|
||||
key: 'openWith',
|
||||
label: '打开方式',
|
||||
label: t('Open With'),
|
||||
icon: <AppstoreOutlined />,
|
||||
children: apps.map(a => ({
|
||||
key: 'openWith-' + a.key,
|
||||
label: a.name + (a.key === defaultApp?.key ? ' (默认)' : ''),
|
||||
label: a.name + (a.key === defaultApp?.key ? ` (${t('Default')})` : ''),
|
||||
onClick: () => actions.onOpenWith(entry, a.key),
|
||||
})),
|
||||
} : null,
|
||||
!entry.is_dir && processorSubMenu.length > 0 ? {
|
||||
key: 'process',
|
||||
label: '处理器',
|
||||
label: t('Processor'),
|
||||
icon: <AppstoreAddOutlined />,
|
||||
children: processorSubMenu,
|
||||
} : null,
|
||||
{
|
||||
key: 'share',
|
||||
label: '分享',
|
||||
label: t('Share'),
|
||||
icon: <ShareAltOutlined />,
|
||||
onClick: () => actions.onShare(targetEntries),
|
||||
},
|
||||
{
|
||||
key: 'directLink',
|
||||
label: '获取直链',
|
||||
label: t('Get Direct Link'),
|
||||
icon: <LinkOutlined />,
|
||||
disabled: targetEntries.length !== 1 || targetEntries[0].is_dir,
|
||||
onClick: () => actions.onGetDirectLink(targetEntries[0]),
|
||||
},
|
||||
{
|
||||
key: 'download',
|
||||
label: '下载',
|
||||
label: t('Download'),
|
||||
icon: <DownloadOutlined />,
|
||||
disabled: targetEntries.some(t => t.is_dir) || targetEntries.length > 1,
|
||||
onClick: () => actions.onDownload(targetEntries[0]),
|
||||
},
|
||||
{
|
||||
key: 'rename',
|
||||
label: '重命名',
|
||||
label: t('Rename'),
|
||||
icon: <EditOutlined />,
|
||||
disabled: targetEntries.length !== 1 || targetEntries[0].type === 'mount',
|
||||
onClick: () => actions.onRename(targetEntries[0]),
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: '删除',
|
||||
label: t('Delete'),
|
||||
icon: <DeleteOutlined />,
|
||||
danger: true,
|
||||
disabled: targetEntries.some(t => t.type === 'mount'),
|
||||
@@ -118,7 +120,7 @@ export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
|
||||
},
|
||||
{
|
||||
key: 'detail',
|
||||
label: '详情',
|
||||
label: t('Details'),
|
||||
icon: <InfoCircleOutlined />,
|
||||
onClick: () => actions.onDetail(entry),
|
||||
},
|
||||
@@ -148,4 +150,4 @@ export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,33 +1,24 @@
|
||||
import React from 'react';
|
||||
import { Button, Space, Typography, theme } from 'antd';
|
||||
import { PlusOutlined, CloudUploadOutlined, ArrowUpOutlined, FolderOpenOutlined } from '@ant-design/icons';
|
||||
import { Typography, theme } from 'antd';
|
||||
import { FolderOpenOutlined } from '@ant-design/icons';
|
||||
import { useI18n } from '../../../i18n';
|
||||
|
||||
interface Props {
|
||||
isRoot: boolean;
|
||||
onCreateDir: () => void;
|
||||
onGoUp: () => void;
|
||||
}
|
||||
|
||||
export const EmptyState: React.FC<Props> = ({ isRoot, onCreateDir, onGoUp }) => {
|
||||
export const EmptyState: React.FC<Props> = ({ isRoot }) => {
|
||||
const { token } = theme.useToken();
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div style={{ display:'flex', flexDirection:'column', alignItems:'center', justifyContent:'center', padding:isRoot? '80px 40px':'60px 40px', minHeight: isRoot? '400px':'300px', color: token.colorTextSecondary }}>
|
||||
<FolderOpenOutlined style={{ fontSize:64, color: token.colorTextQuaternary, marginBottom:16 }} />
|
||||
<Typography.Title level={4} style={{ color: token.colorTextSecondary, marginBottom:8, fontWeight:400 }}>
|
||||
{isRoot ? '这里还没有任何文件' : '此目录为空'}
|
||||
{isRoot ? t('No files yet here') : t('This folder is empty')}
|
||||
</Typography.Title>
|
||||
<Typography.Text style={{ color: token.colorTextTertiary, marginBottom:24, textAlign:'center', maxWidth:300, lineHeight:1.5 }}>
|
||||
{isRoot ? '开始上传文件或创建新目录来组织您的内容' : '您可以在此目录中创建新的文件夹或上传文件'}
|
||||
{isRoot ? t('Start uploading files or create folders to organize your content') : t('You can create folders or upload files here')}
|
||||
</Typography.Text>
|
||||
<Space size={12}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={onCreateDir}>新建目录</Button>
|
||||
<Button icon={<CloudUploadOutlined />} disabled>上传文件</Button>
|
||||
</Space>
|
||||
{!isRoot && (
|
||||
<div style={{ marginTop:16 }}>
|
||||
<Button type="link" size="small" icon={<ArrowUpOutlined />} onClick={onGoUp} style={{ color: token.colorTextTertiary }}>返回上级目录</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Modal, Typography, Spin, theme, Card, Descriptions, Divider, Badge, Space, message } from 'antd';
|
||||
import { FileOutlined, FolderOutlined, CameraOutlined, InfoCircleOutlined } from '@ant-design/icons';
|
||||
import { useI18n } from '../../../i18n';
|
||||
import type { VfsEntry } from '../../../api/client';
|
||||
|
||||
interface Props {
|
||||
@@ -10,21 +11,24 @@ interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const exifFieldMap: Record<string, { label: string; format?: (v: any) => string }> = {
|
||||
'271': { label: '设备品牌' },
|
||||
'272': { label: '设备型号' },
|
||||
'306': { label: '拍摄时间' },
|
||||
'282': { label: '水平分辨率', format: v => `${v} dpi` },
|
||||
'283': { label: '垂直分辨率', format: v => `${v} dpi` },
|
||||
'33434': { label: '曝光时间', format: v => `${v} 秒` },
|
||||
'33437': { label: '光圈值', format: v => `f/${v}` },
|
||||
'34855': { label: 'ISO' },
|
||||
'37377': { label: '焦距', format: v => `${v} mm` },
|
||||
'40962': { label: '宽度', format: v => `${v} px` },
|
||||
'40963': { label: '高度', format: v => `${v} px` },
|
||||
};
|
||||
function getExifFieldMap(t: (k: string)=>string): Record<string, { label: string; format?: (v: any) => string }> {
|
||||
return {
|
||||
'271': { label: t('Camera Make') },
|
||||
'272': { label: t('Camera Model') },
|
||||
'306': { label: t('Capture Time') },
|
||||
'282': { label: t('X Resolution'), format: v => `${v} dpi` },
|
||||
'283': { label: t('Y Resolution'), format: v => `${v} dpi` },
|
||||
'33434': { label: t('Exposure Time'), format: v => `${v} s` },
|
||||
'33437': { label: t('Aperture'), format: v => `f/${v}` },
|
||||
'34855': { label: 'ISO' },
|
||||
'37377': { label: t('Focal Length'), format: v => `${v} mm` },
|
||||
'40962': { label: t('Width'), format: v => `${v} px` },
|
||||
'40963': { label: t('Height'), format: v => `${v} px` },
|
||||
};
|
||||
}
|
||||
|
||||
function renderExif(exif: Record<string, any>) {
|
||||
function renderExif(exif: Record<string, any>, t: (k: string)=>string) {
|
||||
const exifFieldMap = getExifFieldMap(t);
|
||||
const items = Object.entries(exifFieldMap)
|
||||
.filter(([key]) => exif[key] !== undefined)
|
||||
.map(([key, { label, format }]) => ({
|
||||
@@ -35,9 +39,9 @@ function renderExif(exif: Record<string, any>) {
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: 24, color: '#999' }}>
|
||||
<div style={{ textAlign: 'center', padding: 24, color: 'var(--ant-color-text-tertiary, #999)' }}>
|
||||
<InfoCircleOutlined style={{ fontSize: 20, marginBottom: 8 }} />
|
||||
<div>无常见EXIF信息</div>
|
||||
<div>{t('No common EXIF info')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -49,19 +53,19 @@ function renderExif(exif: Record<string, any>) {
|
||||
bordered
|
||||
items={items.map(item => ({
|
||||
key: item.key,
|
||||
label: <span style={{ fontWeight: 500, color: '#595959' }}>{item.label}</span>,
|
||||
children: <span style={{ color: '#262626' }}>{item.value}</span>
|
||||
label: <span style={{ fontWeight: 500, color: 'var(--ant-color-text-secondary, #595959)' }}>{item.label}</span>,
|
||||
children: <span style={{ color: 'var(--ant-color-text, #262626)' }}>{item.value}</span>
|
||||
}))}
|
||||
contentStyle={{ padding: '8px 12px' }}
|
||||
labelStyle={{ padding: '8px 12px', backgroundColor: '#fafafa', width: '30%' }}
|
||||
labelStyle={{ padding: '8px 12px', backgroundColor: 'var(--ant-color-fill-tertiary, #fafafa)', width: '30%' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function formatFileSize(size: number | string): string {
|
||||
function formatFileSize(size: number | string, t: (k: string)=>string): string {
|
||||
if (typeof size !== 'number') return String(size);
|
||||
|
||||
const units = ['字节', 'KB', 'MB', 'GB'];
|
||||
const units = [t('Bytes'), 'KB', 'MB', 'GB'];
|
||||
let index = 0;
|
||||
let fileSize = size;
|
||||
|
||||
@@ -75,13 +79,14 @@ function formatFileSize(size: number | string): string {
|
||||
|
||||
export const FileDetailModal: React.FC<Props> = ({ entry, loading, data, onClose }) => {
|
||||
const { token } = theme.useToken();
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<Space>
|
||||
<InfoCircleOutlined style={{ color: token.colorPrimary }} />
|
||||
<span>文件属性</span>
|
||||
<span>{t('File Properties')}</span>
|
||||
{entry && (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 14 }}>
|
||||
- {entry.name}
|
||||
@@ -100,7 +105,7 @@ export const FileDetailModal: React.FC<Props> = ({ entry, loading, data, onClose
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: 48 }}>
|
||||
<Spin size="large" />
|
||||
<div style={{ marginTop: 16, color: token.colorTextSecondary }}>加载文件信息...</div>
|
||||
<div style={{ marginTop: 16, color: token.colorTextSecondary }}>{t('Loading file info...')}</div>
|
||||
</div>
|
||||
) : data ? (
|
||||
data.error ? (
|
||||
@@ -118,7 +123,7 @@ export const FileDetailModal: React.FC<Props> = ({ entry, loading, data, onClose
|
||||
title={
|
||||
<Space>
|
||||
{data.is_dir ? <FolderOutlined /> : <FileOutlined />}
|
||||
基本信息
|
||||
{t('Basic Info')}
|
||||
</Space>
|
||||
}
|
||||
style={{ borderRadius: 8, height: 'fit-content' }}
|
||||
@@ -129,36 +134,36 @@ export const FileDetailModal: React.FC<Props> = ({ entry, loading, data, onClose
|
||||
items={[
|
||||
{
|
||||
key: 'name',
|
||||
label: '名称',
|
||||
label: t('Name'),
|
||||
children: <Typography.Text strong>{data.name}</Typography.Text>
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
label: '类型',
|
||||
label: t('Type'),
|
||||
children: (
|
||||
<Badge
|
||||
status={data.is_dir ? 'processing' : 'default'}
|
||||
text={data.type || (data.is_dir ? '文件夹' : '文件')}
|
||||
text={data.type || (data.is_dir ? t('Folder') : t('File'))}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'size',
|
||||
label: '大小',
|
||||
children: formatFileSize(data.size)
|
||||
label: t('Size'),
|
||||
children: formatFileSize(data.size, t)
|
||||
},
|
||||
{
|
||||
key: 'mtime',
|
||||
label: '修改时间',
|
||||
label: t('Modified Time'),
|
||||
children: data.mtime ? (
|
||||
typeof data.mtime === 'number'
|
||||
? new Date(data.mtime * 1000).toLocaleString('zh-CN')
|
||||
? new Date(data.mtime * 1000).toLocaleString()
|
||||
: data.mtime
|
||||
) : '-'
|
||||
},
|
||||
{
|
||||
key: 'path',
|
||||
label: '路径',
|
||||
label: t('Path'),
|
||||
children: (
|
||||
<Typography.Text style={{ display: 'block', marginTop: 4 }}>
|
||||
<a
|
||||
@@ -168,9 +173,9 @@ export const FileDetailModal: React.FC<Props> = ({ entry, loading, data, onClose
|
||||
try {
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(data.path).then(() => {
|
||||
message.success('路径已复制到剪贴板');
|
||||
message.success(t('Path copied to clipboard'));
|
||||
}).catch(() => {
|
||||
message.error('复制失败');
|
||||
message.error(t('Copy failed'));
|
||||
});
|
||||
} else {
|
||||
const textarea = document.createElement('textarea');
|
||||
@@ -179,10 +184,10 @@ export const FileDetailModal: React.FC<Props> = ({ entry, loading, data, onClose
|
||||
textarea.select();
|
||||
const ok = document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
message[ok ? 'success' : 'error'](ok ? '路径已复制到剪贴板' : '复制失败');
|
||||
message[ok ? 'success' : 'error'](ok ? t('Path copied to clipboard') : t('Copy failed'));
|
||||
}
|
||||
} catch {
|
||||
message.error('复制失败');
|
||||
message.error(t('Copy failed'));
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
@@ -214,7 +219,7 @@ export const FileDetailModal: React.FC<Props> = ({ entry, loading, data, onClose
|
||||
<>
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
<div>
|
||||
<span style={{ fontWeight: 500, color: token.colorTextSecondary }}>权限:</span>
|
||||
<span style={{ fontWeight: 500, color: token.colorTextSecondary }}>{t('Permissions')}:</span>
|
||||
<Typography.Text code>{data.mode.toString(8)}</Typography.Text>
|
||||
</div>
|
||||
</>
|
||||
@@ -230,12 +235,12 @@ export const FileDetailModal: React.FC<Props> = ({ entry, loading, data, onClose
|
||||
title={
|
||||
<Space>
|
||||
<CameraOutlined />
|
||||
EXIF信息
|
||||
{t('EXIF Info')}
|
||||
</Space>
|
||||
}
|
||||
style={{ borderRadius: 8, height: 'fit-content' }}
|
||||
>
|
||||
{renderExif(data.exif)}
|
||||
{renderExif(data.exif, t)}
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -17,11 +17,25 @@ import {
|
||||
FontSizeOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
export const getFileIcon = (fileName: string, size: number = 16) => {
|
||||
const lightenColor = (hex: string, amount: number) => {
|
||||
const s = hex.replace('#', '');
|
||||
const n = s.length === 3 ? s.split('').map(c => c + c).join('') : s;
|
||||
const num = parseInt(n, 16);
|
||||
if (Number.isNaN(num) || n.length !== 6) return hex;
|
||||
const r = (num >> 16) & 255;
|
||||
const g = (num >> 8) & 255;
|
||||
const b = num & 255;
|
||||
const mix = (c: number) => Math.round(c + (255 - c) * amount);
|
||||
const toHex = (v: number) => v.toString(16).padStart(2, '0');
|
||||
return `#${toHex(mix(r))}${toHex(mix(g))}${toHex(mix(b))}`;
|
||||
};
|
||||
|
||||
export const getFileIcon = (fileName: string, size: number = 16, resolvedMode: 'light' | 'dark' | 'system' = 'light') => {
|
||||
const ext = fileName.split('.').pop()?.toLowerCase() || '';
|
||||
const iconStyle: React.CSSProperties = { fontSize: size, marginRight: size === 16 ? 6 : 0 };
|
||||
|
||||
const make = (node: React.ReactNode, color: string) => React.cloneElement(node as any, { style: { ...iconStyle, color } });
|
||||
const adj = (color: string) => (resolvedMode === 'dark' ? lightenColor(color, 0.3) : color);
|
||||
const make = (node: React.ReactNode, color: string) => React.cloneElement(node as any, { style: { ...iconStyle, color: adj(color) } });
|
||||
|
||||
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'tiff'].includes(ext)) return make(<FileImageOutlined />, '#52c41a');
|
||||
if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'webm', 'm4v', '3gp'].includes(ext)) return make(<VideoCameraOutlined />, '#fa541c');
|
||||
|
||||
@@ -4,6 +4,8 @@ import { FolderFilled, MoreOutlined, EditOutlined, DeleteOutlined, AppstoreOutli
|
||||
import type { VfsEntry } from '../../../api/client';
|
||||
import { getFileIcon } from './FileIcons';
|
||||
import { getAppsForEntry, getDefaultAppForEntry } from '../../../apps/registry';
|
||||
import { useTheme } from '../../../contexts/ThemeContext';
|
||||
import { useI18n } from '../../../i18n';
|
||||
|
||||
interface FileListViewProps {
|
||||
entries: VfsEntry[];
|
||||
@@ -31,28 +33,42 @@ export const FileListView: React.FC<FileListViewProps> = ({
|
||||
onContextMenu,
|
||||
}) => {
|
||||
const { token } = theme.useToken();
|
||||
const { resolvedMode } = useTheme();
|
||||
const { t } = useI18n();
|
||||
const lightenColor = (hex: string, amount: number) => {
|
||||
const s = hex.replace('#', '');
|
||||
const n = s.length === 3 ? s.split('').map(c => c + c).join('') : s;
|
||||
const num = parseInt(n, 16);
|
||||
if (Number.isNaN(num) || n.length !== 6) return hex;
|
||||
const r = (num >> 16) & 255;
|
||||
const g = (num >> 8) & 255;
|
||||
const b = num & 255;
|
||||
const mix = (c: number) => Math.round(c + (255 - c) * amount);
|
||||
const toHex = (v: number) => v.toString(16).padStart(2, '0');
|
||||
return `#${toHex(mix(r))}${toHex(mix(g))}${toHex(mix(b))}`;
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '名称',
|
||||
title: t('Name'),
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (_: any, r: VfsEntry) => (
|
||||
<span style={{ cursor: 'pointer', userSelect: 'none' }} onDoubleClick={() => onOpen(r)}>
|
||||
{r.is_dir ? (
|
||||
<FolderFilled style={{ color: token.colorPrimary, marginRight: 6 }} />
|
||||
<FolderFilled style={{ color: resolvedMode === 'dark' ? lightenColor(String(token.colorPrimary || '#111111'), 0.72) : token.colorPrimary, marginRight: 6 }} />
|
||||
) : (
|
||||
getFileIcon(r.name, 16)
|
||||
getFileIcon(r.name, 16, resolvedMode)
|
||||
)}
|
||||
{r.name}
|
||||
{r.type === 'mount' && <Tooltip title="挂载点"><span style={{ marginLeft: 6, fontSize: 10, padding: '0 4px', border: `1px solid ${token.colorBorderSecondary}`, borderRadius: 4 }}>MOUNT</span></Tooltip>}
|
||||
{r.type === 'mount' && <Tooltip title={t('Mount Point')}><span style={{ marginLeft: 6, fontSize: 10, padding: '0 4px', border: `1px solid ${token.colorBorderSecondary}`, borderRadius: 4 }}>MOUNT</span></Tooltip>}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{ title: '大小', dataIndex: 'size', width: 100, render: (v: number, r: VfsEntry) => r.is_dir ? '-' : v },
|
||||
{ title: '修改时间', dataIndex: 'mtime', width: 160, render: (v: number) => v ? new Date(v * 1000).toLocaleString() : '-' },
|
||||
{ title: t('Size'), dataIndex: 'size', width: 100, render: (v: number, r: VfsEntry) => r.is_dir ? '-' : v },
|
||||
{ title: t('Modified Time'), dataIndex: 'mtime', width: 160, render: (v: number) => v ? new Date(v * 1000).toLocaleString() : '-' },
|
||||
{
|
||||
title: '操作',
|
||||
title: t('Actions'),
|
||||
key: 'actions',
|
||||
width: 110,
|
||||
render: (_: any, r: VfsEntry) => {
|
||||
@@ -62,19 +78,19 @@ export const FileListView: React.FC<FileListViewProps> = ({
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
(r.is_dir || apps.length > 0) ? { key: 'open', label: defaultApp ? `打开(${defaultApp.name})` : '打开', icon: <FolderOpenOutlined />, onClick: () => onOpen(r) } : null,
|
||||
(r.is_dir || apps.length > 0) ? { key: 'open', label: defaultApp ? `${t('Open')}(${defaultApp.name})` : t('Open'), icon: <FolderOpenOutlined />, onClick: () => onOpen(r) } : null,
|
||||
!r.is_dir && apps.length > 0 ? {
|
||||
key: 'openWith',
|
||||
label: '打开方式',
|
||||
label: t('Open With'),
|
||||
icon: <AppstoreOutlined />,
|
||||
children: apps.map(a => ({
|
||||
key: 'openWith-' + a.key,
|
||||
label: a.name + (a.key === defaultApp?.key ? ' (默认)' : ''),
|
||||
label: a.name + (a.key === defaultApp?.key ? ` (${t('Default')})` : ''),
|
||||
onClick: () => onOpenWith(r, a.key)
|
||||
}))
|
||||
} : null,
|
||||
{ key: 'rename', label: '重命名', icon: <EditOutlined />, disabled: r.type === 'mount', onClick: () => onRename(r) },
|
||||
{ key: 'delete', label: '删除', icon: <DeleteOutlined />, danger: true, disabled: r.type === 'mount', onClick: () => onDelete(r) }
|
||||
{ key: 'rename', label: t('Rename'), icon: <EditOutlined />, disabled: r.type === 'mount', onClick: () => onRename(r) },
|
||||
{ key: 'delete', label: t('Delete'), icon: <DeleteOutlined />, danger: true, disabled: r.type === 'mount', onClick: () => onDelete(r) }
|
||||
].filter(Boolean) as any[]
|
||||
}}
|
||||
>
|
||||
@@ -105,4 +121,4 @@ export const FileListView: React.FC<FileListViewProps> = ({
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -4,23 +4,18 @@ import { FolderFilled, PictureOutlined } from '@ant-design/icons';
|
||||
import type { VfsEntry } from '../../../api/client';
|
||||
import { getFileIcon } from './FileIcons';
|
||||
import { EmptyState } from './EmptyState';
|
||||
import { useTheme } from '../../../contexts/ThemeContext';
|
||||
|
||||
interface Props {
|
||||
entries: VfsEntry[];
|
||||
thumbs: Record<string,string>;
|
||||
// ...existing code...
|
||||
// selected was single entry before; now use selectedEntries for multi-select
|
||||
thumbs: Record<string, string>;
|
||||
selectedEntries: string[];
|
||||
loading: boolean;
|
||||
path: string;
|
||||
// onSelect: clicked entry, additive indicates Ctrl/Cmd click to toggle
|
||||
onSelect: (e: VfsEntry, additive?: boolean) => void;
|
||||
// onSelectRange: called when marquee/selecting multiple by box
|
||||
onSelectRange: (names: string[]) => void;
|
||||
onOpen: (e: VfsEntry) => void;
|
||||
onContextMenu: (e: React.MouseEvent, entry: VfsEntry) => void;
|
||||
onCreateDir: () => void;
|
||||
onGoUp: () => void;
|
||||
}
|
||||
|
||||
const formatSize = (size: number) => {
|
||||
@@ -30,14 +25,34 @@ const formatSize = (size: number) => {
|
||||
return (size / 1024 / 1024 / 1024).toFixed(1) + ' GB';
|
||||
};
|
||||
|
||||
export const GridView: React.FC<Props> = ({ entries, thumbs, selectedEntries, loading, path, onSelect, onSelectRange, onOpen, onContextMenu, onCreateDir, onGoUp }) => {
|
||||
export const GridView: React.FC<Props> = ({ entries, thumbs, selectedEntries, loading, path, onSelect, onSelectRange, onOpen, onContextMenu }) => {
|
||||
const { token } = theme.useToken();
|
||||
|
||||
// refs for marquee selection
|
||||
const { resolvedMode } = useTheme();
|
||||
const lightenColor = (hex: string, amount: number) => {
|
||||
const parseHex = (h: string) => {
|
||||
const s = h.replace('#', '');
|
||||
const n = s.length === 3 ? s.split('').map(c => c + c).join('') : s;
|
||||
const num = parseInt(n, 16);
|
||||
if (Number.isNaN(num) || n.length !== 6) return null;
|
||||
return {
|
||||
r: (num >> 16) & 255,
|
||||
g: (num >> 8) & 255,
|
||||
b: num & 255,
|
||||
};
|
||||
};
|
||||
const rgb = parseHex(hex);
|
||||
if (!rgb) return hex;
|
||||
const mix = (c: number) => Math.round(c + (255 - c) * amount);
|
||||
const r = mix(rgb.r);
|
||||
const g = mix(rgb.g);
|
||||
const b = mix(rgb.b);
|
||||
const toHex = (v: number) => v.toString(16).padStart(2, '0');
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
||||
};
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const itemRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
const startRef = useRef<{x:number,y:number} | null>(null);
|
||||
const [rect, setRect] = useState<{left:number,top:number,width:number,height:number} | null>(null);
|
||||
const startRef = useRef<{ x: number, y: number } | null>(null);
|
||||
const [rect, setRect] = useState<{ left: number, top: number, width: number, height: number } | null>(null);
|
||||
const [selecting, setSelecting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -52,12 +67,11 @@ export const GridView: React.FC<Props> = ({ entries, thumbs, selectedEntries, lo
|
||||
const height = Math.abs(cy - s.y);
|
||||
setRect({ left, top, width, height });
|
||||
};
|
||||
const onUp = () => { // 不需要 MouseEvent 参数,避免未使用警告
|
||||
const onUp = () => {
|
||||
if (!startRef.current) return;
|
||||
setSelecting(false);
|
||||
const r = rect;
|
||||
if (r) {
|
||||
// compute intersecting items
|
||||
const container = containerRef.current;
|
||||
if (container) {
|
||||
const sel: string[] = [];
|
||||
@@ -89,17 +103,14 @@ export const GridView: React.FC<Props> = ({ entries, thumbs, selectedEntries, lo
|
||||
}, [selecting, rect, entries, onSelectRange]);
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
// only left button and not on an item actionable element
|
||||
if (e.button !== 0) return;
|
||||
// start marquee if click on empty space inside container
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('.fx-grid-item')) {
|
||||
return; // clicks on item handled separately
|
||||
return;
|
||||
}
|
||||
startRef.current = { x: e.clientX, y: e.clientY };
|
||||
setSelecting(true);
|
||||
setRect({ left: e.clientX, top: e.clientY, width: 0, height: 0 });
|
||||
// prevent text selection
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
@@ -108,29 +119,43 @@ export const GridView: React.FC<Props> = ({ entries, thumbs, selectedEntries, lo
|
||||
{entries.map(ent => {
|
||||
const isImg = thumbs[ent.name];
|
||||
const ext = ent.name.split('.').pop()?.toLowerCase();
|
||||
const isPictureType = ['png','jpg','jpeg','gif','webp','svg'].includes(ext || '');
|
||||
const isPictureType = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext || '');
|
||||
const isSelected = selectedEntries.includes(ent.name);
|
||||
return (
|
||||
<div
|
||||
key={ent.name}
|
||||
ref={(el) => { itemRefs.current[ent.name] = el; }} // 确保函数不返回值,匹配 Ref 类型
|
||||
className={['fx-grid-item', isSelected ? 'selected' : '', ent.is_dir? 'dir':'file'].join(' ')}
|
||||
ref={(el) => { itemRefs.current[ent.name] = el; }}
|
||||
className={['fx-grid-item', isSelected ? 'selected' : '', ent.is_dir ? 'dir' : 'file'].join(' ')}
|
||||
onClick={(ev) => {
|
||||
// click selection: support ctrl/cmd to toggle
|
||||
const additive = ev.ctrlKey || ev.metaKey;
|
||||
onSelect(ent, additive);
|
||||
}}
|
||||
onDoubleClick={() => onOpen(ent)}
|
||||
onContextMenu={(e)=> onContextMenu(e, ent)}
|
||||
style={{ userSelect:'none' }}
|
||||
onContextMenu={(e) => onContextMenu(e, ent)}
|
||||
style={{ userSelect: 'none' }}
|
||||
>
|
||||
<div className="thumb" style={{ background: ent.is_dir ? 'linear-gradient(#fafafa,#f2f2f2)' : '#fff' }}>
|
||||
{ent.is_dir && <FolderFilled style={{ fontSize:32, color: token.colorPrimary }} />}
|
||||
{!ent.is_dir && (isImg ? <img src={isImg} alt={ent.name} style={{ maxWidth:'100%', maxHeight:'100%'}} /> : isPictureType ? <PictureOutlined style={{ fontSize:32, color:'#8c8c8c' }} /> : getFileIcon(ent.name,32))}
|
||||
<div className="thumb" style={{ background: 'var(--ant-color-bg-container, #fff)' }}>
|
||||
{ent.is_dir && (
|
||||
<FolderFilled
|
||||
style={{
|
||||
fontSize: 32,
|
||||
color: resolvedMode === 'dark' ? lightenColor(String(token.colorPrimary || '#111111'), 0.72) : token.colorPrimary,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!ent.is_dir && (
|
||||
isImg ? (
|
||||
<img src={isImg} alt={ent.name} style={{ maxWidth: '100%', maxHeight: '100%' }} />
|
||||
) : isPictureType ? (
|
||||
<PictureOutlined style={{ fontSize: 32, color: resolvedMode === 'dark' ? lightenColor(String(token.colorPrimary || '#111111'), 0.72) : 'var(--ant-color-text-tertiary, #8c8c8c)' }} />
|
||||
) : (
|
||||
getFileIcon(ent.name, 32, resolvedMode)
|
||||
)
|
||||
)}
|
||||
{ent.type === 'mount' && <span className="badge">M</span>}
|
||||
</div>
|
||||
<Tooltip title={ent.name}><div className="name ellipsis" style={{ userSelect:'none' }}>{ent.name}</div></Tooltip>
|
||||
<div className="meta ellipsis" style={{ fontSize:11, color: token.colorTextSecondary, userSelect:'none' }}>{ent.is_dir ? '目录' : formatSize(ent.size)}</div>
|
||||
<Tooltip title={ent.name}><div className="name ellipsis" style={{ userSelect: 'none' }}>{ent.name}</div></Tooltip>
|
||||
<div className="meta ellipsis" style={{ fontSize: 11, color: token.colorTextSecondary, userSelect: 'none' }}>{ent.is_dir ? '目录' : formatSize(ent.size)}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -142,14 +167,14 @@ export const GridView: React.FC<Props> = ({ entries, thumbs, selectedEntries, lo
|
||||
top: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
border: '1px dashed rgba(0,0,0,0.4)',
|
||||
background: 'rgba(0, 120, 212, 0.08)',
|
||||
border: '1px dashed var(--ant-color-border, rgba(0,0,0,0.4))',
|
||||
background: 'var(--ant-color-primary-bg, rgba(0, 120, 212, 0.08))',
|
||||
zIndex: 999
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{loading && <div style={{ width:'100%', textAlign:'center', padding:40 }}><Spin /></div>}
|
||||
{!loading && entries.length === 0 && <EmptyState isRoot={path==='/' } onCreateDir={onCreateDir} onGoUp={onGoUp} />}
|
||||
{loading && <div style={{ width: '100%', textAlign: 'center', padding: 40 }}><Spin /></div>}
|
||||
{!loading && entries.length === 0 && <EmptyState isRoot={path === '/'} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Flex, Typography, Divider, Button, Space, Tooltip, Segmented, Breadcrumb, Input, theme } from 'antd';
|
||||
import { ArrowUpOutlined, ReloadOutlined, PlusOutlined, UploadOutlined, AppstoreOutlined, UnorderedListOutlined } from '@ant-design/icons';
|
||||
import { ArrowUpOutlined, ArrowDownOutlined, ReloadOutlined, PlusOutlined, UploadOutlined, AppstoreOutlined, UnorderedListOutlined } from '@ant-design/icons';
|
||||
import { Select } from 'antd';
|
||||
import { useI18n } from '../../../i18n';
|
||||
import type { ViewMode } from '../types';
|
||||
|
||||
interface HeaderProps {
|
||||
@@ -8,26 +10,33 @@ interface HeaderProps {
|
||||
path: string;
|
||||
loading: boolean;
|
||||
viewMode: ViewMode;
|
||||
sortBy: string;
|
||||
sortOrder: string;
|
||||
onGoUp: () => void;
|
||||
onNavigate: (path: string) => void;
|
||||
onRefresh: () => void;
|
||||
onCreateDir: () => void;
|
||||
onUpload: () => void;
|
||||
onSetViewMode: (mode: ViewMode) => void;
|
||||
onSortChange: (sortBy: string, sortOrder: string) => void;
|
||||
}
|
||||
|
||||
export const Header: React.FC<HeaderProps> = ({
|
||||
path,
|
||||
loading,
|
||||
viewMode,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
onGoUp,
|
||||
onNavigate,
|
||||
onRefresh,
|
||||
onCreateDir,
|
||||
onUpload,
|
||||
onSetViewMode,
|
||||
onSortChange,
|
||||
}) => {
|
||||
const { token } = theme.useToken();
|
||||
const { t } = useI18n();
|
||||
const [editingPath, setEditingPath] = useState(false);
|
||||
const [pathInputValue, setPathInputValue] = useState('');
|
||||
|
||||
@@ -66,7 +75,7 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
}
|
||||
|
||||
const breadcrumbItems = [
|
||||
{ key: 'root', title: <span style={{ cursor: 'pointer' }} onClick={() => onNavigate('/')}>Home</span> },
|
||||
{ key: 'root', title: <span style={{ cursor: 'pointer' }} onClick={() => onNavigate('/')}>{t('Home')}</span> },
|
||||
...path.split('/').filter(Boolean).map((segment, index, arr) => {
|
||||
const segmentPath = '/' + arr.slice(0, index + 1).join('/');
|
||||
return {
|
||||
@@ -92,24 +101,40 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
<Flex align="center" justify="space-between" style={{ padding: '10px 16px', borderBottom: `1px solid ${token.colorBorderSecondary}`, gap: 12 }}>
|
||||
<Flex align="center" gap={8} style={{ flexWrap: 'wrap', flex: 1, overflow: 'hidden' }}>
|
||||
<Button size="small" icon={<ArrowUpOutlined />} onClick={onGoUp} disabled={path === '/'} />
|
||||
<Typography.Text strong>文件管理</Typography.Text>
|
||||
<Typography.Text strong>{t('File Manager')}</Typography.Text>
|
||||
<Divider type="vertical" />
|
||||
{renderBreadcrumb()}
|
||||
</Flex>
|
||||
<Space size={8} wrap>
|
||||
<Button size="small" icon={<ReloadOutlined />} onClick={onRefresh} loading={loading}>刷新</Button>
|
||||
<Button size="small" icon={<PlusOutlined />} onClick={onCreateDir}>新建目录</Button>
|
||||
<Button size="small" icon={<UploadOutlined />} onClick={onUpload}>上传</Button>
|
||||
<Button size="small" icon={<ReloadOutlined />} onClick={onRefresh} loading={loading}>{t('Refresh')}</Button>
|
||||
<Button size="small" icon={<PlusOutlined />} onClick={onCreateDir}>{t('New Folder')}</Button>
|
||||
<Button size="small" icon={<UploadOutlined />} onClick={onUpload}>{t('Upload')}</Button>
|
||||
<Select
|
||||
size="small"
|
||||
value={sortBy}
|
||||
onChange={(val) => onSortChange(val, sortOrder)}
|
||||
style={{ width: 80 }}
|
||||
options={[
|
||||
{ value: 'name', label: t('Name') },
|
||||
{ value: 'size', label: t('Size') },
|
||||
{ value: 'mtime', label: t('Modified Time') },
|
||||
]}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
icon={sortOrder === 'asc' ? <ArrowUpOutlined /> : <ArrowDownOutlined />}
|
||||
onClick={() => onSortChange(sortBy, sortOrder === 'asc' ? 'desc' : 'asc')}
|
||||
/>
|
||||
<Segmented
|
||||
size="small"
|
||||
value={viewMode}
|
||||
onChange={v => onSetViewMode(v as any)}
|
||||
options={[
|
||||
{ label: <Tooltip title="网格"><AppstoreOutlined /></Tooltip>, value: 'grid' },
|
||||
{ label: <Tooltip title="列表"><UnorderedListOutlined /></Tooltip>, value: 'list' }
|
||||
{ label: <Tooltip title={t('Grid')}><AppstoreOutlined /></Tooltip>, value: 'grid' },
|
||||
{ label: <Tooltip title={t('List')}><UnorderedListOutlined /></Tooltip>, value: 'list' }
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Input } from 'antd';
|
||||
import { useI18n } from '../../../../i18n';
|
||||
|
||||
interface CreateDirModalProps {
|
||||
open: boolean;
|
||||
@@ -9,6 +10,7 @@ interface CreateDirModalProps {
|
||||
|
||||
export const CreateDirModal: React.FC<CreateDirModalProps> = ({ open, onOk, onCancel }) => {
|
||||
const [name, setName] = useState('');
|
||||
const { t } = useI18n();
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@@ -22,7 +24,7 @@ export const CreateDirModal: React.FC<CreateDirModalProps> = ({ open, onOk, onCa
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="新建目录"
|
||||
title={t('New Folder')}
|
||||
open={open}
|
||||
onOk={handleOk}
|
||||
onCancel={onCancel}
|
||||
@@ -30,7 +32,7 @@ export const CreateDirModal: React.FC<CreateDirModalProps> = ({ open, onOk, onCa
|
||||
destroyOnClose
|
||||
>
|
||||
<Input
|
||||
placeholder="目录名称"
|
||||
placeholder={t('Folder Name')}
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
onPressEnter={handleOk}
|
||||
@@ -38,4 +40,4 @@ export const CreateDirModal: React.FC<CreateDirModalProps> = ({ open, onOk, onCa
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { memo, useState, useEffect } from 'react';
|
||||
import { Modal, Radio, message, Button, Typography, Input } from 'antd';
|
||||
import { CopyOutlined } from '@ant-design/icons';
|
||||
import { Modal, Radio, message, Button, Typography, Input, Space } from 'antd';
|
||||
import { CopyOutlined, FileMarkdownOutlined } from '@ant-design/icons';
|
||||
import type { VfsEntry } from '../../../../api/client';
|
||||
import { vfsApi } from '../../../../api/client';
|
||||
import { useI18n } from '../../../../i18n';
|
||||
|
||||
interface DirectLinkModalProps {
|
||||
entry: VfsEntry | null;
|
||||
@@ -11,10 +12,26 @@ interface DirectLinkModalProps {
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
// Helper function to check if a file is an image
|
||||
const isImageFile = (fileName: string): boolean => {
|
||||
const ext = fileName.split('.').pop()?.toLowerCase() || '';
|
||||
return ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'tiff'].includes(ext);
|
||||
};
|
||||
|
||||
// Helper function to generate Markdown formatted link
|
||||
const generateMarkdownLink = (fileName: string, url: string): string => {
|
||||
if (isImageFile(fileName)) {
|
||||
return ``;
|
||||
} else {
|
||||
return `[${fileName}](${url})`;
|
||||
}
|
||||
};
|
||||
|
||||
export const DirectLinkModal = memo(function DirectLinkModal({ entry, path, open, onCancel }: DirectLinkModalProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [expiresIn, setExpiresIn] = useState(3600);
|
||||
const [link, setLink] = useState('');
|
||||
const { t } = useI18n();
|
||||
|
||||
useEffect(() => {
|
||||
if (open && entry) {
|
||||
@@ -29,10 +46,14 @@ export const DirectLinkModal = memo(function DirectLinkModal({ entry, path, open
|
||||
try {
|
||||
const fullPath = (path === '/' ? '' : path) + '/' + entry.name;
|
||||
const res = await vfsApi.getTempLinkToken(fullPath, expiresIn);
|
||||
const tempLink = `${window.location.origin}/api/fs/public/${res.token}`;
|
||||
setLink(tempLink);
|
||||
let url = res.url;
|
||||
if (url && !url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
const origin = window.location.origin;
|
||||
url = url.startsWith('/') ? origin + url : origin + '/' + url;
|
||||
}
|
||||
setLink(url);
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '生成链接失败');
|
||||
message.error(e.message || t('Failed to generate link'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -40,40 +61,52 @@ export const DirectLinkModal = memo(function DirectLinkModal({ entry, path, open
|
||||
|
||||
const handleCopy = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
message.success('已复制到剪贴板');
|
||||
message.success(t('Copied to clipboard'));
|
||||
};
|
||||
|
||||
|
||||
const handleCopyMarkdown = () => {
|
||||
if (!entry || !link) return;
|
||||
const markdownText = generateMarkdownLink(entry.name, link);
|
||||
navigator.clipboard.writeText(markdownText);
|
||||
message.success(t('Markdown copied to clipboard'));
|
||||
};
|
||||
|
||||
const handleExpiresChange = (e: any) => {
|
||||
setExpiresIn(e.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="获取直链"
|
||||
title={t('Get Direct Link')}
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
footer={[
|
||||
<Button key="back" onClick={onCancel}>
|
||||
关闭
|
||||
{t('Close')}
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<Typography.Paragraph>
|
||||
为 <strong>{entry?.name}</strong> 生成一个直接访问链接。
|
||||
{t('Generate a direct link for {name}', { name: entry?.name || '' })}
|
||||
</Typography.Paragraph>
|
||||
<Radio.Group value={expiresIn} onChange={handleExpiresChange} style={{ marginBottom: 16 }}>
|
||||
<Radio.Button value={3600}>1 小时</Radio.Button>
|
||||
<Radio.Button value={86400}>1 天</Radio.Button>
|
||||
<Radio.Button value={604800}>7 天</Radio.Button>
|
||||
<Radio.Button value={0}>永久</Radio.Button>
|
||||
<Radio.Button value={3600}>{t('1 hour')}</Radio.Button>
|
||||
<Radio.Button value={86400}>{t('1 day')}</Radio.Button>
|
||||
<Radio.Button value={604800}>{t('7 days')}</Radio.Button>
|
||||
<Radio.Button value={0}>{t('Forever')}</Radio.Button>
|
||||
</Radio.Group>
|
||||
|
||||
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Input readOnly value={link} disabled={loading} placeholder={loading ? "正在生成链接..." : "链接将显示在这里"} />
|
||||
<Button icon={<CopyOutlined />} onClick={() => handleCopy(link)} disabled={!link || loading}>
|
||||
复制
|
||||
</Button>
|
||||
<Input readOnly value={link} disabled={loading} placeholder={loading ? t('Generating link...') : t('Link will appear here')} />
|
||||
<Space.Compact>
|
||||
<Button icon={<CopyOutlined />} onClick={() => handleCopy(link)} disabled={!link || loading}>
|
||||
{t('Copy')}
|
||||
</Button>
|
||||
<Button icon={<FileMarkdownOutlined />} onClick={handleCopyMarkdown} disabled={!link || loading}>
|
||||
{t('Copy Markdown')}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Modal, Form, Select, Input, Checkbox } from 'antd';
|
||||
import { useI18n } from '../../../../i18n';
|
||||
import type { VfsEntry } from '../../../../api/client';
|
||||
import type { ProcessorTypeMeta } from '../../../../api/processors';
|
||||
import { ProcessorConfigForm } from '../../../../components/ProcessorConfigForm';
|
||||
@@ -28,6 +29,7 @@ export const ProcessorModal: React.FC<ProcessorModalProps> = (props) => {
|
||||
onConfigChange, onSavingPathChange, onOverwriteChange
|
||||
} = props;
|
||||
const [form] = Form.useForm();
|
||||
const { t } = useI18n();
|
||||
|
||||
const selectedProcessorMeta = processorTypes.find(pt => pt.type === selectedProcessor);
|
||||
|
||||
@@ -51,7 +53,7 @@ export const ProcessorModal: React.FC<ProcessorModalProps> = (props) => {
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={`使用处理器处理文件${entry ? `: ${entry.name}` : ''}`}
|
||||
title={t('Process file with processor') + (entry ? `: ${entry.name}` : '')}
|
||||
open={visible}
|
||||
onCancel={onCancel}
|
||||
onOk={onOk}
|
||||
@@ -59,11 +61,11 @@ export const ProcessorModal: React.FC<ProcessorModalProps> = (props) => {
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical" onValuesChange={handleFormValuesChange}>
|
||||
<Form.Item name="processor_type" label="处理器" required>
|
||||
<Form.Item name="processor_type" label={t('Processor')} required>
|
||||
<Select
|
||||
onChange={onSelectedProcessorChange}
|
||||
options={processorTypes.map(pt => ({ value: pt.type, label: pt.name }))}
|
||||
placeholder="请选择处理器"
|
||||
placeholder={t('Select a processor')}
|
||||
/>
|
||||
</Form.Item>
|
||||
<ProcessorConfigForm
|
||||
@@ -75,15 +77,15 @@ export const ProcessorModal: React.FC<ProcessorModalProps> = (props) => {
|
||||
<>
|
||||
<Form.Item>
|
||||
<Checkbox checked={overwrite} onChange={e => onOverwriteChange(e.target.checked)}>
|
||||
覆盖原文件
|
||||
{t('Overwrite original file')}
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
{!overwrite && (
|
||||
<Form.Item label="保存为新文件">
|
||||
<Form.Item label={t('Save as new file')}>
|
||||
<Input
|
||||
value={savingPath}
|
||||
onChange={e => onSavingPathChange(e.target.value)}
|
||||
placeholder="如 /newfile.jpg,不填则仅返回处理结果"
|
||||
placeholder={t('e.g. /newfile.jpg, leave blank to only return result')}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
@@ -92,4 +94,4 @@ export const ProcessorModal: React.FC<ProcessorModalProps> = (props) => {
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Input } from 'antd';
|
||||
import { useI18n } from '../../../../i18n';
|
||||
import type { VfsEntry } from '../../../../api/client';
|
||||
|
||||
interface RenameModalProps {
|
||||
@@ -10,6 +11,7 @@ interface RenameModalProps {
|
||||
|
||||
export const RenameModal: React.FC<RenameModalProps> = ({ entry, onOk, onCancel }) => {
|
||||
const [name, setName] = useState('');
|
||||
const { t } = useI18n();
|
||||
|
||||
useEffect(() => {
|
||||
if (entry) {
|
||||
@@ -25,7 +27,7 @@ export const RenameModal: React.FC<RenameModalProps> = ({ entry, onOk, onCancel
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="重命名"
|
||||
title={t('Rename')}
|
||||
open={!!entry}
|
||||
onOk={handleOk}
|
||||
onCancel={onCancel}
|
||||
@@ -33,7 +35,7 @@ export const RenameModal: React.FC<RenameModalProps> = ({ entry, onOk, onCancel
|
||||
destroyOnClose
|
||||
>
|
||||
<Input
|
||||
placeholder="新的名称"
|
||||
placeholder={t('New Name')}
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
onPressEnter={handleOk}
|
||||
@@ -41,4 +43,4 @@ export const RenameModal: React.FC<RenameModalProps> = ({ entry, onOk, onCancel
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Modal, Form, Input, Radio, InputNumber, message, Button, Typography } f
|
||||
import { CopyOutlined } from '@ant-design/icons';
|
||||
import type { VfsEntry, ShareInfoWithPassword } from '../../../../api/client';
|
||||
import { shareApi } from '../../../../api/share';
|
||||
import { useSystemStatus } from '../../../../contexts/SystemContext';
|
||||
import { useI18n } from '../../../../i18n';
|
||||
|
||||
interface ShareModalProps {
|
||||
entries: VfsEntry[];
|
||||
@@ -13,13 +15,15 @@ interface ShareModalProps {
|
||||
}
|
||||
|
||||
export const ShareModal = memo(function ShareModal({ entries, path, open, onOk, onCancel }: ShareModalProps) {
|
||||
const systemStatus = useSystemStatus();
|
||||
const { t } = useI18n();
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [accessType, setAccessType] = useState('public');
|
||||
const [createdShare, setCreatedShare] = useState<ShareInfoWithPassword | null>(null);
|
||||
|
||||
const defaultName = entries.length > 1
|
||||
? `分享 ${entries.length} 个项目`
|
||||
? t('Share {count} items', { count: entries.length.toString() })
|
||||
: (entries.length === 1 ? entries[0].name : '');
|
||||
|
||||
useEffect(() => {
|
||||
@@ -52,10 +56,10 @@ export const ShareModal = memo(function ShareModal({ entries, path, open, onOk,
|
||||
password: values.password,
|
||||
expires_in_days: values.expiresInDays,
|
||||
});
|
||||
message.success('分享链接已创建');
|
||||
message.success(t('Share link created'));
|
||||
setCreatedShare(result);
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '创建失败');
|
||||
message.error(e.message || t('Create failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -63,28 +67,29 @@ export const ShareModal = memo(function ShareModal({ entries, path, open, onOk,
|
||||
|
||||
const handleCopy = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
message.success('已复制到剪贴板');
|
||||
message.success(t('Copied to clipboard'));
|
||||
};
|
||||
|
||||
const shareUrl = createdShare ? `${window.location.origin}/share/${createdShare.token}` : '';
|
||||
const baseUrl = systemStatus?.app_domain || window.location.origin;
|
||||
const shareUrl = createdShare ? new URL(`/share/${createdShare.token}`, baseUrl).href : '';
|
||||
|
||||
const renderForm = () => (
|
||||
<Form form={form} layout="vertical" initialValues={{ name: defaultName, accessType: 'public', expiresInDays: 7 }}>
|
||||
<Form.Item name="name" label="分享名称" rules={[{ required: true }]} >
|
||||
<Form.Item name="name" label={t('Share Name')} rules={[{ required: true }]} >
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="accessType" label="访问权限">
|
||||
<Form.Item name="accessType" label={t('Access')}>
|
||||
<Radio.Group onChange={(e) => setAccessType(e.target.value)}>
|
||||
<Radio value="public">公开</Radio>
|
||||
<Radio value="password">密码访问</Radio>
|
||||
<Radio value="public">{t('Public')}</Radio>
|
||||
<Radio value="password">{t('By Password')}</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
{accessType === 'password' && (
|
||||
<Form.Item name="password" label="访问密码" rules={[{ required: true, message: '请输入密码' }]} >
|
||||
<Form.Item name="password" label={t('Please enter password')} rules={[{ required: true, message: t('Please enter password') }]} >
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item name="expiresInDays" label="有效期 (天)" help="设置为 0 或负数表示永久有效">
|
||||
<Form.Item name="expiresInDays" label={t('Expiration (days)')} help={t('Set 0 or negative for forever')}>
|
||||
<InputNumber min={-1} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
@@ -92,44 +97,44 @@ export const ShareModal = memo(function ShareModal({ entries, path, open, onOk,
|
||||
|
||||
const renderSuccess = () => (
|
||||
<div>
|
||||
<Typography.Paragraph>分享链接已成功创建!</Typography.Paragraph>
|
||||
<Typography.Paragraph>{t('Share link created successfully!')}</Typography.Paragraph>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="分享链接">
|
||||
<Form.Item label={t('Share Link')}>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Input readOnly value={shareUrl} style={{ flex: 1 }} />
|
||||
<Button icon={<CopyOutlined />} onClick={() => handleCopy(shareUrl)}>
|
||||
复制
|
||||
{t('Copy')}
|
||||
</Button>
|
||||
</div>
|
||||
</Form.Item>
|
||||
{createdShare?.password && (
|
||||
<Form.Item label="访问密码">
|
||||
<Form.Item label={t('Password')}>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Input readOnly value={createdShare.password} style={{ flex: 1 }} />
|
||||
<Button icon={<CopyOutlined />} onClick={() => handleCopy(createdShare.password!)}>
|
||||
复制
|
||||
{t('Copy')}
|
||||
</Button>
|
||||
</div>
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form>
|
||||
<Typography.Text type="secondary">
|
||||
有效期至: {createdShare?.expires_at ? new Date(createdShare.expires_at).toLocaleString() : '永久有效'}
|
||||
{t('Expires At')}: {createdShare?.expires_at ? new Date(createdShare.expires_at).toLocaleString() : t('Forever')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={createdShare ? "分享创建成功" : "创建分享"}
|
||||
title={createdShare ? t('Share created') : t('Create Share')}
|
||||
open={open}
|
||||
onOk={createdShare ? onOk : handleOk}
|
||||
onCancel={onCancel}
|
||||
confirmLoading={loading}
|
||||
destroyOnHidden
|
||||
okText={createdShare ? "完成" : "创建"}
|
||||
okText={createdShare ? t('Done') : t('Create')}
|
||||
>
|
||||
{createdShare ? renderSuccess() : renderForm()}
|
||||
</Modal>
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useEffect } from 'react';
|
||||
import { Modal, Button, List, Progress, Typography, message, Flex } from 'antd';
|
||||
import { CopyOutlined, CheckCircleFilled, CloseCircleFilled } from '@ant-design/icons';
|
||||
import type { UploadFile } from '../../hooks/useUploader';
|
||||
import { useI18n } from '../../../../i18n';
|
||||
|
||||
interface UploadModalProps {
|
||||
visible: boolean;
|
||||
@@ -11,6 +12,7 @@ interface UploadModalProps {
|
||||
}
|
||||
|
||||
const UploadModal: React.FC<UploadModalProps> = ({ visible, files, onClose, onStartUpload }) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
const allSuccess = files.every(f => f.status === 'success');
|
||||
|
||||
@@ -22,7 +24,7 @@ const UploadModal: React.FC<UploadModalProps> = ({ visible, files, onClose, onSt
|
||||
|
||||
const handleCopy = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
message.success('链接已复制到剪贴板');
|
||||
message.success(t('Copied to clipboard'));
|
||||
};
|
||||
|
||||
const renderStatus = (file: UploadFile) => {
|
||||
@@ -32,32 +34,32 @@ const UploadModal: React.FC<UploadModalProps> = ({ visible, files, onClose, onSt
|
||||
case 'success':
|
||||
return (
|
||||
<Flex align="center" gap={8}>
|
||||
<CheckCircleFilled style={{ color: '#52c41a' }} />
|
||||
<Typography.Text type="secondary" style={{ verticalAlign: 'middle' }}>上传成功</Typography.Text>
|
||||
<CheckCircleFilled style={{ color: 'var(--ant-color-success, #52c41a)' }} />
|
||||
<Typography.Text type="secondary" style={{ verticalAlign: 'middle' }}>{t('Upload succeeded')}</Typography.Text>
|
||||
<Button icon={<CopyOutlined />} size="small" onClick={() => handleCopy(file.permanentLink!)} type="text" />
|
||||
</Flex>
|
||||
);
|
||||
case 'error':
|
||||
return (
|
||||
<Flex align="center" gap={8}>
|
||||
<CloseCircleFilled style={{ color: '#ff4d4f' }} />
|
||||
<Typography.Text type="danger" title={file.error}>上传失败</Typography.Text>
|
||||
<CloseCircleFilled style={{ color: 'var(--ant-color-error, #ff4d4f)' }} />
|
||||
<Typography.Text type="danger" title={file.error}>{t('Upload failed')}</Typography.Text>
|
||||
</Flex>
|
||||
);
|
||||
default:
|
||||
return <Typography.Text type="secondary">等待上传</Typography.Text>;
|
||||
return <Typography.Text type="secondary">{t('Waiting to upload')}</Typography.Text>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={visible}
|
||||
title="上传文件"
|
||||
title={t('Upload File')}
|
||||
width={600}
|
||||
onCancel={onClose}
|
||||
footer={[
|
||||
<Button key="close" onClick={onClose} disabled={!allSuccess && files.some(f => f.status === 'uploading')}>
|
||||
{allSuccess ? '关闭' : '完成'}
|
||||
{allSuccess ? t('Close') : t('Done')}
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
@@ -71,7 +73,7 @@ const UploadModal: React.FC<UploadModalProps> = ({ visible, files, onClose, onSt
|
||||
borderRadius: 8,
|
||||
transition: 'background-color 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = '#f0f0f0'; }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = 'var(--ant-color-fill-tertiary, #f0f0f0)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent'; }}
|
||||
>
|
||||
<Flex justify="space-between" align="center" style={{ width: '100%' }}>
|
||||
@@ -89,4 +91,4 @@ const UploadModal: React.FC<UploadModalProps> = ({ visible, files, onClose, onSt
|
||||
);
|
||||
};
|
||||
|
||||
export default UploadModal;
|
||||
export default UploadModal;
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Modal, Checkbox } from 'antd';
|
||||
import { useI18n } from '../../../i18n';
|
||||
import type { VfsEntry } from '../../../api/client';
|
||||
import type { AppDescriptor } from '../../../apps/registry';
|
||||
import type { AppWindow } from '../types';
|
||||
import { getAppsForEntry, getDefaultAppForEntry, getAppByKey } from '../../../apps/registry';
|
||||
|
||||
export function useAppWindows(path: string) {
|
||||
const { t } = useI18n();
|
||||
const [appWindows, setAppWindows] = useState<AppWindow[]>([]);
|
||||
|
||||
const openWithApp = useCallback((entry: VfsEntry, app: AppDescriptor) => {
|
||||
@@ -40,7 +42,7 @@ export function useAppWindows(path: string) {
|
||||
const openFileWithDefaultApp = useCallback((entry: VfsEntry) => {
|
||||
const apps = getAppsForEntry(entry);
|
||||
if (!apps.length) {
|
||||
Modal.error({ title: '无法打开该文件:没有可用的应用' });
|
||||
Modal.error({ title: t('Cannot open file: no available app') });
|
||||
return;
|
||||
}
|
||||
const defaultApp = getDefaultAppForEntry(entry) || apps[0];
|
||||
@@ -50,17 +52,17 @@ export function useAppWindows(path: string) {
|
||||
const confirmOpenWithApp = useCallback((entry: VfsEntry, appKey: string) => {
|
||||
const app = getAppByKey(appKey);
|
||||
if (!app) {
|
||||
Modal.error({ title: '错误', content: `应用 "${appKey}" 不存在。` });
|
||||
Modal.error({ title: t('Error'), content: t('App "{key}" not found.', { key: appKey }) });
|
||||
return;
|
||||
}
|
||||
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
||||
let setDefault = false;
|
||||
Modal.confirm({
|
||||
title: `使用 ${app.name} 打开`,
|
||||
title: t('Open with {app}', { app: app.name }),
|
||||
content: (
|
||||
<div>
|
||||
<div style={{ marginBottom: 8 }}>文件: {entry.name}</div>
|
||||
<Checkbox onChange={e => setDefault = e.target.checked}>设为该类型(.{ext})默认应用</Checkbox>
|
||||
<div style={{ marginBottom: 8 }}>{t('File')}: {entry.name}</div>
|
||||
<Checkbox onChange={e => setDefault = e.target.checked}>{t('Set as default for .{ext}', { ext })}</Checkbox>
|
||||
</div>
|
||||
),
|
||||
onOk: () => {
|
||||
@@ -92,4 +94,4 @@ export function useAppWindows(path: string) {
|
||||
bringToFront,
|
||||
updateWindow,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback } from 'react';
|
||||
import { message, Modal } from 'antd';
|
||||
import { useI18n } from '../../../i18n';
|
||||
import { vfsApi, type VfsEntry } from '../../../api/client';
|
||||
|
||||
interface FileActionsParams {
|
||||
@@ -11,9 +12,10 @@ interface FileActionsParams {
|
||||
}
|
||||
|
||||
export function useFileActions({ path, refresh, clearSelection, onShare, onGetDirectLink }: FileActionsParams) {
|
||||
const { t } = useI18n();
|
||||
const doCreateDir = useCallback(async (name: string) => {
|
||||
if (!name.trim()) {
|
||||
message.warning('请输入名称');
|
||||
message.warning(t('Please input name'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -26,8 +28,8 @@ export function useFileActions({ path, refresh, clearSelection, onShare, onGetDi
|
||||
|
||||
const doDelete = useCallback(async (entries: VfsEntry[]) => {
|
||||
Modal.confirm({
|
||||
title: `确认删除 ${entries.length > 1 ? `${entries.length} 项` : entries[0].name} ?`,
|
||||
content: entries.length > 1 ? <div style={{ maxHeight: 180, overflow: 'auto' }}>{entries.map(it => <div key={it.name}>{it.name}{it.type === 'mount' && ' (挂载点)'}</div>)}</div> : null,
|
||||
title: t('Confirm delete {name}?', { name: entries.length > 1 ? `${entries.length} ${t('items')}` : entries[0].name }),
|
||||
content: entries.length > 1 ? <div style={{ maxHeight: 180, overflow: 'auto' }}>{entries.map(it => <div key={it.name}>{it.name}{it.type === 'mount' && ` (${t('Mount Point')})`}</div>)}</div> : null,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await Promise.all(entries.map(it => vfsApi.deletePath((path === '/' ? '' : path) + '/' + it.name)));
|
||||
@@ -57,7 +59,7 @@ export function useFileActions({ path, refresh, clearSelection, onShare, onGetDi
|
||||
|
||||
const doDownload = useCallback(async (entry: VfsEntry) => {
|
||||
if (entry.is_dir) {
|
||||
message.warning('暂不支持下载目录');
|
||||
message.warning(t('Downloading folders is not supported'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -72,13 +74,13 @@ export function useFileActions({ path, refresh, clearSelection, onShare, onGetDi
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '下载失败');
|
||||
message.error(e.message || t('Download failed'));
|
||||
}
|
||||
}, [path]);
|
||||
|
||||
const doShare = useCallback((entries: VfsEntry[]) => {
|
||||
if (entries.length === 0) {
|
||||
message.warning('请选择要分享的文件或目录');
|
||||
message.warning(t('Please select files or folders to share'));
|
||||
return;
|
||||
}
|
||||
onShare(entries);
|
||||
@@ -86,7 +88,7 @@ export function useFileActions({ path, refresh, clearSelection, onShare, onGetDi
|
||||
|
||||
const doGetDirectLink = useCallback((entry: VfsEntry) => {
|
||||
if (entry.is_dir) {
|
||||
message.warning('不支持获取目录的直链');
|
||||
message.warning(t('Direct links for folders are not supported'));
|
||||
return;
|
||||
}
|
||||
onGetDirectLink(entry);
|
||||
@@ -100,4 +102,4 @@ export function useFileActions({ path, refresh, clearSelection, onShare, onGetDi
|
||||
doShare,
|
||||
doGetDirectLink,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,17 +18,19 @@ export function useFileExplorer(navKey: string) {
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total: number, range: [number, number]) => `共 ${total} 项,第 ${range[0]}-${range[1]} 项`,
|
||||
showTotal: (total: number, range: [number, number]) => `${total} ${'items'} ${range[0]}-${range[1]}`,
|
||||
pageSizeOptions: ['20', '50', '100', '200']
|
||||
});
|
||||
const [sortBy, setSortBy] = useState('name');
|
||||
const [sortOrder, setSortOrder] = useState('asc');
|
||||
|
||||
const load = useCallback(async (p: string, page: number = 1, pageSize: number = 50) => {
|
||||
const load = useCallback(async (p: string, page: number = 1, pageSize: number = 50, sb = sortBy, so = sortOrder) => {
|
||||
const canonical = p === '' ? '/' : (p.startsWith('/') ? p : '/' + p);
|
||||
setLoading(true);
|
||||
try {
|
||||
// Load entries and processor types concurrently
|
||||
const [res, processors] = await Promise.all([
|
||||
vfsApi.list(canonical === '/' ? '' : canonical, page, pageSize),
|
||||
vfsApi.list(canonical === '/' ? '' : canonical, page, pageSize, sb, so),
|
||||
processorsApi.list()
|
||||
]);
|
||||
setEntries(res.entries);
|
||||
@@ -41,11 +43,11 @@ export function useFileExplorer(navKey: string) {
|
||||
}));
|
||||
setProcessorTypes(processors);
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '加载失败');
|
||||
message.error(e.message || 'Load failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [sortBy, sortOrder]);
|
||||
|
||||
const navigateTo = useCallback((p: string) => {
|
||||
const canonical = p === '' || p === '/' ? '/' : (p.startsWith('/') ? p : '/' + p);
|
||||
@@ -60,23 +62,32 @@ export function useFileExplorer(navKey: string) {
|
||||
}, [path, navigateTo]);
|
||||
|
||||
const handlePaginationChange = (page: number, pageSize: number) => {
|
||||
load(path, page, pageSize);
|
||||
load(path, page, pageSize, sortBy, sortOrder);
|
||||
};
|
||||
|
||||
const refresh = () => {
|
||||
load(path, pagination.current, pagination.pageSize);
|
||||
load(path, pagination.current, pagination.pageSize, sortBy, sortOrder);
|
||||
}
|
||||
|
||||
const handleSortChange = (sb: string, so: string) => {
|
||||
setSortBy(sb);
|
||||
setSortOrder(so);
|
||||
load(path, 1, pagination.pageSize, sb, so);
|
||||
};
|
||||
|
||||
return {
|
||||
path,
|
||||
entries,
|
||||
loading,
|
||||
pagination,
|
||||
processorTypes,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
load,
|
||||
navigateTo,
|
||||
goUp,
|
||||
handlePaginationChange,
|
||||
refresh,
|
||||
handleSortChange
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { message } from 'antd';
|
||||
import { useI18n } from '../../../i18n';
|
||||
import { processorsApi, type ProcessorTypeMeta } from '../../../api/processors';
|
||||
import type { VfsEntry } from '../../../api/client';
|
||||
|
||||
@@ -10,6 +11,7 @@ interface ProcessorParams {
|
||||
}
|
||||
|
||||
export function useProcessor({ path, processorTypes, refresh }: ProcessorParams) {
|
||||
const { t } = useI18n();
|
||||
const [modal, setModal] = useState<{ entry: VfsEntry | null; visible: boolean }>({ entry: null, visible: false });
|
||||
const [selectedProcessor, setSelectedProcessor] = useState<string>('');
|
||||
const [config, setConfig] = useState<any>({});
|
||||
@@ -48,11 +50,11 @@ export function useProcessor({ path, processorTypes, refresh }: ProcessorParams)
|
||||
};
|
||||
|
||||
await processorsApi.process(params);
|
||||
message.success('处理完成');
|
||||
message.success(t('Processing finished'));
|
||||
setModal({ entry: null, visible: false });
|
||||
if (overwrite || savingPath) refresh();
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '处理失败');
|
||||
message.error(e.message || t('Processing failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -100,4 +102,4 @@ export function useProcessor({ path, processorTypes, refresh }: ProcessorParams)
|
||||
setProcessorSavingPath: setSavingPath,
|
||||
setProcessorOverwrite: setOverwrite,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,13 +39,25 @@ export function useUploader(path: string, onUploadComplete: () => void) {
|
||||
}));
|
||||
setFiles(newFiles);
|
||||
setIsModalVisible(true);
|
||||
// reset file input
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileDrop = (droppedFiles: FileList) => {
|
||||
if (droppedFiles && droppedFiles.length > 0) {
|
||||
const newFiles: UploadFile[] = Array.from(droppedFiles).map(file => ({
|
||||
id: `${file.name}-${Date.now()}`,
|
||||
file,
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
}));
|
||||
setFiles(newFiles);
|
||||
setIsModalVisible(true);
|
||||
}
|
||||
};
|
||||
|
||||
const startUpload = useCallback(async () => {
|
||||
if (files.length === 0) {
|
||||
return;
|
||||
@@ -66,13 +78,13 @@ export function useUploader(path: string, onUploadComplete: () => void) {
|
||||
setFiles(prev => prev.map(f => f.id === uploadFile.id ? { ...f, progress } : f));
|
||||
});
|
||||
|
||||
const link = await vfsApi.getTempLinkToken(dest, 60 * 60 * 24 * 365 * 10); // 10 years
|
||||
const link = await vfsApi.getTempLinkToken(dest, 60 * 60 * 24 * 365 * 10);
|
||||
const permanentLink = vfsApi.getTempPublicUrl(link.token);
|
||||
|
||||
setFiles(prev => prev.map(f => f.id === uploadFile.id ? { ...f, status: 'success', progress: 100, permanentLink } : f));
|
||||
} catch (e: any) {
|
||||
setFiles(prev => prev.map(f => f.id === uploadFile.id ? { ...f, status: 'error', error: e.message } : f));
|
||||
message.error(`上传失败: ${uploadFile.file.name} - ${e.message}`);
|
||||
message.error(`Upload failed: ${uploadFile.file.name} - ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +98,7 @@ export function useUploader(path: string, onUploadComplete: () => void) {
|
||||
openModal,
|
||||
closeModal,
|
||||
handleFileChange,
|
||||
handleFileDrop,
|
||||
startUpload,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user