Files
kefu_cloud/web/src/widgets/VisitorChat.tsx
T
yml2213 fea5f4acf5 支持消息 Markdown 与欢迎语富文本
聊天/离线提示使用 Markdown 安全渲染;欢迎语改为 TipTap 富文本(颜色字号),DOMPurify 白名单清洗后展示。
2026-07-19 00:03:05 +08:00

1316 lines
49 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useRef, useCallback, 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])
// 访客 WSsession / 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} 星` : '请选择 15 星'}
</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