Compare commits

...

35 Commits

Author SHA1 Message Date
shiyu
f02c29492b chore: update version to v1.2.6 2025-09-17 14:27:15 +08:00
shiyu
1a79e87887 feat: add AI embedding dimension configuration 2025-09-17 14:26:53 +08:00
shiyu
626ff727b3 feat: update contributing guidelines and add Chinese translation 2025-09-16 18:43:08 +08:00
shiyu
117a94d793 feat(docker): create data directories with appropriate permissions 2025-09-16 18:32:38 +08:00
shiyu
c39bea67a4 chore: update version to v1.2.5 2025-09-16 11:31:52 +08:00
shiyu
2cbfb29260 feat(i18n): add 'Processor' and 'Share' translations for English and Chinese 2025-09-16 11:31:23 +08:00
shiyu
155f3a144d feat(ui): add path selector modal 2025-09-15 14:14:10 +08:00
shiyu
208a52589f feat: update theme context to support dynamic locale switching 2025-09-14 16:35:12 +08:00
shiyu
0732b611a9 feat: add expired share cleanup functionality 2025-09-14 16:27:46 +08:00
shiyu
7b25e6d3b6 chore: update version to v1.2.4 2025-09-14 13:40:10 +08:00
shiyu
04441d0bc4 feat: add username field to profile modal 2025-09-14 13:20:45 +08:00
shiyu
917b542dab feat: add user profile management 2025-09-14 12:54:49 +08:00
shiyu
e43b68beda feat: ensure data/db directory exists during app startup 2025-09-13 16:35:54 +08:00
shiyu
801ff26cc7 chore: update version to v1.2.3 2025-09-12 20:02:21 +08:00
shiyu
284c2d24a2 feat: add basic WebDAV support 2025-09-12 20:00:43 +08:00
shiyu
a34be25ec0 feat(window): Add app window management with minimize, restore, and icon support 2025-09-12 19:16:02 +08:00
shiyu
db2e02dd32 chore: Reduce gunicorn worker count 2025-09-12 12:06:51 +08:00
shiyu
9bb5310df0 chore: Update version to v1.2.2 2025-09-11 21:15:48 +08:00
shiyu
427a4f023f feat: Add plugin center functionality 2025-09-11 21:11:17 +08:00
shiyu
71a2a88c8e feat: Add PDF viewer 2025-09-10 12:21:13 +08:00
shiyu
fb0b7b13d1 feat: Add Monaco editor support 2025-09-10 11:40:24 +08:00
shiyu
f484557874 refactor: clean up whitespace and improve readability in logging middleware 2025-09-10 10:58:42 +08:00
shiyu
2b8cfce8f2 chore: update version to v1.2.1 2025-09-09 16:56:26 +08:00
shiyu
db453ef09b feat: add i18n with language switcher and English/Chinese translations 2025-09-09 16:50:43 +08:00
shiyu
59c017a05b fix: URL format when generating links 2025-09-09 11:59:01 +08:00
shiyu
d42c6b5cee feat: Support more video formats 2025-09-08 19:15:09 +08:00
shiyu
9e69eb3e20 chore: update version to v1.2.0 2025-09-08 16:53:56 +08:00
shiyu
6e7225ac40 feat: implement Quark adapter 2025-09-08 16:51:09 +08:00
shiyu
d41b72d0ce feat: Add theme and dark mode 2025-09-08 15:20:49 +08:00
shiyu
f40ff4d751 feat: Add App Center plugin functionality 2025-09-08 12:28:37 +08:00
shiyu
280bedcf1a chore: Update version to v1.1.6 2025-09-07 17:00:25 +08:00
shiyu
b03f2619ca feat: Add vector database clearing 2025-09-07 16:48:14 +08:00
Kuenpan Foo
72403d5861 feat: Support Docker for ARM architecture(#35) 2025-09-07 16:46:18 +08:00
ShiYu
dffcdb7a8b feat: Add video playback and image preview support to share page 2025-09-07 11:05:10 +08:00
shiyu
19c4394f3d feat: Add queue management functionality to TasksPage 2025-09-06 19:44:00 +08:00
104 changed files with 5356 additions and 694 deletions

View File

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

3
.gitignore vendored
View File

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

View File

@@ -1,76 +1,76 @@
<div align="right">
<b>English</b> | <a href="./CONTRIBUTING_zh.md">简体中文</a>
</div>
# Contributing to Foxel
🎉 首先,非常感谢您愿意花时间为 Foxel 做出贡献!
We appreciate every minute you spend helping Foxel improve. This guide explains the contribution workflow so you can get started quickly.
我们热烈欢迎各种形式的贡献。无论是报告 Bug、提出新功能建议、完善文档还是直接提交代码都将对项目产生积极的影响。
## Table of Contents
本指南将帮助您顺利地参与到项目中来。
## 目录
- [如何贡献](#如何贡献)
- [🐛 报告 Bug](#-报告-bug)
- [✨ 提交功能建议](#-提交功能建议)
- [🛠️ 贡献代码](#-贡献代码)
- [开发环境搭建](#开发环境搭建)
- [依赖准备](#依赖准备)
- [后端 (FastAPI)](#后端-fastapi)
- [前端 (React + Vite)](#前端-react--vite)
- [代码贡献指南](#代码贡献指南)
- [贡献存储适配器 (Adapter)](#贡献存储适配器-adapter)
- [贡献前端应用 (App)](#贡献前端应用-app)
- [提交规范](#提交规范)
- [Git 分支管理](#git-分支管理)
- [Commit Message 格式](#commit-message-格式)
- [Pull Request 流程](#pull-request-流程)
- [How to Contribute](#how-to-contribute)
- [🐛 Report Bugs](#-report-bugs)
- [✨ Suggest Features](#-suggest-features)
- [🛠️ Contribute Code](#-contribute-code)
- [Development Environment](#development-environment)
- [Prerequisites](#prerequisites)
- [Backend (FastAPI)](#backend-fastapi)
- [Frontend (React + Vite)](#frontend-react--vite)
- [Contribution Guidelines](#contribution-guidelines)
- [Storage Adapters](#storage-adapters)
- [Frontend Apps](#frontend-apps)
- [Submission Rules](#submission-rules)
- [Git Branching](#git-branching)
- [Commit Message Format](#commit-message-format)
- [Pull Request Flow](#pull-request-flow)
---
## 如何贡献
## How to Contribute
### 🐛 报告 Bug
### 🐛 Report Bugs
如果您在使用的过程中发现了 Bug请通过 [GitHub Issues](https://github.com/DrizzleTime/Foxel/issues) 来报告。请在报告中提供以下信息:
If you discover a bug, open a ticket via [GitHub Issues](https://github.com/DrizzleTime/Foxel/issues) and include:
- **清晰的标题**:简明扼要地描述问题。
- **复现步骤**:详细说明如何一步步重现该 Bug
- **期望行为** vs **实际行为**:描述您预期的结果和实际发生的情况。
- **环境信息**例如操作系统、浏览器版本、Foxel 版本等。
- **A clear title** that summarises the problem.
- **Reproduction steps** with enough detail to trigger the bug.
- **Expected vs actual behaviour** to highlight the gap.
- **Environment details** such as operating system, browser version, and the Foxel build you used.
### ✨ 提交功能建议
### ✨ Suggest Features
我们欢迎任何关于新功能或改进的建议。请通过 [GitHub Issues](https://github.com/DrizzleTime/Foxel/issues) 创建一个 "Feature Request",并详细阐述您的想法:
To propose a new capability or an improvement, create an Issue and choose the "Feature Request" template. Document:
- **问题描述**:说明该功能要解决什么问题。
- **方案设想**:描述您希望该功能如何工作。
- **相关信息**:提供任何有助于理解您想法的截图、链接或参考。
- **Problem statement** what pain point will the feature solve?
- **Proposed solution** how you expect it to work.
- **Supporting material** screenshots, references, or related links if helpful.
### 🛠️ 贡献代码
### 🛠️ Contribute Code
如果您希望直接贡献代码,请参考下面的开发和提交流程。
Follow the development setup below before opening a pull request. Keep changes focused and small so they are easier to review.
## 开发环境搭建
## Development Environment
### 依赖准备
### Prerequisites
- **Git**: 用于版本控制。
- **Python**: >= 3.13
- **Bun**: 用于前端包管理和脚本运行。
Install the following tooling first:
### 后端 (FastAPI)
- **Git** for version control
- **Python** 3.13 or newer
- **Bun** for frontend package management and scripts
后端 API 服务基于 Python 和 FastAPI 构建。
### Backend (FastAPI)
1. **克隆仓库**
1. **Clone the repository**
```bash
git clone https://github.com/DrizzleTime/foxel.git
cd Foxel
```
2. **创建并激活 Python 虚拟环境**
2. **Create and activate a virtual environment**
我们推荐使用 `uv` 来管理虚拟环境,以获得最佳性能。
`uv` is recommended for performance and reproducibility:
```bash
uv venv
@@ -78,91 +78,85 @@
# On Windows: .venv\Scripts\activate
```
3. **安装依赖**
3. **Install dependencies**
```bash
uv sync
```
4. **初始化环境**
4. **Prepare local resources**
在启动服务前,请进行以下准备:
- Create the data directory:
- **创建数据目录**:
在项目根目录执行 `mkdir -p data/db`。这将创建用于存放数据库等文件的目录。
> [!IMPORTANT]
> 请确保应用拥有对 `data/db` 目录的读写权限。
```bash
mkdir -p data/db
```
- **创建 `.env` 配置文件**:
在项目根目录创建名为 `.env` 的文件,并填入以下内容。这些密钥用于保障应用安全,您可以按需修改。
Ensure the application user can read and write to `data/db`.
- Create an `.env` file in the project root and provide the required secrets. Replace the sample values with your own random strings:
```dotenv
SECRET_KEY=EnsRhL9NFPxgFVc+7t96/y70DIOR+9SpntcIqQa90TU=
TEMP_LINK_SECRET_KEY=EnsRhL9NFPxgFVc+7t96/y70DIOR+9SpntcIqQa90TU=
```
5. **启动开发服务器**
5. **Start the development server**
```bash
uvicorn main:app --reload --host 0.0.0.0 --port 8000
```
API 服务将在 `http://localhost:8000` 上运行,您可以通过 `http://localhost:8000/docs` 访问自动生成的 API 文档。
The API is available at `http://localhost:8000`, and the interactive docs live at `http://localhost:8000/docs`.
### 前端 (React + Vite)
### Frontend (React + Vite)
前端应用使用 React, Vite, 和 TypeScript 构建。
1. **进入前端目录**
1. **Enter the frontend directory**
```bash
cd web
```
2. **安装依赖**
2. **Install dependencies**
```bash
bun install
```
3. **启动开发服务器**
3. **Run the dev server**
```bash
bun run dev
```
前端开发服务器将在 `http://localhost:5173` 运行。它已经配置了代理,会自动将 `/api` 请求转发到后端服务。
The Vite dev server runs at `http://localhost:5173` and proxies `/api` requests to the backend.
## 代码贡献指南
## Contribution Guidelines
### 贡献存储适配器 (Adapter)
### Storage Adapters
存储适配器是 Foxel 的核心扩展点,用于接入不同的存储后端 (如 S3, FTP, Alist 等)。
Storage adapters integrate new storage providers (for example S3, FTP, or Alist).
1. **创建适配器文件**: 在 [`services/adapters/`](services/adapters/) 目录下,创建一个新文件,例如 `my_new_adapter.py`
2. **实现适配器类**:
- 创建一个类,继承自 [`services.adapters.base.BaseAdapter`](services/adapters/base.py)。
- 实现 `BaseAdapter` 中定义的所有抽象方法,如 `list_dir`, `get_meta`, `upload`, `download` 等。请仔细阅读基类中的文档注释以理解每个方法的作用和参数。
1. Create a new module under [`services/adapters/`](services/adapters/) (for example `my_new_adapter.py`).
2. Implement a class that inherits from [`services.adapters.base.BaseAdapter`](services/adapters/base.py) and provide concrete implementations for the abstract methods such as `list_dir`, `get_meta`, `upload`, and `download`.
### 贡献前端应用 (App)
### Frontend Apps
前端应用允许用户在浏览器中直接预览或编辑特定类型的文件。
Frontend apps enable in-browser previews or editors for specific file types.
1. **创建应用组件**: 在 [`web/src/apps/`](web/src/apps/) 目录下,为您的应用创建一个新的文件夹,并在其中创建 React 组件。
2. **定义应用类型**: 您的应用需要实现 [`web/src/apps/types.ts`](web/src/apps/types.ts) 中定义的 `FoxelApp` 接口。
3. **注册应用**: 在 [`web/src/apps/registry.ts`](web/src/apps/registry.ts) 中,导入您的应用组件,并将其添加到 `APP_REGISTRY`。在注册时,您需要指定该应用可以处理的文件类型(通过 MIME Type 或文件扩展名)。
1. Add a new folder in [`web/src/apps/`](web/src/apps/) for your app and expose a React component.
2. Implement the `FoxelApp` interface defined in [`web/src/apps/types.ts`](web/src/apps/types.ts).
3. Register the app in [`web/src/apps/registry.ts`](web/src/apps/registry.ts) and declare the MIME types or extensions it supports.
## 提交规范
## Submission Rules
### Git 分支管理
### Git Branching
- 从最新的 `main` 分支创建您的特性分支。
Start your work from the latest `main` branch and push feature changes on a dedicated branch.
### Commit Message 格式
### Commit Message Format
我们遵循 [Conventional Commits](https://www.conventionalcommits.org/) 规范。这有助于自动化生成更新日志和版本管理。
Commit Message 格式如下:
We follow the [Conventional Commits](https://www.conventionalcommits.org/) specification to drive release tooling.
```
<type>(<scope>): <subject>
@@ -172,27 +166,27 @@ Commit Message 格式如下:
<footer>
```
- **type**: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore` 等。
- **scope**: (可选) 本次提交影响的范围,例如 `adapter`, `ui`, `api`
- **subject**: 简明扼要的描述。
- **type**: e.g. `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`.
- **scope** (optional): the area impacted by the change, such as `adapter`, `ui`, or `api`.
- **subject**: a concise summary written in the imperative mood.
**示例:**
**Examples:**
```
feat(adapter): Add support for Alist storage
feat(adapter): add support for Alist storage
```
```
fix(ui): Correct display issue in file list view
fix(ui): correct display issue in file list view
```
### Pull Request 流程
### Pull Request Flow
1. Fork 仓库并克隆到本地。
2. 创建并切换到您的特性分支。
3. 完成代码编写和测试。
4. 将您的分支推送到您的 Fork 仓库。
5. 在 Foxel 主仓库创建一个 Pull Request,目标分支为 `main`。
6. 在 PR 描述中清晰地说明您的更改内容、目的和任何相关的 Issue 编号。
1. Fork the repository and clone it locally.
2. Create and switch to your feature branch.
3. Implement the change and run relevant checks.
4. Push the branch to your fork.
5. Open a pull request against `main` in the Foxel repository.
6. Explain the change set, its motivation, and reference related Issues in the PR description.
项目维护者会尽快审查您的 PR。感谢您的耐心和贡献
Maintainers will review your pull request as soon as possible.

202
CONTRIBUTING_zh.md Normal file
View File

@@ -0,0 +1,202 @@
<div align="right">
<a href="./CONTRIBUTING.md">English</a> | <b>简体中文</b>
</div>
# Contributing to Foxel
🎉 首先,非常感谢您愿意花时间为 Foxel 做出贡献!
我们热烈欢迎各种形式的贡献。无论是报告 Bug、提出新功能建议、完善文档还是直接提交代码都将对项目产生积极的影响。
本指南将帮助您顺利地参与到项目中来。
## 目录
- [如何贡献](#如何贡献)
- [🐛 报告 Bug](#-报告-bug)
- [✨ 提交功能建议](#-提交功能建议)
- [🛠️ 贡献代码](#-贡献代码)
- [开发环境搭建](#开发环境搭建)
- [依赖准备](#依赖准备)
- [后端 (FastAPI)](#后端-fastapi)
- [前端 (React + Vite)](#前端-react--vite)
- [代码贡献指南](#代码贡献指南)
- [贡献存储适配器 (Adapter)](#贡献存储适配器-adapter)
- [贡献前端应用 (App)](#贡献前端应用-app)
- [提交规范](#提交规范)
- [Git 分支管理](#git-分支管理)
- [Commit Message 格式](#commit-message-格式)
- [Pull Request 流程](#pull-request-流程)
---
## 如何贡献
### 🐛 报告 Bug
如果您在使用的过程中发现了 Bug请通过 [GitHub Issues](https://github.com/DrizzleTime/Foxel/issues) 来报告。请在报告中提供以下信息:
- **清晰的标题**:简明扼要地描述问题。
- **复现步骤**:详细说明如何一步步重现该 Bug。
- **期望行为** vs **实际行为**:描述您预期的结果和实际发生的情况。
- **环境信息**例如操作系统、浏览器版本、Foxel 版本等。
### ✨ 提交功能建议
我们欢迎任何关于新功能或改进的建议。请通过 [GitHub Issues](https://github.com/DrizzleTime/Foxel/issues) 创建一个 "Feature Request",并详细阐述您的想法:
- **问题描述**:说明该功能要解决什么问题。
- **方案设想**:描述您希望该功能如何工作。
- **相关信息**:提供任何有助于理解您想法的截图、链接或参考。
### 🛠️ 贡献代码
如果您希望直接贡献代码,请参考下面的开发和提交流程。
## 开发环境搭建
### 依赖准备
- **Git**: 用于版本控制。
- **Python**: >= 3.13
- **Bun**: 用于前端包管理和脚本运行。
### 后端 (FastAPI)
后端 API 服务基于 Python 和 FastAPI 构建。
1. **克隆仓库**
```bash
git clone https://github.com/DrizzleTime/foxel.git
cd Foxel
```
2. **创建并激活 Python 虚拟环境**
我们推荐使用 `uv` 来管理虚拟环境,以获得最佳性能。
```bash
uv venv
source .venv/bin/activate
# On Windows: .venv\Scripts\activate
```
3. **安装依赖**
```bash
uv sync
```
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
```
API 服务将在 `http://localhost:8000` 上运行,您可以通过 `http://localhost:8000/docs` 访问自动生成的 API 文档。
### 前端 (React + Vite)
前端应用使用 React, Vite, 和 TypeScript 构建。
1. **进入前端目录**
```bash
cd web
```
2. **安装依赖**
```bash
bun install
```
3. **启动开发服务器**
```bash
bun run dev
```
前端开发服务器将在 `http://localhost:5173` 运行。它已经配置了代理,会自动将 `/api` 请求转发到后端服务。
## 代码贡献指南
### 贡献存储适配器 (Adapter)
存储适配器是 Foxel 的核心扩展点,用于接入不同的存储后端 (如 S3, FTP, Alist 等)。
1. **创建适配器文件**: 在 [`services/adapters/`](services/adapters/) 目录下,创建一个新文件,例如 `my_new_adapter.py`。
2. **实现适配器类**:
- 创建一个类,继承自 [`services.adapters.base.BaseAdapter`](services/adapters/base.py)。
- 实现 `BaseAdapter` 中定义的所有抽象方法,如 `list_dir`, `get_meta`, `upload`, `download` 等。请仔细阅读基类中的文档注释以理解每个方法的作用和参数。
### 贡献前端应用 (App)
前端应用允许用户在浏览器中直接预览或编辑特定类型的文件。
1. **创建应用组件**: 在 [`web/src/apps/`](web/src/apps/) 目录下,为您的应用创建一个新的文件夹,并在其中创建 React 组件。
2. **定义应用类型**: 您的应用需要实现 [`web/src/apps/types.ts`](web/src/apps/types.ts) 中定义的 `FoxelApp` 接口。
3. **注册应用**: 在 [`web/src/apps/registry.ts`](web/src/apps/registry.ts) 中,导入您的应用组件,并将其添加到 `APP_REGISTRY`。在注册时,您需要指定该应用可以处理的文件类型(通过 MIME Type 或文件扩展名)。
## 提交规范
### Git 分支管理
- 从最新的 `main` 分支创建您的特性分支。
### Commit Message 格式
我们遵循 [Conventional Commits](https://www.conventionalcommits.org/) 规范。这有助于自动化生成更新日志和版本管理。
Commit Message 格式如下:
```
<type>(<scope>): <subject>
<BLANK LINE>
<body>
<BLANK LINE>
<footer>
```
- **type**: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore` 等。
- **scope**: (可选) 本次提交影响的范围,例如 `adapter`, `ui`, `api`。
- **subject**: 简明扼要的描述。
**示例:**
```
feat(adapter): Add support for Alist storage
```
```
fix(ui): Correct display issue in file list view
```
### Pull Request 流程
1. Fork 仓库并克隆到本地。
2. 创建并切换到您的特性分支。
3. 完成代码编写和测试。
4. 将您的分支推送到您的 Fork 仓库。
5. 在 Foxel 主仓库创建一个 Pull Request目标分支为 `main`。
6. 在 PR 描述中清晰地说明您的更改内容、目的和任何相关的 Issue 编号。
项目维护者会尽快审查您的 PR。感谢您的耐心和贡献

View File

@@ -27,6 +27,9 @@ COPY . .
COPY nginx.conf /etc/nginx/nginx.conf
RUN mkdir -p data/db data/mount && \
chmod 777 data/db data/mount
EXPOSE 80
COPY entrypoint.sh /entrypoint.sh

View File

@@ -73,7 +73,7 @@ chmod 777 data/db data/mount
We welcome contributions from the community! Whether it's submitting bugs, suggesting new features, or contributing code directly.
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.
Before you start, please read our [`CONTRIBUTING.md`](CONTRIBUTING.md) file, which explains the development environment and submission process. A Simplified Chinese translation is available in [`CONTRIBUTING_zh.md`](CONTRIBUTING_zh.md).
## 🌐 Community

View File

@@ -74,7 +74,7 @@ chmod 777 data/db data/mount
我们非常欢迎来自社区的贡献!无论是提交 Bug、建议新功能还是直接贡献代码。
在开始之前,请先阅读我们的 [`CONTRIBUTING.md`](CONTRIBUTING.md) 文件,它会指导你如何设置开发环境以及提交流程。
在开始之前,请先阅读我们的 [`CONTRIBUTING_zh.md`](CONTRIBUTING_zh.md) 文件,它会指导你如何设置开发环境以及提交流程。
## 🌐 社区

View File

@@ -1,6 +1,8 @@
from fastapi import FastAPI
from .routes import adapters, virtual_fs, 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 webdav
from .routes import plugins
def include_routers(app: FastAPI):
@@ -14,4 +16,7 @@ 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)
app.include_router(webdav.router)

View File

@@ -1,5 +1,6 @@
from typing import Annotated
from fastapi import APIRouter, HTTPException, Depends, Form
import hashlib
from fastapi.security import OAuth2PasswordRequestForm
from services.auth import (
authenticate_user_db,
@@ -7,10 +8,14 @@ from services.auth import (
ACCESS_TOKEN_EXPIRE_MINUTES,
register_user,
Token,
get_current_active_user,
User,
)
from pydantic import BaseModel
from datetime import timedelta
from api.response import success
from models.database import UserAccount
from services.auth import verify_password, get_password_hash
router = APIRouter(prefix="/api/auth", tags=["auth"])
@@ -21,6 +26,7 @@ class RegisterRequest(BaseModel):
email: str | None = None
full_name: str | None = None
@router.post("/register", summary="注册第一个管理员用户")
async def register(data: RegisterRequest):
"""
@@ -51,3 +57,66 @@ async def login_for_access_token(
data={"sub": user.username}, expires_delta=access_token_expires
)
return Token(access_token=access_token, token_type="bearer")
@router.get("/me", summary="获取当前登录用户信息")
async def get_me(current_user: Annotated[User, Depends(get_current_active_user)]):
"""
返回当前登录用户的基本信息,并附带 gravatar 头像链接。
"""
email = (current_user.email or "").strip().lower()
md5_hash = hashlib.md5(email.encode("utf-8")).hexdigest()
gravatar_url = f"https://www.gravatar.com/avatar/{md5_hash}?s=64&d=identicon"
return success({
"id": current_user.id,
"username": current_user.username,
"email": current_user.email,
"full_name": current_user.full_name,
"gravatar_url": gravatar_url,
})
class UpdateMeRequest(BaseModel):
email: str | None = None
full_name: str | None = None
old_password: str | None = None
new_password: str | None = None
@router.put("/me", summary="更新当前登录用户信息")
async def update_me(
payload: UpdateMeRequest,
current_user: Annotated[User, Depends(get_current_active_user)],
):
db_user = await UserAccount.get_or_none(id=current_user.id)
if not db_user:
raise HTTPException(status_code=404, detail="用户不存在")
if payload.email is not None:
exists = await UserAccount.filter(email=payload.email).exclude(id=db_user.id).exists()
if exists:
raise HTTPException(status_code=400, detail="邮箱已被占用")
db_user.email = payload.email
if payload.full_name is not None:
db_user.full_name = payload.full_name
if payload.new_password:
if not payload.old_password:
raise HTTPException(status_code=400, detail="请提供原密码")
if not verify_password(payload.old_password, db_user.hashed_password):
raise HTTPException(status_code=400, detail="原密码错误")
db_user.hashed_password = get_password_hash(payload.new_password)
await db_user.save()
email = (db_user.email or "").strip().lower()
md5_hash = hashlib.md5(email.encode("utf-8")).hexdigest()
gravatar_url = f"https://cn.cravatar.com/avatar/{md5_hash}?s=64&d=identicon"
return success({
"id": db_user.id,
"username": db_user.username,
"email": db_user.email,
"full_name": db_user.full_name,
"gravatar_url": gravatar_url,
})

View File

@@ -1,10 +1,11 @@
import httpx
import time
from fastapi import APIRouter, Depends, Form
from fastapi import APIRouter, Depends, Form, HTTPException
from typing import Annotated
from services.config import ConfigCenter, VERSION
from services.auth import get_current_active_user, User, has_users
from api.response import success
from services.vector_db import VectorDBService
router = APIRouter(prefix="/api/config", tags=["config"])
@@ -23,8 +24,27 @@ async def set_config(
key: str = Form(...),
value: str = Form(...)
):
await ConfigCenter.set(key, value)
return success({"key": key, "value": value})
original_value = await ConfigCenter.get(key)
value_to_save = value
if key == "AI_EMBED_DIM":
try:
parsed_value = int(value)
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="AI_EMBED_DIM must be an integer")
if parsed_value <= 0:
raise HTTPException(status_code=400, detail="AI_EMBED_DIM must be greater than zero")
value_to_save = str(parsed_value)
await ConfigCenter.set(key, value_to_save)
if key == "AI_EMBED_DIM" and str(original_value) != value_to_save:
try:
service = VectorDBService()
service.clear_all_data()
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Failed to clear vector database: {exc}")
return success({"key": key, "value": value_to_save})
@router.get("/all")

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

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

View File

@@ -83,6 +83,18 @@ async def get_my_shares(current_user: User = Depends(get_current_active_user)):
return [ShareInfo.from_orm(s) for s in shares]
@router.delete("/expired")
async def delete_expired_shares(
current_user: User = Depends(get_current_active_user),
):
"""
删除当前用户的所有已过期分享。
"""
user_account = await UserAccount.get(id=current_user.id)
deleted_count = await share_service.delete_expired_shares(user=user_account)
return success({"deleted_count": deleted_count})
@router.delete("/{share_id}")
async def delete_share(
share_id: int,

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

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

273
api/routes/webdav.py Normal file
View File

@@ -0,0 +1,273 @@
from __future__ import annotations
import base64
import hashlib
import mimetypes
from email.utils import formatdate
from urllib.parse import urlparse, unquote
from typing import Optional
from fastapi import APIRouter, Request, Response, HTTPException, Depends
import xml.etree.ElementTree as ET
from services.auth import authenticate_user_db, User, UserInDB
from services.virtual_fs import (
list_virtual_dir,
stat_file,
write_file_stream,
make_dir,
delete_path,
move_path,
copy_path,
stream_file,
)
router = APIRouter(prefix="/webdav", tags=["webdav"])
def _dav_headers(extra: Optional[dict] = None) -> dict:
headers = {
"DAV": "1",
"MS-Author-Via": "DAV",
"Accept-Ranges": "bytes",
"Allow": ", ".join([
"OPTIONS",
"PROPFIND",
"GET",
"HEAD",
"PUT",
"DELETE",
"MKCOL",
"MOVE",
"COPY",
]),
}
if extra:
headers.update(extra)
return headers
async def _get_basic_user(request: Request) -> User:
auth = request.headers.get("Authorization", "")
if not auth:
raise HTTPException(401, detail="Unauthorized", headers={"WWW-Authenticate": "Basic realm=webdav"})
scheme, _, param = auth.partition(" ")
scheme_lower = scheme.lower()
if scheme_lower == "basic":
try:
decoded = base64.b64decode(param).decode("utf-8")
username, _, password = decoded.partition(":")
except Exception:
raise HTTPException(401, detail="Invalid Basic auth", headers={"WWW-Authenticate": "Basic realm=webdav"})
user_or_false: Optional[UserInDB] = await authenticate_user_db(username, password)
if not user_or_false:
raise HTTPException(401, detail="Invalid credentials", headers={"WWW-Authenticate": "Basic realm=webdav"})
u: UserInDB = user_or_false
return User(id=u.id, username=u.username, email=u.email, full_name=u.full_name, disabled=u.disabled)
elif scheme_lower == "bearer":
if not param:
raise HTTPException(401, detail="Invalid Bearer token")
return User(id=0, username="bearer", email=None, full_name=None, disabled=False)
else:
raise HTTPException(401, detail="Unsupported auth", headers={"WWW-Authenticate": "Basic realm=webdav"})
def _httpdate(ts: int | float) -> str:
return formatdate(ts, usegmt=True)
def _etag(path: str, size: int | None, mtime: int | None) -> str:
raw = f"{path}|{size or 0}|{mtime or 0}".encode("utf-8")
return '"' + hashlib.md5(raw).hexdigest() + '"'
def _href_for(path: str, is_dir: bool) -> str:
from urllib.parse import quote
p = "/webdav" + (path if path.startswith("/") else "/" + path)
if is_dir and not p.endswith("/"):
p += "/"
return quote(p)
def _build_prop_response(path: str, name: str, is_dir: bool, size: Optional[int], mtime: Optional[int], content_type: Optional[str]):
ns = "{DAV:}"
resp = ET.Element(ns + "response")
href = ET.SubElement(resp, ns + "href")
href.text = _href_for(path, is_dir)
propstat = ET.SubElement(resp, ns + "propstat")
prop = ET.SubElement(propstat, ns + "prop")
displayname = ET.SubElement(prop, ns + "displayname")
displayname.text = name
resourcetype = ET.SubElement(prop, ns + "resourcetype")
if is_dir:
ET.SubElement(resourcetype, ns + "collection")
if not is_dir:
if size is not None:
gcl = ET.SubElement(prop, ns + "getcontentlength")
gcl.text = str(size)
if content_type:
gct = ET.SubElement(prop, ns + "getcontenttype")
gct.text = content_type
if mtime is not None:
glm = ET.SubElement(prop, ns + "getlastmodified")
glm.text = _httpdate(mtime)
etag = ET.SubElement(prop, ns + "getetag")
etag.text = _etag(path, size, mtime)
status = ET.SubElement(propstat, ns + "status")
status.text = "HTTP/1.1 200 OK"
return resp
def _multistatus_xml(responses: list[ET.Element]) -> bytes:
ns = "{DAV:}"
ms = ET.Element(ns + "multistatus")
for r in responses:
ms.append(r)
return ET.tostring(ms, encoding="utf-8", xml_declaration=True)
def _normalize_fs_path(path: str) -> str:
full = "/" + path if not path.startswith("/") else path
return unquote(full)
@router.options("/{path:path}")
async def options_root(path: str = ""):
return Response(status_code=200, headers=_dav_headers())
@router.api_route("/{path:path}", methods=["PROPFIND"])
async def propfind(request: Request, path: str, user: User = Depends(_get_basic_user)):
full_path = _normalize_fs_path(path)
depth = request.headers.get("Depth", "1").lower()
if depth not in ("0", "1", "infinity"):
depth = "1"
responses: list[ET.Element] = []
# 先获取当前路径信息
try:
st = await stat_file(full_path)
is_dir = bool(st.get("is_dir"))
name = st.get("name") or full_path.rsplit("/", 1)[-1] or "/"
size = None if is_dir else int(st.get("size", 0))
mtime = int(st.get("mtime", 0)) if st.get("mtime") is not None else None
ctype = None if is_dir else (mimetypes.guess_type(name)[0] or "application/octet-stream")
responses.append(_build_prop_response(full_path, name, is_dir, size, mtime, ctype))
except FileNotFoundError:
raise HTTPException(404, detail="Not found")
if depth in ("1", "infinity"):
try:
listing = await list_virtual_dir(full_path, page_num=1, page_size=1000)
for ent in listing["items"]:
is_dir = bool(ent.get("is_dir"))
name = ent.get("name")
child_path = full_path.rstrip("/") + "/" + name
size = None if is_dir else int(ent.get("size", 0))
mtime = int(ent.get("mtime", 0)) if ent.get("mtime") is not None else None
ctype = None if is_dir else (mimetypes.guess_type(name)[0] or "application/octet-stream")
responses.append(_build_prop_response(child_path, name, is_dir, size, mtime, ctype))
except HTTPException as e:
if e.status_code == 400:
pass
else:
raise
xml = _multistatus_xml(responses)
return Response(content=xml, status_code=207, media_type='application/xml; charset="utf-8"', headers=_dav_headers())
@router.get("/{path:path}")
async def dav_get(path: str, request: Request, user: User = Depends(_get_basic_user)):
full_path = _normalize_fs_path(path)
range_header = request.headers.get("Range")
return await stream_file(full_path, range_header)
@router.head("/{path:path}")
async def dav_head(path: str, user: User = Depends(_get_basic_user)):
full_path = _normalize_fs_path(path)
try:
st = await stat_file(full_path)
except FileNotFoundError:
raise HTTPException(404, detail="Not found")
is_dir = bool(st.get("is_dir"))
headers = _dav_headers()
if not is_dir:
size = int(st.get("size", 0))
name = st.get("name") or full_path.rsplit("/", 1)[-1]
ctype = mimetypes.guess_type(name)[0] or "application/octet-stream"
mtime = int(st.get("mtime", 0)) if st.get("mtime") is not None else None
headers.update({
"Content-Length": str(size),
"Content-Type": ctype,
"ETag": _etag(full_path, size, mtime),
})
return Response(status_code=200, headers=headers)
@router.api_route("/{path:path}", methods=["PUT"])
async def dav_put(path: str, request: Request, user: User = Depends(_get_basic_user)):
full_path = _normalize_fs_path(path)
async def body_iter():
async for chunk in request.stream():
if chunk:
yield chunk
size = await write_file_stream(full_path, body_iter(), overwrite=True)
return Response(status_code=201, headers=_dav_headers({"Content-Length": "0"}))
@router.api_route("/{path:path}", methods=["DELETE"])
async def dav_delete(path: str, user: User = Depends(_get_basic_user)):
full_path = _normalize_fs_path(path)
await delete_path(full_path)
return Response(status_code=204, headers=_dav_headers())
@router.api_route("/{path:path}", methods=["MKCOL"])
async def dav_mkcol(path: str, user: User = Depends(_get_basic_user)):
full_path = _normalize_fs_path(path)
await make_dir(full_path)
return Response(status_code=201, headers=_dav_headers())
def _parse_destination(dest: str) -> str:
if not dest:
raise HTTPException(400, detail="Missing Destination header")
p = urlparse(dest)
path = p.path if p.scheme else dest
if path.startswith("/webdav"):
rel = path[len("/webdav"):]
else:
rel = path
return _normalize_fs_path(rel)
@router.api_route("/{path:path}", methods=["MOVE"])
async def dav_move(path: str, request: Request, user: User = Depends(_get_basic_user)):
full_src = _normalize_fs_path(path)
dest_header = request.headers.get("Destination")
dst = _parse_destination(dest_header or "")
overwrite = request.headers.get("Overwrite", "T").upper() != "F"
await move_path(full_src, dst, overwrite=overwrite)
return Response(status_code=204, headers=_dav_headers())
@router.api_route("/{path:path}", methods=["COPY"])
async def dav_copy(path: str, request: Request, user: User = Depends(_get_basic_user)):
full_src = _normalize_fs_path(path)
dest_header = request.headers.get("Destination")
dst = _parse_destination(dest_header or "")
overwrite = request.headers.get("Overwrite", "T").upper() != "F"
await copy_path(full_src, dst, overwrite=overwrite)
return Response(status_code=201 if not overwrite else 204, headers=_dav_headers())

View File

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

View File

@@ -2,4 +2,4 @@
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
exec gunicorn -k uvicorn.workers.UvicornWorker -w 2 -b 0.0.0.0:8000 main:app

View File

@@ -1,3 +1,4 @@
import os
from services.config import VERSION, ConfigCenter
from services.adapters.registry import runtime_registry
from fastapi.middleware.cors import CORSMiddleware
@@ -15,6 +16,7 @@ load_dotenv()
@asynccontextmanager
async def lifespan(app: FastAPI):
os.makedirs("data/db", exist_ok=True)
await init_db()
await runtime_registry.refresh()
await ConfigCenter.set("APP_VERSION", VERSION)
@@ -29,7 +31,7 @@ async def lifespan(app: FastAPI):
def create_app() -> FastAPI:
app = FastAPI(
title="Foxel",
description="AList-like virtual storage aggregator",
description="A highly extensible private cloud storage solution for individuals and teams",
lifespan=lifespan,
)
include_routers(app)

View File

@@ -81,3 +81,29 @@ class ShareLink(Model):
class Meta:
table = "share_links"
class Plugin(Model):
id = fields.IntField(pk=True)
url = fields.CharField(max_length=2048)
enabled = fields.BooleanField(default=True)
key = fields.CharField(max_length=100, null=True)
name = fields.CharField(max_length=255, null=True)
version = fields.CharField(max_length=50, null=True)
supported_exts = fields.JSONField(null=True)
default_bounds = fields.JSONField(null=True)
default_maximized = fields.BooleanField(null=True)
icon = fields.CharField(max_length=2048, null=True)
description = fields.TextField(null=True)
author = fields.CharField(max_length=255, null=True)
website = fields.CharField(max_length=2048, null=True)
github = fields.CharField(max_length=2048, null=True)
created_at = fields.DatetimeField(auto_now_add=True)
updated_at = fields.DatetimeField(auto_now=True)
class Meta:
table = "plugins"

View File

@@ -1,7 +1,10 @@
from schemas.plugins import PluginCreate,PluginOut
from .adapters import AdapterCreate, AdapterOut
from .fs import MkdirRequest, MoveRequest
__all__ = [
"PluginOut"
"PluginCreate"
"AdapterCreate",
"AdapterOut",
"MkdirRequest",

27
schemas/plugins.py Normal file
View File

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

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

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

View File

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

View File

@@ -5,11 +5,18 @@ from services.logging import LogService
from models.database import UserAccount
import jwt
from jwt.exceptions import InvalidTokenError
from services.auth import ALGORITHM
from services.auth import ALGORITHM
from services.config import ConfigCenter
class LoggingMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
path = request.url.path
method = request.method.upper()
if method == "GET":
if path == "/api/logs" or path == "/api/plugins" or path.startswith("/api/config"):
return await call_next(request)
start_time = time.time()
user_id = None
if "authorization" in request.headers:
@@ -27,9 +34,9 @@ class LoggingMiddleware(BaseHTTPMiddleware):
pass
response = await call_next(request)
process_time = (time.time() - start_time) * 1000
details = {
"client_ip": request.client.host,
"method": request.method,
@@ -38,9 +45,9 @@ class LoggingMiddleware(BaseHTTPMiddleware):
"status_code": response.status_code,
"process_time_ms": round(process_time, 2)
}
message = f"{request.method} {request.url.path} - {response.status_code}"
await LogService.api(message, details, user_id)
return response
return response

View File

@@ -2,8 +2,9 @@ from typing import Dict, Any
from fastapi.responses import Response
import base64
from services.ai import describe_image_base64, get_text_embedding
from services.vector_db import VectorDBService
from services.vector_db import VectorDBService, DEFAULT_VECTOR_DIMENSION
from services.logging import LogService
from services.config import ConfigCenter
class VectorIndexProcessor:
@@ -71,7 +72,15 @@ class VectorIndexProcessor:
if embedding is None:
return Response(content="不支持的文件类型", status_code=400)
vector_db.ensure_collection(collection_name, vector=True)
raw_dim = await ConfigCenter.get('AI_EMBED_DIM', DEFAULT_VECTOR_DIMENSION)
try:
vector_dim = int(raw_dim)
except (TypeError, ValueError):
vector_dim = DEFAULT_VECTOR_DIMENSION
if vector_dim <= 0:
vector_dim = DEFAULT_VECTOR_DIMENSION
vector_db.ensure_collection(collection_name, vector=True, dim=vector_dim)
vector_db.upsert_vector(
collection_name, {'path': path, 'embedding': embedding})

View File

@@ -90,6 +90,16 @@ class ShareService:
raise HTTPException(status_code=404, detail="分享链接不存在")
await share.delete()
@staticmethod
async def delete_expired_shares(user: UserAccount) -> int:
"""
删除当前用户所有已过期的分享链接,返回删除数量。
条件expires_at 非空 且 小于等于当前时间UTC
"""
now = datetime.now(timezone.utc)
deleted_count = await ShareLink.filter(user=user, expires_at__lte=now).delete()
return deleted_count
@staticmethod
async def get_shared_item_details(share: ShareLink, sub_path: str = ""):
"""
@@ -122,4 +132,4 @@ class ShareService:
raise e
share_service = ShareService()
share_service = ShareService()

View File

@@ -1,6 +1,9 @@
from pymilvus import CollectionSchema, DataType, FieldSchema, MilvusClient
DEFAULT_VECTOR_DIMENSION = 4096
class VectorDBService:
_instance = None
@@ -13,15 +16,21 @@ class VectorDBService:
if not hasattr(self, 'client'):
self.client = MilvusClient("data/db/milvus.db")
def ensure_collection(self, collection_name, vector: bool = True):
def ensure_collection(self, collection_name, vector: bool = True, dim: int = DEFAULT_VECTOR_DIMENSION):
if self.client.has_collection(collection_name):
return
if vector:
try:
vector_dim = int(dim)
except (TypeError, ValueError):
vector_dim = DEFAULT_VECTOR_DIMENSION
if vector_dim <= 0:
vector_dim = DEFAULT_VECTOR_DIMENSION
fields = [
FieldSchema(name="path", dtype=DataType.VARCHAR,
max_length=512, is_primary=True, auto_id=False),
FieldSchema(name="embedding",
dtype=DataType.FLOAT_VECTOR, dim=4096)
dtype=DataType.FLOAT_VECTOR, dim=vector_dim)
]
schema = CollectionSchema(
fields, description="Image vector collection")
@@ -75,3 +84,9 @@ class VectorDBService:
output_fields=["path"]
)
return [[{'id': r['path'], 'distance': 1.0, 'entity': {'path': r['path']}} for r in results]]
def clear_all_data(self):
"""清空所有集合的内容"""
collections = self.client.list_collections()
for collection_name in collections:
self.client.drop_collection(collection_name)

View File

@@ -6,10 +6,12 @@
"dependencies": {
"@ant-design/icons": "5.x",
"@ant-design/v5-patch-for-react-19": "^1.0.3",
"@monaco-editor/react": "^4.7.0",
"@uiw/react-md-editor": "^4.0.8",
"antd": "^5.27.0",
"artplayer": "^5.2.5",
"date-fns": "^4.1.0",
"monaco-editor": "^0.53.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-markdown": "^10.1.0",
@@ -179,6 +181,10 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.30", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q=="],
"@monaco-editor/loader": ["@monaco-editor/loader@1.5.0", "", { "dependencies": { "state-local": "^1.0.6" } }, "sha512-hKoGSM+7aAc7eRTRjpqAZucPmoNOC4UUbknb/VNoTkEIkCPhqV8LfbsgM1webRM7S/z21eHEx9Fkwx8Z/C/+Xw=="],
"@monaco-editor/react": ["@monaco-editor/react@4.7.0", "", { "dependencies": { "@monaco-editor/loader": "^1.5.0" }, "peerDependencies": { "monaco-editor": ">= 0.25.0 < 1", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
@@ -273,6 +279,8 @@
"@types/react-dom": ["@types/react-dom@19.1.7", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw=="],
"@types/trusted-types": ["@types/trusted-types@1.0.6", "", {}, "sha512-230RC8sFeHoT6sSUlRO6a8cAnclO06eeiq1QDfiv2FGCLWFvvERWgwIQD4FWqD9A69BN7Lzee4OXwoMVnnsWDw=="],
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.39.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.39.1", "@typescript-eslint/type-utils": "8.39.1", "@typescript-eslint/utils": "8.39.1", "@typescript-eslint/visitor-keys": "8.39.1", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.39.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-yYegZ5n3Yr6eOcqgj2nJH8cH/ZZgF+l0YIdKILSDjYFRjgYQMgv/lRjV5Z7Up04b9VYUondt8EPMqg7kTWgJ2g=="],
@@ -641,6 +649,8 @@
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"monaco-editor": ["monaco-editor@0.53.0", "", { "dependencies": { "@types/trusted-types": "^1.0.6" } }, "sha512-0WNThgC6CMWNXXBxTbaYYcunj08iB5rnx4/G56UOPeL9UVIUGGHA1GR0EWIh9Ebabj7NpCRawQ5b0hfN1jQmYQ=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
@@ -823,6 +833,8 @@
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
"state-local": ["state-local@1.0.7", "", {}, "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w=="],
"string-convert": ["string-convert@0.2.1", "", {}, "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A=="],
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],

View File

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

View File

@@ -12,10 +12,12 @@
"dependencies": {
"@ant-design/icons": "5.x",
"@ant-design/v5-patch-for-react-19": "^1.0.3",
"@monaco-editor/react": "^4.7.0",
"@uiw/react-md-editor": "^4.0.8",
"antd": "^5.27.0",
"artplayer": "^5.2.5",
"date-fns": "^4.1.0",
"monaco-editor": "^0.53.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-markdown": "^10.1.0",

View File

@@ -4,11 +4,13 @@ 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 { ThemeProvider } from './contexts/ThemeContext.tsx';
import { Spin } from 'antd';
import { Routes, Route, Navigate } from 'react-router';
import SetupPage from './pages/SetupPage.tsx';
import { I18nProvider } from './i18n';
function App() {
function AppInner() {
const [status, setStatus] = useState<SystemStatus | null>(null);
useEffect(() => {
async function checkInitialization() {
@@ -38,17 +40,25 @@ function App() {
return (
<SystemContext.Provider value={status}>
<AuthProvider>
{!status.is_initialized ? (
<Routes>
<Route path="/setup" element={<SetupPage />} />
<Route path="*" element={<Navigate to="/setup" replace />} />
</Routes>
) : (
<AppRouter />
)}
<ThemeProvider>
{!status.is_initialized ? (
<Routes>
<Route path="/setup" element={<SetupPage />} />
<Route path="*" element={<Navigate to="/setup" replace />} />
</Routes>
) : (
<AppRouter />
)}
</ThemeProvider>
</AuthProvider>
</SystemContext.Provider>
);
}
export default App;
export default function App() {
return (
<I18nProvider>
<AppInner />
</I18nProvider>
);
}

View File

@@ -17,6 +17,21 @@ export interface AuthResponse {
token_type: string;
}
export interface MeResponse {
id: number;
username: string;
email?: string | null;
full_name?: string | null;
gravatar_url: string;
}
export interface UpdateMePayload {
email?: string | null;
full_name?: string | null;
old_password?: string;
new_password?: string;
}
export const authApi = {
register: async (username: string, password: string, email?: string, full_name?: string): Promise<any> => {
return request('/auth/register', {
@@ -42,4 +57,15 @@ export const authApi = {
logout: () => {
localStorage.removeItem('token');
},
me: async () => {
return await request<MeResponse>('/auth/me', {
method: 'GET',
});
},
updateMe: async (payload: UpdateMePayload) => {
return await request<MeResponse>('/auth/me', {
method: 'PUT',
json: payload,
});
},
};

View File

@@ -0,0 +1,52 @@
export interface RepoItem {
key: string;
name: string;
version: string;
author?: string;
description?: string;
website?: string;
github?: string;
icon?: string;
supportedExts?: string[];
createdAt?: number;
downloads?: number;
directUrl: string;
}
export interface RepoListResponse {
items: RepoItem[];
total: number;
page: number;
pageSize: number;
}
export interface RepoQueryParams {
query?: string;
author?: string;
sort?: 'downloads' | 'createdAt';
page?: number;
pageSize?: number;
}
const CENTER_BASE = 'https://center.foxel.cc';
export function buildCenterUrl(path: string) {
return new URL(path, CENTER_BASE).href;
}
export async function fetchRepoList(params: RepoQueryParams = {}): Promise<RepoListResponse> {
const query = new URLSearchParams();
if (params.query) query.set('query', params.query);
if (params.author) query.set('author', params.author);
if (params.sort) query.set('sort', params.sort);
query.set('page', String(params.page ?? 1));
query.set('pageSize', String(params.pageSize ?? 12));
const url = `${CENTER_BASE}/api/repo?${query.toString()}`;
const resp = await fetch(url);
if (!resp.ok) {
throw new Error(`Repo fetch failed: ${resp.status}`);
}
return await resp.json();
}

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

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

View File

@@ -23,10 +23,15 @@ export interface ShareCreatePayload {
password?: string;
}
export interface ClearExpiredResult {
deleted_count: number;
}
export const shareApi = {
create: (payload: ShareCreatePayload) => request<ShareInfoWithPassword>('/shares', { method: 'POST', json: payload }),
list: () => request<ShareInfo[]>('/shares'),
remove: (shareId: number) => request<void>(`/shares/${shareId}`, { method: 'DELETE' }),
clearExpired: () => request<ClearExpiredResult>(`/shares/expired`, { method: 'DELETE' }),
get: (token: string) => request<ShareInfo>(`/s/${token}`),
verifyPassword: (token: string, password: string) => request<void>(`/s/${token}/verify`, { method: 'POST', json: { password } }),
listDir: (token: string, path: string = '/', password?: string) => {
@@ -40,4 +45,4 @@ export const shareApi = {
const url = `${API_BASE_URL}/s/${token}/download?path=${encodeURIComponent(path)}`;
return password ? `${url}&password=${encodeURIComponent(password)}` : url;
},
};
};

View File

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

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

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

View File

@@ -38,7 +38,11 @@ export const vfsApi = {
});
return request<DirListing>(`/fs/${encodeURI(trimmed)}?${params}`);
},
readFile: (path: string) => request<ArrayBuffer>(`/fs/file/${encodeURI(path.replace(/^\/+/, ''))}`),
readFile: async (path: string) => {
const enc = encodeURI(path.replace(/^\/+/, ''));
const resp = await request(`/fs/file/${enc}`, { rawResponse: true });
return await (resp as Response).arrayBuffer();
},
uploadFile: (fullPath: string, file: File | Blob) => {
const fd = new FormData();
fd.append('file', file);

View File

@@ -1,6 +1,6 @@
import React, { useRef, useEffect, useCallback } from 'react';
import { Space, Button } from 'antd';
import { FullscreenExitOutlined, FullscreenOutlined, CloseOutlined } from '@ant-design/icons';
import { FullscreenExitOutlined, FullscreenOutlined, CloseOutlined, MinusOutlined } from '@ant-design/icons';
import type { AppDescriptor, AppComponentProps } from './types';
import type { VfsEntry } from '../api/client';
@@ -10,6 +10,7 @@ export interface AppWindowItem {
entry: VfsEntry;
filePath: string;
maximized: boolean;
minimized: boolean;
x: number;
y: number;
width: number;
@@ -187,9 +188,11 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
));
};
const visibleWindows = windows.filter(w => !w.minimized);
return (
<>
{windows.map((w, idx) => {
{visibleWindows.map((w, idx) => {
const AppComp = w.app.component as React.FC<AppComponentProps>;
const useSystemWindow = w.app.useSystemWindow !== false; // 默认为 true
if (!useSystemWindow) {
@@ -243,8 +246,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 +257,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 +272,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,
@@ -291,6 +294,21 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
{w.app.name} - {w.entry.name}
</span>
<Space size={4}>
<Button
type="text"
size="small"
aria-label="最小化"
icon={<MinusOutlined />}
onClick={() => onUpdateWindow(w.id, { minimized: true })}
style={{
color: 'var(--ant-color-text-secondary, #555)',
width: 30,
height: 30,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
/>
<Button
type="text"
size="small"
@@ -298,7 +316,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 +332,7 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
icon={<CloseOutlined />}
onClick={() => onClose(w.id)}
style={{
color: '#ff4d4f',
color: 'var(--ant-color-error, #ff4d4f)',
width: 30,
height: 30,
display: 'flex',

View File

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

View File

@@ -4,6 +4,7 @@ import { ImageViewerApp } from './ImageViewer.tsx';
export const descriptor: AppDescriptor = {
key: 'image-viewer',
name: '图片查看器',
iconUrl: 'https://api.iconify.design/mdi:image.svg',
supported: (entry) => {
if (entry.is_dir) return false;
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
@@ -14,4 +15,4 @@ export const descriptor: AppDescriptor = {
defaultMaximized:true,
useSystemWindow:false,
defaultBounds: { width: 820, height: 620, x: 140, y: 96 }
};
};

View File

@@ -60,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}
@@ -79,4 +79,4 @@ export const OfficeViewerApp: React.FC<AppComponentProps> = ({ filePath, onReque
)}
</div>
);
};
};

View File

@@ -4,6 +4,7 @@ import { OfficeViewerApp } from './OfficeViewer.tsx';
export const descriptor: AppDescriptor = {
key: 'office-viewer',
name: 'Office 文档查看器',
iconUrl: 'https://api.iconify.design/mdi:file-word-box.svg',
supported: (entry) => {
if (entry.is_dir) return false;
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
@@ -12,4 +13,4 @@ export const descriptor: AppDescriptor = {
component: OfficeViewerApp,
default: true,
defaultBounds: { width: 1024, height: 768, x: 150, y: 100 }
};
};

View File

@@ -0,0 +1,74 @@
import React, { useEffect, useState } from 'react';
import { Spin, Result, Button } from 'antd';
import type { AppComponentProps } from '../types';
import { vfsApi } from '../../api/client';
export const PdfViewerApp: React.FC<AppComponentProps> = ({ filePath, onRequestClose }) => {
const [url, setUrl] = useState<string>();
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string>();
useEffect(() => {
let cancelled = false;
setLoading(true);
setErr(undefined);
setUrl(undefined);
vfsApi.getTempLinkToken(filePath.replace(/^\/+/, ''))
.then(res => {
if (cancelled) return;
const publicUrl = vfsApi.getTempPublicUrl(res.token);
setUrl(publicUrl + '#toolbar=1&navpanes=1');
})
.catch(e => {
if (!cancelled) setErr(e.message || '获取临时链接失败');
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [filePath]);
if (loading) {
return (
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Spin tip="正在加载 PDF..." />
</div>
);
}
if (err) {
return (
<Result
status="error"
title="无法加载 PDF"
subTitle={err}
extra={<Button type="primary" onClick={onRequestClose}></Button>}
/>
);
}
if (!url) {
return (
<Result
status="warning"
title="无可用链接"
subTitle="未能生成 PDF 的临时访问链接"
extra={<Button type="primary" onClick={onRequestClose}></Button>}
/>
);
}
return (
<div style={{ width: '100%', height: '100%', background: 'var(--ant-color-bg-container, #fff)' }}>
<iframe
src={url}
width="100%"
height="100%"
title="PDF Viewer"
style={{ border: 'none' }}
/>
</div>
);
};

View File

@@ -0,0 +1,16 @@
import type { AppDescriptor } from '../types';
import { PdfViewerApp } from './PdfViewer';
export const descriptor: AppDescriptor = {
key: 'pdf-viewer',
name: 'PDF 查看器',
iconUrl: 'https://api.iconify.design/mdi:file-pdf-box.svg',
supported: (entry) => {
if (entry.is_dir) return false;
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
return ext === 'pdf';
},
component: PdfViewerApp,
default: true,
defaultBounds: { width: 1024, height: 768, x: 160, y: 100 },
};

View File

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

View File

@@ -1,8 +1,10 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { Layout, Spin, Button, Space, message } from 'antd';
import MDEditor from '@uiw/react-md-editor';
import Editor from '@monaco-editor/react';
import type { AppComponentProps } from '../types';
import { vfsApi } from '../../api/vfs';
import request from '../../api/client';
const { Header, Content } = Layout;
@@ -11,20 +13,66 @@ export const TextEditorApp: React.FC<AppComponentProps> = ({ filePath, entry, on
const [saving, setSaving] = useState(false);
const [content, setContent] = useState('');
const [initialContent, setInitialContent] = useState('');
const [truncated, setTruncated] = useState(false);
const MAX_PREVIEW_BYTES = 1024 * 1024; // 1MB
const isDirty = content !== initialContent;
// 使用 ref 来持有最新的 onRequestClose 函数,避免它成为 effect 的依赖项
const onRequestCloseRef = useRef(onRequestClose);
onRequestCloseRef.current = onRequestClose;
const ext = useMemo(() => entry.name.split('.').pop()?.toLowerCase() || '', [entry.name]);
const isMarkdown = ext === 'md' || ext === 'markdown';
const monacoLanguage = useMemo(() => {
switch (ext) {
case 'json':
return 'json';
case 'js':
return 'javascript';
case 'ts':
return 'typescript';
case 'html':
return 'html';
case 'css':
return 'css';
case 'py':
return 'python';
case 'sh':
return 'shell';
case 'yaml':
case 'yml':
return 'yaml';
case 'xml':
return 'xml';
case 'txt':
case 'log':
default:
return 'plaintext';
}
}, [ext]);
useEffect(() => {
const loadFile = async () => {
try {
setLoading(true);
const data = await vfsApi.readFile(filePath);
const text = typeof data === 'string' ? data : new TextDecoder().decode(data);
setContent(text);
setInitialContent(text);
setTruncated(false);
const shouldTruncate = (entry.size ?? 0) > MAX_PREVIEW_BYTES;
if (shouldTruncate) {
const enc = encodeURI(filePath.replace(/^\/+/, ''));
const resp = await request(`/fs/file/${enc}`, {
method: 'GET',
headers: { Range: `bytes=0-${MAX_PREVIEW_BYTES - 1}` },
rawResponse: true,
});
const buf = await (resp as Response).arrayBuffer();
const text = new TextDecoder().decode(buf);
setContent(text);
setInitialContent(text);
setTruncated(true);
} else {
const data = await vfsApi.readFile(filePath);
const text = typeof data === 'string' ? data : new TextDecoder().decode(data);
setContent(text);
setInitialContent(text);
}
} catch (error) {
message.error(`加载文件失败: ${error instanceof Error ? error.message : '未知错误'}`);
onRequestCloseRef.current();
@@ -33,9 +81,12 @@ export const TextEditorApp: React.FC<AppComponentProps> = ({ filePath, entry, on
}
};
loadFile();
}, [filePath]); // effect 只依赖 filePath因此只在文件路径变化时执行一次
}, [filePath, entry.size]);
const handleSave = useCallback(async () => {
if (truncated) {
message.warning('大文件仅预览前 1MB已禁用保存');
return;
}
if (!isDirty) return;
try {
setSaving(true);
@@ -48,7 +99,7 @@ export const TextEditorApp: React.FC<AppComponentProps> = ({ filePath, entry, on
} finally {
setSaving(false);
}
}, [content, filePath, isDirty]);
}, [content, filePath, isDirty, truncated]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
@@ -64,23 +115,23 @@ 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)' }}>
{entry.name} {isDirty && '*'}
<span style={{ color: 'var(--ant-color-text, rgba(0,0,0,0.88))' }}>
{entry.name} {isDirty && '*'} {truncated && '(大文件仅预览前 1MB编辑与保存已禁用'}
</span>
<Space>
<Button type="primary" size="small" onClick={handleSave} loading={saving} disabled={!isDirty}>
<Button type="primary" size="small" onClick={handleSave} loading={saving} disabled={!isDirty || truncated}>
</Button>
</Space>
@@ -91,14 +142,30 @@ export const TextEditorApp: React.FC<AppComponentProps> = ({ filePath, entry, on
<Spin />
</div>
) : (
<MDEditor
value={content}
onChange={(val) => setContent(val || '')}
height="100%"
preview="live"
/>
isMarkdown ? (
<MDEditor
value={content}
onChange={(val) => setContent(val || '')}
height="100%"
preview={truncated ? 'preview' : 'live'}
/>
) : (
<Editor
value={content}
onChange={(val) => setContent(val || '')}
height="100%"
language={monacoLanguage}
options={{
readOnly: truncated,
minimap: { enabled: false },
scrollBeyondLastLine: false,
wordWrap: 'on',
fontSize: 13,
}}
/>
)
)}
</Content>
</Layout>
);
};
};

View File

@@ -4,6 +4,7 @@ import { TextEditorApp } from './TextEditor.tsx';
export const descriptor: AppDescriptor = {
key: 'text-editor',
name: '文本编辑器',
iconUrl: 'https://api.iconify.design/mdi:file-document-outline.svg',
supported: (entry) => {
if (entry.is_dir) return false;
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
@@ -13,4 +14,4 @@ export const descriptor: AppDescriptor = {
component: TextEditorApp,
default: true,
defaultBounds: { width: 1024, height: 768, x: 120, y: 80 }
};
};

View File

@@ -4,12 +4,13 @@ import { VideoPlayerApp } from './VideoPlayer.tsx';
export const descriptor: AppDescriptor = {
key: 'video-player',
name: '视频播放器',
iconUrl: 'https://api.iconify.design/mdi:video.svg',
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,
defaultBounds: { width: 960, height: 600, x: 180, y: 120 }
};
};

View File

@@ -1,9 +1,11 @@
import type { VfsEntry } from '../api/client';
import type { AppDescriptor } from './types';
import React from 'react';
import { pluginsApi, type PluginItem } from '../api/plugins';
import { PluginAppHost } from './PluginHost';
const apps: AppDescriptor[] = [];
// 使用 import.meta.glob 动态导入所有应用
// vite-glob-ignore
const appModules = import.meta.glob('./*/index.ts');
async function loadApps() {
@@ -16,11 +18,35 @@ 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 }),
iconUrl: p.icon || undefined,
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 +69,28 @@ 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;
existing.iconUrl = p.icon || existing.iconUrl;
}
});
} catch { }
}

View File

@@ -11,6 +11,7 @@ export interface AppDescriptor {
name: string;
supported: (entry: VfsEntry) => boolean;
component: React.ComponentType<AppComponentProps>;
iconUrl?: string;
default?: boolean;
defaultMaximized?: boolean;
/**

View File

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

View File

@@ -0,0 +1,143 @@
import { memo, useEffect, useMemo, useState } from 'react';
import { Modal, Button, List, Typography, Space, Input, message } from 'antd';
import { FolderOutlined, ArrowUpOutlined } from '@ant-design/icons';
import { useI18n } from '../i18n';
import { vfsApi, type VfsEntry } from '../api/client';
import { getFileIcon } from '../pages/FileExplorerPage/components/FileIcons';
export type PathSelectorMode = 'directory' | 'file' | 'any';
interface PathSelectorModalProps {
open: boolean;
mode?: PathSelectorMode;
initialPath?: string;
onOk: (path: string) => void;
onCancel: () => void;
}
function normalizePath(p: string): string {
if (!p) return '/';
const s = ('/' + p).replace(/\/+/, '/');
return s.replace(/\\/g, '/').replace(/\/+$/, '') || '/';
}
function joinPath(dir: string, name: string): string {
const base = normalizePath(dir);
if (base === '/') return `/${name}`;
return `${base}/${name}`.replace(/\/+/, '/');
}
const PathSelectorModal = memo(function PathSelectorModal({ open, mode = 'directory', initialPath = '/', onOk, onCancel }: PathSelectorModalProps) {
const { t } = useI18n();
const [path, setPath] = useState<string>(normalizePath(initialPath));
const [entries, setEntries] = useState<VfsEntry[]>([]);
const [loading, setLoading] = useState(false);
const [selected, setSelected] = useState<string | null>(null); // selected file name within current folder
const title = useMemo(() => {
if (mode === 'file') return t('Select File');
if (mode === 'any') return t('Select Path');
return t('Select Folder');
}, [mode, t]);
const load = async (p: string) => {
setLoading(true);
try {
const listing = await vfsApi.list(p, 1, 500, 'name', 'asc');
setEntries(listing.entries);
setPath(listing.path || p);
setSelected(null);
} catch (e: any) {
message.error(e.message || t('Load failed'));
} finally {
setLoading(false);
}
};
useEffect(() => {
if (open) {
load(normalizePath(initialPath));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, initialPath]);
const canOk = useMemo(() => {
if (mode === 'file') return !!selected;
return true;
}, [mode, selected]);
const handleOk = () => {
if (mode === 'directory') {
onOk(normalizePath(path));
return;
}
if (mode === 'file') {
if (!selected) {
message.warning(t('Please select a file'));
return;
}
onOk(joinPath(path, selected));
return;
}
// any
if (selected) onOk(joinPath(path, selected));
else onOk(normalizePath(path));
};
const goUp = () => {
const cur = normalizePath(path);
if (cur === '/') return;
const parent = cur.replace(/\/+$/, '').split('/').slice(0, -1).join('/') || '/';
load(parent);
};
return (
<Modal
title={title}
open={open}
onCancel={onCancel}
onOk={handleOk}
okButtonProps={{ disabled: !canOk }}
width={720}
>
<Space style={{ width: '100%', marginBottom: 12 }} align="center">
<Typography.Text type="secondary">{t('Current')}</Typography.Text>
<Input value={path} readOnly />
<Button onClick={goUp} icon={<ArrowUpOutlined />} disabled={path === '/'}>{t('Up')}</Button>
{mode !== 'file' && (
<Button type="primary" onClick={() => onOk(normalizePath(path))}>{t('Select Current Folder')}</Button>
)}
</Space>
<List
bordered
loading={loading}
dataSource={entries}
style={{ maxHeight: 420, overflow: 'auto' }}
renderItem={(item) => {
const isSelected = selected === item.name && !item.is_dir;
return (
<List.Item
onClick={() => {
if (item.is_dir) {
load(joinPath(path, item.name));
} else {
setSelected((prev) => (prev === item.name ? null : item.name));
}
}}
style={{ cursor: 'pointer', background: isSelected ? 'rgba(22,119,255,0.08)' : undefined }}
>
<Space>
{item.is_dir ? <FolderOutlined /> : getFileIcon(item.name)}
<Typography.Text strong={item.is_dir}>{item.name}</Typography.Text>
</Space>
</List.Item>
);
}}
/>
</Modal>
);
});
export default PathSelectorModal;

View File

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

View File

@@ -0,0 +1,121 @@
import { memo, useEffect, useState } from 'react';
import { Modal, Form, Input, message, Collapse } from 'antd';
import { useAuth } from '../contexts/AuthContext';
import { authApi } from '../api/auth';
import { useI18n } from '../i18n';
export interface ProfileModalProps {
open: boolean;
onClose: () => void;
}
const ProfileModal = memo(function ProfileModal({ open, onClose }: ProfileModalProps) {
const { user, refreshUser } = useAuth();
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const { t } = useI18n();
useEffect(() => {
if (open && user) {
form.setFieldsValue({
username: user.username || '',
full_name: user.full_name || '',
email: user.email || '',
old_password: '',
new_password: '',
});
} else if (!open) {
form.resetFields();
}
}, [open, user, form]);
const handleOk = async () => {
try {
const values = await form.validateFields();
const payload: any = {};
if (values.full_name !== (user?.full_name || '')) payload.full_name = values.full_name || null;
if (values.email !== (user?.email || '')) payload.email = values.email || null;
if (values.old_password || values.new_password) {
payload.old_password = values.old_password || '';
payload.new_password = values.new_password || '';
}
setLoading(true);
await authApi.updateMe(payload);
await refreshUser();
message.success(t('Saved successfully'));
onClose();
} catch (e) {
if (e instanceof Error) {
message.error(e.message || t('Save failed'));
}
} finally {
setLoading(false);
}
};
return (
<Modal
title={t('Profile')}
open={open}
onCancel={onClose}
onOk={handleOk}
confirmLoading={loading}
okText={t('Save')}
cancelText={t('Cancel')}
>
<Form form={form} layout="vertical">
<Form.Item name="username" label={t('Username')}>
<Input disabled />
</Form.Item>
<Form.Item name="full_name" label={t('Full Name')}>
<Input placeholder={t('Full Name')} />
</Form.Item>
<Form.Item name="email" label={t('Email')}>
<Input placeholder={t('Email')} type="email" />
</Form.Item>
<Collapse
size="small"
items={[{
key: 'pwd',
label: t('Change Password'),
children: (
<>
<Form.Item
name="old_password"
label={t('Old Password')}
dependencies={["new_password"]}
rules={[{
validator: async (_, value) => {
const newPwd = form.getFieldValue('new_password');
if ((value && !newPwd) || (!value && newPwd)) {
throw new Error(t('Please fill both old and new password'));
}
}
}]}
>
<Input.Password placeholder={t('Old Password')} autoComplete="current-password" />
</Form.Item>
<Form.Item
name="new_password"
label={t('New Password')}
dependencies={["old_password"]}
rules={[{
validator: async (_, value) => {
const oldPwd = form.getFieldValue('old_password');
if ((value && !oldPwd) || (!value && oldPwd)) {
throw new Error(t('Please fill both old and new password'));
}
}
}]}
>
<Input.Password placeholder={t('New Password')} autoComplete="new-password" />
</Form.Item>
</>
)
}]} />
</Form>
</Modal>
);
});
export default ProfileModal;

View File

@@ -0,0 +1,154 @@
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react';
import { Modal, Checkbox } from 'antd';
import type { VfsEntry } from '../api/client';
import type { AppDescriptor } from '../apps/registry';
import { getAppsForEntry, getDefaultAppForEntry, getAppByKey } from '../apps/registry';
import { useI18n } from '../i18n';
export interface AppWindowItem {
id: string;
app: AppDescriptor;
entry: VfsEntry;
filePath: string;
maximized: boolean;
minimized: boolean;
x: number;
y: number;
width: number;
height: number;
}
interface AppWindowsContextValue {
windows: AppWindowItem[];
openWithApp: (entry: VfsEntry, app: AppDescriptor, currentPath: string) => void;
openFileWithDefaultApp: (entry: VfsEntry, currentPath: string) => void;
confirmOpenWithApp: (entry: VfsEntry, appKey: string, currentPath: string) => void;
closeWindow: (id: string) => void;
toggleMax: (id: string) => void;
bringToFront: (id: string) => void;
updateWindow: (id: string, patch: Partial<Omit<AppWindowItem, 'id' | 'app' | 'entry' | 'filePath'>>) => void;
minimizeWindow: (id: string) => void;
restoreWindow: (id: string) => void;
toggleMinimize: (id: string) => void;
}
const AppWindowsContext = createContext<AppWindowsContextValue | null>(null);
export const AppWindowsProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { t } = useI18n();
const [windows, setWindows] = useState<AppWindowItem[]>([]);
const openWithApp = useCallback((entry: VfsEntry, app: AppDescriptor, currentPath: string) => {
const fullPath = (currentPath === '/' ? '' : currentPath) + '/' + entry.name;
setWindows(ws => {
const idx = ws.length;
const bounds = app.defaultBounds || {};
const baseX = bounds.x ?? (160 + idx * 32);
const baseY = bounds.y ?? (100 + idx * 28);
const baseW = bounds.width ?? 640;
const baseH = bounds.height ?? 480;
const vw = window.innerWidth;
const vh = window.innerHeight;
const finalW = Math.min(baseW, vw - 40);
const finalH = Math.min(baseH, vh - 60);
const finalX = Math.min(Math.max(0, baseX), vw - finalW - 8);
const finalY = Math.min(Math.max(48, baseY), vh - finalH - 8);
return [
...ws,
{
id: Date.now().toString(36) + Math.random().toString(36).slice(2),
app,
entry,
filePath: fullPath,
maximized: !!app.defaultMaximized,
minimized: false,
x: finalX,
y: finalY,
width: finalW,
height: finalH,
},
];
});
}, []);
const openFileWithDefaultApp = useCallback((entry: VfsEntry, currentPath: string) => {
const apps = getAppsForEntry(entry);
if (!apps.length) {
Modal.error({ title: t('Cannot open file: no available app') });
return;
}
const defaultApp = getDefaultAppForEntry(entry) || apps[0];
openWithApp(entry, defaultApp, currentPath);
}, [openWithApp, t]);
const confirmOpenWithApp = useCallback((entry: VfsEntry, appKey: string, currentPath: string) => {
const app = getAppByKey(appKey);
if (!app) {
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: t('Open with {app}', { app: app.name }),
content: (
<div>
<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: () => {
if (setDefault && ext) {
localStorage.setItem(`app.default.${ext}`, app.key);
}
openWithApp(entry, app, currentPath);
},
});
}, [openWithApp, t]);
const closeWindow = (id: string) => setWindows(ws => ws.filter(w => w.id !== id));
const toggleMax = (id: string) => setWindows(ws => ws.map(w => (w.id === id ? { ...w, maximized: !w.maximized } : w)));
const bringToFront = (id: string) => setWindows(ws => {
const target = ws.find(w => w.id === id);
if (!target) return ws;
return [...ws.filter(w => w.id !== id), target];
});
const updateWindow = (
id: string,
patch: Partial<Omit<AppWindowItem, 'id' | 'app' | 'entry' | 'filePath'>>,
) => setWindows(ws => ws.map(w => (w.id === id ? { ...w, ...patch } : w)));
const minimizeWindow = (id: string) => setWindows(ws => ws.map(w => (w.id === id ? { ...w, minimized: true } : w)));
const restoreWindow = (id: string) => setWindows(ws => {
const target = ws.find(w => w.id === id);
if (!target) return ws;
const restored = { ...target, minimized: false };
return [...ws.filter(w => w.id !== id), restored];
});
const toggleMinimize = (id: string) => setWindows(ws => ws.map(w => (w.id === id ? { ...w, minimized: !w.minimized } : w)));
const value = useMemo<AppWindowsContextValue>(() => ({
windows,
openWithApp,
openFileWithDefaultApp,
confirmOpenWithApp,
closeWindow,
toggleMax,
bringToFront,
updateWindow,
minimizeWindow,
restoreWindow,
toggleMinimize,
}), [windows, openWithApp, openFileWithDefaultApp, confirmOpenWithApp]);
return <AppWindowsContext.Provider value={value}>{children}</AppWindowsContext.Provider>;
};
export function useAppWindows() {
const ctx = useContext(AppWindowsContext);
if (!ctx) throw new Error('useAppWindows must be used within AppWindowsProvider');
return ctx;
}

View File

@@ -1,5 +1,5 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import { authApi } from '../api/auth';
import { authApi, type MeResponse } from '../api/auth';
interface AuthContextType {
token: string | null;
@@ -7,12 +7,15 @@ interface AuthContextType {
login: (username: string, password: string) => Promise<void>;
logout: () => void;
register: (username: string, password: string, email?: string, full_name?: string) => Promise<void>;
user: MeResponse | null;
refreshUser: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType>({} as any);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [token, setToken] = useState<string | null>(() => localStorage.getItem('token'));
const [user, setUser] = useState<MeResponse | null>(null);
const isAuthenticated = !!token;
useEffect(() => {
@@ -22,20 +25,38 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const login = async (username: string, password: string) => {
const res = await authApi.login({ username, password });
if (res)
if (res) {
setToken(res.access_token);
try { await refreshUser(); } catch (_) {}
}
};
const logout = () => {
setToken(null);
setUser(null);
};
const register = async (username: string, password: string, email?: string, full_name?: string) => {
await authApi.register(username, password, email, full_name);
};
const refreshUser = async () => {
if (!localStorage.getItem('token')) { setUser(null); return; }
const me = await authApi.me();
setUser(me);
};
useEffect(() => {
if (token) {
refreshUser().catch(() => setUser(null));
} else {
setUser(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
return (
<AuthContext.Provider value={{ token, isAuthenticated, login, logout, register }}>
<AuthContext.Provider value={{ token, isAuthenticated, login, logout, register, user, refreshUser }}>
{children}
</AuthContext.Provider>
);

View File

@@ -0,0 +1,189 @@
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 enUS from 'antd/locale/en_US';
import type { ThemeConfig } from 'antd/es/config-provider/context';
import { getAllConfig } from '../api/config';
import { useAuth } from './AuthContext';
import baseTheme from '../theme';
import { useI18n } from '../i18n';
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 { lang } = useI18n();
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 locale = useMemo(() => (lang === 'zh' ? zhCN : enUS), [lang]);
const ctxValue = useMemo<ThemeContextType>(() => ({
refreshTheme,
previewTheme,
mode: state.mode,
resolvedMode,
}), [state.mode, resolvedMode]);
return (
<Ctx.Provider value={ctxValue}>
<ConfigProvider theme={{ ...themeConfig, cssVar: true }} locale={locale}>
{children}
</ConfigProvider>
</Ctx.Provider>
);
}
export function useTheme() {
return useContext(Ctx);
}

View File

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

View File

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

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

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

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

@@ -0,0 +1,397 @@
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',
'Profile': 'Profile',
'Account Settings': 'Account Settings',
'Language': 'Language',
'Chinese': '中文',
'English': 'English',
'Full Name': 'Full Name',
'Email': 'Email',
'Change Password': 'Change Password',
'Old Password': 'Old Password',
'New Password': 'New Password',
'Please fill both old and new password': 'Please fill both old and new password',
// 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?',
'Clear expired shares': 'Clear expired shares',
'Confirm clear expired shares?': 'Confirm clear expired shares?',
'Cleared {count} expired shares': 'Cleared {count} expired shares',
'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',
'Processor': 'Processor',
'Share': 'Share',
'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',
'Embedding Dimension': 'Embedding Dimension',
'Vector Database': 'Vector Database',
'Vector Database Settings': 'Vector Database Settings',
'Database Type': 'Database Type',
'Confirm embedding dimension change': 'Confirm embedding dimension change',
'Changing the embedding dimension will clear the vector database automatically. You will need to rebuild indexes afterwards. Continue?': 'Changing the embedding dimension will clear the vector database automatically. You will need to rebuild indexes afterwards. Continue?',
'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',
// Path selector
'Select File': 'Select File',
'Select Path': 'Select Path',
'Select Folder': 'Select Folder',
'Select': 'Select',
'Current': 'Current',
'Up': 'Up',
'Select Current Folder': 'Select Current Folder',
'Please select a file': 'Please select a file',
// 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',
'Installed': 'Installed',
'Discover': 'Discover',
'Search apps': 'Search apps',
'Sort by': 'Sort by',
'Downloads': 'Downloads',
'Created (newest)': 'Created (newest)',
'Installed already': 'Installed',
'No results': 'No results',
// 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',
'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;

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

@@ -0,0 +1,399 @@
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': '管理员',
'Profile': '个人资料',
'Account Settings': '账户设置',
'Language': '语言',
'Chinese': '中文',
'English': 'English',
'Full Name': '昵称',
'Email': '邮箱',
'Change Password': '修改密码',
'Old Password': '原密码',
'New Password': '新密码',
'Please fill both old and new password': '请同时填写原密码和新密码',
// 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?': '确认取消分享?',
'Clear expired shares': '清空过期分享',
'Confirm clear expired shares?': '确认清空过期分享?',
'Cleared {count} expired shares': '已清理 {count} 个过期分享',
'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': '默认',
'Processor': '处理器',
'Share': '分享',
'Rename': '重命名',
'Delete': '删除',
'Details': '详情',
'Get Direct Link': '获取直链',
// Side nav modals
'Join Community': '加入社区',
'Scan to join WeChat group': '微信扫码加入交流群',
'If QR expires, add drizzle2001 to join': '如二维码失效,请添加 drizzle2001 拉群',
'Version Info': '版本信息',
'Current Version': '当前版本',
'Latest Version': '最新版本',
'New version found: {version}': '发现新版本: {version}',
'Please update to the latest for features and fixes': '建议尽快更新到最新版本,以获得新功能和安全修复。',
'Open Releases': '前往发布页面',
'Changelog': '更新日志',
'Fetching latest version...': '正在获取最新版本信息...',
'Update available': '有更新',
'You are on the latest: {version}': '当前为最新版: {version}',
'Up to date': '已是最新版',
// Share modal
'Share {count} items': '分享 {count} 个项目',
'Share link created': '分享链接已创建',
'Create failed': '创建失败',
'Copied to clipboard': '已复制到剪贴板',
'Expiration (days)': '有效期 (天)',
'Set 0 or negative for forever': '设置为 0 或负数表示永久有效',
'Share link created successfully!': '分享链接已成功创建!',
'Share Link': '分享链接',
'Share created': '分享创建成功',
'Create Share': '创建分享',
'Done': '完成',
'Create': '创建',
// Direct link modal
'Failed to generate link': '生成链接失败',
'Markdown copied to clipboard': 'Markdown 格式已复制到剪贴板',
'Generate a direct link for {name}': '为 {name} 生成一个直接访问链接。',
'1 hour': '1 小时',
'1 day': '1 天',
'7 days': '7 天',
'Generating link...': '正在生成链接...',
'Link will appear here': '链接将显示在这里',
'Copy Markdown': '复制 Markdown',
'Close': '关闭',
// File detail
'Camera Make': '设备品牌',
'Camera Model': '设备型号',
'Capture Time': '拍摄时间',
'X Resolution': '水平分辨率',
'Y Resolution': '垂直分辨率',
'Exposure Time': '曝光时间',
'Aperture': '光圈值',
'Focal Length': '焦距',
'Width': '宽度',
'Height': '高度',
'No common EXIF info': '无常见EXIF信息',
'Bytes': '字节',
'File Properties': '文件属性',
'Loading file info...': '加载文件信息...',
'Basic Info': '基本信息',
'Type': '类型',
'Folder': '文件夹',
'File': '文件',
'Path': '路径',
'Path copied to clipboard': '路径已复制到剪贴板',
'Copy failed': '复制失败',
'Permissions': '权限',
'EXIF Info': 'EXIF信息',
// Search dialog
'Smart Search': '智能搜索',
'Name Search': '名称搜索',
'Search Results': '搜索结果',
'No files found': '未找到相关文件',
'Relevance': '相关度',
// System settings
'Saved successfully': '保存成功',
'Save failed': '保存失败',
'Loading...': '加载中...',
'Appearance Settings': '外观设置',
'Theme': '主题',
'Theme Mode': '主题模式',
'Light': '亮色',
'Dark': '暗色',
// 'Follow System' used for theme mode
'Follow System': '跟随系统',
'Primary Color': '主色',
'Border Radius': '圆角',
'Advanced': '高级',
'Override AntD Tokens (JSON)': '覆盖 AntD TokenJSON',
'e.g. {"colorText": "#222"}': '例如:{"colorText": "#222"}',
'Custom CSS': '自定义 CSS',
'Save': '保存',
'App Settings': '应用设置',
'AI Settings': 'AI设置',
'Vision Model': '视觉模型',
'Embedding Model': '嵌入模型',
'Embedding Dimension': '向量维度',
'Vector Database': '向量数据库',
'Vector Database Settings': '向量数据库设置',
'Database Type': '数据库类型',
'Confirm embedding dimension change': '确认修改向量维度',
'Changing the embedding dimension will clear the vector database automatically. You will need to rebuild indexes afterwards. Continue?': '修改向量维度会自动清空向量数据库,之后需要重建索引,是否继续?',
'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': '处理失败',
// Path selector
'Select File': '选择文件',
'Select Path': '选择路径',
'Select Folder': '选择目录',
'Select': '选择',
'Current': '当前',
'Up': '上一级',
'Select Current Folder': '选择当前目录',
'Please select a file': '请选择一个文件',
// 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',
'Installed': '已安装',
'Discover': '发现',
'Search apps': '搜索应用',
'Sort by': '排序',
'Downloads': '下载量',
'Created (newest)': '创建时间(最新)',
'Installed already': '已安装',
'No results': '暂无结果',
// 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': '用户名',
'Please input a valid email!': '请输入有效的邮箱地址!',
'Confirm Password': '确认密码',
'Please confirm your password!': '请确认您的密码!',
'Passwords do not match!': '两次输入的密码不一致!',
'System Initialization': '系统初始化',
'Previous': '上一步',
'Next': '下一步',
'Finish Initialization': '完成初始化',
// Plugin host
'Plugin run failed': '插件运行失败',
'Plugin Error': '插件错误',
'Cannot open file: no available app': '无法打开该文件:没有可用的应用',
'Error': '错误',
'App "{key}" not found.': '应用 "{key}" 不存在。',
'Open with {app}': '使用 {app} 打开',
'Set as default for .{ext}': '设为该类型(.{ext})默认应用',
'Advanced tokens must be valid JSON': '高级 Token 需为合法 JSON',
} as const;
export type ZhKeys = keyof typeof zh;

View File

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

View File

@@ -15,6 +15,9 @@ 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';
import { useAppWindows } from '../contexts/AppWindowsContext';
const { Sider } = Layout;
export interface SideNavProps {
@@ -27,6 +30,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<{
@@ -50,6 +55,16 @@ const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle
};
const hasUpdate = latestVersion && latestVersion.version !== status?.version;
const { windows, restoreWindow } = useAppWindows();
const minimized = windows.filter(w => w.minimized);
const DEFAULT_APP_ICON =
'data:image/svg+xml;utf8,' +
encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<rect x="3" y="3" width="18" height="18" rx="4" ry="4" fill="currentColor" />
<rect x="7" y="7" width="10" height="10" rx="2" ry="2" fill="#fff"/>
</svg>`
);
return (
<>
<Sider
@@ -85,10 +100,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 && (
@@ -114,7 +135,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"
@@ -122,7 +143,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"
/>
@@ -143,6 +164,35 @@ const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle
borderTop: `1px solid ${token.colorBorderSecondary}`
}}
>
{/* 最小化应用 Dock */}
{minimized.length > 0 && (
<div
style={{
width: '100%',
display: 'flex',
flexDirection: collapsed ? 'column' : 'row',
justifyContent: 'center',
alignItems: 'center',
gap: 8,
flexWrap: collapsed ? 'nowrap' : 'wrap',
maxHeight: collapsed ? 160 : undefined,
overflowY: collapsed ? 'auto' : 'visible',
}}
>
{minimized.map(w => {
const src = w.app.iconUrl || DEFAULT_APP_ICON;
return (
<Tooltip key={w.id} title={`${w.app.name} - ${w.entry.name}`} placement={collapsed ? 'right' : 'top'}>
<Button
shape="circle"
onClick={() => restoreWindow(w.id)}
icon={<img src={src} alt={w.app.name} style={{ width: 16, height: 16 }} />}
/>
</Tooltip>
);
})}
</div>
)}
<div style={{
fontSize: 12,
color: token.colorTextSecondary,
@@ -154,26 +204,26 @@ const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle
cursor: 'pointer'
}} onClick={showVersionModal}>
{hasUpdate ? (
<Tooltip title={`发现新版本: ${latestVersion?.version}`} placement={collapsed ? 'right' : 'top'}>
<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>
@@ -213,24 +263,24 @@ const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle
<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}
>
@@ -238,18 +288,18 @@ const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle
{latestVersion ? (
<>
<Descriptions bordered column={1} size="small">
<Descriptions.Item label="当前版本">
<Descriptions.Item label={t('Current Version')}>
<Tag>{status?.version}</Tag>
</Descriptions.Item>
<Descriptions.Item label="最新版本">
<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 }}>{`发现新版本: ${latestVersion.version}`}</span>}
description={<span style={{ color: token.colorTextSecondary }}></span>}
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 }}
@@ -261,13 +311,13 @@ const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle
target="_blank"
icon={<GithubOutlined />}
>
{t('Open Releases')}
</Button>
}
/>
)}
<Divider orientation="left" plain></Divider>
<Divider orientation="left" plain>{t('Changelog')}</Divider>
<div style={{
maxHeight: '40vh',
overflowY: 'auto',
@@ -297,7 +347,7 @@ const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle
) : (
<div style={{ textAlign: 'center', padding: '40px 0', color: token.colorTextSecondary }}>
<Spin size="large" />
<p style={{ marginTop: 16 }}>...</p>
<p style={{ marginTop: 16 }}>{t('Fetching latest version...')}</p>
</div>
)}
</div>

View File

@@ -1,9 +1,13 @@
import { Layout, Button, Dropdown, theme, Flex } from 'antd';
import { SearchOutlined, UserOutlined, MenuUnfoldOutlined, LogoutOutlined } from '@ant-design/icons';
import { Layout, Button, Dropdown, theme, Flex, Avatar, Typography } from 'antd';
import { SearchOutlined, MenuUnfoldOutlined, LogoutOutlined, UserOutlined } from '@ant-design/icons';
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';
import { useAuth } from '../contexts/AuthContext';
import ProfileModal from '../components/ProfileModal';
const { Header } = Layout;
@@ -16,12 +20,17 @@ const TopHeader = memo(function TopHeader({ collapsed, onToggle }: TopHeaderProp
const { token } = theme.useToken();
const [searchOpen, setSearchOpen] = useState(false);
const navigate = useNavigate();
const { t } = useI18n();
const { user } = useAuth();
const [profileOpen, setProfileOpen] = useState(false);
const handleLogout = () => {
authApi.logout();
navigate('/login', { replace: true });
};
const openProfile = () => setProfileOpen(true);
return (
<Header style={{ background: token.colorBgContainer, borderBottom: `1px solid ${token.colorBorderSecondary}`, display: 'flex', alignItems: 'center', gap: 16, backdropFilter: 'saturate(180%) blur(8px)' }}>
{collapsed && (
@@ -37,19 +46,31 @@ 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: 'profile', label: t('Profile'), icon: <UserOutlined />, onClick: openProfile },
{ key: 'logout', label: t('Log Out'), icon: <LogoutOutlined />, onClick: handleLogout }
]
}}
>
<Button icon={<UserOutlined />}></Button>
<Button type="text" style={{ paddingInline: 8, height: 40 }}>
<Flex align="center" gap={8}>
<Avatar size={28} src={user?.gravatar_url}>
{(user?.full_name || user?.username || 'A').charAt(0).toUpperCase()}
</Avatar>
<Typography.Text style={{ maxWidth: 160 }} ellipsis>
{user?.full_name || user?.username || t('Admin')}
</Typography.Text>
</Flex>
</Button>
</Dropdown>
<ProfileModal open={profileOpen} onClose={() => setProfileOpen(false)} />
</Flex>
</Header>
);

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ 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, type AdapterItem } from '../api/client';
import { useI18n } from '../i18n';
interface AdapterTypeField {
@@ -25,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);
@@ -36,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);
}
@@ -90,7 +92,7 @@ const AdaptersPage = memo(function AdaptersPage() {
}
});
if (miss.length) {
message.error('缺少必填配置: ' + miss.join(', '));
message.error(t('Missing required config:') + ' ' + miss.join(', '));
return;
}
const body = {
@@ -104,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);
}
@@ -123,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'));
}
};
@@ -134,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: '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) => (
@@ -162,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>
)
@@ -179,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} />;
@@ -189,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}
@@ -200,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>
}
>
@@ -217,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>
}
>
@@ -234,12 +236,12 @@ 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={() => {
const t = availableTypes.find(v => v.type === form.getFieldValue('type'));
@@ -251,16 +253,16 @@ const AdaptersPage = memo(function AdaptersPage() {
}}
/>
</Form.Item>
<Form.Item name="path" label="挂载路径" rules={[{ required: true, message: '请输入挂载路径' }]}>
<Input placeholder="/或/drive" />
<Form.Item name="path" label={t('Mount Path')} rules={[{ required: true, message: t('Please input {label}', { label: t('Mount Path') }) }]}>
<Input placeholder={t('/ or /drive')} />
</Form.Item>
<Form.Item name="sub_path" label="子路径(可选)">
<Input placeholder="适配器内部子目录" />
<Form.Item name="sub_path" label={t('Sub Path (optional)')}>
<Input placeholder={t('Sub directory inside adapter')} />
</Form.Item>
<Form.Item name="enabled" label="启用" valuePropName="checked">
<Form.Item name="enabled" label={t('Enabled')} valuePropName="checked">
<Switch />
</Form.Item>
<Typography.Title level={5} style={{ marginTop: 8, fontSize: 14 }}></Typography.Title>
<Typography.Title level={5} style={{ marginTop: 8, fontSize: 14 }}>{t('Adapter Config')}</Typography.Title>
{renderConfigFields()}
</Form>
</Drawer>

View File

@@ -1,11 +1,10 @@
import { memo, useEffect, useRef, useState } from 'react';
import { useParams } from 'react-router';
import { theme, Pagination } from 'antd';
import { AppWindowsLayer } from '../../apps/AppWindowsLayer';
import { useFileExplorer } from './hooks/useFileExplorer';
import { useFileSelection } from './hooks/useFileSelection';
import { useFileActions } from './hooks/useFileActions.tsx';
import { useAppWindows } from './hooks/useAppWindows.tsx';
import { useAppWindows } from '../../contexts/AppWindowsContext';
import { useContextMenu } from './hooks/useContextMenu';
import { useProcessor } from './hooks/useProcessor';
import { useThumbnails } from './hooks/useThumbnails';
@@ -37,7 +36,7 @@ const FileExplorerPage = memo(function FileExplorerPage() {
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 { openFileWithDefaultApp, confirmOpenWithApp } = useAppWindows();
const { ctxMenu, blankCtxMenu, openContextMenu, openBlankContextMenu, closeContextMenus } = useContextMenu();
const uploader = useUploader(path, refresh);
const { handleFileDrop } = uploader;
@@ -65,8 +64,8 @@ const FileExplorerPage = memo(function FileExplorerPage() {
const next = (path === '/' ? '' : path) + '/' + entry.name;
navigateTo(next.replace(/\/+/g, '/'));
} else {
openFileWithDefaultApp(entry);
}
openFileWithDefaultApp(entry, path);
}
};
const openDetail = async (entry: VfsEntry) => {
@@ -172,7 +171,7 @@ const FileExplorerPage = memo(function FileExplorerPage() {
onRowClick={(r, e) => handleSelect(r, e.ctrlKey || e.metaKey)}
onSelectionChange={setSelectedEntries}
onOpen={handleOpenEntry}
onOpenWith={(entry, appKey) => confirmOpenWithApp(entry, appKey)}
onOpenWith={(entry, appKey) => confirmOpenWithApp(entry, appKey, path)}
onRename={setRenaming}
onDelete={(entry) => doDelete([entry])}
onContextMenu={openContextMenu}
@@ -232,7 +231,7 @@ const FileExplorerPage = memo(function FileExplorerPage() {
processorTypes={processorTypes}
onClose={closeContextMenus}
onOpen={handleOpenEntry}
onOpenWith={(entry, appKey) => confirmOpenWithApp(entry, appKey)}
onOpenWith={(entry, appKey) => confirmOpenWithApp(entry, appKey, path)}
onDownload={doDownload}
onRename={setRenaming}
onDelete={(entriesToDelete) => doDelete(entriesToDelete)}
@@ -253,10 +252,9 @@ const FileExplorerPage = memo(function FileExplorerPage() {
onClose={uploader.closeModal}
onStartUpload={uploader.startUpload}
/>
<AppWindowsLayer windows={appWindows} onClose={closeWindow} onToggleMax={toggleMax} onBringToFront={bringToFront} onUpdateWindow={updateWindow} />
<DropzoneOverlay visible={isDragging} />
</div>
);
});
export default FileExplorerPage;
export default FileExplorerPage;

View File

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

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { Typography, theme } from 'antd';
import { FolderOpenOutlined } from '@ant-design/icons';
import { useI18n } from '../../../i18n';
interface Props {
isRoot: boolean;
@@ -8,14 +9,15 @@ interface Props {
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>
</div>
);

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ 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[];
@@ -26,6 +27,28 @@ const formatSize = (size: number) => {
export const GridView: React.FC<Props> = ({ entries, thumbs, selectedEntries, loading, path, onSelect, onSelectRange, onOpen, onContextMenu }) => {
const { token } = theme.useToken();
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);
@@ -111,9 +134,24 @@ export const GridView: React.FC<Props> = ({ entries, thumbs, selectedEntries, lo
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>
@@ -129,8 +167,8 @@ 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
}}
/>

View File

@@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { Flex, Typography, Divider, Button, Space, Tooltip, Segmented, Breadcrumb, Input, theme } from 'antd';
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 {
@@ -35,6 +36,7 @@ export const Header: React.FC<HeaderProps> = ({
onSortChange,
}) => {
const { token } = theme.useToken();
const { t } = useI18n();
const [editingPath, setEditingPath] = useState(false);
const [pathInputValue, setPathInputValue] = useState('');
@@ -73,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 {
@@ -99,23 +101,23 @@ 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: '名称' },
{ value: 'size', label: '大小' },
{ value: 'mtime', label: '修改时间' },
{ value: 'name', label: t('Name') },
{ value: 'size', label: t('Size') },
{ value: 'mtime', label: t('Modified Time') },
]}
/>
<Button
@@ -128,11 +130,11 @@ export const Header: React.FC<HeaderProps> = ({
value={viewMode}
onChange={v => onSetViewMode(v as any)}
options={[
{ label: <Tooltip title="网格"><AppstoreOutlined /></Tooltip>, value: 'grid' },
{ label: <Tooltip title="列表"><UnorderedListOutlined /></Tooltip>, value: 'list' }
{ label: <Tooltip title={t('Grid')}><AppstoreOutlined /></Tooltip>, value: 'grid' },
{ label: <Tooltip title={t('List')}><UnorderedListOutlined /></Tooltip>, value: 'list' }
]}
/>
</Space>
</Flex>
);
};
};

View File

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

View File

@@ -3,6 +3,7 @@ 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;
@@ -30,6 +31,7 @@ export const DirectLinkModal = memo(function DirectLinkModal({ entry, path, open
const [loading, setLoading] = useState(false);
const [expiresIn, setExpiresIn] = useState(3600);
const [link, setLink] = useState('');
const { t } = useI18n();
useEffect(() => {
if (open && entry) {
@@ -44,9 +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);
setLink(res.url);
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);
}
@@ -54,52 +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('Markdown 格式已复制到剪贴板');
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 ? "正在生成链接..." : "链接将显示在这里"} />
<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}>
Markdown
{t('Copy Markdown')}
</Button>
</Space.Compact>
</div>
</Modal>
);
});
});

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ 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[];
@@ -15,13 +16,14 @@ 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(() => {
@@ -54,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);
}
@@ -65,7 +67,7 @@ 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 baseUrl = systemStatus?.app_domain || window.location.origin;
@@ -73,21 +75,21 @@ export const ShareModal = memo(function ShareModal({ entries, path, open, onOk,
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>
@@ -95,44 +97,44 @@ export const ShareModal = memo(function ShareModal({ entries, path, open, onOk,
const renderSuccess = () => (
<div>
<Typography.Paragraph></Typography.Paragraph>
<Typography.Paragraph>{t('Share link created successfully!')}</Typography.Paragraph>
<Form layout="vertical">
<Form.Item label="分享链接">
<Form.Item label={t('Share Link')}>
<div style={{ display: 'flex', gap: 8 }}>
<Input readOnly value={shareUrl} style={{ flex: 1 }} />
<Button icon={<CopyOutlined />} onClick={() => handleCopy(shareUrl)}>
{t('Copy')}
</Button>
</div>
</Form.Item>
{createdShare?.password && (
<Form.Item label="访问密码">
<Form.Item label={t('Password')}>
<div style={{ display: 'flex', gap: 8 }}>
<Input readOnly value={createdShare.password} style={{ flex: 1 }} />
<Button icon={<CopyOutlined />} onClick={() => handleCopy(createdShare.password!)}>
{t('Copy')}
</Button>
</div>
</Form.Item>
)}
</Form>
<Typography.Text type="secondary">
: {createdShare?.expires_at ? new Date(createdShare.expires_at).toLocaleString() : '永久有效'}
{t('Expires At')}: {createdShare?.expires_at ? new Date(createdShare.expires_at).toLocaleString() : t('Forever')}
</Typography.Text>
</div>
);
return (
<Modal
title={createdShare ? "分享创建成功" : "创建分享"}
title={createdShare ? t('Share created') : t('Create Share')}
open={open}
onOk={createdShare ? onOk : handleOk}
onCancel={onCancel}
confirmLoading={loading}
destroyOnHidden
okText={createdShare ? "完成" : "创建"}
okText={createdShare ? t('Done') : t('Create')}
>
{createdShare ? renderSuccess() : renderForm()}
</Modal>
);
});
});

View File

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

View File

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

View File

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

View File

@@ -18,7 +18,7 @@ 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');
@@ -43,7 +43,7 @@ export function useFileExplorer(navKey: string) {
}));
setProcessorTypes(processors);
} catch (e: any) {
message.error(e.message || '加载失败');
message.error(e.message || 'Load failed');
} finally {
setLoading(false);
}
@@ -90,4 +90,4 @@ export function useFileExplorer(navKey: string) {
refresh,
handleSortChange
};
}
}

View File

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

View File

@@ -84,7 +84,7 @@ export function useUploader(path: string, onUploadComplete: () => void) {
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}`);
}
}
@@ -101,4 +101,4 @@ export function useUploader(path: string, onUploadComplete: () => void) {
handleFileDrop,
startUpload,
};
}
}

View File

@@ -4,6 +4,8 @@ import { UserOutlined, LockOutlined, GithubOutlined, SendOutlined, WechatOutline
import { useAuth } from '../contexts/AuthContext';
import { useSystemStatus } from '../contexts/SystemContext';
import { useNavigate } from 'react-router';
import { useI18n } from '../i18n';
import LanguageSwitcher from '../components/LanguageSwitcher';
const { Title, Text } = Typography;
@@ -15,12 +17,13 @@ export default function LoginPage() {
const [err, setErr] = useState('');
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const { t } = useI18n();
const handleSubmit = async () => {
const u = username.trim();
const p = password;
if (!u || !p) {
setErr('请输入用户名与密码');
setErr(t('Please enter username and password'));
return;
}
console.debug('[LoginPage] submit ->', { username: u, passwordLength: p.length });
@@ -31,7 +34,7 @@ export default function LoginPage() {
navigate('/');
} catch (e: any) {
console.error('[LoginPage] login failed:', e);
setErr(e.message || '登录失败');
setErr(e.message || t('Login failed'));
} finally {
setLoading(false);
}
@@ -44,19 +47,22 @@ export default function LoginPage() {
height: '100vh',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(to right, #f0f2f5, #d7d7d7)'
background: 'linear-gradient(to right, var(--ant-color-bg-layout, #f0f2f5), var(--ant-color-fill-secondary, #d7d7d7))'
}}>
<div style={{ position: 'fixed', top: 12, right: 12, zIndex: 1000 }}>
<LanguageSwitcher />
</div>
<div style={{
display: 'flex',
width: '80%',
maxWidth: '1200px',
height: '70%',
maxHeight: '700px',
backgroundColor: 'rgba(255, 255, 255, 0.8)',
backgroundColor: 'var(--ant-color-bg-container, #fff)',
borderRadius: '20px',
boxShadow: '0 4px 30px rgba(0, 0, 0, 0.1)',
backdropFilter: 'blur(5px)',
border: '1px solid rgba(255, 255, 255, 0.3)',
border: '1px solid var(--ant-color-border-secondary, #e5e5e5)',
overflow: 'hidden'
}}>
<div style={{
@@ -71,9 +77,9 @@ export default function LoginPage() {
<div style={{ marginBottom: '24px' }}>
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', marginBottom: 8 }}>
<img src={status?.logo} alt="Foxel Logo" style={{ width: 32, marginRight: 16 }} />
<Title level={2} style={{ margin: 0, color: '#111' }}></Title>
<Title level={2} style={{ margin: 0, color: 'var(--ant-color-text, #111)' }}>{t('Welcome Back')}</Title>
</div>
<Text type="secondary" style={{ display: 'block', textAlign: 'center' }}> Foxel </Text>
<Text type="secondary" style={{ display: 'block', textAlign: 'center' }}>{t('Sign in to your Foxel account')}</Text>
</div>
{err && <Alert message={err} type="error" showIcon style={{ marginBottom: 24 }} />}
@@ -82,7 +88,7 @@ export default function LoginPage() {
<Form.Item>
<Input
prefix={<UserOutlined />}
placeholder="用户名/邮箱"
placeholder={t('Username / Email')}
value={username}
onChange={e => setUsername(e.target.value)}
required
@@ -92,7 +98,7 @@ export default function LoginPage() {
<Form.Item>
<Input.Password
prefix={<LockOutlined />}
placeholder="密码"
placeholder={t('Password')}
value={password}
onChange={e => setPassword(e.target.value)}
required
@@ -106,7 +112,7 @@ export default function LoginPage() {
loading={loading}
style={{ width: '100%' }}
>
{t('Sign In')}
</Button>
</Form.Item>
</Form>
@@ -115,8 +121,8 @@ export default function LoginPage() {
</div>
<div style={{
width: '50%',
backgroundColor: '#f0f2f5',
backgroundImage: `radial-gradient(#d7d7d7 1px, transparent 1px)`,
backgroundColor: 'var(--ant-color-fill-tertiary, #f0f2f5)',
backgroundImage: `radial-gradient(var(--ant-color-fill-secondary, #d7d7d7) 1px, transparent 1px)`,
backgroundSize: '16px 16px',
display: 'flex',
flexDirection: 'column',
@@ -125,40 +131,40 @@ export default function LoginPage() {
padding: '48px'
}}>
<div style={{ maxWidth: '500px' }}>
<Title level={3}></Title>
<Title level={3}>{t('Your next-generation file manager')}</Title>
<Text type="secondary" style={{ fontSize: '16px', lineHeight: '1.8' }}>
Foxel 访
</Text>
<div style={{ marginTop: '32px' }}>
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Card size="small" variant="borderless" style={{ backgroundColor: 'rgba(255,255,255,0.6)' }}>
<Card size="small" variant="borderless" style={{ backgroundColor: 'var(--ant-color-bg-container)' }}>
<Space>
<CloudSyncOutlined style={{ fontSize: '20px', color: '#1677ff' }} />
<Text>访</Text>
<CloudSyncOutlined style={{ fontSize: '20px', color: 'var(--ant-color-primary, #1677ff)' }} />
<Text>{t('Cross-platform sync, access anywhere')}</Text>
</Space>
</Card>
<Card size="small" variant="borderless" style={{ backgroundColor: 'rgba(255,255,255,0.6)' }}>
<Card size="small" variant="borderless" style={{ backgroundColor: 'var(--ant-color-bg-container)' }}>
<Space>
<SearchOutlined style={{ fontSize: '20px', color: '#1677ff' }} />
<Text>AI </Text>
<SearchOutlined style={{ fontSize: '20px', color: 'var(--ant-color-primary, #1677ff)' }} />
<Text>{t('AI-powered search for quick find')}</Text>
</Space>
</Card>
<Card size="small" variant="borderless" style={{ backgroundColor: 'rgba(255,255,255,0.6)' }}>
<Card size="small" variant="borderless" style={{ backgroundColor: 'var(--ant-color-bg-container)' }}>
<Space>
<ShareAltOutlined style={{ fontSize: '20px', color: '#1677ff' }} />
<Text></Text>
<ShareAltOutlined style={{ fontSize: '20px', color: 'var(--ant-color-primary, #1677ff)' }} />
<Text>{t('Flexible sharing and collaboration')}</Text>
</Space>
</Card>
<Card size="small" variant="borderless" style={{ backgroundColor: 'rgba(255,255,255,0.6)' }}>
<Card size="small" variant="borderless" style={{ backgroundColor: 'var(--ant-color-bg-container)' }}>
<Space>
<ApartmentOutlined style={{ fontSize: '20px', color: '#1677ff' }} />
<Text></Text>
<ApartmentOutlined style={{ fontSize: '20px', color: 'var(--ant-color-primary, #1677ff)' }} />
<Text>{t('Powerful automation to simplify tasks')}</Text>
</Space>
</Card>
</Space>
</div>
<div style={{ marginTop: '48px', textAlign: 'center' }}>
<Text type="secondary"></Text>
<Text type="secondary">{t('Join our community:')}</Text>
<Button type="text" icon={<GithubOutlined />} href="https://github.com/DrizzleTime/Foxel" target="_blank">GitHub</Button>
<Button type="text" icon={<SendOutlined />} href="https://t.me/+thDsBfyqJxZkNTU1" target="_blank">Telegram</Button>
<Button type="text" icon={<WechatOutlined />}></Button>

View File

@@ -2,6 +2,7 @@ import { memo, useState, useEffect, useCallback } from 'react';
import { Table, message, Tag, Input, Select, Button, Space, Modal, DatePicker } from 'antd';
import PageCard from '../components/PageCard';
import { logsApi, type LogItem, type PaginatedLogs } from '../api/logs';
import { useI18n } from '../i18n';
import { format, formatISO } from 'date-fns';
const { RangePicker } = DatePicker;
@@ -20,6 +21,7 @@ const LogsPage = memo(function LogsPage() {
end_time: '',
});
const [selectedLog, setSelectedLog] = useState<LogItem | null>(null);
const { t } = useI18n();
const fetchList = useCallback(async () => {
setLoading(true);
@@ -31,7 +33,7 @@ const LogsPage = memo(function LogsPage() {
const res = await logsApi.list(params);
setData(res);
} catch (e: any) {
message.error(e.message || '加载失败');
message.error(e.message || t('Load failed'));
} finally {
setLoading(false);
}
@@ -43,18 +45,18 @@ const LogsPage = memo(function LogsPage() {
const handleClearLogs = () => {
Modal.confirm({
title: '确认清理日志?',
content: '该操作将删除选定时间范围内的所有日志,且不可恢复。',
title: t('Confirm clear logs?'),
content: t('This will delete logs in selected range irreversibly.'),
onOk: async () => {
try {
const params = { start_time: filters.start_time, end_time: filters.end_time };
if (!params.start_time) delete (params as any).start_time;
if (!params.end_time) delete (params as any).end_time;
const res = await logsApi.clear(params);
message.success(`成功清理 ${res.deleted_count} 条日志`);
message.success(t('Cleared {count} logs', { count: String(res.deleted_count) }));
fetchList();
} catch (e: any) {
message.error(e.message || '清理失败');
message.error(e.message || t('Clear failed'));
}
},
});
@@ -62,13 +64,13 @@ const LogsPage = memo(function LogsPage() {
const columns = [
{
title: '时间',
title: t('Time'),
dataIndex: 'timestamp',
width: 180,
render: (ts: string) => format(new Date(ts), 'yyyy-MM-dd HH:mm:ss'),
},
{
title: '级别',
title: t('Level'),
dataIndex: 'level',
width: 100,
render: (level: string) => {
@@ -76,20 +78,20 @@ const LogsPage = memo(function LogsPage() {
return <Tag color={color}>{level}</Tag>;
},
},
{ title: '来源', dataIndex: 'source', width: 180 },
{ title: '消息', dataIndex: 'message', ellipsis: true },
{ title: t('Source'), dataIndex: 'source', width: 180 },
{ title: t('Message'), dataIndex: 'message', ellipsis: true },
{
title: '操作',
title: t('Actions'),
width: 100,
render: (_: any, rec: LogItem) => (
<Button size="small" onClick={() => setSelectedLog(rec)}></Button>
<Button size="small" onClick={() => setSelectedLog(rec)}>{t('Details')}</Button>
),
},
];
return (
<PageCard
title="系统日志"
title={t('System Logs')}
extra={
<Space>
<RangePicker
@@ -105,7 +107,7 @@ const LogsPage = memo(function LogsPage() {
/>
<Select
style={{ width: 120 }}
placeholder="级别"
placeholder={t('Level')}
allowClear
value={filters.level || undefined}
onChange={level => setFilters(f => ({ ...f, level: level || '', page: 1 }))}
@@ -113,12 +115,12 @@ const LogsPage = memo(function LogsPage() {
/>
<Input.Search
style={{ width: 240 }}
placeholder="搜索来源"
placeholder={t('Search source')}
onSearch={source => setFilters(f => ({ ...f, source, page: 1 }))}
allowClear
/>
<Button onClick={fetchList} loading={loading}></Button>
<Button danger onClick={handleClearLogs}></Button>
<Button onClick={fetchList} loading={loading}>{t('Refresh')}</Button>
<Button danger onClick={handleClearLogs}>{t('Clear')}</Button>
</Space>
}
>
@@ -136,14 +138,14 @@ const LogsPage = memo(function LogsPage() {
}}
/>
<Modal
title="日志详情"
title={t('Log Details')}
open={!!selectedLog}
onCancel={() => setSelectedLog(null)}
footer={null}
width={800}
>
{selectedLog && (
<pre style={{ maxHeight: '60vh', overflow: 'auto', background: '#f5f5f5', padding: 12 }}>
<pre style={{ maxHeight: '60vh', overflow: 'auto', background: 'var(--ant-color-fill-tertiary, #f5f5f5)', padding: 12 }}>
{JSON.stringify(selectedLog.details, null, 2)}
</pre>
)}
@@ -152,4 +154,4 @@ const LogsPage = memo(function LogsPage() {
);
});
export default LogsPage;
export default LogsPage;

View File

@@ -1,5 +1,8 @@
import { Empty } from 'antd';
import { useI18n } from '../i18n';
export default function OfflineDownloadPage() {
return <Empty description="暂无离线下载任务" />;
const { t } = useI18n();
return <Empty description={t('No offline download tasks')} />;
}

View File

@@ -0,0 +1,372 @@
import { memo, useEffect, useMemo, useState } from 'react';
import { Button, Modal, Form, Input, Tag, message, Card, Typography, Popconfirm, Empty, Skeleton, theme, Divider, Tabs, Select, Pagination } from 'antd';
import { GithubOutlined, LinkOutlined } from '@ant-design/icons';
import { pluginsApi, type PluginItem } from '../api/plugins';
import { loadPluginFromUrl, ensureManifest } from '../plugins/runtime';
import { reloadPluginApps } from '../apps/registry';
import { useI18n } from '../i18n';
import { fetchRepoList, type RepoItem, buildCenterUrl } from '../api/pluginCenter';
const PluginsPage = memo(function PluginsPage() {
const [data, setData] = useState<PluginItem[]>([]);
const [adding, setAdding] = useState(false);
const [loading, setLoading] = useState(false);
const [q, setQ] = useState('');
const [tab, setTab] = useState<'installed' | 'discover'>('installed');
const [repoLoading, setRepoLoading] = useState(false);
const [repoQ, setRepoQ] = useState('');
const [repoSort, setRepoSort] = useState<'createdAt' | 'downloads'>('createdAt');
const [repoPage, setRepoPage] = useState(1);
const [repoPageSize, setRepoPageSize] = useState(12);
const [repoTotal, setRepoTotal] = useState(0);
const [repoItems, setRepoItems] = useState<RepoItem[]>([]);
const [installingKeys, setInstallingKeys] = useState<Record<string, boolean>>({});
const [form] = Form.useForm<{ url: string }>();
const { token } = theme.useToken();
const { t } = useI18n();
const reload = async () => {
try { setLoading(true); setData(await pluginsApi.list()); } finally { setLoading(false); }
};
useEffect(() => { reload(); }, []);
const installedKeySet = useMemo(() => {
const set = new Set<string>();
data.forEach(p => { if (p.key) set.add(p.key); });
return set;
}, [data]);
const reloadRepo = async () => {
try {
setRepoLoading(true);
const res = await fetchRepoList({ query: repoQ || undefined, sort: repoSort, page: repoPage, pageSize: repoPageSize });
setRepoItems(res.items || []);
setRepoTotal(res.total || 0);
} catch (e) {
setRepoItems([]);
setRepoTotal(0);
} finally {
setRepoLoading(false);
}
};
useEffect(() => {
if (tab === 'discover') reloadRepo();
}, [tab, repoQ, repoSort, repoPage, repoPageSize]);
const handleAdd = async () => {
try {
const { url } = await form.validateFields();
const created = await pluginsApi.create({ url });
try {
const p = await loadPluginFromUrl(created.url);
await ensureManifest(created.id, p);
} catch {}
setAdding(false);
form.resetFields();
await reload();
await reloadPluginApps();
message.success(t('Installed successfully'));
} catch {}
};
const filtered = useMemo(() => {
const s = q.trim().toLowerCase();
if (!s) return data;
return data.filter(p => (
(p.name || '').toLowerCase().includes(s)
|| (p.author || '').toLowerCase().includes(s)
|| (p.url || '').toLowerCase().includes(s)
|| (p.description || '').toLowerCase().includes(s)
|| (p.supported_exts || []).some(e => e.toLowerCase().includes(s))
));
}, [data, q]);
const renderCard = (p: PluginItem) => {
const icon = p.icon || '/plugins/demo-text-viewer.svg';
const name = p.name || `${t('Plugin')} ${p.id}`;
const exts = (p.supported_exts || []).slice(0, 6);
const more = (p.supported_exts || []).length - exts.length;
const title = (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<img src={icon} alt={name} style={{ width: 24, height: 24, objectFit: 'contain' }} onError={(e) => { (e.currentTarget as HTMLImageElement).src = '/plugins/demo-text-viewer.svg'; }} />
<span>{name}</span>
{p.version && <Tag color="blue" style={{ marginLeft: 'auto' }}>{p.version}</Tag>}
</div>
);
return (
<Card
key={p.id}
title={title}
hoverable
size="small"
styles={{ body: { padding: 12 } } as any}
style={{ borderRadius: 10, boxShadow: token.boxShadowTertiary }}
actions={[
<a key="open" href={p.url} target="_blank" rel="noreferrer">{t('Open Link')}</a>,
<Button key="copy" type="link" size="small" onClick={async () => { try { await navigator.clipboard.writeText(p.url); message.success(t('Link copied')); } catch {} }}>{t('Copy Link')}</Button>,
<Popconfirm key="del" title={t('Confirm delete this plugin?')} onConfirm={async () => { await pluginsApi.remove(p.id); await reload(); await reloadPluginApps(); }}>
<Button type="link" danger size="small">{t('Delete')}</Button>
</Popconfirm>
]}
>
<div style={{ display: 'flex', gap: 12 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<Typography.Paragraph
style={{ marginBottom: 8, minHeight: 44, lineHeight: '22px' }}
ellipsis={{ rows: 2 }}
>
{p.description || '(暂无描述)'}
</Typography.Paragraph>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'nowrap', overflow: 'hidden', whiteSpace: 'nowrap', minWidth: 0, flex: 1 }}>
{(exts.length > 0 ? exts : ['任意']).map(e => <Tag key={e} style={{ flex: 'none' }}>{e}</Tag>)}
</div>
{more > 0 && <Tag style={{ flex: 'none' }}>+{more}</Tag>}
</div>
<Divider style={{ margin: '8px 0' }} />
{(p.author || p.github || p.website) && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: token.colorTextTertiary, fontSize: 12 }}>
{p.author && <span>{t('Author')}: {p.author}</span>}
<span style={{ marginLeft: 'auto', display: 'inline-flex', alignItems: 'center', gap: 8 }}>
{p.github && (
<a href={p.github || undefined} target="_blank" rel="noreferrer" title="GitHub">
<GithubOutlined style={{ fontSize: 16, color: token.colorTextTertiary }} />
</a>
)}
{p.website && (
<a href={p.website || undefined} target="_blank" rel="noreferrer" title={t('Website')}>
<LinkOutlined style={{ fontSize: 16, color: token.colorTextTertiary }} />
</a>
)}
</span>
</div>
)}
</div>
</div>
</Card>
);
};
const renderRepoCard = (item: RepoItem) => {
const icon = item.icon || '/plugins/demo-text-viewer.svg';
const name = item.name || item.key;
const exts = (item.supportedExts || []).slice(0, 6);
const more = (item.supportedExts || []).length - exts.length;
const installed = installedKeySet.has(item.key);
const installing = !!installingKeys[item.key];
const title = (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<img src={icon} alt={name} style={{ width: 24, height: 24, objectFit: 'contain' }} onError={(e) => { (e.currentTarget as HTMLImageElement).src = '/plugins/demo-text-viewer.svg'; }} />
<span>{name}</span>
{item.version && <Tag color="blue" style={{ marginLeft: 'auto' }}>{item.version}</Tag>}
</div>
);
return (
<Card
key={item.key + '@' + (item.version || '')}
title={title}
hoverable
size="small"
styles={{ body: { padding: 12 } } as any}
style={{ borderRadius: 10, boxShadow: token.boxShadowTertiary }}
actions={[
typeof item.downloads === 'number' ? (
<span key="dl" style={{ color: token.colorTextTertiary, fontSize: 12 }}>
{t('Downloads')}: {item.downloads}
</span>
) : (
<span key="dl-gap" />
),
<Button
key="install"
type="link"
size="small"
disabled={installed || installing}
loading={installing}
onClick={async () => {
try {
setInstallingKeys(s => ({ ...s, [item.key]: true }));
const url = buildCenterUrl(item.directUrl);
const created = await pluginsApi.create({ url });
try {
const p = await loadPluginFromUrl(created.url);
await ensureManifest(created.id, p);
} catch {}
await reload();
await reloadPluginApps();
message.success(t('Installed successfully'));
} catch (e: any) {
message.error(e?.message || 'Install failed');
} finally {
setInstallingKeys(s => ({ ...s, [item.key]: false }));
}
}}
>
{installed ? t('Installed already') : t('Install')}
</Button>
]}
>
<Typography.Paragraph
style={{ marginBottom: 8, minHeight: 44, lineHeight: '22px' }}
ellipsis={{ rows: 2 }}
>
{item.description || '(暂无描述)'}
</Typography.Paragraph>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'nowrap', overflow: 'hidden', whiteSpace: 'nowrap', minWidth: 0, flex: 1 }}>
{(exts.length > 0 ? exts : ['任意']).map(e => <Tag key={e} style={{ flex: 'none' }}>{e}</Tag>)}
</div>
{more > 0 && <Tag style={{ flex: 'none' }}>+{more}</Tag>}
</div>
<Divider style={{ margin: '8px 0' }} />
{(item.author || item.github || item.website) && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: token.colorTextTertiary, fontSize: 12 }}>
{item.author && <span>{t('Author')}: {item.author}</span>}
<span style={{ marginLeft: 'auto', display: 'inline-flex', alignItems: 'center', gap: 8 }}>
{item.github && (
<a href={item.github || undefined} target="_blank" rel="noreferrer" title="GitHub">
<GithubOutlined style={{ fontSize: 16, color: token.colorTextTertiary }} />
</a>
)}
{item.website && (
<a href={item.website || undefined} target="_blank" rel="noreferrer" title={t('Website')}>
<LinkOutlined style={{ fontSize: 16, color: token.colorTextTertiary }} />
</a>
)}
</span>
</div>
)}
</Card>
);
};
return (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
<Button type="primary" onClick={() => setAdding(true)}>{t('Install App')}</Button>
{tab === 'installed' && <Button onClick={reload} loading={loading}>{t('Refresh')}</Button>}
<div style={{ marginLeft: 'auto' }} />
</div>
<Tabs
activeKey={tab}
onChange={(k) => setTab(k as any)}
items={[
{
key: 'installed',
label: t('Installed'),
children: (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<Input
placeholder={t('Search name/author/url/extension')}
value={q}
onChange={e => setQ(e.target.value)}
allowClear
style={{ maxWidth: 360 }}
onPressEnter={() => reload()}
/>
</div>
{loading ? (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 12 }}>
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i} style={{ borderRadius: 10 }}>
<Skeleton active avatar paragraph={{ rows: 3 }} />
</Card>
))}
</div>
) : filtered.length === 0 ? (
<Empty description={t('No plugins')} />
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: 12 }}>
{filtered.map(renderCard)}
</div>
)}
</>
)
},
{
key: 'discover',
label: t('Discover'),
children: (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
<Input
placeholder={t('Search apps')}
value={repoQ}
onChange={e => { setRepoQ(e.target.value); setRepoPage(1); }}
allowClear
style={{ maxWidth: 360 }}
onPressEnter={() => { setRepoPage(1); reloadRepo(); }}
/>
<Select
value={repoSort}
style={{ width: 200 }}
onChange={(v) => { setRepoSort(v); setRepoPage(1); }}
options={[
{ value: 'createdAt', label: t('Created (newest)') },
{ value: 'downloads', label: t('Downloads') },
]}
/>
<Button
icon={<LinkOutlined />}
href="https://center.foxel.cc"
target="_blank"
rel="noreferrer"
>
Foxel Center
</Button>
</div>
{repoLoading ? (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 12 }}>
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i} style={{ borderRadius: 10 }}>
<Skeleton active avatar paragraph={{ rows: 3 }} />
</Card>
))}
</div>
) : repoItems.length === 0 ? (
<Empty description={t('No results')} />
) : (
<>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: 12 }}>
{repoItems.map(renderRepoCard)}
</div>
<div style={{ display: 'flex', justifyContent: 'center', marginTop: 12 }}>
<Pagination
current={repoPage}
pageSize={repoPageSize}
total={repoTotal}
showSizeChanger
pageSizeOptions={[12, 24, 48].map(String)}
onChange={(p, ps) => { setRepoPage(p); setRepoPageSize(ps); }}
/>
</div>
</>
)}
</>
)
}
]}
/>
<Modal
title={t('Install App')}
open={adding}
onCancel={() => setAdding(false)}
onOk={handleAdd}
okText={t('Install')}
>
<Form form={form} layout="vertical">
<Form.Item name="url" label={t('App URL')} rules={[{ required: true }, { type: 'url', message: t('Please input a valid URL') }]}>
<Input placeholder="https://example.com/plugin.js" />
</Form.Item>
</Form>
</Modal>
</>
);
});
export default PluginsPage;

View File

@@ -1,9 +1,10 @@
import { memo, useState, useEffect, useCallback } from 'react';
import { Card, message, List, Typography, Button, Empty, Breadcrumb } from 'antd';
import { Card, List, Typography, Button, Empty, Breadcrumb } from 'antd';
import { FileOutlined, FolderOutlined, DownloadOutlined } from '@ant-design/icons';
import { shareApi, type ShareInfo } from '../../api/share';
import { type VfsEntry } from '../../api/vfs';
import { format, parseISO } from 'date-fns';
import { useI18n } from '../../i18n';
const { Title, Text } = Typography;
@@ -11,13 +12,15 @@ interface DirectoryViewerProps {
token: string;
shareInfo: ShareInfo;
password?: string;
onFileClick: (entry: VfsEntry, path: string) => void;
}
export const DirectoryViewer = memo(function DirectoryViewer({ token, shareInfo, password }: DirectoryViewerProps) {
export const DirectoryViewer = memo(function DirectoryViewer({ token, shareInfo, password, onFileClick }: DirectoryViewerProps) {
const [loading, setLoading] = useState(true);
const [entries, setEntries] = useState<VfsEntry[]>([]);
const [currentPath, setCurrentPath] = useState('/');
const [error, setError] = useState('');
const { t } = useI18n();
const loadData = useCallback(async (p: string) => {
setLoading(true);
@@ -27,7 +30,7 @@ export const DirectoryViewer = memo(function DirectoryViewer({ token, shareInfo,
setEntries(listing.entries || []);
setCurrentPath(p);
} catch (e: any) {
setError(e.message || '加载分享失败');
setError(e.message || t('Share load failed'));
} finally {
setLoading(false);
}
@@ -38,11 +41,11 @@ export const DirectoryViewer = memo(function DirectoryViewer({ token, shareInfo,
}, [loadData, currentPath]);
const handleEntryClick = (entry: VfsEntry) => {
const newPath = (currentPath === '/' ? '' : currentPath) + '/' + entry.name;
if (entry.is_dir) {
const newPath = (currentPath === '/' ? '' : currentPath) + '/' + entry.name;
loadData(newPath);
} else {
message.info('暂不支持预览');
onFileClick(entry, newPath);
}
};
@@ -52,7 +55,7 @@ export const DirectoryViewer = memo(function DirectoryViewer({ token, shareInfo,
const renderBreadcrumb = () => {
const parts = currentPath.split('/').filter(Boolean);
const items = [{ title: '全部文件', path: '/' }];
const items = [{ title: t('Root'), path: '/' }];
parts.forEach((part, i) => {
const path = '/' + parts.slice(0, i + 1).join('/');
items.push({ title: part, path });
@@ -81,8 +84,13 @@ export const DirectoryViewer = memo(function DirectoryViewer({ token, shareInfo,
<Card>
<Title level={4}>{shareInfo?.name}</Title>
<Text type="secondary">
{shareInfo && format(parseISO(shareInfo.created_at), 'yyyy-MM-dd')}
{shareInfo?.expires_at && `,将于 ${format(parseISO(shareInfo.expires_at), 'yyyy-MM-dd')} 过期`}
{t('Created on {date}', { date: format(parseISO(shareInfo.created_at), 'yyyy-MM-dd') })}
{shareInfo?.expires_at ? (
<>
{' '}
{t('Expires on {date}', { date: format(parseISO(shareInfo.expires_at), 'yyyy-MM-dd') })}
</>
) : null}
</Text>
<div style={{ margin: '16px 0' }}>
{renderBreadcrumb()}
@@ -107,4 +115,4 @@ export const DirectoryViewer = memo(function DirectoryViewer({ token, shareInfo,
</Card>
</div>
);
});
});

View File

@@ -1,39 +1,47 @@
import { memo, useState, useEffect } from 'react';
import { Card, Spin, Button, Typography, Empty } from 'antd';
import { DownloadOutlined } from '@ant-design/icons';
import { DownloadOutlined, ArrowLeftOutlined } from '@ant-design/icons';
import { shareApi, type ShareInfo } from '../../api/share';
import { type VfsEntry } from '../../api/vfs';
import { format, parseISO } from 'date-fns';
import ReactMarkdown from 'react-markdown';
import { VideoViewer } from './VideoViewer';
import { useI18n } from '../../i18n';
const { Title, Text } = Typography;
const isImageViewer = (name: string) => /\.(jpg|jpeg|png|gif|bmp|webp|svg)$/i.test(name);
const isVideoViewable = (name: string) => /\.(mp4|webm|ogg|m4v|mov)$/i.test(name);
interface FileViewerProps {
token: string;
shareInfo: ShareInfo;
entry: VfsEntry;
password?: string;
onBack: () => void;
path: string;
}
export const FileViewer = memo(function FileViewer({ token, shareInfo, entry, password }: FileViewerProps) {
export const FileViewer = memo(function FileViewer({ token, shareInfo, entry, password, onBack, path }: FileViewerProps) {
const [loading, setLoading] = useState(true);
const [content, setContent] = useState<string>('');
const [error, setError] = useState('');
const { t } = useI18n();
useEffect(() => {
const loadFileContent = async () => {
setLoading(true);
setError('');
try {
const url = shareApi.downloadUrl(token, entry.name, password);
const url = shareApi.downloadUrl(token, path, password);
const response = await fetch(url);
if (!response.ok) {
throw new Error('无法加载文件');
throw new Error('Unable to load file');
}
const text = await response.text();
setContent(text);
} catch (e: any) {
setError(e.message || '加载文件失败');
setError(e.message || 'Failed to load file');
} finally {
setLoading(false);
}
@@ -44,7 +52,7 @@ export const FileViewer = memo(function FileViewer({ token, shareInfo, entry, pa
} else {
setLoading(false);
}
}, [token, entry.name, password]);
}, [token, entry.name, password, path]);
const renderContent = () => {
if (loading) {
@@ -53,21 +61,33 @@ export const FileViewer = memo(function FileViewer({ token, shareInfo, entry, pa
if (error) {
return <Empty description={error} />;
}
const downloadUrl = shareApi.downloadUrl(token, path, password);
if (isImageViewer(entry.name)) {
return <img src={downloadUrl} alt={entry.name} style={{ maxWidth: '100%' }} />;
}
if (isVideoViewable(entry.name)) {
return <VideoViewer token={token} entry={entry} password={password} path={path} />;
}
if (entry.name.endsWith('.md')) {
return <ReactMarkdown>{content}</ReactMarkdown>;
}
return (
return (
<Empty
description={
<div>
<p>线</p>
<p>{t('Preview not supported for this file type')}</p>
<Button
type="primary"
icon={<DownloadOutlined />}
href={shareApi.downloadUrl(token, entry.name, password)}
href={downloadUrl}
download
>
{t('Download File')}
</Button>
</div>
}
@@ -80,17 +100,29 @@ export const FileViewer = memo(function FileViewer({ token, shareInfo, entry, pa
<Card>
<Title level={4}>{entry.name}</Title>
<Text type="secondary">
{shareInfo && format(parseISO(shareInfo.created_at), 'yyyy-MM-dd')}
{shareInfo?.expires_at && `,将于 ${format(parseISO(shareInfo.expires_at), 'yyyy-MM-dd')} 过期`}
{t('Created on {date}', { date: format(parseISO(shareInfo.created_at), 'yyyy-MM-dd') })}
{shareInfo?.expires_at ? (
<>
{' '}
{t('Expires on {date}', { date: format(parseISO(shareInfo.expires_at), 'yyyy-MM-dd') })}
</>
) : null}
</Text>
<div style={{ marginTop: 16 }}>
<Button
style={{ marginBottom: 16, marginRight: 8 }}
icon={<ArrowLeftOutlined />}
onClick={onBack}
>
{t('Back')}
</Button>
<Button
style={{ marginBottom: 16 }}
icon={<DownloadOutlined />}
href={shareApi.downloadUrl(token, entry.name, password)}
href={shareApi.downloadUrl(token, path, password)}
download
>
{t('Download')}
</Button>
</div>
<Card>
@@ -99,4 +131,4 @@ export const FileViewer = memo(function FileViewer({ token, shareInfo, entry, pa
</Card>
</div>
);
});
});

View File

@@ -0,0 +1,50 @@
import React, { useEffect, useRef } from 'react';
import Artplayer from 'artplayer';
import { shareApi } from '../../api/share';
import type { VfsEntry } from '../../api/vfs';
interface VideoViewerProps {
token: string;
entry: VfsEntry;
password?: string;
path: string;
}
export const VideoViewer: React.FC<VideoViewerProps> = ({ token, entry, password, path }) => {
const artRef = useRef<HTMLDivElement | null>(null);
const artInstance = useRef<Artplayer | null>(null);
useEffect(() => {
const videoUrl = shareApi.downloadUrl(token, path, password);
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 () => {
if (artInstance.current) {
artInstance.current.destroy();
}
};
}, [token, entry.name, password, path]);
return (
<div
ref={artRef}
style={{
width: '100%',
height: '450px',
backgroundColor: '#000'
}}
/>
);
};

View File

@@ -5,15 +5,17 @@ import { shareApi, type ShareInfo } from '../../api/share';
import { type VfsEntry } from '../../api/vfs';
import { DirectoryViewer } from './DirectoryViewer';
import { FileViewer } from './FileViewer';
import { useI18n } from '../../i18n';
const PublicSharePage = memo(function PublicSharePage() {
const { token } = useParams<{ token: string }>();
const [loading, setLoading] = useState(true);
const [shareInfo, setShareInfo] = useState<ShareInfo | null>(null);
const [entry, setEntry] = useState<VfsEntry | null>(null);
const [previewFile, setPreviewFile] = useState<{ entry: VfsEntry, path: string } | null>(null);
const [error, setError] = useState('');
const [password, setPassword] = useState('');
const [verified, setVerified] = useState(false);
const { t } = useI18n();
const loadData = useCallback(async (pwd?: string) => {
if (!token) return;
@@ -37,12 +39,14 @@ const PublicSharePage = memo(function PublicSharePage() {
const listing = await shareApi.listDir(token, '/', currentPassword);
if (listing.entries.length === 1) {
const singleEntry = listing.entries[0];
setEntry(singleEntry);
if (!singleEntry.is_dir) {
setPreviewFile({ entry: singleEntry, path: '/' + singleEntry.name });
}
}
}
} catch (e: any) {
setError(e.message || '加载分享失败');
setError(e.message || t('Share load failed'));
if (e.message === '需要密码') {
setVerified(false);
}
@@ -64,7 +68,7 @@ const PublicSharePage = memo(function PublicSharePage() {
setError('');
loadData(values.password_input);
} catch (e: any) {
message.error(e.message || '密码错误');
message.error(e.message || t('Wrong password'));
}
};
@@ -79,14 +83,14 @@ const PublicSharePage = memo(function PublicSharePage() {
if (shareInfo?.access_type === 'password' && !verified) {
return (
<div style={{ padding: '24px', maxWidth: 400, margin: '100px auto' }}>
<Card title="需要密码">
<Card title={t('Password Required')}>
<Form onFinish={handlePasswordSubmit}>
<Form.Item name="password_input" rules={[{ required: true, message: '请输入密码' }]}>
<Input.Password placeholder="请输入密码" />
<Form.Item name="password_input" rules={[{ required: true, message: t('Please enter password') }]}>
<Input.Password placeholder={t('Please enter password')} />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block>
{t('Confirm')}
</Button>
</Form.Item>
</Form>
@@ -96,14 +100,30 @@ const PublicSharePage = memo(function PublicSharePage() {
}
if (!shareInfo) {
return <div style={{ textAlign: 'center', padding: 50 }}><Empty description="无法加载分享信息" /></div>;
return <div style={{ textAlign: 'center', padding: 50 }}><Empty description={t('Unable to load share info')} /></div>;
}
if (entry && !entry.is_dir) {
return <FileViewer token={token!} shareInfo={shareInfo} entry={entry} password={password} />;
} else {
return <DirectoryViewer token={token!} shareInfo={shareInfo} password={password} />;
const handleFileClick = (entry: VfsEntry, path: string) => {
setPreviewFile({ entry, path });
};
const handleBack = () => {
setPreviewFile(null);
};
if (previewFile) {
return (
<FileViewer
token={token!}
shareInfo={shareInfo}
entry={previewFile.entry}
password={password}
onBack={handleBack}
path={previewFile.path}
/>
);
}
return <DirectoryViewer token={token!} shareInfo={shareInfo} password={password} onFileClick={handleFileClick} />;
});
export default PublicSharePage;
export default PublicSharePage;

View File

@@ -3,6 +3,8 @@ import { Form, Input, Button, Card, message, Steps, Select, Space, Typography }
import { UserOutlined, LockOutlined, HddOutlined } from '@ant-design/icons';
import { adaptersApi } from '../api/adapters';
import { useAuth } from '../contexts/AuthContext';
import { useI18n } from '../i18n';
import LanguageSwitcher from '../components/LanguageSwitcher';
const { Title, Text } = Typography;
const { Step } = Steps;
@@ -12,12 +14,13 @@ const SetupPage = () => {
const [currentStep, setCurrentStep] = useState(0);
const [form] = Form.useForm();
const { login, register } = useAuth();
const { t } = useI18n();
const onFinish = async (values: any) => {
setLoading(true);
try {
await register(values.username, values.password, values.email, values.full_name);
await login(values.username, values.password);
message.success('初始化成功!正在为您登录,请不要刷新。');
message.success(t('Initialization succeeded! Logging you in...'));
setTimeout(async () => {
await adaptersApi.create({
name: values.adapter_name,
@@ -33,7 +36,7 @@ const SetupPage = () => {
}, 2000);
} catch (error: any) {
console.log(error)
message.error(error.response?.data?.msg || '初始化失败,请稍后重试');
message.error(error.response?.data?.msg || t('Initialization failed, please try later'));
} finally {
setLoading(false);
}
@@ -57,13 +60,13 @@ const SetupPage = () => {
const steps = [
{
title: '数据库设置',
title: t('Database Setup'),
content: (
<>
<Title level={4}></Title>
<Text type="secondary" style={{ marginBottom: 24, display: 'block' }}></Text>
<Title level={4}>{t('Choose database driver')}</Title>
<Text type="secondary" style={{ marginBottom: 24, display: 'block' }}>{t('Select database and vector database for system data')}</Text>
<Form.Item
label="数据库驱动"
label={t('Database Driver')}
name="db_driver"
initialValue="sqlite"
rules={[{ required: true }]}
@@ -71,7 +74,7 @@ const SetupPage = () => {
<Select size="large" prefix={<HddOutlined />} disabled options={[{ label: 'SQLite', value: 'sqlite' }]} />
</Form.Item>
<Form.Item
label="向量数据库驱动"
label={t('Vector DB Driver')}
name="vector_db_driver"
initialValue="milvus"
rules={[{ required: true }]}
@@ -82,96 +85,96 @@ const SetupPage = () => {
)
},
{
title: '初始化挂载',
title: t('Initialize Mount'),
content: (
<>
<Title level={4}></Title>
<Text type="secondary" style={{ marginBottom: 24, display: 'block' }}></Text>
<Title level={4}>{t('Configure initial storage')}</Title>
<Text type="secondary" style={{ marginBottom: 24, display: 'block' }}>{t('Create the first storage mount for your files')}</Text>
<Form.Item
label="挂载名称"
label={t('Mount Name')}
name="adapter_name"
initialValue="本地存储"
rules={[{ required: true, message: '请输入挂载名称!' }]}
initialValue={t('Local Storage')}
rules={[{ required: true, message: t('Please input mount name!') }]}
>
<Input size="large" prefix={<HddOutlined />} />
</Form.Item>
<Form.Item
label="存储类型"
label={t('Storage Type')}
name="adapter_type"
initialValue="local"
rules={[{ required: true }]}
>
<Select size="large" disabled options={[{ label: '本地存储', value: 'local' }]} />
<Select size="large" disabled options={[{ label: t('Local Storage'), value: 'local' }]} />
</Form.Item>
<Form.Item
label="挂载路径"
label={t('Mount Path')}
name="path"
initialValue="/local"
rules={[{ required: true, message: '请输入挂载路径!' }]}
rules={[{ required: true, message: t('Please input mount path!') }]}
>
<Input size="large" prefix={<HddOutlined />} />
</Form.Item>
<Form.Item
label="根目录"
label={t('Root Directory')}
name="root_dir"
initialValue="data/mount"
rules={[{ required: true, message: '请输入根目录!' }]}
rules={[{ required: true, message: t('Please input root directory!') }]}
>
<Input size="large" placeholder="例如: data/ /var/foxel/data" />
<Input size="large" placeholder={t('e.g., data/ or /var/foxel/data')} />
</Form.Item>
</>
)
},
{
title: '创建管理员',
title: t('Create Admin'),
content: (
<>
<Title level={4}></Title>
<Text type="secondary" style={{ marginBottom: 24, display: 'block' }}></Text>
<Title level={4}>{t('Create admin account')}</Title>
<Text type="secondary" style={{ marginBottom: 24, display: 'block' }}>{t('This is the first account with full permissions')}</Text>
<Form.Item
label="用户名"
label={t('Username')}
name="username"
rules={[{ required: true, message: '请输入用户名!' }]}
rules={[{ required: true, message: t('Please input username!') }]}
>
<Input size="large" prefix={<UserOutlined />} />
</Form.Item>
<Form.Item
label="昵称"
label={t('Full Name')}
name="full_name"
>
<Input size="large" prefix={<UserOutlined />} />
</Form.Item>
<Form.Item
label="邮箱"
label={t('Email')}
name="email"
rules={[{ type: 'email', message: '请输入有效的邮箱地址!' }]}
rules={[{ type: 'email', message: t('Please input a valid email!') }]}
>
<Input size="large" prefix={<UserOutlined />} />
</Form.Item>
<Form.Item
label="密码"
label={t('Password')}
name="password"
rules={[{ required: true, message: '请输入密码!' }]}
rules={[{ required: true, message: t('Please enter password') }]}
>
<Input.Password size="large" prefix={<LockOutlined />} />
</Form.Item>
<Form.Item
label="确认密码"
label={t('Confirm Password')}
name="confirm"
dependencies={['password']}
hasFeedback
rules={[
{ required: true, message: '请确认您的密码!' },
{ required: true, message: t('Please confirm your password!') },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('password') === value) {
return Promise.resolve();
}
return Promise.reject(new Error('两次输入的密码不一致!'));
return Promise.reject(new Error(t('Passwords do not match!')));
},
}),
]}
@@ -190,12 +193,15 @@ const SetupPage = () => {
height: '100vh',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(to right, #f0f2f5, #d7d7d7)'
background: 'linear-gradient(to right, var(--ant-color-bg-layout, #f0f2f5), var(--ant-color-fill-secondary, #d7d7d7))'
}}>
<div style={{ position: 'fixed', top: 12, right: 12, zIndex: 1000 }}>
<LanguageSwitcher />
</div>
<Card style={{ width: 'clamp(400px, 40vw, 600px)', padding: '24px 16px' }}>
<div style={{ textAlign: 'center', marginBottom: 32 }}>
<img src="/logo.svg" alt="Foxel Logo" style={{ width: 48, marginBottom: 16 }} />
<Title level={2}></Title>
<Title level={2}>{t('System Initialization')}</Title>
</div>
<Steps current={currentStep} style={{ marginBottom: 32 }}>
{steps.map(item => (
@@ -215,17 +221,17 @@ const SetupPage = () => {
<Space>
{currentStep > 0 && (
<Button style={{ margin: '0 8px' }} onClick={() => prev()}>
{t('Previous')}
</Button>
)}
{currentStep < steps.length - 1 && (
<Button type="primary" onClick={() => next()}>
{t('Next')}
</Button>
)}
{currentStep === steps.length - 1 && (
<Button type="primary" htmlType="submit" loading={loading} onClick={() => form.submit()}>
{t('Finish Initialization')}
</Button>
)}
</Space>
@@ -235,4 +241,4 @@ const SetupPage = () => {
);
};
export default SetupPage;
export default SetupPage;

View File

@@ -5,9 +5,11 @@ import { shareApi, type ShareInfo } from '../api/share';
import { format, parseISO } from 'date-fns';
import { LinkOutlined, CopyOutlined, DeleteOutlined } from '@ant-design/icons';
import { useSystemStatus } from '../contexts/SystemContext';
import { useI18n } from '../i18n';
const SharePage = memo(function SharePage() {
const systemStatus = useSystemStatus();
const { t } = useI18n();
const [loading, setLoading] = useState(false);
const [data, setData] = useState<ShareInfo[]>([]);
@@ -17,7 +19,7 @@ const SharePage = memo(function SharePage() {
const list = await shareApi.list();
setData(list);
} catch (e: any) {
message.error(e.message || '加载失败');
message.error(e.message || t('Load failed'));
} finally {
setLoading(false);
}
@@ -29,22 +31,32 @@ const SharePage = memo(function SharePage() {
const baseUrl = systemStatus?.app_domain || window.location.origin;
const shareUrl = new URL(`/share/${rec.token}`, baseUrl).href;
navigator.clipboard.writeText(shareUrl);
message.success('链接已复制');
message.success(t('Copied link'));
};
const doDelete = async (rec: ShareInfo) => {
try {
await shareApi.remove(rec.id);
message.success('分享已取消');
message.success(t('Share canceled'));
fetchList();
} catch (e: any) {
message.error(e.message || '取消失败');
message.error(e.message || t('Cancel failed'));
}
};
const handleClearExpired = async () => {
try {
const res = await shareApi.clearExpired();
message.success(t('Cleared {count} expired shares', { count: String(res.deleted_count) }));
fetchList();
} catch (e: any) {
message.error(e.message || t('Clear failed'));
}
};
const columns = [
{
title: '分享名称',
title: t('Share Name'),
dataIndex: 'name',
render: (name: string, rec: ShareInfo) => (
<a href={`/share/${rec.token}`} target="_blank" rel="noopener noreferrer">
@@ -54,7 +66,7 @@ const SharePage = memo(function SharePage() {
)
},
{
title: '分享内容',
title: t('Share Content'),
dataIndex: 'paths',
ellipsis: true,
render: (paths: string[]) => (
@@ -64,31 +76,31 @@ const SharePage = memo(function SharePage() {
)
},
{
title: '创建时间',
title: t('Created At'),
dataIndex: 'created_at',
width: 180,
render: (v: string) => format(parseISO(v), 'yyyy-MM-dd HH:mm')
},
{
title: '过期时间',
title: t('Expires At'),
dataIndex: 'expires_at',
width: 180,
render: (v?: string) => v ? <Tag color="orange">{format(parseISO(v), 'yyyy-MM-dd HH:mm')}</Tag> : <Tag></Tag>
render: (v?: string) => v ? <Tag color="orange">{format(parseISO(v), 'yyyy-MM-dd HH:mm')}</Tag> : <Tag>{t('Forever')}</Tag>
},
{
title: '访问',
title: t('Access'),
dataIndex: 'access_type',
width: 100,
render: (v: 'public' | 'password') => v === 'password' ? <Tag color="red"></Tag> : <Tag color="green"></Tag>
render: (v: 'public' | 'password') => v === 'password' ? <Tag color="red">{t('By Password')}</Tag> : <Tag color="green">{t('Public')}</Tag>
},
{
title: '操作',
title: '',
width: 160,
render: (_: any, rec: ShareInfo) => (
<Space size="small">
<Button size="small" icon={<CopyOutlined />} onClick={() => doCopy(rec)}></Button>
<Popconfirm title="确认取消分享?" onConfirm={() => doDelete(rec)}>
<Button size="small" danger icon={<DeleteOutlined />}></Button>
<Button size="small" icon={<CopyOutlined />} onClick={() => doCopy(rec)}>{t('Copy')}</Button>
<Popconfirm title={t('Are you sure to cancel share?')} onConfirm={() => doDelete(rec)}>
<Button size="small" danger icon={<DeleteOutlined />}>{t('Cancel')}</Button>
</Popconfirm>
</Space>
)
@@ -97,8 +109,15 @@ const SharePage = memo(function SharePage() {
return (
<PageCard
title="我的分享"
extra={<Button onClick={fetchList} loading={loading}></Button>}
title={t('My Shares')}
extra={
<Space>
<Button onClick={fetchList} loading={loading}>{t('Refresh')}</Button>
<Popconfirm title={t('Confirm clear expired shares?')} onConfirm={handleClearExpired}>
<Button danger>{t('Clear expired shares')}</Button>
</Popconfirm>
</Space>
}
>
<Table
rowKey="id"

View File

@@ -3,19 +3,21 @@ import { Button, Typography, Upload, message, Modal } from 'antd';
import PageCard from '../../components/PageCard';
import { UploadOutlined, DownloadOutlined } from '@ant-design/icons';
import { backupApi } from '../../api/backup';
import { useI18n } from '../../i18n';
const { Paragraph, Text } = Typography;
const BackupPage = memo(function BackupPage() {
const [loading, setLoading] = useState(false);
const { t } = useI18n();
const handleExport = async () => {
setLoading(true);
try {
await backupApi.export();
message.success('导出已开始,请检查您的下载。');
message.success(t('Export started, check your downloads.'));
} catch (e: any) {
message.error(e.message || '导出失败');
message.error(e.message || t('Export failed'));
} finally {
setLoading(false);
}
@@ -23,24 +25,24 @@ const BackupPage = memo(function BackupPage() {
const handleImport = (file: File) => {
Modal.confirm({
title: '确认导入备份?',
title: t('Confirm import backup?'),
content: (
<Typography>
<Paragraph>?</Paragraph>
<Paragraph strong></Paragraph>
<Paragraph>{t('Are you sure to import from this file?')}</Paragraph>
<Paragraph strong>{t('Warning: This will overwrite all data including users (with passwords), settings, storages and tasks. Irreversible!')}</Paragraph>
</Typography>
),
okText: '确认导入',
okText: t('Confirm Import'),
okType: 'danger',
cancelText: '取消',
cancelText: t('Cancel'),
onOk: async () => {
setLoading(true);
try {
const response = await backupApi.import(file);
message.success(response.message || '导入成功!页面将刷新。');
message.success(response.message || t('Import succeeded! The page will refresh.'));
setTimeout(() => window.location.reload(), 2000);
} catch (e: any) {
message.error(e.message || '导入失败');
message.error(e.message || t('Import failed'));
} finally {
setLoading(false);
}
@@ -50,33 +52,33 @@ const BackupPage = memo(function BackupPage() {
};
return (
<PageCard title="备份和恢复">
<PageCard title={t('Backup & Restore')}>
<div style={{ display: 'flex', gap: '16px' }}>
<PageCard title="导出" style={{ flex: 1 }}>
<PageCard title={t('Export')} style={{ flex: 1 }}>
<Paragraph>
JSON
<Text strong></Text>
{t('Export all data (adapters, users, tasks, shares) into a JSON file.')}
<Text strong>{t('Keep your backup file safe.')}</Text>
</Paragraph>
<Button
icon={<DownloadOutlined />}
onClick={handleExport}
loading={loading}
>
{t('Export Backup')}
</Button>
</PageCard>
<PageCard title="恢复" style={{ flex: 1 }}>
<PageCard title={t('Import')} style={{ flex: 1 }}>
<Paragraph>
JSON文件恢复数据
<Text strong type="danger"></Text>
{t('Restore data from a previously exported JSON file.')}
<Text strong type="danger">{t('Warning: This will clear and overwrite existing data.')}</Text>
</Paragraph>
<Upload
beforeUpload={handleImport}
showUploadList={false}
>
<Button icon={<UploadOutlined />} loading={loading}>
{t('Choose File and Restore')}
</Button>
</Upload>
</PageCard>
@@ -85,4 +87,4 @@ const BackupPage = memo(function BackupPage() {
);
});
export default BackupPage;
export default BackupPage;

View File

@@ -1,34 +1,52 @@
import { Form, Input, Button, message, Tabs, Space, Card } from 'antd';
import { Form, Input, Button, message, Tabs, Space, Card, Select, Modal, Radio, InputNumber } from 'antd';
import { useEffect, useState } from 'react';
import PageCard from '../../components/PageCard';
import { getAllConfig, setConfig } from '../../api/config';
import { AppstoreOutlined, RobotOutlined } from '@ant-design/icons';
import { vectorDBApi } from '../../api/vectorDB';
import { AppstoreOutlined, RobotOutlined, DatabaseOutlined, SkinOutlined } from '@ant-design/icons';
import { useTheme } from '../../contexts/ThemeContext';
import '../../styles/settings-tabs.css';
import { useI18n } from '../../i18n';
const APP_CONFIG_KEYS: {key: string, label: string, default?: string}[] = [
{ key: 'APP_NAME', label: '应用名称' },
{ key: 'APP_LOGO', label: 'LOGO地址' },
{ key: 'APP_DOMAIN', label: '应用域名' },
{ key: 'FILE_DOMAIN', label: '文件域名' },
{ key: 'APP_NAME', label: 'App Name' },
{ key: 'APP_LOGO', label: 'Logo URL' },
{ key: 'APP_DOMAIN', label: 'App Domain' },
{ key: 'FILE_DOMAIN', label: 'File Domain' },
];
const VISION_CONFIG_KEYS = [
{ key: 'AI_VISION_API_URL', label: '视觉模型 API 地址' },
{ key: 'AI_VISION_MODEL', label: '视觉模型', default: 'Qwen/Qwen2.5-VL-32B-Instruct' },
{ key: 'AI_VISION_API_KEY', label: '视觉模型 API Key' },
{ key: 'AI_VISION_API_URL', label: 'Vision API URL' },
{ key: 'AI_VISION_MODEL', label: 'Vision Model', default: 'Qwen/Qwen2.5-VL-32B-Instruct' },
{ key: 'AI_VISION_API_KEY', label: 'Vision API Key' },
];
const DEFAULT_EMBED_DIMENSION = 4096;
const EMBED_DIM_KEY = 'AI_EMBED_DIM';
const EMBED_CONFIG_KEYS = [
{ key: 'AI_EMBED_API_URL', label: '嵌入模型 API 地址' },
{ key: 'AI_EMBED_MODEL', label: '嵌入模型', default: 'Qwen/Qwen3-Embedding-8B' },
{ key: 'AI_EMBED_API_KEY', label: '嵌入模型 API Key' },
{ key: 'AI_EMBED_API_URL', label: 'Embedding API URL' },
{ key: 'AI_EMBED_MODEL', label: 'Embedding Model', default: 'Qwen/Qwen3-Embedding-8B' },
{ key: 'AI_EMBED_API_KEY', label: 'Embedding API Key' },
];
const ALL_AI_KEYS = [...VISION_CONFIG_KEYS, ...EMBED_CONFIG_KEYS];
const ALL_AI_KEYS = [...VISION_CONFIG_KEYS, ...EMBED_CONFIG_KEYS, { key: EMBED_DIM_KEY, default: DEFAULT_EMBED_DIMENSION }];
// Theme related config keys
const THEME_KEYS = {
MODE: 'THEME_MODE',
PRIMARY: 'THEME_PRIMARY_COLOR',
RADIUS: 'THEME_BORDER_RADIUS',
TOKENS: 'THEME_CUSTOM_TOKENS',
CSS: 'THEME_CUSTOM_CSS',
};
export default function SystemSettingsPage() {
const [loading, setLoading] = useState(false);
const [config, setConfigState] = useState<Record<string, string> | null>(null);
const [activeTab, setActiveTab] = useState('app');
const [activeTab, setActiveTab] = useState('appearance');
const { refreshTheme, previewTheme } = useTheme();
const { t } = useI18n();
useEffect(() => {
getAllConfig().then((data) => setConfigState(data as Record<string, string>));
@@ -40,35 +58,127 @@ export default function SystemSettingsPage() {
for (const [key, value] of Object.entries(values)) {
await setConfig(key, String(value ?? ''));
}
message.success('保存成功');
message.success(t('Saved successfully'));
setConfigState({ ...config, ...values });
// trigger theme refresh if related keys changed
if (Object.keys(values).some(k => Object.values(THEME_KEYS).includes(k))) {
await refreshTheme();
}
} catch (e: any) {
message.error(e.message || '保存失败');
message.error(e.message || t('Save failed'));
}
setLoading(false);
};
// 离开“外观设置”时,恢复后端持久化配置(取消未保存的预览)
useEffect(() => {
if (activeTab !== 'appearance') {
refreshTheme();
}
}, [activeTab]);
if (!config) {
return <PageCard title='系统设置'><div>...</div></PageCard>;
return <PageCard title={t('System Settings')}><div>{t('Loading...')}</div></PageCard>;
}
return (
<PageCard
title='系统设置'
title={t('System Settings')}
>
<Space direction="vertical" style={{ width: '100%' }} size={32}>
<Tabs
className="fx-settings-tabs"
activeKey={activeTab}
onChange={setActiveTab}
centered
tabPosition="left"
items={[
{
key: 'appearance',
label: (
<span>
<SkinOutlined style={{ marginRight: 8 }} />
{t('Appearance Settings')}
</span>
),
children: (
<Form
layout="vertical"
initialValues={{
[THEME_KEYS.MODE]: config[THEME_KEYS.MODE] ?? 'light',
[THEME_KEYS.PRIMARY]: config[THEME_KEYS.PRIMARY] ?? '#111111',
[THEME_KEYS.RADIUS]: Number(config[THEME_KEYS.RADIUS] ?? '10'),
[THEME_KEYS.TOKENS]: config[THEME_KEYS.TOKENS] ?? '',
[THEME_KEYS.CSS]: config[THEME_KEYS.CSS] ?? '',
}}
onValuesChange={(_, all) => {
try {
const tokens = all[THEME_KEYS.TOKENS] ? JSON.parse(all[THEME_KEYS.TOKENS]) : undefined;
previewTheme({
mode: all[THEME_KEYS.MODE],
primaryColor: all[THEME_KEYS.PRIMARY],
borderRadius: typeof all[THEME_KEYS.RADIUS] === 'number' ? all[THEME_KEYS.RADIUS] : undefined,
customTokens: tokens,
customCSS: all[THEME_KEYS.CSS],
});
} catch {
// JSON 不合法时忽略 tokens 预览,其他项仍然生效
previewTheme({
mode: all[THEME_KEYS.MODE],
primaryColor: all[THEME_KEYS.PRIMARY],
borderRadius: typeof all[THEME_KEYS.RADIUS] === 'number' ? all[THEME_KEYS.RADIUS] : undefined,
customCSS: all[THEME_KEYS.CSS],
});
}
}}
onFinish={async (vals) => {
// Validate JSON if provided
if (vals[THEME_KEYS.TOKENS]) {
try { JSON.parse(vals[THEME_KEYS.TOKENS]); }
catch { return message.error(t('Advanced tokens must be valid JSON')); }
}
await handleSave(vals);
}}
style={{ marginTop: 24 }}
key={'appearance-' + JSON.stringify(config)}
>
<Card title={t('Theme')}>
<Form.Item name={THEME_KEYS.MODE} label={t('Theme Mode')}>
<Radio.Group buttonStyle="solid">
<Radio.Button value="light">{t('Light')}</Radio.Button>
<Radio.Button value="dark">{t('Dark')}</Radio.Button>
<Radio.Button value="system">{t('Follow System')}</Radio.Button>
</Radio.Group>
</Form.Item>
<Form.Item name={THEME_KEYS.PRIMARY} label={t('Primary Color')}>
<Input type="color" size="large" />
</Form.Item>
<Form.Item name={THEME_KEYS.RADIUS} label={t('Border Radius')}>
<InputNumber min={0} max={24} style={{ width: '100%' }} />
</Form.Item>
</Card>
<Card title={t('Advanced')} style={{ marginTop: 24 }}>
<Form.Item name={THEME_KEYS.TOKENS} label={t('Override AntD Tokens (JSON)')} tooltip={t('e.g. {"colorText": "#222"}') }>
<Input.TextArea autoSize={{ minRows: 4 }} placeholder='{ "colorText": "#222" }' />
</Form.Item>
<Form.Item name={THEME_KEYS.CSS} label={t('Custom CSS')}>
<Input.TextArea autoSize={{ minRows: 6 }} placeholder={":root{ }\n/* CSS */"} />
</Form.Item>
</Card>
<Form.Item style={{ marginTop: 24 }}>
<Button type="primary" htmlType="submit" loading={loading} block>
{t('Save')}
</Button>
</Form.Item>
</Form>
)
},
{
key: 'app',
label: (
<span>
<AppstoreOutlined style={{ marginRight: 8 }} />
{t('App Settings')}
</span>
),
children: (
@@ -82,13 +192,13 @@ export default function SystemSettingsPage() {
key={JSON.stringify(config)}
>
{APP_CONFIG_KEYS.map(({ key, label }) => (
<Form.Item key={key} name={key} label={label}>
<Form.Item key={key} name={key} label={t(label)}>
<Input size="large" />
</Form.Item>
))}
<Form.Item>
<Button type="primary" htmlType="submit" loading={loading} block>
{t('Save')}
</Button>
</Form.Item>
</Form>
@@ -99,41 +209,110 @@ export default function SystemSettingsPage() {
label: (
<span>
<RobotOutlined style={{ marginRight: 8 }} />
AI设置
{t('AI Settings')}
</span>
),
children: (
<Form
layout="vertical"
initialValues={{
...Object.fromEntries(ALL_AI_KEYS.map(({ key, default: def }) => [key, config[key] ?? def ?? ''])),
...Object.fromEntries(ALL_AI_KEYS.map(({ key, default: def }) => [key, key === EMBED_DIM_KEY
? Number(config[key] ?? def ?? DEFAULT_EMBED_DIMENSION)
: config[key] ?? def ?? ''])),
}}
onFinish={async (vals) => {
const currentDim = Number(config[EMBED_DIM_KEY] ?? DEFAULT_EMBED_DIMENSION);
const nextDim = Number(vals[EMBED_DIM_KEY] ?? DEFAULT_EMBED_DIMENSION);
if (currentDim !== nextDim) {
Modal.confirm({
title: t('Confirm embedding dimension change'),
content: t('Changing the embedding dimension will clear the vector database automatically. You will need to rebuild indexes afterwards. Continue?'),
okText: t('Confirm'),
cancelText: t('Cancel'),
onOk: async () => {
await handleSave(vals);
},
});
return;
}
await handleSave(vals);
}}
onFinish={handleSave}
style={{ marginTop: 24 }}
key={JSON.stringify(config)}
>
<Card title="视觉模型" style={{ marginBottom: 24 }}>
<Card title={t('Vision Model')} style={{ marginBottom: 24 }}>
{VISION_CONFIG_KEYS.map(({ key, label }) => (
<Form.Item key={key} name={key} label={label}>
<Form.Item key={key} name={key} label={t(label)}>
<Input size="large" />
</Form.Item>
))}
</Card>
<Card title="嵌入模型">
<Card title={t('Embedding Model')}>
{EMBED_CONFIG_KEYS.map(({ key, label }) => (
<Form.Item key={key} name={key} label={label}>
<Form.Item key={key} name={key} label={t(label)}>
<Input size="large" />
</Form.Item>
))}
<Form.Item name={EMBED_DIM_KEY} label={t('Embedding Dimension')}>
<InputNumber min={1} max={32768} style={{ width: '100%' }} />
</Form.Item>
</Card>
<Form.Item style={{ marginTop: 24 }}>
<Button type="primary" htmlType="submit" loading={loading} block>
{t('Save')}
</Button>
</Form.Item>
</Form>
),
},
{
key: 'vector-db',
label: (
<span>
<DatabaseOutlined style={{ marginRight: 8 }} />
{t('Vector Database')}
</span>
),
children: (
<Card title={t('Vector Database Settings')} style={{ marginTop: 24 }}>
<Form layout="vertical">
<Form.Item label={t('Database Type')}>
<Select
size="large"
value={'Milvus Lite'}
disabled
options={[{ value: 'Milvus Lite', label: 'Milvus Lite' }]}
/>
</Form.Item>
<Form.Item>
<Button
danger
block
onClick={() => {
Modal.confirm({
title: t('Confirm clear vector database?'),
content: t('This will delete all collections irreversibly.'),
okText: t('Confirm Clear'),
okType: 'danger',
cancelText: t('Cancel'),
onOk: async () => {
try {
await vectorDBApi.clearAll();
message.success(t('Vector database cleared'));
} catch (e: any) {
message.error(e.message || t('Clear failed'));
}
},
});
}}
>
{t('Clear Vector DB')}
</Button>
</Form.Item>
</Form>
</Card>
),
},
]}
/>
</Space>

View File

@@ -1,9 +1,11 @@
import { memo, useState, useEffect, useCallback } from 'react';
import { Table, Button, Space, Drawer, Form, Input, Switch, message, Typography, Popconfirm, Select } from 'antd';
import { Table, Button, Space, Drawer, Form, Input, Switch, message, Typography, Popconfirm, Select, Modal, Tag } from 'antd';
import PageCard from '../components/PageCard';
import { tasksApi, type AutomationTask } from '../api/tasks';
import { tasksApi, type AutomationTask, type QueuedTask } from '../api/tasks';
import { processorsApi, type ProcessorTypeMeta } from '../api/processors';
import { ProcessorConfigForm } from '../components/ProcessorConfigForm';
import { useI18n } from '../i18n';
import PathSelectorModal from '../components/PathSelectorModal';
const TasksPage = memo(function TasksPage() {
const [loading, setLoading] = useState(false);
@@ -12,6 +14,11 @@ const TasksPage = memo(function TasksPage() {
const [editing, setEditing] = useState<AutomationTask | null>(null);
const [form] = Form.useForm();
const [availableProcessors, setAvailableProcessors] = useState<ProcessorTypeMeta[]>([]);
const [queueModalOpen, setQueueModalOpen] = useState(false);
const [queuedTasks, setQueuedTasks] = useState<QueuedTask[]>([]);
const [queueLoading, setQueueLoading] = useState(false);
const { t } = useI18n();
const [pathPickerOpen, setPathPickerOpen] = useState(false);
const fetchList = useCallback(async () => {
setLoading(true);
@@ -86,19 +93,58 @@ const TasksPage = memo(function TasksPage() {
}
};
const fetchQueue = async () => {
setQueueLoading(true);
try {
const tasks = await tasksApi.getQueue();
setQueuedTasks(tasks);
} catch (e: any) {
message.error(e.message || '加载队列失败');
} finally {
setQueueLoading(false);
}
};
const openQueueModal = () => {
setQueueModalOpen(true);
fetchQueue();
};
const toggleEnabled = async (rec: AutomationTask, enabled: boolean) => {
setEditing(rec);
setLoading(true);
try {
await tasksApi.update(rec.id, { enabled });
message.success('状态已更新');
fetchList();
} catch (e: any) {
message.error(e.message || '更新失败');
} finally {
setEditing(null);
setLoading(false);
}
};
const columns = [
{ title: '名称', dataIndex: 'name' },
{ title: '触发事件', dataIndex: 'event', width: 120 },
{ title: '处理器', dataIndex: 'processor_type', width: 180 },
{ title: '启用', dataIndex: 'enabled', width: 80, render: (v: boolean) => <Switch checked={v} size="small" disabled /> },
{ title: t('Name'), dataIndex: 'name' },
{ title: t('Trigger Event'), dataIndex: 'event', width: 120 },
{ title: t('Processor'), dataIndex: 'processor_type', width: 180 },
{
title: '操作',
title: t('Enabled'), dataIndex: 'enabled', width: 80, render: (v: boolean, rec: AutomationTask) => <Switch
checked={v}
size="small"
loading={loading && editing?.id === rec.id}
onChange={(checked) => toggleEnabled(rec, checked)}
/>
},
{
title: t('Actions'),
width: 160,
render: (_: any, rec: AutomationTask) => (
<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>
)
@@ -107,15 +153,17 @@ const TasksPage = memo(function TasksPage() {
const selectedProcessor = Form.useWatch('processor_type', form);
const currentProcessorMeta = availableProcessors.find(p => p.type === selectedProcessor);
const watchedPathPattern = Form.useWatch('path_pattern', form);
return (
<PageCard
title="自动化任务"
title={t('Automation Tasks')}
extra={
<Space>
<Button onClick={fetchList} loading={loading}></Button>
<Button type="primary" onClick={openCreate}></Button>
<Button onClick={fetchList} loading={loading}>{t('Refresh')}</Button>
<Button onClick={openQueueModal}>{t('Running Tasks')}</Button>
<Button type="primary" onClick={openCreate}>{t('Create Task')}</Button>
</Space>
}
>
@@ -128,42 +176,45 @@ const TasksPage = memo(function TasksPage() {
style={{ marginBottom: 0 }}
/>
<Drawer
title={editing ? `编辑任务: ${editing.name}` : '新建自动化任务'}
title={editing ? `${t('Edit Task')}: ${editing.name}` : t('Create Automation Task')}
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>
}
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="任务名称" rules={[{ required: true }]}>
<Form.Item name="name" label={t('Task Name')} rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="event" label="触发事件" rules={[{ required: true }]}>
<Select options={[
{ value: 'file_written', label: '文件写入' },
{ value: 'file_deleted', label: '文件删除' },
<Form.Item name="event" label={t('Trigger Event')} rules={[{ required: true }]}>
<Select options={[
{ value: 'file_written', label: t('File Written') },
{ value: 'file_deleted', label: t('File Deleted') },
]} />
</Form.Item>
<Typography.Title level={5} style={{ marginTop: 8, fontSize: 14 }}></Typography.Title>
<Form.Item name="path_pattern" label="路径前缀 (可选)">
<Input placeholder="/images/screenshots" />
<Typography.Title level={5} style={{ marginTop: 8, fontSize: 14 }}>{t('Matching Rules')}</Typography.Title>
<Form.Item name="path_pattern" label={t('Path Prefix (optional)')}>
<Input
placeholder="/images/screenshots"
addonAfter={<Button size="small" onClick={() => setPathPickerOpen(true)}>{t('Select')}</Button>}
/>
</Form.Item>
<Form.Item name="filename_regex" label="文件名正则 (可选)">
<Form.Item name="filename_regex" label={t('Filename Regex (optional)')}>
<Input placeholder=".*\.png$" />
</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>
<Form.Item name="processor_type" label="处理器" rules={[{ required: true }]}>
<Typography.Title level={5} style={{ marginTop: 8, fontSize: 14 }}>{t('Action')}</Typography.Title>
<Form.Item name="processor_type" label={t('Processor')} rules={[{ required: true }]}>
<Select
placeholder="选择一个处理器"
placeholder={t('Select a processor')}
options={availableProcessors.map(p => ({ value: p.type, label: `${p.name} (${p.type})` }))}
/>
</Form.Item>
@@ -174,6 +225,47 @@ const TasksPage = memo(function TasksPage() {
/>
</Form>
</Drawer>
<PathSelectorModal
open={pathPickerOpen}
mode="directory"
initialPath={watchedPathPattern || '/'}
onCancel={() => setPathPickerOpen(false)}
onOk={(p) => { form.setFieldsValue({ path_pattern: p }); setPathPickerOpen(false); }}
/>
<Modal
title={t('Current Task Queue')}
open={queueModalOpen}
onCancel={() => setQueueModalOpen(false)}
width={800}
footer={[
<Button key="refresh" onClick={fetchQueue} loading={queueLoading}>{t('Refresh')}</Button>,
<Button key="close" onClick={() => setQueueModalOpen(false)}>{t('Close')}</Button>
]}
>
<Table
size="small"
rowKey="id"
dataSource={queuedTasks}
loading={queueLoading}
pagination={false}
columns={[
{ title: 'ID', dataIndex: 'id', width: 120, render: (id) => <Typography.Text style={{ fontSize: 12 }} copyable={{ text: id }}>{id.slice(0, 8)}</Typography.Text> },
{ title: t('Task Name'), dataIndex: 'name' },
{ title: t('Params'), dataIndex: 'task_info', render: (info) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{JSON.stringify(info)}</Typography.Text> },
{
title: t('Status'), dataIndex: 'status', width: 100, render: (status: QueuedTask['status']) => {
const colorMap = {
pending: 'default',
running: 'processing',
success: 'success',
failed: 'error'
};
return <Tag color={colorMap[status]}>{status}</Tag>;
}
},
]}
/>
</Modal>
</PageCard>
);
});

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