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.
 
 
 
 
 

663 lines
20 KiB

<script setup lang="ts">
usePageTitle('媒体库')
type RefContext = {
ownerType: string
ownerId: number
label: string
href: string | null
}
type MediaAssetRow = {
id: number
storageKey: string
publicPath: string
mime: string
sizeBytes: number
createdAt: string
refCount: number
userNote: string | null
refContexts: RefContext[]
canDelete: boolean
deleteBlockedReason: 'referenced' | 'cooling' | null
deleteGraceExpiresAt: string | null
}
const toast = useToast()
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 cardDensity = ref<'compact' | 'detailed'>('compact')
const items = ref<MediaAssetRow[]>([])
const total = ref(0)
const loading = ref(true)
/** 非首屏刷新(翻页、搜索):列表区轻量遮罩,避免旧数据与新结果切换生硬 */
const listBusy = ref(false)
/** 输入框内容;`appliedSearch` 防抖后参与请求 */
const searchInput = ref('')
const appliedSearch = ref('')
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
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 noteDraft = reactive<Record<number, string>>({})
const savingNoteId = ref<number | null>(null)
/** 正在编辑描述的资源 id;其余项用服务端文案展示 */
const editingNoteId = ref<number | null>(null)
/** 引用详情是否展开(按资源 id) */
const refDetailOpen = reactive<Record<number, boolean>>({})
const deleteConfirmOpen = ref(false)
const pendingDeleteAsset = ref<MediaAssetRow | null>(null)
const deletingId = ref<number | null>(null)
const skeletonPlaceholders = computed(() => Math.min(Math.max(pageSize.value, 1), 12))
watch(
items,
(list) => {
for (const it of list) {
if (editingNoteId.value === it.id) {
continue
}
noteDraft[it.id] = it.userNote ?? ''
}
},
{ immediate: true },
)
const pageSizeItems = [
{ label: '每页 10', value: 10 },
{ label: '每页 20', value: 20 },
{ label: '每页 50', value: 50 },
]
const isCompact = computed(() => cardDensity.value === 'compact')
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')
}
function deleteDisabledHint(item: MediaAssetRow): string {
if (item.canDelete) {
return ''
}
if (item.deleteBlockedReason === 'referenced') {
return '该文件仍被文章或资料引用,请先从正文、封面或资料中移除对应图片后再删除。'
}
if (item.deleteGraceExpiresAt) {
return `宽限期未满,预计 ${formatDt(item.deleteGraceExpiresAt)} 后可删除;也可在「媒体 → 孤儿清理」中查看进度。`
}
return '当前不可删除,请稍后在「孤儿清理」中处理。'
}
function openDeleteConfirm(item: MediaAssetRow) {
if (!item.canDelete) {
return
}
pendingDeleteAsset.value = item
deleteConfirmOpen.value = true
}
function closeDeleteModal() {
deleteConfirmOpen.value = false
}
watch(deleteConfirmOpen, (open) => {
if (!open) {
pendingDeleteAsset.value = null
}
})
async function executeDelete() {
const item = pendingDeleteAsset.value
if (!item) {
return
}
deletingId.value = item.id
try {
await fetchData(`/api/me/media/assets/${item.id}`, {
method: 'DELETE',
notify: false,
})
toast.add({ title: '已删除', color: 'success' })
closeDeleteModal()
if (editingNoteId.value === item.id) {
editingNoteId.value = null
}
delete refDetailOpen[item.id]
await load()
} catch (e: unknown) {
toast.add({ title: getApiErrorMessage(e), color: 'error' })
} finally {
deletingId.value = null
}
}
async function load() {
const searchTrim = appliedSearch.value.trim()
const blocking = items.value.length === 0 && !searchTrim
if (blocking) {
loading.value = true
} else {
listBusy.value = true
}
try {
const q = new URLSearchParams({
page: String(page.value),
pageSize: String(pageSize.value),
})
if (searchTrim) {
q.set('search', searchTrim)
}
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 {
if (blocking) {
loading.value = false
} else {
listBusy.value = false
}
}
}
watch(pageSize, () => {
page.value = 1
})
watch(searchInput, () => {
if (searchDebounceTimer) {
clearTimeout(searchDebounceTimer)
}
searchDebounceTimer = setTimeout(() => {
searchDebounceTimer = null
const next = searchInput.value.trim()
if (next === appliedSearch.value) {
return
}
if (page.value !== 1) {
page.value = 1
}
appliedSearch.value = next
}, 350)
})
watch([page, pageSize, appliedSearch], () => {
void load()
})
onMounted(() => {
void load()
})
onBeforeUnmount(() => {
if (searchDebounceTimer) {
clearTimeout(searchDebounceTimer)
searchDebounceTimer = null
}
})
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())
}
function startEditNote(item: MediaAssetRow) {
editingNoteId.value = item.id
noteDraft[item.id] = item.userNote ?? ''
}
function cancelEditNote(item: MediaAssetRow) {
editingNoteId.value = null
noteDraft[item.id] = item.userNote ?? ''
}
async function saveMyNote(assetId: number) {
savingNoteId.value = assetId
try {
await fetchData(`/api/me/media/assets/${assetId}/note`, {
method: 'PATCH',
body: { userNote: noteDraft[assetId] ?? '' },
notify: false,
})
toast.add({ title: '描述已保存', color: 'success' })
editingNoteId.value = null
await load()
} catch (e: unknown) {
toast.add({ title: getApiErrorMessage(e), color: 'error' })
} finally {
savingNoteId.value = null
}
}
function toggleRefDetail(id: number) {
refDetailOpen[id] = !refDetailOpen[id]
}
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 = `![](${absoluteUrl})`
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="min-w-[12rem] flex-1 max-w-xl">
<UInput
v-model="searchInput"
placeholder="文件名、引用(文章标题 / 用户名等)、文件描述"
icon="i-lucide-search"
class="w-full"
:disabled="uploading"
/>
</UFormField>
<UFormField label="每页" class="w-40 shrink-0">
<USelect v-model="pageSize" :items="pageSizeItems" value-key="value" class="w-full" />
</UFormField>
<UFormField label="视图密度" class="w-48 shrink-0">
<div class="flex w-full rounded-md border border-default p-1">
<UButton
size="xs"
class="flex-1 justify-center"
:variant="isCompact ? 'solid' : 'ghost'"
:color="isCompact ? 'primary' : 'neutral'"
@click="cardDensity = 'compact'"
>
简洁
</UButton>
<UButton
size="xs"
class="flex-1 justify-center"
:variant="!isCompact ? 'solid' : 'ghost'"
:color="!isCompact ? 'primary' : 'neutral'"
@click="cardDensity = 'detailed'"
>
复杂
</UButton>
</div>
</UFormField>
</div>
<div class="min-h-[22rem] sm:min-h-[26rem]">
<div
v-if="loading"
:class="
isCompact
? 'grid gap-3 sm:grid-cols-2 lg:grid-cols-4 2xl:grid-cols-5'
: 'grid gap-4 sm:grid-cols-2 lg:grid-cols-3'
"
aria-busy="true"
aria-label="加载中"
>
<div
v-for="n in skeletonPlaceholders"
:key="n"
class="rounded-lg border border-default bg-default p-3 space-y-2.5 shadow-sm"
>
<div class="relative w-full aspect-[4/3] rounded-md bg-elevated/80 animate-pulse" />
<div class="h-3 w-[85%] max-w-[12rem] rounded bg-elevated/80 animate-pulse" />
<div class="flex gap-2">
<div class="h-3 w-16 rounded bg-elevated/60 animate-pulse" />
<div class="h-3 w-24 rounded bg-elevated/60 animate-pulse" />
</div>
<div class="h-3 w-20 rounded bg-elevated/50 animate-pulse" />
<div class="h-8 w-full rounded bg-elevated/40 animate-pulse" />
</div>
</div>
<UEmpty
v-else-if="!items.length"
:title="appliedSearch ? '无匹配项' : '暂无图片'"
:description="
appliedSearch
? '没有匹配当前关键词的文件名、引用或描述,可更换关键词或清空搜索。'
: '上传 PNG / JPEG / WebP 后,将在此列出并可直接复制链接。'
"
:class="{ 'opacity-60 pointer-events-none': listBusy }"
/>
<div
v-else
:class="[
isCompact
? 'grid gap-3 sm:grid-cols-2 lg:grid-cols-4 2xl:grid-cols-5'
: 'grid gap-4 sm:grid-cols-2 lg:grid-cols-3',
{ 'opacity-60 pointer-events-none': listBusy },
]"
>
<UCard v-for="item in items" :key="item.id" class="p-0">
<div class="space-y-2.5 p-3">
<div class="relative w-full aspect-[4/3] 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-2.5 gap-y-1 text-[11px] text-muted tabular-nums">
<span>{{ formatBytes(item.sizeBytes) }}</span>
<span>{{ formatDt(item.createdAt) }}</span>
</div>
<p class="text-[11px] text-default">
引用数: {{ item.refCount }}
</p>
<div v-if="!isCompact && item.refContexts.length" class="text-xs space-y-1">
<UButton
size="xs"
variant="link"
color="neutral"
class="p-0 h-auto font-normal"
@click="toggleRefDetail(item.id)"
>
{{ refDetailOpen[item.id] ? '收起引用详情' : `查看引用详情(${item.refContexts.length})` }}
</UButton>
<div v-show="refDetailOpen[item.id]" class="text-muted space-y-1 border-l border-default pl-2 ml-0.5">
<div v-for="(c, idx) in item.refContexts" :key="idx">
<NuxtLink
v-if="c.href"
:to="c.href"
class="text-primary underline-offset-2 hover:underline"
>
{{ c.label }}
</NuxtLink>
<span v-else>{{ c.label }}</span>
</div>
</div>
</div>
<div v-if="!isCompact" class="text-xs space-y-1">
<div class="text-muted">
文件描述
</div>
<template v-if="editingNoteId === item.id">
<UTextarea v-model="noteDraft[item.id]" :rows="2" class="text-xs w-full" />
<div class="flex flex-wrap gap-2">
<UButton
size="xs"
color="primary"
:loading="savingNoteId === item.id"
:disabled="loading || listBusy"
@click="saveMyNote(item.id)"
>
保存
</UButton>
<UButton
size="xs"
variant="ghost"
:disabled="savingNoteId === item.id"
@click="cancelEditNote(item)"
>
取消
</UButton>
</div>
</template>
<template v-else>
<p class="text-default whitespace-pre-wrap break-words min-h-[1.25rem]">
{{ (item.userNote ?? '').trim() ? item.userNote : '—' }}
</p>
<UButton size="xs" variant="soft" @click="startEditNote(item)">
修改描述
</UButton>
</template>
</div>
<div class="flex flex-wrap items-center gap-2">
<UButton size="xs" variant="soft" @click="copyUrl(item)">
复制 URL
</UButton>
<UButton size="xs" variant="soft" @click="copyMarkdown(item)">
复制 Markdown
</UButton>
<span class="inline-flex">
<UButton
size="xs"
color="error"
variant="soft"
:disabled="!item.canDelete || listBusy || loading || deletingId !== null"
@click="openDeleteConfirm(item)"
>
删除
</UButton>
</span>
</div>
<p
v-if="!isCompact && !item.canDelete && item.deleteBlockedReason"
class="text-[11px] text-muted leading-snug"
>
{{ deleteDisabledHint(item) }}
</p>
</div>
</UCard>
</div>
</div>
<div class="min-h-11 flex justify-end items-center">
<UPagination
v-if="!loading && !listBusy && total > pageSize"
v-model:page="page"
:total="total"
:items-per-page="pageSize"
size="sm"
/>
</div>
<UModal v-model:open="deleteConfirmOpen" title="确认删除图片">
<template #body>
<p class="text-sm text-default">
将永久删除文件并移除库记录,不可恢复。
</p>
<p
v-if="pendingDeleteAsset"
class="mt-2 font-mono text-xs text-muted break-all"
>
{{ pendingDeleteAsset.storageKey }}
</p>
</template>
<template #footer>
<div class="flex justify-end gap-2">
<UButton
variant="outline"
color="neutral"
:disabled="deletingId !== null"
@click="closeDeleteModal"
>
取消
</UButton>
<UButton color="error" :loading="deletingId !== null" @click="executeDelete">
删除
</UButton>
</div>
</template>
</UModal>
</div>
</template>