diff --git a/docs/INTEGRATION_GUIDE.md b/docs/INTEGRATION_GUIDE.md index f8ef040..2b06e60 100644 --- a/docs/INTEGRATION_GUIDE.md +++ b/docs/INTEGRATION_GUIDE.md @@ -32,10 +32,14 @@ ### 接入效果 引入脚本后,页面右下角将自动挂载悬浮客服图标: +* **首次自动展开**:每个浏览器标签页首次加载时自动弹出聊天窗口并创建会话;同一标签页后续刷新不重复弹窗。 * **点击展开**:弹出响应式聊天窗口(默认 400×600px,移动端浏览器自动适配全屏)。 * **自动提醒**:客服回复时,访客端自动显示未读消息红点徽标与声音提示。 * **最小化与恢复**:访客可随时收起或重新唤起聊天窗,聊天记录在同一浏览器内自动保留。 +如果希望访客进站时只显示悬浮按钮、不自动展开,可在脚本标签增加 +`data-auto-open="false"`。 + --- ## 3. 自定义触发按钮(用您网页中的按钮打开客服) diff --git a/server/internal/handler/router.go b/server/internal/handler/router.go index 10e8c8c..e491e6b 100644 --- a/server/internal/handler/router.go +++ b/server/internal/handler/router.go @@ -56,12 +56,12 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S authRequired := api.Group("") authRequired.Use(middleware.AuthRequired()) { - // 当前用户 - authRequired.GET("/me", auth.Me) - authRequired.GET("/me/permissions", auth.MePermissions) - authRequired.PUT("/me/status", auth.UpdatePresence) - // 令牌续期(前端定时调用,防止长挂机后过期掉线) - authRequired.POST("/auth/refresh", auth.Refresh) + // 当前用户 + authRequired.GET("/me", auth.Me) + authRequired.GET("/me/permissions", auth.MePermissions) + authRequired.PUT("/me/status", auth.UpdatePresence) + // 令牌续期(前端定时调用,防止长挂机后过期掉线) + authRequired.POST("/auth/refresh", auth.Refresh) // WebSocket authRequired.GET("/ws", wsHandler.Connect) @@ -143,9 +143,13 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S // 租户系统设置 settingsGroup := authRequired.Group("/settings") settingsGroup.GET("", middleware.RequirePermission( - "settings.basic", "settings.assign_rule", "settings.auto_reply", "settings.worktime", "settings.notification", + "settings.basic", "settings.channel", "settings.staff", "settings.assign_rule", + "settings.customer_tag", "settings.auto_reply", "settings.worktime", "settings.notification", ), settings.Get) - settingsGroup.PUT("", middleware.RequirePermission("settings.basic"), settings.Update) + settingsGroup.PUT("", middleware.RequirePermission( + "settings.basic", "settings.channel", "settings.staff", "settings.assign_rule", + "settings.customer_tag", "settings.auto_reply", "settings.worktime", "settings.notification", + ), settings.Update) // 坐席账号(租户内) staffGroup := authRequired.Group("/staff") diff --git a/server/internal/handler/security_integration_test.go b/server/internal/handler/security_integration_test.go index 1f269c1..dde1ea7 100644 --- a/server/internal/handler/security_integration_test.go +++ b/server/internal/handler/security_integration_test.go @@ -810,6 +810,33 @@ func TestSettingsAndExportsHonorFineGrainedPermissions(t *testing.T) { t.Fatalf("缺少分配规则权限仍可修改设置: %d %s", settings.Code, settings.Body.String()) } + autoReplyRoleRec := httptest.NewRecorder() + autoReplyRoleBody := []byte(`{"name":"自动回复主管","permissions":["settings.auto_reply"],"data_scopes":{}}`) + router.ServeHTTP(autoReplyRoleRec, bearerRequest(t, http.MethodPost, "/api/roles", autoReplyRoleBody, admin)) + if autoReplyRoleRec.Code != http.StatusOK { + t.Fatalf("创建自动回复角色失败: %d %s", autoReplyRoleRec.Code, autoReplyRoleRec.Body.String()) + } + var autoReplyRoleResp struct { + Data model.Role `json:"data"` + } + if err := json.Unmarshal(autoReplyRoleRec.Body.Bytes(), &autoReplyRoleResp); err != nil { + t.Fatalf("解析自动回复角色失败: %v", err) + } + autoReplyUser := createUser(t, tenant.ID, "auto-reply-supervisor", autoReplyRoleResp.Data.Code) + + autoReplySave := httptest.NewRecorder() + autoReplyBody := []byte(`{"welcome_messages":[{"type":"text","content":"主管配置的欢迎语"}],"offline_prompt":"主管配置的离线提示"}`) + router.ServeHTTP(autoReplySave, bearerRequest(t, http.MethodPut, "/api/settings", autoReplyBody, autoReplyUser)) + if autoReplySave.Code != http.StatusOK { + t.Fatalf("自动回复权限应允许保存: %d %s", autoReplySave.Code, autoReplySave.Body.String()) + } + + basicSettingsSave := httptest.NewRecorder() + router.ServeHTTP(basicSettingsSave, bearerRequest(t, http.MethodPut, "/api/settings", []byte(`{"display_name":"越权修改"}`), autoReplyUser)) + if basicSettingsSave.Code != http.StatusForbidden { + t.Fatalf("自动回复权限不应允许修改基本设置: %d %s", basicSettingsSave.Code, basicSettingsSave.Body.String()) + } + customerOne := model.Customer{TenantID: tenant.ID, Name: "客户一"} customerTwo := model.Customer{TenantID: tenant.ID, Name: "客户二"} if err := model.DB.Create(&customerOne).Error; err != nil { @@ -1037,6 +1064,8 @@ func TestTenantSettingsPersistAndAffectWidgetPrompt(t *testing.T) { } var initResp struct { Data struct { + SessionID uint `json:"session_id"` + VisitorToken string `json:"visitor_token"` WelcomeMessage string `json:"welcome_message"` WelcomeMessages []struct { Type string `json:"type"` @@ -1058,4 +1087,40 @@ func TestTenantSettingsPersistAndAffectWidgetPrompt(t *testing.T) { if len(initResp.Data.WelcomeMessages) != 2 { t.Fatalf("widget 未返回多段欢迎语: %+v", initResp.Data.WelcomeMessages) } + + // 已存在的会话重新拉消息时,应同步最新的自动回复配置。 + updateRec := httptest.NewRecorder() + updateBody := `{"welcome_messages":[{"type":"text","content":"刷新后的欢迎语"}],"offline_prompt":"刷新后的离线提示"}` + router.ServeHTTP(updateRec, bearerRequest(t, http.MethodPut, "/api/settings", []byte(updateBody), admin)) + if updateRec.Code != http.StatusOK { + t.Fatalf("更新自动回复失败: %s", updateRec.Body.String()) + } + + messageReq := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/widget/messages?session_id=%d", initResp.Data.SessionID), nil) + messageReq.Header.Set("X-Visitor-Token", initResp.Data.VisitorToken) + messageRec := httptest.NewRecorder() + router.ServeHTTP(messageRec, messageReq) + if messageRec.Code != http.StatusOK { + t.Fatalf("读取已有会话消息失败: %s", messageRec.Body.String()) + } + var messageResp struct { + Data struct { + WelcomeMessage string `json:"welcome_message"` + WelcomeMessages []struct { + Type string `json:"type"` + Content string `json:"content"` + } `json:"welcome_messages"` + OfflinePrompt string `json:"offline_prompt"` + AgentsOnline bool `json:"agents_online"` + } `json:"data"` + } + if err := json.Unmarshal(messageRec.Body.Bytes(), &messageResp); err != nil { + t.Fatalf("解析已有会话消息响应失败: %v", err) + } + if messageResp.Data.WelcomeMessage != "刷新后的欢迎语" || + len(messageResp.Data.WelcomeMessages) != 1 || + messageResp.Data.OfflinePrompt != "刷新后的离线提示" || + messageResp.Data.AgentsOnline { + t.Fatalf("已有会话未同步最新自动回复: %+v", messageResp.Data) + } } diff --git a/server/internal/handler/settings.go b/server/internal/handler/settings.go index ac1e7d4..c511ef3 100644 --- a/server/internal/handler/settings.go +++ b/server/internal/handler/settings.go @@ -27,10 +27,6 @@ func (h *SettingsHandler) Get(c *gin.Context) { } func (h *SettingsHandler) Update(c *gin.Context) { - if !middleware.HasPermission(c, "settings.basic") { - c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅租户管理员可修改设置"}) - return - } tenantID := middleware.GetTenantID(c) if tenantID == 0 { c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅租户账号可修改设置"}) @@ -47,6 +43,10 @@ func (h *SettingsHandler) Update(c *gin.Context) { return } // 设置权限按字段分区校验,避免仅拥有基本设置权限即可修改分配、工作时间或通知策略。 + if req.TouchesBasic() && !middleware.HasPermission(c, "settings.basic") { + c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权修改基本设置"}) + return + } if req.TouchesAssignRule() && !middleware.HasPermission(c, "settings.assign_rule") { c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权修改客服分配规则"}) return diff --git a/server/internal/service/tenant_settings.go b/server/internal/service/tenant_settings.go index 2b3da72..f616da6 100644 --- a/server/internal/service/tenant_settings.go +++ b/server/internal/service/tenant_settings.go @@ -51,6 +51,15 @@ type WelcomeSegment struct { Content string `json:"content"` } +// WidgetReplyConfig 是访客端展示自动回复所需的租户配置。 +type WidgetReplyConfig struct { + WelcomeMessage string + WelcomeMessages []WelcomeSegment + OfflinePrompt string + AgentNickname string + DisplayName string +} + var weekdayKeys = []string{"monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"} func defaultWelcomeSegments() []WelcomeSegment { @@ -112,6 +121,37 @@ func ResolveWelcomeSegments(setting *model.TenantSetting) []WelcomeSegment { return defaultWelcomeSegments() } +// ResolveWidgetReplyConfig 将租户设置转换为访客端可直接使用的自动回复配置。 +func ResolveWidgetReplyConfig(setting *model.TenantSetting, withinWorkHours bool) WidgetReplyConfig { + config := WidgetReplyConfig{ + WelcomeMessage: DefaultWelcomeMessage, + WelcomeMessages: defaultWelcomeSegments(), + OfflinePrompt: DefaultOfflinePrompt, + AgentNickname: DefaultAgentNickname, + DisplayName: "在线客服", + } + if setting != nil { + config.WelcomeMessages = ResolveWelcomeSegments(setting) + config.WelcomeMessage = FirstWelcomeText(config.WelcomeMessages) + if setting.OfflinePrompt != "" { + config.OfflinePrompt = setting.OfflinePrompt + } + if setting.AgentNickname != "" { + config.AgentNickname = setting.AgentNickname + } + if setting.DisplayName != "" { + config.DisplayName = setting.DisplayName + } + } + if !withinWorkHours { + config.OfflinePrompt = DefaultWorktimePrompt + if setting != nil && setting.WorktimePrompt != "" { + config.OfflinePrompt = setting.WorktimePrompt + } + } + return config +} + // NormalizeWelcomeSegments 校验并规范化欢迎语段落;返回规范化列表与兼容用首段文本。 func NormalizeWelcomeSegments(input []WelcomeSegment) ([]WelcomeSegment, string, error) { if len(input) == 0 { @@ -343,6 +383,10 @@ type SettingsUpdateReq struct { // TouchesAssignRule / TouchesAutoReply / TouchesWorktime / TouchesNotification // 供 handler 按字段分区校验权限。 +func (r *SettingsUpdateReq) TouchesBasic() bool { + return r.DisplayName != nil || r.AgentNickname != nil || r.Timezone != nil +} + func (r *SettingsUpdateReq) TouchesAssignRule() bool { return r.AssignStrategy != nil || r.MaxActivePerAgent != nil } diff --git a/server/internal/service/widget.go b/server/internal/service/widget.go index 35207fe..483be6b 100644 --- a/server/internal/service/widget.go +++ b/server/internal/service/widget.go @@ -152,31 +152,14 @@ func WidgetInit(in WidgetInitInput) (map[string]interface{}, error) { if payload, err := ws.NewEvent("session_created", session.ID, session); err == nil { ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload) } + if setting == nil || setting.NotifyNewSession { + notice := map[string]string{"visitor_name": customer.Name} + if payload, err := ws.NewEvent("session_created_notify", session.ID, notice); err == nil { + ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload) + } + } - welcomeSegs := ResolveWelcomeSegments(setting) - welcome := FirstWelcomeText(welcomeSegs) - offlinePrompt := DefaultOfflinePrompt - agentNickname := DefaultAgentNickname - displayName := "在线客服" - worktimePrompt := DefaultWorktimePrompt - if setting != nil { - if setting.OfflinePrompt != "" { - offlinePrompt = setting.OfflinePrompt - } - if setting.AgentNickname != "" { - agentNickname = setting.AgentNickname - } - if setting.DisplayName != "" { - displayName = setting.DisplayName - } - if setting.WorktimePrompt != "" { - worktimePrompt = setting.WorktimePrompt - } - } - // 非工作时间优先展示非工作时间提示 - if !withinWorkHours { - offlinePrompt = worktimePrompt - } + replyConfig := ResolveWidgetReplyConfig(setting, withinWorkHours) resp := map[string]interface{}{ "session_id": session.ID, @@ -188,19 +171,19 @@ func WidgetInit(in WidgetInitInput) (map[string]interface{}, error) { "online_agent_count": onlineCount, "within_work_hours": withinWorkHours, "session_status": session.Status, - "welcome_message": welcome, - "welcome_messages": welcomeSegs, - "display_name": displayName, - "agent_nickname": agentNickname, + "welcome_message": replyConfig.WelcomeMessage, + "welcome_messages": replyConfig.WelcomeMessages, + "display_name": replyConfig.DisplayName, + "agent_nickname": replyConfig.AgentNickname, } if !canServeNow { - resp["offline_prompt"] = offlinePrompt + resp["offline_prompt"] = replyConfig.OfflinePrompt } if assignedAgent != nil { resp["agent_id"] = assignedAgent.ID resp["agent_name"] = assignedAgent.Nickname } else if canServeNow { - resp["agent_name"] = agentNickname + resp["agent_name"] = replyConfig.AgentNickname } resp["landing_url"] = session.LandingURL resp["current_url"] = session.CurrentURL @@ -501,12 +484,27 @@ func WidgetMessages(session *model.Session, afterSeq, limit int) (map[string]int if err != nil { return nil, err } - return map[string]interface{}{ + setting, _ := GetOrCreateTenantSettings(session.TenantID) + withinWorkHours := IsWithinWorkHours(setting, time.Now()) + onlineCount, _ := CountOnlineAgents(session.TenantID) + canServeNow := session.Status == "active" || (onlineCount > 0 && withinWorkHours) + replyConfig := ResolveWidgetReplyConfig(setting, withinWorkHours) + + result := map[string]interface{}{ "messages": page.Messages, "after_seq": page.AfterSeq, "max_seq": page.MaxSeq, "has_more": page.HasMore, "session_status": session.Status, "satisfaction_score": session.SatisfactionScore, - }, nil + "welcome_message": replyConfig.WelcomeMessage, + "welcome_messages": replyConfig.WelcomeMessages, + "display_name": replyConfig.DisplayName, + "agent_nickname": replyConfig.AgentNickname, + "agents_online": canServeNow, + } + if !canServeNow { + result["offline_prompt"] = replyConfig.OfflinePrompt + } + return result, nil } diff --git a/web/public/widget.js b/web/public/widget.js index 0b7a033..371979a 100644 --- a/web/public/widget.js +++ b/web/public/widget.js @@ -18,6 +18,7 @@ console.warn('[kefu-widget] missing data-id on script tag'); return; } + var autoOpen = !(script && /^(false|0)$/i.test(script.getAttribute('data-auto-open') || '')); var src = script && script.src ? script.src : ''; var base = ''; @@ -139,6 +140,15 @@ } } + function shouldAutoOpenOnce() { + var key = 'kefu_widget_auto_opened_' + channelKey; + try { + if (window.sessionStorage.getItem(key) === '1') return false; + window.sessionStorage.setItem(key, '1'); + } catch (e) { /* privacy mode: fall back to opening on each page load */ } + return true; + } + btn.addEventListener('click', function () { setOpen(true); }); window.addEventListener('message', function (event) { @@ -188,6 +198,11 @@ function mount() { document.body.appendChild(btn); document.body.appendChild(panel); + if (autoOpen) { + window.setTimeout(function () { + if (!open && shouldAutoOpenOnce()) setOpen(true); + }, 600); + } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', mount); diff --git a/web/src/components/common/NotificationPreferences.tsx b/web/src/components/common/NotificationPreferences.tsx index 7dfb6e6..55b6c1a 100644 --- a/web/src/components/common/NotificationPreferences.tsx +++ b/web/src/components/common/NotificationPreferences.tsx @@ -137,7 +137,7 @@ export default function NotificationPreferences({ className = '' }: Notification