增加客服分组管理
This commit is contained in:
@@ -73,3 +73,29 @@ type ChatQrCode struct {
|
|||||||
func (ChatQrCode) TableName() string {
|
func (ChatQrCode) TableName() string {
|
||||||
return "chat_qrcode_pool"
|
return "chat_qrcode_pool"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ChatSupportGroup struct {
|
||||||
|
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||||
|
Code string `gorm:"size:64;not null;uniqueIndex" json:"code"`
|
||||||
|
Name string `gorm:"size:64;not null" json:"name"`
|
||||||
|
Description string `gorm:"size:255;not null;default:''" json:"description"`
|
||||||
|
Status string `gorm:"size:16;not null;default:'active';index" json:"status"`
|
||||||
|
SortOrder int `gorm:"not null;default:0" json:"sort_order"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ChatSupportGroup) TableName() string {
|
||||||
|
return "chat_support_groups"
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChatSupportGroupMember struct {
|
||||||
|
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||||
|
GroupID uint64 `gorm:"not null;uniqueIndex:uk_support_group_member" json:"group_id"`
|
||||||
|
AdminID uint64 `gorm:"column:admin_user_id;not null;uniqueIndex:uk_support_group_member;index" json:"admin_id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ChatSupportGroupMember) TableName() string {
|
||||||
|
return "chat_support_group_members"
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
"hfb_sys/backend/internal/model"
|
"hfb_sys/backend/internal/model"
|
||||||
|
"hfb_sys/backend/internal/modules/supportgroup"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 会话类型常量
|
// 会话类型常量
|
||||||
@@ -56,11 +57,17 @@ func EnsureListingConversation(tx *gorm.DB, listing model.RentalListing, preferr
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取客服。开放上传场景优先拉上传管理员,普通发布回退到默认客服。
|
// 获取收号组客服。分组未配置时回退到开放上传管理员或默认客服。
|
||||||
supportID := preferredSupportAdminID
|
supportID, err := supportgroup.PickSupportAdmin(tx, supportgroup.GroupCodeOwnerOnboarding)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if supportID <= 0 {
|
||||||
|
supportID = preferredSupportAdminID
|
||||||
if supportID <= 0 {
|
if supportID <= 0 {
|
||||||
supportID = defaultSupportAdminID(tx)
|
supportID = defaultSupportAdminID(tx)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if supportID > 0 {
|
if supportID > 0 {
|
||||||
participants = append(participants, model.ChatParticipant{
|
participants = append(participants, model.ChatParticipant{
|
||||||
ConversationID: conversation.ID,
|
ConversationID: conversation.ID,
|
||||||
@@ -123,7 +130,7 @@ func EnsureListingConversation(tx *gorm.DB, listing model.RentalListing, preferr
|
|||||||
return &conversation, nil
|
return &conversation, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddRenterToListingConversation 拉租客进发布群
|
// AddRenterToListingConversation 拉租客与卖号组客服进发布群
|
||||||
func AddRenterToListingConversation(tx *gorm.DB, listingID uint64, renterID uint64, orderNo string) error {
|
func AddRenterToListingConversation(tx *gorm.DB, listingID uint64, renterID uint64, orderNo string) error {
|
||||||
// 1. 查找发布群
|
// 1. 查找发布群
|
||||||
var conv model.ChatConversation
|
var conv model.ChatConversation
|
||||||
@@ -150,8 +157,29 @@ func AddRenterToListingConversation(tx *gorm.DB, listingID uint64, renterID uint
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 发系统消息
|
// 3. 支付后由卖号组客服接入跟进交接和售后。
|
||||||
|
handoffSupportID, err := supportgroup.PickSupportAdmin(tx, supportgroup.GroupCodeRenterHandoff)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if handoffSupportID > 0 {
|
||||||
|
support := model.ChatParticipant{
|
||||||
|
ConversationID: conv.ID,
|
||||||
|
ParticipantType: "admin",
|
||||||
|
ParticipantID: handoffSupportID,
|
||||||
|
Role: "support",
|
||||||
|
JoinedAt: now,
|
||||||
|
}
|
||||||
|
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&support).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 发系统消息
|
||||||
message := "租客已加入群聊(订单 " + orderNo + ")"
|
message := "租客已加入群聊(订单 " + orderNo + ")"
|
||||||
|
if handoffSupportID > 0 {
|
||||||
|
message += ",卖号组客服已接入"
|
||||||
|
}
|
||||||
return sendSystemMessage(tx, conv.ID, message)
|
return sendSystemMessage(tx, conv.ID, message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,9 +34,13 @@ type AdminConversationCountsDTO struct {
|
|||||||
func (r *Repository) TransferConversation(ctx context.Context, principal Principal, conversationID uint64, toAdminID uint64) error {
|
func (r *Repository) TransferConversation(ctx context.Context, principal Principal, conversationID uint64, toAdminID uint64) error {
|
||||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
// 验证当前操作者是会话参与者
|
// 验证当前操作者是会话参与者
|
||||||
if _, err := r.findParticipant(tx, principal, conversationID, false); err != nil {
|
current, err := r.findParticipant(tx, principal, conversationID, false)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if current.Role != "support" || current.ParticipantType != "admin" {
|
||||||
|
return ErrPermissionDenied
|
||||||
|
}
|
||||||
// 验证目标客服存在、活跃且拥有客服角色,避免转接给超级管理员。
|
// 验证目标客服存在、活跃且拥有客服角色,避免转接给超级管理员。
|
||||||
if !adminIsSupport(tx, toAdminID) {
|
if !adminIsSupport(tx, toAdminID) {
|
||||||
return fmt.Errorf("目标客服不存在、已禁用或不是客服角色")
|
return fmt.Errorf("目标客服不存在、已禁用或不是客服角色")
|
||||||
@@ -51,20 +55,13 @@ func (r *Repository) TransferConversation(ctx context.Context, principal Princip
|
|||||||
if count > 0 {
|
if count > 0 {
|
||||||
return fmt.Errorf("该客服已在会话中")
|
return fmt.Errorf("该客服已在会话中")
|
||||||
}
|
}
|
||||||
// 删除原客服参与者
|
// 只转接当前客服本人,避免误删同群里的收号组/卖号组其他客服。
|
||||||
if err := tx.Where("conversation_id = ? AND participant_type = ? AND role = ?", conversationID, "admin", "support").
|
if err := tx.Model(&model.ChatParticipant{}).
|
||||||
Delete(&model.ChatParticipant{}).Error; err != nil {
|
Where("id = ?", current.ID).
|
||||||
return err
|
Updates(map[string]interface{}{
|
||||||
}
|
"participant_id": toAdminID,
|
||||||
// 添加新客服参与者
|
"joined_at": time.Now(),
|
||||||
participant := model.ChatParticipant{
|
}).Error; err != nil {
|
||||||
ConversationID: conversationID,
|
|
||||||
ParticipantType: "admin",
|
|
||||||
ParticipantID: toAdminID,
|
|
||||||
Role: "support",
|
|
||||||
JoinedAt: time.Now(),
|
|
||||||
}
|
|
||||||
if err := tx.Create(&participant).Error; err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// 添加系统消息记录转接
|
// 添加系统消息记录转接
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package supportgroup
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
const (
|
||||||
|
GroupCodeOwnerOnboarding = "owner_onboarding"
|
||||||
|
GroupCodeRenterHandoff = "renter_handoff"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MemberDTO struct {
|
||||||
|
ID uint64 `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
SupportStatus string `json:"support_status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GroupDTO struct {
|
||||||
|
ID uint64 `json:"id"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
Members []MemberDTO `json:"members"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateGroupRequest struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
MemberIDs []uint64 `json:"member_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateGroupRequest struct {
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
MemberIDs []uint64 `json:"member_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AssignMembersRequest struct {
|
||||||
|
MemberIDs []uint64 `json:"member_ids"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package supportgroup
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrDependencyUnavailable = errors.New("dependency unavailable")
|
||||||
|
ErrInvalidGroup = errors.New("invalid support group")
|
||||||
|
ErrProtectedGroup = errors.New("protected support group")
|
||||||
|
)
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package supportgroup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"hfb_sys/backend/pkg/response"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
service *Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(service *Service) *Handler {
|
||||||
|
return &Handler{service: service}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) List(c *gin.Context) {
|
||||||
|
items, err := h.service.List(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) FindByID(c *gin.Context) {
|
||||||
|
id, ok := parseID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, err := h.service.FindByID(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Create(c *gin.Context) {
|
||||||
|
var req CreateGroupRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "分组名称不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, err := h.service.Create(c.Request.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Created(c, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Update(c *gin.Context) {
|
||||||
|
id, ok := parseID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req UpdateGroupRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "分组名称不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, err := h.service.Update(c.Request.Context(), id, req)
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Delete(c *gin.Context) {
|
||||||
|
id, ok := parseID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.service.Delete(c.Request.Context(), id); err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"deleted": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) AssignMembers(c *gin.Context) {
|
||||||
|
id, ok := parseID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req AssignMembersRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "请求格式不正确")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.service.AssignMembers(c.Request.Context(), id, req); err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"updated": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ListSupportAdmins(c *gin.Context) {
|
||||||
|
items, err := h.service.ListSupportAdmins(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseID(c *gin.Context) (uint64, bool) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
response.BadRequest(c, "ID 不正确")
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return id, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeError(c *gin.Context, err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ErrInvalidGroup):
|
||||||
|
response.BadRequest(c, "客服分组信息不正确")
|
||||||
|
case errors.Is(err, ErrProtectedGroup):
|
||||||
|
response.Error(c, http.StatusConflict, "protected_group", "默认分组不能删除")
|
||||||
|
case IsNotFound(err):
|
||||||
|
response.NotFound(c, "客服分组不存在")
|
||||||
|
default:
|
||||||
|
response.Error(c, http.StatusInternalServerError, "internal_error", "客服分组操作失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
package supportgroup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hfb_sys/backend/internal/model"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Repository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRepository(db *gorm.DB) *Repository {
|
||||||
|
return &Repository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) List(ctx context.Context) ([]GroupDTO, error) {
|
||||||
|
var groups []model.ChatSupportGroup
|
||||||
|
if err := r.db.WithContext(ctx).Order("sort_order ASC, id ASC").Find(&groups).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items := make([]GroupDTO, 0, len(groups))
|
||||||
|
for _, group := range groups {
|
||||||
|
dto, err := r.toDTO(ctx, group)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, dto)
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) FindByID(ctx context.Context, id uint64) (*GroupDTO, error) {
|
||||||
|
var group model.ChatSupportGroup
|
||||||
|
if err := r.db.WithContext(ctx).First(&group, id).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
dto, err := r.toDTO(ctx, group)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &dto, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) Create(ctx context.Context, req CreateGroupRequest) (*GroupDTO, error) {
|
||||||
|
code := strings.TrimSpace(req.Code)
|
||||||
|
if code == "" {
|
||||||
|
code = makeCustomCode(req.Name)
|
||||||
|
}
|
||||||
|
status := normalizeStatus(req.Status)
|
||||||
|
group := model.ChatSupportGroup{
|
||||||
|
Code: code,
|
||||||
|
Name: strings.TrimSpace(req.Name),
|
||||||
|
Description: strings.TrimSpace(req.Description),
|
||||||
|
Status: status,
|
||||||
|
SortOrder: req.SortOrder,
|
||||||
|
}
|
||||||
|
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Create(&group).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return replaceMembers(tx, group.ID, req.MemberIDs)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return r.FindByID(ctx, group.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) Update(ctx context.Context, id uint64, req UpdateGroupRequest) (*GroupDTO, error) {
|
||||||
|
var group model.ChatSupportGroup
|
||||||
|
db := r.db.WithContext(ctx)
|
||||||
|
if err := db.First(&group, id).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
group.Name = strings.TrimSpace(req.Name)
|
||||||
|
group.Description = strings.TrimSpace(req.Description)
|
||||||
|
group.Status = normalizeStatus(req.Status)
|
||||||
|
group.SortOrder = req.SortOrder
|
||||||
|
err := db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Save(&group).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return replaceMembers(tx, group.ID, req.MemberIDs)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return r.FindByID(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) Delete(ctx context.Context, id uint64) error {
|
||||||
|
var group model.ChatSupportGroup
|
||||||
|
db := r.db.WithContext(ctx)
|
||||||
|
if err := db.First(&group, id).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if isProtectedCode(group.Code) {
|
||||||
|
return ErrProtectedGroup
|
||||||
|
}
|
||||||
|
return db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Where("group_id = ?", id).Delete(&model.ChatSupportGroupMember{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Delete(&model.ChatSupportGroup{}, id).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) AssignMembers(ctx context.Context, id uint64, memberIDs []uint64) error {
|
||||||
|
var count int64
|
||||||
|
if err := r.db.WithContext(ctx).Model(&model.ChatSupportGroup{}).Where("id = ?", id).Count(&count).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count == 0 {
|
||||||
|
return gorm.ErrRecordNotFound
|
||||||
|
}
|
||||||
|
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
return replaceMembers(tx, id, memberIDs)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) ListSupportAdmins(ctx context.Context) ([]MemberDTO, error) {
|
||||||
|
var rows []struct {
|
||||||
|
ID uint64
|
||||||
|
Username string
|
||||||
|
Nickname string
|
||||||
|
SupportStatus string
|
||||||
|
}
|
||||||
|
err := r.db.WithContext(ctx).Table("admin_users AS au").
|
||||||
|
Select("DISTINCT au.id, au.username, au.nickname, au.support_status").
|
||||||
|
Joins("JOIN admin_user_roles AS aur ON aur.admin_user_id = au.id").
|
||||||
|
Joins("JOIN roles AS r ON r.id = aur.role_id").
|
||||||
|
Where("au.status = ? AND r.code = ?", "active", "cs").
|
||||||
|
Order("CASE au.support_status WHEN 'online' THEN 0 WHEN 'busy' THEN 1 ELSE 2 END, au.id ASC").
|
||||||
|
Scan(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items := make([]MemberDTO, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
items = append(items, MemberDTO{
|
||||||
|
ID: row.ID,
|
||||||
|
Username: row.Username,
|
||||||
|
Nickname: row.Nickname,
|
||||||
|
SupportStatus: row.SupportStatus,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) toDTO(ctx context.Context, group model.ChatSupportGroup) (GroupDTO, error) {
|
||||||
|
members, err := r.members(ctx, group.ID)
|
||||||
|
if err != nil {
|
||||||
|
return GroupDTO{}, err
|
||||||
|
}
|
||||||
|
return GroupDTO{
|
||||||
|
ID: group.ID,
|
||||||
|
Code: group.Code,
|
||||||
|
Name: group.Name,
|
||||||
|
Description: group.Description,
|
||||||
|
Status: group.Status,
|
||||||
|
SortOrder: group.SortOrder,
|
||||||
|
Members: members,
|
||||||
|
CreatedAt: group.CreatedAt,
|
||||||
|
UpdatedAt: group.UpdatedAt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) members(ctx context.Context, groupID uint64) ([]MemberDTO, error) {
|
||||||
|
var rows []struct {
|
||||||
|
ID uint64
|
||||||
|
Username string
|
||||||
|
Nickname string
|
||||||
|
SupportStatus string
|
||||||
|
}
|
||||||
|
err := r.db.WithContext(ctx).Table("chat_support_group_members AS gm").
|
||||||
|
Select("au.id, au.username, au.nickname, au.support_status").
|
||||||
|
Joins("JOIN admin_users AS au ON au.id = gm.admin_user_id").
|
||||||
|
Where("gm.group_id = ?", groupID).
|
||||||
|
Order("au.id ASC").
|
||||||
|
Scan(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items := make([]MemberDTO, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
items = append(items, MemberDTO{
|
||||||
|
ID: row.ID,
|
||||||
|
Username: row.Username,
|
||||||
|
Nickname: row.Nickname,
|
||||||
|
SupportStatus: row.SupportStatus,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func replaceMembers(tx *gorm.DB, groupID uint64, memberIDs []uint64) error {
|
||||||
|
if err := tx.Where("group_id = ?", groupID).Delete(&model.ChatSupportGroupMember{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, adminID := range uniqueIDs(memberIDs) {
|
||||||
|
if adminID == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
member := model.ChatSupportGroupMember{GroupID: groupID, AdminID: adminID}
|
||||||
|
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&member).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeStatus(status string) string {
|
||||||
|
switch status {
|
||||||
|
case "disabled":
|
||||||
|
return "disabled"
|
||||||
|
default:
|
||||||
|
return "active"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isProtectedCode(code string) bool {
|
||||||
|
return code == GroupCodeOwnerOnboarding || code == GroupCodeRenterHandoff
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeCustomCode(name string) string {
|
||||||
|
base := strings.TrimSpace(strings.ToLower(name))
|
||||||
|
base = strings.ReplaceAll(base, " ", "_")
|
||||||
|
base = strings.ReplaceAll(base, "-", "_")
|
||||||
|
if base == "" {
|
||||||
|
base = "custom"
|
||||||
|
}
|
||||||
|
return "custom_" + base
|
||||||
|
}
|
||||||
|
|
||||||
|
func uniqueIDs(ids []uint64) []uint64 {
|
||||||
|
seen := make(map[uint64]struct{}, len(ids))
|
||||||
|
result := make([]uint64, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
if _, ok := seen[id]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
result = append(result, id)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsNotFound(err error) bool {
|
||||||
|
return errors.Is(err, gorm.ErrRecordNotFound)
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package supportgroup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"hfb_sys/backend/internal/model"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func PickSupportAdmin(tx *gorm.DB, groupCode string) (uint64, error) {
|
||||||
|
var row struct {
|
||||||
|
ID uint64
|
||||||
|
}
|
||||||
|
err := tx.Table("chat_support_groups AS g").
|
||||||
|
Select("au.id").
|
||||||
|
Joins("JOIN chat_support_group_members AS gm ON gm.group_id = g.id").
|
||||||
|
Joins("JOIN admin_users AS au ON au.id = gm.admin_user_id").
|
||||||
|
Where("g.code = ? AND g.status = ? AND au.status = ?", groupCode, "active", "active").
|
||||||
|
Order(`CASE au.support_status WHEN 'online' THEN 0 WHEN 'busy' THEN 1 ELSE 2 END,
|
||||||
|
(
|
||||||
|
SELECT COUNT(1)
|
||||||
|
FROM chat_participants AS cp
|
||||||
|
WHERE cp.participant_type = 'admin'
|
||||||
|
AND cp.role = 'support'
|
||||||
|
AND cp.participant_id = au.id
|
||||||
|
) ASC,
|
||||||
|
au.id ASC`).
|
||||||
|
Limit(1).
|
||||||
|
Scan(&row).Error
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if row.ID > 0 {
|
||||||
|
return row.ID, nil
|
||||||
|
}
|
||||||
|
return fallbackSupportAdmin(tx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fallbackSupportAdmin(tx *gorm.DB) (uint64, error) {
|
||||||
|
var admin model.AdminUser
|
||||||
|
err := tx.Table("admin_users AS au").
|
||||||
|
Select("au.*").
|
||||||
|
Joins("JOIN admin_user_roles AS aur ON aur.admin_user_id = au.id").
|
||||||
|
Joins("JOIN roles AS r ON r.id = aur.role_id").
|
||||||
|
Where("au.status = ? AND r.code = ?", "active", "cs").
|
||||||
|
Order("CASE au.support_status WHEN 'online' THEN 0 WHEN 'busy' THEN 1 ELSE 2 END, au.id ASC").
|
||||||
|
Limit(1).
|
||||||
|
First(&admin).Error
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return admin.ID, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package supportgroup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
repo *Repository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(repo *Repository) *Service {
|
||||||
|
return &Service{repo: repo}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) List(ctx context.Context) ([]GroupDTO, error) {
|
||||||
|
if s.repo == nil {
|
||||||
|
return nil, ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
return s.repo.List(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) FindByID(ctx context.Context, id uint64) (*GroupDTO, error) {
|
||||||
|
if s.repo == nil {
|
||||||
|
return nil, ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
return s.repo.FindByID(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Create(ctx context.Context, req CreateGroupRequest) (*GroupDTO, error) {
|
||||||
|
if s.repo == nil {
|
||||||
|
return nil, ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
req.Name = strings.TrimSpace(req.Name)
|
||||||
|
if req.Name == "" {
|
||||||
|
return nil, ErrInvalidGroup
|
||||||
|
}
|
||||||
|
return s.repo.Create(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Update(ctx context.Context, id uint64, req UpdateGroupRequest) (*GroupDTO, error) {
|
||||||
|
if s.repo == nil {
|
||||||
|
return nil, ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
req.Name = strings.TrimSpace(req.Name)
|
||||||
|
if id == 0 || req.Name == "" {
|
||||||
|
return nil, ErrInvalidGroup
|
||||||
|
}
|
||||||
|
return s.repo.Update(ctx, id, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Delete(ctx context.Context, id uint64) error {
|
||||||
|
if s.repo == nil {
|
||||||
|
return ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
if id == 0 {
|
||||||
|
return ErrInvalidGroup
|
||||||
|
}
|
||||||
|
return s.repo.Delete(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) AssignMembers(ctx context.Context, id uint64, req AssignMembersRequest) error {
|
||||||
|
if s.repo == nil {
|
||||||
|
return ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
if id == 0 {
|
||||||
|
return ErrInvalidGroup
|
||||||
|
}
|
||||||
|
return s.repo.AssignMembers(ctx, id, req.MemberIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ListSupportAdmins(ctx context.Context) ([]MemberDTO, error) {
|
||||||
|
if s.repo == nil {
|
||||||
|
return nil, ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
return s.repo.ListSupportAdmins(ctx)
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ import (
|
|||||||
"hfb_sys/backend/internal/modules/paymentaccount"
|
"hfb_sys/backend/internal/modules/paymentaccount"
|
||||||
"hfb_sys/backend/internal/modules/paymentconfig"
|
"hfb_sys/backend/internal/modules/paymentconfig"
|
||||||
"hfb_sys/backend/internal/modules/realname"
|
"hfb_sys/backend/internal/modules/realname"
|
||||||
|
"hfb_sys/backend/internal/modules/supportgroup"
|
||||||
"hfb_sys/backend/internal/modules/systemconfig"
|
"hfb_sys/backend/internal/modules/systemconfig"
|
||||||
"hfb_sys/backend/internal/modules/user"
|
"hfb_sys/backend/internal/modules/user"
|
||||||
"hfb_sys/backend/internal/modules/wallet"
|
"hfb_sys/backend/internal/modules/wallet"
|
||||||
@@ -283,6 +284,12 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
}
|
}
|
||||||
adminMgrService := adminmgr.NewService(adminMgrRepo)
|
adminMgrService := adminmgr.NewService(adminMgrRepo)
|
||||||
adminMgrHandler := adminmgr.NewHandler(adminMgrService)
|
adminMgrHandler := adminmgr.NewHandler(adminMgrService)
|
||||||
|
var supportGroupRepo *supportgroup.Repository
|
||||||
|
if deps.DB != nil {
|
||||||
|
supportGroupRepo = supportgroup.NewRepository(deps.DB)
|
||||||
|
}
|
||||||
|
supportGroupService := supportgroup.NewService(supportGroupRepo)
|
||||||
|
supportGroupHandler := supportgroup.NewHandler(supportGroupService)
|
||||||
var fileStorage *filemodule.Storage
|
var fileStorage *filemodule.Storage
|
||||||
if cfg.Storage.Endpoint != "" && cfg.Storage.Bucket != "" {
|
if cfg.Storage.Endpoint != "" && cfg.Storage.Bucket != "" {
|
||||||
var err error
|
var err error
|
||||||
@@ -563,6 +570,15 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
adminRoutes.PATCH("/chats/qrcodes/:id", requirePerm("chat:manage"), chatHandler.UpdateQrCodeHandler)
|
adminRoutes.PATCH("/chats/qrcodes/:id", requirePerm("chat:manage"), chatHandler.UpdateQrCodeHandler)
|
||||||
adminRoutes.DELETE("/chats/qrcodes/:id", requirePerm("chat:manage"), chatHandler.DeleteQrCodeHandler)
|
adminRoutes.DELETE("/chats/qrcodes/:id", requirePerm("chat:manage"), chatHandler.DeleteQrCodeHandler)
|
||||||
|
|
||||||
|
// 客服分组
|
||||||
|
adminRoutes.GET("/chat-support-groups", requirePerm("chat:manage"), supportGroupHandler.List)
|
||||||
|
adminRoutes.GET("/chat-support-groups/support-admins", requirePerm("chat:manage"), supportGroupHandler.ListSupportAdmins)
|
||||||
|
adminRoutes.GET("/chat-support-groups/:id", requirePerm("chat:manage"), supportGroupHandler.FindByID)
|
||||||
|
adminRoutes.POST("/chat-support-groups", requirePerm("chat:manage"), supportGroupHandler.Create)
|
||||||
|
adminRoutes.PUT("/chat-support-groups/:id", requirePerm("chat:manage"), supportGroupHandler.Update)
|
||||||
|
adminRoutes.DELETE("/chat-support-groups/:id", requirePerm("chat:manage"), supportGroupHandler.Delete)
|
||||||
|
adminRoutes.PUT("/chat-support-groups/:id/members", requirePerm("chat:manage"), supportGroupHandler.AssignMembers)
|
||||||
|
|
||||||
// 角色管理
|
// 角色管理
|
||||||
adminRoutes.GET("/roles", requirePerm("role:manage"), adminRoleHandler.List)
|
adminRoutes.GET("/roles", requirePerm("role:manage"), adminRoleHandler.List)
|
||||||
adminRoutes.GET("/roles/:id", requirePerm("role:manage"), adminRoleHandler.FindByID)
|
adminRoutes.GET("/roles/:id", requirePerm("role:manage"), adminRoleHandler.FindByID)
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- +goose StatementBegin
|
||||||
|
|
||||||
|
INSERT INTO permissions (code, name, resource, action) VALUES
|
||||||
|
('chat:manage', '管理客服配置', 'chat', 'manage')
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
name = VALUES(name),
|
||||||
|
resource = VALUES(resource),
|
||||||
|
action = VALUES(action);
|
||||||
|
|
||||||
|
INSERT IGNORE INTO role_permissions (role_id, permission_id)
|
||||||
|
SELECT r.id, p.id
|
||||||
|
FROM roles r
|
||||||
|
JOIN permissions p ON p.code = 'chat:manage'
|
||||||
|
WHERE r.code = 'super_admin';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS chat_support_groups (
|
||||||
|
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
code VARCHAR(64) NOT NULL COMMENT '固定业务编码: owner_onboarding/renter_handoff',
|
||||||
|
name VARCHAR(64) NOT NULL COMMENT '客服分组名称',
|
||||||
|
description VARCHAR(255) NOT NULL DEFAULT '' COMMENT '分组说明',
|
||||||
|
status VARCHAR(16) NOT NULL DEFAULT 'active' COMMENT '状态: active/disabled',
|
||||||
|
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序值',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uk_chat_support_groups_code (code),
|
||||||
|
KEY idx_chat_support_groups_status_sort (status, sort_order, id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='客服分组表';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS chat_support_group_members (
|
||||||
|
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
group_id BIGINT UNSIGNED NOT NULL COMMENT '客服分组ID',
|
||||||
|
admin_user_id BIGINT UNSIGNED NOT NULL COMMENT '管理员ID',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uk_support_group_member (group_id, admin_user_id),
|
||||||
|
KEY idx_support_group_members_admin (admin_user_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='客服分组成员表';
|
||||||
|
|
||||||
|
INSERT INTO chat_support_groups (code, name, description, status, sort_order) VALUES
|
||||||
|
('owner_onboarding', '收号组-客服', '负责用户发布后的收号沟通、拉群和入群引导', 'active', 10),
|
||||||
|
('renter_handoff', '卖号组-客服', '负责租客支付后的交接跟进和售后沟通', 'active', 20)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
name = VALUES(name),
|
||||||
|
description = VALUES(description),
|
||||||
|
sort_order = VALUES(sort_order);
|
||||||
|
|
||||||
|
INSERT IGNORE INTO chat_support_group_members (group_id, admin_user_id)
|
||||||
|
SELECT g.id, au.id
|
||||||
|
FROM chat_support_groups g
|
||||||
|
JOIN admin_user_roles aur ON 1 = 1
|
||||||
|
JOIN roles r ON r.id = aur.role_id
|
||||||
|
JOIN admin_users au ON au.id = aur.admin_user_id
|
||||||
|
WHERE g.code IN ('owner_onboarding', 'renter_handoff')
|
||||||
|
AND r.code = 'cs'
|
||||||
|
AND au.status = 'active';
|
||||||
|
|
||||||
|
-- +goose StatementEnd
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
-- +goose StatementBegin
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS chat_support_group_members;
|
||||||
|
DROP TABLE IF EXISTS chat_support_groups;
|
||||||
|
DELETE rp FROM role_permissions rp
|
||||||
|
JOIN permissions p ON p.id = rp.permission_id
|
||||||
|
WHERE p.code = 'chat:manage';
|
||||||
|
DELETE FROM permissions WHERE code = 'chat:manage';
|
||||||
|
|
||||||
|
-- +goose StatementEnd
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { apiClient } from '@/shared/api/client'
|
||||||
|
|
||||||
|
import type { ApiResponse } from '@/shared/types/types'
|
||||||
|
|
||||||
|
export interface SupportGroupMember {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
nickname: string
|
||||||
|
support_status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SupportGroup {
|
||||||
|
id: number
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
status: string
|
||||||
|
sort_order: number
|
||||||
|
members: SupportGroupMember[]
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaveSupportGroupRequest {
|
||||||
|
code?: string
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
status?: string
|
||||||
|
sort_order?: number
|
||||||
|
member_ids: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSupportGroups() {
|
||||||
|
const { data } = await apiClient.get<ApiResponse<SupportGroup[]>>('/admin/chat-support-groups')
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSupportGroupAdmins() {
|
||||||
|
const { data } = await apiClient.get<ApiResponse<SupportGroupMember[]>>(
|
||||||
|
'/admin/chat-support-groups/support-admins'
|
||||||
|
)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSupportGroup(req: SaveSupportGroupRequest) {
|
||||||
|
const { data } = await apiClient.post<ApiResponse<SupportGroup>>(
|
||||||
|
'/admin/chat-support-groups',
|
||||||
|
req
|
||||||
|
)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSupportGroup(id: number, req: SaveSupportGroupRequest) {
|
||||||
|
const { data } = await apiClient.put<ApiResponse<SupportGroup>>(
|
||||||
|
`/admin/chat-support-groups/${id}`,
|
||||||
|
req
|
||||||
|
)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSupportGroup(id: number) {
|
||||||
|
const { data } = await apiClient.delete<ApiResponse<{ deleted: boolean }>>(
|
||||||
|
`/admin/chat-support-groups/${id}`
|
||||||
|
)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ export * from './api/adminPayments'
|
|||||||
export * from './api/adminFinance'
|
export * from './api/adminFinance'
|
||||||
export * from './api/adminAudit'
|
export * from './api/adminAudit'
|
||||||
export * from './api/systemConfigs'
|
export * from './api/systemConfigs'
|
||||||
|
export * from './api/supportGroups'
|
||||||
export * from './composables/useAdminTable'
|
export * from './composables/useAdminTable'
|
||||||
export * from './composables/useAdminPaginatedTable'
|
export * from './composables/useAdminPaginatedTable'
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
|
||||||
|
import {
|
||||||
|
createSupportGroup,
|
||||||
|
deleteSupportGroup,
|
||||||
|
fetchSupportGroupAdmins,
|
||||||
|
fetchSupportGroups,
|
||||||
|
updateSupportGroup,
|
||||||
|
type SaveSupportGroupRequest,
|
||||||
|
type SupportGroup,
|
||||||
|
type SupportGroupMember,
|
||||||
|
} from '@/features/admin/api/supportGroups'
|
||||||
|
import { readError } from '@/shared/utils/error'
|
||||||
|
|
||||||
|
const protectedCodes = new Set(['owner_onboarding', 'renter_handoff'])
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const groups = ref<SupportGroup[]>([])
|
||||||
|
const supportAdmins = ref<SupportGroupMember[]>([])
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const editingGroup = ref<SupportGroup | null>(null)
|
||||||
|
const form = reactive<SaveSupportGroupRequest>({
|
||||||
|
code: '',
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
status: 'active',
|
||||||
|
sort_order: 0,
|
||||||
|
member_ids: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void Promise.all([loadGroups(), loadSupportAdmins()])
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadGroups() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
groups.value = await fetchSupportGroups()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(readError(error, '客服分组加载失败'))
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSupportAdmins() {
|
||||||
|
try {
|
||||||
|
supportAdmins.value = await fetchSupportGroupAdmins()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(readError(error, '客服列表加载失败'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
editingGroup.value = null
|
||||||
|
Object.assign(form, {
|
||||||
|
code: '',
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
status: 'active',
|
||||||
|
sort_order: 0,
|
||||||
|
member_ids: [],
|
||||||
|
})
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(row: SupportGroup) {
|
||||||
|
editingGroup.value = row
|
||||||
|
Object.assign(form, {
|
||||||
|
code: row.code,
|
||||||
|
name: row.name,
|
||||||
|
description: row.description,
|
||||||
|
status: row.status,
|
||||||
|
sort_order: row.sort_order,
|
||||||
|
member_ids: row.members.map(item => item.id),
|
||||||
|
})
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveGroup() {
|
||||||
|
if (!form.name.trim()) {
|
||||||
|
ElMessage.warning('请填写分组名称')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
...form,
|
||||||
|
name: form.name.trim(),
|
||||||
|
description: form.description?.trim(),
|
||||||
|
member_ids: form.member_ids || [],
|
||||||
|
}
|
||||||
|
if (editingGroup.value) {
|
||||||
|
await updateSupportGroup(editingGroup.value.id, payload)
|
||||||
|
ElMessage.success('客服分组已更新')
|
||||||
|
} else {
|
||||||
|
await createSupportGroup(payload)
|
||||||
|
ElMessage.success('客服分组已创建')
|
||||||
|
}
|
||||||
|
dialogVisible.value = false
|
||||||
|
await loadGroups()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(readError(error, '保存失败'))
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(row: SupportGroup) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定删除客服分组「${row.name}」吗?`, '删除确认', {
|
||||||
|
confirmButtonText: '确认删除',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
await deleteSupportGroup(row.id)
|
||||||
|
ElMessage.success('客服分组已删除')
|
||||||
|
await loadGroups()
|
||||||
|
} catch {
|
||||||
|
// 用户取消或接口已提示
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(status: string) {
|
||||||
|
return status === 'disabled' ? '停用' : '启用'
|
||||||
|
}
|
||||||
|
|
||||||
|
function supportName(item: SupportGroupMember) {
|
||||||
|
return item.nickname || item.username
|
||||||
|
}
|
||||||
|
|
||||||
|
function supportStatusLabel(status: string) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
online: '在线',
|
||||||
|
busy: '忙碌',
|
||||||
|
offline: '离线',
|
||||||
|
}
|
||||||
|
return map[status] || status
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="page">
|
||||||
|
<div class="page-header-row">
|
||||||
|
<div class="page-header">
|
||||||
|
<p class="eyebrow">Support Groups</p>
|
||||||
|
<h1>客服分组</h1>
|
||||||
|
<p>配置收号、卖号等业务阶段的客服成员,系统会按分组自动分配群聊客服。</p>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-actions">
|
||||||
|
<el-button @click="loadGroups">刷新</el-button>
|
||||||
|
<el-button type="primary" @click="openCreate">新建分组</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table v-loading="loading" class="table-panel" :data="groups">
|
||||||
|
<el-table-column prop="name" label="分组名称" min-width="150" />
|
||||||
|
<el-table-column prop="code" label="业务编码" min-width="150" />
|
||||||
|
<el-table-column prop="description" label="说明" min-width="240" />
|
||||||
|
<el-table-column label="成员" min-width="260">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="member-tags">
|
||||||
|
<el-tag v-for="member in row.members" :key="member.id" size="small">
|
||||||
|
{{ supportName(member) }}
|
||||||
|
</el-tag>
|
||||||
|
<span v-if="!row.members?.length" class="muted">未配置</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.status === 'disabled' ? 'info' : 'success'" size="small">
|
||||||
|
{{ statusLabel(row.status) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="sort_order" label="排序" width="80" align="center" />
|
||||||
|
<el-table-column label="操作" width="170" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button size="small" @click="openEdit(row)">编辑</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="!protectedCodes.has(row.code)"
|
||||||
|
size="small"
|
||||||
|
type="danger"
|
||||||
|
@click="handleDelete(row)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="dialogVisible"
|
||||||
|
:title="editingGroup ? '编辑客服分组' : '新建客服分组'"
|
||||||
|
width="560px"
|
||||||
|
>
|
||||||
|
<el-form label-position="top">
|
||||||
|
<el-form-item label="分组名称">
|
||||||
|
<el-input v-model="form.name" placeholder="例如:收号组-客服" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="业务编码">
|
||||||
|
<el-input
|
||||||
|
v-model="form.code"
|
||||||
|
:disabled="Boolean(editingGroup)"
|
||||||
|
placeholder="留空自动生成;默认分组编码不可修改"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="说明">
|
||||||
|
<el-input v-model="form.description" type="textarea" :rows="2" />
|
||||||
|
</el-form-item>
|
||||||
|
<div class="form-grid">
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-select v-model="form.status">
|
||||||
|
<el-option label="启用" value="active" />
|
||||||
|
<el-option label="停用" value="disabled" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="排序">
|
||||||
|
<el-input-number v-model="form.sort_order" :min="0" :step="10" />
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
<el-form-item label="客服成员">
|
||||||
|
<el-select
|
||||||
|
v-model="form.member_ids"
|
||||||
|
multiple
|
||||||
|
filterable
|
||||||
|
collapse-tags
|
||||||
|
collapse-tags-tooltip
|
||||||
|
placeholder="选择客服人员"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="admin in supportAdmins"
|
||||||
|
:key="admin.id"
|
||||||
|
:label="`${supportName(admin)}(${supportStatusLabel(admin.support_status)})`"
|
||||||
|
:value="admin.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="saving" @click="saveGroup">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.toolbar-actions,
|
||||||
|
.member-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: #8f9bba;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-select) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -100,6 +100,7 @@ const allNavGroups: NavGroup[] = [
|
|||||||
},
|
},
|
||||||
{ label: '客服群聊', to: adminPath('chats'), icon: ChatDotRound, permission: 'chat:view' },
|
{ label: '客服群聊', to: adminPath('chats'), icon: ChatDotRound, permission: 'chat:view' },
|
||||||
{ label: '二维码池', to: adminPath('qrcode-pool'), icon: Picture, permission: 'chat:view' },
|
{ label: '二维码池', to: adminPath('qrcode-pool'), icon: Picture, permission: 'chat:view' },
|
||||||
|
{ label: '客服分组', to: adminPath('support-groups'), icon: User, permission: 'chat:manage' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -72,6 +72,12 @@ export const adminRoutes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/features/admin/views/AdminQrCodePoolView.vue'),
|
component: () => import('@/features/admin/views/AdminQrCodePoolView.vue'),
|
||||||
meta: adminMeta,
|
meta: adminMeta,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: adminPath('support-groups'),
|
||||||
|
name: 'admin-support-groups',
|
||||||
|
component: () => import('@/features/admin/views/AdminSupportGroupsView.vue'),
|
||||||
|
meta: adminMeta,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: adminPath('finance/dashboard'),
|
path: adminPath('finance/dashboard'),
|
||||||
name: 'admin-finance-dashboard',
|
name: 'admin-finance-dashboard',
|
||||||
|
|||||||
Reference in New Issue
Block a user