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