Template
feat: redesign directory upload workflow
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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: '扫描目录时发生错误' };
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user