From 318ba8a56399d6d4091923ba642813ca11eafbe4 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Wed, 15 Jul 2026 11:27:15 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E7=A7=9F=E6=88=B7=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E8=AE=BE=E7=BD=AE=E8=90=BD=E5=BA=93=EF=BC=9A=E6=AC=A2?= =?UTF-8?q?=E8=BF=8E=E8=AF=AD=E3=80=81=E5=B7=A5=E4=BD=9C=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E4=B8=8E=E9=80=9A=E7=9F=A5=E5=BC=80=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 TenantSetting 模型与 /api/settings 读写接口 - 设置页接通基本资料、自动回复、工作时间、通知偏好 - Widget 初始化使用欢迎语/离线提示,非工作时间不自动分配 - 补充设置持久化与 Widget 提示集成测试 --- server/internal/handler/router.go | 6 + .../handler/security_integration_test.go | 64 ++++ server/internal/handler/settings.go | 298 +++++++++++++++++ server/internal/handler/widget.go | 62 +++- server/internal/model/db.go | 1 + server/internal/model/models.go | 18 ++ web/src/pages/agent/Settings.tsx | 302 +++++++++++++----- web/src/services/api.ts | 19 ++ web/src/widgets/VisitorChat.tsx | 13 +- 9 files changed, 686 insertions(+), 97 deletions(-) create mode 100644 server/internal/handler/settings.go diff --git a/server/internal/handler/router.go b/server/internal/handler/router.go index 9c61019..e4cce33 100644 --- a/server/internal/handler/router.go +++ b/server/internal/handler/router.go @@ -13,6 +13,7 @@ func SetupRoutes(r *gin.Engine) { stats := NewStatisticsHandler() admin := NewAdminHandler() channel := NewChannelHandler() + settings := NewSettingsHandler() ws := NewWsHandler() widget := NewWidgetHandler() @@ -75,6 +76,11 @@ func SetupRoutes(r *gin.Engine) { channels.POST("", channel.Create) channels.PUT("/:id", channel.Update) + // 租户系统设置 + settingsGroup := authRequired.Group("/settings") + settingsGroup.GET("", settings.Get) + settingsGroup.PUT("", settings.Update) + // 统计 statistics := authRequired.Group("/statistics") statistics.GET("/kpi", stats.KPIs) diff --git a/server/internal/handler/security_integration_test.go b/server/internal/handler/security_integration_test.go index 290e745..631e124 100644 --- a/server/internal/handler/security_integration_test.go +++ b/server/internal/handler/security_integration_test.go @@ -682,3 +682,67 @@ func TestAdminTenantLifecycleAndPlanToggle(t *testing.T) { t.Fatalf("套餐状态未更新: %+v", updatedPlan) } } + +func TestTenantSettingsPersistAndAffectWidgetPrompt(t *testing.T) { + router := setupRouter(t) + tenant := createTenant(t, "设置租户", "normal") + admin := createUser(t, tenant.ID, "settings-admin", "admin") + channel := model.Channel{ + TenantID: tenant.ID, Type: "web", Name: "网页", Status: "enabled", + ScriptCode: ``, + } + if err := model.DB.Create(&channel).Error; err != nil { + t.Fatalf("创建渠道失败: %v", err) + } + + // 保存欢迎语与离线提示 + saveRec := httptest.NewRecorder() + saveBody := `{"welcome_message":"定制欢迎语ABC","offline_prompt":"定制离线提示XYZ","work_hours":{"monday":"全天","tuesday":"全天","wednesday":"全天","thursday":"全天","friday":"全天","saturday":"全天","sunday":"全天"}}` + router.ServeHTTP(saveRec, bearerRequest(t, http.MethodPut, "/api/settings", []byte(saveBody), admin)) + if saveRec.Code != http.StatusOK { + t.Fatalf("保存设置失败: %s", saveRec.Body.String()) + } + + getRec := httptest.NewRecorder() + router.ServeHTTP(getRec, bearerRequest(t, http.MethodGet, "/api/settings", nil, admin)) + if getRec.Code != http.StatusOK { + t.Fatalf("读取设置失败: %s", getRec.Body.String()) + } + var getResp struct { + Data struct { + WelcomeMessage string `json:"welcome_message"` + OfflinePrompt string `json:"offline_prompt"` + } `json:"data"` + } + if err := json.Unmarshal(getRec.Body.Bytes(), &getResp); err != nil { + t.Fatalf("解析设置失败: %v", err) + } + if getResp.Data.WelcomeMessage != "定制欢迎语ABC" || getResp.Data.OfflinePrompt != "定制离线提示XYZ" { + t.Fatalf("设置未持久化: %+v", getResp.Data) + } + + // 无在线客服时 init 应返回定制离线提示 + initRec := httptest.NewRecorder() + initReq := httptest.NewRequest(http.MethodPost, "/api/widget/init", bytes.NewBufferString(`{"channel_key":"WK_set_001"}`)) + initReq.Header.Set("Content-Type", "application/json") + router.ServeHTTP(initRec, initReq) + if initRec.Code != http.StatusOK { + t.Fatalf("widget init 失败: %s", initRec.Body.String()) + } + var initResp struct { + Data struct { + WelcomeMessage string `json:"welcome_message"` + OfflinePrompt string `json:"offline_prompt"` + AgentsOnline bool `json:"agents_online"` + } `json:"data"` + } + if err := json.Unmarshal(initRec.Body.Bytes(), &initResp); err != nil { + t.Fatalf("解析 init 失败: %v", err) + } + if initResp.Data.AgentsOnline { + t.Fatalf("无客服时应离线") + } + if initResp.Data.WelcomeMessage != "定制欢迎语ABC" || initResp.Data.OfflinePrompt != "定制离线提示XYZ" { + t.Fatalf("widget 未使用租户设置: %+v", initResp.Data) + } +} diff --git a/server/internal/handler/settings.go b/server/internal/handler/settings.go new file mode 100644 index 0000000..ded8ead --- /dev/null +++ b/server/internal/handler/settings.go @@ -0,0 +1,298 @@ +package handler + +import ( + "encoding/json" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + "kefu-sys/server/internal/middleware" + "kefu-sys/server/internal/model" +) + +type SettingsHandler struct{} + +func NewSettingsHandler() *SettingsHandler { return &SettingsHandler{} } + +const ( + defaultWelcomeMessage = "您好!欢迎咨询,请问有什么可以帮您?" + defaultOfflinePrompt = "当前无客服在线,请留言并留下联系方式,我们上线后会尽快回复您。" + defaultWorktimePrompt = "当前为非工作时间,我们会在工作时段尽快回复您。" + defaultAgentNickname = "在线客服" + defaultTimezone = "Asia/Shanghai" +) + +var weekdayKeys = []string{"monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"} + +func defaultWorkHoursJSON() string { + // 默认全周全天可接待,租户可在设置中收紧为工作日时段 + hours := map[string]string{ + "monday": "全天", "tuesday": "全天", "wednesday": "全天", + "thursday": "全天", "friday": "全天", "saturday": "全天", "sunday": "全天", + } + b, _ := json.Marshal(hours) + return string(b) +} + +func getOrCreateTenantSettings(tenantID uint) (*model.TenantSetting, error) { + var setting model.TenantSetting + err := model.DB.Where("tenant_id = ?", tenantID).First(&setting).Error + if err == nil { + return &setting, nil + } + + var tenant model.Tenant + _ = model.DB.First(&tenant, tenantID).Error + displayName := tenant.Name + if displayName == "" { + displayName = "在线客服" + } + setting = model.TenantSetting{ + TenantID: tenantID, + DisplayName: displayName, + AgentNickname: defaultAgentNickname, + Timezone: defaultTimezone, + WelcomeMessage: defaultWelcomeMessage, + OfflinePrompt: defaultOfflinePrompt, + WorkHoursJSON: defaultWorkHoursJSON(), + WorktimePrompt: defaultWorktimePrompt, + NotifyNewSession: true, + NotifyOfflineLeave: true, + NotifyDailyReport: false, + } + if err := model.DB.Create(&setting).Error; err != nil { + return nil, err + } + return &setting, nil +} + +func parseWorkHours(raw string) map[string]string { + result := map[string]string{} + if strings.TrimSpace(raw) == "" { + _ = json.Unmarshal([]byte(defaultWorkHoursJSON()), &result) + return result + } + if err := json.Unmarshal([]byte(raw), &result); err != nil { + _ = json.Unmarshal([]byte(defaultWorkHoursJSON()), &result) + } + return result +} + +// isWithinWorkHours 根据租户工作时间配置判断当前是否在工作时段。 +// 未配置或某日为空视为休息;时段格式 HH:MM-HH:MM。 +func isWithinWorkHours(setting *model.TenantSetting, now time.Time) bool { + if setting == nil { + return true + } + loc, err := time.LoadLocation(setting.Timezone) + if err != nil || setting.Timezone == "" { + loc, _ = time.LoadLocation(defaultTimezone) + } + local := now.In(loc) + hours := parseWorkHours(setting.WorkHoursJSON) + var key string + switch local.Weekday() { + case time.Monday: + key = "monday" + case time.Tuesday: + key = "tuesday" + case time.Wednesday: + key = "wednesday" + case time.Thursday: + key = "thursday" + case time.Friday: + key = "friday" + case time.Saturday: + key = "saturday" + default: + key = "sunday" + } + slot := strings.TrimSpace(hours[key]) + if slot == "" || slot == "休息" { + return false + } + if slot == "全天" { + return true + } + parts := strings.Split(slot, "-") + if len(parts) != 2 { + return true + } + start, err1 := time.ParseInLocation("15:04", strings.TrimSpace(parts[0]), loc) + end, err2 := time.ParseInLocation("15:04", strings.TrimSpace(parts[1]), loc) + if err1 != nil || err2 != nil { + return true + } + // 组合到当天日期 + startAt := time.Date(local.Year(), local.Month(), local.Day(), start.Hour(), start.Minute(), 0, 0, loc) + endAt := time.Date(local.Year(), local.Month(), local.Day(), end.Hour(), end.Minute(), 0, 0, loc) + if !endAt.After(startAt) { + // 跨午夜:当前 >= start 或 < end + return !local.Before(startAt) || local.Before(endAt) + } + return !local.Before(startAt) && local.Before(endAt) +} + +func settingsResponse(setting *model.TenantSetting) gin.H { + return gin.H{ + "tenant_id": setting.TenantID, + "display_name": setting.DisplayName, + "agent_nickname": setting.AgentNickname, + "timezone": setting.Timezone, + "welcome_message": setting.WelcomeMessage, + "offline_prompt": setting.OfflinePrompt, + "work_hours": parseWorkHours(setting.WorkHoursJSON), + "worktime_prompt": setting.WorktimePrompt, + "notify_new_session": setting.NotifyNewSession, + "notify_offline_leave": setting.NotifyOfflineLeave, + "notify_daily_report": setting.NotifyDailyReport, + } +} + +func (h *SettingsHandler) Get(c *gin.Context) { + tenantID := middleware.GetTenantID(c) + if tenantID == 0 { + c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅租户账号可查看设置"}) + return + } + setting, err := getOrCreateTenantSettings(tenantID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "加载设置失败"}) + return + } + middleware.JSON(c, settingsResponse(setting)) +} + +type updateSettingsReq struct { + DisplayName *string `json:"display_name"` + AgentNickname *string `json:"agent_nickname"` + Timezone *string `json:"timezone"` + WelcomeMessage *string `json:"welcome_message"` + OfflinePrompt *string `json:"offline_prompt"` + WorkHours map[string]string `json:"work_hours"` + WorktimePrompt *string `json:"worktime_prompt"` + NotifyNewSession *bool `json:"notify_new_session"` + NotifyOfflineLeave *bool `json:"notify_offline_leave"` + NotifyDailyReport *bool `json:"notify_daily_report"` +} + +func (h *SettingsHandler) Update(c *gin.Context) { + if !middleware.HasAnyRole(c, "admin") { + 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": "仅租户账号可修改设置"}) + return + } + setting, err := getOrCreateTenantSettings(tenantID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "加载设置失败"}) + return + } + + var req updateSettingsReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) + return + } + + updates := map[string]interface{}{} + if req.DisplayName != nil { + name := strings.TrimSpace(*req.DisplayName) + if name == "" || len([]rune(name)) > 100 { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "租户显示名称无效"}) + return + } + updates["display_name"] = name + // 同步更新租户主表名称(唯一约束下若冲突则仅更新 setting) + var conflict int64 + model.DB.Model(&model.Tenant{}).Where("name = ? AND id <> ?", name, tenantID).Count(&conflict) + if conflict == 0 { + model.DB.Model(&model.Tenant{}).Where("id = ?", tenantID).Update("name", name) + } + } + if req.AgentNickname != nil { + nick := strings.TrimSpace(*req.AgentNickname) + if len([]rune(nick)) > 50 { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "客服昵称过长"}) + return + } + updates["agent_nickname"] = nick + } + if req.Timezone != nil { + tz := strings.TrimSpace(*req.Timezone) + if tz == "" { + tz = defaultTimezone + } + if _, err := time.LoadLocation(tz); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "时区无效"}) + return + } + updates["timezone"] = tz + } + if req.WelcomeMessage != nil { + msg := strings.TrimSpace(*req.WelcomeMessage) + if len([]rune(msg)) > 500 { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "欢迎语不能超过 500 字"}) + return + } + updates["welcome_message"] = msg + } + if req.OfflinePrompt != nil { + msg := strings.TrimSpace(*req.OfflinePrompt) + if len([]rune(msg)) > 500 { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "离线提示不能超过 500 字"}) + return + } + updates["offline_prompt"] = msg + } + if req.WorktimePrompt != nil { + msg := strings.TrimSpace(*req.WorktimePrompt) + if len([]rune(msg)) > 500 { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "非工作时间提示不能超过 500 字"}) + return + } + updates["worktime_prompt"] = msg + } + if req.WorkHours != nil { + normalized := map[string]string{} + for _, key := range weekdayKeys { + val := strings.TrimSpace(req.WorkHours[key]) + if val == "" || val == "休息" || val == "全天" || strings.Contains(val, "-") { + normalized[key] = val + continue + } + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "工作时间格式无效,请使用 09:00-18:00 / 全天 / 空"}) + return + } + b, err := json.Marshal(normalized) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "工作时间序列化失败"}) + return + } + updates["work_hours_json"] = string(b) + } + if req.NotifyNewSession != nil { + updates["notify_new_session"] = *req.NotifyNewSession + } + if req.NotifyOfflineLeave != nil { + updates["notify_offline_leave"] = *req.NotifyOfflineLeave + } + if req.NotifyDailyReport != nil { + updates["notify_daily_report"] = *req.NotifyDailyReport + } + if len(updates) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "没有可更新字段"}) + return + } + + if err := model.DB.Model(setting).Updates(updates).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "保存失败"}) + return + } + _ = model.DB.Where("tenant_id = ?", tenantID).First(setting).Error + middleware.JSON(c, settingsResponse(setting)) +} diff --git a/server/internal/handler/widget.go b/server/internal/handler/widget.go index 928f0d2..2a6b9ae 100644 --- a/server/internal/handler/widget.go +++ b/server/internal/handler/widget.go @@ -125,8 +125,13 @@ func (h *WidgetHandler) Init(c *gin.Context) { return } + setting, _ := getOrCreateTenantSettings(channel.TenantID) + withinWorkHours := isWithinWorkHours(setting, time.Now()) + // 非工作时间不自动分配,走留言/排队提示 + canServeNow := onlineCount > 0 && withinWorkHours + var assignedAgent *model.User - if onlineCount > 0 { + if canServeNow { assignedAgent, err = tryAutoAssign(&session) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "自动分配失败"}) @@ -138,22 +143,55 @@ func (h *WidgetHandler) Init(c *gin.Context) { ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload) } - agentsOnline := onlineCount > 0 - resp := gin.H{ - "session_id": session.ID, - "customer_id": customer.ID, - "channel_id": channel.ID, - "tenant_id": channel.TenantID, - "visitor_token": visitorToken, - "agents_online": agentsOnline, - "session_status": session.Status, + welcome := defaultWelcomeMessage + offlinePrompt := offlineLeavePrompt + agentNickname := defaultAgentNickname + displayName := "在线客服" + worktimePrompt := defaultWorktimePrompt + if setting != nil { + if setting.WelcomeMessage != "" { + welcome = setting.WelcomeMessage + } + 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 !agentsOnline { - resp["offline_prompt"] = offlineLeavePrompt + // 非工作时间优先展示非工作时间提示 + if !withinWorkHours { + offlinePrompt = worktimePrompt + } + + resp := gin.H{ + "session_id": session.ID, + "customer_id": customer.ID, + "channel_id": channel.ID, + "tenant_id": channel.TenantID, + "visitor_token": visitorToken, + "agents_online": canServeNow, + "online_agent_count": onlineCount, + "within_work_hours": withinWorkHours, + "session_status": session.Status, + "welcome_message": welcome, + "display_name": displayName, + "agent_nickname": agentNickname, + } + if !canServeNow { + resp["offline_prompt"] = offlinePrompt } if assignedAgent != nil { resp["agent_id"] = assignedAgent.ID resp["agent_name"] = assignedAgent.Nickname + } else if canServeNow { + resp["agent_name"] = agentNickname } c.JSON(http.StatusOK, gin.H{"code": 0, "data": resp}) diff --git a/server/internal/model/db.go b/server/internal/model/db.go index a2d47d0..caed790 100644 --- a/server/internal/model/db.go +++ b/server/internal/model/db.go @@ -41,5 +41,6 @@ func Migrate(db *gorm.DB) error { &Plan{}, &OperationLog{}, &Announcement{}, + &TenantSetting{}, ) } diff --git a/server/internal/model/models.go b/server/internal/model/models.go index 553e9b5..9e9cdbc 100644 --- a/server/internal/model/models.go +++ b/server/internal/model/models.go @@ -152,3 +152,21 @@ type Announcement struct { CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } + +// TenantSetting 租户级系统设置(欢迎语、工作时间、通知开关等)。 +type TenantSetting struct { + ID uint `gorm:"primaryKey" json:"id"` + TenantID uint `gorm:"uniqueIndex;not null" json:"tenant_id"` + DisplayName string `gorm:"size:100" json:"display_name"` + AgentNickname string `gorm:"size:50" json:"agent_nickname"` + Timezone string `gorm:"size:50;default:Asia/Shanghai" json:"timezone"` + WelcomeMessage string `gorm:"size:500" json:"welcome_message"` + OfflinePrompt string `gorm:"size:500" json:"offline_prompt"` + WorkHoursJSON string `gorm:"type:text" json:"work_hours_json"` + WorktimePrompt string `gorm:"size:500" json:"worktime_prompt"` + NotifyNewSession bool `gorm:"default:true" json:"notify_new_session"` + NotifyOfflineLeave bool `gorm:"default:true" json:"notify_offline_leave"` + NotifyDailyReport bool `gorm:"default:false" json:"notify_daily_report"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/web/src/pages/agent/Settings.tsx b/web/src/pages/agent/Settings.tsx index c5dc586..9997a2a 100644 --- a/web/src/pages/agent/Settings.tsx +++ b/web/src/pages/agent/Settings.tsx @@ -3,7 +3,10 @@ import { Form, Input, Switch, Select, Button, Card, Tag, message, Spin, Empty } import { LinkOutlined, WechatOutlined, PhoneOutlined, MailOutlined, MobileOutlined, CopyOutlined, PlusOutlined, } from '@ant-design/icons' -import { createChannel, getChannels, updateChannel, type Channel } from '@/services/api' +import { + createChannel, getChannels, getTenantSettings, updateChannel, updateTenantSettings, + type Channel, type TenantSettings, type WorkHours, +} from '@/services/api' const typeMeta: Record = { web: { icon: , label: '网页聊天' }, @@ -13,12 +16,34 @@ const typeMeta: Record = { email: { icon: , label: '邮件工单' }, } +const weekDays: { key: keyof WorkHours | string; label: string }[] = [ + { key: 'monday', label: '周一' }, + { key: 'tuesday', label: '周二' }, + { key: 'wednesday', label: '周三' }, + { key: 'thursday', label: '周四' }, + { key: 'friday', label: '周五' }, + { key: 'saturday', label: '周六' }, + { key: 'sunday', label: '周日' }, +] + +const hourOptions = [ + { value: '09:00-18:00', label: '09:00-18:00' }, + { value: '08:00-17:00', label: '08:00-17:00' }, + { value: '10:00-19:00', label: '10:00-19:00' }, + { value: '全天', label: '全天' }, +] + const Settings = () => { - const [activeTab, setActiveTab] = useState('channels') + const [activeTab, setActiveTab] = useState('basic') const [basicForm] = Form.useForm() const [autoReplyForm] = Form.useForm() + const [workForm] = Form.useForm() + const [notifyForm] = Form.useForm() const [channels, setChannels] = useState([]) + const [settings, setSettings] = useState(null) const [loadingChannels, setLoadingChannels] = useState(false) + const [loadingSettings, setLoadingSettings] = useState(false) + const [saving, setSaving] = useState(false) const [togglingId, setTogglingId] = useState(null) const tabItems = [ @@ -44,10 +69,56 @@ const Settings = () => { } } + const loadSettings = async () => { + setLoadingSettings(true) + try { + const res = await getTenantSettings() + const data = res.data + setSettings(data) + basicForm.setFieldsValue({ + display_name: data.display_name, + agent_nickname: data.agent_nickname, + timezone: data.timezone || 'Asia/Shanghai', + language: 'zh-CN', + }) + autoReplyForm.setFieldsValue({ + welcome_message: data.welcome_message, + offline_prompt: data.offline_prompt, + }) + workForm.setFieldsValue({ + worktime_prompt: data.worktime_prompt, + ...Object.fromEntries(weekDays.map(d => [d.key, data.work_hours?.[d.key] || undefined])), + }) + notifyForm.setFieldsValue({ + notify_new_session: data.notify_new_session, + notify_offline_leave: data.notify_offline_leave, + notify_daily_report: data.notify_daily_report, + }) + } catch (e) { + message.error(e instanceof Error ? e.message : '加载设置失败') + } finally { + setLoadingSettings(false) + } + } + useEffect(() => { if (activeTab === 'channels') loadChannels() + if (['basic', 'autoreply', 'worktime', 'notify'].includes(activeTab)) loadSettings() }, [activeTab]) + const savePartial = async (payload: Parameters[0], okText = '已保存') => { + setSaving(true) + try { + const res = await updateTenantSettings(payload) + setSettings(res.data) + message.success(okText) + } catch (e) { + message.error(e instanceof Error ? e.message : '保存失败') + } finally { + setSaving(false) + } + } + const copyScript = async (script: string) => { try { const origin = window.location.origin @@ -110,23 +181,40 @@ const Settings = () => {
{activeTab === 'basic' && ( -
- - - - - - - - - - - - -
+ {loadingSettings && !settings ? ( +
+ ) : ( +
savePartial({ + display_name: values.display_name, + agent_nickname: values.agent_nickname, + timezone: values.timezone, + })} + > + + + + + + + + + + + + +
+ )}
)} @@ -188,18 +276,11 @@ const Settings = () => {
)} {ch.type !== 'web' && ( -
- 本期仅网页渠道可完整接入,其他渠道预留开关。 -
+
本期仅网页渠道可完整接入,其他渠道预留开关。
)}
启用状态 - toggleChannel(ch, v)} - /> + toggleChannel(ch, v)} />
@@ -240,9 +321,9 @@ const Settings = () => {
-
当前生效:负载最低优先
+
当前生效:负载最低优先 + 工作时间
- 系统会在会话创建时,自动分配给当前「进行中会话」最少的在线客服。无在线客服时进入离线留言。 + 工作时间内,自动分配给进行中会话最少的在线客服;非工作时间或无客服在线时,访客可留言。
@@ -254,79 +335,140 @@ const Settings = () => {
-
-
-
-
熟客优先
-
后续版本可切换
-
- -
-
)} {activeTab === 'permission' && ( -

角色权限由系统内置(平台管理员 / 租户管理员 / 主管 / 一线客服),自定义角色将在后续版本开放。

+

+ 角色权限由系统内置(平台管理员 / 租户管理员 / 主管 / 一线客服),自定义角色将在后续版本开放。 +

)} {activeTab === 'autoreply' && ( -
- - - - - - - - - -
+ {loadingSettings && !settings ? ( +
+ ) : ( +
savePartial({ + welcome_message: values.welcome_message, + offline_prompt: values.offline_prompt, + }, '自动回复已保存')} + > + + + + + + + + + +
+ )}
)} {activeTab === 'worktime' && ( -

工作时段配置将在后续版本接通,当前默认全天可接待。

- {['周一', '周二', '周三', '周四', '周五', '周六', '周日'].map(day => ( -
- {day} - + +
+ ))} + + + + + + + + )}
)} {activeTab === 'notify' && ( -
- {[ - { title: '新会话提醒', desc: '有新访客进线时桌面通知' }, - { title: '离线留言通知', desc: '访客提交离线留言时提醒' }, - { title: '日报推送', desc: '每日服务数据摘要' }, - ].map(item => ( -
-
-
{item.title}
-
{item.desc}
+ {loadingSettings && !settings ? ( +
+ ) : ( +
savePartial({ + notify_new_session: values.notify_new_session, + notify_offline_leave: values.notify_offline_leave, + notify_daily_report: values.notify_daily_report, + }, '通知设置已保存')} + > +
+
+
+
新会话提醒
+
有新访客进线时提醒(偏好开关,推送通道后续接入)
+
+ + + +
+
+
+
离线留言通知
+
访客提交离线留言时提醒
+
+ + + +
+
+
+
日报推送
+
每日服务数据摘要
+
+ + +
-
- ))} -
通知推送配置将在后续版本接通。
-
+ + + + + )} )}
diff --git a/web/src/services/api.ts b/web/src/services/api.ts index 0ae5323..6b901ef 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -142,6 +142,25 @@ export const getChannels = () => get('/channels') export const createChannel = (data: { type: string; name?: string }) => post('/channels', data) export const updateChannel = (id: number, data: { name?: string; status?: string }) => put(`/channels/${id}`, data) +// Tenant settings +export type WorkHours = Record +export interface TenantSettings { + tenant_id: number + display_name: string + agent_nickname: string + timezone: string + welcome_message: string + offline_prompt: string + work_hours: WorkHours + worktime_prompt: string + notify_new_session: boolean + notify_offline_leave: boolean + notify_daily_report: boolean +} +export const getTenantSettings = () => get('/settings') +export const updateTenantSettings = (data: Partial & { work_hours?: WorkHours }) => + put('/settings', data) + // Statistics export const getKPIs = () => get('/statistics/kpi') export const getSessionTrend = (period: 'today' | 'day' | 'month' = 'day') => get<{ date: string; count: number }[]>(`/statistics/trend?period=${period}`) diff --git a/web/src/widgets/VisitorChat.tsx b/web/src/widgets/VisitorChat.tsx index 35be5be..40d28d6 100644 --- a/web/src/widgets/VisitorChat.tsx +++ b/web/src/widgets/VisitorChat.tsx @@ -56,6 +56,8 @@ const VisitorChat = ({ const [sendError, setSendError] = useState('') const [agentsOnline, setAgentsOnline] = useState(true) const [offlinePrompt, setOfflinePrompt] = useState('当前无客服在线,请留言并留下联系方式,我们上线后会尽快回复您。') + const [welcomeMessage, setWelcomeMessage] = useState('您好!欢迎咨询,请问有什么可以帮您?') + const [displayName, setDisplayName] = useState('在线客服') const [agentName, setAgentName] = useState('') const [leaveName, setLeaveName] = useState('') const [leavePhone, setLeavePhone] = useState('') @@ -112,7 +114,10 @@ const VisitorChat = ({ setVisitorToken(token) setAgentsOnline(Boolean(data.agents_online)) if (data.offline_prompt) setOfflinePrompt(data.offline_prompt) + if (data.welcome_message) setWelcomeMessage(data.welcome_message) + if (data.display_name) setDisplayName(data.display_name) if (data.agent_name) setAgentName(data.agent_name) + else if (data.agent_nickname) setAgentName(data.agent_nickname) if (data.session_status === 'ended') setSessionEnded(true) localStorage.setItem(storageKey, String(sid)) localStorage.setItem(tokenKey, token) @@ -365,7 +370,7 @@ const VisitorChat = ({
-

在线客服

+

{displayName || '在线客服'}

@@ -405,9 +410,7 @@ const VisitorChat = ({

- {agentsOnline - ? '您好!欢迎咨询,请问有什么可以帮您?' - : offlinePrompt} + {agentsOnline ? welcomeMessage : offlinePrompt}