Files
admin/src/pages/Dashboard.tsx
2026-03-14 18:24:33 +08:00

252 lines
9.5 KiB
TypeScript

import { useState, useEffect } from 'react'
import { Users, FileText, MessageSquare, Clock, TrendingUp, Calendar, CalendarDays, CalendarRange } from 'lucide-react'
import { StatsCard } from '@/components/StatsCard'
import { ActivityChart } from '@/components/ActivityChart'
import { RetentionChart } from '@/components/RetentionChart'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { dashboardApi } from '@/api'
import type { DashboardStatsData, ActivityTrendData, PendingContent } from '@/api/dashboard'
export default function Dashboard() {
const [stats, setStats] = useState<DashboardStatsData | null>(null)
const [activityData, setActivityData] = useState<ActivityTrendData[]>([])
const [pendingContent, setPendingContent] = useState<PendingContent[]>([])
const [isLoading, setIsLoading] = useState(true)
const [activityDays] = useState(7)
useEffect(() => {
loadDashboardData()
}, [])
useEffect(() => {
loadActivityData(activityDays)
}, [activityDays])
const loadDashboardData = async () => {
setIsLoading(true)
try {
const [statsRes, pendingRes] = await Promise.all([
dashboardApi.getStats(),
dashboardApi.getPendingContent(5),
])
if (statsRes.data.data) {
setStats(statsRes.data.data)
}
if (pendingRes.data.data) {
setPendingContent(pendingRes.data.data)
}
} catch (error) {
console.error('加载Dashboard数据失败:', error)
} finally {
setIsLoading(false)
}
}
const loadActivityData = async (days: number) => {
try {
const res = await dashboardApi.getUserActivityTrend(days)
if (res.data.data) {
setActivityData(res.data.data)
}
} catch (error) {
console.error('加载活跃度数据失败:', error)
}
}
const formatTime = (dateStr: string) => {
const date = new Date(dateStr)
const now = new Date()
const diff = now.getTime() - date.getTime()
const minutes = Math.floor(diff / 60000)
const hours = Math.floor(diff / 3600000)
if (minutes < 60) return `${minutes}分钟前`
if (hours < 24) return `${hours}小时前`
return `${Math.floor(hours / 24)}天前`
}
const retentionData = stats ? [
{ name: '次日留存', value: stats.retention_1_day || 0, color: '#8884d8' },
{ name: '7日留存', value: stats.retention_7_day || 0, color: '#82ca9d' },
{ name: '30日留存', value: stats.retention_30_day || 0, color: '#ffc658' },
] : []
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold">Dashboard</h1>
<p className="text-muted-foreground"> Carrot BBS </p>
</div>
{/* 统计卡片 */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatsCard
title="总用户数"
value={isLoading ? '--' : (stats?.total_users?.toLocaleString() || '0')}
description={`今日新增 ${stats?.today_new_users || 0}`}
icon={Users}
trend={{ value: 12.5, label: '较上周', isPositive: true }}
/>
<StatsCard
title="总帖子数"
value={isLoading ? '--' : (stats?.total_posts?.toLocaleString() || '0')}
description={`今日发布 ${stats?.today_posts || 0}`}
icon={FileText}
trend={{ value: 8.3, label: '较上周', isPositive: true }}
/>
<StatsCard
title="总评论数"
value={isLoading ? '--' : (stats?.total_comments?.toLocaleString() || '0')}
description={`今日评论 ${stats?.today_comments || 0}`}
icon={MessageSquare}
trend={{ value: 15.2, label: '较上周', isPositive: true }}
/>
<StatsCard
title="待审核内容"
value={isLoading ? '--' : (stats?.pending_review || 0)}
description={`${stats?.pending_posts_count || 0} 帖子 / ${stats?.pending_comments_count || 0} 评论`}
icon={Clock}
className={stats?.pending_review && stats.pending_review > 0 ? 'border-orange-300' : ''}
/>
</div>
{/* 活跃用户统计 */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatsCard
title="DAU (日活跃)"
value={isLoading ? '--' : (stats?.active_users_today?.toLocaleString() || '0')}
description="今日活跃用户数"
icon={Calendar}
/>
<StatsCard
title="WAU (周活跃)"
value={isLoading ? '--' : (stats?.active_users_week?.toLocaleString() || '0')}
description="本周活跃用户数"
icon={CalendarDays}
/>
<StatsCard
title="MAU (月活跃)"
value={isLoading ? '--' : (stats?.active_users_month?.toLocaleString() || '0')}
description="本月活跃用户数"
icon={CalendarRange}
/>
<StatsCard
title="总群组数"
value={isLoading ? '--' : (stats?.total_groups || 0)}
description={`今日新建 ${stats?.today_new_groups || 0}`}
/>
</div>
{/* 留存率统计 */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatsCard
title="次日留存率"
value={isLoading ? '--' : `${stats?.retention_1_day || 0}%`}
description="新用户次日回访比例"
icon={TrendingUp}
trend={stats?.retention_1_day && stats.retention_1_day > 40 ? { value: stats.retention_1_day, label: '%', isPositive: true } : undefined}
/>
<StatsCard
title="7日留存率"
value={isLoading ? '--' : `${stats?.retention_7_day || 0}%`}
description="新用户7日后回访比例"
icon={TrendingUp}
/>
<StatsCard
title="30日留存率"
value={isLoading ? '--' : `${stats?.retention_30_day || 0}%`}
description="新用户30日后回访比例"
icon={TrendingUp}
/>
<Card className="flex flex-col justify-center">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground"></p>
<p className="text-2xl font-bold text-green-500"></p>
</div>
<div className="h-3 w-3 rounded-full bg-green-500 animate-pulse" />
</div>
</CardContent>
</Card>
</div>
{/* 图表区域 */}
<div className="grid gap-4 md:grid-cols-2">
<ActivityChart
data={activityData}
title="用户活跃度趋势"
description={`最近 ${activityDays} 天活动数据统计`}
lines={[
{ dataKey: 'new_users', name: '新用户', color: '#8884d8' },
{ dataKey: 'active_users', name: '活跃用户', color: '#82ca9d' },
{ dataKey: 'posts', name: '帖子', color: '#ffc658' },
{ dataKey: 'comments', name: '评论', color: '#ff7300' },
]}
/>
<RetentionChart
data={retentionData}
title="用户留存率"
description="新用户回访比例统计"
/>
</div>
{/* 最新待审核内容 */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</div>
<Button variant="outline" size="sm" onClick={() => window.location.href = '/posts'}>
</Button>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">...</div>
) : pendingContent.length === 0 ? (
<div className="text-center py-8 text-muted-foreground"></div>
) : (
<div className="space-y-4">
{pendingContent.map((item) => (
<div
key={`${item.type}-${item.id}`}
className="flex items-start justify-between p-3 rounded-lg border hover:bg-muted/50 transition-colors"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<Badge variant={item.type === 'post' ? 'default' : 'secondary'}>
{item.type === 'post' ? '帖子' : '评论'}
</Badge>
<span className="text-sm text-muted-foreground">
{item.author.nickname || item.author.username}
</span>
<span className="text-xs text-muted-foreground">
{formatTime(item.created_at)}
</span>
</div>
<p className="text-sm truncate">
{item.title || item.content}
</p>
</div>
<div className="flex gap-2 ml-4">
<Button variant="outline" size="sm"></Button>
<Button variant="default" size="sm"></Button>
<Button variant="destructive" size="sm"></Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
)
}