支持坐席切换在线状态,转接列表展示状态
侧栏可切换在线/忙碌/离线;转接弹窗显示状态点与标签,列表含在线与忙碌坐席。
This commit is contained in:
@@ -2,6 +2,7 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
@@ -41,6 +42,14 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
|||||||
return
|
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)
|
token, err := middleware.GenerateToken(user.ID, user.TenantID, user.Role)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "生成token失败"})
|
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,
|
"tenant_id": user.TenantID,
|
||||||
"nickname": user.Nickname,
|
"nickname": user.Nickname,
|
||||||
"role": user.Role,
|
"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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S
|
|||||||
authRequired := api.Group("")
|
authRequired := api.Group("")
|
||||||
authRequired.Use(middleware.AuthRequired())
|
authRequired.Use(middleware.AuthRequired())
|
||||||
{
|
{
|
||||||
|
// 当前用户
|
||||||
|
authRequired.GET("/me", auth.Me)
|
||||||
|
authRequired.PUT("/me/status", auth.UpdatePresence)
|
||||||
|
|
||||||
// WebSocket
|
// WebSocket
|
||||||
authRequired.GET("/ws", wsHandler.Connect)
|
authRequired.GET("/ws", wsHandler.Connect)
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -490,21 +491,34 @@ func (h *SessionHandler) ListAvailableAgents(c *gin.Context) {
|
|||||||
type agentItem struct {
|
type agentItem struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
Nickname string `json:"nickname"`
|
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"})
|
q := model.DB.Where("tenant_id = ? AND role IN ?", middleware.GetTenantID(c), []string{"agent", "supervisor", "admin"})
|
||||||
if c.Query("all") != "1" {
|
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
|
var users []model.User
|
||||||
if err := q.Order("nickname asc").Find(&users).Error; err != nil {
|
if err := q.Order("nickname asc").Find(&users).Error; err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询客服失败"})
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询客服失败"})
|
||||||
return
|
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))
|
items := make([]agentItem, 0, len(users))
|
||||||
for _, user := range 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)
|
middleware.JSON(c, items)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import { useLocation, useNavigate } from 'react-router-dom'
|
import { useLocation, useNavigate } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
AppstoreOutlined, MessageOutlined, HistoryOutlined, TeamOutlined,
|
AppstoreOutlined, MessageOutlined, HistoryOutlined, TeamOutlined,
|
||||||
FileTextOutlined, BarChartOutlined, SettingOutlined, LogoutOutlined,
|
FileTextOutlined, BarChartOutlined, SettingOutlined, LogoutOutlined,
|
||||||
ThunderboltOutlined,
|
ThunderboltOutlined, DownOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
|
import { Dropdown, message as antMsg } from 'antd'
|
||||||
import { useAuth } from '@/stores/auth'
|
import { useAuth } from '@/stores/auth'
|
||||||
|
import type { AgentPresence } from '@/services/api'
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ key: '/agent/dashboard', icon: <AppstoreOutlined />, label: '工作台' },
|
{ key: '/agent/dashboard', icon: <AppstoreOutlined />, label: '工作台' },
|
||||||
@@ -16,10 +19,22 @@ const menuItems = [
|
|||||||
{ key: '/agent/settings', icon: <SettingOutlined />, label: '系统设置' },
|
{ 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 AgentSidebar = () => {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { user, logout } = useAuth()
|
const { user, logout, setPresence } = useAuth()
|
||||||
|
const [switching, setSwitching] = useState(false)
|
||||||
|
|
||||||
const visibleMenuItems = menuItems.filter(item => {
|
const visibleMenuItems = menuItems.filter(item => {
|
||||||
if (item.key === '/agent/statistics') return user?.role === 'admin' || user?.role === 'supervisor'
|
if (item.key === '/agent/statistics') return user?.role === 'admin' || user?.role === 'supervisor'
|
||||||
if (item.key === '/agent/settings') return user?.role === 'admin'
|
if (item.key === '/agent/settings') return user?.role === 'admin'
|
||||||
@@ -33,7 +48,22 @@ const AgentSidebar = () => {
|
|||||||
navigate('/login', { replace: true })
|
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 initial = (user?.nickname || '客').slice(0, 1)
|
||||||
|
const current = presenceMeta(user?.status || 'online')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
@@ -74,21 +104,45 @@ const AgentSidebar = () => {
|
|||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</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">
|
<div className="w-8 h-8 rounded-full bg-[#dbeafe] text-[#2563eb] flex items-center justify-center text-sm font-semibold shrink-0">
|
||||||
{initial}
|
{initial}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-sm font-medium text-neutral-800 truncate">{user?.nickname || '客服'}</div>
|
<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">
|
<Dropdown
|
||||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-green-500" />
|
trigger={['click']}
|
||||||
在线
|
disabled={switching}
|
||||||
</div>
|
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>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleLogout}
|
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="退出登录"
|
title="退出登录"
|
||||||
>
|
>
|
||||||
<LogoutOutlined className="text-sm" />
|
<LogoutOutlined className="text-sm" />
|
||||||
|
|||||||
@@ -148,6 +148,20 @@ function listStatusMeta(session: Session) {
|
|||||||
return { label: '进行中', bar: '#2563eb', avatarBg: '#eff6ff', avatarColor: '#2563eb', dot: '#2563eb' }
|
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) {
|
function relativeTime(iso?: string | null) {
|
||||||
if (!iso) return ''
|
if (!iso) return ''
|
||||||
const diff = Date.now() - new Date(iso).getTime()
|
const diff = Date.now() - new Date(iso).getTime()
|
||||||
@@ -964,17 +978,23 @@ const Dashboard = () => {
|
|||||||
const openTransfer = async () => {
|
const openTransfer = async () => {
|
||||||
if (!selected) return
|
if (!selected) return
|
||||||
try {
|
try {
|
||||||
|
// 默认返回在线/忙碌坐席(含 status)
|
||||||
const response = await getAvailableAgents()
|
const response = await getAvailableAgents()
|
||||||
setAvailableAgents((response.data || []).filter(agent => agent.id !== user?.user_id))
|
setAvailableAgents((response.data || []).filter(agent => agent.id !== user?.user_id))
|
||||||
setTargetAgentID(undefined)
|
setTargetAgentID(undefined)
|
||||||
setTransferOpen(true)
|
setTransferOpen(true)
|
||||||
} catch {
|
} catch {
|
||||||
antMsg.error('加载在线客服失败')
|
antMsg.error('加载可转接坐席失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTransfer = async () => {
|
const handleTransfer = async () => {
|
||||||
if (!selected || !targetAgentID) return
|
if (!selected || !targetAgentID) return
|
||||||
|
const target = availableAgents.find(a => a.id === targetAgentID)
|
||||||
|
if (target && target.status !== 'online' && target.status !== 'busy') {
|
||||||
|
antMsg.warning('只能转接给在线或忙碌的坐席')
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await transferSession(selected.id, targetAgentID)
|
await transferSession(selected.id, targetAgentID)
|
||||||
antMsg.success('会话已转接')
|
antMsg.success('会话已转接')
|
||||||
@@ -1883,15 +1903,63 @@ const Dashboard = () => {
|
|||||||
)}
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<Modal title="转接会话" open={transferOpen} onCancel={() => setTransferOpen(false)} onOk={handleTransfer} okButtonProps={{ disabled: !targetAgentID }}>
|
<Modal
|
||||||
<p className="text-sm text-neutral-500 mb-3">请选择一位在线客服接手当前会话。</p>
|
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
|
<Select
|
||||||
className="w-full"
|
className="w-full"
|
||||||
placeholder="选择客服"
|
placeholder="选择坐席"
|
||||||
value={targetAgentID}
|
value={targetAgentID}
|
||||||
onChange={setTargetAgentID}
|
onChange={setTargetAgentID}
|
||||||
options={availableAgents.map(agent => ({ value: agent.id, label: agent.nickname }))}
|
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>
|
||||||
<Modal title="结束会话" open={endingOpen} onCancel={() => setEndingOpen(false)} onOk={handleEnd} okText="确认结束">
|
<Modal title="结束会话" open={endingOpen} onCancel={() => setEndingOpen(false)} onOk={handleEnd} okText="确认结束">
|
||||||
<p className="text-sm text-neutral-500 mb-3">请选择本次会话的结束原因。</p>
|
<p className="text-sm text-neutral-500 mb-3">请选择本次会话的结束原因。</p>
|
||||||
|
|||||||
+25
-2
@@ -1,7 +1,15 @@
|
|||||||
import { get, post, put, del, getList, postForm, downloadFile } from './request'
|
import { get, post, put, del, getList, postForm, downloadFile } from './request'
|
||||||
|
|
||||||
export interface LoginParams { username: string; password: string }
|
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 {
|
export interface Session {
|
||||||
id: number; tenant_id: number; channel_id: number; customer_id: number; agent_id: number | null
|
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
|
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 {
|
export interface Customer {
|
||||||
id: number; tenant_id: number; name: string; phone: string; email: string
|
id: number; tenant_id: number; name: string; phone: string; email: string
|
||||||
@@ -182,6 +196,15 @@ export interface StatisticsKpis {
|
|||||||
// Auth
|
// Auth
|
||||||
export const login = (params: LoginParams) => post<LoginResult>('/login', params)
|
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
|
// Sessions
|
||||||
export const getSessions = (params?: {
|
export const getSessions = (params?: {
|
||||||
status?: string
|
status?: string
|
||||||
|
|||||||
+69
-8
@@ -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 { 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 {
|
interface AuthState {
|
||||||
user: LoginResult | null
|
user: LoginResult | null
|
||||||
loading: boolean
|
loading: boolean
|
||||||
login: (username: string, password: string) => Promise<LoginResult>
|
login: (username: string, password: string) => Promise<LoginResult>
|
||||||
logout: () => void
|
logout: () => void
|
||||||
|
/** 切换本人在线 / 忙碌 / 离线 */
|
||||||
|
setPresence: (status: AgentPresence) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthState>({
|
const AuthContext = createContext<AuthState>({
|
||||||
user: null, loading: false,
|
user: null,
|
||||||
login: async () => { throw new Error('认证上下文未初始化') }, logout: () => {},
|
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 }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const [user, setUser] = useState<LoginResult | null>(() => {
|
const [user, setUser] = useState<LoginResult | null>(() => {
|
||||||
const saved = localStorage.getItem('auth_user')
|
const saved = localStorage.getItem('auth_user')
|
||||||
if (saved) {
|
if (saved) {
|
||||||
|
try {
|
||||||
const u = JSON.parse(saved) as LoginResult
|
const u = JSON.parse(saved) as LoginResult
|
||||||
setToken(u.token)
|
setToken(u.token)
|
||||||
return u
|
return u
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
const [loading, setLoading] = useState(false)
|
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) => {
|
const login = useCallback(async (username: string, password: string) => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await loginApi({ username, password })
|
const res = await loginApi({ username, password })
|
||||||
const u = res.data
|
const u: LoginResult = { ...res.data, status: res.data.status || 'online' }
|
||||||
setToken(u.token)
|
setToken(u.token)
|
||||||
setUser(u)
|
setUser(u)
|
||||||
localStorage.setItem('auth_user', JSON.stringify(u))
|
persistUser(u)
|
||||||
return u
|
return u
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
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(() => {
|
const logout = useCallback(() => {
|
||||||
|
// 尽力把状态置为离线,不阻塞退出
|
||||||
|
void updateMyStatus('offline').catch(() => {})
|
||||||
setToken('')
|
setToken('')
|
||||||
setUser(null)
|
setUser(null)
|
||||||
localStorage.removeItem('auth_user')
|
persistUser(null)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, loading, login, logout }}>
|
<AuthContext.Provider value={{ user, loading, login, logout, setPresence }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user