Skip to content

这节来写用户端的会议室列表:

现在,用户端首页是这样的:

需要在 / 下添加一个二级路由:

javascript
{
    path: '/',
    element: <Menu/>,
    children: [
      {
        path: '/',
        element: <MeetingRoomList/>
      },
      {
        path: 'meeting_room_list',
        element: <MeetingRoomList/>
      },
      {
        path: 'booking_history',
        element: <BookingHistory/>
      }
    ]
}

然后分别实现这三个组件:

src/page/menu/Menu.tsx

javascript
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 如下:

css
#menu-container {
    display: flex;
    flex-direction: row;
}
#menu-container .menu-area {
    width: 200px;
}

然后是 src/pages/meeting_room_list/MeetingRoomList.tsx

javascript
export function MeetingRoomList() {
    return <div>MeetingRoomList</div>
}

还有 src/pages/booking_history/BookingHistory.tsx

javascript
export function BookingHistory() {
    return <div>BookingHistory</div>
}

在 index.tsx 里导入这些组件后,我们跑起来看看:

npm run start:dev

点击菜单项的路由切换,以及刷新选中对应菜单项,都没问题。

然后来写下列表页面,其实这个和管理端的会议室列表差不多:

我们把那个复制过来改改。

首先,在 interfaces.ts 添加用到的接口:

javascript
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
        }
    });
}

然后写下列表:

javascript
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 部分如下:

css
#meetingRoomList-container {
    padding: 20px;
}
#meetingRoomList-container .meetingRoomList-form {
    margin-bottom: 40px;
}

这样,列表页就完成了:

其实写这个模块的时候偷懒了,应该是写完后端接口,还要写 swager 文档。

然后前端根据 swagger 接口文档才能知道传啥参数,有啥返回值。

当时我们没写 swagger 文档,现在补一下:

打开后端项目,在 MeetingRoomController 里加一下 swagger 相关的装饰器:

首先加一下 delete 接口的:

javascript
@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

javascript
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 的装饰器:

javascript
@ApiBearerAuth()
@ApiParam({
    name: 'id',
    type: Number,
})
@ApiResponse({
    status: HttpStatus.OK,
    description: 'success',
    type: MeetingRoomVo
})

试一下:

接下来是 update 接口:

他有两种响应:

分别写一下:

javascript
@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 装饰器这样写:

javascript
@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

javascript
import { ApiProperty } from "@nestjs/swagger";
import { MeetingRoomVo } from "./meeting-room.vo";

export class MeetingRoomListVo {

    @ApiProperty({
        type: [MeetingRoomVo]
    })
    users: Array<MeetingRoomVo>;

    @ApiProperty()
    totalCount: number;
}

然后加一下 swagger 的装饰器:

javascript
@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 的属性。

这样,会议室模块的前端后端就都完成了。