feat(chat): implement WebSocket chat client with useChatSocket hook

Replace the stubbed chat API with a real WebSocket hook that
connects to /ws/chat and supports DM creation, messaging,
session join/leave, and history retrieval. Keep REST stub
functions for backward compatibility with existing pages
that require listChatSessions/getChatSessionById imports.
This commit is contained in:
zetaloop
2026-05-01 04:25:56 +08:00
parent 1f20198f23
commit a3f0b49112
8 changed files with 426 additions and 215 deletions
+59 -120
View File
@@ -1,14 +1,12 @@
"use client"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
import { EmptyState } from "@/components/ui/empty-state"
import { Input } from "@/components/ui/input"
import { ScrollArea } from "@/components/ui/scroll-area"
import { getChatSessionById, listChatMessages } from "@/lib/api"
import { sendImageMessage, sendTextMessage } from "@/lib/api/chat"
import { uploadFile } from "@/lib/api"
import { useChatSocket } from "@/lib/hooks/use-chat-socket"
import { notifyInfo } from "@/lib/toast"
import { cn } from "@/lib/utils"
import { useAuthStore } from "@/store/auth"
@@ -18,61 +16,33 @@ import Link from "next/link"
import { use, useEffect, useRef, useState } from "react"
export default function ChatDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params)
const [loading, setLoading] = useState(true)
const [session, setSession] = useState<
Awaited<ReturnType<typeof getChatSessionById>> | undefined
>(undefined)
const [messages, setMessages] = useState<Awaited<ReturnType<typeof listChatMessages>>>([])
const { id: targetUserId } = use(params)
const [input, setInput] = useState("")
const [uploading, setUploading] = useState(false)
const imageInputRef = useRef<HTMLInputElement>(null)
const { user } = useAuthStore()
const {
connected,
sessionId,
messages,
error,
createDM,
leaveSession,
sendTextMessage,
sendImageMessage,
} = useChatSocket()
useEffect(() => {
let cancelled = false
void Promise.all([
Promise.resolve(getChatSessionById(id)),
Promise.resolve(listChatMessages(id)),
])
.then(([nextSession, nextMessages]) => {
if (cancelled) return
setSession(nextSession)
setMessages(nextMessages)
})
.catch(() => {
if (cancelled) return
setSession(undefined)
setMessages([])
})
.finally(() => {
if (cancelled) return
setLoading(false)
})
if (!connected) return
createDM(targetUserId)
}, [connected, createDM, targetUserId])
return () => {
cancelled = true
}
}, [id])
if (loading) {
return (
<div className="container mx-auto max-w-2xl px-4 py-8">
<EmptyState title="加载中" description="正在读取会话内容..." icon={MessageSquare} />
</div>
)
}
if (!session) {
return (
<div className="container mx-auto max-w-2xl px-4 py-8">
<EmptyState
title="会话不存在"
description="该会话可能已被删除或暂不可访问。"
icon={MessageSquare}
/>
</div>
)
}
useEffect(
() => () => {
if (sessionId) leaveSession(sessionId)
},
[leaveSession, sessionId],
)
if (!user?.id) {
return (
@@ -86,68 +56,47 @@ export default function ChatDetailPage({ params }: { params: Promise<{ id: strin
)
}
const userId = user.id
const isParticipant = session.participants.some((participant) => participant.id === userId)
if (!isParticipant) {
return (
<div className="container mx-auto max-w-2xl px-4 py-8">
<EmptyState
title="无法查看会话"
description="仅会话参与方可查看并发送消息。"
icon={MessageSquare}
/>
</div>
)
}
const other = session.participants.find((p) => p.id !== userId) ?? session.participants[0]
return (
<div className="container mx-auto max-w-2xl px-4 py-8 h-[calc(100vh-3.5rem)] flex flex-col">
<Card className="flex-1 flex flex-col overflow-hidden border-border/80 shadow-sm">
<div className="border-b border-border/60 px-4 py-3 flex items-center gap-3 bg-background">
<div className="container mx-auto flex h-[calc(100vh-3.5rem)] max-w-2xl flex-col px-4 py-8">
<Card className="flex flex-1 flex-col overflow-hidden border-border/80 shadow-sm">
<div className="flex items-center gap-3 border-b border-border/60 bg-background px-4 py-3">
<Link href="/chat" className="text-muted-foreground hover:text-foreground">
<ArrowLeft className="h-5 w-5" />
</Link>
<Avatar className="h-8 w-8">
<AvatarImage src={other.avatar} />
<AvatarFallback>{other.nickname[0]}</AvatarFallback>
</Avatar>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-muted-foreground">
<MessageSquare className="h-4 w-4" />
</div>
<div>
<span className="text-sm font-medium">{other.nickname}</span>
<div className="flex items-center gap-1">
<Badge
variant={session.type === "order" ? "info" : "neutral"}
className="text-[10px] px-1.5 py-0 font-normal"
>
{session.type === "order" ? "订单会话" : "咨询会话"}
</Badge>
</div>
<span className="text-sm font-medium"> {targetUserId}</span>
<p className="text-xs text-muted-foreground">
{connected ? (sessionId ? "已连接" : "正在创建会话") : "连接中"}
</p>
</div>
</div>
<ScrollArea className="flex-1 p-4">
<div className="space-y-4">
{error && <p className="text-center text-xs text-destructive">{error}</p>}
{messages.length === 0 && (
<EmptyState
title="暂无消息"
description="发送第一条消息开始沟通。"
icon={MessageSquare}
/>
)}
{messages.map((msg) => {
if (msg.type === "system") {
return (
<div key={msg.id} className="text-center">
<span className="text-xs text-muted-foreground bg-muted/60 px-2 py-1 rounded-full">
<span className="rounded-full bg-muted/60 px-2 py-1 text-xs text-muted-foreground">
{msg.content}
</span>
</div>
)
}
const isMine = msg.senderId === userId
const sender = session.participants.find(
(participant) => participant.id === msg.senderId,
)
const isMine = msg.senderId === user.id
return (
<div key={msg.id} className={cn("flex gap-2", isMine && "flex-row-reverse")}>
<Avatar className="h-8 w-8 shrink-0">
<AvatarImage src={sender?.avatar} />
<AvatarFallback>{(sender?.nickname ?? "?")[0]}</AvatarFallback>
</Avatar>
<div className={cn("max-w-[70%]", isMine && "text-right")}>
{msg.type === "image" ? (
<Image
@@ -156,7 +105,7 @@ export default function ChatDetailPage({ params }: { params: Promise<{ id: strin
width={256}
height={192}
unoptimized
className="inline-block rounded-lg max-h-48 max-w-64 border"
className="inline-block max-h-48 max-w-64 rounded-lg border"
/>
) : (
<p
@@ -170,7 +119,7 @@ export default function ChatDetailPage({ params }: { params: Promise<{ id: strin
{msg.content}
</p>
)}
<p className="text-[10px] text-muted-foreground mt-1">
<p className="mt-1 text-[10px] text-muted-foreground">
{new Date(msg.createdAt).toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
@@ -183,7 +132,7 @@ export default function ChatDetailPage({ params }: { params: Promise<{ id: strin
</div>
</ScrollArea>
<div className="border-t border-border/60 p-4 bg-background">
<div className="border-t border-border/60 bg-background p-4">
<input
ref={imageInputRef}
type="file"
@@ -193,41 +142,30 @@ export default function ChatDetailPage({ params }: { params: Promise<{ id: strin
const target = event.currentTarget
const file = target.files?.[0]
if (!file) return
const imageUrl = URL.createObjectURL(file)
void Promise.resolve(sendImageMessage(session.id, imageUrl))
.then((result) => {
if (!result.ok) {
notifyInfo(result.error.msg)
return
}
return Promise.resolve(listChatMessages(session.id)).then(setMessages)
})
setUploading(true)
void uploadFile(file, "chat")
.then((imageUrl) => sendImageMessage(imageUrl))
.catch(() => notifyInfo("图片发送失败"))
.finally(() => {
setUploading(false)
target.value = ""
})
}}
/>
<form
className="flex gap-2"
onSubmit={(e) => {
e.preventDefault()
onSubmit={(event) => {
event.preventDefault()
const text = input.trim()
if (!text) return
void Promise.resolve(sendTextMessage(session.id, text)).then((result) => {
if (!result.ok) {
notifyInfo(result.error.msg)
return
}
setInput("")
return Promise.resolve(listChatMessages(session.id)).then(setMessages)
})
if (!text || !sessionId) return
sendTextMessage(text)
setInput("")
}}
>
<Input
value={input}
onChange={(e) => setInput(e.target.value)}
onChange={(event) => setInput(event.target.value)}
placeholder="输入消息..."
className="flex-1 border-border/60"
/>
@@ -235,11 +173,12 @@ export default function ChatDetailPage({ params }: { params: Promise<{ id: strin
type="button"
size="icon"
variant="outline"
disabled={!sessionId || uploading}
onClick={() => imageInputRef.current?.click()}
>
<ImagePlus className="h-4 w-4" />
</Button>
<Button type="submit" size="icon" disabled={!input.trim()}>
<Button type="submit" size="icon" disabled={!sessionId || !input.trim()}>
<Send className="h-4 w-4" />
</Button>
</form>