一、项目介绍
本项目实现了一个简易聊天室的基本功能,涵括
- 利用vue框架实现注册、登录、聊天的前端页面展示以及与后端的交互逻辑。
- 后端利用springboot处理前端的请求
- 数据全部存储到了mysql数据库中,包括成员、消息等数据
- 通过JWT校验处理前端的请求
- 利用WebSocket向所有连接的成员发送信息
二、前端设计
1.前端登录界面设计
首先是较为简单的登录界面的设计。其中包含了简单的登录账号和登录密码的输入框,以及登录和注册的按键。当点击登录后会向后端提交账号密码,并且返回token,前端的页面会把token存储在pinia仓库内。以便后续请求的使用。
<template>
<div class="background">
<div class="head">
<p>简易群聊</p>
</div>
<div class="login">
<el-form ref="form">
<el-form-item>
<el-input placeholder="请输入用户名" v-model="username" :prefix-icon="User"></el-input>
</el-form-item>
<el-form-item>
<el-input placeholder="请输入密码" :prefix-icon="Lock" v-model="password"></el-input>
</el-form-item>
</el-form>
<div class="anjian">
<el-button :disabled="username=='' || password==''" type="primary" size="default" @click="login" >登录</el-button>
<el-button type="primary" size="default" @click="goRegist">注册</el-button>
</div>
</div>
</div>
</template>
前端界面的控制逻辑如下。
<script setup lang="ts">
import { Lock, User } from '@element-plus/icons-vue'
import { ref } from 'vue';
import request from '@/utils/request';
import {userStore} from '@/store/userStore';
import { ElMessage } from 'element-plus';
import {useRouter} from 'vue-router';
import {MyData} from '@/type/type'
const reqLogin= (data:any)=>request.post('/login',data)
let store = userStore();
let username = ref("");
let password = ref("")
let $router = useRouter()
const login=async()=>{
let result:MyData = await reqLogin({username:username.value,password:password.value})
if(result.code==200){
store.userinfo = result.data
$router.push("/index")
}
else{
ElMessage({
type: 'error',
message: result.message,
})
}
}
const goRegist = ()=>{
$router.push({path:'/register'})
}
</script>
2.注册页面设计
注册页面和登录页面很相似,主要就是输入注册的用户名和密码,多了一个上传头像的选项,向后端发送以后后端会返回相应的结果,若是成功,前端会跳转到。
<template>
<div class="background">
<div class="head">
<p>注册</p>
</div>
<div class="register">
<el-form ref="form">
<el-form-item>
<el-input placeholder="请输入注册用户名" v-model="username" :prefix-icon="User"></el-input>
</el-form-item>
<el-form-item>
<el-input placeholder="请输入注册密码" :prefix-icon="Lock" v-model="password"></el-input>
</el-form-item>
<el-form-item>
<span>上传头像:
</span>
<el-upload class="avatar-uploader" action="http://localhost:9998/upload" :show-file-list="false"
:on-success="handleAvatarSuccess" :before-upload="beforeAvatarUpload">
<img v-if="imageUrl" :src="'http://localhost:9998'+imageUrl" class="avatar" />
<el-icon v-else class="avatar-uploader-icon">
<Plus />
</el-icon>
</el-upload>
</el-form-item>
</el-form>
<div class="anjian">
<el-button :disabled="username == '' || password == ''" type="primary" size="default"
@click="regist">确认注册</el-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import request from '@/utils/request';
import type { UploadProps } from 'element-plus'
import {useRouter} from 'vue-router'
const $router = useRouter()
const reqRegist= (data:any)=>request.post('/regist',data)
const imageUrl = ref('')
let username = ref('')
let password = ref('')
const handleAvatarSuccess: UploadProps['onSuccess'] = (
response,
uploadFile
) => {
imageUrl.value = response.message
}
const beforeAvatarUpload: UploadProps['beforeUpload'] = (rawFile) => {
if (rawFile.type !== 'image/png') {
ElMessage.error('上传的图片必须是png格式!')
return false
}
return true
}
const regist = async()=>{
let result = await reqRegist({username:username.value,password:password.value,headUrl:imageUrl.value})
if(result.code==200){
ElMessage({type:"success",message:"注册成功,请重新登陆"})
$router.replace({path:'/login'})
}else{
ElMessage({type:'error',message:result.message})
}
}
</script>
3.聊天页面设计
聊天界面极简,仅包括所有注册聊天室的成员,消息展示框,发送消息功能以及退出登录功能。在挂载这个页面时进行成员信息获取、历史消息获取以及websocket连接。
<template>
<div class="general">
<div class="top">
<div class="info">
<img :src="'http://localhost:9998' + idToHead.get(myInfo.memberId)" />
<span>{{ myInfo.memberName }}</span>
<span class="quit" @click="logout">退出</span>
</div>
</div>
<div class="container">
<div class="left">
<el-menu class="el-menu-vertical-demo" default-active="1">
<el-menu-item index="1">
<img :src="'http://localhost:9998' + idToHead.get(myInfo.memberId)" />
<span>{{ myInfo.memberName }}</span>
</el-menu-item>
<el-menu-item v-for="(item, no) in a" :key="item.memberId" v-show="item.memberId != myInfo.memberId"
:index="no + 2">
<img :src="'http://localhost:9998' + idToHead.get(item.memberId)" />
<span>{{ item.memberName }}</span>
</el-menu-item>
</el-menu>
</div>
<div class="right">
<div class="history">
<div class="message" v-for="(index, no) in message" :key="no">
<div class="head">
<img :src="'http://localhost:9998' + idToHead.get(index.memberId)" />
<span>{{ idToName.get(index.memberId) }}:</span>
</div>
<div class="xinxi"><span>{{ index.text }}</span></div>
</div>
</div>
<div class="input">
<textarea v-model="userInput"></textarea>
</div>
<div class="bottom" @click="sendMessage()">
<button>发送</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { userStore } from '@/store/userStore'
import type { MemList, Messages } from '@/type/type'
import request from '@/utils/request'
import { onMounted, ref } from 'vue';
let token = ref("")
let $router = useRouter()
window.onbeforeunload = function () {
socket.close();
}
let userInput = ref("")
let store = userStore()
let socket: WebSocket = {} as WebSocket;
let myInfo = store.userinfo
onMounted(async () => {
let t = await request.get("/member")
a.value = t.data
setProperty();
t = await request.get("/message")
message.value = t.data
token.value = store.userinfo.token;
socket = new WebSocket("ws://localhost:9998/ws/" + token.value)
socket.onmessage = (event) => {
message.value.push(JSON.parse(event.data))
}
})
三、后端设计
这部分仅展示一些核心部分。
首先是登录,首先将前端传过来的密码经过MD5加密,再从数据库查询是否为已注册用户,若账号密码都正确,使用JWT生成token,返回给前端,作为之后登录的凭证,设置token的有效期为24小时。
@Service
public class LoginServiceImpl implements LoginService {
@Autowired
private LoginMapper loginMapper;
@Autowired
private JwtProperties jwtProperties;
@Override
public Result Login(LoginVo loginVo) {
String password = DigestUtils.md5DigestAsHex(loginVo.getPassword().getBytes());
loginVo.setPassword(password);
UserInfo info = loginMapper.Login(loginVo);
if(info==null){
return Result.err(Result.CODE_ERR_BUSINESS,"账号或密码错误");
}
Map<String, Object> claims = new HashMap<>();
claims.put("memberId", info.getMemberId());
String token = JwtUtil.createJWT(
jwtProperties.getSecretKey(),
jwtProperties.getTtl(),
claims);
info.setToken(token);
return Result.ok(info);
}
}
接着就是对成员信息和历史消息的查询,采用mybatis框架对数据库进行操作。
@Select("select u.* from login_info l, user_info u where l.member_id=u.member_id and l.username = #{username} and l.password=#{password}")
UserInfo Login(LoginVo loginVo);
@Select("select * from user_info")
List<UserInfo> queryMembers();
最关键的是websocket向各个在线用户发送消息,当收到信息后,JWT解析用户信息,遍历session列表,向每个连接的用户发送信息。
@OnMessage
public void onMessage(String message, @PathParam("token") String token) {
System.out.println("收到来自客户端:" + token + "的信息:" + message);
Claims claims = JwtUtil.parseJWT(jwtProperties.getSecretKey(), token);
Long memberId = Long.valueOf(claims.get("memberId").toString());
Message message1 = new Message();
message1.setSendTime(LocalDateTime.now());
message1.setText(message);
message1.setMemberId(memberId);
messageService.addMessage(message1);
sendToAllClient(message1);
}
public void sendToAllClient(Message message) {
Collection<Session> sessions = sessionMap.values();
for (Session session : sessions) {
try {
//服务器向客户端发送消息
session.getBasicRemote().sendText(JSON.toJSONString(message));
} catch (Exception e) {
e.printStackTrace();
}
}
}
最后展示一下注册方面的处理逻辑。首先就是查询有没有重复的用户名,然后将密码通过MD5加密,登录信息存入数据库,然后再存用户信息,主要是用户名和头像。
@Transactional
@Override
public Result regist(RegisterVo registerVo) {
String password = DigestUtils.md5DigestAsHex(registerVo.getPassword().getBytes());
registerVo.setPassword(password);
int n = registMapper.insertLoginInfo(registerVo);
if(n==0){
return Result.err(500,"用户名重复");
}
registMapper.insertUserInfo(registerVo);
return Result.ok("注册成功");
}
四、结果展示
下面登录两个账号,发送信息,二者都能收到。