支持坐席切换在线状态,转接列表展示状态
侧栏可切换在线/忙碌/离线;转接弹窗显示状态点与标签,列表含在线与忙碌坐席。
This commit is contained in:
@@ -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" />
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user