feat(session): 优化工作台会话分页与筛选
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user