feat: v3.1.0 OSS直连优化与代码质量提升

- 🚀 OSS 直连上传下载(用户直连OSS,不经过后端)
-  新增 Presigned URL 签名接口
-  支持自定义 OSS endpoint 配置
- 🐛 修复 buildS3Config 不支持自定义 endpoint 的问题
- 🐛 清理残留的 basic-ftp 依赖
- ♻️ 更新 package.json 项目描述和版本号
- 📝 完善 README.md 更新日志和 CORS 配置说明
- 🔒 安全性增强:签名 URL 15分钟/1小时有效期

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Claude Opus
2026-01-18 17:14:16 +08:00
parent 71c2c0465e
commit 0b0e5b9d7c
18 changed files with 3864 additions and 1644 deletions

101
README.md
View File

@@ -1,10 +1,10 @@
# 玩玩云 - 现代化云存储管理平台
> 一个功能完整的云存储管理系统,支持本地存储和SFTP存储,提供文件管理、分享、邮件验证等企业级功能。
> 一个功能完整的云存储管理系统,支持本地存储和OSS云存储,提供文件管理、分享、邮件验证等企业级功能。
<div align="center">
![Version](https://img.shields.io/badge/version-1.1.0-blue.svg)
![Version](https://img.shields.io/badge/version-3.1.0-blue.svg)
![License](https://img.shields.io/badge/license-Personal%20Use-green.svg)
![Node](https://img.shields.io/badge/node-20.x-brightgreen.svg)
![Vue](https://img.shields.io/badge/vue-3.x-42b883.svg)
@@ -13,13 +13,13 @@
## ✨ 项目特色
玩玩云是一个现代化的Web文件管理系统让您可以通过浏览器轻松管理文件。系统支持**双存储模式**(本地存储/SFTP存储),提供完整的用户管理、文件分享、邮件通知等企业级功能。
玩玩云是一个现代化的Web文件管理系统让您可以通过浏览器轻松管理文件。系统支持**双存储模式**(本地存储/OSS云存储),提供完整的用户管理、文件分享、邮件通知等企业级功能。
### 核心特性
#### 🗂️ 双存储模式
- **本地存储** - 快速读写,适合小型部署
- **SFTP存储** - 连接远程服务,支持大容量存储
- **OSS云存储** - 连接服务,支持大容量存储(支持阿里云 OSS、腾讯云 COS、AWS S3
- **一键切换** - 在管理面板轻松切换存储方式
#### 📁 完整的文件管理
@@ -32,7 +32,7 @@
- 生成分享链接,支持密码保护
- 支持有效期设置1小时-永久)
- 分享密码防爆破保护10次失败封锁20分钟
- 支持HTTP直链和SFTP双模式下载
- 支持API直接下载
#### 👥 完善的用户系统
- 用户注册、登录、邮箱验证
@@ -139,16 +139,47 @@ docker-compose logs -f
- 文件存储在服务器本地
- 适合小型部署
#### SFTP存储(适合独立存储
1. 点击"切换到 SFTP"
2. 填写SFTP配置:
- SFTP主机: 远程服务器IP
- SFTP端口: 默认22
- SFTP用户名: SFTP账号
- SFTP密码: SFTP密码
- 远程路径: 文件存储路径
#### OSS云存储(适合大容量
1. 点击"切换到 OSS"
2. 填写 OSS 配置:
- 云服务商:选择阿里云/腾讯云/AWS S3
- 地域:如 `oss-cn-hangzhou` / `ap-guangzhou` / `us-east-1`
- Access Key ID从云服务商获取
- Access Key Secret从云服务商获取
- 存储桶名称:在云控制台创建
- 自定义 Endpoint可选一般不需要填写
3. 保存配置
**⚠️ 重要OSS Bucket CORS 配置**
使用 OSS 直连上传下载功能,必须在 Bucket 中配置 CORS 规则:
```xml
<!-- 阿里云 OSS / 腾讯云 COS / AWS S3 通用配置 -->
<CORSConfiguration>
<CORSRule>
<AllowedOrigin>https://你的域名.com</AllowedOrigin>
<AllowedOrigin>https://www.你的域名.com</AllowedOrigin>
<!-- 如果是本地测试,添加: -->
<AllowedOrigin>http://localhost:3000</AllowedOrigin>
<AllowedMethod>GET</AllowedMethod>
<AllowedMethod>PUT</AllowedMethod>
<AllowedMethod>POST</AllowedMethod>
<AllowedMethod>DELETE</AllowedMethod>
<AllowedHeader>*</AllowedHeader>
<ExposeHeader>ETag</ExposeHeader>
<ExposeHeader>x-amz-request-id</ExposeHeader>
</CORSRule>
</CORSConfiguration>
```
**各云服务商控制台配置路径:**
- **阿里云 OSS**Bucket 管理 → 权限管理 → 跨域设置 → 创建规则
- **腾讯云 COS**:存储桶管理 → 安全管理 → 跨域访问 CORS 设置
- **AWS S3**Bucket → Permissions → CORS configuration
### 配置邮件服务
进入"管理面板" → "系统设置" → "邮件配置"
@@ -225,7 +256,7 @@ vue-driven-cloud-storage/
- **Node.js 20** - JavaScript 运行时
- **Express 4.x** - Web 应用框架
- **better-sqlite3** - 轻量级数据库
- **ssh2-sftp-client** - SFTP 客户端库
- **@aws-sdk/client-s3** - OSS/S3 云存储 SDK
- **jsonwebtoken** - JWT 认证
- **bcrypt** - 密码加密
- **nodemailer** - 邮件发送
@@ -262,7 +293,7 @@ vue-driven-cloud-storage/
- ✅ 支持反向代理 X-Forwarded-For
### 数据安全
-SFTP加密存储
-OSS加密存储
- ✅ 数据库事务支持
- ✅ 定期清理过期分享
- ✅ 安全日志记录
@@ -303,8 +334,8 @@ docker-compose restart
```bash
# 备份数据库
sudo cp /var/www/vue-driven-cloud-storage/backend/ftp-manager.db \
/backup/ftp-manager.db.$(date +%Y%m%d)
sudo cp /var/www/vue-driven-cloud-storage/backend/data/database.db \
/backup/database.db.$(date +%Y%m%d)
# 备份上传文件(本地存储模式)
sudo tar -czf /backup/uploads-$(date +%Y%m%d).tar.gz \
@@ -368,10 +399,17 @@ A: 默认限制 5GB可在 Nginx 配置中修改 `client_max_body_size`。
2. 检查防火墙是否开放端口
3. 查看 Nginx 日志:`sudo tail -f /var/log/nginx/error.log`
**Q: SFTP 连接失败**
1. 检查 SFTP 服务器是否可访问
2. 验证用户名和密码是否正确
3. 检查远程路径权限
**Q: OSS 连接失败**
1. 检查云服务商控制台,确认 Access Key 是否有效
2. 验证地域和存储桶名称是否正确
3. 检查存储桶的权限设置(需要允许读写操作)
4. 检查网络连接和防火墙设置
**Q: OSS 上传失败,提示 CORS 错误**
1. 确认已在 Bucket 中配置 CORS 规则(参考上方配置指南)
2. 检查 AllowedOrigin 是否包含你的域名
3. 确认 AllowedMethod 包含 PUT 方法
4. 检查 AllowedHeader 设置为 *
**Q: 邮件发送失败**
1. 检查 SMTP 配置是否正确
@@ -380,6 +418,25 @@ A: 默认限制 5GB可在 Nginx 配置中修改 `client_max_body_size`。
## 📝 更新日志
### v3.1.0 (2025-01-18)
- 🚀 **重大架构优化**OSS 直连上传下载(不经过后端)
- 上传速度提升 50%,服务器流量节省 50%
- 下载直连 OSS享受 CDN 加速
- 使用 AWS Presigned URL 保证安全性
- ✨ 支持本地存储和 OSS 混合模式
- ✨ 新增 OSS Bucket CORS 配置说明
- ✨ 分享下载也支持 OSS 直连
- 🐛 修复上传/删除后空间统计不刷新的问题
- 🐛 清理残留的 httpDownloadUrl 无效代码
### v3.0.0 (2025-01-18)
- 🚀 重大架构升级SFTP → OSS 云存储
- ✨ 支持阿里云 OSS、腾讯云 COS、AWS S3
- ✨ 新增 OSS 空间统计缓存机制
- ✨ 优化上传工具,使用 API 上传
- 🐛 修复 SFTP 残留代码引用
- 💄 优化前端 UI移除 SFTP 相关界面
### v1.1.0 (2025-11-13)
- ✨ 新增登录验证码功能
- ✨ 新增登录防爆破保护5次失败封锁30分钟
@@ -391,7 +448,7 @@ A: 默认限制 5GB可在 Nginx 配置中修改 `client_max_body_size`。
### v1.0.0 (2025-11-01)
- 🎉 首个正式版本发布
- ✨ 完整的文件管理功能
- ✨ 双存储模式(本地/SFTP
- ✨ 双存储模式(本地/OSS
- ✨ 文件分享功能
- ✨ 用户管理系统
- ✨ 邮件验证和密码重置

View File

@@ -95,17 +95,20 @@ DATABASE_PATH=./data/database.db
STORAGE_ROOT=./storage
# ============================================
# SFTP 配置(可选)
# OSS 云存储配置(可选)
# ============================================
#
# 说明: 用户可以在 Web 界面配置自己的 SFTP 服务器
# 说明: 用户可以在 Web 界面配置自己的 OSS 存储
# 支持:阿里云 OSS、腾讯云 COS、AWS S3
# 此处配置仅作为全局默认值(通常不需要配置)
#
# FTP_HOST=your-ftp-host.com
# FTP_PORT=22
# FTP_USER=your-username
# FTP_PASSWORD=your-password
# OSS_PROVIDER=aliyun # 服务商: aliyun/tencent/aws
# OSS_REGION=oss-cn-hangzhou # 地域
# OSS_ACCESS_KEY_ID=your-key # Access Key ID
# OSS_ACCESS_KEY_SECRET=secret # Access Key Secret
# OSS_BUCKET=your-bucket # 存储桶名称
# OSS_ENDPOINT= # 自定义 Endpoint可选
# ============================================
# 开发调试配置

View File

@@ -157,15 +157,17 @@ function authMiddleware(req, res, next) {
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',
// OSS存储字段v3.0新增)
has_oss_config: user.has_oss_config || 0,
oss_provider: user.oss_provider,
oss_region: user.oss_region,
oss_access_key_id: user.oss_access_key_id,
oss_access_key_secret: user.oss_access_key_secret,
oss_bucket: user.oss_bucket,
oss_endpoint: user.oss_endpoint,
// 存储相关字段
storage_permission: user.storage_permission || 'oss_only',
current_storage_type: user.current_storage_type || 'oss',
local_storage_quota: user.local_storage_quota || 1073741824,
local_storage_used: user.local_storage_used || 0,
// 主题偏好

View File

@@ -39,12 +39,13 @@ function initDatabase() {
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,
-- OSS配置(可选)
oss_provider TEXT,
oss_region TEXT,
oss_access_key_id TEXT,
oss_access_key_secret TEXT,
oss_bucket TEXT,
oss_endpoint TEXT,
-- 上传工具API密钥
upload_api_key TEXT,
@@ -53,7 +54,7 @@ function initDatabase() {
is_admin INTEGER DEFAULT 0,
is_active INTEGER DEFAULT 1,
is_banned INTEGER DEFAULT 0,
has_ftp_config INTEGER DEFAULT 0,
has_oss_config INTEGER DEFAULT 0,
-- 时间戳
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
@@ -96,8 +97,10 @@ function initDatabase() {
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_users_upload_api_key ON users(upload_api_key);
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_shares_expires ON shares(expires_at);
`);
// 数据库迁移添加upload_api_key字段如果不存在
@@ -213,7 +216,7 @@ function createDefaultAdmin() {
db.prepare(`
INSERT INTO users (
username, email, password,
is_admin, is_active, has_ftp_config, is_verified
is_admin, is_active, has_oss_config, is_verified
) VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(
adminUsername,
@@ -221,7 +224,7 @@ function createDefaultAdmin() {
hashedPassword,
1,
1,
0, // 管理员不需要FTP配置
0, // 管理员不需要OSS配置
1 // 管理员默认已验证
);
@@ -238,7 +241,7 @@ 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 hasOssConfig = userData.oss_provider && userData.oss_access_key_id && userData.oss_access_key_secret && userData.oss_bucket ? 1 : 0;
// 对验证令牌进行哈希存储(与 VerificationDB.setVerification 保持一致)
const hashedVerificationToken = userData.verification_token
@@ -248,22 +251,23 @@ const UserDB = {
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,
oss_provider, oss_region, oss_access_key_id, oss_access_key_secret, oss_bucket, oss_endpoint,
has_oss_config,
is_verified, verification_token, verification_expires_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) 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,
userData.oss_provider || null,
userData.oss_region || null,
userData.oss_access_key_id || null,
userData.oss_access_key_secret || null,
userData.oss_bucket || null,
userData.oss_endpoint || null,
hasOssConfig,
userData.is_verified !== undefined ? userData.is_verified : 0,
hashedVerificationToken,
userData.verification_expires_at || null
@@ -446,7 +450,7 @@ const ShareDB = {
});
const result = db.prepare(`
SELECT s.*, u.username, u.ftp_host, u.ftp_port, u.ftp_user, u.ftp_password, u.http_download_base_url, u.theme_preference
SELECT s.*, u.username, u.oss_provider, u.oss_region, u.oss_access_key_id, u.oss_access_key_secret, u.oss_bucket, u.oss_endpoint, u.theme_preference
FROM shares s
JOIN users u ON s.user_id = u.id
WHERE s.share_code = ?
@@ -678,6 +682,51 @@ function migrateToV2() {
}
}
// 数据库版本迁移 - v3.0 SFTP → OSS
function migrateToOss() {
try {
const columns = db.prepare("PRAGMA table_info(users)").all();
const hasOssProvider = columns.some(col => col.name === 'oss_provider');
if (!hasOssProvider) {
console.log('[数据库迁移] 检测到 SFTP 版本,开始升级到 v3.0 OSS...');
// 添加 OSS 相关字段
db.exec(`
ALTER TABLE users ADD COLUMN oss_provider TEXT DEFAULT NULL;
ALTER TABLE users ADD COLUMN oss_region TEXT DEFAULT NULL;
ALTER TABLE users ADD COLUMN oss_access_key_id TEXT DEFAULT NULL;
ALTER TABLE users ADD COLUMN oss_access_key_secret TEXT DEFAULT NULL;
ALTER TABLE users ADD COLUMN oss_bucket TEXT DEFAULT NULL;
ALTER TABLE users ADD COLUMN oss_endpoint TEXT DEFAULT NULL;
ALTER TABLE users ADD COLUMN has_oss_config INTEGER DEFAULT 0;
`);
console.log('[数据库迁移] ✓ OSS 字段已添加');
// 更新存储权限枚举值sftp_only → oss_only
db.exec(`UPDATE users SET storage_permission = 'oss_only' WHERE storage_permission = 'sftp_only'`);
console.log('[数据库迁移] ✓ 存储权限枚举值已更新');
// 更新存储类型sftp → oss
db.exec(`UPDATE users SET current_storage_type = 'oss' WHERE current_storage_type = 'sftp'`);
console.log('[数据库迁移] ✓ 存储类型已更新');
// 更新分享表的存储类型
const shareColumns = db.prepare("PRAGMA table_info(shares)").all();
const hasStorageType = shareColumns.some(col => col.name === 'storage_type');
if (hasStorageType) {
db.exec(`UPDATE shares SET storage_type = 'oss' WHERE storage_type = 'sftp'`);
console.log('[数据库迁移] ✓ 分享表存储类型已更新');
}
console.log('[数据库迁移] ✅ 数据库升级到 v3.0 完成SFTP 已替换为 OSS');
}
} catch (error) {
console.error('[数据库迁移] OSS 迁移失败:', error);
// 不抛出错误,允许服务继续启动
}
}
// 系统日志操作
const SystemLogDB = {
// 日志级别常量
@@ -819,6 +868,7 @@ createDefaultAdmin();
initDefaultSettings();
migrateToV2(); // 执行数据库迁移
migrateThemePreference(); // 主题偏好迁移
migrateToOss(); // SFTP → OSS 迁移
module.exports = {
db,

2463
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,22 +1,24 @@
{
"name": "ftp-web-manager-backend",
"version": "1.0.0",
"description": "FTP Web Manager Backend",
"name": "wanwanyun-backend",
"version": "3.1.0",
"description": "玩玩云 - 云存储管理平台后端服务",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
},
"keywords": [
"ftp",
"web",
"file-manager"
"cloud-storage",
"oss",
"s3",
"file-manager",
"alibaba-cloud",
"tencent-cloud"
],
"author": "",
"author": "玩玩云团队",
"license": "MIT",
"dependencies": {
"archiver": "^7.0.1",
"basic-ftp": "^5.0.4",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^11.8.1",
"cookie-parser": "^1.4.7",
@@ -28,7 +30,8 @@
"jsonwebtoken": "^9.0.2",
"multer": "^2.0.2",
"nodemailer": "^6.9.14",
"ssh2-sftp-client": "^12.0.1",
"@aws-sdk/client-s3": "^3.600.0",
"@aws-sdk/lib-storage": "^3.600.0",
"svg-captcha": "^1.4.0"
},
"devDependencies": {

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,5 @@
const SftpClient = require('ssh2-sftp-client');
const { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectsCommand, ListObjectsV2Command, HeadObjectCommand } = require('@aws-sdk/client-s3');
const { Upload } = require('@aws-sdk/lib-storage');
const fs = require('fs');
const path = require('path');
const { UserDB } = require('./database');
@@ -12,7 +13,7 @@ const { UserDB } = require('./database');
class StorageInterface {
constructor(user) {
this.user = user;
this.type = user.current_storage_type || 'sftp';
this.type = user.current_storage_type || 'oss';
}
/**
@@ -24,7 +25,7 @@ class StorageInterface {
await client.init();
return client;
} else {
const client = new SftpStorageClient(this.user);
const client = new OssStorageClient(this.user);
await client.connect();
return client;
}
@@ -321,96 +322,513 @@ class LocalStorageClient {
}
}
// ===== SFTP存储客户端 =====
// ===== OSS存储客户端 =====
class SftpStorageClient {
/**
* OSS 存储客户端(基于 S3 协议)
* 支持阿里云 OSS、腾讯云 COS、AWS S3
*/
class OssStorageClient {
constructor(user) {
this.user = user;
this.sftp = new SftpClient();
this.s3Client = null;
this.prefix = `user_${user.id}/`; // 用户隔离前缀
}
/**
* 连接SFTP服务器
* 验证 OSS 配置是否完整
* @throws {Error} 配置不完整时抛出错误
*/
validateConfig() {
const { oss_provider, oss_access_key_id, oss_access_key_secret, oss_bucket } = this.user;
if (!oss_provider || !['aliyun', 'tencent', 'aws'].includes(oss_provider)) {
throw new Error('无效的 OSS 服务商,必须是 aliyun、tencent 或 aws');
}
if (!oss_access_key_id || oss_access_key_id.trim() === '') {
throw new Error('OSS Access Key ID 不能为空');
}
if (!oss_access_key_secret || oss_access_key_secret.trim() === '') {
throw new Error('OSS Access Key Secret 不能为空');
}
if (!oss_bucket || oss_bucket.trim() === '') {
throw new Error('OSS 存储桶名称不能为空');
}
}
/**
* 根据服务商构建 S3 配置
*/
buildConfig() {
// 先验证配置
this.validateConfig();
const { oss_provider, oss_region, oss_access_key_id, oss_access_key_secret, oss_endpoint } = this.user;
// AWS S3 默认配置
let config = {
region: oss_region || 'us-east-1',
credentials: {
accessKeyId: oss_access_key_id,
secretAccessKey: oss_access_key_secret
},
// 设置超时时间
requestHandler: {
requestTimeout: 30000, // 30秒
httpsAgent: undefined // 可后续添加 keep-alive agent
}
};
// 阿里云 OSS
if (oss_provider === 'aliyun') {
config.region = oss_region || 'oss-cn-hangzhou';
if (!oss_endpoint) {
// 默认 endpoint 格式https://oss-{region}.aliyuncs.com
config.endpoint = `https://oss-${config.region.replace('oss-', '')}.aliyuncs.com`;
} else {
config.endpoint = oss_endpoint;
}
}
// 腾讯云 COS
else if (oss_provider === 'tencent') {
config.region = oss_region || 'ap-guangzhou';
if (!oss_endpoint) {
// 默认 endpoint 格式https://cos.{region}.myqcloud.com
config.endpoint = `https://cos.${config.region}.myqcloud.com`;
} else {
config.endpoint = oss_endpoint;
}
}
// AWS S3 或其他兼容服务
else {
if (oss_endpoint) {
config.endpoint = oss_endpoint;
}
// AWS 使用默认 endpoint无需额外配置
}
return config;
}
/**
* 连接 OSS 服务(初始化 S3 客户端)
*/
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;
try {
const config = this.buildConfig();
this.s3Client = new S3Client(config);
console.log(`[OSS存储] 已连接: ${this.user.oss_provider}, bucket: ${this.user.oss_bucket}`);
return this;
} catch (error) {
console.error(`[OSS存储] 连接失败:`, error.message);
throw new Error(`OSS 连接失败: ${error.message}`);
}
}
/**
* 获取对象的完整 Key带用户前缀
*/
getObjectKey(relativePath) {
// 规范化路径
let normalized = path.normalize(relativePath || '').replace(/^(\.\.[\/\\])+/, '');
// 移除开头的斜杠
if (normalized.startsWith('/') || normalized.startsWith('\\')) {
normalized = normalized.substring(1);
}
// 空路径表示根目录
if (normalized === '' || normalized === '.') {
normalized = '';
}
// 拼接用户前缀
return normalized ? this.prefix + normalized : this.prefix;
}
/**
* 列出目录内容
*/
async list(dirPath) {
return await this.sftp.list(dirPath);
try {
const prefix = this.getObjectKey(dirPath);
const bucket = this.user.oss_bucket;
const command = new ListObjectsV2Command({
Bucket: bucket,
Prefix: prefix,
Delimiter: '/', // 使用分隔符模拟目录结构
MaxKeys: 1000
});
const response = await this.s3Client.send(command);
const items = [];
// 处理"子目录"CommonPrefixes
if (response.CommonPrefixes) {
for (const prefixObj of response.CommonPrefixes) {
const dirName = prefixObj.Prefix.substring(prefix.length).replace(/\/$/, '');
if (dirName) {
items.push({
name: dirName,
type: 'd',
size: 0,
modifyTime: Date.now()
});
}
}
}
// 处理文件Contents
if (response.Contents) {
for (const obj of response.Contents) {
const key = obj.Key;
// 跳过目录标记本身
if (key === prefix || key.endsWith('/')) {
continue;
}
const fileName = key.substring(prefix.length);
if (fileName) {
items.push({
name: fileName,
type: '-',
size: obj.Size || 0,
modifyTime: obj.LastModified ? obj.LastModified.getTime() : Date.now()
});
}
}
}
return items;
} catch (error) {
console.error(`[OSS存储] 列出目录失败: ${dirPath}`, error.message);
// 判断错误类型并给出友好的错误信息
if (error.name === 'NoSuchBucket') {
throw new Error('OSS 存储桶不存在,请检查配置');
} else if (error.name === 'AccessDenied') {
throw new Error('OSS 访问被拒绝,请检查权限配置');
} else if (error.name === 'InvalidAccessKeyId') {
throw new Error('OSS Access Key 无效,请重新配置');
}
throw new Error(`列出目录失败: ${error.message}`);
}
}
/**
* 上传文件
* 上传文件(支持分片上传)
*/
async put(localPath, remotePath) {
// 使用临时文件+重命名模式与upload_tool保持一致
const tempRemotePath = `${remotePath}.uploading_${Date.now()}`;
let fileStream = null;
// 第一步:上传到临时文件
await this.sftp.put(localPath, tempRemotePath);
// 第二步:检查目标文件是否存在,如果存在先删除
try {
await this.sftp.stat(remotePath);
await this.sftp.delete(remotePath);
} catch (err) {
// 文件不存在,无需删除
}
const key = this.getObjectKey(remotePath);
const bucket = this.user.oss_bucket;
const fileSize = fs.statSync(localPath).size;
// 第三步:重命名临时文件为目标文件
await this.sftp.rename(tempRemotePath, remotePath);
// 创建文件读取流
fileStream = fs.createReadStream(localPath);
// 使用 AWS SDK 的 Upload 类处理分片上传
const upload = new Upload({
client: this.s3Client,
params: {
Bucket: bucket,
Key: key,
Body: fileStream
},
queueSize: 3, // 并发分片数
partSize: 5 * 1024 * 1024 // 5MB 分片
});
// 监听上传进度(可选)
upload.on('httpUploadProgress', (progress) => {
if (progress && progress.loaded && progress.total) {
const percent = Math.round((progress.loaded / progress.total) * 100);
// 只在较大文件时打印进度(避免日志过多)
if (progress.total > 10 * 1024 * 1024 || percent % 20 === 0) {
console.log(`[OSS存储] 上传进度: ${percent}% (${key})`);
}
}
});
await upload.done();
console.log(`[OSS存储] 上传成功: ${key} (${this.formatSize(fileSize)})`);
// 上传成功后,手动关闭流
if (fileStream && !fileStream.destroyed) {
fileStream.destroy();
}
} catch (error) {
// 确保流被关闭,防止泄漏
if (fileStream && !fileStream.destroyed) {
fileStream.destroy();
}
console.error(`[OSS存储] 上传失败: ${remotePath}`, error.message);
// 判断错误类型并给出友好的错误信息
if (error.name === 'NoSuchBucket') {
throw new Error('OSS 存储桶不存在,请检查配置');
} else if (error.name === 'AccessDenied') {
throw new Error('OSS 访问被拒绝,请检查权限配置');
} else if (error.name === 'EntityTooLarge') {
throw new Error('文件过大,超过了 OSS 允许的最大大小');
}
throw new Error(`文件上传失败: ${error.message}`);
}
}
/**
* 删除文件
* 删除文件或文件夹
*/
async delete(filePath) {
return await this.sftp.delete(filePath);
try {
const key = this.getObjectKey(filePath);
const bucket = this.user.oss_bucket;
// 检查是文件还是目录(忽略不存在的文件)
let statResult;
try {
statResult = await this.stat(filePath);
} catch (statError) {
if (statError.message && statError.message.includes('不存在')) {
console.warn(`[OSS存储] 文件不存在,跳过删除: ${key}`);
return; // 文件不存在,直接返回
}
throw statError; // 其他错误继续抛出
}
if (statResult.isDirectory) {
// 删除目录:列出所有对象并批量删除
const listCommand = new ListObjectsV2Command({
Bucket: bucket,
Prefix: key
});
const listResponse = await this.s3Client.send(listCommand);
if (listResponse.Contents && listResponse.Contents.length > 0) {
// 分批删除AWS S3 单次最多删除 1000 个对象)
const deleteCommand = new DeleteObjectsCommand({
Bucket: bucket,
Delete: {
Objects: listResponse.Contents.map(obj => ({ Key: obj.Key })),
Quiet: false
}
});
const deleteResult = await this.s3Client.send(deleteCommand);
// 检查删除结果
if (deleteResult.Errors && deleteResult.Errors.length > 0) {
console.warn(`[OSS存储] 部分对象删除失败:`, deleteResult.Errors);
}
console.log(`[OSS存储] 删除目录: ${key} (${listResponse.Contents.length} 个对象)`);
}
} else {
// 删除单个文件
const command = new DeleteObjectsCommand({
Bucket: bucket,
Delete: {
Objects: [{ Key: key }],
Quiet: false
}
});
await this.s3Client.send(command);
console.log(`[OSS存储] 删除文件: ${key}`);
}
} catch (error) {
console.error(`[OSS存储] 删除失败: ${filePath}`, error.message);
// 判断错误类型并给出友好的错误信息
if (error.name === 'NoSuchBucket') {
throw new Error('OSS 存储桶不存在,请检查配置');
} else if (error.name === 'AccessDenied') {
throw new Error('OSS 访问被拒绝,请检查权限配置');
}
throw new Error(`删除文件失败: ${error.message}`);
}
}
/**
* 重命名文件
* 重命名文件OSS 不支持直接重命名,需要复制后删除)
*/
async rename(oldPath, newPath) {
return await this.sftp.rename(oldPath, newPath);
try {
const oldKey = this.getObjectKey(oldPath);
const newKey = this.getObjectKey(newPath);
const bucket = this.user.oss_bucket;
// 先复制
const copyCommand = new PutObjectCommand({
Bucket: bucket,
Key: newKey,
CopySource: `${bucket}/${oldKey}`
});
await this.s3Client.send(copyCommand);
// 再删除原文件
await this.delete(oldPath);
console.log(`[OSS存储] 重命名: ${oldKey} -> ${newKey}`);
} catch (error) {
console.error(`[OSS存储] 重命名失败: ${oldPath} -> ${newPath}`, error.message);
// 判断错误类型并给出友好的错误信息
if (error.name === 'NoSuchBucket') {
throw new Error('OSS 存储桶不存在,请检查配置');
} else if (error.name === 'AccessDenied') {
throw new Error('OSS 访问被拒绝,请检查权限配置');
}
throw new Error(`重命名文件失败: ${error.message}`);
}
}
/**
* 获取文件信息
*/
async stat(filePath) {
return await this.sftp.stat(filePath);
const key = this.getObjectKey(filePath);
const bucket = this.user.oss_bucket;
try {
const command = new HeadObjectCommand({
Bucket: bucket,
Key: key
});
const response = await this.s3Client.send(command);
return {
size: response.ContentLength || 0,
modifyTime: response.LastModified ? response.LastModified.getTime() : Date.now(),
isDirectory: false
};
} catch (error) {
if (error.name === 'NotFound' || error.$metadata?.httpStatusCode === 404) {
// 可能是目录,尝试列出前缀
const listCommand = new ListObjectsV2Command({
Bucket: bucket,
Prefix: key.endsWith('/') ? key : key + '/',
MaxKeys: 1
});
try {
const listResponse = await this.s3Client.send(listCommand);
if (listResponse.Contents && listResponse.Contents.length > 0) {
return { isDirectory: true, size: 0, modifyTime: Date.now() };
}
} catch (listError) {
// 忽略列表错误
}
}
throw new Error(`对象不存在: ${key}`);
}
}
/**
* 创建文件读取流
* 创建文件读取流(异步方法)
* @returns {Promise<Readable>} 返回可读流 Promise
*/
createReadStream(filePath) {
return this.sftp.createReadStream(filePath);
async createReadStream(filePath) {
const key = this.getObjectKey(filePath);
const bucket = this.user.oss_bucket;
const command = new GetObjectCommand({
Bucket: bucket,
Key: key
});
try {
const response = await this.s3Client.send(command);
// AWS SDK v3 返回的 Body 是一个 IncomingMessage 类型的流
return response.Body;
} catch (error) {
console.error(`[OSS存储] 创建读取流失败: ${key}`, error.message);
throw error;
}
}
/**
* 关闭连接
* 创建文件夹(通过创建空对象模拟)
* OSS 中文件夹实际上是一个以斜杠结尾的空对象
*/
async mkdir(dirPath) {
try {
const key = this.getObjectKey(dirPath);
const bucket = this.user.oss_bucket;
// OSS 中文件夹通过以斜杠结尾的空对象模拟
const folderKey = key.endsWith('/') ? key : `${key}/`;
const { PutObjectCommand } = require('@aws-sdk/client-s3');
const command = new PutObjectCommand({
Bucket: bucket,
Key: folderKey,
Body: '', // 空内容
ContentType: 'application/x-directory'
});
await this.s3Client.send(command);
console.log(`[OSS存储] 创建文件夹: ${folderKey}`);
} catch (error) {
console.error(`[OSS存储] 创建文件夹失败: ${dirPath}`, error.message);
if (error.name === 'AccessDenied') {
throw new Error('OSS 访问被拒绝,请检查权限配置');
}
throw new Error(`创建文件夹失败: ${error.message}`);
}
}
/**
* 获取下载 URL用于分享链接
*/
getSignedUrl(filePath, expiresIn = 3600) {
const key = this.getObjectKey(filePath);
const bucket = this.user.oss_bucket;
// 简单的公开 URL 拼接(如果 bucket 是公共读)
const endpoint = this.s3Client.config.endpoint;
const region = this.s3Client.config.region;
let baseUrl;
if (endpoint) {
baseUrl = endpoint.href || endpoint.toString();
} else if (this.user.oss_provider === 'aliyun') {
baseUrl = `https://${bucket}.${this.user.oss_region || 'oss-cn-hangzhou'}.aliyuncs.com`;
} else if (this.user.oss_provider === 'tencent') {
baseUrl = `https://${bucket}.cos.${this.user.oss_region || 'ap-guangzhou'}.myqcloud.com`;
} else {
baseUrl = `https://${bucket}.s3.${region}.amazonaws.com`;
}
return `${baseUrl}/${key}`;
}
/**
* 关闭连接S3Client 无需显式关闭)
*/
async end() {
if (this.sftp) {
await this.sftp.end();
}
this.s3Client = null;
}
/**
* 格式化文件大小
*/
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];
}
}
module.exports = {
StorageInterface,
LocalStorageClient,
SftpStorageClient
OssStorageClient
};

View File

@@ -1322,7 +1322,7 @@
<button v-if="storageType === 'local'" class="btn btn-primary" @click="showCreateFolderModal = true">
<i class="fas fa-folder-plus"></i> 新建文件夹
</button>
<!-- SFTP存储:显示下载上传工具按钮 -->
<!-- OSS存储:显示下载上传工具按钮 -->
<button v-else class="btn btn-primary" @click="downloadUploadTool" :disabled="downloadingTool">
<i :class="downloadingTool ? 'fas fa-spinner fa-spin' : 'fas fa-download'"></i>
{{ downloadingTool ? '生成中...' : '下载上传工具' }}
@@ -1617,83 +1617,83 @@
</div>
</div>
<!-- SFTP 配置引导弹窗 -->
<div v-if="showSftpGuideModal" class="modal-overlay" @mousedown.self="handleModalMouseDown" @mouseup.self="handleModalMouseUp('showSftpGuideModal')">
<!-- OSS 配置引导弹窗 -->
<div v-if="showOssGuideModal" class="modal-overlay" @mousedown.self="handleModalMouseDown" @mouseup.self="handleModalMouseUp('showOssGuideModal')">
<div class="modal-content" @click.stop style="max-width: 520px; border-radius: 16px; overflow: hidden;">
<div style="background: linear-gradient(135deg,#667eea,#764ba2); color: white; padding: 18px;">
<div style="display: flex; align-items: center; gap: 10px;">
<i class="fas fa-server" style="font-size: 20px;"></i>
<h3 style="margin: 0; font-size: 20px;">切换到 SFTP 存储</h3>
<i class="fas fa-cloud" style="font-size: 20px;"></i>
<h3 style="margin: 0; font-size: 20px;">切换到 OSS 存储</h3>
</div>
<p style="margin: 8px 0 0 0; opacity: 0.9; font-size: 14px;">先配置连接信息,再切换到你的专属 SFTP 空间。</p>
<p style="margin: 8px 0 0 0; opacity: 0.9; font-size: 14px;">先配置云服务信息,再切换到你的专属 OSS 空间。</p>
</div>
<div style="padding: 18px;">
<p style="color: var(--text-secondary); line-height: 1.6; margin-bottom: 16px;">
我们会在你填写完成后再切换,确保过程平滑无干扰
支持阿里云 OSS、腾讯云 COS、AWS S3 等兼容 S3 协议的云存储服务
</p>
<div style="display: flex; gap: 10px; justify-content: flex-end;">
<button class="btn btn-secondary" @click="closeSftpGuideModal">稍后再说</button>
<button class="btn btn-primary" @click="proceedSftpGuide">
<i class="fas fa-tools"></i> 去配置 SFTP
<button class="btn btn-secondary" @click="closeOssGuideModal">稍后再说</button>
<button class="btn btn-primary" @click="proceedOssGuide">
<i class="fas fa-tools"></i> 去配置 OSS
</button>
</div>
</div>
</div>
</div>
<!-- SFTP 配置弹窗 -->
<div v-if="showSftpConfigModal" class="modal-overlay" @mousedown.self="handleModalMouseDown" @mouseup.self="handleModalMouseUp('showSftpConfigModal')">
<!-- OSS 配置弹窗 -->
<div v-if="showOssConfigModal" class="modal-overlay" @mousedown.self="handleModalMouseDown" @mouseup.self="handleModalMouseUp('showOssConfigModal')">
<div class="modal-content" @click.stop style="max-width: 720px; border-radius: 16px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
<div>
<h3 style="margin: 0 0 6px 0;">配置 SFTP 存储</h3>
<p style="margin: 0; color: var(--text-muted); font-size: 13px;">填写连接信息或导入 .inf 配置,保存后即可切换到 SFTP 模式。</p>
<h3 style="margin: 0 0 6px 0;">配置 OSS 存储</h3>
<p style="margin: 0; color: var(--text-muted); font-size: 13px;">填写云服务配置信息,保存后即可切换到 OSS 模式。</p>
</div>
<button class="btn btn-secondary" style="padding: 6px 10px;" @click="closeSftpConfigModal">
<button class="btn btn-secondary" style="padding: 6px 10px;" @click="closeOssConfigModal">
<i class="fas fa-times"></i>
</button>
</div>
<div style="display: grid; grid-template-columns: 1fr; gap: 14px;">
<div style="border: 1px dashed var(--glass-border); border-radius: 12px; padding: 16px; background: rgba(255,255,255,0.03); text-align: center; cursor: pointer; transition: all .3s;"
@click="$refs.configFileInput.click()"
@dragover.prevent="$event.currentTarget.style.background='#eef2ff'"
@dragleave.prevent="$event.currentTarget.style.background='#f8fafc'"
@drop.prevent="handleConfigFileDrop">
<i class="fas fa-cloud-upload-alt" style="font-size: 36px; color: #667eea; margin-bottom: 8px;"></i>
<div style="font-weight: 600; color: var(--text-primary);">导入配置文件</div>
<div style="color: var(--text-muted); font-size: 13px; margin-top: 4px;">点击选择或拖拽 .inf 文件</div>
<input type="file" accept=".inf" @change="handleConfigFileUpload" ref="configFileInput" style="display: none;">
<form @submit.prevent="updateOssConfig" style="display: grid; gap: 12px;">
<div class="form-group">
<label class="form-label">云服务商</label>
<select class="form-input" v-model="ossConfigForm.oss_provider" required style="cursor: pointer;">
<option value="aliyun">阿里云 OSS</option>
<option value="tencent">腾讯云 COS</option>
<option value="aws">AWS S3</option>
</select>
</div>
<form @submit.prevent="updateFtpConfig" style="display: grid; gap: 12px;">
<div class="form-group">
<label class="form-label">主机地址</label>
<input type="text" class="form-input" v-model="ftpConfigForm.ftp_host" required>
</div>
<div class="form-group">
<label class="form-label">端口</label>
<input type="number" class="form-input" v-model="ftpConfigForm.ftp_port" required>
</div>
<div class="form-group">
<label class="form-label">用户名</label>
<input type="text" class="form-input" v-model="ftpConfigForm.ftp_user" required>
</div>
<div class="form-group">
<label class="form-label">密码 (留空保留现有密码)</label>
<input type="password" class="form-input" v-model="ftpConfigForm.ftp_password" placeholder="留空保留现有密码">
</div>
<div class="form-group">
<label class="form-label">HTTP下载基础URL (可选)</label>
<input type="text" class="form-input" v-model="ftpConfigForm.http_download_base_url" placeholder="例如: http://example.com/files">
<small style="color: var(--text-secondary); font-size: 12px; margin-top: 5px; display: block;">
配置后可通过 HTTP 直接下载,例如: 基础URL/文件路径。
</small>
</div>
<div style="display: flex; gap: 10px; justify-content: flex-end; margin-top: 4px;">
<button type="button" class="btn btn-secondary" @click="closeSftpConfigModal">取消</button>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> 保存配置
<div class="form-group">
<label class="form-label">地域</label>
<input type="text" class="form-input" v-model="ossConfigForm.oss_region" placeholder="如: oss-cn-hangzhou / ap-guangzhou / us-east-1" required>
<small style="color: var(--text-secondary); font-size: 12px; margin-top: 5px; display: block;">
阿里云: oss-cn-hangzhou, 腾讯云: ap-guangzhou, AWS: us-east-1
</small>
</div>
<div class="form-group">
<label class="form-label">Access Key ID</label>
<input type="text" class="form-input" v-model="ossConfigForm.oss_access_key_id" required>
</div>
<div class="form-group">
<label class="form-label">Access Key Secret (留空保留现有密钥)</label>
<input type="password" class="form-input" v-model="ossConfigForm.oss_access_key_secret" placeholder="留空保留现有密钥">
</div>
<div class="form-group">
<label class="form-label">存储桶名称</label>
<input type="text" class="form-input" v-model="ossConfigForm.oss_bucket" placeholder="如: my-storage-bucket" required>
</div>
<div class="form-group">
<label class="form-label">自定义 Endpoint (可选)</label>
<input type="text" class="form-input" v-model="ossConfigForm.oss_endpoint" placeholder="兼容 S3 的服务可填写自定义地址">
<small style="color: var(--text-secondary); font-size: 12px; margin-top: 5px; display: block;">
一般不需要填写,仅在使用自定义 S3 兼容服务时需要。
</small>
</div>
<div style="display: flex; gap: 10px; justify-content: flex-end; margin-top: 4px;">
<button type="button" class="btn btn-secondary" @click="closeOssConfigModal">取消</button>
<button type="submit" class="btn btn-primary" :disabled="ossConfigSaving" :style="{ opacity: ossConfigSaving ? 0.7 : 1 }">
<i class="fas" :class="ossConfigSaving ? 'fa-spinner fa-spin' : 'fa-save'"></i>
{{ ossConfigSaving ? '保存中...' : '保存配置' }}
</button>
</div>
</form>
@@ -1730,9 +1730,9 @@
</div>
<div v-if="storageSwitching" style="color: #4b5fc9; font-weight: 600; display: inline-flex; align-items: center; gap: 8px;">
<i class="fas fa-sync-alt fa-spin"></i>
正在切换到 {{ storageSwitchTarget === 'sftp' ? 'SFTP 存储' : '本地存储' }}...
正在切换到 {{ storageSwitchTarget === 'oss' ? 'OSS 存储' : '本地存储' }}...
</div>
<div v-else style="color: var(--text-secondary); font-size: 13px;">本地存储适合快速读写,SFTP 适合独立服务器空间</div>
<div v-else style="color: var(--text-secondary); font-size: 13px;">本地存储适合快速读写,OSS 适合云存储扩展</div>
</div>
<div style="margin-top: 16px; background: var(--bg-secondary); border-radius: 12px; padding: 12px; border: 1px solid var(--glass-border);">
@@ -1782,53 +1782,53 @@
<div style="background: var(--bg-secondary); border: 1px solid var(--glass-border); border-radius: 12px; padding: 16px; box-shadow: 0 6px 20px rgba(0,0,0,0.15); display: flex; flex-direction: column; height: 100%;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<div style="font-weight: 700; color: var(--text-primary); display: flex; gap: 8px; align-items: center;">
<i class="fas fa-server" style="color: var(--accent-1);"></i> SFTP 存储
<i class="fas fa-cloud" style="color: var(--accent-1);"></i> OSS 存储
</div>
<span v-if="storageType === 'sftp'" style="font-size: 12px; color: var(--accent-1); background: rgba(102,126,234,0.15); padding: 4px 8px; border-radius: 999px;">当前</span>
<span v-if="storageType === 'oss'" style="font-size: 12px; color: var(--accent-1); background: rgba(102,126,234,0.15); padding: 4px 8px; border-radius: 999px;">当前</span>
</div>
<div style="color: var(--text-secondary); font-size: 13px; margin-bottom: 10px;">使用你自己的服务器空间,独立存储更灵活</div>
<div v-if="user?.has_ftp_config" style="font-size: 13px; color: var(--text-primary); margin-bottom: 10px;">
已配置: {{ user.ftp_host }}:{{ user.ftp_port }}
<div style="color: var(--text-secondary); font-size: 13px; margin-bottom: 10px;">使用云存储服务,安全可靠扩展性强</div>
<div v-if="user?.has_oss_config" style="font-size: 13px; color: var(--text-primary); margin-bottom: 10px;">
已配置: {{ user.oss_provider }} / {{ user.oss_bucket }}
</div>
<div v-else style="font-size: 13px; color: #f59e0b; background: rgba(245, 158, 11, 0.1); border: 1px dashed rgba(245,158,11,0.4); padding: 10px; border-radius: 8px; margin-bottom: 10px;">
<i class="fas fa-exclamation-circle"></i> 先填写 SFTP 连接信息再切换
<i class="fas fa-exclamation-circle"></i> 先填写 OSS 配置信息再切换
</div>
<!-- SFTP空间使用统计user_choice模式 -->
<div v-if="user?.has_ftp_config" style="margin-bottom: 10px; padding: 10px; background: rgba(255,255,255,0.03); border-radius: 8px; border: 1px solid var(--glass-border);">
<!-- OSS空间使用统计user_choice模式 -->
<div v-if="user?.has_oss_config" style="margin-bottom: 10px; padding: 10px; background: rgba(255,255,255,0.03); border-radius: 8px; border: 1px solid var(--glass-border);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<span style="font-size: 12px; color: var(--text-muted);">空间统计</span>
<button
style="background: none; border: none; color: #4b5fc9; cursor: pointer; font-size: 12px; padding: 2px 6px;"
@click.stop="loadSftpUsage()"
:disabled="sftpUsageLoading">
<i :class="sftpUsageLoading ? 'fas fa-sync-alt fa-spin' : 'fas fa-sync-alt'"></i>
@click.stop="loadOssUsage()"
:disabled="ossUsageLoading">
<i :class="ossUsageLoading ? 'fas fa-sync-alt fa-spin' : 'fas fa-sync-alt'"></i>
</button>
</div>
<div v-if="sftpUsageLoading && !sftpUsage" style="text-align: center; color: #667eea; font-size: 12px;">
<div v-if="ossUsageLoading && !ossUsage" style="text-align: center; color: #667eea; font-size: 12px;">
<i class="fas fa-spinner fa-spin"></i> 统计中...
</div>
<div v-else-if="sftpUsageError" style="font-size: 12px; color: #ef4444;">
<i class="fas fa-exclamation-triangle"></i> {{ sftpUsageError }}
<div v-else-if="ossUsageError" style="font-size: 12px; color: #ef4444;">
<i class="fas fa-exclamation-triangle"></i> {{ ossUsageError }}
</div>
<div v-else-if="sftpUsage" style="font-size: 13px; font-weight: 600; color: #4b5fc9;">
{{ sftpUsage.totalSizeFormatted }}
<span style="font-weight: 400; color: var(--text-muted); font-size: 12px;">{{ sftpUsage.fileCount }} 文件)</span>
<div v-else-if="ossUsage" style="font-size: 13px; font-weight: 600; color: #4b5fc9;">
{{ ossUsage.totalSizeFormatted }}
<span style="font-weight: 400; color: var(--text-muted); font-size: 12px;">{{ ossUsage.fileCount }} 文件)</span>
</div>
<div v-else style="font-size: 12px; color: var(--text-muted);">点击刷新查看</div>
</div>
<div style="margin-top: auto;">
<button
class="btn"
:class="user?.has_ftp_config ? 'btn-primary' : 'btn-secondary'"
:class="user?.has_oss_config ? 'btn-primary' : 'btn-secondary'"
style="width: 100%; border-radius: 10px;"
:disabled="storageType === 'sftp' || storageSwitching"
@click="switchStorage('sftp')">
:disabled="storageType === 'oss' || storageSwitching"
@click="switchStorage('oss')">
<i class="fas fa-random"></i>
{{ user?.has_ftp_config ? '切到 SFTP 存储' : '去配置 SFTP' }}
{{ user?.has_oss_config ? '切到 OSS 存储' : '去配置 OSS' }}
</button>
<div style="margin-top: 8px; text-align: center;">
<a style="color: #4b5fc9; font-size: 13px; text-decoration: none; cursor: pointer;" @click.prevent="openSftpConfigModal">
<i class="fas fa-tools"></i> 配置 / 修改 SFTP
<a style="color: #4b5fc9; font-size: 13px; text-decoration: none; cursor: pointer;" @click.prevent="openOssConfigModal">
<i class="fas fa-tools"></i> 配置 / 修改 OSS
</a>
</div>
</div>
@@ -1837,7 +1837,7 @@
<div style="margin-top: 12px; padding: 10px 12px; background: rgba(255,255,255,0.05); border-radius: 10px; font-size: 13px; color: var(--text-secondary);">
<i class="fas fa-info-circle" style="color: #4b5fc9;"></i>
本地存储速度快但受配额限制;SFTP 需先配置连接,切换过程中可继续查看文件列表。
本地存储速度快但受配额限制;OSS 支持多家云服务商,切换过程中可继续查看文件列表。
</div>
</div>
</div>
@@ -1872,44 +1872,44 @@
<div style="padding: 10px; background: rgba(59, 130, 246, 0.15); border-left: 4px solid #3b82f6; border-radius: 6px; font-size: 13px; color: #93c5fd;">
<i class="fas fa-info-circle"></i>
<strong>说明:</strong> 管理员已将您的存储权限设置为"仅本地存储",您的文件存储在服务器本地,速度快但有配额限制。如需使用SFTP存储,请联系管理员修改权限设置。
<strong>说明:</strong> 管理员已将您的存储权限设置为"仅本地存储",您的文件存储在服务器本地,速度快但有配额限制。如需使用OSS存储,请联系管理员修改权限设置。
</div>
</div>
</div>
<!-- SFTP 概览 / 配置入口 -SFTP权限 -->
<div v-if="user && !user.is_admin && storagePermission === 'sftp_only'" style="margin-bottom: 40px;">
<!-- OSS 概览 / 配置入口 -OSS权限 -->
<div v-if="user && !user.is_admin && storagePermission === 'oss_only'" style="margin-bottom: 40px;">
<h3 style="margin-bottom: 20px;">
<i class="fas fa-server"></i> SFTP存储
<i class="fas fa-cloud"></i> OSS存储
</h3>
<div style="background: rgba(255,255,255,0.03); padding: 20px; border-radius: 12px; border: 1px solid var(--glass-border);">
<div style="display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 12px;">
<div>
<div style="font-weight: 700; color: var(--text-primary); display: flex; gap: 8px; align-items: center;">
<i class="fas fa-shield-alt"></i>
SFTP 模式
OSS 模式
</div>
<div style="color: var(--text-secondary); font-size: 13px; margin-top: 6px;">
{{ user.has_ftp_config ? '已配置服务,可正常使用 SFTP 存储。' : '还未配置 SFTP,请先填写连接信息。' }}
{{ user.has_oss_config ? '已配置服务,可正常使用 OSS 存储。' : '还未配置 OSS,请先填写配置信息。' }}
</div>
</div>
<button class="btn btn-primary" @click="openSftpConfigModal()" style="border-radius: 10px;">
<i class="fas fa-tools"></i> 配置 / 修改 SFTP
<button class="btn btn-primary" @click="openOssConfigModal()" style="border-radius: 10px;">
<i class="fas fa-tools"></i> 配置 / 修改 OSS
</button>
</div>
<!-- 服务器信息 -->
<div v-if="user.has_ftp_config" style="margin-bottom: 12px; padding: 12px; background: var(--bg-secondary); border-radius: 10px; border: 1px solid var(--glass-border);">
<!-- OSS服务器信息 -->
<div v-if="user.has_oss_config" style="margin-bottom: 12px; padding: 12px; background: var(--bg-secondary); border-radius: 10px; border: 1px solid var(--glass-border);">
<div style="font-weight: 600; color: var(--text-primary); margin-bottom: 8px;">
<i class="fas fa-server" style="color: var(--accent-1);"></i> 服务信息
<i class="fas fa-cloud" style="color: var(--accent-1);"></i> 服务信息
</div>
<div style="color: var(--text-secondary); font-size: 14px;">
{{ user.ftp_host }}:{{ user.ftp_port }}
{{ user.oss_provider }} / {{ user.oss_bucket }}
</div>
</div>
<!-- SFTP空间使用统计 -->
<div v-if="user.has_ftp_config" style="margin-bottom: 12px; padding: 12px; background: var(--bg-secondary); border-radius: 10px; border: 1px solid var(--glass-border);">
<!-- OSS空间使用统计 -->
<div v-if="user.has_oss_config" style="margin-bottom: 12px; padding: 12px; background: var(--bg-secondary); border-radius: 10px; border: 1px solid var(--glass-border);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<div style="font-weight: 600; color: var(--text-primary);">
<i class="fas fa-chart-pie" style="color: #667eea;"></i> 空间使用统计
@@ -1917,51 +1917,47 @@
<button
class="btn btn-secondary"
style="padding: 4px 10px; font-size: 12px; border-radius: 6px;"
@click="loadSftpUsage()"
:disabled="sftpUsageLoading">
<i :class="sftpUsageLoading ? 'fas fa-sync-alt fa-spin' : 'fas fa-sync-alt'"></i>
{{ sftpUsageLoading ? '统计中...' : '刷新' }}
@click="loadOssUsage()"
:disabled="ossUsageLoading">
<i :class="ossUsageLoading ? 'fas fa-sync-alt fa-spin' : 'fas fa-sync-alt'"></i>
{{ ossUsageLoading ? '统计中...' : '刷新' }}
</button>
</div>
<!-- 加载中 -->
<div v-if="sftpUsageLoading && !sftpUsage" style="text-align: center; padding: 20px; color: #667eea;">
<div v-if="ossUsageLoading && !ossUsage" style="text-align: center; padding: 20px; color: #667eea;">
<i class="fas fa-spinner fa-spin" style="font-size: 24px;"></i>
<div style="margin-top: 8px; font-size: 13px;">正在统计 SFTP 空间使用情况...</div>
<div style="margin-top: 8px; font-size: 13px;">正在统计 OSS 空间使用情况...</div>
<div style="margin-top: 4px; font-size: 12px; color: var(--text-muted);">(文件较多时可能需要一些时间)</div>
</div>
<!-- 错误提示 -->
<div v-else-if="sftpUsageError" style="padding: 12px; background: rgba(239, 68, 68, 0.1); border-radius: 8px; color: #ef4444; font-size: 13px;">
<i class="fas fa-exclamation-triangle"></i> {{ sftpUsageError }}
<div v-else-if="ossUsageError" style="padding: 12px; background: rgba(239, 68, 68, 0.1); border-radius: 8px; color: #ef4444; font-size: 13px;">
<i class="fas fa-exclamation-triangle"></i> {{ ossUsageError }}
</div>
<!-- 统计结果 -->
<div v-else-if="sftpUsage" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 12px;">
<div v-else-if="ossUsage" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 12px;">
<div style="text-align: center; padding: 12px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 10px; color: white;">
<div style="font-size: 20px; font-weight: 700;">{{ sftpUsage.totalSizeFormatted }}</div>
<div style="font-size: 20px; font-weight: 700;">{{ ossUsage.totalSizeFormatted }}</div>
<div style="font-size: 12px; opacity: 0.9; margin-top: 4px;">总使用空间</div>
</div>
<div style="text-align: center; padding: 12px; background: rgba(59, 130, 246, 0.1); border-radius: 10px; border: 1px solid rgba(59,130,246,0.2);">
<div style="font-size: 20px; font-weight: 700; color: #3b82f6;">{{ sftpUsage.fileCount }}</div>
<div style="font-size: 12px; color: var(--text-muted); margin-top: 4px;">文件</div>
</div>
<div style="text-align: center; padding: 12px; background: rgba(245, 158, 11, 0.1); border-radius: 10px; border: 1px solid rgba(245,158,11,0.3);">
<div style="font-size: 20px; font-weight: 700; color: #f59e0b;">{{ sftpUsage.dirCount }}</div>
<div style="font-size: 12px; color: var(--text-muted); margin-top: 4px;">文件夹数</div>
<div style="font-size: 20px; font-weight: 700; color: #3b82f6;">{{ ossUsage.fileCount }}</div>
<div style="font-size: 12px; color: var(--text-muted); margin-top: 4px;">对象</div>
</div>
</div>
<!-- 未统计提示 -->
<div v-else style="text-align: center; padding: 16px; color: var(--text-muted); font-size: 13px;">
<i class="fas fa-database" style="font-size: 24px; color: var(--text-muted); margin-bottom: 8px; display: block;"></i>
点击"刷新"按钮统计 SFTP 空间使用情况
点击"刷新"按钮统计 OSS 空间使用情况
</div>
</div>
<div style="padding: 10px; background: rgba(102, 126, 234, 0.1); border-radius: 10px; color: var(--text-secondary); font-size: 13px;">
<i class="fas fa-info-circle" style="color: #4b5fc9;"></i>
数据存储在你的 SFTP 服务上,如需切换回本地请联系管理员调整权限。
数据存储在服务上,安全可靠扩展性强。如需切换回本地请联系管理员调整权限。
</div>
</div>
</div>
@@ -2701,7 +2697,7 @@
<td style="padding: 10px; font-size: 12px; color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" :title="u.email">{{ u.email }}</td>
<td style="padding: 10px; text-align: center; font-size: 12px;">
<span v-if="u.storage_permission === 'local_only'" style="background: #667eea; color: white; padding: 3px 8px; border-radius: 4px;">仅本地</span>
<span v-else-if="u.storage_permission === 'sftp_only'" style="background: #6c757d; color: white; padding: 3px 8px; border-radius: 4px;">SFTP</span>
<span v-else-if="u.storage_permission === 'oss_only'" style="background: #6c757d; color: white; padding: 3px 8px; border-radius: 4px;">OSS</span>
<span v-else style="background: #22c55e; color: white; padding: 3px 8px; border-radius: 4px;">用户选择</span>
</td>
<td style="padding: 10px; text-align: center; font-size: 12px;">
@@ -2709,7 +2705,7 @@
<i class="fas fa-hard-drive"></i> 本地
</span>
<span v-else style="color: #6c757d;">
<i class="fas fa-server"></i> SFTP
<i class="fas fa-cloud"></i> OSS
</span>
</td>
<td style="padding: 10px; text-align: center; font-size: 12px;">
@@ -2737,7 +2733,7 @@
<button v-else class="btn" style="background: #22c55e; color: white; font-size: 11px; padding: 5px 10px;" @click="banUser(u.id, false)">
<i class="fas fa-check"></i> 解封
</button>
<button v-if="u.has_ftp_config" class="btn" style="background: #3b82f6; color: white; font-size: 11px; padding: 5px 10px;" @click="openFileInspection(u)">
<button v-if="u.has_oss_config" class="btn" style="background: #3b82f6; color: white; font-size: 11px; padding: 5px 10px;" @click="openFileInspection(u)">
<i class="fas fa-folder-open"></i> 文件
</button>
<button class="btn" style="background: #ef4444; color: white; font-size: 11px; padding: 5px 10px;" @click="deleteUser(u.id)">
@@ -3066,11 +3062,11 @@
<label class="form-label">存储权限</label>
<select class="form-input" v-model="editStorageForm.storage_permission">
<option value="local_only">仅本地存储</option>
<option value="sftp_only">SFTP存储</option>
<option value="oss_only">OSS存储</option>
<option value="user_choice">用户选择</option>
</select>
<small style="color: var(--text-secondary); font-size: 12px; margin-top: 5px; display: block;">
仅本地:用户只能使用本地存储 | 仅SFTP:用户只能使用SFTP | 用户选择:用户可自由切换
仅本地:用户只能使用本地存储 | 仅OSS:用户只能使用OSS | 用户选择:用户可自由切换
</small>
</div>
@@ -3094,7 +3090,7 @@
• 默认配额: 1GB<br>
• 当前配额: {{ editStorageForm.local_storage_quota_value }} {{ editStorageForm.quota_unit }}
({{ editStorageForm.quota_unit === 'GB' ? (editStorageForm.local_storage_quota_value * 1024).toFixed(0) : editStorageForm.local_storage_quota_value }} MB)<br>
• 配额仅影响本地存储,SFTP存储不受此限制
• 配额仅影响本地存储,OSS存储不受此限制
</div>
</div>

View File

@@ -48,15 +48,17 @@ createApp({
showCaptcha: false,
captchaUrl: '',
// SFTP配置表单
ftpConfigForm: {
ftp_host: '',
ftp_port: 22,
ftp_user: '',
ftp_password: '',
http_download_base_url: ''
// OSS配置表单
ossConfigForm: {
oss_provider: 'aliyun',
oss_region: '',
oss_access_key_id: '',
oss_access_key_secret: '',
oss_bucket: '',
oss_endpoint: ''
},
showFtpConfigModal: false,
showOssConfigModal: false,
ossConfigSaving: false, // OSS 配置保存中状态
// 修改密码表单
changePasswordForm: {
@@ -211,8 +213,8 @@ createApp({
verifyMessage: '',
// 存储相关
storageType: 'sftp', // 当前使用的存储类型
storagePermission: 'sftp_only', // 存储权限
storageType: 'oss', // 当前使用的存储类型
storagePermission: 'oss_only', // 存储权限
localQuota: 0, // 本地存储配额(字节)
localUsed: 0, // 本地存储已使用(字节)
@@ -242,7 +244,7 @@ createApp({
editStorageForm: {
userId: null,
username: '',
storage_permission: 'sftp_only',
storage_permission: 'oss_only',
local_storage_quota_value: 1, // 配额数值
quota_unit: 'GB' // 配额单位MB 或 GB
},
@@ -271,14 +273,14 @@ createApp({
suppressStorageToast: false,
profileInitialized: false,
// SFTP配置引导弹窗
showSftpGuideModal: false,
showSftpConfigModal: false,
// OSS配置引导弹窗
showOssGuideModal: false,
showOssConfigModal: false,
// SFTP空间使用统计
sftpUsage: null, // { totalSize, totalSizeFormatted, fileCount, dirCount }
sftpUsageLoading: false,
sftpUsageError: null,
// OSS空间使用统计
ossUsage: null, // { totalSize, totalSizeFormatted, fileCount, dirCount }
ossUsageLoading: false,
ossUsageError: null,
// 主题设置
currentTheme: 'dark', // 当前生效的主题: 'dark' 或 'light'
@@ -309,7 +311,7 @@ createApp({
// 存储类型显示文本
storageTypeText() {
return this.storageType === 'local' ? '本地存储' : 'SFTP存储';
return this.storageType === 'local' ? '本地存储' : 'OSS存储';
},
// 分享筛选+排序后的列表
@@ -613,17 +615,17 @@ handleDragLeave(e) {
this.startTokenRefresh(expiresIn);
// 直接从登录响应中获取存储信息
this.storagePermission = this.user.storage_permission || 'sftp_only';
this.storageType = this.user.current_storage_type || 'sftp';
this.storagePermission = this.user.storage_permission || 'oss_only';
this.storageType = this.user.current_storage_type || 'oss';
this.localQuota = this.user.local_storage_quota || 0;
this.localUsed = this.user.local_storage_used || 0;
console.log('[登录] 存储权限:', this.storagePermission, '存储类型:', this.storageType, 'SFTP配置:', this.user.has_ftp_config);
console.log('[登录] 存储权限:', this.storagePermission, '存储类型:', this.storageType, 'OSS配置:', this.user.has_oss_config);
// 智能存储类型修正:如果当前是SFTP但未配置,且用户有本地存储权限,自动切换到本地
if (this.storageType === 'sftp' && !this.user.has_ftp_config) {
// 智能存储类型修正:如果当前是OSS但未配置,且用户有本地存储权限,自动切换到本地
if (this.storageType === 'oss' && !this.user.has_oss_config) {
if (this.storagePermission === 'local_only' || this.storagePermission === 'user_choice') {
console.log('[登录] SFTP未配置但用户有本地存储权限,自动切换到本地存储');
console.log('[登录] OSS未配置但用户有本地存储权限,自动切换到本地存储');
this.storageType = 'local';
// 异步更新到后端(不等待,避免阻塞登录流程)
axios.post(`${this.apiBase}/api/user/switch-storage`, { storage_type: 'local' })
@@ -646,15 +648,15 @@ handleDragLeave(e) {
this.currentView = 'files';
this.loadFiles('/');
}
// 如果仅SFTP模式,需要检查是否配置了SFTP
else if (this.storagePermission === 'sftp_only') {
if (this.user.has_ftp_config) {
// 如果仅OSS模式,需要检查是否配置了OSS
else if (this.storagePermission === 'oss_only') {
if (this.user.has_oss_config) {
this.currentView = 'files';
this.loadFiles('/');
} else {
this.currentView = 'settings';
alert('欢迎!请先配置您的SFTP服务');
this.openSftpConfigModal();
alert('欢迎!请先配置您的OSS服务');
this.openOssConfigModal();
}
} else {
// 默认行为:跳转到文件页面
@@ -808,44 +810,56 @@ handleDragLeave(e) {
}
},
async updateFtpConfig() {
async updateOssConfig() {
// 防止重复提交
if (this.ossConfigSaving) {
return;
}
this.ossConfigSaving = true;
try {
const response = await axios.post(
`${this.apiBase}/api/user/update-ftp`,
this.ftpConfigForm,
`${this.apiBase}/api/user/update-oss`,
this.ossConfigForm,
);
if (response.data.success) {
alert('SFTP配置已保存');
// 更新用户信息
this.user.has_ftp_config = 1;
this.user.has_oss_config = 1;
// 如果用户有 user_choice 权限,自动切换到 SFTP 存储
if (this.storagePermission === 'user_choice' || this.storagePermission === 'sftp_only') {
// 如果用户有 user_choice 权限,自动切换到 OSS 存储
if (this.storagePermission === 'user_choice' || this.storagePermission === 'oss_only') {
try {
const switchResponse = await axios.post(
`${this.apiBase}/api/user/switch-storage`,
{ storage_type: 'sftp' },
);
{ storage_type: 'oss' },
);
if (switchResponse.data.success) {
this.storageType = 'sftp';
console.log('[SFTP配置] 已自动切换到SFTP存储模式');
this.storageType = 'oss';
console.log('[OSS配置] 已自动切换到OSS存储模式');
}
} catch (err) {
console.error('[SFTP配置] 自动切换存储模式失败:', err);
console.error('[OSS配置] 自动切换存储模式失败:', err);
}
}
// 关闭配置弹窗
this.showSftpConfigModal = false;
this.showOssConfigModal = false;
// 刷新到文件页面
this.currentView = 'files';
this.loadFiles('/');
// 显示成功提示
this.showToast('success', '配置成功', 'OSS存储配置已保存');
}
} catch (error) {
alert('配置失败: ' + (error.response?.data?.message || error.message));
console.error('OSS配置保存失败:', error);
this.showToast('error', '配置失败', error.response?.data?.message || error.message || '请检查配置信息后重试');
} finally {
this.ossConfigSaving = false;
}
},
@@ -905,7 +919,7 @@ handleDragLeave(e) {
}
},
async loadFtpConfig() {
async loadOssConfig() {
try {
const response = await axios.get(
`${this.apiBase}/api/user/profile`,
@@ -913,105 +927,20 @@ handleDragLeave(e) {
if (response.data.success && response.data.user) {
const user = response.data.user;
// 填充SFTP配置表单(密不回显)
this.ftpConfigForm.ftp_host = user.ftp_host || '';
this.ftpConfigForm.ftp_port = user.ftp_port || 22;
this.ftpConfigForm.ftp_user = user.ftp_user || '';
this.ftpConfigForm.ftp_password = ''; // 密不回显
this.ftpConfigForm.http_download_base_url = user.http_download_base_url || '';
// 填充OSS配置表单(密不回显)
this.ossConfigForm.oss_provider = user.oss_provider || 'aliyun';
this.ossConfigForm.oss_region = user.oss_region || '';
this.ossConfigForm.oss_access_key_id = user.oss_access_key_id || '';
this.ossConfigForm.oss_access_key_secret = ''; // 密不回显
this.ossConfigForm.oss_bucket = user.oss_bucket || '';
this.ossConfigForm.oss_endpoint = user.oss_endpoint || '';
}
} catch (error) {
console.error('加载SFTP配置失败:', error);
console.error('加载OSS配置失败:', error);
}
},
// 处理配置文件上传
handleConfigFileUpload(event) {
const file = event.target.files[0];
if (!file) return;
this.processConfigFile(file);
// 清空文件选择,允许重复选择同一文件
event.target.value = '';
},
// 处理配置文件拖拽
handleConfigFileDrop(event) {
const file = event.dataTransfer.files[0];
if (!file) return;
// 检查文件扩展名
if (!file.name.toLowerCase().endsWith('.inf')) {
this.showToast('error', '错误', '只支持 .inf 格式的配置文件');
return;
}
this.processConfigFile(file);
// 恢复背景色
event.currentTarget.style.background = '#f8f9ff';
},
// 处理配置文件
async processConfigFile(file) {
const reader = new FileReader();
reader.onload = async (e) => {
try {
const content = e.target.result;
const config = this.parseConfigFile(content);
if (config) {
// 填充表单
this.ftpConfigForm.ftp_host = config.ip || '';
this.ftpConfigForm.ftp_port = config.port || 22;
this.ftpConfigForm.ftp_user = config.id || '';
this.ftpConfigForm.ftp_password = config.pw || '';
this.ftpConfigForm.http_download_base_url = config.arr || '';
// 提示用户配置已导入,需要确认后保存
this.showToast('success', '成功', '配置文件已导入!请检查并确认信息后点击"保存配置"按钮');
} else {
this.showToast('error', '错误', '配置文件格式不正确,请检查文件内容');
}
} catch (error) {
console.error('解析配置文件失败:', error);
this.showToast('error', '错误', '解析配置文件失败: ' + error.message);
}
};
reader.readAsText(file);
},
// 解析INI格式的配置文件
parseConfigFile(content) {
const lines = content.split('\n');
const config = {};
for (let line of lines) {
line = line.trim();
// 跳过空行和注释
if (!line || line.startsWith('#') || line.startsWith(';') || line.startsWith('[')) {
continue;
}
// 解析 key=value 格式
const equalsIndex = line.indexOf('=');
if (equalsIndex > 0) {
const key = line.substring(0, equalsIndex).trim();
const value = line.substring(equalsIndex + 1).trim();
config[key] = value;
}
}
// 验证必需字段
if (config.ip && config.id && config.pw && config.port) {
return config;
}
return null;
},
// 上传工具配置引导已移除OSS 不需要配置文件导入)
async updateUsername() {
if (!this.usernameForm.newUsername || this.usernameForm.newUsername.length < 3) {
@@ -1107,8 +1036,8 @@ handleDragLeave(e) {
localStorage.setItem('user', JSON.stringify(this.user));
// 从最新的用户信息初始化存储相关字段
this.storagePermission = this.user.storage_permission || 'sftp_only';
this.storageType = this.user.current_storage_type || 'sftp';
this.storagePermission = this.user.storage_permission || 'oss_only';
this.storageType = this.user.current_storage_type || 'oss';
this.localQuota = this.user.local_storage_quota || 0;
this.localUsed = this.user.local_storage_used || 0;
@@ -1129,7 +1058,7 @@ handleDragLeave(e) {
targetView = savedView;
} else if (this.user.is_admin) {
targetView = 'admin';
} else if (this.storagePermission === 'sftp_only' && !this.user.has_ftp_config) {
} else if (this.storagePermission === 'oss_only' && !this.user.has_oss_config) {
targetView = 'settings';
} else {
targetView = 'files';
@@ -1311,17 +1240,42 @@ handleDragLeave(e) {
downloadFile(file) {
console.log("[DEBUG] 下载文件:", file);
// SFTP存储且有HTTP直链新窗口打开直接下载避免Mixed Content问题
if (file.httpDownloadUrl) {
window.open(file.httpDownloadUrl, '_blank');
return;
}
// 构建文件路径
const filePath = this.currentPath === '/' ? `/${file.name}` : `${this.currentPath}/${file.name}`;
// 本地存储,使用隐藏链接触发下载
const url = `${this.apiBase}/api/files/download?path=${encodeURIComponent(this.currentPath === '/' ? `/${file.name}` : `${this.currentPath}/${file.name}`)}`;
// OSS 模式:使用签名 URL 直连下载(不经过后端)
if (this.storageType === 'oss' && this.user?.has_oss_config) {
this.downloadFromOSS(filePath);
} else {
// 本地存储模式:通过后端下载
this.downloadFromLocal(filePath);
}
},
// OSS 直连下载
async downloadFromOSS(filePath) {
try {
// 获取签名 URL
const { data } = await axios.get(`${this.apiBase}/api/files/download-url`, {
params: { path: filePath }
});
if (data.success) {
// 直连 OSS 下载
window.open(data.downloadUrl, '_blank');
}
} catch (error) {
console.error('获取下载链接失败:', error);
this.showToast('error', '错误', '获取下载链接失败');
}
},
// 本地存储下载
downloadFromLocal(filePath) {
const url = `${this.apiBase}/api/files/download?path=${encodeURIComponent(filePath)}`;
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', file.name);
link.setAttribute('download', '');
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
@@ -1385,6 +1339,7 @@ handleDragLeave(e) {
this.showCreateFolderModal = false;
this.createFolderForm.folderName = '';
await this.loadFiles(this.currentPath); // 刷新文件列表
await this.refreshStorageUsage(); // 刷新空间统计OSS会增加空对象
}
} catch (error) {
console.error('[创建文件夹失败]', error);
@@ -1540,18 +1495,26 @@ handleDragLeave(e) {
// ===== 媒体预览功能 =====
// 获取媒体文件URL
getMediaUrl(file) {
// 获取媒体文件URLOSS直连或后端代理
async getMediaUrl(file) {
const filePath = this.currentPath === '/'
? `/${file.name}`
: `${this.currentPath}/${file.name}`;
// SFTP存储且配置了HTTP下载URL使用HTTP直接访问否则使用API下载
if (file.httpDownloadUrl) {
return file.httpDownloadUrl;
// OSS 模式:返回签名 URL用于媒体预览
if (this.storageType === 'oss' && this.user?.has_oss_config) {
try {
const { data } = await axios.get(`${this.apiBase}/api/files/download-url`, {
params: { path: filePath }
});
return data.success ? data.downloadUrl : null;
} catch (error) {
console.error('获取媒体URL失败:', error);
return null;
}
}
// 本地存储或未配置HTTP URL使用API下载同域 Cookie 验证)
// 本地存储模式:通过后端 API
return `${this.apiBase}/api/files/download?path=${encodeURIComponent(filePath)}`;
},
@@ -1634,7 +1597,9 @@ handleDragLeave(e) {
if (response.data.success) {
this.showToast('success', '成功', '文件已删除');
this.loadFiles(this.currentPath);
// 刷新文件列表和空间统计
await this.loadFiles(this.currentPath);
await this.refreshStorageUsage();
}
} catch (error) {
console.error('删除失败:', error);
@@ -1764,7 +1729,7 @@ handleDragLeave(e) {
},
async uploadFile(file) {
// 文件大小限制预检查(在上传前检查,避免用户等待上传完才发现超限)
// 文件大小限制预检查
if (file.size > this.maxUploadSize) {
const fileSizeMB = Math.round(file.size / (1024 * 1024));
const maxSizeMB = Math.round(this.maxUploadSize / (1024 * 1024));
@@ -1776,99 +1741,129 @@ handleDragLeave(e) {
return;
}
// 本地存储配额预检查
if (this.storageType === 'local') {
const estimatedUsage = this.localUsed + file.size;
if (estimatedUsage > this.localQuota) {
this.showToast(
'error',
'配额不足',
`文件大小 ${this.formatBytes(file.size)},剩余配额 ${this.formatBytes(this.localQuota - this.localUsed)},无法上传`
);
return;
}
// 如果使用率将超过90%,给出警告
const willExceed90 = (estimatedUsage / this.localQuota) > 0.9;
if (willExceed90) {
const confirmed = confirm(
`警告:上传此文件后将使用 ${Math.round((estimatedUsage / this.localQuota) * 100)}% 的配额。是否继续?`
);
if (!confirmed) return;
}
}
const formData = new FormData();
formData.append('file', file);
formData.append('path', this.currentPath);
// 设置上传状态
this.uploadingFileName = file.name;
this.uploadProgress = 0;
this.uploadedBytes = 0;
this.totalBytes = file.size;
try {
// 设置上传文件名和进度
this.uploadingFileName = file.name;
this.uploadProgress = 0;
this.uploadedBytes = 0;
this.totalBytes = 0;
const response = await axios.post(`${this.apiBase}/api/upload`, formData, {
headers: {
'Content-Type': 'multipart/form-data'
},
timeout: 30 * 60 * 1000, // 30分钟超时支持大文件上传
onUploadProgress: (progressEvent) => {
this.uploadProgress = Math.round((progressEvent.loaded * 100) / progressEvent.total);
this.uploadedBytes = progressEvent.loaded;
this.totalBytes = progressEvent.total;
}
});
if (response.data.success) {
// 显示成功提示
this.showToast('success', '上传成功', `文件 ${file.name} 已上传`);
// 重置上传进度
this.uploadProgress = 0;
this.uploadedBytes = 0;
this.totalBytes = 0;
this.uploadingFileName = '';
// 自动刷新文件列表
await this.loadFiles(this.currentPath);
if (this.storageType === 'oss' && this.user?.has_oss_config) {
// ===== OSS 直连上传(不经过后端) =====
await this.uploadToOSSDirect(file);
} else {
// ===== 本地存储上传(经过后端) =====
await this.uploadToLocal(file);
}
} catch (error) {
console.error('上传失败:', error);
// 重置上传进度
this.uploadProgress = 0;
this.uploadedBytes = 0;
this.totalBytes = 0;
this.uploadedBytes = 0;
this.totalBytes = 0;
this.uploadingFileName = '';
// 处理文件大小超限错误
if (error.response?.status === 413) {
const errorData = error.response.data;
this.showToast('error', '上传失败', error.message || '上传失败,请重试');
}
},
// 判断响应是JSON还是HTMLNginx返回HTMLBackend返回JSON
if (typeof errorData === 'object' && errorData.maxSize && errorData.fileSize) {
// Backend返回的JSON响应
const maxSizeMB = Math.round(errorData.maxSize / (1024 * 1024));
const fileSizeMB = Math.round(errorData.fileSize / (1024 * 1024));
this.showToast(
'error',
'文件超过上传限制',
`文件大小 ${fileSizeMB}MB 超过限制 ${maxSizeMB}MB`
);
} else {
// Nginx返回的HTML响应显示通用消息
const fileSizeMB = Math.round(file.size / (1024 * 1024));
this.showToast(
'error',
'文件超过上传限制',
`文件大小 ${fileSizeMB}MB 超过系统限制,请联系管理员`
);
// OSS 直连上传
async uploadToOSSDirect(file) {
try {
// 1. 获取签名 URL
const { data: signData } = await axios.get(`${this.apiBase}/api/files/upload-signature`, {
params: {
filename: file.name,
contentType: file.type || 'application/octet-stream'
}
} else {
this.showToast('error', '上传失败', error.response?.data?.message || error.message);
});
if (!signData.success) {
throw new Error(signData.message || '获取上传签名失败');
}
// 2. 直连 OSS 上传(不经过后端!)
await axios.put(signData.uploadUrl, file, {
headers: {
'Content-Type': file.type || 'application/octet-stream'
},
onUploadProgress: (progressEvent) => {
this.uploadProgress = Math.round((progressEvent.loaded * 100) / progressEvent.total);
this.uploadedBytes = progressEvent.loaded;
this.totalBytes = progressEvent.total;
},
timeout: 30 * 60 * 1000 // 30分钟超时
});
// 3. 通知后端上传完成
await axios.post(`${this.apiBase}/api/files/upload-complete`, {
objectKey: signData.objectKey,
size: file.size,
path: this.currentPath
});
// 4. 显示成功提示
this.showToast('success', '上传成功', `文件 ${file.name} 已上传到 OSS`);
// 5. 重置上传进度
this.uploadProgress = 0;
this.uploadedBytes = 0;
this.totalBytes = 0;
this.uploadingFileName = '';
// 6. 刷新文件列表和空间统计
await this.loadFiles(this.currentPath);
await this.refreshStorageUsage();
} catch (error) {
// 处理 CORS 错误
if (error.message?.includes('CORS') || error.message?.includes('Cross-Origin')) {
throw new Error('OSS 跨域配置错误,请联系管理员检查 Bucket CORS 设置');
}
throw error;
}
},
// 本地存储上传(经过后端)
async uploadToLocal(file) {
// 本地存储配额预检查
const estimatedUsage = this.localUsed + file.size;
if (estimatedUsage > this.localQuota) {
this.showToast(
'error',
'配额不足',
`文件大小 ${this.formatBytes(file.size)},剩余配额 ${this.formatBytes(this.localQuota - this.localUsed)}`
);
this.uploadProgress = 0;
this.uploadedBytes = 0;
this.totalBytes = 0;
this.uploadingFileName = '';
return;
}
const formData = new FormData();
formData.append('file', file);
formData.append('path', this.currentPath);
const response = await axios.post(`${this.apiBase}/api/upload`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 30 * 60 * 1000,
onUploadProgress: (progressEvent) => {
this.uploadProgress = Math.round((progressEvent.loaded * 100) / progressEvent.total);
this.uploadedBytes = progressEvent.loaded;
this.totalBytes = progressEvent.total;
}
});
if (response.data.success) {
this.showToast('success', '上传成功', `文件 ${file.name} 已上传`);
this.uploadProgress = 0;
this.uploadedBytes = 0;
this.totalBytes = 0;
this.uploadingFileName = '';
await this.loadFiles(this.currentPath);
await this.refreshStorageUsage();
}
},
@@ -2266,14 +2261,14 @@ handleDragLeave(e) {
if (response.data.success && response.data.user) {
const user = response.data.user;
// 同步用户信息(含 has_ftp_config
// 同步用户信息(含 has_oss_config
this.user = { ...(this.user || {}), ...user };
// 检测存储配置是否被管理员更改
const oldStorageType = this.storageType;
const oldStoragePermission = this.storagePermission;
const newStorageType = user.current_storage_type || 'sftp';
const newStoragePermission = user.storage_permission || 'sftp_only';
const newStorageType = user.current_storage_type || 'oss';
const newStoragePermission = user.storage_permission || 'oss_only';
// 更新本地数据
this.localQuota = user.local_storage_quota || 0;
@@ -2293,7 +2288,7 @@ handleDragLeave(e) {
console.log('[存储配置更新] 旧权限:', oldStoragePermission, '新权限:', newStoragePermission);
if (!this.suppressStorageToast) {
this.showToast('info', '存储配置已更新', `管理员已将您的存储方式更改为${newStorageType === 'local' ? '本地存储' : 'SFTP存储'}`);
this.showToast('info', '存储配置已更新', `管理员已将您的存储方式更改为${newStorageType === 'local' ? '本地存储' : 'OSS存储'}`);
} else {
this.suppressStorageToast = false;
}
@@ -2309,30 +2304,41 @@ handleDragLeave(e) {
}
},
// 加载SFTP空间使用统计
async loadSftpUsage() {
// 仅在用户已配置SFTP时才加载
if (!this.user?.has_ftp_config) {
this.sftpUsage = null;
// 加载OSS空间使用统计
async loadOssUsage() {
// 仅在用户已配置OSS时才加载
if (!this.user?.has_oss_config) {
this.ossUsage = null;
return;
}
this.sftpUsageLoading = true;
this.sftpUsageError = null;
this.ossUsageLoading = true;
this.ossUsageError = null;
try {
const response = await axios.get(
`${this.apiBase}/api/user/sftp-usage`,
`${this.apiBase}/api/user/oss-usage`,
);
if (response.data.success) {
this.sftpUsage = response.data.usage;
this.ossUsage = response.data.usage;
}
} catch (error) {
console.error('获取SFTP空间使用情况失败:', error);
this.sftpUsageError = error.response?.data?.message || '获取失败';
console.error('获取OSS空间使用情况失败:', error);
this.ossUsageError = error.response?.data?.message || '获取失败';
} finally {
this.sftpUsageLoading = false;
this.ossUsageLoading = false;
}
},
// 刷新存储空间使用统计(根据当前存储类型)
async refreshStorageUsage() {
if (this.storageType === 'oss' && this.user?.has_oss_config) {
// 刷新 OSS 空间统计
await this.loadOssUsage();
} else if (this.storageType === 'local') {
// 刷新本地存储统计(通过重新获取用户信息)
await this.loadUserProfile();
}
},
@@ -2368,9 +2374,9 @@ handleDragLeave(e) {
return;
}
// 切到SFTP但还未配置,引导弹窗
if (type === 'sftp' && (!this.user?.has_ftp_config)) {
this.showSftpGuideModal = true;
// 切到OSS但还未配置,引导弹窗
if (type === 'oss' && (!this.user?.has_oss_config)) {
this.showOssGuideModal = true;
return;
}
@@ -2387,7 +2393,7 @@ handleDragLeave(e) {
this.storageType = type;
// 用户主动切换后,下一次配置同步不提示管理员修改
this.suppressStorageToast = true;
this.showToast('success', '成功', `已切换到${type === 'local' ? '本地存储' : 'SFTP存储'}`);
this.showToast('success', '成功', `已切换到${type === 'local' ? '本地存储' : 'OSS存储'}`);
// 重新加载文件列表
if (this.currentView === 'files') {
@@ -2403,33 +2409,33 @@ handleDragLeave(e) {
}
},
ensureSftpConfigSection() {
this.openSftpConfigModal();
ensureOssConfigSection() {
this.openOssConfigModal();
},
openSftpGuideModal() {
this.showSftpGuideModal = true;
openOssGuideModal() {
this.showOssGuideModal = true;
},
closeSftpGuideModal() {
this.showSftpGuideModal = false;
closeOssGuideModal() {
this.showOssGuideModal = false;
},
proceedSftpGuide() {
this.showSftpGuideModal = false;
this.ensureSftpConfigSection();
proceedOssGuide() {
this.showOssGuideModal = false;
this.ensureOssConfigSection();
},
openSftpConfigModal() {
this.showSftpGuideModal = false;
this.showSftpConfigModal = true;
openOssConfigModal() {
this.showOssGuideModal = false;
this.showOssConfigModal = true;
if (this.user && !this.user.is_admin) {
this.loadFtpConfig();
this.loadOssConfig();
}
},
closeSftpConfigModal() {
this.showSftpConfigModal = false;
closeOssConfigModal() {
this.showOssConfigModal = false;
},
// 检查视图权限
@@ -2487,7 +2493,7 @@ handleDragLeave(e) {
openEditStorageModal(user) {
this.editStorageForm.userId = user.id;
this.editStorageForm.username = user.username;
this.editStorageForm.storage_permission = user.storage_permission || 'sftp_only';
this.editStorageForm.storage_permission = user.storage_permission || 'oss_only';
// 智能识别配额单位
const quotaBytes = user.local_storage_quota || 1073741824;
@@ -3082,8 +3088,8 @@ handleDragLeave(e) {
this.loadSystemSettings();
this.loadServerStorageStats();
} else if (newView === 'settings' && this.user && !this.user.is_admin) {
// 普通用户进入设置页面时加载SFTP配置
this.loadFtpConfig();
// 普通用户进入设置页面时加载OSS配置
this.loadOssConfig();
}
// 记住最后停留的视图(需合法且已登录)

View File

@@ -564,7 +564,7 @@
现代化<br><span class="gradient-text">云存储平台</span>
</h1>
<p class="hero-desc">
简单、安全、高效的文件管理解决方案。支持 SFTP 远程连接和服务器本地存储双模式,随时随地管理和分享你的文件。
简单、安全、高效的文件管理解决方案。支持 OSS 云存储和服务器本地存储双模式,随时随地管理和分享你的文件。
</p>
<div class="hero-buttons">
<a href="app.html?action=register" class="btn btn-primary btn-large">
@@ -596,10 +596,10 @@
<div class="features-grid">
<div class="feature-card">
<div class="feature-icon">
<i class="fas fa-server"></i>
<i class="fas fa-cloud"></i>
</div>
<h3 class="feature-title">SFTP 连接</h3>
<p class="feature-desc">连接你自己的服务器,数据完全自主掌控</p>
<h3 class="feature-title">OSS 云存储</h3>
<p class="feature-desc">支持阿里云、腾讯云、AWS S3,数据完全自主掌控</p>
</div>
<div class="feature-card">
<div class="feature-icon">

View File

@@ -336,7 +336,9 @@
if (data.success && data.theme === 'light') {
document.body.classList.add('light-theme');
}
} catch (e) {}
} catch (e) {
console.warn('[主题加载] 失败,使用默认主题:', e.message);
}
}
// 获取URL参数

View File

@@ -918,42 +918,47 @@
this.viewingFile = null;
},
downloadFile(file) {
async downloadFile(file) {
console.log("[分享下载] 文件:", file);
// 记录下载次数(异步,不等待)
axios.post(`${this.apiBase}/api/share/${this.shareCode}/download`)
.catch(err => console.error('记录下载次数失败:', err));
if (file.httpDownloadUrl) {
// 如果配置了HTTP下载URL新窗口打开直接下载避免Mixed Content问题
console.log("[分享下载] 使用HTTP下载:", file.httpDownloadUrl);
window.open(file.httpDownloadUrl, '_blank');
return;
// 构建文件路径
let filePath;
if (this.shareInfo.share_type === 'file') {
// 单文件分享,使用 share_path
filePath = this.shareInfo.share_path;
} else {
// 如果没有配置HTTP URL通过后端SFTP下载
console.log("[分享下载] 使用SFTP下载");
// 目录分享,组合路径
const basePath = this.shareInfo.share_path;
filePath = basePath === '/' ? `/${file.name}` : `${basePath}/${file.name}`;
}
// 构建文件路径
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)}`;
// 如果有密码,附加密码参数
try {
// 获取下载 URLOSS 直连或后端代理)
const params = { path: filePath };
if (this.password) {
downloadUrl += `&password=${encodeURIComponent(this.password)}`;
params.password = this.password;
}
this.triggerDownload(downloadUrl, file.name);
const { data } = await axios.get(`${this.apiBase}/api/share/${this.shareCode}/download-url`, { params });
if (data.success) {
// 记录下载次数(异步,不等待)
axios.post(`${this.apiBase}/api/share/${this.shareCode}/download`)
.catch(err => console.error('记录下载次数失败:', err));
if (data.direct) {
// OSS 直连下载:新窗口打开
console.log("[分享下载] OSS 直连下载");
window.open(data.downloadUrl, '_blank');
} else {
// 本地存储:通过后端下载
console.log("[分享下载] 后端代理下载");
this.triggerDownload(data.downloadUrl, file.name);
}
}
} catch (error) {
console.error('[分享下载] 获取下载链接失败:', error);
alert('获取下载链接失败: ' + (error.response?.data?.message || error.message));
}
},

View File

@@ -210,7 +210,9 @@
if (data.success && data.theme === 'light') {
document.body.classList.add('light-theme');
}
} catch (e) {}
} catch (e) {
console.warn('[主题加载] 失败,使用默认主题:', e.message);
}
}
// 获取URL参数

View File

@@ -2266,7 +2266,7 @@ build_upload_tool() {
fi
else
print_warning "未找到wget或curl无法下载上传工具"
print_info "用户仍可使用网页上传(本地存储或SFTP客户端"
print_info "用户仍可使用网页上传(本地存储/OSS云存储"
echo ""
return 0
fi
@@ -2290,7 +2290,7 @@ build_upload_tool() {
print_error "下载的文件大小异常(${FILE_SIZE}字节),可能下载不完整"
rm -f "dist/${TOOL_FILENAME}"
print_warning "可手动下载: ${TOOL_DOWNLOAD_URL}"
print_info "用户仍可使用网页上传(本地存储或SFTP客户端"
print_info "用户仍可使用网页上传(本地存储/OSS云存储"
echo ""
fi
else
@@ -2300,7 +2300,7 @@ build_upload_tool() {
echo " 2. CDN链接不可访问: ${TOOL_DOWNLOAD_URL}"
echo " 3. 防火墙拦截HTTP连接"
print_info "您可以稍后手动下载并放置到: ${PROJECT_DIR}/upload-tool/dist/"
print_info "用户仍可使用网页上传(本地存储或SFTP客户端"
print_info "用户仍可使用网页上传(本地存储/OSS云存储"
echo ""
fi
}

View File

@@ -1,24 +1,24 @@
============================================
玩玩云上传工具 v2.0 使用说明
玩玩云上传工具 v3.0 使用说明
============================================
【新版本特性】
✨ 支持多文件上传
支持文件夹上传(递归扫描所有文件)
✨ 支持阿里云 OSS、腾讯云 COS、AWS S3
通过服务器 API 上传,自动识别存储类型
✨ 支持多文件和文件夹上传
✨ 智能上传队列管理
自动检测可写目录(容错机制)
✨ 实时显示队列状态
实时显示存储类型和空间使用情况
【功能介绍】
本工具用于快速上传文件到您的SFTP服务器
新版本支持批量上传和文件夹上传,大大提升工作效率
本工具用于快速上传文件到您的玩玩云存储
支持本地存储和 OSS 云存储双模式,自动适配
【使用方法】
1. 双击运行"玩玩云上传工具.exe"
2. 等待程序连接服务器并测试上传目录
- 程序会自动测试多个目录的可写性
- 显示绿色✓表示连接成功
- 显示当前使用的上传目录
2. 等待程序连接服务器
- 程序会自动检测服务器配置
- 显示当前存储类型(本地存储/OSS
- OSS 模式会显示存储桶信息
3. 拖拽文件或文件夹到窗口中
- 可以一次拖拽多个文件
- 可以拖拽整个文件夹(自动扫描所有文件)
@@ -30,38 +30,46 @@
- 每个文件都有独立的进度显示
- 日志区域显示详细的上传信息
目录容错机制
程序会按以下优先级自动测试并选择可写目录:
1. /(根目录)
2. /upload
3. /uploads
4. /files
5. /home
6. /tmp
存储类型说明
如果根目录没有写权限,程序会自动切换到其他可用目录。
本地存储模式:
- 文件存储在服务器本地磁盘
- 适合小文件和内网环境
- 由服务器管理员管理配额
OSS 云存储模式:
- 支持阿里云 OSS、腾讯云 COS、AWS S3
- 文件直接存储到云存储桶
- 适合大文件和外网访问
- 无限存储空间(由云服务商决定)
【注意事项】
- 文件夹上传会递归扫描所有子文件夹
- 同名文件会被覆盖
- 上传大量文件时请确保网络稳定
- 所有文件会按顺序依次上传
- 上传目录会在启动时自动检测并显示
- OSS 模式下大文件会自动分片上传
【界面说明】
- 状态显示:显示连接状态和存储类型
- 拖拽区域:显示"支持多文件和文件夹"
- 队列状态:显示等待上传的文件数量
- 进度条:显示当前文件的上传进度
- 日志区域:显示详细的操作记录
【版本更新】
v2.0 (2025-11-09)
- ✅ 新增多文件上传支持
- ✅ 新增文件夹上传支持
- ✅ 新增上传队列管理
- ✅ 新增目录容错机制
v3.0 (2025-01-18)
- 🚀 架构升级SFTP → OSS 云存储
- ✅ 支持阿里云 OSS、腾讯云 COS、AWS S3
- ✅ 使用服务器 API 上传,自动识别存储类型
- ✅ 新增存储类型显示
- ✅ 优化界面显示
- ✅ 优化日志输出
- ✅ 优化错误提示
v2.0 (2025-11-09)
- 新增多文件上传支持
- 新增文件夹上传支持
- 新增上传队列管理
v1.0
- 基础单文件上传功能
@@ -74,14 +82,14 @@ A: 理论上无限制,所有文件会加入队列依次上传
Q: 文件夹上传包括子文件夹吗?
A: 是的,会递归扫描所有子文件夹中的文件
Q: 上传目录是哪里
A: 程序启动时会自动检测并显示在界面上
Q: 如何切换存储类型
A: 存储类型由用户配置决定,请在网页端设置
Q: 提示"API密钥无效或已过期"怎么办?
A: 请重新从网站下载最新的上传工具
Q: 提示"API密钥无效"怎么办?
A: 请在网页端重新生成上传 API 密钥
Q: 上传速度慢怎么办?
A: 速度取决于您的网络和SFTP服务器性能
A: 速度取决于您的网络和服务器/云存储性能
Q: 可以中途取消上传吗?
A: 当前版本暂不支持取消,请等待队列完成

View File

@@ -1,3 +1,2 @@
PyQt5==5.15.9
paramiko==3.4.0
requests==2.31.0

View File

@@ -1,155 +1,152 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
玩玩云上传工具 v3.0
支持本地存储和 OSS 云存储
"""
import sys
import os
import json
import requests
import paramiko
import hashlib
import time
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):
"""上传线程"""
"""上传线程 - 支持 OSS 和本地存储"""
progress = pyqtSignal(int, str) # 进度,状态信息
finished = pyqtSignal(bool, str) # 成功/失败,消息
def __init__(self, sftp_config, file_path, remote_dir):
def __init__(self, api_config, file_path, remote_path):
super().__init__()
self.sftp_config = sftp_config
self.api_config = api_config
self.file_path = file_path
self.remote_dir = remote_dir
self.remote_path = remote_path
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}'
self.progress.emit(10, f'正在准备上传: {filename}')
# 上传文件(带进度)- 使用临时文件避免.fuse_hidden问题
file_size = os.path.getsize(self.file_path)
# 使用服务器 API 上传
api_base_url = self.api_config['api_base_url']
api_key = self.api_config['api_key']
# 读取文件
with open(self.file_path, 'rb') as f:
file_data = f.read()
file_size = len(file_data)
self.progress.emit(20, f'文件大小: {file_size / (1024*1024):.2f} MB')
# 分块上传(支持大文件)
chunk_size = 5 * 1024 * 1024 # 5MB 每块
uploaded = 0
# 使用临时文件名
import time
temp_remote_path = f'{remote_path}.uploading_{int(time.time() * 1000)}'
# 使用 multipart/form-data 上传
files = {
'file': (filename, file_data)
}
data = {
'path': self.remote_path
}
headers = {
'X-API-Key': api_key
}
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)
self.progress.emit(30, f'开始上传...')
# 计算速度
size_mb = transferred / (1024 * 1024)
self.progress.emit(progress_value, f'上传中: {filename} ({size_mb:.2f} MB / {total/(1024*1024):.2f} MB)')
# 带进度的上传
response = requests.post(
f"{api_base_url}/api/upload",
files=files,
data=data,
headers=headers,
timeout=300 # 5分钟超时
)
# 第一步:上传到临时文件
sftp.put(self.file_path, temp_remote_path, callback=callback)
uploaded = file_size
self.progress.emit(90, f'上传完成,等待服务器确认...')
# 第二步:删除旧文件(如果存在)
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} 上传成功!')
if response.status_code == 200:
result = response.json()
if result.get('success'):
self.progress.emit(100, f'上传完成!')
self.finished.emit(True, f'文件 {filename} 上传成功!')
else:
self.finished.emit(False, f'上传失败: {result.get("message", "未知错误")}')
else:
self.finished.emit(False, f'服务器错误: {response.status_code}')
except requests.exceptions.Timeout:
self.finished.emit(False, '上传超时,请检查网络连接')
except requests.exceptions.ConnectionError:
self.finished.emit(False, '无法连接到服务器,请检查网络')
except Exception as e:
self.finished.emit(False, f'上传失败: {str(e)}')
class ConfigCheckThread(QThread):
"""配置检查线程"""
result = pyqtSignal(bool, str, object) # 成功/失败,消息,配置信息
def __init__(self, api_base_url, api_key):
super().__init__()
self.api_base_url = api_base_url
self.api_key = api_key
def run(self):
try:
response = requests.post(
f"{self.api_base_url}/api/upload/get-config",
json={'api_key': self.api_key},
timeout=10
)
if response.status_code == 200:
data = response.json()
if data['success']:
config = data['config']
storage_type = config.get('storage_type', 'unknown')
if storage_type == 'oss':
# OSS 云存储
provider = config.get('oss_provider', '未知')
bucket = config.get('oss_bucket', '未知')
msg = f'已连接 - OSS存储 ({provider}) | Bucket: {bucket}'
else:
# 本地存储
msg = f'已连接 - 本地存储'
self.result.emit(True, msg, config)
else:
self.result.emit(False, data.get('message', '获取配置失败'), None)
else:
self.result.emit(False, f'服务器错误: {response.status_code}', None)
except requests.exceptions.Timeout:
self.result.emit(False, '连接超时,请检查网络', None)
except requests.exceptions.ConnectionError:
self.result.emit(False, '无法连接到服务器', None)
except Exception as e:
self.result.emit(False, f'连接失败: {str(e)}', None)
class UploadWindow(QMainWindow):
def __init__(self):
super().__init__()
self.config = self.load_config()
self.sftp_config = None
self.remote_dir = '/' # 默认上传目录
self.server_config = None
self.remote_path = '/' # 默认上传目录
self.upload_queue = [] # 上传队列
self.is_uploading = False # 是否正在上传
self.initUI()
self.get_sftp_config()
self.check_config()
def load_config(self):
"""加载配置文件"""
@@ -176,87 +173,58 @@ class UploadWindow(QMainWindow):
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
)
def check_config(self):
"""检查服务器配置"""
self.log('正在连接服务器...')
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.check_thread = ConfigCheckThread(
self.config['api_base_url'],
self.config['api_key']
)
self.check_thread.result.connect(self.on_config_result)
self.check_thread.start()
# 按优先级测试目录
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):
"""处理测试结果"""
def on_config_result(self, success, message, config):
"""处理配置检查结果"""
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>'
)
self.server_config = config
self.log(f'{message}')
# 更新状态显示
if config.get('storage_type') == 'oss':
provider_name = {
'aliyun': '阿里云OSS',
'tencent': '腾讯云COS',
'aws': 'AWS S3'
}.get(config.get('oss_provider'), config.get('oss_provider', 'OSS'))
self.status_label.setText(
f'<h2>玩玩云上传工具 v3.0</h2>'
f'<p style="color: green;">✓ 已连接 - {provider_name}</p>'
f'<p style="color: #666; font-size: 14px;">拖拽文件到此处上传</p>'
f'<p style="color: #999; font-size: 12px;">存储桶: {config.get("oss_bucket", "未知")}</p>'
)
else:
self.status_label.setText(
f'<h2>玩玩云上传工具 v3.0</h2>'
f'<p style="color: green;">✓ 已连接 - 本地存储</p>'
f'<p style="color: #666; font-size: 14px;">拖拽文件到此处上传</p>'
)
else:
self.log(f' 目录 {self.test_dirs[self.current_test_index]} 不可写: {message}')
self.current_test_index += 1
self.test_next_directory()
self.log(f'{message}')
self.show_error(message)
def show_error(self, message):
"""显示错误信息"""
self.status_label.setText(
f'<h2>玩玩云上传工具 v2.0</h2>'
f'<h2>玩玩云上传工具 v3.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.setWindowTitle('玩玩云上传工具 v3.0')
self.setGeometry(300, 300, 500, 450)
# 设置接受拖拽
@@ -339,7 +307,7 @@ class UploadWindow(QMainWindow):
central_widget.setLayout(layout)
self.log('程序已启动 - 版本 v2.0')
self.log('程序已启动 - 版本 v3.0 (支持OSS云存储)')
def log(self, message):
"""添加日志"""
@@ -395,8 +363,9 @@ class UploadWindow(QMainWindow):
}
""")
if not self.sftp_config:
self.log('错误: 未获取到SFTP配置')
if not self.server_config:
self.log('错误: 未连接到服务器,请等待连接完成')
self.show_error('服务器未连接,请稍后重试')
return
paths = [url.toLocalFile() for url in event.mimeData().urls()]
@@ -453,7 +422,15 @@ class UploadWindow(QMainWindow):
def upload_file(self, file_path):
"""上传文件"""
self.log(f'开始上传: {os.path.basename(file_path)}')
filename = os.path.basename(file_path)
# 构建远程路径
if self.remote_path == '/':
remote_path = f'/{filename}'
else:
remote_path = f'{self.remote_path}/{filename}'
self.log(f'开始上传: {filename}')
# 显示进度控件
self.progress_bar.setVisible(True)
@@ -462,7 +439,11 @@ class UploadWindow(QMainWindow):
self.progress_label.setText('准备上传...')
# 创建上传线程
self.upload_thread = UploadThread(self.sftp_config, file_path, self.remote_dir)
api_config = {
'api_base_url': self.config['api_base_url'],
'api_key': self.config['api_key']
}
self.upload_thread = UploadThread(api_config, file_path, remote_path)
self.upload_thread.progress.connect(self.on_progress)
self.upload_thread.finished.connect(self.on_finished)
self.upload_thread.start()