Initial commit - 玩玩云文件管理系统 v1.0.0

- 完整的前后端代码
- 支持本地存储和SFTP存储
- 文件分享功能
- 上传工具源代码
- 完整的部署文档
- Nginx配置模板

技术栈:
- 后端: Node.js + Express + SQLite
- 前端: Vue.js 3 + Axios
- 存储: 本地存储 / SFTP远程存储
This commit is contained in:
WanWanYun
2025-11-10 21:50:16 +08:00
commit 0f133962dc
36 changed files with 32178 additions and 0 deletions

92
upload-tool/README.txt Normal file
View 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
View 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

View 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
View 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()