添加消息发送API、丰富种子对话数据、Dashboard支持真实消息发送和客户名显示
This commit is contained in:
@@ -1,99 +1,118 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Input, Spin, message } from 'antd'
|
||||
import { SearchOutlined, UserOutlined, StarFilled } from '@ant-design/icons'
|
||||
import { Input, Spin, message as antMsg } from 'antd'
|
||||
import { SearchOutlined, StarFilled } from '@ant-design/icons'
|
||||
import { useAuth } from '@/stores/auth'
|
||||
import { getSessions, getSession, type Session as SessionType } from '@/services/api'
|
||||
|
||||
interface SessionDetail {
|
||||
id: number
|
||||
customerName: string
|
||||
phone: string
|
||||
email: string
|
||||
source: string
|
||||
tags: string[]
|
||||
status: string
|
||||
conversationCount: number
|
||||
satisfaction: number
|
||||
note: string
|
||||
messages: { sender: string; content: string; time: string }[]
|
||||
}
|
||||
import { getSessions, getSession, getCustomers, type Session as SessionType, type Customer } from '@/services/api'
|
||||
|
||||
const priorityColors: Record<string, string> = { urgent: '#dc2626', normal: '#d97706', waiting: '#d97706' }
|
||||
const priorityLabels: Record<string, string> = { urgent: '紧急', waiting: '等待中', active: '进行中', normal: '进行中' }
|
||||
const statusLabels: Record<string, string> = { active: '进行中', waiting: '等待中', ended: '已结束' }
|
||||
|
||||
const Dashboard = () => {
|
||||
const { user } = useAuth()
|
||||
const [sessions, setSessions] = useState<SessionType[]>([])
|
||||
const [customers, setCustomers] = useState<Record<number, Customer>>({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null)
|
||||
const [detail, setDetail] = useState<SessionDetail | null>(null)
|
||||
const [detail, setDetail] = useState<{ messages: { sender: string; content: string; time: string }[] } | null>(null)
|
||||
const [detailLoading, setDetailLoading] = useState(false)
|
||||
const [messageInput, setMessageInput] = useState('')
|
||||
const [sending, setSending] = useState(false)
|
||||
const initialLoad = useRef(true)
|
||||
const chatEndRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadSessions()
|
||||
}, [])
|
||||
useEffect(() => { loadAll() }, [])
|
||||
|
||||
const loadSessions = async () => {
|
||||
const loadAll = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await getSessions()
|
||||
console.log('Sessions loaded:', res)
|
||||
setSessions(res.list)
|
||||
if (res.list.length > 0 && initialLoad.current) {
|
||||
setSelectedId(res.list[0].id)
|
||||
const [sRes, cRes] = await Promise.all([
|
||||
getSessions(),
|
||||
getCustomers({ page: 1 }),
|
||||
])
|
||||
setSessions(sRes.list)
|
||||
const map: Record<number, Customer> = {}
|
||||
cRes.list.forEach(c => { map[c.id] = c })
|
||||
setCustomers(map)
|
||||
if (sRes.list.length > 0 && initialLoad.current) {
|
||||
setSelectedId(sRes.list[0].id)
|
||||
initialLoad.current = false
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load sessions:', err)
|
||||
message.error('加载会话失败,请重新登录')
|
||||
console.error('加载失败:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedId) {
|
||||
loadDetail(selectedId)
|
||||
}
|
||||
if (selectedId) loadDetail(selectedId)
|
||||
}, [selectedId])
|
||||
|
||||
const loadDetail = async (id: number) => {
|
||||
setDetailLoading(true)
|
||||
try {
|
||||
const res = await getSession(id)
|
||||
const { session } = res.data
|
||||
const msgs: any = res.data as any
|
||||
setDetail({
|
||||
id: session.id,
|
||||
customerName: `客户${session.customer_id}`,
|
||||
phone: '获取中...',
|
||||
email: '',
|
||||
source: '网页',
|
||||
tags: session.priority === 'urgent' ? ['VIP'] : [],
|
||||
status: session.status,
|
||||
conversationCount: 0,
|
||||
satisfaction: session.satisfaction_score || 0,
|
||||
note: '',
|
||||
messages: (res.data as any).messages?.map((m: any) => ({
|
||||
sender: m.sender_type === 'agent' ? `客服(${m.sender_id})` : '客户',
|
||||
messages: (msgs.messages || []).map((m: any) => ({
|
||||
sender: m.sender_type === 'agent' ? '客服' : getCustomerName(id),
|
||||
content: m.content,
|
||||
time: new Date(m.sent_at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
|
||||
})) || [],
|
||||
})),
|
||||
})
|
||||
} catch {
|
||||
message.error('加载会话详情失败')
|
||||
antMsg.error('加载消息失败')
|
||||
} finally {
|
||||
setDetailLoading(false)
|
||||
}
|
||||
setTimeout(() => chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }), 200)
|
||||
}
|
||||
|
||||
const handleSend = () => {
|
||||
if (!messageInput.trim()) return
|
||||
setMessageInput('')
|
||||
message.info('WebSocket 消息发送(待连接)')
|
||||
const getCustomerName = (sessionId: number): string => {
|
||||
const s = sessions.find(s => s.id === sessionId)
|
||||
if (!s) return '访客'
|
||||
const c = customers[s.customer_id]
|
||||
return c ? c.name : `客户${s.customer_id}`
|
||||
}
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!messageInput.trim() || !selectedId || sending) return
|
||||
const text = messageInput.trim()
|
||||
setMessageInput('')
|
||||
setSending(true)
|
||||
try {
|
||||
const token = localStorage.getItem('auth_user')
|
||||
const t = token ? JSON.parse(token).token : ''
|
||||
const res = await fetch(`/api/sessions/${selectedId}/messages`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
|
||||
body: JSON.stringify({ content: text, type: 'text' }),
|
||||
})
|
||||
const json = await res.json()
|
||||
if (json.code === 0) {
|
||||
// 本地立即显示
|
||||
setDetail(prev => prev ? {
|
||||
messages: [...prev.messages, {
|
||||
sender: '客服',
|
||||
content: text,
|
||||
time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
|
||||
}],
|
||||
} : prev)
|
||||
setTimeout(() => chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }), 100)
|
||||
} else {
|
||||
antMsg.error(json.message || '发送失败')
|
||||
}
|
||||
} catch {
|
||||
antMsg.error('发送失败')
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const selected = sessions.find(s => s.id === selectedId)
|
||||
const selectedCustomer = selected ? customers[selected.customer_id] : null
|
||||
|
||||
if (loading) {
|
||||
return <div className="h-full flex items-center justify-center"><Spin size="large" /></div>
|
||||
}
|
||||
@@ -113,34 +132,43 @@ const Dashboard = () => {
|
||||
{sessions.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-neutral-400 text-sm">暂无会话</div>
|
||||
) : (
|
||||
sessions.map(s => (
|
||||
<div
|
||||
key={s.id}
|
||||
className={`px-3 py-2.5 cursor-pointer border-b border-neutral-50 hover:bg-neutral-50 transition-colors ${selectedId === s.id ? 'bg-blue-50' : ''}`}
|
||||
onClick={() => setSelectedId(s.id)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-block w-2 h-2 rounded-full" style={{ backgroundColor: priorityColors[s.priority] || '#2563eb' }} />
|
||||
<span className="text-sm font-medium text-neutral-800">客户{s.customer_id}</span>
|
||||
sessions.map(s => {
|
||||
const c = customers[s.customer_id]
|
||||
return (
|
||||
<div key={s.id}
|
||||
className={`px-3 py-2.5 cursor-pointer border-b border-neutral-50 hover:bg-neutral-50 transition-colors ${selectedId === s.id ? 'bg-blue-50' : ''}`}
|
||||
onClick={() => setSelectedId(s.id)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-block w-2 h-2 rounded-full" style={{ backgroundColor: priorityColors[s.priority] || '#2563eb' }} />
|
||||
<span className="text-sm font-medium text-neutral-800">{c ? c.name : `客户${s.customer_id}`}</span>
|
||||
</div>
|
||||
<span className="text-xs text-neutral-300">{new Date(s.created_at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-1 pl-4">
|
||||
<span className="text-xs text-neutral-400">{statusLabels[s.status] || s.status}</span>
|
||||
{s.satisfaction_score && <span className="text-xs text-yellow-500">{'★'.repeat(s.satisfaction_score)}</span>}
|
||||
</div>
|
||||
<span className="text-xs text-neutral-300">{new Date(s.created_at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}</span>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-400 mt-1 truncate pl-4">
|
||||
<TagBadge status={s.status} priority={s.priority} />
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 中间:聊天区 */}
|
||||
<div className="flex-1 flex flex-col min-w-0 bg-white">
|
||||
{detail ? (
|
||||
{selected && selectedCustomer ? (
|
||||
<>
|
||||
<div className="h-12 px-4 flex items-center justify-between border-b border-neutral-100 flex-shrink-0">
|
||||
<span className="text-sm font-medium text-neutral-800">{detail.customerName}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-neutral-800">{selectedCustomer.name}</span>
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${selected.priority === 'urgent' ? 'text-red-600 bg-red-50' : 'text-blue-600 bg-blue-50'}`}>
|
||||
{priorityLabels[selected.priority] || selected.priority}
|
||||
</span>
|
||||
<span className="text-xs text-neutral-400">{statusLabels[selected.status]}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<span className="text-xs text-neutral-400 cursor-pointer hover:text-neutral-600">转接</span>
|
||||
<span className="text-xs text-neutral-400 cursor-pointer hover:text-red-500">结束</span>
|
||||
@@ -149,18 +177,19 @@ const Dashboard = () => {
|
||||
<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 === 0 ? (
|
||||
<div className="flex-1 flex items-center justify-center text-neutral-400 text-sm">暂无消息</div>
|
||||
) : (
|
||||
) : detail && detail.messages.length > 0 ? (
|
||||
detail.messages.map((msg, i) => (
|
||||
<div key={i} className={`flex ${msg.sender.startsWith('客服') ? 'justify-start' : 'justify-end'}`}>
|
||||
<div className={`max-w-[70%] rounded-lg px-3 py-2 text-sm ${msg.sender.startsWith('客服') ? 'bg-neutral-100 text-neutral-700' : 'bg-blue-500 text-white'}`}>
|
||||
<div key={i} className={`flex ${msg.sender === '客服' ? 'justify-start' : 'justify-end'}`}>
|
||||
<div className={`max-w-[65%] rounded-lg px-3 py-2 text-sm ${msg.sender === '客服' ? 'bg-neutral-100 text-neutral-700' : 'bg-blue-500 text-white'}`}>
|
||||
{msg.content}
|
||||
<div className={`text-xs mt-1 ${msg.sender.startsWith('客服') ? 'text-neutral-400' : 'text-white/60'}`}>{msg.time}</div>
|
||||
<div className={`text-xs mt-1 ${msg.sender === '客服' ? 'text-neutral-400' : 'text-white/60'}`}>{msg.time}</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">
|
||||
<div className="flex items-center gap-2 bg-neutral-50 rounded-lg px-3 py-2">
|
||||
@@ -169,9 +198,10 @@ const Dashboard = () => {
|
||||
placeholder="输入消息... (Enter 发送)"
|
||||
value={messageInput}
|
||||
onChange={e => setMessageInput(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleSend() }}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !sending) handleSend() }}
|
||||
disabled={sending}
|
||||
/>
|
||||
<span className="text-neutral-300 cursor-pointer hover:text-neutral-500">😊</span>
|
||||
{sending && <Spin size="small" />}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -182,33 +212,43 @@ const Dashboard = () => {
|
||||
|
||||
{/* 右侧:客户信息面板 */}
|
||||
<div className="w-[300px] flex-shrink-0 border-l border-neutral-200 bg-white overflow-auto">
|
||||
{detail ? (
|
||||
{selectedCustomer ? (
|
||||
<div className="p-4 space-y-4">
|
||||
<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">
|
||||
<UserOutlined className="text-blue-500" />
|
||||
<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">{detail.customerName}</div>
|
||||
<div className="text-xs text-neutral-400">ID: {detail.id}</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>}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-neutral-400 mb-1.5">标签</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{detail.tags.map(t => <span key={t} className="text-xs px-2 py-0.5 rounded bg-blue-50 text-blue-600">{t}</span>)}
|
||||
{((): string[] => { try { return JSON.parse(selectedCustomer.tags) } catch { return [] } })().map((t: string) => (
|
||||
<span key={t} className="text-xs px-2 py-0.5 rounded bg-blue-50 text-blue-600">{t}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-neutral-400 mb-1.5">统计数据</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="bg-neutral-50 rounded p-2 text-center">
|
||||
<div className="text-lg font-semibold text-neutral-800">{detail.conversationCount}</div>
|
||||
<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">
|
||||
{detail.satisfaction > 0 ? detail.satisfaction : '-'} {detail.satisfaction > 0 && <StarFilled className="text-xs" />}
|
||||
{selected?.satisfaction_score || '-'}
|
||||
{selected?.satisfaction_score ? <StarFilled className="text-xs" /> : null}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-400">满意度</div>
|
||||
</div>
|
||||
@@ -221,10 +261,4 @@ const Dashboard = () => {
|
||||
)
|
||||
}
|
||||
|
||||
function TagBadge({ status, priority }: { status: string; priority: string }) {
|
||||
const color = status === 'ended' ? '#16a34a' : priorityColors[priority] || '#2563eb'
|
||||
const text = status === 'ended' ? '已结束' : status === 'waiting' ? '等待中' : priorityLabels[priority] || status
|
||||
return <span className="text-xs px-1.5 py-0.5 rounded" style={{ color, backgroundColor: `${color}15` }}>{text}</span>
|
||||
}
|
||||
|
||||
export default Dashboard
|
||||
|
||||
Reference in New Issue
Block a user