112 lines
7.9 KiB
Vue
112 lines
7.9 KiB
Vue
<script setup lang="ts">
|
||
import { PhArrowCounterClockwise, PhCheckCircle, PhFloppyDisk, PhImageSquare, PhTrash, PhUploadSimple } from '@phosphor-icons/vue'
|
||
import type { SiteDocument } from '../../../server/utils/site-schema'
|
||
|
||
definePageMeta({ layout: false })
|
||
useHead(() => ({ title: `素材中心|${site.value?.brand.name || '慧遇书院'}` }))
|
||
|
||
interface MediaAsset {
|
||
id: number; key: string; originalName: string; mimeType: string; bytes: number; width: number; height: number
|
||
url: string; thumbnailUrl: string; altText: string; source: string; copyright: string
|
||
authorizationStatus: 'pending' | 'authorized' | 'restricted'; status: 'active' | 'trash' | 'deleted'; referenced: boolean
|
||
createdAt: string; deletedAt: string | null
|
||
}
|
||
|
||
const site = ref<SiteDocument>()
|
||
const items = ref<MediaAsset[]>([])
|
||
const selectedId = ref<number>()
|
||
const view = ref<'active' | 'trash'>('active')
|
||
const loading = ref(true), busy = ref(false), message = ref('')
|
||
const uploadMeta = reactive({ altText: '', source: '', copyright: '', authorizationStatus: 'pending' as MediaAsset['authorizationStatus'] })
|
||
const selected = computed(() => items.value.find(item => item.id === selectedId.value))
|
||
|
||
onMounted(async () => {
|
||
try {
|
||
await $fetch('/api/auth/me')
|
||
site.value = (await $fetch<{ data: SiteDocument }>('/api/admin/site')).data
|
||
await loadItems()
|
||
} catch { await navigateTo('/admin/login') }
|
||
finally { loading.value = false }
|
||
})
|
||
|
||
async function loadItems() {
|
||
const result = await $fetch<{ data: MediaAsset[] }>('/api/admin/media', { query: { status: view.value } })
|
||
items.value = result.data
|
||
selectedId.value = items.value[0]?.id
|
||
}
|
||
|
||
async function changeView(next: 'active' | 'trash') { view.value = next; message.value = ''; await loadItems() }
|
||
|
||
async function upload(event: Event) {
|
||
const input = event.target as HTMLInputElement
|
||
const file = input.files?.[0]
|
||
if (!file) return
|
||
busy.value = true; message.value = ''
|
||
try {
|
||
const body = new FormData(); body.append('file', file)
|
||
Object.entries(uploadMeta).forEach(([key, value]) => body.append(key, value))
|
||
const result = await $fetch<{ media: MediaAsset }>('/api/admin/media', { method: 'POST', body })
|
||
items.value.unshift(result.media); selectedId.value = result.media.id
|
||
uploadMeta.altText = ''; uploadMeta.source = ''; uploadMeta.copyright = ''; uploadMeta.authorizationStatus = 'pending'
|
||
message.value = '素材已上传并生成 WebP 与缩略图'
|
||
} catch (error: any) { message.value = error?.data?.statusMessage || '上传失败' }
|
||
finally { busy.value = false; input.value = '' }
|
||
}
|
||
|
||
async function saveMetadata() {
|
||
if (!selected.value) return
|
||
busy.value = true; message.value = ''
|
||
try {
|
||
await $fetch(`/api/admin/media/${selected.value.id}`, { method: 'PATCH', body: { altText: selected.value.altText, source: selected.value.source, copyright: selected.value.copyright, authorizationStatus: selected.value.authorizationStatus } })
|
||
message.value = '素材信息已保存'
|
||
} catch (error: any) { message.value = error?.data?.statusMessage || '保存失败' }
|
||
finally { busy.value = false }
|
||
}
|
||
|
||
async function trash() {
|
||
if (!selected.value || !window.confirm('确定把该素材移入回收站吗?')) return
|
||
busy.value = true; message.value = ''
|
||
try { await $fetch(`/api/admin/media/${selected.value.id}`, { method: 'DELETE' }); message.value = '素材已移入回收站'; await loadItems() }
|
||
catch (error: any) { message.value = error?.data?.statusMessage || '删除失败' }
|
||
finally { busy.value = false }
|
||
}
|
||
|
||
async function restore() {
|
||
if (!selected.value) return
|
||
busy.value = true
|
||
try { await $fetch(`/api/admin/media/${selected.value.id}/restore`, { method: 'POST' }); message.value = '素材已恢复'; await loadItems() }
|
||
catch (error: any) { message.value = error?.data?.statusMessage || '恢复失败' }
|
||
finally { busy.value = false }
|
||
}
|
||
|
||
const formatBytes = (bytes: number) => bytes < 1024 * 1024 ? `${Math.ceil(bytes / 1024)} KB` : `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||
</script>
|
||
|
||
<template>
|
||
<main class="admin-shell">
|
||
<AdminSidebar active="media" :logo="site?.brand.logo" :brand-name="site?.brand.name" :english-name="site?.brand.englishName" />
|
||
<section class="admin-main">
|
||
<header class="admin-topbar"><div><p>素材中心</p><small>原图、WebP 衍生图、授权信息与安全回收站</small></div><div class="admin-actions"><span v-if="message" class="save-message"><PhCheckCircle :size="18" />{{ message }}</span><button :class="{ 'publish-button': view === 'active' }" @click="changeView('active')">素材库</button><button :class="{ 'publish-button': view === 'trash' }" @click="changeView('trash')">回收站</button></div></header>
|
||
<div v-if="loading" class="admin-loading">正在加载素材…</div>
|
||
<div v-else class="media-admin-body">
|
||
<section v-if="view === 'active'" class="media-upload-panel editor-panel">
|
||
<div><PhUploadSimple :size="30" /><strong>上传新图片</strong><small>支持 JPG、PNG、WebP;最大 20MB、6400 万像素,最长边不超过 12000px。系统保留原图并生成最长边 3200px 的高清展示版</small></div>
|
||
<div class="media-upload-meta"><input v-model="uploadMeta.altText" placeholder="ALT 描述(建议填写)"><input v-model="uploadMeta.source" placeholder="素材来源"><input v-model="uploadMeta.copyright" placeholder="版权/授权说明"><select v-model="uploadMeta.authorizationStatus"><option value="pending">待核验</option><option value="authorized">已授权</option><option value="restricted">限制使用</option></select></div>
|
||
<label class="media-upload-button"><input type="file" accept="image/jpeg,image/png,image/webp" :disabled="busy" @change="upload">{{ busy ? '处理中…' : '选择并上传图片' }}</label>
|
||
</section>
|
||
<div class="media-library-layout">
|
||
<section class="media-grid-panel">
|
||
<div v-if="!items.length" class="empty-editor"><PhImageSquare :size="38" /><p>{{ view === 'active' ? '尚未上传素材' : '回收站为空' }}</p></div>
|
||
<button v-for="item in items" :key="item.id" :class="['media-tile', { active: item.id === selectedId }]" @click="selectedId = item.id"><img :src="item.thumbnailUrl" :alt="item.altText || item.originalName"><span>{{ item.originalName }}</span><small>{{ item.width }}×{{ item.height }} · {{ formatBytes(item.bytes) }}</small><b v-if="item.referenced">使用中</b></button>
|
||
</section>
|
||
<aside v-if="selected" class="media-detail editor-panel">
|
||
<img :src="selected.url" :alt="selected.altText || selected.originalName"><div class="media-detail-meta"><strong>{{ selected.originalName }}</strong><small>{{ selected.mimeType }} · {{ selected.width }}×{{ selected.height }} · {{ formatBytes(selected.bytes) }}</small><a :href="selected.url" target="_blank">打开衍生图</a></div>
|
||
<div v-if="view === 'active'" class="form-grid"><label class="full">ALT 描述<input v-model="selected.altText" maxlength="160"></label><label class="full">素材来源<input v-model="selected.source" maxlength="200"></label><label class="full">版权/授权说明<input v-model="selected.copyright" maxlength="200"></label><label class="full">授权状态<select v-model="selected.authorizationStatus"><option value="pending">待核验</option><option value="authorized">已授权</option><option value="restricted">限制使用</option></select></label></div>
|
||
<div class="media-detail-actions"><template v-if="view === 'active'"><button :disabled="busy" @click="saveMetadata"><PhFloppyDisk :size="17" />保存信息</button><button class="danger-link" :disabled="busy || selected.referenced" @click="trash"><PhTrash :size="17" />{{ selected.referenced ? '页面使用中' : '移入回收站' }}</button></template><button v-else :disabled="busy" @click="restore"><PhArrowCounterClockwise :size="17" />恢复素材</button></div>
|
||
</aside>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</main>
|
||
</template>
|