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(() => { const saved = localStorage.getItem(storageKey) return saved ? Number(saved) : null }) const [visitorToken, setVisitorToken] = useState(() => localStorage.getItem(tokenKey) || '') const [messages, setMessages] = useState(() => { 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(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(null) const initRef = useRef(false) const typingTimerRef = useRef(null) const chatEndRef = useRef(null) const fileInputRef = useRef(null) const textInputRef = useRef(null) const socketRef = useRef(null) const lastTypingAt = useRef(0) const sessionIdRef = useRef(sessionId) const visitorTokenRef = useRef(visitorToken) const agentNameRef = useRef(agentName) const loadMessagesRef = useRef<(sid?: number, token?: string, opts?: { afterSeq?: number; full?: boolean }) => Promise>(async () => {}) const msgsKeyRef = useRef(msgsKey) /** 宿主页信息(嵌入时由 postMessage / 初始 query 更新) */ const hostPageRef = useRef({ 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() 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 && (

{displayName || '在线客服'}

{sessionEnded ? '会话已结束' : agentsOnline ? (agentName ? `${agentName} 为您服务` : '正在为您服务') : '暂不可即时接待 · 可留言'}

{!isFill && ( )}
{showWelcome && (
{agentsOnline ? ( welcomeSegments.map((seg, i) => seg.type === 'image' ? (
) : (
{looksLikeHtml(seg.content) ? ( ) : ( {seg.content} )}
), ) ) : (
{looksLikeHtml(offlinePrompt) ? ( ) : ( {offlinePrompt} )}
)}
)} {showQuick && (
{defaultQuickQuestions.map((q, i) => ( ))}
)} {messages.map(msg => ( msg.sender === 'visitor' ? (
{msg.type === 'image' ? ( ) : ( {msg.content} )}
{msg.time && ( {msg.time} )}
) : (
{msg.type === 'image' ? ( ) : ( {msg.content} )}
{msg.time && ( {msg.time} )}
) ))} {agentTyping && ( <>

客服正在输入...

)}
{sendError &&
{sendError}
} {sessionEnded && (
{!rated && (
会话已结束,欢迎为本次服务打分
)}

将开启新会话,历史消息仅保留在本页展示到重新接入前

)} {!agentsOnline && !sessionEnded && (
{leaveSent ? (
留言已提交,客服上线后会尽快联系您。您也可继续补充留言内容。
) : ( <>
离线留言
setLeaveName(e.target.value)} /> setLeavePhone(e.target.value)} /> setLeaveEmail(e.target.value)} /> )}
)} {!sessionEnded && pendingImage && agentsOnline && (
预览
确认上传并发送?(自动转 WebP)
)} {!sessionEnded && agentsOnline && (
{ handleImageFile(e.target.files?.[0]); e.currentTarget.value = '' }} />
)} {!sessionEnded && (
{ 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 ? ( ) : ( )}
)}
{showRating && (
本次服务如何?
先点亮星级,可补充感受后再提交
{[1, 2, 3, 4, 5].map(star => { const active = star <= (hoverStar || ratingScore) return ( setHoverStar(star)} onMouseLeave={() => setHoverStar(0)} onClick={() => { setRatingScore(star) setSendError('') }} /> ) })}
{ratingScore > 0 ? `${ratingScore} 星` : '请选择 1~5 星'}
{ratingPresets.map(preset => { const selected = ratingText === preset return ( ) })}