完善角色权限与数据隔离

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
+13
View File
@@ -1,4 +1,5 @@
import { Navigate, Outlet } from 'react-router-dom'
import { Result, Spin } from 'antd'
import { useAuth } from '@/stores/auth'
const RequireAuth = () => {
@@ -35,4 +36,16 @@ export const RequireTenantAdmin = () => {
return <Outlet />
}
export const RequirePermission = ({ anyOf }: { anyOf: string[] }) => {
const { user, permissions, permissionsLoaded } = useAuth()
if (!user) return <Navigate to="/login" replace />
if (!permissionsLoaded) {
return <div className="h-full flex items-center justify-center"><Spin /></div>
}
if (!anyOf.some(code => permissions.has(code))) {
return <Result status="403" title="403" subTitle="无权访问此页面" />
}
return <Outlet />
}
export default RequireAuth
+13 -15
View File
@@ -3,21 +3,23 @@ import { useLocation, useNavigate } from 'react-router-dom'
import {
AppstoreOutlined, MessageOutlined, HistoryOutlined, TeamOutlined,
FileTextOutlined, BarChartOutlined, SettingOutlined, LogoutOutlined,
ThunderboltOutlined, DownOutlined, StopOutlined,
ThunderboltOutlined, DownOutlined, StopOutlined, SafetyOutlined,
} 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: '工作台' },
{ key: '/agent/customers', icon: <TeamOutlined />, label: '客户管理' },
{ key: '/agent/chat-history', icon: <HistoryOutlined />, label: '对话记录' },
{ key: '/agent/knowledge', icon: <FileTextOutlined />, label: '知识库' },
{ key: '/agent/quick-replies', icon: <ThunderboltOutlined />, label: '快捷回复' },
{ key: '/agent/blacklist', icon: <StopOutlined />, label: '黑名单' },
{ key: '/agent/statistics', icon: <BarChartOutlined />, label: '数据统计' },
{ key: '/agent/settings', icon: <SettingOutlined />, label: '系统设置' },
{ key: '/agent/dashboard', icon: <AppstoreOutlined />, label: '工作台', permissions: ['session.view'] },
{ key: '/agent/customers', icon: <TeamOutlined />, label: '客户管理', permissions: ['customer.view'] },
{ key: '/agent/chat-history', icon: <HistoryOutlined />, label: '对话记录', permissions: ['chat_history.view'] },
{ key: '/agent/knowledge', icon: <FileTextOutlined />, label: '知识库', permissions: ['knowledge.view'] },
{ key: '/agent/quick-replies', icon: <ThunderboltOutlined />, label: '快捷回复', permissions: ['quick_reply.view'] },
{ key: '/agent/blacklist', icon: <StopOutlined />, label: '黑名单', permissions: ['blacklist.view'] },
{ key: '/agent/statistics', icon: <BarChartOutlined />, label: '数据统计', permissions: ['statistics.view'] },
{ key: '/agent/staff', icon: <TeamOutlined />, label: '成员账号', permissions: ['settings.staff'] },
{ key: '/agent/roles', icon: <SafetyOutlined />, label: '权限控制', permissions: ['permission.view'] },
{ key: '/agent/settings', icon: <SettingOutlined />, label: '系统设置', permissions: ['settings.basic', 'settings.channel', 'settings.assign_rule', 'settings.customer_tag', 'settings.auto_reply', 'settings.worktime', 'settings.notification'] },
]
const presenceOptions: { value: AgentPresence; text: string; dot: string }[] = [
@@ -33,14 +35,10 @@ function presenceMeta(status?: string) {
const AgentSidebar = () => {
const location = useLocation()
const navigate = useNavigate()
const { user, logout, setPresence } = useAuth()
const { user, permissions, 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'
return true
})
const visibleMenuItems = menuItems.filter(item => item.permissions.some(code => permissions.has(code)))
const selectedKey = visibleMenuItems.find(item => location.pathname.startsWith(item.key))?.key || '/agent/dashboard'
+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>
)
}
+20 -13
View File
@@ -1,7 +1,7 @@
import { lazy, Suspense } from 'react'
import { Navigate, createBrowserRouter } from 'react-router-dom'
import { Spin } from 'antd'
import RequireAuth, { RequirePlatformAdmin, RequireStaff, RequireSupervisor, RequireTenantAdmin } from '@/components/RequireAuth'
import RequireAuth, { RequirePermission, RequirePlatformAdmin, RequireStaff, RequireTenantAdmin } from '@/components/RequireAuth'
const AgentLayout = lazy(() => import('@/components/layout/AgentLayout'))
const AdminLayout = lazy(() => import('@/components/layout/AdminLayout'))
@@ -14,6 +14,9 @@ const QuickReplies = lazy(() => import('@/pages/agent/QuickReplies'))
const Blacklist = lazy(() => import('@/pages/agent/Blacklist'))
const Statistics = lazy(() => import('@/pages/agent/Statistics'))
const Settings = lazy(() => import('@/pages/agent/Settings'))
const RoleList = lazy(() => import('@/pages/agent/RoleList'))
const RoleConfig = lazy(() => import('@/pages/agent/RoleConfig'))
const Staff = lazy(() => import('@/pages/agent/Staff'))
const AdminDashboard = lazy(() => import('@/pages/admin/Dashboard'))
const Tenants = lazy(() => import('@/pages/admin/Tenants'))
const Plans = lazy(() => import('@/pages/admin/Plans'))
@@ -64,20 +67,24 @@ export const router = createBrowserRouter([
element: <Lazy><AgentLayout /></Lazy>,
children: [
{ index: true, element: <Navigate to="/agent/dashboard" replace /> },
{ path: 'dashboard', element: <Lazy><Dashboard /></Lazy> },
{ path: 'chat-history', element: <Lazy><ChatHistory /></Lazy> },
{ path: 'customers', element: <Lazy><Customers /></Lazy> },
{ path: 'knowledge', element: <Lazy><Knowledge /></Lazy> },
{ path: 'quick-replies', element: <Lazy><QuickReplies /></Lazy> },
{ path: 'blacklist', element: <Lazy><Blacklist /></Lazy> },
{
element: <RequireSupervisor />,
children: [{ path: 'statistics', element: <Lazy><Statistics /></Lazy> }],
},
{ element: <RequirePermission anyOf={['session.view']} />, children: [{ path: 'dashboard', element: <Lazy><Dashboard /></Lazy> }] },
{ element: <RequirePermission anyOf={['chat_history.view']} />, children: [{ path: 'chat-history', element: <Lazy><ChatHistory /></Lazy> }] },
{ element: <RequirePermission anyOf={['customer.view']} />, children: [{ path: 'customers', element: <Lazy><Customers /></Lazy> }] },
{ element: <RequirePermission anyOf={['knowledge.view']} />, children: [{ path: 'knowledge', element: <Lazy><Knowledge /></Lazy> }] },
{ element: <RequirePermission anyOf={['quick_reply.view']} />, children: [{ path: 'quick-replies', element: <Lazy><QuickReplies /></Lazy> }] },
{ element: <RequirePermission anyOf={['blacklist.view']} />, children: [{ path: 'blacklist', element: <Lazy><Blacklist /></Lazy> }] },
{ element: <RequirePermission anyOf={['statistics.view']} />, children: [{ path: 'statistics', element: <Lazy><Statistics /></Lazy> }] },
{
element: <RequireTenantAdmin />,
children: [{ path: 'settings', element: <Lazy><Settings /></Lazy> }],
},
children: [
{ element: <RequirePermission anyOf={['settings.basic']} />, children: [{ path: 'settings', element: <Lazy><Settings /></Lazy> }] },
{ element: <RequirePermission anyOf={['permission.view']} />, children: [
{ path: 'roles', element: <Lazy><RoleList /></Lazy> },
{ path: 'roles/:id', element: <Lazy><RoleConfig /></Lazy> },
] },
{ element: <RequirePermission anyOf={['settings.staff']} />, children: [{ path: 'staff', element: <Lazy><Staff /></Lazy> }] },
],
},
],
},
],
+48
View File
@@ -587,6 +587,7 @@ export const createStaff = (data: { username: string; password: string; nickname
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 batchStaff = (data: { ids: number[]; action: string; new_role?: string }) => post('/staff/batch', data)
export const deleteStaff = (id: number) => del(`/staff/${id}`)
// Tenant settings
@@ -678,3 +679,50 @@ export const createAnnouncement = (data: { title: string; content: string; statu
export const updateAnnouncement = (id: number, data: Partial<Announcement>) =>
put<Announcement>(`/admin/announcements/${id}`, data)
export const deleteAnnouncement = (id: number) => del(`/admin/announcements/${id}`)
// 权限管理
export interface Permission {
id: number
code: string
name: string
module: string
category: string
sort_order: number
}
export interface RoleWithStats {
id: number
tenant_id: number
name: string
code: string
type: 'builtin' | 'custom'
desc: string
member_count: number
perm_count: number
created_at: string
updated_at: string
}
export interface RoleDetail {
role: RoleWithStats
permissions: string[]
data_scopes: Record<string, 'all' | 'self'>
member_count: number
}
export interface MePermissions {
role: string
permissions: string[]
}
export const getPermissions = () => get<Permission[]>('/roles/permissions')
export const getRoles = () => get<RoleWithStats[]>('/roles')
export const getRoleDetail = (id: number) => get<RoleDetail>(`/roles/${id}`)
export const createRole = (data: { name: string; desc?: string; permissions: string[]; data_scopes?: Record<string, string> }) =>
post<RoleWithStats>('/roles', data)
export const updateRole = (id: number, data: { name?: string; desc?: string; permissions: string[]; data_scopes: Record<string, string> }) =>
put<RoleWithStats>(`/roles/${id}`, data)
export const updateBuiltinRole = (id: number, data: { desc?: string; permissions: string[]; data_scopes: Record<string, string> }) =>
put<RoleWithStats>(`/roles/${id}/builtin`, data)
export const deleteRole = (id: number) => del(`/roles/${id}`)
export const mePermissions = () => get<MePermissions>('/me/permissions')
+16 -3
View File
@@ -1,8 +1,14 @@
const BASE = '/api'
let token = ''
export const PERMISSIONS_CHANGED_EVENT = 'permissions-changed'
export function setToken(t: string) { token = t }
let token = ''
let permissionVersion = ''
export function setToken(t: string) {
if (token !== t) permissionVersion = ''
token = t
}
export function getToken() { return token }
interface Response<T = unknown> {
@@ -32,6 +38,14 @@ async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
if (token) headers['Authorization'] = `Bearer ${token}`
const res = await fetch(`${BASE}${url}`, { ...options, headers })
const nextPermissionVersion = res.headers.get('X-Permission-Version') || ''
if (nextPermissionVersion) {
const changed = permissionVersion !== '' && permissionVersion !== nextPermissionVersion
permissionVersion = nextPermissionVersion
if (changed && typeof window !== 'undefined') {
window.dispatchEvent(new Event(PERMISSIONS_CHANGED_EVENT))
}
}
const json = await res.json()
if (json.code !== 0) {
@@ -87,4 +101,3 @@ export async function downloadFile(path: string, fallbackName: string) {
a.remove()
URL.revokeObjectURL(url)
}
+76 -21
View File
@@ -1,9 +1,10 @@
import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react'
import { setToken } from '@/services/request'
import { PERMISSIONS_CHANGED_EVENT, setToken } from '@/services/request'
import {
getMe,
login as loginApi,
updateMyStatus,
mePermissions,
type AgentPresence,
type LoginResult,
} from '@/services/api'
@@ -11,18 +12,24 @@ import {
interface AuthState {
user: LoginResult | null
loading: boolean
permissions: Set<string>
permissionsLoaded: boolean
login: (username: string, password: string) => Promise<LoginResult>
logout: () => void
/** 切换本人在线 / 忙碌 / 离线 */
setPresence: (status: AgentPresence) => Promise<void>
refreshPermissions: () => Promise<void>
}
const AuthContext = createContext<AuthState>({
user: null,
loading: false,
permissions: new Set(),
permissionsLoaded: false,
login: async () => { throw new Error('认证上下文未初始化') },
logout: () => {},
setPresence: async () => {},
refreshPermissions: async () => {},
})
function persistUser(u: LoginResult | null) {
@@ -48,29 +55,62 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return null
})
const [loading, setLoading] = useState(false)
const [permissions, setPermissions] = useState<Set<string>>(new Set())
const [permissionsLoaded, setPermissionsLoaded] = useState(false)
// 刷新后同步服务端真实状态(含 status)
const refreshPermissions = useCallback(async () => {
if (!user?.token) return
try {
const res = await mePermissions()
if (res.data?.permissions) {
setPermissions(new Set(res.data.permissions))
}
} catch {
setPermissions(new Set())
} finally {
setPermissionsLoaded(true)
}
}, [user?.token])
useEffect(() => {
const handlePermissionsChanged = () => { void refreshPermissions() }
window.addEventListener(PERMISSIONS_CHANGED_EVENT, handlePermissionsChanged)
return () => window.removeEventListener(PERMISSIONS_CHANGED_EVENT, handlePermissionsChanged)
}, [refreshPermissions])
// 刷新后同步服务端真实状态(含 status + permissions
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
})
setPermissionsLoaded(false)
Promise.all([getMe(), mePermissions()])
.then(([meRes, permRes]) => {
if (cancelled) return
if (meRes.data) {
setUser(prev => {
if (!prev) return prev
const next = {
...prev,
nickname: meRes.data.nickname || prev.nickname,
role: meRes.data.role || prev.role,
status: meRes.data.status || prev.status || 'offline',
}
persistUser(next)
return next
})
}
if (permRes.data?.permissions) {
setPermissions(new Set(permRes.data.permissions))
}
setPermissionsLoaded(true)
})
.catch(() => { /* 忽略:token 失效会在后续请求里处理 */ })
.catch(() => {
if (!cancelled) {
setPermissions(new Set())
setPermissionsLoaded(true)
}
})
return () => { cancelled = true }
// 仅挂载时拉一次
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user?.token])
@@ -82,11 +122,20 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setToken(u.token)
setUser(u)
persistUser(u)
setPermissionsLoaded(false)
try {
const permissionRes = await mePermissions()
setPermissions(new Set(permissionRes.data?.permissions || []))
} catch {
setPermissions(new Set())
} finally {
setPermissionsLoaded(true)
}
return u
} finally {
setLoading(false)
}
}, [])
}, [])
const setPresence = useCallback(async (status: AgentPresence) => {
const res = await updateMyStatus(status)
@@ -100,18 +149,24 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, [])
const logout = useCallback(() => {
// 尽力把状态置为离线,不阻塞退出
void updateMyStatus('offline').catch(() => {})
setToken('')
setUser(null)
setPermissions(new Set())
setPermissionsLoaded(false)
persistUser(null)
}, [])
return (
<AuthContext.Provider value={{ user, loading, login, logout, setPresence }}>
<AuthContext.Provider value={{ user, loading, permissions, permissionsLoaded, login, logout, setPresence, refreshPermissions }}>
{children}
</AuthContext.Provider>
)
}
export const useAuth = () => useContext(AuthContext)
export function usePermission(code: string): boolean {
const { permissions } = useAuth()
return permissions.has(code)
}