实现对话记录页并对齐工作台三栏布局

完善会话列表筛选与摘要字段、归档接口;对话记录按效果图重构;工作台顶栏等高对齐;默认管理员账号改为 kefu_admin / kefu_admin123。
This commit is contained in:
yml2213
2026-07-15 13:12:07 +08:00
parent 6bc9247dd6
commit 32fbfb4fe1
8 changed files with 990 additions and 306 deletions
+1 -1
View File
@@ -176,7 +176,7 @@ func (h *AdminHandler) CreateTenant(c *gin.Context) {
}
password := req.AdminPassword
if password == "" {
password = "password123"
password = "kefu_admin123"
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
+2
View File
@@ -54,9 +54,11 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S
sessions.GET("", session.List)
sessions.GET("/:id", session.Get)
sessions.POST("", session.Create)
sessions.POST("/batch-archive", session.BatchArchive)
sessions.POST("/:id/assign", session.Assign)
sessions.POST("/:id/transfer", session.Transfer)
sessions.POST("/:id/end", session.End)
sessions.POST("/:id/archive", session.Archive)
sessions.PUT("/:id/priority", session.UpdatePriority)
sessions.POST("/:id/messages", session.SendMessage)
sessions.POST("/:id/read", session.MarkRead)
+195 -10
View File
@@ -27,6 +27,11 @@ type SessionListItem struct {
UnreadCount int `json:"unread_count"`
LastMessage string `json:"last_message"`
LastMessageAt *time.Time `json:"last_message_at,omitempty"`
MessageCount int `json:"message_count"`
CustomerName string `json:"customer_name"`
AgentName string `json:"agent_name"`
ChannelName string `json:"channel_name"`
ChannelType string `json:"channel_type"`
}
type CreateNoteReq struct {
@@ -169,33 +174,125 @@ func (h *SessionHandler) List(c *gin.Context) {
page, pageSize := middleware.GetPageParams(c)
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")
var sessions []model.Session
var total int64
query := model.DB.Where("tenant_id = ?", tenantID)
query := model.DB.Model(&model.Session{}).Where("sessions.tenant_id = ?", tenantID)
if middleware.GetRole(c) == "agent" {
query = query.Where("agent_id = ? OR status = ?", middleware.GetUserID(c), "waiting")
query = query.Where("sessions.agent_id = ? OR sessions.status = ?", middleware.GetUserID(c), "waiting")
}
if status != "" {
query = query.Where("status = ?", status)
query = query.Where("sessions.status = ?", status)
}
if priority != "" {
query = query.Where("priority = ?", priority)
query = query.Where("sessions.priority = ?", priority)
}
if agentID != "" {
query = query.Where("sessions.agent_id = ?", agentID)
}
if channelID != "" {
query = query.Where("sessions.channel_id = ?", channelID)
}
if from != "" {
if t, err := time.ParseInLocation("2006-01-02", from, time.Local); err == nil {
query = query.Where("sessions.created_at >= ?", t)
}
}
if to != "" {
if t, err := time.ParseInLocation("2006-01-02", to, time.Local); err == nil {
query = query.Where("sessions.created_at < ?", t.Add(24*time.Hour))
}
}
if search != "" {
like := "%" + 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.Model(&model.Session{}).Count(&total)
if err := query.Order("created_at desc").Offset((page - 1) * pageSize).Limit(pageSize).Find(&sessions).Error; err != nil {
query.Count(&total)
if err := query.Order("sessions.created_at desc").Offset((page - 1) * pageSize).Limit(pageSize).Find(&sessions).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询会话失败"})
return
}
// 批量补全客户 / 客服 / 渠道 / 消息数
customerIDs := make([]uint, 0)
agentIDs := make([]uint, 0)
channelIDs := make([]uint, 0)
sessionIDs := make([]uint, 0, len(sessions))
for _, s := range sessions {
sessionIDs = append(sessionIDs, s.ID)
customerIDs = append(customerIDs, s.CustomerID)
channelIDs = append(channelIDs, s.ChannelID)
if s.AgentID != nil {
agentIDs = append(agentIDs, *s.AgentID)
}
}
customerMap := map[uint]model.Customer{}
if len(customerIDs) > 0 {
var customers []model.Customer
model.DB.Where("id IN ?", uniqueUint(customerIDs)).Find(&customers)
for _, cu := range customers {
customerMap[cu.ID] = cu
}
}
agentMap := map[uint]model.User{}
if len(agentIDs) > 0 {
var users []model.User
model.DB.Where("id IN ?", uniqueUint(agentIDs)).Find(&users)
for _, u := range users {
agentMap[u.ID] = u
}
}
channelMap := map[uint]model.Channel{}
if len(channelIDs) > 0 {
var channels []model.Channel
model.DB.Where("id IN ?", uniqueUint(channelIDs)).Find(&channels)
for _, ch := range channels {
channelMap[ch.ID] = ch
}
}
msgCountMap := map[uint]int{}
if len(sessionIDs) > 0 {
type row struct {
SessionID uint
Cnt int
}
var rows []row
model.DB.Model(&model.Message{}).
Select("session_id, COUNT(*) as cnt").
Where("session_id IN ?", sessionIDs).
Group("session_id").
Scan(&rows)
for _, r := range rows {
msgCountMap[r.SessionID] = r.Cnt
}
}
items := make([]SessionListItem, 0, len(sessions))
for _, session := range sessions {
item := SessionListItem{Session: session}
item := SessionListItem{Session: session, MessageCount: msgCountMap[session.ID]}
if middleware.GetRole(c) == "agent" && session.AgentID != nil && *session.AgentID == middleware.GetUserID(c) {
item.UnreadCount = unreadCount(session)
}
if cu, ok := customerMap[session.CustomerID]; ok {
item.CustomerName = cu.Name
}
if session.AgentID != nil {
if u, ok := agentMap[*session.AgentID]; ok {
item.AgentName = u.Nickname
}
}
if ch, ok := channelMap[session.ChannelID]; ok {
item.ChannelName = ch.Name
item.ChannelType = ch.Type
}
var lastMsg model.Message
if err := model.DB.Where("session_id = ?", session.ID).Order("seq desc").First(&lastMsg).Error; err == nil {
if lastMsg.Type == "image" {
@@ -216,6 +313,22 @@ func (h *SessionHandler) List(c *gin.Context) {
middleware.JSONList(c, items, total, page, pageSize)
}
func uniqueUint(ids []uint) []uint {
seen := make(map[uint]struct{}, len(ids))
out := make([]uint, 0, len(ids))
for _, id := range ids {
if id == 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out
}
func (h *SessionHandler) Get(c *gin.Context) {
id := c.Param("id")
@@ -299,10 +412,14 @@ func (h *SessionHandler) ListAvailableAgents(c *gin.Context) {
Nickname string `json:"nickname"`
Status string `json:"status"`
}
// all=1 返回租户全部坐席(含离线),用于对话记录筛选;默认仅在线(工作台转接)
q := model.DB.Where("tenant_id = ? AND role IN ?", middleware.GetTenantID(c), []string{"agent", "supervisor", "admin"})
if c.Query("all") != "1" {
q = q.Where("role = ? AND status = ?", "agent", "online")
}
var users []model.User
if err := model.DB.Where("tenant_id = ? AND role = ? AND status = ?", middleware.GetTenantID(c), "agent", "online").
Order("nickname asc").Find(&users).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询在线客服失败"})
if err := q.Order("nickname asc").Find(&users).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询客服失败"})
return
}
items := make([]agentItem, 0, len(users))
@@ -523,3 +640,71 @@ func (h *SessionHandler) UpdatePriority(c *gin.Context) {
middleware.JSON(c, gin.H{"message": "已更新"})
}
// Archive 将已结束会话归档(主管/管理员)
func (h *SessionHandler) Archive(c *gin.Context) {
if !isTenantManager(c) {
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅主管或管理员可归档"})
return
}
session, ok := loadTenantSession(c, c.Param("id"))
if !ok {
return
}
if session.Status != "ended" {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "仅已结束的会话可归档"})
return
}
result := model.DB.Model(&model.Session{}).
Where("id = ? AND tenant_id = ? AND status = ?", session.ID, session.TenantID, "ended").
Update("status", "archived")
if result.Error != nil || result.RowsAffected == 0 {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "归档失败"})
return
}
model.DB.Create(&model.SessionEvent{
SessionID: session.ID,
OperatorID: middleware.GetUserID(c),
Action: "archive",
Detail: "会话已归档",
})
session.Status = "archived"
broadcastSessionUpdate(session)
middleware.JSON(c, gin.H{"message": "已归档", "session": session})
}
// BatchArchive 批量归档已结束会话
func (h *SessionHandler) BatchArchive(c *gin.Context) {
if !isTenantManager(c) {
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅主管或管理员可归档"})
return
}
var req struct {
IDs []uint `json:"ids" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil || len(req.IDs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "请选择要归档的会话"})
return
}
if len(req.IDs) > 100 {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "单次最多归档 100 条"})
return
}
tenantID := middleware.GetTenantID(c)
result := model.DB.Model(&model.Session{}).
Where("tenant_id = ? AND status = ? AND id IN ?", tenantID, "ended", req.IDs).
Update("status", "archived")
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "批量归档失败"})
return
}
for _, id := range req.IDs {
model.DB.Create(&model.SessionEvent{
SessionID: id,
OperatorID: middleware.GetUserID(c),
Action: "archive",
Detail: "会话已归档",
})
}
middleware.JSON(c, gin.H{"message": "已归档", "count": result.RowsAffected})
}