实现租户坐席账号管理
新增 /api/staff 列表/创建/更新/禁用,按租户坐席配额校验;系统设置增加坐席账号页支持增改禁与配额展示。
This commit is contained in:
@@ -16,6 +16,7 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S
|
|||||||
admin := NewAdminHandler()
|
admin := NewAdminHandler()
|
||||||
channel := NewChannelHandler()
|
channel := NewChannelHandler()
|
||||||
settings := NewSettingsHandler()
|
settings := NewSettingsHandler()
|
||||||
|
staff := NewStaffHandler()
|
||||||
ws := NewWsHandler()
|
ws := NewWsHandler()
|
||||||
widget := NewWidgetHandler()
|
widget := NewWidgetHandler()
|
||||||
upload := NewUploadHandler(store, storageCfg)
|
upload := NewUploadHandler(store, storageCfg)
|
||||||
@@ -94,6 +95,13 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S
|
|||||||
settingsGroup.GET("", settings.Get)
|
settingsGroup.GET("", settings.Get)
|
||||||
settingsGroup.PUT("", settings.Update)
|
settingsGroup.PUT("", settings.Update)
|
||||||
|
|
||||||
|
// 坐席账号(租户内)
|
||||||
|
staffGroup := authRequired.Group("/staff")
|
||||||
|
staffGroup.GET("", staff.List)
|
||||||
|
staffGroup.POST("", staff.Create)
|
||||||
|
staffGroup.PUT("/:id", staff.Update)
|
||||||
|
staffGroup.DELETE("/:id", staff.Delete)
|
||||||
|
|
||||||
// 统计
|
// 统计
|
||||||
statistics := authRequired.Group("/statistics")
|
statistics := authRequired.Group("/statistics")
|
||||||
statistics.GET("/kpi", stats.KPIs)
|
statistics.GET("/kpi", stats.KPIs)
|
||||||
|
|||||||
@@ -0,0 +1,355 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"kefu-sys/server/internal/middleware"
|
||||||
|
"kefu-sys/server/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type StaffHandler struct{}
|
||||||
|
|
||||||
|
func NewStaffHandler() *StaffHandler { return &StaffHandler{} }
|
||||||
|
|
||||||
|
func requireStaffManager(c *gin.Context) bool {
|
||||||
|
if middleware.HasAnyRole(c, "admin") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅租户管理员可管理坐席账号"})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 坐席占用:本租户下 agent / supervisor / admin 均计 1 席(不含 disabled)
|
||||||
|
func countActiveSeats(tenantID uint) (int64, error) {
|
||||||
|
var n int64
|
||||||
|
err := model.DB.Model(&model.User{}).
|
||||||
|
Where("tenant_id = ? AND role IN ? AND status <> ?", tenantID, []string{"agent", "supervisor", "admin"}, "disabled").
|
||||||
|
Count(&n).Error
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadTenantSeatLimit(tenantID uint) (int, error) {
|
||||||
|
var tenant model.Tenant
|
||||||
|
if err := model.DB.Select("id", "seat_count").First(&tenant, tenantID).Error; err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if tenant.SeatCount <= 0 {
|
||||||
|
return 2, nil
|
||||||
|
}
|
||||||
|
return tenant.SeatCount, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type StaffItem struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
LastOnlineAt string `json:"last_online_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *StaffHandler) List(c *gin.Context) {
|
||||||
|
if !middleware.HasAnyRole(c, "admin", "supervisor") {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权查看坐席列表"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tenantID := middleware.GetTenantID(c)
|
||||||
|
var users []model.User
|
||||||
|
if err := model.DB.Where("tenant_id = ? AND role IN ?", tenantID, []string{"agent", "supervisor", "admin"}).
|
||||||
|
Order("role asc, id asc").Find(&users).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询坐席失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seatLimit, _ := loadTenantSeatLimit(tenantID)
|
||||||
|
used, _ := countActiveSeats(tenantID)
|
||||||
|
|
||||||
|
items := make([]StaffItem, 0, len(users))
|
||||||
|
for _, u := range users {
|
||||||
|
item := StaffItem{
|
||||||
|
ID: u.ID, Username: u.Username, Nickname: u.Nickname,
|
||||||
|
Role: u.Role, Status: u.Status,
|
||||||
|
CreatedAt: u.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||||
|
}
|
||||||
|
if u.LastOnlineAt != nil {
|
||||||
|
item.LastOnlineAt = u.LastOnlineAt.Format("2006-01-02 15:04:05")
|
||||||
|
}
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
middleware.JSON(c, gin.H{
|
||||||
|
"list": items,
|
||||||
|
"seat_limit": seatLimit,
|
||||||
|
"seat_used": used,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateStaffReq struct {
|
||||||
|
Username string `json:"username" binding:"required"`
|
||||||
|
Password string `json:"password" binding:"required"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *StaffHandler) Create(c *gin.Context) {
|
||||||
|
if !requireStaffManager(c) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req CreateStaffReq
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
username := strings.TrimSpace(req.Username)
|
||||||
|
if utf8.RuneCountInString(username) < 3 || utf8.RuneCountInString(username) > 30 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "用户名需 3-30 个字符"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.Password) < 6 || len(req.Password) > 64 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "密码需 6-64 位"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
role := req.Role
|
||||||
|
if role == "" {
|
||||||
|
role = "agent"
|
||||||
|
}
|
||||||
|
if role != "agent" && role != "supervisor" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "仅可创建客服或主管账号"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nickname := strings.TrimSpace(req.Nickname)
|
||||||
|
if nickname == "" {
|
||||||
|
nickname = username
|
||||||
|
}
|
||||||
|
if utf8.RuneCountInString(nickname) > 50 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "昵称过长"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tenantID := middleware.GetTenantID(c)
|
||||||
|
seatLimit, err := loadTenantSeatLimit(tenantID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "读取坐席配额失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
used, err := countActiveSeats(tenantID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "统计坐席失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if used >= int64(seatLimit) {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "坐席数已达上限,请升级套餐或增加坐席"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var exists int64
|
||||||
|
model.DB.Model(&model.User{}).Where("username = ?", username).Count(&exists)
|
||||||
|
if exists > 0 {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "用户名已存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "创建失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user := model.User{
|
||||||
|
TenantID: tenantID, Role: role, Username: username,
|
||||||
|
PasswordHash: string(hash), Nickname: nickname, Status: "offline",
|
||||||
|
}
|
||||||
|
if err := model.DB.Create(&user).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "创建失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
model.DB.Create(&model.OperationLog{
|
||||||
|
OperatorID: middleware.GetUserID(c),
|
||||||
|
Action: "create_staff",
|
||||||
|
Detail: "创建坐席: " + username + " (" + role + ")",
|
||||||
|
TargetType: "user",
|
||||||
|
TargetID: &user.ID,
|
||||||
|
IP: c.ClientIP(),
|
||||||
|
})
|
||||||
|
|
||||||
|
middleware.JSON(c, StaffItem{
|
||||||
|
ID: user.ID, Username: user.Username, Nickname: user.Nickname,
|
||||||
|
Role: user.Role, Status: user.Status,
|
||||||
|
CreatedAt: user.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateStaffReq struct {
|
||||||
|
Nickname *string `json:"nickname"`
|
||||||
|
Role *string `json:"role"`
|
||||||
|
Status *string `json:"status"`
|
||||||
|
Password *string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *StaffHandler) Update(c *gin.Context) {
|
||||||
|
if !requireStaffManager(c) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tenantID := middleware.GetTenantID(c)
|
||||||
|
id := c.Param("id")
|
||||||
|
var user model.User
|
||||||
|
if err := model.DB.Where("id = ? AND tenant_id = ?", id, tenantID).First(&user).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "账号不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if user.Role == "platform_admin" {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无法操作该账号"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req UpdateStaffReq
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不可降级/删除最后一个 admin:若改角色离开 admin,检查剩余 admin
|
||||||
|
updates := map[string]interface{}{}
|
||||||
|
if req.Nickname != nil {
|
||||||
|
n := strings.TrimSpace(*req.Nickname)
|
||||||
|
if n == "" || utf8.RuneCountInString(n) > 50 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "昵称无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updates["nickname"] = n
|
||||||
|
}
|
||||||
|
if req.Role != nil {
|
||||||
|
r := *req.Role
|
||||||
|
if r != "agent" && r != "supervisor" && r != "admin" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "角色无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 非当前登录用户不能随意把自己改没 admin:禁止把最后一个 admin 改成非 admin
|
||||||
|
if user.Role == "admin" && r != "admin" {
|
||||||
|
var adminCnt int64
|
||||||
|
model.DB.Model(&model.User{}).Where("tenant_id = ? AND role = ? AND status <> ?", tenantID, "admin", "disabled").Count(&adminCnt)
|
||||||
|
if adminCnt <= 1 {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "至少保留一名启用的租户管理员"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 普通管理员不能创建/提升为 admin?允许 admin 设置另一 admin
|
||||||
|
updates["role"] = r
|
||||||
|
}
|
||||||
|
if req.Status != nil {
|
||||||
|
s := *req.Status
|
||||||
|
if s != "online" && s != "offline" && s != "busy" && s != "disabled" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "状态无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 禁用时检查坐席释放;启用时检查配额
|
||||||
|
if s == "disabled" && user.Status != "disabled" {
|
||||||
|
if user.Role == "admin" {
|
||||||
|
var adminCnt int64
|
||||||
|
model.DB.Model(&model.User{}).Where("tenant_id = ? AND role = ? AND status <> ? AND id <> ?", tenantID, "admin", "disabled", user.ID).Count(&adminCnt)
|
||||||
|
if adminCnt < 1 {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "不能禁用唯一的租户管理员"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if user.ID == middleware.GetUserID(c) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "不能禁用当前登录账号"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s != "disabled" && user.Status == "disabled" {
|
||||||
|
seatLimit, _ := loadTenantSeatLimit(tenantID)
|
||||||
|
used, _ := countActiveSeats(tenantID)
|
||||||
|
if used >= int64(seatLimit) {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "坐席数已达上限,无法启用"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updates["status"] = s
|
||||||
|
}
|
||||||
|
if req.Password != nil && *req.Password != "" {
|
||||||
|
if len(*req.Password) < 6 || len(*req.Password) > 64 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "密码需 6-64 位"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "更新密码失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updates["password_hash"] = string(hash)
|
||||||
|
}
|
||||||
|
if len(updates) == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "没有可更新字段"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := model.DB.Model(&user).Updates(updates).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "更新失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
model.DB.First(&user, user.ID)
|
||||||
|
|
||||||
|
model.DB.Create(&model.OperationLog{
|
||||||
|
OperatorID: middleware.GetUserID(c),
|
||||||
|
Action: "update_staff",
|
||||||
|
Detail: "更新坐席: " + user.Username,
|
||||||
|
TargetType: "user",
|
||||||
|
TargetID: &user.ID,
|
||||||
|
IP: c.ClientIP(),
|
||||||
|
})
|
||||||
|
|
||||||
|
item := StaffItem{
|
||||||
|
ID: user.ID, Username: user.Username, Nickname: user.Nickname,
|
||||||
|
Role: user.Role, Status: user.Status,
|
||||||
|
CreatedAt: user.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||||
|
}
|
||||||
|
if user.LastOnlineAt != nil {
|
||||||
|
item.LastOnlineAt = user.LastOnlineAt.Format("2006-01-02 15:04:05")
|
||||||
|
}
|
||||||
|
middleware.JSON(c, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *StaffHandler) Delete(c *gin.Context) {
|
||||||
|
if !requireStaffManager(c) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tenantID := middleware.GetTenantID(c)
|
||||||
|
id := c.Param("id")
|
||||||
|
var user model.User
|
||||||
|
if err := model.DB.Where("id = ? AND tenant_id = ?", id, tenantID).First(&user).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "账号不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if user.ID == middleware.GetUserID(c) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "不能删除当前登录账号"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if user.Role == "admin" {
|
||||||
|
var adminCnt int64
|
||||||
|
model.DB.Model(&model.User{}).Where("tenant_id = ? AND role = ? AND status <> ?", tenantID, "admin", "disabled").Count(&adminCnt)
|
||||||
|
if adminCnt <= 1 && user.Status != "disabled" {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "不能删除唯一的租户管理员"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 软禁用:保留数据关联;物理删除仅对无会话绑定的账号可选——统一用 disabled 更安全
|
||||||
|
if err := model.DB.Model(&user).Update("status", "disabled").Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "禁用失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
model.DB.Create(&model.OperationLog{
|
||||||
|
OperatorID: middleware.GetUserID(c),
|
||||||
|
Action: "disable_staff",
|
||||||
|
Detail: "禁用坐席: " + user.Username,
|
||||||
|
TargetType: "user",
|
||||||
|
TargetID: &user.ID,
|
||||||
|
IP: c.ClientIP(),
|
||||||
|
})
|
||||||
|
middleware.JSON(c, gin.H{"message": "已禁用"})
|
||||||
|
}
|
||||||
@@ -1,14 +1,17 @@
|
|||||||
import { useEffect, useState, type ReactNode } from 'react'
|
import { useEffect, useState, type ReactNode } from 'react'
|
||||||
import { Form, Input, Switch, Select, Button, message, Spin, Empty } from 'antd'
|
import { Form, Input, Switch, Select, Button, message, Spin, Empty, Modal, Popconfirm } from 'antd'
|
||||||
import {
|
import {
|
||||||
LinkOutlined, WechatOutlined, PhoneOutlined, MailOutlined, MobileOutlined, CopyOutlined,
|
LinkOutlined, WechatOutlined, PhoneOutlined, MailOutlined, MobileOutlined, CopyOutlined,
|
||||||
PlusOutlined, SettingOutlined, ApiOutlined, TeamOutlined, SafetyCertificateOutlined,
|
PlusOutlined, SettingOutlined, ApiOutlined, TeamOutlined, UserSwitchOutlined,
|
||||||
MessageOutlined, ClockCircleOutlined, BellOutlined, GlobalOutlined,
|
MessageOutlined, ClockCircleOutlined, BellOutlined, GlobalOutlined,
|
||||||
|
EditOutlined, StopOutlined, CheckCircleOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import {
|
import {
|
||||||
createChannel, getChannels, getTenantSettings, updateChannel, updateTenantSettings,
|
createChannel, createStaff, deleteStaff, getChannels, getStaff, getTenantSettings,
|
||||||
type Channel, type TenantSettings, type WorkHours,
|
updateChannel, updateStaff, updateTenantSettings,
|
||||||
|
type Channel, type StaffUser, type TenantSettings, type WorkHours,
|
||||||
} from '@/services/api'
|
} from '@/services/api'
|
||||||
|
import { useAuth } from '@/stores/auth'
|
||||||
|
|
||||||
const typeMeta: Record<string, { icon: ReactNode; label: string; desc: string }> = {
|
const typeMeta: Record<string, { icon: ReactNode; label: string; desc: string }> = {
|
||||||
web: { icon: <GlobalOutlined />, label: '网页聊天', desc: '嵌入官网或任意网页的在线客服窗口' },
|
web: { icon: <GlobalOutlined />, label: '网页聊天', desc: '嵌入官网或任意网页的在线客服窗口' },
|
||||||
@@ -35,22 +38,40 @@ const hourOptions = [
|
|||||||
{ value: '全天', label: '全天' },
|
{ value: '全天', label: '全天' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const roleLabel: Record<string, string> = {
|
||||||
|
admin: '管理员',
|
||||||
|
supervisor: '主管',
|
||||||
|
agent: '客服',
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusLabel: Record<string, { text: string; className: string }> = {
|
||||||
|
online: { text: '在线', className: 'bg-emerald-50 text-emerald-700' },
|
||||||
|
offline: { text: '离线', className: 'bg-neutral-100 text-neutral-600' },
|
||||||
|
busy: { text: '忙碌', className: 'bg-amber-50 text-amber-700' },
|
||||||
|
disabled: { text: '已禁用', className: 'bg-red-50 text-red-600' },
|
||||||
|
}
|
||||||
|
|
||||||
const tabItems: { key: string; label: string; desc: string; icon: ReactNode }[] = [
|
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: 'assignment', label: '客服分配规则', desc: '会话自动分配策略说明', icon: <TeamOutlined /> },
|
{ key: 'assignment', label: '客服分配规则', desc: '会话自动分配策略说明', icon: <TeamOutlined /> },
|
||||||
{ key: 'permission', label: '权限管理', desc: '角色与能力说明', icon: <SafetyCertificateOutlined /> },
|
|
||||||
{ 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 /> },
|
||||||
]
|
]
|
||||||
|
|
||||||
const Settings = () => {
|
const Settings = () => {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const isAdmin = user?.role === 'admin'
|
||||||
|
const canViewStaff = user?.role === 'admin' || user?.role === 'supervisor'
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState('basic')
|
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 [workForm] = Form.useForm()
|
||||||
const [notifyForm] = Form.useForm()
|
const [notifyForm] = 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)
|
||||||
const [loadingChannels, setLoadingChannels] = useState(false)
|
const [loadingChannels, setLoadingChannels] = useState(false)
|
||||||
@@ -58,8 +79,31 @@ const Settings = () => {
|
|||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [togglingId, setTogglingId] = useState<number | null>(null)
|
const [togglingId, setTogglingId] = useState<number | null>(null)
|
||||||
|
|
||||||
|
const [staffList, setStaffList] = useState<StaffUser[]>([])
|
||||||
|
const [seatLimit, setSeatLimit] = useState(0)
|
||||||
|
const [seatUsed, setSeatUsed] = useState(0)
|
||||||
|
const [loadingStaff, setLoadingStaff] = useState(false)
|
||||||
|
const [staffModalOpen, setStaffModalOpen] = useState(false)
|
||||||
|
const [editingStaff, setEditingStaff] = useState<StaffUser | null>(null)
|
||||||
|
const [savingStaff, setSavingStaff] = useState(false)
|
||||||
|
|
||||||
const currentTab = tabItems.find(t => t.key === activeTab) || tabItems[0]
|
const currentTab = tabItems.find(t => t.key === activeTab) || tabItems[0]
|
||||||
|
|
||||||
|
const loadStaff = async () => {
|
||||||
|
setLoadingStaff(true)
|
||||||
|
try {
|
||||||
|
const res = await getStaff()
|
||||||
|
setStaffList(Array.isArray(res.data?.list) ? res.data.list : [])
|
||||||
|
setSeatLimit(res.data?.seat_limit ?? 0)
|
||||||
|
setSeatUsed(res.data?.seat_used ?? 0)
|
||||||
|
} catch (e) {
|
||||||
|
setStaffList([])
|
||||||
|
message.error(e instanceof Error ? e.message : '加载坐席失败')
|
||||||
|
} finally {
|
||||||
|
setLoadingStaff(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const loadChannels = async () => {
|
const loadChannels = async () => {
|
||||||
setLoadingChannels(true)
|
setLoadingChannels(true)
|
||||||
try {
|
try {
|
||||||
@@ -108,8 +152,79 @@ 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'].includes(activeTab)) loadSettings()
|
||||||
|
if (activeTab === 'staff' && canViewStaff) loadStaff()
|
||||||
}, [activeTab])
|
}, [activeTab])
|
||||||
|
|
||||||
|
const openCreateStaff = () => {
|
||||||
|
setEditingStaff(null)
|
||||||
|
staffForm.resetFields()
|
||||||
|
staffForm.setFieldsValue({ role: 'agent' })
|
||||||
|
setStaffModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEditStaff = (s: StaffUser) => {
|
||||||
|
setEditingStaff(s)
|
||||||
|
staffForm.resetFields()
|
||||||
|
staffForm.setFieldsValue({
|
||||||
|
nickname: s.nickname,
|
||||||
|
role: s.role === 'admin' ? 'admin' : s.role,
|
||||||
|
status: s.status,
|
||||||
|
password: undefined,
|
||||||
|
})
|
||||||
|
setStaffModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveStaff = async (values: {
|
||||||
|
username?: string; password?: string; nickname?: string; role?: string; status?: string
|
||||||
|
}) => {
|
||||||
|
setSavingStaff(true)
|
||||||
|
try {
|
||||||
|
if (editingStaff) {
|
||||||
|
await updateStaff(editingStaff.id, {
|
||||||
|
nickname: values.nickname,
|
||||||
|
role: values.role === 'admin' ? 'admin' : values.role,
|
||||||
|
status: values.status,
|
||||||
|
password: values.password || undefined,
|
||||||
|
})
|
||||||
|
message.success('坐席已更新')
|
||||||
|
} else {
|
||||||
|
await createStaff({
|
||||||
|
username: values.username!.trim(),
|
||||||
|
password: values.password!,
|
||||||
|
nickname: values.nickname?.trim(),
|
||||||
|
role: values.role || 'agent',
|
||||||
|
})
|
||||||
|
message.success('坐席已创建')
|
||||||
|
}
|
||||||
|
setStaffModalOpen(false)
|
||||||
|
await loadStaff()
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '保存失败')
|
||||||
|
} finally {
|
||||||
|
setSavingStaff(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDisableStaff = async (s: StaffUser) => {
|
||||||
|
try {
|
||||||
|
await deleteStaff(s.id)
|
||||||
|
message.success('已禁用')
|
||||||
|
await loadStaff()
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEnableStaff = async (s: StaffUser) => {
|
||||||
|
try {
|
||||||
|
await updateStaff(s.id, { status: 'offline' })
|
||||||
|
message.success('已启用')
|
||||||
|
await loadStaff()
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '启用失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const savePartial = async (payload: Parameters<typeof updateTenantSettings>[0], okText = '已保存') => {
|
const savePartial = async (payload: Parameters<typeof updateTenantSettings>[0], okText = '已保存') => {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
@@ -226,10 +341,22 @@ const Settings = () => {
|
|||||||
<h1 className="text-base font-semibold text-neutral-900 m-0 truncate">{currentTab.label}</h1>
|
<h1 className="text-base font-semibold text-neutral-900 m-0 truncate">{currentTab.label}</h1>
|
||||||
</div>
|
</div>
|
||||||
{panelHeaderAction}
|
{panelHeaderAction}
|
||||||
|
{activeTab === 'staff' && isAdmin && (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
className="!h-8"
|
||||||
|
disabled={seatLimit > 0 && seatUsed >= seatLimit}
|
||||||
|
onClick={openCreateStaff}
|
||||||
|
>
|
||||||
|
添加坐席
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto px-6 py-5">
|
<div className="flex-1 overflow-auto px-6 py-5">
|
||||||
<div className="max-w-3xl">
|
<div className={activeTab === 'staff' ? 'max-w-4xl' : 'max-w-3xl'}>
|
||||||
<p className="text-xs text-neutral-400 mt-0 mb-5">{currentTab.desc}</p>
|
<p className="text-xs text-neutral-400 mt-0 mb-5">{currentTab.desc}</p>
|
||||||
|
|
||||||
{activeTab === 'basic' && (
|
{activeTab === 'basic' && (
|
||||||
@@ -403,16 +530,115 @@ const Settings = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'permission' && (
|
{activeTab === 'staff' && (
|
||||||
<div className="bg-white rounded-xl border border-neutral-200 shadow-sm p-6">
|
<div>
|
||||||
<div className="text-sm text-neutral-600 leading-relaxed space-y-3">
|
{!canViewStaff ? (
|
||||||
<p className="m-0">角色权限由系统内置,自定义角色将在后续版本开放:</p>
|
<div className="bg-white rounded-xl border border-neutral-200 p-8 text-center text-sm text-neutral-500">
|
||||||
<ul className="m-0 pl-5 space-y-2 text-sm text-neutral-600">
|
仅管理员或主管可查看坐席列表
|
||||||
<li><span className="font-medium text-neutral-800">租户管理员</span> — 全部配置、知识库、统计、坐席管理</li>
|
</div>
|
||||||
<li><span className="font-medium text-neutral-800">主管</span> — 会话监管、客户与知识库管理、数据统计</li>
|
) : loadingStaff ? (
|
||||||
<li><span className="font-medium text-neutral-800">一线客服</span> — 工作台接待、查看客户与知识库</li>
|
<div className="py-16 text-center"><Spin /></div>
|
||||||
</ul>
|
) : (
|
||||||
</div>
|
<>
|
||||||
|
<div className="flex items-center gap-4 mb-4 flex-wrap">
|
||||||
|
<div className="rounded-xl bg-white border border-neutral-200 px-4 py-3 min-w-[140px]">
|
||||||
|
<div className="text-[11px] text-neutral-400 mb-0.5">坐席占用</div>
|
||||||
|
<div className="text-lg font-semibold text-neutral-900 tabular-nums">
|
||||||
|
{seatUsed}
|
||||||
|
<span className="text-sm font-normal text-neutral-400"> / {seatLimit}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-neutral-400 flex-1 min-w-[200px]">
|
||||||
|
启用中的管理员、主管、客服各占 1 个坐席。禁用账号释放配额。角色:客服接待会话;主管可看统计与客户;管理员可改系统设置。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{staffList.length === 0 ? (
|
||||||
|
<Empty description="暂无坐席账号" className="bg-white rounded-xl border border-neutral-200 py-12" />
|
||||||
|
) : (
|
||||||
|
<div className="rounded-xl border border-neutral-200 bg-white overflow-hidden">
|
||||||
|
<table className="w-full border-collapse min-w-[640px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-neutral-50 text-[11px] font-semibold text-neutral-500">
|
||||||
|
<th className="text-left px-4 py-2.5 border-b border-neutral-200">账号</th>
|
||||||
|
<th className="text-left px-4 py-2.5 border-b border-neutral-200">昵称</th>
|
||||||
|
<th className="text-left px-4 py-2.5 border-b border-neutral-200">角色</th>
|
||||||
|
<th className="text-left px-4 py-2.5 border-b border-neutral-200">状态</th>
|
||||||
|
<th className="text-left px-4 py-2.5 border-b border-neutral-200">创建时间</th>
|
||||||
|
{isAdmin && (
|
||||||
|
<th className="text-right px-4 py-2.5 border-b border-neutral-200">操作</th>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{staffList.map(s => {
|
||||||
|
const st = statusLabel[s.status] || statusLabel.offline
|
||||||
|
return (
|
||||||
|
<tr key={s.id} className="hover:bg-neutral-50 border-t border-neutral-100">
|
||||||
|
<td className="px-4 py-3 text-sm font-medium text-neutral-800">{s.username}</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-neutral-700">{s.nickname || '—'}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-full bg-neutral-100 text-neutral-600">
|
||||||
|
{roleLabel[s.role] || s.role}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${st.className}`}>
|
||||||
|
{st.text}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs text-neutral-400 whitespace-nowrap">
|
||||||
|
{s.created_at ? s.created_at.slice(0, 16) : '—'}
|
||||||
|
</td>
|
||||||
|
{isAdmin && (
|
||||||
|
<td className="px-4 py-3 text-right" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="inline-flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="编辑"
|
||||||
|
className="w-7 h-7 rounded-md flex items-center justify-center text-neutral-400 hover:text-[#2563eb] hover:bg-blue-50 border-0 bg-transparent cursor-pointer"
|
||||||
|
onClick={() => openEditStaff(s)}
|
||||||
|
>
|
||||||
|
<EditOutlined className="text-xs" />
|
||||||
|
</button>
|
||||||
|
{s.status === 'disabled' ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="启用"
|
||||||
|
className="w-7 h-7 rounded-md flex items-center justify-center text-neutral-400 hover:text-emerald-600 hover:bg-emerald-50 border-0 bg-transparent cursor-pointer"
|
||||||
|
onClick={() => handleEnableStaff(s)}
|
||||||
|
>
|
||||||
|
<CheckCircleOutlined className="text-xs" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<Popconfirm
|
||||||
|
title="确认禁用该账号?"
|
||||||
|
description="禁用后无法登录,并释放坐席"
|
||||||
|
onConfirm={() => handleDisableStaff(s)}
|
||||||
|
disabled={s.id === user?.user_id}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="禁用"
|
||||||
|
disabled={s.id === user?.user_id}
|
||||||
|
className="w-7 h-7 rounded-md flex items-center justify-center text-neutral-400 hover:text-red-500 hover:bg-red-50 border-0 bg-transparent cursor-pointer disabled:opacity-30"
|
||||||
|
>
|
||||||
|
<StopOutlined className="text-xs" />
|
||||||
|
</button>
|
||||||
|
</Popconfirm>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -541,6 +767,75 @@ const Settings = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={editingStaff ? '编辑坐席' : '添加坐席'}
|
||||||
|
open={staffModalOpen}
|
||||||
|
onCancel={() => setStaffModalOpen(false)}
|
||||||
|
onOk={() => staffForm.submit()}
|
||||||
|
confirmLoading={savingStaff}
|
||||||
|
destroyOnClose
|
||||||
|
okText="保存"
|
||||||
|
width={440}
|
||||||
|
>
|
||||||
|
<Form form={staffForm} layout="vertical" onFinish={handleSaveStaff} className="mt-2" requiredMark={false}>
|
||||||
|
{!editingStaff && (
|
||||||
|
<>
|
||||||
|
<Form.Item
|
||||||
|
name="username"
|
||||||
|
label="登录用户名"
|
||||||
|
rules={[{ required: true, message: '请输入用户名' }, { min: 3, max: 30, message: '3-30 个字符' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="英文/数字,全局唯一" autoComplete="off" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="password"
|
||||||
|
label="初始密码"
|
||||||
|
rules={[{ required: true, message: '请输入密码' }, { min: 6, max: 64, message: '至少 6 位' }]}
|
||||||
|
>
|
||||||
|
<Input.Password placeholder="至少 6 位" autoComplete="new-password" />
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Form.Item name="nickname" label="显示昵称" rules={[{ max: 50 }]}>
|
||||||
|
<Input placeholder="工作台显示名称" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="role" label="角色" rules={[{ required: true }]}>
|
||||||
|
<Select
|
||||||
|
options={
|
||||||
|
editingStaff?.role === 'admin'
|
||||||
|
? [
|
||||||
|
{ value: 'admin', label: '管理员' },
|
||||||
|
{ value: 'supervisor', label: '主管' },
|
||||||
|
{ value: 'agent', label: '客服' },
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ value: 'agent', label: '客服' },
|
||||||
|
{ value: 'supervisor', label: '主管' },
|
||||||
|
...(isAdmin ? [{ value: 'admin', label: '管理员' }] : []),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
{editingStaff && (
|
||||||
|
<>
|
||||||
|
<Form.Item name="status" label="状态">
|
||||||
|
<Select
|
||||||
|
options={[
|
||||||
|
{ value: 'online', label: '在线' },
|
||||||
|
{ value: 'offline', label: '离线' },
|
||||||
|
{ value: 'busy', label: '忙碌' },
|
||||||
|
{ value: 'disabled', label: '禁用' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="password" label="重置密码" extra="留空则不修改">
|
||||||
|
<Input.Password placeholder="可选,至少 6 位" autoComplete="new-password" />
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -223,6 +223,28 @@ 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)
|
||||||
|
|
||||||
|
// 坐席账号
|
||||||
|
export interface StaffUser {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
nickname: string
|
||||||
|
role: string
|
||||||
|
status: string
|
||||||
|
created_at: string
|
||||||
|
last_online_at?: string
|
||||||
|
}
|
||||||
|
export interface StaffListResult {
|
||||||
|
list: StaffUser[]
|
||||||
|
seat_limit: number
|
||||||
|
seat_used: number
|
||||||
|
}
|
||||||
|
export const getStaff = () => get<StaffListResult>('/staff')
|
||||||
|
export const createStaff = (data: { username: string; password: string; nickname?: string; role?: string }) =>
|
||||||
|
post<StaffUser>('/staff', data)
|
||||||
|
export const updateStaff = (id: number, data: { nickname?: string; role?: string; status?: string; password?: string }) =>
|
||||||
|
put<StaffUser>(`/staff/${id}`, data)
|
||||||
|
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 interface TenantSettings {
|
export interface TenantSettings {
|
||||||
|
|||||||
Reference in New Issue
Block a user