支持可配置的会话自动分配策略
- 租户可设置负载最低或轮询,以及每位坐席最大进行中会话数 - 自动分配按策略过滤在线一线客服,满并发则保持排队 - 系统设置「客服分配规则」页可保存上述配置
This commit is contained in:
@@ -2,39 +2,123 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"kefu-sys/server/internal/model"
|
"kefu-sys/server/internal/model"
|
||||||
"kefu-sys/server/internal/ws"
|
"kefu-sys/server/internal/ws"
|
||||||
)
|
)
|
||||||
|
|
||||||
const offlineLeavePrompt = "当前无客服在线,请留言并留下联系方式,我们上线后会尽快回复您。"
|
const (
|
||||||
|
assignStrategyLeastLoad = "least_load"
|
||||||
|
assignStrategyRoundRobin = "round_robin"
|
||||||
|
offlineLeavePrompt = "当前无客服在线,请留言并留下联系方式,我们上线后会尽快回复您。"
|
||||||
|
)
|
||||||
|
|
||||||
// pickLeastLoadedAgent 在在线一线客服中选择当前进行中会话最少的一位。
|
func normalizeAssignStrategy(raw string) string {
|
||||||
func pickLeastLoadedAgent(tenantID uint) (*model.User, error) {
|
switch strings.TrimSpace(raw) {
|
||||||
|
case assignStrategyRoundRobin:
|
||||||
|
return assignStrategyRoundRobin
|
||||||
|
default:
|
||||||
|
return assignStrategyLeastLoad
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// listOnlineAgents 租户内可自动接待的一线客服(online + role=agent)。
|
||||||
|
func listOnlineAgents(tenantID uint) ([]model.User, error) {
|
||||||
var agents []model.User
|
var agents []model.User
|
||||||
if err := model.DB.Where("tenant_id = ? AND role = ? AND status = ?", tenantID, "agent", "online").
|
err := model.DB.Where("tenant_id = ? AND role = ? AND status = ?", tenantID, "agent", "online").
|
||||||
Order("id asc").Find(&agents).Error; err != nil {
|
Order("id asc").Find(&agents).Error
|
||||||
|
return agents, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func agentActiveSessionCount(tenantID, agentID uint) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
err := model.DB.Model(&model.Session{}).
|
||||||
|
Where("tenant_id = ? AND agent_id = ? AND status = ?", tenantID, agentID, "active").
|
||||||
|
Count(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterAssignable 按最大并发过滤;maxActive<=0 表示不限制。
|
||||||
|
func filterAssignable(tenantID uint, agents []model.User, maxActive int) ([]model.User, map[uint]int64, error) {
|
||||||
|
counts := make(map[uint]int64, len(agents))
|
||||||
|
out := make([]model.User, 0, len(agents))
|
||||||
|
for i := range agents {
|
||||||
|
n, err := agentActiveSessionCount(tenantID, agents[i].ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
counts[agents[i].ID] = n
|
||||||
|
if maxActive > 0 && int(n) >= maxActive {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, agents[i])
|
||||||
|
}
|
||||||
|
return out, counts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickLeastLoadedAgent 在候选中选 active 会话最少者;平局取 id 更小。
|
||||||
|
func pickLeastLoadedAgent(candidates []model.User, counts map[uint]int64) *model.User {
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
best := &candidates[0]
|
||||||
|
bestCount := counts[best.ID]
|
||||||
|
for i := 1; i < len(candidates); i++ {
|
||||||
|
c := counts[candidates[i].ID]
|
||||||
|
if c < bestCount || (c == bestCount && candidates[i].ID < best.ID) {
|
||||||
|
bestCount = c
|
||||||
|
best = &candidates[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickRoundRobinAgent 在按 id 排序的候选中,从 lastAgentID 之后轮转下一位。
|
||||||
|
func pickRoundRobinAgent(candidates []model.User, lastAgentID *uint) *model.User {
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if lastAgentID == nil || *lastAgentID == 0 {
|
||||||
|
return &candidates[0]
|
||||||
|
}
|
||||||
|
// 找到 last 在全序中的位置,取其后第一个仍在候选中的
|
||||||
|
start := 0
|
||||||
|
for i, a := range candidates {
|
||||||
|
if a.ID > *lastAgentID {
|
||||||
|
start = i
|
||||||
|
return &candidates[start]
|
||||||
|
}
|
||||||
|
if a.ID == *lastAgentID {
|
||||||
|
start = (i + 1) % len(candidates)
|
||||||
|
return &candidates[start]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// last 已不在列表(离线/满载),从头开始
|
||||||
|
return &candidates[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickAgent 按租户策略选择可分配坐席。
|
||||||
|
func pickAgent(tenantID uint, strategy string, maxActive int, rrLast *uint) (*model.User, error) {
|
||||||
|
agents, err := listOnlineAgents(tenantID)
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if len(agents) == 0 {
|
if len(agents) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
candidates, counts, err := filterAssignable(tenantID, agents, maxActive)
|
||||||
best := &agents[0]
|
if err != nil {
|
||||||
bestCount := int64(-1)
|
|
||||||
for i := range agents {
|
|
||||||
var count int64
|
|
||||||
if err := model.DB.Model(&model.Session{}).
|
|
||||||
Where("tenant_id = ? AND agent_id = ? AND status = ?", tenantID, agents[i].ID, "active").
|
|
||||||
Count(&count).Error; err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if bestCount < 0 || count < bestCount {
|
if len(candidates) == 0 {
|
||||||
bestCount = count
|
return nil, nil
|
||||||
best = &agents[i]
|
|
||||||
}
|
}
|
||||||
|
strategy = normalizeAssignStrategy(strategy)
|
||||||
|
if strategy == assignStrategyRoundRobin {
|
||||||
|
return pickRoundRobinAgent(candidates, rrLast), nil
|
||||||
}
|
}
|
||||||
return best, nil
|
return pickLeastLoadedAgent(candidates, counts), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// countOnlineAgents 统计租户当前可接待的在线客服数。
|
// countOnlineAgents 统计租户当前可接待的在线客服数。
|
||||||
@@ -46,7 +130,7 @@ func countOnlineAgents(tenantID uint) (int64, error) {
|
|||||||
return count, err
|
return count, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// tryAutoAssign 将会话自动分配给负载最低的在线客服;无可分配客服时返回 nil。
|
// tryAutoAssign 按租户分配策略自动分配 waiting 会话;无可分配客服时返回 nil。
|
||||||
func tryAutoAssign(session *model.Session) (*model.User, error) {
|
func tryAutoAssign(session *model.Session) (*model.User, error) {
|
||||||
if session == nil || session.ID == 0 {
|
if session == nil || session.ID == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -55,7 +139,18 @@ func tryAutoAssign(session *model.Session) (*model.User, error) {
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
agent, err := pickLeastLoadedAgent(session.TenantID)
|
setting, err := getOrCreateTenantSettings(session.TenantID)
|
||||||
|
if err != nil {
|
||||||
|
// 设置失败时回退默认 least_load、不限制并发
|
||||||
|
setting = &model.TenantSetting{AssignStrategy: assignStrategyLeastLoad}
|
||||||
|
}
|
||||||
|
strategy := normalizeAssignStrategy(setting.AssignStrategy)
|
||||||
|
maxActive := setting.MaxActivePerAgent
|
||||||
|
if maxActive < 0 {
|
||||||
|
maxActive = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
agent, err := pickAgent(session.TenantID, strategy, maxActive, setting.RRLastAgentID)
|
||||||
if err != nil || agent == nil {
|
if err != nil || agent == nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -80,7 +175,24 @@ func tryAutoAssign(session *model.Session) (*model.User, error) {
|
|||||||
|
|
||||||
session.AgentID = &agent.ID
|
session.AgentID = &agent.ID
|
||||||
session.Status = "active"
|
session.Status = "active"
|
||||||
detail := fmt.Sprintf("系统自动分配给 %s", agent.Nickname)
|
|
||||||
|
// 推进轮询游标(仅 round_robin)
|
||||||
|
if strategy == assignStrategyRoundRobin && setting.ID > 0 {
|
||||||
|
aid := agent.ID
|
||||||
|
_ = model.DB.Model(&model.TenantSetting{}).
|
||||||
|
Where("id = ?", setting.ID).
|
||||||
|
Update("rr_last_agent_id", aid).Error
|
||||||
|
setting.RRLastAgentID = &aid
|
||||||
|
}
|
||||||
|
|
||||||
|
strategyLabel := "负载最低"
|
||||||
|
if strategy == assignStrategyRoundRobin {
|
||||||
|
strategyLabel = "轮询"
|
||||||
|
}
|
||||||
|
detail := fmt.Sprintf("系统自动分配给 %s(%s)", agent.Nickname, strategyLabel)
|
||||||
|
if agent.Nickname == "" {
|
||||||
|
detail = fmt.Sprintf("系统自动分配给 %s(%s)", agent.Username, strategyLabel)
|
||||||
|
}
|
||||||
model.DB.Create(&model.SessionEvent{
|
model.DB.Create(&model.SessionEvent{
|
||||||
SessionID: session.ID,
|
SessionID: session.ID,
|
||||||
OperatorID: 0,
|
OperatorID: 0,
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"kefu-sys/server/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPickLeastLoadedPrefersFewerSessions(t *testing.T) {
|
||||||
|
a := model.User{ID: 1}
|
||||||
|
b := model.User{ID: 2}
|
||||||
|
c := model.User{ID: 3}
|
||||||
|
candidates := []model.User{a, b, c}
|
||||||
|
counts := map[uint]int64{1: 3, 2: 1, 3: 1}
|
||||||
|
got := pickLeastLoadedAgent(candidates, counts)
|
||||||
|
if got == nil || got.ID != 2 {
|
||||||
|
t.Fatalf("期望 id=2(平局取更小 id),got=%v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPickRoundRobinAdvances(t *testing.T) {
|
||||||
|
candidates := []model.User{{ID: 10}, {ID: 20}, {ID: 30}}
|
||||||
|
last := uint(10)
|
||||||
|
got := pickRoundRobinAgent(candidates, &last)
|
||||||
|
if got == nil || got.ID != 20 {
|
||||||
|
t.Fatalf("期望下一位 20,got=%v", got)
|
||||||
|
}
|
||||||
|
last = 30
|
||||||
|
got = pickRoundRobinAgent(candidates, &last)
|
||||||
|
if got == nil || got.ID != 10 {
|
||||||
|
t.Fatalf("期望绕回 10,got=%v", got)
|
||||||
|
}
|
||||||
|
got = pickRoundRobinAgent(candidates, nil)
|
||||||
|
if got == nil || got.ID != 10 {
|
||||||
|
t.Fatalf("无游标时期望第一位 10,got=%v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeAssignStrategy(t *testing.T) {
|
||||||
|
if normalizeAssignStrategy("round_robin") != assignStrategyRoundRobin {
|
||||||
|
t.Fatal("round_robin")
|
||||||
|
}
|
||||||
|
if normalizeAssignStrategy("") != assignStrategyLeastLoad {
|
||||||
|
t.Fatal("default least_load")
|
||||||
|
}
|
||||||
|
if normalizeAssignStrategy("unknown") != assignStrategyLeastLoad {
|
||||||
|
t.Fatal("unknown -> least_load")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,6 +60,8 @@ func getOrCreateTenantSettings(tenantID uint) (*model.TenantSetting, error) {
|
|||||||
NotifyNewSession: true,
|
NotifyNewSession: true,
|
||||||
NotifyOfflineLeave: true,
|
NotifyOfflineLeave: true,
|
||||||
NotifyDailyReport: false,
|
NotifyDailyReport: false,
|
||||||
|
AssignStrategy: assignStrategyLeastLoad,
|
||||||
|
MaxActivePerAgent: 0,
|
||||||
}
|
}
|
||||||
if err := model.DB.Create(&setting).Error; err != nil {
|
if err := model.DB.Create(&setting).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -135,6 +137,11 @@ func isWithinWorkHours(setting *model.TenantSetting, now time.Time) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func settingsResponse(setting *model.TenantSetting) gin.H {
|
func settingsResponse(setting *model.TenantSetting) gin.H {
|
||||||
|
strategy := normalizeAssignStrategy(setting.AssignStrategy)
|
||||||
|
maxActive := setting.MaxActivePerAgent
|
||||||
|
if maxActive < 0 {
|
||||||
|
maxActive = 0
|
||||||
|
}
|
||||||
return gin.H{
|
return gin.H{
|
||||||
"tenant_id": setting.TenantID,
|
"tenant_id": setting.TenantID,
|
||||||
"display_name": setting.DisplayName,
|
"display_name": setting.DisplayName,
|
||||||
@@ -147,6 +154,8 @@ func settingsResponse(setting *model.TenantSetting) gin.H {
|
|||||||
"notify_new_session": setting.NotifyNewSession,
|
"notify_new_session": setting.NotifyNewSession,
|
||||||
"notify_offline_leave": setting.NotifyOfflineLeave,
|
"notify_offline_leave": setting.NotifyOfflineLeave,
|
||||||
"notify_daily_report": setting.NotifyDailyReport,
|
"notify_daily_report": setting.NotifyDailyReport,
|
||||||
|
"assign_strategy": strategy,
|
||||||
|
"max_active_per_agent": maxActive,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,6 +184,8 @@ type updateSettingsReq struct {
|
|||||||
NotifyNewSession *bool `json:"notify_new_session"`
|
NotifyNewSession *bool `json:"notify_new_session"`
|
||||||
NotifyOfflineLeave *bool `json:"notify_offline_leave"`
|
NotifyOfflineLeave *bool `json:"notify_offline_leave"`
|
||||||
NotifyDailyReport *bool `json:"notify_daily_report"`
|
NotifyDailyReport *bool `json:"notify_daily_report"`
|
||||||
|
AssignStrategy *string `json:"assign_strategy"`
|
||||||
|
MaxActivePerAgent *int `json:"max_active_per_agent"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *SettingsHandler) Update(c *gin.Context) {
|
func (h *SettingsHandler) Update(c *gin.Context) {
|
||||||
@@ -284,6 +295,21 @@ func (h *SettingsHandler) Update(c *gin.Context) {
|
|||||||
if req.NotifyDailyReport != nil {
|
if req.NotifyDailyReport != nil {
|
||||||
updates["notify_daily_report"] = *req.NotifyDailyReport
|
updates["notify_daily_report"] = *req.NotifyDailyReport
|
||||||
}
|
}
|
||||||
|
if req.AssignStrategy != nil {
|
||||||
|
raw := strings.TrimSpace(*req.AssignStrategy)
|
||||||
|
if raw != assignStrategyLeastLoad && raw != assignStrategyRoundRobin {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "分配策略无效,可选 least_load / round_robin"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updates["assign_strategy"] = raw
|
||||||
|
}
|
||||||
|
if req.MaxActivePerAgent != nil {
|
||||||
|
if *req.MaxActivePerAgent < 0 || *req.MaxActivePerAgent > 200 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "最大并发需为 0–200(0 表示不限制)"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updates["max_active_per_agent"] = *req.MaxActivePerAgent
|
||||||
|
}
|
||||||
if len(updates) == 0 {
|
if len(updates) == 0 {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "没有可更新字段"})
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "没有可更新字段"})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ type Announcement struct {
|
|||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TenantSetting 租户级系统设置(欢迎语、工作时间、通知开关等)。
|
// TenantSetting 租户级系统设置(欢迎语、工作时间、通知开关、分配策略等)。
|
||||||
type TenantSetting struct {
|
type TenantSetting struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
TenantID uint `gorm:"uniqueIndex;not null" json:"tenant_id"`
|
TenantID uint `gorm:"uniqueIndex;not null" json:"tenant_id"`
|
||||||
@@ -170,6 +170,12 @@ type TenantSetting struct {
|
|||||||
NotifyNewSession bool `gorm:"default:true" json:"notify_new_session"`
|
NotifyNewSession bool `gorm:"default:true" json:"notify_new_session"`
|
||||||
NotifyOfflineLeave bool `gorm:"default:true" json:"notify_offline_leave"`
|
NotifyOfflineLeave bool `gorm:"default:true" json:"notify_offline_leave"`
|
||||||
NotifyDailyReport bool `gorm:"default:false" json:"notify_daily_report"`
|
NotifyDailyReport bool `gorm:"default:false" json:"notify_daily_report"`
|
||||||
|
// AssignStrategy 自动分配策略:least_load(默认)| round_robin
|
||||||
|
AssignStrategy string `gorm:"size:30;default:least_load" json:"assign_strategy"`
|
||||||
|
// MaxActivePerAgent 每位坐席最大进行中会话数;0 表示不限制
|
||||||
|
MaxActivePerAgent int `gorm:"default:0" json:"max_active_per_agent"`
|
||||||
|
// RRLastAgentID 轮询策略的上次分配坐席游标
|
||||||
|
RRLastAgentID *uint `json:"-"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ const tabItems: { key: string; label: string; desc: string; icon: ReactNode }[]
|
|||||||
{ key: 'basic', label: '基本设置', desc: '租户展示名称、默认昵称与时区', icon: <SettingOutlined /> },
|
{ key: 'basic', label: '基本设置', desc: '租户展示名称、默认昵称与时区', icon: <SettingOutlined /> },
|
||||||
{ key: 'channels', label: '渠道管理', desc: '配置客户服务接入渠道与嵌入代码', icon: <ApiOutlined /> },
|
{ key: 'channels', label: '渠道管理', desc: '配置客户服务接入渠道与嵌入代码', icon: <ApiOutlined /> },
|
||||||
{ key: 'staff', label: '坐席账号', desc: '管理客服/主管账号与坐席配额', icon: <UserSwitchOutlined /> },
|
{ key: 'staff', label: '坐席账号', desc: '管理客服/主管账号与坐席配额', icon: <UserSwitchOutlined /> },
|
||||||
{ key: 'assignment', label: '客服分配规则', desc: '会话自动分配策略说明', icon: <TeamOutlined /> },
|
{ key: 'assignment', label: '客服分配规则', desc: '自动分配策略与并发上限', icon: <TeamOutlined /> },
|
||||||
{ key: 'autoreply', label: '自动回复', desc: '欢迎语与离线留言提示', icon: <MessageOutlined /> },
|
{ key: 'autoreply', label: '自动回复', desc: '欢迎语与离线留言提示', icon: <MessageOutlined /> },
|
||||||
{ key: 'worktime', label: '工作时间', desc: '在线服务时段与非工作时间提示', icon: <ClockCircleOutlined /> },
|
{ key: 'worktime', label: '工作时间', desc: '在线服务时段与非工作时间提示', icon: <ClockCircleOutlined /> },
|
||||||
{ key: 'notify', label: '通知设置', desc: '新会话、留言与日报偏好', icon: <BellOutlined /> },
|
{ key: 'notify', label: '通知设置', desc: '新会话、留言与日报偏好', icon: <BellOutlined /> },
|
||||||
@@ -71,6 +71,7 @@ const Settings = () => {
|
|||||||
const [autoReplyForm] = Form.useForm()
|
const [autoReplyForm] = Form.useForm()
|
||||||
const [workForm] = Form.useForm()
|
const [workForm] = Form.useForm()
|
||||||
const [notifyForm] = Form.useForm()
|
const [notifyForm] = Form.useForm()
|
||||||
|
const [assignForm] = Form.useForm()
|
||||||
const [staffForm] = Form.useForm()
|
const [staffForm] = Form.useForm()
|
||||||
const [channels, setChannels] = useState<Channel[]>([])
|
const [channels, setChannels] = useState<Channel[]>([])
|
||||||
const [settings, setSettings] = useState<TenantSettings | null>(null)
|
const [settings, setSettings] = useState<TenantSettings | null>(null)
|
||||||
@@ -142,6 +143,10 @@ const Settings = () => {
|
|||||||
notify_offline_leave: data.notify_offline_leave,
|
notify_offline_leave: data.notify_offline_leave,
|
||||||
notify_daily_report: data.notify_daily_report,
|
notify_daily_report: data.notify_daily_report,
|
||||||
})
|
})
|
||||||
|
assignForm.setFieldsValue({
|
||||||
|
assign_strategy: data.assign_strategy || 'least_load',
|
||||||
|
max_active_per_agent: data.max_active_per_agent ?? 0,
|
||||||
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error(e instanceof Error ? e.message : '加载设置失败')
|
message.error(e instanceof Error ? e.message : '加载设置失败')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -151,7 +156,7 @@ const Settings = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeTab === 'channels') loadChannels()
|
if (activeTab === 'channels') loadChannels()
|
||||||
if (['basic', 'autoreply', 'worktime', 'notify'].includes(activeTab)) loadSettings()
|
if (['basic', 'autoreply', 'worktime', 'notify', 'assignment'].includes(activeTab)) loadSettings()
|
||||||
if (activeTab === 'staff' && canViewStaff) loadStaff()
|
if (activeTab === 'staff' && canViewStaff) loadStaff()
|
||||||
}, [activeTab])
|
}, [activeTab])
|
||||||
|
|
||||||
@@ -504,29 +509,89 @@ const Settings = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'assignment' && (
|
{activeTab === 'assignment' && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-4">
|
||||||
|
{loadingSettings ? (
|
||||||
|
<div className="py-16 text-center"><Spin /></div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<div className="bg-white rounded-xl border border-[#dbeafe] shadow-sm p-5">
|
<div className="bg-white rounded-xl border border-[#dbeafe] shadow-sm p-5">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="w-9 h-9 rounded-lg bg-[#eff6ff] text-[#2563eb] flex items-center justify-center shrink-0">
|
<div className="w-9 h-9 rounded-lg bg-[#eff6ff] text-[#2563eb] flex items-center justify-center shrink-0">
|
||||||
<TeamOutlined />
|
<TeamOutlined />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<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.5 leading-relaxed">
|
<div className="text-xs text-neutral-500 mt-1.5 leading-relaxed">
|
||||||
工作时间内,新会话自动分配给进行中会话最少的在线客服;非工作时间或无客服在线时,访客可提交离线留言。
|
工作时间内,访客进线会按下列策略分配给<strong className="font-medium text-neutral-700">在线一线客服</strong>;
|
||||||
|
非工作时间或无可接坐席时进入排队/留言。人工领取与转接不受此策略限制。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white rounded-xl border border-neutral-200 p-5 opacity-70">
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="bg-white rounded-xl border border-neutral-200 shadow-sm p-5">
|
||||||
<div>
|
<Form
|
||||||
<div className="text-sm font-medium text-neutral-800">轮询分配</div>
|
form={assignForm}
|
||||||
<div className="text-xs text-neutral-400 mt-1">后续版本可切换策略</div>
|
layout="vertical"
|
||||||
</div>
|
disabled={!isAdmin}
|
||||||
<Switch disabled />
|
onFinish={async (values: {
|
||||||
|
assign_strategy: 'least_load' | 'round_robin'
|
||||||
|
max_active_per_agent: number
|
||||||
|
}) => {
|
||||||
|
await savePartial({
|
||||||
|
assign_strategy: values.assign_strategy,
|
||||||
|
max_active_per_agent: Number(values.max_active_per_agent) || 0,
|
||||||
|
}, '分配规则已保存')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name="assign_strategy"
|
||||||
|
label="分配策略"
|
||||||
|
rules={[{ required: true, message: '请选择策略' }]}
|
||||||
|
extra="负载最低:优先进行中会话更少的坐席;轮询:在可接坐席中依次分配,更均匀。"
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
options={[
|
||||||
|
{ value: 'least_load', label: '负载最低(推荐)' },
|
||||||
|
{ value: 'round_robin', label: '轮询分配' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="max_active_per_agent"
|
||||||
|
label="每位坐席最大进行中会话数"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: '请填写上限' },
|
||||||
|
{
|
||||||
|
validator: async (_, v) => {
|
||||||
|
const n = Number(v)
|
||||||
|
if (!Number.isFinite(n) || n < 0 || n > 200) {
|
||||||
|
throw new Error('请填写 0–200 的整数,0 表示不限制')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
extra="达到上限的坐席不再自动分入新会话;全部坐席已满时会话保持排队。0 = 不限制。"
|
||||||
|
>
|
||||||
|
<Input type="number" min={0} max={200} placeholder="0 表示不限制" />
|
||||||
|
</Form.Item>
|
||||||
|
{isAdmin ? (
|
||||||
|
<Button type="primary" htmlType="submit" loading={saving}>
|
||||||
|
保存分配规则
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-neutral-400 m-0">仅租户管理员可修改分配规则</p>
|
||||||
|
)}
|
||||||
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-neutral-50 rounded-xl border border-neutral-200 p-4 text-xs text-neutral-500 leading-relaxed space-y-1">
|
||||||
|
<div className="font-medium text-neutral-700 mb-1">说明</div>
|
||||||
|
<p className="m-0">· 候选范围:角色为客服、状态为「在线」的账号(忙碌/离线不参与自动分配)。</p>
|
||||||
|
<p className="m-0">· 技能组 / 渠道专属坐席将在后续版本支持。</p>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -318,6 +318,8 @@ export const deleteStaff = (id: number) => del(`/staff/${id}`)
|
|||||||
|
|
||||||
// Tenant settings
|
// Tenant settings
|
||||||
export type WorkHours = Record<string, string>
|
export type WorkHours = Record<string, string>
|
||||||
|
export type AssignStrategy = 'least_load' | 'round_robin'
|
||||||
|
|
||||||
export interface TenantSettings {
|
export interface TenantSettings {
|
||||||
tenant_id: number
|
tenant_id: number
|
||||||
display_name: string
|
display_name: string
|
||||||
@@ -330,6 +332,10 @@ export interface TenantSettings {
|
|||||||
notify_new_session: boolean
|
notify_new_session: boolean
|
||||||
notify_offline_leave: boolean
|
notify_offline_leave: boolean
|
||||||
notify_daily_report: boolean
|
notify_daily_report: boolean
|
||||||
|
/** least_load 负载最低 | round_robin 轮询 */
|
||||||
|
assign_strategy?: AssignStrategy
|
||||||
|
/** 每位坐席最大进行中会话数,0=不限制 */
|
||||||
|
max_active_per_agent?: number
|
||||||
}
|
}
|
||||||
export const getTenantSettings = () => get<TenantSettings>('/settings')
|
export const getTenantSettings = () => get<TenantSettings>('/settings')
|
||||||
export const updateTenantSettings = (data: Partial<TenantSettings> & { work_hours?: WorkHours }) =>
|
export const updateTenantSettings = (data: Partial<TenantSettings> & { work_hours?: WorkHours }) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user