You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
319 lines
9.0 KiB
319 lines
9.0 KiB
<script setup lang="ts">
|
|
import { useAuthSession } from '../../../composables/useAuthSession'
|
|
|
|
definePageMeta({ title: '媒体库' })
|
|
|
|
type MediaAssetRow = {
|
|
id: number
|
|
storageKey: string
|
|
publicPath: string
|
|
mime: string
|
|
sizeBytes: number
|
|
createdAt: string
|
|
refCount: number
|
|
}
|
|
|
|
const toast = useToast()
|
|
const { refresh: refreshAuth } = useAuthSession()
|
|
const runtimeConfig = useRuntimeConfig()
|
|
const { fetchData, getApiErrorMessage } = useClientApi()
|
|
|
|
/** 与 `NUXT_PUBLIC_SITE_URL` / `runtimeConfig.public.siteUrl` 一致;未配置则退回当前页 origin。 */
|
|
function resolveCopyOrigin(): string {
|
|
const raw = String(runtimeConfig.public.siteUrl ?? '').trim()
|
|
if (raw) {
|
|
try {
|
|
return new URL(raw).origin
|
|
} catch {
|
|
/* 使用 window */
|
|
}
|
|
}
|
|
return typeof window !== 'undefined' ? window.location.origin : ''
|
|
}
|
|
|
|
const page = ref(1)
|
|
const pageSize = ref(20)
|
|
const items = ref<MediaAssetRow[]>([])
|
|
const total = ref(0)
|
|
const loading = ref(true)
|
|
const uploading = ref(false)
|
|
const fileInput = ref<HTMLInputElement | null>(null)
|
|
const reuploadInput = ref<HTMLInputElement | null>(null)
|
|
const reuploadTargetId = ref<number | null>(null)
|
|
const reuploadingId = ref<number | null>(null)
|
|
/** 预览加载失败(多为磁盘缺文件) */
|
|
const previewFailedIds = ref<Set<number>>(new Set())
|
|
const imgBust = ref<Record<number, number>>({})
|
|
|
|
const pageSizeItems = [
|
|
{ label: '每页 10', value: 10 },
|
|
{ label: '每页 20', value: 20 },
|
|
{ label: '每页 50', value: 50 },
|
|
]
|
|
|
|
function formatBytes(n: number): string {
|
|
if (n < 1024) {
|
|
return `${n} B`
|
|
}
|
|
if (n < 1024 * 1024) {
|
|
return `${(n / 1024).toFixed(1)} KB`
|
|
}
|
|
return `${(n / (1024 * 1024)).toFixed(1)} MB`
|
|
}
|
|
|
|
function formatDt(iso: string | null): string {
|
|
if (!iso) {
|
|
return '—'
|
|
}
|
|
const d = new Date(iso)
|
|
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString('zh-CN')
|
|
}
|
|
|
|
async function load() {
|
|
loading.value = true
|
|
try {
|
|
const q = new URLSearchParams({
|
|
page: String(page.value),
|
|
pageSize: String(pageSize.value),
|
|
})
|
|
const body = await fetchData<{ items: MediaAssetRow[]; total: number }>(
|
|
`/api/me/media/assets?${q.toString()}`,
|
|
{ notify: false },
|
|
)
|
|
items.value = body.items
|
|
total.value = body.total
|
|
const ids = new Set(body.items.map((i) => i.id))
|
|
previewFailedIds.value = new Set([...previewFailedIds.value].filter((id) => ids.has(id)))
|
|
} catch (e: unknown) {
|
|
toast.add({ title: getApiErrorMessage(e), color: 'error' })
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
watch(pageSize, () => {
|
|
page.value = 1
|
|
})
|
|
|
|
watch([page, pageSize], () => {
|
|
void load()
|
|
})
|
|
|
|
onMounted(() => {
|
|
void refreshAuth(true)
|
|
void load()
|
|
})
|
|
|
|
function openFilePicker() {
|
|
fileInput.value?.click()
|
|
}
|
|
|
|
async function onFileChange(ev: Event) {
|
|
const input = ev.target as HTMLInputElement
|
|
const files = input.files ? Array.from(input.files) : []
|
|
input.value = ''
|
|
if (!files.length) {
|
|
return
|
|
}
|
|
|
|
uploading.value = true
|
|
try {
|
|
const form = new FormData()
|
|
for (const f of files) {
|
|
form.append('file', f)
|
|
}
|
|
await fetchData<{ files: { url: string }[] }>('/api/file/upload', {
|
|
method: 'POST',
|
|
body: form,
|
|
notify: false,
|
|
})
|
|
toast.add({
|
|
title: files.length === 1 ? '上传成功' : `已上传 ${files.length} 个文件`,
|
|
color: 'success',
|
|
})
|
|
await load()
|
|
} catch (e: unknown) {
|
|
toast.add({ title: getApiErrorMessage(e), color: 'error' })
|
|
} finally {
|
|
uploading.value = false
|
|
}
|
|
}
|
|
|
|
async function copyUrl(item: MediaAssetRow) {
|
|
if (!import.meta.client) {
|
|
return
|
|
}
|
|
const absoluteUrl = `${resolveCopyOrigin()}${item.publicPath}`
|
|
try {
|
|
await navigator.clipboard.writeText(absoluteUrl)
|
|
toast.add({ title: '已复制 URL', color: 'success' })
|
|
} catch (e: unknown) {
|
|
toast.add({ title: getApiErrorMessage(e), color: 'error' })
|
|
}
|
|
}
|
|
|
|
function imageSrc(item: MediaAssetRow): string {
|
|
const b = imgBust.value[item.id]
|
|
return b ? `${item.publicPath}?v=${b}` : item.publicPath
|
|
}
|
|
|
|
function onImgError(id: number) {
|
|
previewFailedIds.value = new Set([...previewFailedIds.value, id])
|
|
}
|
|
|
|
function openReuploadPicker(assetId: number) {
|
|
reuploadTargetId.value = assetId
|
|
nextTick(() => reuploadInput.value?.click())
|
|
}
|
|
|
|
async function onReuploadFile(ev: Event) {
|
|
const assetId = reuploadTargetId.value
|
|
reuploadTargetId.value = null
|
|
const input = ev.target as HTMLInputElement
|
|
const file = input.files?.[0]
|
|
input.value = ''
|
|
if (!assetId || !file) {
|
|
return
|
|
}
|
|
reuploadingId.value = assetId
|
|
try {
|
|
const form = new FormData()
|
|
form.append('file', file)
|
|
await fetchData<{ url: string; sizeBytes: number }>(`/api/me/media/assets/${assetId}/reupload`, {
|
|
method: 'POST',
|
|
body: form,
|
|
notify: false,
|
|
})
|
|
previewFailedIds.value = new Set([...previewFailedIds.value].filter((id) => id !== assetId))
|
|
imgBust.value = { ...imgBust.value, [assetId]: Date.now() }
|
|
toast.add({ title: '已重新写入磁盘', color: 'success' })
|
|
await load()
|
|
} catch (e: unknown) {
|
|
toast.add({ title: getApiErrorMessage(e), color: 'error' })
|
|
} finally {
|
|
reuploadingId.value = null
|
|
}
|
|
}
|
|
|
|
async function copyMarkdown(item: MediaAssetRow) {
|
|
if (!import.meta.client) {
|
|
return
|
|
}
|
|
const absoluteUrl = `${resolveCopyOrigin()}${item.publicPath}`
|
|
const md = ``
|
|
try {
|
|
await navigator.clipboard.writeText(md)
|
|
toast.add({ title: '已复制 Markdown', color: 'success' })
|
|
} catch (e: unknown) {
|
|
toast.add({ title: getApiErrorMessage(e), color: 'error' })
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="space-y-6 w-full max-w-[min(100%,88rem)] px-0 sm:px-0">
|
|
<input
|
|
ref="reuploadInput"
|
|
type="file"
|
|
class="sr-only"
|
|
accept="image/png,image/jpeg,image/jpg,image/webp"
|
|
@change="onReuploadFile"
|
|
>
|
|
<div>
|
|
<h2 class="text-lg font-medium">
|
|
资源库
|
|
</h2>
|
|
<p class="text-sm text-muted mt-1">
|
|
浏览已上传图片、复制外链或 Markdown,用于文章与资料。
|
|
</p>
|
|
</div>
|
|
|
|
<div class="flex flex-wrap items-end gap-x-5 gap-y-3">
|
|
<input
|
|
ref="fileInput"
|
|
type="file"
|
|
class="sr-only"
|
|
accept="image/png,image/jpeg,image/jpg,image/webp"
|
|
multiple
|
|
@change="onFileChange"
|
|
>
|
|
<UButton
|
|
color="primary"
|
|
:loading="uploading"
|
|
:disabled="uploading"
|
|
@click="openFilePicker"
|
|
>
|
|
选择图片上传
|
|
</UButton>
|
|
<UFormField label="每页" class="w-40 shrink-0">
|
|
<USelect v-model="pageSize" :items="pageSizeItems" value-key="value" class="w-full" />
|
|
</UFormField>
|
|
</div>
|
|
|
|
<div v-if="loading" class="text-muted">
|
|
加载中…
|
|
</div>
|
|
<UEmpty
|
|
v-else-if="!items.length"
|
|
title="暂无图片"
|
|
description="上传 PNG / JPEG / WebP 后,将在此列出并可直接复制链接。"
|
|
/>
|
|
<div v-else class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
<UCard v-for="item in items" :key="item.id">
|
|
<div class="space-y-3">
|
|
<div class="relative w-full aspect-video rounded-md border border-default overflow-hidden bg-elevated/30">
|
|
<img
|
|
:src="imageSrc(item)"
|
|
:alt="item.storageKey"
|
|
class="w-full h-full object-cover"
|
|
loading="lazy"
|
|
@error="onImgError(item.id)"
|
|
/>
|
|
<div
|
|
v-if="previewFailedIds.has(item.id)"
|
|
class="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-default/80 p-2"
|
|
>
|
|
<p class="text-xs text-muted text-center">
|
|
文件缺失或无法加载
|
|
</p>
|
|
<UButton
|
|
size="xs"
|
|
color="primary"
|
|
:loading="reuploadingId === item.id"
|
|
:disabled="reuploadingId !== null && reuploadingId !== item.id"
|
|
@click="openReuploadPicker(item.id)"
|
|
>
|
|
重新上传
|
|
</UButton>
|
|
</div>
|
|
</div>
|
|
<div
|
|
class="truncate font-mono text-xs text-default"
|
|
:title="item.storageKey"
|
|
>
|
|
{{ item.storageKey }}
|
|
</div>
|
|
<div class="flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted tabular-nums">
|
|
<span>{{ formatBytes(item.sizeBytes) }}</span>
|
|
<span>{{ formatDt(item.createdAt) }}</span>
|
|
</div>
|
|
<p class="text-xs text-default">
|
|
引用数: {{ item.refCount }}
|
|
</p>
|
|
<div class="flex flex-wrap gap-2">
|
|
<UButton size="xs" variant="soft" @click="copyUrl(item)">
|
|
复制 URL
|
|
</UButton>
|
|
<UButton size="xs" variant="soft" @click="copyMarkdown(item)">
|
|
复制 Markdown
|
|
</UButton>
|
|
</div>
|
|
</div>
|
|
</UCard>
|
|
</div>
|
|
|
|
<div v-if="!loading && total > pageSize" class="flex justify-end">
|
|
<UPagination v-model:page="page" :total="total" :items-per-page="pageSize" size="sm" />
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|