84创建群聊功能(一)
数据库设计和迁移
创建数据迁移表
npx sequelize migration:generate --name=group
1.执行完命令后,会在database / migrations / 目录下生成数据表迁移文件,然后定义
'use strict'; module.exports = { up: async (queryInterface, Sequelize) => { const { INTEGER, STRING, DATE, ENUM, TEXT } = Sequelize; // 创建表 await queryInterface.createTable('group', { id: { type: INTEGER(20).UNSIGNED, primaryKey: true, autoIncrement: true }, name: { type: STRING(30), allowNull: false, defaultValue: '', comment: '群组名称', }, avatar: { type: STRING(200), allowNull: true, defaultValue: '' }, user_id: { type: INTEGER(20).UNSIGNED, allowNull: false, comment: '群主id', // 定义外键(重要) references: { model: 'user', // 对应表名称(数据表名称) key: 'id' // 对应表的主键 }, onUpdate: 'restrict', // 更新时操作 onDelete: 'cascade' // 删除时操作 }, remark: { type: TEXT, allowNull: true, defaultValue: '', comment: '群公告' }, invite_confirm: { type: INTEGER(1), allowNull: false, defaultValue: 1, comment: '邀请确认' }, status: { type: INTEGER(1), allowNull: false, defaultValue: 1, comment: '状态' }, created_at: DATE, updated_at: DATE }); }, down: async queryInterface => { await queryInterface.dropTable('group'); } };
创建数据迁移表
npx sequelize migration:generate --name=group_user
1.执行完命令后,会在database / migrations / 目录下生成数据表迁移文件,然后定义
'use strict'; module.exports = { up: async (queryInterface, Sequelize) => { const { INTEGER, DATE, STRING } = Sequelize; // 创建表 await queryInterface.createTable('group_user', { id: { type: INTEGER(20).UNSIGNED, primaryKey: true, autoIncrement: true }, user_id: { type: INTEGER(20).UNSIGNED, allowNull: false, comment: '用户id', // 定义外键(重要) references: { model: 'user', // 对应表名称(数据表名称) key: 'id' // 对应表的主键 }, onUpdate: 'restrict', // 更新时操作 onDelete: 'cascade' // 删除时操作 }, group_id: { type: INTEGER(20).UNSIGNED, allowNull: false, comment: '群组id', // 定义外键(重要) references: { model: 'group', // 对应表名称(数据表名称) key: 'id' // 对应表的主键 }, onUpdate: 'restrict', // 更新时操作 onDelete: 'cascade' // 删除时操作 }, nickname: { type: STRING(30), allowNull: false, defaultValue: '', comment: '在群里的昵称', }, created_at: DATE, updated_at: DATE }); }, down: async queryInterface => { await queryInterface.dropTable('group_user'); } };
执行 migrate 进行数据库变更
npx sequelize db:migrate
模型创建
// app/model/group.js 'use strict'; const crypto = require('crypto'); module.exports = app => { const { STRING, INTEGER, DATE, ENUM, TEXT } = app.Sequelize; // 配置(重要:一定要配置详细,一定要!!!) const Group = app.model.define('group', { id: { type: INTEGER(20).UNSIGNED, primaryKey: true, autoIncrement: true }, name: { type: STRING(30), allowNull: false, defaultValue: '', comment: '群组名称', }, avatar: { type: STRING(200), allowNull: true, defaultValue: '' }, user_id: { type: INTEGER(20).UNSIGNED, allowNull: false, comment: '群主id', // 定义外键(重要) references: { model: 'user', // 对应表名称(数据表名称) key: 'id' // 对应表的主键 }, onUpdate: 'restrict', // 更新时操作 onDelete: 'cascade' // 删除时操作 }, remark: { type: TEXT, allowNull: true, defaultValue: '', comment: '群公告' }, invite_confirm: { type: INTEGER(1), allowNull: false, defaultValue: 1, comment: '邀请确认' }, status: { type: INTEGER(1), allowNull: false, defaultValue: 1, comment: '状态' }, created_at: DATE, updated_at: DATE }); // 定义关联关系 Group.associate = function (model) { // 一对多 Group.hasMany(app.model.GroupUser); } return Group; }; // app/model/group_user.js 'use strict'; const crypto = require('crypto'); module.exports = app => { const { STRING, INTEGER, DATE, ENUM, TEXT } = app.Sequelize; // 配置(重要:一定要配置详细,一定要!!!) const GroupUser = app.model.define('group_user', { id: { type: INTEGER(20).UNSIGNED, primaryKey: true, autoIncrement: true }, user_id: { type: INTEGER(20).UNSIGNED, allowNull: false, comment: '用户id', // 定义外键(重要) references: { model: 'user', // 对应表名称(数据表名称) key: 'id' // 对应表的主键 }, onUpdate: 'restrict', // 更新时操作 onDelete: 'cascade' // 删除时操作 }, group_id: { type: INTEGER(20).UNSIGNED, allowNull: false, comment: '群组id', // 定义外键(重要) references: { model: 'group', // 对应表名称(数据表名称) key: 'id' // 对应表的主键 }, onUpdate: 'restrict', // 更新时操作 onDelete: 'cascade' // 删除时操作 }, nickname: { type: STRING(30), allowNull: false, defaultValue: '', comment: '在群里的昵称', }, created_at: DATE, updated_at: DATE }); return GroupUser; };
app/controller/group.js
'use strict'; const Controller = require('egg').Controller; class GroupController extends Controller { // 创建群聊 async create() { const { ctx,app } = this; // 拿到当前用户id let current_user_id = ctx.authUser.id; } } module.exports = GroupController;
85创建群聊功能(二)
pages/mail/mail/mail.vue
<template> <view> <!-- 导航栏 --> <free-nav-bar title="选择" showBack :showRight="true"> <free-main-button :name="buttonText" slot="right" @click="submit"></free-main-button> </free-nav-bar> <!-- 通讯录列表 --> <scroll-view scroll-y="true" :style="'height:'+scrollHeight+'px;'" :scroll-into-view="scrollInto"> <template v-if="type === 'see'"> <free-list-item v-for="(item,index) in typeList" :key="item.key" :title="item.name" :showRightIcon="false" showRight @click="typeIndex = index"> <view slot="right" style="width: 40rpx;height: 40rpx;" class="border rounded-circle flex align-center justify-center mr-4"> <view v-if="typeIndex === index" style="width: 30rpx;height: 30rpx;" class="main-bg-color rounded-circle"></view> </view> </free-list-item> </template> <template v-if="type !== 'see' || (type === 'see' && (typeIndex === 1 || typeIndex === 2)) "> <view v-for="(item,index) in list" :key="index" :id="'item-'+item.title"> <view v-if="item.list.length" class="py-2 px-3 border-bottom bg-light"> <text class="font-md text-dark">{{item.title}}</text> </view> <free-list-item v-for="(item2,index2) in item.list" :key="index2" :title="item2.name" :cover="item2.avatar || '/static/images/userpic.png'" :showRightIcon="false" showRight @click="selectItem(item2)"> <view slot="right" style="width: 40rpx;height: 40rpx;" class="border rounded-circle flex align-center justify-center mr-4"> <view v-if="item2.checked" style="width: 30rpx;height: 30rpx;" class="main-bg-color rounded-circle"></view> </view> </free-list-item> </view> </template> </scroll-view> <!-- 侧边导航条 --> <view class="position-fixed right-0 bottom-0 bg-light flex flex-column" :style="'top:'+top+'px;'" style="width: 50rpx;" @touchstart="touchstart" @touchmove="touchmove" @touchend="touchend"> <view class="flex-1 flex align-center justify-center" v-for="(item,index) in list" :key="index"> <text class="font-sm text-muted">{{item.title}}</text> </view> </view> <view class="position-fixed rounded-circle bg-light border flex align-center justify-center" v-if="current" style="width: 150rpx;height: 150rpx;left: 300rpx;" :style="'top:'+modalTop+'px;'"> <text class="font-lg">{{current}}</text> </view> </view> </template> <script> import freeNavBar from "@/components/free-ui/free-nav-bar.vue" import freeListItem from "@/components/free-ui/free-list-item.vue" import freeMainButton from '@/components/free-ui/free-main-button.vue'; import { mapState } from 'vuex' import $H from '@/common/free-lib/request.js'; export default { components: { freeNavBar, freeListItem, freeMainButton }, data() { return { typeIndex:0, typeList:[{ name:"公开", key:"all" },{ name:"谁可以看", key:"only" },{ name:"不给谁看", key:"except" },{ name:"私密", key:"none" }], top:0, scrollHeight:0, scrollInto:'', current:'', selectList:[], type:"", limit:9, id:0 } }, onLoad(e) { let res = uni.getSystemInfoSync() this.top = res.statusBarHeight + uni.upx2px(90) this.scrollHeight = res.windowHeight - this.top if(e.type){ this.type = e.type } if(e.limit){ this.limit = parseInt(e.limit) } if(e.id){ this.id = e.id if(e.type === 'inviteGroup'){ this.limit = 1 } } this.$store.dispatch('getMailList') }, computed: { ...mapState({ list:state=>state.user.mailList }), buttonText(){ let text = '发送' if(this.type === 'createGroup'){ text = '创建群组' } return text + ' ('+this.selectCount+')' }, modalTop(){ return (this.scrollHeight - uni.upx2px(150)) / 2 }, // 每个索引的高度 itemHeight() { let count = this.list.length if(count < 1){ return 0 } return this.scrollHeight / count }, // 选中数量 selectCount(){ return this.selectList.length } }, methods: { touchstart(e){ this.changeScrollInto(e) }, touchmove(e){ this.changeScrollInto(e) }, touchend(e){ this.current = '' }, // 联动 changeScrollInto(e){ let Y = e.touches[0].pageY // #ifdef MP Y = Y - this.top // #endif let index = Math.floor(Y / this.itemHeight) let item = this.list[index] if(item){ this.scrollInto = 'item-'+item.title this.current = item.title } }, // 选中/取消选中 selectItem(item){ if(!item.checked && this.selectCount === this.limit){ // 选中|限制选中数量 return uni.showToast({ title: '最多选中 '+this.limit+' 个', icon: 'none' }); } item.checked = !item.checked if(item.checked){ // 选中 this.selectList.push(item) } else { // 取消选中 let index = this.selectList.findIndex(v=> v === item) if(index > -1){ this.selectList.splice(index,1) } } }, submit(){ if(this.type !== 'see' && this.selectCount === 0){ return uni.showToast({ title: '请先选择', icon: 'none' }); } switch (this.type){ case 'createGroup': // 创建群组 $H.post('/group/create',{ ids:this.selectList.map(item=>item.user_id) }).then(res=>{ uni.showToast({ title: '创建群聊成功', icon: 'none' }); uni.navigateBack({ delta: 1 }); }) break; case 'sendCard': let item = this.selectList[0] uni.$emit('sendItem',{ sendType:"card", data:item.name, type:"card", options:{ avatar:item.avatar, id:item.user_id } }) uni.navigateBack({ delta: 1 }); break; case 'remind': uni.$emit('sendResult',{ type:"remind", data:this.selectList }) uni.navigateBack({ delta: 1 }); break; case 'see': let k = this.typeList[this.typeIndex].key if(k !== 'all' && k!== 'none' && !this.selectCount){ return uni.showToast({ title: '请先选择', icon: 'none' }); } uni.$emit('sendResult',{ type:"see", data:{ k, v:this.selectList } }) uni.navigateBack({ delta: 1 }); break; case 'inviteGroup': console.log(this.selectList); $H.post('/group/invite',{ id:this.id, user_id:this.selectList[0].user_id }).then(res=>{ uni.showToast({ title: '邀请成功', icon: 'none' }); uni.navigateBack({ delta: 1 }); }) break; } } } } </script> <style> </style>
app/controller/group.js
'use strict'; const Controller = require('egg').Controller; class GroupController extends Controller { // 创建群聊 async create() { const { ctx,app } = this; // 拿到当前用户id let current_user_id = ctx.authUser.id; // 验证参数 ctx.validate({ ids:{ require:true, type:'array' } }) } } module.exports = GroupController;
86创建群聊功能(三)
app/controller/group.js
'use strict'; const Controller = require('egg').Controller; class GroupController extends Controller { // 创建群聊 async create() { const { ctx,app } = this; // 拿到当前用户id let current_user_id = ctx.authUser.id; // 验证参数 ctx.validate({ ids:{ require:true, type:'array' } }); let { ids } = ctx.request.body; // 验证是否是我的好友 let friends = await app.model.Friend.findAll({ where:{ user_id:current_user_id, friend_id:ids }, include:[{ model:app.model.User, as:'friendInfo', attributes:['nickname','username'] }] }); if (!friends.length) { return ctx.apiFail('请选择需要加入群聊的好友'); } // 创建群聊 let name = friends.map(item=>item.friendInfo.nickname || item.friendInfo.username); name.push(ctx.authUser.nickname || ctx.authUser.username); // 将自己的数据加入 await app.model.Group.create({ name:name.join(','), avatar:'', user_id:current_user_id }); // 加入群聊用户 let data = friends.map(item=>{ return {user_id:item.user_id,group_id:group.id} }); data.unshift({ user_id:current_user_id, group_id:group_id }); await app.model.GroupUser.bulkCreate(data); // 消息推送 ctx.apiSuccess('ok'); } } module.exports = GroupController;
/pages/mail/mail/mail.vue
'use strict'; const Controller = require('egg').Controller; class GroupController extends Controller { // 创建群聊 async create() { const { ctx,app } = this; // 拿到当前用户id let current_user_id = ctx.authUser.id; // 验证参数 ctx.validate({ ids:{ require:true, type:'array' } }); let { ids } = ctx.request.body; // 验证是否是我的好友 let friends = await app.model.Friend.findAll({ where:{ user_id:current_user_id, friend_id:ids }, include:[{ model:app.model.User, as:'friendInfo', attributes:['nickname','username'] }] }); if (!friends.length) { return ctx.apiFail('请选择需要加入群聊的好友'); } // 创建群聊 let name = friends.map(item=>item.friendInfo.nickname || item.friendInfo.username); name.push(ctx.authUser.nickname || ctx.authUser.username); // 将自己的数据加入 await app.model.Group.create({ name:name.join(','), avatar:'', user_id:current_user_id }); // 加入群聊用户 let data = friends.map(item=>{ return {user_id:item.user_id,group_id:group.id} }); data.unshift({ user_id:current_user_id, group_id:group_id }); await app.model.GroupUser.bulkCreate(data); // 消息推送 ctx.apiSuccess('ok'); } } module.exports = GroupController;
87创建群聊功能(四)
chat.js
import $U from "./util.js"; import $H from './request.js'; class chat { constructor(arg) { this.url = arg.url this.isOnline = false this.socket = null // 获取当前用户相关信息 let user = $U.getStorage('user'); this.user = user ? JSON.parse(user) : {}, // 初始化聊天对象 this.TO = false; // 连接和监听 if (this.user.token) { this.connectSocket() } } // 连接socket connectSocket() { console.log(this.user); this.socket = uni.connectSocket({ url: this.url + '?token=' + this.user.token, complete: () => {} }) // 监听连接成功 this.socket.onOpen(() => this.onOpen()) // 监听接收信息 this.socket.onMessage((res) => this.onMessage(res)) // 监听断开 this.socket.onClose(() => this.onClose()) // 监听错误 this.socket.onError(() => this.onError()) } // 监听打开 onOpen() { // 用户状态上线 this.isOnline = true; console.log('socket连接成功'); // 获取用户离线消息 } // 监听关闭 onClose() { // 用户下线 this.isOnline = false; this.socket = null; console.log('socket连接关闭'); } // 监听消息 onMessage(data) { console.log('监听消息', data); let res = JSON.parse(data.data) // console.log('监听接收消息',res) // 错误 switch (res.msg) { case 'fail': return uni.showToast({ title: res.data, icon: 'none' }); break; case 'recall': // 撤回消息 this.handleOnRecall(res.data) break; case 'updateApplyList': // 新的好友申请 $store.dispatch('getApply'); break; case 'moment': // 朋友圈更新 this.handleMoment(res.data) break; default: // 处理消息 this.handleOnMessage(res.data) break; } } // 处理消息 async handleOnMessage(message) { // 添加消息记录到本地存储中 let { data } = this.addChatDetail(message, false) // 更新会话列表 this.updateChatList(data, false) // 全局通知 uni.$emit('onMessage', data) // 消息提示 // this.messageNotice() } // 监听连接错误 onError() { // 用户下线 this.isOnline = false; this.socket = null; console.log('socket连接错误'); } // 关闭连接 close() { this.socket.close() } // 创建聊天对象 createChatObject(detail) { this.TO = detail; console.log('创建聊天对象', this.TO) } // 销毁聊天对象 destoryChatObject() { this.TO = false } // 组织发送信息格式 formatSendData(params) { return { id: 0, // 唯一id,后端生成,用于撤回指定消息 from_avatar: this.user.avatar, // 发送者头像 from_name: this.user.nickname || this.user.username, // 发送者昵称 from_id: this.user.id, // 发送者id to_id: params.to_id || this.TO.id, // 接收人/群 id to_name: params.to_name || this.TO.name, // 接收人/群 名称 to_avatar: params.to_avatar || this.TO.avatar, // 接收人/群 头像 chat_type: params.chat_type || this.TO.chat_type, // 接收类型 type: params.type, // 消息类型 data: params.data, // 消息内容 options: params.options ? params.options : {}, // 其他参数 create_time: (new Date()).getTime(), // 创建时间 isremove: 0, // 是否撤回 sendStatus: params.sendStatus ? params.sendStatus : "pending" // 发送状态,success发送成功,fail发送失败,pending发送中 } } // 发送信息 send(message, onProgress = false) { return new Promise((result, reject) => { // 添加消息历史记录 // this.addChatDetail(); let { k } = this.addChatDetail(message); // 更新会话列表 this.updateChatList(message); // 验证是否上线 if (!this.checkOnLine()) return reject('未上线'); // 上传文件 let isUpload = (message.type !== 'text' && message.type !== 'emoticon' && message.type !== 'card' && !message.data.startsWith('http://tangzhe123-com')) let uploadResult = '' if (isUpload) { uploadResult = $H.upload('/upload', { filePath: message.data }, onProgress) if (!uploadResult) { // 发送失败 message.sendStatus = 'fail' // 更新指定历史记录 this.updateChatDetail(message, k) // 断线重连提示 return reject(err) } } $H.post('/chat/send', { to_id: this.TO.id, type: message.type, chat_type: this.TO.chat_type, data: message.data, }).then(res => { // 发送成功 console.log('chat.js发送成功'); message.id = res.id message.sendStatus = 'success'; // 更新指定历史记录 this.updateChatDetail(message, k); result(res); }).catch(err => { // 发送失败 console.log('chat.js发送失败'); message.sendStatus = 'fail'; // 更新指定历史记录 this.updateChatDetail(message, k); // 断线重连提示 result(err); }); }) } // 验证是否上线 checkOnLine() { if (!this.isOnline) { // 断线重连提示 this.reconnectConfirm(); return false; } return true; } // 断线重连提示 reconnectConfirm() { uni.showModal({ title: '你已经断线,是否重新连接?', content: '重新连接', success: res => { if (res.confirm) { this.connectSocket(); } }, }); } // 添加聊天记录 addChatDetail(message, isSend = true) { console.log('添加到聊天记录'); // 获取对方id // let id = isSend ? message.to_id : message.from_id; let id = message.chat_type === 'user' ? (isSend ? message.to_id : message.from_id) : message.to_id; if (!id) { return { data: {}, k: 0 } } // key值:chatDetail_当前用户id_会话类型_接收人/群id let key = `chatDetail_${this.user.id}_${message.chat_type}_${id}`; console.log(key); // 获取原来的聊天记录 let list = this.getChatdetail(key) console.log('获取原来的聊天记录', list); // 标识 message.k = 'k' + list.length list.push(message) // 加入存储 console.log('加入存储', message); this.setStorage(key, list); // 返回 return { data: message, k: message.k } } // 更新指定历史记录 async updateChatDetail(message, k, isSend = true) { // 获取对方id let id = isSend ? message.to_id : message.from_id // key值:chatDetail_当前用户id_会话类型_接收人/群id let key = `chatDetail_${this.user.id}_${message.chat_type}_${id}`; // 获取原来的聊天记录 let list = this.getChatdetail(key); // 根据k查找对应聊天记录 let index = list.findIndex(item => item.k === k); if (index === -1) return; list[index] = message; // 存储 this.setStorage(key, list); } // 获取聊天记录 getChatdetail(key = false) { key = key ? key : `chatDetail_${this.user.id}_${this.TO.chat_type}_${this.TO.id}`; return this.getStorage(key); } // 格式化会话最后一条消息显示 formatChatItemData(message, isSend) { let data = message.data switch (message.type) { case 'emoticon': data = '[表情]' break; case 'image': data = '[图片]' break; case 'audio': data = '[语音]' break; case 'video': data = '[视频]' break; case 'card': data = '[名片]' break; } data = isSend ? data : `${message.from_name}: ${data}` return data } // 更新会话列表 updateChatList(message, isSend = true) { // 获取本地存储会话列表 let list = this.getChatList() // 是否处于当前聊天中 let isCurrentChat = false // 接收人/群 id/头像/昵称 let id = 0 let avatar = '' let name = '' // 判断私聊还是群聊 if (message.chat_type === 'user') { // 私聊 // 聊天对象是否存在 isCurrentChat = this.TO ? (isSend ? this.TO.id === message.to_id : this.TO.id === message.from_id) : false id = isSend ? message.to_id : message.from_id avatar = isSend ? message.to_avatar : message.from_avatar name = isSend ? message.to_name : message.from_name } else { // 群聊 isCurrentChat = this.TO && (this.TO.id === message.to_id) id = message.to_id avatar = message.to_avatar name = message.to_name } // 会话是否存在 let index = list.findIndex(item => { return item.chat_type === message.chat_type && item.id === id }) // 最后一条消息展现形式 // let data = isSend ? message.data : `${message.from_name}: ${message.data}` let data = this.formatChatItemData(message, isSend) // 会话不存在,创建会话 // 未读数是否 + 1 let noreadnum = (isSend || isCurrentChat) ? 0 : 1 if (index === -1) { let chatItem = { id, // 接收人/群 id chat_type: message.chat_type, // 接收类型 user单聊 group群聊 avatar, // 接收人/群 头像 name, // 接收人/群 昵称 update_time: (new Date()).getTime(), // 最后一条消息的时间戳 data, // 最后一条消息内容 type: message.type, // 最后一条消息类型 noreadnum, // 未读数 istop: false, // 是否置顶 shownickname: false, // 是否显示昵称 nowarn: false, // 消息免打扰 strongwarn: false, // 是否开启强提醒 } // 群聊 if (message.chat_type === 'group' && message.group) { chatItem.shownickname = true chatItem.name = message.to_name chatItem = { ...chatItem, user_id: message.group.user_id, // 群管理员id remark: "", // 群公告 invite_confirm: 1, // 邀请确认 } } list.unshift(chatItem) } else { // 存在,更新会话 // 拿到当前会话 let item = list[index] // 更新该会话最后一条消息时间,内容,类型 item.update_time = (new Date()).getTime() item.name = message.to_name item.data = data item.type = message.type // 未读数更新 item.noreadnum += noreadnum // 置顶会话 list = this.listToFirst(list, index) } // 存储 let key = `chatlist_${this.user.id}` this.setStorage(key, list) // 更新未读数 this.updateBadge(list) // 通知更新vuex中的聊天会话列表 uni.$emit('onUpdateChatList', list) return list } // 获取聊天记录 getChatList(message, isSend = true) { // 获取本地存储会话列表 let list = this.getChatList(); // 是否处在当前聊天中 let isCurrentChat = false // 接收人/群 id/头像/昵称 let id = 0; let avatar = ''; let name = ''; // 判断私聊还是群聊 if (message.chat_type === 'user') { // 私聊 isCurrentChat = this.TO ? (isSend ? this.TO.id === message.to_id : message.from_id) : false; id = isSend ? message.to_id : message.from_id; avatar = isSend ? message.to_avatar : message.from_avatar name = isSend ? message.to_name : message.from_name } else { // 群聊 } // 会话是否存在 let index = list.findIndex(item => { return item.chat_type === message.chat_type && item.id === id; }) // 最后一条消息展现形式 let data = isSend ? message.data : `${message.from_name}:${message.data}`; // 未读数是否 +1 let noreadnum = (isSend || isCurrentChat) ? 0 : 1; // 会话不存在 创建会话 if (index === -1) { let chatItem = { id, // 接收人/群 id chat_type: message.chat_type, // 接收类型 user 单聊 group群聊 name, // 接收人/群 昵称 avatar, // 接收人/群 头像 update_time: (new Date()).getTime(), // 最后发送的时间 data, // 最后一条消息的内容 type: message.type, noreadnum: 1, // 未读数 istop: false, // 是否置顶 shownickname: false, // 是否显示昵称 nowarn: false, // 是否免打扰 strongwarn: false, // 是否强提醒 } if (message.chat_type === 'group') { chatItem = { ...chatItem, user_id: 0, // 管理员id remark: '', // 群公告 invite_confirm: 0 // 邀请确认 } } list.unshift(chatItem) } else { // 存在,更新会话 // 拿到当前会话 let item = list[index] // 更新改会话最后一条消息时间,内容,类型 item.update_time = (new Date()).getTime(); item.data = data; item.type = message.type; // 未读数更新 item.noreadnum += noreadnum // 置顶会话 list = this.listToFirst(list, index); } // 存储 let key = `chatlist_${this.user.id}`; this.setStorage(key, list); // 更新未读数 this.updateBadge(list); // 更新vuex中的聊天会话列表 uni.$emit('onUpdateChatList', list); console.log('获取到的会话列表:',list) return list; /** * { id:1, // 接收人/群 id chat_type:'user', // 接收类型 user 单聊 group群聊 name:'昵称', // 接收人/群 昵称 avatar:"/static/images/demo/demo6.jpg", // 接收人/群 头像 type:'',// 最后一条消息类型 update_time:1628069958, // 最后发送的时间 data:"你好啊,哈哈哈", // 最后一条消息的内容 noreadnum:1, // 未读数 istop:false, // 是否置顶 shownickname:0, // 是否显示昵称 nowarn:0, // 是否免打扰 strongwarn:0, // 是否强提醒 user_id://管理员id, remark:'公告', // 群公告 invite_confirm:0, // 邀请确认 }, **/ } // 获取本地存储会话列表 getChatList() { let key = `chatlist_${this.user.id}`; return this.getStorage(key); } // 读取会话 async readChatItem(id,chat_type){ // 获取所有会话列表 let list = this.getChatList(); // 找到当前会话 let index = list.findIndex(item=>item.id === id && item.chat_type === chat_type); if(index !== -1){ list[index].noreadnum = 0; let key = `chatlist_${this.user.id}`; this.setStorage(key,list); // 重新获取未读数 this.updateBadge(); // 更新会话列表状态 uni.$emit('onUpdateChatList',list); } } // 获取指定会话 getChatListItem(id,chat_type){ // 获取所有会话列表 let list = this.getChatList(); // 找到当前会话 let index = list.findIndex(item=>item.id === id && item.chat_type === chat_type); if(index !== -1){ return list[index]; } return false; } // 更新未读数 async updateBadge(list = false) { // 获取所有会话列表 list = list ? list : this.getChatList() // 统计所有未读数 let total = 0 list.forEach(item => { total += item.noreadnum }) // 设置底部导航栏角标 if (total > 0) { uni.setTabBarBadge({ index: 0, text: total <= 99 ? total.toString() : '99+' }) } else { uni.removeTabBarBadge({ index: 0 }) } uni.$emit('totalNoreadnum', total) } // 获取存储 getStorage(key) { let list = $U.getStorage(key); return list ? JSON.parse(list) : []; } // 设置存储 setStorage(key, value) { return $U.setStorage(key, JSON.stringify(value)); } // 数组置顶 listToFirst(arr, index) { if (index != 0) { arr.unshift(arr.splice(index, 1)[0]); } return arr; } } export default chat
app/controller/chat.js
// app/controller/chat.js const Controller = require('egg').Controller; class ChatController extends Controller { // 连接socket async connect() { const { ctx, app, service } = this; if (!ctx.websocket) { ctx.throw(400, '非法访问'); } // console.log(`clients: ${app.ws.clients.size}`); // 监听接收消息和关闭socket ctx.websocket .on('message', msg => { // console.log('接收消息', msg); }) .on('close', (code, reason) => { // 用户下线 console.log('用户下线', code, reason); let user_id = ctx.websocket.user_id; // 移除redis中的用户上线记录 service.cache.remove('online_' + user_id); if (app.ws.user && app.ws.user[user_id]) { delete app.ws.user[user_id]; } }); } // 发送消息 async send(){ const {ctx,app,service} = this; // 用户id let current_user_id = ctx.authUser.id; // 验证参数 ctx.validate({ to_id:{ type:'int', require:true, desc:'接收人/群id' }, chat_type:{ type:'string', require:true, range:{in:['user','group']}, desc:'接收类型' }, type:{ type:'string', require:true, range:{in:['text','image','video','audio']}, desc:'消息类型' }, data:{ type:'string', require:true, desc:'消息内容' } }); // 获取参数 let {to_id,chat_type,type,data} = ctx.request.body; // 单聊 if(chat_type=='user'){ // 验证好友是否存在,并且对方没有把你拉黑 let Friend = await app.model.Friend.findOne({ where:{ user_id:to_id, friend_id:current_user_id, isblack:0 }, include:[{ model:app.model.User, as:'userInfo' },{ model:app.model.User, as:'friendInfo' }] }); if(!Friend){ return ctx.apiFail('对方不存在或者已把你拉黑'); } // 验证好友是否被禁用 if(!Friend.userInfo.status){ return ctx.apiFail('对方已经被禁用'); } // 构建消息格式 let from_name = Friend.friendInfo.nickname ? Friend.friendInfo.nickname : Friend.friendInfo.username; if(Friend.nickname){ from_name = Friend.nickname; } let message = { id:(new Date()).getTime(), // 唯一id,后端生成唯一id from_avatar:Friend.friendInfo.avatar,// 发送者头像 from_name,// 发送者昵称 from_id:current_user_id, // 发送者id to_id,// 接收人id to_name:Friend.userInfo.nickname ? Friend.userInfo.nickname : Friend.userInfo.username,// 接收人/群 名称 to_avatar:Friend.userInfo.avatar,// 接收人/群 头像 chat_type:'user', // 接收类型 type, // 消息类型 data, // 消息内容 options:{}, // 其他参数 create_time:(new Date()).getTime(),// 创建时间 isremove:0 // 是否撤回 } // // 拿到当前的socket // let socket = app.ws.user[to_id]; // // 验证对方是否在线,不在线记录到待接收消息队列中 在线:消息推送 存储到对方的聊天记录中 chatlog_对方用户id_user_当前用户id // if(!socket){ // service.cache.setList('getmessage_'+to_id,message); // }else{ // // 消息推送 // socket.send(JSON.stringify({ // msg:'ok', // data:message // })) // // 存到历史记录中 // service.cache.setList(`chatlog_${to_id}_${message.chat_type}_${current_user_id}`,message); // } ctx.sendAndSaveMessage(to_id,message); // 存储到自己的聊天记录中 service.cache.setList(`chatlog_${current_user_id}_${message.chat_type}_${to_id}`,message); // 返回成功 return ctx.apiSuccess(message); } // 验证 } } module.exports = ChatController;
/app/controller/group.js
'use strict'; const Controller = require('egg').Controller; class GroupController extends Controller { // 创建群聊 async create() { const { ctx,app } = this; // 拿到当前用户id let current_user_id = ctx.authUser.id; // 验证参数 ctx.validate({ ids:{ require:true, type:'array' } }); let { ids } = ctx.request.body; // 验证是否是我的好友 let friends = await app.model.Friend.findAll({ where:{ user_id:current_user_id, friend_id:ids }, include:[{ model:app.model.User, as:'friendInfo', attributes:['nickname','username'] }] }); if (!friends.length) { return ctx.apiFail('请选择需要加入群聊的好友'); } // 创建群聊 let name = friends.map(item=>item.friendInfo.nickname || item.friendInfo.username); name.push(ctx.authUser.nickname || ctx.authUser.username); // 将自己的数据加入 let group = await app.model.Group.create({ name:name.join(','), avatar:'', user_id:current_user_id }); // 加入群聊用户 let data = friends.map(item=>{ return {user_id:item.friend_id,group_id:group.id} }); data.unshift({ user_id:current_user_id, group_id:group.id }); await app.model.GroupUser.bulkCreate(data); // 消息推送 let message = { id:(new Date()).getTime(), // 唯一id,后端生成唯一id from_avatar:ctx.authUser.avatar,// 发送者头像 from_name:ctx.authUser.nickname || ctx.authUser.username,// 发送者昵称 from_id:current_user_id, // 发送者id to_id:group_id,// 接收人id to_name:group.name,// 接收人/群 名称 to_avatar:group.avatar,// 接收人/群 头像 chat_type:'group', // 接收类型 type:'system', // 消息类型 data:'创建群聊成功,可以聊天了', // 消息内容 options:{}, // 其他参数 create_time:(new Date()).getTime(),// 创建时间 isremove:0, // 是否撤回 group:group } data.forEach(item=>{ ctx.sendAndSaveMessage(item.user_id,message); }); ctx.apiSuccess('ok'); } } module.exports = GroupController;
/extend/context.js
// app/extend/context.js module.exports = { // 成功提示 apiSuccess(data = '', msg = 'ok', code = 200) { this.body = { msg, data }; this.status = code; }, // 失败提示 apiFail(data = '', msg = 'fail', code = 400) { this.body = { msg, data }; this.status = code; }, // 生成token getToken(value) { return this.app.jwt.sign(value, this.app.config.jwt.secret); }, // 验证token checkToken(token) { return this.app.jwt.verify(token, this.app.config.jwt.secret); }, // 发送或者存到消息队列中 sendAndSaveMessage(to_id,message){ const { app,service } = this; let current_user_id = this.authUser.id; // 拿到当前的socket let socket = app.ws.user[to_id]; // 验证对方是否在线,不在线记录到待接收消息队列中 在线:消息推送 存储到对方的聊天记录中 chatlog_对方用户id_user_当前用户id if(!socket){ service.cache.setList('getmessage_'+to_id,message); }else{ // 消息推送 socket.send(JSON.stringify({ msg:'ok', data:message })) // 存到历史记录中 service.cache.setList(`chatlog_${to_id}_${message.chat_type}_${current_user_id}`,message); } } };
88创建群聊功能(五)
chat.js
import $U from "./util.js"; import $H from './request.js'; class chat { constructor(arg) { this.url = arg.url this.isOnline = false this.socket = null // 获取当前用户相关信息 let user = $U.getStorage('user'); this.user = user ? JSON.parse(user) : {}, // 初始化聊天对象 this.TO = false; // 连接和监听 if (this.user.token) { this.connectSocket() } } // 连接socket connectSocket() { console.log(this.user); this.socket = uni.connectSocket({ url: this.url + '?token=' + this.user.token, complete: () => {} }) // 监听连接成功 this.socket.onOpen(() => this.onOpen()) // 监听接收信息 this.socket.onMessage((res) => this.onMessage(res)) // 监听断开 this.socket.onClose(() => this.onClose()) // 监听错误 this.socket.onError(() => this.onError()) } // 监听打开 onOpen() { // 用户状态上线 this.isOnline = true; console.log('socket连接成功'); // 获取用户离线消息 } // 监听关闭 onClose() { // 用户下线 this.isOnline = false; this.socket = null; console.log('socket连接关闭'); } // 监听消息 onMessage(data) { console.log('监听消息', data); let res = JSON.parse(data.data) // console.log('监听接收消息',res) // 错误 switch (res.msg) { case 'fail': return uni.showToast({ title: res.data, icon: 'none' }); break; case 'recall': // 撤回消息 this.handleOnRecall(res.data) break; case 'updateApplyList': // 新的好友申请 $store.dispatch('getApply'); break; case 'moment': // 朋友圈更新 this.handleMoment(res.data) break; default: // 处理消息 this.handleOnMessage(res.data) break; } } // 处理消息 async handleOnMessage(message) { // 添加消息记录到本地存储中 let { data } = this.addChatDetail(message, false) // 更新会话列表 this.updateChatList(data, false) // 全局通知 uni.$emit('onMessage', data) // 消息提示 // this.messageNotice() } // 监听连接错误 onError() { // 用户下线 this.isOnline = false; this.socket = null; console.log('socket连接错误'); } // 关闭连接 close() { this.socket.close() } // 创建聊天对象 createChatObject(detail) { this.TO = detail; console.log('创建聊天对象', this.TO) } // 销毁聊天对象 destoryChatObject() { this.TO = false } // 组织发送信息格式 formatSendData(params) { return { id: 0, // 唯一id,后端生成,用于撤回指定消息 from_avatar: this.user.avatar, // 发送者头像 from_name: this.user.nickname || this.user.username, // 发送者昵称 from_id: this.user.id, // 发送者id to_id: params.to_id || this.TO.id, // 接收人/群 id to_name: params.to_name || this.TO.name, // 接收人/群 名称 to_avatar: params.to_avatar || this.TO.avatar, // 接收人/群 头像 chat_type: params.chat_type || this.TO.chat_type, // 接收类型 type: params.type, // 消息类型 data: params.data, // 消息内容 options: params.options ? params.options : {}, // 其他参数 create_time: (new Date()).getTime(), // 创建时间 isremove: 0, // 是否撤回 sendStatus: params.sendStatus ? params.sendStatus : "pending" // 发送状态,success发送成功,fail发送失败,pending发送中 } } // 发送信息 send(message, onProgress = false) { return new Promise((result, reject) => { // 添加消息历史记录 // this.addChatDetail(); let { k } = this.addChatDetail(message); // 更新会话列表 this.updateChatList(message); // 验证是否上线 if (!this.checkOnLine()) return reject('未上线'); // 上传文件 let isUpload = (message.type !== 'text' && message.type !== 'emoticon' && message.type !== 'card' && !message.data.startsWith('http://tangzhe123-com')) let uploadResult = '' if (isUpload) { uploadResult = $H.upload('/upload', { filePath: message.data }, onProgress) if (!uploadResult) { // 发送失败 message.sendStatus = 'fail' // 更新指定历史记录 this.updateChatDetail(message, k) // 断线重连提示 return reject(err) } } $H.post('/chat/send', { to_id: this.TO.id, type: message.type, chat_type: this.TO.chat_type, data: message.data, }).then(res => { // 发送成功 console.log('chat.js发送成功'); message.id = res.id message.sendStatus = 'success'; // 更新指定历史记录 this.updateChatDetail(message, k); result(res); }).catch(err => { // 发送失败 console.log('chat.js发送失败'); message.sendStatus = 'fail'; // 更新指定历史记录 this.updateChatDetail(message, k); // 断线重连提示 result(err); }); }) } // 验证是否上线 checkOnLine() { if (!this.isOnline) { // 断线重连提示 this.reconnectConfirm(); return false; } return true; } // 断线重连提示 reconnectConfirm() { uni.showModal({ title: '你已经断线,是否重新连接?', content: '重新连接', success: res => { if (res.confirm) { this.connectSocket(); } }, }); } // 添加聊天记录 addChatDetail(message, isSend = true) { console.log('添加到聊天记录'); // 获取对方id let id = message.chat_type === 'user' ? (isSend ? message.to_id : message.from_id) : message.to_id; if (!id) { return { data: {}, k: 0 } } // key值:chatDetail_当前用户id_会话类型_接收人/群id let key = `chatDetail_${this.user.id}_${message.chat_type}_${id}`; console.log(key); // 获取原来的聊天记录 let list = this.getChatdetail(key) console.log('获取原来的聊天记录', list); // 标识 message.k = 'k' + list.length list.push(message) // 加入存储 console.log('加入存储', message); this.setStorage(key, list); // 返回 return { data: message, k: message.k } } // 更新指定历史记录 async updateChatDetail(message, k, isSend = true) { // 获取对方id let id = message.chat_type === 'user' ? (isSend ? message.to_id : message.from_id) : message.to_id; // key值:chatDetail_当前用户id_会话类型_接收人/群id let key = `chatDetail_${this.user.id}_${message.chat_type}_${id}`; // 获取原来的聊天记录 let list = this.getChatdetail(key); // 根据k查找对应聊天记录 let index = list.findIndex(item => item.k === k); if (index === -1) return; list[index] = message; // 存储 this.setStorage(key, list); } // 获取聊天记录 getChatdetail(key = false) { key = key ? key : `chatDetail_${this.user.id}_${this.TO.chat_type}_${this.TO.id}`; return this.getStorage(key); } // 格式化会话最后一条消息显示 formatChatItemData(message, isSend) { let data = message.data switch (message.type) { case 'emoticon': data = '[表情]' break; case 'image': data = '[图片]' break; case 'audio': data = '[语音]' break; case 'video': data = '[视频]' break; case 'card': data = '[名片]' break; } data = isSend ? data : `${message.from_name}: ${data}` return data } // 更新会话列表 updateChatList(message, isSend = true) { // 获取本地存储会话列表 let list = this.getChatList() // 是否处于当前聊天中 let isCurrentChat = false // 接收人/群 id/头像/昵称 let id = 0 let avatar = '' let name = '' // 判断私聊还是群聊 if (message.chat_type === 'user') { // 私聊 // 聊天对象是否存在 isCurrentChat = this.TO ? (isSend ? this.TO.id === message.to_id : this.TO.id === message.from_id) : false id = isSend ? message.to_id : message.from_id avatar = isSend ? message.to_avatar : message.from_avatar name = isSend ? message.to_name : message.from_name } else { // 群聊 isCurrentChat = this.TO && (this.TO.id === message.to_id) id = message.to_id avatar = message.to_avatar name = message.to_name } // 会话是否存在 let index = list.findIndex(item => { return item.chat_type === message.chat_type && item.id === id }) // 最后一条消息展现形式 // let data = isSend ? message.data : `${message.from_name}: ${message.data}` let data = this.formatChatItemData(message, isSend) // 会话不存在,创建会话 // 未读数是否 + 1 let noreadnum = (isSend || isCurrentChat) ? 0 : 1 if (index === -1) { let chatItem = { id, // 接收人/群 id chat_type: message.chat_type, // 接收类型 user单聊 group群聊 avatar, // 接收人/群 头像 name, // 接收人/群 昵称 update_time: (new Date()).getTime(), // 最后一条消息的时间戳 data, // 最后一条消息内容 type: message.type, // 最后一条消息类型 noreadnum, // 未读数 istop: false, // 是否置顶 shownickname: false, // 是否显示昵称 nowarn: false, // 消息免打扰 strongwarn: false, // 是否开启强提醒 } // 群聊 if (message.chat_type === 'group' && message.group) { chatItem.shownickname = true chatItem.name = message.to_name chatItem = { ...chatItem, user_id: message.group.user_id, // 群管理员id remark: "", // 群公告 invite_confirm: 1, // 邀请确认 } } list.unshift(chatItem) } else { // 存在,更新会话 // 拿到当前会话 let item = list[index] // 更新该会话最后一条消息时间,内容,类型 item.update_time = (new Date()).getTime() item.name = message.to_name item.data = data item.type = message.type // 未读数更新 item.noreadnum += noreadnum // 置顶会话 list = this.listToFirst(list, index) } // 存储 let key = `chatlist_${this.user.id}` this.setStorage(key, list) // 更新未读数 this.updateBadge(list) // 通知更新vuex中的聊天会话列表 uni.$emit('onUpdateChatList', list) return list } // 获取聊天记录 getChatList(message, isSend = true) { // 获取本地存储会话列表 let list = this.getChatList(); // 是否处在当前聊天中 let isCurrentChat = false // 接收人/群 id/头像/昵称 let id = 0; let avatar = ''; let name = ''; // 判断私聊还是群聊 if (message.chat_type === 'user') { // 私聊 isCurrentChat = this.TO ? (isSend ? this.TO.id === message.to_id : message.from_id) : false; id = isSend ? message.to_id : message.from_id; avatar = isSend ? message.to_avatar : message.from_avatar name = isSend ? message.to_name : message.from_name } else { // 群聊 } // 会话是否存在 let index = list.findIndex(item => { return item.chat_type === message.chat_type && item.id === id; }) // 最后一条消息展现形式 let data = isSend ? message.data : `${message.from_name}:${message.data}`; // 未读数是否 +1 let noreadnum = (isSend || isCurrentChat) ? 0 : 1; // 会话不存在 创建会话 if (index === -1) { let chatItem = { id, // 接收人/群 id chat_type: message.chat_type, // 接收类型 user 单聊 group群聊 name, // 接收人/群 昵称 avatar, // 接收人/群 头像 update_time: (new Date()).getTime(), // 最后发送的时间 data, // 最后一条消息的内容 type: message.type, noreadnum: 1, // 未读数 istop: false, // 是否置顶 shownickname: false, // 是否显示昵称 nowarn: false, // 是否免打扰 strongwarn: false, // 是否强提醒 } if (message.chat_type === 'group') { chatItem = { ...chatItem, user_id: 0, // 管理员id remark: '', // 群公告 invite_confirm: 0 // 邀请确认 } } list.unshift(chatItem) } else { // 存在,更新会话 // 拿到当前会话 let item = list[index] // 更新改会话最后一条消息时间,内容,类型 item.update_time = (new Date()).getTime(); item.data = data; item.type = message.type; // 未读数更新 item.noreadnum += noreadnum // 置顶会话 list = this.listToFirst(list, index); } // 存储 let key = `chatlist_${this.user.id}`; this.setStorage(key, list); // 更新未读数 this.updateBadge(list); // 更新vuex中的聊天会话列表 uni.$emit('onUpdateChatList', list); console.log('获取到的会话列表:',list) return list; /** * { id:1, // 接收人/群 id chat_type:'user', // 接收类型 user 单聊 group群聊 name:'昵称', // 接收人/群 昵称 avatar:"/static/images/demo/demo6.jpg", // 接收人/群 头像 type:'',// 最后一条消息类型 update_time:1628069958, // 最后发送的时间 data:"你好啊,哈哈哈", // 最后一条消息的内容 noreadnum:1, // 未读数 istop:false, // 是否置顶 shownickname:0, // 是否显示昵称 nowarn:0, // 是否免打扰 strongwarn:0, // 是否强提醒 user_id://管理员id, remark:'公告', // 群公告 invite_confirm:0, // 邀请确认 }, **/ } // 获取本地存储会话列表 getChatList() { let key = `chatlist_${this.user.id}`; return this.getStorage(key); } // 读取会话 async readChatItem(id,chat_type){ // 获取所有会话列表 let list = this.getChatList(); // 找到当前会话 let index = list.findIndex(item=>item.id === id && item.chat_type === chat_type); if(index !== -1){ list[index].noreadnum = 0; let key = `chatlist_${this.user.id}`; this.setStorage(key,list); // 重新获取未读数 this.updateBadge(); // 更新会话列表状态 uni.$emit('onUpdateChatList',list); } } // 获取指定会话 getChatListItem(id,chat_type){ // 获取所有会话列表 let list = this.getChatList(); // 找到当前会话 let index = list.findIndex(item=>item.id === id && item.chat_type === chat_type); if(index !== -1){ return list[index]; } return false; } // 更新未读数 async updateBadge(list = false) { // 获取所有会话列表 list = list ? list : this.getChatList() // 统计所有未读数 let total = 0 list.forEach(item => { total += item.noreadnum }) // 设置底部导航栏角标 if (total > 0) { uni.setTabBarBadge({ index: 0, text: total <= 99 ? total.toString() : '99+' }) } else { uni.removeTabBarBadge({ index: 0 }) } uni.$emit('totalNoreadnum', total) } // 获取存储 getStorage(key) { let list = $U.getStorage(key); return list ? JSON.parse(list) : []; } // 设置存储 setStorage(key, value) { return $U.setStorage(key, JSON.stringify(value)); } // 数组置顶 listToFirst(arr, index) { if (index != 0) { arr.unshift(arr.splice(index, 1)[0]); } return arr; } } export default chat
/pages/mail/mail/mail.vue
<template> <view> <!-- 导航栏 --> <free-nav-bar title="选择" showBack :showRight="true"> <free-main-button :name="buttonText" slot="right" @click="submit"></free-main-button> </free-nav-bar> <!-- 通讯录列表 --> <scroll-view scroll-y="true" :style="'height:'+scrollHeight+'px;'" :scroll-into-view="scrollInto"> <template v-if="type === 'see'"> <free-list-item v-for="(item,index) in typeList" :key="item.key" :title="item.name" :showRightIcon="false" showRight @click="typeIndex = index"> <view slot="right" style="width: 40rpx;height: 40rpx;" class="border rounded-circle flex align-center justify-center mr-4"> <view v-if="typeIndex === index" style="width: 30rpx;height: 30rpx;" class="main-bg-color rounded-circle"></view> </view> </free-list-item> </template> <template v-if="type !== 'see' || (type === 'see' && (typeIndex === 1 || typeIndex === 2)) "> <view v-for="(item,index) in list" :key="index" :id="'item-'+item.title"> <view v-if="item.list.length" class="py-2 px-3 border-bottom bg-light"> <text class="font-md text-dark">{{item.title}}</text> </view> <free-list-item v-for="(item2,index2) in item.list" :key="index2" :title="item2.name" :cover="item2.avatar || '/static/images/userpic.png'" :showRightIcon="false" showRight @click="selectItem(item2)"> <view slot="right" style="width: 40rpx;height: 40rpx;" class="border rounded-circle flex align-center justify-center mr-4"> <view v-if="item2.checked" style="width: 30rpx;height: 30rpx;" class="main-bg-color rounded-circle"></view> </view> </free-list-item> </view> </template> </scroll-view> <!-- 侧边导航条 --> <view class="position-fixed right-0 bottom-0 bg-light flex flex-column" :style="'top:'+top+'px;'" style="width: 50rpx;" @touchstart="touchstart" @touchmove="touchmove" @touchend="touchend"> <view class="flex-1 flex align-center justify-center" v-for="(item,index) in list" :key="index"> <text class="font-sm text-muted">{{item.title}}</text> </view> </view> <view class="position-fixed rounded-circle bg-light border flex align-center justify-center" v-if="current" style="width: 150rpx;height: 150rpx;left: 300rpx;" :style="'top:'+modalTop+'px;'"> <text class="font-lg">{{current}}</text> </view> </view> </template> <script> import freeNavBar from "@/components/free-ui/free-nav-bar.vue" import freeListItem from "@/components/free-ui/free-list-item.vue" import freeMainButton from '@/components/free-ui/free-main-button.vue'; import { mapState } from 'vuex' import $H from '@/common/free-lib/request.js'; export default { components: { freeNavBar, freeListItem, freeMainButton }, data() { return { typeIndex:0, typeList:[{ name:"公开", key:"all" },{ name:"谁可以看", key:"only" },{ name:"不给谁看", key:"except" },{ name:"私密", key:"none" }], top:0, scrollHeight:0, scrollInto:'', current:'', selectList:[], type:"", limit:9, id:0 } }, onLoad(e) { let res = uni.getSystemInfoSync() this.top = res.statusBarHeight + uni.upx2px(90) this.scrollHeight = res.windowHeight - this.top if(e.type){ this.type = e.type } if(e.limit){ this.limit = parseInt(e.limit) } if(e.id){ this.id = e.id if(e.type === 'inviteGroup'){ this.limit = 1 } } this.$store.dispatch('getMailList') }, computed: { ...mapState({ list:state=>state.user.mailList }), buttonText(){ let text = '发送' if(this.type === 'createGroup'){ text = '创建群组' } return text + ' ('+this.selectCount+')' }, modalTop(){ return (this.scrollHeight - uni.upx2px(150)) / 2 }, // 每个索引的高度 itemHeight() { let count = this.list.length if(count < 1){ return 0 } return this.scrollHeight / count }, // 选中数量 selectCount(){ return this.selectList.length } }, methods: { touchstart(e){ this.changeScrollInto(e) }, touchmove(e){ this.changeScrollInto(e) }, touchend(e){ this.current = '' }, // 联动 changeScrollInto(e){ let Y = e.touches[0].pageY // #ifdef MP Y = Y - this.top // #endif let index = Math.floor(Y / this.itemHeight) let item = this.list[index] if(item){ this.scrollInto = 'item-'+item.title this.current = item.title } }, // 选中/取消选中 selectItem(item){ if(!item.checked && this.selectCount === this.limit){ // 选中|限制选中数量 return uni.showToast({ title: '最多选中 '+this.limit+' 个', icon: 'none' }); } item.checked = !item.checked if(item.checked){ // 选中 this.selectList.push(item) } else { // 取消选中 let index = this.selectList.findIndex(v=> v === item) if(index > -1){ this.selectList.splice(index,1) } } }, submit(){ if(this.type !== 'see' && this.selectCount === 0){ return uni.showToast({ title: '请先选择', icon: 'none' }); } switch (this.type){ case 'createGroup': // 创建群组 $H.post('/group/create',{ ids:this.selectList.map(item=>item.user_id) }).then(res=>{ uni.showToast({ title: '创建群聊成功', icon: 'none' }); uni.redirectTo({ url:'/pages/tabbar/index/index' }) }) break; case 'sendCard': let item = this.selectList[0] uni.$emit('sendItem',{ sendType:"card", data:item.name, type:"card", options:{ avatar:item.avatar, id:item.user_id } }) uni.navigateBack({ delta: 1 }); break; case 'remind': uni.$emit('sendResult',{ type:"remind", data:this.selectList }) uni.navigateBack({ delta: 1 }); break; case 'see': let k = this.typeList[this.typeIndex].key if(k !== 'all' && k!== 'none' && !this.selectCount){ return uni.showToast({ title: '请先选择', icon: 'none' }); } uni.$emit('sendResult',{ type:"see", data:{ k, v:this.selectList } }) uni.navigateBack({ delta: 1 }); break; case 'inviteGroup': console.log(this.selectList); $H.post('/group/invite',{ id:this.id, user_id:this.selectList[0].user_id }).then(res=>{ uni.showToast({ title: '邀请成功', icon: 'none' }); uni.navigateBack({ delta: 1 }); }) break; } } } } </script> <style> </style>
89群聊发送和接收消息
chat.vue
<template> <view> <!-- 导航栏 --> <free-nav-bar :title="detail.name" :noreadnum="totalNoreadnum" showBack> <free-icon-button slot="right"><text class="iconfont font-md" @click="openChat"></text> </free-icon-button> </free-nav-bar> <!-- 聊天内容区域 --> <scroll-view scroll-y="true" class="bg-light position-fixed left-0 right-0" style="bottom: 105rpx;" :style="chatBodyBottom" :show-scrollbar="false"> <!-- <view v-for="(item,index) in list" :key="index" :id="'chatItem_'+index"> <free-chat-item :item="item" :index="index" ref="chatItem" :pretime=" index > 0 ? list[index-1].create_time : 0" @long="long" @preview="previewImage" :shownickname="currentChatItem.shownickname" ></free-chat-item> </view> --> <!-- 聊天信息列表组件 --> <view v-for="(item,index) in list" :key="index" :id="'chatItem_'+index"> <free-chat-item :item="item" :index="index" ref="chatItem" :pretime=" index > 0 ? list[index-1].create_time : 0" @long="long" @preview="previewImage"> </free-chat-item> </view> <!-- 聊天信息列表组件 --> <!-- <view v-for="(item,index) in list" :key="index"> <free-chat-item :item="item" :index="index" :pretime="index > 0 ? list[index-1].create_time : 0" @long="long" ref="chatItem" @preview="previewImage"></free-chat-item> </view> --> <!-- 右边 --> <!-- <view class="flex align-start justify-end position-relative"> <div class="bg-chat-item p-2 rounded mr-3" style="max-width:500rpx;"> <text class="font-md">你好你好你好你好你好你好你好你好你好你好你好</text> </div> <view class="iconfont font-md position-absolute chat-right-icon"><text class="text-chat-item iconfont font-md"></text></view> <free-avatar size="75" src="/static/images/demo/demo6.jpg"></free-avatar> </view> --> </scroll-view> <!-- 扩展菜单 --> <free-popup ref="action" bottom transformOrigin="center bottom" @hide="keyBoardHeight = 0" :mask="false"> <view style="height: 580rpx;" class="border-top border-light-secondary"> <swiper :indicator-dots="emoticonOrActionList.length>1" style="height:510rpx;"> <swiper-item class="row" v-for="(item,index) in emoticonOrActionList" :key="index"> <view class="col-3 flex flex-column align-center justify-center" style="height: 255rpx;" v-for="(item2,index2) in item" :key="index2" @click="actionEvent(item2)"> <image :src="item2.icon" mode="widthFix" style="width: 100rpx;height: 100rpx;"></image> <text class="font-sm text-muted mt-2">{{item2.name}}</text> </view> </swiper-item> </swiper> </view> </free-popup> <!-- 弹出层 --> <free-popup ref="extend" maskColor bottom :bodyWidth="240" :bodyHeight="geMenusHeight" :tabbarHeight="105"> <view class="flex flex-column" style="width:240rpx;" :style="getMenusStyle"> <view v-for="(item,index) in menusList" :key="index" class="flex-1 flex align-center" hover-class="bg-light" @click="clickEvent(item.event)"> <text class="font-md pl-3">{{item.name}}</text> </view> </view> </free-popup> <!-- #ifdef APP-PLUS-NVUE --> <div class="position-fixed top-0 right-0 left-0 bottom-0" v-if="mode==='action' || mode==='emoticon'" @click="clickPage" :style="'bottom:'+maskBottom+'px;'"></div> <!-- #endif --> <!-- 底部输入框 --> <view class="position-fixed left-0 right-0 border-top flex align-center" style="background-color: #F7F7F6;height: 105rpx;" :style="'bottom:'+keyBoardHeight+'px;'"> <free-icon-button @click="changeVoiceOrText"> <block v-if="mode === 'audio'"> <text class="iconfont font-lg"></text> </block> <block v-else> <text class="iconfont font-lg"></text> </block> </free-icon-button> <view class="flex-1"> <view v-if="mode==='audio'" class="rounded flex align-center justify-center" style="height: 80rpx;" :class="isRecording?'bg-hover-light':'bg-white'" @touchstart="voiceTouchStart" @touchend="voiceTouchEnd" @touchmove="voiceTouchMove" @touchcancel="voiceTouchCancel"> <text class="font">{{isRecording ? '松开 结束' : '按住 说话'}}</text> </view> <!-- 底部输入框 --> <textarea v-else fixed class="bg-white rounded p-2 font-md" style="height: 50rpx;max-width: 450rpx;" :adjust-position="false" v-model="text" @focus="mode = 'text'" /> <!-- @click="onInputClick" --> <!-- <textarea v-else class="bg-white rounded p-1 font-md" style="height: 50rpx;max-width: 500rpx;" :adjust-position="false" v-model="text" @focus="mode = 'text'" /> --> </view> <template v-if="text.length === 0"> <!-- 表情 --> <free-icon-button><text class="iconfont font-lg" @click="openActionOrEmoticon('emoticon')"></text></free-icon-button> <!-- 扩展菜单 --> <free-icon-button @click="openActionOrEmoticon('action')"><text class="iconfont font-lg"></text> </free-icon-button> </template> <view v-else class="flex-shrink"> <!-- 发送按钮 --> <!-- <view class="main-bg-color rounded flex align-center justify-center mr-2 px-2 pt-4" style="height: 70rpx;" @click="send('text')"> 发送 </view> --> <free-main-button name="发送" @click="send('text')"></free-main-button> </view> </view> <!-- 录音提示 --> <view v-if="isRecording" class="position-fixed top-0 left-0 right-0 flex align-center justify-center" style="bottom: 105rpx;"> <view class="rounded flex flex-column align-center justify-center" style="width: 360rpx;height: 360rpx;background-color: rgba(0,0,0,0.5);"> <image src="/static/images/audio/audio/recording.gif" style="width: 150rpx;height: 150rpx;"></image> <text class="font text-white mt-3">{{unRecord?'松开手指,取消发送':'手指上滑,取消发送'}}</text> </view> </view> </view> </template> <script> // #ifdef APP-NVUE const domModule = weex.requireModule('dom'); // #endif import freeNavBar from '@/components/free-ui/free-nav-bar.vue'; import freeIconButton from '@/components/free-ui/free-icon-button.vue'; import freeChatItem from '@/components/free-ui/free-chat-item.vue'; import freePopup from '@/components/free-ui/free-popup.vue'; import freeMainButton from '@/components/free-ui/free-main-button.vue'; import { mapState, mapMutations } from 'vuex'; import auth from '@/common/mixin/auth.js'; export default { mixins: [auth], components: { freeNavBar, freeIconButton, freeChatItem, freePopup, freeMainButton }, watch: { mode(newValue, oldValue) { if (newValue !== 'action' && newValue !== 'emoticon') { this.$refs.action.hide(); } if (newValue !== 'text') { uni.hideKeyboard() } } }, // 生命周期 mounted() { this.statusBarHeight = 0; // 获取任务栏高度 // #ifdef APP-PLUS-NVUE this.statusBarHeight = plus.navigator.getStatusbarHeight() // #endif this.navBarHeight = this.statusBarHeight + uni.upx2px(90) // 监听键盘高度变化 uni.onKeyboardHeightChange((res) => { if (this.mode !== 'action' && this.mode !== 'emoticon') { this.keyBoardHeight = res.height; } if (this.keyBoardHeight > 0) { this.pageToBottom() } }) // 注册发送音频事件 this.regSendVoiceEvent((url) => { if (!this.unRecord) { // 发送 this.send('audio', url, { time: this.RecordTime }) } }); }, onLoad(e) { if (!e.params) { return this.backToast(); } // 初始化 this.__init(); this.detail = JSON.parse(e.params) // 创建聊天对象 this.chat.createChatObject(this.detail) // 获取历史记录 this.list = this.chat.getChatdetail() // 监听接收聊天信息 uni.$on('onMessage', (message)=>{ if((message.from_id === this.detail.id && message.chat_type === 'user') || (message.chat_type==='group' && this.detail.id)){ this.list.push(message); // 置于顶部 } }) // uni.$on('updateHistory', this.updateHistory) }, destroyed() { // 销毁聊天对象 this.chat.destoryChatObject(); // 销毁监听接收聊天消息 uni.$off('onMessage',()=>{}) }, computed: { ...mapState({ RECORD: state => state.audio.RECORD, RecordTime: state => state.audio.RecordTime, chat: state => state.user.chat, totalNoreadnum:state=>state.user.totalNoreadnum }), // 所有信息的图片地址 imageList() { var arr = []; this.list.forEach((item) => { if (item.type === 'emoticon' || item.type === 'image') { arr.push(item.data) } }) return arr; }, // 获取蒙版的位置 maskBottom() { return this.keyBoardHeight + uni.upx2px(105) }, // 动态获取菜单高度 geMenusHeight() { let H = 100; return this.menus.length * H; }, // 获取菜单的样式 getMenusStyle() { return `height:${this.geMenusHeight}rpx;`; }, // 是否是本人 isdoSelf() { // 获取本人id(假设拿到了) let id = 1 let user_id = this.propIndex > -1 ? this.list[this.propIndex].user_id : 0 return user_id === id }, // 获取操作菜单 menusList() { return this.menus.filter(v => { if (v.name === '撤回' && this.isDoSelf) { return false; } else { return true; } }) }, // 聊天区域bottom chatBodyBottom() { return `bottom:${uni.upx2px(105) + this.keyBoardHeight}px;top:${this.navBarHeight}px`; }, // 获取操作或者表情列表 emoticonOrActionList() { if (this.mode === 'emoticon' || this.mode === 'action') { return this[this.mode + 'List']; } else { return []; } } }, data() { return { // 模式 text输入文字 emoticon表情 action操作 audio音频 mode: 'text', // 扩展菜单列表 actionList: [ [{ name: "相册", icon: "/static/images/extends/pic.png", event: "uploadImage" }, { name: "拍摄", icon: "/static/images/extends/video.png", event: "uploadVideo" }, { name: "收藏", icon: "/static/images/extends/shoucan.png", event: "" }, { name: "名片", icon: "/static/images/extends/man.png", event: "" }, { name: "语音通话", icon: "/static/images/extends/phone.png", event: "" }, { name: "位置", icon: "/static/images/extends/path.png", event: "" } ] ], // 表情包 emoticonList: [ [{ name: "沮丧", icon: "/static/images/emoticon/5497/0.gif", event: "" }, { name: "沮丧", icon: "/static/images/emoticon/5497/0.gif", event: "" } ] ], // 输入文字 text: "", // 音频录制中 isRecording: false, // 取消录音 unRecord: false, // 当前气泡索引 keyBoardHeight: 0, propIndex: 1, navBarHeight: 0, list: [], RecordingStartY: 0, detail: { id: 0, name: '', avatar: '', chat_type: 'user' }, menus: [{ name: '复制', event: "" }, { name: '发送给朋友', event: "" }, { name: '收藏', event: "" }, { name: '删除', event: "" }, { name: '多选', event: "" }, { name: '撤回', event: "removeChatItem" } ], } }, methods: { ...mapMutations(['regSendVoiceEvent']), __init() { var total = 24; var page = Math.ceil(total / 8); var arr = []; for (var i = 0; i < page; i++) { var start = i * 8; arr[i] = []; for (var j = 0; j <= 8; j++) { arr[i].push({ name: '表情' + (start + j), icon: '/static/images/emoticon/5497/' + (start + j) + '.gif', event: 'sendEmoticon' }) } } this.emoticonList = arr; }, // 点击区域 clickPage() { // 隐藏操作菜单 this.$refs.action.hide(); this.mode = 'text'; }, // 输入框聚焦 // onInputClick(event) { // this.mode = 'text'; // }, // 事件分发 actionEvent(e) { switch (e.event) { case 'uploadImage': uni.chooseImage({ count: 9, //默认9 success: (res) => { res.tempFilePaths.forEach((item) => { // 发送到服务器 // 渲染到页面 this.send('image', item); }) } }); break; case 'sendEmoticon': // 发送表情包 this.send('emoticon', e.icon); break; case 'uploadVideo': // 发送短视频 uni.chooseVideo({ maxDuration: 10, success: (res) => { // 渲染页面 this.send('video', res.tempFilePath); // 发送到服务端(获取视频封面,返回url) // 修改本地发送状态 } }) break; } }, // 打开扩展菜单或者表情包 openActionOrEmoticon(model = 'action') { this.mode = model; this.$refs.action.show(); uni.hideKeyboard(); this.keyBoardHeight = uni.upx2px(580); }, // 发送 send(type, data = '', options = {}) { // 组织数据格式 switch (type) { case 'text': data = data || this.text break; } let message = this.chat.formatSendData({ type, data, options }) // 渲染到页面 console.log('this.list:', this.list) let index = this.list.length this.list.push(message) // 监听上传进度 let onProgress = false if (message.type !== 'text' && message.type !== 'emoticon' && message.type !== 'card' && !message.data .startsWith('http')) { onProgress = (progress) => { console.log('上传进度:', progress); } } // 发送到服务端 this.chat.send(message, onProgress).then(res => { console.log(res); // 发送成功 this.list[index].id = res.id this.list[index].sendStatus = 'success' }).catch(err => { // 发送失败 this.list[index].sendStatus = 'fail' console.log(err); }) // 发送文字成功,清空输入框 if (type === 'text') { this.text = '' } // 置于底部 // this.pageToBottom() }, // 长按消息气泡 long(e) { this.propIndex = e.index; this.$refs.extend.show(e.x, e.y); }, // 回到底部 pageToBottom() { let chatItem = this.$refs.chatItem; let lastIndex = chatItem.length > 0 ? chatItem.length - 1 : 0; let last = chatItem[lastIndex]; // #ifdef APP-NVUE if (chatItem[lastIndex]) { domModule.scrollToElement(last, {}); } // #endif }, // 操作菜单方法分发 clickEvent(event) { switch (event) { case 'removeChatItem': // 撤回消息 // 拿到当前被操作的信息 this.list[this.propIndex].isremove = true; break; default: break; } // 关闭菜单 this.$refs.extend.hide(); }, // 预览图片 previewImage(url) { // 预览图片 uni.previewImage({ current: url, urls: this.imageList, indicator: "default", longPressActions: { itemList: ['发送给朋友', '保存图片', '收藏'], success: function(data) { console.log('选中了第' + (data.tapIndex + 1) + '个按钮,第' + (data.index + 1) + '张图片'); }, fail: function(err) { console.log(err.errMsg); } } }); }, // 切换音频录制喝文本输入 changeVoiceOrText() { this.mode = this.mode !== 'audio' ? 'audio' : 'text'; }, // 录音相关 // 录音开始 voiceTouchStart(e) { this.isRecording = true; this.RecordingStartY = e.changedTouches[0].screenY; this.unRecord = false; // 开始录音 this.RECORD.start({ format: 'mp3' }) }, // 录音结束 voiceTouchEnd() { this.isRecording = false; // 停止录音 this.RECORD.stop(); }, // 录音打断 voiceTouchCancel() { this.isRecording = false; this.unRecord = true; // 停止录音 this.RECORD.stop(); }, voiceTouchMove(e) { var Y = Math.abs(e.changedTouches[0].screenY - this.RecordingStartY); this.unRecord = (Y >= 80); }, // 打开聊天信息 openChat() { uni.navigateTo({ url: '/pages/chat/chat-set/chat-set?params='+JSON.stringify({ id:this.detail.id, chat_type:this.detail.chat_type }), }); } } } </script> <style> </style>
app/controller/chat.js
// app/controller/chat.js const Controller = require('egg').Controller; class ChatController extends Controller { // 连接socket async connect() { const { ctx, app, service } = this; if (!ctx.websocket) { ctx.throw(400, '非法访问'); } // console.log(`clients: ${app.ws.clients.size}`); // 监听接收消息和关闭socket ctx.websocket .on('message', msg => { // console.log('接收消息', msg); }) .on('close', (code, reason) => { // 用户下线 console.log('用户下线', code, reason); let user_id = ctx.websocket.user_id; // 移除redis中的用户上线记录 service.cache.remove('online_' + user_id); if (app.ws.user && app.ws.user[user_id]) { delete app.ws.user[user_id]; } }); } // 发送消息 async send(){ const {ctx,app,service} = this; // 用户id let current_user_id = ctx.authUser.id; // 验证参数 ctx.validate({ to_id:{ type:'int', require:true, desc:'接收人/群id' }, chat_type:{ type:'string', require:true, range:{in:['user','group']}, desc:'接收类型' }, type:{ type:'string', require:true, range:{in:['text','image','video','audio']}, desc:'消息类型' }, data:{ type:'string', require:true, desc:'消息内容' } }); // 获取参数 let {to_id,chat_type,type,data} = ctx.request.body; // 单聊 if(chat_type=='user'){ // 验证好友是否存在,并且对方没有把你拉黑 let Friend = await app.model.Friend.findOne({ where:{ user_id:to_id, friend_id:current_user_id, isblack:0 }, include:[{ model:app.model.User, as:'userInfo' },{ model:app.model.User, as:'friendInfo' }] }); if(!Friend){ return ctx.apiFail('对方不存在或者已把你拉黑'); } // 验证好友是否被禁用 if(!Friend.userInfo.status){ return ctx.apiFail('对方已经被禁用'); } // 构建消息格式 let from_name = Friend.friendInfo.nickname ? Friend.friendInfo.nickname : Friend.friendInfo.username; if(Friend.nickname){ from_name = Friend.nickname; } let message = { id:(new Date()).getTime(), // 唯一id,后端生成唯一id from_avatar:Friend.friendInfo.avatar,// 发送者头像 from_name,// 发送者昵称 from_id:current_user_id, // 发送者id to_id,// 接收人id to_name:Friend.userInfo.nickname ? Friend.userInfo.nickname : Friend.userInfo.username,// 接收人/群 名称 to_avatar:Friend.userInfo.avatar,// 接收人/群 头像 chat_type:'user', // 接收类型 type, // 消息类型 data, // 消息内容 options:{}, // 其他参数 create_time:(new Date()).getTime(),// 创建时间 isremove:0 // 是否撤回 } // // 拿到当前的socket // let socket = app.ws.user[to_id]; // // 验证对方是否在线,不在线记录到待接收消息队列中 在线:消息推送 存储到对方的聊天记录中 chatlog_对方用户id_user_当前用户id // if(!socket){ // service.cache.setList('getmessage_'+to_id,message); // }else{ // // 消息推送 // socket.send(JSON.stringify({ // msg:'ok', // data:message // })) // // 存到历史记录中 // service.cache.setList(`chatlog_${to_id}_${message.chat_type}_${current_user_id}`,message); // } ctx.sendAndSaveMessage(to_id,message); // 存储到自己的聊天记录中 service.cache.setList(`chatlog_${current_user_id}_${message.chat_type}_${to_id}`,message); // 返回成功 return ctx.apiSuccess(message); } // 群聊 // 验证群聊是否存在,并且你是否在该群中 let group = await app.model.Group.findOne({ where:{ status:1, id:to_id }, include:[{ model:app.model.GroupUser, attributes:['user_id','nickname'] }] }); if(!group){ return ctx.apiFail('该群聊不存在或已被封禁'); } let index = group.group_users.findIndex(item=>item.user_id === current_user_id); // 验证 if(index === -1){ return ctx.apiFail('你不是该群的成员'); } // 组织格式 let from_name = group.group_users[index].nickname; let message = { id:(new Date()).getTime(), // 唯一id,后端生成唯一id from_avatar:ctx.authUser.avatar,// 发送者头像 from_name:from_name || ctx.authUser.nickname || ctx.authUser.username,// 发送者昵称 from_id:current_user_id, // 发送者id to_id,// 接收人id to_name:group.name,// 接收人/群 名称 to_avatar:group.name,// 接收人/群 头像 chat_type:'group', // 接收类型 type, // 消息类型 data, // 消息内容 options:{}, // 其他参数 create_time:(new Date()).getTime(),// 创建时间 isremove:0, // 是否撤回 group:group } // 推送消息 group.group_users.forEach(item=>{ if(item.user_id !== current_user_id){ ctx.sendAndSaveMessage(item.user_id,message); } }); ctx.apiSuccess(message); } } module.exports = ChatController;
90群昵称显示
chat.js
import $U from "./util.js"; import $H from './request.js'; class chat { constructor(arg) { this.url = arg.url this.isOnline = false this.socket = null // 获取当前用户相关信息 let user = $U.getStorage('user'); this.user = user ? JSON.parse(user) : {}, // 初始化聊天对象 this.TO = false; // 连接和监听 if (this.user.token) { this.connectSocket() } } // 连接socket connectSocket() { console.log(this.user); this.socket = uni.connectSocket({ url: this.url + '?token=' + this.user.token, complete: () => {} }) // 监听连接成功 this.socket.onOpen(() => this.onOpen()) // 监听接收信息 this.socket.onMessage((res) => this.onMessage(res)) // 监听断开 this.socket.onClose(() => this.onClose()) // 监听错误 this.socket.onError(() => this.onError()) } // 监听打开 onOpen() { // 用户状态上线 this.isOnline = true; console.log('socket连接成功'); // 获取用户离线消息 } // 监听关闭 onClose() { // 用户下线 this.isOnline = false; this.socket = null; console.log('socket连接关闭'); } // 监听消息 onMessage(data) { console.log('监听消息', data); let res = JSON.parse(data.data) // console.log('监听接收消息',res) // 错误 switch (res.msg) { case 'fail': return uni.showToast({ title: res.data, icon: 'none' }); break; case 'recall': // 撤回消息 this.handleOnRecall(res.data) break; case 'updateApplyList': // 新的好友申请 $store.dispatch('getApply'); break; case 'moment': // 朋友圈更新 this.handleMoment(res.data) break; default: // 处理消息 this.handleOnMessage(res.data) break; } } // 处理消息 async handleOnMessage(message) { // 添加消息记录到本地存储中 let { data } = this.addChatDetail(message, false) // 更新会话列表 this.updateChatList(data, false) // 全局通知 uni.$emit('onMessage', data) // 消息提示 // this.messageNotice() } // 监听连接错误 onError() { // 用户下线 this.isOnline = false; this.socket = null; console.log('socket连接错误'); } // 关闭连接 close() { this.socket.close() } // 创建聊天对象 createChatObject(detail) { this.TO = detail; console.log('创建聊天对象', this.TO) } // 销毁聊天对象 destoryChatObject() { this.TO = false } // 组织发送信息格式 formatSendData(params) { return { id: 0, // 唯一id,后端生成,用于撤回指定消息 from_avatar: this.user.avatar, // 发送者头像 from_name: this.user.nickname || this.user.username, // 发送者昵称 from_id: this.user.id, // 发送者id to_id: params.to_id || this.TO.id, // 接收人/群 id to_name: params.to_name || this.TO.name, // 接收人/群 名称 to_avatar: params.to_avatar || this.TO.avatar, // 接收人/群 头像 chat_type: params.chat_type || this.TO.chat_type, // 接收类型 type: params.type, // 消息类型 data: params.data, // 消息内容 options: params.options ? params.options : {}, // 其他参数 create_time: (new Date()).getTime(), // 创建时间 isremove: 0, // 是否撤回 sendStatus: params.sendStatus ? params.sendStatus : "pending" // 发送状态,success发送成功,fail发送失败,pending发送中 } } // 发送信息 send(message, onProgress = false) { return new Promise((result, reject) => { // 添加消息历史记录 // this.addChatDetail(); let { k } = this.addChatDetail(message); // 更新会话列表 this.updateChatList(message); // 验证是否上线 if (!this.checkOnLine()) return reject('未上线'); // 上传文件 let isUpload = (message.type !== 'text' && message.type !== 'emoticon' && message.type !== 'card' && !message.data.startsWith('http://tangzhe123-com')) let uploadResult = '' if (isUpload) { uploadResult = $H.upload('/upload', { filePath: message.data }, onProgress) if (!uploadResult) { // 发送失败 message.sendStatus = 'fail' // 更新指定历史记录 this.updateChatDetail(message, k) // 断线重连提示 return reject(err) } } $H.post('/chat/send', { to_id: this.TO.id, type: message.type, chat_type: this.TO.chat_type, data: message.data, }).then(res => { // 发送成功 console.log('chat.js发送成功'); message.id = res.id message.sendStatus = 'success'; // 更新指定历史记录 this.updateChatDetail(message, k); result(res); }).catch(err => { // 发送失败 console.log('chat.js发送失败'); message.sendStatus = 'fail'; // 更新指定历史记录 this.updateChatDetail(message, k); // 断线重连提示 result(err); }); }) } // 验证是否上线 checkOnLine() { if (!this.isOnline) { // 断线重连提示 this.reconnectConfirm(); return false; } return true; } // 断线重连提示 reconnectConfirm() { uni.showModal({ title: '你已经断线,是否重新连接?', content: '重新连接', success: res => { if (res.confirm) { this.connectSocket(); } }, }); } // 添加聊天记录 addChatDetail(message, isSend = true) { console.log('添加到聊天记录'); // 获取对方id let id = message.chat_type === 'user' ? (isSend ? message.to_id : message.from_id) : message.to_id; if (!id) { return { data: {}, k: 0 } } // key值:chatDetail_当前用户id_会话类型_接收人/群id let key = `chatDetail_${this.user.id}_${message.chat_type}_${id}`; console.log(key); // 获取原来的聊天记录 let list = this.getChatdetail(key) console.log('获取原来的聊天记录', list); // 标识 message.k = 'k' + list.length list.push(message) // 加入存储 console.log('加入存储', message); this.setStorage(key, list); // 返回 return { data: message, k: message.k } } // 更新指定历史记录 async updateChatDetail(message, k, isSend = true) { // 获取对方id let id = message.chat_type === 'user' ? (isSend ? message.to_id : message.from_id) : message.to_id; // key值:chatDetail_当前用户id_会话类型_接收人/群id let key = `chatDetail_${this.user.id}_${message.chat_type}_${id}`; // 获取原来的聊天记录 let list = this.getChatdetail(key); // 根据k查找对应聊天记录 let index = list.findIndex(item => item.k === k); if (index === -1) return; list[index] = message; // 存储 this.setStorage(key, list); } // 获取聊天记录 getChatdetail(key = false) { key = key ? key : `chatDetail_${this.user.id}_${this.TO.chat_type}_${this.TO.id}`; return this.getStorage(key); } // 格式化会话最后一条消息显示 formatChatItemData(message, isSend) { let data = message.data switch (message.type) { case 'emoticon': data = '[表情]' break; case 'image': data = '[图片]' break; case 'audio': data = '[语音]' break; case 'video': data = '[视频]' break; case 'card': data = '[名片]' break; } data = isSend ? data : `${message.from_name}: ${data}` return data } // 更新会话列表 updateChatList(message, isSend = true) { // 获取本地存储会话列表 let list = this.getChatList() // 是否处于当前聊天中 let isCurrentChat = false // 接收人/群 id/头像/昵称 let id = 0 let avatar = '' let name = '' // 判断私聊还是群聊 if (message.chat_type === 'user') { // 私聊 // 聊天对象是否存在 isCurrentChat = this.TO ? (isSend ? this.TO.id === message.to_id : this.TO.id === message.from_id) : false id = isSend ? message.to_id : message.from_id avatar = isSend ? message.to_avatar : message.from_avatar name = isSend ? message.to_name : message.from_name } else { // 群聊 isCurrentChat = this.TO && (this.TO.id === message.to_id) id = message.to_id avatar = message.to_avatar name = message.to_name } // 会话是否存在 let index = list.findIndex(item => { return item.chat_type === message.chat_type && item.id === id }) // 最后一条消息展现形式 // let data = isSend ? message.data : `${message.from_name}: ${message.data}` let data = this.formatChatItemData(message, isSend) // 会话不存在,创建会话 // 未读数是否 + 1 let noreadnum = (isSend || isCurrentChat) ? 0 : 1 if (index === -1) { let chatItem = { id, // 接收人/群 id chat_type: message.chat_type, // 接收类型 user单聊 group群聊 avatar, // 接收人/群 头像 name, // 接收人/群 昵称 update_time: (new Date()).getTime(), // 最后一条消息的时间戳 data, // 最后一条消息内容 type: message.type, // 最后一条消息类型 noreadnum, // 未读数 istop: false, // 是否置顶 shownickname: false, // 是否显示昵称 nowarn: false, // 消息免打扰 strongwarn: false, // 是否开启强提醒 } // 群聊 if (message.chat_type === 'group' && message.group) { chatItem.shownickname = true chatItem.name = message.to_name chatItem = { ...chatItem, user_id: message.group.user_id, // 群管理员id remark: "", // 群公告 invite_confirm: 1, // 邀请确认 } } list.unshift(chatItem) } else { // 存在,更新会话 // 拿到当前会话 let item = list[index] // 更新该会话最后一条消息时间,内容,类型 item.update_time = (new Date()).getTime() item.name = message.to_name item.data = data item.type = message.type // 未读数更新 item.noreadnum += noreadnum // 置顶会话 list = this.listToFirst(list, index) } // 存储 let key = `chatlist_${this.user.id}` this.setStorage(key, list) // 更新未读数 this.updateBadge(list) // 通知更新vuex中的聊天会话列表 uni.$emit('onUpdateChatList', list) return list } // 获取聊天记录 getChatList(message, isSend = true) { // 获取本地存储会话列表 let list = this.getChatList(); // 是否处在当前聊天中 let isCurrentChat = false // 接收人/群 id/头像/昵称 let id = 0; let avatar = ''; let name = ''; // 判断私聊还是群聊 if (message.chat_type === 'user') { // 私聊 isCurrentChat = this.TO ? (isSend ? this.TO.id === message.to_id : message.from_id) : false; id = isSend ? message.to_id : message.from_id; avatar = isSend ? message.to_avatar : message.from_avatar name = isSend ? message.to_name : message.from_name } else { // 群聊 } // 会话是否存在 let index = list.findIndex(item => { return item.chat_type === message.chat_type && item.id === id; }) // 最后一条消息展现形式 let data = isSend ? message.data : `${message.from_name}:${message.data}`; // 未读数是否 +1 let noreadnum = (isSend || isCurrentChat) ? 0 : 1; // 会话不存在 创建会话 if (index === -1) { let chatItem = { id, // 接收人/群 id chat_type: message.chat_type, // 接收类型 user 单聊 group群聊 name, // 接收人/群 昵称 avatar, // 接收人/群 头像 update_time: (new Date()).getTime(), // 最后发送的时间 data, // 最后一条消息的内容 type: message.type, noreadnum: 1, // 未读数 istop: false, // 是否置顶 shownickname: false, // 是否显示昵称 nowarn: false, // 是否免打扰 strongwarn: false, // 是否强提醒 } if (message.chat_type === 'group') { chatItem = { ...chatItem, user_id: 0, // 管理员id remark: '', // 群公告 invite_confirm: 0 // 邀请确认 } } list.unshift(chatItem) } else { // 存在,更新会话 // 拿到当前会话 let item = list[index] // 更新改会话最后一条消息时间,内容,类型 item.update_time = (new Date()).getTime(); item.data = data; item.type = message.type; // 未读数更新 item.noreadnum += noreadnum // 置顶会话 list = this.listToFirst(list, index); } // 存储 let key = `chatlist_${this.user.id}`; this.setStorage(key, list); // 更新未读数 this.updateBadge(list); // 更新vuex中的聊天会话列表 uni.$emit('onUpdateChatList', list); console.log('获取到的会话列表:',list) return list; /** * { id:1, // 接收人/群 id chat_type:'user', // 接收类型 user 单聊 group群聊 name:'昵称', // 接收人/群 昵称 avatar:"/static/images/demo/demo6.jpg", // 接收人/群 头像 type:'',// 最后一条消息类型 update_time:1628069958, // 最后发送的时间 data:"你好啊,哈哈哈", // 最后一条消息的内容 noreadnum:1, // 未读数 istop:false, // 是否置顶 shownickname:0, // 是否显示昵称 nowarn:0, // 是否免打扰 strongwarn:0, // 是否强提醒 user_id://管理员id, remark:'公告', // 群公告 invite_confirm:0, // 邀请确认 }, **/ } // 获取本地存储会话列表 getChatList() { let key = `chatlist_${this.user.id}`; return this.getStorage(key); } // 读取会话 async readChatItem(id,chat_type){ // 获取所有会话列表 let list = this.getChatList(); // 找到当前会话 let index = list.findIndex(item=>item.id === id && item.chat_type === chat_type); if(index !== -1){ list[index].noreadnum = 0; let key = `chatlist_${this.user.id}`; this.setStorage(key,list); // 重新获取未读数 this.updateBadge(); // 更新会话列表状态 uni.$emit('onUpdateChatList',list); } } // 获取指定会话 getChatListItem(id,chat_type){ // 获取所有会话列表 let list = this.getChatList(); // 找到当前会话 let index = list.findIndex(item=>item.id === id && item.chat_type === chat_type); if(index !== -1){ return list[index]; } return false; } // 更新未读数 async updateBadge(list = false) { // 获取所有会话列表 list = list ? list : this.getChatList() // 统计所有未读数 let total = 0 list.forEach(item => { total += item.noreadnum }) // 设置底部导航栏角标 if (total > 0) { uni.setTabBarBadge({ index: 0, text: total <= 99 ? total.toString() : '99+' }) } else { uni.removeTabBarBadge({ index: 0 }) } uni.$emit('totalNoreadnum', total) } // 获取存储 getStorage(key) { let list = $U.getStorage(key); return list ? JSON.parse(list) : []; } // 设置存储 setStorage(key, value) { return $U.setStorage(key, JSON.stringify(value)); } // 数组置顶 listToFirst(arr, index) { if (index != 0) { arr.unshift(arr.splice(index, 1)[0]); } return arr; } } export default chat
free-chat-item.vue
<template> <div @longpress="long"> <!-- 时间显示 --> <view v-if="showTime" class="flex align-center justify-center pb-4 pt-2"> <text class="font-sm text-light-muted">{{showTime}}</text> </view> <!-- 撤回消息 --> <view v-if="item.isremove" ref="isremove" class="flex align-center justify-center pb-4 pt-1"> <text class="font-sm text-light-muted">{{ isself ? '你' : '对方' }}撤回了一条信息</text> </view> <!-- 系统消息 --> <view v-if="item.type === 'system'" ref="isremove" class="flex align-center justify-center pb-4 pt-1"> <text class="font-sm text-light-muted">{{item.data}}</text> </view> <!-- 气泡 --> <view v-if="item.type !== 'system' && !item.isremove" class="flex align-start position-relative mb-3" :class="!isself ? 'justify-start' : 'justify-end'"> <!-- 好友 --> <template v-if="!isself"> <free-avater size="70" :src="item.from_avatar" @click="openUser"></free-avater> <text v-if="hasLabelClass" class="iconfont text-white font-md position-absolute chat-left-icon" :style="shownickname ? 'top:45rpx;':'top:20rpx;'"></text> </template> <view class="flex flex-column"> <!-- 昵称 --> <view v-if="shownickname" class="flex" :class="nicknameClass" style="max-width:500rpx;background-color: rgba(0,0,0,0);" :style="labelStyle"> <text class="font-sm text-muted">{{item.from_name}}</text> </view> <div class="p-2 rounded" :class="labelClass" style="max-width:500rpx;" :style="labelStyle"> <!-- 文字 --> <text v-if="item.type === 'text'" class="font-md">{{item.data}}</text> <!-- 表情包 | 图片--> <free-image v-else-if="item.type === 'emoticon' || item.type === 'image'" :src="item.data" @click="preview(item.data)" imageClass="rounded" :maxWidth="500" :maxHeight="350"></free-image> <!-- 音频 --> <view v-else-if="item.type === 'audio'" class="flex align-center" @click="openAudio"> <image v-if="isself" :src=" !audioPlaying ? '/static/audio/audio3.png' : '/static/audio/play.gif'" style="width: 50rpx;height: 50rpx;" class="mx-1"></image> <text class="font">{{item.options.time + '"'}}</text> <image v-if="!isself" :src=" !audioPlaying ? '/static/audio/audio3.png' : '/static/audio/play.gif'" style="width: 50rpx;height: 50rpx;" class="mx-1"></image> </view> <!-- 视频 --> <view v-else-if="item.type === 'video'" class="position-relative rounded" @click="openVideo"> <free-image :src="item.options.poster" imageClass="rounded" :maxWidth="300" :maxHeight="350" @load="loadPoster"></free-image> <text class="iconfont text-white position-absolute" style="font-size: 80rpx;width: 80rpx;height: 80rpx;" :style="posterIconStyle"></text> </view> <!-- 名片 --> <view v-else-if="item.type === 'card'" class="bg-white" style="width: 400rpx;" hover-class="bg-light" @click="openUserBase"> <view class="p-3 flex align-center border-bottom border-light-secondary"> <free-avater size="70" :src="item.options.avatar" clickType="navigate"></free-avater> <text class="font ml-2">{{item.data}}</text> </view> <view class="flex align-center p-2"> <text class="font-small text-muted">个人名片</text> </view> </view> </div> </view> <!-- 本人 --> <template v-if="isself"> <text v-if="hasLabelClass" class="iconfont text-chat-item font-md position-absolute chat-right-icon" :style="shownickname ? 'top:45rpx;':'top:20rpx;'"></text> <free-avater size="70" :src="item.from_avatar" @click="openUser"></free-avater> </template> </view> <view v-if="item.sendStatus && item.sendStatus !== 'success'" class="flex align-center justify-end px-4"> <text class="font-sm" :class="item.sendStatus === 'fail' ? 'text-danger' : 'text-muted'">{{item.sendStatus === 'fail' ? '发送失败' : '发送中...'}}</text> </view> </div> </template> <script> import freeAvater from "@/components/free-ui/free-avater.vue" import freeImage from './free-image.vue'; import $T from "@/common/free-lib/time.js" import { mapState,mapActions } from 'vuex' export default { components: { freeAvater, freeImage }, props: { item: Object, index:Number, // 上一条消息的时间戳 pretime:[Number,String], shownickname:{ type:Boolean, default:false } }, data() { return { innerAudioContext:null, audioPlaying:false, // 默认封面的宽高 poster:{ w:100, h:100 } } }, computed: { ...mapState({ user:state=>state.user.user }), // 是否是本人 isself() { // 获取本人id let id = this.user.id ? this.user.id : 0 return this.item.from_id === id }, // 显示的时间 showTime(){ return $T.getChatTime(this.item.create_time,this.pretime) }, // 是否需要气泡样式 hasLabelClass(){ return this.item.type === 'text' || this.item.type === 'audio' }, // 气泡的样式 labelClass(){ let label = this.hasLabelClass ? 'bg-chat-item mr-3' : 'mr-3' return this.isself ? label : 'bg-white ml-3' }, nicknameClass(){ let c = this.isself ? 'justify-end' : '' return c +' '+ this.labelClass }, labelStyle(){ if (this.item.type === 'audio') { let time = this.item.options.time || 0 let width = parseInt(time) / (60/500) width = width < 150 ? 150 : width return `width:${width}rpx;` } }, // 短视频封面图标位置 posterIconStyle(){ let w = this.poster.w/2 - uni.upx2px(80)/2 let h = this.poster.h/2- uni.upx2px(80)/2 return `left:${w}px;top:${h}px;` } }, mounted() { // 注册全局事件 if (this.item.type === 'audio') { this.audioOn(this.onPlayAudio) } // 监听是否撤回消息 // #ifdef APP-PLUS-NVUE this.$watch('item.isremove',(newVal,oldVal)=>{ if (newVal) { const animation = weex.requireModule('animation') this.$nextTick(()=>{ animation.transition(this.$refs.isremove, { styles: { opacity:1 }, duration: 100, //ms timingFunction: 'ease', }, function () { console.log('动画执行结束'); }) }) } }) // #endif }, // 组件销毁 destroyed() { if (this.item.type === 'audio') { this.audioOff(this.onPlayAudio) } // 销毁音频 if (this.innerAudioContext) { this.innerAudioContext.destroy() this.innerAudioContext = null } }, methods:{ ...mapActions(['audioOn','audioEmit','audioOff']), openUser(){ uni.navigateTo({ url: '/pages/mail/user-base/user-base?user_id='+this.item.from_id, }); }, // 打开名片 openUserBase(){ uni.navigateTo({ url: '/pages/mail/user-base/user-base?user_id='+this.item.options.id, }); }, // 加载封面 loadPoster(e){ this.poster.w = e.w this.poster.h = e.h }, // 监听播放音频全局事件 onPlayAudio(index){ if (this.innerAudioContext) { if (this.index !== index) { this.innerAudioContext.pause() } } }, // 播放音频 openAudio(){ // 通知停止其他音频 this.audioEmit(this.index) if (!this.innerAudioContext) { this.innerAudioContext = uni.createInnerAudioContext(); this.innerAudioContext.src = this.item.data this.innerAudioContext.play() // 监听播放 this.innerAudioContext.onPlay(()=>{ this.audioPlaying = true }) // 监听暂停 this.innerAudioContext.onPause(()=>{ this.audioPlaying = false }) // 监听停止 this.innerAudioContext.onStop(()=>{ this.audioPlaying = false }) // 监听错误 this.innerAudioContext.onError(()=>{ this.audioPlaying = false }) } else { this.innerAudioContext.stop() this.innerAudioContext.play() } }, // 预览图片 preview(url){ this.$emit('preview',url) }, // 长按事件 long(e){ let x = 0 let y = 0 // #ifdef APP-PLUS-NVUE if (Array.isArray(e.changedTouches) && e.changedTouches.length > 0) { x = e.changedTouches[0].screenX y = e.changedTouches[0].screenY } // #endif // #ifdef H5 x = e.changedTouches[0].pageX y = e.changedTouches[0].pageY // #endif // #ifdef MP x = e.detail.x y = e.detail.y // #endif this.$emit('long',{ x, y, index:this.index }) }, // 打开视频 openVideo(){ uni.navigateTo({ url: '/pages/chat/video/video?url='+this.item.data, }); } } } </script> <style scoped> .chat-left-icon{ left: 80rpx; } .chat-right-icon{ right: 80rpx; } .chat-animate{ /* #ifndef APP-PLUS-NVUE */ opacity: 0; /* #endif */ } </style>
91获取离线消息
app/controller/chat.js
// app/controller/chat.js const Controller = require('egg').Controller; class ChatController extends Controller { // 连接socket async connect() { const { ctx, app, service } = this; if (!ctx.websocket) { ctx.throw(400, '非法访问'); } // console.log(`clients: ${app.ws.clients.size}`); // 监听接收消息和关闭socket ctx.websocket .on('message', msg => { // console.log('接收消息', msg); }) .on('close', (code, reason) => { // 用户下线 console.log('用户下线', code, reason); let user_id = ctx.websocket.user_id; // 移除redis中的用户上线记录 service.cache.remove('online_' + user_id); if (app.ws.user && app.ws.user[user_id]) { delete app.ws.user[user_id]; } }); } // 发送消息 async send(){ const {ctx,app,service} = this; // 用户id let current_user_id = ctx.authUser.id; // 验证参数 ctx.validate({ to_id:{ type:'int', require:true, desc:'接收人/群id' }, chat_type:{ type:'string', require:true, range:{in:['user','group']}, desc:'接收类型' }, type:{ type:'string', require:true, range:{in:['text','image','video','audio']}, desc:'消息类型' }, data:{ type:'string', require:true, desc:'消息内容' } }); // 获取参数 let {to_id,chat_type,type,data} = ctx.request.body; // 单聊 if(chat_type=='user'){ // 验证好友是否存在,并且对方没有把你拉黑 let Friend = await app.model.Friend.findOne({ where:{ user_id:to_id, friend_id:current_user_id, isblack:0 }, include:[{ model:app.model.User, as:'userInfo' },{ model:app.model.User, as:'friendInfo' }] }); if(!Friend){ return ctx.apiFail('对方不存在或者已把你拉黑'); } // 验证好友是否被禁用 if(!Friend.userInfo.status){ return ctx.apiFail('对方已经被禁用'); } // 构建消息格式 let from_name = Friend.friendInfo.nickname ? Friend.friendInfo.nickname : Friend.friendInfo.username; if(Friend.nickname){ from_name = Friend.nickname; } let message = { id:(new Date()).getTime(), // 唯一id,后端生成唯一id from_avatar:Friend.friendInfo.avatar,// 发送者头像 from_name,// 发送者昵称 from_id:current_user_id, // 发送者id to_id,// 接收人id to_name:Friend.userInfo.nickname ? Friend.userInfo.nickname : Friend.userInfo.username,// 接收人/群 名称 to_avatar:Friend.userInfo.avatar,// 接收人/群 头像 chat_type:'user', // 接收类型 type, // 消息类型 data, // 消息内容 options:{}, // 其他参数 create_time:(new Date()).getTime(),// 创建时间 isremove:0 // 是否撤回 } // // 拿到当前的socket // let socket = app.ws.user[to_id]; // // 验证对方是否在线,不在线记录到待接收消息队列中 在线:消息推送 存储到对方的聊天记录中 chatlog_对方用户id_user_当前用户id // if(!socket){ // service.cache.setList('getmessage_'+to_id,message); // }else{ // // 消息推送 // socket.send(JSON.stringify({ // msg:'ok', // data:message // })) // // 存到历史记录中 // service.cache.setList(`chatlog_${to_id}_${message.chat_type}_${current_user_id}`,message); // } ctx.sendAndSaveMessage(to_id,message); // 存储到自己的聊天记录中 service.cache.setList(`chatlog_${current_user_id}_${message.chat_type}_${to_id}`,message); // 返回成功 return ctx.apiSuccess(message); } // 群聊 // 验证群聊是否存在,并且你是否在该群中 let group = await app.model.Group.findOne({ where:{ status:1, id:to_id }, include:[{ model:app.model.GroupUser, attributes:['user_id','nickname'] }] }); if(!group){ return ctx.apiFail('该群聊不存在或已被封禁'); } let index = group.group_users.findIndex(item=>item.user_id === current_user_id); // 验证 if(index === -1){ return ctx.apiFail('你不是该群的成员'); } // 组织格式 let from_name = group.group_users[index].nickname; let message = { id:(new Date()).getTime(), // 唯一id,后端生成唯一id from_avatar:ctx.authUser.avatar,// 发送者头像 from_name:from_name || ctx.authUser.nickname || ctx.authUser.username,// 发送者昵称 from_id:current_user_id, // 发送者id to_id,// 接收人id to_name:group.name,// 接收人/群 名称 to_avatar:group.name,// 接收人/群 头像 chat_type:'group', // 接收类型 type, // 消息类型 data, // 消息内容 options:{}, // 其他参数 create_time:(new Date()).getTime(),// 创建时间 isremove:0, // 是否撤回 group:group } // 推送消息 group.group_users.forEach(item=>{ if(item.user_id !== current_user_id){ ctx.sendAndSaveMessage(item.user_id,message); } }); ctx.apiSuccess(message); } // 获取离线消息 async getmessage(){ const { ctx, app, service } = this; let current_user_id = ctx.authUser.id; let key = 'getmessage_' + current_user_id; let list = await service.cache.getList(key); // 清除离线消息 await service.cache.remove(key); // 批量推送 list.forEach(async (message) => { let d = JSON.parse(message); ctx.sendAndSaveMessage(current_user_id, d.message, d.msg); }); } } module.exports = ChatController;
chat.js
import $U from "./util.js"; import $H from './request.js'; class chat { constructor(arg) { this.url = arg.url this.isOnline = false this.socket = null // 获取当前用户相关信息 let user = $U.getStorage('user'); this.user = user ? JSON.parse(user) : {}, // 初始化聊天对象 this.TO = false; // 连接和监听 if (this.user.token) { this.connectSocket() } } // 连接socket connectSocket() { console.log(this.user); this.socket = uni.connectSocket({ url: this.url + '?token=' + this.user.token, complete: () => {} }) // 监听连接成功 this.socket.onOpen(() => this.onOpen()) // 监听接收信息 this.socket.onMessage((res) => this.onMessage(res)) // 监听断开 this.socket.onClose(() => this.onClose()) // 监听错误 this.socket.onError(() => this.onError()) } // 监听打开 onOpen() { // 用户状态上线 this.isOnline = true; console.log('socket连接成功'); // 获取用户离线消息 this.getMessage(); } // 获取离线消息 getMessage(){ $H.post('/chat/getmessage'); } // 监听关闭 onClose() { // 用户下线 this.isOnline = false; this.socket = null; console.log('socket连接关闭'); } // 监听消息 onMessage(data) { console.log('监听消息', data); let res = JSON.parse(data.data) // console.log('监听接收消息',res) // 错误 switch (res.msg) { case 'fail': return uni.showToast({ title: res.data, icon: 'none' }); break; case 'recall': // 撤回消息 this.handleOnRecall(res.data) break; case 'updateApplyList': // 新的好友申请 $store.dispatch('getApply'); break; case 'moment': // 朋友圈更新 this.handleMoment(res.data) break; default: // 处理消息 this.handleOnMessage(res.data) break; } } // 处理消息 async handleOnMessage(message) { // 添加消息记录到本地存储中 let { data } = this.addChatDetail(message, false) // 更新会话列表 this.updateChatList(data, false) // 全局通知 uni.$emit('onMessage', data) // 消息提示 // this.messageNotice() } // 监听连接错误 onError() { // 用户下线 this.isOnline = false; this.socket = null; console.log('socket连接错误'); } // 关闭连接 close() { this.socket.close() } // 创建聊天对象 createChatObject(detail) { this.TO = detail; console.log('创建聊天对象', this.TO) } // 销毁聊天对象 destoryChatObject() { this.TO = false } // 组织发送信息格式 formatSendData(params) { return { id: 0, // 唯一id,后端生成,用于撤回指定消息 from_avatar: this.user.avatar, // 发送者头像 from_name: this.user.nickname || this.user.username, // 发送者昵称 from_id: this.user.id, // 发送者id to_id: params.to_id || this.TO.id, // 接收人/群 id to_name: params.to_name || this.TO.name, // 接收人/群 名称 to_avatar: params.to_avatar || this.TO.avatar, // 接收人/群 头像 chat_type: params.chat_type || this.TO.chat_type, // 接收类型 type: params.type, // 消息类型 data: params.data, // 消息内容 options: params.options ? params.options : {}, // 其他参数 create_time: (new Date()).getTime(), // 创建时间 isremove: 0, // 是否撤回 sendStatus: params.sendStatus ? params.sendStatus : "pending" // 发送状态,success发送成功,fail发送失败,pending发送中 } } // 发送信息 send(message, onProgress = false) { return new Promise((result, reject) => { // 添加消息历史记录 // this.addChatDetail(); let { k } = this.addChatDetail(message); // 更新会话列表 this.updateChatList(message); // 验证是否上线 if (!this.checkOnLine()) return reject('未上线'); // 上传文件 let isUpload = (message.type !== 'text' && message.type !== 'emoticon' && message.type !== 'card' && !message.data.startsWith('http://tangzhe123-com')) let uploadResult = '' if (isUpload) { uploadResult = $H.upload('/upload', { filePath: message.data }, onProgress) if (!uploadResult) { // 发送失败 message.sendStatus = 'fail' // 更新指定历史记录 this.updateChatDetail(message, k) // 断线重连提示 return reject(err) } } $H.post('/chat/send', { to_id: this.TO.id, type: message.type, chat_type: this.TO.chat_type, data: message.data, }).then(res => { // 发送成功 console.log('chat.js发送成功'); message.id = res.id message.sendStatus = 'success'; // 更新指定历史记录 this.updateChatDetail(message, k); result(res); }).catch(err => { // 发送失败 console.log('chat.js发送失败'); message.sendStatus = 'fail'; // 更新指定历史记录 this.updateChatDetail(message, k); // 断线重连提示 result(err); }); }) } // 验证是否上线 checkOnLine() { if (!this.isOnline) { // 断线重连提示 this.reconnectConfirm(); return false; } return true; } // 断线重连提示 reconnectConfirm() { uni.showModal({ title: '你已经断线,是否重新连接?', content: '重新连接', success: res => { if (res.confirm) { this.connectSocket(); } }, }); } // 添加聊天记录 addChatDetail(message, isSend = true) { console.log('添加到聊天记录'); // 获取对方id let id = message.chat_type === 'user' ? (isSend ? message.to_id : message.from_id) : message.to_id; if (!id) { return { data: {}, k: 0 } } // key值:chatDetail_当前用户id_会话类型_接收人/群id let key = `chatDetail_${this.user.id}_${message.chat_type}_${id}`; console.log(key); // 获取原来的聊天记录 let list = this.getChatdetail(key) console.log('获取原来的聊天记录', list); // 标识 message.k = 'k' + list.length list.push(message) // 加入存储 console.log('加入存储', message); this.setStorage(key, list); // 返回 return { data: message, k: message.k } } // 更新指定历史记录 async updateChatDetail(message, k, isSend = true) { // 获取对方id let id = message.chat_type === 'user' ? (isSend ? message.to_id : message.from_id) : message.to_id; // key值:chatDetail_当前用户id_会话类型_接收人/群id let key = `chatDetail_${this.user.id}_${message.chat_type}_${id}`; // 获取原来的聊天记录 let list = this.getChatdetail(key); // 根据k查找对应聊天记录 let index = list.findIndex(item => item.k === k); if (index === -1) return; list[index] = message; // 存储 this.setStorage(key, list); } // 获取聊天记录 getChatdetail(key = false) { key = key ? key : `chatDetail_${this.user.id}_${this.TO.chat_type}_${this.TO.id}`; return this.getStorage(key); } // 格式化会话最后一条消息显示 formatChatItemData(message, isSend) { let data = message.data switch (message.type) { case 'emoticon': data = '[表情]' break; case 'image': data = '[图片]' break; case 'audio': data = '[语音]' break; case 'video': data = '[视频]' break; case 'card': data = '[名片]' break; } data = isSend ? data : `${message.from_name}: ${data}` return data } // 更新会话列表 updateChatList(message, isSend = true) { // 获取本地存储会话列表 let list = this.getChatList() // 是否处于当前聊天中 let isCurrentChat = false // 接收人/群 id/头像/昵称 let id = 0 let avatar = '' let name = '' // 判断私聊还是群聊 if (message.chat_type === 'user') { // 私聊 // 聊天对象是否存在 isCurrentChat = this.TO ? (isSend ? this.TO.id === message.to_id : this.TO.id === message.from_id) : false id = isSend ? message.to_id : message.from_id avatar = isSend ? message.to_avatar : message.from_avatar name = isSend ? message.to_name : message.from_name } else { // 群聊 isCurrentChat = this.TO && (this.TO.id === message.to_id) id = message.to_id avatar = message.to_avatar name = message.to_name } // 会话是否存在 let index = list.findIndex(item => { return item.chat_type === message.chat_type && item.id === id }) // 最后一条消息展现形式 // let data = isSend ? message.data : `${message.from_name}: ${message.data}` let data = this.formatChatItemData(message, isSend) // 会话不存在,创建会话 // 未读数是否 + 1 let noreadnum = (isSend || isCurrentChat) ? 0 : 1 if (index === -1) { let chatItem = { id, // 接收人/群 id chat_type: message.chat_type, // 接收类型 user单聊 group群聊 avatar, // 接收人/群 头像 name, // 接收人/群 昵称 update_time: (new Date()).getTime(), // 最后一条消息的时间戳 data, // 最后一条消息内容 type: message.type, // 最后一条消息类型 noreadnum, // 未读数 istop: false, // 是否置顶 shownickname: false, // 是否显示昵称 nowarn: false, // 消息免打扰 strongwarn: false, // 是否开启强提醒 } // 群聊 if (message.chat_type === 'group' && message.group) { chatItem.shownickname = true chatItem.name = message.to_name chatItem = { ...chatItem, user_id: message.group.user_id, // 群管理员id remark: "", // 群公告 invite_confirm: 1, // 邀请确认 } } list.unshift(chatItem) } else { // 存在,更新会话 // 拿到当前会话 let item = list[index] // 更新该会话最后一条消息时间,内容,类型 item.update_time = (new Date()).getTime() item.name = message.to_name item.data = data item.type = message.type // 未读数更新 item.noreadnum += noreadnum // 置顶会话 list = this.listToFirst(list, index) } // 存储 let key = `chatlist_${this.user.id}` this.setStorage(key, list) // 更新未读数 this.updateBadge(list) // 通知更新vuex中的聊天会话列表 uni.$emit('onUpdateChatList', list) return list } // 获取聊天记录 getChatList(message, isSend = true) { // 获取本地存储会话列表 let list = this.getChatList(); // 是否处在当前聊天中 let isCurrentChat = false // 接收人/群 id/头像/昵称 let id = 0; let avatar = ''; let name = ''; // 判断私聊还是群聊 if (message.chat_type === 'user') { // 私聊 isCurrentChat = this.TO ? (isSend ? this.TO.id === message.to_id : message.from_id) : false; id = isSend ? message.to_id : message.from_id; avatar = isSend ? message.to_avatar : message.from_avatar name = isSend ? message.to_name : message.from_name } else { // 群聊 } // 会话是否存在 let index = list.findIndex(item => { return item.chat_type === message.chat_type && item.id === id; }) // 最后一条消息展现形式 let data = isSend ? message.data : `${message.from_name}:${message.data}`; // 未读数是否 +1 let noreadnum = (isSend || isCurrentChat) ? 0 : 1; // 会话不存在 创建会话 if (index === -1) { let chatItem = { id, // 接收人/群 id chat_type: message.chat_type, // 接收类型 user 单聊 group群聊 name, // 接收人/群 昵称 avatar, // 接收人/群 头像 update_time: (new Date()).getTime(), // 最后发送的时间 data, // 最后一条消息的内容 type: message.type, noreadnum: 1, // 未读数 istop: false, // 是否置顶 shownickname: false, // 是否显示昵称 nowarn: false, // 是否免打扰 strongwarn: false, // 是否强提醒 } if (message.chat_type === 'group') { chatItem = { ...chatItem, user_id: 0, // 管理员id remark: '', // 群公告 invite_confirm: 0 // 邀请确认 } } list.unshift(chatItem) } else { // 存在,更新会话 // 拿到当前会话 let item = list[index] // 更新改会话最后一条消息时间,内容,类型 item.update_time = (new Date()).getTime(); item.data = data; item.type = message.type; // 未读数更新 item.noreadnum += noreadnum // 置顶会话 list = this.listToFirst(list, index); } // 存储 let key = `chatlist_${this.user.id}`; this.setStorage(key, list); // 更新未读数 this.updateBadge(list); // 更新vuex中的聊天会话列表 uni.$emit('onUpdateChatList', list); console.log('获取到的会话列表:',list) return list; /** * { id:1, // 接收人/群 id chat_type:'user', // 接收类型 user 单聊 group群聊 name:'昵称', // 接收人/群 昵称 avatar:"/static/images/demo/demo6.jpg", // 接收人/群 头像 type:'',// 最后一条消息类型 update_time:1628069958, // 最后发送的时间 data:"你好啊,哈哈哈", // 最后一条消息的内容 noreadnum:1, // 未读数 istop:false, // 是否置顶 shownickname:0, // 是否显示昵称 nowarn:0, // 是否免打扰 strongwarn:0, // 是否强提醒 user_id://管理员id, remark:'公告', // 群公告 invite_confirm:0, // 邀请确认 }, **/ } // 获取本地存储会话列表 getChatList() { let key = `chatlist_${this.user.id}`; return this.getStorage(key); } // 读取会话 async readChatItem(id,chat_type){ // 获取所有会话列表 let list = this.getChatList(); // 找到当前会话 let index = list.findIndex(item=>item.id === id && item.chat_type === chat_type); if(index !== -1){ list[index].noreadnum = 0; let key = `chatlist_${this.user.id}`; this.setStorage(key,list); // 重新获取未读数 this.updateBadge(); // 更新会话列表状态 uni.$emit('onUpdateChatList',list); } } // 获取指定会话 getChatListItem(id,chat_type){ // 获取所有会话列表 let list = this.getChatList(); // 找到当前会话 let index = list.findIndex(item=>item.id === id && item.chat_type === chat_type); if(index !== -1){ return list[index]; } return false; } // 更新未读数 async updateBadge(list = false) { // 获取所有会话列表 list = list ? list : this.getChatList() // 统计所有未读数 let total = 0 list.forEach(item => { total += item.noreadnum }) // 设置底部导航栏角标 if (total > 0) { uni.setTabBarBadge({ index: 0, text: total <= 99 ? total.toString() : '99+' }) } else { uni.removeTabBarBadge({ index: 0 }) } uni.$emit('totalNoreadnum', total) } // 获取存储 getStorage(key) { let list = $U.getStorage(key); return list ? JSON.parse(list) : []; } // 设置存储 setStorage(key, value) { return $U.setStorage(key, JSON.stringify(value)); } // 数组置顶 listToFirst(arr, index) { if (index != 0) { arr.unshift(arr.splice(index, 1)[0]); } return arr; } } export default chat
92置于底部兼容H5端
chat.vue
<template> <view> <!-- 导航栏 --> <free-nav-bar :title="detail.name" :noreadnum="totalNoreadnum" showBack> <free-icon-button slot="right"><text class="iconfont font-md" @click="openChat"></text> </free-icon-button> </free-nav-bar> <!-- 聊天内容区域 --> <scroll-view scroll-y="true" class="bg-light position-fixed left-0 right-0" style="bottom: 105rpx;" :style="chatBodyBottom" :scroll-into-view="scrollIntoView" :show-scrollbar="false" :scroll-with-animation="true"> <!-- 聊天信息列表组件 --> <view v-for="(item,index) in list" :key="index" :id="'chatItem_'+index"> <free-chat-item :item="item" :index="index" ref="chatItem" :pretime=" index > 0 ? list[index-1].create_time : 0" @long="long" :shownickname="item.shownickname" @preview="previewImage"> </free-chat-item> </view> <!-- 聊天信息列表组件 --> <!-- <view v-for="(item,index) in list" :key="index"> <free-chat-item :item="item" :index="index" :pretime="index > 0 ? list[index-1].create_time : 0" @long="long" ref="chatItem" @preview="previewImage"></free-chat-item> </view> --> <!-- 右边 --> <!-- <view class="flex align-start justify-end position-relative"> <div class="bg-chat-item p-2 rounded mr-3" style="max-width:500rpx;"> <text class="font-md">你好你好你好你好你好你好你好你好你好你好你好</text> </div> <view class="iconfont font-md position-absolute chat-right-icon"><text class="text-chat-item iconfont font-md"></text></view> <free-avatar size="75" src="/static/images/demo/demo6.jpg"></free-avatar> </view> --> </scroll-view> <!-- 扩展菜单 --> <free-popup ref="action" bottom transformOrigin="center bottom" @hide="keyBoardHeight = 0" :mask="false"> <view style="height: 580rpx;" class="border-top border-light-secondary"> <swiper :indicator-dots="emoticonOrActionList.length>1" style="height:510rpx;"> <swiper-item class="row" v-for="(item,index) in emoticonOrActionList" :key="index"> <view class="col-3 flex flex-column align-center justify-center" style="height: 255rpx;" v-for="(item2,index2) in item" :key="index2" @click="actionEvent(item2)"> <image :src="item2.icon" mode="widthFix" style="width: 100rpx;height: 100rpx;"></image> <text class="font-sm text-muted mt-2">{{item2.name}}</text> </view> </swiper-item> </swiper> </view> </free-popup> <!-- 弹出层 --> <free-popup ref="extend" maskColor bottom :bodyWidth="240" :bodyHeight="geMenusHeight" :tabbarHeight="105"> <view class="flex flex-column" style="width:240rpx;" :style="getMenusStyle"> <view v-for="(item,index) in menusList" :key="index" class="flex-1 flex align-center" hover-class="bg-light" @click="clickEvent(item.event)"> <text class="font-md pl-3">{{item.name}}</text> </view> </view> </free-popup> <!-- #ifdef APP-PLUS-NVUE --> <div class="position-fixed top-0 right-0 left-0 bottom-0" v-if="mode==='action' || mode==='emoticon'" @click="clickPage" :style="'bottom:'+maskBottom+'px;'"></div> <!-- #endif --> <!-- 底部输入框 --> <view class="position-fixed left-0 right-0 border-top flex align-center" style="background-color: #F7F7F6;height: 105rpx;" :style="'bottom:'+keyBoardHeight+'px;'"> <free-icon-button @click="changeVoiceOrText"> <block v-if="mode === 'audio'"> <text class="iconfont font-lg"></text> </block> <block v-else> <text class="iconfont font-lg"></text> </block> </free-icon-button> <view class="flex-1"> <view v-if="mode==='audio'" class="rounded flex align-center justify-center" style="height: 80rpx;" :class="isRecording?'bg-hover-light':'bg-white'" @touchstart="voiceTouchStart" @touchend="voiceTouchEnd" @touchmove="voiceTouchMove" @touchcancel="voiceTouchCancel"> <text class="font">{{isRecording ? '松开 结束' : '按住 说话'}}</text> </view> <!-- 底部输入框 --> <textarea v-else fixed class="bg-white rounded p-2 font-md" style="height: 50rpx;max-width: 450rpx;" :adjust-position="false" v-model="text" @focus="mode = 'text'" /> <!-- @click="onInputClick" --> <!-- <textarea v-else class="bg-white rounded p-1 font-md" style="height: 50rpx;max-width: 500rpx;" :adjust-position="false" v-model="text" @focus="mode = 'text'" /> --> </view> <template v-if="text.length === 0"> <!-- 表情 --> <free-icon-button><text class="iconfont font-lg" @click="openActionOrEmoticon('emoticon')"></text></free-icon-button> <!-- 扩展菜单 --> <free-icon-button @click="openActionOrEmoticon('action')"><text class="iconfont font-lg"></text> </free-icon-button> </template> <view v-else class="flex-shrink"> <!-- 发送按钮 --> <!-- <view class="main-bg-color rounded flex align-center justify-center mr-2 px-2 pt-4" style="height: 70rpx;" @click="send('text')"> 发送 </view> --> <free-main-button name="发送" @click="send('text')"></free-main-button> </view> </view> <!-- 录音提示 --> <view v-if="isRecording" class="position-fixed top-0 left-0 right-0 flex align-center justify-center" style="bottom: 105rpx;"> <view class="rounded flex flex-column align-center justify-center" style="width: 360rpx;height: 360rpx;background-color: rgba(0,0,0,0.5);"> <image src="/static/images/audio/audio/recording.gif" style="width: 150rpx;height: 150rpx;"></image> <text class="font text-white mt-3">{{unRecord?'松开手指,取消发送':'手指上滑,取消发送'}}</text> </view> </view> </view> </template> <script> // #ifdef APP-NVUE const domModule = weex.requireModule('dom'); // #endif import freeNavBar from '@/components/free-ui/free-nav-bar.vue'; import freeIconButton from '@/components/free-ui/free-icon-button.vue'; import freeChatItem from '@/components/free-ui/free-chat-item.vue'; import freePopup from '@/components/free-ui/free-popup.vue'; import freeMainButton from '@/components/free-ui/free-main-button.vue'; import { mapState, mapMutations } from 'vuex'; import auth from '@/common/mixin/auth.js'; export default { mixins: [auth], components: { freeNavBar, freeIconButton, freeChatItem, freePopup, freeMainButton }, watch: { mode(newValue, oldValue) { if (newValue !== 'action' && newValue !== 'emoticon') { this.$refs.action.hide(); } if (newValue !== 'text') { uni.hideKeyboard() } } }, // 生命周期 mounted() { this.statusBarHeight = 0; // 获取任务栏高度 // #ifdef APP-PLUS-NVUE this.statusBarHeight = plus.navigator.getStatusbarHeight() // #endif this.navBarHeight = this.statusBarHeight + uni.upx2px(90) // 监听键盘高度变化 uni.onKeyboardHeightChange((res) => { if (this.mode !== 'action' && this.mode !== 'emoticon') { this.keyBoardHeight = res.height; } if (this.keyBoardHeight > 0) { this.pageToBottom() } }) // 注册发送音频事件 this.regSendVoiceEvent((url) => { if (!this.unRecord) { // 发送 this.send('audio', url, { time: this.RecordTime }) } }); }, onLoad(e) { if (!e.params) { return this.backToast(); } // 初始化 this.__init(); this.detail = JSON.parse(e.params) // 创建聊天对象 this.chat.createChatObject(this.detail) // 获取历史记录 this.list = this.chat.getChatdetail() // 监听接收聊天信息 uni.$on('onMessage', (message) => { if ((message.from_id === this.detail.id && message.chat_type === 'user') || (message.chat_type === 'group' && this.detail.id)) { this.list.push(message); // 置于顶部 this.pageToBottom(); } }) // uni.$on('updateHistory', this.updateHistory) }, destroyed() { // 销毁聊天对象 this.chat.destoryChatObject(); // 销毁监听接收聊天消息 uni.$off('onMessage', () => {}) }, computed: { ...mapState({ RECORD: state => state.audio.RECORD, RecordTime: state => state.audio.RecordTime, chat: state => state.user.chat, totalNoreadnum: state => state.user.totalNoreadnum }), // 所有信息的图片地址 imageList() { var arr = []; this.list.forEach((item) => { if (item.type === 'emoticon' || item.type === 'image') { arr.push(item.data) } }) return arr; }, // 获取蒙版的位置 maskBottom() { return this.keyBoardHeight + uni.upx2px(105) }, // 动态获取菜单高度 geMenusHeight() { let H = 100; return this.menus.length * H; }, // 获取菜单的样式 getMenusStyle() { return `height:${this.geMenusHeight}rpx;`; }, // 是否是本人 isdoSelf() { // 获取本人id(假设拿到了) let id = 1 let user_id = this.propIndex > -1 ? this.list[this.propIndex].user_id : 0 return user_id === id }, // 获取操作菜单 menusList() { return this.menus.filter(v => { if (v.name === '撤回' && this.isDoSelf) { return false; } else { return true; } }) }, // 聊天区域bottom chatBodyBottom() { return `bottom:${uni.upx2px(105) + this.keyBoardHeight}px;top:${this.navBarHeight}px`; }, // 获取操作或者表情列表 emoticonOrActionList() { if (this.mode === 'emoticon' || this.mode === 'action') { return this[this.mode + 'List']; } else { return []; } } }, data() { return { // 模式 text输入文字 emoticon表情 action操作 audio音频 mode: 'text', scrollIntoView: "", // 扩展菜单列表 actionList: [ [{ name: "相册", icon: "/static/images/extends/pic.png", event: "uploadImage" }, { name: "拍摄", icon: "/static/images/extends/video.png", event: "uploadVideo" }, { name: "收藏", icon: "/static/images/extends/shoucan.png", event: "" }, { name: "名片", icon: "/static/images/extends/man.png", event: "" }, { name: "语音通话", icon: "/static/images/extends/phone.png", event: "" }, { name: "位置", icon: "/static/images/extends/path.png", event: "" } ] ], // 表情包 emoticonList: [ [{ name: "沮丧", icon: "/static/images/emoticon/5497/0.gif", event: "" }, { name: "沮丧", icon: "/static/images/emoticon/5497/0.gif", event: "" } ] ], // 输入文字 text: "", // 音频录制中 isRecording: false, // 取消录音 unRecord: false, // 当前气泡索引 keyBoardHeight: 0, propIndex: 1, navBarHeight: 0, list: [], RecordingStartY: 0, detail: { id: 0, name: '', avatar: '', chat_type: 'user' }, menus: [{ name: '复制', event: "" }, { name: '发送给朋友', event: "" }, { name: '收藏', event: "" }, { name: '删除', event: "" }, { name: '多选', event: "" }, { name: '撤回', event: "removeChatItem" } ], } }, methods: { ...mapMutations(['regSendVoiceEvent']), __init() { var total = 24; var page = Math.ceil(total / 8); var arr = []; for (var i = 0; i < page; i++) { var start = i * 8; arr[i] = []; for (var j = 0; j <= 8; j++) { arr[i].push({ name: '表情' + (start + j), icon: '/static/images/emoticon/5497/' + (start + j) + '.gif', event: 'sendEmoticon' }) } } this.emoticonList = arr; }, // 点击区域 clickPage() { // 隐藏操作菜单 this.$refs.action.hide(); this.mode = 'text'; }, // 事件分发 actionEvent(e) { switch (e.event) { case 'uploadImage': uni.chooseImage({ count: 9, //默认9 success: (res) => { res.tempFilePaths.forEach((item) => { // 发送到服务器 // 渲染到页面 this.send('image', item); }) } }); break; case 'sendEmoticon': // 发送表情包 this.send('emoticon', e.icon); break; case 'uploadVideo': // 发送短视频 uni.chooseVideo({ maxDuration: 10, success: (res) => { // 渲染页面 this.send('video', res.tempFilePath); // 发送到服务端(获取视频封面,返回url) // 修改本地发送状态 } }) break; } }, // 打开扩展菜单或者表情包 openActionOrEmoticon(model = 'action') { this.mode = model; this.$refs.action.show(); uni.hideKeyboard(); this.keyBoardHeight = uni.upx2px(580); }, // 发送 send(type, data = '', options = {}) { // 组织数据格式 switch (type) { case 'text': data = data || this.text break; } let message = this.chat.formatSendData({ type, data, options }) // 渲染到页面 console.log('this.list:', this.list) let index = this.list.length this.list.push(message) // 监听上传进度 let onProgress = false if (message.type !== 'text' && message.type !== 'emoticon' && message.type !== 'card' && !message.data .startsWith('http')) { onProgress = (progress) => { console.log('上传进度:', progress); } } // 发送到服务端 this.chat.send(message, onProgress).then(res => { console.log(res); // 发送成功 this.list[index].id = res.id this.list[index].sendStatus = 'success' }).catch(err => { // 发送失败 this.list[index].sendStatus = 'fail' console.log(err); }) // 发送文字成功,清空输入框 if (type === 'text') { this.text = '' } // 置于底部 this.pageToBottom() }, // 长按消息气泡 long(e) { this.propIndex = e.index; this.$refs.extend.show(e.x, e.y); }, // 回到底部 pageToBottom() { // #ifdef APP-PLUS-NVUE let chatItem = this.$refs.chatItem let lastIndex = chatItem.length > 0 ? chatItem.length - 1 : 0 if (chatItem[lastIndex]) { dom.scrollToElement(chatItem[lastIndex], {}) } // #endif // #ifndef APP-NVUE setTimeout(() => { let lastIndex = this.list.length - 1 this.scrollIntoView = 'chatItem_' + lastIndex }, 300) // #endif }, // 操作菜单方法分发 clickEvent(event) { switch (event) { case 'removeChatItem': // 撤回消息 // 拿到当前被操作的信息 this.list[this.propIndex].isremove = true; break; default: break; } // 关闭菜单 this.$refs.extend.hide(); }, // 预览图片 previewImage(url) { // 预览图片 uni.previewImage({ current: url, urls: this.imageList, indicator: "default", longPressActions: { itemList: ['发送给朋友', '保存图片', '收藏'], success: function(data) { console.log('选中了第' + (data.tapIndex + 1) + '个按钮,第' + (data.index + 1) + '张图片'); }, fail: function(err) { console.log(err.errMsg); } } }); }, // 切换音频录制喝文本输入 changeVoiceOrText() { this.mode = this.mode !== 'audio' ? 'audio' : 'text'; }, // 录音相关 // 录音开始 voiceTouchStart(e) { this.isRecording = true; this.RecordingStartY = e.changedTouches[0].screenY; this.unRecord = false; // 开始录音 this.RECORD.start({ format: 'mp3' }) }, // 录音结束 voiceTouchEnd() { this.isRecording = false; // 停止录音 this.RECORD.stop(); }, // 录音打断 voiceTouchCancel() { this.isRecording = false; this.unRecord = true; // 停止录音 this.RECORD.stop(); }, voiceTouchMove(e) { var Y = Math.abs(e.changedTouches[0].screenY - this.RecordingStartY); this.unRecord = (Y >= 80); }, // 打开聊天信息 openChat() { uni.navigateTo({ url: '/pages/chat/chat-set/chat-set?params=' + JSON.stringify({ id: this.detail.id, chat_type: this.detail.chat_type }), }); } } } </script> <style> </style>
chat.js
import $U from "./util.js"; import $H from './request.js'; class chat { constructor(arg) { this.url = arg.url this.isOnline = false this.socket = null // 获取当前用户相关信息 let user = $U.getStorage('user'); this.user = user ? JSON.parse(user) : {}, // 初始化聊天对象 this.TO = false; // 连接和监听 if (this.user.token) { this.connectSocket() } } // 连接socket connectSocket() { console.log(this.user); this.socket = uni.connectSocket({ url: this.url + '?token=' + this.user.token, complete: () => {} }) // 监听连接成功 this.socket.onOpen(() => this.onOpen()) // 监听接收信息 this.socket.onMessage((res) => this.onMessage(res)) // 监听断开 this.socket.onClose(() => this.onClose()) // 监听错误 this.socket.onError(() => this.onError()) } // 监听打开 onOpen() { // 用户状态上线 this.isOnline = true; console.log('socket连接成功'); // 获取用户离线消息 this.getMessage(); } // 获取离线消息 getMessage(){ $H.post('/chat/getmessage'); } // 监听关闭 onClose() { // 用户下线 this.isOnline = false; this.socket = null; console.log('socket连接关闭'); } // 监听消息 onMessage(data) { console.log('监听消息', data); let res = JSON.parse(data.data) // console.log('监听接收消息',res) // 错误 switch (res.msg) { case 'fail': return uni.showToast({ title: res.data, icon: 'none' }); break; case 'recall': // 撤回消息 this.handleOnRecall(res.data) break; case 'updateApplyList': // 新的好友申请 $store.dispatch('getApply'); break; case 'moment': // 朋友圈更新 this.handleMoment(res.data) break; default: // 处理消息 this.handleOnMessage(res.data) break; } } // 处理消息 async handleOnMessage(message) { // 添加消息记录到本地存储中 let { data } = this.addChatDetail(message, false) // 更新会话列表 this.updateChatList(data, false) // 全局通知 uni.$emit('onMessage', data) // 消息提示 // this.messageNotice() } // 监听连接错误 onError() { // 用户下线 this.isOnline = false; this.socket = null; console.log('socket连接错误'); } // 关闭连接 close() { this.socket.close() } // 创建聊天对象 createChatObject(detail) { this.TO = detail; console.log('创建聊天对象', this.TO) } // 销毁聊天对象 destoryChatObject() { this.TO = false } // 组织发送信息格式 formatSendData(params) { return { id: 0, // 唯一id,后端生成,用于撤回指定消息 from_avatar: this.user.avatar, // 发送者头像 from_name: this.user.nickname || this.user.username, // 发送者昵称 from_id: this.user.id, // 发送者id to_id: params.to_id || this.TO.id, // 接收人/群 id to_name: params.to_name || this.TO.name, // 接收人/群 名称 to_avatar: params.to_avatar || this.TO.avatar, // 接收人/群 头像 chat_type: params.chat_type || this.TO.chat_type, // 接收类型 type: params.type, // 消息类型 data: params.data, // 消息内容 options: params.options ? params.options : {}, // 其他参数 create_time: (new Date()).getTime(), // 创建时间 isremove: 0, // 是否撤回 sendStatus: params.sendStatus ? params.sendStatus : "pending" // 发送状态,success发送成功,fail发送失败,pending发送中 } } // 发送信息 send(message, onProgress = false) { return new Promise((result, reject) => { // 添加消息历史记录 // this.addChatDetail(); let { k } = this.addChatDetail(message); // 更新会话列表 this.updateChatList(message); // 验证是否上线 if (!this.checkOnLine()) return reject('未上线'); // 上传文件 let isUpload = (message.type !== 'text' && message.type !== 'emoticon' && message.type !== 'card' && !message.data.startsWith('http://tangzhe123-com')) let uploadResult = '' if (isUpload) { uploadResult = $H.upload('/upload', { filePath: message.data }, onProgress) if (!uploadResult) { // 发送失败 message.sendStatus = 'fail' // 更新指定历史记录 this.updateChatDetail(message, k) // 断线重连提示 return reject(err) } } $H.post('/chat/send', { to_id: this.TO.id, type: message.type, chat_type: this.TO.chat_type, data: message.data, }).then(res => { // 发送成功 console.log('chat.js发送成功'); message.id = res.id message.sendStatus = 'success'; // 更新指定历史记录 this.updateChatDetail(message, k); result(res); }).catch(err => { // 发送失败 console.log('chat.js发送失败'); message.sendStatus = 'fail'; // 更新指定历史记录 this.updateChatDetail(message, k); // 断线重连提示 result(err); }); }) } // 验证是否上线 checkOnLine() { if (!this.isOnline) { // 断线重连提示 this.reconnectConfirm(); return false; } return true; } // 断线重连提示 reconnectConfirm() { uni.showModal({ title: '你已经断线,是否重新连接?', content: '重新连接', success: res => { if (res.confirm) { this.connectSocket(); } }, }); } // 添加聊天记录 addChatDetail(message, isSend = true) { console.log('添加到聊天记录'); // 获取对方id let id = message.chat_type === 'user' ? (isSend ? message.to_id : message.from_id) : message.to_id; if (!id) { return { data: {}, k: 0 } } // key值:chatDetail_当前用户id_会话类型_接收人/群id let key = `chatDetail_${this.user.id}_${message.chat_type}_${id}`; console.log(key); // 获取原来的聊天记录 let list = this.getChatdetail(key) console.log('获取原来的聊天记录', list); // 标识 message.k = 'k' + list.length list.push(message) // 加入存储 console.log('加入存储', message); this.setStorage(key, list); // 返回 return { data: message, k: message.k } } // 更新指定历史记录 async updateChatDetail(message, k, isSend = true) { // 获取对方id let id = message.chat_type === 'user' ? (isSend ? message.to_id : message.from_id) : message.to_id; // key值:chatDetail_当前用户id_会话类型_接收人/群id let key = `chatDetail_${this.user.id}_${message.chat_type}_${id}`; // 获取原来的聊天记录 let list = this.getChatdetail(key); // 根据k查找对应聊天记录 let index = list.findIndex(item => item.k === k); if (index === -1) return; list[index] = message; // 存储 this.setStorage(key, list); } // 获取聊天记录 getChatdetail(key = false) { key = key ? key : `chatDetail_${this.user.id}_${this.TO.chat_type}_${this.TO.id}`; return this.getStorage(key); } // 格式化会话最后一条消息显示 formatChatItemData(message, isSend) { let data = message.data switch (message.type) { case 'emoticon': data = '[表情]' break; case 'image': data = '[图片]' break; case 'audio': data = '[语音]' break; case 'video': data = '[视频]' break; case 'card': data = '[名片]' break; } data = isSend ? data : `${message.from_name}: ${data}` return data } // 更新会话列表 updateChatList(message, isSend = true) { // 获取本地存储会话列表 let list = this.getChatList() // 是否处于当前聊天中 let isCurrentChat = false // 接收人/群 id/头像/昵称 let id = 0 let avatar = '' let name = '' // 判断私聊还是群聊 if (message.chat_type === 'user') { // 私聊 // 聊天对象是否存在 isCurrentChat = this.TO ? (isSend ? this.TO.id === message.to_id : this.TO.id === message.from_id) : false id = isSend ? message.to_id : message.from_id avatar = isSend ? message.to_avatar : message.from_avatar name = isSend ? message.to_name : message.from_name } else { // 群聊 isCurrentChat = this.TO && (this.TO.id === message.to_id) id = message.to_id avatar = message.to_avatar name = message.to_name } // 会话是否存在 let index = list.findIndex(item => { return item.chat_type === message.chat_type && item.id === id }) // 最后一条消息展现形式 // let data = isSend ? message.data : `${message.from_name}: ${message.data}` let data = this.formatChatItemData(message, isSend) // 会话不存在,创建会话 // 未读数是否 + 1 let noreadnum = (isSend || isCurrentChat) ? 0 : 1 if (index === -1) { let chatItem = { id, // 接收人/群 id chat_type: message.chat_type, // 接收类型 user单聊 group群聊 avatar, // 接收人/群 头像 name, // 接收人/群 昵称 update_time: (new Date()).getTime(), // 最后一条消息的时间戳 data, // 最后一条消息内容 type: message.type, // 最后一条消息类型 noreadnum, // 未读数 istop: false, // 是否置顶 shownickname: false, // 是否显示昵称 nowarn: false, // 消息免打扰 strongwarn: false, // 是否开启强提醒 } // 群聊 if (message.chat_type === 'group' && message.group) { chatItem.shownickname = true chatItem.name = message.to_name chatItem = { ...chatItem, user_id: message.group.user_id, // 群管理员id remark: "", // 群公告 invite_confirm: 1, // 邀请确认 } } list.unshift(chatItem) } else { // 存在,更新会话 // 拿到当前会话 let item = list[index] // 更新该会话最后一条消息时间,内容,类型 item.update_time = (new Date()).getTime() item.name = message.to_name item.data = data item.type = message.type // 未读数更新 item.noreadnum += noreadnum // 置顶会话 list = this.listToFirst(list, index) } // 存储 let key = `chatlist_${this.user.id}` this.setStorage(key, list) // 更新未读数 this.updateBadge(list) // 通知更新vuex中的聊天会话列表 uni.$emit('onUpdateChatList', list) return list } // 获取聊天记录 getChatList(message, isSend = true) { // 获取本地存储会话列表 let list = this.getChatList(); // 是否处在当前聊天中 let isCurrentChat = false // 接收人/群 id/头像/昵称 let id = 0; let avatar = ''; let name = ''; // 判断私聊还是群聊 if (message.chat_type === 'user') { // 私聊 isCurrentChat = this.TO ? (isSend ? this.TO.id === message.to_id : message.from_id) : false; id = isSend ? message.to_id : message.from_id; avatar = isSend ? message.to_avatar : message.from_avatar name = isSend ? message.to_name : message.from_name } else { // 群聊 } // 会话是否存在 let index = list.findIndex(item => { return item.chat_type === message.chat_type && item.id === id; }) // 最后一条消息展现形式 let data = isSend ? message.data : `${message.from_name}:${message.data}`; // 未读数是否 +1 let noreadnum = (isSend || isCurrentChat) ? 0 : 1; // 会话不存在 创建会话 if (index === -1) { let chatItem = { id, // 接收人/群 id chat_type: message.chat_type, // 接收类型 user 单聊 group群聊 name, // 接收人/群 昵称 avatar, // 接收人/群 头像 update_time: (new Date()).getTime(), // 最后发送的时间 data, // 最后一条消息的内容 type: message.type, noreadnum: 1, // 未读数 istop: false, // 是否置顶 shownickname: false, // 是否显示昵称 nowarn: false, // 是否免打扰 strongwarn: false, // 是否强提醒 } if (message.chat_type === 'group') { chatItem = { ...chatItem, user_id: 0, // 管理员id remark: '', // 群公告 invite_confirm: 0 // 邀请确认 } } list.unshift(chatItem) } else { // 存在,更新会话 // 拿到当前会话 let item = list[index] // 更新改会话最后一条消息时间,内容,类型 item.update_time = (new Date()).getTime(); item.data = data; item.type = message.type; // 未读数更新 item.noreadnum += noreadnum // 置顶会话 list = this.listToFirst(list, index); } // 存储 let key = `chatlist_${this.user.id}`; this.setStorage(key, list); // 更新未读数 this.updateBadge(list); // 更新vuex中的聊天会话列表 uni.$emit('onUpdateChatList', list); console.log('获取到的会话列表:',list) return list; /** * { id:1, // 接收人/群 id chat_type:'user', // 接收类型 user 单聊 group群聊 name:'昵称', // 接收人/群 昵称 avatar:"/static/images/demo/demo6.jpg", // 接收人/群 头像 type:'',// 最后一条消息类型 update_time:1628069958, // 最后发送的时间 data:"你好啊,哈哈哈", // 最后一条消息的内容 noreadnum:1, // 未读数 istop:false, // 是否置顶 shownickname:0, // 是否显示昵称 nowarn:0, // 是否免打扰 strongwarn:0, // 是否强提醒 user_id://管理员id, remark:'公告', // 群公告 invite_confirm:0, // 邀请确认 }, **/ } // 获取本地存储会话列表 getChatList() { let key = `chatlist_${this.user.id}`; return this.getStorage(key); } // 读取会话 async readChatItem(id,chat_type){ // 获取所有会话列表 let list = this.getChatList(); // 找到当前会话 let index = list.findIndex(item=>item.id === id && item.chat_type === chat_type); if(index !== -1){ list[index].noreadnum = 0; let key = `chatlist_${this.user.id}`; this.setStorage(key,list); // 重新获取未读数 this.updateBadge(); // 更新会话列表状态 uni.$emit('onUpdateChatList',list); } } // 获取指定会话 getChatListItem(id,chat_type){ // 获取所有会话列表 let list = this.getChatList(); // 找到当前会话 let index = list.findIndex(item=>item.id === id && item.chat_type === chat_type); if(index !== -1){ return list[index]; } return false; } // 更新未读数 async updateBadge(list = false) { // 获取所有会话列表 list = list ? list : this.getChatList() // 统计所有未读数 let total = 0 list.forEach(item => { total += item.noreadnum }) // 设置底部导航栏角标 if (total > 0) { uni.setTabBarBadge({ index: 0, text: total <= 99 ? total.toString() : '99+' }) } else { uni.removeTabBarBadge({ index: 0 }) } uni.$emit('totalNoreadnum', total) } // 获取存储 getStorage(key) { let list = $U.getStorage(key); return list ? JSON.parse(list) : []; } // 设置存储 setStorage(key, value) { return $U.setStorage(key, JSON.stringify(value)); } // 数组置顶 listToFirst(arr, index) { if (index != 0) { arr.unshift(arr.splice(index, 1)[0]); } return arr; } } export default chat