feat(chat): migrate chat to backend API

This commit is contained in:
zetaloop
2026-03-01 17:03:30 +08:00
parent f189ec9846
commit e2671638e6
6 changed files with 223 additions and 76 deletions
+60 -22
View File
@@ -6,32 +6,57 @@ import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card" import { Card } from "@/components/ui/card"
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 { sendImageMessage, sendTextMessage } from "@/lib/api/chat" import { sendImageMessage, sendTextMessage } from "@/lib/api/chat"
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"
import { useChatStore } from "@/store/chat"
import { ArrowLeft, ImagePlus, Lock, Send } from "lucide-react" import { ArrowLeft, ImagePlus, Lock, Send } from "lucide-react"
import Image from "next/image" import Image from "next/image"
import Link from "next/link" 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 }> }) { export default function ChatDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params) const { id } = use(params)
const session = useChatStore((state) => state.sessions.find((item) => item.id === id)) const [loading, setLoading] = useState(true)
const allMessages = useChatStore((state) => state.messages) const [session, setSession] = useState<
// Filter logic runs here via useMemo rather than inside the Zustand selector. Awaited<ReturnType<typeof getChatSessionById>> | undefined
// useSyncExternalStore requires a stable snapshot reference on each render. >(undefined)
// Inline filter in a selector creates a new array per call and can trigger const [messages, setMessages] = useState<Awaited<ReturnType<typeof listChatMessages>>>([])
// infinite re-render loops in Zustand v5 (pmndrs/zustand#1936).
const messages = useMemo(
() => allMessages.filter((item) => item.sessionId === id),
[allMessages, id],
)
const [input, setInput] = useState("") const [input, setInput] = useState("")
const imageInputRef = useRef<HTMLInputElement>(null) const imageInputRef = useRef<HTMLInputElement>(null)
const { user } = useAuthStore() 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 (
<div className="container mx-auto py-8 px-4 text-center text-muted-foreground">...</div>
)
}
if (!session) { if (!session) {
return ( return (
<div className="container mx-auto py-8 px-4 text-center text-muted-foreground"> <div className="container mx-auto py-8 px-4 text-center text-muted-foreground">
@@ -147,11 +172,22 @@ export default function ChatDetailPage({ params }: { params: Promise<{ id: strin
accept="image/*" accept="image/*"
className="hidden" className="hidden"
onChange={(event) => { onChange={(event) => {
const file = event.target.files?.[0] const target = event.currentTarget
const file = target.files?.[0]
if (!file) return if (!file) return
const result = sendImageMessage(session.id, URL.createObjectURL(file)) const imageUrl = URL.createObjectURL(file)
if (!result.ok) notifyInfo(result.error.msg)
event.target.value = "" 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 = ""
})
}} }}
/> />
<form <form
@@ -161,12 +197,14 @@ export default function ChatDetailPage({ params }: { params: Promise<{ id: strin
const text = input.trim() const text = input.trim()
if (!text) return if (!text) return
const result = sendTextMessage(session.id, text) Promise.resolve(sendTextMessage(session.id, text)).then((result) => {
if (!result.ok) { if (!result.ok) {
notifyInfo(result.error.msg) notifyInfo(result.error.msg)
return return
} }
setInput("") setInput("")
return Promise.resolve(listChatMessages(session.id)).then(setMessages)
})
}} }}
> >
<Input <Input
+20 -2
View File
@@ -3,15 +3,33 @@
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Card, CardContent } from "@/components/ui/card" import { Card, CardContent } from "@/components/ui/card"
import { listChatSessions } from "@/lib/api"
import { useAuthStore } from "@/store/auth" import { useAuthStore } from "@/store/auth"
import { useChatStore } from "@/store/chat"
import { Lock, MessageSquare } from "lucide-react" import { Lock, MessageSquare } from "lucide-react"
import Link from "next/link" import Link from "next/link"
import { useEffect, useState } from "react"
export default function ChatListPage() { export default function ChatListPage() {
const sessions = useChatStore((state) => state.sessions) const [sessions, setSessions] = useState<Awaited<ReturnType<typeof listChatSessions>>>([])
const userId = useAuthStore((state) => state.user?.id) 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 ( return (
<div className="container mx-auto max-w-2xl px-4 py-8 space-y-6"> <div className="container mx-auto max-w-2xl px-4 py-8 space-y-6">
<h1 className="text-2xl font-bold"></h1> <h1 className="text-2xl font-bold"></h1>
+21 -3
View File
@@ -4,11 +4,10 @@ import OrderActions from "@/components/order-actions"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Separator } from "@/components/ui/separator" 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 { ORDER_ACCEPT_TIMEOUT_MS, ORDER_CLOSE_TIMEOUT_MS } from "@/lib/config/demo-timers"
import { statusLabels } from "@/lib/constants" import { statusLabels } from "@/lib/constants"
import type { OrderStatus } from "@/lib/types" import type { OrderStatus } from "@/lib/types"
import { useChatStore } from "@/store/chat"
import { ArrowLeft, CheckCircle, Clock, Star } from "lucide-react" import { ArrowLeft, CheckCircle, Clock, Star } from "lucide-react"
import Link from "next/link" import Link from "next/link"
import { use, useEffect, useState } from "react" 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 }> }) { export default function OrderDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params) const { id } = use(params)
const sessions = useChatStore((state) => state.sessions) const [sessions, setSessions] = useState<Awaited<ReturnType<typeof listChatSessions>>>([])
const [reviews, setReviews] = useState<Awaited<ReturnType<typeof listReviewsByOrder>>>([]) const [reviews, setReviews] = useState<Awaited<ReturnType<typeof listReviewsByOrder>>>([])
const [order, setOrder] = useState<Awaited<ReturnType<typeof getOrderById>> | undefined>( const [order, setOrder] = useState<Awaited<ReturnType<typeof getOrderById>> | undefined>(
undefined, undefined,
@@ -42,6 +41,25 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [nowTs, setNowTs] = useState(0) 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(() => { useEffect(() => {
let cancelled = false let cancelled = false
+21 -3
View File
@@ -4,7 +4,7 @@ import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" 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 { statusLabels } from "@/lib/constants"
import { import {
isActiveOrder, isActiveOrder,
@@ -16,7 +16,6 @@ import { resolveOwnerShop } from "@/lib/domain/resolve-current-shop"
import type { OrderStatus, UserRole } from "@/lib/types" import type { OrderStatus, UserRole } from "@/lib/types"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { useAuthStore } from "@/store/auth" import { useAuthStore } from "@/store/auth"
import { useChatStore } from "@/store/chat"
import { useShopStore } from "@/store/shops" import { useShopStore } from "@/store/shops"
import { Clock, MessageSquare, RefreshCw } from "lucide-react" import { Clock, MessageSquare, RefreshCw } from "lucide-react"
import Link from "next/link" import Link from "next/link"
@@ -83,7 +82,7 @@ function OrderListContent({
}) { }) {
const [tab, setTab] = useState<TabFilter | "pending">("all") const [tab, setTab] = useState<TabFilter | "pending">("all")
const [orders, setOrders] = useState<Awaited<ReturnType<typeof listOrders>>>([]) const [orders, setOrders] = useState<Awaited<ReturnType<typeof listOrders>>>([])
const sessions = useChatStore((state) => state.sessions) const [sessions, setSessions] = useState<Awaited<ReturnType<typeof listChatSessions>>>([])
useEffect(() => { useEffect(() => {
let cancelled = false 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 = const tabs =
currentRole === "consumer" ? consumerTabs : currentRole === "player" ? playerTabs : ownerTabs currentRole === "consumer" ? consumerTabs : currentRole === "player" ? playerTabs : ownerTabs
+1 -5
View File
@@ -12,7 +12,6 @@ import type { ApiDecision } from "@/lib/errors"
import { notifyInfo, notifySuccess } from "@/lib/toast" import { notifyInfo, notifySuccess } from "@/lib/toast"
import type { Order, OrderStatus } from "@/lib/types" import type { Order, OrderStatus } from "@/lib/types"
import { useAuthStore } from "@/store/auth" import { useAuthStore } from "@/store/auth"
import { useChatStore } from "@/store/chat"
import { useOrderStore } from "@/store/orders" import { useOrderStore } from "@/store/orders"
import { useShopStore } from "@/store/shops" import { useShopStore } from "@/store/shops"
import { import {
@@ -51,15 +50,12 @@ export default function OrderActions({
const currentUserId = useAuthStore((state) => state.user?.id) const currentUserId = useAuthStore((state) => state.user?.id)
const storeOrder = useOrderStore((state) => state.orders.find((item) => item.id === orderId)) const storeOrder = useOrderStore((state) => state.orders.find((item) => item.id === orderId))
const order = orderProp ?? storeOrder const order = orderProp ?? storeOrder
const sessions = useChatStore((state) => state.sessions)
const dispatchMode = useShopStore((state) => { const dispatchMode = useShopStore((state) => {
if (!order?.shopId) return "manual" if (!order?.shopId) return "manual"
const shop = state.shops.find((item) => item.id === order.shopId) const shop = state.shops.find((item) => item.id === order.shopId)
return shop?.dispatchMode ?? "manual" return shop?.dispatchMode ?? "manual"
}) })
const resolvedChatSessionId = const resolvedChatSessionId = chatSessionId
chatSessionId ??
sessions.find((session) => session.type === "order" && session.orderId === orderId)?.id
const status = order?.status ?? initialStatus const status = order?.status ?? initialStatus
const isConsumer = order?.consumerId === currentUserId const isConsumer = order?.consumerId === currentUserId
+100 -41
View File
@@ -1,55 +1,114 @@
import { allow, deny } from "@/lib/decision" import { allow, deny } from "@/lib/decision"
import { useAuthStore } from "@/store/auth" import { isApiError, toApiError, type ApiDecision } from "@/lib/errors"
import { useChatStore } from "@/store/chat" import type { ChatMessage, ChatSession } from "@/lib/types"
export function listChatSessions() { import { httpJson } from "./http"
return useChatStore.getState().sessions
type Paginated<T> = {
items: T[]
meta: {
total: number
offset: number
limit: number
}
} }
export function getChatSessionById(sessionId: string) { export type ListChatSessionsOptions = {
return useChatStore.getState().sessions.find((session) => session.id === sessionId) offset?: number
limit?: number
} }
export function listChatMessages(sessionId: string) { export type ListChatMessagesOptions = {
return useChatStore.getState().messages.filter((message) => message.sessionId === sessionId) offset?: number
limit?: number
} }
export function sendTextMessage(sessionId: string, content: string) { function withOffsetLimit(path: string, options?: { offset?: number; limit?: number }): string {
const userId = useAuthStore.getState().user?.id const offset = options?.offset ?? 0
if (!userId) return deny(401, "请先登录") const limit = options?.limit ?? 1000
const chatState = useChatStore.getState() const searchParams = new URLSearchParams({
const session = chatState.sessions.find((item) => item.id === sessionId) offset: String(offset),
if (!session) return deny(404, "会话不存在") limit: String(limit),
if (session.readonly) return deny(400, "当前会话只读") })
if (!session.participants.some((participant) => participant.id === userId)) {
return deny(403, "仅会话参与方可发送消息") return `${path}?${searchParams.toString()}`
}
function unwrapItems<T>(value: Paginated<T> | 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()) { const apiError = toApiError(error)
return deny(400, "消息不能为空") return deny(apiError.code, apiError.msg)
}
chatState.sendTextMessage(sessionId, userId, content)
return allow()
} }
export function sendImageMessage(sessionId: string, imageUrl: string) { export async function listChatSessions(options?: ListChatSessionsOptions): Promise<ChatSession[]> {
const userId = useAuthStore.getState().user?.id const res = await httpJson<Paginated<ChatSession> | ChatSession[]>(
if (!userId) return deny(401, "请先登录") withOffsetLimit("/api/v1/chat/sessions", options),
{
const chatState = useChatStore.getState() cache: "no-store",
const session = chatState.sessions.find((item) => item.id === sessionId) },
if (!session) return deny(404, "会话不存在") )
if (session.readonly) return deny(400, "当前会话只读") return unwrapItems(res)
if (!session.participants.some((participant) => participant.id === userId)) { }
return deny(403, "仅会话参与方可发送消息")
} export async function getChatSessionById(sessionId: string): Promise<ChatSession | undefined> {
try {
if (!imageUrl.trim()) { return await httpJson<ChatSession>(`/api/v1/chat/sessions/${encodeURIComponent(sessionId)}`, {
return deny(400, "图片地址无效") cache: "no-store",
} })
} catch (error) {
chatState.sendImageMessage(sessionId, userId, imageUrl) if (error instanceof Error && error.message === "UNAUTHORIZED") {
return allow() throw error
}
if (isApiError(error) && error.code === 404) {
return undefined
}
throw error
}
}
export async function listChatMessages(
sessionId: string,
options?: ListChatMessagesOptions,
): Promise<ChatMessage[]> {
const res = await httpJson<Paginated<ChatMessage> | 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<ApiDecision> {
try {
await httpJson<unknown>(`/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<ApiDecision> {
try {
await httpJson<unknown>(`/api/v1/chat/sessions/${encodeURIComponent(sessionId)}/messages`, {
method: "POST",
cache: "no-store",
json: { type: "image", content: imageUrl },
})
return allow()
} catch (error) {
return denyFromError(error)
}
} }