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