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.
 
 
 

283 lines
9.3 KiB

<script setup lang="ts">
import { useAuthSession } from '../../../composables/useAuthSession'
definePageMeta({ title: '媒体存储校验' })
type AuditReport = {
scannedAt: string
dbRowCount: number
diskFileCount: number
missingOnDisk: Array<{ id: number; userId: number; storageKey: string; refCount: number }>
invalidStorageKey: Array<{ id: number; userId: number; storageKey: string; refCount: number }>
onDiskNotInDb: string[]
}
const { user, refresh } = useAuthSession()
const { fetchData } = useClientApi()
const toast = useToast()
const loading = ref(false)
const cleaning = ref(false)
const report = ref<AuditReport | null>(null)
const cleanupOpen = ref(false)
async function ensureAdmin() {
await refresh(true)
if (user.value?.role !== 'admin') {
await navigateTo('/me')
}
}
async function runAudit() {
loading.value = true
try {
report.value = await fetchData<AuditReport>('/api/admin/media/storage-audit')
toast.add({ title: '校验完成', color: 'success' })
} finally {
loading.value = false
}
}
async function runCleanup() {
cleaning.value = true
try {
const { removed } = await fetchData<{ removed: number; removedIds: number[] }>(
'/api/admin/media/storage-audit-cleanup',
{ method: 'POST', body: {} },
)
toast.add({ title: `已移除 ${removed} 条失效记录(无引用且磁盘无文件)`, color: 'success' })
cleanupOpen.value = false
await runAudit()
} finally {
cleaning.value = false
}
}
const safeToCleanupCount = computed(() => {
if (!report.value) {
return 0
}
return report.value.missingOnDisk.filter((m) => m.refCount === 0).length
})
const riskyMissingCount = computed(() => {
if (!report.value) {
return 0
}
return report.value.missingOnDisk.filter((m) => m.refCount > 0).length
})
const cleanupDescription = computed(() => {
const n = safeToCleanupCount.value
return `将删除 ${n} 条 media_assets 记录:磁盘上无对应文件,且当前无任何文章引用。此操作不可撤销。`
})
onMounted(async () => {
await ensureAdmin()
})
</script>
<template>
<UContainer class="py-8 space-y-6 w-full max-w-[min(100%,88rem)] px-4 sm:px-6">
<div class="flex flex-wrap justify-between items-start gap-3">
<div>
<h1 class="text-2xl font-semibold">
媒体存储校验
</h1>
<p class="text-sm text-muted mt-1 max-w-3xl">
比对 <code class="text-xs">public/assets</code> 与表 <code class="text-xs">media_assets</code>
库中有记录但文件缺失非法 storageKey以及磁盘上未登记的文件
一键清理仅删除<strong>无文章引用</strong><strong>磁盘上确实没有文件</strong>的库记录不会删磁盘文件
</p>
</div>
<UButton to="/me" variant="ghost" size="sm">
返回控制台
</UButton>
</div>
<div class="flex flex-wrap items-center gap-3">
<UButton :loading="loading" @click="runAudit">
运行校验
</UButton>
<UButton
v-if="report && safeToCleanupCount > 0"
color="warning"
variant="outline"
:disabled="loading"
@click="cleanupOpen = true"
>
清理失效库记录({{ safeToCleanupCount }})
</UButton>
</div>
<div v-if="report" class="space-y-6">
<div class="text-sm text-muted flex flex-wrap gap-x-4 gap-y-1">
<span>扫描时间:{{ new Date(report.scannedAt).toLocaleString('zh-CN') }}</span>
<span>库行数:{{ report.dbRowCount }}</span>
<span>磁盘文件数:{{ report.diskFileCount }}</span>
</div>
<UCard v-if="riskyMissingCount > 0">
<template #header>
<span class="text-warning">需人工处理:文件缺失但仍被文章引用({{ riskyMissingCount }})</span>
</template>
<p class="text-sm text-muted mb-3">
请先修改相关文章正文或封面中的图片地址,再处理库记录;勿使用「清理失效库记录」删除这些行(当前实现不会删除有引用的行)。
</p>
<div class="overflow-x-auto rounded border border-default">
<table class="w-full text-sm">
<thead>
<tr class="text-left text-muted border-b border-default bg-elevated/40">
<th class="p-2">
id
</th>
<th class="p-2">
userId
</th>
<th class="p-2">
storageKey
</th>
<th class="p-2">
引用数
</th>
</tr>
</thead>
<tbody>
<tr v-for="row in report.missingOnDisk.filter((m) => m.refCount > 0)" :key="row.id" class="border-b border-default/60">
<td class="p-2 tabular-nums">
{{ row.id }}
</td>
<td class="p-2 tabular-nums">
{{ row.userId }}
</td>
<td class="p-2 font-mono text-xs">
{{ row.storageKey }}
</td>
<td class="p-2 tabular-nums">
{{ row.refCount }}
</td>
</tr>
</tbody>
</table>
</div>
</UCard>
<UCard>
<template #header>
库有记录但磁盘无文件(且无引用时可清理):{{ report.missingOnDisk.length }}
</template>
<UEmpty v-if="!report.missingOnDisk.length" title="无" />
<div v-else class="overflow-x-auto rounded border border-default">
<table class="w-full text-sm">
<thead>
<tr class="text-left text-muted border-b border-default bg-elevated/40">
<th class="p-2">
id
</th>
<th class="p-2">
userId
</th>
<th class="p-2">
storageKey
</th>
<th class="p-2">
引用数
</th>
</tr>
</thead>
<tbody>
<tr v-for="row in report.missingOnDisk" :key="row.id" class="border-b border-default/60">
<td class="p-2 tabular-nums">
{{ row.id }}
</td>
<td class="p-2 tabular-nums">
{{ row.userId }}
</td>
<td class="p-2 font-mono text-xs">
{{ row.storageKey }}
</td>
<td class="p-2 tabular-nums">
{{ row.refCount }}
</td>
</tr>
</tbody>
</table>
</div>
</UCard>
<UCard v-if="report.invalidStorageKey.length">
<template #header>
非法或可疑的 storageKey:{{ report.invalidStorageKey.length }}
</template>
<div class="overflow-x-auto rounded border border-default">
<table class="w-full text-sm">
<thead>
<tr class="text-left text-muted border-b border-default bg-elevated/40">
<th class="p-2">
id
</th>
<th class="p-2">
userId
</th>
<th class="p-2">
storageKey
</th>
<th class="p-2">
引用数
</th>
</tr>
</thead>
<tbody>
<tr v-for="row in report.invalidStorageKey" :key="row.id" class="border-b border-default/60">
<td class="p-2 tabular-nums">
{{ row.id }}
</td>
<td class="p-2 tabular-nums">
{{ row.userId }}
</td>
<td class="p-2 font-mono text-xs break-all">
{{ row.storageKey }}
</td>
<td class="p-2 tabular-nums">
{{ row.refCount }}
</td>
</tr>
</tbody>
</table>
</div>
</UCard>
<UCard>
<template #header>
磁盘有文件但未在 media_assets 登记:{{ report.onDiskNotInDb.length }}
</template>
<p class="text-sm text-muted mb-3">
可能是历史遗留或手动拷贝;需自行决定是否删除磁盘文件(本页不自动删文件)。
</p>
<UEmpty v-if="!report.onDiskNotInDb.length" title="无" />
<ul v-else class="font-mono text-xs max-h-64 overflow-y-auto space-y-1 border border-default rounded p-3 bg-elevated/30">
<li v-for="name in report.onDiskNotInDb" :key="name">
{{ name }}
</li>
</ul>
</UCard>
</div>
<UModal
v-model:open="cleanupOpen"
title="确认清理失效库记录"
:description="cleanupDescription"
>
<template #footer>
<div class="flex justify-end gap-2">
<UButton variant="outline" color="neutral" :disabled="cleaning" @click="cleanupOpen = false">
取消
</UButton>
<UButton color="warning" :loading="cleaning" @click="runCleanup">
确认清理
</UButton>
</div>
</template>
</UModal>
</UContainer>
</template>