- 客户管理接通创建、编辑、删除与详情历史会话 - 知识库接通分类/条目 CRUD,按角色控制写权限 - 对话记录增强筛选、客户名、消息与操作时间线 - 新增租户渠道 API,系统设置渠道管理可启用与复制嵌入代码
258 lines
11 KiB
TypeScript
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
|