252 lines
9.5 KiB
TypeScript
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>
|
|
)
|
|
}
|