feat: add file deduplication and hash checking for uploads
- Implemented SHA-256 hashing for uploaded files to enable deduplication. - Added CheckHash method to verify if a file with the same hash already exists. - Updated Upload method to reuse existing media assets if a duplicate is found. - Introduced a new hash column in the media_assets table to store file hashes. - Enhanced the upload process to include progress tracking and hash calculation. - Modified frontend to check for existing files before uploading and to show upload progress. - Added vuedraggable for drag-and-drop functionality in the content editing view.
This commit is contained in:
@@ -28,7 +28,9 @@
|
||||
</div>
|
||||
|
||||
<!-- Main Content Form -->
|
||||
<div class="flex-1 overflow-y-auto bg-slate-50/50 p-8 md:p-12">
|
||||
<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">
|
||||
|
||||
<!-- Row 1: Genre, Key & Title -->
|
||||
@@ -98,17 +100,20 @@
|
||||
|
||||
<!-- Row 5: Covers (Max 3) -->
|
||||
<div>
|
||||
<label class="block text-sm font-bold text-slate-700 mb-3">封面 (最多3张)</label>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div v-for="(img, idx) in form.covers" :key="idx"
|
||||
class="relative group aspect-video rounded-lg overflow-hidden bg-slate-100 border border-slate-200">
|
||||
<img :src="img.url" class="w-full h-full object-cover">
|
||||
<button @click="removeCover(idx)"
|
||||
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"><i
|
||||
class="pi pi-times text-xs"></i></button>
|
||||
</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">
|
||||
<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">
|
||||
<Image :src="img.url" preview imageClass="w-full h-full object-cover" />
|
||||
<button @click="removeCover(index)"
|
||||
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="pi pi-times text-xs"></i></button>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
<div v-if="form.covers.length < 3" @click="triggerUpload('cover')"
|
||||
class="aspect-video rounded-lg border-2 border-dashed border-slate-300 flex flex-col items-center justify-center text-slate-400 hover:border-primary-500 hover:bg-primary-50 hover:text-primary-600 cursor-pointer transition-all">
|
||||
class="w-48 aspect-video rounded-lg border-2 border-dashed border-slate-300 flex flex-col items-center justify-center text-slate-400 hover:border-primary-500 hover:bg-primary-50 hover:text-primary-600 cursor-pointer transition-all">
|
||||
<i class="pi pi-plus text-2xl mb-1"></i>
|
||||
<span class="text-xs font-bold">上传封面</span>
|
||||
</div>
|
||||
@@ -117,20 +122,23 @@
|
||||
|
||||
<!-- Row 6: Video -->
|
||||
<div>
|
||||
<label class="block text-sm font-bold text-slate-700 mb-3">视频列表</label>
|
||||
<label class="block text-sm font-bold text-slate-700 mb-3">视频列表 (拖拽排序)</label>
|
||||
<div class="space-y-3">
|
||||
<div v-for="(file, idx) in form.videos" :key="idx"
|
||||
class="flex items-center gap-4 p-3 border border-slate-200 rounded-lg bg-slate-50">
|
||||
<div class="w-10 h-10 bg-blue-100 text-blue-600 rounded flex items-center justify-center"><i
|
||||
class="pi pi-video"></i></div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-bold text-sm text-slate-900 truncate">{{ file.name }}</div>
|
||||
<div class="text-xs text-slate-500">{{ file.size }}</div>
|
||||
</div>
|
||||
<button @click="removeMedia('videos', idx)"
|
||||
class="text-slate-400 hover:text-red-500 px-2 cursor-pointer"><i
|
||||
class="pi pi-trash"></i></button>
|
||||
</div>
|
||||
<draggable v-model="form.videos" item-key="id" class="space-y-3" group="videos">
|
||||
<template #item="{ element: file, index }">
|
||||
<div class="flex items-center gap-4 p-3 border border-slate-200 rounded-lg bg-slate-50 cursor-move hover:border-primary-300 transition-colors">
|
||||
<div class="w-10 h-10 bg-blue-100 text-blue-600 rounded flex items-center justify-center"><i
|
||||
class="pi pi-video"></i></div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-bold text-sm text-slate-900 truncate">{{ file.name }}</div>
|
||||
<div class="text-xs text-slate-500">{{ file.size }}</div>
|
||||
</div>
|
||||
<button @click="removeMedia('videos', index)"
|
||||
class="text-slate-400 hover:text-red-500 px-2 cursor-pointer"><i
|
||||
class="pi pi-trash"></i></button>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
<div @click="triggerUpload('video')"
|
||||
class="h-14 border-2 border-dashed border-slate-300 rounded-lg flex items-center justify-center text-slate-500 hover:border-blue-500 hover:bg-blue-50 hover:text-blue-600 cursor-pointer transition-all font-bold gap-2">
|
||||
<i class="pi pi-video"></i> 添加视频
|
||||
@@ -140,20 +148,23 @@
|
||||
|
||||
<!-- Row 7: Audio -->
|
||||
<div>
|
||||
<label class="block text-sm font-bold text-slate-700 mb-3">音频列表</label>
|
||||
<label class="block text-sm font-bold text-slate-700 mb-3">音频列表 (拖拽排序)</label>
|
||||
<div class="space-y-3">
|
||||
<div v-for="(file, idx) in form.audios" :key="idx"
|
||||
class="flex items-center gap-4 p-3 border border-slate-200 rounded-lg bg-slate-50">
|
||||
<div class="w-10 h-10 bg-purple-100 text-purple-600 rounded flex items-center justify-center"><i
|
||||
class="pi pi-microphone"></i></div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-bold text-sm text-slate-900 truncate">{{ file.name }}</div>
|
||||
<div class="text-xs text-slate-500">{{ file.size }}</div>
|
||||
</div>
|
||||
<button @click="removeMedia('audios', idx)"
|
||||
class="text-slate-400 hover:text-red-500 px-2 cursor-pointer"><i
|
||||
class="pi pi-trash"></i></button>
|
||||
</div>
|
||||
<draggable v-model="form.audios" item-key="id" class="space-y-3" group="audios">
|
||||
<template #item="{ element: file, index }">
|
||||
<div class="flex items-center gap-4 p-3 border border-slate-200 rounded-lg bg-slate-50 cursor-move hover:border-purple-300 transition-colors">
|
||||
<div class="w-10 h-10 bg-purple-100 text-purple-600 rounded flex items-center justify-center"><i
|
||||
class="pi pi-microphone"></i></div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-bold text-sm text-slate-900 truncate">{{ file.name }}</div>
|
||||
<div class="text-xs text-slate-500">{{ file.size }}</div>
|
||||
</div>
|
||||
<button @click="removeMedia('audios', index)"
|
||||
class="text-slate-400 hover:text-red-500 px-2 cursor-pointer"><i
|
||||
class="pi pi-trash"></i></button>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
<div @click="triggerUpload('audio')"
|
||||
class="h-14 border-2 border-dashed border-slate-300 rounded-lg flex items-center justify-center text-slate-500 hover:border-purple-500 hover:bg-purple-50 hover:text-purple-600 cursor-pointer transition-all font-bold gap-2">
|
||||
<i class="pi pi-microphone"></i> 添加音频
|
||||
@@ -163,20 +174,23 @@
|
||||
|
||||
<!-- Row 8: Images -->
|
||||
<div>
|
||||
<label class="block text-sm font-bold text-slate-700 mb-3">图片列表</label>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<div v-for="(file, idx) in form.images" :key="idx"
|
||||
class="relative group aspect-square rounded-lg overflow-hidden border border-slate-200">
|
||||
<img :src="file.url" class="w-full h-full object-cover">
|
||||
<div
|
||||
class="absolute bottom-0 left-0 w-full bg-black/60 text-white text-xs p-1 truncate text-center">
|
||||
{{ file.name }}</div>
|
||||
<button @click="removeMedia('images', idx)"
|
||||
class="absolute top-1 right-1 w-6 h-6 bg-red-500 text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"><i
|
||||
class="pi pi-times text-xs"></i></button>
|
||||
</div>
|
||||
<label class="block text-sm font-bold text-slate-700 mb-3">图片列表 (拖拽排序)</label>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<draggable v-model="form.images" item-key="id" class="flex flex-wrap gap-4" group="images">
|
||||
<template #item="{ element: file, index }">
|
||||
<div class="relative group w-32 aspect-square rounded-lg overflow-hidden border border-slate-200 cursor-move">
|
||||
<Image :src="file.url" preview imageClass="w-full h-full object-cover" />
|
||||
<div
|
||||
class="absolute bottom-0 left-0 w-full bg-black/60 text-white text-xs p-1 truncate text-center pointer-events-none">
|
||||
{{ file.name }}</div>
|
||||
<button @click="removeMedia('images', index)"
|
||||
class="absolute top-1 right-1 w-6 h-6 bg-red-500 text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer z-10"><i
|
||||
class="pi pi-times text-xs"></i></button>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
<div @click="triggerUpload('image')"
|
||||
class="aspect-square border-2 border-dashed border-slate-300 rounded-lg flex flex-col items-center justify-center text-slate-500 hover:border-orange-500 hover:bg-orange-50 hover:text-orange-600 cursor-pointer transition-all">
|
||||
class="w-32 aspect-square border-2 border-dashed border-slate-300 rounded-lg flex flex-col items-center justify-center text-slate-500 hover:border-orange-500 hover:bg-orange-50 hover:text-orange-600 cursor-pointer transition-all">
|
||||
<i class="pi pi-image text-2xl mb-1"></i>
|
||||
<span class="text-xs font-bold">添加图片</span>
|
||||
</div>
|
||||
@@ -193,7 +207,10 @@
|
||||
|
||||
<script setup>
|
||||
import Select from 'primevue/select';
|
||||
import ProgressBar from 'primevue/progressbar';
|
||||
import Image from 'primevue/image';
|
||||
import Toast from 'primevue/toast';
|
||||
import draggable from 'vuedraggable';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { computed, reactive, ref, onMounted } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
@@ -206,6 +223,7 @@ const toast = useToast();
|
||||
const fileInput = ref(null);
|
||||
const currentUploadType = ref('');
|
||||
const isUploading = ref(false);
|
||||
const uploadProgress = ref(0);
|
||||
const isSubmitting = ref(false);
|
||||
const isEditMode = ref(false);
|
||||
const contentId = ref('');
|
||||
@@ -278,12 +296,20 @@ const triggerUpload = (type) => {
|
||||
fileInput.value.click();
|
||||
};
|
||||
|
||||
const calculateHash = async (file) => {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
};
|
||||
|
||||
const handleFileChange = async (event) => {
|
||||
const files = event.target.files;
|
||||
if (!files.length) return;
|
||||
|
||||
isUploading.value = true;
|
||||
toast.add({ severity: 'info', summary: '正在上传', detail: '文件上传中...', life: 3000 });
|
||||
uploadProgress.value = 0;
|
||||
// toast.add({ severity: 'info', summary: '正在上传', detail: '文件上传中...', life: 3000 });
|
||||
|
||||
try {
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
@@ -294,7 +320,24 @@ const handleFileChange = async (event) => {
|
||||
if (currentUploadType.value === 'audio') type = 'audio';
|
||||
if (currentUploadType.value === 'cover') type = 'image';
|
||||
|
||||
const res = await commonApi.upload(file, type);
|
||||
// Check Hash first
|
||||
const hash = await calculateHash(file);
|
||||
let res;
|
||||
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 {
|
||||
// Instant completion if hash found
|
||||
uploadProgress.value = 100;
|
||||
}
|
||||
|
||||
// res: { id, url, ... }
|
||||
|
||||
if (currentUploadType.value === 'cover') {
|
||||
@@ -313,6 +356,7 @@ const handleFileChange = async (event) => {
|
||||
toast.add({ severity: 'error', summary: '上传失败', detail: e.message, life: 3000 });
|
||||
} finally {
|
||||
isUploading.value = false;
|
||||
uploadProgress.value = 0;
|
||||
event.target.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user