|
|
@@ -0,0 +1,400 @@
|
|
|
+'use client'
|
|
|
+
|
|
|
+import {useEffect, useRef, useState} from 'react'
|
|
|
+import {MessageCircle, Send, X} from 'lucide-react'
|
|
|
+
|
|
|
+const WS_BASE_URL = process.env.NEXT_PUBLIC_BASE_WS_URL as string
|
|
|
+const API_REMOTE_BASE = process.env.NEXT_PUBLIC_REMOTE_BASE_URL as string
|
|
|
+
|
|
|
+// 获取今日日期的方法,格式为 YYYYMMDD hhmmss +一位随机字符(a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p/q/r/s/t/u/v/w/x/y/z)
|
|
|
+const getTodayDateWithRandom = () => {
|
|
|
+ const now = new Date()
|
|
|
+ const year = now.getFullYear()
|
|
|
+ const month = String(now.getMonth() + 1).padStart(2, '0')
|
|
|
+ const day = String(now.getDate()).padStart(2, '0')
|
|
|
+ const hour = String(now.getHours()).padStart(2, '0')
|
|
|
+ const minute = String(now.getMinutes()).padStart(2, '0')
|
|
|
+ const second = String(now.getSeconds()).padStart(2, '0')
|
|
|
+ const randomChars = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z']
|
|
|
+ const randomChar = randomChars[Math.floor(Math.random() * randomChars.length)]
|
|
|
+ return `${year}${month}${day}${hour}${minute}${second}${randomChar}`
|
|
|
+}
|
|
|
+
|
|
|
+export default function ChatWidget() {
|
|
|
+ const [isOpen, setIsOpen] = useState(false)
|
|
|
+ const [message, setMessage] = useState('')
|
|
|
+ const [messages, setMessages] = useState([
|
|
|
+ { id: 1, text: '你好,请问有什么可以帮您?', isBot: true, isSystem: false }
|
|
|
+ ])
|
|
|
+ const wsRef = useRef<WebSocket | null>(null)
|
|
|
+ const [userId, setUserId] = useState('')
|
|
|
+ const [adminId, setAdminId] = useState('')
|
|
|
+ const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
|
|
+ const typingStopTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
|
|
+ const textareaRef = useRef<HTMLTextAreaElement>(null)
|
|
|
+
|
|
|
+ // 添加发送限制相关状态
|
|
|
+ const [lastSendTime, setLastSendTime] = useState(0)
|
|
|
+ const [remainingTime, setRemainingTime] = useState(0)
|
|
|
+ const countdownIntervalRef = useRef<NodeJS.Timeout | null>(null)
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ //获取用户ID没有就生成一个 今日的日期作为用户ID 最后一位为随机数
|
|
|
+ const userId = localStorage.getItem('userId') || getTodayDateWithRandom()
|
|
|
+ setUserId(userId)
|
|
|
+ localStorage.setItem('userId', userId)
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ // 清理倒计时定时器
|
|
|
+ useEffect(() => {
|
|
|
+ return () => {
|
|
|
+ if (countdownIntervalRef.current) {
|
|
|
+ clearInterval(countdownIntervalRef.current)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }, [])
|
|
|
+
|
|
|
+ // 自动调整 textarea 高度
|
|
|
+ useEffect(() => {
|
|
|
+ if (textareaRef.current) {
|
|
|
+ textareaRef.current.style.height = 'auto'
|
|
|
+ textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 120)}px`
|
|
|
+ }
|
|
|
+ }, [message])
|
|
|
+
|
|
|
+ const sendTypingMessage = () => {
|
|
|
+ if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN && adminId) {
|
|
|
+ const typingMessage = {
|
|
|
+ from: userId,
|
|
|
+ to: adminId,
|
|
|
+ type: 'typing',
|
|
|
+ role: 'user'
|
|
|
+ }
|
|
|
+ wsRef.current.send(JSON.stringify(typingMessage))
|
|
|
+ console.log('已发送 typing 消息:', typingMessage)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const sendTypingStopMessage = () => {
|
|
|
+ if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN && adminId) {
|
|
|
+ const typingStopMessage = {
|
|
|
+ from: userId,
|
|
|
+ to: adminId,
|
|
|
+ type: 'typingStop',
|
|
|
+ role: 'user'
|
|
|
+ }
|
|
|
+ wsRef.current.send(JSON.stringify(typingStopMessage))
|
|
|
+ console.log('已发送 typingStop 消息:', typingStopMessage)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
|
+ setMessage(e.target.value)
|
|
|
+
|
|
|
+ // 清除之前的定时器
|
|
|
+ if (typingTimeoutRef.current) {
|
|
|
+ clearTimeout(typingTimeoutRef.current)
|
|
|
+ }
|
|
|
+ if (typingStopTimeoutRef.current) {
|
|
|
+ clearTimeout(typingStopTimeoutRef.current)
|
|
|
+ }
|
|
|
+
|
|
|
+ // 发送 typing 消息
|
|
|
+ sendTypingMessage()
|
|
|
+
|
|
|
+ // 设置新的定时器,1秒后再次发送(如果用户还在输入)
|
|
|
+ typingTimeoutRef.current = setTimeout(() => {
|
|
|
+ sendTypingMessage()
|
|
|
+ }, 1000)
|
|
|
+
|
|
|
+ // 设置 typingStop 定时器,2秒后如果没有新输入则发送 typingStop
|
|
|
+ typingStopTimeoutRef.current = setTimeout(() => {
|
|
|
+ sendTypingStopMessage()
|
|
|
+ }, 2000)
|
|
|
+ }
|
|
|
+
|
|
|
+ const handleInputBlur = () => {
|
|
|
+ // 输入框失去焦点时发送 typingStop
|
|
|
+ if (typingTimeoutRef.current) {
|
|
|
+ clearTimeout(typingTimeoutRef.current)
|
|
|
+ }
|
|
|
+ if (typingStopTimeoutRef.current) {
|
|
|
+ clearTimeout(typingStopTimeoutRef.current)
|
|
|
+ }
|
|
|
+ sendTypingStopMessage()
|
|
|
+ }
|
|
|
+
|
|
|
+ const handleSendMessage = async () => {
|
|
|
+ if (!message.trim()) return
|
|
|
+
|
|
|
+ // 检查是否在冷却时间内
|
|
|
+ const now = Date.now()
|
|
|
+ const timeSinceLastSend = now - lastSendTime
|
|
|
+ if (timeSinceLastSend < 3000) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ // 清除 typing 定时器并发送 typingStop
|
|
|
+ if (typingTimeoutRef.current) {
|
|
|
+ clearTimeout(typingTimeoutRef.current)
|
|
|
+ }
|
|
|
+ if (typingStopTimeoutRef.current) {
|
|
|
+ clearTimeout(typingStopTimeoutRef.current)
|
|
|
+ }
|
|
|
+ sendTypingStopMessage()
|
|
|
+
|
|
|
+ // 添加用户消息
|
|
|
+ const userMessage = { id: Date.now(), text: message, isBot: false, isSystem: false }
|
|
|
+ setMessages(prev => [...prev, userMessage])
|
|
|
+
|
|
|
+ // 保存当前消息
|
|
|
+ const currentMessage = message
|
|
|
+ setMessage('')
|
|
|
+
|
|
|
+ // 更新最后发送时间并开始倒计时
|
|
|
+ setLastSendTime(now)
|
|
|
+ setRemainingTime(3)
|
|
|
+
|
|
|
+ // 清除之前的倒计时
|
|
|
+ if (countdownIntervalRef.current) {
|
|
|
+ clearInterval(countdownIntervalRef.current)
|
|
|
+ }
|
|
|
+
|
|
|
+ // 开始新的倒计时
|
|
|
+ countdownIntervalRef.current = setInterval(() => {
|
|
|
+ setRemainingTime(prev => {
|
|
|
+ const newTime = prev - 1
|
|
|
+ if (newTime <= 0) {
|
|
|
+ if (countdownIntervalRef.current) {
|
|
|
+ clearInterval(countdownIntervalRef.current)
|
|
|
+ }
|
|
|
+ return 0
|
|
|
+ }
|
|
|
+ return newTime
|
|
|
+ })
|
|
|
+ }, 1000)
|
|
|
+
|
|
|
+ try {
|
|
|
+ // 获取 token
|
|
|
+ const tokenResponse = await fetch(`${API_REMOTE_BASE}/chat/getToken`, {
|
|
|
+ method: 'GET',
|
|
|
+ headers: {
|
|
|
+ 'Content-Type': 'application/json'
|
|
|
+ }
|
|
|
+ })
|
|
|
+ const tokenResult = await tokenResponse.json()
|
|
|
+ const token = tokenResult.data
|
|
|
+
|
|
|
+ // 建立 WebSocket 连接
|
|
|
+ if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
|
|
|
+ const ws = new WebSocket(`${WS_BASE_URL}?token=${token}`)
|
|
|
+ wsRef.current = ws
|
|
|
+
|
|
|
+ ws.onopen = () => {
|
|
|
+ console.log('WebSocket 连接已建立')
|
|
|
+
|
|
|
+ // 发送登录消息
|
|
|
+ const loginMessage = {
|
|
|
+ type: 'login',
|
|
|
+ from: userId,
|
|
|
+ role: 'user'
|
|
|
+ }
|
|
|
+ ws.send(JSON.stringify(loginMessage))
|
|
|
+ console.log('已发送登录消息:', loginMessage)
|
|
|
+
|
|
|
+ // 发送聊天消息
|
|
|
+ const chatMessage = {
|
|
|
+ type: 'chat',
|
|
|
+ content: currentMessage,
|
|
|
+ from: userId,
|
|
|
+ to: adminId || null,
|
|
|
+ role: 'user'
|
|
|
+ }
|
|
|
+ ws.send(JSON.stringify(chatMessage))
|
|
|
+ console.log('已发送聊天消息:', chatMessage)
|
|
|
+ }
|
|
|
+
|
|
|
+ ws.onmessage = (event) => {
|
|
|
+ console.log('收到服务器消息:', event.data)
|
|
|
+ try {
|
|
|
+ const data = JSON.parse(event.data)
|
|
|
+
|
|
|
+ // 存储对方的 adminId
|
|
|
+ if (data.from && data.role === 'admin') {
|
|
|
+ setAdminId(data.from)
|
|
|
+ }
|
|
|
+
|
|
|
+ // 处理客服分配消息
|
|
|
+ if (data.type === 'adminAllocation') {
|
|
|
+ const allocationMessage = {
|
|
|
+ id: Date.now(),
|
|
|
+ text: data.content,
|
|
|
+ isBot: false,
|
|
|
+ isSystem: true
|
|
|
+ }
|
|
|
+ setMessages(prev => [...prev, allocationMessage])
|
|
|
+ } else if (data.type !== 'typing' && data.type !== 'typingStop') {
|
|
|
+ // 处理其他消息(排除 typing 和 typingStop 消息)
|
|
|
+ const botResponse = {
|
|
|
+ id: data.messageId || Date.now(),
|
|
|
+ text: data.content || data.message || event.data,
|
|
|
+ isBot: true,
|
|
|
+ isSystem: false
|
|
|
+ }
|
|
|
+ setMessages(prev => [...prev, botResponse])
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ // 如果不是 JSON,直接显示文本
|
|
|
+ const botResponse = {
|
|
|
+ id: Date.now(),
|
|
|
+ text: event.data,
|
|
|
+ isBot: true,
|
|
|
+ isSystem: false
|
|
|
+ }
|
|
|
+ setMessages(prev => [...prev, botResponse])
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ ws.onerror = (error) => {
|
|
|
+ console.error('WebSocket 错误:', error)
|
|
|
+ const errorMessage = {
|
|
|
+ id: Date.now(),
|
|
|
+ text: '连接出错,请稍后重试',
|
|
|
+ isBot: true,
|
|
|
+ isSystem: false
|
|
|
+ }
|
|
|
+ setMessages(prev => [...prev, errorMessage])
|
|
|
+ }
|
|
|
+
|
|
|
+ ws.onclose = () => {
|
|
|
+ console.log('WebSocket 连接已关闭')
|
|
|
+ wsRef.current = null
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ // 如果连接已存在,直接发送消息
|
|
|
+ const chatMessage = {
|
|
|
+ type: 'chat',
|
|
|
+ content: currentMessage,
|
|
|
+ from: userId,
|
|
|
+ to: adminId || null,
|
|
|
+ role: 'user'
|
|
|
+ }
|
|
|
+ wsRef.current.send(JSON.stringify(chatMessage))
|
|
|
+ console.log('已发送聊天消息:', chatMessage)
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error('获取 token 失败:', error)
|
|
|
+ const errorMessage = {
|
|
|
+ id: Date.now(),
|
|
|
+ text: '获取连接失败,请稍后重试',
|
|
|
+ isBot: true,
|
|
|
+ isSystem: false
|
|
|
+ }
|
|
|
+ setMessages(prev => [...prev, errorMessage])
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const handleKeyPress = (e: React.KeyboardEvent) => {
|
|
|
+ if (e.key === 'Enter' && !e.shiftKey) {
|
|
|
+ e.preventDefault()
|
|
|
+ handleSendMessage()
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 计算是否可以发送
|
|
|
+ const canSend = message.trim() && remainingTime === 0
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="fixed bottom-3 right-3 sm:bottom-4 sm:right-4 z-50">
|
|
|
+ {/* 聊天窗口 */}
|
|
|
+ {isOpen && (
|
|
|
+ <div className="mb-4 w-[calc(100vw-24px)] max-w-[360px] sm:w-96 h-[500px] sm:h-[600px] bg-white rounded-2xl shadow-2xl border border-gray-200 flex flex-col animate-in slide-in-from-bottom-2 duration-300">
|
|
|
+ {/* 头部 */}
|
|
|
+ <div className="flex items-center justify-between px-4 py-3 sm:px-5 sm:py-4 bg-gradient-to-r from-blue-500 to-blue-600 text-white rounded-t-2xl">
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
+ <div className="w-2 h-2 bg-green-400 rounded-full animate-pulse"></div>
|
|
|
+ <h3 className="font-semibold text-base sm:text-lg">在线客服</h3>
|
|
|
+ </div>
|
|
|
+ <button
|
|
|
+ onClick={() => setIsOpen(false)}
|
|
|
+ className="hover:bg-white/20 rounded-full p-1.5 transition-colors"
|
|
|
+ aria-label="关闭聊天窗口"
|
|
|
+ >
|
|
|
+ <X size={20} />
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* 消息区域 */}
|
|
|
+ <div className="flex-1 p-3 sm:p-4 overflow-y-auto space-y-3 bg-gray-50">
|
|
|
+ {messages.map((msg) => (
|
|
|
+ msg.isSystem ? (
|
|
|
+ // 系统消息样式
|
|
|
+ <div key={msg.id} className="flex justify-center">
|
|
|
+ <div className="px-3 py-1.5 bg-gray-200 text-gray-600 rounded-full text-xs sm:text-sm">
|
|
|
+ {msg.text}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ ) : (
|
|
|
+ // 普通消息样式 - 添加换行支持
|
|
|
+ <div
|
|
|
+ key={msg.id}
|
|
|
+ className={`flex ${msg.isBot ? 'justify-start' : 'justify-end'}`}
|
|
|
+ >
|
|
|
+ <div
|
|
|
+ className={`max-w-[85%] sm:max-w-[75%] px-3 py-2 sm:px-4 sm:py-2.5 rounded-2xl text-sm sm:text-base break-words whitespace-pre-wrap leading-relaxed ${
|
|
|
+ msg.isBot
|
|
|
+ ? 'bg-white text-gray-800 shadow-sm border border-gray-100 rounded-tl-sm'
|
|
|
+ : 'bg-blue-500 text-white shadow-md rounded-tr-sm'
|
|
|
+ }`}
|
|
|
+ >
|
|
|
+ {msg.text}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* 输入区域 */}
|
|
|
+ <div className="p-3 sm:p-4 border-t border-gray-200 bg-white rounded-b-2xl">
|
|
|
+ <div className="flex items-end gap-2">
|
|
|
+ <textarea
|
|
|
+ ref={textareaRef}
|
|
|
+ value={message}
|
|
|
+ onChange={handleInputChange}
|
|
|
+ onBlur={handleInputBlur}
|
|
|
+ onKeyPress={handleKeyPress}
|
|
|
+ placeholder="请输入您的问题..."
|
|
|
+ rows={1}
|
|
|
+ className="flex-1 px-3 py-2 sm:px-4 sm:py-2.5 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm sm:text-base resize-none overflow-y-auto leading-relaxed"
|
|
|
+ style={{ minHeight: '40px', maxHeight: '120px' }}
|
|
|
+ />
|
|
|
+ <button
|
|
|
+ onClick={handleSendMessage}
|
|
|
+ disabled={!canSend}
|
|
|
+ className="flex-shrink-0 w-10 h-10 sm:w-11 sm:h-11 bg-blue-500 text-white rounded-xl hover:bg-blue-600 disabled:bg-gray-300 disabled:cursor-not-allowed transition-all duration-200 flex items-center justify-center shadow-md hover:shadow-lg active:scale-95"
|
|
|
+ aria-label="发送消息"
|
|
|
+ >
|
|
|
+ {remainingTime > 0 ? (
|
|
|
+ <span className="text-xs font-semibold">{remainingTime}s</span>
|
|
|
+ ) : (
|
|
|
+ <Send size={18} />
|
|
|
+ )}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* 浮动按钮 */}
|
|
|
+ <button
|
|
|
+ onClick={() => setIsOpen(!isOpen)}
|
|
|
+ className={`bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white rounded-full shadow-xl hover:shadow-2xl transition-all duration-300 flex items-center justify-center active:scale-95 ${
|
|
|
+ isOpen ? 'hidden' : 'w-14 h-14 sm:w-16 sm:h-16'
|
|
|
+ }`}
|
|
|
+ aria-label="打开聊天窗口"
|
|
|
+ >
|
|
|
+ <MessageCircle size={24} className="sm:w-7 sm:h-7" />
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+}
|