后台新增RBAC权限模型支持按角色配置接单管理权限

- 新增 admin_roles/admin_permissions/admin_role_permissions 三表与种子数据(059迁移)
- 接单平台全部管理路由从固定角色切换为细粒度权限校验
- 撤单按动作二次校验:退回大厅与取消订单分别要求对应权限
- 管理员账号页新增角色权限配置卡片(admin专属,带审计日志)
- 权限更新走事务保证原子性;session实时解析权限确保修改立即生效
This commit is contained in:
yml2213
2026-08-23 16:04:10 +08:00
parent fb0e9c8f9c
commit 3df271b30c
16 changed files with 610 additions and 70 deletions
+22 -2
View File
@@ -32,10 +32,11 @@ import {
} from 'antd'
import type { MenuProps } from 'antd'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { Outlet, useLocation, useNavigate } from 'react-router'
import {
fetchAdminSession,
fetchAdminNotifications,
fetchDevMockStatus,
logoutAdmin,
@@ -47,6 +48,7 @@ import {
getAdminToken,
getAdminTokenExpiresAt,
getAdminUsername,
setAdminPermissions,
} from '@/utils/admin-auth'
import { formatAdminDateTime } from '@/utils/admin-time'
import { useRealtimeEvents, type RealtimeEvent } from '@/hooks/useRealtimeEvents'
@@ -69,6 +71,19 @@ export default function AdminLayout() {
() => window.localStorage.getItem(NOTIFICATION_SOUND_KEY) === '1',
)
const adminSessionQuery = useQuery({
queryKey: ['admin-session'],
queryFn: fetchAdminSession,
retry: false,
})
useEffect(() => {
const session = adminSessionQuery.data?.data
if (session?.authenticated && session.user) {
setAdminPermissions(session.user.permissions || [])
}
}, [adminSessionQuery.data])
const devMockStatusQuery = useQuery({
queryKey: ['admin-dev-mock-status'],
queryFn: () => fetchDevMockStatus(),
@@ -546,7 +561,12 @@ function isRecord(value: unknown): value is Record<string, unknown> {
}
type NotificationAlertKind =
'withdraw' | 'recharge' | 'acceptance' | 'material' | 'reminder' | 'default'
| 'withdraw'
| 'recharge'
| 'acceptance'
| 'material'
| 'reminder'
| 'default'
function playNotificationSound(kind: NotificationAlertKind) {
try {
@@ -24,10 +24,12 @@ import PageHeader from '@/components/admin/PageHeader'
import StatusTag from '@/components/admin/StatusTag'
import {
createAdminUser,
fetchAdminRolePermissions,
fetchAdminUsers,
resetAdminUserPassword,
updateAdminUserRole,
updateAdminUserStatus,
updateAdminRolePermissions,
} from '@/services/admin'
import type { AdminRole, AdminUserListItem } from '@/types/admin'
import { getAdminUserId, hasAdminRole } from '@/utils/admin-auth'
@@ -83,11 +85,33 @@ export default function AdminUsersPage() {
queryFn: () => fetchAdminUsers(queryParams),
})
const data = query.data?.data
const rolePermissionsQuery = useQuery({
queryKey: ['admin-role-permissions'],
enabled: isAdmin,
queryFn: () => fetchAdminRolePermissions(),
})
async function reloadUsers() {
await queryClient.invalidateQueries({ queryKey: ['admin-users'] })
}
async function toggleRolePermission(roleKey: string, permissionKey: string, enabled: boolean) {
const role = rolePermissionsQuery.data?.data.roles.find((item) => item.roleKey === roleKey)
if (!role || roleKey === 'admin') return
const permissions = role.permissions
.filter((item) => item.enabled)
.map((item) => item.permissionKey)
.filter((item) => item !== permissionKey)
if (enabled) permissions.push(permissionKey)
try {
await updateAdminRolePermissions(roleKey, permissions)
await queryClient.invalidateQueries({ queryKey: ['admin-role-permissions'] })
message.success('角色权限已更新')
} catch (error) {
message.error(error instanceof Error ? error.message : '更新角色权限失败')
}
}
async function submitCreate(values: CreateUserForm) {
setCreating(true)
@@ -420,6 +444,37 @@ export default function AdminUsersPage() {
})}
/>
</Card>
<Card title="角色权限">
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
{(rolePermissionsQuery.data?.data.roles || []).map((role) => (
<div key={role.roleKey}>
<Typography.Text strong>{role.roleName}</Typography.Text>
<div style={{ marginTop: 8 }}>
<Space wrap>
{role.permissions.map((permission) => (
<Button
key={permission.permissionKey}
size="small"
type={permission.enabled ? 'primary' : 'default'}
disabled={role.roleKey === 'admin'}
onClick={() =>
void toggleRolePermission(
role.roleKey,
permission.permissionKey,
!permission.enabled,
)
}
>
{permission.permissionName}
</Button>
))}
</Space>
</div>
</div>
))}
</Space>
</Card>
</section>
)
}
@@ -53,6 +53,7 @@ import {
deductAdminWorkOrderPendingDeposit,
deleteAdminWorkOrder,
fetchAdminWorkCategories,
fetchAdminSession,
fetchAdminWorkOrderSharing,
fetchAdminWorkerLevels,
fetchAdminWorkerUsers,
@@ -72,7 +73,7 @@ import type { UploadedFile, WorkOrder, WorkOrderShare } from '@/types/worker-pla
import { AdminRangePicker } from '@/components/admin/AdminDatePicker'
import dayjs, { ADMIN_DATE_FORMAT } from '@/lib/dayjs'
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
import { hasAdminRole } from '@/utils/admin-auth'
import { hasAdminPermission, hasAdminRole } from '@/utils/admin-auth'
import { formatAdminDateTime } from '@/utils/admin-time'
import {
DeadlineCountdown,
@@ -151,6 +152,51 @@ export default function WorkOrdersPanel() {
const queryClient = useQueryClient()
const canEditRequirementFields = hasAdminRole('operator')
const canViewStatistics = hasAdminRole('operator')
const adminSessionQuery = useQuery({
queryKey: ['admin-session'],
queryFn: fetchAdminSession,
retry: false,
})
const sessionPermissions = adminSessionQuery.data?.data.user.permissions
const canReturnToHall = sessionPermissions
? sessionPermissions.includes('worker_order.return_to_hall')
: hasAdminPermission('worker_order.return_to_hall')
const canCancelOrder = sessionPermissions
? sessionPermissions.includes('worker_order.cancel')
: hasAdminPermission('worker_order.cancel')
const canCancelShare = sessionPermissions
? sessionPermissions.includes('worker_order.cancel_share')
: hasAdminPermission('worker_order.cancel_share')
const canEditOrder = sessionPermissions
? sessionPermissions.includes('worker_order.edit')
: hasAdminPermission('worker_order.edit')
const canAssignOrder = sessionPermissions
? sessionPermissions.includes('worker_order.assign')
: hasAdminPermission('worker_order.assign')
const canDeleteOrder = sessionPermissions
? sessionPermissions.includes('worker_order.delete')
: hasAdminPermission('worker_order.delete')
const canReopenOrder = sessionPermissions
? sessionPermissions.includes('worker_order.reopen')
: hasAdminPermission('worker_order.reopen')
const canPublishOrder = sessionPermissions
? sessionPermissions.includes('worker_order.publish')
: hasAdminPermission('worker_order.publish')
const canAcceptOrder = sessionPermissions
? sessionPermissions.includes('worker_order.accept')
: hasAdminPermission('worker_order.accept')
const canUpdateMaterial = sessionPermissions
? sessionPermissions.includes('worker_order.update_material')
: hasAdminPermission('worker_order.update_material')
const canManageProblem = sessionPermissions
? sessionPermissions.includes('worker_order.manage_problem')
: hasAdminPermission('worker_order.manage_problem')
const canDeductDeposit = sessionPermissions
? sessionPermissions.includes('worker_order.deduct_deposit')
: hasAdminPermission('worker_order.deduct_deposit')
const canConfigureSharing = sessionPermissions
? sessionPermissions.includes('worker_order.configure_share')
: hasAdminPermission('worker_order.configure_share')
const [statuses, setStatuses] = useState<string[]>([])
const [targetWorkOrderId, setTargetWorkOrderId] = useState<number | undefined>()
const [keyword, setKeyword] = useState('')
@@ -976,17 +1022,17 @@ export default function WorkOrdersPanel() {
render: (_, row) => (
<Space wrap>
<Button onClick={() => openDetailDrawer(row)}></Button>
{row.capabilities?.canEdit ? (
{row.capabilities?.canEdit && canEditOrder ? (
<Button icon={<EditOutlined />} onClick={() => openEditModal(row)}>
</Button>
) : null}
{row.capabilities?.canDelete ? (
{row.capabilities?.canDelete && canDeleteOrder ? (
<Button danger icon={<DeleteOutlined />} onClick={() => requestDeleteWorkOrder(row)}>
</Button>
) : null}
{row.capabilities?.canReopen ? (
{row.capabilities?.canReopen && canReopenOrder ? (
<Button
icon={<UndoOutlined />}
onClick={() => {
@@ -1006,29 +1052,30 @@ export default function WorkOrdersPanel() {
</Button>
) : null}
{row.sharing?.enabled || row.capabilities?.canConfigureSharing ? (
{row.sharing?.enabled ||
(row.capabilities?.canConfigureSharing && canConfigureSharing) ? (
<Button icon={<TeamOutlined />} onClick={() => openSharingConfigModal(row)}>
{row.sharing?.enabled && !row.capabilities?.canConfigureSharing
? '拼单明细'
: '拼单配置'}
</Button>
) : null}
{row.capabilities?.canAssign ? (
{row.capabilities?.canAssign && canAssignOrder ? (
<Button icon={<UserAddOutlined />} onClick={() => openAssignModal(row)}>
</Button>
) : null}
{row.capabilities?.canCancel ? (
{row.capabilities?.canCancel && (canReturnToHall || canCancelOrder) ? (
<Button danger icon={<UndoOutlined />} onClick={() => openCancelModal(row)}>
</Button>
) : null}
{row.capabilities?.canUpdateMaterial ? (
{row.capabilities?.canUpdateMaterial && canUpdateMaterial ? (
<Button icon={<FormOutlined />} onClick={() => openMaterialModal(row)}>
{['open', 'in_progress'].includes(row.status) ? '更新资料' : '补资料'}
</Button>
) : null}
{row.capabilities?.canPublish ? (
{row.capabilities?.canPublish && canPublishOrder ? (
<Button
icon={<SendOutlined />}
disabled={Number(row.rewardAmount || 0) <= 0}
@@ -1040,7 +1087,7 @@ export default function WorkOrdersPanel() {
</Button>
) : null}
{row.capabilities?.canPin ? (
{row.capabilities?.canPin && canPublishOrder ? (
<Button
icon={<PushpinOutlined />}
title={row.pinnedAt ? '取消置顶' : '置顶后抢单大厅优先展示'}
@@ -1054,7 +1101,7 @@ export default function WorkOrdersPanel() {
{row.pinnedAt ? '取消置顶' : '置顶'}
</Button>
) : null}
{row.capabilities?.canUnpublish ? (
{row.capabilities?.canUnpublish && canPublishOrder ? (
<Button
danger
onClick={() => {
@@ -1071,7 +1118,7 @@ export default function WorkOrdersPanel() {
</Button>
) : null}
{row.capabilities?.canAccept ? (
{row.capabilities?.canAccept && canAcceptOrder ? (
<Button
icon={<CheckOutlined />}
type="primary"
@@ -1080,7 +1127,9 @@ export default function WorkOrdersPanel() {
</Button>
) : null}
{row.capabilities?.canDeduct && Number(row.pendingUnfreezeAmount || 0) > 0 ? (
{row.capabilities?.canDeduct &&
canDeductDeposit &&
Number(row.pendingUnfreezeAmount || 0) > 0 ? (
<Button icon={<WarningOutlined />} danger onClick={() => openDeductModal(row)}>
</Button>
@@ -1098,7 +1147,7 @@ export default function WorkOrdersPanel() {
</Button>
) : null}
{row.capabilities?.canConfirmFriend ? (
{row.capabilities?.canConfirmFriend && canManageProblem ? (
<Button
type="primary"
icon={<UserAddOutlined />}
@@ -1107,17 +1156,17 @@ export default function WorkOrdersPanel() {
</Button>
) : null}
{row.capabilities?.canResetGiftFlow ? (
{row.capabilities?.canResetGiftFlow && canManageProblem ? (
<Button danger onClick={() => requestResetGiftMaterial(row)}>
</Button>
) : null}
{row.capabilities?.canMarkProblem ? (
{row.capabilities?.canMarkProblem && canManageProblem ? (
<Button icon={<WarningOutlined />} danger onClick={() => setProblemOrder(row)}>
</Button>
) : null}
{row.capabilities?.canResolveProblem ? (
{row.capabilities?.canResolveProblem && canManageProblem ? (
<Button icon={<FormOutlined />} onClick={() => setResolutionOrder(row)}>
</Button>
@@ -1470,7 +1519,7 @@ export default function WorkOrdersPanel() {
<Tag color={resolveGiftPhaseColor(detailOrder.gift.phase)}>
{formatGiftPhase(detailOrder.gift.phase)}
</Tag>
{detailOrder.capabilities?.canConfirmFriend ? (
{detailOrder.capabilities?.canConfirmFriend && canManageProblem ? (
<Button
type="primary"
size="small"
@@ -1479,7 +1528,7 @@ export default function WorkOrdersPanel() {
</Button>
) : null}
{detailOrder.capabilities?.canResetGiftFlow ? (
{detailOrder.capabilities?.canResetGiftFlow && canManageProblem ? (
<Button
size="small"
danger
@@ -1814,7 +1863,7 @@ export default function WorkOrdersPanel() {
<Form
form={cancelForm}
layout="vertical"
initialValues={{ action: 'return_to_hall' }}
initialValues={{ action: canReturnToHall ? 'return_to_hall' : 'cancel_order' }}
onFinish={submitCancelOrder}
>
<Alert
@@ -1830,8 +1879,12 @@ export default function WorkOrdersPanel() {
>
<Radio.Group>
<Space direction="vertical">
<Radio value="return_to_hall">退</Radio>
<Radio value="cancel_order">退</Radio>
{canReturnToHall ? (
<Radio value="return_to_hall">退</Radio>
) : null}
{canCancelOrder ? (
<Radio value="cancel_order">退</Radio>
) : null}
</Space>
</Radio.Group>
</Form.Item>
@@ -1988,7 +2041,7 @@ export default function WorkOrdersPanel() {
</Modal>
<Modal
title={`${sharingOrder?.capabilities?.canConfigureSharing ? '拼单配置' : '拼单明细'} · ${sharingOrder?.productName || ''}`}
title={`${sharingOrder?.capabilities?.canConfigureSharing && canConfigureSharing ? '拼单配置' : '拼单明细'} · ${sharingOrder?.productName || ''}`}
open={Boolean(sharingOrder)}
onCancel={() => {
setSharingOrder(null)
@@ -1997,7 +2050,9 @@ export default function WorkOrdersPanel() {
cancelShareForm.resetFields()
}}
onOk={() => sharingForm.submit()}
footer={sharingOrder?.capabilities?.canConfigureSharing ? undefined : null}
footer={
sharingOrder?.capabilities?.canConfigureSharing && canConfigureSharing ? undefined : null
}
destroyOnHidden
width={720}
>
@@ -2012,7 +2067,7 @@ export default function WorkOrdersPanel() {
unitReward: 0,
}}
onFinish={submitSharingConfig}
disabled={!sharingOrder.capabilities?.canConfigureSharing}
disabled={!sharingOrder.capabilities?.canConfigureSharing || !canConfigureSharing}
>
<Form.Item
label="启用拼单"
@@ -2169,17 +2224,19 @@ export default function WorkOrdersPanel() {
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
<Button
danger
size="small"
icon={<UndoOutlined />}
onClick={() => {
setCancelShare(share)
cancelShareForm.setFieldsValue({ reason: '' })
}}
>
</Button>
{canCancelShare ? (
<Button
danger
size="small"
icon={<UndoOutlined />}
onClick={() => {
setCancelShare(share)
cancelShareForm.setFieldsValue({ reason: '' })
}}
>
</Button>
) : null}
</Space>
)
},
+26 -1
View File
@@ -1,6 +1,31 @@
import { apiGet, apiPost } from '@/lib/http'
import { apiGet, apiPost, apiPut } from '@/lib/http'
import type { AdminPagination, AdminUserListItem } from '@/types/admin'
export type AdminRolePermission = {
permissionKey: string
permissionName: string
permissionGroup: string
description: string
enabled: boolean
}
export type AdminRolePermissions = {
roleKey: string
roleName: string
description: string
permissions: AdminRolePermission[]
}
export function fetchAdminRolePermissions() {
return apiGet<{ roles: AdminRolePermissions[] }>('/api/v1/admin/rbac')
}
export function updateAdminRolePermissions(roleKey: string, permissions: string[]) {
return apiPut<AdminRolePermissions>(`/api/v1/admin/rbac/roles/${roleKey}/permissions`, {
permissions,
})
}
export function fetchAdminUsers(params?: Record<string, unknown>) {
return apiGet<{ items: AdminUserListItem[]; pagination: AdminPagination }>(
'/api/v1/admin/users',
+2
View File
@@ -8,6 +8,7 @@ export interface AdminLoginResponse {
userId: number
username: string
role: AdminRole
permissions?: string[]
}
}
@@ -18,5 +19,6 @@ export interface AdminSessionSummary {
userId: number
username: string
role: AdminRole
permissions?: string[]
}
}
+30 -1
View File
@@ -3,6 +3,7 @@ const ADMIN_EXPIRES_AT_KEY = 'order-site-admin-token-expires-at'
const ADMIN_USER_ID_KEY = 'order-site-admin-user-id'
const ADMIN_USERNAME_KEY = 'order-site-admin-username'
const ADMIN_ROLE_KEY = 'order-site-admin-role'
const ADMIN_PERMISSIONS_KEY = 'order-site-admin-permissions'
const ADMIN_ROLE_LEVEL: Record<'support' | 'operator' | 'admin', number> = {
support: 1,
@@ -36,6 +37,26 @@ export function getAdminRole() {
return 'support'
}
export function getAdminPermissions(): string[] {
try {
const value = JSON.parse(localStorage.getItem(ADMIN_PERMISSIONS_KEY) || '[]')
return Array.isArray(value)
? value.map((item) => String(item || '').trim()).filter(Boolean)
: []
} catch {
return []
}
}
export function setAdminPermissions(permissions: string[]) {
localStorage.setItem(ADMIN_PERMISSIONS_KEY, JSON.stringify(permissions || []))
}
export function hasAdminPermission(permission: string) {
if (!hasAdminSession()) return false
return getAdminPermissions().includes(String(permission || '').trim())
}
export function hasAdminRole(role: 'admin' | 'operator' | 'support') {
if (!hasAdminSession()) {
return false
@@ -47,7 +68,12 @@ export function hasAdminRole(role: 'admin' | 'operator' | 'support') {
export function setAdminSession(
token: string,
expiresAt: string,
user?: { userId: number; username: string; role: 'admin' | 'operator' | 'support' },
user?: {
userId: number
username: string
role: 'admin' | 'operator' | 'support'
permissions?: string[]
},
) {
localStorage.setItem(ADMIN_TOKEN_KEY, token)
localStorage.setItem(ADMIN_EXPIRES_AT_KEY, expiresAt)
@@ -63,6 +89,8 @@ export function setAdminSession(
if (user?.role) {
localStorage.setItem(ADMIN_ROLE_KEY, user.role)
}
localStorage.setItem(ADMIN_PERMISSIONS_KEY, JSON.stringify(user?.permissions || []))
}
export function clearAdminSession() {
@@ -71,6 +99,7 @@ export function clearAdminSession() {
localStorage.removeItem(ADMIN_USER_ID_KEY)
localStorage.removeItem(ADMIN_USERNAME_KEY)
localStorage.removeItem(ADMIN_ROLE_KEY)
localStorage.removeItem(ADMIN_PERMISSIONS_KEY)
}
export function hasAdminSession() {