From eae42969809853446e364c9fc2d63e83b6b58e3d Mon Sep 17 00:00:00 2001 From: owen Date: Sun, 20 Sep 2026 12:25:37 +0800 Subject: [PATCH] ow --- .../PublishAudioService.auto.cs | 83 +++++ WebVue/src/components/basic/FileUpload.vue | 285 +++++++++++++++--- .../src/components/basic/OperatorStation.vue | 31 +- .../src/components/basic/VinylAudioPlayer.vue | 253 +++++++++++++--- WebVue/src/views/master/DmsFileEnquiry.vue | 226 +++++++++----- ....timestamp-1740100365739-24208d1e109f1.mjs | 103 ------- 6 files changed, 722 insertions(+), 259 deletions(-) delete mode 100644 WebVue/vite.config.ts.timestamp-1740100365739-24208d1e109f1.mjs diff --git a/CloudBuilder/CloudBuilder.Master.Service/PublishAudioService.auto.cs b/CloudBuilder/CloudBuilder.Master.Service/PublishAudioService.auto.cs index 2132780..2d4010a 100644 --- a/CloudBuilder/CloudBuilder.Master.Service/PublishAudioService.auto.cs +++ b/CloudBuilder/CloudBuilder.Master.Service/PublishAudioService.auto.cs @@ -386,7 +386,90 @@ namespace CloudBuilder.Master.Service dtl.OrderBy = dtls.Sum(x => x.OrderBy) + 1; dtl.Url = dmsFile.OrderKey; + // 同名的歌词、封面一起存下来(优先同目录) + FillLyricAndPic(dmsFile, dtl); + repositoryPublishAudioDetail.InsertNow(dtl); } + + /// + /// 找出与音频同名的歌词和封面文件,写进明细。 + /// + /// 约定:音频叫 xxx.mp3,配套文件就是同目录下的 xxx.lrc(歌词)、xxx.png/jpg(封面)。 + /// 匹配用 OrgFileName 的主文件名(它是用户看到的原始文件名,如 song.mp3), + /// 存的是 OrderKey——因为前端访问文件用的是 OrderKey(分片合并后的名字,如 uid-1-1.mp3)。 + /// 优先取同目录(FilePath 相同)的;同目录没有时才退而求其次找其它目录同名文件。 + /// + private void FillLyricAndPic(DmsFileUploadViewEntity dmsFile, PublishAudioDetailEntity dtl) + { + if (dmsFile == null || dtl == null) return; + if (string.IsNullOrEmpty(dmsFile.OrgFileName)) return; + + string baseName = Path.GetFileNameWithoutExtension(dmsFile.OrgFileName); + if (string.IsNullOrEmpty(baseName)) return; + + IRepository repositoryDmsFile = repository.GetRepository>(); + + // 先取回同目录的记录,文件名比对放在内存里做: + // Path.GetFileNameWithoutExtension 翻不成 SQL,直接写进 Where 会运行时报错 + DmsFileUploadViewEntity[] inFolder = repositoryDmsFile.DetachedEntities + .Where(x => x.FilePath == dmsFile.FilePath) + .ToArray(); + + DmsFileUploadViewEntity lyric = FindCompanion(inFolder, baseName, true); + DmsFileUploadViewEntity pic = FindCompanion(inFolder, baseName, false); + + // 同目录凑不齐,再放宽到其它目录 + if (lyric == null || pic == null) + { + DmsFileUploadViewEntity[] others = repositoryDmsFile.DetachedEntities + .Where(x => x.FilePath != dmsFile.FilePath) + .ToArray(); + + if (lyric == null) lyric = FindCompanion(others, baseName, true); + if (pic == null) pic = FindCompanion(others, baseName, false); + } + + if (lyric != null) dtl.Lrc = lyric.OrderKey; + if (pic != null) dtl.Pic = pic.OrderKey; + } + + /// + /// 在候选文件里找配得上 baseName 的歌词 / 图片。 + /// 类型用 FileTypePolicy 判断,避免自己维护一份扩展名清单。 + /// + /// 匹配优先级: + /// 1) 完全同名:song.mp3 配 song.lrc,最稳妥,命中就直接用 + /// 2) 音频名包含配套文件名:歌手-歌名.mp3 也能配上 歌名.lrc + /// 都用 OrdinalIgnoreCase,避免 Song.LRC / song.lrc 因大小写漏配。 + /// + private DmsFileUploadViewEntity FindCompanion(DmsFileUploadViewEntity[] candidates, string baseName, bool wantLyric) + { + if (candidates == null || candidates.Length == 0) return null; + + // 包含匹配的结果先存着,等确认没有完全同名的再用它 + DmsFileUploadViewEntity fuzzy = null; + + foreach (DmsFileUploadViewEntity item in candidates) + { + if (item == null) continue; + if (string.IsNullOrEmpty(item.OrgFileName) || string.IsNullOrEmpty(item.OrderKey)) continue; + + string name = Path.GetFileNameWithoutExtension(item.OrgFileName); + if (string.IsNullOrEmpty(name)) continue; + + bool typeMatched = wantLyric + ? FileTypePolicy.IsLyric(item.OrgFileName) + : FileTypePolicy.IsPhoto(item.OrgFileName); + if (!typeMatched) continue; + + if (string.Equals(name, baseName, StringComparison.OrdinalIgnoreCase)) return item; + + if (fuzzy == null && baseName.IndexOf(name, StringComparison.OrdinalIgnoreCase) >= 0) + fuzzy = item; + } + + return fuzzy; + } } } diff --git a/WebVue/src/components/basic/FileUpload.vue b/WebVue/src/components/basic/FileUpload.vue index af617ca..fc3d867 100644 --- a/WebVue/src/components/basic/FileUpload.vue +++ b/WebVue/src/components/basic/FileUpload.vue @@ -75,6 +75,10 @@ const props = withDefaults( chunkSize?: number; /** 请求超时时间(毫秒) */ timeout?: number; + /** 单片失败后的自动重试次数,0 表示不重试 */ + retryCount?: number; + /** 重试的初始等待毫秒数,之后按 1x、2x、4x 递增 */ + retryDelay?: number; }>(), { action: "", @@ -87,6 +91,8 @@ const props = withDefaults( buttonText: "上传文件", chunkSize: 1024 * 1024, timeout: 60000, + retryCount: 3, + retryDelay: 1000, } ); @@ -94,6 +100,10 @@ const emit = defineEmits<{ (e: "start", file: File): void; (e: "progress", payload: FileUploadProgress): void; (e: "success", payload: FileUploadResult): void; + /** 一批文件全部传完(多选时用它统一刷新一次,避免每个文件都刷) */ + (e: "complete", payload: { count: number }): void; + /** 上传中断:断点已保留,由外部决定续传还是放弃 */ + (e: "interrupted", payload: { fileName: string; uploaded: number; total: number }): void; (e: "error", error: unknown): void; }>(); @@ -148,64 +158,205 @@ const buildChunkName = ( }; /** 分片上传(顺序上传) */ +/** 计算进度需要的上下文:多文件时要能算到「整体」而不是单个文件内 */ +interface ChunkProgress { + /** 当前第几个文件,从 1 开始 */ + fileNo: number; + /** 本次共几个文件 */ + fileCount: number; + /** 本文件之前已经传完的字节数 */ + baseBytes: number; + /** 本次所有文件的总字节数,0 表示未知 */ + totalBytes: number; + /** 当前文件的大小 */ + fileSize: number; + /** 每成功一片回调,用来记续传位置 */ + onChunkDone?: (index: number) => void; +} + +/** 等待指定毫秒 */ +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * 发单个分片,失败自动重试(等待时间 1x → 2x → 4x 递增)。 + * 网络抖动、短暂断网在这一层就扛过去了, + * 只有全部重试都失败,才交给上层去弹「是否续传」。 + */ +const requestChunk = async ( + chunks: Blob[], + fileName: string, + uid: string, + index: number, + path: string, + /** 这片还没开始传时的百分比,重试时用它,避免进度条往回跳 */ + percentBefore: number +): Promise => { + const retries = props.retryCount > 0 ? props.retryCount : 0; + let lastError: unknown; + + for (let attempt = 0; attempt <= retries; attempt++) { + // 每次重新构造:FormData 发出去一次之后就不能再用了 + const formData = new FormData(); + const chunkName = buildChunkName(uid, index, chunks.length, fileName); + formData.append(fileName, chunks[index - 1], chunkName); + + try { + return await axios.create({ timeout: props.timeout }).request({ + url: props.action || defaultAction(), + method: "post", + data: formData, + headers: { + ...(props.headers || {}), + Authorization: useLoginProfileStore().getToken(), + "Content-Type": "multipart/form-data", + FilePath: path, + }, + }); + } catch (error) { + lastError = error; + if (attempt < retries) { + emit("progress", { + percent: percentBefore, + current: index, + total: chunks.length, + text: `网络异常,正在重试(${attempt + 1}/${retries})...`, + }); + await sleep(props.retryDelay * Math.pow(2, attempt)); + } + } + } + + throw lastError; +}; + const uploadChunk = async ( chunks: Blob[], fileName: string, uid: string, index: number, - path: string + path: string, + progress: ChunkProgress ): Promise => { - const formData = new FormData(); - const chunkName = buildChunkName(uid, index, chunks.length, fileName); - formData.append(fileName, chunks[index - 1], chunkName); + const chunkSize = props.chunkSize > 0 ? props.chunkSize : 1024 * 1024; - const response = await axios.create({ timeout: props.timeout }).request({ - url: props.action || defaultAction(), - method: "post", - data: formData, - headers: { - ...(props.headers || {}), - Authorization: useLoginProfileStore().getToken(), - "Content-Type": "multipart/form-data", - FilePath: path, - }, - }); + // 这片还没传时的进度:重试期间保持不动,不让进度条倒退 + const sentBefore = progress.baseBytes + Math.min((index - 1) * chunkSize, progress.fileSize); + const percentBefore = + progress.totalBytes > 0 + ? Math.min(100, Math.round((sentBefore / progress.totalBytes) * 100)) + : Math.round(((index - 1) / chunks.length) * 100); + + const response = await requestChunk(chunks, fileName, uid, index, path, percentBefore); + + // 整体进度按字节算:多文件时进度条一路向前,不会每个文件都退回 0 + const sentBytes = progress.baseBytes + Math.min(index * chunkSize, progress.fileSize); + const percent = + progress.totalBytes > 0 + ? Math.min(100, Math.round((sentBytes / progress.totalBytes) * 100)) + : Math.round((index / chunks.length) * 100); + + // 这一片已经落到服务器上了,记下位置,中断后从这里之后接着传 + progress.onChunkDone?.(index); emit("progress", { - percent: Math.round((index / chunks.length) * 100), + percent, current: index, total: chunks.length, - text: `上传中(${index}/${chunks.length})...`, + text: + progress.fileCount > 1 + ? `上传中(第 ${progress.fileNo}/${progress.fileCount} 个,分片 ${index}/${chunks.length}) ${percent}%` + : `上传中(${index}/${chunks.length})...`, }); if (index < chunks.length) { - return await uploadChunk(chunks, fileName, uid, index + 1, path); + return await uploadChunk(chunks, fileName, uid, index + 1, path, progress); } return response.data; }; -/** 上传入口 */ -const upload = async (file: File) => { - if (!file || typeof file.size !== "number") { - return; - } - if (uploading.value) { - return; - } +/** + * 一次上传的断点状态。 + * + * 只要不刷新页面,文件、切好的分片和 uid 都还在内存里, + * 中断时记住「下一个要传第几片」,续传就能接着发,不必从头再来。 + * 刷新页面就丢了——这是浏览器的限制,文件对象无法持久化。 + */ +interface UploadSession { + files: File[]; + path: string; + totalBytes: number; + /** 已完成文件的累计字节数 */ + doneBytes: number; + /** 正在传第几个文件(从 0 开始) */ + fileIndex: number; + /** 当前文件已切好的分片,续传时沿用 */ + chunks?: Blob[]; + /** 当前文件的 uid,续传时必须沿用,否则对不上服务器上的分片 */ + uid?: string; + /** 当前文件下一个要传的分片序号 */ + nextChunk: number; +} + +/** 未完成的上传会话,null 表示没有待续传的任务 */ +const session = ref(null); + +/** 是否还有待续传的上传 */ +const hasPendingUpload = computed(() => !!session.value); + +/** 执行或继续一次上传会话 */ +const runSession = async (s: UploadSession): Promise => { + session.value = s; uploading.value = true; - emit("start", file); + try { - const path = await resolvePath(); - const chunks = splitFile(file); - if (chunks.length === 0) { - throw new Error("无法读取文件内容"); + while (s.fileIndex < s.files.length) { + const file = s.files[s.fileIndex]; + + // 只有新文件才切片、生成 uid;续传沿用原来的,保证和服务器上的分片对得上 + if (!s.chunks || !s.uid) { + const chunks = splitFile(file); + if (chunks.length === 0) throw new Error("无法读取文件内容"); + s.chunks = chunks; + s.uid = uuidv4(); + s.nextChunk = 1; + } + + const chunks = s.chunks; + const uid = s.uid; + + const response = await uploadChunk(chunks, file.name, uid, s.nextChunk, s.path, { + fileNo: s.fileIndex + 1, + fileCount: s.files.length, + baseBytes: s.doneBytes, + totalBytes: s.totalBytes, + fileSize: file.size || 0, + onChunkDone: (index) => { + s.nextChunk = index + 1; + }, + }); + + // 返回分割后的最后一个(序号最大的)分片文件名 + const fileName = buildChunkName(uid, chunks.length, chunks.length, file.name); + emit("success", { file, path: s.path, total: chunks.length, fileName, response }); + + // 这个文件传完,进入下一个 + s.doneBytes += file.size || 0; + s.fileIndex += 1; + s.chunks = undefined; + s.uid = undefined; + s.nextChunk = 1; } - const uid = uuidv4(); - const response = await uploadChunk(chunks, file.name, uid, 1, path); - // 返回分割后的最后一个(序号最大的)分片文件名 - const fileName = buildChunkName(uid, chunks.length, chunks.length, file.name); - emit("success", { file, path, total: chunks.length, fileName, response }); + + session.value = null; + emit("complete", { count: s.files.length }); } catch (error) { + // 故意不清 session:留给外部决定续传还是放弃 + const file = s.files[s.fileIndex]; + emit("interrupted", { + fileName: file?.name || "", + uploaded: Math.max(0, s.nextChunk - 1), + total: s.chunks?.length ?? 0, + }); emit("error", error); } finally { uploading.value = false; @@ -213,8 +364,66 @@ const upload = async (file: File) => { } }; +/** + * 上传一批文件:按顺序一个一个传,不做并发, + * 免得后端同时收到多个分片流。多选时所有文件进同一个目录。 + */ +const uploadFiles = async (files: File[]): Promise => { + const list = (files || []).filter((f) => f && typeof f.size === "number"); + if (list.length === 0 || uploading.value) return; + + emit("start", list[0]); + try { + const path = await resolvePath(); + await runSession({ + files: list, + path, + totalBytes: list.reduce((sum, f) => sum + (f.size || 0), 0), + doneBytes: 0, + fileIndex: 0, + nextChunk: 1, + }); + } catch (error) { + emit("error", error); + } +}; + +/** 从断点继续上传:由外部在中断后调用 */ +const resume = async (): Promise => { + const s = session.value; + if (!s || uploading.value) return; + await runSession(s); +}; + +/** 放弃这次上传,清掉断点状态 */ +const discard = (): void => { + session.value = null; +}; + +/** 上传入口(供外部直接调用,单文件) */ +const upload = async (file: File) => { + await uploadFiles([file]); +}; + +/** + * antd 对多选的文件会连续同步调用 beforeUpload。 + * 这里先把文件攒起来,本轮结束后整批处理—— + * 否则每个文件各自起一轮上传,后面的会被 uploading 挡掉。 + */ +let pending: File[] = []; +let scheduled = false; + const handleBeforeUpload: UploadProps["beforeUpload"] = (file) => { - upload(file as unknown as File); + pending.push(file as unknown as File); + if (!scheduled) { + scheduled = true; + Promise.resolve().then(() => { + const files = pending; + pending = []; + scheduled = false; + uploadFiles(files); + }); + } return false; }; @@ -226,7 +435,7 @@ const trigger = () => { uploadRef.value?.$el?.querySelector("input")?.click(); }; -defineExpose({ trigger, upload }); +defineExpose({ trigger, upload, resume, discard, hasPendingUpload });