Files
juwan-frontend/app/(order)/chat/[id]/page.tsx
T
2026-04-25 21:15:43 +08:00

251 lines
8.6 KiB
TypeScript

"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 { notifyInfo } from "@/lib/toast"
import { cn } from "@/lib/utils"
import { useAuthStore } from "@/store/auth"
import { ArrowLeft, ImagePlus, MessageSquare, Send } from "lucide-react"
import Image from "next/image"
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<ReturnType<typeof getChatSessionById>> | undefined
>(undefined)
const [messages, setMessages] = useState<Awaited<ReturnType<typeof listChatMessages>>>([])
const [input, setInput] = useState("")
const imageInputRef = useRef<HTMLInputElement>(null)
const { user } = useAuthStore()
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)
})
return () => {
cancelled = true
}
}, [id])
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) {
return (
<div className="container mx-auto max-w-2xl px-4 py-8">
<EmptyState
title="请先登录"
description="登录后可查看会话并发送消息。"
icon={MessageSquare}
/>
</div>
)
}
const userId = user.id
const isParticipant = session.participants.some((participant) => participant.id === userId)
if (!isParticipant) {
return (
<div className="container mx-auto max-w-2xl px-4 py-8">
<EmptyState
title="无法查看会话"
description="仅会话参与方可查看并发送消息。"
icon={MessageSquare}
/>
</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 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">
<ArrowLeft className="h-5 w-5" />
</Link>
<Avatar className="h-8 w-8">
<AvatarImage src={other.avatar} />
<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>
<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/60 px-2 py-1 rounded-full">
{msg.content}
</span>
</div>
)
}
const isMine = msg.senderId === userId
const sender = session.participants.find(
(participant) => participant.id === msg.senderId,
)
return (
<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")}>
{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-xl border px-3 py-2 text-sm",
isMine
? "border-primary bg-primary text-primary-foreground"
: "border-border/60 bg-muted/60",
)}
>
{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>
<div className="border-t border-border/60 p-4 bg-background">
<input
ref={imageInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(event) => {
const target = event.currentTarget
const file = target.files?.[0]
if (!file) return
const imageUrl = URL.createObjectURL(file)
void 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
className="flex gap-2"
onSubmit={(e) => {
e.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)
})
}}
>
<Input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="输入消息..."
className="flex-1 border-border/60"
/>
<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>
</Card>
</div>
)
}