【vue3系列实战三(第三节)】vue3 + websocket 多人聊天室实战

Hello,宝子们,今天我们正式开始我们的聊天室实战项目,没有看过之前系列的,请移步

【vue3系列实战三(第一节)】vue3 + websocket 多人聊天室实战基础
【vue3系列实战三(第二节)】vue3 + websocket 多人聊天室实战

上节我们已经完成到了前端页面和部分功能,这节我们继续完成,服务端的项目。

初始化项目以及下载项目依赖:

在这里插入图片描述
我们使用koa作为服务器,你也可以使用express,他们都是nodejs的框架。

安装依赖库

  1. 安装 Koa 框架、koa-router路由、静态文件服务、请求体解析、跨域处理、密码加密、JWT、MongoDB ODM 及实时通信等生产依赖。
  2. 安装 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 的好处

  1. 更灵活:你可以拿到 server 实例,做更多底层操作,比如和 WebSocket、Socket.io 等库集成,或者监听更多事件(如 upgrade、connection)。
  2. 兼容性:有些中间件或库(如 socket.io、ws)需要你传递原生的 http server 实例,而不是 Koa 的 app 实例。
  3. 多端口/多协议:如果你想同时启动 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可以在客户端和服务器之间实现 低延迟, 双向基于事件的 通信。

  1. 如果无法建立 WebSocket 连接,连接将回退到 HTTP 长轮询。
  2. 自动重新连接:Socket.IO 包含一个心跳机制,它会定期检查连接的状态。
  3. 广播:在服务器端,您可以向所有连接的客户端或客户端的子集发送事件:
// 到所有连接的客户端
io.emit("hello");

// 致“news”房间中的所有连接客户端
io.to("news").emit("hello");
  1. 多路复用:命名空间允许您在单个共享连接上拆分应用程序的逻辑。例如,如果您想创建一个只有授权用户才能加入的“管理员”频道,这可能很有用。
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()
        })

    })
  });
};

现在逐步讲解一下这块代码:

  1. 登录验证中间件:每个用户连接时,先检查他有没有带“令牌”(token),并验证是否合法。
io.use(async (socket, next) => {
  // 1. 获取 token
  // 2. 验证 token 是否有效
  // 3. 绑定用户ID到 socket
  // 4. 更新用户为在线
  // 5. 通过验证,继续后续连接
});
  1. 连接事件:每当有用户成功连接进来,就会执行这里的代码。
  • 把 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

在这里插入图片描述
最终效果展示:
在这里插入图片描述
查看数据库,果然成功存入了用户信息:
在这里插入图片描述

总结:我们完成了登录注册的功能,以及整个项目的搭建,对服务端的mvc架构有了一定的了解,对socket.io库有基本的认识,对MongoDB的部分功能也学习了一些,下一小节是最后一节,我们将完成聊天室的功能,请大家多多关注!有疑问请联系作者!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值