diff --git a/backend/internal/modules/chat/admin_list_test.go b/backend/internal/modules/chat/admin_list_test.go index 1341382..129ebdb 100644 --- a/backend/internal/modules/chat/admin_list_test.go +++ b/backend/internal/modules/chat/admin_list_test.go @@ -99,6 +99,9 @@ func TestListAdminConversationsUsesPersonalStateAndBatchData(t *testing.T) { if item.UnreadCount != 1 { t.Fatalf("管理员未读数 = %d, want 1", item.UnreadCount) } + if !item.NeedsReply { + t.Fatal("用户消息未回复时应保持待处理") + } counts, err := repo.AdminConversationCounts(t.Context(), Principal{Type: "admin", ID: admin.ID}, adminChatFilterMine, adminChatStageAll, "") if err != nil { t.Fatalf("查询客服统计失败: %v", err) @@ -106,4 +109,23 @@ func TestListAdminConversationsUsesPersonalStateAndBatchData(t *testing.T) { if counts.Ownership[adminChatFilterMine] != 1 || counts.Stages[adminChatStageAll] != 1 { t.Fatalf("聚合统计异常: ownership=%v stages=%v", counts.Ownership, counts.Stages) } + + adminMessage := model.ChatMessage{ConversationID: conversation.ID, SenderType: "admin", SenderID: admin.ID, SenderRole: "support", ContentType: "text", Content: "客服回复", CreatedAt: now.Add(2 * time.Second)} + if err := db.Create(&adminMessage).Error; err != nil { + t.Fatalf("创建客服回复失败: %v", err) + } + if err := db.Model(&conversation).Updates(map[string]interface{}{ + "last_message_id": adminMessage.ID, + "last_message_preview": adminMessage.Content, + "last_message_at": adminMessage.CreatedAt, + }).Error; err != nil { + t.Fatalf("更新客服回复摘要失败: %v", err) + } + result, err = repo.ListConversationsWithFilter(t.Context(), Principal{Type: "admin", ID: admin.ID}, 1, 20, adminChatFilterMine, adminChatStagePending, "") + if err != nil { + t.Fatalf("查询已回复待处理会话失败: %v", err) + } + if result.Total != 0 { + t.Fatalf("客服回复后待处理数量 = %d, want 0", result.Total) + } } diff --git a/backend/internal/modules/chat/dto.go b/backend/internal/modules/chat/dto.go index dd8c6da..ac02646 100644 --- a/backend/internal/modules/chat/dto.go +++ b/backend/internal/modules/chat/dto.go @@ -30,6 +30,7 @@ type ConversationDTO struct { LastSenderID uint64 `json:"last_sender_id,omitempty"` LastSenderRole string `json:"last_sender_role,omitempty"` UnreadCount int64 `json:"unread_count"` + NeedsReply bool `json:"needs_reply"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } diff --git a/backend/internal/modules/chat/participant.go b/backend/internal/modules/chat/participant.go index b9117b0..7410185 100644 --- a/backend/internal/modules/chat/participant.go +++ b/backend/internal/modules/chat/participant.go @@ -33,6 +33,7 @@ type conversationRow struct { LastSenderID uint64 LastSenderRole string UnreadCount int64 + NeedsReply int64 CreatedAt time.Time UpdatedAt time.Time } @@ -333,6 +334,7 @@ func (row conversationRow) toDTO(participants []ParticipantDTO) ConversationDTO LastSenderID: row.LastSenderID, LastSenderRole: row.LastSenderRole, UnreadCount: row.UnreadCount, + NeedsReply: row.NeedsReply > 0, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, } diff --git a/backend/internal/modules/chat/support.go b/backend/internal/modules/chat/support.go index 5b84b57..e915142 100644 --- a/backend/internal/modules/chat/support.go +++ b/backend/internal/modules/chat/support.go @@ -239,6 +239,7 @@ func (r *Repository) listAdminConversations(ctx context.Context, principal Princ COALESCE(explicit_lo.handoff_status, listing_lo.handoff_status) AS latest_handoff_status, COALESCE(explicit_lo.refund_status, listing_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 ` + adminNeedsReplyExpression() + ` THEN 1 ELSE 0 END AS needs_reply, ( SELECT COUNT(1) FROM chat_messages AS cm @@ -414,13 +415,7 @@ func adminChatStageCondition(stage string, principal Principal) (string, []inter latestRefundStatus := "COALESCE(explicit_lo.refund_status, listing_lo.refund_status)" switch stage { case adminChatStagePending: - return `EXISTS ( - SELECT 1 - FROM chat_messages AS cm_pending - WHERE cm_pending.conversation_id = c.id - AND (cm_pending.admin_attention_type <> '' OR cm_pending.sender_type = 'user') - AND cm_pending.id > COALESCE(cas.last_read_message_id, 0) - )`, nil + return adminNeedsReplyExpression(), nil case adminChatStageUnjoined: return latestID + " IS NULL", nil case adminChatStageHandoff: @@ -438,6 +433,20 @@ func adminChatStageCondition(stage string, principal Principal) (string, []inter } } +func adminNeedsReplyExpression() string { + return `COALESCE(( + SELECT MAX(cm_attention.id) + FROM chat_messages AS cm_attention + WHERE cm_attention.conversation_id = c.id + AND (cm_attention.admin_attention_type <> '' OR cm_attention.sender_type = 'user') + ), 0) > COALESCE(( + SELECT MAX(cm_admin.id) + FROM chat_messages AS cm_admin + WHERE cm_admin.conversation_id = c.id + AND cm_admin.sender_type = 'admin' + ), 0)` +} + // ArchiveListingConversation 将商品关联的发布群标记为已归档(解散),并写入系统提示。 // 用于客服封存订单时同事务解散群聊;发布群不存在时静默跳过,不阻断封存流程。 // 归档后 SendMessage 的 status 校验会阻断所有成员继续发言。 diff --git a/frontend/src/features/admin/views/AdminChatsView.vue b/frontend/src/features/admin/views/AdminChatsView.vue index 6fd57ad..79f211f 100644 --- a/frontend/src/features/admin/views/AdminChatsView.vue +++ b/frontend/src/features/admin/views/AdminChatsView.vue @@ -564,7 +564,7 @@ function stageCount(key: string) { } function conversationStageLabel(item: ChatConversation) { - if (item.unread_count > 0) return '待处理' + if (item.needs_reply) return '待处理' if (!item.latest_order_id) return '无订单' if (item.latest_order_status === 'pending_handoff') return '待交接' if (['renting', 'overdue'].includes(item.latest_order_status || '')) return '使用中' @@ -584,7 +584,7 @@ function conversationStageLabel(item: ChatConversation) { } function conversationStageClass(item: ChatConversation) { - if (item.unread_count > 0) return 'pending' + if (item.needs_reply) 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' diff --git a/frontend/src/features/chats/api/chats.ts b/frontend/src/features/chats/api/chats.ts index 93d9bfe..0916d85 100644 --- a/frontend/src/features/chats/api/chats.ts +++ b/frontend/src/features/chats/api/chats.ts @@ -39,6 +39,7 @@ export interface ChatConversation { last_sender_id?: number last_sender_role?: string unread_count: number + needs_reply: boolean created_at: string updated_at: string }