feat(admin): 重构管理控制台布局并完善管理功能
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user