支持访客实时输入草稿展示与联系方式自动识别
坐席输入框上方显示「对方正在输入」草稿;从草稿/消息提取手机微信邮箱QQ并追加入库,不覆盖已有联系方式。
This commit is contained in:
@@ -10,10 +10,10 @@ import { ChatImage } from '@/components/common/ImagePreview'
|
||||
import MarkdownBody, { stripMarkdown } from '@/components/common/MarkdownBody'
|
||||
import { useAuth } from '@/stores/auth'
|
||||
import {
|
||||
addSessionNote, claimSession, endSession, getAvailableAgents, getCustomers, getKnowledgeEntries,
|
||||
addSessionNote, claimSession, endSession, getAvailableAgents, getCustomer, getCustomers, getKnowledgeEntries,
|
||||
getQuickReplies, getSession, getSessionMessages, getSessions, markSessionRead, sendSessionMessage,
|
||||
suggestQuickReplies, transferSession, updateSessionPriority, uploadImage, useQuickReply,
|
||||
type AvailableAgent, type Customer, type KnowledgeEntry, type Message, type QuickReply,
|
||||
type AvailableAgent, type Customer, type CustomerContact, type KnowledgeEntry, type Message, type QuickReply,
|
||||
type Session, type SessionEvent, type VisitorPageView,
|
||||
} from '@/services/api'
|
||||
|
||||
@@ -182,6 +182,9 @@ const Dashboard = () => {
|
||||
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 [clockTick, setClockTick] = useState(0)
|
||||
const initialLoad = useRef(true)
|
||||
@@ -282,6 +285,10 @@ const Dashboard = () => {
|
||||
}
|
||||
: 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))
|
||||
}
|
||||
@@ -452,9 +459,39 @@ const Dashboard = () => {
|
||||
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 })
|
||||
}
|
||||
@@ -534,6 +571,8 @@ const Dashboard = () => {
|
||||
|
||||
useEffect(() => {
|
||||
setVisitorTyping(false)
|
||||
setVisitorDraft('')
|
||||
setCustomerContacts([])
|
||||
}, [selectedId])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -542,6 +581,12 @@ const Dashboard = () => {
|
||||
}
|
||||
}, [visitorTyping])
|
||||
|
||||
useEffect(() => {
|
||||
if (visitorDraft.trim()) {
|
||||
setTimeout(() => chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }), 40)
|
||||
}
|
||||
}, [visitorDraft])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedId) loadDetail(selectedId)
|
||||
}, [selectedId, loadDetail])
|
||||
@@ -647,6 +692,28 @@ const Dashboard = () => {
|
||||
}).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 filterActive = statusFilter !== 'all' || priorityFilter !== 'all'
|
||||
const filteredSessions = sessions
|
||||
.filter(session => {
|
||||
@@ -1229,6 +1296,31 @@ const Dashboard = () => {
|
||||
</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' ? '领取会话后即可回复' : '会话已结束,无法继续发送消息'}
|
||||
@@ -1411,6 +1503,33 @@ const Dashboard = () => {
|
||||
<span className="shrink-0 text-neutral-400 min-w-12">邮箱</span>
|
||||
<span className="truncate text-neutral-800">{selectedCustomer.email || '—'}</span>
|
||||
</div>
|
||||
{(() => {
|
||||
const kindLabel = (k: string) =>
|
||||
k === 'phone' ? '手机' : k === 'wechat' ? '微信' : k === 'email' ? '邮箱' : k === 'qq' ? 'QQ' : k
|
||||
// 主字段已展示的手机/邮箱不再重复;微信/QQ 等一律展示
|
||||
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
|
||||
return true
|
||||
})
|
||||
if (extra.length === 0) return null
|
||||
return (
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="shrink-0 text-neutral-400 min-w-12">更多</span>
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
{extra.map(c => (
|
||||
<span key={c.id || `${c.kind}-${c.value}`} className="text-neutral-800 text-xs break-all">
|
||||
<span className="text-neutral-400 mr-1">{kindLabel(c.kind)}</span>
|
||||
{c.value}
|
||||
{(c.source === 'draft' || c.source === 'message') && (
|
||||
<span className="ml-1 text-[10px] text-amber-600">自动识别</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="shrink-0 text-neutral-400 min-w-12">来源</span>
|
||||
<span className="truncate text-neutral-800">{selectedCustomer.source || '—'}</span>
|
||||
|
||||
+16
-1
@@ -18,6 +18,9 @@ export interface Session {
|
||||
current_url?: string
|
||||
current_title?: string
|
||||
last_seen_at?: string | null
|
||||
/** 访客输入框未发送草稿 */
|
||||
draft_text?: string
|
||||
draft_updated_at?: string | null
|
||||
created_at: string; ended_at: string | null
|
||||
/** 列表接口补全字段 */
|
||||
message_count?: number
|
||||
@@ -27,6 +30,16 @@ export interface Session {
|
||||
channel_type?: string
|
||||
}
|
||||
|
||||
export interface CustomerContact {
|
||||
id: number
|
||||
tenant_id?: number
|
||||
customer_id: number
|
||||
kind: string // phone | wechat | email | qq
|
||||
value: string
|
||||
source?: string
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export interface VisitorPageView {
|
||||
id: number
|
||||
session_id: number
|
||||
@@ -50,6 +63,7 @@ export interface AvailableAgent { id: number; nickname: string; status: string }
|
||||
export interface Customer {
|
||||
id: number; tenant_id: number; name: string; phone: string; email: string; tags: string
|
||||
source: string; status: string; conversation_count: number; last_contact_at: string
|
||||
contacts?: CustomerContact[]
|
||||
}
|
||||
|
||||
export interface KnowledgeCategory {
|
||||
@@ -303,7 +317,8 @@ export const exportSessionsCSV = (params?: {
|
||||
export const exportStatisticsCSV = () =>
|
||||
downloadFile('/statistics/export', `statistics_${Date.now()}.csv`)
|
||||
|
||||
export const getCustomer = (id: number) => get<{ customer: Customer; sessions: Session[] }>(`/customers/${id}`)
|
||||
export const getCustomer = (id: number) =>
|
||||
get<{ customer: Customer; sessions: Session[]; contacts?: CustomerContact[] }>(`/customers/${id}`)
|
||||
export const createCustomer = (data: Partial<Customer>) => post<Customer>('/customers', data)
|
||||
export const updateCustomer = (id: number, data: Partial<Customer>) => put<Customer>(`/customers/${id}`, data)
|
||||
export const deleteCustomer = (id: number) => del(`/customers/${id}`)
|
||||
|
||||
@@ -153,7 +153,7 @@ const VisitorChat = ({
|
||||
const insertEmoji = (emoji: string) => {
|
||||
const { next, cursor } = insertAtCursor(textInputRef.current, input, emoji)
|
||||
setInput(next)
|
||||
if (agentsOnline) emitTyping()
|
||||
emitInputDraft(next)
|
||||
requestAnimationFrame(() => {
|
||||
const el = textInputRef.current
|
||||
if (!el) return
|
||||
@@ -688,7 +688,23 @@ const VisitorChat = ({
|
||||
}
|
||||
}
|
||||
|
||||
/** 向坐席同步输入框草稿(节流);清空时立即发送 */
|
||||
const emitInputDraft = useCallback((text: string, force = false) => {
|
||||
if (!sessionId || sessionEnded) return
|
||||
if (socketRef.current?.readyState !== WebSocket.OPEN) return
|
||||
const now = Date.now()
|
||||
if (!force && now - lastTypingAt.current < 400) return
|
||||
lastTypingAt.current = now
|
||||
const clipped = text.length > 500 ? text.slice(0, 500) : text
|
||||
socketRef.current.send(JSON.stringify({
|
||||
type: 'input_draft',
|
||||
session_id: sessionId,
|
||||
text: clipped,
|
||||
}))
|
||||
}, [sessionId, sessionEnded])
|
||||
|
||||
const emitTyping = () => {
|
||||
// 兼容:无正文的旧 typing;正文走 emitInputDraft
|
||||
if (!sessionId || sessionEnded || !agentsOnline) return
|
||||
const now = Date.now()
|
||||
if (now - lastTypingAt.current < 1200 || socketRef.current?.readyState !== WebSocket.OPEN) return
|
||||
@@ -734,6 +750,7 @@ const VisitorChat = ({
|
||||
}
|
||||
const content = text.trim()
|
||||
setInput('')
|
||||
emitInputDraft('', true)
|
||||
setSendError('')
|
||||
setSending(true)
|
||||
|
||||
@@ -1161,7 +1178,11 @@ const VisitorChat = ({
|
||||
: '描述您的问题(留言)'
|
||||
}
|
||||
value={input}
|
||||
onChange={e => { setInput(e.target.value); if (agentsOnline) emitTyping() }}
|
||||
onChange={e => {
|
||||
const v = e.target.value
|
||||
setInput(v)
|
||||
emitInputDraft(v, v === '')
|
||||
}}
|
||||
onPaste={e => {
|
||||
if (!agentsOnline) return
|
||||
const item = Array.from(e.clipboardData.items).find(i => i.type.startsWith('image/'))
|
||||
|
||||
Reference in New Issue
Block a user