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:
+57
-118
@@ -1,14 +1,12 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Card } from "@/components/ui/card"
|
import { Card } from "@/components/ui/card"
|
||||||
import { EmptyState } from "@/components/ui/empty-state"
|
import { EmptyState } from "@/components/ui/empty-state"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
import { getChatSessionById, listChatMessages } from "@/lib/api"
|
import { uploadFile } from "@/lib/api"
|
||||||
import { sendImageMessage, sendTextMessage } from "@/lib/api/chat"
|
import { useChatSocket } from "@/lib/hooks/use-chat-socket"
|
||||||
import { notifyInfo } from "@/lib/toast"
|
import { notifyInfo } from "@/lib/toast"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { useAuthStore } from "@/store/auth"
|
import { useAuthStore } from "@/store/auth"
|
||||||
@@ -18,61 +16,33 @@ import Link from "next/link"
|
|||||||
import { use, useEffect, useRef, useState } from "react"
|
import { use, useEffect, useRef, useState } from "react"
|
||||||
|
|
||||||
export default function ChatDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
export default function ChatDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = use(params)
|
const { id: targetUserId } = 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 [input, setInput] = useState("")
|
const [input, setInput] = useState("")
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
const imageInputRef = useRef<HTMLInputElement>(null)
|
const imageInputRef = useRef<HTMLInputElement>(null)
|
||||||
const { user } = useAuthStore()
|
const { user } = useAuthStore()
|
||||||
|
const {
|
||||||
|
connected,
|
||||||
|
sessionId,
|
||||||
|
messages,
|
||||||
|
error,
|
||||||
|
createDM,
|
||||||
|
leaveSession,
|
||||||
|
sendTextMessage,
|
||||||
|
sendImageMessage,
|
||||||
|
} = useChatSocket()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
if (!connected) return
|
||||||
void Promise.all([
|
createDM(targetUserId)
|
||||||
Promise.resolve(getChatSessionById(id)),
|
}, [connected, createDM, targetUserId])
|
||||||
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)
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => {
|
useEffect(
|
||||||
cancelled = true
|
() => () => {
|
||||||
}
|
if (sessionId) leaveSession(sessionId)
|
||||||
}, [id])
|
},
|
||||||
|
[leaveSession, sessionId],
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user?.id) {
|
if (!user?.id) {
|
||||||
return (
|
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 (
|
return (
|
||||||
<div className="container mx-auto max-w-2xl px-4 py-8">
|
<div className="container mx-auto flex h-[calc(100vh-3.5rem)] max-w-2xl flex-col px-4 py-8">
|
||||||
<EmptyState
|
<Card className="flex flex-1 flex-col overflow-hidden border-border/80 shadow-sm">
|
||||||
title="无法查看会话"
|
<div className="flex items-center gap-3 border-b border-border/60 bg-background px-4 py-3">
|
||||||
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">
|
|
||||||
<Link href="/chat" className="text-muted-foreground hover:text-foreground">
|
<Link href="/chat" className="text-muted-foreground hover:text-foreground">
|
||||||
<ArrowLeft className="h-5 w-5" />
|
<ArrowLeft className="h-5 w-5" />
|
||||||
</Link>
|
</Link>
|
||||||
<Avatar className="h-8 w-8">
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||||
<AvatarImage src={other.avatar} />
|
<MessageSquare className="h-4 w-4" />
|
||||||
<AvatarFallback>{other.nickname[0]}</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<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>
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium">会话 {targetUserId}</span>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{connected ? (sessionId ? "已连接" : "正在创建会话") : "连接中"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ScrollArea className="flex-1 p-4">
|
<ScrollArea className="flex-1 p-4">
|
||||||
<div className="space-y-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) => {
|
{messages.map((msg) => {
|
||||||
if (msg.type === "system") {
|
if (msg.type === "system") {
|
||||||
return (
|
return (
|
||||||
<div key={msg.id} className="text-center">
|
<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}
|
{msg.content}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const isMine = msg.senderId === userId
|
const isMine = msg.senderId === user.id
|
||||||
const sender = session.participants.find(
|
|
||||||
(participant) => participant.id === msg.senderId,
|
|
||||||
)
|
|
||||||
return (
|
return (
|
||||||
<div key={msg.id} className={cn("flex gap-2", isMine && "flex-row-reverse")}>
|
<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")}>
|
<div className={cn("max-w-[70%]", isMine && "text-right")}>
|
||||||
{msg.type === "image" ? (
|
{msg.type === "image" ? (
|
||||||
<Image
|
<Image
|
||||||
@@ -156,7 +105,7 @@ export default function ChatDetailPage({ params }: { params: Promise<{ id: strin
|
|||||||
width={256}
|
width={256}
|
||||||
height={192}
|
height={192}
|
||||||
unoptimized
|
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
|
<p
|
||||||
@@ -170,7 +119,7 @@ export default function ChatDetailPage({ params }: { params: Promise<{ id: strin
|
|||||||
{msg.content}
|
{msg.content}
|
||||||
</p>
|
</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", {
|
{new Date(msg.createdAt).toLocaleTimeString("zh-CN", {
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
@@ -183,7 +132,7 @@ export default function ChatDetailPage({ params }: { params: Promise<{ id: strin
|
|||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
<div className="border-t border-border/60 p-4 bg-background">
|
<div className="border-t border-border/60 bg-background p-4">
|
||||||
<input
|
<input
|
||||||
ref={imageInputRef}
|
ref={imageInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -193,41 +142,30 @@ export default function ChatDetailPage({ params }: { params: Promise<{ id: strin
|
|||||||
const target = event.currentTarget
|
const target = event.currentTarget
|
||||||
const file = target.files?.[0]
|
const file = target.files?.[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
const imageUrl = URL.createObjectURL(file)
|
|
||||||
|
|
||||||
void Promise.resolve(sendImageMessage(session.id, imageUrl))
|
setUploading(true)
|
||||||
.then((result) => {
|
void uploadFile(file, "chat")
|
||||||
if (!result.ok) {
|
.then((imageUrl) => sendImageMessage(imageUrl))
|
||||||
notifyInfo(result.error.msg)
|
.catch(() => notifyInfo("图片发送失败"))
|
||||||
return
|
|
||||||
}
|
|
||||||
return Promise.resolve(listChatMessages(session.id)).then(setMessages)
|
|
||||||
})
|
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
setUploading(false)
|
||||||
target.value = ""
|
target.value = ""
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<form
|
<form
|
||||||
className="flex gap-2"
|
className="flex gap-2"
|
||||||
onSubmit={(e) => {
|
onSubmit={(event) => {
|
||||||
e.preventDefault()
|
event.preventDefault()
|
||||||
const text = input.trim()
|
const text = input.trim()
|
||||||
if (!text) return
|
if (!text || !sessionId) return
|
||||||
|
sendTextMessage(text)
|
||||||
void Promise.resolve(sendTextMessage(session.id, text)).then((result) => {
|
|
||||||
if (!result.ok) {
|
|
||||||
notifyInfo(result.error.msg)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setInput("")
|
setInput("")
|
||||||
return Promise.resolve(listChatMessages(session.id)).then(setMessages)
|
|
||||||
})
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(event) => setInput(event.target.value)}
|
||||||
placeholder="输入消息..."
|
placeholder="输入消息..."
|
||||||
className="flex-1 border-border/60"
|
className="flex-1 border-border/60"
|
||||||
/>
|
/>
|
||||||
@@ -235,11 +173,12 @@ export default function ChatDetailPage({ params }: { params: Promise<{ id: strin
|
|||||||
type="button"
|
type="button"
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
disabled={!sessionId || uploading}
|
||||||
onClick={() => imageInputRef.current?.click()}
|
onClick={() => imageInputRef.current?.click()}
|
||||||
>
|
>
|
||||||
<ImagePlus className="h-4 w-4" />
|
<ImagePlus className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" size="icon" disabled={!input.trim()}>
|
<Button type="submit" size="icon" disabled={!sessionId || !input.trim()}>
|
||||||
<Send className="h-4 w-4" />
|
<Send className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
+70
-40
@@ -1,84 +1,114 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { EmptyState } from "@/components/ui/empty-state"
|
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 { useAuthStore } from "@/store/auth"
|
||||||
import { MessageSquare } from "lucide-react"
|
import { MessageSquare } from "lucide-react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useEffect, useState } from "react"
|
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() {
|
export default function ChatListPage() {
|
||||||
const [sessions, setSessions] = useState<Awaited<ReturnType<typeof listChatSessions>>>([])
|
const currentRole = useAuthStore((state) => state.currentRole)
|
||||||
const userId = useAuthStore((state) => state.user?.id)
|
const role = orderRole(currentRole)
|
||||||
|
const [entries, setEntries] = useState<ChatEntry[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
|
if (!role) {
|
||||||
|
setEntries([])
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const result = await Promise.resolve(listChatSessions())
|
const orders = (await listOrders({ role })).filter((order) => isActiveOrder(order.status))
|
||||||
if (!cancelled) setSessions(result)
|
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 {
|
} catch {
|
||||||
if (!cancelled) setSessions([])
|
if (!cancelled) setEntries([])
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [])
|
}, [role])
|
||||||
|
|
||||||
return (
|
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>
|
<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">
|
<div className="overflow-hidden rounded-xl border border-border/80 bg-card shadow-sm">
|
||||||
{sessions.map((session) => {
|
{entries.map((entry) => (
|
||||||
const other =
|
|
||||||
session.participants.find((participant) => participant.id !== userId) ??
|
|
||||||
session.participants[0]
|
|
||||||
return (
|
|
||||||
<Link
|
<Link
|
||||||
key={session.id}
|
key={entry.orderId}
|
||||||
href={`/chat/${session.id}`}
|
href={`/chat/${entry.targetUserId}?orderId=${entry.orderId}`}
|
||||||
className="block border-b border-border/60 transition-colors last:border-0 hover:bg-muted/10"
|
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 items-center gap-3 p-4">
|
||||||
<Avatar className="h-10 w-10">
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||||
<AvatarImage src={other.avatar} />
|
<MessageSquare className="h-4 w-4" />
|
||||||
<AvatarFallback>{other.nickname[0]}</AvatarFallback>
|
</div>
|
||||||
</Avatar>
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-medium">{other.nickname}</span>
|
<span className="text-sm font-medium">{entry.title}</span>
|
||||||
<Badge
|
<Badge variant="info" className="px-1.5 py-0 text-[10px] font-normal">
|
||||||
variant={session.type === "order" ? "info" : "neutral"}
|
订单
|
||||||
className="text-[10px] px-1.5 py-0 font-normal"
|
|
||||||
>
|
|
||||||
{session.type === "order" ? "订单" : "咨询"}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground truncate">{session.lastMessage}</p>
|
<p className="truncate text-xs text-muted-foreground">{entry.description}</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
))}
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="暂无消息"
|
title={loading ? "消息加载中" : "暂无消息"}
|
||||||
description="订单沟通和咨询会话会显示在这里。"
|
description="进行中的订单沟通会显示在这里。"
|
||||||
icon={MessageSquare}
|
icon={MessageSquare}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
import { deny } from "@/lib/decision"
|
|
||||||
import type { ApiDecision } from "@/lib/errors"
|
|
||||||
import type { ChatMessage, ChatSession } from "@/lib/types"
|
|
||||||
|
|
||||||
export type ListChatSessionsOptions = {
|
|
||||||
offset?: number
|
|
||||||
limit?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ListChatMessagesOptions = {
|
|
||||||
offset?: number
|
|
||||||
limit?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const unavailable = "聊天接口暂未开放"
|
|
||||||
|
|
||||||
export async function listChatSessions(_options?: ListChatSessionsOptions): Promise<ChatSession[]> {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getChatSessionById(_sessionId: string): Promise<ChatSession | undefined> {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listChatMessages(
|
|
||||||
_sessionId: string,
|
|
||||||
_options?: ListChatMessagesOptions,
|
|
||||||
): Promise<ChatMessage[]> {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function sendTextMessage(_sessionId: string, _content: string): Promise<ApiDecision> {
|
|
||||||
return deny(404, unavailable)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function sendImageMessage(
|
|
||||||
_sessionId: string,
|
|
||||||
_imageUrl: string,
|
|
||||||
): Promise<ApiDecision> {
|
|
||||||
return deny(404, unavailable)
|
|
||||||
}
|
|
||||||
@@ -12,6 +12,29 @@ function getCookieValue(name: string): string | null {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let csrfReady: Promise<void> | null = null
|
||||||
|
|
||||||
|
async function prepareCsrf() {
|
||||||
|
if (getCookieValue("__Host-XSRF-TOKEN") && getCookieValue("__Host-XSRF-GUARD")) return
|
||||||
|
|
||||||
|
csrfReady ??= fetch("/healthz", {
|
||||||
|
cache: "no-store",
|
||||||
|
credentials: "include",
|
||||||
|
})
|
||||||
|
.then(() => {})
|
||||||
|
.finally(() => {
|
||||||
|
csrfReady = null
|
||||||
|
})
|
||||||
|
await csrfReady
|
||||||
|
|
||||||
|
if (!getCookieValue("__Host-XSRF-TOKEN")) {
|
||||||
|
await fetch("/api/v1/games?offset=0&limit=1", {
|
||||||
|
cache: "no-store",
|
||||||
|
credentials: "include",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function readJsonBody(res: Response): Promise<{ json: unknown | null; text: string }> {
|
async function readJsonBody(res: Response): Promise<{ json: unknown | null; text: string }> {
|
||||||
const text = await res.text()
|
const text = await res.text()
|
||||||
if (!text) return { json: null, text: "" }
|
if (!text) return { json: null, text: "" }
|
||||||
@@ -70,6 +93,8 @@ export async function uploadFile(file: File, type: UploadFileType): Promise<stri
|
|||||||
formData.set("type", type)
|
formData.set("type", type)
|
||||||
formData.set("file", file)
|
formData.set("file", file)
|
||||||
|
|
||||||
|
await prepareCsrf()
|
||||||
|
|
||||||
const headers = new Headers()
|
const headers = new Headers()
|
||||||
const xsrfToken = getCookieValue("__Host-XSRF-TOKEN")
|
const xsrfToken = getCookieValue("__Host-XSRF-TOKEN")
|
||||||
if (xsrfToken) headers.set("XSRF-TOKEN", xsrfToken)
|
if (xsrfToken) headers.set("XSRF-TOKEN", xsrfToken)
|
||||||
@@ -78,6 +103,7 @@ export async function uploadFile(file: File, type: UploadFileType): Promise<stri
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
headers,
|
headers,
|
||||||
body: formData,
|
body: formData,
|
||||||
|
credentials: "include",
|
||||||
})
|
})
|
||||||
|
|
||||||
const { json, text } = await readJsonBody(res)
|
const { json, text } = await readJsonBody(res)
|
||||||
|
|||||||
+31
-4
@@ -60,6 +60,34 @@ function getCookieValue(name: string): string | null {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let csrfReady: Promise<void> | null = null
|
||||||
|
|
||||||
|
function needsCsrf(method: string) {
|
||||||
|
return method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE"
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepareCsrf(path: string) {
|
||||||
|
if (typeof document === "undefined") return
|
||||||
|
if (getCookieValue("__Host-XSRF-TOKEN") && getCookieValue("__Host-XSRF-GUARD")) return
|
||||||
|
|
||||||
|
csrfReady ??= fetch("/healthz", {
|
||||||
|
cache: "no-store",
|
||||||
|
credentials: "include",
|
||||||
|
})
|
||||||
|
.then(() => {})
|
||||||
|
.finally(() => {
|
||||||
|
csrfReady = null
|
||||||
|
})
|
||||||
|
await csrfReady
|
||||||
|
|
||||||
|
if (!getCookieValue("__Host-XSRF-TOKEN") && path !== "/healthz") {
|
||||||
|
await fetch("/api/v1/games?offset=0&limit=1", {
|
||||||
|
cache: "no-store",
|
||||||
|
credentials: "include",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isApiErrorWithMessage(value: unknown): value is { code: number; message: string } {
|
function isApiErrorWithMessage(value: unknown): value is { code: number; message: string } {
|
||||||
if (typeof value !== "object" || value === null) return false
|
if (typeof value !== "object" || value === null) return false
|
||||||
const v = value as { code?: unknown; message?: unknown }
|
const v = value as { code?: unknown; message?: unknown }
|
||||||
@@ -91,10 +119,8 @@ export async function httpJson<T>(path: string, init?: JsonRequestInit): Promise
|
|||||||
}
|
}
|
||||||
|
|
||||||
const method = (rest.method ?? "GET").toUpperCase()
|
const method = (rest.method ?? "GET").toUpperCase()
|
||||||
if (
|
if (needsCsrf(method) && !headers.has("XSRF-TOKEN")) {
|
||||||
(method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE") &&
|
await prepareCsrf(path)
|
||||||
!headers.has("XSRF-TOKEN")
|
|
||||||
) {
|
|
||||||
const xsrfToken = getCookieValue("__Host-XSRF-TOKEN")
|
const xsrfToken = getCookieValue("__Host-XSRF-TOKEN")
|
||||||
if (xsrfToken) headers.set("XSRF-TOKEN", xsrfToken)
|
if (xsrfToken) headers.set("XSRF-TOKEN", xsrfToken)
|
||||||
}
|
}
|
||||||
@@ -103,6 +129,7 @@ export async function httpJson<T>(path: string, init?: JsonRequestInit): Promise
|
|||||||
...rest,
|
...rest,
|
||||||
headers,
|
headers,
|
||||||
body,
|
body,
|
||||||
|
credentials: rest.credentials ?? "include",
|
||||||
})
|
})
|
||||||
|
|
||||||
const { json: data, text } = await readJsonBody(res)
|
const { json: data, text } = await readJsonBody(res)
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
export { login, logout, register, resetPassword } from "./auth"
|
export { login, logout, register, resetPassword } from "./auth"
|
||||||
export { sendForgotPasswordCode } from "./auth-extra"
|
export { sendForgotPasswordCode } from "./auth-extra"
|
||||||
export { getChatSessionById, listChatMessages, listChatSessions } from "./chat"
|
|
||||||
export { requestWithAuth } from "./client"
|
export { requestWithAuth } from "./client"
|
||||||
export { addComment, listCommentsByPost, toggleCommentLike } from "./comments"
|
export { addComment, listCommentsByPost, toggleCommentLike } from "./comments"
|
||||||
export { getDisputeByOrderId, listDisputes } from "./disputes"
|
export { getDisputeByOrderId, listDisputes } from "./disputes"
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useAuthStore } from "@/store/auth"
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
|
|
||||||
|
export type ChatSocketMessage = {
|
||||||
|
id: string
|
||||||
|
sessionId: string
|
||||||
|
senderId: string
|
||||||
|
type: "text" | "image" | "system"
|
||||||
|
content: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type WsEnvelope = {
|
||||||
|
type: string
|
||||||
|
sessionId?: number
|
||||||
|
senderId?: number
|
||||||
|
content?: string
|
||||||
|
msgType?: string
|
||||||
|
data?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChatSocketState = {
|
||||||
|
connected: boolean
|
||||||
|
sessionId: string | null
|
||||||
|
messages: ChatSocketMessage[]
|
||||||
|
error: string | null
|
||||||
|
createDM: (targetUserId: string) => void
|
||||||
|
joinSession: (nextSessionId: string) => void
|
||||||
|
leaveSession: (nextSessionId: string) => void
|
||||||
|
sendTextMessage: (content: string) => void
|
||||||
|
sendImageMessage: (imageUrl: string) => void
|
||||||
|
requestHistory: (nextSessionId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChatSocketUrl() {
|
||||||
|
const configured = process.env.NEXT_PUBLIC_BACKEND_URL
|
||||||
|
const origin =
|
||||||
|
configured ||
|
||||||
|
(process.env.NODE_ENV === "development" ? "http://localhost:18080" : window.location.origin)
|
||||||
|
const url = new URL(origin)
|
||||||
|
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
|
||||||
|
url.pathname = "/ws/chat"
|
||||||
|
url.search = ""
|
||||||
|
return url.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function unixToIso(value: unknown) {
|
||||||
|
if (typeof value !== "number" || value <= 0) return new Date().toISOString()
|
||||||
|
return new Date(value * 1000).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMessage(value: unknown): ChatSocketMessage | null {
|
||||||
|
if (typeof value !== "object" || value === null) return null
|
||||||
|
const item = value as {
|
||||||
|
id?: unknown
|
||||||
|
sessionId?: unknown
|
||||||
|
senderId?: unknown
|
||||||
|
type?: unknown
|
||||||
|
content?: unknown
|
||||||
|
createdAt?: unknown
|
||||||
|
}
|
||||||
|
if (typeof item.content !== "string") return null
|
||||||
|
|
||||||
|
const type = item.type === "image" || item.type === "system" ? item.type : "text"
|
||||||
|
return {
|
||||||
|
id: String(item.id ?? `${item.sessionId ?? ""}-${item.senderId ?? ""}-${item.createdAt ?? ""}`),
|
||||||
|
sessionId: String(item.sessionId ?? ""),
|
||||||
|
senderId: String(item.senderId ?? ""),
|
||||||
|
type,
|
||||||
|
content: item.content,
|
||||||
|
createdAt: unixToIso(item.createdAt),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function liveMessage(msg: WsEnvelope): ChatSocketMessage | null {
|
||||||
|
if (msg.type !== "message" || !msg.content || msg.sessionId === undefined) return null
|
||||||
|
const data = typeof msg.data === "object" && msg.data !== null ? msg.data : {}
|
||||||
|
const messageId = "messageId" in data ? (data as { messageId?: unknown }).messageId : undefined
|
||||||
|
const type = msg.msgType === "image" ? "image" : "text"
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(messageId ?? `${msg.sessionId}-${msg.senderId ?? ""}-${Date.now()}`),
|
||||||
|
sessionId: String(msg.sessionId),
|
||||||
|
senderId: String(msg.senderId ?? ""),
|
||||||
|
type,
|
||||||
|
content: msg.content,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonWithInt64(type: string, fields: Record<string, string | undefined>) {
|
||||||
|
const parts = [`"type":${JSON.stringify(type)}`]
|
||||||
|
for (const [key, value] of Object.entries(fields)) {
|
||||||
|
if (!value) continue
|
||||||
|
parts.push(`"${key}":${value}`)
|
||||||
|
}
|
||||||
|
return `{${parts.join(",")}}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonMessage(sessionId: string, content: string, msgType: "text" | "image") {
|
||||||
|
const payload = JSON.stringify({ type: "message", content, msgType })
|
||||||
|
return payload.replace(/^\{/, `{"sessionId":${sessionId},`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useChatSocket(): ChatSocketState {
|
||||||
|
const wsRef = useRef<WebSocket | null>(null)
|
||||||
|
const [connected, setConnected] = useState(false)
|
||||||
|
const [sessionId, setSessionId] = useState<string | null>(null)
|
||||||
|
const [messages, setMessages] = useState<ChatSocketMessage[]>([])
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const userId = useAuthStore((state) => state.user?.id)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!userId) return
|
||||||
|
|
||||||
|
const ws = new WebSocket(getChatSocketUrl())
|
||||||
|
wsRef.current = ws
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
if (wsRef.current !== ws) return
|
||||||
|
setConnected(true)
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
const msg = JSON.parse(event.data) as WsEnvelope
|
||||||
|
|
||||||
|
if (msg.type === "dm_created" || msg.type === "group_created" || msg.type === "joined") {
|
||||||
|
if (msg.sessionId !== undefined) setSessionId(String(msg.sessionId))
|
||||||
|
if (msg.sessionId !== undefined)
|
||||||
|
ws.send(jsonWithInt64("history", { sessionId: String(msg.sessionId) }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === "history") {
|
||||||
|
const nextMessages = Array.isArray(msg.data)
|
||||||
|
? msg.data
|
||||||
|
.map(normalizeMessage)
|
||||||
|
.filter((item): item is ChatSocketMessage => item !== null)
|
||||||
|
: []
|
||||||
|
setMessages(nextMessages)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === "error") {
|
||||||
|
setError(msg.content ?? "聊天服务错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextMessage = liveMessage(msg)
|
||||||
|
if (nextMessage) setMessages((prev) => [...prev, nextMessage])
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
if (wsRef.current !== ws) return
|
||||||
|
setConnected(false)
|
||||||
|
wsRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
if (wsRef.current !== ws) return
|
||||||
|
setError("聊天连接失败")
|
||||||
|
ws.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (wsRef.current === ws) wsRef.current = null
|
||||||
|
ws.close()
|
||||||
|
}
|
||||||
|
}, [userId])
|
||||||
|
|
||||||
|
const sendRaw = useCallback((payload: string) => {
|
||||||
|
const ws = wsRef.current
|
||||||
|
if (ws?.readyState === WebSocket.OPEN) ws.send(payload)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const createDM = useCallback(
|
||||||
|
(targetUserId: string) => sendRaw(jsonWithInt64("create_dm", { targetId: targetUserId })),
|
||||||
|
[sendRaw],
|
||||||
|
)
|
||||||
|
|
||||||
|
const joinSession = useCallback(
|
||||||
|
(nextSessionId: string) => sendRaw(jsonWithInt64("join", { sessionId: nextSessionId })),
|
||||||
|
[sendRaw],
|
||||||
|
)
|
||||||
|
|
||||||
|
const leaveSession = useCallback(
|
||||||
|
(nextSessionId: string) => sendRaw(jsonWithInt64("leave", { sessionId: nextSessionId })),
|
||||||
|
[sendRaw],
|
||||||
|
)
|
||||||
|
|
||||||
|
const requestHistory = useCallback(
|
||||||
|
(nextSessionId: string) => sendRaw(jsonWithInt64("history", { sessionId: nextSessionId })),
|
||||||
|
[sendRaw],
|
||||||
|
)
|
||||||
|
|
||||||
|
const sendTextMessage = useCallback(
|
||||||
|
(content: string) => {
|
||||||
|
if (!sessionId) return
|
||||||
|
sendRaw(jsonMessage(sessionId, content, "text"))
|
||||||
|
},
|
||||||
|
[sendRaw, sessionId],
|
||||||
|
)
|
||||||
|
|
||||||
|
const sendImageMessage = useCallback(
|
||||||
|
(imageUrl: string) => {
|
||||||
|
if (!sessionId) return
|
||||||
|
sendRaw(jsonMessage(sessionId, imageUrl, "image"))
|
||||||
|
},
|
||||||
|
[sendRaw, sessionId],
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
connected,
|
||||||
|
sessionId,
|
||||||
|
messages,
|
||||||
|
error,
|
||||||
|
createDM,
|
||||||
|
joinSession,
|
||||||
|
leaveSession,
|
||||||
|
sendTextMessage,
|
||||||
|
sendImageMessage,
|
||||||
|
requestHistory,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,10 @@ const nextConfig: NextConfig = {
|
|||||||
source: "/api/:path*",
|
source: "/api/:path*",
|
||||||
destination: `${backendUrl}/api/:path*`,
|
destination: `${backendUrl}/api/:path*`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
source: "/healthz",
|
||||||
|
destination: `${backendUrl}/healthz`,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user