NestJS实战-前后端联调
本文介绍 NestJS 实战前后端联调的过程:前端请求封装、页面权限、按钮权限管理、页面的增删改查接口联调。
供自己以后查漏补缺,也欢迎同道朋友交流学习。
引言
之前几章已经介绍过 NestJS 实战的后端开发了,本章主要介绍前后端联调的一些过程,例如封装前端请求 JS、设置 proxy 解决接口跨域、路由权限配置、按钮权限判断、用户/文章/专栏 3 个模块功能实现。
前端开发的技术栈和安装不介绍了,可以看NestJS实战-前端搭建
接口请求封装
封装 request
在 src/utils 目录下新增 request.ts,使用 fetch 封装 handleRequest 请求处理、GET、POST、PATCH、DELETE请求封装:
// src/utils/request.ts
import { message } from 'antd';
import { downloadBlob, transformBody, transformJsonToQuery } from './index';
const BASE_URL = '/api'; // 这个API基础URL可以根据环境进行配置
// 一个处理请求的函数,返回一个Promise
async function handleRequest<T>(url: string, options?: RequestInit): Promise<T> {
try {
// 请求全路径
const fullUrl = `${BASE_URL}${url}`;
// 写入token
let headers = {
'Content-Type': options?.headers?.['Content-Type'] || 'application/json',
...options.headers,
};
if (!fullUrl.includes('/auth/login')) {
const token = localStorage.getItem('cms-project-token');
headers.Authorization = `Bearer ${token}`;
}
const response = await fetch(fullUrl, {
...options,
headers,
});
if (options?.responseType === 'blob') {
const blobData = await response.blob();
const contentDisposition = response.headers.get('Content-Disposition');
console.log('@@@ contentDisposition', contentDisposition);
if (blobData) {
try {
downloadBlob(blobData, contentDisposition);
message.success('操作成功');
return { message: '操作成功' };
} catch {
message.error('操作失败');
return { message: '操作失败' };
}
}
message.error('操作失败');
return { message: '操作失败' };
}
const data = await response.json();
if (!response.ok) {
message.error(data?.msg || '服务调用失败');
return null;
}
return data.result;
} catch (error) {
message.error('服务异常:', error);
throw error;
}
}
// GET请求
export async function get<T>(url: string, body: object): Promise<T> {
// 过滤字符串 去除前后空格
const query = transformBody(body);
// 过滤query再转成字符串
const queryStr = query ? transformJsonToQuery(query) : '';
// 请求路由拼接
const queryUrl = queryStr ? `${url}?${queryStr}` : url;
return handleRequest<T>(queryUrl, {
method: 'GET',
});
}
// POST请求
export async function post<T>(url: string, body: object, config): Promise<T> {
const filterBody = body ? transformBody(body) : undefined;
return handleRequest<T>(url, {
method: 'POST',
body: JSON.stringify(filterBody),
...config,
});
}
// PUT请求
export async function patch<T>(url: string, body: object): Promise<T> {
const filterBody = body ? transformBody(body) : undefined;
return handleRequest<T>(url, {
method: 'PATCH',
body: JSON.stringify(filterBody),
});
}
// DELETE请求
export async function del<T>(url: string, body: object): Promise<T> {
const filterBody = body ? transformBody(body) : undefined;
return handleRequest<T>(url, {
method: 'DELETE',
body: JSON.stringify(filterBody),
});
}
封装 utils
在 src/utils/index.ts 封装工具方法:
// 把json对象转换成str且过滤掉空值
export function transformJsonToQuery(json) {
if (!json) return '';
const params = new URLSearchParams();
for (const [key, value] of Object.entries(json)) {
if (value !== null && value !== undefined && value.toString().trim() !== '') {
params.append(key, value);
}
}
return params.toString();
}
// 把json对象、过滤undefined、过滤字符串 去除前后空格
export function transformBody(body) {
if (!body) return null;
let bodyParams = {};
for (const [key, value] of Object.entries(body)) {
// 过滤undefined
let val = value === undefined ? null : value;
// 过滤字符串 去除前后空格
if (typeof value === 'string') {
val = value.trim();
}
// null、空字符串、0、false都可以通过
bodyParams[key] = val;
}
return bodyParams;
}
// 解析contentDisposition获取filename
export function parseContentDisposition(contentDisposition) {
// 使用正则表达式匹配filename的值
const matches = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/.exec(contentDisposition);
if (matches !== null && matches[1]) {
// 解码文件名
return decodeURIComponent(matches[1].replace(/['"]/g, ''));
}
return null;
}
// 下载Blob文件流
export function downloadBlob(blob, contentDisposition) {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
if (contentDisposition) {
a.download = parseContentDisposition(contentDisposition); // 设置文件名
}
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
a.remove();
}
封装 业务请求库
在 src 下新建 service 目录,封装 user.ts 存放用户和权限接口:
// src/service/user.ts
import { get, post, del, patch } from "../utils/request";
// 登录
export const login = async (body) => {
return await post('/auth/login', body)
}
// 当前用户
export const queryCurrentUser = async () => {
return await get('/auth/queryCurrentUser')
}
// 帐号登出
export const logout = async () => {
return await post('/auth/logout')
}
// 查询角色列表
export const queryRoleList = async () => {
return await get('/user/roleList')
}
// 创建用户
export const createUser = async (body) => {
return await post('/user', body)
}
// 删除用户
export const deleteUser = async (id) => {
return await del(`/user/${id}`)
}
// 批量删除用户
export const batchDeleteUser = async (body) => {
return await del('/user', body)
}
// 更新用户
export const updateUser = async (id, body) => {
return await patch(`/user/${id}`, body)
}
// 查询用户
export const queryUser = async (id) => {
return await get(`/user/${id}`)
}
// 查询用户列表
export const queryUserList = async (queryBody) => {
return await get('/user', queryBody)
}
// 重置用户密码
export const resetPassword = async (body) => {
return await post('/user/resetPassword', body)
}
// 用户修改密码
export const userUpdatePassword = async (body) => {
return await post('/user/userUpdatePassword', body)
}
// 用户表勾选导出
export const userSelectExport = async (body) => {
return await post('/user/userSelectExport', body, {
responseType: 'blob'
})
}
// 用户表查询导出
export const userSearchExport = async (body) => {
return await post('/user/userSearchExport', body, {
responseType: 'blob'
})
}
article-column.ts 存放文章和专栏相关接口:
// src/service/article-column.ts
import { get, post, del, patch } from "../utils/request";
// 创建文章
export const createArticle = async (body) => {
return await post('/article', body)
}
// 分页查询文章列表
export const queryArticleList = async (queryBody) => {
return await get('/article', queryBody)
}
// 根据ID查询文章详情
export const queryArticleDetail = async (id) => {
return await get(`/article/${id}`)
}
// 根据ID修改文章
export const updateArticle = async (id, body) => {
return await patch(`/article/${id}`, body)
}
// 删除文章
export const deleteArticle = async (id) => {
return await del(`/article/${id}`)
}
// 创建专栏
export const createColumn = async (body) => {
return await post('/specialColumn', body)
}
// 专栏列表查询-不分页
export const queryColumnList = async () => {
return await get('/specialColumn')
}
// 专栏列表下拉项
export const columnListForSelect = async () => {
return await get('/specialColumn/columnListForSelect')
}
// 根据ID查询专栏
export const queryColumnDetail = async (id) => {
return await get(`/specialColumn/${id}`)
}
// 根据ID修改专栏
export const updateColumnById = async (id, body) => {
return await patch(`/specialColumn/${id}`, body)
}
// 删除专栏
export const deleteColumnById = async (id) => {
return await del(`/specialColumn/${id}`)
}
// 根据专栏id查询文章列表
export const queryArticleListByColumnId = async (id, queryBody) => {
return await get(`/specialColumn/queryArticleListByColumnId/${id}`, queryBody)
}
// 查询非该专栏的文章列表
export const queryOtherArticleListByColumnId = async (id, queryBody) => {
return await get(`/specialColumn/queryOtherArticleListByColumnId/${id}`, queryBody)
}
// 批量取消收录
export const cancelArticleInclusion = async (body) => {
return await post('/specialColumn/cancelArticleInclusion', body)
}
// 文章批量收录
export const addArticleInclusion = async (body) => {
return await post('/specialColumn/addArticleInclusion', body)
}
接口代理 proxy
直接访问肯定会跨域,那需要设下代理,在 config/proxy.ts 文件下设置:
/**
* @name 代理的配置
* @see 在生产环境 代理是无法生效的,所以这里没有生产环境的配置
* @doc https://umijs.org/docs/guides/proxy
*/
export default {
/**
* @name 详细的代理配置
* @doc https://github.com/chimurai/http-proxy-middleware
*/
dev: {
// localhost:8000/api/** -> http://localhost:8004/**
'/api/': {
target: 'http://localhost:8004',
changeOrigin: true,
pathRewrite: {
'^/api': '', // 重写路径:去掉路径中的 `/api`
},
},
},
test: {
// localhost:8000/api/** -> http://localhost:8004/**
'/api/': {
target: 'http://localhost:8004',
changeOrigin: true,
pathRewrite: { '^/api': '' }, // 重写路径:去掉路径中的 `/api`
},
},
pre: {
// localhost:8000/api/** -> http://localhost:8004/**
'/api/': {
target: 'http://localhost:8004',
changeOrigin: true,
pathRewrite: { '^/api': '' }, // 重写路径:去掉路径中的 `/api`
},
},
};
因为我没用到 env 环境配置,其实生效的都是 dev 配置,如果要发布到线上还需要其他配置。
路由及页面级权限配置
页面权限配置
在 src/access.ts 中封装页面权限:
export default function access(initialState: { currentUser?: API.CurrentUser } | undefined) {
const { currentUser } = initialState ?? {};
return {
canSystemAdmin: currentUser?.roleWeight === 0,
canAdmin: currentUser?.roleWeight <= 1,
canUser: currentUser?.roleWeight <= 2,
canViewer: currentUser?.roleWeight === 3,
};
}
路由配置
在 config/routes.ts 中封装路由,使用 access 权限控制:
export default [
{
name: 'login',
path: '/login',
layout: false,
component: './Login',
},
{
exact: true,
path: '/welcome',
name: 'welcome',
icon: 'smile',
component: './Welcome',
},
{
exact: true,
name: '账号管理',
icon: 'user',
path: '/account',
component: './Account',
access: 'canAdmin',
},
{
exact: true,
name: '账号管理',
path: '/updatePassword',
component: './UpdatePassword',
hideInMenu: true,
},
{
exact: true,
name: '文章管理',
icon: 'read',
path: '/article',
component: './Article/List',
},
{
exact: true,
name: '文章详情',
path: '/article/detail',
component: './Article/Detail',
access: 'canUser',
hideInMenu: true,
},
{
exact: true,
name: '文章详情',
path: '/article/detail/:id',
component: './Article/Detail',
access: 'canUser',
hideInMenu: true,
},
{
exact: true,
name: '专栏管理',
path: '/column',
icon: 'database',
component: './Column/List',
},
{
exact: true,
name: '专栏详情',
path: '/column/detail/:id',
component: './Column/Detail',
hideInMenu: true,
},
{
path: '/',
redirect: '/welcome',
},
{
path: '*',
layout: false,
component: './404',
},
];
主应用配置 app.tsx
在 app.tsx 主要配置 getInitialState 和 layout。
import type { Settings as LayoutSettings } from '@ant-design/pro-components';
import type { RunTimeLayoutConfig } from '@umijs/max';
import { history } from '@umijs/max';
import { App } from 'antd';
import defaultSettings from '../config/defaultSettings';
import { AvatarDropdown } from './components';
import { queryCurrentUser } from './service/user';
const loginPath = '/login';
export async function getInitialState(): Promise<{
settings?: Partial<LayoutSettings>;
currentUser?: object;
loading?: boolean;
fetchUserInfo?: () => Promise<object | undefined>;
}> {
const fetchUserInfo = async () => {
const user = await queryCurrentUser();
if (!user) {
history.push(loginPath);
return null;
}
return user;
};
const { location } = history;
if (location.pathname !== loginPath && location.pathname !== '/' ) {
const currentUser = await fetchUserInfo();
return {
fetchUserInfo,
currentUser,
settings: defaultSettings as Partial<LayoutSettings>,
};
}
return {
fetchUserInfo,
settings: defaultSettings as Partial<LayoutSettings>,
};
}
export const layout: RunTimeLayoutConfig = ({ initialState }) => {
return {
avatarProps: {
title: `${initialState?.currentUser?.username}-${initialState?.currentUser?.account}`,
render: (_, avatarChildren) => {
return <AvatarDropdown>{avatarChildren}</AvatarDropdown>;
},
},
onPageChange: () => {
const { location } = history;
// 如果没有登录,重定向到 login
if (!initialState?.currentUser && location.pathname !== loginPath) {
history.push(loginPath);
}
},
menuHeaderRender: undefined,
// 自定义 403 页面
// unAccessible: <div>unAccessible</div>,
// 增加一个 loading 的状态
childrenRender: (children) => {
// if (initialState?.loading) return <PageLoading />;
return (
<App>
<div>{children}</div>
</App>
);
},
...initialState?.settings,
};
};
功能模块
页面的功能联调我就仅展示下账号管理,其他可以去我的仓库地址里去看
文章管理
文章列表主页 src/pgaes/Account/index.tsx:
// src/pgaes/Account/index.tsx
import {
batchDeleteUser,
createUser,
queryUserList,
resetPassword,
updateUser,
userSearchExport,
userSelectExport,
} from '@/service/user';
import type { ActionType, ProColumns } from '@ant-design/pro-components';
import { PageContainer, ProTable } from '@ant-design/pro-components';
import { useModel } from '@umijs/max';
import { App, Button, message, Space } from 'antd';
import React, { useEffect, useRef, useState } from 'react';
import { defaultColumns } from './components/columns';
import RestPasswordModal from './components/RestPasswordModal';
import UserModal from './components/UserModal';
const AccountList: React.FC = () => {
const { modal } = App.useApp();
const actionRef = useRef<ActionType>();
const { initialState } = useModel('@@initialState');
const currentUser = initialState?.currentUser || {};
const [selectedKeys, setSelectedKeys] = useState<API.RuleListItem[]>([]);
const [columns, setColumns] = useState<ProColumns<API.RuleListItem>[]>([]);
const [totalCount, setTotalCount] = useState<number>(0);
const [queryParams, setQueryParams] = useState<object>(null);
// 操作标识
const [operateType, setOperateType] = useState('');
const [userOpen, setUserOpen] = useState(false);
const [userModalInfo, setUserModalInfo] = useState({});
// 重置密码-操作标识
const [restPasswordOpen, setRestPasswordOpen] = useState(false);
const [restPasswordModalInfo, setRestPasswordModalInfo] = useState({});
// 添加账号 - 弹窗
const prevAddUser = () => {
setOperateType('add');
setUserModalInfo({});
setUserOpen(true);
};
// 删除账号
const deleteUser = (ids) => {
modal.confirm({
title: '删除提示',
content: `确定删除id为${ids.join('、')}的账号吗`,
onOk: async () => {
const res = await batchDeleteUser({ ids });
if (res) {
message.success('删除成功');
actionRef.current?.reloadAndRest();
return;
}
},
});
};
// 修改账号-弹窗
const prevUpdateUser = (item) => {
setOperateType('edit');
setUserModalInfo(item);
setUserOpen(true);
};
// 用户新建/修改
const userOperateFinish = async (values) => {
const { username, roleId } = values;
let res = null;
if (operateType === 'add') {
res = await createUser(values);
} else if (operateType === 'edit') {
res = await updateUser(userModalInfo.id, { username, roleId });
}
if (res) {
message.success('操作成功');
setUserOpen(false);
setUserModalInfo({});
actionRef?.current?.reloadAndRest();
}
};
// 重置密码-弹窗
const prevRestPassword = (item) => {
console.log(item);
setRestPasswordModalInfo(item);
setRestPasswordOpen(true);
};
// 重置密码-提交
const restPasswordFinish = async (values) => {
const { newPassword, confirmPassword } = values;
if (newPassword !== confirmPassword) {
message.error('确认密码与新密码必须一致,请修改');
return;
}
const res = await resetPassword({ id: restPasswordModalInfo.id, newPassword, confirmPassword });
if (res) {
message.success('操作成功');
setRestPasswordOpen(false);
setRestPasswordModalInfo({});
}
};
// 查询导出
const searchExport = () => {
if (!totalCount) {
message.error('列表数据为空,无法查询导出');
return;
}
userSearchExport(queryParams);
};
// 批量导出
const selectExport = (selectedKeys) => {
console.log('@@@ selectedKeys', selectedKeys);
if (!selectedKeys?.length) {
message.error('请勾选数据');
return;
}
userSelectExport({ ids: selectedKeys });
};
useEffect(() => {
// 访客不展示操作按钮
if (currentUser.roleWeight === 3) {
defaultColumns.pop();
} else {
defaultColumns[defaultColumns.length - 1].render = (text, item) => {
// 当前权限权重要小于 该条权重才能就行操作 权重越小 权限越大
return currentUser.roleWeight < item.roleWeight || currentUser.roleWeight === 0 ? (
<>
<Button type="link" onClick={() => prevUpdateUser(item)}>
修改
</Button>
<Button type="link" onClick={() => deleteUser([item.id])}>
删除
</Button>
<Button type="link" onClick={() => prevRestPassword(item)}>
重置密码
</Button>
</>
) : null;
};
}
setColumns(defaultColumns);
}, []);
// 表格查询
const tableSearch = async (params) => {
let values = {
...params,
pageNum: params.current,
pageSize: params.pageSize,
};
delete values.current;
const res = await queryUserList(values);
setQueryParams(values);
setTotalCount(res?.total || 0);
return { success: true, data: res?.list || [], total: res?.total || 0 };
};
return (
<PageContainer>
<ProTable
actionRef={actionRef}
rowKey={(item) => item.id}
search={{
labelWidth: 'auto',
defaultCollapsed: false,
}}
request={tableSearch}
columns={columns}
toolBarRender={() =>
currentUser.roleWeight === 3
? []
: [
<Button key="add" type="primary" onClick={prevAddUser}>
添加账号
</Button>,
<Button key="searchExport" type="primary" onClick={searchExport}>
查询导出
</Button>,
]
}
tableAlertOptionRender={() =>
currentUser.roleWeight === 3 ? null : (
<Space>
<Button key="delete" type="link" danger onClick={() => deleteUser(selectedKeys)}>
批量删除
</Button>
<Button key="selectExport" type="link" onClick={() => selectExport(selectedKeys)}>
批量导出
</Button>
</Space>
)
}
scroll={{ x: 'max-content' }}
rowSelection={
currentUser.roleWeight === 3
? false
: {
onChange: (_) => {
setSelectedKeys(_);
},
}
}
/>
<UserModal
modalInfo={userModalInfo}
operateType={operateType}
open={userOpen}
onOpenChange={setUserOpen}
onFinish={userOperateFinish}
/>
<RestPasswordModal
modalInfo={restPasswordModalInfo}
open={restPasswordOpen}
onOpenChange={setRestPasswordOpen}
onFinish={restPasswordFinish}
/>
</PageContainer>
);
};
export default AccountList;
封装业务的列表字段值:
// src/pages/Account/components/columns.ts
import { queryRoleList } from '@/service/user';
import type { ProColumns } from '@ant-design/pro-components';
export const getRoleList = async () => {
const list = await queryRoleList()
return list.map(role => ({
...role,
key: role.id,
value: role.id,
label: role.name,
}))
}
export const defaultColumns: ProColumns<API.RuleListItem>[] = [
{
title: '账号ID',
dataIndex: 'id',
key: 'id',
width: 70,
},
{
title: '账号',
dataIndex: 'account',
width: 170,
},
{
title: '用户名',
dataIndex: 'username',
width: 120,
},
{
title: '权限',
dataIndex: 'roleId',
width: 110,
valueType: 'select',
request: async () => await getRoleList(),
render: (text, item) => item?.roleName,
},
{
title: '创建人',
dataIndex: 'createdByAccount',
search: false,
width: 150,
},
{
title: '创建时间',
dataIndex: 'createdTime',
search: false,
width: 160,
},
{
title: '更新人',
dataIndex: 'updatedByAccount',
search: false,
width: 150,
},
{
title: '更新时间',
dataIndex: 'updatedTime',
search: false,
width: 160,
},
{
title: '操作',
dataIndex: 'operate',
fixed: 'right',
width: 230,
search: false,
},
];
新建/编辑用户弹窗:
// src/pages/Account/components/UserModal.tsx
import { ModalForm, ProFormSelect, ProFormText } from '@ant-design/pro-components';
import { getRoleList } from './columns';
const UserModal: React.FC = ({ modalInfo, operateType, open, onOpenChange, onFinish }) => {
// 提交按钮
const prevFinish = (values) => {
onFinish(values);
};
if (!open) {
return null;
}
return (
<ModalForm
layout="horizontal"
width={480}
title={`${operateType === 'add' ? '添加' : '修改'}用户账号`}
open={open}
onOpenChange={onOpenChange}
modalProps={{
destroyOnClose: true,
onCancel: () => onOpenChange(false),
}}
onFinish={prevFinish}
>
<ProFormText
name="account"
label="账号"
initialValue={modalInfo?.account}
disabled={operateType === 'edit'}
placeholder="请输入账号(邮箱格式)"
rules={[
{ required: true, message: '请输入账号' },
{ type: 'email', message: '请输入账号(邮箱格式)' },
]}
fieldProps={{
maxLength: 50,
}}
/>
<ProFormText
name="username"
label="用户名"
initialValue={modalInfo?.username}
rules={[{ required: true, message: '请输入用户名' }]}
fieldProps={{
maxLength: 50,
}}
/>
<ProFormSelect
name="roleId"
label="权限"
initialValue={modalInfo?.roleId}
rules={[{ required: true, message: '请选择权限' }]}
request={getRoleList}
/>
{operateType === 'add' ? (
<ProFormText
name="password"
label="密码"
width={240}
fieldProps={{
type: 'password',
minLength: 8,
maxLength: 16,
}}
placeholder="请输入8-16位数字+字母的密码"
rules={[
{ required: true, message: '请输入密码' },
{
pattern: /^(?=.*[a-zA-Z])(?=.*\d).{8,16}$/,
message: '请输入8-16位数字+字母的密码',
},
]}
/>
) : null}
</ModalForm>
);
};
export default UserModal;
重置校验密码弹窗:
import { ModalForm, ProFormText } from '@ant-design/pro-components';
const RestPasswordModal: React.FC = ({ modalInfo, open, onOpenChange, onFinish }) => {
// 提交按钮
const prevFinish = (values) => {
onFinish(values);
};
if (!open) {
return null;
}
return (
<ModalForm
layout="horizontal"
width={480}
title="重置密码"
open={open}
onOpenChange={onOpenChange}
modalProps={{
destroyOnClose: true,
onCancel: () => onOpenChange(false),
}}
onFinish={prevFinish}
>
<ProFormText
name="account"
label="账号"
initialValue={modalInfo?.account}
disabled
placeholder="请输入邮箱格式的账号"
rules={[
{ required: true, message: '请输入账号' },
{ type: 'email', message: '请输入邮箱格式的账号' },
]}
fieldProps={{
maxLength: 50,
}}
/>
<ProFormText
name="newPassword"
label="新密码"
width={240}
fieldProps={{
type: 'password',
minLength: 8,
maxLength: 16,
}}
placeholder="请输入8-16位数字+字母的密码"
rules={[
{ required: true, message: '请输入新密码' },
{
pattern: /^(?=.*[a-zA-Z])(?=.*\d).{8,16}$/,
message: '请输入8-16位数字+字母的密码',
},
]}
/>
<ProFormText
name="confirmPassword"
label="确认密码"
width={240}
fieldProps={{
type: 'password',
minLength: 8,
maxLength: 16,
}}
placeholder="确认密码与新密码一致"
rules={[
{ required: true, message: '请输入确认密码' },
{
pattern: /^(?=.*[a-zA-Z])(?=.*\d).{8,16}$/,
message: '请输入8-16位数字+字母的密码',
},
]}
/>
</ModalForm>
);
};
export default RestPasswordModal;
文章管理和专栏关联
代码就不展示了,可以去代码库去看
实战合集地址
- NestJS实战-产品需求规划
- NestJS实战-前端搭建
- NestJS实战-后端开发-全局配置
- NestJS实战-后端开发-用户及权限模块
- NestJS实战-后端开发-文章专栏功能模块
- NestJS实战-前后端联调
- NestJS实战-系统总结