diff --git a/server/cmd/seed/main.go b/server/cmd/seed/main.go
index 4d7b597..2b87447 100644
--- a/server/cmd/seed/main.go
+++ b/server/cmd/seed/main.go
@@ -19,7 +19,8 @@ func main() {
}
func seed() {
- hash, _ := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.DefaultCost)
+ // 默认后台管理员:kefu_admin / kefu_admin123(其它种子账号同密码)
+ hash, _ := bcrypt.GenerateFromPassword([]byte("kefu_admin123"), bcrypt.DefaultCost)
pwd := string(hash)
// Plans
@@ -48,7 +49,7 @@ func seed() {
// Tenant 1 users
model.DB.Create(&model.User{
- TenantID: tenants[0].ID, Role: "admin", Username: "admin", PasswordHash: pwd, Nickname: "赵总(管理员)", Status: "online",
+ TenantID: tenants[0].ID, Role: "admin", Username: "kefu_admin", PasswordHash: pwd, Nickname: "赵总(管理员)", Status: "online",
})
model.DB.Create(&model.User{
TenantID: tenants[0].ID, Role: "supervisor", Username: "supervisor", PasswordHash: pwd, Nickname: "客服主管", Status: "online",
@@ -146,6 +147,9 @@ func seed() {
{Title: "系统维护通知", Content: "平台将于7月20日 02:00-04:00 进行例行维护", Status: "published"},
{Title: "新功能上线", Content: "知识库批量导入功能已上线", Status: "published"},
})
+
+ log.Println("默认租户管理员: kefu_admin / kefu_admin123")
+ log.Println("其它种子账号密码均为: kefu_admin123")
}
func ptr[T any](v T) *T { return &v }
diff --git a/server/internal/handler/admin.go b/server/internal/handler/admin.go
index abe9aeb..c27ee7c 100644
--- a/server/internal/handler/admin.go
+++ b/server/internal/handler/admin.go
@@ -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 {
diff --git a/server/internal/handler/router.go b/server/internal/handler/router.go
index 18b01c4..d468a61 100644
--- a/server/internal/handler/router.go
+++ b/server/internal/handler/router.go
@@ -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)
diff --git a/server/internal/handler/session.go b/server/internal/handler/session.go
index cdbc9c7..6c2c602 100644
--- a/server/internal/handler/session.go
+++ b/server/internal/handler/session.go
@@ -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})
+}
diff --git a/web/src/pages/Login.tsx b/web/src/pages/Login.tsx
index e4d6538..be809ec 100644
--- a/web/src/pages/Login.tsx
+++ b/web/src/pages/Login.tsx
@@ -35,10 +35,10 @@ const Login = () => {
-
+
-
+