feat(admin): 重构管理控制台布局并完善管理功能

This commit is contained in:
2026-10-01 10:31:06 +08:00
parent f31691c300
commit 86b6383251
11 changed files with 3008 additions and 2213 deletions
@@ -0,0 +1,258 @@
<script setup lang="ts">
import { computed } from 'vue'
import { NButton, NIcon, NTag } from 'naive-ui'
import { ArrowRight, ClipboardCheck, Cloud, Photo, Users } from '@vicons/tabler'
import type { CloudDetail, AuthUser } from '@/types/view-models'
import type { CloudTypeResponse } from '@/types/api-contract'
import { formatDateTime, statusMeta, type AdminSection } from '@/features/admin/models'
const props = defineProps<{
clouds: CloudDetail[]
users: AuthUser[]
cloudTypes: CloudTypeResponse[]
}>()
const emit = defineEmits<{ navigate: [section: AdminSection]; preview: [cloud: CloudDetail] }>()
const pending = computed(() => props.clouds.filter(row => row.status === 'pending'))
const approved = computed(() => props.clouds.filter(row => row.status === 'approved').length)
const publicCount = computed(
() => props.clouds.filter(row => row.status === 'approved' && !row.is_hidden).length,
)
const recent = computed(() =>
[...props.clouds]
.sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at))
.slice(0, 5),
)
const trend = computed(() => {
const now = new Date()
return Array.from({ length: 7 }, (_, index) => {
const date = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 6 + index)
const next = new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1)
return {
label: `${date.getMonth() + 1}/${date.getDate()}`,
count: props.clouds.filter(row => {
const timestamp = Date.parse(row.created_at)
return timestamp >= date.getTime() && timestamp < next.getTime()
}).length,
}
})
})
const maxUploads = computed(() => Math.max(1, ...trend.value.map(day => day.count)))
const stats = computed(() => [
{
label: '云图总量',
value: props.clouds.length,
detail: `${publicCount.value} 张公开可见`,
icon: Photo,
section: 'images' as const,
tone: 'sky',
},
{
label: '待审核',
value: pending.value.length,
detail: pending.value.length ? '需要管理员处理' : '审核队列已清空',
icon: ClipboardCheck,
section: 'review' as const,
tone: 'amber',
},
{
label: '注册用户',
value: props.users.length,
detail: `${props.users.filter(user => user.role === 'admin').length} 位管理员`,
icon: Users,
section: 'users' as const,
tone: 'teal',
},
{
label: '云类型',
value: props.cloudTypes.length,
detail: '维护云图分类与图鉴',
icon: Cloud,
section: 'cloud-types' as const,
tone: 'slate',
},
])
const distribution = computed(() =>
(['approved', 'pending', 'rejected'] as const).map(status => ({
status,
count: props.clouds.filter(row => row.status === status).length,
percent: props.clouds.length
? Math.round(
(props.clouds.filter(row => row.status === status).length / props.clouds.length) *
100,
)
: 0,
})),
)
const oldestPending = computed(
() => [...pending.value].sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at))[0],
)
</script>
<template>
<div class="space-y-6">
<div class="admin-stat-grid">
<button
v-for="stat in stats"
:key="stat.label"
class="admin-stat oc-panel-card"
type="button"
@click="emit('navigate', stat.section)"
>
<div class="flex items-center justify-between gap-3">
<span class="oc-stat-label">{{ stat.label }}</span>
<span class="admin-stat-icon" :class="`admin-stat-icon--${stat.tone}`"
><NIcon size="20"><component :is="stat.icon" /></NIcon
></span>
</div>
<p class="mt-3 text-3xl font-semibold tabular-nums text-slate-900">
{{ stat.value.toLocaleString() }}
</p>
<div class="mt-3 flex items-center justify-between gap-2 text-xs text-slate-500">
<span>{{ stat.detail }}</span
><NIcon><ArrowRight /></NIcon>
</div>
</button>
</div>
<section
class="admin-priority oc-panel-card"
:class="pending.length ? 'admin-priority--pending' : ''"
>
<span class="admin-stat-icon admin-stat-icon--amber"
><NIcon size="24"><ClipboardCheck /></NIcon
></span>
<div class="min-w-0 flex-1">
<h2 class="font-semibold text-slate-900">
{{
pending.length
? `有 ${pending.length} 张云图等待审核`
: '待办已处理,天空档案持续更新'
}}
</h2>
<p class="mt-1 text-sm leading-6 text-slate-500">
{{
oldestPending
? `最早提交于 ${formatDateTime(oldestPending.created_at)},优先检查图片、云型与拍摄信息。`
: '查看近期上传,或维护用户与云类型资料。'
}}
</p>
</div>
<NButton
type="default"
class="oc-panel-button oc-panel-button--amber"
@click="emit('navigate', pending.length ? 'review' : 'images')"
>{{ pending.length ? '进入审核' : '查看云图'
}}<template #icon
><NIcon><ArrowRight /></NIcon></template
></NButton>
</section>
<div class="admin-overview-grid">
<section class="oc-panel-card admin-panel">
<div class="admin-panel-heading">
<div>
<h2>上传趋势</h2>
<p>最近 7 天 · 按上传时间统计</p>
</div>
<NTag size="small" :bordered="false" type="info"
>今日 {{ trend[6]?.count ?? 0 }} 张</NTag
>
</div>
<div
class="admin-chart"
role="img"
:aria-label="trend.map(day => `${day.label}:${day.count} 张`).join(';')"
>
<div v-for="(day, index) in trend" :key="day.label" class="admin-chart-column">
<span class="text-xs tabular-nums text-slate-500">{{ day.count }}</span>
<div class="admin-chart-track">
<div
class="admin-chart-bar"
:class="index === 6 ? 'admin-chart-bar--today' : ''"
:style="{
height: `${Math.max(day.count ? 4 : 0, (day.count / maxUploads) * 100)}%`,
}"
></div>
</div>
<span class="text-xs text-slate-500">{{ day.label }}</span>
</div>
</div>
</section>
<section class="oc-panel-card admin-panel">
<div class="admin-panel-heading">
<div>
<h2>内容状态</h2>
<p>当前全部云图的审核分布</p>
</div>
</div>
<div class="space-y-5 px-5 py-6">
<div v-for="item in distribution" :key="item.status">
<div class="mb-2 flex justify-between text-sm">
<span class="text-slate-600">{{ statusMeta[item.status].label }}</span
><span class="tabular-nums text-slate-900"
>{{ item.count }}
<span class="ml-2 text-xs text-slate-400"
>{{ item.percent }}%</span
></span
>
</div>
<div class="h-2 bg-slate-100">
<div
class="h-full"
:class="{
'bg-emerald-500': item.status === 'approved',
'bg-amber-400': item.status === 'pending',
'bg-rose-400': item.status === 'rejected',
}"
:style="{ width: `${item.percent}%` }"
></div>
</div>
</div>
<p class="border-t border-slate-100 pt-4 text-xs leading-5 text-slate-500">
已通过 {{ approved }} 张,其中 {{ publicCount }} 张公开展示。
</p>
</div>
</section>
</div>
<section class="oc-panel-card admin-panel">
<div class="admin-panel-heading">
<div>
<h2>最近上传</h2>
<p>最近提交的 5 张云图</p>
</div>
<button class="oc-text-button" type="button" @click="emit('navigate', 'images')">
全部云图 →
</button>
</div>
<div v-if="!recent.length" class="py-12 text-center text-sm text-slate-500">
暂无上传记录
</div>
<button
v-for="cloud in recent"
:key="cloud.id"
type="button"
class="admin-recent-row"
@click="emit('preview', cloud)"
>
<img
:src="cloud.thumbnail_url || cloud.image_url"
:alt="cloud.cloud_type_name || '云图'"
class="h-12 w-16 shrink-0 border border-slate-200 bg-sky-50 object-cover"
loading="lazy"
/>
<div class="min-w-0 flex-1">
<p class="truncate font-medium text-slate-900">
{{ cloud.cloud_type_name || '未知云型' }}
</p>
<p class="mt-1 truncate text-xs text-slate-500">
{{ cloud.username }} · {{ formatDateTime(cloud.created_at) }}
</p>
</div>
<NTag size="small" :type="statusMeta[cloud.status].type" :bordered="false">{{
statusMeta[cloud.status].label
}}</NTag>
<NIcon class="hidden text-slate-400 sm:block"><ArrowRight /></NIcon>
</button>
</section>
</div>
</template>