2 changed files with 348 additions and 0 deletions
@ -0,0 +1,337 @@ |
|||||
|
<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 |
||||
|
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 max-w-5xl"> |
||||
|
<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-center gap-3"> |
||||
|
<UFormField label="筛选" class="min-w-40"> |
||||
|
<USelect v-model="filter" :items="filterItems" value-key="value" /> |
||||
|
</UFormField> |
||||
|
<UFormField label="每页" class="min-w-36"> |
||||
|
<USelect v-model="pageSize" :items="pageSizeItems" value-key="value" /> |
||||
|
</UFormField> |
||||
|
<UButton |
||||
|
color="error" |
||||
|
variant="soft" |
||||
|
:disabled="batchCount === 0" |
||||
|
@click="openConfirm([...selectedIds])" |
||||
|
> |
||||
|
批量删除({{ batchCount }}) |
||||
|
</UButton> |
||||
|
</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 text-sm"> |
||||
|
<thead> |
||||
|
<tr class="text-left text-muted border-b border-default bg-elevated/40"> |
||||
|
<th class="p-3 w-10"> |
||||
|
<UCheckbox |
||||
|
v-if="deletableOnPage.length" |
||||
|
:model-value="allDeletableOnPageSelected" |
||||
|
@update:model-value="toggleSelectAllOnPage(!!$event)" |
||||
|
/> |
||||
|
</th> |
||||
|
<th class="p-3 w-24"> |
||||
|
预览 |
||||
|
</th> |
||||
|
<th class="p-3 min-w-[8rem]"> |
||||
|
storageKey |
||||
|
</th> |
||||
|
<th class="p-3 whitespace-nowrap"> |
||||
|
大小 |
||||
|
</th> |
||||
|
<th class="p-3 whitespace-nowrap"> |
||||
|
创建 |
||||
|
</th> |
||||
|
<th class="p-3 whitespace-nowrap hidden lg:table-cell"> |
||||
|
首次引用 |
||||
|
</th> |
||||
|
<th class="p-3 whitespace-nowrap hidden lg:table-cell"> |
||||
|
解除引用 |
||||
|
</th> |
||||
|
<th class="p-3"> |
||||
|
状态 |
||||
|
</th> |
||||
|
<th class="p-3 text-right w-28"> |
||||
|
操作 |
||||
|
</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 object-cover rounded border border-default" |
||||
|
loading="lazy" |
||||
|
> |
||||
|
</td> |
||||
|
<td class="p-3 align-middle font-mono text-xs break-all max-w-[14rem]"> |
||||
|
{{ row.storageKey }} |
||||
|
</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"> |
||||
|
<UBadge v-if="row.state === 'deletable'" color="error" variant="subtle"> |
||||
|
可删除 |
||||
|
</UBadge> |
||||
|
<UBadge v-else color="warning" variant="subtle"> |
||||
|
宽限期 |
||||
|
</UBadge> |
||||
|
</td> |
||||
|
<td class="p-3 align-middle text-right"> |
||||
|
<UButton |
||||
|
v-if="row.state === 'deletable'" |
||||
|
size="xs" |
||||
|
color="error" |
||||
|
variant="soft" |
||||
|
@click="openConfirm([row.id])" |
||||
|
> |
||||
|
删除 |
||||
|
</UButton> |
||||
|
<span v-else class="text-muted text-xs">—</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> |
||||
Loading…
Reference in new issue