Files
juwan-frontend/app/(order)/chat/[id]/page.tsx
T

201 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 { Input } from "@/components/ui/input"
import { ScrollArea } from "@/components/ui/scroll-area"
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"
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 [input, setInput] = useState("")
const imageInputRef = useRef<HTMLInputElement>(null)
const { user } = useAuthStore()
if (!session) {
return (
<div className="container mx-auto py-8 px-4 text-center text-muted-foreground">
</div>
)
}
if (!user?.id) {
return (
<div className="container mx-auto py-8 px-4 text-center text-muted-foreground">
</div>
)
}
const userId = user.id
const isParticipant = session.participants.some((participant) => participant.id === userId)
if (!isParticipant) {
return (
<div className="container mx-auto py-8 px-4 text-center text-muted-foreground">
</div>
)
}
const other = session.participants.find((p) => p.id !== userId) ?? session.participants[0]
return (
<div className="container mx-auto max-w-3xl px-4 py-8 h-[calc(100vh-3.5rem)] flex flex-col">
<Card className="flex-1 flex flex-col overflow-hidden hover:shadow-[var(--shadow-card)]">
<div className="border-b px-4 py-3 flex items-center gap-3 bg-muted/30">
<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.name[0]}</AvatarFallback>
</Avatar>
<div>
<span className="text-sm font-medium">{other.name}</span>
<div className="flex items-center gap-1">
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
{session.type === "order" ? "订单会话" : "咨询会话"}
</Badge>
{session.readonly && (
<span className="text-[10px] text-muted-foreground flex items-center gap-0.5">
<Lock className="h-3 w-3" />
</span>
)}
</div>
</div>
</div>
<ScrollArea className="flex-1 p-4">
<div className="space-y-4">
{messages.map((msg) => {
if (msg.type === "system") {
return (
<div key={msg.id} className="text-center">
<span className="text-xs text-muted-foreground bg-muted px-2 py-1 rounded">
{msg.content}
</span>
</div>
)
}
const isMine = msg.senderId === userId
return (
<div key={msg.id} className={cn("flex gap-2", isMine && "flex-row-reverse")}>
<Avatar className="h-8 w-8 shrink-0">
<AvatarImage src={msg.senderAvatar} />
<AvatarFallback>{msg.senderName[0]}</AvatarFallback>
</Avatar>
<div className={cn("max-w-[70%]", isMine && "text-right")}>
{msg.type === "image" ? (
<Image
src={msg.content}
alt="聊天图片"
width={256}
height={192}
unoptimized
className="inline-block rounded-lg max-h-48 max-w-64 border"
/>
) : (
<p
className={cn(
"inline-block rounded-lg px-3 py-2 text-sm",
isMine ? "bg-primary text-primary-foreground" : "bg-muted",
)}
>
{msg.content}
</p>
)}
<p className="text-[10px] text-muted-foreground mt-1">
{new Date(msg.createdAt).toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
})}
</p>
</div>
</div>
)
})}
</div>
</ScrollArea>
{!session.readonly ? (
<div className="border-t p-4 bg-muted/30">
<input
ref={imageInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0]
if (!file) return
const result = sendImageMessage(session.id, URL.createObjectURL(file))
if (result && !result.ok) notifyInfo(result.message ?? "发送失败")
event.target.value = ""
}}
/>
<form
className="flex gap-2"
onSubmit={(e) => {
e.preventDefault()
const text = input.trim()
if (!text) return
const result = sendTextMessage(session.id, text)
if (result && !result.ok) {
notifyInfo(result.message ?? "发送失败")
return
}
setInput("")
}}
>
<Input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="输入消息..."
className="flex-1"
/>
<Button
type="button"
size="icon"
variant="outline"
onClick={() => imageInputRef.current?.click()}
>
<ImagePlus className="h-4 w-4" />
</Button>
<Button type="submit" size="icon" disabled={!input.trim()}>
<Send className="h-4 w-4" />
</Button>
</form>
</div>
) : (
<div className="border-t p-4 text-center text-sm text-muted-foreground bg-muted/30">
<Lock className="h-4 w-4 inline mr-1" />
</div>
)}
</Card>
</div>
)
}