- 新增 admin-pagination 工具,标准列表与云触手记录分别配置 - 用户/审计等页补齐 showSizeChanger,避免 total≤50 时无法切换 - ConfigProvider 默认开启条数切换
465 lines
14 KiB
TypeScript
465 lines
14 KiB
TypeScript
import {
|
|
DownOutlined,
|
|
PlusOutlined,
|
|
ReloadOutlined,
|
|
SearchOutlined,
|
|
} from '@ant-design/icons'
|
|
import {
|
|
Alert,
|
|
App,
|
|
Button,
|
|
Card,
|
|
Dropdown,
|
|
Empty,
|
|
Form,
|
|
Input,
|
|
Modal,
|
|
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,
|
|
fetchAdminUsers,
|
|
resetAdminUserPassword,
|
|
updateAdminUserRole,
|
|
updateAdminUserStatus,
|
|
} 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
|
|
|
|
async function reloadUsers() {
|
|
await queryClient.invalidateQueries({ queryKey: ['admin-users'] })
|
|
}
|
|
|
|
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>
|
|
</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()
|
|
}
|
|
}
|