实现 P1 客户/知识库/对话记录与渠道设置
- 客户管理接通创建、编辑、删除与详情历史会话 - 知识库接通分类/条目 CRUD,按角色控制写权限 - 对话记录增强筛选、客户名、消息与操作时间线 - 新增租户渠道 API,系统设置渠道管理可启用与复制嵌入代码
This commit is contained in:
@@ -1,105 +1,254 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { Input, Select, Tag, Empty, Spin } from 'antd'
|
||||
import { SearchOutlined, UserOutlined } from '@ant-design/icons'
|
||||
import { getSession, getSessions, type Session as SessionType } from '@/services/api'
|
||||
import {
|
||||
getCustomers, getSession, getSessions,
|
||||
type Customer, type Message, type Session, type SessionEvent,
|
||||
} from '@/services/api'
|
||||
|
||||
const statusColors: Record<string, string> = { active: 'blue', ended: 'green', archived: 'default' }
|
||||
const statusLabels: Record<string, string> = { active: '进行中', ended: '已结束', waiting: '等待中' }
|
||||
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<SessionType[]>([])
|
||||
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<{ id: number; sender_type: string; content: string; sent_at: string }[]>([])
|
||||
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])
|
||||
useEffect(() => {
|
||||
loadSessions()
|
||||
}, [statusFilter, priorityFilter])
|
||||
|
||||
const loadSessions = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await getSessions({ status: statusFilter })
|
||||
setSessions(res.list)
|
||||
if (res.list.length > 0 && !selectedId) setSelectedId(res.list[0].id)
|
||||
} catch { setSessions([]) } finally { setLoading(false) }
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
const selected = sessions.find(s => s.id === selectedId)
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedId) {
|
||||
setMessages([])
|
||||
setEvents([])
|
||||
setSelectedSession(null)
|
||||
return
|
||||
}
|
||||
setDetailLoading(true)
|
||||
getSession(selectedId).then(res => {
|
||||
const detail = res.data as { messages?: { id: number; sender_type: string; content: string; sent_at: string }[] }
|
||||
setMessages(detail.messages || [])
|
||||
}).catch(() => setMessages([])).finally(() => setDetailLoading(false))
|
||||
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" />
|
||||
<Select placeholder="状态" value={statusFilter} onChange={setStatusFilter} allowClear size="small" className="w-full" options={Object.entries(statusLabels).map(([k, v]) => ({ value: k, label: v }))} />
|
||||
<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> :
|
||||
sessions.filter(s => !search || s.status.includes(search) || String(s.customer_id).includes(search)).map(s => (
|
||||
<div key={s.id} className={`px-3 py-3 border-b border-neutral-50 cursor-pointer 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">
|
||||
<div className="w-8 h-8 rounded-full bg-neutral-100 flex items-center justify-center flex-shrink-0"><UserOutlined className="text-neutral-400 text-sm" /></div>
|
||||
<div><div className="text-sm font-medium text-neutral-800">客户{s.customer_id}</div><div className="text-xs text-neutral-400">ID: {s.id}</div></div>
|
||||
{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">{statusLabels[s.status] || s.status}</Tag>
|
||||
<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">
|
||||
<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.satisfaction_score && <span className="text-xs text-yellow-500">{'★'.repeat(s.satisfaction_score)}</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>
|
||||
</div>
|
||||
))}
|
||||
{!loading && sessions.length === 0 && <div className="flex items-center justify-center h-full"><Empty description="暂无对话记录" /></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-white overflow-auto">
|
||||
<div className="flex-1 bg-neutral-50 overflow-auto">
|
||||
{selected ? (
|
||||
<div className="p-6 max-w-3xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6 pb-4 border-b border-neutral-100">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-lg font-semibold text-neutral-800">会话 {selected.id}</h3>
|
||||
<Tag color={statusColors[selected.status]}>{statusLabels[selected.status] || selected.status}</Tag>
|
||||
</div>
|
||||
<div className="text-sm text-neutral-400 mt-1">客户ID: {selected.customer_id} · {new Date(selected.created_at).toLocaleString('zh-CN')}</div>
|
||||
</div>
|
||||
{selected.satisfaction_score && (
|
||||
<div className="text-right">
|
||||
<div className="text-xl text-yellow-500">{'★'.repeat(selected.satisfaction_score)}{'☆'.repeat(5 - selected.satisfaction_score)}</div>
|
||||
<div className="text-xs text-neutral-400">满意度评分</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{detailLoading ? <div className="flex justify-center py-10"><Spin /></div> : messages.length === 0 ? <Empty description="暂无消息记录" /> : messages.map(message => (
|
||||
<div key={message.id} className={`flex ${message.sender_type === 'agent' ? 'justify-start' : 'justify-end'}`}>
|
||||
<div className={`max-w-[70%] rounded-lg px-3 py-2 text-sm ${message.sender_type === 'agent' ? 'bg-neutral-100 text-neutral-700' : 'bg-blue-500 text-white'}`}>
|
||||
<div className="text-xs mb-1 opacity-70">{message.sender_type === 'agent' ? '客服' : '访客'}</div>
|
||||
<div>{message.content}</div>
|
||||
<div className="text-xs mt-1 opacity-60">{new Date(message.sent_at).toLocaleString('zh-CN')}</div>
|
||||
<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 items-center justify-center text-neutral-400">选择一个对话查看详情</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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user