Files
nl-blogs/client/src/components/admin/AttachmentLibraryModal.vue
2026-01-19 21:19:35 +08:00

298 lines
9.4 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>
<Teleport to="body">
<div v-if="show" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" @click.self="handleClose">
<div class="admin-card max-w-4xl w-full mx-4 max-h-[90vh] flex flex-col">
<!-- 标题栏 -->
<div class="flex items-center justify-between mb-4 pb-4 border-b border-white/10">
<h2 class="text-xl font-serif italic text-white">从素材库选择</h2>
<button @click="handleClose" class="text-white/40 hover:text-white transition-colors">
<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">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<!-- 筛选栏 -->
<div class="mb-4 space-y-3">
<div class="flex gap-3">
<!-- 搜索框 -->
<div class="flex-1">
<input
v-model="searchKeyword"
@input="handleSearch"
type="text"
placeholder="搜索文件名..."
class="admin-input w-full"
/>
</div>
<!-- 分类筛选 -->
<div class="w-48">
<CustomSelect
v-model="filterCategoryId"
:options="categoryOptions"
placeholder="全部分类"
@update:modelValue="loadAttachments"
/>
</div>
</div>
<!-- 已选数量提示多选模式 -->
<div v-if="multiple" class="text-sm text-art-muted">
已选择 {{ selectedUrls.length }} / {{ maxCount || '' }} 张图片
</div>
</div>
<!-- 图片网格 -->
<div class="flex-1 overflow-y-auto mb-4">
<div v-if="loading" class="flex justify-center items-center h-64">
<div class="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-art-accent"></div>
</div>
<div v-else-if="attachments.length === 0" class="text-center py-20 text-art-muted">
<p>暂无图片</p>
</div>
<div v-else class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
<div
v-for="attachment in attachments"
:key="attachment.id"
@click="toggleSelect(attachment)"
class="relative group cursor-pointer"
:class="[
isSelected(attachment.fileUrl)
? 'ring-2 ring-art-accent'
: 'hover:ring-2 hover:ring-white/20'
]"
>
<div class="aspect-square rounded-lg overflow-hidden bg-white/5 border border-white/10 transition-all"
:class="isSelected(attachment.fileUrl) ? 'border-art-accent bg-art-accent/10' : ''">
<img
:src="attachment.fileUrl"
:alt="attachment.originalName"
class="w-full h-full object-cover"
/>
<!-- 选中标记 -->
<div v-if="isSelected(attachment.fileUrl)" class="absolute top-2 right-2 w-6 h-6 bg-art-accent rounded-full flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="text-black">
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
</div>
<!-- 文件名提示 -->
<div class="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center p-2">
<p class="text-xs text-white text-center truncate w-full">{{ attachment.originalName }}</p>
</div>
</div>
</div>
</div>
</div>
<!-- 分页 -->
<div v-if="totalPages > 1" class="flex items-center justify-between mb-4 pt-4 border-t border-white/10">
<div class="text-sm text-art-muted">
{{ total }} 张图片 {{ currentPage }} / {{ totalPages }}
</div>
<div class="flex gap-2">
<button
@click="goToPage(currentPage - 1)"
:disabled="currentPage === 1"
class="admin-btn-secondary text-sm px-3 py-1 disabled:opacity-50 disabled:cursor-not-allowed"
>
上一页
</button>
<button
@click="goToPage(currentPage + 1)"
:disabled="currentPage === totalPages"
class="admin-btn-secondary text-sm px-3 py-1 disabled:opacity-50 disabled:cursor-not-allowed"
>
下一页
</button>
</div>
</div>
<!-- 底部操作栏 -->
<div class="flex items-center justify-between pt-4 border-t border-white/10">
<div v-if="multiple" class="text-sm text-art-muted">
已选择 {{ selectedUrls.length }} 张图片
</div>
<div v-else></div>
<div class="flex gap-3">
<button @click="handleClose" class="admin-btn-secondary">
取消
</button>
<button
@click="handleConfirm"
:disabled="selectedUrls.length === 0"
class="admin-btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
>
确认选择 ({{ selectedUrls.length }})
</button>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { useToast } from '../../composables/useToast'
import { getAdminAttachments, getAttachmentCategories, type Attachment, type AttachmentCategory } from '../../services/api'
import CustomSelect from '../CustomSelect.vue'
const props = withDefaults(defineProps<{
show: boolean
multiple?: boolean
maxCount?: number
selectedUrls?: string[]
}>(), {
multiple: false,
selectedUrls: () => []
})
const emit = defineEmits<{
'update:show': [value: boolean]
'confirm': [urls: string[]]
}>()
const toast = useToast()
const attachments = ref<Attachment[]>([])
const categories = ref<AttachmentCategory[]>([])
const loading = ref(false)
const searchKeyword = ref('')
const filterCategoryId = ref(0)
const currentPage = ref(1)
const pageSize = ref(24)
const total = ref(0)
const selectedUrls = ref<string[]>([...props.selectedUrls])
const totalPages = computed(() => Math.ceil(total.value / pageSize.value))
const categoryOptions = computed(() => {
return [
{ value: 0, label: '全部分类' },
...categories.value.map(cat => ({ value: cat.id, label: cat.name }))
]
})
// 搜索防抖
let searchTimer: ReturnType<typeof setTimeout> | null = null
const handleSearch = () => {
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
currentPage.value = 1
loadAttachments()
}, 300)
}
const loadAttachments = async () => {
loading.value = true
try {
const result = await getAdminAttachments({
page: currentPage.value,
pageSize: pageSize.value,
categoryId: filterCategoryId.value > 0 ? filterCategoryId.value : undefined,
fileType: 'image',
keyword: searchKeyword.value || undefined
})
attachments.value = result.list
total.value = result.total
} catch (err: any) {
toast.showToast(err.message || '加载附件失败', 'error')
} finally {
loading.value = false
}
}
const loadCategories = async () => {
try {
categories.value = await getAttachmentCategories()
} catch (err) {
console.error('Failed to load categories:', err)
}
}
const goToPage = (page: number) => {
if (page < 1 || page > totalPages.value) return
currentPage.value = page
loadAttachments()
}
const isSelected = (url: string): boolean => {
return selectedUrls.value.includes(url)
}
const toggleSelect = (attachment: Attachment) => {
const url = attachment.fileUrl
const index = selectedUrls.value.indexOf(url)
if (props.multiple) {
// 多选模式
if (index > -1) {
// 取消选择
selectedUrls.value.splice(index, 1)
} else {
// 检查是否超过最大数量
if (props.maxCount && selectedUrls.value.length >= props.maxCount) {
toast.showToast(`最多只能选择 ${props.maxCount} 张图片`, 'error')
return
}
selectedUrls.value.push(url)
}
} else {
// 单选模式
if (index > -1) {
// 取消选择
selectedUrls.value = []
} else {
// 选择
selectedUrls.value = [url]
}
}
}
const handleClose = () => {
emit('update:show', false)
}
const handleConfirm = () => {
if (selectedUrls.value.length === 0) {
toast.showToast('请至少选择一张图片', 'error')
return
}
emit('confirm', [...selectedUrls.value])
emit('update:show', false)
}
// 监听show变化重置状态
watch(() => props.show, (newVal) => {
if (newVal) {
// 打开模态框时,重置搜索和筛选,但保持已选状态
searchKeyword.value = ''
filterCategoryId.value = 0
currentPage.value = 1
selectedUrls.value = [...props.selectedUrls]
loadAttachments()
}
})
// 监听selectedUrls prop变化
watch(() => props.selectedUrls, (newVal) => {
if (props.show) {
selectedUrls.value = [...newVal]
}
}, { deep: true })
onMounted(() => {
loadCategories()
if (props.show) {
loadAttachments()
}
})
</script>