Browse Source

feat(ui): media orphans review page and dashboard link

Made-with: Cursor
main
npmrun 10 hours ago
parent
commit
8b7b44317d
  1. 11
      app/pages/me/index.vue
  2. 337
      app/pages/me/media/orphans.vue

11
app/pages/me/index.vue

@ -69,6 +69,17 @@ onMounted(async () => {
</UCard> </UCard>
<UCard> <UCard>
<div class="font-medium"> <div class="font-medium">
媒体清理
</div>
<p class="text-sm text-muted mt-1">
孤儿图片审查与清理
</p>
<UButton to="/me/media/orphans" class="mt-3" size="sm">
打开
</UButton>
</UCard>
<UCard>
<div class="font-medium">
时光机 时光机
</div> </div>
<UButton to="/me/timeline" class="mt-3" size="sm"> <UButton to="/me/timeline" class="mt-3" size="sm">

337
app/pages/me/media/orphans.vue

@ -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…
Cancel
Save