实现 P0 自动分配、离线留言与可嵌入 Widget SDK
- 会话创建时按负载自动分配在线客服,无客服则进入离线模式 - 新增 /api/widget/leave-message 沉淀联系方式与留言事件 - 访客端离线留言表单;工作台展示离线留言/自动分配事件 - 提供 public/widget.js + /widget/embed 嵌入方案
This commit is contained in:
@@ -68,7 +68,7 @@ func seed() {
|
|||||||
|
|
||||||
// Channels for Tenant 1
|
// Channels for Tenant 1
|
||||||
channels := []model.Channel{
|
channels := []model.Channel{
|
||||||
{TenantID: tenants[0].ID, Type: "web", Name: "网页聊天", Status: "enabled", ScriptCode: `<script src="https://cs.example.com/widget.js" data-id="WK_8a3f2e"></script>`},
|
{TenantID: tenants[0].ID, Type: "web", Name: "网页聊天", Status: "enabled", ScriptCode: `<script src="/widget.js" data-id="WK_8a3f2e"></script>`},
|
||||||
{TenantID: tenants[0].ID, Type: "wechat", Name: "微信公众号", Status: "enabled"},
|
{TenantID: tenants[0].ID, Type: "wechat", Name: "微信公众号", Status: "enabled"},
|
||||||
{TenantID: tenants[0].ID, Type: "app", Name: "APP内嵌", Status: "disabled"},
|
{TenantID: tenants[0].ID, Type: "app", Name: "APP内嵌", Status: "disabled"},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"kefu-sys/server/internal/model"
|
||||||
|
"kefu-sys/server/internal/ws"
|
||||||
|
)
|
||||||
|
|
||||||
|
const offlineLeavePrompt = "当前无客服在线,请留言并留下联系方式,我们上线后会尽快回复您。"
|
||||||
|
|
||||||
|
// pickLeastLoadedAgent 在在线一线客服中选择当前进行中会话最少的一位。
|
||||||
|
func pickLeastLoadedAgent(tenantID uint) (*model.User, error) {
|
||||||
|
var agents []model.User
|
||||||
|
if err := model.DB.Where("tenant_id = ? AND role = ? AND status = ?", tenantID, "agent", "online").
|
||||||
|
Order("id asc").Find(&agents).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(agents) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
best := &agents[0]
|
||||||
|
bestCount := int64(-1)
|
||||||
|
for i := range agents {
|
||||||
|
var count int64
|
||||||
|
if err := model.DB.Model(&model.Session{}).
|
||||||
|
Where("tenant_id = ? AND agent_id = ? AND status = ?", tenantID, agents[i].ID, "active").
|
||||||
|
Count(&count).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if bestCount < 0 || count < bestCount {
|
||||||
|
bestCount = count
|
||||||
|
best = &agents[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// countOnlineAgents 统计租户当前可接待的在线客服数。
|
||||||
|
func countOnlineAgents(tenantID uint) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
err := model.DB.Model(&model.User{}).
|
||||||
|
Where("tenant_id = ? AND role = ? AND status = ?", tenantID, "agent", "online").
|
||||||
|
Count(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// tryAutoAssign 将会话自动分配给负载最低的在线客服;无可分配客服时返回 nil。
|
||||||
|
func tryAutoAssign(session *model.Session) (*model.User, error) {
|
||||||
|
if session == nil || session.ID == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if session.Status != "waiting" || session.AgentID != nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
agent, err := pickLeastLoadedAgent(session.TenantID)
|
||||||
|
if err != nil || agent == nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := model.DB.Model(&model.Session{}).
|
||||||
|
Where("id = ? AND tenant_id = ? AND status = ? AND agent_id IS NULL", session.ID, session.TenantID, "waiting").
|
||||||
|
Updates(map[string]interface{}{
|
||||||
|
"agent_id": agent.ID,
|
||||||
|
"status": "active",
|
||||||
|
"last_read_seq": 0,
|
||||||
|
})
|
||||||
|
if result.Error != nil {
|
||||||
|
return nil, result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
// 并发下可能已被他人领取
|
||||||
|
if err := model.DB.First(session, session.ID).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
session.AgentID = &agent.ID
|
||||||
|
session.Status = "active"
|
||||||
|
detail := fmt.Sprintf("系统自动分配给 %s", agent.Nickname)
|
||||||
|
model.DB.Create(&model.SessionEvent{
|
||||||
|
SessionID: session.ID,
|
||||||
|
OperatorID: 0,
|
||||||
|
Action: "auto_assign",
|
||||||
|
Detail: detail,
|
||||||
|
})
|
||||||
|
|
||||||
|
if payload, err := ws.NewEvent("session_updated", session.ID, session); err == nil {
|
||||||
|
ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload)
|
||||||
|
ws.DefaultHub.BroadcastToVisitor(session.TenantID, session.ID, payload)
|
||||||
|
}
|
||||||
|
return agent, nil
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ func SetupRoutes(r *gin.Engine) {
|
|||||||
widgetApi.POST("/init", widget.Init)
|
widgetApi.POST("/init", widget.Init)
|
||||||
widgetApi.GET("/init", widget.Init)
|
widgetApi.GET("/init", widget.Init)
|
||||||
widgetApi.POST("/message", widget.SendMessage)
|
widgetApi.POST("/message", widget.SendMessage)
|
||||||
|
widgetApi.POST("/leave-message", widget.LeaveMessage)
|
||||||
widgetApi.GET("/messages", widget.GetMessages)
|
widgetApi.GET("/messages", widget.GetMessages)
|
||||||
widgetApi.GET("/ws", widget.Connect)
|
widgetApi.GET("/ws", widget.Connect)
|
||||||
widgetApi.POST("/rating", widget.SubmitRating)
|
widgetApi.POST("/rating", widget.SubmitRating)
|
||||||
|
|||||||
@@ -438,3 +438,105 @@ func TestWorkbenchSessionLifecycleUnreadNotesTransferAndImage(t *testing.T) {
|
|||||||
t.Fatalf("会话领取/转接/已读状态错误: %+v", savedSession)
|
t.Fatalf("会话领取/转接/已读状态错误: %+v", savedSession)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWidgetAutoAssignAndOfflineLeave(t *testing.T) {
|
||||||
|
router := setupRouter(t)
|
||||||
|
tenant := createTenant(t, "自动分配租户", "normal")
|
||||||
|
channel := model.Channel{
|
||||||
|
TenantID: tenant.ID, Type: "web", Name: "网页", Status: "enabled",
|
||||||
|
ScriptCode: `<script data-id="WK_auto_001"></script>`,
|
||||||
|
}
|
||||||
|
if err := model.DB.Create(&channel).Error; err != nil {
|
||||||
|
t.Fatalf("创建渠道失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 无在线客服 → 离线模式
|
||||||
|
initRecorder := httptest.NewRecorder()
|
||||||
|
initReq := httptest.NewRequest(http.MethodPost, "/api/widget/init", bytes.NewBufferString(`{"channel_key":"WK_auto_001","visitor_name":"离线访客"}`))
|
||||||
|
initReq.Header.Set("Content-Type", "application/json")
|
||||||
|
router.ServeHTTP(initRecorder, initReq)
|
||||||
|
if initRecorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("离线初始化失败: %s", initRecorder.Body.String())
|
||||||
|
}
|
||||||
|
var offlineInit struct {
|
||||||
|
Data struct {
|
||||||
|
SessionID uint `json:"session_id"`
|
||||||
|
VisitorToken string `json:"visitor_token"`
|
||||||
|
AgentsOnline bool `json:"agents_online"`
|
||||||
|
OfflinePrompt string `json:"offline_prompt"`
|
||||||
|
SessionStatus string `json:"session_status"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(initRecorder.Body.Bytes(), &offlineInit); err != nil {
|
||||||
|
t.Fatalf("解析离线初始化失败: %v", err)
|
||||||
|
}
|
||||||
|
if offlineInit.Data.AgentsOnline || offlineInit.Data.OfflinePrompt == "" || offlineInit.Data.SessionStatus != "waiting" {
|
||||||
|
t.Fatalf("期望离线等待会话: %+v", offlineInit.Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
leaveRecorder := httptest.NewRecorder()
|
||||||
|
leaveBody := fmt.Sprintf(`{"session_id":%d,"content":"请回电处理订单问题","name":"张留言","phone":"13800138000","email":"leave@example.com"}`, offlineInit.Data.SessionID)
|
||||||
|
leaveReq := httptest.NewRequest(http.MethodPost, "/api/widget/leave-message", bytes.NewBufferString(leaveBody))
|
||||||
|
leaveReq.Header.Set("Content-Type", "application/json")
|
||||||
|
leaveReq.Header.Set("X-Visitor-Token", offlineInit.Data.VisitorToken)
|
||||||
|
router.ServeHTTP(leaveRecorder, leaveReq)
|
||||||
|
if leaveRecorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("离线留言失败: %s", leaveRecorder.Body.String())
|
||||||
|
}
|
||||||
|
var session model.Session
|
||||||
|
if err := model.DB.First(&session, offlineInit.Data.SessionID).Error; err != nil {
|
||||||
|
t.Fatalf("查询会话失败: %v", err)
|
||||||
|
}
|
||||||
|
var customer model.Customer
|
||||||
|
if err := model.DB.First(&customer, session.CustomerID).Error; err != nil {
|
||||||
|
t.Fatalf("查询客户失败: %v", err)
|
||||||
|
}
|
||||||
|
if customer.Phone != "13800138000" || customer.Email != "leave@example.com" || customer.Name != "张留言" {
|
||||||
|
t.Fatalf("联系方式未沉淀: %+v", customer)
|
||||||
|
}
|
||||||
|
var msgCount int64
|
||||||
|
model.DB.Model(&model.Message{}).Where("session_id = ?", session.ID).Count(&msgCount)
|
||||||
|
if msgCount != 1 {
|
||||||
|
t.Fatalf("留言消息数 = %d", msgCount)
|
||||||
|
}
|
||||||
|
var event model.SessionEvent
|
||||||
|
if err := model.DB.Where("session_id = ? AND action = ?", session.ID, "offline_leave").First(&event).Error; err != nil {
|
||||||
|
t.Fatalf("未记录离线留言事件: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 有在线客服 → 自动分配
|
||||||
|
agent := createUser(t, tenant.ID, "auto-agent-1", "agent")
|
||||||
|
init2 := httptest.NewRecorder()
|
||||||
|
init2Req := httptest.NewRequest(http.MethodPost, "/api/widget/init", bytes.NewBufferString(`{"channel_key":"WK_auto_001","visitor_name":"在线访客"}`))
|
||||||
|
init2Req.Header.Set("Content-Type", "application/json")
|
||||||
|
router.ServeHTTP(init2, init2Req)
|
||||||
|
if init2.Code != http.StatusOK {
|
||||||
|
t.Fatalf("在线初始化失败: %s", init2.Body.String())
|
||||||
|
}
|
||||||
|
var onlineInit struct {
|
||||||
|
Data struct {
|
||||||
|
SessionID uint `json:"session_id"`
|
||||||
|
AgentsOnline bool `json:"agents_online"`
|
||||||
|
SessionStatus string `json:"session_status"`
|
||||||
|
AgentID uint `json:"agent_id"`
|
||||||
|
AgentName string `json:"agent_name"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(init2.Body.Bytes(), &onlineInit); err != nil {
|
||||||
|
t.Fatalf("解析在线初始化失败: %v", err)
|
||||||
|
}
|
||||||
|
if !onlineInit.Data.AgentsOnline || onlineInit.Data.SessionStatus != "active" || onlineInit.Data.AgentID != agent.ID {
|
||||||
|
t.Fatalf("期望自动分配给在线客服: %+v agent=%d", onlineInit.Data, agent.ID)
|
||||||
|
}
|
||||||
|
var assigned model.Session
|
||||||
|
if err := model.DB.First(&assigned, onlineInit.Data.SessionID).Error; err != nil {
|
||||||
|
t.Fatalf("查询已分配会话失败: %v", err)
|
||||||
|
}
|
||||||
|
if assigned.AgentID == nil || *assigned.AgentID != agent.ID || assigned.Status != "active" {
|
||||||
|
t.Fatalf("会话分配状态不正确: %+v", assigned)
|
||||||
|
}
|
||||||
|
var assignEvent model.SessionEvent
|
||||||
|
if err := model.DB.Where("session_id = ? AND action = ?", assigned.ID, "auto_assign").First(&assignEvent).Error; err != nil {
|
||||||
|
t.Fatalf("未记录自动分配事件: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,7 +38,18 @@ type WidgetRatingReq struct {
|
|||||||
VisitorToken string `json:"visitor_token"`
|
VisitorToken string `json:"visitor_token"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WidgetLeaveReq struct {
|
||||||
|
SessionID uint `json:"session_id" binding:"required"`
|
||||||
|
Content string `json:"content" binding:"required"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Phone string `json:"phone"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
VisitorToken string `json:"visitor_token"`
|
||||||
|
}
|
||||||
|
|
||||||
var channelKeyPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{3,64}$`)
|
var channelKeyPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{3,64}$`)
|
||||||
|
var phonePattern = regexp.MustCompile(`^1[3-9]\d{9}$`)
|
||||||
|
var emailPattern = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)
|
||||||
|
|
||||||
func (h *WidgetHandler) Init(c *gin.Context) {
|
func (h *WidgetHandler) Init(c *gin.Context) {
|
||||||
var req WidgetInitReq
|
var req WidgetInitReq
|
||||||
@@ -68,7 +79,7 @@ func (h *WidgetHandler) Init(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
name := req.VisitorName
|
name := strings.TrimSpace(req.VisitorName)
|
||||||
if name == "" {
|
if name == "" {
|
||||||
name = "访客"
|
name = "访客"
|
||||||
}
|
}
|
||||||
@@ -107,20 +118,45 @@ func (h *WidgetHandler) Init(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
model.DB.Model(&model.Customer{}).Where("id = ? AND tenant_id = ?", customer.ID, channel.TenantID).
|
model.DB.Model(&model.Customer{}).Where("id = ? AND tenant_id = ?", customer.ID, channel.TenantID).
|
||||||
Updates(map[string]interface{}{"conversation_count": gorm.Expr("conversation_count + 1"), "last_contact_at": time.Now()})
|
Updates(map[string]interface{}{"conversation_count": gorm.Expr("conversation_count + 1"), "last_contact_at": time.Now()})
|
||||||
|
|
||||||
|
onlineCount, err := countOnlineAgents(channel.TenantID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询在线客服失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var assignedAgent *model.User
|
||||||
|
if onlineCount > 0 {
|
||||||
|
assignedAgent, err = tryAutoAssign(&session)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "自动分配失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if payload, err := ws.NewEvent("session_created", session.ID, session); err == nil {
|
if payload, err := ws.NewEvent("session_created", session.ID, session); err == nil {
|
||||||
ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload)
|
ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
agentsOnline := onlineCount > 0
|
||||||
"code": 0,
|
resp := gin.H{
|
||||||
"data": gin.H{
|
"session_id": session.ID,
|
||||||
"session_id": session.ID,
|
"customer_id": customer.ID,
|
||||||
"customer_id": customer.ID,
|
"channel_id": channel.ID,
|
||||||
"channel_id": channel.ID,
|
"tenant_id": channel.TenantID,
|
||||||
"tenant_id": channel.TenantID,
|
"visitor_token": visitorToken,
|
||||||
"visitor_token": visitorToken,
|
"agents_online": agentsOnline,
|
||||||
},
|
"session_status": session.Status,
|
||||||
})
|
}
|
||||||
|
if !agentsOnline {
|
||||||
|
resp["offline_prompt"] = offlineLeavePrompt
|
||||||
|
}
|
||||||
|
if assignedAgent != nil {
|
||||||
|
resp["agent_id"] = assignedAgent.ID
|
||||||
|
resp["agent_name"] = assignedAgent.Nickname
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "data": resp})
|
||||||
}
|
}
|
||||||
|
|
||||||
func visitorTokenFromRequest(c *gin.Context, bodyToken string) string {
|
func visitorTokenFromRequest(c *gin.Context, bodyToken string) string {
|
||||||
@@ -182,6 +218,14 @@ func (h *WidgetHandler) SendMessage(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
model.DB.Model(&model.Customer{}).Where("id = ? AND tenant_id = ?", session.CustomerID, session.TenantID).Update("last_contact_at", time.Now())
|
model.DB.Model(&model.Customer{}).Where("id = ? AND tenant_id = ?", session.CustomerID, session.TenantID).Update("last_contact_at", time.Now())
|
||||||
|
|
||||||
|
// 仍在排队时尝试自动分配(客服刚上线的场景)
|
||||||
|
if session.Status == "waiting" && session.AgentID == nil {
|
||||||
|
if _, err := tryAutoAssign(session); err == nil {
|
||||||
|
_ = model.DB.First(session, session.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if payload, err := ws.NewEvent("message", session.ID, msg); err == nil {
|
if payload, err := ws.NewEvent("message", session.ID, msg); err == nil {
|
||||||
if session.AgentID == nil {
|
if session.AgentID == nil {
|
||||||
// 等待会话的消息要通知全部客服,便于任一在线客服及时领取。
|
// 等待会话的消息要通知全部客服,便于任一在线客服及时领取。
|
||||||
@@ -194,6 +238,123 @@ func (h *WidgetHandler) SendMessage(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"code": 0, "data": msg})
|
c.JSON(http.StatusOK, gin.H{"code": 0, "data": msg})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LeaveMessage 无客服在线时提交留言并沉淀联系方式。
|
||||||
|
func (h *WidgetHandler) LeaveMessage(c *gin.Context) {
|
||||||
|
var req WidgetLeaveReq
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
content, err := validateMessageContent("text", req.Content)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(req.Name)
|
||||||
|
phone := strings.TrimSpace(req.Phone)
|
||||||
|
email := strings.TrimSpace(req.Email)
|
||||||
|
if phone == "" && email == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "请至少填写手机号或邮箱,便于我们回复"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if phone != "" && !phonePattern.MatchString(phone) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "手机号格式不正确"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if email != "" && !emailPattern.MatchString(email) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "邮箱格式不正确"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if name != "" && (len([]rune(name)) < 2 || len([]rune(name)) > 50) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "姓名需为 2 至 50 个字符"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session, ok := loadVisitorSession(c, req.SessionID, visitorTokenFromRequest(c, req.VisitorToken))
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if session.Status == "ended" || session.Status == "archived" {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "会话已结束,请重新发起咨询"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 若此刻已有客服在线,直接走自动分配,留言仍作为普通消息入库
|
||||||
|
onlineCount, _ := countOnlineAgents(session.TenantID)
|
||||||
|
if onlineCount > 0 && session.Status == "waiting" {
|
||||||
|
if _, err := tryAutoAssign(session); err == nil {
|
||||||
|
_ = model.DB.First(session, session.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updates := map[string]interface{}{"last_contact_at": time.Now()}
|
||||||
|
if name != "" {
|
||||||
|
updates["name"] = name
|
||||||
|
}
|
||||||
|
if phone != "" {
|
||||||
|
updates["phone"] = phone
|
||||||
|
}
|
||||||
|
if email != "" {
|
||||||
|
updates["email"] = email
|
||||||
|
}
|
||||||
|
if err := model.DB.Model(&model.Customer{}).Where("id = ? AND tenant_id = ?", session.CustomerID, session.TenantID).
|
||||||
|
Updates(updates).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "更新联系方式失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
custID := session.CustomerID
|
||||||
|
msg := model.Message{
|
||||||
|
SessionID: session.ID,
|
||||||
|
SenderType: "visitor",
|
||||||
|
SenderID: &custID,
|
||||||
|
Content: content,
|
||||||
|
Type: "text",
|
||||||
|
SentAt: time.Now(),
|
||||||
|
}
|
||||||
|
if err := model.CreateMessage(&msg); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "留言失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
contactBits := make([]string, 0, 3)
|
||||||
|
if name != "" {
|
||||||
|
contactBits = append(contactBits, "姓名:"+name)
|
||||||
|
}
|
||||||
|
if phone != "" {
|
||||||
|
contactBits = append(contactBits, "手机:"+phone)
|
||||||
|
}
|
||||||
|
if email != "" {
|
||||||
|
contactBits = append(contactBits, "邮箱:"+email)
|
||||||
|
}
|
||||||
|
model.DB.Create(&model.SessionEvent{
|
||||||
|
SessionID: session.ID,
|
||||||
|
OperatorID: 0,
|
||||||
|
Action: "offline_leave",
|
||||||
|
Detail: "离线留言 · " + strings.Join(contactBits, " · "),
|
||||||
|
})
|
||||||
|
|
||||||
|
if payload, err := ws.NewEvent("message", session.ID, msg); err == nil {
|
||||||
|
if session.AgentID == nil {
|
||||||
|
ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload)
|
||||||
|
} else {
|
||||||
|
ws.DefaultHub.BroadcastToSession(session.TenantID, session.ID, session.AgentID, payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if payload, err := ws.NewEvent("session_updated", session.ID, session); err == nil {
|
||||||
|
ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"code": 0,
|
||||||
|
"data": gin.H{
|
||||||
|
"message": msg,
|
||||||
|
"session_status": session.Status,
|
||||||
|
"agents_online": onlineCount > 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func visitorTokenFromWebSocket(c *gin.Context) string {
|
func visitorTokenFromWebSocket(c *gin.Context) string {
|
||||||
protocols := strings.Split(c.GetHeader("Sec-WebSocket-Protocol"), ",")
|
protocols := strings.Split(c.GetHeader("Sec-WebSocket-Protocol"), ",")
|
||||||
if len(protocols) == 2 && strings.TrimSpace(protocols[0]) == "kefu-visitor-v1" {
|
if len(protocols) == 2 && strings.TrimSpace(protocols[0]) == "kefu-visitor-v1" {
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* 客服云访客 Widget 嵌入脚本
|
||||||
|
* 用法: <script src="https://your-host/widget.js" data-id="WK_xxxx"></script>
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
if (window.__KEFU_WIDGET_LOADED__) return;
|
||||||
|
window.__KEFU_WIDGET_LOADED__ = true;
|
||||||
|
|
||||||
|
var script = document.currentScript;
|
||||||
|
if (!script) {
|
||||||
|
var scripts = document.getElementsByTagName('script');
|
||||||
|
script = scripts[scripts.length - 1];
|
||||||
|
}
|
||||||
|
var channelKey = (script && script.getAttribute('data-id')) || '';
|
||||||
|
if (!channelKey) {
|
||||||
|
console.warn('[kefu-widget] missing data-id on script tag');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var src = script && script.src ? script.src : '';
|
||||||
|
var base = '';
|
||||||
|
try {
|
||||||
|
var u = new URL(src, window.location.href);
|
||||||
|
base = u.origin;
|
||||||
|
} catch (e) {
|
||||||
|
base = window.location.origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
var open = false;
|
||||||
|
var iframe = null;
|
||||||
|
|
||||||
|
var btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.setAttribute('aria-label', '打开在线客服');
|
||||||
|
btn.style.cssText = [
|
||||||
|
'position:fixed', 'right:24px', 'bottom:24px', 'z-index:2147483000',
|
||||||
|
'width:56px', 'height:56px', 'border:none', 'border-radius:9999px',
|
||||||
|
'background:#2563eb', 'color:#fff', 'cursor:pointer',
|
||||||
|
'box-shadow:0 8px 24px rgba(0,0,0,0.12)',
|
||||||
|
'display:flex', 'align-items:center', 'justify-content:center',
|
||||||
|
'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",sans-serif',
|
||||||
|
].join(';');
|
||||||
|
btn.innerHTML = '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
|
||||||
|
|
||||||
|
var panel = document.createElement('div');
|
||||||
|
panel.style.cssText = [
|
||||||
|
'position:fixed', 'right:24px', 'bottom:24px', 'z-index:2147483001',
|
||||||
|
'width:400px', 'height:600px', 'max-width:calc(100vw - 32px)', 'max-height:calc(100vh - 32px)',
|
||||||
|
'border-radius:12px', 'overflow:hidden',
|
||||||
|
'box-shadow:0 8px 24px rgba(0,0,0,0.12)',
|
||||||
|
'display:none', 'background:#fff',
|
||||||
|
].join(';');
|
||||||
|
|
||||||
|
function ensureIframe() {
|
||||||
|
if (iframe) return;
|
||||||
|
iframe = document.createElement('iframe');
|
||||||
|
iframe.title = '在线客服';
|
||||||
|
iframe.allow = 'clipboard-write';
|
||||||
|
iframe.style.cssText = 'width:100%;height:100%;border:0;display:block;background:#fff;';
|
||||||
|
iframe.src = base + '/widget/embed?channel_key=' + encodeURIComponent(channelKey) + '&embedded=1';
|
||||||
|
panel.appendChild(iframe);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setOpen(next) {
|
||||||
|
open = next;
|
||||||
|
if (open) {
|
||||||
|
ensureIframe();
|
||||||
|
panel.style.display = 'block';
|
||||||
|
btn.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
panel.style.display = 'none';
|
||||||
|
btn.style.display = 'flex';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.addEventListener('click', function () { setOpen(true); });
|
||||||
|
|
||||||
|
window.addEventListener('message', function (event) {
|
||||||
|
if (!event || !event.data) return;
|
||||||
|
if (event.data.type === 'kefu-widget-close' || event.data.type === 'kefu-widget-minimize') {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function mount() {
|
||||||
|
document.body.appendChild(btn);
|
||||||
|
document.body.appendChild(panel);
|
||||||
|
}
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', mount);
|
||||||
|
} else {
|
||||||
|
mount();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.KefuWidget = {
|
||||||
|
open: function () { setOpen(true); },
|
||||||
|
close: function () { setOpen(false); },
|
||||||
|
channelKey: channelKey,
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useSearchParams } from 'react-router-dom'
|
||||||
|
import VisitorChat from '@/widgets/VisitorChat'
|
||||||
|
|
||||||
|
/** 供 widget.js iframe 嵌入的无边框页面 */
|
||||||
|
const WidgetEmbed = () => {
|
||||||
|
const [params] = useSearchParams()
|
||||||
|
const channelKey = useMemo(() => params.get('channel_key') || 'WK_8a3f2e', [params])
|
||||||
|
const embedded = params.get('embedded') === '1'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-screen w-screen overflow-hidden bg-transparent">
|
||||||
|
<VisitorChat
|
||||||
|
defaultOpen
|
||||||
|
channelKey={channelKey}
|
||||||
|
embedded={embedded}
|
||||||
|
layout={embedded ? 'fill' : 'floating'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WidgetEmbed
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import VisitorChat from '@/widgets/VisitorChat'
|
import VisitorChat from '@/widgets/VisitorChat'
|
||||||
|
|
||||||
const WidgetPreview = () => {
|
const WidgetPreview = () => {
|
||||||
|
const origin = typeof window !== 'undefined' ? window.location.origin : 'https://your-host'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-neutral-100 relative overflow-hidden">
|
<div className="min-h-screen bg-neutral-100 relative overflow-hidden">
|
||||||
{/* 模拟宿主站点骨架,贴近设计稿模糊背景 */}
|
|
||||||
<div className="max-w-[960px] mx-auto px-6 py-10 opacity-40 select-none pointer-events-none">
|
<div className="max-w-[960px] mx-auto px-6 py-10 opacity-40 select-none pointer-events-none">
|
||||||
<div className="h-10 w-3/5 bg-neutral-200 rounded mb-4" />
|
<div className="h-10 w-3/5 bg-neutral-200 rounded mb-4" />
|
||||||
<div className="h-4 w-[90%] bg-neutral-200 rounded mb-3" />
|
<div className="h-4 w-[90%] bg-neutral-200 rounded mb-3" />
|
||||||
@@ -15,16 +16,28 @@ const WidgetPreview = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="h-4 w-[85%] bg-neutral-200 rounded mb-3" />
|
<div className="h-4 w-[85%] bg-neutral-200 rounded mb-3" />
|
||||||
<div className="h-4 w-[70%] bg-neutral-200 rounded mb-3" />
|
<div className="h-4 w-[70%] bg-neutral-200 rounded mb-3" />
|
||||||
<div className="h-4 w-4/5 bg-neutral-200 rounded mb-6" />
|
|
||||||
<div className="h-[200px] w-full bg-neutral-200 rounded-lg" />
|
<div className="h-[200px] w-full bg-neutral-200 rounded-lg" />
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute inset-0 flex items-start justify-center pt-16 pointer-events-none">
|
|
||||||
<div className="text-center pointer-events-auto">
|
<div className="absolute top-6 left-1/2 -translate-x-1/2 w-full max-w-xl px-4 pointer-events-auto">
|
||||||
<h1 className="text-xl font-semibold text-neutral-700 mb-1">访客 Widget 预览</h1>
|
<div className="bg-white/95 backdrop-blur border border-neutral-200 rounded-xl shadow-sm p-4">
|
||||||
<p className="text-sm text-neutral-400">右下角为可嵌入聊天组件 · 消息会写入数据库</p>
|
<h1 className="text-base font-semibold text-neutral-800 m-0 mb-1">访客 Widget 预览</h1>
|
||||||
|
<p className="text-xs text-neutral-500 m-0 mb-3">
|
||||||
|
右下角为聊天组件。任意站点可嵌入下方脚本(iframe 版 SDK)。
|
||||||
|
</p>
|
||||||
|
<pre className="m-0 text-[11px] leading-relaxed bg-neutral-50 border border-neutral-100 rounded-lg p-3 overflow-x-auto text-neutral-700 whitespace-pre-wrap">
|
||||||
|
{`<script src="${origin}/widget.js" data-id="WK_8a3f2e"></script>`}
|
||||||
|
</pre>
|
||||||
|
<p className="text-[11px] text-neutral-400 mt-2 mb-0">
|
||||||
|
也可打开独立嵌入页:
|
||||||
|
<a className="text-blue-600 ml-1" href="/widget/embed?channel_key=WK_8a3f2e&embedded=1" target="_blank" rel="noreferrer">
|
||||||
|
/widget/embed
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<VisitorChat defaultOpen />
|
|
||||||
|
<VisitorChat defaultOpen channelKey="WK_8a3f2e" />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -253,7 +253,7 @@ const Dashboard = () => {
|
|||||||
return tb - ta
|
return tb - ta
|
||||||
})
|
})
|
||||||
|
|
||||||
const notes = detail?.events.filter(event => event.action === 'note').slice().reverse() || []
|
const notes = detail?.events.filter(event => event.action === 'note' || event.action === 'offline_leave' || event.action === 'auto_assign').slice().reverse() || []
|
||||||
|
|
||||||
const emitTyping = () => {
|
const emitTyping = () => {
|
||||||
if (!selectedId || !canOperate) return
|
if (!selectedId || !canOperate) return
|
||||||
@@ -899,12 +899,25 @@ const Dashboard = () => {
|
|||||||
<div className="space-y-2 max-h-40 overflow-auto">
|
<div className="space-y-2 max-h-40 overflow-auto">
|
||||||
{notes.length === 0 ? (
|
{notes.length === 0 ? (
|
||||||
<div className="text-xs text-neutral-400">暂无内部备注</div>
|
<div className="text-xs text-neutral-400">暂无内部备注</div>
|
||||||
) : notes.map(note => (
|
) : notes.map(note => {
|
||||||
<div key={note.id} className="bg-amber-50 text-amber-900 rounded-lg p-2 text-xs whitespace-pre-wrap border border-amber-100">
|
const isLeave = note.action === 'offline_leave'
|
||||||
{note.detail}
|
const isAssign = note.action === 'auto_assign'
|
||||||
<div className="text-amber-600/70 mt-1">{new Date(note.created_at).toLocaleString('zh-CN')}</div>
|
const box = isLeave
|
||||||
</div>
|
? 'bg-orange-50 text-orange-900 border-orange-100'
|
||||||
))}
|
: isAssign
|
||||||
|
? 'bg-blue-50 text-blue-900 border-blue-100'
|
||||||
|
: 'bg-amber-50 text-amber-900 border-amber-100'
|
||||||
|
const timeCls = isLeave ? 'text-orange-600/70' : isAssign ? 'text-blue-600/70' : 'text-amber-600/70'
|
||||||
|
return (
|
||||||
|
<div key={note.id} className={`rounded-lg p-2 text-xs whitespace-pre-wrap border ${box}`}>
|
||||||
|
{(isLeave || isAssign) && (
|
||||||
|
<div className="font-medium mb-0.5">{isLeave ? '离线留言' : '自动分配'}</div>
|
||||||
|
)}
|
||||||
|
{note.detail}
|
||||||
|
<div className={`mt-1 ${timeCls}`}>{new Date(note.created_at).toLocaleString('zh-CN')}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
{canOperate && (
|
{canOperate && (
|
||||||
<div className="mt-2 flex gap-1">
|
<div className="mt-2 flex gap-1">
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const Tenants = lazy(() => import('@/pages/admin/Tenants'))
|
|||||||
const Plans = lazy(() => import('@/pages/admin/Plans'))
|
const Plans = lazy(() => import('@/pages/admin/Plans'))
|
||||||
const Ops = lazy(() => import('@/pages/admin/Ops'))
|
const Ops = lazy(() => import('@/pages/admin/Ops'))
|
||||||
const WidgetPreview = lazy(() => import('@/pages/WidgetPreview'))
|
const WidgetPreview = lazy(() => import('@/pages/WidgetPreview'))
|
||||||
|
const WidgetEmbed = lazy(() => import('@/pages/WidgetEmbed'))
|
||||||
|
|
||||||
const loading = (
|
const loading = (
|
||||||
<div className="h-full flex items-center justify-center">
|
<div className="h-full flex items-center justify-center">
|
||||||
@@ -31,6 +32,7 @@ function Lazy({ children }: { children: React.ReactNode }) {
|
|||||||
export const router = createBrowserRouter([
|
export const router = createBrowserRouter([
|
||||||
{ path: '/login', element: <Lazy><Login /></Lazy> },
|
{ path: '/login', element: <Lazy><Login /></Lazy> },
|
||||||
{ path: '/widget/preview', element: <Lazy><WidgetPreview /></Lazy> },
|
{ path: '/widget/preview', element: <Lazy><WidgetPreview /></Lazy> },
|
||||||
|
{ path: '/widget/embed', element: <Lazy><WidgetEmbed /></Lazy> },
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
element: <RequireAuth />,
|
element: <RequireAuth />,
|
||||||
|
|||||||
+393
-256
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||||
import {
|
import {
|
||||||
CloseOutlined, MessageOutlined, SmileOutlined, SendOutlined,
|
CloseOutlined, MessageOutlined, SmileOutlined, SendOutlined,
|
||||||
StarFilled, MinusOutlined, PictureOutlined, CustomerServiceOutlined,
|
StarFilled, MinusOutlined, PictureOutlined, CustomerServiceOutlined,
|
||||||
@@ -12,19 +12,36 @@ interface Message {
|
|||||||
type?: 'text' | 'image'
|
type?: 'text' | 'image'
|
||||||
}
|
}
|
||||||
|
|
||||||
const quickQuestions = ['查询订单状态', '退换货政策', '配送时效说明']
|
const defaultQuickQuestions = ['查询订单状态', '退换货政策', '配送时效说明']
|
||||||
const STORAGE_KEY = 'kefu_widget_session'
|
|
||||||
const VISITOR_TOKEN_KEY = 'kefu_widget_visitor_token'
|
|
||||||
|
|
||||||
const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
type LayoutMode = 'floating' | 'fill'
|
||||||
const [open, setOpen] = useState(defaultOpen)
|
|
||||||
|
interface VisitorChatProps {
|
||||||
|
defaultOpen?: boolean
|
||||||
|
channelKey?: string
|
||||||
|
/** 是否在 iframe 嵌入模式(关闭/最小化会 postMessage 给宿主) */
|
||||||
|
embedded?: boolean
|
||||||
|
layout?: LayoutMode
|
||||||
|
}
|
||||||
|
|
||||||
|
const VisitorChat = ({
|
||||||
|
defaultOpen = false,
|
||||||
|
channelKey = 'WK_8a3f2e',
|
||||||
|
embedded = false,
|
||||||
|
layout = 'floating',
|
||||||
|
}: 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 [sessionId, setSessionId] = useState<number | null>(() => {
|
||||||
const saved = localStorage.getItem(STORAGE_KEY)
|
const saved = localStorage.getItem(storageKey)
|
||||||
return saved ? Number(saved) : null
|
return saved ? Number(saved) : null
|
||||||
})
|
})
|
||||||
const [visitorToken, setVisitorToken] = useState<string>(() => localStorage.getItem(VISITOR_TOKEN_KEY) || '')
|
const [visitorToken, setVisitorToken] = useState<string>(() => localStorage.getItem(tokenKey) || '')
|
||||||
const [messages, setMessages] = useState<Message[]>(() => {
|
const [messages, setMessages] = useState<Message[]>(() => {
|
||||||
const saved = localStorage.getItem(STORAGE_KEY + '_msgs')
|
const saved = localStorage.getItem(msgsKey)
|
||||||
return saved ? JSON.parse(saved) : []
|
return saved ? JSON.parse(saved) : []
|
||||||
})
|
})
|
||||||
const [input, setInput] = useState('')
|
const [input, setInput] = useState('')
|
||||||
@@ -37,6 +54,13 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
|||||||
const [hoverStar, setHoverStar] = useState(0)
|
const [hoverStar, setHoverStar] = useState(0)
|
||||||
const [imagePreview, setImagePreview] = useState<string | null>(null)
|
const [imagePreview, setImagePreview] = useState<string | null>(null)
|
||||||
const [sendError, setSendError] = useState('')
|
const [sendError, setSendError] = useState('')
|
||||||
|
const [agentsOnline, setAgentsOnline] = useState(true)
|
||||||
|
const [offlinePrompt, setOfflinePrompt] = 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 pollRef = useRef<number | null>(null)
|
||||||
const initRef = useRef(false)
|
const initRef = useRef(false)
|
||||||
const typingTimerRef = useRef<number | null>(null)
|
const typingTimerRef = useRef<number | null>(null)
|
||||||
@@ -47,7 +71,7 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
|||||||
|
|
||||||
const loadMessages = useCallback(async (sid?: number, token?: string) => {
|
const loadMessages = useCallback(async (sid?: number, token?: string) => {
|
||||||
const s = sid || sessionId
|
const s = sid || sessionId
|
||||||
const visitorCredential = token || visitorToken || localStorage.getItem(VISITOR_TOKEN_KEY) || ''
|
const visitorCredential = token || visitorToken || localStorage.getItem(tokenKey) || ''
|
||||||
if (!s || !visitorCredential) return
|
if (!s || !visitorCredential) return
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/widget/messages?session_id=${s}`, {
|
const res = await fetch(`/api/widget/messages?session_id=${s}`, {
|
||||||
@@ -63,10 +87,10 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
|||||||
time: new Date(m.sent_at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
|
time: new Date(m.sent_at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
|
||||||
}))
|
}))
|
||||||
setMessages(msgs)
|
setMessages(msgs)
|
||||||
localStorage.setItem(STORAGE_KEY + '_msgs', JSON.stringify(msgs))
|
localStorage.setItem(msgsKey, JSON.stringify(msgs))
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}, [sessionId, visitorToken])
|
}, [sessionId, visitorToken, tokenKey, msgsKey])
|
||||||
|
|
||||||
const initSession = useCallback(async () => {
|
const initSession = useCallback(async () => {
|
||||||
if (sessionId && visitorToken) {
|
if (sessionId && visitorToken) {
|
||||||
@@ -76,21 +100,31 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
|||||||
if (initRef.current) return
|
if (initRef.current) return
|
||||||
initRef.current = true
|
initRef.current = true
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/widget/init?channel_key=WK_8a3f2e&visitor_name=客服云访客`, { method: 'POST' })
|
const res = await fetch(`/api/widget/init?channel_key=${encodeURIComponent(channelKey)}&visitor_name=${encodeURIComponent('访客')}`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
const json = await res.json()
|
const json = await res.json()
|
||||||
if (json.code === 0) {
|
if (json.code === 0) {
|
||||||
const sid = json.data.session_id
|
const data = json.data
|
||||||
const token = json.data.visitor_token
|
const sid = data.session_id
|
||||||
|
const token = data.visitor_token
|
||||||
setSessionId(sid)
|
setSessionId(sid)
|
||||||
setVisitorToken(token)
|
setVisitorToken(token)
|
||||||
localStorage.setItem(STORAGE_KEY, String(sid))
|
setAgentsOnline(Boolean(data.agents_online))
|
||||||
localStorage.setItem(VISITOR_TOKEN_KEY, token)
|
if (data.offline_prompt) setOfflinePrompt(data.offline_prompt)
|
||||||
|
if (data.agent_name) setAgentName(data.agent_name)
|
||||||
|
if (data.session_status === 'ended') setSessionEnded(true)
|
||||||
|
localStorage.setItem(storageKey, String(sid))
|
||||||
|
localStorage.setItem(tokenKey, token)
|
||||||
await loadMessages(sid, token)
|
await loadMessages(sid, token)
|
||||||
|
} else {
|
||||||
|
setSendError(json.message || '初始化会话失败')
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Init failed:', e)
|
console.error('Init failed:', e)
|
||||||
|
setSendError('连接客服失败,请稍后重试')
|
||||||
}
|
}
|
||||||
}, [sessionId, visitorToken, loadMessages])
|
}, [sessionId, visitorToken, loadMessages, channelKey, storageKey, tokenKey])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) initSession()
|
if (open) initSession()
|
||||||
@@ -115,7 +149,6 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
|||||||
try {
|
try {
|
||||||
const payload = JSON.parse(event.data)
|
const payload = JSON.parse(event.data)
|
||||||
if (payload.session_id === sessionId && payload.type === 'typing') {
|
if (payload.session_id === sessionId && payload.type === 'typing') {
|
||||||
// 仅展示客服侧输入状态
|
|
||||||
if (payload.data?.from && payload.data.from !== 'agent') return
|
if (payload.data?.from && payload.data.from !== 'agent') return
|
||||||
setAgentTyping(true)
|
setAgentTyping(true)
|
||||||
if (typingTimerRef.current) clearTimeout(typingTimerRef.current)
|
if (typingTimerRef.current) clearTimeout(typingTimerRef.current)
|
||||||
@@ -123,9 +156,15 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (payload.session_id === sessionId && (payload.type === 'message' || payload.type === 'session_updated')) {
|
if (payload.session_id === sessionId && (payload.type === 'message' || payload.type === 'session_updated')) {
|
||||||
if (payload.type === 'session_updated' && payload.data?.status === 'ended') {
|
if (payload.type === 'session_updated') {
|
||||||
setSessionEnded(true)
|
if (payload.data?.status === 'ended') {
|
||||||
setShowRating(true)
|
setSessionEnded(true)
|
||||||
|
setShowRating(true)
|
||||||
|
}
|
||||||
|
if (payload.data?.status === 'active') {
|
||||||
|
setAgentsOnline(true)
|
||||||
|
setAgentName(payload.data?.agent_name || agentName)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setAgentTyping(false)
|
setAgentTyping(false)
|
||||||
loadMessages(sessionId, visitorToken)
|
loadMessages(sessionId, visitorToken)
|
||||||
@@ -139,14 +178,20 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
|||||||
socketRef.current = null
|
socketRef.current = null
|
||||||
if (typingTimerRef.current) clearTimeout(typingTimerRef.current)
|
if (typingTimerRef.current) clearTimeout(typingTimerRef.current)
|
||||||
}
|
}
|
||||||
}, [sessionId, visitorToken, open, loadMessages])
|
}, [sessionId, visitorToken, open, loadMessages, agentName])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
}, [messages, agentTyping, open, imagePreview])
|
}, [messages, agentTyping, open, imagePreview])
|
||||||
|
|
||||||
|
const notifyHost = (type: 'kefu-widget-close' | 'kefu-widget-minimize') => {
|
||||||
|
if (embedded && window.parent && window.parent !== window) {
|
||||||
|
window.parent.postMessage({ type }, '*')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const emitTyping = () => {
|
const emitTyping = () => {
|
||||||
if (!sessionId || sessionEnded) return
|
if (!sessionId || sessionEnded || !agentsOnline) return
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
if (now - lastTypingAt.current < 1200 || socketRef.current?.readyState !== WebSocket.OPEN) return
|
if (now - lastTypingAt.current < 1200 || socketRef.current?.readyState !== WebSocket.OPEN) return
|
||||||
lastTypingAt.current = now
|
lastTypingAt.current = now
|
||||||
@@ -169,6 +214,10 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
|||||||
|
|
||||||
const sendMessage = async (text: string) => {
|
const sendMessage = async (text: string) => {
|
||||||
if (!text.trim() || sending || sessionEnded) return
|
if (!text.trim() || sending || sessionEnded) return
|
||||||
|
if (!agentsOnline) {
|
||||||
|
setSendError('当前无客服在线,请使用下方留言表单')
|
||||||
|
return
|
||||||
|
}
|
||||||
const content = text.trim()
|
const content = text.trim()
|
||||||
setInput('')
|
setInput('')
|
||||||
setSendError('')
|
setSendError('')
|
||||||
@@ -194,8 +243,42 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) => {
|
const handleImageFile = (file?: File | null) => {
|
||||||
if (!file || sessionEnded || !sessionId) return
|
if (!file || sessionEnded || !sessionId || !agentsOnline) return
|
||||||
if (!['image/jpeg', 'image/png', 'image/gif'].includes(file.type)) {
|
if (!['image/jpeg', 'image/png', 'image/gif'].includes(file.type)) {
|
||||||
setSendError('仅支持 jpg、png、gif 图片')
|
setSendError('仅支持 jpg、png、gif 图片')
|
||||||
return
|
return
|
||||||
@@ -211,7 +294,7 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sendImage = async () => {
|
const sendImage = async () => {
|
||||||
if (!imagePreview || sending || sessionEnded) return
|
if (!imagePreview || sending || sessionEnded || !agentsOnline) return
|
||||||
setSending(true)
|
setSending(true)
|
||||||
setSendError('')
|
setSendError('')
|
||||||
const localMsg: Message = {
|
const localMsg: Message = {
|
||||||
@@ -241,11 +324,13 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
|||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
|
notifyHost('kefu-widget-close')
|
||||||
if (sessionEnded && !rated) setShowRating(true)
|
if (sessionEnded && !rated) setShowRating(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMinimize = () => {
|
const handleMinimize = () => {
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
|
notifyHost('kefu-widget-minimize')
|
||||||
}
|
}
|
||||||
|
|
||||||
const submitRating = async (score: number) => {
|
const submitRating = async (score: number) => {
|
||||||
@@ -266,12 +351,290 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const showWelcome = messages.length === 0
|
const showWelcome = messages.length === 0 && !leaveSent
|
||||||
const showQuick = messages.length <= 1 && !sessionEnded
|
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">在线客服</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-center gap-3">
|
||||||
|
<div className="px-4 py-2 rounded-xl bg-white border border-neutral-200 max-w-[85%] text-center">
|
||||||
|
<p className="m-0 text-[13px] text-neutral-600 leading-normal">
|
||||||
|
{agentsOnline
|
||||||
|
? '您好!欢迎咨询,请问有什么可以帮您?'
|
||||||
|
: offlinePrompt}
|
||||||
|
</p>
|
||||||
|
</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 justify-end max-w-[85%] ml-auto">
|
||||||
|
<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' ? (
|
||||||
|
<img src={msg.content} alt="图片" className="max-w-[180px] rounded-lg block" />
|
||||||
|
) : (
|
||||||
|
<p className="m-0 whitespace-pre-wrap break-words">{msg.content}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</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={`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' ? (
|
||||||
|
<img src={msg.content} alt="图片" className="max-w-[180px] rounded-lg block" />
|
||||||
|
) : (
|
||||||
|
<p className="m-0 whitespace-pre-wrap break-words">{msg.content}</p>
|
||||||
|
)}
|
||||||
|
</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 relative px-4 py-3 bg-white border-t border-neutral-200">
|
||||||
|
<div
|
||||||
|
className="absolute top-0 left-0 right-0 h-[3px] pointer-events-none opacity-40"
|
||||||
|
style={{ background: 'linear-gradient(90deg, transparent 0%, #2563eb 50%, transparent 100%)' }}
|
||||||
|
/>
|
||||||
|
{sendError && <div className="mb-2 text-xs text-red-500">{sendError}</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{imagePreview && agentsOnline && (
|
||||||
|
<div className="mb-2 p-2 rounded-lg border border-neutral-200 bg-neutral-50 flex items-center gap-2">
|
||||||
|
<img src={imagePreview} alt="预览" className="w-14 h-14 object-cover rounded" />
|
||||||
|
<div className="flex-1 min-w-0 text-xs text-neutral-500">确认发送图片?</div>
|
||||||
|
<button type="button" className="text-xs text-neutral-400 border-0 bg-transparent cursor-pointer" onClick={() => setImagePreview(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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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={sessionEnded || !sessionId || sending}
|
||||||
|
>
|
||||||
|
<PictureOutlined className="text-base" />
|
||||||
|
</button>
|
||||||
|
<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" aria-label="表情">
|
||||||
|
<SmileOutlined className="text-base" />
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/gif"
|
||||||
|
className="hidden"
|
||||||
|
onChange={e => { handleImageFile(e.target.files?.[0]); e.currentTarget.value = '' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
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={
|
||||||
|
sessionEnded
|
||||||
|
? '会话已结束'
|
||||||
|
: !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 || sessionEnded}
|
||||||
|
/>
|
||||||
|
{agentsOnline ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => sendMessage(input)}
|
||||||
|
disabled={!input.trim() || sending || !sessionId || sessionEnded}
|
||||||
|
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 || sessionEnded}
|
||||||
|
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">
|
||||||
|
<div className="bg-white rounded-xl p-6 mx-8 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-4">请对我们的服务进行评价</div>
|
||||||
|
<div className="flex justify-center gap-1.5 mb-4">
|
||||||
|
{[1, 2, 3, 4, 5].map(star => (
|
||||||
|
<StarFilled
|
||||||
|
key={star}
|
||||||
|
className="text-2xl cursor-pointer transition-colors"
|
||||||
|
style={{ color: star <= hoverStar ? '#facc15' : '#e2e8f0' }}
|
||||||
|
onMouseEnter={() => setHoverStar(star)}
|
||||||
|
onMouseLeave={() => setHoverStar(0)}
|
||||||
|
onClick={() => submitRating(star)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</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"
|
||||||
|
rows={3}
|
||||||
|
maxLength={500}
|
||||||
|
value={ratingText}
|
||||||
|
onChange={e => setRatingText(e.target.value)}
|
||||||
|
placeholder="可选:写下您的服务感受"
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={() => setShowRating(false)} className="text-sm text-neutral-400 hover:text-neutral-600 border-0 bg-transparent cursor-pointer">
|
||||||
|
跳过
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!open && (
|
{!open && layout === 'floating' && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleOpen}
|
onClick={handleOpen}
|
||||||
@@ -282,233 +645,7 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
|||||||
<MessageOutlined className="text-xl" />
|
<MessageOutlined className="text-xl" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{panel}
|
||||||
{open && (
|
|
||||||
<div
|
|
||||||
className="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"
|
|
||||||
style={{ 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">在线客服</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 bg-[#16a34a]" />
|
|
||||||
{sessionEnded ? '会话已结束' : '正在为您服务'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
|
||||||
<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-center gap-3">
|
|
||||||
<div className="px-4 py-2 rounded-xl bg-white border border-neutral-200 max-w-[85%] text-center">
|
|
||||||
<p className="m-0 text-[13px] text-neutral-600 leading-normal">
|
|
||||||
您好!欢迎咨询,请问有什么可以帮您?
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showQuick && (
|
|
||||||
<div className="flex flex-wrap gap-2 justify-center">
|
|
||||||
{quickQuestions.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 justify-end max-w-[85%] ml-auto">
|
|
||||||
<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' ? (
|
|
||||||
<img src={msg.content} alt="图片" className="max-w-[180px] rounded-lg block" />
|
|
||||||
) : (
|
|
||||||
<p className="m-0 whitespace-pre-wrap break-words">{msg.content}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</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={`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' ? (
|
|
||||||
<img src={msg.content} alt="图片" className="max-w-[180px] rounded-lg block" />
|
|
||||||
) : (
|
|
||||||
<p className="m-0 whitespace-pre-wrap break-words">{msg.content}</p>
|
|
||||||
)}
|
|
||||||
</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 relative px-4 py-3 bg-white border-t border-neutral-200">
|
|
||||||
<div
|
|
||||||
className="absolute top-0 left-0 right-0 h-[3px] pointer-events-none opacity-40"
|
|
||||||
style={{ background: 'linear-gradient(90deg, transparent 0%, #2563eb 50%, transparent 100%)' }}
|
|
||||||
/>
|
|
||||||
{sendError && (
|
|
||||||
<div className="mb-2 text-xs text-red-500">{sendError}</div>
|
|
||||||
)}
|
|
||||||
{imagePreview && (
|
|
||||||
<div className="mb-2 p-2 rounded-lg border border-neutral-200 bg-neutral-50 flex items-center gap-2">
|
|
||||||
<img src={imagePreview} alt="预览" className="w-14 h-14 object-cover rounded" />
|
|
||||||
<div className="flex-1 min-w-0 text-xs text-neutral-500">确认发送图片?</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-xs text-neutral-400 hover:text-neutral-600 border-0 bg-transparent cursor-pointer"
|
|
||||||
onClick={() => setImagePreview(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>
|
|
||||||
)}
|
|
||||||
<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={sessionEnded || !sessionId || sending}
|
|
||||||
>
|
|
||||||
<PictureOutlined className="text-base" />
|
|
||||||
</button>
|
|
||||||
<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"
|
|
||||||
aria-label="表情"
|
|
||||||
>
|
|
||||||
<SmileOutlined className="text-base" />
|
|
||||||
</button>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/jpeg,image/png,image/gif"
|
|
||||||
className="hidden"
|
|
||||||
onChange={e => {
|
|
||||||
handleImageFile(e.target.files?.[0])
|
|
||||||
e.currentTarget.value = ''
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
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={sessionEnded ? '会话已结束' : sessionId ? '输入消息...' : '正在连接...'}
|
|
||||||
value={input}
|
|
||||||
onChange={e => { setInput(e.target.value); emitTyping() }}
|
|
||||||
onPaste={e => {
|
|
||||||
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') sendMessage(input) }}
|
|
||||||
disabled={sending || !sessionId || sessionEnded}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => sendMessage(input)}
|
|
||||||
disabled={!input.trim() || sending || !sessionId || sessionEnded}
|
|
||||||
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>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
{showRating && (
|
|
||||||
<div className="absolute inset-0 bg-black/40 flex items-center justify-center z-10">
|
|
||||||
<div className="bg-white rounded-xl p-6 mx-8 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-4">请对我们的服务进行评价</div>
|
|
||||||
<div className="flex justify-center gap-1.5 mb-4">
|
|
||||||
{[1, 2, 3, 4, 5].map(star => (
|
|
||||||
<StarFilled
|
|
||||||
key={star}
|
|
||||||
className="text-2xl cursor-pointer transition-colors"
|
|
||||||
style={{ color: star <= hoverStar ? '#facc15' : '#e2e8f0' }}
|
|
||||||
onMouseEnter={() => setHoverStar(star)}
|
|
||||||
onMouseLeave={() => setHoverStar(0)}
|
|
||||||
onClick={() => submitRating(star)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</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"
|
|
||||||
rows={3}
|
|
||||||
maxLength={500}
|
|
||||||
value={ratingText}
|
|
||||||
onChange={e => setRatingText(e.target.value)}
|
|
||||||
placeholder="可选:写下您的服务感受"
|
|
||||||
/>
|
|
||||||
<button type="button" onClick={() => setShowRating(false)} className="text-sm text-neutral-400 hover:text-neutral-600 border-0 bg-transparent cursor-pointer">
|
|
||||||
跳过
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user