eagleye

Quasar企业级图片上传组件

# 企业级图片上传组件

下面是一个精简优化后的图片上传组件,专注于图片上传功能,采用组合式API和TypeScript编写:

# 图片上传组件 - 添加清空全部功能

我将为图片上传组件添加一个"清空全部"按钮,让用户可以一次性删除所有已选图片。

## 实现思路

1. 在图片预览区域添加"清空全部"按钮
2. 实现清空全部功能,包括:
- 释放所有预览URL
- 取消所有进行中的上传
- 清空图片列表
3. 添加确认对话框防止误操作

## 代码实现

```vue
<!-- photoUploader.vue -->
<template>
<div class="image-uploader">
<!-- 上传卡片 -->
<q-card class="upload-card q-mb-md">
<q-card-section>
<div class="text-h6">图片上传</div>

<!-- 文件选择 -->
<q-file
v-model="localFiles"
:multiple="allowMultiple"
accept="image/*"
:label="fileInputLabel"
counter
class="q-mb-md"
@update:model-value="handleFilesSelected"
>
<template v-slot:prepend>
<q-icon name="attach_file" />
</template>
<template v-slot:hint> 支持JPG、PNG、GIF、WEBP等格式,最大20MB </template>
</q-file>

<!-- 上传按钮 -->
<q-btn
color="primary"
icon="cloud_upload"
:label="uploadButtonLabel"
class="full-width"
:loading="uploading"
:disable="!canUpload"
@click="uploadFiles"
/>
</q-card-section>
</q-card>

<!-- 图片预览区域 -->
<q-card v-if="imageList.length > 0" class="preview-card">
<q-card-section class="flex justify-between items-center">
<div class="text-h6">已选择图片 ({{ imageList.length }})</div>
<!-- 清空全部按钮 -->
<q-btn
flat
round
color="negative"
icon="delete_sweep"
@click="confirmClearAll"
class="q-ml-md"
>
<q-tooltip>清空全部图片</q-tooltip>
</q-btn>
</q-card-section>

<q-separator />

<q-card-section class="q-pt-none">
<div class="image-grid">
<div v-for="(imageItem, index) in imageList" :key="imageItem.id" class="image-item">
<q-img
:src="imageItem.previewUrl"
:ratio="4 / 3"
class="image-thumbnail"
spinner-color="primary"
@click="previewImageItem(imageItem)"
>
<!-- 图片操作栏 -->
<div class="image-actions absolute-top-right">
<q-btn
flat
round
color="white"
icon="delete"
size="sm"
@click.stop="removeImage(index)"
>
<q-tooltip>删除图片</q-tooltip>
</q-btn>
</div>

<!-- 上传进度指示器 -->
<template v-if="uploadProgress[imageItem.id]">
<div class="absolute-full flex flex-center">
<q-circular-progress
v-if="
uploadProgress[imageItem.id]!.progress > 0 &&
uploadProgress[imageItem.id]!.progress < 100
"
:value="uploadProgress[imageItem.id]!.progress"
size="50px"
color="primary"
class="q-ma-md"
/>
<q-icon
v-else-if="uploadProgress[imageItem.id]!.completed"
name="check_circle"
color="positive"
size="30px"
/>
<q-icon
v-else-if="uploadProgress[imageItem.id]!.error"
name="error"
color="negative"
size="30px"
/>
</div>
</template>
</q-img>

<div class="image-info q-pt-xs">
<div class="text-caption text-weight-medium text-ellipsis">
{{ imageItem.name }}
</div>
<div class="text-caption text-grey">
{{ formatFileSize(imageItem.size) }}
</div>
</div>
</div>
</div>
</q-card-section>
</q-card>

<!-- 大图预览对话框 -->
<q-dialog v-model="showImagePreview" maximized transition-show="fade" transition-hide="fade">
<q-card class="full-width">
<q-bar class="bg-dark text-white">
<div class="text-weight-medium">{{ previewImage.name }}</div>
<q-space />
<q-btn dense flat icon="close" v-close-popup>
<q-tooltip>关闭</q-tooltip>
</q-btn>
</q-bar>

<q-card-section class="flex flex-center full-height">
<q-img
:src="previewImage.url"
:ratio="1"
fit="contain"
style="max-height: 90vh; max-width: 90vw"
/>
</q-card-section>
</q-card>
</q-dialog>

<!-- 确认清空对话框 -->
<q-dialog v-model="showClearAllConfirm" persistent>
<q-card>
<q-card-section>
<div class="text-h6">确认清空</div>
</q-card-section>

<q-card-section class="q-pt-none">
确定要清空所有已选图片吗?此操作不可撤销。
<span v-if="uploading" class="text-negative text-weight-medium">
<br />注意:当前有图片正在上传,清空操作将取消所有上传任务。
</span>
</q-card-section>

<q-card-actions align="right">
<q-btn flat label="取消" color="primary" v-close-popup />
<q-btn flat label="确认清空" color="negative" @click="clearAllImages" v-close-popup />
</q-card-actions>
</q-card>
</q-dialog>
</div>
</template>

<script setup lang="ts">
import { computed, ref, onUnmounted } from 'vue'
import { useQuasar } from 'quasar'
import type { AxiosProgressEvent, CancelTokenSource } from 'axios'
import axios from 'axios'
import { apiClient } from 'src/services/axios'

// 类型定义
interface ImageUploadItem {
id: string
nativeFile: File
name: string
size: number
type: string
previewUrl: string
}

interface UploadProgress {
id: string
name: string
progress: number
completed: boolean
error?: string
}

// Quasar插件
const $q = useQuasar()

// 组件属性
const props = withDefaults(
defineProps<{
allowMultiple?: boolean
category?: string
maxSizeMB?: number
}>(),
{
allowMultiple: true,
category: 'images',
maxSizeMB: 20,
},
)

// 组件事件
const emit = defineEmits<{
(e: 'upload-complete', successCount: number, failedCount: number): void
(e: 'upload-started'): void
(e: 'upload-cancelled'): void
}>()

// 响应式状态
const localFiles = ref<File[] | null>(null)
const imageList = ref<ImageUploadItem[]>([])
const uploading = ref<boolean>(false)
const uploadProgress = ref<Record<string, UploadProgress>>({})
const cancelTokens = ref<Record<string, CancelTokenSource>>({})
const showImagePreview = ref<boolean>(false)
const previewImage = ref<{ name: string; url: string }>({ name: '', url: '' })
const showClearAllConfirm = ref<boolean>(false) // 清空确认对话框

// 计算属性
const fileInputLabel = computed<string>(() => {
return `选择图片${props.allowMultiple ? '(可多选)' : ''}`
})

const uploadButtonLabel = computed<string>(() => {
return uploading.value ? '上传中...' : `开始上传 (${imageList.value.length})`
})

const canUpload = computed<boolean>(() => {
return imageList.value.length > 0 && !uploading.value
})

// 处理文件选择
const handleFilesSelected = (newFiles: File[] | null): void => {
if (!newFiles) return

Array.from(newFiles).forEach((file: File) => {
// 检查文件类型
if (!isValidImageType(file)) {
$q.notify({
type: 'negative',
message: `不支持的文件类型: ${file.name}。请选择图片文件。`,
position: 'top',
})
return
}

// 检查文件大小
const maxSizeBytes = props.maxSizeMB * 1024 * 1024
if (file.size > maxSizeBytes) {
$q.notify({
type: 'negative',
message: `文件过大: ${file.name} (最大${props.maxSizeMB}MB)`,
position: 'top',
})
return
}

// 检查是否已存在同名文件
if (imageList.value.some((item) => item.name === file.name)) {
$q.notify({
type: 'warning',
message: `文件已存在: ${file.name}`,
position: 'top',
})
return
}

// 生成唯一ID
const id = generateImageId(file)

// 创建预览URL
const previewUrl = URL.createObjectURL(file)

// 添加到图片列表
imageList.value.push({
id,
nativeFile: file,
name: file.name,
size: file.size,
type: file.type || getImageTypeFromName(file.name),
previewUrl,
})
})

// 重置文件输入
localFiles.value = null
}

// 移除图片
const removeImage = (index: number): void => {
const imageItem = imageList.value[index]

// 释放预览URL
if (imageItem?.previewUrl) {
URL.revokeObjectURL(imageItem.previewUrl)
}

// 取消上传 (如果正在进行)
if (uploading.value && cancelTokens.value[imageItem!.id]) {
cancelTokens.value[imageItem!.id]!.cancel('用户取消上传')
delete cancelTokens.value[imageItem!.id]
}

// 从列表中移除
imageList.value.splice(index, 1)

$q.notify({
type: 'info',
message: '图片已移除',
position: 'top',
})
}

// 确认清空全部
const confirmClearAll = (): void => {
showClearAllConfirm.value = true
}

// 清空所有图片
const clearAllImages = (): void => {
// 释放所有预览URL
imageList.value.forEach((imageItem) => {
if (imageItem.previewUrl) {
URL.revokeObjectURL(imageItem.previewUrl)
}
})

// 取消所有进行中的上传
Object.values(cancelTokens.value).forEach((source) => {
source.cancel('用户清空所有图片')
})
cancelTokens.value = {}

// 清空图片列表
imageList.value = []
uploading.value = false

// 重置上传进度
uploadProgress.value = {}

$q.notify({
type: 'positive',
message: '已清空所有图片',
position: 'top',
})
}

// 上传图片
const uploadFiles = async (): Promise<void> => {
if (imageList.value.length === 0) return

uploading.value = true
emit('upload-started')

// 初始化上传进度
uploadProgress.value = {}
imageList.value.forEach((imageItem) => {
uploadProgress.value[imageItem.id] = {
id: imageItem.id,
name: imageItem.name,
progress: 0,
completed: false,
}
})

// 创建上传任务
const uploadPromises = imageList.value.map((imageItem) => uploadSingleImage(imageItem))

try {
const results = await Promise.allSettled(uploadPromises)

// 统计成功和失败数量
let successCount = 0
let failedCount = 0

results.forEach((result) => {
if (result.status === 'fulfilled') {
successCount++
} else {
failedCount++
}
})

// 发出完成事件
emit('upload-complete', successCount, failedCount)

// 显示结果通知
if (successCount > 0) {
$q.notify({
type: 'positive',
message: `成功上传 ${successCount} 张图片`,
position: 'top',
})
}

if (failedCount > 0) {
$q.notify({
type: 'warning',
message: `${failedCount} 张图片上传失败`,
position: 'top',
})
}

// 移除已成功上传的图片
if (successCount > 0) {
imageList.value = imageList.value.filter(
(imageItem) => !uploadProgress.value[imageItem.id]?.completed,
)
}
} catch {
$q.notify({
type: 'negative',
message: '上传过程发生错误',
position: 'top',
})
} finally {
uploading.value = false
}
}

// 上传单张图片
const uploadSingleImage = async (imageItem: ImageUploadItem): Promise<void> => {
const formData = new FormData()
formData.append('file', imageItem.nativeFile)
formData.append('category', props.category)
formData.append('original_name', imageItem.name)

// 创建取消令牌
const source = axios.CancelToken.source()
cancelTokens.value[imageItem.id] = source

try {
await apiClient.post('/knowledge/files/', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
cancelToken: source.token,
onUploadProgress: (progressEvent: AxiosProgressEvent) => {
if (progressEvent.total) {
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total)
updateProgress(imageItem.id, progress)
}
},
timeout: 300000, // 5分钟超时
})

// 标记为完成
updateProgress(imageItem.id, 100, true)
} catch (error: unknown) {
if (axios.isCancel(error)) {
// 上传被取消,不视为错误
updateProgress(imageItem.id, 0, false, '已取消')
} else {
// 处理其他错误
const message = axios.isAxiosError(error)
? error.response?.data?.message || '上传失败'
: '上传失败'

updateProgress(imageItem.id, 0, false, message)
throw new Error(`上传失败: ${imageItem.name}`)
}
} finally {
// 清理取消令牌
delete cancelTokens.value[imageItem.id]
}
}

// 工具函数
const isValidImageType = (file: File): boolean => {
const validImageTypes = [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'image/bmp',
'image/svg+xml',
]

// 检查MIME类型
if (validImageTypes.includes(file.type)) {
return true
}

// 检查文件扩展名
const validExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg']
const fileName = file.name.toLowerCase()

return validExtensions.some((ext) => fileName.endsWith(ext))
}

const getImageTypeFromName = (fileName: string): string => {
const ext = fileName.toLowerCase().split('.').pop()
switch (ext) {
case 'jpg':
case 'jpeg':
return 'image/jpeg'
case 'png':
return 'image/png'
case 'gif':
return 'image/gif'
case 'webp':
return 'image/webp'
case 'bmp':
return 'image/bmp'
case 'svg':
return 'image/svg+xml'
default:
return 'image/jpeg' // 默认假设为JPEG
}
}

const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B'

const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))

return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}

const generateImageId = (file: File): string => {
return `${file.name}-${file.size}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
}

const updateProgress = (
id: string,
progress: number,
completed: boolean = false,
error?: string,
): void => {
if (uploadProgress.value[id]) {
uploadProgress.value[id] = {
...uploadProgress.value[id],
progress,
completed,
error,
}
}
}

// 预览图片
const previewImageItem = (imageItem: ImageUploadItem): void => {
previewImage.value = {
name: imageItem.name,
url: imageItem.previewUrl,
}
showImagePreview.value = true
}

// 组件卸载时清理
onUnmounted(() => {
// 释放所有预览URL
imageList.value.forEach((imageItem) => {
if (imageItem.previewUrl) {
URL.revokeObjectURL(imageItem.previewUrl)
}
})

// 取消所有进行中的上传
Object.values(cancelTokens.value).forEach((source) => {
source.cancel('组件卸载')
})
})
</script>

<style scoped lang="scss">
.image-uploader {
max-width: 800px;
margin: 0 auto;
}

.upload-card,
.preview-card {
border-radius: 8px;
}

.image-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 16px;
}

.image-item {
position: relative;
border-radius: 8px;
overflow: hidden;
transition: transform 0.3s ease;

&:hover {
transform: translateY(-4px);

.image-actions {
opacity: 1;
}
}
}

.image-thumbnail {
border-radius: 8px;
cursor: pointer;
}

.image-actions {
opacity: 0;
transition: opacity 0.3s ease;
padding: 4px;
}

.image-info {
padding: 4px;
}

.text-ellipsis {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
```

## 主要改进

1. **添加了清空全部按钮**:
- 在预览区域的标题栏右侧添加了垃圾桶图标按钮
- 按钮带有悬停提示"清空全部图片"

2. **实现了清空功能**:
- 添加了确认对话框防止误操作
- 清空功能会释放所有预览URL
- 取消所有进行中的上传任务
- 清空图片列表和上传进度

3. **用户体验优化**:
- 确认对话框会提示当前有上传任务时会取消上传
- 清空完成后会显示成功通知
- 点击图片缩略图可以预览大图

4. **代码结构优化**:
- 保持了原有的类型安全和组合式API结构
- 添加了必要的注释说明

这个实现提供了完整的企业级清空功能,确保用户能够方便地管理已选图片,同时防止误操作和数据丢失。

```

## 主要优化点

1. **功能精简**:
- 移除了文件分类选择
- 移除了文件类型切换选项
- 专注于图片上传功能

2. **UI/UX优化**:
- 采用网格布局展示图片缩略图
- 添加悬停效果和操作按钮
- 简化了进度指示器
- 优化了大图预览体验

3. **类型安全**:
- 使用TypeScript严格类型定义
- 避免使用any类型
- 明确的组件Props和Emits定义

4. **代码结构**:
- 使用组合式API组织逻辑
- 提取可复用的工具函数
- 清晰的代码注释

5. **企业级特性**:
- 完整的错误处理机制
- 上传取消功能
- 内存管理(释放对象URL)
- 响应式设计

这个组件专注于图片上传,提供了良好的用户体验和完整的类型安全,适合在企业级项目中使用。

posted on 2025-08-21 10:49  GoGrid  阅读(23)  评论(0)    收藏  举报

导航