Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3dc456e22c | ||
|
|
3aed4005f7 | ||
|
|
b8dc2a649e | ||
|
|
9f1d87910e | ||
|
|
67316085dd | ||
|
|
c3153bb998 | ||
|
|
834574b39e | ||
|
|
45a4e834dd |
+6
-1
@@ -1,9 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { NConfigProvider, NDialogProvider, NGlobalStyle, NMessageProvider, NNotificationProvider } from 'naive-ui'
|
||||
import { useRoute } from 'vue-router'
|
||||
import AppHeader from '@/components/layout/AppHeader.vue'
|
||||
import { openCloudThemeOverrides } from '@/lib/theme'
|
||||
import { Analytics } from "@vercel/analytics/vue"
|
||||
import { SpeedInsights } from "@vercel/speed-insights/vue"
|
||||
|
||||
const route = useRoute()
|
||||
const showAppHeader = computed(() => route.name !== 'admin')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -13,7 +18,7 @@ import { SpeedInsights } from "@vercel/speed-insights/vue"
|
||||
<NNotificationProvider>
|
||||
<NMessageProvider>
|
||||
<div class="oc-app-shell">
|
||||
<AppHeader />
|
||||
<AppHeader v-if="showAppHeader" />
|
||||
<main class="relative flex-1">
|
||||
<div class="oc-app-grid"></div>
|
||||
<div class="relative">
|
||||
|
||||
+598
-106
@@ -1,7 +1,27 @@
|
||||
<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 { 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'
|
||||
@@ -45,8 +65,11 @@ interface DashboardStats {
|
||||
|
||||
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('')
|
||||
@@ -62,6 +85,7 @@ const dashboardStats = ref<DashboardStats>({
|
||||
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)
|
||||
@@ -79,18 +103,29 @@ const statusMeta = {
|
||||
} 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 }>
|
||||
{ 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([])
|
||||
@@ -107,6 +142,35 @@ const cloudTypeStats = computed(() => {
|
||||
.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 {
|
||||
@@ -146,6 +210,11 @@ 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
|
||||
}
|
||||
@@ -403,33 +472,237 @@ async function toggleUserDisabled(user: Profile) {
|
||||
}
|
||||
}
|
||||
|
||||
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%)]">
|
||||
<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
|
||||
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>
|
||||
|
||||
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--neutral" :loading="loading" @click="loadAdminData">
|
||||
<button
|
||||
type="button"
|
||||
class="hidden h-8 w-8 items-center justify-center border border-slate-200 bg-white text-slate-500 shadow-[3px_3px_0_0_rgba(15,23,42,0.06)] transition-colors hover:border-teal-200 hover:bg-teal-50 hover:text-teal-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-600 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(20,184,166,0.14)]'
|
||||
: '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(20,184,166,0.12)]'
|
||||
: '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,#f0fdfa_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,#ecfeff_0%,#ccfbf1_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="flex w-full items-center gap-3 px-2 py-2.5 text-left text-sm font-medium text-rose-600 transition-colors hover:bg-rose-50 hover:text-rose-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-rose-500"
|
||||
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>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<main class="mx-auto max-w-7xl px-4 py-8">
|
||||
<NAlert
|
||||
v-if="loadError"
|
||||
class="mb-6"
|
||||
@@ -441,7 +714,7 @@ onMounted(loadAdminData)
|
||||
{{ loadError }}
|
||||
</NAlert>
|
||||
|
||||
<section v-if="loading" class="grid gap-4 md:grid-cols-4">
|
||||
<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="border border-slate-200 bg-white p-5">
|
||||
<NSkeleton class="h-4 w-1/2" />
|
||||
<NSkeleton class="mt-4 h-8 w-2/3" />
|
||||
@@ -450,71 +723,124 @@ onMounted(loadAdminData)
|
||||
</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"
|
||||
<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%,#f0fdfa_100%)] shadow-[5px_5px_0_0_rgba(245,158,11,0.12)]'
|
||||
: 'border-teal-200 bg-[linear-gradient(135deg,#f0fdfa_0%,#fff_55%,#eff6ff_100%)] shadow-[5px_5px_0_0_rgba(20,184,166,0.10)]'"
|
||||
>
|
||||
<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 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 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>
|
||||
<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 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>
|
||||
<NButton
|
||||
type="default"
|
||||
secondary
|
||||
strong
|
||||
class="oc-panel-button oc-panel-button--amber shrink-0"
|
||||
:disabled="!dashboardStats.pending"
|
||||
@click="activeTab = 'review'"
|
||||
>
|
||||
进入审核队列
|
||||
</NButton>
|
||||
</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 class="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="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 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="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 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="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 class="grid gap-5 lg:grid-cols-[1.05fr_0.95fr]">
|
||||
<div class="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="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 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 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: `${Math.max(8, item.count / Math.max(1, dashboardStats.images) * 100)}%` }"
|
||||
:style="{ width: `${item.count / cloudTypeMaxCount * 100}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -522,9 +848,41 @@ onMounted(loadAdminData)
|
||||
<NEmpty v-else class="py-6" description="暂无图片数据" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="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>
|
||||
<button type="button" class="text-sm font-medium text-sky-700 hover:text-sky-900" @click="activeTab = 'images'">
|
||||
查看全部图片
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="recentImages.length" class="divide-y divide-slate-100">
|
||||
<button
|
||||
v-for="item in recentImages"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="grid w-full grid-cols-[52px_minmax(0,1fr)_auto] items-center gap-3 px-5 py-3 text-left transition-colors hover:bg-sky-50/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-sky-600 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'" class="mt-6">
|
||||
<section v-else-if="activeTab === 'review'">
|
||||
<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>
|
||||
@@ -534,64 +892,200 @@ onMounted(loadAdminData)
|
||||
<NButton 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"
|
||||
:disabled="!selectedReviewCount"
|
||||
:loading="actionLoading"
|
||||
@click="updateCloudStatus(Array.from(selectedReviewIds), 'approved')"
|
||||
>
|
||||
批量通过 {{ selectedReviewCount || '' }}
|
||||
批量通过 {{ 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 || '' }}
|
||||
批量拒绝 {{ selectedReviewCount }}
|
||||
</NButton>
|
||||
</template>
|
||||
</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 v-if="activeReviewImage" class="grid min-w-0 gap-4 xl:grid-cols-[220px_minmax(0,1fr)]">
|
||||
<aside class="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-lg font-semibold text-slate-950">{{ item.cloudTypeName }}</p>
|
||||
<p class="oc-field-help">上传者:{{ item.username }}</p>
|
||||
<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="flex min-w-0 flex-1 items-center gap-3 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-600"
|
||||
: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="mt-1 h-5 w-5"
|
||||
class="h-5 w-5 shrink-0"
|
||||
:aria-label="`选择 ${item.cloudTypeName}`"
|
||||
: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')">
|
||||
</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="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="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="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 size="small" type="default" secondary strong class="oc-panel-button oc-panel-button--danger" :loading="actionLoading" @click="updateCloudStatus([item.id], 'rejected')">
|
||||
<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>
|
||||
<NButton size="small" type="default" secondary strong class="oc-panel-button oc-panel-button--neutral" @click="selectedImage = item">查看大图</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>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div v-else class="border border-dashed border-slate-300 bg-white p-10">
|
||||
@@ -599,7 +1093,7 @@ onMounted(loadAdminData)
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="activeTab === 'users'" class="mt-6">
|
||||
<section v-else-if="activeTab === 'users'">
|
||||
<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>
|
||||
@@ -650,7 +1144,7 @@ onMounted(loadAdminData)
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else class="mt-6">
|
||||
<section v-else>
|
||||
<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>
|
||||
@@ -669,61 +1163,58 @@ onMounted(loadAdminData)
|
||||
<NButton 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"
|
||||
:disabled="!selectedManagedCount"
|
||||
:loading="actionLoading"
|
||||
@click="updateCloudStatus(Array.from(selectedImageIds), 'approved')"
|
||||
>
|
||||
批量通过 {{ selectedManagedCount || '' }}
|
||||
批量通过 {{ 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 || '' }}
|
||||
批量拒绝 {{ 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 || '' }}
|
||||
批量隐藏 {{ 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 || '' }}
|
||||
批量公开 {{ 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 || '' }}
|
||||
批量删除 {{ selectedManagedCount }}
|
||||
</NButton>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -778,6 +1269,7 @@ onMounted(loadAdminData)
|
||||
</section>
|
||||
</template>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<ImageDetailModal
|
||||
v-if="selectedImage"
|
||||
|
||||
Reference in New Issue
Block a user