feat(admin): add collapsible standalone admin shell

This commit is contained in:
2026-07-24 00:10:29 +08:00
parent 9f1d87910e
commit b8dc2a649e
2 changed files with 122 additions and 60 deletions
+6 -1
View File
@@ -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">
+116 -59
View File
@@ -1,7 +1,23 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { computed, onMounted, ref, watch, type Component } from 'vue'
import { NAlert, NButton, NEmpty, NIcon, NSkeleton, NTag, useMessage } from 'naive-ui'
import { Check, Eye, EyeOff, Refresh, Trash, X } from '@vicons/tabler'
import {
Check,
ClipboardCheck,
Dashboard,
Eye,
EyeOff,
Home,
LayoutSidebarLeftCollapse,
LayoutSidebarLeftExpand,
Photo,
Refresh,
Shield,
Trash,
UserCircle,
Users,
X,
} from '@vicons/tabler'
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
import { apiRequest } from '@/lib/api'
@@ -47,6 +63,7 @@ const authStore = useAuthStore()
const message = useMessage()
const activeTab = ref<AdminTab>('dashboard')
const sidebarCollapsed = ref(false)
const loading = ref(true)
const actionLoading = ref(false)
const loadError = ref('')
@@ -80,11 +97,11 @@ const statusMeta = {
} satisfies Record<CloudStatus, { label: string; chip: string }>
const tabs = [
{ key: 'dashboard', label: '数据看板', code: '01', description: '运行概览与内容分布' },
{ key: 'review', label: '内容审核', code: '02', description: '处理待审核上传' },
{ key: 'users', label: '用户管理', code: '03', description: '角色与账号状态' },
{ key: 'images', label: '图片管理', code: '04', description: '内容状态与可见性' },
] satisfies Array<{ key: AdminTab; label: string; code: string; description: 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(() =>
@@ -451,86 +468,126 @@ onMounted(loadAdminData)
<template>
<div class="min-h-screen bg-[linear-gradient(180deg,#f8fafc_0%,#eef6ff_55%,#f8fafc_100%)]">
<div class="mx-auto min-h-screen max-w-[1536px] lg:grid lg:grid-cols-[280px_minmax(0,1fr)]">
<aside class="border-b border-slate-200 bg-white/88 px-4 py-5 backdrop-blur lg:sticky lg:top-16 lg:flex lg:h-[calc(100vh-4rem)] lg:flex-col lg:border-b-0 lg:border-r lg:px-5 lg:py-7">
<div class="flex items-start justify-between gap-4 lg:block">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-sky-700">Admin Console</p>
<h1 class="mt-2 text-2xl font-bold text-slate-950">管理工作台</h1>
<p class="mt-2 hidden text-sm leading-6 text-slate-500 lg:block">社区内容、成员与运行状态的集中控制台。</p>
<div
class="min-h-screen w-full transition-[grid-template-columns] duration-200 lg:grid"
:class="sidebarCollapsed
? 'lg:grid-cols-[88px_minmax(0,1fr)]'
: 'lg:grid-cols-[280px_minmax(0,1fr)]'"
>
<aside
class="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:py-5"
:class="sidebarCollapsed ? 'lg:px-3' : 'lg:px-5'"
>
<div
class="flex items-start justify-between gap-3"
:class="sidebarCollapsed ? 'lg:flex-col lg:items-center' : 'lg: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" :class="sidebarCollapsed ? 'lg:hidden' : ''">
<span class="block text-xs font-semibold uppercase tracking-[0.2em] text-sky-700">Admin Console</span>
<span class="mt-1 block truncate text-lg font-bold text-slate-950">管理工作台</span>
</span>
</div>
<div class="lg:hidden">
<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>
<button
type="button"
class="hidden h-9 w-9 shrink-0 items-center justify-center border border-slate-200 bg-white text-slate-500 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:flex"
:aria-label="sidebarCollapsed ? '展开侧边栏' : '折叠侧边栏'"
:aria-expanded="!sidebarCollapsed"
:title="sidebarCollapsed ? '展开侧边栏' : '折叠侧边栏'"
@click="sidebarCollapsed = !sidebarCollapsed"
>
<NIcon size="20">
<LayoutSidebarLeftExpand v-if="sidebarCollapsed" />
<LayoutSidebarLeftCollapse v-else />
</NIcon>
</button>
</div>
<nav aria-label="后台管理分区" class="mt-5 flex gap-2 overflow-x-auto pb-1 lg:mt-8 lg:block lg:space-y-2 lg:overflow-visible lg:pb-0">
<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 flex min-w-[150px] items-center gap-3 border px-3 py-3 text-left transition-all 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'"
class="group relative flex min-w-[150px] items-center gap-3 border px-3 py-3 text-left transition-all 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',
sidebarCollapsed ? 'lg:justify-center lg:px-0' : '',
]"
:aria-current="activeTab === tab.key ? 'page' : undefined"
:aria-label="tab.label"
:title="sidebarCollapsed ? tab.label : undefined"
@click="activeTab = tab.key"
>
<span
class="font-mono text-xs font-semibold"
:class="activeTab === tab.key ? 'text-teal-700' : 'text-slate-400 group-hover:text-slate-600'"
>
{{ tab.code }}
</span>
<span class="min-w-0 flex-1">
<NIcon size="22" class="shrink-0">
<component :is="tab.icon" />
</NIcon>
<span class="min-w-0 flex-1" :class="sidebarCollapsed ? 'lg:hidden' : ''">
<span class="block 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-auto hidden lg:block">
<div class="mb-4 border border-sky-100 bg-[linear-gradient(135deg,#f0fdfa_0%,#eff6ff_100%)] p-4">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">当前待办</p>
<div class="mt-3 flex items-end justify-between">
<span class="text-3xl font-bold text-amber-800">{{ dashboardStats.pending }}</span>
<span class="pb-1 text-xs text-slate-500">张图片待审核</span>
</div>
<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">
<div
class="flex min-w-0 flex-1 items-center gap-3 border border-slate-200 bg-slate-50 px-3 py-2.5 text-slate-700"
:class="sidebarCollapsed ? 'lg:justify-center lg:px-0' : ''"
:title="sidebarCollapsed ? (authStore.profile?.username || '管理员') : undefined"
>
<NIcon size="22" class="shrink-0"><UserCircle /></NIcon>
<span class="min-w-0" :class="sidebarCollapsed ? 'lg:hidden' : ''">
<span class="block truncate text-sm font-semibold text-slate-900">{{ authStore.profile?.username || '管理员' }}</span>
<span class="mt-0.5 block text-xs text-slate-500">当前管理员</span>
</span>
</div>
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--neutral w-full" :loading="loading" @click="loadAdminData">
<template #icon>
<NIcon><Refresh /></NIcon>
</template>
刷新后台数据
</NButton>
<RouterLink
to="/"
class="flex h-[46px] shrink-0 items-center justify-center gap-3 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"
:class="sidebarCollapsed ? 'lg:px-0' : 'lg:w-full'"
title="返回主页"
>
<NIcon size="20" class="shrink-0"><Home /></NIcon>
<span :class="sidebarCollapsed ? 'lg:hidden' : ''">返回主页</span>
</RouterLink>
</div>
</aside>
<main class="min-w-0 px-4 py-7 sm:px-6 lg:px-8 lg:py-9">
<header class="mb-7 border-b border-slate-200 pb-6">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-sky-700">
{{ activeTabMeta.code }} / {{ activeTabMeta.label }}
</p>
<div class="mt-2 flex flex-col gap-2 xl:flex-row xl:items-end xl:justify-between">
<div>
<h2 class="text-3xl font-bold text-slate-950">{{ activeTabMeta.label }}</h2>
<p class="mt-2 text-sm leading-6 text-slate-500">{{ activeTabMeta.description }}</p>
</div>
<p class="text-xs text-slate-400">数据以最近一次刷新结果为准</p>
<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>
</header>
<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"