完善角色权限与数据隔离
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,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="解除后访客可重新进入在线客服"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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> }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user