feat: 优化文件上传功能,支持上传进度显示和中止上传,更新封面和媒体项处理逻辑
This commit is contained in:
@@ -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() };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user