From ada7533ffa4656c14660514a40d7a8807ec890ce Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 19 Jul 2026 00:29:33 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=9D=90=E5=B8=AD=E5=88=87?= =?UTF-8?q?=E6=8D=A2=E5=9C=A8=E7=BA=BF=E7=8A=B6=E6=80=81=EF=BC=8C=E8=BD=AC?= =?UTF-8?q?=E6=8E=A5=E5=88=97=E8=A1=A8=E5=B1=95=E7=A4=BA=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 侧栏可切换在线/忙碌/离线;转接弹窗显示状态点与标签,列表含在线与忙碌坐席。 --- server/internal/handler/auth.go | 71 ++++++++++++++++ server/internal/handler/router.go | 4 + server/internal/handler/session.go | 22 ++++- web/src/components/layout/AgentSidebar.tsx | 70 ++++++++++++++-- web/src/pages/agent/Dashboard.tsx | 88 +++++++++++++++++--- web/src/services/api.ts | 27 +++++- web/src/stores/auth.tsx | 95 ++++++++++++++++++---- 7 files changed, 336 insertions(+), 41 deletions(-) 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 ( - setTransferOpen(false)} onOk={handleTransfer} okButtonProps={{ disabled: !targetAgentID }}> -

请选择一位在线客服接手当前会话。

- { + const st = agentStatusMeta(agent.status) + return { + value: agent.id, + // 选中后输入框展示:昵称 + 状态 + label: `${agent.nickname}(${st.text})`, + disabled: agent.status !== 'online' && agent.status !== 'busy', + } + })} + optionRender={option => { + const agent = availableAgents.find(a => a.id === option.value) + const st = agentStatusMeta(agent?.status || 'offline') + return ( +
+ + + {agent?.nickname || option.label} + + + {st.text} + +
+ ) + }} + /> + )}
setEndingOpen(false)} onOk={handleEnd} okText="确认结束">

请选择本次会话的结束原因。

diff --git a/web/src/services/api.ts b/web/src/services/api.ts index bdb50ba..a587d2f 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -1,7 +1,15 @@ import { get, post, put, del, getList, postForm, downloadFile } from './request' export interface LoginParams { username: string; password: string } -export interface LoginResult { token: string; user_id: number; tenant_id: number; nickname: string; role: string } +export interface LoginResult { + token: string + user_id: number + tenant_id: number + nickname: string + role: string + /** online | busy | offline */ + status?: string +} export interface Session { id: number; tenant_id: number; channel_id: number; customer_id: number; agent_id: number | null @@ -58,7 +66,13 @@ export interface SessionEvent { id: number; session_id: number; operator_id: number; action: string; detail: string; created_at: string } -export interface AvailableAgent { id: number; nickname: string; status: string } +export interface AvailableAgent { + id: number + nickname: string + /** online | busy | offline | disabled */ + status: string + role?: string +} export interface Customer { id: number; tenant_id: number; name: string; phone: string; email: string @@ -182,6 +196,15 @@ export interface StatisticsKpis { // Auth export const login = (params: LoginParams) => post('/login', params) +export type AgentPresence = 'online' | 'busy' | 'offline' + +export const getMe = () => + get<{ user_id: number; tenant_id: number; nickname: string; role: string; status: string; username?: string }>('/me') + +/** 坐席自行切换在线状态 */ +export const updateMyStatus = (status: AgentPresence) => + put<{ user_id: number; status: string }>('/me/status', { status }) + // Sessions export const getSessions = (params?: { status?: string diff --git a/web/src/stores/auth.tsx b/web/src/stores/auth.tsx index 15ad2f1..0d72567 100644 --- a/web/src/stores/auth.tsx +++ b/web/src/stores/auth.tsx @@ -1,53 +1,114 @@ -import { createContext, useContext, useState, useCallback, type ReactNode } from 'react' +import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react' import { setToken } from '@/services/request' -import { login as loginApi, type LoginResult } from '@/services/api' +import { + getMe, + login as loginApi, + updateMyStatus, + type AgentPresence, + type LoginResult, +} from '@/services/api' interface AuthState { - user: LoginResult | null - loading: boolean - login: (username: string, password: string) => Promise + user: LoginResult | null + loading: boolean + login: (username: string, password: string) => Promise logout: () => void + /** 切换本人在线 / 忙碌 / 离线 */ + setPresence: (status: AgentPresence) => Promise } const AuthContext = createContext({ - user: null, loading: false, - login: async () => { throw new Error('认证上下文未初始化') }, logout: () => {}, + user: null, + loading: false, + login: async () => { throw new Error('认证上下文未初始化') }, + logout: () => {}, + setPresence: async () => {}, }) +function persistUser(u: LoginResult | null) { + if (!u) { + localStorage.removeItem('auth_user') + return + } + localStorage.setItem('auth_user', JSON.stringify(u)) +} + export function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(() => { const saved = localStorage.getItem('auth_user') if (saved) { - const u = JSON.parse(saved) as LoginResult - setToken(u.token) - return u + try { + const u = JSON.parse(saved) as LoginResult + setToken(u.token) + return u + } catch { + return null + } } return null }) const [loading, setLoading] = useState(false) + // 刷新后同步服务端真实状态(含 status) + useEffect(() => { + if (!user?.token) return + let cancelled = false + getMe() + .then(res => { + if (cancelled || !res.data) return + setUser(prev => { + if (!prev) return prev + const next = { + ...prev, + nickname: res.data.nickname || prev.nickname, + role: res.data.role || prev.role, + status: res.data.status || prev.status || 'offline', + } + persistUser(next) + return next + }) + }) + .catch(() => { /* 忽略:token 失效会在后续请求里处理 */ }) + return () => { cancelled = true } + // 仅挂载时拉一次 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [user?.token]) + const login = useCallback(async (username: string, password: string) => { setLoading(true) try { const res = await loginApi({ username, password }) - const u = res.data - setToken(u.token) - setUser(u) - localStorage.setItem('auth_user', JSON.stringify(u)) - return u + const u: LoginResult = { ...res.data, status: res.data.status || 'online' } + setToken(u.token) + setUser(u) + persistUser(u) + return u } finally { setLoading(false) } }, []) + const setPresence = useCallback(async (status: AgentPresence) => { + const res = await updateMyStatus(status) + const nextStatus = (res.data?.status || status) as string + setUser(prev => { + if (!prev) return prev + const next = { ...prev, status: nextStatus } + persistUser(next) + return next + }) + }, []) + const logout = useCallback(() => { + // 尽力把状态置为离线,不阻塞退出 + void updateMyStatus('offline').catch(() => {}) setToken('') setUser(null) - localStorage.removeItem('auth_user') + persistUser(null) }, []) return ( - + {children} )