Files
nl-um-vue-ts/src/components/common/AttachmentManager.vue
2025-12-14 11:08:21 +08:00

375 lines
13 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/80 z-[9999] flex items-center justify-center p-4 backdrop-blur-md animate-fade-in"
@click.self="$emit('close')"
>
<div class="bg-[#1e1e24] rounded-2xl w-full max-w-4xl shadow-2xl border border-gray-700/50 overflow-hidden flex flex-col max-h-[85vh]">
<!-- 头部 -->
<div class="px-6 py-4 flex justify-between items-center border-b border-white/5 bg-gradient-to-r from-indigo-600/10 to-purple-600/10">
<div class="flex items-center gap-3">
<i class="fas fa-folder-open text-indigo-400 text-xl"></i>
<h2 class="text-lg font-bold text-white">附件管理</h2>
</div>
<button
class="w-8 h-8 rounded-full bg-black/20 hover:bg-black/40 text-gray-400 hover:text-white transition flex items-center justify-center"
@click="$emit('close')"
>
<i class="fas fa-times"></i>
</button>
</div>
<!-- 筛选栏 -->
<div class="px-6 py-4 border-b border-white/5 flex items-center gap-4">
<div class="flex gap-2">
<button
v-for="filter in filters"
:key="filter.value"
class="px-4 py-2 rounded-lg text-sm transition border"
:class="currentFilter === filter.value
? 'bg-indigo-600 text-white border-indigo-500'
: 'bg-gray-700/50 text-gray-300 border-gray-600 hover:bg-gray-600'"
@click="currentFilter = filter.value; loadAttachments()"
>
<i :class="filter.icon" class="mr-2"></i>
{{ filter.label }}
</button>
</div>
<div class="flex-1"></div>
<button
class="px-4 py-2 bg-gray-700/50 text-gray-300 rounded-lg hover:bg-gray-600 transition text-sm"
@click="loadAttachments"
:disabled="loading"
>
<i class="fas fa-sync-alt mr-2" :class="{ 'fa-spin': loading }"></i>
刷新
</button>
</div>
<!-- 内容区 -->
<div class="flex-1 overflow-y-auto custom-scrollbar p-6">
<!-- 加载中 -->
<div v-if="loading && attachments.length === 0" class="flex justify-center py-16">
<div class="animate-spin rounded-full h-10 w-10 border-b-2 border-indigo-500"></div>
</div>
<!-- 空状态 -->
<div v-else-if="attachments.length === 0" class="flex flex-col items-center justify-center py-16">
<div class="w-24 h-24 bg-gray-800 rounded-full flex items-center justify-center mb-4">
<i class="fas fa-folder-open text-4xl text-gray-600"></i>
</div>
<p class="text-gray-400">暂无附件</p>
</div>
<!-- 附件列表 -->
<div v-else class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
<div
v-for="attachment in attachments"
:key="attachment.id"
class="relative group bg-gray-800/50 rounded-xl overflow-hidden border border-gray-700/50 hover:border-indigo-500/50 transition"
>
<!-- 图片预览 -->
<div
v-if="attachment.type === 'image'"
class="aspect-square overflow-hidden cursor-pointer"
@click="previewImage(attachment)"
>
<img
:src="attachment.file_url"
:alt="attachment.file_name"
class="w-full h-full object-cover group-hover:scale-105 transition-transform"
loading="lazy"
/>
</div>
<!-- 视频预览 -->
<div
v-else-if="attachment.type === 'video'"
class="aspect-square bg-gray-900 flex items-center justify-center cursor-pointer"
@click="previewVideo(attachment)"
>
<i class="fas fa-play-circle text-4xl text-indigo-400"></i>
</div>
<!-- 文件预览 -->
<div
v-else
class="aspect-square bg-gray-900 flex flex-col items-center justify-center cursor-pointer"
@click="downloadFile(attachment)"
>
<i class="fas fa-file text-3xl text-gray-500 mb-2"></i>
<span class="text-xs text-gray-400 px-2 truncate w-full text-center">{{ attachment.file_name }}</span>
</div>
<!-- 文件信息 -->
<div class="p-2 border-t border-gray-700/50">
<p class="text-xs text-gray-300 truncate" :title="attachment.file_name">
{{ attachment.file_name }}
</p>
<div class="flex items-center justify-between mt-1">
<span class="text-[10px] text-gray-500">
{{ formatFileSize(attachment.file_size || 0) }}
</span>
<span class="text-[10px] text-gray-500">
{{ formatTime(attachment.created_at) }}
</span>
</div>
</div>
<!-- 删除按钮 -->
<button
class="absolute top-2 right-2 w-7 h-7 rounded-full bg-red-500/80 text-white opacity-0 group-hover:opacity-100 transition flex items-center justify-center hover:bg-red-600"
@click.stop="confirmDelete(attachment)"
:disabled="deletingId === attachment.id"
>
<i class="fas text-xs" :class="deletingId === attachment.id ? 'fa-circle-notch fa-spin' : 'fa-trash'"></i>
</button>
</div>
</div>
<!-- 加载更多 -->
<div v-if="hasMore" class="flex justify-center py-6">
<button
v-if="!loading"
class="px-6 py-2 bg-gray-700 text-gray-300 rounded-lg hover:bg-gray-600 transition text-sm"
@click="loadMore"
>
加载更多
</button>
<div v-else class="animate-spin rounded-full h-6 w-6 border-b-2 border-indigo-500"></div>
</div>
</div>
<!-- 统计信息 -->
<div class="px-6 py-3 border-t border-white/5 bg-black/20 flex items-center justify-between text-xs text-gray-500">
<span>共 {{ total }} 个附件</span>
<span>已使用 {{ formatFileSize(totalSize) }}</span>
</div>
</div>
<!-- 图片预览 -->
<div
v-if="previewImageUrl"
class="fixed inset-0 bg-black/90 z-[10000] flex items-center justify-center"
@click="previewImageUrl = ''"
>
<img :src="previewImageUrl" class="max-w-[90vw] max-h-[90vh] object-contain" />
<button
class="absolute top-6 right-6 w-10 h-10 rounded-full bg-white/10 text-white hover:bg-white/20 transition flex items-center justify-center"
@click="previewImageUrl = ''"
>
<i class="fas fa-times text-xl"></i>
</button>
</div>
<!-- 视频预览 -->
<div
v-if="previewVideoUrl"
class="fixed inset-0 bg-black/90 z-[10000] flex items-center justify-center"
@click.self="previewVideoUrl = ''"
>
<video
:src="previewVideoUrl"
controls
autoplay
class="max-w-[90vw] max-h-[90vh]"
></video>
<button
class="absolute top-6 right-6 w-10 h-10 rounded-full bg-white/10 text-white hover:bg-white/20 transition flex items-center justify-center"
@click="previewVideoUrl = ''"
>
<i class="fas fa-times text-xl"></i>
</button>
</div>
<!-- 删除确认 -->
<div
v-if="deletingAttachment"
class="fixed inset-0 bg-black/80 z-[10000] flex items-center justify-center"
@click.self="deletingAttachment = null"
>
<div class="bg-gray-800 rounded-xl p-6 w-80 text-center border border-gray-700 shadow-2xl">
<div class="w-16 h-16 bg-red-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
<i class="fas fa-trash-alt text-2xl text-red-500"></i>
</div>
<h3 class="text-white font-bold mb-2">删除附件</h3>
<p class="text-gray-400 text-sm mb-6">确定要删除这个附件吗?此操作不可恢复。</p>
<div class="flex gap-3">
<button
class="flex-1 py-2 bg-gray-700 text-white rounded-lg hover:bg-gray-600 text-sm"
@click="deletingAttachment = null"
>
取消
</button>
<button
class="flex-1 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 text-sm"
:disabled="deletingId !== null"
@click="handleDelete"
>
<i v-if="deletingId" class="fas fa-circle-notch fa-spin mr-1"></i>
确定删除
</button>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import * as attachmentApi from '@/api/modules/attachment'
import type { Attachment } from '@/types/api'
const props = defineProps<{
show: boolean
}>()
const emit = defineEmits(['close'])
// 筛选选项
const filters = [
{ value: '', label: '全部', icon: 'fas fa-th' },
{ value: 'image', label: '图片', icon: 'fas fa-image' },
{ value: 'video', label: '视频', icon: 'fas fa-video' },
{ value: 'file', label: '文件', icon: 'fas fa-file' },
]
// 状态
const currentFilter = ref('')
const attachments = ref<Attachment[]>([])
const loading = ref(false)
const page = ref(1)
const pageSize = 20
const total = ref(0)
const totalSize = ref(0)
const hasMore = ref(true)
// 预览
const previewImageUrl = ref('')
const previewVideoUrl = ref('')
// 删除
const deletingAttachment = ref<Attachment | null>(null)
const deletingId = ref<number | null>(null)
// 加载附件列表
async function loadAttachments(reset = true) {
if (reset) {
page.value = 1
attachments.value = []
totalSize.value = 0
}
loading.value = true
try {
const response = await attachmentApi.getAttachments(currentFilter.value || undefined, page.value, pageSize)
if (reset) {
attachments.value = response.data || []
} else {
attachments.value.push(...(response.data || []))
}
total.value = response.total || 0
hasMore.value = attachments.value.length < total.value
// 计算总大小
totalSize.value = attachments.value.reduce((sum, a) => sum + (a.file_size || 0), 0)
} catch (e) {
console.error('加载附件失败', e)
} finally {
loading.value = false
}
}
// 加载更多
function loadMore() {
page.value++
loadAttachments(false)
}
// 预览图片
function previewImage(attachment: Attachment) {
previewImageUrl.value = attachment.file_url
}
// 预览视频
function previewVideo(attachment: Attachment) {
previewVideoUrl.value = attachment.file_url
}
// 下载文件
function downloadFile(attachment: Attachment) {
const link = document.createElement('a')
link.href = attachment.file_url
link.download = attachment.file_name
link.target = '_blank'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
// 确认删除
function confirmDelete(attachment: Attachment) {
deletingAttachment.value = attachment
}
// 执行删除
async function handleDelete() {
if (!deletingAttachment.value) return
deletingId.value = deletingAttachment.value.id
try {
await attachmentApi.deleteAttachment(deletingAttachment.value.id)
// 从列表中移除
const index = attachments.value.findIndex(a => a.id === deletingAttachment.value!.id)
if (index > -1) {
attachments.value.splice(index, 1)
total.value--
}
deletingAttachment.value = null
} catch (e) {
console.error('删除失败', e)
} finally {
deletingId.value = null
}
}
// 格式化文件大小
function 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]
}
// 格式化时间
function formatTime(time?: string): string {
if (!time) return ''
const date = new Date(time)
return `${date.getMonth() + 1}/${date.getDate()}`
}
// 监听显示状态
watch(() => props.show, (val) => {
if (val) {
loadAttachments()
}
})
</script>
<style scoped>
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
.custom-scrollbar::-webkit-scrollbar-thumb { @apply bg-gray-700 rounded-full; }
.animate-fade-in {
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
</style>