feat: unify cloud workspace and release desktop v0.1.38
This commit is contained in:
8
.gitignore
vendored
8
.gitignore
vendored
@@ -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
|
||||
|
||||
16
backend/package-lock.json
generated
16
backend/package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Tauri + Vue + Typescript App</title>
|
||||
<meta name="theme-color" content="#f7f9fc" />
|
||||
<title>玩玩云 Desktop</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
27
desktop-client/package-lock.json
generated
27
desktop-client/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
2
desktop-client/src-tauri/Cargo.lock
generated
2
desktop-client/src-tauri/Cargo.lock
generated
@@ -693,7 +693,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "desktop-client"
|
||||
version = "0.1.31"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"reqwest 0.12.28",
|
||||
"rusqlite",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "desktop-client"
|
||||
version = "0.1.31"
|
||||
version = "0.1.38"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -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<String> {
|
||||
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<Vec<u8>, 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<Vec<u8>, 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<String, String> {
|
||||
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<std::process::Child> {
|
||||
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<Connection, String> {
|
||||
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<Option<(String, String, String)>, 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<Option<String>, String> {
|
||||
async fn fetch_csrf_token(
|
||||
client: &reqwest::Client,
|
||||
base_url: &str,
|
||||
) -> Result<Option<String>, 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<BridgeResponse, String> {
|
||||
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<String>,
|
||||
password: Option<String>,
|
||||
expiry_days: Option<i32>,
|
||||
max_downloads: Option<u64>,
|
||||
ip_whitelist: Option<String>,
|
||||
device_limit: Option<String>,
|
||||
access_time_start: Option<String>,
|
||||
access_time_end: Option<String>,
|
||||
) -> Result<BridgeResponse, String> {
|
||||
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::<u64>().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<BridgeResponse, String
|
||||
|
||||
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()),
|
||||
);
|
||||
data.insert("installerPath".to_string(), Value::String(path_text));
|
||||
|
||||
Ok(BridgeResponse {
|
||||
@@ -1500,7 +1748,8 @@ fn api_silent_install_and_restart(installer_path: String) -> Result<BridgeRespon
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let current_exe = env::current_exe().map_err(|err| format!("获取当前程序路径失败: {}", err))?;
|
||||
let current_exe =
|
||||
env::current_exe().map_err(|err| format!("获取当前程序路径失败: {}", err))?;
|
||||
let current_pid = std::process::id();
|
||||
let temp_dir = env::temp_dir().join("wanwan-cloud-desktop");
|
||||
fs::create_dir_all(&temp_dir).map_err(|err| format!("创建更新脚本目录失败: {}", err))?;
|
||||
@@ -1508,89 +1757,75 @@ fn api_silent_install_and_restart(installer_path: String) -> Result<BridgeRespon
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis())
|
||||
.unwrap_or_default();
|
||||
let script_path = temp_dir.join(format!("silent-update-{}.cmd", script_stamp));
|
||||
let script_path = temp_dir.join(format!("silent-update-{}.ps1", script_stamp));
|
||||
let log_path = temp_dir.join(format!("silent-update-{}.log", script_stamp));
|
||||
windows_log_file_path = log_path.to_string_lossy().to_string();
|
||||
windows_script_file_path = script_path.to_string_lossy().to_string();
|
||||
|
||||
let installer_text = installer.to_string_lossy().replace('"', "\"\"");
|
||||
let app_text = current_exe.to_string_lossy().replace('"', "\"\"");
|
||||
let log_text = log_path.to_string_lossy().replace('"', "\"\"");
|
||||
let bootstrap_content = format!(
|
||||
"[bootstrap] silent updater prepared\r\npid={}\r\ninstaller={}\r\nscript={}\r\n",
|
||||
current_pid,
|
||||
installer.to_string_lossy(),
|
||||
script_path.to_string_lossy()
|
||||
);
|
||||
fs::write(&log_path, bootstrap_content).map_err(|err| format!("写入更新日志失败: {}", err))?;
|
||||
let script_content = format!(
|
||||
"@echo off\r\n\
|
||||
setlocal enableextensions\r\n\
|
||||
set \"INSTALLER={installer}\"\r\n\
|
||||
set \"APP_EXE={app_exe}\"\r\n\
|
||||
set \"APP_PID={app_pid}\"\r\n\
|
||||
set \"LOG_FILE={log_file}\"\r\n\
|
||||
echo [%%date%% %%time%%] update script started > \"%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<PathBuf> = 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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
import { createApp } from "vue";
|
||||
import "@fontsource-variable/noto-sans-sc/wght.css";
|
||||
import App from "./App.vue";
|
||||
|
||||
createApp(App).mount("#app");
|
||||
|
||||
@@ -1806,9 +1806,14 @@
|
||||
|
||||
<div class="navbar" v-if="isLoggedIn">
|
||||
<div class="navbar-brand">
|
||||
<i class="fas fa-cloud"></i> 玩玩云
|
||||
<span class="brand-mark"><i class="fas fa-cloud"></i></span>
|
||||
<span class="brand-copy">
|
||||
<strong>玩玩云</strong>
|
||||
<small>Cloud Workspace</small>
|
||||
</span>
|
||||
</div>
|
||||
<div class="navbar-menu">
|
||||
<div class="nav-section-label">工作台</div>
|
||||
<div v-if="user && !user.is_admin" class="nav-item" :class="{active: currentView === 'files'}" @click="switchView('files')">
|
||||
<i class="fas fa-folder"></i> 我的文件
|
||||
</div>
|
||||
@@ -1818,21 +1823,26 @@
|
||||
<div v-if="user && user.is_admin" class="nav-item" :class="{active: currentView === 'admin'}" @click="switchView('admin')">
|
||||
<i class="fas fa-user-shield"></i> 管理员
|
||||
</div>
|
||||
<div class="nav-section-label nav-service-label">服务</div>
|
||||
<div class="nav-item" :class="{active: currentView === 'settings'}" @click="switchView('settings')">
|
||||
|
||||
<i class="fas fa-cog"></i> 设置
|
||||
</div>
|
||||
<button class="btn btn-secondary navbar-download-btn" :disabled="desktopClientDownloading" @click="downloadDesktopClient">
|
||||
<i :class="desktopClientDownloading ? 'fas fa-spinner fa-spin' : 'fas fa-download'"></i>
|
||||
{{ desktopClientDownloading ? '准备中...' : '下载客户端' }}
|
||||
</button>
|
||||
<div class="user-info">
|
||||
<i class="fas fa-user-circle"></i>
|
||||
<span>{{ user.username }}</span>
|
||||
<div class="navbar-account">
|
||||
<div class="user-info">
|
||||
<span class="user-avatar"><i class="fas fa-user"></i></span>
|
||||
<span class="user-copy">
|
||||
<small>当前账号</small>
|
||||
<strong>{{ user.username }}</strong>
|
||||
</span>
|
||||
</div>
|
||||
<button class="btn btn-danger navbar-logout-btn" @click="logout" title="退出登录">
|
||||
<i class="fas fa-power-off"></i> 退出
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn btn-danger" @click="logout">
|
||||
<i class="fas fa-power-off"></i> 退出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1840,6 +1850,34 @@
|
||||
<div v-if="isLoggedIn && currentView === 'files'" class="main-container">
|
||||
<div class="card files-view-card">
|
||||
|
||||
<header class="workspace-page-header files-page-header">
|
||||
<div class="workspace-page-heading">
|
||||
<span class="workspace-kicker"><i class="fas fa-layer-group"></i> 云端空间</span>
|
||||
<h1>我的文件</h1>
|
||||
<p>集中管理、查找和分享你的全部文件。</p>
|
||||
</div>
|
||||
<div class="storage-meter-card">
|
||||
<div class="storage-meter-head">
|
||||
<span class="files-storage-badge" :class="storageType === 'local' ? 'local' : 'oss'">
|
||||
<i :class="storageType === 'local' ? 'fas fa-hard-drive' : 'fas fa-server'"></i>
|
||||
{{ storageTypeText }}
|
||||
</span>
|
||||
<strong v-if="storageType === 'local'">{{ localUsedFormatted }} / {{ localQuotaFormatted }}</strong>
|
||||
<strong v-else>{{ ossUsedFormatted }} / {{ ossQuotaFormatted }}</strong>
|
||||
</div>
|
||||
<div class="storage-meter-track">
|
||||
<span :style="{
|
||||
width: (storageType === 'local' ? quotaPercentage : ossQuotaPercentage) + '%',
|
||||
background: (storageType === 'local' ? quotaPercentage : ossQuotaPercentage) > 90 ? '#ef4444' : (storageType === 'local' ? quotaPercentage : ossQuotaPercentage) > 75 ? '#f59e0b' : '#2878ff'
|
||||
}"></span>
|
||||
</div>
|
||||
<div class="storage-meter-foot">
|
||||
<span>{{ storageType === 'local' ? quotaPercentage : ossQuotaPercentage }}% 已使用</span>
|
||||
<span>{{ fileStats.totalCount }} 个项目</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 路径导航 (面包屑) -->
|
||||
<div v-if="currentPath !== '/'" class="breadcrumb-bar">
|
||||
<button class="btn-icon breadcrumb-home" @click="loadFiles('/')" title="返回根目录">
|
||||
@@ -1872,105 +1910,96 @@
|
||||
|
||||
<!-- 文件列表 -->
|
||||
<div v-else class="files-content-shell">
|
||||
<div class="files-content-head files-content-head-compact files-content-head-actions-row">
|
||||
<span class="files-content-title"><i class="fas fa-folder-tree"></i> 文件视图 · {{ fileStats.totalCount }} 项</span>
|
||||
<div class="files-content-head-meta">
|
||||
<span class="files-storage-badge files-head-storage-badge" :class="storageType === 'local' ? 'local' : 'oss'">
|
||||
<i :class="storageType === 'local' ? 'fas fa-hard-drive' : 'fas fa-server'"></i>
|
||||
{{ storageTypeText }}
|
||||
</span>
|
||||
<div class="files-head-usage-progress" :title="storageType === 'local' ? `本地:${localUsedFormatted} / ${localQuotaFormatted} (${quotaPercentage}%)` : `OSS:${ossUsedFormatted} / ${ossQuotaFormatted} (${ossQuotaPercentage}%)`">
|
||||
<div class="files-head-usage-progress-bar" :style="{
|
||||
width: (storageType === 'local' ? quotaPercentage : ossQuotaPercentage) + '%',
|
||||
background: (storageType === 'local' ? quotaPercentage : ossQuotaPercentage) > 90 ? '#ef4444' : (storageType === 'local' ? quotaPercentage : ossQuotaPercentage) > 75 ? '#f59e0b' : '#22c55e'
|
||||
}"></div>
|
||||
<span class="files-head-usage-progress-text" v-if="storageType === 'local'">
|
||||
本地 {{ localUsedFormatted }} / {{ localQuotaFormatted }} · {{ quotaPercentage }}%
|
||||
</span>
|
||||
<span class="files-head-usage-progress-text" v-else>
|
||||
OSS {{ ossUsedFormatted }} / {{ ossQuotaFormatted }} · {{ ossQuotaPercentage }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="files-content-head-actions">
|
||||
<button class="btn btn-primary files-head-action-btn" @click="$refs.fileUploadInput.click()">
|
||||
<div class="files-command-bar">
|
||||
<div class="files-command-primary">
|
||||
<button class="btn btn-primary" @click="$refs.fileUploadInput.click()">
|
||||
<i class="fas fa-upload"></i> 上传文件
|
||||
</button>
|
||||
<button class="btn btn-secondary files-head-action-btn files-head-folder-btn" @click="showCreateFolderModal = true">
|
||||
<button class="btn btn-secondary" @click="showCreateFolderModal = true">
|
||||
<i class="fas fa-folder-plus"></i> 新建文件夹
|
||||
</button>
|
||||
<div class="view-toggle-group files-head-view-toggle">
|
||||
<button class="btn" :class="fileViewMode === 'grid' ? 'btn-primary' : 'btn-secondary'" @click="fileViewMode = 'grid'">
|
||||
<i class="fas fa-th-large"></i> 大图标
|
||||
</div>
|
||||
<div class="view-toggle-group files-view-toggle" aria-label="文件视图切换">
|
||||
<button class="btn" :class="fileViewMode === 'grid' ? 'btn-primary' : 'btn-secondary'" @click="fileViewMode = 'grid'" title="大图标视图">
|
||||
<i class="fas fa-th-large"></i><span>大图标</span>
|
||||
</button>
|
||||
<button class="btn" :class="fileViewMode === 'list' ? 'btn-primary' : 'btn-secondary'" @click="fileViewMode = 'list'">
|
||||
<i class="fas fa-list"></i> 列表
|
||||
<button class="btn" :class="fileViewMode === 'list' ? 'btn-primary' : 'btn-secondary'" @click="fileViewMode = 'list'" title="列表视图">
|
||||
<i class="fas fa-list"></i><span>列表</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin: 10px 0 14px 0; position: relative;">
|
||||
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
|
||||
|
||||
<div class="files-search-wrap">
|
||||
<div class="files-search-row">
|
||||
<div class="files-search-field">
|
||||
<i class="fas fa-search"></i>
|
||||
<input
|
||||
type="text"
|
||||
class="form-input"
|
||||
v-model="globalSearchKeyword"
|
||||
@input="triggerGlobalSearch"
|
||||
@focus="globalSearchVisible = !!globalSearchKeyword"
|
||||
placeholder="全局搜索文件名(跨全部目录)"
|
||||
style="flex: 1; min-width: 220px;">
|
||||
<select class="form-input" v-model="globalSearchType" @change="runGlobalSearch" style="width: 120px;">
|
||||
placeholder="搜索全部目录中的文件或文件夹">
|
||||
</div>
|
||||
<select class="form-input files-search-type" v-model="globalSearchType" @change="runGlobalSearch">
|
||||
<option value="all">全部</option>
|
||||
<option value="file">仅文件</option>
|
||||
<option value="directory">仅文件夹</option>
|
||||
</select>
|
||||
<button class="btn btn-secondary" @click="runGlobalSearch" :disabled="globalSearchLoading" style="min-width: 86px;">
|
||||
<button class="btn btn-secondary files-search-button" @click="runGlobalSearch" :disabled="globalSearchLoading">
|
||||
<i :class="globalSearchLoading ? 'fas fa-spinner fa-spin' : 'fas fa-search'"></i>
|
||||
搜索
|
||||
</button>
|
||||
<button class="btn btn-secondary" @click="clearGlobalSearch()" style="min-width: 72px;">
|
||||
<button class="btn btn-quiet files-search-clear" @click="clearGlobalSearch()">
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="globalSearchVisible" style="position: absolute; left: 0; right: 0; top: calc(100% + 8px); z-index: 30; background: var(--bg-card); border: 1px solid var(--glass-border); border-radius: 10px; box-shadow: 0 8px 28px rgba(0,0,0,0.22); max-height: 320px; overflow: auto;">
|
||||
<div v-if="globalSearchLoading" style="padding: 12px; color: var(--text-secondary);">
|
||||
<div v-if="globalSearchVisible" class="files-search-results">
|
||||
<div v-if="globalSearchLoading" class="files-search-message">
|
||||
<i class="fas fa-spinner fa-spin"></i> 正在搜索...
|
||||
</div>
|
||||
<div v-else-if="globalSearchError" style="padding: 12px; color: #ef4444;">
|
||||
<div v-else-if="globalSearchError" class="files-search-message error">
|
||||
<i class="fas fa-circle-exclamation"></i> {{ globalSearchError }}
|
||||
</div>
|
||||
<div v-else-if="globalSearchResults.length === 0" style="padding: 12px; color: var(--text-secondary);">
|
||||
<div v-else-if="globalSearchResults.length === 0" class="files-search-message">
|
||||
暂无匹配结果
|
||||
</div>
|
||||
<div v-else>
|
||||
<button
|
||||
v-for="item in globalSearchResults"
|
||||
:key="item.path"
|
||||
class="btn"
|
||||
@click="jumpToSearchResult(item)"
|
||||
style="display: block; width: 100%; border: none; border-bottom: 1px solid var(--glass-border); border-radius: 0; text-align: left; background: transparent; padding: 10px 12px;">
|
||||
<div style="display: flex; justify-content: space-between; gap: 12px; align-items: center;">
|
||||
<div style="min-width: 0;">
|
||||
<div style="font-weight: 600; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
|
||||
<i class="fas" :class="item.isDirectory ? 'fa-folder' : 'fa-file'" style="margin-right: 6px; color: #667eea;"></i>
|
||||
class="files-search-result"
|
||||
@click="jumpToSearchResult(item)">
|
||||
<div class="files-search-result-main">
|
||||
<div class="files-search-result-copy">
|
||||
<div class="files-search-result-name">
|
||||
<i class="fas" :class="item.isDirectory ? 'fa-folder' : 'fa-file'"></i>
|
||||
{{ item.name }}
|
||||
</div>
|
||||
<div style="font-size: 12px; color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
|
||||
<div class="files-search-result-path">
|
||||
{{ item.path }}
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size: 12px; color: var(--text-muted); white-space: nowrap;">
|
||||
<div class="files-search-result-size">
|
||||
{{ item.isDirectory ? '文件夹' : (item.sizeFormatted || '-') }}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<div v-if="globalSearchMeta?.truncated" style="padding: 10px 12px; font-size: 12px; color: #f59e0b;">
|
||||
<div v-if="globalSearchMeta?.truncated" class="files-search-truncated">
|
||||
结果已截断,请缩小关键词范围
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="files-list-heading">
|
||||
<div>
|
||||
<strong>{{ currentPath === '/' ? '全部文件' : pathParts[pathParts.length - 1] }}</strong>
|
||||
<span>{{ fileStats.totalCount }} 个项目</span>
|
||||
</div>
|
||||
<span class="files-list-hint"><i class="fas fa-hand-pointer"></i> 双击打开,右键查看更多操作</span>
|
||||
</div>
|
||||
<div @dragenter="handleDragEnter" @dragover="handleDragOver" @dragleave="handleDragLeave" @drop="handleDrop" class="files-container" :class="{ 'drag-over': isDragging }">
|
||||
<div v-if="files.length === 0" class="empty-hint files-empty-state">
|
||||
<i class="fas fa-folder-open"></i>
|
||||
@@ -3054,35 +3083,56 @@
|
||||
|
||||
<!-- 分享视图 -->
|
||||
<div v-if="isLoggedIn && currentView === 'shares'" class="main-container">
|
||||
<div class="card">
|
||||
<!-- 标题和工具栏 -->
|
||||
<div class="shares-page-head" style="margin-bottom: 16px; display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap;">
|
||||
<h3 class="shares-page-title" style="margin: 0; display: flex; align-items: center; gap: 8px;">
|
||||
<i class="fas fa-share-alt"></i> 我的分享
|
||||
</h3>
|
||||
<div class="shares-page-actions" style="display: flex; gap: 8px;">
|
||||
<button class="btn" :class="shareViewMode === 'grid' ? 'btn-primary' : 'btn-secondary'" @click="shareViewMode = 'grid'">
|
||||
<i class="fas fa-th-large"></i> 卡片
|
||||
</button>
|
||||
<button class="btn" :class="shareViewMode === 'list' ? 'btn-primary' : 'btn-secondary'" @click="shareViewMode = 'list'">
|
||||
<i class="fas fa-list"></i> 列表
|
||||
</button>
|
||||
<div class="card shares-page-card">
|
||||
<header class="workspace-page-header shares-page-head">
|
||||
<div class="workspace-page-heading">
|
||||
<span class="workspace-kicker"><i class="fas fa-share-nodes"></i> 共享中心</span>
|
||||
<h1>我的分享</h1>
|
||||
<p>集中查看分享状态、访问数据和文件直链。</p>
|
||||
</div>
|
||||
<div class="shares-page-actions">
|
||||
<div class="view-toggle-group shares-view-toggle" aria-label="分享视图切换">
|
||||
<button class="btn" :class="shareViewMode === 'grid' ? 'btn-primary' : 'btn-secondary'" @click="shareViewMode = 'grid'" title="卡片视图">
|
||||
<i class="fas fa-th-large"></i><span>卡片</span>
|
||||
</button>
|
||||
<button class="btn" :class="shareViewMode === 'list' ? 'btn-primary' : 'btn-secondary'" @click="shareViewMode = 'list'" title="列表视图">
|
||||
<i class="fas fa-list"></i><span>列表</span>
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn btn-secondary" @click="refreshShareResources">
|
||||
<i class="fas fa-sync-alt"></i> 刷新
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="share-metrics">
|
||||
<div class="share-metric">
|
||||
<span class="share-metric-icon blue"><i class="fas fa-share-alt"></i></span>
|
||||
<span><small>分享链接</small><strong>{{ shares.length }}</strong></span>
|
||||
</div>
|
||||
<div class="share-metric">
|
||||
<span class="share-metric-icon teal"><i class="fas fa-link"></i></span>
|
||||
<span><small>文件直链</small><strong>{{ directLinks.length }}</strong></span>
|
||||
</div>
|
||||
<div class="share-metric share-metric-wide">
|
||||
<span class="share-metric-icon amber"><i class="fas fa-layer-group"></i></span>
|
||||
<span><small>共享资源总数</small><strong>{{ shares.length + directLinks.length }}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 筛选/搜索 -->
|
||||
<div class="share-toolbar shares-toolbar">
|
||||
<input type="text" v-model="shareFilters.keyword" placeholder="搜索路径 / 链接 / 分享码" style="flex: 1; min-width: 180px;">
|
||||
<select v-model="shareFilters.type">
|
||||
<div class="share-search-field">
|
||||
<i class="fas fa-search"></i>
|
||||
<input type="text" v-model="shareFilters.keyword" placeholder="搜索文件名、链接或分享码">
|
||||
</div>
|
||||
<select v-model="shareFilters.type" aria-label="分享类型">
|
||||
<option value="all">全部类型</option>
|
||||
<option value="file">文件</option>
|
||||
<option value="directory">文件夹</option>
|
||||
<option value="all_files">全部文件</option>
|
||||
</select>
|
||||
<select v-model="shareFilters.status">
|
||||
<select v-model="shareFilters.status" aria-label="分享状态">
|
||||
<option value="all">全部状态</option>
|
||||
<option value="active">有效</option>
|
||||
<option value="expiring">即将到期</option>
|
||||
@@ -3090,7 +3140,7 @@
|
||||
<option value="protected">已加密</option>
|
||||
<option value="public">公开</option>
|
||||
</select>
|
||||
<select v-model="shareFilters.sort">
|
||||
<select v-model="shareFilters.sort" aria-label="分享排序">
|
||||
<option value="created_desc">最新创建</option>
|
||||
<option value="created_asc">最早创建</option>
|
||||
<option value="views_desc">访问最多</option>
|
||||
@@ -3099,161 +3149,148 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="shares.length === 0" class="alert alert-info">
|
||||
还没有创建任何分享
|
||||
</div>
|
||||
<div v-else-if="filteredShares.length === 0" class="alert alert-warning">
|
||||
没有符合筛选条件的分享,试试清空搜索/筛选。
|
||||
</div>
|
||||
|
||||
<!-- 大图标视图 -->
|
||||
<div v-else-if="shareViewMode === 'grid'" class="share-card-grid">
|
||||
<div v-for="share in filteredShares" :key="share.id" class="share-card">
|
||||
<div class="share-card__title">
|
||||
<i class="fas" :class="getShareTypeIcon(share)" style="color: var(--accent-1);"></i>
|
||||
<span :title="share.share_path">{{ getPathBaseName(share.share_path) }}</span>
|
||||
</div>
|
||||
<div class="share-card__chips">
|
||||
<span :class="['share-chip', getShareStatus(share).class]">
|
||||
<i class="fas" :class="getShareStatus(share).icon"></i> {{ getShareStatus(share).text }}
|
||||
</span>
|
||||
<span class="share-chip info">
|
||||
<i class="fas fa-tag"></i> {{ getShareTypeLabel(share) }}
|
||||
</span>
|
||||
<span class="share-chip info">
|
||||
<i class="fas" :class="getShareProtection(share).icon"></i> {{ getShareProtection(share).text }}
|
||||
</span>
|
||||
<span class="share-chip info" v-if="share.storage_type">
|
||||
<i class="fas fa-hdd"></i> {{ getStorageLabel(share.storage_type) }}
|
||||
</span>
|
||||
<span class="share-chip info">
|
||||
<i class="fas fa-barcode"></i> {{ share.share_code }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="share-card__url" style="font-size: 13px; color: var(--text-secondary); margin-bottom: 10px; word-break: break-all;">
|
||||
<i class="fas fa-link"></i>
|
||||
<span class="share-card__url-link" :title="share.share_url">{{ share.share_url }}</span>
|
||||
</div>
|
||||
<div class="share-card__meta">
|
||||
<span><i class="fas fa-eye"></i> 访问 {{ share.view_count }}</span>
|
||||
<span><i class="fas fa-download"></i> 下载 {{ share.download_count }}</span>
|
||||
<span><i class="fas fa-clock"></i> {{ share.expires_at ? formatExpireTime(share.expires_at) : '永久有效' }}</span>
|
||||
<span><i class="fas fa-calendar-alt"></i> 创建 {{ formatDateTime(share.created_at) }}</span>
|
||||
</div>
|
||||
<div class="share-card__actions">
|
||||
<button class="btn btn-secondary" @click.stop="openShare(share.share_url)">
|
||||
<i class="fas fa-external-link-alt"></i> 打开
|
||||
</button>
|
||||
<button class="btn btn-secondary" @click.stop="copyShareLink(share.share_url)">
|
||||
<i class="fas fa-copy"></i> 复制链接
|
||||
</button>
|
||||
<button class="btn share-card__delete-btn" style="background: #ef4444; color: white;" @click.stop="requestDeleteShare(share.id, $event)">
|
||||
<i class="fas fa-trash"></i> 删除
|
||||
</button>
|
||||
<section class="share-section">
|
||||
<div class="share-section-head">
|
||||
<div>
|
||||
<span class="share-section-icon"><i class="fas fa-share-alt"></i></span>
|
||||
<span><strong>分享链接</strong><small>可设置有效期、访问密码并查看使用情况</small></span>
|
||||
</div>
|
||||
<span class="share-section-count">{{ filteredShares.length }} 项</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 列表视图 -->
|
||||
<table v-else class="share-list-table">
|
||||
<thead>
|
||||
<tr style="border-bottom: 2px solid #ddd;">
|
||||
<th style="padding: 10px; text-align: left; width: 18%;">文件名</th>
|
||||
<th style="padding: 10px; text-align: left; width: 30%;">链接地址</th>
|
||||
<th style="padding: 10px; text-align: center; width: 8%;">访问次数</th>
|
||||
<th style="padding: 10px; text-align: center; width: 8%;">下载次数</th>
|
||||
<th style="padding: 10px; text-align: center; width: 16%;">到期时间</th>
|
||||
<th style="padding: 10px; text-align: center; width: 20%;">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="share in filteredShares" :key="share.id" style="border-bottom: 1px solid #eee;">
|
||||
<td style="padding: 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" :title="share.share_path">{{ getPathBaseName(share.share_path) }}</td>
|
||||
<td style="padding: 10px; overflow: hidden;">
|
||||
<span class="share-list-link-text" :title="share.share_url">{{ share.share_url }}</span>
|
||||
</td>
|
||||
<td style="padding: 10px; text-align: center;">{{ share.view_count }}</td>
|
||||
<td style="padding: 10px; text-align: center;">{{ share.download_count }}</td>
|
||||
<td style="padding: 10px; text-align: center;">
|
||||
<span v-if="!share.expires_at" style="color: #22c55e;"><i class="fas fa-infinity"></i> 永久有效</span>
|
||||
<span v-else :style="{color: isExpiringSoon(share.expires_at) ? '#ffc107' : isExpired(share.expires_at) ? '#dc3545' : '#667eea'}" :title="share.expires_at"><i class="fas fa-clock"></i> {{ formatExpireTime(share.expires_at) }}</span>
|
||||
</td>
|
||||
<td style="padding: 10px; text-align: center;">
|
||||
<div class="share-list-actions">
|
||||
<button class="btn btn-secondary" @click.stop="openShare(share.share_url)">
|
||||
<i class="fas fa-up-right-from-square"></i> 打开
|
||||
</button>
|
||||
<button class="btn btn-secondary" @click.stop="copyShareLink(share.share_url)">
|
||||
<i class="fas fa-copy"></i> 复制链接
|
||||
</button>
|
||||
<button class="btn" style="background: #ef4444; color: white;" @click.stop="requestDeleteShare(share.id, $event)">
|
||||
<i class="fas fa-trash"></i> 删除
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="shares.length === 0" class="share-empty-state">
|
||||
<span class="share-empty-icon"><i class="fas fa-paper-plane"></i></span>
|
||||
<strong>还没有创建分享</strong>
|
||||
<p>前往我的文件,选择文件或文件夹后即可生成分享链接。</p>
|
||||
<button class="btn btn-primary" @click="switchView('files')"><i class="fas fa-folder-open"></i> 前往我的文件</button>
|
||||
</div>
|
||||
<div v-else-if="filteredShares.length === 0" class="share-empty-state compact">
|
||||
<span class="share-empty-icon"><i class="fas fa-filter-circle-xmark"></i></span>
|
||||
<strong>没有匹配的分享</strong>
|
||||
<p>请调整上方的关键词或筛选条件。</p>
|
||||
</div>
|
||||
|
||||
<!-- 大图标视图 -->
|
||||
<div v-else-if="shareViewMode === 'grid'" class="share-card-grid">
|
||||
<article v-for="share in filteredShares" :key="share.id" class="share-card">
|
||||
<div class="share-card__title">
|
||||
<span class="share-card__type"><i class="fas" :class="getShareTypeIcon(share)"></i></span>
|
||||
<span :title="share.share_path">{{ getPathBaseName(share.share_path) }}</span>
|
||||
</div>
|
||||
<div class="share-card__chips">
|
||||
<span :class="['share-chip', getShareStatus(share).class]">
|
||||
<i class="fas" :class="getShareStatus(share).icon"></i> {{ getShareStatus(share).text }}
|
||||
</span>
|
||||
<span class="share-chip info"><i class="fas fa-tag"></i> {{ getShareTypeLabel(share) }}</span>
|
||||
<span class="share-chip info"><i class="fas" :class="getShareProtection(share).icon"></i> {{ getShareProtection(share).text }}</span>
|
||||
<span class="share-chip info" v-if="share.storage_type"><i class="fas fa-hdd"></i> {{ getStorageLabel(share.storage_type) }}</span>
|
||||
</div>
|
||||
<div class="share-card__url">
|
||||
<i class="fas fa-link"></i>
|
||||
<span class="share-card__url-link" :title="share.share_url">{{ share.share_url }}</span>
|
||||
<span class="share-code">{{ share.share_code }}</span>
|
||||
</div>
|
||||
<div class="share-card__meta">
|
||||
<span><i class="fas fa-eye"></i> {{ share.view_count }} 次访问</span>
|
||||
<span><i class="fas fa-download"></i> {{ share.download_count }} 次下载</span>
|
||||
<span><i class="fas fa-clock"></i> {{ share.expires_at ? formatExpireTime(share.expires_at) : '永久有效' }}</span>
|
||||
<span><i class="fas fa-calendar-alt"></i> {{ formatDateTime(share.created_at) }}</span>
|
||||
</div>
|
||||
<div class="share-card__actions">
|
||||
<button class="btn btn-secondary" @click.stop="openShare(share.share_url)"><i class="fas fa-external-link-alt"></i> 打开</button>
|
||||
<button class="btn btn-secondary" @click.stop="copyShareLink(share.share_url)"><i class="fas fa-copy"></i> 复制</button>
|
||||
<button class="btn btn-danger share-card__delete-btn" @click.stop="requestDeleteShare(share.id, $event)"><i class="fas fa-trash"></i> 删除</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<!-- 列表视图 -->
|
||||
<div v-else class="share-table-wrap">
|
||||
<table class="share-list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="share-col-name">文件名</th>
|
||||
<th class="share-col-link">链接地址</th>
|
||||
<th class="share-col-number">访问</th>
|
||||
<th class="share-col-number">下载</th>
|
||||
<th class="share-col-expire">有效期</th>
|
||||
<th class="share-col-actions">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="share in filteredShares" :key="share.id">
|
||||
<td :title="share.share_path"><span class="share-list-resource"><i class="fas" :class="getShareTypeIcon(share)"></i>{{ getPathBaseName(share.share_path) }}</span></td>
|
||||
<td><span class="share-list-link-text" :title="share.share_url">{{ share.share_url }}</span></td>
|
||||
<td class="share-number-cell">{{ share.view_count }}</td>
|
||||
<td class="share-number-cell">{{ share.download_count }}</td>
|
||||
<td class="share-expire-cell">
|
||||
<span v-if="!share.expires_at" class="share-forever"><i class="fas fa-infinity"></i> 永久有效</span>
|
||||
<span v-else :style="{color: isExpiringSoon(share.expires_at) ? '#d97706' : isExpired(share.expires_at) ? '#dc2626' : '#2878ff'}" :title="share.expires_at"><i class="fas fa-clock"></i> {{ formatExpireTime(share.expires_at) }}</span>
|
||||
</td>
|
||||
<td><div class="share-list-actions">
|
||||
<button class="btn btn-secondary" @click.stop="openShare(share.share_url)" title="打开"><i class="fas fa-up-right-from-square"></i></button>
|
||||
<button class="btn btn-secondary" @click.stop="copyShareLink(share.share_url)" title="复制链接"><i class="fas fa-copy"></i></button>
|
||||
<button class="btn btn-danger" @click.stop="requestDeleteShare(share.id, $event)" title="删除"><i class="fas fa-trash"></i></button>
|
||||
</div></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 直链管理(独立于普通分享) -->
|
||||
<div style="margin-top: 24px;">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 10px;">
|
||||
<h4 style="margin: 0; display: flex; align-items: center; gap: 8px;">
|
||||
<i class="fas fa-link"></i> 我的直链
|
||||
</h4>
|
||||
<small style="color: var(--text-secondary);">提示:可在文件右键菜单中生成直链</small>
|
||||
<section class="share-section direct-link-section">
|
||||
<div class="share-section-head">
|
||||
<div>
|
||||
<span class="share-section-icon teal"><i class="fas fa-link"></i></span>
|
||||
<span><strong>文件直链</strong><small>适合外部引用和直接下载,可在文件右键菜单中创建</small></span>
|
||||
</div>
|
||||
<span class="share-section-count">{{ filteredDirectLinks.length }} 项</span>
|
||||
</div>
|
||||
|
||||
<div v-if="directLinksLoading" class="alert alert-info">
|
||||
正在加载直链列表...
|
||||
<div v-if="directLinksLoading" class="share-loading-state"><i class="fas fa-spinner fa-spin"></i> 正在加载直链列表...</div>
|
||||
<div v-else-if="directLinks.length === 0" class="share-empty-state compact">
|
||||
<span class="share-empty-icon teal"><i class="fas fa-link-slash"></i></span>
|
||||
<strong>还没有创建直链</strong>
|
||||
<p>在文件列表中右键目标文件,选择“生成直链”。</p>
|
||||
</div>
|
||||
<div v-else-if="directLinks.length === 0" class="alert alert-info">
|
||||
还没有创建直链
|
||||
<div v-else-if="filteredDirectLinks.length === 0" class="share-empty-state compact">
|
||||
<span class="share-empty-icon"><i class="fas fa-search"></i></span>
|
||||
<strong>没有匹配的直链</strong>
|
||||
<p>请调整上方搜索关键词。</p>
|
||||
</div>
|
||||
<div v-else-if="filteredDirectLinks.length === 0" class="alert alert-warning">
|
||||
没有符合搜索条件的直链
|
||||
<div v-else class="share-table-wrap">
|
||||
<table class="share-list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="share-col-name">文件名</th>
|
||||
<th class="share-col-link">链接地址</th>
|
||||
<th class="share-col-number">访问</th>
|
||||
<th class="share-col-number">下载</th>
|
||||
<th class="share-col-expire">有效期</th>
|
||||
<th class="share-col-actions">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="link in filteredDirectLinks" :key="`direct-${link.id}`">
|
||||
<td :title="link.file_path"><span class="share-list-resource"><i class="fas fa-file"></i>{{ getPathBaseName(link.file_path, link.file_name) }}</span></td>
|
||||
<td><span class="share-list-link-text" :title="link.direct_url">{{ link.direct_url }}</span></td>
|
||||
<td class="share-number-cell muted" title="直链暂不统计访问次数">—</td>
|
||||
<td class="share-number-cell">{{ link.download_count || 0 }}</td>
|
||||
<td class="share-expire-cell">
|
||||
<span v-if="!link.expires_at" class="share-forever"><i class="fas fa-infinity"></i> 永久有效</span>
|
||||
<span v-else :style="{color: isExpiringSoon(link.expires_at) ? '#d97706' : isExpired(link.expires_at) ? '#dc2626' : '#2878ff'}" :title="link.expires_at"><i class="fas fa-clock"></i> {{ formatExpireTime(link.expires_at) }}</span>
|
||||
</td>
|
||||
<td><div class="share-list-actions">
|
||||
<button class="btn btn-secondary" @click.stop="openShare(link.direct_url)" title="打开"><i class="fas fa-up-right-from-square"></i></button>
|
||||
<button class="btn btn-secondary" @click.stop="copyDirectLink(link.direct_url)" title="复制链接"><i class="fas fa-copy"></i></button>
|
||||
<button class="btn btn-danger" @click.stop="requestDeleteDirectLink(link.id, $event)" title="删除"><i class="fas fa-trash"></i></button>
|
||||
</div></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<table v-else class="share-list-table">
|
||||
<thead>
|
||||
<tr style="border-bottom: 2px solid #ddd;">
|
||||
<th style="padding: 10px; text-align: left; width: 18%;">文件名</th>
|
||||
<th style="padding: 10px; text-align: left; width: 30%;">链接地址</th>
|
||||
<th style="padding: 10px; text-align: center; width: 8%;">访问次数</th>
|
||||
<th style="padding: 10px; text-align: center; width: 8%;">下载次数</th>
|
||||
<th style="padding: 10px; text-align: center; width: 16%;">到期时间</th>
|
||||
<th style="padding: 10px; text-align: center; width: 20%;">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="link in filteredDirectLinks" :key="`direct-${link.id}`" style="border-bottom: 1px solid #eee;">
|
||||
<td style="padding: 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" :title="link.file_path">{{ getPathBaseName(link.file_path, link.file_name) }}</td>
|
||||
<td style="padding: 10px; overflow: hidden;">
|
||||
<span class="share-list-link-text" :title="link.direct_url">{{ link.direct_url }}</span>
|
||||
</td>
|
||||
<td style="padding: 10px; text-align: center; color: var(--text-muted);" title="直链暂不统计访问次数">—</td>
|
||||
<td style="padding: 10px; text-align: center;">{{ link.download_count || 0 }}</td>
|
||||
<td style="padding: 10px; text-align: center;">
|
||||
<span v-if="!link.expires_at" style="color: #22c55e;"><i class="fas fa-infinity"></i> 永久有效</span>
|
||||
<span v-else :style="{color: isExpiringSoon(link.expires_at) ? '#ffc107' : isExpired(link.expires_at) ? '#dc3545' : '#667eea'}" :title="link.expires_at"><i class="fas fa-clock"></i> {{ formatExpireTime(link.expires_at) }}</span>
|
||||
</td>
|
||||
<td style="padding: 10px; text-align: center;">
|
||||
<div class="share-list-actions">
|
||||
<button class="btn btn-secondary" @click.stop="openShare(link.direct_url)">
|
||||
<i class="fas fa-up-right-from-square"></i> 打开
|
||||
</button>
|
||||
<button class="btn btn-secondary" @click.stop="copyDirectLink(link.direct_url)">
|
||||
<i class="fas fa-copy"></i> 复制链接
|
||||
</button>
|
||||
<button class="btn" style="background: #ef4444; color: white;" @click.stop="requestDeleteDirectLink(link.id, $event)">
|
||||
<i class="fas fa-trash"></i> 删除
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7632,6 +7669,7 @@
|
||||
}
|
||||
</style>
|
||||
|
||||
<script src="app.js?v=20260218002"></script>
|
||||
<link rel="stylesheet" href="workspace.css?v=20260721005">
|
||||
<script src="app.js?v=20260721005"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -2045,28 +2045,28 @@ handleDragLeave(e) {
|
||||
}
|
||||
},
|
||||
|
||||
// 本地存储下载(先预检,避免浏览器下载 JSON 错误文件)
|
||||
// 本地存储下载使用短时令牌,兼容不携带站点 Cookie 的系统下载器。
|
||||
async downloadFromLocal(filePath) {
|
||||
try {
|
||||
const checkResp = await axios.get(`${this.apiBase}/api/files/download-check`, {
|
||||
const response = await axios.get(`${this.apiBase}/api/files/download-url`, {
|
||||
params: { path: filePath }
|
||||
});
|
||||
|
||||
if (!checkResp.data?.success) {
|
||||
this.showToast('error', '下载失败', checkResp.data?.message || '下载失败,请稍后重试');
|
||||
if (!response.data?.success || !response.data?.downloadUrl) {
|
||||
this.showToast('error', '下载失败', response.data?.message || '下载失败,请稍后重试');
|
||||
return false;
|
||||
}
|
||||
|
||||
const url = `${this.apiBase}/api/files/download?path=${encodeURIComponent(filePath)}`;
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.href = response.data.downloadUrl;
|
||||
link.setAttribute('download', '');
|
||||
link.rel = 'noopener noreferrer';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('本地下载预检失败:', error);
|
||||
console.error('获取本地下载地址失败:', error);
|
||||
const message = error.response?.data?.message || '下载失败,请稍后重试';
|
||||
this.showToast('error', '下载失败', message);
|
||||
if (error.response?.status === 401) {
|
||||
@@ -3101,9 +3101,26 @@ handleDragLeave(e) {
|
||||
formData.append('chunk_index', String(chunkIndex));
|
||||
formData.append('chunk', chunkBlob, `${file.name}.part${chunkIndex}`);
|
||||
|
||||
const confirmedUploadedBytes = uploadedBytes;
|
||||
const chunkResp = await axios.post(`${this.apiBase}/api/upload/resumable/chunk`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: 30 * 60 * 1000
|
||||
timeout: 30 * 60 * 1000,
|
||||
onUploadProgress: (progressEvent) => {
|
||||
const currentChunkLoaded = Math.min(
|
||||
chunkBlob.size,
|
||||
Math.max(0, Number(progressEvent.loaded) || 0)
|
||||
);
|
||||
const liveUploadedBytes = Math.min(
|
||||
file.size,
|
||||
confirmedUploadedBytes + currentChunkLoaded
|
||||
);
|
||||
this.uploadedBytes = liveUploadedBytes;
|
||||
this.totalBytes = file.size;
|
||||
this.uploadProgress = file.size > 0
|
||||
? Math.min(100, Math.round((liveUploadedBytes / file.size) * 100))
|
||||
: 0;
|
||||
this.uploadPhase = liveUploadedBytes >= file.size ? '分片确认中' : '上传中';
|
||||
}
|
||||
});
|
||||
|
||||
if (!chunkResp.data?.success) {
|
||||
|
||||
1839
frontend/workspace.css
Normal file
1839
frontend/workspace.css
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2390,7 +2390,7 @@ server {
|
||||
location / {
|
||||
root ${PROJECT_DIR}/frontend;
|
||||
index index.html;
|
||||
try_files \$uri \$uri/ /index.html;
|
||||
try_files \$uri \$uri/ =404;
|
||||
}
|
||||
|
||||
# 后端API
|
||||
@@ -2677,7 +2677,7 @@ server {
|
||||
location / {
|
||||
root ${PROJECT_DIR}/frontend;
|
||||
index index.html;
|
||||
try_files \$uri \$uri/ /index.html;
|
||||
try_files \$uri \$uri/ =404;
|
||||
}
|
||||
|
||||
# 后端API
|
||||
@@ -2814,7 +2814,7 @@ server {
|
||||
location / {
|
||||
root ${PROJECT_DIR}/frontend;
|
||||
index index.html;
|
||||
try_files \$uri \$uri/ /index.html;
|
||||
try_files \$uri \$uri/ =404;
|
||||
}
|
||||
|
||||
# 后端API
|
||||
|
||||
Reference in New Issue
Block a user