@@ -1,4 +1,4 @@
import { useState , useEffect , useRef , useCallback } from 'react'
import { useState , useEffect , useRef , useCallback , useMemo } from 'react'
import {
CloseOutlined , MessageOutlined , SmileOutlined , SendOutlined ,
StarFilled , MinusOutlined , PictureOutlined , CustomerServiceOutlined ,
@@ -12,19 +12,36 @@ interface Message {
type ? : 'text' | 'image'
}
const quickQuestions = [ '查询订单状态' , '退换货政策' , '配送时效说明' ]
const STORAGE_KEY = 'kefu_widget_session'
const VISITOR_TOKEN_KEY = 'kefu_widget_visitor_token'
const defaultQuickQuestions = [ '查询订单状态' , '退换货政策' , '配送时效说明' ]
const VisitorChat = ( { defaultOpen = false } : { defaultOpen? : boolean } ) = > {
const [ open , setOpen ] = useState ( defaultOpen )
type LayoutMode = 'floating' | 'fill'
interface VisitorChatProps {
defaultOpen? : boolean
channelKey? : string
/** 是否在 iframe 嵌入模式(关闭/最小化会 postMessage 给宿主) */
embedded? : boolean
layout? : LayoutMode
}
const VisitorChat = ( {
defaultOpen = false ,
channelKey = 'WK_8a3f2e' ,
embedded = false ,
layout = 'floating' ,
} : VisitorChatProps ) = > {
const storageKey = useMemo ( ( ) = > ` kefu_widget_session_ ${ channelKey } ` , [ channelKey ] )
const tokenKey = useMemo ( ( ) = > ` kefu_widget_visitor_token_ ${ channelKey } ` , [ channelKey ] )
const msgsKey = useMemo ( ( ) = > ` ${ storageKey } _msgs ` , [ storageKey ] )
const [ open , setOpen ] = useState ( defaultOpen || layout === 'fill' )
const [ sessionId , setSessionId ] = useState < number | null > ( ( ) = > {
const saved = localStorage . getItem ( STORAGE_KEY )
const saved = localStorage . getItem ( storageKey )
return saved ? Number ( saved ) : null
} )
const [ visitorToken , setVisitorToken ] = useState < string > ( ( ) = > localStorage . getItem ( VISITOR_TOKEN_KEY ) || '' )
const [ visitorToken , setVisitorToken ] = useState < string > ( ( ) = > localStorage . getItem ( tokenKey ) || '' )
const [ messages , setMessages ] = useState < Message [ ] > ( ( ) = > {
const saved = localStorage . getItem ( STORAGE_KEY + '_msgs' )
const saved = localStorage . getItem ( msgsKey )
return saved ? JSON . parse ( saved ) : [ ]
} )
const [ input , setInput ] = useState ( '' )
@@ -37,6 +54,13 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
const [ hoverStar , setHoverStar ] = useState ( 0 )
const [ imagePreview , setImagePreview ] = useState < string | null > ( null )
const [ sendError , setSendError ] = useState ( '' )
const [ agentsOnline , setAgentsOnline ] = useState ( true )
const [ offlinePrompt , setOfflinePrompt ] = useState ( '当前无客服在线,请留言并留下联系方式,我们上线后会尽快回复您。' )
const [ agentName , setAgentName ] = useState ( '' )
const [ leaveName , setLeaveName ] = useState ( '' )
const [ leavePhone , setLeavePhone ] = useState ( '' )
const [ leaveEmail , setLeaveEmail ] = useState ( '' )
const [ leaveSent , setLeaveSent ] = useState ( false )
const pollRef = useRef < number | null > ( null )
const initRef = useRef ( false )
const typingTimerRef = useRef < number | null > ( null )
@@ -47,7 +71,7 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
const loadMessages = useCallback ( async ( sid? : number , token? : string ) = > {
const s = sid || sessionId
const visitorCredential = token || visitorToken || localStorage . getItem ( VISITOR_TOKEN_KEY ) || ''
const visitorCredential = token || visitorToken || localStorage . getItem ( tokenKey ) || ''
if ( ! s || ! visitorCredential ) return
try {
const res = await fetch ( ` /api/widget/messages?session_id= ${ s } ` , {
@@ -63,10 +87,10 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
time : new Date ( m . sent_at ) . toLocaleTimeString ( 'zh-CN' , { hour : '2-digit' , minute : '2-digit' } ) ,
} ) )
setMessages ( msgs )
localStorage . setItem ( STORAGE_KEY + '_msgs' , JSON . stringify ( msgs ) )
localStorage . setItem ( msgsKey , JSON . stringify ( msgs ) )
}
} catch { /* ignore */ }
} , [ sessionId , visitorToken ] )
} , [ sessionId , visitorToken , tokenKey , msgsKey ] )
const initSession = useCallback ( async ( ) = > {
if ( sessionId && visitorToken ) {
@@ -76,21 +100,31 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
if ( initRef . current ) return
initRef . current = true
try {
const res = await fetch ( ` /api/widget/init?channel_key=WK_8a3f2e&visitor_name=客服云访客 ` , { method : 'POST' } )
const res = await fetch ( ` /api/widget/init?channel_key= ${ encodeURIComponent ( channelKey ) } &visitor_name= ${ encodeURIComponent ( '访客' ) } ` , {
method : 'POST' ,
} )
const json = await res . json ( )
if ( json . code === 0 ) {
const sid = json . data . session_id
const token = json . data . visitor_token
const data = json . data
const sid = data . session_id
const token = data . visitor_token
setSessionId ( sid )
setVisitorToken ( token )
localStorage . setItem ( STORAGE_KEY , String ( sid ) )
localStorage . setItem ( VISITOR_TOKEN_KEY , token )
setAgentsOnline ( Boolean ( data . agents_online ) )
if ( data . offline_prompt ) setOfflinePrompt ( data . offline_prompt )
if ( data . agent_name ) setAgentName ( data . agent_name )
if ( data . session_status === 'ended' ) setSessionEnded ( true )
localStorage . setItem ( storageKey , String ( sid ) )
localStorage . setItem ( tokenKey , token )
await loadMessages ( sid , token )
} else {
setSendError ( json . message || '初始化会话失败' )
}
} catch ( e ) {
console . error ( 'Init failed:' , e )
setSendError ( '连接客服失败,请稍后重试' )
}
} , [ sessionId , visitorToken , loadMessages ] )
} , [ sessionId , visitorToken , loadMessages , channelKey , storageKey , tokenKey ] )
useEffect ( ( ) = > {
if ( open ) initSession ( )
@@ -115,7 +149,6 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
try {
const payload = JSON . parse ( event . data )
if ( payload . session_id === sessionId && payload . type === 'typing' ) {
// 仅展示客服侧输入状态
if ( payload . data ? . from && payload . data . from !== 'agent' ) return
setAgentTyping ( true )
if ( typingTimerRef . current ) clearTimeout ( typingTimerRef . current )
@@ -123,9 +156,15 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
return
}
if ( payload . session_id === sessionId && ( payload . type === 'message' || payload . type === 'session_updated' ) ) {
if ( payload . type === 'session_updated' && payload . data ? . status === 'ended' ) {
setSessionEnded ( true )
setShowRating ( true )
if ( payload . type === 'session_updated' ) {
if ( payload . data ? . status === 'ended' ) {
setSessionEnded ( true )
setShowRating ( true )
}
if ( payload . data ? . status === 'active' ) {
setAgentsOnline ( true )
setAgentName ( payload . data ? . agent_name || agentName )
}
}
setAgentTyping ( false )
loadMessages ( sessionId , visitorToken )
@@ -139,14 +178,20 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
socketRef . current = null
if ( typingTimerRef . current ) clearTimeout ( typingTimerRef . current )
}
} , [ sessionId , visitorToken , open , loadMessages ] )
} , [ sessionId , visitorToken , open , loadMessages , agentName ] )
useEffect ( ( ) = > {
chatEndRef . current ? . scrollIntoView ( { behavior : 'smooth' } )
} , [ messages , agentTyping , open , imagePreview ] )
const notifyHost = ( type : 'kefu-widget-close' | 'kefu-widget-minimize' ) = > {
if ( embedded && window . parent && window . parent !== window ) {
window . parent . postMessage ( { type } , '*' )
}
}
const emitTyping = ( ) = > {
if ( ! sessionId || sessionEnded ) return
if ( ! sessionId || sessionEnded || ! agentsOnline ) return
const now = Date . now ( )
if ( now - lastTypingAt . current < 1200 || socketRef . current ? . readyState !== WebSocket . OPEN ) return
lastTypingAt . current = now
@@ -169,6 +214,10 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
const sendMessage = async ( text : string ) = > {
if ( ! text . trim ( ) || sending || sessionEnded ) return
if ( ! agentsOnline ) {
setSendError ( '当前无客服在线,请使用下方留言表单' )
return
}
const content = text . trim ( )
setInput ( '' )
setSendError ( '' )
@@ -194,8 +243,42 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
}
}
const submitLeaveMessage = async ( ) = > {
if ( ! sessionId || ! visitorToken || sending || leaveSent ) return
const content = input . trim ( ) || '请尽快与我联系,谢谢。'
if ( ! leavePhone . trim ( ) && ! leaveEmail . trim ( ) ) {
setSendError ( '请至少填写手机号或邮箱' )
return
}
setSending ( true )
setSendError ( '' )
try {
const res = await fetch ( '/api/widget/leave-message' , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' , 'X-Visitor-Token' : visitorToken } ,
body : JSON.stringify ( {
session_id : sessionId ,
content ,
name : leaveName.trim ( ) ,
phone : leavePhone.trim ( ) ,
email : leaveEmail.trim ( ) ,
} ) ,
} )
const json = await res . json ( )
if ( json . code !== 0 ) throw new Error ( json . message || '留言失败' )
setLeaveSent ( true )
setInput ( '' )
if ( json . data ? . agents_online ) setAgentsOnline ( true )
await loadMessages ( sessionId , visitorToken )
} catch ( e ) {
setSendError ( e instanceof Error ? e . message : '留言失败' )
} finally {
setSending ( false )
}
}
const handleImageFile = ( file? : File | null ) = > {
if ( ! file || sessionEnded || ! sessionId ) return
if ( ! file || sessionEnded || ! sessionId || ! agentsOnline ) return
if ( ! [ 'image/jpeg' , 'image/png' , 'image/gif' ] . includes ( file . type ) ) {
setSendError ( '仅支持 jpg、png、gif 图片' )
return
@@ -211,7 +294,7 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
}
const sendImage = async ( ) = > {
if ( ! imagePreview || sending || sessionEnded ) return
if ( ! imagePreview || sending || sessionEnded || ! agentsOnline ) return
setSending ( true )
setSendError ( '' )
const localMsg : Message = {
@@ -241,11 +324,13 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
const handleClose = ( ) = > {
setOpen ( false )
notifyHost ( 'kefu-widget-close' )
if ( sessionEnded && ! rated ) setShowRating ( true )
}
const handleMinimize = ( ) = > {
setOpen ( false )
notifyHost ( 'kefu-widget-minimize' )
}
const submitRating = async ( score : number ) = > {
@@ -266,12 +351,290 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
}
}
const showWelcome = messages . length === 0
const showQuick = messages . length <= 1 && ! sessionEnded
const showWelcome = messages . length === 0 && ! leaveSent
const showQuick = messages . length <= 1 && ! sessionEnded && agentsOnline
const isFill = layout === 'fill'
const shellClass = isFill
? 'relative w-full h-full flex flex-col bg-white overflow-hidden'
: 'fixed z-50 right-6 bottom-6 w-[400px] h-[600px] max-w-[calc(100vw-32px)] max-h-[calc(100vh-32px)] flex flex-col rounded-xl overflow-hidden bg-white'
const panel = open && (
< div className = { shellClass } style = { isFill ? undefined : { boxShadow : 'var(--shadow-floating)' } } >
< header className = "shrink-0 px-4 py-4 bg-[#2563eb] text-white flex items-center gap-3" >
< div className = "w-10 h-10 rounded-full bg-white/20 flex items-center justify-center shrink-0" >
< CustomerServiceOutlined className = "text-lg text-white" / >
< / div >
< div className = "flex-1 min-w-0" >
< h1 className = "m-0 text-[15px] font-semibold leading-tight text-white" > 在 线 客 服 < / h1 >
< p className = "m-0 text-xs leading-normal text-white/80 flex items-center gap-1 mt-0.5" >
< span
className = "inline-block w-1.5 h-1.5 rounded-full"
style = { { background : sessionEnded ? '#94a3b8' : agentsOnline ? '#16a34a' : '#d97706' } }
/ >
{ sessionEnded
? '会话已结束'
: agentsOnline
? ( agentName ? ` ${ agentName } 为您服务 ` : '正在为您服务' )
: '客服离线 · 可留言' }
< / p >
< / div >
< div className = "flex items-center gap-1 shrink-0" >
{ ! isFill && (
< button
type = "button"
onClick = { handleMinimize }
className = "w-7 h-7 rounded bg-white/15 hover:bg-white/25 border-0 cursor-pointer flex items-center justify-center text-white"
aria-label = "最小化"
>
< MinusOutlined className = "text-xs" / >
< / button >
) }
< button
type = "button"
onClick = { handleClose }
className = "w-7 h-7 rounded bg-white/15 hover:bg-white/25 border-0 cursor-pointer flex items-center justify-center text-white"
aria-label = "关闭"
>
< CloseOutlined className = "text-xs" / >
< / button >
< / div >
< / header >
< section className = "flex-1 overflow-y-auto p-4 flex flex-col gap-3 bg-neutral-50 no-scrollbar" >
{ showWelcome && (
< div className = "flex flex-col items-center gap-3" >
< div className = "px-4 py-2 rounded-xl bg-white border border-neutral-200 max-w-[85%] text-center" >
< p className = "m-0 text-[13px] text-neutral-600 leading-normal" >
{ agentsOnline
? '您好!欢迎咨询,请问有什么可以帮您?'
: offlinePrompt }
< / p >
< / div >
< / div >
) }
{ showQuick && (
< div className = "flex flex-wrap gap-2 justify-center" >
{ defaultQuickQuestions . map ( ( q , i ) = > (
< button
key = { i }
type = "button"
onClick = { ( ) = > sendMessage ( q ) }
disabled = { ! sessionId || sending || sessionEnded }
className = "px-3 py-1 rounded-full border border-[#2563eb] bg-white text-[#2563eb] text-xs cursor-pointer whitespace-nowrap hover:bg-[#eff6ff] disabled:opacity-50"
>
{ q }
< / button >
) ) }
< / div >
) }
{ messages . map ( msg = > (
msg . sender === 'visitor' ? (
< div key = { msg . id } className = "flex justify-end max-w-[85%] ml-auto" >
< div className = { ` rounded-xl bg-[#2563eb] text-white text-[13px] leading-normal ${ msg . type === 'image' ? 'p-1.5' : 'px-4 py-3' } ` } >
{ msg . type === 'image' ? (
< img src = { msg . content } alt = "图片" className = "max-w-[180px] rounded-lg block" / >
) : (
< p className = "m-0 whitespace-pre-wrap break-words" > { msg . content } < / p >
) }
< / div >
< / div >
) : (
< div key = { msg . id } className = "flex gap-2 max-w-[85%]" >
< div className = "w-8 h-8 rounded-full bg-neutral-200 flex items-center justify-center shrink-0 mt-0.5" >
< CustomerServiceOutlined className = "text-xs text-neutral-500" / >
< / div >
< div className = { ` rounded-xl bg-white border border-neutral-200 text-[13px] text-neutral-800 leading-normal ${ msg . type === 'image' ? 'p-1.5' : 'px-4 py-3' } ` } >
{ msg . type === 'image' ? (
< img src = { msg . content } alt = "图片" className = "max-w-[180px] rounded-lg block" / >
) : (
< p className = "m-0 whitespace-pre-wrap break-words" > { msg . content } < / p >
) }
< / div >
< / div >
)
) ) }
{ agentTyping && (
< >
< div className = "flex gap-2 max-w-[85%]" >
< div className = "w-8 h-8 rounded-full bg-neutral-200 flex items-center justify-center shrink-0 mt-0.5" >
< CustomerServiceOutlined className = "text-xs text-neutral-500" / >
< / div >
< div className = "px-4 py-3 rounded-xl bg-white border border-neutral-200 flex items-center gap-1" >
< span className = "typing-dot" style = { { animationDelay : '0s' } } / >
< span className = "typing-dot" style = { { animationDelay : '0.2s' } } / >
< span className = "typing-dot" style = { { animationDelay : '0.4s' } } / >
< / div >
< / div >
< p className = "text-left text-xs text-neutral-400 m-0 pl-[42px]" > 客 服 正 在 输 入 . . . < / p >
< / >
) }
< div ref = { chatEndRef } / >
< / section >
< footer className = "shrink-0 relative px-4 py-3 bg-white border-t border-neutral-200" >
< div
className = "absolute top-0 left-0 right-0 h-[3px] pointer-events-none opacity-40"
style = { { background : 'linear-gradient(90deg, transparent 0%, #2563eb 50%, transparent 100%)' } }
/ >
{ sendError && < div className = "mb-2 text-xs text-red-500" > { sendError } < / div > }
{ ! agentsOnline && ! sessionEnded && (
< div className = "mb-3 p-3 rounded-lg border border-amber-100 bg-amber-50 space-y-2" >
{ leaveSent ? (
< div className = "text-xs text-amber-800 leading-relaxed" >
留 言 已 提 交 , 客 服 上 线 后 会 尽 快 联 系 您 。 您 也 可 继 续 补 充 留 言 内 容 。
< / div >
) : (
< >
< div className = "text-xs text-amber-800 font-medium" > 离 线 留 言 < / div >
< input
className = "w-full h-8 px-2 rounded-md border border-neutral-200 bg-white text-xs outline-none"
placeholder = "您的姓名(可选)"
value = { leaveName }
onChange = { e = > setLeaveName ( e . target . value ) }
/ >
< input
className = "w-full h-8 px-2 rounded-md border border-neutral-200 bg-white text-xs outline-none"
placeholder = "手机号"
value = { leavePhone }
onChange = { e = > setLeavePhone ( e . target . value ) }
/ >
< input
className = "w-full h-8 px-2 rounded-md border border-neutral-200 bg-white text-xs outline-none"
placeholder = "邮箱"
value = { leaveEmail }
onChange = { e = > setLeaveEmail ( e . target . value ) }
/ >
< / >
) }
< / div >
) }
{ imagePreview && agentsOnline && (
< div className = "mb-2 p-2 rounded-lg border border-neutral-200 bg-neutral-50 flex items-center gap-2" >
< img src = { imagePreview } alt = "预览" className = "w-14 h-14 object-cover rounded" / >
< div className = "flex-1 min-w-0 text-xs text-neutral-500" > 确 认 发 送 图 片 ? < / div >
< button type = "button" className = "text-xs text-neutral-400 border-0 bg-transparent cursor-pointer" onClick = { ( ) = > setImagePreview ( null ) } > 取 消 < / button >
< button type = "button" disabled = { sending } className = "text-xs px-2 py-1 rounded-md bg-[#2563eb] text-white border-0 cursor-pointer disabled:opacity-50" onClick = { sendImage } > 发 送 < / button >
< / div >
) }
{ agentsOnline && (
< div className = "flex items-center gap-2 mb-2" >
< button
type = "button"
className = "w-8 h-8 rounded-md border-0 bg-transparent cursor-pointer flex items-center justify-center text-neutral-400 hover:bg-neutral-50 disabled:opacity-40"
aria-label = "发送图片"
onClick = { ( ) = > fileInputRef . current ? . click ( ) }
disabled = { sessionEnded || ! sessionId || sending }
>
< PictureOutlined className = "text-base" / >
< / button >
< button type = "button" className = "w-8 h-8 rounded-md border-0 bg-transparent cursor-pointer flex items-center justify-center text-neutral-400 hover:bg-neutral-50" aria-label = "表情" >
< SmileOutlined className = "text-base" / >
< / button >
< input
ref = { fileInputRef }
type = "file"
accept = "image/jpeg,image/png,image/gif"
className = "hidden"
onChange = { e = > { handleImageFile ( e . target . files ? . [ 0 ] ) ; e . currentTarget . value = '' } }
/ >
< / div >
) }
< div className = "flex items-center gap-2" >
< input
className = "flex-1 min-w-0 h-10 px-3 rounded-xl border border-neutral-200 bg-neutral-50 text-[13px] text-neutral-800 outline-none focus:border-[#2563eb]"
placeholder = {
sessionEnded
? '会话已结束'
: ! sessionId
? '正在连接...'
: agentsOnline
? '输入消息...'
: '描述您的问题(留言)'
}
value = { input }
onChange = { e = > { setInput ( e . target . value ) ; if ( agentsOnline ) emitTyping ( ) } }
onPaste = { e = > {
if ( ! agentsOnline ) return
const item = Array . from ( e . clipboardData . items ) . find ( i = > i . type . startsWith ( 'image/' ) )
if ( item ) {
e . preventDefault ( )
handleImageFile ( item . getAsFile ( ) )
}
} }
onKeyDown = { e = > {
if ( e . key === 'Enter' ) {
if ( agentsOnline ) sendMessage ( input )
}
} }
disabled = { sending || ! sessionId || sessionEnded }
/ >
{ agentsOnline ? (
< button
type = "button"
onClick = { ( ) = > sendMessage ( input ) }
disabled = { ! input . trim ( ) || sending || ! sessionId || sessionEnded }
className = "w-10 h-10 rounded-full border-0 bg-[#2563eb] hover:bg-[#1d4ed8] disabled:opacity-40 cursor-pointer flex items-center justify-center shrink-0"
aria-label = "发送"
>
< SendOutlined className = "text-white text-sm" / >
< / button >
) : (
< button
type = "button"
onClick = { submitLeaveMessage }
disabled = { sending || ! sessionId || sessionEnded }
className = "h-10 px-3 rounded-full border-0 bg-[#d97706] hover:bg-[#b45309] disabled:opacity-40 cursor-pointer text-white text-xs shrink-0"
>
{ leaveSent ? '再留言' : '提交留言' }
< / button >
) }
< / div >
< / footer >
{ showRating && (
< div className = "absolute inset-0 bg-black/40 flex items-center justify-center z-10" >
< div className = "bg-white rounded-xl p-6 mx-8 w-full max-w-xs text-center shadow-lg" >
< div className = "text-lg font-semibold text-neutral-800 mb-1" > 本 次 服 务 如 何 ? < / div >
< div className = "text-sm text-neutral-400 mb-4" > 请 对 我 们 的 服 务 进 行 评 价 < / div >
< div className = "flex justify-center gap-1.5 mb-4" >
{ [ 1 , 2 , 3 , 4 , 5 ] . map ( star = > (
< StarFilled
key = { star }
className = "text-2xl cursor-pointer transition-colors"
style = { { color : star <= hoverStar ? '#facc15' : '#e2e8f0' } }
onMouseEnter = { ( ) = > setHoverStar ( star ) }
onMouseLeave = { ( ) = > setHoverStar ( 0 ) }
onClick = { ( ) = > submitRating ( star ) }
/ >
) ) }
< / div >
< textarea
className = "w-full rounded-lg border border-neutral-200 p-2 text-sm outline-none focus:border-blue-400 mb-3 resize-none"
rows = { 3 }
maxLength = { 500 }
value = { ratingText }
onChange = { e = > setRatingText ( e . target . value ) }
placeholder = "可选:写下您的服务感受"
/ >
< button type = "button" onClick = { ( ) = > setShowRating ( false ) } className = "text-sm text-neutral-400 hover:text-neutral-600 border-0 bg-transparent cursor-pointer" >
跳 过
< / button >
< / div >
< / div >
) }
< / div >
)
return (
< >
{ ! open && (
{ ! open && layout === 'floating' && (
< button
type = "button"
onClick = { handleOpen }
@@ -282,233 +645,7 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
< MessageOutlined className = "text-xl" / >
< / button >
) }
{ open && (
< div
className = "fixed z-50 right-6 bottom-6 w-[400px] h-[600px] max-w-[calc(100vw-32px)] max-h-[calc(100vh-32px)] flex flex-col rounded-xl overflow-hidden bg-white"
style = { { boxShadow : 'var(--shadow-floating)' } }
>
< header className = "shrink-0 px-4 py-4 bg-[#2563eb] text-white flex items-center gap-3" >
< div className = "w-10 h-10 rounded-full bg-white/20 flex items-center justify-center shrink-0" >
< CustomerServiceOutlined className = "text-lg text-white" / >
< / div >
< div className = "flex-1 min-w-0" >
< h1 className = "m-0 text-[15px] font-semibold leading-tight text-white" > 在 线 客 服 < / h1 >
< p className = "m-0 text-xs leading-normal text-white/80 flex items-center gap-1 mt-0.5" >
< span className = "inline-block w-1.5 h-1.5 rounded-full bg-[#16a34a]" / >
{ sessionEnded ? '会话已结束' : '正在为您服务' }
< / p >
< / div >
< div className = "flex items-center gap-1 shrink-0" >
< button
type = "button"
onClick = { handleMinimize }
className = "w-7 h-7 rounded bg-white/15 hover:bg-white/25 border-0 cursor-pointer flex items-center justify-center text-white"
aria-label = "最小化"
>
< MinusOutlined className = "text-xs" / >
< / button >
< button
type = "button"
onClick = { handleClose }
className = "w-7 h-7 rounded bg-white/15 hover:bg-white/25 border-0 cursor-pointer flex items-center justify-center text-white"
aria-label = "关闭"
>
< CloseOutlined className = "text-xs" / >
< / button >
< / div >
< / header >
< section className = "flex-1 overflow-y-auto p-4 flex flex-col gap-3 bg-neutral-50 no-scrollbar" >
{ showWelcome && (
< div className = "flex flex-col items-center gap-3" >
< div className = "px-4 py-2 rounded-xl bg-white border border-neutral-200 max-w-[85%] text-center" >
< p className = "m-0 text-[13px] text-neutral-600 leading-normal" >
您 好 ! 欢 迎 咨 询 , 请 问 有 什 么 可 以 帮 您 ?
< / p >
< / div >
< / div >
) }
{ showQuick && (
< div className = "flex flex-wrap gap-2 justify-center" >
{ quickQuestions . map ( ( q , i ) = > (
< button
key = { i }
type = "button"
onClick = { ( ) = > sendMessage ( q ) }
disabled = { ! sessionId || sending || sessionEnded }
className = "px-3 py-1 rounded-full border border-[#2563eb] bg-white text-[#2563eb] text-xs cursor-pointer whitespace-nowrap hover:bg-[#eff6ff] disabled:opacity-50"
>
{ q }
< / button >
) ) }
< / div >
) }
{ messages . map ( msg = > (
msg . sender === 'visitor' ? (
< div key = { msg . id } className = "flex justify-end max-w-[85%] ml-auto" >
< div className = { ` rounded-xl bg-[#2563eb] text-white text-[13px] leading-normal ${ msg . type === 'image' ? 'p-1.5' : 'px-4 py-3' } ` } >
{ msg . type === 'image' ? (
< img src = { msg . content } alt = "图片" className = "max-w-[180px] rounded-lg block" / >
) : (
< p className = "m-0 whitespace-pre-wrap break-words" > { msg . content } < / p >
) }
< / div >
< / div >
) : (
< div key = { msg . id } className = "flex gap-2 max-w-[85%]" >
< div className = "w-8 h-8 rounded-full bg-neutral-200 flex items-center justify-center shrink-0 mt-0.5" >
< CustomerServiceOutlined className = "text-xs text-neutral-500" / >
< / div >
< div className = { ` rounded-xl bg-white border border-neutral-200 text-[13px] text-neutral-800 leading-normal ${ msg . type === 'image' ? 'p-1.5' : 'px-4 py-3' } ` } >
{ msg . type === 'image' ? (
< img src = { msg . content } alt = "图片" className = "max-w-[180px] rounded-lg block" / >
) : (
< p className = "m-0 whitespace-pre-wrap break-words" > { msg . content } < / p >
) }
< / div >
< / div >
)
) ) }
{ agentTyping && (
< >
< div className = "flex gap-2 max-w-[85%]" >
< div className = "w-8 h-8 rounded-full bg-neutral-200 flex items-center justify-center shrink-0 mt-0.5" >
< CustomerServiceOutlined className = "text-xs text-neutral-500" / >
< / div >
< div className = "px-4 py-3 rounded-xl bg-white border border-neutral-200 flex items-center gap-1" >
< span className = "typing-dot" style = { { animationDelay : '0s' } } / >
< span className = "typing-dot" style = { { animationDelay : '0.2s' } } / >
< span className = "typing-dot" style = { { animationDelay : '0.4s' } } / >
< / div >
< / div >
< p className = "text-left text-xs text-neutral-400 m-0 pl-[42px]" > 客 服 正 在 输 入 . . . < / p >
< / >
) }
< div ref = { chatEndRef } / >
< / section >
< footer className = "shrink-0 relative px-4 py-3 bg-white border-t border-neutral-200" >
< div
className = "absolute top-0 left-0 right-0 h-[3px] pointer-events-none opacity-40"
style = { { background : 'linear-gradient(90deg, transparent 0%, #2563eb 50%, transparent 100%)' } }
/ >
{ sendError && (
< div className = "mb-2 text-xs text-red-500" > { sendError } < / div >
) }
{ imagePreview && (
< div className = "mb-2 p-2 rounded-lg border border-neutral-200 bg-neutral-50 flex items-center gap-2" >
< img src = { imagePreview } alt = "预览" className = "w-14 h-14 object-cover rounded" / >
< div className = "flex-1 min-w-0 text-xs text-neutral-500" > 确 认 发 送 图 片 ? < / div >
< button
type = "button"
className = "text-xs text-neutral-400 hover:text-neutral-600 border-0 bg-transparent cursor-pointer"
onClick = { ( ) = > setImagePreview ( null ) }
>
取 消
< / button >
< button
type = "button"
disabled = { sending }
className = "text-xs px-2 py-1 rounded-md bg-[#2563eb] text-white border-0 cursor-pointer disabled:opacity-50"
onClick = { sendImage }
>
发 送
< / button >
< / div >
) }
< div className = "flex items-center gap-2 mb-2" >
< button
type = "button"
className = "w-8 h-8 rounded-md border-0 bg-transparent cursor-pointer flex items-center justify-center text-neutral-400 hover:bg-neutral-50 disabled:opacity-40"
aria-label = "发送图片"
onClick = { ( ) = > fileInputRef . current ? . click ( ) }
disabled = { sessionEnded || ! sessionId || sending }
>
< PictureOutlined className = "text-base" / >
< / button >
< button
type = "button"
className = "w-8 h-8 rounded-md border-0 bg-transparent cursor-pointer flex items-center justify-center text-neutral-400 hover:bg-neutral-50"
aria-label = "表情"
>
< SmileOutlined className = "text-base" / >
< / button >
< input
ref = { fileInputRef }
type = "file"
accept = "image/jpeg,image/png,image/gif"
className = "hidden"
onChange = { e = > {
handleImageFile ( e . target . files ? . [ 0 ] )
e . currentTarget . value = ''
} }
/ >
< / div >
< div className = "flex items-center gap-2" >
< input
className = "flex-1 min-w-0 h-10 px-3 rounded-xl border border-neutral-200 bg-neutral-50 text-[13px] text-neutral-800 outline-none focus:border-[#2563eb]"
placeholder = { sessionEnded ? '会话已结束' : sessionId ? '输入消息...' : '正在连接...' }
value = { input }
onChange = { e = > { setInput ( e . target . value ) ; emitTyping ( ) } }
onPaste = { e = > {
const item = Array . from ( e . clipboardData . items ) . find ( i = > i . type . startsWith ( 'image/' ) )
if ( item ) {
e . preventDefault ( )
handleImageFile ( item . getAsFile ( ) )
}
} }
onKeyDown = { e = > { if ( e . key === 'Enter' ) sendMessage ( input ) } }
disabled = { sending || ! sessionId || sessionEnded }
/ >
< button
type = "button"
onClick = { ( ) = > sendMessage ( input ) }
disabled = { ! input . trim ( ) || sending || ! sessionId || sessionEnded }
className = "w-10 h-10 rounded-full border-0 bg-[#2563eb] hover:bg-[#1d4ed8] disabled:opacity-40 cursor-pointer flex items-center justify-center shrink-0"
aria-label = "发送"
>
< SendOutlined className = "text-white text-sm" / >
< / button >
< / div >
< / footer >
{ showRating && (
< div className = "absolute inset-0 bg-black/40 flex items-center justify-center z-10" >
< div className = "bg-white rounded-xl p-6 mx-8 w-full max-w-xs text-center shadow-lg" >
< div className = "text-lg font-semibold text-neutral-800 mb-1" > 本 次 服 务 如 何 ? < / div >
< div className = "text-sm text-neutral-400 mb-4" > 请 对 我 们 的 服 务 进 行 评 价 < / div >
< div className = "flex justify-center gap-1.5 mb-4" >
{ [ 1 , 2 , 3 , 4 , 5 ] . map ( star = > (
< StarFilled
key = { star }
className = "text-2xl cursor-pointer transition-colors"
style = { { color : star <= hoverStar ? '#facc15' : '#e2e8f0' } }
onMouseEnter = { ( ) = > setHoverStar ( star ) }
onMouseLeave = { ( ) = > setHoverStar ( 0 ) }
onClick = { ( ) = > submitRating ( star ) }
/ >
) ) }
< / div >
< textarea
className = "w-full rounded-lg border border-neutral-200 p-2 text-sm outline-none focus:border-blue-400 mb-3 resize-none"
rows = { 3 }
maxLength = { 500 }
value = { ratingText }
onChange = { e = > setRatingText ( e . target . value ) }
placeholder = "可选:写下您的服务感受"
/ >
< button type = "button" onClick = { ( ) = > setShowRating ( false ) } className = "text-sm text-neutral-400 hover:text-neutral-600 border-0 bg-transparent cursor-pointer" >
跳 过
< / button >
< / div >
< / div >
) }
< / div >
) }
{ panel }
< / >
)
}