Files
order_site/apps/frontend/src/pages/admin/AdminUsersPage.tsx
T
yml2213 3df271b30c 后台新增RBAC权限模型支持按角色配置接单管理权限
- 新增 admin_roles/admin_permissions/admin_role_permissions 三表与种子数据(059迁移)
- 接单平台全部管理路由从固定角色切换为细粒度权限校验
- 撤单按动作二次校验:退回大厅与取消订单分别要求对应权限
- 管理员账号页新增角色权限配置卡片(admin专属,带审计日志)
- 权限更新走事务保证原子性;session实时解析权限确保修改立即生效
2026-08-23 16:04:10 +08:00

518 lines
16 KiB
TypeScript

import { DownOutlined, PlusOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons'
import {
Alert,
App,
Button,
Card,
Dropdown,
Empty,
Form,
Input,
Result,
Select,
Space,
Table,
Tag,
Typography,
} from 'antd'
import type { MenuProps, TableColumnsType } from 'antd'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { useSearchParams } from 'react-router'
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'
import { adminUserRoleOptions, adminUserStatusOptions } from '@/utils/admin-options'
import {
ADMIN_DEFAULT_PAGE_SIZE,
buildAdminTablePagination,
resolveAdminPageSize,
} from '@/utils/admin-pagination'
import { formatAdminDateTime } from '@/utils/admin-time'
type UserFilterForm = {
username?: string
role?: string
status?: string
}
type CreateUserForm = {
username: string
password: string
role: AdminRole
}
const USER_ROLE_OPTIONS = [
{ label: '普通运营', value: 'operator' },
{ label: '客服', value: 'support' },
{ label: '管理员', value: 'admin' },
]
export default function AdminUsersPage() {
const isAdmin = hasAdminRole('admin')
const currentUserId = getAdminUserId()
const queryClient = useQueryClient()
const { message, modal } = App.useApp()
const [searchParams, setSearchParams] = useSearchParams()
const [createForm] = Form.useForm<CreateUserForm>()
const [creating, setCreating] = useState(false)
const [actionLoadingId, setActionLoadingId] = useState<number | null>(null)
const page = Number(searchParams.get('page') || 1) || 1
const pageSize = resolveAdminPageSize(searchParams.get('pageSize'), ADMIN_DEFAULT_PAGE_SIZE)
const filters = {
username: searchParams.get('username') || '',
role: searchParams.get('role') || '',
status: searchParams.get('status') || '',
}
const queryParams = useMemo(
() => ({ page, pageSize, ...filters }),
[filters.role, filters.status, filters.username, page, pageSize],
)
const query = useQuery({
queryKey: ['admin-users', queryParams],
enabled: isAdmin,
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)
try {
await createAdminUser({
username: values.username.trim(),
password: values.password.trim(),
role: values.role,
status: 'active',
})
message.success('后台用户已创建')
createForm.resetFields()
setSearchParams(cleanParams({ ...filters, page: 1, pageSize }))
await reloadUsers()
} catch (error) {
message.error(error instanceof Error ? error.message : '创建后台用户失败')
} finally {
setCreating(false)
}
}
function confirmUpdateRole(item: AdminUserListItem, nextRole: AdminRole) {
if (item.role === nextRole) return
modal.confirm({
title: '确认操作',
content: `确认将 ${item.username} 调整为${formatRoleLabel(nextRole)}吗?`,
okText: '继续执行',
cancelText: '取消',
centered: true,
onOk: async () => {
setActionLoadingId(item.userId)
try {
await updateAdminUserRole(item.userId, { role: nextRole })
message.success('用户角色已更新')
await reloadUsers()
} catch (error) {
message.error(error instanceof Error ? error.message : '更新用户角色失败')
} finally {
setActionLoadingId(null)
}
},
})
}
function confirmToggleStatus(item: AdminUserListItem) {
const nextStatus = item.status === 'active' ? 'disabled' : 'active'
modal.confirm({
title: '确认操作',
content: `确认将 ${item.username}${nextStatus === 'active' ? '启用' : '停用'}吗?`,
okText: '继续执行',
cancelText: '取消',
centered: true,
onOk: async () => {
setActionLoadingId(item.userId)
try {
await updateAdminUserStatus(item.userId, { status: nextStatus })
message.success('用户状态已更新')
await reloadUsers()
} catch (error) {
message.error(error instanceof Error ? error.message : '更新用户状态失败')
} finally {
setActionLoadingId(null)
}
},
})
}
function openResetPasswordModal(item: AdminUserListItem) {
const form = ModalFormStore.create<{ password: string }>()
modal.confirm({
title: '重置密码',
content: (
<Form
layout="vertical"
preserve={false}
initialValues={{ password: '' }}
ref={form.bind}
className="modal-form"
>
<Form.Item
name="password"
label={`请输入 ${item.username} 的新密码`}
rules={[
{ required: true, message: '请输入新密码' },
{ min: 8, message: '密码至少 8 位' },
]}
>
<Input.Password placeholder="至少 8 位" />
</Form.Item>
</Form>
),
okText: '提交',
cancelText: '取消',
centered: true,
onOk: async () => {
const values = await form.validate()
setActionLoadingId(item.userId)
try {
await resetAdminUserPassword(item.userId, { password: values.password.trim() })
message.success('用户密码已重置')
await reloadUsers()
} catch (error) {
message.error(error instanceof Error ? error.message : '重置密码失败')
} finally {
setActionLoadingId(null)
}
},
})
}
function applyFilters(values: UserFilterForm) {
setSearchParams(cleanParams({ ...values, page: 1, pageSize }))
}
function resetFilters() {
setSearchParams(cleanParams({ page: 1, pageSize }))
}
if (!isAdmin) {
return (
<section className="page-stack">
<PageHeader title="后台用户" description="管理员可维护后台账号、角色和启停状态。" />
<Result status="warning" title="仅管理员可以访问用户管理。" />
</section>
)
}
const columns: TableColumnsType<AdminUserListItem> = [
{
title: 'ID',
dataIndex: 'userId',
width: 72,
align: 'center',
},
{
title: '账号',
dataIndex: 'username',
minWidth: 150,
render: (value, row) => (
<Space>
<Typography.Text strong>{value}</Typography.Text>
{row.userId === currentUserId ? <Tag>当前账号</Tag> : null}
</Space>
),
},
{
title: '角色',
dataIndex: 'role',
width: 120,
render: (value: AdminRole) => formatRoleLabel(value),
},
{
title: '状态',
dataIndex: 'status',
width: 140,
render: (value) => <StatusTag value={value} />,
},
{
title: '创建时间',
dataIndex: 'createdAt',
width: 170,
render: (value) => formatAdminDateTime(value),
},
{
title: '更新时间',
dataIndex: 'updatedAt',
width: 170,
render: (value) => formatAdminDateTime(value),
},
{
title: '操作',
width: 310,
render: (_, row) => {
const isCurrent = row.userId === currentUserId
const roleMenuItems: MenuProps['items'] = USER_ROLE_OPTIONS.map((item) => ({
key: item.value,
label: `设为${item.label}`,
disabled: row.role === item.value,
}))
return (
<Space wrap>
<Dropdown
disabled={isCurrent}
menu={{
items: roleMenuItems,
onClick: ({ key }) => confirmUpdateRole(row, key as AdminRole),
}}
>
<Button disabled={isCurrent} loading={actionLoadingId === row.userId}>
改角色 <DownOutlined />
</Button>
</Dropdown>
<Button
disabled={isCurrent}
loading={actionLoadingId === row.userId}
onClick={() => confirmToggleStatus(row)}
>
{row.status === 'active' ? '停用' : '启用'}
</Button>
<Button
type="primary"
loading={actionLoadingId === row.userId}
onClick={() => openResetPasswordModal(row)}
>
重置密码
</Button>
</Space>
)
},
},
]
return (
<section className="page-stack">
<PageHeader
title="后台用户"
description="管理员可维护后台账号、角色和启停状态,避免继续使用单一口令。"
/>
<Card className="users-action-card">
<div className="users-action-row">
<section className="users-action-section">
<div className="users-action-title">筛选用户</div>
<Form<UserFilterForm>
layout="inline"
initialValues={filters}
onFinish={applyFilters}
className="filter-form users-action-form"
>
<Form.Item name="username">
<Input allowClear placeholder="账号筛选" className="users-input-md" />
</Form.Item>
<Form.Item name="role">
<Select
allowClear
placeholder="角色"
options={adminUserRoleOptions}
className="users-select-sm"
/>
</Form.Item>
<Form.Item name="status">
<Select
allowClear
placeholder="状态"
options={adminUserStatusOptions}
className="users-select-sm"
/>
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" icon={<SearchOutlined />} htmlType="submit">
查询
</Button>
<Button icon={<ReloadOutlined />} onClick={resetFilters}>
重置
</Button>
</Space>
</Form.Item>
</Form>
</section>
<section className="users-action-section">
<div className="users-action-title">新增用户</div>
<Form<CreateUserForm>
form={createForm}
layout="inline"
initialValues={{ role: 'operator' }}
onFinish={submitCreate}
className="filter-form users-action-form"
>
<Form.Item name="username" rules={[{ required: true, message: '请输入新账号' }]}>
<Input placeholder="新账号" className="users-input-md" />
</Form.Item>
<Form.Item
name="password"
rules={[
{ required: true, message: '请输入新密码' },
{ min: 8, message: '密码至少 8 位' },
]}
>
<Input.Password placeholder="新密码,至少 8 位" className="users-input-lg" />
</Form.Item>
<Form.Item name="role" rules={[{ required: true, message: '请选择角色' }]}>
<Select options={USER_ROLE_OPTIONS} className="users-select-sm" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" icon={<PlusOutlined />} loading={creating}>
新增用户
</Button>
</Form.Item>
</Form>
</section>
</div>
</Card>
{query.error ? (
<Alert
type="error"
showIcon
message={query.error instanceof Error ? query.error.message : '读取后台用户失败'}
/>
) : null}
<Card
title="用户列表"
extra={
<Typography.Text type="secondary">
{data?.pagination.total || 0} 个账号
</Typography.Text>
}
>
<Table<AdminUserListItem>
rowKey="userId"
loading={query.isLoading || query.isFetching}
columns={columns}
dataSource={data?.items || []}
locale={{ emptyText: <Empty description="暂无用户" /> }}
scroll={{ x: 1060 }}
pagination={buildAdminTablePagination({
current: data?.pagination.page || page,
pageSize: data?.pagination.pageSize || pageSize,
total: data?.pagination.total || 0,
onChange: (nextPage, nextPageSize) => {
setSearchParams(cleanParams({ ...filters, page: nextPage, pageSize: nextPageSize }))
},
})}
/>
</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>
)
}
function formatRoleLabel(role: AdminRole) {
if (role === 'admin') return '管理员'
if (role === 'support') return '客服'
return '普通运营'
}
function cleanParams(params: Record<string, unknown>) {
const next = new URLSearchParams()
Object.entries(params).forEach(([key, value]) => {
const normalized = String(value ?? '').trim()
if (normalized) {
next.set(key, normalized)
}
})
return next
}
class ModalFormStore<T extends object> {
private form: ReturnType<typeof Form.useForm<T>>[0] | null = null
static create<T extends object>() {
return new ModalFormStore<T>()
}
bind = (instance: unknown) => {
this.form = instance as ReturnType<typeof Form.useForm<T>>[0] | null
}
async validate(): Promise<T> {
if (!this.form) {
return {} as T
}
return this.form.validateFields()
}
}