优化 P0 工作台与访客 Widget,对齐设计稿 UI
- 工作台改为设计稿三栏:320 会话列表 + 聊天区 + 320 客户信息 - 会话列表展示头像色条、最后消息预览、相对时间与未读角标 - 消息方向对齐设计(访客左、客服右)并补系统会话条 - 访客 Widget 还原欢迎语、快捷问题、输入状态动画与实色顶栏 - 会话列表 API 返回 last_message 供列表预览
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import { Layout } from 'antd'
|
||||
import AgentSidebar from './AgentSidebar'
|
||||
|
||||
const { Content } = Layout
|
||||
|
||||
const AgentLayout = () => {
|
||||
return (
|
||||
<Layout className="h-screen">
|
||||
<div className="flex h-screen overflow-hidden bg-neutral-50">
|
||||
<AgentSidebar />
|
||||
<Layout>
|
||||
<Content className="overflow-auto bg-neutral-50">
|
||||
<Outlet />
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
<div className="flex-1 min-w-0 h-full overflow-auto">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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: <AppstoreOutlined />, label: '工作台' },
|
||||
{ key: '/agent/chat-history', icon: <HistoryOutlined />, label: '对话记录' },
|
||||
{ key: '/agent/customers', icon: <TeamOutlined />, label: '客户管理' },
|
||||
{ key: '/agent/chat-history', icon: <HistoryOutlined />, label: '对话记录' },
|
||||
{ key: '/agent/knowledge', icon: <FileTextOutlined />, label: '知识库' },
|
||||
{ key: '/agent/statistics', icon: <BarChartOutlined />, label: '数据统计' },
|
||||
{ key: '/agent/settings', icon: <SettingOutlined />, label: '系统设置' },
|
||||
@@ -34,34 +31,68 @@ const AgentSidebar = () => {
|
||||
navigate('/login', { replace: true })
|
||||
}
|
||||
|
||||
const initial = (user?.nickname || '客').slice(0, 1)
|
||||
|
||||
return (
|
||||
<Sider width={220} className="!bg-white border-r border-neutral-200 flex flex-col">
|
||||
<div className="h-14 flex items-center px-5 border-b border-neutral-100">
|
||||
<MessageOutlined className="text-lg text-[#2563eb] mr-2.5" />
|
||||
<span className="text-base font-semibold text-neutral-900">客服云</span>
|
||||
<aside
|
||||
className="shrink-0 flex flex-col h-full bg-white border-r border-neutral-200"
|
||||
style={{ width: 'var(--sidebar-width)' }}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-2.5 px-4 shrink-0 border-b border-neutral-200"
|
||||
style={{ height: 'var(--header-height)' }}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg bg-[#2563eb] flex items-center justify-center shrink-0">
|
||||
<MessageOutlined className="text-white text-sm" />
|
||||
</div>
|
||||
<span className="font-semibold text-neutral-900 text-base truncate">在线客服</span>
|
||||
</div>
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={[selectedKey]}
|
||||
items={visibleMenuItems}
|
||||
onClick={({ key }) => navigate(key)}
|
||||
className="border-e-0 mt-2 flex-1"
|
||||
/>
|
||||
<div className="border-t border-neutral-100 p-3">
|
||||
<Dropdown
|
||||
menu={{ items: [{ key: 'logout', icon: <LogoutOutlined />, label: '退出登录', onClick: handleLogout }] }}
|
||||
trigger={['click']}
|
||||
>
|
||||
<div className="flex items-center gap-2 cursor-pointer hover:bg-neutral-50 rounded p-1.5">
|
||||
<Avatar size={28} icon={<UserOutlined />} className="!bg-blue-100 !text-blue-500" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-medium text-neutral-700 truncate">{user?.nickname || '客服'}</div>
|
||||
<div className="text-xs text-neutral-400">在线</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 overflow-y-auto py-3 px-2">
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{visibleMenuItems.map(item => {
|
||||
const active = selectedKey === item.key
|
||||
return (
|
||||
<li key={item.key}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(item.key)}
|
||||
className={`w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm truncate transition-colors ${
|
||||
active
|
||||
? 'bg-[#dbeafe] text-[#2563eb] font-medium'
|
||||
: 'text-neutral-600 hover:bg-neutral-50'
|
||||
}`}
|
||||
>
|
||||
<span className="text-base leading-none">{item.icon}</span>
|
||||
<span className="truncate">{item.label}</span>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-2.5 px-4 shrink-0 border-t border-neutral-200" style={{ height: 52 }}>
|
||||
<div className="w-8 h-8 rounded-full bg-[#dbeafe] text-[#2563eb] flex items-center justify-center text-sm font-semibold shrink-0">
|
||||
{initial}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-neutral-800 truncate">{user?.nickname || '客服'}</div>
|
||||
<div className="text-xs text-neutral-400 flex items-center gap-1">
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-green-500" />
|
||||
在线
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className="w-7 h-7 rounded-md text-neutral-400 hover:text-neutral-600 hover:bg-neutral-50 flex items-center justify-center"
|
||||
title="退出登录"
|
||||
>
|
||||
<LogoutOutlined className="text-sm" />
|
||||
</button>
|
||||
</div>
|
||||
</Sider>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -1,23 +1,30 @@
|
||||
import { useState } from 'react'
|
||||
import VisitorChat from '@/widgets/VisitorChat'
|
||||
|
||||
const WidgetPreview = () => {
|
||||
const [showWidget, setShowWidget] = useState(true)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-neutral-100 flex flex-col">
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-neutral-800 mb-2">示例网站页面</h1>
|
||||
<p className="text-neutral-400 mb-4">Widget 在右下角,发送消息会写入数据库</p>
|
||||
{!showWidget && (
|
||||
<button onClick={() => setShowWidget(true)} className="text-sm text-blue-500 hover:text-blue-600 underline">
|
||||
重新显示 Widget
|
||||
</button>
|
||||
)}
|
||||
<div className="min-h-screen bg-neutral-100 relative overflow-hidden">
|
||||
{/* 模拟宿主站点骨架,贴近设计稿模糊背景 */}
|
||||
<div className="max-w-[960px] mx-auto px-6 py-10 opacity-40 select-none pointer-events-none">
|
||||
<div className="h-10 w-3/5 bg-neutral-200 rounded mb-4" />
|
||||
<div className="h-4 w-[90%] bg-neutral-200 rounded mb-3" />
|
||||
<div className="h-4 w-3/4 bg-neutral-200 rounded mb-6" />
|
||||
<div className="flex gap-4 mb-6">
|
||||
<div className="w-[200px] h-[120px] bg-neutral-200 rounded-lg" />
|
||||
<div className="w-[200px] h-[120px] bg-neutral-200 rounded-lg" />
|
||||
<div className="w-[200px] h-[120px] bg-neutral-200 rounded-lg" />
|
||||
</div>
|
||||
<div className="h-4 w-[85%] bg-neutral-200 rounded mb-3" />
|
||||
<div className="h-4 w-[70%] bg-neutral-200 rounded mb-3" />
|
||||
<div className="h-4 w-4/5 bg-neutral-200 rounded mb-6" />
|
||||
<div className="h-[200px] w-full bg-neutral-200 rounded-lg" />
|
||||
</div>
|
||||
<div className="absolute inset-0 flex items-start justify-center pt-16 pointer-events-none">
|
||||
<div className="text-center pointer-events-auto">
|
||||
<h1 className="text-xl font-semibold text-neutral-700 mb-1">访客 Widget 预览</h1>
|
||||
<p className="text-sm text-neutral-400">右下角为可嵌入聊天组件 · 消息会写入数据库</p>
|
||||
</div>
|
||||
</div>
|
||||
{showWidget && <VisitorChat />}
|
||||
<VisitorChat defaultOpen />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+606
-115
@@ -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<string, string> = { urgent: '#dc2626', normal: '#2563eb' }
|
||||
const priorityLabels: Record<string, string> = { urgent: '紧急', normal: '普通' }
|
||||
const statusLabels: Record<string, string> = { 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<string | null>(null)
|
||||
const [noteInput, setNoteInput] = useState('')
|
||||
const [savingNote, setSavingNote] = useState(false)
|
||||
const [customerHistory, setCustomerHistory] = useState<Session[]>([])
|
||||
const initialLoad = useRef(true)
|
||||
const chatEndRef = useRef<HTMLDivElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(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) => (
|
||||
<div className="mb-3" key={title}>
|
||||
<div className="px-3 py-1.5 text-xs font-medium text-neutral-500 flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: color }} />
|
||||
{title} · {items.length}
|
||||
</div>
|
||||
{items.map(session => {
|
||||
const customer = customers[session.customer_id]
|
||||
return <button key={session.id} type="button"
|
||||
className={`w-full text-left px-3 py-2.5 border-y border-neutral-50 hover:bg-neutral-50 ${selectedId === session.id ? 'bg-blue-50' : ''}`}
|
||||
onClick={() => setSelectedId(session.id)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: priorityColors[session.priority] || '#2563eb' }} />
|
||||
<span className="text-sm font-medium text-neutral-800 truncate flex-1">{customer?.name || `客户${session.customer_id}`}</span>
|
||||
{session.unread_count > 0 && <span className="min-w-5 h-5 px-1 rounded-full bg-red-500 text-white text-xs text-center leading-5">{session.unread_count > 99 ? '99+' : session.unread_count}</span>}
|
||||
const agentInitial = (user?.nickname || '客').slice(0, 1)
|
||||
|
||||
if (loading) {
|
||||
return <div className="h-full flex items-center justify-center"><Spin size="large" /></div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex overflow-hidden">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/gif"
|
||||
className="hidden"
|
||||
onChange={event => { handleImage(event.target.files?.[0]); event.currentTarget.value = '' }}
|
||||
/>
|
||||
|
||||
{/* 会话列表面板 320px */}
|
||||
<section className="w-[320px] shrink-0 flex flex-col h-full border-r border-neutral-200 bg-white">
|
||||
<div className="shrink-0 px-3 py-3 border-b border-neutral-200">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="flex items-center flex-1 min-w-0 rounded-lg px-2.5 h-[34px] bg-neutral-100 border border-neutral-200">
|
||||
<SearchOutlined className="text-neutral-400 text-xs mr-1.5 shrink-0" />
|
||||
<input
|
||||
className="bg-transparent border-none outline-none flex-1 min-w-0 text-sm text-neutral-900 placeholder:text-neutral-400"
|
||||
placeholder="搜索访客或会话"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="w-[34px] h-[34px] rounded-lg bg-neutral-100 border border-neutral-200 text-neutral-500 flex items-center justify-center shrink-0"
|
||||
title="筛选"
|
||||
>
|
||||
<FilterOutlined className="text-xs" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex justify-between items-center mt-1 pl-4 gap-2">
|
||||
<span className="text-xs text-neutral-400">{statusLabels[session.status]}</span>
|
||||
{session.status === 'waiting' && user?.role === 'agent' ? <span role="button" tabIndex={0}
|
||||
className="text-xs text-blue-600 hover:text-blue-700" onClick={event => { event.stopPropagation(); handleClaim(session.id) }}
|
||||
onKeyDown={event => { if (event.key === 'Enter') { event.stopPropagation(); handleClaim(session.id) } }}>领取</span> :
|
||||
<span className="text-xs text-neutral-300">{new Date(session.created_at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}</span>}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-[#dbeafe] text-[#2563eb]">
|
||||
当前会话 {filteredSessions.length} 个
|
||||
</span>
|
||||
<a
|
||||
href="/widget/preview"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs text-[#2563eb] hover:bg-[#eff6ff]"
|
||||
>
|
||||
<ExportOutlined className="text-[10px]" />
|
||||
预览访客窗口
|
||||
</a>
|
||||
</div>
|
||||
</button>
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto no-scrollbar">
|
||||
{filteredSessions.length === 0 ? (
|
||||
<div className="text-center text-sm text-neutral-400 py-16">暂无待处理会话</div>
|
||||
) : 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 (
|
||||
<button
|
||||
key={session.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(session.id)}
|
||||
className={`w-full flex items-stretch text-left border-b border-neutral-100 transition-colors ${
|
||||
selected ? 'bg-[#eff6ff]' : 'hover:bg-neutral-50'
|
||||
}`}
|
||||
>
|
||||
<div className="w-[3px] shrink-0" style={{ background: selected || session.priority === 'urgent' || session.status === 'waiting' ? meta.bar : 'transparent' }} />
|
||||
<div className="flex-1 min-w-0 px-3 py-3">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div
|
||||
className="w-9 h-9 rounded-full flex items-center justify-center shrink-0 text-sm font-semibold"
|
||||
style={{ background: meta.avatarBg, color: meta.avatarColor }}
|
||||
>
|
||||
{name.slice(0, 1)}
|
||||
</div>
|
||||
<span className="truncate font-medium text-sm text-neutral-800">{name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0 ml-2">
|
||||
<span className="whitespace-nowrap text-xs text-neutral-400">
|
||||
{relativeTime(session.last_message_at || session.created_at)}
|
||||
</span>
|
||||
{session.unread_count > 0 && (
|
||||
<span className="min-w-[18px] h-[18px] px-[5px] rounded-full bg-[#dc2626] text-white text-[11px] font-semibold flex items-center justify-center">
|
||||
{session.unread_count > 99 ? '99+' : session.unread_count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pl-0">
|
||||
<p className="truncate flex-1 min-w-0 mr-2 text-xs text-neutral-500">
|
||||
{session.last_message || '暂无消息'}
|
||||
</p>
|
||||
<span className="flex items-center gap-1 shrink-0 text-xs text-neutral-500">
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full" style={{ background: meta.dot }} />
|
||||
{meta.label}
|
||||
</span>
|
||||
</div>
|
||||
{session.status === 'waiting' && user?.role === 'agent' && (
|
||||
<div className="mt-1.5 pl-11">
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="text-xs text-[#2563eb] hover:underline"
|
||||
onClick={e => { e.stopPropagation(); handleClaim(session.id) }}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.stopPropagation(); handleClaim(session.id) } }}
|
||||
>
|
||||
领取会话
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 主聊天区 */}
|
||||
<section className="flex-1 flex flex-col min-w-0 h-full bg-neutral-50">
|
||||
{selected && selectedCustomer ? (
|
||||
<>
|
||||
<div
|
||||
className="shrink-0 flex items-center justify-between px-5 bg-white border-b border-neutral-200"
|
||||
style={{ height: 'var(--header-height)' }}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div
|
||||
className="w-[38px] h-[38px] rounded-full flex items-center justify-center shrink-0 text-base font-semibold"
|
||||
style={{
|
||||
background: listStatusMeta(selected).avatarBg,
|
||||
color: listStatusMeta(selected).avatarColor,
|
||||
}}
|
||||
>
|
||||
{selectedCustomer.name.slice(0, 1)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-semibold text-base text-neutral-900">{selectedCustomer.name}</span>
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-[#ecfeff] text-[#0891b2]">
|
||||
{channelLabel(selectedCustomer.source)}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-[#f0fdf4] text-[#16a34a]">
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-[#16a34a]" />
|
||||
{selectedCustomer.status === 'online' ? '在线' : selectedCustomer.status === 'busy' ? '忙碌' : '离线'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5 text-xs text-neutral-400">
|
||||
<span className="truncate">会话 #{selected.id}</span>
|
||||
{selectedCustomer.source && (
|
||||
<>
|
||||
<span>|</span>
|
||||
<span className="truncate">{selectedCustomer.source}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0 ml-4">
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'urgent', label: '标记紧急', onClick: () => handlePriority('urgent') },
|
||||
{ key: 'normal', label: '取消紧急', onClick: () => handlePriority('normal') },
|
||||
],
|
||||
}}
|
||||
disabled={!canOperate}
|
||||
>
|
||||
<button type="button" className="w-8 h-8 rounded-lg flex items-center justify-center text-neutral-500 hover:bg-neutral-100 disabled:opacity-40" title="优先级" disabled={!canOperate}>
|
||||
<FlagOutlined />
|
||||
</button>
|
||||
</Dropdown>
|
||||
<button type="button" className="w-8 h-8 rounded-lg flex items-center justify-center text-neutral-500 hover:bg-neutral-100 disabled:opacity-40" title="知识库" disabled={!canOperate} onClick={() => setKnowledgeOpen(true)}>
|
||||
<BookOutlined />
|
||||
</button>
|
||||
<button type="button" className="w-8 h-8 rounded-lg flex items-center justify-center text-neutral-500 hover:bg-neutral-100 disabled:opacity-40" title="转接" disabled={!canOperate} onClick={openTransfer}>
|
||||
<SwapOutlined />
|
||||
</button>
|
||||
<button type="button" className="w-8 h-8 rounded-lg flex items-center justify-center text-red-500 hover:bg-red-50 disabled:opacity-40" title="结束会话" disabled={!canOperate} onClick={() => setEndingOpen(true)}>
|
||||
<CheckCircleOutlined />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4 min-h-0">
|
||||
{detailLoading ? (
|
||||
<div className="h-full flex items-center justify-center"><Spin /></div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex justify-center mb-4">
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-lg text-xs text-neutral-400 bg-white border border-neutral-100">
|
||||
会话开始 — {new Date(selected.created_at).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
{(detail?.messages || []).map(message => {
|
||||
const isAgent = message.sender_type === 'agent'
|
||||
const visitorInitial = selectedCustomer.name.slice(0, 1)
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`flex items-start gap-2.5 mb-4 ${isAgent ? 'flex-row-reverse' : ''}`}
|
||||
>
|
||||
<div
|
||||
className="w-9 h-9 rounded-full flex items-center justify-center shrink-0 text-sm font-semibold"
|
||||
style={isAgent
|
||||
? { background: '#2563eb', color: '#fff' }
|
||||
: { background: listStatusMeta(selected).avatarBg, color: listStatusMeta(selected).avatarColor }}
|
||||
>
|
||||
{isAgent ? agentInitial : visitorInitial}
|
||||
</div>
|
||||
<div className={`min-w-0 max-w-[65%] ${isAgent ? 'items-end' : ''}`}>
|
||||
<div
|
||||
className={`px-3.5 py-2.5 shadow-sm ${
|
||||
isAgent
|
||||
? 'rounded-xl rounded-tr-sm bg-[#2563eb] text-white'
|
||||
: 'rounded-xl rounded-tl-sm bg-white text-neutral-800 border border-neutral-100'
|
||||
}`}
|
||||
>
|
||||
{message.type === 'image' ? (
|
||||
<img src={message.content} alt="聊天图片" className="max-w-64 max-h-64 rounded-lg" />
|
||||
) : (
|
||||
<p className="text-sm leading-normal whitespace-pre-wrap break-words m-0">{message.content}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className={`mt-1 text-xs text-neutral-400 ${isAgent ? 'text-right' : ''}`}>
|
||||
{new Date(message.sent_at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{detail?.messages.length === 0 && (
|
||||
<div className="text-center text-sm text-neutral-400 py-10">暂无消息,开始对话吧</div>
|
||||
)}
|
||||
<div ref={chatEndRef} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 bg-white border-t border-neutral-200">
|
||||
{selected.status !== 'active' || !canOperate ? (
|
||||
<div className="text-center text-sm text-neutral-400 py-4">
|
||||
{selected.status === 'waiting' ? '领取会话后即可回复' : '会话已结束,无法继续发送消息'}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-1 px-4 pt-2.5 pb-1">
|
||||
<button type="button" className="w-8 h-8 rounded-md flex items-center justify-center text-neutral-500 hover:bg-neutral-100" title="表情">
|
||||
<SmileOutlined />
|
||||
</button>
|
||||
<button type="button" className="w-8 h-8 rounded-md flex items-center justify-center text-neutral-500 hover:bg-neutral-100" title="图片" onClick={() => fileInputRef.current?.click()}>
|
||||
<PictureOutlined />
|
||||
</button>
|
||||
<button type="button" className="w-8 h-8 rounded-md flex items-center justify-center text-neutral-500 hover:bg-neutral-100" title="附件" onClick={() => fileInputRef.current?.click()}>
|
||||
<PaperClipOutlined />
|
||||
</button>
|
||||
<div className="w-px h-5 bg-neutral-200 mx-1" />
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: quickReplies.map((content, index) => ({
|
||||
key: String(index),
|
||||
label: content,
|
||||
onClick: () => setMessageInput(content),
|
||||
})),
|
||||
}}
|
||||
>
|
||||
<button type="button" className="h-8 px-2 rounded-md text-xs text-neutral-500 hover:bg-neutral-100">
|
||||
快捷回复
|
||||
</button>
|
||||
</Dropdown>
|
||||
<button type="button" className="h-8 px-2 rounded-md text-xs text-neutral-500 hover:bg-neutral-100 flex items-center gap-1" onClick={() => setKnowledgeOpen(true)}>
|
||||
<FileTextOutlined />
|
||||
知识库
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-end gap-2.5 px-4 pb-3 pt-1">
|
||||
<div className="flex-1 min-w-0 rounded-lg px-3.5 py-2.5 bg-neutral-50 border border-neutral-200">
|
||||
<Input.TextArea
|
||||
autoSize={{ minRows: 1, maxRows: 4 }}
|
||||
variant="borderless"
|
||||
className="!bg-transparent !p-0 text-sm"
|
||||
placeholder="输入回复内容… Enter 发送,Shift+Enter 换行"
|
||||
value={messageInput}
|
||||
onChange={event => { 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)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!messageInput.trim() || sending}
|
||||
onClick={() => sendMessage(messageInput)}
|
||||
className="w-10 h-10 rounded-lg bg-[#2563eb] hover:bg-[#1d4ed8] disabled:opacity-40 text-white flex items-center justify-center shrink-0 shadow-sm"
|
||||
>
|
||||
{sending ? <Spin size="small" /> : <SendOutlined />}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-neutral-400 gap-2">
|
||||
<div className="w-16 h-16 rounded-2xl bg-white border border-neutral-200 flex items-center justify-center text-2xl text-neutral-300">
|
||||
<SearchOutlined />
|
||||
</div>
|
||||
<div className="text-sm">选择一个会话开始处理</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 右侧客户信息 320px */}
|
||||
<aside className="w-[320px] shrink-0 flex flex-col h-full border-l border-neutral-200 bg-white">
|
||||
{selectedCustomer ? (
|
||||
<>
|
||||
<div
|
||||
className="shrink-0 flex items-center justify-between px-4 border-b border-neutral-200"
|
||||
style={{ height: 'var(--header-height)' }}
|
||||
>
|
||||
<span className="font-semibold text-base text-neutral-800">客户信息</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-4 py-4 no-scrollbar">
|
||||
<div className="flex flex-col items-center mb-5">
|
||||
<div
|
||||
className="w-14 h-14 rounded-full flex items-center justify-center mb-2 text-[22px] font-semibold"
|
||||
style={{
|
||||
background: selected ? listStatusMeta(selected).avatarBg : '#eff6ff',
|
||||
color: selected ? listStatusMeta(selected).avatarColor : '#2563eb',
|
||||
}}
|
||||
>
|
||||
{selectedCustomer.name.slice(0, 1)}
|
||||
</div>
|
||||
<div className="font-semibold text-lg text-neutral-900">{selectedCustomer.name}</div>
|
||||
<div className="flex items-center gap-1 mt-1 text-xs text-neutral-400">
|
||||
<span
|
||||
className="inline-block w-1.5 h-1.5 rounded-full"
|
||||
style={{ background: selectedCustomer.status === 'online' ? '#16a34a' : '#94a3b8' }}
|
||||
/>
|
||||
{selectedCustomer.status === 'online' ? '在线' : selectedCustomer.status === 'busy' ? '忙碌' : '离线'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-5">
|
||||
<div className="flex items-center gap-1.5 mb-2.5 text-xs font-semibold text-neutral-500 uppercase tracking-wider">
|
||||
基本信息
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 text-sm">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="shrink-0 text-neutral-400 min-w-12">手机</span>
|
||||
<span className="truncate text-neutral-800">{selectedCustomer.phone || '—'}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="shrink-0 text-neutral-400 min-w-12">邮箱</span>
|
||||
<span className="truncate text-neutral-800">{selectedCustomer.email || '—'}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="shrink-0 text-neutral-400 min-w-12">来源</span>
|
||||
<span className="truncate text-neutral-800">{selectedCustomer.source || '—'}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="shrink-0 text-neutral-400 min-w-12">对话</span>
|
||||
<span className="truncate text-neutral-800">{selectedCustomer.conversation_count} 次</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-5">
|
||||
<div className="mb-2.5 text-xs font-semibold text-neutral-500 uppercase tracking-wider">客户标签</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{parseTags(selectedCustomer.tags).length === 0 ? (
|
||||
<span className="text-xs text-neutral-400">暂无标签</span>
|
||||
) : parseTags(selectedCustomer.tags).map(tag => (
|
||||
<span key={tag} className="text-xs px-2 py-0.5 rounded-md bg-[#dbeafe] text-[#2563eb] font-medium">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-5">
|
||||
<div className="mb-2.5 text-xs font-semibold text-neutral-500 uppercase tracking-wider">访问来源</div>
|
||||
<div className="flex flex-col gap-2 text-sm">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="shrink-0 text-neutral-400 min-w-12">渠道</span>
|
||||
<span className="text-neutral-800">{channelLabel(selectedCustomer.source)}在线客服</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="shrink-0 text-neutral-400 min-w-12">来源</span>
|
||||
<span className="text-neutral-800">{selectedCustomer.source || '直接访问'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-5">
|
||||
<div className="mb-2.5 text-xs font-semibold text-neutral-500 uppercase tracking-wider">历史会话</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{customerHistory.length === 0 ? (
|
||||
<div className="text-xs text-neutral-400">暂无其他会话</div>
|
||||
) : customerHistory.map(s => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(s.id)}
|
||||
className="rounded-lg px-3 py-2 text-left bg-neutral-50 border border-neutral-100 hover:border-blue-200"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="truncate text-sm font-medium text-neutral-700">会话 #{s.id}</span>
|
||||
<span className="whitespace-nowrap text-xs text-neutral-400">
|
||||
{new Date(s.created_at).toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
<p className="line-clamp-1 text-xs text-neutral-400 m-0">
|
||||
{s.last_message || (s.status === 'ended' ? '已结束' : '进行中')}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-2">
|
||||
<div className="mb-2.5 text-xs font-semibold text-neutral-500 uppercase tracking-wider">内部备注</div>
|
||||
<div className="space-y-2 max-h-40 overflow-auto">
|
||||
{notes.length === 0 ? (
|
||||
<div className="text-xs text-neutral-400">暂无内部备注</div>
|
||||
) : notes.map(note => (
|
||||
<div key={note.id} className="bg-amber-50 text-amber-900 rounded-lg p-2 text-xs whitespace-pre-wrap border border-amber-100">
|
||||
{note.detail}
|
||||
<div className="text-amber-600/70 mt-1">{new Date(note.created_at).toLocaleString('zh-CN')}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{canOperate && (
|
||||
<div className="mt-2 flex gap-1">
|
||||
<Input
|
||||
size="small"
|
||||
maxLength={500}
|
||||
placeholder="添加仅客服可见的备注"
|
||||
value={noteInput}
|
||||
onChange={e => setNoteInput(e.target.value)}
|
||||
onPressEnter={handleAddNote}
|
||||
/>
|
||||
<Button size="small" loading={savingNote} onClick={handleAddNote}>保存</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-sm text-neutral-400">选择会话查看客户信息</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<Modal title="转接会话" open={transferOpen} onCancel={() => setTransferOpen(false)} onOk={handleTransfer} okButtonProps={{ disabled: !targetAgentID }}>
|
||||
<p className="text-sm text-neutral-500 mb-3">请选择一位在线客服接手当前会话。</p>
|
||||
<Select
|
||||
className="w-full"
|
||||
placeholder="选择客服"
|
||||
value={targetAgentID}
|
||||
onChange={setTargetAgentID}
|
||||
options={availableAgents.map(agent => ({ value: agent.id, label: agent.nickname }))}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal title="结束会话" open={endingOpen} onCancel={() => setEndingOpen(false)} onOk={handleEnd} okText="确认结束">
|
||||
<p className="text-sm text-neutral-500 mb-3">请选择本次会话的结束原因。</p>
|
||||
<Select className="w-full" value={endReason} onChange={setEndReason} options={endReasons} />
|
||||
</Modal>
|
||||
<Modal
|
||||
title="图片预览"
|
||||
open={Boolean(imagePreview)}
|
||||
onCancel={() => setImagePreview(null)}
|
||||
onOk={() => { if (imagePreview) sendMessage(imagePreview, 'image'); setImagePreview(null) }}
|
||||
okText="发送"
|
||||
okButtonProps={{ loading: sending }}
|
||||
>
|
||||
<div className="flex justify-center">
|
||||
<img src={imagePreview || ''} alt="待发送图片预览" className="max-h-[420px] max-w-full rounded-lg" />
|
||||
</div>
|
||||
</Modal>
|
||||
<Modal title="知识库与快捷回复" open={knowledgeOpen} onCancel={() => setKnowledgeOpen(false)} footer={null} width={640}>
|
||||
<Input
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="搜索标题或内容"
|
||||
value={knowledgeKeyword}
|
||||
onChange={event => setKnowledgeKeyword(event.target.value)}
|
||||
allowClear
|
||||
className="mb-3"
|
||||
/>
|
||||
{knowledgeLoading ? (
|
||||
<div className="py-10 text-center"><Spin /></div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-96 overflow-auto">
|
||||
{knowledgeEntries.length === 0 ? (
|
||||
<div className="text-center text-neutral-400 py-8">未找到可用知识条目</div>
|
||||
) : knowledgeEntries.map(entry => (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
className="w-full text-left border border-neutral-100 rounded-lg p-3 hover:border-blue-300 hover:bg-blue-50"
|
||||
onClick={() => { setMessageInput(entry.content); setKnowledgeOpen(false) }}
|
||||
>
|
||||
<div className="text-sm font-medium text-neutral-800">{entry.title}</div>
|
||||
<div className="text-xs text-neutral-500 mt-1 line-clamp-2">{entry.content}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (loading) return <div className="h-full flex items-center justify-center"><Spin size="large" /></div>
|
||||
|
||||
return <div className="h-full flex">
|
||||
<input ref={fileInputRef} type="file" accept="image/jpeg,image/png,image/gif" className="hidden" onChange={event => { handleImage(event.target.files?.[0]); event.currentTarget.value = '' }} />
|
||||
<aside className="w-[300px] flex-shrink-0 border-r border-neutral-200 bg-white flex flex-col">
|
||||
<div className="px-3 py-3 border-b border-neutral-100">
|
||||
<div className="text-sm font-medium text-neutral-800">{user?.nickname || '客服'}</div>
|
||||
<div className="text-xs text-green-600">在线 · 我的会话</div>
|
||||
</div>
|
||||
<div className="p-3 border-b border-neutral-100"><Input prefix={<SearchOutlined />} placeholder="搜索访客或会话 ID" value={search} onChange={event => setSearch(event.target.value)} size="small" allowClear /></div>
|
||||
<div className="flex-1 overflow-auto py-2">
|
||||
{filteredSessions.length === 0 ? <div className="text-center text-sm text-neutral-400 py-10">暂无待处理会话</div> : <>
|
||||
{renderSessionGroup('紧急会话', urgentSessions, '#dc2626')}
|
||||
{renderSessionGroup('等待中', waitingSessions, '#d97706')}
|
||||
{renderSessionGroup('进行中', activeSessions, '#2563eb')}
|
||||
</>}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="flex-1 flex flex-col min-w-0 bg-white">
|
||||
{selected && selectedCustomer ? <>
|
||||
<div className="h-14 px-4 flex items-center justify-between border-b border-neutral-100 flex-shrink-0">
|
||||
<div className="min-w-0"><div className="flex items-center gap-2"><span className="text-sm font-medium text-neutral-800 truncate">{selectedCustomer.name}</span><span className="text-xs px-1.5 py-0.5 rounded bg-blue-50 text-blue-600">{priorityLabels[selected.priority]}</span><span className="text-xs text-neutral-400">{statusLabels[selected.status]}</span></div><div className="text-xs text-neutral-400 mt-0.5">会话 #{selected.id}</div></div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Dropdown menu={{ items: [{ key: 'urgent', label: '标记紧急', onClick: () => handlePriority('urgent') }, { key: 'normal', label: '标记普通', onClick: () => handlePriority('normal') }] }} disabled={!canOperate}><Button type="text" size="small" icon={<FlagOutlined />}>优先级</Button></Dropdown>
|
||||
<Button type="text" size="small" icon={<FileTextOutlined />} onClick={() => setKnowledgeOpen(true)} disabled={!canOperate}>知识库</Button>
|
||||
<Button type="text" size="small" icon={<SwapOutlined />} onClick={openTransfer} disabled={!canOperate}>转接</Button>
|
||||
<Button type="text" size="small" danger icon={<CheckCircleOutlined />} onClick={() => setEndingOpen(true)} disabled={!canOperate}>结束</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-4 flex flex-col gap-3">
|
||||
{detailLoading ? <div className="flex-1 flex items-center justify-center"><Spin /></div> : detail?.messages.length ? detail.messages.map(message => <div key={message.id} className={`flex ${message.sender_type === 'agent' ? 'justify-start' : 'justify-end'}`}>
|
||||
<div className={`max-w-[65%] rounded-lg px-3 py-2 text-sm ${message.sender_type === 'agent' ? 'bg-neutral-100 text-neutral-700' : 'bg-blue-500 text-white'}`}>
|
||||
{message.type === 'image' ? <img src={message.content} alt="聊天图片" className="max-w-64 max-h-64 rounded" /> : <div className="whitespace-pre-wrap break-words">{message.content}</div>}
|
||||
<div className={`text-xs mt-1 ${message.sender_type === 'agent' ? 'text-neutral-400' : 'text-white/60'}`}>{message.sender_type === 'agent' ? '客服' : '访客'} · {new Date(message.sent_at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}</div>
|
||||
</div>
|
||||
</div>) : <div className="flex-1 flex items-center justify-center text-neutral-400 text-sm">暂无消息,开始对话吧</div>}
|
||||
<div ref={chatEndRef} />
|
||||
</div>
|
||||
<div className="p-3 border-t border-neutral-100 flex-shrink-0">
|
||||
{selected.status !== 'active' || !canOperate ? <div className="text-center text-sm text-neutral-400 py-2">{selected.status === 'waiting' ? '领取会话后即可回复' : '会话已结束'}</div> : <>
|
||||
<div className="flex items-center gap-1 mb-1">
|
||||
<Dropdown menu={{ items: quickReplies.map((content, index) => ({ key: String(index), label: content, onClick: () => setMessageInput(content) })) }}><Button type="text" size="small">快捷回复</Button></Dropdown>
|
||||
<Tooltip title="搜索知识库"><Button type="text" size="small" icon={<FileTextOutlined />} onClick={() => setKnowledgeOpen(true)} /></Tooltip>
|
||||
<Tooltip title="发送图片"><Button type="text" size="small" icon={<PaperClipOutlined />} onClick={() => fileInputRef.current?.click()} /></Tooltip>
|
||||
</div>
|
||||
<div className="flex items-end gap-2 bg-neutral-50 rounded-lg px-3 py-2">
|
||||
<Input.TextArea autoSize={{ minRows: 1, maxRows: 4 }} bordered={false} className="!bg-transparent" placeholder="输入消息… Enter 发送,Shift+Enter 换行" value={messageInput}
|
||||
onChange={event => { 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) } }} />
|
||||
<Button type="primary" shape="circle" icon={sending ? <Spin size="small" /> : <SendOutlined />} disabled={!messageInput.trim() || sending} onClick={() => sendMessage(messageInput)} />
|
||||
</div>
|
||||
</>}
|
||||
</div>
|
||||
</> : <div className="flex-1 flex items-center justify-center text-neutral-400">选择一个会话开始处理</div>}
|
||||
</main>
|
||||
|
||||
<aside className="w-[300px] flex-shrink-0 border-l border-neutral-200 bg-white overflow-auto">
|
||||
{selectedCustomer && <div className="p-4 space-y-5">
|
||||
<div className="flex items-center gap-3 pb-3 border-b border-neutral-100"><div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center text-blue-500 font-semibold">{selectedCustomer.name[0]}</div><div><div className="text-sm font-medium text-neutral-800">{selectedCustomer.name}</div><div className="text-xs text-neutral-400">{selectedCustomer.source || '未知渠道'}</div></div></div>
|
||||
<div><div className="text-xs text-neutral-400 mb-1.5">联系方式</div><div className="text-sm text-neutral-700 space-y-1">{selectedCustomer.phone && <div>{selectedCustomer.phone}</div>}{selectedCustomer.email && <div>{selectedCustomer.email}</div>}{!selectedCustomer.phone && !selectedCustomer.email && <div>暂无联系方式</div>}</div></div>
|
||||
<div><div className="text-xs text-neutral-400 mb-1.5">客户标签</div><div className="flex flex-wrap gap-1">{((): string[] => { try { return JSON.parse(selectedCustomer.tags) } catch { return [] } })().map(tag => <span key={tag} className="text-xs px-2 py-0.5 rounded bg-blue-50 text-blue-600">{tag}</span>)}</div></div>
|
||||
<div className="grid grid-cols-3 gap-2"><div className="bg-neutral-50 rounded p-2 text-center"><div className="text-lg font-semibold text-neutral-800">{selectedCustomer.conversation_count}</div><div className="text-xs text-neutral-400">对话次数</div></div><div className="bg-neutral-50 rounded p-2 text-center"><div className="text-lg font-semibold text-green-600 flex items-center justify-center gap-0.5">{selected?.satisfaction_score || '-'}{selected?.satisfaction_score ? <StarFilled className="text-xs" /> : null}</div><div className="text-xs text-neutral-400">满意度</div></div><div className="bg-neutral-50 rounded p-2 text-center"><div className="text-lg font-semibold text-neutral-800">{detail?.pendingCount || 0}</div><div className="text-xs text-neutral-400">待处理</div></div></div>
|
||||
<div><div className="text-xs text-neutral-400 mb-2">内部备注</div><div className="space-y-2 max-h-36 overflow-auto">{notes.length === 0 ? <div className="text-xs text-neutral-400">暂无内部备注</div> : notes.map(note => <div key={note.id} className="bg-amber-50 text-amber-900 rounded p-2 text-xs whitespace-pre-wrap">{note.detail}<div className="text-amber-600/70 mt-1">{new Date(note.created_at).toLocaleString('zh-CN')}</div></div>)}</div>{canOperate && <div className="mt-2 flex gap-1"><Input size="small" maxLength={500} placeholder="添加仅客服可见的备注" value={noteInput} onChange={event => setNoteInput(event.target.value)} onPressEnter={handleAddNote} /><Button size="small" loading={savingNote} onClick={handleAddNote}>保存</Button></div>}</div>
|
||||
</div>}
|
||||
</aside>
|
||||
|
||||
<Modal title="转接会话" open={transferOpen} onCancel={() => setTransferOpen(false)} onOk={handleTransfer} okButtonProps={{ disabled: !targetAgentID }}>
|
||||
<p className="text-sm text-neutral-500 mb-3">请选择一位在线客服接手当前会话。</p><Select className="w-full" placeholder="选择客服" value={targetAgentID} onChange={setTargetAgentID} options={availableAgents.map(agent => ({ value: agent.id, label: agent.nickname }))} />
|
||||
</Modal>
|
||||
<Modal title="结束会话" open={endingOpen} onCancel={() => setEndingOpen(false)} onOk={handleEnd} okText="确认结束"><p className="text-sm text-neutral-500 mb-3">请选择本次会话的结束原因。</p><Select className="w-full" value={endReason} onChange={setEndReason} options={endReasons} /></Modal>
|
||||
<Modal title="图片预览" open={Boolean(imagePreview)} onCancel={() => setImagePreview(null)} onOk={() => { if (imagePreview) sendMessage(imagePreview, 'image'); setImagePreview(null) }} okText="发送" okButtonProps={{ loading: sending }}><div className="flex justify-center"><img src={imagePreview || ''} alt="待发送图片预览" className="max-h-[420px] max-w-full rounded-lg" /></div></Modal>
|
||||
<Modal title="知识库与快捷回复" open={knowledgeOpen} onCancel={() => setKnowledgeOpen(false)} footer={null} width={640}><Input prefix={<SearchOutlined />} placeholder="搜索标题或内容" value={knowledgeKeyword} onChange={event => setKnowledgeKeyword(event.target.value)} allowClear className="mb-3" />{knowledgeLoading ? <div className="py-10 text-center"><Spin /></div> : <div className="space-y-2 max-h-96 overflow-auto">{knowledgeEntries.length === 0 ? <div className="text-center text-neutral-400 py-8">未找到可用知识条目</div> : knowledgeEntries.map(entry => <button key={entry.id} type="button" className="w-full text-left border border-neutral-100 rounded-lg p-3 hover:border-blue-300 hover:bg-blue-50" onClick={() => { setMessageInput(entry.content); setKnowledgeOpen(false) }}><div className="text-sm font-medium text-neutral-800">{entry.title}</div><div className="text-xs text-neutral-500 mt-1 line-clamp-2">{entry.content}</div></button>)}</div>}</Modal>
|
||||
</div>
|
||||
}
|
||||
|
||||
export default Dashboard
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+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