1316 lines
49 KiB
TypeScript
1316 lines
49 KiB
TypeScript
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||
import {
|
||
CloseOutlined, MessageOutlined, SendOutlined,
|
||
StarFilled, MinusOutlined, PictureOutlined, CustomerServiceOutlined,
|
||
} from '@ant-design/icons'
|
||
import EmojiPicker, { insertAtCursor } from '@/components/common/EmojiPicker'
|
||
import { ChatImage } from '@/components/common/ImagePreview'
|
||
import MarkdownBody from '@/components/common/MarkdownBody'
|
||
import SafeHtml, { looksLikeHtml } from '@/components/common/SafeHtml'
|
||
|
||
interface Message {
|
||
id: number
|
||
sender: 'visitor' | 'agent'
|
||
content: string
|
||
time: string
|
||
type?: 'text' | 'image'
|
||
/** 服务端单调序号,用于增量同步 */
|
||
seq?: number
|
||
}
|
||
|
||
const defaultQuickQuestions = ['查询订单状态', '退换货政策', '配送时效说明']
|
||
|
||
type WelcomeSegment = { type: 'text' | 'image'; content: string }
|
||
|
||
const defaultWelcomeSegments: WelcomeSegment[] = [
|
||
{ type: 'text', content: '您好!欢迎咨询,请问有什么可以帮您?' },
|
||
]
|
||
|
||
function parseWelcomeSegments(data: {
|
||
welcome_messages?: unknown
|
||
welcome_message?: string
|
||
}): WelcomeSegment[] {
|
||
if (Array.isArray(data.welcome_messages) && data.welcome_messages.length > 0) {
|
||
const segs: WelcomeSegment[] = []
|
||
for (const raw of data.welcome_messages) {
|
||
if (!raw || typeof raw !== 'object') continue
|
||
const item = raw as { type?: string; content?: string }
|
||
const content = typeof item.content === 'string' ? item.content.trim() : ''
|
||
if (!content) continue
|
||
if (item.type === 'image') segs.push({ type: 'image', content })
|
||
else segs.push({ type: 'text', content })
|
||
}
|
||
if (segs.length > 0) return segs
|
||
}
|
||
if (typeof data.welcome_message === 'string' && data.welcome_message.trim()) {
|
||
return [{ type: 'text', content: data.welcome_message.trim() }]
|
||
}
|
||
return defaultWelcomeSegments
|
||
}
|
||
|
||
type LayoutMode = 'floating' | 'fill'
|
||
|
||
export type HostPageInfo = { url?: string; title?: string; referrer?: string }
|
||
|
||
interface VisitorChatProps {
|
||
defaultOpen?: boolean
|
||
channelKey?: string
|
||
/** 是否在 iframe 嵌入模式(关闭/最小化会 postMessage 给宿主) */
|
||
embedded?: boolean
|
||
layout?: LayoutMode
|
||
/** 宿主页初始 URL(由 widget.js 经 query 传入) */
|
||
initialPage?: HostPageInfo
|
||
}
|
||
|
||
const VisitorChat = ({
|
||
defaultOpen = false,
|
||
channelKey = 'WK_8a3f2e',
|
||
embedded = false,
|
||
layout = 'floating',
|
||
initialPage,
|
||
}: VisitorChatProps) => {
|
||
const storageKey = useMemo(() => `kefu_widget_session_${channelKey}`, [channelKey])
|
||
const tokenKey = useMemo(() => `kefu_widget_visitor_token_${channelKey}`, [channelKey])
|
||
const msgsKey = useMemo(() => `${storageKey}_msgs`, [storageKey])
|
||
|
||
const [open, setOpen] = useState(defaultOpen || layout === 'fill')
|
||
const [sessionId, setSessionId] = useState<number | null>(() => {
|
||
const saved = localStorage.getItem(storageKey)
|
||
return saved ? Number(saved) : null
|
||
})
|
||
const [visitorToken, setVisitorToken] = useState<string>(() => localStorage.getItem(tokenKey) || '')
|
||
const [messages, setMessages] = useState<Message[]>(() => {
|
||
const saved = localStorage.getItem(msgsKey)
|
||
return saved ? JSON.parse(saved) : []
|
||
})
|
||
const [input, setInput] = useState('')
|
||
const [sending, setSending] = useState(false)
|
||
const [showRating, setShowRating] = useState(false)
|
||
const [rated, setRated] = useState(false)
|
||
const [sessionEnded, setSessionEnded] = useState(false)
|
||
const [agentTyping, setAgentTyping] = useState(false)
|
||
const [ratingScore, setRatingScore] = useState(0)
|
||
const [ratingText, setRatingText] = useState('')
|
||
const [hoverStar, setHoverStar] = useState(0)
|
||
const [ratingSubmitting, setRatingSubmitting] = useState(false)
|
||
|
||
/** 评价快捷文案:按星级展示更贴切的选项 */
|
||
const ratingPresets = useMemo(() => {
|
||
if (ratingScore >= 5) {
|
||
return ['服务很棒,非常感谢!', '回复及时,专业耐心', '问题已顺利解决', '下次还会咨询']
|
||
}
|
||
if (ratingScore === 4) {
|
||
return ['整体不错,体验良好', '基本解决了问题', '态度很好,继续加油', '回复比较及时']
|
||
}
|
||
if (ratingScore >= 1 && ratingScore <= 3) {
|
||
return ['等待时间偏长', '问题未完全解决', '希望回复更清晰一些', '需要再跟进一下']
|
||
}
|
||
return ['服务很好,感谢!', '回复及时,很专业', '问题已解决', '等待时间稍长']
|
||
}, [ratingScore])
|
||
const [pendingImage, setPendingImage] = useState<{ file: File; preview: string } | null>(null)
|
||
const [sendError, setSendError] = useState('')
|
||
const [agentsOnline, setAgentsOnline] = useState(true)
|
||
const [offlinePrompt, setOfflinePrompt] = useState('当前无客服在线,请留言并留下联系方式,我们上线后会尽快回复您。')
|
||
const [welcomeSegments, setWelcomeSegments] = useState<WelcomeSegment[]>(defaultWelcomeSegments)
|
||
const [displayName, setDisplayName] = useState('在线客服')
|
||
const [agentName, setAgentName] = useState('')
|
||
const [leaveName, setLeaveName] = useState('')
|
||
const [leavePhone, setLeavePhone] = useState('')
|
||
const [leaveEmail, setLeaveEmail] = useState('')
|
||
const [leaveSent, setLeaveSent] = useState(false)
|
||
const pollRef = useRef<number | null>(null)
|
||
const initRef = useRef(false)
|
||
const typingTimerRef = useRef<number | null>(null)
|
||
const chatEndRef = useRef<HTMLDivElement>(null)
|
||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||
const textInputRef = useRef<HTMLInputElement>(null)
|
||
const socketRef = useRef<WebSocket | null>(null)
|
||
const lastTypingAt = useRef(0)
|
||
const sessionIdRef = useRef<number | null>(sessionId)
|
||
const visitorTokenRef = useRef(visitorToken)
|
||
const agentNameRef = useRef(agentName)
|
||
const loadMessagesRef = useRef<(sid?: number, token?: string, opts?: { afterSeq?: number; full?: boolean }) => Promise<void>>(async () => {})
|
||
const msgsKeyRef = useRef(msgsKey)
|
||
/** 宿主页信息(嵌入时由 postMessage / 初始 query 更新) */
|
||
const hostPageRef = useRef<HostPageInfo>({
|
||
url: initialPage?.url || (typeof window !== 'undefined' ? window.location.href : ''),
|
||
title: initialPage?.title || (typeof document !== 'undefined' ? document.title : ''),
|
||
referrer: initialPage?.referrer || (typeof document !== 'undefined' ? document.referrer : ''),
|
||
})
|
||
const lastPageKeyRef = useRef('') // url + title,避免同 URL 标题更新被吞
|
||
/** 本地已同步到的最大 seq(从缓存消息初始化) */
|
||
const lastSeqRef = useRef((() => {
|
||
try {
|
||
const saved = localStorage.getItem(msgsKey)
|
||
if (!saved) return 0
|
||
const cached = JSON.parse(saved) as Message[]
|
||
return cached.reduce((acc, m) => Math.max(acc, m.seq || 0), 0)
|
||
} catch {
|
||
return 0
|
||
}
|
||
})())
|
||
|
||
const insertEmoji = (emoji: string) => {
|
||
const { next, cursor } = insertAtCursor(textInputRef.current, input, emoji)
|
||
setInput(next)
|
||
if (agentsOnline) emitTyping()
|
||
requestAnimationFrame(() => {
|
||
const el = textInputRef.current
|
||
if (!el) return
|
||
el.focus()
|
||
el.setSelectionRange(cursor, cursor)
|
||
})
|
||
}
|
||
|
||
const mapServerMessage = (m: {
|
||
id: number
|
||
sender_type?: string
|
||
content?: string
|
||
type?: string
|
||
sent_at?: string
|
||
seq?: number
|
||
}): Message => ({
|
||
id: Number(m.id),
|
||
sender: m.sender_type === 'agent' ? 'agent' : 'visitor',
|
||
content: String(m.content ?? ''),
|
||
type: m.type === 'image' ? 'image' : 'text',
|
||
seq: typeof m.seq === 'number' ? m.seq : Number(m.seq || 0) || undefined,
|
||
time: m.sent_at
|
||
? new Date(m.sent_at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||
: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
|
||
})
|
||
|
||
const rememberSeq = (seq?: number) => {
|
||
if (seq && seq > lastSeqRef.current) lastSeqRef.current = seq
|
||
}
|
||
|
||
const mergeVisitorMessages = (existing: Message[], incoming: Message[]): Message[] => {
|
||
const map = new Map<number, Message>()
|
||
for (const item of existing) {
|
||
if (item.id > 0) map.set(item.id, item)
|
||
}
|
||
// 保留未确认的乐观气泡
|
||
const temps = existing.filter(item => item.id < 0)
|
||
for (const item of incoming) map.set(item.id, item)
|
||
const merged = Array.from(map.values())
|
||
// 去掉已被服务端确认的乐观气泡
|
||
const remainingTemps = temps.filter(temp =>
|
||
!incoming.some(srv =>
|
||
srv.sender === 'visitor'
|
||
&& srv.content === temp.content
|
||
&& (srv.type || 'text') === (temp.type || 'text'),
|
||
),
|
||
)
|
||
return [...merged, ...remainingTemps].sort((a, b) => {
|
||
const as = a.seq || 0
|
||
const bs = b.seq || 0
|
||
if (as && bs && as !== bs) return as - bs
|
||
if (a.id > 0 && b.id > 0) return a.id - b.id
|
||
return a.id - b.id
|
||
})
|
||
}
|
||
|
||
const applySessionStatus = useCallback((status?: string, satisfactionScore?: number | null) => {
|
||
if (!status) return
|
||
if (status === 'ended' || status === 'archived') {
|
||
setSessionEnded(true)
|
||
const alreadyRated = satisfactionScore != null && Number(satisfactionScore) > 0
|
||
if (alreadyRated) {
|
||
setRated(true)
|
||
setShowRating(false)
|
||
} else {
|
||
setRated(false)
|
||
setShowRating(true)
|
||
}
|
||
} else if (status === 'active') {
|
||
setAgentsOnline(true)
|
||
}
|
||
}, [])
|
||
|
||
const loadMessages = useCallback(async (
|
||
sid?: number,
|
||
token?: string,
|
||
opts?: { afterSeq?: number; full?: boolean },
|
||
) => {
|
||
const s = sid || sessionIdRef.current || sessionId
|
||
const visitorCredential = token || visitorTokenRef.current || visitorToken || localStorage.getItem(tokenKey) || ''
|
||
if (!s || !visitorCredential) return
|
||
const full = opts?.full === true || (opts?.afterSeq == null && lastSeqRef.current <= 0)
|
||
const afterSeq = full ? 0 : (opts?.afterSeq ?? lastSeqRef.current)
|
||
try {
|
||
const qs = new URLSearchParams({ session_id: String(s) })
|
||
if (afterSeq > 0) qs.set('after_seq', String(afterSeq))
|
||
const res = await fetch(`/api/widget/messages?${qs}`, {
|
||
headers: { 'X-Visitor-Token': visitorCredential },
|
||
})
|
||
const json = await res.json()
|
||
if (json.code !== 0 || !json.data) return
|
||
|
||
// 兼容旧版 data 为数组的响应
|
||
const payload = json.data
|
||
const list: any[] = Array.isArray(payload)
|
||
? payload
|
||
: (Array.isArray(payload.messages) ? payload.messages : [])
|
||
const maxSeq = Array.isArray(payload)
|
||
? list.reduce((acc, m) => Math.max(acc, Number(m.seq || 0)), 0)
|
||
: Number(payload.max_seq || 0)
|
||
const hasMore = !Array.isArray(payload) && Boolean(payload.has_more)
|
||
|
||
if (!Array.isArray(payload)) {
|
||
applySessionStatus(payload.session_status, payload.satisfaction_score)
|
||
}
|
||
|
||
if (list.length === 0) {
|
||
if (maxSeq > 0) rememberSeq(maxSeq)
|
||
return
|
||
}
|
||
|
||
const mapped = list.map((m: any) => mapServerMessage(m))
|
||
setMessages(prev => {
|
||
const next = afterSeq > 0 ? mergeVisitorMessages(prev, mapped) : mapped
|
||
localStorage.setItem(msgsKeyRef.current, JSON.stringify(next.filter(m => m.id > 0)))
|
||
return next
|
||
})
|
||
const localMax = mapped.reduce((acc, m) => Math.max(acc, m.seq || 0), 0)
|
||
rememberSeq(Math.max(maxSeq, localMax))
|
||
|
||
// 一次未拉完则继续增量
|
||
if (hasMore && localMax > afterSeq) {
|
||
await loadMessages(s, visitorCredential, { afterSeq: localMax })
|
||
}
|
||
} catch { /* ignore */ }
|
||
}, [sessionId, visitorToken, tokenKey, applySessionStatus])
|
||
|
||
const appendPushedMessage = useCallback((raw: {
|
||
id?: number
|
||
sender_type?: string
|
||
content?: string
|
||
type?: string
|
||
sent_at?: string
|
||
seq?: number
|
||
}) => {
|
||
if (!raw?.id) return false
|
||
const msg = mapServerMessage(raw as {
|
||
id: number
|
||
sender_type?: string
|
||
content?: string
|
||
type?: string
|
||
sent_at?: string
|
||
seq?: number
|
||
})
|
||
const known = lastSeqRef.current
|
||
// seq 空洞 → 走增量拉取补齐
|
||
if (known > 0 && msg.seq && msg.seq > known + 1) {
|
||
void loadMessagesRef.current(undefined, undefined, { afterSeq: known })
|
||
return true
|
||
}
|
||
setMessages(prev => {
|
||
if (prev.some(item => item.id === msg.id)) return prev
|
||
const next = mergeVisitorMessages(prev, [msg])
|
||
localStorage.setItem(msgsKeyRef.current, JSON.stringify(next.filter(m => m.id > 0)))
|
||
return next
|
||
})
|
||
if (msg.seq) rememberSeq(msg.seq)
|
||
return true
|
||
}, [])
|
||
|
||
const reportPageView = useCallback(async (url: string, title: string) => {
|
||
const sid = sessionIdRef.current
|
||
const token = visitorTokenRef.current
|
||
if (!url) return
|
||
// 会话尚未 Init 完成:只缓存宿主页,避免丢掉打开前/初始化中的换页
|
||
if (!sid || !token) {
|
||
hostPageRef.current = {
|
||
...hostPageRef.current,
|
||
url,
|
||
title: title || hostPageRef.current.title,
|
||
}
|
||
return
|
||
}
|
||
if (sessionEnded) return
|
||
const key = `${url}\0${title || ''}`
|
||
// 同 URL 但标题变了仍要上报(服务端会更新 current_title;新 URL 会插轨迹)
|
||
if (key === lastPageKeyRef.current) return
|
||
lastPageKeyRef.current = key
|
||
try {
|
||
const res = await fetch('/api/widget/pageview', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-Visitor-Token': token,
|
||
},
|
||
body: JSON.stringify({
|
||
session_id: sid,
|
||
visitor_token: token,
|
||
page_url: url,
|
||
page_title: title || '',
|
||
}),
|
||
})
|
||
if (!res.ok) {
|
||
lastPageKeyRef.current = ''
|
||
}
|
||
} catch {
|
||
lastPageKeyRef.current = ''
|
||
}
|
||
}, [sessionEnded])
|
||
|
||
/** 调用 Init 创建新会话并写入本地状态(不复用已结束会话) */
|
||
const bootstrapNewSession = useCallback(async () => {
|
||
const page = hostPageRef.current
|
||
const res = await fetch('/api/widget/init', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
channel_key: channelKey,
|
||
visitor_name: '访客',
|
||
page_url: page.url || '',
|
||
page_title: page.title || '',
|
||
referrer: page.referrer || '',
|
||
}),
|
||
})
|
||
const json = await res.json()
|
||
if (json.code !== 0) {
|
||
throw new Error(json.message || '初始化会话失败')
|
||
}
|
||
const data = json.data
|
||
const sid = data.session_id as number
|
||
const token = data.visitor_token as string
|
||
setSessionId(sid)
|
||
setVisitorToken(token)
|
||
sessionIdRef.current = sid
|
||
visitorTokenRef.current = token
|
||
setAgentsOnline(Boolean(data.agents_online))
|
||
if (data.offline_prompt) setOfflinePrompt(data.offline_prompt)
|
||
setWelcomeSegments(parseWelcomeSegments(data))
|
||
if (data.display_name) setDisplayName(data.display_name)
|
||
if (data.agent_name) setAgentName(data.agent_name)
|
||
else if (data.agent_nickname) setAgentName(data.agent_nickname)
|
||
else setAgentName('')
|
||
setSessionEnded(false)
|
||
setRated(false)
|
||
setShowRating(false)
|
||
setLeaveSent(false)
|
||
setMessages([])
|
||
localStorage.setItem(storageKey, String(sid))
|
||
localStorage.setItem(tokenKey, token)
|
||
localStorage.removeItem(msgsKey)
|
||
lastSeqRef.current = 0
|
||
// init 已写入落地页;标记已上报,避免立刻重复;宿主若已换页则补报
|
||
const latest = hostPageRef.current
|
||
if (page.url) lastPageKeyRef.current = `${page.url}\0${page.title || ''}`
|
||
await loadMessages(sid, token, { full: true })
|
||
if (latest.url && (latest.url !== page.url || (latest.title || '') !== (page.title || ''))) {
|
||
lastPageKeyRef.current = ''
|
||
void reportPageView(latest.url, latest.title || '')
|
||
}
|
||
}, [channelKey, loadMessages, storageKey, tokenKey, msgsKey, reportPageView])
|
||
|
||
const closeSocket = useCallback(() => {
|
||
const sock = socketRef.current
|
||
if (!sock) return
|
||
try {
|
||
sock.onclose = null
|
||
sock.close()
|
||
} catch { /* ignore */ }
|
||
socketRef.current = null
|
||
}, [])
|
||
|
||
/** 会话结束后重新咨询:清本地凭证并 Init 新会话 */
|
||
const startNewSession = useCallback(async () => {
|
||
if (sending) return
|
||
setSending(true)
|
||
setSendError('')
|
||
setShowRating(false)
|
||
closeSocket()
|
||
localStorage.removeItem(storageKey)
|
||
localStorage.removeItem(tokenKey)
|
||
localStorage.removeItem(msgsKey)
|
||
setSessionId(null)
|
||
setVisitorToken('')
|
||
sessionIdRef.current = null
|
||
visitorTokenRef.current = ''
|
||
setMessages([])
|
||
setInput('')
|
||
setSessionEnded(false)
|
||
setRated(false)
|
||
setRatingScore(0)
|
||
setRatingText('')
|
||
setHoverStar(0)
|
||
setAgentTyping(false)
|
||
setAgentName('')
|
||
setLeaveSent(false)
|
||
setPendingImage(prev => {
|
||
if (prev?.preview) URL.revokeObjectURL(prev.preview)
|
||
return null
|
||
})
|
||
lastSeqRef.current = 0
|
||
initRef.current = true
|
||
try {
|
||
await bootstrapNewSession()
|
||
} catch (e) {
|
||
console.error('Start new session failed:', e)
|
||
setSendError(e instanceof Error ? e.message : '发起新咨询失败,请稍后重试')
|
||
initRef.current = false
|
||
} finally {
|
||
setSending(false)
|
||
}
|
||
}, [sending, closeSocket, storageKey, tokenKey, msgsKey, bootstrapNewSession])
|
||
|
||
const initSession = useCallback(async () => {
|
||
if (sessionId && visitorToken) {
|
||
// 本地有 seq 游标时增量补齐,否则全量(若已结束会由 loadMessages 带回状态)
|
||
const after = lastSeqRef.current
|
||
await loadMessages(sessionId, visitorToken, after > 0 ? { afterSeq: after } : { full: true })
|
||
return
|
||
}
|
||
if (initRef.current) return
|
||
initRef.current = true
|
||
try {
|
||
await bootstrapNewSession()
|
||
} catch (e) {
|
||
console.error('Init failed:', e)
|
||
setSendError(e instanceof Error ? e.message : '连接客服失败,请稍后重试')
|
||
initRef.current = false
|
||
}
|
||
}, [sessionId, visitorToken, loadMessages, bootstrapNewSession])
|
||
|
||
useEffect(() => {
|
||
sessionIdRef.current = sessionId
|
||
visitorTokenRef.current = visitorToken
|
||
agentNameRef.current = agentName
|
||
loadMessagesRef.current = loadMessages
|
||
msgsKeyRef.current = msgsKey
|
||
}, [sessionId, visitorToken, agentName, loadMessages, msgsKey])
|
||
|
||
useEffect(() => {
|
||
if (open) initSession()
|
||
}, [open, initSession])
|
||
|
||
// 嵌入模式:通知宿主就绪,并接收宿主页 URL 变更
|
||
useEffect(() => {
|
||
if (!embedded) return
|
||
try {
|
||
window.parent.postMessage({ type: 'kefu-widget-ready' }, '*')
|
||
} catch { /* ignore */ }
|
||
const onMsg = (event: MessageEvent) => {
|
||
const data = event?.data
|
||
if (!data || data.type !== 'kefu-host-page') return
|
||
const url = typeof data.url === 'string' ? data.url : ''
|
||
const title = typeof data.title === 'string' ? data.title : ''
|
||
const referrer = typeof data.referrer === 'string' ? data.referrer : hostPageRef.current.referrer
|
||
if (!url) return
|
||
hostPageRef.current = { url, title, referrer }
|
||
void reportPageView(url, title)
|
||
}
|
||
window.addEventListener('message', onMsg)
|
||
return () => window.removeEventListener('message', onMsg)
|
||
}, [embedded, reportPageView])
|
||
|
||
// 非嵌入预览:用当前页作为来源
|
||
useEffect(() => {
|
||
if (embedded || !sessionId || !visitorToken || sessionEnded) return
|
||
const url = window.location.href
|
||
const title = document.title
|
||
hostPageRef.current = {
|
||
url,
|
||
title,
|
||
referrer: document.referrer || hostPageRef.current.referrer,
|
||
}
|
||
void reportPageView(url, title)
|
||
}, [embedded, sessionId, visitorToken, sessionEnded, reportPageView])
|
||
|
||
// 心跳:刷新 last_seen_at,供客服端在线读秒
|
||
useEffect(() => {
|
||
if (!sessionId || !visitorToken || sessionEnded || !open) return
|
||
const beat = () => {
|
||
void fetch('/api/widget/heartbeat', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-Visitor-Token': visitorToken,
|
||
},
|
||
body: JSON.stringify({ session_id: sessionId, visitor_token: visitorToken }),
|
||
}).catch(() => {})
|
||
}
|
||
beat()
|
||
const t = window.setInterval(beat, 20000)
|
||
return () => clearInterval(t)
|
||
}, [sessionId, visitorToken, sessionEnded, open])
|
||
|
||
// 兜底轮询:按 after_seq 增量对齐
|
||
useEffect(() => {
|
||
if (sessionId && visitorToken && open) {
|
||
pollRef.current = window.setInterval(() => {
|
||
const after = lastSeqRef.current
|
||
void loadMessages(undefined, undefined, after > 0 ? { afterSeq: after } : { full: true })
|
||
}, 8000)
|
||
return () => { if (pollRef.current) clearInterval(pollRef.current) }
|
||
}
|
||
}, [sessionId, visitorToken, open, loadMessages])
|
||
|
||
// 访客 WS:session / token 就绪后建连;断线指数退避重连;消息直接追加
|
||
useEffect(() => {
|
||
if (!sessionId || !visitorToken || !open) 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 sid = sessionIdRef.current
|
||
const token = visitorTokenRef.current
|
||
if (!sid || !token) return
|
||
|
||
const scheme = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||
const socket = new WebSocket(
|
||
`${scheme}//${window.location.host}/api/widget/ws?session_id=${sid}`,
|
||
['kefu-visitor-v1', token],
|
||
)
|
||
socketRef.current = socket
|
||
|
||
socket.onopen = () => {
|
||
attempt = 0
|
||
// 重连后按 seq 增量 catch-up
|
||
const after = lastSeqRef.current
|
||
void loadMessagesRef.current(sid, token, after > 0 ? { afterSeq: after } : { full: true })
|
||
}
|
||
|
||
socket.onmessage = (event) => {
|
||
try {
|
||
const payload = JSON.parse(event.data) as {
|
||
type?: string
|
||
session_id?: number | string
|
||
data?: {
|
||
id?: number
|
||
sender_type?: string
|
||
content?: string
|
||
type?: string
|
||
sent_at?: string
|
||
from?: string
|
||
status?: string
|
||
agent_name?: string
|
||
}
|
||
}
|
||
const eventSid = Number(payload.session_id)
|
||
const currentSid = Number(sessionIdRef.current)
|
||
if (!Number.isFinite(eventSid) || eventSid !== currentSid) return
|
||
|
||
if (payload.type === 'typing') {
|
||
if (payload.data?.from && payload.data.from !== 'agent') return
|
||
setAgentTyping(true)
|
||
if (typingTimerRef.current) clearTimeout(typingTimerRef.current)
|
||
typingTimerRef.current = window.setTimeout(() => setAgentTyping(false), 1800)
|
||
return
|
||
}
|
||
|
||
if (payload.type === 'session_updated') {
|
||
const status = payload.data?.status
|
||
if (status === 'ended' || status === 'archived') {
|
||
setSessionEnded(true)
|
||
setShowRating(true)
|
||
setAgentTyping(false)
|
||
return
|
||
}
|
||
if (status === 'active') {
|
||
setAgentsOnline(true)
|
||
setAgentName(payload.data?.agent_name || agentNameRef.current)
|
||
}
|
||
setAgentTyping(false)
|
||
const after = lastSeqRef.current
|
||
void loadMessagesRef.current(
|
||
currentSid,
|
||
visitorTokenRef.current,
|
||
after > 0 ? { afterSeq: after } : { full: true },
|
||
)
|
||
return
|
||
}
|
||
|
||
if (payload.type === 'message') {
|
||
setAgentTyping(false)
|
||
const ok = appendPushedMessage(payload.data || {})
|
||
if (!ok) {
|
||
const after = lastSeqRef.current
|
||
void loadMessagesRef.current(
|
||
currentSid,
|
||
visitorTokenRef.current,
|
||
after > 0 ? { afterSeq: after } : { full: true },
|
||
)
|
||
}
|
||
}
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
|
||
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 = () => {
|
||
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 (typingTimerRef.current) clearTimeout(typingTimerRef.current)
|
||
}
|
||
}, [sessionId, visitorToken, open, appendPushedMessage])
|
||
|
||
useEffect(() => {
|
||
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||
}, [messages, agentTyping, open, pendingImage])
|
||
|
||
const notifyHost = (type: 'kefu-widget-close' | 'kefu-widget-minimize') => {
|
||
if (embedded && window.parent && window.parent !== window) {
|
||
window.parent.postMessage({ type }, '*')
|
||
}
|
||
}
|
||
|
||
const emitTyping = () => {
|
||
if (!sessionId || sessionEnded || !agentsOnline) 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: sessionId }))
|
||
}
|
||
|
||
const postMessage = async (content: string, type: 'text' | 'image') => {
|
||
if (!sessionId || !visitorToken) throw new Error('会话未就绪')
|
||
const res = await fetch('/api/widget/message', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'X-Visitor-Token': visitorToken },
|
||
body: JSON.stringify({ session_id: sessionId, content, type }),
|
||
})
|
||
const json = await res.json()
|
||
if (json.code !== 0) {
|
||
throw new Error(json.message || '发送失败')
|
||
}
|
||
return json.data
|
||
}
|
||
|
||
const uploadVisitorImage = async (file: File) => {
|
||
if (!sessionId || !visitorToken) throw new Error('会话未就绪')
|
||
const form = new FormData()
|
||
form.append('file', file)
|
||
form.append('session_id', String(sessionId))
|
||
form.append('visitor_token', visitorToken)
|
||
const res = await fetch('/api/widget/upload', {
|
||
method: 'POST',
|
||
headers: { 'X-Visitor-Token': visitorToken },
|
||
body: form,
|
||
})
|
||
const json = await res.json()
|
||
if (json.code !== 0) throw new Error(json.message || '上传失败')
|
||
return json.data as { url: string; thumb_url: string }
|
||
}
|
||
|
||
const sendMessage = async (text: string) => {
|
||
if (!text.trim() || sending || sessionEnded) return
|
||
if (!agentsOnline) {
|
||
setSendError('当前无客服在线,请使用下方留言表单')
|
||
return
|
||
}
|
||
const content = text.trim()
|
||
setInput('')
|
||
setSendError('')
|
||
setSending(true)
|
||
|
||
const localMsg: Message = {
|
||
id: -Date.now(),
|
||
sender: 'visitor',
|
||
content,
|
||
type: 'text',
|
||
time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
|
||
}
|
||
setMessages(prev => [...prev, localMsg])
|
||
|
||
try {
|
||
await postMessage(content, 'text')
|
||
await loadMessages(sessionId || undefined)
|
||
} catch (e) {
|
||
setSendError(e instanceof Error ? e.message : '发送失败')
|
||
setMessages(prev => prev.filter(m => m.id !== localMsg.id))
|
||
} finally {
|
||
setSending(false)
|
||
}
|
||
}
|
||
|
||
const submitLeaveMessage = async () => {
|
||
if (!sessionId || !visitorToken || sending || leaveSent) return
|
||
const content = input.trim() || '请尽快与我联系,谢谢。'
|
||
if (!leavePhone.trim() && !leaveEmail.trim()) {
|
||
setSendError('请至少填写手机号或邮箱')
|
||
return
|
||
}
|
||
setSending(true)
|
||
setSendError('')
|
||
try {
|
||
const res = await fetch('/api/widget/leave-message', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'X-Visitor-Token': visitorToken },
|
||
body: JSON.stringify({
|
||
session_id: sessionId,
|
||
content,
|
||
name: leaveName.trim(),
|
||
phone: leavePhone.trim(),
|
||
email: leaveEmail.trim(),
|
||
}),
|
||
})
|
||
const json = await res.json()
|
||
if (json.code !== 0) throw new Error(json.message || '留言失败')
|
||
setLeaveSent(true)
|
||
setInput('')
|
||
if (json.data?.agents_online) setAgentsOnline(true)
|
||
await loadMessages(sessionId, visitorToken)
|
||
} catch (e) {
|
||
setSendError(e instanceof Error ? e.message : '留言失败')
|
||
} finally {
|
||
setSending(false)
|
||
}
|
||
}
|
||
|
||
const handleImageFile = (file?: File | null) => {
|
||
if (!file || sessionEnded || !sessionId || !agentsOnline) return
|
||
const okTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
|
||
if (!okTypes.includes(file.type)) {
|
||
setSendError('仅支持 jpg、png、gif、webp 图片')
|
||
return
|
||
}
|
||
if (file.size > 10 * 1024 * 1024) {
|
||
setSendError('图片不能超过 10 MB')
|
||
return
|
||
}
|
||
setSendError('')
|
||
if (pendingImage?.preview) URL.revokeObjectURL(pendingImage.preview)
|
||
setPendingImage({ file, preview: URL.createObjectURL(file) })
|
||
}
|
||
|
||
const sendImage = async () => {
|
||
if (!pendingImage || sending || sessionEnded || !agentsOnline) return
|
||
setSending(true)
|
||
setSendError('')
|
||
try {
|
||
const uploaded = await uploadVisitorImage(pendingImage.file)
|
||
const localMsg: Message = {
|
||
id: -Date.now(),
|
||
sender: 'visitor',
|
||
content: uploaded.url,
|
||
type: 'image',
|
||
time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
|
||
}
|
||
setMessages(prev => [...prev, localMsg])
|
||
URL.revokeObjectURL(pendingImage.preview)
|
||
setPendingImage(null)
|
||
await postMessage(uploaded.url, 'image')
|
||
await loadMessages(sessionId || undefined)
|
||
} catch (e) {
|
||
setSendError(e instanceof Error ? e.message : '图片发送失败')
|
||
} finally {
|
||
setSending(false)
|
||
}
|
||
}
|
||
|
||
const handleOpen = () => {
|
||
setOpen(true)
|
||
setShowRating(sessionEnded && !rated)
|
||
}
|
||
|
||
const handleClose = () => {
|
||
setOpen(false)
|
||
notifyHost('kefu-widget-close')
|
||
if (sessionEnded && !rated) setShowRating(true)
|
||
}
|
||
|
||
const handleMinimize = () => {
|
||
setOpen(false)
|
||
notifyHost('kefu-widget-minimize')
|
||
}
|
||
|
||
const submitRating = async () => {
|
||
if (!sessionId || !visitorToken || rated || ratingSubmitting) return
|
||
if (ratingScore < 1 || ratingScore > 5) {
|
||
setSendError('请先点击星星选择评分')
|
||
return
|
||
}
|
||
setRatingSubmitting(true)
|
||
setSendError('')
|
||
try {
|
||
const res = await fetch('/api/widget/rating', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'X-Visitor-Token': visitorToken },
|
||
body: JSON.stringify({
|
||
session_id: sessionId,
|
||
score: ratingScore,
|
||
text: ratingText.trim(),
|
||
}),
|
||
})
|
||
const json = await res.json()
|
||
if (json.code === 0) {
|
||
setRated(true)
|
||
setShowRating(false)
|
||
setRatingScore(0)
|
||
setRatingText('')
|
||
setSendError('')
|
||
} else {
|
||
setSendError(json.message || '评价提交失败')
|
||
}
|
||
} catch {
|
||
setSendError('评价提交失败,请稍后重试')
|
||
} finally {
|
||
setRatingSubmitting(false)
|
||
}
|
||
}
|
||
|
||
const showWelcome = messages.length === 0 && !leaveSent
|
||
const showQuick = messages.length <= 1 && !sessionEnded && agentsOnline
|
||
const isFill = layout === 'fill'
|
||
const shellClass = isFill
|
||
? 'relative w-full h-full flex flex-col bg-white overflow-hidden'
|
||
: 'fixed z-50 right-6 bottom-6 w-[400px] h-[600px] max-w-[calc(100vw-32px)] max-h-[calc(100vh-32px)] flex flex-col rounded-xl overflow-hidden bg-white'
|
||
|
||
const panel = open && (
|
||
<div className={shellClass} style={isFill ? undefined : { boxShadow: 'var(--shadow-floating)' }}>
|
||
<header className="shrink-0 px-4 py-4 bg-[#2563eb] text-white flex items-center gap-3">
|
||
<div className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center shrink-0">
|
||
<CustomerServiceOutlined className="text-lg text-white" />
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<h1 className="m-0 text-[15px] font-semibold leading-tight text-white">{displayName || '在线客服'}</h1>
|
||
<p className="m-0 text-xs leading-normal text-white/80 flex items-center gap-1 mt-0.5">
|
||
<span
|
||
className="inline-block w-1.5 h-1.5 rounded-full"
|
||
style={{ background: sessionEnded ? '#94a3b8' : agentsOnline ? '#16a34a' : '#d97706' }}
|
||
/>
|
||
{sessionEnded
|
||
? '会话已结束'
|
||
: agentsOnline
|
||
? (agentName ? `${agentName} 为您服务` : '正在为您服务')
|
||
: '暂不可即时接待 · 可留言'}
|
||
</p>
|
||
</div>
|
||
<div className="flex items-center gap-1 shrink-0">
|
||
{!isFill && (
|
||
<button
|
||
type="button"
|
||
onClick={handleMinimize}
|
||
className="w-7 h-7 rounded bg-white/15 hover:bg-white/25 border-0 cursor-pointer flex items-center justify-center text-white"
|
||
aria-label="最小化"
|
||
>
|
||
<MinusOutlined className="text-xs" />
|
||
</button>
|
||
)}
|
||
<button
|
||
type="button"
|
||
onClick={handleClose}
|
||
className="w-7 h-7 rounded bg-white/15 hover:bg-white/25 border-0 cursor-pointer flex items-center justify-center text-white"
|
||
aria-label="关闭"
|
||
>
|
||
<CloseOutlined className="text-xs" />
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<section className="flex-1 overflow-y-auto p-4 flex flex-col gap-3 bg-neutral-50 no-scrollbar">
|
||
{showWelcome && (
|
||
<div className="flex flex-col items-start gap-2 max-w-[90%]">
|
||
{agentsOnline ? (
|
||
welcomeSegments.map((seg, i) =>
|
||
seg.type === 'image' ? (
|
||
<div
|
||
key={`w-${i}`}
|
||
className="rounded-xl bg-white border border-neutral-200 p-1.5"
|
||
>
|
||
<ChatImage
|
||
src={seg.content}
|
||
alt="欢迎图"
|
||
className="max-w-[220px] max-h-[220px]"
|
||
/>
|
||
</div>
|
||
) : (
|
||
<div
|
||
key={`w-${i}`}
|
||
className="px-4 py-2.5 rounded-xl bg-white border border-neutral-200 text-[13px] text-neutral-700 leading-normal"
|
||
>
|
||
{looksLikeHtml(seg.content) ? (
|
||
<SafeHtml html={seg.content} />
|
||
) : (
|
||
<MarkdownBody>{seg.content}</MarkdownBody>
|
||
)}
|
||
</div>
|
||
),
|
||
)
|
||
) : (
|
||
<div className="px-4 py-2.5 rounded-xl bg-white border border-neutral-200 text-[13px] text-neutral-600 leading-normal">
|
||
{looksLikeHtml(offlinePrompt) ? (
|
||
<SafeHtml html={offlinePrompt} />
|
||
) : (
|
||
<MarkdownBody>{offlinePrompt}</MarkdownBody>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{showQuick && (
|
||
<div className="flex flex-wrap gap-2 justify-center">
|
||
{defaultQuickQuestions.map((q, i) => (
|
||
<button
|
||
key={i}
|
||
type="button"
|
||
onClick={() => sendMessage(q)}
|
||
disabled={!sessionId || sending || sessionEnded}
|
||
className="px-3 py-1 rounded-full border border-[#2563eb] bg-white text-[#2563eb] text-xs cursor-pointer whitespace-nowrap hover:bg-[#eff6ff] disabled:opacity-50"
|
||
>
|
||
{q}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{messages.map(msg => (
|
||
msg.sender === 'visitor' ? (
|
||
<div key={msg.id} className="flex flex-col items-end max-w-[85%] ml-auto gap-1">
|
||
<div className={`rounded-xl bg-[#2563eb] text-white text-[13px] leading-normal ${msg.type === 'image' ? 'p-1.5' : 'px-4 py-3'}`}>
|
||
{msg.type === 'image' ? (
|
||
<ChatImage src={msg.content} alt="图片" className="max-w-[180px] max-h-[180px]" />
|
||
) : (
|
||
<MarkdownBody tone="inverse">{msg.content}</MarkdownBody>
|
||
)}
|
||
</div>
|
||
{msg.time && (
|
||
<span className="text-[11px] text-neutral-400 pr-0.5">{msg.time}</span>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div key={msg.id} className="flex gap-2 max-w-[85%]">
|
||
<div className="w-8 h-8 rounded-full bg-neutral-200 flex items-center justify-center shrink-0 mt-0.5">
|
||
<CustomerServiceOutlined className="text-xs text-neutral-500" />
|
||
</div>
|
||
<div className="min-w-0 flex flex-col gap-1">
|
||
<div className={`rounded-xl bg-white border border-neutral-200 text-[13px] text-neutral-800 leading-normal ${msg.type === 'image' ? 'p-1.5' : 'px-4 py-3'}`}>
|
||
{msg.type === 'image' ? (
|
||
<ChatImage src={msg.content} alt="图片" className="max-w-[180px] max-h-[180px]" />
|
||
) : (
|
||
<MarkdownBody>{msg.content}</MarkdownBody>
|
||
)}
|
||
</div>
|
||
{msg.time && (
|
||
<span className="text-[11px] text-neutral-400 pl-0.5">{msg.time}</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
))}
|
||
|
||
{agentTyping && (
|
||
<>
|
||
<div className="flex gap-2 max-w-[85%]">
|
||
<div className="w-8 h-8 rounded-full bg-neutral-200 flex items-center justify-center shrink-0 mt-0.5">
|
||
<CustomerServiceOutlined className="text-xs text-neutral-500" />
|
||
</div>
|
||
<div className="px-4 py-3 rounded-xl bg-white border border-neutral-200 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>
|
||
<p className="text-left text-xs text-neutral-400 m-0 pl-[42px]">客服正在输入...</p>
|
||
</>
|
||
)}
|
||
<div ref={chatEndRef} />
|
||
</section>
|
||
|
||
<footer className="shrink-0 px-4 py-3 bg-white border-t border-neutral-200">
|
||
{sendError && <div className="mb-2 text-xs text-red-500">{sendError}</div>}
|
||
|
||
{sessionEnded && (
|
||
<div className="mb-3 space-y-2">
|
||
{!rated && (
|
||
<div className="rounded-lg bg-amber-50 border border-amber-100 px-3 py-2 flex items-center justify-between gap-2">
|
||
<span className="text-xs text-amber-800">会话已结束,欢迎为本次服务打分</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowRating(true)}
|
||
className="shrink-0 text-xs font-medium text-amber-900 bg-amber-100 hover:bg-amber-200 border-0 rounded-md px-2 py-1 cursor-pointer"
|
||
>
|
||
去评价
|
||
</button>
|
||
</div>
|
||
)}
|
||
<button
|
||
type="button"
|
||
onClick={() => { void startNewSession() }}
|
||
disabled={sending}
|
||
className="w-full h-10 rounded-xl border-0 bg-[#2563eb] hover:bg-[#1d4ed8] disabled:opacity-50 cursor-pointer text-white text-sm font-medium"
|
||
>
|
||
{sending ? '正在接入...' : '重新咨询'}
|
||
</button>
|
||
<p className="m-0 text-center text-[11px] text-neutral-400">将开启新会话,历史消息仅保留在本页展示到重新接入前</p>
|
||
</div>
|
||
)}
|
||
|
||
{!agentsOnline && !sessionEnded && (
|
||
<div className="mb-3 p-3 rounded-lg border border-amber-100 bg-amber-50 space-y-2">
|
||
{leaveSent ? (
|
||
<div className="text-xs text-amber-800 leading-relaxed">
|
||
留言已提交,客服上线后会尽快联系您。您也可继续补充留言内容。
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div className="text-xs text-amber-800 font-medium">离线留言</div>
|
||
<input
|
||
className="w-full h-8 px-2 rounded-md border border-neutral-200 bg-white text-xs outline-none"
|
||
placeholder="您的姓名(可选)"
|
||
value={leaveName}
|
||
onChange={e => setLeaveName(e.target.value)}
|
||
/>
|
||
<input
|
||
className="w-full h-8 px-2 rounded-md border border-neutral-200 bg-white text-xs outline-none"
|
||
placeholder="手机号"
|
||
value={leavePhone}
|
||
onChange={e => setLeavePhone(e.target.value)}
|
||
/>
|
||
<input
|
||
className="w-full h-8 px-2 rounded-md border border-neutral-200 bg-white text-xs outline-none"
|
||
placeholder="邮箱"
|
||
value={leaveEmail}
|
||
onChange={e => setLeaveEmail(e.target.value)}
|
||
/>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{!sessionEnded && pendingImage && agentsOnline && (
|
||
<div className="mb-2 p-2 rounded-lg border border-neutral-200 bg-neutral-50 flex items-center gap-2">
|
||
<img src={pendingImage.preview} alt="预览" className="w-14 h-14 object-cover rounded" />
|
||
<div className="flex-1 min-w-0 text-xs text-neutral-500">确认上传并发送?(自动转 WebP)</div>
|
||
<button
|
||
type="button"
|
||
className="text-xs text-neutral-400 border-0 bg-transparent cursor-pointer"
|
||
onClick={() => {
|
||
URL.revokeObjectURL(pendingImage.preview)
|
||
setPendingImage(null)
|
||
}}
|
||
>
|
||
取消
|
||
</button>
|
||
<button type="button" disabled={sending} className="text-xs px-2 py-1 rounded-md bg-[#2563eb] text-white border-0 cursor-pointer disabled:opacity-50" onClick={sendImage}>发送</button>
|
||
</div>
|
||
)}
|
||
|
||
{!sessionEnded && agentsOnline && (
|
||
<div className="flex items-center gap-2 mb-2">
|
||
<button
|
||
type="button"
|
||
className="w-8 h-8 rounded-md border-0 bg-transparent cursor-pointer flex items-center justify-center text-neutral-400 hover:bg-neutral-50 disabled:opacity-40"
|
||
aria-label="发送图片"
|
||
onClick={() => fileInputRef.current?.click()}
|
||
disabled={!sessionId || sending}
|
||
>
|
||
<PictureOutlined className="text-base" />
|
||
</button>
|
||
<EmojiPicker
|
||
onSelect={insertEmoji}
|
||
disabled={!sessionId || sending}
|
||
className="w-8 h-8 rounded-md border-0 bg-transparent cursor-pointer flex items-center justify-center text-neutral-400 hover:bg-neutral-50 disabled:opacity-40"
|
||
placement="topLeft"
|
||
/>
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept="image/jpeg,image/png,image/gif,image/webp"
|
||
className="hidden"
|
||
onChange={e => { handleImageFile(e.target.files?.[0]); e.currentTarget.value = '' }}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{!sessionEnded && (
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
ref={textInputRef}
|
||
className="flex-1 min-w-0 h-10 px-3 rounded-xl border border-neutral-200 bg-neutral-50 text-[13px] text-neutral-800 outline-none focus:border-[#2563eb]"
|
||
placeholder={
|
||
!sessionId
|
||
? '正在连接...'
|
||
: agentsOnline
|
||
? '输入消息...'
|
||
: '描述您的问题(留言)'
|
||
}
|
||
value={input}
|
||
onChange={e => { setInput(e.target.value); if (agentsOnline) emitTyping() }}
|
||
onPaste={e => {
|
||
if (!agentsOnline) return
|
||
const item = Array.from(e.clipboardData.items).find(i => i.type.startsWith('image/'))
|
||
if (item) {
|
||
e.preventDefault()
|
||
handleImageFile(item.getAsFile())
|
||
}
|
||
}}
|
||
onKeyDown={e => {
|
||
if (e.key === 'Enter') {
|
||
if (agentsOnline) sendMessage(input)
|
||
}
|
||
}}
|
||
disabled={sending || !sessionId}
|
||
/>
|
||
{agentsOnline ? (
|
||
<button
|
||
type="button"
|
||
onClick={() => sendMessage(input)}
|
||
disabled={!input.trim() || sending || !sessionId}
|
||
className="w-10 h-10 rounded-full border-0 bg-[#2563eb] hover:bg-[#1d4ed8] disabled:opacity-40 cursor-pointer flex items-center justify-center shrink-0"
|
||
aria-label="发送"
|
||
>
|
||
<SendOutlined className="text-white text-sm" />
|
||
</button>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
onClick={submitLeaveMessage}
|
||
disabled={sending || !sessionId}
|
||
className="h-10 px-3 rounded-full border-0 bg-[#d97706] hover:bg-[#b45309] disabled:opacity-40 cursor-pointer text-white text-xs shrink-0"
|
||
>
|
||
{leaveSent ? '再留言' : '提交留言'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</footer>
|
||
|
||
{showRating && (
|
||
<div className="absolute inset-0 bg-black/40 flex items-center justify-center z-10 p-4">
|
||
<div className="bg-white rounded-xl p-5 w-full max-w-xs text-center shadow-lg">
|
||
<div className="text-lg font-semibold text-neutral-800 mb-1">本次服务如何?</div>
|
||
<div className="text-sm text-neutral-400 mb-3">先点亮星级,可补充感受后再提交</div>
|
||
<div className="flex justify-center gap-1.5 mb-1">
|
||
{[1, 2, 3, 4, 5].map(star => {
|
||
const active = star <= (hoverStar || ratingScore)
|
||
return (
|
||
<StarFilled
|
||
key={star}
|
||
className="text-2xl cursor-pointer transition-colors"
|
||
style={{ color: active ? '#facc15' : '#e2e8f0' }}
|
||
onMouseEnter={() => setHoverStar(star)}
|
||
onMouseLeave={() => setHoverStar(0)}
|
||
onClick={() => {
|
||
setRatingScore(star)
|
||
setSendError('')
|
||
}}
|
||
/>
|
||
)
|
||
})}
|
||
</div>
|
||
<div className="text-xs text-neutral-400 mb-3 h-4">
|
||
{ratingScore > 0 ? `${ratingScore} 星` : '请选择 1~5 星'}
|
||
</div>
|
||
<div className="flex flex-wrap gap-1.5 justify-center mb-3">
|
||
{ratingPresets.map(preset => {
|
||
const selected = ratingText === preset
|
||
return (
|
||
<button
|
||
key={preset}
|
||
type="button"
|
||
onClick={() => setRatingText(prev => (prev === preset ? '' : preset))}
|
||
className={`px-2 py-1 rounded-full text-[11px] border cursor-pointer transition-colors ${
|
||
selected
|
||
? 'bg-[#2563eb] border-[#2563eb] text-white'
|
||
: 'bg-neutral-50 border-neutral-200 text-neutral-600 hover:border-blue-300 hover:text-blue-600'
|
||
}`}
|
||
>
|
||
{preset}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
<textarea
|
||
className="w-full rounded-lg border border-neutral-200 p-2 text-sm outline-none focus:border-blue-400 mb-3 resize-none text-left"
|
||
rows={3}
|
||
maxLength={500}
|
||
value={ratingText}
|
||
onChange={e => setRatingText(e.target.value)}
|
||
placeholder="可选:点击上方标签快速填入,或自行输入"
|
||
/>
|
||
<button
|
||
type="button"
|
||
disabled={ratingSubmitting || ratingScore < 1}
|
||
onClick={() => { void submitRating() }}
|
||
className="w-full h-10 rounded-xl border-0 bg-[#2563eb] hover:bg-[#1d4ed8] disabled:opacity-40 cursor-pointer text-white text-sm font-medium mb-2"
|
||
>
|
||
{ratingSubmitting ? '提交中...' : '提交评价'}
|
||
</button>
|
||
<div className="flex flex-col gap-1.5">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setShowRating(false)
|
||
setRatingScore(0)
|
||
setHoverStar(0)
|
||
}}
|
||
className="text-sm text-neutral-400 hover:text-neutral-600 border-0 bg-transparent cursor-pointer"
|
||
>
|
||
跳过
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={sending || ratingSubmitting}
|
||
onClick={() => {
|
||
setShowRating(false)
|
||
setRatingScore(0)
|
||
setHoverStar(0)
|
||
void startNewSession()
|
||
}}
|
||
className="text-sm text-[#2563eb] hover:text-[#1d4ed8] border-0 bg-transparent cursor-pointer disabled:opacity-50"
|
||
>
|
||
跳过并重新咨询
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
|
||
return (
|
||
<>
|
||
{!open && layout === 'floating' && (
|
||
<button
|
||
type="button"
|
||
onClick={handleOpen}
|
||
className="fixed right-6 bottom-6 w-14 h-14 rounded-full bg-[#2563eb] hover:bg-[#1d4ed8] text-white flex items-center justify-center z-50 transition-transform hover:scale-105"
|
||
style={{ boxShadow: 'var(--shadow-floating)' }}
|
||
aria-label="打开在线客服"
|
||
>
|
||
<MessageOutlined className="text-xl" />
|
||
</button>
|
||
)}
|
||
{panel}
|
||
</>
|
||
)
|
||
}
|
||
|
||
export default VisitorChat
|