From 32fbfb4fe1c5e2c4c7141f6d6eb072bc2190dd53 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Wed, 15 Jul 2026 13:12:07 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E5=AF=B9=E8=AF=9D=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E9=A1=B5=E5=B9=B6=E5=AF=B9=E9=BD=90=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E5=8F=B0=E4=B8=89=E6=A0=8F=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 完善会话列表筛选与摘要字段、归档接口;对话记录按效果图重构;工作台顶栏等高对齐;默认管理员账号改为 kefu_admin / kefu_admin123。 --- server/cmd/seed/main.go | 8 +- server/internal/handler/admin.go | 2 +- server/internal/handler/router.go | 2 + server/internal/handler/session.go | 205 ++++++- web/src/pages/Login.tsx | 4 +- web/src/pages/agent/ChatHistory.tsx | 826 ++++++++++++++++++++++------ web/src/pages/agent/Dashboard.tsx | 219 ++++---- web/src/services/api.ts | 30 +- 8 files changed, 990 insertions(+), 306 deletions(-) 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 = () => {
- + - + + + + + + {/* 筛选栏 — 全宽,左右分栏顶边对齐 */} +
+
+
+ + setSearchInput(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') handleSearch() }} + className="flex-1 min-w-0 bg-transparent border-0 outline-none text-sm text-neutral-800 placeholder:text-neutral-400" />
+ { setDateRange(v); setPage(1) }} + className="!h-8" + allowClear + /> +
-
- {loading ? ( -
- ) : filtered.map(s => { - const name = customers[s.customer_id]?.name || `客户${s.customer_id}` - return ( - - ) - })} - {!loading && filtered.length === 0 && ( -
- )} +
+
+ 渠道: + { setAgentFilter(v); setPage(1) }} + className="!w-[120px]" + size="small" + options={agents.map(a => ({ value: a.id, label: a.nickname }))} + /> +
+
+ 状态: + setSearch(e.target.value)} - /> -
- -
-
会话状态
-
- {([ - { key: 'all', label: '全部' }, - { key: 'waiting', label: '等待中' }, - { key: 'active', label: '进行中' }, - ] as const).map(item => ( - - ))} -
+
+
+ + setSearch(e.target.value)} + /> +
+ + {filteredSessions.length} + + +
+
会话状态
+
+ {([ + { key: 'all', label: '全部' }, + { key: 'waiting', label: '等待中' }, + { key: 'active', label: '进行中' }, + ] as const).map(item => ( + + ))}
-
-
优先级
-
- {([ - { key: 'all', label: '全部' }, - { key: 'urgent', label: '仅紧急' }, - ] as const).map(item => ( - - ))} -
-
- {filterActive && ( - - )}
- )} +
+
优先级
+
+ {([ + { key: 'all', label: '全部' }, + { key: 'urgent', label: '仅紧急' }, + ] as const).map(item => ( + + ))} +
+
+ {filterActive && ( + + )} +
+ )} + > + - -
-
- - 当前会话 {filteredSessions.length} 个 - - - - 预览访客窗口 - -
+ + {filterActive && } + +
+ + +
@@ -631,10 +633,13 @@ const Dashboard = () => {
{selected && selectedCustomer ? ( <> -
+
{ > {selectedCustomer.name.slice(0, 1)}
-
+
- {selectedCustomer.name} + {selectedCustomer.name} {channelLabel(selectedCustomer.source)} @@ -653,8 +658,8 @@ const Dashboard = () => { {selectedCustomer.status === 'online' ? '在线' : selectedCustomer.status === 'busy' ? '忙碌' : '离线'}
- {/* 与设计稿一致:姓名下方展示 IP | 地区 */} -
+ {/* IP | 地区 — 压缩行高,保证三栏顶栏等高 */} +
IP: {selected.visitor_ip || '—'} diff --git a/web/src/services/api.ts b/web/src/services/api.ts index d7fa662..3be0404 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -8,10 +8,17 @@ export interface Session { status: string; priority: string; unread_count: number; satisfaction_score: number | null last_message?: string; last_message_at?: string | null satisfaction_text?: string + end_reason?: string visitor_ip?: string visitor_region?: string user_agent?: string created_at: string; ended_at: string | null + /** 列表接口补全字段 */ + message_count?: number + customer_name?: string + agent_name?: string + channel_name?: string + channel_type?: string } export interface Message { @@ -88,10 +95,25 @@ export interface StatisticsKpis { export const login = (params: LoginParams) => post('/login', params) // Sessions -export const getSessions = (params?: { status?: string; priority?: string; page?: number; pageSize?: number }) => { +export const getSessions = (params?: { + status?: string + priority?: string + agent_id?: number | string + channel_id?: number | string + search?: string + from?: string + to?: string + page?: number + pageSize?: number +}) => { const search = new URLSearchParams() 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?.search) search.set('search', params.search) + if (params?.from) search.set('from', params.from) + if (params?.to) search.set('to', params.to) if (params?.page) search.set('page', String(params.page)) if (params?.pageSize) search.set('pageSize', String(params.pageSize)) return getList(`/sessions?${search}`) @@ -101,11 +123,15 @@ export const assignSession = (id: number, agentId: number) => post(`/sessions/${ export const claimSession = (id: number) => post(`/sessions/${id}/assign`, {}) export const transferSession = (id: number, agentId: number) => post(`/sessions/${id}/transfer`, { agent_id: agentId }) export const endSession = (id: number, reason: string) => post(`/sessions/${id}/end?reason=${reason}`, {}) +export const archiveSession = (id: number) => post<{ message: string; session: Session }>(`/sessions/${id}/archive`, {}) +export const batchArchiveSessions = (ids: number[]) => post<{ message: string; count: number }>('/sessions/batch-archive', { ids }) export const updateSessionPriority = (id: number, priority: 'normal' | 'urgent') => put(`/sessions/${id}/priority?priority=${priority}`, {}) export const markSessionRead = (id: number) => post(`/sessions/${id}/read`, {}) export const addSessionNote = (id: number, content: string) => post(`/sessions/${id}/notes`, { content }) export const sendSessionMessage = (id: number, content: string, type: 'text' | 'image' = 'text') => post(`/sessions/${id}/messages`, { content, type }) -export const getAvailableAgents = () => get('/agents/available') +/** onlineOnly 默认 true;传 false 返回全部坐席(对话记录筛选) */ +export const getAvailableAgents = (onlineOnly = true) => + get(`/agents/available${onlineOnly ? '' : '?all=1'}`) export interface UploadImageResult { url: string