客服可以给会话设置个人备注, 设置 快捷回复
建群自动话术
This commit is contained in:
@@ -29,6 +29,7 @@ type ChatParticipant struct {
|
|||||||
ParticipantType string `gorm:"size:16;not null;index" json:"participant_type"`
|
ParticipantType string `gorm:"size:16;not null;index" json:"participant_type"`
|
||||||
ParticipantID uint64 `gorm:"not null;index" json:"participant_id"`
|
ParticipantID uint64 `gorm:"not null;index" json:"participant_id"`
|
||||||
Role string `gorm:"size:32;not null" json:"role"`
|
Role string `gorm:"size:32;not null" json:"role"`
|
||||||
|
Remark string `gorm:"size:128;not null;default:''" json:"remark"`
|
||||||
LastReadAt *time.Time `json:"last_read_at"`
|
LastReadAt *time.Time `json:"last_read_at"`
|
||||||
JoinedAt time.Time `json:"joined_at"`
|
JoinedAt time.Time `json:"joined_at"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type ChatQuickReply struct {
|
||||||
|
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
|
AdminUserID uint64 `gorm:"not null;default:0;index" json:"admin_user_id"` // 0=全局, >0=个人
|
||||||
|
Title string `gorm:"size:64;not null" json:"title"`
|
||||||
|
Content string `gorm:"type:text;not null" json:"content"`
|
||||||
|
SortOrder int `gorm:"not null;default:0" json:"sort_order"`
|
||||||
|
CreatedAt time.Time `gorm:"not null;default:CURRENT_TIMESTAMP" json:"created_at"`
|
||||||
|
UpdatedAt time.Time `gorm:"not null;default:CURRENT_TIMESTAMP" json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ChatQuickReply) TableName() string {
|
||||||
|
return "chat_quick_replies"
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ type ParticipantDTO struct {
|
|||||||
ParticipantType string `json:"participant_type"`
|
ParticipantType string `json:"participant_type"`
|
||||||
ParticipantID uint64 `json:"participant_id"`
|
ParticipantID uint64 `json:"participant_id"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
|
Remark string `json:"remark"`
|
||||||
DisplayName string `json:"display_name"`
|
DisplayName string `json:"display_name"`
|
||||||
AvatarURL string `json:"avatar_url"`
|
AvatarURL string `json:"avatar_url"`
|
||||||
LastReadAt *time.Time `json:"last_read_at"`
|
LastReadAt *time.Time `json:"last_read_at"`
|
||||||
@@ -54,6 +55,41 @@ type SendMessageRequest struct {
|
|||||||
Content string `json:"content" binding:"required"`
|
Content string `json:"content" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TransferRequest struct {
|
||||||
|
ToAdminID uint64 `json:"to_admin_id" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateRemarkRequest struct {
|
||||||
|
Remark string `json:"remark"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SupportAdminDTO struct {
|
||||||
|
ID uint64 `json:"id"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
ChatCount int64 `json:"chat_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type QuickReplyDTO struct {
|
||||||
|
ID uint64 `json:"id"`
|
||||||
|
AdminUserID uint64 `json:"admin_user_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
IsGlobal bool `json:"is_global"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateQuickReplyRequest struct {
|
||||||
|
Title string `json:"title" binding:"required"`
|
||||||
|
Content string `json:"content" binding:"required"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateQuickReplyRequest struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
SortOrder *int `json:"sort_order"`
|
||||||
|
}
|
||||||
|
|
||||||
type PaginatedResult struct {
|
type PaginatedResult struct {
|
||||||
Items interface{} `json:"items"`
|
Items interface{} `json:"items"`
|
||||||
Total int64 `json:"total"`
|
Total int64 `json:"total"`
|
||||||
|
|||||||
@@ -34,7 +34,15 @@ func (h *Handler) AdminList(c *gin.Context) {
|
|||||||
response.Unauthorized(c, "缺少管理员上下文")
|
response.Unauthorized(c, "缺少管理员上下文")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.list(c, Principal{Type: "admin", ID: adminID})
|
filter := c.DefaultQuery("filter", "all")
|
||||||
|
page, pageSize := parsePagination(c)
|
||||||
|
principal := Principal{Type: "admin", ID: adminID}
|
||||||
|
result, err := h.service.ListConversationsWithFilter(principal, page, pageSize, filter)
|
||||||
|
if err != nil {
|
||||||
|
writeChatError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) Detail(c *gin.Context) {
|
func (h *Handler) Detail(c *gin.Context) {
|
||||||
@@ -127,6 +135,153 @@ func (h *Handler) AdminMarkRead(c *gin.Context) {
|
|||||||
h.markRead(c, Principal{Type: "admin", ID: adminID})
|
h.markRead(c, Principal{Type: "admin", ID: adminID})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) AdminTransfer(c *gin.Context) {
|
||||||
|
adminID, ok := currentAdminID(c)
|
||||||
|
if !ok {
|
||||||
|
response.Unauthorized(c, "缺少管理员上下文")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := parseID(c, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req TransferRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "目标客服 ID 不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
principal := Principal{Type: "admin", ID: adminID}
|
||||||
|
if err := h.service.TransferConversation(principal, id, req); err != nil {
|
||||||
|
writeChatError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"transferred": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) AdminSupportAdmins(c *gin.Context) {
|
||||||
|
admins, err := h.service.GetAvailableSupportAdmins()
|
||||||
|
if err != nil {
|
||||||
|
writeChatError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, admins)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) AdminUpdateRemark(c *gin.Context) {
|
||||||
|
adminID, ok := currentAdminID(c)
|
||||||
|
if !ok {
|
||||||
|
response.Unauthorized(c, "缺少管理员上下文")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := parseID(c, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req UpdateRemarkRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "请求参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
principal := Principal{Type: "admin", ID: adminID}
|
||||||
|
if err := h.service.UpdateRemark(principal, id, req); err != nil {
|
||||||
|
writeChatError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"updated": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) AdminListQuickReplies(c *gin.Context) {
|
||||||
|
adminID, ok := currentAdminID(c)
|
||||||
|
if !ok {
|
||||||
|
response.Unauthorized(c, "缺少管理员上下文")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
replies, err := h.service.ListQuickReplies(adminID)
|
||||||
|
if err != nil {
|
||||||
|
writeChatError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, replies)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) AdminCreateQuickReply(c *gin.Context) {
|
||||||
|
adminID, ok := currentAdminID(c)
|
||||||
|
if !ok {
|
||||||
|
response.Unauthorized(c, "缺少管理员上下文")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req CreateQuickReplyRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "标题和内容不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reply, err := h.service.CreateQuickReply(adminID, req)
|
||||||
|
if err != nil {
|
||||||
|
writeChatError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Created(c, reply)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) AdminUpdateQuickReply(c *gin.Context) {
|
||||||
|
adminID, ok := currentAdminID(c)
|
||||||
|
if !ok {
|
||||||
|
response.Unauthorized(c, "缺少管理员上下文")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := parseID(c, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req UpdateQuickReplyRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "请求参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.service.UpdateQuickReply(adminID, id, req); err != nil {
|
||||||
|
writeChatError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"updated": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) AdminDeleteQuickReply(c *gin.Context) {
|
||||||
|
adminID, ok := currentAdminID(c)
|
||||||
|
if !ok {
|
||||||
|
response.Unauthorized(c, "缺少管理员上下文")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := parseID(c, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.service.DeleteQuickReply(adminID, id); err != nil {
|
||||||
|
writeChatError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"deleted": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) AdminGetAutoWelcome(c *gin.Context) {
|
||||||
|
message := h.service.GetAutoWelcomeMessage()
|
||||||
|
response.OK(c, gin.H{"message": message})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) AdminUpdateAutoWelcome(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Message string `json:"message" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "话术内容不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.service.UpdateAutoWelcomeMessage(req.Message); err != nil {
|
||||||
|
writeChatError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"updated": true})
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) list(c *gin.Context, principal Principal) {
|
func (h *Handler) list(c *gin.Context, principal Principal) {
|
||||||
page, pageSize := parsePagination(c)
|
page, pageSize := parsePagination(c)
|
||||||
result, err := h.service.ListConversations(principal, page, pageSize)
|
result, err := h.service.ListConversations(principal, page, pageSize)
|
||||||
|
|||||||
@@ -83,12 +83,18 @@ func EnsureOrderConversation(tx *gorm.DB, order model.RentalOrder) (*model.ChatC
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取自动话术
|
||||||
|
autoMessage := "订单已支付,群聊已创建。租客、号主和客服可在这里沟通交接与结账问题。"
|
||||||
|
var cfg model.SystemConfig
|
||||||
|
if err := tx.Where("`key` = ?", "chat.auto_welcome_message").First(&cfg).Error; err == nil && cfg.Value != "" {
|
||||||
|
autoMessage = cfg.Value
|
||||||
|
}
|
||||||
message := model.ChatMessage{
|
message := model.ChatMessage{
|
||||||
ConversationID: conversation.ID,
|
ConversationID: conversation.ID,
|
||||||
SenderType: "system",
|
SenderType: "system",
|
||||||
SenderRole: "system",
|
SenderRole: "system",
|
||||||
ContentType: "system",
|
ContentType: "system",
|
||||||
Content: "订单已支付,群聊已创建。租客、号主和客服可在这里沟通交接与结账问题。",
|
Content: autoMessage,
|
||||||
AttachmentURLS: emptyJSONList(),
|
AttachmentURLS: emptyJSONList(),
|
||||||
}
|
}
|
||||||
if err := tx.Create(&message).Error; err != nil {
|
if err := tx.Create(&message).Error; err != nil {
|
||||||
@@ -499,6 +505,54 @@ func (row conversationRow) toDTO(participants []ParticipantDTO) ConversationDTO
|
|||||||
}
|
}
|
||||||
|
|
||||||
func defaultSupportAdminID(tx *gorm.DB) uint64 {
|
func defaultSupportAdminID(tx *gorm.DB) uint64 {
|
||||||
|
// 查询所有有 chat:view 权限且状态为 active 的管理员
|
||||||
|
var adminIDs []uint64
|
||||||
|
err := tx.Table("admin_users AS au").
|
||||||
|
Joins("JOIN admin_user_roles AS aur ON aur.admin_user_id = au.id").
|
||||||
|
Joins("JOIN role_permissions AS rp ON rp.role_id = aur.role_id").
|
||||||
|
Joins("JOIN permissions AS p ON p.id = rp.permission_id").
|
||||||
|
Where("au.status = ? AND p.code = ?", "active", "chat:view").
|
||||||
|
Distinct("au.id").
|
||||||
|
Pluck("au.id", &adminIDs).Error
|
||||||
|
if err != nil || len(adminIDs) == 0 {
|
||||||
|
// 回退到原有逻辑
|
||||||
|
return fallbackSupportAdminID(tx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统计每个客服当前负责的会话数,选择负载最少的
|
||||||
|
type adminLoad struct {
|
||||||
|
AdminID uint64
|
||||||
|
Count int64
|
||||||
|
}
|
||||||
|
var loads []adminLoad
|
||||||
|
tx.Table("chat_participants AS cp").
|
||||||
|
Select("cp.participant_id AS admin_id, COUNT(*) AS count").
|
||||||
|
Where("cp.participant_type = ? AND cp.role = ? AND cp.participant_id IN ?", "admin", "support", adminIDs).
|
||||||
|
Group("cp.participant_id").
|
||||||
|
Scan(&loads)
|
||||||
|
|
||||||
|
loadMap := make(map[uint64]int64)
|
||||||
|
for _, l := range loads {
|
||||||
|
loadMap[l.AdminID] = l.Count
|
||||||
|
}
|
||||||
|
|
||||||
|
// 找到负载最少的客服
|
||||||
|
var minLoad int64 = -1
|
||||||
|
var selectedID uint64
|
||||||
|
for _, id := range adminIDs {
|
||||||
|
count := loadMap[id]
|
||||||
|
if minLoad < 0 || count < minLoad {
|
||||||
|
minLoad = count
|
||||||
|
selectedID = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if selectedID > 0 {
|
||||||
|
return selectedID
|
||||||
|
}
|
||||||
|
return fallbackSupportAdminID(tx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fallbackSupportAdminID(tx *gorm.DB) uint64 {
|
||||||
var cfg model.SystemConfig
|
var cfg model.SystemConfig
|
||||||
if err := tx.Where("`key` = ?", "chat.default_support_admin_id").First(&cfg).Error; err == nil {
|
if err := tx.Where("`key` = ?", "chat.default_support_admin_id").First(&cfg).Error; err == nil {
|
||||||
id, parseErr := strconv.ParseUint(cfg.Value, 10, 64)
|
id, parseErr := strconv.ParseUint(cfg.Value, 10, 64)
|
||||||
@@ -570,6 +624,175 @@ func truncatePreview(content string) string {
|
|||||||
return string(runes[:80])
|
return string(runes[:80])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TransferConversation 转接会话给其他客服
|
||||||
|
func (r *Repository) TransferConversation(principal Principal, conversationID uint64, toAdminID uint64) error {
|
||||||
|
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
// 验证当前操作者是会话参与者
|
||||||
|
if _, err := r.findParticipant(tx, principal, conversationID, false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 验证目标客服存在且活跃
|
||||||
|
if !adminActive(tx, toAdminID) {
|
||||||
|
return fmt.Errorf("目标客服不存在或已禁用")
|
||||||
|
}
|
||||||
|
// 检查目标客服是否已有该会话
|
||||||
|
var count int64
|
||||||
|
if err := tx.Model(&model.ChatParticipant{}).
|
||||||
|
Where("conversation_id = ? AND participant_type = ? AND participant_id = ?", conversationID, "admin", toAdminID).
|
||||||
|
Count(&count).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
return fmt.Errorf("该客服已在会话中")
|
||||||
|
}
|
||||||
|
// 删除原客服参与者
|
||||||
|
if err := tx.Where("conversation_id = ? AND participant_type = ? AND role = ?", conversationID, "admin", "support").
|
||||||
|
Delete(&model.ChatParticipant{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 添加新客服参与者
|
||||||
|
participant := model.ChatParticipant{
|
||||||
|
ConversationID: conversationID,
|
||||||
|
ParticipantType: "admin",
|
||||||
|
ParticipantID: toAdminID,
|
||||||
|
Role: "support",
|
||||||
|
JoinedAt: time.Now(),
|
||||||
|
}
|
||||||
|
if err := tx.Create(&participant).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 添加系统消息记录转接
|
||||||
|
message := model.ChatMessage{
|
||||||
|
ConversationID: conversationID,
|
||||||
|
SenderType: "system",
|
||||||
|
SenderRole: "system",
|
||||||
|
ContentType: "system",
|
||||||
|
Content: "会话已转接给其他客服",
|
||||||
|
AttachmentURLS: emptyJSONList(),
|
||||||
|
}
|
||||||
|
if err := tx.Create(&message).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAvailableSupportAdmins 获取可用客服列表及其会话数
|
||||||
|
func (r *Repository) GetAvailableSupportAdmins() ([]SupportAdminDTO, error) {
|
||||||
|
// 查询所有有 chat:view 权限且状态为 active 的管理员
|
||||||
|
type adminRow struct {
|
||||||
|
ID uint64
|
||||||
|
Nickname string
|
||||||
|
}
|
||||||
|
var admins []adminRow
|
||||||
|
err := r.db.Table("admin_users AS au").
|
||||||
|
Select("au.id, au.nickname").
|
||||||
|
Joins("JOIN admin_user_roles AS aur ON aur.admin_user_id = au.id").
|
||||||
|
Joins("JOIN role_permissions AS rp ON rp.role_id = aur.role_id").
|
||||||
|
Joins("JOIN permissions AS p ON p.id = rp.permission_id").
|
||||||
|
Where("au.status = ? AND p.code = ?", "active", "chat:view").
|
||||||
|
Distinct("au.id").
|
||||||
|
Scan(&admins).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统计每个客服的会话数
|
||||||
|
type loadRow struct {
|
||||||
|
AdminID uint64
|
||||||
|
Count int64
|
||||||
|
}
|
||||||
|
var loads []loadRow
|
||||||
|
adminIDs := make([]uint64, len(admins))
|
||||||
|
for i, a := range admins {
|
||||||
|
adminIDs[i] = a.ID
|
||||||
|
}
|
||||||
|
if len(adminIDs) > 0 {
|
||||||
|
r.db.Table("chat_participants").
|
||||||
|
Select("participant_id AS admin_id, COUNT(*) AS count").
|
||||||
|
Where("participant_type = ? AND role = ? AND participant_id IN ?", "admin", "support", adminIDs).
|
||||||
|
Group("participant_id").
|
||||||
|
Scan(&loads)
|
||||||
|
}
|
||||||
|
loadMap := make(map[uint64]int64)
|
||||||
|
for _, l := range loads {
|
||||||
|
loadMap[l.AdminID] = l.Count
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]SupportAdminDTO, len(admins))
|
||||||
|
for i, a := range admins {
|
||||||
|
result[i] = SupportAdminDTO{
|
||||||
|
ID: a.ID,
|
||||||
|
Nickname: a.Nickname,
|
||||||
|
ChatCount: loadMap[a.ID],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListConversationsWithFilter 支持筛选的会话列表
|
||||||
|
func (r *Repository) ListConversationsWithFilter(principal Principal, page, pageSize int, filter string) (*PaginatedResult, error) {
|
||||||
|
page, pageSize = normalizePagination(page, pageSize)
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
countDB := r.db.Table("chat_conversations AS c").
|
||||||
|
Joins("JOIN chat_participants AS cp ON cp.conversation_id = c.id")
|
||||||
|
|
||||||
|
switch filter {
|
||||||
|
case "mine":
|
||||||
|
// 只看我的会话
|
||||||
|
countDB = countDB.Where("cp.participant_type = ? AND cp.participant_id = ?", principal.Type, principal.ID)
|
||||||
|
case "unassigned":
|
||||||
|
// 未分配客服的会话
|
||||||
|
countDB = countDB.Where("c.id NOT IN (?)",
|
||||||
|
r.db.Table("chat_participants").Select("conversation_id").Where("participant_type = ? AND role = ?", "admin", "support"))
|
||||||
|
default:
|
||||||
|
// 全部会话(admin 可以看所有)
|
||||||
|
if principal.Type == "admin" {
|
||||||
|
// 管理员看所有会话
|
||||||
|
} else {
|
||||||
|
countDB = countDB.Where("cp.participant_type = ? AND cp.participant_id = ?", principal.Type, principal.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := countDB.Count(&total).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows []conversationRow
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
|
|
||||||
|
queryDB := r.conversationQuery(principal)
|
||||||
|
switch filter {
|
||||||
|
case "mine":
|
||||||
|
queryDB = queryDB.Where("cp.participant_type = ? AND cp.participant_id = ?", principal.Type, principal.ID)
|
||||||
|
case "unassigned":
|
||||||
|
queryDB = queryDB.Where("c.id NOT IN (?)",
|
||||||
|
r.db.Table("chat_participants").Select("conversation_id").Where("participant_type = ? AND role = ?", "admin", "support"))
|
||||||
|
default:
|
||||||
|
if principal.Type == "admin" {
|
||||||
|
// 管理员看所有会话
|
||||||
|
} else {
|
||||||
|
queryDB = queryDB.Where("cp.participant_type = ? AND cp.participant_id = ?", principal.Type, principal.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err := queryDB.
|
||||||
|
Order("COALESCE(c.last_message_at, c.created_at) DESC, c.id DESC").
|
||||||
|
Offset(offset).
|
||||||
|
Limit(pageSize).
|
||||||
|
Scan(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]ConversationDTO, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
items = append(items, row.toDTO(nil))
|
||||||
|
}
|
||||||
|
return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func fallbackName(participantType string, id uint64, name string) string {
|
func fallbackName(participantType string, id uint64, name string) string {
|
||||||
if name != "" {
|
if name != "" {
|
||||||
return name
|
return name
|
||||||
@@ -602,6 +825,98 @@ func emptyJSONList() datatypes.JSON {
|
|||||||
return datatypes.JSON(raw)
|
return datatypes.JSON(raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateRemark 更新会话备注
|
||||||
|
func (r *Repository) UpdateRemark(principal Principal, conversationID uint64, remark string) error {
|
||||||
|
return r.db.Model(&model.ChatParticipant{}).
|
||||||
|
Where("conversation_id = ? AND participant_type = ? AND participant_id = ?", conversationID, principal.Type, principal.ID).
|
||||||
|
Update("remark", remark).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListQuickReplies 获取快捷回复列表(个人 + 全局)
|
||||||
|
func (r *Repository) ListQuickReplies(adminID uint64) ([]QuickReplyDTO, error) {
|
||||||
|
var replies []model.ChatQuickReply
|
||||||
|
err := r.db.Where("admin_user_id = ? OR admin_user_id = 0", adminID).
|
||||||
|
Order("admin_user_id DESC, sort_order ASC, id ASC").
|
||||||
|
Find(&replies).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := make([]QuickReplyDTO, len(replies))
|
||||||
|
for i, reply := range replies {
|
||||||
|
result[i] = QuickReplyDTO{
|
||||||
|
ID: reply.ID,
|
||||||
|
AdminUserID: reply.AdminUserID,
|
||||||
|
Title: reply.Title,
|
||||||
|
Content: reply.Content,
|
||||||
|
SortOrder: reply.SortOrder,
|
||||||
|
IsGlobal: reply.AdminUserID == 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateQuickReply 创建快捷回复
|
||||||
|
func (r *Repository) CreateQuickReply(adminID uint64, req CreateQuickReplyRequest) (*QuickReplyDTO, error) {
|
||||||
|
reply := model.ChatQuickReply{
|
||||||
|
AdminUserID: adminID,
|
||||||
|
Title: req.Title,
|
||||||
|
Content: req.Content,
|
||||||
|
SortOrder: req.SortOrder,
|
||||||
|
}
|
||||||
|
if err := r.db.Create(&reply).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &QuickReplyDTO{
|
||||||
|
ID: reply.ID,
|
||||||
|
AdminUserID: reply.AdminUserID,
|
||||||
|
Title: reply.Title,
|
||||||
|
Content: reply.Content,
|
||||||
|
SortOrder: reply.SortOrder,
|
||||||
|
IsGlobal: false,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateQuickReply 更新快捷回复
|
||||||
|
func (r *Repository) UpdateQuickReply(adminID uint64, replyID uint64, req UpdateQuickReplyRequest) error {
|
||||||
|
query := r.db.Model(&model.ChatQuickReply{}).Where("id = ? AND (admin_user_id = ? OR admin_user_id = 0)", replyID, adminID)
|
||||||
|
updates := map[string]interface{}{}
|
||||||
|
if req.Title != "" {
|
||||||
|
updates["title"] = req.Title
|
||||||
|
}
|
||||||
|
if req.Content != "" {
|
||||||
|
updates["content"] = req.Content
|
||||||
|
}
|
||||||
|
if req.SortOrder != nil {
|
||||||
|
updates["sort_order"] = *req.SortOrder
|
||||||
|
}
|
||||||
|
if len(updates) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return query.Updates(updates).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteQuickReply 删除快捷回复
|
||||||
|
func (r *Repository) DeleteQuickReply(adminID uint64, replyID uint64) error {
|
||||||
|
return r.db.Where("id = ? AND admin_user_id = ?", replyID, adminID).
|
||||||
|
Delete(&model.ChatQuickReply{}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAutoWelcomeMessage 获取建群自动话术
|
||||||
|
func (r *Repository) GetAutoWelcomeMessage() string {
|
||||||
|
var cfg model.SystemConfig
|
||||||
|
if err := r.db.Where("`key` = ?", "chat.auto_welcome_message").First(&cfg).Error; err != nil {
|
||||||
|
return "欢迎加入订单群聊!如有任何问题,请随时沟通。"
|
||||||
|
}
|
||||||
|
return cfg.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateAutoWelcomeMessage 更新建群自动话术
|
||||||
|
func (r *Repository) UpdateAutoWelcomeMessage(message string) error {
|
||||||
|
return r.db.Model(&model.SystemConfig{}).
|
||||||
|
Where("`key` = ?", "chat.auto_welcome_message").
|
||||||
|
Update("value", message).Error
|
||||||
|
}
|
||||||
|
|
||||||
func decodeStringList(raw datatypes.JSON) []string {
|
func decodeStringList(raw datatypes.JSON) []string {
|
||||||
if len(raw) == 0 {
|
if len(raw) == 0 {
|
||||||
return []string{}
|
return []string{}
|
||||||
|
|||||||
@@ -68,3 +68,79 @@ func (s *Service) MarkRead(principal Principal, conversationID uint64) error {
|
|||||||
}
|
}
|
||||||
return s.repo.MarkRead(principal, conversationID)
|
return s.repo.MarkRead(principal, conversationID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) TransferConversation(principal Principal, conversationID uint64, req TransferRequest) error {
|
||||||
|
if s.repo == nil {
|
||||||
|
return ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
if conversationID == 0 || req.ToAdminID == 0 {
|
||||||
|
return ErrInvalidMessage
|
||||||
|
}
|
||||||
|
return s.repo.TransferConversation(principal, conversationID, req.ToAdminID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetAvailableSupportAdmins() ([]SupportAdminDTO, error) {
|
||||||
|
if s.repo == nil {
|
||||||
|
return nil, ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
return s.repo.GetAvailableSupportAdmins()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ListConversationsWithFilter(principal Principal, page, pageSize int, filter string) (*PaginatedResult, error) {
|
||||||
|
if s.repo == nil {
|
||||||
|
return nil, ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
return s.repo.ListConversationsWithFilter(principal, page, pageSize, filter)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) UpdateRemark(principal Principal, conversationID uint64, req UpdateRemarkRequest) error {
|
||||||
|
if s.repo == nil {
|
||||||
|
return ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
return s.repo.UpdateRemark(principal, conversationID, req.Remark)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ListQuickReplies(adminID uint64) ([]QuickReplyDTO, error) {
|
||||||
|
if s.repo == nil {
|
||||||
|
return nil, ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
return s.repo.ListQuickReplies(adminID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) CreateQuickReply(adminID uint64, req CreateQuickReplyRequest) (*QuickReplyDTO, error) {
|
||||||
|
if s.repo == nil {
|
||||||
|
return nil, ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
if req.Title == "" || req.Content == "" {
|
||||||
|
return nil, ErrInvalidMessage
|
||||||
|
}
|
||||||
|
return s.repo.CreateQuickReply(adminID, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) UpdateQuickReply(adminID uint64, replyID uint64, req UpdateQuickReplyRequest) error {
|
||||||
|
if s.repo == nil {
|
||||||
|
return ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
return s.repo.UpdateQuickReply(adminID, replyID, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) DeleteQuickReply(adminID uint64, replyID uint64) error {
|
||||||
|
if s.repo == nil {
|
||||||
|
return ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
return s.repo.DeleteQuickReply(adminID, replyID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetAutoWelcomeMessage() string {
|
||||||
|
if s.repo == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return s.repo.GetAutoWelcomeMessage()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) UpdateAutoWelcomeMessage(message string) error {
|
||||||
|
if s.repo == nil {
|
||||||
|
return ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
return s.repo.UpdateAutoWelcomeMessage(message)
|
||||||
|
}
|
||||||
|
|||||||
@@ -303,6 +303,15 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
adminRoutes.GET("/chats/:id/messages", requirePerm("chat:view"), chatHandler.AdminMessages)
|
adminRoutes.GET("/chats/:id/messages", requirePerm("chat:view"), chatHandler.AdminMessages)
|
||||||
adminRoutes.POST("/chats/:id/messages", requirePerm("chat:send"), chatHandler.AdminSend)
|
adminRoutes.POST("/chats/:id/messages", requirePerm("chat:send"), chatHandler.AdminSend)
|
||||||
adminRoutes.POST("/chats/:id/read", requirePerm("chat:view"), chatHandler.AdminMarkRead)
|
adminRoutes.POST("/chats/:id/read", requirePerm("chat:view"), chatHandler.AdminMarkRead)
|
||||||
|
adminRoutes.POST("/chats/:id/transfer", requirePerm("chat:send"), chatHandler.AdminTransfer)
|
||||||
|
adminRoutes.GET("/chats/support-admins", requirePerm("chat:view"), chatHandler.AdminSupportAdmins)
|
||||||
|
adminRoutes.PUT("/chats/:id/remark", requirePerm("chat:send"), chatHandler.AdminUpdateRemark)
|
||||||
|
adminRoutes.GET("/chats/quick-replies", requirePerm("chat:view"), chatHandler.AdminListQuickReplies)
|
||||||
|
adminRoutes.POST("/chats/quick-replies", requirePerm("chat:send"), chatHandler.AdminCreateQuickReply)
|
||||||
|
adminRoutes.PUT("/chats/quick-replies/:id", requirePerm("chat:send"), chatHandler.AdminUpdateQuickReply)
|
||||||
|
adminRoutes.DELETE("/chats/quick-replies/:id", requirePerm("chat:send"), chatHandler.AdminDeleteQuickReply)
|
||||||
|
adminRoutes.GET("/chats/auto-welcome", requirePerm("system_config:view"), chatHandler.AdminGetAutoWelcome)
|
||||||
|
adminRoutes.PUT("/chats/auto-welcome", requirePerm("system_config:update"), chatHandler.AdminUpdateAutoWelcome)
|
||||||
|
|
||||||
// 角色管理
|
// 角色管理
|
||||||
adminRoutes.GET("/roles", requirePerm("role:manage"), adminRoleHandler.List)
|
adminRoutes.GET("/roles", requirePerm("role:manage"), adminRoleHandler.List)
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- 会话备注字段(个人级别)
|
||||||
|
ALTER TABLE chat_participants ADD COLUMN remark VARCHAR(128) DEFAULT '' AFTER last_read_at;
|
||||||
|
|
||||||
|
-- 快捷回复表
|
||||||
|
CREATE TABLE IF NOT EXISTS chat_quick_replies (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
admin_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '0=全局, >0=个人',
|
||||||
|
title VARCHAR(64) NOT NULL COMMENT '快捷回复标题',
|
||||||
|
content TEXT NOT NULL COMMENT '回复内容',
|
||||||
|
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,
|
||||||
|
INDEX idx_admin_user_id (admin_user_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='快捷回复模板';
|
||||||
|
|
||||||
|
-- 建群自动话术配置
|
||||||
|
INSERT INTO system_configs (`key`, `value`, description) VALUES
|
||||||
|
('chat.auto_welcome_message', '欢迎加入订单群聊!如有任何问题,请随时沟通。', '建群后自动发送的欢迎话术')
|
||||||
|
ON DUPLICATE KEY UPDATE description = VALUES(description);
|
||||||
Vendored
+4
@@ -18,6 +18,9 @@ declare module 'vue' {
|
|||||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||||
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
|
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
|
||||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||||
|
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
||||||
|
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
|
||||||
|
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
|
||||||
ElEmpty: typeof import('element-plus/es')['ElEmpty']
|
ElEmpty: typeof import('element-plus/es')['ElEmpty']
|
||||||
ElForm: typeof import('element-plus/es')['ElForm']
|
ElForm: typeof import('element-plus/es')['ElForm']
|
||||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||||
@@ -26,6 +29,7 @@ declare module 'vue' {
|
|||||||
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
||||||
ElOption: typeof import('element-plus/es')['ElOption']
|
ElOption: typeof import('element-plus/es')['ElOption']
|
||||||
ElPagination: typeof import('element-plus/es')['ElPagination']
|
ElPagination: typeof import('element-plus/es')['ElPagination']
|
||||||
|
ElRadio: typeof import('element-plus/es')['ElRadio']
|
||||||
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
||||||
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export interface ChatParticipant {
|
|||||||
participant_type: 'user' | 'admin'
|
participant_type: 'user' | 'admin'
|
||||||
participant_id: number
|
participant_id: number
|
||||||
role: 'renter' | 'owner' | 'support'
|
role: 'renter' | 'owner' | 'support'
|
||||||
|
remark: string
|
||||||
display_name: string
|
display_name: string
|
||||||
avatar_url: string
|
avatar_url: string
|
||||||
last_read_at?: string
|
last_read_at?: string
|
||||||
@@ -78,9 +79,9 @@ export async function markChatRead(id: number) {
|
|||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchAdminChats(page = 1, pageSize = 50) {
|
export async function fetchAdminChats(page = 1, pageSize = 50, filter = 'all') {
|
||||||
const { data } = await apiClient.get<ApiResponse<PaginatedResult<ChatConversation>>>('/admin/chats', {
|
const { data } = await apiClient.get<ApiResponse<PaginatedResult<ChatConversation>>>('/admin/chats', {
|
||||||
params: { page, page_size: pageSize },
|
params: { page, page_size: pageSize, filter },
|
||||||
})
|
})
|
||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
@@ -106,3 +107,69 @@ export async function markAdminChatRead(id: number) {
|
|||||||
const { data } = await apiClient.post<ApiResponse<{ read: boolean }>>(`/admin/chats/${id}/read`)
|
const { data } = await apiClient.post<ApiResponse<{ read: boolean }>>(`/admin/chats/${id}/read`)
|
||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SupportAdmin {
|
||||||
|
id: number
|
||||||
|
nickname: string
|
||||||
|
chat_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSupportAdmins() {
|
||||||
|
const { data } = await apiClient.get<ApiResponse<SupportAdmin[]>>('/admin/chats/support-admins')
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function transferChat(id: number, toAdminId: number) {
|
||||||
|
const { data } = await apiClient.post<ApiResponse<{ transferred: boolean }>>(`/admin/chats/${id}/transfer`, {
|
||||||
|
to_admin_id: toAdminId,
|
||||||
|
})
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateChatRemark(id: number, remark: string) {
|
||||||
|
const { data } = await apiClient.put<ApiResponse<{ updated: boolean }>>(`/admin/chats/${id}/remark`, { remark })
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuickReply {
|
||||||
|
id: number
|
||||||
|
admin_user_id: number
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
sort_order: number
|
||||||
|
is_global: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchQuickReplies() {
|
||||||
|
const { data } = await apiClient.get<ApiResponse<QuickReply[]>>('/admin/chats/quick-replies')
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createQuickReply(title: string, content: string, sortOrder = 0) {
|
||||||
|
const { data } = await apiClient.post<ApiResponse<QuickReply>>('/admin/chats/quick-replies', {
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
sort_order: sortOrder,
|
||||||
|
})
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateQuickReply(id: number, updates: { title?: string; content?: string; sort_order?: number }) {
|
||||||
|
const { data } = await apiClient.put<ApiResponse<{ updated: boolean }>>(`/admin/chats/quick-replies/${id}`, updates)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteQuickReply(id: number) {
|
||||||
|
const { data } = await apiClient.delete<ApiResponse<{ deleted: boolean }>>(`/admin/chats/quick-replies/${id}`)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAutoWelcomeMessage() {
|
||||||
|
const { data } = await apiClient.get<ApiResponse<{ message: string }>>('/admin/chats/auto-welcome')
|
||||||
|
return data.data.message
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAutoWelcomeMessage(message: string) {
|
||||||
|
const { data } = await apiClient.put<ApiResponse<{ updated: boolean }>>('/admin/chats/auto-welcome', { message })
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,13 +5,18 @@ import {
|
|||||||
fetchAdminChat,
|
fetchAdminChat,
|
||||||
fetchAdminChatMessages,
|
fetchAdminChatMessages,
|
||||||
fetchAdminChats,
|
fetchAdminChats,
|
||||||
|
fetchQuickReplies,
|
||||||
markAdminChatRead,
|
markAdminChatRead,
|
||||||
sendAdminChatMessage,
|
sendAdminChatMessage,
|
||||||
|
updateChatRemark,
|
||||||
type ChatConversation,
|
type ChatConversation,
|
||||||
type ChatMessage,
|
type ChatMessage,
|
||||||
|
type QuickReply,
|
||||||
} from '@/api/chats'
|
} from '@/api/chats'
|
||||||
import { useChatSSE, type ChatEvent } from '@/composables/useChatSSE'
|
import { useChatSSE, type ChatEvent } from '@/composables/useChatSSE'
|
||||||
import { formatDateMinute } from '@/utils/time'
|
import { formatDateMinute } from '@/utils/time'
|
||||||
|
import TransferDialog from './components/TransferDialog.vue'
|
||||||
|
import QuickReplyDialog from './components/QuickReplyDialog.vue'
|
||||||
|
|
||||||
const currentAdminId = Number(localStorage.getItem('admin_id') || 0)
|
const currentAdminId = Number(localStorage.getItem('admin_id') || 0)
|
||||||
|
|
||||||
@@ -23,12 +28,30 @@ const messageLoading = ref(false)
|
|||||||
const sending = ref(false)
|
const sending = ref(false)
|
||||||
const content = ref('')
|
const content = ref('')
|
||||||
const listRef = ref<HTMLElement | null>(null)
|
const listRef = ref<HTMLElement | null>(null)
|
||||||
|
const filter = ref<'all' | 'mine' | 'unassigned'>('mine')
|
||||||
|
const transferVisible = ref(false)
|
||||||
|
const quickReplyVisible = ref(false)
|
||||||
|
const quickReplies = ref<QuickReply[]>([])
|
||||||
|
const remarkEditing = ref(false)
|
||||||
|
const remarkValue = ref('')
|
||||||
|
|
||||||
const activeMembers = computed(() => {
|
const activeMembers = computed(() => {
|
||||||
const participants = active.value?.participants || []
|
const participants = active.value?.participants || []
|
||||||
return participants.map(item => `${roleLabel(item.role)}:${item.display_name}`).join(' / ')
|
return participants.map(item => {
|
||||||
|
const remark = getParticipantRemark(item)
|
||||||
|
const name = remark ? `${remark}(${item.display_name})` : item.display_name
|
||||||
|
return `${roleLabel(item.role)}:${name}`
|
||||||
|
}).join(' / ')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function getParticipantRemark(participant: any) {
|
||||||
|
if (!active.value) return ''
|
||||||
|
const myParticipant = active.value.participants?.find(
|
||||||
|
p => p.participant_type === 'admin' && p.participant_id === currentAdminId
|
||||||
|
)
|
||||||
|
return myParticipant?.remark || ''
|
||||||
|
}
|
||||||
|
|
||||||
function handleSSEEvent(event: ChatEvent) {
|
function handleSSEEvent(event: ChatEvent) {
|
||||||
if (event.type === 'conversation_updated') {
|
if (event.type === 'conversation_updated') {
|
||||||
loadConversations(false)
|
loadConversations(false)
|
||||||
@@ -59,13 +82,13 @@ const { onEvent } = useChatSSE('admin', '/api/admin/chats/events')
|
|||||||
onEvent(handleSSEEvent)
|
onEvent(handleSSEEvent)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadConversations()
|
await Promise.all([loadConversations(), loadQuickReplies()])
|
||||||
})
|
})
|
||||||
|
|
||||||
async function loadConversations(showLoading = true) {
|
async function loadConversations(showLoading = true) {
|
||||||
if (showLoading) loading.value = true
|
if (showLoading) loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await fetchAdminChats(1, 100)
|
const res = await fetchAdminChats(1, 100, filter.value)
|
||||||
conversations.value = res.items
|
conversations.value = res.items
|
||||||
const first = conversations.value[0]
|
const first = conversations.value[0]
|
||||||
if (!active.value && first) {
|
if (!active.value && first) {
|
||||||
@@ -78,6 +101,12 @@ async function loadConversations(showLoading = true) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadQuickReplies() {
|
||||||
|
try {
|
||||||
|
quickReplies.value = await fetchQuickReplies()
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
async function openConversation(item: ChatConversation) {
|
async function openConversation(item: ChatConversation) {
|
||||||
messageLoading.value = true
|
messageLoading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -85,6 +114,8 @@ async function openConversation(item: ChatConversation) {
|
|||||||
await loadMessages(item.id)
|
await loadMessages(item.id)
|
||||||
await markAdminChatRead(item.id)
|
await markAdminChatRead(item.id)
|
||||||
await loadConversations(false)
|
await loadConversations(false)
|
||||||
|
remarkEditing.value = false
|
||||||
|
remarkValue.value = ''
|
||||||
} catch {
|
} catch {
|
||||||
ElMessage.error('会话详情加载失败')
|
ElMessage.error('会话详情加载失败')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -115,6 +146,49 @@ async function handleSend() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleQuickReplySelect(reply: QuickReply) {
|
||||||
|
content.value = reply.content
|
||||||
|
quickReplyVisible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFilterChange(val: string) {
|
||||||
|
filter.value = val as typeof filter.value
|
||||||
|
active.value = null
|
||||||
|
messages.value = []
|
||||||
|
loadConversations()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTransferSuccess() {
|
||||||
|
loadConversations(false)
|
||||||
|
if (active.value) {
|
||||||
|
loadMessages(active.value.id, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startEditRemark() {
|
||||||
|
if (!active.value) return
|
||||||
|
const myParticipant = active.value.participants?.find(
|
||||||
|
p => p.participant_type === 'admin' && p.participant_id === currentAdminId
|
||||||
|
)
|
||||||
|
remarkValue.value = myParticipant?.remark || active.value.title
|
||||||
|
remarkEditing.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRemark() {
|
||||||
|
if (!active.value) return
|
||||||
|
try {
|
||||||
|
await updateChatRemark(active.value.id, remarkValue.value)
|
||||||
|
ElMessage.success('备注已更新')
|
||||||
|
remarkEditing.value = false
|
||||||
|
await fetchAdminChat(active.value.id).then(chat => {
|
||||||
|
active.value = chat
|
||||||
|
})
|
||||||
|
await loadConversations(false)
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('更新备注失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function scrollBottom() {
|
function scrollBottom() {
|
||||||
const el = listRef.value
|
const el = listRef.value
|
||||||
if (!el) return
|
if (!el) return
|
||||||
@@ -135,6 +209,18 @@ function senderLabel(item: ChatMessage) {
|
|||||||
if (item.sender_type === 'system') return '系统'
|
if (item.sender_type === 'system') return '系统'
|
||||||
return `${roleLabel(item.sender_role)} · ${item.sender_name}`
|
return `${roleLabel(item.sender_role)} · ${item.sender_name}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getConversationTitle(item: ChatConversation) {
|
||||||
|
const myParticipant = item.participants?.find(
|
||||||
|
p => p.participant_type === 'admin' && p.participant_id === currentAdminId
|
||||||
|
)
|
||||||
|
return myParticipant?.remark || item.title
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSupportName(item: ChatConversation) {
|
||||||
|
const support = item.participants?.find(p => p.role === 'support')
|
||||||
|
return support?.display_name || '未分配'
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -144,11 +230,21 @@ function senderLabel(item: ChatMessage) {
|
|||||||
<h1>客服群聊</h1>
|
<h1>客服群聊</h1>
|
||||||
<p>处理订单三方沟通</p>
|
<p>处理订单三方沟通</p>
|
||||||
</div>
|
</div>
|
||||||
<el-button :loading="loading" @click="loadConversations()">刷新</el-button>
|
<div class="head-right">
|
||||||
|
<el-button @click="quickReplyVisible = true">快捷回复管理</el-button>
|
||||||
|
<el-button :loading="loading" @click="loadConversations()">刷新</el-button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="chat-workbench">
|
<div class="chat-workbench">
|
||||||
<aside class="conversation-pane" v-loading="loading">
|
<aside class="conversation-pane" v-loading="loading">
|
||||||
|
<div class="filter-tabs">
|
||||||
|
<el-radio-group v-model="filter" size="small" @change="handleFilterChange">
|
||||||
|
<el-radio-button value="mine">我的会话</el-radio-button>
|
||||||
|
<el-radio-button value="all">全部</el-radio-button>
|
||||||
|
<el-radio-button value="unassigned">未分配</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
v-for="item in conversations"
|
v-for="item in conversations"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
@@ -158,11 +254,14 @@ function senderLabel(item: ChatMessage) {
|
|||||||
@click="openConversation(item)"
|
@click="openConversation(item)"
|
||||||
>
|
>
|
||||||
<div class="row-title">
|
<div class="row-title">
|
||||||
<strong>{{ item.title }}</strong>
|
<strong>{{ getConversationTitle(item) }}</strong>
|
||||||
<span>{{ formatDateMinute(item.last_message_at || item.created_at) }}</span>
|
<span>{{ formatDateMinute(item.last_message_at || item.created_at) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p>{{ item.last_message_preview || '订单群聊已创建' }}</p>
|
<p>{{ item.last_message_preview || '订单群聊已创建' }}</p>
|
||||||
<em v-if="item.unread_count > 0">{{ item.unread_count }}</em>
|
<div class="row-meta">
|
||||||
|
<span class="support-name">{{ getSupportName(item) }}</span>
|
||||||
|
<em v-if="item.unread_count > 0">{{ item.unread_count }}</em>
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<el-empty v-if="!loading && conversations.length === 0" description="暂无客服会话" />
|
<el-empty v-if="!loading && conversations.length === 0" description="暂无客服会话" />
|
||||||
</aside>
|
</aside>
|
||||||
@@ -170,11 +269,29 @@ function senderLabel(item: ChatMessage) {
|
|||||||
<main class="message-pane">
|
<main class="message-pane">
|
||||||
<template v-if="active">
|
<template v-if="active">
|
||||||
<header class="message-head">
|
<header class="message-head">
|
||||||
<div>
|
<div class="head-title">
|
||||||
<h2>{{ active.title }}</h2>
|
<template v-if="remarkEditing">
|
||||||
<p>{{ activeMembers }}</p>
|
<el-input
|
||||||
|
v-model="remarkValue"
|
||||||
|
size="small"
|
||||||
|
style="width: 200px"
|
||||||
|
placeholder="输入备注"
|
||||||
|
@keyup.enter="saveRemark"
|
||||||
|
/>
|
||||||
|
<el-button size="small" type="primary" @click="saveRemark">保存</el-button>
|
||||||
|
<el-button size="small" @click="remarkEditing = false">取消</el-button>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<h2>{{ getConversationTitle(active) }} <el-button link size="small" @click="startEditRemark">编辑备注</el-button></h2>
|
||||||
|
<p>{{ activeMembers }}</p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div class="head-actions">
|
||||||
|
<el-button size="small" @click="transferVisible = true">转接</el-button>
|
||||||
|
<RouterLink :to="`/admin/orders/${active.order_id}`">
|
||||||
|
<el-button size="small">查看订单</el-button>
|
||||||
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
<RouterLink :to="`/admin/orders/${active.order_id}`">查看订单</RouterLink>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div ref="listRef" class="message-list" v-loading="messageLoading">
|
<div ref="listRef" class="message-list" v-loading="messageLoading">
|
||||||
@@ -195,21 +312,55 @@ function senderLabel(item: ChatMessage) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="composer">
|
<footer class="composer">
|
||||||
<el-input
|
<div class="composer-tools">
|
||||||
v-model="content"
|
<el-dropdown trigger="click" @command="handleQuickReplySelect">
|
||||||
type="textarea"
|
<el-button size="small" text>快捷回复</el-button>
|
||||||
:rows="3"
|
<template #dropdown>
|
||||||
maxlength="1000"
|
<el-dropdown-menu>
|
||||||
show-word-limit
|
<el-dropdown-item
|
||||||
placeholder="输入客服回复"
|
v-for="reply in quickReplies"
|
||||||
@keydown.enter.exact.prevent="handleSend"
|
:key="reply.id"
|
||||||
/>
|
:command="reply"
|
||||||
<el-button type="primary" :loading="sending" :disabled="!content.trim()" @click="handleSend">发送</el-button>
|
>
|
||||||
|
<span class="reply-title">{{ reply.title }}</span>
|
||||||
|
<span class="reply-preview">{{ reply.content.slice(0, 30) }}{{ reply.content.length > 30 ? '...' : '' }}</span>
|
||||||
|
</el-dropdown-item>
|
||||||
|
<el-dropdown-item v-if="quickReplies.length === 0" disabled>
|
||||||
|
暂无快捷回复
|
||||||
|
</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</el-dropdown>
|
||||||
|
</div>
|
||||||
|
<div class="composer-input">
|
||||||
|
<el-input
|
||||||
|
v-model="content"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
maxlength="1000"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="输入客服回复"
|
||||||
|
@keydown.enter.exact.prevent="handleSend"
|
||||||
|
/>
|
||||||
|
<el-button type="primary" :loading="sending" :disabled="!content.trim()" @click="handleSend">发送</el-button>
|
||||||
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</template>
|
</template>
|
||||||
<el-empty v-else description="请选择会话" />
|
<el-empty v-else description="请选择会话" />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<TransferDialog
|
||||||
|
v-if="active"
|
||||||
|
v-model="transferVisible"
|
||||||
|
:conversation-id="active.id"
|
||||||
|
@success="handleTransferSuccess"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<QuickReplyDialog
|
||||||
|
v-model="quickReplyVisible"
|
||||||
|
@success="loadQuickReplies"
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -231,6 +382,11 @@ function senderLabel(item: ChatMessage) {
|
|||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.head-right {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-workbench {
|
.chat-workbench {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-height: 640px;
|
min-height: 640px;
|
||||||
@@ -247,6 +403,11 @@ function senderLabel(item: ChatMessage) {
|
|||||||
background: #f8fafc;
|
background: #f8fafc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filter-tabs {
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
.conversation-row {
|
.conversation-row {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: block;
|
display: block;
|
||||||
@@ -292,10 +453,19 @@ function senderLabel(item: ChatMessage) {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.conversation-row em {
|
.row-meta {
|
||||||
position: absolute;
|
display: flex;
|
||||||
right: 12px;
|
align-items: center;
|
||||||
bottom: 12px;
|
justify-content: space-between;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-name {
|
||||||
|
color: #8a94a6;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-meta em {
|
||||||
min-width: 18px;
|
min-width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
padding: 0 5px;
|
padding: 0 5px;
|
||||||
@@ -317,23 +487,41 @@ function senderLabel(item: ChatMessage) {
|
|||||||
|
|
||||||
.message-head {
|
.message-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 14px 18px;
|
padding: 14px 18px;
|
||||||
border-bottom: 1px solid #e5e7eb;
|
border-bottom: 1px solid #e5e7eb;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-head h2 {
|
.head-title {
|
||||||
margin: 0;
|
flex: 1;
|
||||||
font-size: 18px;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-head p {
|
.head-title h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head-title p {
|
||||||
margin: 6px 0 0;
|
margin: 6px 0 0;
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.head-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head-actions a {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
.message-list {
|
.message-list {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
@@ -386,11 +574,29 @@ function senderLabel(item: ChatMessage) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.composer {
|
.composer {
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-tools {
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-input {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 88px;
|
grid-template-columns: minmax(0, 1fr) 88px;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
align-items: end;
|
align-items: end;
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
border-top: 1px solid #e5e7eb;
|
}
|
||||||
|
|
||||||
|
.reply-title {
|
||||||
|
font-weight: 500;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-preview {
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import SalePriceDialog from './components/SalePriceDialog.vue'
|
|||||||
import HomeAnnouncementsDialog from './components/HomeAnnouncementsDialog.vue'
|
import HomeAnnouncementsDialog from './components/HomeAnnouncementsDialog.vue'
|
||||||
import HomeBannersDialog from './components/HomeBannersDialog.vue'
|
import HomeBannersDialog from './components/HomeBannersDialog.vue'
|
||||||
import GeneralConfigDialog from './components/GeneralConfigDialog.vue'
|
import GeneralConfigDialog from './components/GeneralConfigDialog.vue'
|
||||||
|
import AutoWelcomeConfig from './components/AutoWelcomeConfig.vue'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const configs = ref<SystemConfig[]>([])
|
const configs = ref<SystemConfig[]>([])
|
||||||
@@ -267,6 +268,8 @@ function formatConfigValue(row: SystemConfig) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<AutoWelcomeConfig />
|
||||||
|
|
||||||
<el-table v-loading="loading" class="table-panel" :data="regularConfigs">
|
<el-table v-loading="loading" class="table-panel" :data="regularConfigs">
|
||||||
<el-table-column prop="key" label="配置项" min-width="260" />
|
<el-table-column prop="key" label="配置项" min-width="260" />
|
||||||
<el-table-column label="当前值" min-width="180" show-overflow-tooltip>
|
<el-table-column label="当前值" min-width="180" show-overflow-tooltip>
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { fetchAutoWelcomeMessage, updateAutoWelcomeMessage } from '@/api/chats'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const message = ref('')
|
||||||
|
const editing = ref(false)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadMessage()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadMessage() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
message.value = await fetchAutoWelcomeMessage()
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('加载自动话术失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!message.value.trim()) {
|
||||||
|
ElMessage.warning('话术内容不能为空')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await updateAutoWelcomeMessage(message.value.trim())
|
||||||
|
ElMessage.success('保存成功')
|
||||||
|
editing.value = false
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('保存失败')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit() {
|
||||||
|
editing.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
editing.value = false
|
||||||
|
loadMessage()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="auto-welcome-config" v-loading="loading">
|
||||||
|
<div class="config-header">
|
||||||
|
<h3>建群自动话术</h3>
|
||||||
|
<p>订单群聊创建后自动发送的欢迎消息</p>
|
||||||
|
</div>
|
||||||
|
<div class="config-content">
|
||||||
|
<template v-if="editing">
|
||||||
|
<el-input
|
||||||
|
v-model="message"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
maxlength="500"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="输入建群后自动发送的话术"
|
||||||
|
/>
|
||||||
|
<div class="config-actions">
|
||||||
|
<el-button size="small" @click="cancelEdit">取消</el-button>
|
||||||
|
<el-button size="small" type="primary" :loading="saving" @click="handleSave">
|
||||||
|
保存
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="preview-box">
|
||||||
|
<p>{{ message || '未设置' }}</p>
|
||||||
|
</div>
|
||||||
|
<el-button size="small" @click="startEdit">编辑</el-button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.auto-welcome-config {
|
||||||
|
padding: 20px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-header {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-header h3 {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-header p {
|
||||||
|
margin: 0;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-box {
|
||||||
|
padding: 12px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 6px;
|
||||||
|
min-height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-box p {
|
||||||
|
margin: 0;
|
||||||
|
color: #374151;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import {
|
||||||
|
fetchQuickReplies,
|
||||||
|
createQuickReply,
|
||||||
|
updateQuickReply,
|
||||||
|
deleteQuickReply,
|
||||||
|
type QuickReply,
|
||||||
|
} from '@/api/chats'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean]
|
||||||
|
success: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const visible = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
const replies = ref<QuickReply[]>([])
|
||||||
|
const editingId = ref<number | null>(null)
|
||||||
|
const form = ref({
|
||||||
|
title: '',
|
||||||
|
content: '',
|
||||||
|
sort_order: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (val) => {
|
||||||
|
visible.value = val
|
||||||
|
if (val) {
|
||||||
|
loadReplies()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(visible, (val) => {
|
||||||
|
emit('update:modelValue', val)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadReplies() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
replies.value = await fetchQuickReplies()
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('加载快捷回复失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
form.value = { title: '', content: '', sort_order: 0 }
|
||||||
|
editingId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(reply: QuickReply) {
|
||||||
|
editingId.value = reply.id
|
||||||
|
form.value = {
|
||||||
|
title: reply.title,
|
||||||
|
content: reply.content,
|
||||||
|
sort_order: reply.sort_order,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!form.value.title || !form.value.content) {
|
||||||
|
ElMessage.warning('标题和内容不能为空')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (editingId.value) {
|
||||||
|
await updateQuickReply(editingId.value, form.value)
|
||||||
|
ElMessage.success('更新成功')
|
||||||
|
} else {
|
||||||
|
await createQuickReply(form.value.title, form.value.content, form.value.sort_order)
|
||||||
|
ElMessage.success('创建成功')
|
||||||
|
}
|
||||||
|
resetForm()
|
||||||
|
await loadReplies()
|
||||||
|
emit('success')
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(reply: QuickReply) {
|
||||||
|
if (reply.is_global) {
|
||||||
|
ElMessage.warning('不能删除全局快捷回复')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确定删除这条快捷回复?', '确认删除', {
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
await deleteQuickReply(reply.id)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
await loadReplies()
|
||||||
|
emit('success')
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
resetForm()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-dialog v-model="visible" title="快捷回复管理" width="600px">
|
||||||
|
<div class="quick-reply-content">
|
||||||
|
<div class="reply-form">
|
||||||
|
<el-input
|
||||||
|
v-model="form.title"
|
||||||
|
placeholder="快捷回复标题"
|
||||||
|
maxlength="64"
|
||||||
|
style="margin-bottom: 8px"
|
||||||
|
/>
|
||||||
|
<el-input
|
||||||
|
v-model="form.content"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
placeholder="回复内容"
|
||||||
|
maxlength="500"
|
||||||
|
show-word-limit
|
||||||
|
style="margin-bottom: 8px"
|
||||||
|
/>
|
||||||
|
<div class="form-actions">
|
||||||
|
<el-input-number
|
||||||
|
v-model="form.sort_order"
|
||||||
|
:min="0"
|
||||||
|
:max="999"
|
||||||
|
size="small"
|
||||||
|
placeholder="排序"
|
||||||
|
style="width: 120px"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<el-button v-if="editingId" size="small" @click="handleCancel">取消</el-button>
|
||||||
|
<el-button size="small" type="primary" @click="handleSubmit">
|
||||||
|
{{ editingId ? '更新' : '添加' }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="reply-list" v-loading="loading">
|
||||||
|
<div
|
||||||
|
v-for="reply in replies"
|
||||||
|
:key="reply.id"
|
||||||
|
class="reply-item"
|
||||||
|
:class="{ global: reply.is_global }"
|
||||||
|
>
|
||||||
|
<div class="reply-info">
|
||||||
|
<div class="reply-header">
|
||||||
|
<span class="reply-title">{{ reply.title }}</span>
|
||||||
|
<el-tag v-if="reply.is_global" size="small" type="info">全局</el-tag>
|
||||||
|
<el-tag v-else size="small" type="success">个人</el-tag>
|
||||||
|
</div>
|
||||||
|
<div class="reply-content">{{ reply.content }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="reply-actions">
|
||||||
|
<el-button link size="small" @click="startEdit(reply)">编辑</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="!reply.is_global"
|
||||||
|
link
|
||||||
|
size="small"
|
||||||
|
type="danger"
|
||||||
|
@click="handleDelete(reply)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-empty v-if="!loading && replies.length === 0" description="暂无快捷回复" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.quick-reply-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
max-height: 60vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-form {
|
||||||
|
padding: 16px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-list {
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-item.global {
|
||||||
|
background: #f0f9ff;
|
||||||
|
border-color: #bae6fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-title {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-content {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { fetchSupportAdmins, transferChat, type SupportAdmin } from '@/api/chats'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
conversationId: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean]
|
||||||
|
success: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const visible = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
const submitting = ref(false)
|
||||||
|
const admins = ref<SupportAdmin[]>([])
|
||||||
|
const selectedAdminId = ref<number | null>(null)
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (val) => {
|
||||||
|
visible.value = val
|
||||||
|
if (val) {
|
||||||
|
loadAdmins()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(visible, (val) => {
|
||||||
|
emit('update:modelValue', val)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadAdmins() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
admins.value = await fetchSupportAdmins()
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('加载客服列表失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!selectedAdminId.value) {
|
||||||
|
ElMessage.warning('请选择目标客服')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
await transferChat(props.conversationId, selectedAdminId.value)
|
||||||
|
ElMessage.success('转接成功')
|
||||||
|
visible.value = false
|
||||||
|
emit('success')
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('转接失败')
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-dialog v-model="visible" title="转接会话" width="400px">
|
||||||
|
<div v-loading="loading" class="transfer-content">
|
||||||
|
<p class="tip">选择要转接给的客服:</p>
|
||||||
|
<el-radio-group v-model="selectedAdminId" class="admin-list">
|
||||||
|
<el-radio
|
||||||
|
v-for="admin in admins"
|
||||||
|
:key="admin.id"
|
||||||
|
:value="admin.id"
|
||||||
|
class="admin-item"
|
||||||
|
>
|
||||||
|
<span class="admin-name">{{ admin.nickname }}</span>
|
||||||
|
<span class="admin-count">当前 {{ admin.chat_count }} 个会话</span>
|
||||||
|
</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
<el-empty v-if="!loading && admins.length === 0" description="暂无可用客服" />
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="visible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="submitting" :disabled="!selectedAdminId" @click="handleSubmit">
|
||||||
|
确认转接
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.transfer-content {
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tip {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: auto;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-item.is-checked {
|
||||||
|
border-color: #409eff;
|
||||||
|
background: #ecf5ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-count {
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user