重命名前端目录
This commit is contained in:
@@ -0,0 +1,460 @@
|
||||
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 { 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 = Number(searchParams.get('pageSize') || 20) || 20
|
||||
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={{
|
||||
current: data?.pagination.page || page,
|
||||
pageSize: data?.pagination.pageSize || pageSize,
|
||||
total: data?.pagination.total || 0,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user