From e2671638e66e76aca0ef9f0be7b4d159964d7869 Mon Sep 17 00:00:00 2001 From: zetaloop Date: Sun, 1 Mar 2026 17:03:30 +0800 Subject: [PATCH] feat(chat): migrate chat to backend API --- app/(order)/chat/[id]/page.tsx | 82 ++++++++++++++----- app/(order)/chat/page.tsx | 22 ++++- app/(order)/order/[id]/page.tsx | 24 +++++- app/(order)/orders/page.tsx | 24 +++++- components/order-actions.tsx | 6 +- lib/api/chat.ts | 141 ++++++++++++++++++++++---------- 6 files changed, 223 insertions(+), 76 deletions(-) diff --git a/app/(order)/chat/[id]/page.tsx b/app/(order)/chat/[id]/page.tsx index 2f14909..1919bc2 100644 --- a/app/(order)/chat/[id]/page.tsx +++ b/app/(order)/chat/[id]/page.tsx @@ -6,32 +6,57 @@ import { Button } from "@/components/ui/button" import { Card } from "@/components/ui/card" 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 { notifyInfo } from "@/lib/toast" import { cn } from "@/lib/utils" import { useAuthStore } from "@/store/auth" -import { useChatStore } from "@/store/chat" import { ArrowLeft, ImagePlus, Lock, Send } from "lucide-react" import Image from "next/image" import Link from "next/link" -import { use, useMemo, useRef, useState } from "react" +import { use, useEffect, useRef, useState } from "react" export default function ChatDetailPage({ params }: { params: Promise<{ id: string }> }) { const { id } = use(params) - const session = useChatStore((state) => state.sessions.find((item) => item.id === id)) - const allMessages = useChatStore((state) => state.messages) - // Filter logic runs here via useMemo rather than inside the Zustand selector. - // useSyncExternalStore requires a stable snapshot reference on each render. - // Inline filter in a selector creates a new array per call and can trigger - // infinite re-render loops in Zustand v5 (pmndrs/zustand#1936). - const messages = useMemo( - () => allMessages.filter((item) => item.sessionId === id), - [allMessages, id], - ) + const [loading, setLoading] = useState(true) + const [session, setSession] = useState< + Awaited> | undefined + >(undefined) + const [messages, setMessages] = useState>>([]) const [input, setInput] = useState("") const imageInputRef = useRef(null) const { user } = useAuthStore() + useEffect(() => { + let cancelled = false + setLoading(true) + 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) + }) + + return () => { + cancelled = true + } + }, [id]) + + if (loading) { + return ( +
加载中...
+ ) + } + if (!session) { return (
@@ -147,11 +172,22 @@ export default function ChatDetailPage({ params }: { params: Promise<{ id: strin accept="image/*" className="hidden" onChange={(event) => { - const file = event.target.files?.[0] + const target = event.currentTarget + const file = target.files?.[0] if (!file) return - const result = sendImageMessage(session.id, URL.createObjectURL(file)) - if (!result.ok) notifyInfo(result.error.msg) - event.target.value = "" + const imageUrl = URL.createObjectURL(file) + + Promise.resolve(sendImageMessage(session.id, imageUrl)) + .then((result) => { + if (!result.ok) { + notifyInfo(result.error.msg) + return + } + return Promise.resolve(listChatMessages(session.id)).then(setMessages) + }) + .finally(() => { + target.value = "" + }) }} />
{ + if (!result.ok) { + notifyInfo(result.error.msg) + return + } + setInput("") + return Promise.resolve(listChatMessages(session.id)).then(setMessages) + }) }} > state.sessions) + const [sessions, setSessions] = useState>>([]) const userId = useAuthStore((state) => state.user?.id) + useEffect(() => { + let cancelled = false + + void (async () => { + try { + const result = await Promise.resolve(listChatSessions()) + if (!cancelled) setSessions(result) + } catch { + if (!cancelled) setSessions([]) + } + })() + + return () => { + cancelled = true + } + }, []) + return (

消息

diff --git a/app/(order)/order/[id]/page.tsx b/app/(order)/order/[id]/page.tsx index 69695cd..23a3bf4 100644 --- a/app/(order)/order/[id]/page.tsx +++ b/app/(order)/order/[id]/page.tsx @@ -4,11 +4,10 @@ import OrderActions from "@/components/order-actions" import { Badge } from "@/components/ui/badge" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Separator } from "@/components/ui/separator" -import { getOrderById, listReviewsByOrder } from "@/lib/api" +import { getOrderById, listChatSessions, listReviewsByOrder } from "@/lib/api" import { ORDER_ACCEPT_TIMEOUT_MS, ORDER_CLOSE_TIMEOUT_MS } from "@/lib/config/demo-timers" import { statusLabels } from "@/lib/constants" import type { OrderStatus } from "@/lib/types" -import { useChatStore } from "@/store/chat" import { ArrowLeft, CheckCircle, Clock, Star } from "lucide-react" import Link from "next/link" import { use, useEffect, useState } from "react" @@ -34,7 +33,7 @@ const cancelledStatusSteps: OrderStatus[] = ["pending_payment", "pending_accept" export default function OrderDetailPage({ params }: { params: Promise<{ id: string }> }) { const { id } = use(params) - const sessions = useChatStore((state) => state.sessions) + const [sessions, setSessions] = useState>>([]) const [reviews, setReviews] = useState>>([]) const [order, setOrder] = useState> | undefined>( undefined, @@ -42,6 +41,25 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri const [loading, setLoading] = useState(true) const [nowTs, setNowTs] = useState(0) + useEffect(() => { + let cancelled = false + + ;(async () => { + try { + const sessions = await Promise.resolve(listChatSessions()) + if (cancelled) return + setSessions(sessions) + } catch { + if (cancelled) return + setSessions([]) + } + })() + + return () => { + cancelled = true + } + }, []) + useEffect(() => { let cancelled = false diff --git a/app/(order)/orders/page.tsx b/app/(order)/orders/page.tsx index d29e914..e62ba24 100644 --- a/app/(order)/orders/page.tsx +++ b/app/(order)/orders/page.tsx @@ -4,7 +4,7 @@ import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" -import { listOrders } from "@/lib/api" +import { listChatSessions, listOrders } from "@/lib/api" import { statusLabels } from "@/lib/constants" import { isActiveOrder, @@ -16,7 +16,6 @@ import { resolveOwnerShop } from "@/lib/domain/resolve-current-shop" import type { OrderStatus, UserRole } from "@/lib/types" import { cn } from "@/lib/utils" import { useAuthStore } from "@/store/auth" -import { useChatStore } from "@/store/chat" import { useShopStore } from "@/store/shops" import { Clock, MessageSquare, RefreshCw } from "lucide-react" import Link from "next/link" @@ -83,7 +82,7 @@ function OrderListContent({ }) { const [tab, setTab] = useState("all") const [orders, setOrders] = useState>>([]) - const sessions = useChatStore((state) => state.sessions) + const [sessions, setSessions] = useState>>([]) useEffect(() => { let cancelled = false @@ -104,6 +103,25 @@ function OrderListContent({ } }, []) + useEffect(() => { + let cancelled = false + + ;(async () => { + try { + const items = await Promise.resolve(listChatSessions()) + if (cancelled) return + setSessions(items) + } catch { + if (cancelled) return + setSessions([]) + } + })() + + return () => { + cancelled = true + } + }, []) + const tabs = currentRole === "consumer" ? consumerTabs : currentRole === "player" ? playerTabs : ownerTabs diff --git a/components/order-actions.tsx b/components/order-actions.tsx index e1a7d82..85124ee 100644 --- a/components/order-actions.tsx +++ b/components/order-actions.tsx @@ -12,7 +12,6 @@ import type { ApiDecision } from "@/lib/errors" import { notifyInfo, notifySuccess } from "@/lib/toast" import type { Order, OrderStatus } from "@/lib/types" import { useAuthStore } from "@/store/auth" -import { useChatStore } from "@/store/chat" import { useOrderStore } from "@/store/orders" import { useShopStore } from "@/store/shops" import { @@ -51,15 +50,12 @@ export default function OrderActions({ const currentUserId = useAuthStore((state) => state.user?.id) const storeOrder = useOrderStore((state) => state.orders.find((item) => item.id === orderId)) const order = orderProp ?? storeOrder - const sessions = useChatStore((state) => state.sessions) const dispatchMode = useShopStore((state) => { if (!order?.shopId) return "manual" const shop = state.shops.find((item) => item.id === order.shopId) return shop?.dispatchMode ?? "manual" }) - const resolvedChatSessionId = - chatSessionId ?? - sessions.find((session) => session.type === "order" && session.orderId === orderId)?.id + const resolvedChatSessionId = chatSessionId const status = order?.status ?? initialStatus const isConsumer = order?.consumerId === currentUserId diff --git a/lib/api/chat.ts b/lib/api/chat.ts index a8a1b0d..b5d8517 100644 --- a/lib/api/chat.ts +++ b/lib/api/chat.ts @@ -1,55 +1,114 @@ import { allow, deny } from "@/lib/decision" -import { useAuthStore } from "@/store/auth" -import { useChatStore } from "@/store/chat" +import { isApiError, toApiError, type ApiDecision } from "@/lib/errors" +import type { ChatMessage, ChatSession } from "@/lib/types" -export function listChatSessions() { - return useChatStore.getState().sessions +import { httpJson } from "./http" + +type Paginated = { + items: T[] + meta: { + total: number + offset: number + limit: number + } } -export function getChatSessionById(sessionId: string) { - return useChatStore.getState().sessions.find((session) => session.id === sessionId) +export type ListChatSessionsOptions = { + offset?: number + limit?: number } -export function listChatMessages(sessionId: string) { - return useChatStore.getState().messages.filter((message) => message.sessionId === sessionId) +export type ListChatMessagesOptions = { + offset?: number + limit?: number } -export function sendTextMessage(sessionId: string, content: string) { - const userId = useAuthStore.getState().user?.id - if (!userId) return deny(401, "请先登录") +function withOffsetLimit(path: string, options?: { offset?: number; limit?: number }): string { + const offset = options?.offset ?? 0 + const limit = options?.limit ?? 1000 - const chatState = useChatStore.getState() - const session = chatState.sessions.find((item) => item.id === sessionId) - if (!session) return deny(404, "会话不存在") - if (session.readonly) return deny(400, "当前会话只读") - if (!session.participants.some((participant) => participant.id === userId)) { - return deny(403, "仅会话参与方可发送消息") + const searchParams = new URLSearchParams({ + offset: String(offset), + limit: String(limit), + }) + + return `${path}?${searchParams.toString()}` +} + +function unwrapItems(value: Paginated | T[]): T[] { + return Array.isArray(value) ? value : value.items +} + +function denyFromError(error: unknown): ApiDecision { + if (error instanceof Error && error.message === "UNAUTHORIZED") { + return deny(401, "请先登录") } - if (!content.trim()) { - return deny(400, "消息不能为空") - } - - chatState.sendTextMessage(sessionId, userId, content) - return allow() + const apiError = toApiError(error) + return deny(apiError.code, apiError.msg) } -export function sendImageMessage(sessionId: string, imageUrl: string) { - const userId = useAuthStore.getState().user?.id - if (!userId) return deny(401, "请先登录") - - const chatState = useChatStore.getState() - const session = chatState.sessions.find((item) => item.id === sessionId) - if (!session) return deny(404, "会话不存在") - if (session.readonly) return deny(400, "当前会话只读") - if (!session.participants.some((participant) => participant.id === userId)) { - return deny(403, "仅会话参与方可发送消息") - } - - if (!imageUrl.trim()) { - return deny(400, "图片地址无效") - } - - chatState.sendImageMessage(sessionId, userId, imageUrl) - return allow() +export async function listChatSessions(options?: ListChatSessionsOptions): Promise { + const res = await httpJson | ChatSession[]>( + withOffsetLimit("/api/v1/chat/sessions", options), + { + cache: "no-store", + }, + ) + return unwrapItems(res) +} + +export async function getChatSessionById(sessionId: string): Promise { + try { + return await httpJson(`/api/v1/chat/sessions/${encodeURIComponent(sessionId)}`, { + cache: "no-store", + }) + } catch (error) { + if (error instanceof Error && error.message === "UNAUTHORIZED") { + throw error + } + if (isApiError(error) && error.code === 404) { + return undefined + } + throw error + } +} + +export async function listChatMessages( + sessionId: string, + options?: ListChatMessagesOptions, +): Promise { + const res = await httpJson | ChatMessage[]>( + withOffsetLimit(`/api/v1/chat/sessions/${encodeURIComponent(sessionId)}/messages`, options), + { + cache: "no-store", + }, + ) + return unwrapItems(res) +} + +export async function sendTextMessage(sessionId: string, content: string): Promise { + try { + await httpJson(`/api/v1/chat/sessions/${encodeURIComponent(sessionId)}/messages`, { + method: "POST", + cache: "no-store", + json: { type: "text", content }, + }) + return allow() + } catch (error) { + return denyFromError(error) + } +} + +export async function sendImageMessage(sessionId: string, imageUrl: string): Promise { + try { + await httpJson(`/api/v1/chat/sessions/${encodeURIComponent(sessionId)}/messages`, { + method: "POST", + cache: "no-store", + json: { type: "image", content: imageUrl }, + }) + return allow() + } catch (error) { + return denyFromError(error) + } }