831 lines
34 KiB
Vue
831 lines
34 KiB
Vue
<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 { apiRequest } from '@/lib/api'
|
||
import { useAuthStore } from '@/stores/auth'
|
||
import type { AdminStats, AdminUserItem, ApiCloud, ApiPage } from '@/types/api'
|
||
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 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 toAdminCloud(row: ApiCloud): AdminCloud {
|
||
return {
|
||
id: row.id,
|
||
user_id: row.user_id,
|
||
cloud_type_id: row.cloud_type_id,
|
||
custom_cloud_type: row.custom_cloud_type,
|
||
image_url: row.image_url,
|
||
thumbnail_url: row.thumbnail_url,
|
||
latitude: row.latitude,
|
||
longitude: row.longitude,
|
||
location_name: row.location_name,
|
||
description: row.description,
|
||
captured_at: row.captured_at,
|
||
created_at: row.created_at,
|
||
status: row.status,
|
||
is_hidden: row.is_hidden,
|
||
cloudTypeName: row.cloud_type_name || row.custom_cloud_type || '未知云型',
|
||
cloudTypeRarity: row.cloud_type_rarity || 'common',
|
||
username: row.username || '匿名用户',
|
||
}
|
||
}
|
||
|
||
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) {
|
||
return error instanceof Error ? error.message : fallback
|
||
}
|
||
|
||
async function fetchStats() {
|
||
const stats = await apiRequest<AdminStats>('/admin/stats')
|
||
dashboardStats.value = {
|
||
users: stats.users,
|
||
images: stats.images,
|
||
todayUploads: stats.today_uploads,
|
||
pending: stats.pending,
|
||
approved: stats.approved,
|
||
rejected: stats.rejected,
|
||
hidden: stats.hidden,
|
||
}
|
||
}
|
||
|
||
async function fetchUsers() {
|
||
const result = await apiRequest<ApiPage<AdminUserItem>>('/admin/users?page=1&page_size=100')
|
||
users.value = result.items.map(item => item.profile)
|
||
}
|
||
|
||
async function fetchImages() {
|
||
const result = await apiRequest<ApiPage<ApiCloud>>('/admin/clouds?page=1&page_size=120')
|
||
images.value = result.items.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 }
|
||
}
|
||
}
|
||
|
||
function chunkIds(ids: string[]) {
|
||
return Array.from({ length: Math.ceil(ids.length / 100) }, (_, index) =>
|
||
ids.slice(index * 100, (index + 1) * 100),
|
||
)
|
||
}
|
||
|
||
async function updateCloudStatus(ids: string[], status: CloudStatus) {
|
||
if (!ids.length) return
|
||
|
||
actionLoading.value = true
|
||
loadError.value = ''
|
||
|
||
try {
|
||
for (const chunk of chunkIds(ids)) {
|
||
await apiRequest('/admin/clouds/status', {
|
||
method: 'PATCH',
|
||
body: { ids: chunk, status },
|
||
})
|
||
}
|
||
|
||
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 {
|
||
for (const chunk of chunkIds(ids)) {
|
||
await apiRequest('/admin/clouds/visibility', {
|
||
method: 'PATCH',
|
||
body: { ids: chunk, is_hidden: isHidden },
|
||
})
|
||
}
|
||
|
||
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)
|
||
for (const chunk of chunkIds(idsToDelete)) {
|
||
await apiRequest('/admin/clouds/batch-delete', {
|
||
method: 'POST',
|
||
body: { ids: chunk },
|
||
})
|
||
}
|
||
|
||
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 result = await apiRequest<AdminUserItem>(`/admin/users/${user.id}`, {
|
||
method: 'PATCH',
|
||
body: { role },
|
||
})
|
||
users.value = users.value.map(item => item.id === user.id ? result.profile : 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 result = await apiRequest<AdminUserItem>(`/admin/users/${user.id}`, {
|
||
method: 'PATCH',
|
||
body: { is_disabled: nextDisabled },
|
||
})
|
||
users.value = users.value.map(item => item.id === user.id ? result.profile : 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">
|
||
集中处理社区云图审核、图片可见性、用户角色和运行数据。所有操作都由 FastAPI 服务统一处理。
|
||
</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>
|