Files
opencloud/src/views/admin/AdminView.vue
T

938 lines
37 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
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'
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
import { supabase } from '@/lib/supabase'
import { useAuthStore } from '@/stores/auth'
import { useProfileStore } from '@/stores/profile'
import type { CloudType, Profile } from '@/types/database'
type AdminTab = 'dashboard' | 'review' | 'users' | 'images'
type CloudStatus = 'pending' | 'approved' | 'rejected'
type ImageFilter = 'all' | CloudStatus
interface AdminCloud {
id: string
user_id: string
cloud_type_id: number | null
custom_cloud_type: string | null
image_url: string
thumbnail_url: string | null
latitude: number | null
longitude: number | null
location_name: string | null
description: string | null
captured_at: string | null
created_at: string
status: CloudStatus
is_hidden: boolean
cloudTypeName: string
cloudTypeRarity: CloudType['rarity']
username: string
}
interface DashboardStats {
users: number
images: number
todayUploads: number
pending: number
approved: number
rejected: number
hidden: number
}
const authStore = useAuthStore()
const profileStore = useProfileStore()
const message = useMessage()
const activeTab = ref<AdminTab>('dashboard')
const loading = ref(true)
const actionLoading = ref(false)
const loadError = ref('')
const dashboardStats = ref<DashboardStats>({
users: 0,
images: 0,
todayUploads: 0,
pending: 0,
approved: 0,
rejected: 0,
hidden: 0,
})
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)
const rarityMeta = {
common: { label: '常见', chip: 'border-sky-200 bg-sky-100 text-sky-700' },
uncommon: { label: '少见', chip: 'border-amber-200 bg-amber-100 text-amber-700' },
rare: { label: '罕见', chip: 'border-rose-200 bg-rose-100 text-rose-700' },
} satisfies Record<CloudType['rarity'], { label: string; chip: string }>
const statusMeta = {
pending: { label: '待审核', chip: 'border-amber-200 bg-amber-100 text-amber-700' },
approved: { label: '已通过', chip: 'border-emerald-200 bg-emerald-100 text-emerald-700' },
rejected: { label: '已拒绝', chip: 'border-rose-200 bg-rose-100 text-rose-700' },
} satisfies Record<CloudStatus, { label: string; chip: string }>
const tabs = [
{ key: 'dashboard', label: '数据看板' },
{ key: 'review', label: '内容审核' },
{ key: 'users', label: '用户管理' },
{ key: 'images', label: '图片管理' },
] satisfies Array<{ key: AdminTab; label: string }>
const pendingImages = computed(() => images.value.filter(item => item.status === 'pending'))
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>()
for (const item of images.value) {
counts.set(item.cloudTypeName, (counts.get(item.cloudTypeName) || 0) + 1)
}
return Array.from(counts.entries())
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 8)
})
const selectedReviewCount = computed(() => selectedReviewIds.value.size)
function readJoinedOne(value: unknown) {
const rows = Array.isArray(value) ? value : value ? [value] : []
return rows[0] as Record<string, unknown> | undefined
}
function toAdminCloud(row: Record<string, unknown>): AdminCloud {
const cloudType = readJoinedOne(row.cloud_types)
const profile = readJoinedOne(row.profiles)
return {
id: row.id as string,
user_id: row.user_id as string,
cloud_type_id: (row.cloud_type_id as number | null) ?? null,
custom_cloud_type: (row.custom_cloud_type as string | null) ?? null,
image_url: row.image_url as string,
thumbnail_url: (row.thumbnail_url as string | null) ?? null,
latitude: (row.latitude as number | null) ?? null,
longitude: (row.longitude as number | null) ?? null,
location_name: (row.location_name as string | null) ?? null,
description: (row.description as string | null) ?? null,
captured_at: (row.captured_at as string | null) ?? null,
created_at: row.created_at as string,
status: row.status as CloudStatus,
is_hidden: (row.is_hidden as boolean) ?? false,
cloudTypeName: (cloudType?.name as string) || (row.custom_cloud_type as string) || '未知云型',
cloudTypeRarity: (cloudType?.rarity as CloudType['rarity']) || 'common',
username: (profile?.username as string) || '匿名用户',
}
}
function formatDateTime(iso: string | null) {
if (!iso) return '未知时间'
return new Date(iso).toLocaleString('zh-CN', {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
function formatCoordinate(value: number | null) {
return value === null ? '未记录' : value.toFixed(2)
}
function getErrorMessage(error: unknown, fallback: string) {
if (!error || typeof error !== 'object') return fallback
const payload = error as {
message?: unknown
code?: unknown
details?: unknown
hint?: unknown
}
const parts = [
typeof payload.message === 'string' ? payload.message : '',
typeof payload.code === 'string' ? `错误码:${payload.code}` : '',
typeof payload.details === 'string' ? payload.details : '',
typeof payload.hint === 'string' ? `提示:${payload.hint}` : '',
].filter(Boolean)
return parts.length ? parts.join('') : fallback
}
function todayIsoStart() {
const date = new Date()
date.setHours(0, 0, 0, 0)
return date.toISOString()
}
async function countProfiles() {
const { count, error } = await supabase
.from('profiles')
.select('*', { head: true, count: 'exact' })
if (error) throw error
return count || 0
}
async function countClouds(filters: {
status?: CloudStatus
isHidden?: boolean
createdAfter?: string
} = {}) {
let query = supabase
.from('clouds')
.select('*', { head: true, count: 'exact' })
if (filters.status) query = query.eq('status', filters.status)
if (typeof filters.isHidden === 'boolean') query = query.eq('is_hidden', filters.isHidden)
if (filters.createdAfter) query = query.gte('created_at', filters.createdAfter)
const { count, error } = await query
if (error) throw error
return count || 0
}
async function fetchStats() {
const [
userCount,
imageCount,
todayUploads,
pendingCount,
approvedCount,
rejectedCount,
hiddenCount,
] = await Promise.all([
countProfiles(),
countClouds(),
countClouds({ createdAfter: todayIsoStart() }),
countClouds({ status: 'pending' }),
countClouds({ status: 'approved' }),
countClouds({ status: 'rejected' }),
countClouds({ isHidden: true }),
])
dashboardStats.value = {
users: userCount,
images: imageCount,
todayUploads,
pending: pendingCount,
approved: approvedCount,
rejected: rejectedCount,
hidden: hiddenCount,
}
}
async function fetchUsers() {
const { data, error } = await supabase
.from('profiles')
.select('id,username,avatar_url,role,is_disabled,created_at')
.order('created_at', { ascending: false })
.limit(100)
if (error) throw error
users.value = (data || []) as Profile[]
}
async function fetchImages() {
const { data, error } = await supabase
.from('clouds')
.select('id,user_id,cloud_type_id,custom_cloud_type,image_url,thumbnail_url,latitude,longitude,location_name,description,captured_at,created_at,status,is_hidden,cloud_types(name,rarity),profiles(username)')
.order('created_at', { ascending: false })
.limit(120)
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() {
loading.value = true
loadError.value = ''
try {
await Promise.all([
fetchStats(),
fetchUsers(),
fetchImages(),
])
} catch (error) {
loadError.value = error instanceof Error ? error.message : '管理后台加载失败'
} finally {
loading.value = false
}
}
function setSelectedReviewIds(ids: Iterable<string>) {
selectedReviewIds.value = new Set(ids)
}
function toggleReviewSelection(cloudId: string) {
const next = new Set(selectedReviewIds.value)
if (next.has(cloudId)) {
next.delete(cloudId)
} else {
next.add(cloudId)
}
setSelectedReviewIds(next)
}
function toggleAllPendingSelection() {
if (selectedReviewIds.value.size === pendingImages.value.length) {
setSelectedReviewIds([])
return
}
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))
if (selectedImage.value && idSet.has(selectedImage.value.id)) {
selectedImage.value = { ...selectedImage.value, ...patch }
}
}
async function updateCloudStatus(ids: string[], status: CloudStatus) {
if (!ids.length) return
actionLoading.value = true
loadError.value = ''
try {
let query = supabase
.from('clouds')
.update({ status })
.select('id')
query = ids.length === 1 ? query.eq('id', ids[0]) : query.in('id', ids)
const { data, error } = await query
if (error) throw error
if ((data || []).length !== ids.length) {
throw new Error('部分图片状态没有写入数据库,请检查管理员 UPDATE RLS policy。')
}
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) {
const text = getErrorMessage(error, '审核操作失败')
loadError.value = text
message.error(text)
} finally {
actionLoading.value = false
}
}
async function updateImageVisibility(ids: string[], isHidden: boolean) {
if (!ids.length) return
actionLoading.value = true
loadError.value = ''
try {
const { data, error } = await supabase
.from('clouds')
.update({ is_hidden: isHidden })
.in('id', ids)
.select('id')
if (error) throw error
if ((data || []).length !== ids.length) {
throw new Error('图片可见性没有写入数据库,请检查管理员 UPDATE RLS policy。')
}
patchImages(ids, { is_hidden: isHidden })
setSelectedImageIds([...selectedImageIds.value].filter(id => !ids.includes(id)))
await fetchStats()
message.success(isHidden ? `已隐藏 ${ids.length} 张图片` : `已公开 ${ids.length} 张图片`)
} catch (error) {
const text = getErrorMessage(error, '可见性更新失败')
loadError.value = text
message.error(text)
} finally {
actionLoading.value = false
}
}
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 {
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(clouds.length === 1 ? '图片已删除' : `已删除 ${clouds.length} 张图片`)
} catch (error) {
const text = getErrorMessage(error, '图片删除失败')
loadError.value = text
message.error(text)
} finally {
actionLoading.value = false
}
}
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('不能移除自己的管理员权限')
return
}
actionLoading.value = true
loadError.value = ''
try {
const { data, error } = await supabase
.from('profiles')
.update({ role })
.eq('id', user.id)
.select('id,role')
if (error) throw error
if (!data?.length) {
throw new Error('用户角色没有写入数据库,请检查管理员 UPDATE RLS policy。')
}
users.value = users.value.map(item => (item.id === user.id ? { ...item, role } : item))
message.success('用户角色已更新')
} catch (error) {
const text = getErrorMessage(error, '用户角色更新失败')
loadError.value = text
message.error(text)
} finally {
actionLoading.value = false
}
}
async function toggleUserDisabled(user: Profile) {
if (user.id === authStore.user?.id) {
message.warning('不能禁用当前登录的管理员账号')
return
}
actionLoading.value = true
loadError.value = ''
try {
const nextDisabled = !user.is_disabled
const { data, error } = await supabase
.from('profiles')
.update({ is_disabled: nextDisabled })
.eq('id', user.id)
.select('id,is_disabled')
if (error) throw error
if (!data?.length) {
throw new Error('用户状态没有写入数据库,请检查 profiles 表的 UPDATE RLS policy。')
}
users.value = users.value.map(item => (item.id === user.id ? { ...item, is_disabled: nextDisabled } : item))
message.success(nextDisabled ? '用户已禁用' : '用户已恢复')
} catch (error) {
const text = getErrorMessage(error, '用户状态更新失败')
loadError.value = text
message.error(text)
} finally {
actionLoading.value = false
}
}
onMounted(loadAdminData)
</script>
<template>
<div class="min-h-screen bg-[linear-gradient(180deg,#f8fafc_0%,#eef6ff_55%,#f8fafc_100%)]">
<section class="border-b border-slate-200 bg-white/82 backdrop-blur">
<div class="mx-auto max-w-7xl px-4 py-10">
<div class="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
<div>
<p class="text-sm font-semibold uppercase tracking-[0.24em] text-sky-700">Admin Console</p>
<h1 class="mt-3 text-4xl font-bold text-slate-950">管理后台</h1>
<p class="mt-4 max-w-2xl text-sm leading-7 text-slate-600">
集中处理社区云图审核图片可见性用户角色和运行数据所有写入都直接落到 Supabase
</p>
</div>
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--neutral" :loading="loading" @click="loadAdminData">
<template #icon>
<NIcon><Refresh /></NIcon>
</template>
刷新数据
</NButton>
</div>
</div>
</section>
<main class="mx-auto max-w-7xl px-4 py-8">
<NAlert
v-if="loadError"
class="mb-6"
type="error"
:show-icon="false"
:bordered="false"
title="后台操作失败"
>
{{ loadError }}
</NAlert>
<section v-if="loading" class="grid gap-4 md:grid-cols-4">
<div v-for="n in 8" :key="n" class="border border-slate-200 bg-white p-5">
<NSkeleton class="h-4 w-1/2" />
<NSkeleton class="mt-4 h-8 w-2/3" />
<NSkeleton class="mt-3 h-3 w-full" />
</div>
</section>
<template v-else>
<section class="grid gap-3 md:grid-cols-4">
<button
v-for="tab in tabs"
:key="tab.key"
type="button"
class="border px-5 py-4 text-left transition-colors"
:class="activeTab === tab.key ? 'border-sky-500 bg-sky-50 text-sky-900' : 'border-slate-200 bg-white text-slate-600 hover:border-slate-400'"
@click="activeTab = tab.key"
>
<span class="text-sm font-semibold">{{ tab.label }}</span>
<span v-if="tab.key === 'review'" class="ml-2 text-xs text-amber-600">{{ dashboardStats.pending }}</span>
</button>
</section>
<section v-if="activeTab === 'dashboard'" class="mt-6 space-y-6">
<div class="grid gap-4 md:grid-cols-4">
<div class="border border-slate-200 bg-white p-5">
<p class="text-sm text-slate-500">总用户数</p>
<p class="mt-3 text-3xl font-bold text-slate-950">{{ dashboardStats.users }}</p>
</div>
<div class="border border-slate-200 bg-white p-5">
<p class="text-sm text-slate-500">图片总数</p>
<p class="mt-3 text-3xl font-bold text-slate-950">{{ dashboardStats.images }}</p>
</div>
<div class="border border-slate-200 bg-white p-5">
<p class="text-sm text-slate-500">今日上传</p>
<p class="mt-3 text-3xl font-bold text-slate-950">{{ dashboardStats.todayUploads }}</p>
</div>
<div class="border border-amber-200 bg-amber-50 p-5">
<p class="text-sm text-amber-700">待审核</p>
<p class="mt-3 text-3xl font-bold text-amber-900">{{ dashboardStats.pending }}</p>
</div>
</div>
<div class="grid gap-6 lg:grid-cols-[1fr_1.2fr]">
<div class="border border-slate-200 bg-white p-6">
<h2 class="text-xl font-bold text-slate-950">审核状态</h2>
<div class="mt-5 grid gap-3">
<div class="flex items-center justify-between border border-slate-100 bg-slate-50 px-4 py-3">
<span class="text-sm text-slate-600">已通过</span>
<span class="font-semibold text-emerald-700">{{ dashboardStats.approved }}</span>
</div>
<div class="flex items-center justify-between border border-slate-100 bg-slate-50 px-4 py-3">
<span class="text-sm text-slate-600">已拒绝</span>
<span class="font-semibold text-rose-700">{{ dashboardStats.rejected }}</span>
</div>
<div class="flex items-center justify-between border border-slate-100 bg-slate-50 px-4 py-3">
<span class="text-sm text-slate-600">隐藏图片</span>
<span class="font-semibold text-slate-900">{{ dashboardStats.hidden }}</span>
</div>
</div>
</div>
<div class="border border-slate-200 bg-white p-6">
<h2 class="text-xl font-bold text-slate-950">云型分布</h2>
<div v-if="cloudTypeStats.length" class="mt-5 space-y-3">
<div v-for="item in cloudTypeStats" :key="item.name">
<div class="mb-1 flex items-center justify-between text-sm">
<span class="text-slate-600">{{ item.name }}</span>
<span class="font-medium text-slate-900">{{ item.count }}</span>
</div>
<div class="h-2 bg-slate-100">
<div
class="h-full bg-sky-500"
:style="{ width: `${Math.max(8, item.count / Math.max(1, dashboardStats.images) * 100)}%` }"
></div>
</div>
</div>
</div>
<NEmpty v-else class="py-6" description="暂无图片数据" />
</div>
</div>
</section>
<section v-else-if="activeTab === 'review'" class="mt-6">
<div class="mb-4 flex flex-col gap-3 border border-slate-200 bg-white p-4 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="text-xl font-bold text-slate-950">待审核队列</h2>
<p class="mt-1 text-sm text-slate-500"> {{ pendingImages.length }} 张待处理图片</p>
</div>
<div class="flex flex-wrap gap-2">
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--neutral" @click="toggleAllPendingSelection">
{{ selectedReviewCount === pendingImages.length && pendingImages.length ? '取消全选' : '全选待审核' }}
</NButton>
<NButton
secondary
strong
type="default"
class="oc-panel-button oc-panel-button--teal"
:disabled="!selectedReviewCount"
:loading="actionLoading"
@click="updateCloudStatus(Array.from(selectedReviewIds), 'approved')"
>
批量通过 {{ selectedReviewCount || '' }}
</NButton>
<NButton
secondary
strong
type="default"
class="oc-panel-button oc-panel-button--danger"
:disabled="!selectedReviewCount"
:loading="actionLoading"
@click="updateCloudStatus(Array.from(selectedReviewIds), 'rejected')"
>
批量拒绝 {{ selectedReviewCount || '' }}
</NButton>
</div>
</div>
<div v-if="pendingImages.length" class="grid gap-4 lg:grid-cols-2">
<article v-for="item in pendingImages" :key="item.id" class="grid gap-4 border border-slate-200 bg-white p-4 md:grid-cols-[180px_minmax(0,1fr)]">
<button type="button" class="overflow-hidden bg-slate-100" @click="selectedImage = item">
<img :src="item.thumbnail_url || item.image_url" :alt="item.cloudTypeName" class="h-44 w-full object-cover transition-transform hover:scale-105" />
</button>
<div class="min-w-0">
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-lg font-semibold text-slate-950">{{ item.cloudTypeName }}</p>
<p class="mt-1 text-sm text-slate-500">上传者{{ item.username }}</p>
</div>
<input
type="checkbox"
class="mt-1 h-5 w-5"
:checked="selectedReviewIds.has(item.id)"
@change="toggleReviewSelection(item.id)"
/>
</div>
<p class="mt-3 text-sm text-slate-600">{{ item.location_name || '未填写位置' }} · {{ formatDateTime(item.created_at) }}</p>
<p class="mt-2 line-clamp-2 text-sm leading-6 text-slate-500">{{ item.description || '没有图片说明。' }}</p>
<div class="mt-4 flex flex-wrap gap-2">
<NButton size="small" type="default" secondary strong class="oc-panel-button oc-panel-button--teal" :loading="actionLoading" @click="updateCloudStatus([item.id], 'approved')">
<template #icon><NIcon><Check /></NIcon></template>
通过
</NButton>
<NButton size="small" type="default" secondary strong class="oc-panel-button oc-panel-button--danger" :loading="actionLoading" @click="updateCloudStatus([item.id], 'rejected')">
<template #icon><NIcon><X /></NIcon></template>
拒绝
</NButton>
<NButton size="small" type="default" secondary strong class="oc-panel-button oc-panel-button--neutral" @click="selectedImage = item">查看大图</NButton>
</div>
</div>
</article>
</div>
<div v-else class="border border-dashed border-slate-300 bg-white p-10">
<NEmpty description="当前没有待审核图片" />
</div>
</section>
<section v-else-if="activeTab === 'users'" class="mt-6">
<div class="overflow-hidden border border-slate-200 bg-white">
<div class="grid grid-cols-[1.1fr_0.8fr_0.7fr_0.9fr_1.2fr] border-b border-slate-200 bg-slate-50 px-4 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-slate-500">
<span>用户</span>
<span>角色</span>
<span>状态</span>
<span>注册时间</span>
<span>操作</span>
</div>
<div v-for="user in users" :key="user.id" class="grid grid-cols-[1.1fr_0.8fr_0.7fr_0.9fr_1.2fr] items-center gap-3 border-b border-slate-100 px-4 py-4 last:border-b-0">
<div class="min-w-0">
<p class="truncate font-medium text-slate-950">{{ user.username }}</p>
<p class="mt-1 truncate text-xs text-slate-400">{{ user.id }}</p>
</div>
<NTag size="small" :bordered="false" :type="user.role === 'admin' ? 'success' : 'default'">
{{ user.role === 'admin' ? '管理员' : '用户' }}
</NTag>
<NTag size="small" :bordered="false" :type="user.is_disabled ? 'error' : 'success'">
{{ user.is_disabled ? '已禁用' : '正常' }}
</NTag>
<span class="text-sm text-slate-500">{{ formatDateTime(user.created_at) }}</span>
<div class="flex flex-wrap gap-2">
<NButton
size="small"
secondary
strong
type="default"
class="oc-panel-button oc-panel-button--amber"
:disabled="user.id === authStore.user?.id && user.role === 'admin'"
:loading="actionLoading"
@click="updateUserRole(user, user.role === 'admin' ? 'user' : 'admin')"
>
{{ user.role === 'admin' ? '设为用户' : '设为管理员' }}
</NButton>
<NButton
size="small"
type="default"
secondary
strong
:class="user.is_disabled ? 'oc-panel-button oc-panel-button--teal' : 'oc-panel-button oc-panel-button--danger'"
:disabled="user.id === authStore.user?.id"
:loading="actionLoading"
@click="toggleUserDisabled(user)"
>
{{ user.is_disabled ? '恢复' : '禁用' }}
</NButton>
</div>
</div>
</div>
</section>
<section v-else class="mt-6">
<div class="mb-4 flex flex-col gap-3 border border-slate-200 bg-white p-4 md:flex-row md:items-center md:justify-between">
<div>
<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"
>
<option value="all">全部状态</option>
<option value="pending">待审核</option>
<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">
<article v-for="item in filteredImages" :key="item.id" class="overflow-hidden border border-slate-200 bg-white">
<button type="button" class="block w-full overflow-hidden bg-slate-100" @click="selectedImage = item">
<img :src="item.thumbnail_url || item.image_url" :alt="item.cloudTypeName" class="h-52 w-full object-cover transition-transform hover:scale-105" />
</button>
<div class="p-4">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<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>
<NButton size="small" type="default" secondary strong class="oc-panel-button oc-panel-button--teal" :disabled="item.status === 'approved'" :loading="actionLoading" @click="updateCloudStatus([item.id], 'approved')">通过</NButton>
<NButton size="small" type="default" secondary strong class="oc-panel-button oc-panel-button--danger" :disabled="item.status === 'rejected'" :loading="actionLoading" @click="updateCloudStatus([item.id], 'rejected')">拒绝</NButton>
<NButton size="small" type="default" secondary strong class="oc-panel-button oc-panel-button--amber" :loading="actionLoading" @click="toggleImageVisibility(item)">
<template #icon>
<NIcon>
<Eye v-if="item.is_hidden" />
<EyeOff v-else />
</NIcon>
</template>
{{ item.is_hidden ? '公开' : '隐藏' }}
</NButton>
<NButton size="small" type="default" secondary strong class="oc-panel-button oc-panel-button--danger" :loading="actionLoading" @click="deleteImage(item)">
<template #icon><NIcon><Trash /></NIcon></template>
删除
</NButton>
</div>
</div>
</article>
</div>
<div v-else class="border border-dashed border-slate-300 bg-white p-10">
<NEmpty description="没有符合筛选条件的图片" />
</div>
</section>
</template>
</main>
<ImageDetailModal
v-if="selectedImage"
:open="!!selectedImage"
:image-url="selectedImage.image_url"
:thumbnail-url="selectedImage.thumbnail_url"
:image-alt="selectedImage.cloudTypeName"
:title="selectedImage.cloudTypeName"
:subtitle="`上传者:${selectedImage.username}`"
:badge-label="rarityMeta[selectedImage.cloudTypeRarity].label"
:badge-class="rarityMeta[selectedImage.cloudTypeRarity].chip"
@close="selectedImage = null"
>
<div class="grid gap-4 sm:grid-cols-2">
<div class="border border-slate-200 bg-slate-50 p-4">
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">上传时间</p>
<p class="mt-2 text-sm font-medium text-slate-900">{{ formatDateTime(selectedImage.created_at) }}</p>
</div>
<div class="border border-slate-200 bg-slate-50 p-4">
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">拍摄时间</p>
<p class="mt-2 text-sm font-medium text-slate-900">{{ formatDateTime(selectedImage.captured_at) }}</p>
</div>
<div class="border border-slate-200 bg-slate-50 p-4">
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">状态</p>
<p class="mt-2 text-sm font-medium text-slate-900">
{{ statusMeta[selectedImage.status].label }} / {{ selectedImage.is_hidden ? '隐藏' : '公开' }}
</p>
</div>
<div class="border border-slate-200 bg-slate-50 p-4">
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">模糊化经纬度</p>
<p class="mt-2 text-sm font-medium text-slate-900">
纬度 {{ formatCoordinate(selectedImage.latitude) }} / 经度 {{ formatCoordinate(selectedImage.longitude) }}
</p>
</div>
</div>
<div class="mt-5 border border-slate-200 bg-white p-5">
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">图片说明</p>
<p class="mt-3 text-sm leading-7 text-slate-700">
{{ selectedImage.description || '上传者没有留下额外说明。' }}
</p>
</div>
<MiniLocationMap
:latitude="selectedImage.latitude"
:longitude="selectedImage.longitude"
:location-name="selectedImage.location_name"
/>
</ImageDetailModal>
</div>
</template>