From a3f0b49112f42d5c207b0f9b13a07b2a65d5b9b6 Mon Sep 17 00:00:00 2001 From: zetaloop Date: Fri, 1 May 2026 04:25:56 +0800 Subject: [PATCH] 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. --- app/(order)/chat/[id]/page.tsx | 179 +++++++++----------------- app/(order)/chat/page.tsx | 128 ++++++++++++------- lib/api/chat.ts | 41 ------ lib/api/files.ts | 26 ++++ lib/api/http.ts | 35 ++++- lib/api/index.ts | 1 - lib/hooks/use-chat-socket.ts | 227 +++++++++++++++++++++++++++++++++ next.config.ts | 4 + 8 files changed, 426 insertions(+), 215 deletions(-) delete mode 100644 lib/api/chat.ts create mode 100644 lib/hooks/use-chat-socket.ts diff --git a/app/(order)/chat/[id]/page.tsx b/app/(order)/chat/[id]/page.tsx index 2bdd69c..e1e4e39 100644 --- a/app/(order)/chat/[id]/page.tsx +++ b/app/(order)/chat/[id]/page.tsx @@ -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> | undefined - >(undefined) - const [messages, setMessages] = useState>>([]) + const { id: targetUserId } = use(params) const [input, setInput] = useState("") + const [uploading, setUploading] = useState(false) const imageInputRef = useRef(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 ( -
- -
- ) - } - - if (!session) { - return ( -
- -
- ) - } + 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 ( -
- -
- ) - } - - const other = session.participants.find((p) => p.id !== userId) ?? session.participants[0] - return ( -
- -
+
+ +
- - - {other.nickname[0]} - +
+ +
- {other.nickname} -
- - {session.type === "order" ? "订单会话" : "咨询会话"} - -
+ 会话 {targetUserId} +

+ {connected ? (sessionId ? "已连接" : "正在创建会话") : "连接中"} +

+ {error &&

{error}

} + {messages.length === 0 && ( + + )} {messages.map((msg) => { if (msg.type === "system") { return (
- + {msg.content}
) } - const isMine = msg.senderId === userId - const sender = session.participants.find( - (participant) => participant.id === msg.senderId, - ) + const isMine = msg.senderId === user.id return (
- - - {(sender?.nickname ?? "?")[0]} -
{msg.type === "image" ? ( ) : (

)} -

+

{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

-
+
{ - 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 = "" }) }} />
{ - 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("") }} > 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()} > - diff --git a/app/(order)/chat/page.tsx b/app/(order)/chat/page.tsx index 4de9405..d39ed8b 100644 --- a/app/(order)/chat/page.tsx +++ b/app/(order)/chat/page.tsx @@ -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>>([]) - const userId = useAuthStore((state) => state.user?.id) + const currentRole = useAuthStore((state) => state.currentRole) + const role = orderRole(currentRole) + const [entries, setEntries] = useState([]) + 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 ( -
+

消息

- {sessions.length > 0 ? ( + {entries.length > 0 ? (
- {sessions.map((session) => { - const other = - session.participants.find((participant) => participant.id !== userId) ?? - session.participants[0] - return ( - -
- - - {other.nickname[0]} - -
-
- {other.nickname} - - {session.type === "order" ? "订单" : "咨询"} - -
-

{session.lastMessage}

-
-
- {session.unreadCount > 0 && ( - - {session.unreadCount} - - )} -
+ {entries.map((entry) => ( + +
+
+
- - ) - })} +
+
+ {entry.title} + + 订单 + +
+

{entry.description}

+
+
+ + ))}
) : ( )} diff --git a/lib/api/chat.ts b/lib/api/chat.ts deleted file mode 100644 index 1eb3f69..0000000 --- a/lib/api/chat.ts +++ /dev/null @@ -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 { - return [] -} - -export async function getChatSessionById(_sessionId: string): Promise { - return undefined -} - -export async function listChatMessages( - _sessionId: string, - _options?: ListChatMessagesOptions, -): Promise { - return [] -} - -export async function sendTextMessage(_sessionId: string, _content: string): Promise { - return deny(404, unavailable) -} - -export async function sendImageMessage( - _sessionId: string, - _imageUrl: string, -): Promise { - return deny(404, unavailable) -} diff --git a/lib/api/files.ts b/lib/api/files.ts index a04c984..9c9bbfa 100644 --- a/lib/api/files.ts +++ b/lib/api/files.ts @@ -12,6 +12,29 @@ function getCookieValue(name: string): string | null { return null } +let csrfReady: Promise | 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 }> { const text = await res.text() if (!text) return { json: null, text: "" } @@ -70,6 +93,8 @@ export async function uploadFile(file: File, type: UploadFileType): Promise | 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 } { if (typeof value !== "object" || value === null) return false const v = value as { code?: unknown; message?: unknown } @@ -91,10 +119,8 @@ export async function httpJson(path: string, init?: JsonRequestInit): Promise } const method = (rest.method ?? "GET").toUpperCase() - if ( - (method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE") && - !headers.has("XSRF-TOKEN") - ) { + if (needsCsrf(method) && !headers.has("XSRF-TOKEN")) { + await prepareCsrf(path) const xsrfToken = getCookieValue("__Host-XSRF-TOKEN") if (xsrfToken) headers.set("XSRF-TOKEN", xsrfToken) } @@ -103,6 +129,7 @@ export async function httpJson(path: string, init?: JsonRequestInit): Promise ...rest, headers, body, + credentials: rest.credentials ?? "include", }) const { json: data, text } = await readJsonBody(res) diff --git a/lib/api/index.ts b/lib/api/index.ts index f8947fa..c1632f8 100644 --- a/lib/api/index.ts +++ b/lib/api/index.ts @@ -1,6 +1,5 @@ export { login, logout, register, resetPassword } from "./auth" export { sendForgotPasswordCode } from "./auth-extra" -export { getChatSessionById, listChatMessages, listChatSessions } from "./chat" export { requestWithAuth } from "./client" export { addComment, listCommentsByPost, toggleCommentLike } from "./comments" export { getDisputeByOrderId, listDisputes } from "./disputes" diff --git a/lib/hooks/use-chat-socket.ts b/lib/hooks/use-chat-socket.ts new file mode 100644 index 0000000..c27041b --- /dev/null +++ b/lib/hooks/use-chat-socket.ts @@ -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) { + 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(null) + const [connected, setConnected] = useState(false) + const [sessionId, setSessionId] = useState(null) + const [messages, setMessages] = useState([]) + const [error, setError] = useState(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, + } +} diff --git a/next.config.ts b/next.config.ts index 95e4e3f..9eb806a 100644 --- a/next.config.ts +++ b/next.config.ts @@ -14,6 +14,10 @@ const nextConfig: NextConfig = { source: "/api/:path*", destination: `${backendUrl}/api/:path*`, }, + { + source: "/healthz", + destination: `${backendUrl}/healthz`, + }, ] }, }