Files
opencloud/src/features/admin/components/AdminOverview.vue
T

259 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>