From 3ffa9ddc89109ec27fd66c4c7363b4701072be88 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Wed, 15 Jul 2026 11:01:38 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=20P0=20=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E5=8F=B0=E4=B8=8E=E8=AE=BF=E5=AE=A2=20Widget=EF=BC=8C=E5=AF=B9?= =?UTF-8?q?=E9=BD=90=E8=AE=BE=E8=AE=A1=E7=A8=BF=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 工作台改为设计稿三栏:320 会话列表 + 聊天区 + 320 客户信息 - 会话列表展示头像色条、最后消息预览、相对时间与未读角标 - 消息方向对齐设计(访客左、客服右)并补系统会话条 - 访客 Widget 还原欢迎语、快捷问题、输入状态动画与实色顶栏 - 会话列表 API 返回 last_message 供列表预览 --- server/internal/handler/session.go | 18 +- web/src/components/layout/AgentLayout.tsx | 15 +- web/src/components/layout/AgentSidebar.tsx | 89 ++- web/src/index.css | 30 + web/src/pages/WidgetPreview.tsx | 35 +- web/src/pages/agent/Dashboard.tsx | 721 +++++++++++++++++---- web/src/services/api.ts | 4 +- web/src/widgets/VisitorChat.tsx | 244 +++++-- 8 files changed, 930 insertions(+), 226 deletions(-) diff --git a/server/internal/handler/session.go b/server/internal/handler/session.go index 5b96b06..cdbc9c7 100644 --- a/server/internal/handler/session.go +++ b/server/internal/handler/session.go @@ -24,7 +24,9 @@ type SendMessageReq struct { type SessionListItem struct { model.Session - UnreadCount int `json:"unread_count"` + UnreadCount int `json:"unread_count"` + LastMessage string `json:"last_message"` + LastMessageAt *time.Time `json:"last_message_at,omitempty"` } type CreateNoteReq struct { @@ -194,6 +196,20 @@ func (h *SessionHandler) List(c *gin.Context) { if middleware.GetRole(c) == "agent" && session.AgentID != nil && *session.AgentID == middleware.GetUserID(c) { item.UnreadCount = unreadCount(session) } + var lastMsg model.Message + if err := model.DB.Where("session_id = ?", session.ID).Order("seq desc").First(&lastMsg).Error; err == nil { + if lastMsg.Type == "image" { + item.LastMessage = "[图片]" + } else { + item.LastMessage = lastMsg.Content + if utf8.RuneCountInString(item.LastMessage) > 40 { + runes := []rune(item.LastMessage) + item.LastMessage = string(runes[:40]) + "…" + } + } + t := lastMsg.SentAt + item.LastMessageAt = &t + } items = append(items, item) } diff --git a/web/src/components/layout/AgentLayout.tsx b/web/src/components/layout/AgentLayout.tsx index eb4c168..704be88 100644 --- a/web/src/components/layout/AgentLayout.tsx +++ b/web/src/components/layout/AgentLayout.tsx @@ -1,19 +1,14 @@ import { Outlet } from 'react-router-dom' -import { Layout } from 'antd' import AgentSidebar from './AgentSidebar' -const { Content } = Layout - const AgentLayout = () => { return ( - +
- - - - - - +
+ +
+
) } diff --git a/web/src/components/layout/AgentSidebar.tsx b/web/src/components/layout/AgentSidebar.tsx index 78909cb..4531113 100644 --- a/web/src/components/layout/AgentSidebar.tsx +++ b/web/src/components/layout/AgentSidebar.tsx @@ -1,17 +1,14 @@ import { useLocation, useNavigate } from 'react-router-dom' -import { Layout, Menu, Dropdown, Avatar } from 'antd' import { AppstoreOutlined, MessageOutlined, HistoryOutlined, TeamOutlined, - FileTextOutlined, BarChartOutlined, SettingOutlined, LogoutOutlined, UserOutlined, + FileTextOutlined, BarChartOutlined, SettingOutlined, LogoutOutlined, } from '@ant-design/icons' import { useAuth } from '@/stores/auth' -const { Sider } = Layout - const menuItems = [ { key: '/agent/dashboard', icon: , label: '工作台' }, - { key: '/agent/chat-history', icon: , label: '对话记录' }, { key: '/agent/customers', icon: , label: '客户管理' }, + { key: '/agent/chat-history', icon: , label: '对话记录' }, { key: '/agent/knowledge', icon: , label: '知识库' }, { key: '/agent/statistics', icon: , label: '数据统计' }, { key: '/agent/settings', icon: , label: '系统设置' }, @@ -34,34 +31,68 @@ const AgentSidebar = () => { navigate('/login', { replace: true }) } + const initial = (user?.nickname || '客').slice(0, 1) + return ( - -
- - 客服云 + ) } diff --git a/web/src/index.css b/web/src/index.css index 2dd52bf..f23f488 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -2,6 +2,9 @@ :root { --brand-primary: #2563eb; + --brand-primary-hover: #1d4ed8; + --brand-primary-light: #dbeafe; + --brand-primary-lighter: #eff6ff; --neutral-0: #ffffff; --neutral-50: #f8fafc; --neutral-100: #f1f5f9; @@ -15,13 +18,40 @@ --neutral-900: #0f172a; --neutral-950: #020617; --success: #16a34a; + --success-bg: #f0fdf4; --warning: #d97706; + --warning-bg: #fffbeb; --error: #dc2626; + --error-bg: #fef2f2; --info: #0891b2; + --info-bg: #ecfeff; + --sidebar-width: 240px; + --panel-width: 320px; + --header-height: 56px; + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.03); + --shadow-floating: 0 8px 24px rgba(0, 0, 0, 0.12); } body { margin: 0; font-family: 'Inter', 'PingFang SC', 'Microsoft YaHei', -apple-system, BlinkMacSystemFont, sans-serif; -webkit-font-smoothing: antialiased; + background: var(--neutral-50); + color: var(--neutral-900); } + +@keyframes typingDot { + 0%, 60%, 100% { opacity: 0.3; transform: translateY(0); } + 30% { opacity: 1; transform: translateY(-3px); } +} + +.typing-dot { + width: 6px; + height: 6px; + border-radius: 9999px; + background: var(--neutral-400); + animation: typingDot 1.4s infinite; +} + +.no-scrollbar::-webkit-scrollbar { display: none; } +.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; } diff --git a/web/src/pages/WidgetPreview.tsx b/web/src/pages/WidgetPreview.tsx index e2f24e1..9b8bb4b 100644 --- a/web/src/pages/WidgetPreview.tsx +++ b/web/src/pages/WidgetPreview.tsx @@ -1,23 +1,30 @@ -import { useState } from 'react' import VisitorChat from '@/widgets/VisitorChat' const WidgetPreview = () => { - const [showWidget, setShowWidget] = useState(true) - return ( -
-
-
-

示例网站页面

-

Widget 在右下角,发送消息会写入数据库

- {!showWidget && ( - - )} +
+ {/* 模拟宿主站点骨架,贴近设计稿模糊背景 */} +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

访客 Widget 预览

+

右下角为可嵌入聊天组件 · 消息会写入数据库

- {showWidget && } +
) } diff --git a/web/src/pages/agent/Dashboard.tsx b/web/src/pages/agent/Dashboard.tsx index c10b7c8..af64f0f 100644 --- a/web/src/pages/agent/Dashboard.tsx +++ b/web/src/pages/agent/Dashboard.tsx @@ -1,8 +1,9 @@ import { useState, useEffect, useRef, useCallback } from 'react' -import { Button, Dropdown, Input, Modal, Select, Spin, Tooltip, message as antMsg } from 'antd' +import { Button, Dropdown, Input, Modal, Select, Spin, message as antMsg } from 'antd' import { CheckCircleOutlined, FileTextOutlined, FlagOutlined, PaperClipOutlined, - SearchOutlined, SendOutlined, StarFilled, SwapOutlined, + SearchOutlined, SendOutlined, SmileOutlined, SwapOutlined, FilterOutlined, + ExportOutlined, BookOutlined, PictureOutlined, } from '@ant-design/icons' import { useAuth } from '@/stores/auth' import { @@ -11,9 +12,6 @@ import { type AvailableAgent, type Customer, type KnowledgeEntry, type Message, type Session, type SessionEvent, } from '@/services/api' -const priorityColors: Record = { urgent: '#dc2626', normal: '#2563eb' } -const priorityLabels: Record = { urgent: '紧急', normal: '普通' } -const statusLabels: Record = { active: '进行中', waiting: '等待中', ended: '已结束' } const endReasons = [ { value: 'resolved', label: '已解决' }, { value: 'no_response', label: '无人回复' }, @@ -27,6 +25,47 @@ const quickReplies = [ '为更快处理,请您提供订单号或截图。', ] +/** 列表项展示:紧急 / 等待中 / 进行中 */ +function listStatusMeta(session: Session) { + if (session.priority === 'urgent') { + return { label: '紧急', bar: '#dc2626', avatarBg: '#fef2f2', avatarColor: '#dc2626', dot: '#dc2626' } + } + if (session.status === 'waiting') { + return { label: '等待中', bar: '#d97706', avatarBg: '#fffbeb', avatarColor: '#d97706', dot: '#d97706' } + } + return { label: '进行中', bar: '#2563eb', avatarBg: '#eff6ff', avatarColor: '#2563eb', dot: '#2563eb' } +} + +function relativeTime(iso?: string | null) { + if (!iso) return '' + const diff = Date.now() - new Date(iso).getTime() + const mins = Math.floor(diff / 60000) + if (mins < 1) return '刚刚' + if (mins < 60) return `${mins}分钟前` + const hours = Math.floor(mins / 60) + if (hours < 24) return `${hours}小时前` + const days = Math.floor(hours / 24) + if (days < 7) return `${days}天前` + return new Date(iso).toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' }) +} + +function parseTags(tagsStr: string): string[] { + try { + const parsed = JSON.parse(tagsStr) + return Array.isArray(parsed) ? parsed : [] + } catch { + return tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : [] + } +} + +function channelLabel(source?: string) { + if (!source) return '网页' + if (source.includes('微信') || source.toLowerCase().includes('wechat')) return '微信' + if (source.toLowerCase().includes('app')) return 'APP' + if (source.includes('电话')) return '电话' + return source.length > 6 ? '网页' : source +} + interface SessionDetail { messages: Message[] events: SessionEvent[] @@ -56,6 +95,7 @@ const Dashboard = () => { const [imagePreview, setImagePreview] = useState(null) const [noteInput, setNoteInput] = useState('') const [savingNote, setSavingNote] = useState(false) + const [customerHistory, setCustomerHistory] = useState([]) const initialLoad = useRef(true) const chatEndRef = useRef(null) const fileInputRef = useRef(null) @@ -76,7 +116,9 @@ const Dashboard = () => { setSessions(sessionList) setCustomers(Object.fromEntries(customerList.map(customer => [customer.id, customer]))) if (sessionList.length > 0 && initialLoad.current) { - const preferred = sessionList.find(session => session.status === 'active') || sessionList[0] + const preferred = sessionList.find(session => session.status === 'active') + || sessionList.find(session => session.priority === 'urgent') + || sessionList[0] setSelectedId(preferred.id) initialLoad.current = false } @@ -124,7 +166,7 @@ const Dashboard = () => { loadAll() } } catch { - // 忽略格式错误的实时消息 + // ignore } } return () => { @@ -148,15 +190,43 @@ const Dashboard = () => { const selected = sessions.find(session => session.id === selectedId) const selectedCustomer = selected ? customers[selected.customer_id] : null const canOperate = Boolean(selected && (isManager || selected.agent_id === user?.user_id) && selected.status === 'active') - const filteredSessions = sessions.filter(session => { - if (session.status === 'ended') return false - const customer = customers[session.customer_id] - const keyword = search.trim().toLowerCase() - return !keyword || customer?.name.toLowerCase().includes(keyword) || String(session.id).includes(keyword) - }) - const urgentSessions = filteredSessions.filter(session => session.priority === 'urgent') - const waitingSessions = filteredSessions.filter(session => session.status === 'waiting' && session.priority !== 'urgent') - const activeSessions = filteredSessions.filter(session => session.status === 'active' && session.priority !== 'urgent') + + useEffect(() => { + if (!selectedCustomer) { + setCustomerHistory([]) + return + } + // 从已加载会话中筛出该客户的历史(含已结束) + getSessions({ page: 1, pageSize: 50 }).then(res => { + const list = Array.isArray(res.list) ? res.list : [] + setCustomerHistory( + list + .filter(s => s.customer_id === selectedCustomer.id && s.id !== selectedId) + .slice(0, 5), + ) + }).catch(() => setCustomerHistory([])) + }, [selectedCustomer?.id, selectedId]) + + const filteredSessions = sessions + .filter(session => { + if (session.status === 'ended') return false + const customer = customers[session.customer_id] + const keyword = search.trim().toLowerCase() + return !keyword + || customer?.name.toLowerCase().includes(keyword) + || String(session.id).includes(keyword) + || (session.last_message || '').toLowerCase().includes(keyword) + }) + .slice() + .sort((a, b) => { + const rank = (s: Session) => (s.priority === 'urgent' ? 0 : s.status === 'waiting' ? 1 : 2) + const r = rank(a) - rank(b) + if (r !== 0) return r + const ta = new Date(a.last_message_at || a.created_at).getTime() + const tb = new Date(b.last_message_at || b.created_at).getTime() + return tb - ta + }) + const notes = detail?.events.filter(event => event.action === 'note').slice().reverse() || [] const emitTyping = () => { @@ -273,109 +343,530 @@ const Dashboard = () => { } } - const renderSessionGroup = (title: string, items: Session[], color: string) => ( -
-
- - {title} · {items.length} -
- {items.map(session => { - const customer = customers[session.customer_id] - return
-
- {statusLabels[session.status]} - {session.status === 'waiting' && user?.role === 'agent' ? { event.stopPropagation(); handleClaim(session.id) }} - onKeyDown={event => { if (event.key === 'Enter') { event.stopPropagation(); handleClaim(session.id) } }}>领取 : - {new Date(session.created_at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}} +
+ + 当前会话 {filteredSessions.length} 个 + + + + 预览访客窗口 +
- - })} +
+ +
+ {filteredSessions.length === 0 ? ( +
暂无待处理会话
+ ) : filteredSessions.map(session => { + const customer = customers[session.customer_id] + const name = customer?.name || `客户${session.customer_id}` + const meta = listStatusMeta(session) + const selected = selectedId === session.id + return ( + + ) + })} +
+ + + {/* 主聊天区 */} +
+ {selected && selectedCustomer ? ( + <> +
+
+
+ {selectedCustomer.name.slice(0, 1)} +
+
+
+ {selectedCustomer.name} + + {channelLabel(selectedCustomer.source)} + + + + {selectedCustomer.status === 'online' ? '在线' : selectedCustomer.status === 'busy' ? '忙碌' : '离线'} + +
+
+ 会话 #{selected.id} + {selectedCustomer.source && ( + <> + | + {selectedCustomer.source} + + )} +
+
+
+
+ handlePriority('urgent') }, + { key: 'normal', label: '取消紧急', onClick: () => handlePriority('normal') }, + ], + }} + disabled={!canOperate} + > + + + + + +
+
+ +
+ {detailLoading ? ( +
+ ) : ( + <> +
+ + 会话开始 — {new Date(selected.created_at).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })} + +
+ {(detail?.messages || []).map(message => { + const isAgent = message.sender_type === 'agent' + const visitorInitial = selectedCustomer.name.slice(0, 1) + return ( +
+
+ {isAgent ? agentInitial : visitorInitial} +
+
+
+ {message.type === 'image' ? ( + 聊天图片 + ) : ( +

{message.content}

+ )} +
+
+ {new Date(message.sent_at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })} +
+
+
+ ) + })} + {detail?.messages.length === 0 && ( +
暂无消息,开始对话吧
+ )} +
+ + )} +
+ +
+ {selected.status !== 'active' || !canOperate ? ( +
+ {selected.status === 'waiting' ? '领取会话后即可回复' : '会话已结束,无法继续发送消息'} +
+ ) : ( + <> +
+ + + +
+ ({ + key: String(index), + label: content, + onClick: () => setMessageInput(content), + })), + }} + > + + + +
+
+
+ { setMessageInput(event.target.value); emitTyping() }} + onPaste={event => { + const image = Array.from(event.clipboardData.items).find(item => item.type.startsWith('image/')) + if (image) { + event.preventDefault() + handleImage(image.getAsFile() || undefined) + } + }} + onKeyDown={event => { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault() + sendMessage(messageInput) + } + }} + /> +
+ +
+ + )} +
+ + ) : ( +
+
+ +
+
选择一个会话开始处理
+
+ )} +
+ + {/* 右侧客户信息 320px */} + + + setTransferOpen(false)} onOk={handleTransfer} okButtonProps={{ disabled: !targetAgentID }}> +

请选择一位在线客服接手当前会话。

+ +
+ setImagePreview(null)} + onOk={() => { if (imagePreview) sendMessage(imagePreview, 'image'); setImagePreview(null) }} + okText="发送" + okButtonProps={{ loading: sending }} + > +
+ 待发送图片预览 +
+
+ setKnowledgeOpen(false)} footer={null} width={640}> + } + placeholder="搜索标题或内容" + value={knowledgeKeyword} + onChange={event => setKnowledgeKeyword(event.target.value)} + allowClear + className="mb-3" + /> + {knowledgeLoading ? ( +
+ ) : ( +
+ {knowledgeEntries.length === 0 ? ( +
未找到可用知识条目
+ ) : knowledgeEntries.map(entry => ( + + ))} +
+ )} +
) - - if (loading) return
- - return
- { handleImage(event.target.files?.[0]); event.currentTarget.value = '' }} /> - - -
- {selected && selectedCustomer ? <> -
-
{selectedCustomer.name}{priorityLabels[selected.priority]}{statusLabels[selected.status]}
会话 #{selected.id}
-
- handlePriority('urgent') }, { key: 'normal', label: '标记普通', onClick: () => handlePriority('normal') }] }} disabled={!canOperate}> - - - -
-
-
- {detailLoading ?
: detail?.messages.length ? detail.messages.map(message =>
-
- {message.type === 'image' ? 聊天图片 :
{message.content}
} -
{message.sender_type === 'agent' ? '客服' : '访客'} · {new Date(message.sent_at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}
-
-
) :
暂无消息,开始对话吧
} -
-
-
- {selected.status !== 'active' || !canOperate ?
{selected.status === 'waiting' ? '领取会话后即可回复' : '会话已结束'}
: <> -
- ({ key: String(index), label: content, onClick: () => setMessageInput(content) })) }}> -
-
- { setMessageInput(event.target.value); emitTyping() }} - onPaste={event => { const image = Array.from(event.clipboardData.items).find(item => item.type.startsWith('image/')); if (image) { event.preventDefault(); handleImage(image.getAsFile() || undefined) } }} - onKeyDown={event => { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); sendMessage(messageInput) } }} /> -
- } -
- :
选择一个会话开始处理
} -
- - - - setTransferOpen(false)} onOk={handleTransfer} okButtonProps={{ disabled: !targetAgentID }}> -

请选择一位在线客服接手当前会话。

- setImagePreview(null)} onOk={() => { if (imagePreview) sendMessage(imagePreview, 'image'); setImagePreview(null) }} okText="发送" okButtonProps={{ loading: sending }}>
待发送图片预览
- setKnowledgeOpen(false)} footer={null} width={640}>} placeholder="搜索标题或内容" value={knowledgeKeyword} onChange={event => setKnowledgeKeyword(event.target.value)} allowClear className="mb-3" />{knowledgeLoading ?
:
{knowledgeEntries.length === 0 ?
未找到可用知识条目
: knowledgeEntries.map(entry => )}
}
-
} export default Dashboard diff --git a/web/src/services/api.ts b/web/src/services/api.ts index 907eba6..f86fa2f 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -5,7 +5,9 @@ export interface LoginResult { token: string; user_id: number; tenant_id: number export interface Session { id: number; tenant_id: number; channel_id: number; customer_id: number; agent_id: number | null - status: string; priority: string; unread_count: number; satisfaction_score: number | null; created_at: string; ended_at: string | null + status: string; priority: string; unread_count: number; satisfaction_score: number | null + last_message?: string; last_message_at?: string | null + created_at: string; ended_at: string | null } export interface Message { diff --git a/web/src/widgets/VisitorChat.tsx b/web/src/widgets/VisitorChat.tsx index 79cbca9..4972239 100644 --- a/web/src/widgets/VisitorChat.tsx +++ b/web/src/widgets/VisitorChat.tsx @@ -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(null) const initRef = useRef(false) const typingTimerRef = useRef(null) + const chatEndRef = useRef(null) + const fileInputRef = useRef(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 && ( - )} {open && ( -
-
-
-
- -
-
-
客服云
-
在线 · 会话{sessionId || '...'}
-
+
+ {/* Header — solid brand blue */} +
+
+
- -
+
+

在线客服

+

+ + {sessionEnded ? '会话已结束' : '正在为您服务'} +

+
+
+ + +
+ -
- {messages.length === 0 && ( -
- -
欢迎咨询,请描述您的问题
-
- )} - {agentTyping &&
客服正在输入…
} - {messages.map(msg => ( -
-
-
{msg.sender === 'agent' && 客服}{msg.content}
- {msg.time &&
{msg.time}
} + {/* Messages */} +
+ {showWelcome && ( +
+
+

+ 您好!欢迎咨询,请问有什么可以帮您? +

- ))} -
+ )} - {messages.length <= 1 && ( -
-
快捷问题:
-
+ {showQuick && ( +
{quickQuestions.map((q, i) => ( - + ))}
-
- )} + )} -
-
- + {messages.map(msg => ( + msg.sender === 'visitor' ? ( +
+
+ {msg.type === 'image' ? ( + 图片 + ) : ( +

{msg.content}

+ )} +
+
+ ) : ( +
+
+ +
+
+ {msg.type === 'image' ? ( + 图片 + ) : ( +

{msg.content}

+ )} +
+
+ ) + ))} + + {agentTyping && ( + <> +
+
+ +
+
+ + + +
+
+

客服正在输入...

+ + )} +
+ + + {/* Footer */} +
+
+
+ + + +
+
setInput(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') sendMessage(input) }} disabled={sending || !sessionId || sessionEnded} /> - sendMessage(input)} /> +
-
+
{showRating && (
-
+
本次服务如何?
请对我们的服务进行评价
{[1, 2, 3, 4, 5].map(star => ( - submitRating(star)} /> + setHoverStar(star)} + onMouseLeave={() => setHoverStar(0)} + onClick={() => submitRating(star)} + /> ))}