feat: add bulk actions to admin image management

This commit is contained in:
2026-05-31 16:30:35 +08:00
parent f6edd778b0
commit e385087ea6
+144 -15
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { NAlert, NButton, NEmpty, NIcon, NSkeleton, NTag, useMessage } from 'naive-ui'
import { Check, Eye, EyeOff, Refresh, Trash, X } from '@vicons/tabler'
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
@@ -63,6 +63,7 @@ const dashboardStats = ref<DashboardStats>({
const users = ref<Profile[]>([])
const images = ref<AdminCloud[]>([])
const selectedReviewIds = ref<Set<string>>(new Set())
const selectedImageIds = ref<Set<string>>(new Set())
const imageFilter = ref<ImageFilter>('all')
const selectedImage = ref<AdminCloud | null>(null)
@@ -90,6 +91,11 @@ const filteredImages = computed(() => {
if (imageFilter.value === 'all') return images.value
return images.value.filter(item => item.status === imageFilter.value)
})
const selectedManagedCount = computed(() => selectedImageIds.value.size)
watch(imageFilter, () => {
setSelectedImageIds([])
})
const cloudTypeStats = computed(() => {
const counts = new Map<string, number>()
@@ -249,6 +255,7 @@ async function fetchImages() {
if (error) throw error
images.value = ((data || []) as Array<Record<string, unknown>>).map(toAdminCloud)
selectedReviewIds.value = new Set([...selectedReviewIds.value].filter(id => images.value.some(item => item.id === id)))
selectedImageIds.value = new Set([...selectedImageIds.value].filter(id => images.value.some(item => item.id === id)))
}
async function loadAdminData() {
@@ -290,6 +297,28 @@ function toggleAllPendingSelection() {
setSelectedReviewIds(pendingImages.value.map(item => item.id))
}
function setSelectedImageIds(ids: Iterable<string>) {
selectedImageIds.value = new Set(ids)
}
function toggleImageSelection(cloudId: string) {
const next = new Set(selectedImageIds.value)
if (next.has(cloudId)) {
next.delete(cloudId)
} else {
next.add(cloudId)
}
setSelectedImageIds(next)
}
function toggleAllFilteredImageSelection() {
if (selectedImageIds.value.size === filteredImages.value.length) {
setSelectedImageIds([])
return
}
setSelectedImageIds(filteredImages.value.map(item => item.id))
}
function patchImages(ids: string[], patch: Partial<AdminCloud>) {
const idSet = new Set(ids)
images.value = images.value.map(item => (idSet.has(item.id) ? { ...item, ...patch } : item))
@@ -321,6 +350,7 @@ async function updateCloudStatus(ids: string[], status: CloudStatus) {
patchImages(ids, { status })
setSelectedReviewIds([...selectedReviewIds.value].filter(id => !ids.includes(id)))
setSelectedImageIds([...selectedImageIds.value].filter(id => !ids.includes(id)))
await fetchStats()
message.success(`${status === 'approved' ? '通过' : '拒绝'} ${ids.length} 张图片`)
} catch (error) {
@@ -332,26 +362,28 @@ async function updateCloudStatus(ids: string[], status: CloudStatus) {
}
}
async function toggleImageVisibility(cloud: AdminCloud) {
async function updateImageVisibility(ids: string[], isHidden: boolean) {
if (!ids.length) return
actionLoading.value = true
loadError.value = ''
try {
const nextHidden = !cloud.is_hidden
const { data, error } = await supabase
.from('clouds')
.update({ is_hidden: nextHidden })
.eq('id', cloud.id)
.update({ is_hidden: isHidden })
.in('id', ids)
.select('id')
if (error) throw error
if (!data?.length) {
if ((data || []).length !== ids.length) {
throw new Error('图片可见性没有写入数据库,请检查管理员 UPDATE RLS policy。')
}
patchImages([cloud.id], { is_hidden: nextHidden })
patchImages(ids, { is_hidden: isHidden })
setSelectedImageIds([...selectedImageIds.value].filter(id => !ids.includes(id)))
await fetchStats()
message.success(nextHidden ? '图片已设为隐藏' : '图片已恢复公开')
message.success(isHidden ? `已隐藏 ${ids.length} 张图片` : `已公开 ${ids.length} 张图片`)
} catch (error) {
const text = getErrorMessage(error, '可见性更新失败')
loadError.value = text
@@ -361,20 +393,45 @@ async function toggleImageVisibility(cloud: AdminCloud) {
}
}
async function deleteImage(cloud: AdminCloud) {
const confirmed = window.confirm(`确定删除 ${cloud.cloudTypeName} 这张图片吗?删除后无法在页面中恢复。`)
async function toggleImageVisibility(cloud: AdminCloud) {
await updateImageVisibility([cloud.id], !cloud.is_hidden)
}
async function deleteImages(clouds: AdminCloud[]) {
if (!clouds.length) return
const confirmed = window.confirm(
clouds.length === 1
? `确定删除 ${clouds[0].cloudTypeName} 这张图片吗?删除后无法在页面中恢复。`
: `确定删除选中的 ${clouds.length} 张图片吗?删除后无法在页面中恢复。`,
)
if (!confirmed) return
actionLoading.value = true
loadError.value = ''
try {
await profileStore.deleteClouds(cloud.user_id, [cloud.id])
images.value = images.value.filter(item => item.id !== cloud.id)
if (selectedImage.value?.id === cloud.id) selectedImage.value = null
setSelectedReviewIds([...selectedReviewIds.value].filter(id => id !== cloud.id))
const idsToDelete = clouds.map(cloud => cloud.id)
const cloudsByUser = new Map<string, string[]>()
for (const cloud of clouds) {
const current = cloudsByUser.get(cloud.user_id) || []
current.push(cloud.id)
cloudsByUser.set(cloud.user_id, current)
}
for (const [userId, cloudIds] of cloudsByUser.entries()) {
await profileStore.deleteClouds(userId, cloudIds)
}
images.value = images.value.filter(item => !idsToDelete.includes(item.id))
if (selectedImage.value && idsToDelete.includes(selectedImage.value.id)) {
selectedImage.value = null
}
setSelectedReviewIds([...selectedReviewIds.value].filter(id => !idsToDelete.includes(id)))
setSelectedImageIds([...selectedImageIds.value].filter(id => !idsToDelete.includes(id)))
await fetchStats()
message.success('图片已删除')
message.success(clouds.length === 1 ? '图片已删除' : `已删除 ${clouds.length} 张图片`)
} catch (error) {
const text = getErrorMessage(error, '图片删除失败')
loadError.value = text
@@ -384,6 +441,10 @@ async function deleteImage(cloud: AdminCloud) {
}
}
async function deleteImage(cloud: AdminCloud) {
await deleteImages([cloud])
}
async function updateUserRole(user: Profile, role: Profile['role']) {
if (user.id === authStore.user?.id && role !== 'admin') {
message.warning('不能移除自己的管理员权限')
@@ -702,6 +763,7 @@ onMounted(loadAdminData)
<h2 class="text-xl font-bold text-slate-950">图片管理</h2>
<p class="mt-1 text-sm text-slate-500">最近 {{ images.length }} 张图片可调整审核状态可见性或删除</p>
</div>
<div class="flex flex-wrap gap-2">
<select
v-model="imageFilter"
class="border border-slate-300 bg-white px-3 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500"
@@ -711,6 +773,65 @@ onMounted(loadAdminData)
<option value="approved">已通过</option>
<option value="rejected">已拒绝</option>
</select>
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--neutral" @click="toggleAllFilteredImageSelection">
{{ selectedManagedCount === filteredImages.length && filteredImages.length ? '取消全选' : '全选当前列表' }}
</NButton>
<NButton
secondary
strong
type="default"
class="oc-panel-button oc-panel-button--teal"
:disabled="!selectedManagedCount"
:loading="actionLoading"
@click="updateCloudStatus(Array.from(selectedImageIds), 'approved')"
>
批量通过 {{ selectedManagedCount || '' }}
</NButton>
<NButton
secondary
strong
type="default"
class="oc-panel-button oc-panel-button--danger"
:disabled="!selectedManagedCount"
:loading="actionLoading"
@click="updateCloudStatus(Array.from(selectedImageIds), 'rejected')"
>
批量拒绝 {{ selectedManagedCount || '' }}
</NButton>
<NButton
secondary
strong
type="default"
class="oc-panel-button oc-panel-button--amber"
:disabled="!selectedManagedCount"
:loading="actionLoading"
@click="updateImageVisibility(Array.from(selectedImageIds), true)"
>
批量隐藏 {{ selectedManagedCount || '' }}
</NButton>
<NButton
secondary
strong
type="default"
class="oc-panel-button oc-panel-button--teal"
:disabled="!selectedManagedCount"
:loading="actionLoading"
@click="updateImageVisibility(Array.from(selectedImageIds), false)"
>
批量公开 {{ selectedManagedCount || '' }}
</NButton>
<NButton
secondary
strong
type="default"
class="oc-panel-button oc-panel-button--danger"
:disabled="!selectedManagedCount"
:loading="actionLoading"
@click="deleteImages(filteredImages.filter(item => selectedImageIds.has(item.id)))"
>
批量删除 {{ selectedManagedCount || '' }}
</NButton>
</div>
</div>
<div v-if="filteredImages.length" class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
@@ -724,9 +845,17 @@ onMounted(loadAdminData)
<p class="truncate font-semibold text-slate-950">{{ item.cloudTypeName }}</p>
<p class="mt-1 truncate text-sm text-slate-500">{{ item.username }} · {{ item.location_name || '未填写位置' }}</p>
</div>
<div class="flex items-start gap-2">
<NTag size="small" :bordered="false" :class="statusMeta[item.status].chip">
{{ statusMeta[item.status].label }}
</NTag>
<input
type="checkbox"
class="mt-0.5 h-5 w-5"
:checked="selectedImageIds.has(item.id)"
@change="toggleImageSelection(item.id)"
/>
</div>
</div>
<div class="mt-4 flex flex-wrap gap-2">
<NButton size="small" type="default" secondary strong class="oc-panel-button oc-panel-button--neutral" @click="selectedImage = item">查看</NButton>