perf(chat): 分页客服会话并拆分统计查询
This commit is contained in:
@@ -68,6 +68,7 @@ type ChatMessage struct {
|
|||||||
ContentType string `gorm:"size:32;not null;default:'text'" json:"content_type"`
|
ContentType string `gorm:"size:32;not null;default:'text'" json:"content_type"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
AttachmentURLS datatypes.JSON `gorm:"column:attachment_urls" json:"attachment_urls"`
|
AttachmentURLS datatypes.JSON `gorm:"column:attachment_urls" json:"attachment_urls"`
|
||||||
|
AdminAttentionType string `gorm:"column:admin_attention_type;size:32;not null;default:'';index" json:"admin_attention_type"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -99,7 +99,10 @@ func TestListAdminConversationsUsesPersonalStateAndBatchData(t *testing.T) {
|
|||||||
if item.UnreadCount != 1 {
|
if item.UnreadCount != 1 {
|
||||||
t.Fatalf("管理员未读数 = %d, want 1", item.UnreadCount)
|
t.Fatalf("管理员未读数 = %d, want 1", item.UnreadCount)
|
||||||
}
|
}
|
||||||
counts := result.Counts.(*AdminConversationCountsDTO)
|
counts, err := repo.AdminConversationCounts(t.Context(), Principal{Type: "admin", ID: admin.ID}, adminChatFilterMine, adminChatStageAll, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("查询客服统计失败: %v", err)
|
||||||
|
}
|
||||||
if counts.Ownership[adminChatFilterMine] != 1 || counts.Stages[adminChatStageAll] != 1 {
|
if counts.Ownership[adminChatFilterMine] != 1 || counts.Stages[adminChatStageAll] != 1 {
|
||||||
t.Fatalf("聚合统计异常: ownership=%v stages=%v", counts.Ownership, counts.Stages)
|
t.Fatalf("聚合统计异常: ownership=%v stages=%v", counts.Ownership, counts.Stages)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,8 +60,7 @@ func (r *Repository) FindConversation(ctx context.Context, principal Principal,
|
|||||||
var unreadCount int64
|
var unreadCount int64
|
||||||
if err := db.Table("chat_messages AS cm").
|
if err := db.Table("chat_messages AS cm").
|
||||||
Where("cm.conversation_id = ?", conversation.ID).
|
Where("cm.conversation_id = ?", conversation.ID).
|
||||||
Where("cm.sender_type <> ?", "system").
|
Where("(cm.admin_attention_type <> ? OR cm.sender_type = ?)", "", "user").
|
||||||
Where("NOT (cm.sender_type = ? AND cm.sender_id = ?)", "admin", principal.ID).
|
|
||||||
Where("cm.id > ?", state.LastReadMessageID).
|
Where("cm.id > ?", state.LastReadMessageID).
|
||||||
Count(&unreadCount).Error; err != nil {
|
Count(&unreadCount).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ type MessageDTO struct {
|
|||||||
ContentType string `json:"content_type"`
|
ContentType string `json:"content_type"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
AttachmentURLS []string `json:"attachment_urls"`
|
AttachmentURLS []string `json:"attachment_urls"`
|
||||||
|
AdminAttentionType string `json:"admin_attention_type,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,27 @@ func (h *Handler) AdminList(c *gin.Context) {
|
|||||||
response.OK(c, result)
|
response.OK(c, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) AdminCounts(c *gin.Context) {
|
||||||
|
adminID, ok := currentAdminID(c)
|
||||||
|
if !ok {
|
||||||
|
response.Unauthorized(c, "缺少管理员上下文")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
principal := Principal{Type: "admin", ID: adminID}
|
||||||
|
result, err := h.service.AdminConversationCounts(
|
||||||
|
c.Request.Context(),
|
||||||
|
principal,
|
||||||
|
c.DefaultQuery("filter", "all"),
|
||||||
|
c.DefaultQuery("stage", "all"),
|
||||||
|
c.Query("keyword"),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
writeChatError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) AdminDetail(c *gin.Context) {
|
func (h *Handler) AdminDetail(c *gin.Context) {
|
||||||
adminID, ok := currentAdminID(c)
|
adminID, ok := currentAdminID(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ func AddRenterToListingConversation(tx *gorm.DB, listingID uint64, renterID uint
|
|||||||
if handoffSupportID > 0 {
|
if handoffSupportID > 0 {
|
||||||
message += ",卖号组客服已接入"
|
message += ",卖号组客服已接入"
|
||||||
}
|
}
|
||||||
return sendSystemMessage(tx, conv.ID, message)
|
return sendSystemMessageWithAttention(tx, conv.ID, message, "order_paid")
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveRenterFromListingConversation 移出租客,返回是否真的删除了租客成员记录。
|
// RemoveRenterFromListingConversation 移出租客,返回是否真的删除了租客成员记录。
|
||||||
@@ -248,6 +248,10 @@ func getListingGroupWelcomeMessage(tx *gorm.DB) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func sendSystemMessage(tx *gorm.DB, conversationID uint64, content string) error {
|
func sendSystemMessage(tx *gorm.DB, conversationID uint64, content string) error {
|
||||||
|
return sendSystemMessageWithAttention(tx, conversationID, content, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendSystemMessageWithAttention(tx *gorm.DB, conversationID uint64, content, attentionType string) error {
|
||||||
message := model.ChatMessage{
|
message := model.ChatMessage{
|
||||||
ConversationID: conversationID,
|
ConversationID: conversationID,
|
||||||
SenderType: "system",
|
SenderType: "system",
|
||||||
@@ -255,6 +259,7 @@ func sendSystemMessage(tx *gorm.DB, conversationID uint64, content string) error
|
|||||||
ContentType: "system",
|
ContentType: "system",
|
||||||
Content: content,
|
Content: content,
|
||||||
AttachmentURLS: emptyJSONList(),
|
AttachmentURLS: emptyJSONList(),
|
||||||
|
AdminAttentionType: attentionType,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.Create(&message).Error; err != nil {
|
if err := tx.Create(&message).Error; err != nil {
|
||||||
|
|||||||
@@ -104,6 +104,9 @@ func (r *Repository) SendMessage(ctx context.Context, principal Principal, conve
|
|||||||
Content: req.Content,
|
Content: req.Content,
|
||||||
AttachmentURLS: encodeStringList(req.AttachmentURLS),
|
AttachmentURLS: encodeStringList(req.AttachmentURLS),
|
||||||
}
|
}
|
||||||
|
if principal.Type == "user" {
|
||||||
|
message.AdminAttentionType = "user_inquiry"
|
||||||
|
}
|
||||||
if err := tx.Create(&message).Error; err != nil {
|
if err := tx.Create(&message).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -155,6 +158,7 @@ func (r *Repository) SendMessage(ctx context.Context, principal Principal, conve
|
|||||||
ContentType: msg.ContentType,
|
ContentType: msg.ContentType,
|
||||||
Content: msg.Content,
|
Content: msg.Content,
|
||||||
AttachmentURLS: msg.AttachmentURLS,
|
AttachmentURLS: msg.AttachmentURLS,
|
||||||
|
AdminAttentionType: msg.AdminAttentionType,
|
||||||
CreatedAt: msg.CreatedAt.Format(time.RFC3339),
|
CreatedAt: msg.CreatedAt.Format(time.RFC3339),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,6 +188,7 @@ func (r *Repository) toMessageDTOs(ctx context.Context, principal Principal, row
|
|||||||
ContentType: row.ContentType,
|
ContentType: row.ContentType,
|
||||||
Content: row.Content,
|
Content: row.Content,
|
||||||
AttachmentURLS: decodeStringList(row.AttachmentURLS),
|
AttachmentURLS: decodeStringList(row.AttachmentURLS),
|
||||||
|
AdminAttentionType: row.AdminAttentionType,
|
||||||
CreatedAt: row.CreatedAt,
|
CreatedAt: row.CreatedAt,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,6 +155,13 @@ func (s *Service) ListConversationsWithFilter(ctx context.Context, principal Pri
|
|||||||
return s.repo.ListConversationsWithFilter(ctx, principal, page, pageSize, filter, stage, keyword)
|
return s.repo.ListConversationsWithFilter(ctx, principal, page, pageSize, filter, stage, keyword)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) AdminConversationCounts(ctx context.Context, principal Principal, filter string, stage string, keyword string) (*AdminConversationCountsDTO, error) {
|
||||||
|
if s.repo == nil {
|
||||||
|
return nil, ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
return s.repo.AdminConversationCounts(ctx, principal, 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 {
|
||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return ErrDependencyUnavailable
|
return ErrDependencyUnavailable
|
||||||
|
|||||||
@@ -221,7 +221,7 @@ func (r *Repository) ListConversationsWithFilter(ctx context.Context, principal
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) listAdminConversations(ctx context.Context, principal Principal, page, pageSize int, filter string, stage string, keyword string) (*PaginatedResult, error) {
|
func (r *Repository) listAdminConversations(ctx context.Context, principal Principal, page, pageSize int, filter string, stage string, keyword string) (*PaginatedResult, error) {
|
||||||
counts, err := r.adminConversationCounts(ctx, principal, filter, stage, keyword)
|
total, err := r.adminConversationTotal(ctx, principal, filter, stage, keyword)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -243,10 +243,9 @@ func (r *Repository) listAdminConversations(ctx context.Context, principal Princ
|
|||||||
SELECT COUNT(1)
|
SELECT COUNT(1)
|
||||||
FROM chat_messages AS cm
|
FROM chat_messages AS cm
|
||||||
WHERE cm.conversation_id = c.id
|
WHERE cm.conversation_id = c.id
|
||||||
AND cm.sender_type <> 'system'
|
AND (cm.admin_attention_type <> '' OR cm.sender_type = 'user')
|
||||||
AND NOT (cm.sender_type = ? AND cm.sender_id = ?)
|
|
||||||
AND cm.id > COALESCE(cas.last_read_message_id, 0)
|
AND cm.id > COALESCE(cas.last_read_message_id, 0)
|
||||||
) AS unread_count`, principal.Type, principal.ID)
|
) AS unread_count`)
|
||||||
applyAdminChatOwnershipFilter(queryDB, filter, principal)
|
applyAdminChatOwnershipFilter(queryDB, filter, principal)
|
||||||
applyAdminChatStageFilter(queryDB, stage, principal)
|
applyAdminChatStageFilter(queryDB, stage, principal)
|
||||||
if err := queryDB.
|
if err := queryDB.
|
||||||
@@ -269,7 +268,26 @@ func (r *Repository) listAdminConversations(ctx context.Context, principal Princ
|
|||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
items = append(items, row.toDTO(participantsByConversation[row.ID]))
|
items = append(items, row.toDTO(participantsByConversation[row.ID]))
|
||||||
}
|
}
|
||||||
return &PaginatedResult{Items: items, Total: counts.Total, Page: page, PageSize: pageSize, Counts: counts.DTO}, nil
|
return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) AdminConversationCounts(ctx context.Context, principal Principal, filter string, stage string, keyword string) (*AdminConversationCountsDTO, error) {
|
||||||
|
result, err := r.adminConversationCounts(ctx, principal, normalizeAdminChatFilter(filter), normalizeAdminChatStage(stage), strings.TrimSpace(keyword))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return result.DTO, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) adminConversationTotal(ctx context.Context, principal Principal, filter string, stage string, keyword string) (int64, error) {
|
||||||
|
db := r.adminConversationBase(ctx, principal, strings.TrimSpace(keyword))
|
||||||
|
applyAdminChatOwnershipFilter(db, normalizeAdminChatFilter(filter), principal)
|
||||||
|
applyAdminChatStageFilter(db, normalizeAdminChatStage(stage), principal)
|
||||||
|
var total int64
|
||||||
|
if err := db.Select("COUNT(DISTINCT c.id)").Scan(&total).Error; err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) adminConversationBase(ctx context.Context, principal Principal, keyword string) *gorm.DB {
|
func (r *Repository) adminConversationBase(ctx context.Context, principal Principal, keyword string) *gorm.DB {
|
||||||
@@ -396,17 +414,13 @@ func adminChatStageCondition(stage string, principal Principal) (string, []inter
|
|||||||
latestRefundStatus := "COALESCE(explicit_lo.refund_status, listing_lo.refund_status)"
|
latestRefundStatus := "COALESCE(explicit_lo.refund_status, listing_lo.refund_status)"
|
||||||
switch stage {
|
switch stage {
|
||||||
case adminChatStagePending:
|
case adminChatStagePending:
|
||||||
return `(
|
return `EXISTS (
|
||||||
lm.sender_type = 'user'
|
|
||||||
OR EXISTS (
|
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM chat_messages AS cm_pending
|
FROM chat_messages AS cm_pending
|
||||||
WHERE cm_pending.conversation_id = c.id
|
WHERE cm_pending.conversation_id = c.id
|
||||||
AND cm_pending.sender_type <> 'system'
|
AND (cm_pending.admin_attention_type <> '' OR cm_pending.sender_type = 'user')
|
||||||
AND NOT (cm_pending.sender_type = 'admin' AND cm_pending.sender_id = ?)
|
|
||||||
AND cm_pending.id > COALESCE(cas.last_read_message_id, 0)
|
AND cm_pending.id > COALESCE(cas.last_read_message_id, 0)
|
||||||
)
|
)`, nil
|
||||||
)`, []interface{}{principal.ID}
|
|
||||||
case adminChatStageUnjoined:
|
case adminChatStageUnjoined:
|
||||||
return latestID + " IS NULL", nil
|
return latestID + " IS NULL", nil
|
||||||
case adminChatStageHandoff:
|
case adminChatStageHandoff:
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ type MessageData struct {
|
|||||||
ContentType string `json:"content_type"`
|
ContentType string `json:"content_type"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
AttachmentURLS []string `json:"attachment_urls"`
|
AttachmentURLS []string `json:"attachment_urls"`
|
||||||
|
AdminAttentionType string `json:"admin_attention_type,omitempty"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -726,6 +726,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
adminRoutes.GET("/chats/events", requirePerm("chat:view"), chatHubHandler.AdminEvents)
|
adminRoutes.GET("/chats/events", requirePerm("chat:view"), chatHubHandler.AdminEvents)
|
||||||
}
|
}
|
||||||
adminRoutes.GET("/chats", requirePerm("chat:view"), chatHandler.AdminList)
|
adminRoutes.GET("/chats", requirePerm("chat:view"), chatHandler.AdminList)
|
||||||
|
adminRoutes.GET("/chats/counts", requirePerm("chat:view"), chatHandler.AdminCounts)
|
||||||
adminRoutes.GET("/chats/:id", requirePerm("chat:view"), chatHandler.AdminDetail)
|
adminRoutes.GET("/chats/:id", requirePerm("chat:view"), chatHandler.AdminDetail)
|
||||||
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)
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- +goose StatementBegin
|
||||||
|
|
||||||
|
ALTER TABLE chat_messages
|
||||||
|
ADD COLUMN admin_attention_type VARCHAR(32) NOT NULL DEFAULT '' COMMENT '客服关注类型: user_inquiry用户咨询, order_paid支付成功' AFTER attachment_urls,
|
||||||
|
ADD KEY idx_chat_messages_attention (conversation_id, admin_attention_type, id);
|
||||||
|
|
||||||
|
UPDATE chat_messages
|
||||||
|
SET admin_attention_type = 'user_inquiry'
|
||||||
|
WHERE sender_type = 'user' AND admin_attention_type = '';
|
||||||
|
|
||||||
|
INSERT INTO chat_admin_conversation_states (
|
||||||
|
conversation_id, admin_user_id, remark, last_read_message_id, last_read_at
|
||||||
|
)
|
||||||
|
SELECT c.id, au.id, '', COALESCE(c.last_message_id, 0), c.last_message_at
|
||||||
|
FROM chat_conversations c
|
||||||
|
JOIN admin_users au ON au.status = 'active'
|
||||||
|
JOIN admin_user_roles aur ON aur.admin_user_id = au.id
|
||||||
|
JOIN roles r ON r.id = aur.role_id AND r.code = 'cs'
|
||||||
|
LEFT JOIN chat_admin_conversation_states cas
|
||||||
|
ON cas.conversation_id = c.id AND cas.admin_user_id = au.id
|
||||||
|
WHERE cas.id IS NULL;
|
||||||
|
|
||||||
|
UPDATE chat_admin_conversation_states cas
|
||||||
|
JOIN chat_conversations c ON c.id = cas.conversation_id
|
||||||
|
SET cas.last_read_message_id = COALESCE(c.last_message_id, 0),
|
||||||
|
cas.last_read_at = c.last_message_at
|
||||||
|
WHERE cas.last_read_message_id = 0;
|
||||||
|
|
||||||
|
-- +goose StatementEnd
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
-- +goose StatementBegin
|
||||||
|
ALTER TABLE chat_messages
|
||||||
|
DROP KEY idx_chat_messages_attention,
|
||||||
|
DROP COLUMN admin_attention_type;
|
||||||
|
-- +goose StatementEnd
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { Close, Picture } from '@element-plus/icons-vue'
|
import { Close, Picture } from '@element-plus/icons-vue'
|
||||||
import {
|
import {
|
||||||
fetchAdminChat,
|
fetchAdminChat,
|
||||||
fetchAdminChatMessages,
|
fetchAdminChatMessages,
|
||||||
|
fetchAdminChatCounts,
|
||||||
fetchAdminChats,
|
fetchAdminChats,
|
||||||
fetchQuickReplies,
|
fetchQuickReplies,
|
||||||
markAdminChatRead,
|
markAdminChatRead,
|
||||||
@@ -41,6 +42,9 @@ const currentAdminId = Number(localStorage.getItem('admin_id') || 0)
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
const conversations = ref<ChatConversation[]>([])
|
const conversations = ref<ChatConversation[]>([])
|
||||||
|
const conversationPage = ref(1)
|
||||||
|
const conversationPageSize = 50
|
||||||
|
const conversationTotal = ref(0)
|
||||||
const active = ref<ChatConversation | null>(null)
|
const active = ref<ChatConversation | null>(null)
|
||||||
const messages = ref<ChatMessage[]>([])
|
const messages = ref<ChatMessage[]>([])
|
||||||
const orderLoading = ref(false)
|
const orderLoading = ref(false)
|
||||||
@@ -67,6 +71,9 @@ 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
|
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let countsTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let countsCacheKey = ''
|
||||||
|
let countsLoadedAt = 0
|
||||||
|
|
||||||
const ownershipTabs = [
|
const ownershipTabs = [
|
||||||
{ key: 'mine', label: '我的' },
|
{ key: 'mine', label: '我的' },
|
||||||
@@ -123,6 +130,7 @@ const desktopNotification = useDesktopNotification('admin')
|
|||||||
function handleSSEEvent(event: ChatEvent) {
|
function handleSSEEvent(event: ChatEvent) {
|
||||||
if (event.type === 'conversation_updated') {
|
if (event.type === 'conversation_updated') {
|
||||||
loadConversations(false)
|
loadConversations(false)
|
||||||
|
scheduleCountsRefresh()
|
||||||
}
|
}
|
||||||
if (event.type === 'conversation_read') {
|
if (event.type === 'conversation_read') {
|
||||||
// 对方已读:仅当本端仍有未获回执的自己消息时重载,刷新「已读」状态
|
// 对方已读:仅当本端仍有未获回执的自己消息时重载,刷新「已读」状态
|
||||||
@@ -164,9 +172,10 @@ function handleSSEEvent(event: ChatEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
loadConversations(false)
|
loadConversations(false)
|
||||||
|
scheduleCountsRefresh()
|
||||||
|
|
||||||
// 不是自己发送的消息才发送通知;当前正在看的会话且页面前台时会在 notify 内部静默。
|
// 不是自己发送的消息才发送通知;当前正在看的会话且页面前台时会在 notify 内部静默。
|
||||||
if (!isSelf) {
|
if (!isSelf && msg.admin_attention_type) {
|
||||||
desktopNotification.notify(event, active.value?.id ?? null)
|
desktopNotification.notify(event, active.value?.id ?? null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -187,19 +196,25 @@ onMounted(async () => {
|
|||||||
stage.value = routeStage as typeof stage.value
|
stage.value = routeStage as typeof stage.value
|
||||||
}
|
}
|
||||||
keyword.value = firstQueryValue(route.query.chat_keyword)
|
keyword.value = firstQueryValue(route.query.chat_keyword)
|
||||||
await Promise.all([loadConversations(), loadQuickReplies()])
|
await Promise.all([loadConversations(), loadCounts(true), loadQuickReplies()])
|
||||||
})
|
})
|
||||||
|
|
||||||
async function loadConversations(showLoading = true) {
|
onBeforeUnmount(() => {
|
||||||
|
if (searchTimer) clearTimeout(searchTimer)
|
||||||
|
if (countsTimer) clearTimeout(countsTimer)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadConversations(showLoading = true, resetPage = false) {
|
||||||
|
if (resetPage) conversationPage.value = 1
|
||||||
if (showLoading) loading.value = true
|
if (showLoading) loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await fetchAdminChats(1, 100, {
|
const res = await fetchAdminChats(conversationPage.value, conversationPageSize, {
|
||||||
filter: filter.value,
|
filter: filter.value,
|
||||||
stage: stage.value,
|
stage: stage.value,
|
||||||
keyword: keyword.value.trim(),
|
keyword: keyword.value.trim(),
|
||||||
})
|
})
|
||||||
conversations.value = res.items
|
conversations.value = res.items
|
||||||
chatCounts.value = res.counts || {}
|
conversationTotal.value = res.total
|
||||||
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)
|
||||||
@@ -216,6 +231,40 @@ async function loadConversations(showLoading = true) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadCounts(force = false) {
|
||||||
|
const query = {
|
||||||
|
filter: filter.value,
|
||||||
|
stage: stage.value,
|
||||||
|
keyword: keyword.value.trim(),
|
||||||
|
}
|
||||||
|
const cacheKey = JSON.stringify(query)
|
||||||
|
if (!force && cacheKey === countsCacheKey && Date.now() - countsLoadedAt < 5000) return
|
||||||
|
try {
|
||||||
|
chatCounts.value = await fetchAdminChatCounts(query)
|
||||||
|
countsCacheKey = cacheKey
|
||||||
|
countsLoadedAt = Date.now()
|
||||||
|
} catch {
|
||||||
|
/* keep the last successful counts visible */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleCountsRefresh() {
|
||||||
|
if (countsTimer) clearTimeout(countsTimer)
|
||||||
|
countsTimer = setTimeout(() => {
|
||||||
|
countsTimer = null
|
||||||
|
void loadCounts(true)
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePageChange(page: number) {
|
||||||
|
conversationPage.value = page
|
||||||
|
void loadConversations()
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshAll() {
|
||||||
|
void Promise.all([loadConversations(true), loadCounts(true)])
|
||||||
|
}
|
||||||
|
|
||||||
async function loadQuickReplies() {
|
async function loadQuickReplies() {
|
||||||
try {
|
try {
|
||||||
quickReplies.value = await fetchQuickReplies()
|
quickReplies.value = await fetchQuickReplies()
|
||||||
@@ -236,6 +285,7 @@ async function openConversationById(id: number, refreshList = true) {
|
|||||||
void loadOrderContext(chat)
|
void loadOrderContext(chat)
|
||||||
await loadMessages(id)
|
await loadMessages(id)
|
||||||
await markAdminChatRead(id)
|
await markAdminChatRead(id)
|
||||||
|
scheduleCountsRefresh()
|
||||||
if (refreshList) await loadConversations(false)
|
if (refreshList) await loadConversations(false)
|
||||||
remarkEditing.value = false
|
remarkEditing.value = false
|
||||||
remarkValue.value = ''
|
remarkValue.value = ''
|
||||||
@@ -384,27 +434,27 @@ 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()
|
resetActiveConversation()
|
||||||
loadConversations()
|
void Promise.all([loadConversations(true, true), loadCounts(true)])
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleStageChange(val: string) {
|
function handleStageChange(val: string) {
|
||||||
stage.value = val as typeof stage.value
|
stage.value = val as typeof stage.value
|
||||||
resetActiveConversation()
|
resetActiveConversation()
|
||||||
loadConversations()
|
void Promise.all([loadConversations(true, true), loadCounts(true)])
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeywordInput() {
|
function handleKeywordInput() {
|
||||||
if (searchTimer) clearTimeout(searchTimer)
|
if (searchTimer) clearTimeout(searchTimer)
|
||||||
searchTimer = setTimeout(() => {
|
searchTimer = setTimeout(() => {
|
||||||
resetActiveConversation()
|
resetActiveConversation()
|
||||||
loadConversations()
|
void Promise.all([loadConversations(true, true), loadCounts(true)])
|
||||||
}, 320)
|
}, 320)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeywordSearch() {
|
function handleKeywordSearch() {
|
||||||
if (searchTimer) clearTimeout(searchTimer)
|
if (searchTimer) clearTimeout(searchTimer)
|
||||||
resetActiveConversation()
|
resetActiveConversation()
|
||||||
loadConversations()
|
void Promise.all([loadConversations(true, true), loadCounts(true)])
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetActiveConversation() {
|
function resetActiveConversation() {
|
||||||
@@ -417,6 +467,7 @@ function resetActiveConversation() {
|
|||||||
|
|
||||||
function handleTransferSuccess() {
|
function handleTransferSuccess() {
|
||||||
loadConversations(false)
|
loadConversations(false)
|
||||||
|
scheduleCountsRefresh()
|
||||||
if (active.value) {
|
if (active.value) {
|
||||||
loadMessages(active.value.id, false)
|
loadMessages(active.value.id, false)
|
||||||
}
|
}
|
||||||
@@ -488,7 +539,7 @@ function stageCount(key: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function conversationStageLabel(item: ChatConversation) {
|
function conversationStageLabel(item: ChatConversation) {
|
||||||
if (item.unread_count > 0 || item.last_sender_type === 'user') return '待处理'
|
if (item.unread_count > 0) return '待处理'
|
||||||
if (!item.latest_order_id) return '无订单'
|
if (!item.latest_order_id) return '无订单'
|
||||||
if (item.latest_order_status === 'pending_handoff') return '待交接'
|
if (item.latest_order_status === 'pending_handoff') return '待交接'
|
||||||
if (['renting', 'overdue'].includes(item.latest_order_status || '')) return '使用中'
|
if (['renting', 'overdue'].includes(item.latest_order_status || '')) return '使用中'
|
||||||
@@ -508,7 +559,7 @@ function conversationStageLabel(item: ChatConversation) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function conversationStageClass(item: ChatConversation) {
|
function conversationStageClass(item: ChatConversation) {
|
||||||
if (item.unread_count > 0 || item.last_sender_type === 'user') return 'pending'
|
if (item.unread_count > 0) return 'pending'
|
||||||
if (!item.latest_order_id) return 'unjoined'
|
if (!item.latest_order_id) return 'unjoined'
|
||||||
if (item.latest_order_status === 'pending_handoff') return 'handoff'
|
if (item.latest_order_status === 'pending_handoff') return 'handoff'
|
||||||
if (['renting', 'overdue'].includes(item.latest_order_status || '')) return 'renting'
|
if (['renting', 'overdue'].includes(item.latest_order_status || '')) return 'renting'
|
||||||
@@ -603,7 +654,7 @@ function firstQueryValue(value: unknown) {
|
|||||||
<div class="head-right">
|
<div class="head-right">
|
||||||
<NotificationSettings scope="admin" />
|
<NotificationSettings scope="admin" />
|
||||||
<el-button @click="quickReplyVisible = true">快捷回复管理</el-button>
|
<el-button @click="quickReplyVisible = true">快捷回复管理</el-button>
|
||||||
<el-button :loading="loading" @click="loadConversations()">刷新</el-button>
|
<el-button :loading="loading" @click="refreshAll">刷新</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -668,6 +719,18 @@ function firstQueryValue(value: unknown) {
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<el-empty v-if="!loading && conversations.length === 0" description="暂无客服会话" />
|
<el-empty v-if="!loading && conversations.length === 0" description="暂无客服会话" />
|
||||||
|
<div v-if="conversationTotal > conversationPageSize" class="conversation-pagination">
|
||||||
|
<el-pagination
|
||||||
|
small
|
||||||
|
background
|
||||||
|
layout="prev, pager, next"
|
||||||
|
:current-page="conversationPage"
|
||||||
|
:page-size="conversationPageSize"
|
||||||
|
:total="conversationTotal"
|
||||||
|
:disabled="loading"
|
||||||
|
@current-change="handlePageChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main class="message-pane">
|
<main class="message-pane">
|
||||||
@@ -1013,6 +1076,14 @@ function firstQueryValue(value: unknown) {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.conversation-pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 12px 8px;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
.conversation-row {
|
.conversation-row {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
@@ -160,6 +160,16 @@ export async function fetchAdminChats(page = 1, pageSize = 50, query: AdminChatQ
|
|||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchAdminChatCounts(query: AdminChatQuery = {}) {
|
||||||
|
const params = Object.fromEntries(
|
||||||
|
Object.entries(query).filter(([, value]) => value !== '' && value !== undefined)
|
||||||
|
)
|
||||||
|
const { data } = await apiClient.get<ApiResponse<AdminChatCounts>>('/admin/chats/counts', {
|
||||||
|
params,
|
||||||
|
})
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchAdminChat(id: number) {
|
export async function fetchAdminChat(id: number) {
|
||||||
const { data } = await apiClient.get<ApiResponse<ChatConversation>>(`/admin/chats/${id}`)
|
const { data } = await apiClient.get<ApiResponse<ChatConversation>>(`/admin/chats/${id}`)
|
||||||
return data.data
|
return data.data
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export interface SSEMessage {
|
|||||||
content_type: string
|
content_type: string
|
||||||
content: string
|
content: string
|
||||||
attachment_urls: string[]
|
attachment_urls: string[]
|
||||||
|
admin_attention_type?: string
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user