diff --git a/server/internal/handler/session.go b/server/internal/handler/session.go index 22a6423..812a081 100644 --- a/server/internal/handler/session.go +++ b/server/internal/handler/session.go @@ -84,15 +84,17 @@ func (h *SessionHandler) SendMessage(c *gin.Context) { func (h *SessionHandler) List(c *gin.Context) { page, pageSize := middleware.GetPageParams(c) f := service.SessionFilter{ - Status: c.Query("status"), - Priority: c.Query("priority"), - AgentID: c.Query("agent_id"), - ChannelID: c.Query("channel_id"), - Search: strings.TrimSpace(c.Query("search")), - From: c.Query("from"), - To: c.Query("to"), - Page: page, - PageSize: pageSize, + Scope: c.Query("scope"), + Status: c.Query("status"), + Priority: c.Query("priority"), + AgentID: c.Query("agent_id"), + ChannelID: c.Query("channel_id"), + CustomerID: c.Query("customer_id"), + Search: strings.TrimSpace(c.Query("search")), + From: c.Query("from"), + To: c.Query("to"), + Page: page, + PageSize: pageSize, } items, total, err := service.ListSessions(sessionActor(c), middleware.GetTenantID(c), f) if err != nil { diff --git a/server/internal/model/models.go b/server/internal/model/models.go index 6115524..d90f24d 100644 --- a/server/internal/model/models.go +++ b/server/internal/model/models.go @@ -98,10 +98,10 @@ type BlacklistEntry struct { type Session struct { ID uint `gorm:"primaryKey" json:"id"` - TenantID uint `gorm:"index;not null" json:"tenant_id"` + TenantID uint `gorm:"index;index:idx_session_tenant_status,priority:1;index:idx_session_tenant_agent_status,priority:1;not null" json:"tenant_id"` ChannelID uint `json:"channel_id"` CustomerID uint `gorm:"index" json:"customer_id"` - AgentID *uint `gorm:"index" json:"agent_id"` + AgentID *uint `gorm:"index;index:idx_session_tenant_agent_status,priority:2" json:"agent_id"` VisitorTokenHash string `gorm:"size:64;index" json:"-"` VisitorIP string `gorm:"size:64" json:"visitor_ip"` VisitorRegion string `gorm:"size:100" json:"visitor_region"` @@ -128,7 +128,7 @@ type Session struct { DraftText string `gorm:"type:text" json:"draft_text"` DraftUpdatedAt *time.Time `json:"draft_updated_at"` LastReadSeq int `gorm:"default:0" json:"last_read_seq"` - Status string `gorm:"size:20;default:waiting" json:"status"` + Status string `gorm:"size:20;default:waiting;index:idx_session_tenant_status,priority:2;index:idx_session_tenant_agent_status,priority:3" json:"status"` Priority string `gorm:"size:20;default:normal" json:"priority"` SatisfactionScore *int `json:"satisfaction_score"` SatisfactionText string `gorm:"size:500" json:"satisfaction_text"` diff --git a/server/internal/service/session.go b/server/internal/service/session.go index 7c5e153..b12e159 100644 --- a/server/internal/service/session.go +++ b/server/internal/service/session.go @@ -41,15 +41,17 @@ type AgentItem struct { // SessionFilter 会话列表筛选。 type SessionFilter struct { - Status string - Priority string - AgentID string - ChannelID string - Search string - From string - To string - Page int - PageSize int + Scope string + Status string + Priority string + AgentID string + ChannelID string + CustomerID string + Search string + From string + To string + Page int + PageSize int } // LoadTenantSession 加载本租户会话;跨租户访问返回 403。 @@ -120,21 +122,30 @@ func assignableRoleCodes(tenantID uint) []string { return append(codes, customCodes...) } -func unreadCount(session model.Session) int { - var count int64 - model.DB.Model(&model.Message{}). - Where("session_id = ? AND sender_type = ? AND seq > ?", session.ID, "visitor", session.LastReadSeq). - Count(&count) - return int(count) -} - -// ListSessions 会话列表:筛选、分页并批量补全客户/坐席/渠道/消息数。 +// ListSessions 会话列表:筛选、分页并批量补全客户/坐席/渠道/消息摘要。 func ListSessions(actor Actor, tenantID uint, f SessionFilter) ([]SessionListItem, int64, error) { var sessions []model.Session var total int64 + page := f.Page + pageSize := f.PageSize + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + if pageSize > 100 { + pageSize = 100 + } + query := model.DB.Model(&model.Session{}).Where("sessions.tenant_id = ?", tenantID) - if !actor.SessionAll || !actor.HistoryAll { + if f.Scope == "workbench" { + query = query.Where("sessions.status IN ?", []string{"waiting", "active"}) + if !actor.SessionAll { + query = query.Where("sessions.agent_id = ? OR sessions.status = ?", actor.UserID, "waiting") + } + } else if !actor.SessionAll || !actor.HistoryAll { query = query.Where("sessions.agent_id = ? OR sessions.status = ?", actor.UserID, "waiting") } if f.Status != "" { @@ -149,6 +160,9 @@ func ListSessions(actor Actor, tenantID uint, f SessionFilter) ([]SessionListIte if f.ChannelID != "" { query = query.Where("sessions.channel_id = ?", f.ChannelID) } + if f.CustomerID != "" { + query = query.Where("sessions.customer_id = ?", f.CustomerID) + } if f.From != "" { if t, err := time.ParseInLocation("2006-01-02", f.From, time.Local); err == nil { query = query.Where("sessions.created_at >= ?", t) @@ -161,16 +175,52 @@ func ListSessions(actor Actor, tenantID uint, f SessionFilter) ([]SessionListIte } if f.Search != "" { like := "%" + f.Search + "%" - query = query.Joins("LEFT JOIN customers ON customers.id = sessions.customer_id"). - Where("customers.name LIKE ? OR CAST(sessions.id AS TEXT) LIKE ? OR COALESCE(sessions.end_reason, '') LIKE ?", like, like, like) + query = query.Where( + `EXISTS ( + SELECT 1 FROM customers AS search_customers + WHERE search_customers.id = sessions.customer_id + AND search_customers.name LIKE ? + ) OR CAST(sessions.id AS TEXT) LIKE ? OR EXISTS ( + SELECT 1 FROM messages AS search_messages + WHERE search_messages.session_id = sessions.id + AND search_messages.seq = ( + SELECT MAX(latest_message.seq) + FROM messages AS latest_message + WHERE latest_message.session_id = sessions.id + ) + AND search_messages.content LIKE ? + )`, + like, like, like, + ) } - query.Count(&total) - if err := query.Order("sessions.created_at desc").Offset((f.Page - 1) * f.PageSize).Limit(f.PageSize).Find(&sessions).Error; err != nil { + if err := query.Count(&total).Error; err != nil { + return nil, 0, Internal("统计会话失败") + } + if f.Scope == "workbench" { + query = query. + Order(`CASE + WHEN sessions.priority = 'urgent' THEN 0 + WHEN sessions.status = 'waiting' THEN 1 + ELSE 2 + END ASC`). + Order(`COALESCE( + (SELECT order_messages.sent_at + FROM messages AS order_messages + WHERE order_messages.session_id = sessions.id + ORDER BY order_messages.seq DESC + LIMIT 1), + sessions.created_at + ) DESC`). + Order("sessions.id DESC") + } else { + query = query.Order("sessions.created_at DESC").Order("sessions.id DESC") + } + if err := query.Offset((page - 1) * pageSize).Limit(pageSize).Find(&sessions).Error; err != nil { return nil, 0, Internal("查询会话失败") } - // 批量补全客户 / 客服 / 渠道 / 消息数 + // 批量补全客户 / 客服 / 渠道 / 消息摘要,避免按会话逐条查询。 customerIDs := make([]uint, 0) agentIDs := make([]uint, 0) channelIDs := make([]uint, 0) @@ -186,7 +236,9 @@ func ListSessions(actor Actor, tenantID uint, f SessionFilter) ([]SessionListIte customerMap := map[uint]model.Customer{} if len(customerIDs) > 0 { var customers []model.Customer - model.DB.Where("id IN ?", UniqueUint(customerIDs)).Find(&customers) + if err := model.DB.Where("id IN ?", UniqueUint(customerIDs)).Find(&customers).Error; err != nil { + return nil, 0, Internal("查询会话客户失败") + } for _, cu := range customers { customerMap[cu.ID] = cu } @@ -194,7 +246,9 @@ func ListSessions(actor Actor, tenantID uint, f SessionFilter) ([]SessionListIte agentMap := map[uint]model.User{} if len(agentIDs) > 0 { var users []model.User - model.DB.Where("id IN ?", UniqueUint(agentIDs)).Find(&users) + if err := model.DB.Where("id IN ?", UniqueUint(agentIDs)).Find(&users).Error; err != nil { + return nil, 0, Internal("查询会话坐席失败") + } for _, u := range users { agentMap[u.ID] = u } @@ -202,35 +256,74 @@ func ListSessions(actor Actor, tenantID uint, f SessionFilter) ([]SessionListIte channelMap := map[uint]model.Channel{} if len(channelIDs) > 0 { var channels []model.Channel - model.DB.Unscoped().Where("id IN ?", UniqueUint(channelIDs)).Find(&channels) + if err := model.DB.Unscoped().Where("id IN ?", UniqueUint(channelIDs)).Find(&channels).Error; err != nil { + return nil, 0, Internal("查询会话渠道失败") + } for _, ch := range channels { channelMap[ch.ID] = ch } } msgCountMap := map[uint]int{} + lastMessageMap := map[uint]model.Message{} + unreadMap := map[uint]int{} if len(sessionIDs) > 0 { type row struct { SessionID uint - Cnt int + Cnt int `gorm:"column:cnt"` } var rows []row - model.DB.Model(&model.Message{}). + if err := model.DB.Model(&model.Message{}). Select("session_id, COUNT(*) as cnt"). Where("session_id IN ?", sessionIDs). Group("session_id"). - Scan(&rows) + Scan(&rows).Error; err != nil { + return nil, 0, Internal("统计会话消息失败") + } for _, r := range rows { msgCountMap[r.SessionID] = r.Cnt } + + var lastMessages []model.Message + if err := model.DB.Where( + `session_id IN ? AND seq = ( + SELECT MAX(latest.seq) + FROM messages AS latest + WHERE latest.session_id = messages.session_id + )`, + sessionIDs, + ).Find(&lastMessages).Error; err != nil { + return nil, 0, Internal("查询会话最后消息失败") + } + for _, msg := range lastMessages { + lastMessageMap[msg.SessionID] = msg + } + + var unreadRows []row + if err := model.DB.Table("messages"). + Select("messages.session_id, COUNT(*) AS cnt"). + Joins("JOIN sessions AS unread_sessions ON unread_sessions.id = messages.session_id"). + Where( + "messages.session_id IN ? AND messages.sender_type = ? AND messages.seq > unread_sessions.last_read_seq", + sessionIDs, + "visitor", + ). + Group("messages.session_id"). + Scan(&unreadRows).Error; err != nil { + return nil, 0, Internal("统计会话未读数失败") + } + for _, r := range unreadRows { + unreadMap[r.SessionID] = r.Cnt + } } items := make([]SessionListItem, 0, len(sessions)) for _, session := range sessions { - item := SessionListItem{Session: session, MessageCount: msgCountMap[session.ID]} - item.VisitorOnline = VisitorIsOnline(&session, time.Now()) - if !actor.SessionAll && session.AgentID != nil && *session.AgentID == actor.UserID { - item.UnreadCount = unreadCount(session) + item := SessionListItem{ + Session: session, + MessageCount: msgCountMap[session.ID], + UnreadCount: unreadMap[session.ID], } + item.VisitorOnline = VisitorIsOnline(&session, time.Now()) if cu, ok := customerMap[session.CustomerID]; ok { item.CustomerName = cu.Name } @@ -244,8 +337,7 @@ func ListSessions(actor Actor, tenantID uint, f SessionFilter) ([]SessionListIte item.ChannelType = ch.Type item.ChannelDomain = ch.SiteDomain } - var lastMsg model.Message - if err := model.DB.Where("session_id = ?", session.ID).Order("seq desc").First(&lastMsg).Error; err == nil { + if lastMsg, ok := lastMessageMap[session.ID]; ok { if lastMsg.Type == "image" { item.LastMessage = "[图片]" } else { diff --git a/server/internal/service/session_list_test.go b/server/internal/service/session_list_test.go new file mode 100644 index 0000000..94369c8 --- /dev/null +++ b/server/internal/service/session_list_test.go @@ -0,0 +1,179 @@ +package service + +import ( + "fmt" + "testing" + "time" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "kefu-cloud/server/internal/model" +) + +func setupSessionListTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open( + sqlite.Open(fmt.Sprintf("file:kefu_session_list_%d?mode=memory&cache=shared", time.Now().UnixNano())), + &gorm.Config{}, + ) + if err != nil { + t.Fatalf("打开测试数据库失败: %v", err) + } + model.DB = db + if err := model.Migrate(db); err != nil { + t.Fatalf("迁移测试数据库失败: %v", err) + } + return db +} + +func TestListSessionsWorkbenchPaginatesSortsAndExcludesEnded(t *testing.T) { + db := setupSessionListTestDB(t) + tenant := model.Tenant{Name: "分页测试租户", Status: "normal"} + if err := db.Create(&tenant).Error; err != nil { + t.Fatalf("创建租户失败: %v", err) + } + customer := model.Customer{TenantID: tenant.ID, Name: "星河科技"} + if err := db.Create(&customer).Error; err != nil { + t.Fatalf("创建客户失败: %v", err) + } + + now := time.Now().Truncate(time.Second) + sessions := []model.Session{ + { + TenantID: tenant.ID, CustomerID: customer.ID, Status: "active", Priority: "urgent", + VisitorIP: "10.0.0.1", CreatedAt: now.Add(-4 * time.Hour), + }, + { + TenantID: tenant.ID, CustomerID: customer.ID, Status: "waiting", Priority: "normal", + VisitorIP: "10.0.0.2", CreatedAt: now.Add(-3 * time.Hour), + }, + { + TenantID: tenant.ID, CustomerID: customer.ID, Status: "active", Priority: "normal", + VisitorIP: "10.0.0.3", CreatedAt: now.Add(-2 * time.Hour), + }, + { + TenantID: tenant.ID, CustomerID: customer.ID, Status: "ended", Priority: "urgent", + VisitorIP: "10.0.0.4", CreatedAt: now.Add(-time.Hour), + }, + } + if err := db.Create(&sessions).Error; err != nil { + t.Fatalf("创建会话失败: %v", err) + } + messages := []model.Message{ + {SessionID: sessions[0].ID, SenderType: "visitor", Content: "紧急报价单", Type: "text", Seq: 1, SentAt: now.Add(-10 * time.Minute)}, + {SessionID: sessions[1].ID, SenderType: "visitor", Content: "等待接入", Type: "text", Seq: 1, SentAt: now.Add(-5 * time.Minute)}, + {SessionID: sessions[2].ID, SenderType: "visitor", Content: "普通咨询", Type: "text", Seq: 1, SentAt: now.Add(-time.Minute)}, + {SessionID: sessions[3].ID, SenderType: "visitor", Content: "已结束会话", Type: "text", Seq: 1, SentAt: now}, + } + if err := db.Create(&messages).Error; err != nil { + t.Fatalf("创建消息失败: %v", err) + } + + allData := Actor{SessionAll: true, HistoryAll: true} + firstPage, total, err := ListSessions(allData, tenant.ID, SessionFilter{ + Scope: "workbench", Page: 1, PageSize: 2, + }) + if err != nil { + t.Fatalf("查询第一页失败: %v", err) + } + if total != 3 { + t.Fatalf("工作台总数=%d,期望 3(不包含已结束会话)", total) + } + if len(firstPage) != 2 { + t.Fatalf("第一页数量=%d,期望 2", len(firstPage)) + } + if firstPage[0].ID != sessions[0].ID || firstPage[1].ID != sessions[1].ID { + t.Fatalf("排序异常: got [%d, %d], want [%d, %d]", + firstPage[0].ID, firstPage[1].ID, sessions[0].ID, sessions[1].ID) + } + if firstPage[0].CustomerName != "星河科技" || firstPage[0].LastMessage != "紧急报价单" { + t.Fatalf("会话摘要补全异常: %+v", firstPage[0]) + } + if firstPage[0].UnreadCount != 1 || firstPage[0].MessageCount != 1 { + t.Fatalf("未读/消息数异常: unread=%d messages=%d", firstPage[0].UnreadCount, firstPage[0].MessageCount) + } + + secondPage, secondTotal, err := ListSessions(allData, tenant.ID, SessionFilter{ + Scope: "workbench", Page: 2, PageSize: 2, + }) + if err != nil { + t.Fatalf("查询第二页失败: %v", err) + } + if secondTotal != 3 || len(secondPage) != 1 || secondPage[0].ID != sessions[2].ID { + t.Fatalf("第二页异常: total=%d items=%+v", secondTotal, secondPage) + } + + historyItems, historyTotal, err := ListSessions(allData, tenant.ID, SessionFilter{Page: 1, PageSize: 20}) + if err != nil { + t.Fatalf("查询普通会话列表失败: %v", err) + } + if historyTotal != 4 || len(historyItems) != 4 { + t.Fatalf("非工作台查询不应排除已结束会话: total=%d items=%+v", historyTotal, historyItems) + } + + agentItems, agentTotal, err := ListSessions(Actor{UserID: 999}, tenant.ID, SessionFilter{ + Scope: "workbench", Page: 1, PageSize: 20, + }) + if err != nil { + t.Fatalf("查询普通坐席工作台失败: %v", err) + } + if agentTotal != 1 || len(agentItems) != 1 || agentItems[0].ID != sessions[1].ID { + t.Fatalf("普通坐席只能看到等待中或自己的进行中会话: total=%d items=%+v", agentTotal, agentItems) + } +} + +func TestListSessionsWorkbenchFiltersSearchAndCustomer(t *testing.T) { + db := setupSessionListTestDB(t) + tenant := model.Tenant{Name: "筛选测试租户", Status: "normal"} + if err := db.Create(&tenant).Error; err != nil { + t.Fatalf("创建租户失败: %v", err) + } + customers := []model.Customer{ + {TenantID: tenant.ID, Name: "远山贸易"}, + {TenantID: tenant.ID, Name: "青禾设计"}, + } + if err := db.Create(&customers).Error; err != nil { + t.Fatalf("创建客户失败: %v", err) + } + sessions := []model.Session{ + {TenantID: tenant.ID, CustomerID: customers[0].ID, Status: "waiting", Priority: "urgent"}, + {TenantID: tenant.ID, CustomerID: customers[1].ID, Status: "active", Priority: "normal"}, + } + if err := db.Create(&sessions).Error; err != nil { + t.Fatalf("创建会话失败: %v", err) + } + messages := []model.Message{ + {SessionID: sessions[0].ID, SenderType: "visitor", Content: "需要产品报价", Type: "text", Seq: 1, SentAt: time.Now()}, + {SessionID: sessions[0].ID, SenderType: "agent", Content: "稍等", Type: "text", Seq: 2, SentAt: time.Now()}, + {SessionID: sessions[1].ID, SenderType: "visitor", Content: "售后服务", Type: "text", Seq: 1, SentAt: time.Now()}, + } + if err := db.Create(&messages).Error; err != nil { + t.Fatalf("创建消息失败: %v", err) + } + + allData := Actor{SessionAll: true, HistoryAll: true} + cases := []struct { + name string + filter SessionFilter + want uint + }{ + {name: "按客户名搜索", filter: SessionFilter{Scope: "workbench", Search: "远山"}, want: sessions[0].ID}, + {name: "按最后消息搜索", filter: SessionFilter{Scope: "workbench", Search: "稍等"}, want: sessions[0].ID}, + {name: "按状态筛选", filter: SessionFilter{Scope: "workbench", Status: "active"}, want: sessions[1].ID}, + {name: "按优先级筛选", filter: SessionFilter{Scope: "workbench", Priority: "urgent"}, want: sessions[0].ID}, + {name: "按客户筛选", filter: SessionFilter{Scope: "workbench", CustomerID: fmt.Sprint(customers[1].ID)}, want: sessions[1].ID}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + tc.filter.Page = 1 + tc.filter.PageSize = 20 + items, total, err := ListSessions(allData, tenant.ID, tc.filter) + if err != nil { + t.Fatalf("查询失败: %v", err) + } + if total != 1 || len(items) != 1 || items[0].ID != tc.want { + t.Fatalf("筛选结果异常: total=%d items=%+v want=%d", total, items, tc.want) + } + }) + } +} diff --git a/web/src/pages/agent/Dashboard.tsx b/web/src/pages/agent/Dashboard.tsx index 6865fc2..e4d1b59 100644 --- a/web/src/pages/agent/Dashboard.tsx +++ b/web/src/pages/agent/Dashboard.tsx @@ -4,7 +4,7 @@ import { CheckCircleOutlined, FileTextOutlined, FlagOutlined, PaperClipOutlined, SearchOutlined, SendOutlined, SwapOutlined, FilterOutlined, ExportOutlined, BookOutlined, PictureOutlined, ThunderboltOutlined, - StopOutlined, UserOutlined, + StopOutlined, UserOutlined, LeftOutlined, RightOutlined, } from '@ant-design/icons' import EmojiPicker, { insertAtCursor } from '@/components/common/EmojiPicker' import { ChatImage } from '@/components/common/ImagePreview' @@ -12,7 +12,7 @@ import MarkdownBody, { stripMarkdown } from '@/components/common/MarkdownBody' import { useAuth } from '@/stores/auth' import { OPEN_AGENT_SESSION_EVENT, useAgentNotifications } from '@/stores/notifications' import { - addSessionNote, claimSession, getCustomer, getCustomerTags, getCustomers, getSession, getSessionMessages, getSessions, + addSessionNote, claimSession, getCustomer, getCustomerTags, getSession, getSessionMessages, getSessions, getQuickReplies, markSessionRead, sendSessionMessage, suggestQuickReplies, updateCustomer, updateSessionPriority, uploadImage, useQuickReply as recordQuickReplyUsage, type Customer, type CustomerContact, type CustomerTag, type Message, type QuickReply, @@ -26,10 +26,13 @@ import { import { CustomerInfoField, CustomerTagsEditor } from './dashboard/CustomerFields' import { BlacklistModal, EndSessionModal, KnowledgePickerModal, QuickReplyPickerModal, TransferModal } from './dashboard/modals' +const SESSION_PAGE_SIZE = 20 + const Dashboard = () => { const { user } = useAuth() const { setActiveSessionId } = useAgentNotifications() const [sessions, setSessions] = useState([]) + const [selectedSession, setSelectedSession] = useState(null) const [customers, setCustomers] = useState>({}) const [loading, setLoading] = useState(true) const [selectedId, setSelectedId] = useState(null) @@ -37,6 +40,9 @@ const Dashboard = () => { const [detailLoading, setDetailLoading] = useState(false) const [messageInput, setMessageInput] = useState('') const [search, setSearch] = useState('') + const [debouncedSearch, setDebouncedSearch] = useState('') + const [sessionPage, setSessionPage] = useState(1) + const [sessionTotal, setSessionTotal] = useState(0) const [sending, setSending] = useState(false) const [transferOpen, setTransferOpen] = useState(false) const [blacklistOpen, setBlacklistOpen] = useState(false) @@ -79,6 +85,8 @@ const Dashboard = () => { const fileInputRef = useRef(null) const messageInputRef = useRef(null) const socketRef = useRef(null) + const listRequestRef = useRef(0) + const detailRequestRef = useRef(0) const lastTypingAt = useRef(0) const visitorTypingTimer = useRef(null) /** 当前选中会话,供稳定 WS 闭包读取,避免切会话时重连 */ @@ -114,46 +122,67 @@ const Dashboard = () => { const isManager = user?.role === 'admin' || user?.role === 'supervisor' const loadAll = useCallback(async (opts?: { silent?: boolean }) => { + const requestId = ++listRequestRef.current if (!opts?.silent) setLoading(true) try { - const [sessionRes, customerRes] = await Promise.all([ - getSessions({ page: 1, pageSize: 100 }), - getCustomers({ page: 1, pageSize: 100 }), - ]) + const sessionRes = await getSessions({ + scope: 'workbench', + status: statusFilter === 'all' ? undefined : statusFilter, + priority: priorityFilter === 'all' ? undefined : priorityFilter, + search: debouncedSearch || undefined, + page: sessionPage, + pageSize: SESSION_PAGE_SIZE, + }) + if (requestId !== listRequestRef.current) return const sessionList = Array.isArray(sessionRes.list) ? sessionRes.list : [] - const customerList = Array.isArray(customerRes.list) ? customerRes.list : [] + const total = Number(sessionRes.total) || 0 + const maxPage = Math.max(1, Math.ceil(total / SESSION_PAGE_SIZE)) + if (sessionPage > maxPage) { + setSessionPage(maxPage) + return + } setSessions(sessionList) - setCustomers(Object.fromEntries(customerList.map(customer => [customer.id, customer]))) + setSessionTotal(total) // 默认选中上次会话,若没有则默认选中第一条会话 - if (sessionList.length > 0 && initialLoad.current) { - const savedId = localStorage.getItem('kefu_last_session_id') - const matched = savedId ? sessionList.find(s => s.id === Number(savedId)) : null - if (matched) { - setSelectedId(matched.id) - } else { - const fallback = sessionList.find(s => s.status === 'active') + if (initialLoad.current) { + if (sessionList.length > 0) { + const savedId = localStorage.getItem('kefu_last_session_id') + const matched = savedId ? sessionList.find(s => s.id === Number(savedId)) : null + const fallback = matched || sessionList.find(s => s.priority === 'urgent') + || sessionList.find(s => s.status === 'waiting') || sessionList[0] setSelectedId(fallback.id) + setSelectedSession(fallback) } initialLoad.current = false + } else if (selectedIdRef.current != null) { + const refreshed = sessionList.find(s => s.id === selectedIdRef.current) + if (refreshed) { + setSelectedSession(previous => + previous?.id === refreshed.id ? { ...previous, ...refreshed } : refreshed, + ) + } } } catch { + if (requestId !== listRequestRef.current) return if (!opts?.silent) antMsg.error('加载会话失败') if (!opts?.silent) { setSessions([]) - setCustomers({}) + setSessionTotal(0) } } finally { - if (!opts?.silent) setLoading(false) + if (requestId === listRequestRef.current) setLoading(false) } - }, []) + }, [debouncedSearch, priorityFilter, sessionPage, statusFilter]) const loadDetail = useCallback(async (id: number, markRead = true, opts?: { silent?: boolean }) => { + const requestId = ++detailRequestRef.current if (!opts?.silent) setDetailLoading(true) try { const response = await getSession(id) + if (requestId !== detailRequestRef.current || selectedIdRef.current !== id) return const data = response.data const messages = data.messages || [] setDetail({ @@ -169,6 +198,11 @@ const Dashboard = () => { if (typeof data.max_seq === 'number') rememberSeq(id, data.max_seq) // 用详情接口的完整会话字段(含 IP/地区)回填列表,保证顶栏展示准确 if (data.session) { + setSelectedSession(previous => ({ + ...(previous?.id === id ? previous : data.session), + ...data.session, + unread_count: markRead ? 0 : previous?.id === id ? previous.unread_count : 0, + })) setSessions(previous => previous.map(session => session.id === id ? { @@ -186,14 +220,16 @@ const Dashboard = () => { } } else if (markRead) { setSessions(previous => previous.map(session => session.id === id ? { ...session, unread_count: 0 } : session)) + setSelectedSession(previous => previous?.id === id ? { ...previous, unread_count: 0 } : previous) } if (markRead) { await markSessionRead(id) } } catch { + if (requestId !== detailRequestRef.current) return if (!opts?.silent) antMsg.error('加载消息失败') } finally { - if (!opts?.silent) setDetailLoading(false) + if (requestId === detailRequestRef.current && !opts?.silent) setDetailLoading(false) } setTimeout(() => chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }), 80) }, [rememberMessagesSeq, rememberSeq]) @@ -231,6 +267,14 @@ const Dashboard = () => { } if (latest && selectedIdRef.current === id) { const preview = latest.type === 'image' ? '[图片]' : stripMarkdown(latest.content) + setSelectedSession(previous => previous?.id === id + ? { + ...previous, + last_message: preview, + last_message_at: latest!.sent_at, + unread_count: opts?.markRead === false ? previous.unread_count : 0, + } + : previous) setSessions(previous => previous.map(session => session.id === id ? { @@ -288,6 +332,16 @@ const Dashboard = () => { unread_count: viewing ? 0 : (session.unread_count || 0) + (msg.sender_type === 'visitor' ? 1 : 0), } })) + setSelectedSession(previous => previous?.id === sessionId + ? { + ...previous, + last_message: preview, + last_message_at: msg.sent_at, + unread_count: selectedIdRef.current === sessionId + ? 0 + : (previous.unread_count || 0) + (msg.sender_type === 'visitor' ? 1 : 0), + } + : previous) if (msg.seq > 0) rememberSeq(sessionId, msg.seq) if (selectedIdRef.current === sessionId) { setTimeout(() => chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }), 40) @@ -308,7 +362,10 @@ const Dashboard = () => { useEffect(() => { const handleOpenSession = (event: Event) => { const sessionId = Number((event as CustomEvent).detail) - if (Number.isFinite(sessionId) && sessionId > 0) setSelectedId(sessionId) + if (Number.isFinite(sessionId) && sessionId > 0 && selectedIdRef.current !== sessionId) { + setSelectedSession(null) + setSelectedId(sessionId) + } } window.addEventListener(OPEN_AGENT_SESSION_EVENT, handleOpenSession) return () => window.removeEventListener(OPEN_AGENT_SESSION_EVENT, handleOpenSession) @@ -320,6 +377,14 @@ const Dashboard = () => { syncAfterSeqRef.current = syncAfterSeq }, [loadAll, loadDetail, syncAfterSeq]) + useEffect(() => { + const timer = window.setTimeout(() => { + setDebouncedSearch(search.trim()) + setSessionPage(1) + }, 300) + return () => window.clearTimeout(timer) + }, [search]) + useEffect(() => { void loadAll() }, [loadAll]) useEffect(() => { @@ -432,16 +497,16 @@ const Dashboard = () => { last_seen_at?: string | null visitor_left_at?: string | null } - setSessions(previous => previous.map(session => - session.id === sid - ? { - ...session, - visitor_online: Boolean(presence.visitor_online), - last_seen_at: presence.last_seen_at ?? session.last_seen_at, - visitor_left_at: presence.visitor_left_at ?? null, - } - : session, - )) + const patchPresence = (session: Session): Session => session.id === sid + ? { + ...session, + visitor_online: Boolean(presence.visitor_online), + last_seen_at: presence.last_seen_at ?? session.last_seen_at, + visitor_left_at: presence.visitor_left_at ?? null, + } + : session + setSessions(previous => previous.map(patchPresence)) + setSelectedSession(previous => previous ? patchPresence(previous) : previous) return } @@ -462,16 +527,16 @@ const Dashboard = () => { // 最新轨迹插到最上方 return { ...prev, pageViews: [raw, ...prev.pageViews] } }) - setSessions(previous => previous.map(session => - session.id === sid - ? { - ...session, - current_url: raw.url, - current_title: raw.title || session.current_title, - last_seen_at: raw.entered_at || session.last_seen_at, - } - : session, - )) + const patchPageView = (session: Session): Session => session.id === sid + ? { + ...session, + current_url: raw.url, + current_title: raw.title || session.current_title, + last_seen_at: raw.entered_at || session.last_seen_at, + } + : session + setSessions(previous => previous.map(patchPageView)) + setSelectedSession(previous => previous ? patchPageView(previous) : previous) } } } catch { @@ -680,8 +745,23 @@ const Dashboard = () => { }, 220) }, [closeSuggest]) - const selected = sessions.find(session => session.id === selectedId) - const selectedCustomer = selected ? customers[selected.customer_id] : null + const selected = selectedSession + const selectedCustomer: Customer | null = selected + ? customers[selected.customer_id] || { + id: selected.customer_id, + tenant_id: selected.tenant_id, + name: selected.customer_name || `客户${selected.customer_id}`, + phone: '', + email: '', + tags: '', + source: '', + status: 'normal', + conversation_count: 0, + last_contact_at: selected.created_at, + } + : null + const selectedCustomerId = selectedCustomer?.id + const selectedStatus = selected?.status const visitorOnline = selected ? isVisitorOnline(selected) : false const canOperate = Boolean(selected && (isManager || selected.agent_id === user?.user_id) && selected.status === 'active') @@ -693,7 +773,7 @@ const Dashboard = () => { // 进行中会话:轨迹每 8s 静默拉一次(防 WS 漏推) useEffect(() => { - if (!selected || selected.status === 'ended' || selected.status === 'archived') return + if (!selectedId || selectedStatus === 'ended' || selectedStatus === 'archived') return const poll = window.setInterval(() => { if (selectedIdRef.current != null) { void loadDetailRef.current(selectedIdRef.current, false, { silent: true }) @@ -702,28 +782,33 @@ const Dashboard = () => { return () => { clearInterval(poll) } - }, [selected?.id, selected?.status]) + }, [selectedId, selectedStatus]) useEffect(() => { - if (!selectedCustomer) { + if (!selectedCustomerId) { setCustomerHistory([]) return } - getSessions({ page: 1, pageSize: 50 }).then(res => { + let cancelled = false + getSessions({ customer_id: selectedCustomerId, page: 1, pageSize: 6 }).then(res => { + if (cancelled) return const list = Array.isArray(res.list) ? res.list : [] setCustomerHistory( list - .filter(s => s.customer_id === selectedCustomer.id && s.id !== selectedId) + .filter(s => s.customer_id === selectedCustomerId && s.id !== selectedId) .slice(0, 5), ) - }).catch(() => setCustomerHistory([])) - }, [selectedCustomer?.id, selectedId]) + }).catch(() => { + if (!cancelled) setCustomerHistory([]) + }) + return () => { cancelled = true } + }, [selectedCustomerId, selectedId]) // 拉取客户详情中的多条联系方式(输入识别沉淀) useEffect(() => { - if (!selectedCustomer?.id) return + if (!selectedCustomerId) return let cancelled = false - getCustomer(selectedCustomer.id) + getCustomer(selectedCustomerId) .then(res => { if (cancelled) return const data = res.data @@ -739,7 +824,7 @@ const Dashboard = () => { }) .catch(() => { /* 侧栏增强信息失败不影响会话 */ }) return () => { cancelled = true } - }, [selectedCustomer?.id, selectedId]) + }, [selectedCustomerId, selectedId]) const saveCustomerField = useCallback(async (customerId: number, field: CustomerEditableField, value: string) => { setSavingCustomerField(field) @@ -791,27 +876,9 @@ const Dashboard = () => { }, [customers]) const filterActive = statusFilter !== 'all' || priorityFilter !== 'all' - const filteredSessions = sessions - .filter(session => { - if (session.status === 'ended') return false - if (statusFilter !== 'all' && session.status !== statusFilter) return false - if (priorityFilter === 'urgent' && session.priority !== 'urgent') return false - const customer = customers[session.customer_id] - const keyword = search.trim().toLowerCase() - return !keyword - || customer?.name.toLowerCase().includes(keyword) - || String(session.id).includes(keyword) - || (session.last_message || '').toLowerCase().includes(keyword) - }) - .slice() - .sort((a, b) => { - const rank = (s: Session) => (s.priority === 'urgent' ? 0 : s.status === 'waiting' ? 1 : 2) - const r = rank(a) - rank(b) - if (r !== 0) return r - const ta = new Date(a.last_message_at || a.created_at).getTime() - const tb = new Date(b.last_message_at || b.created_at).getTime() - return tb - ta - }) + const totalPages = Math.max(1, Math.ceil(sessionTotal / SESSION_PAGE_SIZE)) + const pageStart = sessionTotal === 0 ? 0 : (sessionPage - 1) * SESSION_PAGE_SIZE + 1 + const pageEnd = Math.min(sessionPage * SESSION_PAGE_SIZE, sessionTotal) const timelineEvents = (detail?.events || []).filter(event => event.action === 'transfer' @@ -1017,16 +1084,16 @@ const Dashboard = () => { setSearch(e.target.value)} /> - {filteredSessions.length} + {sessionTotal} { @@ -1117,18 +1194,23 @@ const Dashboard = () => {
- {filteredSessions.length === 0 ? ( -
暂无待处理会话
- ) : filteredSessions.map(session => { + {sessions.length === 0 ? ( +
+ {debouncedSearch || filterActive ? '没有匹配的会话' : '暂无待处理会话'} +
+ ) : sessions.map(session => { const customer = customers[session.customer_id] - const name = customer?.name || `客户${session.customer_id}` + const name = session.customer_name || customer?.name || `客户${session.customer_id}` const meta = listStatusMeta(session) const selected = selectedId === session.id return (
+ {sessionTotal > 0 && ( +
+ + {pageStart}-{pageEnd} / 共 {sessionTotal} + +
+ + + {sessionPage} / {totalPages} + + +
+
+ )} {/* 主聊天区 */} @@ -1927,20 +2039,20 @@ const Dashboard = () => { setTransferOpen(false)} onDone={clearAndReload} /> setEndingOpen(false)} onDone={clearAndReload} /> setBlacklistOpen(false)} onDone={handleBlacklistDone} /> diff --git a/web/src/services/api.ts b/web/src/services/api.ts index 8405498..ee1b93c 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -259,10 +259,12 @@ export const updateMyStatus = (status: AgentPresence) => // Sessions export const getSessions = (params?: { + scope?: string status?: string priority?: string agent_id?: number | string channel_id?: number | string + customer_id?: number | string search?: string from?: string to?: string @@ -270,10 +272,12 @@ export const getSessions = (params?: { pageSize?: number }) => { const search = new URLSearchParams() + if (params?.scope) search.set('scope', params.scope) if (params?.status) search.set('status', params.status) if (params?.priority) search.set('priority', params.priority) if (params?.agent_id != null && params.agent_id !== '') search.set('agent_id', String(params.agent_id)) if (params?.channel_id != null && params.channel_id !== '') search.set('channel_id', String(params.channel_id)) + if (params?.customer_id != null && params.customer_id !== '') search.set('customer_id', String(params.customer_id)) if (params?.search) search.set('search', params.search) if (params?.from) search.set('from', params.from) if (params?.to) search.set('to', params.to)