diff --git a/server/internal/handler/auth.go b/server/internal/handler/auth.go index f612f56..cd75491 100644 --- a/server/internal/handler/auth.go +++ b/server/internal/handler/auth.go @@ -2,6 +2,7 @@ package handler import ( "net/http" + "time" "github.com/gin-gonic/gin" "golang.org/x/crypto/bcrypt" @@ -41,6 +42,14 @@ func (h *AuthHandler) Login(c *gin.Context) { return } + // 登录后默认置为在线(可被坐席自行改忙碌/离线) + now := time.Now() + _ = model.DB.Model(&user).Updates(map[string]interface{}{ + "status": "online", + "last_online_at": now, + }).Error + user.Status = "online" + token, err := middleware.GenerateToken(user.ID, user.TenantID, user.Role) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "生成token失败"}) @@ -56,6 +65,68 @@ func (h *AuthHandler) Login(c *gin.Context) { "tenant_id": user.TenantID, "nickname": user.Nickname, "role": user.Role, + "status": user.Status, }, }) } + +// Me 当前登录用户信息。 +func (h *AuthHandler) Me(c *gin.Context) { + var user model.User + if err := model.DB.Select("id", "tenant_id", "nickname", "role", "status", "username"). + First(&user, middleware.GetUserID(c)).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "用户不存在"}) + return + } + middleware.JSON(c, gin.H{ + "user_id": user.ID, + "tenant_id": user.TenantID, + "nickname": user.Nickname, + "role": user.Role, + "status": user.Status, + "username": user.Username, + }) +} + +type UpdatePresenceReq struct { + Status string `json:"status" binding:"required"` +} + +// UpdatePresence 坐席自行切换在线状态(online / busy / offline)。 +func (h *AuthHandler) UpdatePresence(c *gin.Context) { + var req UpdatePresenceReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) + return + } + status := req.Status + if status != "online" && status != "busy" && status != "offline" { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "状态仅支持 online / busy / offline"}) + return + } + + userID := middleware.GetUserID(c) + var user model.User + if err := model.DB.First(&user, userID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "用户不存在"}) + return + } + if user.Status == "disabled" { + c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "账号已停用"}) + return + } + + updates := map[string]interface{}{"status": status} + if status == "online" { + updates["last_online_at"] = time.Now() + } + if err := model.DB.Model(&user).Updates(updates).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "更新状态失败"}) + return + } + user.Status = status + middleware.JSON(c, gin.H{ + "user_id": user.ID, + "status": user.Status, + }) +} diff --git a/server/internal/handler/router.go b/server/internal/handler/router.go index df83da2..94c8c2d 100644 --- a/server/internal/handler/router.go +++ b/server/internal/handler/router.go @@ -50,6 +50,10 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S authRequired := api.Group("") authRequired.Use(middleware.AuthRequired()) { + // 当前用户 + authRequired.GET("/me", auth.Me) + authRequired.PUT("/me/status", auth.UpdatePresence) + // WebSocket authRequired.GET("/ws", wsHandler.Connect) diff --git a/server/internal/handler/session.go b/server/internal/handler/session.go index 12140fe..da4da21 100644 --- a/server/internal/handler/session.go +++ b/server/internal/handler/session.go @@ -2,6 +2,7 @@ package handler import ( "net/http" + "sort" "strconv" "strings" "time" @@ -490,21 +491,34 @@ func (h *SessionHandler) ListAvailableAgents(c *gin.Context) { type agentItem struct { ID uint `json:"id"` Nickname string `json:"nickname"` - Status string `json:"status"` + Status string `json:"status"` // online | busy | offline | disabled + Role string `json:"role,omitempty"` } - // all=1 返回租户全部坐席(含离线),用于对话记录筛选;默认仅在线(工作台转接) + // all=1 返回租户全部坐席(含离线),用于对话记录筛选; + // 默认:可转接坐席(role=agent,在线/忙碌),便于前端展示状态 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") + q = q.Where("role = ? AND status IN ?", "agent", []string{"online", "busy"}) } var users []model.User if err := q.Order("nickname asc").Find(&users).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询客服失败"}) return } + // 在线优先,其次忙碌,再按昵称 + statusRank := map[string]int{"online": 0, "busy": 1, "offline": 2, "disabled": 3} + sort.SliceStable(users, func(i, j int) bool { + ri, rj := statusRank[users[i].Status], statusRank[users[j].Status] + if ri != rj { + return ri < rj + } + return users[i].Nickname < users[j].Nickname + }) items := make([]agentItem, 0, len(users)) for _, user := range users { - items = append(items, agentItem{ID: user.ID, Nickname: user.Nickname, Status: user.Status}) + items = append(items, agentItem{ + ID: user.ID, Nickname: user.Nickname, Status: user.Status, Role: user.Role, + }) } middleware.JSON(c, items) } diff --git a/web/src/components/layout/AgentSidebar.tsx b/web/src/components/layout/AgentSidebar.tsx index 80f9ffb..6648205 100644 --- a/web/src/components/layout/AgentSidebar.tsx +++ b/web/src/components/layout/AgentSidebar.tsx @@ -1,10 +1,13 @@ +import { useState } from 'react' import { useLocation, useNavigate } from 'react-router-dom' import { AppstoreOutlined, MessageOutlined, HistoryOutlined, TeamOutlined, FileTextOutlined, BarChartOutlined, SettingOutlined, LogoutOutlined, - ThunderboltOutlined, + ThunderboltOutlined, DownOutlined, } from '@ant-design/icons' +import { Dropdown, message as antMsg } from 'antd' import { useAuth } from '@/stores/auth' +import type { AgentPresence } from '@/services/api' const menuItems = [ { key: '/agent/dashboard', icon: , label: '工作台' }, @@ -16,10 +19,22 @@ const menuItems = [ { key: '/agent/settings', icon: , label: '系统设置' }, ] +const presenceOptions: { value: AgentPresence; text: string; dot: string }[] = [ + { value: 'online', text: '在线', dot: '#16a34a' }, + { value: 'busy', text: '忙碌', dot: '#d97706' }, + { value: 'offline', text: '离线', dot: '#94a3b8' }, +] + +function presenceMeta(status?: string) { + return presenceOptions.find(p => p.value === status) || presenceOptions[2] +} + const AgentSidebar = () => { const location = useLocation() const navigate = useNavigate() - const { user, logout } = useAuth() + const { user, logout, setPresence } = useAuth() + const [switching, setSwitching] = useState(false) + const visibleMenuItems = menuItems.filter(item => { if (item.key === '/agent/statistics') return user?.role === 'admin' || user?.role === 'supervisor' if (item.key === '/agent/settings') return user?.role === 'admin' @@ -33,7 +48,22 @@ const AgentSidebar = () => { navigate('/login', { replace: true }) } + const handlePresence = async (status: AgentPresence) => { + if (status === (user?.status || 'offline') || switching) return + setSwitching(true) + try { + await setPresence(status) + const label = presenceMeta(status).text + antMsg.success(`已切换为${label}`) + } catch (e) { + antMsg.error(e instanceof Error ? e.message : '切换状态失败') + } finally { + setSwitching(false) + } + } + const initial = (user?.nickname || '客').slice(0, 1) + const current = presenceMeta(user?.status || 'online') return (