diff --git a/.gitignore b/.gitignore index 02f9f93..580703d 100644 --- a/.gitignore +++ b/.gitignore @@ -129,3 +129,11 @@ backend/*最终*.json backend/fix-env.js backend/create-admin.js backend/*.backup.* + +# 本地协作与设计验收产物 +/work/ +/desktop-client/design-qa.md + +# 桌面端历史安装包仅保留在发布服务器,仓库只跟踪当前版本 +/frontend/downloads/*.exe +!/frontend/downloads/wanwan-cloud-desktop_v0.1.38_x64-setup.exe diff --git a/backend/package-lock.json b/backend/package-lock.json index 16ba067..98c5b40 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -21,8 +21,8 @@ "express-validator": "^7.3.0", "jsonwebtoken": "^9.0.2", "lodash": "^4.17.23", - "multer": "^2.0.2", - "nodemailer": "^8.0.1", + "multer": "^2.2.0", + "nodemailer": "^9.0.3", "svg-captcha": "^1.4.0" }, "devDependencies": { @@ -3312,9 +3312,9 @@ "license": "MIT" }, "node_modules/multer": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz", - "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", "license": "MIT", "dependencies": { "append-field": "^1.0.0", @@ -3358,9 +3358,9 @@ } }, "node_modules/nodemailer": { - "version": "8.0.11", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.11.tgz", - "integrity": "sha512-nrO/pDAUKl+wXX+lx16tDLbnm0fW6sK/x8mgohaCpg+CdCEl482bD4tCuAZk2DyliruiNTIZxRCoWkDqJEnAiA==", + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz", + "integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==", "license": "MIT-0", "engines": { "node": ">=6.0.0" diff --git a/backend/package.json b/backend/package.json index 2e7678e..fde3d11 100644 --- a/backend/package.json +++ b/backend/package.json @@ -5,7 +5,10 @@ "main": "server.js", "scripts": { "start": "node server.js", - "dev": "nodemon server.js" + "dev": "nodemon server.js", + "test": "npm run test:unit && npm run test:integration && node test_download_quota_defaults.js", + "test:unit": "node tests/run-all-tests.js", + "test:integration": "node tests/full-audit-regression.js" }, "keywords": [ "cloud-storage", @@ -30,8 +33,8 @@ "express-validator": "^7.3.0", "jsonwebtoken": "^9.0.2", "lodash": "^4.17.23", - "multer": "^2.0.2", - "nodemailer": "^8.0.1", + "multer": "^2.2.0", + "nodemailer": "^9.0.3", "svg-captcha": "^1.4.0" }, "devDependencies": { diff --git a/backend/server.js b/backend/server.js index b349d24..7c608ee 100644 --- a/backend/server.js +++ b/backend/server.js @@ -112,7 +112,7 @@ const DOWNLOAD_SIGNED_URL_EXPIRES_SECONDS = Math.max( 10, Math.min(3600, Number(process.env.DOWNLOAD_SIGNED_URL_EXPIRES_SECONDS || 30)) ); -const DEFAULT_DESKTOP_VERSION = process.env.DESKTOP_LATEST_VERSION || '0.1.31'; +const DEFAULT_DESKTOP_VERSION = process.env.DESKTOP_LATEST_VERSION || '0.1.38'; const DEFAULT_DESKTOP_INSTALLER_URL = process.env.DESKTOP_INSTALLER_URL || ''; const DEFAULT_DESKTOP_INSTALLER_SHA256 = String(process.env.DESKTOP_INSTALLER_SHA256 || '').trim().toLowerCase(); const DEFAULT_DESKTOP_INSTALLER_SIZE = Math.max(0, Number(process.env.DESKTOP_INSTALLER_SIZE || 0)); @@ -2562,7 +2562,13 @@ const MULTER_UPLOAD_MAX_BYTES = Math.max( ); const upload = multer({ dest: path.join(__dirname, 'uploads'), - limits: { fileSize: MULTER_UPLOAD_MAX_BYTES } + limits: { + fileSize: MULTER_UPLOAD_MAX_BYTES, + fields: 20, + parts: 22, + fieldNameSize: 100, + fieldSize: 1024 * 1024 + } }); // ===== TTL缓存类 ===== @@ -5719,11 +5725,63 @@ app.get('/api/files/search', authMiddleware, async (req, res) => { } }); +function rewritePersistedPathsAfterRename(userId, storageType, oldPath, newPath, isDirectory) { + const oldPrefix = `${oldPath}/`; + const rewritePath = (value) => { + const current = normalizeVirtualPath(value || ''); + if (!current) return null; + if (current === oldPath) return newPath; + if (isDirectory && current.startsWith(oldPrefix)) { + return `${newPath}${current.slice(oldPath.length)}`; + } + return null; + }; + + db.transaction(() => { + const shares = db.prepare(` + SELECT id, share_path FROM shares + WHERE user_id = ? AND COALESCE(storage_type, 'oss') = ? + AND (share_path = ? OR share_path LIKE ?) + `).all(userId, storageType, oldPath, `${oldPrefix}%`); + const updateShare = db.prepare('UPDATE shares SET share_path = ? WHERE id = ?'); + for (const share of shares) { + const rewritten = rewritePath(share.share_path); + if (rewritten) updateShare.run(rewritten, share.id); + } + + const links = db.prepare(` + SELECT id, file_path FROM direct_links + WHERE user_id = ? AND COALESCE(storage_type, 'oss') = ? + AND (file_path = ? OR file_path LIKE ?) + `).all(userId, storageType, oldPath, `${oldPrefix}%`); + const updateLink = db.prepare('UPDATE direct_links SET file_path = ?, file_name = ? WHERE id = ?'); + for (const link of links) { + const rewritten = rewritePath(link.file_path); + if (rewritten) updateLink.run(rewritten, rewritten.split('/').pop() || null, link.id); + } + + const hashes = db.prepare(` + SELECT id, file_path FROM user_file_hash_index + WHERE user_id = ? AND storage_type = ? + AND (file_path = ? OR file_path LIKE ?) + `).all(userId, storageType, oldPath, `${oldPrefix}%`); + const updateHash = db.prepare(` + UPDATE user_file_hash_index + SET file_path = ?, object_key = NULL, updated_at = datetime('now', 'localtime') + WHERE id = ? + `); + for (const row of hashes) { + const rewritten = rewritePath(row.file_path); + if (rewritten) updateHash.run(rewritten, row.id); + } + })(); +} + // 重命名文件 app.post('/api/files/rename', authMiddleware, async (req, res) => { const oldName = decodeHtmlEntities(req.body.oldName); const newName = decodeHtmlEntities(req.body.newName); - const path = decodeHtmlEntities(req.body.path) || '/'; + const parentPath = normalizeVirtualPath(decodeHtmlEntities(req.body.path) || '/'); let storage; if (!oldName || !newName) { @@ -5733,14 +5791,22 @@ app.post('/api/files/rename', authMiddleware, async (req, res) => { }); } + if (!parentPath || !isSafePathSegment(oldName) || !isSafePathSegment(newName)) { + return res.status(400).json({ + success: false, + message: '文件名或路径包含非法字符' + }); + } + try { // 使用统一存储接口 const { StorageInterface } = require('./storage'); const storageInterface = new StorageInterface(req.user); storage = await storageInterface.connect(); - const oldPath = path === '/' ? `/${oldName}` : `${path}/${oldName}`; - const newPath = path === '/' ? `/${newName}` : `${path}/${newName}`; + const oldPath = parentPath === '/' ? `/${oldName}` : `${parentPath}/${oldName}`; + const newPath = parentPath === '/' ? `/${newName}` : `${parentPath}/${newName}`; + const sourceStats = await storage.stat(oldPath); await storage.rename(oldPath, newPath); @@ -5748,18 +5814,22 @@ app.post('/api/files/rename', authMiddleware, async (req, res) => { const normalizedOldPath = normalizeVirtualPath(oldPath); const normalizedNewPath = normalizeVirtualPath(newPath); if (normalizedOldPath && normalizedNewPath) { - const oldHashRow = FileHashIndexDB.getByPath(req.user.id, normalizedStorageType, normalizedOldPath); - if (oldHashRow) { - FileHashIndexDB.upsert({ - userId: req.user.id, - storageType: normalizedStorageType, - fileHash: oldHashRow.file_hash, - fileSize: oldHashRow.file_size, - filePath: normalizedNewPath, - objectKey: oldHashRow.object_key || null - }); + try { + rewritePersistedPathsAfterRename( + req.user.id, + normalizedStorageType, + normalizedOldPath, + normalizedNewPath, + !!sourceStats?.isDirectory + ); + } catch (metadataError) { + try { + await storage.rename(newPath, oldPath); + } catch (rollbackError) { + console.error('重命名元数据失败且存储回滚失败:', rollbackError); + } + throw metadataError; } - FileHashIndexDB.deleteByPath(req.user.id, normalizedStorageType, normalizedOldPath); } // 清除 OSS 使用情况缓存(如果用户使用 OSS) @@ -7140,6 +7210,28 @@ app.get('/api/files/download-url', authMiddleware, async (req, res) => { const trafficState = getDownloadTrafficState(latestUser); + if ((latestUser.current_storage_type || 'oss') === 'local') { + const localMode = isPreviewMode ? 'preview' : 'download'; + const downloadToken = signEphemeralToken({ + type: 'local_download', + userId: latestUser.id, + path: normalizedPath, + mode: localMode + }, 15 * 60); + const downloadUrl = `${getSecureBaseUrl(req)}/api/files/download` + + `?path=${encodeURIComponent(normalizedPath)}` + + `&mode=${encodeURIComponent(localMode)}` + + `&token=${encodeURIComponent(downloadToken)}`; + return res.json({ + success: true, + downloadUrl, + expiresIn: 15 * 60, + direct: false, + storageType: 'local', + quotaLimited: !trafficState.isUnlimited + }); + } + // 检查用户是否配置了 OSS(包括个人配置和系统级统一配置) const hasUnifiedConfig = SettingsDB.hasUnifiedOssConfig(); if (!req.user.has_oss_config && !hasUnifiedConfig) { @@ -7469,8 +7561,50 @@ app.get('/api/files/download-check', authMiddleware, async (req, res) => { } }); +function parseSingleByteRange(rangeHeader, fileSize) { + if (!rangeHeader) return null; + const match = /^bytes=(\d*)-(\d*)$/.exec(String(rangeHeader).trim()); + if (!match || (!match[1] && !match[2]) || fileSize <= 0) return { invalid: true }; + + let start; + let end; + if (!match[1]) { + const suffixLength = Number(match[2]); + if (!Number.isSafeInteger(suffixLength) || suffixLength <= 0) return { invalid: true }; + start = Math.max(0, fileSize - suffixLength); + end = fileSize - 1; + } else { + start = Number(match[1]); + end = match[2] ? Number(match[2]) : fileSize - 1; + if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || start >= fileSize || end < start) { + return { invalid: true }; + } + end = Math.min(end, fileSize - 1); + } + + return { invalid: false, start, end, length: end - start + 1 }; +} + +function authOrLocalDownloadToken(req, res, next) { + const tokenResult = verifyEphemeralToken(req.query?.token, 'local_download'); + if (tokenResult.valid) { + const payload = tokenResult.payload || {}; + const requestedPath = normalizeVirtualPath(req.query?.path || ''); + const requestedMode = req.query?.mode === 'preview' ? 'preview' : 'download'; + if (requestedPath && payload.path === requestedPath && payload.mode === requestedMode) { + const user = UserDB.findById(Number(payload.userId)); + if (user && user.is_active && !user.is_banned) { + req.user = user; + req.localDownloadTokenAuthenticated = true; + return next(); + } + } + } + return authMiddleware(req, res, next); +} + // 下载文件 -app.get('/api/files/download', authMiddleware, async (req, res) => { +app.get('/api/files/download', authOrLocalDownloadToken, async (req, res) => { const filePath = req.query.path; let storage; let storageEnded = false; // 防止重复关闭 @@ -7545,6 +7679,37 @@ app.get('/api/files/download', authMiddleware, async (req, res) => { }); } + // Legacy pages download with the session cookie only. Redirect once to a + // short-lived URL so Android's external download manager can resume without it. + if (!req.localDownloadTokenAuthenticated) { + const securityResult = evaluateDownloadSecurityPolicy(req, { + ownerUserId: req.user.id, + filePath: normalizedPath, + source: 'user_download_legacy_redirect' + }); + if (!securityResult.allowed) { + return handleDownloadSecurityBlock(req, res, securityResult, { + statusCode: 503, + ownerUserId: req.user.id, + filePath: normalizedPath, + source: 'user_download_legacy_redirect' + }); + } + + const downloadMode = req.query?.mode === 'preview' ? 'preview' : 'download'; + const downloadToken = signEphemeralToken({ + type: 'local_download', + userId: req.user.id, + path: normalizedPath, + mode: downloadMode + }, 15 * 60); + const redirectUrl = '/api/files/download' + + `?path=${encodeURIComponent(normalizedPath)}` + + `&mode=${encodeURIComponent(downloadMode)}` + + `&token=${encodeURIComponent(downloadToken)}`; + return res.redirect(302, redirectUrl); + } + try { const policyState = enforceDownloadTrafficPolicy(req.user.id, 'download'); const latestUser = policyState?.user || UserDB.findById(req.user.id); @@ -7555,18 +7720,22 @@ app.get('/api/files/download', authMiddleware, async (req, res) => { }); } - const securityResult = evaluateDownloadSecurityPolicy(req, { - ownerUserId: latestUser.id, - filePath: normalizedPath, - source: 'user_download_stream' - }); - if (!securityResult.allowed) { - return handleDownloadSecurityBlock(req, res, securityResult, { - statusCode: 503, + // The security policy is evaluated when the short-lived token is issued. + // Do not count the browser probes and DownloadManager requests again. + if (req.localDownloadTokenAuthenticated !== true) { + const securityResult = evaluateDownloadSecurityPolicy(req, { ownerUserId: latestUser.id, filePath: normalizedPath, source: 'user_download_stream' }); + if (!securityResult.allowed) { + return handleDownloadSecurityBlock(req, res, securityResult, { + statusCode: 503, + ownerUserId: latestUser.id, + filePath: normalizedPath, + source: 'user_download_stream' + }); + } } const trafficState = getDownloadTrafficState(latestUser); @@ -7582,29 +7751,49 @@ app.get('/api/files/download', authMiddleware, async (req, res) => { // 先获取文件信息(获取文件大小) const fileStats = await storage.stat(normalizedPath); const fileSize = Math.max(0, Number(fileStats?.size) || 0); + const isLocalStorage = (latestUser.current_storage_type || 'oss') === 'local'; + const byteRange = isLocalStorage ? parseSingleByteRange(req.headers.range, fileSize) : null; console.log('[下载] 文件: ' + fileName + ', 大小: ' + fileSize + ' 字节'); - if (!trafficState.isUnlimited && fileSize > trafficState.remaining) { + if (byteRange?.invalid) { + res.setHeader('Content-Range', `bytes */${fileSize}`); + await safeEndStorage(); + return res.status(416).end(); + } + + const responseSize = byteRange ? byteRange.length : fileSize; + if (!trafficState.isUnlimited && responseSize > trafficState.remaining) { await safeEndStorage(); return res.status(403).json({ success: false, - message: `下载流量不足:文件 ${formatFileSize(fileSize)},剩余 ${formatFileSize(trafficState.remaining)}` + message: `下载流量不足:本次需要 ${formatFileSize(responseSize)},剩余 ${formatFileSize(trafficState.remaining)}` }); } // 设置响应头(包含文件大小,浏览器可显示下载进度) res.setHeader('Content-Type', 'application/octet-stream'); - res.setHeader('Content-Length', fileSize); + res.setHeader('Content-Length', responseSize); + if (isLocalStorage) { + res.setHeader('Accept-Ranges', 'bytes'); + } + if (byteRange) { + res.status(206); + res.setHeader('Content-Range', `bytes ${byteRange.start}-${byteRange.end}/${fileSize}`); + } // 关闭 Nginx 代理缓冲,避免上游提前读完整文件导致流量计量失真 res.setHeader('X-Accel-Buffering', 'no'); - res.setHeader('Content-Disposition', 'attachment; filename="' + encodeURIComponent(fileName) + '"; filename*=UTF-8\'\'' + encodeURIComponent(fileName)); + const disposition = req.query?.mode === 'preview' ? 'inline' : 'attachment'; + res.setHeader('Content-Disposition', disposition + '; filename="' + encodeURIComponent(fileName) + '"; filename*=UTF-8\'\'' + encodeURIComponent(fileName)); if (typeof res.flushHeaders === 'function') { res.flushHeaders(); } responseBodyStartSocketBytes = Number(res.socket?.bytesWritten) || 0; // 创建文件流并传输(流式下载,服务器不保存临时文件) - const stream = await storage.createReadStream(normalizedPath); + const stream = await storage.createReadStream( + normalizedPath, + byteRange ? { start: byteRange.start, end: byteRange.end } : undefined + ); stream.on('data', (chunk) => { if (!chunk) return; @@ -8865,7 +9054,7 @@ app.post('/api/share/:code/download-url', shareRateLimitMiddleware, async (req, }); } - const normalizedFilePath = normalizeVirtualPath(filePath); + let normalizedFilePath = normalizeVirtualPath(filePath); if (!normalizedFilePath) { return res.status(400).json({ success: false, @@ -8901,6 +9090,12 @@ app.post('/api/share/:code/download-url', shareRateLimitMiddleware, async (req, } } + // Older cached share pages may request "/" for single-file shares. + // Map that back to the immutable share path before the strict scope check. + if (share.share_type === 'file' && normalizedFilePath === '/') { + normalizedFilePath = normalizeVirtualPath(share.share_path); + } + // 安全验证:检查请求路径是否在分享范围内 if (!isPathWithinShare(normalizedFilePath, share)) { return res.status(403).json({ @@ -8909,9 +9104,10 @@ app.post('/api/share/:code/download-url', shareRateLimitMiddleware, async (req, }); } + const isResumeRequest = /^bytes=[1-9]\d*-/i.test(String(req.headers.range || '').trim()); const accessPolicy = evaluateShareSecurityPolicy(share, req, { action: 'download', - enforceDownloadLimit: true + enforceDownloadLimit: !isResumeRequest }); if (!accessPolicy.allowed) { return res.status(403).json({ @@ -8934,20 +9130,21 @@ app.post('/api/share/:code/download-url', shareRateLimitMiddleware, async (req, const storageType = share.storage_type || 'oss'; const ownerTrafficState = getDownloadTrafficState(shareOwner); - if (storageType === 'oss') { - const securityResult = evaluateDownloadSecurityPolicy(req, { + const downloadSecuritySource = storageType === 'oss' + ? 'share_download_url_oss' + : 'share_download_url_local'; + const securityResult = evaluateDownloadSecurityPolicy(req, { + ownerUserId: shareOwner.id, + filePath: normalizedFilePath, + source: downloadSecuritySource + }); + if (!securityResult.allowed) { + return handleDownloadSecurityBlock(req, res, securityResult, { + statusCode: 503, ownerUserId: shareOwner.id, filePath: normalizedFilePath, - source: 'share_download_url_oss' + source: downloadSecuritySource }); - if (!securityResult.allowed) { - return handleDownloadSecurityBlock(req, res, securityResult, { - statusCode: 503, - ownerUserId: shareOwner.id, - filePath: normalizedFilePath, - source: 'share_download_url_oss' - }); - } } // 本地存储:继续走后端下载 @@ -8990,16 +9187,17 @@ app.post('/api/share/:code/download-url', shareRateLimitMiddleware, async (req, } } - let downloadUrl = `${getSecureBaseUrl(req)}/api/share/${code}/download-file?path=${encodeURIComponent(normalizedFilePath)}`; + const downloadToken = signEphemeralToken({ + type: 'share_download', + code, + path: normalizedFilePath + }, 15 * 60); + const downloadUrl = `${getSecureBaseUrl(req)}/api/share/${code}/download-file` + + `?path=${encodeURIComponent(normalizedFilePath)}` + + `&token=${encodeURIComponent(downloadToken)}`; - if (share.share_password) { - const downloadToken = signEphemeralToken({ - type: 'share_download', - code, - path: normalizedFilePath - }, 15 * 60); - downloadUrl += `&token=${encodeURIComponent(downloadToken)}`; - } + // Count one user-initiated download, not every browser probe or range request. + ShareDB.incrementDownloadCount(code); return res.json({ success: true, @@ -9105,7 +9303,7 @@ app.get('/api/share/:code/download-file', shareRateLimitMiddleware, async (req, const { code } = req.params; const rawFilePath = typeof req.query?.path === 'string' ? req.query.path : ''; const { password, token } = req.query; - const filePath = normalizeVirtualPath(rawFilePath); + let filePath = normalizeVirtualPath(rawFilePath); let storage; let storageEnded = false; // 防止重复关闭 let transferFinalized = false; // 防止重复结算 @@ -9181,21 +9379,24 @@ app.get('/api/share/:code/download-file', shareRateLimitMiddleware, async (req, }); } + // Older cached share pages may request "/" for single-file shares. + // Map that back to the immutable share path before validating the token. + if (share.share_type === 'file' && filePath === '/') { + filePath = normalizeVirtualPath(share.share_path); + } + + let verifiedByShareToken = false; + if (token) { + const tokenResult = verifyEphemeralToken(token, 'share_download'); + if (tokenResult.valid) { + const tokenPayload = tokenResult.payload || {}; + verifiedByShareToken = tokenPayload.code === code && tokenPayload.path === filePath; + } + } + // 验证密码(如果需要),支持短期下载 token(避免密码出现在 URL) if (share.share_password) { - let verifiedByToken = false; - - if (token) { - const tokenResult = verifyEphemeralToken(token, 'share_download'); - if (tokenResult.valid) { - const tokenPayload = tokenResult.payload || {}; - if (tokenPayload.code === code && tokenPayload.path === filePath) { - verifiedByToken = true; - } - } - } - - if (!verifiedByToken) { + if (!verifiedByShareToken) { if (!password || !ShareDB.verifyPassword(password, share.share_password)) { // 只在密码错误时记录失败 if (req.shareRateLimitKey) { @@ -9224,9 +9425,10 @@ app.get('/api/share/:code/download-file', shareRateLimitMiddleware, async (req, }); } + const isResumeRequest = /^bytes=[1-9]\d*-/i.test(String(req.headers.range || '').trim()); const accessPolicy = evaluateShareSecurityPolicy(share, req, { action: 'download', - enforceDownloadLimit: true + enforceDownloadLimit: !verifiedByShareToken && !isResumeRequest }); if (!accessPolicy.allowed) { return res.status(403).json({ @@ -9246,18 +9448,20 @@ app.get('/api/share/:code/download-file', shareRateLimitMiddleware, async (req, } shareOwnerId = shareOwner.id; - const securityResult = evaluateDownloadSecurityPolicy(req, { - ownerUserId: shareOwner.id, - filePath, - source: 'share_download_stream' - }); - if (!securityResult.allowed) { - return handleDownloadSecurityBlock(req, res, securityResult, { - statusCode: 503, + if (!verifiedByShareToken) { + const securityResult = evaluateDownloadSecurityPolicy(req, { ownerUserId: shareOwner.id, filePath, source: 'share_download_stream' }); + if (!securityResult.allowed) { + return handleDownloadSecurityBlock(req, res, securityResult, { + statusCode: 503, + ownerUserId: shareOwner.id, + filePath, + source: 'share_download_stream' + }); + } } const ownerTrafficState = getDownloadTrafficState(shareOwner); @@ -9283,22 +9487,36 @@ app.get('/api/share/:code/download-file', shareRateLimitMiddleware, async (req, // 获取文件信息(获取文件大小) const fileStats = await storage.stat(filePath); const fileSize = fileStats.size; + const isLocalStorage = storageType === 'local'; + const byteRange = isLocalStorage ? parseSingleByteRange(req.headers.range, fileSize) : null; console.log(`[分享下载] 文件: ${fileName}, 大小: ${fileSize} 字节`); - if (!ownerTrafficState.isUnlimited && fileSize > ownerTrafficState.remaining) { + if (byteRange?.invalid) { + res.setHeader('Content-Range', `bytes */${fileSize}`); + await safeEndStorage(); + return res.status(416).end(); + } + + const responseSize = byteRange ? byteRange.length : fileSize; + if (!ownerTrafficState.isUnlimited && responseSize > ownerTrafficState.remaining) { await safeEndStorage(); return res.status(403).json({ success: false, - message: `分享者下载流量不足:文件 ${formatFileSize(fileSize)},剩余 ${formatFileSize(ownerTrafficState.remaining)}` + message: `分享者下载流量不足:本次需要 ${formatFileSize(responseSize)},剩余 ${formatFileSize(ownerTrafficState.remaining)}` }); } // 增加下载次数 - ShareDB.incrementDownloadCount(code); + if (!verifiedByShareToken && !isResumeRequest) ShareDB.incrementDownloadCount(code); // 设置响应头(包含文件大小,浏览器可显示下载进度) res.setHeader('Content-Type', 'application/octet-stream'); - res.setHeader('Content-Length', fileSize); + res.setHeader('Content-Length', responseSize); + if (isLocalStorage) res.setHeader('Accept-Ranges', 'bytes'); + if (byteRange) { + res.status(206); + res.setHeader('Content-Range', `bytes ${byteRange.start}-${byteRange.end}/${fileSize}`); + } // 关闭 Nginx 代理缓冲,减少代理预读导致的计量偏差 res.setHeader('X-Accel-Buffering', 'no'); res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(fileName)}"; filename*=UTF-8''${encodeURIComponent(fileName)}`); @@ -9308,7 +9526,10 @@ app.get('/api/share/:code/download-file', shareRateLimitMiddleware, async (req, responseBodyStartSocketBytes = Number(res.socket?.bytesWritten) || 0; // 创建文件流并传输(流式下载,服务器不保存临时文件) - const stream = await storage.createReadStream(filePath); + const stream = await storage.createReadStream( + filePath, + byteRange ? { start: byteRange.start, end: byteRange.end } : undefined + ); stream.on('data', (chunk) => { if (!chunk) return; @@ -11466,6 +11687,7 @@ app.get('/d/:code', async (req, res) => { const fileStats = await storage.stat(normalizedPath); const fileSize = Number(fileStats?.size || 0); + const byteRange = parseSingleByteRange(req.headers.range, fileSize); if (fileStats?.isDirectory) { await safeEndStorage(); @@ -11477,15 +11699,27 @@ app.get('/d/:code', async (req, res) => { return sendPlainTextError(res, 404, '文件不存在'); } - if (!ownerTrafficState.isUnlimited && fileSize > ownerTrafficState.remaining) { + if (byteRange?.invalid) { + res.setHeader('Content-Range', `bytes */${fileSize}`); + await safeEndStorage(); + return res.status(416).end(); + } + + const responseSize = byteRange ? byteRange.length : fileSize; + if (!ownerTrafficState.isUnlimited && responseSize > ownerTrafficState.remaining) { await safeEndStorage(); return sendPlainTextError(res, 503, getBusyDownloadMessage()); } - DirectLinkDB.incrementDownloadCount(code); + if (!byteRange || byteRange.start === 0) DirectLinkDB.incrementDownloadCount(code); res.setHeader('Content-Type', 'application/octet-stream'); - res.setHeader('Content-Length', fileSize); + res.setHeader('Content-Length', responseSize); + res.setHeader('Accept-Ranges', 'bytes'); + if (byteRange) { + res.status(206); + res.setHeader('Content-Range', `bytes ${byteRange.start}-${byteRange.end}/${fileSize}`); + } res.setHeader('X-Accel-Buffering', 'no'); res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(directFileName)}"; filename*=UTF-8''${encodeURIComponent(directFileName)}`); if (typeof res.flushHeaders === 'function') { @@ -11493,7 +11727,10 @@ app.get('/d/:code', async (req, res) => { } responseBodyStartSocketBytes = Number(res.socket?.bytesWritten) || 0; - const stream = await storage.createReadStream(normalizedPath); + const stream = await storage.createReadStream( + normalizedPath, + byteRange ? { start: byteRange.start, end: byteRange.end } : undefined + ); stream.on('data', (chunk) => { if (!chunk) return; downloadedBytes += Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(chunk); diff --git a/backend/storage.js b/backend/storage.js index 6e7da5d..c441ad4 100644 --- a/backend/storage.js +++ b/backend/storage.js @@ -433,14 +433,14 @@ class LocalStorageClient { * @param {string} filePath - 文件路径 * @returns {ReadStream} 文件读取流 */ - createReadStream(filePath) { + createReadStream(filePath, options = undefined) { const fullPath = this.getFullPath(filePath); if (!fs.existsSync(fullPath)) { throw new Error(`文件不存在: ${filePath}`); } - return fs.createReadStream(fullPath); + return fs.createReadStream(fullPath, options); } /** diff --git a/backend/tests/full-audit-regression.js b/backend/tests/full-audit-regression.js index ce43271..4471c38 100644 --- a/backend/tests/full-audit-regression.js +++ b/backend/tests/full-audit-regression.js @@ -181,6 +181,8 @@ async function run() { let shareId = null; let shareCode = ''; let directLinkId = null; + let directLinkCode = ''; + let artifactDirName = AUDIT_PREFIX; test('public health/config/csrf endpoints are reachable', async () => { const health = await request(baseUrl, jar, 'GET', '/api/health'); @@ -288,12 +290,30 @@ async function run() { assert.strictEqual(check.data.success, true); const url = await request(baseUrl, jar, 'GET', `/api/files/download-url?path=/${encodeURIComponent(AUDIT_PREFIX)}/hello.txt&mode=download`); - assert.strictEqual(url.status, 400); - assert.match(url.data.message, /OSS/); + assert.strictEqual(url.status, 200); + assert.strictEqual(url.data.storageType, 'local'); + assert.match(url.data.downloadUrl, /\/api\/files\/download/); - const download = await request(baseUrl, jar, 'GET', `/api/files/download?path=/${encodeURIComponent(AUDIT_PREFIX)}/hello.txt`); + const tokenDownload = await request(baseUrl, null, 'GET', url.data.downloadUrl); + assert.strictEqual(tokenDownload.status, 200); + assert.ok(tokenDownload.raw.toString('utf8').includes(AUDIT_PREFIX)); + + const legacyDownload = await request(baseUrl, jar, 'GET', `/api/files/download?path=/${encodeURIComponent(AUDIT_PREFIX)}/hello.txt`); + assert.strictEqual(legacyDownload.status, 302); + const legacyLocation = legacyDownload.headers.get('location') || ''; + assert.match(legacyLocation, /\/api\/files\/download\?.*token=/); + + const download = await request(baseUrl, null, 'GET', legacyLocation); assert.strictEqual(download.status, 200); assert.ok(download.raw.toString('utf8').includes(AUDIT_PREFIX)); + + const partialUrl = new URL(url.data.downloadUrl); + const partial = await request(baseUrl, null, 'GET', partialUrl.toString(), { + headers: { Range: 'bytes=6-' } + }); + assert.strictEqual(partial.status, 206); + assert.match(partial.headers.get('content-range') || '', /^bytes 6-/); + assert.strictEqual(partial.raw.toString('utf8'), download.raw.subarray(6).toString('utf8')); }); test('share and direct-link flows preserve path boundaries', async () => { @@ -337,6 +357,29 @@ async function run() { }); assert.ok([400, 403, 404].includes(traversal.status)); + const shareUrl = await request(baseUrl, publicJar, 'POST', `/api/share/${shareCode}/download-url`, { + json: { + path: `/${AUDIT_PREFIX}/hello.txt`, + mode: 'download', + password: `${AUDIT_PREFIX}_pw` + } + }); + assert.strictEqual(shareUrl.status, 200); + assert.match(shareUrl.data.downloadUrl, /\/api\/share\/[^/]+\/download-file\?.*token=/); + + const shareDownload = await request(baseUrl, null, 'GET', shareUrl.data.downloadUrl); + assert.strictEqual(shareDownload.status, 200); + assert.ok(shareDownload.raw.toString('utf8').includes(AUDIT_PREFIX)); + + const sharePartial = await request(baseUrl, null, 'GET', shareUrl.data.downloadUrl, { + headers: { Range: 'bytes=6-' } + }); + assert.strictEqual(sharePartial.status, 206); + assert.strictEqual( + sharePartial.raw.toString('utf8'), + shareDownload.raw.subarray(6).toString('utf8') + ); + const direct = await request(baseUrl, jar, 'POST', '/api/direct-link/create', { json: { file_path: `/${AUDIT_PREFIX}/hello.txt`, @@ -347,6 +390,32 @@ async function run() { assert.strictEqual(direct.status, 200); assert.strictEqual(direct.data.success, true); directLinkId = direct.data.link_id; + directLinkCode = direct.data.link_code; + }); + + test('renaming a directory preserves shares and direct links', async () => { + const renamedDir = `${AUDIT_PREFIX}_renamed`; + const rename = await request(baseUrl, jar, 'POST', '/api/files/rename', { + json: { path: '/', oldName: AUDIT_PREFIX, newName: renamedDir } + }); + assert.strictEqual(rename.status, 200); + assert.strictEqual(rename.data.success, true); + artifactDirName = renamedDir; + + const shares = await request(baseUrl, jar, 'GET', '/api/share/my'); + const share = shares.data.shares.find(item => item.share_code === shareCode); + assert.strictEqual(share.share_path, `/${renamedDir}`); + + const publicJar = new CookieJar(); + const list = await request(baseUrl, publicJar, 'POST', `/api/share/${shareCode}/list`, { + json: { path: '', password: `${AUDIT_PREFIX}_pw` } + }); + assert.strictEqual(list.status, 200); + assert.ok(list.data.items.some(item => item.name === 'hello.txt')); + + const directDownload = await request(baseUrl, null, 'GET', `/d/${directLinkCode}`); + assert.strictEqual(directDownload.status, 200); + assert.ok(directDownload.raw.toString('utf8').includes(AUDIT_PREFIX)); }); test('admin listing/logging endpoints are authenticated and sanitized', async () => { @@ -376,7 +445,7 @@ async function run() { assert.ok([200, 404].includes(res.status)); } const del = await request(baseUrl, jar, 'POST', '/api/files/delete', { - json: { path: '/', fileName: AUDIT_PREFIX } + json: { path: '/', fileName: artifactDirName } }); assert.strictEqual(del.status, 200); assert.strictEqual(del.data.success, true); diff --git a/desktop-client/index.html b/desktop-client/index.html index 99f203f..70ed0d8 100644 --- a/desktop-client/index.html +++ b/desktop-client/index.html @@ -1,10 +1,10 @@ - + - - Tauri + Vue + Typescript App + + 玩玩云 Desktop diff --git a/desktop-client/package-lock.json b/desktop-client/package-lock.json index 0ff5126..0eea82a 100644 --- a/desktop-client/package-lock.json +++ b/desktop-client/package-lock.json @@ -1,13 +1,15 @@ { "name": "desktop-client", - "version": "0.1.31", + "version": "0.1.38", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "desktop-client", - "version": "0.1.31", + "version": "0.1.38", "dependencies": { + "@fontsource-variable/noto-sans-sc": "^5.3.0", + "@phosphor-icons/vue": "^2.2.1", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-opener": "^2", @@ -509,12 +511,33 @@ "node": ">=18" } }, + "node_modules/@fontsource-variable/noto-sans-sc": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/noto-sans-sc/-/noto-sans-sc-5.3.0.tgz", + "integrity": "sha512-lNar1dF7Ik/lHNPo/7JWG0TolXY29LtsqYgMvEysooZ5bsO9uH4shJmRrwyJ3PjyTPljhpMJEK0jDuLSU4vJ1w==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "license": "MIT" }, + "node_modules/@phosphor-icons/vue": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@phosphor-icons/vue/-/vue-2.2.1.tgz", + "integrity": "sha512-3RNg1utc2Z5RwPKWFkW3eXI/0BfQAwXgtFxPUPeSzi55jGYUq16b+UqcgbKLazWFlwg5R92OCLKjDiJjeiJcnA==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "vue": ">=3.2.39" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.61.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz", diff --git a/desktop-client/package.json b/desktop-client/package.json index c8c3084..e6cf2c6 100644 --- a/desktop-client/package.json +++ b/desktop-client/package.json @@ -1,7 +1,7 @@ { "name": "desktop-client", "private": true, - "version": "0.1.31", + "version": "0.1.38", "type": "module", "scripts": { "dev": "vite", @@ -10,6 +10,8 @@ "tauri": "tauri" }, "dependencies": { + "@fontsource-variable/noto-sans-sc": "^5.3.0", + "@phosphor-icons/vue": "^2.2.1", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-opener": "^2", diff --git a/desktop-client/src-tauri/Cargo.lock b/desktop-client/src-tauri/Cargo.lock index f44bb47..c1d778c 100644 --- a/desktop-client/src-tauri/Cargo.lock +++ b/desktop-client/src-tauri/Cargo.lock @@ -693,7 +693,7 @@ dependencies = [ [[package]] name = "desktop-client" -version = "0.1.31" +version = "0.1.38" dependencies = [ "reqwest 0.12.28", "rusqlite", diff --git a/desktop-client/src-tauri/Cargo.toml b/desktop-client/src-tauri/Cargo.toml index 55a16e8..3bc48c2 100644 --- a/desktop-client/src-tauri/Cargo.toml +++ b/desktop-client/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "desktop-client" -version = "0.1.31" +version = "0.1.38" description = "A Tauri App" authors = ["you"] edition = "2021" diff --git a/desktop-client/src-tauri/src/lib.rs b/desktop-client/src-tauri/src/lib.rs index c21d6fe..097375e 100644 --- a/desktop-client/src-tauri/src/lib.rs +++ b/desktop-client/src-tauri/src/lib.rs @@ -1,5 +1,5 @@ -use reqwest::{Method, Url}; use reqwest::StatusCode; +use reqwest::{Method, Url}; use rusqlite::{params, Connection}; use serde::Serialize; use serde_json::{Map, Value}; @@ -18,19 +18,15 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use tauri::Emitter; use tokio::time::sleep; +#[cfg(target_os = "windows")] +use windows_sys::Win32::Foundation::LocalFree; #[cfg(target_os = "windows")] use windows_sys::Win32::Security::Cryptography::{ CryptProtectData, CryptUnprotectData, CRYPTPROTECT_UI_FORBIDDEN, CRYPT_INTEGER_BLOB, }; -#[cfg(target_os = "windows")] -use windows_sys::Win32::Foundation::LocalFree; #[cfg(target_os = "windows")] const CREATE_NO_WINDOW: u32 = 0x08000000; -#[cfg(target_os = "windows")] -const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200; -#[cfg(target_os = "windows")] -const DETACHED_PROCESS: u32 = 0x00000008; const RESUMABLE_CHUNK_MAX_RETRIES: u32 = 3; const RESUMABLE_CHUNK_RETRY_BASE_DELAY_MS: u64 = 900; @@ -182,22 +178,6 @@ fn build_desktop_client_meta() -> (String, String, String) { (platform, device_name, device_id) } -fn build_upload_file_fingerprint(meta: &fs::Metadata) -> Option { - let size = meta.len(); - let modified_ms = meta - .modified() - .ok() - .and_then(|ts| ts.duration_since(UNIX_EPOCH).ok()) - .map(|duration| duration.as_millis()) - .unwrap_or(0); - let fingerprint = format!("v1:size:{}:mtime:{}", size, modified_ms); - if fingerprint.len() > 120 { - None - } else { - Some(fingerprint) - } -} - fn is_retryable_upload_status(status: u16) -> bool { matches!(status, 408 | 425 | 429 | 500 | 502 | 503 | 504) } @@ -208,7 +188,9 @@ fn is_retryable_transport_error(err: &reqwest::Error) -> bool { fn build_chunk_retry_delay(attempt: u32) -> Duration { let multiplier = 2_u64.saturating_pow(attempt.min(5)); - let ms = RESUMABLE_CHUNK_RETRY_BASE_DELAY_MS.saturating_mul(multiplier).min(15_000); + let ms = RESUMABLE_CHUNK_RETRY_BASE_DELAY_MS + .saturating_mul(multiplier) + .min(15_000); Duration::from_millis(ms) } @@ -263,7 +245,10 @@ fn dpapi_protect_bytes(input: &[u8]) -> Result, String> { ) }; if ok == 0 { - return Err(format!("加密登录状态失败: {}", std::io::Error::last_os_error())); + return Err(format!( + "加密登录状态失败: {}", + std::io::Error::last_os_error() + )); } let data = unsafe { @@ -302,7 +287,10 @@ fn dpapi_unprotect_bytes(input: &[u8]) -> Result, String> { ) }; if ok == 0 { - return Err(format!("解密登录状态失败: {}", std::io::Error::last_os_error())); + return Err(format!( + "解密登录状态失败: {}", + std::io::Error::last_os_error() + )); } let data = unsafe { @@ -341,8 +329,8 @@ fn decode_login_password(stored_password: &str) -> Result { while index < bytes.len() { let part = std::str::from_utf8(&bytes[index..index + 2]) .map_err(|_| "登录状态密文格式无效".to_string())?; - let value = u8::from_str_radix(part, 16) - .map_err(|_| "登录状态密文格式无效".to_string())?; + let value = + u8::from_str_radix(part, 16).map_err(|_| "登录状态密文格式无效".to_string())?; encrypted.push(value); index += 2; } @@ -377,7 +365,10 @@ fn sanitize_file_name(name: &str) -> String { let raw = name.trim(); let mut cleaned = String::with_capacity(raw.len()); for ch in raw.chars() { - if matches!(ch, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' | '\0') { + if matches!( + ch, + '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' | '\0' + ) { cleaned.push('_'); } else { cleaned.push(ch); @@ -435,11 +426,25 @@ fn alloc_download_path(download_dir: &Path, preferred_name: &str) -> PathBuf { first } -fn build_download_resume_temp_path(download_dir: &Path, preferred_name: &str, url: &str) -> PathBuf { +fn build_download_resume_temp_path( + download_dir: &Path, + preferred_name: &str, + url: &str, +) -> PathBuf { let mut hasher = std::collections::hash_map::DefaultHasher::new(); use std::hash::{Hash, Hasher}; preferred_name.hash(&mut hasher); - url.hash(&mut hasher); + let resume_identity = Url::parse(url) + .map(|parsed| { + let host = parsed.host_str().unwrap_or_default(); + let port = parsed + .port() + .map(|value| format!(":{}", value)) + .unwrap_or_default(); + format!("{}://{}{}{}", parsed.scheme(), host, port, parsed.path()) + }) + .unwrap_or_else(|_| url.to_string()); + resume_identity.hash(&mut hasher); let digest = format!("{:016x}", hasher.finish()); let safe_name = sanitize_file_name(preferred_name); let temp_name = format!(".{}.{}.part", safe_name, digest); @@ -504,7 +509,8 @@ fn cleanup_old_update_installers( ) -> Result<(), String> { let mut entries: Vec<(PathBuf, SystemTime)> = Vec::new(); let normalized_keep = keep_file_name.trim(); - for entry in fs::read_dir(download_dir).map_err(|err| format!("扫描下载目录失败: {}", err))? { + for entry in fs::read_dir(download_dir).map_err(|err| format!("扫描下载目录失败: {}", err))? + { let path = match entry { Ok(item) => item.path(), Err(_) => continue, @@ -544,6 +550,163 @@ fn cleanup_old_update_installers( Ok(()) } +#[cfg(target_os = "windows")] +fn powershell_single_quoted_path(path: &Path) -> String { + path.to_string_lossy().replace('\'', "''") +} + +#[cfg(target_os = "windows")] +fn build_windows_update_script( + installer: &Path, + app_exe: &Path, + app_pid: u32, + log_file: &Path, +) -> String { + let template = r#"$ErrorActionPreference = 'Continue' +$Installer = '__INSTALLER__' +$AppExe = '__APP_EXE__' +$AppPid = __APP_PID__ +$LogFile = '__LOG_FILE__' + +function Write-UpdateLog([string]$Message) { + Add-Content -LiteralPath $LogFile -Encoding UTF8 -Value ('[{0}] {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff'), $Message) +} + +Write-UpdateLog 'update script started' +if (-not (Test-Path -LiteralPath $Installer -PathType Leaf)) { + Write-UpdateLog ('installer not found: ' + $Installer) + exit 2 +} + +Start-Sleep -Milliseconds 700 +Stop-Process -Id $AppPid -Force -ErrorAction SilentlyContinue +for ($Attempt = 0; $Attempt -lt 20; $Attempt++) { + if (-not (Get-Process -Id $AppPid -ErrorAction SilentlyContinue)) { break } + Start-Sleep -Milliseconds 250 +} + +$InstallExit = 1 +try { + $InstallerProcess = Start-Process -FilePath $Installer -ArgumentList '/S' -WindowStyle Hidden -Wait -PassThru -ErrorAction Stop + $InstallExit = $InstallerProcess.ExitCode + Write-UpdateLog ('installer exit code: ' + $InstallExit) +} catch { + Write-UpdateLog ('installer failed: ' + $_.Exception.Message) +} + +$Candidates = @( + $AppExe, + (Join-Path $env:LOCALAPPDATA '玩玩云\desktop-client.exe') +) +$TargetApp = $null +for ($Attempt = 0; $Attempt -lt 40 -and -not $TargetApp; $Attempt++) { + foreach ($Candidate in $Candidates) { + if ($Candidate -and (Test-Path -LiteralPath $Candidate -PathType Leaf)) { + $TargetApp = $Candidate + break + } + } + if (-not $TargetApp) { Start-Sleep -Milliseconds 500 } +} + +if ($TargetApp) { + try { + Start-Process -FilePath $TargetApp -ErrorAction Stop + Write-UpdateLog ('application restarted: ' + $TargetApp) + } catch { + Write-UpdateLog ('application restart failed: ' + $_.Exception.Message) + } +} else { + Write-UpdateLog 'application executable not found after install' +} + +Start-Sleep -Milliseconds 300 +Remove-Item -LiteralPath $PSCommandPath -Force -ErrorAction SilentlyContinue +exit $InstallExit +"#; + + let script = template + .replace("__INSTALLER__", &powershell_single_quoted_path(installer)) + .replace("__APP_EXE__", &powershell_single_quoted_path(app_exe)) + .replace("__APP_PID__", &app_pid.to_string()) + .replace("__LOG_FILE__", &powershell_single_quoted_path(log_file)) + .replace('\n', "\r\n"); + format!("\u{feff}{}", script) +} + +#[cfg(target_os = "windows")] +fn spawn_windows_update_script( + script_path: &Path, + working_dir: &Path, +) -> std::io::Result { + Command::new("powershell.exe") + .arg("-NoLogo") + .arg("-NoProfile") + .arg("-NonInteractive") + .arg("-ExecutionPolicy") + .arg("Bypass") + .arg("-WindowStyle") + .arg("Hidden") + .arg("-File") + .arg(script_path) + .current_dir(working_dir) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .creation_flags(CREATE_NO_WINDOW) + .spawn() +} + +#[cfg(all(test, target_os = "windows"))] +mod windows_update_tests { + use super::*; + + #[test] + fn update_script_is_utf8_and_does_not_spawn_timeout_processes() { + let script = build_windows_update_script( + Path::new(r"C:\downloads\release's setup.exe"), + Path::new(r"C:\Users\tester\AppData\Local\玩玩云\desktop-client.exe"), + 4242, + Path::new(r"C:\Temp\silent-update.log"), + ); + + assert!(script.starts_with('\u{feff}')); + assert!(!script.to_ascii_lowercase().contains("timeout /t")); + assert!(script.contains("Start-Sleep")); + assert!(script.contains("-WindowStyle Hidden -Wait -PassThru")); + assert!(script.contains(r"$Installer = 'C:\downloads\release''s setup.exe'")); + assert!(script.contains(r"玩玩云\desktop-client.exe")); + } + + #[test] + fn updater_powershell_process_runs_hidden_script() { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default(); + let test_dir = env::temp_dir().join(format!("玩玩云-updater-test-{}", stamp)); + fs::create_dir_all(&test_dir).expect("create updater test directory"); + let script_path = test_dir.join("probe.ps1"); + let marker_path = test_dir.join("probe-ok.txt"); + let script = format!( + "\u{feff}Start-Sleep -Milliseconds 120\r\nSet-Content -LiteralPath '{}' -Value 'ok' -Encoding UTF8\r\n", + powershell_single_quoted_path(&marker_path) + ); + fs::write(&script_path, script.as_bytes()).expect("write updater probe script"); + + let mut child = spawn_windows_update_script(&script_path, &test_dir) + .expect("spawn hidden updater powershell"); + let status = child.wait().expect("wait for updater powershell"); + assert!(status.success(), "powershell exited with {status}"); + assert!( + marker_path.is_file(), + "updater script did not create marker" + ); + + let _ = fs::remove_dir_all(test_dir); + } +} + fn resolve_local_state_dir() -> PathBuf { if let Some(appdata) = env::var_os("APPDATA") { return PathBuf::from(appdata).join("wanwan-cloud-desktop"); @@ -558,7 +721,8 @@ fn open_local_state_db() -> Result { let state_dir = resolve_local_state_dir(); fs::create_dir_all(&state_dir).map_err(|err| format!("创建本地状态目录失败: {}", err))?; let db_path = state_dir.join("client_state.db"); - let conn = Connection::open(db_path).map_err(|err| format!("打开本地状态数据库失败: {}", err))?; + let conn = + Connection::open(db_path).map_err(|err| format!("打开本地状态数据库失败: {}", err))?; conn.execute( "CREATE TABLE IF NOT EXISTS login_state ( id INTEGER PRIMARY KEY CHECK (id = 1), @@ -608,16 +772,14 @@ fn load_login_state_record() -> Result, String> }); drop(stmt); match row { - Ok((base_url, username, password)) => { - match decode_login_password(&password) { - Ok(decoded_password) => Ok(Some((base_url, username, decoded_password))), - Err(err) => { - eprintln!("decode login state failed, clearing invalid state: {}", err); - let _ = conn.execute("DELETE FROM login_state WHERE id = 1", []); - Ok(None) - } + Ok((base_url, username, password)) => match decode_login_password(&password) { + Ok(decoded_password) => Ok(Some((base_url, username, decoded_password))), + Err(err) => { + eprintln!("decode login state failed, clearing invalid state: {}", err); + let _ = conn.execute("DELETE FROM login_state WHERE id = 1", []); + Ok(None) } - } + }, Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), Err(err) => Err(format!("读取登录状态失败: {}", err)), } @@ -694,7 +856,10 @@ async fn request_json( }) } -async fn fetch_csrf_token(client: &reqwest::Client, base_url: &str) -> Result, String> { +async fn fetch_csrf_token( + client: &reqwest::Client, + base_url: &str, +) -> Result, String> { let response = request_json( client, Method::GET, @@ -749,7 +914,10 @@ async fn api_login( let mut body = Map::new(); body.insert("username".to_string(), Value::String(username)); body.insert("password".to_string(), Value::String(password)); - body.insert("client_type".to_string(), Value::String("desktop".to_string())); + body.insert( + "client_type".to_string(), + Value::String("desktop".to_string()), + ); body.insert("platform".to_string(), Value::String(platform)); body.insert("device_name".to_string(), Value::String(device_name)); body.insert("device_id".to_string(), Value::String(device_id)); @@ -852,7 +1020,10 @@ fn api_save_login_state( let mut data = Map::new(); data.insert("success".to_string(), Value::Bool(true)); - data.insert("message".to_string(), Value::String("登录状态已保存".to_string())); + data.insert( + "message".to_string(), + Value::String("登录状态已保存".to_string()), + ); Ok(BridgeResponse { ok: true, status: 200, @@ -886,7 +1057,10 @@ fn api_clear_login_state() -> Result { clear_login_state_record()?; let mut data = Map::new(); data.insert("success".to_string(), Value::Bool(true)); - data.insert("message".to_string(), Value::String("登录状态已清除".to_string())); + data.insert( + "message".to_string(), + Value::String("登录状态已清除".to_string()), + ); Ok(BridgeResponse { ok: true, status: 200, @@ -1122,6 +1296,11 @@ async fn api_create_share( file_name: Option, password: Option, expiry_days: Option, + max_downloads: Option, + ip_whitelist: Option, + device_limit: Option, + access_time_start: Option, + access_time_end: Option, ) -> Result { let mut body = Map::new(); body.insert("share_type".to_string(), Value::String(share_type)); @@ -1129,7 +1308,10 @@ async fn api_create_share( if let Some(name) = file_name { if !name.trim().is_empty() { - body.insert("file_name".to_string(), Value::String(name.trim().to_string())); + body.insert( + "file_name".to_string(), + Value::String(name.trim().to_string()), + ); } } @@ -1152,6 +1334,52 @@ async fn api_create_share( body.insert("expiry_days".to_string(), Value::Null); } + if let Some(limit) = max_downloads { + if limit > 0 { + body.insert("max_downloads".to_string(), Value::Number(limit.into())); + } + } + + if let Some(value) = ip_whitelist { + let normalized = value.trim(); + if !normalized.is_empty() { + body.insert( + "ip_whitelist".to_string(), + Value::String(normalized.to_string()), + ); + } + } + + if let Some(value) = device_limit { + let normalized = value.trim(); + if !normalized.is_empty() { + body.insert( + "device_limit".to_string(), + Value::String(normalized.to_string()), + ); + } + } + + if let Some(value) = access_time_start { + let normalized = value.trim(); + if !normalized.is_empty() { + body.insert( + "access_time_start".to_string(), + Value::String(normalized.to_string()), + ); + } + } + + if let Some(value) = access_time_end { + let normalized = value.trim(); + if !normalized.is_empty() { + body.insert( + "access_time_end".to_string(), + Value::String(normalized.to_string()), + ); + } + } + request_with_optional_csrf( &state.client, Method::POST, @@ -1198,7 +1426,10 @@ async fn api_create_direct_link( if let Some(name) = file_name { if !name.trim().is_empty() { - body.insert("file_name".to_string(), Value::String(name.trim().to_string())); + body.insert( + "file_name".to_string(), + Value::String(name.trim().to_string()), + ); } } @@ -1245,11 +1476,11 @@ async fn api_native_download( let download_dir = resolve_download_dir(); if !download_dir.exists() { - fs::create_dir_all(&download_dir) - .map_err(|err| format!("创建下载目录失败: {}", err))?; + fs::create_dir_all(&download_dir).map_err(|err| format!("创建下载目录失败: {}", err))?; } - let resume_temp_path = build_download_resume_temp_path(&download_dir, preferred_name, &trimmed_url); + let resume_temp_path = + build_download_resume_temp_path(&download_dir, preferred_name, &trimmed_url); let existing_size = if resume_temp_path.exists() { fs::metadata(&resume_temp_path) .ok() @@ -1271,6 +1502,16 @@ async fn api_native_download( let status = response.status(); if status == reqwest::StatusCode::RANGE_NOT_SATISFIABLE && existing_size > 0 { + let remote_size = response + .headers() + .get(reqwest::header::CONTENT_RANGE) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("bytes */")) + .and_then(|value| value.parse::().ok()); + if remote_size != Some(existing_size) { + let _ = fs::remove_file(&resume_temp_path); + return Err("断点文件与远端文件不一致,已清理临时文件,请重试".to_string()); + } let save_path = alloc_download_path(&download_dir, preferred_name); fs::rename(&resume_temp_path, &save_path) .map_err(|err| format!("完成断点下载失败: {}", err))?; @@ -1416,7 +1657,11 @@ async fn api_native_download( ); data.insert( "resumedBytes".to_string(), - Value::Number(serde_json::Number::from(if append_mode { existing_size } else { 0 })), + Value::Number(serde_json::Number::from(if append_mode { + existing_size + } else { + 0 + })), ); Ok(BridgeResponse { @@ -1478,7 +1723,10 @@ fn api_launch_installer(installer_path: String) -> Result Result Result \"%LOG_FILE%\"\r\n\ -if not exist \"%INSTALLER%\" (\r\n\ - echo [%%date%% %%time%%] installer not found: %INSTALLER% >> \"%LOG_FILE%\"\r\n\ - exit /b 2\r\n\ -)\r\n\ -timeout /t 1 /nobreak >nul\r\n\ -taskkill /PID %APP_PID% /F >nul 2>nul\r\n\ -timeout /t 1 /nobreak >nul\r\n\ -start \"\" /wait \"%INSTALLER%\" /S\r\n\ -set \"INSTALL_EXIT=%ERRORLEVEL%\"\r\n\ -echo [%%date%% %%time%%] installer exit code: %INSTALL_EXIT% >> \"%LOG_FILE%\"\r\n\ -set \"RETRY_COUNT=0\"\r\n\ -:wait_for_app\r\n\ -if exist \"%APP_EXE%\" goto launch_app\r\n\ -if exist \"%LOCALAPPDATA%\\玩玩云\\desktop-client.exe\" (\r\n\ - set \"APP_EXE=%LOCALAPPDATA%\\玩玩云\\desktop-client.exe\"\r\n\ - goto launch_app\r\n\ -)\r\n\ -if %RETRY_COUNT% GEQ 25 goto app_missing\r\n\ -set /a RETRY_COUNT+=1\r\n\ -timeout /t 1 /nobreak >nul\r\n\ -goto wait_for_app\r\n\ -:launch_app\r\n\ -start \"\" \"%APP_EXE%\"\r\n\ -set \"START_EXIT=%ERRORLEVEL%\"\r\n\ -echo [%%date%% %%time%%] launch app exit code: %START_EXIT% path=%APP_EXE% >> \"%LOG_FILE%\"\r\n\ -goto cleanup\r\n\ -:app_missing\r\n\ -echo [%%date%% %%time%%] app exe not found after install >> \"%LOG_FILE%\"\r\n\ -:cleanup\r\n\ -del \"%~f0\" >nul 2>nul\r\n", - installer = installer_text, - app_exe = app_text, - app_pid = current_pid, - log_file = log_text - ); - fs::write(&script_path, script_content).map_err(|err| format!("写入更新脚本失败: {}", err))?; + fs::write(&log_path, bootstrap_content) + .map_err(|err| format!("写入更新日志失败: {}", err))?; + let script_content = + build_windows_update_script(&installer, ¤t_exe, current_pid, &log_path); + fs::write(&script_path, script_content.as_bytes()) + .map_err(|err| format!("写入更新脚本失败: {}", err))?; - let mut updater_cmd = Command::new("cmd"); - let spawn_result = updater_cmd - .arg("/D") - .arg("/C") - .arg("call") - .arg(&script_path) - .current_dir(&temp_dir) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .creation_flags(CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS) - .spawn(); - if let Err(err) = spawn_result { + let mut updater_child = + spawn_windows_update_script(&script_path, &temp_dir).map_err(|err| { + let _ = fs::OpenOptions::new() + .create(true) + .append(true) + .open(&log_path) + .and_then(|mut file| { + writeln!( + file, + "[bootstrap] failed to spawn updater powershell: {}", + err + )?; + Ok(()) + }); + format!("启动静默更新流程失败: {}", err) + })?; + let updater_pid = updater_child.id(); + + // A successful CreateProcess call does not prove that PowerShell parsed and + // started the script. Catch immediate startup failures before closing the app. + std::thread::sleep(Duration::from_millis(300)); + let early_exit = updater_child + .try_wait() + .map_err(|err| format!("检查静默更新进程失败: {}", err))?; + if let Some(status) = early_exit { let _ = fs::OpenOptions::new() .create(true) .append(true) .open(&log_path) .and_then(|mut file| { - writeln!(file, "[bootstrap] failed to spawn updater cmd: {}", err)?; + writeln!( + file, + "[bootstrap] updater powershell exited before handoff: {}", + status + )?; Ok(()) }); - return Err(format!("启动静默更新流程失败: {}", err)); + return Err(format!("静默更新进程启动后立即退出: {}", status)); } + let _ = fs::OpenOptions::new() + .create(true) + .append(true) + .open(&log_path) + .and_then(|mut file| { + writeln!( + file, + "[bootstrap] updater powershell running: pid={}", + updater_pid + )?; + Ok(()) + }); let mut cleanup_entries: Vec = fs::read_dir(&temp_dir) .ok() @@ -1801,12 +2036,13 @@ async fn api_upload_file_resumable( return Err("仅支持上传文件,不支持文件夹".to_string()); } - let metadata = fs::metadata(&source_path).map_err(|err| format!("读取文件信息失败: {}", err))?; + let metadata = + fs::metadata(&source_path).map_err(|err| format!("读取文件信息失败: {}", err))?; let file_size = metadata.len(); if file_size == 0 { return Err("空文件不支持分片上传".to_string()); } - let file_fingerprint = build_upload_file_fingerprint(&metadata); + let file_fingerprint = Some(format!("sha256:{}", compute_file_sha256_hex(&source_path)?)); let file_name = source_path .file_name() @@ -1818,7 +2054,9 @@ async fn api_upload_file_resumable( } else { target_path }; - let effective_chunk = chunk_size.unwrap_or(4 * 1024 * 1024).clamp(256 * 1024, 32 * 1024 * 1024); + let effective_chunk = chunk_size + .unwrap_or(4 * 1024 * 1024) + .clamp(256 * 1024, 32 * 1024 * 1024); let csrf_token = fetch_csrf_token(&state.client, &base_url).await?; let mut init_body = Map::new(); @@ -1845,7 +2083,13 @@ async fn api_upload_file_resumable( ) .await?; - if !init_resp.ok || !init_resp.data.get("success").and_then(Value::as_bool).unwrap_or(false) { + if !init_resp.ok + || !init_resp + .data + .get("success") + .and_then(Value::as_bool) + .unwrap_or(false) + { return Ok(init_resp); } @@ -1888,7 +2132,8 @@ async fn api_upload_file_resumable( emit_native_upload_progress(&window, id, uploaded_bytes, file_size, false); } - let mut source = fs::File::open(&source_path).map_err(|err| format!("打开文件失败: {}", err))?; + let mut source = + fs::File::open(&source_path).map_err(|err| format!("打开文件失败: {}", err))?; let mut last_emit = Instant::now(); for chunk_index in 0..total_chunks { if uploaded_chunks.contains(&chunk_index) { @@ -1973,7 +2218,9 @@ async fn api_upload_file_resumable( return Err("上传分片失败,请重试".to_string()); } - uploaded_bytes = uploaded_bytes.saturating_add(read_size as u64).min(file_size); + uploaded_bytes = uploaded_bytes + .saturating_add(read_size as u64) + .min(file_size); if let Some(ref id) = task_id { if last_emit.elapsed() >= Duration::from_millis(120) { emit_native_upload_progress(&window, id, uploaded_bytes, file_size, false); @@ -2000,7 +2247,13 @@ async fn api_upload_file_resumable( .map_err(|err| format!("完成分片上传失败: {}", err))?; let complete_resp = parse_response_as_bridge(complete_raw).await?; - if complete_resp.ok && complete_resp.data.get("success").and_then(Value::as_bool).unwrap_or(false) { + if complete_resp.ok + && complete_resp + .data + .get("success") + .and_then(Value::as_bool) + .unwrap_or(false) + { if let Some(ref id) = task_id { emit_native_upload_progress(&window, id, file_size, file_size, true); } @@ -2030,9 +2283,10 @@ async fn api_upload_file( if !source_path.is_file() { return Err("仅支持上传文件,不支持文件夹".to_string()); } - let file_meta = fs::metadata(&source_path).map_err(|err| format!("读取文件信息失败: {}", err))?; + let file_meta = + fs::metadata(&source_path).map_err(|err| format!("读取文件信息失败: {}", err))?; let file_size = file_meta.len(); - let file_fingerprint = build_upload_file_fingerprint(&file_meta); + let file_fingerprint = Some(format!("sha256:{}", compute_file_sha256_hex(&source_path)?)); let file_name = source_path .file_name() @@ -2093,7 +2347,11 @@ async fn api_upload_file( Ok(parsed) => parsed, Err(_) => fallback_json(status, &text), }; - let success = status.is_success() && data.get("success").and_then(Value::as_bool).unwrap_or(false); + let success = status.is_success() + && data + .get("success") + .and_then(Value::as_bool) + .unwrap_or(false); if success { if let Some(ref id) = task_id { emit_native_upload_progress(&window, id, file_size, file_size.max(1), true); diff --git a/desktop-client/src-tauri/tauri.conf.json b/desktop-client/src-tauri/tauri.conf.json index 7908239..8857a3d 100644 --- a/desktop-client/src-tauri/tauri.conf.json +++ b/desktop-client/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "玩玩云", - "version": "0.1.31", + "version": "0.1.38", "identifier": "cn.workyai.wanwancloud.desktop", "build": { "beforeDevCommand": "npm run dev", diff --git a/desktop-client/src/App.vue b/desktop-client/src/App.vue index f125665..b50af59 100644 --- a/desktop-client/src/App.vue +++ b/desktop-client/src/App.vue @@ -7,6 +7,45 @@ import { getVersion } from "@tauri-apps/api/app"; import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWebview } from "@tauri-apps/api/webview"; +import { + PhArrowClockwise, + PhArrowsClockwise, + PhArrowsDownUp, + PhCaretDown, + PhCaretRight, + PhCheck, + PhCloudArrowUp, + PhDownloadSimple, + PhDotsThreeVertical, + PhFile, + PhFileArchive, + PhFileAudio, + PhFileDoc, + PhFileImage, + PhFilePdf, + PhFilePpt, + PhFileText, + PhFileVideo, + PhFileXls, + PhFileZip, + PhFolderSimple, + PhFolderSimplePlus, + PhGearSix, + PhInfo, + PhMagnifyingGlass, + PhMinus, + PhPencilSimple, + PhPlus, + PhSelectionAll, + PhShareNetwork, + PhSignOut, + PhSortAscending, + PhSortDescending, + PhTrash, + PhUploadSimple, + PhX, +} from "@phosphor-icons/vue"; +import dogLogo from "../src-tauri/icons/wanwan-dog-source.png"; type NavKey = "files" | "transfers" | "shares" | "sync" | "settings"; @@ -18,6 +57,11 @@ type FileItem = { size?: number; sizeFormatted?: string; modifiedAt?: string; + createdAt?: string; + owner?: string; + updatedBy?: string; + tags?: string[]; + description?: string; isDirectory?: boolean; }; @@ -35,6 +79,35 @@ type ShareItem = { storage_type?: string; }; +type ShareExpiryType = "never" | "7" | "30" | "custom"; +type ShareDeviceLimit = "all" | "mobile" | "desktop"; + +type ShareCreateOptions = { + password: string | null; + expiryDays: number | null; + maxDownloads: number | null; + ipWhitelist: string | null; + deviceLimit: ShareDeviceLimit; + accessTimeStart: string | null; + accessTimeEnd: string | null; +}; + +type ShareCreateResult = { + itemName: string; + shareUrl: string; + shareCode: string; + expiresAt: string | null; + hasPassword: boolean; + password: string; + reused: boolean; + securityPolicy: Record | null; +}; + +type ShareCreateFailure = { + itemName: string; + message: string; +}; + type DirectLinkItem = { id: number; link_code: string; @@ -105,9 +178,16 @@ type TransferTask = { fileName?: string; }; +type RateMeasureState = { + lastMeasureAt: number; + lastMeasureBytes: number; +}; + const nav = ref("files"); const authenticated = ref(false); const user = ref | null>(null); +const uiPreviewMode = (import.meta.env.DEV || import.meta.env.VITE_UI_REVIEW === "true") + && new URLSearchParams(window.location.search).get("ui-preview") === "files"; const appConfig = reactive({ baseUrl: "https://cs.workyai.cn", @@ -133,15 +213,25 @@ const pathState = reactive({ }); const files = ref([]); -const selectedFileName = ref(""); +const selectedFileKey = ref(""); const searchKeyword = ref(""); const shares = ref([]); const directLinks = ref([]); const directLinksLoading = ref(false); -const batchMode = ref(false); -const batchSelectedNames = ref([]); +const batchSelectedKeys = ref([]); +const batchMode = computed(() => batchSelectedKeys.value.length > 0); const transferTasks = ref([]); +const unreadTransferTaskIds = ref([]); +const unreadTransferCount = computed(() => unreadTransferTaskIds.value.length); +const activeTransferTaskIds = computed(() => transferTasks.value + .filter((task) => task.status === "queued" || isTaskRunning(task.status)) + .map((task) => task.id)); +const transferBadgeCount = computed(() => new Set([ + ...activeTransferTaskIds.value, + ...unreadTransferTaskIds.value, +]).size); +const downloadRateSamples = new Map(); const sharesLoading = ref(false); const transferQueue = reactive({ paused: false, @@ -166,7 +256,7 @@ const syncState = reactive({ nextRunAt: "", }); const updateState = reactive({ - currentVersion: "0.1.31", + currentVersion: "0.1.38", latestVersion: "", available: false, mandatory: false, @@ -207,6 +297,28 @@ const contextMenu = reactive({ y: 0, item: null as FileItem | null, }); +const shareCreateDialog = reactive({ + visible: false, + loading: false, + submitted: false, + processedCount: 0, + items: [] as FileItem[], + enablePassword: false, + password: "", + expiryType: "never" as ShareExpiryType, + customDays: 7, + enableAdvancedSecurity: false, + maxDownloadsEnabled: false, + maxDownloads: 10, + ipWhitelist: "", + deviceLimit: "all" as ShareDeviceLimit, + accessTimeEnabled: false, + accessTimeStart: "09:00", + accessTimeEnd: "23:00", + error: "", + results: [] as ShareCreateResult[], + failures: [] as ShareCreateFailure[], +}); const shareDeleteDialog = reactive({ visible: false, loading: false, @@ -229,6 +341,7 @@ const operationConfirmDialog = reactive({ }); const inlineRename = reactive({ active: false, + itemKey: "", originalName: "", value: "", saving: false, @@ -264,15 +377,53 @@ const toast = reactive({ message: "", }); let toastTimer: ReturnType | null = null; +let fileViewRequestId = 0; const navItems = computed(() => [ - { key: "files" as const, label: "全部文件", hint: `${files.value.length} 项`, icon: "M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" }, - { key: "transfers" as const, label: "传输列表", hint: `${transferTasks.value.length} 个任务`, icon: "M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" }, - { key: "shares" as const, label: "我的分享", hint: `${shares.value.length + directLinks.value.length} 条`, icon: "M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" }, - { key: "sync" as const, label: "同步盘", hint: syncState.localDir ? "已配置" : "未配置", icon: "M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" }, - { key: "settings" as const, label: "设置", hint: updateState.available ? "发现新版本" : "系统与更新", icon: "M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z" }, + { key: "files" as const, label: "全部文件", hint: `${files.value.length} 项`, icon: PhFolderSimple }, + { + key: "transfers" as const, + label: "传输列表", + hint: unreadTransferCount.value > 0 + ? `${unreadTransferCount.value} 个未读结果` + : (activeTransferTaskIds.value.length > 0 + ? `${activeTransferTaskIds.value.length} 个任务进行中` + : `${transferTasks.value.length} 个任务`), + icon: PhArrowsDownUp, + }, + { key: "shares" as const, label: "我的分享", hint: `${shares.value.length + directLinks.value.length} 条`, icon: PhShareNetwork }, + { key: "sync" as const, label: "同步盘", hint: syncState.localDir ? "已配置" : "未配置", icon: PhArrowsClockwise }, + { key: "settings" as const, label: "设置", hint: updateState.available ? "发现新版本" : "系统与更新", icon: PhGearSix }, ]); +const currentPageTitle = computed(() => { + const labels: Record = { + files: "全部文件", + transfers: "传输列表", + shares: "我的分享", + sync: "同步盘", + settings: "设置", + }; + return labels[nav.value]; +}); + +const currentStorage = computed(() => { + const isLocal = user.value?.current_storage_type === "local"; + const used = Number(isLocal ? user.value?.local_storage_used : user.value?.storage_used) || 0; + const quota = Number(isLocal ? user.value?.local_storage_quota : user.value?.oss_storage_quota) || 0; + return { used: Math.max(0, used), quota: Math.max(0, quota) }; +}); + +const storageUsagePercent = computed(() => { + if (currentStorage.value.quota <= 0) return 0; + return Math.min(100, Math.max(0, (currentStorage.value.used / currentStorage.value.quota) * 100)); +}); + +const storageUsageLabel = computed(() => { + const { used, quota } = currentStorage.value; + return quota > 0 ? `已用 ${formatBytes(used)} / ${formatBytes(quota)}` : `已用 ${formatBytes(used)}`; +}); + const sortedShares = computed(() => { return [...shares.value].sort((a, b) => { const ta = new Date(a.created_at || 0).getTime(); @@ -313,6 +464,10 @@ const filteredFiles = computed(() => { const orderFactor = fileViewState.sortOrder === "asc" ? 1 : -1; const sorted = [...filtered].sort((a, b) => { + const aIsDirectory = Boolean(a.isDirectory || a.type === "directory"); + const bIsDirectory = Boolean(b.isDirectory || b.type === "directory"); + if (aIsDirectory !== bIsDirectory) return aIsDirectory ? -1 : 1; + const sortBy = fileViewState.sortBy; if (sortBy === "name") { const av = String(a.displayName || a.name || ""); @@ -367,13 +522,35 @@ const toolbarCrumbs = computed(() => { }); const selectedFile = computed(() => { - if (!selectedFileName.value) return null; - return files.value.find((item) => item.name === selectedFileName.value) || null; + if (!selectedFileKey.value) return null; + return files.value.find((item) => fileSelectionKey(item) === selectedFileKey.value) || null; }); const batchSelectedItems = computed(() => { - const selectedSet = new Set(batchSelectedNames.value); - return files.value.filter((item) => selectedSet.has(item.name)); + const selectedSet = new Set(batchSelectedKeys.value); + return files.value.filter((item) => selectedSet.has(fileSelectionKey(item))); +}); + +const shareDialogIsBatch = computed(() => shareCreateDialog.items.length > 1); +const shareDialogReusedCount = computed(() => shareCreateDialog.results.filter((item) => item.reused).length); +const shareDialogTargetName = computed(() => { + if (shareDialogIsBatch.value) return `已选择 ${shareCreateDialog.items.length} 项`; + const item = shareCreateDialog.items[0]; + return item?.displayName || item?.name || "未选择项目"; +}); +const shareDialogTargetPath = computed(() => { + const item = shareCreateDialog.items[0]; + return item ? buildItemPath(item) : ""; +}); + +const areAllVisibleFilesSelected = computed(() => { + return filteredFiles.value.length > 0 + && filteredFiles.value.every((item) => batchSelectedKeys.value.includes(fileSelectionKey(item))); +}); + +const areSomeVisibleFilesSelected = computed(() => { + if (filteredFiles.value.length === 0 || areAllVisibleFilesSelected.value) return false; + return filteredFiles.value.some((item) => batchSelectedKeys.value.includes(fileSelectionKey(item))); }); const fileStats = computed(() => { @@ -399,6 +576,11 @@ function mapApiItem(raw: Record): FileItem { size: Number(raw?.size || 0), sizeFormatted: raw?.sizeFormatted || raw?.size_formatted || undefined, modifiedAt: raw?.modifiedAt || raw?.modified_at || raw?.modifyTime || raw?.modifiedTime || raw?.updatedAt || undefined, + createdAt: raw?.createdAt || raw?.created_at || undefined, + owner: raw?.owner || raw?.creator_name || raw?.created_by || undefined, + updatedBy: raw?.updatedBy || raw?.updated_by || raw?.modifier_name || undefined, + tags: Array.isArray(raw?.tags) ? raw.tags.map(String) : undefined, + description: typeof raw?.description === "string" ? raw.description : undefined, isDirectory, }; } @@ -451,7 +633,13 @@ function formatBytes(value: number | undefined) { function fileTypeLabel(item: FileItem) { if (item.isDirectory || item.type === "directory") return "文件夹"; - return "文件"; + const ext = getFileExt(item); + return ext ? `${ext.toUpperCase()} 文件` : "文件"; +} + +function fileSelectionKey(item: FileItem) { + if (item.path && item.path.trim()) return normalizePath(item.path); + return normalizePath(`${pathState.currentPath}/${item.name}`); } function formatSpeed(bytesPerSecond: number) { @@ -461,7 +649,7 @@ function formatSpeed(bytesPerSecond: number) { function measureRate( currentBytes: number, - state: { lastMeasureAt: number; lastMeasureBytes: number }, + state: RateMeasureState, ) { const now = Date.now(); if (!state.lastMeasureAt) { @@ -476,11 +664,22 @@ function measureRate( const deltaBytes = Math.max(0, currentBytes - state.lastMeasureBytes); state.lastMeasureAt = now; state.lastMeasureBytes = currentBytes; - if (deltaBytes <= 0) return "0 B/s"; + // Progress events can repeat the same byte offset. Keep the last valid rate + // instead of briefly flashing a misleading 0 B/s value. + if (deltaBytes <= 0) return "-"; const bytesPerSecond = (deltaBytes * 1000) / elapsedMs; return formatSpeed(bytesPerSecond); } +function measureDownloadTaskRate(taskId: string, currentBytes: number) { + let state = downloadRateSamples.get(taskId); + if (!state) { + state = { lastMeasureAt: 0, lastMeasureBytes: 0 }; + downloadRateSamples.set(taskId, state); + } + return measureRate(currentBytes, state); +} + function resetUpdateRuntime() { updateRuntime.downloading = false; updateRuntime.installing = false; @@ -501,7 +700,10 @@ function applyNativeDownloadProgress(payload: NativeDownloadProgressEvent) { const downloadedBytes = Number(payload?.downloadedBytes || 0); const totalBytesRaw = payload?.totalBytes; const totalBytes = totalBytesRaw === null || totalBytesRaw === undefined ? NaN : Number(totalBytesRaw); - const eventProgress = Number(payload?.progress); + const progressRaw = payload?.progress; + const eventProgress = progressRaw === null || progressRaw === undefined + ? NaN + : Number(progressRaw); const calculatedProgress = Number.isFinite(eventProgress) ? eventProgress : (Number.isFinite(totalBytes) && totalBytes > 0 ? (downloadedBytes / totalBytes) * 100 : NaN); @@ -526,8 +728,11 @@ function applyNativeDownloadProgress(payload: NativeDownloadProgressEvent) { return; } + const currentTask = transferTasks.value.find((task) => task.id === taskId); + const sampledSpeed = measureDownloadTaskRate(taskId, downloadedBytes); + const previousSpeed = currentTask?.speed?.endsWith("/s") ? currentTask.speed : "下载中"; const patch: Partial = { - speed: "下载中", + speed: sampledSpeed === "-" ? previousSpeed : sampledSpeed, status: "downloading", note: Number.isFinite(totalBytes) && totalBytes > 0 ? `${formatBytes(downloadedBytes)} / ${formatBytes(totalBytes)}` @@ -536,6 +741,10 @@ function applyNativeDownloadProgress(payload: NativeDownloadProgressEvent) { if (Number.isFinite(boundedProgress)) { patch.progress = Number(boundedProgress.toFixed(1)); } + if (payload?.done) { + patch.speed = "-"; + downloadRateSamples.delete(taskId); + } updateTransferTask(taskId, patch); } @@ -606,18 +815,20 @@ function fileExtLabel(item: FileItem) { return ext.slice(0, 4); } -function fileIconSvg(kind: string): string { - const icons: Record = { - folder: "M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z", - image: "M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z", - video: "M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z", - audio: "M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2z", - archive: "M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4", - document: "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z M9 13h6 M9 17h4", - app: "M14 10l-2 1m0 0l-2-1m2 1v2.5M20 7l-2 1m2-1l-2-1m2 1v2.5M14 4l-2-1-2 1M4 7l2-1M4 7l2 1M4 7v2.5M12 21l-2-1m2 1l2-1m-2 1v-2.5M6 18l-2-1v-2.5M18 18l2-1v-2.5", - file: "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z", - }; - return icons[kind] || icons.file; +function fileIconComponent(item: FileItem) { + if (item.isDirectory || item.type === "directory") return PhFolderSimple; + const ext = getFileExt(item); + if (ext === "pdf") return PhFilePdf; + if (["doc", "docx"].includes(ext)) return PhFileDoc; + if (["xls", "xlsx", "csv"].includes(ext)) return PhFileXls; + if (["ppt", "pptx"].includes(ext)) return PhFilePpt; + if (["zip", "rar", "7z"].includes(ext)) return PhFileZip; + if (["tar", "gz", "bz2", "xz"].includes(ext)) return PhFileArchive; + if (["jpg", "jpeg", "png", "webp", "gif", "bmp", "svg", "heic"].includes(ext)) return PhFileImage; + if (["mp4", "mkv", "mov", "avi", "webm", "flv"].includes(ext)) return PhFileVideo; + if (["mp3", "wav", "flac", "aac", "ogg", "m4a"].includes(ext)) return PhFileAudio; + if (["txt", "md"].includes(ext)) return PhFileText; + return PhFile; } function matchFileTypeFilter(item: FileItem, type: string) { @@ -682,15 +893,57 @@ function showToast(message: string, type = "info") { }, 2500); } +function isTerminalTransferStatus(status: TransferTaskStatus | undefined) { + return status === "done" || status === "failed"; +} + +function clearUnreadTransferTask(taskId: string) { + unreadTransferTaskIds.value = unreadTransferTaskIds.value.filter((id) => id !== taskId); +} + +function markTransferTaskUnread(taskId: string) { + if (nav.value === "transfers" || unreadTransferTaskIds.value.includes(taskId)) return; + unreadTransferTaskIds.value = [...unreadTransferTaskIds.value, taskId]; +} + +function acknowledgeTransferResults() { + unreadTransferTaskIds.value = []; +} + +function pruneUnreadTransferTasks() { + const retainedIds = new Set(transferTasks.value.map((task) => task.id)); + unreadTransferTaskIds.value = unreadTransferTaskIds.value.filter((id) => retainedIds.has(id)); +} + function prependTransferTask(task: TransferTask) { transferTasks.value = [task, ...transferTasks.value.slice(0, 119)]; + pruneUnreadTransferTasks(); + if (isTerminalTransferStatus(task.status)) { + markTransferTaskUnread(task.id); + } } function updateTransferTask( id: string, patch: Partial, ) { - transferTasks.value = transferTasks.value.map((task) => (task.id === id ? { ...task, ...patch } : task)); + let previousStatus: TransferTaskStatus | undefined; + let nextStatus: TransferTaskStatus | undefined; + transferTasks.value = transferTasks.value.map((task) => { + if (task.id !== id) return task; + previousStatus = task.status; + const updated = { ...task, ...patch }; + nextStatus = updated.status; + return updated; + }); + + if (!isTerminalTransferStatus(previousStatus) && isTerminalTransferStatus(nextStatus)) { + markTransferTaskUnread(id); + return; + } + if (isTerminalTransferStatus(previousStatus) && !isTerminalTransferStatus(nextStatus)) { + clearUnreadTransferTask(id); + } } function getTaskStatusLabel(status: string) { @@ -708,11 +961,17 @@ function isTaskRunning(status: string) { } function removeTransferTask(taskId: string) { + downloadRateSamples.delete(taskId); transferTasks.value = transferTasks.value.filter((task) => task.id !== taskId); + clearUnreadTransferTask(taskId); } function clearCompletedTransferTasks() { + transferTasks.value + .filter((task) => task.status === "done" || task.status === "failed") + .forEach((task) => downloadRateSamples.delete(task.id)); transferTasks.value = transferTasks.value.filter((task) => task.status !== "done" && task.status !== "failed"); + pruneUnreadTransferTasks(); } function toggleTransferQueuePause() { @@ -726,27 +985,51 @@ async function waitForTransferQueue() { } } -function isBatchSelected(name: string) { - return batchSelectedNames.value.includes(name); +function isBatchSelected(item: FileItem) { + return batchSelectedKeys.value.includes(fileSelectionKey(item)); } -function toggleBatchMode() { - batchMode.value = !batchMode.value; - batchSelectedNames.value = []; +function isFocusedFile(item: FileItem) { + return selectedFileKey.value === fileSelectionKey(item); } function clearBatchSelection() { - batchSelectedNames.value = []; + batchSelectedKeys.value = []; +} + +function clearFocusedFile() { + selectedFileKey.value = ""; +} + +function clearFileInteractionState() { + clearFocusedFile(); + clearBatchSelection(); } function toggleBatchSelection(item: FileItem) { - const name = item.name; - if (!name) return; - if (isBatchSelected(name)) { - batchSelectedNames.value = batchSelectedNames.value.filter((v) => v !== name); + const key = fileSelectionKey(item); + if (!key) return; + if (isBatchSelected(item)) { + batchSelectedKeys.value = batchSelectedKeys.value.filter((value) => value !== key); return; } - batchSelectedNames.value = [...batchSelectedNames.value, name]; + clearFocusedFile(); + batchSelectedKeys.value = [...batchSelectedKeys.value, key]; +} + +function toggleRowSelection(item: FileItem) { + toggleBatchSelection(item); +} + +function toggleSelectAllVisible() { + const visibleKeys = filteredFiles.value.map(fileSelectionKey).filter(Boolean); + if (areAllVisibleFilesSelected.value) { + const visibleSet = new Set(visibleKeys); + batchSelectedKeys.value = batchSelectedKeys.value.filter((key) => !visibleSet.has(key)); + return; + } + clearFocusedFile(); + batchSelectedKeys.value = [...new Set([...batchSelectedKeys.value, ...visibleKeys])]; } function normalizeRelativePath(rawPath: string) { @@ -1118,16 +1401,21 @@ async function installLatestUpdate(): Promise { const win = getCurrentWindow(); void (async () => { try { - await win.close(); + await win.destroy(); } catch { try { - await win.destroy(); + await win.close(); } catch { // ignore } } })(); }, 400); + setTimeout(() => { + if (!updateRuntime.installing) return; + resetUpdateRuntime(); + showToast("更新程序未能接管,请重新更新或手动运行安装包", "error"); + }, 8000); return true; } if (logFilePath) { @@ -1559,7 +1847,9 @@ async function loadProfile() { } async function loadFiles(targetPath = pathState.currentPath) { + const requestId = ++fileViewRequestId; cancelInlineRename(true); + clearFileInteractionState(); pathState.loading = true; pathState.error = ""; const normalizedPath = normalizePath(targetPath); @@ -1567,12 +1857,11 @@ async function loadFiles(targetPath = pathState.currentPath) { baseUrl: appConfig.baseUrl, path: normalizedPath, }); + if (requestId !== fileViewRequestId) return; if (response.ok && response.data?.success) { files.value = Array.isArray(response.data.items) ? response.data.items.map((item: Record) => mapApiItem(item)) : []; pathState.currentPath = normalizePath(response.data.path || normalizedPath); pathState.mode = "directory"; - selectedFileName.value = files.value[0]?.name || ""; - batchSelectedNames.value = []; } else { pathState.error = response.data?.message || "读取文件列表失败"; showToast(pathState.error, "error"); @@ -1640,32 +1929,273 @@ async function getSignedUrlForItem(item: FileItem, mode: "download" | "preview") return ""; } -async function createShareForItem(current: FileItem, silent = false) { - const response = await invokeBridge("api_create_share", { +function openShareCreateDialog(items: FileItem[]) { + const targets = items.filter(Boolean); + if (targets.length === 0) { + showToast("请先选择要分享的文件或文件夹", "info"); + return; + } + + Object.assign(shareCreateDialog, { + visible: true, + loading: false, + submitted: false, + processedCount: 0, + items: [...targets], + enablePassword: false, + password: "", + expiryType: "never" as ShareExpiryType, + customDays: 7, + enableAdvancedSecurity: false, + maxDownloadsEnabled: false, + maxDownloads: 10, + ipWhitelist: "", + deviceLimit: "all" as ShareDeviceLimit, + accessTimeEnabled: false, + accessTimeStart: "09:00", + accessTimeEnd: "23:00", + error: "", + results: [], + failures: [], + }); +} + +function closeShareCreateDialog(force = false) { + if (shareCreateDialog.loading && !force) return; + shareCreateDialog.visible = false; + shareCreateDialog.loading = false; + shareCreateDialog.submitted = false; + shareCreateDialog.processedCount = 0; + shareCreateDialog.items = []; + shareCreateDialog.error = ""; + shareCreateDialog.results = []; + shareCreateDialog.failures = []; +} + +function createShareForItem(current: FileItem) { + openShareCreateDialog([current]); +} + +function validateShareCreateOptions(): ShareCreateOptions | null { + shareCreateDialog.error = ""; + + const password = shareCreateDialog.enablePassword ? shareCreateDialog.password.trim() : null; + if (shareCreateDialog.enablePassword && !password) { + shareCreateDialog.error = "已启用密码保护,请输入访问密码"; + return null; + } + if (password && password.length > 32) { + shareCreateDialog.error = "访问密码不能超过 32 个字符"; + return null; + } + + let expiryDays: number | null = null; + if (shareCreateDialog.expiryType !== "never") { + const rawDays = shareCreateDialog.expiryType === "custom" + ? Number(shareCreateDialog.customDays) + : Number(shareCreateDialog.expiryType); + if (!Number.isInteger(rawDays) || rawDays < 1 || rawDays > 365) { + shareCreateDialog.error = "有效期必须是 1 到 365 天的整数"; + return null; + } + expiryDays = rawDays; + } + + let maxDownloads: number | null = null; + let ipWhitelist: string | null = null; + let deviceLimit: ShareDeviceLimit = "all"; + let accessTimeStart: string | null = null; + let accessTimeEnd: string | null = null; + + if (shareCreateDialog.enableAdvancedSecurity) { + if (shareCreateDialog.maxDownloadsEnabled) { + const value = Number(shareCreateDialog.maxDownloads); + if (!Number.isInteger(value) || value < 1 || value > 1_000_000) { + shareCreateDialog.error = "下载次数上限需为 1 到 1000000 的整数"; + return null; + } + maxDownloads = value; + } + + ipWhitelist = shareCreateDialog.ipWhitelist.trim() || null; + if (!["all", "mobile", "desktop"].includes(shareCreateDialog.deviceLimit)) { + shareCreateDialog.error = "设备限制参数无效"; + return null; + } + deviceLimit = shareCreateDialog.deviceLimit; + + if (shareCreateDialog.accessTimeEnabled) { + const start = shareCreateDialog.accessTimeStart.trim(); + const end = shareCreateDialog.accessTimeEnd.trim(); + const timePattern = /^([01]\d|2[0-3]):([0-5]\d)$/; + if (!timePattern.test(start) || !timePattern.test(end)) { + shareCreateDialog.error = "访问时段格式必须为 HH:mm"; + return null; + } + accessTimeStart = start; + accessTimeEnd = end; + } + } + + return { + password, + expiryDays, + maxDownloads, + ipWhitelist, + deviceLimit, + accessTimeStart, + accessTimeEnd, + }; +} + +async function requestShareCreation(current: FileItem, options: ShareCreateOptions): Promise { + if (uiPreviewMode) { + await new Promise((resolve) => setTimeout(resolve, 180)); + const shareCode = Math.random().toString(36).slice(2, 8).toUpperCase(); + return { + ok: true, + status: 200, + data: { + success: true, + reused: false, + share_code: shareCode, + share_url: `${appConfig.baseUrl}/s/${shareCode}`, + expires_at: options.expiryDays + ? new Date(Date.now() + options.expiryDays * 86_400_000).toISOString() + : null, + has_password: Boolean(options.password), + security_policy: { + max_downloads: options.maxDownloads, + ip_whitelist_count: options.ipWhitelist ? options.ipWhitelist.split(/[\s,]+/).filter(Boolean).length : 0, + device_limit: options.deviceLimit, + access_time_start: options.accessTimeStart, + access_time_end: options.accessTimeEnd, + }, + }, + }; + } + + return invokeBridge("api_create_share", { baseUrl: appConfig.baseUrl, shareType: current.isDirectory || current.type === "directory" ? "directory" : "file", filePath: buildItemPath(current), fileName: current.displayName || current.name, - password: null, - expiryDays: null, + password: options.password, + expiryDays: options.expiryDays, + maxDownloads: options.maxDownloads, + ipWhitelist: options.ipWhitelist, + deviceLimit: options.deviceLimit, + accessTimeStart: options.accessTimeStart, + accessTimeEnd: options.accessTimeEnd, }); +} - if (response.ok && response.data?.success) { - if (!silent) { - showToast("分享创建成功", "success"); +async function submitShareCreateDialog() { + if (shareCreateDialog.loading) return; + const options = validateShareCreateOptions(); + if (!options) return; + + const targets = [...shareCreateDialog.items]; + if (targets.length === 0) { + shareCreateDialog.error = "没有可分享的项目"; + return; + } + + shareCreateDialog.loading = true; + shareCreateDialog.submitted = false; + shareCreateDialog.processedCount = 0; + shareCreateDialog.results = []; + shareCreateDialog.failures = []; + + const results: ShareCreateResult[] = []; + const failures: ShareCreateFailure[] = []; + for (const current of targets) { + const itemName = current.displayName || current.name || "未命名项目"; + try { + const response = await requestShareCreation(current, options); + if (!response.ok || !response.data?.success) { + throw new Error(String(response.data?.message || "创建分享失败")); + } + + results.push({ + itemName, + shareUrl: String(response.data.share_url || ""), + shareCode: String(response.data.share_code || ""), + expiresAt: response.data.expires_at ? String(response.data.expires_at) : null, + hasPassword: Boolean(response.data.has_password), + password: response.data.reused ? "" : (options.password || ""), + reused: Boolean(response.data.reused), + securityPolicy: response.data.security_policy && typeof response.data.security_policy === "object" + ? response.data.security_policy + : null, + }); + } catch (error) { + failures.push({ + itemName, + message: error instanceof Error ? error.message : String(error), + }); + } finally { + shareCreateDialog.processedCount += 1; } - await loadShares(true); - const shareUrl = String(response.data.share_url || ""); - if (!silent && shareUrl) { - await copyText(shareUrl, "分享链接已复制"); + } + + shareCreateDialog.results = results; + shareCreateDialog.failures = failures; + shareCreateDialog.submitted = true; + shareCreateDialog.loading = false; + + if (results.length > 0) { + if (!uiPreviewMode) await loadShares(true); + if (targets.length > 1) clearBatchSelection(); + if (targets.length === 1 && results[0].reused) { + showToast("已复用现有分享,原安全设置保持不变", "info"); + } else { + const failedText = failures.length > 0 ? `,失败 ${failures.length} 项` : ""; + showToast(`分享创建完成:成功 ${results.length} 项${failedText}`, failures.length > 0 ? "info" : "success"); } return; } - if (!silent) { - showToast(response.data?.message || "创建分享失败", "error"); + showToast(failures[0]?.message || "创建分享失败", "error"); +} + +function returnToShareSettings() { + if (shareCreateDialog.loading) return; + shareCreateDialog.submitted = false; + shareCreateDialog.processedCount = 0; + shareCreateDialog.error = ""; + shareCreateDialog.results = []; + shareCreateDialog.failures = []; +} + +function getShareDeviceLabel(value: unknown) { + if (value === "mobile") return "仅移动端"; + if (value === "desktop") return "仅桌面端"; + return "全部设备"; +} + +async function copyCreatedShareLink(result: ShareCreateResult) { + if (!result.shareUrl) { + showToast("分享链接为空", "error"); + return; + } + await copyText(result.shareUrl, "分享链接已复制"); +} + +async function openCreatedShareLink(result: ShareCreateResult) { + if (!result.shareUrl) { + showToast("分享链接为空", "error"); + return; + } + if (uiPreviewMode) { + window.open(result.shareUrl, "_blank", "noopener,noreferrer"); + return; + } + try { + await openUrl(result.shareUrl); + } catch { + showToast("打开分享链接失败", "error"); } - throw new Error(String(response.data?.message || "创建分享失败")); } async function createDirectLinkForItem(current: FileItem, silent = false) { @@ -1873,10 +2403,18 @@ function getItemParentPath(item: FileItem) { async function runGlobalSearch() { const keyword = searchKeyword.value.trim(); + if (uiPreviewMode) { + pathState.mode = keyword ? "search" : "directory"; + clearFileInteractionState(); + if (keyword) showToast(`已筛选与“${keyword}”相关的文件`, "success"); + return; + } if (!keyword) { await loadFiles(pathState.currentPath); return; } + const requestId = ++fileViewRequestId; + clearFileInteractionState(); pathState.loading = true; pathState.error = ""; const response = await invokeBridge("api_search_files", { @@ -1886,11 +2424,11 @@ async function runGlobalSearch() { searchType: "all", limit: 200, }); + if (requestId !== fileViewRequestId) return; if (response.ok && response.data?.success) { files.value = Array.isArray(response.data.items) ? response.data.items.map((item: Record) => mapApiItem(item)) : []; pathState.mode = "search"; - selectedFileName.value = files.value[0]?.name || ""; showToast(`搜索完成,共 ${files.value.length} 条结果`, "success"); } else { pathState.error = response.data?.message || "搜索失败"; @@ -1899,6 +2437,24 @@ async function runGlobalSearch() { pathState.loading = false; } +async function resetFileSearch() { + searchKeyword.value = ""; + pathState.mode = "directory"; + if (uiPreviewMode) { + clearFileInteractionState(); + return; + } + await loadFiles(pathState.currentPath); +} + +async function refreshCurrentFiles() { + if (uiPreviewMode) { + showToast("文件列表已刷新", "success"); + return; + } + await loadFiles(pathState.currentPath); +} + async function handleLogin() { if (loginState.loading) return; loginState.loading = true; @@ -1946,9 +2502,7 @@ async function handleLogout() { authenticated.value = false; user.value = null; files.value = []; - selectedFileName.value = ""; - batchMode.value = false; - batchSelectedNames.value = []; + clearFileInteractionState(); loginForm.password = ""; nav.value = "files"; syncState.localDir = ""; @@ -1999,7 +2553,7 @@ async function createFolder() { function isInlineRenaming(item: FileItem | null | undefined) { if (!item) return false; - return inlineRename.active && inlineRename.originalName === item.name; + return inlineRename.active && inlineRename.itemKey === fileSelectionKey(item); } function focusInlineRenameInput() { @@ -2014,6 +2568,7 @@ function focusInlineRenameInput() { function cancelInlineRename(force = false) { if (inlineRename.saving && !force) return; inlineRename.active = false; + inlineRename.itemKey = ""; inlineRename.originalName = ""; inlineRename.value = ""; inlineRename.saving = false; @@ -2025,8 +2580,9 @@ function startInlineRename(target?: FileItem | null) { showToast("请先选中文件或文件夹", "info"); return; } - selectedFileName.value = current.name; + selectedFileKey.value = fileSelectionKey(current); inlineRename.active = true; + inlineRename.itemKey = fileSelectionKey(current); inlineRename.originalName = current.name; inlineRename.value = current.displayName || current.name; inlineRename.saving = false; @@ -2034,7 +2590,7 @@ function startInlineRename(target?: FileItem | null) { } async function submitInlineRename(target?: FileItem | null) { - const current = target || files.value.find((item) => item.name === inlineRename.originalName) || null; + const current = target || files.value.find((item) => fileSelectionKey(item) === inlineRename.itemKey) || null; if (!current || !inlineRename.active || inlineRename.saving) return; const nextName = String(inlineRename.value || "").trim(); @@ -2057,10 +2613,11 @@ async function submitInlineRename(target?: FileItem | null) { inlineRename.saving = false; if (response.ok && response.data?.success) { + const renamedPath = normalizePath(`${getItemParentPath(current)}/${nextName}`); showToast("重命名成功", "success"); cancelInlineRename(true); await loadFiles(pathState.currentPath); - selectedFileName.value = nextName; + selectedFileKey.value = renamedPath; return; } @@ -2110,6 +2667,7 @@ async function downloadSelected(target?: FileItem | null) { if (!signedUrl) return; const taskId = `D-${Date.now()}`; + downloadRateSamples.delete(taskId); prependTransferTask({ id: taskId, kind: "download", @@ -2131,6 +2689,7 @@ async function downloadSelected(target?: FileItem | null) { }); if (nativeResponse.ok && nativeResponse.data?.success) { + downloadRateSamples.delete(taskId); const resumedBytes = Number(nativeResponse.data?.resumedBytes || 0); const resumeText = resumedBytes > 0 ? `,已续传 ${formatBytes(resumedBytes)}` : ""; updateTransferTask(taskId, { speed: "-", progress: 100, status: "done", note: `下载成功${resumeText}` }); @@ -2140,6 +2699,7 @@ async function downloadSelected(target?: FileItem | null) { } const message = String(nativeResponse.data?.message || "原生下载失败"); + downloadRateSamples.delete(taskId); updateTransferTask(taskId, { speed: "-", progress: 0, status: "failed", note: message }); showToast(message, "error"); } @@ -2149,7 +2709,7 @@ function selectFile(item: FileItem) { toggleBatchSelection(item); return; } - selectedFileName.value = item.name; + selectedFileKey.value = fileSelectionKey(item); } function handleFileCardClick(item: FileItem) { @@ -2267,6 +2827,7 @@ async function retryTransferTask(taskId: string) { return; } await waitForTransferQueue(); + downloadRateSamples.delete(taskId); updateTransferTask(taskId, { status: "downloading", speed: "重试下载", progress: 10, note: "正在重试" }); const response = await invokeBridge("api_native_download", { url: task.downloadUrl, @@ -2274,6 +2835,7 @@ async function retryTransferTask(taskId: string) { taskId, }); if (response.ok && response.data?.success) { + downloadRateSamples.delete(taskId); const resumedBytes = Number(response.data?.resumedBytes || 0); const resumeText = resumedBytes > 0 ? `,已续传 ${formatBytes(resumedBytes)}` : ""; updateTransferTask(taskId, { status: "done", speed: "-", progress: 100, note: `下载成功${resumeText}` }); @@ -2285,6 +2847,7 @@ async function retryTransferTask(taskId: string) { progress: 0, note: String(response.data?.message || "重试下载失败"), }); + downloadRateSamples.delete(taskId); } async function batchDeleteSelected() { @@ -2344,26 +2907,12 @@ async function confirmOperationDialog() { } } -async function batchShareSelected() { +function batchShareSelected() { if (!batchMode.value || batchSelectedItems.value.length === 0) { showToast("请先勾选批量文件", "info"); return; } - - let success = 0; - let failed = 0; - const items = [...batchSelectedItems.value]; - for (const item of items) { - try { - await createShareForItem(item, true); - success += 1; - } catch { - failed += 1; - } - } - await loadShares(true); - const failedText = failed > 0 ? `,失败 ${failed} 个` : ""; - showToast(`批量分享完成:成功 ${success} 个${failedText}`, failed > 0 ? "info" : "success"); + openShareCreateDialog([...batchSelectedItems.value]); } async function batchDirectLinkSelected() { @@ -2384,6 +2933,7 @@ async function batchDirectLinkSelected() { } } await loadShares(true); + clearBatchSelection(); const failedText = failed > 0 ? `,失败 ${failed} 个` : ""; showToast(`批量直链完成:成功 ${success} 个${failedText}`, failed > 0 ? "info" : "success"); } @@ -2394,6 +2944,10 @@ function handleGlobalClick() { function handleGlobalKey(event: KeyboardEvent) { if (event.key === "Escape") { + if (shareCreateDialog.visible) { + closeShareCreateDialog(); + return; + } if (operationConfirmDialog.visible) { closeOperationConfirmDialog(); return; @@ -2404,7 +2958,13 @@ function handleGlobalKey(event: KeyboardEvent) { } if (contextMenu.visible) { closeContextMenu(); + return; } + if (batchMode.value) { + clearBatchSelection(); + return; + } + if (selectedFile.value) clearFocusedFile(); return; } @@ -2440,6 +3000,24 @@ function canUseDragUpload() { return authenticated.value && nav.value === "files"; } +async function chooseUploadFiles() { + if (uiPreviewMode) { + showToast("桌面客户端中可选择并上传多个文件", "info"); + return; + } + try { + const result = await openDialog({ + directory: false, + multiple: true, + title: "选择要上传的文件", + }); + const paths = Array.isArray(result) ? result : (typeof result === "string" ? [result] : []); + await uploadDroppedFiles(paths); + } catch { + showToast("选择文件失败", "error"); + } +} + async function uploadDroppedFiles(paths: string[]) { const uniquePaths = [...new Set((paths || []).map((item) => String(item || "").trim()).filter(Boolean))]; if (uniquePaths.length === 0) { @@ -2580,9 +3158,18 @@ async function registerNativeUploadProgressListener() { } watch(nav, async (next) => { + if (next === "transfers") { + acknowledgeTransferResults(); + } if (next !== "files" && inlineRename.active) { cancelInlineRename(true); } + if (next !== "files") { + fileViewRequestId += 1; + pathState.loading = false; + clearFileInteractionState(); + } + if (uiPreviewMode) return; if (next === "shares" && authenticated.value) { await loadShares(); await loadDirectLinks(); @@ -2594,6 +3181,11 @@ watch(nav, async (next) => { } }); +watch( + () => [fileViewState.filter, searchKeyword.value], + () => clearFileInteractionState(), +); + watch( () => [syncState.localDir, syncState.remoteBasePath, syncState.autoEnabled, syncState.intervalMinutes, authenticated.value], () => { @@ -2607,10 +3199,60 @@ watch( }, ); +function applyUiPreviewData() { + authenticated.value = true; + nav.value = "files"; + user.value = { + id: 1, + username: "张小明", + current_storage_type: "local", + local_storage_used: 42.36 * 1024 * 1024 * 1024, + local_storage_quota: 200 * 1024 * 1024 * 1024, + }; + pathState.currentPath = "/项目资料"; + pathState.mode = "directory"; + files.value = [ + { name: "产品资料", displayName: "产品资料", type: "directory", isDirectory: true, modifiedAt: "2026-07-20 18:32" }, + { name: "项目归档", displayName: "项目归档", type: "directory", isDirectory: true, modifiedAt: "2026-07-15 10:21" }, + { name: "设计素材", displayName: "设计素材", type: "directory", isDirectory: true, modifiedAt: "2026-07-10 09:44" }, + { + name: "需求评审.pdf", + displayName: "需求评审.pdf", + type: "file", + size: 2.45 * 1024 * 1024, + modifiedAt: "2026-07-21 14:28", + createdAt: "2026-07-21 14:28", + owner: "张小明", + updatedBy: "张小明", + tags: ["项目评审"], + }, + { name: "功能清单.docx", displayName: "功能清单.docx", type: "file", size: 1.28 * 1024 * 1024, modifiedAt: "2026-07-19 16:03" }, + { name: "项目排期表.xlsx", displayName: "项目排期表.xlsx", type: "file", size: 98.64 * 1024, modifiedAt: "2026-07-18 11:22" }, + { name: "客户交付.zip", displayName: "客户交付.zip", type: "file", size: 156.35 * 1024 * 1024, modifiedAt: "2026-07-16 17:45" }, + { name: "使用说明.txt", displayName: "使用说明.txt", type: "file", size: 3.21 * 1024, modifiedAt: "2026-07-12 08:53" }, + ]; + const previewTransferId = "D-PREVIEW-001"; + transferTasks.value = [{ + id: previewTransferId, + kind: "download", + name: "需求评审.pdf", + speed: "-", + progress: 100, + status: "done", + note: "下载成功", + }]; + unreadTransferTaskIds.value = [previewTransferId]; + clearFileInteractionState(); +} + onMounted(async () => { window.addEventListener("click", handleGlobalClick); window.addEventListener("keydown", handleGlobalKey); window.addEventListener("contextmenu", handleGlobalContextMenu); + if (uiPreviewMode) { + applyUiPreviewData(); + return; + } await registerDragDropListener(); await registerNativeDownloadProgressListener(); await registerNativeUploadProgressListener(); @@ -2645,7 +3287,10 @@ onBeforeUnmount(() => {