feat: add batch upload queue with per-file progress

This commit is contained in:
gary committed 2026-10-27 23:09:28 +08:00
1 parent b6cfdfeca7
commit 3581f62139
3 files changed
+153 -33

No files matched your search

+11 -4
View File
@@ -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,
+43 -27
View File
@@ -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 });
+99 -2
View File
@@ -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);