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.
379 lines
12 KiB
379 lines
12 KiB
<script setup lang="ts">
|
|
import { request, unwrapApiBody, type ApiResponse } from '../../../utils/http/factory'
|
|
import { useAuthSession } from '../../../composables/useAuthSession'
|
|
|
|
definePageMeta({ title: '图片孤儿审查' })
|
|
|
|
type OrphanItem = {
|
|
id: number
|
|
storageKey: string
|
|
publicUrl: string
|
|
sizeBytes: number
|
|
createdAt: string
|
|
firstReferencedAt: string | null
|
|
dereferencedAt: string | null
|
|
/** 宽限结束、允许删除的绝对时间(ISO);无法推算时为 null */
|
|
graceExpiresAt: string | null
|
|
state: 'deletable' | 'cooling'
|
|
}
|
|
|
|
type Filter = 'all' | 'deletable' | 'cooling'
|
|
|
|
const toast = useToast()
|
|
const { refresh: refreshAuth } = useAuthSession()
|
|
|
|
const filter = ref<Filter>('all')
|
|
const page = ref(1)
|
|
const pageSize = ref(20)
|
|
const items = ref<OrphanItem[]>([])
|
|
const total = ref(0)
|
|
const loading = ref(true)
|
|
const selectedIds = ref<Set<number>>(new Set())
|
|
const confirmOpen = ref(false)
|
|
const confirmIds = ref<number[]>([])
|
|
const deleting = ref(false)
|
|
|
|
const filterItems = [
|
|
{ label: '全部', value: 'all' as const },
|
|
{ label: '可删除', value: 'deletable' as const },
|
|
{ label: '宽限期', value: 'cooling' as const },
|
|
]
|
|
|
|
const pageSizeItems = [
|
|
{ label: '每页 10', value: 10 },
|
|
{ label: '每页 20', value: 20 },
|
|
{ label: '每页 50', value: 50 },
|
|
]
|
|
|
|
const deletableOnPage = computed(() => items.value.filter((i) => i.state === 'deletable'))
|
|
|
|
const allDeletableOnPageSelected = computed(() => {
|
|
const d = deletableOnPage.value
|
|
return d.length > 0 && d.every((i) => selectedIds.value.has(i.id))
|
|
})
|
|
|
|
const confirmDescription = computed(() => {
|
|
const n = confirmIds.value.length
|
|
return n === 1 ? '确定删除该图片?此操作不可恢复。' : `确定删除选中的 ${n} 张图片?此操作不可恢复。`
|
|
})
|
|
|
|
function extractError(e: unknown): string {
|
|
if (e && typeof e === 'object') {
|
|
const fe = e as {
|
|
statusMessage?: string
|
|
message?: string
|
|
data?: { message?: string }
|
|
}
|
|
if (typeof fe.statusMessage === 'string' && fe.statusMessage.length) {
|
|
return fe.statusMessage
|
|
}
|
|
if (typeof fe.data?.message === 'string' && fe.data.message.length) {
|
|
return fe.data.message
|
|
}
|
|
if (typeof fe.message === 'string' && fe.message.length) {
|
|
return fe.message
|
|
}
|
|
}
|
|
return '操作失败'
|
|
}
|
|
|
|
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({
|
|
filter: filter.value,
|
|
page: String(page.value),
|
|
pageSize: String(pageSize.value),
|
|
})
|
|
const res = await request<ApiResponse<{ items: OrphanItem[]; total: number }>>(
|
|
`/api/me/media/orphans?${q.toString()}`,
|
|
)
|
|
const body = unwrapApiBody(res)
|
|
items.value = body.items
|
|
total.value = body.total
|
|
const allowed = new Set(body.items.filter((i) => i.state === 'deletable').map((i) => i.id))
|
|
selectedIds.value = new Set([...selectedIds.value].filter((id) => allowed.has(id)))
|
|
} catch (e: unknown) {
|
|
toast.add({ title: extractError(e), color: 'error' })
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
watch([filter, pageSize], () => {
|
|
page.value = 1
|
|
})
|
|
|
|
watch(
|
|
[filter, page, pageSize],
|
|
() => {
|
|
void load()
|
|
},
|
|
{ immediate: true },
|
|
)
|
|
|
|
onMounted(() => {
|
|
void refreshAuth(true)
|
|
})
|
|
|
|
function toggleSelected(id: number, checked: boolean) {
|
|
const next = new Set(selectedIds.value)
|
|
if (checked) {
|
|
next.add(id)
|
|
} else {
|
|
next.delete(id)
|
|
}
|
|
selectedIds.value = next
|
|
}
|
|
|
|
function toggleSelectAllOnPage(checked: boolean) {
|
|
if (!checked) {
|
|
const next = new Set(selectedIds.value)
|
|
for (const i of deletableOnPage.value) {
|
|
next.delete(i.id)
|
|
}
|
|
selectedIds.value = next
|
|
return
|
|
}
|
|
const next = new Set(selectedIds.value)
|
|
for (const i of deletableOnPage.value) {
|
|
next.add(i.id)
|
|
}
|
|
selectedIds.value = next
|
|
}
|
|
|
|
const batchCount = computed(() => [...selectedIds.value].length)
|
|
|
|
function openConfirm(ids: number[]) {
|
|
confirmIds.value = ids
|
|
confirmOpen.value = true
|
|
}
|
|
|
|
async function executeDelete() {
|
|
deleting.value = true
|
|
try {
|
|
const res = await request<ApiResponse<{ deleted: number }>>('/api/me/media/orphans-delete', {
|
|
method: 'POST',
|
|
body: { ids: confirmIds.value },
|
|
})
|
|
const { deleted } = unwrapApiBody(res)
|
|
toast.add({ title: `已删除 ${deleted} 项`, color: 'success' })
|
|
confirmOpen.value = false
|
|
selectedIds.value = new Set()
|
|
await load()
|
|
} catch (e: unknown) {
|
|
toast.add({ title: extractError(e), color: 'error' })
|
|
} finally {
|
|
deleting.value = false
|
|
}
|
|
}
|
|
</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">
|
|
未被任何文章引用的上传图片;宽限期结束后可删除。
|
|
</p>
|
|
</div>
|
|
<UButton to="/me" variant="ghost" size="sm">
|
|
返回控制台
|
|
</UButton>
|
|
</div>
|
|
|
|
<div class="flex flex-wrap items-end gap-x-5 gap-y-3">
|
|
<UFormField label="筛选" class="w-44 shrink-0">
|
|
<USelect v-model="filter" :items="filterItems" value-key="value" class="w-full" />
|
|
</UFormField>
|
|
<UFormField label="每页" class="w-40 shrink-0">
|
|
<USelect v-model="pageSize" :items="pageSizeItems" value-key="value" class="w-full" />
|
|
</UFormField>
|
|
<div class="shrink-0">
|
|
<UButton
|
|
:color="batchCount > 0 ? 'error' : 'neutral'"
|
|
variant="outline"
|
|
:disabled="batchCount === 0"
|
|
class="min-h-8"
|
|
@click="openConfirm([...selectedIds])"
|
|
>
|
|
批量删除({{ batchCount }})
|
|
</UButton>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="loading" class="text-muted">
|
|
加载中…
|
|
</div>
|
|
<UEmpty v-else-if="!items.length" title="暂无记录" description="当前筛选下没有孤儿图片" />
|
|
<div v-else class="overflow-x-auto rounded-lg border border-default">
|
|
<table class="w-full min-w-[960px] table-fixed text-sm">
|
|
<thead>
|
|
<tr class="text-left text-muted border-b border-default bg-elevated/40">
|
|
<th class="p-3 w-10 align-middle">
|
|
<UCheckbox
|
|
v-if="deletableOnPage.length"
|
|
:model-value="allDeletableOnPageSelected"
|
|
@update:model-value="toggleSelectAllOnPage(!!$event)"
|
|
/>
|
|
</th>
|
|
<th class="p-3 w-20 align-middle">
|
|
预览
|
|
</th>
|
|
<th class="p-3 align-middle w-[32%]">
|
|
文件
|
|
</th>
|
|
<th class="p-3 whitespace-nowrap w-16 align-middle">
|
|
大小
|
|
</th>
|
|
<th class="p-3 whitespace-nowrap w-36 align-middle">
|
|
创建
|
|
</th>
|
|
<th class="p-3 whitespace-nowrap hidden lg:table-cell w-36 align-middle">
|
|
首次引用
|
|
</th>
|
|
<th class="p-3 whitespace-nowrap hidden lg:table-cell w-36 align-middle">
|
|
解除引用
|
|
</th>
|
|
<th class="p-3 whitespace-nowrap w-40 align-middle">
|
|
到期可删
|
|
</th>
|
|
<th class="p-3 w-24 align-middle whitespace-nowrap">
|
|
状态
|
|
</th>
|
|
<th class="p-3 text-right w-24 align-middle whitespace-nowrap">
|
|
操作
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="row in items" :key="row.id" class="border-b border-default/60 last:border-b-0">
|
|
<td class="p-3 align-middle">
|
|
<UCheckbox
|
|
v-if="row.state === 'deletable'"
|
|
:model-value="selectedIds.has(row.id)"
|
|
@update:model-value="toggleSelected(row.id, !!$event)"
|
|
/>
|
|
<span v-else class="text-muted text-xs">—</span>
|
|
</td>
|
|
<td class="p-3 align-middle">
|
|
<img
|
|
:src="row.publicUrl"
|
|
:alt="row.storageKey"
|
|
class="h-14 w-14 shrink-0 object-cover rounded border border-default"
|
|
loading="lazy"
|
|
>
|
|
</td>
|
|
<td class="p-3 align-middle max-w-0">
|
|
<div
|
|
class="truncate font-mono text-xs text-default"
|
|
:title="row.storageKey"
|
|
>
|
|
{{ row.storageKey }}
|
|
</div>
|
|
</td>
|
|
<td class="p-3 align-middle tabular-nums whitespace-nowrap">
|
|
{{ formatBytes(row.sizeBytes) }}
|
|
</td>
|
|
<td class="p-3 align-middle text-xs whitespace-nowrap">
|
|
{{ formatDt(row.createdAt) }}
|
|
</td>
|
|
<td class="p-3 align-middle text-xs whitespace-nowrap hidden lg:table-cell">
|
|
{{ formatDt(row.firstReferencedAt) }}
|
|
</td>
|
|
<td class="p-3 align-middle text-xs whitespace-nowrap hidden lg:table-cell">
|
|
{{ formatDt(row.dereferencedAt) }}
|
|
</td>
|
|
<td class="p-3 align-middle text-xs whitespace-nowrap tabular-nums">
|
|
<span
|
|
:class="row.state === 'deletable' ? 'text-muted' : 'text-default'"
|
|
:title="row.graceExpiresAt ? `到达该时间后即可删除(或已可删)` : ''"
|
|
>
|
|
{{ formatDt(row.graceExpiresAt) }}
|
|
</span>
|
|
</td>
|
|
<td class="p-3 align-middle">
|
|
<div class="flex items-center justify-start">
|
|
<UBadge
|
|
v-if="row.state === 'deletable'"
|
|
color="error"
|
|
variant="subtle"
|
|
size="sm"
|
|
class="whitespace-nowrap"
|
|
>
|
|
可删除
|
|
</UBadge>
|
|
<UBadge
|
|
v-else
|
|
color="warning"
|
|
variant="subtle"
|
|
size="sm"
|
|
class="whitespace-nowrap"
|
|
>
|
|
宽限期
|
|
</UBadge>
|
|
</div>
|
|
</td>
|
|
<td class="p-3 align-middle text-right">
|
|
<UButton
|
|
v-if="row.state === 'deletable'"
|
|
size="xs"
|
|
color="error"
|
|
variant="soft"
|
|
class="whitespace-nowrap"
|
|
@click="openConfirm([row.id])"
|
|
>
|
|
删除
|
|
</UButton>
|
|
<span
|
|
v-else
|
|
class="inline-block text-left text-xs text-muted leading-snug max-w-[5.5rem]"
|
|
title="宽限期未满,不可删除。可在「可删除」筛选中查看已到期项。"
|
|
>
|
|
宽限中
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</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>
|
|
|
|
<UModal v-model:open="confirmOpen" title="确认删除" :description="confirmDescription">
|
|
<template #footer>
|
|
<div class="flex justify-end gap-2">
|
|
<UButton variant="outline" color="neutral" :disabled="deleting" @click="confirmOpen = false">
|
|
取消
|
|
</UButton>
|
|
<UButton color="error" :loading="deleting" @click="executeDelete">
|
|
删除
|
|
</UButton>
|
|
</div>
|
|
</template>
|
|
</UModal>
|
|
</UContainer>
|
|
</template>
|
|
|