259 lines
11 KiB
Vue
259 lines
11 KiB
Vue
<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>
|