Hello,宝子们,今天我们正式开始我们的聊天室实战项目,没有看过之前系列的,请移步
【vue3系列实战三(第一节)】vue3 + websocket 多人聊天室实战基础。
【vue3系列实战三(第二节)】vue3 + websocket 多人聊天室实战
上节我们已经完成到了前端页面和部分功能,这节我们继续完成,服务端的项目。
初始化项目以及下载项目依赖:
我们使用koa作为服务器,你也可以使用express,他们都是nodejs的框架。
安装依赖库
- 安装 Koa 框架、koa-router路由、静态文件服务、请求体解析、跨域处理、密码加密、JWT、MongoDB ODM 及实时通信等生产依赖。
- 安装 TypeScript 类型定义文件及开发工具等开发环境依赖,支持TS开发与调试。
npm install koa @koa/router koa-static koa-bodyparser cors bcryptjs jsonwebtoken mongoose socket.io -dev
**开发环境中:我们使用ts,所以还需要下载ts相关的依赖,否则我们的依赖可能不能被识别**
npm install @types/bcryptjs @types/cors @types/jsonwebtoken @types/koa @types/koa-bodyparser @types/koa__router @types/koa-static @types/node ts-node-dev typescript --save-dev
接下来我们安装必要的目录,编写一个简单的服务:
初始化tsconfig.json:
tsc --init
代码如下:
{
"compilerOptions": {
"target": "ES2020", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
"lib": ["ES2020"],
"module": "commonjs", // 指定模块代码生成方式
"moduleResolution": "node",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"outDir": "./dist", // 指定输出目录
"rootDir": "./src", // 指定根目录
"strict": true, /* Enable all strict type-checking options. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
"skipLibCheck": true, /* Skip type checking all .d.ts files. */
"resolveJsonModule": true,
"noImplicitAny": false,
"noUnusedLocals": true,
"noUnusedParameters": true,
"sourceMap": true, /* Create source map files for emitted JavaScript files. */
"experimentalDecorators": true, /* 启用装饰器 */
"emitDecoratorMetadata": true /* 启用装饰器元数据 */
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}
新建app.ts(项目入口文件),以及修改package.json的启动项配置:
我们是ts项目所以我使用了第三方库:ts-node-dev
app.ts:
import Koa from "koa";
import http from "http";
const app = new Koa();
const server = http.createServer(app.callback());
app.use(async (ctx) => {
ctx.body = "Hello World";
});
server.listen(3000, () => {
console.log("服务已启动,顿口: 3000");
});
package.json:
"main": "dist/app.js",
"scripts": {
"start": "node dist/app.js",
"dev": "ts-node-dev --respawn --transpile-only src/app.ts",
"build": "tsc",
"test": "echo \"Error: no test specified\" && exit 1"
},
运行项目:
npm run dev
在浏览器访问,得到以下画面,那么就启动成功!:
注意,我需要给大家解释一下我们最简易的app.ts,为什么要这样写?之前我们正常实现服务器,只需要:
而我们单独拎出来了服务:
const server = http.createServer(app.callback());
1. 为什么用 http.createServer(app.callback()) 而不是 app.listen?
app.listen 的本质
Koa 的 app.listen(…) 其实是对 Node.js 原生 http.createServer(app.callback()).listen(…) 的封装。
也就是说,app.listen 内部就是调用了 http.createServer(app.callback()),只是帮你省略了这一步。
直接用 http.createServer 的好处
- 更灵活:你可以拿到 server 实例,做更多底层操作,比如和 WebSocket、Socket.io 等库集成,或者监听更多事件(如 upgrade、connection)。
- 兼容性:有些中间件或库(如 socket.io、ws)需要你传递原生的 http server 实例,而不是 Koa 的 app 实例。
- 多端口/多协议:如果你想同时启动 HTTP 和 HTTPS 服务,或者同一个 app 绑定多个端口,直接用 http.createServer 更方便。
2. 为什么要传递 app.callback()?app.callback() 是什么?
app.callback() 返回的是一个标准的 Node.js HTTP 请求处理函数,签名是 (req, res) => void。
这是 Koa 框架内部把中间件组合成的最终处理函数,专门给 Node.js 的 http server 用的。
作用
http.createServer 需要一个 (req, res) 处理函数,Koa 的 app.callback() 就是把 Koa 的中间件机制“适配”为 Node.js 能识别的处理函数。
这样,Koa 的所有中间件、路由、响应等逻辑都能正常工作。
3. 总结
app.listen(…) 其实等价于 http.createServer(app.callback()).listen(…),只是更简洁。
直接用 http.createServer(app.callback()) 可以让你获得更多底层控制权,适合需要和原生 http server 交互的场景。
app.callback() 是 Koa 框架暴露给 Node.js http server 的标准请求处理函数。
现在我们来配置路由:
用户管理路由:user.ts
import Router from '@koa/router';
import { Context } from 'koa';
const router = new Router(); // 创建路由实例
router.get('/info',(ctx:Context) =>{
ctx.body = '用户列表';
});
router.post('/login',(ctx:Context) =>{
ctx.body = '登录';
});
router.post('/register',(ctx:Context) =>{
ctx.body = '注册';
});
router.post('/logout',(ctx:Context) =>{
ctx.body = '退出';
});
router.get('/online',(ctx:Context) =>{
ctx.body = '在线用户';
});
export default router;
消息管理路由:message.ts
import Router from '@koa/router';
import { Context } from 'koa';
const router = new Router(); // 创建路由实例
// 获取消息列表
router.get('/list',(ctx:Context) =>{
ctx.body = {
code: 0,
data: [
{ id: 1, content: '你好', read: false },
{ id: 2, content: 'Hello', read: true }
],
msg: '获取成功'
}
});
// 发送消息
router.post('/send', (ctx: Context) => {
// 假设消息内容在 ctx.request.body.content
ctx.body =''
});
// 获取未读消息数量
router.get('/unread/count', (ctx: Context) => {
ctx.body = ''
});
// 标记消息为已读
router.post('/read', (ctx: Context) => {
ctx.body = ''
});
export default router;
我们把所有的路由板块都放在index.ts入口里,专门做路由的入口:index.ts
import Router from "@koa/router";
import userRoutes from './user';
import messageRoutes from './message';
const router = new Router({
prefix:'/api' // 路由前缀
});
// 注册路由
router.use('/user',userRoutes.routes(),userRoutes.allowedMethods());
router.use('/message',messageRoutes.routes(),messageRoutes.allowedMethods());
export default router
最后在app.ts,加载我们的路由:
+ import router from "./routes";
// 引入路由
+ app.use(router.routes()).use(router.allowedMethods());
最后在浏览器进行测试,我们访问get请求:
OK,我们的建议的路由算是完成了,其实路由后面可以封装为装饰器的写法,更方便,不过我们的项目对路由要求比较低,我们就使用这种方式,后续有机会再推出其他项目中,我们可以集成装饰器写法。
现在我们来实现数据库相关配置,我们使用MongoDB,所以大家需要自己安装环境,我们直接开始我们的功能编写:
在浏览器访问端口,MongoDB默认端口是27017,得到以下就表示mongoose服务已经启动了
现在我们来配置数据库:
import mongoose from "mongoose";
// 数据库连接URI
const MONGO_URI = process.env.MONGO_URI || "mongodb://localhost:27017/chatroom"; // 本地数据库
// 链接到数据库
export const connect = async (): Promise<void> => {
try {
await mongoose.connect(MONGO_URI);
console.log("成功连接到MongoDB数据库");
} catch (error) {
console.error("连接数据库失败:", error);
process.exit(1); // 退出进程
}
};
// 断开数据库连接
export const disconnect = async (): Promise<void> => {
try {
await mongoose.disconnect();
console.log("已断开MongoDB数据库连接");
} catch (error) {
console.error("断开数据库连接失败:", error);
}
};
在这里解释一下mongoose数据库的配置:
1. 本地连接数据库默认不需要用户名和密码:所以生产环境应该这样配置:
mongodb://用户名:密码@localhost:27017/chatroom
2. MongoDB的一个特点是不需要预先创建数据库,只有当实际写入数据时(创建集合并插入文档),数据库才会物理创建
在app.ts中引入数据库链接:
让我们重启项目:npm run dev,终端输出这个就表示链接成功了!
现在我们来实现模型(model), 我们使用的是MVC架构。
根据下图一一创建文件夹和文件:
简单介绍一下什么事模型:User模型就像是数据库中的一张表格,专门用来存储用户信息。它定义了每个用户应该有哪些信息,以及这些信息应该是什么格式。
User模型代码如下:
import bcrypt from "bcryptjs";
import mongoose, { Document, Schema } from "mongoose";
// 用户文档接口
export interface UserDocument extends Document {
username: string; // 用户名
password: string; // 密码
email?: string; // 邮箱
avatar: string; // 头像
isOnline: boolean; // 是否在线
lastActive: Date; // 最后一次在线的时间
createdAt: Date; // 创建用户时间
updatedAt: Date; // 更新用户时间
comparePassword(password: string): Promise<boolean>; // 校验正确密码
}
// 编写用户模型规则
const userSchema = new Schema(
{
username: {
type: String,
required: true,
unique: true,
trim: true,
minlength: 3,
maxlength: 20,
},
password: {
type: String,
required: true,
minlength: 6,
},
email: {
type: String,
trim: true, // 去除空格
lowercase: true, // 小写
sparse: true, // 稀疏索引?: 如果邮箱为空,则不建立索引
},
avatar: {
type: String,
default: function (this: UserDocument) {
return `https://api.dicebear.com/7.x/avataaars/svg?seed=${this.username}`;
},
},
isOnline: {
type: Boolean,
default: false,
},
lastActive: {
type: Date,
default: Date.now,
},
},
{
timestamps: true, // 自动添加创建和更新时间
}
);
// 保存前对密码进行加密
userSchema.pre("save", async function (next) {
// 只有在密码被修改时才重新加密
if (!this.isModified("password")) return next();
try {
// 使用 bcrypt 加密密码
const salt = await bcrypt.genSalt(10);
this.password = await bcrypt.hash(this.password, salt);
next();
} catch (error: any) {
next(error);
}
});
// 比较密码
userSchema.methods.comparePassword = async function (
password: string
): Promise<boolean> {
return bcrypt.compare(password, this.password);
};
// 创建和导出用户模型
const User = mongoose.model<UserDocument>("User", userSchema);
export default User;
接下来我们来实现用户控制器userController.ts中的登录和注册功能:
注意:我们现在还有许多工具类没有实现,比如socket.io和jwt,所以我们目前的功能是不完全的,我们这一步主要是把整体的逻辑拉通,方便大家理解,后续会逐步完善功能,请大家一步步来!
import { Context } from "koa";
import User from "../model/User";
// 登录
export const login = async (ctx: Context) => {
try {
const { username, password } = ctx.request.body as {username:string,password:string};
if (!username || !password) {
ctx.status = 400;
ctx.body = { code: 400, message: "用户名和密码不能为空" };
}
// 1.查找用户
const user = await User.findOne({ username });
if (!user) {
ctx.status = 404;
ctx.body = { code: 404, message: "用户不存在" };
return;
}
// 2.验证密码
const isMatch = await user.comparePassword(password);
if (!isMatch) {
ctx.status = 401;
ctx.body = { code: 401, message: "密码错误,请重新输入" };
return;
}
// 3.跟新用户状态为在线
user.isOnline = true;
user.lastActive = new Date();
await user.save(); // 保存用户信息
// 4. 广播用户上线信息:这里需要使用socket.io,我们后面再实现
// 5.生成token,我们后面再实现
// 6.返回用户信息和token
ctx.body = {
code: 200,
message: "登录成功",
data: {
id: user._id,
username: user.username,
avatar: user.avatar,
token: "11111111111111111111111111111111111111111",
},
};
} catch (error) {
ctx.status = 500;
ctx.body = { code: 500, message: '服务器错误' };
console.error('登录错误:', error);
}
};
// 注册
export const register = async (ctx: Context): Promise<void> => {
try {
const { username, password, email } = ctx.request.body as {
username: string;
password: string;
email?: string;
};
// 参数验证
if (!username || !password) {
ctx.status = 400;
ctx.body = { code: 400, message: '用户名和密码不能为空' };
return;
}
// 检查用户名是否已存在
const existingUser = await User.findOne({ username });
if (existingUser) {
ctx.status = 409;
ctx.body = { code: 409, message: '用户名已存在' };
return;
}
// 创建新用户
const newUser = new User({
username,
password,
email,
isOnline: true,
lastActive: new Date()
});
await newUser.save();
// 生成token
// 广播新用户加入消息
// 返回用户信息和token
ctx.body = {
code: 200,
message: '注册成功',
data: {
id: newUser._id,
username: newUser.username,
avatar: newUser.avatar,
token:'11111111111111111111'
}
};
} catch (error) {
ctx.status = 500;
ctx.body = { code: 500, message: '服务器错误' };
console.error('注册错误:', error);
}
};
在路由中使用controller的方法:
router.post('/login',login);
router.post('/register',register);
现在我们来进行前后端联调:可以使用postman或者apifox等工具,我们先试用apifox(查看文档学习)进行调试看看能否成功调用接口:
返回的状态码是500(不是404),看来我们的接口已经通了,报错是因为我们的数据库没有数据,所以报错了。
我们现在来完成我们的核心功能socket.io工具:
对 socket.io 做一个简单的介绍,不熟悉socket.io的朋友一定要去官网浏览一遍。
socket.io可以在客户端和服务器之间实现 低延迟, 双向 和 基于事件的 通信。
- 如果无法建立 WebSocket 连接,连接将回退到 HTTP 长轮询。
- 自动重新连接:Socket.IO 包含一个心跳机制,它会定期检查连接的状态。
- 广播:在服务器端,您可以向所有连接的客户端或客户端的子集发送事件:
// 到所有连接的客户端
io.emit("hello");
// 致“news”房间中的所有连接客户端
io.to("news").emit("hello");
- 多路复用:命名空间允许您在单个共享连接上拆分应用程序的逻辑。例如,如果您想创建一个只有授权用户才能加入的“管理员”频道,这可能很有用。
io.on("connection", (socket) => {
// 普通用户
});
io.of("/admin").on("connection", (socket) => {
// 管理员用户
});
代码如下:
import { Server, Socket } from "socket.io";
import jwt from 'jsonwebtoken';
import User from "../model/User";
// JWT密钥
const JWT_SECRET = 'daimaxiaoku1111111';
// Socket.IO消息类型
enum MessageType {
CHAT = "chat",
TYPING = "typing",
JOIN_ROOM = "join_room",
LEAVE_ROOM = "leave_room",
USER_STATUS = "user_status",
}
// Socket.IO 事件处理:
export const setupSocketHandlers = (io: Server): void => {
// 中间件:验证用户身份
io.use(async (socket, next) => {
try {
const token = socket.handshake.auth.token ||
socket.handshake.query.token;
if (!token) {
return next(new Error('未授权:缺少有效的身份验证令牌'));
}
// 验证token
const decoded = jwt.verify(token as string, JWT_SECRET) as { id: string };
// 将用户ID绑定到socket
socket.data.userId = decoded.id;
// 更新用户状态为在线
await User.findByIdAndUpdate(decoded.id, {
isOnline: true,
lastActive: new Date()
});
next();
} catch (error) {
next(new Error('未授权:身份验证令牌无效或已过期'));
}
});
// 连接处理
io.on("connection", async (socket: Socket) => {
const userId = socket.data.userId;
console.log("用户已连接:", userId);
// 1.将socket加入到用户专属房间,可以通过socket.rooms获取具体房间
socket.join(userId);
// 2.查询用户的信息,只获取用户名和头像
const user = await User.findById(userId).select("username avatar");
// 3.广播用户上线消息,socket.broadcast广播事件:除发送者外的所有连接的客户端都会接受到信息
let params = {
userId: userId,
username: user?.username,
avatar: user?.avatar,
status: "online",
timestamp: new Date(),
};
socket.broadcast.emit(MessageType.USER_STATUS, params);
// 处理加入聊天室
socket.on(MessageType.JOIN_ROOM, (roomId: string) => {
socket.join(`room:${roomId}`);
console.log(`用户 ${userId} 加入房间 ${roomId}`);
// 向房间广播加入消息
socket.to(`room:${roomId}`).emit(MessageType.JOIN_ROOM, {
userId: userId,
username: user?.username,
roomId: roomId,
timestamp: new Date(),
});
});
// 处理离开聊天室
socket.on(MessageType.LEAVE_ROOM, (roomId: string) => {
socket.leave(`room:${roomId}`);
console.log(`用户 ${userId} 离开房间 ${roomId}`);
// 向房间广播用户离开消息
socket.to(`room:${roomId}`).emit(MessageType.LEAVE_ROOM, {
userId: userId,
username: user?.username,
roomId: roomId,
timestamp: new Date(),
});
});
// 处理正在输入状态
socket.on(MessageType.TYPING, ({to,isRoom = false}) =>{
let params = {
userId: userId,
username: user?.username,
timestamp: new Date()
}
// 如果是私聊
if(!isRoom){
socket.to(to).emit(MessageType.TYPING,params)
}
// // 如果是群聊
else{
socket.to(`room:${to}`).emit(MessageType.TYPING,{
...params,
roomId:to
})
}
});
// 处理断开连接
socket.on('disconnect',async() =>{
console.log('用户断开连接:', userId);
// 更新用户状态为离线
await User.findByIdAndUpdate(userId,{
isOnline:false,
lastActive:new Date()
})
// 广播用户下线消息
socket.broadcast.emit(MessageType.USER_STATUS,{
userId: userId,
status: 'offline',
timestamp: new Date()
})
})
});
};
现在逐步讲解一下这块代码:
- 登录验证中间件:每个用户连接时,先检查他有没有带“令牌”(token),并验证是否合法。
io.use(async (socket, next) => {
// 1. 获取 token
// 2. 验证 token 是否有效
// 3. 绑定用户ID到 socket
// 4. 更新用户为在线
// 5. 通过验证,继续后续连接
});
- 连接事件:每当有用户成功连接进来,就会执行这里的代码。
- 把 socket 加入用户专属房间(方便私聊)。
- 查询用户信息(用户名、头像)。
- 广播“某用户上线”消息给其他人。
- 监听各种事件(加入房间、离开房间、正在输入、断开连接等)。
io.on("connection", async (socket: Socket) => {
// 1. 用户连接后,做初始化
// 2. 监听各种事件
});
api小结:
- broadcast:通知“所有人,除了自己”。
- to:通知“某个房间/某个人”,更精准。
接下来我们在app.ts去引入:
代码如下:
import Koa from "koa";
import http from "http";
import router from "./routes";
import { connect } from "./config/database";
import { setupSocketHandlers } from "./utils/socketHandler";
import { Server } from "socket.io";
const app = new Koa();
const server = http.createServer(app.callback());
// 创建 Socket.IO 服务器
const io = new Server(server, {
cors: {
origin: '*', // 允许所有来源
methods: ['GET', 'POST']
}
});
// 连接数据库
connect();
// 引入路由
app.use(router.routes()).use(router.allowedMethods());
// 设置 Socket.IO 处理程序
setupSocketHandlers(io);
server.listen(3000, () => {
console.log("服务已启动,端口: 3000");
});
export { app, io };
中间件token验证的实现:
代码如下:
import { Context, Next } from 'koa';
import jwt from 'jsonwebtoken';
import { UserDocument } from '../model/User';
// JWT密钥
const JWT_SECRET = 'daimaxiaoku1111111';
// 验证中间件
export const authenticate = async (ctx: Context, next: Next): Promise<void> => {
try {
// 从请求头中获取token
const authHeader = ctx.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
ctx.status = 401;
ctx.body = { code: 401, message: '未授权:缺少有效的身份验证令牌' };
return;
}
const token = authHeader.split(' ')[1];
// 验证token
const decoded = jwt.verify(token, JWT_SECRET) as { id: string };
// 将用户ID添加到上下文中
ctx.state.userId = decoded.id;
// 继续处理请求
await next();
} catch (error) {
ctx.status = 401;
ctx.body = { code: 401, message: '未授权:身份验证令牌无效或已过期' };
}
};
// 生成JWT
export const generateToken = (user: UserDocument): string => {
return jwt.sign(
{ id: user._id },
JWT_SECRET,
{ expiresIn: '24h' }
);
};
在userController里面使用:
完整代码如下:
import { Context } from "koa";
import User from "../model/User";
import { io } from "../app";
import { generateToken } from "../middlewares/auth";
// 登录
export const login = async (ctx: Context) => {
try {
const { username, password } = ctx.request.body as {username:string,password:string};
if (!username || !password) {
ctx.status = 400;
ctx.body = { code: 400, message: "用户名和密码不能为空" };
}
// 1.查找用户
const user = await User.findOne({ username });
if (!user) {
ctx.status = 404;
ctx.body = { code: 404, message: "用户不存在" };
return;
}
// 2.验证密码
const isMatch = await user.comparePassword(password);
if (!isMatch) {
ctx.status = 401;
ctx.body = { code: 401, message: "密码错误,请重新输入" };
return;
}
// 3.跟新用户状态为在线
user.isOnline = true;
user.lastActive = new Date();
await user.save(); // 保存用户信息
// 4. 广播用户上线信息
io.emit('user:status',{
userId:user._id,
status:'online'
})
// 5.生成token
const token = generateToken(user);
// 6.返回用户信息和token
ctx.body = {
code: 200,
message: "登录成功",
data: {
id: user._id,
username: user.username,
avatar: user.avatar,
token
},
};
} catch (error) {
ctx.status = 500;
ctx.body = { code: 500, message: '服务器错误' };
console.error('登录错误:', error);
}
};
// 注册
export const register = async (ctx: Context): Promise<void> => {
try {
const { username, password, email } = ctx.request.body as {
username: string;
password: string;
email?: string;
};
// 参数验证
if (!username || !password) {
ctx.status = 400;
ctx.body = { code: 400, message: '用户名和密码不能为空' };
return;
}
// 检查用户名是否已存在
const existingUser = await User.findOne({ username });
if (existingUser) {
ctx.status = 409;
ctx.body = { code: 409, message: '用户名已存在' };
return;
}
// 创建新用户
const newUser = new User({
username,
password,
email,
isOnline: true,
lastActive: new Date()
});
await newUser.save();
// 生成token
const token = generateToken(newUser);
// 广播新用户加入消息
io.emit('user:new',{
userId:newUser._id,
username:newUser.username,
avatar:newUser.avatar
})
// 返回用户信息和token
ctx.body = {
code: 200,
message: '注册成功',
data: {
id: newUser._id,
username: newUser.username,
avatar: newUser.avatar,
token
}
};
} catch (error) {
ctx.status = 500;
ctx.body = { code: 500, message: '服务器错误' };
console.error('注册错误:', error);
}
};
接下来我们去路由模块,对中间件进行引入:
小结,现在我们算是完整的实现了后端服务的流程,现在我们转到前端进行登录注册功能的完成!
安装axios,pinia, socket.io-client依赖库。
npm install socket.io-client axios pinia
新建request.ts
import axios from 'axios';
import type { InternalAxiosRequestConfig, AxiosResponse, AxiosError } from 'axios';
import { ElMessage } from 'element-plus';
// 创建axios实例
const service = axios.create({
// API的基础URL,实际开发中可能是不同的环境有不同的baseURL
baseURL: 'http://localhost:3000/api',
// 请求超时时间
timeout: 10000
});
interface UserData {
token?: string;
username?: string;
id?: string;
avatar?: string;
}
// 定义响应数据接口
interface ApiResponse<T = any> {
code: number;
data: T;
message: string;
}
// 请求拦截器
service.interceptors.request.use(
(config: InternalAxiosRequestConfig): InternalAxiosRequestConfig => {
// 从本地存储获取token并添加到请求头
const userStr = localStorage.getItem('user') || sessionStorage.getItem('user');
if (userStr) {
try {
const user = JSON.parse(userStr) as UserData;
if (user.token && config.headers) {
// 请求头添加token
config.headers['Authorization'] = `Bearer ${user.token}`;
}
} catch (e) {
console.error('Parse user data error', e);
}
}
return config;
},
(error: AxiosError) => {
console.error('请求错误:', error);
return Promise.reject(error);
}
);
// 响应拦截器
service.interceptors.response.use(
(response: AxiosResponse): any => {
const res = response.data;
// 如果响应的状态码不是200,则判断为错误
if (response.status !== 200) {
ElMessage.error(res.message || '请求失败');
return Promise.reject(new Error(res.message || '请求失败'));
}
return res;
},
(error: AxiosError) => {
console.error('响应错误:', error);
// 处理不同的错误状态码
const { response } = error;
if (response) {
switch (response.status) {
case 401:
ElMessage.error('未授权,请重新登录');
// 清除本地token并重定向到登录页
localStorage.removeItem('user');
sessionStorage.removeItem('user');
setTimeout(() => {
window.location.href = '/#/login';
}, 1500);
break;
case 403:
ElMessage.error('拒绝访问');
break;
case 404:
ElMessage.error('请求的资源不存在');
break;
case 500:
ElMessage.error('服务器错误');
break;
default:
const data = response.data as any;
ElMessage.error(data.message || `未知错误 ${response.status}`);
}
} else {
// 网络错误、超时等
ElMessage.error('网络错误,请检查您的网络连接');
}
return Promise.reject(error);
}
);
export default service;
新建api目录,我们的请求统一在这个目录管理。
user.ts
import request from '../utils/request';
// 用户登录参数接口
export interface LoginParams {
username: string;
password: string;
}
// 用户信息接口
export interface UserInfo {
id: string;
username: string;
avatar: string;
token?: string;
[key: string]: any;
}
/**
* 用户登录
* @param username 用户名
* @param password 密码
* @returns Promise
*/
export function login(username: string, password: string) {
return request({
url: '/user/login',
method: 'post',
data: {
username,
password
}
});
}
/**
* 获取用户信息
* @returns Promise
*/
export function getUserInfo() {
return request({
url: '/user/info',
method: 'get'
});
}
/**
* 退出登录
* @returns Promise
*/
export function logout() {
return request({
url: '/user/logout',
method: 'post'
});
}
// 注册接口参数
export interface RegisterParams extends LoginParams {
email?: string;
nickname?: string;
}
/**
* 用户注册
* @param username 用户名
* @param password 密码
* @param email 邮箱 (可选)
* @returns Promise
*/
export function register(username: string, password: string, email?: string) {
return request({
url: '/user/register',
method: 'post',
data: {
username,
password,
email
}
});
}
// 获取在线用户列表
export function getOnlineUsers() {
return request({
url: '/user/online',
method: 'get'
});
}
创建store全局状态管理
import { defineStore } from 'pinia';
import { login as loginApi, register as registerApi, getUserInfo as getUserInfoApi } from '../api/user';
// 定义用户状态接口
interface UserState {
id: string;
username: string;
avatar: string;
token: string;
isLoggedIn: boolean;
}
// 创建用户状态 Store
export const useUserStore = defineStore('user', {
// 状态
state: (): UserState => ({
id: localStorage.getItem('userId') || '',
username: localStorage.getItem('username') || '',
avatar: localStorage.getItem('avatar') || '',
token: localStorage.getItem('token') || '',
isLoggedIn: !!localStorage.getItem('token')
}),
// Getters
getters: {
// 用户是否已登录
loggedIn: (state) => state.isLoggedIn,
// 获取用户信息
userInfo: (state) => ({
id: state.id,
username: state.username,
avatar: state.avatar
})
},
// Actions
actions: {
// 设置用户信息
setUserInfo(userInfo: { id: string; username: string; avatar: string; token: string }) {
this.id = userInfo.id;
this.username = userInfo.username;
this.avatar = userInfo.avatar;
this.token = userInfo.token;
this.isLoggedIn = true;
// 保存到本地存储
localStorage.setItem('token', userInfo.token);
localStorage.setItem('userId', userInfo.id);
localStorage.setItem('username', userInfo.username);
localStorage.setItem('avatar', userInfo.avatar || '');
},
// 清除用户信息
clearUserInfo() {
this.id = '';
this.username = '';
this.avatar = '';
this.token = '';
this.isLoggedIn = false;
// 清除本地存储
localStorage.removeItem('token');
localStorage.removeItem('userId');
localStorage.removeItem('username');
localStorage.removeItem('avatar');
},
// 登录
async login(username: string, password: string) {
try {
const res = await loginApi(username, password);
this.setUserInfo({
id: res.data.id,
username: res.data.username,
avatar: res.data.avatar,
token: res.data.token
});
return res;
} catch (error) {
throw error;
}
},
// 注册
async register(username: string, password: string, email?: string) {
try {
const res = await registerApi(username, password, email);
return res;
} catch (error) {
throw error;
}
},
// 获取用户信息
async getUserInfo() {
try {
const res = await getUserInfoApi();
this.setUserInfo({
id: res.data.id,
username: res.data.username,
avatar: res.data.avatar,
token: this.token // 保留当前 token
});
return res;
} catch (error) {
throw error;
}
}
}
});
在main.ts中引入pinia:
现在我们来实现登录,注册功能Login.vue:
<template>
<div class="min-h-screen flex items-center justify-center bg-gradient-to-b from-gray-50 to-gray-100">
<div class="max-w-md w-full bg-white rounded-3xl shadow-xl p-10 backdrop-blur-lg">
<div class="text-center mb-8">
<h1 class="text-3xl font-light text-gray-800 mb-2">多人聊天室</h1>
<p class="text-gray-500 text-sm">{{ isLogin ? '输入您的账户信息以继续' : '创建新账户' }}</p>
</div>
<!-- 登录表单 -->
<el-form v-if="isLogin" ref="loginFormRef" :model="loginForm" :rules="loginRules" label-position="top">
<el-form-item prop="username">
<el-input v-model="loginForm.username" placeholder="用户名" :prefix-icon="User" class="apple-input" />
</el-form-item>
<el-form-item prop="password" class="mt-4">
<el-input v-model="loginForm.password" type="password" placeholder="密码" :prefix-icon="Lock"
class="apple-input" @keyup.enter="handleLogin" />
</el-form-item>
<div class="flex justify-between items-center mt-6 mb-8">
<el-checkbox v-model="rememberMe" class="apple-checkbox">记住我</el-checkbox>
<a href="#" class="text-blue-500 text-sm hover:text-blue-600 transition">忘记密码?</a>
</div>
<el-button type="primary" class="w-full apple-button" :loading="loading" @click="handleLogin">
登录
</el-button>
<div class="text-center mt-6 text-gray-500 text-sm">
还没有账号? <a href="#" class="text-blue-500 hover:text-blue-600 transition"
@click.prevent="switchForm">注册</a>
</div>
</el-form>
<!-- 注册表单 -->
<el-form v-else ref="registerFormRef" :model="registerForm" :rules="registerRules" label-position="top">
<el-form-item prop="username">
<el-input v-model="registerForm.username" placeholder="用户名" :prefix-icon="User" class="apple-input" />
</el-form-item>
<el-form-item prop="password" class="mt-4">
<el-input v-model="registerForm.password" type="password" placeholder="密码" :prefix-icon="Lock"
class="apple-input" />
</el-form-item>
<el-form-item prop="confirmPassword" class="mt-4">
<el-input v-model="registerForm.confirmPassword" type="password" placeholder="确认密码" :prefix-icon="Lock"
class="apple-input" />
</el-form-item>
<el-form-item prop="email" class="mt-4">
<el-input v-model="registerForm.email" placeholder="邮箱 (可选)" :prefix-icon="Message"
class="apple-input" />
</el-form-item>
<el-button type="primary" class="w-full apple-button mt-6" :loading="loading" @click="handleRegister">
注册
</el-button>
<div class="text-center mt-6 text-gray-500 text-sm">
已有账号? <a href="#" class="text-blue-500 hover:text-blue-600 transition"
@click.prevent="switchForm">登录</a>
</div>
</el-form>
</div>
</div>
</template>
<script setup lang="ts">
import { User, Lock, Message } from '@element-plus/icons-vue';
import { ref, reactive } from 'vue';
import { ElMessage } from 'element-plus';
import type { FormInstance, FormRules } from 'element-plus';
import { useUserStore } from '../store/userStore';
import { useRouter } from 'vue-router';
const router = useRouter();
const userStore = useUserStore();
const loginFormRef = ref<FormInstance | null>(null);
const registerFormRef = ref<FormInstance | null>(null);
const loading = ref(false);
const isLogin = ref(true); // 控制显示登录还是注册表单
// 登录表单数据
const loginForm = reactive({
username: '',
password: ''
});
// 注册表单数据
const registerForm = reactive({
username: '',
password: '',
confirmPassword: '',
email: ''
});
// 登录表单验证规则
const loginRules = reactive<FormRules>({
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 20, message: '用户名长度应为3-20个字符', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, max: 20, message: '密码长度应为6-20个字符', trigger: 'blur' }
]
});
// 注册表单验证规则
const registerRules = reactive<FormRules>({
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 20, message: '用户名长度应为3-20个字符', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, max: 20, message: '密码长度应为6-20个字符', trigger: 'blur' }
],
confirmPassword: [
{ required: true, message: '请确认密码', trigger: 'blur' },
{
validator: (_, value, callback) => {
if (value !== registerForm.password) {
callback(new Error('两次输入的密码不一致'));
} else {
callback();
}
},
trigger: 'blur'
}
],
email: [
{
pattern: /^$|^[\w-]+(\.[\w-]+)*@([\w-]+\.)+[a-zA-Z]{2,7}$/,
message: '请输入有效的邮箱地址',
trigger: 'blur'
}
]
});
// 记住我选项
const rememberMe = ref(false);
// 切换登录/注册表单
const switchForm = () => {
isLogin.value = !isLogin.value;
};
// 登录处理
const handleLogin = async () => {
if (!loginFormRef.value) return;
await loginFormRef.value.validate(async (valid) => {
if (valid) {
loading.value = true;
try {
await userStore.login(loginForm.username, loginForm.password);
ElMessage.success('登录成功');
// 如果记住我,可以在这里设置cookie的过期时间
// 跳转到聊天页面
router.push('/chat');
} catch (error) {
console.error('登录失败:', error);
} finally {
loading.value = false;
}
}
});
};
// 注册处理
const handleRegister = async () => {
if (!registerFormRef.value) return;
await registerFormRef.value.validate(async (valid) => {
if (valid) {
loading.value = true;
try {
await userStore.register(
registerForm.username,
registerForm.password,
registerForm.email || undefined
);
ElMessage.success('注册成功,请登录');
// 清空表单
registerForm.username = '';
registerForm.password = '';
registerForm.confirmPassword = '';
registerForm.email = '';
// 切换到登录表单
isLogin.value = true;
} catch (error) {
console.error('注册失败:', error);
} finally {
loading.value = false;
}
}
});
};
</script>
<style scoped>
/* 自定义的输入框 */
:deep(.apple-input .el-input__wrapper) {
border-radius: 12px;
height: 48px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
padding: 0 15px;
transition: all 0.3s;
background-color: #f5f5f7;
border: 1px solid transparent;
}
:deep(.apple-input .el-input__wrapper:hover) {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
:deep(.apple-input .el-input__wrapper.is-focus) {
box-shadow: 0 0 0 3px rgba(0, 125, 250, 0.2) !important;
background-color: #fff;
border-color: #0070e0;
}
:deep(.apple-button) {
height: 48px;
font-size: 16px;
font-weight: 500;
border-radius: 12px;
background: linear-gradient(135deg, #0a84ff, #0066cc);
border: none;
letter-spacing: 0.2px;
box-shadow: 0 2px 8px rgba(0, 102, 204, 0.3);
transition: all 0.3s;
}
:deep(.apple-button:hover) {
background: linear-gradient(135deg, #0091ff, #0074e0);
box-shadow: 0 3px 12px rgba(0, 102, 204, 0.4);
transform: translateY(-1px);
}
:deep(.el-checkbox__label) {
font-size: 14px;
color: #606266;
}
:deep(.el-checkbox__input.is-checked .el-checkbox__inner) {
background-color: #0070e0;
border-color: #0070e0;
}
</style>
当我们点击登录的时候,遇到了跨域的问题,我们来解决一下:
提示:同学们之前下载的cors不用了,用@koa/cors 代替
npm install @koa/cors
在app.ts引入:
注意:我们还需要解决一个问题,我们的服务端没有办法解析post请求的body,所以需要使用插件:koa-bodyparser
最终效果展示:
查看数据库,果然成功存入了用户信息: