Compare commits

81 Commits

Author SHA1 Message Date
d8cd7fd514 fix: improve app relaunch after update and release 0.1.28 2026-02-20 23:53:02 +08:00
b161a3e3e7 feat: improve sync workspace and updater stability in 0.1.27 2026-02-20 23:44:26 +08:00
b179cae14e fix: make silent updater produce logs reliably and release 0.1.26 2026-02-20 22:55:20 +08:00
c8f63d6fc9 feat: verify updater package and harden client reliability in 0.1.25 2026-02-20 22:15:28 +08:00
fe544efc91 fix: harden silent updater flow and release 0.1.24 2026-02-20 20:34:02 +08:00
01384a2215 feat: improve upload resilience and release 0.1.23 2026-02-20 20:21:42 +08:00
6618de1aed fix: correct mobile web layout for client download button 2026-02-20 16:15:54 +08:00
e2318ac42e feat: add web entry to download desktop client 2026-02-19 21:49:32 +08:00
cdfe45b3a2 fix: stabilize silent updater and release 0.1.22 2026-02-19 21:41:44 +08:00
7563664733 chore: bump desktop update channel to 0.1.21 2026-02-19 21:04:12 +08:00
77799ef819 feat: tighten file card borders on web and desktop with 0.1.10 release 2026-02-19 20:57:24 +08:00
374238d15f feat: restyle desktop file grid to baidu-style icon layout 2026-02-19 20:46:31 +08:00
099ba3e3e0 feat: release desktop 0.1.9 with device dedupe and new branding 2026-02-19 20:33:54 +08:00
71a19e9e87 feat: adjust confirm button order and improve file name wrapping 2026-02-19 20:09:05 +08:00
9c3ced5c44 chore: release desktop client 0.1.7 2026-02-19 19:44:15 +08:00
5082a5ed04 fix: unify client confirmations and inline rename UX 2026-02-19 19:36:52 +08:00
d604b8dc7b chore: bump desktop client version to 0.1.6 2026-02-19 19:14:35 +08:00
50d41cb7ae fix: restore login by defining getClientIp helper 2026-02-19 19:08:12 +08:00
19f53875c9 feat: add online device management and desktop settings integration 2026-02-19 17:34:41 +08:00
365ada1a4a feat(desktop): remember login in sqlite and streamline update flow 2026-02-19 00:12:33 +08:00
3329ff10cf chore(release): bump desktop client to 0.1.4 2026-02-18 22:55:52 +08:00
9d600a2d5c fix(desktop): require in-app confirmation before deleting shares 2026-02-18 22:53:53 +08:00
bb8a4ea386 fix(frontend): replace share delete confirm with in-app modal 2026-02-18 22:48:31 +08:00
fd236e6949 chore(release): bump desktop client to 0.1.3 2026-02-18 22:27:20 +08:00
b931f36bde feat(desktop): stream download progress and auto-launch installer 2026-02-18 22:24:41 +08:00
e4098bfda9 docs(desktop): rewrite README in Chinese with integration and build guide 2026-02-18 22:16:20 +08:00
fec2bd37a4 feat(update): auto-clean old desktop installer packages 2026-02-18 22:14:26 +08:00
af51d74a9f feat(share): reuse existing share and direct links per file 2026-02-18 22:13:14 +08:00
ada7986669 fix(frontend): use native confirm for share/direct-link deletion 2026-02-18 22:11:55 +08:00
74032fe497 chore(release): publish desktop 0.1.2 with manual update checks 2026-02-18 22:02:34 +08:00
f96a9ccaa9 feat(security): shorten download signed URLs to 30s and remove update polling 2026-02-18 21:59:14 +08:00
c83d9304ea chore(release): sync tauri cargo lock to 0.1.1 2026-02-18 21:25:47 +08:00
4b3a113285 chore(release): bump desktop client to 0.1.1 2026-02-18 21:23:05 +08:00
c81b5395ac feat(desktop): implement startup and scheduled auto update flow 2026-02-18 21:11:59 +08:00
5f91fd925d fix(desktop-ui): stabilize files toolbar layout 2026-02-18 21:08:18 +08:00
5c484e33a6 chore(desktop): align update panel copy with one-click installer flow 2026-02-18 20:28:08 +08:00
c668c88f7f feat(desktop): align requested 4/5/6/8 with resume queue batch and one-click update 2026-02-18 20:26:16 +08:00
32a66e6c77 feat(desktop): add sort/filter, update center, and local sync workspace 2026-02-18 20:07:21 +08:00
d4818a78d3 perf(desktop): stream drag-upload and improve transfer status UX 2026-02-18 19:50:34 +08:00
09043e8059 feat(desktop): add drag-and-drop upload for file view 2026-02-18 19:46:11 +08:00
2b36275c4a style(desktop): improve alignment and spacing across file/share views 2026-02-18 19:33:39 +08:00
8736a127a5 fix(desktop): enforce square file cards and icon tiles 2026-02-18 19:29:25 +08:00
24ac734503 feat(desktop): native download and working context menu actions 2026-02-18 19:25:52 +08:00
9da90f38cc feat(desktop): square file cards and context-menu file actions 2026-02-18 18:30:53 +08:00
3c483d2093 feat(desktop): implement share management and hide endpoint settings 2026-02-18 17:17:49 +08:00
e343f6ac2a feat(desktop): add tauri desktop client for cs.workyai.cn 2026-02-18 16:53:22 +08:00
3ea17db971 fix: correct local datetime display and remove false devtools detection 2026-02-18 11:23:34 +08:00
751428a29a fix: keep expired reservations reconcilable for delayed OSS logs 2026-02-18 10:49:58 +08:00
5eab1de03e fix: ingest oss traffic logs without file extensions 2026-02-18 10:24:00 +08:00
7ee727bd3a fix: bump app.js cache busting version 2026-02-18 10:08:11 +08:00
96ff46aa4a feat: add configurable stealth download security policies 2026-02-18 09:48:14 +08:00
8956270a60 fix: improve reservation cleanup and share popup handling 2026-02-17 23:55:31 +08:00
1a1c64c0e7 feat: add share security, resumable upload, global search and reservation ops panel 2026-02-17 23:36:30 +08:00
3c75986566 fix: bump app.js cache-busting version 2026-02-17 22:56:49 +08:00
aad1202d5e ui: show file names instead of full paths in shares 2026-02-17 22:54:12 +08:00
5f7599bd0d style: align share and direct-link table layout 2026-02-17 22:42:11 +08:00
b261d2750c fix: unify share/direct link click and copy actions 2026-02-17 22:39:19 +08:00
e909d9917a fix: normalize traffic range buttons layout in settings 2026-02-17 22:14:40 +08:00
6242622f1a feat: add independent direct-link sharing flow 2026-02-17 21:57:38 +08:00
d236a790a1 test: update admin/share edge scripts for cookie+csrf auth 2026-02-17 21:32:07 +08:00
aed5dfdcb2 feat: add server-side admin user pagination and align traffic report accounting 2026-02-17 20:30:02 +08:00
1eae645bfd feat: improve admin user management with filters and pagination 2026-02-17 20:13:32 +08:00
c506cf83be feat: improve media preview UX with caching and loading states 2026-02-17 20:03:02 +08:00
0885195cb5 fix: remove preview content-type override for aliyun oss compatibility 2026-02-17 19:51:01 +08:00
f0e7381c1d fix: use preview-mode signed URLs and graceful media preview fallback 2026-02-17 19:36:49 +08:00
2b700978ad fix: precheck local downloads to avoid JSON file download on quota errors 2026-02-17 19:32:48 +08:00
dd6c439eb3 fix: fallback to file icon when thumbnail load fails 2026-02-17 19:29:42 +08:00
978ae545e1 feat: make zero download quota block downloads and use -1 for unlimited 2026-02-17 19:25:39 +08:00
53e77ebf4e fix: precheck local share download quota at download-url stage 2026-02-17 19:08:47 +08:00
3ab92d672d chore: properly ignore runtime storage and data directories 2026-02-17 19:07:11 +08:00
19d3f29f6b fix: move share quota block to download and add 3s download alert 2026-02-17 19:05:12 +08:00
10a3f09952 feat: switch OSS download quota to reservation plus log reconcile 2026-02-17 18:12:33 +08:00
b171b41599 fix: force OSS direct download even when traffic quota is enabled 2026-02-17 17:40:55 +08:00
3a22b88f23 feat: add user download traffic reports and restore OSS direct downloads 2026-02-17 17:36:26 +08:00
7687397954 feat: enhance download traffic quota lifecycle controls 2026-02-17 17:19:25 +08:00
2629237f9e feat(quota): add downloadable traffic quota with local/OSS/share metering 2026-02-17 16:52:26 +08:00
b0e89df5c4 fix(security): harden CORS/cookie policy and share path validation 2026-02-12 21:39:01 +08:00
a3932747e3 fix(ui): apply true large-screen scaling and mobile overflow safeguards 2026-02-12 20:33:36 +08:00
8193101566 fix(frontend): improve 2k/4k scaling and mobile overflow responsiveness 2026-02-12 20:28:08 +08:00
12859cbb20 feat: apply UI/storage/share optimizations and quota improvements 2026-02-12 18:02:57 +08:00
1fcc60b9aa feat(frontend): unify landing style and add product/scenes/start pages 2026-02-12 18:02:28 +08:00
95 changed files with 32179 additions and 2547 deletions

6
.gitignore vendored
View File

@@ -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

View File

@@ -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,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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": {

View File

@@ -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 - 管理员功能

File diff suppressed because it is too large Load Diff

View File

@@ -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请使用分片上传`);
}
// 使用Buffer上传而非流式上传避免AWS SDK使用aws-chunked编码
// 这样可以确保与阿里云OSS的兼容性
if (fileSize <= OSS_SINGLE_UPLOAD_THRESHOLD) {
// 小文件保持单请求上传,兼容性更高。
const fileContent = fs.readFileSync(localPath);
// 直接上传
const command = new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: fileContent,
ContentLength: fileSize, // 明确指定内容长度,避免某些服务端问题
// 禁用checksum算法阿里云OSS不完全支持AWS的x-amz-content-sha256头
ContentLength: fileSize,
ChecksumAlgorithm: undefined
});
await this.s3Client.send(command);
console.log(`[OSS存储] 上传成功: ${key} (${formatFileSize(fileSize)})`);
return;
}
// 大文件自动切换到分片上传,避免整文件读入内存和 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 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,
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({

View File

@@ -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 = {
const transport = url.protocol === 'https:' ? https : http;
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;
}
}
}
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,
port: url.port || (url.protocol === 'https:' ? 443 : 80),
path: url.pathname + url.search,
method: method,
headers: {
'Content-Type': 'application/json'
}
};
if (token) {
options.headers['Authorization'] = `Bearer ${token}`;
}
const req = http.request(options, (res) => {
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: '' // 开发环境可能不需要验证码
// 2. 安全检查:无效 Token 应被拒绝
async function testInvalidTokenAccess() {
const res = await request('GET', '/api/admin/users', {
headers: {
Authorization: 'Bearer invalid-token'
}
});
// 登录可能因为验证码失败,这是预期的
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;
}
}
}
// 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`, {
const res = await request('POST', `/api/admin/users/${state.testUserId}/storage-permission`, {
data: {
storage_permission: 'local_only',
local_storage_quota: 2147483648 // 2GB
}, adminToken);
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', {
data: {
storage_permission: 'local_only'
}, adminToken);
},
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);
}
// 用户管理测试
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);
});

View File

@@ -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}`);

View File

@@ -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 = {
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;
}
}
}
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: port,
port: url.port || (url.protocol === 'https:' ? 443 : 80),
path: url.pathname + url.search,
method: method,
headers: {
'Content-Type': 'application/json',
...headers
}
};
const req = http.request(options, (res) => {
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, headers: res.headers });
} catch (e) {
resolve({ status: res.statusCode, data: body, headers: res.headers });
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', {
const createRes = await request('POST', '/api/share/create', {
data: {
share_type: 'file',
file_path: path
}, { Cookie: authCookie });
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', {
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('; ');
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('========================================');

View File

@@ -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
View 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
View 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 交叉编译 Windowsx64
```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
View 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

File diff suppressed because it is too large Load Diff

View 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"
}
}

View 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

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

File diff suppressed because it is too large Load Diff

View 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"] }

View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View 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"
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

@@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

File diff suppressed because it is too large Load Diff

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

View 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

File diff suppressed because it is too large Load Diff

View 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

View 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
View File

@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}

View 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" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View 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/**"],
},
},
}));

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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>
<!-- 导航栏 -->
<nav class="navbar">
<div class="logo">
<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>
</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 class="top-nav">
<a href="product.html">产品能力</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">
<!-- 左侧文案 -->
<div class="hero-content">
<div class="badge">
<i class="fas fa-circle"></i>
<span>安全 · 高效 · 简洁</span>
<section class="hero">
<div>
<div class="hero-tag">
<i class="fas fa-shield-halved"></i>
企业网盘 · 稳定可控
</div>
<h1 class="hero-title">
现代化<br><span class="gradient-text">云存储平台</span>
</h1>
<h1 class="hero-title">像主流网盘一样好用,
<br>更适合你的私有部署场景</h1>
<p class="hero-desc">
简单、安全、高效的文件管理解决方案。支持 OSS 云存储和服务器本地存储双模式,随时随地管理和分享你的文件
玩玩云提供文件上传、在线预览、分享权限、存储配额与后台管理能力,支持本地与 OSS 双模式,满足团队长期文件管理与协作需求
</p>
<div class="hero-buttons">
<a href="app.html?action=register" class="btn btn-primary btn-large">
<div class="hero-actions">
<a href="app.html?action=register" class="btn btn-primary">
<i class="fas fa-rocket"></i>
<span>免费注册</span>
立即开始
</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=login" class="btn btn-secondary">
<i class="fas fa-circle-user"></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 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>
<!-- 右侧功能卡片 -->
<div class="features-grid">
<div class="feature-card">
<div class="feature-icon">
<i class="fas fa-cloud"></i>
<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>
<h3 class="feature-title">OSS 云存储</h3>
<p class="feature-desc">支持阿里云、腾讯云、AWS S3数据完全自主掌控</p>
<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>
<div class="feature-card">
<div class="feature-icon">
<i class="fas fa-cloud-arrow-up"></i>
</article>
<article class="carousel-slide" data-title="分享控制">
<div class="slide-head">
<div class="slide-title">分享权限控制</div>
<span class="slide-tag">安全分享</span>
</div>
<h3 class="feature-title">极速上传</h3>
<p class="feature-desc">拖拽上传,实时进度,支持大文件直连上传</p>
<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>
<div class="feature-card">
<div class="feature-icon">
<i class="fas fa-share-nodes"></i>
</article>
<article class="carousel-slide" data-title="存储策略">
<div class="slide-head">
<div class="slide-title">双存储模式</div>
<span class="slide-tag">本地 + OSS</span>
</div>
<h3 class="feature-title">安全分享</h3>
<p class="feature-desc">一键生成链接,支持密码保护和有效期</p>
<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>
<div class="feature-card">
<div class="feature-icon">
<i class="fas fa-shield-halved"></i>
</article>
</div>
<h3 class="feature-title">企业安全</h3>
<p class="feature-desc">JWT 认证bcrypt 加密,全链路安全</p>
<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>
</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>
<div class="tech-item">
<i class="fab fa-vuejs"></i>
<span>Vue.js</span>
</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>
<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="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

File diff suppressed because it is too large Load Diff

148
frontend/product.html Normal file
View 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
View 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>

View File

@@ -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 {
// 获取下载 URLOSS 直连或后端代理)
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
View 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>