386 lines
12 KiB
Vue
386 lines
12 KiB
Vue
<template>
|
||
<div class="w-full animate-reveal">
|
||
<div class="flex items-center justify-between mb-6">
|
||
<h1 class="text-2xl font-serif italic text-white">附件管理</h1>
|
||
<button @click="showUploadModal = true" class="admin-btn-primary">
|
||
上传附件
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Filters -->
|
||
<AdminTableFilter
|
||
v-model:keyword="searchKeyword"
|
||
keyword-placeholder="搜索文件名..."
|
||
@search="loadAttachments"
|
||
@reset="resetFilters"
|
||
>
|
||
<template #filters>
|
||
<div class="filter-field">
|
||
<label>分类</label>
|
||
<CustomSelect
|
||
v-model="filterCategoryId"
|
||
:options="categoryOptions"
|
||
placeholder="全部"
|
||
style="width: 140px;"
|
||
/>
|
||
</div>
|
||
<div class="filter-field">
|
||
<label>类型</label>
|
||
<CustomSelect
|
||
v-model="filterFileType"
|
||
:options="fileTypeOptions"
|
||
placeholder="全部"
|
||
style="width: 120px;"
|
||
/>
|
||
</div>
|
||
</template>
|
||
</AdminTableFilter>
|
||
|
||
<!-- Attachments Grid -->
|
||
<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="openDetailModal(attachment)"
|
||
class="admin-card p-4 group cursor-pointer hover:border-art-accent transition-colors"
|
||
>
|
||
<div v-if="attachment.fileType === 'image'" class="aspect-square mb-2 rounded overflow-hidden bg-white/5">
|
||
<img :src="attachment.fileUrl" :alt="attachment.originalName" class="w-full h-full object-cover" />
|
||
</div>
|
||
<div v-else-if="attachment.fileType === 'video'" class="aspect-square mb-2 rounded overflow-hidden bg-black/40 relative">
|
||
<video
|
||
:src="attachment.fileUrl"
|
||
muted
|
||
preload="metadata"
|
||
class="w-full h-full object-cover"
|
||
/>
|
||
<div class="absolute inset-0 flex items-center justify-center bg-black/30 pointer-events-none">
|
||
<div class="w-10 h-10 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center border border-white/30">
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="white">
|
||
<polygon points="8 5 19 12 8 19 8 5"></polygon>
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-else class="aspect-square mb-2 rounded bg-white/5 flex items-center justify-center">
|
||
<i data-lucide="file" class="w-12 h-12 text-art-muted"></i>
|
||
</div>
|
||
|
||
<p class="text-xs text-white truncate mb-1" :title="attachment.originalName">
|
||
{{ attachment.originalName }}
|
||
</p>
|
||
<p class="text-xs text-art-muted mb-2">
|
||
{{ formatFileSize(attachment.fileSize) }}
|
||
</p>
|
||
|
||
<div class="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||
<button
|
||
@click.stop="copyUrl(attachment.fileUrl)"
|
||
class="flex-1 px-2 py-1 text-xs bg-white/5 hover:bg-white/10 rounded transition-colors"
|
||
>
|
||
复制链接
|
||
</button>
|
||
<button
|
||
@click.stop="deleteAttachment(attachment.id)"
|
||
class="px-2 py-1 text-xs bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded transition-colors"
|
||
>
|
||
删除
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Detail Modal -->
|
||
<AttachmentDetailModal
|
||
:show="showDetailModal"
|
||
:attachment="selectedAttachment"
|
||
:categories="categories"
|
||
@update:show="showDetailModal = $event"
|
||
@save="handleSaveCategory"
|
||
@copy-url="copyUrl"
|
||
/>
|
||
|
||
<!-- Upload Modal -->
|
||
<div v-if="showUploadModal" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" @click.self="showUploadModal = false">
|
||
<div class="admin-card max-w-md w-full mx-4">
|
||
<h2 class="text-xl font-serif italic text-white mb-4">上传附件</h2>
|
||
|
||
<div class="space-y-4">
|
||
<div>
|
||
<label class="block text-sm text-art-muted mb-2">选择文件</label>
|
||
<div
|
||
@click="triggerUploadSelect"
|
||
@dragover.prevent="handleDragOver"
|
||
@dragenter.prevent="handleDragEnter"
|
||
@dragleave.prevent="handleDragLeave"
|
||
@drop.prevent="handleDrop"
|
||
class="border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors"
|
||
:class="[
|
||
isDragging ? 'border-art-accent bg-art-accent/5' : 'border-white/20 hover:border-art-accent/50',
|
||
uploading ? 'opacity-50 cursor-not-allowed' : ''
|
||
]"
|
||
>
|
||
<input
|
||
type="file"
|
||
ref="uploadInput"
|
||
@change="handleUploadFileSelect"
|
||
class="hidden"
|
||
/>
|
||
<p class="text-sm text-art-muted">点击或拖拽文件到此处上传</p>
|
||
<p v-if="pendingFileName" class="text-xs text-art-accent mt-2">已选择:{{ pendingFileName }}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label class="block text-sm text-art-muted mb-2">分类(可选)</label>
|
||
<CustomSelect
|
||
v-model="uploadCategoryId"
|
||
:options="uploadCategoryOptions"
|
||
placeholder="无分类"
|
||
/>
|
||
</div>
|
||
|
||
<div class="flex gap-3">
|
||
<button type="button" @click="showUploadModal = false" class="admin-btn-secondary flex-1">
|
||
取消
|
||
</button>
|
||
<button type="button" @click="uploadFile" :disabled="uploading" class="admin-btn-primary flex-1">
|
||
{{ uploading ? '上传中...' : '上传' }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, onMounted, computed } from 'vue'
|
||
import { useToast } from '../../composables/useToast'
|
||
import CustomSelect from '../../components/CustomSelect.vue'
|
||
import AdminTableFilter from '../../components/admin/AdminTableFilter.vue'
|
||
import AttachmentDetailModal from '../../components/admin/AttachmentDetailModal.vue'
|
||
import { getAdminAttachments, updateAttachment, API_BASE, authFetch, parseApiResponse, type Attachment } from '../../services/api'
|
||
import { useImageDropUpload } from '../../composables/useImageDropUpload'
|
||
|
||
const toast = useToast()
|
||
|
||
interface LocalAttachment extends Omit<Attachment, 'mimeType' | 'createdAt' | 'storageType'> {
|
||
categoryId?: number
|
||
mimeType?: string
|
||
storageType?: string
|
||
createdAt?: string
|
||
}
|
||
|
||
interface Category {
|
||
id: number
|
||
name: string
|
||
}
|
||
|
||
const attachments = ref<LocalAttachment[]>([])
|
||
const categories = ref<Category[]>([])
|
||
const loading = ref(false)
|
||
const showUploadModal = ref(false)
|
||
const showDetailModal = ref(false)
|
||
const selectedAttachment = ref<LocalAttachment | null>(null)
|
||
const uploadInput = ref<HTMLInputElement | null>(null)
|
||
const uploadCategoryId = ref(0)
|
||
const uploading = ref(false)
|
||
const pendingFileName = ref('')
|
||
const filterCategoryId = ref(0)
|
||
const filterFileType = ref('')
|
||
const searchKeyword = ref('')
|
||
|
||
// 计算属性:分类选项
|
||
const categoryOptions = computed(() => {
|
||
return [
|
||
{ value: 0, label: '全部' },
|
||
...categories.value.map(cat => ({ value: cat.id, label: cat.name }))
|
||
]
|
||
})
|
||
|
||
// 计算属性:文件类型选项
|
||
const fileTypeOptions = computed(() => {
|
||
return [
|
||
{ value: '', label: '全部' },
|
||
{ value: 'image', label: '图片' },
|
||
{ value: 'video', label: '视频' },
|
||
{ value: 'document', label: '文档' },
|
||
{ value: 'other', label: '其他' }
|
||
]
|
||
})
|
||
|
||
// 计算属性:上传分类选项
|
||
const uploadCategoryOptions = computed(() => {
|
||
return [
|
||
{ value: 0, label: '无分类' },
|
||
...categories.value.map(cat => ({ value: cat.id, label: cat.name }))
|
||
]
|
||
})
|
||
|
||
|
||
const loadAttachments = async () => {
|
||
loading.value = true
|
||
try {
|
||
const data = await getAdminAttachments({
|
||
page: 1,
|
||
pageSize: 100,
|
||
categoryId: filterCategoryId.value > 0 ? filterCategoryId.value : undefined,
|
||
fileType: filterFileType.value || undefined,
|
||
keyword: searchKeyword.value.trim() || undefined,
|
||
})
|
||
attachments.value = data.list as LocalAttachment[]
|
||
} catch (err: any) {
|
||
toast.showToast(err.message || '加载失败', 'error')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
const resetFilters = () => {
|
||
searchKeyword.value = ''
|
||
filterCategoryId.value = 0
|
||
filterFileType.value = ''
|
||
loadAttachments()
|
||
}
|
||
|
||
const loadCategories = async () => {
|
||
try {
|
||
const data = await parseApiResponse<Category[]>(await authFetch(`${API_BASE}/admin/attachment-categories`))
|
||
categories.value = data || []
|
||
} catch (err) {
|
||
console.error('Failed to load categories:', err)
|
||
}
|
||
}
|
||
|
||
const handleUploadFileSelect = () => {
|
||
const file = uploadInput.value?.files?.[0]
|
||
if (file) {
|
||
pendingFileName.value = file.name
|
||
void uploadFileWithFile(file)
|
||
}
|
||
}
|
||
|
||
const triggerUploadSelect = () => {
|
||
if (uploading.value) return
|
||
uploadInput.value?.click()
|
||
}
|
||
|
||
const { isDragging, handleDragOver, handleDragEnter, handleDragLeave, handleDrop } = useImageDropUpload({
|
||
disabled: () => uploading.value,
|
||
acceptImagesOnly: false,
|
||
onFiles: (files) => {
|
||
if (files[0]) {
|
||
pendingFileName.value = files[0].name
|
||
void uploadFileWithFile(files[0])
|
||
}
|
||
},
|
||
})
|
||
|
||
const uploadFileWithFile = async (file: File) => {
|
||
uploading.value = true
|
||
try {
|
||
const formData = new FormData()
|
||
formData.append('file', file)
|
||
if (uploadCategoryId.value > 0) {
|
||
formData.append('categoryId', uploadCategoryId.value.toString())
|
||
}
|
||
formData.append('storageType', 'local')
|
||
|
||
await parseApiResponse(await authFetch(`${API_BASE}/admin/attachments/upload`, {
|
||
method: 'POST',
|
||
headers: {},
|
||
body: formData
|
||
}))
|
||
|
||
toast.showToast('上传成功', 'success')
|
||
showUploadModal.value = false
|
||
if (uploadInput.value) uploadInput.value.value = ''
|
||
uploadCategoryId.value = 0
|
||
pendingFileName.value = ''
|
||
loadAttachments()
|
||
} catch (err: any) {
|
||
toast.showToast(err.message || '上传失败', 'error')
|
||
} finally {
|
||
uploading.value = false
|
||
}
|
||
}
|
||
|
||
const uploadFile = async () => {
|
||
if (!uploadInput.value?.files || uploadInput.value.files.length === 0) {
|
||
toast.showToast('请选择文件', 'error')
|
||
return
|
||
}
|
||
|
||
await uploadFileWithFile(uploadInput.value.files[0])
|
||
}
|
||
|
||
const deleteAttachment = async (id: number) => {
|
||
if (!confirm('确定要删除这个附件吗?')) return
|
||
|
||
try {
|
||
await parseApiResponse(await authFetch(`${API_BASE}/admin/attachments/${id}`, {
|
||
method: 'DELETE'
|
||
}))
|
||
|
||
toast.showToast('删除成功', 'success')
|
||
loadAttachments()
|
||
} catch (err: any) {
|
||
toast.showToast(err.message || '删除失败', 'error')
|
||
}
|
||
}
|
||
|
||
const copyUrl = (url: string) => {
|
||
navigator.clipboard.writeText(url)
|
||
toast.showToast('链接已复制', 'success')
|
||
}
|
||
|
||
const formatFileSize = (bytes: number): string => {
|
||
if (bytes < 1024) return bytes + ' B'
|
||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||
}
|
||
|
||
const openDetailModal = (attachment: LocalAttachment) => {
|
||
selectedAttachment.value = attachment
|
||
showDetailModal.value = true
|
||
}
|
||
|
||
const handleSaveCategory = async (data: { categoryId: number | null }) => {
|
||
if (!selectedAttachment.value) return
|
||
|
||
try {
|
||
await updateAttachment(selectedAttachment.value.id, data)
|
||
|
||
toast.showToast('分类更新成功', 'success')
|
||
|
||
// 更新本地数据
|
||
if (selectedAttachment.value) {
|
||
selectedAttachment.value.categoryId = data.categoryId || undefined
|
||
}
|
||
|
||
// 刷新列表
|
||
loadAttachments()
|
||
|
||
// 关闭模态框
|
||
showDetailModal.value = false
|
||
selectedAttachment.value = null
|
||
} catch (err: any) {
|
||
toast.showToast(err.message || '更新失败', 'error')
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
loadCategories()
|
||
loadAttachments()
|
||
})
|
||
</script>
|