diff --git a/backend/internal/modules/chat/dto.go b/backend/internal/modules/chat/dto.go index 4985c51..e817b83 100644 --- a/backend/internal/modules/chat/dto.go +++ b/backend/internal/modules/chat/dto.go @@ -8,20 +8,28 @@ type Principal struct { } type ConversationDTO struct { - ID uint64 `json:"id"` - OrderID *uint64 `json:"order_id"` - ListingID *uint64 `json:"listing_id"` - Type string `json:"type"` - Title string `json:"title"` - Status string `json:"status"` - Role string `json:"role"` - Participants []ParticipantDTO `json:"participants,omitempty"` - LastMessageID *uint64 `json:"last_message_id"` - LastMessagePreview string `json:"last_message_preview"` - LastMessageAt *time.Time `json:"last_message_at"` - UnreadCount int64 `json:"unread_count"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint64 `json:"id"` + OrderID *uint64 `json:"order_id"` + ListingID *uint64 `json:"listing_id"` + LatestOrderID *uint64 `json:"latest_order_id,omitempty"` + LatestOrderNo string `json:"latest_order_no,omitempty"` + LatestOrderStatus string `json:"latest_order_status,omitempty"` + LatestHandoffStatus string `json:"latest_handoff_status,omitempty"` + LatestRefundStatus string `json:"latest_refund_status,omitempty"` + Type string `json:"type"` + Title string `json:"title"` + Status string `json:"status"` + Role string `json:"role"` + Participants []ParticipantDTO `json:"participants,omitempty"` + 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 { @@ -104,4 +112,5 @@ type PaginatedResult struct { Total int64 `json:"total"` Page int `json:"page"` PageSize int `json:"page_size"` + Counts interface{} `json:"counts,omitempty"` } diff --git a/backend/internal/modules/chat/handler_admin.go b/backend/internal/modules/chat/handler_admin.go index 56861cc..1c5dfc2 100644 --- a/backend/internal/modules/chat/handler_admin.go +++ b/backend/internal/modules/chat/handler_admin.go @@ -13,9 +13,11 @@ func (h *Handler) AdminList(c *gin.Context) { return } filter := c.DefaultQuery("filter", "all") + stage := c.DefaultQuery("stage", "all") + keyword := c.Query("keyword") page, pageSize := parsePagination(c) 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 { writeChatError(c, err) return diff --git a/backend/internal/modules/chat/participant.go b/backend/internal/modules/chat/participant.go index e719430..544258d 100644 --- a/backend/internal/modules/chat/participant.go +++ b/backend/internal/modules/chat/participant.go @@ -12,19 +12,27 @@ import ( ) type conversationRow struct { - ID uint64 - OrderID *uint64 - ListingID *uint64 - Type string - Title string - Status string - Role string - LastMessageID *uint64 - LastMessagePreview string - LastMessageAt *time.Time - UnreadCount int64 - CreatedAt time.Time - UpdatedAt time.Time + ID uint64 + OrderID *uint64 + ListingID *uint64 + LatestOrderID *uint64 + LatestOrderNo string + LatestOrderStatus string + LatestHandoffStatus string + LatestRefundStatus string + Type string + Title string + Status string + Role string + 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 { @@ -281,20 +289,28 @@ func (r *Repository) adminNames(ctx context.Context, ids []uint64) (map[uint64]s } func (row conversationRow) toDTO(participants []ParticipantDTO) ConversationDTO { return ConversationDTO{ - ID: row.ID, - OrderID: row.OrderID, - ListingID: row.ListingID, - Type: row.Type, - Title: row.Title, - Status: row.Status, - Role: row.Role, - Participants: participants, - LastMessageID: row.LastMessageID, - LastMessagePreview: row.LastMessagePreview, - LastMessageAt: row.LastMessageAt, - UnreadCount: row.UnreadCount, - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, + ID: row.ID, + OrderID: row.OrderID, + ListingID: row.ListingID, + LatestOrderID: row.LatestOrderID, + LatestOrderNo: row.LatestOrderNo, + LatestOrderStatus: row.LatestOrderStatus, + LatestHandoffStatus: row.LatestHandoffStatus, + LatestRefundStatus: row.LatestRefundStatus, + Type: row.Type, + Title: row.Title, + Status: row.Status, + Role: row.Role, + Participants: participants, + 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 { diff --git a/backend/internal/modules/chat/service.go b/backend/internal/modules/chat/service.go index 7442201..3a06140 100644 --- a/backend/internal/modules/chat/service.go +++ b/backend/internal/modules/chat/service.go @@ -148,11 +148,11 @@ func (s *Service) GetAvailableSupportAdmins(ctx context.Context) ([]SupportAdmin 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 { 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 { diff --git a/backend/internal/modules/chat/support.go b/backend/internal/modules/chat/support.go index 029d59c..cde9f26 100644 --- a/backend/internal/modules/chat/support.go +++ b/backend/internal/modules/chat/support.go @@ -5,9 +5,32 @@ import ( "fmt" "gorm.io/gorm" "hfb_sys/backend/internal/model" + "strings" "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 { 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 } -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) - db := r.db.WithContext(ctx) + filter = normalizeAdminChatFilter(filter) + stage = normalizeAdminChatStage(stage) + keyword = strings.TrimSpace(keyword) - // 管理员在"全部"模式下直接查询所有会话 - if principal.Type == "admin" && filter == "all" { - 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 + if principal.Type == "admin" { + return r.listAdminConversations(ctx, principal, page, pageSize, filter, stage, keyword) } // 其他情况使用原有逻辑 var total int64 + db := r.db.WithContext(ctx) countDB := db.Table("chat_conversations AS c"). 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 } + +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 + } +} diff --git a/frontend/src/features/admin/views/AdminChatsView.vue b/frontend/src/features/admin/views/AdminChatsView.vue index aaad64d..1eac2b6 100644 --- a/frontend/src/features/admin/views/AdminChatsView.vue +++ b/frontend/src/features/admin/views/AdminChatsView.vue @@ -11,6 +11,7 @@ import { markAdminChatRead, sendAdminChatMessage, updateChatRemark, + type AdminChatCounts, type ChatConversation, type ChatMessage, type QuickReply, @@ -55,11 +56,32 @@ const attachments = ref([]) const listRef = ref(null) const fileInputRef = ref(null) 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({}) const transferVisible = ref(false) const quickReplyVisible = ref(false) const quickReplies = ref([]) const remarkEditing = ref(false) const remarkValue = ref('') +let searchTimer: ReturnType | 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 participants = active.value?.participants || [] @@ -88,6 +110,8 @@ const orderDetailLink = computed(() => from: 'chat', chat_id: String(active.value?.id || ''), 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)) { 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()]) }) async function loadConversations(showLoading = true) { if (showLoading) loading.value = true 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 + chatCounts.value = res.counts || {} const first = conversations.value[0] if (!active.value) { const routeChatID = Number(firstQueryValue(route.query.chat_id) || 0) @@ -358,9 +392,36 @@ function handleQuickReplySelect(reply: QuickReply) { function handleFilterChange(val: string) { 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 messages.value = [] - loadConversations() + activeOrder.value = null + activeHandoffRecords.value = [] + activePaymentRecords.value = [] } function handleTransferSuccess() { @@ -433,6 +494,61 @@ function getSupportName(item: ChatConversation) { 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) { if (cent !== undefined && cent !== null) return centToYuan(Number(cent || 0)) return 0 @@ -507,10 +623,32 @@ function firstQueryValue(value: unknown) {