2291 lines
99 KiB
TypeScript
2291 lines
99 KiB
TypeScript
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
|