Template
feat: redesign directory upload workflow
This commit is contained in:
@@ -108,20 +108,44 @@
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="control-actions">
|
||||
<div class="upload-area">
|
||||
<input type="file" webkitdirectory multiple ref="uploadInput" style="display: none" @change="onFilesSelected" />
|
||||
<button
|
||||
v-if="!uploadState.uploading && !running"
|
||||
class="btn btn-primary upload-btn"
|
||||
type="button"
|
||||
:disabled="!canStart"
|
||||
@click="openDirectoryPicker"
|
||||
<div class="upload-area"
|
||||
:class="{ 'upload-area--dragging': uiStage === 'dragging' }"
|
||||
@dragenter.prevent="onDragEnter"
|
||||
@dragleave.prevent="onDragLeave"
|
||||
@dragover.prevent
|
||||
@drop.prevent="onDragLeaveDrop">
|
||||
<el-upload
|
||||
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" />
|
||||
<span>选择本地目录上传并入库</span>
|
||||
</button>
|
||||
<div v-if="uiStage === 'scanning'" class="upload-status">
|
||||
<app-icon name="refresh" :size="24" class="spin" />
|
||||
<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">
|
||||
<span>上传进度 ({{ uploadState.uploaded + uploadState.skipped }}/{{ uploadState.totalAudio + uploadState.totalLrc }})</span>
|
||||
<button class="icon-btn cancel-btn" @click="cancelUpload" title="取消上传">
|
||||
@@ -148,23 +172,24 @@
|
||||
</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">
|
||||
<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 class="failed-list c-scroll">
|
||||
<div v-for="fail in failedUploads" :key="fail.relPath" class="failed-item" :title="fail.relPath">
|
||||
{{ fail.relPath }}
|
||||
</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>
|
||||
|
||||
@@ -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<ProgressMessage>({
|
||||
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<HTMLInputElement | null>(null);
|
||||
type UiStage = 'idle' | 'dragging' | 'scanning' | 'uploading' | 'failed';
|
||||
const uiStage = ref<UiStage>('idle');
|
||||
const uploadRef = ref<UploadInstance | null>(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<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({
|
||||
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(() => {
|
||||
<style scoped>
|
||||
.upload-area {
|
||||
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 {
|
||||
padding: 10px;
|
||||
border: 1px solid var(--c-border);
|
||||
@@ -2033,6 +2229,11 @@ onUnmounted(() => {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.failed-actions {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.btn-sm {
|
||||
padding: 4px 8px;
|
||||
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