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
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>
|
|
|