支持坐席切换在线状态,转接列表展示状态

侧栏可切换在线/忙碌/离线;转接弹窗显示状态点与标签,列表含在线与忙碌坐席。
This commit is contained in:
yml2213
2026-07-19 00:29:33 +08:00
parent cbc76f3258
commit ada7533ffa
7 changed files with 336 additions and 41 deletions
+71
View File
@@ -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,
})
}
+4
View File
@@ -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)
+18 -4
View File
@@ -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)
}
+62 -8
View File
@@ -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: <AppstoreOutlined />, label: '工作台' },
@@ -16,10 +19,22 @@ const menuItems = [
{ key: '/agent/settings', icon: <SettingOutlined />, 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 (
<aside
@@ -74,21 +104,45 @@ const AgentSidebar = () => {
</ul>
</nav>
<div className="flex items-center gap-2.5 px-4 shrink-0 border-t border-neutral-200" style={{ height: 52 }}>
<div className="flex items-center gap-2 px-3 shrink-0 border-t border-neutral-200" style={{ height: 56 }}>
<div className="w-8 h-8 rounded-full bg-[#dbeafe] text-[#2563eb] flex items-center justify-center text-sm font-semibold shrink-0">
{initial}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-neutral-800 truncate">{user?.nickname || '客服'}</div>
<div className="text-xs text-neutral-400 flex items-center gap-1">
<span className="inline-block w-1.5 h-1.5 rounded-full bg-green-500" />
线
</div>
<Dropdown
trigger={['click']}
disabled={switching}
menu={{
selectedKeys: [user?.status || 'online'],
onClick: ({ key }) => { void handlePresence(key as AgentPresence) },
items: presenceOptions.map(p => ({
key: p.value,
label: (
<span className="inline-flex items-center gap-2 text-[13px]">
<span className="inline-block w-1.5 h-1.5 rounded-full" style={{ background: p.dot }} />
{p.text}
</span>
),
})),
}}
>
<button
type="button"
disabled={switching}
title="切换在线状态"
className="mt-0.5 inline-flex items-center gap-1 text-xs text-neutral-500 hover:text-neutral-700 max-w-full"
>
<span className="inline-block w-1.5 h-1.5 rounded-full shrink-0" style={{ background: current.dot }} />
<span className="truncate">{current.text}</span>
<DownOutlined className="text-[9px] opacity-60" />
</button>
</Dropdown>
</div>
<button
type="button"
onClick={handleLogout}
className="w-7 h-7 rounded-md text-neutral-400 hover:text-neutral-600 hover:bg-neutral-50 flex items-center justify-center"
className="w-7 h-7 rounded-md text-neutral-400 hover:text-neutral-600 hover:bg-neutral-50 flex items-center justify-center shrink-0"
title="退出登录"
>
<LogoutOutlined className="text-sm" />
+78 -10
View File
@@ -148,6 +148,20 @@ function listStatusMeta(session: Session) {
return { label: '进行中', bar: '#2563eb', avatarBg: '#eff6ff', avatarColor: '#2563eb', dot: '#2563eb' }
}
/** 坐席在线状态(转接列表) */
function agentStatusMeta(status: string) {
switch (status) {
case 'online':
return { text: '在线', dot: '#16a34a', bg: '#ecfdf5', color: '#15803d' }
case 'busy':
return { text: '忙碌', dot: '#d97706', bg: '#fffbeb', color: '#b45309' }
case 'disabled':
return { text: '停用', dot: '#94a3b8', bg: '#f1f5f9', color: '#64748b' }
default:
return { text: '离线', dot: '#94a3b8', bg: '#f1f5f9', color: '#64748b' }
}
}
function relativeTime(iso?: string | null) {
if (!iso) return ''
const diff = Date.now() - new Date(iso).getTime()
@@ -964,17 +978,23 @@ const Dashboard = () => {
const openTransfer = async () => {
if (!selected) return
try {
// 默认返回在线/忙碌坐席(含 status)
const response = await getAvailableAgents()
setAvailableAgents((response.data || []).filter(agent => agent.id !== user?.user_id))
setTargetAgentID(undefined)
setTransferOpen(true)
} catch {
antMsg.error('加载在线客服失败')
antMsg.error('加载可转接坐席失败')
}
}
const handleTransfer = async () => {
if (!selected || !targetAgentID) return
const target = availableAgents.find(a => a.id === targetAgentID)
if (target && target.status !== 'online' && target.status !== 'busy') {
antMsg.warning('只能转接给在线或忙碌的坐席')
return
}
try {
await transferSession(selected.id, targetAgentID)
antMsg.success('会话已转接')
@@ -1883,15 +1903,63 @@ const Dashboard = () => {
)}
</aside>
<Modal title="转接会话" open={transferOpen} onCancel={() => setTransferOpen(false)} onOk={handleTransfer} okButtonProps={{ disabled: !targetAgentID }}>
<p className="text-sm text-neutral-500 mb-3">线</p>
<Select
className="w-full"
placeholder="选择客服"
value={targetAgentID}
onChange={setTargetAgentID}
options={availableAgents.map(agent => ({ value: agent.id, label: agent.nickname }))}
/>
<Modal
title="转接会话"
open={transferOpen}
onCancel={() => setTransferOpen(false)}
onOk={handleTransfer}
okButtonProps={{
disabled: !targetAgentID || (() => {
const t = availableAgents.find(a => a.id === targetAgentID)
return Boolean(t && t.status !== 'online' && t.status !== 'busy')
})(),
}}
>
<p className="text-sm text-neutral-500 mb-3">绿线 / </p>
{availableAgents.length === 0 ? (
<div className="text-sm text-neutral-400 py-6 text-center border border-dashed border-neutral-200 rounded-lg">
</div>
) : (
<Select
className="w-full"
placeholder="选择坐席"
value={targetAgentID}
onChange={setTargetAgentID}
optionLabelProp="label"
options={availableAgents.map(agent => {
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 (
<div className="flex items-center gap-2 py-0.5 min-w-0">
<span
className="inline-block w-2 h-2 rounded-full shrink-0"
style={{ background: st.dot }}
title={st.text}
/>
<span className="truncate text-neutral-800 flex-1 min-w-0">
{agent?.nickname || option.label}
</span>
<span
className="shrink-0 text-[11px] px-1.5 py-0 rounded font-medium"
style={{ background: st.bg, color: st.color }}
>
{st.text}
</span>
</div>
)
}}
/>
)}
</Modal>
<Modal title="结束会话" open={endingOpen} onCancel={() => setEndingOpen(false)} onOk={handleEnd} okText="确认结束">
<p className="text-sm text-neutral-500 mb-3"></p>
+25 -2
View File
@@ -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<LoginResult>('/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
+78 -17
View File
@@ -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<LoginResult>
user: LoginResult | null
loading: boolean
login: (username: string, password: string) => Promise<LoginResult>
logout: () => void
/** 切换本人在线 / 忙碌 / 离线 */
setPresence: (status: AgentPresence) => Promise<void>
}
const AuthContext = createContext<AuthState>({
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<LoginResult | null>(() => {
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 (
<AuthContext.Provider value={{ user, loading, login, logout }}>
<AuthContext.Provider value={{ user, loading, login, logout, setPresence }}>
{children}
</AuthContext.Provider>
)