实现租户系统设置落库:欢迎语、工作时间与通知开关
- 新增 TenantSetting 模型与 /api/settings 读写接口 - 设置页接通基本资料、自动回复、工作时间、通知偏好 - Widget 初始化使用欢迎语/离线提示,非工作时间不自动分配 - 补充设置持久化与 Widget 提示集成测试
This commit is contained in:
@@ -13,6 +13,7 @@ func SetupRoutes(r *gin.Engine) {
|
|||||||
stats := NewStatisticsHandler()
|
stats := NewStatisticsHandler()
|
||||||
admin := NewAdminHandler()
|
admin := NewAdminHandler()
|
||||||
channel := NewChannelHandler()
|
channel := NewChannelHandler()
|
||||||
|
settings := NewSettingsHandler()
|
||||||
ws := NewWsHandler()
|
ws := NewWsHandler()
|
||||||
widget := NewWidgetHandler()
|
widget := NewWidgetHandler()
|
||||||
|
|
||||||
@@ -75,6 +76,11 @@ func SetupRoutes(r *gin.Engine) {
|
|||||||
channels.POST("", channel.Create)
|
channels.POST("", channel.Create)
|
||||||
channels.PUT("/:id", channel.Update)
|
channels.PUT("/:id", channel.Update)
|
||||||
|
|
||||||
|
// 租户系统设置
|
||||||
|
settingsGroup := authRequired.Group("/settings")
|
||||||
|
settingsGroup.GET("", settings.Get)
|
||||||
|
settingsGroup.PUT("", settings.Update)
|
||||||
|
|
||||||
// 统计
|
// 统计
|
||||||
statistics := authRequired.Group("/statistics")
|
statistics := authRequired.Group("/statistics")
|
||||||
statistics.GET("/kpi", stats.KPIs)
|
statistics.GET("/kpi", stats.KPIs)
|
||||||
|
|||||||
@@ -682,3 +682,67 @@ func TestAdminTenantLifecycleAndPlanToggle(t *testing.T) {
|
|||||||
t.Fatalf("套餐状态未更新: %+v", updatedPlan)
|
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: `<script data-id="WK_set_001"></script>`,
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -125,8 +125,13 @@ func (h *WidgetHandler) Init(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setting, _ := getOrCreateTenantSettings(channel.TenantID)
|
||||||
|
withinWorkHours := isWithinWorkHours(setting, time.Now())
|
||||||
|
// 非工作时间不自动分配,走留言/排队提示
|
||||||
|
canServeNow := onlineCount > 0 && withinWorkHours
|
||||||
|
|
||||||
var assignedAgent *model.User
|
var assignedAgent *model.User
|
||||||
if onlineCount > 0 {
|
if canServeNow {
|
||||||
assignedAgent, err = tryAutoAssign(&session)
|
assignedAgent, err = tryAutoAssign(&session)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "自动分配失败"})
|
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)
|
ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
agentsOnline := onlineCount > 0
|
welcome := defaultWelcomeMessage
|
||||||
resp := gin.H{
|
offlinePrompt := offlineLeavePrompt
|
||||||
"session_id": session.ID,
|
agentNickname := defaultAgentNickname
|
||||||
"customer_id": customer.ID,
|
displayName := "在线客服"
|
||||||
"channel_id": channel.ID,
|
worktimePrompt := defaultWorktimePrompt
|
||||||
"tenant_id": channel.TenantID,
|
if setting != nil {
|
||||||
"visitor_token": visitorToken,
|
if setting.WelcomeMessage != "" {
|
||||||
"agents_online": agentsOnline,
|
welcome = setting.WelcomeMessage
|
||||||
"session_status": session.Status,
|
}
|
||||||
|
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 {
|
if assignedAgent != nil {
|
||||||
resp["agent_id"] = assignedAgent.ID
|
resp["agent_id"] = assignedAgent.ID
|
||||||
resp["agent_name"] = assignedAgent.Nickname
|
resp["agent_name"] = assignedAgent.Nickname
|
||||||
|
} else if canServeNow {
|
||||||
|
resp["agent_name"] = agentNickname
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"code": 0, "data": resp})
|
c.JSON(http.StatusOK, gin.H{"code": 0, "data": resp})
|
||||||
|
|||||||
@@ -41,5 +41,6 @@ func Migrate(db *gorm.DB) error {
|
|||||||
&Plan{},
|
&Plan{},
|
||||||
&OperationLog{},
|
&OperationLog{},
|
||||||
&Announcement{},
|
&Announcement{},
|
||||||
|
&TenantSetting{},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,3 +152,21 @@ type Announcement struct {
|
|||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_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"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import { Form, Input, Switch, Select, Button, Card, Tag, message, Spin, Empty }
|
|||||||
import {
|
import {
|
||||||
LinkOutlined, WechatOutlined, PhoneOutlined, MailOutlined, MobileOutlined, CopyOutlined, PlusOutlined,
|
LinkOutlined, WechatOutlined, PhoneOutlined, MailOutlined, MobileOutlined, CopyOutlined, PlusOutlined,
|
||||||
} from '@ant-design/icons'
|
} 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<string, { icon: React.ReactNode; label: string }> = {
|
const typeMeta: Record<string, { icon: React.ReactNode; label: string }> = {
|
||||||
web: { icon: <LinkOutlined />, label: '网页聊天' },
|
web: { icon: <LinkOutlined />, label: '网页聊天' },
|
||||||
@@ -13,12 +16,34 @@ const typeMeta: Record<string, { icon: React.ReactNode; label: string }> = {
|
|||||||
email: { icon: <MailOutlined />, label: '邮件工单' },
|
email: { icon: <MailOutlined />, 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 Settings = () => {
|
||||||
const [activeTab, setActiveTab] = useState('channels')
|
const [activeTab, setActiveTab] = useState('basic')
|
||||||
const [basicForm] = Form.useForm()
|
const [basicForm] = Form.useForm()
|
||||||
const [autoReplyForm] = Form.useForm()
|
const [autoReplyForm] = Form.useForm()
|
||||||
|
const [workForm] = Form.useForm()
|
||||||
|
const [notifyForm] = Form.useForm()
|
||||||
const [channels, setChannels] = useState<Channel[]>([])
|
const [channels, setChannels] = useState<Channel[]>([])
|
||||||
|
const [settings, setSettings] = useState<TenantSettings | null>(null)
|
||||||
const [loadingChannels, setLoadingChannels] = useState(false)
|
const [loadingChannels, setLoadingChannels] = useState(false)
|
||||||
|
const [loadingSettings, setLoadingSettings] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
const [togglingId, setTogglingId] = useState<number | null>(null)
|
const [togglingId, setTogglingId] = useState<number | null>(null)
|
||||||
|
|
||||||
const tabItems = [
|
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(() => {
|
useEffect(() => {
|
||||||
if (activeTab === 'channels') loadChannels()
|
if (activeTab === 'channels') loadChannels()
|
||||||
|
if (['basic', 'autoreply', 'worktime', 'notify'].includes(activeTab)) loadSettings()
|
||||||
}, [activeTab])
|
}, [activeTab])
|
||||||
|
|
||||||
|
const savePartial = async (payload: Parameters<typeof updateTenantSettings>[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) => {
|
const copyScript = async (script: string) => {
|
||||||
try {
|
try {
|
||||||
const origin = window.location.origin
|
const origin = window.location.origin
|
||||||
@@ -110,23 +181,40 @@ const Settings = () => {
|
|||||||
<div className="flex-1 overflow-auto p-6">
|
<div className="flex-1 overflow-auto p-6">
|
||||||
{activeTab === 'basic' && (
|
{activeTab === 'basic' && (
|
||||||
<Card title="基本设置" className="max-w-2xl">
|
<Card title="基本设置" className="max-w-2xl">
|
||||||
<Form form={basicForm} layout="vertical" initialValues={{ name: '示例公司', nickname: '客服小助手', timezone: 'Asia/Shanghai', language: 'zh-CN' }}>
|
{loadingSettings && !settings ? (
|
||||||
<Form.Item name="name" label="租户名称" rules={[{ required: true }]}>
|
<div className="py-10 text-center"><Spin /></div>
|
||||||
<Input placeholder="公司名称" />
|
) : (
|
||||||
</Form.Item>
|
<Form
|
||||||
<Form.Item name="nickname" label="客服昵称">
|
form={basicForm}
|
||||||
<Input placeholder="客服在访客端显示的昵称" />
|
layout="vertical"
|
||||||
</Form.Item>
|
onFinish={values => savePartial({
|
||||||
<Form.Item name="timezone" label="时区">
|
display_name: values.display_name,
|
||||||
<Select options={[{ value: 'Asia/Shanghai', label: '东八区 (UTC+8)' }, { value: 'Asia/Tokyo', label: '东京 (UTC+9)' }]} />
|
agent_nickname: values.agent_nickname,
|
||||||
</Form.Item>
|
timezone: values.timezone,
|
||||||
<Form.Item name="language" label="语言">
|
})}
|
||||||
<Select options={[{ value: 'zh-CN', label: '简体中文' }]} disabled />
|
>
|
||||||
</Form.Item>
|
<Form.Item name="display_name" label="租户名称" rules={[{ required: true }, { min: 1, max: 100 }]}>
|
||||||
<Form.Item>
|
<Input placeholder="公司名称 / 访客端展示名" />
|
||||||
<Button type="primary" onClick={() => message.info('租户基本资料接口将在后续版本接通')}>保存设置</Button>
|
</Form.Item>
|
||||||
</Form.Item>
|
<Form.Item name="agent_nickname" label="客服默认昵称">
|
||||||
</Form>
|
<Input placeholder="客服在访客端显示的昵称" maxLength={50} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="timezone" label="时区">
|
||||||
|
<Select options={[
|
||||||
|
{ value: 'Asia/Shanghai', label: '东八区 (UTC+8)' },
|
||||||
|
{ value: 'Asia/Tokyo', label: '东京 (UTC+9)' },
|
||||||
|
{ value: 'UTC', label: 'UTC' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="language" label="语言">
|
||||||
|
<Select options={[{ value: 'zh-CN', label: '简体中文' }]} disabled />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit" loading={saving}>保存设置</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -188,18 +276,11 @@ const Settings = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{ch.type !== 'web' && (
|
{ch.type !== 'web' && (
|
||||||
<div className="text-xs text-neutral-400">
|
<div className="text-xs text-neutral-400">本期仅网页渠道可完整接入,其他渠道预留开关。</div>
|
||||||
本期仅网页渠道可完整接入,其他渠道预留开关。
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-sm text-neutral-400">启用状态</span>
|
<span className="text-sm text-neutral-400">启用状态</span>
|
||||||
<Switch
|
<Switch checked={enabled} size="small" loading={togglingId === ch.id} onChange={v => toggleChannel(ch, v)} />
|
||||||
checked={enabled}
|
|
||||||
size="small"
|
|
||||||
loading={togglingId === ch.id}
|
|
||||||
onChange={v => toggleChannel(ch, v)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -240,9 +321,9 @@ const Settings = () => {
|
|||||||
<Card title="客服分配规则" className="max-w-2xl">
|
<Card title="客服分配规则" className="max-w-2xl">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="p-4 border border-blue-100 bg-blue-50 rounded-lg">
|
<div className="p-4 border border-blue-100 bg-blue-50 rounded-lg">
|
||||||
<div className="text-sm font-medium text-neutral-800">当前生效:负载最低优先</div>
|
<div className="text-sm font-medium text-neutral-800">当前生效:负载最低优先 + 工作时间</div>
|
||||||
<div className="text-xs text-neutral-500 mt-1">
|
<div className="text-xs text-neutral-500 mt-1">
|
||||||
系统会在会话创建时,自动分配给当前「进行中会话」最少的在线客服。无在线客服时进入离线留言。
|
工作时间内,自动分配给进行中会话最少的在线客服;非工作时间或无客服在线时,访客可留言。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 border border-neutral-200 rounded-lg opacity-60">
|
<div className="p-4 border border-neutral-200 rounded-lg opacity-60">
|
||||||
@@ -254,79 +335,140 @@ const Settings = () => {
|
|||||||
<Switch disabled />
|
<Switch disabled />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 border border-neutral-200 rounded-lg opacity-60">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium text-neutral-800">熟客优先</div>
|
|
||||||
<div className="text-xs text-neutral-400 mt-1">后续版本可切换</div>
|
|
||||||
</div>
|
|
||||||
<Switch disabled />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'permission' && (
|
{activeTab === 'permission' && (
|
||||||
<Card title="权限管理" className="max-w-2xl">
|
<Card title="权限管理" className="max-w-2xl">
|
||||||
<p className="text-sm text-neutral-500 m-0">角色权限由系统内置(平台管理员 / 租户管理员 / 主管 / 一线客服),自定义角色将在后续版本开放。</p>
|
<p className="text-sm text-neutral-500 m-0">
|
||||||
|
角色权限由系统内置(平台管理员 / 租户管理员 / 主管 / 一线客服),自定义角色将在后续版本开放。
|
||||||
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'autoreply' && (
|
{activeTab === 'autoreply' && (
|
||||||
<Card title="自动回复" className="max-w-2xl">
|
<Card title="自动回复" className="max-w-2xl">
|
||||||
<Form form={autoReplyForm} layout="vertical" initialValues={{ welcome: '您好!欢迎咨询,请问有什么可以帮您?', offline: '当前无客服在线,请留言并留下联系方式。' }}>
|
{loadingSettings && !settings ? (
|
||||||
<Form.Item name="welcome" label="欢迎语">
|
<div className="py-10 text-center"><Spin /></div>
|
||||||
<Input.TextArea rows={3} maxLength={500} showCount />
|
) : (
|
||||||
</Form.Item>
|
<Form
|
||||||
<Form.Item name="offline" label="离线留言提示">
|
form={autoReplyForm}
|
||||||
<Input.TextArea rows={3} maxLength={500} showCount />
|
layout="vertical"
|
||||||
</Form.Item>
|
onFinish={values => savePartial({
|
||||||
<Form.Item>
|
welcome_message: values.welcome_message,
|
||||||
<Button type="primary" onClick={() => message.info('自动回复配置接口将在后续版本接通')}>保存</Button>
|
offline_prompt: values.offline_prompt,
|
||||||
</Form.Item>
|
}, '自动回复已保存')}
|
||||||
</Form>
|
>
|
||||||
|
<Form.Item name="welcome_message" label="欢迎语" rules={[{ max: 500 }]}>
|
||||||
|
<Input.TextArea rows={3} maxLength={500} showCount placeholder="访客打开窗口时展示" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="offline_prompt" label="离线留言提示" rules={[{ max: 500 }]}>
|
||||||
|
<Input.TextArea rows={3} maxLength={500} showCount placeholder="无客服在线时展示" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit" loading={saving}>保存</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'worktime' && (
|
{activeTab === 'worktime' && (
|
||||||
<Card title="工作时间" className="max-w-2xl">
|
<Card title="工作时间" className="max-w-2xl">
|
||||||
<p className="text-sm text-neutral-500 mb-4">工作时段配置将在后续版本接通,当前默认全天可接待。</p>
|
{loadingSettings && !settings ? (
|
||||||
{['周一', '周二', '周三', '周四', '周五', '周六', '周日'].map(day => (
|
<div className="py-10 text-center"><Spin /></div>
|
||||||
<div key={day} className="flex items-center justify-between py-2 border-b border-neutral-50">
|
) : (
|
||||||
<span className="text-sm text-neutral-700 w-12">{day}</span>
|
<Form
|
||||||
<Select
|
form={workForm}
|
||||||
defaultValue={day === '周六' || day === '周日' ? undefined : '09:00-18:00'}
|
layout="vertical"
|
||||||
placeholder="休息"
|
onFinish={values => {
|
||||||
className="w-36"
|
const work_hours: WorkHours = {}
|
||||||
size="small"
|
weekDays.forEach(d => {
|
||||||
allowClear
|
work_hours[d.key] = values[d.key] || ''
|
||||||
disabled
|
})
|
||||||
options={['09:00-18:00', '08:00-17:00', '10:00-19:00', '全天'].map(v => ({ value: v, label: v }))}
|
return savePartial({
|
||||||
/>
|
work_hours,
|
||||||
</div>
|
worktime_prompt: values.worktime_prompt,
|
||||||
))}
|
}, '工作时间已保存')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="text-sm text-neutral-500 mb-4">
|
||||||
|
非工作时间访客将看到下方提示并可留言;清空某天时段表示休息。
|
||||||
|
</p>
|
||||||
|
{weekDays.map(day => (
|
||||||
|
<div key={day.key} className="flex items-center justify-between py-2 border-b border-neutral-50">
|
||||||
|
<span className="text-sm text-neutral-700 w-12">{day.label}</span>
|
||||||
|
<Form.Item name={day.key} className="!mb-0">
|
||||||
|
<Select
|
||||||
|
placeholder="休息"
|
||||||
|
className="w-40"
|
||||||
|
size="small"
|
||||||
|
allowClear
|
||||||
|
options={hourOptions}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Form.Item name="worktime_prompt" label="非工作时间提示" className="mt-4" rules={[{ max: 500 }]}>
|
||||||
|
<Input placeholder="当前为非工作时间,我们会在工作时段尽快回复您" maxLength={500} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit" loading={saving}>保存工作时间</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'notify' && (
|
{activeTab === 'notify' && (
|
||||||
<Card title="通知设置" className="max-w-2xl">
|
<Card title="通知设置" className="max-w-2xl">
|
||||||
<div className="space-y-3">
|
{loadingSettings && !settings ? (
|
||||||
{[
|
<div className="py-10 text-center"><Spin /></div>
|
||||||
{ title: '新会话提醒', desc: '有新访客进线时桌面通知' },
|
) : (
|
||||||
{ title: '离线留言通知', desc: '访客提交离线留言时提醒' },
|
<Form
|
||||||
{ title: '日报推送', desc: '每日服务数据摘要' },
|
form={notifyForm}
|
||||||
].map(item => (
|
layout="vertical"
|
||||||
<div key={item.title} className="flex items-center justify-between p-3 border border-neutral-100 rounded-lg">
|
onFinish={values => savePartial({
|
||||||
<div>
|
notify_new_session: values.notify_new_session,
|
||||||
<div className="text-sm font-medium text-neutral-800">{item.title}</div>
|
notify_offline_leave: values.notify_offline_leave,
|
||||||
<div className="text-xs text-neutral-400">{item.desc}</div>
|
notify_daily_report: values.notify_daily_report,
|
||||||
|
}, '通知设置已保存')}
|
||||||
|
>
|
||||||
|
<div className="space-y-3 mb-4">
|
||||||
|
<div className="flex items-center justify-between p-3 border border-neutral-100 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-neutral-800">新会话提醒</div>
|
||||||
|
<div className="text-xs text-neutral-400">有新访客进线时提醒(偏好开关,推送通道后续接入)</div>
|
||||||
|
</div>
|
||||||
|
<Form.Item name="notify_new_session" valuePropName="checked" className="!mb-0">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between p-3 border border-neutral-100 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-neutral-800">离线留言通知</div>
|
||||||
|
<div className="text-xs text-neutral-400">访客提交离线留言时提醒</div>
|
||||||
|
</div>
|
||||||
|
<Form.Item name="notify_offline_leave" valuePropName="checked" className="!mb-0">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between p-3 border border-neutral-100 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-neutral-800">日报推送</div>
|
||||||
|
<div className="text-xs text-neutral-400">每日服务数据摘要</div>
|
||||||
|
</div>
|
||||||
|
<Form.Item name="notify_daily_report" valuePropName="checked" className="!mb-0">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
</div>
|
</div>
|
||||||
<Switch disabled defaultChecked={item.title !== '日报推送'} />
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
<Form.Item>
|
||||||
<div className="text-xs text-neutral-400">通知推送配置将在后续版本接通。</div>
|
<Button type="primary" htmlType="submit" loading={saving}>保存通知设置</Button>
|
||||||
</div>
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -142,6 +142,25 @@ export const getChannels = () => get<Channel[]>('/channels')
|
|||||||
export const createChannel = (data: { type: string; name?: string }) => post<Channel>('/channels', data)
|
export const createChannel = (data: { type: string; name?: string }) => post<Channel>('/channels', data)
|
||||||
export const updateChannel = (id: number, data: { name?: string; status?: string }) => put<Channel>(`/channels/${id}`, data)
|
export const updateChannel = (id: number, data: { name?: string; status?: string }) => put<Channel>(`/channels/${id}`, data)
|
||||||
|
|
||||||
|
// Tenant settings
|
||||||
|
export type WorkHours = Record<string, string>
|
||||||
|
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<TenantSettings>('/settings')
|
||||||
|
export const updateTenantSettings = (data: Partial<TenantSettings> & { work_hours?: WorkHours }) =>
|
||||||
|
put<TenantSettings>('/settings', data)
|
||||||
|
|
||||||
// Statistics
|
// Statistics
|
||||||
export const getKPIs = () => get<StatisticsKpis>('/statistics/kpi')
|
export const getKPIs = () => get<StatisticsKpis>('/statistics/kpi')
|
||||||
export const getSessionTrend = (period: 'today' | 'day' | 'month' = 'day') => get<{ date: string; count: number }[]>(`/statistics/trend?period=${period}`)
|
export const getSessionTrend = (period: 'today' | 'day' | 'month' = 'day') => get<{ date: string; count: number }[]>(`/statistics/trend?period=${period}`)
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ const VisitorChat = ({
|
|||||||
const [sendError, setSendError] = useState('')
|
const [sendError, setSendError] = useState('')
|
||||||
const [agentsOnline, setAgentsOnline] = useState(true)
|
const [agentsOnline, setAgentsOnline] = useState(true)
|
||||||
const [offlinePrompt, setOfflinePrompt] = useState('当前无客服在线,请留言并留下联系方式,我们上线后会尽快回复您。')
|
const [offlinePrompt, setOfflinePrompt] = useState('当前无客服在线,请留言并留下联系方式,我们上线后会尽快回复您。')
|
||||||
|
const [welcomeMessage, setWelcomeMessage] = useState('您好!欢迎咨询,请问有什么可以帮您?')
|
||||||
|
const [displayName, setDisplayName] = useState('在线客服')
|
||||||
const [agentName, setAgentName] = useState('')
|
const [agentName, setAgentName] = useState('')
|
||||||
const [leaveName, setLeaveName] = useState('')
|
const [leaveName, setLeaveName] = useState('')
|
||||||
const [leavePhone, setLeavePhone] = useState('')
|
const [leavePhone, setLeavePhone] = useState('')
|
||||||
@@ -112,7 +114,10 @@ const VisitorChat = ({
|
|||||||
setVisitorToken(token)
|
setVisitorToken(token)
|
||||||
setAgentsOnline(Boolean(data.agents_online))
|
setAgentsOnline(Boolean(data.agents_online))
|
||||||
if (data.offline_prompt) setOfflinePrompt(data.offline_prompt)
|
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)
|
if (data.agent_name) setAgentName(data.agent_name)
|
||||||
|
else if (data.agent_nickname) setAgentName(data.agent_nickname)
|
||||||
if (data.session_status === 'ended') setSessionEnded(true)
|
if (data.session_status === 'ended') setSessionEnded(true)
|
||||||
localStorage.setItem(storageKey, String(sid))
|
localStorage.setItem(storageKey, String(sid))
|
||||||
localStorage.setItem(tokenKey, token)
|
localStorage.setItem(tokenKey, token)
|
||||||
@@ -365,7 +370,7 @@ const VisitorChat = ({
|
|||||||
<CustomerServiceOutlined className="text-lg text-white" />
|
<CustomerServiceOutlined className="text-lg text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h1 className="m-0 text-[15px] font-semibold leading-tight text-white">在线客服</h1>
|
<h1 className="m-0 text-[15px] font-semibold leading-tight text-white">{displayName || '在线客服'}</h1>
|
||||||
<p className="m-0 text-xs leading-normal text-white/80 flex items-center gap-1 mt-0.5">
|
<p className="m-0 text-xs leading-normal text-white/80 flex items-center gap-1 mt-0.5">
|
||||||
<span
|
<span
|
||||||
className="inline-block w-1.5 h-1.5 rounded-full"
|
className="inline-block w-1.5 h-1.5 rounded-full"
|
||||||
@@ -375,7 +380,7 @@ const VisitorChat = ({
|
|||||||
? '会话已结束'
|
? '会话已结束'
|
||||||
: agentsOnline
|
: agentsOnline
|
||||||
? (agentName ? `${agentName} 为您服务` : '正在为您服务')
|
? (agentName ? `${agentName} 为您服务` : '正在为您服务')
|
||||||
: '客服离线 · 可留言'}
|
: '暂不可即时接待 · 可留言'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
@@ -405,9 +410,7 @@ const VisitorChat = ({
|
|||||||
<div className="flex flex-col items-center gap-3">
|
<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">
|
<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 className="m-0 text-[13px] text-neutral-600 leading-normal">
|
||||||
{agentsOnline
|
{agentsOnline ? welcomeMessage : offlinePrompt}
|
||||||
? '您好!欢迎咨询,请问有什么可以帮您?'
|
|
||||||
: offlinePrompt}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user