优化 P0 工作台与访客 Widget,对齐设计稿 UI
- 工作台改为设计稿三栏:320 会话列表 + 聊天区 + 320 客户信息 - 会话列表展示头像色条、最后消息预览、相对时间与未读角标 - 消息方向对齐设计(访客左、客服右)并补系统会话条 - 访客 Widget 还原欢迎语、快捷问题、输入状态动画与实色顶栏 - 会话列表 API 返回 last_message 供列表预览
This commit is contained in:
+188
-56
@@ -1,14 +1,18 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { CloseOutlined, MessageOutlined, SmileOutlined, SendOutlined, StarFilled } from '@ant-design/icons'
|
||||
import {
|
||||
CloseOutlined, MessageOutlined, SmileOutlined, SendOutlined,
|
||||
StarFilled, MinusOutlined, PictureOutlined, CustomerServiceOutlined,
|
||||
} from '@ant-design/icons'
|
||||
|
||||
interface Message {
|
||||
id: number
|
||||
sender: 'visitor' | 'agent'
|
||||
content: string
|
||||
time: string
|
||||
type?: 'text' | 'image'
|
||||
}
|
||||
|
||||
const quickQuestions = ['产品功能介绍', '价格咨询', '售后服务', '合作咨询']
|
||||
const quickQuestions = ['查询订单状态', '退换货政策', '配送时效说明']
|
||||
const STORAGE_KEY = 'kefu_widget_session'
|
||||
const VISITOR_TOKEN_KEY = 'kefu_widget_visitor_token'
|
||||
|
||||
@@ -30,9 +34,12 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
||||
const [sessionEnded, setSessionEnded] = useState(false)
|
||||
const [agentTyping, setAgentTyping] = useState(false)
|
||||
const [ratingText, setRatingText] = useState('')
|
||||
const [hoverStar, setHoverStar] = useState(0)
|
||||
const pollRef = useRef<number | null>(null)
|
||||
const initRef = useRef(false)
|
||||
const typingTimerRef = useRef<number | null>(null)
|
||||
const chatEndRef = useRef<HTMLDivElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const loadMessages = useCallback(async (sid?: number, token?: string) => {
|
||||
const s = sid || sessionId
|
||||
@@ -48,6 +55,7 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
||||
id: m.id,
|
||||
sender: m.sender_type === 'agent' ? 'agent' : 'visitor',
|
||||
content: m.content,
|
||||
type: m.type === 'image' ? 'image' : 'text',
|
||||
time: new Date(m.sent_at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
|
||||
}))
|
||||
setMessages(msgs)
|
||||
@@ -81,9 +89,7 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
||||
}, [sessionId, visitorToken, loadMessages])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
initSession()
|
||||
}
|
||||
if (open) initSession()
|
||||
}, [open, initSession])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -114,10 +120,11 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
||||
setSessionEnded(true)
|
||||
setShowRating(true)
|
||||
}
|
||||
setAgentTyping(false)
|
||||
loadMessages(sessionId, visitorToken)
|
||||
}
|
||||
} catch {
|
||||
// 忽略格式错误的实时消息
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
@@ -126,14 +133,21 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
||||
}
|
||||
}, [sessionId, visitorToken, open, loadMessages])
|
||||
|
||||
useEffect(() => {
|
||||
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages, agentTyping, open])
|
||||
|
||||
const sendMessage = async (text: string) => {
|
||||
if (!text.trim() || sending) return
|
||||
if (!text.trim() || sending || sessionEnded) return
|
||||
const content = text.trim()
|
||||
setInput('')
|
||||
setSending(true)
|
||||
|
||||
const localMsg: Message = {
|
||||
id: -Date.now(), sender: 'visitor', content,
|
||||
id: -Date.now(),
|
||||
sender: 'visitor',
|
||||
content,
|
||||
type: 'text',
|
||||
time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
|
||||
}
|
||||
setMessages(prev => [...prev, localMsg])
|
||||
@@ -161,6 +175,10 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
||||
if (sessionEnded && !rated) setShowRating(true)
|
||||
}
|
||||
|
||||
const handleMinimize = () => {
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const submitRating = async (score: number) => {
|
||||
if (!sessionId || !visitorToken || rated) return
|
||||
try {
|
||||
@@ -175,97 +193,211 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
||||
setShowRating(false)
|
||||
}
|
||||
} catch {
|
||||
// 评价失败时保留弹窗,允许访客稍后重试
|
||||
// keep modal
|
||||
}
|
||||
}
|
||||
|
||||
const showWelcome = messages.length === 0
|
||||
const showQuick = messages.length <= 1 && !sessionEnded
|
||||
|
||||
return (
|
||||
<>
|
||||
{!open && (
|
||||
<button onClick={handleOpen} className="fixed right-5 bottom-5 w-14 h-14 rounded-full bg-blue-500 hover:bg-blue-600 text-white shadow-lg flex items-center justify-center transition-all hover:scale-110 z-50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpen}
|
||||
className="fixed right-6 bottom-6 w-14 h-14 rounded-full bg-[#2563eb] hover:bg-[#1d4ed8] text-white flex items-center justify-center z-50 transition-transform hover:scale-105"
|
||||
style={{ boxShadow: 'var(--shadow-floating)' }}
|
||||
aria-label="打开在线客服"
|
||||
>
|
||||
<MessageOutlined className="text-xl" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{open && (
|
||||
<div className="fixed right-5 bottom-5 w-[400px] h-[600px] bg-white rounded-xl shadow-2xl border border-neutral-200 flex flex-col z-50 overflow-hidden">
|
||||
<div className="h-14 px-4 flex items-center justify-between bg-gradient-to-r from-blue-500 to-blue-600 flex-shrink-0">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded-full bg-white/20 flex items-center justify-center">
|
||||
<span className="text-white text-sm font-medium">云</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-white">客服云</div>
|
||||
<div className="text-xs text-white/70">在线 · 会话{sessionId || '...'}</div>
|
||||
</div>
|
||||
<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 — solid brand blue */}
|
||||
<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>
|
||||
<CloseOutlined className="text-white cursor-pointer hover:text-white/80" onClick={handleClose} />
|
||||
</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>
|
||||
|
||||
<div className="flex-1 overflow-auto p-4 space-y-3 bg-neutral-50">
|
||||
{messages.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-full text-neutral-400 gap-3">
|
||||
<MessageOutlined className="text-3xl" />
|
||||
<div className="text-sm">欢迎咨询,请描述您的问题</div>
|
||||
</div>
|
||||
)}
|
||||
{agentTyping && <div className="text-xs text-neutral-400">客服正在输入…</div>}
|
||||
{messages.map(msg => (
|
||||
<div key={msg.id} className={`flex ${msg.sender === 'visitor' ? 'justify-end' : 'justify-start'}`}>
|
||||
<div className={`max-w-[75%] rounded-lg px-3 py-2 text-sm ${msg.sender === 'visitor' ? 'bg-blue-500 text-white' : 'bg-white text-neutral-700 border border-neutral-200'}`}>
|
||||
<div>{msg.sender === 'agent' && <span className="block text-xs text-blue-500 font-medium mb-0.5">客服</span>}{msg.content}</div>
|
||||
{msg.time && <div className={`text-xs mt-1 ${msg.sender === 'visitor' ? 'text-white/60' : 'text-neutral-400'}`}>{msg.time}</div>}
|
||||
{/* Messages */}
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.length <= 1 && (
|
||||
<div className="px-4 py-2 border-t border-neutral-100 flex-shrink-0">
|
||||
<div className="text-xs text-neutral-400 mb-1.5">快捷问题:</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{showQuick && (
|
||||
<div className="flex flex-wrap gap-2 justify-center">
|
||||
{quickQuestions.map((q, i) => (
|
||||
<button key={i} onClick={() => sendMessage(q)} className="text-xs px-2.5 py-1 rounded-full bg-blue-50 text-blue-600 hover:bg-blue-100 transition-colors border border-blue-100">{q}</button>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
<div className="p-3 border-t border-neutral-100 flex-shrink-0">
|
||||
<div className="flex items-center gap-2 bg-neutral-50 rounded-lg px-3 py-1.5">
|
||||
<SmileOutlined className="text-neutral-300" />
|
||||
{messages.map(msg => (
|
||||
msg.sender === 'visitor' ? (
|
||||
<div key={msg.id} className="flex justify-end max-w-[85%] ml-auto">
|
||||
<div className="px-4 py-3 rounded-xl bg-[#2563eb] text-white text-[13px] leading-normal">
|
||||
{msg.type === 'image' ? (
|
||||
<img src={msg.content} alt="图片" className="max-w-[180px] rounded-lg" />
|
||||
) : (
|
||||
<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="px-4 py-3 rounded-xl bg-white border border-neutral-200 text-[13px] text-neutral-800 leading-normal">
|
||||
{msg.type === 'image' ? (
|
||||
<img src={msg.content} alt="图片" className="max-w-[180px] rounded-lg" />
|
||||
) : (
|
||||
<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 */}
|
||||
<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%)' }}
|
||||
/>
|
||||
<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"
|
||||
aria-label="发送图片"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={sessionEnded || !sessionId}
|
||||
>
|
||||
<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/*" className="hidden" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
className="flex-1 bg-transparent outline-none text-sm text-neutral-700 placeholder:text-neutral-400"
|
||||
placeholder={sessionId ? '输入消息... Enter 发送' : '正在连接...'}
|
||||
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)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') sendMessage(input) }}
|
||||
disabled={sending || !sessionId || sessionEnded}
|
||||
/>
|
||||
<SendOutlined className={`cursor-pointer ${sessionId && !sessionEnded ? 'text-blue-500 hover:text-blue-600' : 'text-neutral-300'}`} onClick={() => sendMessage(input)} />
|
||||
<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>
|
||||
</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">
|
||||
<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 text-neutral-200 hover:text-yellow-400 transition-colors"
|
||||
onClick={() => submitRating(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"
|
||||
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 onClick={() => setShowRating(false)} className="text-sm text-neutral-400 hover:text-neutral-600">跳过</button>
|
||||
<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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user