Initial commit - 玩玩云文件管理系统 v1.0.0
- 完整的前后端代码 - 支持本地存储和SFTP存储 - 文件分享功能 - 上传工具源代码 - 完整的部署文档 - Nginx配置模板 技术栈: - 后端: Node.js + Express + SQLite - 前端: Vue.js 3 + Axios - 存储: 本地存储 / SFTP远程存储
This commit is contained in:
67
.gitignore
vendored
Normal file
67
.gitignore
vendored
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# 依赖
|
||||||
|
node_modules/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
|
||||||
|
# 数据库
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# 临时文件
|
||||||
|
backend/uploads/
|
||||||
|
storage/ # 本地存储数据
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# 环境配置
|
||||||
|
.env
|
||||||
|
!backend/.env.example
|
||||||
|
|
||||||
|
# SSL证书
|
||||||
|
certbot/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# 上传工具构建产物
|
||||||
|
upload-tool/dist/
|
||||||
|
upload-tool/build/
|
||||||
|
upload-tool/__pycache__/
|
||||||
|
upload-tool/config.json
|
||||||
|
upload-tool/*.spec
|
||||||
|
|
||||||
|
# 备份文件
|
||||||
|
*.bak
|
||||||
|
*.backup
|
||||||
|
*.old
|
||||||
|
|
||||||
|
# 操作系统
|
||||||
|
.DS_Store
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
Desktop.ini
|
||||||
|
|
||||||
|
# 压缩文件
|
||||||
|
*.zip
|
||||||
|
*.tar
|
||||||
|
*.gz
|
||||||
|
*.rar
|
||||||
|
*.7z
|
||||||
|
|
||||||
|
# npm/yarn
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
package-lock.json.bak
|
||||||
|
|
||||||
|
# Claude配置
|
||||||
|
.claude/
|
||||||
183
DEPLOY.md
Normal file
183
DEPLOY.md
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
# 玩玩云 - 部署指南
|
||||||
|
|
||||||
|
## 🚀 快速部署(3分钟完成)
|
||||||
|
|
||||||
|
### 第一步:上传项目到服务器
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 方法1: 使用scp上传
|
||||||
|
scp -r ftp-web-manager root@服务器IP:/var/www/
|
||||||
|
|
||||||
|
# 方法2: 使用FTP工具上传到 /var/www/ 目录
|
||||||
|
```
|
||||||
|
|
||||||
|
### 第二步:SSH登录服务器
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh root@服务器IP
|
||||||
|
```
|
||||||
|
|
||||||
|
### 第三步:一键部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/ftp-web-manager
|
||||||
|
bash deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
部署脚本会自动:
|
||||||
|
- ✅ 检查Docker环境
|
||||||
|
- ✅ 创建必要目录
|
||||||
|
- ✅ 构建Docker镜像
|
||||||
|
- ✅ 启动所有服务
|
||||||
|
- ✅ 显示访问信息
|
||||||
|
|
||||||
|
### 第四步:访问系统
|
||||||
|
|
||||||
|
打开浏览器访问:
|
||||||
|
```
|
||||||
|
http://服务器IP:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
使用默认账号登录:
|
||||||
|
```
|
||||||
|
用户名: admin
|
||||||
|
密码: admin123
|
||||||
|
```
|
||||||
|
|
||||||
|
**⚠️ 重要:首次登录后立即修改密码!**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 环境要求
|
||||||
|
|
||||||
|
- Docker 20.10.0+
|
||||||
|
- Docker Compose 2.0.0+
|
||||||
|
- 最低 1GB 内存(推荐 2GB+)
|
||||||
|
- Linux 系统(Ubuntu/Debian/CentOS)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 手动部署(如果自动脚本失败)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 进入项目目录
|
||||||
|
cd /var/www/ftp-web-manager
|
||||||
|
|
||||||
|
# 2. 创建必要目录
|
||||||
|
mkdir -p certbot/conf certbot/www backend/uploads
|
||||||
|
|
||||||
|
# 3. 构建并启动
|
||||||
|
docker-compose up --build -d
|
||||||
|
|
||||||
|
# 4. 查看日志
|
||||||
|
docker-compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 部署验证
|
||||||
|
|
||||||
|
检查容器状态:
|
||||||
|
```bash
|
||||||
|
docker-compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
应该看到3个容器都是 "Up" 状态:
|
||||||
|
- wanwanyun-backend
|
||||||
|
- wanwanyun-frontend
|
||||||
|
- wanwanyun-certbot
|
||||||
|
|
||||||
|
查看后端日志:
|
||||||
|
```bash
|
||||||
|
docker-compose logs backend
|
||||||
|
```
|
||||||
|
|
||||||
|
应该看到:
|
||||||
|
```
|
||||||
|
数据库初始化完成
|
||||||
|
默认管理员账号已创建
|
||||||
|
玩玩云已启动
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛑 停止服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/ftp-web-manager
|
||||||
|
docker-compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 重启服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/ftp-web-manager
|
||||||
|
docker-compose restart
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 更新代码
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/ftp-web-manager
|
||||||
|
git pull # 或重新上传文件
|
||||||
|
docker-compose up --build -d
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ❓ 常见问题
|
||||||
|
|
||||||
|
### Q: 端口8080被占用怎么办?
|
||||||
|
|
||||||
|
修改 docker-compose.yml 中的端口映射:
|
||||||
|
```yaml
|
||||||
|
ports:
|
||||||
|
- "8081:80" # 改为8081或其他端口
|
||||||
|
```
|
||||||
|
|
||||||
|
### Q: Docker容器启动失败?
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看详细日志
|
||||||
|
docker-compose logs backend
|
||||||
|
|
||||||
|
# 重新构建
|
||||||
|
docker-compose down
|
||||||
|
docker-compose up --build -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Q: 忘记管理员密码怎么办?
|
||||||
|
|
||||||
|
删除数据库文件重新初始化:
|
||||||
|
```bash
|
||||||
|
docker-compose down
|
||||||
|
rm backend/ftp-manager.db
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Q: 如何配置HTTPS?
|
||||||
|
|
||||||
|
参考主README.md中的SSL配置章节。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 获取帮助
|
||||||
|
|
||||||
|
- 查看详细文档: README.md
|
||||||
|
- 查看部署检查报告: 桌面上的检查报告文件
|
||||||
|
- 查看对话历史: 桌面上的对话总结文件
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**部署成功后,记得:**
|
||||||
|
1. ✅ 修改admin密码
|
||||||
|
2. ✅ 配置SFTP连接
|
||||||
|
3. ✅ 设置JWT密钥(backend/.env)
|
||||||
|
4. ✅ 配置HTTPS(生产环境)
|
||||||
|
5. ✅ 定期备份数据库
|
||||||
|
|
||||||
|
祝您使用愉快!☁️
|
||||||
189
DEPLOYMENT.md
Normal file
189
DEPLOYMENT.md
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
# 玩玩云部署指南
|
||||||
|
|
||||||
|
## 快速部署
|
||||||
|
|
||||||
|
### 1. 基础部署(Docker Compose)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 克隆项目
|
||||||
|
git clone <repository-url>
|
||||||
|
cd ftp-web-manager
|
||||||
|
|
||||||
|
# 启动服务
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
服务将在以下端口运行:
|
||||||
|
- Frontend (Nginx): 8080 (HTTP), 8443 (HTTPS)
|
||||||
|
- Backend (Node.js): 40001
|
||||||
|
|
||||||
|
### 2. 如果使用宿主机Nginx作为反向代理
|
||||||
|
|
||||||
|
如果你在宿主机上使用Nginx作为SSL终止/反向代理(推荐用于生产环境),需要在Nginx配置中添加大文件上传支持。
|
||||||
|
|
||||||
|
#### 2.1 创建Nginx配置文件
|
||||||
|
|
||||||
|
创建 `/etc/nginx/sites-available/wanwanyun.conf`(或对应的配置目录):
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name your-domain.com;
|
||||||
|
|
||||||
|
# HTTP重定向到HTTPS
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name your-domain.com;
|
||||||
|
|
||||||
|
# SSL证书配置(使用Let's Encrypt或其他证书)
|
||||||
|
ssl_certificate /path/to/fullchain.pem;
|
||||||
|
ssl_certificate_key /path/to/privkey.pem;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
ssl_prefer_server_ciphers on;
|
||||||
|
|
||||||
|
# 反向代理到Docker容器
|
||||||
|
location / {
|
||||||
|
# ⚠️ 重要:设置最大上传文件大小为5GB
|
||||||
|
client_max_body_size 5G;
|
||||||
|
|
||||||
|
# ⚠️ 重要:大文件上传超时设置(1小时)
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
proxy_connect_timeout 3600s;
|
||||||
|
|
||||||
|
# 代理到Docker容器的8080端口
|
||||||
|
proxy_pass http://127.0.0.1:8080;
|
||||||
|
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_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.2 应用配置
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 启用站点配置
|
||||||
|
ln -s /etc/nginx/sites-available/wanwanyun.conf /etc/nginx/sites-enabled/
|
||||||
|
|
||||||
|
# 测试Nginx配置
|
||||||
|
nginx -t
|
||||||
|
|
||||||
|
# 重新加载Nginx
|
||||||
|
nginx -s reload
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 宝塔面板用户
|
||||||
|
|
||||||
|
如果使用宝塔面板,配置文件通常在:
|
||||||
|
- `/www/server/panel/vhost/nginx/your-domain.conf`
|
||||||
|
|
||||||
|
在站点的 `location /` 块中添加:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location / {
|
||||||
|
# 设置最大上传文件大小为5GB
|
||||||
|
client_max_body_size 5G;
|
||||||
|
|
||||||
|
# 大文件上传超时设置(1小时)
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
proxy_connect_timeout 3600s;
|
||||||
|
|
||||||
|
# ... 其他代理配置
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
然后重载Nginx:
|
||||||
|
```bash
|
||||||
|
nginx -s reload
|
||||||
|
```
|
||||||
|
|
||||||
|
## 上传限制说明
|
||||||
|
|
||||||
|
系统支持的最大上传文件大小为 **5GB**,需要在以下三个层级进行配置:
|
||||||
|
|
||||||
|
### 1. ✅ 容器内Nginx(已配置)
|
||||||
|
- 文件:`nginx/nginx.conf`
|
||||||
|
- 配置:`client_max_body_size 5G;`
|
||||||
|
|
||||||
|
### 2. ✅ 后端Multer(已配置)
|
||||||
|
- 文件:`backend/server.js`
|
||||||
|
- 配置:`limits: { fileSize: 5 * 1024 * 1024 * 1024 }`
|
||||||
|
|
||||||
|
### 3. ⚠️ 宿主机Nginx(需要手动配置)
|
||||||
|
- 如果使用宿主机Nginx作为反向代理
|
||||||
|
- 必须在 `location /` 块中添加 `client_max_body_size 5G;`
|
||||||
|
- 否则上传会在64MB时失败(Nginx默认限制)
|
||||||
|
|
||||||
|
## 故障排查
|
||||||
|
|
||||||
|
### 上传文件提示413错误
|
||||||
|
|
||||||
|
**问题**:上传大于64MB的文件时失败,浏览器控制台显示 `413 Payload Too Large`
|
||||||
|
|
||||||
|
**原因**:宿主机Nginx的 `client_max_body_size` 限制(默认1MB或64MB)
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
1. 找到宿主机Nginx配置文件(通常是 `/etc/nginx/sites-available/` 或 `/www/server/panel/vhost/nginx/`)
|
||||||
|
2. 在 `location /` 块中添加:
|
||||||
|
```nginx
|
||||||
|
client_max_body_size 5G;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
proxy_connect_timeout 3600s;
|
||||||
|
```
|
||||||
|
3. 测试并重载Nginx:
|
||||||
|
```bash
|
||||||
|
nginx -t
|
||||||
|
nginx -s reload
|
||||||
|
```
|
||||||
|
|
||||||
|
### 上传进度
|
||||||
|
|
||||||
|
前端已实现实时上传进度显示(使用axios的 `onUploadProgress`),无需额外配置。
|
||||||
|
|
||||||
|
## 存储配置
|
||||||
|
|
||||||
|
系统支持两种存储方式:
|
||||||
|
|
||||||
|
### 本地存储
|
||||||
|
- 文件存储在:`backend/local-storage/`
|
||||||
|
- 可设置用户配额限制
|
||||||
|
- 适合中小型部署
|
||||||
|
|
||||||
|
### SFTP存储
|
||||||
|
- 用户可配置自己的SFTP服务器
|
||||||
|
- 支持HTTP直接下载(配置 `http_download_base_url`)
|
||||||
|
- 适合大规模部署
|
||||||
|
|
||||||
|
## 安全建议
|
||||||
|
|
||||||
|
1. **使用HTTPS**:生产环境务必配置SSL证书
|
||||||
|
2. **定期备份数据库**:`backend/data.db` 包含所有用户数据
|
||||||
|
3. **限制管理员账号**:定期审查用户权限
|
||||||
|
4. **配置防火墙**:只开放必要的端口(80, 443)
|
||||||
|
|
||||||
|
## 技术支持
|
||||||
|
|
||||||
|
如有问题,请查看日志:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 后端日志
|
||||||
|
docker logs wanwanyun-backend
|
||||||
|
|
||||||
|
# 前端日志
|
||||||
|
docker logs wanwanyun-frontend
|
||||||
|
|
||||||
|
# Nginx日志
|
||||||
|
tail -f /www/wwwlogs/your-domain.log
|
||||||
|
tail -f /www/wwwlogs/your-domain.error.log
|
||||||
|
```
|
||||||
1211
DOCKER部署指南.md
Normal file
1211
DOCKER部署指南.md
Normal file
File diff suppressed because it is too large
Load Diff
49
QUICK_START.txt
Normal file
49
QUICK_START.txt
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
╔══════════════════════════════════════════════════════════════╗
|
||||||
|
║ 玩玩云 - 快速开始指南 ║
|
||||||
|
╚══════════════════════════════════════════════════════════════╝
|
||||||
|
|
||||||
|
【最简单的部署方法 - 3步完成】
|
||||||
|
|
||||||
|
1️⃣ 上传项目到服务器的 /var/www/ 目录
|
||||||
|
|
||||||
|
2️⃣ SSH登录服务器,执行:
|
||||||
|
cd /var/www/ftp-web-manager
|
||||||
|
bash deploy.sh
|
||||||
|
|
||||||
|
3️⃣ 打开浏览器访问:
|
||||||
|
http://服务器IP:8080
|
||||||
|
|
||||||
|
默认账号:admin
|
||||||
|
默认密码:admin123
|
||||||
|
|
||||||
|
✅ 部署完成!
|
||||||
|
|
||||||
|
───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
【环境要求】
|
||||||
|
✓ Docker 20.10+
|
||||||
|
✓ Docker Compose 2.0+
|
||||||
|
✓ Linux系统
|
||||||
|
✓ 1GB+ 内存
|
||||||
|
|
||||||
|
───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
【详细文档】
|
||||||
|
📖 完整部署指南:DEPLOY.md
|
||||||
|
📖 使用说明:README.md
|
||||||
|
📖 部署检查报告:桌面上的检查报告
|
||||||
|
|
||||||
|
───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
【重要提示】
|
||||||
|
⚠️ 首次登录后立即修改admin密码
|
||||||
|
⚠️ 生产环境请配置HTTPS
|
||||||
|
⚠️ 定期备份 backend/ftp-manager.db 数据库文件
|
||||||
|
|
||||||
|
───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
【获取帮助】
|
||||||
|
💬 查看常见问题:DEPLOY.md
|
||||||
|
💬 查看详细文档:README.md
|
||||||
|
|
||||||
|
祝您使用愉快!☁️
|
||||||
244
README.md
Normal file
244
README.md
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
# 玩玩云 - Web SFTP 文件管理系统
|
||||||
|
|
||||||
|
> 一个基于Web的SFTP文件管理系统,提供文件上传、下载、分享等功能,支持多用户管理。
|
||||||
|
|
||||||
|
## 📋 项目简介
|
||||||
|
|
||||||
|
玩玩云是一个现代化的Web文件管理系统,让您可以通过浏览器管理SFTP服务器上的文件。系统支持文件的上传、下载、重命名、删除、分享等操作,并提供桌面端上传工具,方便快速上传大文件。
|
||||||
|
|
||||||
|
### 主要特性
|
||||||
|
|
||||||
|
- ✅ **文件管理** - 浏览、上传、下载、重命名、删除文件
|
||||||
|
- ✅ **文件分享** - 生成分享链接,支持密码保护和有效期设置
|
||||||
|
- ✅ **多用户系统** - 用户注册、登录、权限管理
|
||||||
|
- ✅ **桌面上传工具** - 拖拽上传,实时显示进度
|
||||||
|
- ✅ **流式下载** - 服务器零存储,纯中转下载
|
||||||
|
- ✅ **管理员功能** - 用户管理、文件审查、系统设置
|
||||||
|
- ✅ **Docker部署** - 一键部署,易于维护
|
||||||
|
|
||||||
|
## 🛠️ 技术栈
|
||||||
|
|
||||||
|
### 前端
|
||||||
|
- **Vue.js 3** - 渐进式JavaScript框架
|
||||||
|
- **Axios** - HTTP请求库
|
||||||
|
- **Font Awesome** - 图标库
|
||||||
|
|
||||||
|
### 后端
|
||||||
|
- **Node.js 20** - JavaScript运行时
|
||||||
|
- **Express** - Web应用框架
|
||||||
|
- **better-sqlite3** - 轻量级数据库
|
||||||
|
- **ssh2-sftp-client** - SFTP客户端
|
||||||
|
- **JWT** - 用户认证
|
||||||
|
|
||||||
|
### 部署
|
||||||
|
- **Docker** - 容器化
|
||||||
|
- **Docker Compose** - 容器编排
|
||||||
|
- **Nginx** - 反向代理
|
||||||
|
|
||||||
|
## 🚀 快速开始
|
||||||
|
|
||||||
|
### 环境要求
|
||||||
|
|
||||||
|
- **Docker**: 20.10.0+
|
||||||
|
- **Docker Compose**: 2.0.0+
|
||||||
|
- **操作系统**: Linux (Ubuntu 20.04+ / Debian 10+ / CentOS 7+)
|
||||||
|
- **内存**: 最低 1GB RAM(推荐 2GB+)
|
||||||
|
|
||||||
|
### 方法1: 一键部署(推荐)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 上传或克隆项目
|
||||||
|
cd /var/www
|
||||||
|
# 将项目文件上传到此目录
|
||||||
|
|
||||||
|
# 2. 进入项目目录
|
||||||
|
cd ftp-web-manager
|
||||||
|
|
||||||
|
# 3. 一键部署
|
||||||
|
bash deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
deploy.sh脚本会自动:
|
||||||
|
- 检查Docker和Docker Compose环境
|
||||||
|
- 创建必要的目录
|
||||||
|
- 构建并启动所有服务
|
||||||
|
- 显示访问信息和默认账号
|
||||||
|
|
||||||
|
### 方法2: 手动部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 进入项目目录
|
||||||
|
cd /var/www/ftp-web-manager
|
||||||
|
|
||||||
|
# 2. 创建必要的目录
|
||||||
|
mkdir -p certbot/conf certbot/www backend/uploads
|
||||||
|
|
||||||
|
# 3. 构建并启动服务
|
||||||
|
docker-compose up --build -d
|
||||||
|
|
||||||
|
# 4. 查看日志
|
||||||
|
docker-compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
### 访问系统
|
||||||
|
|
||||||
|
部署完成后:
|
||||||
|
|
||||||
|
- **前端地址**: http://服务器IP:8080
|
||||||
|
- **后端API**: http://服务器IP:40001
|
||||||
|
- **默认管理员账号**:
|
||||||
|
- 用户名: `admin`
|
||||||
|
- 密码: `admin123`
|
||||||
|
- ⚠️ **请立即登录并修改密码!**
|
||||||
|
|
||||||
|
## 📖 使用教程
|
||||||
|
|
||||||
|
### 配置SFTP服务器
|
||||||
|
|
||||||
|
首次使用需要配置SFTP连接信息:
|
||||||
|
|
||||||
|
1. 登录后点击右上角用户菜单
|
||||||
|
2. 选择"设置"
|
||||||
|
3. 填写SFTP配置:
|
||||||
|
- **SFTP主机**: 您的SFTP服务器IP
|
||||||
|
- **SFTP端口**: 默认22
|
||||||
|
- **SFTP用户名**: SFTP账号
|
||||||
|
- **SFTP密码**: SFTP密码
|
||||||
|
- **HTTP下载基础URL**(可选): 如果有HTTP直接下载地址
|
||||||
|
|
||||||
|
4. 点击"保存配置"
|
||||||
|
|
||||||
|
### 文件管理
|
||||||
|
|
||||||
|
- **浏览文件**: 点击文件夹图标进入子目录
|
||||||
|
- **上传文件**: 点击"上传文件"按钮选择本地文件
|
||||||
|
- **下载文件**: 点击文件行的下载按钮
|
||||||
|
- **重命名**: 点击"重命名"按钮修改文件名
|
||||||
|
- **删除**: 点击"删除"按钮删除文件
|
||||||
|
|
||||||
|
### 文件分享
|
||||||
|
|
||||||
|
1. 点击文件行的"分享"按钮
|
||||||
|
2. 设置分享选项:
|
||||||
|
- **分享密码**(可选)
|
||||||
|
- **有效期**(可选)
|
||||||
|
3. 复制分享链接发送给他人
|
||||||
|
|
||||||
|
## 🔧 维护操作
|
||||||
|
|
||||||
|
### 查看日志
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看所有日志
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
# 查看后端日志
|
||||||
|
docker-compose logs -f backend
|
||||||
|
```
|
||||||
|
|
||||||
|
### 重启服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 重启所有容器
|
||||||
|
docker-compose restart
|
||||||
|
|
||||||
|
# 重启指定容器
|
||||||
|
docker-compose restart backend
|
||||||
|
```
|
||||||
|
|
||||||
|
### 停止服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
### 备份数据
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 备份数据库
|
||||||
|
cp backend/ftp-manager.db backup/ftp-manager.db.$(date +%Y%m%d)
|
||||||
|
|
||||||
|
# 备份整个项目
|
||||||
|
tar -czf backup/wanwanyun-$(date +%Y%m%d).tar.gz .
|
||||||
|
```
|
||||||
|
|
||||||
|
### 更新代码
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 拉取最新代码
|
||||||
|
git pull
|
||||||
|
|
||||||
|
# 重新构建并重启
|
||||||
|
docker-compose up --build -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔐 安全建议
|
||||||
|
|
||||||
|
1. **修改默认密码**: 首次登录后立即修改admin密码
|
||||||
|
2. **使用HTTPS**: 配置SSL证书,使用HTTPS访问
|
||||||
|
3. **修改JWT密钥**: 在backend/.env文件中设置随机的JWT_SECRET
|
||||||
|
4. **定期备份**: 定期备份数据库文件
|
||||||
|
5. **限制端口**: 不要对外暴露40001端口,只通过Nginx访问
|
||||||
|
|
||||||
|
## ❓ 常见问题
|
||||||
|
|
||||||
|
### Docker容器启动失败
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看日志
|
||||||
|
docker-compose logs backend
|
||||||
|
|
||||||
|
# 重新构建
|
||||||
|
docker-compose down
|
||||||
|
docker-compose up --build -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### 上传失败提示权限错误
|
||||||
|
|
||||||
|
检查SFTP服务器目录权限,确保上传目录有写权限。
|
||||||
|
|
||||||
|
### 分享链接无法访问
|
||||||
|
|
||||||
|
检查nginx配置和防火墙设置,确保端口8080可访问。
|
||||||
|
|
||||||
|
## 📁 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
ftp-web-manager/
|
||||||
|
├── backend/ # 后端代码
|
||||||
|
│ ├── server.js # 主服务器文件
|
||||||
|
│ ├── database.js # 数据库操作
|
||||||
|
│ ├── auth.js # 认证中间件
|
||||||
|
│ ├── Dockerfile # Docker镜像
|
||||||
|
│ └── package.json # 依赖配置
|
||||||
|
│
|
||||||
|
├── frontend/ # 前端代码
|
||||||
|
│ ├── index.html # 登录页面
|
||||||
|
│ ├── app.html # 主应用页面
|
||||||
|
│ ├── share.html # 分享页面
|
||||||
|
│ └── libs/ # 第三方库
|
||||||
|
│
|
||||||
|
├── nginx/ # Nginx配置
|
||||||
|
│ └── nginx.conf # 配置文件
|
||||||
|
│
|
||||||
|
├── upload-tool/ # 上传工具
|
||||||
|
│ ├── upload_tool.py # Python源码
|
||||||
|
│ └── build.bat # 打包脚本
|
||||||
|
│
|
||||||
|
├── docker-compose.yml # Docker编排
|
||||||
|
├── deploy.sh # 一键部署脚本
|
||||||
|
├── .gitignore # Git忽略文件
|
||||||
|
└── README.md # 本文件
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🤝 贡献指南
|
||||||
|
|
||||||
|
欢迎提交Issue和Pull Request!
|
||||||
|
|
||||||
|
## 📄 许可证
|
||||||
|
|
||||||
|
本项目仅供学习和个人使用。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**玩玩云** - 让文件管理更简单 ☁️
|
||||||
98
VERSION.txt
Normal file
98
VERSION.txt
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
玩玩云 - 版本信息
|
||||||
|
═══════════════════════════════════════
|
||||||
|
|
||||||
|
版本号: v1.0.0
|
||||||
|
发布日期: 2025-11-09
|
||||||
|
状态: 生产就绪 ✅
|
||||||
|
|
||||||
|
═══════════════════════════════════════
|
||||||
|
|
||||||
|
【本版本特性】
|
||||||
|
|
||||||
|
✅ 完整的文件管理功能
|
||||||
|
- SFTP文件浏览、上传、下载
|
||||||
|
- 文件重命名、删除
|
||||||
|
- 流式下载,支持进度显示
|
||||||
|
|
||||||
|
✅ 文件分享功能
|
||||||
|
- 生成分享链接
|
||||||
|
- 支持密码保护
|
||||||
|
- 支持有效期设置
|
||||||
|
- 双模式下载(HTTP/SFTP)
|
||||||
|
|
||||||
|
✅ 用户管理系统
|
||||||
|
- 用户注册、登录
|
||||||
|
- 密码加密存储
|
||||||
|
- JWT认证
|
||||||
|
- 管理员权限管理
|
||||||
|
|
||||||
|
✅ 桌面上传工具
|
||||||
|
- 拖拽上传
|
||||||
|
- 实时进度显示
|
||||||
|
- 自动配置
|
||||||
|
|
||||||
|
✅ Docker容器化部署
|
||||||
|
- 一键部署脚本
|
||||||
|
- 自动环境检查
|
||||||
|
- 完整的日志记录
|
||||||
|
|
||||||
|
═══════════════════════════════════════
|
||||||
|
|
||||||
|
【技术栈】
|
||||||
|
|
||||||
|
后端:
|
||||||
|
- Node.js 20
|
||||||
|
- Express 4.x
|
||||||
|
- better-sqlite3
|
||||||
|
- ssh2-sftp-client
|
||||||
|
- JWT认证
|
||||||
|
|
||||||
|
前端:
|
||||||
|
- Vue.js 3
|
||||||
|
- Axios
|
||||||
|
- Font Awesome
|
||||||
|
|
||||||
|
部署:
|
||||||
|
- Docker
|
||||||
|
- Docker Compose
|
||||||
|
- Nginx
|
||||||
|
|
||||||
|
═══════════════════════════════════════
|
||||||
|
|
||||||
|
【已修复的问题】
|
||||||
|
|
||||||
|
✅ 数据库初始化语法错误
|
||||||
|
✅ 分享链接重定向错误
|
||||||
|
✅ 分享页面下载按钮缺失
|
||||||
|
✅ 密码验证错误
|
||||||
|
✅ SFTP连接过早关闭
|
||||||
|
✅ Docker配置不完整
|
||||||
|
|
||||||
|
═══════════════════════════════════════
|
||||||
|
|
||||||
|
【部署状态】
|
||||||
|
|
||||||
|
✅ 数据库自动初始化
|
||||||
|
✅ 默认管理员自动创建
|
||||||
|
✅ 数据库迁移逻辑完整
|
||||||
|
✅ Docker镜像自动构建
|
||||||
|
✅ 所有依赖配置齐全
|
||||||
|
✅ 部署脚本完整可用
|
||||||
|
|
||||||
|
═══════════════════════════════════════
|
||||||
|
|
||||||
|
【安全特性】
|
||||||
|
|
||||||
|
✅ 密码bcrypt加密
|
||||||
|
✅ JWT令牌认证
|
||||||
|
✅ SFTP密码安全存储
|
||||||
|
✅ SQL注入防护
|
||||||
|
✅ XSS防护
|
||||||
|
✅ CORS配置
|
||||||
|
|
||||||
|
═══════════════════════════════════════
|
||||||
|
|
||||||
|
更新日志: 查看 CHANGELOG.md (如有)
|
||||||
|
许可证: 仅供学习和个人使用
|
||||||
|
|
||||||
|
═══════════════════════════════════════
|
||||||
8
backend/.env.example
Normal file
8
backend/.env.example
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# FTP服务器配置
|
||||||
|
FTP_HOST=your-ftp-host.com
|
||||||
|
FTP_PORT=21
|
||||||
|
FTP_USER=your-username
|
||||||
|
FTP_PASSWORD=your-password
|
||||||
|
|
||||||
|
# 服务器配置
|
||||||
|
PORT=3000
|
||||||
21
backend/Dockerfile
Normal file
21
backend/Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 安装编译工具
|
||||||
|
RUN apk add --no-cache python3 make g++
|
||||||
|
|
||||||
|
# 复制package文件
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
RUN npm install --production
|
||||||
|
|
||||||
|
# 复制应用代码
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# 暴露端口
|
||||||
|
EXPOSE 40001
|
||||||
|
|
||||||
|
# 启动应用
|
||||||
|
CMD ["node", "server.js"]
|
||||||
108
backend/auth.js
Normal file
108
backend/auth.js
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const { UserDB } = require('./database');
|
||||||
|
|
||||||
|
// JWT密钥(生产环境应该放在环境变量中)
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
|
||||||
|
|
||||||
|
// 生成JWT Token
|
||||||
|
function generateToken(user) {
|
||||||
|
return jwt.sign(
|
||||||
|
{
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
is_admin: user.is_admin
|
||||||
|
},
|
||||||
|
JWT_SECRET,
|
||||||
|
{ expiresIn: '7d' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证Token中间件
|
||||||
|
function authMiddleware(req, res, next) {
|
||||||
|
// 从请求头、cookie或URL参数中获取token
|
||||||
|
const token = req.headers.authorization?.replace('Bearer ', '') || req.cookies?.token || req.query?.token;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: '未提供认证令牌'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, JWT_SECRET);
|
||||||
|
const user = UserDB.findById(decoded.id);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: '用户不存在'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.is_banned) {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: '账号已被封禁'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.is_active) {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: '账号未激活'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将用户信息附加到请求对象(包含所有存储相关字段)
|
||||||
|
req.user = {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
is_admin: user.is_admin,
|
||||||
|
has_ftp_config: user.has_ftp_config,
|
||||||
|
ftp_host: user.ftp_host,
|
||||||
|
ftp_port: user.ftp_port,
|
||||||
|
ftp_user: user.ftp_user,
|
||||||
|
ftp_password: user.ftp_password,
|
||||||
|
http_download_base_url: user.http_download_base_url,
|
||||||
|
// 存储相关字段(v2.0新增)
|
||||||
|
storage_permission: user.storage_permission || 'sftp_only',
|
||||||
|
current_storage_type: user.current_storage_type || 'sftp',
|
||||||
|
local_storage_quota: user.local_storage_quota || 1073741824,
|
||||||
|
local_storage_used: user.local_storage_used || 0
|
||||||
|
};
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === 'TokenExpiredError') {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: '令牌已过期'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: '无效的令牌'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 管理员权限中间件
|
||||||
|
function adminMiddleware(req, res, next) {
|
||||||
|
if (!req.user || !req.user.is_admin) {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: '需要管理员权限'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
JWT_SECRET,
|
||||||
|
generateToken,
|
||||||
|
authMiddleware,
|
||||||
|
adminMiddleware
|
||||||
|
};
|
||||||
52
backend/backup.bat
Normal file
52
backend/backup.bat
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
@echo off
|
||||||
|
chcp 65001 >nul
|
||||||
|
echo ========================================
|
||||||
|
echo 数据库备份工具
|
||||||
|
echo ========================================
|
||||||
|
echo.
|
||||||
|
|
||||||
|
cd /d %~dp0
|
||||||
|
|
||||||
|
REM 创建备份目录
|
||||||
|
if not exist backup mkdir backup
|
||||||
|
|
||||||
|
REM 生成时间戳
|
||||||
|
set YEAR=%date:~0,4%
|
||||||
|
set MONTH=%date:~5,2%
|
||||||
|
set DAY=%date:~8,2%
|
||||||
|
set HOUR=%time:~0,2%
|
||||||
|
set MINUTE=%time:~3,2%
|
||||||
|
set SECOND=%time:~6,2%
|
||||||
|
|
||||||
|
REM 去掉小时前面的空格
|
||||||
|
if "%HOUR:~0,1%" == " " set HOUR=0%HOUR:~1,1%
|
||||||
|
|
||||||
|
set TIMESTAMP=%YEAR%%MONTH%%DAY%_%HOUR%%MINUTE%%SECOND%
|
||||||
|
|
||||||
|
REM 备份数据库
|
||||||
|
copy ftp-manager.db backup\ftp-manager-%TIMESTAMP%.db >nul
|
||||||
|
|
||||||
|
if %errorlevel% == 0 (
|
||||||
|
echo [成功] 备份完成!
|
||||||
|
echo 文件: backup\ftp-manager-%TIMESTAMP%.db
|
||||||
|
|
||||||
|
REM 获取文件大小
|
||||||
|
for %%A in (backup\ftp-manager-%TIMESTAMP%.db) do echo 大小: %%~zA 字节
|
||||||
|
) else (
|
||||||
|
echo [错误] 备份失败!
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
|
||||||
|
REM 清理30天前的备份
|
||||||
|
echo 清理30天前的旧备份...
|
||||||
|
forfiles /P backup /M ftp-manager-*.db /D -30 /C "cmd /c del @path" 2>nul
|
||||||
|
if %errorlevel% == 0 (
|
||||||
|
echo [成功] 旧备份已清理
|
||||||
|
) else (
|
||||||
|
echo [提示] 没有需要清理的旧备份
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ========================================
|
||||||
|
pause
|
||||||
554
backend/database.js
Normal file
554
backend/database.js
Normal file
@@ -0,0 +1,554 @@
|
|||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// 创建或连接数据库
|
||||||
|
const db = new Database(path.join(__dirname, 'ftp-manager.db'));
|
||||||
|
|
||||||
|
// 启用外键约束
|
||||||
|
db.pragma('foreign_keys = ON');
|
||||||
|
|
||||||
|
// 初始化数据库表
|
||||||
|
function initDatabase() {
|
||||||
|
// 用户表
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
email TEXT UNIQUE NOT NULL,
|
||||||
|
password TEXT NOT NULL,
|
||||||
|
|
||||||
|
-- FTP配置(可选)
|
||||||
|
ftp_host TEXT,
|
||||||
|
ftp_port INTEGER DEFAULT 22,
|
||||||
|
ftp_user TEXT,
|
||||||
|
ftp_password TEXT,
|
||||||
|
http_download_base_url TEXT,
|
||||||
|
|
||||||
|
-- 上传工具API密钥
|
||||||
|
upload_api_key TEXT,
|
||||||
|
|
||||||
|
-- 用户状态
|
||||||
|
is_admin INTEGER DEFAULT 0,
|
||||||
|
is_active INTEGER DEFAULT 1,
|
||||||
|
is_banned INTEGER DEFAULT 0,
|
||||||
|
has_ftp_config INTEGER DEFAULT 0,
|
||||||
|
|
||||||
|
-- 时间戳
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 分享链接表
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS shares (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
share_code TEXT UNIQUE NOT NULL,
|
||||||
|
share_path TEXT NOT NULL,
|
||||||
|
share_type TEXT DEFAULT 'file',
|
||||||
|
share_password TEXT,
|
||||||
|
|
||||||
|
-- 分享统计
|
||||||
|
view_count INTEGER DEFAULT 0,
|
||||||
|
download_count INTEGER DEFAULT 0,
|
||||||
|
|
||||||
|
-- 时间戳
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
expires_at DATETIME,
|
||||||
|
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 系统设置表
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS system_settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 密码重置请求表
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS password_reset_requests (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
new_password TEXT NOT NULL,
|
||||||
|
status TEXT DEFAULT 'pending', -- pending, approved, rejected
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
reviewed_at DATETIME,
|
||||||
|
reviewed_by INTEGER,
|
||||||
|
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (reviewed_by) REFERENCES users (id)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 创建索引
|
||||||
|
db.exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_shares_code ON shares(share_code);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_shares_user ON shares(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reset_requests_user ON password_reset_requests(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reset_requests_status ON password_reset_requests(status);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 数据库迁移:添加upload_api_key字段(如果不存在)
|
||||||
|
try {
|
||||||
|
const columns = db.prepare("PRAGMA table_info(users)").all();
|
||||||
|
const hasUploadApiKey = columns.some(col => col.name === 'upload_api_key');
|
||||||
|
|
||||||
|
if (!hasUploadApiKey) {
|
||||||
|
db.exec(`ALTER TABLE users ADD COLUMN upload_api_key TEXT`);
|
||||||
|
console.log('数据库迁移:添加upload_api_key字段完成');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('数据库迁移失败:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 数据库迁移:添加share_type字段(如果不存在)
|
||||||
|
try {
|
||||||
|
const shareColumns = db.prepare("PRAGMA table_info(shares)").all();
|
||||||
|
const hasShareType = shareColumns.some(col => col.name === 'share_type');
|
||||||
|
|
||||||
|
if (!hasShareType) {
|
||||||
|
db.exec(`ALTER TABLE shares ADD COLUMN share_type TEXT DEFAULT 'file'`);
|
||||||
|
console.log('数据库迁移:添加share_type字段完成');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('数据库迁移(share_type)失败:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('数据库初始化完成');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建默认管理员账号
|
||||||
|
function createDefaultAdmin() {
|
||||||
|
const adminExists = db.prepare('SELECT id FROM users WHERE is_admin = 1').get();
|
||||||
|
|
||||||
|
if (!adminExists) {
|
||||||
|
const hashedPassword = bcrypt.hashSync('admin123', 10);
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO users (
|
||||||
|
username, email, password,
|
||||||
|
is_admin, is_active, has_ftp_config
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
'admin',
|
||||||
|
'admin@example.com',
|
||||||
|
hashedPassword,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
0 // 管理员不需要FTP配置
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('默认管理员账号已创建');
|
||||||
|
console.log('用户名: admin');
|
||||||
|
console.log('密码: admin123');
|
||||||
|
console.log('⚠️ 请登录后立即修改密码!');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户相关操作
|
||||||
|
const UserDB = {
|
||||||
|
// 创建用户
|
||||||
|
create(userData) {
|
||||||
|
const hashedPassword = bcrypt.hashSync(userData.password, 10);
|
||||||
|
|
||||||
|
const hasFtpConfig = userData.ftp_host && userData.ftp_user && userData.ftp_password ? 1 : 0;
|
||||||
|
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
INSERT INTO users (
|
||||||
|
username, email, password,
|
||||||
|
ftp_host, ftp_port, ftp_user, ftp_password, http_download_base_url,
|
||||||
|
has_ftp_config
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const result = stmt.run(
|
||||||
|
userData.username,
|
||||||
|
userData.email,
|
||||||
|
hashedPassword,
|
||||||
|
userData.ftp_host || null,
|
||||||
|
userData.ftp_port || 22,
|
||||||
|
userData.ftp_user || null,
|
||||||
|
userData.ftp_password || null,
|
||||||
|
userData.http_download_base_url || null,
|
||||||
|
hasFtpConfig
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.lastInsertRowid;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 根据用户名查找
|
||||||
|
findByUsername(username) {
|
||||||
|
return db.prepare('SELECT * FROM users WHERE username = ?').get(username);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 根据邮箱查找
|
||||||
|
findByEmail(email) {
|
||||||
|
return db.prepare('SELECT * FROM users WHERE email = ?').get(email);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 根据ID查找
|
||||||
|
findById(id) {
|
||||||
|
return db.prepare('SELECT * FROM users WHERE id = ?').get(id);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 验证密码
|
||||||
|
verifyPassword(plainPassword, hashedPassword) {
|
||||||
|
return bcrypt.compareSync(plainPassword, hashedPassword);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 更新用户
|
||||||
|
update(id, updates) {
|
||||||
|
const fields = [];
|
||||||
|
const values = [];
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(updates)) {
|
||||||
|
if (key === 'password') {
|
||||||
|
fields.push(`${key} = ?`);
|
||||||
|
values.push(bcrypt.hashSync(value, 10));
|
||||||
|
} else {
|
||||||
|
fields.push(`${key} = ?`);
|
||||||
|
values.push(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.push('updated_at = CURRENT_TIMESTAMP');
|
||||||
|
values.push(id);
|
||||||
|
|
||||||
|
const stmt = db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`);
|
||||||
|
return stmt.run(...values);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取所有用户
|
||||||
|
getAll(filters = {}) {
|
||||||
|
let query = 'SELECT * FROM users WHERE 1=1';
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
if (filters.is_admin !== undefined) {
|
||||||
|
query += ' AND is_admin = ?';
|
||||||
|
params.push(filters.is_admin);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.is_banned !== undefined) {
|
||||||
|
query += ' AND is_banned = ?';
|
||||||
|
params.push(filters.is_banned);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ' ORDER BY created_at DESC';
|
||||||
|
|
||||||
|
return db.prepare(query).all(...params);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 删除用户
|
||||||
|
delete(id) {
|
||||||
|
return db.prepare('DELETE FROM users WHERE id = ?').run(id);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 封禁/解封用户
|
||||||
|
setBanStatus(id, isBanned) {
|
||||||
|
return db.prepare('UPDATE users SET is_banned = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
|
||||||
|
.run(isBanned ? 1 : 0, id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 分享链接相关操作
|
||||||
|
const ShareDB = {
|
||||||
|
// 生成随机分享码
|
||||||
|
generateShareCode(length = 8) {
|
||||||
|
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||||
|
let code = '';
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
code += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||||
|
}
|
||||||
|
return code;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 创建分享链接
|
||||||
|
// 创建分享链接
|
||||||
|
create(userId, options = {}) {
|
||||||
|
const {
|
||||||
|
share_type = 'file',
|
||||||
|
file_path = '',
|
||||||
|
file_name = '',
|
||||||
|
password = null,
|
||||||
|
expiry_days = null
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
let shareCode;
|
||||||
|
let attempts = 0;
|
||||||
|
|
||||||
|
// 尝试生成唯一的分享码
|
||||||
|
do {
|
||||||
|
shareCode = this.generateShareCode();
|
||||||
|
attempts++;
|
||||||
|
if (attempts > 10) {
|
||||||
|
shareCode = this.generateShareCode(10); // 增加长度
|
||||||
|
}
|
||||||
|
} while (this.findByCode(shareCode) && attempts < 20);
|
||||||
|
|
||||||
|
// 计算过期时间
|
||||||
|
let expiresAt = null;
|
||||||
|
if (expiry_days) {
|
||||||
|
const expireDate = new Date();
|
||||||
|
expireDate.setDate(expireDate.getDate() + parseInt(expiry_days));
|
||||||
|
expiresAt = expireDate.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
INSERT INTO shares (user_id, share_code, share_path, share_type, share_password, expires_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const hashedPassword = password ? bcrypt.hashSync(password, 10) : null;
|
||||||
|
const sharePath = share_type === 'file' ? file_path : '/';
|
||||||
|
|
||||||
|
const result = stmt.run(
|
||||||
|
userId,
|
||||||
|
shareCode,
|
||||||
|
sharePath,
|
||||||
|
share_type,
|
||||||
|
hashedPassword,
|
||||||
|
expiresAt
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: result.lastInsertRowid,
|
||||||
|
share_code: shareCode,
|
||||||
|
share_type: share_type
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 根据分享码查找
|
||||||
|
findByCode(shareCode) {
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT s.*, u.username, u.ftp_host, u.ftp_port, u.ftp_user, u.ftp_password, u.http_download_base_url
|
||||||
|
FROM shares s
|
||||||
|
JOIN users u ON s.user_id = u.id
|
||||||
|
WHERE s.share_code = ?
|
||||||
|
`).get(shareCode);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 根据ID查找
|
||||||
|
findById(id) {
|
||||||
|
return db.prepare('SELECT * FROM shares WHERE id = ?').get(id);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 验证分享密码
|
||||||
|
verifyPassword(plainPassword, hashedPassword) {
|
||||||
|
return bcrypt.compareSync(plainPassword, hashedPassword);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取用户的所有分享
|
||||||
|
getUserShares(userId) {
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT * FROM shares
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`).all(userId);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 增加查看次数
|
||||||
|
incrementViewCount(shareCode) {
|
||||||
|
return db.prepare(`
|
||||||
|
UPDATE shares
|
||||||
|
SET view_count = view_count + 1
|
||||||
|
WHERE share_code = ?
|
||||||
|
`).run(shareCode);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 增加下载次数
|
||||||
|
incrementDownloadCount(shareCode) {
|
||||||
|
return db.prepare(`
|
||||||
|
UPDATE shares
|
||||||
|
SET download_count = download_count + 1
|
||||||
|
WHERE share_code = ?
|
||||||
|
`).run(shareCode);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 删除分享
|
||||||
|
delete(id, userId = null) {
|
||||||
|
if (userId) {
|
||||||
|
return db.prepare('DELETE FROM shares WHERE id = ? AND user_id = ?').run(id, userId);
|
||||||
|
}
|
||||||
|
return db.prepare('DELETE FROM shares WHERE id = ?').run(id);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取所有分享(管理员)
|
||||||
|
getAll() {
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT s.*, u.username
|
||||||
|
FROM shares s
|
||||||
|
JOIN users u ON s.user_id = u.id
|
||||||
|
ORDER BY s.created_at DESC
|
||||||
|
`).all();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 系统设置管理
|
||||||
|
const SettingsDB = {
|
||||||
|
// 获取设置
|
||||||
|
get(key) {
|
||||||
|
const row = db.prepare('SELECT value FROM system_settings WHERE key = ?').get(key);
|
||||||
|
return row ? row.value : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 设置值
|
||||||
|
set(key, value) {
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO system_settings (key, value, updated_at)
|
||||||
|
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET
|
||||||
|
value = excluded.value,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
`).run(key, value);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取所有设置
|
||||||
|
getAll() {
|
||||||
|
return db.prepare('SELECT key, value FROM system_settings').all();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 密码重置请求管理
|
||||||
|
const PasswordResetDB = {
|
||||||
|
// 创建密码重置请求
|
||||||
|
create(userId, newPassword) {
|
||||||
|
const hashedPassword = bcrypt.hashSync(newPassword, 10);
|
||||||
|
|
||||||
|
// 删除该用户之前的pending请求
|
||||||
|
db.prepare('DELETE FROM password_reset_requests WHERE user_id = ? AND status = ?')
|
||||||
|
.run(userId, 'pending');
|
||||||
|
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
INSERT INTO password_reset_requests (user_id, new_password, status)
|
||||||
|
VALUES (?, ?, 'pending')
|
||||||
|
`);
|
||||||
|
|
||||||
|
const result = stmt.run(userId, hashedPassword);
|
||||||
|
return result.lastInsertRowid;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取待审核的请求
|
||||||
|
getPending() {
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT r.*, u.username, u.email
|
||||||
|
FROM password_reset_requests r
|
||||||
|
JOIN users u ON r.user_id = u.id
|
||||||
|
WHERE r.status = 'pending'
|
||||||
|
ORDER BY r.created_at DESC
|
||||||
|
`).all();
|
||||||
|
},
|
||||||
|
|
||||||
|
// 审核请求(批准或拒绝)
|
||||||
|
review(requestId, adminId, approved) {
|
||||||
|
const request = db.prepare('SELECT * FROM password_reset_requests WHERE id = ?').get(requestId);
|
||||||
|
|
||||||
|
if (!request || request.status !== 'pending') {
|
||||||
|
throw new Error('请求不存在或已被处理');
|
||||||
|
}
|
||||||
|
|
||||||
|
const newStatus = approved ? 'approved' : 'rejected';
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE password_reset_requests
|
||||||
|
SET status = ?, reviewed_at = CURRENT_TIMESTAMP, reviewed_by = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(newStatus, adminId, requestId);
|
||||||
|
|
||||||
|
// 如果批准,更新用户密码
|
||||||
|
if (approved) {
|
||||||
|
db.prepare('UPDATE users SET password = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
|
||||||
|
.run(request.new_password, request.user_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取用户的所有请求
|
||||||
|
getUserRequests(userId) {
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT * FROM password_reset_requests
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`).all(userId);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 检查用户是否有待处理的请求
|
||||||
|
hasPendingRequest(userId) {
|
||||||
|
const request = db.prepare(`
|
||||||
|
SELECT id FROM password_reset_requests
|
||||||
|
WHERE user_id = ? AND status = 'pending'
|
||||||
|
`).get(userId);
|
||||||
|
return !!request;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 初始化默认设置
|
||||||
|
function initDefaultSettings() {
|
||||||
|
// 默认上传限制为100MB
|
||||||
|
if (!SettingsDB.get('max_upload_size')) {
|
||||||
|
SettingsDB.set('max_upload_size', '104857600'); // 100MB in bytes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 数据库版本迁移 - v2.0 本地存储功能
|
||||||
|
function migrateToV2() {
|
||||||
|
try {
|
||||||
|
const columns = db.prepare("PRAGMA table_info(users)").all();
|
||||||
|
const hasStoragePermission = columns.some(col => col.name === 'storage_permission');
|
||||||
|
|
||||||
|
if (!hasStoragePermission) {
|
||||||
|
console.log('[数据库迁移] 检测到旧版本数据库,开始升级到 v2.0...');
|
||||||
|
|
||||||
|
// 添加本地存储相关字段
|
||||||
|
db.exec(`
|
||||||
|
ALTER TABLE users ADD COLUMN storage_permission TEXT DEFAULT 'sftp_only';
|
||||||
|
ALTER TABLE users ADD COLUMN current_storage_type TEXT DEFAULT 'sftp';
|
||||||
|
ALTER TABLE users ADD COLUMN local_storage_quota INTEGER DEFAULT 1073741824;
|
||||||
|
ALTER TABLE users ADD COLUMN local_storage_used INTEGER DEFAULT 0;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 更新现有用户为SFTP模式(保持兼容)
|
||||||
|
const updateStmt = db.prepare("UPDATE users SET current_storage_type = 'sftp' WHERE has_ftp_config = 1");
|
||||||
|
updateStmt.run();
|
||||||
|
|
||||||
|
console.log('[数据库迁移] ✓ 用户表已升级');
|
||||||
|
|
||||||
|
// 为分享表添加存储类型字段
|
||||||
|
const shareColumns = db.prepare("PRAGMA table_info(shares)").all();
|
||||||
|
const hasShareStorageType = shareColumns.some(col => col.name === 'storage_type');
|
||||||
|
|
||||||
|
if (!hasShareStorageType) {
|
||||||
|
db.exec(`ALTER TABLE shares ADD COLUMN storage_type TEXT DEFAULT 'sftp';`);
|
||||||
|
console.log('[数据库迁移] ✓ 分享表已升级');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[数据库迁移] ✅ 数据库升级到 v2.0 完成!本地存储功能已启用');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[数据库迁移] 迁移失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化数据库
|
||||||
|
initDatabase();
|
||||||
|
createDefaultAdmin();
|
||||||
|
initDefaultSettings();
|
||||||
|
migrateToV2(); // 执行数据库迁移
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
db,
|
||||||
|
UserDB,
|
||||||
|
ShareDB,
|
||||||
|
SettingsDB,
|
||||||
|
PasswordResetDB
|
||||||
|
};
|
||||||
3010
backend/package-lock.json
generated
Normal file
3010
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
backend/package.json
Normal file
34
backend/package.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "ftp-web-manager-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "FTP Web Manager Backend",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"dev": "nodemon server.js"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"ftp",
|
||||||
|
"web",
|
||||||
|
"file-manager"
|
||||||
|
],
|
||||||
|
"author": "",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"archiver": "^7.0.1",
|
||||||
|
"basic-ftp": "^5.0.4",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
|
"better-sqlite3": "^12.4.1",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.3.1",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"express-validator": "^7.3.0",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"multer": "^2.0.2",
|
||||||
|
"ssh2-sftp-client": "^12.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.0.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
2113
backend/server.js
Normal file
2113
backend/server.js
Normal file
File diff suppressed because it is too large
Load Diff
10
backend/start.bat
Normal file
10
backend/start.bat
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
@echo off
|
||||||
|
echo ========================================
|
||||||
|
echo FTP 网盘管理平台 - 启动脚本
|
||||||
|
echo ========================================
|
||||||
|
echo.
|
||||||
|
|
||||||
|
cd /d %~dp0
|
||||||
|
node server.js
|
||||||
|
|
||||||
|
pause
|
||||||
321
backend/storage.js
Normal file
321
backend/storage.js
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
const SftpClient = require('ssh2-sftp-client');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { UserDB } = require('./database');
|
||||||
|
|
||||||
|
// ===== 统一存储接口 =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 存储接口工厂
|
||||||
|
* 根据用户的存储类型返回对应的存储客户端
|
||||||
|
*/
|
||||||
|
class StorageInterface {
|
||||||
|
constructor(user) {
|
||||||
|
this.user = user;
|
||||||
|
this.type = user.current_storage_type || 'sftp';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建并返回存储客户端
|
||||||
|
*/
|
||||||
|
async connect() {
|
||||||
|
if (this.type === 'local') {
|
||||||
|
const client = new LocalStorageClient(this.user);
|
||||||
|
await client.init();
|
||||||
|
return client;
|
||||||
|
} else {
|
||||||
|
const client = new SftpStorageClient(this.user);
|
||||||
|
await client.connect();
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 本地存储客户端 =====
|
||||||
|
|
||||||
|
class LocalStorageClient {
|
||||||
|
constructor(user) {
|
||||||
|
this.user = user;
|
||||||
|
// 使用环境变量或默认路径(不硬编码)
|
||||||
|
const storageRoot = process.env.STORAGE_ROOT || path.join(__dirname, 'storage');
|
||||||
|
this.basePath = path.join(storageRoot, `user_${user.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化用户存储目录
|
||||||
|
*/
|
||||||
|
async init() {
|
||||||
|
if (!fs.existsSync(this.basePath)) {
|
||||||
|
fs.mkdirSync(this.basePath, { recursive: true, mode: 0o755 });
|
||||||
|
console.log(`[本地存储] 创建用户目录: ${this.basePath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列出目录内容
|
||||||
|
*/
|
||||||
|
async list(dirPath) {
|
||||||
|
const fullPath = this.getFullPath(dirPath);
|
||||||
|
|
||||||
|
// 确保目录存在
|
||||||
|
if (!fs.existsSync(fullPath)) {
|
||||||
|
fs.mkdirSync(fullPath, { recursive: true });
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = fs.readdirSync(fullPath, { withFileTypes: true });
|
||||||
|
|
||||||
|
return items.map(item => {
|
||||||
|
const itemPath = path.join(fullPath, item.name);
|
||||||
|
const stats = fs.statSync(itemPath);
|
||||||
|
return {
|
||||||
|
name: item.name,
|
||||||
|
type: item.isDirectory() ? 'd' : '-',
|
||||||
|
size: stats.size,
|
||||||
|
modifyTime: stats.mtimeMs
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传文件
|
||||||
|
*/
|
||||||
|
async put(localPath, remotePath) {
|
||||||
|
const destPath = this.getFullPath(remotePath);
|
||||||
|
|
||||||
|
// 检查配额
|
||||||
|
const fileSize = fs.statSync(localPath).size;
|
||||||
|
this.checkQuota(fileSize);
|
||||||
|
|
||||||
|
// 确保目标目录存在
|
||||||
|
const destDir = path.dirname(destPath);
|
||||||
|
if (!fs.existsSync(destDir)) {
|
||||||
|
fs.mkdirSync(destDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用临时文件+重命名模式,避免文件被占用问题
|
||||||
|
const tempPath = `${destPath}.uploading_${Date.now()}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 复制到临时文件
|
||||||
|
fs.copyFileSync(localPath, tempPath);
|
||||||
|
|
||||||
|
// 如果目标文件存在,先删除
|
||||||
|
if (fs.existsSync(destPath)) {
|
||||||
|
fs.unlinkSync(destPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重命名临时文件为目标文件
|
||||||
|
fs.renameSync(tempPath, destPath);
|
||||||
|
|
||||||
|
// 更新已使用空间
|
||||||
|
this.updateUsedSpace(fileSize);
|
||||||
|
} catch (error) {
|
||||||
|
// 清理临时文件
|
||||||
|
if (fs.existsSync(tempPath)) {
|
||||||
|
fs.unlinkSync(tempPath);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除文件
|
||||||
|
*/
|
||||||
|
async delete(filePath) {
|
||||||
|
const fullPath = this.getFullPath(filePath);
|
||||||
|
const stats = fs.statSync(fullPath);
|
||||||
|
|
||||||
|
fs.unlinkSync(fullPath);
|
||||||
|
|
||||||
|
// 更新已使用空间
|
||||||
|
this.updateUsedSpace(-stats.size);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重命名文件
|
||||||
|
*/
|
||||||
|
async rename(oldPath, newPath) {
|
||||||
|
const oldFullPath = this.getFullPath(oldPath);
|
||||||
|
const newFullPath = this.getFullPath(newPath);
|
||||||
|
|
||||||
|
// 确保新路径的目录存在
|
||||||
|
const newDir = path.dirname(newFullPath);
|
||||||
|
if (!fs.existsSync(newDir)) {
|
||||||
|
fs.mkdirSync(newDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.renameSync(oldFullPath, newFullPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取文件信息
|
||||||
|
*/
|
||||||
|
async stat(filePath) {
|
||||||
|
const fullPath = this.getFullPath(filePath);
|
||||||
|
return fs.statSync(fullPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建文件读取流
|
||||||
|
*/
|
||||||
|
createReadStream(filePath) {
|
||||||
|
const fullPath = this.getFullPath(filePath);
|
||||||
|
return fs.createReadStream(fullPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭连接(本地存储无需关闭)
|
||||||
|
*/
|
||||||
|
async end() {
|
||||||
|
// 本地存储无需关闭连接
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 辅助方法 =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取完整路径(带安全检查)
|
||||||
|
*/
|
||||||
|
getFullPath(relativePath) {
|
||||||
|
// 1. 规范化路径,移除 ../ 等危险路径
|
||||||
|
const normalized = path.normalize(relativePath).replace(/^(\.\.[\/\\])+/, '');
|
||||||
|
|
||||||
|
// 2. 拼接完整路径
|
||||||
|
const fullPath = path.join(this.basePath, normalized);
|
||||||
|
|
||||||
|
// 3. 安全检查:确保路径在用户目录内(防止目录遍历攻击)
|
||||||
|
if (!fullPath.startsWith(this.basePath)) {
|
||||||
|
throw new Error('非法路径访问');
|
||||||
|
}
|
||||||
|
|
||||||
|
return fullPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查配额
|
||||||
|
*/
|
||||||
|
checkQuota(additionalSize) {
|
||||||
|
const newUsed = (this.user.local_storage_used || 0) + additionalSize;
|
||||||
|
if (newUsed > this.user.local_storage_quota) {
|
||||||
|
const used = this.formatSize(this.user.local_storage_used);
|
||||||
|
const quota = this.formatSize(this.user.local_storage_quota);
|
||||||
|
const need = this.formatSize(additionalSize);
|
||||||
|
throw new Error(`存储配额不足。已使用: ${used}, 配额: ${quota}, 需要: ${need}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新已使用空间
|
||||||
|
*/
|
||||||
|
updateUsedSpace(delta) {
|
||||||
|
const newUsed = Math.max(0, (this.user.local_storage_used || 0) + delta);
|
||||||
|
UserDB.update(this.user.id, { local_storage_used: newUsed });
|
||||||
|
// 更新内存中的值
|
||||||
|
this.user.local_storage_used = newUsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化文件大小
|
||||||
|
*/
|
||||||
|
formatSize(bytes) {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== SFTP存储客户端 =====
|
||||||
|
|
||||||
|
class SftpStorageClient {
|
||||||
|
constructor(user) {
|
||||||
|
this.user = user;
|
||||||
|
this.sftp = new SftpClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 连接SFTP服务器
|
||||||
|
*/
|
||||||
|
async connect() {
|
||||||
|
await this.sftp.connect({
|
||||||
|
host: this.user.ftp_host,
|
||||||
|
port: this.user.ftp_port || 22,
|
||||||
|
username: this.user.ftp_user,
|
||||||
|
password: this.user.ftp_password
|
||||||
|
});
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列出目录内容
|
||||||
|
*/
|
||||||
|
async list(dirPath) {
|
||||||
|
return await this.sftp.list(dirPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传文件
|
||||||
|
*/
|
||||||
|
async put(localPath, remotePath) {
|
||||||
|
// 使用临时文件+重命名模式(与upload_tool保持一致)
|
||||||
|
const tempRemotePath = `${remotePath}.uploading_${Date.now()}`;
|
||||||
|
|
||||||
|
// 第一步:上传到临时文件
|
||||||
|
await this.sftp.put(localPath, tempRemotePath);
|
||||||
|
|
||||||
|
// 第二步:检查目标文件是否存在,如果存在先删除
|
||||||
|
try {
|
||||||
|
await this.sftp.stat(remotePath);
|
||||||
|
await this.sftp.delete(remotePath);
|
||||||
|
} catch (err) {
|
||||||
|
// 文件不存在,无需删除
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第三步:重命名临时文件为目标文件
|
||||||
|
await this.sftp.rename(tempRemotePath, remotePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除文件
|
||||||
|
*/
|
||||||
|
async delete(filePath) {
|
||||||
|
return await this.sftp.delete(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重命名文件
|
||||||
|
*/
|
||||||
|
async rename(oldPath, newPath) {
|
||||||
|
return await this.sftp.rename(oldPath, newPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取文件信息
|
||||||
|
*/
|
||||||
|
async stat(filePath) {
|
||||||
|
return await this.sftp.stat(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建文件读取流
|
||||||
|
*/
|
||||||
|
createReadStream(filePath) {
|
||||||
|
return this.sftp.createReadStream(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭连接
|
||||||
|
*/
|
||||||
|
async end() {
|
||||||
|
if (this.sftp) {
|
||||||
|
await this.sftp.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
StorageInterface,
|
||||||
|
LocalStorageClient,
|
||||||
|
SftpStorageClient
|
||||||
|
};
|
||||||
110
deploy.sh
Normal file
110
deploy.sh
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# 玩玩云一键部署脚本
|
||||||
|
# 使用方法: bash deploy.sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "========================================="
|
||||||
|
echo " 玩玩云 - 一键部署脚本"
|
||||||
|
echo "========================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 检查Docker
|
||||||
|
if ! command -v docker &> /dev/null; then
|
||||||
|
echo "❌ 错误: Docker未安装"
|
||||||
|
echo "请先安装Docker: https://docs.docker.com/engine/install/"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 检查Docker Compose
|
||||||
|
if ! command -v docker-compose &> /dev/null; then
|
||||||
|
echo "❌ 错误: Docker Compose未安装"
|
||||||
|
echo "请先安装Docker Compose: https://docs.docker.com/compose/install/"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✓ Docker版本: $(docker --version)"
|
||||||
|
echo "✓ Docker Compose版本: $(docker-compose --version)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 检查必要的目录
|
||||||
|
echo "📁 检查项目结构..."
|
||||||
|
REQUIRED_DIRS=("backend" "frontend" "nginx")
|
||||||
|
for dir in "${REQUIRED_DIRS[@]}"; do
|
||||||
|
if [ ! -d "$dir" ]; then
|
||||||
|
echo "❌ 错误: 缺少 $dir 目录"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "✓ 项目结构完整"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 创建必要的目录
|
||||||
|
echo "📂 创建必要的目录..."
|
||||||
|
mkdir -p certbot/conf
|
||||||
|
mkdir -p certbot/www
|
||||||
|
mkdir -p backend/uploads
|
||||||
|
echo "✓ 目录创建完成"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 检查.env文件
|
||||||
|
if [ ! -f "backend/.env" ]; then
|
||||||
|
echo "⚠️ 警告: backend/.env 文件不存在"
|
||||||
|
if [ -f "backend/.env.example" ]; then
|
||||||
|
echo "正在从.env.example创建.env文件..."
|
||||||
|
cp backend/.env.example backend/.env
|
||||||
|
echo "✓ 已创建.env文件,请根据需要修改配置"
|
||||||
|
else
|
||||||
|
echo "⚠️ 建议创建.env文件配置JWT密钥等参数"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 停止旧容器
|
||||||
|
echo "🔄 停止旧容器..."
|
||||||
|
docker-compose down 2>/dev/null || true
|
||||||
|
echo "✓ 旧容器已停止"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 构建并启动
|
||||||
|
echo "🚀 构建并启动服务..."
|
||||||
|
docker-compose up --build -d
|
||||||
|
|
||||||
|
# 等待服务启动
|
||||||
|
echo ""
|
||||||
|
echo "⏳ 等待服务启动..."
|
||||||
|
sleep 5
|
||||||
|
|
||||||
|
# 检查容器状态
|
||||||
|
echo ""
|
||||||
|
echo "📊 检查容器状态..."
|
||||||
|
docker-compose ps
|
||||||
|
|
||||||
|
# 检查后端日志
|
||||||
|
echo ""
|
||||||
|
echo "📝 后端启动日志:"
|
||||||
|
docker-compose logs --tail=20 backend
|
||||||
|
|
||||||
|
# 显示访问信息
|
||||||
|
echo ""
|
||||||
|
echo "========================================="
|
||||||
|
echo " 🎉 部署完成!"
|
||||||
|
echo "========================================="
|
||||||
|
echo ""
|
||||||
|
echo "📍 访问地址:"
|
||||||
|
echo " 前端: http://localhost:8080"
|
||||||
|
echo " 后端API: http://localhost:40001"
|
||||||
|
echo ""
|
||||||
|
echo "👤 默认管理员账号:"
|
||||||
|
echo " 用户名: admin"
|
||||||
|
echo " 密码: admin123"
|
||||||
|
echo " ⚠️ 请立即登录并修改密码!"
|
||||||
|
echo ""
|
||||||
|
echo "📚 查看日志:"
|
||||||
|
echo " docker-compose logs -f"
|
||||||
|
echo ""
|
||||||
|
echo "🛑 停止服务:"
|
||||||
|
echo " docker-compose down"
|
||||||
|
echo ""
|
||||||
|
echo "========================================="
|
||||||
53
docker-compose.yml
Normal file
53
docker-compose.yml
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: wanwanyun-backend
|
||||||
|
container_name: wanwanyun-backend
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "40001:40001"
|
||||||
|
volumes:
|
||||||
|
- ./backend:/app
|
||||||
|
- /app/node_modules
|
||||||
|
- ./upload-tool:/upload-tool
|
||||||
|
- ./storage:/app/storage # 本地存储卷
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- STORAGE_ROOT=/app/storage # 存储根目录(不硬编码)
|
||||||
|
networks:
|
||||||
|
- wanwanyun-network
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
image: nginx:alpine
|
||||||
|
container_name: wanwanyun-frontend
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
- "8443:443"
|
||||||
|
volumes:
|
||||||
|
- ./frontend:/usr/share/nginx/html
|
||||||
|
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf
|
||||||
|
- ./certbot/conf:/etc/letsencrypt
|
||||||
|
- ./certbot/www:/var/www/certbot
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
networks:
|
||||||
|
- wanwanyun-network
|
||||||
|
command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'"
|
||||||
|
|
||||||
|
certbot:
|
||||||
|
image: certbot/certbot
|
||||||
|
container_name: wanwanyun-certbot
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./certbot/conf:/etc/letsencrypt
|
||||||
|
- ./certbot/www:/var/www/certbot
|
||||||
|
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
wanwanyun-network:
|
||||||
|
driver: bridge
|
||||||
1997
frontend/app.html
Normal file
1997
frontend/app.html
Normal file
File diff suppressed because it is too large
Load Diff
1708
frontend/app.js
Normal file
1708
frontend/app.js
Normal file
File diff suppressed because it is too large
Load Diff
191
frontend/index.html
Normal file
191
frontend/index.html
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>玩玩云 - 主页</title>
|
||||||
|
<link rel="stylesheet" href="libs/fontawesome/css/all.min.css">
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
.navbar {
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
padding: 20px 50px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
box-shadow: 0 2px 20px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #667eea;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.nav-buttons { display: flex; gap: 15px; }
|
||||||
|
.btn {
|
||||||
|
padding: 12px 24px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.3s;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: #5568d3;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
.btn-outline {
|
||||||
|
background: transparent;
|
||||||
|
color: #667eea;
|
||||||
|
border: 2px solid #667eea;
|
||||||
|
}
|
||||||
|
.btn-outline:hover {
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.hero {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 80px auto;
|
||||||
|
padding: 0 50px;
|
||||||
|
text-align: center;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.hero h1 {
|
||||||
|
font-size: 56px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.hero p {
|
||||||
|
font-size: 20px;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
opacity: 0.95;
|
||||||
|
}
|
||||||
|
.hero-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.features {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 80px auto;
|
||||||
|
padding: 0 50px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: 30px;
|
||||||
|
}
|
||||||
|
.feature-card {
|
||||||
|
background: white;
|
||||||
|
padding: 40px 30px;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 10px 40px rgba(0,0,0,0.1);
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
.feature-card:hover {
|
||||||
|
transform: translateY(-8px);
|
||||||
|
box-shadow: 0 20px 50px rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
.feature-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
color: #667eea;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.feature-title {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.feature-desc {
|
||||||
|
font-size: 15px;
|
||||||
|
color: #666;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
color: white;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="navbar">
|
||||||
|
<div class="logo">
|
||||||
|
<i class="fas fa-cloud"></i>
|
||||||
|
玩玩云
|
||||||
|
</div>
|
||||||
|
<div class="nav-buttons">
|
||||||
|
<a href="app.html?action=login" class="btn btn-outline">
|
||||||
|
<i class="fas fa-right-to-bracket"></i> 登录
|
||||||
|
</a>
|
||||||
|
<a href="app.html?action=register" class="btn btn-primary">
|
||||||
|
<i class="fas fa-user-plus"></i> 注册
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="hero">
|
||||||
|
<h1><i class="fas fa-cloud"></i> 玩玩云管理平台</h1>
|
||||||
|
<p>简单、安全、高效的文件管理解决方案<br>连接你的SFTP服务器,随时随地管理文件</p>
|
||||||
|
<div class="hero-buttons">
|
||||||
|
<a href="app.html?action=register" class="btn btn-primary" style="padding: 16px 32px; font-size: 18px;">
|
||||||
|
<i class="fas fa-rocket"></i> 立即开始
|
||||||
|
</a>
|
||||||
|
<a href="app.html?action=login" class="btn btn-outline" style="padding: 16px 32px; font-size: 18px;">
|
||||||
|
<i class="fas fa-right-to-bracket"></i> 已有账号登录
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="features">
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon"><i class="fas fa-server"></i></div>
|
||||||
|
<div class="feature-title">连接你的SFTP</div>
|
||||||
|
<div class="feature-desc">支持连接任何SFTP服务器,数据存储在你自己的服务器上,更安全可靠</div>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon"><i class="fas fa-cloud-upload-alt"></i></div>
|
||||||
|
<div class="feature-title">轻松上传下载</div>
|
||||||
|
<div class="feature-desc">网盘式界面,拖拽上传,快速下载,文件管理从未如此简单</div>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon"><i class="fas fa-share-alt"></i></div>
|
||||||
|
<div class="feature-title">一键分享</div>
|
||||||
|
<div class="feature-desc">生成分享链接,可设置密码保护,轻松分享文件给朋友</div>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon"><i class="fas fa-lock"></i></div>
|
||||||
|
<div class="feature-title">安全可靠</div>
|
||||||
|
<div class="feature-desc">JWT认证,密码加密存储,保护你的数据安全</div>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon"><i class="fas fa-mobile-alt"></i></div>
|
||||||
|
<div class="feature-title">响应式设计</div>
|
||||||
|
<div class="feature-desc">完美支持桌面和移动设备,随时随地访问你的文件</div>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon"><i class="fas fa-chart-line"></i></div>
|
||||||
|
<div class="feature-title">分享统计</div>
|
||||||
|
<div class="feature-desc">查看分享链接的访问次数和下载统计,了解分享效果</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p><i class="fas fa-heart" style="color: #ff6b6b;"></i> 玩玩云 © 2025</p>
|
||||||
|
<p style="margin-top: 10px; font-size: 14px;">轻松管理你的文件,让分享更简单</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3
frontend/libs/axios.min.js
vendored
Normal file
3
frontend/libs/axios.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
9
frontend/libs/fontawesome/css/all.min.css
vendored
Normal file
9
frontend/libs/fontawesome/css/all.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
frontend/libs/fontawesome/webfonts/fa-brands-400.woff2
Normal file
BIN
frontend/libs/fontawesome/webfonts/fa-brands-400.woff2
Normal file
Binary file not shown.
BIN
frontend/libs/fontawesome/webfonts/fa-regular-400.woff2
Normal file
BIN
frontend/libs/fontawesome/webfonts/fa-regular-400.woff2
Normal file
Binary file not shown.
BIN
frontend/libs/fontawesome/webfonts/fa-solid-900.woff2
Normal file
BIN
frontend/libs/fontawesome/webfonts/fa-solid-900.woff2
Normal file
Binary file not shown.
18323
frontend/libs/vue.global.js
Normal file
18323
frontend/libs/vue.global.js
Normal file
File diff suppressed because it is too large
Load Diff
13
frontend/libs/vue.global.prod.js
Normal file
13
frontend/libs/vue.global.prod.js
Normal file
File diff suppressed because one or more lines are too long
767
frontend/share.html
Normal file
767
frontend/share.html
Normal file
@@ -0,0 +1,767 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>文件分享 - 玩玩云</title>
|
||||||
|
<script src="libs/vue.global.prod.js"></script>
|
||||||
|
<script src="libs/axios.min.js"></script>
|
||||||
|
<link rel="stylesheet" href="libs/fontawesome/css/all.min.css">
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||||
|
padding: 30px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #667eea;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.form-group { margin-bottom: 20px; }
|
||||||
|
.form-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
.form-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 2px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
padding: 10px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.btn-primary:hover { background: #5568d3; }
|
||||||
|
.alert { padding: 12px; border-radius: 8px; margin-bottom: 15px; }
|
||||||
|
.alert-error { background: #f8d7da; color: #721c24; }
|
||||||
|
.file-list {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.file-item {
|
||||||
|
padding: 15px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.file-item:hover {
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
.file-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
.file-icon {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
/* 视图切换按钮 */
|
||||||
|
.view-controls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.btn-secondary {
|
||||||
|
background: #e0e0e0;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: #d0d0d0;
|
||||||
|
}
|
||||||
|
/* 大图标视图 */
|
||||||
|
.file-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
|
gap: 25px;
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
.file-grid-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px 15px;
|
||||||
|
border: 2px solid #e8e8e8;
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.file-grid-item:hover {
|
||||||
|
background: #f8f9fa;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||||
|
border-color: #667eea;
|
||||||
|
transform: translateY(-3px);
|
||||||
|
}
|
||||||
|
.file-grid-icon {
|
||||||
|
font-size: 56px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.file-grid-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: center;
|
||||||
|
word-break: break-all;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
max-width: 100%;
|
||||||
|
color: #333;
|
||||||
|
/* 固定显示2行,超出显示省略号 */
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
line-height: 1.4;
|
||||||
|
min-height: 39px;
|
||||||
|
max-height: 39px;
|
||||||
|
}
|
||||||
|
.file-grid-size {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
/* 单文件居中显示 */
|
||||||
|
.single-file-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
.single-file-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 50px 40px;
|
||||||
|
border: 2px solid #e0e0e0;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
|
||||||
|
max-width: 500px;
|
||||||
|
width: 100%;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
.single-file-card:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
box-shadow: 0 12px 32px rgba(0,0,0,0.18);
|
||||||
|
}
|
||||||
|
.single-file-icon {
|
||||||
|
font-size: 120px;
|
||||||
|
margin-bottom: 25px;
|
||||||
|
animation: float 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes float {
|
||||||
|
0%, 100% { transform: translateY(0px); }
|
||||||
|
50% { transform: translateY(-10px); }
|
||||||
|
}
|
||||||
|
.single-file-name {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
word-break: break-all;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.single-file-size {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
.single-file-download {
|
||||||
|
padding: 15px 40px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 30px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
|
||||||
|
}
|
||||||
|
.single-file-download:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.6);
|
||||||
|
}
|
||||||
|
/* 响应式设计 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.single-file-card {
|
||||||
|
padding: 30px 20px;
|
||||||
|
}
|
||||||
|
.single-file-icon {
|
||||||
|
font-size: 80px;
|
||||||
|
}
|
||||||
|
.single-file-name {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.single-file-size {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.single-file-download {
|
||||||
|
padding: 12px 30px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* 分享不存在提示 */
|
||||||
|
.share-not-found {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.share-not-found-icon {
|
||||||
|
font-size: 100px;
|
||||||
|
color: #ccc;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
animation: fadeIn 0.5s;
|
||||||
|
}
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: scale(0.8); }
|
||||||
|
to { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
.share-not-found-title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
.share-not-found-message {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #999;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 移动端适配 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
body {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
font-size: 20px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 视图切换按钮移动端优化 */
|
||||||
|
.view-controls {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
.view-controls .btn {
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表单移动端优化 */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
.form-label {
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.form-input {
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 文件网格视图移动端优化 */
|
||||||
|
.file-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
.file-grid-item {
|
||||||
|
padding: 15px 10px;
|
||||||
|
}
|
||||||
|
.file-grid-icon {
|
||||||
|
font-size: 44px !important;
|
||||||
|
}
|
||||||
|
.file-grid-name {
|
||||||
|
font-size: 12px;
|
||||||
|
min-height: 34px;
|
||||||
|
max-height: 34px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.file-grid-size {
|
||||||
|
font-size: 11px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.file-grid-item .btn {
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 列表视图移动端优化 */
|
||||||
|
.file-list {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.file-item {
|
||||||
|
padding: 12px 8px;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.file-info {
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.file-icon {
|
||||||
|
font-size: 20px !important;
|
||||||
|
}
|
||||||
|
.file-item .btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 单文件显示移动端优化 */
|
||||||
|
.single-file-container {
|
||||||
|
padding: 20px 10px;
|
||||||
|
min-height: 250px;
|
||||||
|
}
|
||||||
|
.single-file-card {
|
||||||
|
padding: 30px 20px;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.single-file-icon {
|
||||||
|
font-size: 80px !important;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.single-file-name {
|
||||||
|
font-size: 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.single-file-size {
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.single-file-download {
|
||||||
|
padding: 12px 30px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 分享不存在提示移动端优化 */
|
||||||
|
.share-not-found {
|
||||||
|
padding: 40px 15px;
|
||||||
|
}
|
||||||
|
.share-not-found-icon {
|
||||||
|
font-size: 70px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.share-not-found-title {
|
||||||
|
font-size: 20px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.share-not-found-message {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 加载状态移动端优化 */
|
||||||
|
.loading {
|
||||||
|
padding: 30px 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 超小屏幕优化 (手机竖屏) */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.card {
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 文件网格更紧凑 */
|
||||||
|
.file-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(75px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.file-grid-icon {
|
||||||
|
font-size: 36px !important;
|
||||||
|
}
|
||||||
|
.file-grid-name {
|
||||||
|
font-size: 11px;
|
||||||
|
min-height: 31px;
|
||||||
|
max-height: 31px;
|
||||||
|
}
|
||||||
|
.file-grid-size {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 单文件显示更紧凑 */
|
||||||
|
.single-file-icon {
|
||||||
|
font-size: 60px !important;
|
||||||
|
}
|
||||||
|
.single-file-name {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.single-file-size {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.single-file-download {
|
||||||
|
padding: 10px 24px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 视图切换按钮 */
|
||||||
|
.view-controls .btn {
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<div class="container">
|
||||||
|
<div class="card">
|
||||||
|
<div class="title">
|
||||||
|
<i class="fas fa-cloud"></i>
|
||||||
|
文件分享
|
||||||
|
</div>
|
||||||
|
<!-- 通用错误显示 -->
|
||||||
|
<div v-if="errorMessage && !needPassword && !verified && !shareNotFound" class="share-not-found">
|
||||||
|
<i class="fas fa-exclamation-circle share-not-found-icon" style="color: #dc3545;"></i>
|
||||||
|
<div class="share-not-found-title">访问失败</div>
|
||||||
|
<div class="share-not-found-message">{{ errorMessage }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- 密码验证 -->
|
||||||
|
<div v-if="needPassword && !verified && !shareNotFound">
|
||||||
|
<div v-if="errorMessage" class="alert alert-error">{{ errorMessage }}</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">请输入分享密码</label>
|
||||||
|
<input type="password" class="form-input" v-model="password" @keyup.enter="verifyShare" placeholder="输入密码">
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" @click="verifyShare">
|
||||||
|
<i class="fas fa-unlock"></i> 验证
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 分享不存在 -->
|
||||||
|
<div v-else-if="shareNotFound" class="share-not-found">
|
||||||
|
<i class="fas fa-inbox share-not-found-icon"></i>
|
||||||
|
<div class="share-not-found-title">来晚了~</div>
|
||||||
|
<div class="share-not-found-message">
|
||||||
|
分享的内容已经取消了<br>
|
||||||
|
或者该分享链接已过期
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 文件列表 -->
|
||||||
|
<div v-else-if="verified">
|
||||||
|
<p style="color: #666; margin-bottom: 20px;">
|
||||||
|
分享者: <strong>{{ shareInfo.username }}</strong> |
|
||||||
|
创建时间: {{ formatDate(shareInfo.created_at) }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- 视图切换按钮 (多文件时才显示) -->
|
||||||
|
<div v-if="files.length > 1" class="view-controls">
|
||||||
|
<button class="btn" :class="viewMode === 'grid' ? 'btn-primary' : 'btn-secondary'" @click="viewMode = 'grid'">
|
||||||
|
<i class="fas fa-th-large"></i> 大图标
|
||||||
|
</button>
|
||||||
|
<button class="btn" :class="viewMode === 'list' ? 'btn-primary' : 'btn-secondary'" @click="viewMode = 'list'">
|
||||||
|
<i class="fas fa-list"></i> 列表
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="loading">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>加载中...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 大图标视图 - 单文件居中显示 -->
|
||||||
|
<!-- 单文件居中显示 -->
|
||||||
|
<div v-else-if="files.length === 1" class="single-file-container">
|
||||||
|
<div class="single-file-card">
|
||||||
|
<i class="single-file-icon fas" :class="getFileIcon(files[0])" :style="getIconColor(files[0])"></i>
|
||||||
|
<div class="single-file-name">{{ files[0].name }}</div>
|
||||||
|
<div class="single-file-size">{{ files[0].sizeFormatted }}</div>
|
||||||
|
<button v-if="!files[0].isDirectory" class="btn single-file-download" @click="downloadFile(files[0])">
|
||||||
|
<i class="fas fa-download"></i> 下载文件
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- 大图标视图 - 多文件网格显示 -->
|
||||||
|
<div v-else-if="viewMode === 'grid'" class="file-grid">
|
||||||
|
<div v-for="file in files" :key="file.name" class="file-grid-item"
|
||||||
|
@click="handleFileClick(file)"
|
||||||
|
@contextmenu="showFileContextMenu($event, file)"
|
||||||
|
@touchstart="startLongPress($event, file)"
|
||||||
|
@touchend="cancelLongPress"
|
||||||
|
@touchmove="cancelLongPress">
|
||||||
|
<i class="file-grid-icon fas" :class="getFileIcon(file)" :style="getIconColor(file)"></i>
|
||||||
|
<div class="file-grid-name" :title="file.name">{{ file.name }}</div>
|
||||||
|
<div class="file-grid-size">{{ file.sizeFormatted }}</div>
|
||||||
|
<button v-if="!file.isDirectory" class="btn btn-primary" @click.stop="downloadFile(file)" style="width: 100%;">
|
||||||
|
<i class="fas fa-download"></i> 下载
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 列表视图 -->
|
||||||
|
<ul v-else class="file-list">
|
||||||
|
<li v-for="file in files" :key="file.name" class="file-item">
|
||||||
|
<div class="file-info">
|
||||||
|
<i class="file-icon fas" :class="getFileIcon(file)" :style="getIconColor(file)"></i>
|
||||||
|
<div>
|
||||||
|
<div style="font-weight: 500;">{{ file.name }}</div>
|
||||||
|
<div style="font-size: 12px; color: #999;">{{ file.sizeFormatted }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button v-if="!file.isDirectory" class="btn btn-primary" @click.stop="downloadFile(file)">
|
||||||
|
<i class="fas fa-download"></i> 下载
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p v-if="files.length === 0" style="text-align: center; color: #999; padding: 40px;">
|
||||||
|
暂无文件
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 加载中 -->
|
||||||
|
<div v-else-if="loading" class="loading">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>加载中...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const { createApp } = Vue;
|
||||||
|
|
||||||
|
createApp({
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
// API配置 - 动态适配localhost或生产环境
|
||||||
|
apiBase: window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
|
||||||
|
? 'http://localhost:40001'
|
||||||
|
: window.location.protocol + '//' + window.location.host,
|
||||||
|
shareCode: '',
|
||||||
|
password: '',
|
||||||
|
needPassword: false,
|
||||||
|
verified: false,
|
||||||
|
shareNotFound: false,
|
||||||
|
shareInfo: null,
|
||||||
|
files: [],
|
||||||
|
loading: true,
|
||||||
|
errorMessage: '',
|
||||||
|
viewMode: "grid", // 视图模式: grid 大图标, list 列表(默认大图标)
|
||||||
|
// 媒体预览
|
||||||
|
showImageViewer: false,
|
||||||
|
showVideoPlayer: false,
|
||||||
|
showAudioPlayer: false,
|
||||||
|
currentMediaUrl: '',
|
||||||
|
currentMediaName: '',
|
||||||
|
currentMediaType: '', // 'image', 'video', 'audio'
|
||||||
|
// 右键菜单
|
||||||
|
showContextMenu: false,
|
||||||
|
contextMenuX: 0,
|
||||||
|
contextMenuY: 0,
|
||||||
|
contextMenuFile: null,
|
||||||
|
// 长按支持(移动端)
|
||||||
|
longPressTimer: null,
|
||||||
|
longPressFile: null
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
async init() {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
this.shareCode = urlParams.get('code');
|
||||||
|
|
||||||
|
if (!this.shareCode) {
|
||||||
|
this.errorMessage = '无效的分享链接';
|
||||||
|
this.loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试验证分享
|
||||||
|
await this.verifyShare();
|
||||||
|
},
|
||||||
|
|
||||||
|
async verifyShare() {
|
||||||
|
this.errorMessage = '';
|
||||||
|
this.loading = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`${this.apiBase}/api/share/${this.shareCode}/verify`, {
|
||||||
|
password: this.password
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
this.verified = true;
|
||||||
|
this.shareInfo = response.data.share;
|
||||||
|
|
||||||
|
// 如果是单文件分享且后端已返回文件信息,直接使用,无需再次请求
|
||||||
|
if (response.data.file) {
|
||||||
|
this.files = [response.data.file];
|
||||||
|
this.loading = false;
|
||||||
|
} else {
|
||||||
|
// 目录分享,需要加载文件列表
|
||||||
|
await this.loadFiles();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 404错误 - 分享不存在
|
||||||
|
if (error.response?.status === 404) {
|
||||||
|
this.shareNotFound = true;
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
// 需要密码
|
||||||
|
else if (error.response?.data?.needPassword) {
|
||||||
|
this.needPassword = true;
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
// 其他错误
|
||||||
|
else {
|
||||||
|
this.errorMessage = error.response?.data?.message || '验证失败';
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadFiles() {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`${this.apiBase}/api/share/${this.shareCode}/list`, {
|
||||||
|
password: this.password,
|
||||||
|
path: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
this.files = response.data.items;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载文件失败:', error);
|
||||||
|
this.errorMessage = '加载文件失败';
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
downloadFile(file) {
|
||||||
|
console.log("[分享下载] 文件:", file);
|
||||||
|
|
||||||
|
// 记录下载次数(异步,不等待)
|
||||||
|
axios.post(`${this.apiBase}/api/share/${this.shareCode}/download`)
|
||||||
|
.catch(err => console.error('记录下载次数失败:', err));
|
||||||
|
|
||||||
|
if (file.httpDownloadUrl) {
|
||||||
|
// 如果配置了HTTP下载URL,使用HTTP直接下载
|
||||||
|
console.log("[分享下载] 使用HTTP下载:", file.httpDownloadUrl);
|
||||||
|
window.open(file.httpDownloadUrl, '_blank');
|
||||||
|
} else {
|
||||||
|
// 如果没有配置HTTP URL,通过后端SFTP下载
|
||||||
|
console.log("[分享下载] 使用SFTP下载");
|
||||||
|
|
||||||
|
// 构建文件路径
|
||||||
|
let filePath;
|
||||||
|
if (this.shareInfo.share_type === 'file') {
|
||||||
|
// 单文件分享,使用 share_path
|
||||||
|
filePath = this.shareInfo.share_path;
|
||||||
|
} else {
|
||||||
|
// 目录分享,组合路径
|
||||||
|
const basePath = this.shareInfo.share_path;
|
||||||
|
filePath = basePath === '/' ? `/${file.name}` : `${basePath}/${file.name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用分享下载API(公开API,不需要认证)
|
||||||
|
let downloadUrl = `${this.apiBase}/api/share/${this.shareCode}/download-file?path=${encodeURIComponent(filePath)}`;
|
||||||
|
|
||||||
|
// 如果有密码,附加密码参数
|
||||||
|
if (this.password) {
|
||||||
|
downloadUrl += `&password=${encodeURIComponent(this.password)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.open(downloadUrl, '_blank');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getFileIcon(file) {
|
||||||
|
if (file.isDirectory) return 'fa-folder';
|
||||||
|
if (file.name.match(/\.(jpg|jpeg|png|gif|bmp|svg)$/i)) return 'fa-file-image';
|
||||||
|
if (file.name.match(/\.(mp4|avi|mov|wmv|flv|mkv)$/i)) return 'fa-file-video';
|
||||||
|
if (file.name.match(/\.(mp3|wav|flac|aac|ogg)$/i)) return 'fa-file-audio';
|
||||||
|
if (file.name.match(/\.(pdf)$/i)) return 'fa-file-pdf';
|
||||||
|
if (file.name.match(/\.(doc|docx)$/i)) return 'fa-file-word';
|
||||||
|
if (file.name.match(/\.(xls|xlsx)$/i)) return 'fa-file-excel';
|
||||||
|
if (file.name.match(/\.(zip|rar|7z|tar|gz)$/i)) return 'fa-file-archive';
|
||||||
|
return 'fa-file';
|
||||||
|
},
|
||||||
|
|
||||||
|
getIconColor(file) {
|
||||||
|
if (file.isDirectory) return 'color: #FFC107;';
|
||||||
|
if (file.name.match(/\.(jpg|jpeg|png|gif|bmp|svg)$/i)) return 'color: #4CAF50;';
|
||||||
|
if (file.name.match(/\.(mp4|avi|mov|wmv|flv|mkv)$/i)) return 'color: #9C27B0;';
|
||||||
|
if (file.name.match(/\.(mp3|wav|flac|aac|ogg)$/i)) return 'color: #FF5722;';
|
||||||
|
if (file.name.match(/\.(pdf)$/i)) return 'color: #F44336;';
|
||||||
|
if (file.name.match(/\.(doc|docx)$/i)) return 'color: #2196F3;';
|
||||||
|
if (file.name.match(/\.(xls|xlsx)$/i)) return 'color: #4CAF50;';
|
||||||
|
if (file.name.match(/\.(zip|rar|7z|tar|gz)$/i)) return 'color: #795548;';
|
||||||
|
return 'color: #9E9E9E;';
|
||||||
|
},
|
||||||
|
|
||||||
|
formatDate(dateString) {
|
||||||
|
if (!dateString) return '';
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleString('zh-CN');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
}).mount('#app');
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
34
nginx/nginx.conf
Normal file
34
nginx/nginx.conf
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
|
||||||
|
# 前端静态文件
|
||||||
|
location / {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
try_files $uri $uri/ =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 后端API反向代理
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:40001;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
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 $http_x_forwarded_proto;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 分享链接重定向
|
||||||
|
location /s/ {
|
||||||
|
proxy_pass http://backend:40001;
|
||||||
|
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 $http_x_forwarded_proto;
|
||||||
|
}
|
||||||
|
}
|
||||||
52
nginx/nginx.conf.example
Normal file
52
nginx/nginx.conf.example
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name your-domain.com;
|
||||||
|
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/certbot;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
return 301 https://$server_name$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name your-domain.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
|
||||||
|
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
ssl_prefer_server_ciphers on;
|
||||||
|
|
||||||
|
# 前端静态文件
|
||||||
|
location / {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
try_files $uri $uri/ =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 后端API反向代理
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:40001;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
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_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 分享链接重定向
|
||||||
|
location /s/ {
|
||||||
|
proxy_pass http://backend:40001;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
|
}
|
||||||
92
upload-tool/README.txt
Normal file
92
upload-tool/README.txt
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
============================================
|
||||||
|
玩玩云上传工具 v2.0 使用说明
|
||||||
|
============================================
|
||||||
|
|
||||||
|
【新版本特性】
|
||||||
|
✨ 支持多文件上传
|
||||||
|
✨ 支持文件夹上传(递归扫描所有文件)
|
||||||
|
✨ 智能上传队列管理
|
||||||
|
✨ 自动检测可写目录(容错机制)
|
||||||
|
✨ 实时显示队列状态
|
||||||
|
|
||||||
|
【功能介绍】
|
||||||
|
本工具用于快速上传文件到您的SFTP服务器。
|
||||||
|
新版本支持批量上传和文件夹上传,大大提升工作效率!
|
||||||
|
|
||||||
|
【使用方法】
|
||||||
|
1. 双击运行"玩玩云上传工具.exe"
|
||||||
|
2. 等待程序连接服务器并测试上传目录
|
||||||
|
- 程序会自动测试多个目录的可写性
|
||||||
|
- 显示绿色✓表示连接成功
|
||||||
|
- 显示当前使用的上传目录
|
||||||
|
3. 拖拽文件或文件夹到窗口中
|
||||||
|
- 可以一次拖拽多个文件
|
||||||
|
- 可以拖拽整个文件夹(自动扫描所有文件)
|
||||||
|
- 混合拖拽也支持
|
||||||
|
4. 查看队列状态
|
||||||
|
- 界面显示"队列: X 个文件等待上传"
|
||||||
|
- 文件会按顺序依次上传
|
||||||
|
5. 实时查看上传进度
|
||||||
|
- 每个文件都有独立的进度显示
|
||||||
|
- 日志区域显示详细的上传信息
|
||||||
|
|
||||||
|
【目录容错机制】
|
||||||
|
程序会按以下优先级自动测试并选择可写目录:
|
||||||
|
1. /(根目录)
|
||||||
|
2. /upload
|
||||||
|
3. /uploads
|
||||||
|
4. /files
|
||||||
|
5. /home
|
||||||
|
6. /tmp
|
||||||
|
|
||||||
|
如果根目录没有写权限,程序会自动切换到其他可用目录。
|
||||||
|
|
||||||
|
【注意事项】
|
||||||
|
- 文件夹上传会递归扫描所有子文件夹
|
||||||
|
- 同名文件会被覆盖
|
||||||
|
- 上传大量文件时请确保网络稳定
|
||||||
|
- 所有文件会按顺序依次上传
|
||||||
|
- 上传目录会在启动时自动检测并显示
|
||||||
|
|
||||||
|
【界面说明】
|
||||||
|
- 拖拽区域:显示"支持多文件和文件夹"
|
||||||
|
- 队列状态:显示等待上传的文件数量
|
||||||
|
- 进度条:显示当前文件的上传进度
|
||||||
|
- 日志区域:显示详细的操作记录
|
||||||
|
|
||||||
|
【版本更新】
|
||||||
|
v2.0 (2025-11-09)
|
||||||
|
- ✅ 新增多文件上传支持
|
||||||
|
- ✅ 新增文件夹上传支持
|
||||||
|
- ✅ 新增上传队列管理
|
||||||
|
- ✅ 新增目录容错机制
|
||||||
|
- ✅ 优化界面显示
|
||||||
|
- ✅ 优化日志输出
|
||||||
|
|
||||||
|
v1.0
|
||||||
|
- 基础单文件上传功能
|
||||||
|
|
||||||
|
【常见问题】
|
||||||
|
|
||||||
|
Q: 支持上传多少个文件?
|
||||||
|
A: 理论上无限制,所有文件会加入队列依次上传
|
||||||
|
|
||||||
|
Q: 文件夹上传包括子文件夹吗?
|
||||||
|
A: 是的,会递归扫描所有子文件夹中的文件
|
||||||
|
|
||||||
|
Q: 上传目录是哪里?
|
||||||
|
A: 程序启动时会自动检测并显示在界面上
|
||||||
|
|
||||||
|
Q: 提示"API密钥无效或已过期"怎么办?
|
||||||
|
A: 请重新从网站下载最新的上传工具
|
||||||
|
|
||||||
|
Q: 上传速度慢怎么办?
|
||||||
|
A: 速度取决于您的网络和SFTP服务器性能
|
||||||
|
|
||||||
|
Q: 可以中途取消上传吗?
|
||||||
|
A: 当前版本暂不支持取消,请等待队列完成
|
||||||
|
|
||||||
|
【技术支持】
|
||||||
|
如有问题请联系管理员
|
||||||
|
|
||||||
|
============================================
|
||||||
52
upload-tool/build.bat
Normal file
52
upload-tool/build.bat
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
@echo off
|
||||||
|
chcp 65001 > nul
|
||||||
|
echo ========================================
|
||||||
|
echo 玩玩云上传工具打包脚本
|
||||||
|
echo ========================================
|
||||||
|
echo.
|
||||||
|
|
||||||
|
REM 检查Python是否安装
|
||||||
|
python --version > nul 2>&1
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo [错误] 未检测到Python,请先安装Python 3.7+
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo [1/4] 安装依赖包...
|
||||||
|
pip install -r requirements.txt
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo [错误] 依赖安装失败
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo [2/4] 安装PyInstaller...
|
||||||
|
pip install pyinstaller
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo [错误] PyInstaller安装失败
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo [3/4] 打包程序...
|
||||||
|
pyinstaller --onefile --windowed --name="玩玩云上传工具" --icon=NONE upload_tool.py
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo [错误] 打包失败
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo [4/4] 清理临时文件...
|
||||||
|
rmdir /s /q build
|
||||||
|
del /q *.spec
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ========================================
|
||||||
|
echo 打包完成!
|
||||||
|
echo 输出文件: dist\玩玩云上传工具.exe
|
||||||
|
echo ========================================
|
||||||
|
pause
|
||||||
3
upload-tool/requirements.txt
Normal file
3
upload-tool/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
PyQt5==5.15.9
|
||||||
|
paramiko==3.4.0
|
||||||
|
requests==2.31.0
|
||||||
499
upload-tool/upload_tool.py
Normal file
499
upload-tool/upload_tool.py
Normal file
@@ -0,0 +1,499 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import requests
|
||||||
|
import paramiko
|
||||||
|
from PyQt5.QtWidgets import (QApplication, QMainWindow, QLabel, QVBoxLayout,
|
||||||
|
QWidget, QProgressBar, QTextEdit, QPushButton)
|
||||||
|
from PyQt5.QtCore import Qt, QThread, pyqtSignal
|
||||||
|
from PyQt5.QtGui import QDragEnterEvent, QDropEvent, QFont
|
||||||
|
|
||||||
|
class TestDirectoryThread(QThread):
|
||||||
|
"""测试目录可写性线程"""
|
||||||
|
result = pyqtSignal(bool, str) # 成功/失败,目录路径或错误信息
|
||||||
|
|
||||||
|
def __init__(self, test_dir, sftp_config):
|
||||||
|
super().__init__()
|
||||||
|
self.test_dir = test_dir
|
||||||
|
self.sftp_config = sftp_config
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
try:
|
||||||
|
transport = paramiko.Transport((
|
||||||
|
self.sftp_config['host'],
|
||||||
|
self.sftp_config['port']
|
||||||
|
))
|
||||||
|
transport.connect(
|
||||||
|
username=self.sftp_config['username'],
|
||||||
|
password=self.sftp_config['password']
|
||||||
|
)
|
||||||
|
sftp = paramiko.SFTPClient.from_transport(transport)
|
||||||
|
|
||||||
|
# 测试文件名
|
||||||
|
test_file = f"{self.test_dir}/.wwy_test_{os.getpid()}"
|
||||||
|
if not self.test_dir.endswith('/'):
|
||||||
|
test_file = f"{self.test_dir}/.wwy_test_{os.getpid()}"
|
||||||
|
else:
|
||||||
|
test_file = f"{self.test_dir}.wwy_test_{os.getpid()}"
|
||||||
|
|
||||||
|
# 尝试创建测试文件
|
||||||
|
try:
|
||||||
|
with sftp.open(test_file, 'w') as f:
|
||||||
|
f.write('test')
|
||||||
|
|
||||||
|
# 删除测试文件
|
||||||
|
try:
|
||||||
|
sftp.remove(test_file)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
sftp.close()
|
||||||
|
transport.close()
|
||||||
|
self.result.emit(True, self.test_dir)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
sftp.close()
|
||||||
|
transport.close()
|
||||||
|
self.result.emit(False, str(e))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.result.emit(False, str(e))
|
||||||
|
|
||||||
|
class UploadThread(QThread):
|
||||||
|
"""上传线程"""
|
||||||
|
progress = pyqtSignal(int, str) # 进度,状态信息
|
||||||
|
finished = pyqtSignal(bool, str) # 成功/失败,消息
|
||||||
|
|
||||||
|
def __init__(self, sftp_config, file_path, remote_dir):
|
||||||
|
super().__init__()
|
||||||
|
self.sftp_config = sftp_config
|
||||||
|
self.file_path = file_path
|
||||||
|
self.remote_dir = remote_dir
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
try:
|
||||||
|
# 连接SFTP
|
||||||
|
self.progress.emit(10, f'正在连接服务器...')
|
||||||
|
transport = paramiko.Transport((
|
||||||
|
self.sftp_config['host'],
|
||||||
|
self.sftp_config['port']
|
||||||
|
))
|
||||||
|
transport.connect(
|
||||||
|
username=self.sftp_config['username'],
|
||||||
|
password=self.sftp_config['password']
|
||||||
|
)
|
||||||
|
sftp = paramiko.SFTPClient.from_transport(transport)
|
||||||
|
|
||||||
|
self.progress.emit(30, f'连接成功,开始上传...')
|
||||||
|
|
||||||
|
# 获取文件名
|
||||||
|
filename = os.path.basename(self.file_path)
|
||||||
|
# 构建远程路径
|
||||||
|
if self.remote_dir.endswith('/'):
|
||||||
|
remote_path = f'{self.remote_dir}{filename}'
|
||||||
|
else:
|
||||||
|
remote_path = f'{self.remote_dir}/{filename}'
|
||||||
|
|
||||||
|
# 上传文件(带进度)- 使用临时文件避免.fuse_hidden问题
|
||||||
|
file_size = os.path.getsize(self.file_path)
|
||||||
|
uploaded = 0
|
||||||
|
|
||||||
|
# 使用临时文件名
|
||||||
|
import time
|
||||||
|
temp_remote_path = f'{remote_path}.uploading_{int(time.time() * 1000)}'
|
||||||
|
|
||||||
|
def callback(transferred, total):
|
||||||
|
nonlocal uploaded
|
||||||
|
uploaded = transferred
|
||||||
|
percent = int((transferred / total) * 100) if total > 0 else 0
|
||||||
|
# 进度从30%到90%
|
||||||
|
progress_value = 30 + int(percent * 0.6)
|
||||||
|
|
||||||
|
# 计算速度
|
||||||
|
size_mb = transferred / (1024 * 1024)
|
||||||
|
self.progress.emit(progress_value, f'上传中: {filename} ({size_mb:.2f} MB / {total/(1024*1024):.2f} MB)')
|
||||||
|
|
||||||
|
# 第一步:上传到临时文件
|
||||||
|
sftp.put(self.file_path, temp_remote_path, callback=callback)
|
||||||
|
|
||||||
|
# 第二步:删除旧文件(如果存在)
|
||||||
|
try:
|
||||||
|
sftp.stat(remote_path)
|
||||||
|
sftp.remove(remote_path)
|
||||||
|
except:
|
||||||
|
pass # 文件不存在,无需删除
|
||||||
|
|
||||||
|
# 第三步:重命名临时文件为目标文件
|
||||||
|
sftp.rename(temp_remote_path, remote_path)
|
||||||
|
|
||||||
|
# 关闭连接
|
||||||
|
sftp.close()
|
||||||
|
transport.close()
|
||||||
|
|
||||||
|
self.progress.emit(100, f'上传完成!')
|
||||||
|
self.finished.emit(True, f'文件 {filename} 上传成功!')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.finished.emit(False, f'上传失败: {str(e)}')
|
||||||
|
|
||||||
|
|
||||||
|
class UploadWindow(QMainWindow):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.config = self.load_config()
|
||||||
|
self.sftp_config = None
|
||||||
|
self.remote_dir = '/' # 默认上传目录
|
||||||
|
self.upload_queue = [] # 上传队列
|
||||||
|
self.is_uploading = False # 是否正在上传
|
||||||
|
self.initUI()
|
||||||
|
self.get_sftp_config()
|
||||||
|
|
||||||
|
def load_config(self):
|
||||||
|
"""加载配置文件"""
|
||||||
|
try:
|
||||||
|
# PyInstaller打包后使用sys._MEIPASS
|
||||||
|
if getattr(sys, 'frozen', False):
|
||||||
|
# 打包后的exe
|
||||||
|
base_path = os.path.dirname(sys.executable)
|
||||||
|
else:
|
||||||
|
# 开发环境
|
||||||
|
base_path = os.path.dirname(__file__)
|
||||||
|
|
||||||
|
config_path = os.path.join(base_path, 'config.json')
|
||||||
|
|
||||||
|
if not os.path.exists(config_path):
|
||||||
|
from PyQt5.QtWidgets import QMessageBox
|
||||||
|
QMessageBox.critical(None, '错误', f'找不到配置文件: {config_path}\n\n请确保config.json与程序在同一目录下!')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
with open(config_path, 'r', encoding='utf-8') as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception as e:
|
||||||
|
from PyQt5.QtWidgets import QMessageBox
|
||||||
|
QMessageBox.critical(None, '错误', f'加载配置失败:\n{str(e)}')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def get_sftp_config(self):
|
||||||
|
"""从服务器获取SFTP配置"""
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
f"{self.config['api_base_url']}/api/upload/get-config",
|
||||||
|
json={'api_key': self.config['api_key']},
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
if data['success']:
|
||||||
|
self.sftp_config = data['sftp_config']
|
||||||
|
# 自动测试并设置上传目录
|
||||||
|
self.test_and_set_upload_directory()
|
||||||
|
else:
|
||||||
|
self.show_error(data.get('message', '获取配置失败'))
|
||||||
|
else:
|
||||||
|
self.show_error(f'服务器错误: {response.status_code}')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.show_error(f'无法连接到服务器: {str(e)}')
|
||||||
|
|
||||||
|
def test_and_set_upload_directory(self):
|
||||||
|
"""测试并设置上传目录"""
|
||||||
|
if not self.sftp_config:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.log('开始测试上传目录...')
|
||||||
|
self.status_label.setText(
|
||||||
|
f'<h2>玩玩云上传工具 v2.0</h2>'
|
||||||
|
f'<p style="color: orange;">正在测试上传目录...</p>'
|
||||||
|
)
|
||||||
|
|
||||||
|
# 按优先级测试目录
|
||||||
|
self.test_dirs = ['/', '/upload', '/uploads', '/files', '/home', '/tmp']
|
||||||
|
self.current_test_index = 0
|
||||||
|
|
||||||
|
self.test_next_directory()
|
||||||
|
|
||||||
|
def test_next_directory(self):
|
||||||
|
"""测试下一个目录"""
|
||||||
|
if self.current_test_index >= len(self.test_dirs):
|
||||||
|
self.log('✗ 所有目录都无法写入,请检查SFTP权限')
|
||||||
|
self.show_error('无法找到可写入的目录,请检查SFTP权限')
|
||||||
|
return
|
||||||
|
|
||||||
|
test_dir = self.test_dirs[self.current_test_index]
|
||||||
|
self.log(f'测试目录: {test_dir}')
|
||||||
|
|
||||||
|
self.test_thread = TestDirectoryThread(test_dir, self.sftp_config)
|
||||||
|
self.test_thread.result.connect(self.on_test_result)
|
||||||
|
self.test_thread.start()
|
||||||
|
|
||||||
|
def on_test_result(self, success, message):
|
||||||
|
"""处理测试结果"""
|
||||||
|
if success:
|
||||||
|
self.remote_dir = message
|
||||||
|
self.log(f'✓ 已设置上传目录为: {message}')
|
||||||
|
self.status_label.setText(
|
||||||
|
f'<h2>玩玩云上传工具 v2.0</h2>'
|
||||||
|
f'<p style="color: green;">✓ 已连接 - 用户: {self.config["username"]}</p>'
|
||||||
|
f'<p style="color: #666; font-size: 14px;">拖拽文件/文件夹到此处上传</p>'
|
||||||
|
f'<p style="color: #999; font-size: 12px;">上传目录: {self.remote_dir}</p>'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.log(f'✗ 目录 {self.test_dirs[self.current_test_index]} 不可写: {message}')
|
||||||
|
self.current_test_index += 1
|
||||||
|
self.test_next_directory()
|
||||||
|
|
||||||
|
def show_error(self, message):
|
||||||
|
"""显示错误信息"""
|
||||||
|
self.status_label.setText(
|
||||||
|
f'<h2>玩玩云上传工具 v2.0</h2>'
|
||||||
|
f'<p style="color: red;">✗ 错误: {message}</p>'
|
||||||
|
f'<p style="color: #666; font-size: 14px;">请检查网络连接或联系管理员</p>'
|
||||||
|
)
|
||||||
|
|
||||||
|
def initUI(self):
|
||||||
|
"""初始化界面"""
|
||||||
|
self.setWindowTitle('玩玩云上传工具 v2.0')
|
||||||
|
self.setGeometry(300, 300, 500, 450)
|
||||||
|
|
||||||
|
# 设置接受拖拽
|
||||||
|
self.setAcceptDrops(True)
|
||||||
|
|
||||||
|
# 中心部件
|
||||||
|
central_widget = QWidget()
|
||||||
|
self.setCentralWidget(central_widget)
|
||||||
|
|
||||||
|
# 布局
|
||||||
|
layout = QVBoxLayout()
|
||||||
|
|
||||||
|
# 状态标签
|
||||||
|
self.status_label = QLabel('正在连接服务器...')
|
||||||
|
self.status_label.setAlignment(Qt.AlignCenter)
|
||||||
|
self.status_label.setFont(QFont('Arial', 11))
|
||||||
|
self.status_label.setWordWrap(True)
|
||||||
|
self.status_label.setStyleSheet('padding: 20px;')
|
||||||
|
layout.addWidget(self.status_label)
|
||||||
|
|
||||||
|
# 拖拽提示区域
|
||||||
|
self.drop_area = QLabel('📁\n\n支持多文件和文件夹')
|
||||||
|
self.drop_area.setAlignment(Qt.AlignCenter)
|
||||||
|
self.drop_area.setStyleSheet("""
|
||||||
|
QLabel {
|
||||||
|
font-size: 50px;
|
||||||
|
color: #667eea;
|
||||||
|
border: 3px dashed #667eea;
|
||||||
|
border-radius: 10px;
|
||||||
|
background-color: #f5f7fa;
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
layout.addWidget(self.drop_area)
|
||||||
|
|
||||||
|
# 队列状态标签
|
||||||
|
self.queue_label = QLabel('队列: 0 个文件等待上传')
|
||||||
|
self.queue_label.setAlignment(Qt.AlignCenter)
|
||||||
|
self.queue_label.setStyleSheet('color: #2c3e50; font-weight: bold; padding: 5px;')
|
||||||
|
layout.addWidget(self.queue_label)
|
||||||
|
|
||||||
|
# 进度条
|
||||||
|
self.progress_bar = QProgressBar()
|
||||||
|
self.progress_bar.setValue(0)
|
||||||
|
self.progress_bar.setVisible(False)
|
||||||
|
self.progress_bar.setStyleSheet("""
|
||||||
|
QProgressBar {
|
||||||
|
border: 2px solid #e0e0e0;
|
||||||
|
border-radius: 5px;
|
||||||
|
text-align: center;
|
||||||
|
height: 25px;
|
||||||
|
}
|
||||||
|
QProgressBar::chunk {
|
||||||
|
background-color: #667eea;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
layout.addWidget(self.progress_bar)
|
||||||
|
|
||||||
|
# 进度信息
|
||||||
|
self.progress_label = QLabel('')
|
||||||
|
self.progress_label.setAlignment(Qt.AlignCenter)
|
||||||
|
self.progress_label.setVisible(False)
|
||||||
|
layout.addWidget(self.progress_label)
|
||||||
|
|
||||||
|
# 日志区域
|
||||||
|
self.log_text = QTextEdit()
|
||||||
|
self.log_text.setReadOnly(True)
|
||||||
|
self.log_text.setMaximumHeight(100)
|
||||||
|
self.log_text.setStyleSheet("""
|
||||||
|
QTextEdit {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 5px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
layout.addWidget(self.log_text)
|
||||||
|
|
||||||
|
central_widget.setLayout(layout)
|
||||||
|
|
||||||
|
self.log('程序已启动 - 版本 v2.0')
|
||||||
|
|
||||||
|
def log(self, message):
|
||||||
|
"""添加日志"""
|
||||||
|
self.log_text.append(f'[{self.get_time()}] {message}')
|
||||||
|
# 自动滚动到底部
|
||||||
|
self.log_text.verticalScrollBar().setValue(
|
||||||
|
self.log_text.verticalScrollBar().maximum()
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_time(self):
|
||||||
|
"""获取当前时间"""
|
||||||
|
from datetime import datetime
|
||||||
|
return datetime.now().strftime('%H:%M:%S')
|
||||||
|
|
||||||
|
def dragEnterEvent(self, event: QDragEnterEvent):
|
||||||
|
"""拖拽进入事件"""
|
||||||
|
if event.mimeData().hasUrls():
|
||||||
|
event.acceptProposedAction()
|
||||||
|
self.drop_area.setStyleSheet("""
|
||||||
|
QLabel {
|
||||||
|
font-size: 50px;
|
||||||
|
color: #667eea;
|
||||||
|
border: 3px dashed #667eea;
|
||||||
|
border-radius: 10px;
|
||||||
|
background-color: #e8ecf7;
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
|
||||||
|
def dragLeaveEvent(self, event):
|
||||||
|
"""拖拽离开事件"""
|
||||||
|
self.drop_area.setStyleSheet("""
|
||||||
|
QLabel {
|
||||||
|
font-size: 50px;
|
||||||
|
color: #667eea;
|
||||||
|
border: 3px dashed #667eea;
|
||||||
|
border-radius: 10px;
|
||||||
|
background-color: #f5f7fa;
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
|
||||||
|
def dropEvent(self, event: QDropEvent):
|
||||||
|
"""拖拽放下事件"""
|
||||||
|
self.drop_area.setStyleSheet("""
|
||||||
|
QLabel {
|
||||||
|
font-size: 50px;
|
||||||
|
color: #667eea;
|
||||||
|
border: 3px dashed #667eea;
|
||||||
|
border-radius: 10px;
|
||||||
|
background-color: #f5f7fa;
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
|
||||||
|
if not self.sftp_config:
|
||||||
|
self.log('错误: 未获取到SFTP配置')
|
||||||
|
return
|
||||||
|
|
||||||
|
paths = [url.toLocalFile() for url in event.mimeData().urls()]
|
||||||
|
|
||||||
|
all_files = []
|
||||||
|
for path in paths:
|
||||||
|
if os.path.isfile(path):
|
||||||
|
all_files.append(path)
|
||||||
|
elif os.path.isdir(path):
|
||||||
|
self.log(f'扫描文件夹: {os.path.basename(path)}')
|
||||||
|
folder_files = self.scan_folder(path)
|
||||||
|
all_files.extend(folder_files)
|
||||||
|
self.log(f'找到 {len(folder_files)} 个文件')
|
||||||
|
|
||||||
|
if all_files:
|
||||||
|
self.upload_queue.extend(all_files)
|
||||||
|
self.update_queue_label()
|
||||||
|
self.log(f'添加 {len(all_files)} 个文件到上传队列')
|
||||||
|
|
||||||
|
if not self.is_uploading:
|
||||||
|
self.process_upload_queue()
|
||||||
|
|
||||||
|
def scan_folder(self, folder_path):
|
||||||
|
"""递归扫描文件夹"""
|
||||||
|
files = []
|
||||||
|
try:
|
||||||
|
for root, dirs, filenames in os.walk(folder_path):
|
||||||
|
for filename in filenames:
|
||||||
|
file_path = os.path.join(root, filename)
|
||||||
|
files.append(file_path)
|
||||||
|
except Exception as e:
|
||||||
|
self.log(f'扫描文件夹失败: {str(e)}')
|
||||||
|
|
||||||
|
return files
|
||||||
|
|
||||||
|
def update_queue_label(self):
|
||||||
|
"""更新队列标签"""
|
||||||
|
count = len(self.upload_queue)
|
||||||
|
self.queue_label.setText(f'队列: {count} 个文件等待上传')
|
||||||
|
|
||||||
|
def process_upload_queue(self):
|
||||||
|
"""处理上传队列"""
|
||||||
|
if not self.upload_queue:
|
||||||
|
self.is_uploading = False
|
||||||
|
self.update_queue_label()
|
||||||
|
self.log('✓ 所有文件上传完成!')
|
||||||
|
return
|
||||||
|
|
||||||
|
self.is_uploading = True
|
||||||
|
file_path = self.upload_queue.pop(0)
|
||||||
|
self.update_queue_label()
|
||||||
|
|
||||||
|
self.upload_file(file_path)
|
||||||
|
|
||||||
|
def upload_file(self, file_path):
|
||||||
|
"""上传文件"""
|
||||||
|
self.log(f'开始上传: {os.path.basename(file_path)}')
|
||||||
|
|
||||||
|
# 显示进度控件
|
||||||
|
self.progress_bar.setVisible(True)
|
||||||
|
self.progress_bar.setValue(0)
|
||||||
|
self.progress_label.setVisible(True)
|
||||||
|
self.progress_label.setText('准备上传...')
|
||||||
|
|
||||||
|
# 创建上传线程
|
||||||
|
self.upload_thread = UploadThread(self.sftp_config, file_path, self.remote_dir)
|
||||||
|
self.upload_thread.progress.connect(self.on_progress)
|
||||||
|
self.upload_thread.finished.connect(self.on_finished)
|
||||||
|
self.upload_thread.start()
|
||||||
|
|
||||||
|
def on_progress(self, value, message):
|
||||||
|
"""上传进度更新"""
|
||||||
|
self.progress_bar.setValue(value)
|
||||||
|
self.progress_label.setText(message)
|
||||||
|
|
||||||
|
def on_finished(self, success, message):
|
||||||
|
"""上传完成"""
|
||||||
|
self.log(message)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
self.progress_label.setText('✓ ' + message)
|
||||||
|
self.progress_label.setStyleSheet('color: green; font-weight: bold;')
|
||||||
|
else:
|
||||||
|
self.progress_label.setText('✗ ' + message)
|
||||||
|
self.progress_label.setStyleSheet('color: red; font-weight: bold;')
|
||||||
|
|
||||||
|
# 继续处理队列
|
||||||
|
from PyQt5.QtCore import QTimer
|
||||||
|
QTimer.singleShot(1000, self.process_upload_queue)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
window = UploadWindow()
|
||||||
|
window.show()
|
||||||
|
sys.exit(app.exec_())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user