Files
nl-blogs/client/src/components/admin/AttachmentDetailModal.vue
2026-06-24 16:50:04 +08:00

237 lines
8.5 KiB
Vue

<template>
<Teleport to="body">
<div
v-if="show"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
@click.self="handleClose"
>
<div class="admin-card max-w-3xl w-full mx-4 max-h-[92vh] overflow-hidden flex flex-col border border-white/10 shadow-2xl">
<!-- Header -->
<div class="flex items-start justify-between gap-4 px-6 py-4 border-b border-white/10 shrink-0">
<div class="min-w-0">
<h2 class="text-xl font-serif italic text-white">附件详情</h2>
<p v-if="attachment" class="text-sm text-art-muted mt-1 truncate" :title="attachment.originalName">
{{ attachment.originalName }}
</p>
</div>
<button @click="handleClose" class="text-white/40 hover:text-white transition-colors shrink-0 p-1">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div v-if="attachment" class="overflow-y-auto custom-scrollbar flex-1">
<!-- Media preview -->
<div class="px-6 pt-6">
<div class="rounded-xl overflow-hidden bg-black/40 border border-white/10 min-h-[200px] flex items-center justify-center">
<img
v-if="attachment.fileType === 'image'"
:src="attachment.fileUrl"
:alt="attachment.originalName"
class="w-full max-h-[420px] object-contain"
/>
<video
v-else-if="attachment.fileType === 'video'"
:src="attachment.fileUrl"
controls
preload="metadata"
class="w-full max-h-[420px] bg-black"
/>
<div v-else class="flex flex-col items-center justify-center py-16 px-6 text-center">
<div class="w-20 h-20 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center mb-4">
<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="text-art-muted">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
</svg>
</div>
<p class="text-white font-medium mb-1">{{ attachment.originalName }}</p>
<p class="text-sm text-art-muted">此文件类型暂不支持预览</p>
</div>
</div>
</div>
<!-- Badges -->
<div class="px-6 pt-4 flex flex-wrap gap-2">
<span class="px-3 py-1 rounded-full text-xs font-medium border" :class="typeBadgeClass">
{{ getFileTypeLabel(attachment.fileType) }}
</span>
<span class="px-3 py-1 rounded-full text-xs bg-white/5 text-art-muted border border-white/10">
{{ formatFileSize(attachment.fileSize) }}
</span>
<span class="px-3 py-1 rounded-full text-xs bg-white/5 text-art-muted border border-white/10">
{{ getStorageTypeLabel(attachment.storageType) }}
</span>
</div>
<!-- Meta grid -->
<div class="px-6 py-5 grid sm:grid-cols-2 gap-4">
<div class="rounded-lg bg-white/[0.03] border border-white/5 p-4">
<label class="block text-xs font-medium text-art-muted uppercase tracking-wider mb-2">MIME 类型</label>
<p class="text-white font-mono text-sm break-all">{{ attachment.mimeType || '—' }}</p>
</div>
<div class="rounded-lg bg-white/[0.03] border border-white/5 p-4">
<label class="block text-xs font-medium text-art-muted uppercase tracking-wider mb-2">创建时间</label>
<p class="text-white text-sm">{{ attachment.createdAt ? formatDate(attachment.createdAt) : '—' }}</p>
</div>
<div class="sm:col-span-2 rounded-lg bg-white/[0.03] border border-white/5 p-4">
<label class="block text-xs font-medium text-art-muted uppercase tracking-wider mb-2">分类</label>
<CustomSelect
v-model="localCategoryId"
:options="categoryOptions"
placeholder="无分类"
/>
</div>
<div class="sm:col-span-2 rounded-lg bg-white/[0.03] border border-white/5 p-4">
<label class="block text-xs font-medium text-art-muted uppercase tracking-wider mb-2">文件 URL</label>
<div class="flex gap-2">
<input
type="text"
:value="attachment.fileUrl"
readonly
class="admin-input flex-1 font-mono text-sm"
/>
<button @click="handleCopyUrl" class="admin-btn-secondary px-4 whitespace-nowrap">
复制
</button>
</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="flex gap-3 px-6 py-4 border-t border-white/10 shrink-0">
<button @click="handleClose" class="admin-btn-secondary flex-1">
取消
</button>
<button @click="handleSave" :disabled="saving" class="admin-btn-primary flex-1">
{{ saving ? '保存中...' : '保存' }}
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import CustomSelect from '../CustomSelect.vue'
interface Attachment {
id: number
originalName: string
fileUrl: string
fileSize: number
fileType: string
categoryId?: number
mimeType?: string
storageType?: string
createdAt?: string
}
interface Category {
id: number
name: string
}
interface Props {
show: boolean
attachment: Attachment | null
categories: Category[]
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:show': [value: boolean]
'save': [data: { categoryId: number | null }]
'copy-url': [url: string]
}>()
const localCategoryId = ref<number>(0)
const saving = ref(false)
const categoryOptions = computed(() => {
return [
{ value: 0, label: '无分类' },
...props.categories.map(cat => ({ value: cat.id, label: cat.name }))
]
})
const typeBadgeClass = computed(() => {
const map: Record<string, string> = {
image: 'bg-emerald-500/10 text-emerald-300 border-emerald-500/30',
video: 'bg-violet-500/10 text-violet-300 border-violet-500/30',
document: 'bg-blue-500/10 text-blue-300 border-blue-500/30',
other: 'bg-white/5 text-art-muted border-white/10'
}
return map[props.attachment?.fileType || 'other'] || map.other
})
watch(() => props.attachment, (newAttachment) => {
if (newAttachment) {
localCategoryId.value = newAttachment.categoryId || 0
}
}, { immediate: true })
const handleClose = () => {
emit('update:show', false)
}
const handleSave = () => {
saving.value = true
const categoryId = localCategoryId.value === 0 ? null : localCategoryId.value
emit('save', { categoryId })
}
const handleCopyUrl = () => {
if (props.attachment) {
emit('copy-url', props.attachment.fileUrl)
}
}
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 getFileTypeLabel = (fileType: string): string => {
const labels: Record<string, string> = {
image: '图片',
video: '视频',
document: '文档',
other: '其他'
}
return labels[fileType] || fileType
}
const getStorageTypeLabel = (storageType?: string): string => {
const labels: Record<string, string> = {
local: '本地存储',
qcloud: '腾讯云 COS',
aliyun: '阿里云 OSS',
qiniu: '七牛云'
}
return labels[storageType || 'local'] || storageType || '本地存储'
}
const formatDate = (timestamp: string | number): string => {
const date = new Date(typeof timestamp === 'string' ? parseInt(timestamp) * 1000 : timestamp * 1000)
return date.toLocaleString('zh-CN')
}
defineExpose({ saving })
</script>
<style scoped>
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 2px;
}
</style>