当前位置: 首页 > news >正文

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. 下一步计划

  • 实现数据导出功能
  • 添加更多统计维度
  • 优化图表交互
  • 实现实时数据更新

相关文章:

  • CVPR-2025 | 长程视觉语言导航平台与数据集:迈向复杂环境中的智能机器人
  • 论文阅读笔记:Deep Unsupervised Learning using Nonequilibrium Thermodynamics
  • Springboot+mabatis增删改查,设置不可重复字段
  • 基于python+django+vue.js开发的停车管理系统运行-期末作业
  • 嵌入式web服务器实现上传下载储存研究
  • 基于ensp的IP企业网络规划
  • 1191:流感传染--BFS
  • 星越L_三角指示牌及危险警示灯使用
  • 【技术支持】记一次mac电脑换行符差异问题
  • Vmware下安装openEuler24.03 LTS
  • 函数指针/逗号表达式/不用if语句完成的字母输出题
  • #mapreduce打包#maven:could not resolve dependencies for project
  • STM32驱动代码规范化编写指南(嵌入式C语言方向)
  • R语言高效数据处理-自定义格式EXCEL数据输出
  • Java 大视界 -- Java 大数据在智能金融资产定价与风险管理中的应用(134)
  • 在windows上通过idea搭建doris fe的开发环境(快速成功版)
  • [Hello-CTF]RCE-Labs超详细WP-Level10(无字母命令执行_二进制整数替换)
  • LeetCode 环形链表II:为什么双指针第二次会在环的入口相遇?
  • 串的KMP算法详解
  • LeetCode[203]移除链表元素
  • 上百家单位展示AI+教育的实践与成果,上海教育博览会开幕
  • 艺术稀缺性和价值坚守如何构筑品牌差异化壁垒?从“心邸”看CINDY CHAO的破局之道
  • 最高人民法院、中国证监会联合发布《关于严格公正执法司法 服务保障资本市场高质量发展的指导意见》
  • 缅甸内观冥想的历史漂流:从“人民鸦片”到东方灵修
  • 泽连斯基:正在等待俄方确认参加会谈的代表团组成
  • 前四个月社会融资规模增量累计为16.34万亿元,比上年同期多3.61万亿元