完成基础开发

This commit is contained in:
2025-07-18 15:46:07 +08:00
parent 99f30bd1eb
commit cff16ef2af
42 changed files with 7290 additions and 0 deletions

68
.dockerignore Normal file
View File

@ -0,0 +1,68 @@
# Git
.git
.gitignore
# Documentation
README.md
开发指南.md
# Node.js
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Python
__pycache__
*.pyc
*.pyo
*.pyd
.Python
env
pip-log.txt
pip-delete-this-directory.txt
.tox
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.log
.git
.mypy_cache
.pytest_cache
.hypothesis
# Virtual environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Build outputs
dist/
build/
*.egg-info/
# Temporary files
tmp/
temp/

212
.gitignore vendored Normal file
View File

@ -0,0 +1,212 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
.python-version
# celery beat schedule file
celerybeat-schedule
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Node.js
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage/
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage
.grunt
# Bower dependency directory
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons
build/Release
# Dependency directories
jspm_packages/
# TypeScript v1 declaration files
typings/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env.test
# parcel-bundler cache
.cache
.parcel-cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
public
# Storybook build outputs
.out
.storybook-out
# Temporary folders
tmp/
temp/
# Editor directories and files
.vscode/
.idea/
*.swp
*.swo
*~
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# UV
.uv/

38
.promptx/pouch.json Normal file
View File

@ -0,0 +1,38 @@
{
"currentState": "role_activated_with_memory",
"stateHistory": [
{
"from": "initial",
"command": "init",
"timestamp": "2025-07-18T07:43:49.083Z",
"args": [
{
"workingDirectory": "/Volumes/EX950-2T/code/project/qbit-manager"
}
]
},
{
"from": "initialized",
"command": "action",
"timestamp": "2025-07-18T07:44:49.596Z",
"args": [
"vue-developer"
]
},
{
"from": "role_activated_with_memory",
"command": "welcome",
"timestamp": "2025-07-18T07:45:47.171Z",
"args": []
},
{
"from": "service_discovery",
"command": "action",
"timestamp": "2025-07-18T07:46:00.978Z",
"args": [
"assistant"
]
}
],
"lastUpdated": "2025-07-18T07:46:00.992Z"
}

View File

@ -0,0 +1,17 @@
{
"version": "2.0.0",
"source": "project",
"metadata": {
"version": "2.0.0",
"description": "project 级资源注册表",
"createdAt": "2025-07-18T07:43:49.113Z",
"updatedAt": "2025-07-18T07:43:49.116Z",
"resourceCount": 0
},
"resources": [],
"stats": {
"totalResources": 0,
"byProtocol": {},
"bySource": {}
}
}

222
README.md
View File

@ -0,0 +1,222 @@
# qBittorrent 管理平台
多客户端 qBittorrent 集中管理平台,在一个统一的 Web 界面中集中管理和监控多个 qBittorrent 客户端。
## 功能特性
- 🎯 **集中管理**: 在一个界面中管理多个 qBittorrent 客户端
- 📊 **数据聚合**: 聚合展示所有客户端的种子列表和全局速度概览
-**基本操作**: 支持对种子进行暂停、恢复、删除等操作
- 🔒 **安全管理**: 安全地管理各客户端的连接信息
- 📱 **响应式设计**: 支持桌面和移动设备的优秀体验
## 技术栈
### 后端
- **Python 3.10+** - 编程语言
- **Flask** - Web 框架
- **qbittorrent-api** - qBittorrent 客户端通信
- **uv** - Python 包管理器
### 前端
- **Vue.js 3** - 前端框架 (Composition API)
- **Element Plus** - UI 组件库
- **Pinia** - 状态管理
- **Axios** - HTTP 客户端
- **Vite** - 构建工具
## 快速开始
### 🐳 Docker 部署(推荐)
1. 确保已安装 Docker 和 Docker Compose
2. 克隆项目并进入目录:
```bash
git clone <repository-url>
cd qbit-manager
```
3. 一键部署:
```bash
./deploy.sh
```
4. 访问应用:
- 前端界面: http://localhost:8080
- 后端 API: http://localhost:8080/api
### 📋 管理命令
```bash
# 查看服务状态
docker-compose ps
# 查看日志
docker-compose logs -f
# 停止服务
docker-compose down
# 重启服务
docker-compose restart
# 更新服务
./deploy.sh
```
### 🛠️ 开发环境
#### 后端开发
1. 进入后端目录:
```bash
cd backend
```
2. 使用 uv 创建虚拟环境并安装依赖:
```bash
uv venv --python 3.10
source .venv/bin/activate # Linux/macOS
# 或 .venv\Scripts\activate # Windows
uv pip install -r requirements.txt
```
3. 复制环境变量文件:
```bash
cp .env.example .env
```
4. 启动后端服务:
```bash
python app.py
```
#### 前端开发
1. 进入前端目录:
```bash
cd frontend
```
2. 安装依赖:
```bash
npm install
```
3. 启动开发服务器:
```bash
npm run dev
```
## 项目结构
```
qbit-manager/
├── backend/ # 后端代码
│ ├── app.py # Flask 应用入口
│ ├── config.py # 配置文件
│ ├── data/ # 数据存储
│ └── pyproject.toml # Python 项目配置
├── frontend/ # 前端代码
│ ├── src/ # 源代码
│ │ ├── views/ # 页面组件
│ │ ├── components/ # 可复用组件
│ │ ├── stores/ # Pinia 状态管理
│ │ ├── api/ # API 接口
│ │ └── router/ # 路由配置
│ ├── package.json # Node.js 项目配置
│ └── vite.config.js # Vite 配置
└── README.md # 项目说明
```
## 📖 使用说明
### 添加 qBittorrent 客户端
1. 点击"显示客户端管理"按钮
2. 点击"添加客户端"
3. 填写客户端信息:
- 名称:自定义客户端名称
- 主机qBittorrent 所在主机的 IP 地址
- 端口qBittorrent WebUI 端口(默认 8080
- 用户名qBittorrent WebUI 用户名
- 密码qBittorrent WebUI 密码
4. 点击"测试"按钮验证连接
5. 保存客户端配置
### 管理种子
- **查看种子**:主界面显示所有客户端的种子列表
- **筛选种子**:可按状态和客户端进行筛选
- **批量操作**:选择多个种子进行批量暂停、恢复或删除
- **单个操作**:对单个种子进行操作
- **实时更新**:支持自动刷新数据
### 全局统计
- 实时显示所有客户端的总下载/上传速度
- 显示种子总数和活跃种子数
- 按状态分类显示种子统计
## 🔧 配置说明
### 环境变量
后端支持以下环境变量配置:
```bash
# Flask 配置
FLASK_ENV=production
SECRET_KEY=your-secret-key-here
# 应用配置
APP_HOST=0.0.0.0
APP_PORT=8888
DEBUG=False
# 客户端配置文件路径
CLIENTS_CONFIG_PATH=data/clients.json
```
### 数据持久化
- 客户端配置存储在 `data/clients.json` 文件中
- Docker 部署时会自动创建 `data` 目录并挂载
## 🚨 注意事项
1. **安全性**:请确保 qBittorrent WebUI 使用强密码
2. **网络**:确保应用能够访问到 qBittorrent 客户端
3. **防火墙**:检查防火墙设置,确保端口可访问
4. **资源**:大量种子可能影响性能,建议合理配置刷新间隔
## 🐛 故障排除
### 常见问题
1. **无法连接客户端**
- 检查 IP 地址和端口是否正确
- 确认 qBittorrent WebUI 已启用
- 检查用户名和密码
2. **数据不更新**
- 检查网络连接
- 查看浏览器控制台错误
- 重启服务
3. **Docker 部署问题**
- 检查 Docker 和 Docker Compose 版本
- 查看容器日志:`docker-compose logs`
## 🤝 贡献
欢迎提交 Issue 和 Pull Request
## 📄 开发指南
详细的开发指南请参考 [开发指南.md](./开发指南.md)
## 📜 许可证
MIT License

11
backend/.env.example Normal file
View File

@ -0,0 +1,11 @@
# Flask 配置
FLASK_ENV=development
SECRET_KEY=your-secret-key-here
# 应用配置
APP_HOST=0.0.0.0
APP_PORT=5000
DEBUG=True
# 客户端配置文件路径
CLIENTS_CONFIG_PATH=data/clients.json

35
backend/Dockerfile Normal file
View File

@ -0,0 +1,35 @@
# 后端 Dockerfile
FROM python:3.10-slim
# 设置工作目录
WORKDIR /app
# 安装系统依赖
RUN apt-get update && apt-get install -y \
gcc \
&& rm -rf /var/lib/apt/lists/*
# 安装 uv
RUN pip install uv
# 复制依赖文件
COPY requirements.txt pyproject.toml ./
# 安装 Python 依赖
RUN uv pip install --system -r requirements.txt
# 复制应用代码
COPY . .
# 创建数据目录
RUN mkdir -p data
# 设置环境变量
ENV PYTHONPATH=/app
ENV FLASK_APP=app.py
# 暴露端口
EXPOSE 8888
# 启动命令
CMD ["gunicorn", "--bind", "0.0.0.0:8888", "--workers", "4", "--timeout", "120", "app:create_app()"]

1
backend/api/__init__.py Normal file
View File

@ -0,0 +1 @@
# API 蓝图模块

115
backend/api/clients.py Normal file
View File

@ -0,0 +1,115 @@
"""
客户端管理 API 蓝图
"""
from flask import Blueprint, request, jsonify
from services.client_service import ClientService
clients_bp = Blueprint('clients', __name__, url_prefix='/api/clients')
client_service = ClientService()
@clients_bp.route('', methods=['GET'])
def get_clients():
"""获取所有客户端列表"""
try:
clients = client_service.get_clients_with_connection()
return jsonify({
'success': True,
'data': clients
})
except Exception as e:
return jsonify({
'success': False,
'error': str(e)
}), 500
@clients_bp.route('', methods=['POST'])
def add_client():
"""添加新客户端"""
try:
data = request.get_json()
# 验证必需字段
required_fields = ['name', 'host', 'port']
for field in required_fields:
if field not in data:
return jsonify({
'success': False,
'error': f'缺少必需字段: {field}'
}), 400
client = client_service.add_client(data)
return jsonify({
'success': True,
'data': client
}), 201
except Exception as e:
return jsonify({
'success': False,
'error': str(e)
}), 500
@clients_bp.route('/<client_id>', methods=['PUT'])
def update_client(client_id):
"""更新客户端配置"""
try:
data = request.get_json()
client = client_service.update_client(client_id, data)
if client:
return jsonify({
'success': True,
'data': client
})
else:
return jsonify({
'success': False,
'error': '客户端不存在'
}), 404
except Exception as e:
return jsonify({
'success': False,
'error': str(e)
}), 500
@clients_bp.route('/<client_id>', methods=['DELETE'])
def delete_client(client_id):
"""删除客户端"""
try:
success = client_service.delete_client(client_id)
if success:
return jsonify({
'success': True,
'message': '客户端删除成功'
})
else:
return jsonify({
'success': False,
'error': '客户端不存在'
}), 404
except Exception as e:
return jsonify({
'success': False,
'error': str(e)
}), 500
@clients_bp.route('/<client_id>/test', methods=['POST'])
def test_client_connection(client_id):
"""测试客户端连接"""
try:
result = client_service.test_client_connection(client_id)
return jsonify(result)
except Exception as e:
return jsonify({
'success': False,
'error': str(e)
}), 500

254
backend/api/torrents.py Normal file
View File

@ -0,0 +1,254 @@
"""
种子管理 API 蓝图
"""
from flask import Blueprint, request, jsonify
from werkzeug.utils import secure_filename
import os
import tempfile
from services.torrent_service import TorrentService
torrents_bp = Blueprint('torrents', __name__, url_prefix='/api/torrents')
torrent_service = TorrentService()
@torrents_bp.route('', methods=['GET'])
def get_torrents():
"""获取种子列表"""
try:
# 获取查询参数
client_ids = request.args.getlist('client_ids')
data = torrent_service.get_all_torrents(client_ids if client_ids else None)
return jsonify({
'success': True,
'data': data
})
except Exception as e:
return jsonify({
'success': False,
'error': str(e)
}), 500
@torrents_bp.route('/maindata', methods=['GET'])
def get_maindata():
"""获取主要数据(聚合接口)"""
try:
# 获取查询参数
client_ids = request.args.getlist('client_ids')
data = torrent_service.get_all_torrents(client_ids if client_ids else None)
return jsonify({
'success': True,
'data': data
})
except Exception as e:
return jsonify({
'success': False,
'error': str(e)
}), 500
@torrents_bp.route('/pause', methods=['POST'])
def pause_torrents():
"""暂停种子"""
try:
data = request.get_json()
torrent_hashes = data.get('hashes', [])
client_id = data.get('client_id')
if not torrent_hashes:
return jsonify({
'success': False,
'error': '请提供要暂停的种子哈希值'
}), 400
result = torrent_service.pause_torrents(torrent_hashes, client_id)
return jsonify(result)
except Exception as e:
return jsonify({
'success': False,
'error': str(e)
}), 500
@torrents_bp.route('/resume', methods=['POST'])
def resume_torrents():
"""恢复种子"""
try:
data = request.get_json()
torrent_hashes = data.get('hashes', [])
client_id = data.get('client_id')
if not torrent_hashes:
return jsonify({
'success': False,
'error': '请提供要恢复的种子哈希值'
}), 400
result = torrent_service.resume_torrents(torrent_hashes, client_id)
return jsonify(result)
except Exception as e:
return jsonify({
'success': False,
'error': str(e)
}), 500
@torrents_bp.route('/delete', methods=['POST'])
def delete_torrents():
"""删除种子"""
try:
data = request.get_json()
torrent_hashes = data.get('hashes', [])
client_id = data.get('client_id')
delete_files = data.get('delete_files', False)
if not torrent_hashes:
return jsonify({
'success': False,
'error': '请提供要删除的种子哈希值'
}), 400
result = torrent_service.delete_torrents(torrent_hashes, delete_files, client_id)
return jsonify(result)
except Exception as e:
return jsonify({
'success': False,
'error': str(e)
}), 500
@torrents_bp.route('/<torrent_hash>/details', methods=['GET'])
def get_torrent_details(torrent_hash):
"""获取种子详细信息"""
try:
client_id = request.args.get('client_id')
if not client_id:
return jsonify({
'success': False,
'error': '请提供客户端ID'
}), 400
details = torrent_service.get_torrent_details(torrent_hash, client_id)
if details:
return jsonify({
'success': True,
'data': details
})
else:
return jsonify({
'success': False,
'error': '种子不存在或无法获取详细信息'
}), 404
except Exception as e:
return jsonify({
'success': False,
'error': str(e)
}), 500
@torrents_bp.route('/add', methods=['POST'])
def add_torrent():
"""添加种子"""
try:
# 获取客户端ID
client_id = request.form.get('client_id') or request.json.get('client_id') if request.is_json else None
if not client_id:
return jsonify({
'success': False,
'error': '请指定客户端ID'
}), 400
# 检查添加方式
if 'torrent_file' in request.files:
# 种子文件上传
return _add_torrent_file(client_id)
elif request.is_json:
data = request.get_json()
if 'magnet_link' in data:
# 磁力链接
return _add_magnet_link(client_id, data['magnet_link'], data.get('options', {}))
elif 'torrent_url' in data:
# 种子URL
return _add_torrent_url(client_id, data['torrent_url'], data.get('options', {}))
return jsonify({
'success': False,
'error': '请提供种子文件、磁力链接或种子URL'
}), 400
except Exception as e:
return jsonify({
'success': False,
'error': str(e)
}), 500
def _add_torrent_file(client_id):
"""添加种子文件"""
file = request.files['torrent_file']
if file.filename == '':
return jsonify({
'success': False,
'error': '请选择种子文件'
}), 400
if not file.filename.lower().endswith('.torrent'):
return jsonify({
'success': False,
'error': '请上传.torrent文件'
}), 400
# 获取可选参数
options = {
'category': request.form.get('category', ''),
'tags': request.form.get('tags', ''),
'save_path': request.form.get('save_path', ''),
'paused': request.form.get('paused', 'false').lower() == 'true'
}
# 保存临时文件
filename = secure_filename(file.filename)
with tempfile.NamedTemporaryFile(delete=False, suffix='.torrent') as temp_file:
file.save(temp_file.name)
try:
result = torrent_service.add_torrent_file(client_id, temp_file.name, options)
return jsonify(result)
finally:
# 清理临时文件
os.unlink(temp_file.name)
def _add_magnet_link(client_id, magnet_link, options):
"""添加磁力链接"""
if not magnet_link.startswith('magnet:'):
return jsonify({
'success': False,
'error': '无效的磁力链接'
}), 400
result = torrent_service.add_magnet_link(client_id, magnet_link, options)
return jsonify(result)
def _add_torrent_url(client_id, torrent_url, options):
"""添加种子URL"""
if not torrent_url.startswith(('http://', 'https://')):
return jsonify({
'success': False,
'error': '无效的种子URL'
}), 400
result = torrent_service.add_torrent_url(client_id, torrent_url, options)
return jsonify(result)

67
backend/app.py Normal file
View File

@ -0,0 +1,67 @@
"""
Flask 应用入口文件
多客户端 qBittorrent 集中管理平台后端
"""
from flask import Flask, jsonify
from flask_cors import CORS
from config import Config
from api.clients import clients_bp
from api.torrents import torrents_bp
def create_app():
"""创建 Flask 应用实例"""
app = Flask(__name__)
# 加载配置
app.config.from_object(Config)
# 配置 CORS
CORS(app, origins=Config.CORS_ORIGINS)
# 注册蓝图
app.register_blueprint(clients_bp)
app.register_blueprint(torrents_bp)
# 健康检查端点
@app.route('/api/health')
def health_check():
return jsonify({
'status': 'healthy',
'message': 'qBittorrent 管理平台后端运行正常'
})
# 根路径
@app.route('/')
def index():
return jsonify({
'name': 'qBittorrent 管理平台 API',
'version': '1.0.0',
'description': '多客户端 qBittorrent 集中管理平台后端 API'
})
# 全局错误处理
@app.errorhandler(404)
def not_found(error):
return jsonify({
'success': False,
'error': '接口不存在'
}), 404
@app.errorhandler(500)
def internal_error(error):
return jsonify({
'success': False,
'error': '服务器内部错误'
}), 500
return app
if __name__ == '__main__':
app = create_app()
app.run(
debug=Config.DEBUG,
host=Config.APP_HOST,
port=Config.APP_PORT
)

20
backend/config.py Normal file
View File

@ -0,0 +1,20 @@
import os
from dotenv import load_dotenv
# 加载环境变量
load_dotenv()
class Config:
"""应用配置类"""
SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production'
# 应用配置
APP_HOST = os.environ.get('APP_HOST', '0.0.0.0')
APP_PORT = int(os.environ.get('APP_PORT', 8888))
DEBUG = os.environ.get('DEBUG', 'True').lower() == 'true'
# 客户端配置文件路径
CLIENTS_CONFIG_PATH = os.environ.get('CLIENTS_CONFIG_PATH', 'data/clients.json')
# CORS 配置
CORS_ORIGINS = ['http://localhost:3000', 'http://127.0.0.1:3000']

11
backend/data/clients.json Normal file
View File

@ -0,0 +1,11 @@
[
{
"id": "a0c59f6a-a4aa-4bd9-9108-192c42f8f6c7",
"name": "zack-server",
"host": "https://qbitn.nosuchip.com",
"port": 3443,
"username": "zack",
"password": "haHA7359339.pq",
"enabled": true
}
]

67
backend/debug_transfer.py Normal file
View File

@ -0,0 +1,67 @@
#!/usr/bin/env python3
"""
调试脚本:检查 qBittorrent transfer info 对象的属性
"""
import json
from services.client_service import ClientService
def debug_transfer_info():
client_service = ClientService()
clients = client_service.get_enabled_clients()
if not clients:
print("没有找到启用的客户端")
return
for client in clients:
print(f"\n调试客户端: {client['name']}")
try:
qbt_client = client_service._create_qbt_client(client)
# 先测试基本连接
print(f"qBittorrent 版本: {qbt_client.app.version}")
# 获取种子列表
torrents = qbt_client.torrents.info()
print(f"种子数量: {len(torrents)}")
# 尝试不同的方式获取传输信息
print("\n尝试获取传输信息:")
# 方法1: transfer.info()
try:
transfer_info = qbt_client.transfer.info()
print(f"transfer.info() 成功: {type(transfer_info)}")
print(f"内容: {transfer_info}")
except Exception as e:
print(f"transfer.info() 失败: {e}")
# 方法2: transfer_info()
try:
transfer_info = qbt_client.transfer_info()
print(f"transfer_info() 成功: {type(transfer_info)}")
print(f"内容: {transfer_info}")
except Exception as e:
print(f"transfer_info() 失败: {e}")
# 方法3: sync.maindata()
try:
maindata = qbt_client.sync.maindata()
print(f"sync.maindata() 成功: {type(maindata)}")
if hasattr(maindata, 'server_state'):
server_state = maindata.server_state
print(f"server_state: {server_state}")
if hasattr(server_state, 'dl_info_speed'):
print(f"下载速度: {server_state.dl_info_speed}")
if hasattr(server_state, 'up_info_speed'):
print(f"上传速度: {server_state.up_info_speed}")
except Exception as e:
print(f"sync.maindata() 失败: {e}")
except Exception as e:
print(f"连接客户端失败: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
debug_transfer_info()

25
backend/pyproject.toml Normal file
View File

@ -0,0 +1,25 @@
[project]
name = "qbit-manager-backend"
version = "0.1.0"
description = "多客户端 qBittorrent 集中管理平台后端"
requires-python = ">=3.10"
dependencies = [
"flask>=3.0.0",
"flask-cors>=4.0.0",
"qbittorrent-api>=2024.1.59",
"python-dotenv>=1.0.0",
"gunicorn>=21.2.0",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["."]
[tool.uv]
dev-dependencies = [
"pytest>=7.0.0",
"pytest-flask>=1.2.0",
]

5
backend/requirements.txt Normal file
View File

@ -0,0 +1,5 @@
flask>=3.0.0
flask-cors>=4.0.0
qbittorrent-api>=2024.3.60
python-dotenv>=1.0.0
gunicorn>=21.2.0

View File

@ -0,0 +1 @@
# 服务层模块

View File

@ -0,0 +1,178 @@
"""
qBittorrent 客户端服务层
负责管理客户端连接信息和与 qBittorrent API 的交互
"""
import json
import os
from typing import List, Dict, Optional, Any
from concurrent.futures import ThreadPoolExecutor, as_completed
import qbittorrentapi
from config import Config
class ClientService:
"""qBittorrent 客户端服务"""
def __init__(self):
self.config_path = Config.CLIENTS_CONFIG_PATH
self._ensure_config_dir()
def _ensure_config_dir(self):
"""确保配置目录存在"""
os.makedirs(os.path.dirname(self.config_path), exist_ok=True)
if not os.path.exists(self.config_path):
with open(self.config_path, 'w', encoding='utf-8') as f:
json.dump([], f)
def get_clients(self) -> List[Dict[str, Any]]:
"""获取所有客户端配置"""
try:
with open(self.config_path, 'r', encoding='utf-8') as f:
clients = json.load(f)
# 移除密码字段,不返回给前端
return [{k: v for k, v in client.items() if k != 'password'}
for client in clients]
except (FileNotFoundError, json.JSONDecodeError):
return []
def get_client_by_id(self, client_id: str) -> Optional[Dict[str, Any]]:
"""根据ID获取客户端配置"""
clients = self._load_clients_with_password()
return next((client for client in clients if client['id'] == client_id), None)
def _load_clients_with_password(self) -> List[Dict[str, Any]]:
"""加载包含密码的客户端配置(内部使用)"""
try:
with open(self.config_path, 'r', encoding='utf-8') as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return []
def add_client(self, client_data: Dict[str, Any]) -> Dict[str, Any]:
"""添加新客户端"""
clients = self._load_clients_with_password()
# 生成唯一ID
import uuid
client_id = str(uuid.uuid4())
client = {
'id': client_id,
'name': client_data['name'],
'host': client_data['host'],
'port': client_data['port'],
'username': client_data.get('username', ''),
'password': client_data.get('password', ''),
'enabled': client_data.get('enabled', True)
}
clients.append(client)
self._save_clients(clients)
# 返回不包含密码的客户端信息
return {k: v for k, v in client.items() if k != 'password'}
def update_client(self, client_id: str, client_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""更新客户端配置"""
clients = self._load_clients_with_password()
for i, client in enumerate(clients):
if client['id'] == client_id:
# 更新字段
client.update({
'name': client_data.get('name', client['name']),
'host': client_data.get('host', client['host']),
'port': client_data.get('port', client['port']),
'username': client_data.get('username', client['username']),
'enabled': client_data.get('enabled', client['enabled'])
})
# 只有提供了密码才更新
if 'password' in client_data:
client['password'] = client_data['password']
clients[i] = client
self._save_clients(clients)
# 返回不包含密码的客户端信息
return {k: v for k, v in client.items() if k != 'password'}
return None
def delete_client(self, client_id: str) -> bool:
"""删除客户端"""
clients = self._load_clients_with_password()
original_count = len(clients)
clients = [client for client in clients if client['id'] != client_id]
if len(clients) < original_count:
self._save_clients(clients)
return True
return False
def _save_clients(self, clients: List[Dict[str, Any]]):
"""保存客户端配置到文件"""
with open(self.config_path, 'w', encoding='utf-8') as f:
json.dump(clients, f, indent=2, ensure_ascii=False)
def test_client_connection(self, client_id: str) -> Dict[str, Any]:
"""测试客户端连接"""
client = self.get_client_by_id(client_id)
if not client:
return {'success': False, 'error': '客户端不存在'}
try:
qbt_client = self._create_qbt_client(client)
# 尝试获取应用版本来测试连接
version = qbt_client.app.version
return {
'success': True,
'version': version,
'web_api_version': qbt_client.app.web_api_version
}
except qbittorrentapi.LoginFailed:
return {'success': False, 'error': '认证失败,请检查用户名和密码'}
except qbittorrentapi.APIConnectionError:
return {'success': False, 'error': '连接失败,请检查主机地址和端口'}
except Exception as e:
return {'success': False, 'error': f'连接错误: {str(e)}'}
def _create_qbt_client(self, client: Dict[str, Any]) -> qbittorrentapi.Client:
"""创建 qBittorrent 客户端实例"""
return qbittorrentapi.Client(
host=client['host'],
port=client['port'],
username=client['username'],
password=client['password'],
REQUESTS_ARGS={'timeout': (5, 30)} # 连接超时5秒读取超时30秒
)
def get_enabled_clients(self) -> List[Dict[str, Any]]:
"""获取所有启用的客户端"""
clients = self._load_clients_with_password()
return [client for client in clients if client.get('enabled', True)]
def get_clients_with_connection(self) -> List[Dict[str, Any]]:
"""获取客户端列表并测试连接状态"""
clients = self.get_clients()
def test_connection(client):
result = self.test_client_connection(client['id'])
client['connected'] = result['success']
if result['success']:
client['version'] = result.get('version')
client['web_api_version'] = result.get('web_api_version')
else:
client['error'] = result.get('error')
return client
# 并发测试连接
with ThreadPoolExecutor(max_workers=10) as executor:
future_to_client = {executor.submit(test_connection, client): client
for client in clients}
results = []
for future in as_completed(future_to_client):
results.append(future.result())
return results

View File

@ -0,0 +1,364 @@
"""
种子服务层
负责聚合多个客户端的种子数据和执行种子操作
"""
from typing import List, Dict, Any, Optional
from concurrent.futures import ThreadPoolExecutor, as_completed
import qbittorrentapi
from .client_service import ClientService
class TorrentService:
"""种子服务"""
def __init__(self):
self.client_service = ClientService()
def get_all_torrents(self, client_ids: Optional[List[str]] = None) -> Dict[str, Any]:
"""获取所有客户端的种子列表"""
clients = self.client_service.get_enabled_clients()
# 如果指定了客户端ID则只获取这些客户端的数据
if client_ids:
clients = [client for client in clients if client['id'] in client_ids]
if not clients:
return {
'torrents': [],
'global_stats': {
'download_speed': 0,
'upload_speed': 0,
'total_torrents': 0,
'active_torrents': 0,
'downloading': 0,
'seeding': 0,
'paused': 0
},
'clients_status': []
}
def fetch_client_data(client):
"""获取单个客户端的数据"""
try:
qbt_client = self.client_service._create_qbt_client(client)
# 获取种子列表
torrents = qbt_client.torrents.info()
# 获取全局统计信息
transfer_info = qbt_client.transfer_info()
# 为每个种子添加客户端信息
client_torrents = []
for torrent in torrents:
# TorrentDictionary 本身就是字典,直接转换
torrent_dict = dict(torrent)
torrent_dict['client_id'] = client['id']
torrent_dict['client_name'] = client['name']
client_torrents.append(torrent_dict)
return {
'success': True,
'client_id': client['id'],
'client_name': client['name'],
'torrents': client_torrents,
'stats': {
'download_speed': transfer_info.dl_info_speed,
'upload_speed': transfer_info.up_info_speed,
'total_torrents': len(torrents),
'downloading': len([t for t in torrents if t.state in ['downloading', 'stalledDL', 'metaDL']]),
'seeding': len([t for t in torrents if t.state in ['uploading', 'stalledUP']]),
'paused': len([t for t in torrents if 'paused' in t.state.lower()])
}
}
except Exception as e:
return {
'success': False,
'client_id': client['id'],
'client_name': client['name'],
'error': str(e),
'torrents': [],
'stats': {
'download_speed': 0,
'upload_speed': 0,
'total_torrents': 0,
'downloading': 0,
'seeding': 0,
'paused': 0
}
}
# 并发获取所有客户端数据
all_torrents = []
global_stats = {
'download_speed': 0,
'upload_speed': 0,
'total_torrents': 0,
'active_torrents': 0,
'downloading': 0,
'seeding': 0,
'paused': 0
}
clients_status = []
with ThreadPoolExecutor(max_workers=10) as executor:
future_to_client = {executor.submit(fetch_client_data, client): client
for client in clients}
for future in as_completed(future_to_client):
result = future.result()
# 收集种子数据
all_torrents.extend(result['torrents'])
# 聚合统计数据
stats = result['stats']
global_stats['download_speed'] += stats['download_speed']
global_stats['upload_speed'] += stats['upload_speed']
global_stats['total_torrents'] += stats['total_torrents']
global_stats['downloading'] += stats['downloading']
global_stats['seeding'] += stats['seeding']
global_stats['paused'] += stats['paused']
# 记录客户端状态
clients_status.append({
'client_id': result['client_id'],
'client_name': result['client_name'],
'connected': result['success'],
'error': result.get('error'),
'stats': stats
})
# 计算活跃种子数
global_stats['active_torrents'] = global_stats['downloading'] + global_stats['seeding']
return {
'torrents': all_torrents,
'global_stats': global_stats,
'clients_status': clients_status
}
def pause_torrents(self, torrent_hashes: List[str], client_id: Optional[str] = None) -> Dict[str, Any]:
"""暂停种子"""
return self._execute_torrent_action('pause', torrent_hashes, client_id)
def resume_torrents(self, torrent_hashes: List[str], client_id: Optional[str] = None) -> Dict[str, Any]:
"""恢复种子"""
return self._execute_torrent_action('resume', torrent_hashes, client_id)
def delete_torrents(self, torrent_hashes: List[str], delete_files: bool = False, client_id: Optional[str] = None) -> Dict[str, Any]:
"""删除种子"""
return self._execute_torrent_action('delete', torrent_hashes, client_id, delete_files=delete_files)
def _execute_torrent_action(self, action: str, torrent_hashes: List[str], client_id: Optional[str] = None, **kwargs) -> Dict[str, Any]:
"""执行种子操作"""
if client_id:
# 对指定客户端执行操作
client = self.client_service.get_client_by_id(client_id)
if not client:
return {'success': False, 'error': '客户端不存在'}
return self._execute_action_on_client(client, action, torrent_hashes, **kwargs)
else:
# 对所有客户端执行操作
clients = self.client_service.get_enabled_clients()
results = []
with ThreadPoolExecutor(max_workers=10) as executor:
future_to_client = {
executor.submit(self._execute_action_on_client, client, action, torrent_hashes, **kwargs): client
for client in clients
}
for future in as_completed(future_to_client):
client = future_to_client[future]
try:
result = future.result()
result['client_id'] = client['id']
result['client_name'] = client['name']
results.append(result)
except Exception as e:
results.append({
'success': False,
'client_id': client['id'],
'client_name': client['name'],
'error': str(e)
})
# 统计成功和失败的结果
successful = [r for r in results if r['success']]
failed = [r for r in results if not r['success']]
return {
'success': len(successful) > 0,
'results': results,
'summary': {
'total_clients': len(results),
'successful_clients': len(successful),
'failed_clients': len(failed)
}
}
def _execute_action_on_client(self, client: Dict[str, Any], action: str, torrent_hashes: List[str], **kwargs) -> Dict[str, Any]:
"""在指定客户端上执行操作"""
try:
qbt_client = self.client_service._create_qbt_client(client)
if action == 'pause':
qbt_client.torrents.pause(torrent_hashes=torrent_hashes)
elif action == 'resume':
qbt_client.torrents.resume(torrent_hashes=torrent_hashes)
elif action == 'delete':
delete_files = kwargs.get('delete_files', False)
qbt_client.torrents.delete(torrent_hashes=torrent_hashes, delete_files=delete_files)
else:
return {'success': False, 'error': f'不支持的操作: {action}'}
return {'success': True, 'message': f'操作 {action} 执行成功'}
except Exception as e:
return {'success': False, 'error': str(e)}
def get_torrent_details(self, torrent_hash: str, client_id: str) -> Optional[Dict[str, Any]]:
"""获取种子详细信息"""
client = self.client_service.get_client_by_id(client_id)
if not client:
return None
try:
qbt_client = self.client_service._create_qbt_client(client)
# 获取种子基本信息
torrents = qbt_client.torrents.info(torrent_hashes=torrent_hash)
if not torrents:
return None
torrent = torrents[0]
torrent_dict = dict(torrent)
# 获取种子文件列表
try:
files = qbt_client.torrents.files(torrent_hash=torrent_hash)
torrent_dict['files'] = [dict(file) for file in files]
except:
torrent_dict['files'] = []
# 获取 Tracker 信息
try:
trackers = qbt_client.torrents.trackers(torrent_hash=torrent_hash)
torrent_dict['trackers'] = [dict(tracker) for tracker in trackers]
except:
torrent_dict['trackers'] = []
# 获取 Peers 信息
try:
peers = qbt_client.torrents.peers(torrent_hash=torrent_hash)
torrent_dict['peers'] = [dict(peer) for peer in peers.values()]
except:
torrent_dict['peers'] = []
# 添加客户端信息
torrent_dict['client_id'] = client['id']
torrent_dict['client_name'] = client['name']
return torrent_dict
except Exception as e:
return None
def add_torrent_file(self, client_id: str, torrent_file_path: str, options: dict) -> dict:
"""添加种子文件"""
client = self.client_service.get_client_by_id(client_id)
if not client:
return {'success': False, 'error': '客户端不存在'}
try:
qbt_client = self.client_service._create_qbt_client(client)
# 准备添加选项
add_options = {}
if options.get('category'):
add_options['category'] = options['category']
if options.get('tags'):
add_options['tags'] = options['tags']
if options.get('save_path'):
add_options['savepath'] = options['save_path']
if options.get('paused'):
add_options['paused'] = 'true'
# 添加种子文件
with open(torrent_file_path, 'rb') as torrent_file:
result = qbt_client.torrents.add(torrent_files=torrent_file, **add_options)
return {
'success': True,
'message': '种子文件添加成功',
'result': result
}
except Exception as e:
return {'success': False, 'error': str(e)}
def add_magnet_link(self, client_id: str, magnet_link: str, options: dict) -> dict:
"""添加磁力链接"""
client = self.client_service.get_client_by_id(client_id)
if not client:
return {'success': False, 'error': '客户端不存在'}
try:
qbt_client = self.client_service._create_qbt_client(client)
# 准备添加选项
add_options = {}
if options.get('category'):
add_options['category'] = options['category']
if options.get('tags'):
add_options['tags'] = options['tags']
if options.get('save_path'):
add_options['savepath'] = options['save_path']
if options.get('paused'):
add_options['paused'] = 'true'
# 添加磁力链接
result = qbt_client.torrents.add(urls=magnet_link, **add_options)
return {
'success': True,
'message': '磁力链接添加成功',
'result': result
}
except Exception as e:
return {'success': False, 'error': str(e)}
def add_torrent_url(self, client_id: str, torrent_url: str, options: dict) -> dict:
"""添加种子URL"""
client = self.client_service.get_client_by_id(client_id)
if not client:
return {'success': False, 'error': '客户端不存在'}
try:
qbt_client = self.client_service._create_qbt_client(client)
# 准备添加选项
add_options = {}
if options.get('category'):
add_options['category'] = options['category']
if options.get('tags'):
add_options['tags'] = options['tags']
if options.get('save_path'):
add_options['savepath'] = options['save_path']
if options.get('paused'):
add_options['paused'] = 'true'
# 添加种子URL
result = qbt_client.torrents.add(urls=torrent_url, **add_options)
return {
'success': True,
'message': '种子URL添加成功',
'result': result
}
except Exception as e:
return {'success': False, 'error': str(e)}

76
deploy.sh Executable file
View File

@ -0,0 +1,76 @@
#!/bin/bash
# qBittorrent 管理平台部署脚本
set -e
echo "🚀 开始部署 qBittorrent 管理平台..."
# 检查 Docker 和 Docker Compose 是否安装
if ! command -v docker &> /dev/null; then
echo "❌ Docker 未安装,请先安装 Docker"
exit 1
fi
if ! command -v docker-compose &> /dev/null; then
echo "❌ Docker Compose 未安装,请先安装 Docker Compose"
exit 1
fi
# 创建数据目录
echo "📁 创建数据目录..."
mkdir -p data
# 停止现有容器(如果存在)
echo "🛑 停止现有容器..."
docker-compose down --remove-orphans || true
# 构建并启动服务
echo "🔨 构建并启动服务..."
docker-compose up --build -d
# 等待服务启动
echo "⏳ 等待服务启动..."
sleep 10
# 检查服务状态
echo "🔍 检查服务状态..."
docker-compose ps
# 检查后端健康状态
echo "🏥 检查后端健康状态..."
for i in {1..30}; do
if curl -f http://localhost:8080/api/health &> /dev/null; then
echo "✅ 后端服务启动成功!"
break
fi
if [ $i -eq 30 ]; then
echo "❌ 后端服务启动失败,请检查日志"
docker-compose logs backend
exit 1
fi
sleep 2
done
# 检查前端
echo "🌐 检查前端服务..."
if curl -f http://localhost:8080 &> /dev/null; then
echo "✅ 前端服务启动成功!"
else
echo "❌ 前端服务启动失败,请检查日志"
docker-compose logs frontend
exit 1
fi
echo ""
echo "🎉 部署完成!"
echo ""
echo "📱 访问地址: http://localhost:8080"
echo "🔧 管理命令:"
echo " 查看日志: docker-compose logs -f"
echo " 停止服务: docker-compose down"
echo " 重启服务: docker-compose restart"
echo " 更新服务: ./deploy.sh"
echo ""
echo "📊 服务状态:"
docker-compose ps

52
docker-compose.yml Normal file
View File

@ -0,0 +1,52 @@
version: '3.8'
services:
# 后端服务
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: qbit-manager-backend
restart: unless-stopped
environment:
- FLASK_ENV=production
- APP_HOST=0.0.0.0
- APP_PORT=8888
- DEBUG=False
- CLIENTS_CONFIG_PATH=/app/data/clients.json
volumes:
- ./data:/app/data
networks:
- qbit-network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8888/api/health"]
interval: 30s
timeout: 10s
retries: 3
# 前端服务
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: qbit-manager-frontend
restart: unless-stopped
ports:
- "8080:80"
depends_on:
- backend
networks:
- qbit-network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:80"]
interval: 30s
timeout: 10s
retries: 3
networks:
qbit-network:
driver: bridge
volumes:
qbit-data:
driver: local

32
frontend/Dockerfile Normal file
View File

@ -0,0 +1,32 @@
# 前端 Dockerfile - 多阶段构建
# 第一阶段:构建
FROM node:18-alpine AS builder
WORKDIR /app
# 复制 package 文件
COPY package*.json ./
# 安装依赖
RUN npm ci --only=production
# 复制源代码
COPY . .
# 构建应用
RUN npm run build
# 第二阶段:运行
FROM nginx:alpine
# 复制构建产物
COPY --from=builder /app/dist /usr/share/nginx/html
# 复制 nginx 配置
COPY nginx.conf /etc/nginx/conf.d/default.conf
# 暴露端口
EXPOSE 80
# 启动 nginx
CMD ["nginx", "-g", "daemon off;"]

13
frontend/index.html Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>qBittorrent 管理平台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

40
frontend/nginx.conf Normal file
View File

@ -0,0 +1,40 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# 启用 gzip 压缩
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
# 静态文件缓存
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# API 请求代理到后端
location /api/ {
proxy_pass http://backend:8888;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 30s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
}
# Vue Router 历史模式支持
location / {
try_files $uri $uri/ /index.html;
}
# 安全头
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
}

3155
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
frontend/package.json Normal file
View File

@ -0,0 +1,28 @@
{
"name": "qbit-manager-frontend",
"version": "0.1.0",
"description": "多客户端 qBittorrent 集中管理平台前端",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
},
"dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.2.5",
"pinia": "^2.1.7",
"element-plus": "^2.4.4",
"axios": "^1.6.2",
"@element-plus/icons-vue": "^2.3.1"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.5.2",
"vite": "^5.0.8",
"eslint": "^8.56.0",
"eslint-plugin-vue": "^9.19.2",
"@vue/eslint-config-prettier": "^9.0.0",
"prettier": "^3.1.1"
}
}

23
frontend/src/App.vue Normal file
View File

@ -0,0 +1,23 @@
<template>
<div id="app">
<router-view />
</div>
</template>
<script setup>
// 应用根组件
</script>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: #2c3e50;
}
body {
margin: 0;
padding: 0;
}
</style>

View File

@ -0,0 +1,31 @@
/**
* 客户端管理 API
*/
import api from './index'
export const clientsApi = {
// 获取所有客户端
getClients() {
return api.get('/clients')
},
// 添加客户端
addClient(clientData) {
return api.post('/clients', clientData)
},
// 更新客户端
updateClient(clientId, clientData) {
return api.put(`/clients/${clientId}`, clientData)
},
// 删除客户端
deleteClient(clientId) {
return api.delete(`/clients/${clientId}`)
},
// 测试客户端连接
testConnection(clientId) {
return api.post(`/clients/${clientId}/test`)
}
}

39
frontend/src/api/index.js Normal file
View File

@ -0,0 +1,39 @@
/**
* API 接口配置
*/
import axios from 'axios'
// 创建 axios 实例
const api = axios.create({
baseURL: '/api',
timeout: 30000,
headers: {
'Content-Type': 'application/json'
}
})
// 请求拦截器
api.interceptors.request.use(
config => {
// 可以在这里添加认证 token
return config
},
error => {
return Promise.reject(error)
}
)
// 响应拦截器
api.interceptors.response.use(
response => {
return response.data
},
error => {
// 统一错误处理
const message = error.response?.data?.error || error.message || '请求失败'
console.error('API Error:', message)
return Promise.reject(new Error(message))
}
)
export default api

View File

@ -0,0 +1,87 @@
/**
* 种子管理 API
*/
import api from './index'
export const torrentsApi = {
// 获取种子列表
getTorrents(clientIds = []) {
const params = clientIds.length > 0 ? { client_ids: clientIds } : {}
return api.get('/torrents', { params })
},
// 获取主要数据(聚合接口)
getMainData(clientIds = []) {
const params = clientIds.length > 0 ? { client_ids: clientIds } : {}
return api.get('/torrents/maindata', { params })
},
// 暂停种子
pauseTorrents(hashes, clientId = null) {
return api.post('/torrents/pause', {
hashes,
client_id: clientId
})
},
// 恢复种子
resumeTorrents(hashes, clientId = null) {
return api.post('/torrents/resume', {
hashes,
client_id: clientId
})
},
// 删除种子
deleteTorrents(hashes, deleteFiles = false, clientId = null) {
return api.post('/torrents/delete', {
hashes,
delete_files: deleteFiles,
client_id: clientId
})
},
// 获取种子详细信息
getTorrentDetails(torrentHash, clientId) {
return api.get(`/torrents/${torrentHash}/details`, {
params: { client_id: clientId }
})
},
// 添加种子文件
addTorrentFile(clientId, file, options = {}) {
const formData = new FormData()
formData.append('client_id', clientId)
formData.append('torrent_file', file)
// 添加可选参数
if (options.category) formData.append('category', options.category)
if (options.tags) formData.append('tags', options.tags)
if (options.savePath) formData.append('save_path', options.savePath)
if (options.paused) formData.append('paused', options.paused.toString())
return api.post('/torrents/add', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
},
// 添加磁力链接
addMagnetLink(clientId, magnetLink, options = {}) {
return api.post('/torrents/add', {
client_id: clientId,
magnet_link: magnetLink,
options
})
},
// 添加种子URL
addTorrentUrl(clientId, torrentUrl, options = {}) {
return api.post('/torrents/add', {
client_id: clientId,
torrent_url: torrentUrl,
options
})
}
}

View File

@ -0,0 +1,260 @@
<template>
<el-dialog
title="添加种子"
v-model="visible"
width="600px"
@close="resetForm"
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="100px"
>
<el-form-item label="客户端" prop="clientId">
<el-select v-model="form.clientId" placeholder="选择客户端" style="width: 100%">
<el-option
v-for="client in connectedClients"
:key="client.id"
:label="client.name"
:value="client.id"
/>
</el-select>
</el-form-item>
<el-form-item label="添加方式">
<el-radio-group v-model="addMethod">
<el-radio value="file">种子文件</el-radio>
<el-radio value="magnet">磁力链接</el-radio>
<el-radio value="url">种子URL</el-radio>
</el-radio-group>
</el-form-item>
<!-- 种子文件上传 -->
<el-form-item v-if="addMethod === 'file'" label="种子文件" prop="torrentFile">
<el-upload
ref="uploadRef"
:auto-upload="false"
:show-file-list="true"
:limit="1"
accept=".torrent"
:on-change="handleFileChange"
:on-remove="handleFileRemove"
>
<el-button type="primary">选择文件</el-button>
<template #tip>
<div class="el-upload__tip">
只能上传 .torrent 文件
</div>
</template>
</el-upload>
</el-form-item>
<!-- 磁力链接 -->
<el-form-item v-if="addMethod === 'magnet'" label="磁力链接" prop="magnetLink">
<el-input
v-model="form.magnetLink"
type="textarea"
:rows="3"
placeholder="magnet:?xt=urn:btih:..."
/>
</el-form-item>
<!-- 种子URL -->
<el-form-item v-if="addMethod === 'url'" label="种子URL" prop="torrentUrl">
<el-input
v-model="form.torrentUrl"
placeholder="https://example.com/torrent.torrent"
/>
</el-form-item>
<!-- 可选设置 -->
<el-divider content-position="left">可选设置</el-divider>
<el-form-item label="分类">
<el-input v-model="form.category" placeholder="种子分类" />
</el-form-item>
<el-form-item label="标签">
<el-input v-model="form.tags" placeholder="用逗号分隔多个标签" />
</el-form-item>
<el-form-item label="保存路径">
<el-input v-model="form.savePath" placeholder="自定义保存路径" />
</el-form-item>
<el-form-item label="添加后暂停">
<el-switch v-model="form.paused" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="submitForm" :loading="submitting">
添加种子
</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { useClientsStore } from '@/stores/clients'
import { torrentsApi } from '@/api/torrents'
import { ElMessage } from 'element-plus'
// Props
const props = defineProps({
modelValue: {
type: Boolean,
default: false
}
})
// Emits
const emit = defineEmits(['update:modelValue', 'success'])
// Stores
const clientsStore = useClientsStore()
// 响应式数据
const visible = ref(false)
const addMethod = ref('file')
const submitting = ref(false)
const formRef = ref()
const uploadRef = ref()
const form = ref({
clientId: '',
torrentFile: null,
magnetLink: '',
torrentUrl: '',
category: '',
tags: '',
savePath: '',
paused: false
})
const rules = {
clientId: [
{ required: true, message: '请选择客户端', trigger: 'change' }
],
torrentFile: [
{ required: true, message: '请选择种子文件', trigger: 'change' }
],
magnetLink: [
{ required: true, message: '请输入磁力链接', trigger: 'blur' },
{ pattern: /^magnet:/, message: '请输入有效的磁力链接', trigger: 'blur' }
],
torrentUrl: [
{ required: true, message: '请输入种子URL', trigger: 'blur' },
{ type: 'url', message: '请输入有效的URL', trigger: 'blur' }
]
}
// 计算属性
const connectedClients = computed(() => clientsStore.connectedClients)
// 监听 modelValue 变化
watch(() => props.modelValue, (newVal) => {
visible.value = newVal
})
watch(visible, (newVal) => {
emit('update:modelValue', newVal)
})
// 监听添加方式变化,重置验证
watch(addMethod, () => {
if (formRef.value) {
formRef.value.clearValidate()
}
})
// 方法
const handleFileChange = (file) => {
form.value.torrentFile = file.raw
}
const handleFileRemove = () => {
form.value.torrentFile = null
}
const submitForm = async () => {
if (!formRef.value) return
try {
// 根据添加方式验证不同字段
const fieldsToValidate = ['clientId']
if (addMethod.value === 'file') fieldsToValidate.push('torrentFile')
if (addMethod.value === 'magnet') fieldsToValidate.push('magnetLink')
if (addMethod.value === 'url') fieldsToValidate.push('torrentUrl')
await formRef.value.validateField(fieldsToValidate)
submitting.value = true
const options = {
category: form.value.category,
tags: form.value.tags,
savePath: form.value.savePath,
paused: form.value.paused
}
let response
if (addMethod.value === 'file') {
response = await torrentsApi.addTorrentFile(form.value.clientId, form.value.torrentFile, options)
} else if (addMethod.value === 'magnet') {
response = await torrentsApi.addMagnetLink(form.value.clientId, form.value.magnetLink, options)
} else if (addMethod.value === 'url') {
response = await torrentsApi.addTorrentUrl(form.value.clientId, form.value.torrentUrl, options)
}
if (response.success) {
ElMessage.success(response.message || '种子添加成功')
visible.value = false
emit('success')
} else {
ElMessage.error(response.error || '添加失败')
}
} catch (error) {
ElMessage.error(`添加种子失败: ${error.message}`)
} finally {
submitting.value = false
}
}
const resetForm = () => {
form.value = {
clientId: '',
torrentFile: null,
magnetLink: '',
torrentUrl: '',
category: '',
tags: '',
savePath: '',
paused: false
}
addMethod.value = 'file'
if (formRef.value) {
formRef.value.resetFields()
}
if (uploadRef.value) {
uploadRef.value.clearFiles()
}
}
</script>
<style scoped>
.el-divider {
margin: 20px 0;
}
.el-upload__tip {
color: #909399;
font-size: 12px;
margin-top: 5px;
}
</style>

View File

@ -0,0 +1,258 @@
<template>
<div class="client-manager">
<div class="header">
<h3>客户端管理</h3>
<el-button type="primary" @click="showAddDialog = true">
<el-icon><Plus /></el-icon>
添加客户端
</el-button>
</div>
<el-table
:data="clients"
v-loading="loading"
style="width: 100%"
empty-text="暂无客户端"
>
<el-table-column prop="name" label="名称" min-width="120" />
<el-table-column prop="host" label="主机" min-width="120" />
<el-table-column prop="port" label="端口" width="80" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.connected ? 'success' : 'danger'">
{{ row.connected ? '已连接' : '未连接' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="版本" min-width="100">
<template #default="{ row }">
<span v-if="row.version">{{ row.version }}</span>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="testConnection(row.id)">
测试
</el-button>
<el-button size="small" type="primary" @click="editClient(row)">
编辑
</el-button>
<el-button size="small" type="danger" @click="deleteClient(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 添加/编辑客户端对话框 -->
<el-dialog
:title="editingClient ? '编辑客户端' : '添加客户端'"
v-model="showAddDialog"
width="500px"
@close="resetForm"
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="80px"
>
<el-form-item label="名称" prop="name">
<el-input v-model="form.name" placeholder="请输入客户端名称" />
</el-form-item>
<el-form-item label="主机" prop="host">
<el-input v-model="form.host" placeholder="例如: 192.168.1.100" />
</el-form-item>
<el-form-item label="端口" prop="port">
<el-input-number
v-model="form.port"
:min="1"
:max="65535"
placeholder="8080"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="用户名" prop="username">
<el-input v-model="form.username" placeholder="qBittorrent 用户名" />
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="qBittorrent 密码"
show-password
/>
</el-form-item>
<el-form-item label="启用">
<el-switch v-model="form.enabled" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showAddDialog = false">取消</el-button>
<el-button type="primary" @click="submitForm" :loading="submitting">
{{ editingClient ? '更新' : '添加' }}
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useClientsStore } from '@/stores/clients'
import { ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
const clientsStore = useClientsStore()
// 响应式数据
const showAddDialog = ref(false)
const editingClient = ref(null)
const submitting = ref(false)
const formRef = ref()
const form = ref({
name: '',
host: '',
port: 8080,
username: 'admin',
password: '',
enabled: true
})
const rules = {
name: [
{ required: true, message: '请输入客户端名称', trigger: 'blur' }
],
host: [
{ required: true, message: '请输入主机地址', trigger: 'blur' }
],
port: [
{ required: true, message: '请输入端口号', trigger: 'blur' },
{ type: 'number', min: 1, max: 65535, message: '端口号必须在1-65535之间', trigger: 'blur' }
]
}
// 计算属性
const clients = computed(() => clientsStore.clients)
const loading = computed(() => clientsStore.loading)
// 方法
const fetchClients = () => {
clientsStore.fetchClients()
}
const testConnection = async (clientId) => {
await clientsStore.testConnection(clientId)
}
const editClient = (client) => {
editingClient.value = client
form.value = {
name: client.name,
host: client.host,
port: client.port,
username: client.username || 'admin',
password: '', // 不显示原密码
enabled: client.enabled !== false
}
showAddDialog.value = true
}
const deleteClient = async (client) => {
try {
await ElMessageBox.confirm(
`确定要删除客户端 "${client.name}" 吗?`,
'确认删除',
{
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning'
}
)
await clientsStore.deleteClient(client.id)
} catch (error) {
// 用户取消删除
}
}
const submitForm = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
submitting.value = true
if (editingClient.value) {
await clientsStore.updateClient(editingClient.value.id, form.value)
} else {
await clientsStore.addClient(form.value)
}
showAddDialog.value = false
resetForm()
} catch (error) {
// 表单验证失败或提交失败
} finally {
submitting.value = false
}
}
const resetForm = () => {
editingClient.value = null
form.value = {
name: '',
host: '',
port: 8080,
username: 'admin',
password: '',
enabled: true
}
if (formRef.value) {
formRef.value.resetFields()
}
}
// 生命周期
onMounted(() => {
fetchClients()
})
</script>
<style scoped>
.client-manager {
background: white;
border-radius: 8px;
padding: 20px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.header h3 {
margin: 0;
color: #303133;
}
.text-muted {
color: #909399;
}
@media (max-width: 768px) {
.client-manager {
padding: 15px;
}
.header {
flex-direction: column;
align-items: stretch;
gap: 15px;
}
}
</style>

View File

@ -0,0 +1,156 @@
<template>
<div class="global-stats">
<el-row :gutter="20">
<el-col :xs="12" :sm="6" :md="6" :lg="6">
<el-card class="stats-card">
<div class="stats-item">
<div class="stats-icon download">
<el-icon><Download /></el-icon>
</div>
<div class="stats-content">
<div class="stats-value">{{ formatSpeed(downloadSpeed) }}</div>
<div class="stats-label">下载速度</div>
</div>
</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="6" :md="6" :lg="6">
<el-card class="stats-card">
<div class="stats-item">
<div class="stats-icon upload">
<el-icon><Upload /></el-icon>
</div>
<div class="stats-content">
<div class="stats-value">{{ formatSpeed(uploadSpeed) }}</div>
<div class="stats-label">上传速度</div>
</div>
</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="6" :md="6" :lg="6">
<el-card class="stats-card">
<div class="stats-item">
<div class="stats-icon total">
<el-icon><Document /></el-icon>
</div>
<div class="stats-content">
<div class="stats-value">{{ totalTorrents }}</div>
<div class="stats-label">总种子数</div>
</div>
</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="6" :md="6" :lg="6">
<el-card class="stats-card">
<div class="stats-item">
<div class="stats-icon active">
<el-icon><VideoPlay /></el-icon>
</div>
<div class="stats-content">
<div class="stats-value">{{ activeTorrents }}</div>
<div class="stats-label">活跃种子</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useTorrentsStore } from '@/stores/torrents'
import { Download, Upload, Document, VideoPlay } from '@element-plus/icons-vue'
const torrentsStore = useTorrentsStore()
const downloadSpeed = computed(() => torrentsStore.downloadSpeed)
const uploadSpeed = computed(() => torrentsStore.uploadSpeed)
const totalTorrents = computed(() => torrentsStore.totalTorrents)
const activeTorrents = computed(() => torrentsStore.globalStats.active_torrents)
const formatSpeed = (speed) => torrentsStore.formatSpeed(speed)
</script>
<style scoped>
.global-stats {
margin-bottom: 20px;
}
.stats-card {
border-radius: 8px;
transition: all 0.3s ease;
}
.stats-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.stats-item {
display: flex;
align-items: center;
padding: 10px 0;
}
.stats-icon {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 15px;
font-size: 20px;
color: white;
}
.stats-icon.download {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.stats-icon.upload {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}
.stats-icon.total {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
}
.stats-icon.active {
background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
}
.stats-content {
flex: 1;
}
.stats-value {
font-size: 24px;
font-weight: bold;
color: #303133;
line-height: 1;
margin-bottom: 5px;
}
.stats-label {
font-size: 14px;
color: #909399;
}
@media (max-width: 768px) {
.stats-value {
font-size: 20px;
}
.stats-icon {
width: 40px;
height: 40px;
font-size: 18px;
margin-right: 10px;
}
}
</style>

View File

@ -0,0 +1,542 @@
<template>
<div class="torrent-list">
<div class="toolbar">
<div class="filters">
<el-select v-model="filterStatus" placeholder="状态筛选" style="width: 120px">
<el-option label="全部" value="all" />
<el-option label="下载中" value="downloading" />
<el-option label="做种中" value="seeding" />
<el-option label="已暂停" value="paused" />
<el-option label="已完成" value="completed" />
</el-select>
<el-select
v-model="filterClients"
multiple
placeholder="客户端筛选"
style="width: 200px"
collapse-tags
>
<el-option
v-for="client in connectedClients"
:key="client.id"
:label="client.name"
:value="client.id"
/>
</el-select>
</div>
<div class="actions">
<el-button
size="small"
:disabled="selectedTorrents.length === 0"
@click="resumeSelected"
>
<el-icon><VideoPlay /></el-icon>
恢复
</el-button>
<el-button
size="small"
:disabled="selectedTorrents.length === 0"
@click="pauseSelected"
>
<el-icon><VideoPause /></el-icon>
暂停
</el-button>
<el-button
size="small"
type="danger"
:disabled="selectedTorrents.length === 0"
@click="deleteSelected"
>
<el-icon><Delete /></el-icon>
删除
</el-button>
<el-button
size="small"
@click="refreshData"
:loading="refreshing"
>
<el-icon><Refresh /></el-icon>
{{ refreshing ? '刷新中...' : '刷新' }}
</el-button>
<el-button
size="small"
type="success"
@click="showAddDialog = true"
>
<el-icon><Plus /></el-icon>
添加种子
</el-button>
</div>
</div>
<el-table
:data="filteredTorrents"
v-loading="loading"
:element-loading-text="loadingText"
@selection-change="handleSelectionChange"
style="width: 100%"
empty-text="暂无种子"
max-height="600"
>
<el-table-column type="selection" width="55" />
<el-table-column label="名称" min-width="250">
<template #default="{ row }">
<div class="torrent-name">
<div class="name">{{ row.name }}</div>
<div class="torrent-meta">
<el-tag size="small" type="info">{{ row.client_name }}</el-tag>
<el-tag v-if="row.category" size="small" type="warning">{{ row.category }}</el-tag>
<el-tag v-for="tag in getTags(row.tags)" :key="tag" size="small" type="success">{{ tag }}</el-tag>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="大小" width="100">
<template #default="{ row }">
{{ formatSize(row.size) }}
</template>
</el-table-column>
<el-table-column label="进度" width="120">
<template #default="{ row }">
<el-progress
:percentage="Math.round(row.progress * 100)"
:stroke-width="6"
:show-text="false"
/>
<div class="progress-text">{{ Math.round(row.progress * 100) }}%</div>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="getStatusType(row.state)" size="small">
{{ getStatusText(row.state) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="做种/下载" width="100">
<template #default="{ row }">
<div class="peers-info">
<span class="seeds">{{ row.num_seeds || 0 }}</span>
<span class="separator">/</span>
<span class="leeches">{{ row.num_leechs || 0 }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="分享率" width="80">
<template #default="{ row }">
<span :class="getRatioClass(row.ratio)">
{{ formatRatio(row.ratio) }}
</span>
</template>
</el-table-column>
<el-table-column label="下载速度" width="100">
<template #default="{ row }">
{{ formatSpeed(row.dlspeed) }}
</template>
</el-table-column>
<el-table-column label="上传速度" width="100">
<template #default="{ row }">
{{ formatSpeed(row.upspeed) }}
</template>
</el-table-column>
<el-table-column label="添加时间" width="120">
<template #default="{ row }">
{{ formatDate(row.added_on) }}
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button
size="small"
v-if="row.state.includes('paused')"
@click="resumeTorrent(row)"
>
恢复
</el-button>
<el-button
size="small"
v-else
@click="pauseTorrent(row)"
>
暂停
</el-button>
<el-button
size="small"
type="danger"
@click="deleteTorrent(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 状态栏 -->
<div class="status-bar">
<div class="status-info">
<span v-if="lastUpdateTime" class="last-update">
最后更新: {{ formatTime(lastUpdateTime) }}
</span>
<span v-if="operationLoading" class="operation-status">
<el-icon class="is-loading"><Loading /></el-icon>
操作执行中...
</span>
</div>
<div class="torrent-count">
显示 {{ filteredTorrents.length }} / {{ torrentsStore.totalTorrents }} 个种子
</div>
</div>
<!-- 添加种子对话框 -->
<AddTorrentDialog
v-model="showAddDialog"
@success="handleAddSuccess"
/>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { useTorrentsStore } from '@/stores/torrents'
import { useClientsStore } from '@/stores/clients'
import { ElMessageBox } from 'element-plus'
import { VideoPlay, VideoPause, Delete, Refresh, Loading, Plus } from '@element-plus/icons-vue'
import AddTorrentDialog from './AddTorrentDialog.vue'
const torrentsStore = useTorrentsStore()
const clientsStore = useClientsStore()
// 响应式数据
const selectedTorrents = ref([])
const filterStatus = ref('all')
const filterClients = ref([])
const showAddDialog = ref(false)
// 计算属性
const filteredTorrents = computed(() => torrentsStore.filteredTorrents)
const loading = computed(() => torrentsStore.loading)
const refreshing = computed(() => torrentsStore.refreshing)
const operationLoading = computed(() => torrentsStore.operationLoading)
const connectedClients = computed(() => clientsStore.connectedClients)
const lastUpdateTime = computed(() => torrentsStore.lastUpdateTime)
// 加载文本
const loadingText = computed(() => {
if (refreshing.value) return '刷新数据中...'
if (operationLoading.value) return '操作执行中...'
return '加载中...'
})
// 监听筛选条件变化
watch([filterStatus, filterClients], () => {
torrentsStore.setFilter({
status: filterStatus.value,
clientIds: filterClients.value
})
})
// 方法
const handleSelectionChange = (selection) => {
selectedTorrents.value = selection
torrentsStore.setSelectedTorrents(selection)
}
const refreshData = () => {
torrentsStore.fetchTorrents(filterClients.value, true) // 传入 isRefresh = true
}
const handleAddSuccess = () => {
// 添加种子成功后刷新数据
refreshData()
}
const resumeSelected = async () => {
const hashes = selectedTorrents.value.map(t => t.hash)
await torrentsStore.resumeTorrents(hashes)
}
const pauseSelected = async () => {
const hashes = selectedTorrents.value.map(t => t.hash)
await torrentsStore.pauseTorrents(hashes)
}
const deleteSelected = async () => {
try {
await ElMessageBox.confirm(
`确定要删除选中的 ${selectedTorrents.value.length} 个种子吗?`,
'确认删除',
{
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning'
}
)
const hashes = selectedTorrents.value.map(t => t.hash)
await torrentsStore.deleteTorrents(hashes, false)
} catch (error) {
// 用户取消删除
}
}
const resumeTorrent = async (torrent) => {
await torrentsStore.resumeTorrents([torrent.hash], torrent.client_id)
}
const pauseTorrent = async (torrent) => {
await torrentsStore.pauseTorrents([torrent.hash], torrent.client_id)
}
const deleteTorrent = async (torrent) => {
try {
await ElMessageBox.confirm(
`确定要删除种子 "${torrent.name}" 吗?`,
'确认删除',
{
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning'
}
)
await torrentsStore.deleteTorrents([torrent.hash], false, torrent.client_id)
} catch (error) {
// 用户取消删除
}
}
const formatSize = (size) => torrentsStore.formatSize(size)
const formatSpeed = (speed) => torrentsStore.formatSpeed(speed)
const formatTime = (time) => {
if (!time) return ''
const now = new Date()
const diff = now - time
if (diff < 60000) return '刚刚'
if (diff < 3600000) return `${Math.floor(diff / 60000)} 分钟前`
if (diff < 86400000) return `${Math.floor(diff / 3600000)} 小时前`
return time.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
const formatDate = (timestamp) => {
if (!timestamp) return '-'
const date = new Date(timestamp * 1000) // Unix 时间戳转换
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
})
}
const formatRatio = (ratio) => {
if (ratio === undefined || ratio === null) return '-'
if (ratio === -1) return '∞'
return ratio.toFixed(2)
}
const getRatioClass = (ratio) => {
if (ratio === undefined || ratio === null) return ''
if (ratio >= 2) return 'ratio-excellent'
if (ratio >= 1) return 'ratio-good'
if (ratio >= 0.5) return 'ratio-normal'
return 'ratio-low'
}
const getTags = (tags) => {
if (!tags) return []
return tags.split(',').filter(tag => tag.trim()).slice(0, 3) // 最多显示3个标签
}
const getStatusType = (state) => {
if (state.includes('paused')) return 'info'
if (['downloading', 'stalledDL', 'metaDL'].includes(state)) return 'primary'
if (['uploading', 'stalledUP'].includes(state)) return 'success'
if (state === 'error') return 'danger'
return 'info'
}
const getStatusText = (state) => {
const statusMap = {
'downloading': '下载中',
'uploading': '上传中',
'stalledDL': '等待下载',
'stalledUP': '等待上传',
'pausedDL': '已暂停',
'pausedUP': '已暂停',
'queuedDL': '排队下载',
'queuedUP': '排队上传',
'checkingDL': '检查中',
'checkingUP': '检查中',
'error': '错误',
'missingFiles': '文件丢失',
'allocating': '分配空间'
}
return statusMap[state] || state
}
// 生命周期
onMounted(() => {
refreshData()
})
</script>
<style scoped>
.torrent-list {
background: white;
border-radius: 8px;
padding: 20px;
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 15px;
}
.filters {
display: flex;
gap: 15px;
flex-wrap: wrap;
}
.actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.torrent-name {
display: flex;
flex-direction: column;
gap: 5px;
}
.name {
font-weight: 500;
color: #303133;
word-break: break-all;
line-height: 1.4;
}
.torrent-meta {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.peers-info {
display: flex;
align-items: center;
font-size: 12px;
}
.seeds {
color: #67c23a;
font-weight: 500;
}
.leeches {
color: #e6a23c;
font-weight: 500;
}
.separator {
color: #909399;
margin: 0 2px;
}
.ratio-excellent {
color: #67c23a;
font-weight: 600;
}
.ratio-good {
color: #409eff;
font-weight: 500;
}
.ratio-normal {
color: #e6a23c;
}
.ratio-low {
color: #f56c6c;
}
.progress-text {
text-align: center;
font-size: 12px;
color: #909399;
margin-top: 2px;
}
.status-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
border-top: 1px solid #ebeef5;
margin-top: 10px;
font-size: 12px;
color: #909399;
}
.status-info {
display: flex;
align-items: center;
gap: 15px;
}
.operation-status {
display: flex;
align-items: center;
gap: 5px;
color: #409eff;
}
.last-update {
color: #606266;
}
.torrent-count {
font-weight: 500;
}
@media (max-width: 768px) {
.torrent-list {
padding: 15px;
}
.toolbar {
flex-direction: column;
align-items: stretch;
}
.filters,
.actions {
justify-content: center;
}
}
</style>

21
frontend/src/main.js Normal file
View File

@ -0,0 +1,21 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'
const app = createApp(App)
// 注册 Element Plus 图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(createPinia())
app.use(router)
app.use(ElementPlus)
app.mount('#app')

View File

@ -0,0 +1,17 @@
import { createRouter, createWebHistory } from 'vue-router'
import Dashboard from '@/views/Dashboard.vue'
const routes = [
{
path: '/',
name: 'Dashboard',
component: Dashboard
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
export default router

View File

@ -0,0 +1,67 @@
/**
* 应用全局状态管理
*/
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useAppStore = defineStore('app', () => {
// 状态
const theme = ref('light')
const sidebarCollapsed = ref(false)
const refreshInterval = ref(5000) // 5秒刷新间隔
const autoRefresh = ref(true)
// 操作方法
const toggleTheme = () => {
theme.value = theme.value === 'light' ? 'dark' : 'light'
// 保存到本地存储
localStorage.setItem('theme', theme.value)
}
const toggleSidebar = () => {
sidebarCollapsed.value = !sidebarCollapsed.value
}
const setRefreshInterval = (interval) => {
refreshInterval.value = interval
localStorage.setItem('refreshInterval', interval.toString())
}
const toggleAutoRefresh = () => {
autoRefresh.value = !autoRefresh.value
localStorage.setItem('autoRefresh', autoRefresh.value.toString())
}
const initializeSettings = () => {
// 从本地存储恢复设置
const savedTheme = localStorage.getItem('theme')
if (savedTheme) {
theme.value = savedTheme
}
const savedInterval = localStorage.getItem('refreshInterval')
if (savedInterval) {
refreshInterval.value = parseInt(savedInterval)
}
const savedAutoRefresh = localStorage.getItem('autoRefresh')
if (savedAutoRefresh) {
autoRefresh.value = savedAutoRefresh === 'true'
}
}
return {
// 状态
theme,
sidebarCollapsed,
refreshInterval,
autoRefresh,
// 方法
toggleTheme,
toggleSidebar,
setRefreshInterval,
toggleAutoRefresh,
initializeSettings
}
})

View File

@ -0,0 +1,128 @@
/**
* 客户端状态管理
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { clientsApi } from '@/api/clients'
import { ElMessage } from 'element-plus'
export const useClientsStore = defineStore('clients', () => {
// 状态
const clients = ref([])
const loading = ref(false)
// 计算属性
const enabledClients = computed(() =>
clients.value.filter(client => client.enabled)
)
const connectedClients = computed(() =>
clients.value.filter(client => client.connected)
)
const clientsCount = computed(() => clients.value.length)
// 操作方法
const fetchClients = async () => {
loading.value = true
try {
const response = await clientsApi.getClients()
if (response.success) {
clients.value = response.data
}
} catch (error) {
ElMessage.error(`获取客户端列表失败: ${error.message}`)
} finally {
loading.value = false
}
}
const addClient = async (clientData) => {
try {
const response = await clientsApi.addClient(clientData)
if (response.success) {
clients.value.push(response.data)
ElMessage.success('客户端添加成功')
return response.data
}
} catch (error) {
ElMessage.error(`添加客户端失败: ${error.message}`)
throw error
}
}
const updateClient = async (clientId, clientData) => {
try {
const response = await clientsApi.updateClient(clientId, clientData)
if (response.success) {
const index = clients.value.findIndex(c => c.id === clientId)
if (index !== -1) {
clients.value[index] = response.data
}
ElMessage.success('客户端更新成功')
return response.data
}
} catch (error) {
ElMessage.error(`更新客户端失败: ${error.message}`)
throw error
}
}
const deleteClient = async (clientId) => {
try {
const response = await clientsApi.deleteClient(clientId)
if (response.success) {
clients.value = clients.value.filter(c => c.id !== clientId)
ElMessage.success('客户端删除成功')
}
} catch (error) {
ElMessage.error(`删除客户端失败: ${error.message}`)
throw error
}
}
const testConnection = async (clientId) => {
try {
const response = await clientsApi.testConnection(clientId)
if (response.success) {
ElMessage.success('连接测试成功')
// 更新客户端连接状态
const client = clients.value.find(c => c.id === clientId)
if (client) {
client.connected = true
client.version = response.version
client.web_api_version = response.web_api_version
}
} else {
ElMessage.error(`连接测试失败: ${response.error}`)
}
return response
} catch (error) {
ElMessage.error(`连接测试失败: ${error.message}`)
throw error
}
}
const getClientById = (clientId) => {
return clients.value.find(c => c.id === clientId)
}
return {
// 状态
clients,
loading,
// 计算属性
enabledClients,
connectedClients,
clientsCount,
// 方法
fetchClients,
addClient,
updateClient,
deleteClient,
testConnection,
getClientById
}
})

View File

@ -0,0 +1,227 @@
/**
* 种子状态管理
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { torrentsApi } from '@/api/torrents'
import { ElMessage } from 'element-plus'
export const useTorrentsStore = defineStore('torrents', () => {
// 状态
const torrents = ref([])
const globalStats = ref({
download_speed: 0,
upload_speed: 0,
total_torrents: 0,
active_torrents: 0,
downloading: 0,
seeding: 0,
paused: 0
})
const clientsStatus = ref([])
const loading = ref(false)
const refreshing = ref(false) // 区分初始加载和刷新
const operationLoading = ref(false) // 操作加载状态
const selectedTorrents = ref([])
const filterOptions = ref({
status: 'all',
clientIds: []
})
const lastUpdateTime = ref(null)
// 计算属性
const filteredTorrents = computed(() => {
let filtered = torrents.value
// 按状态过滤
if (filterOptions.value.status !== 'all') {
filtered = filtered.filter(torrent => {
switch (filterOptions.value.status) {
case 'downloading':
return ['downloading', 'stalledDL', 'metaDL'].includes(torrent.state)
case 'seeding':
return ['uploading', 'stalledUP'].includes(torrent.state)
case 'paused':
return torrent.state.toLowerCase().includes('paused')
case 'completed':
return torrent.progress === 1
default:
return true
}
})
}
// 按客户端过滤
if (filterOptions.value.clientIds.length > 0) {
filtered = filtered.filter(torrent =>
filterOptions.value.clientIds.includes(torrent.client_id)
)
}
return filtered
})
const downloadSpeed = computed(() => globalStats.value.download_speed)
const uploadSpeed = computed(() => globalStats.value.upload_speed)
const totalTorrents = computed(() => globalStats.value.total_torrents)
// 操作方法
const fetchTorrents = async (clientIds = [], isRefresh = false) => {
const loadingRef = isRefresh ? refreshing : loading
loadingRef.value = true
try {
const response = await torrentsApi.getMainData(clientIds)
if (response.success) {
torrents.value = response.data.torrents
globalStats.value = response.data.global_stats
clientsStatus.value = response.data.clients_status
lastUpdateTime.value = new Date()
}
} catch (error) {
ElMessage.error(`获取种子列表失败: ${error.message}`)
} finally {
loadingRef.value = false
}
}
// 静默刷新数据(不显示加载状态)
const silentRefresh = async (clientIds = []) => {
try {
const response = await torrentsApi.getMainData(clientIds)
if (response.success) {
torrents.value = response.data.torrents
globalStats.value = response.data.global_stats
clientsStatus.value = response.data.clients_status
lastUpdateTime.value = new Date()
}
} catch (error) {
// 静默失败,不显示错误消息
console.error('Silent refresh failed:', error)
}
}
const pauseTorrents = async (hashes, clientId = null) => {
operationLoading.value = true
try {
const response = await torrentsApi.pauseTorrents(hashes, clientId)
if (response.success) {
ElMessage.success('种子暂停成功')
// 静默刷新数据,不显示加载状态
await silentRefresh(filterOptions.value.clientIds)
}
} catch (error) {
ElMessage.error(`暂停种子失败: ${error.message}`)
throw error
} finally {
operationLoading.value = false
}
}
const resumeTorrents = async (hashes, clientId = null) => {
operationLoading.value = true
try {
const response = await torrentsApi.resumeTorrents(hashes, clientId)
if (response.success) {
ElMessage.success('种子恢复成功')
// 静默刷新数据,不显示加载状态
await silentRefresh(filterOptions.value.clientIds)
}
} catch (error) {
ElMessage.error(`恢复种子失败: ${error.message}`)
throw error
} finally {
operationLoading.value = false
}
}
const deleteTorrents = async (hashes, deleteFiles = false, clientId = null) => {
operationLoading.value = true
try {
const response = await torrentsApi.deleteTorrents(hashes, deleteFiles, clientId)
if (response.success) {
ElMessage.success('种子删除成功')
// 静默刷新数据,不显示加载状态
await silentRefresh(filterOptions.value.clientIds)
}
} catch (error) {
ElMessage.error(`删除种子失败: ${error.message}`)
throw error
} finally {
operationLoading.value = false
}
}
const getTorrentDetails = async (torrentHash, clientId) => {
try {
const response = await torrentsApi.getTorrentDetails(torrentHash, clientId)
if (response.success) {
return response.data
}
} catch (error) {
ElMessage.error(`获取种子详情失败: ${error.message}`)
throw error
}
}
const setSelectedTorrents = (torrents) => {
selectedTorrents.value = torrents
}
const setFilter = (filter) => {
filterOptions.value = { ...filterOptions.value, ...filter }
}
const formatSpeed = (speed) => {
if (speed === 0) return '0 B/s'
const units = ['B/s', 'KB/s', 'MB/s', 'GB/s']
let unitIndex = 0
while (speed >= 1024 && unitIndex < units.length - 1) {
speed /= 1024
unitIndex++
}
return `${speed.toFixed(1)} ${units[unitIndex]}`
}
const formatSize = (size) => {
if (size === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let unitIndex = 0
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024
unitIndex++
}
return `${size.toFixed(1)} ${units[unitIndex]}`
}
return {
// 状态
torrents,
globalStats,
clientsStatus,
loading,
refreshing,
operationLoading,
selectedTorrents,
filterOptions,
lastUpdateTime,
// 计算属性
filteredTorrents,
downloadSpeed,
uploadSpeed,
totalTorrents,
// 方法
fetchTorrents,
silentRefresh,
pauseTorrents,
resumeTorrents,
deleteTorrents,
getTorrentDetails,
setSelectedTorrents,
setFilter,
formatSpeed,
formatSize
}
})

View File

@ -0,0 +1,160 @@
<template>
<div class="dashboard">
<div class="header">
<h1>qBittorrent 管理平台</h1>
<div class="header-actions">
<el-button @click="toggleAutoRefresh">
<el-icon><Timer /></el-icon>
{{ autoRefresh ? '停止自动刷新' : '开启自动刷新' }}
</el-button>
<el-button @click="showClientManager = !showClientManager">
<el-icon><Setting /></el-icon>
{{ showClientManager ? '隐藏客户端管理' : '显示客户端管理' }}
</el-button>
</div>
</div>
<!-- 全局统计 -->
<GlobalStats />
<!-- 客户端管理 -->
<div v-if="showClientManager" class="section">
<ClientManager />
</div>
<!-- 种子列表 -->
<div class="section">
<TorrentList />
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { useAppStore } from '@/stores/app'
import { useTorrentsStore } from '@/stores/torrents'
import { useClientsStore } from '@/stores/clients'
import GlobalStats from '@/components/GlobalStats.vue'
import ClientManager from '@/components/ClientManager.vue'
import TorrentList from '@/components/TorrentList.vue'
import { Timer, Setting } from '@element-plus/icons-vue'
const appStore = useAppStore()
const torrentsStore = useTorrentsStore()
const clientsStore = useClientsStore()
// 响应式数据
const showClientManager = ref(false)
let refreshTimer = null
// 计算属性
const autoRefresh = computed(() => appStore.autoRefresh)
const refreshInterval = computed(() => appStore.refreshInterval)
// 方法
const toggleAutoRefresh = () => {
appStore.toggleAutoRefresh()
if (appStore.autoRefresh) {
startAutoRefresh()
} else {
stopAutoRefresh()
}
}
const startAutoRefresh = () => {
if (refreshTimer) {
clearInterval(refreshTimer)
}
refreshTimer = setInterval(() => {
// 使用静默刷新,不显示加载状态
torrentsStore.silentRefresh()
}, refreshInterval.value)
}
const stopAutoRefresh = () => {
if (refreshTimer) {
clearInterval(refreshTimer)
refreshTimer = null
}
}
const initializeData = async () => {
// 初始化应用设置
appStore.initializeSettings()
// 获取客户端列表
await clientsStore.fetchClients()
// 获取种子数据
await torrentsStore.fetchTorrents()
// 开启自动刷新
if (appStore.autoRefresh) {
startAutoRefresh()
}
}
// 生命周期
onMounted(() => {
initializeData()
})
onUnmounted(() => {
stopAutoRefresh()
})
</script>
<style scoped>
.dashboard {
padding: 20px;
max-width: 1400px;
margin: 0 auto;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
flex-wrap: wrap;
gap: 20px;
}
.header h1 {
margin: 0;
color: #303133;
font-size: 28px;
font-weight: 600;
}
.header-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.section {
margin-bottom: 30px;
}
@media (max-width: 768px) {
.dashboard {
padding: 15px;
}
.header {
flex-direction: column;
align-items: stretch;
text-align: center;
}
.header h1 {
font-size: 24px;
}
.header-actions {
justify-content: center;
}
}
</style>

21
frontend/vite.config.js Normal file
View File

@ -0,0 +1,21 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8888',
changeOrigin: true
}
}
}
})

146
开发指南.md Normal file
View File

@ -0,0 +1,146 @@
### **全方位开发指南:构建多客户端 qBittorrent 管理平台**
#### **项目愿景**
创建一个现代化、集中式、高响应性的 Web 应用,允许用户在一个统一的界面中无缝管理和监控分布在不同设备上的多个 qBittorrent 客户端。目标是提供超越原生 WebUI 的用户体验,并完美适配桌面和移动设备。
---
### **阶段一:规划与设计 (Planning & Design)**
这是项目成功的基石。在此阶段,我们明确“做什么”和“怎么做”。
1. **需求分析与功能定义**
* **核心功能 (MVP - 最小可行产品):**
* 客户端管理:能够安全地添加、编辑、删除和测试 qBittorrent 客户端的连接信息(地址、端口、凭据)。
* 种子列表:能够聚合展示来自所有或选定客户端的种子,并显示关键信息(名称、进度、状态、速度)。
* 基本操作:能够对单个或多个种子执行基本操作(暂停、恢复、删除)。
* 全局概览:显示所有客户端的总上传/下载速度。
* **进阶功能:**
* 详细信息查看种子的文件列表、Tracker 状态、Peers 信息。
* 高级操作:设置种子的分类、标签;调整优先级;重新校验。
* 过滤与排序:按状态、客户端、分类、标签等对种子列表进行强大的筛选和排序。
* 添加种子:支持通过磁力链接或种子文件添加新任务。
* 系统设置:应用本身的主题切换(明亮/暗黑模式)、轮询间隔设置等。
2. **技术选型确认**
* **后端:** Python + Flask。轻量、灵活生态系统成熟`qbittorrent-api` 库可直接使用。
* **前端:** Vue.js + Element Plus。现代化的前端框架组件库美观且功能强大非常适合构建数据驱动的管理后台。
* **部署:** Docker + Docker Compose。实现环境标准化、一键部署和轻松运维。
3. **UI/UX 设计**
* **线框图与原型:** 在编写任何代码之前应该事先构思好完整且全面的UI布局及界面交互逻辑。 这包括但不限于:
* 主仪表盘Dashboard布局。
* 种子列表的表格/卡片设计。
* 添加/编辑客户端的表单模态框。
* 移动设备上的导航(如侧滑菜单)和布局。
* 种子操作按钮的布局和交互效果。
* 二级菜单的呼出方式和交互效果。
* 详细信息页面的设计包括文件列表、Tracker 状态、Peers 信息等。
* 高级操作的确认提示和结果反馈。
* 过滤与排序功能的交互设计。
* 添加种子的流程,包括磁力链接输入和文件上传。
* 系统设置的选项卡和布局。
* **用户流程:** 规划用户完成关键任务的路径。例如,“用户如何添加一个新的 qBittorrent 客户端并看到它的种子?”、“用户在手机上如何快速暂停一个正在下载的任务?”。
4. **数据结构设计**
* **客户端配置:** 设计存储客户端信息的结构。例如,一个 JSON 文件,其中包含一个对象数组,每个对象代表一个客户端,拥有 `id`, `name`, `host`, `port`, `username`, `password` 等字段。
* **API 数据契约:** 初步定义前后端交互的 JSON 数据格式。例如,一个种子的数据对象应该包含哪些字段,全局状态的数据对象又该如何组织。
---
### **阶段二:后端开发 (Backend Development)**
后端是应用的“大脑”,负责所有逻辑处理和与 qBittorrent 的通信。
1. **环境与项目结构**
* 建立 Python 虚拟环境并使用uv管理环境依赖使用 python=3.10。
* 采用蓝图Blueprints来组织 Flask 项目,按功能(如 `clients_api`, `torrents_api`)划分模块,保持代码清晰。
2. **配置管理**
* 将敏感信息(如默认密码、密钥)与代码分离。使用环境变量或外部配置文件 (`config.py``.env``config.yml`) 进行管理,这对于 Docker 部署尤其重要。
* 设计一个安全的机制来存储用户添加的 qBittorrent 客户端凭据,初期可以是文件,但需注意权限控制。
3. **API 设计与实现**
* 遵循 RESTful 设计原则,设计无状态、资源导向的 API 端点。
* 实现阶段一中定义的所有 API 接口。
* 重点实现一个聚合数据的核心接口(如 `/api/maindata`),该接口一次性返回前端所需的主要数据(全局速度、种子列表、分类列表等),以减少 HTTP 请求次数,优化前端加载性能。
4. **核心服务逻辑**
* 封装与 `qbittorrent-api` 的交互逻辑,创建一个服务层。
* 实现并发请求逻辑,当需要从多个客户端获取数据时,使用并发工具(如 `concurrent.futures`)同时发起请求,而不是串行等待,这能极大提升响应速度。
* 建立完善的错误处理机制。能优雅地处理网络超时、认证失败、客户端离线等各种异常,并向前端返回标准化的错误信息。
5. **安全性**
* **CORS (跨源资源共享):** 配置 Flask 以允许来自前端开发服务器的跨域请求。
* **(可选) 应用级认证:** 如果需要多人使用或公网访问,应为应用本身增加一层认证,例如使用 JWT (JSON Web Tokens) 来保护 API。
---
### **阶段三:前端开发 (Frontend Development)**
前端是应用的“脸面”,直接决定了用户体验。
1. **环境与项目结构**
* 使用 Vite 初始化 Vue 项目,获得极速的开发体验。
* 建立清晰的目录结构:`views` 存放页面级组件,`components` 存放可复用的小组件,`api` 存放所有与后端通信的函数,`store` 存放状态管理逻辑,`router` 存放路由配置。
2. **状态管理 (State Management)**
* 使用 Pinia 作为全局状态管理器。
* 创建不同的 Store 来管理不同的数据域,例如 `clientStore` (管理客户端列表)、`torrentStore` (管理种子数据、筛选条件)、`appStore` (管理全局加载状态、主题、通知)。
3. **组件化构建**
* 将 UI 拆分为可复用的组件。例如,一个 `TorrentListItem` 组件、一个 `GlobalStats` 组件、一个 `ClientSelector` 组件。
* 页面由这些基础组件组合而成,遵循“自下而上”的构建思路。
4. **响应式布局策略**
* 采用“移动端优先”的设计理念。先设计好在小屏幕上的布局和交互,再通过媒体查询和 Element Plus 的响应式栅格系统逐步增强大屏幕的体验。
* 灵活运用 `ElDrawer`(抽屉)、`ElDialog`(对话框)等容器组件,在不同尺寸的设备上提供最佳的交互模式。
5. **用户体验增强**
* **实时数据更新:** 实现一个定时轮询机制,定期从后端获取最新数据并更新界面,给用户“实时”的感觉。
* **加载状态:** 在任何数据请求期间,都应有明确的加载指示(如骨架屏 `ElSkeleton` 或加载覆盖 `v-loading`),避免用户面对空白或无响应的界面。
* **平滑过渡与动画:** 利用 Vue 的 `<Transition>` 和 CSS `transition` 为元素的出现、消失和状态变化添加自然的动画效果,提升应用的流畅度和质感。
* **用户反馈:** 对用户的每一个操作(成功、失败、警告)都给予即时反馈,使用 Element Plus 的 `ElNotification``ElMessage` 组件。
* **用户登录:** 支持自定义用户名密码进行登录。
---
### **阶段四:集成与测试 (Integration & Testing)**
确保前后端能正确协同工作,并保证软件质量。
1. **联调**
* 在本地同时运行前端开发服务器和后端 Flask 服务器。
* 配置前端的开发代理,将所有 `/api` 请求转发到本地的后端端口,解决开发环境下的跨域问题。
2. **测试策略**
* **后端测试:** 使用 `pytest` 等框架,对核心服务逻辑编写单元测试,对 API 端点编写集成测试。
* **前端测试:** 使用 `Vitest``Jest` 对关键组件和 Pinia Store 编写单元测试。
* **端到端 (E2E) 测试:** (可选,适用于复杂项目) 使用 Cypress 或 Playwright 模拟真实用户操作,测试关键用户流程是否通畅。
* **手动测试:** 根据阶段一设计的用户流程,创建一份测试用例清单,手动验证所有功能在不同设备(或浏览器开发者工具的移动视图)上是否正常工作。
---
### **阶段五:部署 (Deployment)**
将开发好的应用发布上线。
1. **容器化**
* 为后端编写 `Dockerfile`,将其打包成一个包含 Gunicorn 的 Python 应用镜像。
* 为前端编写多阶段 `Dockerfile`,先用 Node.js 环境构建出静态文件,再将这些文件放入一个轻量的 Nginx 镜像中。
2. **编排**
* 编写 `docker-compose.yml` 文件。
* 定义 `backend``frontend` 两个服务。
* 配置 Nginx 作为反向代理,将 Web 流量导向静态文件,将 API 请求 (`/api/...`) 转发到 `backend` 服务。
* 使用 `volumes` 为后端配置数据持久化,确保应用数据(如客户端配置)在容器重启后不丢失。
3. **上线流程**
* 在服务器上安装 Docker 和 Docker Compose。
* 将整个项目代码(包含所有 Dockerfile 和 docker-compose.yml上传到服务器。
* 运行 `docker-compose up --build -d` 命令一键构建并启动整个应用。
* 通过 `http://<服务器IP>:<指定端口>` 访问您的应用。