feat: 添加在线咨询和人工转接页面,优化底部导航栏配置
This commit is contained in:
parent
608c1e4a46
commit
7dd1338d54
|
|
@ -0,0 +1,167 @@
|
||||||
|
<template>
|
||||||
|
<u-tabbar
|
||||||
|
v-if="show"
|
||||||
|
:value="currentIndex"
|
||||||
|
:show="true"
|
||||||
|
:bg-color="style.backgroundColor"
|
||||||
|
:border-top="style.borderTop"
|
||||||
|
:fixed="true"
|
||||||
|
:height="style.height"
|
||||||
|
:list="formattedTabList"
|
||||||
|
@change="handleTabChange"
|
||||||
|
>
|
||||||
|
</u-tabbar>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import tabbarConfig from "@/config/tabbar.config.js";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "TabBar",
|
||||||
|
props: {
|
||||||
|
// 当前页面路径(可选)
|
||||||
|
currentPath: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
// 是否显示 TabBar
|
||||||
|
show: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
currentIndex: 0,
|
||||||
|
tabList: tabbarConfig.tabs,
|
||||||
|
style: tabbarConfig.style,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
// 格式化为 uview tabbar 所需格式
|
||||||
|
formattedTabList() {
|
||||||
|
return this.tabList.map((item) => ({
|
||||||
|
text: item.text,
|
||||||
|
iconPath: item.icon,
|
||||||
|
selectedIconPath: item.activeIcon,
|
||||||
|
pagePath: item.pagePath,
|
||||||
|
count: item.badge || 0,
|
||||||
|
isDot: item.dot || false,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
// 监听 currentPath 变化
|
||||||
|
currentPath: {
|
||||||
|
handler(newPath) {
|
||||||
|
if (newPath) {
|
||||||
|
this.updateActiveTab(newPath);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
immediate: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.initActiveTab();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
/**
|
||||||
|
* 初始化激活的 tab
|
||||||
|
*/
|
||||||
|
initActiveTab() {
|
||||||
|
const path = this.currentPath || this.getCurrentPagePath();
|
||||||
|
this.updateActiveTab(path);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新激活的 tab
|
||||||
|
*/
|
||||||
|
updateActiveTab(path) {
|
||||||
|
const index = this.tabList.findIndex((item) =>
|
||||||
|
this.isPathMatch(path, item.pagePath)
|
||||||
|
);
|
||||||
|
if (index !== -1) {
|
||||||
|
this.currentIndex = index;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 路径匹配判断
|
||||||
|
*/
|
||||||
|
isPathMatch(currentPath, targetPath) {
|
||||||
|
const normalize = (path) => path.replace(/^\//, "");
|
||||||
|
return normalize(currentPath) === normalize(targetPath);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前页面路径
|
||||||
|
*/
|
||||||
|
getCurrentPagePath() {
|
||||||
|
const pages = getCurrentPages();
|
||||||
|
console.log('pages',pages);
|
||||||
|
|
||||||
|
if (pages.length > 0) {
|
||||||
|
return "/" + pages[pages.length - 1].route;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 tab 切换
|
||||||
|
*/
|
||||||
|
handleTabChange(index) {
|
||||||
|
if (this.currentIndex === index) {
|
||||||
|
this.$emit("reclick", this.tabList[index].pagePath, index);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetTab = this.tabList[index];
|
||||||
|
if (!targetTab) return;
|
||||||
|
|
||||||
|
this.currentIndex = index;
|
||||||
|
this.$emit("change", targetTab.pagePath, index);
|
||||||
|
|
||||||
|
// 跳转
|
||||||
|
this.navigateToTab(targetTab.pagePath);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 跳转到 tab 页面
|
||||||
|
*/
|
||||||
|
navigateToTab(url) {
|
||||||
|
uni.switchTab({
|
||||||
|
url,
|
||||||
|
fail: () => {
|
||||||
|
// 降级方案
|
||||||
|
uni.reLaunch({ url });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置角标
|
||||||
|
* @param {number} index - tab 索引
|
||||||
|
* @param {string|number} badge - 角标内容
|
||||||
|
*/
|
||||||
|
setBadge(index, badge) {
|
||||||
|
if (this.tabList[index]) {
|
||||||
|
this.$set(this.tabList[index], "badge", badge);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显示/隐藏小红点
|
||||||
|
* @param {number} index - tab 索引
|
||||||
|
* @param {boolean} show - 是否显示
|
||||||
|
*/
|
||||||
|
setDot(index, show) {
|
||||||
|
if (this.tabList[index]) {
|
||||||
|
this.$set(this.tabList[index], "dot", show);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
|
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
/**
|
||||||
|
* TabBar 配置文件
|
||||||
|
* 集中管理底部导航栏配置
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const TAB_BAR_CONFIG = [
|
||||||
|
{
|
||||||
|
text: "在线咨询",
|
||||||
|
icon: "/static/tabbar/tabbar-icon1.png",
|
||||||
|
activeIcon: "/static/tabbar/tabbar-icon1-active.png",
|
||||||
|
pagePath: "/pages/consultation/index",
|
||||||
|
// 可选配置
|
||||||
|
badge: "", // 角标文字
|
||||||
|
dot: false, // 是否显示小红点
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "人工转接",
|
||||||
|
icon: "/static/tabbar/tabbar-icon4.png",
|
||||||
|
activeIcon: "/static/tabbar/tabbar-icon4-active.png",
|
||||||
|
pagePath: "/pages/transfer/index",
|
||||||
|
badge: "",
|
||||||
|
dot: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "我的",
|
||||||
|
icon: "/static/tabbar/tabbar-icon3.png",
|
||||||
|
activeIcon: "/static/tabbar/tabbar-icon3-active.png",
|
||||||
|
pagePath: "/pages/my/index",
|
||||||
|
badge: "",
|
||||||
|
dot: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// TabBar 样式配置
|
||||||
|
export const TAB_BAR_STYLE = {
|
||||||
|
color: "#999999", // 未激活颜色
|
||||||
|
selectedColor: "#4a6cf7", // 激活颜色
|
||||||
|
backgroundColor: "#ffffff", // 背景色
|
||||||
|
borderTop: false, // 是否显示顶部边框
|
||||||
|
height: 148, // 高度(rpx)
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
tabs: TAB_BAR_CONFIG,
|
||||||
|
style: TAB_BAR_STYLE,
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
/**
|
||||||
|
* WebSocket 配置文件
|
||||||
|
*
|
||||||
|
* 集中管理 WebSocket 相关配置
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 根据环境选择 WebSocket 地址
|
||||||
|
const getWebSocketUrl = () => {
|
||||||
|
// #ifdef H5
|
||||||
|
// H5 开发环境
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
return 'ws://localhost:8082/ws/chat'
|
||||||
|
}
|
||||||
|
// H5 生产环境
|
||||||
|
return 'wss://120.55.234.65:8082/ws/chat'
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
// 小程序必须使用 wss(加密连接)
|
||||||
|
return 'wss://120.55.234.65:8082/ws/chat'
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// #ifdef APP-PLUS
|
||||||
|
// App 可以使用 ws 或 wss
|
||||||
|
return 'wss://120.55.234.65:8082/ws/chat'
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// 默认地址
|
||||||
|
return 'ws://120.55.234.65:8082/ws/chat'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
// WebSocket 服务器地址(动态获取)
|
||||||
|
url: getWebSocketUrl(),
|
||||||
|
|
||||||
|
// 重连配置
|
||||||
|
reconnect: {
|
||||||
|
enabled: true, // 是否启用自动重连
|
||||||
|
maxAttempts: 10, // 最大重连次数
|
||||||
|
interval: 3000, // 重连间隔(毫秒)
|
||||||
|
incrementInterval: true, // 是否递增重连间隔
|
||||||
|
maxInterval: 30000 // 最大重连间隔
|
||||||
|
},
|
||||||
|
|
||||||
|
// 心跳配置
|
||||||
|
heartbeat: {
|
||||||
|
enabled: true, // 是否启用心跳
|
||||||
|
interval: 30000, // 心跳间隔(毫秒)
|
||||||
|
timeout: 10000, // 心跳超时时间
|
||||||
|
pingMessage: { // 心跳消息格式
|
||||||
|
type: 'ping',
|
||||||
|
timestamp: () => Date.now()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 消息队列配置
|
||||||
|
messageQueue: {
|
||||||
|
enabled: true, // 是否启用消息队列
|
||||||
|
maxSize: 100, // 队列最大长度
|
||||||
|
clearOnConnect: false // 连接成功后是否清空队列
|
||||||
|
},
|
||||||
|
|
||||||
|
// 日志配置
|
||||||
|
log: {
|
||||||
|
enabled: true, // 是否启用日志
|
||||||
|
level: 'info' // 日志级别:debug, info, warn, error
|
||||||
|
},
|
||||||
|
|
||||||
|
// 消息类型定义
|
||||||
|
messageTypes: {
|
||||||
|
// 文字消息
|
||||||
|
TEXT: 'text',
|
||||||
|
// 图片消息
|
||||||
|
IMAGE: 'image',
|
||||||
|
// 语音消息
|
||||||
|
VOICE: 'voice',
|
||||||
|
// 系统消息
|
||||||
|
SYSTEM: 'system',
|
||||||
|
// 心跳
|
||||||
|
PING: 'ping',
|
||||||
|
PONG: 'pong',
|
||||||
|
// 已读回执
|
||||||
|
READ: 'read',
|
||||||
|
// 输入中
|
||||||
|
TYPING: 'typing',
|
||||||
|
// 人工转接
|
||||||
|
TRANSFER: 'transfer'
|
||||||
|
},
|
||||||
|
|
||||||
|
// 事件名称定义
|
||||||
|
events: {
|
||||||
|
OPEN: 'open',
|
||||||
|
CLOSE: 'close',
|
||||||
|
ERROR: 'error',
|
||||||
|
MESSAGE: 'message',
|
||||||
|
RECONNECT: 'reconnect',
|
||||||
|
RECONNECT_FAILED: 'reconnect_failed'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,607 @@
|
||||||
|
# WebSocket 使用示例
|
||||||
|
|
||||||
|
## 📦 已创建的文件
|
||||||
|
|
||||||
|
✅ `/utils/websocket-manager.js` - WebSocket 管理器(已完成)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 快速开始
|
||||||
|
|
||||||
|
### 1. 在 App.vue 中初始化连接
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script>
|
||||||
|
import wsManager from '@/utils/websocket-manager.js'
|
||||||
|
import { mapState } from 'vuex'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
computed: {
|
||||||
|
...mapState(['vuex_token', 'vuex_user'])
|
||||||
|
},
|
||||||
|
|
||||||
|
onLaunch() {
|
||||||
|
console.log('App Launch')
|
||||||
|
|
||||||
|
// 如果已登录,连接 WebSocket
|
||||||
|
if (this.vuex_token) {
|
||||||
|
this.connectWebSocket()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
connectWebSocket() {
|
||||||
|
// 替换为你的 WebSocket 服务器地址
|
||||||
|
const wsUrl = `wss://your-server.com/chat?token=${this.vuex_token}`
|
||||||
|
|
||||||
|
wsManager.connect(wsUrl, {
|
||||||
|
reconnectInterval: 3000, // 重连间隔 3 秒
|
||||||
|
maxReconnectAttempts: 10, // 最多重连 10 次
|
||||||
|
heartbeatInterval: 30000 // 心跳间隔 30 秒
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听连接成功
|
||||||
|
wsManager.on('open', () => {
|
||||||
|
console.log('[App] WebSocket 连接成功')
|
||||||
|
uni.showToast({
|
||||||
|
title: '已连接',
|
||||||
|
icon: 'success'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听连接关闭
|
||||||
|
wsManager.on('close', (res) => {
|
||||||
|
console.log('[App] WebSocket 已关闭', res)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听连接错误
|
||||||
|
wsManager.on('error', (err) => {
|
||||||
|
console.error('[App] WebSocket 错误', err)
|
||||||
|
uni.showToast({
|
||||||
|
title: '连接失败',
|
||||||
|
icon: 'none'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听重连
|
||||||
|
wsManager.on('reconnect', (attempts) => {
|
||||||
|
console.log(`[App] 正在重连 (${attempts})`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听消息(全局消息处理)
|
||||||
|
wsManager.on('message', (data) => {
|
||||||
|
console.log('[App] 收到消息:', data)
|
||||||
|
|
||||||
|
// 根据消息类型处理
|
||||||
|
switch (data.type) {
|
||||||
|
case 'message':
|
||||||
|
// 新消息
|
||||||
|
this.handleNewMessage(data)
|
||||||
|
break
|
||||||
|
case 'notification':
|
||||||
|
// 系统通知
|
||||||
|
this.handleNotification(data)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
console.log('[App] 未知消息类型:', data.type)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
handleNewMessage(data) {
|
||||||
|
// 更新 Vuex 中的消息列表
|
||||||
|
this.$store.commit('addMessage', data)
|
||||||
|
|
||||||
|
// 显示新消息提示
|
||||||
|
uni.showTabBarRedDot({
|
||||||
|
index: 1 // 消息页的 tabBar 索引
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
handleNotification(data) {
|
||||||
|
uni.showToast({
|
||||||
|
title: data.content,
|
||||||
|
icon: 'none'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onHide() {
|
||||||
|
// App 进入后台,保持连接
|
||||||
|
console.log('App Hide')
|
||||||
|
},
|
||||||
|
|
||||||
|
onShow() {
|
||||||
|
// App 从后台回到前台
|
||||||
|
console.log('App Show')
|
||||||
|
|
||||||
|
// 如果连接已断开,重新连接
|
||||||
|
const state = wsManager.getState()
|
||||||
|
if (!state.isConnected && !state.isConnecting) {
|
||||||
|
this.connectWebSocket()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. 在聊天页面中使用
|
||||||
|
|
||||||
|
#### 方式 A:修改现有的 `dialogBox.vue`
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<view>
|
||||||
|
<!-- 消息列表 -->
|
||||||
|
<scroll-view
|
||||||
|
:scroll-top="scrollTop"
|
||||||
|
:scroll-with-animation="true"
|
||||||
|
class="scroll"
|
||||||
|
scroll-y="true"
|
||||||
|
>
|
||||||
|
<view v-for="(msg, index) in messageList" :key="index" class="message-item">
|
||||||
|
<!-- 我方消息 -->
|
||||||
|
<view v-if="msg.fromUserId == vuex_user.id" class="my-message">
|
||||||
|
<text>{{ msg.content }}</text>
|
||||||
|
</view>
|
||||||
|
<!-- 对方消息 -->
|
||||||
|
<view v-else class="other-message">
|
||||||
|
<text>{{ msg.content }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
<!-- 输入框 -->
|
||||||
|
<view class="input-box">
|
||||||
|
<input
|
||||||
|
v-model="inputValue"
|
||||||
|
placeholder="请输入消息"
|
||||||
|
@confirm="sendMessage"
|
||||||
|
/>
|
||||||
|
<button @click="sendMessage">发送</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import wsManager from '@/utils/websocket-manager.js'
|
||||||
|
import { mapState } from 'vuex'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
messageList: [],
|
||||||
|
inputValue: '',
|
||||||
|
scrollTop: 0,
|
||||||
|
targetUserId: '', // 对方用户 ID
|
||||||
|
messageHandler: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
...mapState(['vuex_user'])
|
||||||
|
},
|
||||||
|
|
||||||
|
onLoad(options) {
|
||||||
|
// 获取对方用户 ID
|
||||||
|
this.targetUserId = options.userId || ''
|
||||||
|
|
||||||
|
// 加载历史消息
|
||||||
|
this.loadHistoryMessages()
|
||||||
|
|
||||||
|
// 监听新消息
|
||||||
|
this.messageHandler = (data) => {
|
||||||
|
// 只处理与当前聊天对象相关的消息
|
||||||
|
if (data.fromUserId === this.targetUserId || data.toUserId === this.targetUserId) {
|
||||||
|
this.messageList.push({
|
||||||
|
fromUserId: data.fromUserId,
|
||||||
|
toUserId: data.toUserId,
|
||||||
|
content: data.content,
|
||||||
|
timestamp: data.timestamp
|
||||||
|
})
|
||||||
|
|
||||||
|
// 滚动到底部
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.scrollToBottom()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 发送已读回执
|
||||||
|
this.sendReadReceipt(data.messageId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
wsManager.on('message', this.messageHandler)
|
||||||
|
},
|
||||||
|
|
||||||
|
onUnload() {
|
||||||
|
// 移除消息监听
|
||||||
|
if (this.messageHandler) {
|
||||||
|
wsManager.off('message', this.messageHandler)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
// 加载历史消息
|
||||||
|
async loadHistoryMessages() {
|
||||||
|
try {
|
||||||
|
const res = await this.$u.api.getMessages({
|
||||||
|
userId: this.targetUserId,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 50
|
||||||
|
})
|
||||||
|
|
||||||
|
this.messageList = res.data || []
|
||||||
|
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.scrollToBottom()
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载历史消息失败:', e)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 发送消息
|
||||||
|
sendMessage() {
|
||||||
|
if (!this.inputValue.trim()) {
|
||||||
|
uni.showToast({
|
||||||
|
title: '请输入消息',
|
||||||
|
icon: 'none'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通过 WebSocket 发送
|
||||||
|
wsManager.send({
|
||||||
|
type: 'message',
|
||||||
|
fromUserId: this.vuex_user.id,
|
||||||
|
toUserId: this.targetUserId,
|
||||||
|
content: this.inputValue,
|
||||||
|
timestamp: Date.now()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 立即显示到界面(乐观更新)
|
||||||
|
this.messageList.push({
|
||||||
|
fromUserId: this.vuex_user.id,
|
||||||
|
toUserId: this.targetUserId,
|
||||||
|
content: this.inputValue,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
sending: true // 标记为发送中
|
||||||
|
})
|
||||||
|
|
||||||
|
// 清空输入框
|
||||||
|
this.inputValue = ''
|
||||||
|
|
||||||
|
// 滚动到底部
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.scrollToBottom()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
// 发送已读回执
|
||||||
|
sendReadReceipt(messageId) {
|
||||||
|
wsManager.send({
|
||||||
|
type: 'read',
|
||||||
|
messageId: messageId,
|
||||||
|
userId: this.vuex_user.id
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
// 滚动到底部
|
||||||
|
scrollToBottom() {
|
||||||
|
this.scrollTop = 99999
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. 在 Vuex 中管理 WebSocket 状态(可选)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// store/index.js
|
||||||
|
export default new Vuex.Store({
|
||||||
|
state: {
|
||||||
|
// ... 其他状态
|
||||||
|
wsConnected: false,
|
||||||
|
unreadCount: 0,
|
||||||
|
messages: []
|
||||||
|
},
|
||||||
|
|
||||||
|
mutations: {
|
||||||
|
// WebSocket 连接状态
|
||||||
|
SET_WS_CONNECTED(state, connected) {
|
||||||
|
state.wsConnected = connected
|
||||||
|
},
|
||||||
|
|
||||||
|
// 新消息
|
||||||
|
ADD_MESSAGE(state, message) {
|
||||||
|
state.messages.push(message)
|
||||||
|
state.unreadCount++
|
||||||
|
},
|
||||||
|
|
||||||
|
// 清空未读
|
||||||
|
CLEAR_UNREAD(state) {
|
||||||
|
state.unreadCount = 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
// 在 App.vue 中调用
|
||||||
|
wsConnected({ commit }) {
|
||||||
|
commit('SET_WS_CONNECTED', true)
|
||||||
|
},
|
||||||
|
|
||||||
|
wsDisconnected({ commit }) {
|
||||||
|
commit('SET_WS_CONNECTED', false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 消息协议设计
|
||||||
|
|
||||||
|
### 客户端 → 服务器
|
||||||
|
|
||||||
|
#### 1. 发送文字消息
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "message",
|
||||||
|
"fromUserId": "user123",
|
||||||
|
"toUserId": "user456",
|
||||||
|
"content": "你好",
|
||||||
|
"timestamp": 1635678901234
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. 发送心跳
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "ping",
|
||||||
|
"timestamp": 1635678901234
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. 发送已读回执
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "read",
|
||||||
|
"messageId": "msg123",
|
||||||
|
"userId": "user123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 服务器 → 客户端
|
||||||
|
|
||||||
|
#### 1. 推送消息
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "message",
|
||||||
|
"messageId": "msg123",
|
||||||
|
"fromUserId": "user456",
|
||||||
|
"toUserId": "user123",
|
||||||
|
"content": "你好",
|
||||||
|
"timestamp": 1635678901234
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. 心跳响应
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "pong",
|
||||||
|
"timestamp": 1635678901234
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. 系统通知
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "notification",
|
||||||
|
"content": "系统维护通知",
|
||||||
|
"timestamp": 1635678901234
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. 错误消息
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "error",
|
||||||
|
"code": 401,
|
||||||
|
"message": "未授权",
|
||||||
|
"timestamp": 1635678901234
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 后端实现参考
|
||||||
|
|
||||||
|
### Node.js + ws
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const WebSocket = require('ws')
|
||||||
|
const wss = new WebSocket.Server({ port: 8080 })
|
||||||
|
|
||||||
|
// 存储所有连接(用户 ID -> WebSocket)
|
||||||
|
const clients = new Map()
|
||||||
|
|
||||||
|
wss.on('connection', (ws, req) => {
|
||||||
|
// 从 URL 获取 token
|
||||||
|
const token = new URL(req.url, 'http://localhost').searchParams.get('token')
|
||||||
|
|
||||||
|
// 验证 token,获取用户 ID
|
||||||
|
const userId = verifyToken(token)
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
ws.close(4001, '未授权')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存连接
|
||||||
|
clients.set(userId, ws)
|
||||||
|
console.log(`用户 ${userId} 已连接`)
|
||||||
|
|
||||||
|
// 处理消息
|
||||||
|
ws.on('message', (message) => {
|
||||||
|
const data = JSON.parse(message)
|
||||||
|
|
||||||
|
switch (data.type) {
|
||||||
|
case 'message':
|
||||||
|
// 转发消息
|
||||||
|
handleMessage(data)
|
||||||
|
break
|
||||||
|
case 'ping':
|
||||||
|
// 心跳响应
|
||||||
|
ws.send(JSON.stringify({ type: 'pong', timestamp: Date.now() }))
|
||||||
|
break
|
||||||
|
case 'read':
|
||||||
|
// 已读回执
|
||||||
|
handleReadReceipt(data)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 处理断开
|
||||||
|
ws.on('close', () => {
|
||||||
|
clients.delete(userId)
|
||||||
|
console.log(`用户 ${userId} 已断开`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 转发消息
|
||||||
|
function handleMessage(data) {
|
||||||
|
const { toUserId, fromUserId, content } = data
|
||||||
|
|
||||||
|
// 保存到数据库
|
||||||
|
saveMessage({ fromUserId, toUserId, content })
|
||||||
|
|
||||||
|
// 推送给接收者
|
||||||
|
const targetWs = clients.get(toUserId)
|
||||||
|
if (targetWs && targetWs.readyState === WebSocket.OPEN) {
|
||||||
|
targetWs.send(JSON.stringify({
|
||||||
|
type: 'message',
|
||||||
|
messageId: generateId(),
|
||||||
|
fromUserId,
|
||||||
|
toUserId,
|
||||||
|
content,
|
||||||
|
timestamp: Date.now()
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 完整流程图
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐
|
||||||
|
│ App 启动 │
|
||||||
|
└──────┬──────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────┐
|
||||||
|
│ 检查登录状态 │
|
||||||
|
└──────┬──────┘
|
||||||
|
│
|
||||||
|
▼ (已登录)
|
||||||
|
┌─────────────┐
|
||||||
|
│ 连接 WS │ ← wsManager.connect()
|
||||||
|
└──────┬──────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────┐
|
||||||
|
│ 监听消息 │ ← wsManager.on('message')
|
||||||
|
└──────┬──────┘
|
||||||
|
│
|
||||||
|
├─→ (收到消息) → 更新 UI
|
||||||
|
│
|
||||||
|
└─→ (发送消息) → wsManager.send()
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────┐
|
||||||
|
│ 服务器处理 │
|
||||||
|
└──────┬──────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────┐
|
||||||
|
│ 推送给对方 │
|
||||||
|
└─────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 测试清单
|
||||||
|
|
||||||
|
### 功能测试
|
||||||
|
- [ ] 连接成功
|
||||||
|
- [ ] 发送消息
|
||||||
|
- [ ] 接收消息
|
||||||
|
- [ ] 心跳正常
|
||||||
|
- [ ] 手动断开
|
||||||
|
- [ ] 自动重连
|
||||||
|
- [ ] 消息队列
|
||||||
|
|
||||||
|
### 场景测试
|
||||||
|
- [ ] App 切换到后台
|
||||||
|
- [ ] App 从后台恢复
|
||||||
|
- [ ] 网络断开
|
||||||
|
- [ ] 网络恢复
|
||||||
|
- [ ] 服务器重启
|
||||||
|
- [ ] 并发多人聊天
|
||||||
|
|
||||||
|
### 性能测试
|
||||||
|
- [ ] 消息延迟 < 200ms
|
||||||
|
- [ ] 重连时间 < 5s
|
||||||
|
- [ ] 内存占用正常
|
||||||
|
- [ ] CPU 占用正常
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎉 总结
|
||||||
|
|
||||||
|
现在你已经有:
|
||||||
|
|
||||||
|
1. ✅ **WebSocket 管理器** (`utils/websocket-manager.js`)
|
||||||
|
- 自动重连
|
||||||
|
- 心跳检测
|
||||||
|
- 消息队列
|
||||||
|
- 事件监听
|
||||||
|
|
||||||
|
2. ✅ **使用示例**
|
||||||
|
- App.vue 初始化
|
||||||
|
- 聊天页面使用
|
||||||
|
- Vuex 状态管理
|
||||||
|
|
||||||
|
3. ✅ **消息协议**
|
||||||
|
- 客户端 → 服务器
|
||||||
|
- 服务器 → 客户端
|
||||||
|
|
||||||
|
4. ✅ **后端参考**
|
||||||
|
- Node.js + ws 实现
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 下一步
|
||||||
|
|
||||||
|
1. **确认后端技术栈**
|
||||||
|
- 告诉我你用什么后端,我帮你写完整的服务端代码
|
||||||
|
|
||||||
|
2. **集成到现有聊天页面**
|
||||||
|
- 我帮你修改 `dialogBox.vue`
|
||||||
|
|
||||||
|
3. **添加更多功能**
|
||||||
|
- 图片消息
|
||||||
|
- 语音消息
|
||||||
|
- 消息已读
|
||||||
|
- 输入中状态
|
||||||
|
|
||||||
|
随时告诉我需要什么!🎯
|
||||||
|
|
||||||
|
|
@ -0,0 +1,616 @@
|
||||||
|
# WebSocket 实施计划
|
||||||
|
|
||||||
|
## 🎯 项目信息
|
||||||
|
|
||||||
|
- **项目名称**:英星AI - 浙江科技大学招生咨询系统
|
||||||
|
- **技术栈**:uni-app + Vue 2
|
||||||
|
- **后端**:ASP.NET Core(推测)
|
||||||
|
- **方案选择**:WebSocket 原生方案 ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 实施步骤
|
||||||
|
|
||||||
|
### 阶段一:前端准备(已完成 ✅)
|
||||||
|
|
||||||
|
#### 1. WebSocket 管理器
|
||||||
|
- ✅ 文件:`utils/websocket-manager.js`
|
||||||
|
- ✅ 功能:自动重连、心跳检测、消息队列、事件监听
|
||||||
|
|
||||||
|
#### 2. WebSocket 配置
|
||||||
|
- ✅ 文件:`config/websocket.config.js`
|
||||||
|
- ✅ 功能:集中管理 WebSocket 配置
|
||||||
|
|
||||||
|
#### 3. 使用文档
|
||||||
|
- ✅ 文件:`docs/WebSocket使用示例.md`
|
||||||
|
- ✅ 文件:`docs/实时通讯方案对比.md`
|
||||||
|
- ✅ 文件:`docs/实时通讯快速选型.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 阶段二:后端开发(待实施 ⏳)
|
||||||
|
|
||||||
|
#### 选项 A:ASP.NET Core WebSocket 服务(推荐)
|
||||||
|
|
||||||
|
**时间**:3-5 天
|
||||||
|
|
||||||
|
**工作量:**
|
||||||
|
1. 创建 WebSocket 中间件(1 天)
|
||||||
|
2. 实现消息路由(1 天)
|
||||||
|
3. 集成用户认证(1 天)
|
||||||
|
4. 消息持久化(1 天)
|
||||||
|
5. 测试调试(1 天)
|
||||||
|
|
||||||
|
**文件清单:**
|
||||||
|
- `WebSocketMiddleware.cs` - WebSocket 中间件
|
||||||
|
- `WebSocketConnectionManager.cs` - 连接管理器
|
||||||
|
- `WebSocketMessageHandler.cs` - 消息处理器
|
||||||
|
- `ChatController.cs` - HTTP API(获取历史消息)
|
||||||
|
- `MessageRepository.cs` - 消息存储
|
||||||
|
|
||||||
|
**后端代码**:见下文 `.NET Core WebSocket 服务端代码`
|
||||||
|
|
||||||
|
#### 选项 B:使用现有 SignalR(备选)
|
||||||
|
|
||||||
|
你的项目已经有 SignalR:
|
||||||
|
```javascript
|
||||||
|
// main.js
|
||||||
|
var connection = new HubConnectionBuilder()
|
||||||
|
.withUrl("http://sl.vrgon.com:8003/ChatHub")
|
||||||
|
.build();
|
||||||
|
```
|
||||||
|
|
||||||
|
**建议:**
|
||||||
|
- 如果 SignalR 服务正常,可以继续使用
|
||||||
|
- 如果需要切换到原生 WebSocket,按选项 A 实施
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 阶段三:前端集成(2-3 天)
|
||||||
|
|
||||||
|
#### 1. 在 App.vue 初始化 WebSocket
|
||||||
|
|
||||||
|
**文件**:`App.vue`
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import wsManager from '@/utils/websocket-manager.js'
|
||||||
|
import wsConfig from '@/config/websocket.config.js'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
onLaunch() {
|
||||||
|
// 登录后连接 WebSocket
|
||||||
|
if (this.vuex_token) {
|
||||||
|
this.connectWebSocket()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
connectWebSocket() {
|
||||||
|
// 构建 WebSocket URL(带 token)
|
||||||
|
const wsUrl = `${wsConfig.url}?token=${this.vuex_token}`
|
||||||
|
|
||||||
|
// 连接
|
||||||
|
wsManager.connect(wsUrl, {
|
||||||
|
reconnectInterval: wsConfig.reconnect.interval,
|
||||||
|
maxReconnectAttempts: wsConfig.reconnect.maxAttempts,
|
||||||
|
heartbeatInterval: wsConfig.heartbeat.interval
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听事件
|
||||||
|
this.setupWebSocketListeners()
|
||||||
|
},
|
||||||
|
|
||||||
|
setupWebSocketListeners() {
|
||||||
|
// 连接成功
|
||||||
|
wsManager.on('open', () => {
|
||||||
|
console.log('[App] WebSocket 已连接')
|
||||||
|
})
|
||||||
|
|
||||||
|
// 收到消息
|
||||||
|
wsManager.on('message', (data) => {
|
||||||
|
this.handleWebSocketMessage(data)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 连接关闭
|
||||||
|
wsManager.on('close', () => {
|
||||||
|
console.log('[App] WebSocket 已断开')
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
handleWebSocketMessage(data) {
|
||||||
|
// 根据消息类型处理
|
||||||
|
switch (data.type) {
|
||||||
|
case 'message':
|
||||||
|
// 新消息提醒
|
||||||
|
this.$store.commit('addMessage', data)
|
||||||
|
break
|
||||||
|
case 'system':
|
||||||
|
// 系统通知
|
||||||
|
uni.showToast({ title: data.content, icon: 'none' })
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. 修改聊天页面
|
||||||
|
|
||||||
|
**文件**:`pages/message/dialogBox/dialogBox.vue`
|
||||||
|
|
||||||
|
需要:
|
||||||
|
1. 导入 WebSocket 管理器
|
||||||
|
2. 监听实时消息
|
||||||
|
3. 发送消息通过 WebSocket
|
||||||
|
4. 加载历史消息通过 HTTP API
|
||||||
|
|
||||||
|
**代码示例**:见 `docs/WebSocket使用示例.md`
|
||||||
|
|
||||||
|
#### 3. 添加聊天相关 API
|
||||||
|
|
||||||
|
**文件**:`common/http.api.js`
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 获取聊天历史记录
|
||||||
|
let getChatHistory = (params = {}) => vm.$u.get('api/Chat/GetHistory', params);
|
||||||
|
|
||||||
|
// 发送离线消息(WebSocket 断开时使用)
|
||||||
|
let sendOfflineMessage = (params = {}) => vm.$u.post('api/Chat/SendOffline', params);
|
||||||
|
|
||||||
|
// 标记消息已读
|
||||||
|
let markMessageRead = (params = {}) => vm.$u.post('api/Chat/MarkRead', params);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 阶段四:测试验证(2-3 天)
|
||||||
|
|
||||||
|
#### 1. 功能测试
|
||||||
|
|
||||||
|
- [ ] 连接建立
|
||||||
|
- [ ] 发送文字消息
|
||||||
|
- [ ] 接收文字消息
|
||||||
|
- [ ] 心跳保持
|
||||||
|
- [ ] 自动重连
|
||||||
|
- [ ] 离线消息推送
|
||||||
|
- [ ] 消息已读回执
|
||||||
|
|
||||||
|
#### 2. 场景测试
|
||||||
|
|
||||||
|
- [ ] 单人聊天
|
||||||
|
- [ ] 多人并发
|
||||||
|
- [ ] 网络断开
|
||||||
|
- [ ] 网络恢复
|
||||||
|
- [ ] App 切换后台
|
||||||
|
- [ ] App 恢复前台
|
||||||
|
- [ ] 登录/登出
|
||||||
|
|
||||||
|
#### 3. 性能测试
|
||||||
|
|
||||||
|
- [ ] 消息延迟 < 200ms
|
||||||
|
- [ ] 重连时间 < 5s
|
||||||
|
- [ ] 内存占用正常
|
||||||
|
- [ ] CPU 占用正常
|
||||||
|
- [ ] 100+ 并发用户
|
||||||
|
|
||||||
|
#### 4. 兼容性测试
|
||||||
|
|
||||||
|
- [ ] H5(Chrome、Safari)
|
||||||
|
- [ ] 微信小程序
|
||||||
|
- [ ] Android App
|
||||||
|
- [ ] iOS App
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 后端实现
|
||||||
|
|
||||||
|
### .NET Core WebSocket 服务端代码
|
||||||
|
|
||||||
|
#### 1. WebSocket 中间件
|
||||||
|
|
||||||
|
**文件**:`WebSocketMiddleware.cs`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using System;
|
||||||
|
using System.Net.WebSockets;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
public class WebSocketMiddleware
|
||||||
|
{
|
||||||
|
private readonly RequestDelegate _next;
|
||||||
|
private readonly WebSocketConnectionManager _connectionManager;
|
||||||
|
|
||||||
|
public WebSocketMiddleware(RequestDelegate next, WebSocketConnectionManager connectionManager)
|
||||||
|
{
|
||||||
|
_next = next;
|
||||||
|
_connectionManager = connectionManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InvokeAsync(HttpContext context)
|
||||||
|
{
|
||||||
|
// 检查是否是 WebSocket 请求
|
||||||
|
if (!context.WebSockets.IsWebSocketRequest)
|
||||||
|
{
|
||||||
|
await _next(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 token
|
||||||
|
var token = context.Request.Query["token"].ToString();
|
||||||
|
var userId = ValidateToken(token);
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(userId))
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = 401;
|
||||||
|
await context.Response.WriteAsync("Unauthorized");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 接受 WebSocket 连接
|
||||||
|
var webSocket = await context.WebSockets.AcceptWebSocketAsync();
|
||||||
|
|
||||||
|
// 保存连接
|
||||||
|
_connectionManager.AddConnection(userId, webSocket);
|
||||||
|
|
||||||
|
Console.WriteLine($"[WebSocket] 用户 {userId} 已连接");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await HandleWebSocketAsync(userId, webSocket);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
// 移除连接
|
||||||
|
_connectionManager.RemoveConnection(userId);
|
||||||
|
Console.WriteLine($"[WebSocket] 用户 {userId} 已断开");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleWebSocketAsync(string userId, WebSocket webSocket)
|
||||||
|
{
|
||||||
|
var buffer = new byte[1024 * 4];
|
||||||
|
|
||||||
|
while (webSocket.State == WebSocketState.Open)
|
||||||
|
{
|
||||||
|
var result = await webSocket.ReceiveAsync(
|
||||||
|
new ArraySegment<byte>(buffer),
|
||||||
|
CancellationToken.None
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.MessageType == WebSocketMessageType.Close)
|
||||||
|
{
|
||||||
|
await webSocket.CloseAsync(
|
||||||
|
WebSocketCloseStatus.NormalClosure,
|
||||||
|
"关闭连接",
|
||||||
|
CancellationToken.None
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析消息
|
||||||
|
var message = Encoding.UTF8.GetString(buffer, 0, result.Count);
|
||||||
|
await HandleMessageAsync(userId, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleMessageAsync(string userId, string message)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var data = JsonConvert.DeserializeObject<WebSocketMessage>(message);
|
||||||
|
|
||||||
|
switch (data.Type)
|
||||||
|
{
|
||||||
|
case "message":
|
||||||
|
// 处理聊天消息
|
||||||
|
await HandleChatMessageAsync(userId, data);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "ping":
|
||||||
|
// 处理心跳
|
||||||
|
await SendToUserAsync(userId, new
|
||||||
|
{
|
||||||
|
type = "pong",
|
||||||
|
timestamp = DateTimeOffset.Now.ToUnixTimeMilliseconds()
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "read":
|
||||||
|
// 处理已读回执
|
||||||
|
await HandleReadReceiptAsync(userId, data);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
Console.WriteLine($"未知消息类型: {data.Type}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"消息处理失败: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleChatMessageAsync(string fromUserId, WebSocketMessage data)
|
||||||
|
{
|
||||||
|
var toUserId = data.ToUserId;
|
||||||
|
var content = data.Content;
|
||||||
|
|
||||||
|
// 保存消息到数据库
|
||||||
|
var messageId = await SaveMessageAsync(fromUserId, toUserId, content);
|
||||||
|
|
||||||
|
// 构建消息对象
|
||||||
|
var messageObj = new
|
||||||
|
{
|
||||||
|
type = "message",
|
||||||
|
messageId = messageId,
|
||||||
|
fromUserId = fromUserId,
|
||||||
|
toUserId = toUserId,
|
||||||
|
content = content,
|
||||||
|
timestamp = DateTimeOffset.Now.ToUnixTimeMilliseconds()
|
||||||
|
};
|
||||||
|
|
||||||
|
// 发送给接收者
|
||||||
|
await SendToUserAsync(toUserId, messageObj);
|
||||||
|
|
||||||
|
// 发送给发送者(确认消息已发送)
|
||||||
|
await SendToUserAsync(fromUserId, messageObj);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> SaveMessageAsync(string fromUserId, string toUserId, string content)
|
||||||
|
{
|
||||||
|
// TODO: 保存消息到数据库
|
||||||
|
// 返回消息 ID
|
||||||
|
return Guid.NewGuid().ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleReadReceiptAsync(string userId, WebSocketMessage data)
|
||||||
|
{
|
||||||
|
// TODO: 更新消息已读状态
|
||||||
|
Console.WriteLine($"用户 {userId} 已读消息 {data.MessageId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SendToUserAsync(string userId, object message)
|
||||||
|
{
|
||||||
|
var webSocket = _connectionManager.GetConnection(userId);
|
||||||
|
if (webSocket != null && webSocket.State == WebSocketState.Open)
|
||||||
|
{
|
||||||
|
var json = JsonConvert.SerializeObject(message);
|
||||||
|
var bytes = Encoding.UTF8.GetBytes(json);
|
||||||
|
await webSocket.SendAsync(
|
||||||
|
new ArraySegment<byte>(bytes),
|
||||||
|
WebSocketMessageType.Text,
|
||||||
|
true,
|
||||||
|
CancellationToken.None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string ValidateToken(string token)
|
||||||
|
{
|
||||||
|
// TODO: 验证 JWT token,返回用户 ID
|
||||||
|
// 这里简化处理,直接返回一个用户 ID
|
||||||
|
return "user123";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 消息实体
|
||||||
|
public class WebSocketMessage
|
||||||
|
{
|
||||||
|
public string Type { get; set; }
|
||||||
|
public string FromUserId { get; set; }
|
||||||
|
public string ToUserId { get; set; }
|
||||||
|
public string Content { get; set; }
|
||||||
|
public string MessageId { get; set; }
|
||||||
|
public long Timestamp { get; set; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. 连接管理器
|
||||||
|
|
||||||
|
**文件**:`WebSocketConnectionManager.cs`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Net.WebSockets;
|
||||||
|
|
||||||
|
public class WebSocketConnectionManager
|
||||||
|
{
|
||||||
|
// 存储所有连接(用户 ID -> WebSocket)
|
||||||
|
private readonly ConcurrentDictionary<string, WebSocket> _connections =
|
||||||
|
new ConcurrentDictionary<string, WebSocket>();
|
||||||
|
|
||||||
|
// 添加连接
|
||||||
|
public void AddConnection(string userId, WebSocket webSocket)
|
||||||
|
{
|
||||||
|
_connections.TryAdd(userId, webSocket);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除连接
|
||||||
|
public void RemoveConnection(string userId)
|
||||||
|
{
|
||||||
|
_connections.TryRemove(userId, out _);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取连接
|
||||||
|
public WebSocket GetConnection(string userId)
|
||||||
|
{
|
||||||
|
_connections.TryGetValue(userId, out var webSocket);
|
||||||
|
return webSocket;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所有在线用户
|
||||||
|
public IEnumerable<string> GetOnlineUsers()
|
||||||
|
{
|
||||||
|
return _connections.Keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取在线用户数
|
||||||
|
public int GetOnlineCount()
|
||||||
|
{
|
||||||
|
return _connections.Count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. 注册中间件
|
||||||
|
|
||||||
|
**文件**:`Startup.cs`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class Startup
|
||||||
|
{
|
||||||
|
public void ConfigureServices(IServiceCollection services)
|
||||||
|
{
|
||||||
|
// 注册连接管理器为单例
|
||||||
|
services.AddSingleton<WebSocketConnectionManager>();
|
||||||
|
|
||||||
|
// 其他服务...
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
|
||||||
|
{
|
||||||
|
// 启用 WebSocket
|
||||||
|
app.UseWebSockets(new WebSocketOptions
|
||||||
|
{
|
||||||
|
KeepAliveInterval = TimeSpan.FromSeconds(30)
|
||||||
|
});
|
||||||
|
|
||||||
|
// 注册 WebSocket 中间件
|
||||||
|
app.Map("/ws/chat", builder =>
|
||||||
|
{
|
||||||
|
builder.UseMiddleware<WebSocketMiddleware>();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 其他中间件...
|
||||||
|
app.UseRouting();
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
|
app.UseEndpoints(endpoints =>
|
||||||
|
{
|
||||||
|
endpoints.MapControllers();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. HTTP API(获取历史消息)
|
||||||
|
|
||||||
|
**文件**:`ChatController.cs`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class ChatController : ControllerBase
|
||||||
|
{
|
||||||
|
// 获取聊天历史
|
||||||
|
[HttpGet("GetHistory")]
|
||||||
|
public async Task<IActionResult> GetHistory(string userId, int page = 1, int pageSize = 50)
|
||||||
|
{
|
||||||
|
// TODO: 从数据库查询历史消息
|
||||||
|
var messages = new List<object>
|
||||||
|
{
|
||||||
|
new
|
||||||
|
{
|
||||||
|
messageId = "msg001",
|
||||||
|
fromUserId = "user123",
|
||||||
|
toUserId = userId,
|
||||||
|
content = "你好",
|
||||||
|
timestamp = DateTimeOffset.Now.ToUnixTimeMilliseconds()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
success = true,
|
||||||
|
data = messages,
|
||||||
|
total = messages.Count
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送离线消息
|
||||||
|
[HttpPost("SendOffline")]
|
||||||
|
public async Task<IActionResult> SendOffline([FromBody] SendMessageRequest request)
|
||||||
|
{
|
||||||
|
// TODO: 保存离线消息到数据库
|
||||||
|
return Ok(new { success = true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记已读
|
||||||
|
[HttpPost("MarkRead")]
|
||||||
|
public async Task<IActionResult> MarkRead([FromBody] MarkReadRequest request)
|
||||||
|
{
|
||||||
|
// TODO: 更新消息已读状态
|
||||||
|
return Ok(new { success = true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SendMessageRequest
|
||||||
|
{
|
||||||
|
public string ToUserId { get; set; }
|
||||||
|
public string Content { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MarkReadRequest
|
||||||
|
{
|
||||||
|
public string MessageId { get; set; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 时间安排
|
||||||
|
|
||||||
|
| 阶段 | 工作内容 | 预计时间 | 负责人 |
|
||||||
|
|------|----------|----------|--------|
|
||||||
|
| **阶段一** | 前端准备 | ✅ 已完成 | 前端 |
|
||||||
|
| **阶段二** | 后端开发 | 3-5 天 | 后端 |
|
||||||
|
| **阶段三** | 前端集成 | 2-3 天 | 前端 |
|
||||||
|
| **阶段四** | 测试验证 | 2-3 天 | 全员 |
|
||||||
|
| **总计** | | **7-11 天** | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 下一步行动
|
||||||
|
|
||||||
|
### 立即可做(前端)
|
||||||
|
|
||||||
|
1. **在 App.vue 中初始化 WebSocket**
|
||||||
|
- 导入 `websocket-manager.js`
|
||||||
|
- 在 `onLaunch` 中调用 `wsManager.connect()`
|
||||||
|
- 设置事件监听
|
||||||
|
|
||||||
|
2. **测试 WebSocket 管理器**
|
||||||
|
- 使用在线 WebSocket 测试服务(如 `wss://echo.websocket.org`)
|
||||||
|
- 验证连接、发送、接收功能
|
||||||
|
|
||||||
|
### 需要后端配合
|
||||||
|
|
||||||
|
1. **确认后端技术栈**
|
||||||
|
- 是否是 ASP.NET Core?
|
||||||
|
- 后端开发人员是谁?
|
||||||
|
|
||||||
|
2. **部署 WebSocket 服务**
|
||||||
|
- 使用上面提供的 .NET Core 代码
|
||||||
|
- 或者告诉我你的后端技术,我提供对应代码
|
||||||
|
|
||||||
|
3. **配置服务器**
|
||||||
|
- 确保服务器支持 WebSocket
|
||||||
|
- 配置 Nginx 反向代理(如果需要)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 联系与支持
|
||||||
|
|
||||||
|
如果你需要:
|
||||||
|
1. ✅ 其他后端语言的 WebSocket 服务端代码(Node.js/Java/Go/Python)
|
||||||
|
2. ✅ 帮助修改 `dialogBox.vue` 集成 WebSocket
|
||||||
|
3. ✅ Nginx 配置 WebSocket 反向代理
|
||||||
|
4. ✅ 调试和测试支持
|
||||||
|
|
||||||
|
随时告诉我!🚀
|
||||||
|
|
||||||
35
pages.json
35
pages.json
|
|
@ -27,13 +27,7 @@
|
||||||
"navigationStyle": "custom"
|
"navigationStyle": "custom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"path": "pages/my/index",
|
|
||||||
"style": {
|
|
||||||
"navigationBarTitleText": "我的",
|
|
||||||
"navigationStyle": "custom"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"path": "pages/my/personalInfo",
|
"path": "pages/my/personalInfo",
|
||||||
"style": {
|
"style": {
|
||||||
|
|
@ -58,13 +52,28 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/home/conversations/index",
|
"path": "pages/consultation/index",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationBarTitleText": "会话列表",
|
"navigationBarTitleText": "在线咨询",
|
||||||
"enablePullDownRefresh": false,
|
"enablePullDownRefresh": false,
|
||||||
"navigationStyle": "custom"
|
"navigationStyle": "custom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/transfer/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "人工转接",
|
||||||
|
"enablePullDownRefresh": false,
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/my/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "我的",
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/login/login/index",
|
"path": "pages/login/login/index",
|
||||||
"style": {
|
"style": {
|
||||||
|
|
@ -104,16 +113,16 @@
|
||||||
"backgroundColor": "#ffffff",
|
"backgroundColor": "#ffffff",
|
||||||
"list": [
|
"list": [
|
||||||
{
|
{
|
||||||
"pagePath": "pages/home/conversations/index",
|
"pagePath": "pages/consultation/index",
|
||||||
"iconPath": "static/tabbar/icon_home.png",
|
"iconPath": "static/tabbar/icon_home.png",
|
||||||
"selectedIconPath": "static/tabbar/icon_home_active.png",
|
"selectedIconPath": "static/tabbar/icon_home_active.png",
|
||||||
"text": "会话列表"
|
"text": "在线咨询"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"pagePath": "pages/notes/index",
|
"pagePath": "pages/transfer/index",
|
||||||
"iconPath": "static/tabbar/icon_message.png",
|
"iconPath": "static/tabbar/icon_message.png",
|
||||||
"selectedIconPath": "static/tabbar/icon_message_active.png",
|
"selectedIconPath": "static/tabbar/icon_message_active.png",
|
||||||
"text": "留言板"
|
"text": "人工转接"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"pagePath": "pages/my/index",
|
"pagePath": "pages/my/index",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,224 @@
|
||||||
|
<template>
|
||||||
|
<view class="page-container">
|
||||||
|
<view class="page-header">
|
||||||
|
<PageHeader title="在线咨询" :is-back="false" :border-bottom="false" />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<scroll-view
|
||||||
|
class="page-main"
|
||||||
|
scroll-y
|
||||||
|
enable-back-to-top
|
||||||
|
>
|
||||||
|
<!-- 内容内层 - 添加内边距和安全区域 -->
|
||||||
|
<view class="main-content">
|
||||||
|
<!-- 咨询入口列表 -->
|
||||||
|
<view
|
||||||
|
class="consultation-item"
|
||||||
|
v-for="(item, index) in consultationList"
|
||||||
|
:key="index"
|
||||||
|
@click="handleConsultation(item)"
|
||||||
|
>
|
||||||
|
<view class="item-left">
|
||||||
|
<image class="item-icon" :src="item.icon"></image>
|
||||||
|
<view class="item-info">
|
||||||
|
<text class="item-title">{{ item.title }}</text>
|
||||||
|
<text class="item-desc">{{ item.desc }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<u-icon name="arrow-right" color="#999" size="20"></u-icon>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 无数据提示 -->
|
||||||
|
<view class="empty-tip" v-if="consultationList.length === 0">
|
||||||
|
暂无咨询服务
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
<view class="page-tabbar">
|
||||||
|
<TabBar :currentPath="'/pages/consultation/index'" @change="handleTabChange" />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<u-modal
|
||||||
|
v-model="showModal"
|
||||||
|
:show-cancel-button="false"
|
||||||
|
title="提示"
|
||||||
|
:content="modalContent"
|
||||||
|
@confirm="showModal = false"
|
||||||
|
></u-modal>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import TabBar from "@/components/TabBar-optimized.vue";
|
||||||
|
import PageHeader from "@/components/PageHeader.vue";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "ConsultationPage",
|
||||||
|
components: {
|
||||||
|
TabBar,
|
||||||
|
PageHeader,
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
showModal: false,
|
||||||
|
modalContent: '',
|
||||||
|
consultationList: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: "智能问答",
|
||||||
|
desc: "AI智能机器人为您解答",
|
||||||
|
icon: "/static/common/icon/robot.png",
|
||||||
|
type: "ai"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: "招生咨询",
|
||||||
|
desc: "招生相关问题咨询",
|
||||||
|
icon: "/static/common/icon/admissions.png",
|
||||||
|
type: "admissions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: "教务咨询",
|
||||||
|
desc: "教务相关问题咨询",
|
||||||
|
icon: "/static/common/icon/academic.png",
|
||||||
|
type: "academic"
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
handleTabChange(path, index) {
|
||||||
|
console.log("切换到标签页:", path, index);
|
||||||
|
},
|
||||||
|
handleConsultation(item) {
|
||||||
|
// 这里可以跳转到具体的咨询页面
|
||||||
|
this.modalContent = `即将进入${item.title}`;
|
||||||
|
this.showModal = true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ===== 页面容器 - 主流三段式布局 ===== */
|
||||||
|
.page-container {
|
||||||
|
/* 固定定位,占满整个视口 */
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
|
||||||
|
/* Flex 布局 */
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
/* 背景色 */
|
||||||
|
background-color: #f5f6fa;
|
||||||
|
|
||||||
|
/* 防止溢出 */
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 头部导航 ===== */
|
||||||
|
.page-header {
|
||||||
|
/* 不收缩,固定高度 */
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
/* 层级 */
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 内容区域 ===== */
|
||||||
|
.page-main {
|
||||||
|
/* 占据剩余空间 */
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
/* 重要:防止 flex 子元素溢出 */
|
||||||
|
height: 0;
|
||||||
|
|
||||||
|
/* 允许滚动 */
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
/* iOS 滚动优化 */
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 内容内层 ===== */
|
||||||
|
.main-content {
|
||||||
|
/* 内边距 */
|
||||||
|
padding: 10px;
|
||||||
|
|
||||||
|
/* 底部留出 TabBar 空间 + 安全区域 */
|
||||||
|
padding-bottom: calc(50px + env(safe-area-inset-bottom) + 10px);
|
||||||
|
|
||||||
|
/* 最小高度(确保可以滚动) */
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 底部导航 ===== */
|
||||||
|
.page-tabbar {
|
||||||
|
/* 不收缩,固定高度 */
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
/* 层级 */
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consultation-item {
|
||||||
|
background-color: #fff;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consultation-item:active {
|
||||||
|
background-color: #f8f8f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
margin-right: 15px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-tip {
|
||||||
|
text-align: center;
|
||||||
|
padding: 50px 20px;
|
||||||
|
color: #999;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|
@ -1,89 +1,99 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="my-page">
|
<view class="page-container">
|
||||||
<PageHeader
|
<view class="page-header">
|
||||||
title="我的"
|
<PageHeader
|
||||||
:is-back="false"
|
title="我的"
|
||||||
:border-bottom="false"
|
:is-back="false"
|
||||||
:background="headerBackground"
|
:border-bottom="false"
|
||||||
/>
|
:background="headerBackground"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
|
||||||
<div class="content-wrapper">
|
<scroll-view
|
||||||
<div class="user-info">
|
class="page-main"
|
||||||
<div class="avatar">
|
scroll-y
|
||||||
<!-- <img src="" alt="用户头像" /> -->
|
enable-back-to-top
|
||||||
</div>
|
>
|
||||||
<div class="info">
|
<view class="main-content">
|
||||||
<div class="name">{{ teacherInfo.name }}</div>
|
<div class="user-info">
|
||||||
<div class="tag">
|
<div class="avatar">
|
||||||
<image
|
<!-- <img src="" alt="用户头像" /> -->
|
||||||
class="tag-icon"
|
|
||||||
src="@/static/notes/collage-icon.png"
|
|
||||||
></image>
|
|
||||||
{{ teacherInfo.collegeName }}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="tag">
|
<div class="info">
|
||||||
<image class="tag-icon" src="@/static/notes/major-icon.png"></image>
|
<div class="name">{{ teacherInfo.name }}</div>
|
||||||
{{ teacherInfo.professionalName }}
|
<div class="tag">
|
||||||
|
<image
|
||||||
|
class="tag-icon"
|
||||||
|
src="@/static/notes/collage-icon.png"
|
||||||
|
></image>
|
||||||
|
{{ teacherInfo.collegeName }}
|
||||||
|
</div>
|
||||||
|
<div class="tag">
|
||||||
|
<image class="tag-icon" src="@/static/notes/major-icon.png"></image>
|
||||||
|
{{ teacherInfo.professionalName }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="statistics">
|
<div class="statistics">
|
||||||
<div class="stat-item">
|
<div class="stat-item">
|
||||||
<div class="stat-num">36</div>
|
<div class="stat-num">36</div>
|
||||||
<div class="stat-label">总答题</div>
|
<div class="stat-label">总答题</div>
|
||||||
</div>
|
|
||||||
<div class="stat-item">
|
|
||||||
<div class="stat-num">10</div>
|
|
||||||
<div class="stat-label">已完成</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-item">
|
|
||||||
<div class="stat-num">26</div>
|
|
||||||
<div class="stat-label">未回复</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="banner">
|
|
||||||
<img src="@/static/notes/banner.png" alt="banner" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="menu-list">
|
|
||||||
<div class="menu-item" @click="navigateTo('personal-info')">
|
|
||||||
<div class="menu-icon">
|
|
||||||
<image src="@/static/notes/menu1.png" class="menu-icon-img"></image>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="menu-text">个人信息</div>
|
<div class="stat-item">
|
||||||
<view class="arrow-icon">
|
<div class="stat-num">10</div>
|
||||||
<u-icon name="arrow-right" color="#999" size="24"></u-icon>
|
<div class="stat-label">已完成</div>
|
||||||
</view>
|
|
||||||
</div>
|
|
||||||
<div class="menu-item" @click="navigateTo('change-password')">
|
|
||||||
<div class="menu-icon">
|
|
||||||
<image src="@/static/notes/menu2.png" class="menu-icon-img"></image>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="menu-text">修改密码</div>
|
<div class="stat-item">
|
||||||
<view class="arrow-icon">
|
<div class="stat-num">26</div>
|
||||||
<u-icon name="arrow-right" color="#999" size="24"></u-icon>
|
<div class="stat-label">未回复</div>
|
||||||
</view>
|
|
||||||
</div>
|
|
||||||
<div class="menu-item" @click="navigateTo('logout-records')">
|
|
||||||
<div class="menu-icon">
|
|
||||||
<image src="@/static/notes/menu3.png" class="menu-icon-img"></image>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="menu-text">退出登录</div>
|
|
||||||
<view class="arrow-icon">
|
|
||||||
<u-icon name="arrow-right" color="#999" size="24"></u-icon>
|
|
||||||
</view>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TabBar :currentPath="'/pages/my/index'" @change="handleTabChange" />
|
<div class="banner">
|
||||||
</div>
|
<img src="@/static/notes/banner.png" alt="banner" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="menu-list">
|
||||||
|
<div class="menu-item" @click="navigateTo('personal-info')">
|
||||||
|
<div class="menu-icon">
|
||||||
|
<image src="@/static/notes/menu1.png" class="menu-icon-img"></image>
|
||||||
|
</div>
|
||||||
|
<div class="menu-text">个人信息</div>
|
||||||
|
<view class="arrow-icon">
|
||||||
|
<u-icon name="arrow-right" color="#999" size="24"></u-icon>
|
||||||
|
</view>
|
||||||
|
</div>
|
||||||
|
<div class="menu-item" @click="navigateTo('change-password')">
|
||||||
|
<div class="menu-icon">
|
||||||
|
<image src="@/static/notes/menu2.png" class="menu-icon-img"></image>
|
||||||
|
</div>
|
||||||
|
<div class="menu-text">修改密码</div>
|
||||||
|
<view class="arrow-icon">
|
||||||
|
<u-icon name="arrow-right" color="#999" size="24"></u-icon>
|
||||||
|
</view>
|
||||||
|
</div>
|
||||||
|
<div class="menu-item" @click="navigateTo('logout-records')">
|
||||||
|
<div class="menu-icon">
|
||||||
|
<image src="@/static/notes/menu3.png" class="menu-icon-img"></image>
|
||||||
|
</div>
|
||||||
|
<div class="menu-text">退出登录</div>
|
||||||
|
<view class="arrow-icon">
|
||||||
|
<u-icon name="arrow-right" color="#999" size="24"></u-icon>
|
||||||
|
</view>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
<view class="page-tabbar">
|
||||||
|
<TabBar :currentPath="'/pages/my/index'" @change="handleTabChange" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import TabBar from "@/components/TabBar.vue";
|
import TabBar from "@/components/TabBar-optimized.vue";
|
||||||
import PageHeader from "@/components/PageHeader.vue";
|
import PageHeader from "@/components/PageHeader.vue";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|
@ -149,24 +159,69 @@ export default {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.my-page {
|
/* ===== 页面容器 - 主流三段式布局 ===== */
|
||||||
height: 100vh;
|
.page-container {
|
||||||
/* background-color: #f5f6fa; */
|
/* 固定定位,占满整个视口 */
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
|
||||||
|
/* Flex 布局 */
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
/* 背景图片(保留特色) */
|
||||||
background-image: url("@/static/notes/bg.png");
|
background-image: url("@/static/notes/bg.png");
|
||||||
background-position: center top;
|
background-position: center top;
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
background-size: 100% auto; /* 以宽度为基准等比例缩放 */
|
background-size: 100% auto;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
/* 防止溢出 */
|
||||||
position: relative;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-wrapper {
|
/* ===== 头部导航 ===== */
|
||||||
|
.page-header {
|
||||||
|
/* 不收缩,固定高度 */
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
/* 层级 */
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 内容区域 ===== */
|
||||||
|
.page-main {
|
||||||
|
/* 占据剩余空间 */
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
/* 重要:防止 flex 子元素溢出 */
|
||||||
padding-bottom: 60px; /* 为底部导航栏预留空间 */
|
height: 0;
|
||||||
overflow: hidden; /* 防止滚动条出现 */
|
|
||||||
|
/* 允许滚动 */
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
/* iOS 滚动优化 */
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 内容内层 ===== */
|
||||||
|
.main-content {
|
||||||
|
/* 底部留出 TabBar 空间 + 安全区域 */
|
||||||
|
padding-bottom: calc(50px + env(safe-area-inset-bottom));
|
||||||
|
|
||||||
|
/* 最小高度(确保可以滚动) */
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 底部导航 ===== */
|
||||||
|
.page-tabbar {
|
||||||
|
/* 不收缩,固定高度 */
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
/* 层级 */
|
||||||
|
z-index: 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-info {
|
.user-info {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,310 @@
|
||||||
|
<template>
|
||||||
|
<view class="page-container">
|
||||||
|
<view class="page-header">
|
||||||
|
<PageHeader title="人工转接" :is-back="false" :border-bottom="false" />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<scroll-view
|
||||||
|
class="page-main"
|
||||||
|
scroll-y
|
||||||
|
enable-back-to-top
|
||||||
|
>
|
||||||
|
<view class="main-content">
|
||||||
|
<!-- 服务状态卡片 -->
|
||||||
|
<view class="status-card">
|
||||||
|
<view class="status-title">人工服务</view>
|
||||||
|
<view class="status-content">
|
||||||
|
<view class="status-badge" :class="isOnline ? 'online' : 'offline'">
|
||||||
|
{{ isOnline ? '在线' : '离线' }}
|
||||||
|
</view>
|
||||||
|
<view class="status-desc">
|
||||||
|
{{ isOnline ? '客服正在为您服务' : '当前暂无客服在线' }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 人工客服列表 -->
|
||||||
|
<view class="section-title">在线客服</view>
|
||||||
|
<view class="agent-list">
|
||||||
|
<view class="agent-item" v-for="(agent, index) in agentList" :key="index" @click="handleTransfer(agent)">
|
||||||
|
<view class="agent-avatar-wrapper">
|
||||||
|
<image class="agent-avatar" :src="agent.avatar || defaultAvatar"></image>
|
||||||
|
<view class="online-dot" v-if="agent.online"></view>
|
||||||
|
</view>
|
||||||
|
<view class="agent-info">
|
||||||
|
<view class="agent-name">{{ agent.name }}</view>
|
||||||
|
<view class="agent-status">{{ agent.statusText }}</view>
|
||||||
|
</view>
|
||||||
|
<view class="agent-action">
|
||||||
|
<u-button
|
||||||
|
size="mini"
|
||||||
|
type="primary"
|
||||||
|
:disabled="!agent.online"
|
||||||
|
@click.stop="handleTransfer(agent)"
|
||||||
|
>
|
||||||
|
{{ agent.online ? '转接' : '离线' }}
|
||||||
|
</u-button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 无数据提示 -->
|
||||||
|
<view class="empty-tip" v-if="agentList.length === 0">
|
||||||
|
暂无客服在线
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
<view class="page-tabbar">
|
||||||
|
<TabBar :currentPath="'/pages/manual-transfer/index'" @change="handleTabChange" />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<u-modal
|
||||||
|
v-model="showModal"
|
||||||
|
:show-cancel-button="true"
|
||||||
|
title="人工转接"
|
||||||
|
:content="modalContent"
|
||||||
|
@confirm="confirmTransfer"
|
||||||
|
@cancel="showModal = false"
|
||||||
|
></u-modal>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import TabBar from "@/components/TabBar-optimized.vue";
|
||||||
|
import PageHeader from "@/components/PageHeader.vue";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "TransferPage",
|
||||||
|
components: {
|
||||||
|
TabBar,
|
||||||
|
PageHeader,
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
showModal: false,
|
||||||
|
modalContent: '',
|
||||||
|
selectedAgent: null,
|
||||||
|
defaultAvatar: "/static/avatar/default-avatar.png",
|
||||||
|
isOnline: true,
|
||||||
|
agentList: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "客服小王",
|
||||||
|
avatar: "",
|
||||||
|
online: true,
|
||||||
|
statusText: "空闲中",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: "客服小李",
|
||||||
|
avatar: "",
|
||||||
|
online: true,
|
||||||
|
statusText: "忙碌中",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: "客服小张",
|
||||||
|
avatar: "",
|
||||||
|
online: false,
|
||||||
|
statusText: "离线",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
handleTabChange(path, index) {
|
||||||
|
console.log("切换到标签页:", path, index);
|
||||||
|
},
|
||||||
|
handleTransfer(agent) {
|
||||||
|
if (!agent.online) {
|
||||||
|
uni.showToast({
|
||||||
|
title: '该客服当前离线',
|
||||||
|
icon: 'none'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.selectedAgent = agent;
|
||||||
|
this.modalContent = `确认转接到${agent.name}吗?`;
|
||||||
|
this.showModal = true;
|
||||||
|
},
|
||||||
|
confirmTransfer() {
|
||||||
|
// 这里可以执行实际的转接逻辑
|
||||||
|
uni.showToast({
|
||||||
|
title: '正在为您转接...',
|
||||||
|
icon: 'none'
|
||||||
|
});
|
||||||
|
this.showModal = false;
|
||||||
|
|
||||||
|
// 模拟跳转到聊天页面
|
||||||
|
setTimeout(() => {
|
||||||
|
// uni.navigateTo({
|
||||||
|
// url: '/pages/message/dialogBox/dialogBox?agentId=' + this.selectedAgent.id
|
||||||
|
// });
|
||||||
|
}, 1000);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ===== 页面容器 - 主流三段式布局 ===== */
|
||||||
|
.page-container {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: #f5f6fa;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 头部导航 ===== */
|
||||||
|
.page-header {
|
||||||
|
flex-shrink: 0;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 内容区域 ===== */
|
||||||
|
.page-main {
|
||||||
|
flex: 1;
|
||||||
|
height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 内容内层 ===== */
|
||||||
|
.main-content {
|
||||||
|
padding: 10px;
|
||||||
|
padding-bottom: calc(50px + env(safe-area-inset-bottom) + 10px);
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 底部导航 ===== */
|
||||||
|
.page-tabbar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-card {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #fff;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.online {
|
||||||
|
background-color: #52c41a;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.offline {
|
||||||
|
background-color: #999;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
margin: 15px 0 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-list {
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 15px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-avatar-wrapper {
|
||||||
|
position: relative;
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-avatar {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-dot {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 2px;
|
||||||
|
right: 2px;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
background-color: #52c41a;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-info {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-name {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-status {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-action {
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-tip {
|
||||||
|
text-align: center;
|
||||||
|
padding: 50px 20px;
|
||||||
|
color: #999;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.5 KiB |
|
|
@ -0,0 +1,353 @@
|
||||||
|
/**
|
||||||
|
* WebSocket 管理器
|
||||||
|
*
|
||||||
|
* 功能:
|
||||||
|
* 1. 自动重连
|
||||||
|
* 2. 心跳检测
|
||||||
|
* 3. 消息队列
|
||||||
|
* 4. 事件监听
|
||||||
|
*
|
||||||
|
* 使用示例:
|
||||||
|
* import wsManager from '@/utils/websocket-manager.js'
|
||||||
|
*
|
||||||
|
* // 连接
|
||||||
|
* wsManager.connect('wss://example.com/chat')
|
||||||
|
*
|
||||||
|
* // 监听消息
|
||||||
|
* wsManager.on('message', (data) => {
|
||||||
|
* console.log('收到消息:', data)
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* // 发送消息
|
||||||
|
* wsManager.send({ type: 'text', content: 'Hello' })
|
||||||
|
*/
|
||||||
|
|
||||||
|
class WebSocketManager {
|
||||||
|
constructor() {
|
||||||
|
// WebSocket 实例
|
||||||
|
this.socketTask = null
|
||||||
|
|
||||||
|
// 连接状态
|
||||||
|
this.isConnected = false
|
||||||
|
this.isConnecting = false
|
||||||
|
|
||||||
|
// 配置
|
||||||
|
this.url = ''
|
||||||
|
this.reconnectAttempts = 0
|
||||||
|
this.maxReconnectAttempts = 5
|
||||||
|
this.reconnectInterval = 5000
|
||||||
|
this.heartbeatInterval = 30000
|
||||||
|
|
||||||
|
// 定时器
|
||||||
|
this.reconnectTimer = null
|
||||||
|
this.heartbeatTimer = null
|
||||||
|
|
||||||
|
// 消息队列(连接未建立时暂存)
|
||||||
|
this.messageQueue = []
|
||||||
|
|
||||||
|
// 事件监听器
|
||||||
|
this.listeners = {
|
||||||
|
message: [], // 收到消息
|
||||||
|
open: [], // 连接成功
|
||||||
|
close: [], // 连接关闭
|
||||||
|
error: [], // 连接错误
|
||||||
|
reconnect: [] // 重连中
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 连接 WebSocket
|
||||||
|
* @param {String} url WebSocket 服务器地址
|
||||||
|
* @param {Object} options 配置选项
|
||||||
|
*/
|
||||||
|
connect(url, options = {}) {
|
||||||
|
// 如果已经在连接中,直接返回
|
||||||
|
if (this.isConnecting || this.isConnected) {
|
||||||
|
console.log('[WebSocket] 已在连接中或已连接')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.url = url
|
||||||
|
this.isConnecting = true
|
||||||
|
|
||||||
|
// 合并配置
|
||||||
|
Object.assign(this, options)
|
||||||
|
|
||||||
|
console.log(`[WebSocket] 开始连接: ${url}`)
|
||||||
|
|
||||||
|
// 创建连接
|
||||||
|
this.socketTask = uni.connectSocket({
|
||||||
|
url: this.url,
|
||||||
|
success: () => {
|
||||||
|
console.log('[WebSocket] 连接请求已发送')
|
||||||
|
},
|
||||||
|
fail: (err) => {
|
||||||
|
console.error('[WebSocket] 连接失败:', err)
|
||||||
|
this.isConnecting = false
|
||||||
|
this.handleError(err)
|
||||||
|
this.handleReconnect()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听连接打开
|
||||||
|
this.socketTask.onOpen(() => {
|
||||||
|
console.log('[WebSocket] 连接成功')
|
||||||
|
this.isConnected = true
|
||||||
|
this.isConnecting = false
|
||||||
|
this.reconnectAttempts = 0
|
||||||
|
|
||||||
|
// 触发 open 事件
|
||||||
|
this.emit('open')
|
||||||
|
|
||||||
|
// 启动心跳
|
||||||
|
this.startHeartbeat()
|
||||||
|
|
||||||
|
// 发送队列中的消息
|
||||||
|
this.flushMessageQueue()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听消息
|
||||||
|
this.socketTask.onMessage((res) => {
|
||||||
|
console.log('[WebSocket] 收到消息:', res.data)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(res.data)
|
||||||
|
|
||||||
|
// 处理 pong(心跳响应)
|
||||||
|
if (data.type === 'pong') {
|
||||||
|
console.log('[WebSocket] 收到心跳响应')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 触发 message 事件
|
||||||
|
this.emit('message', data)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[WebSocket] 消息解析失败:', e)
|
||||||
|
// 如果不是 JSON,直接传递原始数据
|
||||||
|
this.emit('message', res.data)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听连接关闭
|
||||||
|
this.socketTask.onClose((res) => {
|
||||||
|
console.log('[WebSocket] 连接已关闭:', res)
|
||||||
|
this.isConnected = false
|
||||||
|
this.isConnecting = false
|
||||||
|
|
||||||
|
// 停止心跳
|
||||||
|
this.stopHeartbeat()
|
||||||
|
|
||||||
|
// 触发 close 事件
|
||||||
|
this.emit('close', res)
|
||||||
|
|
||||||
|
// 尝试重连
|
||||||
|
this.handleReconnect()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听错误
|
||||||
|
this.socketTask.onError((err) => {
|
||||||
|
console.error('[WebSocket] 连接错误:', err)
|
||||||
|
this.isConnected = false
|
||||||
|
this.isConnecting = false
|
||||||
|
|
||||||
|
// 触发 error 事件
|
||||||
|
this.handleError(err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送消息
|
||||||
|
* @param {Object|String} data 要发送的数据
|
||||||
|
*/
|
||||||
|
send(data) {
|
||||||
|
// 如果未连接,加入队列
|
||||||
|
if (!this.isConnected) {
|
||||||
|
console.warn('[WebSocket] 未连接,消息加入队列')
|
||||||
|
this.messageQueue.push(data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = typeof data === 'string' ? data : JSON.stringify(data)
|
||||||
|
|
||||||
|
this.socketTask.send({
|
||||||
|
data: message,
|
||||||
|
success: () => {
|
||||||
|
console.log('[WebSocket] 消息发送成功:', data)
|
||||||
|
},
|
||||||
|
fail: (err) => {
|
||||||
|
console.error('[WebSocket] 消息发送失败:', err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭连接
|
||||||
|
* @param {Boolean} manual 是否手动关闭(手动关闭不重连)
|
||||||
|
*/
|
||||||
|
close(manual = true) {
|
||||||
|
console.log(`[WebSocket] 关闭连接 (手动: ${manual})`)
|
||||||
|
|
||||||
|
// 如果是手动关闭,清除重连定时器
|
||||||
|
if (manual) {
|
||||||
|
this.clearReconnectTimer()
|
||||||
|
this.reconnectAttempts = this.maxReconnectAttempts // 阻止重连
|
||||||
|
}
|
||||||
|
|
||||||
|
this.stopHeartbeat()
|
||||||
|
|
||||||
|
if (this.socketTask) {
|
||||||
|
this.socketTask.close({
|
||||||
|
code: 1000,
|
||||||
|
reason: manual ? '手动关闭' : '自动关闭'
|
||||||
|
})
|
||||||
|
this.socketTask = null
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isConnected = false
|
||||||
|
this.isConnecting = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动心跳
|
||||||
|
*/
|
||||||
|
startHeartbeat() {
|
||||||
|
console.log('[WebSocket] 启动心跳')
|
||||||
|
this.stopHeartbeat()
|
||||||
|
|
||||||
|
this.heartbeatTimer = setInterval(() => {
|
||||||
|
if (this.isConnected) {
|
||||||
|
console.log('[WebSocket] 发送心跳')
|
||||||
|
this.send({ type: 'ping', timestamp: Date.now() })
|
||||||
|
}
|
||||||
|
}, this.heartbeatInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止心跳
|
||||||
|
*/
|
||||||
|
stopHeartbeat() {
|
||||||
|
if (this.heartbeatTimer) {
|
||||||
|
console.log('[WebSocket] 停止心跳')
|
||||||
|
clearInterval(this.heartbeatTimer)
|
||||||
|
this.heartbeatTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理重连
|
||||||
|
*/
|
||||||
|
handleReconnect() {
|
||||||
|
// 如果达到最大重连次数,停止重连
|
||||||
|
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||||
|
console.error('[WebSocket] 达到最大重连次数,停止重连')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果已经在重连中,直接返回
|
||||||
|
if (this.reconnectTimer) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.reconnectAttempts++
|
||||||
|
console.log(`[WebSocket] 准备重连 (${this.reconnectAttempts}/${this.maxReconnectAttempts})`)
|
||||||
|
|
||||||
|
// 触发 reconnect 事件
|
||||||
|
this.emit('reconnect', this.reconnectAttempts)
|
||||||
|
|
||||||
|
this.reconnectTimer = setTimeout(() => {
|
||||||
|
this.reconnectTimer = null
|
||||||
|
this.connect(this.url)
|
||||||
|
}, this.reconnectInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除重连定时器
|
||||||
|
*/
|
||||||
|
clearReconnectTimer() {
|
||||||
|
if (this.reconnectTimer) {
|
||||||
|
clearTimeout(this.reconnectTimer)
|
||||||
|
this.reconnectTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送队列中的消息
|
||||||
|
*/
|
||||||
|
flushMessageQueue() {
|
||||||
|
if (this.messageQueue.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[WebSocket] 发送队列中的 ${this.messageQueue.length} 条消息`)
|
||||||
|
|
||||||
|
while (this.messageQueue.length > 0) {
|
||||||
|
const message = this.messageQueue.shift()
|
||||||
|
this.send(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理错误
|
||||||
|
*/
|
||||||
|
handleError(err) {
|
||||||
|
this.emit('error', err)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 监听事件
|
||||||
|
* @param {String} event 事件名称
|
||||||
|
* @param {Function} callback 回调函数
|
||||||
|
*/
|
||||||
|
on(event, callback) {
|
||||||
|
if (this.listeners[event]) {
|
||||||
|
this.listeners[event].push(callback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除事件监听
|
||||||
|
* @param {String} event 事件名称
|
||||||
|
* @param {Function} callback 回调函数
|
||||||
|
*/
|
||||||
|
off(event, callback) {
|
||||||
|
if (this.listeners[event]) {
|
||||||
|
const index = this.listeners[event].indexOf(callback)
|
||||||
|
if (index > -1) {
|
||||||
|
this.listeners[event].splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 触发事件
|
||||||
|
* @param {String} event 事件名称
|
||||||
|
* @param {*} data 数据
|
||||||
|
*/
|
||||||
|
emit(event, data) {
|
||||||
|
if (this.listeners[event]) {
|
||||||
|
this.listeners[event].forEach(callback => {
|
||||||
|
try {
|
||||||
|
callback(data)
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[WebSocket] 事件 ${event} 回调执行失败:`, e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取连接状态
|
||||||
|
*/
|
||||||
|
getState() {
|
||||||
|
return {
|
||||||
|
isConnected: this.isConnected,
|
||||||
|
isConnecting: this.isConnecting,
|
||||||
|
reconnectAttempts: this.reconnectAttempts,
|
||||||
|
queueLength: this.messageQueue.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出单例
|
||||||
|
export default new WebSocketManager()
|
||||||
|
|
||||||
Loading…
Reference in New Issue