Files
nl-blogs/client/src/pages/admin/Attachments.vue

305 lines
8.9 KiB
Vue
Raw Normal View History

2026-01-19 16:14:08 +08:00
<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 -->
<div class="admin-card p-4 mb-6 flex gap-4 items-center">
<div class="flex items-center gap-2">
<label class="text-sm text-art-muted">分类:</label>
<div class="w-48">
<CustomSelect
v-model="filterCategoryId"
:options="categoryOptions"
placeholder="全部"
@update:modelValue="loadAttachments"
/>
</div>
</div>
<div class="flex items-center gap-2">
<label class="text-sm text-art-muted">类型:</label>
<div class="w-48">
<CustomSelect
v-model="filterFileType"
:options="fileTypeOptions"
placeholder="全部"
@update:modelValue="loadAttachments"
/>
</div>
</div>
</div>
<!-- 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"
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 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>
<!-- 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>
<input
type="file"
ref="uploadInput"
@change="handleUploadFile"
class="admin-input"
/>
</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 @click="showUploadModal = false" class="admin-btn-secondary flex-1">
取消
</button>
<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'
const toast = useToast()
interface Attachment {
id: number
originalName: string
fileUrl: string
fileSize: number
fileType: string
categoryId?: number
}
interface Category {
id: number
name: string
}
const attachments = ref<Attachment[]>([])
const categories = ref<Category[]>([])
const loading = ref(false)
const showUploadModal = ref(false)
const uploadInput = ref<HTMLInputElement | null>(null)
const uploadCategoryId = ref(0)
const uploading = ref(false)
const filterCategoryId = ref(0)
const filterFileType = 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 API_BASE = 'http://localhost:8081/api'
const getAuthHeaders = () => {
const token = localStorage.getItem('token')
return {
...(token ? { Authorization: `Bearer ${token}` } : {})
}
}
const loadAttachments = async () => {
loading.value = true
try {
let url = `${API_BASE}/admin/attachments?pageSize=100`
if (filterCategoryId.value > 0) {
url += `&categoryId=${filterCategoryId.value}`
}
if (filterFileType.value) {
url += `&fileType=${filterFileType.value}`
}
const response = await fetch(url, {
headers: getAuthHeaders()
})
if (!response.ok) throw new Error('获取附件列表失败')
const data = await response.json()
attachments.value = data.result.list || []
} catch (err: any) {
toast.showToast(err.message || '加载失败', 'error')
} finally {
loading.value = false
}
}
const loadCategories = async () => {
try {
const response = await fetch(`${API_BASE}/admin/attachment-categories`, {
headers: getAuthHeaders()
})
if (!response.ok) throw new Error('获取分类失败')
const data = await response.json()
categories.value = data.result || []
} catch (err) {
console.error('Failed to load categories:', err)
}
}
const handleUploadFile = () => {
// File selection handled in uploadFile
}
const uploadFile = async () => {
if (!uploadInput.value?.files || uploadInput.value.files.length === 0) {
toast.showToast('请选择文件', 'error')
return
}
uploading.value = true
try {
const formData = new FormData()
formData.append('file', uploadInput.value.files[0])
if (uploadCategoryId.value > 0) {
formData.append('categoryId', uploadCategoryId.value.toString())
}
formData.append('storageType', 'local')
const response = await fetch(`${API_BASE}/admin/attachments/upload`, {
method: 'POST',
headers: getAuthHeaders(),
body: formData
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.message || '上传失败')
}
toast.showToast('上传成功', 'success')
showUploadModal.value = false
uploadInput.value.value = ''
uploadCategoryId.value = 0
loadAttachments()
} catch (err: any) {
toast.showToast(err.message || '上传失败', 'error')
} finally {
uploading.value = false
}
}
const deleteAttachment = async (id: number) => {
if (!confirm('确定要删除这个附件吗?')) return
try {
const response = await fetch(`${API_BASE}/admin/attachments/${id}`, {
method: 'DELETE',
headers: getAuthHeaders()
})
if (!response.ok) throw new Error('删除失败')
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'
}
onMounted(() => {
loadCategories()
loadAttachments()
})
</script>