人生的真相:
爱情是滤镜,能把普通人磨成男神女神;
生活是卸妆水,一擦全是素颜的烟火气。
从0 死磕全栈系列文章
从0死磕全栈第十天:nest.js集成prisma完成CRUD
上一节,我们已经在后端实现了使用prisma整合数据库的用户crud功能,这次就需要来完成企业里面的正式的用户功能页面了。
在实现这个功能之前,我们需要提前安装好需要的依赖
npm install antd @ant-design/icons react-router-dom --force
稍等几分钟,下载好了之后,就可以开始进入正题。
在真正实现一个企业级的页面,我们需要用到成熟的UI框架,给我们提供很多前人已经写好的优秀组件,借助这些组件,可以帮助我们快速搭建页面,这里我们选择的就是Antd Design。
Antd Design简介
Ant Design(简称 antd)是阿里巴巴开源的企业级 React UI 组件库,提供丰富的预设组件和设计规范。它基于 React 技术栈,遵循 Ant Design 设计语言,包含按钮、表单、表格等 50+ 高质量组件,支持主题定制和国际化。antd 以「确定性」、「意义感」、「生长性」和「自然感」为设计价值观,广泛应用于中后台系统开发,具有 TypeScript 支持、响应式布局和优雅的交互体验,能显著提升开发效率。其完善的文档和活跃的社区使其成为最受欢迎的 React UI 库之一。
第一步,项目结构
my-react-app/
├── public/ # 静态资源
│ ├── index.html # 主HTML文件
│ ├── favicon.ico # 网站图标
│ └── robots.txt # SEO配置
│
├── src/
│ ├── assets/ # 静态资产
│ │ ├── fonts/ # 字体文件
│ │ ├── images/ # 图片资源
│ │ └── styles/ # 全局样式
│ │
│ ├── components/ # 通用组件
│ │ ├── ui/ # 基础UI组件(Button, Input等)
│ │ ├── layout/ # 布局组件
│ │ └── shared/ # 业务通用组件
│ │
│ ├── features/ # 功能模块(推荐)
│ │ ├── auth/ # 认证模块
│ │ │ ├── components/ # 模块专用组件
│ │ │ ├── hooks/ # 模块hooks
│ │ │ ├── types/ # 类型定义
│ │ │ └── index.tsx # 模块入口
│ │ │
│ │ └── dashboard/ # 另一个功能模块
│ │
│ ├── hooks/ # 全局自定义hooks
│ ├── lib/ # 工具库/第三方配置
│ ├── pages/ # 页面组件(Next.js风格)
│ ├── routes/ # 路由配置
│ ├── stores/ # 状态管理(Zustand/Jotai)
│ ├── test/ # 测试相关
│ ├── types/ # 全局类型定义
│ ├── utils/ # 工具函数
│ ├── App.tsx # 根组件
│ ├── main.tsx # 应用入口
│ └── vite-env.d.ts # 类型声明
│
├── .env # 环境变量
├── .eslintrc # ESLint配置
├── .prettierrc # Prettier配置
├── tsconfig.json # TypeScript配置
└── package.json
如上是一个标准的react项目结构,我们也按照这样来组织文件。
第二步,布局
在实现具体页面之前,我们需要首先思考我们的页面的布局是怎样的
页面主要包含左边的菜单栏,点击某个菜单会在右边显示对应的页面
所以我们在src下面新建layout,在小虾米创建一个布局的文件AppLayOut.tsx
import React, { useState } from 'react';
import { Layout, Menu, theme } from 'antd';
import { UserOutlined } from '@ant-design/icons';
import { Outlet, useNavigate } from 'react-router-dom';
import './AppLayout.css';
const { Header, Content, Sider } = Layout;
const AppLayout: React.FC = () => {
// 导航钩子
const navigate = useNavigate();
// 菜单状态管理
const [collapsed, setCollapsed] = useState(false);
// 当前选中的菜单项
const [current, setCurrent] = useState('users');
// 处理菜单点击
const handleMenuClick = (e: { key: string }) => {
setCurrent(e.key);
navigate(`/${e.key}`);
};
// 获取Ant Design主题
const { token } = theme.useToken();
// 侧边栏菜单配置
const menuItems = [
{
key: 'users',
icon: <UserOutlined />,
label: '用户管理',
},
// 可以根据需要添加更多菜单项
];
return (
<Layout style={{ minHeight: '100vh' }}>
{/* 顶部导航栏 */}
<Header className="header" style={{ background: token.colorBgContainer }}>
<div className="logo">企业管理系统</div>
</Header>
<Layout>
{/* 侧边栏 */}
<Sider
collapsible
collapsed={collapsed}
onCollapse={(value) => setCollapsed(value)}
style={{ background: token.colorBgContainer }}
>
<Menu
mode="inline"
selectedKeys={[current]}
onClick={handleMenuClick}
items={menuItems}
style={{ height: '100%', borderRight: 0 }}
/>
</Sider>
{/* 主内容区域 */}
<Content
style={{
margin: '0 16px',
padding: 24,
minHeight: 280,
background: token.colorBgContainer,
}}
>
<Outlet />
</Content>
</Layout>
</Layout>
);
};
export default AppLayout;
可以看到实现左边的菜单栏是通过antd的Layout+Sider+Menu这样的层级来构建的。
我们还可以再增加一个角色管理的菜单
只需要再menuItems增加就可以了
{
key: 'roles',
icon: <TeamOutlined />,
label: '角色管理',
},
UserOutlined
含义:用户轮廓图标,通常用于表示与单个用户相关的功能或页面。
视觉特征:一般呈现为一个简化的人形轮廓,有时带有头部和肩部的线条。
使用场景:在项目中,常用于用户管理相关的菜单、按钮或页面标识,如代码中用于<UserOutlined /> 用户管理
菜单项,直观传达该菜单与用户数据管理相关。
TeamOutlined
含义:团队/群组轮廓图标,用于表示与团队、角色或用户组相关的功能或页面。
视觉特征:通常呈现为多个人形轮廓的组合(如两个或更多人形并排),强调群体性。
使用场景:在项目中,常用于角色管理、团队配置或用户组管理相关的菜单、按钮或页面标识,如代码中用于<TeamOutlined /> 角色管理
菜单项,明确传达该菜单与角色定义和权限分配相关。
第三步,实现UserList组件
import React, { useState } from 'react';
import { useQuery } from 'react-query';
import { Table, Pagination, Spin, Alert } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { UserOutlined } from '@ant-design/icons';
import './UserList.css';
import { fetchUsers } from '../services/userService';
import type { User, PaginationParams, UserResponse } from '../services/userService';
const UserList: React.FC = () => {
const [pagination, setPagination] = useState<PaginationParams>({
pageSize: 10,
currentPage: 1,
});
const { data, isLoading, isError, error } = useQuery<UserResponse>(
['users', pagination],
() => fetchUsers(pagination),
{
refetchOnWindowFocus: false,
keepPreviousData: true,
}
);
const handlePaginationChange = (current: number, pageSize: number) => {
setPagination({
currentPage: current,
pageSize,
});
};
const columns: ColumnsType<User> = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 80,
},
{
title: '邮箱',
dataIndex: 'email',
key: 'email',
width: 200,
},
{
title: '姓名',
dataIndex: 'name',
key: 'name',
width: 120,
},
];
if (isLoading) {
return (
<div className="loading-container">
<Spin size="large" />
<div className="loading-text">加载用户数据中...</div>
</div>
);
}
if (isError) {
return (
<Alert
message="获取用户数据失败"
description={error instanceof Error ? error.message : '网络请求异常'}
type="error"
showIcon
/>
);
}
if (!data || data.users.length === 0) {
return (
<Alert
message="暂无数据"
description="当前没有用户数据"
type="info"
showIcon
/>
);
}
return (
<div className="user-list-container">
<div className="table-header">
<h2> 用户管理</h2>
</div>
<Table
columns={columns}
dataSource={data.users}
rowKey="id"
pagination={{
current: pagination.currentPage,
pageSize: pagination.pageSize,
total: data.total,
onChange: handlePaginationChange,
showSizeChanger: true,
pageSizeOptions: ['10', '20', '50'],
showTotal: (total) => `共 ${total} 条记录`,
}}
bordered
/>
</div>
);
};
export default UserList;
这个业务组件需要调用后端的接口,为了不让组件有太多代码,我们把调用接口的代码放在另外的文件,这里我们再src下面新建一个services目录,再里面新建一个文件userService.ts
// 定义用户类型接口
export interface User {
id: number;
name: string;
email: string;
createTime: string;
}
// 定义分页参数接口
export interface PaginationParams {
pageSize: number;
currentPage: number;
}
// 定义API响应接口
export interface UserResponse {
users: User[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
// 获取用户列表数据
export const fetchUsers = async (
params: PaginationParams
): Promise<UserResponse> => {
try {
console.log(`【API调用】请求第${params.currentPage}页,每页${params.pageSize}条用户数据`);
// 构建查询参数
const queryParams = new URLSearchParams({
page: params.currentPage.toString(),
pageSize: params.pageSize.toString(),
});
// 使用fetch API发送请求
const response = await fetch(`/api/users/page?${queryParams}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
console.error('获取用户数据失败:', error);
// 错误处理,可以返回默认数据或抛出错误
throw error;
}
};
// 可以导出其他必要的工具或常量
// export default {...}
第四步:路由
路由我们一般放在src下面的routes文件夹下面,再routes下面创建一个routes.tsx存放项目的路由配置
import React from 'react';
import { createBrowserRouter } from 'react-router-dom';
import AppLayout from '../components/layout/AppLayout';
import UserList from '../pages/UserList';
import RoleList from '../pages/RoleList';
// 定义路由配置
const router = createBrowserRouter([
{
path: '/',
element: <AppLayout />,
children: [
{
path: 'users',
element: <UserList />,
},
{ path: 'roles', element: <RoleList />, },
{
path: '/',
},
],
},
]);
export default router;
最后,需要在main.tsx引入routes.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClientProvider } from 'react-query'
import './index.css'
import { RouterProvider } from 'react-router-dom'
import router from './routes/routes.tsx'
import queryClient from './queryClient.ts'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</StrictMode>,
)
目前的页面效果如下
虽然看起来样式不好看,但是总算能够把后端返回的数据成功渲染到页面了。
之所以这个表格的内容是偏左的,是因为,我们给的最外层的div宽度太小了,
.user-list-container {
width: 700px;
height: 100%;
display: flex;
}
在UserList.tsx里面引入antd的Space,Typography
const { Title, Text } = Typography;
接着给Table配置title
<Table
title={() => (
<Space align="center" style={{ display: 'flex', alignItems: 'center', width: '120px', }}>
<Title level={4} style={{ margin: 0, marginLeft: 8 }}>
用户管理
</Title>
</Space>
)}
columns={columns}
dataSource={data.users}
rowKey="id"
pagination={{
current: pagination.currentPage,
pageSize: pagination.pageSize,
total: data.total,
onChange: handlePaginationChange,
showSizeChanger: true,
pageSizeOptions: ['10', '20', '50'],
showTotal: (total) => `共 ${total} 条记录`,
}}
bordered
/>
并且适当的调整columns里面配置的宽度
const columns: ColumnsType<User> = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 80,
},
{
title: '邮箱',
dataIndex: 'email',
key: 'email',
width: 250,
},
{
title: '姓名',
dataIndex: 'name',
key: 'name',
width: 250,
},
];
此时的页面样式就比之前好看一些了,调整样式是一个细致活,需要慢慢调。
好吧,恭喜自己已经基本完成了分页查询用户信息的页面!!!