优化 P0 工作台与访客 Widget,对齐设计稿 UI

- 工作台改为设计稿三栏:320 会话列表 + 聊天区 + 320 客户信息
- 会话列表展示头像色条、最后消息预览、相对时间与未读角标
- 消息方向对齐设计(访客左、客服右)并补系统会话条
- 访客 Widget 还原欢迎语、快捷问题、输入状态动画与实色顶栏
- 会话列表 API 返回 last_message 供列表预览
This commit is contained in:
yml2213
2026-07-15 11:01:38 +08:00
parent b424dfb9a0
commit 3ffa9ddc89
8 changed files with 930 additions and 226 deletions
+188 -56
View File
@@ -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>
)}