251 lines
8.6 KiB
TypeScript
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>
|
|
)
|
|
}
|