优化客服会话筛选
This commit is contained in:
@@ -8,20 +8,28 @@ type Principal struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ConversationDTO struct {
|
type ConversationDTO struct {
|
||||||
ID uint64 `json:"id"`
|
ID uint64 `json:"id"`
|
||||||
OrderID *uint64 `json:"order_id"`
|
OrderID *uint64 `json:"order_id"`
|
||||||
ListingID *uint64 `json:"listing_id"`
|
ListingID *uint64 `json:"listing_id"`
|
||||||
Type string `json:"type"`
|
LatestOrderID *uint64 `json:"latest_order_id,omitempty"`
|
||||||
Title string `json:"title"`
|
LatestOrderNo string `json:"latest_order_no,omitempty"`
|
||||||
Status string `json:"status"`
|
LatestOrderStatus string `json:"latest_order_status,omitempty"`
|
||||||
Role string `json:"role"`
|
LatestHandoffStatus string `json:"latest_handoff_status,omitempty"`
|
||||||
Participants []ParticipantDTO `json:"participants,omitempty"`
|
LatestRefundStatus string `json:"latest_refund_status,omitempty"`
|
||||||
LastMessageID *uint64 `json:"last_message_id"`
|
Type string `json:"type"`
|
||||||
LastMessagePreview string `json:"last_message_preview"`
|
Title string `json:"title"`
|
||||||
LastMessageAt *time.Time `json:"last_message_at"`
|
Status string `json:"status"`
|
||||||
UnreadCount int64 `json:"unread_count"`
|
Role string `json:"role"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
Participants []ParticipantDTO `json:"participants,omitempty"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
LastMessageID *uint64 `json:"last_message_id"`
|
||||||
|
LastMessagePreview string `json:"last_message_preview"`
|
||||||
|
LastMessageAt *time.Time `json:"last_message_at"`
|
||||||
|
LastSenderType string `json:"last_sender_type,omitempty"`
|
||||||
|
LastSenderID uint64 `json:"last_sender_id,omitempty"`
|
||||||
|
LastSenderRole string `json:"last_sender_role,omitempty"`
|
||||||
|
UnreadCount int64 `json:"unread_count"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ParticipantDTO struct {
|
type ParticipantDTO struct {
|
||||||
@@ -104,4 +112,5 @@ type PaginatedResult struct {
|
|||||||
Total int64 `json:"total"`
|
Total int64 `json:"total"`
|
||||||
Page int `json:"page"`
|
Page int `json:"page"`
|
||||||
PageSize int `json:"page_size"`
|
PageSize int `json:"page_size"`
|
||||||
|
Counts interface{} `json:"counts,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,9 +13,11 @@ func (h *Handler) AdminList(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
filter := c.DefaultQuery("filter", "all")
|
filter := c.DefaultQuery("filter", "all")
|
||||||
|
stage := c.DefaultQuery("stage", "all")
|
||||||
|
keyword := c.Query("keyword")
|
||||||
page, pageSize := parsePagination(c)
|
page, pageSize := parsePagination(c)
|
||||||
principal := Principal{Type: "admin", ID: adminID}
|
principal := Principal{Type: "admin", ID: adminID}
|
||||||
result, err := h.service.ListConversationsWithFilter(c.Request.Context(), principal, page, pageSize, filter)
|
result, err := h.service.ListConversationsWithFilter(c.Request.Context(), principal, page, pageSize, filter, stage, keyword)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeChatError(c, err)
|
writeChatError(c, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -12,19 +12,27 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type conversationRow struct {
|
type conversationRow struct {
|
||||||
ID uint64
|
ID uint64
|
||||||
OrderID *uint64
|
OrderID *uint64
|
||||||
ListingID *uint64
|
ListingID *uint64
|
||||||
Type string
|
LatestOrderID *uint64
|
||||||
Title string
|
LatestOrderNo string
|
||||||
Status string
|
LatestOrderStatus string
|
||||||
Role string
|
LatestHandoffStatus string
|
||||||
LastMessageID *uint64
|
LatestRefundStatus string
|
||||||
LastMessagePreview string
|
Type string
|
||||||
LastMessageAt *time.Time
|
Title string
|
||||||
UnreadCount int64
|
Status string
|
||||||
CreatedAt time.Time
|
Role string
|
||||||
UpdatedAt time.Time
|
LastMessageID *uint64
|
||||||
|
LastMessagePreview string
|
||||||
|
LastMessageAt *time.Time
|
||||||
|
LastSenderType string
|
||||||
|
LastSenderID uint64
|
||||||
|
LastSenderRole string
|
||||||
|
UnreadCount int64
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) conversationQuery(ctx context.Context, principal Principal) *gorm.DB {
|
func (r *Repository) conversationQuery(ctx context.Context, principal Principal) *gorm.DB {
|
||||||
@@ -281,20 +289,28 @@ func (r *Repository) adminNames(ctx context.Context, ids []uint64) (map[uint64]s
|
|||||||
}
|
}
|
||||||
func (row conversationRow) toDTO(participants []ParticipantDTO) ConversationDTO {
|
func (row conversationRow) toDTO(participants []ParticipantDTO) ConversationDTO {
|
||||||
return ConversationDTO{
|
return ConversationDTO{
|
||||||
ID: row.ID,
|
ID: row.ID,
|
||||||
OrderID: row.OrderID,
|
OrderID: row.OrderID,
|
||||||
ListingID: row.ListingID,
|
ListingID: row.ListingID,
|
||||||
Type: row.Type,
|
LatestOrderID: row.LatestOrderID,
|
||||||
Title: row.Title,
|
LatestOrderNo: row.LatestOrderNo,
|
||||||
Status: row.Status,
|
LatestOrderStatus: row.LatestOrderStatus,
|
||||||
Role: row.Role,
|
LatestHandoffStatus: row.LatestHandoffStatus,
|
||||||
Participants: participants,
|
LatestRefundStatus: row.LatestRefundStatus,
|
||||||
LastMessageID: row.LastMessageID,
|
Type: row.Type,
|
||||||
LastMessagePreview: row.LastMessagePreview,
|
Title: row.Title,
|
||||||
LastMessageAt: row.LastMessageAt,
|
Status: row.Status,
|
||||||
UnreadCount: row.UnreadCount,
|
Role: row.Role,
|
||||||
CreatedAt: row.CreatedAt,
|
Participants: participants,
|
||||||
UpdatedAt: row.UpdatedAt,
|
LastMessageID: row.LastMessageID,
|
||||||
|
LastMessagePreview: row.LastMessagePreview,
|
||||||
|
LastMessageAt: row.LastMessageAt,
|
||||||
|
LastSenderType: row.LastSenderType,
|
||||||
|
LastSenderID: row.LastSenderID,
|
||||||
|
LastSenderRole: row.LastSenderRole,
|
||||||
|
UnreadCount: row.UnreadCount,
|
||||||
|
CreatedAt: row.CreatedAt,
|
||||||
|
UpdatedAt: row.UpdatedAt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
func defaultSupportAdminID(tx *gorm.DB) uint64 {
|
func defaultSupportAdminID(tx *gorm.DB) uint64 {
|
||||||
|
|||||||
@@ -148,11 +148,11 @@ func (s *Service) GetAvailableSupportAdmins(ctx context.Context) ([]SupportAdmin
|
|||||||
return s.repo.GetAvailableSupportAdmins(ctx)
|
return s.repo.GetAvailableSupportAdmins(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) ListConversationsWithFilter(ctx context.Context, principal Principal, page, pageSize int, filter string) (*PaginatedResult, error) {
|
func (s *Service) ListConversationsWithFilter(ctx context.Context, principal Principal, page, pageSize int, filter string, stage string, keyword string) (*PaginatedResult, error) {
|
||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return nil, ErrDependencyUnavailable
|
return nil, ErrDependencyUnavailable
|
||||||
}
|
}
|
||||||
return s.repo.ListConversationsWithFilter(ctx, principal, page, pageSize, filter)
|
return s.repo.ListConversationsWithFilter(ctx, principal, page, pageSize, filter, stage, keyword)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) UpdateRemark(ctx context.Context, principal Principal, conversationID uint64, req UpdateRemarkRequest) error {
|
func (s *Service) UpdateRemark(ctx context.Context, principal Principal, conversationID uint64, req UpdateRemarkRequest) error {
|
||||||
|
|||||||
@@ -5,9 +5,32 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"hfb_sys/backend/internal/model"
|
"hfb_sys/backend/internal/model"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
adminChatFilterAll = "all"
|
||||||
|
adminChatFilterMine = "mine"
|
||||||
|
adminChatFilterUnassigned = "unassigned"
|
||||||
|
|
||||||
|
adminChatStageAll = "all"
|
||||||
|
adminChatStagePending = "pending"
|
||||||
|
adminChatStageUnjoined = "unjoined"
|
||||||
|
adminChatStageHandoff = "handoff"
|
||||||
|
adminChatStageRenting = "renting"
|
||||||
|
adminChatStageAfterSale = "after_sale"
|
||||||
|
adminChatStageEnded = "ended"
|
||||||
|
)
|
||||||
|
|
||||||
|
var adminChatFilters = []string{adminChatFilterMine, adminChatFilterAll, adminChatFilterUnassigned}
|
||||||
|
var adminChatStages = []string{adminChatStageAll, adminChatStagePending, adminChatStageUnjoined, adminChatStageHandoff, adminChatStageRenting, adminChatStageAfterSale, adminChatStageEnded}
|
||||||
|
|
||||||
|
type AdminConversationCountsDTO struct {
|
||||||
|
Ownership map[string]int64 `json:"ownership"`
|
||||||
|
Stages map[string]int64 `json:"stages"`
|
||||||
|
}
|
||||||
|
|
||||||
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 {
|
||||||
// 验证当前操作者是会话参与者
|
// 验证当前操作者是会话参与者
|
||||||
@@ -112,54 +135,19 @@ func (r *Repository) GetAvailableSupportAdmins(ctx context.Context) ([]SupportAd
|
|||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
func (r *Repository) ListConversationsWithFilter(ctx context.Context, principal Principal, page, pageSize int, filter string) (*PaginatedResult, error) {
|
func (r *Repository) ListConversationsWithFilter(ctx context.Context, principal Principal, page, pageSize int, filter string, stage string, keyword string) (*PaginatedResult, error) {
|
||||||
page, pageSize = normalizePagination(page, pageSize)
|
page, pageSize = normalizePagination(page, pageSize)
|
||||||
db := r.db.WithContext(ctx)
|
filter = normalizeAdminChatFilter(filter)
|
||||||
|
stage = normalizeAdminChatStage(stage)
|
||||||
|
keyword = strings.TrimSpace(keyword)
|
||||||
|
|
||||||
// 管理员在"全部"模式下直接查询所有会话
|
if principal.Type == "admin" {
|
||||||
if principal.Type == "admin" && filter == "all" {
|
return r.listAdminConversations(ctx, principal, page, pageSize, filter, stage, keyword)
|
||||||
var total int64
|
|
||||||
if err := db.Model(&model.ChatConversation{}).Count(&total).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var conversations []model.ChatConversation
|
|
||||||
offset := (page - 1) * pageSize
|
|
||||||
if err := db.Order("COALESCE(last_message_at, created_at) DESC, id DESC").
|
|
||||||
Offset(offset).
|
|
||||||
Limit(pageSize).
|
|
||||||
Find(&conversations).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]ConversationDTO, 0, len(conversations))
|
|
||||||
for _, conv := range conversations {
|
|
||||||
participants, err := r.participants(ctx, conv.ID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
items = append(items, ConversationDTO{
|
|
||||||
ID: conv.ID,
|
|
||||||
OrderID: conv.OrderID,
|
|
||||||
ListingID: conv.ListingID,
|
|
||||||
Type: conv.Type,
|
|
||||||
Title: conv.Title,
|
|
||||||
Status: conv.Status,
|
|
||||||
Role: "admin", // 管理员角色
|
|
||||||
Participants: participants,
|
|
||||||
LastMessageID: conv.LastMessageID,
|
|
||||||
LastMessagePreview: conv.LastMessagePreview,
|
|
||||||
LastMessageAt: conv.LastMessageAt,
|
|
||||||
UnreadCount: 0, // 管理员不计未读
|
|
||||||
CreatedAt: conv.CreatedAt,
|
|
||||||
UpdatedAt: conv.UpdatedAt,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 其他情况使用原有逻辑
|
// 其他情况使用原有逻辑
|
||||||
var total int64
|
var total int64
|
||||||
|
db := r.db.WithContext(ctx)
|
||||||
countDB := db.Table("chat_conversations AS c").
|
countDB := db.Table("chat_conversations AS c").
|
||||||
Joins("JOIN chat_participants AS cp ON cp.conversation_id = c.id")
|
Joins("JOIN chat_participants AS cp ON cp.conversation_id = c.id")
|
||||||
|
|
||||||
@@ -214,3 +202,170 @@ func (r *Repository) ListConversationsWithFilter(ctx context.Context, principal
|
|||||||
}
|
}
|
||||||
return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil
|
return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Repository) listAdminConversations(ctx context.Context, principal Principal, page, pageSize int, filter string, stage string, keyword string) (*PaginatedResult, error) {
|
||||||
|
var total int64
|
||||||
|
countDB := r.adminConversationBase(ctx, principal, keyword)
|
||||||
|
applyAdminChatOwnershipFilter(countDB, filter, principal)
|
||||||
|
applyAdminChatStageFilter(countDB, stage, principal)
|
||||||
|
if err := countDB.Count(&total).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows []conversationRow
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
|
queryDB := r.adminConversationBase(ctx, principal, keyword).
|
||||||
|
Select(`c.id, c.order_id, c.listing_id, c.type, c.title, c.status, c.last_message_id,
|
||||||
|
c.last_message_preview, c.last_message_at, c.created_at, c.updated_at,
|
||||||
|
COALESCE(cp_me.role, 'admin') AS role,
|
||||||
|
lo.id AS latest_order_id, lo.order_no AS latest_order_no, lo.status AS latest_order_status,
|
||||||
|
lo.handoff_status AS latest_handoff_status, lo.refund_status AS latest_refund_status,
|
||||||
|
lm.sender_type AS last_sender_type, lm.sender_id AS last_sender_id, lm.sender_role AS last_sender_role,
|
||||||
|
CASE WHEN cp_me.id IS NULL THEN 0 ELSE (
|
||||||
|
SELECT COUNT(1)
|
||||||
|
FROM chat_messages AS cm
|
||||||
|
WHERE cm.conversation_id = c.id
|
||||||
|
AND NOT (cm.sender_type = ? AND cm.sender_id = ?)
|
||||||
|
AND (cp_me.last_read_at IS NULL OR cm.created_at > cp_me.last_read_at)
|
||||||
|
) END AS unread_count`, principal.Type, principal.ID)
|
||||||
|
applyAdminChatOwnershipFilter(queryDB, filter, principal)
|
||||||
|
applyAdminChatStageFilter(queryDB, stage, principal)
|
||||||
|
if err := queryDB.
|
||||||
|
Order("COALESCE(c.last_message_at, c.created_at) DESC, c.id DESC").
|
||||||
|
Offset(offset).
|
||||||
|
Limit(pageSize).
|
||||||
|
Scan(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]ConversationDTO, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
participants, err := r.participants(ctx, row.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, row.toDTO(participants))
|
||||||
|
}
|
||||||
|
|
||||||
|
counts, err := r.adminConversationCounts(ctx, principal, filter, stage, keyword)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize, Counts: counts}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) adminConversationBase(ctx context.Context, principal Principal, keyword string) *gorm.DB {
|
||||||
|
db := r.db.WithContext(ctx).Table("chat_conversations AS c").
|
||||||
|
Joins("LEFT JOIN chat_participants AS cp_me ON cp_me.conversation_id = c.id AND cp_me.participant_type = ? AND cp_me.participant_id = ?", principal.Type, principal.ID).
|
||||||
|
Joins("LEFT JOIN chat_messages AS lm ON lm.id = c.last_message_id").
|
||||||
|
Joins("LEFT JOIN rental_orders AS lo ON lo.id = COALESCE(c.order_id, (SELECT ro.id FROM rental_orders AS ro WHERE ro.listing_id = c.listing_id ORDER BY ro.id DESC LIMIT 1))").
|
||||||
|
Joins("LEFT JOIN rental_listings AS l ON l.id = COALESCE(lo.listing_id, c.listing_id)").
|
||||||
|
Joins("LEFT JOIN users AS renter ON renter.id = lo.renter_id").
|
||||||
|
Joins("LEFT JOIN users AS owner ON owner.id = COALESCE(lo.owner_id, l.owner_id)")
|
||||||
|
if keyword != "" {
|
||||||
|
like := "%" + keyword + "%"
|
||||||
|
db = db.Where(`c.title LIKE ? OR c.last_message_preview LIKE ? OR lo.order_no LIKE ? OR l.listing_no LIKE ?
|
||||||
|
OR renter.phone LIKE ? OR owner.phone LIKE ?
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM chat_participants AS cp_kw
|
||||||
|
WHERE cp_kw.conversation_id = c.id AND cp_kw.remark LIKE ?
|
||||||
|
)`, like, like, like, like, like, like, like)
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) adminConversationCounts(ctx context.Context, principal Principal, filter string, stage string, keyword string) (*AdminConversationCountsDTO, error) {
|
||||||
|
counts := &AdminConversationCountsDTO{
|
||||||
|
Ownership: make(map[string]int64, len(adminChatFilters)),
|
||||||
|
Stages: make(map[string]int64, len(adminChatStages)),
|
||||||
|
}
|
||||||
|
for _, item := range adminChatFilters {
|
||||||
|
db := r.adminConversationBase(ctx, principal, keyword)
|
||||||
|
applyAdminChatOwnershipFilter(db, item, principal)
|
||||||
|
applyAdminChatStageFilter(db, stage, principal)
|
||||||
|
var count int64
|
||||||
|
if err := db.Count(&count).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
counts.Ownership[item] = count
|
||||||
|
}
|
||||||
|
for _, item := range adminChatStages {
|
||||||
|
db := r.adminConversationBase(ctx, principal, keyword)
|
||||||
|
applyAdminChatOwnershipFilter(db, filter, principal)
|
||||||
|
applyAdminChatStageFilter(db, item, principal)
|
||||||
|
var count int64
|
||||||
|
if err := db.Count(&count).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
counts.Stages[item] = count
|
||||||
|
}
|
||||||
|
return counts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeAdminChatFilter(filter string) string {
|
||||||
|
switch filter {
|
||||||
|
case adminChatFilterAll, adminChatFilterMine, adminChatFilterUnassigned:
|
||||||
|
return filter
|
||||||
|
default:
|
||||||
|
return adminChatFilterMine
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeAdminChatStage(stage string) string {
|
||||||
|
switch stage {
|
||||||
|
case adminChatStageAll, adminChatStagePending, adminChatStageUnjoined, adminChatStageHandoff, adminChatStageRenting, adminChatStageAfterSale, adminChatStageEnded:
|
||||||
|
return stage
|
||||||
|
default:
|
||||||
|
return adminChatStageAll
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyAdminChatOwnershipFilter(db *gorm.DB, filter string, principal Principal) {
|
||||||
|
switch filter {
|
||||||
|
case adminChatFilterMine:
|
||||||
|
db.Where("cp_me.id IS NOT NULL")
|
||||||
|
case adminChatFilterUnassigned:
|
||||||
|
db.Where(`NOT EXISTS (
|
||||||
|
SELECT 1 FROM chat_participants AS cp_support
|
||||||
|
WHERE cp_support.conversation_id = c.id
|
||||||
|
AND cp_support.participant_type = ?
|
||||||
|
AND cp_support.role = ?
|
||||||
|
)`, "admin", "support")
|
||||||
|
case adminChatFilterAll:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
db.Where("cp_me.participant_type = ? AND cp_me.participant_id = ?", principal.Type, principal.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyAdminChatStageFilter(db *gorm.DB, stage string, principal Principal) {
|
||||||
|
switch stage {
|
||||||
|
case adminChatStagePending:
|
||||||
|
db.Where(`(
|
||||||
|
lm.sender_type = ?
|
||||||
|
OR (
|
||||||
|
cp_me.id IS NOT NULL
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM chat_messages AS cm_pending
|
||||||
|
WHERE cm_pending.conversation_id = c.id
|
||||||
|
AND NOT (cm_pending.sender_type = ? AND cm_pending.sender_id = ?)
|
||||||
|
AND (cp_me.last_read_at IS NULL OR cm_pending.created_at > cp_me.last_read_at)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)`, "user", principal.Type, principal.ID)
|
||||||
|
case adminChatStageUnjoined:
|
||||||
|
db.Where("lo.id IS NULL")
|
||||||
|
case adminChatStageHandoff:
|
||||||
|
db.Where("lo.status = ?", "pending_handoff")
|
||||||
|
case adminChatStageRenting:
|
||||||
|
db.Where("lo.status IN ?", []string{"renting", "overdue"})
|
||||||
|
case adminChatStageAfterSale:
|
||||||
|
db.Where("(lo.status IN ? OR (lo.refund_status IS NOT NULL AND lo.refund_status <> ?))",
|
||||||
|
[]string{"pending_checkout_confirm", "pending_checkout_accept", "checkout_disputing", "abnormal"}, "none")
|
||||||
|
case adminChatStageEnded:
|
||||||
|
db.Where("(c.status IN ? OR lo.status IN ?)", []string{"archived", "closed"}, []string{"completed", "cancelled", "closed"})
|
||||||
|
case adminChatStageAll:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
markAdminChatRead,
|
markAdminChatRead,
|
||||||
sendAdminChatMessage,
|
sendAdminChatMessage,
|
||||||
updateChatRemark,
|
updateChatRemark,
|
||||||
|
type AdminChatCounts,
|
||||||
type ChatConversation,
|
type ChatConversation,
|
||||||
type ChatMessage,
|
type ChatMessage,
|
||||||
type QuickReply,
|
type QuickReply,
|
||||||
@@ -55,11 +56,32 @@ const attachments = ref<string[]>([])
|
|||||||
const listRef = ref<HTMLElement | null>(null)
|
const listRef = ref<HTMLElement | null>(null)
|
||||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||||
const filter = ref<'all' | 'mine' | 'unassigned'>('mine')
|
const filter = ref<'all' | 'mine' | 'unassigned'>('mine')
|
||||||
|
const stage = ref<'all' | 'pending' | 'unjoined' | 'handoff' | 'renting' | 'after_sale' | 'ended'>(
|
||||||
|
'pending'
|
||||||
|
)
|
||||||
|
const keyword = ref('')
|
||||||
|
const chatCounts = ref<AdminChatCounts>({})
|
||||||
const transferVisible = ref(false)
|
const transferVisible = ref(false)
|
||||||
const quickReplyVisible = ref(false)
|
const quickReplyVisible = ref(false)
|
||||||
const quickReplies = ref<QuickReply[]>([])
|
const quickReplies = ref<QuickReply[]>([])
|
||||||
const remarkEditing = ref(false)
|
const remarkEditing = ref(false)
|
||||||
const remarkValue = ref('')
|
const remarkValue = ref('')
|
||||||
|
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
const ownershipTabs = [
|
||||||
|
{ key: 'mine', label: '我的' },
|
||||||
|
{ key: 'all', label: '全部' },
|
||||||
|
{ key: 'unassigned', label: '未分配' },
|
||||||
|
] as const
|
||||||
|
const stageTabs = [
|
||||||
|
{ key: 'pending', label: '待处理' },
|
||||||
|
{ key: 'unjoined', label: '无订单' },
|
||||||
|
{ key: 'handoff', label: '待交接' },
|
||||||
|
{ key: 'renting', label: '使用中' },
|
||||||
|
{ key: 'after_sale', label: '售后中' },
|
||||||
|
{ key: 'ended', label: '已结束' },
|
||||||
|
{ key: 'all', label: '全部阶段' },
|
||||||
|
] as const
|
||||||
|
|
||||||
const activeMembers = computed(() => {
|
const activeMembers = computed(() => {
|
||||||
const participants = active.value?.participants || []
|
const participants = active.value?.participants || []
|
||||||
@@ -88,6 +110,8 @@ const orderDetailLink = computed(() =>
|
|||||||
from: 'chat',
|
from: 'chat',
|
||||||
chat_id: String(active.value?.id || ''),
|
chat_id: String(active.value?.id || ''),
|
||||||
chat_filter: filter.value,
|
chat_filter: filter.value,
|
||||||
|
chat_stage: stage.value,
|
||||||
|
...(keyword.value.trim() ? { chat_keyword: keyword.value.trim() } : {}),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: ''
|
: ''
|
||||||
@@ -167,14 +191,24 @@ onMounted(async () => {
|
|||||||
if (['all', 'mine', 'unassigned'].includes(routeFilter)) {
|
if (['all', 'mine', 'unassigned'].includes(routeFilter)) {
|
||||||
filter.value = routeFilter as typeof filter.value
|
filter.value = routeFilter as typeof filter.value
|
||||||
}
|
}
|
||||||
|
const routeStage = firstQueryValue(route.query.chat_stage)
|
||||||
|
if (stageTabs.some(item => item.key === routeStage)) {
|
||||||
|
stage.value = routeStage as typeof stage.value
|
||||||
|
}
|
||||||
|
keyword.value = firstQueryValue(route.query.chat_keyword)
|
||||||
await Promise.all([loadConversations(), loadQuickReplies()])
|
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, filter.value)
|
const res = await fetchAdminChats(1, 100, {
|
||||||
|
filter: filter.value,
|
||||||
|
stage: stage.value,
|
||||||
|
keyword: keyword.value.trim(),
|
||||||
|
})
|
||||||
conversations.value = res.items
|
conversations.value = res.items
|
||||||
|
chatCounts.value = res.counts || {}
|
||||||
const first = conversations.value[0]
|
const first = conversations.value[0]
|
||||||
if (!active.value) {
|
if (!active.value) {
|
||||||
const routeChatID = Number(firstQueryValue(route.query.chat_id) || 0)
|
const routeChatID = Number(firstQueryValue(route.query.chat_id) || 0)
|
||||||
@@ -358,9 +392,36 @@ function handleQuickReplySelect(reply: QuickReply) {
|
|||||||
|
|
||||||
function handleFilterChange(val: string) {
|
function handleFilterChange(val: string) {
|
||||||
filter.value = val as typeof filter.value
|
filter.value = val as typeof filter.value
|
||||||
|
resetActiveConversation()
|
||||||
|
loadConversations()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStageChange(val: string) {
|
||||||
|
stage.value = val as typeof stage.value
|
||||||
|
resetActiveConversation()
|
||||||
|
loadConversations()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeywordInput() {
|
||||||
|
if (searchTimer) clearTimeout(searchTimer)
|
||||||
|
searchTimer = setTimeout(() => {
|
||||||
|
resetActiveConversation()
|
||||||
|
loadConversations()
|
||||||
|
}, 320)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeywordSearch() {
|
||||||
|
if (searchTimer) clearTimeout(searchTimer)
|
||||||
|
resetActiveConversation()
|
||||||
|
loadConversations()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetActiveConversation() {
|
||||||
active.value = null
|
active.value = null
|
||||||
messages.value = []
|
messages.value = []
|
||||||
loadConversations()
|
activeOrder.value = null
|
||||||
|
activeHandoffRecords.value = []
|
||||||
|
activePaymentRecords.value = []
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTransferSuccess() {
|
function handleTransferSuccess() {
|
||||||
@@ -433,6 +494,61 @@ function getSupportName(item: ChatConversation) {
|
|||||||
return support?.display_name || '未分配'
|
return support?.display_name || '未分配'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ownershipCount(key: string) {
|
||||||
|
return Number(chatCounts.value.ownership?.[key] || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stageCount(key: string) {
|
||||||
|
return Number(chatCounts.value.stages?.[key] || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function conversationStageLabel(item: ChatConversation) {
|
||||||
|
if (item.unread_count > 0 || item.last_sender_type === 'user') return '待处理'
|
||||||
|
if (!item.latest_order_id) return '无订单'
|
||||||
|
if (item.latest_order_status === 'pending_handoff') return '待交接'
|
||||||
|
if (['renting', 'overdue'].includes(item.latest_order_status || '')) return '使用中'
|
||||||
|
if (
|
||||||
|
[
|
||||||
|
'pending_checkout_confirm',
|
||||||
|
'pending_checkout_accept',
|
||||||
|
'checkout_disputing',
|
||||||
|
'abnormal',
|
||||||
|
].includes(item.latest_order_status || '') ||
|
||||||
|
(item.latest_refund_status && item.latest_refund_status !== 'none')
|
||||||
|
) {
|
||||||
|
return '售后中'
|
||||||
|
}
|
||||||
|
if (['completed', 'cancelled', 'closed'].includes(item.latest_order_status || '')) return '已结束'
|
||||||
|
return '跟进中'
|
||||||
|
}
|
||||||
|
|
||||||
|
function conversationStageClass(item: ChatConversation) {
|
||||||
|
if (item.unread_count > 0 || item.last_sender_type === 'user') return 'pending'
|
||||||
|
if (!item.latest_order_id) return 'unjoined'
|
||||||
|
if (item.latest_order_status === 'pending_handoff') return 'handoff'
|
||||||
|
if (['renting', 'overdue'].includes(item.latest_order_status || '')) return 'renting'
|
||||||
|
if (
|
||||||
|
[
|
||||||
|
'pending_checkout_confirm',
|
||||||
|
'pending_checkout_accept',
|
||||||
|
'checkout_disputing',
|
||||||
|
'abnormal',
|
||||||
|
].includes(item.latest_order_status || '') ||
|
||||||
|
(item.latest_refund_status && item.latest_refund_status !== 'none')
|
||||||
|
) {
|
||||||
|
return 'after-sale'
|
||||||
|
}
|
||||||
|
if (['completed', 'cancelled', 'closed'].includes(item.latest_order_status || '')) return 'ended'
|
||||||
|
return 'normal'
|
||||||
|
}
|
||||||
|
|
||||||
|
function conversationOrderText(item: ChatConversation) {
|
||||||
|
if (item.latest_order_no) return item.latest_order_no
|
||||||
|
if (item.latest_order_id) return `订单 ${item.latest_order_id}`
|
||||||
|
if (item.listing_id) return `发布 ${formatListingNo('', item.listing_id)}`
|
||||||
|
return '暂无订单'
|
||||||
|
}
|
||||||
|
|
||||||
function amountYuan(cent: unknown) {
|
function amountYuan(cent: unknown) {
|
||||||
if (cent !== undefined && cent !== null) return centToYuan(Number(cent || 0))
|
if (cent !== undefined && cent !== null) return centToYuan(Number(cent || 0))
|
||||||
return 0
|
return 0
|
||||||
@@ -507,10 +623,32 @@ function firstQueryValue(value: unknown) {
|
|||||||
<aside class="conversation-pane" v-loading="loading">
|
<aside class="conversation-pane" v-loading="loading">
|
||||||
<div class="filter-tabs">
|
<div class="filter-tabs">
|
||||||
<el-radio-group v-model="filter" size="small" @change="handleFilterChange">
|
<el-radio-group v-model="filter" size="small" @change="handleFilterChange">
|
||||||
<el-radio-button value="mine">我的会话</el-radio-button>
|
<el-radio-button v-for="item in ownershipTabs" :key="item.key" :value="item.key">
|
||||||
<el-radio-button value="all">全部</el-radio-button>
|
{{ item.label }} {{ ownershipCount(item.key) }}
|
||||||
<el-radio-button value="unassigned">未分配</el-radio-button>
|
</el-radio-button>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
|
<div class="stage-tabs">
|
||||||
|
<button
|
||||||
|
v-for="item in stageTabs"
|
||||||
|
:key="item.key"
|
||||||
|
type="button"
|
||||||
|
:class="{ active: stage === item.key }"
|
||||||
|
@click="handleStageChange(item.key)"
|
||||||
|
>
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
|
<em>{{ stageCount(item.key) }}</em>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<el-input
|
||||||
|
v-model="keyword"
|
||||||
|
class="conversation-search"
|
||||||
|
clearable
|
||||||
|
size="small"
|
||||||
|
placeholder="搜索群名 / 订单 / 手机号 / 备注"
|
||||||
|
@input="handleKeywordInput"
|
||||||
|
@clear="handleKeywordSearch"
|
||||||
|
@keyup.enter="handleKeywordSearch"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
v-for="item in conversations"
|
v-for="item in conversations"
|
||||||
@@ -530,6 +668,12 @@ function firstQueryValue(value: unknown) {
|
|||||||
(item.type === 'general_support' ? '客服会话已创建' : '订单群聊已创建')
|
(item.type === 'general_support' ? '客服会话已创建' : '订单群聊已创建')
|
||||||
}}
|
}}
|
||||||
</p>
|
</p>
|
||||||
|
<div class="row-tags">
|
||||||
|
<span class="stage-tag" :class="conversationStageClass(item)">
|
||||||
|
{{ conversationStageLabel(item) }}
|
||||||
|
</span>
|
||||||
|
<span>{{ conversationOrderText(item) }}</span>
|
||||||
|
</div>
|
||||||
<div class="row-meta">
|
<div class="row-meta">
|
||||||
<span class="support-name">{{ getSupportName(item) }}</span>
|
<span class="support-name">{{ getSupportName(item) }}</span>
|
||||||
<em v-if="item.unread_count > 0">{{ item.unread_count }}</em>
|
<em v-if="item.unread_count > 0">{{ item.unread_count }}</em>
|
||||||
@@ -810,10 +954,67 @@ function firstQueryValue(value: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.filter-tabs {
|
.filter-tabs {
|
||||||
padding: 12px;
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px 12px;
|
||||||
border-bottom: 1px solid #e5e7eb;
|
border-bottom: 1px solid #e5e7eb;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filter-tabs :deep(.el-radio-group) {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-tabs :deep(.el-radio-button) {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-tabs :deep(.el-radio-button__inner) {
|
||||||
|
width: 100%;
|
||||||
|
padding: 7px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-tabs {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-tabs button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
height: 26px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border: 1px solid #d8dee8;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #fff;
|
||||||
|
color: #4b5563;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-tabs button.active {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-tabs em {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-tabs button.active em {
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-search {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.conversation-row {
|
.conversation-row {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: block;
|
display: block;
|
||||||
@@ -859,6 +1060,63 @@ function firstQueryValue(value: unknown) {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.row-tags {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-tags span {
|
||||||
|
overflow: hidden;
|
||||||
|
max-width: 160px;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #eef2f7;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 18px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-tags .stage-tag {
|
||||||
|
flex: none;
|
||||||
|
max-width: none;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-tag.pending {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-tag.unjoined {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-tag.handoff {
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-tag.renting {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-tag.after-sale {
|
||||||
|
background: #f3e8ff;
|
||||||
|
color: #7e22ce;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-tag.ended {
|
||||||
|
background: #e5e7eb;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
.row-meta {
|
.row-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -39,12 +39,16 @@ const returnTarget = computed(() => {
|
|||||||
const from = firstQueryValue(route.query.from)
|
const from = firstQueryValue(route.query.from)
|
||||||
const chatID = firstQueryValue(route.query.chat_id)
|
const chatID = firstQueryValue(route.query.chat_id)
|
||||||
const chatFilter = firstQueryValue(route.query.chat_filter)
|
const chatFilter = firstQueryValue(route.query.chat_filter)
|
||||||
|
const chatStage = firstQueryValue(route.query.chat_stage)
|
||||||
|
const chatKeyword = firstQueryValue(route.query.chat_keyword)
|
||||||
if (from === 'chat' && chatID) {
|
if (from === 'chat' && chatID) {
|
||||||
return {
|
return {
|
||||||
path: adminPath('chats'),
|
path: adminPath('chats'),
|
||||||
query: {
|
query: {
|
||||||
chat_id: chatID,
|
chat_id: chatID,
|
||||||
...(chatFilter ? { chat_filter: chatFilter } : {}),
|
...(chatFilter ? { chat_filter: chatFilter } : {}),
|
||||||
|
...(chatStage ? { chat_stage: chatStage } : {}),
|
||||||
|
...(chatKeyword ? { chat_keyword: chatKeyword } : {}),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ export interface ChatConversation {
|
|||||||
id: number
|
id: number
|
||||||
order_id: number | null
|
order_id: number | null
|
||||||
listing_id?: number | null
|
listing_id?: number | null
|
||||||
|
latest_order_id?: number | null
|
||||||
|
latest_order_no?: string
|
||||||
|
latest_order_status?: string
|
||||||
|
latest_handoff_status?: string
|
||||||
|
latest_refund_status?: string
|
||||||
type: string
|
type: string
|
||||||
title: string
|
title: string
|
||||||
status: string
|
status: string
|
||||||
@@ -26,11 +31,29 @@ export interface ChatConversation {
|
|||||||
last_message_id?: number
|
last_message_id?: number
|
||||||
last_message_preview: string
|
last_message_preview: string
|
||||||
last_message_at?: string
|
last_message_at?: string
|
||||||
|
last_sender_type?: 'user' | 'admin' | 'system'
|
||||||
|
last_sender_id?: number
|
||||||
|
last_sender_role?: string
|
||||||
unread_count: number
|
unread_count: number
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminChatCounts {
|
||||||
|
ownership?: Record<string, number>
|
||||||
|
stages?: Record<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminChatQuery {
|
||||||
|
filter?: string
|
||||||
|
stage?: string
|
||||||
|
keyword?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminChatResult = PaginatedResult<ChatConversation> & {
|
||||||
|
counts?: AdminChatCounts
|
||||||
|
}
|
||||||
|
|
||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
id: number
|
id: number
|
||||||
conversation_id: number
|
conversation_id: number
|
||||||
@@ -102,13 +125,15 @@ export async function markChatRead(id: number) {
|
|||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchAdminChats(page = 1, pageSize = 50, filter = 'all') {
|
export async function fetchAdminChats(page = 1, pageSize = 50, query: AdminChatQuery = {}) {
|
||||||
const { data } = await apiClient.get<ApiResponse<PaginatedResult<ChatConversation>>>(
|
const params = Object.fromEntries(
|
||||||
'/admin/chats',
|
Object.entries({ page, page_size: pageSize, ...query }).filter(
|
||||||
{
|
([, value]) => value !== '' && value !== undefined
|
||||||
params: { page, page_size: pageSize, filter },
|
)
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
const { data } = await apiClient.get<ApiResponse<AdminChatResult>>('/admin/chats', {
|
||||||
|
params,
|
||||||
|
})
|
||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user