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

Hello,宝子们,今天我们正式开始我们的聊天室实战项目,没有看过之前系列的,请移步【vue3系列实战三(第一节)】vue3 + websocket 多人聊天室实战基础

项目成果预览图:

第一步:项目初始化与目录结构

前端项目初始化

# 创建 Vue3 + TypeScript 项目
npm init vite@latest chat-client -- --template vue-ts
cd chat-client

# 安装 ElementPlus 和 TailwindCSS
npm install element-plus @element-plus/icons-vue
npm install -D tailwindcss@3 postcss autoprefixer
npx tailwindcss init -p

# 安装 Socket.IO 客户端
npm install socket.io-client

注意:最新版的tailwindcss是x4,我使用的是x3,大家可以自行选择版本

后端项目初始化

# 创建后端项目目录
mkdir chat-server && cd chat-server
npm init -y

# 安装依赖
npm install koa koa-bodyparser koa-static mongoose socket.io bcryptjs jsonwebtoken cors

# 安装 TypeScript 和类型定义
npm install -D typescript @types/koa @types/koa-bodyparser @types/koa-static @types/node @types/cors @types/bcryptjs @types/jsonwebtoken ts-node-dev
npx tsc --init

完整的目录结构如下:

chat-app/
├── chat-client/          # 前端项目
│   ├── src/
│   │   ├── api/          # API 请求
│   │   ├── assets/       # 静态资源
│   │   ├── components/   # 组件
│   │   ├── composables/  # 组合式函数
│   │   ├── router/       # 路由
│   │   ├── stores/       # Pinia 状态管理
│   │   ├── types/        # 类型定义
│   │   ├── utils/        # 工具函数
│   │   ├── views/        # 视图
│   │   ├── App.vue
│   │   └── main.ts
│   ├── index.html
│   ├── package.json
│   ├── tailwind.config.js
│
└── chat-server/          # 后端项目
    ├── src/
    │   ├── config/       # 配置文件
    │   ├── controllers/  # 控制器
    │   ├── middleware/   # 中间件
    │   ├── models/       # 数据模型
    │   ├── routes/       # 路由
    │   ├── services/     # 服务
    │   ├── sockets/      # Socket.IO 处理
    │   ├── utils/        # 工具函数
    │   └── app.ts        # 应用入口
    ├── package.json
    └── tsconfig.json

在这里插入图片描述

第二步:前端实现

配置 TailwindCSS

在这里插入图片描述

/** @type {import('tailwindcss').Config} */
export default {
  content: ["./index.html", "./src/**/*.{vue,js,ts,jsx,tsx}"],
  theme: {
    extend: {},
  },
  plugins: [],
};



配置style.css:

@tailwind base;
@tailwind components;
@tailwind utilities;

配置 ElementPlus

在 main.ts 中引入并配置 ElementPlus:

import { createApp } from "vue";
import App from "./App.vue";
import "./style.css";
import ElementPlus from "element-plus";
import "element-plus/dist/index.css";
import * as ElementPlusIconsVue from "@element-plus/icons-vue";

const app = createApp(App);

// 注册 ElementPlus 图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
  app.component(key, component);
}

app.use(ElementPlus);
app.mount("#app");


在App.vue中进行elementplus 和 tailwindcss 配置检测:

<template>
  <div>
    <h1 class="text-red-400">vue3 chat box</h1>
    <el-button :icon="Search" circle />
    <el-button type="primary" :icon="Edit" circle />
    <el-button type="success" :icon="Check" circle />
    <el-button type="info" :icon="Message" circle />
    <el-button type="warning" :icon="Star" circle />
    <el-button type="danger" :icon="Delete" circle />
  </div>
</template>

<script lang="ts" setup>
import {
  Check,
  Delete,
  Edit,
  Message,
  Search,
  Star,
} from "@element-plus/icons-vue";
</script>

<style scoped></style>

如果出现这个界面,那么就配置完成了!

我们前端主要分成两个页面,第一是登录页面,第二是聊天室页面,所以我们引入vue-router,页面样式就不带大家写了,主要关注功能实现。

npm install vue-router

接下来新建:
在这里插入图片描述
聊天室页面Chat.vue:

<template>
    <div class=" flex justify-center items-center  h-screen">
        <div class="flex flex-col bg-gradient-to-b from-gray-50 to-gray-100 overflow-hidden container">
            <!-- 头部导航 -->
            <header class="bg-white/80 backdrop-blur-lg shadow-sm border-b border-gray-200/30">
                <div class="max-w-7xl mx-auto px-6 py-3 flex justify-between items-center">
                    <h1 class="text-2xl font-light text-gray-800">
                        <span class="text-blue-500 font-normal">多人聊天室</span>
                    </h1>
                    <div class="flex items-center space-x-4">
                        <div class="flex items-center">
                            <el-avatar 
                                size="small" 
                                src="https://api.dicebear.com/7.x/avataaars/svg?seed=当前用户" 
                                class="shadow-sm"
                            />
                            <span class="ml-2 font-medium text-gray-700">当前用户</span>
                        </div>
                        <el-button 
                            type="danger" 
                            size="small" 
                            class="apple-btn-danger">
                            退出
                        </el-button>
                    </div>
                </div>
            </header>

            <!-- 主内容区 -->
            <div class="flex-1 flex overflow-hidden">
                <!-- 侧边栏: 在线用户列表 -->
                <div class="w-80 bg-white/80 backdrop-blur-lg border-r border-gray-200/30 flex flex-col">
                    <div class="p-4 border-b border-gray-200/50">
                        <div class="flex justify-between items-center">
                            <h2 class="text-xl font-light text-gray-800">在线用户</h2>
                            <el-badge :value="userList.length" type="primary" class="badge-apple" />
                        </div>
                        <div class="mt-2 relative">
                            <el-input 
                                placeholder="搜索用户..." 
                                :prefix-icon="Search"
                                size="small"
                                class="apple-input-search"
                            />
                        </div>
                    </div>
                    <div class="overflow-y-auto h-full">
                        <!-- 用户列表 -->
                        <el-menu class="user-list">
                            <el-menu-item 
                                v-for="user in userList" 
                                :key="user.id" 
                                :index="user.id"
                                class="user-item"
                            >
                                <div class="flex items-center">
                                    <div class="relative">
                                        <el-avatar 
                                            size="default" 
                                            :src="user.avatar" 
                                        />
                                        <div :class="[
                                            'absolute bottom-0 right-0 w-3 h-3 rounded-full border-2 border-white',
                                            user.isActive ? 'bg-green-500' : 'bg-yellow-500'
                                        ]"></div>
                                    </div>
                                    <div class="ml-3">
                                        <div class="font-medium text-gray-800">{{ user.name }}</div>
                                        <div class="text-xs text-gray-500">{{ user.status }}</div>
                                    </div>
                                </div>
                            </el-menu-item>
                        </el-menu>
                    </div>
                </div>

                <!-- 聊天区域 -->
                <div class="flex-1 flex flex-col bg-white/70 backdrop-blur-lg">
                    <!-- 消息列表 -->
                    <div class="flex-1 overflow-y-auto p-6 space-y-4">
                        <div 
                            v-for="(message, index) in messageList" 
                            :key="index"
                            :class="[
                                'flex', 
                                message.isMe ? 'justify-end' : 'justify-start'
                            ]"
                        >
                            <div :class="[
                                'max-w-md p-3 rounded-2xl shadow-sm', 
                                message.isMe 
                                    ? 'bg-blue-500 text-white rounded-br-none' 
                                    : 'bg-gray-100 text-gray-800 rounded-bl-none'
                            ]">
                                <div v-if="!message.isMe" class="font-medium text-xs mb-1">
                                    {{ message.sender }}
                                </div>
                                <div class="leading-relaxed">{{ message.content }}</div>
                                <div :class="[
                                    'text-xs mt-1 text-right', 
                                    message.isMe ? 'text-blue-100' : 'text-gray-400'
                                ]">
                                    {{ message.time }}
                                </div>
                            </div>
                        </div>
                    </div>

                    <!-- 输入框 -->
                    <div class="border-t border-gray-200/30 p-4 bg-white/80 backdrop-blur-lg">
                        <div class="flex items-center space-x-3">
                            <el-button 
                                circle 
                                :icon="Paperclip"
                                class="apple-btn-icon"
                            />
                            <el-input 
                                type="textarea" 
                                :rows="3"
                                placeholder="输入消息..." 
                                resize="none"
                                class="apple-textarea"
                            />
                            <el-button 
                                circle 
                                :icon="Position"
                                type="primary"
                                class="apple-btn-primary"
                            />
                        </div>
                    </div>
                </div>
            </div>
            <footer class="text-center text-gray-500 text-sm p-4">
                <p>
                   <a href="https://diamaxiaoku.com">@代码小库 2025 多人聊天室</a> 
                </p>
            </footer>
        </div>
    </div>
</template>

<script setup>
import { Search, Position, Paperclip } from '@element-plus/icons-vue';
import { ref } from 'vue';

// 用户列表数据
const userList = ref([
    {
        id: '1',
        name: '张三',
        avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=张三',
        status: '活跃',
        isActive: true
    },
    {
        id: '2',
        name: '李四',
        avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=李四',
        status: '正在输入...',
        isActive: true
    },
    {
        id: '3',
        name: '王五',
        avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=王五',
        status: '5分钟前活跃',
        isActive: false
    },
    {
        id: '4',
        name: '赵六',
        avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=赵六',
        status: '10分钟前活跃',
        isActive: false
    },
    {
        id: '5',
        name: '钱七',
        avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=钱七',
        status: '刚刚登录',
        isActive: true
    }
]);

// 消息列表数据
const messageList = ref([
    {
        id: '1',
        sender: '张三',
        content: '大家好,今天天气不错!',
        time: '10:25',
        isMe: false
    },
    {
        id: '2',
        sender: '李四',
        content: '确实不错,适合出去玩。',
        time: '10:26',
        isMe: false
    },
    {
        id: '3',
        sender: '王五',
        content: '我正在准备出门呢,有人一起吗?',
        time: '10:28',
        isMe: false
    },
    {
        id: '4',
        sender: '当前用户',
        content: '我可以,你们想去哪里玩?',
        time: '10:30',
        isMe: true
    },
    {
        id: '5',
        sender: '李四',
        content: '要不去公园走走吧,刚好那里有个展览。',
        time: '10:32',
        isMe: false
    },
    {
        id: '6',
        sender: '当前用户',
        content: '好主意,我很久没去公园了,那个展览是什么主题的?',
        time: '10:33',
        isMe: true
    },
    {
        id: '7',
        sender: '张三',
        content: '是一个关于现代艺术的展览,听说挺有意思的。',
        time: '10:35',
        isMe: false
    },
    {
        id: '8',
        sender: '王五',
        content: '我们几点集合?要不11点公园门口见?',
        time: '10:36',
        isMe: false
    }
]);
</script>

<style scoped>
/* 自定义滚动条 */
.overflow-y-auto::-webkit-scrollbar {
    width: 6px;
}

.overflow-y-auto::-webkit-scrollbar-track {
    background: transparent;
}

.overflow-y-auto::-webkit-scrollbar-thumb {
    background: rgba(0, 0, 0, 0.1);
    border-radius: 3px;
}

.overflow-y-auto::-webkit-scrollbar-thumb:hover {
    background: rgba(0, 0, 0, 0.2);
}

/* 容器样式 */
.container {
    height: 90vh;
    max-width: 1280px;
    margin: 0 auto;
}

/* 用户列表样式 */
:deep(.user-list) {
    border-right: none;
}

:deep(.user-item) {
    height: auto;
    padding: 12px 16px;
    line-height: normal;
    border-bottom: 1px solid rgba(229, 231, 235, 0.5);
}

:deep(.user-item:hover) {
    background-color: rgba(243, 244, 246, 0.8);
}

:deep(.user-item.is-active) {
    background-color: rgba(243, 244, 246, 0.8);
    color: inherit;
}

/* 自定义的输入框 */
:deep(.apple-input-search .el-input__wrapper) {
    border-radius: 18px;
    background-color: #f1f1f4;
    box-shadow: none;
    border: 1px solid transparent;
}

:deep(.apple-input-search .el-input__wrapper:hover) {
    border-color: #e1e1e6;
}

:deep(.apple-input-search .el-input__wrapper.is-focus) {
    box-shadow: 0 0 0 2px rgba(0, 125, 250, 0.2) !important;
    border-color: #0070e0;
}

/* 自定义的文本域 */
:deep(.apple-textarea .el-textarea__inner) {
    border-radius: 20px;
    padding: 12px 18px;
    resize: none;
    box-shadow: none;
    border: 1px solid #e4e4e4;
    transition: all 0.3s;
    min-height: 46px !important;
    max-height: 100px;
}

:deep(.apple-textarea .el-textarea__inner:focus) {
    border-color: #0070e0;
    box-shadow: 0 0 0 3px rgba(0, 125, 250, 0.2);
}

/* 自定义的按钮 */
:deep(.apple-btn-primary) {
    background: linear-gradient(135deg, #0a84ff, #0066cc);
    border: none;
    box-shadow: 0 2px 6px rgba(0, 102, 204, 0.3);
    transition: all 0.3s;
}

:deep(.apple-btn-primary:hover:not(:disabled)) {
    background: linear-gradient(135deg, #0091ff, #0074e0);
    box-shadow: 0 3px 8px rgba(0, 102, 204, 0.4);
    transform: translateY(-1px);
}

:deep(.apple-btn-icon) {
    background-color: #f5f5f7;
    border: 1px solid #e4e4e4;
    color: #666;
}

:deep(.apple-btn-icon:hover) {
    background-color: #e4e4e4;
}

:deep(.apple-btn-danger) {
    background: transparent;
    border: 1px solid #ff3b30;
    color: #ff3b30;
}

:deep(.apple-btn-danger:hover) {
    background-color: rgba(255, 59, 48, 0.1);
}

/* 徽章样式 */
:deep(.badge-apple .el-badge__content) {
    background-color: #0a84ff;
}
</style>

登录页面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">输入您的账户信息以继续</p>
            </div>
            
            <el-form label-position="top">
                <el-form-item>
                    <el-input 
                        placeholder="用户名" 
                        :prefix-icon="User"
                        class="apple-input" 
                    />
                </el-form-item>
                
                <el-form-item class="mt-4">
                    <el-input 
                        type="password" 
                        placeholder="密码" 
                        :prefix-icon="Lock"
                        class="apple-input" 
                    />
                </el-form-item>
                
                <div class="flex justify-between items-center mt-6 mb-8">
                    <el-checkbox 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">
                    登录
                </el-button>
                
                <div class="text-center mt-6 text-gray-500 text-sm">
                    还没有账号? <a href="#" class="text-blue-500 hover:text-blue-600 transition">注册</a>
                </div>
            </el-form>
        </div>
    </div>
</template>

<script setup>
import { User, Lock } from '@element-plus/icons-vue';
</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>

router/index.ts:
在这里插入图片描述
我们动态的引入两个页面:

import { createRouter, createWebHistory } from 'vue-router';

const routes = [
  {
    path: '/',
    redirect: '/login'
  },
  {
    path: '/login',
    name: 'Login',
    component: () => import('../views/Login.vue')
  },
  {
    path: '/chat',
    name: 'Chat',
    component: () => import('../views/Chat.vue')
  }
];

const router = createRouter({
  history: createWebHistory(),
  routes
});

export default router;

在main.ts里面引入路由:
在这里插入图片描述

+ import router from "./router";
+ app.use(router);

不要忘记在根组件使用我们的出口,不写这个你访问不到任何路由页面,对路由不熟悉的同学可以查看官网概览一遍:
在这里插入图片描述

这样我们就可以访问我们的页面了:
在这里插入图片描述
在这里插入图片描述
小结:我们的两个页面已经写好了,第一:样式不会带大家一步步写,没有必要,大家也可以根据自己的想法和审美去设计完成样式。第二:我们的聊天室的页面的数据是写死的假数据,并且没有绑定任何方法和功能操作,接下来我们开始我们的前端功能实现以及,组件的抽离。

登录页面功能实现:表单验证以及表单提交。

Login.vue:

// 表单验证
import type { FormInstance, FormRules } from 'element-plus';

// 表单验证规则
const rules = 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 loginFormRef = ref<FormInstance | null>(null); // 表单实例
+ const loading = ref(false); // 加载状态

添加按钮loading效果,防止用户重复点击:
在这里插入图片描述
使用双向绑定获取用户输入的内容:


// 表单数据
const loginForm = reactive<LoginParams>({
  username: '',
  password: ''
});

在这里插入图片描述
注意: 表单项需要添加对应的prop值,这个prop值你可以理解为原生form表单值的name,否则无法获取到表单值。

绑定登录按钮点击事件:

      <el-button 
                    type="primary" 
                    class="w-full apple-button"
                    :loading="loading"
                    + @click="handleLogin"
                    >
                    登录
                </el-button>
// 登录处理
const handleLogin = async () => {
    if (!loginFormRef.value) return;
    loginFormRef.value.validate((valid) => { // 表单验证
        // 如果表单验证通过
        if (valid) {
            console.log("loginForm", loginForm)
        }
    })
}

现在我们来看看效果:
在这里插入图片描述
完整代码如下:

<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">输入您的账户信息以继续</p>
            </div>

            <el-form ref="loginFormRef" :model="loginForm" :rules="rules" 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="handleRegister">注册</a>
                </div>
            </el-form>
        </div>
    </div>
</template>

<script setup lang="ts">
import { User, Lock } from '@element-plus/icons-vue';
import { ref, reactive } from 'vue';
import { ElMessage } from 'element-plus';
import type { FormInstance, FormRules } from 'element-plus';

const loginFormRef = ref<FormInstance | null>(null); // 表单实例
const loading = ref(false); // 加载状态

interface LoginParams {
  username: string;
  password: string;
}



// 表单数据
const loginForm = reactive<LoginParams>({
    username: '',
    password: ''
});

// 表单验证规则
const rules = 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 rememberMe = ref(false);

// 登录处理
const handleLogin = async () => {
    if (!loginFormRef.value) return;
    loginFormRef.value.validate((valid) => { // 表单验证
        // 如果表单验证通过
        if (valid) {
            console.log("loginForm", loginForm)
        }
    })
}
// 注册处理
const handleRegister = () => {
    ElMessage.info('注册功能暂未开放,请直接使用任意用户名和密码登录');
};
</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>

接下来我们来对Chat.vue页面进行一个组件的拆分,保证代码的可读性。

在这里插入图片描述
ChatHeader.vue:组件通信props + emit;

<template>
  <header class="bg-white/80 backdrop-blur-lg shadow-sm border-b border-gray-200/30">
    <div class="max-w-7xl mx-auto px-6 py-3 flex justify-between items-center">
      <h1 class="text-2xl font-light text-gray-800">
        <span class="text-blue-500 font-normal">{{ title }}</span>
      </h1>
      <div class="flex items-center space-x-4">
        <div class="flex items-center">
          <el-avatar 
            size="small" 
            :src="currentUser.avatar" 
            class="shadow-sm"
          />
          <span class="ml-2 font-medium text-gray-700">{{ currentUser.username }}</span>
        </div>
        <el-button 
          type="danger" 
          size="small" 
          class="apple-btn-danger"
          @click="handleLogout"
        >
          退出
        </el-button>
      </div>
    </div>
  </header>
</template>

<script setup lang="ts">
// 用户信息接口
interface CurrentUser {
  username: string;
  avatar: string;
  id: string;
}

// 组件属性
interface Props {
  title: string;
  currentUser: CurrentUser;
}

// 定义属性
const props = defineProps<Props>();

// 定义事件
const emit = defineEmits<{
  (e: 'logout'): void;
}>();

// 处理退出登录
const handleLogout = () => {
  emit('logout');
};
</script>

<style scoped>
/* 自定义的按钮 */
:deep(.apple-btn-danger) {
  background: transparent;
  border: 1px solid #ff3b30;
  color: #ff3b30;
}

:deep(.apple-btn-danger:hover) {
  background-color: rgba(255, 59, 48, 0.1);
}
</style> 

用户列表组件:UserList.vue

<template>
  <div class="w-80 bg-white/80 backdrop-blur-lg border-r border-gray-200/30 flex flex-col">
    <div class="p-4 border-b border-gray-200/50">
      <div class="flex justify-between items-center">
        <h2 class="text-xl font-light text-gray-800">在线用户</h2>
        <el-badge :value="userList.length" type="primary" class="badge-apple" />
      </div>
      <div class="mt-2 relative">
        <el-input 
          v-model="searchQuery"
          placeholder="搜索用户..." 
          :prefix-icon="Search"
          size="small"
          class="apple-input-search"
        />
      </div>
    </div>
    <div class="overflow-y-auto h-full">
      <!-- 用户列表 -->
      <el-menu class="user-list">
        <el-menu-item 
          v-for="user in filteredUsers" 
          :key="user.id" 
          :index="user.id"
          class="user-item"
          @click="selectUser(user)"
        >
          <div class="flex items-center">
            <div class="relative">
              <el-avatar 
                size="default" 
                :src="user.avatar" 
              />
              <div :class="[
                'absolute bottom-0 right-0 w-3 h-3 rounded-full border-2 border-white',
                user.isActive ? 'bg-green-500' : 'bg-yellow-500'
              ]"></div>
            </div>
            <div class="ml-3">
              <div class="font-medium text-gray-800">{{ user.name }}</div>
              <div class="text-xs text-gray-500">{{ user.status }}</div>
            </div>
          </div>
        </el-menu-item>
      </el-menu>
    </div>
  </div>
</template>

<script setup lang="ts">
import { Search } from '@element-plus/icons-vue';
import { ref, computed } from 'vue';

// 用户接口定义
export interface User {
  id: string;
  name: string;
  avatar: string;
  status: string;
  isActive: boolean;
}

// 组件属性
interface Props {
  userList: User[];
}

// 定义属性
const props = defineProps<Props>();

// 定义事件
const emit = defineEmits<{
  (e: 'select-user', user: User): void;
}>();

// 搜索功能
const searchQuery = ref('');

// 筛选用户
const filteredUsers = computed(() => {
  if (!searchQuery.value) return props.userList;
  
  const query = searchQuery.value.toLowerCase();
  return props.userList.filter(user => 
    user.name.toLowerCase().includes(query)
  );
});

// 选择用户
const selectUser = (user: User) => {
  emit('select-user', user);
};
</script>

<style scoped>
/* 用户列表样式 */
:deep(.user-list) {
  border-right: none;
}

:deep(.user-item) {
  height: auto;
  padding: 12px 16px;
  line-height: normal;
  border-bottom: 1px solid rgba(229, 231, 235, 0.5);
}

:deep(.user-item:hover) {
  background-color: rgba(243, 244, 246, 0.8);
}

:deep(.user-item.is-active) {
  background-color: rgba(243, 244, 246, 0.8);
  color: inherit;
}

/* 自定义的输入框 */
:deep(.apple-input-search .el-input__wrapper) {
  border-radius: 18px;
  background-color: #f1f1f4;
  box-shadow: none;
  border: 1px solid transparent;
}

:deep(.apple-input-search .el-input__wrapper:hover) {
  border-color: #e1e1e6;
}

:deep(.apple-input-search .el-input__wrapper.is-focus) {
  box-shadow: 0 0 0 2px rgba(0, 125, 250, 0.2) !important;
  border-color: #0070e0;
}

/* 徽章样式 */
:deep(.badge-apple .el-badge__content) {
  background-color: #0a84ff;
}
</style> 

发送消息组件:MessageInput.vue

<template>
  <div class="border-t border-gray-200/30 p-4 bg-white/80 backdrop-blur-lg">
    <div class="flex items-center space-x-3">
      <el-button 
        circle 
        :icon="Paperclip"
        class="apple-btn-icon"
        @click="handleAttachment"
      />
      <el-input 
        v-model="message" 
        type="textarea" 
        :rows="1"
        placeholder="输入消息..." 
        resize="none"
        class="apple-textarea"
        @keyup.enter.exact="sendMessage"
        @keyup.enter.ctrl="newLine"
        @input="handleInput"
      />
      <el-button 
        circle 
        :icon="Position"
        type="primary"
        class="apple-btn-primary"
        :disabled="!canSend"
        @click="sendMessage"
      />
    </div>
    <div class="text-xs text-right text-gray-400 mt-1 mr-1">
      按Enter发送,Ctrl+Enter换行
    </div>
  </div>
</template>

<script setup lang="ts">
import { Position, Paperclip } from '@element-plus/icons-vue';
import { ref, computed } from 'vue';
import { ElMessage } from 'element-plus';

// 定义事件
const emit = defineEmits<{
  (e: 'send', content: string): void;
  (e: 'typing'): void;
}>();

// 输入的消息
const message = ref('');

// 是否可以发送
const canSend = computed(() => message.value.trim().length > 0);

// 发送消息
const sendMessage = () => {
  if (!canSend.value) return;
  
  emit('send', message.value.trim());
  message.value = '';
};

// 换行处理
const newLine = (e: KeyboardEvent) => {
  // 阻止默认的回车发送
  e.preventDefault();
  message.value += '\n';
};

// 处理附件
const handleAttachment = () => {
  ElMessage.info('附件功能暂未开放');
};

// 处理输入,通知"正在输入"状态
let typingTimeout: number | null = null;
let isTyping = false;

const handleInput = () => {
  // 如果还没有通知过"正在输入"状态,或者之前的状态已经过期
  if (!isTyping) {
    emit('typing');
    isTyping = true;
  }
  
  // 防抖处理,避免频繁发送"正在输入"状态
  if (typingTimeout) {
    clearTimeout(typingTimeout);
  }
  
  typingTimeout = window.setTimeout(() => {
    isTyping = false;
    typingTimeout = null;
  }, 3000);
};
</script>

<style scoped>
/* 自定义的文本域 */
:deep(.apple-textarea .el-textarea__inner) {
  border-radius: 20px;
  padding: 12px 18px;
  resize: none;
  box-shadow: none;
  border: 1px solid #e4e4e4;
  transition: all 0.3s;
  min-height: 46px !important;
  max-height: 100px;
}

:deep(.apple-textarea .el-textarea__inner:focus) {
  border-color: #0070e0;
  box-shadow: 0 0 0 3px rgba(0, 125, 250, 0.2);
}

/* 自定义的按钮 */
:deep(.apple-btn-primary) {
  background: linear-gradient(135deg, #0a84ff, #0066cc);
  border: none;
  box-shadow: 0 2px 6px rgba(0, 102, 204, 0.3);
  transition: all 0.3s;
}

:deep(.apple-btn-primary:hover:not(:disabled)) {
  background: linear-gradient(135deg, #0091ff, #0074e0);
  box-shadow: 0 3px 8px rgba(0, 102, 204, 0.4);
  transform: translateY(-1px);
}

:deep(.apple-btn-primary:disabled) {
  background: #d1d1d6;
  opacity: 0.8;
}

:deep(.apple-btn-icon) {
  background-color: #f5f5f7;
  border: 1px solid #e4e4e4;
  color: #666;
}

:deep(.apple-btn-icon:hover) {
  background-color: #e4e4e4;
}
</style> 

这个组件需要关注一下,键盘关键字的使用,以及我们发送请求的防抖处理,不管是面试还是日常开发,防抖都是很重要的!:
在这里插入图片描述
在这里插入图片描述
消息组件:MessageList.vue,这个组件需要根据数据的isMe字段进行判断,对布局进行设计左右:

<template>
  <div class="flex-1 overflow-y-auto p-6 space-y-4" ref="messagesContainer">
    <div 
      v-for="(message, index) in messageList" 
      :key="index"
      :class="[
        'flex', 
        message.isMe ? 'justify-end' : 'justify-start'
      ]"
    >
      <div :class="[
        'max-w-md p-3 rounded-2xl shadow-sm', 
        message.isMe 
          ? 'bg-blue-500 text-white rounded-br-none' 
          : 'bg-gray-100 text-gray-800 rounded-bl-none'
      ]">
        <div v-if="!message.isMe" class="font-medium text-xs mb-1">
          {{ message.sender }}
        </div>
        <div class="leading-relaxed">{{ message.content }}</div>
        <div :class="[
          'text-xs mt-1 text-right', 
          message.isMe ? 'text-blue-100' : 'text-gray-400'
        ]">
          {{ message.time }}
        </div>
      </div>
    </div>

    <!-- 加载更多历史消息 -->
    <div v-if="hasMoreMessages" class="text-center py-2">
      <el-button size="small" text @click="loadMoreMessages">
        加载更多历史消息
      </el-button>
    </div>

    <!-- 无消息提示 -->
    <div v-if="messageList.length === 0" class="text-center p-10 text-gray-400">
      暂无消息,开始聊天吧!
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, watch, nextTick } from 'vue';

// 消息接口
export interface ChatMessage {
  id: string;
  sender: string;
  content: string;
  time: string;
  isMe: boolean;
}

// 组件属性
interface Props {
  messageList: ChatMessage[];
  hasMoreMessages?: boolean;
}

// 定义属性
const props = defineProps<Props>();

// 定义事件
const emit = defineEmits<{
  (e: 'load-more', lastMessageId: string): void;
}>();

// 消息容器DOM引用
const messagesContainer = ref<HTMLElement | null>(null);


// 加载更多历史消息
const loadMoreMessages = () => {
  if (props.messageList.length > 0) {
    const firstMessageId = props.messageList[0].id;
    emit('load-more', firstMessageId);
  }
};
</script>

<style scoped>
.overflow-y-auto::-webkit-scrollbar {
  width: 6px;
}

.overflow-y-auto::-webkit-scrollbar-track {
  background: transparent;
}

.overflow-y-auto::-webkit-scrollbar-thumb {
  background: rgba(0, 0, 0, 0.1);
  border-radius: 3px;
}

.overflow-y-auto::-webkit-scrollbar-thumb:hover {
  background: rgba(0, 0, 0, 0.2);
}
</style> 

最后我们在Chat.vue 整合一下组件:

<template>
    <div class=" flex justify-center items-center  h-screen">
        <div class="flex flex-col bg-gradient-to-b from-gray-50 to-gray-100 overflow-hidden container">
            <!-- 聊天室头部 -->
            <ChatHeader 
                :title="'多人聊天室'" 
                :current-user="currentUser"
                @logout="handleLogout"
            />

            <!-- 主内容区 -->
            <div class="flex-1 flex overflow-hidden">
                <!-- 用户列表 -->
                <UserList 
                    :user-list="userList" 
                    @select-user="handleSelectUser"
                />

                <!-- 聊天区域 -->
                <div class="flex-1 flex flex-col bg-white/70 backdrop-blur-lg">
                    <!-- 消息列表 -->
                    <MessageList :message-list="messageList" :has-more-messages="hasMoreMessages" @load-more="handleLoadMore" />

                    <!-- 消息输入框 -->
                    <MessageInput @send="handleSendMessage" @typing="handleTyping" />
                </div>
            </div>
            <footer class="text-center text-gray-500 text-sm p-4">
                <p>
                   <a href="https://diamaxiaoku.com">@代码小库 2025 多人聊天室</a> 
                </p>
            </footer>
        </div>
    </div>
</template>

<script setup lang="ts">
import { ref, reactive } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage } from 'element-plus';
import ChatHeader from '../components/ChatHeader.vue';
import UserList from '../components/UserList.vue';
import MessageList from '../components/MessageList.vue';
import MessageInput from '../components/MessageInput.vue';
import type { User } from '../components/UserList.vue';
import type { ChatMessage } from '../components/MessageList.vue';

const router = useRouter();

// 当前用户信息
const currentUser = reactive({
    id: '0',
    username: '当前用户',
    avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=当前用户'
});


// 在线用户列表
const userList = ref<User[]>([
    {
        id: '1',
        name: '张三',
        avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=张三',
        status: '活跃',
        isActive: true
    },
    {
        id: '2',
        name: '李四',
        avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=李四',
        status: '正在输入...',
        isActive: true
    },
    {
        id: '3',
        name: '王五',
        avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=王五',
        status: '5分钟前活跃',
        isActive: false
    },
    {
        id: '4',
        name: '赵六',
        avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=赵六',
        status: '10分钟前活跃',
        isActive: false
    },
    {
        id: '5',
        name: '钱七',
        avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=钱七',
        status: '刚刚登录',
        isActive: true
    }
]);

// 消息列表
const messageList = ref<ChatMessage[]>([
    {
        id: '1',
        sender: '张三',
        content: '大家好,今天天气不错!',
        time: '10:25',
        isMe: false
    },
    {
        id: '2',
        sender: '李四',
        content: '确实不错,适合出去玩。',
        time: '10:26',
        isMe: false
    },
    {
        id: '3',
        sender: '王五',
        content: '我正在准备出门呢,有人一起吗?',
        time: '10:28',
        isMe: false
    },
    {
        id: '4',
        sender: '当前用户',
        content: '我可以,你们想去哪里玩?',
        time: '10:30',
        isMe: true
    },
    {
        id: '5',
        sender: '李四',
        content: '要不去公园走走吧,刚好那里有个展览。',
        time: '10:32',
        isMe: false
    },
    {
        id: '6',
        sender: '当前用户',
        content: '好主意,我很久没去公园了,那个展览是什么主题的?',
        time: '10:33',
        isMe: true
    },
    {
        id: '7',
        sender: '张三',
        content: '是一个关于现代艺术的展览,听说挺有意思的。',
        time: '10:35',
        isMe: false
    },
    {
        id: '8',
        sender: '王五',
        content: '我们几点集合?要不11点公园门口见?',
        time: '10:36',
        isMe: false
    }
]);

// 是否有更多历史消息
const hasMoreMessages = ref(true);


// 处理用户选择
const handleSelectUser = (user: User) => {
    console.log('选择用户:', user);
    ElMessage.success(`已选择用户: ${user.name}`);
};

// 处理发送消息
const handleSendMessage = (content: string) => {
    console.log('发送消息:', content);
    
    // 添加新消息
    messageList.value.push({
        id: Date.now().toString(),
        sender: currentUser.username,
        content,
        time: formatTime(new Date()),
        isMe: true
    });
};

// 处理正在输入
const handleTyping = () => {
    console.log('正在输入...');
};

// 加载更多历史消息
const handleLoadMore = (lastMessageId: string) => {
    console.log('加载更多消息,上一条消息ID:', lastMessageId);
    ElMessage.info('加载更多消息功能暂未实现');
};

// 处理退出登录
const handleLogout = () => {
    localStorage.removeItem('user');
    sessionStorage.removeItem('user');
    ElMessage.success('已退出登录');
    router.push('/login');
};

// 格式化时间
const formatTime = (date: Date): string => {
    const hours = date.getHours().toString().padStart(2, '0');
    const minutes = date.getMinutes().toString().padStart(2, '0');
    return `${hours}:${minutes}`;
};
</script>

<style scoped>
/* 自定义滚动条 */
.overflow-y-auto::-webkit-scrollbar {
    width: 6px;
}

.overflow-y-auto::-webkit-scrollbar-track {
    background: transparent;
}

.overflow-y-auto::-webkit-scrollbar-thumb {
    background: rgba(0, 0, 0, 0.1);
    border-radius: 3px;
}

.overflow-y-auto::-webkit-scrollbar-thumb:hover {
    background: rgba(0, 0, 0, 0.2);
}

/* 容器样式 */
.container {
    height: 90vh;
    max-width: 1280px;
    margin: 0 auto;
}

/* 用户列表样式 */
:deep(.user-list) {
    border-right: none;
}

:deep(.user-item) {
    height: auto;
    padding: 12px 16px;
    line-height: normal;
    border-bottom: 1px solid rgba(229, 231, 235, 0.5);
}

:deep(.user-item:hover) {
    background-color: rgba(243, 244, 246, 0.8);
}

:deep(.user-item.is-active) {
    background-color: rgba(243, 244, 246, 0.8);
    color: inherit;
}

/* 自定义的输入框 */
:deep(.apple-input-search .el-input__wrapper) {
    border-radius: 18px;
    background-color: #f1f1f4;
    box-shadow: none;
    border: 1px solid transparent;
}

:deep(.apple-input-search .el-input__wrapper:hover) {
    border-color: #e1e1e6;
}

:deep(.apple-input-search .el-input__wrapper.is-focus) {
    box-shadow: 0 0 0 2px rgba(0, 125, 250, 0.2) !important;
    border-color: #0070e0;
}

/* 自定义的文本域 */
:deep(.apple-textarea .el-textarea__inner) {
    border-radius: 20px;
    padding: 12px 18px;
    resize: none;
    box-shadow: none;
    border: 1px solid #e4e4e4;
    transition: all 0.3s;
    min-height: 46px !important;
    max-height: 100px;
}

:deep(.apple-textarea .el-textarea__inner:focus) {
    border-color: #0070e0;
    box-shadow: 0 0 0 3px rgba(0, 125, 250, 0.2);
}

/* 自定义的按钮 */
:deep(.apple-btn-primary) {
    background: linear-gradient(135deg, #0a84ff, #0066cc);
    border: none;
    box-shadow: 0 2px 6px rgba(0, 102, 204, 0.3);
    transition: all 0.3s;
}

:deep(.apple-btn-primary:hover:not(:disabled)) {
    background: linear-gradient(135deg, #0091ff, #0074e0);
    box-shadow: 0 3px 8px rgba(0, 102, 204, 0.4);
    transform: translateY(-1px);
}

:deep(.apple-btn-icon) {
    background-color: #f5f5f7;
    border: 1px solid #e4e4e4;
    color: #666;
}

:deep(.apple-btn-icon:hover) {
    background-color: #e4e4e4;
}

:deep(.apple-btn-danger) {
    background: transparent;
    border: 1px solid #ff3b30;
    color: #ff3b30;
}

:deep(.apple-btn-danger:hover) {
    background-color: rgba(255, 59, 48, 0.1);
}

/* 徽章样式 */
:deep(.badge-apple .el-badge__content) {
    background-color: #0a84ff;
}
</style>

效果图入下:
在这里插入图片描述
*样式布局都没有问题,但是有一些细节还需要优化,我们发送消息之后好像,没有显示最新的消息,我们需要手动滚动滚动条到底部,这显然不合理。所以我们需要优化代码。
我们需要监听消息列表当消息列表的数据发生改变的时候,滚动滚动条,所以我们需要操作ref,有一个关键点我们更新滚动条的位置的时候时机很重要,我们总需要在*dom更新之后在执行滚动条的滚动,否则滚动条操作可能无效,大家可以自己进行尝试,所以我们需要使用nextTick。

// 滚动到底部
const scrollToBottom = () => {
  if (messagesContainer.value) {
    messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight;
  }
};
// 监听消息列表变化,自动滚动到底部
watch(() => props.messageList.length, () => {
  // 延迟执行,确保DOM已更新
  nextTick(() => {
    scrollToBottom();
  });
}, { immediate: true });

效果图:
在这里插入图片描述
很完美!!!!,由于内容较多,下节再做,如果内容对你有帮助,可以点赞收藏哦!!!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值