实现租户坐席账号管理

新增 /api/staff 列表/创建/更新/禁用,按租户坐席配额校验;系统设置增加坐席账号页支持增改禁与配额展示。
This commit is contained in:
yml2213
2026-07-15 14:07:06 +08:00
parent 34d3866520
commit ea3901d8b0
4 changed files with 696 additions and 16 deletions
+311 -16
View File
@@ -1,14 +1,17 @@
import { useEffect, useState, type ReactNode } from 'react'
import { Form, Input, Switch, Select, Button, message, Spin, Empty } from 'antd'
import { Form, Input, Switch, Select, Button, message, Spin, Empty, Modal, Popconfirm } from 'antd'
import {
LinkOutlined, WechatOutlined, PhoneOutlined, MailOutlined, MobileOutlined, CopyOutlined,
PlusOutlined, SettingOutlined, ApiOutlined, TeamOutlined, SafetyCertificateOutlined,
PlusOutlined, SettingOutlined, ApiOutlined, TeamOutlined, UserSwitchOutlined,
MessageOutlined, ClockCircleOutlined, BellOutlined, GlobalOutlined,
EditOutlined, StopOutlined, CheckCircleOutlined,
} from '@ant-design/icons'
import {
createChannel, getChannels, getTenantSettings, updateChannel, updateTenantSettings,
type Channel, type TenantSettings, type WorkHours,
createChannel, createStaff, deleteStaff, getChannels, getStaff, getTenantSettings,
updateChannel, updateStaff, updateTenantSettings,
type Channel, type StaffUser, type TenantSettings, type WorkHours,
} from '@/services/api'
import { useAuth } from '@/stores/auth'
const typeMeta: Record<string, { icon: ReactNode; label: string; desc: string }> = {
web: { icon: <GlobalOutlined />, label: '网页聊天', desc: '嵌入官网或任意网页的在线客服窗口' },
@@ -35,22 +38,40 @@ const hourOptions = [
{ value: '全天', label: '全天' },
]
const roleLabel: Record<string, string> = {
admin: '管理员',
supervisor: '主管',
agent: '客服',
}
const statusLabel: Record<string, { text: string; className: string }> = {
online: { text: '在线', className: 'bg-emerald-50 text-emerald-700' },
offline: { text: '离线', className: 'bg-neutral-100 text-neutral-600' },
busy: { text: '忙碌', className: 'bg-amber-50 text-amber-700' },
disabled: { text: '已禁用', className: 'bg-red-50 text-red-600' },
}
const tabItems: { key: string; label: string; desc: string; icon: ReactNode }[] = [
{ key: 'basic', label: '基本设置', desc: '租户展示名称、默认昵称与时区', icon: <SettingOutlined /> },
{ key: 'channels', label: '渠道管理', desc: '配置客户服务接入渠道与嵌入代码', icon: <ApiOutlined /> },
{ key: 'staff', label: '坐席账号', desc: '管理客服/主管账号与坐席配额', icon: <UserSwitchOutlined /> },
{ key: 'assignment', label: '客服分配规则', desc: '会话自动分配策略说明', icon: <TeamOutlined /> },
{ key: 'permission', label: '权限管理', desc: '角色与能力说明', icon: <SafetyCertificateOutlined /> },
{ key: 'autoreply', label: '自动回复', desc: '欢迎语与离线留言提示', icon: <MessageOutlined /> },
{ key: 'worktime', label: '工作时间', desc: '在线服务时段与非工作时间提示', icon: <ClockCircleOutlined /> },
{ key: 'notify', label: '通知设置', desc: '新会话、留言与日报偏好', icon: <BellOutlined /> },
]
const Settings = () => {
const { user } = useAuth()
const isAdmin = user?.role === 'admin'
const canViewStaff = user?.role === 'admin' || user?.role === 'supervisor'
const [activeTab, setActiveTab] = useState('basic')
const [basicForm] = Form.useForm()
const [autoReplyForm] = Form.useForm()
const [workForm] = Form.useForm()
const [notifyForm] = Form.useForm()
const [staffForm] = Form.useForm()
const [channels, setChannels] = useState<Channel[]>([])
const [settings, setSettings] = useState<TenantSettings | null>(null)
const [loadingChannels, setLoadingChannels] = useState(false)
@@ -58,8 +79,31 @@ const Settings = () => {
const [saving, setSaving] = useState(false)
const [togglingId, setTogglingId] = useState<number | null>(null)
const [staffList, setStaffList] = useState<StaffUser[]>([])
const [seatLimit, setSeatLimit] = useState(0)
const [seatUsed, setSeatUsed] = useState(0)
const [loadingStaff, setLoadingStaff] = useState(false)
const [staffModalOpen, setStaffModalOpen] = useState(false)
const [editingStaff, setEditingStaff] = useState<StaffUser | null>(null)
const [savingStaff, setSavingStaff] = useState(false)
const currentTab = tabItems.find(t => t.key === activeTab) || tabItems[0]
const loadStaff = async () => {
setLoadingStaff(true)
try {
const res = await getStaff()
setStaffList(Array.isArray(res.data?.list) ? res.data.list : [])
setSeatLimit(res.data?.seat_limit ?? 0)
setSeatUsed(res.data?.seat_used ?? 0)
} catch (e) {
setStaffList([])
message.error(e instanceof Error ? e.message : '加载坐席失败')
} finally {
setLoadingStaff(false)
}
}
const loadChannels = async () => {
setLoadingChannels(true)
try {
@@ -108,8 +152,79 @@ const Settings = () => {
useEffect(() => {
if (activeTab === 'channels') loadChannels()
if (['basic', 'autoreply', 'worktime', 'notify'].includes(activeTab)) loadSettings()
if (activeTab === 'staff' && canViewStaff) loadStaff()
}, [activeTab])
const openCreateStaff = () => {
setEditingStaff(null)
staffForm.resetFields()
staffForm.setFieldsValue({ role: 'agent' })
setStaffModalOpen(true)
}
const openEditStaff = (s: StaffUser) => {
setEditingStaff(s)
staffForm.resetFields()
staffForm.setFieldsValue({
nickname: s.nickname,
role: s.role === 'admin' ? 'admin' : s.role,
status: s.status,
password: undefined,
})
setStaffModalOpen(true)
}
const handleSaveStaff = async (values: {
username?: string; password?: string; nickname?: string; role?: string; status?: string
}) => {
setSavingStaff(true)
try {
if (editingStaff) {
await updateStaff(editingStaff.id, {
nickname: values.nickname,
role: values.role === 'admin' ? 'admin' : values.role,
status: values.status,
password: values.password || undefined,
})
message.success('坐席已更新')
} else {
await createStaff({
username: values.username!.trim(),
password: values.password!,
nickname: values.nickname?.trim(),
role: values.role || 'agent',
})
message.success('坐席已创建')
}
setStaffModalOpen(false)
await loadStaff()
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败')
} finally {
setSavingStaff(false)
}
}
const handleDisableStaff = async (s: StaffUser) => {
try {
await deleteStaff(s.id)
message.success('已禁用')
await loadStaff()
} catch (e) {
message.error(e instanceof Error ? e.message : '操作失败')
}
}
const handleEnableStaff = async (s: StaffUser) => {
try {
await updateStaff(s.id, { status: 'offline' })
message.success('已启用')
await loadStaff()
} catch (e) {
message.error(e instanceof Error ? e.message : '启用失败')
}
}
const savePartial = async (payload: Parameters<typeof updateTenantSettings>[0], okText = '已保存') => {
setSaving(true)
try {
@@ -226,10 +341,22 @@ const Settings = () => {
<h1 className="text-base font-semibold text-neutral-900 m-0 truncate">{currentTab.label}</h1>
</div>
{panelHeaderAction}
{activeTab === 'staff' && isAdmin && (
<Button
type="primary"
size="small"
icon={<PlusOutlined />}
className="!h-8"
disabled={seatLimit > 0 && seatUsed >= seatLimit}
onClick={openCreateStaff}
>
</Button>
)}
</div>
<div className="flex-1 overflow-auto px-6 py-5">
<div className="max-w-3xl">
<div className={activeTab === 'staff' ? 'max-w-4xl' : 'max-w-3xl'}>
<p className="text-xs text-neutral-400 mt-0 mb-5">{currentTab.desc}</p>
{activeTab === 'basic' && (
@@ -403,16 +530,115 @@ const Settings = () => {
</div>
)}
{activeTab === 'permission' && (
<div className="bg-white rounded-xl border border-neutral-200 shadow-sm p-6">
<div className="text-sm text-neutral-600 leading-relaxed space-y-3">
<p className="m-0"></p>
<ul className="m-0 pl-5 space-y-2 text-sm text-neutral-600">
<li><span className="font-medium text-neutral-800"></span> </li>
<li><span className="font-medium text-neutral-800"></span> </li>
<li><span className="font-medium text-neutral-800">线</span> </li>
</ul>
</div>
{activeTab === 'staff' && (
<div>
{!canViewStaff ? (
<div className="bg-white rounded-xl border border-neutral-200 p-8 text-center text-sm text-neutral-500">
</div>
) : loadingStaff ? (
<div className="py-16 text-center"><Spin /></div>
) : (
<>
<div className="flex items-center gap-4 mb-4 flex-wrap">
<div className="rounded-xl bg-white border border-neutral-200 px-4 py-3 min-w-[140px]">
<div className="text-[11px] text-neutral-400 mb-0.5"></div>
<div className="text-lg font-semibold text-neutral-900 tabular-nums">
{seatUsed}
<span className="text-sm font-normal text-neutral-400"> / {seatLimit}</span>
</div>
</div>
<div className="text-xs text-neutral-400 flex-1 min-w-[200px]">
1
</div>
</div>
{staffList.length === 0 ? (
<Empty description="暂无坐席账号" className="bg-white rounded-xl border border-neutral-200 py-12" />
) : (
<div className="rounded-xl border border-neutral-200 bg-white overflow-hidden">
<table className="w-full border-collapse min-w-[640px]">
<thead>
<tr className="bg-neutral-50 text-[11px] font-semibold text-neutral-500">
<th className="text-left px-4 py-2.5 border-b border-neutral-200"></th>
<th className="text-left px-4 py-2.5 border-b border-neutral-200"></th>
<th className="text-left px-4 py-2.5 border-b border-neutral-200"></th>
<th className="text-left px-4 py-2.5 border-b border-neutral-200"></th>
<th className="text-left px-4 py-2.5 border-b border-neutral-200"></th>
{isAdmin && (
<th className="text-right px-4 py-2.5 border-b border-neutral-200"></th>
)}
</tr>
</thead>
<tbody>
{staffList.map(s => {
const st = statusLabel[s.status] || statusLabel.offline
return (
<tr key={s.id} className="hover:bg-neutral-50 border-t border-neutral-100">
<td className="px-4 py-3 text-sm font-medium text-neutral-800">{s.username}</td>
<td className="px-4 py-3 text-sm text-neutral-700">{s.nickname || '—'}</td>
<td className="px-4 py-3">
<span className="text-xs px-2 py-0.5 rounded-full bg-neutral-100 text-neutral-600">
{roleLabel[s.role] || s.role}
</span>
</td>
<td className="px-4 py-3">
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${st.className}`}>
{st.text}
</span>
</td>
<td className="px-4 py-3 text-xs text-neutral-400 whitespace-nowrap">
{s.created_at ? s.created_at.slice(0, 16) : '—'}
</td>
{isAdmin && (
<td className="px-4 py-3 text-right" onClick={e => e.stopPropagation()}>
<div className="inline-flex items-center gap-1">
<button
type="button"
title="编辑"
className="w-7 h-7 rounded-md flex items-center justify-center text-neutral-400 hover:text-[#2563eb] hover:bg-blue-50 border-0 bg-transparent cursor-pointer"
onClick={() => openEditStaff(s)}
>
<EditOutlined className="text-xs" />
</button>
{s.status === 'disabled' ? (
<button
type="button"
title="启用"
className="w-7 h-7 rounded-md flex items-center justify-center text-neutral-400 hover:text-emerald-600 hover:bg-emerald-50 border-0 bg-transparent cursor-pointer"
onClick={() => handleEnableStaff(s)}
>
<CheckCircleOutlined className="text-xs" />
</button>
) : (
<Popconfirm
title="确认禁用该账号?"
description="禁用后无法登录,并释放坐席"
onConfirm={() => handleDisableStaff(s)}
disabled={s.id === user?.user_id}
>
<button
type="button"
title="禁用"
disabled={s.id === user?.user_id}
className="w-7 h-7 rounded-md flex items-center justify-center text-neutral-400 hover:text-red-500 hover:bg-red-50 border-0 bg-transparent cursor-pointer disabled:opacity-30"
>
<StopOutlined className="text-xs" />
</button>
</Popconfirm>
)}
</div>
</td>
)}
</tr>
)
})}
</tbody>
</table>
</div>
)}
</>
)}
</div>
)}
@@ -541,6 +767,75 @@ const Settings = () => {
</div>
</div>
</section>
<Modal
title={editingStaff ? '编辑坐席' : '添加坐席'}
open={staffModalOpen}
onCancel={() => setStaffModalOpen(false)}
onOk={() => staffForm.submit()}
confirmLoading={savingStaff}
destroyOnClose
okText="保存"
width={440}
>
<Form form={staffForm} layout="vertical" onFinish={handleSaveStaff} className="mt-2" requiredMark={false}>
{!editingStaff && (
<>
<Form.Item
name="username"
label="登录用户名"
rules={[{ required: true, message: '请输入用户名' }, { min: 3, max: 30, message: '3-30 个字符' }]}
>
<Input placeholder="英文/数字,全局唯一" autoComplete="off" />
</Form.Item>
<Form.Item
name="password"
label="初始密码"
rules={[{ required: true, message: '请输入密码' }, { min: 6, max: 64, message: '至少 6 位' }]}
>
<Input.Password placeholder="至少 6 位" autoComplete="new-password" />
</Form.Item>
</>
)}
<Form.Item name="nickname" label="显示昵称" rules={[{ max: 50 }]}>
<Input placeholder="工作台显示名称" />
</Form.Item>
<Form.Item name="role" label="角色" rules={[{ required: true }]}>
<Select
options={
editingStaff?.role === 'admin'
? [
{ value: 'admin', label: '管理员' },
{ value: 'supervisor', label: '主管' },
{ value: 'agent', label: '客服' },
]
: [
{ value: 'agent', label: '客服' },
{ value: 'supervisor', label: '主管' },
...(isAdmin ? [{ value: 'admin', label: '管理员' }] : []),
]
}
/>
</Form.Item>
{editingStaff && (
<>
<Form.Item name="status" label="状态">
<Select
options={[
{ value: 'online', label: '在线' },
{ value: 'offline', label: '离线' },
{ value: 'busy', label: '忙碌' },
{ value: 'disabled', label: '禁用' },
]}
/>
</Form.Item>
<Form.Item name="password" label="重置密码" extra="留空则不修改">
<Input.Password placeholder="可选,至少 6 位" autoComplete="new-password" />
</Form.Item>
</>
)}
</Form>
</Modal>
</div>
)
}
+22
View File
@@ -223,6 +223,28 @@ export const getChannels = () => get<Channel[]>('/channels')
export const createChannel = (data: { type: string; name?: string }) => post<Channel>('/channels', data)
export const updateChannel = (id: number, data: { name?: string; status?: string }) => put<Channel>(`/channels/${id}`, data)
// 坐席账号
export interface StaffUser {
id: number
username: string
nickname: string
role: string
status: string
created_at: string
last_online_at?: string
}
export interface StaffListResult {
list: StaffUser[]
seat_limit: number
seat_used: number
}
export const getStaff = () => get<StaffListResult>('/staff')
export const createStaff = (data: { username: string; password: string; nickname?: string; role?: string }) =>
post<StaffUser>('/staff', data)
export const updateStaff = (id: number, data: { nickname?: string; role?: string; status?: string; password?: string }) =>
put<StaffUser>(`/staff/${id}`, data)
export const deleteStaff = (id: number) => del(`/staff/${id}`)
// Tenant settings
export type WorkHours = Record<string, string>
export interface TenantSettings {