Files
nl-blogs/client/src/components/admin/ImageUpload.vue
李琦 4f4153ee0e 1. 小程序端
2. 视频优化
2026-07-30 14:11:26 +08:00

445 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="image-upload">
<!-- 单个上传模式 -->
<template v-if="!multiple">
<div
v-if="!imageUrl"
@click="triggerFileInput"
@dragover.prevent="handleDragOver"
@dragenter.prevent="handleDragEnter"
@dragleave.prevent="handleDragLeave"
@drop.prevent="handleDrop"
class="upload-area border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors"
:class="[
isDragging ? 'border-art-accent bg-art-accent/5' : 'border-white/20 hover:border-art-accent/50',
disabled ? 'opacity-50 cursor-not-allowed' : ''
]"
>
<input
ref="fileInput"
type="file"
:accept="accept"
:multiple="false"
@change="handleFileSelect"
class="hidden"
:disabled="disabled"
/>
<div class="space-y-3">
<div class="w-12 h-12 mx-auto flex items-center justify-center text-art-muted">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>
</div>
<p class="text-sm text-art-muted">点击或拖拽图片到此处上传</p>
<p class="text-xs text-art-muted/50">支持 JPGPNGGIF 格式</p>
</div>
</div>
<!-- 从素材库选择按钮 -->
<div v-if="!imageUrl" class="mt-3 text-center">
<button
@click="openLibraryModal"
type="button"
class="text-sm text-art-accent hover:text-art-accent/80 transition-colors"
:disabled="disabled"
>
或从素材库选择
</button>
</div>
<div v-else class="image-preview relative group">
<img :src="imageUrl" alt="Preview" class="w-full h-auto rounded-lg max-h-64 object-contain bg-white/5" />
<div class="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity rounded-lg flex items-center justify-center gap-2">
<button
type="button"
@click.stop="() => removeImage()"
class="px-4 py-2 bg-red-500/80 text-white rounded hover:bg-red-500 transition-colors text-sm"
:disabled="disabled"
>
删除
</button>
<button
type="button"
@click.stop="triggerFileInput"
class="px-4 py-2 bg-art-accent text-black rounded hover:opacity-90 transition-colors text-sm"
:disabled="disabled"
>
更换
</button>
</div>
</div>
<div v-if="uploading" class="mt-4 text-center">
<div class="inline-flex items-center gap-2 text-art-accent">
<div class="animate-spin rounded-full h-4 w-4 border-t-2 border-b-2 border-art-accent"></div>
<span class="text-sm">上传中...</span>
</div>
</div>
<div v-if="error" class="mt-4 text-center text-red-400 text-sm">
{{ error }}
</div>
</template>
<!-- 批量上传模式 -->
<template v-else>
<div class="space-y-4">
<!-- 图片列表 -->
<div v-if="imageUrls.length > 0" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
<div
v-for="(url, index) in imageUrls"
:key="index"
class="image-item relative group aspect-square rounded-lg overflow-hidden bg-white/5 border border-white/10"
>
<img :src="url" :alt="`Image ${index + 1}`" class="w-full h-full object-cover" />
<div class="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
<button
type="button"
@click="() => removeImage(index)"
class="px-3 py-1.5 bg-red-500/80 text-white rounded hover:bg-red-500 transition-colors text-xs"
:disabled="disabled"
>
删除
</button>
</div>
<div class="absolute top-2 left-2 bg-black/50 text-white text-xs px-2 py-1 rounded">
{{ index + 1 }}
</div>
</div>
</div>
<!-- 上传区域 -->
<div
v-if="imageUrls.length < (maxCount || Infinity)"
@click="triggerFileInput"
@dragover.prevent="handleDragOver"
@dragenter.prevent="handleDragEnter"
@dragleave.prevent="handleDragLeave"
@drop.prevent="handleDrop"
class="upload-area border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors"
:class="[
isDragging ? 'border-art-accent bg-art-accent/5' : 'border-white/20 hover:border-art-accent/50',
disabled ? 'opacity-50 cursor-not-allowed' : ''
]"
>
<input
ref="fileInput"
type="file"
:accept="accept"
:multiple="true"
@change="handleFileSelect"
class="hidden"
:disabled="disabled"
/>
<div class="space-y-2">
<div class="w-10 h-10 mx-auto flex items-center justify-center text-art-muted">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>
</div>
<p class="text-sm text-art-muted">点击或拖拽图片到此处上传</p>
<p class="text-xs text-art-muted/50">
{{ maxCount ? `最多上传 ${maxCount} 张,已上传 ${imageUrls.length}` : `已上传 ${imageUrls.length}` }}
</p>
</div>
</div>
<!-- 从素材库选择按钮 -->
<div v-if="imageUrls.length < (maxCount || Infinity)" class="mt-3 text-center">
<button
@click="openLibraryModal"
type="button"
class="text-sm text-art-accent hover:text-art-accent/80 transition-colors"
:disabled="disabled"
>
或从素材库选择
</button>
</div>
<!-- 上传进度 -->
<div v-if="uploadingCount > 0" class="mt-4 text-center">
<div class="inline-flex items-center gap-2 text-art-accent">
<div class="animate-spin rounded-full h-4 w-4 border-t-2 border-b-2 border-art-accent"></div>
<span class="text-sm">正在上传 {{ uploadingCount }} 张图片...</span>
</div>
</div>
<!-- 错误提示 -->
<div v-if="error" class="mt-4 text-center text-red-400 text-sm">
{{ error }}
</div>
</div>
</template>
<!-- 素材库选择模态框 -->
<AttachmentLibraryModal
:show="showLibraryModal"
:multiple="multiple"
:max-count="maxCount"
:selected-urls="multiple ? imageUrls : (imageUrl ? [imageUrl] : [])"
@update:show="showLibraryModal = $event"
@confirm="handleLibrarySelect"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useToast } from '../../composables/useToast'
import { API_BASE, authFetch, parseApiResponse } from '../../services/api'
import AttachmentLibraryModal from './AttachmentLibraryModal.vue'
const props = withDefaults(defineProps<{
modelValue?: string | string[]
multiple?: boolean
maxCount?: number
categoryId?: number
accept?: string
disabled?: boolean
}>(), {
multiple: false,
accept: 'image/*',
disabled: false
})
const emit = defineEmits<{
'update:modelValue': [value: string | string[]]
}>()
const fileInput = ref<HTMLInputElement | null>(null)
const uploading = ref(false)
const uploadingCount = ref(0)
const error = ref('')
const isDragging = ref(false)
const showLibraryModal = ref(false)
const toast = useToast()
// 单个模式的值
const imageUrl = computed({
get: () => {
if (props.multiple) return ''
return (props.modelValue as string) || ''
},
set: (val: string) => {
if (!props.multiple) {
emit('update:modelValue', val)
}
}
})
// 批量模式的值
const imageUrls = computed({
get: () => {
if (!props.multiple) return []
const value = props.modelValue
if (Array.isArray(value)) {
return value
}
return value ? [value] : []
},
set: (val: string[]) => {
if (props.multiple) {
emit('update:modelValue', val)
}
}
})
const triggerFileInput = () => {
if (props.disabled) return
fileInput.value?.click()
}
const handleDragOver = (e: DragEvent) => {
if (props.disabled) return
e.preventDefault()
isDragging.value = true
}
const handleDragEnter = (e: DragEvent) => {
if (props.disabled) return
e.preventDefault()
isDragging.value = true
}
const handleDragLeave = (e: DragEvent) => {
if (props.disabled) return
e.preventDefault()
isDragging.value = false
}
const handleDrop = (e: DragEvent) => {
if (props.disabled) return
e.preventDefault()
isDragging.value = false
if (e.dataTransfer?.files) {
const files = Array.from(e.dataTransfer.files).filter(file => file.type.startsWith('image/'))
if (files.length > 0) {
if (props.multiple) {
handleMultipleFiles(files)
} else {
uploadFile(files[0])
}
}
}
}
const handleFileSelect = (e: Event) => {
const target = e.target as HTMLInputElement
if (target.files && target.files.length > 0) {
const files = Array.from(target.files).filter(file => file.type.startsWith('image/'))
if (files.length > 0) {
if (props.multiple) {
handleMultipleFiles(files)
} else {
uploadFile(files[0])
}
}
}
// 清空input允许重复选择同一文件
if (target) {
target.value = ''
}
}
const handleMultipleFiles = async (files: File[]) => {
if (props.maxCount && imageUrls.value.length + files.length > props.maxCount) {
error.value = `最多只能上传 ${props.maxCount} 张图片`
toast.showToast(error.value, 'error')
return
}
error.value = ''
uploadingCount.value = files.length
const uploadPromises = files.map(file => uploadSingleFile(file))
try {
const results = await Promise.all(uploadPromises)
const successfulUrls = results.filter(url => url !== null) as string[]
if (successfulUrls.length > 0) {
const newUrls = [...imageUrls.value, ...successfulUrls]
imageUrls.value = newUrls
toast.showToast(`成功上传 ${successfulUrls.length} 张图片`, 'success')
}
} catch (err) {
console.error('Batch upload error:', err)
} finally {
uploadingCount.value = 0
}
}
const uploadFile = async (file: File) => {
if (!file.type.startsWith('image/')) {
error.value = '请选择图片文件'
toast.showToast(error.value, 'error')
return
}
uploading.value = true
error.value = ''
try {
const url = await uploadSingleFile(file)
if (url) {
imageUrl.value = url
toast.showToast('图片上传成功', 'success')
}
} catch (err: any) {
error.value = err.message || '上传失败,请重试'
toast.showToast(error.value, 'error')
} finally {
uploading.value = false
}
}
const uploadSingleFile = async (file: File): Promise<string | null> => {
try {
const formData = new FormData()
formData.append('file', file)
if (props.categoryId) {
formData.append('categoryId', props.categoryId.toString())
}
const result = await parseApiResponse<{ fileUrl: string }>(await authFetch(`${API_BASE}/admin/attachments/upload`, {
method: 'POST',
headers: {},
body: formData
}))
return result.fileUrl
} catch (err: any) {
console.error('Upload error:', err)
throw err
}
}
const removeImage = (index?: number) => {
if (props.disabled) return
if (props.multiple && typeof index === 'number') {
const newUrls = [...imageUrls.value]
newUrls.splice(index, 1)
imageUrls.value = newUrls
} else {
imageUrl.value = ''
if (fileInput.value) {
fileInput.value.value = ''
}
}
}
const openLibraryModal = () => {
if (props.disabled) return
showLibraryModal.value = true
}
const handleLibrarySelect = (urls: string[]) => {
if (props.multiple) {
// 批量模式添加选中的图片考虑maxCount限制
const currentCount = imageUrls.value.length
const remainingCount = props.maxCount ? props.maxCount - currentCount : Infinity
const urlsToAdd = urls.slice(0, remainingCount)
if (urlsToAdd.length > 0) {
const newUrls = [...imageUrls.value, ...urlsToAdd]
imageUrls.value = newUrls
toast.showToast(`已添加 ${urlsToAdd.length} 张图片`, 'success')
}
if (urls.length > urlsToAdd.length) {
toast.showToast(`最多只能添加 ${props.maxCount} 张图片,已添加 ${urlsToAdd.length}`, 'error')
}
} else {
// 单个模式:直接设置选中的第一张图片
if (urls.length > 0) {
imageUrl.value = urls[0]
toast.showToast('图片选择成功', 'success')
}
}
}
</script>
<style scoped>
.upload-area {
min-height: 120px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.image-item {
transition: transform 0.2s;
}
.image-item:hover {
transform: scale(1.02);
}
</style>