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

Next.js项目MindAI教程 - 第六章:在线咨询功能

1. WebSocket服务配置

1.1 安装依赖

npm install socket.io socket.io-client

1.2 配置WebSocket服务器

// src/lib/socket.ts
import { Server as NetServer } from 'http'
import { Server as SocketIOServer } from 'socket.io'
import { NextApiRequest } from 'next'
import { getSession } from 'next-auth/react'

export type NextApiResponseWithSocket = NextApiResponse & {
  socket: {
    server: NetServer & {
      io?: SocketIOServer
    }
  }
}

export const initSocket = (req: NextApiRequest, res: NextApiResponseWithSocket) => {
  if (!res.socket.server.io) {
    const io = new SocketIOServer(res.socket.server)
    
    io.use(async (socket, next) => {
      const session = await getSession({ req: socket.request })
      if (!session) {
        next(new Error('未授权'))
      } else {
        socket.data.user = session.user
        next()
      }
    })

    io.on('connection', (socket) => {
      console.log('Client connected:', socket.id)

      socket.on('join-room', (roomId) => {
        socket.join(roomId)
      })

      socket.on('leave-room', (roomId) => {
        socket.leave(roomId)
      })

      socket.on('disconnect', () => {
        console.log('Client disconnected:', socket.id)
      })
    })

    res.socket.server.io = io
  }
  return res.socket.server.io
}

2. 咨询室组件实现

2.1 聊天界面组件

// src/components/consultation/ChatRoom.tsx
'use client'

import { useEffect, useRef, useState } from 'react'
import { useSession } from 'next-auth/react'
import { io, Socket } from 'socket.io-client'
import { format } from 'date-fns'

interface Message {
  id: string
  content: string
  senderId: string
  senderName: string
  createdAt: Date
}

interface ChatRoomProps {
  consultationId: string
}

export function ChatRoom({ consultationId }: ChatRoomProps) {
  const { data: session } = useSession()
  const [messages, setMessages] = useState<Message[]>([])
  const [newMessage, setNewMessage] = useState('')
  const [socket, setSocket] = useState<Socket | null>(null)
  const messagesEndRef = useRef<HTMLDivElement>(null)

  useEffect(() => {
    const socketInstance = io('', {
      path: '/api/socketio',
    })

    socketInstance.on('connect', () => {
      console.log('Connected to WebSocket')
      socketInstance.emit('join-room', consultationId)
    })

    socketInstance.on('message', (message: Message) => {
      setMessages((prev) => [...prev, message])
    })

    setSocket(socketInstance)

    return () => {
      socketInstance.emit('leave-room', consultationId)
      socketInstance.disconnect()
    }
  }, [consultationId])

  useEffect(() => {
    // 加载历史消息
    fetch(`/api/consultation/${consultationId}/messages`)
      .then((res) => res.json())
      .then(setMessages)
      .catch(console.error)
  }, [consultationId])

  useEffect(() => {
    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
  }, [messages])

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    if (!newMessage.trim() || !socket || !session) return

    const message = {
      content: newMessage,
      senderId: session.user.id,
      senderName: session.user.name || session.user.email,
      consultationId,
    }

    try {
      await fetch('/api/consultation/message', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(message),
      })

      socket.emit('message', message)
      setNewMessage('')
    } catch (error) {
      console.error('Failed to send message:', error)
    }
  }

  return (
    <div className="flex flex-col h-[calc(100vh-200px)]">
      <div className="flex-1 overflow-y-auto p-4 space-y-4">
        {messages.map((message) => (
          <div
            key={message.id}
            className={`flex ${
              message.senderId === session?.user.id
                ? 'justify-end'
                : 'justify-start'
            }`}
          >
            <div
              className={`max-w-[70%] rounded-lg p-3 ${
                message.senderId === session?.user.id
                  ? 'bg-primary-100 text-primary-900'
                  : 'bg-gray-100'
              }`}
            >
              <div className="text-sm text-gray-500 mb-1">
                {message.senderName} •{' '}
                {format(new Date(message.createdAt), 'HH:mm')}
              </div>
              <div className="whitespace-pre-wrap">{message.content}</div>
            </div>
          </div>
        ))}
        <div ref={messagesEndRef} />
      </div>

      <form onSubmit={handleSubmit} className="p-4 border-t">
        <div className="flex space-x-2">
          <input
            type="text"
            value={newMessage}
            onChange={(e) => setNewMessage(e.target.value)}
            className="flex-1 input-primary"
            placeholder="输入消息..."
          />
          <button type="submit" className="btn-primary">
            发送
          </button>
        </div>
      </form>
    </div>
  )
}

2.2咨询预约组件

// src/components/consultation/BookingForm.tsx
'use client'

import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { Card } from '@/components/ui/Card'

export function BookingForm() {
  const router = useRouter()
  const [loading, setLoading] = useState(false)
  const [formData, setFormData] = useState({
    topic: '',
    description: '',
    preferredTime: '',
  })

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    setLoading(true)

    try {
      const response = await fetch('/api/consultation/book', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(formData),
      })

      if (!response.ok) throw new Error('预约失败')

      const data = await response.json()
      router.push(`/consultation/${data.id}`)
    } catch (error) {
      console.error(error)
      alert('预约失败,请重试')
    } finally {
      setLoading(false)
    }
  }

  return (
    <Card className="p-6">
      <h2 className="text-2xl font-bold mb-6">预约咨询</h2>
      <form onSubmit={handleSubmit} className="space-y-4">
        <div>
          <label
            htmlFor="topic"
            className="block text-sm font-medium text-gray-700"
          >
            咨询主题
          </label>
          <input
            type="text"
            id="topic"
            value={formData.topic}
            onChange={(e) =>
              setFormData((prev) => ({ ...prev, topic: e.target.value }))
            }
            className="mt-1 input-primary"
            required
          />
        </div>

        <div>
          <label
            htmlFor="description"
            className="block text-sm font-medium text-gray-700"
          >
            问题描述
          </label>
          <textarea
            id="description"
            value={formData.description}
            onChange={(e) =>
              setFormData((prev) => ({ ...prev, description: e.target.value }))
            }
            rows={4}
            className="mt-1 input-primary"
            required
          />
        </div>

        <div>
          <label
            htmlFor="preferredTime"
            className="block text-sm font-medium text-gray-700"
          >
            期望咨询时间
          </label>
          <input
            type="datetime-local"
            id="preferredTime"
            value={formData.preferredTime}
            onChange={(e) =>
              setFormData((prev) => ({ ...prev, preferredTime: e.target.value }))
            }
            className="mt-1 input-primary"
            required
          />
        </div>

        <button
          type="submit"
          disabled={loading}
          className="w-full btn-primary"
        >
          {loading ? '提交中...' : '提交预约'}
        </button>
      </form>
    </Card>
  )
}

3. 咨询管理功能

3.1 咨询列表组件

// src/components/consultation/ConsultationList.tsx
'use client'

import { useEffect, useState } from 'react'
import Link from 'next/link'
import { format } from 'date-fns'
import { Card } from '@/components/ui/Card'

interface Consultation {
  id: string
  topic: string
  status: string
  startTime: string
  counselorName?: string
}

export function ConsultationList() {
  const [consultations, setConsultations] = useState<Consultation[]>([])
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    fetch('/api/consultation')
      .then((res) => res.json())
      .then(setConsultations)
      .catch(console.error)
      .finally(() => setLoading(false))
  }, [])

  if (loading) return <div>加载中...</div>

  return (
    <div className="space-y-4">
      {consultations.map((consultation) => (
        <Card key={consultation.id} className="p-4">
          <div className="flex justify-between items-center">
            <div>
              <h3 className="text-lg font-medium">{consultation.topic}</h3>
              <p className="text-sm text-gray-500">
                {format(new Date(consultation.startTime), 'yyyy-MM-dd HH:mm')}
              </p>
              {consultation.counselorName && (
                <p className="text-sm text-gray-600">
                  咨询师:{consultation.counselorName}
                </p>
              )}
            </div>
            <div className="flex items-center space-x-4">
              <span
                className={`px-2 py-1 text-sm rounded ${
                  consultation.status === 'PENDING'
                    ? 'bg-yellow-100 text-yellow-800'
                    : consultation.status === 'ONGOING'
                    ? 'bg-green-100 text-green-800'
                    : 'bg-gray-100 text-gray-800'
                }`}
              >
                {consultation.status === 'PENDING'
                  ? '待确认'
                  : consultation.status === 'ONGOING'
                  ? '进行中'
                  : '已完成'}
              </span>
              <Link
                href={`/consultation/${consultation.id}`}
                className="text-primary-600 hover:text-primary-700"
              >
                查看详情
              </Link>
            </div>
          </div>
        </Card>
      ))}
    </div>
  )
}

3.2 咨询详情组件

// src/components/consultation/ConsultationDetail.tsx
'use client'

import { useEffect, useState } from 'react'
import { format } from 'date-fns'
import { Card } from '@/components/ui/Card'
import { ChatRoom } from './ChatRoom'

interface ConsultationDetail {
  id: string
  topic: string
  description: string
  status: string
  startTime: string
  endTime?: string
  counselorName?: string
  userId: string
  counselorId?: string
}

interface ConsultationDetailProps {
  id: string
}

export function ConsultationDetail({ id }: ConsultationDetailProps) {
  const [consultation, setConsultation] = useState<ConsultationDetail | null>(null)
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    fetch(`/api/consultation/${id}`)
      .then((res) => res.json())
      .then(setConsultation)
      .catch(console.error)
      .finally(() => setLoading(false))
  }, [id])

  if (loading) return <div>加载中...</div>
  if (!consultation) return <div>未找到咨询记录</div>

  return (
    <div className="space-y-6">
      <Card className="p-6">
        <div className="mb-6">
          <h2 className="text-2xl font-bold mb-2">{consultation.topic}</h2>
          <div className="text-sm text-gray-500">
            <p>状态:{consultation.status}</p>
            <p>
              开始时间:
              {format(new Date(consultation.startTime), 'yyyy-MM-dd HH:mm')}
            </p>
            {consultation.counselorName && (
              <p>咨询师:{consultation.counselorName}</p>
            )}
          </div>
        </div>

        <div className="mb-6">
          <h3 className="text-lg font-medium mb-2">问题描述</h3>
          <p className="text-gray-700 whitespace-pre-wrap">
            {consultation.description}
          </p>
        </div>
      </Card>

      {consultation.status === 'ONGOING' && (
        <Card className="p-6">
          <ChatRoom consultationId={id} />
        </Card>
      )}
    </div>
  )
}

4. API路由实现

4.1 WebSocket API

// src/app/api/socketio/route.ts
import { NextResponse } from 'next/server'
import { initSocket } from '@/lib/socket'

export function GET(req: Request, res: NextApiResponseWithSocket) {
  try {
    const io = initSocket(req, res)
    return NextResponse.json({ success: true })
  } catch (error) {
    console.error('WebSocket initialization failed:', error)
    return NextResponse.json(
      { error: 'WebSocket initialization failed' },
      { status: 500 }
    )
  }
}

4.2 咨询API

// src/app/api/consultation/book/route.ts
import { NextResponse } from 'next/server'
import { getServerSession } from 'next-auth/next'
import { prisma } from '@/lib/prisma'

export async function POST(req: Request) {
  const session = await getServerSession()
  if (!session) {
    return NextResponse.json({ error: '未登录' }, { status: 401 })
  }

  try {
    const { topic, description, preferredTime } = await req.json()

    const consultation = await prisma.consultation.create({
      data: {
        userId: session.user.id,
        topic,
        description,
        startTime: new Date(preferredTime),
        status: 'PENDING',
      },
    })

    return NextResponse.json(consultation)
  } catch (error) {
    console.error(error)
    return NextResponse.json(
      { error: '创建咨询失败' },
      { status: 500 }
    )
  }
}

// src/app/api/consultation/message/route.ts
export async function POST(req: Request) {
  const session = await getServerSession()
  if (!session) {
    return NextResponse.json({ error: '未登录' }, { status: 401 })
  }

  try {
    const { content, consultationId } = await req.json()

    const message = await prisma.message.create({
      data: {
        content,
        consultationId,
        senderId: session.user.id,
      },
    })

    return NextResponse.json(message)
  } catch (error) {
    console.error(error)
    return NextResponse.json(
      { error: '发送消息失败' },
      { status: 500 }
    )
  }
}

5. 页面路由实现

5.1 咨询列表页面

// src/app/consultation/page.tsx
import { ConsultationList } from '@/components/consultation/ConsultationList'
import { BookingForm } from '@/components/consultation/BookingForm'

export default function ConsultationPage() {
  return (
    <div className="space-y-8">
      <h1 className="text-3xl font-bold">在线咨询</h1>
      
      <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
        <div>
          <h2 className="text-xl font-semibold mb-4">我的咨询</h2>
          <ConsultationList />
        </div>
        
        <div>
          <BookingForm />
        </div>
      </div>
    </div>
  )
}

5.2 咨询详情页面

// src/app/consultation/[id]/page.tsx
import { ConsultationDetail } from '@/components/consultation/ConsultationDetail'

interface ConsultationPageProps {
  params: {
    id: string
  }
}

export default function ConsultationPage({ params }: ConsultationPageProps) {
  return (
    <div className="space-y-8">
      <h1 className="text-3xl font-bold">咨询详情</h1>
      <ConsultationDetail id={params.id} />
    </div>
  )
}

6. 下一步计划

  • 实现社区功能
  • 添加数据统计
  • 优化用户界面
  • 完善错误处理

相关文章:

  • Keil5下载教程及安装教程(附安装包)
  • 小说文本分析工具:基于streamlit实现的文本分析
  • Python依赖包迁移到断网环境安装
  • 【GPT入门】第22课 langchain LCEL介绍
  • 基于SpringBoot和Thymeleaf的仿商城系统开发与设计
  • HTB 学习笔记 【中/英】《前端 vs. 后端》P3
  • Qt程序基于共享内存读写CodeSys的变量
  • MySQL面试题
  • C++学习之动态数组和链表
  • 【SpringMVC】常用注解:@SessionAttributes
  • 阿里百炼Spring AI Alibaba
  • Windows安装Apache Maven 3.9.9
  • 手机验证码
  • 组合 力扣77
  • 3.14-进程间通信
  • LeetCode 第8题:字符串转换整数 (atoi)
  • 【最后203篇系列】016 Q201架构思考
  • vue 导航跳转created不执行,页面不刷新的解决办法
  • Web自动化测试框架
  • 虚拟电商-数据库分库分表(二)
  • 全文丨中华人民共和国传染病防治法
  • 体重管理门诊来了,瘦不下来的我们有救了?|健康有方FM
  • 科学家为AI模型设置“防火墙”,以防止被不法分子滥用
  • 国务院安委会对辽宁辽阳一饭店重大火灾事故查处挂牌督办
  • 中国体育报关注徐梦桃、王曼昱、盛李豪等获评全国先进工作者:为建设体育强国再立新功
  • 一季度我国服务进出口总额19741.8亿元,同比增长8.7%