diff --git a/server/internal/handler/router.go b/server/internal/handler/router.go index a9d357c..89ae92d 100644 --- a/server/internal/handler/router.go +++ b/server/internal/handler/router.go @@ -13,6 +13,7 @@ func SetupRoutes(r *gin.Engine) { stats := NewStatisticsHandler() admin := NewAdminHandler() ws := NewWsHandler() + widget := NewWidgetHandler() api := r.Group("/api") @@ -20,10 +21,12 @@ func SetupRoutes(r *gin.Engine) { api.POST("/login", auth.Login) api.POST("/register", auth.Register) - // widget 接口(通过 channel_id 鉴权,简化处理) - widget := api.Group("/widget") - widget.POST("/init", func(c *gin.Context) { c.JSON(200, gin.H{"code": 0, "data": gin.H{"session_id": 1}}) }) - widget.POST("/message", func(c *gin.Context) { c.JSON(200, gin.H{"code": 0}) }) + // widget 接口 + widgetApi := api.Group("/widget") + widgetApi.POST("/init", widget.Init) + widgetApi.GET("/init", widget.Init) + widgetApi.POST("/message", widget.SendMessage) + widgetApi.GET("/messages", widget.GetMessages) // 需要认证的接口 authRequired := api.Group("") diff --git a/server/internal/handler/widget.go b/server/internal/handler/widget.go new file mode 100644 index 0000000..fcc6efb --- /dev/null +++ b/server/internal/handler/widget.go @@ -0,0 +1,134 @@ +package handler + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + "kefu-sys/server/internal/model" +) + +type WidgetHandler struct{} + +func NewWidgetHandler() *WidgetHandler { return &WidgetHandler{} } + +type WidgetInitReq struct { + ChannelKey string `json:"channel_key" form:"channel_key"` + VisitorName string `json:"visitor_name"` +} + +type WidgetMessageReq struct { + SessionID uint `json:"session_id" binding:"required"` + Content string `json:"content" binding:"required"` + Type string `json:"type"` +} + +func (h *WidgetHandler) Init(c *gin.Context) { + var req WidgetInitReq + if err := c.ShouldBindQuery(&req); err != nil && c.ShouldBindJSON(&req) != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) + return + } + if req.ChannelKey == "" { + req.ChannelKey = c.Query("channel_key") + } + + var channel model.Channel + if err := model.DB.Where("script_code LIKE ?", "%"+req.ChannelKey+"%").Or("script_code LIKE ?", "%"+req.ChannelKey+"%").First(&channel).Error; err != nil { + // 如果没有匹配的渠道,使用第一个启用的渠道 + if err := model.DB.Where("type = ? AND status = ?", "web", "enabled").First(&channel).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "渠道不存在"}) + return + } + } + + name := req.VisitorName + if name == "" { + name = "访客" + } + + // 创建或查找客户 + var customer model.Customer + model.DB.Where("tenant_id = ? AND name = ? AND phone = ''", channel.TenantID, name).First(&customer) + if customer.ID == 0 { + customer = model.Customer{ + TenantID: channel.TenantID, + Name: name, + Source: "网页", + Status: "online", + } + model.DB.Create(&customer) + } + + // 创建会话 + session := model.Session{ + TenantID: channel.TenantID, + ChannelID: channel.ID, + CustomerID: customer.ID, + Status: "waiting", + Priority: "normal", + } + model.DB.Create(&session) + + c.JSON(http.StatusOK, gin.H{ + "code": 0, + "data": gin.H{ + "session_id": session.ID, + "customer_id": customer.ID, + "channel_id": channel.ID, + "tenant_id": channel.TenantID, + }, + }) +} + +func (h *WidgetHandler) SendMessage(c *gin.Context) { + var req WidgetMessageReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) + return + } + if req.Type == "" { + req.Type = "text" + } + + var session model.Session + if err := model.DB.First(&session, req.SessionID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "会话不存在"}) + return + } + + // 如果是等待中的会话,更新为活跃 + if session.Status == "waiting" { + model.DB.Model(&session).Update("status", "active") + } + + var maxSeq int + model.DB.Model(&model.Message{}).Where("session_id = ?", session.ID).Select("COALESCE(MAX(seq), 0)").Scan(&maxSeq) + + custID := session.CustomerID + msg := model.Message{ + SessionID: session.ID, + SenderType: "visitor", + SenderID: &custID, + Content: req.Content, + Type: req.Type, + Seq: maxSeq + 1, + SentAt: time.Now(), + } + + if err := model.DB.Create(&msg).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "发送失败"}) + return + } + + c.JSON(http.StatusOK, gin.H{"code": 0, "data": msg}) +} + +func (h *WidgetHandler) GetMessages(c *gin.Context) { + sessionID := c.Query("session_id") + + var messages []model.Message + model.DB.Where("session_id = ?", sessionID).Order("seq asc").Find(&messages) + + c.JSON(http.StatusOK, gin.H{"code": 0, "data": messages}) +} diff --git a/web/src/widgets/VisitorChat.tsx b/web/src/widgets/VisitorChat.tsx index a15f60e..e7596d8 100644 --- a/web/src/widgets/VisitorChat.tsx +++ b/web/src/widgets/VisitorChat.tsx @@ -1,63 +1,123 @@ +import { useState, useEffect, useRef } from 'react' +import { CloseOutlined, MessageOutlined, SmileOutlined, SendOutlined, StarFilled } from '@ant-design/icons' + interface Message { - id: string + id: number sender: 'visitor' | 'agent' content: string time: string } -const initialMessages: Message[] = [ - { id: '1', sender: 'agent', content: '您好!欢迎咨询客服云,请问有什么可以帮您的?', time: '10:30' }, -] - const quickQuestions = ['产品功能介绍', '价格咨询', '售后服务', '合作咨询'] -import { useState } from 'react' -import { CloseOutlined, MessageOutlined, SmileOutlined, PaperClipOutlined, SendOutlined, StarFilled } from '@ant-design/icons' - const VisitorChat = () => { const [open, setOpen] = useState(false) - const [messages, setMessages] = useState(initialMessages) + const [sessionId, setSessionId] = useState(null) + const [messages, setMessages] = useState([ + { id: 0, sender: 'agent', content: '您好!欢迎咨询客服云,请问有什么可以帮您的?', time: '' }, + ]) const [input, setInput] = useState('') - const [typing, setTyping] = useState(false) + const [sending, setSending] = useState(false) const [showRating, setShowRating] = useState(false) const [rated, setRated] = useState(false) + const pollRef = useRef(null) - const sendMessage = (text: string) => { - if (!text.trim()) return - const msg: Message = { id: Date.now().toString(), sender: 'visitor', content: text, time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) } - setMessages(prev => [...prev, msg]) - setInput('') - setTyping(true) - setTimeout(() => { - setTyping(false) - const reply: Message = { id: (Date.now() + 1).toString(), sender: 'agent', content: '感谢您的咨询,客服正在为您处理中...', time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) } - setMessages(prev => [...prev, reply]) - }, 1500) + const initSession = async () => { + try { + const res = await fetch(`/api/widget/init?channel_key=WK_8a3f2e&visitor_name=测试访客`, { method: 'POST' }) + const json = await res.json() + if (json.code === 0) { + setSessionId(json.data.session_id) + } + } catch (e) { + console.error('Init session failed:', e) + } } - const handleEnd = () => { - if (!rated) { - setShowRating(true) + useEffect(() => { + if (open && !sessionId) { + initSession() } - setOpen(false) + if (!open) { + setSessionId(null) + if (pollRef.current) clearInterval(pollRef.current) + } + return () => { if (pollRef.current) clearInterval(pollRef.current) } + }, [open]) + + useEffect(() => { + if (sessionId) { + pollRef.current = window.setInterval(async () => { + try { + const res = await fetch(`/api/widget/messages?session_id=${sessionId}`) + const json = await res.json() + if (json.code === 0 && json.data) { + const serverMsgs: Message[] = json.data.map((m: any) => ({ + id: m.id, + sender: m.sender_type === 'agent' ? 'agent' : 'visitor', + content: m.content, + time: new Date(m.sent_at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }), + })) + setMessages(prev => { + const existing = new Set(prev.map(m => m.id)) + const welcome = prev[0] + // Filter out server messages that we already have; keep welcome + const serverOnly = serverMsgs.filter(m => !existing.has(m.id) && m.id > 0) + // Merge sorted + const merged = [welcome, ...serverOnly].sort((a, b) => a.id - b.id) + return merged.length > 1 ? merged : prev + }) + } + } catch { /* ignore */ } + }, 2000) + return () => { if (pollRef.current) clearInterval(pollRef.current) } + } + }, [sessionId]) + + const sendMessage = async (text: string) => { + if (!text.trim() || sending) return + const content = text.trim() + setInput('') + setSending(true) + + // Add locally immediately + const localMsg: Message = { id: -Date.now(), sender: 'visitor', content, time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) } + setMessages(prev => [...prev, localMsg]) + + if (sessionId) { + try { + const res = await fetch('/api/widget/message', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ session_id: sessionId, content, type: 'text' }), + }) + const json = await res.json() + if (json.code !== 0) { + console.error('Send failed:', json.message) + } + } catch (e) { + console.error('Send error:', e) + } + } + setSending(false) + } + + const handleOpen = () => { + setOpen(true) + setShowRating(false) + setRated(false) } return ( <> - {/* 悬浮按钮 */} {!open && ( - )} - {/* 聊天窗口 */} {open && (
- {/* 顶部 */}
@@ -65,65 +125,48 @@ const VisitorChat = () => {
客服云
-
{typing ? '正在输入...' : '在线'}
+
在线
- + { setOpen(false); if (!rated) setShowRating(true) }} />
- {/* 消息区域 */}
{messages.map(msg => (
{msg.content} -
{msg.time}
+ {msg.time &&
{msg.time}
}
))} - {typing && ( -
-
-
- - - -
-
-
- )}
- {/* 快捷问题 */} {messages.length <= 1 && (
{quickQuestions.map((q, i) => ( - + ))}
)} - {/* 输入框 */}
- - + setInput(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') { sendMessage(input) } }} + onKeyDown={e => { if (e.key === 'Enter') sendMessage(input) }} + disabled={sending} /> sendMessage(input)} />
- {/* 满意度弹窗 */} {showRating && (
@@ -131,11 +174,8 @@ const VisitorChat = () => {
请对我们的服务进行评价
{[1, 2, 3, 4, 5].map(star => ( - { setRated(true); setShowRating(false) }} - /> + { setRated(true); setShowRating(false) }} /> ))}