优化密码找回流程、管理后台批量操作和上传体验 #2
+156
-27
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<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 { NAlert, NButton, NEmpty, NIcon, NSkeleton, NTag, useMessage } from 'naive-ui'
|
||||||
import { Check, Eye, EyeOff, Refresh, Trash, X } from '@vicons/tabler'
|
import { Check, Eye, EyeOff, Refresh, Trash, X } from '@vicons/tabler'
|
||||||
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
|
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
|
||||||
@@ -63,6 +63,7 @@ const dashboardStats = ref<DashboardStats>({
|
|||||||
const users = ref<Profile[]>([])
|
const users = ref<Profile[]>([])
|
||||||
const images = ref<AdminCloud[]>([])
|
const images = ref<AdminCloud[]>([])
|
||||||
const selectedReviewIds = ref<Set<string>>(new Set())
|
const selectedReviewIds = ref<Set<string>>(new Set())
|
||||||
|
const selectedImageIds = ref<Set<string>>(new Set())
|
||||||
const imageFilter = ref<ImageFilter>('all')
|
const imageFilter = ref<ImageFilter>('all')
|
||||||
const selectedImage = ref<AdminCloud | null>(null)
|
const selectedImage = ref<AdminCloud | null>(null)
|
||||||
|
|
||||||
@@ -90,6 +91,11 @@ const filteredImages = computed(() => {
|
|||||||
if (imageFilter.value === 'all') return images.value
|
if (imageFilter.value === 'all') return images.value
|
||||||
return images.value.filter(item => item.status === imageFilter.value)
|
return images.value.filter(item => item.status === imageFilter.value)
|
||||||
})
|
})
|
||||||
|
const selectedManagedCount = computed(() => selectedImageIds.value.size)
|
||||||
|
|
||||||
|
watch(imageFilter, () => {
|
||||||
|
setSelectedImageIds([])
|
||||||
|
})
|
||||||
|
|
||||||
const cloudTypeStats = computed(() => {
|
const cloudTypeStats = computed(() => {
|
||||||
const counts = new Map<string, number>()
|
const counts = new Map<string, number>()
|
||||||
@@ -249,6 +255,7 @@ async function fetchImages() {
|
|||||||
if (error) throw error
|
if (error) throw error
|
||||||
images.value = ((data || []) as Array<Record<string, unknown>>).map(toAdminCloud)
|
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)))
|
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() {
|
async function loadAdminData() {
|
||||||
@@ -290,6 +297,28 @@ function toggleAllPendingSelection() {
|
|||||||
setSelectedReviewIds(pendingImages.value.map(item => item.id))
|
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>) {
|
function patchImages(ids: string[], patch: Partial<AdminCloud>) {
|
||||||
const idSet = new Set(ids)
|
const idSet = new Set(ids)
|
||||||
images.value = images.value.map(item => (idSet.has(item.id) ? { ...item, ...patch } : item))
|
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 })
|
patchImages(ids, { status })
|
||||||
setSelectedReviewIds([...selectedReviewIds.value].filter(id => !ids.includes(id)))
|
setSelectedReviewIds([...selectedReviewIds.value].filter(id => !ids.includes(id)))
|
||||||
|
setSelectedImageIds([...selectedImageIds.value].filter(id => !ids.includes(id)))
|
||||||
await fetchStats()
|
await fetchStats()
|
||||||
message.success(`已${status === 'approved' ? '通过' : '拒绝'} ${ids.length} 张图片`)
|
message.success(`已${status === 'approved' ? '通过' : '拒绝'} ${ids.length} 张图片`)
|
||||||
} catch (error) {
|
} 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
|
actionLoading.value = true
|
||||||
loadError.value = ''
|
loadError.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const nextHidden = !cloud.is_hidden
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('clouds')
|
.from('clouds')
|
||||||
.update({ is_hidden: nextHidden })
|
.update({ is_hidden: isHidden })
|
||||||
.eq('id', cloud.id)
|
.in('id', ids)
|
||||||
.select('id')
|
.select('id')
|
||||||
|
|
||||||
if (error) throw error
|
if (error) throw error
|
||||||
if (!data?.length) {
|
if ((data || []).length !== ids.length) {
|
||||||
throw new Error('图片可见性没有写入数据库,请检查管理员 UPDATE RLS policy。')
|
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()
|
await fetchStats()
|
||||||
message.success(nextHidden ? '图片已设为隐藏' : '图片已恢复公开')
|
message.success(isHidden ? `已隐藏 ${ids.length} 张图片` : `已公开 ${ids.length} 张图片`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const text = getErrorMessage(error, '可见性更新失败')
|
const text = getErrorMessage(error, '可见性更新失败')
|
||||||
loadError.value = text
|
loadError.value = text
|
||||||
@@ -361,20 +393,45 @@ async function toggleImageVisibility(cloud: AdminCloud) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteImage(cloud: AdminCloud) {
|
async function toggleImageVisibility(cloud: AdminCloud) {
|
||||||
const confirmed = window.confirm(`确定删除 ${cloud.cloudTypeName} 这张图片吗?删除后无法在页面中恢复。`)
|
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
|
if (!confirmed) return
|
||||||
|
|
||||||
actionLoading.value = true
|
actionLoading.value = true
|
||||||
loadError.value = ''
|
loadError.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await profileStore.deleteClouds(cloud.user_id, [cloud.id])
|
const idsToDelete = clouds.map(cloud => cloud.id)
|
||||||
images.value = images.value.filter(item => item.id !== cloud.id)
|
const cloudsByUser = new Map<string, string[]>()
|
||||||
if (selectedImage.value?.id === cloud.id) selectedImage.value = null
|
|
||||||
setSelectedReviewIds([...selectedReviewIds.value].filter(id => id !== cloud.id))
|
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()
|
await fetchStats()
|
||||||
message.success('图片已删除')
|
message.success(clouds.length === 1 ? '图片已删除' : `已删除 ${clouds.length} 张图片`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const text = getErrorMessage(error, '图片删除失败')
|
const text = getErrorMessage(error, '图片删除失败')
|
||||||
loadError.value = text
|
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']) {
|
async function updateUserRole(user: Profile, role: Profile['role']) {
|
||||||
if (user.id === authStore.user?.id && role !== 'admin') {
|
if (user.id === authStore.user?.id && role !== 'admin') {
|
||||||
message.warning('不能移除自己的管理员权限')
|
message.warning('不能移除自己的管理员权限')
|
||||||
@@ -702,15 +763,75 @@ onMounted(loadAdminData)
|
|||||||
<h2 class="text-xl font-bold text-slate-950">图片管理</h2>
|
<h2 class="text-xl font-bold text-slate-950">图片管理</h2>
|
||||||
<p class="mt-1 text-sm text-slate-500">最近 {{ images.length }} 张图片,可调整审核状态、可见性或删除。</p>
|
<p class="mt-1 text-sm text-slate-500">最近 {{ images.length }} 张图片,可调整审核状态、可见性或删除。</p>
|
||||||
</div>
|
</div>
|
||||||
<select
|
<div class="flex flex-wrap gap-2">
|
||||||
v-model="imageFilter"
|
<select
|
||||||
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"
|
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"
|
||||||
<option value="all">全部状态</option>
|
>
|
||||||
<option value="pending">待审核</option>
|
<option value="all">全部状态</option>
|
||||||
<option value="approved">已通过</option>
|
<option value="pending">待审核</option>
|
||||||
<option value="rejected">已拒绝</option>
|
<option value="approved">已通过</option>
|
||||||
</select>
|
<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>
|
||||||
|
|
||||||
<div v-if="filteredImages.length" class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
<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="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>
|
<p class="mt-1 truncate text-sm text-slate-500">{{ item.username }} · {{ item.location_name || '未填写位置' }}</p>
|
||||||
</div>
|
</div>
|
||||||
<NTag size="small" :bordered="false" :class="statusMeta[item.status].chip">
|
<div class="flex items-start gap-2">
|
||||||
{{ statusMeta[item.status].label }}
|
<NTag size="small" :bordered="false" :class="statusMeta[item.status].chip">
|
||||||
</NTag>
|
{{ 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>
|
||||||
<div class="mt-4 flex flex-wrap gap-2">
|
<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>
|
<NButton size="small" type="default" secondary strong class="oc-panel-button oc-panel-button--neutral" @click="selectedImage = item">查看</NButton>
|
||||||
|
|||||||
Reference in New Issue
Block a user