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