Files
kefu_cloud/web/src/pages/agent/Dashboard.tsx
T
yml2213 b483cbc26d 工作台右侧栏精简客户信息并内嵌快捷回复
去掉访问来源区块以缩短信息区;保留浏览轨迹、历史会话与内部备注,下方增加团队/个人快捷回复列表。
2026-07-19 23:52:53 +08:00

2291 lines
99 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, Checkbox, Dropdown, Input, Modal, Radio, Select, Spin, message as antMsg, Popover } from 'antd'
import {
CheckCircleOutlined, FileTextOutlined, FlagOutlined, PaperClipOutlined,
SearchOutlined, SendOutlined, SwapOutlined, FilterOutlined,
ExportOutlined, BookOutlined, PictureOutlined, ThunderboltOutlined,
StopOutlined,
} 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, createBlacklist, endSession, getAvailableAgents, getCustomer, getCustomers, getKnowledgeEntries,
getQuickReplies, getSession, getSessionMessages, getSessions, markSessionRead, sendSessionMessage,
suggestQuickReplies, transferSession, updateCustomer, updateSessionPriority, uploadImage, useQuickReply,
type AvailableAgent, type BlacklistDuration, 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 agentStatusMeta(status: string) {
switch (status) {
case 'online':
return { text: '在线', dot: '#16a34a', bg: '#ecfdf5', color: '#15803d' }
case 'busy':
return { text: '忙碌', dot: '#d97706', bg: '#fffbeb', color: '#b45309' }
case 'disabled':
return { text: '停用', dot: '#94a3b8', bg: '#f1f5f9', color: '#64748b' }
default:
return { text: '离线', dot: '#94a3b8', bg: '#f1f5f9', color: '#64748b' }
}
}
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
}
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)
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 [blacklistOpen, setBlacklistOpen] = useState(false)
const [blacklistKind, setBlacklistKind] = useState<'ip' | 'device'>('ip')
const [blacklistDuration, setBlacklistDuration] = useState<BlacklistDuration>('7d')
const [blacklistReason, setBlacklistReason] = useState('')
const [blacklistEndSession, setBlacklistEndSession] = useState(true)
const [blacklistSaving, setBlacklistSaving] = useState(false)
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 [sidebarQuickScope, setSidebarQuickScope] = useState<'team' | 'personal'>('team')
const [sidebarQuickKeyword, setSidebarQuickKeyword] = useState('')
const [sidebarQuickList, setSidebarQuickList] = useState<QuickReply[]>([])
const [sidebarQuickLoading, setSidebarQuickLoading] = useState(false)
/** 输入框快捷回复建议:slash=/ 输入码;keyword=正文关键字 */
const [suggestOpen, setSuggestOpen] = useState(false)
const [suggestMode, setSuggestMode] = useState<'slash' | 'keyword'>('slash')
const [slashPrefix, setSlashPrefix] = useState('')
const [keywordQuery, setKeywordQuery] = useState('')
const [suggestItems, setSuggestItems] = useState<QuickReply[]>([])
const [suggestIndex, setSuggestIndex] = useState(0)
const keywordTimer = useRef<number | null>(null)
/** 避免唯一匹配自动填入后立刻再次触发 */
const autoAppliedRef = useRef('')
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(() => {
let cancelled = false
setSidebarQuickLoading(true)
getQuickReplies({
scope: sidebarQuickScope,
// 团队仅展示已发布;个人展示自己的全部可用
status: sidebarQuickScope === 'team' ? 'published' : undefined,
q: sidebarQuickKeyword.trim() || undefined,
page: 1,
pageSize: 50,
}).then(response => {
if (cancelled) return
setSidebarQuickList(Array.isArray(response.list) ? response.list : [])
}).catch(() => {
if (!cancelled) setSidebarQuickList([])
}).finally(() => {
if (!cancelled) setSidebarQuickLoading(false)
})
return () => { cancelled = true }
}, [sidebarQuickScope, sidebarQuickKeyword])
const closeSuggest = useCallback(() => {
setSuggestOpen(false)
setSuggestItems([])
setSuggestIndex(0)
setSlashPrefix('')
setKeywordQuery('')
}, [])
// / 模式:拉输入码建议(按个人调用频次)
useEffect(() => {
if (!suggestOpen || suggestMode !== 'slash') return
let cancelled = false
suggestQuickReplies({ mode: 'slash', prefix: slashPrefix }).then(res => {
if (cancelled) return
const list = Array.isArray(res.data) ? res.data : []
setSuggestItems(list)
setSuggestIndex(0)
// 输入码细化后仅剩 1 条 → 自动填入(纯 / 不自动,避免误触)
if (list.length === 1 && slashPrefix.length > 0) {
const only = list[0]
const key = `slash:${slashPrefix}:${only.id}`
if (autoAppliedRef.current !== key) {
autoAppliedRef.current = key
// 延迟到下一 tick,避免与 onChange 竞态
window.setTimeout(() => {
void applyQuickReplyRef.current(only, 'slash')
}, 0)
}
}
}).catch(() => {
if (!cancelled) setSuggestItems([])
})
return () => { cancelled = true }
}, [suggestOpen, suggestMode, slashPrefix])
// 关键字模式:防抖搜索标题/内容
useEffect(() => {
if (!suggestOpen || suggestMode !== 'keyword') return
const q = keywordQuery.trim()
if (q.length < 2) {
setSuggestItems([])
return
}
let cancelled = false
suggestQuickReplies({ mode: 'keyword', q }).then(res => {
if (cancelled) return
const list = Array.isArray(res.data) ? res.data : []
setSuggestItems(list)
setSuggestIndex(0)
}).catch(() => {
if (!cancelled) setSuggestItems([])
})
return () => { cancelled = true }
}, [suggestOpen, suggestMode, keywordQuery])
const applyQuickReplyRef = useRef<(item: QuickReply, mode?: 'slash' | 'keyword' | 'panel') => Promise<void>>(async () => {})
const applyQuickReply = useCallback(async (item: QuickReply, mode: 'slash' | 'keyword' | 'panel' = 'panel') => {
if (mode === 'slash') {
// 只替换末尾 /输入码,保留前文
setMessageInput(prev => prev.replace(/(^|[\s\n])\/([a-zA-Z0-9_-]*)$/, `$1${item.content}`))
} else if (mode === 'keyword') {
// 关键字触发:用话术替换当前输入(模板式回复)
setMessageInput(item.content)
} else {
setMessageInput(item.content)
}
setQuickOpen(false)
closeSuggest()
try {
await useQuickReply(item.id)
} catch { /* 计数失败可忽略 */ }
requestAnimationFrame(() => {
const el = messageInputRef.current
if (el) {
el.focus()
const len = el.value.length
el.setSelectionRange(len, len)
}
})
}, [closeSuggest])
applyQuickReplyRef.current = applyQuickReply
/** 解析输入:优先 / 输入码;否则关键字联想 */
const syncSuggestFromInput = useCallback((value: string) => {
// 末尾 /xxx(行首或空白后)
const slashMatch = /(^|[\s\n])\/([a-zA-Z0-9_-]*)$/.exec(value)
if (slashMatch) {
if (keywordTimer.current) {
window.clearTimeout(keywordTimer.current)
keywordTimer.current = null
}
setSuggestMode('slash')
setSuggestOpen(true)
setSlashPrefix(slashMatch[2] || '')
setKeywordQuery('')
return
}
// 无 / 时:取最后一段非空白作关键字(至少 2 字)
const trimmed = value.trim()
if (!trimmed) {
closeSuggest()
return
}
// 取末行最后一词/整段
const lastLine = trimmed.split(/\n/).pop() || trimmed
const token = lastLine.trim()
if (token.length < 2) {
if (keywordTimer.current) {
window.clearTimeout(keywordTimer.current)
keywordTimer.current = null
}
closeSuggest()
return
}
if (keywordTimer.current) window.clearTimeout(keywordTimer.current)
keywordTimer.current = window.setTimeout(() => {
setSuggestMode('keyword')
setSuggestOpen(true)
setKeywordQuery(token)
setSlashPrefix('')
}, 220)
}, [closeSuggest])
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'
|| event.action === 'blacklist',
)
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 === 'blacklist'
|| 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 'blacklist': 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 {
// 默认返回在线/忙碌坐席(含 status)
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
const target = availableAgents.find(a => a.id === targetAgentID)
if (target && target.status !== 'online' && target.status !== 'busy') {
antMsg.warning('只能转接给在线或忙碌的坐席')
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 openBlacklist = () => {
if (!selected) return
setBlacklistKind(selected.visitor_ip ? 'ip' : 'device')
setBlacklistDuration('7d')
setBlacklistReason('')
setBlacklistEndSession(selected.status === 'active' || selected.status === 'waiting')
setBlacklistOpen(true)
}
const handleBlacklist = async () => {
if (!selected) return
const reason = blacklistReason.trim()
if (!reason) {
antMsg.warning('请填写拉黑原因')
return
}
if (blacklistKind === 'ip' && !selected.visitor_ip) {
antMsg.warning('该会话无有效 IP,请改选「设备」')
return
}
if (blacklistKind === 'device' && !selected.device_key && !selected.user_agent) {
antMsg.warning('该会话无设备标识,请改选「IP」')
return
}
setBlacklistSaving(true)
try {
await createBlacklist({
session_id: selected.id,
kind: blacklistKind,
duration: blacklistDuration,
reason,
end_session: blacklistEndSession,
})
antMsg.success(blacklistKind === 'ip' ? '已拉黑该 IP' : '已拉黑该设备')
setBlacklistOpen(false)
if (blacklistEndSession) {
setSelectedId(null)
setDetail(null)
} else if (selectedId) {
await loadDetail(selectedId, false)
}
await loadAll()
} catch (error) {
antMsg.error(error instanceof Error ? error.message : '拉黑失败')
} finally {
setBlacklistSaving(false)
}
}
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-neutral-500 hover:bg-red-50 hover:text-red-600 disabled:opacity-40"
title="拉黑"
disabled={!selected}
onClick={openBlacklist}
>
<StopOutlined />
</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">
{suggestOpen && (suggestMode === 'slash' || keywordQuery.trim().length >= 2) && (
<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">
<div className="px-3 py-1.5 text-[11px] text-neutral-400 border-b border-neutral-100 flex items-center justify-between">
<span>
{suggestMode === 'slash'
? (slashPrefix ? `输入码 /${slashPrefix}` : '常用话术(按你的调用频率)')
: `关键字「${keywordQuery}」`}
</span>
<span>↑↓ 选择 · Enter 填入 · Esc 关闭</span>
</div>
{suggestItems.length === 0 ? (
<div className="px-3 py-2 text-xs text-neutral-400">无匹配话术</div>
) : (
suggestItems.map((item, idx) => (
<button
key={item.id}
type="button"
className={`w-full text-left px-3 py-2 border-0 cursor-pointer ${
idx === suggestIndex ? 'bg-blue-50' : 'bg-white hover:bg-neutral-50'
}`}
onMouseDown={e => {
e.preventDefault()
void applyQuickReply(item, suggestMode)
}}
>
<div className="flex items-center gap-2 min-w-0">
<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>
{(item.my_usage_count || 0) > 0 && (
<span className="text-[10px] text-neutral-400 shrink-0 ml-auto">用过 {item.my_usage_count} </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="输入文字联想话术,/ 调输入码… Enter 发送"
value={messageInput}
onChange={event => {
const v = event.target.value
setMessageInput(v)
syncSuggestFromInput(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 (suggestOpen && suggestItems.length > 0) {
if (event.key === 'ArrowDown') {
event.preventDefault()
setSuggestIndex(i => (i + 1) % suggestItems.length)
return
}
if (event.key === 'ArrowUp') {
event.preventDefault()
setSuggestIndex(i => (i - 1 + suggestItems.length) % suggestItems.length)
return
}
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
void applyQuickReply(suggestItems[suggestIndex] || suggestItems[0], suggestMode)
return
}
if (event.key === 'Escape') {
event.preventDefault()
closeSuggest()
return
}
if (event.key === 'Tab') {
event.preventDefault()
void applyQuickReply(suggestItems[suggestIndex] || suggestItems[0], suggestMode)
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>
{/* 右侧:上客户信息 + 下快捷回复 */}
<aside className="w-[320px] shrink-0 flex flex-col h-full border-l border-neutral-200 bg-white min-h-0">
{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="shrink-0 max-h-[42%] overflow-y-auto px-4 py-3 no-scrollbar border-b border-neutral-100">
<div className="flex items-center gap-3 mb-3">
<div
className="w-10 h-10 rounded-full flex items-center justify-center text-[16px] font-semibold shrink-0"
style={{
background: selected ? listStatusMeta(selected).avatarBg : '#eff6ff',
color: selected ? listStatusMeta(selected).avatarColor : '#2563eb',
}}
>
{selectedCustomer.name.slice(0, 1)}
</div>
<div className="min-w-0">
<div className="font-semibold text-[15px] text-neutral-900 truncate">{selectedCustomer.name}</div>
<div className="flex items-center gap-1 mt-0.5 text-[11px] 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' ? '忙碌' : '离线'}
{selectedCustomer.source ? (
<span className="text-neutral-300">· {selectedCustomer.source}</span>
) : null}
</div>
</div>
</div>
<div className="flex flex-col gap-1 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="点击填写"
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-0.5">
<span className="shrink-0 text-neutral-400 min-w-12 leading-5 text-sm">更多</span>
<div className="flex flex-col gap-0.5 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}
</span>
))}
</div>
</div>
)
})()}
</div>
<div className="mt-3">
<div className="mb-1.5 text-[11px] font-semibold text-neutral-500">标签</div>
<div className="flex flex-wrap gap-1">
{parseTags(selectedCustomer.tags).length === 0 ? (
<span className="text-xs text-neutral-400">暂无</span>
) : parseTags(selectedCustomer.tags).map(tag => (
<span key={tag} className="text-[11px] px-1.5 py-0.5 rounded-md bg-[#dbeafe] text-[#2563eb] font-medium">
{tag}
</span>
))}
</div>
</div>
<div className="mt-4 mb-3">
<div className="mb-1.5 text-[11px] font-semibold text-neutral-500">浏览轨迹</div>
{!detail?.pageViews?.length ? (
<div className="text-xs text-neutral-400">暂无页面记录(需嵌入 widget 并打开会话)</div>
) : (
<div className="flex flex-col gap-0 max-h-36 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-3">
<div className="mb-1.5 text-[11px] font-semibold text-neutral-500">历史会话</div>
<div className="flex flex-col gap-1.5">
{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-2.5 py-2 text-left bg-neutral-50 border border-neutral-100 hover:border-blue-200"
>
<div className="flex items-center justify-between mb-0.5">
<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-1">
<div className="mb-1.5 text-[11px] font-semibold text-neutral-500">内部备注</div>
<div className="space-y-1.5 max-h-32 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 min-h-0 flex flex-col">
<div className="shrink-0 px-3 pt-2.5 pb-2 border-b border-neutral-100">
<div className="flex items-center justify-between gap-2 mb-2">
<span className="text-sm font-semibold text-neutral-800">快捷回复</span>
<div className="flex items-center gap-0.5 p-0.5 rounded-md bg-neutral-100">
{([
{ key: 'team' as const, label: '团队' },
{ key: 'personal' as const, label: '个人' },
]).map(tab => (
<button
key={tab.key}
type="button"
onClick={() => setSidebarQuickScope(tab.key)}
className={`h-6 px-2 rounded text-xs border-0 cursor-pointer transition-colors ${
sidebarQuickScope === tab.key
? 'bg-white text-[#2563eb] font-medium shadow-sm'
: 'bg-transparent text-neutral-500 hover:text-neutral-700'
}`}
>
{tab.label}
</button>
))}
</div>
</div>
<Input
size="small"
allowClear
prefix={<SearchOutlined className="text-neutral-400" />}
placeholder="搜索标题、内容或输入码"
value={sidebarQuickKeyword}
onChange={e => setSidebarQuickKeyword(e.target.value)}
/>
</div>
<div className="flex-1 min-h-0 overflow-y-auto px-2 py-2 no-scrollbar">
{sidebarQuickLoading ? (
<div className="py-8 text-center"><Spin size="small" /></div>
) : sidebarQuickList.length === 0 ? (
<div className="py-8 text-center text-xs text-neutral-400">
{sidebarQuickScope === 'team' ? '暂无已发布的团队话术' : '暂无个人快捷回复'}
</div>
) : (
<div className="flex flex-col gap-1">
{sidebarQuickList.map(item => (
<button
key={item.id}
type="button"
disabled={!canOperate}
title={canOperate ? '点击填入输入框' : '领取会话后可用'}
onClick={() => void applyQuickReply(item, 'panel')}
className="w-full text-left rounded-lg px-2.5 py-2 border-0 bg-neutral-50 hover:bg-blue-50 hover:ring-1 hover:ring-blue-200 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<div className="flex items-center gap-1.5 min-w-0">
<span className="text-[13px] font-medium text-neutral-800 truncate">{item.title}</span>
{item.shortcut && (
<code className="text-[10px] text-blue-600 bg-blue-50 px-1 rounded shrink-0">/{item.shortcut}</code>
)}
</div>
<div className="text-[11px] text-neutral-500 line-clamp-2 mt-0.5 leading-snug">{item.content}</div>
</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 || (() => {
const t = availableAgents.find(a => a.id === targetAgentID)
return Boolean(t && t.status !== 'online' && t.status !== 'busy')
})(),
}}
>
<p className="text-sm text-neutral-500 mb-3">请选择接手坐席(绿色在线 / 橙色忙碌)。</p>
{availableAgents.length === 0 ? (
<div className="text-sm text-neutral-400 py-6 text-center border border-dashed border-neutral-200 rounded-lg">
当前没有其他可转接坐席
</div>
) : (
<Select
className="w-full"
placeholder="选择坐席"
value={targetAgentID}
onChange={setTargetAgentID}
optionLabelProp="label"
options={availableAgents.map(agent => {
const st = agentStatusMeta(agent.status)
return {
value: agent.id,
// 选中后输入框展示:昵称 + 状态
label: `${agent.nickname}${st.text}`,
disabled: agent.status !== 'online' && agent.status !== 'busy',
}
})}
optionRender={option => {
const agent = availableAgents.find(a => a.id === option.value)
const st = agentStatusMeta(agent?.status || 'offline')
return (
<div className="flex items-center gap-2 py-0.5 min-w-0">
<span
className="inline-block w-2 h-2 rounded-full shrink-0"
style={{ background: st.dot }}
title={st.text}
/>
<span className="truncate text-neutral-800 flex-1 min-w-0">
{agent?.nickname || option.label}
</span>
<span
className="shrink-0 text-[11px] px-1.5 py-0 rounded font-medium"
style={{ background: st.bg, color: st.color }}
>
{st.text}
</span>
</div>
)
}}
/>
)}
</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={blacklistOpen}
onCancel={() => setBlacklistOpen(false)}
onOk={handleBlacklist}
okText="确认拉黑"
okButtonProps={{ danger: true, loading: blacklistSaving, disabled: !blacklistReason.trim() }}
cancelButtonProps={{ disabled: blacklistSaving }}
destroyOnClose
>
<div className="flex flex-col gap-3.5 pt-1">
<div>
<div className="text-sm text-neutral-600 mb-2">拉黑类型</div>
<Radio.Group
value={blacklistKind}
onChange={e => setBlacklistKind(e.target.value)}
optionType="button"
buttonStyle="solid"
options={[
{
value: 'ip',
label: selected?.visitor_ip ? `IP${selected.visitor_ip}` : 'IP(无)',
disabled: !selected?.visitor_ip,
},
{
value: 'device',
label: selected?.device_key || selected?.user_agent
? '设备'
: '设备(无)',
disabled: !selected?.device_key && !selected?.user_agent,
},
]}
/>
{blacklistKind === 'device' && (
<div className="mt-1.5 text-xs text-neutral-400 truncate" title={selected?.device_key || selected?.user_agent}>
{selected?.device_key
? `设备标识:${selected.device_key.slice(0, 12)}…`
: selected?.user_agent
? `将按浏览器指纹:${selected.user_agent.slice(0, 48)}…`
: ''}
</div>
)}
</div>
<div>
<div className="text-sm text-neutral-600 mb-2">释放时间</div>
<Select
className="w-full"
value={blacklistDuration}
onChange={v => setBlacklistDuration(v)}
options={[
{ value: '1h', label: '1 小时后自动解除' },
{ value: '6h', label: '6 小时后自动解除' },
{ value: '1d', label: '1 天后自动解除' },
{ value: '7d', label: '7 天后自动解除' },
{ value: '30d', label: '30 天后自动解除' },
{ value: 'permanent', label: '长期(不自动解除)' },
]}
/>
</div>
<div>
<div className="text-sm text-neutral-600 mb-2">拉黑原因 <span className="text-red-500">*</span></div>
<Input.TextArea
value={blacklistReason}
onChange={e => setBlacklistReason(e.target.value)}
placeholder="例如:恶意骚扰、发送垃圾信息…"
maxLength={200}
showCount
rows={3}
/>
</div>
<Checkbox
checked={blacklistEndSession}
onChange={e => setBlacklistEndSession(e.target.checked)}
disabled={selected?.status === 'ended' || selected?.status === 'archived'}
>
拉黑后同时结束当前会话
</Checkbox>
<div className="text-xs text-amber-700 bg-amber-50 border border-amber-100 rounded-md px-2.5 py-2 leading-relaxed">
拉黑后,该{blacklistKind === 'ip' ? ' IP ' : '设备'}再次访问在线客服将被拦截,直至到期或手动解除。
</div>
</div>
</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