Forráskód Böngészése

feat(chat): 添加在线客服聊天组件

- 新增 ChatWidget 组件,支持实时 WebSocket 通信
- 实现消息发送、接收及系统通知展示功能- 添加输入框自动调整高度与消息防抖处理
- 集成打字状态提示(typing/typingStop)逻辑
- 支持消息发送频率限制与倒计时提示
- 使用环境变量配置 WebSocket 和 API 地址
- 实现用户 ID 本地存储与随机生成机制- 添加客服分配与连接错误处理逻辑
- 设计响应式聊天界面,支持移动端展示
- 支持 Enter 快捷发送与 Shift+Enter 换行fix(banner):优化轮播图自适应高度与图片容器样式

- 添加 adaptiveHeight 属性以支持内容高度自适应- 为轮播图项添加 h-full 类以确保高度占满容器
-修复图片容器未正确拉伸导致的布局问题feat(contact): 动态展示联系信息与二维码

-从 basicInfo 属性中动态获取联系信息
- 支持电话、邮箱、地址等信息的动态渲染
- 使用环境变量拼接二维码图片完整路径- 优化地图初始化逻辑与组件结构
nahida 7 hónapja
szülő
commit
e897163d67

+ 400 - 0
src/components/ChatWidget.tsx

@@ -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>
+  )
+}

+ 7 - 5
src/components/about/ContactUs.tsx

@@ -4,7 +4,8 @@ import SubTitle from "@/components/subTitle";
 import {BDMapGL} from "@/utils/BDMap";
 import Image from "next/image";
 
-function ContactUs() {
+function ContactUs({basicInfo}: {basicInfo: BasicInfo}) {
+  const BASE_URL:string = process.env.NEXT_PUBLIC_BASE_URL as string;
 
   const BDMapInit = () => {
     BDMapGL('94KOPB1NRjoXmLvlJN6HMi7eVC50z09K').then((BMapGL: any) => {
@@ -17,6 +18,7 @@ function ContactUs() {
       map.addOverlay(marker)
     })
   }
+  const qrCodeUrl = BASE_URL + basicInfo.qrCodeUrl
   useEffect(() => {
     BDMapInit()
   }, []);
@@ -31,11 +33,11 @@ function ContactUs() {
           <div className="h-88" id="container"></div>
           <div className="space-y-4">
             <div className="bg-gray-300 w-60 h-60">
-              <Image src={'/assets/about/22.jpg'} alt={'qrcode'} width={500} height={500}/>
+              <Image src={qrCodeUrl} alt={'qrcode'} width={500} height={500}/>
             </div>
-            <p>电话:0731-85315153</p>
-            <p>邮箱:337843345@qq.com</p>
-            <p>地址:湖南省长沙市岳麓区梅溪湖街道泉水路461号长沙健康医疗大数据产业孵化基地1-2#楼 未来楼5楼左侧</p>
+            <p>电话:{basicInfo.telephone}</p>
+            <p>邮箱:{basicInfo.email}</p>
+            <p>地址:{basicInfo.address}</p>
           </div>
         </div>
       </section>

+ 4 - 3
src/components/bannerCarousel.tsx

@@ -23,19 +23,20 @@ function BannerCarousel() {
             dots={{
               className: "bottom-4"
             }}
+            adaptiveHeight={true}
           >
             <div>
-              <div className="w-full bg-blue-500 flex items-center justify-center text-white text-4xl font-bold">
+              <div className="w-full h-full bg-blue-500 flex items-center justify-center text-white text-4xl font-bold">
                 <Image src={"/assets/home/1.jpg"} alt={"轮播图图片"} width={1920} height={1080} />
               </div>
             </div>
             <div>
-              <div className="w-full bg-green-500 flex items-center justify-center text-white text-4xl font-bold">
+              <div className="w-full h-full bg-green-500 flex items-center justify-center text-white text-4xl font-bold">
                 <Image src={"/assets/home/2.jpg"} alt={"轮播图图片"} width={1920} height={1080} />
               </div>
             </div>
             <div>
-              <div className="w-full bg-red-500 flex items-center justify-center text-white text-4xl font-bold">
+              <div className="w-full h-full bg-red-500 flex items-center justify-center text-white text-4xl font-bold">
                 <Image src={"/assets/home/3.jpg"} alt={"轮播图图片"} width={1920} height={1080} />
               </div>
             </div>