Files
nl-blogs/client/src/pages/admin/Attachments.vue
2026-01-20 14:31:39 +08:00

348 lines
10 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 -->
<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"
@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 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>
<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'
import AttachmentDetailModal from '../../components/admin/AttachmentDetailModal.vue'
import { updateAttachment, type Attachment } from '../../services/api'
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 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 = '/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'
}
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>