From 24fac1bcf125ddc167c55c85c302b8ce73446c78 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 3 Nov 2026 19:29:32 +0800 Subject: [PATCH] =?UTF-8?q?fix(widget):=20=E5=AE=8C=E5=96=84=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=9B=9E=E5=A4=8D=E4=B8=8E=E6=96=B0=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E8=BF=9B=E7=BA=BF=E6=8F=90=E9=86=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/INTEGRATION_GUIDE.md | 4 ++ server/internal/handler/router.go | 20 +++--- .../handler/security_integration_test.go | 65 +++++++++++++++++++ server/internal/handler/settings.go | 8 +-- server/internal/service/tenant_settings.go | 44 +++++++++++++ server/internal/service/widget.go | 62 +++++++++--------- web/public/widget.js | 15 +++++ .../common/NotificationPreferences.tsx | 2 +- web/src/pages/agent/Settings.tsx | 36 ++++++---- web/src/pages/agent/settings/tabs.tsx | 17 ++--- web/src/router/index.tsx | 7 +- web/src/stores/notifications.tsx | 54 +++++++++++---- web/src/widgets/VisitorChat.tsx | 24 +++++-- web/src/widgets/visitor/MessageList.tsx | 30 +-------- web/src/widgets/visitor/types.ts | 2 - 15 files changed, 275 insertions(+), 115 deletions(-) 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
声音提醒
-
收到访客消息时播放提示音
+
新访客进线或收到访客消息时播放提示音
diff --git a/web/src/pages/agent/Settings.tsx b/web/src/pages/agent/Settings.tsx index e0f996f..dbcce66 100644 --- a/web/src/pages/agent/Settings.tsx +++ b/web/src/pages/agent/Settings.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { Button, Form, message } from 'antd' import { PlusOutlined } from '@ant-design/icons' import { @@ -24,11 +24,19 @@ import TagsSection from './settings/TagsSection' import TagModal from './settings/TagModal' const Settings = () => { - const { user } = useAuth() - const isAdmin = usePermission('settings.basic') + const { user, permissions } = useAuth() const canViewStaff = usePermission('settings.staff') + const canEditAssignment = usePermission('settings.assign_rule') - const [activeTab, setActiveTab] = useState('basic') + const visibleTabGroups = useMemo( + () => tabGroups + .map(group => ({ ...group, items: group.items.filter(item => permissions.has(item.permission)) })) + .filter(group => group.items.length > 0), + [permissions], + ) + const visibleTabItems = useMemo(() => visibleTabGroups.flatMap(group => group.items), [visibleTabGroups]) + + const [activeTab, setActiveTab] = useState(() => visibleTabItems[0]?.key || '') const [basicForm] = Form.useForm() const [autoReplyForm] = Form.useForm() const [workForm] = Form.useForm() @@ -60,7 +68,7 @@ const Settings = () => { const [savingTag, setSavingTag] = useState(false) const [tagForm] = Form.useForm() - const currentTab = tabItems.find(t => t.key === activeTab) || tabItems[0] + const currentTab = visibleTabItems.find(t => t.key === activeTab) || visibleTabItems[0] || tabItems[0] const loadCustomerTags = async () => { setLoadingTags(true) @@ -146,6 +154,12 @@ const Settings = () => { } } + useEffect(() => { + if (visibleTabItems.length > 0 && !visibleTabItems.some(item => item.key === activeTab)) { + setActiveTab(visibleTabItems[0].key) + } + }, [activeTab, visibleTabItems]) + useEffect(() => { if (activeTab === 'channels') loadChannels() if (['basic', 'autoreply', 'worktime', 'notify', 'assignment'].includes(activeTab)) loadSettings() @@ -284,7 +298,7 @@ const Settings = () => { ) : null - const currentGroup = tabGroups.find(g => g.items.some(i => i.key === activeTab)) + const currentGroup = visibleTabGroups.find(g => g.items.some(i => i.key === activeTab)) return (
@@ -298,7 +312,7 @@ const Settings = () => {
企业配置与业务规则