305 lines
8.9 KiB
Vue
305 lines
8.9 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 -->
|
||
<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>
|