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 });
效果图:
很完美!!!!,由于内容较多,下节再做,如果内容对你有帮助,可以点赞收藏哦!!!