diff --git a/frontend/src/components/IngestTab.vue b/frontend/src/components/IngestTab.vue index dd857f5..4cb09cf 100644 --- a/frontend/src/components/IngestTab.vue +++ b/frontend/src/components/IngestTab.vue @@ -108,20 +108,44 @@
-
- - +
+ +
正在读取目录...
+
+
+ +
松开以开始上传
+
+
+ +
拖入音乐文件夹
+
支持音频文件和同目录歌词,上传完成后自动入库
+ + + 选择目录 + +
+ -
+
上传进度 ({{ uploadState.uploaded + uploadState.skipped }}/{{ uploadState.totalAudio + uploadState.totalLrc }})
-
+
上传失败 ({{ failedUploads.length }}) -
{{ fail.relPath }}
+
+ + +
@@ -407,8 +432,10 @@ import type { ProgressMessage } from '../composables/useWebSocket'; import { useAppState } from '../composables/useAppState'; import { BusinessError } from '../api/request'; import type { AxiosProgressEvent } from 'axios'; - import { uploadFile } from '../api/ingest'; +import { scanDataTransfer, normalizePath } from '../utils/dirScanner'; +import type { ScannedFile } from '../utils/dirScanner'; +import type { UploadFile, UploadInstance } from 'element-plus'; interface IngestProgressMessage extends ProgressMessage { @@ -459,7 +486,7 @@ const progress = reactive({ otherRejectedFiles: 0 }); -const canStart = computed(() => isConfigured.value && !submitting.value && !uploadState.uploading); +const canStart = computed(() => isConfigured.value && !submitting.value && uiStage.value === 'idle'); /** * 歌词统计(独立于 ProgressMessage 类型,避免修改共享类型)。 @@ -762,18 +789,162 @@ function stopPolling() { } } +async function onDrop(e: DragEvent) { + dragCounter = 0; + if ((uiStage.value !== 'idle' && uiStage.value !== 'dragging') || !isConfigured.value || submitting.value || running.value) return; + if (!e.dataTransfer) { + uiStage.value = 'idle'; + return; + } + + uiStage.value = 'scanning'; + const result = await scanDataTransfer(e.dataTransfer); + + if (!result.success) { + ElMessage.warning(result.error); + uiStage.value = 'idle'; + return; + } + + await processScannedFiles(result.files); +} + +function processSelectedFiles(files: File[]) { + if (!files.length) return; + const toUpload: ScannedFile[] = []; + + for (const file of files) { + const relPath = file.webkitRelativePath; + if (!relPath) { + ElMessage.error('无法获取相对路径,整个批次被拒绝'); + uiStage.value = 'idle'; + uploadRef.value?.clearFiles(); + return; + } + + const norm = normalizePath(relPath); + if (norm) { + toUpload.push({ file, relativePath: norm }); + } else { + ElMessage.error('选中的目录包含无效路径,整个批次被拒绝'); + uiStage.value = 'idle'; + uploadRef.value?.clearFiles(); + return; + } + } + + processScannedFiles(toUpload); +} + +async function processScannedFiles(scannedFiles: ScannedFile[]) { + let totalAudio = 0; + let totalLrc = 0; + let totalBytes = 0; + let ignored = 0; + const filteredFiles: ScannedFile[] = []; + + for (const item of scannedFiles) { + const name = item.file.name.toLowerCase(); + const isAudio = SUPPORTED_AUDIO_EXTS.some(ext => name.endsWith(ext)); + const isLrc = name.endsWith('.lrc'); + if (isAudio || isLrc) { + if (isAudio) totalAudio++; + else totalLrc++; + totalBytes += item.file.size; + filteredFiles.push(item); + } else { + ignored++; + } + } + + if (totalAudio === 0) { + ElMessage.warning('选中的目录未包含支持的音频文件'); + uiStage.value = 'idle'; + uploadState.ignored = ignored; + uploadRef.value?.clearFiles(); + return; + } + + batchContainsAudio = true; + + // 2. 初始化状态 + uiStage.value = 'uploading'; + + uploadState.totalAudio = totalAudio; + uploadState.totalLrc = totalLrc; + uploadState.ignored = ignored; + uploadState.totalBytes = totalBytes; + uploadState.uploadedBytes = 0; + uploadState.uploaded = 0; + uploadState.skipped = 0; + uploadState.failed = 0; + uploadState.percentage = 0; + uploadState.formattedSize = formatBytes(totalBytes); + + uploadAbortController = new AbortController(); + failedUploads.value = []; + + await doUploadLoop(filteredFiles.map(s => ({ file: s.file, relPath: s.relativePath })), totalAudio, totalLrc, totalBytes); +} /* ---------------- 上传与自动入库逻辑 ---------------- */ -const uploadInput = ref(null); +type UiStage = 'idle' | 'dragging' | 'scanning' | 'uploading' | 'failed'; +const uiStage = ref('idle'); +const uploadRef = ref(null); -function openDirectoryPicker() { - if (uploadInput.value) { - uploadInput.value.click(); +let dragCounter = 0; +function onDragEnter(e: DragEvent) { + if (uiStage.value === 'idle' || uiStage.value === 'dragging') { + dragCounter++; + uiStage.value = 'dragging'; + } +} +function onDragLeave(e: DragEvent) { + if (uiStage.value === 'dragging') { + dragCounter--; + if (dragCounter <= 0) { + dragCounter = 0; + uiStage.value = 'idle'; + } } } +function onDragLeaveDrop(e: DragEvent) { + dragCounter = 0; + if (uiStage.value === 'dragging') uiStage.value = 'idle'; +} +let batchContainsAudio = false; + +function resetUiStage() { + uiStage.value = 'idle'; + failedUploads.value = []; + batchContainsAudio = false; +} + +const SUPPORTED_AUDIO_EXTS = ['.mp3', '.flac', '.wav', '.m4a', '.ogg', '.opus', '.wma', '.ape', '.aiff', '.aif', '.wv', '.tta']; +const failedUploads = ref<{ file: File, relPath: string }[]>([]); + +let fileBatch: File[] = []; +let batchTimer: ReturnType | null = null; + +function onElUploadChange(file: UploadFile) { + if (file.raw) { + fileBatch.push(file.raw); + } + if (batchTimer) { + clearTimeout(batchTimer); + } + batchTimer = setTimeout(() => { + if (fileBatch.length > 0) { + processSelectedFiles([...fileBatch]); + fileBatch = []; + } + uploadRef.value?.clearFiles(); + batchTimer = null; + }, 100); +} + const uploadState = reactive({ - uploading: false, totalAudio: 0, totalLrc: 0, ignored: 0, @@ -797,74 +968,13 @@ function formatBytes(bytes: number) { return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; } -const SUPPORTED_AUDIO_EXTS = ['.mp3', '.flac', '.wav', '.m4a', '.ogg', '.opus', '.wma', '.ape', '.aiff', '.aif', '.wv', '.tta']; -const failedUploads = ref<{ file: File, relPath: string }[]>([]); - -async function onFilesSelected(e: Event) { - const target = e.target as HTMLInputElement; - const files = Array.from(target.files || []); - if (!files.length) return; - target.value = ''; // reset - - // 1. 过滤和统计 - const toUpload: { file: File, relPath: string }[] = []; - let totalAudio = 0; - let totalLrc = 0; - let ignored = 0; - let totalBytes = 0; - - for (const file of files) { - const name = file.name.toLowerCase(); - const isAudio = SUPPORTED_AUDIO_EXTS.some(ext => name.endsWith(ext)); - const isLrc = name.endsWith('.lrc'); - - if (isAudio || isLrc) { - if (isAudio) totalAudio++; - if (isLrc) totalLrc++; - totalBytes += file.size; - - // Strip root directory - const pathParts = file.webkitRelativePath.split('/'); - pathParts.shift(); // remove root dir - const relPath = pathParts.join('/'); - toUpload.push({ file, relPath }); - } else { - ignored++; - } - } - - if (totalAudio === 0) { - ElMessage.warning('选中的目录未包含支持的音频文件'); - return; - } - - // 2. 初始化状态 - uploadState.uploading = true; - uploadState.totalAudio = totalAudio; - uploadState.totalLrc = totalLrc; - uploadState.ignored = ignored; - uploadState.totalBytes = totalBytes; - uploadState.uploadedBytes = 0; - uploadState.uploaded = 0; - uploadState.skipped = 0; - uploadState.failed = 0; - uploadState.percentage = 0; - uploadState.formattedSize = formatBytes(totalBytes); - - uploadAbortController = new AbortController(); - failedUploads.value = []; // Reset previous failures on new full upload - - await doUploadLoop(toUpload, totalAudio, totalLrc, totalBytes); -} - async function doUploadLoop(items: { file: File, relPath: string }[], totalAudio: number, totalLrc: number, totalBytes: number) { - let hasAudioSuccess = false; let isCancelled = false; let previousFilesBytes = uploadState.uploadedBytes; // 3. 串行上传 for (const item of items) { - if (uploadAbortController.signal.aborted) { + if (uploadAbortController?.signal.aborted) { isCancelled = true; break; } @@ -874,11 +984,11 @@ async function doUploadLoop(items: { file: File, relPath: string }[], totalAudio let success = false; let attempts = 0; while (attempts < 3) { - if (uploadAbortController.signal.aborted) break; + if (uploadAbortController?.signal.aborted) break; attempts++; try { const resp = await uploadFile(item.file, item.relPath, { - signal: uploadAbortController.signal, + signal: uploadAbortController?.signal, timeout: 0, // No timeout onUploadProgress: (pEvent: AxiosProgressEvent) => { if (pEvent.loaded) { @@ -902,14 +1012,10 @@ async function doUploadLoop(items: { file: File, relPath: string }[], totalAudio uploadState.percentage = Math.floor((uploadState.uploadedBytes / totalBytes) * 100); } - if (SUPPORTED_AUDIO_EXTS.some(ext => item.file.name.toLowerCase().endsWith(ext))) { - hasAudioSuccess = true; - } - success = true; break; // Success } catch (err: unknown) { - if (axios.isCancel(err) || uploadAbortController.signal.aborted) { + if (axios.isCancel(err) || uploadAbortController?.signal.aborted) { break; } let status = 0; @@ -942,18 +1048,24 @@ async function doUploadLoop(items: { file: File, relPath: string }[], totalAudio isCancelled = true; } - uploadState.uploading = false; uploadAbortController = null; + uploadRef.value?.clearFiles(); if (isCancelled) { + batchContainsAudio = false; ElMessage.info('上传已取消,部分完成的文件已保留在目录中'); + uiStage.value = uploadState.failed > 0 ? 'failed' : 'idle'; } else { if (uploadState.failed > 0) { + uiStage.value = 'failed'; ElMessage.warning(`上传结束:失败 ${uploadState.failed} 个文件,自动入库已取消`); - } else if (hasAudioSuccess) { + } else if (batchContainsAudio) { + uiStage.value = 'idle'; ElMessage.success(`上传成功 (音频:${totalAudio}, 歌词:${totalLrc}),即将开始自动入库`); await startIngest(); + batchContainsAudio = false; } else { + uiStage.value = 'idle'; ElMessage.warning('上传结束,但没有成功的音频文件,未自动触发入库'); } } @@ -964,7 +1076,7 @@ async function retryFailedUploads() { const itemsToRetry = [...failedUploads.value]; failedUploads.value = []; // clear current failures list - uploadState.uploading = true; + uiStage.value = 'uploading'; uploadState.failed = 0; // reset for this retry session uploadState.uploadedBytes = 0; uploadState.percentage = 0; @@ -1163,6 +1275,11 @@ onMounted(async () => { onUnmounted(() => { wsDisconnect(); stopPolling(); + if (batchTimer) { + clearTimeout(batchTimer); + batchTimer = null; + } + fileBatch = []; // 交还所有权:若任务仍在跑,由外壳后台监视继续同步真实完成态。 releaseIngestViewOwnership(submitting.value); }); @@ -1962,6 +2079,21 @@ onUnmounted(() => { } } +@media (max-width: 480px) { + :deep(.custom-drag-upload .el-upload-dragger), .failed-uploads-box { + height: auto; + min-height: 120px; + padding: 10px; + } +} + +@media (prefers-reduced-motion: reduce) { + * { + animation: none !important; + transition: none !important; + } +} + @media (max-width: 390px) { .ingest-view { gap: 14px; @@ -1984,7 +2116,71 @@ onUnmounted(() => {