375 lines
13 KiB
Vue
375 lines
13 KiB
Vue
<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>
|
||
|