这节来写用户端的会议室列表:
现在,用户端首页是这样的:
需要在 / 下添加一个二级路由:
{
path: '/',
element: <Menu/>,
children: [
{
path: '/',
element: <MeetingRoomList/>
},
{
path: 'meeting_room_list',
element: <MeetingRoomList/>
},
{
path: 'booking_history',
element: <BookingHistory/>
}
]
}
然后分别实现这三个组件:
src/page/menu/Menu.tsx
import { Outlet, useLocation } from "react-router-dom";
import { Menu as AntdMenu, MenuProps } from 'antd';
import './menu.css';
import { MenuClickEventHandler } from "rc-menu/lib/interface";
import { router } from "../..";
const items: MenuProps['items'] = [
{
key: '1',
label: "会议室列表"
},
{
key: '2',
label: "预定历史"
}
];
const handleMenuItemClick: MenuClickEventHandler = (info) => {
let path = '';
switch(info.key) {
case '1':
path = '/meeting_room_list';
break;
case '2':
path = '/booking_history';
break;
}
router.navigate(path);
}
export function Menu() {
const location = useLocation();
function getSelectedKeys() {
if(location.pathname === '/meeting_room_list') {
return ['1']
} else if(location.pathname === '/booking_history') {
return ['2']
} else {
return ['1']
}
}
return <div id="menu-container">
<div className="menu-area">
<AntdMenu
defaultSelectedKeys={getSelectedKeys()}
items={items}
onClick={handleMenuItemClick}
/>
</div>
<div className="content-area">
<Outlet></Outlet>
</div>
</div>
}
引入 antd 的 Menu 实现菜单。
渲染的时候根据 useLocation 拿到的 pathname 来设置选中的菜单项。
点击菜单项的时候用 router.push 修改路径。
这里用到的 router 需要在 index.tsx 导出:
这些我们前面写过一遍。
menu.css 如下:
#menu-container {
display: flex;
flex-direction: row;
}
#menu-container .menu-area {
width: 200px;
}
然后是 src/pages/meeting_room_list/MeetingRoomList.tsx
export function MeetingRoomList() {
return <div>MeetingRoomList</div>
}
还有 src/pages/booking_history/BookingHistory.tsx
export function BookingHistory() {
return <div>BookingHistory</div>
}
在 index.tsx 里导入这些组件后,我们跑起来看看:
npm run start:dev
点击菜单项的路由切换,以及刷新选中对应菜单项,都没问题。
然后来写下列表页面,其实这个和管理端的会议室列表差不多:
我们把那个复制过来改改。
首先,在 interfaces.ts 添加用到的接口:
export async function searchMeetingRoomList(name: string, capacity: number, equipment: string, pageNo: number, pageSize: number) {
return await axiosInstance.get('/meeting-room/list', {
params: {
name,
capacity,
equipment,
pageNo,
pageSize
}
});
}
然后写下列表:
import { Badge, Button, Form, Input, Popconfirm, Table, message } from "antd";
import { useCallback, useEffect, useMemo, useState } from "react";
import './meeting_room_list.css';
import { ColumnsType } from "antd/es/table";
import { useForm } from "antd/es/form/Form";
import { searchMeetingRoomList } from "../../interface/interfaces";
interface SearchMeetingRoom {
name: string;
capacity: number;
equipment: string;
}
interface MeetingRoomSearchResult {
id: number,
name: string;
capacity: number;
location: string;
equipment: string;
description: string;
isBooked: boolean;
createTime: Date;
updateTime: Date;
}
export function MeetingRoomList() {
const [pageNo, setPageNo] = useState<number>(1);
const [pageSize, setPageSize] = useState<number>(10);
const [meetingRoomResult, setMeetingRoomResult] = useState<Array<MeetingRoomSearchResult>>([]);
const columns: ColumnsType<MeetingRoomSearchResult> = useMemo(() => [
{
title: '名称',
dataIndex: 'name'
},
{
title: '容纳人数',
dataIndex: 'capacity',
},
{
title: '位置',
dataIndex: 'location'
},
{
title: '设备',
dataIndex: 'equipment'
},
{
title: '描述',
dataIndex: 'description'
},
{
title: '添加时间',
dataIndex: 'createTime'
},
{
title: '上次更新时间',
dataIndex: 'updateTime'
},
{
title: '预定状态',
dataIndex: 'isBooked',
render: (_, record) => (
record.isBooked ? <Badge status="error">已被预订</Badge> : <Badge status="success">可预定</Badge>
)
},
{
title: '操作',
render: (_, record) => (
<div>
<a href="#">预定</a>
</div>
)
}
], []);
const searchMeetingRoom = useCallback(async (values: SearchMeetingRoom) => {
const res = await searchMeetingRoomList(values.name, values.capacity, values.equipment, pageNo, pageSize);
const { data } = res.data;
if(res.status === 201 || res.status === 200) {
setMeetingRoomResult(data.meetingRooms.map((item: MeetingRoomSearchResult) => {
return {
key: item.id,
...item
}
}))
} else {
message.error(data || '系统繁忙,请稍后再试');
}
}, []);
const [form ] = useForm();
useEffect(() => {
searchMeetingRoom({
name: form.getFieldValue('name'),
capacity: form.getFieldValue('capacity'),
equipment: form.getFieldValue('equipment')
});
}, [pageNo, pageSize]);
const changePage = useCallback(function(pageNo: number, pageSize: number) {
setPageNo(pageNo);
setPageSize(pageSize);
}, []);
return <div id="meetingRoomList-container">
<div className="meetingRoomList-form">
<Form
form={form}
onFinish={searchMeetingRoom}
name="search"
layout='inline'
colon={false}
>
<Form.Item label="会议室名称" name="name">
<Input />
</Form.Item>
<Form.Item label="容纳人数" name="capacity">
<Input />
</Form.Item>
<Form.Item label="设备" name="equipment">
<Input/>
</Form.Item>
<Form.Item label=" ">
<Button type="primary" htmlType="submit">
搜索会议室
</Button>
</Form.Item>
</Form>
</div>
<div className="meetingRoomList-table">
<Table columns={columns} dataSource={meetingRoomResult} pagination={ {
current: pageNo,
pageSize: pageSize,
onChange: changePage
}}/>
</div>
</div>
}
上面是 form、下面是 table。
调用搜索接口来搜索列表数据,然后设置到 table 的 dataSource。
每次分页变化的时候重新搜索。
然后 css 部分如下:
#meetingRoomList-container {
padding: 20px;
}
#meetingRoomList-container .meetingRoomList-form {
margin-bottom: 40px;
}
这样,列表页就完成了:
其实写这个模块的时候偷懒了,应该是写完后端接口,还要写 swager 文档。
然后前端根据 swagger 接口文档才能知道传啥参数,有啥返回值。
当时我们没写 swagger 文档,现在补一下:
打开后端项目,在 MeetingRoomController 里加一下 swagger 相关的装饰器:
首先加一下 delete 接口的:
@ApiParam({
name: 'id',
type: Number,
description: 'id'
})
@ApiResponse({
status: HttpStatus.OK,
description: 'success'
})
访问 http://localhost:3005/api-doc 可以看到这个接口的文档:
其实会议室的接口都是需要登录才能访问的,当时为了测试方便没有加,现在加一下:
添加 @RequireLogin 装饰器,标识接口需要登录。
并且添加对应的 @ApiBearerAuth 的 swagger 装饰器,代表需要添加 Bearer 的 header。
我们现在 postman 里测试下:
这时候直接调用 delete 接口就会提示需要先登录了。
然后我们登录下,拿到 token。
把它复制到 swagger 文档这里:
然后点击这个 try it out:
数据库中现在有 3 条记录:
把 id 为 10 那条删掉。
点击 execute:
swagger 会发送请求,下面会打印响应:
这时数据库里就没有这条记录了:
可以直接在 swagger 文档里测试接口,不用 postman 也行。
然后继续写下个接口的 swagger 文档:
这个接口的参数也是用 @ApiParam 标识,但它的响应不是 string,而是 MeetingRoom。
而我们现在并没有 vo,没地方标识属性:
所以要创建个 vo:
新建 src/meeting-room/vo/meeting-room.vo.ts
import { ApiProperty } from "@nestjs/swagger";
export class MeetingRoomVo {
@ApiProperty()
id: number;
@ApiProperty()
name: string;
@ApiProperty()
capacity: number;
@ApiProperty()
location: string;
@ApiProperty()
equipment: string;
@ApiProperty()
description: string;
@ApiProperty()
isBooked: boolean;
@ApiProperty()
createTime: Date;
@ApiProperty()
updateTime: Date;
}
然后加一下 swagger 的装饰器:
@ApiBearerAuth()
@ApiParam({
name: 'id',
type: Number,
})
@ApiResponse({
status: HttpStatus.OK,
description: 'success',
type: MeetingRoomVo
})
试一下:
接下来是 update 接口:
他有两种响应:
分别写一下:
@ApiBearerAuth()
@ApiBody({
type: UpdateMeetingRoomDto,
})
@ApiResponse({
status: HttpStatus.BAD_REQUEST,
description: '会议室不存在'
})
@ApiResponse({
status: HttpStatus.OK,
description: 'success'
})
然后在 dto 里标注下属性:
因为 update 的 dto 继承了 create 的 dto,所以那里也要加一下:
这样 swagger 文档就对了:
然后是 create 接口:
postman 里调用下是这样的:
所以 swagger 装饰器这样写:
@ApiBearerAuth()
@ApiBody({
type: CreateMeetingRoomDto,
})
@ApiResponse({
status: HttpStatus.BAD_REQUEST,
description: '会议室名字已存在'
})
@ApiResponse({
status: HttpStatus.OK,
type: MeetingRoomVo
})
这样 swagger 文档显示的就对了:
然后还有最后一个 list 接口:
它的响应是这样的:
首先创建响应数据的 vo:
src/meeting-room/vo/meeting-room-list.vo.ts
import { ApiProperty } from "@nestjs/swagger";
import { MeetingRoomVo } from "./meeting-room.vo";
export class MeetingRoomListVo {
@ApiProperty({
type: [MeetingRoomVo]
})
users: Array<MeetingRoomVo>;
@ApiProperty()
totalCount: number;
}
然后加一下 swagger 的装饰器:
@ApiBearerAuth()
@ApiQuery({
name: 'pageNo',
type: Number,
required: false
})
@ApiQuery({
name: 'pageSize',
type: Number,
required: false
})
@ApiQuery({
name: 'name',
type: String,
required: false
})
@ApiQuery({
name: 'capacity',
type: String,
required: false
})
@ApiQuery({
name: 'equipment',
type: String,
required: false
})
@ApiResponse({
type: MeetingRoomListVo
})
有同学说,不用把 service 里的返回值改成 MeetingRoomListVo 对象么?
不用,只要结构对上就行。
最后,在 controller 上加上个 @ApiTags,把下面的接口分到单独一组:
这样,用户端的会议室列表页面,swagger 文档就都完成了。
案例代码上传了小册仓库:
总结
这节我们写了用户端的会议室列表页,并且补了 swagger 文档。
用户端列表页就是调用 list 接口,通过 form 来填写参数,通过 table 展示结果。
swagger 文档部分就是分别通过 @ApiPram @ApiQuery @ApiBody @ApiResponse 标识接口,通过 @ApiProperty 标识 dto 和 vo 的属性。
这样,会议室模块的前端后端就都完成了。