237 lines
8.5 KiB
Vue
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>
|