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:
+59
-120
@@ -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>
|
||||
|
||||
+79
-49
@@ -1,84 +1,114 @@
|
||||
"use client"
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { EmptyState } from "@/components/ui/empty-state"
|
||||
import { listChatSessions } from "@/lib/api"
|
||||
import { getPlayerById, listOrders } from "@/lib/api"
|
||||
import { isActiveOrder } from "@/lib/domain/order-filters"
|
||||
import type { UserRole } from "@/lib/types"
|
||||
import { useAuthStore } from "@/store/auth"
|
||||
import { MessageSquare } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
type ChatEntry = {
|
||||
orderId: string
|
||||
targetUserId: string
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
function orderRole(role: UserRole): "consumer" | "player" | undefined {
|
||||
if (role === "consumer" || role === "player") return role
|
||||
return undefined
|
||||
}
|
||||
|
||||
export default function ChatListPage() {
|
||||
const [sessions, setSessions] = useState<Awaited<ReturnType<typeof listChatSessions>>>([])
|
||||
const userId = useAuthStore((state) => state.user?.id)
|
||||
const currentRole = useAuthStore((state) => state.currentRole)
|
||||
const role = orderRole(currentRole)
|
||||
const [entries, setEntries] = useState<ChatEntry[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
void (async () => {
|
||||
if (!role) {
|
||||
setEntries([])
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await Promise.resolve(listChatSessions())
|
||||
if (!cancelled) setSessions(result)
|
||||
const orders = (await listOrders({ role })).filter((order) => isActiveOrder(order.status))
|
||||
const nextEntries = await Promise.all(
|
||||
orders.map(async (order) => {
|
||||
if (role === "consumer") {
|
||||
const player = await getPlayerById(String(order.playerId))
|
||||
if (!player) return null
|
||||
return {
|
||||
orderId: String(order.id),
|
||||
targetUserId: player.user.id,
|
||||
title: player.user.nickname,
|
||||
description: order.service.title,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
orderId: String(order.id),
|
||||
targetUserId: String(order.consumerId),
|
||||
title: `客户 ${order.consumerId}`,
|
||||
description: order.service.title,
|
||||
}
|
||||
}),
|
||||
)
|
||||
if (!cancelled)
|
||||
setEntries(nextEntries.filter((entry): entry is ChatEntry => entry !== null))
|
||||
} catch {
|
||||
if (!cancelled) setSessions([])
|
||||
if (!cancelled) setEntries([])
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
}, [role])
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-2xl px-4 py-8 space-y-6">
|
||||
<div className="container mx-auto max-w-2xl space-y-6 px-4 py-8">
|
||||
<h1 className="text-2xl font-bold">消息</h1>
|
||||
|
||||
{sessions.length > 0 ? (
|
||||
{entries.length > 0 ? (
|
||||
<div className="overflow-hidden rounded-xl border border-border/80 bg-card shadow-sm">
|
||||
{sessions.map((session) => {
|
||||
const other =
|
||||
session.participants.find((participant) => participant.id !== userId) ??
|
||||
session.participants[0]
|
||||
return (
|
||||
<Link
|
||||
key={session.id}
|
||||
href={`/chat/${session.id}`}
|
||||
className="block border-b border-border/60 transition-colors last:border-0 hover:bg-muted/10"
|
||||
>
|
||||
<div className="flex items-center gap-3 p-4">
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarImage src={other.avatar} />
|
||||
<AvatarFallback>{other.nickname[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{other.nickname}</span>
|
||||
<Badge
|
||||
variant={session.type === "order" ? "info" : "neutral"}
|
||||
className="text-[10px] px-1.5 py-0 font-normal"
|
||||
>
|
||||
{session.type === "order" ? "订单" : "咨询"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate">{session.lastMessage}</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1 shrink-0">
|
||||
{session.unreadCount > 0 && (
|
||||
<Badge className="h-4 min-w-4 px-1 flex items-center justify-center rounded-full text-[10px]">
|
||||
{session.unreadCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{entries.map((entry) => (
|
||||
<Link
|
||||
key={entry.orderId}
|
||||
href={`/chat/${entry.targetUserId}?orderId=${entry.orderId}`}
|
||||
className="block border-b border-border/60 transition-colors last:border-0 hover:bg-muted/10"
|
||||
>
|
||||
<div className="flex items-center gap-3 p-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{entry.title}</span>
|
||||
<Badge variant="info" className="px-1.5 py-0 text-[10px] font-normal">
|
||||
订单
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="truncate text-xs text-muted-foreground">{entry.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="暂无消息"
|
||||
description="订单沟通和咨询会话会显示在这里。"
|
||||
title={loading ? "消息加载中" : "暂无消息"}
|
||||
description="进行中的订单沟通会显示在这里。"
|
||||
icon={MessageSquare}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user