Files
nl-blogs/client/src/pages/admin/Attachments.vue
2026-06-26 17:00:02 +08:00

386 lines
12 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>
<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>