-
+
+
+
+
+
@@ -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(() => {