上节打通了一对一聊天的流程,这节继续打通群聊的流程。

其实现在展示的群聊列表有点问题,应该把一对一的聊天室过滤掉,而且把群聊人数显示出来。
我们改一下: 

interface GroupSearchResult {
id: number;
name: string;
type: boolean;
userCount: number;
userIds: Array<number>;
createTime: Date;
}
setGroupResult(
res.data
.filter((item: GroupSearchResult) => {
return item.type === true;
})
.map((item: GroupSearchResult) => {
return {
...item,
key: item.id,
};
})
);
现在就只剩下群聊了:

然后我们优化下日期展示,加上人数展示,加一个详情按钮:

{
title: '创建时间',
dataIndex: 'createTime',
render: (_, record) => {
return new Date(record.createTime).toLocaleString()
}
},
{
title: '人数',
dataIndex: 'userCount'
},
{
title: '操作',
render: (_, record) => (
<div>
<a href="#">聊天</a>
<a href="#">详情</a>
</div>
)
}

点击详情按钮的时候,通过弹窗展示群聊的成员。
我们加一个 Modal 组件:
src/pages/Group/MembersModal.tsx
import { message, Modal, Table } from "antd";
import { ColumnsType } from "antd/es/table";
import { useEffect, useState } from "react";
import { groupMembers } from "../../interfaces";
export interface MembersModalProps {
isOpen: boolean;
chatroomId: number;
handleClose: () => void;
}
interface User {
id: number;
username: string;
nickName: string;
headPic: string;
email: string;
}
export function MembersModal(props: MembersModalProps) {
const [members, setMembers] = useState<Array<User>>();
const queryMembers = async () => {
try {
const res = await groupMembers(props.chatroomId);
if (res.status === 201 || res.status === 200) {
setMembers(
res.data.map((item: User) => {
return {
...item,
key: item.id,
};
})
);
}
} catch (e: any) {
message.error(e.response?.data?.message || "系统繁忙,请稍后再试");
}
};
useEffect(() => {
queryMembers();
}, [props.chatroomId]);
const columns: ColumnsType<User> = [
{
title: "ID",
dataIndex: "id",
},
{
title: "用户名",
dataIndex: "username",
},
{
title: "昵称",
dataIndex: "nickName",
},
{
title: "头像",
dataIndex: "headPic",
render: (_, record) => (
<div>
<img src={record.headPic} width={50} height={50} />
</div>
),
},
{
title: "邮箱",
dataIndex: "email",
},
];
return (
<Modal
title="群聊成员"
open={props.isOpen}
onCancel={() => props.handleClose()}
onOk={() => props.handleClose()}
width={1000}>
<Table columns={columns} dataSource={members} pagination={false} />
</Modal>
);
}
它就是调用 members 接口查询成员数据,用 table 展示。
在 interface 加一下这个接口:
export async function groupMembers(chatroomId: number) {
return axiosInstance.get(`/chatroom/members`, {
params: {
chatroomId
}
});
}
然后在 Group/index.tsx 调用下:


就是用一个 state 保存 Modal 打开状态,一个 state 保存当前的 chatroomId
import { Badge, Button, Form, Input, Popconfirm, Table, message } from "antd";
import { useCallback, useEffect, useMemo, useState } from "react";
import "./index.css";
import { ColumnsType } from "antd/es/table";
import { useForm } from "antd/es/form/Form";
import { chatroomList } from "../../interfaces";
import { MembersModal } from "./MembersModal";
interface SearchGroup {
name: string;
}
interface GroupSearchResult {
id: number;
name: string;
type: boolean;
userCount: number;
userIds: Array<number>;
createTime: Date;
}
export function Group() {
const [groupResult, setGroupResult] = useState<Array<GroupSearchResult>>(
[]
);
const [isMembersModalOpen, setMembersModalOpen] = useState(false);
const [chatroomId, setChatroomId] = useState<number>(-1);
const columns: ColumnsType<GroupSearchResult> = [
{
title: "名称",
dataIndex: "name",
},
{
title: "创建时间",
dataIndex: "createTime",
render: (_, record) => {
return new Date(record.createTime).toLocaleString();
},
},
{
title: "人数",
dataIndex: "userCount",
},
{
title: "操作",
render: (_, record) => (
<div>
<a href="#">聊天</a>
<a
href="#"
onClick={() => {
setChatroomId(record.id);
setMembersModalOpen(true);
}}>
详情
</a>
</div>
),
},
];
const searchGroup = async (values: SearchGroup) => {
try {
const res = await chatroomList(values.name || "");
if (res.status === 201 || res.status === 200) {
setGroupResult(
res.data
.filter((item: GroupSearchResult) => {
return item.type === true;
})
.map((item: GroupSearchResult) => {
return {
...item,
key: item.id,
};
})
);
}
} catch (e: any) {
message.error(e.response?.data?.message || "系统繁忙,请稍后再试");
}
};
const [form] = useForm();
useEffect(() => {
searchGroup({
name: form.getFieldValue("name"),
});
}, []);
return (
<div id="group-container">
<div className="group-form">
<Form
form={form}
onFinish={searchGroup}
name="search"
layout="inline"
colon={false}>
<Form.Item label="名称" name="name">
<Input />
</Form.Item>
<Form.Item label=" ">
<Button type="primary" htmlType="submit">
搜索
</Button>
</Form.Item>
</Form>
</div>
<div className="group-table">
<Table
columns={columns}
dataSource={groupResult}
style={{ width: "1000px" }}
/>
</div>
<MembersModal
isOpen={isMembersModalOpen}
handleClose={() => {
setMembersModalOpen(false);
}}
chatroomId={chatroomId}
/>
</div>
);
}
试下效果:

没啥问题。
其实好友列表那里也应该把 id 和 username 展示出来:


然后群聊点击聊天的时候可以直接知道 chatroomId,跳到聊天页面:

<a
href=""
onClick={() => {
navigate("/chat", {
state: {
chatroomId: record.id,
},
});
}}>
聊天
</a>

没啥问题。
但现在这个群就一个人,没啥好聊的。
我们加一下添加成员的功能。
同样,我们要加一个弹窗:
src/pages/Group/AddMemberModal.tsx
import { Button, Form, Input, InputNumber, Modal, message } from "antd";
import { useForm } from "antd/es/form/Form";
import { addMember } from "../../interfaces";
interface AddMemberModalProps {
chatroomId: number;
isOpen: boolean;
handleClose: Function;
}
const layout = {
labelCol: { span: 6 },
wrapperCol: { span: 18 },
};
export interface AddMember {
username: string;
}
export function AddMemberModal(props: AddMemberModalProps) {
const [form] = useForm<AddMember>();
const handleOk = async function () {
await form.validateFields();
const values = form.getFieldsValue();
try {
const res = await addMember(values.username);
if (res.status === 201 || res.status === 200) {
message.success("成员添加成功");
form.resetFields();
props.handleClose();
}
} catch (e: any) {
message.error(e.response?.data?.message || "系统繁忙,请稍后再试");
}
};
return (
<Modal
title="添加成员"
open={props.isOpen}
onOk={handleOk}
onCancel={() => props.handleClose()}
okText={"添加"}
cancelText={"取消"}>
<Form form={form} colon={false} {...layout}>
<Form.Item
label="用户名"
name="username"
rules={[{ required: true, message: "请输入用户名!" }]}>
<Input />
</Form.Item>
</Form>
</Modal>
);
}
这里调用 memberAdd 接口来添加成员。
我们之前的接口有点问题,没有支持根据 username 加人。
改一下:


接收 username 参数,根据 username 查询用户,如果用户不存在,返回错误,否则,把用户加入聊天室。
@Get('join/:id')
async join(@Param('id') id: number, @Query('joinUsername') joinUsername: string) {
if(!id) {
throw new BadRequestException('id 不能为空')
}
if(!joinUsername) {
throw new BadRequestException('joinUsername 不能为空')
}
return this.chatroomService.join(id, joinUsername);
}
async join(id: number, username: string) {
const chatroom = await this.prismaService.chatroom.findUnique({
where: {
id
}
});
if(chatroom.type === false) {
throw new BadRequestException('一对一聊天室不能加人');
}
const user = await this.prismaService.user.findUnique({
where: {
username
}
});
if(!user) {
throw new BadRequestException('用户不存在');
}
await this.prismaService.userChatroom.create({
data: {
userId: user.id,
chatroomId: id
}
})
return chatroom.id;
}
然后 interfaces 里调用下:
export async function addMember(chatroomId: number, joinUsername: string) {
return axiosInstance.get(`/chatroom/join/${chatroomId}`, {
params: {
joinUsername,
},
});
}
改下参数:

在 Group/index.tsx 调用下:


加一个 state 来保存弹窗状态,点击添加成员的时候展示,关闭弹窗的时候重新搜索。
测试下:

可以看到,添加成功后成员数量变了,但点击详情还是一个成员。
这是因为 chatroomId 参数没变, MembersModal 并没有触发重新搜索。

我们加一个 queryKey 参数来控制重新查询:

在 Group/index.tsx 组件里传进来:

const [queryKey, setQueryKey] = useState < string > "";
setQueryKey(Math.random().toString().slice(2, 10));
当添加成员完毕,设置 queryKey 为一个新的随机值,从而触发重新请求:

现在群里有三个成员,我们登录另外两个账号聊聊天试试:
登录小红的账号:

确实,她有了这个群聊。
再登录东东的账号聊天试试:

没问题,可以三人在房间里聊天。
但现在还有个 bug:

点击聊天进入聊天室的时候,并不会查询聊天记录,只有切换聊天室才会。
当解析 state 中的 chatroomId 的时候,再查一下聊天记录就好了:

useEffect(() => {
if (location.state?.chatroomId) {
setChatroomId(location.state?.chatroomId);
queryChatHistoryList(location.state?.chatroomId);
}
}, [location.state?.chatroomId]);
而且也要判断下,chatroomId 不为空才查聊天记录。
现在从群聊、从好友点进来,就都会查询聊天记录了:

此外,我们还要添加下创建群聊的功能。
同样,要写一个 Modal:
src/pages/CreateGroupModal.tsx
import { Button, Form, Input, InputNumber, Modal, message } from "antd";
import { useForm } from "antd/es/form/Form";
import { createGroup } from "../../interfaces";
interface CreateGroupModalProps {
isOpen: boolean;
handleClose: Function;
}
const layout = {
labelCol: { span: 6 },
wrapperCol: { span: 18 },
};
export interface GroupGroup {
name: string;
}
export function CreateGroupModal(props: CreateGroupModalProps) {
const [form] = useForm<GroupGroup>();
const handleOk = async function () {
await form.validateFields();
const values = form.getFieldsValue();
try {
const res = await createGroup(values.name);
if (res.status === 201 || res.status === 200) {
message.success("群聊创建成功过");
form.resetFields();
props.handleClose();
}
} catch (e: any) {
message.error(e.response?.data?.message || "系统繁忙,请稍后再试");
}
};
return (
<Modal
title="创建群聊"
open={props.isOpen}
onOk={handleOk}
onCancel={() => props.handleClose()}
okText={"创建"}
cancelText={"取消"}>
<Form form={form} colon={false} {...layout}>
<Form.Item
label="群聊名称"
name="name"
rules={[{ required: true, message: "请输入群聊名称!" }]}>
<Input />
</Form.Item>
</Form>
</Modal>
);
}
和之前的 Modal 一样,创建群聊需要填入群聊名称。
在 interfaces 添加这个接口:
export async function createGroup(name: string) {
return axiosInstance.get(`/chatroom/create-group`, {
params: {
name,
},
});
}
在 Group/index.tsx 里用一下:


加一个按钮,点击显示创建群聊弹窗,关闭弹窗的时候刷新列表。
import { Badge, Button, Form, Input, Popconfirm, Table, message } from "antd";
import { useCallback, useEffect, useMemo, useState } from "react";
import "./index.css";
import { ColumnsType } from "antd/es/table";
import { useForm } from "antd/es/form/Form";
import { chatroomList } from "../../interfaces";
import { MembersModal } from "./MembersModal";
import { useNavigate } from "react-router-dom";
import { AddMemberModal } from "./AddMemberModal";
import { CreateGroupModal } from "./CreateGroupModal";
interface SearchGroup {
name: string;
}
interface GroupSearchResult {
id: number;
name: string;
type: boolean;
userCount: number;
userIds: Array<number>;
createTime: Date;
}
export function Group() {
const [groupResult, setGroupResult] = useState<Array<GroupSearchResult>>(
[]
);
const [isMembersModalOpen, setMembersModalOpen] = useState(false);
const [isMemberAddModalOpen, setMemberAddModalOpen] = useState(false);
const [isCreateGroupModalOpen, setCreateGroupModalOpen] = useState(false);
const [chatroomId, setChatroomId] = useState<number>(-1);
const navigate = useNavigate();
const columns: ColumnsType<GroupSearchResult> = [
{
title: "名称",
dataIndex: "name",
},
{
title: "创建时间",
dataIndex: "createTime",
render: (_, record) => {
return new Date(record.createTime).toLocaleString();
},
},
{
title: "人数",
dataIndex: "userCount",
},
{
title: "操作",
render: (_, record) => (
<div>
<a
href="javascript:void(0);"
onClick={() => {
navigate("/chat", {
state: {
chatroomId: record.id,
},
});
}}>
聊天
</a>
<a
href="#"
onClick={() => {
setChatroomId(record.id);
setMembersModalOpen(true);
}}>
详情
</a>
<a
href="#"
onClick={() => {
setChatroomId(record.id);
setMemberAddModalOpen(true);
}}>
添加成员
</a>
</div>
),
},
];
const searchGroup = async (values: SearchGroup) => {
try {
const res = await chatroomList(values.name || "");
if (res.status === 201 || res.status === 200) {
setGroupResult(
res.data
.filter((item: GroupSearchResult) => {
return item.type === true;
})
.map((item: GroupSearchResult) => {
return {
...item,
key: item.id,
};
})
);
}
} catch (e: any) {
message.error(e.response?.data?.message || "系统繁忙,请稍后再试");
}
};
const [form] = useForm();
useEffect(() => {
searchGroup({
name: form.getFieldValue("name"),
});
}, []);
const [queryKey, setQueryKey] = useState<string>("");
return (
<div id="group-container">
<div className="group-form">
<Form
form={form}
onFinish={searchGroup}
name="search"
layout="inline"
colon={false}>
<Form.Item label="名称" name="name">
<Input />
</Form.Item>
<Form.Item label=" ">
<Button type="primary" htmlType="submit">
搜索
</Button>
</Form.Item>
<Form.Item label=" ">
<Button
type="primary"
style={{ background: "green" }}
onClick={() => setCreateGroupModalOpen(true)}>
创建群聊
</Button>
</Form.Item>
</Form>
</div>
<div className="group-table">
<Table
columns={columns}
dataSource={groupResult}
style={{ width: "1000px" }}
/>
</div>
<MembersModal
isOpen={isMembersModalOpen}
handleClose={() => {
setMembersModalOpen(false);
}}
chatroomId={chatroomId}
queryKey={queryKey}
/>
<AddMemberModal
isOpen={isMemberAddModalOpen}
handleClose={() => {
setMemberAddModalOpen(false);
setQueryKey(Math.random().toString().slice(2, 10));
searchGroup({
name: form.getFieldValue("name"),
});
}}
chatroomId={chatroomId}
/>
<CreateGroupModal
isOpen={isCreateGroupModalOpen}
handleClose={() => {
setCreateGroupModalOpen(false);
searchGroup({
name: form.getFieldValue("name"),
});
}}
/>
</div>
);
}

然后给这个闲聊群加几个成员:


进入聊天:



三个人都能正常聊天。
从创建聊天、加入聊天到在群里聊天,整个流程都没问题。
过程中发现一个小问题:

刚加载出聊天记录的时候,不会滚动到底部。
我们也加一下自动滚动:

useEffect(() => {
setTimeout(() => {
document.getElementById("bottom-bar")?.scrollIntoView({ block: "end" });
}, 300);
}, [roomId]);

这样就好了。
总结
这节我们打通了群聊的流程。
首先支持了查看群聊成员,通过弹窗展示。
然后支持了添加成员,填入对方的 username 即可。
之后实现了点击聊天进入群聊。
然后实现了创建群聊。
最后从创建群聊、添加成员、查看成员、开始聊天,到三个人在群里聊天,都没问题。
这样,群聊功能就完成了。
