Conversation
577fkj
commented
Apr 2, 2026
- 添加云盘音乐上传功能
There was a problem hiding this comment.
Code Review
This pull request implements a cloud music upload feature, adding backend support for multipart file uploads and a new frontend CloudUpload modal. The implementation includes file size limit increases, MD5 calculation for integrity, and a fallback mechanism between proxy and direct-to-cloud uploads. Feedback identifies a bug where the Content-MD5 header uses hex instead of Base64, an issue with filename sanitization that strips extensions, and a recommendation to move metadata from query parameters to the request body to prevent URL length issues.
| const ext = item.file.name.split(".").pop() || "mp3"; | ||
| const filename = item.file.name.replace("." + ext, "").replace(/\s/g, "").replace(/\./g, "_"); |
| headers: { | ||
| "x-nos-token": uploadData.uploadToken, | ||
| "Content-Type": item.file.type || "audio/mpeg", | ||
| "Content-MD5": fileMd5, |
| params: { | ||
| songId, | ||
| filename, | ||
| resourceId, | ||
| md5, | ||
| song, | ||
| artist, | ||
| album, | ||
| timestamp: Date.now(), | ||
| }, |
There was a problem hiding this comment.
Pull request overview
此 PR 为“我的云盘”页面新增音乐上传能力,包含前端上传弹窗与上传流程(后端代理上传失败时回退到客户端直传),并在 Electron 内置 API 服务侧补充 multipart 文件上传支持及上传大小限制。
Changes:
- 在云盘页面的“更多操作”中新增“上传音乐”入口,打开上传弹窗并在成功后刷新云盘列表
- 新增
CloudUpload弹窗组件,实现上传队列、进度展示、失败重试,以及“代理上传 → 直传”回退逻辑 - 补充云盘直传所需的 token/complete API 封装,并在 Electron Fastify 服务端支持 multipart 文件转发与 100MB 限制
Reviewed changes
Copilot reviewed 7 out of 9 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| src/views/Cloud.vue | 增加“上传音乐”菜单项,调用上传弹窗并在成功后刷新云盘数据 |
| src/utils/modal.ts | 新增 openCloudUpload,以 modal 方式加载并展示上传组件 |
| src/components/Modal/CloudUpload.vue | 新增上传弹窗:队列/进度/重试、代理上传失败回退直传、元数据提取 |
| src/api/cloud.ts | 新增直传所需的 /cloud/upload/token 与 /cloud/upload/complete 请求封装 |
| electron/server/netease/index.ts | 动态 API 路由增加 multipart 文件读取并映射为 songFile 参数 |
| electron/server/index.ts | 配置 Fastify bodyLimit 与 multipart fileSize 为 100MB |
| components.d.ts | 注册 CloudUpload 组件与 Naive UI Upload 相关组件类型 |
| package.json | 升级 @neteasecloudmusicapienhanced/api 依赖版本 |
| pnpm-lock.yaml | 锁文件更新以匹配依赖升级(含传递依赖变更) |
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
|
|
||
| // 检查是否为音频文件 | ||
| if (!file.type.startsWith("audio/")) { | ||
| window.$message.warning(`${file.name} 不是音频文件`); | ||
| return false; | ||
| } | ||
|
|
||
| return true; |
There was a problem hiding this comment.
beforeUpload 仅依赖 file.type.startsWith('audio/') 判断音频类型;但在部分环境(尤其本地文件/Electron 场景)File.type 可能为空字符串,导致合法音频被误拒绝。建议在 file.type 为空时回退用扩展名判断,或交由后端校验并前端仅做提示。
| // 检查是否为音频文件 | |
| if (!file.type.startsWith("audio/")) { | |
| window.$message.warning(`${file.name} 不是音频文件`); | |
| return false; | |
| } | |
| return true; | |
| const mimeType = file.type; | |
| // 1. 优先使用 MIME 类型判断 | |
| if (mimeType && mimeType.startsWith("audio/")) { | |
| return true; | |
| } | |
| // 2. 当 MIME 为空时回退使用扩展名判断(兼容部分本地/Electron 场景) | |
| if (!mimeType || mimeType === "") { | |
| const name = file.name || ""; | |
| const dotIndex = name.lastIndexOf("."); | |
| const ext = dotIndex !== -1 ? name.slice(dotIndex + 1).toLowerCase() : ""; | |
| const audioExts = ["mp3", "wav", "flac", "aac", "ogg", "m4a", "wma", "aiff", "alac"]; | |
| if (ext && audioExts.includes(ext)) { | |
| return true; | |
| } | |
| } | |
| // 非音频文件给出提示 | |
| window.$message.warning(`${file.name} 不是音频文件`); | |
| return false; |
| // 文件上传前校验 | ||
| const beforeUpload = (data: { file: { file: File | null }; fileList: any[] }) => { | ||
| const file = data.file.file; | ||
| if (!file) return false; | ||
|
|
||
| // 检查是否为音频文件 | ||
| if (!file.type.startsWith("audio/")) { | ||
| window.$message.warning(`${file.name} 不是音频文件`); | ||
| return false; | ||
| } | ||
|
|
There was a problem hiding this comment.
前端未限制上传文件大小;但 Electron 侧 Fastify 已把 bodyLimit/multipart fileSize 设为 100MB,超出时会失败且提示不友好。建议在 beforeUpload 增加与服务端一致的 size 校验(提示中说明上限),并避免 FileReader 对超大文件做全量读取造成内存压力。
| // 文件上传前校验 | |
| const beforeUpload = (data: { file: { file: File | null }; fileList: any[] }) => { | |
| const file = data.file.file; | |
| if (!file) return false; | |
| // 检查是否为音频文件 | |
| if (!file.type.startsWith("audio/")) { | |
| window.$message.warning(`${file.name} 不是音频文件`); | |
| return false; | |
| } | |
| // 最大上传大小:100MB(需与后端 Fastify bodyLimit / multipart fileSize 保持一致) | |
| const MAX_UPLOAD_SIZE = 100 * 1024 * 1024; | |
| // 文件上传前校验 | |
| const beforeUpload = (data: { file: { file: File | null }; fileList: any[] }) => { | |
| const file = data.file.file; | |
| if (!file) return false; | |
| // 大小限制校验 | |
| if (file.size > MAX_UPLOAD_SIZE) { | |
| window.$message.warning( | |
| `${file.name} 超出大小限制,最大仅支持 100MB` | |
| ); | |
| return false; | |
| } | |
| // 检查是否为音频文件 | |
| if (!file.type.startsWith("audio/")) { | |
| window.$message.warning(`${file.name} 不是音频文件`); | |
| return false; | |
| } |
src/components/Modal/CloudUpload.vue
Outdated
| for (const item of uploadQueue.value) { | ||
| if (item.status !== "pending") continue; | ||
|
|
||
| try { | ||
| // 先尝试后端代理,失败则回退到客户端直传 | ||
| await uploadFileWithFallback(item); | ||
| item.status = "success"; | ||
| item.statusText = "上传完成!"; | ||
| item.percent = 100; | ||
| } catch (error: any) { | ||
| console.error(`${item.file.name} 上传失败:`, error); | ||
| item.status = "error"; | ||
| item.error = true; | ||
| const errorMsg = error.response?.data?.msg || error.message || "未知错误"; | ||
| item.statusText = `上传失败: ${errorMsg}`; | ||
| item.percent = 0; | ||
| } | ||
| } | ||
|
|
||
| // 检查是否有成功的上传 | ||
| const hasSuccess = uploadQueue.value.some(item => item.status === "success"); | ||
| if (hasSuccess) { | ||
| // 通知刷新 | ||
| emit("success"); | ||
|
|
There was a problem hiding this comment.
processUploadQueue() 使用 for (const item of uploadQueue.value) 仅遍历一次当前队列;上传过程中若继续添加文件(isUploading 为 true 时不会触发新一轮处理),新加入的 pending 项可能在本轮结束后一直不被处理。建议改成“循环直到不存在 pending”的处理方式,或结束前检测仍有 pending 时继续跑一轮。
| for (const item of uploadQueue.value) { | |
| if (item.status !== "pending") continue; | |
| try { | |
| // 先尝试后端代理,失败则回退到客户端直传 | |
| await uploadFileWithFallback(item); | |
| item.status = "success"; | |
| item.statusText = "上传完成!"; | |
| item.percent = 100; | |
| } catch (error: any) { | |
| console.error(`${item.file.name} 上传失败:`, error); | |
| item.status = "error"; | |
| item.error = true; | |
| const errorMsg = error.response?.data?.msg || error.message || "未知错误"; | |
| item.statusText = `上传失败: ${errorMsg}`; | |
| item.percent = 0; | |
| } | |
| } | |
| // 检查是否有成功的上传 | |
| const hasSuccess = uploadQueue.value.some(item => item.status === "success"); | |
| if (hasSuccess) { | |
| // 通知刷新 | |
| emit("success"); | |
| // 循环处理,直到队列中不存在 pending 状态的任务 | |
| while (true) { | |
| const nextItem = uploadQueue.value.find(item => item.status === "pending"); | |
| if (!nextItem) { | |
| break; | |
| } | |
| try { | |
| // 先尝试后端代理,失败则回退到客户端直传 | |
| await uploadFileWithFallback(nextItem); | |
| nextItem.status = "success"; | |
| nextItem.statusText = "上传完成!"; | |
| nextItem.percent = 100; | |
| } catch (error: any) { | |
| console.error(`${nextItem.file.name} 上传失败:`, error); | |
| nextItem.status = "error"; | |
| nextItem.error = true; | |
| const errorMsg = error.response?.data?.msg || error.message || "未知错误"; | |
| nextItem.statusText = `上传失败: ${errorMsg}`; | |
| nextItem.percent = 0; | |
| } | |
| } | |
| // 检查是否有成功的上传 | |
| const hasSuccess = uploadQueue.value.some(item => item.status === "success"); | |
| if (hasSuccess) { | |
| // 通知刷新 | |
| emit("success"); |
| const handleUpload = async (options: UploadCustomRequestOptions) => { | ||
| const file = options.file.file; | ||
| if (!file) return; | ||
|
|
||
| // 检查文件是否已存在于队列中 | ||
| if (isFileInQueue(file as File)) { | ||
| window.$message.warning(`${file.name} 已在上传队列中`); | ||
| return; | ||
| } |
There was a problem hiding this comment.
n-upload 使用 custom-request 时通常需要在上传成功/失败后调用 options.onFinish / options.onError(以及可选的 onProgress),否则 Upload 组件内部状态可能一直处于 uploading,影响后续选择/事件触发。建议在队列任务完成时回调对应 handler(即使 show-file-list=false 也应同步状态)。