完善角色权限与数据隔离

This commit is contained in:
yml2213
2026-07-26 14:00:50 +08:00
parent b483cbc26d
commit 16d9ab0e5e
38 changed files with 2847 additions and 292 deletions
+3 -1
View File
@@ -3,6 +3,7 @@ import { Button, Input, Popconfirm, Select, Spin, Table, Tag, message } from 'an
import type { ColumnsType } from 'antd/es/table'
import { ReloadOutlined, StopOutlined } from '@ant-design/icons'
import { getBlacklist, releaseBlacklist, type BlacklistEntry } from '@/services/api'
import { usePermission } from '@/stores/auth'
function isActive(entry: BlacklistEntry) {
if (!entry.expires_at) return true
@@ -32,6 +33,7 @@ function maskValue(kind: string, value: string) {
}
const Blacklist = () => {
const canRelease = usePermission('blacklist.delete')
const [list, setList] = useState<BlacklistEntry[]>([])
const [loading, setLoading] = useState(false)
const [kind, setKind] = useState<string | undefined>()
@@ -140,7 +142,7 @@ const Blacklist = () => {
width: 100,
fixed: 'right',
render: (_, row) => (
isActive(row) ? (
isActive(row) && canRelease ? (
<Popconfirm
title="确认解除该黑名单?"
description="解除后访客可重新进入在线客服"
+7 -3
View File
@@ -12,7 +12,7 @@ import {
} from '@/services/api'
import { ChatImage } from '@/components/common/ImagePreview'
import MarkdownBody from '@/components/common/MarkdownBody'
import { useAuth } from '@/stores/auth'
import { usePermission } from '@/stores/auth'
const { RangePicker } = DatePicker
@@ -137,8 +137,8 @@ const TagChip = ({ label, bg, color }: { label: string; bg: string; color: strin
)
const ChatHistory = () => {
const { user } = useAuth()
const canArchive = user?.role === 'admin' || user?.role === 'supervisor'
const canExport = usePermission('chat_history.export')
const canArchive = usePermission('chat_history.batch_archive')
const [sessions, setSessions] = useState<Session[]>([])
const [total, setTotal] = useState(0)
@@ -335,6 +335,7 @@ const ChatHistory = () => {
<header className="shrink-0 h-14 px-6 flex items-center justify-between bg-white border-b border-neutral-200">
<h1 className="text-lg font-semibold text-neutral-900 m-0">对话记录</h1>
<div className="flex items-center gap-2">
{canExport && (
<button
type="button"
disabled={exporting}
@@ -361,6 +362,8 @@ const ChatHistory = () => {
<DownloadOutlined />
{exporting ? '导出中…' : '导出'}
</button>
)}
{canArchive && (
<button
type="button"
disabled={archiving}
@@ -370,6 +373,7 @@ const ChatHistory = () => {
<InboxOutlined />
批量归档
</button>
)}
</div>
</header>
+8 -3
View File
@@ -12,7 +12,7 @@ import {
createCustomer, deleteCustomer, exportCustomersCSV, getCustomer, getCustomerTags, getCustomers, updateCustomer,
type Customer, type CustomerContact, type CustomerTag, type Session,
} from '@/services/api'
import { useAuth } from '@/stores/auth'
import { usePermission } from '@/stores/auth'
const statusMap: Record<string, { color: string; text: string; dot: string }> = {
online: { color: '#16a34a', text: '在线', dot: '#16a34a' },
@@ -133,9 +133,10 @@ const StatusDot = ({ status }: { status: string }) => {
}
const Customers = () => {
const { user } = useAuth()
const navigate = useNavigate()
const canDelete = user?.role === 'admin' || user?.role === 'supervisor'
const canExport = usePermission('customer.export')
const canCreate = usePermission('customer.create')
const canDelete = usePermission('customer.export')
const [customers, setCustomers] = useState<Customer[]>([])
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
@@ -412,6 +413,7 @@ const Customers = () => {
</div>
<div className="flex items-center gap-2 ml-auto">
{canExport && (
<Button
icon={<ExportOutlined />}
loading={exporting}
@@ -430,6 +432,8 @@ const Customers = () => {
>
导出
</Button>
)}
{canCreate && (
<Button
type="primary"
icon={<PlusOutlined />}
@@ -438,6 +442,7 @@ const Customers = () => {
>
添加客户
</Button>
)}
</div>
</div>
</div>
+21 -19
View File
@@ -12,7 +12,7 @@ import {
getKnowledgeCategories, getKnowledgeEntries, updateKnowledgeCategory, updateKnowledgeEntry,
type KnowledgeCategory, type KnowledgeEntry,
} from '@/services/api'
import { useAuth } from '@/stores/auth'
import { usePermission } from '@/stores/auth'
type CatNode = KnowledgeCategory & { children: KnowledgeCategory[] }
@@ -47,8 +47,10 @@ function formatUsage(n: number) {
}
const Knowledge = () => {
const { user } = useAuth()
const canManage = user?.role === 'admin' || user?.role === 'supervisor'
const canCreate = usePermission('knowledge.create')
const canEdit = usePermission('knowledge.edit')
const canDelete = usePermission('knowledge.delete')
const canManage = canCreate || canEdit || canDelete
const [categories, setCategories] = useState<KnowledgeCategory[]>([])
const [totalEntries, setTotalEntries] = useState(0)
@@ -355,27 +357,27 @@ const Knowledge = () => {
const renderCatActions = (cat: KnowledgeCategory, isRoot: boolean) => {
if (!canManage) return null
return (
<span className="hidden group-hover:flex items-center gap-0.5 shrink-0" onClick={e => e.stopPropagation()}>
{isRoot && (
<button
type="button"
return (
<span className="hidden group-hover:flex items-center gap-0.5 shrink-0" onClick={e => e.stopPropagation()}>
{isRoot && canCreate && (
<button
type="button"
title="添加子分类"
className="w-6 h-6 rounded flex items-center justify-center text-neutral-400 hover:text-[#2563eb] hover:bg-blue-50 border-0 bg-transparent cursor-pointer"
onClick={e => { e.stopPropagation(); openCreateCategory(cat.id) }}
>
<PlusCircleOutlined className="text-xs" />
</button>
)}
<button
</button>
)}
{canEdit && <button
type="button"
title="编辑"
className="w-6 h-6 rounded flex items-center justify-center text-neutral-400 hover:text-neutral-600 hover:bg-neutral-100 border-0 bg-transparent cursor-pointer"
onClick={e => openEditCategory(cat, e)}
>
<EditOutlined className="text-xs" />
</button>
<Popconfirm
</button>}
{canDelete && <Popconfirm
title="确认删除该分类?"
description="需无子分类且无条目"
onConfirm={() => handleDeleteCategory(cat)}
@@ -388,7 +390,7 @@ const Knowledge = () => {
>
<DeleteOutlined className="text-xs" />
</button>
</Popconfirm>
</Popconfirm>}
</span>
)
}
@@ -503,7 +505,7 @@ const Knowledge = () => {
)}
</div>
{canManage && (
{canCreate && (
<div className="shrink-0 p-3 border-t border-neutral-200">
<button
type="button"
@@ -566,7 +568,7 @@ const Knowledge = () => {
</div>
<div className="flex-1 min-w-2" />
<span className="text-xs text-neutral-400 whitespace-nowrap shrink-0">共 {total} 条</span>
{canManage && (
{canCreate && (
<Button type="primary" icon={<PlusOutlined />} className="!h-8 !text-sm shrink-0" onClick={openCreate}>
新建知识条目
</Button>
@@ -649,7 +651,7 @@ const Knowledge = () => {
className="w-[12%] flex items-center justify-end gap-0.5 pr-1"
onClick={e => e.stopPropagation()}
>
{canManage && (
{canEdit && (
<button
type="button"
title="编辑"
@@ -667,7 +669,7 @@ const Knowledge = () => {
>
<CopyOutlined className="text-xs" />
</button>
{canManage && (
{canDelete && (
<Popconfirm title="确认删除该条目?" onConfirm={() => handleDelete(entry.id)}>
<button
type="button"
@@ -718,7 +720,7 @@ const Knowledge = () => {
<Button size="small" icon={<CopyOutlined />} onClick={() => handleCopy(detailEntry)}>
复制
</Button>
{canManage && (
{canEdit && (
<Button size="small" icon={<EditOutlined />} onClick={() => { setDetailEntry(null); openEdit(detailEntry) }}>
编辑
</Button>
+5 -4
View File
@@ -12,11 +12,12 @@ import {
getQuickReplies, importQuickReplies, publishQuickReply, unpublishQuickReply, updateQuickReply,
type QuickReply, type QuickReplyScope,
} from '@/services/api'
import { useAuth } from '@/stores/auth'
import { usePermission } from '@/stores/auth'
const QuickReplies = () => {
const { user } = useAuth()
const canManageTeam = user?.role === 'admin' || user?.role === 'supervisor'
const canCreateTeam = usePermission('quick_reply.team_create')
const canEditTeam = usePermission('quick_reply.team_edit')
const canManageTeam = canCreateTeam || canEditTeam
const [tab, setTab] = useState<QuickReplyScope>(canManageTeam ? 'team' : 'personal')
const [list, setList] = useState<QuickReply[]>([])
@@ -160,7 +161,7 @@ const QuickReplies = () => {
}
const canEditRow = (row: QuickReply) => {
if (row.scope === 'team') return canManageTeam
if (row.scope === 'team') return canEditTeam
return true
}
+184
View File
@@ -0,0 +1,184 @@
import { useState, useEffect, useCallback } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { Button, Spin, Card, Checkbox, Divider, Input, Select, message, Tag, Breadcrumb } from 'antd'
import { SaveOutlined, ArrowLeftOutlined } from '@ant-design/icons'
import { getPermissions, getRoleDetail, updateRole, updateBuiltinRole, type Permission, type RoleDetail } from '@/services/api'
interface ModulePerms {
module: string
params: Permission[]
}
export default function RoleConfig() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [detail, setDetail] = useState<RoleDetail | null>(null)
const [allPerms, setAllPerms] = useState<Permission[]>([])
const [selected, setSelected] = useState<Set<string>>(new Set())
const [dataScopes, setDataScopes] = useState<Record<string, string>>({})
const [roleName, setRoleName] = useState('')
const [roleDesc, setRoleDesc] = useState('')
const load = useCallback(async () => {
setLoading(true)
try {
const [permsRes, detailRes] = await Promise.all([
getPermissions(),
getRoleDetail(Number(id)),
])
setAllPerms(permsRes.data || [])
setDetail(detailRes.data)
setSelected(new Set(detailRes.data?.permissions || []))
setDataScopes(detailRes.data?.data_scopes || {})
setRoleName(detailRes.data?.role.name || '')
setRoleDesc(detailRes.data?.role.desc || '')
} catch {
message.error('加载失败')
} finally {
setLoading(false)
}
}, [id])
useEffect(() => { void load() }, [load])
const toggle = (code: string) => {
setSelected(prev => {
const next = new Set(prev)
const permission = allPerms.find(item => item.code === code)
if (!permission) return next
const modulePermissions = allPerms.filter(item => item.module === permission.module)
const viewCodes = modulePermissions.filter(item => item.category === 'view').map(item => item.code)
if (next.has(code)) {
next.delete(code)
if (permission.category === 'view' && !viewCodes.some(viewCode => next.has(viewCode))) {
modulePermissions.filter(item => item.category === 'operate').forEach(item => next.delete(item.code))
}
} else {
next.add(code)
if (permission.category === 'operate' && viewCodes.length > 0) {
next.add(viewCodes[0])
}
}
return next
})
}
const handleSave = async () => {
if (!detail) return
setSaving(true)
try {
const permCodes = Array.from(selected)
if (detail.role.type === 'builtin') {
await updateBuiltinRole(Number(id), { desc: roleDesc, permissions: permCodes, data_scopes: dataScopes })
} else {
await updateRole(Number(id), { name: roleName, desc: roleDesc, permissions: permCodes, data_scopes: dataScopes })
}
message.success('权限已保存,下次请求即时生效')
} catch {
message.error('保存失败')
} finally {
setSaving(false)
}
}
const moduleMap = new Map<string, Permission[]>()
for (const permission of allPerms) {
moduleMap.set(permission.module, [...(moduleMap.get(permission.module) || []), permission])
}
const modules: ModulePerms[] = Array.from(moduleMap, ([module, params]) => ({ module, params }))
const scopeModuleByLabel: Record<string, string> = {
工作台: 'session', 客户管理: 'customer', 对话记录: 'chat_history', 数据统计: 'statistics',
}
if (loading) return <div className="h-full flex items-center justify-center"><Spin size="large" /></div>
if (!detail) return <div className="h-full flex items-center justify-center text-neutral-400">角色不存在</div>
const { role } = detail
return (
<div className="h-full p-6 overflow-auto">
<div className="max-w-4xl mx-auto">
<Breadcrumb
className="mb-4"
items={[
{ title: <a onClick={() => navigate('/agent/roles')}>角色管理</a> },
{ title: `配置角色:${role.name}` },
]}
/>
<Card className="mb-4 shadow-sm">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-blue-50 flex items-center justify-center shrink-0">
<span className="text-blue-500 font-semibold">{role.name.slice(0, 1)}</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-lg font-semibold text-neutral-900">{role.name}</span>
<Tag color={role.type === 'builtin' ? 'blue' : 'green'}>
{role.type === 'builtin' ? '内置角色' : '自定义角色'}
</Tag>
{role.type === 'builtin' && (
<span className="text-xs text-neutral-400">标识:{role.code} · {detail.member_count} 位成员</span>
)}
</div>
{role.type === 'custom' ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3">
<Input value={roleName} maxLength={50} onChange={event => setRoleName(event.target.value)} placeholder="角色名称" />
<Input value={roleDesc} maxLength={200} onChange={event => setRoleDesc(event.target.value)} placeholder="角色描述" />
</div>
) : (
<Input value={roleDesc} maxLength={200} onChange={event => setRoleDesc(event.target.value)} className="mt-3" placeholder="角色描述" />
)}
</div>
</div>
</Card>
{modules.map(mod => (
<Card
key={mod.module}
className="mb-4 shadow-sm"
title={mod.module}
extra={scopeModuleByLabel[mod.module] ? (
<Select
className="w-32"
disabled={role.code === 'admin'}
value={dataScopes[scopeModuleByLabel[mod.module]] || 'self'}
onChange={scope => setDataScopes(prev => ({ ...prev, [scopeModuleByLabel[mod.module]]: scope }))}
options={[{ label: '全部数据', value: 'all' }, { label: '仅自己', value: 'self' }]}
/>
) : undefined}
>
<div className="flex flex-col gap-2">
{mod.params.map(p => (
<label
key={p.code}
className="flex items-center gap-3 py-1.5 cursor-pointer hover:bg-neutral-50 px-2 rounded"
>
<Checkbox
checked={selected.has(p.code)}
onChange={() => toggle(p.code)}
disabled={role.type === 'builtin' && role.code === 'admin' && p.code.startsWith('permission.')}
/>
<span className="text-sm text-neutral-700">{p.name}</span>
<Tag className="ml-auto" color={p.category === 'view' ? 'default' : 'blue'}>
{p.category === 'view' ? '查看' : '操作'}
</Tag>
</label>
))}
</div>
</Card>
))}
<Divider />
<div className="flex justify-end gap-3">
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/agent/roles')}>返回</Button>
<Button type="primary" icon={<SaveOutlined />} onClick={handleSave} loading={saving}>
保存
</Button>
</div>
</div>
</div>
)
}
+148
View File
@@ -0,0 +1,148 @@
import { useState, useEffect, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { Button, Card, Spin, Tag, Modal, Form, Input, message, Popconfirm, Empty } from 'antd'
import { PlusOutlined, SettingOutlined, DeleteOutlined, TeamOutlined, SafetyOutlined } from '@ant-design/icons'
import { getRoles, createRole, deleteRole, type RoleWithStats } from '@/services/api'
const roleTypeColors: Record<string, string> = { builtin: 'blue', custom: 'green' }
const roleTypeLabels: Record<string, string> = { builtin: '内置', custom: '自定义' }
export default function RoleList() {
const navigate = useNavigate()
const [roles, setRoles] = useState<RoleWithStats[]>([])
const [loading, setLoading] = useState(true)
const [modalOpen, setModalOpen] = useState(false)
const [saving, setSaving] = useState(false)
const [form] = Form.useForm()
const load = useCallback(async () => {
setLoading(true)
try {
const res = await getRoles()
setRoles(res.data || [])
} catch {
message.error('加载角色列表失败')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { void load() }, [load])
const handleCreate = async () => {
const values = await form.validateFields().catch(() => null)
if (!values) return
setSaving(true)
try {
await createRole({ name: values.name, desc: values.desc, permissions: [] })
message.success('角色创建成功')
setModalOpen(false)
form.resetFields()
void load()
} catch {
message.error('创建失败')
} finally {
setSaving(false)
}
}
const handleDelete = async (id: number) => {
try {
await deleteRole(id)
message.success('已删除')
void load()
} catch (e: any) {
message.error(e?.message || '删除失败')
}
}
if (loading) return <div className="h-full flex items-center justify-center"><Spin size="large" /></div>
return (
<div className="h-full p-6 overflow-auto">
<div className="max-w-5xl mx-auto">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold text-neutral-900">角色管理</h2>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalOpen(true)}>
新建角色
</Button>
</div>
{roles.length === 0 ? (
<Card><Empty description="暂无角色" /></Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{roles.map(role => (
<Card
key={role.id}
className="shadow-sm"
actions={[
<Button
key="config"
type="link"
icon={<SettingOutlined />}
onClick={() => navigate(`/agent/roles/${role.id}`)}
>
配置权限
</Button>,
role.type === 'custom' && (
<Popconfirm
key="delete"
title="确定删除此角色?"
onConfirm={() => handleDelete(role.id)}
okText="确定"
cancelText="取消"
>
<Button type="link" danger icon={<DeleteOutlined />}>删除</Button>
</Popconfirm>
),
].filter(Boolean)}
>
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-lg bg-blue-50 flex items-center justify-center shrink-0">
<SafetyOutlined className="text-blue-500 text-lg" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-neutral-900 text-base">{role.name}</span>
<Tag color={roleTypeColors[role.type]}>{roleTypeLabels[role.type]}</Tag>
</div>
<p className="text-sm text-neutral-500 mb-3 truncate">
{role.desc || '暂无描述'}
</p>
<div className="flex items-center gap-4 text-xs text-neutral-400">
<span className="inline-flex items-center gap-1">
<TeamOutlined /> 成员数:{role.member_count}
</span>
<span>权限项:{role.perm_count}/42</span>
<span>最后更新:{role.type === 'builtin' ? '系统默认' : role.updated_at?.slice(0, 10)}</span>
</div>
</div>
</div>
</Card>
))}
</div>
)}
<Modal
title="新建自定义角色"
open={modalOpen}
onCancel={() => { setModalOpen(false); form.resetFields() }}
onOk={handleCreate}
confirmLoading={saving}
okText="创建"
cancelText="取消"
>
<Form form={form} layout="vertical" className="mt-4">
<Form.Item name="name" label="角色名称" rules={[{ required: true, message: '请输入角色名称' }]}>
<Input maxLength={50} placeholder="如:VIP 专属客服" />
</Form.Item>
<Form.Item name="desc" label="角色描述">
<Input.TextArea maxLength={200} rows={3} placeholder="描述该角色的职责与权限范围" />
</Form.Item>
</Form>
</Modal>
</div>
</div>
)
}
+3 -3
View File
@@ -15,7 +15,7 @@ import {
updateChannel, updateCustomerTag, updateStaff, updateTenantSettings, uploadImage,
type Channel, type CustomerTag, type StaffUser, type TenantSettings, type WorkHours, type WelcomeSegment,
} from '@/services/api'
import { useAuth } from '@/stores/auth'
import { useAuth, usePermission } from '@/stores/auth'
import MarkdownEditor from '@/components/common/MarkdownEditor'
import RichTextEditor from '@/components/common/RichTextEditor'
@@ -143,8 +143,8 @@ const tabItems: { key: string; label: string; desc: string; icon: ReactNode }[]
const Settings = () => {
const { user } = useAuth()
const isAdmin = user?.role === 'admin'
const canViewStaff = user?.role === 'admin' || user?.role === 'supervisor'
const isAdmin = usePermission('settings.basic')
const canViewStaff = usePermission('settings.staff')
const [activeTab, setActiveTab] = useState('basic')
const [basicForm] = Form.useForm()
+255
View File
@@ -0,0 +1,255 @@
import { useState, useEffect, useCallback } from 'react'
import { Button, Spin, Card, Table, Modal, Form, Input, Select, Tag, message, Space, type TableColumnsType } from 'antd'
import { PlusOutlined, EditOutlined, ReloadOutlined } from '@ant-design/icons'
import { useAuth, usePermission } from '@/stores/auth'
import { getStaff, createStaff, updateStaff, deleteStaff, batchStaff, getRoles, type StaffUser, type RoleWithStats } from '@/services/api'
const roleLabels: Record<string, string> = { admin: '管理员', supervisor: '主管', agent: '客服' }
const statusColors: Record<string, string> = { online: 'green', busy: 'gold', offline: 'default', disabled: 'red' }
const statusLabels: Record<string, string> = { online: '在线', busy: '忙碌', offline: '离线', disabled: '已停用' }
export default function Staff() {
const { user } = useAuth()
const canManage = usePermission('settings.staff')
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([])
const [staff, setStaff] = useState<StaffUser[]>([])
const [seatUsed, setSeatUsed] = useState(0)
const [seatLimit, setSeatLimit] = useState(0)
const [roles, setRoles] = useState<RoleWithStats[]>([])
const [loading, setLoading] = useState(true)
const [modalOpen, setModalOpen] = useState(false)
const [editingUser, setEditingUser] = useState<StaffUser | null>(null)
const [saving, setSaving] = useState(false)
const [form] = Form.useForm()
const load = useCallback(async () => {
setLoading(true)
try {
const [staffRes, rolesRes] = await Promise.all([
getStaff(),
getRoles(),
])
setStaff(Array.isArray(staffRes.data?.list) ? staffRes.data.list : [])
setSeatUsed(staffRes.data?.seat_used || 0)
setSeatLimit(staffRes.data?.seat_limit || 0)
setRoles(rolesRes.data || [])
} catch {
message.error('加载失败')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { void load() }, [load])
const openCreate = () => {
setEditingUser(null)
form.resetFields()
form.setFieldsValue({ role: 'agent' })
setModalOpen(true)
}
const openEdit = (u: StaffUser) => {
setEditingUser(u)
form.setFieldsValue({
nickname: u.nickname,
role: u.role,
status: u.status,
})
setModalOpen(true)
}
const handleSave = async () => {
const values = await form.validateFields().catch(() => null)
if (!values) return
setSaving(true)
try {
if (editingUser) {
await updateStaff(editingUser.id, values)
message.success('已更新')
} else {
await createStaff(values)
message.success('已创建')
}
setModalOpen(false)
void load()
} catch (e: any) {
message.error(e?.message || '操作失败')
} finally {
setSaving(false)
}
}
const handleToggleStatus = async (u: StaffUser) => {
try {
if (u.status === 'disabled') {
await updateStaff(u.id, { status: 'offline' })
message.success('已启用')
} else {
await deleteStaff(u.id)
message.success('已停用')
}
void load()
} catch (e: any) {
message.error(e?.message || '操作失败')
}
}
const handleBatch = async (action: string, newRole?: string) => {
if (selectedRowKeys.length === 0) {
message.warning('请先选择账号')
return
}
const actions: Record<string, string> = { enable: '启用', disable: '停用', change_role: '更换角色' }
const confirmed = confirm(`确定批量${actions[action]} ${selectedRowKeys.length} 个账号?`)
if (!confirmed) return
try {
await batchStaff({ ids: selectedRowKeys.map(Number), action, new_role: newRole })
message.success(`批量${actions[action]}完成`)
setSelectedRowKeys([])
void load()
} catch (e: any) {
message.error(e?.message || '操作失败')
}
}
const rowSelection = canManage ? {
selectedRowKeys,
onChange: (keys: React.Key[]) => setSelectedRowKeys(keys),
} : undefined
const columns: TableColumnsType<StaffUser> = [
{ title: '账号', dataIndex: 'username', key: 'username', width: 160 },
{ title: '姓名', dataIndex: 'nickname', key: 'nickname', width: 120 },
{
title: '角色', dataIndex: 'role', key: 'role', width: 90,
render: (role: string) => <Tag>{roleLabels[role] || role}</Tag>,
},
{
title: '状态', dataIndex: 'status', key: 'status', width: 80,
render: (s: string) => <Tag color={statusColors[s] || 'default'}>{statusLabels[s] || s}</Tag>,
},
{
title: '最近登录', dataIndex: 'last_online_at', key: 'last_online_at', width: 150,
render: (v: string) => v ? v.slice(0, 19).replace('T', ' ') : '-',
},
{
title: '操作', key: 'actions', width: 140, fixed: 'right',
render: (_, u) => {
if (!canManage) return null
return (
<Space size={0}>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(u)}>
编辑
</Button>
<Button
type="link" size="small"
danger={u.status !== 'disabled'}
disabled={u.id === user?.user_id}
onClick={() => handleToggleStatus(u)}
>
{u.status === 'disabled' ? '启用' : '停用'}
</Button>
</Space>
)
},
},
]
if (loading) return <div className="h-full flex items-center justify-center"><Spin size="large" /></div>
return (
<div className="h-full p-6 overflow-auto">
<div className="max-w-5xl mx-auto">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-neutral-900">成员账号</h2>
<Space>
<Button icon={<ReloadOutlined />} onClick={load}>刷新</Button>
{canManage && (
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate} disabled={seatUsed >= seatLimit}>
新增账号
</Button>
)}
</Space>
</div>
<Card size="small" className="mb-4">
<span className="text-sm text-neutral-500">
坐席配额:{seatUsed} / {seatLimit}
{seatUsed >= seatLimit && <Tag color="red" className="ml-2">已满</Tag>}
</span>
{selectedRowKeys.length > 0 && (
<Space className="float-right">
<span className="text-xs text-neutral-400">已选 {selectedRowKeys.length} 个</span>
<Button size="small" onClick={() => handleBatch('enable')}>批量启用</Button>
<Button size="small" danger onClick={() => handleBatch('disable')}>批量停用</Button>
<Button size="small" onClick={() => {
const role = prompt('请输入新角色码(admin/supervisor/agent):')
if (role) handleBatch('change_role', role)
}}>批量更换角色</Button>
<Button size="small" onClick={() => setSelectedRowKeys([])}>取消选择</Button>
</Space>
)}
</Card>
<Table
rowKey="id"
columns={columns}
dataSource={staff}
rowSelection={rowSelection}
pagination={{ pageSize: 20, showSizeChanger: false, showTotal: t => `共 ${t} 个账号` }}
scroll={{ x: 700 }}
/>
<Modal
title={editingUser ? '编辑账号' : '新增账号'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={handleSave}
confirmLoading={saving}
okText="保存"
cancelText="取消"
destroyOnClose
>
<Form form={form} layout="vertical" className="mt-4">
{!editingUser && (
<>
<Form.Item name="username" label="登录账号" rules={[{ required: true, min: 3, max: 30, message: '3-30个字符' }]}>
<Input placeholder="如 agent01@company.com" />
</Form.Item>
<Form.Item name="password" label="密码" rules={[{ required: true, min: 6, message: '至少6位' }]}>
<Input.Password placeholder="至少6位" />
</Form.Item>
</>
)}
<Form.Item name="nickname" label="姓名">
<Input maxLength={50} placeholder="客服姓名" />
</Form.Item>
<Form.Item name="role" label="角色" rules={[{ required: true }]}>
<Select
options={roles.map(r => ({ label: `${r.name}${r.type === 'builtin' ? ' (内置)' : ''}`, value: r.code }))}
/>
</Form.Item>
{editingUser && (
<Form.Item name="status" label="状态">
<Select
options={[
{ label: '在线', value: 'online' },
{ label: '忙碌', value: 'busy' },
{ label: '离线', value: 'offline' },
{ label: '已停用', value: 'disabled' },
]}
/>
</Form.Item>
)}
{editingUser && (
<Form.Item name="password" label="密码(留空不修改)">
<Input.Password placeholder="留空不修改" />
</Form.Item>
)}
</Form>
</Modal>
</div>
</div>
)
}