Files
kefu_cloud/web/src/pages/agent/Dashboard.tsx
T
yml2213 cbc76f3258 客户基本信息支持快速编辑,新增微信与QQ字段
工作台侧栏可点击编辑手机/邮箱/微信/QQ;客户资料同步展示,自动识别仅在主字段为空时回填。
2026-07-19 00:23:21 +08:00

1988 lines
88 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useRef, useCallback } from 'react'
import { Button, Dropdown, Input, Modal, Select, Spin, message as antMsg, Popover } from 'antd'
import {
CheckCircleOutlined, FileTextOutlined, FlagOutlined, PaperClipOutlined,
SearchOutlined, SendOutlined, SwapOutlined, FilterOutlined,
ExportOutlined, BookOutlined, PictureOutlined, ThunderboltOutlined,
} from '@ant-design/icons'
import EmojiPicker, { insertAtCursor } from '@/components/common/EmojiPicker'
import { ChatImage } from '@/components/common/ImagePreview'
import MarkdownBody, { stripMarkdown } from '@/components/common/MarkdownBody'
import { useAuth } from '@/stores/auth'
import {
addSessionNote, claimSession, endSession, getAvailableAgents, getCustomer, getCustomers, getKnowledgeEntries,
getQuickReplies, getSession, getSessionMessages, getSessions, markSessionRead, sendSessionMessage,
suggestQuickReplies, transferSession, updateCustomer, updateSessionPriority, uploadImage, useQuickReply,
type AvailableAgent, type Customer, type CustomerContact, type KnowledgeEntry, type Message, type QuickReply,
type Session, type SessionEvent, type VisitorPageView,
} from '@/services/api'
type CustomerEditableField = 'phone' | 'email' | 'wechat' | 'qq'
/** 客户侧栏基本信息:点击输入,失焦/回车保存 */
function CustomerInfoField({
label,
value,
placeholder,
disabled,
saving,
onSave,
}: {
label: string
value: string
placeholder?: string
disabled?: boolean
saving?: boolean
onSave: (next: string) => Promise<void> | void
}) {
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState(value)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (!editing) setDraft(value)
}, [value, editing])
useEffect(() => {
if (editing) {
requestAnimationFrame(() => {
inputRef.current?.focus()
inputRef.current?.select()
})
}
}, [editing])
const commit = async () => {
const next = draft.trim()
setEditing(false)
if (next === (value || '').trim()) return
await onSave(next)
}
return (
<div className="flex items-center gap-2 min-h-[28px]">
<span className="shrink-0 text-neutral-400 min-w-12 leading-7">{label}</span>
{editing ? (
<input
ref={inputRef}
className="min-w-0 flex-1 h-7 px-2 rounded border border-[#2563eb] bg-white text-[13px] text-neutral-800 outline-none"
value={draft}
disabled={saving || disabled}
placeholder={placeholder || '点击填写'}
onChange={e => setDraft(e.target.value)}
onBlur={() => { void commit() }}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault()
void commit()
}
if (e.key === 'Escape') {
setDraft(value)
setEditing(false)
}
}}
/>
) : (
<button
type="button"
disabled={disabled || saving}
title="点击编辑"
onClick={() => !disabled && setEditing(true)}
className={`min-w-0 flex-1 text-left h-7 px-1 -mx-1 rounded truncate text-[13px] leading-7 ${
value
? 'text-neutral-800 hover:bg-neutral-100'
: 'text-neutral-400 hover:bg-neutral-100'
} disabled:cursor-default disabled:hover:bg-transparent`}
>
{value || placeholder || '点击填写'}
</button>
)}
</div>
)
}
/** 按 id 合并消息,再按 seq / id 排序 */
function mergeMessagesBySeq(existing: Message[], incoming: Message[]): Message[] {
const map = new Map<number, Message>()
for (const item of existing) map.set(item.id, item)
for (const item of incoming) map.set(item.id, item)
return Array.from(map.values()).sort((a, b) => {
const seqDiff = (a.seq || 0) - (b.seq || 0)
return seqDiff !== 0 ? seqDiff : a.id - b.id
})
}
function maxMessageSeq(messages: Message[]): number {
return messages.reduce((acc, item) => Math.max(acc, item.seq || 0), 0)
}
function normalizeMessage(raw: Partial<Message> & { id?: number }, sessionId: number): Message | null {
if (!raw?.id) return null
return {
id: Number(raw.id),
session_id: Number(raw.session_id ?? sessionId),
sender_type: raw.sender_type === 'agent' ? 'agent' : 'visitor',
sender_id: raw.sender_id ?? null,
content: String(raw.content ?? ''),
type: raw.type === 'image' ? 'image' : 'text',
seq: Number(raw.seq ?? 0),
sent_at: raw.sent_at || new Date().toISOString(),
}
}
const endReasons = [
{ value: 'resolved', label: '已解决' },
{ value: 'no_response', label: '无人回复' },
{ value: 'visitor_left', label: '访客离开' },
{ value: 'transferred', label: '已转接' },
{ value: 'other', label: '其他' },
]
/** 列表项展示:紧急 / 等待中 / 进行中 */
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
}
/** 从 UA 摘要设备信息,与后端 summarizeUserAgent 逻辑对齐 */
function summarizeDevice(ua?: string) {
if (!ua) return ''
let browser = '浏览器'
let osName = '未知系统'
if (ua.includes('Edg/')) browser = 'Edge'
else if (ua.includes('Chrome/') && !ua.includes('Edg/')) browser = 'Chrome'
else if (ua.includes('Firefox/')) browser = 'Firefox'
else if (ua.includes('Safari/') && !ua.includes('Chrome/')) browser = 'Safari'
else if (ua.includes('MicroMessenger')) browser = '微信'
if (ua.includes('Windows')) osName = 'Windows'
else if (ua.includes('Mac OS X') || ua.includes('Macintosh')) osName = 'macOS'
else if (ua.includes('Android')) osName = 'Android'
else if (ua.includes('iPhone') || ua.includes('iPad')) osName = 'iOS'
else if (ua.includes('Linux')) osName = 'Linux'
return `${browser} / ${osName}`
}
interface SessionDetail {
messages: Message[]
events: SessionEvent[]
pageViews: VisitorPageView[]
pendingCount: number
}
function formatOnlineDuration(startIso?: string | null, endIso?: string | null, nowMs?: number): string {
if (!startIso) return '—'
const start = new Date(startIso).getTime()
if (!Number.isFinite(start)) return '—'
const end = endIso ? new Date(endIso).getTime() : (nowMs ?? Date.now())
let sec = Math.max(0, Math.floor((end - start) / 1000))
const h = Math.floor(sec / 3600)
sec %= 3600
const m = Math.floor(sec / 60)
const s = sec % 60
if (h > 0) return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
}
function shortPagePath(url?: string): string {
if (!url) return '—'
try {
const u = new URL(url)
// 必须带 hashdemo-shop 等 SPA 靠 #/cart 区分页面
const path = u.pathname + (u.search || '') + (u.hash || '')
return path.length > 56 ? `${path.slice(0, 54)}…` : path || '/'
} catch {
return url.length > 56 ? `${url.slice(0, 54)}…` : url
}
}
const Dashboard = () => {
const { user } = useAuth()
const [sessions, setSessions] = useState<Session[]>([])
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 [detailLoading, setDetailLoading] = useState(false)
const [messageInput, setMessageInput] = useState('')
const [search, setSearch] = useState('')
const [sending, setSending] = useState(false)
const [transferOpen, setTransferOpen] = useState(false)
const [availableAgents, setAvailableAgents] = useState<AvailableAgent[]>([])
const [targetAgentID, setTargetAgentID] = useState<number>()
const [endingOpen, setEndingOpen] = useState(false)
const [endReason, setEndReason] = useState('resolved')
const [knowledgeOpen, setKnowledgeOpen] = useState(false)
const [knowledgeKeyword, setKnowledgeKeyword] = useState('')
const [knowledgeEntries, setKnowledgeEntries] = useState<KnowledgeEntry[]>([])
const [knowledgeLoading, setKnowledgeLoading] = useState(false)
const [quickOpen, setQuickOpen] = useState(false)
const [quickKeyword, setQuickKeyword] = useState('')
const [quickList, setQuickList] = useState<QuickReply[]>([])
const [quickLoading, setQuickLoading] = useState(false)
/** 输入框 / 触发建议 */
const [slashOpen, setSlashOpen] = useState(false)
const [slashPrefix, setSlashPrefix] = useState('')
const [slashItems, setSlashItems] = useState<QuickReply[]>([])
const [slashIndex, setSlashIndex] = useState(0)
const [pendingImage, setPendingImage] = useState<{ file: File; preview: string } | null>(null)
const [noteInput, setNoteInput] = useState('')
const [savingNote, setSavingNote] = useState(false)
const [customerHistory, setCustomerHistory] = useState<Session[]>([])
const [statusFilter, setStatusFilter] = useState<'all' | 'waiting' | 'active'>('all')
const [priorityFilter, setPriorityFilter] = useState<'all' | 'urgent'>('all')
const [filterOpen, setFilterOpen] = useState(false)
const [visitorTyping, setVisitorTyping] = useState(false)
/** 访客输入框未发送草稿(实时) */
const [visitorDraft, setVisitorDraft] = useState('')
const [customerContacts, setCustomerContacts] = useState<CustomerContact[]>([])
const [savingCustomerField, setSavingCustomerField] = useState<CustomerEditableField | null>(null)
/** 驱动在线读秒每秒刷新 */
const [clockTick, setClockTick] = useState(0)
const initialLoad = useRef(true)
const chatEndRef = useRef<HTMLDivElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const messageInputRef = useRef<HTMLTextAreaElement>(null)
const socketRef = useRef<WebSocket | null>(null)
const lastTypingAt = useRef(0)
const visitorTypingTimer = useRef<number | null>(null)
/** 当前选中会话,供稳定 WS 闭包读取,避免切会话时重连 */
const selectedIdRef = useRef<number | null>(null)
/** 各会话本地已同步到的最大 seq,用于重连增量 catch-up */
const lastSeqBySessionRef = useRef<Record<number, number>>({})
const loadAllRef = useRef<(opts?: { silent?: boolean }) => Promise<void>>(async () => {})
const loadDetailRef = useRef<(id: number, markRead?: boolean, opts?: { silent?: boolean }) => Promise<void>>(async () => {})
const syncAfterSeqRef = useRef<(id: number, opts?: { markRead?: boolean }) => Promise<void>>(async () => {})
const rememberSeq = useCallback((sessionId: number, seq: number) => {
if (!sessionId || !seq || seq <= 0) return
const prev = lastSeqBySessionRef.current[sessionId] || 0
if (seq > prev) lastSeqBySessionRef.current[sessionId] = seq
}, [])
const rememberMessagesSeq = useCallback((sessionId: number, messages: Message[]) => {
rememberSeq(sessionId, maxMessageSeq(messages))
}, [rememberSeq])
const insertEmoji = (emoji: string) => {
const { next, cursor } = insertAtCursor(messageInputRef.current, messageInput, emoji)
setMessageInput(next)
emitTyping()
requestAnimationFrame(() => {
const el = messageInputRef.current
if (!el) return
el.focus()
el.setSelectionRange(cursor, cursor)
})
}
const isManager = user?.role === 'admin' || user?.role === 'supervisor'
const loadAll = useCallback(async (opts?: { silent?: boolean }) => {
if (!opts?.silent) setLoading(true)
try {
const [sessionRes, customerRes] = await Promise.all([
getSessions({ page: 1, pageSize: 100 }),
getCustomers({ page: 1, pageSize: 100 }),
])
const sessionList = Array.isArray(sessionRes.list) ? sessionRes.list : []
const customerList = Array.isArray(customerRes.list) ? customerRes.list : []
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.find(session => session.priority === 'urgent')
|| sessionList[0]
setSelectedId(preferred.id)
initialLoad.current = false
}
} catch {
if (!opts?.silent) antMsg.error('加载会话失败')
if (!opts?.silent) {
setSessions([])
setCustomers({})
}
} finally {
if (!opts?.silent) setLoading(false)
}
}, [])
const loadDetail = useCallback(async (id: number, markRead = true, opts?: { silent?: boolean }) => {
if (!opts?.silent) setDetailLoading(true)
try {
const response = await getSession(id)
const data = response.data
const messages = data.messages || []
setDetail({
messages,
events: data.events || [],
// 接口已 desc;再保险按时间新→旧
pageViews: (Array.isArray(data.page_views) ? data.page_views : [])
.slice()
.sort((a, b) => new Date(b.entered_at).getTime() - new Date(a.entered_at).getTime()),
pendingCount: data.pending_count || 0,
})
rememberMessagesSeq(id, messages)
if (typeof data.max_seq === 'number') rememberSeq(id, data.max_seq)
// 用详情接口的完整会话字段(含 IP/地区)回填列表,保证顶栏展示准确
if (data.session) {
setSessions(previous => previous.map(session =>
session.id === id
? {
...session,
...data.session,
unread_count: markRead ? 0 : session.unread_count,
last_message: data.session.last_message ?? session.last_message,
last_message_at: data.session.last_message_at ?? session.last_message_at,
}
: session,
))
// 仅在主动打开会话时恢复草稿;静默轮询不覆盖 WS 实时值
if (!opts?.silent && selectedIdRef.current === id) {
setVisitorDraft(data.session.draft_text || '')
}
} else if (markRead) {
setSessions(previous => previous.map(session => session.id === id ? { ...session, unread_count: 0 } : session))
}
if (markRead) {
await markSessionRead(id)
}
} catch {
if (!opts?.silent) antMsg.error('加载消息失败')
} finally {
if (!opts?.silent) setDetailLoading(false)
}
setTimeout(() => chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }), 80)
}, [rememberMessagesSeq, rememberSeq])
/** 重连 / 发现 seq 空洞时:只拉 after_seq 之后的消息并合并 */
const syncAfterSeq = useCallback(async (id: number, opts?: { markRead?: boolean }) => {
const afterSeq = lastSeqBySessionRef.current[id] || 0
// 本地尚无游标:退回全量详情
if (afterSeq <= 0) {
await loadDetail(id, opts?.markRead ?? true, { silent: true })
return
}
try {
let cursor = afterSeq
let guard = 0
let latest: Message | null = null
while (guard < 10) {
guard += 1
const response = await getSessionMessages(id, cursor, 200)
const batch = response.data.messages || []
const maxSeq = Number(response.data.max_seq || cursor)
if (batch.length > 0) {
latest = batch[batch.length - 1]
setDetail(previous => {
if (selectedIdRef.current !== id) return previous
if (!previous) return { messages: batch, events: [], pageViews: [], pendingCount: 0 }
return { ...previous, messages: mergeMessagesBySeq(previous.messages, batch) }
})
rememberMessagesSeq(id, batch)
}
rememberSeq(id, maxSeq)
const lastBatchSeq = batch.length ? maxMessageSeq(batch) : cursor
if (!response.data.has_more || lastBatchSeq >= maxSeq || batch.length === 0) break
cursor = lastBatchSeq
}
if (latest && selectedIdRef.current === id) {
const preview = latest.type === 'image' ? '[图片]' : stripMarkdown(latest.content)
setSessions(previous => previous.map(session =>
session.id === id
? {
...session,
last_message: preview,
last_message_at: latest!.sent_at,
unread_count: opts?.markRead === false ? session.unread_count : 0,
}
: session,
))
setTimeout(() => chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }), 40)
}
if (opts?.markRead !== false) {
await markSessionRead(id).catch(() => {})
}
} catch {
await loadDetail(id, opts?.markRead ?? true, { silent: true })
}
}, [loadDetail, rememberMessagesSeq, rememberSeq])
/** 将 WS 推送的消息直接追加;若 seq 有空洞则触发增量同步 */
const appendPushedMessage = useCallback((sessionId: number, raw: Partial<Message> & { id?: number }) => {
const msg = normalizeMessage(raw, sessionId)
if (!msg) return false
const known = lastSeqBySessionRef.current[sessionId] || 0
// 已知游标且出现空洞(漏消息)→ 走 after_seq 增量补齐
if (known > 0 && msg.seq > 0 && msg.seq > known + 1) {
void syncAfterSeqRef.current(sessionId, { markRead: true })
return true
}
const preview = msg.type === 'image' ? '[图片]' : stripMarkdown(msg.content)
let appended = false
setDetail(previous => {
if (selectedIdRef.current !== sessionId) return previous
if (!previous) {
appended = true
return { messages: [msg], events: [], pageViews: [], pendingCount: 0 }
}
if (previous.messages.some(item => item.id === msg.id)) {
appended = true
return previous
}
appended = true
return { ...previous, messages: mergeMessagesBySeq(previous.messages, [msg]) }
})
setSessions(previous => previous.map(session => {
if (session.id !== sessionId) return session
const viewing = selectedIdRef.current === sessionId
return {
...session,
last_message: preview,
last_message_at: msg.sent_at,
unread_count: viewing ? 0 : (session.unread_count || 0) + (msg.sender_type === 'visitor' ? 1 : 0),
}
}))
if (msg.seq > 0) rememberSeq(sessionId, msg.seq)
if (selectedIdRef.current === sessionId) {
setTimeout(() => chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }), 40)
void markSessionRead(sessionId).catch(() => {})
}
return appended
}, [rememberSeq])
useEffect(() => {
selectedIdRef.current = selectedId
}, [selectedId])
useEffect(() => {
loadAllRef.current = loadAll
loadDetailRef.current = loadDetail
syncAfterSeqRef.current = syncAfterSeq
}, [loadAll, loadDetail, syncAfterSeq])
useEffect(() => { void loadAll() }, [loadAll])
// 稳定 WebSocket:仅 token 变化时建连;切会话不重连;断线自动重连(ws / wss)
useEffect(() => {
if (!user?.token) return
let disposed = false
let reconnectTimer: number | null = null
let attempt = 0
const clearReconnect = () => {
if (reconnectTimer != null) {
window.clearTimeout(reconnectTimer)
reconnectTimer = null
}
}
const connect = () => {
if (disposed) return
const scheme = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const socket = new WebSocket(`${scheme}//${window.location.host}/api/ws`, ['kefu-v1', user.token])
socketRef.current = socket
socket.onopen = () => {
attempt = 0
// 重连后:列表静默刷新 + 当前会话按 seq 增量 catch-up
void loadAllRef.current({ silent: true })
const sid = selectedIdRef.current
if (sid != null) void syncAfterSeqRef.current(sid, { markRead: true })
}
socket.onmessage = event => {
try {
const payload = JSON.parse(event.data) as {
type?: string
session_id?: number | string
data?: Partial<Message> & { from?: string; status?: string; agent_name?: string }
}
const sid = Number(payload.session_id)
const current = selectedIdRef.current
const sameSession = Number.isFinite(sid) && current != null && sid === Number(current)
if (payload.type === 'typing' && sameSession && payload.data?.from === 'visitor') {
setVisitorTyping(true)
if (visitorTypingTimer.current) clearTimeout(visitorTypingTimer.current)
visitorTypingTimer.current = window.setTimeout(() => setVisitorTyping(false), 1800)
return
}
if (payload.type === 'input_draft' && sameSession) {
const text = typeof (payload.data as { text?: string })?.text === 'string'
? String((payload.data as { text?: string }).text)
: ''
setVisitorDraft(text)
setVisitorTyping(text.length > 0)
if (visitorTypingTimer.current) clearTimeout(visitorTypingTimer.current)
if (text) {
visitorTypingTimer.current = window.setTimeout(() => setVisitorTyping(false), 2500)
}
return
}
if (payload.type === 'customer_updated' && sameSession && payload.data) {
const d = payload.data as {
customer?: Customer
contacts?: CustomerContact[]
}
if (d.customer) {
setCustomers(prev => ({ ...prev, [d.customer!.id]: { ...prev[d.customer!.id], ...d.customer! } }))
}
if (Array.isArray(d.contacts)) {
setCustomerContacts(d.contacts)
}
return
}
if (payload.type === 'message') {
if (sameSession) {
setVisitorTyping(false)
// 访客发出消息后草稿应清空(后端也会推 input_draft 空串,这里兜底)
const sender = (payload.data as { sender_type?: string })?.sender_type
if (sender === 'visitor') setVisitorDraft('')
const ok = appendPushedMessage(sid, payload.data || {})
if (!ok) void syncAfterSeqRef.current(sid, { markRead: true })
}
void loadAllRef.current({ silent: true })
return
}
if (payload.type === 'session_created' || payload.type === 'session_updated') {
void loadAllRef.current({ silent: true })
// 转接/分配/结束等会写 SessionEvent,需全量刷新详情(含 events)
if (sameSession) void loadDetailRef.current(sid, false, { silent: true })
}
if (payload.type === 'page_view' && sameSession && payload.data) {
const raw = payload.data as unknown as VisitorPageView & { title_fix?: boolean }
if (raw?.url) {
setDetail(prev => {
if (!prev) return prev
const idx = prev.pageViews.findIndex(p => p.id === raw.id)
if (idx >= 0) {
// 标题纠正:更新已有条目,并移到最前(仍是当前页)
const next = prev.pageViews.slice()
const item = { ...next[idx], title: raw.title || next[idx].title, url: raw.url }
next.splice(idx, 1)
return { ...prev, pageViews: [item, ...next] }
}
if (prev.pageViews.some(p => p.url === raw.url && !raw.id)) return prev
// 最新轨迹插到最上方
return { ...prev, pageViews: [raw, ...prev.pageViews] }
})
setSessions(previous => previous.map(session =>
session.id === sid
? {
...session,
current_url: raw.url,
current_title: raw.title || session.current_title,
last_seen_at: raw.entered_at || session.last_seen_at,
}
: session,
))
}
}
} catch {
// ignore malformed frames
}
}
socket.onclose = () => {
if (socketRef.current === socket) socketRef.current = null
if (disposed) return
const delay = Math.min(1000 * (2 ** attempt), 15000)
attempt += 1
clearReconnect()
reconnectTimer = window.setTimeout(connect, delay)
}
socket.onerror = () => {
// onclose 会负责重连
try { socket.close() } catch { /* ignore */ }
}
}
connect()
return () => {
disposed = true
clearReconnect()
const sock = socketRef.current
socketRef.current = null
if (sock) {
sock.onclose = null
sock.close()
}
if (visitorTypingTimer.current) clearTimeout(visitorTypingTimer.current)
}
}, [user?.token, appendPushedMessage])
useEffect(() => {
setVisitorTyping(false)
setVisitorDraft('')
setCustomerContacts([])
}, [selectedId])
useEffect(() => {
if (visitorTyping) {
setTimeout(() => chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }), 40)
}
}, [visitorTyping])
useEffect(() => {
if (visitorDraft.trim()) {
setTimeout(() => chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }), 40)
}
}, [visitorDraft])
useEffect(() => {
if (selectedId) loadDetail(selectedId)
}, [selectedId, loadDetail])
useEffect(() => {
if (!knowledgeOpen) return
setKnowledgeLoading(true)
getKnowledgeEntries({ search: knowledgeKeyword, page: 1 }).then(response => {
setKnowledgeEntries(Array.isArray(response.list) ? response.list : [])
}).catch(() => setKnowledgeEntries([])).finally(() => setKnowledgeLoading(false))
}, [knowledgeOpen, knowledgeKeyword])
useEffect(() => {
if (!quickOpen) return
setQuickLoading(true)
getQuickReplies({
scope: 'all',
q: quickKeyword.trim() || undefined,
page: 1,
pageSize: 50,
}).then(response => {
setQuickList(Array.isArray(response.list) ? response.list : [])
}).catch(() => setQuickList([])).finally(() => setQuickLoading(false))
}, [quickOpen, quickKeyword])
useEffect(() => {
if (!slashOpen) return
let cancelled = false
suggestQuickReplies(slashPrefix).then(res => {
if (cancelled) return
const list = Array.isArray(res.data) ? res.data : []
setSlashItems(list)
setSlashIndex(0)
}).catch(() => {
if (!cancelled) setSlashItems([])
})
return () => { cancelled = true }
}, [slashOpen, slashPrefix])
const applyQuickReply = useCallback(async (item: QuickReply) => {
setMessageInput(item.content)
setQuickOpen(false)
setSlashOpen(false)
setSlashPrefix('')
try {
await useQuickReply(item.id)
} catch { /* 计数失败可忽略 */ }
requestAnimationFrame(() => {
const el = messageInputRef.current
if (el) {
el.focus()
const len = item.content.length
el.setSelectionRange(len, len)
}
})
}, [])
/** 从输入内容解析末尾 /shortcut 触发 */
const syncSlashFromInput = useCallback((value: string) => {
// 匹配末尾未完成的 /xxx(前面是行首或空白)
const m = /(^|[\s\n])\/([a-zA-Z0-9_-]*)$/.exec(value)
if (m) {
setSlashOpen(true)
setSlashPrefix(m[2] || '')
} else {
setSlashOpen(false)
setSlashPrefix('')
}
}, [])
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')
// 进行中会话:在线时长每秒刷新;轨迹每 8s 静默拉一次(防 WS 漏推)
useEffect(() => {
if (!selected || selected.status === 'ended' || selected.status === 'archived') return
const t = window.setInterval(() => setClockTick(n => n + 1), 1000)
const poll = window.setInterval(() => {
if (selectedIdRef.current != null) {
void loadDetailRef.current(selectedIdRef.current, false, { silent: true })
}
}, 8000)
return () => {
clearInterval(t)
clearInterval(poll)
}
}, [selected?.id, selected?.status])
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])
// 拉取客户详情中的多条联系方式(输入识别沉淀)
useEffect(() => {
if (!selectedCustomer?.id) return
let cancelled = false
getCustomer(selectedCustomer.id)
.then(res => {
if (cancelled) return
const data = res.data
if (data?.customer) {
setCustomers(prev => ({
...prev,
[data.customer.id]: { ...prev[data.customer.id], ...data.customer },
}))
}
if (Array.isArray(data?.contacts)) {
setCustomerContacts(data.contacts)
}
})
.catch(() => { /* 侧栏增强信息失败不影响会话 */ })
return () => { cancelled = true }
}, [selectedCustomer?.id, selectedId])
const saveCustomerField = useCallback(async (customerId: number, field: CustomerEditableField, value: string) => {
setSavingCustomerField(field)
try {
const res = await updateCustomer(customerId, { [field]: value })
const updated = res.data
setCustomers(prev => ({
...prev,
[customerId]: { ...prev[customerId], ...updated },
}))
} catch (e) {
antMsg.error(e instanceof Error ? e.message : '保存失败')
} finally {
setSavingCustomerField(null)
}
}, [])
const filterActive = statusFilter !== 'all' || priorityFilter !== 'all'
const filteredSessions = sessions
.filter(session => {
if (session.status === 'ended') return false
if (statusFilter !== 'all' && session.status !== statusFilter) return false
if (priorityFilter === 'urgent' && session.priority !== 'urgent') 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 timelineEvents = (detail?.events || []).filter(event =>
event.action === 'transfer'
|| event.action === 'assign'
|| event.action === 'auto_assign'
|| event.action === 'end'
|| event.action === 'offline_leave',
)
const notes = detail?.events
.filter(event =>
event.action === 'note'
|| event.action === 'offline_leave'
|| event.action === 'auto_assign'
|| event.action === 'assign'
|| event.action === 'transfer'
|| event.action === 'end',
)
.slice()
.reverse() || []
type ChatTimelineItem =
| { kind: 'message'; at: number; message: Message }
| { kind: 'event'; at: number; event: SessionEvent }
const chatTimeline: ChatTimelineItem[] = (() => {
const items: ChatTimelineItem[] = []
for (const message of detail?.messages || []) {
items.push({ kind: 'message', at: new Date(message.sent_at).getTime(), message })
}
for (const event of timelineEvents) {
items.push({ kind: 'event', at: new Date(event.created_at).getTime(), event })
}
items.sort((a, b) => a.at - b.at || (a.kind === 'event' ? -1 : 1))
return items
})()
const eventLabel = (action: string) => {
switch (action) {
case 'transfer': return '会话转接'
case 'assign': return '人工分配'
case 'auto_assign': return '自动分配'
case 'end': return '结束会话'
case 'offline_leave': return '离线留言'
default: return '系统记录'
}
}
const emitTyping = () => {
if (!selectedId || !canOperate) return
const now = Date.now()
if (now - lastTypingAt.current < 1200 || socketRef.current?.readyState !== WebSocket.OPEN) return
lastTypingAt.current = now
socketRef.current.send(JSON.stringify({ type: 'typing', session_id: selectedId }))
}
const sendMessage = async (content: string, type: 'text' | 'image' = 'text') => {
if (!selectedId || !canOperate || sending || (type === 'text' && !content.trim())) return
setSending(true)
try {
const response = await sendSessionMessage(selectedId, content, type)
if (type === 'text') setMessageInput('')
// 发送成功后优先用返回体追加并推进 seq,失败再静默全量
if (response.data?.id) {
appendPushedMessage(selectedId, response.data)
void loadAll({ silent: true })
} else {
await syncAfterSeq(selectedId, { markRead: true })
await loadAll({ silent: true })
}
} catch (error) {
antMsg.error(error instanceof Error ? error.message : '发送失败')
} finally {
setSending(false)
}
}
const handleImage = (file?: File) => {
if (!file) return
const okTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
if (!okTypes.includes(file.type)) {
antMsg.error('仅支持 jpg、png、gif、webp 图片')
return
}
if (file.size > 10 * 1024 * 1024) {
antMsg.error('图片不能超过 10 MB')
return
}
if (pendingImage?.preview) URL.revokeObjectURL(pendingImage.preview)
setPendingImage({ file, preview: URL.createObjectURL(file) })
}
const confirmSendImage = async () => {
if (!pendingImage || !selectedId || !canOperate) return
setSending(true)
try {
const uploaded = await uploadImage(pendingImage.file)
const response = await sendSessionMessage(selectedId, uploaded.data.url, 'image')
URL.revokeObjectURL(pendingImage.preview)
setPendingImage(null)
if (response.data?.id) {
appendPushedMessage(selectedId, response.data)
void loadAll({ silent: true })
} else {
await syncAfterSeq(selectedId, { markRead: true })
await loadAll({ silent: true })
}
} catch (error) {
antMsg.error(error instanceof Error ? error.message : '图片发送失败')
} finally {
setSending(false)
}
}
const handleClaim = async (sessionID: number) => {
try {
await claimSession(sessionID)
antMsg.success('已领取会话')
setSelectedId(sessionID)
await loadAll()
} catch (error) {
antMsg.error(error instanceof Error ? error.message : '领取失败')
}
}
const openTransfer = async () => {
if (!selected) return
try {
const response = await getAvailableAgents()
setAvailableAgents((response.data || []).filter(agent => agent.id !== user?.user_id))
setTargetAgentID(undefined)
setTransferOpen(true)
} catch {
antMsg.error('加载在线客服失败')
}
}
const handleTransfer = async () => {
if (!selected || !targetAgentID) return
try {
await transferSession(selected.id, targetAgentID)
antMsg.success('会话已转接')
setTransferOpen(false)
setSelectedId(null)
setDetail(null)
await loadAll()
} catch (error) {
antMsg.error(error instanceof Error ? error.message : '转接失败')
}
}
const handlePriority = async (priority: 'normal' | 'urgent') => {
if (!selected) return
try {
await updateSessionPriority(selected.id, priority)
antMsg.success('优先级已更新')
await loadAll()
} catch (error) {
antMsg.error(error instanceof Error ? error.message : '更新失败')
}
}
const handleEnd = async () => {
if (!selected) return
try {
await endSession(selected.id, endReason)
antMsg.success('会话已结束')
setEndingOpen(false)
setSelectedId(null)
setDetail(null)
await loadAll()
} catch (error) {
antMsg.error(error instanceof Error ? error.message : '结束会话失败')
}
}
const handleAddNote = async () => {
if (!selected || !noteInput.trim()) return
setSavingNote(true)
try {
await addSessionNote(selected.id, noteInput)
setNoteInput('')
await loadDetail(selected.id, false)
} catch (error) {
antMsg.error(error instanceof Error ? error.message : '保存备注失败')
} finally {
setSavingNote(false)
}
}
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,image/webp"
className="hidden"
onChange={event => { handleImage(event.target.files?.[0]); event.currentTarget.value = '' }}
/>
{/* 会话列表面板 320px — 顶栏固定 56px,与中/右对齐 */}
<section className="w-[320px] shrink-0 flex flex-col h-full border-r border-neutral-200 bg-white">
<div
className="shrink-0 px-3 flex items-center gap-2 border-b border-neutral-200"
style={{ height: 'var(--header-height)' }}
>
<div className="flex items-center flex-1 min-w-0 rounded-lg px-2.5 h-8 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>
<span
className="inline-flex items-center h-8 px-2 rounded-md text-xs font-medium bg-[#dbeafe] text-[#2563eb] whitespace-nowrap shrink-0"
title={`当前会话 ${filteredSessions.length} 个`}
>
{filteredSessions.length}
</span>
<Popover
open={filterOpen}
onOpenChange={setFilterOpen}
trigger="click"
placement="bottomRight"
content={(
<div className="w-52 space-y-3">
<div>
<div className="text-xs text-neutral-400 mb-1.5">会话状态</div>
<div className="flex flex-wrap gap-1.5">
{([
{ key: 'all', label: '全部' },
{ key: 'waiting', label: '等待中' },
{ key: 'active', label: '进行中' },
] as const).map(item => (
<button
key={item.key}
type="button"
onClick={() => setStatusFilter(item.key)}
className={`px-2 py-0.5 rounded-md text-xs border ${
statusFilter === item.key
? 'bg-[#dbeafe] border-[#2563eb] text-[#2563eb]'
: 'bg-white border-neutral-200 text-neutral-600'
}`}
>
{item.label}
</button>
))}
</div>
</div>
<div>
<div className="text-xs text-neutral-400 mb-1.5">优先级</div>
<div className="flex flex-wrap gap-1.5">
{([
{ key: 'all', label: '全部' },
{ key: 'urgent', label: '仅紧急' },
] as const).map(item => (
<button
key={item.key}
type="button"
onClick={() => setPriorityFilter(item.key)}
className={`px-2 py-0.5 rounded-md text-xs border ${
priorityFilter === item.key
? 'bg-[#dbeafe] border-[#2563eb] text-[#2563eb]'
: 'bg-white border-neutral-200 text-neutral-600'
}`}
>
{item.label}
</button>
))}
</div>
</div>
{filterActive && (
<button
type="button"
className="text-xs text-neutral-500 hover:text-neutral-700"
onClick={() => { setStatusFilter('all'); setPriorityFilter('all') }}
>
清除筛选
</button>
)}
</div>
)}
>
<button
type="button"
className={`w-8 h-8 rounded-lg border flex items-center justify-center shrink-0 relative ${
filterActive
? 'bg-[#dbeafe] border-[#2563eb] text-[#2563eb]'
: 'bg-neutral-100 border-neutral-200 text-neutral-500'
}`}
title="筛选"
>
<FilterOutlined className="text-xs" />
{filterActive && <span className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-[#2563eb]" />}
</button>
</Popover>
<a
href="/widget/preview"
target="_blank"
rel="noreferrer"
className="w-8 h-8 rounded-lg border border-neutral-200 bg-neutral-100 text-[#2563eb] hover:bg-[#eff6ff] flex items-center justify-center shrink-0"
title="预览访客窗口"
>
<ExportOutlined className="text-xs" />
</a>
</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-9 h-9 rounded-full flex items-center justify-center shrink-0 text-sm font-semibold"
style={{
background: listStatusMeta(selected).avatarBg,
color: listStatusMeta(selected).avatarColor,
}}
>
{selectedCustomer.name.slice(0, 1)}
</div>
<div className="min-w-0 leading-tight">
<div className="flex items-center gap-2">
<span className="truncate font-semibold text-[15px] 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>
{/* IP | 地区 — 压缩行高,保证三栏顶栏等高 */}
<div className="flex items-center gap-1.5 mt-0.5 text-[11px] text-neutral-400 min-w-0">
<span className="truncate">
IP: {selected.visitor_ip || '—'}
</span>
<span className="shrink-0 text-neutral-300">|</span>
<span className="truncate">
{selected.visitor_region || '未知'}
</span>
<span className="shrink-0 text-neutral-300">|</span>
<span className="tabular-nums shrink-0" title="访客在线时长(自进线起)">
在线 {formatOnlineDuration(
selected.created_at,
selected.status === 'ended' || selected.status === 'archived' ? selected.ended_at : null,
clockTick >= 0 ? Date.now() : Date.now(),
)}
</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={() => setQuickOpen(true)}>
<ThunderboltOutlined />
</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={() => 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>
{chatTimeline.map(item => {
if (item.kind === 'event') {
const { event } = item
return (
<div key={`ev-${event.id}`} className="flex justify-center mb-4">
<span className="inline-flex flex-col items-center max-w-[90%] px-3 py-1.5 rounded-lg text-xs text-violet-700 bg-violet-50 border border-violet-100">
<span className="font-medium text-violet-800">{eventLabel(event.action)}</span>
<span className="text-center leading-relaxed mt-0.5">{event.detail || eventLabel(event.action)}</span>
<span className="text-[11px] text-violet-500/80 mt-0.5">
{new Date(event.created_at).toLocaleString('zh-CN', {
month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit',
})}
</span>
</span>
</div>
)
}
const message = item.message
const isAgent = message.sender_type === 'agent'
const visitorInitial = selectedCustomer.name.slice(0, 1)
return (
<div
key={`msg-${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' ? (
<ChatImage src={message.content} alt="聊天图片" className="max-w-64 max-h-64" />
) : (
<MarkdownBody
tone={isAgent ? 'inverse' : 'default'}
className="text-sm"
>
{message.content}
</MarkdownBody>
)}
</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>
)
})}
{chatTimeline.length === 0 && (
<div className="text-center text-sm text-neutral-400 py-10">暂无消息,开始对话吧</div>
)}
{visitorTyping && selectedCustomer && (
<div className="flex items-start gap-2.5 mb-2">
<div
className="w-9 h-9 rounded-full flex items-center justify-center shrink-0 text-sm font-semibold"
style={{
background: listStatusMeta(selected).avatarBg,
color: listStatusMeta(selected).avatarColor,
}}
>
{selectedCustomer.name.slice(0, 1)}
</div>
<div>
<div className="px-3.5 py-2.5 rounded-xl rounded-tl-sm bg-white border border-neutral-100 inline-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 className="mt-1 text-xs text-neutral-400">访客正在输入…</div>
</div>
</div>
)}
<div ref={chatEndRef} />
</>
)}
</div>
<div className="shrink-0 bg-white border-t border-neutral-200">
{/* 访客实时输入草稿:展示在工具栏/输入框上方 */}
{(visitorDraft.trim() || visitorTyping) && (
<div className="mx-4 mt-2 mb-0 rounded-md border border-amber-200/80 bg-amber-50 px-3 py-2">
<div className="flex items-start gap-1.5 min-w-0">
<span className="shrink-0 text-[12px] font-medium text-amber-700 leading-5">
对方正在输入
{visitorTyping && (
<span className="inline-flex items-center gap-0.5 ml-1 align-middle">
<span className="typing-dot" style={{ animationDelay: '0s', width: 3, height: 3 }} />
<span className="typing-dot" style={{ animationDelay: '0.2s', width: 3, height: 3 }} />
<span className="typing-dot" style={{ animationDelay: '0.4s', width: 3, height: 3 }} />
</span>
)}
{visitorDraft.trim() ? '' : ''}
</span>
{visitorDraft.trim() ? (
<span className="min-w-0 flex-1 text-[13px] text-neutral-800 whitespace-pre-wrap break-words max-h-20 overflow-y-auto leading-5">
{visitorDraft.trim()}
</span>
) : (
<span className="text-[12px] text-amber-600/70 leading-5"></span>
)}
</div>
</div>
)}
{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">
<EmojiPicker onSelect={insertEmoji} disabled={!canOperate} />
<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" />
<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={() => setQuickOpen(true)}
>
<ThunderboltOutlined />
快捷回复
</button>
<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>
<span className="text-[11px] text-neutral-400 ml-1">输入 / 调用话术</span>
</div>
<div className="relative flex items-end gap-2.5 px-4 pb-3 pt-1">
{slashOpen && (
<div className="absolute bottom-full left-4 right-16 mb-1 z-20 max-h-56 overflow-auto rounded-lg border border-neutral-200 bg-white shadow-lg">
{slashItems.length === 0 ? (
<div className="px-3 py-2 text-xs text-neutral-400">无匹配话术</div>
) : (
slashItems.map((item, idx) => (
<button
key={item.id}
type="button"
className={`w-full text-left px-3 py-2 border-0 cursor-pointer ${
idx === slashIndex ? 'bg-blue-50' : 'bg-white hover:bg-neutral-50'
}`}
onMouseDown={e => {
e.preventDefault()
void applyQuickReply(item)
}}
>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-neutral-800 truncate">{item.title}</span>
{item.shortcut && (
<code className="text-[11px] text-blue-600 bg-blue-50 px-1 rounded shrink-0">/{item.shortcut}</code>
)}
<span className="text-[10px] text-neutral-400 shrink-0">
{item.scope === 'team' ? '团队' : '个人'}
</span>
</div>
<div className="text-xs text-neutral-500 line-clamp-1 mt-0.5">{item.content}</div>
</button>
))
)}
</div>
)}
<textarea
ref={messageInputRef}
rows={3}
className="flex-1 min-w-0 min-h-[88px] max-h-[160px] rounded-xl px-3.5 py-3 bg-neutral-50 border border-neutral-200 text-sm leading-6 text-neutral-800 placeholder:text-neutral-400 outline-none resize-y focus:border-[#2563eb] transition-colors"
placeholder="支持 Markdown**加粗** *斜体* 列表 链接)… / 调话术,Enter 发送"
value={messageInput}
onChange={event => {
const v = event.target.value
setMessageInput(v)
syncSlashFromInput(v)
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 (slashOpen && slashItems.length > 0) {
if (event.key === 'ArrowDown') {
event.preventDefault()
setSlashIndex(i => (i + 1) % slashItems.length)
return
}
if (event.key === 'ArrowUp') {
event.preventDefault()
setSlashIndex(i => (i - 1 + slashItems.length) % slashItems.length)
return
}
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
void applyQuickReply(slashItems[slashIndex] || slashItems[0])
return
}
if (event.key === 'Escape') {
event.preventDefault()
setSlashOpen(false)
return
}
if (event.key === 'Tab') {
event.preventDefault()
void applyQuickReply(slashItems[slashIndex] || slashItems[0])
return
}
}
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
sendMessage(messageInput)
}
}}
/>
<button
type="button"
disabled={!messageInput.trim() || sending}
onClick={() => sendMessage(messageInput)}
className="w-11 h-11 rounded-xl bg-[#2563eb] hover:bg-[#1d4ed8] disabled:opacity-40 text-white flex items-center justify-center shrink-0 shadow-sm"
>
{sending ? <Spin size="small" /> : <SendOutlined className="text-base" />}
</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">
基本信息
<span className="font-normal normal-case tracking-normal text-neutral-400">点击可编辑</span>
</div>
<div className="flex flex-col gap-1.5 text-sm">
<CustomerInfoField
label="手机"
value={selectedCustomer.phone || ''}
placeholder="点击填写手机号"
saving={savingCustomerField === 'phone'}
onSave={v => saveCustomerField(selectedCustomer.id, 'phone', v)}
/>
<CustomerInfoField
label="邮箱"
value={selectedCustomer.email || ''}
placeholder="点击填写邮箱"
saving={savingCustomerField === 'email'}
onSave={v => saveCustomerField(selectedCustomer.id, 'email', v)}
/>
<CustomerInfoField
label="微信"
value={selectedCustomer.wechat || ''}
placeholder="点击填写微信号"
saving={savingCustomerField === 'wechat'}
onSave={v => saveCustomerField(selectedCustomer.id, 'wechat', v)}
/>
<CustomerInfoField
label="QQ"
value={selectedCustomer.qq || ''}
placeholder="点击填写 QQ 号"
saving={savingCustomerField === 'qq'}
onSave={v => saveCustomerField(selectedCustomer.id, 'qq', v)}
/>
{(() => {
const kindLabel = (k: string) =>
k === 'phone' ? '手机' : k === 'wechat' ? '微信' : k === 'email' ? '邮箱' : k === 'qq' ? 'QQ' : k
// 主字段已展示的联系方式不重复
const extra = customerContacts.filter(c => {
if (c.kind === 'phone' && selectedCustomer.phone && c.value === selectedCustomer.phone) return false
if (c.kind === 'email' && selectedCustomer.email && c.value === selectedCustomer.email) return false
if (c.kind === 'wechat' && selectedCustomer.wechat && c.value === selectedCustomer.wechat) return false
if (c.kind === 'qq' && selectedCustomer.qq && c.value === selectedCustomer.qq) return false
return true
})
if (extra.length === 0) return null
return (
<div className="flex items-start gap-2 pt-1">
<span className="shrink-0 text-neutral-400 min-w-12 leading-5">更多</span>
<div className="flex flex-col gap-1 min-w-0">
{extra.map(c => (
<span key={c.id || `${c.kind}-${c.value}`} className="text-neutral-800 text-xs break-all">
<span className="text-neutral-400 mr-1">{kindLabel(c.kind)}</span>
{c.value}
{(c.source === 'draft' || c.source === 'message') && (
<span className="ml-1 text-[10px] text-amber-600">自动识别</span>
)}
</span>
))}
</div>
</div>
)
})()}
<div className="flex items-start gap-2 pt-1">
<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 className="flex items-start gap-2">
<span className="shrink-0 text-neutral-400 min-w-12">地区</span>
<span className="truncate text-neutral-800">{selected?.visitor_region || '—'}</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">{summarizeDevice(selected?.user_agent) || '—'}</span>
</div>
<div className="flex items-start gap-2 min-w-0">
<span className="shrink-0 text-neutral-400 min-w-12">落地页</span>
{selected?.landing_url ? (
<a
href={selected.landing_url}
target="_blank"
rel="noreferrer"
className="text-[#2563eb] hover:underline break-all min-w-0"
title={selected.landing_title || selected.landing_url}
>
{selected.landing_title || shortPagePath(selected.landing_url)}
</a>
) : (
<span className="text-neutral-400"></span>
)}
</div>
<div className="flex items-start gap-2 min-w-0">
<span className="shrink-0 text-neutral-400 min-w-12">当前页</span>
{selected?.current_url ? (
<a
href={selected.current_url}
target="_blank"
rel="noreferrer"
className="text-neutral-800 hover:text-[#2563eb] hover:underline break-all min-w-0"
title={selected.current_title || selected.current_url}
>
{selected.current_title || shortPagePath(selected.current_url)}
</a>
) : (
<span className="text-neutral-400"></span>
)}
</div>
{selected?.referrer ? (
<div className="flex items-start gap-2 min-w-0">
<span className="shrink-0 text-neutral-400 min-w-12">来路</span>
<a
href={selected.referrer}
target="_blank"
rel="noreferrer"
className="text-neutral-500 hover:underline break-all text-xs min-w-0"
>
{shortPagePath(selected.referrer)}
</a>
</div>
) : null}
<div className="flex items-start gap-2">
<span className="shrink-0 text-neutral-400 min-w-12">在线</span>
<span className="text-neutral-800 tabular-nums font-medium">
{formatOnlineDuration(
selected?.created_at,
selected?.status === 'ended' || selected?.status === 'archived' ? selected?.ended_at : null,
clockTick >= 0 ? Date.now() : Date.now(),
)}
</span>
</div>
</div>
</div>
<div className="mb-5">
<div className="mb-2.5 text-xs font-semibold text-neutral-500 uppercase tracking-wider">浏览轨迹</div>
{!detail?.pageViews?.length ? (
<div className="text-xs text-neutral-400">暂无页面记录(需嵌入 widget 并打开会话)</div>
) : (
<div className="flex flex-col gap-0 max-h-48 overflow-y-auto rounded-lg border border-neutral-100">
{detail.pageViews.map((pv, idx) => (
<div
key={pv.id || `${pv.url}-${pv.entered_at}`}
className={`px-2.5 py-2 text-xs ${idx > 0 ? 'border-t border-neutral-50' : ''} ${
idx === 0 ? 'bg-blue-50/50' : 'bg-white'
}`}
>
<div className="flex items-center justify-between gap-2 mb-0.5">
<span className="text-neutral-400 tabular-nums shrink-0">
{new Date(pv.entered_at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</span>
{idx === 0 && (
<span className="text-[10px] text-[#2563eb] font-medium">当前</span>
)}
</div>
<a
href={pv.url}
target="_blank"
rel="noreferrer"
className="text-neutral-800 hover:text-[#2563eb] break-all leading-snug"
title={pv.url}
>
{pv.title || shortPagePath(pv.url)}
</a>
</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 => {
const isLeave = note.action === 'offline_leave'
const isAuto = note.action === 'auto_assign'
const isTransfer = note.action === 'transfer'
const isAssign = note.action === 'assign'
const isEnd = note.action === 'end'
const box = isLeave
? 'bg-orange-50 text-orange-900 border-orange-100'
: isTransfer
? 'bg-violet-50 text-violet-900 border-violet-100'
: (isAuto || isAssign)
? 'bg-blue-50 text-blue-900 border-blue-100'
: isEnd
? 'bg-neutral-100 text-neutral-700 border-neutral-200'
: 'bg-amber-50 text-amber-900 border-amber-100'
const timeCls = isLeave
? 'text-orange-600/70'
: isTransfer
? 'text-violet-600/70'
: (isAuto || isAssign)
? 'text-blue-600/70'
: isEnd
? 'text-neutral-500'
: 'text-amber-600/70'
const title = isLeave
? '离线留言'
: isTransfer
? '会话转接'
: isAuto
? '自动分配'
: isAssign
? '人工分配'
: isEnd
? '结束会话'
: null
return (
<div key={note.id} className={`rounded-lg p-2 text-xs whitespace-pre-wrap border ${box}`}>
{title && <div className="font-medium mb-0.5">{title}</div>}
{note.detail}
<div className={`mt-1 ${timeCls}`}>{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(pendingImage)}
onCancel={() => {
if (pendingImage?.preview) URL.revokeObjectURL(pendingImage.preview)
setPendingImage(null)
}}
onOk={confirmSendImage}
okText="上传并发送"
okButtonProps={{ loading: sending }}
>
<div className="flex justify-center">
<img src={pendingImage?.preview || ''} alt="待发送图片预览" className="max-h-[420px] max-w-full rounded-lg" />
</div>
<p className="text-xs text-neutral-400 text-center mt-3 mb-0">将自动压缩并转为 WebP 后上传</p>
</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>
<Modal title="快捷回复" open={quickOpen} onCancel={() => setQuickOpen(false)} footer={null} width={640}>
<Input
prefix={<SearchOutlined />}
placeholder="搜索标题、内容或输入码"
value={quickKeyword}
onChange={event => setQuickKeyword(event.target.value)}
allowClear
className="mb-3"
/>
<p className="text-xs text-neutral-400 mb-2">含团队已发布 + 我的话术。管理请到侧栏「快捷回复」。</p>
{quickLoading ? (
<div className="py-10 text-center"><Spin /></div>
) : (
<div className="space-y-2 max-h-96 overflow-auto">
{quickList.length === 0 ? (
<div className="text-center text-neutral-400 py-8">暂无快捷回复</div>
) : quickList.map(item => (
<button
key={item.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={() => void applyQuickReply(item)}
>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-neutral-800">{item.title}</span>
{item.shortcut && (
<code className="text-[11px] text-blue-600 bg-blue-50 px-1 rounded">/{item.shortcut}</code>
)}
<span className="text-[10px] text-neutral-400 ml-auto">
{item.scope === 'team' ? '团队' : '个人'}
</span>
</div>
<div className="text-xs text-neutral-500 mt-1 line-clamp-2">{item.content}</div>
</button>
))}
</div>
)}
</Modal>
</div>
)
}
export default Dashboard