Files
kefu_cloud/web/src/pages/agent/ChatHistory.tsx
T
yml2213 65014d80de 实现 P1 客户/知识库/对话记录与渠道设置
- 客户管理接通创建、编辑、删除与详情历史会话
- 知识库接通分类/条目 CRUD,按角色控制写权限
- 对话记录增强筛选、客户名、消息与操作时间线
- 新增租户渠道 API,系统设置渠道管理可启用与复制嵌入代码
2026-07-15 11:18:01 +08:00

258 lines
11 KiB
TypeScript

import { useState, useEffect, useMemo } from 'react'
import { Input, Select, Tag, Empty, Spin } from 'antd'
import { SearchOutlined, UserOutlined } from '@ant-design/icons'
import {
getCustomers, getSession, getSessions,
type Customer, type Message, type Session, type SessionEvent,
} from '@/services/api'
const statusColors: Record<string, string> = {
active: 'blue', waiting: 'orange', ended: 'green', archived: 'default',
}
const statusLabels: Record<string, string> = {
active: '进行中', ended: '已结束', waiting: '等待中', archived: '已归档',
}
const priorityLabels: Record<string, string> = { urgent: '紧急', normal: '普通' }
const ChatHistory = () => {
const [sessions, setSessions] = useState<Session[]>([])
const [customers, setCustomers] = useState<Record<number, Customer>>({})
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState<string>()
const [priorityFilter, setPriorityFilter] = useState<string>()
const [selectedId, setSelectedId] = useState<number | null>(null)
const [messages, setMessages] = useState<Message[]>([])
const [events, setEvents] = useState<SessionEvent[]>([])
const [selectedSession, setSelectedSession] = useState<Session | null>(null)
const [detailLoading, setDetailLoading] = useState(false)
useEffect(() => {
loadSessions()
}, [statusFilter, priorityFilter])
const loadSessions = async () => {
setLoading(true)
try {
const [sessionRes, customerRes] = await Promise.all([
getSessions({ status: statusFilter, priority: priorityFilter, page: 1, pageSize: 100 }),
getCustomers({ page: 1, pageSize: 200 }),
])
const list = Array.isArray(sessionRes.list) ? sessionRes.list : []
setSessions(list)
const map = Object.fromEntries((customerRes.list || []).map(c => [c.id, c]))
setCustomers(map)
if (list.length > 0) {
const still = selectedId && list.some(s => s.id === selectedId)
if (!still) setSelectedId(list[0].id)
} else {
setSelectedId(null)
}
} catch {
setSessions([])
} finally {
setLoading(false)
}
}
useEffect(() => {
if (!selectedId) {
setMessages([])
setEvents([])
setSelectedSession(null)
return
}
setDetailLoading(true)
getSession(selectedId).then(res => {
setMessages(res.data.messages || [])
setEvents(res.data.events || [])
setSelectedSession(res.data.session || sessions.find(s => s.id === selectedId) || null)
}).catch(() => {
setMessages([])
setEvents([])
}).finally(() => setDetailLoading(false))
}, [selectedId])
const filtered = useMemo(() => {
const keyword = search.trim().toLowerCase()
return sessions.filter(s => {
if (!keyword) return true
const name = customers[s.customer_id]?.name || ''
return name.toLowerCase().includes(keyword)
|| String(s.id).includes(keyword)
|| String(s.customer_id).includes(keyword)
|| (s.last_message || '').toLowerCase().includes(keyword)
})
}, [sessions, customers, search])
const selected = selectedSession || sessions.find(s => s.id === selectedId)
const customer = selected ? customers[selected.customer_id] : null
return (
<div className="h-full flex">
<div className="w-[360px] flex-shrink-0 bg-white border-r border-neutral-200 flex flex-col">
<div className="p-3 border-b border-neutral-100 space-y-2">
<Input
prefix={<SearchOutlined />}
placeholder="搜索客户/会话/消息"
value={search}
onChange={e => setSearch(e.target.value)}
allowClear
size="small"
/>
<div className="flex gap-2">
<Select
placeholder="状态"
value={statusFilter}
onChange={setStatusFilter}
allowClear
size="small"
className="flex-1"
options={Object.entries(statusLabels).map(([k, v]) => ({ value: k, label: v }))}
/>
<Select
placeholder="优先级"
value={priorityFilter}
onChange={setPriorityFilter}
allowClear
size="small"
className="flex-1"
options={Object.entries(priorityLabels).map(([k, v]) => ({ value: k, label: v }))}
/>
</div>
</div>
<div className="flex-1 overflow-auto">
{loading ? (
<div className="flex justify-center py-10"><Spin /></div>
) : filtered.map(s => {
const name = customers[s.customer_id]?.name || `客户${s.customer_id}`
return (
<button
key={s.id}
type="button"
className={`w-full text-left px-3 py-3 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 gap-2">
<div className="flex items-center gap-2 min-w-0">
<div className="w-8 h-8 rounded-full bg-neutral-100 flex items-center justify-center flex-shrink-0 text-xs font-semibold text-neutral-500">
{name.slice(0, 1)}
</div>
<div className="min-w-0">
<div className="text-sm font-medium text-neutral-800 truncate">{name}</div>
<div className="text-xs text-neutral-400 truncate">{s.last_message || `会话 #${s.id}`}</div>
</div>
</div>
<Tag color={statusColors[s.status]} className="text-xs m-0 shrink-0">{statusLabels[s.status] || s.status}</Tag>
</div>
<div className="flex items-center justify-between mt-1.5 pl-10">
<span className="text-xs text-neutral-300">{new Date(s.created_at).toLocaleString('zh-CN')}</span>
{s.priority === 'urgent' && <Tag color="red" className="text-xs m-0">紧急</Tag>}
{s.satisfaction_score ? <span className="text-xs text-yellow-500">{'★'.repeat(s.satisfaction_score)}</span> : null}
</div>
</button>
)
})}
{!loading && filtered.length === 0 && (
<div className="flex items-center justify-center h-40"><Empty description="暂无对话记录" /></div>
)}
</div>
</div>
<div className="flex-1 bg-neutral-50 overflow-auto">
{selected ? (
<div className="p-6 max-w-3xl mx-auto">
<div className="bg-white rounded-xl border border-neutral-200 p-5 mb-4">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="text-lg font-semibold text-neutral-800 m-0 truncate">
{customer?.name || `客户${selected.customer_id}`}
</h3>
<Tag color={statusColors[selected.status]}>{statusLabels[selected.status] || selected.status}</Tag>
{selected.priority === 'urgent' && <Tag color="red">紧急</Tag>}
</div>
<div className="text-sm text-neutral-400 mt-1">
会话 #{selected.id}
{customer?.source ? ` · ${customer.source}` : ''}
{' · '}
{new Date(selected.created_at).toLocaleString('zh-CN')}
{selected.ended_at ? ` ~ ${new Date(selected.ended_at).toLocaleString('zh-CN')}` : ''}
</div>
</div>
{selected.satisfaction_score != null && (
<div className="text-right shrink-0">
<div className="text-xl text-yellow-500">
{'★'.repeat(selected.satisfaction_score)}{'☆'.repeat(Math.max(0, 5 - selected.satisfaction_score))}
</div>
<div className="text-xs text-neutral-400">满意度评分</div>
{selected.satisfaction_text && (
<div className="text-xs text-neutral-500 mt-1 max-w-[180px]">{selected.satisfaction_text}</div>
)}
</div>
)}
</div>
</div>
<div className="bg-white rounded-xl border border-neutral-200 p-5 mb-4">
<div className="text-sm font-medium text-neutral-700 mb-4">消息记录</div>
<div className="space-y-4">
{detailLoading ? (
<div className="flex justify-center py-10"><Spin /></div>
) : messages.length === 0 ? (
<Empty description="暂无消息记录" />
) : messages.map(message => {
const isAgent = message.sender_type === 'agent'
return (
<div key={message.id} className={`flex ${isAgent ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[70%] rounded-xl px-3.5 py-2.5 text-sm ${
isAgent ? 'bg-[#2563eb] text-white rounded-tr-sm' : 'bg-neutral-100 text-neutral-700 rounded-tl-sm'
}`}>
<div className={`text-xs mb-1 ${isAgent ? 'text-white/70' : 'text-neutral-400'}`}>
{isAgent ? '客服' : '访客'}
</div>
{message.type === 'image' ? (
<img src={message.content} alt="图片" className="max-w-56 max-h-56 rounded-lg" />
) : (
<div className="whitespace-pre-wrap break-words">{message.content}</div>
)}
<div className={`text-xs mt-1 ${isAgent ? 'text-white/60' : 'text-neutral-400'}`}>
{new Date(message.sent_at).toLocaleString('zh-CN')}
</div>
</div>
</div>
)
})}
</div>
</div>
{events.length > 0 && (
<div className="bg-white rounded-xl border border-neutral-200 p-5">
<div className="text-sm font-medium text-neutral-700 mb-3">操作记录</div>
<div className="space-y-2">
{events.slice().reverse().map(ev => (
<div key={ev.id} className="flex gap-3 text-xs text-neutral-500 border-b border-neutral-50 pb-2">
<span className="shrink-0 text-neutral-400 w-36">
{new Date(ev.created_at).toLocaleString('zh-CN')}
</span>
<Tag className="m-0 text-xs">{ev.action}</Tag>
<span className="flex-1">{ev.detail}</span>
</div>
))}
</div>
</div>
)}
</div>
) : (
<div className="h-full flex flex-col items-center justify-center text-neutral-400 gap-2">
<UserOutlined className="text-2xl" />
<div className="text-sm">选择一个对话查看详情</div>
</div>
)}
</div>
</div>
)
}
export default ChatHistory