66 Commits

Author SHA1 Message Date
bob
bdb221f123 登录页面不显示备案信息 2025-04-02 09:10:43 +08:00
bob
5a5bea368c 服务异常 不弹窗 2025-04-02 08:53:00 +08:00
bob
1139b1b14a 更新v1.1 readme 2025-04-02 08:46:40 +08:00
bob
3db0ba1cee 通讯录-联系人不显示最近一条消息 2025-04-01 21:57:08 +08:00
bob
9988e77455 样式微调 2025-04-01 21:18:31 +08:00
bob
c26d0ff4ed RECORDING显示为语音 2025-04-01 17:56:29 +08:00
bob
c4e24cb0ad bug fixed 2025-04-01 17:53:35 +08:00
bob
e2c2eba8d1 v1.1.0发布 2025-04-01 10:35:00 +08:00
bob
da22ce103b bug fixed 2025-03-31 21:59:53 +08:00
bob
c178b98cf7 bug fixed 2025-03-31 21:21:37 +08:00
bob
336d8a39d2 ReachBottom时等list初始化好 2025-03-29 22:54:46 +08:00
bob
48387b74c7 store命名优化 2025-03-29 21:59:35 +08:00
bob
0b39027d9d 文档的图标重构 2025-03-29 21:01:05 +08:00
bob
f976934b9d 修改头像上报avatarId 2025-03-29 12:35:58 +08:00
bob
3524649a10 过早释放了资源 2025-03-29 12:11:04 +08:00
bob
0cea21d51e 样式微调 2025-03-27 14:40:55 +08:00
bob
d4a5d72cba locateSession延迟定位 2025-03-27 12:20:45 +08:00
bob
153d007e8e 样式微调 2025-03-27 11:00:22 +08:00
bob
2565828114 样式微调 2025-03-27 09:59:37 +08:00
bob
f8a1a16513 bug fixed 2025-03-27 09:50:43 +08:00
bob
7532cda953 切换录音时保存草稿 2025-03-27 09:40:54 +08:00
bob
12a1e27081 表情窗口可以用Esc关闭 2025-03-27 09:21:03 +08:00
bob
99df91750f 增加文档类型 2025-03-27 09:15:26 +08:00
bob
d8697b045c 元素结构调整 2025-03-27 09:12:53 +08:00
bob
7d909640ff bug fixed 2025-03-26 22:52:32 +08:00
bob
87fdf836e0 切换session要取消录音发送 2025-03-26 22:45:17 +08:00
bob
8fd384e1eb 切换session重置麦克风输入状态 2025-03-26 22:16:51 +08:00
bob
90909f078f 支持document上传 2025-03-26 22:05:12 +08:00
bob
5aa13e0c68 样式bug fixed 2025-03-26 17:10:06 +08:00
bob
5ddd002de3 视频提供下载按钮,屏蔽快捷键 2025-03-26 17:05:51 +08:00
bob
cb7d3def41 多媒体消息格式重构 2025-03-26 16:56:52 +08:00
bob
e62ede16dc bug fixed 2025-03-26 12:25:31 +08:00
bob
155ee79011 支持上传视频 2025-03-26 12:14:15 +08:00
bob
56fb3bcdf6 本地仅用minIO开发 2025-03-26 09:14:03 +08:00
bob
7280721a4f 图标替换 2025-03-25 17:33:02 +08:00
bob
a918678543 文件方式上传音频消息重做 2025-03-25 16:58:45 +08:00
bob
658822588a 文件名重构 2025-03-25 15:43:15 +08:00
bob
e3e1158486 增加上传图片的按钮 2025-03-25 15:28:05 +08:00
bob
7362c5e451 以文件方式上传图片,显示文件名和大小 2025-03-25 12:16:39 +08:00
bob
edcf8efe34 音频播放异常保护 2025-03-25 09:46:58 +08:00
bob
e8cdf6de96 代码重构 2025-03-25 09:03:04 +08:00
bob
dced8fcb4d 变量命名优化 2025-03-24 21:18:09 +08:00
bob
8f9b748560 样式调整 2025-03-24 20:52:22 +08:00
bob
a4bb96a6bc 性能优化 2025-03-24 17:55:00 +08:00
bob
0117d1adf0 支持发音频消息 2025-03-24 17:46:54 +08:00
bob
86e6adb7b8 语音通话的图标修改 2025-03-23 21:53:16 +08:00
bob
a332753328 以文件方式上传音频 2025-03-23 12:30:30 +08:00
bob
4d57c5200d 支持上传音频 2025-03-21 16:49:07 +08:00
bob
6370d89517 样式微调 2025-03-21 10:11:22 +08:00
bob
4ebbbe5773 样式微调 2025-03-21 10:08:25 +08:00
bob
b0f17bac22 头像给默认值 2025-03-21 09:57:17 +08:00
bob
e73ba1185d session中可以显示[未送达] 2025-03-21 09:41:02 +08:00
bob
7b133ce74e handleSendMessage使用局部的SessionId变量 2025-03-21 09:24:42 +08:00
bob
c7634c1c0a DELIVERED消息回调函数不能立即删除,需要考虑消息重发 2025-03-20 17:50:12 +08:00
bob
c8bfd3e070 代码重构 2025-03-20 16:12:51 +08:00
bob
9ac47d90d8 消息内容动态渲染后的位置处理 2025-03-20 15:51:13 +08:00
bob
d35ac6453b 拆分消息类型 2025-03-19 16:33:15 +08:00
bob
184489e95c 消息动态渲染优化 2025-03-19 11:28:05 +08:00
bob
8cc5d35a10 样式优化 2025-03-19 11:13:20 +08:00
bob
b926ad7ce4 头像更新联动bug 2025-03-19 11:10:40 +08:00
bob
b363564229 样式微调 2025-03-19 10:50:53 +08:00
bob
115fed7fac messageItem中动态加载图片后重新划到最底部 2025-03-18 22:58:16 +08:00
bob
e8a9a5d1de 点击文件上传图片添加loading 2025-03-18 21:29:46 +08:00
bob
8662f377a6 图片渲染启用懒加载 2025-03-18 21:29:15 +08:00
bob
1e4b4a5132 点击文件可以发送图片 2025-03-18 21:21:12 +08:00
bob
9d741fae59 聊天工具栏抽组件 2025-03-18 16:30:41 +08:00
86 changed files with 2019 additions and 519 deletions

View File

@@ -24,19 +24,15 @@ Open AnyLink是一款面向企业的IM即时通讯解决方案旨在帮助企
- [x] 单聊
- [x] 群聊
#### 通话功能
- [ ] 语音通话
- [ ] 视频通话
#### 消息类型
- [x] 文本
- [x] 表情
- [x] 图片
- [ ]
- [ ]
- [ ] 文件
- [x]
- [x]
- [x] 视频
- [x] 文件
#### 消息功能
@@ -61,8 +57,7 @@ Open AnyLink是一款面向企业的IM即时通讯解决方案旨在帮助企
- [x] 群公告
- [x] 群系统消息
- [x] 群转让
- [ ] 万人大
- [ ] 团队组织群
- [ ] 组织
- [ ] 公开群
#### 通讯录功能
@@ -73,6 +68,11 @@ Open AnyLink是一款面向企业的IM即时通讯解决方案旨在帮助企
- [x] 群分组
- [ ] 组织管理
#### 通话功能
- [ ] 语音通话
- [ ] 视频通话
#### 会议功能
- [ ] 语音会议
@@ -93,6 +93,7 @@ Open AnyLink是一款面向企业的IM即时通讯解决方案旨在帮助企
- [ ] 大文件传输
- [ ] 待办事项
- [ ] 管理控制台
## 项目预览
@@ -100,6 +101,8 @@ Open AnyLink是一款面向企业的IM即时通讯解决方案旨在帮助企
![img_2.png](doc/image/img_2.png)
![img_5.png](doc/image/img_5.png)
![img_3.png](doc/image/img_3.png)
![img_4.png](doc/image/img_4.png)
@@ -123,7 +126,7 @@ Open AnyLink是一款面向企业的IM即时通讯解决方案旨在帮助企
<img src="doc/image/qq_group.jpg" alt="QQ交流社群" width="30%" />
<img src="doc/image/wx_group.png" alt="微信交流社群" width="30%" />
QQ群号825505574微信群有效期3月24
QQ群号825505574微信群有效期4月9
## 如何联系我们

Binary file not shown.

Before

Width:  |  Height:  |  Size: 579 KiB

After

Width:  |  Height:  |  Size: 488 KiB

BIN
doc/image/img_5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 534 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 121 KiB

View File

@@ -1,6 +1,6 @@
{
"name": "anylink-web",
"version": "1.0.0",
"version": "1.1.0",
"private": true,
"type": "module",
"scripts": {
@@ -26,8 +26,10 @@
"protobufjs": "^7.4.0",
"uuid": "^10.0.0",
"vue": "^3.4.29",
"vue-audio-visual": "^3.0.10",
"vue-cropper": "^1.1.1",
"vue-router": "^4.3.3"
"vue-router": "^4.3.3",
"xgplayer": "^3.0.21"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.8.0",

View File

@@ -7,3 +7,15 @@ export const mtsUploadService = (obj) => {
export const mtsImageService = (obj) => {
return request.get('/mts/image', { params: obj })
}
export const mtsAudioService = (obj) => {
return request.get('/mts/audio', { params: obj })
}
export const mtsVideoService = (obj) => {
return request.get('/mts/video', { params: obj })
}
export const mtsDocumentService = (obj) => {
return request.get('/mts/document', { params: obj })
}

View File

@@ -1,5 +1,5 @@
import request from '@/js/utils/request'
import { userStore } from '@/stores'
import { useUserStore } from '@/stores'
import { CLIENT_TYPE, CLIENT_NAME, CLIENT_VERSION } from '@/const/userConst'
import { encryptPasswordObj, encryptDoublePasswordObj } from '@/js/utils/crypto'
@@ -7,7 +7,7 @@ export const userRegisterService = async ({ account, nickName, password }) => {
const obj = await encryptPasswordObj(account, password)
return request.post('/user/register', {
account: account,
clientId: userStore().clientId,
clientId: useUserStore().clientId,
nickName: nickName,
...obj
})
@@ -17,7 +17,7 @@ export const userNonceService = ({ account }) => {
return request.get('/user/nonce', {
params: {
account: account,
clientId: userStore().clientId
clientId: useUserStore().clientId
}
})
}
@@ -34,7 +34,7 @@ export const userForgetService = async (obj) => {
const passwordObjObj = await encryptPasswordObj(obj.account, obj.password)
delete obj.password
return request.post('/user/forget', {
clientId: userStore().clientId,
clientId: useUserStore().clientId,
...obj,
...passwordObjObj
})
@@ -44,7 +44,7 @@ export const userLoginService = async ({ account, password }) => {
const obj = await encryptPasswordObj(account, password)
return request.post('/user/login', {
account: account,
clientId: userStore().clientId,
clientId: useUserStore().clientId,
...obj
})
}
@@ -64,7 +64,7 @@ export const userModifySelfService = (obj) => {
export const userModifyPassword = async ({ account, oldPasswordStr, newPasswordStr }) => {
const obj = await encryptDoublePasswordObj(account, oldPasswordStr, newPasswordStr)
return request.post('/user/modifyPwd', {
clientId: userStore().clientId,
clientId: useUserStore().clientId,
...obj
})
}

View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1742995579543" class="svg-icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="33949" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M923.2 352H100.8C62.4 352 32 321.6 32 283.2V132.8C32 94.4 62.4 64 100.8 64h824C961.6 64 992 94.4 992 132.8v152c0 36.8-30.4 67.2-68.8 67.2z" fill="#55C7F7" p-id="33950"></path><path d="M923.2 672H100.8C62.4 672 32 641.6 32 603.2V420.8C32 382.4 62.4 352 100.8 352h824c38.4 0 68.8 30.4 68.8 68.8v184c-1.6 36.8-32 67.2-70.4 67.2z" fill="#F95F5D" p-id="33951"></path><path d="M923.2 960H100.8C62.4 960 32 929.6 32 891.2v-152C32 702.4 62.4 672 100.8 672h824c38.4 0 68.8 30.4 68.8 68.8v152c-1.6 36.8-32 67.2-70.4 67.2z" fill="#7ECF3B" p-id="33952"></path><path d="M624 32v960H400V32z" fill="#FDAF42" p-id="33953"></path><path d="M632 616h-240c-22.4 0-40-17.6-40-40v-128c0-22.4 17.6-40 40-40h240c22.4 0 40 17.6 40 40v128c0 22.4-17.6 40-40 40z m-232-48h224v-112H400v112z" fill="#FFFFFF" p-id="33954"></path></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1742888981247" class="svg-icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="10532" id="mx_n_1742888981248" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M945.587697 291.972171v653.744416c0 42.592457-35.23398 77.439766-78.306847 77.439766H156.812889C113.740022 1023.156353 78.506042 988.309044 78.506042 945.716587V78.916148C78.506042 36.323691 113.751739 1.476382 156.812889 1.476382h503.141706" fill="#409eff" p-id="10533" data-spm-anchor-id="a313x.search_index.0.i24.20d13a81QCpEmN" class=""></path><path d="M659.954595 1.476382L945.587697 291.983889H691.884291s-25.778103-9.830831-31.929696-39.335042z" fill="#f5f5f5" p-id="10534"></path><path d="M666.153057 358.268766l-266.53387 41.549616a5.143903 5.143903 0 0 0-2.343464 0.632735h-9.502746c-6.456243 0-11.881362 4.757232-11.881362 11.096302v284.379348a73.44416 73.44416 0 0 0-35.316002-8.881729c-39.053826 0-70.632003 29.480776-70.632003 65.945076s31.636763 65.945075 70.632003 65.945075 70.632003-29.480776 70.632003-65.945075c0-2.530941-0.339802-5.389967-0.339802-7.932626a10.05346 10.05346 0 0 0 0.339802-3.163676V510.781399l247.551813-40.893446v160.093739a73.39729 73.39729 0 0 0-35.339436-8.870011c-39.053826 0-70.632003 29.480776-70.632003 65.945075s31.636763 65.945075 70.632003 65.945075 70.632003-29.480776 70.632003-65.945075c0-2.530941-0.339802-5.389967-0.339803-7.920908a10.10033 10.10033 0 0 0 0.339803-3.175394V368.439399c0-5.389967-4.077627-9.830831-9.842549-10.779934a10.70963 10.70963 0 0 0-7.135848-1.26547l-6.11644 0.949103h-0.679605z m0 0" fill="#F5F5F5" p-id="10535"></path></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

1
src/assets/svg/code.svg Normal file
View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1742894306389" class="svg-icon" viewBox="0 0 1027 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="11488" xmlns:xlink="http://www.w3.org/1999/xlink" width="200.5859375" height="200"><path d="M321.828571 226.742857c-14.628571-14.628571-36.571429-14.628571-51.2 0L7.314286 482.742857c-14.628571 14.628571-14.628571 36.571429 0 51.2l256 256c14.628571 14.628571 36.571429 14.628571 51.2 0 14.628571-14.628571 14.628571-36.571429 0-51.2L87.771429 512l234.057142-234.057143c7.314286-14.628571 7.314286-36.571429 0-51.2z m263.314286 0c-14.628571 0-36.571429 7.314286-43.885714 29.257143l-131.657143 497.371429c-7.314286 21.942857 7.314286 36.571429 29.257143 43.885714s36.571429-7.314286 43.885714-29.257143l131.657143-497.371429c7.314286-14.628571-7.314286-36.571429-29.257143-43.885714z m431.542857 256l-256-256c-14.628571-14.628571-36.571429-14.628571-51.2 0-14.628571 14.628571-14.628571 36.571429 0 51.2L936.228571 512l-234.057142 234.057143c-14.628571 14.628571-14.628571 36.571429 0 51.2 14.628571 14.628571 36.571429 14.628571 51.2 0l256-256c14.628571-14.628571 14.628571-43.885714 7.314285-58.514286z" fill="" p-id="11489"></path></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg id="eLpfKCFis3W1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" class="svg-icon" viewBox="0 0 1024 1024" shape-rendering="geometricPrecision" text-rendering="geometricPrecision" project-id="3158866e09674fdba1f410069e2041ca" export-id="664c0c49075e4a0caa3d6954772a62f4" cached="false"><path d="M945.587697,291.972171v653.744416c0,42.592457-35.23398,77.439766-78.306847,77.439766h-710.467961c-43.072867,0-78.306847-34.847309-78.306847-77.439766v-866.800439c0-42.592457,35.245697-77.439766,78.306847-77.439766h503.141706" fill="#409eff"/><path d="M659.954595,1.476382L945.587697,291.983889h-253.703406c0,0-25.778103-9.830831-31.929696-39.335042v-251.172465Z" fill="#f5f5f5"/><path d="M254.91983,471.097609c65.115255-.000001,463.752463,0,463.752463,0c68.644911,0,68.644911,95.054852,0,95.054852s-398.637208,0-463.752463,0-65.115255-95.054866,0-95.054852Z" transform="translate(32.61514-32.932245)" fill="#fff" stroke="rgba(0,0,0,0)" stroke-width="2.048"/><path d="M254.91983,471.097609c65.115255-.000001,463.752463,0,463.752463,0c68.644911,0,68.644911,95.054852,0,95.054852s-398.637208,0-463.752463,0-65.115255-95.054866,0-95.054852Z" transform="translate(32.61514 300.088283)" fill="#fff" stroke="rgba(0,0,0,0)" stroke-width="2.048"/><path d="M254.91983,471.097609c65.115255-.000001,463.752463,0,463.752463,0c68.644911,0,68.644911,95.054852,0,95.054852s-398.637208,0-463.752463,0-65.115255-95.054866,0-95.054852Z" transform="translate(32.61514 133.578019)" fill="#fff" stroke="rgba(0,0,0,0)" stroke-width="2.048"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

1
src/assets/svg/file.svg Normal file
View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1742886980365" class="svg-icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="7852" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M907.636364 791.272727c0 32.093091-26.088727 58.181818-58.181819 58.181818h-674.90909A58.228364 58.228364 0 0 1 116.363636 791.272727V314.181818h733.090909c32.093091 0 58.181818 26.088727 58.181819 58.181818v418.909091zM116.363636 197.818182c0-6.4 5.236364-11.636364 11.636364-11.636364h305.058909c4.096 0 7.936 2.187636 10.030546 5.725091l30.999272 52.456727H116.363636v-46.545454z m733.090909 46.545454H555.170909l-51.968-87.970909A81.780364 81.780364 0 0 0 433.058909 116.363636H128C83.083636 116.363636 46.545455 152.901818 46.545455 197.818182V791.272727c0 70.562909 57.437091 128 128 128h674.90909c70.562909 0 128-57.437091 128-128V372.363636c0-70.562909-57.437091-128-128-128z" fill="#000" p-id="7853"></path></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1 @@
<svg id="eMzCWX3bu2x1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" class="svg-icon" viewBox="0 0 1024 1024" shape-rendering="geometricPrecision" text-rendering="geometricPrecision" project-id="3158866e09674fdba1f410069e2041ca" export-id="c57776a9e1bf40d190c4b69a8f6ad9e3" cached="false"><path d="M945.587697,291.972171v653.744416c0,42.592457-35.23398,77.439766-78.306847,77.439766h-710.467961c-43.072867,0-78.306847-34.847309-78.306847-77.439766v-866.800439c0-42.592457,35.245697-77.439766,78.306847-77.439766h503.141706" fill="#409eff"/><path d="M659.954595,1.476382L945.587697,291.983889h-253.703406c0,0-25.778103-9.830831-31.929696-39.335042v-251.172465Z" fill="#f5f5f5"/></svg>

After

Width:  |  Height:  |  Size: 716 B

1
src/assets/svg/image.svg Normal file
View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1742886914618" class="svg-icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3037" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M896 160H128c-17.67 0-32 14.33-32 32v640c0 17.67 14.33 32 32 32h768c17.67 0 32-14.33 32-32V192c0-17.67-14.33-32-32-32z m-32 64v472.01l-227.2-170.4c-16.97-12.72-40.62-12.72-57.59 0l-98.58 73.92L349.2 494.39c-16.47-13.19-39.27-14.19-56.81-2.39L160 581.02V224h704zM160 800V658.16l158.36-106.48 161.02 128.8L608 584l256 192v24H160z" fill="#000" p-id="3038"></path><path d="M704 480c52.94 0 96-43.06 96-96s-43.06-96-96-96-96 43.06-96 96 43.06 96 96 96z m0-128c17.64 0 32 14.36 32 32s-14.36 32-32 32-32-14.36-32-32 14.36-32 32-32z" fill="#000" p-id="3039"></path></svg>

After

Width:  |  Height:  |  Size: 900 B

1
src/assets/svg/pause.svg Normal file
View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1742561362047" class="svg-icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="7934" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M682.666667 213.333333a42.666667 42.666667 0 0 1 42.666666 42.666667v512a42.666667 42.666667 0 0 1-85.333333 0V256a42.666667 42.666667 0 0 1 42.666667-42.666667zM341.333333 213.333333a42.666667 42.666667 0 0 1 42.666667 42.666667v512a42.666667 42.666667 0 0 1-85.333333 0V256a42.666667 42.666667 0 0 1 42.666666-42.666667z" fill="#000" p-id="7935"></path></svg>

After

Width:  |  Height:  |  Size: 698 B

1
src/assets/svg/play.svg Normal file
View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1742561328036" class="svg-icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6809" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M213.333333 65.386667a85.333333 85.333333 0 0 1 43.904 12.16L859.370667 438.826667a85.333333 85.333333 0 0 1 0 146.346666L257.237333 946.453333A85.333333 85.333333 0 0 1 128 873.28V150.72a85.333333 85.333333 0 0 1 85.333333-85.333333z m0 64a21.333333 21.333333 0 0 0-21.184 18.837333L192 150.72v722.56a21.333333 21.333333 0 0 0 30.101333 19.456l2.197334-1.152L826.453333 530.282667a21.333333 21.333333 0 0 0 2.048-35.178667l-2.048-1.386667L224.298667 132.416A21.333333 21.333333 0 0 0 213.333333 129.386667z" fill="#000" p-id="6810"></path></svg>

After

Width:  |  Height:  |  Size: 883 B

1
src/assets/svg/vote.svg Normal file
View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1742894802568" class="svg-icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4325" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M256.410578 940.384405H91.122722c-15.462716 0-27.999168-12.536452-27.999168-27.999168v-592.645304c0-15.462716 12.536452-27.999168 27.999168-27.999168h165.287856c15.462716 0 27.999168 12.536452 27.999168 27.999168v592.645304c0 15.462716-12.536452 27.999168-27.999168 27.999168zM119.12189 884.386069h109.28952V347.740125H119.12189v536.645944zM595.723103 940.384405H430.435247c-15.462716 0-27.999168-12.536452-27.999168-27.999168V500.341018c0-15.462716 12.536452-27.999168 27.999168-27.999168h165.287856c15.462716 0 27.999168 12.536452 27.999168 27.999168v412.044219c0 15.462716-12.536452 27.999168-27.999168 27.999168z m-137.288688-55.998336h109.28952v-356.045883h-109.28952v356.045883zM928.92508 940.384405H763.62289c-15.462716 0-27.999168-12.536452-27.999168-27.999168V111.852305c0-15.462716 12.536452-27.999168 27.999168-27.999169h165.30219c15.462716 0 27.999168 12.536452 27.999168 27.999169v800.533956c0 15.461692-12.537476 27.998144-27.999168 27.998144z m-137.303022-55.998336h109.303854V139.851473H791.622058v744.534596z" fill="#000" p-id="4326"></path></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -11,7 +11,13 @@ import DeleteButton from '@/components/common/DeleteButton.vue'
import EditAvatar from '@/components/common/EditAvatar.vue'
import { combineId } from '@/js/utils/common'
import { userQueryService } from '@/api/user'
import { groupStore, userStore, messageStore, userCardStore, groupCardStore } from '@/stores'
import {
useGroupStore,
useUserStore,
useMessageStore,
useUserCardStore,
useGroupCardStore
} from '@/stores'
import SelectUserDialog from '../common/SelectUserDialog.vue'
import SingleSelectDialog from '../common/SingleSelectDialog.vue'
import {
@@ -28,11 +34,11 @@ import router from '@/router'
import GroupMembersTable from '../common/GroupMembersTable.vue'
import { msgChatCreateSessionService } from '@/api/message'
const groupData = groupStore()
const userData = userStore()
const messageData = messageStore()
const userCardData = userCardStore()
const groupCardData = groupCardStore()
const groupData = useGroupStore()
const userData = useUserStore()
const messageData = useMessageStore()
const userCardData = useUserCardStore()
const groupCardData = useGroupCardStore()
const isShowSelectDialog = ref(false)
const isShowSingleSelectDialog = ref(false)
const isShowEditAvatar = ref(false)
@@ -217,6 +223,8 @@ const onShowUserCard = (account) => {
...messageData.sessionList[sessionId].objectInfo,
nickName: res.data.data.nickName,
signature: res.data.data.signature,
avatarId: res.data.data.avatarId,
avatar: res.data.data.avatar,
avatarThumb: res.data.data.avatarThumb,
gender: res.data.data.gender,
phoneNum: res.data.data.phoneNum,
@@ -357,18 +365,18 @@ const onReturnInfo = () => {
groupCardData.setShowModel('info')
}
const onNewAvatar = ({ avatar, avatarThumb }) => {
const onNewAvatar = ({ avatarId, avatar, avatarThumb }) => {
const loadingInstance = ElLoading.service(el_loading_options)
groupUpdateInfoService({
groupId: groupCardData.groupId,
avatar: avatar,
avatarThumb: avatarThumb
avatarId: avatarId
})
.then(() => {
groupData.setGroupInfo({
groupId: groupCardData.groupId,
groupInfo: {
...groupInfo.value,
avatarId: avatarId,
avatar: avatar,
avatarThumb: avatarThumb
}

View File

@@ -1,5 +1,5 @@
<script setup>
import { ref, computed, nextTick } from 'vue'
import { ref, computed, nextTick, watch } from 'vue'
import {
Close,
Male,
@@ -7,20 +7,20 @@ import {
Check,
Edit,
ChatRound,
Microphone,
Phone,
VideoCamera
} from '@element-plus/icons-vue'
import default_avatar from '@/assets/image/default_avatar.png'
import { userStore, messageStore, userCardStore } from '@/stores'
import { useUserStore, useMessageStore, useUserCardStore } from '@/stores'
import { combineId } from '@/js/utils/common'
import { MsgType } from '@/proto/msg'
import { msgChatCreateSessionService } from '@/api/message'
import router from '@/router'
import { ElMessage } from 'element-plus'
const userData = userStore()
const messageData = messageStore()
const userCardData = userCardStore()
const userData = useUserStore()
const messageData = useMessageStore()
const userCardData = useUserCardStore()
const sessionId = computed(() => {
return combineId(userData.user.account, userCardData.userInfo?.account)
@@ -135,12 +135,18 @@ const onVideoCall = () => {
ElMessage.warning('功能开发中')
}
const showAvatar = ref(userCardData.userInfo.avatarThumb || default_avatar)
const showAvatar = ref(userCardData.userInfo.avatarThumb)
const handleAvatarError = () => {
console.log('handleAvatarError')
showAvatar.value = default_avatar
}
watch(
() => userCardData.userInfo.avatarThumb,
(newValue) => {
showAvatar.value = newValue || default_avatar
}
)
</script>
<template>
@@ -291,7 +297,7 @@ const handleAvatarError = () => {
color="#409eff"
@click="onVoiceCall"
>
<Microphone />
<Phone />
</el-icon>
<el-icon
class="action-button"
@@ -417,6 +423,7 @@ const handleAvatarError = () => {
.body {
width: 100%;
padding: 5px 0 5px 0;
flex: 1;
background-color: #fff;
display: flex;
@@ -425,7 +432,7 @@ const handleAvatarError = () => {
.signature {
width: 80%;
margin-top: 16px;
margin-top: 10px;
margin-bottom: 10px;
padding: 5px;
border-radius: 4px;

View File

@@ -77,6 +77,7 @@ onUnmounted(() => {
.drag-line {
position: absolute;
cursor: col-resize;
transition: all 0.3s;
&:hover,
&.drag_resizing {

View File

@@ -1,6 +1,6 @@
<script setup>
import { ref, computed } from 'vue'
import { userStore } from '@/stores'
import { useUserStore } from '@/stores'
import { Plus, Upload, RefreshLeft, RefreshRight, Refresh } from '@element-plus/icons-vue'
import { mtsUploadService } from '@/api/mts'
import { ElMessage } from 'element-plus'
@@ -9,7 +9,7 @@ import { VueCropper } from 'vue-cropper'
const props = defineProps(['modelValue', 'model', 'groupInfo'])
const emit = defineEmits(['update:modelValue', 'update:newAvatar'])
const userData = userStore()
const userData = useUserStore()
const cropper = ref()
const srcImg = ref('')
@@ -30,8 +30,8 @@ const avatar = computed(() => {
// 打开的时候触发
const onOpen = () => {
fileName.value = avatar.value?.split('/').pop()
srcImg.value = import.meta.env.VITE_OSS_CORS_FLAG + avatar.value
fileName.value = avatar.value?.split('/').pop().split('?')[0]
srcImg.value = avatar.value ? import.meta.env.VITE_OSS_CORS_FLAG + avatar.value : avatar.value
previewImg.value = srcImg.value
resetData.value = {
previewImg: previewImg.value
@@ -71,6 +71,7 @@ const onUpload = async () => {
try {
const res = await mtsUploadService({ file: file, storeType: 0 })
emit('update:newAvatar', {
avatarId: res.data.data.objectId,
avatar: res.data.data.originUrl,
avatarThumb: res.data.data.thumbUrl
})
@@ -230,11 +231,19 @@ const onRotateRight = () => {
.preview-100 {
width: 100px;
height: 100px;
:deep(img) {
margin: 0;
}
}
.preview-40 {
width: 40px;
height: 40px;
:deep(img) {
margin: 0;
}
}
}
</style>

View File

@@ -3,7 +3,7 @@ import { ref, computed } from 'vue'
import { Mute } from '@element-plus/icons-vue'
import { ElLoading, ElMessage, ElMessageBox } from 'element-plus'
import { el_loading_options } from '@/const/commonConst'
import { userStore, groupStore, messageStore, userCardStore } from '@/stores'
import { useUserStore, useGroupStore, useMessageStore, useUserCardStore } from '@/stores'
import {
groupUpdateMuteService,
groupChangeRoleService,
@@ -19,10 +19,10 @@ import { MsgType } from '@/proto/msg'
const props = defineProps(['groupId', 'memberSearchKey'])
const emit = defineEmits(['openSession'])
const userData = userStore()
const groupData = groupStore()
const messageData = messageStore()
const userCardData = userCardStore()
const userData = useUserStore()
const groupData = useGroupStore()
const messageData = useMessageStore()
const userCardData = useUserCardStore()
const myAccount = computed(() => userData.user.account)

View File

@@ -2,12 +2,12 @@
import HashNoData from '@/components/common/HasNoData.vue'
import ContactItem from '@/components/item/ContactItem.vue'
import GroupItem from '@/components/item/GroupItem.vue'
import { searchStore } from '@/stores'
import { useSearchStore } from '@/stores'
const props = defineProps(['searchTab', 'keyWords'])
const emit = defineEmits(['showContactCard', 'showGroupCard', 'openSession'])
const searchData = searchStore()
const searchData = useSearchStore()
const onShowContactCard = (contactInfo) => {
emit('showContactCard', contactInfo)

View File

@@ -3,13 +3,13 @@ import { ref, computed, nextTick, watch } from 'vue'
import { Search } from '@element-plus/icons-vue'
import { userQueryService, userQueryByNickService } from '@/api/user'
import { groupSearchGroupInfoService } from '@/api/group'
import { searchStore } from '@/stores'
import { useSearchStore } from '@/stores'
import ResultBox from './ResultBox.vue'
import { ElMessage } from 'element-plus'
const emit = defineEmits(['showContactCard', 'showGroupCard', 'openSession'])
const searchData = searchStore()
const searchData = useSearchStore()
const inputRef = ref()
const keyWords = ref('')
const isShowSearchDialog = ref(false)

View File

@@ -10,3 +10,22 @@ export const proto = {
// 和服务端约定好的第一个消息都是从10001开始的
export const BEGIN_MSG_ID = 10001
// 消息内容类型
export const msgContentType = {
MIX: 0, // 组合,包含多种类型
TEXT: 1, // 文本
IMAGE: 2, // 图片
RECORDING: 3, // 语音
AUDIO: 4, // 音频文件
EMOJI: 5, // 视频
VIDEO: 6, // 表情
DOCUMENT: 7 // 文档
}
// 消息发送状态
export const msgSendStatus = {
PENDING: 'pending', // 发送中
OK: 'ok', // 发送成功
FAILED: 'failed' // 发送失败
}

View File

@@ -1,6 +1,6 @@
export const CLIENT_TYPE = 2
export const CLIENT_NAME = 'web'
export const CLIENT_VERSION = '1.0.0'
export const CLIENT_VERSION = '1.1.0'
export const LEAVING_AFTER_DURATION = 5 * 60 * 1000
export const LOGOUT_AFTER_DURATION = 8 * 60 * 60 * 1000

View File

@@ -1,12 +1,11 @@
import { nextTick } from 'vue'
import { messageStore } from '@/stores'
import { useMessageStore } from '@/stores'
import { msgChatCreateSessionService } from '@/api/message'
import { MsgType } from '@/proto/msg'
import { playMsgReceive } from '../utils/audio'
export const onReceiveChatMsg = (msgListDiv = null, capacity = null) => {
export const onReceiveChatMsg = (updateScroll, capacity) => {
return async (msg) => {
const messageData = messageStore()
const messageData = useMessageStore()
const sessionId = msg.body.sessionId
const now = new Date()
@@ -37,7 +36,7 @@ export const onReceiveChatMsg = (msgListDiv = null, capacity = null) => {
...readParams
})
messageData.addMsgRecords(sessionId, [
await messageData.addMsgRecords(sessionId, [
{
sessionId: sessionId,
msgId: msg.body.msgId,
@@ -53,16 +52,9 @@ export const onReceiveChatMsg = (msgListDiv = null, capacity = null) => {
}
// 如果是当前正打开的会话
if (msgListDiv && capacity && messageData.selectedSessionId === sessionId) {
const scrollHeight = msgListDiv.value?.scrollHeight
const clientHeight = document.querySelector('.show-message-box')?.clientHeight
capacity.value += 1 //接收一条消息,展示列表的容量就+1
nextTick(() => {
// 如果滚动条触底,接收到新消息时继续保持触底
if (scrollHeight - msgListDiv.value?.scrollTop - clientHeight < 1) {
msgListDiv.value.scrollTop = msgListDiv.value?.scrollHeight
}
})
if (messageData.selectedSessionId === sessionId) {
updateScroll()
capacity.value++ //接收一条消息,展示列表的容量就+1
}
}
}

View File

@@ -1,8 +1,8 @@
import { messageStore } from '@/stores'
import { useMessageStore } from '@/stores'
export const onReceiveChatReadMsg = () => {
return async (msg) => {
const messageData = messageStore()
const messageData = useMessageStore()
messageData.updateSession({
sessionId: msg.body.sessionId,
remoteRead: msg.body.content

View File

@@ -1,12 +1,11 @@
import { nextTick } from 'vue'
import { messageStore } from '@/stores'
import { useMessageStore } from '@/stores'
import { MsgType } from '@/proto/msg'
import { msgChatQuerySessionService } from '@/api/message'
import { playMsgReceive } from '../utils/audio'
export const onReceiveGroupChatMsg = (msgListDiv = null, capacity = null) => {
export const onReceiveGroupChatMsg = (updateScroll, capacity) => {
return async (msg) => {
const messageData = messageStore()
const messageData = useMessageStore()
const sessionId = msg.body.sessionId
const now = new Date()
@@ -35,7 +34,7 @@ export const onReceiveGroupChatMsg = (msgListDiv = null, capacity = null) => {
...readParams
})
messageData.addMsgRecords(sessionId, [
await messageData.addMsgRecords(sessionId, [
{
sessionId: sessionId,
msgId: msg.body.msgId,
@@ -51,16 +50,9 @@ export const onReceiveGroupChatMsg = (msgListDiv = null, capacity = null) => {
}
// 如果是当前正打开的会话
if (msgListDiv && capacity && messageData.selectedSessionId === sessionId) {
const scrollHeight = msgListDiv.value?.scrollHeight
const clientHeight = document.querySelector('.show-message-box')?.clientHeight
capacity.value += 1 //接收一条消息,展示列表的容量就+1
nextTick(() => {
// 如果滚动条触底,接收到新消息时继续保持触底
if (scrollHeight - msgListDiv.value?.scrollTop - clientHeight < 1) {
msgListDiv.value.scrollTop = msgListDiv.value?.scrollHeight
}
})
if (messageData.selectedSessionId === sessionId) {
updateScroll()
capacity.value++ //接收一条消息,展示列表的容量就+1
}
}
}

View File

@@ -1,9 +1,9 @@
import { messageStore } from '@/stores'
import { useMessageStore } from '@/stores'
export const onReceiveGroupChatReadMsg = () => {
return async (msg) => {
if (msg.body.fromId === msg.body.toId) {
const messageData = messageStore()
const messageData = useMessageStore()
const now = new Date()
messageData.updateSession({
sessionId: msg.body.sessionId,

View File

@@ -1,12 +1,11 @@
import { nextTick } from 'vue'
import { messageStore, groupStore } from '@/stores'
import { useMessageStore, useGroupStore } from '@/stores'
import { msgChatQuerySessionService } from '@/api/message'
import { groupInfoService } from '@/api/group'
export const onReceiveGroupSystemMsg = (msgListDiv = null, capacity = null) => {
return (msg) => {
const messageData = messageStore()
const groupData = groupStore()
export const onReceiveGroupSystemMsg = (updateScroll, capacity) => {
return async (msg) => {
const messageData = useMessageStore()
const groupData = useGroupStore()
const sessionId = msg.body.sessionId
const now = new Date()
@@ -28,7 +27,7 @@ export const onReceiveGroupSystemMsg = (msgListDiv = null, capacity = null) => {
})
// 更新聊天记录
messageData.addMsgRecords(sessionId, [
await messageData.addMsgRecords(sessionId, [
{
sessionId: sessionId,
msgId: msg.body.msgId,
@@ -40,16 +39,9 @@ export const onReceiveGroupSystemMsg = (msgListDiv = null, capacity = null) => {
])
// 如果是当前正打开的会话
if (msgListDiv && capacity && messageData.selectedSessionId === sessionId) {
const scrollHeight = msgListDiv.value?.scrollHeight
const clientHeight = document.querySelector('.show-message-box')?.clientHeight
capacity.value += 1 //接收一条消息,展示列表的容量就+1
nextTick(() => {
// 如果滚动条触底,接收到新消息时继续保持触底
if (scrollHeight - msgListDiv.value?.scrollTop - clientHeight < 1) {
msgListDiv.value.scrollTop = msgListDiv.value?.scrollHeight
}
})
if (messageData.selectedSessionId === sessionId) {
updateScroll()
capacity.value++ //接收一条消息,展示列表的容量就+1
}
}
}

View File

@@ -1,12 +1,12 @@
import { userStore, messageStore, groupStore } from '@/stores'
import { useUserStore, useMessageStore, useGroupStore } from '@/stores'
import { combineId, jsonParseSafe } from '@/js/utils/common'
import { MsgType } from '@/proto/msg'
export const onReceiveStatusResMsg = () => {
return async (msg) => {
const userData = userStore()
const messageData = messageStore()
const groupData = groupStore()
const userData = useUserStore()
const messageData = useMessageStore()
const groupData = useGroupStore()
// 1. 更新本账号的多端下的最终状态
const content = jsonParseSafe(msg.body.content)

View File

@@ -1,7 +1,7 @@
import msgReceive from '@/assets/audio/msgreceive.mp3'
import msgSend from '@/assets/audio/msgsend.mp3'
import { userStore } from '@/stores'
const userData = userStore()
import { useUserStore } from '@/stores'
const userData = useUserStore()
export const playMsgReceive = () => {
if (!userData.user.newMsgTips) {

View File

@@ -227,3 +227,15 @@ export const base64ToFile = (base64Data, fileName) => {
}
return new File([u8arr], fileName, { type: mimeType })
}
export const formatFileSize = (size) => {
if (!size) {
return ''
} else if (size < 1024) {
return size + ' B'
} else if (size < 1024 * 1024) {
return (size / 1024).toFixed(2) + ' KB'
} else {
return (size / (1024 * 1024)).toFixed(2) + ' MB'
}
}

View File

@@ -1,5 +1,5 @@
import axios from 'axios'
import { userStore } from '@/stores'
import { useUserStore } from '@/stores'
import router from '@/router'
import { generateSign } from './crypto'
import { v4 as uuidv4 } from 'uuid'
@@ -23,7 +23,7 @@ const instance = axios.create({
// 请求拦截器
instance.interceptors.request.use(
async (config) => {
const userData = userStore()
const userData = useUserStore()
const traceId = uuidv4()
if (config.url === '/user/refreshToken') {
const token = userData.getRefreshToken()
@@ -56,18 +56,27 @@ instance.interceptors.response.use(
if (res.data.code === 0) {
return res
}
ElMessage.error(res.data.desc || '服务异常')
console.error(
'The response was not the expected code: ',
res.config?.url,
res.data?.code,
res.data?.desc
)
return Promise.reject(res.data)
},
async (err) => {
if (err.response?.status === 401) {
userStore().clearAt()
userStore().clearRt()
useUserStore().clearAt()
useUserStore().clearRt()
ElMessage.error('您还未登录,请先登录')
router.push('/login')
} else {
ElMessage.error(err.response?.message || '服务异常')
console.error(
'The request was failed: ',
err.config?.url,
err.response?.status,
err.response?.message
)
}
return Promise.reject(err)

View File

@@ -1,6 +1,6 @@
import { Msg, Header, MsgType, Body } from '@/proto/msg'
import { proto } from '@/const/msgConst'
import { userStore } from '@/stores'
import { useUserStore } from '@/stores'
import { v4 as uuidv4 } from 'uuid'
export const chatConstructor = (sessionId, toId, content, seq) => {
@@ -11,7 +11,7 @@ export const chatConstructor = (sessionId, toId, content, seq) => {
isExtension: false
})
const userData = userStore()
const userData = useUserStore()
const body = Body.create({
fromId: userData.user.account,
fromClient: userData.clientId,
@@ -35,7 +35,7 @@ export const groupChatConstructor = (sessionId, groupId, content, seq) => {
isExtension: false
})
const userData = userStore()
const userData = useUserStore()
const body = Body.create({
fromId: userData.user.account,
fromClient: userData.clientId,
@@ -87,7 +87,7 @@ export const chatReadConstructor = (sessionId, toId, content) => {
isExtension: false
})
const userData = userStore()
const userData = useUserStore()
const body = Body.create({
fromId: userData.user.account,
fromClient: userData.clientId,
@@ -111,7 +111,7 @@ export const groupChatReadConstructor = (sessionId, groupId, content) => {
isExtension: false
})
const userData = userStore()
const userData = useUserStore()
const body = Body.create({
fromId: userData.user.account,
fromClient: userData.clientId,
@@ -135,7 +135,7 @@ export const statusReqConstructor = (accounts) => {
isExtension: false
})
const userData = userStore()
const userData = useUserStore()
const body = Body.create({
fromId: userData.user.account,
fromClient: userData.clientId,
@@ -156,7 +156,7 @@ export const statusSyncConstructor = (status) => {
isExtension: false
})
const userData = userStore()
const userData = useUserStore()
const body = Body.create({
fromId: userData.user.account,
fromClient: userData.clientId,

View File

@@ -1,5 +1,5 @@
import { Msg, MsgType } from '@/proto/msg'
import { userStore } from '@/stores'
import { useUserStore } from '@/stores'
import { v4 as uuidv4 } from 'uuid'
import { generateSign } from '../utils/crypto'
import {
@@ -105,7 +105,10 @@ class WsConnect {
},
[MsgType.DELIVERED]: (deliveredMsg) => {
this.msgIdRefillCallback[deliveredMsg.body.seq](deliveredMsg.body.msgId)
delete this.msgIdRefillCallback[deliveredMsg.body.seq]
setTimeout(() => {
// 不能立即删除,因为有可能重发消息还会用到
delete this.msgIdRefillCallback[deliveredMsg.body.seq]
}, 30000)
},
[MsgType.HEART_BEAT]: () => {
if (this.heartBeat.healthPoint > 0) this.heartBeat.healthPoint--
@@ -173,7 +176,7 @@ class WsConnect {
return
}
// console.log('create websocket')
const userData = userStore()
const userData = useUserStore()
const token = await userData.getAccessToken()
const traceId = uuidv4()
const timestamp = Math.floor(new Date().getTime() / 1000)
@@ -329,14 +332,14 @@ class WsConnect {
* @param {*} msgType
* @param {*} content
* @param {*} seq
* @param {*} callbackBefore 发送前的处理,用于展示发送前状态
* @param {*} callbackAfter 发送后(接收MsgType.DELIVERED时)的处理,用于展示发送后状态
* @param {*} before 发送前的处理,用于展示发送前状态
* @param {*} after 发送后(接收MsgType.DELIVERED时)的处理,用于展示发送后状态
*/
sendMsg(sessionId, remoteId, msgType, content, seq, callbackBefore, callbackAfter) {
sendMsg(sessionId, remoteId, msgType, content, seq, before, after) {
const sequence = seq || uuidv4()
const data = this.dataConstructor[msgType](sessionId, remoteId, content, sequence)
callbackBefore(sequence, data)
this.msgIdRefillCallback[sequence] = callbackAfter
before(sequence, data)
this.msgIdRefillCallback[sequence] = after
this.sendAgent(data)
}

View File

@@ -1,5 +1,5 @@
import { createRouter, createWebHistory } from 'vue-router'
import { userStore } from '@/stores'
import { useUserStore } from '@/stores'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@@ -167,7 +167,7 @@ const router = createRouter({
})
router.beforeEach(async (to, from, next) => {
const userData = userStore()
const userData = useUserStore()
const isLogin = await userData.isLogin()
if (!isLogin) {

60
src/stores/audio.js Normal file
View File

@@ -0,0 +1,60 @@
import { mtsAudioService } from '@/api/mts'
import { msgContentType } from '@/const/msgConst'
import { jsonParseSafe } from '@/js/utils/common'
import { defineStore } from 'pinia'
import { ref } from 'vue'
// audio的缓存数据不持久化存储
export const useAudioStore = defineStore('anylink-audio', () => {
/**
* {
* objectId_01: {objectId: objectId_01, url: xxx},
* objectId_02: {objectId: objectId_02, url: xxx},
* }
*/
const audio = ref({})
/**
* 在同一个session中的audioid集合
*/
const audioInSession = ref({})
const setAudio = (sessionId, obj) => {
audio.value[obj.objectId] = obj
if (!audioInSession.value[sessionId]) {
audioInSession.value[sessionId] = []
}
audioInSession.value[sessionId].push(obj.objectId)
}
const preloadAudio = async (sessionId, msgRecords) => {
const audioIds = new Set()
msgRecords.forEach((item) => {
const content = item.content
const contentJson = jsonParseSafe(content)
if (
(contentJson && contentJson['type'] === msgContentType.RECORDING) ||
(contentJson && contentJson['type'] === msgContentType.AUDIO)
) {
const objectId = contentJson['value']
if (!audio.value[objectId]) {
audioIds.add(objectId)
}
}
})
if (audioIds.size > 0) {
const res = await mtsAudioService({ objectIds: [...audioIds].join(',') })
res.data.data.forEach((item) => {
setAudio(sessionId, item)
})
}
}
return {
audio,
audioInSession,
setAudio,
preloadAudio
}
})

57
src/stores/document.js Normal file
View File

@@ -0,0 +1,57 @@
import { mtsDocumentService } from '@/api/mts'
import { msgContentType } from '@/const/msgConst'
import { jsonParseSafe } from '@/js/utils/common'
import { defineStore } from 'pinia'
import { ref } from 'vue'
// document的缓存数据不持久化存储
export const useDocumentStore = defineStore('anylink-document', () => {
/**
* {
* objectId_01: {objectId: objectId_01, url: xxx},
* objectId_02: {objectId: objectId_02, url: xxx},
* }
*/
const document = ref({})
/**
* 在同一个session中的documentid集合
*/
const documentInSession = ref({})
const setDocument = (sessionId, obj) => {
document.value[obj.objectId] = obj
if (!documentInSession.value[sessionId]) {
documentInSession.value[sessionId] = []
}
documentInSession.value[sessionId].push(obj.objectId)
}
const preloadDocument = async (sessionId, msgRecords) => {
const documentIds = new Set()
msgRecords.forEach((item) => {
const content = item.content
const contentJson = jsonParseSafe(content)
if (contentJson && contentJson['type'] === msgContentType.DOCUMENT) {
const objectId = contentJson['value']
if (!document.value[objectId]) {
documentIds.add(objectId)
}
}
})
if (documentIds.size > 0) {
const res = await mtsDocumentService({ objectIds: [...documentIds].join(',') })
res.data.data.forEach((item) => {
setDocument(sessionId, item)
})
}
}
return {
document,
documentInSession,
setDocument,
preloadDocument
}
})

View File

@@ -3,7 +3,7 @@ import { ref } from 'vue'
import { groupInfoListService } from '@/api/group'
// group群组相关的缓存数据不持久化存储
export const groupStore = defineStore('anylink-group', () => {
export const useGroupStore = defineStore('anylink-group', () => {
/**
* 和我有关的所有群组,格式:{groupId_1: groupInfo_1, groupId_2: groupInfo_2}
*/

View File

@@ -3,7 +3,7 @@ import { ref } from 'vue'
import {} from '@/api/group'
// groupCard群组详情卡片相关的缓存数据不持久化存储
export const groupCardStore = defineStore('anylink-groupCard', () => {
export const useGroupCardStore = defineStore('anylink-groupCard', () => {
const isShow = ref(false)
const groupId = ref('')

View File

@@ -1,11 +1,13 @@
import { mtsImageService } from '@/api/mts'
import { msgContentType } from '@/const/msgConst'
import { jsonParseSafe } from '@/js/utils/common'
import { defineStore } from 'pinia'
import { ref } from 'vue'
const pattern = /\{[a-f0-9]+\}/g
// image的缓存数据不持久化存储
export const imageStore = defineStore('anylink-image', () => {
export const useImageStore = defineStore('anylink-image', () => {
/**
* {
* objectId_01: {objectId: objectId_01, originUrl: xxx, thumbUrl: xxx},
@@ -71,11 +73,45 @@ export const imageStore = defineStore('anylink-image', () => {
}
}
const preloadImage = async (sessionId, msgRecords) => {
const imageIds = new Set()
msgRecords.forEach((item) => {
const content = item.content
const contentJson = jsonParseSafe(content)
if (contentJson && contentJson['type'] === msgContentType.IMAGE) {
const objectId = contentJson['value']
if (!image.value[objectId]) {
imageIds.add(objectId)
}
} else {
const matches = content.match(pattern)
if (matches && matches.length > 0) {
matches.forEach((item) => {
let startIndex = item.indexOf('{')
let endIndex = item.indexOf('}')
const objectId = item.slice(startIndex + 1, endIndex)
if (!image.value[objectId]) {
imageIds.add(objectId)
}
})
}
}
})
if (imageIds.size > 0) {
const res = await mtsImageService({ objectIds: [...imageIds].join(',') })
res.data.data.forEach((item) => {
setImage(sessionId, item)
})
}
}
return {
image,
imageInSession,
setImage,
imageTrans,
loadImageInfoFromContent
loadImageInfoFromContent,
preloadImage
}
})

View File

@@ -13,3 +13,6 @@ export * from './search'
export * from './userCard'
export * from './groupCard'
export * from './image'
export * from './audio'
export * from './video'
export * from './document'

View File

@@ -6,9 +6,10 @@ import {
msgQueryPartitionService
} from '@/api/message'
import { ElMessage } from 'element-plus'
import { useImageStore, useAudioStore, useVideoStore, useDocumentStore } from '@/stores'
// 消息功能相关需要缓存的数据,不持久化存储
export const messageStore = defineStore('anylink-message', () => {
export const useMessageStore = defineStore('anylink-message', () => {
/**
* message页面当前被选中的sessionId
*/
@@ -120,7 +121,13 @@ export const messageStore = defineStore('anylink-message', () => {
* @param {*} sessionId 会话id
* @param {*} msgRecords 新的消息数组
*/
const addMsgRecords = (sessionId, msgRecords) => {
const addMsgRecords = async (sessionId, msgRecords) => {
// 预加载消息中的图片和音频
await useImageStore().preloadImage(sessionId, msgRecords)
await useAudioStore().preloadAudio(sessionId, msgRecords)
await useVideoStore().preloadVideo(sessionId, msgRecords)
await useDocumentStore().preloadDocument(sessionId, msgRecords)
if (!msgRecords?.length) return
msgRecords.forEach((item) => {
if (!msgRecordsList.value[sessionId]) {
@@ -215,9 +222,12 @@ export const messageStore = defineStore('anylink-message', () => {
const loadSessionList = async () => {
if (!Object.keys(sessionList.value).length) {
const res = await msgChatSessionListService()
Object.keys(res.data.data).forEach((item) => {
Object.keys(res.data.data).forEach(async (item) => {
addSession(res.data.data[item].session)
addMsgRecords(item, res.data.data[item].msgList)
const msgList = res.data.data[item].msgList
if (msgList) {
await addMsgRecords(item, msgList)
}
})
}
}

View File

@@ -2,7 +2,7 @@ import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
// 不持久化存储
export const searchStore = defineStore('anylink-search', () => {
export const useSearchStore = defineStore('anylink-search', () => {
const keywords = ref('')
const setKeywords = (words) => {

View File

@@ -2,7 +2,7 @@ import { defineStore } from 'pinia'
import { ref } from 'vue'
// 界面设置相关需要缓存的设置
export const settingStore = defineStore(
export const useSettingStore = defineStore(
'anylink-setting',
() => {
const sessionListDrag = ref(0)

View File

@@ -4,7 +4,7 @@ import { userInfoService } from '@/api/user'
import { refreshToken } from '@/api/user'
// 用户模块
export const userStore = defineStore(
export const useUserStore = defineStore(
'anylink-user',
() => {
const at = ref({

View File

@@ -3,7 +3,7 @@ import { ref } from 'vue'
import {} from '@/api/group'
// userCard用户详情卡片相关的缓存数据不持久化存储
export const userCardStore = defineStore('anylink-userCard', () => {
export const useUserCardStore = defineStore('anylink-userCard', () => {
const isShow = ref(false)
const userInfo = ref({})

57
src/stores/video.js Normal file
View File

@@ -0,0 +1,57 @@
import { mtsVideoService } from '@/api/mts'
import { msgContentType } from '@/const/msgConst'
import { jsonParseSafe } from '@/js/utils/common'
import { defineStore } from 'pinia'
import { ref } from 'vue'
// video的缓存数据不持久化存储
export const useVideoStore = defineStore('anylink-video', () => {
/**
* {
* objectId_01: {objectId: objectId_01, url: xxx},
* objectId_02: {objectId: objectId_02, url: xxx},
* }
*/
const video = ref({})
/**
* 在同一个session中的videoid集合
*/
const videoInSession = ref({})
const setVideo = (sessionId, obj) => {
video.value[obj.objectId] = obj
if (!videoInSession.value[sessionId]) {
videoInSession.value[sessionId] = []
}
videoInSession.value[sessionId].push(obj.objectId)
}
const preloadVideo = async (sessionId, msgRecords) => {
const videoIds = new Set()
msgRecords.forEach((item) => {
const content = item.content
const contentJson = jsonParseSafe(content)
if (contentJson && contentJson['type'] === msgContentType.VIDEO) {
const objectId = contentJson['value']
if (!video.value[objectId]) {
videoIds.add(objectId)
}
}
})
if (videoIds.size > 0) {
const res = await mtsVideoService({ objectIds: [...videoIds].join(',') })
res.data.data.forEach((item) => {
setVideo(sessionId, item)
})
}
}
return {
video,
videoInSession,
setVideo,
preloadVideo
}
})

View File

@@ -11,7 +11,7 @@ import {
userVerifyCaptchaService,
userForgetService
} from '@/api/user.js'
import { userStore } from '@/stores'
import { useUserStore } from '@/stores'
import { generateClientId } from '@/js/utils/common'
import { flowLimiteWrapper } from '@/js/utils/flowLimite'
@@ -99,7 +99,7 @@ const rules = {
]
}
const userData = userStore()
const userData = useUserStore()
const register = async () => {
if (demoFlag) {
@@ -587,15 +587,6 @@ watch(tabMode, () => {
<div class="row">
<span class="item">©2024 - 2025 Open AnyLink</span>
</div>
<div class="row">
<p class="item" style="margin: 0">
<img src="@/assets/image/beian-logo.png" width="16" />
<a href="https://beian.mps.gov.cn/#/query/webSearch?code=61011602000694" target="_blank">
陕公网安备61011602000694号
</a>
</p>
<a class="item" href="https://beian.miit.gov.cn/" target="_blank">陕ICP备2025059454号-2</a>
</div>
</div>
</div>
</template>

View File

@@ -3,9 +3,9 @@ import contactListIcon from '@/assets/svg/contactList.svg'
import groupIcon from '@/assets/svg/group.svg'
import organizationIcon from '@/assets/svg/organization.svg'
import { onMounted } from 'vue'
import { messageStore } from '@/stores'
import { useMessageStore } from '@/stores'
const messageData = messageStore()
const messageData = useMessageStore()
onMounted(async () => {
await messageData.loadSessionList()

View File

@@ -1,5 +1,5 @@
<script setup>
import { ChatRound, Microphone, VideoCamera } from '@element-plus/icons-vue'
import { ChatRound, Phone, VideoCamera } from '@element-plus/icons-vue'
import GroupItem from '@/components/item/GroupItem.vue'
import router from '@/router'
import { ElMessage } from 'element-plus'
@@ -57,7 +57,7 @@ const onVideoCall = () => {
color="#409eff"
@click="onVoiceCall"
>
<Microphone />
<Phone />
</el-icon>
<el-icon
class="action-button"

View File

@@ -4,7 +4,13 @@ import { Search, Edit, Delete, Check, Close } from '@element-plus/icons-vue'
import AddButton from '@/components/common/AddButton.vue'
import HashNoData from '@/components/common/HasNoData.vue'
import SelectUserDialog from '@/components/common/SelectUserDialog.vue'
import { groupStore, userStore, messageStore, userCardStore, groupCardStore } from '@/stores'
import {
useGroupStore,
useUserStore,
useMessageStore,
useUserCardStore,
useGroupCardStore
} from '@/stores'
import { combineId } from '@/js/utils/common'
import { userQueryService } from '@/api/user'
import { ElLoading, ElMessage } from 'element-plus'
@@ -15,11 +21,11 @@ import { MsgType } from '@/proto/msg'
const props = defineProps(['tab', 'params'])
const groupData = groupStore()
const userData = userStore()
const messageData = messageStore()
const userCardData = userCardStore()
const groupCardData = groupCardStore()
const groupData = useGroupStore()
const userData = useUserStore()
const messageData = useMessageStore()
const userCardData = useUserCardStore()
const groupCardData = useGroupCardStore()
const searchKey = ref('')
const searchData = ref([])
const isShowSelectDialog = ref(false)

View File

@@ -2,7 +2,7 @@
import { ref, computed, watch, onMounted } from 'vue'
import { Search, MoreFilled } from '@element-plus/icons-vue'
import SubCommon from '../components/SubCommon.vue'
import { messageStore, groupCardStore, groupStore } from '@/stores'
import { useMessageStore, useGroupCardStore, useGroupStore } from '@/stores'
import { PARTITION_TYPE } from '@/const/commonConst'
import {
msgCreatePartitionService,
@@ -19,9 +19,9 @@ import { highLightedText } from '@/js/utils/common'
import { MsgType } from '@/proto/msg'
import { groupInfoService } from '@/api/group'
const messageData = messageStore()
const groupCardData = groupCardStore()
const groupData = groupStore()
const messageData = useMessageStore()
const groupCardData = useGroupCardStore()
const groupData = useGroupStore()
const partitionSearchKey = ref('')
const isShowAddPartitionDialog = ref(false)

View File

@@ -1,24 +1,15 @@
<script setup>
import { ref, nextTick, computed } from 'vue'
import {
ChatRound,
Microphone,
VideoCamera,
Edit,
Delete,
Check,
Close
} from '@element-plus/icons-vue'
import { ref, nextTick } from 'vue'
import { ChatRound, Phone, VideoCamera, Edit, Delete, Check, Close } from '@element-plus/icons-vue'
import ContactItem from '@/components/item/ContactItem.vue'
import { sessionShowTime } from '@/js/utils/common'
import router from '@/router'
import { messageStore } from '@/stores'
import { useMessageStore } from '@/stores'
import { ElMessage } from 'element-plus'
const props = defineProps(['type', 'session', 'partitions', 'keyWords'])
const emit = defineEmits(['showUserCard'])
const messageData = messageStore()
const messageData = useMessageStore()
const markEditing = ref(false)
const newMark = ref('')
@@ -27,14 +18,6 @@ const markEditRef = ref()
const partitioEditing = ref(false)
const newPartitionId = ref(props.session.partitionId)
const lastMsg = computed(() => {
const msgIds = messageData.msgIdSortArray[props.session.sessionId]
if (!msgIds?.length) {
return {}
}
return messageData.getMsg(props.session.sessionId, msgIds[msgIds.length - 1])
})
const onShowCard = () => {
emit('showUserCard', {
sessionId: props.session.sessionId,
@@ -129,12 +112,6 @@ const onVideoCall = () => {
style="width: 200px"
></ContactItem>
<div class="diff-display">
<div v-if="props.type === 'all'" class="all">
<div class="tips-block">{{ sessionShowTime(lastMsg.msgTime) }}</div>
<div class="all-content text-ellipsis" :title="lastMsg.content">
{{ lastMsg.content }}
</div>
</div>
<div v-if="props.type === 'mark'" class="mark">
<div class="tips-block">备注</div>
<div v-if="!markEditing" class="mark-content-wrapper">
@@ -281,7 +258,7 @@ const onVideoCall = () => {
color="#409eff"
@click="onVoiceCall"
>
<Microphone />
<Phone />
</el-icon>
<el-icon
class="action-button"
@@ -333,17 +310,6 @@ const onVideoCall = () => {
flex-shrink: 0;
}
.all {
width: 200px;
height: 100%;
display: flex;
align-items: center;
.all-content {
margin-left: 5px;
}
}
.mark {
height: 100%;
display: flex;

View File

@@ -1,7 +1,7 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import { userQueryService } from '@/api/user'
import { messageStore, userCardStore } from '@/stores'
import { useMessageStore, useUserCardStore } from '@/stores'
import ContactListUserItem from '@/views/contactList/user/components/ContactListUserItem.vue'
import { ElLoading } from 'element-plus'
import { el_loading_options } from '@/const/commonConst'
@@ -9,8 +9,8 @@ import { Search } from '@element-plus/icons-vue'
import HashNoData from '@/components/common/HasNoData.vue'
import { MsgType } from '@/proto/msg'
const messageData = messageStore()
const userCardData = userCardStore()
const messageData = useMessageStore()
const userCardData = useUserCardStore()
const totalCount = computed(() => {
return Object.keys(allData.value).length

View File

@@ -1,7 +1,7 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import { userQueryService } from '@/api/user'
import { messageStore, userCardStore } from '@/stores'
import { useMessageStore, useUserCardStore } from '@/stores'
import ContactListUserItem from '@/views/contactList/user/components/ContactListUserItem.vue'
import { ElLoading } from 'element-plus'
import { el_loading_options } from '@/const/commonConst'
@@ -9,8 +9,8 @@ import { Search } from '@element-plus/icons-vue'
import HashNoData from '@/components/common/HasNoData.vue'
import { MsgType } from '@/proto/msg'
const messageData = messageStore()
const userCardData = userCardStore()
const messageData = useMessageStore()
const userCardData = useUserCardStore()
const totalCount = computed(() => {
return Object.keys(markData.value).length
})

View File

@@ -14,16 +14,16 @@ import {
} from '@/api/message'
import { PARTITION_TYPE } from '@/const/commonConst'
import { ElMessage, ElMessageBox } from 'element-plus'
import { messageStore, userStore, userCardStore } from '@/stores'
import { useMessageStore, useUserStore, useUserCardStore } from '@/stores'
import { ElLoading } from 'element-plus'
import { el_loading_options } from '@/const/commonConst'
import SelectUserDialog from '@/components/common/SelectUserDialog.vue'
import { combineId, highLightedText } from '@/js/utils/common'
import { MsgType } from '@/proto/msg'
const messageData = messageStore()
const userData = userStore()
const userCardData = userCardStore()
const messageData = useMessageStore()
const userData = useUserStore()
const userCardData = useUserCardStore()
const partitionSearchKey = ref('')
const userSearchKey = ref('')
const isShowAddPartitionDialog = ref(false)

View File

@@ -8,7 +8,7 @@ import {
} from '@element-plus/icons-vue'
import { onMounted, onUnmounted, ref, computed } from 'vue'
import ContactUs from '@/views/layout/components/ContactUs.vue'
import { userStore, messageStore, searchStore, groupStore } from '@/stores'
import { useUserStore, useMessageStore, useSearchStore, useGroupStore } from '@/stores'
import router from '@/router'
import MyCard from '@/views/layout/components/MyCard.vue'
import NaviMenu from '@/views/layout/components/NaviMenu.vue'
@@ -30,10 +30,10 @@ import githubIcon from '@/assets/svg/github.svg'
import SourceCode from '@/views/layout/components/SourceCode.vue'
const myAvatar = ref()
const userData = userStore()
const messageData = messageStore()
const searchData = searchStore()
const groupData = groupStore()
const userData = useUserStore()
const messageData = useMessageStore()
const searchData = useSearchStore()
const groupData = useGroupStore()
const isShowMyCard = ref(false)
const contactUsRef = ref(null)
const sourceCodeRef = ref(null)
@@ -150,9 +150,6 @@ const autoLogout = () => {
autoLogoutTimer = setTimeout(() => {
userLogoutService(userData.user.account).finally(() => {
userData.clear()
messageData.clear()
searchData.clear()
wsConnect.closeWs()
router.push('/login')
})
}, LOGOUT_AFTER_DURATION)
@@ -167,9 +164,6 @@ const onExit = async () => {
.then(() => {
userLogoutService(userData.user.account).finally(() => {
userData.clear()
messageData.clear()
searchData.clear()
wsConnect.closeWs()
router.push('/login')
})
})

View File

@@ -1,14 +1,14 @@
<script setup>
import { ref, computed, watch } from 'vue'
import { Close, Male, Female } from '@element-plus/icons-vue'
import { userStore } from '@/stores'
import { useUserStore } from '@/stores'
import default_avatar from '@/assets/image/default_avatar.png'
import router from '@/router'
const props = defineProps(['isShow'])
const emit = defineEmits(['close'])
const userData = userStore()
const userData = useUserStore()
const truncatedSignature = computed(() => {
const signature = userData.user.signature || '您还没有个性签名。'
@@ -37,12 +37,19 @@ const onClickAvatar = () => {
router.push('/setting/personal')
}
const showAvatar = ref(userData.user.avatarThumb)
const showAvatar = ref(userData.user.avatarThumb || default_avatar)
const handleAvatarError = () => {
showAvatar.value = default_avatar
}
watch(
() => userData.user.avatarThumb,
(newValue) => {
showAvatar.value = newValue
}
)
watch(
() => props.isShow,
(newValue) => {
@@ -89,7 +96,7 @@ watch(
.card-dialog {
background: linear-gradient(to bottom, #a0cfff, #fff);
width: 300px;
height: 300px;
height: 360px;
position: absolute;
left: 0px;
top: 20px;

View File

@@ -1,8 +1,8 @@
<script setup>
import { onMounted } from 'vue'
import { messageStore } from '@/stores'
import { useMessageStore } from '@/stores'
const messageData = messageStore()
const messageData = useMessageStore()
onMounted(async () => {
await messageData.loadSessionList()

View File

@@ -2,14 +2,10 @@ eslint-disable prettier/prettier
<script setup>
import { ref, onMounted, onUnmounted, computed, nextTick, watch } from 'vue'
import {
Microphone,
Phone,
VideoCamera,
MoreFilled,
CirclePlus,
LocationInformation,
Clock,
FolderAdd,
CreditCard,
ArrowDownBold,
ArrowUp
} from '@element-plus/icons-vue'
@@ -17,18 +13,18 @@ import DragLine from '@/components/common/DragLine.vue'
import SearchBox from '@/components/search/SearchBox.vue'
import AddButton from '@/components/common/AddButton.vue'
import SessionItem from '@/views/message/components/SessionItem.vue'
import InputTool from '@/views/message/components/InputTool.vue'
import InputToolBar from '@/views/message/components/InputToolBar.vue'
import InputEditor from '@/views/message/components/InputEditor.vue'
import MessageItem from '@/views/message/components/MessageItem.vue'
import SessionTag from '@/views/message/components/SessionTag.vue'
import SelectUserDialog from '@/components/common/SelectUserDialog.vue'
import {
userStore,
settingStore,
messageStore,
userCardStore,
groupCardStore,
groupStore
useUserStore,
useSettingStore,
useMessageStore,
useUserCardStore,
useGroupCardStore,
useGroupStore
} from '@/stores'
import backgroupImage from '@/assets/svg/messagebx_bg.svg'
import {
@@ -46,21 +42,20 @@ import { el_loading_options } from '@/const/commonConst'
import { combineId, sessionIdConvert } from '@/js/utils/common'
import SessionMenu from '@/views/message/components/SessionMenu.vue'
import router from '@/router'
import { BEGIN_MSG_ID } from '@/const/msgConst'
import { BEGIN_MSG_ID, msgContentType, msgSendStatus } from '@/const/msgConst'
import EditDialog from '@/components/common/EditDialog.vue'
import AddOprMenu from './components/AddOprMenu.vue'
import MessageGroupRightSide from './components/MessageGroupRightSide.vue'
import EmojiBox from './components/EmojiBox.vue'
import EmojiIcon from '@/assets/svg/emoji.svg'
import AddOprMenu from '@/views/message/components/AddOprMenu.vue'
import MessageGroupRightSide from '@/views/message/components/MessageGroupRightSide.vue'
import HashNoData from '@/components/common/HasNoData.vue'
import InputRecorder from '@/views/message/components/InputRecorder.vue'
import { playMsgSend } from '@/js/utils/audio'
const userData = userStore()
const settingData = settingStore()
const messageData = messageStore()
const userCardData = userCardStore()
const groupCardData = groupCardStore()
const groupData = groupStore()
const userData = useUserStore()
const settingData = useSettingStore()
const messageData = useMessageStore()
const userCardData = useUserCardStore()
const groupCardData = useGroupCardStore()
const groupData = useGroupStore()
const sessionListRef = ref()
const asideWidth = ref(0)
@@ -72,6 +67,8 @@ const inputBoxHeightMin = 200
const inputBoxHeightMax = 500
const msgListDiv = ref()
const disToBottom = ref(0)
const nearBottomDis = 50
const newMsgTips = ref({
isShowTopTips: false,
isShowBottomTips: false,
@@ -79,7 +76,7 @@ const newMsgTips = ref({
firstElement: null
})
const isShowEmojiBox = ref(false)
const inputToolBarRef = ref()
const myAccount = computed(() => {
return userData.user.account
@@ -180,10 +177,20 @@ const initSession = (sessionId) => {
isLoadMoreLoading: false
}
}
isShowRecorder.value = false // 麦克风输入状态重置
inputRecorderRef.value?.cancelSend() // 取消音频发送
}
/**
* 定位的session的位置
* 这里受限sessionListSorted的排序速度如果定位的时候排序没有完成定位的位置就不对
* @param sessionId
*/
const locateSession = (sessionId) => {
nextTick(() => {
let task
let count = 0
task = setInterval(() => {
if (count >= 3) clearInterval(task)
const selectedElement = document.querySelector(`#session-item-${sessionIdConvert(sessionId)}`)
// 如果被选中元素的上边在scrollTop之的上面或这在下边在scrollTop+clientHeight的下面显示不全或者完全没有显示则需要重新定位
// 由于offsetTop和offsetHeight不包含外边距因此定位存在细小误差暂不处理
@@ -195,7 +202,8 @@ const locateSession = (sessionId) => {
) {
sessionListRef.value.scrollTop = selectedElement.offsetTop - sessionListRef.value.clientHeight
}
})
count++
}, 200)
}
const msgIdsShow = computed(() => {
@@ -240,9 +248,9 @@ onMounted(async () => {
asideWidth.value = settingData.sessionListDrag[myAccount.value] || 300
inputBoxHeight.value = settingData.inputBoxDrag[myAccount.value] || 300
wsConnect.bindEvent(MsgType.CHAT, onReceiveChatMsg(msgListDiv, capacity)) //绑定接收Chat消息的事件
wsConnect.bindEvent(MsgType.GROUP_CHAT, onReceiveGroupChatMsg(msgListDiv, capacity)) //绑定接收GroupChat消息的事件
wsConnect.bindGroupSystemMsgEvent(onReceiveGroupSystemMsg(msgListDiv, capacity)) //绑定接收群系统消息事件
wsConnect.bindEvent(MsgType.CHAT, onReceiveChatMsg(updateScroll, capacity)) //绑定接收Chat消息的事件
wsConnect.bindEvent(MsgType.GROUP_CHAT, onReceiveGroupChatMsg(updateScroll, capacity)) //绑定接收GroupChat消息的事件
wsConnect.bindGroupSystemMsgEvent(onReceiveGroupSystemMsg(updateScroll, capacity)) //绑定接收群系统消息事件
// 这里要接收从其他页面跳转过来传递的sessionId参数
const routerSessionId = router.currentRoute.value.query.sessionId
@@ -277,10 +285,11 @@ const handleMsgListWheel = async () => {
}
const clientHeight = document.querySelector('.message-main').clientHeight
const diffToBottom = msgListDiv.value.scrollHeight - msgListDiv.value.scrollTop - clientHeight
// diffToBottom接近50个像素的时候关闭底部未读tips控件
newMsgTips.value.isShowBottomTips = diffToBottom < 50 ? false : newMsgTips.value.isShowBottomTips
// isShowReturnBottom.value = diffToBottom > 300 // 控制是否显示"回到底部"的按钮。暂时取消这个提示功能,与消息提示的按钮显得有点重复
disToBottom.value = msgListDiv.value.scrollHeight - msgListDiv.value.scrollTop - clientHeight
// disToBottom接近50个像素的时候关闭底部未读tips控件
newMsgTips.value.isShowBottomTips =
disToBottom.value < nearBottomDis ? false : newMsgTips.value.isShowBottomTips
// isShowReturnBottom.value = disToBottom.value > 300 // 控制是否显示"回到底部"的按钮。暂时取消这个提示功能,与消息提示的按钮显得有点重复
if (newMsgTips.value.firstElement?.getBoundingClientRect().top > 0) {
newMsgTips.value.isShowTopTips = false
@@ -311,12 +320,10 @@ const sessionListSorted = computed(() => {
const a_msgIds_len = a_msgIds?.length
if (!a_msgIds_len) return 1
const a_lastMsg = messageData.getMsg(a.sessionId, a_msgIds[a_msgIds_len - 1])
const b_msgIds = messageData.msgIdSortArray[b.sessionId]
const b_msgIds_len = b_msgIds?.length
if (!b_msgIds_len) return -1
const b_lastMsg = messageData.getMsg(b.sessionId, b_msgIds[b_msgIds_len - 1])
const bTime = new Date(b_lastMsg.msgTime).getTime()
const aTIme = new Date(a_lastMsg.msgTime).getTime()
if (bTime !== aTIme) {
@@ -408,7 +415,7 @@ const pullMsg = async (endMsgId = null) => {
const res = await msgChatPullMsgService(params)
const msgCount = res.data.data.count
if (msgCount > 0) {
messageData.addMsgRecords(sessionId, res.data.data.msgList)
await messageData.addMsgRecords(sessionId, res.data.data.msgList)
}
if (msgCount < pageSize) {
@@ -481,35 +488,35 @@ const handleSendMessage = (content, resendSeq = '') => {
return
}
isShowEmojiBox.value = false
if (inputToolBarRef.value) inputToolBarRef.value.closeWindow()
const msg = {
sessionId: selectedSessionId.value,
fromId: myAccount.value,
msgType: selectedSession.value.sessionType,
content: content,
status: 'pending',
status: msgSendStatus.PENDING,
msgTime: new Date(),
sendTime: new Date()
}
const resendInterval = 2000 //2秒
const callbackBefore = (seq, data) => {
const before = async (seq, data) => {
// 当2s内status如果还是pending中则重发3次。如果最后还是pending则把status置为failed
setTimeout(() => {
if (msg.status === 'pending') {
if (msg.status === msgSendStatus.PENDING) {
wsConnect.sendAgent(data)
setTimeout(() => {
if (msg.status === 'pending') {
if (msg.status === msgSendStatus.PENDING) {
wsConnect.sendAgent(data)
setTimeout(() => {
if (msg.status === 'pending') {
if (msg.status === msgSendStatus.PENDING) {
wsConnect.sendAgent(data)
setTimeout(() => {
if (msg.status === 'pending') {
messageData.removeMsgRecord(selectedSessionId.value, msg.msgId)
msg.status = 'failed'
messageData.addMsgRecords(selectedSessionId.value, [msg])
setTimeout(async () => {
if (msg.status === msgSendStatus.PENDING) {
messageData.removeMsgRecord(msg.sessionId, msg.msgId)
msg.status = msgSendStatus.FAILED
await messageData.addMsgRecords(msg.sessionId, [msg])
ElMessage.error('消息发送失败')
}
}, resendInterval)
@@ -521,42 +528,43 @@ const handleSendMessage = (content, resendSeq = '') => {
}, resendInterval)
messageData.updateSession({
sessionId: selectedSessionId.value,
sessionId: msg.sessionId,
unreadCount: 0, // 最后一条消息是自己发的因此未读是0
draft: '' //草稿意味着要清空
})
msg.seq = seq
msg.msgId = seq //服务器没有回复DELIVERED消息之前都用seq暂代msgId
messageData.removeMsgRecord(selectedSessionId.value, msg.msgId)
messageData.addMsgRecords(selectedSessionId.value, [msg])
await messageData.addMsgRecords(msg.sessionId, [msg])
}
const callbackAfter = (msgId) => {
const after = async (msgId) => {
messageData.updateSession({
sessionId: selectedSessionId.value,
sessionId: msg.sessionId,
readMsgId: msgId, // 最后一条消息是自己发的因此已读更新到刚发的这条消息的msgId
readTime: new Date()
})
messageData.removeMsgRecord(selectedSessionId.value, msg.msgId) //移除seq为key的msg
messageData.removeMsgRecord(msg.sessionId, msg.msgId) //移除seq为key的msg
msg.msgId = msgId
msg.status = 'ok'
messageData.addMsgRecords(selectedSessionId.value, [msg]) //添加服务端返回msgId为key的msg
if (!messageData.sessionList[selectedSessionId.value].dnd) {
msg.status = msgSendStatus.OK
await messageData.addMsgRecords(msg.sessionId, [msg]) //添加服务端返回msgId为key的msg
if (!messageData.sessionList[msg.sessionId].dnd) {
playMsgSend()
}
}
wsConnect.sendMsg(
selectedSessionId.value,
msg.sessionId,
showId.value,
selectedSession.value.sessionType,
content,
resendSeq,
callbackBefore,
callbackAfter
before,
after
)
locateSession(selectedSessionId.value)
capacity.value++
msgListReachBottom()
locateSession(msg.sessionId)
}
const handleResendMessage = ({ content, seq }) => {
@@ -584,11 +592,10 @@ const onLoadMore = async () => {
})
}
// MessageItem最后一个msg渲染结束后的动作
const renderFinished = () => {
setTimeout(() => {
const updateScroll = () => {
if (disToBottom.value < nearBottomDis) {
msgListReachBottom('smooth')
}, 100)
}
}
/**
@@ -596,13 +603,27 @@ const renderFinished = () => {
* @param behavior smooth 平滑的, instant 立即(默认)
*/
const msgListReachBottom = (behavior = 'instant') => {
setTimeout(() => {
msgListDiv.value?.scrollTo({
top: msgListDiv.value.scrollHeight,
behavior: behavior
const scrollToBottom = () => {
setTimeout(() => {
msgListDiv.value.scrollTo({
top: msgListDiv.value.scrollHeight,
behavior: behavior
})
newMsgTips.value.isShowBottomTips = false
disToBottom.value = 0
}, 50)
}
if (msgListDiv.value) {
scrollToBottom()
} else {
const stopWatch = watch(msgListDiv, (newValue) => {
if (newValue) {
scrollToBottom()
stopWatch() // 停止监听
}
})
newMsgTips.value.isShowBottomTips = false
}, 0)
}
}
const onReturnBottom = () => {
@@ -960,6 +981,32 @@ const inputEditorRef = ref()
const onSendEmoji = (key) => {
inputEditorRef.value.addEmoji(key)
}
const onSendImage = ({ objectId }) => {
handleSendMessage(JSON.stringify({ type: msgContentType.IMAGE, value: objectId }))
}
const onSendAudio = ({ objectId }) => {
handleSendMessage(JSON.stringify({ type: msgContentType.AUDIO, value: objectId }))
}
const onSendRecording = ({ objectId }) => {
handleSendMessage(JSON.stringify({ type: msgContentType.RECORDING, value: objectId }))
}
const onSendVideo = ({ objectId }) => {
handleSendMessage(JSON.stringify({ type: msgContentType.VIDEO, value: objectId }))
}
const onSendDocument = ({ objectId }) => {
handleSendMessage(JSON.stringify({ type: msgContentType.DOCUMENT, value: objectId }))
}
const inputRecorderRef = ref(null)
const isShowRecorder = ref(false)
const onShowRecorder = () => {
isShowRecorder.value = true
}
</script>
<template>
@@ -1048,7 +1095,7 @@ const onSendEmoji = (key) => {
:title="selectedSession.sessionType === MsgType.GROUP_CHAT ? '多人语音' : '语音通话'"
@click="onVoiceCall"
>
<Microphone />
<Phone />
</el-icon>
<el-icon
class="action-button"
@@ -1109,7 +1156,7 @@ const onSendEmoji = (key) => {
@showUserCard="onShowUserCard"
@showGroupCard="onShowGroupCard"
@resendMsg="handleResendMessage"
@renderFinished="renderFinished"
@loadFinished="updateScroll"
></MessageItem>
</div>
<el-button
@@ -1141,51 +1188,27 @@ const onSendEmoji = (key) => {
</el-button>
</div>
<div class="input-box bdr-t" :style="{ height: inputBoxHeight + 'px' }">
<el-container class="input-box-container">
<el-container v-if="isShowRecorder">
<InputRecorder
ref="inputRecorderRef"
:sessionId="selectedSessionId"
@exit="isShowRecorder = false"
@sendRecording="onSendRecording"
></InputRecorder>
</el-container>
<el-container v-else class="input-box-container">
<el-header class="input-box-header">
<div class="tool-set">
<div v-if="!isNotInGroup" class="left-tools">
<InputTool tips="表情" @click="isShowEmojiBox = true">
<template #iconSlot>
<EmojiIcon />
</template>
</InputTool>
<InputTool tips="文件" @click="ElMessage.warning('功能开发中')">
<template #iconSlot>
<FolderAdd />
</template>
</InputTool>
<InputTool tips="代码" @click="ElMessage.warning('功能开发中')">
<template #iconSlot>
<CreditCard />
</template>
</InputTool>
<InputTool tips="位置" @click="ElMessage.warning('功能开发中')">
<template #iconSlot>
<LocationInformation />
</template>
</InputTool>
</div>
<div class="right-tools">
<InputTool tips="聊天记录" @click="ElMessage.warning('功能开发中')">
<template #iconSlot>
<Clock />
</template>
</InputTool>
</div>
</div>
<DragLine
direction="top"
:min="inputBoxHeightMin"
:max="inputBoxHeightMax"
:origin-size="inputBoxHeight"
@drag-update="onInputBoxDragUpdate"
></DragLine>
<EmojiBox
:isShow="isShowEmojiBox"
@close="isShowEmojiBox = false"
<InputToolBar
ref="inputToolBarRef"
:sessionId="selectedSessionId"
:isShowToolSet="!isNotInGroup"
@sendEmoji="onSendEmoji"
></EmojiBox>
@sendImage="onSendImage"
@sendAudio="onSendAudio"
@sendVideo="onSendVideo"
@sendDocument="onSendDocument"
@showRecorder="onShowRecorder"
></InputToolBar>
</el-header>
<el-main class="input-box-main">
<div
@@ -1211,6 +1234,13 @@ const onSendEmoji = (key) => {
></InputEditor>
</el-main>
</el-container>
<DragLine
direction="top"
:min="inputBoxHeightMin"
:max="inputBoxHeightMax"
:origin-size="inputBoxHeight"
@drag-update="onInputBoxDragUpdate"
></DragLine>
</div>
</div>
<MessageGroupRightSide
@@ -1440,26 +1470,13 @@ const onSendEmoji = (key) => {
.input-box {
width: 100%;
display: flex;
position: relative;
.input-box-header {
width: 100%;
height: auto;
padding: 0;
position: relative;
.tool-set {
display: flex;
position: relative;
.left-tools {
display: flex;
}
.right-tools {
position: absolute;
right: 0;
}
}
}
.input-box-main {

View File

@@ -0,0 +1,62 @@
<script setup>
import { onMounted, computed } from 'vue'
import AudioFileIcon from '@/assets/svg/audiofile.svg'
import { formatFileSize } from '@/js/utils/common'
const props = defineProps(['url', 'fileName', 'size'])
const emits = defineEmits(['load'])
const formatSize = computed(() => {
return formatFileSize(props.size)
})
onMounted(() => {
emits('load') //向父组件暴露load事件
})
</script>
<template>
<div class="audio-msg-wrapper">
<AudioFileIcon />
<div class="main">
<span class="file-name text-ellipsis" :title="props.fileName || '未知'">
{{ props.fileName || '未知' }}
</span>
<div class="footer">
<div class="size" :title="formatSize">{{ formatSize }}</div>
<a :href="props.url" :download="props.fileName">下载</a>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.audio-msg-wrapper {
padding: 4px 8px 4px 8px;
display: flex;
gap: 10px;
.svg-icon {
width: 48px;
height: 48px;
}
.main {
width: 140px;
display: flex;
flex-direction: column;
justify-content: space-around;
gap: 8px;
.file-name {
font-size: 14px;
}
.footer {
display: flex;
justify-content: space-between;
font-size: 12px;
}
}
}
</style>

View File

@@ -0,0 +1,111 @@
<script setup>
import { onMounted, computed } from 'vue'
import { formatFileSize } from '@/js/utils/common'
import DocumentIcon from '@/assets/svg/document.svg'
import ArchiveIcon from '@/assets/svg/archive.svg'
import FileTemplateIcon from '@/assets/svg/filetemplate.svg'
const props = defineProps(['url', 'fileName', 'contentType', 'size'])
const emits = defineEmits(['load'])
const iconMap = {
'text/csv': 'CSV',
'application/msword': 'DOC',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'DOCX',
'text/html': 'HTML',
'application/pdf': 'PDF',
'application/vnd.ms-powerpoint': 'PPT',
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'PPTX',
'text/plain': 'TXT',
'application/vnd.ms-works': 'WPS',
'application/vnd.ms-excel': 'XLS',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'XLSX',
'application/zip': ArchiveIcon,
'application/vnd.rar': ArchiveIcon,
'application/x-zip-compressed': ArchiveIcon,
'application/x-7z-compressed': ArchiveIcon,
'application/x-tar': ArchiveIcon,
'application/gzip': ArchiveIcon,
'application/x-bzip2': ArchiveIcon
}
const iconComponent = computed(() => {
return iconMap[props.contentType] || DocumentIcon
})
const formatSize = computed(() => {
return formatFileSize(props.size)
})
onMounted(() => {
emits('load') //向父组件暴露load事件
})
</script>
<template>
<div class="document-msg-wrapper">
<div v-if="typeof iconComponent === 'string'" class="file-template">
<FileTemplateIcon></FileTemplateIcon>
<span class="extension">{{ iconComponent }}</span>
</div>
<component v-else :is="iconComponent" />
<div class="main">
<span class="file-name text-ellipsis" :title="props.fileName || '未知'">
{{ props.fileName || '未知' }}
</span>
<div class="footer">
<div class="size" :title="formatSize">{{ formatSize }}</div>
<a :href="props.url" :download="props.fileName">下载</a>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.document-msg-wrapper {
padding: 4px 8px 4px 8px;
display: flex;
gap: 10px;
.file-template {
position: relative;
.extension {
width: 100%;
position: absolute;
left: 0;
top: 16px;
font-size: 16px;
line-height: 24px;
font-weight: bold;
letter-spacing: -2px;
text-align: center;
color: #fff;
user-select: none;
}
}
.svg-icon {
width: 48px;
height: 48px;
}
.main {
width: 140px;
display: flex;
flex-direction: column;
justify-content: space-around;
gap: 8px;
.file-name {
font-size: 14px;
}
.footer {
display: flex;
justify-content: space-between;
font-size: 12px;
}
}
}
</style>

View File

@@ -16,9 +16,16 @@ const clickListener = (e) => {
}
}
const handleEscKey = (e) => {
if (e.key === 'Escape') {
close()
}
}
const close = () => {
isShowDialog.value = false
document.removeEventListener('click', clickListener)
document.removeEventListener('keydown', handleEscKey)
emit('close')
}
@@ -30,10 +37,12 @@ watch(
tabOption.value = 'system'
setTimeout(() => {
document.addEventListener('click', clickListener)
document.addEventListener('keydown', handleEscKey)
}, 0)
} else {
// 父组件通过props.isShow关闭时也要注销掉listener
document.removeEventListener('click', clickListener)
document.removeEventListener('keydown', handleEscKey)
}
}
)
@@ -63,7 +72,7 @@ const onSelectEmoji = (key) => {
<style lang="scss" scoped>
.emoji-box {
width: 472px;
width: 480px;
height: 240px;
padding: 0 5px 0 5px;
position: absolute;

View File

@@ -0,0 +1,102 @@
<script setup>
import { computed } from 'vue'
import { ElImage } from 'element-plus'
import { formatFileSize } from '@/js/utils/common'
const props = defineProps(['url', 'imgId', 'srcList', 'initialIndex', 'fileName', 'size'])
const emits = defineEmits(['load'])
const onLoad = (e) => {
const img = e.target
const ratio = img.naturalWidth / img.naturalHeight
const maxRatio = 300 / 200 // 最大宽高比
// 如果图片尺寸在限制范围内,保持原始尺寸
if (img.naturalWidth <= 300 && img.naturalHeight <= 200) {
img.style.width = img.naturalWidth + 'px'
img.style.height = img.naturalHeight + 'px'
} else if (ratio > maxRatio) {
// 如果图片更宽,以宽度为基准
img.style.width = '300px'
img.style.height = 'auto'
} else {
// 如果图片更高,以高度为基准
img.style.height = '200px'
img.style.width = 'auto'
}
emits('load') //向父组件暴露load事件
}
const formatSize = computed(() => {
return formatFileSize(props.size)
})
</script>
<template>
<div class="image-msg-wrapper">
<el-image
:src="props.url"
:alt="props.imgId"
:preview-src-list="props.srcList"
:initial-index="props.initialIndex"
:infinite="false"
:lazy="false"
fit="contain"
@load="onLoad"
>
</el-image>
<div v-if="props.fileName || props.size > 0" class="info">
<span class="name item text-ellipsis" :title="props.fileName">
{{ props.fileName || '' }}
</span>
<span class="size item text-ellipsis" :title="formatSize">
{{ formatSize }}
</span>
</div>
</div>
</template>
<style lang="scss" scoped>
.image-msg-wrapper {
display: flex;
position: relative;
.el-image {
max-width: 300px;
max-height: 200px;
width: auto;
height: auto;
:deep(.el-image__inner) {
margin: 0;
}
}
.info {
width: 100%;
height: 32px;
line-height: 32px;
left: 0;
bottom: 0;
display: flex;
justify-content: space-between;
background: linear-gradient(to bottom, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.5));
position: absolute;
.item {
max-width: 40%;
color: #fff;
font-size: 12px;
}
.name {
margin-left: 8px;
}
.size {
margin-right: 8px;
}
}
}
</style>

View File

@@ -1,9 +1,9 @@
<script setup>
import { QuillEditor, Delta, Quill } from '@vueup/vue-quill'
import '@vueup/vue-quill/dist/vue-quill.snow.css'
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { onMounted, onUnmounted, onBeforeUnmount, ref, watch } from 'vue'
import { v4 as uuidv4 } from 'uuid'
import { messageStore, imageStore } from '@/stores'
import { useMessageStore, useImageStore } from '@/stores'
import { ElMessage, ElLoading } from 'element-plus'
import { emojiTrans, getEmojiHtml } from '@/js/utils/emojis'
import { base64ToFile } from '@/js/utils/common'
@@ -12,8 +12,8 @@ import { el_loading_options } from '@/const/commonConst'
const props = defineProps(['sessionId', 'draft'])
const emit = defineEmits(['sendMessage'])
const messageData = messageStore()
const imageData = imageStore()
const messageData = useMessageStore()
const imageData = useImageStore()
const editorRef = ref()
@@ -36,6 +36,18 @@ onMounted(async () => {
})
})
onBeforeUnmount(async () => {
let content = await getContent()
// 草稿若没发生变动,则不触发存储
const draft = messageData.sessionList[props.sessionId]?.draft
if (content && draft && content !== draft) {
messageData.updateSession({
sessionId: props.sessionId,
draft: content
})
}
})
onUnmounted(() => {
if (editorRef.value) {
document.querySelector('.ql-editor').classList.remove('my-scrollbar')

View File

@@ -0,0 +1,273 @@
<script setup>
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
import { Microphone } from '@element-plus/icons-vue'
import { ElLoading, ElMessage } from 'element-plus'
import { useAudioStore } from '@/stores'
import { mtsUploadService } from '@/api/mts'
import { el_loading_options } from '@/const/commonConst'
import { v4 as uuidv4 } from 'uuid'
const props = defineProps(['sessionId'])
const emit = defineEmits(['exit', 'sendRecording'])
const audioData = useAudioStore()
const spaceDown = ref(false) // 空格键是否被按下
const isRecord = ref(false) // 是否开始录音
const isCancel = ref(false) // 取消发送
const mediaRecorder = ref(null)
const recordedChunks = ref([])
const recordBlob = ref(null)
const recordType = 'audio/webm;codecs=opus'
const fileSuffix = 'webm'
let recordStart = 0 // 录制开始时间
let recordDuration = 0 // 录制时长
const dynamicDuration = ref(0)
let dynamicDurationInterval = null
let timer
const handleKeyDown = async (event) => {
if (event.key === 'Escape') {
if (isRecord.value) {
cancelSend()
} else {
emit('exit')
}
} else if (event.key === ' ' && !spaceDown.value) {
event.preventDefault()
clearTimeout(timer)
timer = setTimeout(() => {
isRecord.value = true
spaceDown.value = true
isCancel.value = false
}, 300)
}
}
const handleKeyUp = (event) => {
if (event.key === ' ') {
clearTimeout(timer)
if (spaceDown.value) {
event.preventDefault()
isRecord.value = false
spaceDown.value = false
dynamicDuration.value = 0
clearInterval(dynamicDurationInterval)
stopRecording()
}
}
}
const handleExit = () => {
emit('exit')
}
const cancelSend = () => {
isRecord.value = false
isCancel.value = true
dynamicDuration.value = 0
clearInterval(dynamicDurationInterval)
recordedChunks.value = []
stopRecording()
}
const startRecording = async () => {
// 检查是否有麦克风授权
const permission = await navigator.permissions.query({ name: 'microphone' })
const initPermissionState = permission.state
if (permission.state === 'denied') {
ElMessage.warning('您拒绝授权麦克风,无法发送语音')
return
}
navigator.mediaDevices
.getUserMedia({ audio: true })
.then((mediaStream) => {
mediaRecorder.value = new MediaRecorder(mediaStream)
// 初次授权要弹出窗口,空格键可能已经弹起,因此先不录音直接返回
if (initPermissionState === 'prompt') {
// 授权时跳出授权窗口会使监听按键弹起的事件失效,状态需要手动更新
isRecord.value = false
spaceDown.value = false
stopRecording()
return
}
mediaRecorder.value.onstart = () => {
recordStart = new Date().getTime()
dynamicDurationInterval = setInterval(() => {
dynamicDuration.value = Math.floor((new Date().getTime() - recordStart) / 1000)
}, 1000)
}
mediaRecorder.value.ondataavailable = (event) => {
if (event.data.size > 0) {
recordedChunks.value.push(event.data)
}
}
mediaRecorder.value.onstop = () => {
recordDuration = new Date().getTime() - recordStart
recordBlob.value = new Blob(recordedChunks.value, { type: recordType })
if (!isCancel.value) {
// 语音时长过短不予处理单位ms
if (recordDuration > 1000) {
uploadRecord()
} else {
ElMessage.warning('语音时长过短')
}
}
recordedChunks.value = []
recordStart = 0
recordDuration = 0
}
mediaRecorder.value.start()
})
.catch(() => {
// 用户不授权,也要把状态手动更新
isRecord.value = false
spaceDown.value = false
})
}
const stopRecording = () => {
if (mediaRecorder.value) {
if (mediaRecorder.value.state !== 'inactive') {
mediaRecorder.value.stop()
}
const stream = mediaRecorder.value.stream
stream.getTracks().forEach((track) => track.stop()) // 停止 MediaStream 中的所有音轨
}
}
const uploadRecord = () => {
const loadingInstance = ElLoading.service(el_loading_options)
const fileName = `${uuidv4()}.${fileSuffix}`
const file = new File([recordBlob.value], fileName, { type: recordType })
mtsUploadService({ file, storeType: 1, duration: Math.floor(recordDuration / 1000) })
.then((res) => {
if (res.data.code === 0) {
audioData.setAudio(props.sessionId, res.data.data) // 缓存audio的数据
emit('sendRecording', res.data.data)
}
})
.finally(() => {
loadingInstance.close()
})
}
// 格式化时间
const formatDynamicDuration = computed(() => {
if (!dynamicDuration.value) {
return ''
}
const minutes = Math.floor(dynamicDuration.value / 60)
const seconds = Math.floor(dynamicDuration.value % 60)
return `${minutes}:${seconds.toString().padStart(2, '0')}`
})
watch(
() => isRecord.value,
async (newValue) => {
if (newValue) {
await startRecording()
}
}
)
defineExpose({
cancelSend
})
onMounted(async () => {
window.addEventListener('keydown', handleKeyDown)
window.addEventListener('keyup', handleKeyUp)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('keyup', handleKeyUp)
})
</script>
<template>
<div class="audio-recorder">
<div class="tips" style="height: 20px">{{ formatDynamicDuration }}</div>
<div class="recorder-icon-wrapper">
<Microphone class="recorder-icon" />
<div v-show="isRecord" class="sound-wave"></div>
</div>
<span v-if="isRecord" class="tips">
松开发送按Esc键或点击
<span @click="cancelSend" class="button-text">取消发送</span>
</span>
<span v-else class="tips">
长按空格键说话按Esc键或点击
<span @click="handleExit" class="button-text">退出</span>
</span>
</div>
</template>
<style lang="scss" scoped>
.audio-recorder {
width: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 16px;
.recorder-icon-wrapper {
width: 32px;
height: 32px;
padding: 16px;
border-radius: 50%;
background: radial-gradient(circle, #90c0f3 30%, #409eff 70%);
box-shadow: 0 0 10px rgba(64, 158, 255, 0.5);
display: flex;
justify-content: center;
align-items: center;
position: relative;
.recorder-icon {
color: white;
}
.sound-wave {
position: absolute;
transform: translate(-50%, -50%);
width: calc(100% - 4px);
height: calc(100% - 4px);
border-radius: 50%;
border: 2px solid rgba(64, 158, 255, 0.5);
animation: soundVibration 0.5s infinite alternate;
pointer-events: none;
}
}
.tips {
font-size: 14px;
color: gray;
.button-text {
color: #409eff;
cursor: pointer;
}
}
}
@keyframes soundVibration {
0% {
transform: scale(0.8);
opacity: 0.7;
}
100% {
transform: scale(1.2);
opacity: 1;
}
}
</style>

View File

@@ -14,7 +14,7 @@ const props = defineProps(['tips'])
.tool-icon-wrapper {
width: 30px;
height: 30px;
margin: 5px;
padding: 5px;
border-radius: 4px;
display: flex;
justify-content: center;

View File

@@ -0,0 +1,209 @@
<script setup>
import { ref } from 'vue'
import { Clock, Microphone } from '@element-plus/icons-vue'
import { ElMessage, ElLoading } from 'element-plus'
import EmojiIcon from '@/assets/svg/emoji.svg'
import FileIcon from '@/assets/svg/file.svg'
import ImageIcon from '@/assets/svg/image.svg'
import CodeIcon from '@/assets/svg/code.svg'
import VoteIcon from '@/assets/svg/vote.svg'
import EmojiBox from './EmojiBox.vue'
import InputTool from '@/views/message/components/InputTool.vue'
import { mtsUploadService } from '@/api/mts'
import {
useMessageStore,
useImageStore,
useAudioStore,
useVideoStore,
useDocumentStore
} from '@/stores'
import { el_loading_options } from '@/const/commonConst'
import { MsgType } from '@/proto/msg'
const props = defineProps(['sessionId', 'isShowToolSet'])
const emit = defineEmits([
'sendEmoji',
'sendImage',
'sendAudio',
'sendVideo',
'sendDocument',
'showRecorder'
])
const messageData = useMessageStore()
const imageData = useImageStore()
const audioData = useAudioStore()
const videoData = useVideoStore()
const documentData = useDocumentStore()
const isShowEmojiBox = ref(false)
const onSelectedFile = (file) => {
if (!file) {
return
}
if (file.raw.type && file.raw.type.startsWith('image/')) {
const loadingInstance = ElLoading.service(el_loading_options)
mtsUploadService({ file: file.raw, storeType: 1 })
.then((res) => {
if (res.data.code === 0) {
imageData.setImage(props.sessionId, res.data.data) // 缓存image数据
emit('sendImage', res.data.data)
}
})
.finally(() => {
loadingInstance.close()
})
} else if (file.raw.type && file.raw.type.startsWith('audio/')) {
const loadingInstance = ElLoading.service(el_loading_options)
mtsUploadService({ file: file.raw, storeType: 1 })
.then((res) => {
if (res.data.code === 0) {
audioData.setAudio(props.sessionId, res.data.data) // 缓存audio的数据
emit('sendAudio', res.data.data)
}
})
.finally(() => {
loadingInstance.close()
})
} else if (file.raw.type && file.raw.type.startsWith('video/')) {
const loadingInstance = ElLoading.service(el_loading_options)
mtsUploadService({ file: file.raw, storeType: 1 })
.then((res) => {
if (res.data.code === 0) {
videoData.setVideo(props.sessionId, res.data.data) // 缓存video的数据
emit('sendVideo', res.data.data)
}
})
.finally(() => {
loadingInstance.close()
})
} else {
const loadingInstance = ElLoading.service(el_loading_options)
mtsUploadService({ file: file.raw, storeType: 1 })
.then((res) => {
if (res.data.code === 0) {
documentData.setDocument(props.sessionId, res.data.data) // 缓存video的数据
emit('sendDocument', res.data.data)
}
})
.finally(() => {
loadingInstance.close()
})
}
}
const onSendEmoji = (key) => {
emit('sendEmoji', key)
}
/**
* 关掉bar上弹出的窗口
*/
const closeWindow = () => {
isShowEmojiBox.value = false
}
const showRecorder = () => {
emit('showRecorder')
}
defineExpose({
closeWindow
})
</script>
<template>
<div class="tool-set">
<div v-if="props.isShowToolSet" class="left-tools">
<InputTool tips="表情" @click="isShowEmojiBox = true">
<template #iconSlot>
<EmojiIcon />
</template>
</InputTool>
<el-upload
:auto-upload="false"
:show-file-list="false"
accept="image/*"
:on-change="onSelectedFile"
>
<template #trigger>
<InputTool tips="图片">
<template #iconSlot>
<ImageIcon />
</template>
</InputTool>
</template>
</el-upload>
<el-upload :auto-upload="false" :show-file-list="false" :on-change="onSelectedFile">
<template #trigger>
<InputTool tips="文件">
<template #iconSlot>
<FileIcon />
</template>
</InputTool>
</template>
</el-upload>
<InputTool tips="语音消息" @click="showRecorder">
<template #iconSlot>
<Microphone />
</template>
</InputTool>
<InputTool tips="代码" @click="ElMessage.warning('功能开发中')">
<template #iconSlot>
<CodeIcon />
</template>
</InputTool>
<!-- <InputTool tips="位置" @click="ElMessage.warning('功能开发中')">
<template #iconSlot>
<LocationInformation />
</template>
</InputTool> -->
<InputTool
v-if="messageData.sessionList[props.sessionId].sessionType === MsgType.GROUP_CHAT"
tips="群投票"
@click="ElMessage.warning('功能开发中')"
>
<template #iconSlot>
<VoteIcon />
</template>
</InputTool>
</div>
<div class="right-tools">
<InputTool tips="聊天记录" @click="ElMessage.warning('功能开发中')">
<template #iconSlot>
<Clock />
</template>
</InputTool>
</div>
</div>
<EmojiBox
:isShow="isShowEmojiBox"
@close="isShowEmojiBox = false"
@sendEmoji="onSendEmoji"
></EmojiBox>
</template>
<style lang="scss" scoped>
.tool-set {
height: 42px;
display: flex;
position: relative;
.left-tools {
display: flex;
// 调整文件按钮选中之后的颜色默认是rgb(64, 158, 255)
:deep(.el-upload) {
color: #000;
fill: #000;
outline-color: #000;
}
}
.right-tools {
position: absolute;
right: 0;
}
}
</style>

View File

@@ -1,17 +1,24 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick, markRaw } from 'vue'
import { ChatDotRound, Tickets, Microphone, VideoCamera, Mute } from '@element-plus/icons-vue'
import {
ChatDotRound,
Tickets,
Microphone,
VideoCamera,
Mute,
Phone
} from '@element-plus/icons-vue'
import AtIcon from '@/assets/svg/at.svg'
import adminIcon from '@/assets/svg/administrator.svg'
import DeleteIcon from '@/assets/svg/delete.svg'
import TransferIcon from '@/assets/svg/transfer.svg'
import { userStore, groupStore } from '@/stores'
import { useUserStore, useGroupStore } from '@/stores'
const props = defineProps(['groupId', 'account'])
const emit = defineEmits(['selectMenu'])
const userData = userStore()
const groupData = groupStore()
const userData = useUserStore()
const groupData = useGroupStore()
const myAccount = computed(() => userData.user.account)
@@ -63,7 +70,7 @@ const lable_atTa = ref({
const lable_voiceCall = ref({
label: 'voiceCall',
desc: '语音通话',
icon: markRaw(Microphone)
icon: markRaw(Phone)
})
const lable_videoCall = ref({
label: 'videoCall',

View File

@@ -2,18 +2,24 @@
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { MsgType } from '@/proto/msg'
import { Edit, Search } from '@element-plus/icons-vue'
import { userStore, settingStore, messageStore, groupStore, groupCardStore } from '@/stores'
import {
useUserStore,
useSettingStore,
useMessageStore,
useGroupStore,
useGroupCardStore
} from '@/stores'
import DragLine from '@/components/common/DragLine.vue'
import GroupMembersTable from '@/components/common/GroupMembersTable.vue'
const props = defineProps(['sessionId'])
const emit = defineEmits(['showGroupCard', 'openSession'])
const userData = userStore()
const settingData = settingStore()
const messageData = messageStore()
const groupData = groupStore()
const groupCardData = groupCardStore()
const userData = useUserStore()
const settingData = useSettingStore()
const messageData = useMessageStore()
const groupData = useGroupStore()
const groupCardData = useGroupCardStore()
const msgGroupRightSideWidth = ref(0)
const announcementInSideHeight = ref(0)

View File

@@ -1,12 +1,26 @@
<script setup>
import { computed, onMounted, h, createApp, watch, nextTick, reactive } from 'vue'
import { ElImage } from 'element-plus'
import { WarningFilled } from '@element-plus/icons-vue'
import { MsgType } from '@/proto/msg'
import { userStore, messageStore, groupStore, groupCardStore, imageStore } from '@/stores'
import {
useUserStore,
useMessageStore,
useGroupStore,
useGroupCardStore,
useImageStore,
useAudioStore,
useVideoStore,
useDocumentStore
} from '@/stores'
import { messageSysShowTime, showTimeFormat, jsonParseSafe } from '@/js/utils/common'
import UserAvatarIcon from '@/components/common/UserAvatarIcon.vue'
import { emojis } from '@/js/utils/emojis'
import { msgContentType, msgSendStatus } from '@/const/msgConst'
import RecordingMsgBox from '@/views/message/components/RecordingMsgBox.vue'
import ImageMsgBox from '@/views/message/components/ImageMsgBox.vue'
import AudioMsgBox from '@/views/message/components/AudioMsgBox.vue'
import VideoMsgBox from '@/views/message/components/VideoMsgBox.vue'
import DocumentMsgBox from '@/views/message/components/DocumentMsgBox.vue'
const props = defineProps([
'sessionId',
@@ -20,26 +34,19 @@ const props = defineProps([
'hasNoMoreMsg',
'isLoadMoreLoading'
])
const emit = defineEmits([
'loadMore',
'showUserCard',
'showGroupCard',
'resendMsg',
'renderFinished'
])
const emit = defineEmits(['loadMore', 'showUserCard', 'showGroupCard', 'resendMsg', 'loadFinished'])
const userData = userStore()
const messageData = messageStore()
const groupData = groupStore()
const groupCardData = groupCardStore()
const imageData = imageStore()
const userData = useUserStore()
const messageData = useMessageStore()
const groupData = useGroupStore()
const groupCardData = useGroupCardStore()
const imageData = useImageStore()
const audioData = useAudioStore()
const videoData = useVideoStore()
const documentData = useDocumentStore()
onMounted(() => {
rendering().then(() => {
if (props.lastMsgId === props.msgId) {
emit('renderFinished')
}
})
rendering()
})
let app = null
@@ -57,8 +64,49 @@ const rendering = async () => {
}
}
/**
* 动态渲染消息内容
* @param content 消息内容
*/
const renderComponent = async (content) => {
await imageData.loadImageInfoFromContent(props.sessionId, content)
const contentJson = jsonParseSafe(content)
if (!contentJson) {
return await renderMix(content)
}
const type = contentJson['type']
const value = contentJson['value']
if (!type || !value) {
return await renderMix(content)
}
switch (type) {
case msgContentType.MIX:
return await renderMix(value)
case msgContentType.TEXT:
return renderText(value)
case msgContentType.RECORDING:
return renderRecording(value)
case msgContentType.AUDIO:
return renderAudio(value)
case msgContentType.IMAGE:
return renderImage(value)
case msgContentType.EMOJI:
return renderEmoji(value)
case msgContentType.VIDEO:
return renderVideo(value)
case msgContentType.DOCUMENT:
return renderDocument(value)
default:
return h('div', [])
}
}
const renderText = (content) => {
return h('span', content)
}
const renderMix = async (content) => {
if (!content) return h('div', [])
let contentArray = []
//匹配内容中的图片
@@ -71,42 +119,125 @@ const renderComponent = async (content) => {
})
})
const elements = contentArray.map((item) => {
return contentArray.map((item) => {
if (item.startsWith('{') && item.endsWith('}')) {
const imgId = item.slice(1, -1)
const url = imageData.image[imgId]?.originUrl
if (url) {
const imgIdList = imageData.imageInSession[props.sessionId].sort((a, b) => a - b)
const srcList = imgIdList.map((item) => imageData.image[item].originUrl)
return h(ElImage, {
src: url,
alt: `{${imgId}}`,
fit: 'cover',
previewSrcList: srcList,
initialIndex: imgIdList.indexOf(imgId),
infinite: false
})
} else {
return h('span', item)
}
return renderImage(item.slice(1, -1), false)
} else if (item.startsWith('[') && item.endsWith(']')) {
const emojiId = `[${item.slice(1, -1)}]`
const url = emojis[emojiId]
if (url) {
return h('img', {
class: 'emoji',
src: url,
alt: emojiId,
title: item.slice(1, -1)
})
} else {
return h('span', item)
}
return renderEmoji(item.slice(1, -1))
} else {
return h('span', item)
}
})
return elements
}
const renderEmoji = (content) => {
const emojiId = `[${content}]`
const url = emojis[emojiId]
if (url) {
return h('img', {
class: 'emoji',
src: url,
alt: emojiId,
title: content,
onLoad: () => {
emit('loadFinished')
}
})
} else {
return h('span', `[${content}]`)
}
}
const renderVideo = (content) => {
const videoId = content
const url = videoData.video[videoId]?.url
if (url) {
return h(VideoMsgBox, {
videoId,
url,
fileName: videoData.video[videoId].fileName,
size: videoData.video[videoId].size,
onLoad: () => {
emit('loadFinished')
}
})
} else {
return h('span', `[${content}]`)
}
}
const renderImage = (content, ishowInfo = true) => {
const imgId = content
const url = imageData.image[imgId]?.thumbUrl
if (url) {
const imgIdList = imageData.imageInSession[props.sessionId].sort((a, b) => a - b)
const srcList = imgIdList.map((item) => imageData.image[item].originUrl)
return h(ImageMsgBox, {
url,
imgId,
srcList,
initialIndex: imgIdList.indexOf(imgId),
fileName: ishowInfo ? imageData.image[imgId].fileName : '',
size: ishowInfo ? imageData.image[imgId].size : '',
onLoad: () => {
emit('loadFinished')
}
})
} else {
return h('span', `[${content}]`)
}
}
const renderRecording = (content) => {
const audioId = content
const url = audioData.audio[audioId]?.url
const duration = audioData.audio[audioId]?.duration
if (url) {
return h(RecordingMsgBox, {
audioUrl: import.meta.env.VITE_OSS_CORS_FLAG + url,
duration: duration,
onLoad: () => {
emit('loadFinished')
}
})
} else {
return h('span', `[${content}]`)
}
}
const renderAudio = (content) => {
const audioId = content
const url = audioData.audio[audioId]?.url
if (url) {
return h(AudioMsgBox, {
url: import.meta.env.VITE_OSS_CORS_FLAG + url,
fileName: audioData.audio[audioId].fileName,
size: audioData.audio[audioId].size,
onLoad: () => {
emit('loadFinished')
}
})
} else {
return h('span', `[${content}]`)
}
}
const renderDocument = (content) => {
const documentId = content
const url = documentData.document[documentId]?.url
if (url) {
return h(DocumentMsgBox, {
url: import.meta.env.VITE_OSS_CORS_FLAG + url,
fileName: documentData.document[documentId].fileName,
contentType: documentData.document[documentId].documentType,
size: documentData.document[documentId].size,
onLoad: () => {
emit('loadFinished')
}
})
} else {
return h('span', `[${content}]`)
}
}
const msg = computed(() => {
@@ -114,7 +245,7 @@ const msg = computed(() => {
})
const msgStatus = computed(() => {
return msg.value.status || 'ok'
return msg.value.status || msgSendStatus.OK
})
const isSystemMsg = computed(() => {
@@ -355,16 +486,28 @@ const systemMsgContent = computed(() => {
return `<div style="text-align: center;">${getSysGroupUpdateAvatar(content)}</div>`
case MsgType.SYS_GROUP_SET_ADMIN:
case MsgType.SYS_GROUP_CANCEL_ADMIN:
return `<div style="text-align: center;">${getSysGroupChangeRoleMsgTips(msg.value.msgType, content)}</div>`
return `<div style="text-align: center;">${getSysGroupChangeRoleMsgTips(
msg.value.msgType,
content
)}</div>`
case MsgType.SYS_GROUP_SET_ALL_MUTED:
case MsgType.SYS_GROUP_CANCEL_ALL_MUTED:
return `<div style="text-align: center;">${getSysGroupUpdateAllMuted(msg.value.msgType, content)}</div>`
return `<div style="text-align: center;">${getSysGroupUpdateAllMuted(
msg.value.msgType,
content
)}</div>`
case MsgType.SYS_GROUP_SET_JOIN_APPROVAL:
case MsgType.SYS_GROUP_CANCEL_JOIN_APPROVAL:
return `<div style="text-align: center;">${getSysGroupUpdateJoinApproval(msg.value.msgType, content)}</div>`
return `<div style="text-align: center;">${getSysGroupUpdateJoinApproval(
msg.value.msgType,
content
)}</div>`
case MsgType.SYS_GROUP_SET_HISTORY_BROWSE:
case MsgType.SYS_GROUP_CANCEL_HISTORY_BROWSE:
return `<div style="text-align: center;">${getSysGroupUpdateHistoryBrowse(msg.value.msgType, content)}</div>`
return `<div style="text-align: center;">${getSysGroupUpdateHistoryBrowse(
msg.value.msgType,
content
)}</div>`
case MsgType.SYS_GROUP_OWNER_TRANSFER:
return `<div style="text-align: center;">${getSysGroupOwnerTransfer(content)}</div>`
case MsgType.SYS_GROUP_UPDATE_MEMBER_MUTED:
@@ -403,7 +546,7 @@ const myMsgIsRead = computed(() => {
})
const isShowLoadMore = computed(() => {
// 这里用弱等于==左边是数字右边是string
// 这里用弱等于"=="左边是数字右边是string
if (msg.value.msgId == props.firstMsgId && !props.hasNoMoreMsg) {
return true
} else {
@@ -411,7 +554,7 @@ const isShowLoadMore = computed(() => {
}
})
const isShowNoMoreMsg = computed(() => {
// 这里用弱等于==左边是数字右边是string
// 这里用弱等于"=="左边是数字右边是string
if (msg.value.msgId == props.firstMsgId && props.hasNoMoreMsg) {
return true
} else {
@@ -548,13 +691,13 @@ watch(
</el-header>
<el-main class="message-content">
<div
v-if="msgStatus === 'pending'"
v-if="msgStatus === msgSendStatus.PENDING"
class="my-message-status my-message-status-pending"
>
<div class="loading-circular" v-loading="true"></div>
</div>
<div
v-else-if="msgStatus === 'failed'"
v-else-if="msgStatus === msgSendStatus.FAILED"
class="my-message-status my-message-status-failed"
>
<el-icon color="red" title="点击重发" @click="onResendMsg"
@@ -565,7 +708,7 @@ watch(
<div v-if="myMsgIsRead" class="remote_read"></div>
<div v-else class="remote_unread"></div>
</div>
<div class="div-content" :id="`div-content-${msg.msgId}`"></div>
<div class="div-content" :id="`div-content-${msg.msgId}`">内容加载中...</div>
</el-main>
</el-container>
</el-main>
@@ -597,7 +740,7 @@ watch(
<span>{{ msgTime }}</span>
</el-header>
<el-main class="message-content">
<div class="div-content" :id="`div-content-${msg.msgId}`"></div>
<div class="div-content" :id="`div-content-${msg.msgId}`">内容加载中...</div>
</el-main>
</el-container>
</el-main>

View File

@@ -0,0 +1,139 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import { ElIcon, ElMessage } from 'element-plus'
import PlayIcon from '@/assets/svg/play.svg'
import PauseIcon from '@/assets/svg/pause.svg'
import { AVWaveform } from 'vue-audio-visual'
const props = defineProps(['audioUrl', 'duration'])
const emits = defineEmits(['load'])
const waveformRef = ref(null)
const isPlaying = ref(false)
const audioDuration = ref(null)
// 格式化时间
const formatDuration = computed(() => {
if (!audioDuration.value) {
return '0:00'
}
const minutes = Math.floor(audioDuration.value / 60)
const seconds = Math.floor(audioDuration.value % 60)
return `${minutes}:${seconds.toString().padStart(2, '0')}`
})
const playAudio = async () => {
const audioPlayer = waveformRef.value.querySelector('audio')
if (audioPlayer) {
try {
await audioPlayer.play()
} catch (error) {
ElMessage.error('音频播放遇到问题')
}
}
}
const pauseAudio = async () => {
const audioPlayer = waveformRef.value.querySelector('audio')
if (audioPlayer) {
try {
await audioPlayer.pause()
} catch (error) {
ElMessage.error('音频暂停遇到问题')
}
}
}
// 播放/暂停音频
const togglePlay = async () => {
if (isPlaying.value) {
pauseAudio()
} else {
playAudio()
}
isPlaying.value = !isPlaying.value
}
onMounted(() => {
const audioPlayer = waveformRef.value.querySelector('audio')
if (audioPlayer) {
// 监听播放事件
audioPlayer.addEventListener('play', () => {
isPlaying.value = true
})
// 监听暂停事件
audioPlayer.addEventListener('pause', () => {
isPlaying.value = false
})
// 监听音频元数据加载完成事件
audioPlayer.addEventListener('loadedmetadata', () => {
// 媒体文件中语音消息没有duration信息所以要从服务端返回的字段获取
if (audioPlayer.duration !== Infinity) {
audioDuration.value = audioPlayer.duration
} else {
audioDuration.value = props.duration
}
})
}
emits('load') //触发load事件
})
</script>
<template>
<div ref="waveformRef" class="audio-player">
<div class="play-button" @click="togglePlay">
<el-icon v-if="isPlaying"><PauseIcon /></el-icon>
<el-icon v-else><PlayIcon /></el-icon>
</div>
<AVWaveform
:src="props.audioUrl"
:audio-controls="false"
:playtime="false"
:canv-width="120"
:canv-height="40"
:playtime-slider-color="`#409eff`"
></AVWaveform>
<span class="time">{{ formatDuration }}</span>
</div>
</template>
<style lang="scss" scoped>
.audio-player {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 10px 5px 10px;
background-color: #f5f5f5;
border-radius: 4px;
:deep(canvas) {
cursor: pointer;
}
.play-button {
padding: 8px;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
background-color: #fff;
cursor: pointer;
&:hover {
background-color: #c6e2ff;
color: #409eff;
}
}
.time {
font-size: 12px;
color: #606266;
text-align: center;
}
}
</style>

View File

@@ -6,10 +6,11 @@ import SessionTag from './SessionTag.vue'
import { jsonParseSafe, sessionShowTime } from '@/js/utils/common'
import { Top, MuteNotification } from '@element-plus/icons-vue'
import { MsgType } from '@/proto/msg'
import { userStore, messageStore, groupStore } from '@/stores'
import { useUserStore, useMessageStore, useGroupStore } from '@/stores'
import { msgChatCloseSessionService } from '@/api/message'
import router from '@/router'
import { ElMessage } from 'element-plus'
import { msgContentType, msgSendStatus } from '@/const/msgConst'
const props = defineProps([
'sessionId',
@@ -25,9 +26,9 @@ const emit = defineEmits([
'noneSelected',
'showUpdateMarkDialog'
])
const messageData = messageStore()
const groupData = groupStore()
const userData = userStore()
const messageData = useMessageStore()
const groupData = useGroupStore()
const userData = useUserStore()
const myAccount = computed(() => userData.user.account)
const sessionInfo = computed(() => {
return messageData.sessionList[props.sessionId]
@@ -216,55 +217,78 @@ const getGroupChatMsgTips = (content) => {
const showDetailContent = computed(() => {
if (isShowDraft.value) {
return sessionInfo.value.draft
return sessionInfo.value.draft?.replace(/\{\d+\}/g, '[图片]') // 把内容中的`{xxxxxx}`格式的图片统一转成`[图片]`
} else {
const replaceContent = lastMsg.value.content?.replace(/\{\d+\}/g, '{图片}') // 把内容中的`{xxxxxx}`格式的图片统一转成`{图片}`
if (replaceContent) {
if (sessionInfo.value.sessionType === MsgType.GROUP_CHAT) {
const content = jsonParseSafe(replaceContent)
switch (lastMsg.value.msgType) {
case MsgType.SYS_GROUP_CREATE:
return getSysGroupCreateMsgTips(content)
case MsgType.SYS_GROUP_ADD_MEMBER:
return getSysGroupAddMemberMsgTips(content)
case MsgType.SYS_GROUP_DEL_MEMBER:
return getSysGroupDelMemberMsgTips(content)
case MsgType.SYS_GROUP_UPDATE_ANNOUNCEMENT:
return getSysGroupUpdateAnnouncement(content)
case MsgType.SYS_GROUP_UPDATE_NAME:
return getSysGroupUpdateName(content)
case MsgType.SYS_GROUP_UPDATE_AVATAR:
return getSysGroupUpdateAvatar(content)
case MsgType.SYS_GROUP_SET_ADMIN:
case MsgType.SYS_GROUP_CANCEL_ADMIN:
return getSysGroupChangeRoleMsgTips(lastMsg.value.msgType, content)
case MsgType.SYS_GROUP_SET_ALL_MUTED:
case MsgType.SYS_GROUP_CANCEL_ALL_MUTED:
return getSysGroupUpdateAllMuted(lastMsg.value.msgType, content)
case MsgType.SYS_GROUP_SET_JOIN_APPROVAL:
case MsgType.SYS_GROUP_CANCEL_JOIN_APPROVAL:
return getSysGroupUpdateJoinApproval(lastMsg.value.msgType, content)
case MsgType.SYS_GROUP_SET_HISTORY_BROWSE:
case MsgType.SYS_GROUP_CANCEL_HISTORY_BROWSE:
return getSysGroupUpdateHistoryBrowse(lastMsg.value.msgType, content)
case MsgType.SYS_GROUP_OWNER_TRANSFER:
return getSysGroupOwnerTransfer(content)
case MsgType.SYS_GROUP_UPDATE_MEMBER_MUTED:
return getSysGroupUpdateMemberMuted(content)
case MsgType.SYS_GROUP_LEAVE:
return getSysGroupLeave(content)
case MsgType.SYS_GROUP_DROP:
return getSysGroupDrop(content)
case MsgType.GROUP_CHAT:
return getGroupChatMsgTips(replaceContent)
default:
return ''
}
if (!lastMsg.value.content) {
return '...'
}
const jsonContent = jsonParseSafe(lastMsg.value.content)
let template
if (jsonContent && jsonContent['type'] && jsonContent['value']) {
if (jsonContent['type'] == msgContentType.IMAGE) {
template = '[图片]'
} else if (jsonContent['type'] == msgContentType.AUDIO) {
template = '[音频]'
} else if (jsonContent['type'] == msgContentType.RECORDING) {
template = '[语音]'
} else if (jsonContent['type'] == msgContentType.VIDEO) {
template = '[视频]'
} else if (jsonContent['type'] == msgContentType.DOCUMENT) {
template = '[文件]'
} else {
return replaceContent
template = jsonContent['value']
}
if (sessionInfo.value.sessionType === MsgType.GROUP_CHAT) {
return getGroupChatMsgTips(template)
} else {
return template
}
}
if (sessionInfo.value.sessionType === MsgType.GROUP_CHAT) {
const content = jsonParseSafe(lastMsg.value.content)
switch (lastMsg.value.msgType) {
case MsgType.SYS_GROUP_CREATE:
return getSysGroupCreateMsgTips(content)
case MsgType.SYS_GROUP_ADD_MEMBER:
return getSysGroupAddMemberMsgTips(content)
case MsgType.SYS_GROUP_DEL_MEMBER:
return getSysGroupDelMemberMsgTips(content)
case MsgType.SYS_GROUP_UPDATE_ANNOUNCEMENT:
return getSysGroupUpdateAnnouncement(content)
case MsgType.SYS_GROUP_UPDATE_NAME:
return getSysGroupUpdateName(content)
case MsgType.SYS_GROUP_UPDATE_AVATAR:
return getSysGroupUpdateAvatar(content)
case MsgType.SYS_GROUP_SET_ADMIN:
case MsgType.SYS_GROUP_CANCEL_ADMIN:
return getSysGroupChangeRoleMsgTips(lastMsg.value.msgType, content)
case MsgType.SYS_GROUP_SET_ALL_MUTED:
case MsgType.SYS_GROUP_CANCEL_ALL_MUTED:
return getSysGroupUpdateAllMuted(lastMsg.value.msgType, content)
case MsgType.SYS_GROUP_SET_JOIN_APPROVAL:
case MsgType.SYS_GROUP_CANCEL_JOIN_APPROVAL:
return getSysGroupUpdateJoinApproval(lastMsg.value.msgType, content)
case MsgType.SYS_GROUP_SET_HISTORY_BROWSE:
case MsgType.SYS_GROUP_CANCEL_HISTORY_BROWSE:
return getSysGroupUpdateHistoryBrowse(lastMsg.value.msgType, content)
case MsgType.SYS_GROUP_OWNER_TRANSFER:
return getSysGroupOwnerTransfer(content)
case MsgType.SYS_GROUP_UPDATE_MEMBER_MUTED:
return getSysGroupUpdateMemberMuted(content)
case MsgType.SYS_GROUP_LEAVE:
return getSysGroupLeave(content)
case MsgType.SYS_GROUP_DROP:
return getSysGroupDrop(content)
case MsgType.GROUP_CHAT:
return getGroupChatMsgTips(lastMsg.value.content.replace(/\{\d+\}/g, '[图片]'))
default:
return ''
}
} else {
return '...'
return lastMsg.value.content.replace(/\{\d+\}/g, '[图片]')
}
}
})
@@ -278,6 +302,7 @@ const isShowUnread = computed(() => {
sessionInfo.value.sessionType === MsgType.CHAT &&
!isShowDraft.value &&
lastMsg.value?.fromId === myAccount.value &&
(lastMsg.value.status === undefined || lastMsg.value.status === msgSendStatus.OK) &&
+sessionInfo.value?.remoteRead < +lastMsgId.value
) {
return true
@@ -291,6 +316,7 @@ const isShowRead = computed(() => {
sessionInfo.value.sessionType === MsgType.CHAT &&
!isShowDraft.value &&
lastMsg.value?.fromId === myAccount.value &&
(lastMsg.value.status === undefined || lastMsg.value.status === msgSendStatus.OK) &&
+sessionInfo.value?.remoteRead === +lastMsgId.value
) {
return true
@@ -299,6 +325,19 @@ const isShowRead = computed(() => {
}
})
const isShowUnSend = computed(() => {
if (
sessionInfo.value.sessionType === MsgType.CHAT &&
!isShowDraft.value &&
lastMsg.value?.fromId === myAccount.value &&
lastMsg.value?.status === msgSendStatus.FAILED
) {
return true
} else {
return false
}
})
const isShowUnreadCount = computed(() => {
return sessionInfo.value.unreadCount > 0
})
@@ -429,8 +468,9 @@ defineExpose({
>[{{ sessionInfo.unreadCount > 99 ? '99+' : sessionInfo.unreadCount }}]</span
>
<span v-if="isShowDraft" class="draft">[草稿]</span>
<span v-if="isShowUnread" class="unread-or-read">[未读]</span>
<span v-if="isShowRead" class="unread-or-read">[已读]</span>
<span v-else-if="isShowUnread" class="unread-or-read">[未读]</span>
<span v-else-if="isShowRead" class="unread-or-read">[已读]</span>
<span v-else-if="isShowUnSend" class="unread-or-read">[未送达]</span>
<span class="detail text-ellipsis"> {{ showDetailContent }}</span>
</div>
<div class="action">

View File

@@ -1,12 +1,12 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { Top, Bottom, MuteNotification, Bell, CircleClose, Edit } from '@element-plus/icons-vue'
import { messageStore } from '@/stores'
import { useMessageStore } from '@/stores'
const props = defineProps(['sessionId'])
const emit = defineEmits(['selectMenu', 'closeMenu'])
const messageData = messageStore()
const messageData = useMessageStore()
const top = computed(() => {
if (props.sessionId) {

View File

@@ -0,0 +1,112 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import Player from 'xgplayer'
import 'xgplayer/dist/index.min.css'
import { formatFileSize } from '@/js/utils/common'
const props = defineProps(['videoId', 'url', 'fileName', 'size'])
const emits = defineEmits(['load'])
const isLoaded = ref(false)
const isClickPlay = ref(false)
const videoWrapperRef = ref(null)
const formatSize = computed(() => {
return formatFileSize(props.size)
})
onMounted(() => {
const player = new Player({
id: `msg-xgplayer-${props.videoId}`,
url: props.url,
fluid: true,
autoplay: false,
lang: 'zh-cn',
download: true,
keyShortcut: false
})
// 监听播放开始事件
player.on('play', () => {
isClickPlay.value = true
})
// 监听播放ready事件
player.on('ready', () => {
// 监听视频元数据加载完成事件
const videoElement = player.root.querySelector('video')
videoElement.addEventListener('loadedmetadata', () => {
const videoWidth = videoElement.videoWidth
const videoHeight = videoElement.videoHeight
const maxWidth = 480
const maxHeight = 270
let newWidth = videoWidth
let newHeight = videoHeight
// 判断是横屏还是竖屏
if (videoWidth > videoHeight) {
// 横屏视频,宽度固定为 480高度按比例计算
newWidth = maxWidth
newHeight = Math.floor((maxWidth / videoWidth) * videoHeight)
} else {
// 竖屏视频,高度固定为 270宽度按比例计算
newHeight = maxHeight
newWidth = Math.floor((maxHeight / videoHeight) * videoWidth)
}
videoWrapperRef.value.style.width = `${newWidth}px`
videoWrapperRef.value.style.height = `${newHeight}px`
videoWrapperRef.value.style.padding = 0
isLoaded.value = true
emits('load') //向父组件暴露load事件
})
})
})
</script>
<template>
<div class="video-msg-wrapper">
<div v-show="isLoaded" ref="videoWrapperRef" :id="`msg-xgplayer-${props.videoId}`"></div>
<div v-if="!isClickPlay && (props.fileName || props.size > 0)" class="info">
<span class="name item text-ellipsis" :title="props.fileName">
{{ props.fileName || '' }}
</span>
<span class="size item text-ellipsis" :title="formatSize">
{{ formatSize }}
</span>
</div>
</div>
</template>
<style lang="scss" scoped>
.video-msg-wrapper {
position: relative;
.info {
width: 100%;
height: 32px;
line-height: 32px;
left: 0;
bottom: 0;
display: flex;
justify-content: space-between;
background: linear-gradient(to bottom, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.5));
position: absolute;
.item {
max-width: 40%;
color: #fff;
font-size: 12px;
}
.name {
margin-left: 8px;
}
.size {
margin-right: 8px;
}
}
}
</style>

View File

@@ -1,9 +1,9 @@
<script setup>
import { onMounted } from 'vue'
import { User, Key, Bell } from '@element-plus/icons-vue'
import { messageStore } from '@/stores'
import { useMessageStore } from '@/stores'
const messageData = messageStore()
const messageData = useMessageStore()
onMounted(async () => {
await messageData.loadSessionList()

View File

@@ -1,13 +1,13 @@
<script setup>
import { ref, onBeforeUnmount, onMounted } from 'vue'
import { userStore } from '@/stores'
import { useUserStore } from '@/stores'
import { userModifySelfService } from '@/api/user'
import { ElMessage } from 'element-plus'
defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue', 'success'])
const userData = userStore()
const userData = useUserStore()
const form = ref()
const formModel = ref({

View File

@@ -2,12 +2,12 @@
import { ref } from 'vue'
import { userModifyPassword } from '@/api/user'
import { ElMessage } from 'element-plus'
import { userStore } from '@/stores'
import { useUserStore } from '@/stores'
defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
const userData = userStore()
const userData = useUserStore()
const form = ref()
const formModel = ref({

View File

@@ -1,13 +1,13 @@
<script setup>
import { ref, onBeforeUnmount, onMounted } from 'vue'
import { userStore } from '@/stores'
import { useUserStore } from '@/stores'
import { userModifySelfService } from '@/api/user'
import { ElMessage } from 'element-plus'
defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue', 'success'])
const userData = userStore()
const userData = useUserStore()
const form = ref()
const formModel = ref({

View File

@@ -1,9 +1,9 @@
<script setup>
import { ref, onMounted } from 'vue'
import { userStore } from '@/stores'
import { useUserStore } from '@/stores'
import { userModifySelfService } from '@/api/user'
const userData = userStore()
const userData = useUserStore()
const isNewMsgTips = ref()
const isSendMsgTips = ref()

View File

@@ -1,6 +1,6 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import { userStore } from '@/stores'
import { useUserStore } from '@/stores'
import router from '@/router'
import { userModifySelfService } from '@/api/user'
import defaultImg from '@/assets/image/select_avatar.jpg'
@@ -9,9 +9,10 @@ import { maskPhoneNum, showTimeFormatDay } from '@/js/utils/common'
import EditAvatar from '@/components/common/EditAvatar.vue'
import { ElMessage } from 'element-plus'
const userData = userStore()
const userData = useUserStore()
// 准备表单数据
const formModel = ref({})
const avatarUrl = ref(userData.user.avatar)
const isLoading = ref(false)
const isShowEditAvatar = ref(false)
@@ -22,13 +23,13 @@ onMounted(async () => {
})
})
const onNewAvatar = ({ avatar, avatarThumb }) => {
formModel.value.avatar = avatar
formModel.value.avatarThumb = avatarThumb
const onNewAvatar = ({ avatarId, avatar }) => {
formModel.value.avatarId = avatarId
avatarUrl.value = avatar
}
const onSave = () => {
if (!isSomeChanged()) {
if (!isSomeOneChanged()) {
ElMessage.warning('您还没有修改任何信息哦!')
return
}
@@ -44,14 +45,13 @@ const onSave = () => {
})
}
const isSomeChanged = () => {
const isSomeOneChanged = () => {
return !(
formModel.value.nickName === userData.user.nickName &&
formModel.value.gender === userData.user.gender &&
formModel.value.birthday === showTimeFormatDay(userData.user.birthday) &&
formModel.value.signature === userData.user.signature &&
formModel.value.avatar === userData.user.avatar &&
formModel.value.avatarThumb === userData.user.avatarThumb
formModel.value.avatarId === userData.user.avatarId
)
}
@@ -70,7 +70,7 @@ const displayPhone = computed(() => {
<el-container class="el-container__body">
<el-aside width="240px">
<img
:src="formModel.avatar || defaultImg"
:src="avatarUrl || defaultImg"
alt="图片加载错误"
@click="isShowEditAvatar = true"
style="text-align: center; border-radius: 10px"

View File

@@ -1,13 +1,13 @@
<script setup>
import { ref, computed } from 'vue'
import { userStore } from '@/stores'
import { useUserStore } from '@/stores'
import { maskPhoneNum } from '@/js/utils/common'
import EditEmail from '@/views/setting/components/EditEmail.vue'
import EditPassword from '@/views/setting/components/EditPassword.vue'
import EditPhone from '@/views/setting/components/EditPhone.vue'
import { ElMessage } from 'element-plus'
const userData = userStore()
const userData = useUserStore()
const isShowEditPassword = ref(false)
const isShowEditPhone = ref(false)
const isShowEditEmail = ref(false)

View File

@@ -28,7 +28,7 @@ export default defineConfig({
},
'/oss/': {
// 获取图片的请求
target: 'https://bk0528.oss-cn-beijing.aliyuncs.com', // 对象存储oss的源
target: 'http://127.0.0.1:9001', // 对象存储oss的源
changeOrigin: true, // 修改源
rewrite: (path) => path.replace(/^\/oss\//, '')
}