feat: 优化文件上传功能,支持上传进度显示和中止上传,更新封面和媒体项处理逻辑

This commit is contained in:
2026-01-04 15:41:34 +08:00
parent 2438d363f5
commit 03773b6205
2 changed files with 177 additions and 109 deletions

View File

@@ -10,62 +10,84 @@ export const commonApi = {
formData.append('type', type);
return request('/upload', { method: 'POST', body: formData });
},
uploadMultipart: async (file, hash, onProgress) => {
// 1. Check Hash
try {
const res = await commonApi.checkHash(hash);
if (res) {
if (onProgress) onProgress(100);
return res;
uploadMultipart: (file, hash, type, onProgress) => {
const controller = new AbortController();
const signal = controller.signal;
const promise = (async () => {
// 1. Check Hash
try {
const res = await commonApi.checkHash(hash);
if (res) {
if (onProgress) onProgress(100);
return res;
}
} catch (e) {
// Ignore hash check errors
}
} catch(e) {}
// 2. Init
const initRes = await request('/upload/init', {
method: 'POST',
body: {
filename: file.name,
size: file.size,
mime_type: file.type,
hash: hash
if (signal.aborted) throw new Error('Aborted');
// 2. Init
const initRes = await request('/upload/init', {
method: 'POST',
body: {
filename: file.name,
size: file.size,
mime_type: file.type,
hash: hash,
type: type
},
signal
});
const { upload_id, chunk_size } = initRes;
const totalChunks = Math.ceil(file.size / chunk_size);
// 3. Upload Parts
for (let i = 0; i < totalChunks; i++) {
if (signal.aborted) throw new Error('Aborted');
const start = i * chunk_size;
const end = Math.min(start + chunk_size, file.size);
const chunk = file.slice(start, end);
const formData = new FormData();
formData.append('file', chunk);
formData.append('upload_id', upload_id);
formData.append('part_number', i + 1);
// request helper with FormData handles content-type, but we need signal
await request('/upload/part', {
method: 'POST',
body: formData,
signal
});
if (onProgress) {
const percent = Math.round(((i + 1) / totalChunks) * 100);
onProgress(percent);
}
}
});
const { upload_id, chunk_size } = initRes;
const totalChunks = Math.ceil(file.size / chunk_size);
// 4. Complete
return request('/upload/complete', {
method: 'POST',
body: { upload_id },
signal
});
})();
// 3. Upload Parts
for (let i = 0; i < totalChunks; i++) {
const start = i * chunk_size;
const end = Math.min(start + chunk_size, file.size);
const chunk = file.slice(start, end);
const formData = new FormData();
formData.append('file', chunk);
formData.append('upload_id', upload_id);
formData.append('part_number', i + 1);
await request('/upload/part', { method: 'POST', body: formData });
if (onProgress) {
const percent = Math.round(((i + 1) / totalChunks) * 100);
onProgress(percent);
}
}
// 4. Complete
return request('/upload/complete', {
method: 'POST',
body: { upload_id }
});
return { promise, abort: () => controller.abort() };
},
uploadWithProgress: (file, type, onProgress) => {
return new Promise((resolve, reject) => {
let xhr;
const promise = new Promise((resolve, reject) => {
const formData = new FormData();
formData.append('file', file);
formData.append('type', type);
const xhr = new XMLHttpRequest();
xhr = new XMLHttpRequest();
xhr.open('POST', '/v1/upload');
const token = localStorage.getItem('token');
@@ -94,7 +116,10 @@ export const commonApi = {
};
xhr.onerror = () => reject(new Error('Network Error'));
xhr.onabort = () => reject(new Error('Aborted'));
xhr.send(formData);
});
return { promise, abort: () => xhr.abort() };
}
};

View File

@@ -25,7 +25,6 @@
<!-- Main Content Form -->
<div class="flex-1 overflow-y-auto bg-slate-50/50 p-8 md:p-12 relative">
<ProgressBar v-if="isUploading" mode="determinate" :value="uploadProgress" class="absolute top-0 left-0 w-full h-1 z-20" />
<div class="max-w-screen-xl mx-auto bg-white p-10 rounded-2xl border border-slate-200 shadow-sm space-y-10">
@@ -98,9 +97,13 @@
<div>
<label class="block text-sm font-bold text-slate-700 mb-3">封面 (最多3张, 拖拽排序)</label>
<div class="flex flex-wrap gap-4">
<draggable v-model="form.covers" item-key="id" class="flex flex-wrap gap-4" group="covers">
<draggable v-model="form.covers" item-key="tempId" class="flex flex-wrap gap-4" group="covers">
<template #item="{ element: img, index }">
<div class="relative group w-48 aspect-video rounded-lg overflow-hidden bg-slate-100 border border-slate-200 cursor-move">
<div v-if="img.status === 'uploading'" class="absolute inset-0 bg-black/60 flex flex-col items-center justify-center z-20 text-white">
<i class="pi pi-spin pi-spinner text-2xl mb-2"></i>
<span class="text-xs font-bold">{{ img.progress }}%</span>
</div>
<Image :src="img.url" preview imageClass="w-full h-full object-cover" />
<button @click="removeMediaItem('cover', index, img)"
class="absolute top-1 right-1 w-6 h-6 bg-black/50 hover:bg-red-500 text-white rounded-full flex items-center justify-center transition-colors cursor-pointer z-10"><i
@@ -219,8 +222,8 @@ const route = useRoute();
const toast = useToast();
const fileInput = ref(null);
const currentUploadType = ref('');
const isUploading = ref(false);
const uploadProgress = ref(0);
// const isUploading = ref(false); // Removed global state
// const uploadProgress = ref(0); // Removed global state
const isSubmitting = ref(false);
const isEditMode = ref(false);
const contentId = ref('');
@@ -265,7 +268,15 @@ const loadContent = async (id) => {
// Parse Assets
if (res.assets) {
res.assets.forEach(asset => {
const item = { id: asset.id, url: asset.url, name: asset.name || 'Unknown', size: asset.size || '' };
// Add status='done' for existing items
const item = {
id: asset.id,
url: asset.url,
name: asset.name || 'Unknown',
size: asset.size || '',
status: 'done',
progress: 100
};
if (asset.role === 'cover') {
form.covers.push(item);
} else if (asset.type === 'video') {
@@ -288,7 +299,16 @@ const keys = ['C大调', 'D大调', 'E大调', 'F大调', 'G大调', 'A大调',
const triggerUpload = (type) => {
currentUploadType.value = type;
fileInput.value.click();
if (fileInput.value) {
if (type === 'image' || type === 'cover') {
fileInput.value.accept = 'image/*';
} else if (type === 'video') {
fileInput.value.accept = 'video/*';
} else if (type === 'audio') {
fileInput.value.accept = 'audio/*';
}
fileInput.value.click();
}
};
const calculateHash = async (file) => {
@@ -300,77 +320,100 @@ const handleFileChange = async (event) => {
const files = event.target.files;
if (!files.length) return;
isUploading.value = true;
uploadProgress.value = 0;
// toast.add({ severity: 'info', summary: '正在上传', detail: '文件上传中...', life: 3000 });
for (let i = 0; i < files.length; i++) {
const file = files[i];
let type = 'image';
let listName = 'images';
if (currentUploadType.value === 'video') { type = 'video'; listName = 'videos'; }
else if (currentUploadType.value === 'audio') { type = 'audio'; listName = 'audios'; }
else if (currentUploadType.value === 'cover') { type = 'image'; listName = 'covers'; }
else { listName = 'images'; }
try {
for (let i = 0; i < files.length; i++) {
const file = files[i];
// Determine backend type: 'image', 'video', 'audio'
let type = 'image';
if (currentUploadType.value === 'video') type = 'video';
if (currentUploadType.value === 'audio') type = 'audio';
if (currentUploadType.value === 'cover') type = 'image';
const hash = await calculateHash(file);
let res;
if (type === 'video' || type === 'audio') {
// Multipart upload for large files
res = await commonApi.uploadMultipart(file, hash, (progress) => {
uploadProgress.value = Math.round(progress);
});
} else {
// Simple upload for images
try {
res = await commonApi.checkHash(hash);
} catch (e) {
// Not found or error, proceed to upload
}
if (!res) {
res = await commonApi.uploadWithProgress(file, type, (progress) => {
uploadProgress.value = Math.round(progress);
});
} else {
uploadProgress.value = 100;
}
}
// res: { id, url, ... }
if (currentUploadType.value === 'cover') {
if (form.covers.length < 3) form.covers.push({ url: res.url, id: res.id });
} else if (currentUploadType.value === 'video') {
form.videos.push({ name: file.name, size: (file.size/1024/1024).toFixed(2)+'MB', url: res.url, id: res.id });
} else if (currentUploadType.value === 'audio') {
form.audios.push({ name: file.name, size: (file.size/1024/1024).toFixed(2)+'MB', url: res.url, id: res.id });
} else if (currentUploadType.value === 'image') {
form.images.push({ name: file.name, size: (file.size/1024/1024).toFixed(2)+'MB', url: res.url, id: res.id });
}
// Create Temp Item
const tempId = 'temp-' + Date.now() + Math.random();
const item = reactive({
id: '', // Empty until success
tempId: tempId,
name: file.name,
size: (file.size/1024/1024).toFixed(2)+'MB',
url: URL.createObjectURL(file), // Preview local file immediately
progress: 0,
status: 'uploading',
abort: null
});
// Enforce max 3 covers
if (listName === 'covers') {
if (form.covers.length >= 3) {
toast.add({ severity: 'warn', summary: '限制', detail: '最多上传3张封面', life: 3000 });
continue;
}
form.covers.push(item);
} else {
form[listName].push(item);
}
toast.add({ severity: 'success', summary: '上传成功', life: 2000 });
} catch (e) {
console.error(e);
toast.add({ severity: 'error', summary: '上传失败', detail: e.message, life: 3000 });
} finally {
isUploading.value = false;
uploadProgress.value = 0;
event.target.value = '';
// Start Upload
(async () => {
try {
const hash = await calculateHash(file);
let task;
if (type === 'video' || type === 'audio') {
task = commonApi.uploadMultipart(file, hash, type, (p) => item.progress = p);
} else {
// Check hash for images
let existing = null;
try {
existing = await commonApi.checkHash(hash);
} catch(e) {}
if (existing) {
task = { promise: Promise.resolve(existing), abort: () => {} };
} else {
task = commonApi.uploadWithProgress(file, type, (p) => item.progress = p);
}
}
item.abort = task.abort;
const res = await task.promise;
// Success
item.id = res.id;
item.url = res.url; // Update to remote URL
item.status = 'done';
item.progress = 100;
} catch (e) {
if (e.message === 'Aborted') {
console.log('Upload aborted');
} else {
console.error(e);
toast.add({ severity: 'error', summary: '上传失败', detail: file.name, life: 3000 });
// Remove failed item
const idx = form[listName].findIndex(x => x.tempId === tempId);
if (idx !== -1) form[listName].splice(idx, 1);
}
}
})();
}
event.target.value = '';
};
const removeMediaItem = async (type, index, item) => {
// Cancel upload if in progress
if (item.abort && item.status === 'uploading') {
item.abort();
}
if (item.id) {
try {
await commonApi.deleteMedia(item.id);
} catch (e) {
console.error(e);
toast.add({ severity: 'error', summary: '删除失败', detail: '无法删除文件', life: 3000 });
return;
}
}
if (type === 'cover') {
form.covers.splice(index, 1);
} else {