feat(session): 优化工作台会话分页与筛选
CI / Backend (go vet + test) (push) Failing after 3m18s
CI / Frontend (typecheck + lint + build) (push) Successful in 10m42s

This commit is contained in:
yml2213
2026-11-06 19:05:41 +08:00
parent 93ef9f2040
commit 723989f039
6 changed files with 524 additions and 135 deletions
+11 -9
View File
@@ -84,15 +84,17 @@ func (h *SessionHandler) SendMessage(c *gin.Context) {
func (h *SessionHandler) List(c *gin.Context) { func (h *SessionHandler) List(c *gin.Context) {
page, pageSize := middleware.GetPageParams(c) page, pageSize := middleware.GetPageParams(c)
f := service.SessionFilter{ f := service.SessionFilter{
Status: c.Query("status"), Scope: c.Query("scope"),
Priority: c.Query("priority"), Status: c.Query("status"),
AgentID: c.Query("agent_id"), Priority: c.Query("priority"),
ChannelID: c.Query("channel_id"), AgentID: c.Query("agent_id"),
Search: strings.TrimSpace(c.Query("search")), ChannelID: c.Query("channel_id"),
From: c.Query("from"), CustomerID: c.Query("customer_id"),
To: c.Query("to"), Search: strings.TrimSpace(c.Query("search")),
Page: page, From: c.Query("from"),
PageSize: pageSize, To: c.Query("to"),
Page: page,
PageSize: pageSize,
} }
items, total, err := service.ListSessions(sessionActor(c), middleware.GetTenantID(c), f) items, total, err := service.ListSessions(sessionActor(c), middleware.GetTenantID(c), f)
if err != nil { if err != nil {
+3 -3
View File
@@ -98,10 +98,10 @@ type BlacklistEntry struct {
type Session struct { type Session struct {
ID uint `gorm:"primaryKey" json:"id"` 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"` ChannelID uint `json:"channel_id"`
CustomerID uint `gorm:"index" json:"customer_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:"-"` VisitorTokenHash string `gorm:"size:64;index" json:"-"`
VisitorIP string `gorm:"size:64" json:"visitor_ip"` VisitorIP string `gorm:"size:64" json:"visitor_ip"`
VisitorRegion string `gorm:"size:100" json:"visitor_region"` VisitorRegion string `gorm:"size:100" json:"visitor_region"`
@@ -128,7 +128,7 @@ type Session struct {
DraftText string `gorm:"type:text" json:"draft_text"` DraftText string `gorm:"type:text" json:"draft_text"`
DraftUpdatedAt *time.Time `json:"draft_updated_at"` DraftUpdatedAt *time.Time `json:"draft_updated_at"`
LastReadSeq int `gorm:"default:0" json:"last_read_seq"` 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"` Priority string `gorm:"size:20;default:normal" json:"priority"`
SatisfactionScore *int `json:"satisfaction_score"` SatisfactionScore *int `json:"satisfaction_score"`
SatisfactionText string `gorm:"size:500" json:"satisfaction_text"` SatisfactionText string `gorm:"size:500" json:"satisfaction_text"`
+128 -36
View File
@@ -41,15 +41,17 @@ type AgentItem struct {
// SessionFilter 会话列表筛选。 // SessionFilter 会话列表筛选。
type SessionFilter struct { type SessionFilter struct {
Status string Scope string
Priority string Status string
AgentID string Priority string
ChannelID string AgentID string
Search string ChannelID string
From string CustomerID string
To string Search string
Page int From string
PageSize int To string
Page int
PageSize int
} }
// LoadTenantSession 加载本租户会话;跨租户访问返回 403。 // LoadTenantSession 加载本租户会话;跨租户访问返回 403。
@@ -120,21 +122,30 @@ func assignableRoleCodes(tenantID uint) []string {
return append(codes, customCodes...) return append(codes, customCodes...)
} }
func unreadCount(session model.Session) int { // ListSessions 会话列表:筛选、分页并批量补全客户/坐席/渠道/消息摘要。
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 会话列表:筛选、分页并批量补全客户/坐席/渠道/消息数。
func ListSessions(actor Actor, tenantID uint, f SessionFilter) ([]SessionListItem, int64, error) { func ListSessions(actor Actor, tenantID uint, f SessionFilter) ([]SessionListItem, int64, error) {
var sessions []model.Session var sessions []model.Session
var total int64 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) 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") query = query.Where("sessions.agent_id = ? OR sessions.status = ?", actor.UserID, "waiting")
} }
if f.Status != "" { if f.Status != "" {
@@ -149,6 +160,9 @@ func ListSessions(actor Actor, tenantID uint, f SessionFilter) ([]SessionListIte
if f.ChannelID != "" { if f.ChannelID != "" {
query = query.Where("sessions.channel_id = ?", 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 f.From != "" {
if t, err := time.ParseInLocation("2006-01-02", f.From, time.Local); err == nil { if t, err := time.ParseInLocation("2006-01-02", f.From, time.Local); err == nil {
query = query.Where("sessions.created_at >= ?", t) query = query.Where("sessions.created_at >= ?", t)
@@ -161,16 +175,52 @@ func ListSessions(actor Actor, tenantID uint, f SessionFilter) ([]SessionListIte
} }
if f.Search != "" { if f.Search != "" {
like := "%" + f.Search + "%" like := "%" + f.Search + "%"
query = query.Joins("LEFT JOIN customers ON customers.id = sessions.customer_id"). query = query.Where(
Where("customers.name LIKE ? OR CAST(sessions.id AS TEXT) LIKE ? OR COALESCE(sessions.end_reason, '') LIKE ?", like, like, like) `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.Count(&total).Error; err != nil {
if err := query.Order("sessions.created_at desc").Offset((f.Page - 1) * f.PageSize).Limit(f.PageSize).Find(&sessions).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("查询会话失败") return nil, 0, Internal("查询会话失败")
} }
// 批量补全客户 / 客服 / 渠道 / 消息数 // 批量补全客户 / 客服 / 渠道 / 消息摘要,避免按会话逐条查询。
customerIDs := make([]uint, 0) customerIDs := make([]uint, 0)
agentIDs := make([]uint, 0) agentIDs := make([]uint, 0)
channelIDs := 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{} customerMap := map[uint]model.Customer{}
if len(customerIDs) > 0 { if len(customerIDs) > 0 {
var customers []model.Customer 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 { for _, cu := range customers {
customerMap[cu.ID] = cu customerMap[cu.ID] = cu
} }
@@ -194,7 +246,9 @@ func ListSessions(actor Actor, tenantID uint, f SessionFilter) ([]SessionListIte
agentMap := map[uint]model.User{} agentMap := map[uint]model.User{}
if len(agentIDs) > 0 { if len(agentIDs) > 0 {
var users []model.User 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 { for _, u := range users {
agentMap[u.ID] = u agentMap[u.ID] = u
} }
@@ -202,35 +256,74 @@ func ListSessions(actor Actor, tenantID uint, f SessionFilter) ([]SessionListIte
channelMap := map[uint]model.Channel{} channelMap := map[uint]model.Channel{}
if len(channelIDs) > 0 { if len(channelIDs) > 0 {
var channels []model.Channel 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 { for _, ch := range channels {
channelMap[ch.ID] = ch channelMap[ch.ID] = ch
} }
} }
msgCountMap := map[uint]int{} msgCountMap := map[uint]int{}
lastMessageMap := map[uint]model.Message{}
unreadMap := map[uint]int{}
if len(sessionIDs) > 0 { if len(sessionIDs) > 0 {
type row struct { type row struct {
SessionID uint SessionID uint
Cnt int Cnt int `gorm:"column:cnt"`
} }
var rows []row var rows []row
model.DB.Model(&model.Message{}). if err := model.DB.Model(&model.Message{}).
Select("session_id, COUNT(*) as cnt"). Select("session_id, COUNT(*) as cnt").
Where("session_id IN ?", sessionIDs). Where("session_id IN ?", sessionIDs).
Group("session_id"). Group("session_id").
Scan(&rows) Scan(&rows).Error; err != nil {
return nil, 0, Internal("统计会话消息失败")
}
for _, r := range rows { for _, r := range rows {
msgCountMap[r.SessionID] = r.Cnt 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)) items := make([]SessionListItem, 0, len(sessions))
for _, session := range sessions { for _, session := range sessions {
item := SessionListItem{Session: session, MessageCount: msgCountMap[session.ID]} item := SessionListItem{
item.VisitorOnline = VisitorIsOnline(&session, time.Now()) Session: session,
if !actor.SessionAll && session.AgentID != nil && *session.AgentID == actor.UserID { MessageCount: msgCountMap[session.ID],
item.UnreadCount = unreadCount(session) UnreadCount: unreadMap[session.ID],
} }
item.VisitorOnline = VisitorIsOnline(&session, time.Now())
if cu, ok := customerMap[session.CustomerID]; ok { if cu, ok := customerMap[session.CustomerID]; ok {
item.CustomerName = cu.Name item.CustomerName = cu.Name
} }
@@ -244,8 +337,7 @@ func ListSessions(actor Actor, tenantID uint, f SessionFilter) ([]SessionListIte
item.ChannelType = ch.Type item.ChannelType = ch.Type
item.ChannelDomain = ch.SiteDomain item.ChannelDomain = ch.SiteDomain
} }
var lastMsg model.Message if lastMsg, ok := lastMessageMap[session.ID]; ok {
if err := model.DB.Where("session_id = ?", session.ID).Order("seq desc").First(&lastMsg).Error; err == nil {
if lastMsg.Type == "image" { if lastMsg.Type == "image" {
item.LastMessage = "[图片]" item.LastMessage = "[图片]"
} else { } else {
@@ -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)
}
})
}
}
+199 -87
View File
@@ -4,7 +4,7 @@ import {
CheckCircleOutlined, FileTextOutlined, FlagOutlined, PaperClipOutlined, CheckCircleOutlined, FileTextOutlined, FlagOutlined, PaperClipOutlined,
SearchOutlined, SendOutlined, SwapOutlined, FilterOutlined, SearchOutlined, SendOutlined, SwapOutlined, FilterOutlined,
ExportOutlined, BookOutlined, PictureOutlined, ThunderboltOutlined, ExportOutlined, BookOutlined, PictureOutlined, ThunderboltOutlined,
StopOutlined, UserOutlined, StopOutlined, UserOutlined, LeftOutlined, RightOutlined,
} from '@ant-design/icons' } from '@ant-design/icons'
import EmojiPicker, { insertAtCursor } from '@/components/common/EmojiPicker' import EmojiPicker, { insertAtCursor } from '@/components/common/EmojiPicker'
import { ChatImage } from '@/components/common/ImagePreview' import { ChatImage } from '@/components/common/ImagePreview'
@@ -12,7 +12,7 @@ import MarkdownBody, { stripMarkdown } from '@/components/common/MarkdownBody'
import { useAuth } from '@/stores/auth' import { useAuth } from '@/stores/auth'
import { OPEN_AGENT_SESSION_EVENT, useAgentNotifications } from '@/stores/notifications' import { OPEN_AGENT_SESSION_EVENT, useAgentNotifications } from '@/stores/notifications'
import { import {
addSessionNote, claimSession, getCustomer, getCustomerTags, getCustomers, getSession, getSessionMessages, getSessions, addSessionNote, claimSession, getCustomer, getCustomerTags, getSession, getSessionMessages, getSessions,
getQuickReplies, markSessionRead, sendSessionMessage, suggestQuickReplies, updateCustomer, updateSessionPriority, uploadImage, getQuickReplies, markSessionRead, sendSessionMessage, suggestQuickReplies, updateCustomer, updateSessionPriority, uploadImage,
useQuickReply as recordQuickReplyUsage, useQuickReply as recordQuickReplyUsage,
type Customer, type CustomerContact, type CustomerTag, type Message, type QuickReply, type Customer, type CustomerContact, type CustomerTag, type Message, type QuickReply,
@@ -26,10 +26,13 @@ import {
import { CustomerInfoField, CustomerTagsEditor } from './dashboard/CustomerFields' import { CustomerInfoField, CustomerTagsEditor } from './dashboard/CustomerFields'
import { BlacklistModal, EndSessionModal, KnowledgePickerModal, QuickReplyPickerModal, TransferModal } from './dashboard/modals' import { BlacklistModal, EndSessionModal, KnowledgePickerModal, QuickReplyPickerModal, TransferModal } from './dashboard/modals'
const SESSION_PAGE_SIZE = 20
const Dashboard = () => { const Dashboard = () => {
const { user } = useAuth() const { user } = useAuth()
const { setActiveSessionId } = useAgentNotifications() const { setActiveSessionId } = useAgentNotifications()
const [sessions, setSessions] = useState<Session[]>([]) const [sessions, setSessions] = useState<Session[]>([])
const [selectedSession, setSelectedSession] = useState<Session | null>(null)
const [customers, setCustomers] = useState<Record<number, Customer>>({}) const [customers, setCustomers] = useState<Record<number, Customer>>({})
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [selectedId, setSelectedId] = useState<number | null>(null) const [selectedId, setSelectedId] = useState<number | null>(null)
@@ -37,6 +40,9 @@ const Dashboard = () => {
const [detailLoading, setDetailLoading] = useState(false) const [detailLoading, setDetailLoading] = useState(false)
const [messageInput, setMessageInput] = useState('') const [messageInput, setMessageInput] = useState('')
const [search, setSearch] = 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 [sending, setSending] = useState(false)
const [transferOpen, setTransferOpen] = useState(false) const [transferOpen, setTransferOpen] = useState(false)
const [blacklistOpen, setBlacklistOpen] = useState(false) const [blacklistOpen, setBlacklistOpen] = useState(false)
@@ -79,6 +85,8 @@ const Dashboard = () => {
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const messageInputRef = useRef<HTMLTextAreaElement>(null) const messageInputRef = useRef<HTMLTextAreaElement>(null)
const socketRef = useRef<WebSocket | null>(null) const socketRef = useRef<WebSocket | null>(null)
const listRequestRef = useRef(0)
const detailRequestRef = useRef(0)
const lastTypingAt = useRef(0) const lastTypingAt = useRef(0)
const visitorTypingTimer = useRef<number | null>(null) const visitorTypingTimer = useRef<number | null>(null)
/** 当前选中会话,供稳定 WS 闭包读取,避免切会话时重连 */ /** 当前选中会话,供稳定 WS 闭包读取,避免切会话时重连 */
@@ -114,46 +122,67 @@ const Dashboard = () => {
const isManager = user?.role === 'admin' || user?.role === 'supervisor' const isManager = user?.role === 'admin' || user?.role === 'supervisor'
const loadAll = useCallback(async (opts?: { silent?: boolean }) => { const loadAll = useCallback(async (opts?: { silent?: boolean }) => {
const requestId = ++listRequestRef.current
if (!opts?.silent) setLoading(true) if (!opts?.silent) setLoading(true)
try { try {
const [sessionRes, customerRes] = await Promise.all([ const sessionRes = await getSessions({
getSessions({ page: 1, pageSize: 100 }), scope: 'workbench',
getCustomers({ page: 1, pageSize: 100 }), 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 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) setSessions(sessionList)
setCustomers(Object.fromEntries(customerList.map(customer => [customer.id, customer]))) setSessionTotal(total)
// 默认选中上次会话,若没有则默认选中第一条会话 // 默认选中上次会话,若没有则默认选中第一条会话
if (sessionList.length > 0 && initialLoad.current) { if (initialLoad.current) {
const savedId = localStorage.getItem('kefu_last_session_id') if (sessionList.length > 0) {
const matched = savedId ? sessionList.find(s => s.id === Number(savedId)) : null const savedId = localStorage.getItem('kefu_last_session_id')
if (matched) { const matched = savedId ? sessionList.find(s => s.id === Number(savedId)) : null
setSelectedId(matched.id) const fallback = matched
} else {
const fallback = sessionList.find(s => s.status === 'active')
|| sessionList.find(s => s.priority === 'urgent') || sessionList.find(s => s.priority === 'urgent')
|| sessionList.find(s => s.status === 'waiting')
|| sessionList[0] || sessionList[0]
setSelectedId(fallback.id) setSelectedId(fallback.id)
setSelectedSession(fallback)
} }
initialLoad.current = false 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 { } catch {
if (requestId !== listRequestRef.current) return
if (!opts?.silent) antMsg.error('加载会话失败') if (!opts?.silent) antMsg.error('加载会话失败')
if (!opts?.silent) { if (!opts?.silent) {
setSessions([]) setSessions([])
setCustomers({}) setSessionTotal(0)
} }
} finally { } 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 loadDetail = useCallback(async (id: number, markRead = true, opts?: { silent?: boolean }) => {
const requestId = ++detailRequestRef.current
if (!opts?.silent) setDetailLoading(true) if (!opts?.silent) setDetailLoading(true)
try { try {
const response = await getSession(id) const response = await getSession(id)
if (requestId !== detailRequestRef.current || selectedIdRef.current !== id) return
const data = response.data const data = response.data
const messages = data.messages || [] const messages = data.messages || []
setDetail({ setDetail({
@@ -169,6 +198,11 @@ const Dashboard = () => {
if (typeof data.max_seq === 'number') rememberSeq(id, data.max_seq) if (typeof data.max_seq === 'number') rememberSeq(id, data.max_seq)
// 用详情接口的完整会话字段(含 IP/地区)回填列表,保证顶栏展示准确 // 用详情接口的完整会话字段(含 IP/地区)回填列表,保证顶栏展示准确
if (data.session) { 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 => setSessions(previous => previous.map(session =>
session.id === id session.id === id
? { ? {
@@ -186,14 +220,16 @@ const Dashboard = () => {
} }
} else if (markRead) { } else if (markRead) {
setSessions(previous => previous.map(session => session.id === id ? { ...session, unread_count: 0 } : session)) 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) { if (markRead) {
await markSessionRead(id) await markSessionRead(id)
} }
} catch { } catch {
if (requestId !== detailRequestRef.current) return
if (!opts?.silent) antMsg.error('加载消息失败') if (!opts?.silent) antMsg.error('加载消息失败')
} finally { } finally {
if (!opts?.silent) setDetailLoading(false) if (requestId === detailRequestRef.current && !opts?.silent) setDetailLoading(false)
} }
setTimeout(() => chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }), 80) setTimeout(() => chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }), 80)
}, [rememberMessagesSeq, rememberSeq]) }, [rememberMessagesSeq, rememberSeq])
@@ -231,6 +267,14 @@ const Dashboard = () => {
} }
if (latest && selectedIdRef.current === id) { if (latest && selectedIdRef.current === id) {
const preview = latest.type === 'image' ? '[图片]' : stripMarkdown(latest.content) 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 => setSessions(previous => previous.map(session =>
session.id === id session.id === id
? { ? {
@@ -288,6 +332,16 @@ const Dashboard = () => {
unread_count: viewing ? 0 : (session.unread_count || 0) + (msg.sender_type === 'visitor' ? 1 : 0), 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 (msg.seq > 0) rememberSeq(sessionId, msg.seq)
if (selectedIdRef.current === sessionId) { if (selectedIdRef.current === sessionId) {
setTimeout(() => chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }), 40) setTimeout(() => chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }), 40)
@@ -308,7 +362,10 @@ const Dashboard = () => {
useEffect(() => { useEffect(() => {
const handleOpenSession = (event: Event) => { const handleOpenSession = (event: Event) => {
const sessionId = Number((event as CustomEvent<number>).detail) const sessionId = Number((event as CustomEvent<number>).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) window.addEventListener(OPEN_AGENT_SESSION_EVENT, handleOpenSession)
return () => window.removeEventListener(OPEN_AGENT_SESSION_EVENT, handleOpenSession) return () => window.removeEventListener(OPEN_AGENT_SESSION_EVENT, handleOpenSession)
@@ -320,6 +377,14 @@ const Dashboard = () => {
syncAfterSeqRef.current = syncAfterSeq syncAfterSeqRef.current = syncAfterSeq
}, [loadAll, loadDetail, 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(() => { void loadAll() }, [loadAll])
useEffect(() => { useEffect(() => {
@@ -432,16 +497,16 @@ const Dashboard = () => {
last_seen_at?: string | null last_seen_at?: string | null
visitor_left_at?: string | null visitor_left_at?: string | null
} }
setSessions(previous => previous.map(session => const patchPresence = (session: Session): Session => session.id === sid
session.id === sid ? {
? { ...session,
...session, visitor_online: Boolean(presence.visitor_online),
visitor_online: Boolean(presence.visitor_online), last_seen_at: presence.last_seen_at ?? session.last_seen_at,
last_seen_at: presence.last_seen_at ?? session.last_seen_at, visitor_left_at: presence.visitor_left_at ?? null,
visitor_left_at: presence.visitor_left_at ?? null, }
} : session
: session, setSessions(previous => previous.map(patchPresence))
)) setSelectedSession(previous => previous ? patchPresence(previous) : previous)
return return
} }
@@ -462,16 +527,16 @@ const Dashboard = () => {
// 最新轨迹插到最上方 // 最新轨迹插到最上方
return { ...prev, pageViews: [raw, ...prev.pageViews] } return { ...prev, pageViews: [raw, ...prev.pageViews] }
}) })
setSessions(previous => previous.map(session => const patchPageView = (session: Session): Session => session.id === sid
session.id === sid ? {
? { ...session,
...session, current_url: raw.url,
current_url: raw.url, current_title: raw.title || session.current_title,
current_title: raw.title || session.current_title, last_seen_at: raw.entered_at || session.last_seen_at,
last_seen_at: raw.entered_at || session.last_seen_at, }
} : session
: session, setSessions(previous => previous.map(patchPageView))
)) setSelectedSession(previous => previous ? patchPageView(previous) : previous)
} }
} }
} catch { } catch {
@@ -680,8 +745,23 @@ const Dashboard = () => {
}, 220) }, 220)
}, [closeSuggest]) }, [closeSuggest])
const selected = sessions.find(session => session.id === selectedId) const selected = selectedSession
const selectedCustomer = selected ? customers[selected.customer_id] : null 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 visitorOnline = selected ? isVisitorOnline(selected) : false
const canOperate = Boolean(selected && (isManager || selected.agent_id === user?.user_id) && selected.status === 'active') const canOperate = Boolean(selected && (isManager || selected.agent_id === user?.user_id) && selected.status === 'active')
@@ -693,7 +773,7 @@ const Dashboard = () => {
// 进行中会话:轨迹每 8s 静默拉一次(防 WS 漏推) // 进行中会话:轨迹每 8s 静默拉一次(防 WS 漏推)
useEffect(() => { useEffect(() => {
if (!selected || selected.status === 'ended' || selected.status === 'archived') return if (!selectedId || selectedStatus === 'ended' || selectedStatus === 'archived') return
const poll = window.setInterval(() => { const poll = window.setInterval(() => {
if (selectedIdRef.current != null) { if (selectedIdRef.current != null) {
void loadDetailRef.current(selectedIdRef.current, false, { silent: true }) void loadDetailRef.current(selectedIdRef.current, false, { silent: true })
@@ -702,28 +782,33 @@ const Dashboard = () => {
return () => { return () => {
clearInterval(poll) clearInterval(poll)
} }
}, [selected?.id, selected?.status]) }, [selectedId, selectedStatus])
useEffect(() => { useEffect(() => {
if (!selectedCustomer) { if (!selectedCustomerId) {
setCustomerHistory([]) setCustomerHistory([])
return 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 : [] const list = Array.isArray(res.list) ? res.list : []
setCustomerHistory( setCustomerHistory(
list list
.filter(s => s.customer_id === selectedCustomer.id && s.id !== selectedId) .filter(s => s.customer_id === selectedCustomerId && s.id !== selectedId)
.slice(0, 5), .slice(0, 5),
) )
}).catch(() => setCustomerHistory([])) }).catch(() => {
}, [selectedCustomer?.id, selectedId]) if (!cancelled) setCustomerHistory([])
})
return () => { cancelled = true }
}, [selectedCustomerId, selectedId])
// 拉取客户详情中的多条联系方式(输入识别沉淀) // 拉取客户详情中的多条联系方式(输入识别沉淀)
useEffect(() => { useEffect(() => {
if (!selectedCustomer?.id) return if (!selectedCustomerId) return
let cancelled = false let cancelled = false
getCustomer(selectedCustomer.id) getCustomer(selectedCustomerId)
.then(res => { .then(res => {
if (cancelled) return if (cancelled) return
const data = res.data const data = res.data
@@ -739,7 +824,7 @@ const Dashboard = () => {
}) })
.catch(() => { /* 侧栏增强信息失败不影响会话 */ }) .catch(() => { /* 侧栏增强信息失败不影响会话 */ })
return () => { cancelled = true } return () => { cancelled = true }
}, [selectedCustomer?.id, selectedId]) }, [selectedCustomerId, selectedId])
const saveCustomerField = useCallback(async (customerId: number, field: CustomerEditableField, value: string) => { const saveCustomerField = useCallback(async (customerId: number, field: CustomerEditableField, value: string) => {
setSavingCustomerField(field) setSavingCustomerField(field)
@@ -791,27 +876,9 @@ const Dashboard = () => {
}, [customers]) }, [customers])
const filterActive = statusFilter !== 'all' || priorityFilter !== 'all' const filterActive = statusFilter !== 'all' || priorityFilter !== 'all'
const filteredSessions = sessions const totalPages = Math.max(1, Math.ceil(sessionTotal / SESSION_PAGE_SIZE))
.filter(session => { const pageStart = sessionTotal === 0 ? 0 : (sessionPage - 1) * SESSION_PAGE_SIZE + 1
if (session.status === 'ended') return false const pageEnd = Math.min(sessionPage * SESSION_PAGE_SIZE, sessionTotal)
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 timelineEvents = (detail?.events || []).filter(event => const timelineEvents = (detail?.events || []).filter(event =>
event.action === 'transfer' event.action === 'transfer'
@@ -1017,16 +1084,16 @@ const Dashboard = () => {
<SearchOutlined className="text-slate-400 text-xs mr-2 shrink-0" /> <SearchOutlined className="text-slate-400 text-xs mr-2 shrink-0" />
<input <input
className="bg-transparent border-none outline-none flex-1 min-w-0 text-xs text-slate-800 placeholder:text-slate-400" className="bg-transparent border-none outline-none flex-1 min-w-0 text-xs text-slate-800 placeholder:text-slate-400"
placeholder="搜索访客或会话..." placeholder="搜索客户、最后消息或会话 ID..."
value={search} value={search}
onChange={e => setSearch(e.target.value)} onChange={e => setSearch(e.target.value)}
/> />
</div> </div>
<span <span
className="inline-flex items-center h-7 px-2 rounded-lg text-xs font-semibold bg-blue-50 text-blue-600 border border-blue-100/80 whitespace-nowrap shrink-0" className="inline-flex items-center h-7 px-2 rounded-lg text-xs font-semibold bg-blue-50 text-blue-600 border border-blue-100/80 whitespace-nowrap shrink-0"
title={`当前会话 ${filteredSessions.length} 个`} title={`共 ${sessionTotal} 个${debouncedSearch || filterActive ? '匹配' : '未结束'}会话`}
> >
{filteredSessions.length} {sessionTotal}
</span> </span>
<Popover <Popover
open={filterOpen} open={filterOpen}
@@ -1046,7 +1113,10 @@ const Dashboard = () => {
<button <button
key={item.key} key={item.key}
type="button" type="button"
onClick={() => setStatusFilter(item.key)} onClick={() => {
setStatusFilter(item.key)
setSessionPage(1)
}}
className={`px-2.5 py-1 rounded-lg text-xs border transition-all cursor-pointer ${ className={`px-2.5 py-1 rounded-lg text-xs border transition-all cursor-pointer ${
statusFilter === item.key statusFilter === item.key
? 'bg-blue-50 border-blue-400 text-blue-600 font-medium' ? 'bg-blue-50 border-blue-400 text-blue-600 font-medium'
@@ -1068,7 +1138,10 @@ const Dashboard = () => {
<button <button
key={item.key} key={item.key}
type="button" type="button"
onClick={() => setPriorityFilter(item.key)} onClick={() => {
setPriorityFilter(item.key)
setSessionPage(1)
}}
className={`px-2.5 py-1 rounded-lg text-xs border transition-all cursor-pointer ${ className={`px-2.5 py-1 rounded-lg text-xs border transition-all cursor-pointer ${
priorityFilter === item.key priorityFilter === item.key
? 'bg-blue-50 border-blue-400 text-blue-600 font-medium' ? 'bg-blue-50 border-blue-400 text-blue-600 font-medium'
@@ -1084,7 +1157,11 @@ const Dashboard = () => {
<button <button
type="button" type="button"
className="text-xs text-slate-500 hover:text-blue-600 font-medium cursor-pointer border-0 bg-transparent p-0" className="text-xs text-slate-500 hover:text-blue-600 font-medium cursor-pointer border-0 bg-transparent p-0"
onClick={() => { setStatusFilter('all'); setPriorityFilter('all') }} onClick={() => {
setStatusFilter('all')
setPriorityFilter('all')
setSessionPage(1)
}}
> >
清除筛选 清除筛选
</button> </button>
@@ -1117,18 +1194,23 @@ const Dashboard = () => {
</div> </div>
<div className="flex-1 overflow-y-auto p-1.5 space-y-1"> <div className="flex-1 overflow-y-auto p-1.5 space-y-1">
{filteredSessions.length === 0 ? ( {sessions.length === 0 ? (
<div className="text-center text-xs text-slate-400 py-16">暂无待处理会话</div> <div className="text-center text-xs text-slate-400 py-16">
) : filteredSessions.map(session => { {debouncedSearch || filterActive ? '没有匹配的会话' : '暂无待处理会话'}
</div>
) : sessions.map(session => {
const customer = customers[session.customer_id] 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 meta = listStatusMeta(session)
const selected = selectedId === session.id const selected = selectedId === session.id
return ( return (
<button <button
key={session.id} key={session.id}
type="button" type="button"
onClick={() => setSelectedId(session.id)} onClick={() => {
setSelectedSession(session)
setSelectedId(session.id)
}}
className={`w-full flex items-stretch text-left rounded-xl transition-all duration-150 relative border cursor-pointer ${ className={`w-full flex items-stretch text-left rounded-xl transition-all duration-150 relative border cursor-pointer ${
selected selected
? 'bg-blue-50/80 border-blue-200/80 shadow-xs' ? 'bg-blue-50/80 border-blue-200/80 shadow-xs'
@@ -1191,6 +1273,36 @@ const Dashboard = () => {
) )
})} })}
</div> </div>
{sessionTotal > 0 && (
<div className="shrink-0 h-10 px-3 border-t border-slate-100 flex items-center justify-between text-[11px] text-slate-400">
<span className="tabular-nums">
{pageStart}-{pageEnd} / 共 {sessionTotal}
</span>
<div className="flex items-center gap-1.5">
<button
type="button"
title="上一页"
disabled={sessionPage <= 1}
onClick={() => setSessionPage(page => Math.max(1, page - 1))}
className="w-7 h-7 rounded-lg border border-slate-200 bg-white text-slate-500 flex items-center justify-center cursor-pointer hover:bg-slate-50 hover:text-slate-800 disabled:opacity-35 disabled:cursor-not-allowed"
>
<LeftOutlined className="text-[10px]" />
</button>
<span className="min-w-10 text-center tabular-nums text-slate-500 font-medium">
{sessionPage} / {totalPages}
</span>
<button
type="button"
title="下一页"
disabled={sessionPage >= totalPages}
onClick={() => setSessionPage(page => Math.min(totalPages, page + 1))}
className="w-7 h-7 rounded-lg border border-slate-200 bg-white text-slate-500 flex items-center justify-center cursor-pointer hover:bg-slate-50 hover:text-slate-800 disabled:opacity-35 disabled:cursor-not-allowed"
>
<RightOutlined className="text-[10px]" />
</button>
</div>
</div>
)}
</section> </section>
{/* 主聊天区 */} {/* 主聊天区 */}
@@ -1927,20 +2039,20 @@ const Dashboard = () => {
<TransferModal <TransferModal
open={transferOpen} open={transferOpen}
session={selected} session={selected || undefined}
excludeAgentId={user?.user_id} excludeAgentId={user?.user_id}
onClose={() => setTransferOpen(false)} onClose={() => setTransferOpen(false)}
onDone={clearAndReload} onDone={clearAndReload}
/> />
<EndSessionModal <EndSessionModal
open={endingOpen} open={endingOpen}
session={selected} session={selected || undefined}
onClose={() => setEndingOpen(false)} onClose={() => setEndingOpen(false)}
onDone={clearAndReload} onDone={clearAndReload}
/> />
<BlacklistModal <BlacklistModal
open={blacklistOpen} open={blacklistOpen}
session={selected} session={selected || undefined}
onClose={() => setBlacklistOpen(false)} onClose={() => setBlacklistOpen(false)}
onDone={handleBlacklistDone} onDone={handleBlacklistDone}
/> />
+4
View File
@@ -259,10 +259,12 @@ export const updateMyStatus = (status: AgentPresence) =>
// Sessions // Sessions
export const getSessions = (params?: { export const getSessions = (params?: {
scope?: string
status?: string status?: string
priority?: string priority?: string
agent_id?: number | string agent_id?: number | string
channel_id?: number | string channel_id?: number | string
customer_id?: number | string
search?: string search?: string
from?: string from?: string
to?: string to?: string
@@ -270,10 +272,12 @@ export const getSessions = (params?: {
pageSize?: number pageSize?: number
}) => { }) => {
const search = new URLSearchParams() const search = new URLSearchParams()
if (params?.scope) search.set('scope', params.scope)
if (params?.status) search.set('status', params.status) if (params?.status) search.set('status', params.status)
if (params?.priority) search.set('priority', params.priority) 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?.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?.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?.search) search.set('search', params.search)
if (params?.from) search.set('from', params.from) if (params?.from) search.set('from', params.from)
if (params?.to) search.set('to', params.to) if (params?.to) search.set('to', params.to)