使用Vue全家桶和Nestjs编写的即时聊天应用

前言

这是一个DEMO!
这是一个参照 Fiora 项目的原型但技术栈彻底不同的DEMO!
这是一个大三🐕为了找实习而写的DEMO!javascript

项目体验和源码

你们能够给我这个可怜的大三臭弟弟一个star🐎html

关于原型

让各位看官老爷笑话了,因为本人在设计上没有任何天赋,所以该项目以 Fiora 项目其中的一个主题做为了项目原型进行设计与开发。前端

  • 首页(游客状态)java

  • 登陆界面git

  • 联系人信息界面github

关于技术栈

  • 前端技术栈:Vue , Vuex , Vue Router , element ui
  • 后端技术栈:Nestjs , TypeORM , Socket.IO
  • 数据库:MySql
  • 开发语言:前端以ES6和TypeScript混合开发为主,后端 All in TypeScript

关于功能

因为时间和精力有限(一时全栈一时爽,一直全栈...),该项目仅实现了一些基本功能:web

  1. 群聊、私聊
  2. 消息列表、好友列表
  3. 好友添加(我好像忘了作好友搜索的功能...可是能够经过点击群聊中的头像进行添加)
  4. 默认表情发送,图片发送

另外,还使用了HTML5的一些新API,算法

  1. 桌面消息通知(经过notification实现)
  2. Web截图(经过Canvas实现,生产版本使用了kscreenshot插件)

关于socket链接

在客户端,基于Socket.IO-client,每一个用户会获得一个socket实例,实例中的socketId为惟一标识符。chrome

import client from 'socket.io-client';
// 为 URL 中的路径名指定的名称空间返回一个新的 Socket 实例
app.socket = client(socketUrl);

// 对于已注册用户,咱们还须要携带查询参数来肯定用户身份
app.socket = client(socketUrl,{
  query: {
    userId
  }
});
复制代码

服务端处理socket链接typescript

/** * 当有新的链接创建时调用该函数,已登陆用户更新自身的socketId并加入全部群聊 * @param socket */
@UseFilters(new CustomWsExceptionFilter())
async handleConnection(socket: Socket) {
    const socketId = socket.id;
    // 获取握手的细节信息,包括查询参数对象以及客户端IP
    const {query,address as clentIp} = socket.handshake;
    if(query.userId) {
        const userId = query.userId;
        await this.websocketService.updateSocketMes(socketId,clientIp,userId);
        await this.websocketService.userJoinRooms(socket,userId);
    }
}
复制代码

关于用户的登陆与注册

除了socket通讯以外,其余接口皆不涉及websocket协议。

用户注册

用户须要提供用户名和密码来进行注册。密码会经过bcrypt进行加盐处理

export async function genSaltPassword(pass: string): Promise<string> {
    const salt = await bcrypt.genSalt(saltRounds);
    const hash = await bcrypt.hash(pass,salt);
    return hash;
}
复制代码

注册成功后(用户名不重复)会得到随机头像并加入默认群组

// 得到随机头像
const randomAvatarUrl = await this.randomAvatarService.getRandomAvatarUrl();

// 加入默认群组
const defaultGroup = await this.groupService.getDefaultGroup();
await this.groupRelationshipService.createGroupRelationship({
    user_id: userBasicMes.user_id,
    group_id: defaultGroup.group_id,
    top_if: 0,
    group_member_type: '普通'
})
复制代码

用户登陆

用户登陆分为经过帐号密码进行登陆和经过客户端存储的token进行自动登陆

经过帐密进行登陆

当经过帐密进行登陆时,服务端会验证帐密的正确性

async validatePassword(userName: string, password: string): Promise<boolean> {
    // 验证该用户是否存在(经过用户名进行查询)以及密码是否匹配
    return this.userService.checkUserExistence({ user_name: userName }) &&
        this.userService.checkUserPassword(userName, password);
}
复制代码

对于密码匹配,bcrypt会根据用户给定的密码和注册时存入数据库的加盐密码进行比对

if(user) {
    saltPassword = user.user_saltPassword;
    return await bcrypt.compare(password,saltPassword);
}
复制代码

当身份验证经过后,服务端会生成一个jwt返回给客户端用于后续的自动登陆处理

// 导入jwt-simple
import * as jwt from "jwt-simple";

async genJWT(userName: string) {
    const userId = (await this.userService.getUserBasicMes({user_name: userName})).user_id;
    const payload = {
        userId,
        enviroment: 'web',
        // 设置jwt的过时时间
        expires: Date.now() + jwtConfig.tokenExpiresTime
    };
    // 使用默认编码算法(HS256)进行编码
    const token = jwt.encode(payload,jwtConfig.jwtSecret);
    return token;
}
复制代码

最后,更新用户的登陆状态,socketId及其余信息

await this.userService.updateUser({
    user_id
},{
    user_lastLogin_time: datetime,
    user_state: 1,
    user_socketId,
    user_preferSetting_id
})
复制代码

经过jwt自动登陆

经过jwt自动登陆的,服务端须要验证jwt的有效性

async validateToken(accessToken: string): Promise<boolean> {
    // 对token进行解码
    const decodedToken  = this.decodeToken(accessToken);
    // token过时的处理
    if(decodedToken.expires < Date.now()) {
        throw new HttpException("Token已过时,请从新登陆",HttpStatus.FORBIDDEN);
    }
    if(this.userService.checkUserExistence({user_id: decodedToken.usrId})) {
        return true;
    }
    else {
        throw new HttpException("无效Token,此为非法登陆",HttpStatus.FORBIDDEN);
    }
}
复制代码

验证经过后,更新用户相关信息,用户登陆成功!

关于消息

用户发送消息

客户端经过socket实例emit一个message事件来发送消息

socket.emit("message",{
    messageFromId,
    messageToId,
    messageType, // 消息类型(私聊仍是群聊)
    messageContent,
    messageContentType // 消息内容类型(可分为text,img...)
},res => {
    // ...成功发送后的回调
})

// 发送消息失败时的处理
socket.once('messageException', err => {
    const {message} = err;
    showMessage(app,'warning',message,3000,true);
})
复制代码

服务端会在websocket网关监听到该事件的发生

@UseFilters(new CustomWsExceptionFilter())
@SubscribeMessage('message')
async sendMessage(@ConnectedSocket() socket: Socket, @MessageBody() mes: any) {
    return await this.chatingMessageService.sendMessage(mes,socket);
}
复制代码

而后将消息进行持久化到数据库中并发送给目标(用户或者群聊)

// 持久化消息
messageId = await this.messageService.createMessage(mes);

// 经过目标群聊名(惟一)发送消息到群聊中
socket.to(groupName).emit('groupMemberMessage',{
    messageFromUser,
    messageTarget: messageTarget,
    messageId,
    messageContent,
    messageContentType,
    messageType,
    messageCreatedTime
});

// 经过目标用户的socketId发送私聊消息
socket.to(targetSocketId).emit('messageFromFriend',{
    messageFromUser,
    messageTarget: messageTarget,
    messageId,
    messageContent,
    messageContentType,
    messageType,
    messageCreatedTime
});
复制代码

用户接收消息

客户端经过监听messageFromFriend和groupMemberMessage事件分别接受私聊消息和群聊消息

socket.on('messageFromFriend', resolveReceiveMes)
socket.on('groupMemberMessage', resolveReceiveMes)
复制代码

接受到消息以后,该消息会被加入到对应的消息列表中,

// 若是该消息对应的对话信息不存在,那么建立相应的对话信息框
if(!store.state.messageMap[dialogTargetId]) {
    await store.dispatch('resolveMessageMap',{
        dialogId,
        dialogTargetId,
        page: 1,
        limit: 50
    })
}
// 若是存在则直接将消息压入到消息列表中
else store.commit('addNewMessage',{
    dialogId,dialogTargetId,messageContentMes
})
复制代码

另外,若是用户容许了桌面消息通知功能,那么会经过获得(以正则的方式)发送来的消息内容类型以来显示对应的消息

if(store.state.notification) {
    // ...
    if (/image\/(gif|jpeg|jpg|png|svg)/g.test(message.messageContentType)) {
        notificationContent = `[图片]`
    }
    else {
        notificationContent = resolveEmoji(message,'messageContent');
    }
    if(messageType === 0) {
        notificationTile = `好友 ${notifiFromName} 向您发来了一条新消息:`;
        notificationAvatar = notifiFromAvatar;
    }
    else {
        notificationTile = `群组 ${notifiTargetName} 新增一条成员信息`;
        notificationAvatar = notifiTargetAvatar;
        notificationContent = `${notifiFromName}: ` + notificationContent;
    }
    createNotification(notificationTile,{
        body: notificationContent,
        icon: notificationAvatar
    })
}
复制代码

获取历史消息

客户端经过分页的方式来获取历史消息。getHistoryMessages接口接受如下三个参数:

  1. dialogId 对话Id
  2. page 页数
  3. limit 每页的数量

为了每次切换对话框时不须要从新去请求历史消息,所以,客户端会在Vuex中全局设置一个messageMap的哈希表来保存已经获得的消息数据

/** * 处理messageMap * 具体为在messageMap中建立一个以dialogId为键的消息列表 * @param param0 * @param option */
async resolveMessageMap({commit},option) {
    const {dialogId,dialogTargetId,page,limit,app} = option;
    const messages = await historyMessages(dialogId,page,limit);
    // page大于1确保当一个对话没有任何信息时,不会提示没有更多历史消息了
    if((!messages || messages.length === 0) && page > 1) {
        noMoreMessage(app);
    }
    commit('setMessageMap',{dialogTargetId,dialogId,messages});
}
复制代码

另外,客户端采用无限上滑的方式来获取更多的历史消息

// 当scrollTop等于scrollHeight-clientHeight时,表示滑动条滑倒了对话框的顶端,那么就加载更多的历史消息
if(Math.floor(element.scrollTop)+1 < element.scrollHeight - element.clientHeight) {
    // getHistoryMessages
}

// 为了不某些手速快的男孩子在数据返回以前屡次触发事件,在这里设置了一个滚动条回弹5px以及防抖处理
element.scrollTop = 5;

import * as _ from "lodash";
_.debounce();
复制代码

scrollTop和scrollHight的图示以下:

关于对话列表和联系人列表

对话列表

对话列表项中最后一条消息的时间会根据实际时间与当前时间的间隔而显示出不一样的格式

export function resolveTime(time,option) {
    const mesDatetime = new Date(time); // 获取当前时间戳
    const {year: curYear, month: curMonth, date: curDate} = getDatetimeMes(new Date());
    const {
        year: mesYear,
        month: mesMonth,
        date: mesDate
    } = getDatetimeMes(mesDatetime);
    // 若是是今天的消息,那么只返回时间,例如: 15:00
    if(curYear === mesYear && curMonth === mesMonth && curDate === mesDate) {
        return mesDatetime.toTimeString().slice(0,5);
    }
    else if(curYear === mesYear && curMonth === mesMonth) {
        switch (curDate - mesDate) {
            case 1 : 
                return '昨天' + option; // 昨天的消息,只返回 "昨天"
            case 2:
                return '前天' + option; // 前天的消息,只返回 "前天"
            default: return mesDatetime.toLocaleDateString().slice(5) + option; // 返回日期,例如 2/1
        }
    }
    else if(curYear === mesYear){
        return mesDatetime.toLocaleDateString().slice(5) + option; // 若是是往年的消息,返回年月日,例如2019/2/1
    }
    else {
        return mesDatetime.toLocaleDateString() + option;
    }
}
复制代码

联系人列表

联系人列表根据联系人首字母的大写进行分类,若是首字母为数字或其余非[A-Z]形式,则放入“#”类中。

// 引入cnchar来获取首字母
var cnchar = require('cnchar');
/** * 根据首字母进行分类(非26个大写字母则分到#) * @param allFriendsMes */
classifyFriendsByChar(allFriendsMes: Array<UserBasicMes>) {
    const friendsCharMap: Map<string,Array<UserBasicMes>> = new Map();
    allFriendsMes.forEach(friendMes => {
        const firstLetter: string = cnchar.spell(friendMes.user_name,'array','first')[0].toLocaleUpperCase();
        const pattern = /[A-Z]/g;
        if(pattern.test(firstLetter)) {
            // 若是首字母为[A-Z],那么加入到以该字母为键名的键值(数组)中
            this.solveCharMap(friendsCharMap,firstLetter,friendMes);
        }
        else {
            // 不然加入到以 "#" 为键名的键值(数组)中
            this.solveCharMap(friendsCharMap,'#',friendMes);
        }
    })
    const friendsList: {[char: string]: UserBasicMes[]} = {};
    friendsCharMap.forEach((friendsMes,char)=>{
        // 将数组内(同一类别)的好友根据unicode进行排序
        this.sortFriends(friendsMes);
        friendsList[char] = friendsMes;
    })
    return friendsList;
}
复制代码

关于图片发送

图片和截图在发送前都会被上传到服务器做为静态资源,而后将该静态资源的url进行持久化以及做为消息进行发送。

前端文件上传

前端经过element ui的文件上传组件进行文件上传,在上传以前,咱们须要确保上传的文件为图片且大小符合预期:

beforeImageUpload(file) {
    const imagepattern = /image\/(gif|jpeg|jpg|png|svg)/g;
    const isJPG = imagepattern.test(file.type);
    const isLt2M = file.size / 1024 / 1024 < 2;
    
    if (!isJPG) {
      showMessage(this,'error','您当前只能上传图片哦',0,true)
    }
    else if (!isLt2M) {
      showMessage(this,'error','上传图片大小不能超过 2MB!',0,true)
    }
    else {
      this.$store.commit('setImageLoading',true);
    }
}
复制代码

后端文件处理

首先咱们须要设置一个静态资源服务器,使得咱们能够经过url的方式来获取获得静态资源

// 将该目录设置为静态资源目录
app.useStaticAssets(join(__dirname,'../public/','static'), {
    prefix: '/static/',
});
复制代码

后端会经过FileInterceptor()装饰器和@UploadedFile()装饰器来得到文件对象并交由provider进行处理

@Post('upload')
@UseInterceptors(FileInterceptor('file'))
async uploadFile(@UploadedFile() file) {
  return await this.uploadService.getUrl(file);
}
复制代码

获取到文件对象以后,咱们须要将文件流写入到静态资源目录中,

async saveFile(file): Promise<string> {
    const datetime: number = Date.now();
    const fileName: string = datetime+file.originalname;
    // 文件的buffer
    const fileBuffer: Buffer = file.buffer;
    const filePath: string = join(__dirname,'../../public/','static',fileName);
    const staticFilePath: string = staticDirPath + fileName;
    // 建立一个写入流
    const writeFile: WriteStream = createWriteStream(filePath);
    return await new Promise((resolve,reject)=> {
        writeFile.write(fileBuffer,(error: Error) => {
            if(error) {
                throw new HttpException('文件上传失败',HttpStatus.FORBIDDEN);
            } 
            resolve(staticFilePath);
        });
    })        
}
复制代码

关于桌面消息通知

经过浏览器全局对象window中是否有全局属性Notification来判断当前浏览器是否支持Notification

export function supportNotification(): boolean {
    return ("Notification" in window);
}
复制代码

若是支持,咱们能够从Notification.permission中获取当前是否容许受权通知。其有三种可能:

  1. denied 拒绝受权通知
  2. granted 容许受权通知
  3. default 默认,由于不知道用户的选择,因此浏览器的行为与 denied 时相同

为了更少程度的打扰用户,对于选择denied的用户咱们不会再此弹出受权通知提示,只有当permission依然为default时,咱们会经过Notification.requestPermission()来请求向用户获取权限

if (Notification.permission === 'defalut') {
    Notification.requestPermission(function (permission) {
      // 若是用户赞成,就能够向他们发送通知
      if (permission === "granted") {
        var notification = new Notification("Hi there!");
      }
    });
}
复制代码

关于Web截图

在开发版本中,Web截图功能的实现主要依赖于库html2canvas和canvas来实现。其主要思路以下:

  1. 设置两个canvas,其中原页面在最下层,中间一层经过html2canvas将当前页面经过读取DOM并应用样式,从而生成为canvas图片,最上层用于截图效果的实现

    const middleCanvas = await html2canvas(document.body);
    const topCanvas = document.createElement("canvas");
    // 设置最上层canvas的宽高为body的宽高
    const {offsetWidth,offsetHeight} = document.body;
    topCanvas.width = offsetWidth;
    topCanvas.height = offsetHeight;
    复制代码
  2. 实现截图效果,监听鼠标的按下,移动,和松开

    // 鼠标按下事件,获取到鼠标按下的位置做为截图的初始位置
    onMousedown(e) {
        const {clipState} = this.$store.state;
        if(!clipState) return;
        const {offsetX,offsetY} = e;
        this.start = {
            startX: offsetX,
            startY: offsetY
        }
    }
    // 鼠标移动事件,用来生成截图的区域
    onMousemove(e) {
      if(!this.start) return;
      const {start,clipArea} = this;
      this.fillClipArea(start.startX,start.startY,e.offsetX-start.startX,e.offsetY-start.startY);
    }
    // 鼠标松开,截图结束,将canvas转化为图片进行下一步操做
    onMouseup(e) {
        this.canvasToClipImage(this.bodyCanvas);
        this.start = null;
    }
    复制代码
  3. fillClipArea(生成截图区域)函数的实现

    fillClipArea(x,y,w,h) {
      // 获取绘制canvas接口对象
      const ctx = this.topcanvas.getContext('2d');
      if(!ctx) return;
      ctx.fillStyle = 'rgba(0,0,0,0.6)'; // 设置截图区域的填充颜色
      ctx.strokeStyle="green"; // 设置截图区域的轮廓
      const width = document.body.offsetWidth;
      const height = document.body.offsetHeight;
      // 每次移动位置时,都须要擦除以前的绘制内容进行从新绘制(不然没法获得想要的效果)
      ctx.clearRect(0,0,width,height); 
      // 开始建立一条新路径
      ctx.beginPath();
      // 建立遮罩层
      ctx.globalCompositeOperation = "source-over";
      ctx.fillRect(0,0,width,height);
      //画框
      ctx.globalCompositeOperation = 'destination-out';
      ctx.fillRect(x,y,w,h);
      //描边
      ctx.globalCompositeOperation = "source-over";
      // 将路径的起点移动到(x,y)坐标
      ctx.moveTo(x,y);
      // 绘制一个矩形
      // lineto方法会使用直线链接子路径的终点到x,y坐标的方法(并不会真正地绘制)
      ctx.lineTo(x+w,y);
      ctx.lineTo(x+w,y+h);
      ctx.lineTo(x,y+h);
      ctx.lineTo(x,y);
      // 绘制当前已有的路径
      ctx.stroke();
      // 从当前点到起始点绘制一条直线路径,若是图形已是封闭的或者只有一个点,那么此方法不会作任何操做。
      ctx.closePath();
      this.clipArea = {
          x,
          y,
          w,
          h
      }
    }
    复制代码
  4. canvasToClipImage(canvas转为截图)函数的实现

    canvasToClipImage(canvas) {
        if(!canvas) return;
        // 建立一个新的canvas用于将截到的canvas数据绘制上去
        const newCanvas = document.createElement("canvas");
        const {x,y,w,h} = this.clipArea;
        newCanvas.width = w;
        newCanvas.height = h;
        // 获取中间层canvas(也就是经过html2canvas将body绘制成的canvas)的绘制接口对象
        const canvasCtx = this.middleCanvas.getContext('2d');
        const newCanvasCtx = newCanvas.getContext('2d');
        //获取到截图区域的图像数据(值得注意的是:它会得到区域隐含的像素数据,所以,截图效果并不十分理想)
        const imageData = canvasCtx.getImageData(0,0,w,h);
        // 将该图像数据绘制到新建立的canvas上
        newCanvasCtx.putImageData(imageData,0,0);
        // 将该canvas转化为 data URI 
        const dataUrl = newCanvas.toDataURL("image/png"); 
        console.log(dataUrl);
        //this.downloadImg(dataUrl);
    }
    复制代码
相关文章
相关标签/搜索