用户相关的功能,后端代码、用户端前端代码、管理端前端代码都写完了。
这节开始写下一个模块:会议室管理。
看下当时分析的需求:
以及分析出来的接口:
| 接口路径 | 请求方式 | 描述 |
|---|---|---|
| /meeting_room/list | GET | 会议室列表 |
| /meeting_room/delete/:id | DELETE | 会议室删除 |
| /meeting_room/update/:id | PUT | 会议室更新 |
| /meeting_room/create | POST | 会议室新增 |
| /meeting_room/search | GET | 会议室搜索 |
一共 5 个接口。
在后端项目下创建一个 meeting-room 模块:
nest g resource meeting-room修改下 meeting-room.entity.ts
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from "typeorm";
@Entity()
export class MeetingRoom {
@PrimaryGeneratedColumn({
comment: '会议室ID'
})
id: number;
@Column({
length: 50,
comment: '会议室名字'
})
name: string;
@Column({
comment: '会议室容量'
})
capacity: number;
@Column({
length: 50,
comment: '会议室位置'
})
location: string;
@Column({
length: 50,
comment: '设备',
default: ''
})
equipment: string;
@Column({
length: 100,
comment: '描述',
default: ''
})
description: string;
@Column({
comment: '是否被预订',
default: false
})
isBooked: boolean;
@CreateDateColumn({
comment: '创建时间'
})
createTime: Date;
@UpdateDateColumn({
comment: '更新时间'
})
updateTime: Date;
}这个是根据当时我们的数据库设计来的:
| 字段名 | 数据类型 | 描述 |
|---|---|---|
| id | INT | 会议室ID |
| name | VARCHAR(50) | 会议室名字 |
| capacity | INT | 会议室容量 |
| location | VARCHAR(50) | 会议室位置 |
| equipment | VARCHAR(50) | 设备 |
| description | VARCHAR(100) | 描述 |
| is_booked | BOOLEAN | 是否被预订 |
| create_time | DATETIME | 创建时间 |
| update_time | DATETIME | 更新时间 |
在 entities 里引入下:
把服务跑起来:
npm run start:dev会生成建表语句:
在 mysql workbench 里点击刷新就可以看到这个表:
点击第二个图标,查看表定义:
没啥问题。
然后先来初始化下数据:
在 MeetingRoomModule 引入 MeetingRoom 的 Repository:
@InjectRepository(MeetingRoom)
private repository: Repository<MeetingRoom>;
initData() {
}然后来写初始化数据的逻辑:
initData() {
const room1 = new MeetingRoom();
room1.name = '木星';
room1.capacity = 10;
room1.equipment = '白板';
room1.location = '一层西';
const room2 = new MeetingRoom();
room2.name = '金星';
room2.capacity = 5;
room2.equipment = '';
room2.location = '二层东';
const room3 = new MeetingRoom();
room3.name = '天王星';
room3.capacity = 30;
room3.equipment = '白板,电视';
room3.location = '三层东';
this.repository.save([room1, room2, room3])
}还需要像 user 模块那样,添加一个 init-data 的路由,浏览器访问么?
不用,可以用 repl 的模式来跑:
添加 src/repl.ts
import { repl } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const replServer = await repl(AppModule);
replServer.setupHistory(".nestjs_repl_history", (err) => {
if (err) {
console.error(err);
}
});
}
bootstrap();然后在 package.json 里添加一个 scripts
"repl": "nest start --watch --entryFile repl",把服务停掉,执行:
npm run repl先查看下 MeetingRoomService 的方法:
methods(MeetingRoomService)然后调用下:
get(MeetingRoomService).initData()打印了 3 条 insert 语句,之后又把它 select 出来返回:
数据库里也可以看到插入的三条数据:
很多地方都是用的 repository.save,但如果你确定是 insert 或者 update 的时候,直接用 repository.insert 或者 repository.update 更好。
先 truncate table 清空数据:
刷新可以看到,确实清空了:
把 save 换成 insert:
重新跑一下:
get(MeetingRoomService).initData()现在是批量插入了 3 条数据。
所以说确定是 insert 的时候 用 insert 比用 save 更好,能够批量插入数据。
同理,确定是 update 的时候,也不要用 save,因为它会先 select 一次,再确定是 update 还是 insert。
然后写一下 CRUD 的接口:
| 接口路径 | 请求方式 | 描述 |
|---|---|---|
| /meeting_room/list | GET | 会议室列表 |
| /meeting_room/delete/:id | DELETE | 会议室删除 |
| /meeting_room/update/:id | PUT | 会议室更新 |
| /meeting_room/create | POST | 会议室新增 |
| /meeting_room/search | GET | 会议室搜索 |
在 MeetingRoomController 增加一个 list 接口:
import { Controller, Get, Post, Body, Patch, Param, Delete, Query, DefaultValuePipe } from '@nestjs/common';
import { MeetingRoomService } from './meeting-room.service';
import { generateParseIntPipe } from 'src/utils';
@Controller('meeting-room')
export class MeetingRoomController {
constructor(private readonly meetingRoomService: MeetingRoomService) {}
@Get('list')
async list(
@Query('pageNo', new DefaultValuePipe(1), generateParseIntPipe('pageNo')) pageNo: number,
@Query('pageSize', new DefaultValuePipe(2), generateParseIntPipe('pageSize')) pageSize: number,
) {
return await this.meetingRoomService.find(pageNo, pageSize);
}
}然后在 MeetingRoomService 添加 find 方法:
async find(pageNo: number, pageSize: number) {
if(pageNo < 1) {
throw new BadRequestException('页码最小为 1');
}
const skipCount = (pageNo - 1) * pageSize;
const [meetingRooms, totalCount] = await this.repository.findAndCount({
skip: skipCount,
take: pageSize
});
return {
meetingRooms,
totalCount
}
}重新跑下服务:
npm run start:dev测试下:
没啥问题。
然后是 create 接口:
@Post('create')
async create(@Body() meetingRoomDto: CreateMeetingRoomDto) {
return await this.meetingRoomService.create(meetingRoomDto);
}修改下 create-meeting-room.dto.ts
import { IsNotEmpty, MaxLength } from "class-validator";
export class CreateMeetingRoomDto {
@IsNotEmpty({
message: '会议室名称不能为空'
})
@MaxLength(10, {
message: '会议室名称最长为 10 字符'
})
name: string;
@IsNotEmpty({
message: '容量不能为空'
})
capacity: number;
@IsNotEmpty({
message: '位置不能为空'
})
@MaxLength(50, {
message: '位置最长为 50 字符'
})
location: string;
@IsNotEmpty({
message: '设备不能为空'
})
@MaxLength(50, {
message: '设备最长为 50 字符'
})
equipment: string;
@IsNotEmpty({
message: '描述不能为空'
})
@MaxLength(100, {
message: '描述最长为 100 字符'
})
description: string;
}在 MeetingRoomService 里添加 create 方法:
async create(meetingRoomDto: CreateMeetingRoomDto) {
return await this.repository.insert(meetingRoomDto);
}测试下:
这个错误的格式是在 exception filter 里自定义的。
创建成功时会返回创建成功的会议室信息。
这个会议室名其实应该保持唯一,加一下校验逻辑:
async create(meetingRoomDto: CreateMeetingRoomDto) {
const room = await this.repository.findOneBy({
name: meetingRoomDto.name
});
if(room) {
throw new BadRequestException('会议室名字已存在');
}
return await this.repository.save(meetingRoomDto);
}这样就能保证会议室名字不会重复。
然后实现下 update 接口:
@Put('update')
async update(@Body() meetingRoomDto: UpdateMeetingRoomDto) {
return await this.meetingRoomService.update(meetingRoomDto);
}这里的 UpdateMeetingRoomDto 和 CreateMeetingRoomDto 差不多,用 PartialType 来复用它的字段:
import { ApiProperty, PartialType } from '@nestjs/swagger';
import { CreateMeetingRoomDto } from './create-meeting-room.dto';
import { IsNotEmpty } from 'class-validator';
export class UpdateMeetingRoomDto extends PartialType(CreateMeetingRoomDto) {
@ApiProperty()
@IsNotEmpty({
message: 'id 不能为空'
})
id: number;
}
然后在 MeetingRoomService 实现 update 方法:
async update(meetingRoomDto: UpdateMeetingRoomDto) {
const meetingRoom = await this.repository.findOneBy({
id: meetingRoomDto.id
})
if(!meetingRoom) {
throw new BadRequestException('会议室不存在');
}
meetingRoom.capacity = meetingRoomDto.capacity;
meetingRoom.location = meetingRoomDto.location;
meetingRoom.name = meetingRoomDto.name;
if(meetingRoomDto.description) {
meetingRoom.description = meetingRoomDto.description;
}
if(meetingRoomDto.equipment) {
meetingRoom.equipment = meetingRoomDto.equipment;
}
await this.repository.update({
id: meetingRoom.id
} , meetingRoom);
return 'success';
}先查询一下,如果查不到就返回会议室不存在。
否则,更新会议室信息。
这里的 description 和 equipment 因为可以不传,所以要判断下,传了才更新。
测试下:
服务端打印了 select 和 update 的 sql:
数据库中也更新了:
然后还需要一个回显的接口,用在修改的时候回显数据:
添加一个 :id 接口:
@Get(':id')
async find(@Param('id') id: number) {
return await this.meetingRoomService.findById(id);
}然后在 service 实现这个方法:
async findById(id: number) {
return this.repository.findOneBy({
id
});
}测试下:
然后是 delete 接口:
@Delete(':id')
async delete(@Param('id') id: number) {
return await this.meetingRoomService.delete(id);
}在 service 实现 delete 方法:
@InjectEntityManager()
entityManager: EntityManager;
async delete(id: number) {
const bookings = await this.entityManager.findBy(Booking, {
room: {
id: id
}
});
for(let i = 0; i< bookings.length; i++) {
this.entityManager.delete(Booking, bookings[i].id);
}
await this.repository.delete(id);
return 'success';
}
注意,因为 booking 表关联了 meeting-room 表,有外键约束,所以要删除所有的预订之后再去删除会议室。
测试下:
确实删除了。
最后,还有个搜索接口:
没必要单独新建个接口,直接改下 list 接口就行:
@Get('list')
async list(
@Query('pageNo', new DefaultValuePipe(1), generateParseIntPipe('pageNo')) pageNo: number,
@Query('pageSize', new DefaultValuePipe(2), generateParseIntPipe('pageSize')) pageSize: number,
@Query('name') name: string,
@Query('capacity') capacity: number,
@Query('equipment') equipment: string
) {
return await this.meetingRoomService.find(pageNo, pageSize, name, capacity, equipment);
}添加 3 个参数。
service 里的 find 方法也要添加 3 个参数:
async find(pageNo: number, pageSize: number, name: string, capacity: number, equipment: string) {
if(pageNo < 1) {
throw new BadRequestException('页码最小为 1');
}
const skipCount = (pageNo - 1) * pageSize;
const condition: Record<string, any> = {};
if(name) {
condition.name = Like(`%${name}%`);
}
if(equipment) {
condition.equipment = Like(`%${equipment}%`);
}
if(capacity) {
condition.capacity = capacity;
}
const [meetingRooms, totalCount] = await this.repository.findAndCount({
skip: skipCount,
take: pageSize,
where: condition
});
return {
meetingRooms,
totalCount
}
}如果传了这三个参数,就添加查询的 where 条件。
测试下:
没啥问题。
这样,会议室管理模块的接口就写完了。
案例代码上传了。
这节,实现了会议室管理模块。
首先添加了 entity,然后实现了 CRUD 方法。
其中,在 list 接口实现了分页和搜索。
这些接口在用户模块都写过。
其实很多模块的功能都是差不多的,都是 CRUD 的复合。
后端接口写完了,这节来实现下前端页面。
先写管理端的:
把管理端项目跑起来:
npm run dev已经添加了对应的路由,但是还没做点击菜单时的切换。
加一下:
const handleMenuItemClick: MenuClickEventHandler = (info) => {
let path = '';
switch(info.key) {
case '1':
path = '/meeting_room_manage';
break;
case '2':
path = '/booking_manage';
break;
case '3':
path = '/user_manage';
break;
case '4':
path = '/statistics';
break;
}
router.navigate(path);
}
然后写下这 3 个路由的组件:
src/pages/MeetingRoomManage/MeetingRoomManage.tsx
export function MeetingRoomManage() {
return <div>MeetingRoomManage</div>
}src/pages/BookingManage/BookingManage.tsx
export function BookingManage() {
return <div>BookingManage</div>
}src/pages/Statistics/Statistics.tsx
export function Statistics() {
return <div>Statistics</div>
}注册这三个组件对应的路由:
{
path: '/',
element: <MeetingRoomManage/>
},
{
path: 'user_manage',
element: <UserManage/>
},
{
path: 'meeting_room_manage',
element: <MeetingRoomManage/>
},
{
path: 'booking_manage',
element: <BookingManage/>
},
{
path: 'statistics',
element: <Statistics/>
}测试下:
然后还要加上页面刷新时选中对应菜单项的逻辑:
const location = useLocation();
function getSelectedKeys() {
if(location.pathname === '/user_manage') {
return ['3']
} else if(location.pathname === '/booking_manage') {
return ['2']
} else if(location.pathname === '/meeting_room_manage') {
return ['1']
} else if(location.pathname === '/statistics') {
return ['4']
} else {
return ['1']
}
}这样,刷新后也会选中对应的菜单项:
然后来实现会议室管理页面:
和前面写过的用户列表差不多:
import { Badge, Button, Form, Image, Input, Table, message } from "antd";
import { useCallback, useEffect, useMemo, useState } from "react";
import './meeting_room_manage.css';
import { ColumnsType } from "antd/es/table";
import { useForm } from "antd/es/form/Form";
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 MeetingRoomManage() {
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) => (
<a href="#" onClick={() => {}}>删除</a>
)
}
], []);
const searchMeetingRoom = useCallback(async (values: SearchMeetingRoom) => {
}, []);
const [form ] = useForm();
const changePage = useCallback(function(pageNo: number, pageSize: number) {
setPageNo(pageNo);
setPageSize(pageSize);
}, []);
return <div id="meetingRoomManage-container">
<div className="meetingRoomManage-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="location">
<Input/>
</Form.Item>
<Form.Item label=" ">
<Button type="primary" htmlType="submit">
搜索会议室
</Button>
<Button type="primary" style={{background: 'green'}}>
添加会议室
</Button>
</Form.Item>
</Form>
</div>
<div className="meetingRoomManage-table">
<Table columns={columns} dataSource={meetingRoomResult} pagination={ {
current: pageNo,
pageSize: pageSize,
onChange: changePage
}}/>
</div>
</div>
}css 部分如下:
#meetingRoomManage-container {
padding: 20px;
}
#meetingRoomManage-container .meetingRoomManage-form {
margin-bottom: 40px;
}然后在 interfaces.ts 添加 list 接口:
export async function meetingRoomList(name: string, capacity: number, equipment: string, pageNo: number, pageSize: number) {
return await axiosInstance.get('/meeting-room/list', {
params: {
name,
capacity,
equipment,
pageNo,
pageSize
}
});
}在页面调用下:
const searchMeetingRoom = useCallback(async (values: SearchMeetingRoom) => {
const res = await meetingRoomList(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 || '系统繁忙,请稍后再试');
}
}, []);按名称搜索: 按容量搜索: 按设备搜索: 然后,最开始进入页面的时候也得搜索一次:
useEffect(() => {
searchMeetingRoom({
name: form.getFieldValue('name'),
capacity: form.getFieldValue('capacity'),
equipment: form.getFieldValue('equipment')
});
}, [pageNo, pageSize]);最开始搜索一次,并且分页信息变了也重新搜索。
这样,刚进入页面就会触发一次搜索。
然后处理删除:
在 interfaces.ts 里添加 delete 接口:
export async function deleteMeetingRoom(id: number) {
return await axiosInstance.delete('/meeting-room/' + id);
}然后添加删除按钮的处理逻辑:
<a href="#" onClick={() => handleDelete(record.id)}>删除</a>const handleDelete = useCallback(async (id: number) => {
try {
await deleteMeetingRoom(id);
message.success('删除成功');
} catch(e) {
console.log(e);
message.error('删除失败');
}
}, []);提示删除成功,刷新后也确实没有了。
不过应该是删除后自动刷新的。
添加一个状态,删除后设置一个随机数,然后把它作为 useEffect 的依赖,这样就能触发重新搜索。
const [num, setNum] = useState<number>();setNum(Math.random());不过,删除操作最好加上个二次确认。
这个把按钮包一下就好了:
{
title: '操作',
render: (_, record) => (
<Popconfirm
title="会议室删除"
description="确认删除吗?"
onConfirm={() => handleDelete(record.id)}
okText="Yes"
cancelText="No"
>
<a href="#">删除</a>
</Popconfirm>
)
}这样,点击后就会出现一个确认框,确认后才会删除:
然后实现添加会议室:
在 MeetingRoomManage 的同级添加一个 CreateMeetingRoomModal 组件:
import { Modal } from "antd";
import { useCallback } from "react";
interface CreateMeetingRoomModalProps {
isOpen: boolean;
handleClose: Function
}
export function CreateMeetingRoomModal(props: CreateMeetingRoomModalProps) {
const handleOk = useCallback(async function() {
props.handleClose();
}, []);
return <Modal title="创建会议室" open={props.isOpen} onOk={handleOk} onCancel={() => props.handleClose()}>
<p>xxxx</p>
</Modal>
}组件里有一个 Modal,通过参数 isOpen 控制是否显示。
当点击取消的时候,或者确认的时候,都会调用 props.handleClose 方法。
然后在 MeetingRoomManage 引入它:
先添加一个状态代表 modal 是否打开:
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);然后添加 Modal 组件,并且点击按钮的时候 open:
<Button type="primary" style={{background: 'green'}} onClick={
() => setIsCreateModalOpen(true)
}>
添加会议室
</Button>
<CreateMeetingRoomModal isOpen={isCreateModalOpen} handleClose={() => {
setIsCreateModalOpen(false);
}}></CreateMeetingRoomModal>这样,modal 就添加成功了:
然后实现 modal 的具体逻辑,创建会议室:
import { Button, Form, Input,InputNumber, Modal } from "antd";
import { useForm } from "antd/es/form/Form";
import TextArea from "antd/es/input/TextArea";
import { useCallback } from "react";
interface CreateMeetingRoomModalProps {
isOpen: boolean;
handleClose: Function
}
const layout = {
labelCol: { span: 6 },
wrapperCol: { span: 18 }
}
export interface CreateMeetingRoom {
name: string;
capacity: number;
location: string;
equipment: string;
description: string;
}
export function CreateMeetingRoomModal(props: CreateMeetingRoomModalProps) {
const [form] = useForm();
const handleOk = useCallback(async function() {
const values = form.getFieldsValue();
console.log(values);
props.handleClose();
}, []);
return <Modal title="创建会议室" open={props.isOpen} onOk={handleOk} onCancel={() => props.handleClose()} okText={'创建'}>
<Form
form={form}
colon={false}
{...layout}
>
<Form.Item
label="会议室名称"
name="name"
rules={[
{ required: true, message: '请输入会议室名称!' },
]}
>
<Input />
</Form.Item>
<Form.Item
label="位置"
name="location"
rules={[
{ required: true, message: '请输入会议室位置!' },
]}
>
<Input />
</Form.Item>
<Form.Item
label="容纳人数"
name="capacity"
rules={[
{ required: true, message: '请输入会议室容量!' },
]}
>
<InputNumber />
</Form.Item>
<Form.Item
label="设备"
name="equipment"
>
<Input />
</Form.Item>
<Form.Item
label="描述"
name="description"
>
<TextArea />
</Form.Item>
</Form>
</Modal>
}在 modal 里添加一个表单,点击创建按钮的时候打印表单值。
在 interfaces.ts 添加创建会议室的接口:
export async function createMeetingRoom(meetingRoom: CreateMeetingRoom) {
return await axiosInstance.post('/meeting-room/create', meetingRoom);
}在组件里调用下:
const [form] = useForm<CreateMeetingRoom>();
const handleOk = useCallback(async function() {
const values = form.getFieldsValue();
values.description = values.description || '';
values.equipment = values.equipment || '';
const res = await createMeetingRoom(values);
if(res.status === 201 || res.status === 200) {
message.success('创建成功');
form.resetFields();
props.handleClose();
} else {
message.error(res.data.data);
}
}, []);如果没有填 description 或者 equipment 就设置个空字符串。
测试下:
创建失败时:
创建成功时:
创建成功后,手动刷新页面,就看到了新的会议室。
然后在关掉弹窗的时候设置下 num。
这样就会触发列表数据的刷新:
测试下:
最后,加上更新会议室的功能:
创建 UpdateMeetingRoom.tsx
内容和 create 的基本一样:
import { Button, Form, Input, InputNumber, Modal, message } from "antd";
import { useForm } from "antd/es/form/Form";
import TextArea from "antd/es/input/TextArea";
import { useCallback } from "react";
import { updateMeetingRoom } from "../../interfaces/interfaces";
interface UpdateMeetingRoomModalProps {
isOpen: boolean;
handleClose: Function
}
const layout = {
labelCol: { span: 6 },
wrapperCol: { span: 18 }
}
export interface UpdateMeetingRoom {
name: string;
capacity: number;
location: string;
equipment: string;
description: string;
}
export function UpdateMeetingRoomModal(props: UpdateMeetingRoomModalProps) {
const [form] = useForm<UpdateMeetingRoom>();
const handleOk = useCallback(async function() {
props.handleClose();
}, []);
return <Modal title="更新会议室" open={props.isOpen} onOk={handleOk} onCancel={() => props.handleClose()} okText={'更新'}>
<Form
form={form}
colon={false}
{...layout}
>
<Form.Item
label="会议室名称"
name="name"
rules={[
{ required: true, message: '请输入会议室名称!' },
]}
>
<Input />
</Form.Item>
<Form.Item
label="位置"
name="location"
rules={[
{ required: true, message: '请输入会议室位置!' },
]}
>
<Input />
</Form.Item>
<Form.Item
label="容纳人数"
name="capacity"
rules={[
{ required: true, message: '请输入会议室容量!' },
]}
>
<InputNumber />
</Form.Item>
<Form.Item
label="设备"
name="equipment"
>
<Input />
</Form.Item>
<Form.Item
label="描述"
name="description"
>
<TextArea/>
</Form.Item>
</Form>
</Modal>
}在 interfaces.ts 里创建会用到的接口:
export async function updateMeetingRoom(meetingRoom: UpdateMeetingRoom) {
return await axiosInstance.put('/meeting-room/update', meetingRoom);
}
export async function findMeetingRoom(id: number) {
return await axiosInstance.get('/meeting-room/' + id);
}然后在 MeetingRoomManage 组件引入:
先创建两个 state:
const [isUpdateModalOpen, setIsUpdateModalOpen] = useState(false);
const [updateId, setUpdateId] = useState<number>();一个是 update 弹窗是否打开,一个是当前的 id。
然后添加一个更新按钮,点击的时候打开弹出弹窗,设置 id:
{
title: '操作',
render: (_, record) => (
<div>
<Popconfirm
title="会议室删除"
description="确认删除吗?"
onConfirm={() => handleDelete(record.id)}
okText="Yes"
cancelText="No"
>
<a href="#">删除</a>
</Popconfirm>
<br/>
<a href="#" onClick={() => {
setIsUpdateModalOpen(true);
setUpdateId(record.id);
}}>更新</a>
</div>
)
}在下面加上弹窗:
<UpdateMeetingRoomModal isOpen={isUpdateModalOpen} handleClose={() => {
setIsUpdateModalOpen(false);
setNum(Math.random());
}}></UpdateMeetingRoomModal>这样更新弹窗就加上了:
然后要把 id 传过去:
updateId 的默认值是 undefined,可能为空,加上 ! 代表非空。
然后在组件里添加这个参数:
并且调用查询接口,查询 id 对应的数据来回显:
useEffect(() => {
async function query() {
if(!props.id) {
return;
}
const res = await findMeetingRoom(props.id);
const { data } = res;
if(res.status === 200 || res.status === 201) {
form.setFieldValue('id', data.data.id);
form.setFieldValue('name', data.data.name);
form.setFieldValue('location', data.data.location);
form.setFieldValue('capacity', data.data.capacity);
form.setFieldValue('equipment', data.data.equipment);
form.setFieldValue('description', data.data.description);
} else {
message.error(res.data.data);
}
}
query();
}, [props.id]);现在就能回显数据了:
然后再加上更新数据的接口:
const handleOk = useCallback(async function() {
const values = form.getFieldsValue();
values.description = values.description || '';
values.equipment = values.equipment || '';
const res = await updateMeetingRoom({
...values,
id: form.getFieldValue('id')
});
if(res.status === 201 || res.status === 200) {
message.success('更新成功');
props.handleClose();
} else {
message.error(res.data.data);
}
}, []);这里要的参数要额外带上 id。
测试下:
更新成功了。
这样,会议室管理的页面就完成了。
案例代码上传了。
这节实现了会议室管理的前端页面。
实现了列表、分页和搜索,添加会议室、更新会议室、删除会议室。
其中添加和更新会议室需要创建 Modal,把它拆分成了单独的组件。
更新会议室的时候,传入 id,根据 id 回显数据,然后修改完以后再更新数据。
至此,会议室管理的后端和前端代码就都完成了。
这节来写用户端的会议室列表:
现在,用户端首页是这样的:
需要在 / 下添加一个二级路由:
{
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 dev: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 "../../interfaces/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;
}这样,列表页就完成了:
其实写这个模块的时候偷懒了,应该是写完后端接口,还要写 swagger 文档。
然后前端根据 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]
})
meetingRooms: 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
})不用,只要结构对上就行。
最后,在 controller 上加上个 @ApiTags,把下面的接口分到单独一组:
这样,用户端的会议室列表页面,swagger 文档就都完成了。
这节写了用户端的会议室列表页,并且补了 swagger 文档。
用户端列表页就是调用 list 接口,通过 form 来填写参数,通过 table 展示结果。
swagger 文档部分就是分别通过 @ApiParam @ApiQuery @ApiBody @ApiResponse 标识接口,通过 @ApiProperty 标识 dto 和 vo 的属性。
这样,会议室模块的前端后端就都完成了。