Next.js项目MindAI教程 - 第八章:数据统计与可视化
1. 安装依赖
首先安装必要的图表库和数据处理工具:
npm install chart.js react-chartjs-2 date-fns
2. 统计服务实现
2.1 统计服务类
// src/services/statistics.ts
import { prisma } from '@/lib/prisma'
import { startOfDay, subDays, format } from 'date-fns'
export class StatisticsService {
// 获取用户增长统计
static async getUserGrowth(days: number = 7) {
const startDate = startOfDay(subDays(new Date(), days - 1))
const users = await prisma.user.findMany({
where: {
createdAt: {
gte: startDate,
},
},
select: {
createdAt: true,
},
orderBy: {
createdAt: 'asc',
},
})
const dailyData = Array.from({ length: days }, (_, i) => {
const date = format(subDays(new Date(), days - 1 - i), 'yyyy-MM-dd')
return {
date,
count: users.filter(
(user) => format(user.createdAt, 'yyyy-MM-dd') === date
).length,
}
})
return dailyData
}
// 获取情绪分布统计
static async getEmotionDistribution(userId?: string) {
const where = userId ? { userId } : {}
const emotions = await prisma.emotion.groupBy({
by: ['type'],
_count: {
_all: true,
},
where,
})
return emotions.map((emotion) => ({
type: emotion.type,
count: emotion._count._all,
}))
}
// 获取测评完成情况
static async getAssessmentCompletion(days: number = 30) {
const startDate = startOfDay(subDays(new Date(), days - 1))
const assessments = await prisma.assessment.findMany({
where: {
createdAt: {
gte: startDate,
},
},
select: {
type: true,
createdAt: true,
},
})
const typeData = assessments.reduce((acc, assessment) => {
acc[assessment.type] = (acc[assessment.type] || 0) + 1
return acc
}, {} as Record<string, number>)
return Object.entries(typeData).map(([type, count]) => ({
type,
count,
}))
}
// 获取咨询统计数据
static async getConsultationStats() {
const [total, ongoing, completed] = await Promise.all([
prisma.consultation.count(),
prisma.consultation.count({
where: { status: 'ONGOING' },
}),
prisma.consultation.count({
where: { status: 'COMPLETED' },
}),
])
return {
total,
ongoing,
completed,
completion_rate: total ? (completed / total) * 100 : 0,
}
}
// 获取社区活跃度统计
static async getCommunityActivity(days: number = 7) {
const startDate = startOfDay(subDays(new Date(), days - 1))
const [posts, comments] = await Promise.all([
prisma.post.findMany({
where: {
createdAt: {
gte: startDate,
},
},
select: {
createdAt: true,
},
}),
prisma.comment.findMany({
where: {
createdAt: {
gte: startDate,
},
},
select: {
createdAt: true,
},
}),
])
const dailyData = Array.from({ length: days }, (_, i) => {
const date = format(subDays(new Date(), days - 1 - i), 'yyyy-MM-dd')
return {
date,
posts: posts.filter(
(post) => format(post.createdAt, 'yyyy-MM-dd') === date
).length,
comments: comments.filter(
(comment) => format(comment.createdAt, 'yyyy-MM-dd') === date
).length,
}
})
return dailyData
}
}
3. 统计图表组件
3.1 用户增长图表
// src/components/statistics/UserGrowthChart.tsx
'use client'
import { useEffect, useState } from 'react'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
} from 'chart.js'
import { Line } from 'react-chartjs-2'
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend
)
export function UserGrowthChart() {
const [data, setData] = useState<any>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch('/api/statistics/user-growth')
.then((res) => res.json())
.then((data) => {
setData({
labels: data.map((item: any) => item.date),
datasets: [
{
label: '新增用户',
data: data.map((item: any) => item.count),
borderColor: 'rgb(75, 192, 192)',
tension: 0.1,
},
],
})
})
.catch(console.error)
.finally(() => setLoading(false))
}, [])
if (loading) return <div>加载中...</div>
if (!data) return <div>暂无数据</div>
return (
<Line
data={data}
options={{
responsive: true,
plugins: {
legend: {
position: 'top' as const,
},
title: {
display: true,
text: '用户增长趋势',
},
},
}}
/>
)
}
3.2 情绪分布图表
// src/components/statistics/EmotionDistributionChart.tsx
'use client'
import { useEffect, useState } from 'react'
import {
Chart as ChartJS,
ArcElement,
Tooltip,
Legend,
} from 'chart.js'
import { Pie } from 'react-chartjs-2'
ChartJS.register(ArcElement, Tooltip, Legend)
const EMOTION_COLORS = {
happy: 'rgb(255, 205, 86)',
sad: 'rgb(54, 162, 235)',
angry: 'rgb(255, 99, 132)',
anxious: 'rgb(75, 192, 192)',
neutral: 'rgb(201, 203, 207)',
}
export function EmotionDistributionChart() {
const [data, setData] = useState<any>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch('/api/statistics/emotion-distribution')
.then((res) => res.json())
.then((data) => {
setData({
labels: data.map((item: any) => item.type),
datasets: [
{
data: data.map((item: any) => item.count),
backgroundColor: data.map(
(item: any) =>
EMOTION_COLORS[item.type as keyof typeof EMOTION_COLORS]
),
},
],
})
})
.catch(console.error)
.finally(() => setLoading(false))
}, [])
if (loading) return <div>加载中...</div>
if (!data) return <div>暂无数据</div>
return (
<Pie
data={data}
options={{
responsive: true,
plugins: {
legend: {
position: 'top' as const,
},
title: {
display: true,
text: '情绪分布统计',
},
},
}}
/>
)
}
3.3 统计卡片组件
// src/components/statistics/StatCard.tsx
interface StatCardProps {
title: string
value: number | string
description?: string
trend?: number
icon?: React.ReactNode
}
export function StatCard({
title,
value,
description,
trend,
icon,
}: StatCardProps) {
return (
<div className="p-6 bg-white rounded-lg shadow-sm">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium text-gray-900">{title}</h3>
{icon && <div className="text-gray-400">{icon}</div>}
</div>
<div className="mt-2">
<div className="text-3xl font-bold">{value}</div>
{description && (
<p className="mt-1 text-sm text-gray-500">{description}</p>
)}
{trend !== undefined && (
<div
className={`mt-2 text-sm ${
trend >= 0 ? 'text-green-600' : 'text-red-600'
}`}
>
{trend >= 0 ? '↑' : '↓'} {Math.abs(trend)}%
</div>
)}
</div>
</div>
)
}
4. API路由实现
4.1 统计API路由
// src/app/api/statistics/route.ts
import { NextResponse } from 'next/server'
import { StatisticsService } from '@/services/statistics'
export async function GET() {
try {
const [userGrowth, emotionDistribution, assessmentCompletion, consultationStats, communityActivity] =
await Promise.all([
StatisticsService.getUserGrowth(),
StatisticsService.getEmotionDistribution(),
StatisticsService.getAssessmentCompletion(),
StatisticsService.getConsultationStats(),
StatisticsService.getCommunityActivity(),
])
return NextResponse.json({
userGrowth,
emotionDistribution,
assessmentCompletion,
consultationStats,
communityActivity,
})
} catch (error) {
console.error(error)
return NextResponse.json(
{ error: '获取统计数据失败' },
{ status: 500 }
)
}
}
4.2 个人统计API路由
// src/app/api/statistics/personal/route.ts
import { NextResponse } from 'next/server'
import { getServerSession } from 'next-auth/next'
import { StatisticsService } from '@/services/statistics'
export async function GET() {
const session = await getServerSession()
if (!session) {
return NextResponse.json({ error: '未登录' }, { status: 401 })
}
try {
const [emotionDistribution, assessmentCompletion] = await Promise.all([
StatisticsService.getEmotionDistribution(session.user.id),
StatisticsService.getAssessmentCompletion(),
])
return NextResponse.json({
emotionDistribution,
assessmentCompletion,
})
} catch (error) {
console.error(error)
return NextResponse.json(
{ error: '获取个人统计数据失败' },
{ status: 500 }
)
}
}
5. 统计页面实现
5.1 管理员统计页面
// src/app/admin/statistics/page.tsx
import { StatCard } from '@/components/statistics/StatCard'
import { UserGrowthChart } from '@/components/statistics/UserGrowthChart'
import { EmotionDistributionChart } from '@/components/statistics/EmotionDistributionChart'
export default function StatisticsPage() {
return (
<div className="space-y-8">
<h1 className="text-3xl font-bold">数据统计</h1>
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<StatCard
title="总用户数"
value="1,234"
trend={5.2}
icon={
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
}
/>
<StatCard
title="今日活跃"
value="256"
trend={-2.1}
icon={
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
}
/>
<StatCard
title="咨询完成率"
value="85.6%"
trend={1.2}
icon={
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
}
/>
<StatCard
title="社区活跃度"
value="92.3"
description="基于发帖和评论数据"
trend={3.1}
icon={
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 8h2a2 2 0 012 2v6a2 2 0 01-2 2h-2v4l-4-4H9a1.994 1.994 0 01-1.414-.586m0 0L11 14h4a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2v4l.586-.586z"
/>
</svg>
}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="bg-white p-6 rounded-lg shadow-sm">
<UserGrowthChart />
</div>
<div className="bg-white p-6 rounded-lg shadow-sm">
<EmotionDistributionChart />
</div>
</div>
</div>
)
}
6. 下一步计划
- 实现数据导出功能
- 添加更多统计维度
- 优化图表交互
- 实现实时数据更新