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 }) { const [editing, setEditing] = useState(false) const [draft, setDraft] = useState(value) const inputRef = useRef(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 (
{label} {editing ? ( 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) } }} /> ) : ( )}
) } /** 按 id 合并消息,再按 seq / id 排序 */ function mergeMessagesBySeq(existing: Message[], incoming: Message[]): Message[] { const map = new Map() 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 & { 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) // 必须带 hash:demo-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([]) const [customers, setCustomers] = useState>({}) const [loading, setLoading] = useState(true) const [selectedId, setSelectedId] = useState(null) const [detail, setDetail] = useState(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([]) const [targetAgentID, setTargetAgentID] = useState() const [endingOpen, setEndingOpen] = useState(false) const [endReason, setEndReason] = useState('resolved') const [knowledgeOpen, setKnowledgeOpen] = useState(false) const [knowledgeKeyword, setKnowledgeKeyword] = useState('') const [knowledgeEntries, setKnowledgeEntries] = useState([]) const [knowledgeLoading, setKnowledgeLoading] = useState(false) const [quickOpen, setQuickOpen] = useState(false) const [quickKeyword, setQuickKeyword] = useState('') const [quickList, setQuickList] = useState([]) const [quickLoading, setQuickLoading] = useState(false) /** 输入框 / 触发建议 */ const [slashOpen, setSlashOpen] = useState(false) const [slashPrefix, setSlashPrefix] = useState('') const [slashItems, setSlashItems] = useState([]) 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([]) 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([]) const [savingCustomerField, setSavingCustomerField] = useState(null) /** 驱动在线读秒每秒刷新 */ const [clockTick, setClockTick] = useState(0) const initialLoad = useRef(true) const chatEndRef = useRef(null) const fileInputRef = useRef(null) const messageInputRef = useRef(null) const socketRef = useRef(null) const lastTypingAt = useRef(0) const visitorTypingTimer = useRef(null) /** 当前选中会话,供稳定 WS 闭包读取,避免切会话时重连 */ const selectedIdRef = useRef(null) /** 各会话本地已同步到的最大 seq,用于重连增量 catch-up */ const lastSeqBySessionRef = useRef>({}) const loadAllRef = useRef<(opts?: { silent?: boolean }) => Promise>(async () => {}) const loadDetailRef = useRef<(id: number, markRead?: boolean, opts?: { silent?: boolean }) => Promise>(async () => {}) const syncAfterSeqRef = useRef<(id: number, opts?: { markRead?: boolean }) => Promise>(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 & { 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 & { 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
} return (
{ handleImage(event.target.files?.[0]); event.currentTarget.value = '' }} /> {/* 会话列表面板 320px — 顶栏固定 56px,与中/右对齐 */}
setSearch(e.target.value)} />
{filteredSessions.length}
会话状态
{([ { key: 'all', label: '全部' }, { key: 'waiting', label: '等待中' }, { key: 'active', label: '进行中' }, ] as const).map(item => ( ))}
优先级
{([ { key: 'all', label: '全部' }, { key: 'urgent', label: '仅紧急' }, ] as const).map(item => ( ))}
{filterActive && ( )}
)} >
{filteredSessions.length === 0 ? (
暂无待处理会话
) : 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 ( ) })}
{/* 主聊天区 */}
{selected && selectedCustomer ? ( <>
{selectedCustomer.name.slice(0, 1)}
{selectedCustomer.name} {channelLabel(selectedCustomer.source)} {selectedCustomer.status === 'online' ? '在线' : selectedCustomer.status === 'busy' ? '忙碌' : '离线'}
{/* IP | 地区 — 压缩行高,保证三栏顶栏等高 */}
IP: {selected.visitor_ip || '—'} | {selected.visitor_region || '未知'} | 在线 {formatOnlineDuration( selected.created_at, selected.status === 'ended' || selected.status === 'archived' ? selected.ended_at : null, clockTick >= 0 ? Date.now() : Date.now(), )}
handlePriority('urgent') }, { key: 'normal', label: '取消紧急', onClick: () => handlePriority('normal') }, ], }} disabled={!canOperate} >
{detailLoading ? (
) : ( <>
会话开始 — {new Date(selected.created_at).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
{chatTimeline.map(item => { if (item.kind === 'event') { const { event } = item return (
{eventLabel(event.action)} {event.detail || eventLabel(event.action)} {new Date(event.created_at).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit', })}
) } const message = item.message const isAgent = message.sender_type === 'agent' const visitorInitial = selectedCustomer.name.slice(0, 1) return (
{isAgent ? agentInitial : visitorInitial}
{message.type === 'image' ? ( ) : ( {message.content} )}
{new Date(message.sent_at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
) })} {chatTimeline.length === 0 && (
暂无消息,开始对话吧
)} {visitorTyping && selectedCustomer && (
{selectedCustomer.name.slice(0, 1)}
访客正在输入…
)}
)}
{/* 访客实时输入草稿:展示在工具栏/输入框上方 */} {(visitorDraft.trim() || visitorTyping) && (
对方正在输入 {visitorTyping && ( )} {visitorDraft.trim() ? ':' : ''} {visitorDraft.trim() ? ( {visitorDraft.trim()} ) : ( )}
)} {selected.status !== 'active' || !canOperate ? (
{selected.status === 'waiting' ? '领取会话后即可回复' : '会话已结束,无法继续发送消息'}
) : ( <>
输入 / 调用话术
{slashOpen && (
{slashItems.length === 0 ? (
无匹配话术
) : ( slashItems.map((item, idx) => ( )) )}
)}