Files
ApiServer-Web-admin_dashboa…/src/components/admin/ImageSelector.vue
T
lin cd16ec17ae
Build and Deploy Vue3 / build (push) Successful in 1m33s
Build and Deploy Vue3 / deploy (push) Successful in 1m15s
feat: 新增移动端配置信息
2026-03-17 18:40:12 +08:00

685 lines
17 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>
<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">
支持jpgpnggifwebp等图片格式单个文件不超过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>