Files
opencloud/src/views/admin/AdminView.vue
T
2026-07-28 21:31:38 +08:00

1344 lines
58 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, type Component } from 'vue'
import { NAlert, NButton, NEmpty, NIcon, NPopover, NSkeleton, NTag, useMessage } from 'naive-ui'
import {
Check,
ChevronUp,
ClipboardCheck,
Dashboard,
Eye,
EyeOff,
Home,
LayoutSidebarLeftCollapse,
LayoutSidebarLeftExpand,
Logout,
Photo,
Refresh,
Settings,
Shield,
Trash,
UserCircle,
Users,
X,
} from '@vicons/tabler'
import { useRouter } from 'vue-router'
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 router = useRouter()
const activeTab = ref<AdminTab>('dashboard')
const sidebarCollapsed = ref(false)
const accountMenuOpen = ref(false)
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 activeReviewId = ref<string | null>(null)
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: '数据看板', description: '运行概览与内容分布', icon: Dashboard },
{ key: 'review', label: '内容审核', description: '处理待审核上传', icon: ClipboardCheck },
{ key: 'users', label: '用户管理', description: '角色与账号状态', icon: Users },
{ key: 'images', label: '图片管理', description: '内容状态与可见性', icon: Photo },
] satisfies Array<{ key: AdminTab; label: string; description: string; icon: Component }>
const pendingImages = computed(() => images.value.filter(item => item.status === 'pending'))
const activeReviewImage = computed(() =>
pendingImages.value.find(item => item.id === activeReviewId.value) ?? pendingImages.value[0] ?? null,
)
const activeReviewPosition = computed(() => {
if (!activeReviewImage.value) return 0
return pendingImages.value.findIndex(item => item.id === activeReviewImage.value?.id) + 1
})
const activeTabMeta = computed(() => tabs.find(tab => tab.key === activeTab.value) ?? tabs[0])
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)
const displayUsername = computed(() => authStore.profile?.username || authStore.user?.email || '管理员')
const userEmail = computed(() => authStore.user?.email || '未绑定邮箱')
const displayInitial = computed(() => displayUsername.value.slice(0, 1).toUpperCase())
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 reviewedCount = computed(() => dashboardStats.value.approved + dashboardStats.value.rejected)
const approvalRate = computed(() => getPercentage(dashboardStats.value.approved, reviewedCount.value))
const publicImageCount = computed(() => Math.max(0, dashboardStats.value.images - dashboardStats.value.hidden))
const recentImages = computed(() => images.value.slice(0, 5))
const cloudTypeMaxCount = computed(() => Math.max(1, ...cloudTypeStats.value.map(item => item.count)))
const reviewStatusStats = computed(() => [
{
key: 'approved',
label: '已通过',
count: dashboardStats.value.approved,
bar: 'bg-emerald-500',
text: 'text-emerald-700',
},
{
key: 'pending',
label: '待审核',
count: dashboardStats.value.pending,
bar: 'bg-amber-400',
text: 'text-amber-700',
},
{
key: 'rejected',
label: '已拒绝',
count: dashboardStats.value.rejected,
bar: 'bg-rose-400',
text: 'text-rose-700',
},
])
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 getPercentage(value: number, total: number) {
if (!total) return 0
return Math.round(value / total * 100)
}
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
}
}
function toggleSidebar() {
accountMenuOpen.value = false
sidebarCollapsed.value = !sidebarCollapsed.value
}
async function handleLogout() {
accountMenuOpen.value = false
try {
await authStore.logout()
} finally {
await router.push('/')
}
}
onMounted(loadAdminData)
</script>
<template>
<div class="min-h-screen bg-[linear-gradient(180deg,#f8fafc_0%,#eef6ff_55%,#f8fafc_100%)]">
<div
class="min-h-screen w-full transition-[grid-template-columns] duration-200 ease-out lg:grid"
:class="sidebarCollapsed
? 'lg:grid-cols-[88px_minmax(0,1fr)]'
: 'lg:grid-cols-[280px_minmax(0,1fr)]'"
>
<aside
class="relative border-b border-slate-200 bg-white/90 px-4 py-4 backdrop-blur lg:sticky lg:top-0 lg:flex lg:h-screen lg:flex-col lg:border-b-0 lg:border-r lg:px-5 lg:py-5"
>
<div class="flex items-center">
<div class="flex min-w-0 items-center gap-3">
<span class="flex h-10 w-10 shrink-0 items-center justify-center border border-sky-200 bg-sky-50 text-sky-800 shadow-[3px_3px_0_0_rgba(14,165,233,0.12)]">
<NIcon size="22"><Shield /></NIcon>
</span>
<span
class="min-w-0 overflow-hidden whitespace-nowrap transition-[max-width,opacity] duration-200 ease-out"
:class="sidebarCollapsed
? 'lg:max-w-0 lg:opacity-0'
: 'lg:max-w-[168px] lg:opacity-100'"
>
<span class="block whitespace-nowrap text-xs font-semibold uppercase tracking-[0.2em] text-sky-700">Admin Console</span>
<span class="mt-1 block whitespace-nowrap text-lg font-bold text-slate-950">管理工作台</span>
</span>
</div>
</div>
<button
type="button"
class="oc-icon-button oc-icon-button--small hidden lg:absolute lg:-right-4 lg:top-6 lg:z-20 lg:flex"
:aria-label="sidebarCollapsed ? '展开侧边栏' : '折叠侧边栏'"
:aria-expanded="!sidebarCollapsed"
:title="sidebarCollapsed ? '展开侧边栏' : '折叠侧边栏'"
@click="toggleSidebar"
>
<NIcon size="18">
<LayoutSidebarLeftExpand v-if="sidebarCollapsed" />
<LayoutSidebarLeftCollapse v-else />
</NIcon>
</button>
<nav aria-label="后台管理分区" class="mt-4 flex gap-2 overflow-x-auto pb-1 lg:mt-7 lg:block lg:space-y-2 lg:overflow-visible lg:pb-0">
<button
v-for="tab in tabs"
:key="tab.key"
type="button"
class="group relative flex min-w-[150px] items-center gap-3 overflow-hidden border px-3 py-3 text-left transition-[background-color,border-color,color,box-shadow] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-600 focus-visible:ring-offset-2 lg:w-full lg:min-w-0"
:class="activeTab === tab.key
? 'border-teal-200 bg-teal-50 text-teal-900 shadow-[3px_3px_0_0_rgba(52,116,93,0.16)]'
: 'border-transparent bg-white text-slate-600 hover:border-slate-200 hover:bg-slate-50 hover:text-slate-950'"
:aria-current="activeTab === tab.key ? 'page' : undefined"
:aria-label="tab.label"
:title="sidebarCollapsed ? tab.label : undefined"
@click="activeTab = tab.key"
>
<NIcon size="22" class="shrink-0">
<component :is="tab.icon" />
</NIcon>
<span
class="min-w-0 flex-1 overflow-hidden whitespace-nowrap transition-[max-width,opacity] duration-200 ease-out"
:class="sidebarCollapsed
? 'lg:max-w-0 lg:opacity-0'
: 'lg:max-w-[190px] lg:opacity-100'"
>
<span class="block whitespace-nowrap text-sm font-semibold">{{ tab.label }}</span>
<span class="mt-0.5 hidden truncate text-xs text-slate-500 lg:block">{{ tab.description }}</span>
</span>
<span
v-if="tab.key === 'review' && dashboardStats.pending"
class="inline-flex min-w-6 justify-center border border-amber-200 bg-amber-100 px-1.5 py-0.5 text-xs font-semibold text-amber-800"
:class="sidebarCollapsed ? 'lg:absolute lg:right-0 lg:top-0 lg:min-w-4 lg:px-1 lg:text-[10px]' : ''"
>
{{ dashboardStats.pending }}
</span>
</button>
</nav>
<div class="mt-4 flex gap-2 border-t border-slate-100 pt-3 lg:mt-auto lg:block lg:space-y-2 lg:border-t-0 lg:pt-0">
<NPopover
v-model:show="accountMenuOpen"
trigger="click"
placement="top-start"
:show-arrow="false"
raw
>
<template #trigger>
<button
type="button"
class="flex h-14 min-w-0 flex-1 items-center gap-3 overflow-hidden border px-3 py-2 text-left transition-[background-color,border-color,color,box-shadow] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-600 lg:w-full"
:class="accountMenuOpen
? 'border-teal-200 bg-teal-50 text-teal-800 shadow-[3px_3px_0_0_rgba(52,116,93,0.16)]'
: 'border-slate-200 bg-slate-50 text-slate-700 hover:border-teal-200 hover:bg-teal-50 hover:text-teal-800'"
aria-label="打开管理员账户菜单"
aria-haspopup="menu"
:aria-expanded="accountMenuOpen"
:title="sidebarCollapsed ? displayUsername : undefined"
>
<NIcon size="22" class="shrink-0"><UserCircle /></NIcon>
<span
class="min-w-0 flex-1 overflow-hidden whitespace-nowrap transition-[max-width,opacity] duration-200 ease-out"
:class="sidebarCollapsed
? 'lg:max-w-0 lg:opacity-0'
: 'lg:max-w-[145px] lg:opacity-100'"
>
<span class="block truncate text-sm font-semibold text-slate-900">{{ displayUsername }}</span>
<span class="mt-0.5 block text-xs text-slate-500">管理员账户</span>
</span>
<span
class="overflow-hidden transition-[max-width,opacity] duration-200 ease-out"
:class="sidebarCollapsed
? 'lg:max-w-0 lg:opacity-0'
: 'lg:max-w-4 lg:opacity-100'"
>
<NIcon size="16" class="shrink-0 transition-transform" :class="accountMenuOpen ? 'rotate-180' : ''">
<ChevronUp />
</NIcon>
</span>
</button>
</template>
<div
class="w-64 border border-slate-200 bg-white shadow-[8px_8px_0_0_rgba(15,23,42,0.08)]"
role="menu"
aria-label="管理员账户"
>
<div class="border-b border-slate-200 bg-[linear-gradient(180deg,#f2faf6_0%,#ffffff_100%)] px-4 py-4">
<div class="flex items-center gap-3">
<div class="flex h-11 w-11 shrink-0 items-center justify-center border border-teal-200 bg-[linear-gradient(135deg,#eef9f3_0%,#d9efe3_100%)] text-lg font-bold text-teal-800">
{{ displayInitial }}
</div>
<div class="min-w-0">
<p class="truncate text-sm font-semibold text-slate-950">@{{ displayUsername }}</p>
<p class="mt-1 truncate text-xs text-slate-500">{{ userEmail }}</p>
</div>
</div>
</div>
<div class="py-2">
<RouterLink
to="/profile"
class="flex items-center gap-3 px-4 py-2.5 text-sm font-medium text-slate-700 transition-colors hover:bg-teal-50 hover:text-teal-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-teal-600"
role="menuitem"
@click="accountMenuOpen = false"
>
<NIcon size="18"><UserCircle /></NIcon>
<span>个人中心</span>
</RouterLink>
<RouterLink
to="/profile/settings"
class="flex items-center gap-3 px-4 py-2.5 text-sm font-medium text-slate-700 transition-colors hover:bg-teal-50 hover:text-teal-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-teal-600"
role="menuitem"
@click="accountMenuOpen = false"
>
<NIcon size="18"><Settings /></NIcon>
<span>账号设置</span>
</RouterLink>
</div>
<div class="border-t border-slate-200 p-2">
<button
type="button"
class="oc-menu-item oc-menu-item--danger px-2"
role="menuitem"
@click="handleLogout"
>
<NIcon size="18"><Logout /></NIcon>
<span>登出</span>
</button>
</div>
</div>
</NPopover>
<RouterLink
to="/"
class="flex h-[46px] shrink-0 items-center justify-start gap-3 overflow-hidden border border-slate-200 bg-white px-3 text-sm font-semibold text-slate-700 shadow-[3px_3px_0_0_rgba(15,23,42,0.06)] transition-colors hover:border-sky-200 hover:bg-sky-50 hover:text-sky-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-600 lg:w-full"
title="返回主页"
>
<NIcon size="20" class="shrink-0"><Home /></NIcon>
<span
class="overflow-hidden whitespace-nowrap transition-[max-width,opacity] duration-200 ease-out"
:class="sidebarCollapsed
? 'lg:max-w-0 lg:opacity-0'
: 'lg:max-w-[100px] lg:opacity-100'"
>
返回主页
</span>
</RouterLink>
</div>
</aside>
<main class="min-w-0 px-4 py-5 sm:px-6 lg:px-8 lg:py-6">
<div class="mb-5 flex min-h-10 items-center justify-between gap-3 border-b border-slate-200 pb-4">
<div class="flex min-w-0 items-center gap-2 text-slate-700">
<NIcon size="18" class="shrink-0">
<component :is="activeTabMeta.icon" />
</NIcon>
<span class="truncate text-sm font-semibold">{{ activeTabMeta.label }}</span>
</div>
<NButton
secondary
strong
type="default"
size="small"
class="oc-panel-button oc-panel-button--neutral"
:loading="loading"
@click="loadAdminData"
>
<template #icon>
<NIcon><Refresh /></NIcon>
</template>
刷新数据
</NButton>
</div>
<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-2 xl:grid-cols-4">
<div v-for="n in 8" :key="n" class="oc-panel-card 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 v-if="activeTab === 'dashboard'" class="space-y-5">
<div class="grid gap-4 xl:grid-cols-[1.35fr_0.65fr]">
<div
class="flex flex-col justify-between gap-5 border p-6 sm:flex-row sm:items-center"
:class="dashboardStats.pending
? 'border-amber-200 bg-[linear-gradient(135deg,#fffbeb_0%,#fff_52%,#f2faf6_100%)] shadow-[5px_5px_0_0_rgba(245,158,11,0.12)]'
: 'border-teal-200 bg-[linear-gradient(135deg,#f2faf6_0%,#fff_55%,#eff6ff_100%)] shadow-[5px_5px_0_0_rgba(52,116,93,0.16)]'"
>
<div class="flex items-center gap-5">
<div
class="flex h-20 w-20 shrink-0 items-center justify-center border font-mono text-3xl font-bold"
:class="dashboardStats.pending ? 'border-amber-300 bg-amber-100 text-amber-900' : 'border-teal-200 bg-teal-100 text-teal-900'"
>
{{ dashboardStats.pending }}
</div>
<div>
<p class="text-xs font-semibold uppercase tracking-[0.2em]" :class="dashboardStats.pending ? 'text-amber-700' : 'text-teal-700'">
{{ dashboardStats.pending ? 'Priority Queue' : 'Queue Cleared' }}
</p>
<h3 class="mt-2 text-xl font-bold text-slate-950">
{{ dashboardStats.pending ? '待审核内容需要处理' : '审核队列已经清空' }}
</h3>
<p class="mt-2 text-sm leading-6 text-slate-600">
{{ dashboardStats.pending
? `当前占全部内容的 ${getPercentage(dashboardStats.pending, dashboardStats.images)}%,建议优先核对图片与拍摄信息。`
: '当前没有待处理上传,可以继续检查隐藏内容与近期提交。' }}
</p>
</div>
</div>
<NButton
v-if="dashboardStats.pending > 0"
type="default"
secondary
strong
class="oc-panel-button oc-panel-button--amber shrink-0"
@click="activeTab = 'review'"
>
进入审核队列
</NButton>
</div>
<div class="oc-panel-card grid grid-cols-2 border border-slate-200 bg-white">
<div class="border-b border-r border-slate-100 p-4">
<p class="text-xs font-medium text-slate-500">今日上传</p>
<p class="mt-2 text-2xl font-bold text-sky-800">{{ dashboardStats.todayUploads }}</p>
</div>
<div class="border-b border-slate-100 p-4">
<p class="text-xs font-medium text-slate-500">审核通过率</p>
<p class="mt-2 text-2xl font-bold text-teal-800">{{ approvalRate }}%</p>
</div>
<div class="border-r border-slate-100 p-4">
<p class="text-xs font-medium text-slate-500">公开图片</p>
<p class="mt-2 text-2xl font-bold text-slate-950">{{ publicImageCount }}</p>
</div>
<div class="p-4">
<p class="text-xs font-medium text-slate-500">社区用户</p>
<p class="mt-2 text-2xl font-bold text-slate-950">{{ dashboardStats.users }}</p>
</div>
</div>
</div>
<div class="grid gap-5 lg:grid-cols-[1.05fr_0.95fr]">
<div class="oc-panel-card border border-slate-200 bg-white p-5 sm:p-6">
<div class="flex items-start justify-between gap-4">
<div>
<p class="oc-section-label">Review Health</p>
<h3 class="mt-2 text-xl font-bold text-slate-950">审核健康度</h3>
</div>
<div class="text-right">
<p class="text-3xl font-bold text-teal-800">{{ approvalRate }}%</p>
<p class="mt-1 text-xs text-slate-500">{{ reviewedCount }} 张已完成审核</p>
</div>
</div>
<div class="mt-6 flex h-3 overflow-hidden bg-slate-100" role="img" aria-label="审核状态分布">
<div
v-for="item in reviewStatusStats"
:key="item.key"
:class="item.bar"
:style="{ width: `${getPercentage(item.count, dashboardStats.images)}%` }"
></div>
</div>
<div class="mt-5 grid gap-3 sm:grid-cols-3">
<div v-for="item in reviewStatusStats" :key="item.key" class="border border-slate-100 bg-slate-50 p-3">
<div class="flex items-center gap-2">
<span class="h-2 w-2" :class="item.bar"></span>
<span class="text-xs font-medium text-slate-500">{{ item.label }}</span>
</div>
<div class="mt-2 flex items-end justify-between gap-2">
<span class="text-xl font-bold" :class="item.text">{{ item.count }}</span>
<span class="pb-0.5 text-xs text-slate-400">{{ getPercentage(item.count, dashboardStats.images) }}%</span>
</div>
</div>
</div>
<div class="mt-5 flex items-center justify-between border-t border-slate-100 pt-4 text-sm">
<span class="text-slate-500">隐藏内容(与审核状态独立)</span>
<span class="font-semibold text-slate-800">{{ dashboardStats.hidden }} 张</span>
</div>
</div>
<div class="oc-panel-card border border-slate-200 bg-white p-5 sm:p-6">
<div>
<p class="oc-section-label">Cloud Type Mix</p>
<h3 class="mt-2 text-xl font-bold text-slate-950">云型分布</h3>
<p class="mt-2 text-xs text-slate-500">基于最近加载的 {{ images.length }} 张内容,按最高频云型对比。</p>
</div>
<div v-if="cloudTypeStats.length" class="mt-5 space-y-3.5">
<div v-for="item in cloudTypeStats" :key="item.name">
<div class="mb-1.5 flex items-center justify-between gap-3 text-sm">
<span class="truncate text-slate-600">{{ item.name }}</span>
<span class="shrink-0 font-mono text-xs font-medium text-slate-700">
{{ item.count }} / {{ getPercentage(item.count, images.length) }}%
</span>
</div>
<div class="h-2 bg-slate-100">
<div
class="h-full bg-sky-500"
:style="{ width: `${item.count / cloudTypeMaxCount * 100}%` }"
></div>
</div>
</div>
</div>
<NEmpty v-else class="py-6" description="暂无图片数据" />
</div>
</div>
<div class="oc-panel-card border border-slate-200 bg-white">
<div class="flex items-end justify-between gap-4 border-b border-slate-200 px-5 py-4">
<div>
<p class="oc-section-label">Latest Activity</p>
<h3 class="mt-2 text-xl font-bold text-slate-950">最近上传</h3>
</div>
<NButton
size="small"
type="default"
secondary
strong
class="oc-panel-button oc-panel-button--neutral"
@click="activeTab = 'images'"
>
查看全部图片
</NButton>
</div>
<div v-if="recentImages.length" class="divide-y divide-slate-100">
<button
v-for="item in recentImages"
:key="item.id"
type="button"
class="oc-interactive-row grid w-full grid-cols-[52px_minmax(0,1fr)_auto] items-center gap-3 px-5 py-3 text-left sm:grid-cols-[52px_minmax(0,1fr)_minmax(120px,0.5fr)_auto]"
@click="selectedImage = item"
>
<img :src="item.thumbnail_url || item.image_url" :alt="item.cloudTypeName" class="h-11 w-13 object-cover" />
<span class="min-w-0">
<span class="block truncate text-sm font-semibold text-slate-900">{{ item.cloudTypeName }}</span>
<span class="mt-0.5 block truncate text-xs text-slate-500">{{ item.username }} · {{ item.location_name || '未填写位置' }}</span>
</span>
<span class="hidden truncate text-xs text-slate-500 sm:block">{{ formatDateTime(item.created_at) }}</span>
<span class="border px-2 py-1 text-xs font-medium" :class="statusMeta[item.status].chip">
{{ statusMeta[item.status].label }}
</span>
</button>
</div>
<NEmpty v-else class="py-8" description="暂无上传记录" />
</div>
</section>
<section v-else-if="activeTab === 'review'">
<div class="oc-panel-card 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="oc-field-help">共 {{ pendingImages.length }} 张待处理图片。</p>
</div>
<div class="flex flex-wrap gap-2">
<NButton
v-if="pendingImages.length > 0"
secondary
strong
type="default"
class="oc-panel-button oc-panel-button--neutral"
@click="toggleAllPendingSelection"
>
{{ selectedReviewCount === pendingImages.length && pendingImages.length ? '取消全选' : '全选待审核' }}
</NButton>
<template v-if="selectedReviewCount">
<NButton
secondary
strong
type="default"
class="oc-panel-button oc-panel-button--teal"
:loading="actionLoading"
@click="updateCloudStatus(Array.from(selectedReviewIds), 'approved')"
>
批量通过 {{ selectedReviewCount }}
</NButton>
<NButton
secondary
strong
type="default"
class="oc-panel-button oc-panel-button--danger"
:loading="actionLoading"
@click="updateCloudStatus(Array.from(selectedReviewIds), 'rejected')"
>
批量拒绝 {{ selectedReviewCount }}
</NButton>
</template>
</div>
</div>
<div v-if="activeReviewImage" class="grid min-w-0 gap-4 xl:grid-cols-[220px_minmax(0,1fr)]">
<aside class="oc-panel-card min-w-0 self-start border border-slate-200 bg-white xl:sticky xl:top-20 xl:max-h-[calc(100vh-6rem)]">
<div class="flex items-center justify-between border-b border-slate-200 px-4 py-3">
<div>
<p class="text-sm font-semibold text-slate-900">审核队列</p>
<p class="mt-0.5 text-xs text-slate-500">{{ activeReviewPosition }} / {{ pendingImages.length }}</p>
</div>
<span class="border border-amber-200 bg-amber-100 px-2 py-1 text-xs font-semibold text-amber-800">
{{ pendingImages.length }}
</span>
</div>
<div class="flex gap-2 overflow-x-auto p-2 xl:max-h-[calc(100vh-11rem)] xl:block xl:space-y-2 xl:overflow-y-auto">
<div
v-for="(item, index) in pendingImages"
:key="item.id"
class="flex min-w-[210px] items-center gap-2 border p-2 transition-colors xl:min-w-0"
:class="activeReviewImage.id === item.id
? 'border-teal-300 bg-teal-50'
: 'border-transparent bg-white hover:border-slate-200 hover:bg-slate-50'"
>
<button
type="button"
class="oc-interactive-row flex min-w-0 flex-1 items-center gap-3 text-left"
:aria-pressed="activeReviewImage.id === item.id"
@click="activeReviewId = item.id"
>
<span class="relative shrink-0">
<img :src="item.thumbnail_url || item.image_url" :alt="item.cloudTypeName" class="h-12 w-14 object-cover" />
<span class="absolute left-0 top-0 bg-slate-950/80 px-1 font-mono text-[10px] text-white">{{ index + 1 }}</span>
</span>
<span class="min-w-0">
<span class="block truncate text-sm font-semibold text-slate-900">{{ item.cloudTypeName }}</span>
<span class="mt-1 block truncate text-xs text-slate-500">{{ item.username }}</span>
</span>
</button>
<input
type="checkbox"
class="h-5 w-5 shrink-0"
:aria-label="`选择 ${item.cloudTypeName}`"
:checked="selectedReviewIds.has(item.id)"
@change="toggleReviewSelection(item.id)"
/>
</div>
</div>
</aside>
<div class="grid min-w-0 gap-4 lg:grid-cols-[minmax(0,1fr)_300px]">
<div class="min-w-0 space-y-4">
<div class="oc-panel-card border border-slate-200 bg-white">
<div class="flex items-center justify-between gap-4 border-b border-slate-200 px-4 py-3">
<div class="min-w-0">
<p class="truncate text-sm font-semibold text-slate-900">{{ activeReviewImage.cloudTypeName }}</p>
<p class="mt-0.5 truncate text-xs text-slate-500">内容编号 {{ activeReviewImage.id }}</p>
</div>
<span class="shrink-0 whitespace-nowrap border px-2 py-1 text-xs font-medium" :class="rarityMeta[activeReviewImage.cloudTypeRarity].chip">
{{ rarityMeta[activeReviewImage.cloudTypeRarity].label }}
</span>
</div>
<button
type="button"
class="group flex min-h-[360px] w-full items-center justify-center overflow-hidden bg-slate-950 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-sky-500 sm:min-h-[480px]"
aria-label="打开图片大图"
@click="selectedImage = activeReviewImage"
>
<img
:src="activeReviewImage.image_url"
:alt="activeReviewImage.cloudTypeName"
class="max-h-[620px] w-full object-contain transition-transform duration-300 group-hover:scale-[1.01]"
/>
</button>
<div class="flex items-center justify-between gap-4 border-t border-slate-200 bg-slate-50 px-4 py-3">
<p class="text-xs text-slate-500">点击图片可查看原图与地图</p>
<NButton size="small" type="default" secondary strong class="oc-panel-button oc-panel-button--neutral" @click="selectedImage = activeReviewImage">
查看完整详情
</NButton>
</div>
</div>
<div class="oc-panel-card border border-slate-200 bg-white p-5">
<p class="oc-section-label">Uploader Notes</p>
<h3 class="mt-2 text-lg font-bold text-slate-950">上传说明</h3>
<p class="mt-3 text-sm leading-7 text-slate-700">
{{ activeReviewImage.description || '上传者没有填写图片说明。' }}
</p>
</div>
</div>
<aside class="oc-panel-card self-start border border-slate-200 bg-white lg:sticky lg:top-20">
<div class="border-b border-slate-200 bg-[linear-gradient(180deg,#fffbeb_0%,#fff_100%)] p-5">
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-amber-700">Review Decision</p>
<h3 class="mt-2 text-xl font-bold text-slate-950">审核资料</h3>
<p class="mt-2 text-xs leading-5 text-slate-500">核对图片内容、云型标注、时间与位置后作出决定。</p>
</div>
<dl class="divide-y divide-slate-100 px-5">
<div class="py-3">
<dt class="text-xs font-medium text-slate-400">上传者</dt>
<dd class="mt-1 text-sm font-semibold text-slate-900">@{{ activeReviewImage.username }}</dd>
</div>
<div class="grid grid-cols-2 gap-3 py-3">
<div>
<dt class="text-xs font-medium text-slate-400">云型标注</dt>
<dd class="mt-1 text-sm font-semibold text-slate-900">{{ activeReviewImage.cloudTypeName }}</dd>
</div>
<div>
<dt class="text-xs font-medium text-slate-400">可见性</dt>
<dd class="mt-1 text-sm font-semibold text-slate-900">{{ activeReviewImage.is_hidden ? '隐藏' : '公开' }}</dd>
</div>
</div>
<div class="py-3">
<dt class="text-xs font-medium text-slate-400">拍摄时间</dt>
<dd class="mt-1 text-sm font-medium text-slate-800">{{ formatDateTime(activeReviewImage.captured_at) }}</dd>
</div>
<div class="py-3">
<dt class="text-xs font-medium text-slate-400">上传时间</dt>
<dd class="mt-1 text-sm font-medium text-slate-800">{{ formatDateTime(activeReviewImage.created_at) }}</dd>
</div>
<div class="py-3">
<dt class="text-xs font-medium text-slate-400">位置名称</dt>
<dd class="mt-1 text-sm font-medium text-slate-800">{{ activeReviewImage.location_name || '未填写位置名称' }}</dd>
</div>
<div class="py-3">
<dt class="text-xs font-medium text-slate-400">模糊化坐标</dt>
<dd class="mt-1 font-mono text-xs leading-5 text-slate-700">
LAT {{ formatCoordinate(activeReviewImage.latitude) }}<br />
LNG {{ formatCoordinate(activeReviewImage.longitude) }}
</dd>
</div>
</dl>
<div class="space-y-2 border-t border-slate-200 bg-slate-50 p-4">
<NButton
block
size="large"
type="default"
secondary
strong
class="oc-panel-button oc-panel-button--teal"
:loading="actionLoading"
@click="updateCloudStatus([activeReviewImage.id], 'approved')"
>
<template #icon><NIcon><Check /></NIcon></template>
通过并处理下一张
</NButton>
<NButton
block
size="large"
type="default"
secondary
strong
class="oc-panel-button oc-panel-button--danger"
:loading="actionLoading"
@click="updateCloudStatus([activeReviewImage.id], 'rejected')"
>
<template #icon><NIcon><X /></NIcon></template>
拒绝并处理下一张
</NButton>
<label class="mt-3 flex cursor-pointer items-center gap-3 border border-slate-200 bg-white px-3 py-2.5 text-sm text-slate-600">
<input
type="checkbox"
class="h-5 w-5"
:checked="selectedReviewIds.has(activeReviewImage.id)"
@change="toggleReviewSelection(activeReviewImage.id)"
/>
加入批量选择
</label>
</div>
</aside>
</div>
</div>
<div v-else class="oc-panel-card border border-dashed border-slate-300 bg-white p-10">
<NEmpty description="当前没有待审核图片" />
</div>
</section>
<section v-else-if="activeTab === 'users'">
<div class="oc-panel-card 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>
<div class="oc-panel-card 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="oc-field-help">最近 {{ 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
v-if="filteredImages.length > 0"
secondary
strong
type="default"
class="oc-panel-button oc-panel-button--neutral"
@click="toggleAllFilteredImageSelection"
>
{{ selectedManagedCount === filteredImages.length && filteredImages.length ? '取消全选' : '全选当前列表' }}
</NButton>
<template v-if="selectedManagedCount">
<NButton
secondary
strong
type="default"
class="oc-panel-button oc-panel-button--teal"
:loading="actionLoading"
@click="updateCloudStatus(Array.from(selectedImageIds), 'approved')"
>
批量通过 {{ selectedManagedCount }}
</NButton>
<NButton
secondary
strong
type="default"
class="oc-panel-button oc-panel-button--danger"
:loading="actionLoading"
@click="updateCloudStatus(Array.from(selectedImageIds), 'rejected')"
>
批量拒绝 {{ selectedManagedCount }}
</NButton>
<NButton
secondary
strong
type="default"
class="oc-panel-button oc-panel-button--amber"
:loading="actionLoading"
@click="updateImageVisibility(Array.from(selectedImageIds), true)"
>
批量隐藏 {{ selectedManagedCount }}
</NButton>
<NButton
secondary
strong
type="default"
class="oc-panel-button oc-panel-button--teal"
:loading="actionLoading"
@click="updateImageVisibility(Array.from(selectedImageIds), false)"
>
批量公开 {{ selectedManagedCount }}
</NButton>
<NButton
secondary
strong
type="default"
class="oc-panel-button oc-panel-button--danger"
:loading="actionLoading"
@click="deleteImages(filteredImages.filter(item => selectedImageIds.has(item.id)))"
>
批量删除 {{ selectedManagedCount }}
</NButton>
</template>
</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="oc-panel-card overflow-hidden border border-slate-200 bg-white">
<button type="button" class="oc-interactive-card 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="oc-panel-card border border-dashed border-slate-300 bg-white p-10">
<NEmpty description="没有符合筛选条件的图片" />
</div>
</section>
</template>
</main>
</div>
<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="oc-inset-panel">
<p class="oc-section-label">上传时间</p>
<p class="mt-2 text-sm font-medium text-slate-900">{{ formatDateTime(selectedImage.created_at) }}</p>
</div>
<div class="oc-inset-panel">
<p class="oc-section-label">拍摄时间</p>
<p class="mt-2 text-sm font-medium text-slate-900">{{ formatDateTime(selectedImage.captured_at) }}</p>
</div>
<div class="oc-inset-panel">
<p class="oc-section-label">状态</p>
<p class="mt-2 text-sm font-medium text-slate-900">
{{ statusMeta[selectedImage.status].label }} / {{ selectedImage.is_hidden ? '隐藏' : '公开' }}
</p>
</div>
<div class="oc-inset-panel">
<p class="oc-section-label">模糊化经纬度</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="oc-section-label">图片说明</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>