feat: redesign directory upload workflow

This commit is contained in:
2026-07-21 20:47:07 +08:00
parent 59cf2ea67c
commit dcc1917580
2 changed files with 422 additions and 100 deletions
+301 -100
View File
@@ -108,20 +108,44 @@
<!-- 操作按钮 --> <!-- 操作按钮 -->
<div class="control-actions"> <div class="control-actions">
<div class="upload-area"> <div class="upload-area"
<input type="file" webkitdirectory multiple ref="uploadInput" style="display: none" @change="onFilesSelected" /> :class="{ 'upload-area--dragging': uiStage === 'dragging' }"
<button @dragenter.prevent="onDragEnter"
v-if="!uploadState.uploading && !running" @dragleave.prevent="onDragLeave"
class="btn btn-primary upload-btn" @dragover.prevent
type="button" @drop.prevent="onDragLeaveDrop">
:disabled="!canStart" <el-upload
@click="openDirectoryPicker" v-if="uiStage === 'idle' || uiStage === 'dragging' || uiStage === 'scanning'"
class="custom-drag-upload"
drag
directory
:auto-upload="false"
:show-file-list="false"
:disabled="!isConfigured || submitting || uiStage === 'scanning'"
@drop.capture.prevent.stop="onDrop"
@change="onElUploadChange"
ref="uploadRef"
> >
<app-icon name="upload" :size="16" /> <div v-if="uiStage === 'scanning'" class="upload-status">
<span>选择本地目录上传并入库</span> <app-icon name="refresh" :size="24" class="spin" />
</button> <div class="el-upload__text">正在读取目录...</div>
</div>
<div v-else-if="uiStage === 'dragging'" class="upload-status">
<app-icon name="upload" :size="24" />
<div class="el-upload__text">松开以开始上传</div>
</div>
<div v-else class="upload-status">
<app-icon name="upload" :size="24" />
<div class="el-upload__text main-text">拖入音乐文件夹</div>
<div class="el-upload__text sub-text">支持音频文件和同目录歌词上传完成后自动入库</div>
<span class="picker-action">
<app-icon name="folder" :size="14" />
<span>选择目录</span>
</span>
</div>
</el-upload>
<div v-if="uploadState.uploading" class="upload-progress-box"> <div v-if="uiStage === 'uploading'" class="upload-progress-box">
<div class="upload-header"> <div class="upload-header">
<span>上传进度 ({{ uploadState.uploaded + uploadState.skipped }}/{{ uploadState.totalAudio + uploadState.totalLrc }})</span> <span>上传进度 ({{ uploadState.uploaded + uploadState.skipped }}/{{ uploadState.totalAudio + uploadState.totalLrc }})</span>
<button class="icon-btn cancel-btn" @click="cancelUpload" title="取消上传"> <button class="icon-btn cancel-btn" @click="cancelUpload" title="取消上传">
@@ -148,23 +172,24 @@
</div> </div>
<!-- 失败文件列表及重试 --> <!-- 失败文件列表及重试 -->
<div v-if="!uploadState.uploading && !running && failedUploads.length > 0" class="failed-uploads-box"> <div v-if="uiStage === 'failed'" class="failed-uploads-box failed-stage">
<div class="failed-header"> <div class="failed-header">
<span class="c-error">上传失败 ({{ failedUploads.length }})</span> <span class="c-error">上传失败 ({{ failedUploads.length }})</span>
<button
class="btn btn-ghost btn-sm"
type="button"
@click="retryFailedUploads"
>
<app-icon name="refresh" :size="14" />
<span>重试</span>
</button>
</div> </div>
<div class="failed-list c-scroll"> <div class="failed-list c-scroll">
<div v-for="fail in failedUploads" :key="fail.relPath" class="failed-item" :title="fail.relPath"> <div v-for="fail in failedUploads" :key="fail.relPath" class="failed-item" :title="fail.relPath">
{{ fail.relPath }} {{ fail.relPath }}
</div> </div>
</div> </div>
<div class="failed-actions">
<button class="btn btn-primary btn-sm" type="button" @click="retryFailedUploads">
<app-icon name="refresh" :size="14" />
<span>重试失败项</span>
</button>
<button class="btn btn-ghost btn-sm" type="button" @click="resetUiStage">
重新选择目录
</button>
</div>
</div> </div>
</div> </div>
@@ -407,8 +432,10 @@ import type { ProgressMessage } from '../composables/useWebSocket';
import { useAppState } from '../composables/useAppState'; import { useAppState } from '../composables/useAppState';
import { BusinessError } from '../api/request'; import { BusinessError } from '../api/request';
import type { AxiosProgressEvent } from 'axios'; import type { AxiosProgressEvent } from 'axios';
import { uploadFile } from '../api/ingest'; 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 { interface IngestProgressMessage extends ProgressMessage {
@@ -459,7 +486,7 @@ const progress = reactive<ProgressMessage>({
otherRejectedFiles: 0 otherRejectedFiles: 0
}); });
const canStart = computed(() => isConfigured.value && !submitting.value && !uploadState.uploading); const canStart = computed(() => isConfigured.value && !submitting.value && uiStage.value === 'idle');
/** /**
* 歌词统计(独立于 ProgressMessage 类型,避免修改共享类型)。 * 歌词统计(独立于 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<HTMLInputElement | null>(null); type UiStage = 'idle' | 'dragging' | 'scanning' | 'uploading' | 'failed';
const uiStage = ref<UiStage>('idle');
const uploadRef = ref<UploadInstance | null>(null);
function openDirectoryPicker() { let dragCounter = 0;
if (uploadInput.value) { function onDragEnter(e: DragEvent) {
uploadInput.value.click(); 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<typeof setTimeout> | 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({ const uploadState = reactive({
uploading: false,
totalAudio: 0, totalAudio: 0,
totalLrc: 0, totalLrc: 0,
ignored: 0, ignored: 0,
@@ -797,74 +968,13 @@ function formatBytes(bytes: number) {
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; 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) { async function doUploadLoop(items: { file: File, relPath: string }[], totalAudio: number, totalLrc: number, totalBytes: number) {
let hasAudioSuccess = false;
let isCancelled = false; let isCancelled = false;
let previousFilesBytes = uploadState.uploadedBytes; let previousFilesBytes = uploadState.uploadedBytes;
// 3. 串行上传 // 3. 串行上传
for (const item of items) { for (const item of items) {
if (uploadAbortController.signal.aborted) { if (uploadAbortController?.signal.aborted) {
isCancelled = true; isCancelled = true;
break; break;
} }
@@ -874,11 +984,11 @@ async function doUploadLoop(items: { file: File, relPath: string }[], totalAudio
let success = false; let success = false;
let attempts = 0; let attempts = 0;
while (attempts < 3) { while (attempts < 3) {
if (uploadAbortController.signal.aborted) break; if (uploadAbortController?.signal.aborted) break;
attempts++; attempts++;
try { try {
const resp = await uploadFile(item.file, item.relPath, { const resp = await uploadFile(item.file, item.relPath, {
signal: uploadAbortController.signal, signal: uploadAbortController?.signal,
timeout: 0, // No timeout timeout: 0, // No timeout
onUploadProgress: (pEvent: AxiosProgressEvent) => { onUploadProgress: (pEvent: AxiosProgressEvent) => {
if (pEvent.loaded) { if (pEvent.loaded) {
@@ -902,14 +1012,10 @@ async function doUploadLoop(items: { file: File, relPath: string }[], totalAudio
uploadState.percentage = Math.floor((uploadState.uploadedBytes / totalBytes) * 100); uploadState.percentage = Math.floor((uploadState.uploadedBytes / totalBytes) * 100);
} }
if (SUPPORTED_AUDIO_EXTS.some(ext => item.file.name.toLowerCase().endsWith(ext))) {
hasAudioSuccess = true;
}
success = true; success = true;
break; // Success break; // Success
} catch (err: unknown) { } catch (err: unknown) {
if (axios.isCancel(err) || uploadAbortController.signal.aborted) { if (axios.isCancel(err) || uploadAbortController?.signal.aborted) {
break; break;
} }
let status = 0; let status = 0;
@@ -942,18 +1048,24 @@ async function doUploadLoop(items: { file: File, relPath: string }[], totalAudio
isCancelled = true; isCancelled = true;
} }
uploadState.uploading = false;
uploadAbortController = null; uploadAbortController = null;
uploadRef.value?.clearFiles();
if (isCancelled) { if (isCancelled) {
batchContainsAudio = false;
ElMessage.info('上传已取消,部分完成的文件已保留在目录中'); ElMessage.info('上传已取消,部分完成的文件已保留在目录中');
uiStage.value = uploadState.failed > 0 ? 'failed' : 'idle';
} else { } else {
if (uploadState.failed > 0) { if (uploadState.failed > 0) {
uiStage.value = 'failed';
ElMessage.warning(`上传结束:失败 ${uploadState.failed} 个文件,自动入库已取消`); ElMessage.warning(`上传结束:失败 ${uploadState.failed} 个文件,自动入库已取消`);
} else if (hasAudioSuccess) { } else if (batchContainsAudio) {
uiStage.value = 'idle';
ElMessage.success(`上传成功 (音频:${totalAudio}, 歌词:${totalLrc}),即将开始自动入库`); ElMessage.success(`上传成功 (音频:${totalAudio}, 歌词:${totalLrc}),即将开始自动入库`);
await startIngest(); await startIngest();
batchContainsAudio = false;
} else { } else {
uiStage.value = 'idle';
ElMessage.warning('上传结束,但没有成功的音频文件,未自动触发入库'); ElMessage.warning('上传结束,但没有成功的音频文件,未自动触发入库');
} }
} }
@@ -964,7 +1076,7 @@ async function retryFailedUploads() {
const itemsToRetry = [...failedUploads.value]; const itemsToRetry = [...failedUploads.value];
failedUploads.value = []; // clear current failures list failedUploads.value = []; // clear current failures list
uploadState.uploading = true; uiStage.value = 'uploading';
uploadState.failed = 0; // reset for this retry session uploadState.failed = 0; // reset for this retry session
uploadState.uploadedBytes = 0; uploadState.uploadedBytes = 0;
uploadState.percentage = 0; uploadState.percentage = 0;
@@ -1163,6 +1275,11 @@ onMounted(async () => {
onUnmounted(() => { onUnmounted(() => {
wsDisconnect(); wsDisconnect();
stopPolling(); stopPolling();
if (batchTimer) {
clearTimeout(batchTimer);
batchTimer = null;
}
fileBatch = [];
// 交还所有权:若任务仍在跑,由外壳后台监视继续同步真实完成态。 // 交还所有权:若任务仍在跑,由外壳后台监视继续同步真实完成态。
releaseIngestViewOwnership(submitting.value); 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) { @media (max-width: 390px) {
.ingest-view { .ingest-view {
gap: 14px; gap: 14px;
@@ -1984,7 +2116,71 @@ onUnmounted(() => {
<style scoped> <style scoped>
.upload-area { .upload-area {
margin-bottom: 10px; margin-bottom: 10px;
min-height: 180px;
} }
.custom-drag-upload,
.custom-drag-upload :deep(.el-upload),
.custom-drag-upload :deep(.el-upload-dragger) {
width: 100%;
}
.custom-drag-upload :deep(.el-upload-dragger) {
min-height: 150px;
background-color: var(--c-bg-inset, #2a2a2a);
border-radius: 8px;
border: 1px dashed var(--c-border, #ccc);
transition: border-color 0.2s, background-color 0.2s;
}
.upload-area--dragging .custom-drag-upload :deep(.el-upload-dragger) {
border-color: var(--c-accent, #10b981);
background-color: rgba(16, 185, 129, 0.1);
}
.custom-drag-upload:focus-visible {
outline: 2px solid var(--c-accent, #10b981);
outline-offset: 2px;
}
.upload-status {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.main-text {
font-size: 14px;
font-weight: 700;
}
.sub-text {
font-size: 12px;
color: var(--c-text-secondary, #9ca3af);
white-space: pre-wrap;
}
.picker-action {
min-height: 36px;
padding: 0 12px;
border: 1px solid var(--c-border);
border-radius: var(--c-radius-sm);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
color: var(--c-accent-text);
background: rgba(16, 185, 129, 0.06);
font-size: 12px;
font-weight: 600;
}
.custom-drag-upload:hover .picker-action {
border-color: var(--c-accent);
background: rgba(16, 185, 129, 0.12);
}
.upload-progress-box { .upload-progress-box {
padding: 10px; padding: 10px;
border: 1px solid var(--c-border); border: 1px solid var(--c-border);
@@ -2033,6 +2229,11 @@ onUnmounted(() => {
font-size: 12px; font-size: 12px;
font-weight: bold; font-weight: bold;
} }
.failed-actions {
margin-top: 8px;
display: flex;
gap: 8px;
}
.btn-sm { .btn-sm {
padding: 4px 8px; padding: 4px 8px;
font-size: 11px; font-size: 11px;
+121
View File
@@ -0,0 +1,121 @@
export interface ScannedFile {
file: File;
relativePath: string;
}
export type ScanResult =
| { success: true; files: ScannedFile[] }
| { success: false; error: string };
export function normalizePath(rawPath: string): string | null {
if (!rawPath) return null;
const normalized = rawPath.replace(/\\/g, '/');
if (normalized.startsWith('/')) return null;
if (/^[a-zA-Z]:\//.test(normalized)) return null;
const parts = normalized.split('/');
if (parts.includes('') || parts.includes('.') || parts.includes('..')) return null;
if (parts.length < 2) return null; // Needs at least top dir and file name
parts.shift(); // Remove EXACTLY one top-level directory
const finalPath = parts.join('/');
if (!finalPath) return null;
return finalPath;
}
function readEntriesPromise(reader: FileSystemDirectoryReader): Promise<FileSystemEntry[]> {
return new Promise((resolve, reject) => {
reader.readEntries(resolve, reject);
});
}
async function readAllEntries(reader: FileSystemDirectoryReader): Promise<FileSystemEntry[]> {
const entries: FileSystemEntry[] = [];
while (true) {
const batch = await readEntriesPromise(reader);
if (batch.length === 0) break;
entries.push(...batch);
}
return entries;
}
async function getFilePromise(fileEntry: FileSystemFileEntry): Promise<File> {
return new Promise((resolve, reject) => {
fileEntry.file(resolve, reject);
});
}
async function scanDirectory(dirEntry: FileSystemDirectoryEntry, pathPrefix: string, scannedFiles: ScannedFile[]): Promise<void> {
const reader = dirEntry.createReader();
const entries = await readAllEntries(reader);
for (const entry of entries) {
if (entry.isDirectory) {
await scanDirectory(entry as FileSystemDirectoryEntry, `${pathPrefix}${entry.name}/`, scannedFiles);
} else if (entry.isFile) {
const fileEntry = entry as FileSystemFileEntry;
const file = await getFilePromise(fileEntry);
const relativePath = `${pathPrefix}${entry.name}`;
scannedFiles.push({ file, relativePath });
}
}
}
export async function scanDataTransfer(dataTransfer: DataTransfer): Promise<ScanResult> {
const items = dataTransfer.items;
if (!items || items.length === 0) {
return { success: false, error: '未检测到文件或目录' };
}
const entries: FileSystemEntry[] = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.webkitGetAsEntry) {
const entry = item.webkitGetAsEntry();
if (entry) {
entries.push(entry);
}
}
}
if (entries.length === 0) {
return { success: false, error: '无法读取拖拽内容' };
}
// Reject multiple top-level directories or loose files
if (entries.length > 1) {
return { success: false, error: '请每次仅拖入或选择一个目录' };
}
const rootEntry = entries[0];
if (!rootEntry.isDirectory) {
return { success: false, error: '请拖入或选择一个目录,而不是单个文件' };
}
try {
const scannedFiles: ScannedFile[] = [];
// Start scanning with the top-level directory name in the path prefix
await scanDirectory(rootEntry as FileSystemDirectoryEntry, `${rootEntry.name}/`, scannedFiles);
// Filter out invalid paths if any
const validFiles: ScannedFile[] = [];
for (const item of scannedFiles) {
const norm = normalizePath(item.relativePath);
if (norm) {
validFiles.push({ file: item.file, relativePath: norm });
} else {
return { success: false, error: '目录包含无效的路径格式' };
}
}
if (validFiles.length === 0) {
return { success: false, error: '选中的目录为空或没有有效文件' };
}
return { success: true, files: validFiles };
} catch (err: unknown) {
return { success: false, error: '扫描目录时发生错误' };
}
}