feat: 优化文件上传功能,支持上传进度显示和中止上传,更新封面和媒体项处理逻辑
This commit is contained in:
@@ -10,62 +10,84 @@ export const commonApi = {
|
|||||||
formData.append('type', type);
|
formData.append('type', type);
|
||||||
return request('/upload', { method: 'POST', body: formData });
|
return request('/upload', { method: 'POST', body: formData });
|
||||||
},
|
},
|
||||||
uploadMultipart: async (file, hash, onProgress) => {
|
uploadMultipart: (file, hash, type, onProgress) => {
|
||||||
// 1. Check Hash
|
const controller = new AbortController();
|
||||||
try {
|
const signal = controller.signal;
|
||||||
const res = await commonApi.checkHash(hash);
|
|
||||||
if (res) {
|
const promise = (async () => {
|
||||||
if (onProgress) onProgress(100);
|
// 1. Check Hash
|
||||||
return res;
|
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
|
if (signal.aborted) throw new Error('Aborted');
|
||||||
const initRes = await request('/upload/init', {
|
|
||||||
method: 'POST',
|
// 2. Init
|
||||||
body: {
|
const initRes = await request('/upload/init', {
|
||||||
filename: file.name,
|
method: 'POST',
|
||||||
size: file.size,
|
body: {
|
||||||
mime_type: file.type,
|
filename: file.name,
|
||||||
hash: hash
|
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;
|
// 4. Complete
|
||||||
const totalChunks = Math.ceil(file.size / chunk_size);
|
return request('/upload/complete', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { upload_id },
|
||||||
|
signal
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
// 3. Upload Parts
|
return { promise, abort: () => controller.abort() };
|
||||||
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 }
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
uploadWithProgress: (file, type, onProgress) => {
|
uploadWithProgress: (file, type, onProgress) => {
|
||||||
return new Promise((resolve, reject) => {
|
let xhr;
|
||||||
|
const promise = new Promise((resolve, reject) => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
formData.append('type', type);
|
formData.append('type', type);
|
||||||
|
|
||||||
const xhr = new XMLHttpRequest();
|
xhr = new XMLHttpRequest();
|
||||||
xhr.open('POST', '/v1/upload');
|
xhr.open('POST', '/v1/upload');
|
||||||
|
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
@@ -94,7 +116,10 @@ export const commonApi = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
xhr.onerror = () => reject(new Error('Network Error'));
|
xhr.onerror = () => reject(new Error('Network Error'));
|
||||||
|
xhr.onabort = () => reject(new Error('Aborted'));
|
||||||
xhr.send(formData);
|
xhr.send(formData);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return { promise, abort: () => xhr.abort() };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,7 +25,6 @@
|
|||||||
|
|
||||||
<!-- Main Content Form -->
|
<!-- Main Content Form -->
|
||||||
<div class="flex-1 overflow-y-auto bg-slate-50/50 p-8 md:p-12 relative">
|
<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">
|
<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>
|
<div>
|
||||||
<label class="block text-sm font-bold text-slate-700 mb-3">封面 (最多3张, 拖拽排序)</label>
|
<label class="block text-sm font-bold text-slate-700 mb-3">封面 (最多3张, 拖拽排序)</label>
|
||||||
<div class="flex flex-wrap gap-4">
|
<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 }">
|
<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 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" />
|
<Image :src="img.url" preview imageClass="w-full h-full object-cover" />
|
||||||
<button @click="removeMediaItem('cover', index, img)"
|
<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
|
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 toast = useToast();
|
||||||
const fileInput = ref(null);
|
const fileInput = ref(null);
|
||||||
const currentUploadType = ref('');
|
const currentUploadType = ref('');
|
||||||
const isUploading = ref(false);
|
// const isUploading = ref(false); // Removed global state
|
||||||
const uploadProgress = ref(0);
|
// const uploadProgress = ref(0); // Removed global state
|
||||||
const isSubmitting = ref(false);
|
const isSubmitting = ref(false);
|
||||||
const isEditMode = ref(false);
|
const isEditMode = ref(false);
|
||||||
const contentId = ref('');
|
const contentId = ref('');
|
||||||
@@ -265,7 +268,15 @@ const loadContent = async (id) => {
|
|||||||
// Parse Assets
|
// Parse Assets
|
||||||
if (res.assets) {
|
if (res.assets) {
|
||||||
res.assets.forEach(asset => {
|
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') {
|
if (asset.role === 'cover') {
|
||||||
form.covers.push(item);
|
form.covers.push(item);
|
||||||
} else if (asset.type === 'video') {
|
} else if (asset.type === 'video') {
|
||||||
@@ -288,7 +299,16 @@ const keys = ['C大调', 'D大调', 'E大调', 'F大调', 'G大调', 'A大调',
|
|||||||
|
|
||||||
const triggerUpload = (type) => {
|
const triggerUpload = (type) => {
|
||||||
currentUploadType.value = 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) => {
|
const calculateHash = async (file) => {
|
||||||
@@ -300,77 +320,100 @@ const handleFileChange = async (event) => {
|
|||||||
const files = event.target.files;
|
const files = event.target.files;
|
||||||
if (!files.length) return;
|
if (!files.length) return;
|
||||||
|
|
||||||
isUploading.value = true;
|
for (let i = 0; i < files.length; i++) {
|
||||||
uploadProgress.value = 0;
|
const file = files[i];
|
||||||
// toast.add({ severity: 'info', summary: '正在上传', detail: '文件上传中...', life: 3000 });
|
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 {
|
// Create Temp Item
|
||||||
for (let i = 0; i < files.length; i++) {
|
const tempId = 'temp-' + Date.now() + Math.random();
|
||||||
const file = files[i];
|
const item = reactive({
|
||||||
// Determine backend type: 'image', 'video', 'audio'
|
id: '', // Empty until success
|
||||||
let type = 'image';
|
tempId: tempId,
|
||||||
if (currentUploadType.value === 'video') type = 'video';
|
name: file.name,
|
||||||
if (currentUploadType.value === 'audio') type = 'audio';
|
size: (file.size/1024/1024).toFixed(2)+'MB',
|
||||||
if (currentUploadType.value === 'cover') type = 'image';
|
url: URL.createObjectURL(file), // Preview local file immediately
|
||||||
|
progress: 0,
|
||||||
const hash = await calculateHash(file);
|
status: 'uploading',
|
||||||
let res;
|
abort: null
|
||||||
|
});
|
||||||
if (type === 'video' || type === 'audio') {
|
|
||||||
// Multipart upload for large files
|
// Enforce max 3 covers
|
||||||
res = await commonApi.uploadMultipart(file, hash, (progress) => {
|
if (listName === 'covers') {
|
||||||
uploadProgress.value = Math.round(progress);
|
if (form.covers.length >= 3) {
|
||||||
});
|
toast.add({ severity: 'warn', summary: '限制', detail: '最多上传3张封面', life: 3000 });
|
||||||
} else {
|
continue;
|
||||||
// Simple upload for images
|
}
|
||||||
try {
|
form.covers.push(item);
|
||||||
res = await commonApi.checkHash(hash);
|
} else {
|
||||||
} catch (e) {
|
form[listName].push(item);
|
||||||
// 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 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
toast.add({ severity: 'success', summary: '上传成功', life: 2000 });
|
|
||||||
} catch (e) {
|
// Start Upload
|
||||||
console.error(e);
|
(async () => {
|
||||||
toast.add({ severity: 'error', summary: '上传失败', detail: e.message, life: 3000 });
|
try {
|
||||||
} finally {
|
const hash = await calculateHash(file);
|
||||||
isUploading.value = false;
|
let task;
|
||||||
uploadProgress.value = 0;
|
|
||||||
event.target.value = '';
|
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) => {
|
const removeMediaItem = async (type, index, item) => {
|
||||||
|
// Cancel upload if in progress
|
||||||
|
if (item.abort && item.status === 'uploading') {
|
||||||
|
item.abort();
|
||||||
|
}
|
||||||
|
|
||||||
if (item.id) {
|
if (item.id) {
|
||||||
try {
|
try {
|
||||||
await commonApi.deleteMedia(item.id);
|
await commonApi.deleteMedia(item.id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
toast.add({ severity: 'error', summary: '删除失败', detail: '无法删除文件', life: 3000 });
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'cover') {
|
if (type === 'cover') {
|
||||||
form.covers.splice(index, 1);
|
form.covers.splice(index, 1);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Reference in New Issue
Block a user