Compare commits
20
Commits
5da8d6df59
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6abb53570 | ||
|
|
3a58ce675e | ||
|
|
e4c6fd5f9f | ||
|
|
dc734198bd | ||
|
|
0ce763cc79 | ||
|
|
5de8de007b | ||
|
|
064578f294 | ||
|
|
88cf0abc96 | ||
|
|
d84048d60a | ||
|
|
afedec626f | ||
|
|
c3a6d2bc5b | ||
|
|
2f15cebc4c | ||
|
|
0f8c3d7b51 | ||
|
|
761b7da0fc | ||
|
|
41354a3090 | ||
|
|
b812582597 | ||
|
|
3d17fd4788 | ||
|
|
f8625ab36e | ||
|
|
fc563ab4bc | ||
|
|
4beed1531f |
@@ -0,0 +1,3 @@
|
||||
# 必须要以VUE_APP_打头,不然识别不了
|
||||
VUE_APP_HTTP_BASE_URL=/api
|
||||
VUE_APP_WS_URL=ws://localhost:3000
|
||||
@@ -0,0 +1,2 @@
|
||||
VUE_APP_HTTP_BASE_URL=http://localhost:3000/api
|
||||
VUE_APP_WS_URL=ws://localhost:3000
|
||||
@@ -0,0 +1,200 @@
|
||||
# Chat Demo 前端设计书
|
||||
|
||||
## 1. 文档说明
|
||||
|
||||
本文档依据当前仓库中的前端代码,说明系统目标、技术选型、模块边界、代码结构、主要数据流和交互设计。项目是一个 Vue 单页聊天演示前端;当前仓库不包含服务端实现,因此服务端接口及消息协议以客户端实际调用和解析的内容为准。
|
||||
|
||||
## 2. 项目概述
|
||||
|
||||
系统提供账号注册、登录和一对一即时聊天页面。用户登录后可以查看最近会话、搜索最近联系人、浏览联系人在线状态、查看历史消息、加载更早消息,并通过 WebSocket 收发消息。聊天页面采用三栏布局:功能导航、会话/联系人列表、当前聊天内容。
|
||||
|
||||
前端职责包括:
|
||||
|
||||
- 通过 REST API 完成注册、登录以及用户、联系人、会话和历史消息数据读取。
|
||||
- 保存登录令牌,并在后续 HTTP 请求中携带令牌。
|
||||
- 使用 Vue Router 限制聊天路由的访问。
|
||||
- 建立 WebSocket 连接,发送聊天消息、处理服务端推送并回传消息确认 ACK。
|
||||
- 管理页面内的会话、消息、未读数、在线用户和输入状态。
|
||||
|
||||
## 3. 技术选型
|
||||
|
||||
| 分类 | 技术/模块 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| UI 框架 | Vue 2.6 | 页面组件与响应式视图,采用 Options API |
|
||||
| 路由 | Vue Router 3 | `/login`、`/register`、`/chat` 页面切换及导航守卫 |
|
||||
| 状态管理 | Vuex 3 | 保存 token 和当前用户名 |
|
||||
| UI 组件 | Element UI 2 | 登录/注册表单、输入框、按钮、提示消息 |
|
||||
| HTTP | Axios 1 | REST 请求、认证请求头、统一响应与 401 处理 |
|
||||
| 实时通信 | 浏览器原生 WebSocket | 即时消息、在线用户列表和 ACK |
|
||||
| 构建工具 | Vue CLI 5、Babel 7 | 本地开发、构建、ES 转译和 Element UI 组件按需样式处理 |
|
||||
|
||||
项目未使用 TypeScript、独立 API 状态库或 WebSocket 第三方库;聊天页面自身维护主要业务状态。
|
||||
|
||||
## 4. 总体架构
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Browser[浏览器] --> Entry[src/main.js]
|
||||
Entry --> App[src/App.vue]
|
||||
Entry --> Router[src/router.js]
|
||||
Entry --> Store[src/store/index.js]
|
||||
Router --> Pages[登录 / 注册 / 聊天页面]
|
||||
Pages --> API[src/utils/api.js]
|
||||
API --> Request[src/utils/request.js]
|
||||
Request -->|HTTP /api| Backend[后端 localhost:3000]
|
||||
Pages --> WS[src/utils/websocket.js]
|
||||
WS -->|WebSocket ws://localhost:3000| Backend
|
||||
Request --> Store
|
||||
Router --> Store
|
||||
```
|
||||
|
||||
页面层负责交互、视图状态和流程编排;`api.js` 将业务接口映射为 HTTP 方法;`request.js` 统一管理 Axios 配置和拦截器;`websocket.js` 封装浏览器 WebSocket 的创建、发送、关闭和回调。Vuex 在入口、路由和请求层之间共享认证状态。
|
||||
|
||||
## 5. 代码结构与模块职责
|
||||
|
||||
```text
|
||||
.
|
||||
├── public/
|
||||
│ └── index.html # SPA HTML 宿主
|
||||
├── src/
|
||||
│ ├── main.js # Vue、路由、store、Element UI 与全局 CSS 初始化
|
||||
│ ├── App.vue # 根组件,通过 router-view 渲染当前页面
|
||||
│ ├── router.js # 路由表与 token 路由守卫
|
||||
│ ├── store/
|
||||
│ │ └── index.js # token、username 与 mutations
|
||||
│ ├── pages/
|
||||
│ │ ├── login/
|
||||
│ │ │ ├── LoginPage.vue # 登录表单和登录后跳转
|
||||
│ │ │ └── RegisterPage.vue # 注册表单和密码确认
|
||||
│ │ └── index/
|
||||
│ │ └── ChatPage.vue # 聊天布局、会话状态及实时消息处理
|
||||
│ ├── utils/
|
||||
│ │ ├── api.js # 登录、注册、用户和聊天 REST API
|
||||
│ │ ├── request.js # Axios 实例、认证头和响应拦截器
|
||||
│ │ └── websocket.js # WebSocket 生命周期和 JSON 消息封装
|
||||
│ └── assets/css/
|
||||
│ └── style_public.css # 全局重置与基础样式
|
||||
├── babel.config.js # Vue CLI Babel preset 与 Element UI 按需样式
|
||||
├── jsconfig.json # JavaScript 配置及 @ -> src 别名
|
||||
├── vue.config.js # Vue CLI 开发服务器代理
|
||||
└── package.json # 依赖和开发/构建/lint 命令
|
||||
```
|
||||
|
||||
### 5.1 应用启动与路由
|
||||
|
||||
`src/main.js` 加载 Element UI 及其主题样式、全局公共 CSS,并将 router 和 store 注入 Vue 根实例。`App.vue` 只提供 `router-view` 容器。路由采用 history 模式:根路径跳转 `/login`;`/login`、`/register` 和 `/chat` 分别对应登录、注册和聊天页面。`/chat` 标记 `requiresAuth`,导航守卫通过 `store.state.token` 判断是否允许进入。
|
||||
|
||||
### 5.2 登录、注册与认证状态
|
||||
|
||||
登录页收集用户名和密码,调用 `login()`;成功后从响应中读取 token,提交 `SET_TOKEN`、`SET_USERNAME` 并跳转聊天页。注册页在前端检查两次密码一致后调用 `register()`,成功后返回登录页。
|
||||
|
||||
Vuex 当前包含:
|
||||
|
||||
| 状态 | 初始值/持久化 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `token` | 初始化时从 `localStorage` 读取;`SET_TOKEN` 同步写入 localStorage | 路由鉴权及请求认证 |
|
||||
| `username` | 空字符串,登录时写入 Vuex,不持久化 | 当前会话中的用户标识 |
|
||||
|
||||
HTTP 请求拦截器从 localStorage 读取 token 并设置 `Authorization` 请求头。响应状态为 401 时清理 token 和用户名并跳转登录页。退出登录时关闭 WebSocket、清空 Vuex token/username 并跳转登录。
|
||||
|
||||
### 5.3 REST API 封装
|
||||
|
||||
`src/utils/api.js` 提供以下调用:
|
||||
|
||||
| 函数 | 方法与路径 | 当前用途 |
|
||||
| --- | --- | --- |
|
||||
| `login(data)` | `POST /login` | 登录并取得 token |
|
||||
| `register(data)` | `POST /register` | 创建账号 |
|
||||
| `getUserInfo()` | `GET /userInfo` | 读取当前用户资料 |
|
||||
| `getContactInfo()` | `GET /contactsInfo` | 读取联系人列表 |
|
||||
| `getRecentMessages()` | `GET /recentChats` | 读取最近会话及预览信息 |
|
||||
| `getContactMessages(data)` | `POST /contactMessagesInfo` | 分页读取指定联系人的消息 |
|
||||
| `sendMessage(data)` | `POST /sendMessage` | HTTP 发送接口;当前聊天页未使用,实际发送走 WebSocket |
|
||||
|
||||
`src/utils/request.js` 创建 Axios 实例,默认 `baseURL` 为 `/api`,超时为 10 秒。开发服务器将 `/api` 转发到 `http://localhost:3000`。响应拦截器返回 `response.data`;页面代码随后通过 `response.data` 读取业务字段,因此当前前后端约定应在 HTTP 响应外层提供 `data` 字段(例如 Axios 响应体为 `{ "data": { ... } }`)。由于服务端代码不在本仓库,实际响应格式仍需与后端核对。
|
||||
|
||||
### 5.4 聊天页面与状态
|
||||
|
||||
`ChatPage.vue` 以一个组件承载聊天主界面及其业务流程,关键状态如下:
|
||||
|
||||
| 状态 | 含义 |
|
||||
| --- | --- |
|
||||
| `currentUser` | 当前用户资料 |
|
||||
| `contactList` | 联系人列表 |
|
||||
| `recentChats` | 最近会话列表,包含最近消息预览与未读数 |
|
||||
| `onlineList` | 服务端推送的在线用户名列表 |
|
||||
| `currentContact` | 当前正在查看的联系人 |
|
||||
| `contactMessages` | 当前会话中展示的消息数组 |
|
||||
| `searchText`、`messageText` | 联系人搜索关键字和编辑中的消息 |
|
||||
| `activeMenu` | messages、contacts、settings 侧栏菜单状态 |
|
||||
| `firstLoad`、`loadingMore`、`loadEnd` | 历史消息初次加载、分页加载和加载结束标记 |
|
||||
|
||||
组件创建时并行触发当前用户资料、最近会话和 WebSocket 初始化。最近会话加载完成后,如果列表非空,会自动选择第一条会话。切换至联系人菜单时另行请求联系人列表。选择联系人时清空当前消息并请求首批 10 条,查看后将该联系人未读数清零。
|
||||
|
||||
历史消息使用 `limit=10`,`offset` 取当前消息条数。加载更早消息时将返回数据倒序后置于现有列表之前,并依据加载前后的滚动区域高度调整 `scrollTop`,以尽量保持用户正在阅读的位置。发送或收到当前会话消息后,视图滚动到底部。
|
||||
|
||||
### 5.5 WebSocket 实时通信
|
||||
|
||||
`src/utils/websocket.js` 持有模块级 WebSocket 实例,连接地址固定为 `ws://localhost:3000`。聊天页面从 Vuex 读取 token,去除可能存在的 `Bearer ` 前缀并 URL 编码,通过查询参数 `token` 传递。连接建立后以 JSON 收发消息,并将解析后的数据交给页面回调。
|
||||
|
||||
客户端当前使用/处理的消息类型:
|
||||
|
||||
| 类型 | 方向 | 处理方式 |
|
||||
| --- | --- | --- |
|
||||
| `sendMessage` | 客户端 -> 服务端 | 包含 `messageId`、`senderName`、`receiverName`、`content`、`createdAt`;成功写入本地当前消息列表并更新最近会话 |
|
||||
| `newMessage` | 服务端 -> 客户端 | 从 `message` 读取消息;当前联系人消息加入聊天列表,其他联系人的消息更新最近会话和未读数 |
|
||||
| `ack` | 客户端 -> 服务端 | 收到 `newMessage` 后按 `messageId` 确认 |
|
||||
| `sendError` | 服务端 -> 客户端 | 提示发送失败 |
|
||||
| `userOnline` | 服务端 -> 客户端 | 使用 `userList` 更新在线列表 |
|
||||
|
||||
上述字段是前端代码所依赖的协议,不代表后端已实现或经过端到端验证。`connectWebSocket` 提供 open、close、error 回调;聊天页实现了 open 和 error 处理,未提供 close 回调。发送时要求连接处于 OPEN 状态;否则封装仅写控制台,不抛出异常,因此页面的 try/catch 不能据此判断 WebSocket 是否真正发送成功。
|
||||
|
||||
### 5.6 UI 与样式
|
||||
|
||||
聊天页面采用固定宽度左侧导航栏(78px)、联系人面板(300px)和弹性聊天面板。聊天面板包含会话标题、消息滚动区、输入区。消息区区分本人和对方消息气泡,并展示时间;未读数和联系人在线状态通过独立样式表达。页面 CSS 以 `ChatPage.vue` 内的 scoped 样式为主,公共 CSS 提供基础重置。
|
||||
|
||||
登录和注册页使用 Element UI 表单控件,页面样式局部定义,窄屏时缩小表单内边距。聊天容器设置 `min-width: 980px`,当前小屏行为是保留最小宽度并可能产生横向溢出,不属于完整的移动端适配。
|
||||
|
||||
## 6. 主要业务流程
|
||||
|
||||
### 6.1 登录进入聊天
|
||||
|
||||
1. 用户提交用户名和密码,页面调用 `POST /api/login`。
|
||||
2. 成功响应中的 token 写入 Vuex 和 localStorage,用户名写入 Vuex。
|
||||
3. 路由跳转 `/chat`;导航守卫检测到 token 后放行。
|
||||
4. 聊天页读取用户资料、最近会话,并携带 token 建立 WebSocket。
|
||||
5. 最近会话返回后默认打开第一位联系人并拉取最近 10 条消息。
|
||||
|
||||
### 6.2 发送与接收消息
|
||||
|
||||
1. 用户点击发送或按 Enter,页面去除首尾空白并检查消息及联系人。
|
||||
2. 页面组装 `sendMessage` JSON,通过 WebSocket 发送,并乐观地将消息加入当前视图。
|
||||
3. 服务端推送 `newMessage` 时,页面按发送者是否为当前联系人分流:更新当前消息列表,或更新会话预览和未读数。
|
||||
4. 页面向服务端发送 `ack`,确认消息已接收。
|
||||
|
||||
### 6.3 认证失效或退出
|
||||
|
||||
HTTP 401 会清理认证状态并导航至登录页。用户主动退出会先关闭 WebSocket,再清理 Vuex 状态并返回登录页。当前认证守卫主要检查 token 是否存在,并不验证 token 有效性;有效性最终由服务端响应决定。
|
||||
|
||||
## 7. 构建与运行配置
|
||||
|
||||
- `npm install`:安装依赖。
|
||||
- `npm run serve`:启动 Vue CLI 开发服务器。
|
||||
- `npm run build`:生成生产构建。
|
||||
- `npm run lint`:执行 ESLint 检查。
|
||||
- 开发环境 `/api` 代理目标为 `http://localhost:3000`。
|
||||
- WebSocket URL 在 `src/utils/websocket.js` 中固定为 `ws://localhost:3000`;部署到其他环境前需要配置化,并在 HTTPS 部署时切换到安全 WebSocket (`wss://`)。
|
||||
|
||||
## 8. 当前边界与改进建议
|
||||
|
||||
以下内容是根据当前代码观察到的限制,供后续迭代使用,并非已实现功能:
|
||||
|
||||
1. **运行配置集中化**:HTTP base URL 与 WebSocket 地址目前分散/硬编码,应按开发、测试、生产环境配置,减少部署时遗漏。(已解决)
|
||||
2. **认证状态持久性**:token 会持久化,但 username 不持久化;刷新后需由用户信息接口恢复展示状态。还应评估 localStorage 对 token 的安全取舍及 token 过期刷新方案。(username已删除,token问题待解决)
|
||||
3. **WebSocket 健壮性**:当前没有断线重连、心跳、待发送队列或连接状态 UI;发送前应明确连接不可用的反馈,并评估 ACK 超时及重复消息处理。
|
||||
4. **会话切换与分页状态**:`loadEnd` 等分页标志需要在联系人切换时明确重置;异步消息请求也应避免旧联系人响应覆盖新联系人视图。(已解决)
|
||||
5. **实时会话一致性**:收到当前联系人消息时会更新消息区,但当前实现没有同步刷新最近会话预览;接收新消息的未读判断及空会话创建逻辑也值得通过业务用例确认。(已解决)
|
||||
6. **响应契约与错误处理**:页面依赖响应中存在 `data` 字段。建议与服务端定义统一响应 DTO,并区分网络错误、认证错误、业务错误;当前错误主要由页面提示。(已解决)
|
||||
7. **UI 菜单与能力范围**:侧栏存在设置入口,但当前点击后主要改变菜单状态,未形成独立设置页;工具栏的表情和附件按钮也尚未绑定完整功能。
|
||||
8. **移动端适配与无障碍**:聊天页最小宽度为 980px;联系人条目以鼠标点击交互为主。若支持移动设备或键盘操作,需补充响应式布局、语义按钮和焦点管理。
|
||||
9. **自动化验证**:当前仓库配置了 lint/build 脚本,但未看到专门的单元测试或端到端测试。可优先为认证守卫、消息收发分流、未读计数和历史分页补充测试。
|
||||
+231
-69
@@ -88,8 +88,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="contact.unread > 0" class="unread">
|
||||
{{ contact.unread }}
|
||||
<div v-if="contact.unreadCount > 0" class="unread">
|
||||
{{ contact.unreadCount }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -113,6 +113,15 @@
|
||||
<div class="contact-name">
|
||||
{{ contact.contactName }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="contact-user-status"
|
||||
:class="
|
||||
onlineList.includes(contact.contactName) ? 'online' : 'offline'
|
||||
"
|
||||
>
|
||||
{{ onlineList.includes(contact.contactName) ? "在线" : "离线" }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -140,7 +149,10 @@
|
||||
</div>
|
||||
|
||||
<div class="chat-user-status">
|
||||
{{ currentContact.online ? "在线" : "离线" }}
|
||||
<span v-if="onlineList.includes(currentContact.contactName)">
|
||||
在线
|
||||
</span>
|
||||
<span v-else> 离线 </span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -168,7 +180,8 @@
|
||||
|
||||
<div class="message-content">
|
||||
<div class="message-time">
|
||||
{{ message.time }}
|
||||
{{ getTime(message.time) }}
|
||||
<!-- {{ message.time }} -->
|
||||
</div>
|
||||
|
||||
<div class="message-bubble">
|
||||
@@ -187,7 +200,10 @@
|
||||
</div>
|
||||
|
||||
<!-- 输入消息区域 -->
|
||||
<div v-if="activeMenu === 'messages'" class="input-panel">
|
||||
<div
|
||||
v-if="activeMenu === 'messages' || activeMenu === 'contacts'"
|
||||
class="input-panel"
|
||||
>
|
||||
<div class="toolbar">
|
||||
<button type="button">😊</button>
|
||||
<button type="button">📎</button>
|
||||
@@ -223,8 +239,14 @@ import {
|
||||
getContactInfo,
|
||||
getRecentMessages,
|
||||
getContactMessages,
|
||||
sendMessage,
|
||||
// sendMessage,
|
||||
} from "@/utils/api";
|
||||
import {
|
||||
connectWebSocket,
|
||||
sendWebSocketMessage,
|
||||
closeWebSocket,
|
||||
} from "@/utils/websocket";
|
||||
|
||||
export default {
|
||||
name: "ChatPage",
|
||||
|
||||
@@ -234,6 +256,8 @@ export default {
|
||||
currentUser: {},
|
||||
// 联系人
|
||||
contactList: [],
|
||||
// 在线用户
|
||||
onlineList: [],
|
||||
// 当前聊天联系人
|
||||
currentContact: null,
|
||||
// 最近聊天列表
|
||||
@@ -242,6 +266,8 @@ export default {
|
||||
contactMessages: [],
|
||||
// 首次加载
|
||||
firstLoad: true,
|
||||
// 每次请求加载的消息条数
|
||||
pageSize: 10,
|
||||
// 正在加载更多消息
|
||||
loadingMore: false,
|
||||
// 是否所有消息都加载完毕
|
||||
@@ -272,7 +298,7 @@ export default {
|
||||
|
||||
created() {
|
||||
this.getCurrentUser();
|
||||
this.getRecentMessageList();
|
||||
this.createWebsocket();
|
||||
},
|
||||
|
||||
methods: {
|
||||
@@ -280,16 +306,13 @@ export default {
|
||||
changeMenu(menu) {
|
||||
this.activeMenu = menu;
|
||||
this.currentContact = null;
|
||||
if (menu === "contacts") {
|
||||
this.getContactList();
|
||||
}
|
||||
},
|
||||
|
||||
// 退出登录
|
||||
logout() {
|
||||
// 清除 token 和用户信息
|
||||
// 关闭websocket连接,清除 token 和用户信息
|
||||
closeWebSocket();
|
||||
this.$store.commit("SET_TOKEN", "");
|
||||
this.$store.commit("SET_USERNAME", "");
|
||||
this.$router.push("/login");
|
||||
},
|
||||
|
||||
@@ -297,39 +320,43 @@ export default {
|
||||
getCurrentUser() {
|
||||
// 获取当前登录用户信息
|
||||
getUserInfo()
|
||||
.then((response) => {
|
||||
this.currentUser = response.data;
|
||||
.then((data) => {
|
||||
this.currentUser = data;
|
||||
this.getRecentMessageList();
|
||||
this.getContactList();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
this.$message.error("获取用户信息失败,请重新登录");
|
||||
this.$router.push("/login");
|
||||
this.$message.error(error.message);
|
||||
});
|
||||
},
|
||||
|
||||
// 获取最近消息列表
|
||||
getRecentMessageList() {
|
||||
if (!this.currentUser) return;
|
||||
getRecentMessages()
|
||||
.then((response) => {
|
||||
this.recentChats = response.data.recentChats || [];
|
||||
.then((data) => {
|
||||
this.recentChats = data.recentChats || [];
|
||||
this.selectFirstContact();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
this.$message.error("获取消息列表失败");
|
||||
this.$message.error(error.message);
|
||||
});
|
||||
},
|
||||
|
||||
// 选择联系人
|
||||
async selectContact(contact) {
|
||||
this.currentContact = contact;
|
||||
|
||||
this.loadEnd = false; //重置加载消息结束标志
|
||||
this.contactMessages = [];
|
||||
// 每次选择联系人的时候需要首次加载消息
|
||||
this.firstLoad = true;
|
||||
await this.getContactMessageList(contact.contactName, 10, 0);
|
||||
await this.getContactMessageList(contact.contactName, this.pageSize, 0);
|
||||
|
||||
// 查看联系人后清除未读数量
|
||||
contact.unread = 0;
|
||||
contact.unreadCount = 0;
|
||||
|
||||
// 使用同步等消息获取完成后再让滚动条滚到底部,否则滚动条的位置不对
|
||||
this.$nextTick(() => {
|
||||
@@ -349,14 +376,14 @@ export default {
|
||||
async getContactMessageList(contactName, limit, offset) {
|
||||
try {
|
||||
// 获取当前联系人的消息列表
|
||||
const response = await getContactMessages({
|
||||
const data = await getContactMessages({
|
||||
contactName: contactName,
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
});
|
||||
|
||||
const contactMessages = [];
|
||||
const messages = response.data.messages || [];
|
||||
const messages = data.messages || [];
|
||||
messages.forEach((item) => {
|
||||
contactMessages.push({
|
||||
...item,
|
||||
@@ -372,22 +399,22 @@ export default {
|
||||
console.log("contactMessages", contactMessages);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
this.$message.error("获取消息列表失败");
|
||||
this.$message.error(error.message);
|
||||
}
|
||||
},
|
||||
|
||||
// 获取联系人列表
|
||||
getContactList() {
|
||||
getContactInfo()
|
||||
.then((response) => {
|
||||
this.contactList = response.data.contacts.map((contact) => ({
|
||||
.then((data) => {
|
||||
this.contactList = data.contacts.map((contact) => ({
|
||||
...contact,
|
||||
unread: contact.unread || 0,
|
||||
unreadCount: contact.unreadCount || 0,
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
this.$message.error("获取联系人信息失败");
|
||||
this.$message.error(error.message);
|
||||
});
|
||||
},
|
||||
|
||||
@@ -403,56 +430,169 @@ export default {
|
||||
this.$set(this.contactMessages, this.currentContact.contactName, []);
|
||||
}
|
||||
|
||||
// 新消息
|
||||
const newMessage = {
|
||||
type: "sendMessage",
|
||||
messageId: Date.now() + "-" + Math.random().toString(36).slice(2),
|
||||
senderName: this.currentUser.username,
|
||||
receiverName: this.currentContact.contactName,
|
||||
content: content,
|
||||
};
|
||||
|
||||
sendMessage(newMessage)
|
||||
.then((response) => {
|
||||
console.log("发送消息成功", response.data);
|
||||
this.contactMessages[this.currentContact.contactName].push({
|
||||
...newMessage,
|
||||
from: "me",
|
||||
try {
|
||||
sendWebSocketMessage(newMessage);
|
||||
} catch (error) {
|
||||
// 清空输入框
|
||||
this.messageText = "";
|
||||
console.error(error);
|
||||
this.$message.error("发送消息失败");
|
||||
}
|
||||
},
|
||||
|
||||
getTime(time) {
|
||||
if (!time) return "";
|
||||
|
||||
const date = new Date(time);
|
||||
return `${date.getFullYear()}年${String(date.getMonth() + 1).padStart(
|
||||
2,
|
||||
"0"
|
||||
)}月${String(date.getDate()).padStart(2, "0")}日 ${String(
|
||||
date.getHours()
|
||||
).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
|
||||
},
|
||||
|
||||
// 更新最近消息列表
|
||||
updateRecentChat(messageType, contactName, newMessage) {
|
||||
const index = this.recentChats.findIndex(
|
||||
(item) => item.contactName === contactName
|
||||
);
|
||||
|
||||
if (index !== -1) {
|
||||
//找到这条数据,修改内容并移除后添加到数组最前面
|
||||
const chat = this.recentChats[index];
|
||||
|
||||
chat.content = newMessage.content;
|
||||
chat.createdAt = newMessage.createdAt;
|
||||
// 接收非当前正在聊天的联系人新消息时,未读数量 +1
|
||||
if (
|
||||
messageType == "receive" &&
|
||||
this.currentContact.contactName != contactName
|
||||
) {
|
||||
chat.unreadCount = (chat.unreadCount || 0) + 1;
|
||||
}
|
||||
|
||||
this.recentChats.splice(index, 1);
|
||||
this.recentChats.unshift(chat);
|
||||
console.log(this.recentChats);
|
||||
} else {
|
||||
// 没有找到这条数据,直接添加到数组最前面
|
||||
this.recentChats.unshift({
|
||||
contactName: contactName,
|
||||
content: newMessage.content,
|
||||
createdAt: newMessage.createdAt,
|
||||
unreadCount: 1
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// 监听websocket连接成功事件
|
||||
websocketOnOpen() {},
|
||||
|
||||
// 监听websocket接收消息事件
|
||||
websocketOnMessage(data) {
|
||||
// 格式如下:
|
||||
// {
|
||||
// type: "newMessage",
|
||||
// message: {
|
||||
// "messageId": 146,
|
||||
// "senderName": "admin",
|
||||
// "receiverName": "query",
|
||||
// "content": "好的额没关系",
|
||||
// "createdAt": "2026-09-22T06:38:13.000Z"
|
||||
// }
|
||||
// }
|
||||
|
||||
// 当接收到新消息的时候,对消息显示进行处理,同时需要给websocket回一个ack确认收到的消息
|
||||
if (data.type == "newMessage") {
|
||||
const message = data.message;
|
||||
const senderName = message.senderName;
|
||||
|
||||
// 给发消息的人返回 ACK
|
||||
sendWebSocketMessage({
|
||||
type: "ack",
|
||||
messageId: message.messageId,
|
||||
});
|
||||
|
||||
const currentContactName =
|
||||
this.currentContact && this.currentContact.contactName;
|
||||
|
||||
// 是当前聊天联系人
|
||||
if (senderName === currentContactName) {
|
||||
this.contactMessages.push({
|
||||
...message,
|
||||
from: "other",
|
||||
time: message.createdAt,
|
||||
});
|
||||
|
||||
// 更新最近消息列表
|
||||
const contactName = this.currentContact.contactName;
|
||||
const index = this.recentChats.findIndex(
|
||||
(item) => item.contactName === contactName
|
||||
);
|
||||
|
||||
if (index !== -1) {
|
||||
//找到这条数据,修改内容并移除后添加到数组最前面
|
||||
const chat = this.recentChats[index];
|
||||
|
||||
chat.content = content;
|
||||
chat.createdAt = newMessage.createdAt;
|
||||
|
||||
this.recentChats.splice(index, 1);
|
||||
this.recentChats.unshift(chat);
|
||||
} else {
|
||||
// 没有找到这条数据,直接添加到数组最前面
|
||||
this.recentChats.unshift({
|
||||
contactName: contactName,
|
||||
content: content,
|
||||
createdAt: newMessage.createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
// 清空输入框
|
||||
this.messageText = "";
|
||||
this.$nextTick(() => {
|
||||
this.scrollToBottom();
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
// 清空输入框
|
||||
this.messageText = "";
|
||||
console.error(error);
|
||||
this.$message.error("发送消息失败");
|
||||
} else {
|
||||
// 不是当前联系人,不加入 contactMessages
|
||||
// 如果是当前联系人列表中的成员,更新最近聊天列表、未读数量
|
||||
const contactIndex = this.contactList.findIndex(
|
||||
(item) => item.contactName === senderName
|
||||
);
|
||||
console.log("contactIndex="+contactIndex)
|
||||
if (contactIndex === -1) return;
|
||||
|
||||
this.updateRecentChat("receive", senderName, message);
|
||||
}
|
||||
// 发送新消息成功之后,需要把新消息显示到聊天框中(由于要拿数据库里的写入时间,所以要等websocket返回成功再刷新)
|
||||
} else if (data.type == "sendSuccess") {
|
||||
const message = data.message;
|
||||
this.contactMessages.push({
|
||||
...message,
|
||||
from: "me",
|
||||
time: message.createdAt,
|
||||
});
|
||||
|
||||
// 更新最近消息列表
|
||||
this.updateRecentChat("send", this.currentContact.contactName, message);
|
||||
// 清空输入框
|
||||
this.messageText = "";
|
||||
this.$nextTick(() => {
|
||||
this.scrollToBottom();
|
||||
});
|
||||
} else if (data.type == "sendError") {
|
||||
this.$message.error("发送失败,请重试");
|
||||
this.$nextTick(() => {
|
||||
this.scrollToBottom();
|
||||
});
|
||||
} else if (data.type == "userOnline") {
|
||||
// 获取所有在线用户
|
||||
this.onlineList = data.userList;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
},
|
||||
|
||||
// 监听websocket连接报错事件
|
||||
websocketOnErr() {
|
||||
this.$message.error("网络连接失败,请重试");
|
||||
this.logout();
|
||||
},
|
||||
|
||||
// 创建websocket
|
||||
createWebsocket() {
|
||||
const token = this.$store.state.token;
|
||||
const jwtToken = token.replace("Bearer ", "");
|
||||
connectWebSocket(
|
||||
encodeURIComponent(jwtToken),
|
||||
this.websocketOnMessage,
|
||||
this.websocketOnOpen,
|
||||
null,
|
||||
this.websocketOnErr
|
||||
);
|
||||
},
|
||||
|
||||
// 滚动到底部
|
||||
@@ -481,7 +621,7 @@ export default {
|
||||
try {
|
||||
await this.getContactMessageList(
|
||||
this.currentContact.contactName,
|
||||
10,
|
||||
this.pageSize,
|
||||
this.contactMessages.length
|
||||
);
|
||||
|
||||
@@ -493,7 +633,7 @@ export default {
|
||||
const addedHeight = newScrollHeight - oldScrollHeight;
|
||||
el.scrollTop = addedHeight;
|
||||
|
||||
if(el.scrollTop === 0) this.loadEnd = true; //所有数据已经加载完成,不需要再继续请求了
|
||||
if (el.scrollTop === 0) this.loadEnd = true; //所有数据已经加载完成,不需要再继续请求了
|
||||
} finally {
|
||||
this.loadingMore = false;
|
||||
}
|
||||
@@ -794,10 +934,18 @@ export default {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.message-other .message-bubble {
|
||||
float: left;
|
||||
}
|
||||
|
||||
.message-me {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.message-me .message-bubble {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.message-avatar {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
@@ -807,6 +955,7 @@ export default {
|
||||
.message-content {
|
||||
max-width: 60%;
|
||||
margin: 0 10px;
|
||||
clear: both;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
@@ -911,4 +1060,17 @@ export default {
|
||||
background: #ddd;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.contact-user-status {
|
||||
margin-top: 5px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.contact-user-status.online {
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
.contact-user-status.offline {
|
||||
color: #999;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -54,15 +54,14 @@ export default {
|
||||
username: this.username,
|
||||
password: this.password,
|
||||
})
|
||||
.then((response) => {
|
||||
const token = response.data.token;
|
||||
.then((data) => {
|
||||
const token = data.token;
|
||||
this.$store.commit("SET_TOKEN", token);
|
||||
this.$store.commit("SET_USERNAME", this.username);
|
||||
this.$router.push("/chat");
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
this.$message.error("账号或密码错误");
|
||||
this.$message.error(error.message);
|
||||
});
|
||||
} else {
|
||||
this.$message.error("请输入账号和密码");
|
||||
|
||||
@@ -69,7 +69,7 @@ export default {
|
||||
this.$router.push('/login')
|
||||
}).catch((error) => {
|
||||
console.error(error)
|
||||
this.$message.error('注册失败,请重试')
|
||||
this.$message.error(error.message)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+14
-6
@@ -34,7 +34,7 @@ const routes = [
|
||||
]
|
||||
|
||||
const router = new VueRouter({
|
||||
mode: 'history',
|
||||
mode: 'history',
|
||||
base: process.env.BASE_URL,
|
||||
routes
|
||||
})
|
||||
@@ -42,12 +42,20 @@ const router = new VueRouter({
|
||||
// 路由守卫,当访问需要登录的页面时,检查是否有token,如果没有则跳转到登录页
|
||||
router.beforeEach((to, from, next) => {
|
||||
const token = store.state.token;
|
||||
if(to.meta.requiresAuth) {
|
||||
if(token) {
|
||||
next()
|
||||
} else {
|
||||
next('/login')
|
||||
if (to.meta.requiresAuth) {
|
||||
// 登录页直接放行
|
||||
if (to.path === "/login") {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// 其他页面没有 token,不允许进入
|
||||
if (!token) {
|
||||
next("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
|
||||
+1
-5
@@ -5,8 +5,7 @@ Vue.use(Vuex)
|
||||
|
||||
const store = new Vuex.Store({
|
||||
state: {
|
||||
token: localStorage.getItem('token') || '',
|
||||
username: ''
|
||||
token: localStorage.getItem('token') || ''
|
||||
},
|
||||
mutations: {
|
||||
// 设置token和用户名
|
||||
@@ -17,9 +16,6 @@ const store = new Vuex.Store({
|
||||
REMOVE_TOKEN(state) {
|
||||
state.token = '';
|
||||
localStorage.removeItem('token');
|
||||
},
|
||||
SET_USERNAME(state, username) {
|
||||
state.username = username;
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export const HTTP_BASE_URL = process.env.VUE_APP_HTTP_BASE_URL;
|
||||
export const WS_URL = process.env.VUE_APP_WS_URL;
|
||||
+53
-7
@@ -1,11 +1,32 @@
|
||||
import axios from 'axios';
|
||||
import store from '../store';
|
||||
import router from '../router';
|
||||
import { HTTP_BASE_URL } from "./config"
|
||||
|
||||
let redirectingToLogin = false;
|
||||
|
||||
function redirectToLogin() {
|
||||
if (redirectingToLogin || router.currentRoute.path === '/login') {
|
||||
return;
|
||||
}
|
||||
|
||||
redirectingToLogin = true;
|
||||
router.replace('/login').then(
|
||||
() => {
|
||||
redirectingToLogin = false;
|
||||
},
|
||||
(navigationError) => {
|
||||
redirectingToLogin = false;
|
||||
if (navigationError.name !== 'NavigationDuplicated') {
|
||||
console.error(navigationError);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// 配置请求
|
||||
const request = axios.create({
|
||||
// baseURL: process.env.REACT_APP_API_BASE_URL,
|
||||
baseURL: "/api",
|
||||
baseURL: HTTP_BASE_URL,
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
@@ -26,16 +47,41 @@ request.interceptors.request.use(
|
||||
|
||||
request.interceptors.response.use(
|
||||
(response) => {
|
||||
return response.data;
|
||||
const result = response.data;
|
||||
|
||||
// HTTP 请求成功,但业务失败
|
||||
if (result.code !== 200) {
|
||||
return Promise.reject({
|
||||
type: "business",
|
||||
message: result.message
|
||||
});
|
||||
}
|
||||
return result.data;
|
||||
},
|
||||
(error) => {
|
||||
if (!error.response) {
|
||||
// 网络错误
|
||||
return Promise.reject({
|
||||
type: "network",
|
||||
message: "网络连接失败,请检查网络"
|
||||
});
|
||||
}
|
||||
|
||||
if (error.response && error.response.status === 401) {
|
||||
// 如果返回401,说明token过期或无效,清除token并跳转到登录页
|
||||
store.commit('SET_TOKEN', '');
|
||||
store.commit('SET_USERNAME', '');
|
||||
router.push('/login');
|
||||
}
|
||||
return Promise.reject(error);
|
||||
redirectToLogin();
|
||||
|
||||
return Promise.reject({
|
||||
type: "auth",
|
||||
message: "登录已过期,请重新登录"
|
||||
});
|
||||
};
|
||||
|
||||
return Promise.reject({
|
||||
type: "server",
|
||||
message: "服务器异常,请稍后重试"
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { WS_URL } from "./config";
|
||||
|
||||
let ws = null;
|
||||
|
||||
// 建立WebSocket连接
|
||||
export function connectWebSocket(token, onMessage, onOpen, onClose, onError) {
|
||||
// 如果已经连接,就不重复连接
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
console.log("WebSocket已经连接");
|
||||
return;
|
||||
}
|
||||
|
||||
// 携带JWT token
|
||||
ws = new WebSocket(`${WS_URL}?token=${token}`);
|
||||
|
||||
// 连接成功
|
||||
ws.onopen = () => {
|
||||
console.log("WebSocket连接成功");
|
||||
|
||||
if (onOpen) {
|
||||
onOpen();
|
||||
}
|
||||
};
|
||||
|
||||
// 接收到服务器消息
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
console.log("收到WebSocket消息:", data);
|
||||
|
||||
if (onMessage) {
|
||||
onMessage(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("WebSocket消息解析失败:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// 连接关闭
|
||||
ws.onclose = () => {
|
||||
console.log("WebSocket连接关闭");
|
||||
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
|
||||
ws = null;
|
||||
};
|
||||
|
||||
// 连接错误
|
||||
ws.onerror = (error) => {
|
||||
console.error("WebSocket连接错误:", error);
|
||||
|
||||
if (onError) {
|
||||
onError(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 发送消息
|
||||
export function sendWebSocketMessage(data) {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||
console.error("WebSocket没有连接");
|
||||
return;
|
||||
}
|
||||
|
||||
ws.send(JSON.stringify(data));
|
||||
}
|
||||
|
||||
// 关闭WebSocket
|
||||
export function closeWebSocket() {
|
||||
if (ws) {
|
||||
ws.close();
|
||||
ws = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取当前WebSocket
|
||||
export function getWebSocket() {
|
||||
return ws;
|
||||
}
|
||||
Reference in New Issue
Block a user