feat: add batch upload queue with per-file progress
This commit is contained in:
1 parent
b6cfdfeca7
commit
3581f62139
3 files changed
+153
-33
No files matched your search
@@ -45,6 +45,8 @@ const acceptDirectTask = value => {
|
||||
const previous = directTasks.value.find(task => task.id === value.id);
|
||||
directTasks.value = mergeTasks(directTasks.value, [value]);
|
||||
const task = directTasks.value.find(item => item.id === value.id);
|
||||
const sessionForTask = sessions.get(value.client_id || task?.client_id);
|
||||
sessionForTask?.onItem?.(sessionForTask.file, { status: value.status === 'failed' ? 'failed' : value.status === 'ready' ? 'success' : 'uploading', task: value, image: value.image || undefined, percent: value.percent, loaded: value.loaded, total: value.total, error: value.message || value.clientError }, sessionForTask.clientId);
|
||||
if (task && ['receiving', 'queued', 'processing', 'ready', 'cancelled'].includes(task.status)) {
|
||||
patchTask(taskKey(task), { clientError: '', clientRetryable: false });
|
||||
const session = sessions.get(taskKey(task));
|
||||
@@ -96,9 +98,14 @@ const runSession = async session => {
|
||||
patchTask(key, { busy: true, clientError: '', clientRetryable: false, hasFile: !!session.file });
|
||||
try {
|
||||
await runDirectUpload(session, { api: directApi, signal: controller.signal,
|
||||
onTask: acceptDirectTask, onState: patch => patchTask(key, patch) });
|
||||
onTask: acceptDirectTask, onState: patch => { patchTask(key, patch); session.onItem?.(session.file, { status: 'uploading', ...patch }, session.clientId); } });
|
||||
} catch (error) {
|
||||
if (error.name !== 'AbortError') taskError({ client_id: key }, error);
|
||||
if (error.name !== 'AbortError') {
|
||||
taskError({ client_id: key }, error);
|
||||
session.onItem?.(session.file, { status: 'failed', error: safeMessage(error.message, '上传失败') }, session.clientId);
|
||||
} else {
|
||||
session.onItem?.(session.file, { status: 'failed', error: '已停止传输' }, session.clientId);
|
||||
}
|
||||
} finally {
|
||||
if (taskControllers.get(key) === controller) {
|
||||
taskControllers.delete(key);
|
||||
@@ -107,7 +114,7 @@ const runSession = async session => {
|
||||
if (!isDisposed()) refreshDirectTasks();
|
||||
}
|
||||
};
|
||||
const startDirectFiles = async files => {
|
||||
const startDirectFiles = async (files, onItem = () => {}) => {
|
||||
const activeCount = directTasks.value.filter(task => task.busy || ['awaiting_upload', 'receiving', 'queued', 'processing'].includes(task.status)).length;
|
||||
if (files.length + activeCount > MAX_RECENT_TASKS) {
|
||||
Message.warning(`最多同时保留 ${MAX_RECENT_TASKS} 个未完成任务,请先完成或取消已有任务后再选择文件`);
|
||||
@@ -118,7 +125,7 @@ const startDirectFiles = async files => {
|
||||
try {
|
||||
for (const file of files) {
|
||||
const clientId = newClientId();
|
||||
const session = { clientId, bucketId: selectedBucket.value, folderId, file,
|
||||
const session = { clientId, bucketId: selectedBucket.value, folderId, file, onItem,
|
||||
previewOnReady: files.length === 1 };
|
||||
sessions.set(clientId, session);
|
||||
directTasks.value = mergeTasks(directTasks.value, [{ client_id: clientId, filename: file.name,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { fileValidationMessage } from './uploadValidation.js';
|
||||
import { uploadOutcome } from './batchResults.js';
|
||||
import { boundedPage, RECENT_IMAGE_LIMIT } from './renderBounds.js';
|
||||
import { xhrUpload, mergeImages, safeMessage } from './directUpload.js';
|
||||
export function createHomeUploadActions({ API_BASE_URL, isDragOver, isUploading, fileInput, resumeTask, handleResumeFile, clientUploadLimits, uploadConfig, storageConfigLoaded, directEnabled, startDirectFiles, legacyUploading, uploadingCount, uploadProgress, getUploadFolderId = () => 0, multiStorageSync, selectedBucket, recentImages, loadRecentImages, isDisposed, onSingleUpload = () => {} }) {
|
||||
export function createHomeUploadActions({ API_BASE_URL, isDragOver, isUploading, fileInput, resumeTask, handleResumeFile, clientUploadLimits, uploadConfig, storageConfigLoaded, directEnabled, startDirectFiles, legacyUploading, uploadingCount, uploadProgress, getUploadFolderId = () => 0, multiStorageSync, selectedBucket, recentImages, loadRecentImages, isDisposed, onSingleUpload = () => {}, onFilesReady = () => {} }) {
|
||||
let legacyController;
|
||||
const authHeaders = () => ({});
|
||||
const handleDragOver = () => {
|
||||
@@ -28,7 +28,7 @@ const handleDrop = (e) => {
|
||||
const validFiles = validateFiles(files);
|
||||
|
||||
if (validFiles.length > 0) {
|
||||
uploadFiles(validFiles);
|
||||
onFilesReady(validFiles);
|
||||
} else {
|
||||
Message.error('请拖拽有效的 JPG、PNG、GIF 或 WebP 图片;SVG 不允许上传', {
|
||||
duration: 3000,
|
||||
@@ -53,7 +53,7 @@ const handleFileSelect = (e) => {
|
||||
if (files.length > 0) {
|
||||
const validFiles = validateFiles(files);
|
||||
if (validFiles.length > 0) {
|
||||
uploadFiles(validFiles);
|
||||
onFilesReady(validFiles);
|
||||
}
|
||||
}
|
||||
e.target.value = ''; // 清空文件选择
|
||||
@@ -86,7 +86,7 @@ const handlePaste = async (e) => {
|
||||
e.preventDefault();
|
||||
const valid = validateFiles(imageFiles);
|
||||
if (!valid.length) return;
|
||||
uploadFiles(valid);
|
||||
onFilesReady(valid);
|
||||
Message.success(`从剪贴板粘贴了 ${valid.length} 个图片`, {
|
||||
duration: 2000,
|
||||
position: 'top-right'
|
||||
@@ -109,7 +109,7 @@ const validateFiles = (files) => {
|
||||
});
|
||||
};
|
||||
|
||||
const uploadFiles = async (files) => {
|
||||
const uploadFiles = async (files, onItem = () => {}) => {
|
||||
if (isUploading.value) return;
|
||||
if (!storageConfigLoaded.value) {
|
||||
Message.warning('存储配置正在加载,请稍后重试');
|
||||
@@ -118,40 +118,56 @@ const uploadFiles = async (files) => {
|
||||
|
||||
files = validateFiles(files);
|
||||
if (!files.length) return;
|
||||
if (directEnabled()) { await startDirectFiles(files); return; }
|
||||
if (directEnabled()) { await startDirectFiles(files, onItem); return; }
|
||||
legacyUploading.value = true;
|
||||
legacyController = new AbortController();
|
||||
uploadingCount.value = files.length;
|
||||
uploadProgress.value = 0;
|
||||
|
||||
let activeFile = null;
|
||||
try {
|
||||
const formData = new FormData();
|
||||
files.forEach(file => {
|
||||
const images = [];
|
||||
const errors = [];
|
||||
for (const file of files) {
|
||||
if (isDisposed()) return;
|
||||
activeFile = file;
|
||||
const formData = new FormData();
|
||||
formData.append('images[]', file);
|
||||
});
|
||||
|
||||
// Snapshot before any transfer; never send removed upload-tag state.
|
||||
formData.append('folder_id', String(getUploadFolderId()));
|
||||
if (!multiStorageSync.value) {
|
||||
formData.append('bucket_id', selectedBucket.value || '1');
|
||||
formData.append('folder_id', String(getUploadFolderId()));
|
||||
if (!multiStorageSync.value) formData.append('bucket_id', selectedBucket.value || '1');
|
||||
onItem(file, { status: 'uploading', percent: 0, loaded: 0, total: file.size });
|
||||
try {
|
||||
const { result } = await xhrUpload({ url: `${API_BASE_URL}/api/upload/images`, body: formData, returnEnvelope: true,
|
||||
headers: authHeaders(), signal: legacyController.signal,
|
||||
onProgress: ({ loaded, total, percent }) => {
|
||||
uploadProgress.value = percent;
|
||||
onItem(file, { status: 'uploading', percent, loaded, total: total || file.size });
|
||||
} });
|
||||
const outcome = uploadOutcome(result, 1);
|
||||
images.push(...outcome.images);
|
||||
errors.push(...outcome.errors);
|
||||
if (outcome.failed) onItem(file, { status: 'failed', error: outcome.errors[0]?.message || result.message || '上传失败' });
|
||||
else {
|
||||
onItem(file, { status: 'success', percent: 100, loaded: file.size, total: file.size, image: outcome.images[0] });
|
||||
if (files.length === 1 && outcome.images[0]) onSingleUpload(outcome.images[0]);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') throw error;
|
||||
const message = safeMessage(error.message, '上传失败');
|
||||
errors.push({ filename: file.name, message });
|
||||
onItem(file, { status: 'failed', error: message });
|
||||
}
|
||||
}
|
||||
const { result } = await xhrUpload({ url: `${API_BASE_URL}/api/upload/images`, body: formData, returnEnvelope: true,
|
||||
headers: authHeaders(), signal: legacyController.signal,
|
||||
onProgress: ({ percent }) => { uploadProgress.value = percent; } });
|
||||
if (isDisposed()) return;
|
||||
const outcome = uploadOutcome(result, files.length);
|
||||
recentImages.value = boundedPage(mergeImages(recentImages.value, outcome.images), RECENT_IMAGE_LIMIT);
|
||||
if (images.length) recentImages.value = boundedPage(mergeImages(recentImages.value, images), RECENT_IMAGE_LIMIT);
|
||||
await loadRecentImages();
|
||||
if (outcome.failed) {
|
||||
const detail = outcome.errors.map(item => `${item.filename || item.file || '文件'}:${item.message || item.error || '上传失败'}`).join(';');
|
||||
Message.warning(`成功 ${outcome.images.length} 个,失败 ${outcome.failed} 个。成功文件已保留,请勿整批重传。${detail || result.message || ''}`, { duration: 7000, showClose: true });
|
||||
if (errors.length) {
|
||||
const detail = errors.map(item => `${item.filename || item.file || '文件'}:${item.message || item.error || '上传失败'}`).join(';');
|
||||
Message.warning(`成功 ${images.length} 个,失败 ${errors.length} 个。成功文件已保留,请勿整批重传。${detail}`, { duration: 7000, showClose: true });
|
||||
} else {
|
||||
Message.success(multiStorageSync.value ? '已保存到本机,正在后台同步' : `上传成功 ${outcome.images.length} 个`, { duration: 2000, position: 'top-right' });
|
||||
}
|
||||
if (!isDisposed() && files.length === 1 && !outcome.failed && outcome.images.length === 1) {
|
||||
onSingleUpload(outcome.images[0]);
|
||||
Message.success(multiStorageSync.value ? '已保存到本机,正在后台同步' : `上传成功 ${images.length} 个`, { duration: 2000, position: 'top-right' });
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError' && activeFile) onItem(activeFile, { status: 'failed', error: '已停止传输;服务器可能已接收部分文件,请检查最近上传' });
|
||||
if (!isDisposed()) {
|
||||
if (error.name === 'AbortError') Message.info('已停止上传请求;已接收的文件仍可能完成,请检查最近上传');
|
||||
else Message.error(`上传失败: ${safeMessage(error.message)}`, { duration: 3000, position: 'top-right', showClose: true });
|
||||
|
||||
@@ -36,8 +36,8 @@
|
||||
<div class="upload-icon mb-2.5 text-4xl text-slate-900 dark:text-white sm:text-[42px]">
|
||||
<i class="ri-upload-cloud-line"></i>
|
||||
</div>
|
||||
<h3 class="mb-1.5 text-base font-semibold text-slate-900 dark:text-white">拖拽图片到此处,或点击立即上传</h3>
|
||||
<p class="mx-auto mb-3 max-w-md text-sm leading-5 text-slate-500 dark:text-slate-400">支持常见图片格式、剪贴板和 URL 上传。</p>
|
||||
<h3 class="mb-1.5 text-base font-semibold text-slate-900 dark:text-white">拖拽图片到此处,或点击加入上传队列</h3>
|
||||
<p class="mx-auto mb-3 max-w-md text-sm leading-5 text-slate-500 dark:text-slate-400">先整理要上传的图片,再点击“开始上传”;支持常见图片格式和剪贴板。</p>
|
||||
<p v-if="isGuest" class="mx-auto mb-3 max-w-md text-xs text-secondary">游客图片依赖本浏览器的安全 Cookie。清除 Cookie 或更换浏览器后无法找回,请注册账户保存长期身份。</p>
|
||||
<div class="flex flex-col items-stretch justify-center gap-2 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<button class="primary-button w-full px-4 py-2 sm:w-auto">
|
||||
@@ -77,6 +77,43 @@
|
||||
class="hidden"
|
||||
/>
|
||||
|
||||
<section v-if="uploadQueue.length" class="content-panel home-panel-compact mt-3 space-y-3" aria-labelledby="upload-queue-title">
|
||||
<div class="flex flex-col gap-2 border-b border-slate-200/70 pb-2.5 sm:flex-row sm:items-center sm:justify-between dark:border-white/10">
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 id="upload-queue-title" class="text-sm font-semibold text-slate-900 dark:text-white">上传队列</h2>
|
||||
<span class="text-xs text-slate-500 dark:text-slate-400">{{ uploadQueue.length }} 个文件</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button v-if="queueHasFinished" type="button" class="soft-button px-3 py-1.5 text-xs" :disabled="queueBusy" @click="clearFinishedQueue">清除已完成</button>
|
||||
<button v-if="!queueBusy" type="button" class="soft-button px-3 py-1.5 text-xs" :disabled="!queueHasRemovable" @click="clearUploadQueue">清空队列</button>
|
||||
<button v-if="queueHasPending" type="button" class="primary-button px-3 py-1.5 text-xs" :disabled="queueBusy || !storageConfigLoaded" @click="startQueuedUpload"><i class="ri-upload-2-line" aria-hidden="true"></i>开始上传</button>
|
||||
<button v-if="queueBusy && !directEnabled()" type="button" class="soft-button px-3 py-1.5 text-xs" @click="cancelLegacyUpload">停止上传</button>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="divide-y divide-slate-200/70 dark:divide-white/10">
|
||||
<li v-for="item in uploadQueue" :key="item.id" class="flex min-w-0 items-start gap-3 py-3 first:pt-0 last:pb-0">
|
||||
<div class="relative h-12 w-12 shrink-0 overflow-hidden rounded border border-slate-200 bg-slate-100 dark:border-white/10 dark:bg-slate-900">
|
||||
<img v-if="item.previewUrl" :src="item.previewUrl" :alt="`${item.file.name} 预览`" class="h-full w-full object-cover" loading="lazy" decoding="async" />
|
||||
<i v-else class="ri-image-line absolute inset-0 grid place-items-center text-lg text-slate-400" aria-hidden="true"></i>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
|
||||
<p class="min-w-0 flex-1 truncate text-sm font-medium text-slate-800 dark:text-slate-100" :title="item.file.name">{{ item.file.name }}</p>
|
||||
<span class="shrink-0 text-xs text-slate-500 dark:text-slate-400">{{ formatFileSize(item.loaded ?? item.file.size) }}<template v-if="item.status === 'uploading'"> / {{ formatFileSize(item.total || item.file.size) }}</template></span>
|
||||
</div>
|
||||
<div class="mt-1.5 flex items-center justify-between gap-2 text-xs">
|
||||
<span :class="queueStatusClass(item.status)">{{ queueStatusLabel(item.status) }}</span>
|
||||
<span v-if="item.status === 'uploading' && item.percent != null" class="tabular-nums text-slate-600 dark:text-slate-300">{{ Math.round(item.percent) }}%</span>
|
||||
<button v-if="item.status === 'queued' || item.status === 'failed'" type="button" class="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded text-slate-500 hover:bg-slate-100 hover:text-red-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary dark:hover:bg-white/10" :aria-label="`移除 ${item.file.name}`" title="移除文件" :disabled="queueBusy" @click="removeQueuedFile(item.id)"><i class="ri-close-line" aria-hidden="true"></i></button>
|
||||
<button v-if="item.status === 'success' && item.image?.url" type="button" class="soft-button shrink-0 px-2 py-1 text-xs" @click.stop="showUploadResult(item.image)"><i class="ri-information-line" aria-hidden="true"></i>图片信息</button>
|
||||
</div>
|
||||
<progress v-if="item.status === 'uploading'" class="mt-1.5 block h-1.5 w-full accent-blue-600" :value="item.percent == null ? undefined : item.percent" max="100" :aria-label="`${item.file.name} 上传进度`"></progress>
|
||||
<p v-if="item.error" class="mt-1 break-words text-xs text-red-600 dark:text-red-400">{{ item.error }}</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="content-panel home-panel-compact space-y-2.5">
|
||||
@@ -226,8 +263,21 @@ const clientUploadLimits = computed(() => uploadLimits(uploadConfig.value));
|
||||
const uploadAccept = computed(() => allowedRasterTypes(uploadConfig.value).join(','));
|
||||
const uploadingCount = ref(0);
|
||||
const uploadProgress = ref(0);
|
||||
const uploadQueue = ref([]);
|
||||
const queueBusy = ref(false);
|
||||
const queueHasPending = computed(() => uploadQueue.value.some(item => item.status === 'queued' || (item.status === 'failed' && !directEnabled())));
|
||||
const queueHasFinished = computed(() => uploadQueue.value.some(item => item.status === 'success' || item.status === 'failed'));
|
||||
const queueHasRemovable = computed(() => uploadQueue.value.some(item => item.status !== 'uploading'));
|
||||
const recentImages = ref([]);
|
||||
const fileInput = ref(null);
|
||||
const createPreviewUrl = file => {
|
||||
try { return URL.createObjectURL(file); } catch { return ''; }
|
||||
};
|
||||
const revokeQueuePreview = item => {
|
||||
if (!item?.previewUrl) return;
|
||||
URL.revokeObjectURL(item.previewUrl);
|
||||
item.previewUrl = '';
|
||||
};
|
||||
|
||||
// Folder state belongs to this mounted account only; never persist upload destinations.
|
||||
const route = useRoute();
|
||||
@@ -302,6 +352,51 @@ const formatFileSize = (bytes) => {
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const queueStatusLabel = status => ({ queued: '等待上传', uploading: '正在传输', success: '上传完成', failed: '上传失败' }[status] || '等待上传');
|
||||
const queueStatusIcon = status => ({ queued: 'ri-time-line text-slate-400', uploading: 'ri-loader-4-line animate-spin text-blue-600', success: 'ri-checkbox-circle-fill text-emerald-600', failed: 'ri-error-warning-fill text-red-600' }[status] || 'ri-time-line text-slate-400');
|
||||
const queueStatusClass = status => ({ queued: 'text-slate-500 dark:text-slate-400', uploading: 'text-blue-700 dark:text-blue-300', success: 'text-emerald-700 dark:text-emerald-300', failed: 'text-red-700 dark:text-red-300' }[status] || 'text-slate-500');
|
||||
const enqueueFiles = files => {
|
||||
if (queueBusy.value || isUploading.value) return;
|
||||
const available = Math.max(0, clientUploadLimits.value.max_upload_files - uploadQueue.value.length);
|
||||
const accepted = files.slice(0, available);
|
||||
if (files.length > accepted.length) Message.warning(`队列最多保留 ${clientUploadLimits.value.max_upload_files} 个文件,已加入 ${accepted.length} 个`);
|
||||
uploadQueue.value.push(...accepted.map(file => ({ id: crypto.randomUUID?.() || `${Date.now()}-${Math.random()}`, file, previewUrl: createPreviewUrl(file), status: 'queued', percent: null, loaded: 0, total: file.size, error: '' })));
|
||||
};
|
||||
const removeQueuedFile = id => {
|
||||
if (queueBusy.value) return;
|
||||
const removed = uploadQueue.value.find(item => item.id === id);
|
||||
revokeQueuePreview(removed);
|
||||
uploadQueue.value = uploadQueue.value.filter(item => item.id !== id);
|
||||
};
|
||||
const clearFinishedQueue = () => {
|
||||
if (queueBusy.value) return;
|
||||
const removed = uploadQueue.value.filter(item => item.status === 'success' || item.status === 'failed');
|
||||
removed.forEach(revokeQueuePreview);
|
||||
uploadQueue.value = uploadQueue.value.filter(item => item.status !== 'success' && item.status !== 'failed');
|
||||
};
|
||||
const clearUploadQueue = () => {
|
||||
if (queueBusy.value) return;
|
||||
uploadQueue.value.forEach(revokeQueuePreview);
|
||||
uploadQueue.value = [];
|
||||
};
|
||||
const startQueuedUpload = async () => {
|
||||
if (queueBusy.value || !storageConfigLoaded.value) return;
|
||||
const items = uploadQueue.value.filter(item => item.status === 'queued' || (item.status === 'failed' && !directEnabled()));
|
||||
if (!items.length) return;
|
||||
queueBusy.value = true;
|
||||
items.forEach(item => { item.status = 'queued'; item.error = ''; item.percent = null; item.loaded = 0; });
|
||||
try {
|
||||
await uploadFiles(items.map(item => item.file), (file, patch, clientId) => {
|
||||
const item = (clientId && uploadQueue.value.find(entry => entry.clientId === clientId)) || uploadQueue.value.find(entry => entry.file === file && entry.status !== 'success');
|
||||
if (item) {
|
||||
if (clientId) item.clientId = clientId;
|
||||
Object.assign(item, patch);
|
||||
if (patch.task?.image) item.image = { ...patch.task.image };
|
||||
}
|
||||
});
|
||||
} finally { queueBusy.value = false; }
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化日期
|
||||
*/
|
||||
@@ -495,6 +590,7 @@ const { handleDragOver, handleDragEnter, handleDragLeave, handleDrop, triggerFil
|
||||
API_BASE_URL, isDragOver, isUploading, fileInput, resumeTask, handleResumeFile, clientUploadLimits,
|
||||
uploadConfig, storageConfigLoaded, directEnabled, startDirectFiles, legacyUploading, uploadingCount,
|
||||
uploadProgress, getUploadFolderId, multiStorageSync, selectedBucket, recentImages, loadRecentImages,
|
||||
onFilesReady: enqueueFiles,
|
||||
isDisposed: () => disposed,
|
||||
onSingleUpload: showUploadResult,
|
||||
});
|
||||
@@ -700,6 +796,7 @@ onMounted(() => {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true;
|
||||
uploadQueue.value.forEach(revokeQueuePreview);
|
||||
disposeDirectTasks();
|
||||
cancelLegacyUpload();
|
||||
if (syncPollTimer) clearTimeout(syncPollTimer);
|
||||
|
||||
Reference in new issue
Block a user