Compare commits
81 Commits
d46d20f670
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| d8cd7fd514 | |||
| b161a3e3e7 | |||
| b179cae14e | |||
| c8f63d6fc9 | |||
| fe544efc91 | |||
| 01384a2215 | |||
| 6618de1aed | |||
| e2318ac42e | |||
| cdfe45b3a2 | |||
| 7563664733 | |||
| 77799ef819 | |||
| 374238d15f | |||
| 099ba3e3e0 | |||
| 71a19e9e87 | |||
| 9c3ced5c44 | |||
| 5082a5ed04 | |||
| d604b8dc7b | |||
| 50d41cb7ae | |||
| 19f53875c9 | |||
| 365ada1a4a | |||
| 3329ff10cf | |||
| 9d600a2d5c | |||
| bb8a4ea386 | |||
| fd236e6949 | |||
| b931f36bde | |||
| e4098bfda9 | |||
| fec2bd37a4 | |||
| af51d74a9f | |||
| ada7986669 | |||
| 74032fe497 | |||
| f96a9ccaa9 | |||
| c83d9304ea | |||
| 4b3a113285 | |||
| c81b5395ac | |||
| 5f91fd925d | |||
| 5c484e33a6 | |||
| c668c88f7f | |||
| 32a66e6c77 | |||
| d4818a78d3 | |||
| 09043e8059 | |||
| 2b36275c4a | |||
| 8736a127a5 | |||
| 24ac734503 | |||
| 9da90f38cc | |||
| 3c483d2093 | |||
| e343f6ac2a | |||
| 3ea17db971 | |||
| 751428a29a | |||
| 5eab1de03e | |||
| 7ee727bd3a | |||
| 96ff46aa4a | |||
| 8956270a60 | |||
| 1a1c64c0e7 | |||
| 3c75986566 | |||
| aad1202d5e | |||
| 5f7599bd0d | |||
| b261d2750c | |||
| e909d9917a | |||
| 6242622f1a | |||
| d236a790a1 | |||
| aed5dfdcb2 | |||
| 1eae645bfd | |||
| c506cf83be | |||
| 0885195cb5 | |||
| f0e7381c1d | |||
| 2b700978ad | |||
| dd6c439eb3 | |||
| 978ae545e1 | |||
| 53e77ebf4e | |||
| 3ab92d672d | |||
| 19d3f29f6b | |||
| 10a3f09952 | |||
| b171b41599 | |||
| 3a22b88f23 | |||
| 7687397954 | |||
| 2629237f9e | |||
| b0e89df5c4 | |||
| a3932747e3 | |||
| 8193101566 | |||
| 12859cbb20 | |||
| 1fcc60b9aa |
6
.gitignore
vendored
@@ -15,9 +15,11 @@ __pycache__/
|
||||
|
||||
# 临时文件
|
||||
backend/uploads/
|
||||
backend/storage/ # 本地存储数据
|
||||
# 本地存储数据目录(保留 .gitkeep)
|
||||
backend/storage/*
|
||||
!backend/storage/.gitkeep
|
||||
backend/data/ # 数据库目录
|
||||
# 数据库目录(保留 .gitkeep)
|
||||
backend/data/*
|
||||
!backend/data/.gitkeep
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
102
backend/auth.js
@@ -1,7 +1,9 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const crypto = require('crypto');
|
||||
const { UserDB } = require('./database');
|
||||
const { UserDB, DeviceSessionDB } = require('./database');
|
||||
const { decryptSecret } = require('./utils/encryption');
|
||||
const DEFAULT_LOCAL_STORAGE_QUOTA_BYTES = 1024 * 1024 * 1024; // 1GB
|
||||
const DEFAULT_OSS_STORAGE_QUOTA_BYTES = 1024 * 1024 * 1024; // 1GB
|
||||
|
||||
// JWT密钥(必须在环境变量中设置)
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
|
||||
@@ -62,13 +64,16 @@ if (JWT_SECRET.length < 32) {
|
||||
console.log('[安全] ✓ JWT密钥验证通过');
|
||||
|
||||
// 生成Access Token(短期)
|
||||
function generateToken(user) {
|
||||
function generateToken(user, sessionId = null) {
|
||||
const safeSessionId = typeof sessionId === 'string' && sessionId.trim() ? sessionId.trim() : null;
|
||||
return jwt.sign(
|
||||
{
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
is_admin: user.is_admin,
|
||||
type: 'access'
|
||||
type: 'access',
|
||||
sid: safeSessionId,
|
||||
jti: crypto.randomBytes(12).toString('hex')
|
||||
},
|
||||
JWT_SECRET,
|
||||
{ expiresIn: ACCESS_TOKEN_EXPIRES }
|
||||
@@ -76,11 +81,13 @@ function generateToken(user) {
|
||||
}
|
||||
|
||||
// 生成Refresh Token(长期)
|
||||
function generateRefreshToken(user) {
|
||||
function generateRefreshToken(user, sessionId = null) {
|
||||
const safeSessionId = typeof sessionId === 'string' && sessionId.trim() ? sessionId.trim() : null;
|
||||
return jwt.sign(
|
||||
{
|
||||
id: user.id,
|
||||
type: 'refresh',
|
||||
sid: safeSessionId,
|
||||
// 添加随机标识,使每次生成的refresh token不同
|
||||
jti: crypto.randomBytes(16).toString('hex')
|
||||
},
|
||||
@@ -89,8 +96,26 @@ function generateRefreshToken(user) {
|
||||
);
|
||||
}
|
||||
|
||||
function decodeAccessToken(token) {
|
||||
if (!token || typeof token !== 'string') return null;
|
||||
try {
|
||||
return jwt.verify(token, JWT_SECRET);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function decodeRefreshToken(token) {
|
||||
if (!token || typeof token !== 'string') return null;
|
||||
try {
|
||||
return jwt.verify(token, REFRESH_SECRET);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 验证Refresh Token并返回新的Access Token
|
||||
function refreshAccessToken(refreshToken) {
|
||||
function refreshAccessToken(refreshToken, context = {}) {
|
||||
try {
|
||||
const decoded = jwt.verify(refreshToken, REFRESH_SECRET);
|
||||
|
||||
@@ -98,6 +123,18 @@ function refreshAccessToken(refreshToken) {
|
||||
return { success: false, message: '无效的刷新令牌类型' };
|
||||
}
|
||||
|
||||
const sessionId = typeof decoded.sid === 'string' ? decoded.sid.trim() : '';
|
||||
if (sessionId) {
|
||||
const activeSession = DeviceSessionDB.findActiveBySessionId(sessionId);
|
||||
if (!activeSession || Number(activeSession.user_id) !== Number(decoded.id)) {
|
||||
return { success: false, message: '当前设备会话已失效,请重新登录' };
|
||||
}
|
||||
DeviceSessionDB.touch(sessionId, {
|
||||
ipAddress: context.ipAddress,
|
||||
userAgent: context.userAgent
|
||||
});
|
||||
}
|
||||
|
||||
const user = UserDB.findById(decoded.id);
|
||||
|
||||
if (!user) {
|
||||
@@ -113,11 +150,12 @@ function refreshAccessToken(refreshToken) {
|
||||
}
|
||||
|
||||
// 生成新的access token
|
||||
const newAccessToken = generateToken(user);
|
||||
const newAccessToken = generateToken(user, sessionId || null);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
token: newAccessToken,
|
||||
sessionId: sessionId || null,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
@@ -146,6 +184,29 @@ function authMiddleware(req, res, next) {
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
|
||||
if (decoded.type !== 'access') {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: '无效的令牌类型'
|
||||
});
|
||||
}
|
||||
|
||||
const sessionId = typeof decoded.sid === 'string' ? decoded.sid.trim() : '';
|
||||
if (sessionId) {
|
||||
const activeSession = DeviceSessionDB.findActiveBySessionId(sessionId);
|
||||
if (!activeSession || Number(activeSession.user_id) !== Number(decoded.id)) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: '当前设备已下线,请重新登录'
|
||||
});
|
||||
}
|
||||
DeviceSessionDB.touch(sessionId, {
|
||||
ipAddress: req.ip || req.socket?.remoteAddress || '',
|
||||
userAgent: req.get('user-agent') || ''
|
||||
});
|
||||
}
|
||||
|
||||
const user = UserDB.findById(decoded.id);
|
||||
|
||||
if (!user) {
|
||||
@@ -169,6 +230,22 @@ function authMiddleware(req, res, next) {
|
||||
});
|
||||
}
|
||||
|
||||
const rawOssQuota = Number(user.oss_storage_quota);
|
||||
const effectiveOssQuota = Number.isFinite(rawOssQuota) && rawOssQuota > 0
|
||||
? rawOssQuota
|
||||
: DEFAULT_OSS_STORAGE_QUOTA_BYTES;
|
||||
const rawDownloadTrafficQuota = Number(user.download_traffic_quota);
|
||||
const effectiveDownloadTrafficQuota = Number.isFinite(rawDownloadTrafficQuota) && rawDownloadTrafficQuota > 0
|
||||
? Math.floor(rawDownloadTrafficQuota)
|
||||
: 0; // 0 表示不限流量
|
||||
const rawDownloadTrafficUsed = Number(user.download_traffic_used);
|
||||
const normalizedDownloadTrafficUsed = Number.isFinite(rawDownloadTrafficUsed) && rawDownloadTrafficUsed > 0
|
||||
? Math.floor(rawDownloadTrafficUsed)
|
||||
: 0;
|
||||
const cappedDownloadTrafficUsed = effectiveDownloadTrafficQuota > 0
|
||||
? Math.min(normalizedDownloadTrafficUsed, effectiveDownloadTrafficQuota)
|
||||
: normalizedDownloadTrafficUsed;
|
||||
|
||||
// 将用户信息附加到请求对象(包含所有存储相关字段)
|
||||
req.user = {
|
||||
id: user.id,
|
||||
@@ -187,11 +264,20 @@ function authMiddleware(req, res, next) {
|
||||
// 存储相关字段
|
||||
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_quota: user.local_storage_quota || DEFAULT_LOCAL_STORAGE_QUOTA_BYTES,
|
||||
local_storage_used: user.local_storage_used || 0,
|
||||
oss_storage_quota: effectiveOssQuota,
|
||||
storage_used: user.storage_used || 0,
|
||||
download_traffic_quota: effectiveDownloadTrafficQuota,
|
||||
download_traffic_used: cappedDownloadTrafficUsed,
|
||||
download_traffic_quota_expires_at: user.download_traffic_quota_expires_at || null,
|
||||
download_traffic_reset_cycle: user.download_traffic_reset_cycle || 'none',
|
||||
download_traffic_last_reset_at: user.download_traffic_last_reset_at || null,
|
||||
// 主题偏好
|
||||
theme_preference: user.theme_preference || null
|
||||
};
|
||||
req.authSessionId = sessionId || null;
|
||||
req.authTokenJti = typeof decoded.jti === 'string' ? decoded.jti : null;
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
@@ -304,6 +390,8 @@ module.exports = {
|
||||
JWT_SECRET,
|
||||
generateToken,
|
||||
generateRefreshToken,
|
||||
decodeAccessToken,
|
||||
decodeRefreshToken,
|
||||
refreshAccessToken,
|
||||
authMiddleware,
|
||||
adminMiddleware,
|
||||
|
||||
1794
backend/database.js
577
backend/package-lock.json
generated
@@ -18,6 +18,8 @@
|
||||
"author": "玩玩云团队",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.985.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.985.0",
|
||||
"archiver": "^7.0.1",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^11.8.1",
|
||||
@@ -28,10 +30,9 @@
|
||||
"express-session": "^1.18.2",
|
||||
"express-validator": "^7.3.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lodash": "^4.17.23",
|
||||
"multer": "^2.0.2",
|
||||
"nodemailer": "^6.9.14",
|
||||
"@aws-sdk/client-s3": "^3.600.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.600.0",
|
||||
"nodemailer": "^8.0.1",
|
||||
"svg-captcha": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
* - POST /api/share/:code/verify
|
||||
* - POST /api/share/:code/list
|
||||
* - POST /api/share/:code/download
|
||||
* - GET /api/share/:code/download-url
|
||||
* - POST /api/share/:code/download-url
|
||||
* - GET /api/share/:code/download-file
|
||||
*
|
||||
* 6. routes/admin.js - 管理员功能
|
||||
|
||||
5980
backend/server.js
@@ -1,4 +1,16 @@
|
||||
const { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectsCommand, ListObjectsV2Command, HeadObjectCommand, CopyObjectCommand } = require('@aws-sdk/client-s3');
|
||||
const {
|
||||
S3Client,
|
||||
PutObjectCommand,
|
||||
GetObjectCommand,
|
||||
DeleteObjectsCommand,
|
||||
ListObjectsV2Command,
|
||||
HeadObjectCommand,
|
||||
CopyObjectCommand,
|
||||
CreateMultipartUploadCommand,
|
||||
UploadPartCommand,
|
||||
CompleteMultipartUploadCommand,
|
||||
AbortMultipartUploadCommand
|
||||
} = require('@aws-sdk/client-s3');
|
||||
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
@@ -18,6 +30,25 @@ function formatFileSize(bytes) {
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value, fallback) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.floor(parsed);
|
||||
}
|
||||
|
||||
const OSS_MULTIPART_MAX_PARTS = 10000;
|
||||
const OSS_MULTIPART_MIN_PART_SIZE = 5 * 1024 * 1024; // S3 协议要求最小 5MB(最后一片除外)
|
||||
const OSS_MULTIPART_DEFAULT_PART_SIZE = Math.max(
|
||||
OSS_MULTIPART_MIN_PART_SIZE,
|
||||
parsePositiveInteger(process.env.OSS_MULTIPART_PART_SIZE_BYTES, 16 * 1024 * 1024)
|
||||
);
|
||||
const OSS_SINGLE_UPLOAD_THRESHOLD = Math.max(
|
||||
OSS_MULTIPART_MIN_PART_SIZE,
|
||||
parsePositiveInteger(process.env.OSS_SINGLE_UPLOAD_THRESHOLD_BYTES, 64 * 1024 * 1024)
|
||||
);
|
||||
|
||||
/**
|
||||
* 将 OSS/网络错误转换为友好的错误信息
|
||||
* @param {Error} error - 原始错误
|
||||
@@ -1090,9 +1121,13 @@ class OssStorageClient {
|
||||
* @param {string} remotePath - 远程文件路径
|
||||
*/
|
||||
async put(localPath, remotePath) {
|
||||
let bucket = '';
|
||||
let key = '';
|
||||
let uploadId = null;
|
||||
let fileHandle = null;
|
||||
try {
|
||||
const key = this.getObjectKey(remotePath);
|
||||
const bucket = this.getBucket();
|
||||
key = this.getObjectKey(remotePath);
|
||||
bucket = this.getBucket();
|
||||
|
||||
// 检查本地文件是否存在
|
||||
if (!fs.existsSync(localPath)) {
|
||||
@@ -1102,30 +1137,107 @@ class OssStorageClient {
|
||||
const fileStats = fs.statSync(localPath);
|
||||
const fileSize = fileStats.size;
|
||||
|
||||
// 检查文件大小(AWS S3 单次上传最大 5GB)
|
||||
const MAX_SINGLE_UPLOAD_SIZE = 5 * 1024 * 1024 * 1024; // 5GB
|
||||
if (fileSize > MAX_SINGLE_UPLOAD_SIZE) {
|
||||
throw new Error(`文件过大 (${formatFileSize(fileSize)}),单次上传最大支持 5GB,请使用分片上传`);
|
||||
if (fileSize <= OSS_SINGLE_UPLOAD_THRESHOLD) {
|
||||
// 小文件保持单请求上传,兼容性更高。
|
||||
const fileContent = fs.readFileSync(localPath);
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
Body: fileContent,
|
||||
ContentLength: fileSize,
|
||||
ChecksumAlgorithm: undefined
|
||||
});
|
||||
await this.s3Client.send(command);
|
||||
console.log(`[OSS存储] 上传成功: ${key} (${formatFileSize(fileSize)})`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用Buffer上传而非流式上传,避免AWS SDK使用aws-chunked编码
|
||||
// 这样可以确保与阿里云OSS的兼容性
|
||||
const fileContent = fs.readFileSync(localPath);
|
||||
// 大文件自动切换到分片上传,避免整文件读入内存和 5GB 单请求上限。
|
||||
const minPartSizeByCount = Math.ceil(fileSize / OSS_MULTIPART_MAX_PARTS);
|
||||
const alignedMinPartSize = Math.ceil(
|
||||
Math.max(minPartSizeByCount, OSS_MULTIPART_MIN_PART_SIZE) / (1024 * 1024)
|
||||
) * 1024 * 1024;
|
||||
const partSize = Math.max(OSS_MULTIPART_DEFAULT_PART_SIZE, alignedMinPartSize);
|
||||
const estimatedParts = Math.ceil(fileSize / partSize);
|
||||
if (estimatedParts > OSS_MULTIPART_MAX_PARTS) {
|
||||
throw new Error(`文件过大,分片数量超限 (${estimatedParts}/${OSS_MULTIPART_MAX_PARTS})`);
|
||||
}
|
||||
|
||||
// 直接上传
|
||||
const command = new PutObjectCommand({
|
||||
const createResp = await this.s3Client.send(new CreateMultipartUploadCommand({
|
||||
Bucket: bucket,
|
||||
Key: key
|
||||
}));
|
||||
uploadId = createResp?.UploadId || null;
|
||||
if (!uploadId) {
|
||||
throw new Error('创建分片上传会话失败:缺少 UploadId');
|
||||
}
|
||||
|
||||
const uploadedParts = [];
|
||||
let offset = 0;
|
||||
let partNumber = 1;
|
||||
fileHandle = await fs.promises.open(localPath, 'r');
|
||||
|
||||
while (offset < fileSize) {
|
||||
const remaining = fileSize - offset;
|
||||
const currentPartSize = Math.min(partSize, remaining);
|
||||
const buffer = Buffer.allocUnsafe(currentPartSize);
|
||||
const { bytesRead } = await fileHandle.read(buffer, 0, currentPartSize, offset);
|
||||
if (bytesRead <= 0) {
|
||||
throw new Error('读取本地文件失败,分片上传中断');
|
||||
}
|
||||
|
||||
const body = bytesRead === currentPartSize ? buffer : buffer.subarray(0, bytesRead);
|
||||
const partResp = await this.s3Client.send(new UploadPartCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
UploadId: uploadId,
|
||||
PartNumber: partNumber,
|
||||
Body: body,
|
||||
ContentLength: bytesRead
|
||||
}));
|
||||
|
||||
if (!partResp?.ETag) {
|
||||
throw new Error(`分片 ${partNumber} 上传失败:服务端未返回 ETag`);
|
||||
}
|
||||
uploadedParts.push({
|
||||
ETag: partResp.ETag,
|
||||
PartNumber: partNumber
|
||||
});
|
||||
|
||||
offset += bytesRead;
|
||||
if (partNumber === 1 || partNumber % 10 === 0 || offset >= fileSize) {
|
||||
console.log(
|
||||
`[OSS存储] 上传分片进度: ${key} ${partNumber}/${estimatedParts} (${formatFileSize(offset)}/${formatFileSize(fileSize)})`
|
||||
);
|
||||
}
|
||||
partNumber += 1;
|
||||
}
|
||||
|
||||
await this.s3Client.send(new CompleteMultipartUploadCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
Body: fileContent,
|
||||
ContentLength: fileSize, // 明确指定内容长度,避免某些服务端问题
|
||||
// 禁用checksum算法(阿里云OSS不完全支持AWS的x-amz-content-sha256头)
|
||||
ChecksumAlgorithm: undefined
|
||||
});
|
||||
|
||||
await this.s3Client.send(command);
|
||||
console.log(`[OSS存储] 上传成功: ${key} (${formatFileSize(fileSize)})`);
|
||||
UploadId: uploadId,
|
||||
MultipartUpload: { Parts: uploadedParts }
|
||||
}));
|
||||
uploadId = null;
|
||||
console.log(
|
||||
`[OSS存储] 分片上传完成: ${key} (${formatFileSize(fileSize)}, ${uploadedParts.length} 片)`
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
if (uploadId && bucket && key) {
|
||||
try {
|
||||
await this.s3Client.send(new AbortMultipartUploadCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
UploadId: uploadId
|
||||
}));
|
||||
console.warn(`[OSS存储] 已中止失败的分片上传: ${key}`);
|
||||
} catch (abortError) {
|
||||
console.error(`[OSS存储] 中止分片上传失败: ${key}`, abortError.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.error(`[OSS存储] 上传失败: ${remotePath}`, error.message);
|
||||
|
||||
// 判断错误类型并给出友好的错误信息
|
||||
@@ -1139,6 +1251,10 @@ class OssStorageClient {
|
||||
throw new Error(`本地文件不存在: ${localPath}`);
|
||||
}
|
||||
throw new Error(`文件上传失败: ${error.message}`);
|
||||
} finally {
|
||||
if (fileHandle) {
|
||||
await fileHandle.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1534,7 +1650,7 @@ class OssStorageClient {
|
||||
*/
|
||||
async createReadStream(filePath) {
|
||||
const key = this.getObjectKey(filePath);
|
||||
const bucket = this.user.oss_bucket;
|
||||
const bucket = this.getBucket();
|
||||
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: bucket,
|
||||
@@ -1589,7 +1705,7 @@ class OssStorageClient {
|
||||
*/
|
||||
async getPresignedUrl(filePath, expiresIn = 3600) {
|
||||
const key = this.getObjectKey(filePath);
|
||||
const bucket = this.user.oss_bucket;
|
||||
const bucket = this.getBucket();
|
||||
|
||||
try {
|
||||
const command = new GetObjectCommand({
|
||||
@@ -1646,7 +1762,7 @@ class OssStorageClient {
|
||||
*/
|
||||
async getUploadPresignedUrl(filePath, expiresIn = 900, contentType = 'application/octet-stream') {
|
||||
const key = this.getObjectKey(filePath);
|
||||
const bucket = this.user.oss_bucket;
|
||||
const bucket = this.getBucket();
|
||||
|
||||
try {
|
||||
const command = new PutObjectCommand({
|
||||
|
||||
@@ -1,69 +1,163 @@
|
||||
/**
|
||||
* 管理员功能完整性测试脚本
|
||||
* 测试范围:
|
||||
* 1. 用户管理 - 用户列表、搜索、封禁/解封、删除、修改存储权限、查看用户文件
|
||||
* 2. 系统设置 - SMTP邮件配置、存储配置、注册开关、主题设置
|
||||
* 3. 分享管理 - 查看所有分享、删除分享
|
||||
* 4. 系统监控 - 健康检查、存储统计、操作日志
|
||||
* 5. 安全检查 - 管理员权限验证、敏感操作确认
|
||||
* 管理员功能完整性测试脚本(Cookie + CSRF 认证模型)
|
||||
*
|
||||
* 覆盖范围:
|
||||
* 1. 鉴权与权限校验
|
||||
* 2. 用户管理(列表、封禁/删除自保护、存储权限)
|
||||
* 3. 系统设置(获取/更新/参数校验)
|
||||
* 4. 分享管理
|
||||
* 5. 系统监控(健康、存储、日志)
|
||||
* 6. 上传工具接口
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const { UserDB } = require('./database');
|
||||
|
||||
const BASE_URL = 'http://localhost:40001';
|
||||
let adminToken = '';
|
||||
let testUserId = null;
|
||||
let testShareId = null;
|
||||
const BASE_URL = process.env.TEST_BASE_URL || 'http://127.0.0.1:40001';
|
||||
|
||||
const state = {
|
||||
adminSession: {
|
||||
cookies: {},
|
||||
csrfToken: ''
|
||||
},
|
||||
adminUserId: null,
|
||||
testUserId: null,
|
||||
latestSettings: null
|
||||
};
|
||||
|
||||
// 测试结果收集
|
||||
const testResults = {
|
||||
passed: [],
|
||||
failed: [],
|
||||
warnings: []
|
||||
};
|
||||
|
||||
// 辅助函数:发送HTTP请求
|
||||
function request(method, path, data = null, token = null) {
|
||||
function makeCookieHeader(cookies) {
|
||||
return Object.entries(cookies || {})
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
function storeSetCookies(session, setCookieHeader) {
|
||||
if (!session || !setCookieHeader) return;
|
||||
const list = Array.isArray(setCookieHeader) ? setCookieHeader : [setCookieHeader];
|
||||
for (const raw of list) {
|
||||
const first = String(raw || '').split(';')[0];
|
||||
const idx = first.indexOf('=');
|
||||
if (idx <= 0) continue;
|
||||
const key = first.slice(0, idx).trim();
|
||||
const value = first.slice(idx + 1).trim();
|
||||
session.cookies[key] = value;
|
||||
if (key === 'csrf_token') {
|
||||
session.csrfToken = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isSafeMethod(method) {
|
||||
const upper = String(method || '').toUpperCase();
|
||||
return upper === 'GET' || upper === 'HEAD' || upper === 'OPTIONS';
|
||||
}
|
||||
|
||||
function request(method, path, options = {}) {
|
||||
const {
|
||||
data = null,
|
||||
session = null,
|
||||
headers = {},
|
||||
requireCsrf = true
|
||||
} = options;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = new URL(path, BASE_URL);
|
||||
const options = {
|
||||
hostname: url.hostname,
|
||||
port: url.port,
|
||||
path: url.pathname + url.search,
|
||||
method: method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
};
|
||||
const transport = url.protocol === 'https:' ? https : http;
|
||||
|
||||
if (token) {
|
||||
options.headers['Authorization'] = `Bearer ${token}`;
|
||||
const requestHeaders = { ...headers };
|
||||
if (session) {
|
||||
const cookieHeader = makeCookieHeader(session.cookies);
|
||||
if (cookieHeader) {
|
||||
requestHeaders.Cookie = cookieHeader;
|
||||
}
|
||||
|
||||
if (requireCsrf && !isSafeMethod(method)) {
|
||||
const csrfToken = session.csrfToken || session.cookies.csrf_token;
|
||||
if (csrfToken) {
|
||||
requestHeaders['X-CSRF-Token'] = csrfToken;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const req = http.request(options, (res) => {
|
||||
let payload = null;
|
||||
if (data !== null && data !== undefined) {
|
||||
payload = JSON.stringify(data);
|
||||
requestHeaders['Content-Type'] = 'application/json';
|
||||
requestHeaders['Content-Length'] = Buffer.byteLength(payload);
|
||||
}
|
||||
|
||||
const req = transport.request({
|
||||
protocol: url.protocol,
|
||||
hostname: url.hostname,
|
||||
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
||||
path: url.pathname + url.search,
|
||||
method,
|
||||
headers: requestHeaders
|
||||
}, (res) => {
|
||||
let body = '';
|
||||
res.on('data', chunk => body += chunk);
|
||||
res.on('data', chunk => {
|
||||
body += chunk;
|
||||
});
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const json = JSON.parse(body);
|
||||
resolve({ status: res.statusCode, data: json });
|
||||
} catch (e) {
|
||||
resolve({ status: res.statusCode, data: body });
|
||||
if (session) {
|
||||
storeSetCookies(session, res.headers['set-cookie']);
|
||||
}
|
||||
|
||||
let parsed = body;
|
||||
try {
|
||||
parsed = body ? JSON.parse(body) : {};
|
||||
} catch (e) {
|
||||
// keep raw text
|
||||
}
|
||||
|
||||
resolve({
|
||||
status: res.statusCode,
|
||||
data: parsed,
|
||||
headers: res.headers
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
|
||||
if (data) {
|
||||
req.write(JSON.stringify(data));
|
||||
if (payload) {
|
||||
req.write(payload);
|
||||
}
|
||||
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// 测试函数包装器
|
||||
async function initCsrf(session) {
|
||||
const res = await request('GET', '/api/csrf-token', {
|
||||
session,
|
||||
requireCsrf: false
|
||||
});
|
||||
|
||||
if (res.status === 200 && res.data && res.data.csrfToken) {
|
||||
session.csrfToken = res.data.csrfToken;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
function warn(message) {
|
||||
testResults.warnings.push(message);
|
||||
console.log(`[WARN] ${message}`);
|
||||
}
|
||||
|
||||
async function test(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
@@ -75,432 +169,355 @@ async function test(name, fn) {
|
||||
}
|
||||
}
|
||||
|
||||
// 警告记录
|
||||
function warn(message) {
|
||||
testResults.warnings.push(message);
|
||||
console.log(`[WARN] ${message}`);
|
||||
function ensureTestUser() {
|
||||
if (state.testUserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const suffix = Date.now();
|
||||
const username = `admin_test_user_${suffix}`;
|
||||
const email = `${username}@test.local`;
|
||||
const password = `AdminTest#${suffix}`;
|
||||
|
||||
const id = UserDB.create({
|
||||
username,
|
||||
email,
|
||||
password,
|
||||
is_verified: 1
|
||||
});
|
||||
|
||||
UserDB.update(id, {
|
||||
is_active: 1,
|
||||
is_banned: 0,
|
||||
storage_permission: 'user_choice',
|
||||
current_storage_type: 'oss'
|
||||
});
|
||||
|
||||
state.testUserId = id;
|
||||
}
|
||||
|
||||
// 断言函数
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
function cleanupTestUser() {
|
||||
if (!state.testUserId) return;
|
||||
try {
|
||||
UserDB.delete(state.testUserId);
|
||||
} catch (error) {
|
||||
warn(`清理测试用户失败: ${error.message}`);
|
||||
} finally {
|
||||
state.testUserId = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 测试用例 ============
|
||||
|
||||
// 1. 安全检查:未认证访问应被拒绝
|
||||
async function testUnauthorizedAccess() {
|
||||
const res = await request('GET', '/api/admin/users');
|
||||
assert(res.status === 401, `未认证访问应返回401,实际返回: ${res.status}`);
|
||||
}
|
||||
|
||||
// 2. 管理员登录
|
||||
async function testAdminLogin() {
|
||||
const res = await request('POST', '/api/login', {
|
||||
username: 'admin',
|
||||
password: 'admin123',
|
||||
captcha: '' // 开发环境可能不需要验证码
|
||||
});
|
||||
|
||||
// 登录可能因为验证码失败,这是预期的
|
||||
if (res.status === 400 && res.data.message && res.data.message.includes('验证码')) {
|
||||
warn('登录需要验证码,跳过登录测试,使用模拟token');
|
||||
// 使用JWT库生成一个测试token(需要知道JWT_SECRET)
|
||||
// 或者直接查询数据库
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.data.success) {
|
||||
adminToken = res.data.token;
|
||||
console.log(' - 获取到管理员token');
|
||||
} else {
|
||||
throw new Error(`登录失败: ${res.data.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 用户列表获取
|
||||
async function testGetUsers() {
|
||||
if (!adminToken) {
|
||||
warn('无admin token,跳过用户列表测试');
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await request('GET', '/api/admin/users', null, adminToken);
|
||||
assert(res.status === 200, `应返回200,实际: ${res.status}`);
|
||||
assert(res.data.success === true, '应返回success: true');
|
||||
assert(Array.isArray(res.data.users), 'users应为数组');
|
||||
|
||||
// 记录测试用户ID
|
||||
if (res.data.users.length > 1) {
|
||||
const nonAdminUser = res.data.users.find(u => !u.is_admin);
|
||||
if (nonAdminUser) {
|
||||
testUserId = nonAdminUser.id;
|
||||
// 2. 安全检查:无效 Token 应被拒绝
|
||||
async function testInvalidTokenAccess() {
|
||||
const res = await request('GET', '/api/admin/users', {
|
||||
headers: {
|
||||
Authorization: 'Bearer invalid-token'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 系统设置获取
|
||||
async function testGetSettings() {
|
||||
if (!adminToken) {
|
||||
warn('无admin token,跳过系统设置测试');
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await request('GET', '/api/admin/settings', null, adminToken);
|
||||
assert(res.status === 200, `应返回200,实际: ${res.status}`);
|
||||
assert(res.data.success === true, '应返回success: true');
|
||||
assert(res.data.settings !== undefined, '应包含settings对象');
|
||||
assert(res.data.settings.smtp !== undefined, '应包含smtp配置');
|
||||
assert(res.data.settings.global_theme !== undefined, '应包含全局主题设置');
|
||||
}
|
||||
|
||||
// 5. 更新系统设置
|
||||
async function testUpdateSettings() {
|
||||
if (!adminToken) {
|
||||
warn('无admin token,跳过更新系统设置测试');
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await request('POST', '/api/admin/settings', {
|
||||
global_theme: 'dark',
|
||||
max_upload_size: 10737418240
|
||||
}, adminToken);
|
||||
|
||||
assert(res.status === 200, `应返回200,实际: ${res.status}`);
|
||||
assert(res.data.success === true, '应返回success: true');
|
||||
}
|
||||
|
||||
// 6. 健康检查
|
||||
async function testHealthCheck() {
|
||||
if (!adminToken) {
|
||||
warn('无admin token,跳过健康检查测试');
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await request('GET', '/api/admin/health-check', null, adminToken);
|
||||
assert(res.status === 200, `应返回200,实际: ${res.status}`);
|
||||
assert(res.data.success === true, '应返回success: true');
|
||||
assert(res.data.checks !== undefined, '应包含checks数组');
|
||||
assert(res.data.overallStatus !== undefined, '应包含overallStatus');
|
||||
assert(res.data.summary !== undefined, '应包含summary');
|
||||
|
||||
// 检查各项检测项目
|
||||
const checkNames = res.data.checks.map(c => c.name);
|
||||
assert(checkNames.includes('JWT密钥'), '应包含JWT密钥检查');
|
||||
assert(checkNames.includes('数据库连接'), '应包含数据库连接检查');
|
||||
assert(checkNames.includes('存储目录'), '应包含存储目录检查');
|
||||
}
|
||||
|
||||
// 7. 存储统计
|
||||
async function testStorageStats() {
|
||||
if (!adminToken) {
|
||||
warn('无admin token,跳过存储统计测试');
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await request('GET', '/api/admin/storage-stats', null, adminToken);
|
||||
assert(res.status === 200, `应返回200,实际: ${res.status}`);
|
||||
assert(res.data.success === true, '应返回success: true');
|
||||
assert(res.data.stats !== undefined, '应包含stats对象');
|
||||
assert(typeof res.data.stats.totalDisk === 'number', 'totalDisk应为数字');
|
||||
}
|
||||
|
||||
// 8. 系统日志获取
|
||||
async function testGetLogs() {
|
||||
if (!adminToken) {
|
||||
warn('无admin token,跳过系统日志测试');
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await request('GET', '/api/admin/logs?page=1&pageSize=10', null, adminToken);
|
||||
assert(res.status === 200, `应返回200,实际: ${res.status}`);
|
||||
assert(res.data.success === true, '应返回success: true');
|
||||
assert(Array.isArray(res.data.logs), 'logs应为数组');
|
||||
assert(typeof res.data.total === 'number', 'total应为数字');
|
||||
}
|
||||
|
||||
// 9. 日志统计
|
||||
async function testLogStats() {
|
||||
if (!adminToken) {
|
||||
warn('无admin token,跳过日志统计测试');
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await request('GET', '/api/admin/logs/stats', null, adminToken);
|
||||
assert(res.status === 200, `应返回200,实际: ${res.status}`);
|
||||
assert(res.data.success === true, '应返回success: true');
|
||||
assert(res.data.stats !== undefined, '应包含stats对象');
|
||||
}
|
||||
|
||||
// 10. 分享列表获取
|
||||
async function testGetShares() {
|
||||
if (!adminToken) {
|
||||
warn('无admin token,跳过分享列表测试');
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await request('GET', '/api/admin/shares', null, adminToken);
|
||||
assert(res.status === 200, `应返回200,实际: ${res.status}`);
|
||||
assert(res.data.success === true, '应返回success: true');
|
||||
assert(Array.isArray(res.data.shares), 'shares应为数组');
|
||||
|
||||
// 记录测试分享ID
|
||||
if (res.data.shares.length > 0) {
|
||||
testShareId = res.data.shares[0].id;
|
||||
}
|
||||
}
|
||||
|
||||
// 11. 安全检查:普通用户不能访问管理员API
|
||||
async function testNonAdminAccess() {
|
||||
// 使用一个无效的token模拟普通用户
|
||||
const fakeToken = 'invalid-token';
|
||||
const res = await request('GET', '/api/admin/users', null, fakeToken);
|
||||
});
|
||||
assert(res.status === 401, `无效token应返回401,实际: ${res.status}`);
|
||||
}
|
||||
|
||||
// 12. 安全检查:不能封禁自己
|
||||
async function testCannotBanSelf() {
|
||||
if (!adminToken) {
|
||||
warn('无admin token,跳过封禁自己测试');
|
||||
return;
|
||||
}
|
||||
// 3. 管理员登录(基于 Cookie)
|
||||
async function testAdminLogin() {
|
||||
await initCsrf(state.adminSession);
|
||||
|
||||
// 获取当前管理员ID
|
||||
const usersRes = await request('GET', '/api/admin/users', null, adminToken);
|
||||
const adminUser = usersRes.data.users.find(u => u.is_admin);
|
||||
const res = await request('POST', '/api/login', {
|
||||
data: {
|
||||
username: 'admin',
|
||||
password: 'admin123'
|
||||
},
|
||||
session: state.adminSession,
|
||||
requireCsrf: false
|
||||
});
|
||||
|
||||
if (!adminUser) {
|
||||
warn('未找到管理员用户');
|
||||
return;
|
||||
}
|
||||
assert(res.status === 200, `登录应返回200,实际: ${res.status}`);
|
||||
assert(res.data && res.data.success === true, `登录失败: ${res.data?.message || 'unknown'}`);
|
||||
assert(!!state.adminSession.cookies.token, '登录后应写入 token Cookie');
|
||||
|
||||
const res = await request('POST', `/api/admin/users/${adminUser.id}/ban`, {
|
||||
banned: true
|
||||
}, adminToken);
|
||||
await initCsrf(state.adminSession);
|
||||
|
||||
assert(res.status === 400, `封禁自己应返回400,实际: ${res.status}`);
|
||||
assert(res.data.message.includes('不能封禁自己'), '应提示不能封禁自己');
|
||||
const profileRes = await request('GET', '/api/user/profile', {
|
||||
session: state.adminSession
|
||||
});
|
||||
|
||||
assert(profileRes.status === 200, `读取profile应返回200,实际: ${profileRes.status}`);
|
||||
assert(profileRes.data?.success === true, '读取profile应成功');
|
||||
assert(profileRes.data?.user?.is_admin === 1, '登录账号应为管理员');
|
||||
|
||||
state.adminUserId = profileRes.data.user.id;
|
||||
assert(Number.isInteger(state.adminUserId) && state.adminUserId > 0, '应获取管理员ID');
|
||||
}
|
||||
|
||||
// 13. 安全检查:不能删除自己
|
||||
// 4. 用户列表获取(分页)
|
||||
async function testGetUsers() {
|
||||
const res = await request('GET', '/api/admin/users?paged=1&page=1&pageSize=20&sort=created_desc', {
|
||||
session: state.adminSession
|
||||
});
|
||||
|
||||
assert(res.status === 200, `应返回200,实际: ${res.status}`);
|
||||
assert(res.data?.success === true, '应返回success: true');
|
||||
assert(Array.isArray(res.data?.users), 'users应为数组');
|
||||
assert(!!res.data?.pagination, '分页模式应返回pagination');
|
||||
}
|
||||
|
||||
// 5. 系统设置获取
|
||||
async function testGetSettings() {
|
||||
const res = await request('GET', '/api/admin/settings', {
|
||||
session: state.adminSession
|
||||
});
|
||||
|
||||
assert(res.status === 200, `应返回200,实际: ${res.status}`);
|
||||
assert(res.data?.success === true, '应返回success: true');
|
||||
assert(res.data?.settings && typeof res.data.settings === 'object', '应包含settings对象');
|
||||
assert(res.data?.settings?.smtp && typeof res.data.settings.smtp === 'object', '应包含smtp配置');
|
||||
assert(res.data?.settings?.global_theme !== undefined, '应包含全局主题设置');
|
||||
|
||||
state.latestSettings = res.data.settings;
|
||||
}
|
||||
|
||||
// 6. 更新系统设置(写回当前值,避免影响测试环境)
|
||||
async function testUpdateSettings() {
|
||||
const current = state.latestSettings || {};
|
||||
const payload = {
|
||||
global_theme: current.global_theme || 'dark',
|
||||
max_upload_size: Number(current.max_upload_size || 10737418240)
|
||||
};
|
||||
|
||||
const res = await request('POST', '/api/admin/settings', {
|
||||
data: payload,
|
||||
session: state.adminSession
|
||||
});
|
||||
|
||||
assert(res.status === 200, `应返回200,实际: ${res.status}`);
|
||||
assert(res.data?.success === true, '应返回success: true');
|
||||
}
|
||||
|
||||
// 7. 健康检查
|
||||
async function testHealthCheck() {
|
||||
const res = await request('GET', '/api/admin/health-check', {
|
||||
session: state.adminSession
|
||||
});
|
||||
|
||||
assert(res.status === 200, `应返回200,实际: ${res.status}`);
|
||||
assert(res.data?.success === true, '应返回success: true');
|
||||
assert(Array.isArray(res.data?.checks), '应包含checks数组');
|
||||
assert(res.data?.summary && typeof res.data.summary === 'object', '应包含summary');
|
||||
}
|
||||
|
||||
// 8. 存储统计
|
||||
async function testStorageStats() {
|
||||
const res = await request('GET', '/api/admin/storage-stats', {
|
||||
session: state.adminSession
|
||||
});
|
||||
|
||||
assert(res.status === 200, `应返回200,实际: ${res.status}`);
|
||||
assert(res.data?.success === true, '应返回success: true');
|
||||
assert(res.data?.stats && typeof res.data.stats === 'object', '应包含stats对象');
|
||||
}
|
||||
|
||||
// 9. 系统日志获取
|
||||
async function testGetLogs() {
|
||||
const res = await request('GET', '/api/admin/logs?page=1&pageSize=10', {
|
||||
session: state.adminSession
|
||||
});
|
||||
|
||||
assert(res.status === 200, `应返回200,实际: ${res.status}`);
|
||||
assert(res.data?.success === true, '应返回success: true');
|
||||
assert(Array.isArray(res.data?.logs), 'logs应为数组');
|
||||
}
|
||||
|
||||
// 10. 日志统计
|
||||
async function testLogStats() {
|
||||
const res = await request('GET', '/api/admin/logs/stats', {
|
||||
session: state.adminSession
|
||||
});
|
||||
|
||||
assert(res.status === 200, `应返回200,实际: ${res.status}`);
|
||||
assert(res.data?.success === true, '应返回success: true');
|
||||
assert(res.data?.stats && typeof res.data.stats === 'object', '应包含stats对象');
|
||||
}
|
||||
|
||||
// 11. 分享列表获取
|
||||
async function testGetShares() {
|
||||
const res = await request('GET', '/api/admin/shares', {
|
||||
session: state.adminSession
|
||||
});
|
||||
|
||||
assert(res.status === 200, `应返回200,实际: ${res.status}`);
|
||||
assert(res.data?.success === true, '应返回success: true');
|
||||
assert(Array.isArray(res.data?.shares), 'shares应为数组');
|
||||
}
|
||||
|
||||
// 12. 不能封禁自己
|
||||
async function testCannotBanSelf() {
|
||||
assert(state.adminUserId, '管理员ID未初始化');
|
||||
|
||||
const res = await request('POST', `/api/admin/users/${state.adminUserId}/ban`, {
|
||||
data: { banned: true },
|
||||
session: state.adminSession
|
||||
});
|
||||
|
||||
assert(res.status === 400, `封禁自己应返回400,实际: ${res.status}`);
|
||||
assert(String(res.data?.message || '').includes('不能封禁自己'), '应提示不能封禁自己');
|
||||
}
|
||||
|
||||
// 13. 不能删除自己
|
||||
async function testCannotDeleteSelf() {
|
||||
if (!adminToken) {
|
||||
warn('无admin token,跳过删除自己测试');
|
||||
return;
|
||||
}
|
||||
assert(state.adminUserId, '管理员ID未初始化');
|
||||
|
||||
const usersRes = await request('GET', '/api/admin/users', null, adminToken);
|
||||
const adminUser = usersRes.data.users.find(u => u.is_admin);
|
||||
const res = await request('DELETE', `/api/admin/users/${state.adminUserId}`, {
|
||||
session: state.adminSession
|
||||
});
|
||||
|
||||
if (!adminUser) {
|
||||
warn('未找到管理员用户');
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await request('DELETE', `/api/admin/users/${adminUser.id}`, null, adminToken);
|
||||
assert(res.status === 400, `删除自己应返回400,实际: ${res.status}`);
|
||||
assert(res.data.message.includes('不能删除自己'), '应提示不能删除自己');
|
||||
assert(String(res.data?.message || '').includes('不能删除自己'), '应提示不能删除自己');
|
||||
}
|
||||
|
||||
// 14. 参数验证:无效用户ID
|
||||
async function testInvalidUserId() {
|
||||
if (!adminToken) {
|
||||
warn('无admin token,跳过无效用户ID测试');
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await request('POST', '/api/admin/users/invalid/ban', {
|
||||
banned: true
|
||||
}, adminToken);
|
||||
data: { banned: true },
|
||||
session: state.adminSession
|
||||
});
|
||||
|
||||
assert(res.status === 400, `无效用户ID应返回400,实际: ${res.status}`);
|
||||
}
|
||||
|
||||
// 15. 参数验证:无效分享ID
|
||||
async function testInvalidShareId() {
|
||||
if (!adminToken) {
|
||||
warn('无admin token,跳过无效分享ID测试');
|
||||
return;
|
||||
}
|
||||
const res = await request('DELETE', '/api/admin/shares/invalid', {
|
||||
session: state.adminSession
|
||||
});
|
||||
|
||||
const res = await request('DELETE', '/api/admin/shares/invalid', null, adminToken);
|
||||
assert(res.status === 400, `无效分享ID应返回400,实际: ${res.status}`);
|
||||
}
|
||||
|
||||
// 16. 存储权限设置
|
||||
async function testSetStoragePermission() {
|
||||
if (!adminToken || !testUserId) {
|
||||
warn('无admin token或测试用户,跳过存储权限测试');
|
||||
return;
|
||||
}
|
||||
ensureTestUser();
|
||||
|
||||
const res = await request('POST', `/api/admin/users/${testUserId}/storage-permission`, {
|
||||
storage_permission: 'local_only',
|
||||
local_storage_quota: 2147483648 // 2GB
|
||||
}, adminToken);
|
||||
const res = await request('POST', `/api/admin/users/${state.testUserId}/storage-permission`, {
|
||||
data: {
|
||||
storage_permission: 'local_only',
|
||||
local_storage_quota: 2147483648,
|
||||
download_traffic_quota: 3221225472
|
||||
},
|
||||
session: state.adminSession
|
||||
});
|
||||
|
||||
assert(res.status === 200, `应返回200,实际: ${res.status}`);
|
||||
assert(res.data.success === true, '应返回success: true');
|
||||
assert(res.data?.success === true, '应返回success: true');
|
||||
}
|
||||
|
||||
// 17. 参数验证:无效的存储权限值
|
||||
async function testInvalidStoragePermission() {
|
||||
if (!adminToken || !testUserId) {
|
||||
warn('无admin token或测试用户,跳过无效存储权限测试');
|
||||
return;
|
||||
}
|
||||
ensureTestUser();
|
||||
|
||||
const res = await request('POST', `/api/admin/users/${testUserId}/storage-permission`, {
|
||||
storage_permission: 'invalid_permission'
|
||||
}, adminToken);
|
||||
const res = await request('POST', `/api/admin/users/${state.testUserId}/storage-permission`, {
|
||||
data: { storage_permission: 'invalid_permission' },
|
||||
session: state.adminSession
|
||||
});
|
||||
|
||||
assert(res.status === 400, `无效存储权限应返回400,实际: ${res.status}`);
|
||||
}
|
||||
|
||||
// 18. 主题设置验证
|
||||
async function testInvalidTheme() {
|
||||
if (!adminToken) {
|
||||
warn('无admin token,跳过无效主题测试');
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await request('POST', '/api/admin/settings', {
|
||||
global_theme: 'invalid_theme'
|
||||
}, adminToken);
|
||||
data: { global_theme: 'invalid_theme' },
|
||||
session: state.adminSession
|
||||
});
|
||||
|
||||
assert(res.status === 400, `无效主题应返回400,实际: ${res.status}`);
|
||||
}
|
||||
|
||||
// 19. 日志清理测试
|
||||
async function testLogCleanup() {
|
||||
if (!adminToken) {
|
||||
warn('无admin token,跳过日志清理测试');
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await request('POST', '/api/admin/logs/cleanup', {
|
||||
keepDays: 90
|
||||
}, adminToken);
|
||||
data: { keepDays: 90 },
|
||||
session: state.adminSession
|
||||
});
|
||||
|
||||
assert(res.status === 200, `应返回200,实际: ${res.status}`);
|
||||
assert(res.data.success === true, '应返回success: true');
|
||||
assert(typeof res.data.deletedCount === 'number', 'deletedCount应为数字');
|
||||
assert(res.data?.success === true, '应返回success: true');
|
||||
assert(typeof res.data?.deletedCount === 'number', 'deletedCount应为数字');
|
||||
}
|
||||
|
||||
// 20. SMTP测试(预期失败因为未配置)
|
||||
// 20. SMTP测试(未配置时返回400,配置后可能200/500)
|
||||
async function testSmtpTest() {
|
||||
if (!adminToken) {
|
||||
warn('无admin token,跳过SMTP测试');
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await request('POST', '/api/admin/settings/test-smtp', {
|
||||
to: 'test@example.com'
|
||||
}, adminToken);
|
||||
data: { to: 'test@example.com' },
|
||||
session: state.adminSession
|
||||
});
|
||||
|
||||
// SMTP未配置时应返回400
|
||||
if (res.status === 400 && res.data.message && res.data.message.includes('SMTP未配置')) {
|
||||
if (res.status === 400 && String(res.data?.message || '').includes('SMTP未配置')) {
|
||||
console.log(' - SMTP未配置,这是预期的');
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果SMTP已配置,可能成功或失败
|
||||
assert(res.status === 200 || res.status === 500, `应返回200或500,实际: ${res.status}`);
|
||||
}
|
||||
|
||||
// 21. 上传工具检查
|
||||
async function testCheckUploadTool() {
|
||||
if (!adminToken) {
|
||||
warn('无admin token,跳过上传工具检查测试');
|
||||
return;
|
||||
}
|
||||
// 21. 上传工具配置生成(替代已移除的 /api/admin/check-upload-tool)
|
||||
async function testGenerateUploadToolConfig() {
|
||||
const res = await request('POST', '/api/upload/generate-tool', {
|
||||
data: {},
|
||||
session: state.adminSession
|
||||
});
|
||||
|
||||
const res = await request('GET', '/api/admin/check-upload-tool', null, adminToken);
|
||||
assert(res.status === 200, `应返回200,实际: ${res.status}`);
|
||||
assert(res.data.success === true, '应返回success: true');
|
||||
assert(typeof res.data.exists === 'boolean', 'exists应为布尔值');
|
||||
assert(res.data?.success === true, '应返回success: true');
|
||||
assert(res.data?.config && typeof res.data.config === 'object', '应包含config对象');
|
||||
assert(typeof res.data?.config?.api_key === 'string' && res.data.config.api_key.length > 0, '应返回有效api_key');
|
||||
}
|
||||
|
||||
// 22. 用户文件查看 - 无效用户ID验证
|
||||
async function testInvalidUserIdForFiles() {
|
||||
if (!adminToken) {
|
||||
warn('无admin token,跳过用户文件查看无效ID测试');
|
||||
return;
|
||||
}
|
||||
const res = await request('GET', '/api/admin/users/invalid/files', {
|
||||
session: state.adminSession
|
||||
});
|
||||
|
||||
const res = await request('GET', '/api/admin/users/invalid/files', null, adminToken);
|
||||
assert(res.status === 400, `无效用户ID应返回400,实际: ${res.status}`);
|
||||
}
|
||||
|
||||
// 23. 删除用户 - 无效用户ID验证
|
||||
async function testInvalidUserIdForDelete() {
|
||||
if (!adminToken) {
|
||||
warn('无admin token,跳过删除用户无效ID测试');
|
||||
return;
|
||||
}
|
||||
const res = await request('DELETE', '/api/admin/users/invalid', {
|
||||
session: state.adminSession
|
||||
});
|
||||
|
||||
const res = await request('DELETE', '/api/admin/users/invalid', null, adminToken);
|
||||
assert(res.status === 400, `无效用户ID应返回400,实际: ${res.status}`);
|
||||
}
|
||||
|
||||
// 24. 存储权限设置 - 无效用户ID验证
|
||||
async function testInvalidUserIdForPermission() {
|
||||
if (!adminToken) {
|
||||
warn('无admin token,跳过存储权限无效ID测试');
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await request('POST', '/api/admin/users/invalid/storage-permission', {
|
||||
storage_permission: 'local_only'
|
||||
}, adminToken);
|
||||
data: {
|
||||
storage_permission: 'local_only'
|
||||
},
|
||||
session: state.adminSession
|
||||
});
|
||||
|
||||
assert(res.status === 400, `无效用户ID应返回400,实际: ${res.status}`);
|
||||
}
|
||||
|
||||
// 主测试函数
|
||||
async function runTests() {
|
||||
console.log('========================================');
|
||||
console.log('管理员功能完整性测试');
|
||||
console.log('管理员功能完整性测试(Cookie + CSRF)');
|
||||
console.log('========================================\n');
|
||||
|
||||
// 先尝试直接使用数据库获取token
|
||||
try {
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { UserDB } = require('./database');
|
||||
require('dotenv').config();
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
|
||||
const adminUser = UserDB.findByUsername('admin');
|
||||
|
||||
if (adminUser) {
|
||||
adminToken = jwt.sign(
|
||||
{
|
||||
id: adminUser.id,
|
||||
username: adminUser.username,
|
||||
is_admin: adminUser.is_admin,
|
||||
type: 'access'
|
||||
},
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '2h' }
|
||||
);
|
||||
console.log('[INFO] 已通过数据库直接生成管理员token\n');
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('[INFO] 无法直接生成token,将尝试登录: ' + e.message + '\n');
|
||||
}
|
||||
|
||||
// 安全检查测试
|
||||
console.log('\n--- 安全检查 ---');
|
||||
await test('未认证访问应被拒绝', testUnauthorizedAccess);
|
||||
await test('无效token应被拒绝', testNonAdminAccess);
|
||||
await test('无效token应被拒绝', testInvalidTokenAccess);
|
||||
|
||||
// 如果还没有token,尝试登录
|
||||
if (!adminToken) {
|
||||
await test('管理员登录', testAdminLogin);
|
||||
}
|
||||
await test('管理员登录', testAdminLogin);
|
||||
|
||||
// 用户管理测试
|
||||
console.log('\n--- 用户管理 ---');
|
||||
await test('获取用户列表', testGetUsers);
|
||||
await test('不能封禁自己', testCannotBanSelf);
|
||||
@@ -509,19 +526,16 @@ async function runTests() {
|
||||
await test('设置存储权限', testSetStoragePermission);
|
||||
await test('无效存储权限验证', testInvalidStoragePermission);
|
||||
|
||||
// 系统设置测试
|
||||
console.log('\n--- 系统设置 ---');
|
||||
await test('获取系统设置', testGetSettings);
|
||||
await test('更新系统设置', testUpdateSettings);
|
||||
await test('无效主题验证', testInvalidTheme);
|
||||
await test('SMTP测试', testSmtpTest);
|
||||
|
||||
// 分享管理测试
|
||||
console.log('\n--- 分享管理 ---');
|
||||
await test('获取分享列表', testGetShares);
|
||||
await test('无效分享ID验证', testInvalidShareId);
|
||||
|
||||
// 系统监控测试
|
||||
console.log('\n--- 系统监控 ---');
|
||||
await test('健康检查', testHealthCheck);
|
||||
await test('存储统计', testStorageStats);
|
||||
@@ -529,17 +543,16 @@ async function runTests() {
|
||||
await test('日志统计', testLogStats);
|
||||
await test('日志清理', testLogCleanup);
|
||||
|
||||
// 其他功能测试
|
||||
console.log('\n--- 其他功能 ---');
|
||||
await test('上传工具检查', testCheckUploadTool);
|
||||
await test('上传工具配置生成', testGenerateUploadToolConfig);
|
||||
|
||||
// 参数验证增强测试
|
||||
console.log('\n--- 参数验证增强 ---');
|
||||
await test('用户文件查看无效ID验证', testInvalidUserIdForFiles);
|
||||
await test('删除用户无效ID验证', testInvalidUserIdForDelete);
|
||||
await test('存储权限设置无效ID验证', testInvalidUserIdForPermission);
|
||||
|
||||
// 输出测试结果
|
||||
cleanupTestUser();
|
||||
|
||||
console.log('\n========================================');
|
||||
console.log('测试结果汇总');
|
||||
console.log('========================================');
|
||||
@@ -549,26 +562,25 @@ async function runTests() {
|
||||
|
||||
if (testResults.failed.length > 0) {
|
||||
console.log('\n失败的测试:');
|
||||
testResults.failed.forEach(f => {
|
||||
for (const f of testResults.failed) {
|
||||
console.log(` - ${f.name}: ${f.error}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (testResults.warnings.length > 0) {
|
||||
console.log('\n警告:');
|
||||
testResults.warnings.forEach(w => {
|
||||
for (const w of testResults.warnings) {
|
||||
console.log(` - ${w}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n========================================');
|
||||
|
||||
// 返回退出码
|
||||
process.exit(testResults.failed.length > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
// 运行测试
|
||||
runTests().catch(err => {
|
||||
console.error('测试执行错误:', err);
|
||||
runTests().catch((error) => {
|
||||
cleanupTestUser();
|
||||
console.error('测试执行异常:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -448,7 +448,7 @@ async function testGetDownloadUrl() {
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await request('GET', `/api/share/${testShareCode}/download-url?path=/test-file.txt`);
|
||||
const res = await request('POST', `/api/share/${testShareCode}/download-url`, { path: '/test-file.txt' });
|
||||
|
||||
// 如果文件存在
|
||||
if (res.status === 200) {
|
||||
@@ -481,7 +481,7 @@ async function testDownloadWithPassword() {
|
||||
|
||||
// 测试无密码
|
||||
try {
|
||||
const res1 = await request('GET', `/api/share/${passwordShareCode}/download-url?path=/test-file-password.txt`);
|
||||
const res1 = await request('POST', `/api/share/${passwordShareCode}/download-url`, { path: '/test-file-password.txt' });
|
||||
assert(res1.status === 401, '无密码应返回 401');
|
||||
} catch (error) {
|
||||
console.log(` [ERROR] 测试无密码下载: ${error.message}`);
|
||||
@@ -489,7 +489,7 @@ async function testDownloadWithPassword() {
|
||||
|
||||
// 测试带密码
|
||||
try {
|
||||
const res2 = await request('GET', `/api/share/${passwordShareCode}/download-url?path=/test-file-password.txt&password=test123`);
|
||||
const res2 = await request('POST', `/api/share/${passwordShareCode}/download-url`, { path: '/test-file-password.txt', password: 'test123' });
|
||||
// 密码正确,根据文件是否存在返回不同结果
|
||||
if (res2.status === 200) {
|
||||
assert(res2.data.downloadUrl, '应返回下载链接');
|
||||
@@ -535,7 +535,7 @@ async function testDownloadPathValidation() {
|
||||
|
||||
// 测试越权访问
|
||||
try {
|
||||
const res = await request('GET', `/api/share/${testShareCode}/download-url?path=/other-file.txt`);
|
||||
const res = await request('POST', `/api/share/${testShareCode}/download-url`, { path: '/other-file.txt' });
|
||||
|
||||
// 单文件分享应该禁止访问其他文件
|
||||
assert(res.status === 403 || res.status === 404, '越权访问应被拒绝');
|
||||
@@ -546,7 +546,7 @@ async function testDownloadPathValidation() {
|
||||
|
||||
// 测试路径遍历
|
||||
try {
|
||||
const res2 = await request('GET', `/api/share/${testShareCode}/download-url?path=/../../../etc/passwd`);
|
||||
const res2 = await request('POST', `/api/share/${testShareCode}/download-url`, { path: '/../../../etc/passwd' });
|
||||
assert(res2.status === 403 || res2.status === 400, '路径遍历应被拒绝');
|
||||
} catch (error) {
|
||||
console.log(` [ERROR] 路径遍历测试: ${error.message}`);
|
||||
@@ -682,7 +682,7 @@ async function testShareNotExists() {
|
||||
|
||||
// 下载
|
||||
try {
|
||||
const res3 = await request('GET', `/api/share/${nonExistentCode}/download-url?path=/test.txt`);
|
||||
const res3 = await request('POST', `/api/share/${nonExistentCode}/download-url`, { path: '/test.txt' });
|
||||
assert(res3.status === 404, '下载不存在分享应返回 404');
|
||||
} catch (error) {
|
||||
console.log(` [ERROR] ${error.message}`);
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
/**
|
||||
* 分享功能边界条件深度测试
|
||||
* 分享功能边界条件深度测试(兼容 Cookie + CSRF)
|
||||
*
|
||||
* 测试场景:
|
||||
* 测试场景:
|
||||
* 1. 已过期的分享
|
||||
* 2. 分享者被删除
|
||||
* 3. 存储类型切换后的分享
|
||||
* 2. 分享文件不存在
|
||||
* 3. 被封禁用户的分享
|
||||
* 4. 路径遍历攻击
|
||||
* 5. 并发访问限流
|
||||
* 5. 特殊字符路径
|
||||
* 6. 并发密码尝试
|
||||
* 7. 分享统计
|
||||
* 8. 分享码唯一性
|
||||
* 9. 过期时间格式
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const { db, ShareDB, UserDB } = require('./database');
|
||||
|
||||
const BASE_URL = process.env.TEST_BASE_URL || 'http://127.0.0.1:40001';
|
||||
@@ -20,45 +26,129 @@ const results = {
|
||||
errors: []
|
||||
};
|
||||
|
||||
// HTTP 请求工具
|
||||
function request(method, path, data = null, headers = {}) {
|
||||
const adminSession = {
|
||||
cookies: {},
|
||||
csrfToken: ''
|
||||
};
|
||||
|
||||
let adminUserId = 1;
|
||||
|
||||
function makeCookieHeader(cookies) {
|
||||
return Object.entries(cookies || {})
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
function storeSetCookies(session, setCookieHeader) {
|
||||
if (!session || !setCookieHeader) return;
|
||||
const list = Array.isArray(setCookieHeader) ? setCookieHeader : [setCookieHeader];
|
||||
for (const raw of list) {
|
||||
const first = String(raw || '').split(';')[0];
|
||||
const idx = first.indexOf('=');
|
||||
if (idx <= 0) continue;
|
||||
const key = first.slice(0, idx).trim();
|
||||
const value = first.slice(idx + 1).trim();
|
||||
session.cookies[key] = value;
|
||||
if (key === 'csrf_token') {
|
||||
session.csrfToken = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isSafeMethod(method) {
|
||||
const upper = String(method || '').toUpperCase();
|
||||
return upper === 'GET' || upper === 'HEAD' || upper === 'OPTIONS';
|
||||
}
|
||||
|
||||
function request(method, path, options = {}) {
|
||||
const {
|
||||
data = null,
|
||||
session = null,
|
||||
headers = {},
|
||||
requireCsrf = true
|
||||
} = options;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = new URL(path, BASE_URL);
|
||||
const port = url.port ? parseInt(url.port, 10) : 80;
|
||||
const transport = url.protocol === 'https:' ? https : http;
|
||||
|
||||
const options = {
|
||||
hostname: url.hostname,
|
||||
port: port,
|
||||
path: url.pathname + url.search,
|
||||
method: method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...headers
|
||||
const requestHeaders = { ...headers };
|
||||
|
||||
if (session) {
|
||||
const cookieHeader = makeCookieHeader(session.cookies);
|
||||
if (cookieHeader) {
|
||||
requestHeaders.Cookie = cookieHeader;
|
||||
}
|
||||
};
|
||||
|
||||
const req = http.request(options, (res) => {
|
||||
let body = '';
|
||||
res.on('data', chunk => body += chunk);
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const json = JSON.parse(body);
|
||||
resolve({ status: res.statusCode, data: json, headers: res.headers });
|
||||
} catch (e) {
|
||||
resolve({ status: res.statusCode, data: body, headers: res.headers });
|
||||
if (requireCsrf && !isSafeMethod(method)) {
|
||||
const csrfToken = session.csrfToken || session.cookies.csrf_token;
|
||||
if (csrfToken) {
|
||||
requestHeaders['X-CSRF-Token'] = csrfToken;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let payload = null;
|
||||
if (data !== null && data !== undefined) {
|
||||
payload = JSON.stringify(data);
|
||||
requestHeaders['Content-Type'] = 'application/json';
|
||||
requestHeaders['Content-Length'] = Buffer.byteLength(payload);
|
||||
}
|
||||
|
||||
const req = transport.request({
|
||||
protocol: url.protocol,
|
||||
hostname: url.hostname,
|
||||
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
||||
path: url.pathname + url.search,
|
||||
method,
|
||||
headers: requestHeaders
|
||||
}, (res) => {
|
||||
let body = '';
|
||||
res.on('data', chunk => {
|
||||
body += chunk;
|
||||
});
|
||||
res.on('end', () => {
|
||||
if (session) {
|
||||
storeSetCookies(session, res.headers['set-cookie']);
|
||||
}
|
||||
|
||||
let parsed = body;
|
||||
try {
|
||||
parsed = body ? JSON.parse(body) : {};
|
||||
} catch (e) {
|
||||
// keep raw text
|
||||
}
|
||||
|
||||
resolve({
|
||||
status: res.statusCode,
|
||||
data: parsed,
|
||||
headers: res.headers
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
|
||||
if (data) {
|
||||
req.write(JSON.stringify(data));
|
||||
if (payload) {
|
||||
req.write(payload);
|
||||
}
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function initCsrf(session) {
|
||||
const res = await request('GET', '/api/csrf-token', {
|
||||
session,
|
||||
requireCsrf: false
|
||||
});
|
||||
|
||||
if (res.status === 200 && res.data && res.data.csrfToken) {
|
||||
session.csrfToken = res.data.csrfToken;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (condition) {
|
||||
results.passed++;
|
||||
@@ -70,41 +160,48 @@ function assert(condition, message) {
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 测试用例 =====
|
||||
function createValidShareCode(prefix = '') {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const generated = ShareDB.generateShareCode();
|
||||
const code = (prefix + generated).replace(/[^A-Za-z0-9]/g, '').slice(0, 16);
|
||||
if (!code || code.length < 6) continue;
|
||||
const exists = db.prepare('SELECT 1 FROM shares WHERE share_code = ?').get(code);
|
||||
if (!exists) return code;
|
||||
}
|
||||
|
||||
return ShareDB.generateShareCode();
|
||||
}
|
||||
|
||||
async function testExpiredShare() {
|
||||
console.log('\n[测试] 已过期的分享...');
|
||||
|
||||
// 直接在数据库中创建一个已过期的分享
|
||||
const expiredShareCode = 'expired_' + Date.now();
|
||||
const expiredShareCode = createValidShareCode('E');
|
||||
|
||||
try {
|
||||
// 插入一个已过期的分享(过期时间设为昨天)
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
const expiresAt = yesterday.toISOString().replace('T', ' ').substring(0, 19);
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO shares (user_id, share_code, share_path, share_type, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(1, expiredShareCode, '/expired-test.txt', 'file', expiresAt);
|
||||
INSERT INTO shares (user_id, share_code, share_path, share_type, storage_type, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(adminUserId, expiredShareCode, '/expired-test.txt', 'file', 'local', expiresAt);
|
||||
|
||||
console.log(` 创建过期分享: ${expiredShareCode}, 过期时间: ${expiresAt}`);
|
||||
|
||||
// 尝试访问过期分享
|
||||
const res = await request('POST', `/api/share/${expiredShareCode}/verify`, {});
|
||||
const res = await request('POST', `/api/share/${expiredShareCode}/verify`, {
|
||||
data: {},
|
||||
requireCsrf: false
|
||||
});
|
||||
|
||||
assert(res.status === 404, `过期分享应返回 404, 实际: ${res.status}`);
|
||||
assert(res.data.message === '分享不存在', '应提示分享不存在');
|
||||
assert(res.data && res.data.message === '分享不存在', '应提示分享不存在');
|
||||
|
||||
// 清理测试数据
|
||||
db.prepare('DELETE FROM shares WHERE share_code = ?').run(expiredShareCode);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` [ERROR] ${error.message}`);
|
||||
results.failed++;
|
||||
// 清理
|
||||
db.prepare('DELETE FROM shares WHERE share_code = ?').run(expiredShareCode);
|
||||
return false;
|
||||
}
|
||||
@@ -113,32 +210,27 @@ async function testExpiredShare() {
|
||||
async function testShareWithDeletedFile() {
|
||||
console.log('\n[测试] 分享的文件不存在...');
|
||||
|
||||
// 创建一个指向不存在文件的分享
|
||||
const shareCode = 'nofile_' + Date.now();
|
||||
const shareCode = createValidShareCode('N');
|
||||
|
||||
try {
|
||||
db.prepare(`
|
||||
INSERT INTO shares (user_id, share_code, share_path, share_type, storage_type)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(1, shareCode, '/non_existent_file_xyz.txt', 'file', 'local');
|
||||
`).run(adminUserId, shareCode, '/non_existent_file_xyz.txt', 'file', 'local');
|
||||
|
||||
console.log(` 创建分享: ${shareCode}, 路径: /non_existent_file_xyz.txt`);
|
||||
|
||||
// 访问分享
|
||||
const res = await request('POST', `/api/share/${shareCode}/verify`, {});
|
||||
const res = await request('POST', `/api/share/${shareCode}/verify`, {
|
||||
data: {},
|
||||
requireCsrf: false
|
||||
});
|
||||
|
||||
// 应该返回错误(文件不存在)
|
||||
// 注意:verify 接口在缓存未命中时会查询存储
|
||||
assert(res.status === 500 || res.status === 200, `应返回500或200,实际: ${res.status}`);
|
||||
if (res.status === 500) {
|
||||
assert(res.data.message && res.data.message.includes('不存在'), '应提示文件不存在');
|
||||
} else if (res.status === 200) {
|
||||
// 如果成功返回,file 字段应该没有正确的文件信息
|
||||
console.log(` [INFO] verify 返回 200,检查文件信息`);
|
||||
assert(!!res.data?.message, '500时应返回错误消息');
|
||||
}
|
||||
|
||||
// 清理
|
||||
db.prepare('DELETE FROM shares WHERE share_code = ?').run(shareCode);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` [ERROR] ${error.message}`);
|
||||
@@ -150,20 +242,17 @@ async function testShareWithDeletedFile() {
|
||||
async function testShareByBannedUser() {
|
||||
console.log('\n[测试] 被封禁用户的分享...');
|
||||
|
||||
// 创建测试用户
|
||||
let testUserId = null;
|
||||
const shareCode = 'banned_' + Date.now();
|
||||
const shareCode = createValidShareCode('B');
|
||||
|
||||
try {
|
||||
// 创建测试用户
|
||||
testUserId = UserDB.create({
|
||||
username: 'test_banned_' + Date.now(),
|
||||
username: `test_banned_${Date.now()}`,
|
||||
email: `test_banned_${Date.now()}@test.com`,
|
||||
password: 'test123',
|
||||
is_verified: 1
|
||||
});
|
||||
|
||||
// 创建分享
|
||||
db.prepare(`
|
||||
INSERT INTO shares (user_id, share_code, share_path, share_type, storage_type)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
@@ -172,21 +261,16 @@ async function testShareByBannedUser() {
|
||||
console.log(` 创建测试用户 ID: ${testUserId}`);
|
||||
console.log(` 创建分享: ${shareCode}`);
|
||||
|
||||
// 封禁用户
|
||||
UserDB.setBanStatus(testUserId, true);
|
||||
console.log(` 封禁用户: ${testUserId}`);
|
||||
|
||||
// 访问分享
|
||||
const res = await request('POST', `/api/share/${shareCode}/verify`, {});
|
||||
const res = await request('POST', `/api/share/${shareCode}/verify`, {
|
||||
data: {},
|
||||
requireCsrf: false
|
||||
});
|
||||
|
||||
// 当前实现:被封禁用户的分享仍然可以访问
|
||||
// 如果需要阻止,应该在 ShareDB.findByCode 中检查用户状态
|
||||
console.log(` 被封禁用户分享访问状态码: ${res.status}`);
|
||||
|
||||
// 注意:这里可能是一个潜在的功能增强点
|
||||
// 如果希望被封禁用户的分享也被禁止访问,需要修改代码
|
||||
|
||||
// 清理
|
||||
db.prepare('DELETE FROM shares WHERE share_code = ?').run(shareCode);
|
||||
UserDB.delete(testUserId);
|
||||
|
||||
@@ -203,16 +287,14 @@ async function testShareByBannedUser() {
|
||||
async function testPathTraversalAttacks() {
|
||||
console.log('\n[测试] 路径遍历攻击防护...');
|
||||
|
||||
// 创建测试分享
|
||||
const shareCode = 'traverse_' + Date.now();
|
||||
const shareCode = createValidShareCode('T');
|
||||
|
||||
try {
|
||||
db.prepare(`
|
||||
INSERT INTO shares (user_id, share_code, share_path, share_type, storage_type)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(1, shareCode, '/allowed-folder', 'directory', 'local');
|
||||
`).run(adminUserId, shareCode, '/allowed-folder', 'directory', 'local');
|
||||
|
||||
// 测试各种路径遍历攻击
|
||||
const attackPaths = [
|
||||
'../../../etc/passwd',
|
||||
'..\\..\\..\\etc\\passwd',
|
||||
@@ -225,7 +307,10 @@ async function testPathTraversalAttacks() {
|
||||
|
||||
let blocked = 0;
|
||||
for (const attackPath of attackPaths) {
|
||||
const res = await request('GET', `/api/share/${shareCode}/download-url?path=${encodeURIComponent(attackPath)}`);
|
||||
const res = await request('POST', `/api/share/${shareCode}/download-url`, {
|
||||
data: { path: attackPath },
|
||||
requireCsrf: false
|
||||
});
|
||||
|
||||
if (res.status === 403 || res.status === 400) {
|
||||
blocked++;
|
||||
@@ -237,9 +322,7 @@ async function testPathTraversalAttacks() {
|
||||
|
||||
assert(blocked >= attackPaths.length - 1, `路径遍历攻击应被阻止 (${blocked}/${attackPaths.length})`);
|
||||
|
||||
// 清理
|
||||
db.prepare('DELETE FROM shares WHERE share_code = ?').run(shareCode);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` [ERROR] ${error.message}`);
|
||||
@@ -251,7 +334,12 @@ async function testPathTraversalAttacks() {
|
||||
async function testSpecialCharactersInPath() {
|
||||
console.log('\n[测试] 特殊字符路径处理...');
|
||||
|
||||
// 测试创建包含特殊字符的分享
|
||||
if (!adminSession.cookies.token) {
|
||||
console.log(' [WARN] 未登录,跳过特殊字符路径测试');
|
||||
assert(true, '未登录时跳过特殊字符路径测试');
|
||||
return true;
|
||||
}
|
||||
|
||||
const specialPaths = [
|
||||
'/文件夹/中文文件.txt',
|
||||
'/folder with spaces/file.txt',
|
||||
@@ -262,28 +350,35 @@ async function testSpecialCharactersInPath() {
|
||||
|
||||
let handled = 0;
|
||||
|
||||
for (const path of specialPaths) {
|
||||
for (const virtualPath of specialPaths) {
|
||||
try {
|
||||
const res = await request('POST', '/api/share/create', {
|
||||
share_type: 'file',
|
||||
file_path: path
|
||||
}, { Cookie: authCookie });
|
||||
const createRes = await request('POST', '/api/share/create', {
|
||||
data: {
|
||||
share_type: 'file',
|
||||
file_path: virtualPath
|
||||
},
|
||||
session: adminSession
|
||||
});
|
||||
|
||||
if (res.status === 200 || res.status === 400) {
|
||||
if (createRes.status === 200 || createRes.status === 400) {
|
||||
handled++;
|
||||
console.log(` [OK] ${path.substring(0, 30)}... - 状态: ${res.status}`);
|
||||
console.log(` [OK] ${virtualPath.substring(0, 30)}... - 状态: ${createRes.status}`);
|
||||
|
||||
// 如果创建成功,清理
|
||||
if (res.data.share_code) {
|
||||
const myShares = await request('GET', '/api/share/my', null, { Cookie: authCookie });
|
||||
const share = myShares.data.shares?.find(s => s.share_code === res.data.share_code);
|
||||
if (createRes.status === 200 && createRes.data?.share_code) {
|
||||
const mySharesRes = await request('GET', '/api/share/my', {
|
||||
session: adminSession
|
||||
});
|
||||
|
||||
const share = mySharesRes.data?.shares?.find(s => s.share_code === createRes.data.share_code);
|
||||
if (share) {
|
||||
await request('DELETE', `/api/share/${share.id}`, null, { Cookie: authCookie });
|
||||
await request('DELETE', `/api/share/${share.id}`, {
|
||||
session: adminSession
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(` [ERROR] ${path}: ${error.message}`);
|
||||
console.log(` [ERROR] ${virtualPath}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,36 +389,33 @@ async function testSpecialCharactersInPath() {
|
||||
async function testConcurrentPasswordAttempts() {
|
||||
console.log('\n[测试] 并发密码尝试限流...');
|
||||
|
||||
// 创建一个带密码的分享
|
||||
const shareCode = 'concurrent_' + Date.now();
|
||||
const shareCode = createValidShareCode('C');
|
||||
|
||||
try {
|
||||
// 使用 bcrypt 哈希密码
|
||||
const bcrypt = require('bcryptjs');
|
||||
const hashedPassword = bcrypt.hashSync('correct123', 10);
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO shares (user_id, share_code, share_path, share_type, share_password, storage_type)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(1, shareCode, '/test.txt', 'file', hashedPassword, 'local');
|
||||
`).run(adminUserId, shareCode, '/test.txt', 'file', hashedPassword, 'local');
|
||||
|
||||
// 发送大量并发错误密码请求
|
||||
const promises = [];
|
||||
for (let i = 0; i < 20; i++) {
|
||||
promises.push(request('POST', `/api/share/${shareCode}/verify`, {
|
||||
password: 'wrong' + i
|
||||
}));
|
||||
promises.push(
|
||||
request('POST', `/api/share/${shareCode}/verify`, {
|
||||
data: { password: `wrong${i}` },
|
||||
requireCsrf: false
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
const responses = await Promise.all(promises);
|
||||
|
||||
// 检查是否有请求被限流
|
||||
const rateLimited = results.filter(r => r.status === 429).length;
|
||||
const unauthorized = results.filter(r => r.status === 401).length;
|
||||
const rateLimited = responses.filter(r => r.status === 429).length;
|
||||
const unauthorized = responses.filter(r => r.status === 401).length;
|
||||
|
||||
console.log(` 并发请求: 20, 限流: ${rateLimited}, 401错误: ${unauthorized}`);
|
||||
|
||||
// 注意:限流是否触发取决于配置
|
||||
if (rateLimited > 0) {
|
||||
assert(true, '限流机制生效');
|
||||
} else {
|
||||
@@ -331,9 +423,7 @@ async function testConcurrentPasswordAttempts() {
|
||||
assert(true, '并发测试完成');
|
||||
}
|
||||
|
||||
// 清理
|
||||
db.prepare('DELETE FROM shares WHERE share_code = ?').run(shareCode);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` [ERROR] ${error.message}`);
|
||||
@@ -345,25 +435,28 @@ async function testConcurrentPasswordAttempts() {
|
||||
async function testShareStatistics() {
|
||||
console.log('\n[测试] 分享统计功能...');
|
||||
|
||||
const shareCode = 'stats_' + Date.now();
|
||||
const shareCode = createValidShareCode('S');
|
||||
|
||||
try {
|
||||
db.prepare(`
|
||||
INSERT INTO shares (user_id, share_code, share_path, share_type, storage_type, view_count, download_count)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(1, shareCode, '/test.txt', 'file', 'local', 0, 0);
|
||||
`).run(adminUserId, shareCode, '/test.txt', 'file', 'local', 0, 0);
|
||||
|
||||
// 验证多次(增加查看次数)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await request('POST', `/api/share/${shareCode}/verify`, {});
|
||||
await request('POST', `/api/share/${shareCode}/verify`, {
|
||||
data: {},
|
||||
requireCsrf: false
|
||||
});
|
||||
}
|
||||
|
||||
// 记录下载次数
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await request('POST', `/api/share/${shareCode}/download`, {});
|
||||
await request('POST', `/api/share/${shareCode}/download`, {
|
||||
data: {},
|
||||
requireCsrf: false
|
||||
});
|
||||
}
|
||||
|
||||
// 检查统计数据
|
||||
const share = db.prepare('SELECT view_count, download_count FROM shares WHERE share_code = ?').get(shareCode);
|
||||
|
||||
assert(share.view_count === 3, `查看次数应为 3, 实际: ${share.view_count}`);
|
||||
@@ -371,9 +464,7 @@ async function testShareStatistics() {
|
||||
|
||||
console.log(` 查看次数: ${share.view_count}, 下载次数: ${share.download_count}`);
|
||||
|
||||
// 清理
|
||||
db.prepare('DELETE FROM shares WHERE share_code = ?').run(shareCode);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` [ERROR] ${error.message}`);
|
||||
@@ -386,12 +477,10 @@ async function testShareCodeUniqueness() {
|
||||
console.log('\n[测试] 分享码唯一性...');
|
||||
|
||||
try {
|
||||
// 创建多个分享,检查分享码是否唯一
|
||||
const codes = new Set();
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const code = ShareDB.generateShareCode();
|
||||
|
||||
if (codes.has(code)) {
|
||||
console.log(` [WARN] 发现重复分享码: ${code}`);
|
||||
}
|
||||
@@ -401,7 +490,6 @@ async function testShareCodeUniqueness() {
|
||||
assert(codes.size === 10, `应生成 10 个唯一分享码, 实际: ${codes.size}`);
|
||||
console.log(` 生成了 ${codes.size} 个唯一分享码`);
|
||||
|
||||
// 检查分享码长度和字符
|
||||
const sampleCode = ShareDB.generateShareCode();
|
||||
assert(sampleCode.length === 8, `分享码长度应为 8, 实际: ${sampleCode.length}`);
|
||||
assert(/^[a-zA-Z0-9]+$/.test(sampleCode), '分享码应只包含字母数字');
|
||||
@@ -417,11 +505,10 @@ async function testExpiryTimeFormat() {
|
||||
console.log('\n[测试] 过期时间格式...');
|
||||
|
||||
try {
|
||||
// 测试不同的过期天数
|
||||
const testDays = [1, 7, 30, 365];
|
||||
|
||||
for (const days of testDays) {
|
||||
const result = ShareDB.create(1, {
|
||||
const result = ShareDB.create(adminUserId, {
|
||||
share_type: 'file',
|
||||
file_path: `/test_${days}_days.txt`,
|
||||
expiry_days: days
|
||||
@@ -429,15 +516,12 @@ async function testExpiryTimeFormat() {
|
||||
|
||||
const share = db.prepare('SELECT expires_at FROM shares WHERE share_code = ?').get(result.share_code);
|
||||
|
||||
// 验证过期时间格式
|
||||
const expiresAt = new Date(share.expires_at);
|
||||
const now = new Date();
|
||||
const diffDays = Math.round((expiresAt - now) / (1000 * 60 * 60 * 24));
|
||||
|
||||
// 允许1天的误差(由于时区等因素)
|
||||
assert(Math.abs(diffDays - days) <= 1, `${days}天过期应正确设置, 实际差异: ${diffDays}天`);
|
||||
|
||||
// 清理
|
||||
db.prepare('DELETE FROM shares WHERE share_code = ?').run(result.share_code);
|
||||
}
|
||||
|
||||
@@ -448,28 +532,37 @@ async function testExpiryTimeFormat() {
|
||||
}
|
||||
}
|
||||
|
||||
// 全局认证 Cookie
|
||||
let authCookie = '';
|
||||
|
||||
async function login() {
|
||||
console.log('\n[准备] 登录获取认证...');
|
||||
|
||||
try {
|
||||
await initCsrf(adminSession);
|
||||
|
||||
const res = await request('POST', '/api/login', {
|
||||
username: 'admin',
|
||||
password: 'admin123'
|
||||
data: {
|
||||
username: 'admin',
|
||||
password: 'admin123'
|
||||
},
|
||||
session: adminSession,
|
||||
requireCsrf: false
|
||||
});
|
||||
|
||||
if (res.status === 200 && res.data.success) {
|
||||
const setCookie = res.headers['set-cookie'];
|
||||
if (setCookie) {
|
||||
authCookie = setCookie.map(c => c.split(';')[0]).join('; ');
|
||||
console.log(' 认证成功');
|
||||
return true;
|
||||
if (res.status === 200 && res.data?.success && adminSession.cookies.token) {
|
||||
await initCsrf(adminSession);
|
||||
|
||||
const profileRes = await request('GET', '/api/user/profile', {
|
||||
session: adminSession
|
||||
});
|
||||
|
||||
if (profileRes.status === 200 && profileRes.data?.user?.id) {
|
||||
adminUserId = profileRes.data.user.id;
|
||||
}
|
||||
|
||||
console.log(' 认证成功');
|
||||
return true;
|
||||
}
|
||||
|
||||
console.log(' 认证失败');
|
||||
console.log(` 认证失败: status=${res.status}, message=${res.data?.message || 'unknown'}`);
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.log(` [ERROR] ${error.message}`);
|
||||
@@ -477,20 +570,16 @@ async function login() {
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 主测试流程 =====
|
||||
|
||||
async function runTests() {
|
||||
console.log('========================================');
|
||||
console.log(' 分享功能边界条件深度测试');
|
||||
console.log(' 分享功能边界条件深度测试(Cookie + CSRF)');
|
||||
console.log('========================================');
|
||||
|
||||
// 登录
|
||||
const loggedIn = await login();
|
||||
if (!loggedIn) {
|
||||
console.log('\n[WARN] 登录失败,部分测试可能无法执行');
|
||||
}
|
||||
|
||||
// 运行测试
|
||||
await testExpiredShare();
|
||||
await testShareWithDeletedFile();
|
||||
await testShareByBannedUser();
|
||||
@@ -501,7 +590,6 @@ async function runTests() {
|
||||
await testShareCodeUniqueness();
|
||||
await testExpiryTimeFormat();
|
||||
|
||||
// 结果统计
|
||||
console.log('\n========================================');
|
||||
console.log(' 测试结果统计');
|
||||
console.log('========================================');
|
||||
|
||||
@@ -53,21 +53,20 @@ class StorageUsageCache {
|
||||
*/
|
||||
static async updateUsage(userId, deltaSize) {
|
||||
try {
|
||||
// 使用 SQL 原子操作,避免并发问题
|
||||
const result = UserDB.update(userId, {
|
||||
// 使用原始 SQL,因为 update 方法不支持表达式
|
||||
// 注意:这里需要在数据库层执行 UPDATE ... SET storage_used = storage_used + ?
|
||||
});
|
||||
const numericDelta = Number(deltaSize);
|
||||
if (!Number.isFinite(numericDelta)) {
|
||||
throw new Error('deltaSize 必须是有效数字');
|
||||
}
|
||||
|
||||
// 直接执行 SQL 更新
|
||||
// 直接执行 SQL 原子更新,并保证不小于 0
|
||||
const { db } = require('../database');
|
||||
db.prepare(`
|
||||
UPDATE users
|
||||
SET storage_used = storage_used + ?
|
||||
SET storage_used = MAX(COALESCE(storage_used, 0) + ?, 0)
|
||||
WHERE id = ?
|
||||
`).run(deltaSize, userId);
|
||||
`).run(numericDelta, userId);
|
||||
|
||||
console.log(`[存储缓存] 用户 ${userId} 存储变化: ${deltaSize > 0 ? '+' : ''}${deltaSize} 字节`);
|
||||
console.log(`[存储缓存] 用户 ${userId} 存储变化: ${numericDelta > 0 ? '+' : ''}${numericDelta} 字节`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[存储缓存] 更新失败:', error);
|
||||
|
||||
24
desktop-client/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
118
desktop-client/README.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# 玩玩云桌面客户端(Tauri + Vue + TypeScript)
|
||||
|
||||
这是玩玩云网盘的桌面端,基于 `Tauri 2 + Vue 3 + TypeScript` 开发。
|
||||
|
||||
默认对接地址:`https://cs.workyai.cn`。
|
||||
|
||||
## 功能概览
|
||||
|
||||
- 账号登录(复用网页端账号体系)
|
||||
- 文件列表浏览、目录切换、全局搜索
|
||||
- 新建文件夹、重命名、删除
|
||||
- 分享链接 / 直链创建与管理
|
||||
- 下载(支持断点续传)
|
||||
- 本地目录同步(手动/定时)
|
||||
- 客户端版本检测与更新下载
|
||||
|
||||
## 环境要求
|
||||
|
||||
- Node.js 18+(建议 20 LTS)
|
||||
- Rust stable(建议通过 `rustup` 安装)
|
||||
- Tauri CLI(项目已在 `package.json` 中声明)
|
||||
|
||||
可选:Linux 交叉编译 Windows 时还需要:
|
||||
|
||||
- `mingw-w64`
|
||||
- `nsis`
|
||||
|
||||
## 开发运行
|
||||
|
||||
```bash
|
||||
cd desktop-client
|
||||
npm install
|
||||
npm run tauri dev
|
||||
```
|
||||
|
||||
## 对接你的网盘后端
|
||||
|
||||
### 1. 修改 API 基础地址
|
||||
|
||||
当前默认地址在 `desktop-client/src/App.vue`:
|
||||
|
||||
- `appConfig.baseUrl`(默认 `https://cs.workyai.cn`)
|
||||
|
||||
改成你的服务地址后重新运行/打包。
|
||||
|
||||
### 2. 后端需提供的核心接口
|
||||
|
||||
桌面端通过现有 Web API 工作,至少需要这些接口:
|
||||
|
||||
- `POST /api/login`
|
||||
- `POST /api/logout`
|
||||
- `GET /api/user/profile`
|
||||
- `GET /api/files`
|
||||
- `GET /api/files/search`
|
||||
- `POST /api/files/mkdir`
|
||||
- `POST /api/files/rename`
|
||||
- `POST /api/files/delete`
|
||||
- `POST /api/share/create`
|
||||
- `GET /api/share/my`
|
||||
- `DELETE /api/share/:id`
|
||||
- `POST /api/direct-link/create`
|
||||
- `GET /api/client/desktop-update`
|
||||
- 分片上传相关接口(客户端走 `api_upload_file_resumable`)
|
||||
|
||||
### 3. 鉴权与跨域要求
|
||||
|
||||
- 使用 Cookie / Session 鉴权
|
||||
- 服务端允许桌面端跨域并携带凭据(credentials)
|
||||
- 建议启用 HTTPS
|
||||
|
||||
## 构建打包
|
||||
|
||||
```bash
|
||||
cd desktop-client
|
||||
npm install
|
||||
npm run tauri build
|
||||
```
|
||||
|
||||
构建产物示例:
|
||||
|
||||
- `desktop-client/src-tauri/target/release/bundle/nsis/*.exe`
|
||||
|
||||
## Linux 交叉编译 Windows(x64)
|
||||
|
||||
```bash
|
||||
cd desktop-client
|
||||
rustup target add x86_64-pc-windows-gnu
|
||||
npm run tauri build -- --target x86_64-pc-windows-gnu
|
||||
```
|
||||
|
||||
如需生成安装包,请确保系统安装 `mingw-w64` 与 `nsis`。
|
||||
|
||||
## 更新发布接入(与本项目后端配合)
|
||||
|
||||
1. 把新安装包上传到服务端下载目录(示例:`frontend/downloads/`)。
|
||||
2. 在后台设置中更新:
|
||||
- `desktop_update.latest_version`
|
||||
- `desktop_update.installer_url`
|
||||
- `desktop_update.release_notes`
|
||||
3. 客户端会通过 `GET /api/client/desktop-update` 检查更新并下载新包。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q1:改了接口地址但还是连旧地址?
|
||||
|
||||
确认修改的是 `desktop-client/src/App.vue` 中的 `appConfig.baseUrl`,并重新构建。
|
||||
|
||||
### Q2:为什么更新接口返回有新版本,但客户端不弹更新?
|
||||
|
||||
检查:
|
||||
|
||||
- `latest_version` 是否确实大于客户端当前版本
|
||||
- `installer_url` 是否可访问
|
||||
- 客户端能否访问后端 `desktop-update` 接口
|
||||
|
||||
### Q3:分享/直链重复创建问题
|
||||
|
||||
当前后端已支持“同一用户同一路径复用已有链接”。若要生成新链接,请先删除旧链接再创建。
|
||||
14
desktop-client/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Tauri + Vue + Typescript App</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
1753
desktop-client/package-lock.json
generated
Normal file
25
desktop-client/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "desktop-client",
|
||||
"private": true,
|
||||
"version": "0.1.28",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"vue": "^3.5.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.3",
|
||||
"vue-tsc": "^2.1.10"
|
||||
}
|
||||
}
|
||||
6
desktop-client/public/tauri.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
|
||||
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
1
desktop-client/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
7
desktop-client/src-tauri/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
5843
desktop-client/src-tauri/Cargo.lock
generated
Normal file
34
desktop-client/src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,34 @@
|
||||
[package]
|
||||
name = "desktop-client"
|
||||
version = "0.1.28"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
# The `_lib` suffix may seem redundant but it is necessary
|
||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||
name = "desktop_client_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
tokio = { version = "1", features = ["time"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "cookies", "multipart", "stream", "rustls-tls"] }
|
||||
urlencoding = "2.1"
|
||||
walkdir = "2.5"
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Security_Cryptography"] }
|
||||
3
desktop-client/src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
11
desktop-client/src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default",
|
||||
"dialog:default"
|
||||
]
|
||||
}
|
||||
BIN
desktop-client/src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
desktop-client/src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
desktop-client/src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
desktop-client/src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
BIN
desktop-client/src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
desktop-client/src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
desktop-client/src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
desktop-client/src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
desktop-client/src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
desktop-client/src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
desktop-client/src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
desktop-client/src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
BIN
desktop-client/src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
desktop-client/src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 35 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
BIN
desktop-client/src-tauri/icons/icon.icns
Normal file
BIN
desktop-client/src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
desktop-client/src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
desktop-client/src-tauri/icons/ios/AppIcon-20x20@1x.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
desktop-client/src-tauri/icons/ios/AppIcon-20x20@2x-1.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
desktop-client/src-tauri/icons/ios/AppIcon-20x20@2x.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
desktop-client/src-tauri/icons/ios/AppIcon-20x20@3x.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
desktop-client/src-tauri/icons/ios/AppIcon-29x29@1x.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
desktop-client/src-tauri/icons/ios/AppIcon-29x29@2x-1.png
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
desktop-client/src-tauri/icons/ios/AppIcon-29x29@2x.png
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
desktop-client/src-tauri/icons/ios/AppIcon-29x29@3x.png
Normal file
|
After Width: | Height: | Size: 9.7 KiB |
BIN
desktop-client/src-tauri/icons/ios/AppIcon-40x40@1x.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
desktop-client/src-tauri/icons/ios/AppIcon-40x40@2x-1.png
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
BIN
desktop-client/src-tauri/icons/ios/AppIcon-40x40@2x.png
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
BIN
desktop-client/src-tauri/icons/ios/AppIcon-40x40@3x.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
desktop-client/src-tauri/icons/ios/AppIcon-512@2x.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
desktop-client/src-tauri/icons/ios/AppIcon-60x60@2x.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
desktop-client/src-tauri/icons/ios/AppIcon-60x60@3x.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
desktop-client/src-tauri/icons/ios/AppIcon-76x76@1x.png
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
BIN
desktop-client/src-tauri/icons/ios/AppIcon-76x76@2x.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
desktop-client/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
desktop-client/src-tauri/icons/wanwan-dog-source.png
Normal file
|
After Width: | Height: | Size: 83 KiB |
2091
desktop-client/src-tauri/src/lib.rs
Normal file
6
desktop-client/src-tauri/src/main.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
desktop_client_lib::run()
|
||||
}
|
||||
37
desktop-client/src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "玩玩云",
|
||||
"version": "0.1.28",
|
||||
"identifier": "cn.workyai.wanwancloud.desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "玩玩云",
|
||||
"width": 1360,
|
||||
"height": 860,
|
||||
"minWidth": 1120,
|
||||
"minHeight": 720
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
4682
desktop-client/src/App.vue
Normal file
1
desktop-client/src/assets/vue.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
4
desktop-client/src/main.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
|
||||
createApp(App).mount("#app");
|
||||
7
desktop-client/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
export default component;
|
||||
}
|
||||
25
desktop-client/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
desktop-client/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
32
desktop-client/vite.config.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
|
||||
// @ts-expect-error process is a nodejs global
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig(async () => ({
|
||||
plugins: [vue()],
|
||||
|
||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||
//
|
||||
// 1. prevent Vite from obscuring rust errors
|
||||
clearScreen: false,
|
||||
// 2. tauri expects a fixed port, fail if that port is not available
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
host: host || false,
|
||||
hmr: host
|
||||
? {
|
||||
protocol: "ws",
|
||||
host,
|
||||
port: 1421,
|
||||
}
|
||||
: undefined,
|
||||
watch: {
|
||||
// 3. tell Vite to ignore watching `src-tauri`
|
||||
ignored: ["**/src-tauri/**"],
|
||||
},
|
||||
},
|
||||
}));
|
||||
5032
frontend/app.html
2261
frontend/app.js
@@ -3,9 +3,8 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>玩玩云 - 现代化云存储平台</title>
|
||||
<title>玩玩云 - 企业网盘</title>
|
||||
<script>
|
||||
// 邮件激活/重置链接重定向
|
||||
(function() {
|
||||
const search = window.location.search;
|
||||
if (!search) return;
|
||||
@@ -17,651 +16,243 @@
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="libs/fontawesome/css/all.min.css">
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 暗色主题(默认) */
|
||||
:root {
|
||||
--bg-primary: #0a0a0f;
|
||||
--bg-secondary: #12121a;
|
||||
--glass: rgba(255, 255, 255, 0.03);
|
||||
--glass-border: rgba(255, 255, 255, 0.08);
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: rgba(255, 255, 255, 0.6);
|
||||
--accent-1: #667eea;
|
||||
--accent-2: #764ba2;
|
||||
--accent-3: #f093fb;
|
||||
--glow: rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
/* 亮色主题 */
|
||||
.light-theme {
|
||||
--bg-primary: #f0f4f8;
|
||||
--bg-secondary: #ffffff;
|
||||
--glass: rgba(102, 126, 234, 0.05);
|
||||
--glass-border: rgba(102, 126, 234, 0.15);
|
||||
--text-primary: #1a1a2e;
|
||||
--text-secondary: rgba(26, 26, 46, 0.7);
|
||||
--accent-1: #5a67d8;
|
||||
--accent-2: #6b46c1;
|
||||
--accent-3: #d53f8c;
|
||||
--glow: rgba(90, 103, 216, 0.3);
|
||||
}
|
||||
|
||||
/* 亮色主题特定样式 */
|
||||
body.light-theme .navbar {
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
body.light-theme .grid-bg {
|
||||
background-image:
|
||||
linear-gradient(rgba(102, 126, 234, 0.05) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(102, 126, 234, 0.05) 1px, transparent 1px);
|
||||
}
|
||||
|
||||
body.light-theme .gradient-orb {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
body.light-theme .feature-card {
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
body.light-theme .tech-bar {
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* 动态背景 */
|
||||
.bg-gradient {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: -1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gradient-orb {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(80px);
|
||||
opacity: 0.5;
|
||||
animation: float 20s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.orb-1 {
|
||||
width: 600px;
|
||||
height: 600px;
|
||||
background: linear-gradient(135deg, var(--accent-1), var(--accent-2));
|
||||
top: -200px;
|
||||
right: -200px;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.orb-2 {
|
||||
width: 500px;
|
||||
height: 500px;
|
||||
background: linear-gradient(135deg, var(--accent-2), var(--accent-3));
|
||||
bottom: -150px;
|
||||
left: -150px;
|
||||
animation-delay: -7s;
|
||||
}
|
||||
|
||||
.orb-3 {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
background: linear-gradient(135deg, var(--accent-3), var(--accent-1));
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
animation-delay: -14s;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
25% { transform: translate(50px, -50px) scale(1.1); }
|
||||
50% { transform: translate(-30px, 30px) scale(0.95); }
|
||||
75% { transform: translate(-50px, -30px) scale(1.05); }
|
||||
}
|
||||
|
||||
/* 网格背景 */
|
||||
.grid-bg {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-image:
|
||||
linear-gradient(rgba(255,255,255,0.02) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,0.02) 1px, transparent 1px);
|
||||
background-size: 60px 60px;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
/* 导航栏 */
|
||||
.navbar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 20px 50px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
z-index: 100;
|
||||
background: rgba(10, 10, 15, 0.8);
|
||||
backdrop-filter: blur(20px);
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.logo i {
|
||||
font-size: 32px;
|
||||
background: linear-gradient(135deg, var(--accent-1), var(--accent-3));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 12px 28px;
|
||||
border-radius: 12px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--glass);
|
||||
border-color: var(--glass-border);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--accent-1), var(--accent-2));
|
||||
color: white;
|
||||
box-shadow: 0 4px 20px var(--glow);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 30px var(--glow);
|
||||
}
|
||||
|
||||
/* 主内容区 */
|
||||
.main {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 120px 50px 80px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 80px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 左侧内容 */
|
||||
.hero-content {
|
||||
animation: fadeIn 1s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(30px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
background: var(--glass);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 50px;
|
||||
font-size: 13px;
|
||||
color: var(--accent-3);
|
||||
margin-bottom: 30px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.badge i {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 64px;
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
margin-bottom: 24px;
|
||||
letter-spacing: -2px;
|
||||
}
|
||||
|
||||
.hero-title .gradient-text {
|
||||
background: linear-gradient(135deg, var(--accent-1), var(--accent-3));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.hero-desc {
|
||||
font-size: 18px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.7;
|
||||
margin-bottom: 40px;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.hero-buttons {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
|
||||
.btn-large {
|
||||
padding: 16px 36px;
|
||||
font-size: 16px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
/* 统计数据 */
|
||||
.stats {
|
||||
display: flex;
|
||||
gap: 50px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, var(--text-primary), var(--text-secondary));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* 右侧功能卡片 */
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 20px;
|
||||
animation: fadeIn 1s ease-out 0.3s both;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
background: var(--glass);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 20px;
|
||||
padding: 28px;
|
||||
backdrop-filter: blur(20px);
|
||||
transition: all 0.4s ease;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
transform: translateY(-8px);
|
||||
border-color: rgba(102, 126, 234, 0.3);
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.feature-card:nth-child(2) { animation-delay: 0.1s; }
|
||||
.feature-card:nth-child(3) { animation-delay: 0.2s; }
|
||||
.feature-card:nth-child(4) { animation-delay: 0.3s; }
|
||||
|
||||
.feature-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: linear-gradient(135deg, var(--accent-1), var(--accent-2));
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 18px;
|
||||
font-size: 22px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.feature-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.feature-desc {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 底部技术栈 */
|
||||
.tech-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 20px 50px;
|
||||
background: rgba(10, 10, 15, 0.9);
|
||||
backdrop-filter: blur(20px);
|
||||
border-top: 1px solid var(--glass-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tech-list {
|
||||
display: flex;
|
||||
gap: 30px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tech-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.tech-item:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tech-item i {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.copyright {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 1024px) {
|
||||
.container {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 60px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.hero-desc {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.hero-buttons {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.stats {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.navbar {
|
||||
padding: 15px 20px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.logo i {
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.main {
|
||||
padding: 100px 20px 120px;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 40px;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
|
||||
.hero-desc {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.hero-buttons {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.btn-large {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.stats {
|
||||
flex-wrap: wrap;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.tech-bar {
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
padding: 15px 20px;
|
||||
}
|
||||
|
||||
.tech-list {
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.nav-links .btn span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-links .btn {
|
||||
padding: 10px 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="landing.css?v=20260212008">
|
||||
</head>
|
||||
<body>
|
||||
<!-- 背景效果 -->
|
||||
<div class="bg-gradient">
|
||||
<div class="gradient-orb orb-1"></div>
|
||||
<div class="gradient-orb orb-2"></div>
|
||||
<div class="gradient-orb orb-3"></div>
|
||||
</div>
|
||||
<div class="grid-bg"></div>
|
||||
<div class="page">
|
||||
<header class="site-header">
|
||||
<div class="container site-header-inner">
|
||||
<a class="brand" href="index.html">
|
||||
<i class="fas fa-cloud"></i>
|
||||
<span>玩玩云</span>
|
||||
</a>
|
||||
|
||||
<!-- 导航栏 -->
|
||||
<nav class="navbar">
|
||||
<div class="logo">
|
||||
<i class="fas fa-cloud"></i>
|
||||
<span>玩玩云</span>
|
||||
</div>
|
||||
<div class="nav-links">
|
||||
<a href="app.html?action=login" class="btn btn-ghost">
|
||||
<i class="fas fa-arrow-right-to-bracket"></i>
|
||||
<span>登录</span>
|
||||
</a>
|
||||
<a href="app.html?action=register" class="btn btn-primary">
|
||||
<i class="fas fa-rocket"></i>
|
||||
<span>开始使用</span>
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
<nav class="top-nav">
|
||||
<a href="product.html">产品能力</a>
|
||||
<a href="scenes.html">应用场景</a>
|
||||
<a href="start.html">快速开始</a>
|
||||
</nav>
|
||||
|
||||
<!-- 主内容 -->
|
||||
<main class="main">
|
||||
<div class="container">
|
||||
<!-- 左侧文案 -->
|
||||
<div class="hero-content">
|
||||
<div class="badge">
|
||||
<i class="fas fa-circle"></i>
|
||||
<span>安全 · 高效 · 简洁</span>
|
||||
</div>
|
||||
<h1 class="hero-title">
|
||||
现代化<br><span class="gradient-text">云存储平台</span>
|
||||
</h1>
|
||||
<p class="hero-desc">
|
||||
简单、安全、高效的文件管理解决方案。支持 OSS 云存储和服务器本地存储双模式,随时随地管理和分享你的文件。
|
||||
</p>
|
||||
<div class="hero-buttons">
|
||||
<a href="app.html?action=register" class="btn btn-primary btn-large">
|
||||
<i class="fas fa-rocket"></i>
|
||||
<span>免费注册</span>
|
||||
<div class="header-actions">
|
||||
<a href="app.html?action=login" class="btn btn-secondary">
|
||||
<i class="fas fa-circle-user"></i>
|
||||
登录
|
||||
</a>
|
||||
<a href="app.html?action=login" class="btn btn-ghost btn-large">
|
||||
<i class="fas fa-play"></i>
|
||||
<span>已有账号</span>
|
||||
<a href="app.html?action=register" class="btn btn-primary">
|
||||
<i class="fas fa-user-plus"></i>
|
||||
免费注册
|
||||
</a>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">5GB</div>
|
||||
<div class="stat-label">单文件上限</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">双模式</div>
|
||||
<div class="stat-label">存储方案</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">24/7</div>
|
||||
<div class="stat-label">全天候服务</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 右侧功能卡片 -->
|
||||
<div class="features-grid">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<i class="fas fa-cloud"></i>
|
||||
<main class="main">
|
||||
<div class="container">
|
||||
<section class="hero">
|
||||
<div>
|
||||
<div class="hero-tag">
|
||||
<i class="fas fa-shield-halved"></i>
|
||||
企业网盘 · 稳定可控
|
||||
</div>
|
||||
<h1 class="hero-title">像主流网盘一样好用,
|
||||
<br>更适合你的私有部署场景</h1>
|
||||
<p class="hero-desc">
|
||||
玩玩云提供文件上传、在线预览、分享权限、存储配额与后台管理能力,支持本地与 OSS 双模式,满足团队长期文件管理与协作需求。
|
||||
</p>
|
||||
<div class="hero-actions">
|
||||
<a href="app.html?action=register" class="btn btn-primary">
|
||||
<i class="fas fa-rocket"></i>
|
||||
立即开始
|
||||
</a>
|
||||
<a href="app.html?action=login" class="btn btn-secondary">
|
||||
<i class="fas fa-circle-user"></i>
|
||||
已有账号,去登录
|
||||
</a>
|
||||
</div>
|
||||
<div class="hero-points">
|
||||
<span><i class="fas fa-check-circle"></i> 单文件分享 / 密码访问 / 时效控制</span>
|
||||
<span><i class="fas fa-check-circle"></i> 用户存储策略与配额可按人精细化配置</span>
|
||||
<span><i class="fas fa-check-circle"></i> 网页端与移动端统一体验,开箱即用</span>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="feature-title">OSS 云存储</h3>
|
||||
<p class="feature-desc">支持阿里云、腾讯云、AWS S3,数据完全自主掌控</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<i class="fas fa-cloud-arrow-up"></i>
|
||||
</div>
|
||||
<h3 class="feature-title">极速上传</h3>
|
||||
<p class="feature-desc">拖拽上传,实时进度,支持大文件直连上传</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<i class="fas fa-share-nodes"></i>
|
||||
</div>
|
||||
<h3 class="feature-title">安全分享</h3>
|
||||
<p class="feature-desc">一键生成链接,支持密码保护和有效期</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<i class="fas fa-shield-halved"></i>
|
||||
</div>
|
||||
<h3 class="feature-title">企业安全</h3>
|
||||
<p class="feature-desc">JWT 认证,bcrypt 加密,全链路安全</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 底部技术栈 -->
|
||||
<div class="tech-bar">
|
||||
<div class="tech-list">
|
||||
<div class="tech-item">
|
||||
<i class="fab fa-node-js"></i>
|
||||
<span>Node.js</span>
|
||||
<div class="carousel" id="netdiskCarousel">
|
||||
<div class="carousel-viewport">
|
||||
<article class="carousel-slide is-active" data-title="文件管理">
|
||||
<div class="slide-head">
|
||||
<div class="slide-title">文件管理界面</div>
|
||||
<span class="slide-tag">目录组织</span>
|
||||
</div>
|
||||
<div class="slide-body">
|
||||
<div class="slide-row"><span>当前目录</span><strong>/项目文档/交付包</strong></div>
|
||||
<div class="slide-row"><span>文件数量</span><strong>128 项</strong></div>
|
||||
<div class="slide-row"><span>总容量</span><strong>7.6 GB</strong></div>
|
||||
<div class="slide-progress"><span style="width: 62%;"></span></div>
|
||||
<div class="slide-row"><span>最近上传</span><strong>需求说明-v3.pdf</strong></div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="carousel-slide" data-title="分享控制">
|
||||
<div class="slide-head">
|
||||
<div class="slide-title">分享权限控制</div>
|
||||
<span class="slide-tag">安全分享</span>
|
||||
</div>
|
||||
<div class="slide-body">
|
||||
<div class="slide-row"><span>分享链接</span><strong>/s/Ab8K9Q</strong></div>
|
||||
<div class="slide-row"><span>访问方式</span><strong>密码 + 有效期</strong></div>
|
||||
<div class="slide-row"><span>访问次数</span><strong>剩余 9 / 10</strong></div>
|
||||
<div class="slide-progress"><span style="width: 90%; background: linear-gradient(90deg,#f59e0b,#ef4444);"></span></div>
|
||||
<div class="slide-row"><span>过期时间</span><strong>2026-02-20 23:59</strong></div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="carousel-slide" data-title="存储策略">
|
||||
<div class="slide-head">
|
||||
<div class="slide-title">双存储模式</div>
|
||||
<span class="slide-tag">本地 + OSS</span>
|
||||
</div>
|
||||
<div class="slide-body">
|
||||
<div class="slide-row"><span>当前存储</span><strong>OSS 存储</strong></div>
|
||||
<div class="slide-row"><span>已使用</span><strong>408.99 MB / 1 GB</strong></div>
|
||||
<div class="slide-row"><span>用户配额</span><strong>默认 1 GB(可调整)</strong></div>
|
||||
<div class="slide-progress"><span style="width: 41%; background: linear-gradient(90deg,#22c55e,#2468f2);"></span></div>
|
||||
<div class="slide-row"><span>策略状态</span><strong>管理员可统一下发</strong></div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="carousel-footer">
|
||||
<div class="carousel-dots" id="carouselDots">
|
||||
<button class="carousel-dot active" data-index="0" aria-label="切换到第1张"></button>
|
||||
<button class="carousel-dot" data-index="1" aria-label="切换到第2张"></button>
|
||||
<button class="carousel-dot" data-index="2" aria-label="切换到第3张"></button>
|
||||
</div>
|
||||
<div class="carousel-nav">
|
||||
<button class="carousel-btn" id="carouselPrev" aria-label="上一张"><i class="fas fa-angle-left"></i></button>
|
||||
<button class="carousel-btn" id="carouselNext" aria-label="下一张"><i class="fas fa-angle-right"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2 class="section-title"><i class="fas fa-layer-group"></i>核心能力</h2>
|
||||
<div class="feature-grid">
|
||||
<article class="feature-card">
|
||||
<div class="feature-icon"><i class="fas fa-cloud-arrow-up"></i></div>
|
||||
<div class="feature-name">高效上传</div>
|
||||
<p class="feature-desc">支持多文件上传、拖拽上传与进度反馈,日常资料归档更高效。</p>
|
||||
</article>
|
||||
<article class="feature-card">
|
||||
<div class="feature-icon"><i class="fas fa-folder-tree"></i></div>
|
||||
<div class="feature-name">目录管理</div>
|
||||
<p class="feature-desc">支持目录层级导航、重命名、删除等管理操作,结构清晰。</p>
|
||||
</article>
|
||||
<article class="feature-card">
|
||||
<div class="feature-icon"><i class="fas fa-share-nodes"></i></div>
|
||||
<div class="feature-name">可控分享</div>
|
||||
<p class="feature-desc">可设置密码、有效期、访问次数,外发资料更安全。</p>
|
||||
</article>
|
||||
<article class="feature-card">
|
||||
<div class="feature-icon"><i class="fas fa-user-shield"></i></div>
|
||||
<div class="feature-name">后台管控</div>
|
||||
<p class="feature-desc">支持用户权限、配额策略、存储类型管理,维护成本低。</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="tech-item">
|
||||
<i class="fab fa-vuejs"></i>
|
||||
<span>Vue.js</span>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="container footer-inner">
|
||||
<div>玩玩云 © 2026</div>
|
||||
<div class="footer-tech">
|
||||
<span><i class="fab fa-node-js"></i> Node.js</span>
|
||||
<span><i class="fab fa-vuejs"></i> Vue.js</span>
|
||||
<span><i class="fas fa-database"></i> SQLite</span>
|
||||
<span><i class="fab fa-docker"></i> Docker</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tech-item">
|
||||
<i class="fas fa-database"></i>
|
||||
<span>SQLite</span>
|
||||
</div>
|
||||
<div class="tech-item">
|
||||
<i class="fab fa-docker"></i>
|
||||
<span>Docker</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="copyright">
|
||||
玩玩云 © 2025
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 加载全局主题
|
||||
(function() {
|
||||
const carousel = document.getElementById('netdiskCarousel');
|
||||
if (!carousel) return;
|
||||
|
||||
const slides = Array.from(carousel.querySelectorAll('.carousel-slide'));
|
||||
const dots = Array.from(document.querySelectorAll('#carouselDots .carousel-dot'));
|
||||
const prevBtn = document.getElementById('carouselPrev');
|
||||
const nextBtn = document.getElementById('carouselNext');
|
||||
let currentIndex = 0;
|
||||
let timer = null;
|
||||
|
||||
function render(index) {
|
||||
currentIndex = (index + slides.length) % slides.length;
|
||||
slides.forEach((slide, idx) => {
|
||||
slide.classList.toggle('is-active', idx === currentIndex);
|
||||
});
|
||||
dots.forEach((dot, idx) => {
|
||||
dot.classList.toggle('active', idx === currentIndex);
|
||||
});
|
||||
}
|
||||
|
||||
function next() {
|
||||
render(currentIndex + 1);
|
||||
}
|
||||
|
||||
function startAuto() {
|
||||
stopAuto();
|
||||
timer = setInterval(next, 4500);
|
||||
}
|
||||
|
||||
function stopAuto() {
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
dots.forEach((dot, idx) => {
|
||||
dot.addEventListener('click', () => {
|
||||
render(idx);
|
||||
startAuto();
|
||||
});
|
||||
});
|
||||
|
||||
if (prevBtn) {
|
||||
prevBtn.addEventListener('click', () => {
|
||||
render(currentIndex - 1);
|
||||
startAuto();
|
||||
});
|
||||
}
|
||||
|
||||
if (nextBtn) {
|
||||
nextBtn.addEventListener('click', () => {
|
||||
render(currentIndex + 1);
|
||||
startAuto();
|
||||
});
|
||||
}
|
||||
|
||||
carousel.addEventListener('mouseenter', stopAuto);
|
||||
carousel.addEventListener('mouseleave', startAuto);
|
||||
carousel.addEventListener('touchstart', stopAuto, { passive: true });
|
||||
carousel.addEventListener('touchend', startAuto, { passive: true });
|
||||
|
||||
render(0);
|
||||
startAuto();
|
||||
})();
|
||||
|
||||
(async function() {
|
||||
try {
|
||||
const response = await fetch('/api/public/theme');
|
||||
const data = await response.json();
|
||||
if (data.success && data.theme === 'light') {
|
||||
document.body.classList.add('light-theme');
|
||||
if (data.success && data.theme === 'dark') {
|
||||
document.body.classList.add('theme-dark');
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('无法加载主题设置');
|
||||
} catch (error) {
|
||||
console.log('主题加载失败,使用默认主题');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
1048
frontend/landing.css
Normal file
148
frontend/product.html
Normal file
@@ -0,0 +1,148 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>产品能力 - 玩玩云</title>
|
||||
<link rel="stylesheet" href="libs/fontawesome/css/all.min.css">
|
||||
<link rel="stylesheet" href="landing.css?v=20260212008">
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<header class="site-header">
|
||||
<div class="container site-header-inner">
|
||||
<a class="brand" href="index.html">
|
||||
<i class="fas fa-cloud"></i>
|
||||
<span>玩玩云</span>
|
||||
</a>
|
||||
|
||||
<nav class="top-nav">
|
||||
<a href="product.html" class="active">产品能力</a>
|
||||
<a href="scenes.html">应用场景</a>
|
||||
<a href="start.html">快速开始</a>
|
||||
</nav>
|
||||
|
||||
<div class="header-actions">
|
||||
<a href="app.html?action=login" class="btn btn-secondary">
|
||||
<i class="fas fa-circle-user"></i>
|
||||
登录
|
||||
</a>
|
||||
<a href="app.html?action=register" class="btn btn-primary">
|
||||
<i class="fas fa-user-plus"></i>
|
||||
免费注册
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main">
|
||||
<div class="container">
|
||||
<section class="page-banner">
|
||||
<h1>产品能力</h1>
|
||||
<p>围绕“上传、管理、分享、权限”构建企业网盘核心能力,保持简单操作路径与稳定使用体验。</p>
|
||||
<div class="kpi-row">
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label">存储模式</div>
|
||||
<div class="kpi-value">本地 + OSS</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label">分享策略</div>
|
||||
<div class="kpi-value">密码/时效/次数</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label">管理能力</div>
|
||||
<div class="kpi-value">用户/配额/权限</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2 class="section-title"><i class="fas fa-layer-group"></i>核心功能矩阵</h2>
|
||||
<div class="feature-grid">
|
||||
<article class="feature-card">
|
||||
<div class="feature-icon"><i class="fas fa-cloud-arrow-up"></i></div>
|
||||
<div class="feature-name">上传与同步</div>
|
||||
<p class="feature-desc">支持多文件上传、拖拽上传与进度反馈,上传结果即时可见。</p>
|
||||
</article>
|
||||
<article class="feature-card">
|
||||
<div class="feature-icon"><i class="fas fa-folder-tree"></i></div>
|
||||
<div class="feature-name">目录管理</div>
|
||||
<p class="feature-desc">支持文件夹层级管理、重命名、删除与路径导航。</p>
|
||||
</article>
|
||||
<article class="feature-card">
|
||||
<div class="feature-icon"><i class="fas fa-image"></i></div>
|
||||
<div class="feature-name">在线预览</div>
|
||||
<p class="feature-desc">图片、视频、音频等常见格式可在线打开预览。</p>
|
||||
</article>
|
||||
<article class="feature-card">
|
||||
<div class="feature-icon"><i class="fas fa-share-nodes"></i></div>
|
||||
<div class="feature-name">安全分享</div>
|
||||
<p class="feature-desc">支持访问密码、有效期、访问次数,外发更可控。</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2 class="section-title"><i class="fas fa-sliders"></i>管理能力</h2>
|
||||
<div class="matrix">
|
||||
<article class="matrix-card">
|
||||
<h3>用户与权限管理</h3>
|
||||
<p>管理员可分配用户权限、管理账号状态,支持业务隔离需求。</p>
|
||||
<ul class="list-check" style="margin-top:8px;">
|
||||
<li><i class="fas fa-check"></i>管理员/用户角色分离</li>
|
||||
<li><i class="fas fa-check"></i>权限策略可调整</li>
|
||||
<li><i class="fas fa-check"></i>支持后续策略扩展</li>
|
||||
</ul>
|
||||
</article>
|
||||
<article class="matrix-card">
|
||||
<h3>存储策略与配额</h3>
|
||||
<p>支持按用户设置存储类型、默认配额、容量上限,避免资源失控。</p>
|
||||
<ul class="list-check" style="margin-top:8px;">
|
||||
<li><i class="fas fa-check"></i>默认配额可配置</li>
|
||||
<li><i class="fas fa-check"></i>OSS 与本地并行管理</li>
|
||||
<li><i class="fas fa-check"></i>容量使用实时可见</li>
|
||||
</ul>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="cta-panel">
|
||||
<div>
|
||||
<h3 style="font-size:16px;margin-bottom:4px;">开始体验完整产品能力</h3>
|
||||
<p>注册账号后即可进入文件管理后台,按你的业务流程配置网盘策略。</p>
|
||||
</div>
|
||||
<a href="app.html?action=register" class="btn btn-primary"><i class="fas fa-rocket"></i>立即开始</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="container footer-inner">
|
||||
<div>玩玩云 © 2026</div>
|
||||
<div class="footer-tech">
|
||||
<span><i class="fab fa-node-js"></i> Node.js</span>
|
||||
<span><i class="fab fa-vuejs"></i> Vue.js</span>
|
||||
<span><i class="fas fa-database"></i> SQLite</span>
|
||||
<span><i class="fab fa-docker"></i> Docker</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(async function() {
|
||||
try {
|
||||
const response = await fetch('/api/public/theme');
|
||||
const data = await response.json();
|
||||
if (data.success && data.theme === 'dark') {
|
||||
document.body.classList.add('theme-dark');
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('主题加载失败,使用默认主题');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
155
frontend/scenes.html
Normal file
@@ -0,0 +1,155 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>应用场景 - 玩玩云</title>
|
||||
<link rel="stylesheet" href="libs/fontawesome/css/all.min.css">
|
||||
<link rel="stylesheet" href="landing.css?v=20260212008">
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<header class="site-header">
|
||||
<div class="container site-header-inner">
|
||||
<a class="brand" href="index.html">
|
||||
<i class="fas fa-cloud"></i>
|
||||
<span>玩玩云</span>
|
||||
</a>
|
||||
|
||||
<nav class="top-nav">
|
||||
<a href="product.html">产品能力</a>
|
||||
<a href="scenes.html" class="active">应用场景</a>
|
||||
<a href="start.html">快速开始</a>
|
||||
</nav>
|
||||
|
||||
<div class="header-actions">
|
||||
<a href="app.html?action=login" class="btn btn-secondary">
|
||||
<i class="fas fa-circle-user"></i>
|
||||
登录
|
||||
</a>
|
||||
<a href="app.html?action=register" class="btn btn-primary">
|
||||
<i class="fas fa-user-plus"></i>
|
||||
免费注册
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main">
|
||||
<div class="container">
|
||||
<section class="page-banner">
|
||||
<h1>应用场景</h1>
|
||||
<p>无论是团队协作、客户交付还是私有部署文档管理,玩玩云都能提供稳定可控的文件平台能力。</p>
|
||||
<div class="kpi-row">
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label">文档集中管理</div>
|
||||
<div class="kpi-value">统一入口</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label">外部交付</div>
|
||||
<div class="kpi-value">安全分享</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label">数据控制</div>
|
||||
<div class="kpi-value">私有部署</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2 class="section-title"><i class="fas fa-briefcase"></i>典型业务场景</h2>
|
||||
<div class="scene-grid">
|
||||
<article class="scene">
|
||||
<h3>团队协作文件库</h3>
|
||||
<p>集中存放项目资料、合同、设计稿,统一访问入口,减少文件分散。</p>
|
||||
<ul class="list-check">
|
||||
<li><i class="fas fa-check"></i>项目文档统一归档</li>
|
||||
<li><i class="fas fa-check"></i>分享权限可追踪</li>
|
||||
<li><i class="fas fa-check"></i>支持移动端快速查看</li>
|
||||
</ul>
|
||||
</article>
|
||||
<article class="scene">
|
||||
<h3>客户交付中心</h3>
|
||||
<p>交付包通过链接安全下发,支持密码与时效,降低误扩散风险。</p>
|
||||
<ul class="list-check">
|
||||
<li><i class="fas fa-check"></i>分享自动失效</li>
|
||||
<li><i class="fas fa-check"></i>下载行为可控</li>
|
||||
<li><i class="fas fa-check"></i>可按客户分目录管理</li>
|
||||
</ul>
|
||||
</article>
|
||||
<article class="scene">
|
||||
<h3>私有化文档平台</h3>
|
||||
<p>适合内网或私有云部署,数据可控,满足合规与长期沉淀需求。</p>
|
||||
<ul class="list-check">
|
||||
<li><i class="fas fa-check"></i>本地与 OSS 灵活组合</li>
|
||||
<li><i class="fas fa-check"></i>用户配额精细化</li>
|
||||
<li><i class="fas fa-check"></i>部署维护成本可控</li>
|
||||
</ul>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2 class="section-title"><i class="fas fa-building"></i>按团队角色使用</h2>
|
||||
<div class="matrix">
|
||||
<article class="matrix-card">
|
||||
<h3>产品/运营团队</h3>
|
||||
<p>用于版本资料、活动素材、协作文档统一管理,避免版本混乱。</p>
|
||||
<ul class="list-check" style="margin-top:8px;">
|
||||
<li><i class="fas fa-check"></i>按项目分类目录</li>
|
||||
<li><i class="fas fa-check"></i>快速检索与下载</li>
|
||||
<li><i class="fas fa-check"></i>外部共享便捷</li>
|
||||
</ul>
|
||||
</article>
|
||||
<article class="matrix-card">
|
||||
<h3>技术/交付团队</h3>
|
||||
<p>用于交付包、部署文档、测试包归档,实现可追溯的交付链路。</p>
|
||||
<ul class="list-check" style="margin-top:8px;">
|
||||
<li><i class="fas fa-check"></i>版本文件集中留存</li>
|
||||
<li><i class="fas fa-check"></i>访问策略可控制</li>
|
||||
<li><i class="fas fa-check"></i>长期沉淀知识资产</li>
|
||||
</ul>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="cta-panel">
|
||||
<div>
|
||||
<h3 style="font-size:16px;margin-bottom:4px;">让文件平台贴合你的业务场景</h3>
|
||||
<p>你可以先注册体验,再按团队方式配置存储与分享策略。</p>
|
||||
</div>
|
||||
<a href="app.html?action=register" class="btn btn-primary"><i class="fas fa-rocket"></i>开始试用</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="container footer-inner">
|
||||
<div>玩玩云 © 2026</div>
|
||||
<div class="footer-tech">
|
||||
<span><i class="fab fa-node-js"></i> Node.js</span>
|
||||
<span><i class="fab fa-vuejs"></i> Vue.js</span>
|
||||
<span><i class="fas fa-database"></i> SQLite</span>
|
||||
<span><i class="fab fa-docker"></i> Docker</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(async function() {
|
||||
try {
|
||||
const response = await fetch('/api/public/theme');
|
||||
const data = await response.json();
|
||||
if (data.success && data.theme === 'dark') {
|
||||
document.body.classList.add('theme-dark');
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('主题加载失败,使用默认主题');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -4,9 +4,9 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>文件分享 - 玩玩云</title>
|
||||
<script src="libs/vue.global.prod.js"></script>
|
||||
<script src="libs/axios.min.js"></script>
|
||||
<link rel="stylesheet" href="libs/fontawesome/css/all.min.css">
|
||||
<script src="/libs/vue.global.prod.js"></script>
|
||||
<script src="/libs/axios.min.js"></script>
|
||||
<link rel="stylesheet" href="/libs/fontawesome/css/all.min.css">
|
||||
<style>
|
||||
/* 防止 Vue 初始化前显示原始模板 */
|
||||
[v-cloak] { display: none !important; }
|
||||
@@ -626,14 +626,375 @@
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* ===== Enterprise Netdisk Share UI Rebuild (Classic Cloud Disk) ===== */
|
||||
body.enterprise-netdisk-share,
|
||||
body.enterprise-netdisk-share.light-theme,
|
||||
body.enterprise-netdisk-share:not(.light-theme) {
|
||||
--bg-primary: #edf2fb;
|
||||
--bg-secondary: #fbfdff;
|
||||
--bg-card: #f8fbff;
|
||||
--bg-card-hover: #eef3fb;
|
||||
--glass-border: #d2ddec;
|
||||
--glass-border-hover: #bccce2;
|
||||
--text-primary: #1f2937;
|
||||
--text-secondary: #5b6472;
|
||||
--text-muted: #8b95a7;
|
||||
--accent-1: #2563eb;
|
||||
--accent-2: #1d4ed8;
|
||||
--accent-3: #1e40af;
|
||||
--glow: rgba(37, 99, 235, 0.14);
|
||||
--danger: #dc2626;
|
||||
--success: #16a34a;
|
||||
--warning: #d97706;
|
||||
background: linear-gradient(180deg, #eef3fb 0%, #f8fafe 42%, #edf2fb 100%) !important;
|
||||
color: var(--text-primary) !important;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', Roboto, sans-serif;
|
||||
line-height: 1.5;
|
||||
padding: 14px 8px;
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
body.enterprise-netdisk-share::before {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body.enterprise-netdisk-share .container {
|
||||
max-width: 1140px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
body.enterprise-netdisk-share .card {
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 10px;
|
||||
background: var(--bg-card);
|
||||
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06);
|
||||
backdrop-filter: none;
|
||||
padding: 18px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
body.enterprise-netdisk-share .title {
|
||||
margin-bottom: 14px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
color: var(--text-primary);
|
||||
font-size: 21px;
|
||||
background: none;
|
||||
-webkit-text-fill-color: currentColor;
|
||||
}
|
||||
|
||||
body.enterprise-netdisk-share .title i {
|
||||
color: var(--accent-1);
|
||||
-webkit-text-fill-color: currentColor;
|
||||
}
|
||||
|
||||
body.enterprise-netdisk-share .share-back-btn {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
body.enterprise-netdisk-share .share-meta-bar {
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-card-hover);
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
body.enterprise-netdisk-share .share-expire-time {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
body.enterprise-netdisk-share .share-expire-time.expiring {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
body.enterprise-netdisk-share .share-expire-time.expired {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
body.enterprise-netdisk-share .share-expire-time.valid {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
body.enterprise-netdisk-share .btn {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
padding: 9px 14px;
|
||||
box-shadow: none;
|
||||
transition: all .2s ease;
|
||||
}
|
||||
|
||||
body.enterprise-netdisk-share .btn-primary {
|
||||
background: var(--accent-1);
|
||||
border-color: var(--accent-1);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
body.enterprise-netdisk-share .btn-primary:hover {
|
||||
background: var(--accent-2);
|
||||
border-color: var(--accent-2);
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
body.enterprise-netdisk-share .btn-secondary {
|
||||
background: var(--bg-secondary);
|
||||
border-color: var(--glass-border);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
body.enterprise-netdisk-share .btn-secondary:hover {
|
||||
background: var(--bg-card-hover);
|
||||
border-color: var(--glass-border-hover);
|
||||
}
|
||||
|
||||
body.enterprise-netdisk-share .form-label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
body.enterprise-netdisk-share .form-input {
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
padding: 10px 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
body.enterprise-netdisk-share .form-input:focus {
|
||||
border-color: var(--accent-1);
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.14);
|
||||
outline: none;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
body.enterprise-netdisk-share .alert {
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
body.enterprise-netdisk-share .alert-error {
|
||||
background: #fef2f2;
|
||||
border-color: #fca5a5;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
body.enterprise-netdisk-share .download-alert {
|
||||
margin-bottom: 12px;
|
||||
animation: fadeInOut 0.2s ease;
|
||||
}
|
||||
|
||||
body.enterprise-netdisk-share .view-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
body.enterprise-netdisk-share .loading {
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-card-hover);
|
||||
color: var(--text-secondary);
|
||||
padding: 28px 14px;
|
||||
}
|
||||
|
||||
body.enterprise-netdisk-share .spinner {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 3px solid rgba(148, 163, 184, 0.25);
|
||||
border-top: 3px solid var(--accent-1);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
body.enterprise-netdisk-share .file-grid {
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
}
|
||||
|
||||
body.enterprise-netdisk-share .file-grid-item {
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-secondary);
|
||||
min-height: 170px;
|
||||
padding: 12px;
|
||||
transition: all .2s ease;
|
||||
}
|
||||
|
||||
body.enterprise-netdisk-share .file-grid-item:hover {
|
||||
border-color: #93c5fd;
|
||||
background: #f8fbff;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
body.enterprise-netdisk-share .file-grid-icon {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
body.enterprise-netdisk-share .file-grid-name {
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
body.enterprise-netdisk-share .file-grid-size {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin: 6px 0 10px;
|
||||
}
|
||||
|
||||
body.enterprise-netdisk-share .file-list {
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
body.enterprise-netdisk-share .file-item {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
transition: all .2s ease;
|
||||
}
|
||||
|
||||
body.enterprise-netdisk-share .file-item:hover {
|
||||
background: var(--bg-card-hover);
|
||||
}
|
||||
|
||||
body.enterprise-netdisk-share .file-icon {
|
||||
width: 26px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
body.enterprise-netdisk-share .single-file-container {
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-card-hover);
|
||||
padding: 14px;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
body.enterprise-netdisk-share .single-file-card {
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-secondary);
|
||||
box-shadow: none;
|
||||
padding: 20px;
|
||||
max-width: 460px;
|
||||
}
|
||||
|
||||
body.enterprise-netdisk-share .single-file-name {
|
||||
color: var(--text-primary);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
body.enterprise-netdisk-share .single-file-size {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
body.enterprise-netdisk-share .single-file-download {
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
body.enterprise-netdisk-share .share-not-found {
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-card-hover);
|
||||
padding: 40px 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
body.enterprise-netdisk-share .share-not-found-title {
|
||||
margin-bottom: 10px;
|
||||
font-size: 22px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
body.enterprise-netdisk-share .share-not-found-message {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
body.enterprise-netdisk-share ::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
body.enterprise-netdisk-share ::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
body.enterprise-netdisk-share ::-webkit-scrollbar-thumb {
|
||||
background: rgba(148, 163, 184, 0.45);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
body.enterprise-netdisk-share .card {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
body.enterprise-netdisk-share .title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
body.enterprise-netdisk-share .view-controls {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
body.enterprise-netdisk-share .view-controls .btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
body.enterprise-netdisk-share .file-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(118px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
body.enterprise-netdisk-share .file-grid-item {
|
||||
min-height: 152px;
|
||||
}
|
||||
|
||||
body.enterprise-netdisk-share .single-file-card {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInOut {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<body class="enterprise-netdisk-share">
|
||||
<div id="app" v-cloak>
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<div class="title">
|
||||
<!-- 返回按钮:仅在查看单个文件详情且不是单文件分享时显示 -->
|
||||
<button v-if="viewingFile && shareInfo.share_type !== 'file'" class="btn btn-secondary" @click="backToList" style="margin-right: 10px;">
|
||||
<button v-if="viewingFile && shareInfo.share_type !== 'file'" class="btn btn-secondary share-back-btn" @click="backToList">
|
||||
<i class="fas fa-arrow-left"></i> 返回列表
|
||||
</button>
|
||||
<i class="fas fa-cloud"></i>
|
||||
@@ -671,11 +1032,12 @@
|
||||
|
||||
<!-- 文件列表 -->
|
||||
<div v-else-if="verified">
|
||||
<p style="color: var(--text-secondary); margin-bottom: 20px;">
|
||||
<div v-if="downloadAlertMessage" class="alert alert-error download-alert">{{ downloadAlertMessage }}</div>
|
||||
<p class="share-meta-bar">
|
||||
分享者: <strong style="color: var(--text-primary);">{{ shareInfo.username }}</strong> |
|
||||
创建时间: {{ formatDate(shareInfo.created_at) }}
|
||||
<span v-if="shareInfo.expires_at"> | 到期时间: <strong :style="{color: isExpiringSoon(shareInfo.expires_at) ? '#f59e0b' : isExpired(shareInfo.expires_at) ? '#ef4444' : '#22c55e'}">{{ formatExpireTime(shareInfo.expires_at) }}</strong></span>
|
||||
<span v-else> | 有效期: <strong style="color: #22c55e;">永久有效</strong></span>
|
||||
<span v-if="shareInfo.expires_at"> | 到期时间: <strong class="share-expire-time" :class="{ expiring: isExpiringSoon(shareInfo.expires_at), expired: isExpired(shareInfo.expires_at) }">{{ formatExpireTime(shareInfo.expires_at) }}</strong></span>
|
||||
<span v-else> | 有效期: <strong class="share-expire-time valid">永久有效</strong></span>
|
||||
</p>
|
||||
|
||||
<!-- 视图切换按钮 (多文件时才显示) -->
|
||||
@@ -766,6 +1128,8 @@
|
||||
files: [],
|
||||
loading: true,
|
||||
errorMessage: '',
|
||||
downloadAlertMessage: '',
|
||||
downloadAlertTimer: null,
|
||||
viewMode: "grid", // 视图模式: grid 大图标, list 列表(默认大图标)
|
||||
// 主题
|
||||
currentTheme: 'dark',
|
||||
@@ -777,7 +1141,21 @@
|
||||
methods: {
|
||||
async init() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
this.shareCode = urlParams.get('code');
|
||||
let shareCode = (urlParams.get('code') || '').trim();
|
||||
|
||||
// 兼容 /s/{code} 直链(无 query 参数)
|
||||
if (!shareCode) {
|
||||
const match = window.location.pathname.match(/^\/s\/([^/?#]+)/i);
|
||||
if (match && match[1]) {
|
||||
try {
|
||||
shareCode = decodeURIComponent(match[1]).trim();
|
||||
} catch (_) {
|
||||
shareCode = match[1].trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.shareCode = shareCode;
|
||||
|
||||
if (!this.shareCode) {
|
||||
this.errorMessage = '无效的分享链接';
|
||||
@@ -817,6 +1195,7 @@
|
||||
|
||||
async verifyShare() {
|
||||
this.errorMessage = '';
|
||||
this.downloadAlertMessage = '';
|
||||
this.loading = true;
|
||||
|
||||
try {
|
||||
@@ -906,21 +1285,20 @@
|
||||
|
||||
try {
|
||||
// 获取下载 URL(OSS 直连或后端代理)
|
||||
const params = { path: filePath };
|
||||
if (this.password) {
|
||||
params.password = this.password;
|
||||
}
|
||||
|
||||
const { data } = await axios.get(`${this.apiBase}/api/share/${this.shareCode}/download-url`, { params });
|
||||
const { data } = await axios.post(`${this.apiBase}/api/share/${this.shareCode}/download-url`, {
|
||||
path: filePath,
|
||||
password: this.password || undefined
|
||||
});
|
||||
|
||||
if (data.success) {
|
||||
// 记录下载次数(异步,不等待)
|
||||
axios.post(`${this.apiBase}/api/share/${this.shareCode}/download`)
|
||||
.catch(err => console.error('记录下载次数失败:', err));
|
||||
|
||||
if (data.direct) {
|
||||
// OSS 直连下载:新窗口打开
|
||||
console.log("[分享下载] OSS 直连下载");
|
||||
|
||||
// 仅直连下载需要单独记录下载次数(本地代理下载在后端接口内已计数)
|
||||
axios.post(`${this.apiBase}/api/share/${this.shareCode}/download`)
|
||||
.catch(err => console.error('记录下载次数失败:', err));
|
||||
|
||||
window.open(data.downloadUrl, '_blank');
|
||||
} else {
|
||||
// 本地存储:通过后端下载
|
||||
@@ -930,10 +1308,28 @@
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[分享下载] 获取下载链接失败:', error);
|
||||
alert('获取下载链接失败: ' + (error.response?.data?.message || error.message));
|
||||
const message = error.response?.data?.message || '当前网络繁忙,请稍后再试';
|
||||
this.showDownloadAlert(message);
|
||||
}
|
||||
},
|
||||
|
||||
showDownloadAlert(message) {
|
||||
const safeMessage = typeof message === 'string' && message.trim()
|
||||
? message.trim()
|
||||
: '当前网络繁忙,请稍后再试';
|
||||
|
||||
this.downloadAlertMessage = safeMessage;
|
||||
if (this.downloadAlertTimer) {
|
||||
clearTimeout(this.downloadAlertTimer);
|
||||
this.downloadAlertTimer = null;
|
||||
}
|
||||
|
||||
this.downloadAlertTimer = setTimeout(() => {
|
||||
this.downloadAlertMessage = '';
|
||||
this.downloadAlertTimer = null;
|
||||
}, 3000);
|
||||
},
|
||||
|
||||
// 触发下载(使用隐藏的a标签,避免页面闪动)
|
||||
triggerDownload(url, filename) {
|
||||
const link = document.createElement('a');
|
||||
@@ -973,17 +1369,36 @@
|
||||
return 'color: #9E9E9E;';
|
||||
},
|
||||
|
||||
formatDate(dateString) {
|
||||
if (!dateString) return '';
|
||||
|
||||
// SQLite 返回的是 UTC 时间字符串,需要显式处理
|
||||
let dateStr = dateString;
|
||||
if (!dateStr.includes('Z') && !dateStr.includes('+') && !dateStr.includes('T')) {
|
||||
// SQLite 格式: "2025-11-13 16:37:19" -> ISO格式: "2025-11-13T16:37:19Z"
|
||||
dateStr = dateStr.replace(' ', 'T') + 'Z';
|
||||
parseDateValue(value) {
|
||||
if (!value) return null;
|
||||
if (value instanceof Date) {
|
||||
return Number.isNaN(value.getTime()) ? null : value;
|
||||
}
|
||||
|
||||
const date = new Date(dateStr);
|
||||
const raw = String(value).trim();
|
||||
if (!raw) return null;
|
||||
|
||||
const localMatch = raw.match(/^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?$/);
|
||||
if (localMatch) {
|
||||
const year = Number(localMatch[1]);
|
||||
const month = Number(localMatch[2]);
|
||||
const day = Number(localMatch[3]);
|
||||
const hour = Number(localMatch[4] || 0);
|
||||
const minute = Number(localMatch[5] || 0);
|
||||
const second = Number(localMatch[6] || 0);
|
||||
const localDate = new Date(year, month - 1, day, hour, minute, second);
|
||||
return Number.isNaN(localDate.getTime()) ? null : localDate;
|
||||
}
|
||||
|
||||
const normalized = raw.includes('T') ? raw : raw.replace(' ', 'T');
|
||||
const parsed = new Date(normalized);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
},
|
||||
|
||||
formatDate(dateString) {
|
||||
if (!dateString) return '';
|
||||
const date = this.parseDateValue(dateString);
|
||||
if (!date) return String(dateString);
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
@@ -999,7 +1414,8 @@
|
||||
formatExpireTime(expiresAt) {
|
||||
if (!expiresAt) return '永久有效';
|
||||
|
||||
const expireDate = new Date(expiresAt);
|
||||
const expireDate = this.parseDateValue(expiresAt);
|
||||
if (!expireDate) return String(expiresAt);
|
||||
const now = new Date();
|
||||
const diffMs = expireDate - now;
|
||||
const diffMinutes = Math.floor(diffMs / (1000 * 60));
|
||||
@@ -1033,7 +1449,8 @@
|
||||
// 判断是否即将过期(3天内)
|
||||
isExpiringSoon(expiresAt) {
|
||||
if (!expiresAt) return false;
|
||||
const expireDate = new Date(expiresAt);
|
||||
const expireDate = this.parseDateValue(expiresAt);
|
||||
if (!expireDate) return false;
|
||||
const now = new Date();
|
||||
const diffMs = expireDate - now;
|
||||
const diffDays = diffMs / (1000 * 60 * 60 * 24);
|
||||
@@ -1043,14 +1460,43 @@
|
||||
// 判断是否已过期
|
||||
isExpired(expiresAt) {
|
||||
if (!expiresAt) return false;
|
||||
const expireDate = new Date(expiresAt);
|
||||
const expireDate = this.parseDateValue(expiresAt);
|
||||
if (!expireDate) return false;
|
||||
const now = new Date();
|
||||
return expireDate <= now;
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
async mounted() {
|
||||
axios.defaults.withCredentials = true;
|
||||
|
||||
axios.interceptors.request.use(config => {
|
||||
const csrfToken = document.cookie
|
||||
.split('; ')
|
||||
.find(row => row.startsWith('csrf_token='))
|
||||
?.split('=')[1];
|
||||
|
||||
if (csrfToken && ['POST', 'PUT', 'DELETE', 'PATCH'].includes(config.method?.toUpperCase())) {
|
||||
config.headers['X-CSRF-Token'] = csrfToken;
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
try {
|
||||
await axios.get(`${this.apiBase}/api/csrf-token`);
|
||||
} catch (error) {
|
||||
console.warn('初始化 CSRF Token 失败:', error?.message || error);
|
||||
}
|
||||
|
||||
this.init();
|
||||
},
|
||||
|
||||
beforeUnmount() {
|
||||
if (this.downloadAlertTimer) {
|
||||
clearTimeout(this.downloadAlertTimer);
|
||||
this.downloadAlertTimer = null;
|
||||
}
|
||||
}
|
||||
}).mount('#app');
|
||||
</script>
|
||||
@@ -1070,57 +1516,17 @@
|
||||
// 禁用F12和常见开发者工具快捷键(调试模式下不禁用)
|
||||
if (!isDebugMode) {
|
||||
document.addEventListener('keydown', function(e) {
|
||||
// F12
|
||||
if (e.key === 'F12' || e.keyCode === 123) {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}
|
||||
// Ctrl+Shift+I (开发者工具)
|
||||
if (e.ctrlKey && e.shiftKey && (e.key === 'I' || e.keyCode === 73)) {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}
|
||||
// Ctrl+Shift+J (控制台)
|
||||
if (e.ctrlKey && e.shiftKey && (e.key === 'J' || e.keyCode === 74)) {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}
|
||||
// Ctrl+U (查看源代码)
|
||||
if (e.ctrlKey && (e.key === 'U' || e.keyCode === 85)) {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}
|
||||
// Ctrl+Shift+C (元素选择器)
|
||||
if (e.ctrlKey && e.shiftKey && (e.key === 'C' || e.keyCode === 67)) {
|
||||
const key = String(e.key || '').toLowerCase();
|
||||
const blocked = e.keyCode === 123
|
||||
|| (e.ctrlKey && e.shiftKey && ['i', 'j', 'c'].includes(key))
|
||||
|| (e.ctrlKey && key === 'u');
|
||||
if (blocked) {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 检测开发者工具是否打开(调试模式下不检测)
|
||||
if (!isDebugMode) {
|
||||
(function() {
|
||||
const threshold = 160;
|
||||
let isDevToolsOpen = false;
|
||||
|
||||
setInterval(function() {
|
||||
const widthThreshold = window.outerWidth - window.innerWidth > threshold;
|
||||
const heightThreshold = window.outerHeight - window.innerHeight > threshold;
|
||||
|
||||
if (!(heightThreshold && widthThreshold) &&
|
||||
((window.Firebug && window.Firebug.chrome && window.Firebug.chrome.isInitialized) || widthThreshold || heightThreshold)) {
|
||||
if (!isDevToolsOpen) {
|
||||
isDevToolsOpen = true;
|
||||
console.clear();
|
||||
}
|
||||
} else {
|
||||
isDevToolsOpen = false;
|
||||
}
|
||||
}, 500);
|
||||
})();
|
||||
}
|
||||
|
||||
// 禁用console输出(调试模式下不禁用)
|
||||
if (!isDebugMode && window.location.hostname !== 'localhost' && window.location.hostname !== '127.0.0.1') {
|
||||
console.log = function() {};
|
||||
|
||||
157
frontend/start.html
Normal file
@@ -0,0 +1,157 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>快速开始 - 玩玩云</title>
|
||||
<link rel="stylesheet" href="libs/fontawesome/css/all.min.css">
|
||||
<link rel="stylesheet" href="landing.css?v=20260212008">
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<header class="site-header">
|
||||
<div class="container site-header-inner">
|
||||
<a class="brand" href="index.html">
|
||||
<i class="fas fa-cloud"></i>
|
||||
<span>玩玩云</span>
|
||||
</a>
|
||||
|
||||
<nav class="top-nav">
|
||||
<a href="product.html">产品能力</a>
|
||||
<a href="scenes.html">应用场景</a>
|
||||
<a href="start.html" class="active">快速开始</a>
|
||||
</nav>
|
||||
|
||||
<div class="header-actions">
|
||||
<a href="app.html?action=login" class="btn btn-secondary">
|
||||
<i class="fas fa-circle-user"></i>
|
||||
登录
|
||||
</a>
|
||||
<a href="app.html?action=register" class="btn btn-primary">
|
||||
<i class="fas fa-user-plus"></i>
|
||||
免费注册
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main">
|
||||
<div class="container">
|
||||
<section class="page-banner">
|
||||
<h1>快速开始</h1>
|
||||
<p>3 分钟完成首次体验:注册账号、上传文件、创建安全分享。后续可按业务需要配置存储与权限策略。</p>
|
||||
<div class="kpi-row">
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label">上手成本</div>
|
||||
<div class="kpi-value">低学习门槛</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label">部署方式</div>
|
||||
<div class="kpi-value">Docker / Node.js</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label">推荐起步</div>
|
||||
<div class="kpi-value">默认 1GB 配额</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2 class="section-title"><i class="fas fa-list-check"></i>上手流程</h2>
|
||||
<div class="step-line">
|
||||
<article class="step-item">
|
||||
<span class="step-num">1</span>
|
||||
<div class="step-content">
|
||||
<h3>注册并登录</h3>
|
||||
<p>创建用户账号后进入“我的文件”,系统会自动使用默认存储策略与配额。</p>
|
||||
</div>
|
||||
</article>
|
||||
<article class="step-item">
|
||||
<span class="step-num">2</span>
|
||||
<div class="step-content">
|
||||
<h3>上传并整理文件</h3>
|
||||
<p>通过“上传文件”与“新建文件夹”完成基础目录结构,便于后续共享与检索。</p>
|
||||
</div>
|
||||
</article>
|
||||
<article class="step-item">
|
||||
<span class="step-num">3</span>
|
||||
<div class="step-content">
|
||||
<h3>创建分享链接</h3>
|
||||
<p>按需设置密码、有效期和访问次数,将资料安全地发送给目标对象。</p>
|
||||
</div>
|
||||
</article>
|
||||
<article class="step-item">
|
||||
<span class="step-num">4</span>
|
||||
<div class="step-content">
|
||||
<h3>管理员精细配置</h3>
|
||||
<p>在管理员面板配置用户存储类型(本地/OSS)与配额限制,保证资源可控。</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2 class="section-title"><i class="fas fa-circle-question"></i>常见问题</h2>
|
||||
<div class="matrix">
|
||||
<article class="matrix-card">
|
||||
<h3>默认配额是多少?</h3>
|
||||
<p>如果管理员未单独设置,系统默认使用 1GB 配额策略,后续可动态调整。</p>
|
||||
</article>
|
||||
<article class="matrix-card">
|
||||
<h3>如何选择存储方式?</h3>
|
||||
<p>支持本地与 OSS 模式。管理员可统一控制,也可在授权下由用户自行切换。</p>
|
||||
</article>
|
||||
<article class="matrix-card">
|
||||
<h3>如何提升分享安全性?</h3>
|
||||
<p>建议启用访问密码、较短有效期和访问次数上限,避免链接扩散风险。</p>
|
||||
</article>
|
||||
<article class="matrix-card">
|
||||
<h3>移动端是否可用?</h3>
|
||||
<p>支持移动端上传、预览、下载与分享操作,常用流程已完成触控优化。</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="cta-panel">
|
||||
<div>
|
||||
<h3 style="font-size:16px;margin-bottom:4px;">准备好了就开始吧</h3>
|
||||
<p>你可以立即注册体验,也可以先登录已有账号继续使用。</p>
|
||||
</div>
|
||||
<div style="display:inline-flex;gap:8px;flex-wrap:wrap;">
|
||||
<a href="app.html?action=register" class="btn btn-primary"><i class="fas fa-rocket"></i>立即注册</a>
|
||||
<a href="app.html?action=login" class="btn btn-secondary"><i class="fas fa-circle-user"></i>去登录</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="container footer-inner">
|
||||
<div>玩玩云 © 2026</div>
|
||||
<div class="footer-tech">
|
||||
<span><i class="fab fa-node-js"></i> Node.js</span>
|
||||
<span><i class="fab fa-vuejs"></i> Vue.js</span>
|
||||
<span><i class="fas fa-database"></i> SQLite</span>
|
||||
<span><i class="fab fa-docker"></i> Docker</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(async function() {
|
||||
try {
|
||||
const response = await fetch('/api/public/theme');
|
||||
const data = await response.json();
|
||||
if (data.success && data.theme === 'dark') {
|
||||
document.body.classList.add('theme-dark');
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('主题加载失败,使用默认主题');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||