685 lines
17 KiB
Vue
685 lines
17 KiB
Vue
<template>
|
||
<el-dialog
|
||
v-model="visible"
|
||
title="选择图片"
|
||
width="900px"
|
||
append-to-body
|
||
@close="handleClose"
|
||
>
|
||
<div class="image-selector">
|
||
<el-tabs v-model="activeTab" @tab-click="handleTabClick">
|
||
<!-- 文件库 -->
|
||
<el-tab-pane label="文件库" name="fileLibrary">
|
||
<div class="file-list-container">
|
||
<div class="file-list-header">
|
||
<h4>图片文件库</h4>
|
||
<div class="header-actions">
|
||
<span v-if="props.multiple && selectedIds.size > 0" class="selected-count">
|
||
已选 {{ selectedIds.size }} 个文件
|
||
</span>
|
||
<el-button type="primary" @click="switchToUpload" :icon="Upload">
|
||
上传新图片
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 搜索过滤 -->
|
||
<div class="filter-section">
|
||
<el-input
|
||
v-model="searchKeyword"
|
||
placeholder="搜索文件名"
|
||
:prefix-icon="Search"
|
||
clearable
|
||
@input="handleSearch"
|
||
style="width: 300px;"
|
||
/>
|
||
</div>
|
||
|
||
<div class="file-grid" v-loading="loading">
|
||
<div
|
||
v-for="file in filteredFileList"
|
||
:key="file.id"
|
||
class="file-item"
|
||
:class="{ 'selected': props.multiple ? selectedIds.has(file.id) : selectedId === file.id }"
|
||
@click="selectFile(file)"
|
||
>
|
||
<div class="file-check-badge" v-if="props.multiple && selectedIds.has(file.id)">
|
||
<el-icon><Select /></el-icon>
|
||
</div>
|
||
<div class="file-preview">
|
||
<img
|
||
:src="processImageUrl(file.url)"
|
||
:alt="file.realName"
|
||
@error="handleImageError"
|
||
/>
|
||
</div>
|
||
<div class="file-info">
|
||
<p class="file-name" :title="file.realName">{{ file.realName }}</p>
|
||
<p class="file-size">{{ formatFileSize(file.size) }}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<el-empty v-if="filteredFileList.length === 0 && !loading" description="暂无图片文件" />
|
||
|
||
<!-- 分页 -->
|
||
<div class="pagination-container" v-if="total > 0">
|
||
<el-pagination
|
||
v-model:current-page="currentPage"
|
||
v-model:page-size="pageSize"
|
||
:page-sizes="[12, 24, 36, 48]"
|
||
:total="total"
|
||
layout="total, sizes, prev, pager, next, jumper"
|
||
background
|
||
@size-change="handleSizeChange"
|
||
@current-change="handlePageChange"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</el-tab-pane>
|
||
|
||
<!-- 上传图片 -->
|
||
<el-tab-pane label="上传图片" name="upload">
|
||
<div class="upload-section">
|
||
<el-upload
|
||
:auto-upload="false"
|
||
:show-file-list="false"
|
||
:on-change="handleFileChange"
|
||
accept="image/*"
|
||
multiple
|
||
drag
|
||
>
|
||
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
|
||
<div class="el-upload__text">
|
||
将文件拖到此处,或<em>点击上传</em>
|
||
</div>
|
||
<template #tip>
|
||
<div class="el-upload__tip">
|
||
支持jpg、png、gif、webp等图片格式,单个文件不超过5MB
|
||
</div>
|
||
</template>
|
||
</el-upload>
|
||
|
||
<!-- 待上传文件列表 -->
|
||
<div v-if="pendingFiles.length > 0" class="pending-files">
|
||
<div class="pending-header">
|
||
<h4>待上传文件 ({{ pendingFiles.length }})</h4>
|
||
<el-button type="danger" link @click="pendingFiles = []">清空</el-button>
|
||
</div>
|
||
<div class="pending-list">
|
||
<div v-for="(file, index) in pendingFiles" :key="index" class="pending-item">
|
||
<img :src="file.previewUrl" class="pending-preview" />
|
||
<span class="pending-name" :title="file.name">{{ file.name }}</span>
|
||
<span class="pending-size">{{ formatFileSize(file.size) }}</span>
|
||
<el-button type="danger" link size="small" @click="removePendingFile(index)">移除</el-button>
|
||
</div>
|
||
</div>
|
||
<el-button
|
||
type="primary"
|
||
@click="handleBatchUpload"
|
||
:loading="uploading"
|
||
style="margin-top: 16px; width: 100%;"
|
||
>
|
||
开始上传 ({{ pendingFiles.length }} 个文件)
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
</el-tab-pane>
|
||
</el-tabs>
|
||
</div>
|
||
|
||
<template #footer>
|
||
<div class="dialog-footer">
|
||
<el-button @click="handleClose">取消</el-button>
|
||
<el-button
|
||
type="primary"
|
||
@click="handleConfirm"
|
||
:disabled="props.multiple ? selectedIds.size === 0 : !selectedId"
|
||
>
|
||
确定选择{{ props.multiple && selectedIds.size > 0 ? ` (${selectedIds.size})` : '' }}
|
||
</el-button>
|
||
</div>
|
||
</template>
|
||
</el-dialog>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, watch, computed } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import { Upload, UploadFilled, Search, Select, Delete } from '@element-plus/icons-vue'
|
||
import { getFileList, getFileDetail, uploadFile } from '@/api/admin/file'
|
||
|
||
// Props
|
||
const props = defineProps({
|
||
modelValue: {
|
||
type: Boolean,
|
||
default: false
|
||
},
|
||
currentFileId: {
|
||
type: [String, Number],
|
||
default: ''
|
||
},
|
||
multiple: {
|
||
type: Boolean,
|
||
default: false
|
||
}
|
||
})
|
||
|
||
// Emits
|
||
const emit = defineEmits(['update:modelValue', 'confirm'])
|
||
|
||
// 响应式数据
|
||
const visible = ref(false)
|
||
const activeTab = ref('fileLibrary')
|
||
const fileList = ref([])
|
||
const loading = ref(false)
|
||
const selectedId = ref('')
|
||
const selectedIds = ref(new Set()) // 多选模式下选中的文件ID集合
|
||
const currentPage = ref(1)
|
||
const pageSize = ref(12)
|
||
const total = ref(0)
|
||
const searchKeyword = ref('')
|
||
const pendingFiles = ref([]) // 待上传文件列表
|
||
const uploading = ref(false) // 批量上传中
|
||
let fetchVersion = 0 // 防止 fetchFileList 竞态条件
|
||
|
||
// 监听 modelValue 变化
|
||
watch(() => props.modelValue, (newVal) => {
|
||
visible.value = newVal
|
||
if (newVal) {
|
||
selectedId.value = props.currentFileId
|
||
selectedIds.value = new Set()
|
||
currentPage.value = 1
|
||
searchKeyword.value = ''
|
||
fetchFileList()
|
||
}
|
||
})
|
||
|
||
// 监听 visible 变化
|
||
watch(visible, (newVal) => {
|
||
emit('update:modelValue', newVal)
|
||
})
|
||
|
||
// 过滤后的文件列表
|
||
const filteredFileList = computed(() => {
|
||
if (!searchKeyword.value) {
|
||
return fileList.value
|
||
}
|
||
return fileList.value.filter(file =>
|
||
file.realName?.toLowerCase().includes(searchKeyword.value.toLowerCase())
|
||
)
|
||
})
|
||
|
||
// 处理图片URL,确保正确显示
|
||
const processImageUrl = (url) => {
|
||
if (!url) return ''
|
||
// 先处理转义字符:将 \u0026 替换为 &
|
||
let processedUrl = url.replace(/\\u0026/g, '&')
|
||
// 再进行URL解码
|
||
return decodeURIComponent(processedUrl)
|
||
}
|
||
|
||
// 获取文件列表(带版本号防止竞态条件)
|
||
const fetchFileList = async () => {
|
||
const currentFetchVersion = ++fetchVersion
|
||
loading.value = true
|
||
|
||
try {
|
||
const res = await getFileList({
|
||
page: currentPage.value,
|
||
count: pageSize.value
|
||
})
|
||
|
||
// 如果有更新的请求发起,丢弃当前结果
|
||
if (currentFetchVersion !== fetchVersion) return
|
||
|
||
if (res.data.code === 200) {
|
||
const list = res.data.data.list || []
|
||
total.value = res.data.data.all_count || 0
|
||
|
||
// 并行获取所有文件详情(替代逐个串行,大幅提升速度)
|
||
const detailPromises = list.map(item =>
|
||
getFileDetail({ file_id: item.id })
|
||
.then(res2 => {
|
||
if (res2.data.code === 200) {
|
||
return {
|
||
id: res2.data.data.data.id,
|
||
url: res2.data.data.url,
|
||
size: res2.data.data.data.size,
|
||
realName: res2.data.data.data.realName
|
||
}
|
||
}
|
||
return null
|
||
})
|
||
.catch(error => {
|
||
console.error('获取文件详情失败:', error)
|
||
return null
|
||
})
|
||
)
|
||
|
||
const results = await Promise.all(detailPromises)
|
||
|
||
// 再次检查版本号,防止旧结果覆盖新结果
|
||
if (currentFetchVersion !== fetchVersion) return
|
||
|
||
fileList.value = results.filter(item => item !== null)
|
||
}
|
||
} catch (error) {
|
||
if (currentFetchVersion === fetchVersion) {
|
||
console.error('获取文件列表失败:', error)
|
||
ElMessage.error('获取文件列表失败')
|
||
}
|
||
} finally {
|
||
if (currentFetchVersion === fetchVersion) {
|
||
loading.value = false
|
||
}
|
||
}
|
||
}
|
||
|
||
// 处理标签页切换
|
||
const handleTabClick = (tab) => {
|
||
if (tab.name === 'fileLibrary') {
|
||
currentPage.value = 1
|
||
fetchFileList()
|
||
}
|
||
}
|
||
|
||
// 处理搜索
|
||
const handleSearch = () => {
|
||
// 搜索时重置到第一页
|
||
currentPage.value = 1
|
||
}
|
||
|
||
// 分页处理
|
||
const handleSizeChange = (size) => {
|
||
pageSize.value = size
|
||
currentPage.value = 1
|
||
fetchFileList()
|
||
}
|
||
|
||
const handlePageChange = (page) => {
|
||
currentPage.value = page
|
||
fetchFileList()
|
||
}
|
||
|
||
// 切换到上传标签页
|
||
const switchToUpload = () => {
|
||
activeTab.value = 'upload'
|
||
}
|
||
|
||
// 格式化文件大小
|
||
const formatFileSize = (size) => {
|
||
if (!size) return '0 B'
|
||
const units = ['B', 'KB', 'MB', 'GB']
|
||
let unitIndex = 0
|
||
let fileSize = size
|
||
|
||
while (fileSize >= 1024 && unitIndex < units.length - 1) {
|
||
fileSize /= 1024
|
||
unitIndex++
|
||
}
|
||
|
||
return `${fileSize.toFixed(1)} ${units[unitIndex]}`
|
||
}
|
||
|
||
// 选择文件
|
||
const selectFile = (file) => {
|
||
if (props.multiple) {
|
||
// 多选模式:切换选中状态
|
||
const newSet = new Set(selectedIds.value)
|
||
if (newSet.has(file.id)) {
|
||
newSet.delete(file.id)
|
||
} else {
|
||
newSet.add(file.id)
|
||
}
|
||
selectedIds.value = newSet
|
||
} else {
|
||
selectedId.value = file.id
|
||
}
|
||
}
|
||
|
||
// 文件选择变化(收集待上传文件)
|
||
const handleFileChange = (file) => {
|
||
const rawFile = file.raw
|
||
if (!rawFile) return
|
||
|
||
// 验证文件类型
|
||
const isImage = rawFile.type.startsWith('image/')
|
||
if (!isImage) {
|
||
ElMessage.error(`${rawFile.name} 不是图片文件,已跳过`)
|
||
return
|
||
}
|
||
|
||
// 验证文件大小
|
||
const isLt5M = rawFile.size / 1024 / 1024 < 5
|
||
if (!isLt5M) {
|
||
ElMessage.error(`${rawFile.name} 超过 5MB,已跳过`)
|
||
return
|
||
}
|
||
|
||
// 检查是否重复添加
|
||
const exists = pendingFiles.value.some(f => f.name === rawFile.name && f.size === rawFile.size)
|
||
if (exists) return
|
||
|
||
// 添加到待上传列表,生成本地预览URL
|
||
pendingFiles.value.push({
|
||
raw: rawFile,
|
||
name: rawFile.name,
|
||
size: rawFile.size,
|
||
previewUrl: URL.createObjectURL(rawFile)
|
||
})
|
||
}
|
||
|
||
// 移除待上传文件
|
||
const removePendingFile = (index) => {
|
||
const file = pendingFiles.value[index]
|
||
if (file?.previewUrl) {
|
||
URL.revokeObjectURL(file.previewUrl)
|
||
}
|
||
pendingFiles.value.splice(index, 1)
|
||
}
|
||
|
||
// 批量上传(所有文件合并为一次请求,多个 file_names 和 files 条目)
|
||
const handleBatchUpload = async () => {
|
||
if (pendingFiles.value.length === 0) {
|
||
ElMessage.warning('请先选择要上传的文件')
|
||
return
|
||
}
|
||
|
||
uploading.value = true
|
||
|
||
const formData = new FormData()
|
||
pendingFiles.value.forEach(file => {
|
||
formData.append('file_names', file.name)
|
||
formData.append('files', file.raw)
|
||
})
|
||
formData.append('update_type', 'cover')
|
||
formData.append('open_down', 'true')
|
||
|
||
try {
|
||
const res = await uploadFile(formData)
|
||
|
||
if (res.data.code === 200) {
|
||
const count = pendingFiles.value.length
|
||
// 释放所有预览URL
|
||
pendingFiles.value.forEach(f => {
|
||
if (f.previewUrl) URL.revokeObjectURL(f.previewUrl)
|
||
})
|
||
pendingFiles.value = []
|
||
ElMessage.success(`成功上传 ${count} 个文件`)
|
||
|
||
// 刷新文件列表并切换到文件库
|
||
currentPage.value = 1
|
||
await fetchFileList()
|
||
activeTab.value = 'fileLibrary'
|
||
} else {
|
||
ElMessage.error(res.data.msg || '上传失败')
|
||
}
|
||
} catch (error) {
|
||
console.error('批量上传失败:', error)
|
||
ElMessage.error('上传失败,请重试')
|
||
} finally {
|
||
uploading.value = false
|
||
}
|
||
}
|
||
|
||
// 图片加载错误处理
|
||
const handleImageError = (event) => {
|
||
event.target.style.display = 'none'
|
||
}
|
||
|
||
// 关闭对话框
|
||
const handleClose = () => {
|
||
visible.value = false
|
||
selectedId.value = ''
|
||
selectedIds.value = new Set()
|
||
fileList.value = []
|
||
currentPage.value = 1
|
||
total.value = 0
|
||
searchKeyword.value = ''
|
||
// 清理待上传文件的预览URL
|
||
pendingFiles.value.forEach(f => {
|
||
if (f.previewUrl) URL.revokeObjectURL(f.previewUrl)
|
||
})
|
||
pendingFiles.value = []
|
||
}
|
||
|
||
// 确认选择
|
||
const handleConfirm = () => {
|
||
if (props.multiple) {
|
||
// 多选模式:返回选中的文件数组
|
||
if (selectedIds.value.size === 0) return
|
||
const selectedFiles = fileList.value
|
||
.filter(file => selectedIds.value.has(file.id))
|
||
.map(file => ({
|
||
id: file.id,
|
||
url: file.url || '',
|
||
realName: file.realName || ''
|
||
}))
|
||
emit('confirm', selectedFiles)
|
||
handleClose()
|
||
} else {
|
||
// 单选模式:返回单个文件对象
|
||
if (selectedId.value) {
|
||
const selectedFile = fileList.value.find(file => file.id === selectedId.value)
|
||
emit('confirm', {
|
||
id: selectedId.value,
|
||
url: selectedFile?.url || '',
|
||
realName: selectedFile?.realName || ''
|
||
})
|
||
handleClose()
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.image-selector {
|
||
min-height: 500px;
|
||
}
|
||
|
||
.file-list-container {
|
||
padding: 20px 0;
|
||
}
|
||
|
||
.file-list-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.file-list-header h4 {
|
||
margin: 0;
|
||
color: #303133;
|
||
}
|
||
|
||
.filter-section {
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.file-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||
gap: 16px;
|
||
max-height: 450px;
|
||
overflow-y: auto;
|
||
padding: 10px 0;
|
||
}
|
||
|
||
.file-item {
|
||
border: 2px solid #e4e7ed;
|
||
border-radius: 8px;
|
||
padding: 12px;
|
||
cursor: pointer;
|
||
transition: all 0.3s ease;
|
||
text-align: center;
|
||
background: #fff;
|
||
}
|
||
|
||
.file-item:hover {
|
||
border-color: #409EFF;
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.2);
|
||
}
|
||
|
||
.file-item.selected {
|
||
border-color: #409EFF;
|
||
background-color: #f0f9ff;
|
||
}
|
||
|
||
.file-item {
|
||
position: relative;
|
||
}
|
||
|
||
.file-check-badge {
|
||
position: absolute;
|
||
top: 6px;
|
||
right: 6px;
|
||
width: 22px;
|
||
height: 22px;
|
||
background-color: #409EFF;
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #fff;
|
||
font-size: 14px;
|
||
z-index: 1;
|
||
box-shadow: 0 2px 4px rgba(64, 158, 255, 0.4);
|
||
}
|
||
|
||
.selected-count {
|
||
color: #409EFF;
|
||
font-weight: 600;
|
||
font-size: 14px;
|
||
margin-right: 12px;
|
||
}
|
||
|
||
.header-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.file-preview {
|
||
width: 100px;
|
||
height: 100px;
|
||
margin: 0 auto 8px;
|
||
border-radius: 4px;
|
||
overflow: hidden;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background-color: #f5f7fa;
|
||
}
|
||
|
||
.file-preview img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
}
|
||
|
||
.file-info {
|
||
text-align: center;
|
||
}
|
||
|
||
.file-name {
|
||
font-size: 12px;
|
||
color: #303133;
|
||
margin: 0 0 4px 0;
|
||
word-break: break-all;
|
||
line-height: 1.3;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.file-size {
|
||
font-size: 11px;
|
||
color: #909399;
|
||
margin: 0;
|
||
}
|
||
|
||
.upload-section {
|
||
padding: 20px;
|
||
text-align: center;
|
||
}
|
||
|
||
/* 待上传文件列表 */
|
||
.pending-files {
|
||
margin-top: 20px;
|
||
text-align: left;
|
||
}
|
||
|
||
.pending-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.pending-header h4 {
|
||
margin: 0;
|
||
color: #303133;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.pending-list {
|
||
max-height: 240px;
|
||
overflow-y: auto;
|
||
border: 1px solid #ebeef5;
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.pending-item {
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 8px 12px;
|
||
border-bottom: 1px solid #f0f0f0;
|
||
gap: 10px;
|
||
}
|
||
|
||
.pending-item:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.pending-item:hover {
|
||
background-color: #fafafa;
|
||
}
|
||
|
||
.pending-preview {
|
||
width: 40px;
|
||
height: 40px;
|
||
border-radius: 4px;
|
||
object-fit: cover;
|
||
flex-shrink: 0;
|
||
border: 1px solid #ebeef5;
|
||
}
|
||
|
||
.pending-name {
|
||
flex: 1;
|
||
font-size: 13px;
|
||
color: #303133;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.pending-size {
|
||
font-size: 12px;
|
||
color: #909399;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.pagination-container {
|
||
margin-top: 20px;
|
||
display: flex;
|
||
justify-content: center;
|
||
}
|
||
|
||
.dialog-footer {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 10px;
|
||
}
|
||
</style>
|