🍀我实现了个摸鱼聊天室🚀

一、技术栈

vue3 + Pina +Typescript + webSocket + Nest + Mysql + Redis + prisma + socket.io

体验地址:摸鱼聊天室

二、效果图

三、前端功能点

1、主题色修改

vue 复制代码
<template>
<t-color-picker
    v-model="formData.brandTheme"
    :color-modes="['monochrome']"
    format="HEX"
    :on-change="toggleBrandTheme"
  />
</template>
<script lang="ts" setup>
import { useSettingStore } from '@/store';   
const settingStore = useSettingStore();    
const toggleBrandTheme = () => {
    settingStore.updateConfig({ ...formData.value });
}
</script>
ts 复制代码
import { Color } from 'tvision-color';

type ModeType = 'dark' | 'light';
type TColorToken = Record<string, string>;
type TColorSeries = Record<string, TColorToken>;

interface State {
    mode: ModeType
    colorList: TColorSeries;
}
export const useSettingStore = defineStore('setting', {
    state: (): State => {
       mode: 'light',
       colorList: {}
    },
    getters: {
      displayMode: (): ModeType => {
          return this.mode;
      }  
    },
    changeBrandTheme(brandTheme: string) {
        const mode = this.displayMode;
        // 以主题色加显示模式作为键
        const colorKey = `${brandTheme}[${mode}]`;
        let colorMap = this.colorList[colorKey];
        if (colorMap === undefined) {
          const [{ colors: newPalette, primary: brandColorIndex }] = Color.getColorGradations({
            colors: [brandTheme],
            step: 10,
            remainInput: false, // 是否保留输入 不保留会矫正不合适的主题色
          });
     
          colorMap = generateColorMap(brandTheme, newPalette, mode, brandColorIndex);
          this.colorList[colorKey] = colorMap;  
        }
        
        insertThemeStylesheet(brandTheme, colorMap, mode);
        document.documentElement.setAttribute('theme-color', brandTheme);
    },
    updateConfig(payload: { brandTheme?: string, mode?: string, }) {
        for (const key in payload) {
             if (key === 'brandTheme') {
                 this.changeBrandTheme(payload[key]);
             }
        }
    }
})
typescript 复制代码
/**
 * 根据当前主题色、模式等情景 计算最后生成的色阶
 */
export function generateColorMap(theme: string, colorPalette: Array<string>, mode: ModeType, brandColorIdx: number) {
  const isDarkMode = mode === 'dark';

  if (isDarkMode) {
    colorPalette.reverse().map((color) => {
      const [h, s, l] = Color.colorTransform(color, 'hex', 'hsl');
      return Color.colorTransform([h, Number(s) - 4, l], 'hsl', 'hex');
    });
    brandColorIdx = 5;
    colorPalette[0] = `${colorPalette[brandColorIdx]}20`;
  }

  const colorMap: TColorToken = {
    '--td-brand-color': colorPalette[brandColorIdx], // 主题色
    '--td-brand-color-1': colorPalette[0], // light
    '--td-brand-color-2': colorPalette[1], // focus
    '--td-brand-color-3': colorPalette[2], // disabled
    '--td-brand-color-4': colorPalette[3],
    '--td-brand-color-5': colorPalette[4],
    '--td-brand-color-6': colorPalette[5],
    '--td-brand-color-7': brandColorIdx > 0 ? colorPalette[brandColorIdx - 1] : theme, // hover
    '--td-brand-color-8': colorPalette[brandColorIdx], // 主题色
    '--td-brand-color-9': brandColorIdx > 8 ? theme : colorPalette[brandColorIdx + 1], // click
    '--td-brand-color-10': colorPalette[9],
  };
  return colorMap;
}

/**
 * 将生成的样式嵌入头部
 */
export function insertThemeStylesheet(theme: string, colorMap: TColorToken, mode: ModeType) {
  const isDarkMode = mode === 'dark';
  const root = !isDarkMode ? `:root[theme-color='${theme}']` : `:root[theme-color='${theme}'][theme-mode='dark']`;

  const styleSheet = document.createElement('style');
  styleSheet.type = 'text/css';
  styleSheet.textContent = `${root}{
    --td-brand-color: ${colorMap['--td-brand-color']};
    --td-brand-color-1: ${colorMap['--td-brand-color-1']};
    --td-brand-color-2: ${colorMap['--td-brand-color-2']};
    --td-brand-color-3: ${colorMap['--td-brand-color-3']};
    --td-brand-color-4: ${colorMap['--td-brand-color-4']};
    --td-brand-color-5: ${colorMap['--td-brand-color-5']};
    --td-brand-color-6: ${colorMap['--td-brand-color-6']};
    --td-brand-color-7: ${colorMap['--td-brand-color-7']};
    --td-brand-color-8: ${colorMap['--td-brand-color-8']};
    --td-brand-color-9: ${colorMap['--td-brand-color-9']};
    --td-brand-color-10: ${colorMap['--td-brand-color-10']};
  }`;

  document.head.appendChild(styleSheet);
}

2、暗黑模式切换

vue 复制代码
<template>
  <t-tooltip :content="formData.mode !== 'light' ? '黑夜' : '白天'" placement="right">
      <t-button theme="default" shape="square" @click="toggleMode">
        <t-icon :name="formData.mode !== 'light' ? 'mode-light' : 'mode-dark'" />
      </t-button>
    </t-tooltip>
</template>
<script lang="ts" setup>
import { useSettingStore } from '@/store';   
const settingStore = useSettingStore();  
const toggleMode = () => {
  formData.value.mode = formData.value.mode === 'light' ? 'dark' : 'light';
  settingStore.updateConfig({ ...formData.value });
};
</script>
typescript 复制代码
import { Color } from 'tvision-color';

type ModeType = 'dark' | 'light';
type TColorToken = Record<string, string>;
type TColorSeries = Record<string, TColorToken>;

interface State {
    mode: ModeType
    colorList: TColorSeries;
}
export const useSettingStore = defineStore('setting', {
    state: (): State => {
       mode: 'light',
       colorList: {}
    },
    getters: {
      displayMode: (): ModeType => {
          return this.mode;
      }  
    },
    async changeMode(mode: ModeType) {
        let theme = mode;
        const isDarkMode = theme === 'dark';
        document.documentElement.setAttribute('theme-mode', isDarkMode ? 'dark' : '');
    },
    updateConfig(payload: { brandTheme?: string, mode?: string, }) {
        for (const key in payload) {
             if (key === 'brandTheme') {
                 this.changeBrandTheme(payload[key]);
             }
            
             if (key === 'mode') {
               this.changeMode(payload[key] as ModeType);
             } 
        }
    }
})

3、随机头像

# 🍀发现个有趣的工具可以用来随机头像🚀🚀

vue 复制代码
<template>
    <div class="ava" v-html="getRamdonPic(user)"></div>
</template>
<script setup lang="ts">
import type { UserInfo } from '@/api/model/user';    
import { useRandomPic } from '@/hooks';
    
const getRamdonPic = (row: UserInfo) => {
  const { getPic } = useRandomPic(row.nickName);
  return getPic();
};
</script>
typescript 复制代码
import multiavatar from '@multiavatar/multiavatar/esm';
export const useRandomPic = (name: string, timeStamp?: boolean) => {
  let randomId = `avatar_${name}}`;
  if (timeStamp) {
    randomId = `${randomId}_${new Date().getTime()}`;
  }

  const getPic = () => {
    // @ts-ignore
    return multiavatar(randomId);
  };
  return {
    getPic,
  };
};

4、webSocket 使用

typescript 复制代码
import { io } from 'socket.io-client';

// 启动 websocket
startSocket(userId: string) {
  if (!this.socket) {
    this.socket = io(domain);
    this.socket.on('connect', () => {
      this.socket.emit('join', userId);

      // 监听
      this.socket.on('message', (payload: { [key: string]: any }) => {
        // 加入聊天室
        if (payload.type === 'joinRoom') {
          if (+payload.userId !== +userId && payload.chatroomName) {
            MessagePlugin.success({
              content: `${payload.nickName} 加入了${payload.chatroomName}`,
              placement: 'top-right',
            });
          }
        }

        // 发送消息
        if (payload.type === 'sendMessage') {
          const { sendUserId, chatroomId, message, chatType } = payload;
          const { content } = message;
        }

        // 好友申请
        if (payload.type === 'addFriendRequest') {
          MessagePlugin.success({
            content: `${payload.data.nickName} 向您发送了好友申请`,
            placement: 'top',
          });
        }

        // 好友通过/拒绝
        if (payload.type === 'friendRequest') {
          if (payload.data.status === 1) {
            MessagePlugin.success({
              content: `${payload.data.nickName} 同意了您的好友申请`,
              placement: 'top',
            });
          } else if (payload.data.status === 2) {
            MessagePlugin.error({
              content: `${payload.data.nickName} 拒绝了您的好友申请`,
              placement: 'top',
            });
          }
        }
      });
    });
  }
}

四、后端功能点

1、redis 使用

案例:模拟注册校验验证码是否正确

  • 配置 redis
typescript 复制代码
// redis.module.ts

import { Global, Module } from '@nestjs/common';
import { RedisService } from './redis.service';
import { createClient } from 'redis';

@Global()
@Module({
  providers: [
    RedisService,
    {
      provide: 'REDIS_CLIENT',
      async useFactory() {
        const client = createClient({
            socket: {
                host: 'localhost',
                port: 6379
            },
        });
        await client.connect();
        return client;
      }
    }
  ],
  exports: [RedisService]
})
export class RedisModule {}
typescript 复制代码
// redis.service

import { Inject, Injectable } from '@nestjs/common';
import { RedisClientType } from 'redis';

@Injectable()
export class RedisService {
  @Inject('REDIS_CLIENT')
  private redisClient: RedisClientType;

  async get(key: string) {
    return await this.redisClient.get(key);
  }

  async set(key: string, value: string | number, ttl?: number) {
    await this.redisClient.set(key, value);

    if (ttl) {
      await this.redisClient.expire(key, ttl);
    }
  }
}
  • 导入 redis
typescript 复制代码
// app.module.ts

import { RedisModule } from './redis/redis.module';
@Module({
    imports:[
        RedisModule, // 导入
    ],
    controllers: [AppController],
    providers:[
        AppService
    ]
})
export class AppModule {}
  • 使用
typescript 复制代码
import { RedisService } from 'src/redis/redis.service';
export class UserController {
  @Inject(RedisService)
  private redisService: RedisService;
  
  // 获取校验码
  @Get('captcha')
  async captcha(@Query('email') email: string) {
     const code = Math.random().toString().slice(0, 8);
     await this.redisService.set(`captcha_${address}`, code, 5 * 60);
     
     // 发送验证码
     await this.emailService.sendMail({
        to: email,
        subject: '注册验证码',
        html: `<p>你的注册验证码是 ${code}</p>`,
      });
      return '发送成功';
  }
   
  // 注册
  @Post('register')
  async register(@Body() registerUser: RegisterUserDto) {
      const captcha = await this.redisService.get(`captcha_${user.email}`);
      
      if (!captcha) {
        throw new HttpException('验证码已失效', HttpStatus.BAD_REQUEST);
      }
      
      if (user.captcha !== captcha) {
        throw new HttpException('验证码不正确', HttpStatus.BAD_REQUEST);
      }
      
      // TODO 处理注册
  }
}

2、prisma 使用

案例:获取当前用户信息

  • 定义 prisma
typescript 复制代码
// prisma.module.ts

import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';

@Global()
@Module({
  providers: [PrismaService],
  exports: [PrismaService],
})
export class PrismaModule {}
typescript 复制代码
// prisma.service.ts
import { Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
  [x: string]: any;
  constructor() {
    super({
      log: [
        {
          emit: 'stdout',
          level: 'query',
        },
      ],
    });
  }

  async onModuleInit() {
    await this.$connect();
  }
}
  • 导入 prisma
typescript 复制代码
// app.module.ts

import { PrismaModule } from './prisma/prisma.module';
@Module({
    imports:[
        PrismaModule, // 导入
    ],
    controllers: [AppController],
    providers:[
        AppService
    ]
})
export class AppModule {}
  • 定义表结构
properties 复制代码
// 用户表
model User {
  id           Int      @id @default(autoincrement())
  userName     String   @unique @db.VarChar(50)
  password     String   @db.VarChar(50)
  nickName     String   @db.VarChar(50)
  email        String   @db.VarChar(50)
  headPic      String   @default("") @db.VarChar(100)
  createTime   DateTime @default(now())
  updateTime   DateTime @updatedAt
  // 主题色
  theme        String?  @default("") @db.VarChar(50)
  // 1 在线 2 离线 3 隐身
  status       Int?     @default(3)
  // 是否开启邮件通知(添加好友通知)0 不开启
  notification Int?     @default(0)
  // 个性签名
  motto        String?  @default("此人没个性 没有个性签名") @db.VarChar(100)

  friends        Friendship[] @relation("userToFriend")
  inverseFriends Friendship[] @relation("friendToUser")

  toUserInfo   FriendRequest[] @relation("toUserId")
  fromUserInfo FriendRequest[] @relation("fromUserId")
}
  • 执行命令
txt 复制代码
npx prisma generate  生成 docs client
npx prisma migrate reset  重置数据库
npx prisma migrate dev --name xxx 创建 model
  • 使用 prisma
typescript 复制代码
// user.service.ts
import { PrismaService } from 'src/prisma/prisma.service';
export class UserService {
  @Inject(PrismaService)
  private prismaService: PrismaService;
    
  async findUserDetailById(userID: number) {
    const userfield = this.userfield;
    const user = await this.prismaService.user.findUnique({
      where: {
        id: userId,
      },
      select: {
        ...userfield,
        email: true,
      },
    });
    return user;
  }
}

更多 prisma 使用方式,可以执行命令 npx prisma generate


3、权限校验

案例:校验用户是否已登录

  • 中间件
typescript 复制代码
import { CanActivate, ExecutionContext, Inject, Injectable, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';
import { Request, Response } from 'express';
import { Observable } from 'rxjs';

interface JwtUserData {
  userId: number;
  userName: string;
  nickName: string;
}

declare module 'express' {
  interface Request {
    user: JwtUserData;
  }
}

@Injectable()
export class AuthGuard implements CanActivate {
  @Inject()
  private reflector: Reflector;

  @Inject(JwtService)
  private jwtService: JwtService;

  canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean> {
    const request: Request = context.switchToHttp().getRequest();
    const response: Response = context.switchToHttp().getResponse();

    /*
     * 用 reflector 从目标 controller 和 handler 上拿到 require-login 的 metadata
     * @SetMetadata('require-login', true)
     */
    const requireLogin = this.reflector.getAllAndOverride('require-login', [context.getClass(), context.getHandler()]);


    // 如果没有 metadata,就是不需要登录,返回 true 放行
    if (!requireLogin) {
      return true;
    }

    const authorization = request.headers.authorization;

    if (!authorization) {
      throw new UnauthorizedException('用户未登录');
    }

    try {
      const token = authorization.split(' ')[1];
      const data = this.jwtService.verify<JwtUserData>(token);

      // 刷新 token 续登录时长
      response.header(
        'token',
        this.jwtService.sign(
          {
            userId: data.userId,
            userName: data.userName,
            nickName: data.nickName,
          },
          {
            expiresIn: '7d',
          },
        ),
      );

      return true;
    } catch (e) {
      const message = e?.response?.message || 'token 失效,请重新登录';
      throw new UnauthorizedException(message);
    }
  }
}
  • 中间件注册
typescript 复制代码
import { JwtModule } from '@nestjs/jwt';
import { AuthGuard } from './auth.guard';
import { APP_GUARD } from '@nestjs/core';
@Module({
    imports:[
      JwtModule.registerAsync({
          global: true,
          useFactory() {
            return {
              secret: 'asdkjfeoc',
              signOptions: {
                expiresIn: '30m', // 默认 30 分钟
              },
            };
          },
        }),
    ],
    controllers: [AppController],
    providers:[
       AppService,
       {
          provide: APP_GUARD,
          useClass: AuthGuard,
        },
    ]
})
export class AppModule {}
  • 使用
typescript 复制代码
import { JwtService } from '@nestjs/jwt';
import { RequireLogin } from 'src/custom.decorator';
import { SetMetadata } from '@nestjs/common';

export class UserController {
  @Inject(JwtService)
  private jwtService: JwtService;
    
  @Post('login')
  async login(@Body() loginUser: LoginUserDto) {
      // 校验账号密码正确后,保存用户信息
      
      const token = this.jwtService.sign(
        {
           userId: user.id,
           userName: user.userName,
           nickName: user.nickName,
        },
        {
          expiresIn: '1d',   
        }
      )
      
      return {
          token,
      }
  }
    
   
  // 获取用户信息(需登录)
  @Get('info')
  SetMetadata('require-login', true) // 校验是否已登录
  async info(@UserInfo('userId') userId: number) {
    return this.userService.findUserDetailById(userId);
  }
}

4、发送邮件

案例:发送好友申请通知

  • 定义 Email
typescript 复制代码
// email.controller.ts
import { Controller } from '@nestjs/common';
import { EmailService } from './email.service';

@Controller('email')
export class EmailController {
  constructor(private readonly emailService: EmailService) {}
}
typescript 复制代码
// email.module.ts
import { Global, Module } from '@nestjs/common';
import { EmailService } from './email.service';
import { EmailController } from './email.controller';

@Global()
@Module({
  controllers: [EmailController],
  providers: [EmailService],
  exports: [EmailService]
})
export class EmailModule {}
typescript 复制代码
// email.service.ts
import { Injectable } from '@nestjs/common';
import { createTransport, Transporter } from 'nodemailer';

@Injectable()
export class EmailService {
  transporter: Transporter;

  constructor() {
    this.transporter = createTransport({
      host: 'smtp.qq.com',
      port: 587,
      secure: false,
      auth: {
        user: '', // 发送者(开发者)的邮箱地址
        pass: '', // 发送者(开发者)的 token
      },
    });
  }

  async sendMail({ to, subject, html }) {
    await this.transporter.sendMail({
      from: {
        name: '', // 主题
        address: '', // 发送者(开发者)的邮箱地址
      },
      to,
      subject,
      html,
    });
  }
}
  • 导入 email
typescript 复制代码
// app.module.ts

import { EmailModule } from './email/email.module';
@Module({
    imports:[
        EmailModule, // 导入
    ],
    controllers: [AppController],
    providers:[
        AppService
    ]
})
export class AppModule {}
  • 使用
typescript 复制代码
// friendApplication.controller.ts

import { EmailService } from 'src/email/email.service';
import { UserService } from 'src/user/user.service';

export class FriendApplicationController {
    
  @Inject(UserService)
  private userService: UserService;
 
  @Inject(EmailService)
  private emailService: EmailService;
    
  @Post('add_request')
  async add(@Body() friendAddDto: FriendAddDto, @Body('userId') userId: number, @Body('nickName') nickName: string) {
      // 添加一条好友申请记录
      const res = await this.friendApplicationService.add(friendAddDto, userId);
      
      // 获取用户邮箱
      const detail = await this.userService.findUserDetailById(+friendAddDto.friendId);
      // 邮件通知
      if (detail.email && detail.notification) {
          await this.emailService.sendMail({
            to: detail.email,
            subject: '好友申请',
            html: `<p>收到了来自摸鱼聊天室的一条好友 ${nickName} 申请</p>`,
          });
      } 
  }  
}

5、socket 使用

  • 定义 gateway
typescript 复制代码
import { MessageBody, SubscribeMessage, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
import { ChatService } from './chat.service';
import { Server, Socket } from 'socket.io';
import { Inject } from '@nestjs/common';
import { ChatHistoryService } from 'src/chat-history/chat-history.service';
import { ChatroomService } from 'src/chatroom/chatroom.service';

interface JoinRoomPayload {
  chatroomId: number
  userId: number
  chatroomName: string
}

interface SendMessagePayload {
  sendUserId: number;
  chatroomId: number;
  message: {
    type: 'text' | 'image',
    content: string
  }
}

@WebSocketGateway({cors: { origin: '*' }})
export class ChatGateway {
  constructor(private readonly chatService: ChatService) {}

  @Inject(ChatHistoryService)
  private chatHistoryService: ChatHistoryService

  @WebSocketServer() server: Server;

  @Inject(ChatroomService)
  private chatroomService: ChatroomService;

  // 加入聊天室
  @SubscribeMessage('joinRoom')
  async joinRoom(client: Socket, payload: JoinRoomPayload) {
    const userIdList = await this.chatroomService.members(payload.chatroomId);

    const item = userIdList.find(item => +item.id === +payload.userId);
    const nickName = item.nickName;
    for (let i = 0; i < userIdList.length; i++) {
      const { id } = userIdList[i];
      this.server.to(id.toString()).emit('message', {
        type: 'joinRoom',
        userId: payload.userId,
        nickName: nickName,
        chatroomName: payload.chatroomName
      });
    }
  }

  // 通过 userId 链接 socket
  @SubscribeMessage('join')
  join(client: Socket, userId: number): void {
    const key = userId.toString();

    client.join(key);
  }

  // 发送消息
  @SubscribeMessage('sendMessage')
  async sendMessage(@MessageBody() payload: SendMessagePayload) {
    // const roomName = payload.chatroomId.toString();

    await this.chatHistoryService.add(payload.chatroomId, {
      content: payload.message.content,
      type: payload.message.type === 'image' ? 1 : 0,
      chatroomId: payload.chatroomId,
      senderId: payload.sendUserId
    });

    // 查找该聊天室的所有成员
    const userIdList = await this.chatroomService.members(payload.chatroomId);

    for(let i = 0; i < userIdList.length; i++) {
      const { id } = userIdList[i];
      this.server.to(id.toString()).emit('message', {
        type: 'sendMessage',
        ...payload,
      });
    }
  }
}

其他文章

# 我很好奇客户会用得懂这个组件吗

#【🍀新鲜出炉 】十个 "如何"从零搭建 Nuxt3 项目

# 图解封装多种数据结构(栈、队列、优先级队列、链表、双向链表、二叉树)

# 面试官提问:为什么表单提交不会出现跨域

# 【前端】整理了一些网络相关面试题(🍀拿走不谢🍀)

# 学完 Pinia 真香,不想用 vuex 了

# 非标题党:前端项目编程规范化配置(大厂规范)

相关推荐
im_AMBER4 小时前
React 02
前端·笔记·学习·react.js·前端框架
玲小珑4 小时前
LangChain.js 完全开发手册(十六)实战综合项目二:AI 驱动的代码助手
前端·langchain·ai编程
井柏然4 小时前
从 Monorepo 重温 ESM 的模块化机制
前端·javascript·前端工程化
晓得迷路了4 小时前
栗子前端技术周刊第 102 期 - Vite+ 正式发布、React Native 0.82、Nitro v3 alpha 版...
前端·javascript·vite
XXX-X-XXJ4 小时前
Vue Router完全指南 —— 从基础配置到权限控制
前端·javascript·vue.js
云和数据.ChenGuang4 小时前
vue钩子函数调用问题
前端·javascript·vue.js
鹏多多4 小时前
React动画方案对比:CSS动画和Framer Motion和React Spring
前端·javascript·react.js
亿元程序员5 小时前
8年游戏主程,一篇文章,多少收益?
前端
IT_陈寒5 小时前
5个Java 21新特性实战技巧,让你的代码性能飙升200%!
前端·人工智能·后端